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:
@@ -0,0 +1,204 @@
|
||||
#include "ctl.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Heredoc args: `arg <key> <<TAG` ... a line equal to TAG.
|
||||
* The body is confirmed only by the terminator line, so no prefix of the
|
||||
* stream may ever produce CALL_READY. */
|
||||
|
||||
static int nfail;
|
||||
|
||||
#define EXPECT(cond, msg) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
|
||||
nfail++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
typedef struct {
|
||||
int ready;
|
||||
int nargs;
|
||||
char keys[CTL_MAX_ARGS][CTL_MAX_KEY];
|
||||
char *vals[CTL_MAX_ARGS];
|
||||
size_t lens[CTL_MAX_ARGS];
|
||||
char text[4096];
|
||||
size_t text_len;
|
||||
} cap_t;
|
||||
|
||||
static void on_ev(const ctl_event_t *ev, void *ud) {
|
||||
cap_t *c = ud;
|
||||
if (ev->type == CTL_EV_TEXT && c->text_len + ev->text_len < sizeof(c->text)) {
|
||||
memcpy(c->text + c->text_len, ev->text, ev->text_len);
|
||||
c->text_len += ev->text_len;
|
||||
}
|
||||
if (ev->type != CTL_EV_CALL_READY)
|
||||
return;
|
||||
c->ready++;
|
||||
c->nargs = ev->call->nargs;
|
||||
for (int i = 0; i < ev->call->nargs; i++) {
|
||||
snprintf(c->keys[i], sizeof(c->keys[i]), "%s", ev->call->args[i].key);
|
||||
free(c->vals[i]);
|
||||
c->vals[i] = malloc(ev->call->args[i].value_len + 1);
|
||||
memcpy(c->vals[i], ev->call->args[i].value, ev->call->args[i].value_len);
|
||||
c->vals[i][ev->call->args[i].value_len] = 0;
|
||||
c->lens[i] = ev->call->args[i].value_len;
|
||||
}
|
||||
}
|
||||
|
||||
static void cap_free(cap_t *c) {
|
||||
for (int i = 0; i < CTL_MAX_ARGS; i++)
|
||||
free(c->vals[i]);
|
||||
memset(c, 0, sizeof(*c));
|
||||
}
|
||||
|
||||
/* feed in `chunk`-byte pieces; finish if asked */
|
||||
static ctl_status_t run(const char *s, size_t n, size_t chunk, int finish,
|
||||
cap_t *c, ctl_parser_stats_t *st) {
|
||||
memset(c, 0, sizeof(*c));
|
||||
ctl_parser_t *p = ctl_parser_create(on_ev, c);
|
||||
ctl_status_t rc = CTL_OK;
|
||||
for (size_t i = 0; i < n && rc == CTL_OK; i += chunk) {
|
||||
size_t k = n - i < chunk ? n - i : chunk;
|
||||
rc = ctl_parser_feed(p, s + i, k);
|
||||
}
|
||||
if (rc == CTL_OK && finish)
|
||||
rc = ctl_parser_finish(p);
|
||||
if (st)
|
||||
ctl_parser_stats(p, st);
|
||||
ctl_parser_destroy(p);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static const char *BODY = "all:\n"
|
||||
"\tgcc -o app main.c\n"
|
||||
"\n"
|
||||
"end\n"
|
||||
"call x y\n"
|
||||
"arg path 3\n"
|
||||
"```\n"
|
||||
" EOF\n"
|
||||
"last line\n";
|
||||
|
||||
static const char *STREAM = "Writing the Makefile.\n"
|
||||
"call w1 write_file\n"
|
||||
"arg path Makefile\n"
|
||||
"arg content <<EOF\n"
|
||||
"all:\n"
|
||||
"\tgcc -o app main.c\n"
|
||||
"\n"
|
||||
"end\n"
|
||||
"call x y\n"
|
||||
"arg path 3\n"
|
||||
"```\n"
|
||||
" EOF\n"
|
||||
"last line\n"
|
||||
"EOF\n"
|
||||
"end\n";
|
||||
|
||||
static void test_body_verbatim(void) {
|
||||
size_t chunks[] = {1, 2, 5, 64, 100000};
|
||||
for (size_t k = 0; k < sizeof(chunks) / sizeof(chunks[0]); k++) {
|
||||
cap_t c;
|
||||
ctl_parser_stats_t st;
|
||||
ctl_status_t rc = run(STREAM, strlen(STREAM), chunks[k], 1, &c, &st);
|
||||
EXPECT(rc == CTL_OK, "heredoc stream ok");
|
||||
EXPECT(c.ready == 1, "one call ready");
|
||||
EXPECT(c.nargs == 2, "two args");
|
||||
EXPECT(strcmp(c.keys[0], "path") == 0 && strcmp(c.vals[0], "Makefile") == 0,
|
||||
"inline path");
|
||||
EXPECT(strcmp(c.keys[1], "content") == 0, "content key");
|
||||
EXPECT(c.vals[1] && strcmp(c.vals[1], BODY) == 0,
|
||||
"body verbatim: tabs, blank lines, end/call/arg/fence lines, "
|
||||
"indented tag");
|
||||
EXPECT(st.args_heredoc == 1 && st.args_inline == 1, "stats per arg form");
|
||||
EXPECT(c.text_len == strlen("Writing the Makefile.\n"), "prose is text");
|
||||
cap_free(&c);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_never_premature(void) {
|
||||
size_t n = strlen(STREAM);
|
||||
int premature = 0;
|
||||
for (size_t cut = 0; cut < n; cut++) {
|
||||
cap_t c;
|
||||
run(STREAM, cut, 7, 0, &c, NULL);
|
||||
if (c.ready)
|
||||
premature++;
|
||||
cap_free(&c);
|
||||
}
|
||||
EXPECT(premature == 0, "no prefix of a heredoc call is executable");
|
||||
|
||||
/* EOF inside the body: error, nothing executed */
|
||||
const char *mid = strstr(STREAM, "\tgcc");
|
||||
cap_t c;
|
||||
ctl_parser_stats_t st;
|
||||
ctl_status_t rc = run(STREAM, (size_t)(mid - STREAM), 3, 1, &c, &st);
|
||||
EXPECT(rc == CTL_ERR_CONTRACT && c.ready == 0, "EOF in heredoc rejected");
|
||||
EXPECT(st.error_kind == CTL_EK_HEREDOC_EOF, "error kind heredoc_eof");
|
||||
cap_free(&c);
|
||||
}
|
||||
|
||||
static void test_tag_forms(void) {
|
||||
const char *quoted = "call q1 write_file\narg path a.txt\n"
|
||||
"arg content <<'BODY_1'\nx\nBODY_1\nend\n";
|
||||
cap_t c;
|
||||
EXPECT(run(quoted, strlen(quoted), 1, 1, &c, NULL) == CTL_OK && c.ready == 1,
|
||||
"single-quoted tag");
|
||||
EXPECT(c.vals[1] && strcmp(c.vals[1], "x\n") == 0, "quoted tag body");
|
||||
cap_free(&c);
|
||||
|
||||
const char *dq = "call q2 write_file\narg path a.txt\n"
|
||||
"arg content << \"T\"\n\nT\nend\n";
|
||||
EXPECT(run(dq, strlen(dq), 4, 1, &c, NULL) == CTL_OK && c.ready == 1,
|
||||
"double-quoted tag with space");
|
||||
EXPECT(c.vals[1] && strcmp(c.vals[1], "\n") == 0, "single blank line body");
|
||||
cap_free(&c);
|
||||
|
||||
const char *empty = "call q3 write_file\narg path a.txt\n"
|
||||
"arg content <<E\nE\nend\n";
|
||||
EXPECT(run(empty, strlen(empty), 3, 1, &c, NULL) == CTL_OK && c.ready == 1 &&
|
||||
c.lens[1] == 0,
|
||||
"empty body");
|
||||
cap_free(&c);
|
||||
|
||||
const char *bad = "call q4 write_file\narg content <<a-b\nx\na-b\nend\n";
|
||||
ctl_parser_stats_t st;
|
||||
EXPECT(run(bad, strlen(bad), 1, 1, &c, &st) == CTL_ERR_CONTRACT &&
|
||||
st.error_kind == CTL_EK_BAD_ARG && c.ready == 0,
|
||||
"invalid tag rejected");
|
||||
cap_free(&c);
|
||||
|
||||
/* terminator on the last line without newline, implicit end at EOF */
|
||||
const char *eof = "call q5 write_file\narg path a.txt\narg content <<EOF\n"
|
||||
"body\nEOF";
|
||||
EXPECT(run(eof, strlen(eof), 2, 1, &c, NULL) == CTL_OK && c.ready == 1,
|
||||
"tag at EOF accepted");
|
||||
EXPECT(c.vals[1] && strcmp(c.vals[1], "body\n") == 0, "tag at EOF body");
|
||||
cap_free(&c);
|
||||
|
||||
/* mixing all three arg forms in one call */
|
||||
const char *mix = "call m1 apply_patch\narg note hello world\narg patch 4\n"
|
||||
"ab\ncarg extra <<X\nq\nX\nend\n";
|
||||
EXPECT(run(mix, strlen(mix), 1, 1, &c, &st) == CTL_OK && c.ready == 1 &&
|
||||
c.nargs == 3,
|
||||
"inline + nbytes + heredoc in one call");
|
||||
EXPECT(c.vals[1] && strcmp(c.vals[1], "ab\nc") == 0, "nbytes body exact");
|
||||
EXPECT(st.args_inline == 1 && st.args_nbytes == 1 && st.args_heredoc == 1,
|
||||
"stats count each form");
|
||||
cap_free(&c);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_body_verbatim();
|
||||
test_never_premature();
|
||||
test_tag_forms();
|
||||
if (nfail) {
|
||||
fprintf(stderr, "test_ctl_heredoc: %d failed\n", nfail);
|
||||
return 1;
|
||||
}
|
||||
printf("test_ctl_heredoc: OK\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user