Usability pass: tool registry, TUI polish, saved keys and settings
ci / linux (clang) (push) Canceled after 0s
ci / linux (gcc) (push) Canceled after 0s
ci / sanitizers (push) Canceled after 0s
ci / musl (push) Canceled after 0s
ci / macos (push) Canceled after 0s

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
+194 -14
View File
@@ -11,7 +11,9 @@ extern "C" {
#define CST_AGENT_MAX_MSG 256
#define CST_AGENT_MAX_CONTENT (512 * 1024)
#define CST_AGENT_CTX_WINDOW 40 /* max non-system msgs sent to LLM */
#define CST_AGENT_CTX_WINDOW 80 /* max non-system msgs sent to LLM */
#define CST_DEFAULT_MAX_STEPS 30
#define CST_DEFAULT_TOOL_OUT_CAP (16 * 1024)
typedef enum {
CST_ROLE_SYSTEM = 0,
@@ -20,12 +22,39 @@ typedef enum {
CST_ROLE_TOOL
} cst_role_t;
/* How tool calls travel between model and runtime.
* native-eos : OpenAI tools[]; execute after the stream ends (baseline).
* native-inc : OpenAI tools[]; execute as soon as one call's arguments are
* complete (next index started / finish_reason).
* ctl : CTL frames in plain text, length-prefixed args; execute on
* confirmed `end` while the stream is still running.
* ctl-heredoc: same, prompt teaches `arg <key> <<TAG ... TAG` bodies.
* xml : MiniMax <minimax:tool_call> text; execute after the stream. */
typedef enum {
CST_PROTO_NATIVE_EOS = 0,
CST_PROTO_NATIVE_INC,
CST_PROTO_CTL,
CST_PROTO_CTL_HEREDOC,
CST_PROTO_XML
} cst_protocol_t;
const char *cst_protocol_name(cst_protocol_t p);
int cst_protocol_parse(const char *s, cst_protocol_t *out);
int cst_protocol_is_native(cst_protocol_t p);
int cst_protocol_is_ctl(cst_protocol_t p);
/* Text protocols carry calls/results in message text (no tools[]). */
int cst_protocol_is_text(cst_protocol_t p);
/* Eager protocols execute a call during the stream, at confirmation time. */
int cst_protocol_is_eager(cst_protocol_t p);
typedef struct {
cst_role_t role;
char *content;
char tool_call_id[CTL_MAX_ID]; /* for tool results */
char tool_call_id[CTL_MAX_ID]; /* tool results (role=tool, or text-protocol
result frames sent as role=user) */
char tool_name[CTL_MAX_NAME];
char *tool_calls_json; /* assistant: OpenAI tool_calls array JSON, or NULL */
char *meta_json; /* optional JSON object (kind, protocol, ...) */
} cst_message_t;
typedef struct {
@@ -34,10 +63,18 @@ typedef struct {
char api_base[512];
char api_key[512];
char model[128];
char provider[32];
char effort[16]; /* low | medium | high | max */
cst_protocol_t protocol;
int max_steps; /* model calls per user turn */
size_t tool_out_cap; /* bytes of one tool output sent to the model */
int headless;
int confirm_shell; /* confirm run_shell */
int confirm_write; /* confirm write_file / apply_patch */
int auto_approve; /* headless / CST_AUTO_APPROVE=1 */
int confirm_write; /* confirm file-changing tools */
int confirm_net; /* confirm web_fetch */
int auto_approve; /* headless / CST_AUTO_APPROVE=1 / --yolo */
int tool_profile; /* cst_tool_profile_t (CST_TOOLS) */
unsigned long long tools_off; /* bit i = registry tool i disabled */
} cst_agent_config_t;
typedef struct cst_session cst_session_t;
@@ -56,6 +93,10 @@ const cst_message_t *cst_session_get(const cst_session_t *s, int i);
const char *cst_session_path(const cst_session_t *s);
int cst_session_clear(cst_session_t *s, int keep_system);
int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n);
/* Readers on another thread must hold the lock while using pointers returned
* by cst_session_get (append may reallocate). Append locks internally. */
void cst_session_lock(cst_session_t *s);
void cst_session_unlock(cst_session_t *s);
/* --- workspace .agent/sessions store (OpenCode-like) --- */
int cst_agent_store_ensure(const char *workspace);
@@ -70,6 +111,18 @@ int cst_agent_store_resolve_default(const char *workspace, char *path_out,
size_t path_sz, char *id_out,
size_t id_sz);
int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids);
typedef struct {
char id[128];
long long mtime;
int messages;
char title[128]; /* set via /rename; empty when none */
char first_user[128]; /* first user message, one line */
} cst_session_info_t;
int cst_agent_store_info(const char *workspace, const char *id,
cst_session_info_t *info);
int cst_agent_store_title_set(const char *workspace, const char *id,
const char *title);
int cst_agent_store_delete(const char *workspace, const char *id);
/* --- tools --- */
typedef struct {
@@ -80,6 +133,12 @@ typedef struct {
void cst_tool_result_free(cst_tool_result_t *r);
int cst_tool_known(const char *name);
int cst_tool_is_shell(const char *name);
int cst_tool_is_write(const char *name);
/* OpenAI tools[] array for every tool (cJSON*, caller frees). */
void *cst_tools_schema(void);
int cst_tool_read_file(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r);
int cst_tool_run_shell(const char *workspace_root, const ctl_tool_call_t *call,
@@ -91,30 +150,72 @@ int cst_tool_write_file(const char *workspace_root, const ctl_tool_call_t *call,
int cst_tool_dispatch(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r);
/* Build ctl_tool_call from native OpenAI tool_call fields. */
/* Build ctl_tool_call from native OpenAI tool_call fields. Returns -1 when
* args_json is not a JSON object (call still gets id/name). */
int cst_tool_call_from_native(ctl_tool_call_t *out, const char *id,
const char *name, const char *args_json);
void cst_tool_call_clear(ctl_tool_call_t *c);
/* Apply unified-diff hunks (text after the ---/+++ header) to one buffer.
* All hunks must be complete and locate unambiguously, else nothing changes.
* On success *out is malloc'd. */
int cst_patch_apply_buffer(const char *orig, size_t olen, const char *hunks,
size_t hlen, char **out, size_t *out_len,
int *nhunks, char *err, size_t err_sz);
/* --- LLM --- */
typedef void (*cst_llm_on_delta_fn)(const char *data, size_t len, void *ud);
/* args_json == NULL means the arguments were not valid JSON / too large. */
typedef void (*cst_llm_on_native_tool_fn)(const char *id, const char *name,
const char *args_json, void *ud);
typedef void (*cst_llm_on_native_open_fn)(int index, const char *id,
const char *name, void *ud);
typedef struct {
long http_status;
char finish_reason[32];
long prompt_tokens;
long completion_tokens;
long cached_tokens;
double t_first_byte_ms; /* relative to request start; -1 if none */
double t_end_ms;
int client_stopped; /* stream ended by stop_flag (not an error) */
int retries;
char error[512]; /* in-stream or HTTP error message */
} cst_llm_stats_t;
typedef struct {
const char *api_base;
const char *api_key;
const char *model;
const char *reasoning_effort; /* NULL = not sent */
const cst_message_t *msgs;
int nmsgs;
cst_llm_on_delta_fn on_delta;
cst_llm_on_native_tool_fn on_native_tool; /* fired once per completed call */
/* Fired once per call, as soon as that call's arguments are complete. */
cst_llm_on_native_tool_fn on_native_tool;
cst_llm_on_native_open_fn on_native_open;
void *userdata;
int enable_tools; /* send tools[] schema */
const cst_agent_config_t *tool_cfg; /* active tool set; NULL = all */
int tool_choice_none; /* "tool_choice":"none" */
volatile int *cancel_flag; /* non-zero → abort curl */
size_t tool_out_cap; /* wire cap for role=tool content; 0 = default */
volatile int *cancel_flag; /* non-zero → abort (error "cancelled") */
volatile int *stop_flag; /* non-zero → stop reading, not an error */
cst_llm_stats_t *stats; /* optional */
} cst_llm_request_t;
int cst_llm_chat_stream(const cst_llm_request_t *req, char *err, size_t err_sz);
/* JSON request body (malloc'd) exactly as sent on the wire. */
char *cst_llm_build_body(const cst_llm_request_t *req);
/* Reset the CST_RECORD_SSE / CST_REPLAY_SSE sequence counter. */
void cst_llm_trace_reset(void);
/* on_permission answers */
#define CST_PERM_DENY 0
#define CST_PERM_ONCE 1
#define CST_PERM_ALWAYS_TOOL 2 /* saved to .agent/permissions.json */
#define CST_PERM_ALWAYS_PREFIX 3 /* run_shell: allow this command prefix */
/* Live UI / observability hooks for a turn */
typedef struct {
@@ -123,8 +224,20 @@ typedef struct {
void (*on_tool_event)(const char *phase, const ctl_tool_call_t *call,
const char *detail, void *ud);
void (*on_think_delta)(const char *text, size_t len, void *ud);
/* Return 1=allow, 0=deny. NULL → use config auto_approve / defaults. */
int (*on_permission)(const char *tool_name, const char *detail, void *ud);
/* Return a CST_PERM_* answer. NULL → deny unless auto_approve. preview is
* the diff a write call would make, the full command for run_shell, or
* NULL. */
int (*on_permission)(const char *tool_name, const char *detail,
const char *preview, void *ud);
/* ask_user tool. Fill ans and return 0, or -1 when dismissed. NULL means
* nobody can answer (headless). */
int (*on_ask_user)(const char *question, const char *options, char *ans,
size_t ans_sz, void *ud);
/* todo_write replaced the task list (one item per line). */
void (*on_todo)(const char *text, void *ud);
/* Full tool output, fired right after execution (before it is persisted). */
void (*on_tool_result)(const ctl_tool_call_t *call, const char *out,
size_t len, int exit_code, void *ud);
void *userdata;
} cst_agent_hooks_t;
@@ -143,14 +256,58 @@ void cst_agent_request_cancel(cst_agent_t *a);
int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz);
int cst_agent_switch_session(cst_agent_t *a, const char *id);
int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz);
/* Bare id of the current session (file name without .jsonl). */
void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz);
/* Latest todo_write list of this session, or NULL. */
const char *cst_agent_todo(const cst_agent_t *a);
/* AGENTS.md (or CST.md) text injected into the system prompt, or NULL. */
const char *cst_agent_notes(const cst_agent_t *a);
void cst_agent_reload_notes(cst_agent_t *a);
/* Roll back the file changes of the newest undoable turn. */
int cst_agent_undo(cst_agent_t *a, char *report, size_t report_sz);
int cst_agent_undo_available(const cst_agent_t *a);
/* Persistent permission rules (see cst_perms.h). */
struct cst_perms *cst_agent_perms(cst_agent_t *a);
/* /retry: remove the last user prompt and its replies; caller frees. */
char *cst_agent_rewind_last_turn(cst_agent_t *a);
/* Last prompt_tokens reported by the model (0 if unknown). */
long cst_agent_last_prompt_tokens(const cst_agent_t *a);
const char *cst_agent_system_prompt(void);
const char *cst_agent_system_prompt_for(cst_protocol_t p);
/* Prompt for this config's tool set and protocol, plus optional project
* instructions (AGENTS.md). malloc'd. */
char *cst_agent_build_prompt(const cst_agent_config_t *cfg, cst_protocol_t p,
const char *project_notes);
/* Credentials: zenity + ~/.config/cst-agent/credentials (0600) */
int cst_creds_load_api_key(char *out, size_t out_sz);
int cst_creds_save_api_key(const char *key);
int cst_creds_prompt_api_key(char *out, size_t out_sz);
int cst_creds_ensure_api_key(char *out, size_t out_sz);
/* Per-provider API keys in <config>/credentials (mode 0600), one
* "<provider>.api_key=..." line each. */
int cst_creds_load(const char *provider, char *out, size_t out_sz);
int cst_creds_save(const char *provider, const char *key);
/* Saved key, then the provider's env variable. Returns 0 when found;
* *from (optional) gets "saved" or the variable name. */
int cst_creds_resolve(const char *provider, char *out, size_t out_sz,
const char **from);
/* Hidden console prompt (no echo). */
int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz);
/* Last used provider, model per provider and effort, in
* <config>/settings.json. */
#define CST_SETTINGS_MAX_MODELS 16
typedef struct {
char provider[32];
char effort[16];
int nmodels;
struct {
char provider[32];
char model[128];
} models[CST_SETTINGS_MAX_MODELS];
} cst_settings_t;
int cst_settings_load(cst_settings_t *s);
int cst_settings_save(const cst_settings_t *s);
const char *cst_settings_model(const cst_settings_t *s, const char *provider);
/* Record cfg's provider, model and effort as the last used ones. */
int cst_settings_remember(const cst_agent_config_t *cfg);
/* JSON baseline parser for experiments (buffer until valid object). */
typedef struct {
@@ -161,6 +318,29 @@ typedef struct {
int cst_json_try_parse_tool(const char *buf, size_t len, cst_json_tool_t *out);
/* Provider presets (base URL + default model). */
typedef struct {
const char *name;
const char *api_base;
const char *model;
const char *key_env; /* provider-specific key variable, e.g. DEEPSEEK_API_KEY */
} cst_provider_t;
const cst_provider_t *cst_provider_find(const char *name);
const cst_provider_t *cst_provider_default(void);
int cst_provider_count(void);
const cst_provider_t *cst_provider_at(int i);
/* Context window of a model in tokens (CST_CTX_TOKENS overrides). */
long cst_model_context_tokens(const char *model);
/* Reasoning effort: low | medium | high | max. */
#define CST_DEFAULT_EFFORT "medium"
int cst_effort_valid(const char *effort);
/* Value for "reasoning_effort", or NULL when this provider/model does not
* take the parameter. */
const char *cst_effort_wire(const char *provider, const char *model,
const char *effort);
/* Built-in / CST_MODELS model picker list. Writes up to max_out names.
* Always includes `current` if non-empty and not already listed. */
int cst_models_list(const char *current, char out[][128], int max_out);