// SPDX-License-Identifier: ISC/* * clm-tui -- an ncurses frontend for libclm. * * Design: libclm callbacks fire on the uv loop thread and MUST NOT block or do * heavy work (see the contract in clm/clm.h). So every callback here only * mutates a small UI model -- appending styled text segments to a queue and * updating status fields. A single uv_timer owns ALL drawing: it drains the * segment queue into a scrolling transcript window and repaints the status and * input lines. Terminal input is read through uv_poll on stdin, so ncurses * shares the caller's event loop rather than blocking in getch(). */#include <ctype.h>#include <errno.h>#include <limits.h>#include <locale.h>#include <stdio.h>#include <stdlib.h>#include <string.h>#include <sys/ioctl.h>#include <sys/queue.h>#include <unistd.h>#include <wchar.h> #include <curses.h>#include <cjson/cJSON.h>#include <uv.h> #include "clm/clm.h"#include "clm/cleanup.h"#include "clm/log.h"#include "clm/host_uv.h"#include "clm/lua_plugin.h"#include "clm/provider.h"#include "complete.h"#include "emoji.h"#include "frontend.h"#include "md_render.h"#include "mcp_setup.h"#include "model_spec.h"#include "tui_internal.h"#include "xdg.h" /* * Custom key codes for bracketed paste (xterm/most terminal emulators, * enabled via the "\033[?2004h" mode below): a paste is wrapped in * "\033[200~" ... "\033[201~" so we can tell "the user hit Enter" apart from * "the pasted text happened to contain a newline" -- without this, pasting * multi-line text submits one line at a time (each embedded newline reaching * the input loop looks exactly like a real Enter keypress) instead of * landing as a single multi-line prompt. Values are well above KEY_MAX * (0777) so they can't collide with any real curses key code. */#define UI_KEY_PASTE_START 0x1000#define UI_KEY_PASTE_END 0x1001 /* ---- UI model mutation (called from libclm callbacks; keep cheap) ---- */ voidui_push(struct ui *u, enum ui_style style, const char *text){ if (text == NULL || *text == '\0') return; /* A markdown reparse (gen bump) only makes sense at a line boundary -- * a partial trailing line ("**bol", an unfinished table row) must not be * rendered until it completes. So bump gen (triggering rebuild_render) * only when this chunk closes a line; otherwise just append and mark * dirty, and the last complete render stays on screen. Turn-end pushes a * trailing newline, which flushes the final line. */ bool has_nl = strchr(text, '\n') != NULL; /* Coalesce consecutive same-style pushes into one span. This keeps the * span count small under streaming (many tiny chunks) and, crucially, * rejoins a multibyte char that was split across two stream chunks. */ if (u->nsegs > 0 && u->segs[u->nsegs - 1].style == style) { char *old = u->segs[u->nsegs - 1].text; size_t oldlen = strlen(old); size_t addlen = strlen(text); char *p = realloc(old, oldlen + addlen + 1); if (p == NULL) return; memcpy(p + oldlen, text, addlen + 1); u->segs[u->nsegs - 1].text = p; if (has_nl) u->gen++; u->dirty = true; return; } if (u->nsegs == u->cap_segs) { size_t ncap = u->cap_segs ? u->cap_segs * 2 : 32; struct seg *p = realloc(u->segs, ncap * sizeof(*p)); if (p == NULL) return; /* drop the segment rather than crash */ u->segs = p; u->cap_segs = ncap; } char *dup = strdup(text); if (dup == NULL) return; u->segs[u->nsegs].style = style; u->segs[u->nsegs].text = dup; memset(u->segs[u->nsegs].cnt, 0, sizeof(u->segs[u->nsegs].cnt)); u->nsegs++; /* A new span of a different style closes the previous line's context; * bump gen so the switch is rendered even without an embedded newline. */ u->gen++; u->dirty = true;} static voidcb_mcp_status(const char *msg, void *user){ struct ui *u = user; char line[512]; if (msg == NULL) return; (void)snprintf(line, sizeof(line), "%s\n", msg); ui_push(u, ST_META, line);} static voidui_set_state(struct ui *u, enum clm_agent_state st){ u->state = st; u->dirty = true; if (st == CLM_STATE_RATE_LIMITED) u->busy = true;} static void drain_steering_queue(struct ui *u); /* injects at decision point */static void enqueue_steering(struct ui *u, const char *prompt);static bool do_submit(struct ui *u, const char *prompt, bool echo); /* ---- libclm callbacks ---- */ static voidcb_assistant_text(const char *text, void *user){ struct ui *u = user; clm_debug("[content] %.*s", (int)(strlen(text) > 200 ? 200 : strlen(text)), text); if (!u->started_assist) { ui_push(u, ST_LABEL, "\nclm>\n"); u->started_assist = true; } ui_push(u, ST_ASSIST, text);} static voidcb_reasoning(const char *text, void *user){ clm_debug("[reason] %.*s", (int)(strlen(text) > 200 ? 200 : strlen(text)), text); ui_push(user, ST_REASON, text);} /* Extract a string field from a JSON args object, or NULL. Caller must not * free the result; it is owned by (and valid for the life of) obj. */static const char *json_field(cJSON *obj, const char *key){ cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key); if (v == NULL || !cJSON_IsString(v)) return NULL; return cJSON_GetStringValue(v);} /* Push a one-line, human-readable summary of a tool call -- never raw JSON. * The detail is flattened (no newlines/tabs) and clamped so one call is one * row; an over-long detail gets an ellipsis. */static voidpush_tool_summary(struct ui *u, const char *verb, const char *detail){ char line[200]; size_t n = 0; size_t cap = sizeof(line) - 5; /* room for "...\n\0" */ line[n++] = ' '; line[n++] = ' '; for (const char *p = verb; *p != '\0' && n < cap; p++) line[n++] = *p; if (detail != NULL && *detail != '\0' && n < cap) { const char *p = detail; line[n++] = ':'; line[n++] = ' '; while (*p != '\0' && n < cap) { line[n++] = (*p == '\n' || *p == '\t') ? ' ' : *p; p++; } if (*p != '\0') { /* truncated */ line[n++] = '.'; line[n++] = '.'; line[n++] = '.'; } } line[n++] = '\n'; line[n] = '\0'; ui_push(u, ST_TOOL, line);} /* Render every field of a tool-call args object (except skip_key, if * non-NULL -- used to omit a field already shown elsewhere, e.g. * cdda_action's own "action" name shown as the verb) as * "key=value key2=value2 ...", clamped to a max length with a trailing * "..." if it would run longer -- a generic fallback so any tool * (including ones with no hand-written case in cb_tool_begin below) * still shows what it was actually called with, instead of a bare tool * name with no arguments visible at all. Values use * cJSON_PrintUnformatted for anything that isn't a plain string * (numbers, bools, nested objects/arrays all render reasonably that * way); string values are shown unquoted since the key= already makes * the field clear without JSON's quoting noise. Writes into buf * (caller-provided, of size bufsz) and returns it. */static char *render_tool_args(cJSON *obj, char *buf, size_t bufsz, const char *skip_key){ size_t n = 0; bool first = true; buf[0] = '\0'; if (obj == NULL || bufsz == 0) return buf; for (cJSON *val = obj->child; val != NULL; val = val->next) { const char *key = val->string; autofree char *printed = NULL; const char *vstr; int written; if (key == NULL) continue; if (skip_key != NULL && strcmp(key, skip_key) == 0) continue; if (cJSON_IsString(val)) { vstr = cJSON_GetStringValue(val); } else { printed = cJSON_PrintUnformatted(val); vstr = printed != NULL ? printed : ""; } written = snprintf(buf + n, bufsz - n, "%s%s=%s", first ? "" : " ", key, vstr); if (written < 0 || (size_t)written >= bufsz - n) { /* Would overflow -- truncate with an ellipsis rather * than a partially-written garbled field. */ const char *ell = "..."; size_t ell_len = strlen(ell); if (bufsz > ell_len) memcpy(buf + bufsz - 1 - ell_len, ell, ell_len + 1); return buf; } n += (size_t)written; first = false; } return buf;} static voidcb_tool_begin(const char *name, const char *args, void *user){ struct ui *u = user; cJSON *obj = args ? cJSON_Parse(args) : NULL; if (strcmp(name, "shell_exec") == 0) { push_tool_summary(u, "executing shell command", json_field(obj, "command")); u->n_cmd++; } else if (strcmp(name, "read_file") == 0) { push_tool_summary(u, "reading file", json_field(obj, "path")); u->n_read++; } else if (strcmp(name, "write_file") == 0) { push_tool_summary(u, "writing file", json_field(obj, "path")); u->n_write++; } else if (strcmp(name, "cdda_think") == 0) { push_tool_summary(u, "thinking", json_field(obj, "thought")); u->n_other++; } else if (strcmp(name, "cdda_action") == 0) { char buf[36]; /* 32 chars of content + room for "..." */ render_tool_args(obj, buf, sizeof(buf), "action"); char label[64]; snprintf(label, sizeof(label), "%s%s%s", json_field(obj, "action") ? json_field(obj, "action") : "?", strlen(buf) > 0 ? " " : "", buf); push_tool_summary(u, "action", label); u->n_other++; } else if (strcmp(name, "cdda_state") == 0) { push_tool_summary(u, "state", ""); u->n_other++; /* just a marker that the call happened -- the raw JSON * result itself is still not shown (see cb_tool_result * below), it's noisy and adds nothing a human wants to * watch scroll by. */ } else if (strcmp(name, "cdda_overmap") == 0) { char buf[32]; cJSON *jr = obj != NULL ? cJSON_GetObjectItemCaseSensitive(obj, "radius") : NULL; snprintf(buf, sizeof(buf), "radius=%d", jr != NULL ? (int)cJSON_GetNumberValue(jr) : 10); push_tool_summary(u, "overmap", buf); u->n_other++; } else if (strcmp(name, "cdda_examine") == 0) { char buf[48]; cJSON *jx = NULL, *jy = NULL, *jz = NULL; if (obj != NULL) { jx = cJSON_GetObjectItemCaseSensitive(obj, "x"); jy = cJSON_GetObjectItemCaseSensitive(obj, "y"); jz = cJSON_GetObjectItemCaseSensitive(obj, "z"); } snprintf(buf, sizeof(buf), "x=%d y=%d z=%d", jx != NULL ? (int)cJSON_GetNumberValue(jx) : 0, jy != NULL ? (int)cJSON_GetNumberValue(jy) : 0, jz != NULL ? (int)cJSON_GetNumberValue(jz) : 0); push_tool_summary(u, "examine", buf); u->n_other++; } else { char buf[36]; /* 32 chars of content + room for "..." */ render_tool_args(obj, buf, sizeof(buf), NULL); push_tool_summary(u, name, buf); u->n_other++; } cJSON_Delete(obj);} static voidcb_tool_result(const char *name, const char *content, enum clm_tool_outcome outcome, void *user){ struct ui *u = user; size_t clen; switch (outcome) { case CLM_TOOL_OK: /* cdda_* tool results are raw JSON state dumps -- the begin * summary already showed what matters (thought/action), so * skip echoing the body. Failures still show below. */ if (strcmp(name, "cdda_state") == 0 || strcmp(name, "cdda_action") == 0 || strcmp(name, "cdda_think") == 0 || strcmp(name, "cdda_examine") == 0) break; ui_push(u, ST_TOOL_OUT, content); /* Ensure exactly one trailing newline (don't double-count). */ clen = content ? strlen(content) : 0; if (clen == 0 || content[clen - 1] != '\n') ui_push(u, ST_TOOL_OUT, "\n"); break; case CLM_TOOL_FAILED: ui_push(u, ST_ERROR, "[x "); ui_push(u, ST_ERROR, name); ui_push(u, ST_ERROR, "] "); ui_push(u, ST_ERROR, content); ui_push(u, ST_ERROR, "\n"); break; case CLM_TOOL_TIMEDOUT: ui_push(u, ST_TIMEOUT, "[timeout "); ui_push(u, ST_TIMEOUT, name); ui_push(u, ST_TIMEOUT, "] "); ui_push(u, ST_TIMEOUT, content); ui_push(u, ST_TIMEOUT, "\n"); break; }} /* Shared "ran N commands, read N files, wrote N files, called N tools" tally * formatter behind both the per-batch summary line (push_batch_summary) and * the collapsed-older-clusters aggregate (push_collapsed_summary). Returns * false (line untouched) if every count is zero. */static boolformat_tool_tally(char *line, size_t cap, const int cnt[4], const char *suffix){ size_t n; int parts = 0; static const struct { const char *one, *many; } cats[] = { {"ran 1 command", "commands"}, {"read 1 file", "files"}, {"wrote 1 file", "files"}, {"called 1 tool", "tools"}, }; static const char *verb[] = {"ran", "read", "wrote", "called"}; if (cnt[0] + cnt[1] + cnt[2] + cnt[3] == 0) return false; n = (size_t)snprintf(line, cap, " -- "); for (size_t i = 0; i < 4; i++) { if (cnt[i] == 0) continue; if (parts++ > 0 && n < cap - 4) n += (size_t)snprintf(line + n, cap - n, ", "); if (cnt[i] == 1) n += (size_t)snprintf(line + n, cap - n, "%s", cats[i].one); else n += (size_t)snprintf(line + n, cap - n, "%s %d %s", verb[i], cnt[i], cats[i].many); } if (n < cap - 1) snprintf(line + n, cap - n, "%s", suffix); return true;} /* Emit "ran 2 commands, read 3 files, ..." from the batch tally, tagged * ST_BATCH (not ST_META) and carrying the raw counts on the segment so a * later, no-longer-latest cluster can still be folded into a combined * aggregate once a newer one takes its place (see rebuild_render). */static voidpush_batch_summary(struct ui *u){ char line[128]; int cnt[4] = {u->n_cmd, u->n_read, u->n_write, u->n_other}; if (!format_tool_tally(line, sizeof(line), cnt, "\n")) return; ui_push(u, ST_BATCH, line); if (u->nsegs > 0 && u->segs[u->nsegs - 1].style == ST_BATCH) { u->segs[u->nsegs - 1].cnt[0] = cnt[0]; u->segs[u->nsegs - 1].cnt[1] = cnt[1]; u->segs[u->nsegs - 1].cnt[2] = cnt[2]; u->segs[u->nsegs - 1].cnt[3] = cnt[3]; }} static voidcb_tool_batch(size_t completed, size_t total, void *user){ struct ui *u = user; if (completed == 0) { /* batch starting: reset the tally */ u->n_cmd = u->n_read = u->n_write = u->n_other = 0; } if (completed >= total) { /* batch done: emit the aggregate line */ push_batch_summary(u); u->batch[0] = '\0'; } else { snprintf(u->batch, sizeof(u->batch), "tools %zu/%zu", completed, total); } u->dirty = true;} static voidcb_finish_reason(enum clm_finish_reason reason, void *user){ struct ui *u = user; if (reason == CLM_FINISH_LENGTH) ui_push(u, ST_META, "\n[truncated: hit token limit]\n"); else if (reason == CLM_FINISH_CONTENT_FILTER) ui_push(u, ST_META, "\n[stopped: content filter]\n");} /* * Autocompact threshold checking itself lives in agent.c now * (clm_agent_over_autocompact_threshold), shared with * clm_agent_tools_done()'s own mid-chain check so both use the exact same * calc. tui.c's cb_usage below still needs to feed the agent a live usage * number for that shared check to have anything current to look at. */ static voidcb_usage(const struct clm_usage *usage, void *user){ struct ui *u = user; /* Tokens carried into the next turn's prompt; drives the context gauge * drawn in the status bar. */ u->ctx_used = (int64_t)usage->prompt_tokens + usage->completion_tokens; if (usage->tokens_per_sec > 0) snprintf(u->usage, sizeof(u->usage), "%.0f tok/s", usage->tokens_per_sec); else u->usage[0] = '\0'; u->dirty = true;} static voidcb_state(enum clm_agent_state st, void *user){ struct ui *u = user; /* A mid-chain autocompact (triggered internally between tool * batches, not by tui.c's own end-of-turn check further down) has * no on_turn_done to piggyback a message on -- the whole point is * that the interrupted tool chain resumes silently rather than * ending the turn. Every state transition passes through here * though, including the THINKING transition right after a mid-chain * resume, so poll for a just-failed attempt on every callback. */ if (clm_agent_take_mid_chain_compact_started(u->agent)) { ui_push(u, ST_META, "\n[context over threshold, auto-compacting...]\n"); u->dirty = true; } if (clm_agent_take_mid_chain_compact_succeeded(u->agent)) { ui_push(u, ST_META, "[autocompact complete, resuming]\n"); u->dirty = true; } if (clm_agent_take_mid_chain_compact_error(u->agent)) { ui_push(u, ST_META, "\n[autocompact failed, continuing anyway: "); ui_push(u, ST_META, clm_agent_get_last_error(u->agent));Lines 1–500 of 3249. Load more
// SPDX-License-Identifier: ISC /* * clm-tui -- an ncurses frontend for libclm. * * Design: libclm callbacks fire on the uv loop thread and MUST NOT block or do * heavy work (see the contract in clm/clm.h). So every callback here only * mutates a small UI model -- appending styled text segments to a queue and * updating status fields. A single uv_timer owns ALL drawing: it drains the * segment queue into a scrolling transcript window and repaints the status and * input lines. Terminal input is read through uv_poll on stdin, so ncurses * shares the caller's event loop rather than blocking in getch(). */ #include <ctype.h> #include <errno.h> #include <limits.h> #include <locale.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/ioctl.h> #include <sys/queue.h> #include <unistd.h> #include <wchar.h> #include <curses.h> #include <cjson/cJSON.h> #include <uv.h> #include "clm/clm.h" #include "clm/cleanup.h" #include "clm/log.h" #include "clm/host_uv.h" #include "clm/lua_plugin.h" #include "clm/provider.h" #include "complete.h" #include "emoji.h" #include "frontend.h" #include "md_render.h" #include "mcp_setup.h" #include "model_spec.h" #include "tui_internal.h" #include "xdg.h" /* * Custom key codes for bracketed paste (xterm/most terminal emulators, * enabled via the "\033[?2004h" mode below): a paste is wrapped in * "\033[200~" ... "\033[201~" so we can tell "the user hit Enter" apart from * "the pasted text happened to contain a newline" -- without this, pasting * multi-line text submits one line at a time (each embedded newline reaching * the input loop looks exactly like a real Enter keypress) instead of * landing as a single multi-line prompt. Values are well above KEY_MAX * (0777) so they can't collide with any real curses key code. */ #define UI_KEY_PASTE_START 0x1000 #define UI_KEY_PASTE_END 0x1001 /* ---- UI model mutation (called from libclm callbacks; keep cheap) ---- */ void ui_push(struct ui *u, enum ui_style style, const char *text) { if (text == NULL || *text == '\0') return; /* A markdown reparse (gen bump) only makes sense at a line boundary -- * a partial trailing line ("**bol", an unfinished table row) must not be * rendered until it completes. So bump gen (triggering rebuild_render) * only when this chunk closes a line; otherwise just append and mark * dirty, and the last complete render stays on screen. Turn-end pushes a * trailing newline, which flushes the final line. */ bool has_nl = strchr(text, '\n') != NULL; /* Coalesce consecutive same-style pushes into one span. This keeps the * span count small under streaming (many tiny chunks) and, crucially, * rejoins a multibyte char that was split across two stream chunks. */ if (u->nsegs > 0 && u->segs[u->nsegs - 1].style == style) { char *old = u->segs[u->nsegs - 1].text; size_t oldlen = strlen(old); size_t addlen = strlen(text); char *p = realloc(old, oldlen + addlen + 1); if (p == NULL) return; memcpy(p + oldlen, text, addlen + 1); u->segs[u->nsegs - 1].text = p; if (has_nl) u->gen++; u->dirty = true; return; } if (u->nsegs == u->cap_segs) { size_t ncap = u->cap_segs ? u->cap_segs * 2 : 32; struct seg *p = realloc(u->segs, ncap * sizeof(*p)); if (p == NULL) return; /* drop the segment rather than crash */ u->segs = p; u->cap_segs = ncap; } char *dup = strdup(text); if (dup == NULL) return; u->segs[u->nsegs].style = style; u->segs[u->nsegs].text = dup; memset(u->segs[u->nsegs].cnt, 0, sizeof(u->segs[u->nsegs].cnt)); u->nsegs++; /* A new span of a different style closes the previous line's context; * bump gen so the switch is rendered even without an embedded newline. */ u->gen++; u->dirty = true; } static void cb_mcp_status(const char *msg, void *user) { struct ui *u = user; char line[512]; if (msg == NULL) return; (void)snprintf(line, sizeof(line), "%s\n", msg); ui_push(u, ST_META, line); } static void ui_set_state(struct ui *u, enum clm_agent_state st) { u->state = st; u->dirty = true; if (st == CLM_STATE_RATE_LIMITED) u->busy = true; } static void drain_steering_queue(struct ui *u); /* injects at decision point */ static void enqueue_steering(struct ui *u, const char *prompt); static bool do_submit(struct ui *u, const char *prompt, bool echo); /* ---- libclm callbacks ---- */ static void cb_assistant_text(const char *text, void *user) { struct ui *u = user; clm_debug("[content] %.*s", (int)(strlen(text) > 200 ? 200 : strlen(text)), text); if (!u->started_assist) { ui_push(u, ST_LABEL, "\nclm>\n"); u->started_assist = true; } ui_push(u, ST_ASSIST, text); } static void cb_reasoning(const char *text, void *user) { clm_debug("[reason] %.*s", (int)(strlen(text) > 200 ? 200 : strlen(text)), text); ui_push(user, ST_REASON, text); } /* Extract a string field from a JSON args object, or NULL. Caller must not * free the result; it is owned by (and valid for the life of) obj. */ static const char * json_field(cJSON *obj, const char *key) { cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key); if (v == NULL || !cJSON_IsString(v)) return NULL; return cJSON_GetStringValue(v); } /* Push a one-line, human-readable summary of a tool call -- never raw JSON. * The detail is flattened (no newlines/tabs) and clamped so one call is one * row; an over-long detail gets an ellipsis. */ static void push_tool_summary(struct ui *u, const char *verb, const char *detail) { char line[200]; size_t n = 0; size_t cap = sizeof(line) - 5; /* room for "...\n\0" */ line[n++] = ' '; line[n++] = ' '; for (const char *p = verb; *p != '\0' && n < cap; p++) line[n++] = *p; if (detail != NULL && *detail != '\0' && n < cap) { const char *p = detail; line[n++] = ':'; line[n++] = ' '; while (*p != '\0' && n < cap) { line[n++] = (*p == '\n' || *p == '\t') ? ' ' : *p; p++; } if (*p != '\0') { /* truncated */ line[n++] = '.'; line[n++] = '.'; line[n++] = '.'; } } line[n++] = '\n'; line[n] = '\0'; ui_push(u, ST_TOOL, line); } /* Render every field of a tool-call args object (except skip_key, if * non-NULL -- used to omit a field already shown elsewhere, e.g. * cdda_action's own "action" name shown as the verb) as * "key=value key2=value2 ...", clamped to a max length with a trailing * "..." if it would run longer -- a generic fallback so any tool * (including ones with no hand-written case in cb_tool_begin below) * still shows what it was actually called with, instead of a bare tool * name with no arguments visible at all. Values use * cJSON_PrintUnformatted for anything that isn't a plain string * (numbers, bools, nested objects/arrays all render reasonably that * way); string values are shown unquoted since the key= already makes * the field clear without JSON's quoting noise. Writes into buf * (caller-provided, of size bufsz) and returns it. */ static char * render_tool_args(cJSON *obj, char *buf, size_t bufsz, const char *skip_key) { size_t n = 0; bool first = true; buf[0] = '\0'; if (obj == NULL || bufsz == 0) return buf; for (cJSON *val = obj->child; val != NULL; val = val->next) { const char *key = val->string; autofree char *printed = NULL; const char *vstr; int written; if (key == NULL) continue; if (skip_key != NULL && strcmp(key, skip_key) == 0) continue; if (cJSON_IsString(val)) { vstr = cJSON_GetStringValue(val); } else { printed = cJSON_PrintUnformatted(val); vstr = printed != NULL ? printed : ""; } written = snprintf(buf + n, bufsz - n, "%s%s=%s", first ? "" : " ", key, vstr); if (written < 0 || (size_t)written >= bufsz - n) { /* Would overflow -- truncate with an ellipsis rather * than a partially-written garbled field. */ const char *ell = "..."; size_t ell_len = strlen(ell); if (bufsz > ell_len) memcpy(buf + bufsz - 1 - ell_len, ell, ell_len + 1); return buf; } n += (size_t)written; first = false; } return buf; } static void cb_tool_begin(const char *name, const char *args, void *user) { struct ui *u = user; cJSON *obj = args ? cJSON_Parse(args) : NULL; if (strcmp(name, "shell_exec") == 0) { push_tool_summary(u, "executing shell command", json_field(obj, "command")); u->n_cmd++; } else if (strcmp(name, "read_file") == 0) { push_tool_summary(u, "reading file", json_field(obj, "path")); u->n_read++; } else if (strcmp(name, "write_file") == 0) { push_tool_summary(u, "writing file", json_field(obj, "path")); u->n_write++; } else if (strcmp(name, "cdda_think") == 0) { push_tool_summary(u, "thinking", json_field(obj, "thought")); u->n_other++; } else if (strcmp(name, "cdda_action") == 0) { char buf[36]; /* 32 chars of content + room for "..." */ render_tool_args(obj, buf, sizeof(buf), "action"); char label[64]; snprintf(label, sizeof(label), "%s%s%s", json_field(obj, "action") ? json_field(obj, "action") : "?", strlen(buf) > 0 ? " " : "", buf); push_tool_summary(u, "action", label); u->n_other++; } else if (strcmp(name, "cdda_state") == 0) { push_tool_summary(u, "state", ""); u->n_other++; /* just a marker that the call happened -- the raw JSON * result itself is still not shown (see cb_tool_result * below), it's noisy and adds nothing a human wants to * watch scroll by. */ } else if (strcmp(name, "cdda_overmap") == 0) { char buf[32]; cJSON *jr = obj != NULL ? cJSON_GetObjectItemCaseSensitive(obj, "radius") : NULL; snprintf(buf, sizeof(buf), "radius=%d", jr != NULL ? (int)cJSON_GetNumberValue(jr) : 10); push_tool_summary(u, "overmap", buf); u->n_other++; } else if (strcmp(name, "cdda_examine") == 0) { char buf[48]; cJSON *jx = NULL, *jy = NULL, *jz = NULL; if (obj != NULL) { jx = cJSON_GetObjectItemCaseSensitive(obj, "x"); jy = cJSON_GetObjectItemCaseSensitive(obj, "y"); jz = cJSON_GetObjectItemCaseSensitive(obj, "z"); } snprintf(buf, sizeof(buf), "x=%d y=%d z=%d", jx != NULL ? (int)cJSON_GetNumberValue(jx) : 0, jy != NULL ? (int)cJSON_GetNumberValue(jy) : 0, jz != NULL ? (int)cJSON_GetNumberValue(jz) : 0); push_tool_summary(u, "examine", buf); u->n_other++; } else { char buf[36]; /* 32 chars of content + room for "..." */ render_tool_args(obj, buf, sizeof(buf), NULL); push_tool_summary(u, name, buf); u->n_other++; } cJSON_Delete(obj); } static void cb_tool_result(const char *name, const char *content, enum clm_tool_outcome outcome, void *user) { struct ui *u = user; size_t clen; switch (outcome) { case CLM_TOOL_OK: /* cdda_* tool results are raw JSON state dumps -- the begin * summary already showed what matters (thought/action), so * skip echoing the body. Failures still show below. */ if (strcmp(name, "cdda_state") == 0 || strcmp(name, "cdda_action") == 0 || strcmp(name, "cdda_think") == 0 || strcmp(name, "cdda_examine") == 0) break; ui_push(u, ST_TOOL_OUT, content); /* Ensure exactly one trailing newline (don't double-count). */ clen = content ? strlen(content) : 0; if (clen == 0 || content[clen - 1] != '\n') ui_push(u, ST_TOOL_OUT, "\n"); break; case CLM_TOOL_FAILED: ui_push(u, ST_ERROR, "[x "); ui_push(u, ST_ERROR, name); ui_push(u, ST_ERROR, "] "); ui_push(u, ST_ERROR, content); ui_push(u, ST_ERROR, "\n"); break; case CLM_TOOL_TIMEDOUT: ui_push(u, ST_TIMEOUT, "[timeout "); ui_push(u, ST_TIMEOUT, name); ui_push(u, ST_TIMEOUT, "] "); ui_push(u, ST_TIMEOUT, content); ui_push(u, ST_TIMEOUT, "\n"); break; } } /* Shared "ran N commands, read N files, wrote N files, called N tools" tally * formatter behind both the per-batch summary line (push_batch_summary) and * the collapsed-older-clusters aggregate (push_collapsed_summary). Returns * false (line untouched) if every count is zero. */ static bool format_tool_tally(char *line, size_t cap, const int cnt[4], const char *suffix) { size_t n; int parts = 0; static const struct { const char *one, *many; } cats[] = { {"ran 1 command", "commands"}, {"read 1 file", "files"}, {"wrote 1 file", "files"}, {"called 1 tool", "tools"}, }; static const char *verb[] = {"ran", "read", "wrote", "called"}; if (cnt[0] + cnt[1] + cnt[2] + cnt[3] == 0) return false; n = (size_t)snprintf(line, cap, " -- "); for (size_t i = 0; i < 4; i++) { if (cnt[i] == 0) continue; if (parts++ > 0 && n < cap - 4) n += (size_t)snprintf(line + n, cap - n, ", "); if (cnt[i] == 1) n += (size_t)snprintf(line + n, cap - n, "%s", cats[i].one); else n += (size_t)snprintf(line + n, cap - n, "%s %d %s", verb[i], cnt[i], cats[i].many); } if (n < cap - 1) snprintf(line + n, cap - n, "%s", suffix); return true; } /* Emit "ran 2 commands, read 3 files, ..." from the batch tally, tagged * ST_BATCH (not ST_META) and carrying the raw counts on the segment so a * later, no-longer-latest cluster can still be folded into a combined * aggregate once a newer one takes its place (see rebuild_render). */ static void push_batch_summary(struct ui *u) { char line[128]; int cnt[4] = {u->n_cmd, u->n_read, u->n_write, u->n_other}; if (!format_tool_tally(line, sizeof(line), cnt, "\n")) return; ui_push(u, ST_BATCH, line); if (u->nsegs > 0 && u->segs[u->nsegs - 1].style == ST_BATCH) { u->segs[u->nsegs - 1].cnt[0] = cnt[0]; u->segs[u->nsegs - 1].cnt[1] = cnt[1]; u->segs[u->nsegs - 1].cnt[2] = cnt[2]; u->segs[u->nsegs - 1].cnt[3] = cnt[3]; } } static void cb_tool_batch(size_t completed, size_t total, void *user) { struct ui *u = user; if (completed == 0) { /* batch starting: reset the tally */ u->n_cmd = u->n_read = u->n_write = u->n_other = 0; } if (completed >= total) { /* batch done: emit the aggregate line */ push_batch_summary(u); u->batch[0] = '\0'; } else { snprintf(u->batch, sizeof(u->batch), "tools %zu/%zu", completed, total); } u->dirty = true; } static void cb_finish_reason(enum clm_finish_reason reason, void *user) { struct ui *u = user; if (reason == CLM_FINISH_LENGTH) ui_push(u, ST_META, "\n[truncated: hit token limit]\n"); else if (reason == CLM_FINISH_CONTENT_FILTER) ui_push(u, ST_META, "\n[stopped: content filter]\n"); } /* * Autocompact threshold checking itself lives in agent.c now * (clm_agent_over_autocompact_threshold), shared with * clm_agent_tools_done()'s own mid-chain check so both use the exact same * calc. tui.c's cb_usage below still needs to feed the agent a live usage * number for that shared check to have anything current to look at. */ static void cb_usage(const struct clm_usage *usage, void *user) { struct ui *u = user; /* Tokens carried into the next turn's prompt; drives the context gauge * drawn in the status bar. */ u->ctx_used = (int64_t)usage->prompt_tokens + usage->completion_tokens; if (usage->tokens_per_sec > 0) snprintf(u->usage, sizeof(u->usage), "%.0f tok/s", usage->tokens_per_sec); else u->usage[0] = '\0'; u->dirty = true; } static void cb_state(enum clm_agent_state st, void *user) { struct ui *u = user; /* A mid-chain autocompact (triggered internally between tool * batches, not by tui.c's own end-of-turn check further down) has * no on_turn_done to piggyback a message on -- the whole point is * that the interrupted tool chain resumes silently rather than * ending the turn. Every state transition passes through here * though, including the THINKING transition right after a mid-chain * resume, so poll for a just-failed attempt on every callback. */ if (clm_agent_take_mid_chain_compact_started(u->agent)) { ui_push(u, ST_META, "\n[context over threshold, auto-compacting...]\n"); u->dirty = true; } if (clm_agent_take_mid_chain_compact_succeeded(u->agent)) { ui_push(u, ST_META, "[autocompact complete, resuming]\n"); u->dirty = true; } if (clm_agent_take_mid_chain_compact_error(u->agent)) { ui_push(u, ST_META, "\n[autocompact failed, continuing anyway: "); ui_push(u, ST_META, clm_agent_get_last_error(u->agent));