v0.8.6: general mode, clipboard text, PDF text extraction
ci / tcc-check (push) Successful in 59s
ci / linux (aarch64, 0) (push) Successful in 19m11s
ci / linux (x86, 1) (push) Successful in 11m31s
ci / linux (x86_64, 1) (push) Successful in 11m11s
ci / windows (push) Successful in 9m24s

Working mode: --mode coding|general (CST_MODE, settings.json, /mode,
WebUI run settings). general swaps the default framing for a general
assistant one -- workflow, safety notes, tool wording (run_shell, the
subagents) -- and /status shows it; minimal (the paper profile) stays
byte-frozen. The prompt state carries the mode so a switch leaves a
system note in history.

Clipboard: clipboard_read / clipboard_write tools (16 MiB cap read,
default 64 KiB window; writing is a WRITE permission with a preview) on
pbcopy / wl-copy / xclip / pbpaste / wl-paste / xclip and the Win32
clipboard. In the TUI, /attach paste falls back to clipboard text when
there is no image on it.

PDF: a dependency-free text extractor (src/pdf_text.c): inflate,
Flate/ASCIIHex/ASCII85, PNG/TIFF predictors, layout-independent object
scan, object streams, page tree walk with inherited resources, content
lexer (Tj/TJ/'/" and the Td/TD/T*/Tm spacing heuristics), per-font
ToUnicode CMaps and inline images skipped. Encrypted files and pages
with no text layer are refused with the reason.

Attachments: /attach (a path or files dropped on the terminal) now take
images, PDFs and text. A PDF is stored as its text layer with the mime
still saying application/pdf and its page count; anything else has to be
UTF-8 text. Documents travel inline as text parts, so CST_NO_IMAGES and
text-only models no longer drop them, while images keep their old path.

Tests: test_pdf_text (plain, bare and zlib-framed Flate, inline image,
CID + ToUnicode, object stream, error paths), documents in test_attach,
inlined documents in test_llm_body, mode in test_config.
This commit is contained in:
WHC
2026-10-11 11:09:39 +08:00
parent 50abcf1a91
commit e0b0eddf3a
28 changed files with 3291 additions and 111 deletions
+108 -6
View File
@@ -177,8 +177,62 @@ static int tool_on(const cst_agent_config_t *cfg, const char *name) {
return cst_tool_active(cfg, cst_tool_find(name));
}
/* /mode general (cst_mode_name covers "" and unknown values). */
static int general_mode(const cst_agent_config_t *cfg) {
return cfg && strcmp(cst_mode_name(cfg->mode), "general") == 0;
}
/* Workflow for /mode general: gather, then do the work, then check it. */
static void put_workflow_general(cst_buf_t *b, const cst_agent_config_t *cfg) {
cst_buf_puts(b, "\nWorkflow:\n1) Gather what you need");
const char *explore[] = {"list_dir", "glob", "grep"};
int n = 0;
for (int i = 0; i < 3; i++) {
if (tool_on(cfg, explore[i]))
cst_buf_printf(b, "%s%s", n++ ? ", " : " (", explore[i]);
}
if (n)
cst_buf_puts(b, ")");
if (tool_on(cfg, "read_file"))
cst_buf_puts(b, "; read big files in slices with read_file offset/limit, "
"and use the documents the user attached");
cst_buf_puts(b, ".\n2) Do the work");
n = 0;
if (tool_on(cfg, "write_file")) {
cst_buf_puts(b, ": write_file for new files");
n++;
}
if (tool_on(cfg, "edit_file"))
cst_buf_printf(b, "%sedit_file for small exact replacements",
n++ ? ", " : ": ");
if (tool_on(cfg, "apply_patch"))
cst_buf_printf(b, "%sapply_patch for multi-hunk diffs to existing files",
n++ ? ", " : ": ");
if (tool_on(cfg, "run_shell"))
cst_buf_printf(b, "%srun_shell for commands, computations, data "
"processing and other CLIs",
n++ ? ", " : ": ");
cst_buf_puts(b, ".\n");
if (tool_on(cfg, "run_shell"))
cst_buf_puts(b, "3) Check the result before saying it works (re-read it, "
"run it, or verify the numbers); fix and re-run until it "
"holds.\n");
if (tool_on(cfg, "todo_write"))
cst_buf_puts(b, "- For work with several steps keep a todo list with "
"todo_write and update it as you go.\n");
if (tool_on(cfg, "ask_user"))
cst_buf_puts(b, "- Use ask_user only when a decision is truly the user's; "
"otherwise choose sensibly and say so.\n");
cst_buf_puts(b, "Finish with a short plain-text summary of the result.\n"
"Never invent other tool names.\n");
}
/* Workflow section for the default tool profile; names only active tools. */
static void put_workflow(cst_buf_t *b, const cst_agent_config_t *cfg) {
if (general_mode(cfg)) {
put_workflow_general(b, cfg);
return;
}
cst_buf_puts(b, "\nWorkflow:\n1) Inspect only what you need");
const char *explore[] = {"list_dir", "glob", "grep"};
int n = 0;
@@ -235,6 +289,25 @@ static const char DEF_INTRO[] =
"could not verify, say so.\n"
"- Keep going until the task is done or truly blocked.\n\n";
/* Same role, framed for /mode general: the workspace may hold documents,
* data or anything else, not just code. */
static const char DEF_INTRO_GENERAL[] =
"You are CST-Agent, a general-purpose assistant working in the user's "
"local workspace. All tool paths are relative to the workspace root and "
"shell commands run there; the material you work on may be code, "
"documents, data or anything else.\n\n"
"Principles:\n"
"- Reply in the user's language. Be concise: lead with the result, no "
"filler.\n"
"- Understand before answering: read the files, documents and sources the "
"task refers to instead of guessing; never invent content, numbers or "
"tool results.\n"
"- Do what was asked, at the scope asked: leave unrelated files, data and "
"material alone.\n"
"- Check your work when you can (re-read it, run it, cross-check the "
"source); if you could not verify something, say so.\n"
"- Keep going until the task is done or truly blocked.\n\n";
static void put_tool_use(cst_buf_t *b, const cst_agent_config_t *cfg) {
cst_buf_puts(b, "\nTool use:\n");
if (tool_on(cfg, "run_shell") &&
@@ -248,15 +321,26 @@ static void put_tool_use(cst_buf_t *b, const cst_agent_config_t *cfg) {
cst_buf_puts(b, "- Read a file before editing it and again after a failed "
"edit. The `N|` prefixes in read_file output are not part "
"of the file.\n");
if (general_mode(cfg) && tool_on(cfg, "web_fetch"))
cst_buf_puts(b, "- Use web_fetch for pages the user points you to; say so "
"when a page could not be fetched instead of guessing its "
"content.\n");
cst_buf_puts(b, "- When a call fails, read the error and change approach "
"instead of repeating the same call.\n");
}
static void put_safety(cst_buf_t *b, const cst_agent_config_t *cfg) {
cst_buf_puts(b, "\nSafety:\n"
"- Do not commit, push or rewrite git history unless asked. "
"Do not delete or overwrite files you did not create unless "
"the task requires it. Never print secrets or API keys.\n");
if (general_mode(cfg))
cst_buf_puts(b, "\nSafety:\n"
"- Do not delete or overwrite files or data you did not "
"create unless the task requires it, and do not send the "
"user's content anywhere the task did not ask for. Never "
"print secrets or API keys.\n");
else
cst_buf_puts(b, "\nSafety:\n"
"- Do not commit, push or rewrite git history unless asked. "
"Do not delete or overwrite files you did not create unless "
"the task requires it. Never print secrets or API keys.\n");
if (tool_on(cfg, "run_shell"))
cst_buf_puts(b, "- run_shell may run in a sandbox (writes only inside the "
"workspace and temp dir; the network works). If a command is "
@@ -365,7 +449,9 @@ char *cst_agent_build_prompt(const cst_agent_config_t *cfg, cst_protocol_t p,
const char *project_notes) {
int minimal = !cfg || cfg->tool_profile == CST_TOOLS_MINIMAL;
cst_buf_t b = {0};
cst_buf_puts(&b, minimal ? PROMPT_BASE : DEF_INTRO);
cst_buf_puts(&b, minimal ? PROMPT_BASE
: general_mode(cfg) ? DEF_INTRO_GENERAL
: DEF_INTRO);
if (cst_protocol_is_native(p))
cst_buf_puts(&b, minimal ? SEC_NATIVE
: "Call tools through the native "
@@ -2241,11 +2327,12 @@ int cst_agent_undo_available(const cst_agent_t *a) {
* later changes reach the model as persisted notes appended to the history,
* which keeps the cached prefix intact. */
#define PROMPT_VERSION 2
#define PROMPT_VERSION 3
typedef struct {
int plan;
char approval[96];
char mode[16];
char branch[128];
char day[16];
char notes[24]; /* hash of AGENTS.md, "" = none */
@@ -2255,6 +2342,7 @@ static void state_now(const cst_agent_t *a, pstate_t *s) {
memset(s, 0, sizeof(*s));
s->plan = a->cfg.plan_mode;
snprintf(s->approval, sizeof(s->approval), "%s", approval_desc(&a->cfg));
snprintf(s->mode, sizeof(s->mode), "%s", cst_mode_name(a->cfg.mode));
git_branch(a->cfg.workspace_root, s->branch, sizeof(s->branch));
today(s->day, sizeof(s->day));
if (a->notes && a->notes[0])
@@ -2267,6 +2355,7 @@ static cJSON *state_json(const pstate_t *s) {
cJSON *o = cJSON_CreateObject();
cJSON_AddNumberToObject(o, "plan", s->plan);
cJSON_AddStringToObject(o, "approval", s->approval);
cJSON_AddStringToObject(o, "mode", s->mode);
cJSON_AddStringToObject(o, "branch", s->branch);
cJSON_AddStringToObject(o, "day", s->day);
cJSON_AddStringToObject(o, "notes", s->notes);
@@ -2282,6 +2371,7 @@ static void state_parse(const cJSON *o, pstate_t *s) {
if (cJSON_IsString(v)) \
snprintf(s->field, sizeof(s->field), "%s", v->valuestring);
STATE_STR("approval", approval)
STATE_STR("mode", mode)
STATE_STR("branch", branch)
STATE_STR("day", day)
STATE_STR("notes", notes)
@@ -2293,6 +2383,7 @@ static char *system_meta(const cst_agent_t *a, const pstate_t *s) {
cJSON_AddStringToObject(o, "protocol", cst_protocol_name(a->cfg.protocol));
cJSON_AddNumberToObject(o, "pv", PROMPT_VERSION);
cJSON_AddNumberToObject(o, "tools", a->cfg.tool_profile);
cJSON_AddStringToObject(o, "mode", cst_mode_name(a->cfg.mode));
cJSON_AddItemToObject(o, "state", state_json(s));
char *out = cJSON_PrintUnformatted(o);
cJSON_Delete(o);
@@ -2312,10 +2403,13 @@ static int system_current(const cst_message_t *m, const cst_agent_config_t *c) {
const cJSON *pv = cJSON_GetObjectItemCaseSensitive(meta, "pv");
const cJSON *pr = cJSON_GetObjectItemCaseSensitive(meta, "protocol");
const cJSON *tp = cJSON_GetObjectItemCaseSensitive(meta, "tools");
const cJSON *md = cJSON_GetObjectItemCaseSensitive(meta, "mode");
int ok = cJSON_IsNumber(pv) && pv->valueint == PROMPT_VERSION &&
cJSON_IsString(pr) &&
strcmp(pr->valuestring, cst_protocol_name(c->protocol)) == 0 &&
cJSON_IsNumber(tp) && tp->valueint == c->tool_profile;
ok = ok && cJSON_IsString(md) &&
strcmp(md->valuestring, cst_mode_name(c->mode)) == 0;
cJSON_Delete(meta);
return ok;
}
@@ -2393,6 +2487,14 @@ static void sync_notes(cst_agent_t *a) {
"is available again.\n");
if (strcmp(now.approval, was.approval) != 0)
cst_buf_printf(&b, "[cst] Approval mode is now %s.\n", now.approval);
if (strcmp(now.mode, was.mode) != 0)
cst_buf_printf(&b,
"[cst] Working mode is now %s%s. Earlier instructions about "
"how to frame the work no longer apply.\n",
now.mode,
strcmp(now.mode, "general") == 0
? " (general: documents, data, research)"
: " (coding: repo, build, tests)");
if (strcmp(now.branch, was.branch) != 0)
cst_buf_printf(&b, "[cst] The git branch is now %s.\n",
now.branch[0] ? now.branch : "(none)");
+129 -24
View File
@@ -4,6 +4,7 @@
#include "cJSON.h"
#include "cst_attach.h"
#include "cst_pdf.h"
#include "cst_util.h"
#include "os_compat.h"
@@ -102,6 +103,9 @@ static int le16(const unsigned char *p) {
}
static const char *mime_of_ext(const char *mime) {
if (strcmp(mime, "application/pdf") == 0 ||
strcmp(mime, "text/plain") == 0)
return "txt"; /* a document is stored as the text it was reduced to */
if (strcmp(mime, "image/png") == 0)
return "png";
if (strcmp(mime, "image/jpeg") == 0)
@@ -352,23 +356,73 @@ static void make_id(const char *name, const char *data, size_t n, char *out,
snprintf(out, out_sz, "img-%08x", (unsigned)(h & 0xFFFFFFFFu));
}
/* n bytes, NUL-terminated: a text payload is handed around as a C string. */
static char *memdup_z(const char *data, size_t n) {
char *copy = malloc(n + 1);
if (!copy)
return NULL;
memcpy(copy, data, n);
copy[n] = 0;
return copy;
}
/* What a picked file is, plus the payload to store for it: an image stays as
* it is, a PDF is reduced to its text layer (the mime still says PDF, the
* stored file holds text), anything else must be UTF-8 text. The caller frees
* the result; *out_len is its length. */
static char *prepare(const char *data, size_t n, char *mime, size_t mime_sz,
int *pages, size_t *out_len, char *err, size_t err_sz) {
*pages = 0;
*out_len = n;
if (cst_attach_sniff(data, n, mime, mime_sz) == 0) {
if (n > CST_ATTACH_MAX_BYTES) {
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
char *copy = memdup_z(data, n);
if (!copy)
snprintf(err, err_sz, "out of memory");
return copy;
}
if (n >= 4 && memcmp(data, "%PDF", 4) == 0) {
int pg = 0;
char perr[256];
char *text = cst_pdf_text(data, n, &pg, perr, sizeof(perr));
if (!text) {
snprintf(err, err_sz, "cannot read the PDF: %.200s", perr);
return NULL;
}
snprintf(mime, mime_sz, "application/pdf");
*pages = pg;
*out_len = strlen(text);
return text;
}
if (n > CST_ATTACH_TEXT_MAX) {
snprintf(err, err_sz, "text is %.1f MB; the limit is %d MB",
(double)n / (1024 * 1024), (int)(CST_ATTACH_TEXT_MAX >> 20));
return NULL;
}
if (memchr(data, 0, n) || !cst_utf8_valid(data, n)) {
snprintf(err, err_sz,
"not an image (PNG, JPEG, GIF, WebP or BMP), a PDF or UTF-8 "
"text; the content decides, not the extension");
return NULL;
}
snprintf(mime, mime_sz, "text/plain");
char *copy = memdup_z(data, n);
if (!copy)
snprintf(err, err_sz, "out of memory");
return copy;
}
/* Write one payload whose kind is already known. */
static cst_attachment_t *store(const char *session_path, const char *name,
const char *data, size_t n, const char *origin,
char *err, size_t err_sz) {
const char *data, size_t n, const char *mime,
int pages, const char *origin, char *err,
size_t err_sz) {
if (!name || !name[0])
name = "image";
char mime[32];
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0) {
snprintf(err, err_sz,
"not a supported image (PNG, JPEG, GIF, WebP or BMP; "
"checked by content, not by extension)");
return NULL;
}
if (n > CST_ATTACH_MAX_BYTES) {
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
char dir[OS_PATH_MAX];
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0) {
snprintf(err, err_sz, "cannot create the attachment directory");
@@ -386,7 +440,9 @@ static cst_attachment_t *store(const char *session_path, const char *name,
snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user");
snprintf(a->detail, sizeof(a->detail), "auto");
a->bytes = (long long)n;
cst_attach_dimensions(data, n, &a->width, &a->height);
a->pages = pages;
if (cst_attach_is_image(a))
cst_attach_dimensions(data, n, &a->width, &a->height);
char full[OS_PATH_MAX];
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
@@ -419,14 +475,26 @@ cst_attachment_t *cst_attach_import_bytes(const char *session_path,
}
err[0] = 0;
if (!session_path || !session_path[0]) {
snprintf(err, err_sz, "no session to store the image in");
snprintf(err, err_sz, "no session to store the file in");
return NULL;
}
if (!data || !n) {
snprintf(err, err_sz, "image is empty");
snprintf(err, err_sz, "the file is empty");
return NULL;
}
return store(session_path, name, data, n, "user", err, err_sz);
char mime[32];
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0) {
snprintf(err, err_sz,
"not a supported image (PNG, JPEG, GIF, WebP or BMP; "
"checked by content, not by extension)");
return NULL;
}
if (n > CST_ATTACH_MAX_BYTES) {
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
return store(session_path, name, data, n, mime, 0, "user", err, err_sz);
}
cst_attachment_t *cst_attach_import_file(const char *session_path,
@@ -449,11 +517,30 @@ cst_attachment_t *cst_attach_import_file(const char *session_path,
for (const char *p = src_path; *p; p++)
if (*p == '/' || *p == '\\')
leaf = p + 1;
cst_attachment_t *a = store(session_path, leaf, data, n, "user", err, err_sz);
char mime[32];
int pages = 0;
size_t pn = 0;
char *payload =
prepare(data, n, mime, sizeof(mime), &pages, &pn, err, err_sz);
free(data);
if (!payload)
return NULL;
cst_attachment_t *a =
store(session_path, leaf, payload, pn, mime, pages, "user", err, err_sz);
free(payload);
return a;
}
/* Images are opaque and travel as image parts; everything else is text the
* store holds and the request inlines. */
int cst_attach_is_image(const cst_attachment_t *a) {
return a && strncmp(a->mime, "image/", 6) == 0;
}
int cst_attach_is_text(const cst_attachment_t *a) {
return a && !cst_attach_is_image(a);
}
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
size_t *out_len, char *err, size_t err_sz) {
char dummy[8];
@@ -470,11 +557,12 @@ char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
char *data = read_whole(full, out_len, CST_ATTACH_MAX_BYTES);
if (!data) {
snprintf(err, err_sz,
"%.200s is missing; the image was deleted from the session "
"store",
"%.200s is missing; it was deleted from the session store",
a->name);
return NULL;
}
if (!cst_attach_is_image(a)) /* a document is text, nothing to verify */
return data;
char mime[32];
if (cst_attach_sniff(data, *out_len, mime, sizeof(mime)) != 0) {
snprintf(err, err_sz, "%.200s in the session store is no longer an image",
@@ -486,6 +574,8 @@ char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
}
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a) {
if (!cst_attach_is_image(a)) /* a document travels as text, not as a URL */
return NULL;
char err[256];
size_t n = 0;
char *data = cst_attach_read(session_path, a, &n, err, sizeof(err));
@@ -509,10 +599,21 @@ void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz) {
snprintf(out, out_sz, "(none)");
return;
}
const char *kind = strncmp(a->mime, "image/", 6) == 0 ? a->mime + 6 : a->mime;
const char *kind = a->mime;
char dim[32] = "";
if (a->width > 0 && a->height > 0)
snprintf(dim, sizeof(dim), "%dx%d ", a->width, a->height);
if (cst_attach_is_image(a)) {
if (strncmp(a->mime, "image/", 6) == 0)
kind = a->mime + 6;
if (a->width > 0 && a->height > 0)
snprintf(dim, sizeof(dim), "%dx%d ", a->width, a->height);
} else if (strcmp(a->mime, "application/pdf") == 0) {
kind = "pdf";
if (a->pages > 0)
snprintf(dim, sizeof(dim), "%d page%s ", a->pages,
a->pages == 1 ? "" : "s");
} else if (strcmp(a->mime, "text/plain") == 0 || !a->mime[0]) {
kind = "text";
}
out[0] = 0;
if (a->bytes >= 1024 * 1024)
snprintf(out, out_sz, "%s%s %.1f MB", dim, kind,
@@ -580,6 +681,8 @@ char *cst_attachments_to_json(const cst_attachment_t *list, int n) {
cJSON_AddNumberToObject(o, "width", a->width);
cJSON_AddNumberToObject(o, "height", a->height);
}
if (a->pages > 0)
cJSON_AddNumberToObject(o, "pages", a->pages);
if (a->detail[0] && strcmp(a->detail, "auto") != 0)
cJSON_AddStringToObject(o, "detail", a->detail);
if (a->origin[0])
@@ -628,6 +731,8 @@ cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out)
a->width = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "height");
a->height = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "pages");
a->pages = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "detail");
snprintf(a->detail, sizeof(a->detail), "%s",
cJSON_IsString(v) ? v->valuestring : "auto");
+169
View File
@@ -390,6 +390,175 @@ int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz) {
#endif
/* --- text clipboard ------------------------------------------------------ */
#ifdef _WIN32
int cst_clipboard_text(char **data, size_t *n, char *err, size_t err_sz) {
*data = NULL;
*n = 0;
if (!IsClipboardFormatAvailable(CF_UNICODETEXT) || !OpenClipboard(NULL)) {
set_err(err, err_sz, "no text on the clipboard");
return -1;
}
HANDLE h = GetClipboardData(CF_UNICODETEXT);
const wchar_t *w = h ? GlobalLock(h) : NULL;
if (!w) {
if (h)
GlobalUnlock(h);
CloseClipboard();
set_err(err, err_sz, "no text on the clipboard");
return -1;
}
char *utf8 = os_wide_to_utf8(w);
size_t len = utf8 ? strlen(utf8) : 0;
GlobalUnlock(h);
CloseClipboard();
if (!utf8 || !len) {
free(utf8);
set_err(err, err_sz, "no text on the clipboard");
return -1;
}
if (len > CST_CLIPBOARD_TEXT_MAX) {
free(utf8);
set_err(err, err_sz, "clipboard text is too large");
return -1;
}
*data = utf8;
*n = len;
return 0;
}
int cst_clipboard_set_text(const char *text, size_t n, char *err,
size_t err_sz) {
if (!text || os_clipboard_set(text, n) != 0) {
set_err(err, err_sz, "could not put text on the clipboard");
return -1;
}
return 0;
}
#else
/* stdout of a clipboard reader, capped and NUL-terminated. */
static int clip_read(const char *cmd, const char *tool, char **data, size_t *n,
char *err, size_t err_sz) {
os_run_result_t r;
memset(&r, 0, sizeof(r));
if (os_run_shell_c(NULL, cmd, CST_CLIPBOARD_TEXT_MAX + 1, &r) != 0) {
os_run_result_free(&r);
set_err(err, err_sz, "could not read the clipboard");
return -1;
}
if (r.exit_code != 0 || r.out_len == 0) {
int missing = r.exit_code == 127;
os_run_result_free(&r);
if (missing) {
char msg[96];
snprintf(msg, sizeof(msg), "reading the clipboard needs %s", tool);
set_err(err, err_sz, msg);
} else {
set_err(err, err_sz, "no text on the clipboard");
}
return -1;
}
if (r.out_len > CST_CLIPBOARD_TEXT_MAX) {
os_run_result_free(&r);
set_err(err, err_sz, "clipboard text is too large");
return -1;
}
char *copy = malloc(r.out_len + 1);
if (!copy) {
os_run_result_free(&r);
set_err(err, err_sz, "out of memory");
return -1;
}
memcpy(copy, r.out, r.out_len);
copy[r.out_len] = 0;
os_run_result_free(&r);
*data = copy;
*n = r.out_len;
return 0;
}
int cst_clipboard_text(char **data, size_t *n, char *err, size_t err_sz) {
*data = NULL;
*n = 0;
#ifdef __APPLE__
return clip_read("pbpaste 2>/dev/null", "pbpaste", data, n, err, err_sz);
#else
const char *wl = getenv("WAYLAND_DISPLAY");
if (wl && *wl)
return clip_read("wl-paste --type text/plain --no-newline 2>/dev/null",
"wl-clipboard (wl-paste)", data, n, err, err_sz);
const char *x = getenv("DISPLAY");
if (x && *x)
return clip_read("xclip -selection clipboard -o 2>/dev/null", "xclip",
data, n, err, err_sz);
set_err(err, err_sz, "no clipboard here (no Wayland or X11 display)");
return -1;
#endif
}
int cst_clipboard_set_text(const char *text, size_t n, char *err,
size_t err_sz) {
if (!text)
return -1;
if (n > CST_CLIPBOARD_TEXT_MAX) {
set_err(err, err_sz, "text is too large for the clipboard");
return -1;
}
const char *fmt = NULL, *tool = NULL;
#ifdef __APPLE__
fmt = "pbcopy < '%s'";
tool = "pbcopy";
#else
if (getenv("WAYLAND_DISPLAY")) {
fmt = "wl-copy < '%s'";
tool = "wl-clipboard (wl-copy)";
} else if (getenv("DISPLAY")) {
fmt = "xclip -selection clipboard -i < '%s'";
tool = "xclip";
}
#endif
if (!fmt) {
set_err(err, err_sz, "no clipboard here (no Wayland or X11 display)");
return -1;
}
char tmp[OS_PATH_MAX];
int fd = os_mkstemp(tmp, sizeof(tmp), "cst-clip");
if (fd < 0) {
set_err(err, err_sz, "could not create a temporary file");
return -1;
}
FILE *fp = fdopen(fd, "wb");
int ok = fp && fwrite(text, 1, n, fp) == n && fclose(fp) == 0;
if (!ok) {
if (fp)
fclose(fp);
remove(tmp);
set_err(err, err_sz, "could not write the text");
return -1;
}
char cmd[OS_PATH_MAX + 64];
snprintf(cmd, sizeof(cmd), fmt, tmp);
os_run_result_t r;
memset(&r, 0, sizeof(r));
int rc = os_run_shell_c(NULL, cmd, 4096, &r);
int code = r.exit_code;
os_run_result_free(&r);
remove(tmp);
if (rc != 0 || code != 0) {
char msg[96];
snprintf(msg, sizeof(msg), "writing the clipboard needs %s", tool);
set_err(err, err_sz, msg);
return -1;
}
return 0;
}
#endif
static int hexval(int c) {
if (c >= '0' && c <= '9')
return c - '0';
+90 -23
View File
@@ -255,28 +255,78 @@ static cJSON *image_part(const cst_llm_request_t *req,
return p;
}
/* Append every attachment of m as image parts (or a note saying why not). */
static void parts_add_images(const cst_llm_request_t *req, cJSON *parts,
const cst_message_t *m, size_t *budget) {
/* One stored document as a text part: its text, framed so the model knows
* where it came from. Documents are plain text on the wire, so a text-only
* model and CST_NO_IMAGES both still get them. */
static cJSON *document_part(const cst_llm_request_t *req,
const cst_attachment_t *a, size_t *budget,
char *why, size_t why_sz) {
why[0] = 0;
if (!req->session_path || !req->session_path[0]) {
snprintf(why, why_sz, "there is no session store to read it from");
return NULL;
}
char err[256];
size_t n = 0;
char *text = cst_attach_read(req->session_path, a, &n, err, sizeof(err));
if (!text) {
snprintf(why, why_sz, "%s", err);
return NULL;
}
int cut = 0;
if (n > *budget) {
n = *budget;
cut = 1;
}
*budget -= n;
char desc[128];
cst_attach_describe(a, desc, sizeof(desc));
cst_buf_t b = {0};
cst_buf_printf(&b, "[document \"%.120s\" (%s)%s]\n", a->name, desc,
cut ? " -- only the start is included" : "");
cst_buf_append(&b, text, n);
cst_buf_puts(&b, cut ? "\n[-- document truncated --]"
: "\n[-- end of document --]");
free(text);
cJSON *p = cJSON_CreateObject();
cJSON_AddStringToObject(p, "type", "text");
cJSON_AddStringToObject(p, "text", b.buf ? b.buf : "");
cst_buf_free(&b);
return p;
}
/* Append every attachment of m: images as image parts (or a note saying why
* not), documents inline as text. */
static void parts_add_attachments(const cst_llm_request_t *req, cJSON *parts,
const cst_message_t *m, size_t *budget,
size_t *doc_budget, const char *img_off) {
int n = m->nattach;
if (n > CST_ATTACH_MAX_COUNT) {
char note[160];
snprintf(note, sizeof(note), "[%d of %d images sent]", CST_ATTACH_MAX_COUNT,
n);
snprintf(note, sizeof(note),
"[only the first %d of %d attachments are sent]",
CST_ATTACH_MAX_COUNT, n);
parts_add_text(parts, cJSON_CreateString(note));
n = CST_ATTACH_MAX_COUNT;
}
for (int i = 0; i < n; i++) {
const cst_attachment_t *a = &m->attach[i];
int is_img = cst_attach_is_image(a);
char why[256];
cJSON *p = image_part(req, a, budget, why, sizeof(why));
cJSON *p = NULL;
if (is_img && img_off)
snprintf(why, sizeof(why), "%s", img_off);
else if (is_img)
p = image_part(req, a, budget, why, sizeof(why));
else
p = document_part(req, a, doc_budget, why, sizeof(why));
if (p) {
cJSON_AddItemToArray(parts, p);
continue;
}
char note[512];
snprintf(note, sizeof(note), "[image \"%.120s\" not sent: %.200s]",
a->name, why);
snprintf(note, sizeof(note), "[%s \"%.120s\" not sent: %.200s]",
is_img ? "image" : "document", a->name, why);
parts_add_text(parts, cJSON_CreateString(note));
}
}
@@ -284,8 +334,10 @@ static void parts_add_images(const cst_llm_request_t *req, cJSON *parts,
/* Images a tool result produced are deferred: an assistant tool_call must be
* answered by tool messages only, so they ride in one extra user message
* after the run of tool messages ends. *pending collects it. */
static void defer_images(const cst_llm_request_t *req, const cst_message_t *m,
cJSON **pending, size_t *budget) {
static void defer_attachments(const cst_llm_request_t *req,
const cst_message_t *m, cJSON **pending,
size_t *budget, size_t *doc_budget,
const char *img_off) {
cJSON *p = *pending;
if (!p) {
p = cJSON_CreateObject();
@@ -293,15 +345,18 @@ static void defer_images(const cst_llm_request_t *req, const cst_message_t *m,
cJSON_AddArrayToObject(p, "content");
*pending = p;
}
parts_add_images(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m,
budget);
parts_add_attachments(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m,
budget, doc_budget, img_off);
}
/* m's text plus one line per image it would have carried (malloc'd). */
/* m's text plus one line per image it would have carried (malloc'd); a
* document is not affected (it is text and is sent as such). */
static char *image_notes(const cst_message_t *m, const char *why) {
cst_buf_t b = {0};
cst_buf_puts(&b, m->content ? m->content : "");
for (int i = 0; i < m->nattach; i++) {
if (!cst_attach_is_image(&m->attach[i]))
continue;
char d[128];
cst_attach_describe(&m->attach[i], d, sizeof(d));
cst_buf_printf(&b, "%s[image \"%.120s\" (%s) not sent: %s]",
@@ -394,6 +449,7 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON *prev_user = NULL; /* merge consecutive user messages */
cJSON *pending = NULL; /* images from tool results, waiting for a break */
size_t img_budget = CST_ATTACH_MAX_TOTAL;
size_t doc_budget = CST_ATTACH_MAX_TOTAL;
const char *noimg = getenv("CST_NO_IMAGES");
const char *img_off =
noimg && noimg[0] && noimg[0] != '0'
@@ -407,15 +463,26 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cst_role_t role = m->role;
if (role == CST_ROLE_TOOL && !m->tool_call_id[0])
role = CST_ROLE_USER;
int has_img = m->attach && m->nattach > 0;
int n_att = m->attach ? m->nattach : 0;
int has_img = 0, has_doc = 0;
for (int k = 0; k < n_att; k++) {
if (cst_attach_is_image(&m->attach[k]))
has_img = 1;
else
has_doc = 1;
}
char *noted = NULL;
if (has_img && img_off) {
/* plain text: text-only APIs may refuse content arrays */
noted = image_notes(m, img_off);
if (noted)
mm.content = noted;
has_img = 0;
if (!has_doc) {
/* plain text: text-only APIs may refuse content arrays */
noted = image_notes(m, img_off);
if (noted)
mm.content = noted;
}
}
/* a document needs a content array even when no image can go out */
int att_parts = has_img || has_doc;
/* Every tool_call must be answered by tool messages, so tool-produced
* images only leave the queue when the run of tool results is over. */
@@ -431,7 +498,7 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON *pc = prev_user
? cJSON_GetObjectItemCaseSensitive(prev_user, "content")
: NULL;
if (!has_img && !cJSON_IsArray(pc)) {
if (!att_parts && !cJSON_IsArray(pc)) {
cJSON *c = clean_string(m->content, 0);
if (prev_user) {
cst_buf_t b = {0};
@@ -463,8 +530,8 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
}
cJSON *parts = content_parts(o);
parts_add_text(parts, clean_string(m->content, 0));
if (has_img)
parts_add_images(req, parts, m, &img_budget);
if (att_parts)
parts_add_attachments(req, parts, m, &img_budget, &doc_budget, img_off);
free(noted);
continue;
}
@@ -498,8 +565,8 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON_AddTrueToObject(o, "prefix");
}
cJSON_AddItemToArray(msgs, o);
if (role == CST_ROLE_TOOL && has_img)
defer_images(req, m, &pending, &img_budget);
if (role == CST_ROLE_TOOL && n_att)
defer_attachments(req, m, &pending, &img_budget, &doc_budget, img_off);
free(noted);
}
if (pending)
+25 -8
View File
@@ -26,6 +26,8 @@ static void usage(const char *argv0) {
" (last used)\n"
" -m, --model NAME model (last used for the provider)\n"
" -e, --effort LEVEL low | medium | high | max (last used)\n"
" -M, --mode NAME coding (default, code framed) | general\n"
" (documents, data, research; last used)\n"
" -w, --workspace DIR project directory (default: current)\n"
" -c, --continue reopen the last session (default)\n"
" -N, --new start a new session\n"
@@ -59,13 +61,13 @@ static void usage(const char *argv0) {
"API keys: the TUI asks for a missing key and saves it per provider\n"
" in the config dir (credentials, mode 0600); /key changes it.\n"
" Fallbacks: <provider env> (DEEPSEEK_API_KEY, ...), CST_API_KEY.\n"
"Settings: provider, models and effort are remembered in\n"
"Settings: provider, models, effort and mode are remembered in\n"
" settings.json next to the credentials.\n"
"Env: CST_PROVIDER CST_MODEL CST_EFFORT CST_PROTOCOL CST_TOOLS\n"
" CST_CONFIRM CST_MAX_STEPS CST_TOOL_OUT_CAP CST_CTX_WINDOW\n"
" CST_CTX_TOKENS CST_AUTO_COMPACT=0 CST_SHELL_TIMEOUT\n"
" CST_METRICS=0 CST_RECORD_SSE CST_REPLAY_SSE CST_MODELS\n"
" CST_APPROVAL=ask|auto|yolo CST_AUTO_APPROVE=1 (=yolo)\n"
"Env: CST_PROVIDER CST_MODEL CST_EFFORT CST_MODE CST_PROTOCOL\n"
" CST_TOOLS CST_CONFIRM CST_MAX_STEPS CST_TOOL_OUT_CAP\n"
" CST_CTX_WINDOW CST_CTX_TOKENS CST_AUTO_COMPACT=0\n"
" CST_SHELL_TIMEOUT CST_METRICS=0 CST_RECORD_SSE CST_REPLAY_SSE\n"
" CST_MODELS CST_APPROVAL=ask|auto|yolo CST_AUTO_APPROVE=1 (=yolo)\n"
" CST_SANDBOX=off CST_SHELL CST_NO_PROJECT_NOTES=1 NO_COLOR\n"
"TUI: type /help inside for commands and keys\n",
argv0);
@@ -374,6 +376,7 @@ int main(int argc, char **argv) {
{"approval", required_argument, 0, 'A'},
{"confirm", required_argument, 0, 'C'},
{"effort", required_argument, 0, 'e'},
{"mode", required_argument, 0, 'M'},
{"help", no_argument, 0, 'h'},
{"version", no_argument, 0, 'V'},
{"list-models", optional_argument, 0, 0x100},
@@ -385,8 +388,8 @@ int main(int argc, char **argv) {
{"web-open", no_argument, 0, 0x106},
{0, 0, 0, 0}};
int c;
while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:", opts, NULL)) !=
-1) {
while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:M:",
opts, NULL)) != -1) {
switch (c) {
case 'w':
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", optarg);
@@ -478,6 +481,14 @@ int main(int argc, char **argv) {
}
snprintf(cfg.effort, sizeof(cfg.effort), "%s", optarg);
break;
case 'M':
if (!cst_mode_valid(optarg)) {
fprintf(stderr, "bad --mode %s (coding | general)\n", optarg);
curl_global_cleanup();
return 1;
}
snprintf(cfg.mode, sizeof(cfg.mode), "%s", optarg);
break;
case 'h':
usage(argv[0]);
curl_global_cleanup();
@@ -557,6 +568,12 @@ int main(int argc, char **argv) {
snprintf(cfg.effort, sizeof(cfg.effort), "%s",
cst_effort_valid(e) ? e : CST_DEFAULT_EFFORT);
}
if (!cfg.mode[0]) {
const char *m = getenv("CST_MODE");
if (!cst_mode_valid(m))
m = saved.mode;
snprintf(cfg.mode, sizeof(cfg.mode), "%s", cst_mode_name(m));
}
if (!cfg.api_base[0]) {
const char *env_base = getenv("CST_API_BASE");
if (!env_base || !env_base[0])
+1901
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -452,7 +452,10 @@ char *cst_session_export_md(cst_session_t *s, const char *title,
for (int k = 0; k < m->nattach; k++) {
char d[128];
cst_attach_describe(&m->attach[k], d, sizeof(d));
cst_buf_printf(&b, "- image `%s` (%s)\n", m->attach[k].name, d);
cst_buf_printf(&b, "- %s `%s` (%s)\n",
cst_attach_is_image(&m->attach[k]) ? "image"
: "document",
m->attach[k].name, d);
}
if (m->nattach)
cst_buf_puts(&b, "\n");
+28
View File
@@ -47,6 +47,8 @@ int cst_settings_load(cst_settings_t *s) {
cJSON_GetObjectItemCaseSensitive(root, "provider"));
copy_str(s->effort, sizeof(s->effort),
cJSON_GetObjectItemCaseSensitive(root, "effort"));
copy_str(s->mode, sizeof(s->mode),
cJSON_GetObjectItemCaseSensitive(root, "mode"));
copy_str(s->approval, sizeof(s->approval),
cJSON_GetObjectItemCaseSensitive(root, "approval"));
s->show_thinking =
@@ -86,6 +88,8 @@ int cst_settings_save(const cst_settings_t *s) {
cJSON_AddStringToObject(root, "provider", s->provider);
if (s->effort[0])
cJSON_AddStringToObject(root, "effort", s->effort);
if (s->mode[0])
cJSON_AddStringToObject(root, "mode", s->mode);
if (s->approval[0])
cJSON_AddStringToObject(root, "approval", s->approval);
if (s->show_thinking)
@@ -135,6 +139,21 @@ int cst_approval_parse(const char *s, int *out) {
return -1;
}
static const char *const MODE_NAMES[] = {"coding", "general"};
int cst_mode_valid(const char *m) {
for (size_t i = 0; m && m[0] && i < sizeof(MODE_NAMES) / sizeof(*MODE_NAMES);
i++) {
if (strcmp(m, MODE_NAMES[i]) == 0)
return 1;
}
return 0;
}
const char *cst_mode_name(const char *m) {
return cst_mode_valid(m) ? m : CST_DEFAULT_MODE;
}
const char *cst_settings_model(const cst_settings_t *s, const char *provider) {
for (int i = 0; provider && i < s->nmodels; i++) {
if (strcmp(s->models[i].provider, provider) == 0 && s->models[i].model[0])
@@ -148,6 +167,8 @@ int cst_settings_remember(const cst_agent_config_t *cfg) {
cst_settings_load(&s);
snprintf(s.provider, sizeof(s.provider), "%s", cfg->provider);
snprintf(s.effort, sizeof(s.effort), "%s", cfg->effort);
if (cfg->mode[0])
snprintf(s.mode, sizeof(s.mode), "%s", cfg->mode);
int i = 0;
while (i < s.nmodels && strcmp(s.models[i].provider, cfg->provider) != 0)
i++;
@@ -176,6 +197,13 @@ int cst_settings_remember_thinking(int show_thinking) {
return cst_settings_save(&s);
}
int cst_settings_remember_mode(const char *mode) {
cst_settings_t s;
cst_settings_load(&s);
snprintf(s.mode, sizeof(s.mode), "%s", cst_mode_name(mode));
return cst_settings_save(&s);
}
static void real_ws(const char *ws, char *out, size_t n) {
if (!ws || os_realpath(ws, out, n) != 0)
snprintf(out, n, "%s", ws ? ws : "");
+5 -5
View File
@@ -40,7 +40,7 @@ static const struct {
"runs commands as needed.",
"", 0},
{"explore",
"Fast read-only explorer: searches and reads the codebase to answer a "
"Fast read-only explorer: searches and reads the workspace to answer a "
"question (where is X, how does Y work, which files do Z). Several can "
"run at once.",
"You only read. Search broadly first (grep, glob, list_dir), then read "
@@ -49,11 +49,11 @@ static const struct {
"you looked for and did not find.",
1},
{"plan",
"Read-only planner: investigates the code involved and returns a "
"Read-only planner: investigates the material involved and returns a "
"step-by-step implementation plan.",
"You only read. Investigate the code involved, then return a concrete "
"plan: the files to change and what to change in each, risks and edge "
"cases, and how to verify. Sketch code only where it removes ambiguity.",
"You only read. Investigate what is involved, then return a concrete "
"plan: what to produce or change and where, risks and edge cases, and "
"how to check the result. Sketch code only where it removes ambiguity.",
1},
};
#define NBUILTIN ((int)(sizeof(BUILTIN) / sizeof(BUILTIN[0])))
+68
View File
@@ -7,6 +7,7 @@
#include <string.h>
#include "cst_tools.h"
#include "cst_clipboard.h"
#include "cst_util.h"
#include "tools_internal.h"
@@ -106,3 +107,70 @@ int cst_tool_ask_user(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
1);
return tool_set_resultf(r, 0, "user answered: %s", ans);
}
/* The system clipboard: what the user copied elsewhere. Reading needs no
* permission (READ); writing replaces what the user has copied (WRITE, so
* the confirmation frontier applies). */
int cst_tool_clipboard_read(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r) {
(void)ctx;
char err[192];
char *text = NULL;
size_t n = 0;
if (cst_clipboard_text(&text, &n, err, sizeof(err)) != 0)
return tool_set_result(r, err, 1);
long cap = tool_arg_long(c, "max_bytes", 65536);
if (cap > 0 && (size_t)cap < n)
n = (size_t)cap;
cst_buf_t b = {0};
cst_buf_printf(&b, "clipboard text (%zu bytes%s):\n", (size_t)n,
(size_t)cap < (size_t)n ? ", truncated" : "");
cst_buf_append(&b, text, n);
if (b.len && b.buf[b.len - 1] != '\n')
cst_buf_puts(&b, "\n");
free(text);
return tool_take_buf(r, &b, 0);
}
int cst_tool_clipboard_write(const cst_tool_ctx_t *ctx,
const ctl_tool_call_t *c, cst_tool_result_t *r) {
(void)ctx;
size_t n = 0;
const char *text = tool_arg_n(c, "text", &n);
if (!text)
return tool_set_result(r, "error: missing text", 1);
char err[192];
if (cst_clipboard_set_text(text, n, err, sizeof(err)) != 0)
return tool_set_result(r, err, 1);
return tool_set_resultf(r, 0, "copied %zu bytes to the clipboard", n);
}
/* Confirmation preview: what the clipboard would hold afterwards. Clipboard
* text has no diff, so this is the text itself, cut to a readable size. */
#define CLIP_PREVIEW_MAX 2000
int cst_preview_clipboard_write(const cst_tool_ctx_t *ctx,
const ctl_tool_call_t *c, char **out) {
(void)ctx;
size_t n = 0;
const char *text = tool_arg_n(c, "text", &n);
if (!text) {
*out = strdup("error: missing text");
return -1;
}
int lines = 0;
for (size_t i = 0; i < n; i++)
lines += text[i] == '\n';
size_t cut = n;
if (cut > CLIP_PREVIEW_MAX)
cut = cst_utf8_floor(text, CLIP_PREVIEW_MAX);
cst_buf_t b = {0};
cst_buf_printf(&b, "clipboard: replace with %zu bytes, %d line%s\n", n,
lines + (n && text[n - 1] != '\n'), lines == 0 ? "" : "s");
cst_buf_append(&b, text, cut);
if (b.len && b.buf[b.len - 1] != '\n')
cst_buf_puts(&b, "\n");
if (cut < n)
cst_buf_printf(&b, "… (%zu more bytes)\n", n - cut);
*out = cst_buf_steal(&b);
return 0;
}
+8
View File
@@ -267,6 +267,14 @@ int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_attachment_t *a = cst_attach_import_file(session, full, err, sizeof(err));
if (!a)
return tool_set_resultf(r, 1, "error: cannot show %.200s: %s", path, err);
if (!cst_attach_is_image(a)) {
cst_attach_free(a);
return tool_set_resultf(
r, 1,
"error: %.200s is not an image; a text file or PDF reaches the model "
"through /attach <path> (a PDF becomes its text layer)",
path);
}
snprintf(a->origin, sizeof(a->origin), "view_image");
const char *d = tool_arg(c, "detail");
if (d && (strcmp(d, "low") == 0 || strcmp(d, "high") == 0))
+16 -2
View File
@@ -66,8 +66,9 @@ static const cst_tool_def_t TOOLS[] = {
{"detail", S, 0, "low or high (provider hint; default auto)"}},
CST_TP_READ, 0, cst_tool_view_image, NULL, NO_DYN},
{"run_shell",
"Run a non-interactive shell command in the workspace root for builds, "
"tests, git and other CLIs (stdin is empty; long runs are killed). "
"Run a non-interactive shell command in the workspace root: builds, "
"tests, git, scripts, computations, data processing, other CLIs "
"(stdin is empty; long runs are killed). "
"Returns stdout+stderr with colors stripped and long output cut to head "
"+ tail. Prefer read_file/grep/glob/list_dir over cat/grep/find.",
"Run a shell command in the workspace root (non-interactive, stdin is "
@@ -158,6 +159,19 @@ static const cst_tool_def_t TOOLS[] = {
{{"question", S, 1, "the question"},
{"options", S, 0, "suggested answers, one per line"}},
CST_TP_ASK, 0, cst_tool_ask_user, NULL, NO_DYN},
{"clipboard_read",
"Read the text on the system clipboard — what the user copied outside "
"the workspace. Returns UTF-8 text.",
NULL,
{{"max_bytes", I, 0, "cap on the returned text (default 65536)"}},
CST_TP_READ, 0, cst_tool_clipboard_read, NULL, NO_DYN},
{"clipboard_write",
"Replace the system clipboard with text, so the user can paste it into "
"another app. Overwrites what they had copied.",
NULL,
{{"text", S, 1, "text to put on the clipboard"}},
CST_TP_WRITE, 0, cst_tool_clipboard_write, cst_preview_clipboard_write,
NO_DYN},
};
#define NTOOLS ((int)(sizeof(TOOLS) / sizeof(TOOLS[0])))
+7
View File
@@ -46,6 +46,13 @@ int cst_tool_todo_write(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r);
int cst_tool_ask_user(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r);
/* Clipboard text (system clipboard, not the workspace). */
int cst_tool_clipboard_read(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r);
int cst_tool_clipboard_write(const cst_tool_ctx_t *ctx,
const ctl_tool_call_t *c, cst_tool_result_t *r);
int cst_preview_clipboard_write(const cst_tool_ctx_t *ctx,
const ctl_tool_call_t *c, char **out);
int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r);
int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
+109 -20
View File
@@ -2354,6 +2354,7 @@ static const char *const TIPS[] = {
"Ctrl+P opens the command palette",
"Type @ to mention a file in your message",
"/effort sets reasoning effort: low, medium, high, max",
"/mode general reframes the agent for non-code work",
"/model lists the models your API key can use",
"/undo reverts the file changes of the last turn",
"/provider switches provider; keys are saved per provider",
@@ -2832,7 +2833,10 @@ static void reload_hist_from_session(tui_t *t) {
for (int k = 0; k < m->nattach; k++) {
char d[128];
cst_attach_describe(&m->attach[k], d, sizeof(d));
cst_buf_printf(&ub, "\n[image %s · %s]", m->attach[k].name, d);
cst_buf_printf(&ub, "\n[%s %s · %s]",
cst_attach_is_image(&m->attach[k]) ? "image"
: "document",
m->attach[k].name, d);
}
block_set_body(b, ub.buf ? ub.buf : "", ub.len);
cst_buf_free(&ub);
@@ -4361,6 +4365,41 @@ static void cmd_effort(tui_t *t, char *arg, int *quit) {
cfg->effort, cfg->provider, cfg->model);
}
static void cmd_mode(tui_t *t, char *arg, int *quit) {
(void)quit;
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
static const char *const names[] = {"coding", "general"};
static const char *const desc[] = {
"code framed: repo, build, tests (default)",
"documents, data, research, everyday files"};
const char *want = NULL;
if (arg && arg[0]) {
if (!cst_mode_valid(arg)) {
push_system_line(t, "Unknown mode '%s' (coding | general)", arg);
return;
}
want = arg;
} else {
char items[2][128];
int cur_idx = 0;
for (int i = 0; i < 2; i++) {
snprintf(items[i], 128, "%-8s %s", names[i], desc[i]);
if (strcmp(names[i], cst_mode_name(cfg->mode)) == 0)
cur_idx = i;
}
int pick = picker_run(t, "Working mode", items, 2, cur_idx);
if (pick < 0)
return;
want = names[pick];
}
snprintf(cfg->mode, sizeof(cfg->mode), "%s", want);
cst_settings_remember_mode(cfg->mode);
push_system_line(t, "(mode → %s%s)", cfg->mode,
strcmp(cfg->mode, "general") == 0
? "; the system prompt is rewritten on the next turn"
: "");
}
static void cmd_provider(tui_t *t, char *arg, int *quit) {
(void)quit;
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
@@ -4639,14 +4678,15 @@ static void cmd_status(tui_t *t, char *arg, int *quit) {
: !same ? "this run only (--api-key / CST_API_KEY)"
: strcmp(from, "saved") == 0 ? "saved (/key to replace)"
: from);
push_system_line(t, " protocol %s · tools %s · max steps %d",
push_system_line(t, " protocol %s · tools %s · mode %s · max steps %d",
cst_protocol_name(cfg->protocol),
cst_tool_profile_name(cfg->tool_profile), cfg->max_steps);
cst_tool_profile_name(cfg->tool_profile),
cst_mode_name(cfg->mode), cfg->max_steps);
char appr[64];
approval_label(t, appr, sizeof(appr));
push_system_line(t, " approval %s — %s", appr,
approval_help(approval_mode(t)));
push_system_line(t, " mode %s",
push_system_line(t, " plan %s",
cfg->plan_mode ? "Plan (read-only tools)" : "Build");
const char *notes = cst_agent_notes(t->agent);
push_system_line(t, " notes %s",
@@ -4825,19 +4865,39 @@ static void report_attached(tui_t *t, int added) {
cst_agent_config(t->agent)->model);
}
/* /attach: stage images for the next user message. The bytes are copied into
* the session's attachment directory now, so a picture picked from /tmp keeps
* working after the file is gone. */
/* /attach paste: the clipboard image if there is one, else its text goes
* into the composer (CR dropped, control bytes kept out, INPUT_CAP respected). */
static int paste_text_into_input(tui_t *t, const char *text, size_t n) {
char *buf = malloc(n + 1);
if (!buf)
return 0;
size_t k = 0;
for (size_t i = 0; i < n && t->in_len + k + 1 < INPUT_CAP; i++) {
unsigned char c = (unsigned char)text[i];
if (c == '\r' || (c < 0x20 && c != '\n' && c != '\t'))
continue;
buf[k++] = (char)c;
}
if (k)
input_insert(t, buf, k);
free(buf);
return (int)k;
}
/* /attach: stage files for the next user message -- images, PDFs (reduced to
* their text) and text. The payload is copied into the session's attachment
* directory now, so a file picked from /tmp keeps working after it is gone. */
static void cmd_attach(tui_t *t, char *arg, int *quit) {
(void)quit;
int n = cst_agent_pending_count(t->agent);
if (!arg || !arg[0] || strcmp(arg, "list") == 0) {
if (n == 0) {
push_system_line(t, "No images picked. Usage: /attach <path>");
push_system_line(t, "Nothing attached yet. Usage: /attach <path>, "
"/attach paste, /attach list, /attach clear");
return;
}
push_system_line(t, "%d image%s with the next message (sent once):", n,
n > 1 ? "s" : "");
push_system_line(t, "%d attachment%s with the next message (sent once):",
n, n > 1 ? "s" : "");
for (int i = 0; i < n; i++) {
char line[256];
if (cst_agent_pending_describe(t->agent, i, line, sizeof(line)) == 0)
@@ -4848,7 +4908,7 @@ static void cmd_attach(tui_t *t, char *arg, int *quit) {
}
if (strcmp(arg, "clear") == 0 || strcmp(arg, "none") == 0) {
cst_agent_pending_clear(t->agent);
push_system_line(t, "Dropped the picked images.");
push_system_line(t, "Dropped the attachments.");
return;
}
char err[256];
@@ -4856,7 +4916,24 @@ static void cmd_attach(tui_t *t, char *arg, int *quit) {
char *img = NULL;
size_t len = 0;
if (cst_clipboard_image(&img, &len, err, sizeof(err)) != 0) {
push_system_line(t, "Error: %s", err);
/* no image: put the clipboard text in the composer instead */
char *text = NULL;
size_t tn = 0;
char terr[192];
if (cst_clipboard_text(&text, &tn, terr, sizeof(terr)) != 0) {
push_system_line(t, "Error: %s", terr);
return;
}
int added = paste_text_into_input(t, text, tn);
free(text);
if (added <= 0) {
push_system_line(t, "(no room in the composer for the clipboard "
"text)");
return;
}
push_system_line(t, "(pasted %d bytes of clipboard text into the "
"composer)", added);
layout_maybe_grow_input(t);
return;
}
char mime[32], name[32];
@@ -4881,19 +4958,29 @@ static void cmd_attach(tui_t *t, char *arg, int *quit) {
report_attached(t, 1);
}
static int file_is_image(const char *path) {
/* A file worth attaching: an image, a PDF, or something that reads as text.
* Deciding here in the TUI only avoids turning a random dropped binary into
* a pasted path; the store checks the content for real. */
static int file_is_attachable(const char *path) {
FILE *f = fopen(path, "rb");
if (!f)
return 0;
unsigned char head[16];
unsigned char head[64];
size_t got = fread(head, 1, sizeof(head), f);
fclose(f);
char mime[32];
return cst_attach_sniff(head, got, mime, sizeof(mime)) == 0;
if (cst_attach_sniff(head, got, mime, sizeof(mime)) == 0)
return 1;
if (got >= 4 && memcmp(head, "%PDF", 4) == 0)
return 1;
for (size_t i = 0; i < got; i++)
if (head[i] == 0 || head[i] < 0x09)
return 0;
return got > 0;
}
/* A paste that is nothing but image file paths (a file dropped on the
* terminal) attaches those files instead of typing the paths. */
/* A paste that is nothing but file paths (a file dropped on the terminal)
* attaches those files instead of typing the paths. */
static void paste_image_paths(tui_t *t) {
size_t from = t->paste_at, to = t->cur;
if (to <= from || to - from != t->in_len - t->paste_len0 ||
@@ -4905,7 +4992,7 @@ static void paste_image_paths(tui_t *t) {
if (n <= 0)
return;
for (int i = 0; i < n; i++)
if (!file_is_image(paths[i]))
if (!file_is_attachable(paths[i]))
return;
input_erase(t, from, to);
int added = 0;
@@ -5441,6 +5528,8 @@ static const slash_cmd_t SLASH[] = {
cmd_protocol},
{"tools", "", "[on|off NAME|minimal]", "list or toggle tools", 0,
cmd_tools},
{"mode", "", "[coding|general]", "coding or general (non-code) framing", 1,
cmd_mode},
{"permissions", "perms", "[reset]", "show saved always-allow rules", 1,
cmd_permissions},
{"sandbox", "", "[reset-labels]", "shell sandbox and labeled folders", 1,
@@ -5464,8 +5553,8 @@ static const slash_cmd_t SLASH[] = {
{"export", "", "[file]", "write the transcript as Markdown", 1,
cmd_export},
{"copy", "", "", "copy the last reply (OSC 52)", 1, cmd_copy},
{"attach", "image", "[path|paste|clear]",
"send image(s) with the next message",
{"attach", "image|file", "[path|paste|clear]",
"send files (image, PDF, text) or clipboard text with the next message",
1, cmd_attach},
{"init", "", "", "have the agent write AGENTS.md", 0, cmd_init},
{"details", "", "", "expand/collapse all tool output", 1, cmd_details},
+16
View File
@@ -263,6 +263,7 @@ static cJSON *wui_state_new(wui_t *w) {
cJSON_AddStringToObject(o, "provider", cfg->provider);
cJSON_AddStringToObject(o, "model", cfg->model);
cJSON_AddStringToObject(o, "effort", cfg->effort);
cJSON_AddStringToObject(o, "mode", cst_mode_name(cfg->mode));
cJSON_AddStringToObject(o, "approval", cst_approval_name(cfg->approval));
cJSON_AddStringToObject(o, "protocol_tools", cst_protocol_name(cfg->protocol));
cJSON_AddBoolToObject(o, "plan_mode", cfg->plan_mode);
@@ -1670,6 +1671,12 @@ static int h_settings_get(cst_http_conn_t *c, void *ud) {
cJSON *pr = cJSON_AddArrayToObject(o, "profiles");
for (int p = CST_TOOLS_DEFAULT; pr && p <= CST_TOOLS_MINIMAL; p++)
cJSON_AddItemToArray(pr, cJSON_CreateString(cst_tool_profile_name(p)));
cJSON_AddStringToObject(o, "mode", cst_mode_name(cfg->mode));
cJSON *md = cJSON_AddArrayToObject(o, "modes");
if (md) {
cJSON_AddItemToArray(md, cJSON_CreateString("coding"));
cJSON_AddItemToArray(md, cJSON_CreateString("general"));
}
cJSON *cf = cJSON_AddObjectToObject(o, "confirm");
if (cf) {
cJSON_AddBoolToObject(cf, "shell", cfg->confirm_shell);
@@ -1728,6 +1735,7 @@ static int h_settings_x(cst_http_conn_t *c, void *ud) {
const cJSON *proto = cJSON_GetObjectItem(j, "protocol");
const cJSON *steps = cJSON_GetObjectItem(j, "max_steps");
const cJSON *prof = cJSON_GetObjectItem(j, "tool_profile");
const cJSON *mode = cJSON_GetObjectItem(j, "mode");
const cJSON *tools = cJSON_GetObjectItem(j, "tools");
const cJSON *conf = cJSON_GetObjectItem(j, "confirm");
cst_protocol_t p = cfg->protocol;
@@ -1742,6 +1750,9 @@ static int h_settings_x(cst_http_conn_t *c, void *ud) {
else if (prof && (!cJSON_IsString(prof) ||
cst_tool_profile_parse(prof->valuestring, &profile) != 0))
snprintf(bad, sizeof(bad), "未知的工具集(default 或 minimal)");
else if (mode && (!cJSON_IsString(mode) ||
!cst_mode_valid(mode->valuestring)))
snprintf(bad, sizeof(bad), "工作模式只能是 coding / general");
else if (tools && !cJSON_IsObject(tools))
snprintf(bad, sizeof(bad), "tools 要是 {\"名字\": true/false}");
else if (conf && !cJSON_IsObject(conf))
@@ -1760,6 +1771,11 @@ static int h_settings_x(cst_http_conn_t *c, void *ud) {
}
cfg->protocol = p;
cfg->tool_profile = profile;
if (mode) {
snprintf(cfg->mode, sizeof(cfg->mode), "%s",
cst_mode_name(mode->valuestring));
cst_settings_remember_mode(cfg->mode);
}
if (steps)
cfg->max_steps = (int)steps->valuedouble;
for (const cJSON *t = tools ? tools->child : NULL; t; t = t->next)