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
+296
View File
@@ -0,0 +1,296 @@
/* Per-turn file snapshots under <ws>/.agent/undo/<session>/<tag>/ so the
* file changes of the latest turn can be rolled back (/undo). */
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#ifndef _WIN32
#include <dirent.h>
#include <unistd.h>
#endif
#include "cst_agent.h"
#include "cst_undo.h"
#include "cst_util.h"
#include "os_compat.h"
#define UNDO_FILE_MAX (8 * 1024 * 1024)
static int undo_root(const char *ws, const char *sid, char *out, size_t n) {
int k = snprintf(out, n, "%s/.agent/undo/%s", ws, sid && sid[0] ? sid : "_");
return k < 0 || (size_t)k >= n ? -1 : 0;
}
/* Workspace-relative, no "..", not absolute: undo may delete these paths. */
static int rel_ok(const char *rel) {
if (!rel || !rel[0] || rel[0] == '/' || rel[0] == '\\' ||
(rel[0] && rel[1] == ':'))
return 0;
const char *p = rel;
while (*p) {
size_t n = strcspn(p, "/\\");
if (n == 2 && p[0] == '.' && p[1] == '.')
return 0;
p += n;
while (*p == '/' || *p == '\\')
p++;
}
return strchr(rel, '\n') == NULL && strchr(rel, '\t') == NULL;
}
static char *slurp(const char *p, size_t *len, size_t max) {
*len = 0;
FILE *fp = fopen(p, "rb");
if (!fp)
return NULL;
fseek(fp, 0, SEEK_END);
long sz = ftell(fp);
if (sz < 0 || (size_t)sz > max) {
fclose(fp);
return NULL;
}
rewind(fp);
char *b = malloc((size_t)sz + 1);
if (!b) {
fclose(fp);
return NULL;
}
*len = fread(b, 1, (size_t)sz, fp);
b[*len] = 0;
fclose(fp);
return b;
}
static int put(const char *p, const char *d, size_t n) {
FILE *fp = fopen(p, "wb");
if (!fp)
return -1;
int ok = n == 0 || fwrite(d, 1, n, fp) == n;
return fclose(fp) == 0 && ok ? 0 : -1;
}
static int manifest_has(const char *mf, const char *rel) {
FILE *fp = fopen(mf, "r");
if (!fp)
return 0;
char line[1400];
int found = 0;
while (!found && fgets(line, sizeof(line), fp)) {
char *t2 = strchr(line, '\t');
char *t3 = t2 ? strchr(t2 + 1, '\t') : NULL;
if (!t3)
continue;
t3++;
size_t n = strlen(t3);
if (n && t3[n - 1] == '\n')
t3[--n] = 0;
found = strcmp(t3, rel) == 0;
}
fclose(fp);
return found;
}
int cst_undo_snapshot(const char *ws, const char *sid, const char *tag,
const char *rel) {
if (!ws || !tag || !rel_ok(rel))
return -1;
char full[OS_PATH_MAX];
if (cst_sandbox_resolve(ws, rel, full, sizeof(full)) != 0) {
/* parent missing: the file is new; record it as absent */
snprintf(full, sizeof(full), "%s", "");
}
char dir[OS_PATH_MAX], mf[OS_PATH_MAX];
if (undo_root(ws, sid, dir, sizeof(dir)) != 0)
return -1;
size_t dl = strlen(dir);
int k = snprintf(dir + dl, sizeof(dir) - dl, "/%s", tag);
if (k < 0 || (size_t)k >= sizeof(dir) - dl)
return -1;
if (os_mkdir_p(dir, 0755) != 0)
return -1;
k = snprintf(mf, sizeof(mf), "%s/MANIFEST", dir);
if (k < 0 || (size_t)k >= sizeof(mf))
return -1;
if (manifest_has(mf, rel))
return 0;
int idx = 0;
{
FILE *fp = fopen(mf, "r");
if (fp) {
int ch;
while ((ch = fgetc(fp)) != EOF)
idx += ch == '\n';
fclose(fp);
}
}
const char *state = "absent";
struct stat st;
if (full[0] && stat(full, &st) == 0 && S_ISREG(st.st_mode)) {
size_t n = 0;
char *data = slurp(full, &n, UNDO_FILE_MAX);
if (!data) {
state = "toolarge";
} else {
char bak[OS_PATH_MAX + 16];
int bl = snprintf(bak, sizeof(bak), "%s/%d.bak", dir, idx);
state = bl > 0 && (size_t)bl < sizeof(bak) && put(bak, data, n) == 0
? "file"
: "toolarge";
free(data);
}
}
FILE *fp = fopen(mf, "a");
if (!fp)
return -1;
fprintf(fp, "%d\t%s\t%s\n", idx, state, rel);
fclose(fp);
return 0;
}
#ifndef _WIN32
static int latest_tag(const char *root, char *tag, size_t n) {
DIR *d = opendir(root);
if (!d)
return -1;
struct dirent *de;
tag[0] = 0;
while ((de = readdir(d)) != NULL) {
if (de->d_name[0] == '.')
continue;
if (strcmp(de->d_name, tag) > 0)
snprintf(tag, n, "%s", de->d_name);
}
closedir(d);
return tag[0] ? 0 : -1;
}
static void rm_dir(const char *dir) {
DIR *d = opendir(dir);
if (d) {
struct dirent *de;
while ((de = readdir(d)) != NULL) {
if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0)
continue;
char p[OS_PATH_MAX];
if (snprintf(p, sizeof(p), "%s/%s", dir, de->d_name) < (int)sizeof(p))
unlink(p);
}
closedir(d);
}
rmdir(dir);
}
int cst_undo_count(const char *ws, const char *sid) {
char root[OS_PATH_MAX];
if (undo_root(ws, sid, root, sizeof(root)) != 0)
return 0;
DIR *d = opendir(root);
if (!d)
return 0;
int n = 0;
struct dirent *de;
while ((de = readdir(d)) != NULL)
n += de->d_name[0] != '.';
closedir(d);
return n;
}
int cst_undo_restore_last(const char *ws, const char *sid, char *report,
size_t report_sz) {
report[0] = 0;
char root[OS_PATH_MAX], tag[256], dir[OS_PATH_MAX], mf[OS_PATH_MAX];
if (undo_root(ws, sid, root, sizeof(root)) != 0 ||
latest_tag(root, tag, sizeof(tag)) != 0) {
snprintf(report, report_sz, "nothing to undo");
return -1;
}
if (snprintf(dir, sizeof(dir), "%s/%s", root, tag) >= (int)sizeof(dir) ||
snprintf(mf, sizeof(mf), "%s/MANIFEST", dir) >= (int)sizeof(mf)) {
snprintf(report, report_sz, "undo path too long");
return -1;
}
FILE *fp = fopen(mf, "r");
cst_buf_t rep = {0};
int restored = 0, failed = 0;
if (fp) {
char line[1400];
while (fgets(line, sizeof(line), fp)) {
size_t n = strlen(line);
if (n && line[n - 1] == '\n')
line[--n] = 0;
char *t2 = strchr(line, '\t');
char *t3 = t2 ? strchr(t2 + 1, '\t') : NULL;
if (!t3)
continue;
*t2++ = 0;
*t3++ = 0;
const char *rel = t3, *state = t2;
char full[OS_PATH_MAX];
struct stat lst;
if (!rel_ok(rel) || os_path_join(full, sizeof(full), ws, rel) != 0)
continue;
if (lstat(full, &lst) == 0 && S_ISLNK(lst.st_mode)) {
cst_buf_printf(&rep, "skipped %s (now a symlink)\n", rel);
failed++;
continue;
}
if (strcmp(state, "file") == 0) {
char bak[OS_PATH_MAX];
if (snprintf(bak, sizeof(bak), "%s/%s.bak", dir, line) >=
(int)sizeof(bak))
bak[0] = 0;
size_t len = 0;
char *data = slurp(bak, &len, UNDO_FILE_MAX);
char parent[OS_PATH_MAX];
snprintf(parent, sizeof(parent), "%s", full);
char *sl = strrchr(parent, '/');
if (sl) {
*sl = 0;
os_mkdir_p(parent, 0755);
}
if (data && put(full, data, len) == 0) {
cst_buf_printf(&rep, "restored %s\n", rel);
restored++;
} else {
cst_buf_printf(&rep, "FAILED to restore %s\n", rel);
failed++;
}
free(data);
} else if (strcmp(state, "absent") == 0) {
if (unlink(full) == 0) {
cst_buf_printf(&rep, "removed %s (did not exist before)\n", rel);
restored++;
}
} else {
cst_buf_printf(&rep, "skipped %s (too large to snapshot)\n", rel);
failed++;
}
}
fclose(fp);
}
rm_dir(dir);
if (!rep.buf)
cst_buf_puts(&rep, "nothing changed\n");
snprintf(report, report_sz, "%s", rep.buf);
cst_buf_free(&rep);
return failed && !restored ? -1 : 0;
}
#else
int cst_undo_count(const char *ws, const char *sid) {
(void)ws;
(void)sid;
return 0;
}
int cst_undo_restore_last(const char *ws, const char *sid, char *report,
size_t report_sz) {
(void)ws;
(void)sid;
snprintf(report, report_sz, "undo is not available on Windows");
return -1;
}
#endif