3249 lines · 98.2 KB · C
RawDownload
// 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.