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
+219 -35
View File
@@ -1,8 +1,14 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#ifdef __APPLE__
#include <mach-o/dyld.h>
#include <stdint.h>
#endif
#include "os_compat.h"
#include <errno.h>
@@ -21,9 +27,12 @@
#include <windows.h>
#else
#include <fcntl.h>
#include <poll.h>
#include <signal.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#endif
@@ -146,6 +155,22 @@ int os_mkdir(const char *path, int mode) {
#endif
}
int os_mkdir_p(const char *path, int mode) {
if (!path || !path[0])
return -1;
char tmp[OS_PATH_MAX];
snprintf(tmp, sizeof(tmp), "%s", path);
for (char *p = tmp + 1; *p; p++) {
if (*p == '/' || *p == '\\') {
char c = *p;
*p = 0;
os_mkdir(tmp, mode);
*p = c;
}
}
return os_mkdir(tmp, mode);
}
int os_config_dir(char *out, size_t out_sz) {
if (!out || out_sz < 8)
return -1;
@@ -157,10 +182,15 @@ int os_config_dir(char *out, size_t out_sz) {
return -1;
int n = snprintf(out, out_sz, "%s\\cst-agent", base);
#else
const char *xdg = getenv("XDG_CONFIG_HOME");
const char *home = getenv("HOME");
if (!home || !home[0])
int n;
if (xdg && xdg[0] == '/')
n = snprintf(out, out_sz, "%s/cst-agent", xdg);
else if (home && home[0])
n = snprintf(out, out_sz, "%s/.config/cst-agent", home);
else
return -1;
int n = snprintf(out, out_sz, "%s/.config/cst-agent", home);
#endif
if (n < 0 || (size_t)n >= out_sz)
return -1;
@@ -258,7 +288,12 @@ int os_exe_dir(char *out, size_t out_sz) {
return 0;
#else
char buf[OS_PATH_MAX];
#ifdef __APPLE__
uint32_t asz = (uint32_t)sizeof(buf);
ssize_t n = _NSGetExecutablePath(buf, &asz) == 0 ? (ssize_t)strlen(buf) : -1;
#else
ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1);
#endif
if (n >= 0) {
buf[n] = 0;
char *slash = strrchr(buf, '/');
@@ -343,6 +378,77 @@ int os_resolve_shell(char *out, size_t out_sz) {
#endif
}
static volatile int *g_run_cancel;
static int g_run_timeout_s;
void os_set_cancel_flag(volatile int *flag) { g_run_cancel = flag; }
void os_set_run_timeout(int seconds) { g_run_timeout_s = seconds; }
static int run_timeout_s(void) {
if (g_run_timeout_s > 0)
return g_run_timeout_s;
const char *e = getenv("CST_SHELL_TIMEOUT");
if (e && atoi(e) > 0)
return atoi(e);
return 120;
}
double os_now_ms(void) {
#ifdef _WIN32
static LARGE_INTEGER freq;
LARGE_INTEGER now;
if (!freq.QuadPart)
QueryPerformanceFrequency(&freq);
QueryPerformanceCounter(&now);
return (double)now.QuadPart * 1000.0 / (double)freq.QuadPart;
#else
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1e6;
#endif
}
/* Append to capture buffer, keeping at most max_out bytes. */
static int cap_append(char **buf, size_t *len, size_t *cap, size_t max_out,
const char *data, size_t n, int *truncated) {
if (*len >= max_out) {
*truncated = 1;
return 0;
}
if (*len + n > max_out) {
n = max_out - *len;
*truncated = 1;
}
if (*len + n + 1 > *cap) {
size_t nc = *cap ? *cap * 2 : 4096;
while (nc < *len + n + 1)
nc *= 2;
char *nb = realloc(*buf, nc);
if (!nb)
return -1;
*buf = nb;
*cap = nc;
}
memcpy(*buf + *len, data, n);
*len += n;
(*buf)[*len] = 0;
return 0;
}
static void cap_note(char **buf, size_t *len, size_t *cap, const char *note) {
size_t n = strlen(note);
if (*len + n + 1 > *cap) {
char *nb = realloc(*buf, *len + n + 1);
if (!nb)
return;
*buf = nb;
*cap = *len + n + 1;
}
memcpy(*buf + *len, note, n + 1);
*len += n;
}
#ifdef _WIN32
int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
os_run_result_t *r) {
@@ -396,7 +502,9 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = wr;
si.hStdError = wr;
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
HANDLE nul = CreateFileA("NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
&sa, OPEN_EXISTING, 0, NULL);
si.hStdInput = nul != INVALID_HANDLE_VALUE ? nul : GetStdHandle(STD_INPUT_HANDLE);
PROCESS_INFORMATION pi = {0};
char *cmd_mut = _strdup(cmdline);
@@ -409,6 +517,8 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
cwd && cwd[0] ? cwd : NULL, &si, &pi);
free(cmd_mut);
CloseHandle(wr);
if (nul != INVALID_HANDLE_VALUE)
CloseHandle(nul);
if (!ok) {
CloseHandle(rd);
return -1;
@@ -422,30 +532,54 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
CloseHandle(pi.hThread);
return -1;
}
buf[0] = 0;
int truncated = 0, killed = 0;
double deadline = os_now_ms() + run_timeout_s() * 1000.0;
for (;;) {
char tmp[1024];
DWORD n = 0;
if (!ReadFile(rd, tmp, sizeof(tmp), &n, NULL) || n == 0)
break;
if (len + n + 1 > cap) {
size_t nc = cap * 2;
if (nc < len + n + 1)
nc = len + n + 1;
char *nb = realloc(buf, nc);
if (!nb)
DWORD avail = 0;
if (!PeekNamedPipe(rd, NULL, 0, NULL, &avail, NULL))
break; /* writer closed */
if (avail > 0) {
char tmp[4096];
DWORD n = 0;
if (!ReadFile(rd, tmp, sizeof(tmp), &n, NULL) || n == 0)
break;
buf = nb;
cap = nc;
cap_append(&buf, &len, &cap, max_out, tmp, n, &truncated);
continue;
}
memcpy(buf + len, tmp, n);
len += n;
if (len >= max_out)
if (WaitForSingleObject(pi.hProcess, 50) == WAIT_OBJECT_0) {
/* drain what is left */
while (PeekNamedPipe(rd, NULL, 0, NULL, &avail, NULL) && avail > 0) {
char tmp[4096];
DWORD n = 0;
if (!ReadFile(rd, tmp, sizeof(tmp), &n, NULL) || n == 0)
break;
cap_append(&buf, &len, &cap, max_out, tmp, n, &truncated);
}
break;
}
if ((g_run_cancel && *g_run_cancel) || os_now_ms() > deadline) {
killed = (g_run_cancel && *g_run_cancel) ? 2 : 1;
TerminateProcess(pi.hProcess, killed == 1 ? 124 : 130);
break;
}
}
CloseHandle(rd);
WaitForSingleObject(pi.hProcess, INFINITE);
DWORD code = 1;
GetExitCodeProcess(pi.hProcess, &code);
if (truncated)
cap_note(&buf, &len, &cap, "\n...(output truncated)\n");
if (killed == 1) {
char note[96];
snprintf(note, sizeof(note), "\n[cst: killed after %ds timeout]\n",
run_timeout_s());
cap_note(&buf, &len, &cap, note);
code = 124;
} else if (killed == 2) {
cap_note(&buf, &len, &cap, "\n[cst: cancelled]\n");
code = 130;
}
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
buf[len] = 0;
@@ -473,7 +607,13 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
return -1;
}
if (pid == 0) {
setpgid(0, 0);
close(pipefd[0]);
int devnull = open("/dev/null", O_RDONLY);
if (devnull >= 0) {
dup2(devnull, STDIN_FILENO);
close(devnull);
}
dup2(pipefd[1], STDOUT_FILENO);
dup2(pipefd[1], STDERR_FILENO);
close(pipefd[1]);
@@ -482,41 +622,85 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
execvp(argv[0], (char *const *)argv);
_exit(127);
}
setpgid(pid, pid); /* also in parent: avoid racing the child */
close(pipefd[1]);
size_t cap = 4096, len = 0;
char *buf = malloc(cap);
if (!buf) {
close(pipefd[0]);
kill(-pid, SIGKILL);
waitpid(pid, NULL, 0);
return -1;
}
buf[0] = 0;
int truncated = 0, killed = 0;
int status = 0, reaped = 0, bg_killed = 0;
double reaped_at = 0, killed_at = 0;
double deadline = os_now_ms() + run_timeout_s() * 1000.0;
for (;;) {
if (len + 1024 > cap) {
cap *= 2;
char *nb = realloc(buf, cap);
if (!nb)
struct pollfd pfd = {pipefd[0], POLLIN, 0};
int pr = poll(&pfd, 1, 100);
if (pr < 0 && errno != EINTR)
break;
if (pr > 0) {
char tmp[4096];
ssize_t n = read(pipefd[0], tmp, sizeof(tmp));
if (n == 0)
break; /* every writer closed */
if (n > 0)
cap_append(&buf, &len, &cap, max_out, tmp, (size_t)n, &truncated);
else if (errno != EINTR && errno != EAGAIN)
break;
buf = nb;
}
ssize_t n = read(pipefd[0], buf + len, 1024);
if (n < 0) {
if (errno == EINTR)
continue;
break;
double now = os_now_ms();
if (!reaped && waitpid(pid, &status, WNOHANG) == pid) {
reaped = 1;
reaped_at = now;
}
if (n == 0)
break;
len += (size_t)n;
if (len >= max_out)
/* The shell exited but a background child still holds the pipe
* (`server &`): give it a moment, then end the group. */
if (reaped && !killed && !bg_killed && now - reaped_at > 1000) {
kill(-pid, SIGKILL);
bg_killed = 1;
killed_at = now;
}
/* Checked on every pass, so a chatty command still stops promptly. */
if (!reaped && !killed &&
((g_run_cancel && *g_run_cancel) || now > deadline)) {
killed = (g_run_cancel && *g_run_cancel) ? 2 : 1;
killed_at = now;
kill(-pid, SIGTERM);
}
if (killed && !reaped && now - killed_at > 1000)
kill(-pid, SIGKILL);
/* a writer that left the process group can keep the pipe open */
if ((killed || bg_killed) && now - killed_at > 3000)
break;
}
close(pipefd[0]);
int status = 0;
waitpid(pid, &status, 0);
buf[len] = 0;
if (pid > 0 && !reaped) {
if (killed)
kill(-pid, SIGKILL);
waitpid(pid, &status, 0);
}
if (truncated)
cap_note(&buf, &len, &cap, "\n...(output truncated)\n");
if (bg_killed)
cap_note(&buf, &len, &cap,
"\n[cst: background processes left running were stopped]\n");
r->out = buf;
r->out_len = len;
r->exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 1;
if (killed == 1) {
char note[96];
snprintf(note, sizeof(note), "\n[cst: killed after %ds timeout]\n",
run_timeout_s());
cap_note(&r->out, &r->out_len, &cap, note);
r->exit_code = 124;
} else if (killed == 2) {
cap_note(&r->out, &r->out_len, &cap, "\n[cst: cancelled]\n");
r->exit_code = 130;
}
return 0;
}
#endif