// 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #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)); ui_push(u, ST_META, "]\n"); u->dirty = true; } /* Check for mid-turn steering at decision points: after tool batch * completes, before/after LLM response, before tool calls. */ if (u->steering_nqueue > 0) { enum clm_agent_state prev = u->prev_state; /* Inject after tools finish (CALLING_TOOL→THINKING), after LLM * finishes thinking (THINKING→CALLING_TOOL or THINKING→COMPLETE). */ if ((prev == CLM_STATE_CALLING_TOOL && st == CLM_STATE_THINKING) || (prev == CLM_STATE_THINKING && (st == CLM_STATE_CALLING_TOOL || st == CLM_STATE_COMPLETE))) drain_steering_queue(u); } u->prev_state = st; ui_set_state(user, st); } static void cb_connection(enum clm_conn_status status, const char *detail, void *user) { struct ui *u = user; u->conn = status; if (detail != NULL) snprintf(u->conn_detail, sizeof(u->conn_detail), "%s", detail); else u->conn_detail[0] = '\0'; u->dirty = true; } static void cb_turn_done(int status, void *user) { struct ui *u = user; bool was_compacting = u->autocompacting; u->autocompacting = false; /* -ECANCELED is a user Escape; we already showed "[cancelled]". A * failed autocompact attempt (was_compacting, any other status) is * handled below -- don't show the generic error banner for it or * re-probe the connection, since a compaction failure (e.g. the * compact request itself timing/erroring out, plausible given it * ships the entire pre-compaction history as one extra LLM call) * isn't a sign the backend is actually down, just that this one * shrink attempt didn't work. */ if (status != 0 && status != -ECANCELED && !was_compacting) { ui_push(u, ST_ERROR, "\nerror: "); ui_push(u, ST_ERROR, clm_agent_get_last_error(u->agent)); /* A failed turn may mean the server went away; re-probe. */ clm_agent_check_connection(u->agent); } else if (was_compacting && status != 0 && status != -ECANCELED) { ui_push(u, ST_META, "\n[autocompact failed, continuing anyway: "); ui_push(u, ST_META, clm_agent_get_last_error(u->agent)); ui_push(u, ST_META, "]\n"); } ui_push(u, ST_NORMAL, "\n"); u->busy = false; u->perm_count = 0; /* no prompt outlives its turn */ u->perm_showing = false; u->batch[0] = '\0'; u->dirty = true; /* * Force a compaction here, before draining the queue or resubmitting * --forever, if usage crossed the threshold on the turn that just * finished. * * This is NOT truly mid-turn -- clm_agent_compact()/clm_agent_cancel() * only have anything to act on while an HTTP request or a tool batch * is actually in flight (checked: emit_usage() fires synchronously * right after a response lands and before the next tool dispatch or * LLM call in the same chain, so there's no live "inflight"/ * "active_batch" at that instant for cancel to grab). A single * cdda-style prompt can still chain many tool-calling round-trips * before ever reaching this callback, so an agent stuck in a long * chain can overshoot the threshold before this ever runs. Catching * that earlier would need a hook inside clm_agent_tools_done() (see * agent.c) between a tool batch finishing and the next LLM call * firing -- a real library change, not a tui.c-only hack -- so this * is the honest, working version for now; the harder mid-chain * version is a real follow-up, not implemented here. * * Skipped entirely if this call is itself compaction's own * completion (was_compacting) -- whether it succeeded or failed, * don't immediately try to compact again from in here; either the * context already shrank (success) or a retry belongs on some later * turn once real usage is known again (failure), not stacked * directly on top of the failure in the same callback. */ if (!was_compacting && (status == 0 || status == -ECANCELED) && clm_agent_over_autocompact_threshold(u->agent)) { int rc = clm_agent_compact(u->agent); if (rc == 0) { u->busy = true; u->autocompacting = true; /* Compaction's own completion re-enters this same * callback (see compact_success_cb/compact_error_cb * in agent.c, both of which call cb_on_turn_done) * before any real usage figure for the shrunk (or * still-oversized, on failure) context is known -- * clear the stale pre-compaction ctx_used now so * that re-entry doesn't see the same over-threshold * reading and try to compact again immediately. It'll * be replaced with a real number on the next actual * LLM call either way. */ u->ctx_used = 0; ui_push(u, ST_META, "\n[context over threshold, auto-compacting...]\n"); return; /* re-enters this function (see above) with * was_compacting true, which then falls * through below to drain the queue / resubmit * regardless of whether compaction itself * succeeded or failed. */ } /* -EBUSY shouldn't happen here (state is idle), and any other * failure just means we fall through and keep going without * having shrunk the context -- not fatal, just try again * next turn. */ } /* --forever: keep the agent going instead of idling for human input. * Skip on a real error so a broken backend/tool doesn't spin forever * -- UNLESS that "error" was just a failed autocompact attempt * (was_compacting), in which case the original turn before it already * completed successfully and --forever should keep going regardless; * only a failure of the agent's actual conversational turn should stop * the loop. */ if (!u->busy && u->forever_prompt != NULL && (status == 0 || status == -ECANCELED || was_compacting)) do_submit(u, u->forever_prompt, true); } /* * Render tool-call args for the permission prompt without the JSON envelope: * a single string arg shows as just its value (e.g. shell_exec's command); * multiple args show as "key: value" pairs. Falls back to the raw string if * it is not a JSON object. */ static void push_perm_args(struct ui *u, const char *args) { cJSON *obj; bool first = true; if (args == NULL || args[0] == '\0' || strcmp(args, "{}") == 0) return; obj = cJSON_Parse(args); if (obj == NULL || !cJSON_IsObject(obj)) { ui_push(u, ST_PERM, ": "); ui_push(u, ST_PERM, args); /* not an object: show as-is */ if (obj != NULL) cJSON_Delete(obj); return; } ui_push(u, ST_PERM, ": "); { int n = cJSON_GetArraySize(obj); /* member count works for objects too */ for (cJSON *v = obj->child; v != NULL; v = v->next) { autofree char *printed = NULL; if (!first) ui_push(u, ST_PERM, " "); first = false; /* For a lone arg, the key is noise; show only the value. */ if (n > 1 && v->string != NULL) { ui_push(u, ST_PERM, v->string); ui_push(u, ST_PERM, ": "); } if (cJSON_IsString(v)) { ui_push(u, ST_PERM, cJSON_GetStringValue(v)); } else { printed = cJSON_PrintUnformatted(v); ui_push(u, ST_PERM, printed != NULL ? printed : ""); } } } cJSON_Delete(obj); } /* * Permission handler: render an orange prompt showing the tool and its args, * then park in permission-input mode. handle_keys routes the next y/n/a/d key * (or Escape) to answer via clm_tool_permission_respond. */ /* * Show the next permission prompt from the queue (if any). */ static void show_next_perm(struct ui *u) { const struct clm_permission_req *req; const char *name, *args; if (u->perm_count == 0) { u->perm_showing = false; return; } req = u->perm_queue[0]; name = clm_permission_req_name(req); args = clm_permission_req_args(req); u->perm_showing = true; ui_push(u, ST_PERM, "\nallow tool "); ui_push(u, ST_PERM, name ? name : "?"); push_perm_args(u, args); ui_push(u, ST_PERM, "\n(y) once (n) deny (a) always (d) never [esc = deny+cancel]\n"); u->dirty = true; } static void cb_permission(const struct clm_permission_req *req, void *user) { struct ui *u = user; /* Enqueue the request. */ if (u->perm_count >= u->perm_cap) { size_t ncap = u->perm_cap ? u->perm_cap * 2 : 16; const struct clm_permission_req **nq = realloc(u->perm_queue, ncap * sizeof(*nq)); if (nq == NULL) return; /* drop on OOM; tool will time out */ u->perm_queue = nq; u->perm_cap = ncap; } u->perm_queue[u->perm_count++] = req; /* If no prompt is currently showing, display this one. */ if (!u->perm_showing) show_next_perm(u); } static void cb_notice(const char *text, void *user) { struct ui *u = user; ui_push(u, ST_META, "\n["); ui_push(u, ST_META, text ? text : "notice"); ui_push(u, ST_META, "]\n"); u->dirty = true; } /* * Mirror every appended history message into the session log. System * messages are skipped: the prologue is rebuilt from config on every start, * so persisting it would only resurrect a stale prompt on resume. */ static void cb_message(const struct clm_message *msg, void *user) { struct ui *u = user; int r; if (u->session == NULL || msg->role == CLM_ROLE_SYSTEM) return; r = clm_session_append(u->session, msg, NULL); if (r < 0) { /* Warn once and stop logging rather than spamming a warning * per message on e.g. a full disk. */ ui_push(u, ST_META, "\n[session logging failed; disabled]\n"); clm_session_free(u->session); u->session = NULL; u->dirty = true; } } static const struct clm_callbacks tui_callbacks = { .on_assistant_text = cb_assistant_text, .on_reasoning = cb_reasoning, .on_tool_begin = cb_tool_begin, .on_permission = cb_permission, .on_tool_result = cb_tool_result, .on_tool_batch = cb_tool_batch, .on_finish_reason = cb_finish_reason, .on_usage = cb_usage, .on_connection = cb_connection, .on_state = cb_state, .on_turn_done = cb_turn_done, .on_notice = cb_notice, .on_message = cb_message, }; /* ---- rendering (all drawing happens here, from the repaint timer) ---- */ static int seg_attr(enum ui_style style) { switch (style) { case ST_USER: return COLOR_PAIR(1); case ST_ASSIST: return A_NORMAL; case ST_LABEL: return COLOR_PAIR(8); case ST_PERM: return COLOR_PAIR(9); case ST_REASON: return COLOR_PAIR(6); case ST_TOOL: return COLOR_PAIR(2); case ST_TOOL_OUT: return A_DIM; case ST_ERROR: return COLOR_PAIR(3); case ST_TIMEOUT: return COLOR_PAIR(4); case ST_META: case ST_BATCH: return A_DIM; case ST_NORMAL: break; } return A_NORMAL; } static const char * state_label(enum clm_agent_state st) { switch (st) { case CLM_STATE_IDLE: return "idle"; case CLM_STATE_THINKING: return "thinking"; case CLM_STATE_CALLING_TOOL: return "tool"; case CLM_STATE_RATE_LIMITED: return "waiting"; /* animated with dots in draw_status directly */ case CLM_STATE_COMPLETE: return "done"; case CLM_STATE_ERROR: return "error"; } return "?"; } /* * Fill a buffer with a block-glyph context gauge like "[████░░░░] 40%" from * the tokens carried forward vs the backend's context window. Writes an empty * string when the window is unknown (non-llama.cpp, or /props not yet seen). * GAUGE_CELLS filled/empty cells use U+2588 / U+2591. */ #define GAUGE_CELLS 10 static void fmt_ctx_gauge(struct ui *u, char *buf, size_t len) { int64_t ctx_max = clm_agent_get_ctx_max(u->agent); int filled, pct; size_t off = 0; if (buf == NULL || len == 0) return; buf[0] = '\0'; if (ctx_max <= 0 || u->ctx_used <= 0) return; pct = (int)((u->ctx_used * 100) / ctx_max); if (pct > 100) pct = 100; filled = (pct * GAUGE_CELLS) / 100; off += (size_t)snprintf(buf + off, len - off, "["); for (int i = 0; i < GAUGE_CELLS && off < len; i++) off += (size_t)snprintf(buf + off, len - off, "%s", i < filled ? "\u2588" : "\u2591"); if (off < len) (void)snprintf(buf + off, len - off, "] %d%%", pct); } static void draw_status(struct ui *u) { static const char spin[] = "|/-\\"; const char *info; char sp = u->busy ? spin[u->spinner & 3] : ' '; werase(u->stat); wbkgd(u->stat, COLOR_PAIR(5)); wattron(u->stat, COLOR_PAIR(5)); /* Live status sits on the left, next to the model, so the eye doesn't * have to travel across the width of the terminal to read it. */ /* Rate-limit wait wins over the leftover batch/usage strings: while * the turn is parked nothing else is in flight, and a stale "N tok/s" * would hide the only signal that explains the silence. */ if (u->state == CLM_STATE_RATE_LIMITED) { static const char *dots[] = {"waiting.", "waiting..", "waiting..."}; info = dots[((unsigned)u->spinner / 4) % 3]; } else if (u->batch[0] != '\0') info = u->batch; else if (u->usage[0] != '\0') info = u->usage; else info = state_label(u->state); mvwprintw(u->stat, 0, 0, " clm"); /* One combined "[provider/model:agent]" tag rather than three separate * bracketed fields -- picking a provider/model without changing agent * profile is the common case (see /model, /provider), so keeping them * visually grouped reads better than agent_name winning outright the * way the old agent-name-or-model fallback did. '/' between * provider and model matches the "provider/model-id" addressing * spec /model, -m/--model, and config.lua's top-level `model` key * all take (see src/model_spec.h); ':' before agent since the agent * profile is a separate axis, not part of that address. Any part * not currently known (e.g. no config.lua providers[] entry behind * a literal model switch) is just omitted, not shown as an empty * field. */ { char tag[160]; size_t off = 0; bool any = false; tag[0] = '\0'; if (u->provider_name != NULL) { off += (size_t)snprintf(tag + off, sizeof(tag) - off, "%s", u->provider_name); any = true; } if (u->model != NULL) { off += (size_t)snprintf(tag + off, sizeof(tag) - off, "%s%s", any ? "/" : "", u->model); any = true; } if (u->agent_name != NULL) { off += (size_t)snprintf(tag + off, sizeof(tag) - off, "%s%s", any ? ":" : "", u->agent_name); any = true; } if (any) wprintw(u->stat, " [%s]", tag); } switch (u->conn) { case CLM_CONN_CHECKING: wprintw(u->stat, " [connecting]"); break; case CLM_CONN_ONLINE: wprintw(u->stat, " [online]"); break; case CLM_CONN_OFFLINE: if (u->conn_detail[0] != '\0') wprintw(u->stat, " [offline: %s]", u->conn_detail); else wprintw(u->stat, " [offline]"); break; } /* Fixed width so the animated waiting dots don't shift the gauge. */ wprintw(u->stat, " %c %-14s", sp, info); /* Context-window gauge, when the backend reports a window size. */ { char gauge[64]; fmt_ctx_gauge(u, gauge, sizeof(gauge)); if (gauge[0] != '\0') wprintw(u->stat, " %s ", gauge); } /* Right-aligned key hints, shown only when they fit without colliding * with the live status on the left. */ { static const char hints[] = "^R reasoning ^O output ^L redraw PgUp/PgDn/End scroll "; int w = getmaxx(u->stat); int used = getcurx(u->stat); int hw = (int)(sizeof(hints) - 1); if (w - hw > used + 2) mvwprintw(u->stat, 0, w - hw, "%s", hints); } wattroff(u->stat, COLOR_PAIR(5)); wnoutrefresh(u->stat); } /* Display columns occupied by the first n bytes of a UTF-8 string. */ static int disp_width(const char *s, size_t n) { mbstate_t ps; size_t i = 0; int cols = 0; memset(&ps, 0, sizeof(ps)); while (i < n) { wchar_t wc; size_t k = mbrtowc(&wc, s + i, n - i, &ps); if (k == (size_t)-1 || k == (size_t)-2 || k == 0) { i++; /* invalid/incomplete: count as one column */ cols++; continue; } int w = wcwidth(wc); cols += (w > 0) ? w : 1; i += k; } return cols; } /* * Rows the input box needs to show all its text. Unlike a plain * disp_width()/w division, this walks the text and treats '\n' as a hard * line break rather than just another (near-zero-width) character -- a * bracketed paste can land literal newlines in u->input (see * UI_KEY_PASTE_START), and a short multi-line paste's *total* display width * can easily be smaller than one row, which would make the width-only * calculation say "1 row" even though the text is actually three lines. */ static int input_rows(struct ui *u, int w) { mbstate_t ps; size_t i = 0; int rows = 1; int col = 2; /* "> " prefix */ if (w < 1) w = 1; memset(&ps, 0, sizeof(ps)); while (i < u->input_len) { if (u->input[i] == '\n') { rows++; col = 0; i++; continue; } wchar_t wc; size_t k = mbrtowc(&wc, u->input + i, u->input_len - i, &ps); int cw = 1; if (k == (size_t)-1 || k == (size_t)-2 || k == 0) { k = 1; /* invalid/incomplete byte: one column, advance one byte */ } else { cw = wcwidth(wc); if (cw < 0) cw = 1; } col += cw; if (col >= w) { rows++; col = cw; } i += k; } return rows; } static void draw_input(struct ui *u) { int w = getmaxx(u->in); int h = getmaxy(u->in); int off, cy, cx; werase(u->in); wmove(u->in, 0, 0); waddstr(u->in, "> "); /* Let curses wrap the text across the box's rows. */ waddnstr(u->in, u->input, (int)u->input_len); off = 2 + disp_width(u->input, u->input_pos); cy = off / w; cx = off % w; if (cy > h - 1) { /* box capped and scrolled: pin cursor to last row */ cy = h - 1; cx = w - 1; } wmove(u->in, cy, cx); wnoutrefresh(u->in); } /* * Recompute the input box height from its contents and re-tile the screen: * the transcript keeps the top, the status bar sits just above the box, and * the box occupies the bottom, growing upward. Returns true if geometry * changed (so the caller can force a full redraw). force relays out even when * the height is unchanged (used after a terminal resize). */ static bool relayout(struct ui *u, bool force) { int h = LINES, w = COLS; int need, maxih, txt_h; if (h < 3) h = 3; maxih = h / 2; if (maxih < 1) maxih = 1; if (maxih > h - 2) maxih = h - 2; if (maxih < 1) maxih = 1; need = input_rows(u, w); if (need > maxih) need = maxih; if (need < 1) need = 1; if (!force && need == u->in_h) return false; u->in_h = need; txt_h = h - 1 - need; if (txt_h < 1) txt_h = 1; /* The transcript window holds scrollback content, so only resize it in * place (origin stays 0,0). The status/input windows carry no state, so * recreate them at the new positions -- simpler and always valid. */ wresize(u->txt, txt_h, w); delwin(u->stat); delwin(u->in); u->stat = newwin(1, w, txt_h, 0); u->in = newwin(need, w, txt_h + 1, 0); scrollok(u->in, TRUE); keypad(u->in, TRUE); nodelay(u->in, TRUE); return true; } /* Append a rendered run to the cache, copying len bytes of text. */ static void rseg_push(struct ui *u, int attr, const char *text, size_t len) { char *dup; if (len == 0) return; if (u->nrsegs == u->cap_rsegs) { size_t ncap = u->cap_rsegs ? u->cap_rsegs * 2 : 32; struct rseg *p = realloc(u->rsegs, ncap * sizeof(*p)); if (p == NULL) return; u->rsegs = p; u->cap_rsegs = ncap; } dup = malloc(len + 1); if (dup == NULL) return; memcpy(dup, text, len); dup[len] = '\0'; u->rsegs[u->nrsegs].attr = attr; u->rsegs[u->nrsegs].text = dup; u->nrsegs++; } /* * Markdown rendering. * * The md_render module (src/md_render.c) parses markdown into abstract styled * runs; here we map those abstract styles to curses attributes. * * Colour model: element styles are applied uniformly across streams -- code is * green (COLOR_PAIR 7) whether inline or block, in the answer or the think * channel. A stream contributes a "floor" via base_attr: the assistant stream * is A_NORMAL, the reasoning channel is COLOR_PAIR(6) (Solarized base01, a * muted secondary tone). An element with its own colour (code) overrides the * floor's colour pair. On a Solarized 16-colour palette A_BOLD promotes an * accent colour to its grey bright-slot twin, so the reasoning stream sets * no_bold to keep emphasis from greying/brightening its muted text. */ struct md_sink { struct ui *u; unsigned base_attr; bool no_bold; /* drop A_BOLD (reasoning stream: bold would promote) */ }; /* Non-colour emphasis attributes for a run's abstract style. */ static unsigned md_style_to_attr(unsigned style) { unsigned a = 0; if (style & MD_ST_BOLD) a |= (unsigned)A_BOLD; if (style & MD_ST_ITALIC) a |= (unsigned)A_ITALIC; if (style & MD_ST_UNDERLINE) a |= (unsigned)A_UNDERLINE; if (style & MD_ST_STRIKE) a |= (unsigned)A_DIM; return a; } static void md_emit(const struct md_run *run, void *userdata) { struct md_sink *s = userdata; unsigned attr = s->base_attr | md_style_to_attr(run->style); /* Code carries its own colour, overriding the stream's default pair. */ if (run->style & MD_ST_CODE) attr = (attr & ~(unsigned)A_COLOR) | (unsigned)COLOR_PAIR(7); if (s->no_bold) attr &= ~(unsigned)A_BOLD; rseg_push(s->u, (int)attr, run->text, run->len); } /* * Render one markdown span into rendered runs, OR-ing base_attr into every run * (e.g. the reasoning colour for the think channel). no_bold drops emphasis * bold (used by the reasoning stream, where bold would promote its muted * colour to a grey bright slot). Width feeds table layout; soft-wrapping of the * emitted runs is still done later by wrap_walk. */ static void render_markdown(struct ui *u, const char *md, int w, int base_attr, bool no_bold) { struct md_sink sink = { .u = u, .base_attr = (unsigned)base_attr, .no_bold = no_bold, }; struct md_opts opts = {.width = w, .tables = MD_TABLE_AUTO}; md_render(md, strlen(md), &opts, md_emit, &sink); } /* Number of newline-delimited lines in text (a trailing partial line counts). */ static int count_lines(const char *s, size_t len) { int lines = 0; for (size_t i = 0; i < len; i++) if (s[i] == '\n') lines++; if (len > 0 && s[len - 1] != '\n') lines++; return lines; } /* Emit a tool-output body: full when expanded, else the first few lines plus a * dim "+N lines" hint the user can toggle open with ^O. Older tool output * (is_latest false) collapses further still, down to a single hint line with * no body at all -- once a call is no longer the most recent one, its output * is rarely what the user is looking at, and letting every call in a long * session leave ~8 lines behind makes the transcript unscannable. ^O * (u->expand_output) overrides this for every call, latest or not. */ static void push_tool_output(struct ui *u, const char *text, bool is_latest) { enum { PREVIEW = 6 }; size_t len = strlen(text); int total = count_lines(text, len); size_t cut; int seen; char hint[64]; if (u->expand_output || (is_latest && total <= PREVIEW)) { rseg_push(u, seg_attr(ST_TOOL_OUT), text, len); return; } if (!is_latest) { snprintf(hint, sizeof(hint), " ... (%d lines, ^O to expand)\n", total); rseg_push(u, seg_attr(ST_TOOL_OUT), hint, strlen(hint)); return; } cut = 0; seen = 0; while (cut < len && seen < PREVIEW) { if (text[cut] == '\n') seen++; cut++; } rseg_push(u, seg_attr(ST_TOOL_OUT), text, cut); snprintf(hint, sizeof(hint), " ... (+%d lines, ^O to expand)\n", total - PREVIEW); rseg_push(u, seg_attr(ST_TOOL_OUT), hint, strlen(hint)); } /* Render one combined "-- ran N commands, read N files, ..." line summarizing * every tool-call cluster older than the most recent one, in place of their * individual "executing ..."/output/tally lines. ^O (u->expand_output) * bypasses this in rebuild_render, so this is only ever reached collapsed. */ static void push_collapsed_summary(struct ui *u, const int cnt[4]) { char line[160]; if (format_tool_tally(line, sizeof(line), cnt, " (^O to expand)\n")) rseg_push(u, seg_attr(ST_META), line, strlen(line)); } /* Resolve the source span list into the rendered run cache for width w. */ static void rebuild_render(struct ui *u, int w) { size_t last_tool_out = (size_t)-1; size_t last_batch = (size_t)-1, collapse_end = (size_t)-1; int agg[4] = {0, 0, 0, 0}; bool aggregating = false; for (size_t i = 0; i < u->nrsegs; i++) free(u->rsegs[i].text); u->nrsegs = 0; u->wrap_valid = false; /* rsegs are about to change shape */ /* Find the most recent ST_TOOL_OUT segment so push_tool_output can * leave only it at full preview depth and collapse the rest. */ for (size_t i = u->nsegs; i > 0; i--) { if (u->segs[i - 1].style == ST_TOOL_OUT) { last_tool_out = i - 1; break; } } /* Find the two most recent ST_BATCH segments. Everything up to and * including the second-to-last one (collapse_end) belongs to a * completed tool-call cluster older than the latest one, and gets * folded into a single aggregate below instead of rendered * individually. Skipped entirely (collapse_end left at -1) when ^O * is on, or when there's at most one completed cluster so far -- * nothing older to collapse yet. */ if (!u->expand_output) { for (size_t i = u->nsegs; i > 0; i--) { if (u->segs[i - 1].style != ST_BATCH) continue; if (last_batch == (size_t)-1) { last_batch = i - 1; continue; } collapse_end = i - 1; break; } } for (size_t i = 0; i < u->nsegs; i++) { const struct seg *g = &u->segs[i]; /* Checked before the collapse logic below: a hidden think- * channel segment is invisible either way and must not count * as an "interruption" that breaks a run of collapsed tool * clusters -- models routinely emit one of these between every * tool call, which used to flush (and restart) the aggregate * once per call, defeating the whole point of collapsing. */ if (g->style == ST_REASON && !u->show_reasoning) continue; /* think channel hidden */ /* Mute tool-cluster segments in the collapsed range, tallying * ST_BATCH's counts as we go; anything else in that range * (e.g. assistant text interleaved between tool calls) still * renders normally, flushing any pending aggregate first so * ordering stays correct. */ if (collapse_end != (size_t)-1 && i <= collapse_end && (g->style == ST_TOOL || g->style == ST_TOOL_OUT || g->style == ST_BATCH)) { if (g->style == ST_BATCH) { agg[0] += g->cnt[0]; agg[1] += g->cnt[1]; agg[2] += g->cnt[2]; agg[3] += g->cnt[3]; } aggregating = true; continue; } if (aggregating) { push_collapsed_summary(u, agg); agg[0] = agg[1] = agg[2] = agg[3] = 0; aggregating = false; } if (g->style == ST_ASSIST && w >= 4) render_markdown(u, g->text, w, A_NORMAL, false); else if (g->style == ST_REASON && w >= 4) render_markdown(u, g->text, w, COLOR_PAIR(6), true); else if (g->style == ST_TOOL_OUT) push_tool_output(u, g->text, i == last_tool_out); else rseg_push(u, seg_attr(g->style), g->text, strlen(g->text)); } if (aggregating) push_collapsed_summary(u, agg); u->built_gen = u->gen; u->built_width = w; u->built_expand = u->expand_output; u->built_reasoning = u->show_reasoning; } /* * Soft-wrap rsegs[i0, iN) to width w, starting at cursor (*row, *col), and * advance *row and *col past the last character walked. If ckpt_row and * ckpt_col are non-NULL, record the (row, col) at the start of each rseg * into them -- this is how wrap_walk below builds its resume cache. If draw * is true, * cells whose wrapped-row index falls in [start, start+h) are painted into * u->txt at row (index - start). Both the checkpoint-building pass and the * paint pass call this same core loop, so they can never disagree about * where lines wrap. */ static void wrap_span(struct ui *u, size_t i0, size_t iN, int *row, int *col, int w, int h, int start, bool draw, int *ckpt_row, int *ckpt_col) { for (size_t i = i0; i < iN; i++) { const char *s = u->rsegs[i].text; size_t len = strlen(s); size_t j = 0; mbstate_t ps; if (ckpt_row != NULL) { ckpt_row[i] = *row; ckpt_col[i] = *col; } if (draw) wattrset(u->txt, u->rsegs[i].attr); memset(&ps, 0, sizeof(ps)); while (j < len) { wchar_t wc; size_t k = mbrtowc(&wc, s + j, len - j, &ps); int cw; if (k == (size_t)-1 || k == (size_t)-2 || k == 0) { k = 1; /* invalid/incomplete byte */ wc = 0; } if (wc == L'\n') { (*row)++; *col = 0; j += k; continue; } cw = wcwidth(wc); if (cw < 0) cw = 1; if (cw > 0 && *col + cw > w) { (*row)++; *col = 0; } if (draw && *row >= start && *row < start + h) { wmove(u->txt, *row - start, *col); waddnstr(u->txt, s + j, (int)k); } *col += cw; j += k; } } if (draw) wattrset(u->txt, A_NORMAL); } /* Binary-search wrap_row[0..n] (sorted ascending) for the rightmost index * whose checkpoint row is <= start, so a paint pass can resume closest to * its visible window instead of walking from rseg 0. */ static size_t wrap_checkpoint_before(const int *rows, size_t n, int start) { size_t lo = 0, hi = n; while (lo < hi) { size_t mid = lo + (hi - lo) / 2; if (rows[mid] <= start) lo = mid + 1; else hi = mid; } return lo > 0 ? lo - 1 : 0; } /* Ensure wrap_row/wrap_col hold valid checkpoints for rsegs[0..base] at * width w (index `base` = the position just after the last real rseg, * where the transient steering tail begins -- see draw_transcript). If the * cache is already valid (rebuild_render hasn't run and w hasn't changed * since), this is a no-op: no mbrtowc/wcwidth work at all. Otherwise it * does one full wrap_span pass over the real rsegs to rebuild it. */ static void wrap_ensure_checkpoints(struct ui *u, int w, size_t base) { int row = 0, col = 0; int *r, *c; if (u->wrap_valid && u->wrap_width == w) return; if (u->cap_wrap < base + 1) { size_t ncap = u->cap_wrap ? u->cap_wrap * 2 : 64; while (ncap < base + 1) ncap *= 2; r = realloc(u->wrap_row, ncap * sizeof(*r)); c = realloc(u->wrap_col, ncap * sizeof(*c)); if (r != NULL) u->wrap_row = r; if (c != NULL) u->wrap_col = c; if (r == NULL || c == NULL) return; /* leave wrap_valid false; retry next tick */ u->cap_wrap = ncap; } wrap_span(u, 0, base, &row, &col, w, 0, 0, false, u->wrap_row, u->wrap_col); u->wrap_row[base] = row; u->wrap_col[base] = col; u->wrap_width = w; u->wrap_valid = true; } /* * Walk the rendered runs (real rsegs plus the transient tail appended past * `base` by draw_transcript), soft-wrapped to width w. Returns the total * number of wrapped rows. When draw is true, cells whose wrapped-row index * falls in [start, start+h) are painted into u->txt at row (index - start). * * The real-rseg portion is resumed from a cached checkpoint rather than * walked from scratch whenever possible: wrap_ensure_checkpoints only * redoes that work when rebuild_render has changed rsegs or w has changed; * a paint pass additionally jumps to the closest checkpoint at or before * `start` instead of resuming at row 0. Only the checkpoint-building pass * and the tail (0-3 short lines) ever get walked character-by-character on * a steady-state repaint tick. */ static int wrap_walk(struct ui *u, int w, int h, int start, bool draw, size_t base) { int row, col; size_t i0; if (w < 1) w = 1; wrap_ensure_checkpoints(u, w, base); if (draw) { i0 = wrap_checkpoint_before(u->wrap_row, base + 1, start); row = u->wrap_row[i0]; col = u->wrap_col[i0]; } else { i0 = base; row = u->wrap_row[base]; col = u->wrap_col[base]; } wrap_span(u, i0, u->nrsegs, &row, &col, w, h, start, draw, NULL, NULL); return row + 1; } static void draw_transcript(struct ui *u) { int w = getmaxx(u->txt); int h = getmaxy(u->txt); int total, maxscroll, start; size_t base; if (u->gen != u->built_gen || w != u->built_width || u->expand_output != u->built_expand || u->show_reasoning != u->built_reasoning) rebuild_render(u, w); /* Pending steering entries aren't part of the transcript source (see * enqueue_steering): they haven't happened yet, so rather than mixing * them into segs/rsegs (and the gen-keyed rebuild cache) they're * appended here as a transient tail, pinned below whatever the real * cache holds, then trimmed back off below so next frame starts clean. */ base = u->nrsegs; for (size_t i = 0; i < u->steering_nqueue; i++) { char line[600]; rseg_push(u, seg_attr(ST_USER), "\nyou> ", 6); rseg_push(u, seg_attr(ST_USER), u->steering_queue[i], strlen(u->steering_queue[i])); (void)snprintf(line, sizeof(line), " (queued)\n"); rseg_push(u, seg_attr(ST_META), line, strlen(line)); } total = wrap_walk(u, w, h, 0, false, base); /* scroll is a distance from the bottom, so on its own it re-anchors * to the new bottom whenever total grows -- which drags the viewport * forward through content that just streamed in, exactly while the * user is trying to read something further up. While actually * scrolled up (not following), grow scroll by however much total * grew since the last paint, holding the viewport's absolute * position (start, below) steady instead. Following (scroll == 0) * is deliberately left alone -- that case is supposed to track the * bottom. */ if (u->scroll > 0 && u->last_total >= 0 && total > u->last_total) u->scroll += (size_t)(total - u->last_total); u->last_total = total; maxscroll = total - h; if (maxscroll < 0) maxscroll = 0; if (u->scroll > (size_t)maxscroll) u->scroll = (size_t)maxscroll; start = total - h - (int)u->scroll; if (start < 0) start = 0; werase(u->txt); wrap_walk(u, w, h, start, true, base); wnoutrefresh(u->txt); /* Drop the transient queue tail appended above -- it must not survive * into the next frame's rsegs count (rebuild_render's free loop, or * this same overlay run again, would otherwise double up on it). */ for (size_t i = base; i < u->nrsegs; i++) free(u->rsegs[i].text); u->nrsegs = base; } static void handle_keys(struct ui *u); /* defined below; shared with on_stdin */ static void on_repaint(uv_timer_t *t) { struct ui *u = t->data; /* Also drain input here so a lone Escape (held by ncurses waiting for a * possible escape sequence) is flushed within a tick, even if no further * keystroke arrives to trigger the stdin poll. */ handle_keys(u); if (u->busy) u->spinner++; if (!u->dirty && !u->busy) return; /* Grow/shrink the input box to fit what's typed before drawing. */ if (relayout(u, false)) u->full_redraw = true; if (u->full_redraw) { /* The three windows tile the screen; redrawing them all from * scratch repaints every cell, clearing resize artifacts. */ redrawwin(u->txt); redrawwin(u->stat); redrawwin(u->in); u->full_redraw = false; } draw_transcript(u); draw_status(u); draw_input(u); doupdate(); u->dirty = false; } /* ---- input ---- */ /* Echo a prompt and hand it to the agent. Returns true if the turn started. */ static bool do_submit(struct ui *u, const char *prompt, bool echo) { int r; /* No forced "scroll = 0, snap to the bottom" here on purpose. * Someone already at the bottom (scroll == 0, the common case: typed * a prompt, hit enter) is unaffected either way. But submitting a * prompt while scrolled up reading history used to yank the * viewport back to the bottom regardless -- exactly the complaint * this fixes, same root issue as draw_transcript's own scroll-vs- * growing-transcript fix, just triggered by a new turn starting * instead of an existing one streaming. */ if (echo) { ui_push(u, ST_USER, "\nyou> "); ui_push(u, ST_USER, prompt); ui_push(u, ST_USER, "\n"); } r = clm_agent_submit(u->agent, prompt); if (r < 0) { ui_push(u, ST_ERROR, "\nerror: "); ui_push(u, ST_ERROR, clm_agent_get_last_error(u->agent)); return false; } u->busy = true; u->started_assist = false; u->usage[0] = '\0'; return true; } static void enqueue_steering(struct ui *u, const char *prompt) { char *dup = strdup(prompt); if (dup == NULL) return; if (u->steering_nqueue == u->steering_cap) { size_t ncap = u->steering_cap ? u->steering_cap * 2 : 8; char **p = realloc(u->steering_queue, ncap * sizeof(*p)); if (p == NULL) { free(dup); return; } u->steering_queue = p; u->steering_cap = ncap; } u->steering_queue[u->steering_nqueue++] = dup; /* Don't push into the transcript source here: a turn is in flight and * streaming into the same segs array, so anything appended now would * land wherever the stream happens to be, not at the end of the * conversation -- draw_transcript renders pending queue entries as a * transient tail instead, pinned below the real content until each one * drains. */ u->dirty = true; } static void drain_steering_queue(struct ui *u) { char *prompt; enum clm_agent_state prev_state; int r; if (!u->busy || u->steering_nqueue == 0) return; /* Mid-turn steering: inject immediately at decision point. */ prompt = u->steering_queue[0]; memmove(u->steering_queue, u->steering_queue + 1, (u->steering_nqueue - 1) * sizeof(*u->steering_queue)); u->steering_nqueue--; /* Render exactly like any other submitted prompt: no separate * "[steering: ...]" treatment, so the transcript doesn't distinguish * a mid-turn nudge from a normal turn-start prompt once it lands. */ ui_push(u, ST_USER, "\nyou> "); ui_push(u, ST_USER, prompt); ui_push(u, ST_USER, "\n"); /* * clm_agent_notify(), not clm_agent_submit(): two of the three * decision points this fires at (the CALLING_TOOL<->THINKING * transitions) still have the agent's state set to THINKING or * CALLING_TOOL at this exact instant, and clm_agent_submit() * unconditionally rejects those with "turn already in progress". * notify() queues the text as a pending follow-up in that case and * submits it itself once the current turn lands on COMPLETE/ERROR; * it only submits right away -- starting a genuinely new turn -- at * the third decision point (THINKING->COMPLETE), where the agent is * actually free. */ prev_state = clm_agent_get_state(u->agent); r = clm_agent_notify(u->agent, prompt); if (r < 0) { ui_push(u, ST_ERROR, "\nerror: "); ui_push(u, ST_ERROR, clm_agent_get_last_error(u->agent)); } else if (prev_state != CLM_STATE_THINKING && prev_state != CLM_STATE_CALLING_TOOL) { /* Actually started a fresh turn, same bookkeeping do_submit() * does on a successful submit. */ u->started_assist = false; u->usage[0] = '\0'; } free(prompt); u->dirty = true; } /* * Set u->model (status bar display + /provider's "keep the current model" * lookup), replacing any prior value. Always makes an owned copy: unlike * most of this file's config-derived strings, u->model can now also come * from live/ad hoc input (a model id typed straight into /model that * isn't in config.lua's models{} table, see CMD("model")'s literal * fallback) backed by a transient command-line buffer, not the * long-lived Lua config table -- so it can never safely be borrowed. * NULL is accepted (clears the display) and safe to pass. */ static void set_current_model(struct ui *u, const char *model) { char *dup = model != NULL ? strdup(model) : NULL; free(u->model); u->model = dup; } /* * Set u->provider_name (status bar display), replacing any prior value. * Same owned-copy contract as set_current_model. NULL clears the display -- * used whenever a switch lands on a connection with no config.lua providers[] * entry behind it (a literal model id on the currently active connection, see * cmd_model's fallback path), since that connection's provider is unnamed. */ static void set_current_provider(struct ui *u, const char *provider) { char *dup = provider != NULL ? strdup(provider) : NULL; free(u->provider_name); u->provider_name = dup; } static int strp_cmp(const void *a, const void *b) { return strcmp(*(const char *const *)a, *(const char *const *)b); } /* * Push a sorted, comma-joined listing of config.'s entry names * (e.g. "providers" or "models") for /model and /provider run with no * argument -- discoverability, since otherwise the only way to see what's * configured is to go read config.lua. label is the human word used in * the message ("model"/"provider"); cmd is what to type ("/model * "/"/provider "). */ static void list_config_names(struct ui *u, const char *table, const char *label, const char *cmd) { char **names; char msg[1024]; size_t off = 0; if (u->lcfg == NULL) { ui_push(u, ST_ERROR, "\nno config loaded\n"); return; } names = clm_lua_cfg_list_names(u->lcfg, table); if (names == NULL) { char nomsg[128]; (void)snprintf(nomsg, sizeof(nomsg), "\nno %ss configured\n", label); ui_push(u, ST_META, nomsg); return; } size_t n = 0; while (names[n] != NULL) n++; qsort(names, n, sizeof(*names), strp_cmp); /* snprintf's return value is how much it WOULD have written, which * can exceed the remaining space on truncation -- clamp off after * every call or "sizeof(msg) - off" underflows (size_t) on the next * one and hands snprintf a huge bogus size. */ int wrote = snprintf(msg + off, sizeof(msg) - off, "\navailable %ss:\n", label); off += (wrote > 0) ? (size_t)wrote : 0; if (off > sizeof(msg)) off = sizeof(msg); for (size_t i = 0; i < n && off < sizeof(msg); i++) { wrote = snprintf(msg + off, sizeof(msg) - off, " %s\n", names[i]); off += (wrote > 0) ? (size_t)wrote : 0; if (off > sizeof(msg)) off = sizeof(msg); } if (off < sizeof(msg)) { (void)snprintf(msg + off, sizeof(msg) - off, "usage: %s \n", cmd); } ui_push(u, ST_META, msg); clm_lua_cfg_free_str_list(names); } /* * /model's live half: async GET {provider}/models against whatever * connection is currently active, appended below list_config_names' * "from config:" section once it resolves (config.lua's models{} table * only ever tells you what YOU declared; this is what the server actually * has right now -- the two can and do diverge, e.g. a multi-model router * like freellmapi vs. a single-model local llama.cpp instance). ids is * only valid for the duration of this call (see clm_agent_list_models); * copy what's needed into msg before returning. */ static void on_live_models_result(char **ids, void *user) { struct ui *u = user; size_t cap = sizeof("\nfrom server:\n"); size_t off; int wrote; autofree char *msg = NULL; /* Heap, not a stack buffer: a router like freellmapi can report 60+ * ids, and sizing up front (rather than a fixed-size stack array) * also sidesteps -Wframe-larger-than on a plain char msg[N]. */ for (size_t i = 0; ids[i] != NULL; i++) cap += strlen(ids[i]) + sizeof(" \n") - 1; msg = malloc(cap); if (msg == NULL) return; wrote = snprintf(msg, cap, "\nfrom server:\n"); off = (wrote > 0) ? (size_t)wrote : 0; for (size_t i = 0; ids[i] != NULL && off < cap; i++) { wrote = snprintf(msg + off, cap - off, " %s\n", ids[i]); off += (wrote > 0) ? (size_t)wrote : 0; } ui_push(u, ST_META, msg); } static void on_live_models_error(const char *emsg, void *user) { struct ui *u = user; char msg[256]; (void)snprintf(msg, sizeof(msg), "\n(couldn't query live models from the current provider: %s)\n", emsg); ui_push(u, ST_META, msg); } static void cmd_agent(struct ui *u, const char *arg) { /* /agent * Switch agent: reload profile, swap provider+prompt, clear history. * Safe even mid-turn: clm_agent_free() (via clm_tools_detach(), * lib/tools.c) severs any in-flight tool batch from the agent * before freeing it, so a shell/bg child process that is still * running gets to finish asynchronously without touching freed * memory. */ if (arg[0] == '\0') { ui_push(u, ST_META, "\nusage: /agent \n"); } else if (u->lcfg == NULL) { ui_push(u, ST_ERROR, "\nno config loaded\n"); } else { char *adir = xdg_config_path("clm/agents"); if (clm_lua_cfg_load_agent(u->lcfg, adir, arg) < 0) { char msg[128]; (void)snprintf(msg, sizeof(msg), "\nagent '%s' not found\n", arg); ui_push(u, ST_ERROR, msg); free(adir); } else { free(adir); /* Resolve the agent's model (or the top-level * default, if the profile doesn't set its own) -- * a "provider/model-id" spec, see src/model_spec.h -- * down to a provider connection, same indirection * as the startup path in main.c. */ const char *model_name = clm_lua_cfg_get_str(u->lcfg, "model"); autofree char *spec_provider = NULL; const char *spec_model = NULL; split_provider_model(model_name, &spec_provider, &spec_model); const char *prov = spec_provider; const char *purl = prov ? clm_lua_cfg_provider_str(u->lcfg, prov, "url") : NULL; const char *pkind = prov ? clm_lua_cfg_provider_str(u->lcfg, prov, "kind") : NULL; const char *pkey = prov ? clm_lua_cfg_provider_str(u->lcfg, prov, "api_key") : NULL; const char *pmodel = spec_model; const char *sprompt = clm_lua_cfg_get_str(u->lcfg, "system_prompt"); /* Tear down plugins + MCP clients (must happen while the * old agent is still alive: unregistering their tools * touches it) + agent. */ clm_lua_env_free(u->lua_env); u->lua_env = NULL; clm_cli_free_mcp_servers(u->mcp_clients, u->mcp_client_count); u->mcp_clients = NULL; u->mcp_client_count = 0; clm_agent_free(u->agent); /* Rebuild cfg. */ int r; struct clm_cfg newcfg = {0}; char url_buf[512]; if (purl != NULL) { size_t ulen = strlen(purl); while (ulen > 0 && purl[ulen-1] == '/') ulen--; (void)snprintf(url_buf, sizeof(url_buf), "%.*s/chat/completions", (int)ulen, purl); newcfg.base_url = url_buf; } else { newcfg.base_url = "http://127.0.0.1:8081/v1/chat/completions"; } newcfg.model = pmodel ? pmodel : "local-model"; /* Empty string is a valid, explicit "no key needed" * (see http_async.c: Content-Type no longer depends * on a non-empty key, so there's no need for a * non-empty placeholder here either). */ newcfg.api_key = (pkey != NULL && pkey[0] != '\0') ? pkey : (getenv("CLM_API_KEY") ? getenv("CLM_API_KEY") : ""); newcfg.system_prompt = sprompt; newcfg.provider = clm_provider_from_str(pkind); newcfg.stream = 1; if (prov != NULL && spec_model != NULL) { newcfg.context_size = clm_lua_cfg_provider_model_int(u->lcfg, prov, spec_model, "context_size", 0); newcfg.autocompact_pct = (int)clm_lua_cfg_provider_model_int(u->lcfg, prov, spec_model, "autocompact_pct", 0); } if (prov) { newcfg.rate_tokens_per_sec = clm_lua_cfg_provider_int(u->lcfg, prov, "rate_tokens_per_sec", 0); newcfg.rate_burst = clm_lua_cfg_provider_int(u->lcfg, prov, "rate_burst", 0); newcfg.disable_parallel_tool_calls = clm_lua_cfg_provider_int(u->lcfg, prov, "disable_parallel_tool_calls", 0) != 0; } /* Volatile-tool policy from the new agent profile. * Not freed on the next switch (borrowed by the * agent for its lifetime) -- same intentional * process-lifetime leak as lcfg itself. */ newcfg.volatile_tools = (const char *const *) clm_lua_cfg_get_str_list(u->lcfg, "volatile_tools"); r = clm_agent_new(&newcfg, u->host, &tui_callbacks, u, &u->agent); if (r < 0) { ui_push(u, ST_ERROR, "\nfailed to create agent\n"); } else { clm_tools_register_shell(u->agent); clm_tools_register_bg(u->agent); /* Reload plugins. */ if (clm_lua_env_new(u->agent, &u->lua_env) == 0) { clm_lua_env_set_config_from(u->lua_env, u->lcfg); if (u->plugin_dir != NULL) { clm_lua_load_plugins(u->lua_env, u->plugin_dir); } else { char *pp = xdg_config_path("clm/plugins"); if (pp != NULL) { clm_lua_load_plugins(u->lua_env, pp); free(pp); } } /* Load agent-specific plugins. */ char *apdir = xdg_config_path("clm/agents"); if (apdir != NULL) { char apbuf[512]; (void)snprintf(apbuf, sizeof(apbuf), "%s/%s", apdir, arg); clm_lua_load_plugins(u->lua_env, apbuf); free(apdir); } } u->mcp_clients = clm_cli_connect_mcp_servers(u->agent, u->loop, u->lcfg, cb_mcp_status, u, &u->mcp_client_count); free(u->agent_name); u->agent_name = strdup(arg); set_current_model(u, newcfg.model); set_current_provider(u, prov); char msg[128]; (void)snprintf(msg, sizeof(msg), "\n[switched to agent: %s]\n", arg); ui_push(u, ST_META, msg); clm_agent_check_connection(u->agent); } } } } static void cmd_model(struct ui *u, const char *arg) { /* /model : resolve * config.providers[provider].models[model-id] to a connection + * wire model id and reconfigure the live agent in place via * clm_agent_set_provider() -- history, tools, and MCP clients are * untouched. Contrast /agent, which also changes the system * prompt/tool set and needs a full teardown + rebuild. * * A bare id with no "/" is a literal wire model id requested on * whatever connection is currently active (no context_size/ * autocompact_pct override applies) -- this is what makes the live * listing below actually useful: an id the server reports but that * was never added to any provider's models{} can still be switched * to, same "config spec, or literal fallback" contract as * -m/--model on the CLI in main.c. */ if (arg[0] == '\0') { list_config_names(u, "models", "model", "/model"); (void)clm_agent_list_models(u->agent, on_live_models_result, on_live_models_error, u); } else if (u->lcfg == NULL) { ui_push(u, ST_ERROR, "\nno config loaded\n"); } else { autofree char *spec_provider = NULL; const char *spec_model = NULL; split_provider_model(arg, &spec_provider, &spec_model); const char *purl = spec_provider != NULL ? clm_lua_cfg_provider_str(u->lcfg, spec_provider, "url") : NULL; if (spec_provider != NULL && purl == NULL) { /* A "provider/..." was typed but that provider name * doesn't exist -- an error, not a literal fallback: * silently sending "typo/model-id" itself as a wire * id would be actively misleading. */ char msg[128]; (void)snprintf(msg, sizeof(msg), "\nprovider '%s' not found\n", spec_provider); ui_push(u, ST_ERROR, msg); } else if (spec_provider == NULL) { /* No "/" at all -- literal wire id on the current * connection. */ struct clm_cfg litcfg = {0}; const char *cur_url = clm_agent_get_base_url(u->agent); if (cur_url == NULL) { ui_push(u, ST_ERROR, "\nno active connection to switch on\n"); } else { char msg[160]; litcfg.base_url = cur_url; litcfg.api_key = clm_agent_get_api_key(u->agent); litcfg.provider = clm_agent_get_provider(u->agent); litcfg.model = arg; int rc = clm_agent_set_provider(u->agent, &litcfg); if (rc == 0) { set_current_model(u, litcfg.model); (void)snprintf(msg, sizeof(msg), "\n[switched to model: %s " "(literal, not in config.lua)]\n", arg); ui_push(u, ST_META, msg); clm_agent_check_connection(u->agent); } else if (rc == -EBUSY) { ui_push(u, ST_ERROR, "\nbusy; try again when idle\n"); } else { ui_push(u, ST_ERROR, "\nfailed to switch model\n"); } } } else { struct clm_cfg newcfg = {0}; char url_buf[512]; enum clm_provider provider = clm_provider_from_str( clm_lua_cfg_provider_str(u->lcfg, spec_provider, "kind")); /* clm_provider_build_url() is the one place the * base-url + endpoint-path logic lives -- see * clm/provider.h's endpoint_path for why that matters * (this call site used to hardcode "/chat/completions" * independently of main.c's CLI-startup path, and * drifted out of sync with it). */ clm_provider_build_url(url_buf, sizeof(url_buf), purl, provider); newcfg.base_url = url_buf; newcfg.api_key = clm_lua_cfg_provider_str(u->lcfg, spec_provider, "api_key"); newcfg.provider = provider; newcfg.model = spec_model; newcfg.context_size = clm_lua_cfg_provider_model_int(u->lcfg, spec_provider, spec_model, "context_size", 0); newcfg.autocompact_pct = (int)clm_lua_cfg_provider_model_int(u->lcfg, spec_provider, spec_model, "autocompact_pct", 0); newcfg.rate_tokens_per_sec = clm_lua_cfg_provider_int(u->lcfg, spec_provider, "rate_tokens_per_sec", 0); newcfg.rate_burst = clm_lua_cfg_provider_int(u->lcfg, spec_provider, "rate_burst", 0); int rc = clm_agent_set_provider(u->agent, &newcfg); if (rc == 0) { set_current_model(u, newcfg.model); set_current_provider(u, spec_provider); char msg[128]; (void)snprintf(msg, sizeof(msg), "\n[switched to model: %s]\n", arg); ui_push(u, ST_META, msg); clm_agent_check_connection(u->agent); } else if (rc == -EBUSY) { ui_push(u, ST_ERROR, "\nbusy; try again when idle\n"); } else { ui_push(u, ST_ERROR, "\nfailed to switch model\n"); } } } } static void cmd_provider(struct ui *u, const char *arg) { /* /provider : like /model, but overrides only the * provider connection (endpoint, key, wire dialect, rate * limits) backing whichever model is currently active, e.g. * to fail over to a mirror endpoint without changing which * model is requested. */ if (arg[0] == '\0') { list_config_names(u, "providers", "provider", "/provider"); } else if (u->lcfg == NULL) { ui_push(u, ST_ERROR, "\nno config loaded\n"); } else { const char *purl = clm_lua_cfg_provider_str(u->lcfg, arg, "url"); if (purl == NULL) { char msg[128]; (void)snprintf(msg, sizeof(msg), "\nprovider '%s' not found\n", arg); ui_push(u, ST_ERROR, msg); } else { struct clm_cfg newcfg = {0}; char url_buf[512]; size_t ulen = strlen(purl); while (ulen > 0 && purl[ulen-1] == '/') ulen--; (void)snprintf(url_buf, sizeof(url_buf), "%.*s/chat/completions", (int)ulen, purl); newcfg.base_url = url_buf; newcfg.api_key = clm_lua_cfg_provider_str(u->lcfg, arg, "api_key"); newcfg.provider = clm_provider_from_str( clm_lua_cfg_provider_str(u->lcfg, arg, "kind")); newcfg.model = u->model; /* keep the currently active model */ newcfg.rate_tokens_per_sec = clm_lua_cfg_provider_int(u->lcfg, arg, "rate_tokens_per_sec", 0); newcfg.rate_burst = clm_lua_cfg_provider_int(u->lcfg, arg, "rate_burst", 0); int rc = clm_agent_set_provider(u->agent, &newcfg); if (rc == 0) { set_current_provider(u, arg); char msg[128]; (void)snprintf(msg, sizeof(msg), "\n[switched to provider: %s]\n", arg); ui_push(u, ST_META, msg); clm_agent_check_connection(u->agent); } else if (rc == -EBUSY) { ui_push(u, ST_ERROR, "\nbusy; try again when idle\n"); } else { ui_push(u, ST_ERROR, "\nfailed to switch provider\n"); } } } } /* A '/word ...' line: run a UI command instead of prompting the model. */ static void run_command(struct ui *u, const char *line) { const char *arg; size_t wlen; line++; /* skip '/' */ arg = line; while (*arg != '\0' && *arg != ' ') arg++; wlen = (size_t)(arg - line); while (*arg == ' ') arg++; #define CMD(s) (wlen == strlen(s) && strncmp(line, s, wlen) == 0) if (CMD("help") || CMD("h") || CMD("?")) { ui_push( u, ST_META, "\ncommands:\n" " /help this help\n" " /clear clear the transcript (starts a new " "session log)\n" " /agent switch agent profile (system prompt + " "tools)\n" " /model [name] switch model (config.lua's `models` " "table); no arg lists what's available\n" " /provider [name] switch provider connection (config.lua's " "`providers` table); no arg lists what's available\n" " /reasoning [on|off] show/hide the think channel (^R)\n" " /output [full|short] tool output detail (^O)\n" " /compact summarize old turns to reclaim context\n" " /quit exit\n" "keys: ^R reasoning ^O output ^L redraw " "PgUp/PgDn/wheel/End scroll\n"); } else if (CMD("clear") || CMD("cls")) { /* Reset the actual conversation history first -- an agent busy * mid-turn refuses (-EBUSY) and the on-screen transcript is left * alone in that case too, rather than looking cleared while the * backend history it was displaying still isn't. */ int rc = clm_agent_clear_history(u->agent); if (rc == -EBUSY) { ui_push(u, ST_ERROR, "\nbusy; try again when idle\n"); } else { for (size_t i = 0; i < u->nsegs; i++) free(u->segs[i].text); u->nsegs = 0; u->scroll = 0; u->gen++; /* Rotate the session log too: the cleared transcript * stays resumable under its old id, and what follows * is a new conversation, so it gets a new file. An * empty old session is just deleted. */ if (u->session != NULL) { if (clm_session_is_empty(u->session)) (void)clm_session_discard(u->session); else clm_session_free(u->session); u->session = NULL; if (clm_session_create(NULL, u->model, u->provider_name, u->agent_name, &u->session) == 0) { ui_push(u, ST_META, "[new session "); ui_push(u, ST_META, clm_session_id(u->session)); ui_push(u, ST_META, "]\n"); } } } } else if (CMD("reasoning") || CMD("think")) { if (strcmp(arg, "on") == 0) u->show_reasoning = true; else if (strcmp(arg, "off") == 0) u->show_reasoning = false; else u->show_reasoning = !u->show_reasoning; ui_push(u, ST_META, u->show_reasoning ? "\n[reasoning shown]\n" : "\n[reasoning hidden]\n"); } else if (CMD("output")) { if (strcmp(arg, "full") == 0) u->expand_output = true; else if (strcmp(arg, "short") == 0) u->expand_output = false; else u->expand_output = !u->expand_output; ui_push(u, ST_META, u->expand_output ? "\n[output: full]\n" : "\n[output: short]\n"); } else if (CMD("compact")) { int rc = clm_agent_compact(u->agent); if (rc == 0) { u->busy = true; ui_push(u, ST_META, "\n[compacting the conversation...]\n"); } else if (rc == -EBUSY) { ui_push(u, ST_ERROR, "\nbusy; try again when idle\n"); } else { ui_push(u, ST_ERROR, "\ncompaction failed to start\n"); } } else if (CMD("agent") || CMD("a")) { cmd_agent(u, arg); } else if (CMD("model")) { cmd_model(u, arg); } else if (CMD("provider")) { cmd_provider(u, arg); } else if (CMD("quit") || CMD("exit") || CMD("q")) { u->quit = true; } else { ui_push(u, ST_ERROR, "\nunknown command (try /help)\n"); } #undef CMD } /* * Record a submitted line in the recall history. Skips empties and immediate * duplicates of the most recent entry. The browse cursor is reset to the live * line by the caller after submit. */ static void hist_add(struct ui *u, const char *line) { char *dup; if (line[0] == '\0') return; if (u->nhist > 0 && strcmp(u->hist[u->nhist - 1], line) == 0) return; if (u->nhist == u->cap_hist) { size_t ncap = u->cap_hist ? u->cap_hist * 2 : 16; char **h = realloc(u->hist, ncap * sizeof(*h)); if (h == NULL) return; /* history is best-effort */ u->hist = h; u->cap_hist = ncap; } dup = strdup(line); if (dup == NULL) return; u->hist[u->nhist++] = dup; } /* Load a history entry (or a saved live line) into the input buffer. */ static void hist_load(struct ui *u, const char *text) { size_t n = strlen(text); if (n >= sizeof(u->input)) n = sizeof(u->input) - 1; memcpy(u->input, text, n); u->input[n] = '\0'; u->input_len = n; u->input_pos = n; u->dirty = true; } /* * Move through the prompt history. dir < 0 goes to older entries, dir > 0 to * newer. Stepping up from the live line stashes it so stepping back down * restores it; stepping down past the newest entry returns to that live line. */ static void hist_recall(struct ui *u, int dir) { if (u->nhist == 0) return; if (dir < 0) { /* older */ if (u->hist_pos == 0) return; /* already at the oldest */ if (u->hist_pos == u->nhist) { /* Leaving the live line: save it for the trip back. */ u->input[u->input_len] = '\0'; (void)snprintf(u->hist_saved, sizeof(u->hist_saved), "%s", u->input); } u->hist_pos--; hist_load(u, u->hist[u->hist_pos]); } else { /* newer */ if (u->hist_pos >= u->nhist) return; /* already at the live line */ u->hist_pos++; if (u->hist_pos == u->nhist) hist_load(u, u->hist_saved); /* back to the live line */ else hist_load(u, u->hist[u->hist_pos]); } } static void submit_line(struct ui *u) { u->input[u->input_len] = '\0'; if (u->input_len == 0) return; hist_add(u, u->input); if (u->input[0] == '/') run_command(u, u->input); else if (strcmp(u->input, "quit") == 0 || strcmp(u->input, "exit") == 0) u->quit = true; else if (u->busy) enqueue_steering(u, u->input); else do_submit(u, u->input, true); u->input_len = 0; u->input_pos = 0; u->hist_pos = u->nhist; /* reset recall to the live line */ u->hist_saved[0] = '\0'; u->dirty = true; } /* Byte offset of the UTF-8 char boundary before/after pos. */ static size_t prev_boundary(const char *s, size_t pos) { if (pos == 0) return 0; do { pos--; } while (pos > 0 && (s[pos] & 0xc0) == 0x80); return pos; } static size_t next_boundary(const char *s, size_t pos, size_t len) { if (pos >= len) return len; do { pos++; } while (pos < len && (s[pos] & 0xc0) == 0x80); return pos; } /* A ':' sits at u->input_pos - 1 (just typed, or just inserted by tab * completion). If it closes a ":shortcode:" run immediately behind the * cursor, replace the whole ":shortcode:" span with the looked-up glyph, * Slack/Discord-style. No-op (silently) if there's no opening ':' nearby, * the name has characters a shortcode can't, or the name isn't in the * table. Declared in tui_internal.h -- complete.c calls this too, after an * unambiguous shortcode-name tab completion appends the closing ':'. */ void tui_expand_emoji_at_cursor(struct ui *u) { size_t close = u->input_pos - 1; /* index of the ':' just inserted */ size_t i = close; while (i > 0) { i--; char c = u->input[i]; if (c == ':') break; if (!(isalnum((unsigned char)c) || c == '_' || c == '+' || c == '-')) return; if (close - i > 64) /* no shortcode is this long; bail early */ return; } if (u->input[i] != ':') return; size_t namestart = i + 1; size_t namelen = close - namestart; if (namelen == 0) return; char glyph[5]; size_t glyphlen = clm_emoji_lookup(u->input + namestart, namelen, glyph); if (glyphlen == 0) return; size_t fullspan = close - i + 1; /* ":name:" including both colons */ if (glyphlen > fullspan && u->input_len + (glyphlen - fullspan) >= sizeof(u->input) - 1) return; size_t tail_len = u->input_len - u->input_pos; memmove(u->input + i + glyphlen, u->input + u->input_pos, tail_len); memcpy(u->input + i, glyph, glyphlen); u->input_len = u->input_len - fullspan + glyphlen; u->input_pos = i + glyphlen; u->dirty = true; } static void input_char(struct ui *u, wint_t wch) { char mb[MB_LEN_MAX]; int n = wctomb(mb, (wchar_t)wch); if (n <= 0) return; if (u->input_len + (size_t)n >= sizeof(u->input) - 1) return; /* Insert at the cursor, shifting the tail right. */ memmove(u->input + u->input_pos + (size_t)n, u->input + u->input_pos, u->input_len - u->input_pos); memcpy(u->input + u->input_pos, mb, (size_t)n); u->input_len += (size_t)n; u->input_pos += (size_t)n; u->dirty = true; if (wch == L':') tui_expand_emoji_at_cursor(u); } static void input_backspace(struct ui *u) { if (u->input_pos == 0) return; size_t start = prev_boundary(u->input, u->input_pos); memmove(u->input + start, u->input + u->input_pos, u->input_len - u->input_pos); u->input_len -= u->input_pos - start; u->input_pos = start; u->dirty = true; } /* Save [from,to) to the kill buffer and splice it out of the input. */ static void input_kill(struct ui *u, size_t from, size_t to) { if (to <= from) return; size_t n = to - from; if (n >= sizeof(u->kill)) n = sizeof(u->kill) - 1; memcpy(u->kill, u->input + from, n); u->kill_len = n; memmove(u->input + from, u->input + to, u->input_len - to); u->input_len -= to - from; if (u->input_pos > to) u->input_pos -= to - from; else if (u->input_pos > from) u->input_pos = from; u->dirty = true; } static void input_yank(struct ui *u) { if (u->kill_len == 0) return; if (u->input_len + u->kill_len >= sizeof(u->input) - 1) return; memmove(u->input + u->input_pos + u->kill_len, u->input + u->input_pos, u->input_len - u->input_pos); memcpy(u->input + u->input_pos, u->kill, u->kill_len); u->input_len += u->kill_len; u->input_pos += u->kill_len; u->dirty = true; } /* * Answer a pending permission prompt from a keypress. Returns true if the key * was a recognised answer (y/n/a/d) or Escape. Escape denies and cancels the * turn; the others map to the four allow/deny x once/always decisions. */ static bool answer_permission(struct ui *u, int ch) { const struct clm_permission_req *req; enum clm_permission_decision d; const char *label; if (u->perm_count == 0) return false; req = u->perm_queue[0]; switch (ch) { case 'y': case 'Y': d = CLM_PERM_ALLOW_ONCE; label = "allowed"; break; case 'a': case 'A': d = CLM_PERM_ALLOW_ALWAYS; label = "always allowed"; break; case 'n': case 'N': d = CLM_PERM_DENY_ONCE; label = "denied"; break; case 'd': case 'D': d = CLM_PERM_DENY_ALWAYS; label = "always denied"; break; case 27: d = CLM_PERM_DENY_ONCE; label = "denied (cancelled)"; break; default: return false; /* not an answer key */ } /* Pop the front of the queue. */ u->perm_count--; for (size_t i = 0; i < u->perm_count; i++) u->perm_queue[i] = u->perm_queue[i + 1]; u->perm_showing = false; /* For "always" decisions, we need the answered name before we potentially free the invocation. */ const char *answered_name = NULL; if (d == CLM_PERM_ALLOW_ALWAYS || d == CLM_PERM_DENY_ALWAYS) { answered_name = clm_permission_req_name(req); } ui_push(u, ST_PERM, "-> "); ui_push(u, ST_PERM, label); ui_push(u, ST_PERM, "\n"); clm_tool_permission_respond(u->agent, req, d); /* Escape also cancels the rest of the turn (drain remaining). */ if (ch == 27) { for (size_t i = 0; i < u->perm_count; i++) clm_tool_permission_respond(u->agent, u->perm_queue[i], CLM_PERM_DENY_ONCE); u->perm_count = 0; clm_agent_cancel(u->agent); } /* For "always" decisions, auto-resolve remaining queued requests for * the same tool without prompting. */ if (d == CLM_PERM_ALLOW_ALWAYS || d == CLM_PERM_DENY_ALWAYS) { size_t i = 0; while (i < u->perm_count) { const char *qname = clm_permission_req_name(u->perm_queue[i]); if (answered_name != NULL && qname != NULL && strcmp(answered_name, qname) == 0) { clm_tool_permission_respond(u->agent, u->perm_queue[i], d); u->perm_count--; for (size_t j = i; j < u->perm_count; j++) u->perm_queue[j] = u->perm_queue[j + 1]; } else { i++; } } } /* Show next prompt if any remain. */ if (u->perm_count > 0) show_next_perm(u); u->dirty = true; return true; } static void handle_keys(struct ui *u) { wint_t wch; int r; while ((r = wget_wch(u->in, &wch)) != ERR) { /* Every key -- including the TAB that starts a new completion * pass -- invalidates any completion request still in flight * from an earlier key (see complete_generation's doc comment * in tui_internal.h). */ u->complete_generation++; /* * Permission-input mode: while a tool authorization is pending, * the next key answers it and nothing else is processed. */ if (u->perm_showing && r != KEY_CODE_YES) { if (answer_permission(u, (int)wch)) continue; /* Not a recognised answer key: ignore it, stay pending. */ continue; } if (r == KEY_CODE_YES) { switch (wch) { case KEY_BACKSPACE: input_backspace(u); break; case KEY_ENTER: submit_line(u); break; case UI_KEY_PASTE_START: { /* * Consume everything up to the matching END marker * ourselves, inserting it straight into the input * buffer (embedded newlines included) rather than * letting it flow back through the normal * key-at-a-time path, where a '\r'/'\n' means * "submit". A CR inside pasted text (xterm sends * line endings as '\r') is translated to '\n' so * it renders/wraps the same as a typed newline. * * u->in is nodelay(), so wget_wch never blocks -- * ERR just means "nothing buffered right now", not * "the paste is over". A large paste can arrive * split across multiple pty reads, so on ERR we * retry briefly (bounded, so a real dropped/missing * END marker can't wedge the UI) before giving up * and taking whatever arrived so far. */ wint_t pwch; int pr; int idle_retries = 0; const int max_idle_retries = 200; /* ~200 * 1ms = 200ms */ for (;;) { pr = wget_wch(u->in, &pwch); if (pr == ERR) { if (++idle_retries > max_idle_retries) break; /* END never showed up; stop waiting */ napms(1); continue; } idle_retries = 0; if (pr == KEY_CODE_YES) { if (pwch == (wint_t)UI_KEY_PASTE_END) break; continue; /* ignore other special keys mid-paste */ } if (pwch == L'\r') pwch = L'\n'; if (pwch >= 32 || pwch == L'\n' || pwch == L'\t') input_char(u, pwch); } u->dirty = true; break; } case UI_KEY_PASTE_END: /* Stray END with no matching START: ignore. */ break; case KEY_LEFT: u->input_pos = prev_boundary(u->input, u->input_pos); u->dirty = true; break; case KEY_RIGHT: u->input_pos = next_boundary( u->input, u->input_pos, u->input_len); u->dirty = true; break; case KEY_UP: /* recall an older prompt */ hist_recall(u, -1); break; case KEY_DOWN: /* recall a newer prompt */ hist_recall(u, +1); break; case KEY_HOME: u->input_pos = 0; u->dirty = true; break; case KEY_END: /* An empty input line has no meaningful * "end of line" to jump to, so repurpose End * there as "scroll the transcript to the * bottom" instead -- the same convention many * pagers/terminals use, and it doesn't * collide with the common case (End while * actually editing a line still just moves * the cursor). */ if (u->input_len == 0) u->scroll = 0; else u->input_pos = u->input_len; u->dirty = true; break; case KEY_DC: /* Delete */ if (u->input_pos < u->input_len) input_kill(u, u->input_pos, next_boundary(u->input, u->input_pos, u->input_len)); u->kill_len = 0; /* Delete doesn't feed yank */ break; case KEY_PPAGE: { /* PageUp: scroll transcript back */ int page = getmaxy(u->txt) - 1; if (page < 1) page = 1; u->scroll += (size_t)page; u->dirty = true; break; } case KEY_NPAGE: { /* PageDown: scroll toward the bottom */ int page = getmaxy(u->txt) - 1; if (page < 1) page = 1; if (u->scroll > (size_t)page) u->scroll -= (size_t)page; else u->scroll = 0; u->dirty = true; break; } case KEY_MOUSE: { /* Wheel notch: a few lines, not a full page * like PgUp/PgDn. Terminals report wheel * motion as button-4/5 press events, no * corresponding release. */ MEVENT event; if (getmouse(&event) != OK) break; const size_t notch = 3; if (event.bstate & BUTTON4_PRESSED) { u->scroll += notch; u->dirty = true; } else if (event.bstate & BUTTON5_PRESSED) { if (u->scroll > notch) u->scroll -= notch; else u->scroll = 0; u->dirty = true; } break; } case KEY_RESIZE: break; /* handled via SIGWINCH */ default: break; } continue; } switch (wch) { case 4: /* Ctrl-D */ if (u->input_len == 0) u->quit = true; break; case 3: /* Ctrl-C */ u->quit = true; break; case 1: /* Ctrl-A: start of line */ u->input_pos = 0; u->dirty = true; break; case 5: /* Ctrl-E: end of line */ u->input_pos = u->input_len; u->dirty = true; break; case 2: /* Ctrl-B: back one char */ u->input_pos = prev_boundary(u->input, u->input_pos); u->dirty = true; break; case 6: /* Ctrl-F: forward one char */ u->input_pos = next_boundary(u->input, u->input_pos, u->input_len); u->dirty = true; break; case 21: /* Ctrl-U: kill to start of line */ input_kill(u, 0, u->input_pos); break; case 11: /* Ctrl-K: kill to end of line */ input_kill(u, u->input_pos, u->input_len); break; case 25: /* Ctrl-Y: yank */ input_yank(u); break; case 12: /* Ctrl-L: redraw the screen */ u->full_redraw = true; u->dirty = true; break; case 15: /* Ctrl-O: toggle full vs. collapsed tool output */ u->expand_output = !u->expand_output; u->dirty = true; break; case 18: /* Ctrl-R: toggle the reasoning (think) channel */ u->show_reasoning = !u->show_reasoning; u->dirty = true; break; case 27: { /* Escape: cancel a running turn, else clear the input */ int cancel_rc = clm_agent_cancel(u->agent); if (cancel_rc == 0) { ui_push(u, ST_META, "\n[cancelled]\n"); } else if (cancel_rc != -EALREADY) { /* Truly nothing in flight -- treat Escape as * clear-input instead. -EALREADY means a cancel * from an earlier Escape is still unwinding * (killed subprocesses take a moment to actually * exit); do nothing rather than spamming another * "[cancelled]" per repeat keypress or clearing * input the user hasn't finished typing yet. */ u->input_len = 0; u->input_pos = 0; } u->dirty = true; break; } case 9: /* TAB: completion (commands, config/live names, paths) */ complete_input(u, u->complete_generation); break; case 8: case 127: /* Backspace / DEL */ input_backspace(u); break; case '\r': case '\n': submit_line(u); break; default: if (wch >= 32) input_char(u, wch); break; } } if (u->quit) uv_stop(u->loop); } static void on_stdin(uv_poll_t *p, int status, int events) { struct ui *u = p->data; (void)events; if (status < 0) return; handle_keys(u); } /* ---- window lifecycle ---- */ static void make_windows(struct ui *u) { int h = LINES, w = COLS; if (h < 3) h = 3; u->txt = newwin(h - 2, w, 0, 0); u->stat = newwin(1, w, h - 2, 0); u->in = newwin(1, w, h - 1, 0); u->in_h = 1; scrollok(u->in, TRUE); keypad(u->in, TRUE); nodelay(u->in, TRUE); } static void on_winch(uv_signal_t *s, int signum) { struct ui *u = s->data; struct winsize ws; (void)signum; /* * ncurses can't learn the new size on its own here (its own SIGWINCH * path is bypassed by our uv_signal handler), so query the tty and tell * it via resizeterm(), which updates LINES/COLS and stdscr. Then re-lay * out our windows and force a full, artifact-free repaint. */ if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_row > 0 && ws.ws_col > 0) resizeterm(ws.ws_row, ws.ws_col); relayout(u, true); u->full_redraw = true; u->dirty = true; } /* * Colour is deliberately confined to the 16-colour ANSI palette: the 8 base * colours plus A_BOLD (bright). No init_color(), no 256/truecolour indices -- * this keeps us readable on plain terminals and under any user theme (the -1 * background is the terminal default). Markdown is rendered NOCOLOUR, so it * only contributes attributes, never pair colours. Keep it this way. */ static void init_colors(void) { if (!has_colors()) return; start_color(); use_default_colors(); /* * Colours are chosen to read well on a stock Solarized 16-colour palette * where A_BOLD promotes 0-7 accents to the grey 8-15 base tones. So we * distinguish by colour, not bold, and reserve the grey base tones for * genuinely secondary text (the reasoning channel). TODO: themeable. */ init_pair(1, COLOR_BLUE, -1); /* user prompt (blue) */ init_pair(2, COLOR_MAGENTA, -1); /* tool call summary (magenta) */ init_pair(3, COLOR_RED, -1); /* error */ init_pair(4, COLOR_YELLOW, -1); /* timeout / warning */ init_pair(5, COLOR_BLACK, COLOR_BLUE); /* status bar */ init_pair(6, 10, -1); /* reasoning: Solarized base01 */ init_pair(7, COLOR_GREEN, -1); /* code (inline and block) */ init_pair(8, COLOR_CYAN, -1); /* assistant "clm>" label */ /* * Reserved for attention, not decoration: red = error; yellow = warning / * timeout / tool-call permission prompts (the "about to do something, * confirm?" caution colour -- shares the base-8 yellow with ST_TIMEOUT * rather than reaching for ANSI 9/brred, which silently vanished on * anything without a 16-colour terminfo entry, e.g. plain "screen" over * ssh in tmux). Plain, not | A_BOLD: some terminal/terminfo * combinations render bold as a fixed bright-white override rather * than intensifying the underlying colour pair, which made this show * up as bold white instead of yellow. TODO: expose the whole * role->colour map via the theme layer. */ init_pair(9, COLOR_YELLOW, -1); /* permission prompt (yellow) */ } /* Periodic (and startup) connectivity probe. */ static void on_health(uv_timer_t *t) { struct ui *u = t->data; clm_agent_check_connection(u->agent); } /* * Re-render a restored session's transcript through the same seg vocabulary * the live callbacks use, so a resumed conversation looks like it never * ended. Internal context-update messages are replayed into the agent but * not shown, same as live (they are never echoed). */ static void replay_transcript(struct ui *u, const struct clm_history *h) { const struct clm_message *m; TAILQ_FOREACH(m, h, entries) { switch (m->role) { case CLM_ROLE_SYSTEM: break; case CLM_ROLE_USER: if (m->content == NULL || strncmp(m->content, "[context update]", strlen("[context update]")) == 0) break; ui_push(u, ST_USER, "\nyou> "); ui_push(u, ST_USER, m->content); ui_push(u, ST_USER, "\n"); break; case CLM_ROLE_ASSISTANT: { const struct clm_tool_call *tc; TAILQ_FOREACH(tc, &m->tool_calls, entries) push_tool_summary(u, tc->name != NULL ? tc->name : "?", tc->args); if (m->content != NULL && m->content[0] != '\0') { ui_push(u, ST_LABEL, "\nclm>\n"); ui_push(u, ST_ASSIST, m->content); ui_push(u, ST_NORMAL, "\n"); } break; } case CLM_ROLE_TOOL: if (m->content != NULL && m->content[0] != '\0') { ui_push(u, ST_TOOL_OUT, m->content); if (m->content[strlen(m->content) - 1] != '\n') ui_push(u, ST_TOOL_OUT, "\n"); } break; } } } int tui_run(const struct clm_cfg *cfg, const char *plugin_dir, struct clm_lua_cfg *lcfg, const char *forever_prompt, struct clm_session *session, const struct clm_history *restore) { struct ui *u; uv_loop_t *loop; int r; setlocale(LC_ALL, ""); u = calloc(1, sizeof(*u)); /* too large for the stack frame limit */ if (u == NULL) { fprintf(stderr, "error: out of memory\n"); return 1; } u->last_total = -1; /* draw_transcript hasn't painted yet */ loop = uv_default_loop(); u->loop = loop; set_current_model(u, cfg->model); set_current_provider(u, cfg->provider_name); u->forever_prompt = forever_prompt; if (lcfg != NULL) { const char *aname = clm_lua_cfg_get_agent_name(lcfg); if (aname != NULL) u->agent_name = strdup(aname); } u->lcfg = lcfg; u->plugin_dir = plugin_dir; u->session = session; u->state = CLM_STATE_IDLE; /* Default to the clean view: reasoning hidden and tool output collapsed. * Opt in with ^R / ^O (see the status-bar hints). */ u->show_reasoning = false; u->expand_output = false; if (lcfg != NULL) { const char *sr = clm_lua_cfg_get_str(lcfg, "show_thinking"); if (sr != NULL && strcmp(sr, "true") == 0) u->show_reasoning = true; const char *eo = clm_lua_cfg_get_str(lcfg, "expand_output"); if (eo != NULL && strcmp(eo, "true") == 0) u->expand_output = true; } r = clm_host_uv_new(loop, &u->host); if (r < 0) { fprintf(stderr, "error: failed to create host (%d)\n", r); free(u); return 1; } r = clm_agent_new(cfg, u->host, &tui_callbacks, u, &u->agent); if (r < 0) { fprintf(stderr, "error: failed to create agent (%d)\n", r); clm_host_uv_free(u->host); free(u); return 1; } /* Desktop uv layer: add the shell_exec/bg_exec tools (not in the portable core). */ clm_tools_register_shell(u->agent); clm_tools_register_bg(u->agent); if (clm_lua_env_new(u->agent, &u->lua_env) == 0) { if (lcfg != NULL) clm_lua_env_set_config_from(u->lua_env, lcfg); if (plugin_dir != NULL) { clm_lua_load_plugins(u->lua_env, plugin_dir); } else { char *ppath = xdg_config_path("clm/plugins"); if (ppath != NULL) { clm_lua_load_plugins(u->lua_env, ppath); free(ppath); } } } u->mcp_clients = clm_cli_connect_mcp_servers(u->agent, loop, lcfg, cb_mcp_status, u, &u->mcp_client_count); initscr(); cbreak(); noecho(); nonl(); set_escdelay(25); /* make a lone Escape (cancel) responsive */ mousemask(BUTTON4_PRESSED | BUTTON5_PRESSED, NULL); init_colors(); make_windows(u); /* * Bracketed paste: ask the terminal to wrap pasted text in * \033[200~ ... \033[201~ so handle_keys can tell a paste apart from * real Enter keypresses (see UI_KEY_PASTE_START's comment above). * define_key teaches wget_wch to recognize the two marker sequences * as atomic custom key codes; the \033[?2004h write is what actually * turns the feature on in the terminal. Both are no-ops (harmless * escape bytes swallowed, or an unrecognized sequence typed back * character-by-character) on a terminal that doesn't support it. */ define_key("\033[200~", UI_KEY_PASTE_START); define_key("\033[201~", UI_KEY_PASTE_END); fputs("\033[?2004h", stdout); fflush(stdout); ui_push(u, ST_META, "clm -- type a prompt, /help for commands, /quit to exit.\n"); if (restore != NULL) { r = clm_agent_restore_history(u->agent, restore); if (r < 0) { ui_push(u, ST_ERROR, "\nfailed to restore the session; " "starting fresh\n"); } else { replay_transcript(u, restore); ui_push(u, ST_META, "\n[resumed session "); ui_push(u, ST_META, u->session != NULL ? clm_session_id(u->session) : "?"); ui_push(u, ST_META, "]\n"); } } uv_poll_init(loop, &u->stdin_poll, fileno(stdin)); u->stdin_poll.data = u; uv_poll_start(&u->stdin_poll, UV_READABLE, on_stdin); uv_timer_init(loop, &u->repaint); u->repaint.data = u; uv_timer_start(&u->repaint, on_repaint, 0, 80); uv_signal_init(loop, &u->winch); u->winch.data = u; uv_signal_start(&u->winch, on_winch, SIGWINCH); /* Probe connectivity now (0ms) and every 20s, so a wrong URL or a down * server shows in the status bar before the first prompt. */ u->conn = CLM_CONN_CHECKING; uv_timer_init(loop, &u->health); u->health.data = u; uv_timer_start(&u->health, on_health, 0, 20000); /* --forever: kick off the first turn ourselves instead of waiting on * a human to type it in. */ if (u->forever_prompt != NULL) do_submit(u, u->forever_prompt, true); uv_run(loop, UV_RUN_DEFAULT); /* Leave the terminal as we found it. */ fputs("\033[?2004l", stdout); fflush(stdout); endwin(); /* Print the resume handle now that the terminal is sane again; a * session nothing was said in is deleted instead. */ if (u->session != NULL) { if (clm_session_is_empty(u->session)) { (void)clm_session_discard(u->session); } else { printf("session: %s (resume with clm --resume %s)\n", clm_session_id(u->session), clm_session_id(u->session)); clm_session_free(u->session); } u->session = NULL; } clm_lua_env_free(u->lua_env); clm_cli_free_mcp_servers(u->mcp_clients, u->mcp_client_count); clm_agent_free(u->agent); clm_host_uv_free(u->host); for (size_t i = 0; i < u->nsegs; i++) free(u->segs[i].text); free(u->segs); free(u->perm_queue); for (size_t i = 0; i < u->nrsegs; i++) free(u->rsegs[i].text); free(u->rsegs); free(u->wrap_row); free(u->wrap_col); for (size_t i = 0; i < u->steering_nqueue; i++) free(u->steering_queue[i]); free(u->steering_queue); for (size_t i = 0; i < u->nhist; i++) free(u->hist[i]); free(u->hist); free(u); return 0; }