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
+285
View File
@@ -0,0 +1,285 @@
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <curl/curl.h>
#include "cst_tools.h"
#include "cst_util.h"
#include "tools_internal.h"
#define FETCH_DOWNLOAD_MAX (5 * 1024 * 1024)
#define FETCH_DEFAULT_OUT (64 * 1024)
#define FETCH_MAX_OUT (1024 * 1024)
typedef struct {
cst_buf_t body;
int overflow;
} fetch_t;
static size_t on_body(char *p, size_t sz, size_t n, void *ud) {
fetch_t *f = ud;
size_t len = sz * n;
if (f->body.len + len > FETCH_DOWNLOAD_MAX) {
f->overflow = 1;
return 0; /* abort */
}
cst_buf_append(&f->body, p, len);
return len;
}
static int tag_is(const char *p, const char *name) {
size_t n = strlen(name);
if (strncasecmp(p, name, n) != 0)
return 0;
char c = p[n];
return c == '>' || c == ' ' || c == '/' || c == '\t' || c == '\n' ||
c == '\r';
}
static const char *const BLOCK_TAGS[] = {
"p", "br", "div", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6",
"section", "article", "header", "footer", "pre", "table", "ul", "ol",
"blockquote", "hr"};
static void put_entity(cst_buf_t *b, const char **pp, const char *end) {
static const struct {
const char *name;
const char *rep;
} ents[] = {{"&amp;", "&"}, {"&lt;", "<"}, {"&gt;", ">"},
{"&quot;", "\""}, {"&#39;", "'"}, {"&apos;", "'"},
{"&nbsp;", " "}};
const char *p = *pp;
for (size_t i = 0; i < sizeof(ents) / sizeof(ents[0]); i++) {
size_t n = strlen(ents[i].name);
if ((size_t)(end - p) >= n && strncmp(p, ents[i].name, n) == 0) {
cst_buf_puts(b, ents[i].rep);
*pp = p + n;
return;
}
}
if (end - p > 3 && p[1] == '#') {
char *e = NULL;
long cp = p[2] == 'x' || p[2] == 'X' ? strtol(p + 3, &e, 16)
: strtol(p + 2, &e, 10);
if (e && *e == ';' && cp > 0 && cp < 0x110000) {
char u[4];
int k = 0;
if (cp < 0x80) {
u[k++] = (char)cp;
} else if (cp < 0x800) {
u[k++] = (char)(0xC0 | (cp >> 6));
u[k++] = (char)(0x80 | (cp & 0x3F));
} else if (cp < 0x10000) {
u[k++] = (char)(0xE0 | (cp >> 12));
u[k++] = (char)(0x80 | ((cp >> 6) & 0x3F));
u[k++] = (char)(0x80 | (cp & 0x3F));
} else {
u[k++] = (char)(0xF0 | (cp >> 18));
u[k++] = (char)(0x80 | ((cp >> 12) & 0x3F));
u[k++] = (char)(0x80 | ((cp >> 6) & 0x3F));
u[k++] = (char)(0x80 | (cp & 0x3F));
}
cst_buf_append(b, u, (size_t)k);
*pp = e + 1;
return;
}
}
cst_buf_append(b, p, 1);
*pp = p + 1;
}
/* Crude HTML → text: drop script/style/head, tags become spaces or
* newlines, entities decoded, blank runs collapsed. */
static char *html_to_text(const char *s, size_t n) {
cst_buf_t b = {0};
const char *p = s, *end = s + n;
for (const char *t = s; t + 7 < end; t++) {
if (*t == '<' && strncasecmp(t + 1, "title", 5) == 0) {
const char *gt = memchr(t, '>', (size_t)(end - t));
const char *e = gt ? gt + 1 : end;
while (e + 7 < end && strncasecmp(e, "</title", 7) != 0)
e++;
if (gt && e + 7 < end && e > gt + 1) {
cst_buf_puts(&b, "# ");
cst_buf_append(&b, gt + 1, (size_t)(e - gt - 1));
cst_buf_puts(&b, "\n\n");
}
break;
}
}
while (p < end) {
if (*p == '<') {
const char *q = p + 1;
int closing = *q == '/';
if (closing)
q++;
if (!closing && (tag_is(q, "script") || tag_is(q, "style") ||
tag_is(q, "head") || tag_is(q, "noscript"))) {
const char *name = tag_is(q, "script") ? "</script"
: tag_is(q, "style") ? "</style"
: tag_is(q, "head") ? "</head"
: "</noscript";
const char *e = p;
size_t nl = strlen(name);
while (e < end && !(end - e >= (long)nl && strncasecmp(e, name, nl) == 0))
e++;
const char *gt = e < end ? memchr(e, '>', (size_t)(end - e)) : NULL;
p = gt ? gt + 1 : end;
continue;
}
if (end - p >= 4 && strncmp(p, "<!--", 4) == 0) {
const char *e = p + 4;
while (e + 2 < end && strncmp(e, "-->", 3) != 0)
e++;
p = e + 3 <= end ? e + 3 : end;
continue;
}
int block = 0;
for (size_t i = 0; i < sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); i++)
if (tag_is(q, BLOCK_TAGS[i]))
block = 1;
const char *gt = memchr(p, '>', (size_t)(end - p));
p = gt ? gt + 1 : end;
cst_buf_puts(&b, block ? "\n" : " ");
continue;
}
if (*p == '&') {
put_entity(&b, &p, end);
continue;
}
cst_buf_append(&b, p, 1);
p++;
}
/* collapse whitespace: spaces within lines, at most one blank line */
cst_buf_t o = {0};
int nls = 0, sp = 0;
for (size_t i = 0; i < b.len; i++) {
char ch = b.buf[i];
if (ch == '\r')
continue;
if (ch == '\n') {
if (nls < 2 && o.len)
cst_buf_puts(&o, "\n");
nls++;
sp = 0;
continue;
}
if (ch == ' ' || ch == '\t') {
sp = 1;
continue;
}
if (sp && nls == 0 && o.len)
cst_buf_puts(&o, " ");
sp = 0;
nls = 0;
cst_buf_append(&o, &ch, 1);
}
cst_buf_free(&b);
if (!o.buf)
cst_buf_puts(&o, "");
return cst_buf_steal(&o);
}
static int textual(const char *ctype) {
if (!ctype || !ctype[0])
return 1;
return strncasecmp(ctype, "text/", 5) == 0 || strstr(ctype, "json") ||
strstr(ctype, "xml") || strstr(ctype, "javascript") ||
strstr(ctype, "yaml") || strstr(ctype, "toml");
}
int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r) {
(void)ctx;
const char *url = tool_arg(c, "url");
if (!url || !url[0])
return tool_set_result(r, "error: missing url", 1);
while (*url == ' ')
url++;
if (strncasecmp(url, "http://", 7) != 0 &&
strncasecmp(url, "https://", 8) != 0)
return tool_set_result(r, "error: only http:// and https:// URLs", 1);
long max_out = tool_arg_long(c, "max_bytes", FETCH_DEFAULT_OUT);
if (max_out <= 0)
max_out = FETCH_DEFAULT_OUT;
if (max_out > FETCH_MAX_OUT)
max_out = FETCH_MAX_OUT;
CURL *curl = curl_easy_init();
if (!curl)
return tool_set_result(r, "error: curl init failed", 1);
fetch_t f;
memset(&f, 0, sizeof(f));
char errbuf[CURL_ERROR_SIZE];
errbuf[0] = 0;
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
#if LIBCURL_VERSION_NUM >= 0x075500
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "http,https");
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "http,https");
#else
curl_easy_setopt(curl, CURLOPT_PROTOCOLS, (long)(CURLPROTO_HTTP | CURLPROTO_HTTPS));
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS,
(long)(CURLPROTO_HTTP | CURLPROTO_HTTPS));
#endif
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "cst-agent/1.0 (+web_fetch)");
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, on_body);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &f);
curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, errbuf);
CURLcode rc = curl_easy_perform(curl);
long http = 0;
char *ctype = NULL;
char *final_url = NULL;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http);
curl_easy_getinfo(curl, CURLINFO_CONTENT_TYPE, &ctype);
curl_easy_getinfo(curl, CURLINFO_EFFECTIVE_URL, &final_url);
cst_buf_t b = {0};
int code = 0;
if (rc != CURLE_OK && !f.overflow) {
cst_buf_printf(&b, "error: fetch failed: %s",
errbuf[0] ? errbuf : curl_easy_strerror(rc));
code = 1;
} else {
cst_buf_printf(&b, "HTTP %ld %s\ncontent-type: %s\n\n", http,
final_url ? final_url : url, ctype ? ctype : "?");
if (http >= 400)
code = 1;
if (!textual(ctype)) {
cst_buf_printf(&b, "(non-text content, %zu bytes not shown)\n",
f.body.len);
} else {
char *text = NULL;
size_t tlen = 0;
int html = ctype && strstr(ctype, "html");
if (html) {
text = html_to_text(f.body.buf ? f.body.buf : "", f.body.len);
tlen = text ? strlen(text) : 0;
}
const char *src = html ? text : (f.body.buf ? f.body.buf : "");
size_t slen = html ? tlen : f.body.len;
size_t keep = slen > (size_t)max_out
? cst_utf8_floor(src, (size_t)max_out)
: slen;
size_t clean_len = 0;
char *clean = cst_utf8_sanitize(src, keep, &clean_len);
if (clean)
cst_buf_append(&b, clean, clean_len);
free(clean);
if (keep < slen || f.overflow)
cst_buf_printf(&b, "\n...(truncated; %zu%s bytes in total)\n", slen,
f.overflow ? "+" : "");
free(text);
}
}
curl_easy_cleanup(curl);
cst_buf_free(&f.body);
return tool_take_buf(r, &b, code);
}