Usability pass: tool registry, TUI polish, saved keys and settings

Tools
- Table-driven tool registry with permission classes and default/minimal
  profiles; new tools: edit_file, list_dir, glob, grep, delete_file,
  move_file, todo_write, web_fetch, ask_user (plus read_file paging).
- Built-in Myers diff; write tools return unified diffs.
- Persistent permission rules (.agent/permissions.json), per-turn undo
  snapshots (.agent/undo), AGENTS.md project notes, auto-compact at 80%
  of the model context, cancellable run_shell with process-group kill.

TUI
- Table-driven slash commands: /provider /key /effort /status /tools
  /permissions /yolo /undo /retry /diff /export /copy /rename /delete
  /init /max-steps /todo, with / and @ completion.
- Permission modal with diff and prefix rules, ask_user modal, todo
  panel, header with model/effort/context, bracketed paste, line editing,
  wide-char cursor, NO_COLOR and ASCII fallback; Esc never quits.

Startup and configuration
- API keys are entered in the TUI (hidden) and saved per provider in
  <config>/credentials (0600); env variables are only a fallback.
- Last provider, per-provider model and reasoning effort are remembered
  in <config>/settings.json; --effort/-e and /effort send
  reasoning_effort (low|medium|high|max) to models that accept it.
- DeepSeek preset uses deepseek-flash / deepseek-v4-pro.
- Session flags --new/--continue/--resume/--list-sessions, --yolo,
  --confirm.

Compatibility and tests
- ncurses detection, macOS/Homebrew support, make install, XDG config
  dir, POSIX cleanups; Gitea CI (gcc/clang/asan/musl/macOS).
- Unit tests for diff, fs tools, registry, undo/perms and config;
  e2e fixtures multi_file_refactor and ask_or_todo; vendored cJSON.
This commit is contained in:
WHC
2026-10-05 01:47:01 +08:00
parent 9877ff48b6
commit 2fde898da0
105 changed files with 19348 additions and 3786 deletions
+295
View File
@@ -0,0 +1,295 @@
#define _POSIX_C_SOURCE 200809L
#include "cJSON.h"
#include "ctl.h"
#include "cst_agent.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/*
* Truncation experiment on recorded model streams (tests/fixtures/sse).
*
* For every recorded assistant message we rebuild the logical stream the
* runtime sees and cut it at every byte:
* - CTL traces: the content text; executable calls = CALL_READY events.
* - native traces: tool calls as a JSON array of {"name","arguments"};
* json-whole : execute only when the whole array parses (EOS baseline)
* json-object: execute each object once it parses (incremental JSON)
*
* Checked properties (the CST soundness claims):
* S1 no call is executable on a prefix unless its bytes are complete;
* S2 every call executed on a prefix is identical to the same call in the
* full stream (prefix consistency, i.e. execution is never revoked);
* S3 the number of executable calls is monotone in the prefix length.
* The printed table reports how early calls become executable (experiment C).
*/
static int nfail;
#define EXPECT(cond, msg) \
do { \
if (!(cond)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
nfail++; \
} \
} while (0)
typedef struct {
char *buf;
size_t len, cap;
} sbuf_t;
static void sb_add(sbuf_t *b, const char *s, size_t n) {
if (b->len + n + 1 > b->cap) {
size_t nc = b->cap ? b->cap * 2 : 1024;
while (nc < b->len + n + 1)
nc *= 2;
b->buf = realloc(b->buf, nc);
b->cap = nc;
}
memcpy(b->buf + b->len, s, n);
b->len += n;
b->buf[b->len] = 0;
}
#define MAXC 32
typedef struct {
sbuf_t content;
int ncalls;
char names[MAXC][64];
sbuf_t args[MAXC];
} trace_t;
static void trace_free(trace_t *t) {
free(t->content.buf);
for (int i = 0; i < MAXC; i++)
free(t->args[i].buf);
memset(t, 0, sizeof(*t));
}
static int load_trace(const char *path, trace_t *t) {
memset(t, 0, sizeof(*t));
FILE *fp = fopen(path, "rb");
if (!fp)
return -1;
char *line = NULL;
size_t cap = 0;
ssize_t n;
while ((n = getline(&line, &cap, fp)) > 0) {
if (strncmp(line, "data:", 5) != 0)
continue;
cJSON *j = cJSON_Parse(line + 5);
if (!j)
continue;
const cJSON *ch = cJSON_GetArrayItem(cJSON_GetObjectItem(j, "choices"), 0);
const cJSON *d = cJSON_GetObjectItem(ch, "delta");
const cJSON *c = cJSON_GetObjectItem(d, "content");
if (cJSON_IsString(c))
sb_add(&t->content, c->valuestring, strlen(c->valuestring));
const cJSON *tc = NULL;
cJSON_ArrayForEach(tc, cJSON_GetObjectItem(d, "tool_calls")) {
const cJSON *ji = cJSON_GetObjectItem(tc, "index");
int idx = cJSON_IsNumber(ji) ? ji->valueint : 0;
if (idx < 0 || idx >= MAXC)
continue;
if (idx >= t->ncalls)
t->ncalls = idx + 1;
const cJSON *fn = cJSON_GetObjectItem(tc, "function");
const cJSON *nm = cJSON_GetObjectItem(fn, "name");
const cJSON *ar = cJSON_GetObjectItem(fn, "arguments");
if (cJSON_IsString(nm) && nm->valuestring[0])
snprintf(t->names[idx], sizeof(t->names[idx]), "%s", nm->valuestring);
if (cJSON_IsString(ar))
sb_add(&t->args[idx], ar->valuestring, strlen(ar->valuestring));
}
cJSON_Delete(j);
}
free(line);
fclose(fp);
return 0;
}
/* --- CTL side ------------------------------------------------------------- */
typedef struct {
int n;
char sig[MAXC][512]; /* id|name|k=v... (truncated) as identity */
} ready_t;
static void on_ctl(const ctl_event_t *ev, void *ud) {
ready_t *r = ud;
if (ev->type != CTL_EV_CALL_READY || r->n >= MAXC)
return;
char *s = r->sig[r->n++];
int o = snprintf(s, 512, "%s|%s", ev->call->id, ev->call->name);
for (int i = 0; i < ev->call->nargs && o < 500; i++)
o += snprintf(s + o, (size_t)(512 - o), "|%s=%.*s", ev->call->args[i].key,
(int)(ev->call->args[i].value_len > 200
? 200
: ev->call->args[i].value_len),
ev->call->args[i].value);
}
static void ctl_ready(const char *s, size_t n, int finish, ready_t *r) {
memset(r, 0, sizeof(*r));
ctl_parser_t *p = ctl_parser_create(on_ctl, r);
ctl_parser_feed(p, s, n);
if (finish)
ctl_parser_finish(p);
ctl_parser_destroy(p);
}
/* --- JSON side ------------------------------------------------------------ */
static void build_json_stream(const trace_t *t, sbuf_t *out) {
sb_add(out, "[", 1);
for (int i = 0; i < t->ncalls; i++) {
char head[160];
int hn = snprintf(head, sizeof(head), "%s{\"name\":\"%s\",\"arguments\":",
i ? "," : "", t->names[i]);
sb_add(out, head, (size_t)hn);
if (t->args[i].len)
sb_add(out, t->args[i].buf, t->args[i].len);
else
sb_add(out, "{}", 2);
sb_add(out, "}", 1);
}
sb_add(out, "]", 1);
}
static int json_whole(const char *s, size_t n) {
cJSON *j = cJSON_ParseWithLength(s, n);
int k = cJSON_IsArray(j) ? cJSON_GetArraySize(j) : 0;
cJSON_Delete(j);
return k;
}
static int json_objects(const char *s, size_t n) {
int k = 0;
size_t off = 0;
while (off < n) {
cst_json_tool_t t;
const char *p = memchr(s + off, '{', n - off);
if (!p)
break;
if (!cst_json_try_parse_tool(p, n - (size_t)(p - s), &t))
break;
k++;
/* skip past this object */
const char *end = NULL;
cJSON *j = cJSON_ParseWithLengthOpts(p, n - (size_t)(p - s), &end, 0);
cJSON_Delete(j);
if (!end || end <= p)
break;
off = (size_t)(end - s);
}
return k;
}
/* --- experiment ----------------------------------------------------------- */
static void report_row(const char *label, size_t n, const int *at_pct,
int total) {
printf(" %-26s bytes=%-6zu calls=%d executable@25/50/75/90/100%%: "
"%d/%d/%d/%d/%d\n",
label, n, total, at_pct[0], at_pct[1], at_pct[2], at_pct[3],
at_pct[4]);
}
static const int PCTS[5] = {25, 50, 75, 90, 100};
static void run_ctl(const char *label, const sbuf_t *c) {
ready_t full;
ctl_ready(c->buf, c->len, 1, &full);
int prev = 0, at[5] = {0};
size_t first_ready_at = 0;
for (size_t cut = 0; cut <= c->len; cut++) {
ready_t r;
ctl_ready(c->buf, cut, cut == c->len, &r);
EXPECT(r.n >= prev, "S3 monotone");
EXPECT(r.n <= full.n, "S1 never more than the full stream");
for (int i = 0; i < r.n && i < full.n; i++)
EXPECT(strcmp(r.sig[i], full.sig[i]) == 0, "S2 prefix-consistent call");
if (r.n > 0 && prev == 0)
first_ready_at = cut;
prev = r.n;
for (int k = 0; k < 5; k++)
if (cut == c->len * (size_t)PCTS[k] / 100)
at[k] = r.n;
}
report_row(label, c->len, at, full.n);
if (full.n > 0)
printf(" %-26s first call executable at byte %zu (%.0f%% of message)\n",
"", first_ready_at, 100.0 * (double)first_ready_at / (double)c->len);
}
static void run_native(const char *label, const trace_t *t) {
sbuf_t js = {0};
build_json_stream(t, &js);
int whole_full = json_whole(js.buf, js.len);
int obj_full = json_objects(js.buf, js.len);
EXPECT(whole_full == t->ncalls, "json-whole parses at full length");
EXPECT(obj_full == t->ncalls, "json-object parses all at full length");
int at_w[5] = {0}, at_o[5] = {0}, prev_o = 0;
for (size_t cut = 0; cut <= js.len; cut++) {
int w = json_whole(js.buf, cut);
int o = json_objects(js.buf, cut);
if (cut < js.len)
EXPECT(w == 0, "json-whole executes nothing before the end");
EXPECT(o >= prev_o, "S3 monotone (json-object)");
prev_o = o;
for (int k = 0; k < 5; k++) {
if (cut == js.len * (size_t)PCTS[k] / 100) {
at_w[k] = w;
at_o[k] = o;
}
}
}
char l[80];
snprintf(l, sizeof(l), "%s json-whole", label);
report_row(l, js.len, at_w, whole_full);
snprintf(l, sizeof(l), "%s json-object", label);
report_row(l, js.len, at_o, obj_full);
free(js.buf);
}
int main(void) {
static const char *dirs[] = {"tests/fixtures/sse/deepseek-ctl-heredoc",
"tests/fixtures/sse/deepseek-ctl",
"tests/fixtures/sse/deepseek-native-eos"};
int traces = 0;
for (size_t d = 0; d < sizeof(dirs) / sizeof(dirs[0]); d++) {
printf("%s\n", dirs[d]);
for (int seq = 0; seq < 64; seq++) {
char path[512];
snprintf(path, sizeof(path), "%s/%04d.sse", dirs[d], seq);
if (access(path, R_OK) != 0)
break;
trace_t t;
if (load_trace(path, &t) != 0)
continue;
traces++;
char label[64];
snprintf(label, sizeof(label), "#%04d", seq);
if (t.ncalls > 0)
run_native(label, &t);
else if (t.content.len > 0 && strstr(t.content.buf, "call "))
run_ctl(label, &t.content);
else
printf(" %-26s (final answer, no calls)\n", label);
trace_free(&t);
}
}
if (traces == 0)
printf("(no recorded traces found; run from the repo root)\n");
if (nfail) {
fprintf(stderr, "test_replay_real: %d failed\n", nfail);
return 1;
}
printf("test_replay_real: OK\n");
return 0;
}