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:
+285
@@ -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[] = {{"&", "&"}, {"<", "<"}, {">", ">"},
|
||||
{""", "\""}, {"'", "'"}, {"'", "'"},
|
||||
{" ", " "}};
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user