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
+94
View File
@@ -0,0 +1,94 @@
#define _POSIX_C_SOURCE 200809L
#include "cst_agent.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
/* run_shell must never hang the agent: timeout kills the whole process
* group, stdin is empty, huge output is capped. */
static int nfail;
#define EXPECT(cond, msg) \
do { \
if (!(cond)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
nfail++; \
} \
} while (0)
static char g_ws[256];
static int sh(const char *cmd, cst_tool_result_t *r, double *ms) {
ctl_tool_call_t c;
memset(&c, 0, sizeof(c));
snprintf(c.id, sizeof(c.id), "s");
snprintf(c.name, sizeof(c.name), "run_shell");
snprintf(c.args[0].key, sizeof(c.args[0].key), "cmd");
c.args[0].value = (char *)cmd;
c.args[0].value_len = strlen(cmd);
c.nargs = 1;
memset(r, 0, sizeof(*r));
double t0 = os_now_ms();
cst_tool_dispatch(g_ws, &c, r);
*ms = os_now_ms() - t0;
return r->exit_code;
}
int main(void) {
snprintf(g_ws, sizeof(g_ws), "/tmp/cst-shell-test-%d", (int)getpid());
mkdir(g_ws, 0755);
cst_tool_result_t r;
double ms;
EXPECT(sh("echo hi; exit 3", &r, &ms) == 3, "exit code passed through");
EXPECT(r.out && strstr(r.out, "hi"), "stdout captured");
cst_tool_result_free(&r);
/* stdin is /dev/null: a command reading stdin returns at once */
sh("cat; echo done", &r, &ms);
EXPECT(r.out && strstr(r.out, "done") && ms < 5000, "stdin is empty");
cst_tool_result_free(&r);
/* timeout kills the process group, including background children that
* keep the output pipe open */
setenv("CST_SHELL_TIMEOUT", "1", 1);
os_set_run_timeout(1);
int ec = sh("sleep 30 & sleep 30; echo never", &r, &ms);
EXPECT(ec == 124, "timeout exit code 124");
EXPECT(ms < 8000, "timeout returns promptly");
EXPECT(!r.out || !strstr(r.out, "never"), "command was stopped");
cst_tool_result_free(&r);
os_set_run_timeout(0);
unsetenv("CST_SHELL_TIMEOUT");
/* the shell exits but leaves a background child holding the pipe */
ec = sh("sleep 30 & echo started", &r, &ms);
EXPECT(ec == 0, "shell exit code kept when a background child lingers");
EXPECT(r.out && strstr(r.out, "started"), "foreground output kept");
EXPECT(ms < 5000, "lingering background child does not block");
cst_tool_result_free(&r);
/* huge output: capped and marked, the child is not blocked on the pipe */
sh("head -c 5000000 /dev/zero | tr '\\0' x; echo; echo tail-marker", &r, &ms);
EXPECT(r.out && r.out_len < 2000000, "output capped");
EXPECT(r.out && strstr(r.out, "truncated"), "truncation noted");
EXPECT(ms < 20000, "large output does not hang");
cst_tool_result_free(&r);
char cmd[300];
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_ws);
if (system(cmd) != 0)
fprintf(stderr, "warning: cleanup failed\n");
if (nfail) {
fprintf(stderr, "test_shell: %d failed\n", nfail);
return 1;
}
printf("test_shell: OK\n");
return 0;
}