Files
Hermit/src/tui.c
T
WHCandCursor ea38247161
ci / tcc-check (push) Failing after 42s
ci / linux (aarch64, 0) (push) Skipped
ci / linux (x86, 1) (push) Skipped
ci / linux (x86_64, 1) (push) Skipped
ci / windows (push) Skipped
feat: Hermit 1.1.0 — HERMIT_* env, Tauri desktop shell
Wire hermit_getenv across the engine, rebrand WebUI/systemd/docs, and
add apps/desktop (Tauri 2 + React) with a DeepSeek-Harness-like UI.
Qt app/ is frozen; full libcstcore FFI is scaffolded behind --features engine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 13:15:47 +08:00

8385 lines
252 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include "cJSON.h"
#include "cst_agent.h"
#include "cst_attach.h"
#include "cst_clipboard.h"
#include "cst_keymap.h"
#include "cst_ext.h"
#include "cst_mcp.h"
#include "cst_models.h"
#include "cst_discover.h"
#include "cst_parliament.h"
#include "cst_plugins.h"
#include "cst_realm.h"
#include "cst_provider_switch.h"
#include "cst_skills.h"
#include "cst_subagent.h"
#include "cst_runtime.h"
#include "cst_textsel.h"
#include "cst_perms.h"
#include "cst_sandbox.h"
#include "cst_tools.h"
#include "cst_util.h"
#include "cst_version.h"
#include "os_compat.h"
#include "sashidown.h"
#include "cst_hermit_glyph.h"
#include <locale.h>
#include <pthread.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <wchar.h>
#include <ctype.h>
#include <wctype.h>
#ifdef _WIN32
#include <io.h>
#include <windows.h>
#else
#include <langinfo.h>
#include <strings.h>
#include <unistd.h>
#endif
static int g_nocolor; /* NO_COLOR */
static int g_ascii; /* non-UTF-8 locale or CST_ASCII=1 */
static int g_logo_text; /* console font lacks the logo's block glyphs */
static int g_utf8 = 1; /* locale can show non-ASCII text */
#define HAS_COLOR() (!g_nocolor && has_colors())
/* Curses: ncursesw (Unix and Windows), or PDCurses (wide) on Windows. */
#if defined(CST_USE_PDCURSES)
#ifndef PDC_WIDE
#define PDC_WIDE 1
#endif
#ifndef NCURSES_MOUSE_VERSION
#define NCURSES_MOUSE_VERSION 2
#endif
#include <curses.h>
#ifndef BUTTON4_PRESSED
#define BUTTON4_PRESSED 0
#endif
#ifndef BUTTON5_PRESSED
#define BUTTON5_PRESSED 0
#endif
#ifndef KEY_RESIZE
#define KEY_RESIZE 0632
#endif
#else
#include <ncurses.h>
#endif
#ifdef _WIN32
#define wcwidth cst_wcwidth /* MinGW often lacks wcwidth */
#endif
/* The Windows console (conhost/ConPTY) loses wide characters on insert or
* delete-character, so curses must repaint cells instead. */
static WINDOW *tui_newwin(int rows, int cols, int y, int x) {
WINDOW *w = newwin(rows, cols, y, x);
#ifdef _WIN32
if (w)
idcok(w, FALSE);
#endif
return w;
}
#ifdef _WIN32
static void cst_usleep(unsigned usec) {
Sleep((usec + 999) / 1000);
}
static int cst_clock_gettime_mono(struct timespec *ts) {
static LARGE_INTEGER freq;
static int init;
LARGE_INTEGER c;
if (!init) {
QueryPerformanceFrequency(&freq);
init = 1;
}
QueryPerformanceCounter(&c);
ts->tv_sec = (time_t)(c.QuadPart / freq.QuadPart);
ts->tv_nsec =
(long)(((c.QuadPart % freq.QuadPart) * 1000000000ULL) / freq.QuadPart);
return 0;
}
#define usleep(us) cst_usleep((unsigned)(us))
#define clock_gettime(clk, ts) cst_clock_gettime_mono(ts)
#ifndef CLOCK_MONOTONIC
#define CLOCK_MONOTONIC 1
#endif
#endif
/*
* OpenCode-inspired C ncurses/PDCurses TUI:
* structured transcript blocks, thinking/tool cards, permissions, cancel.
*/
#define INPUT_CAP 32768
#define SCROLL_SPEED 3
#define REPLAY_MSGS 30
#define MD_INIT (64 * 1024)
#define ANSI_INIT (64 * 1024)
#define TOOL_PREVIEW_LINES 3
#define DIFF_FOLD_LINES 40
#define BLOCKS_INIT 64
#define MAX_RENDER_LINES 65536
#define PROMPT_HIST_CAP 50
#define QUEUE_CAP 3
#define INPUT_MAX_ROWS 8
#define COMP_MAX 8
#define TODO_PANEL_MAX 6
#define ASK_OPTS_MAX 9
/* Our define_key() codes. ncurses numbers the terminfo extended keys (kLFT5,
* kDC3, ...) from KEY_MAX up, and key codes must fit in 16 bits, so stay well
* clear of the first and below the second. */
#define KEY_OWN_BASE (KEY_MAX + 0x1000)
/* Bracketed paste markers. */
#define KEY_PASTE_BEGIN (KEY_OWN_BASE + 1)
#define KEY_PASTE_END (KEY_OWN_BASE + 2)
/* Kitty keyboard protocol: the reply to CSI ? u, and CSI cp;1+mods u reports
* bound as KEY_KITTY_BASE + cp * 8 + mods for ASCII code points. */
#define KEY_KITTY_REPLY (KEY_OWN_BASE + 3)
#define KEY_KITTY_BASE (KEY_OWN_BASE + 16)
#define KITTY_CP_END 128
/* Transcript selection states. */
#define SEL_NONE 0
#define SEL_PRESS 1 /* button down, not moved off the pressed cell yet */
#define SEL_DRAG 2
#define SEL_SHOWN 3 /* released; highlighted until the next input */
#define MULTI_CLICK_MS 400
#ifdef NCURSES_VERSION
static int g_kitty_on;
#ifndef _WIN32
/* Bind the reports and ask whether the terminal speaks the protocol; the
* flags are pushed once the reply arrives (see tui_wget_wch). */
static void kitty_init(void) {
char seq[24];
for (int f = 0; f < 32; f++) {
snprintf(seq, sizeof(seq), "\033[?%du", f);
define_key(seq, KEY_KITTY_REPLY);
}
for (int cp = 9; cp < KITTY_CP_END; cp++) {
if (cp != 9 && cp != 13 && cp != 27 && cp < ' ')
continue;
if (cp == 27)
define_key("\033[27u", KEY_KITTY_BASE + cp * 8);
for (int m = 1; m < 8; m++) {
snprintf(seq, sizeof(seq), "\033[%d;%du", cp, m + 1);
define_key(seq, KEY_KITTY_BASE + cp * 8 + m);
}
}
printf("\033[?u");
fflush(stdout);
}
/* Pop our flags before leaving; harmless if they were never pushed. */
static void kitty_restore(void) {
if (g_kitty_on) {
printf("\033[<u");
fflush(stdout);
g_kitty_on = 0;
}
}
#endif /* !_WIN32 */
#endif
/* wget_wch() that turns kitty key reports back into legacy input. */
static int tui_wget_wch(WINDOW *w, wint_t *wch) {
for (;;) {
int rc = wget_wch(w, wch);
#ifdef NCURSES_VERSION
if (rc != KEY_CODE_YES)
return rc;
if (*wch == KEY_KITTY_REPLY) {
if (!g_kitty_on) {
/* disambiguate: Esc, alt+x, ctrl+x, shift+enter... as CSI u */
printf("\033[>1u");
fflush(stdout);
g_kitty_on = 1;
}
continue;
}
int k = (int)*wch - KEY_KITTY_BASE;
if (k < 0 || k >= KITTY_CP_END * 8)
return rc;
int out[2];
int n = cst_key_from_kitty(k / 8, k % 8, out);
if (n == 0)
continue;
if (n == 2)
unget_wch((wchar_t)out[1]);
if (out[0] == CST_K_BTAB) {
*wch = KEY_BTAB;
return KEY_CODE_YES;
}
*wch = (wint_t)out[0];
return OK;
#else
return rc;
#endif
}
}
/* Color pair indices */
enum {
CP_HEADER = 1,
CP_STATUS,
CP_INPUT,
CP_PRIMARY, /* user / accents */
CP_ASSIST, /* cyan assistant */
CP_MUTED,
CP_SUCCESS,
CP_ERROR,
CP_TOOL,
CP_PANEL, /* transcript bg */
CP_SGR_GREEN,
CP_SGR_YELLOW,
CP_SGR_CYAN,
CP_SGR_MAGENTA,
/* OpenCode-style structured diff (bg distinguishes add/del/context) */
CP_DIFF_ADD,
CP_DIFF_DEL,
CP_DIFF_CTX,
/* raised surfaces: input box / user message, sidebar */
CP_BOX,
CP_BOX_MUTED,
CP_BOX_ACCENT,
CP_BOX_PRIMARY,
CP_BOX_BRIGHT,
CP_SIDE,
CP_SIDE_MUTED,
CP_SIDE_BRIGHT,
CP_SIDE_SUCCESS,
CP_SIDE_PRIMARY,
CP_ACCENT, /* blue on panel */
CP_BRIGHT, /* near-white on panel */
CP_LOGO_DIM,
CP_SEL, /* selected list row */
CP_TOPBAR, /* session chrome strip */
CP_TOPBAR_MUTED,
CP_TOPBAR_ACCENT
};
typedef enum {
BLK_USER = 0,
BLK_ASSISTANT,
BLK_THINK,
BLK_TOOL,
BLK_STATUS,
BLK_SYSTEM,
BLK_FOOTER /* "▣ Build · model · 4.7s" after a turn */
} block_kind_t;
typedef struct {
block_kind_t kind;
char *title;
char *body; /* raw text / markdown / tool output */
char *render; /* ANSI for assistant; NULL → draw body */
int expanded;
char meta[256]; /* phase / timing / detail */
char tool_name[CTL_MAX_NAME];
char tool_id[CTL_MAX_ID];
} block_t;
typedef struct {
block_t *items;
int n;
int cap;
} blocks_t;
typedef struct {
char text[256];
char desc[112];
} comp_item_t;
typedef struct {
cst_agent_t *agent;
WINDOW *header;
WINDOW *logw;
WINDOW *todow;
WINDOW *status;
WINDOW *input;
WINDOW *side;
int rows, cols;
int input_rows; /* 1..INPUT_MAX_ROWS */
int todo_rows;
int home; /* empty transcript: centered start screen */
int show_side; /* /sidebar */
int motion_on; /* /motion — Seat Deck ASCII micro-motion */
int side_w;
int in_x, in_y, in_w, in_h; /* input box, screen coords */
int log_x, log_y; /* logw origin, screen coords */
int *row_blk; /* logw row → hist block (-1 none) */
int row_blk_cap;
int cmd_x0, cmd_x1; /* "ctrl+p commands" in the status row */
int mouse_on;
int log_start; /* display line shown in logw row 0 */
/* Mouse selection in the transcript: display line + logw column. */
int sel_state; /* SEL_* */
int sel_mode; /* 1 characters, 2 words, 3 lines */
int sel_ay, sel_ax; /* anchor */
int sel_y, sel_x; /* current end */
int sel_press_y, sel_press_x; /* screen cell of the press */
int sel_clicks, sel_click_y, sel_click_x;
struct timespec sel_click_at;
const char *tip;
const char *sandbox_name; /* "landlock" / "seatbelt" / "rules" */
char where[300]; /* ~/path:branch */
blocks_t hist; /* sealed blocks */
block_t live; /* streaming assistant (kind set when as_live) */
int as_live;
char *md_live;
size_t md_len;
size_t md_cap;
size_t md_shown; /* bytes of md_live revealed on screen (drip buffer) */
size_t md_rendered; /* md_shown at the last live_render */
double drip_carry; /* fractional codepoints awaiting reveal */
struct timespec drip_at;
char *md_prefix_ansi; /* rendered md_live[0, md_prefix) for long replies */
size_t md_prefix;
struct timespec md_render_at;
int show_thinking; /* /thinking: Thought blocks start expanded */
cst_keymap_t km;
int leader_pending;
struct timespec leader_at;
int log_scroll;
char inbuf[INPUT_CAP];
size_t in_len;
size_t cur; /* cursor byte offset into inbuf */
int in_paste;
int paste_cr; /* last pasted byte was CR (fold CRLF) */
size_t paste_at, paste_len0; /* cursor and length when the paste began */
char status_msg[512];
char title[128]; /* session title or first prompt, for the header */
struct timespec ctrlc_at;
struct timespec turn_t0;
int spin;
/* "/" and "@" completion popup */
comp_item_t comp[COMP_MAX];
int comp_n, comp_sel, comp_kind; /* kind 1 = slash, 2 = file */
size_t comp_start;
int comp_off; /* dismissed with Esc until the input changes */
int comp_drawn; /* popup rows currently on screen */
char **files;
int nfiles;
struct timespec files_at;
char *todo;
int show_todo;
volatile int need_layout;
char *pending_prompt; /* set by /retry and /init */
int busy;
int details_all; /* /details: expand all tool bodies */
int awaiting_perm;
int think_idx; /* hist index of open think, or -1 */
int last_tool_idx;
int focus_think_idx;
struct timespec think_t0;
int think_timing; /* think_t0 valid */
/* Prompt history (newest at end). hist_browse=-1 means live buffer. */
char *prompt_hist[PROMPT_HIST_CAP];
int prompt_hist_n;
int hist_browse;
char *hist_draft; /* the live input while browsing */
/* Busy-turn FIFO */
char *queue[QUEUE_CAP];
int queue_n;
/* Permission handshake (worker → main). */
volatile int perm_ask;
volatile int perm_ans; /* -1 waiting, else CST_PERM_* */
char perm_tool[CTL_MAX_NAME];
char perm_detail[512];
char *perm_preview;
char perm_prefix[128];
int perm_sandbox; /* asking to rerun a blocked command outside the sandbox */
int perm_scroll;
/* ask_user handshake (worker → main). */
volatile int ask_pending;
volatile int ask_done;
int ask_rc;
int ask_sel;
int ask_nopts;
char ask_q[1024];
char ask_opts[ASK_OPTS_MAX][256];
char ask_buf[1024];
pthread_mutex_t mu;
volatile int dirty;
volatile int turn_done;
int turn_rc;
char turn_err[512];
int jobs_done; /* background completions drained this session */
} tui_t;
/* --- UTF-8 helpers -------------------------------------------------------- */
static size_t utf8_prev_len(const char *s, size_t len) {
if (len == 0)
return 0;
size_t i = len - 1;
while (i > 0 && ((unsigned char)s[i] & 0xC0) == 0x80)
i--;
return len - i;
}
static int utf8_decode(const char *s, size_t avail, wchar_t *wc, size_t *eaten) {
return cst_utf8_decode(s, avail, wc, eaten);
}
static int utf8_width(wchar_t wc) { return cst_utf8_width(wc); }
static int utf8_cols(const char *s, size_t n) {
int cols = 0;
size_t i = 0;
while (i < n) {
wchar_t wc;
size_t eaten;
utf8_decode(s + i, n - i, &wc, &eaten);
cols += utf8_width(wc);
i += eaten;
}
return cols;
}
static size_t utf8_fit_cols(const char *s, size_t n, int max_cols) {
int cols = 0;
size_t i = 0;
while (i < n) {
wchar_t wc;
size_t eaten;
utf8_decode(s + i, n - i, &wc, &eaten);
int w = utf8_width(wc);
if (cols + w > max_cols)
break;
cols += w;
i += eaten;
}
return i;
}
static size_t ansi_skip(const char *s, size_t n) { return cst_ansi_skip(s, n); }
static char *xstrdup(const char *s) {
if (!s)
s = "";
size_t n = strlen(s);
char *p = malloc(n + 1);
if (!p)
return NULL;
memcpy(p, s, n + 1);
return p;
}
static char *xstrndup(const char *s, size_t n) {
char *p = malloc(n + 1);
if (!p)
return NULL;
memcpy(p, s, n);
p[n] = 0;
return p;
}
/* --- theme ---------------------------------------------------------------- */
/* Hermit palette: brand teal, crab orange, eye purple (design mock). */
static void theme_init(void) {
const char *nc = getenv("NO_COLOR");
g_nocolor = nc && nc[0];
const char *asc = hermit_getenv("ASCII");
#if defined(__ANDROID__)
g_utf8 = 1; /* bionic is UTF-8 only; nl_langinfo needs API 26 */
#elif !defined(_WIN32)
const char *cs = nl_langinfo(CODESET);
g_utf8 = cs && (strcasecmp(cs, "UTF-8") == 0 || strcasecmp(cs, "utf8") == 0);
#endif
g_ascii = asc[0] ? asc[0] != '0' : !g_utf8;
if (!HAS_COLOR())
return;
start_color();
/* Do NOT call use_default_colors(): -1 would leak the terminal theme. */
short primary = COLOR_CYAN;
short panel = COLOR_BLACK;
short muted = COLOR_WHITE;
short success = COLOR_GREEN;
short err = COLOR_RED;
short assist = COLOR_MAGENTA;
short text = COLOR_WHITE;
short box = COLOR_BLACK, side = COLOR_BLACK;
short accent = COLOR_CYAN, bright = COLOR_WHITE, logo_dim = COLOR_CYAN;
short tool = COLOR_YELLOW;
short prompt_c = COLOR_CYAN;
if (COLORS >= 256) {
box = 235;
side = 234;
accent = 75; /* #277F94 brand teal */
bright = 255;
logo_dim = 242;
primary = 75;
panel = 233;
muted = 244;
success = 114;
err = 167;
assist = 177; /* #9957AD eye purple */
tool = 216; /* #F68042 crab orange */
prompt_c = 152; /* #A9D9D7 prompt cyan */
text = 252;
if (can_change_color()) {
init_color(75, 153, 498, 580); /* #277F94 brand */
init_color(152, 663, 851, 843); /* #A9D9D7 prompt */
init_color(216, 965, 502, 259); /* #F68042 crab */
init_color(177, 600, 341, 678); /* #9957AD eye */
init_color(167, 850, 320, 320); /* error red */
init_color(168, 643, 341, 310); /* #A4574F brick legs */
init_color(233, 55, 58, 70); /* #0e0f12 panel */
init_color(244, 502, 520, 560); /* muted */
init_color(252, 820, 830, 850); /* body text */
init_color(114, 400, 750, 450);
init_color(235, 100, 105, 120);
init_color(234, 78, 82, 96);
}
} else if (can_change_color() && COLORS >= 8) {
init_color(COLOR_BLACK, 55, 58, 70);
init_color(COLOR_CYAN, 153, 498, 580);
init_color(COLOR_WHITE, 820, 830, 850);
init_color(COLOR_GREEN, 400, 750, 450);
init_color(COLOR_RED, 850, 320, 320);
init_color(COLOR_MAGENTA, 600, 341, 678);
init_color(COLOR_YELLOW, 965, 502, 259);
panel = COLOR_BLACK;
primary = COLOR_CYAN;
muted = COLOR_WHITE;
text = COLOR_WHITE;
success = COLOR_GREEN;
err = COLOR_RED;
assist = COLOR_MAGENTA;
tool = COLOR_YELLOW;
prompt_c = COLOR_CYAN;
}
/* Header is dark too — primary only as text accent (not a bright bar). */
init_pair(CP_HEADER, primary, panel);
init_pair(CP_STATUS, muted, panel);
init_pair(CP_INPUT, text, panel);
init_pair(CP_PRIMARY, primary, panel);
init_pair(CP_ASSIST, assist, panel);
init_pair(CP_MUTED, muted, panel);
init_pair(CP_SUCCESS, success, panel);
init_pair(CP_ERROR, err, panel);
init_pair(CP_TOOL, tool, panel);
init_pair(CP_PANEL, text, panel);
init_pair(CP_SGR_GREEN, success, panel);
init_pair(CP_SGR_YELLOW, tool, panel);
init_pair(CP_SGR_CYAN, prompt_c, panel);
if (COLORS >= 256)
init_pair(CP_SGR_MAGENTA, 176, panel);
else
init_pair(CP_SGR_MAGENTA, COLOR_MAGENTA, panel);
/* Diff rows: high-contrast xterm-256 palette only.
* Do NOT init_color() cube indices 16–21 — many terminals ignore it and leave
* default blues, which made add/del rows unreadable (dark-on-blue). */
if (COLORS >= 256) {
init_pair(CP_DIFF_ADD, 157, 22); /* #afff87 on #005f00 */
init_pair(CP_DIFF_DEL, 203, 52); /* #ff5f5f on #5f0000 */
init_pair(CP_DIFF_CTX, 252, panel); /* bright gray on panel */
} else {
init_pair(CP_DIFF_ADD, COLOR_GREEN, COLOR_BLACK);
init_pair(CP_DIFF_DEL, COLOR_RED, COLOR_BLACK);
init_pair(CP_DIFF_CTX, COLOR_WHITE, COLOR_BLACK);
}
if (COLORS < 256) {
box = side = panel;
accent = COLORS >= 8 && !can_change_color() ? COLOR_BLUE : assist;
}
init_pair(CP_BOX, text, box);
init_pair(CP_BOX_MUTED, muted, box);
init_pair(CP_BOX_ACCENT, accent, box);
init_pair(CP_BOX_PRIMARY, primary, box);
init_pair(CP_BOX_BRIGHT, bright, box);
init_pair(CP_SIDE, text, side);
init_pair(CP_SIDE_MUTED, muted, side);
init_pair(CP_SIDE_BRIGHT, bright, side);
init_pair(CP_SIDE_SUCCESS, success, side);
init_pair(CP_SIDE_PRIMARY, primary, side);
init_pair(CP_ACCENT, accent, panel);
init_pair(CP_BRIGHT, bright, panel);
init_pair(CP_LOGO_DIM, logo_dim, panel);
init_pair(CP_SEL, panel, accent);
/* Same panel ground as the transcript — no raised strip / hairline seam. */
init_pair(CP_TOPBAR, bright, panel);
init_pair(CP_TOPBAR_MUTED, muted, panel);
init_pair(CP_TOPBAR_ACCENT, accent, panel);
}
/* Blank the window into the virtual screen before deleting it: refreshing
* stdscr doesn't reliably cover the cells a deleted window leaves behind. */
static void drop_win(WINDOW **w) {
if (*w) {
werase(*w);
wnoutrefresh(*w);
delwin(*w);
}
*w = NULL;
}
static void theme_paint_root(void) {
if (!HAS_COLOR())
return;
wbkgd(stdscr, COLOR_PAIR(CP_PANEL));
werase(stdscr);
wnoutrefresh(stdscr);
}
/* --- blocks --------------------------------------------------------------- */
static void block_clear(block_t *b) {
if (!b)
return;
free(b->title);
free(b->body);
free(b->render);
memset(b, 0, sizeof(*b));
}
static void blocks_init(blocks_t *bs) {
bs->items = calloc((size_t)BLOCKS_INIT, sizeof(block_t));
bs->n = 0;
bs->cap = bs->items ? BLOCKS_INIT : 0;
}
static void blocks_free(blocks_t *bs) {
if (!bs)
return;
for (int i = 0; i < bs->n; i++)
block_clear(&bs->items[i]);
free(bs->items);
bs->items = NULL;
bs->n = bs->cap = 0;
}
static void blocks_clear(blocks_t *bs) {
for (int i = 0; i < bs->n; i++)
block_clear(&bs->items[i]);
bs->n = 0;
}
static block_t *blocks_push(blocks_t *bs) {
if (!bs->items)
return NULL;
if (bs->n >= bs->cap) {
int nc = bs->cap * 2;
block_t *ni = realloc(bs->items, (size_t)nc * sizeof(block_t));
if (!ni)
return NULL;
memset(ni + bs->cap, 0, (size_t)(nc - bs->cap) * sizeof(block_t));
bs->items = ni;
bs->cap = nc;
}
block_t *b = &bs->items[bs->n++];
memset(b, 0, sizeof(*b));
return b;
}
static void block_set_title(block_t *b, const char *t) {
free(b->title);
b->title = xstrdup(t ? t : "");
}
static void block_set_body(block_t *b, const char *s, size_t n) {
free(b->body);
b->body = n ? xstrndup(s, n) : xstrdup("");
}
static void block_append_body(block_t *b, const char *s, size_t n) {
if (!s || !n)
return;
size_t old = b->body ? strlen(b->body) : 0;
char *nb = realloc(b->body, old + n + 1);
if (!nb)
return;
memcpy(nb + old, s, n);
nb[old + n] = 0;
b->body = nb;
}
/* Markdown → ANSI via Sashidown. */
static int md_to_ansi_dyn(const char *md, size_t md_len, char **outp,
size_t *out_len) {
if (!md || !outp)
return -1;
if (md_len == 0) {
*outp = NULL;
if (out_len)
*out_len = 0;
return 0;
}
size_t cap = ANSI_INIT;
char *out = malloc(cap);
if (!out)
return -1;
for (;;) {
size_t n = 0;
int rc = sashidown_convert(md, md_len, out, cap, &n, SASHIDOWN_FMT_ANSI);
if (rc == 0) {
*outp = out;
if (out_len)
*out_len = n;
return 0;
}
size_t nc = cap * 2;
if (nc < md_len * 4)
nc = md_len * 4;
if (nc > 16 * 1024 * 1024) {
free(out);
out = malloc(md_len + 1);
if (!out)
return -1;
memcpy(out, md, md_len);
out[md_len] = 0;
*outp = out;
if (out_len)
*out_len = md_len;
return 0;
}
char *nb = realloc(out, nc);
if (!nb) {
free(out);
return -1;
}
out = nb;
cap = nc;
}
}
static void block_render_assistant(block_t *b) {
free(b->render);
b->render = NULL;
if (!b->body || !b->body[0])
return;
char *ansi = NULL;
size_t n = 0;
if (md_to_ansi_dyn(b->body, strlen(b->body), &ansi, &n) == 0 && ansi) {
b->render = ansi;
}
}
static const char *tool_prefix(const char *name) {
if (!name)
return "•";
if (strcmp(name, "run_shell") == 0)
return "$";
if (cst_tool_is_write(name))
return "←";
if (strcmp(name, "read_file") == 0)
return "✱";
if (strcmp(name, "web_fetch") == 0)
return "%";
return "•";
}
static void tool_arg_summary(const ctl_tool_call_t *call, char *out,
size_t out_sz) {
static const struct {
const char *tool, *key;
} PREFER[] = {{"run_shell", "cmd"}, {"read_file", "path"},
{"write_file", "path"}, {"edit_file", "path"},
{"delete_file", "path"}, {"move_file", "from"},
{"list_dir", "path"}, {"glob", "pattern"},
{"grep", "pattern"}, {"web_fetch", "url"},
{"ask_user", "question"}, {"apply_patch", "patch"},
{"task", "description"}, {"skill", "name"}};
out[0] = 0;
if (!call || out_sz == 0)
return;
if (strcmp(call->name, "todo_write") == 0) {
snprintf(out, out_sz, "update task list");
return;
}
const char *prefer = NULL;
for (size_t i = 0; i < sizeof(PREFER) / sizeof(PREFER[0]); i++)
if (strcmp(call->name, PREFER[i].tool) == 0)
prefer = PREFER[i].key;
int pick = -1;
for (int i = 0; i < call->nargs; i++)
if (prefer && strcmp(call->args[i].key, prefer) == 0)
pick = i;
if (pick < 0 && call->nargs > 0)
pick = 0;
if (pick < 0 || !call->args[pick].value)
return;
const char *v = call->args[pick].value;
size_t len = call->args[pick].value_len ? call->args[pick].value_len
: strlen(v);
size_t nl = strcspn(v, "\n");
int cut = len > 80 || nl < len;
if (nl < len)
len = nl;
if (len > 80)
len = cst_utf8_floor(v, 80);
snprintf(out, out_sz, "%.*s%s", (int)len, v, cut ? "…" : "");
}
static void tui_lock(tui_t *t) { pthread_mutex_lock(&t->mu); }
static void tui_unlock(tui_t *t) { pthread_mutex_unlock(&t->mu); }
static void tui_mark_dirty(tui_t *t) { t->dirty = 1; }
static void set_status_locked(tui_t *t, const char *msg) {
snprintf(t->status_msg, sizeof(t->status_msg), "%s", msg ? msg : "");
tui_mark_dirty(t);
}
static void set_status(tui_t *t, const char *msg) {
tui_lock(t);
set_status_locked(t, msg);
tui_unlock(t);
}
/* Empty status_msg means "show ~/path:branch" in the status row. */
static void status_idle(tui_t *t) {
if (t->queue_n > 0)
snprintf(t->status_msg, sizeof(t->status_msg),
"%d queued · empty Enter sends the next one", t->queue_n);
else
t->status_msg[0] = 0;
}
/* --- live assistant ------------------------------------------------------- */
static void live_end_cleanup(tui_t *t) {
block_clear(&t->live);
free(t->md_live);
t->md_live = NULL;
t->md_len = 0;
t->md_cap = 0;
t->md_shown = 0;
t->md_rendered = 0;
t->drip_carry = 0;
free(t->md_prefix_ansi);
t->md_prefix_ansi = NULL;
t->md_prefix = 0;
t->as_live = 0;
}
#define LIVE_PREFIX_MIN (16 * 1024)
#define LIVE_RENDER_MS 50
static int is_fence_line(const char *s, size_t n) {
size_t i = 0;
while (i < n && i < 3 && s[i] == ' ')
i++;
return i + 3 <= n && ((s[i] == '`' && s[i + 1] == '`' && s[i + 2] == '`') ||
(s[i] == '~' && s[i + 1] == '~' && s[i + 2] == '~'));
}
/* Scans md[from, n): returns whether it ends inside a code fence, and the
* offset just past the last blank line outside any fence (or from). */
static int md_scan(const char *md, size_t from, size_t n, size_t *stable) {
int fence = 0;
*stable = from;
size_t i = from;
while (i < n) {
const char *nl = memchr(md + i, '\n', n - i);
size_t e = nl ? (size_t)(nl - md) : n;
if (!nl)
break; /* the last, unfinished line never counts */
if (is_fence_line(md + i, e - i))
fence = !fence;
else if (!fence && e == i)
*stable = e + 1;
i = e + 1;
}
if (i < n && is_fence_line(md + i, n - i))
fence = !fence;
return fence;
}
static char *md_render_part(const char *md, size_t n, int close_fence) {
char *src = malloc(n + 6);
if (!src)
return NULL;
memcpy(src, md, n);
size_t sn = n;
if (close_fence) {
memcpy(src + sn, "\n```\n", 5);
sn += 5;
}
src[sn] = 0;
char *ansi = NULL;
size_t an = 0;
if (md_to_ansi_dyn(src, sn, &ansi, &an) != 0)
ansi = NULL;
free(src);
return ansi;
}
/* Markdown → ANSI while streaming: unfinished fences are closed for display,
* and long replies keep the finished prefix rendered once. Renders only the
* dripped prefix [0, md_shown). Caller locks. */
static void live_render(tui_t *t) {
if (!t->as_live || !t->md_live || t->md_rendered == t->md_shown)
return;
const char *md = t->md_live;
size_t n = t->md_shown, stable = 0;
if (n >= LIVE_PREFIX_MIN) {
md_scan(md, t->md_prefix, n, &stable);
if (stable > t->md_prefix + 4096) {
char *pre = md_render_part(md, stable, 0);
if (pre) {
free(t->md_prefix_ansi);
t->md_prefix_ansi = pre;
t->md_prefix = stable;
}
}
}
size_t from = t->md_prefix_ansi ? t->md_prefix : 0;
size_t dummy;
int fence = md_scan(md, from, n, &dummy);
char *tail = md_render_part(md + from, n - from, fence);
if (!tail)
return;
free(t->live.render);
if (from && t->md_prefix_ansi) {
size_t a = strlen(t->md_prefix_ansi), b = strlen(tail);
t->live.render = malloc(a + b + 1);
if (t->live.render) {
memcpy(t->live.render, t->md_prefix_ansi, a);
memcpy(t->live.render + a, tail, b + 1);
}
free(tail);
} else {
t->live.render = tail;
}
t->md_rendered = n;
clock_gettime(CLOCK_MONOTONIC, &t->md_render_at);
}
static long ms_since(const struct timespec *a) {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (long)(now.tv_sec - a->tv_sec) * 1000 +
(long)(now.tv_nsec - a->tv_nsec) / 1000000;
}
/* Stream frontier as a decode wave: plasma tip → braille wake → lock flash →
* plaintext. Returns a malloc'd ANSI copy, or NULL to use the source as-is. */
#define GLITCH_FRONT 42
static unsigned glitch_mix(unsigned x) {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
return x;
}
static size_t glitch_put(char *out, size_t o, const char *s) {
size_t n = strlen(s);
memcpy(out + o, s, n);
return o + n;
}
static char *glitch_stream_frontier(const char *src, int spin) {
if (!src || !src[0])
return NULL;
size_t n = strlen(src);
/* SGR wraps + glyph swaps need headroom. */
char *out = malloc(n + (size_t)GLITCH_FRONT * 24 + 64);
if (!out)
return NULL;
size_t starts[GLITCH_FRONT];
size_t lens[GLITCH_FRONT];
int nv = 0;
size_t i = 0;
while (i < n) {
if (src[i] == '\033' && i + 1 < n && src[i + 1] == '[') {
size_t j = i + 2;
while (j < n && src[j] != 'm')
j++;
i = j < n ? j + 1 : n;
continue;
}
wchar_t wc;
size_t eaten;
utf8_decode(src + i, n - i, &wc, &eaten);
if (eaten == 0)
break;
if (wc != L'\n' && wc != L'\r') {
if (nv < GLITCH_FRONT) {
starts[nv] = i;
lens[nv] = eaten;
nv++;
} else {
memmove(starts, starts + 1, (GLITCH_FRONT - 1) * sizeof(starts[0]));
memmove(lens, lens + 1, (GLITCH_FRONT - 1) * sizeof(lens[0]));
starts[GLITCH_FRONT - 1] = i;
lens[GLITCH_FRONT - 1] = eaten;
}
}
i += eaten;
}
if (nv < 3) {
free(out);
return NULL;
}
/* Braille / phosphor — quieter than katakana soup, still cyber. */
static const char *const uni_plasma[] = {
"⣿", "⣾", "⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾", "⠁", "⠂",
"⠄", "⡀", "⢀", "⠠", "⠐", "⠈", "░", "▒", "▓"};
static const char *const uni_wake[] = {
"·", "˙", "˚", "˳", "⠁", "⠉", "⠋", "⠛", "⠟", "⠿", "∙", "•"};
static const char *const uni_caret[] = {"▍", "▋", "▌", "▎", "▏", "▎", "▌", "▋"};
static const char *const asc_plasma[] = {
"#", "@", "%", "&", "*", "+", "=", "?", "/", "\\", "|", ":",
"0", "1", "X", "Z", "~", "^", ".", ":", ";"};
static const char *const asc_wake[] = {
".", ":", "-", "~", "*", "+", "o", "x", "=", ";", "`", "'"};
static const char *const asc_caret[] = {"#", "=", "#", "-", "#", "=", "#", "-"};
const char *const *plasma = g_ascii ? asc_plasma : uni_plasma;
const char *const *wake = g_ascii ? asc_wake : uni_wake;
const char *const *caret = g_ascii ? asc_caret : uni_caret;
const int nplasma = g_ascii ? (int)(sizeof(asc_plasma) / sizeof(asc_plasma[0]))
: (int)(sizeof(uni_plasma) / sizeof(uni_plasma[0]));
const int nwake = g_ascii ? (int)(sizeof(asc_wake) / sizeof(asc_wake[0]))
: (int)(sizeof(uni_wake) / sizeof(uni_wake[0]));
const int ncaret = g_ascii ? (int)(sizeof(asc_caret) / sizeof(asc_caret[0]))
: (int)(sizeof(uni_caret) / sizeof(uni_caret[0]));
/* Traveling decode crest — sweeps from older wake toward the tip. */
int wave = (spin * 2) % (nv + 6);
size_t o = 0;
size_t cursor = 0;
for (int v = 0; v < nv; v++) {
size_t st = starts[v];
if (st > cursor) {
memcpy(out + o, src + cursor, st - cursor);
o += st - cursor;
}
int age = nv - 1 - v; /* 0 = newest (tip) */
unsigned h = glitch_mix((unsigned)spin * 131u + (unsigned)v * 17u +
(unsigned)st * 3u);
int on_wave = (age >= wave - 2 && age <= wave + 1);
if (age == 0) {
/* Write head sits ON the tip glyph (never appended after a newline,
* which would park a lonely block at column 0 of the next row). */
o = glitch_put(out, o, "\033[1;96m");
o = glitch_put(out, o, caret[(unsigned)spin % (unsigned)ncaret]);
o = glitch_put(out, o, "\033[22;39m");
} else if (age <= 3) {
/* Plasma just behind the head. */
const char *g = plasma[(h + (unsigned)spin) % (unsigned)nplasma];
o = glitch_put(out, o, "\033[1;96m");
o = glitch_put(out, o, g);
o = glitch_put(out, o, "\033[22;39m");
} else if (age <= 18) {
/* Wake: ghost real text; crest braids phosphor as it sweeps. */
int dens = 20 - age;
int hit = on_wave || (int)(h % 16) < dens / 3;
if (on_wave) {
const char *g = wake[h % (unsigned)nwake];
o = glitch_put(out, o, "\033[1;96m");
o = glitch_put(out, o, g);
o = glitch_put(out, o, "\033[22;39m");
} else if (hit && (spin + v) % 4 != 0) {
const char *g = wake[h % (unsigned)nwake];
o = glitch_put(out, o, "\033[2;36m");
o = glitch_put(out, o, g);
o = glitch_put(out, o, "\033[22;39m");
} else if (age <= 8 && (spin + age) % 5 == 0) {
o = glitch_put(out, o, "\033[1;97m");
memcpy(out + o, src + st, lens[v]);
o += lens[v];
o = glitch_put(out, o, "\033[22;39m");
} else {
o = glitch_put(out, o, "\033[2m");
memcpy(out + o, src + st, lens[v]);
o += lens[v];
o = glitch_put(out, o, "\033[22m");
}
} else if (age <= 22 && (spin + age) % 6 == 0) {
o = glitch_put(out, o, "\033[1m");
memcpy(out + o, src + st, lens[v]);
o += lens[v];
o = glitch_put(out, o, "\033[22m");
} else {
memcpy(out + o, src + st, lens[v]);
o += lens[v];
}
cursor = st + lens[v];
}
/* Trailing newlines / SGR after the last glyph stay as-is — caret already
* sits on the tip character above, so it tracks the text, not col 0. */
if (cursor < n) {
memcpy(out + o, src + cursor, n - cursor);
o += n - cursor;
}
out[o] = 0;
return out;
}
static size_t utf8_codepoints(const char *s, size_t n) {
size_t i = 0, c = 0;
while (i < n) {
wchar_t wc;
size_t eaten;
utf8_decode(s + i, n - i, &wc, &eaten);
if (eaten == 0)
break;
i += eaten;
c++;
}
return c;
}
static size_t utf8_advance_n(const char *s, size_t n, size_t max_chars) {
size_t i = 0, c = 0;
while (i < n && c < max_chars) {
wchar_t wc;
size_t eaten;
utf8_decode(s + i, n - i, &wc, &eaten);
if (eaten == 0)
break;
i += eaten;
c++;
}
return i;
}
static int isqrt_sz(size_t n) {
if (n <= 1)
return (int)n;
size_t x = n, y = (x + 1) / 2;
while (y < x) {
x = y;
y = (x + n / x) / 2;
}
return (int)x;
}
/* Non-linear drip: slow and readable near the tip; accelerates with backlog
* so a fast model never leaves us many seconds behind. */
static double drip_cps(size_t backlog_chars) {
if (backlog_chars == 0)
return 0;
if (backlog_chars <= 6)
return 10.0; /* hold the tip so the decode wave is readable */
if (backlog_chars <= 40)
return 16.0 + (double)backlog_chars * 0.85;
/* ~ 22 + 14*sqrt(backlog) */
double cps = 22.0 + 14.0 * (double)isqrt_sz(backlog_chars);
if (backlog_chars > 600)
cps = (double)backlog_chars * 8.0; /* drain hard */
if (cps > 900.0)
cps = 900.0;
return cps;
}
static int live_apply_shown(tui_t *t) {
free(t->live.body);
t->live.body = t->md_shown ? xstrndup(t->md_live, t->md_shown) : xstrdup("");
t->md_rendered = 0; /* force live_render */
live_render(t);
return 1;
}
/* Advance md_shown toward md_len. Returns 1 if the display changed. */
static int live_drip(tui_t *t) {
if (!t->as_live || !t->md_live || t->md_shown >= t->md_len)
return 0;
long dt = ms_since(&t->drip_at);
if (dt < 12)
return 0;
if (dt > 200)
dt = 200;
clock_gettime(CLOCK_MONOTONIC, &t->drip_at);
size_t backlog =
utf8_codepoints(t->md_live + t->md_shown, t->md_len - t->md_shown);
if (backlog == 0) {
t->md_shown = t->md_len;
return live_apply_shown(t);
}
t->drip_carry += drip_cps(backlog) * ((double)dt / 1000.0);
size_t want = (size_t)t->drip_carry;
if (want < 1) {
if (dt >= 70 && backlog > 0) {
want = 1; /* guarantee tip motion */
t->drip_carry = 0;
} else {
return 0;
}
} else {
t->drip_carry -= (double)want;
}
if (want > backlog)
want = backlog;
if (want > 64)
want = 64; /* hard per-frame cap for readability */
size_t adv =
utf8_advance_n(t->md_live + t->md_shown, t->md_len - t->md_shown, want);
if (adv == 0)
return 0;
t->md_shown += adv;
return live_apply_shown(t);
}
static void live_flush(tui_t *t) {
if (!t->as_live || !t->md_live)
return;
if (t->md_shown >= t->md_len)
return;
t->md_shown = t->md_len;
t->drip_carry = 0;
(void)live_apply_shown(t);
}
static void live_begin(tui_t *t) {
live_end_cleanup(t);
t->md_cap = MD_INIT;
t->md_live = calloc(1, t->md_cap);
t->md_len = 0;
t->md_shown = 0;
t->drip_carry = 0;
clock_gettime(CLOCK_MONOTONIC, &t->drip_at);
t->live.kind = BLK_ASSISTANT;
block_set_title(&t->live, "Assistant");
t->live.expanded = 1;
t->as_live = 1;
}
static void live_feed(tui_t *t, const char *text, size_t len) {
if (!t->as_live || !text || !len)
return;
if (t->md_len + len + 1 > t->md_cap) {
size_t nc = t->md_cap * 2;
while (nc < t->md_len + len + 1)
nc *= 2;
char *nb = realloc(t->md_live, nc);
if (!nb)
return;
t->md_live = nb;
t->md_cap = nc;
}
memcpy(t->md_live + t->md_len, text, len);
t->md_len += len;
t->md_live[t->md_len] = 0;
/* Network buffer only — screen catch-up is live_drip on the UI tick. */
}
static void live_seal(tui_t *t) {
if (!t->as_live)
return;
live_flush(t);
block_t *b = blocks_push(&t->hist);
if (b) {
b->kind = BLK_ASSISTANT;
block_set_title(b, "Assistant");
if (t->md_len)
block_set_body(b, t->md_live, t->md_len);
else if (t->live.body)
block_set_body(b, t->live.body, strlen(t->live.body));
b->expanded = 1;
block_render_assistant(b);
}
live_end_cleanup(t);
}
static void collapse_open_think(tui_t *t) {
if (t->think_idx < 0 || t->think_idx >= t->hist.n)
return;
block_t *th = &t->hist.items[t->think_idx];
if (th->kind != BLK_THINK)
return;
if (!t->show_thinking)
th->expanded = 0;
char title[64];
if (t->think_timing) {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
double sec = (double)(now.tv_sec - t->think_t0.tv_sec) +
(double)(now.tv_nsec - t->think_t0.tv_nsec) / 1e9;
if (sec < 0)
sec = 0;
if (sec < 1.0)
snprintf(title, sizeof(title), "Thought: %dms", (int)(sec * 1000));
else
snprintf(title, sizeof(title), "Thought: %.1fs", sec);
t->think_timing = 0;
} else {
snprintf(title, sizeof(title), "Thought");
}
block_set_title(th, title);
t->think_idx = -1;
}
static int find_tool_block(tui_t *t, const char *id) {
if (!id || !id[0])
return -1;
for (int i = t->hist.n - 1; i >= 0; i--) {
block_t *b = &t->hist.items[i];
if (b->kind == BLK_TOOL && strcmp(b->tool_id, id) == 0)
return i;
}
return -1;
}
#define msg_is_tool_result cst_message_is_tool_result
/* Strip the "[tool …]" / "result <id> exit=N <n>" header and "end" trailer. */
static void tool_result_view(const char *content, const char **body,
size_t *len) {
const char *s = content ? content : "";
size_t n = strlen(s);
int framed = strncmp(s, "result ", 7) == 0;
if (framed || strncmp(s, "[tool ", 6) == 0) {
const char *nl = strchr(s, '\n');
if (nl) {
n -= (size_t)(nl + 1 - s);
s = nl + 1;
} else {
n = 0;
}
}
if (framed && n >= 4 && memcmp(s + n - 4, "\nend", 4) == 0)
n -= 4;
else if (framed && n == 3 && memcmp(s, "end", 3) == 0)
n = 0;
*body = s;
*len = n;
}
static void attach_tool_output_from_session(tui_t *t, block_t *b) {
if (!b || b->kind != BLK_TOOL || !b->tool_id[0])
return;
cst_session_t *s = cst_agent_session(t->agent);
if (!s)
return;
cst_session_lock(s);
int n = cst_session_count(s);
for (int i = n - 1; i >= 0; i--) {
const cst_message_t *m = cst_session_get(s, i);
if (!msg_is_tool_result(m))
continue;
if (strcmp(m->tool_call_id, b->tool_id) != 0)
continue;
const char *body;
size_t len;
tool_result_view(m->content, &body, &len);
block_set_body(b, body, len);
break;
}
cst_session_unlock(s);
}
/* --- drawing: SGR --------------------------------------------------------- */
typedef struct {
int attrs;
int pair;
} sty_t;
static void sgr_apply(sty_t *st, int code) {
switch (code) {
case 0:
st->attrs = 0;
st->pair = 0;
break;
case 1:
st->attrs |= A_BOLD;
break;
case 2:
st->attrs |= A_DIM;
break;
case 3:
#ifdef A_ITALIC
st->attrs |= A_ITALIC;
#endif
break;
case 4:
st->attrs |= A_UNDERLINE;
break;
case 7:
st->attrs |= A_REVERSE;
break;
case 22:
st->attrs &= ~(A_BOLD | A_DIM);
break;
case 23:
#ifdef A_ITALIC
st->attrs &= ~A_ITALIC;
#endif
break;
case 24:
st->attrs &= ~A_UNDERLINE;
break;
case 27:
st->attrs &= ~A_REVERSE;
break;
case 31:
case 91:
st->pair = CP_ERROR;
break;
case 32:
case 92:
st->pair = CP_SGR_GREEN;
break;
case 33:
case 93:
st->pair = CP_SGR_YELLOW;
break;
case 36:
case 96:
st->pair = CP_SGR_CYAN;
break;
case 35:
case 95:
st->pair = CP_SGR_MAGENTA;
break;
case 34:
case 94:
st->pair = CP_ACCENT;
break;
case 90:
st->pair = CP_MUTED;
break;
case 97:
st->pair = CP_BRIGHT;
break;
case 39:
st->pair = 0;
break;
default:
break;
}
}
static size_t sgr_parse(const char *s, size_t n, sty_t *st) {
if (n < 3 || (unsigned char)s[0] != 0x1b || s[1] != '[')
return 0;
size_t i = 2;
int val = 0, have = 0;
while (i < n) {
unsigned char c = (unsigned char)s[i++];
if (c >= '0' && c <= '9') {
val = val * 10 + (c - '0');
have = 1;
} else if (c == ';') {
sgr_apply(st, have ? val : 0);
val = 0;
have = 0;
} else if (c == 'm') {
sgr_apply(st, have ? val : 0);
return i;
} else {
return 0;
}
}
return 0;
}
static wchar_t ascii_glyph(wchar_t wc) {
if (!g_ascii || wc < 128)
return wc;
switch (wc) {
case 0x2503: /* ┃ */
case 0x2502: /* │ */
return L'|';
case 0x2500: /* ─ */
case 0x00b7: /* · */
return L'-';
case 0x25bc: /* ▼ */
case 0x2193: /* ↓ */
return L'v';
case 0x25b6: /* ▶ */
case 0x25b8: /* ▸ */
case 0x2192: /* → */
return L'>';
case 0x2190: /* ← */
return L'<';
case 0x2191: /* ↑ */
return L'^';
case 0x2026: /* … */
return L'.';
case 0x2713: /* ✓ */
return L'x';
case 0x25cb: /* ○ */
return L'o';
case 0x25cf: /* ● */
case 0x2022: /* • */
return L'*';
case 0x25a3: /* ▣ */
return L'#';
default:
return g_utf8 ? wc : L'?';
}
}
static int put_wc(WINDOW *win, int y, int col, wchar_t wc, int attrs) {
wc = ascii_glyph(wc);
cchar_t cc;
wchar_t wstr[2] = {wc, 0};
short pair = (short)PAIR_NUMBER(attrs);
attr_t a = (attr_t)(attrs & ~(attr_t)A_COLOR);
if (setcchar(&cc, wstr, a, pair, NULL) == ERR) {
if (wc < 128)
mvwaddch(win, y, col, (chtype)wc | (chtype)attrs);
else
mvwaddstr(win, y, col, "?");
return 1;
}
mvwadd_wch(win, y, col, &cc);
return utf8_width(wc);
}
/* Flatten blocks into display lines (plain text with optional ANSI in body). */
typedef struct {
char *text; /* owned line content (may include ANSI) */
int pair; /* base color pair for whole line (0 = default) */
int bold;
int dim;
int indent; /* columns left blank before text */
int fill; /* pair painted across the whole row (0 = none) */
int bar; /* pair for a "┃" in column 0 (0 = none) */
int blk; /* hist block index, -1 for live / none */
int cont; /* soft-wrapped continuation of the previous row */
int cont_pad; /* leading columns of a continuation that only indent */
} dline_t;
static int g_dl_blk = -1; /* block being flattened, stamped into dline_t */
typedef struct {
dline_t *lines;
int n;
int cap;
} dlines_t;
static void dlines_free(dlines_t *dl) {
if (!dl)
return;
for (int i = 0; i < dl->n; i++)
free(dl->lines[i].text);
free(dl->lines);
dl->lines = NULL;
dl->n = dl->cap = 0;
}
static int dlines_push(dlines_t *dl, const char *text, int pair, int bold,
int dim) {
if (dl->n >= dl->cap) {
int nc = dl->cap ? dl->cap * 2 : 256;
dline_t *ni = realloc(dl->lines, (size_t)nc * sizeof(dline_t));
if (!ni)
return -1;
dl->lines = ni;
dl->cap = nc;
}
dline_t *L = &dl->lines[dl->n++];
L->text = xstrdup(text ? text : "");
L->pair = pair;
L->bold = bold;
L->dim = dim;
L->indent = 0;
L->fill = 0;
L->bar = 0;
L->blk = g_dl_blk;
L->cont = 0;
L->cont_pad = 0;
return 0;
}
/* Raised row: bar in column 0, the rest painted with `fill`. */
static void dlines_push_box(dlines_t *dl, const char *text, int pair,
int fill, int bar) {
if (dlines_push(dl, text, pair, 0, 0) == 0) {
dl->lines[dl->n - 1].fill = fill;
dl->lines[dl->n - 1].bar = bar;
}
}
static void push_wrapped(dlines_t *dl, const char *prefix, const char *body,
int width, int pair, int bold, int dim, int ansi) {
(void)ansi;
char line[4096];
int pref_cols = prefix ? utf8_cols(prefix, strlen(prefix)) : 0;
int body_w = width - pref_cols;
if (body_w < 8)
body_w = 8;
if (!body || !body[0]) {
snprintf(line, sizeof(line), "%s", prefix ? prefix : "");
dlines_push(dl, line, pair, bold, dim);
return;
}
/* Walk body; for ANSI text, wrap on visible columns skipping SGR. */
size_t n = strlen(body);
size_t i = 0;
int first = 1, soft = 0;
while (i < n && dl->n < MAX_RENDER_LINES) {
size_t start = i;
int col = 0, pad = 0;
while (i < n) {
size_t sg = 0;
if ((unsigned char)body[i] == 0x1b) {
sty_t dummy = {0};
sg = sgr_parse(body + i, n - i, &dummy);
if (!sg)
sg = ansi_skip(body + i, n - i);
if (sg) {
i += sg;
continue;
}
}
if (body[i] == '\n') {
i++;
break;
}
wchar_t wc;
size_t eaten;
utf8_decode(body + i, n - i, &wc, &eaten);
int cw = utf8_width(wc);
if (col + cw > body_w && col > 0)
break;
col += cw;
i += eaten;
if (col >= body_w)
break;
}
size_t chunk = i - start;
/* trim trailing newline already consumed */
if (chunk > 0 && body[start + chunk - 1] == '\n')
chunk--;
if (first && prefix) {
if (chunk + strlen(prefix) + 1 < sizeof(line)) {
memcpy(line, prefix, strlen(prefix));
memcpy(line + strlen(prefix), body + start, chunk);
line[strlen(prefix) + chunk] = 0;
} else {
snprintf(line, sizeof(line), "%s", prefix);
}
first = 0;
} else {
/* continuation indent matching prefix visual width */
pad = pref_cols;
if (pad < 0)
pad = 0;
if (pad > 40)
pad = 40;
memset(line, ' ', (size_t)pad);
if (chunk + (size_t)pad + 1 < sizeof(line)) {
memcpy(line + pad, body + start, chunk);
line[pad + chunk] = 0;
} else {
line[pad] = 0;
}
}
if (dlines_push(dl, line, pair, bold, dim) == 0 && soft) {
dl->lines[dl->n - 1].cont = 1;
dl->lines[dl->n - 1].cont_pad = pad;
}
soft = body[i - 1] != '\n';
}
}
static void push_body_preview(dlines_t *dl, const char *body, int width,
int pair, int expanded, int max_lines) {
if (!body || !body[0])
return;
if (expanded) {
push_wrapped(dl, " ", body, width, pair, 0, 1, 0);
return;
}
const char *p = body;
int shown = 0;
while (*p && shown < max_lines) {
const char *nl = strchr(p, '\n');
size_t len = nl ? (size_t)(nl - p) : strlen(p);
char tmp[1024];
if (len >= sizeof(tmp))
len = sizeof(tmp) - 1;
memcpy(tmp, p, len);
tmp[len] = 0;
push_wrapped(dl, " ", tmp, width, pair, 0, 1, 0);
shown++;
if (!nl)
break;
p = nl + 1;
}
if (*p)
dlines_push(dl, " …", pair, 0, 1);
}
static int body_looks_like_diff(const char *body) {
if (!body || !body[0])
return 0;
return strstr(body, "\n--- ") || strstr(body, "\n+++ ") ||
strstr(body, "\n@@ ") || strstr(body, "\n@@-") ||
strncmp(body, "--- ", 4) == 0 || strncmp(body, "diff ", 5) == 0 ||
strstr(body, "\n--- a/") || strstr(body, "\n+++ b/");
}
static void diff_extract_path(const char *body, const char *fallback,
char *out, size_t out_sz) {
if (!out || out_sz == 0)
return;
out[0] = 0;
if (body) {
const char *p = body;
while (*p) {
const char *nl = strchr(p, '\n');
size_t len = nl ? (size_t)(nl - p) : strlen(p);
if (len > 6 && strncmp(p, "wrote ", 6) == 0) {
const char *s = p + 6;
size_t n = len - 6;
/* trim " (N bytes)" */
const char *sp = memchr(s, ' ', n);
if (sp)
n = (size_t)(sp - s);
if (n >= out_sz)
n = out_sz - 1;
memcpy(out, s, n);
out[n] = 0;
return;
}
if (len > 6 && strncmp(p, "+++ b/", 6) == 0) {
size_t n = len - 6;
if (n >= out_sz)
n = out_sz - 1;
memcpy(out, p + 6, n);
out[n] = 0;
return;
}
if (len > 6 && strncmp(p, "--- a/", 6) == 0) {
size_t n = len - 6;
if (n >= out_sz)
n = out_sz - 1;
memcpy(out, p + 6, n);
out[n] = 0;
/* keep scanning for +++ / wrote */
}
if (!nl)
break;
p = nl + 1;
}
}
if (!out[0] && fallback && fallback[0]) {
snprintf(out, out_sz, "%s", fallback);
}
}
static int parse_hunk_at(const char *line, int *old_ln, int *new_ln) {
/* @@ -old[,n] +new[,n] @@ */
if (line[0] != '@' || line[1] != '@')
return 0;
const char *p = line;
while (*p && *p != '-')
p++;
if (*p != '-')
return 0;
p++;
int o = 0, n = 0;
if (sscanf(p, "%d", &o) != 1)
return 0;
while (*p && *p != '+')
p++;
if (*p != '+')
return 0;
p++;
if (sscanf(p, "%d", &n) != 1)
return 0;
if (old_ln)
*old_ln = o;
if (new_ln)
*new_ln = n;
return 1;
}
typedef struct {
int additions;
int deletions;
int content_lines;
} diff_stats_t;
static void diff_count_stats(const char *body, diff_stats_t *st) {
memset(st, 0, sizeof(*st));
if (!body)
return;
const char *p = body;
while (*p) {
const char *nl = strchr(p, '\n');
size_t len = nl ? (size_t)(nl - p) : strlen(p);
if (len == 0) {
/* empty */
} else if (strncmp(p, "---", 3) == 0 || strncmp(p, "+++", 3) == 0 ||
strncmp(p, "diff ", 5) == 0 || strncmp(p, "index ", 6) == 0 ||
strncmp(p, "@@", 2) == 0 || strncmp(p, "wrote ", 6) == 0 ||
(p[0] == '[' && strstr(p, "tool "))) {
/* skip meta */
} else if (p[0] == '+') {
st->additions++;
st->content_lines++;
} else if (p[0] == '-') {
st->deletions++;
st->content_lines++;
} else if (p[0] == ' ' || p[0] == '\\') {
st->content_lines++;
}
if (!nl)
break;
p = nl + 1;
}
}
/* OpenCode-style: strip ---/+++/@@, line numbers, add/del backgrounds. */
static void push_structured_diff(dlines_t *dl, const char *body, int width,
int unlimited, int fold_limit) {
if (!body || !body[0])
return;
int old_ln = 0, new_ln = 0;
int shown = 0;
int total_content = 0;
/* First pass: count content lines for fold message */
{
diff_stats_t st;
diff_count_stats(body, &st);
total_content = st.content_lines;
}
int limit = unlimited ? 1000000 : fold_limit;
if (limit < 1)
limit = 1;
const char *p = body;
while (*p) {
const char *nl = strchr(p, '\n');
size_t len = nl ? (size_t)(nl - p) : strlen(p);
char raw[1024];
if (len >= sizeof(raw))
len = sizeof(raw) - 1;
memcpy(raw, p, len);
raw[len] = 0;
int skip = 0;
if (raw[0] == 0)
skip = 1;
else if (strncmp(raw, "wrote ", 6) == 0)
skip = 1;
else if (raw[0] == '[' && strstr(raw, "tool "))
skip = 1;
else if (strncmp(raw, "---", 3) == 0 || strncmp(raw, "+++", 3) == 0 ||
strncmp(raw, "diff ", 5) == 0 || strncmp(raw, "index ", 6) == 0)
skip = 1;
else if (strncmp(raw, "@@", 2) == 0) {
parse_hunk_at(raw, &old_ln, &new_ln);
skip = 1;
} else if (raw[0] == '\\') {
/* "\ No newline at end of file" */
skip = 1;
}
if (!skip && (raw[0] == '+' || raw[0] == '-' || raw[0] == ' ')) {
if (shown >= limit) {
/* keep counting via total_content; stop emitting */
break;
}
char kind = raw[0];
const char *content = raw + 1;
int lnum = 0;
char mark = ' ';
int pair = CP_DIFF_CTX;
if (kind == '+') {
lnum = new_ln++;
mark = '+';
pair = CP_DIFF_ADD;
} else if (kind == '-') {
lnum = old_ln++;
mark = '-';
pair = CP_DIFF_DEL;
} else {
lnum = new_ln;
old_ln++;
new_ln++;
mark = ' ';
pair = CP_DIFF_CTX;
}
char line[1200];
/* │ 12+│ content — left border + gutter + body */
int body_w = width - 8;
if (body_w < 8)
body_w = 8;
char clipped[1024];
size_t clen = strlen(content);
/* column-clip roughly by bytes for MVP */
if ((int)clen > body_w) {
int keep = body_w > 3 ? body_w - 3 : body_w;
memcpy(clipped, content, (size_t)keep);
if (body_w > 3)
memcpy(clipped + keep, "...", 4);
else
clipped[keep] = 0;
content = clipped;
}
snprintf(line, sizeof(line), "│%4d%c│ %s", lnum, mark, content);
/* Pad to width so row background fills like OpenCode. */
{
int cols = utf8_cols(line, strlen(line));
size_t llen = strlen(line);
while (cols < width && llen + 1 < sizeof(line)) {
line[llen++] = ' ';
line[llen] = 0;
cols++;
}
}
dlines_push(dl, line, pair, kind != ' ', 0);
shown++;
}
if (!nl)
break;
p = nl + 1;
}
if (!unlimited && total_content > shown) {
char more[96];
snprintf(more, sizeof(more), "│ … %d more lines · /details",
total_content - shown);
dlines_push(dl, more, CP_MUTED, 0, 1);
}
}
static void push_diff_or_body(dlines_t *dl, const char *body, int width,
int default_pair, int expanded, int max_lines,
int details_all) {
if (!body || !body[0])
return;
if (!body_looks_like_diff(body)) {
push_body_preview(dl, body, width, default_pair, expanded || details_all,
max_lines);
return;
}
/* Auto-expanded write cards still fold at DIFF_FOLD_LINES unless /details. */
int unlimited = details_all;
int fold = expanded ? DIFF_FOLD_LINES : max_lines;
if (!expanded && !details_all)
fold = 0; /* title-only when collapsed */
if (fold == 0 && !unlimited)
return;
push_structured_diff(dl, body, width, unlimited, fold);
}
static void block_to_lines(const block_t *b, int width, int details_all,
dlines_t *dl) {
char head[512];
switch (b->kind) {
case BLK_USER: {
int n0 = dl->n;
dlines_push_box(dl, "", CP_BOX, CP_BOX, CP_BOX_ACCENT);
push_wrapped(dl, " ", b->body ? b->body : "", width - 1, CP_BOX, 0, 0, 0);
for (int i = n0 + 1; i < dl->n; i++) {
dl->lines[i].fill = CP_BOX;
dl->lines[i].bar = CP_BOX_ACCENT;
}
dlines_push_box(dl, "", CP_BOX, CP_BOX, CP_BOX_ACCENT);
dlines_push(dl, "", 0, 0, 0);
break;
}
case BLK_ASSISTANT: {
const char *src = b->render ? b->render : (b->body ? b->body : "");
push_wrapped(dl, "", src, width, CP_PANEL, 0, 0, 1);
dlines_push(dl, "", 0, 0, 0);
break;
}
case BLK_THINK:
snprintf(head, sizeof(head), "%s %s", b->expanded ? "-" : "+",
b->title ? b->title : "Thought");
dlines_push(dl, head, CP_PRIMARY, 0, 0);
if (b->expanded)
push_wrapped(dl, " ", b->body ? b->body : "", width, CP_MUTED, 0, 1, 0);
dlines_push(dl, "", 0, 0, 0);
break;
case BLK_FOOTER:
snprintf(head, sizeof(head), "\033[34m%s\033[0m %s",
g_ascii ? "#" : "▣", b->body ? b->body : "");
dlines_push(dl, head, CP_MUTED, 0, 0);
dlines_push(dl, "", 0, 0, 0);
break;
case BLK_TOOL: {
const char *px = tool_prefix(b->tool_name);
int exp = details_all || b->expanded;
int denied = (strncmp(b->meta, "denied", 6) == 0);
int is_write = (strcmp(b->tool_name, "write_file") == 0);
int is_file_edit = cst_tool_is_write(b->tool_name);
const char *verb_done = is_write ? "Wrote" : "Edit";
const char *verb_run = is_write ? "Write" : "Edit";
if (strcmp(b->tool_name, "delete_file") == 0)
verb_done = "Deleted", verb_run = "Delete";
else if (strcmp(b->tool_name, "move_file") == 0)
verb_done = "Moved", verb_run = "Move";
int done = (strncmp(b->meta, "exit=", 5) == 0) ||
(strcmp(b->meta, "done") == 0) ||
(b->body && body_looks_like_diff(b->body));
if (is_file_edit && !denied) {
char path[256];
diff_extract_path(b->body, b->title, path, sizeof(path));
if (!path[0] && b->title && b->title[0])
snprintf(path, sizeof(path), "%s", b->title);
if (!path[0])
snprintf(path, sizeof(path), "file");
diff_stats_t st = {0};
if (b->body)
diff_count_stats(b->body, &st);
char head[512];
if (done) {
/* OpenCode: ← Wrote path / ← Edit path +N -M */
snprintf(head, sizeof(head),
"│ ← %s %.200s \033[32m+%d\033[0m \033[31m-%d\033[0m",
verb_done, path, st.additions, st.deletions);
} else {
snprintf(head, sizeof(head), "│ ← %s %.200s %.100s", verb_run, path,
b->meta[0] ? b->meta : "…");
}
dlines_push(dl, head, CP_MUTED, 0, 0);
if (done || exp)
push_diff_or_body(dl, b->body, width, CP_MUTED, b->expanded,
TOOL_PREVIEW_LINES, details_all);
} else {
char status_short[96];
status_short[0] = 0;
if (denied) {
snprintf(status_short, sizeof(status_short), "denied");
} else if (strncmp(b->meta, "exit=", 5) == 0) {
snprintf(status_short, sizeof(status_short), "done %.80s", b->meta);
} else if (b->meta[0]) {
snprintf(status_short, sizeof(status_short), "%.80s", b->meta);
}
snprintf(head, sizeof(head), "%s %s %s%s%s", px,
b->tool_name[0] ? b->tool_name : "tool",
b->title ? b->title : "",
status_short[0] ? " " : "", status_short);
dlines_push(dl, head, denied ? CP_ERROR : CP_TOOL, 1, 0);
push_diff_or_body(dl, b->body, width, denied ? CP_ERROR : CP_MUTED, exp,
TOOL_PREVIEW_LINES, details_all);
}
break;
}
case BLK_STATUS:
case BLK_SYSTEM:
snprintf(head, sizeof(head), "%s%s%s", b->title ? b->title : "",
(b->title && b->body && b->body[0]) ? " " : "",
b->body ? b->body : "");
push_wrapped(dl, "", head, width, CP_MUTED, 0, 1, 0);
break;
}
}
/* Non-box lines sit two columns right of the user-message bar. */
#define LOG_INDENT 2
static void block_to_lines_indented(const block_t *b, int width,
int details_all, dlines_t *dl) {
int n0 = dl->n;
if (b->kind == BLK_USER) {
block_to_lines(b, width, details_all, dl);
return;
}
block_to_lines(b, width - LOG_INDENT, details_all, dl);
for (int i = n0; i < dl->n; i++)
if (!dl->lines[i].fill)
dl->lines[i].indent = LOG_INDENT;
}
static void build_display(tui_t *t, dlines_t *dl, int width) {
dlines_free(dl);
dl->lines = NULL;
dl->n = dl->cap = 0;
for (int i = 0; i < t->hist.n; i++) {
g_dl_blk = i;
block_to_lines_indented(&t->hist.items[i], width, t->details_all, dl);
}
g_dl_blk = -1;
if (t->as_live) {
block_t live = t->live;
char *glitch = NULL;
if (t->motion_on && t->busy) {
const char *src =
t->live.render ? t->live.render : (t->live.body ? t->live.body : "");
glitch = glitch_stream_frontier(src, t->spin);
if (glitch)
live.render = glitch;
}
block_to_lines_indented(&live, width, t->details_all, dl);
free(glitch);
}
}
/* Draws s at (y, x0) within maxw columns; returns the columns used. */
static int draw_ansi_at(WINDOW *win, int y, int x0, int maxw, const char *s,
int base_pair, int base_bold, int base_dim) {
sty_t sty = {0};
sty.pair = base_pair;
if (base_bold)
sty.attrs |= A_BOLD;
if (base_dim)
sty.attrs |= A_DIM;
int col = 0;
size_t n = s ? strlen(s) : 0;
size_t i = 0;
while (i < n && col < maxw) {
size_t sg = sgr_parse(s + i, n - i, &sty);
if (sg) {
i += sg;
continue;
}
size_t skip = ansi_skip(s + i, n - i);
if (skip) {
i += skip;
continue;
}
wchar_t wc;
size_t eaten;
utf8_decode(s + i, n - i, &wc, &eaten);
wc = ascii_glyph(wc);
int cw = utf8_width(wc);
if (col + cw > maxw)
break;
int attrs = sty.attrs;
if (sty.pair && HAS_COLOR())
attrs |= COLOR_PAIR(sty.pair);
else if (base_pair && HAS_COLOR())
attrs |= COLOR_PAIR(base_pair);
put_wc(win, y, x0 + col, wc, attrs);
col += cw;
i += eaten;
}
return col;
}
static void draw_ansi_line(WINDOW *win, int y, int maxw, const char *s,
int base_pair, int base_bold, int base_dim) {
draw_ansi_at(win, y, 0, maxw, s, base_pair, base_bold, base_dim);
}
static double since_ms(const struct timespec *t0) {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (double)(now.tv_sec - t0->tv_sec) * 1000.0 +
(double)(now.tv_nsec - t0->tv_nsec) / 1e6;
}
static const char *dot_sep(void) { return g_ascii ? " - " : " · "; }
/* Branch from the nearest .git/HEAD at or above dir (worktree files too). */
static void git_branch(const char *dir, char *out, size_t sz) {
out[0] = 0;
char d[OS_PATH_MAX], p[OS_PATH_MAX + 64], line[OS_PATH_MAX];
snprintf(d, sizeof(d), "%s", dir);
FILE *f = NULL;
for (int depth = 0; depth < 64 && d[0]; depth++) {
snprintf(p, sizeof(p), "%s/.git/HEAD", d);
if ((f = fopen(p, "rb")) != NULL)
break;
snprintf(p, sizeof(p), "%s/.git", d);
FILE *g = fopen(p, "rb");
if (g) {
int ok = fgets(line, sizeof(line), g) && strncmp(line, "gitdir: ", 8) == 0;
fclose(g);
if (ok) {
line[strcspn(line, "\r\n")] = 0;
int n = line[8] == '/'
? snprintf(p, sizeof(p), "%s/HEAD", line + 8)
: snprintf(p, sizeof(p), "%s/%s/HEAD", d, line + 8);
if (n > 0 && (size_t)n < sizeof(p))
f = fopen(p, "rb");
break;
}
}
char *slash = strrchr(d, '/');
if (!slash || slash == d)
break;
*slash = 0;
}
if (!f)
return;
if (fgets(line, sizeof(line), f)) {
line[strcspn(line, "\r\n")] = 0;
const char *ref = strncmp(line, "ref: refs/heads/", 16) == 0 ? line + 16
: line;
size_t n = ref == line ? 7 : strlen(ref);
if (n >= sz)
n = sz - 1;
memcpy(out, ref, n);
out[n] = 0;
}
fclose(f);
}
static void where_label(const char *ws, char *out, size_t sz) {
const char *home = getenv("HOME");
size_t hl = home ? strlen(home) : 0;
char br[128];
git_branch(ws, br, sizeof(br));
int tilde = hl > 1 && strncmp(ws, home, hl) == 0 && (ws[hl] == '/' || !ws[hl]);
int n = snprintf(out, sz, "%s%s%s%s", tilde ? "~" : "", tilde ? ws + hl : ws,
br[0] ? ":" : "", br);
if (n < 0)
out[0] = 0;
}
/* Display columns of s, ignoring escape sequences. */
static int vis_cols(const char *s) {
int cols = 0;
size_t n = strlen(s), i = 0;
while (i < n) {
size_t sk = ansi_skip(s + i, n - i);
if (sk) {
i += sk;
continue;
}
wchar_t wc;
size_t eaten;
utf8_decode(s + i, n - i, &wc, &eaten);
cols += utf8_width(wc);
i += eaten;
}
return cols;
}
static const char *spinner_frame(const tui_t *t) {
static const char *const uni[] = {"⠋", "⠙", "⠹", "⠸", "⠼",
"⠴", "⠦", "⠧", "⠇", "⠏"};
static const char *const asc[] = {"|", "/", "-", "\\"};
return g_ascii ? asc[t->spin % 4] : uni[t->spin % 10];
}
/* Seat Deck motion: short glyph sequences driven by t->spin (80ms ticks). */
static const char *motion_fleet_frame(const tui_t *t) {
static const char *const uni[] = {"✦ · ·", "· ✦ ·", "· · ✦", "· ✦ ·"};
static const char *const asc[] = {"* . .", ". * .", ". . *", ". * ."};
int i = (t->spin / 2) % 4;
return g_ascii ? asc[i] : uni[i];
}
static const char *seat_pulse_dot(const tui_t *t) {
int on = 1;
if (t->motion_on) {
if (t->busy)
on = (t->spin % 2) == 0;
else
on = ((t->spin / 6) % 2) == 0;
}
if (g_ascii)
return on ? "*" : ".";
return on ? "●" : "○";
}
static int realm_fp_short(char out[9]) {
out[0] = 0;
cst_peer_info_t peers[1];
if (cst_discover_local_list(NULL, peers, 1) > 0 && peers[0].realm_fp[0]) {
snprintf(out, 9, "%.8s", peers[0].realm_fp);
return 0;
}
char dir[OS_PATH_MAX];
cst_realm_keys_t k;
char fp[33];
if (cst_realm_dir(dir, sizeof(dir)) == 0 && cst_realm_load(dir, &k) == 0 &&
cst_realm_fingerprint(k.pk, fp) == 0) {
snprintf(out, 9, "%.8s", fp);
return 0;
}
return -1;
}
static void draw_ctx_bar(char *out, size_t sz, int pct, int pulse_on) {
int bar_w = 12;
int fill = pct <= 0 ? 0 : pct >= 100 ? bar_w : (pct * bar_w) / 100;
size_t o = 0;
out[0] = 0;
for (int i = 0; i < bar_w && o + 4 < sz; i++) {
const char *cell;
if (i < fill) {
if (pulse_on && pct >= 80 && i == fill - 1)
cell = g_ascii ? "*" : "▓";
else
cell = g_ascii ? "=" : "█";
} else {
cell = g_ascii ? "." : "░";
}
size_t n = strlen(cell);
memcpy(out + o, cell, n);
o += n;
out[o] = 0;
}
}
static void fmt_tokens(long n, char *out, size_t sz) {
if (n >= 1000000)
snprintf(out, sz, "%.1fM", (double)n / 1e6);
else if (n >= 1000)
snprintf(out, sz, "%.1fK", (double)n / 1e3);
else
snprintf(out, sz, "%ld", n);
}
static int ctx_percent(const tui_t *t, long *used) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
long win = cfg ? cst_model_context_tokens(cfg->model) : 0;
*used = cst_agent_last_prompt_tokens(t->agent);
return win > 0 ? (int)(*used * 100 / win) : 0;
}
static const char *money_sym(const char *cur) {
if (g_ascii && strcmp(cur, "¥") == 0)
return "CNY ";
return cur;
}
static int approval_mode(const tui_t *t) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
if (!cfg)
return CST_APPROVAL_ASK;
return cfg->auto_approve ? CST_APPROVAL_YOLO : cfg->approval;
}
static void approval_label(const tui_t *t, char *out, size_t sz) {
int a = approval_mode(t);
if (a == CST_APPROVAL_YOLO)
snprintf(out, sz, "YOLO");
else if (a == CST_APPROVAL_AUTO)
snprintf(out, sz, "auto%s%s", dot_sep(), t->sandbox_name);
else
snprintf(out, sz, "ask");
}
/* Bottom row: path / status left; palette hint right. CTX lives in Seat Deck. */
static void draw_status(tui_t *t) {
if (!t->status)
return;
werase(t->status);
if (HAS_COLOR())
wbkgd(t->status, COLOR_PAIR(CP_PANEL));
int w = getmaxx(t->status);
int sx = getbegx(t->status);
char right[160];
int rw;
t->cmd_x0 = t->cmd_x1 = -1;
if (t->home) {
snprintf(right, sizeof(right), "%s", CST_VERSION);
rw = utf8_cols(right, strlen(right));
draw_ansi_at(t->status, 0, w - rw, rw, right, CP_MUTED, 0, 0);
} else {
snprintf(right, sizeof(right), "\033[97mctrl+p\033[39m commands");
rw = vis_cols(right);
if (rw > w)
rw = w;
draw_ansi_at(t->status, 0, w - rw, rw, right, CP_MUTED, 0, 0);
t->cmd_x1 = sx + w - 1;
t->cmd_x0 = t->cmd_x1 - (int)strlen("ctrl+p commands") + 1;
}
int lw = w - rw - 2;
if (lw < 1)
return (void)wrefresh(t->status);
if (t->busy) {
char line[600];
snprintf(line, sizeof(line), "\033[33m%s\033[39m %s", spinner_frame(t),
t->status_msg[0] ? t->status_msg : "working");
draw_ansi_at(t->status, 0, 0, lw, line, CP_MUTED, 0, 0);
} else if (t->status_msg[0]) {
draw_ansi_at(t->status, 0, 0, lw, t->status_msg, CP_MUTED, 0, 0);
} else {
draw_ansi_at(t->status, 0, 0, lw, t->where, CP_MUTED, 0, 0);
}
wrefresh(t->status);
}
/* Short mode row: Build/Plan · effort — model lives in the topbar. */
static void draw_mode_line(tui_t *t, WINDOW *w, int y, int maxw) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
if (!cfg)
return;
const char *eff = cst_effort_wire(cfg->provider, cfg->model, cfg->effort)
? cfg->effort
: NULL;
char right[160];
int rpair = CP_BOX_MUTED;
if (t->busy) {
snprintf(right, sizeof(right), "%s %.0fs esc", spinner_frame(t),
since_ms(&t->turn_t0) / 1000.0);
rpair = CP_BOX_PRIMARY;
} else {
approval_label(t, right, sizeof(right));
if (approval_mode(t) == CST_APPROVAL_YOLO)
rpair = CP_BOX_PRIMARY;
}
int rw = utf8_cols(right, strlen(right));
int x = 2, room = maxw - x - rw - 2;
if (room < 8) {
rw = 0;
room = maxw - x - 1;
}
x += draw_ansi_at(w, y, x, room, cfg->plan_mode ? "Plan" : "Build",
cfg->plan_mode ? CP_BOX_PRIMARY : CP_BOX_ACCENT, 1, 0);
if (eff) {
x += draw_ansi_at(w, y, x, room - x + 2, dot_sep(), CP_BOX_MUTED, 0, 0);
x += draw_ansi_at(w, y, x, room - x + 2, eff, CP_BOX_PRIMARY, 0, 0);
}
int npick = cst_agent_pending_count(t->agent);
if (npick > 0) {
char mark[32];
snprintf(mark, sizeof(mark), "%d img", npick);
x += draw_ansi_at(w, y, x, room - x + 2, dot_sep(), CP_BOX_MUTED, 0, 0);
draw_ansi_at(w, y, x, room - x + 2, mark, CP_BOX_PRIMARY, 0, 0);
}
if (rw)
draw_ansi_at(w, y, maxw - rw - 2, rw, right, rpair, 0, 0);
}
/* Visual position of every byte offset: soft wrap at the window edge plus
* hard newlines. Calls fn(ud, offset, row, col, wc, width) per character and
* returns the row count; cursor row/col are reported for t->cur. */
typedef void (*input_cell_fn)(void *ud, int row, int col, wchar_t wc, int cw);
#define IN_COL 2 /* text column inside the input box (bar at 0) */
static int input_layout(const tui_t *t, input_cell_fn fn, void *ud,
int *cur_row, int *cur_col) {
int row = 0, col = IN_COL;
int right = (t->in_w > 0 ? t->in_w : t->cols) - 2;
*cur_row = 0;
*cur_col = IN_COL;
size_t i = 0;
while (i < t->in_len) {
if (i == t->cur) {
*cur_row = row;
*cur_col = col;
}
if (t->inbuf[i] == '\n') {
row++;
col = IN_COL;
i++;
continue;
}
wchar_t wc;
size_t eaten;
utf8_decode(t->inbuf + i, t->in_len - i, &wc, &eaten);
if (wc == L'\t')
wc = L' ';
int cw = utf8_width(wc);
if (col + cw > right && col > IN_COL) {
row++;
col = IN_COL;
}
if (fn)
fn(ud, row, col, wc, cw);
col += cw;
i += eaten;
}
if (t->cur >= t->in_len) {
if (col >= right) {
row++;
col = IN_COL;
}
*cur_row = row;
*cur_col = col;
}
return row + 1;
}
typedef struct {
WINDOW *win;
int top, h, attrs;
int row0; /* window row of the first visible input line */
} input_draw_t;
static void input_draw_cell(void *ud, int row, int col, wchar_t wc, int cw) {
(void)cw;
input_draw_t *d = ud;
if (row < d->top || row >= d->top + d->h)
return;
put_wc(d->win, d->row0 + row - d->top, col, wc, d->attrs);
}
static void draw_input(tui_t *t) {
if (!t->input)
return;
WINDOW *w = t->input;
if (HAS_COLOR())
wbkgd(w, COLOR_PAIR(CP_BOX));
werase(w);
int bh, bw;
getmaxyx(w, bh, bw);
int h = t->input_rows > 0 ? t->input_rows : 1;
int pad = (bh - h - 1) / 2; /* rows above the text */
if (pad < 0)
pad = 0;
int bar = HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX_ACCENT) : 0;
int edge = HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX_MUTED) : 0;
/* Composer card frame. */
if (bh >= 2 && bw >= 4) {
put_wc(w, 0, 0, g_ascii ? L'+' : L'┌', bar);
for (int x = 1; x < bw - 1; x++)
put_wc(w, 0, x, g_ascii ? L'-' : L'─', edge);
put_wc(w, 0, bw - 1, g_ascii ? L'+' : L'┐', edge);
put_wc(w, bh - 1, 0, g_ascii ? L'+' : L'└', edge);
for (int x = 1; x < bw - 1; x++)
put_wc(w, bh - 1, x, g_ascii ? L'-' : L'─', edge);
put_wc(w, bh - 1, bw - 1, g_ascii ? L'+' : L'┘', edge);
}
for (int r = 1; r < bh - 1; r++) {
put_wc(w, r, 0, L'┃', bar);
put_wc(w, r, bw - 1, g_ascii ? L'|' : L'│', edge);
}
draw_mode_line(t, w, pad + h, bw);
int cr, cc;
input_layout(t, NULL, NULL, &cr, &cc);
int top = cr >= h ? cr - h + 1 : 0;
if (top > 0)
put_wc(w, pad, 1, g_ascii ? L'^' : L'↑',
HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX_MUTED) : 0);
if (t->in_len == 0) {
const char *hint =
t->busy ? "Enter redirects the active task"
: (g_ascii ? "Ask anything... \"Fix a TODO in this repo\""
: "Ask anything… \"Fix a TODO in this repo\"");
draw_ansi_at(w, pad, IN_COL, bw - IN_COL - 1, hint, CP_BOX_MUTED, 0, 0);
wmove(w, pad, IN_COL);
wrefresh(w);
return;
}
input_draw_t d = {w, top, h, HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX) : 0, pad};
input_layout(t, input_draw_cell, &d, &cr, &cc);
if (cc >= bw)
cc = bw - 1;
wmove(w, pad + cr - top, cc);
wrefresh(w);
}
/* Popup of up to `room` rows at logw (y, x): growing upward from y when
* up, else downward. Rows are recorded for mouse picks. */
static int g_comp_y0 = -1, g_comp_x0, g_comp_w;
static void draw_completion(tui_t *t, int y, int x, int bw, int room, int up) {
t->comp_drawn = t->comp_n;
g_comp_y0 = -1;
if (t->comp_n <= 0 || room < 1 || bw < 4)
return;
int n = t->comp_n < room ? t->comp_n : room;
int y0 = up ? y - n + 1 : y;
g_comp_y0 = t->log_y + y0;
g_comp_x0 = t->log_x + x;
g_comp_w = bw;
for (int i = 0; i < n; i++) {
const comp_item_t *c = &t->comp[i];
int sel = i == t->comp_sel;
int attrs = HAS_COLOR() ? (int)COLOR_PAIR(sel ? CP_SEL : CP_BOX)
: (sel ? A_REVERSE : 0);
for (int k = 0; k < bw; k++)
put_wc(t->logw, y0 + i, x + k, L' ', attrs);
char line[420];
snprintf(line, sizeof(line), "%-22s ", c->text);
int used = draw_ansi_at(t->logw, y0 + i, x + 1, bw - 2, line,
sel ? CP_SEL : CP_BOX_BRIGHT, sel, 0);
if (bw - 2 - used > 0)
draw_ansi_at(t->logw, y0 + i, x + 1 + used, bw - 2 - used, c->desc,
sel ? CP_SEL : CP_BOX_MUTED, 0, 0);
}
}
/* Task list panel above the status bar while todo_write has open items. */
static int todo_open_items(const char *todo, int *total) {
int open = 0, n = 0;
for (const char *p = todo; p && *p;) {
const char *nl = strchr(p, '\n');
size_t len = nl ? (size_t)(nl - p) : strlen(p);
if (len > 0) {
n++;
if (!(len >= 3 && strncmp(p, "[x]", 3) == 0))
open++;
}
p = nl ? nl + 1 : p + len;
}
if (total)
*total = n;
return open;
}
static void draw_todo(tui_t *t) {
if (!t->todow)
return;
werase(t->todow);
if (HAS_COLOR())
wbkgd(t->todow, COLOR_PAIR(CP_PANEL));
int total = 0, open = todo_open_items(t->todo, &total);
char head[96];
snprintf(head, sizeof(head), g_ascii ? " Tasks %d/%d done (Ctrl+T hides)"
: " Tasks %d/%d done · Ctrl+T hides",
total - open, total);
draw_ansi_line(t->todow, 0, getmaxx(t->todow) - 1, head, CP_PRIMARY, 1, 0);
int row = 1, skipped = 0;
int room = t->todo_rows - 1;
/* keep the first unfinished item visible */
int first_open = 0, idx = 0;
for (const char *p = t->todo; p && *p; idx++) {
const char *nl = strchr(p, '\n');
if (strncmp(p, "[x]", 3) != 0) {
first_open = idx;
break;
}
p = nl ? nl + 1 : p + strlen(p);
}
int skip = first_open > 1 ? first_open - 1 : 0;
if (total - skip < room)
skip = total > room ? total - room : 0;
idx = 0;
for (const char *p = t->todo; p && *p && row <= room;) {
const char *nl = strchr(p, '\n');
size_t len = nl ? (size_t)(nl - p) : strlen(p);
if (len >= 3 && idx++ >= skip) {
const char *mark = "○";
int pair = CP_PANEL, dim = 0;
if (strncmp(p, "[x]", 3) == 0) {
mark = "✓";
pair = CP_SUCCESS;
dim = 1;
} else if (strncmp(p, "[~]", 3) == 0) {
mark = "▸";
pair = CP_PRIMARY;
}
char line[400];
if (row == room && total - idx > 0) {
snprintf(line, sizeof(line), " … %d more", total - idx + 1);
draw_ansi_line(t->todow, row, getmaxx(t->todow) - 1, line, CP_MUTED, 0, 1);
break;
}
const char *txt = p + 3;
while (txt < p + len && *txt == ' ')
txt++;
snprintf(line, sizeof(line), " %s %.*s", mark, (int)(p + len - txt),
txt);
draw_ansi_line(t->todow, row++, getmaxx(t->todow) - 1, line, pair, 0, dim);
} else if (len >= 3) {
skipped++;
}
p = nl ? nl + 1 : p + len;
}
(void)skipped;
wrefresh(t->todow);
}
/* --- start screen ---------------------------------------------------------- */
/* 3-row block font for "HERMIT"; each glyph is 4 columns wide. */
static const char *const LOGO[6][3] = {
{"█ █", "█▀▀█", "█ █"}, /* H */
{"█▀▀▀", "█▀▀ ", "▀▀▀▀"}, /* E */
{"█▀▀█", "█▀▀█", "▀ ▀"}, /* R */
{"█▀▄█", "█ █", "▀ ▀"}, /* M */
{"▀█▀▀", " █ ", " ▀▀▀"}, /* I */
{"▀█▀▀", " █ ", " ▀▀▀"}, /* T */
};
#define LOGO_N 6
#define LOGO_W (LOGO_N * 5 - 1)
#define LOGO_H 3
static int hermit_role_pair(int role) {
switch (role) {
case 0:
return CP_TOOL; /* body */
case 1:
return CP_ACCENT; /* shell */
case 2:
return CP_ASSIST; /* eye */
case 3:
return COLORS >= 256 ? CP_MUTED : CP_ERROR; /* brick legs */
case 4:
return CP_SGR_CYAN; /* >_ */
case 5:
return CP_ASSIST; /* ? ! claw mark */
default:
return CP_MUTED;
}
}
static void draw_hermit_icon(WINDOW *win, int y0, int x0, hermit_state_t st,
int with_shell, int frame) {
hermit_icon_mode_t mode = hermit_icon_mode_eff(g_ascii);
if (mode == HERMIT_ICON_FONT) {
wchar_t wc = hermit_font_codepoint(st, frame, with_shell);
cchar_t cc;
wchar_t wstr[2] = {wc, 0};
if (setcchar(&cc, wstr, 0, 0, NULL) == OK) {
if (HAS_COLOR())
wattron(win, COLOR_PAIR(CP_TOOL));
mvwadd_wch(win, y0, x0, &cc);
if (HAS_COLOR())
wattroff(win, COLOR_PAIR(CP_TOOL));
return;
}
}
const char *lines[8];
int n = hermit_shell_art_frame(st, with_shell, g_ascii, frame, lines, 8);
for (int r = 0; r < n; r++) {
const char *row = lines[r];
for (int c = 0; row[c]; c++) {
char raw = row[c];
if (raw == ' ')
continue;
char draw = (raw == '@') ? '#' : raw;
int role = 0;
if (raw == '@')
role = 1;
else if (raw == '#' )
role = (with_shell && r <= 2) ? 1 : 0;
else if (raw == '>' || raw == '_')
role = 4;
else if (raw == ':' || raw == '=' || raw == '^' || raw == 'x')
role = 2;
else if (raw == '|')
role = 3;
else if (raw == '?' || raw == '!' || raw == '~')
role = 5;
else
role = hermit_shell_cell_role(st, with_shell, r, c);
char ch[2] = {draw, 0};
draw_ansi_at(win, y0 + r, x0 + c, 1, ch, hermit_role_pair(role), 0, 0);
}
}
}
static void draw_hermit_badge(WINDOW *win, int y, int x, const tui_t *t) {
hermit_state_t st = hermit_state_from_ui(
t->busy, t->ask_pending || t->awaiting_perm, 0);
hermit_icon_mode_t mode = hermit_icon_mode_eff(g_ascii);
if (mode == HERMIT_ICON_FONT) {
wchar_t wc = hermit_font_codepoint(st, t->spin, 0);
cchar_t cc;
wchar_t wstr[2] = {wc, 0};
if (setcchar(&cc, wstr, 0, 0, NULL) == OK) {
if (HAS_COLOR())
wattron(win, COLOR_PAIR(CP_TOOL));
mvwadd_wch(win, y, x, &cc);
if (HAS_COLOR())
wattroff(win, COLOR_PAIR(CP_TOOL));
return;
}
}
/* Compact topbar mark — avoid wide ║║ which reads as an orange slab. */
const char *b;
if (g_ascii) {
b = (st == HERMIT_ST_CODING) ? ">_" : "<>";
} else {
switch (st) {
case HERMIT_ST_QUESTION:
b = "◇?";
break;
case HERMIT_ST_ERROR:
b = "◇!";
break;
case HERMIT_ST_CODING:
b = ">_";
break;
default:
b = "◇";
break;
}
}
(void)t;
draw_ansi_at(win, y, x, 2, b, CP_TOOL, 0, 0);
}
static const char *const TIPS[] = {
"Tab switches between Build and Plan (read-only) mode",
"Shift+Tab cycles approval: ask, auto, yolo",
"Ctrl+P opens the command palette",
"Type @ to mention a file in your message",
"/effort sets reasoning effort: low, medium, high, max",
"/mode general reframes the agent for non-code work",
"/model lists the models your API key can use",
"/undo reverts the file changes of the last turn",
"/provider switches provider; keys are saved per provider",
"Click a tool card or Thought to expand it",
"Drag over the transcript to copy it; double-click copies a word",
};
static void draw_home(tui_t *t) {
int h, w;
getmaxyx(t->logw, h, w);
int box_top = t->in_y - t->log_y;
int box_bot = box_top + t->in_h;
int brand_y = box_top - 2;
if (brand_y < 0)
brand_y = 0;
int want_block = 0;
{
const char *lb = hermit_getenv("LOGO");
want_block = lb[0] && !strcmp(lb, "block");
}
hermit_state_t crab_st = hermit_state_from_ui(
t->busy, t->ask_pending || t->awaiting_perm, 0);
int crab_w = hermit_shell_art_width();
int crab_h = hermit_shell_art_height();
int crab_x = w - crab_w - 2;
int show_crab = crab_x > 20 && box_top >= crab_h + 1 && w >= 48;
if (want_block && !(g_ascii || !g_utf8 || g_logo_text) &&
box_top - LOGO_H - 2 >= 0 && w >= LOGO_W) {
int x0 = show_crab ? (crab_x - LOGO_W) / 2 : (w - LOGO_W) / 2;
if (x0 < 0)
x0 = 0;
int y0 = box_top - LOGO_H - 2;
for (int g = 0; g < LOGO_N; g++)
for (int r = 0; r < LOGO_H; r++)
draw_ansi_at(t->logw, y0 + r, x0 + g * 5, 4, LOGO[g][r], CP_ACCENT, 0,
0);
brand_y = y0 + LOGO_H;
} else {
const char *mark = "HERMIT";
int mw = (int)strlen(mark);
int x = (w - mw) / 2;
if (x < 0)
x = 0;
if (brand_y >= 1)
brand_y -= 1;
draw_ansi_at(t->logw, brand_y, x, mw, mark, CP_ACCENT, 1, 0);
}
if (show_crab) {
int cy = brand_y;
if (cy + crab_h > box_top)
cy = box_top - crab_h;
if (cy < 0)
cy = 0;
draw_hermit_icon(t->logw, cy, crab_x, crab_st, 1, t->spin);
}
int ty = brand_y + 1;
if (ty < box_top) {
const char *tag =
g_ascii ? "code . plan . parliament" : "code · plan · parliament";
int tw = (int)strlen(tag);
int x = (w - tw) / 2;
if (x < 0)
x = 0;
draw_ansi_at(t->logw, ty, x, tw, tag, CP_MUTED, 0, 0);
}
/* Compact parliament chip — never dump full status_text on the home. */
if (cst_parliament_is_active() && ty + 1 < box_top) {
cst_parliament_meta_t m;
memset(&m, 0, sizeof(m));
cst_parliament_load(&m);
char sid[64] = {0};
int sess = cst_parliament_session_ok(sid, sizeof(sid));
int chair = sess && cst_parliament_is_chair();
char chip[96];
snprintf(chip, sizeof(chip), "%s parliament · rev=%lu · %s",
g_ascii ? "*" : "◆", (unsigned long)m.revision,
!sess ? "login" : chair ? "chair" : "member");
int cw = vis_cols(chip);
if (cw > w - 4)
cw = w - 4;
int cx = (w - cw) / 2;
if (cx < 0)
cx = 0;
draw_ansi_at(t->logw, ty + 1, cx, cw, chip, CP_ACCENT, 0, 0);
}
int right = t->in_x - t->log_x + t->in_w;
if (box_bot < h) {
char hint[128];
snprintf(hint, sizeof(hint),
"\033[97mtab\033[39m modes \033[97mctrl+p\033[39m commands "
"\033[97mshift+tab\033[39m approval");
int hw = vis_cols(hint);
if (right - hw >= 0)
draw_ansi_at(t->logw, box_bot, right - hw, hw, hint, CP_MUTED, 0, 0);
}
if (t->tip && box_bot + 2 < h) {
char tip[256];
snprintf(tip, sizeof(tip), "%s %s", g_ascii ? "*" : "●", t->tip);
int tw = vis_cols(tip);
if (tw > w - 4)
tw = w - 4;
draw_ansi_at(t->logw, box_bot + 2, (w - tw) / 2, tw, tip, CP_TOOL, 0, 0);
}
const char *ext = cst_extensions_report();
if (ext[0] && box_bot + 3 < h) {
int ew = vis_cols(ext);
if (ew > w - 2)
ew = w - 2;
draw_ansi_at(t->logw, box_bot + 3, (w - ew) / 2, ew, ext, CP_MUTED, 0, 0);
}
int room = h - box_bot;
draw_completion(t, box_bot, t->in_x - t->log_x, t->in_w, room, 0);
}
/* --- sidebar --------------------------------------------------------------- */
typedef struct {
char path[200];
int add, del;
} mod_file_t;
/* Files changed by write tools this session, newest stats merged per path. */
static int collect_modified(const tui_t *t, mod_file_t *out, int max) {
int n = 0;
for (int i = 0; i < t->hist.n; i++) {
const block_t *b = &t->hist.items[i];
if (b->kind != BLK_TOOL || !cst_tool_is_write(b->tool_name) ||
strncmp(b->meta, "denied", 6) == 0 || !b->body || !b->body[0])
continue;
char path[256];
diff_extract_path(b->body, b->title, path, sizeof(path));
if (!path[0])
continue;
diff_stats_t st;
diff_count_stats(b->body, &st);
int k = 0;
while (k < n && strcmp(out[k].path, path) != 0)
k++;
if (k == n) {
if (n >= max)
continue;
snprintf(out[n].path, sizeof(out[n].path), "%.199s", path);
out[n].add = out[n].del = 0;
n++;
}
out[k].add += st.additions;
out[k].del += st.deletions;
}
return n;
}
static void draw_side(tui_t *t) {
if (!t->side)
return;
WINDOW *s = t->side;
if (HAS_COLOR())
wbkgd(s, COLOR_PAIR(CP_SIDE));
werase(s);
int h, w;
getmaxyx(s, h, w);
const char *rail = g_ascii ? "|" : "┃";
for (int ry = 0; ry < h; ry++)
draw_ansi_at(s, ry, 0, 1, rail, CP_SIDE_PRIMARY, 0, 0);
int x = 2, mw = w - 3, y = 1;
if (mw < 8)
mw = w > 2 ? w - 2 : 1;
char line[512];
int bottom = h - 3; /* where + version */
/* SEAT — role on one line; instance + realm indented under it */
draw_ansi_at(s, y++, x, mw, "seat", CP_SIDE_MUTED, 0, 0);
{
char sid[64] = {0};
int sess = cst_parliament_session_ok(sid, sizeof(sid));
int chair = sess && cst_parliament_is_chair();
const char *role = !sess ? "logged-out" : chair ? "chair" : "member";
int pair = !sess ? CP_SIDE_MUTED
: chair ? CP_SIDE_SUCCESS : CP_SIDE_PRIMARY;
int dx = x;
dx += draw_ansi_at(s, y, dx, 2, seat_pulse_dot(t), pair, 0, 0);
dx += draw_ansi_at(s, y, dx, 1, " ", pair, 0, 0);
draw_ansi_at(s, y, dx, mw - (dx - x), role, pair, 1, 0);
y++;
int ix = x + 2; /* align under role text, past the pulse */
int imw = mw - 2;
if (imw < 6) {
ix = x;
imw = mw;
}
if (!sid[0])
cst_parliament_default_instance(sid, sizeof(sid));
if (sid[0] && y < bottom) {
size_t fit = utf8_fit_cols(sid, strlen(sid), imw);
snprintf(line, sizeof(line), "%.*s", (int)fit, sid);
draw_ansi_at(s, y++, ix, imw, line, CP_SIDE_MUTED, 0, 0);
}
char fp[9];
if (realm_fp_short(fp) == 0 && y < bottom) {
snprintf(line, sizeof(line), "realm %.8s", fp);
draw_ansi_at(s, y++, ix, imw, line, CP_SIDE_MUTED, 0, 0);
}
}
y++;
/* FLEET — peers / daemon / revision */
if (y + 1 < bottom) {
draw_ansi_at(s, y++, x, mw, "fleet", CP_SIDE_MUTED, 0, 0);
int pid = 0, port = 0;
char diid[64] = {0};
int svc = cst_parliament_daemon_info(&pid, diid, sizeof(diid), &port) == 0;
cst_parliament_meta_t meta;
memset(&meta, 0, sizeof(meta));
int have_meta = cst_parliament_load(&meta) == 0;
cst_peer_info_t peers[16];
int np = cst_discover_local_list(NULL, peers, 16);
if (!svc) {
draw_ansi_at(s, y++, x, mw, "offline", CP_SIDE_MUTED, 0, 0);
} else {
snprintf(line, sizeof(line), "port %d · %d peer%s", port, np,
np == 1 ? "" : "s");
draw_ansi_at(s, y++, x, mw, line, CP_SIDE, 0, 0);
if (have_meta && y < bottom) {
int dx = x;
snprintf(line, sizeof(line), "rev=%lu", meta.revision);
dx += draw_ansi_at(s, y, x, mw, line, CP_SIDE_BRIGHT, 0, 0);
if (t->motion_on && np > 0 && dx + 8 < x + mw)
draw_ansi_at(s, y, dx + 1, x + mw - (dx + 1), motion_fleet_frame(t),
CP_SIDE_PRIMARY, 0, 0);
y++;
}
if (np == 0 && y < bottom)
draw_ansi_at(s, y++, x, mw, "no peers", CP_SIDE_MUTED, 0, 0);
else {
for (int i = 0; i < np && y < bottom && i < 4; i++) {
int is_chair =
have_meta && meta.chair_instance[0] &&
!strcmp(peers[i].instance_id, meta.chair_instance);
snprintf(line, sizeof(line), "%s%.28s", is_chair ? "+ " : " ",
peers[i].instance_id);
draw_ansi_at(s, y++, x, mw, line,
is_chair ? CP_SIDE_SUCCESS : CP_SIDE_MUTED, 0, 0);
}
}
}
y++;
}
/* CTX — context meter */
if (y + 2 < bottom) {
draw_ansi_at(s, y++, x, mw, "ctx", CP_SIDE_MUTED, 0, 0);
long used;
int pct = ctx_percent(t, &used);
char tok[32], bar[40];
fmt_tokens(used, tok, sizeof(tok));
int pulse = t->motion_on && pct >= 80 && (t->spin % 2) == 0;
draw_ctx_bar(bar, sizeof(bar), pct, pulse);
snprintf(line, sizeof(line), "%s %d%% %s%s", bar, pct, g_ascii ? "" : "· ",
tok);
draw_ansi_at(s, y++, x, mw, line, pct >= 80 ? CP_SIDE_PRIMARY : CP_SIDE,
0, 0);
y++;
}
/* WORK — todo + modified, or a designed idle line (no empty void). */
if (y + 2 < bottom) {
if (t->jobs_done > 0) {
snprintf(line, sizeof(line), "work %d done", t->jobs_done);
draw_ansi_at(s, y++, x, mw, line, CP_SIDE_MUTED, 0, 0);
} else
draw_ansi_at(s, y++, x, mw, "work", CP_SIDE_MUTED, 0, 0);
int total = 0;
int todo_n = 0;
if (t->show_todo && t->todo && todo_open_items(t->todo, &total) >= 0 &&
total > 0) {
for (const char *p = t->todo; *p && y < bottom - 1 && todo_n < 4;) {
const char *nl = strchr(p, '\n');
size_t len = nl ? (size_t)(nl - p) : strlen(p);
if (len >= 3) {
int pair = CP_SIDE;
const char *mark = "[ ]";
if (strncmp(p, "[x]", 3) == 0)
pair = CP_SIDE_MUTED, mark = g_ascii ? "[x]" : "[✓]";
else if (strncmp(p, "[~]", 3) == 0)
pair = CP_SIDE_PRIMARY, mark = g_ascii ? "[~]" : "[•]";
const char *txt = p + 3;
while (txt < p + len && *txt == ' ')
txt++;
snprintf(line, sizeof(line), "%s %.*s", mark, (int)(p + len - txt),
txt);
draw_ansi_at(s, y++, x, mw, line, pair, 0, 0);
todo_n++;
}
p = nl ? nl + 1 : p + len;
}
}
mod_file_t mods[16];
int nm = collect_modified(t, mods, 16);
if (nm > 5)
nm = 5;
for (int i = 0; i < nm && y < bottom; i++) {
char pl[16], mi[16];
snprintf(pl, sizeof(pl), "+%d", mods[i].add);
snprintf(mi, sizeof(mi), "-%d", mods[i].del);
int sw = (int)strlen(pl) + 1 + (int)strlen(mi);
draw_ansi_at(s, y, x, mw - sw - 1, mods[i].path, CP_SIDE_MUTED, 0, 0);
int px = x + mw - sw;
px += draw_ansi_at(s, y, px, (int)strlen(pl), pl, CP_SIDE_SUCCESS, 0, 0);
draw_ansi_at(s, y, px + 1, (int)strlen(mi), mi, CP_SIDE_PRIMARY, 0, 0);
y++;
}
if (todo_n == 0 && nm == 0 && y < bottom)
draw_ansi_at(s, y++, x, mw, "idle", CP_SIDE_MUTED, 0, 0);
y++;
}
/* SYNC — parliament pending/quiet, or solo (local workers) */
if (y + 1 < bottom) {
if (t->jobs_done > 0 && !cst_parliament_is_active()) {
snprintf(line, sizeof(line), "sync %d done", t->jobs_done);
draw_ansi_at(s, y++, x, mw, line, CP_SIDE_MUTED, 0, 0);
} else
draw_ansi_at(s, y++, x, mw, "sync", CP_SIDE_MUTED, 0, 0);
if (!cst_parliament_is_active())
draw_ansi_at(s, y++, x, mw, "solo", CP_SIDE_MUTED, 0, 0);
else if (cst_parliament_sync_pending())
draw_ansi_at(s, y++, x, mw, "pending", CP_SIDE_PRIMARY, 0, 0);
else
draw_ansi_at(s, y++, x, mw, "quiet", CP_SIDE_MUTED, 0, 0);
y++;
}
/* KEYS — always one designed hint row */
if (y + 1 < bottom) {
draw_ansi_at(s, y++, x, mw, "keys", CP_SIDE_MUTED, 0, 0);
draw_ansi_at(s, y++, x, mw,
g_ascii ? "tab mode . ^p cmd" : "tab mode · ^p cmd",
CP_SIDE_MUTED, 0, 0);
}
if (h >= 4) {
draw_ansi_at(s, h - 3, x, mw, t->where, CP_SIDE_MUTED, 0, 0);
snprintf(line, sizeof(line), "Hermit %s", CST_VERSION);
draw_ansi_at(s, h - 2, x, mw, line, CP_SIDE_MUTED, 0, 0);
}
wrefresh(s);
}
/* Ordered selection [y1:x1, y2:x2] over display lines, snapped to words or
* whole lines by the click count; x2 < 0 means the end of the line. */
static void sel_range(const tui_t *t, const dlines_t *dl, int *y1, int *x1,
int *y2, int *x2) {
int ay = t->sel_ay, ax = t->sel_ax, by = t->sel_y, bx = t->sel_x;
if (by < ay || (by == ay && bx < ax)) {
int ty = ay, tx = ax;
ay = by, ax = bx, by = ty, bx = tx;
}
if (t->sel_mode == 2) {
int c1, c2;
if (ay >= 0 && ay < dl->n) {
cst_textsel_word(dl->lines[ay].text, dl->lines[ay].indent, ax, &c1, &c2);
ax = c1;
}
if (by >= 0 && by < dl->n) {
cst_textsel_word(dl->lines[by].text, dl->lines[by].indent, bx, &c1, &c2);
bx = c2;
}
} else if (t->sel_mode == 3) {
while (ay > 0 && ay < dl->n && dl->lines[ay].cont)
ay--;
while (by + 1 < dl->n && dl->lines[by + 1].cont)
by++;
ax = 0;
bx = -1;
}
*y1 = ay, *x1 = ax, *y2 = by, *x2 = bx;
}
static void draw_selection(tui_t *t, const dlines_t *dl, int h, int w) {
if (t->sel_state != SEL_DRAG && t->sel_state != SEL_SHOWN)
return;
int y1, x1, y2, x2;
sel_range(t, dl, &y1, &x1, &y2, &x2);
for (int row = 0; row < h; row++) {
int li = t->log_start + row;
if (li < y1 || li > y2)
continue;
int a = li == y1 ? x1 : 0;
int b = li == y2 && x2 >= 0 ? x2 : w - 1;
if (li < dl->n)
cst_textsel_snap(dl->lines[li].text, dl->lines[li].indent, &a, &b);
if (b >= w)
b = w - 1;
if (a <= b)
mvwchgat(t->logw, row, a, b - a + 1, A_REVERSE,
HAS_COLOR() ? CP_PANEL : 0, NULL);
}
}
static void draw_log(tui_t *t) {
if (!t->logw)
return;
werase(t->logw);
if (HAS_COLOR())
wbkgd(t->logw, COLOR_PAIR(CP_PANEL));
int h, w;
getmaxyx(t->logw, h, w);
if (h < 1 || w < 1)
return;
if (t->home) {
draw_home(t);
wrefresh(t->logw);
if (t->input) {
touchwin(t->input);
wrefresh(t->input);
}
return;
}
dlines_t dl = {0};
build_display(t, &dl, w);
int total = dl.n > 0 ? dl.n : 1;
int max_scroll = total - h;
if (max_scroll < 0)
max_scroll = 0;
if (t->log_scroll > max_scroll)
t->log_scroll = max_scroll;
if (t->log_scroll < 0)
t->log_scroll = 0;
int start = total - h - t->log_scroll;
if (start < 0)
start = 0;
t->log_start = start;
if (t->row_blk_cap < h) {
int *nb = realloc(t->row_blk, (size_t)h * sizeof(int));
if (nb) {
t->row_blk = nb;
t->row_blk_cap = h;
}
}
for (int row = 0; row < h; row++) {
int li = start + row;
if (row < t->row_blk_cap)
t->row_blk[row] = li >= 0 && li < dl.n ? dl.lines[li].blk : -1;
if (li < 0 || li >= dl.n)
continue;
dline_t *L = &dl.lines[li];
if (L->fill && HAS_COLOR())
for (int x = 0; x < w; x++)
put_wc(t->logw, row, x, L' ', (int)COLOR_PAIR(L->fill));
draw_ansi_at(t->logw, row, L->indent, w - L->indent, L->text, L->pair,
L->bold, L->dim);
if (L->bar)
put_wc(t->logw, row, 0, L'┃', HAS_COLOR() ? (int)COLOR_PAIR(L->bar) : 0);
}
draw_selection(t, &dl, h, w);
if (t->log_scroll > 0 && h > 2) {
char more[64];
snprintf(more, sizeof(more), g_ascii ? " v%d" : " ↓%d", t->log_scroll);
wmove(t->logw, h - 1, 0);
wclrtoeol(t->logw);
int mw = utf8_cols(more, strlen(more));
draw_ansi_at(t->logw, h - 1, w - mw - 1 > 0 ? w - mw - 1 : 0, mw, more,
CP_MUTED, 0, 0);
}
dlines_free(&dl);
draw_completion(t, h - 1, 0, w < 90 ? w : 90, h - 1, 1);
wrefresh(t->logw);
}
static int is_home(const tui_t *t) {
return t->hist.n == 0 && !t->as_live && !t->busy;
}
static void layout(tui_t *t);
static void draw_topbar(tui_t *t) {
if (!t->header)
return;
WINDOW *w = t->header;
if (HAS_COLOR())
wbkgd(w, COLOR_PAIR(CP_TOPBAR));
werase(w);
int bw = getmaxx(w);
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
char left[256];
const char *title = t->title[0] ? t->title : "New session";
/* Session store ids look like hex prefixes — keep chrome human. */
{
int hexish = 1;
size_t n = strlen(title);
if (n >= 8 && n <= 40) {
for (size_t i = 0; i < n; i++) {
char c = title[i];
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F') || c == '-')) {
hexish = 0;
break;
}
}
} else {
hexish = 0;
}
if (hexish)
title = "New session";
}
/* Badge + brand + title on the left; model then seat chip on the right. */
int x = 1;
draw_hermit_badge(w, 0, x, t);
x += 3;
x += draw_ansi_at(w, 0, x, 6, "Hermit", CP_TOPBAR_ACCENT, 1, 0);
x += draw_ansi_at(w, 0, x, 1, " ", CP_TOPBAR_MUTED, 0, 0);
char seat_chip[48] = "";
int seat_w = 0;
{
char sid[64] = {0};
int sess = cst_parliament_session_ok(sid, sizeof(sid));
int chair = sess && cst_parliament_is_chair();
if (cst_parliament_is_active()) {
snprintf(seat_chip, sizeof(seat_chip), "%s %s",
seat_pulse_dot(t),
!sess ? "login" : chair ? "chair" : "member");
seat_w = utf8_cols(seat_chip, strlen(seat_chip)) + 2;
}
}
char mid[160] = "";
int model_room = 0;
if (cfg) {
snprintf(mid, sizeof(mid), "%.40s%s%.20s", cfg->model,
cfg->provider[0] ? " · " : "", cfg->provider);
model_room = utf8_cols(mid, strlen(mid)) + 2;
}
int title_max = bw - x - 1 - model_room - seat_w;
if (title_max < 8)
title_max = 8;
size_t tfit = utf8_fit_cols(title, strlen(title), title_max);
snprintf(left, sizeof(left), "%.*s", (int)tfit, title);
x += draw_ansi_at(w, 0, x, bw - x, left, CP_TOPBAR, 1, 0);
int right = bw - 1;
if (seat_chip[0]) {
int sw = utf8_cols(seat_chip, strlen(seat_chip));
int sx = right - sw;
if (sx > x + 2) {
int pair = strstr(seat_chip, "chair") ? CP_TOPBAR_ACCENT : CP_TOPBAR_MUTED;
draw_ansi_at(w, 0, sx, sw, seat_chip, pair, 0, 0);
right = sx - 2;
}
}
if (mid[0] && x + 2 < right) {
int mw = utf8_cols(mid, strlen(mid));
int mx = right - mw;
if (mx < x + 2)
mx = x + 2;
draw_ansi_at(w, 0, mx, right - mx + 1, mid, CP_TOPBAR_MUTED, 0, 0);
}
wrefresh(w);
}
static void redraw_all(tui_t *t) {
if (is_home(t) != t->home)
layout(t);
theme_paint_root();
draw_topbar(t);
draw_log(t);
draw_todo(t);
draw_side(t);
draw_status(t);
draw_input(t);
}
/* --- session reload ------------------------------------------------------- */
static void push_system_line(tui_t *t, const char *fmt, ...);
/* Push finished background jobs into the transcript (non-modal). */
static int drain_job_completions(tui_t *t) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
if (!cfg || !cfg->runtime)
return 0;
cst_job_completion_t comps[8];
int n = cst_runtime_take_ui_completions(cfg->runtime, comps, 8);
for (int i = 0; i < n; i++) {
const char *mark =
!strcmp(comps[i].status, "completed")
? (g_ascii ? "*" : "✓")
: (!strcmp(comps[i].status, "cancelled") ? "-"
: (g_ascii ? "x" : "✗"));
if (comps[i].ms > 0)
push_system_line(t, "%s job %s %s exit=%d %.1fs%s%s", mark, comps[i].id,
comps[i].status, comps[i].exit_code,
comps[i].ms / 1000.0,
comps[i].summary[0] ? " — " : "",
comps[i].summary[0] ? comps[i].summary : "");
else
push_system_line(t, "%s job %s %s exit=%d%s%s", mark, comps[i].id,
comps[i].status, comps[i].exit_code,
comps[i].summary[0] ? " — " : "",
comps[i].summary[0] ? comps[i].summary : "");
}
if (n > 0)
t->jobs_done += n;
return n;
}
static void push_system_line(tui_t *t, const char *fmt, ...) {
char tmp[1024];
va_list ap;
va_start(ap, fmt);
vsnprintf(tmp, sizeof(tmp), fmt, ap);
va_end(ap);
block_t *b = blocks_push(&t->hist);
if (!b)
return;
b->kind = BLK_SYSTEM;
block_set_body(b, tmp, strlen(tmp));
}
typedef struct {
char id[CTL_MAX_ID];
char title[160];
} call_title_t;
/* Native tool_calls JSON → per-call one-line titles for replayed cards. */
static void remember_call_titles(const char *json, call_title_t *v, int *n) {
cJSON *arr = cJSON_Parse(json);
const cJSON *it;
cJSON_ArrayForEach(it, arr) {
const cJSON *id = cJSON_GetObjectItemCaseSensitive(it, "id");
const cJSON *fn = cJSON_GetObjectItemCaseSensitive(it, "function");
const cJSON *name = cJSON_GetObjectItemCaseSensitive(fn, "name");
const cJSON *args = cJSON_GetObjectItemCaseSensitive(fn, "arguments");
if (!cJSON_IsString(id) || !cJSON_IsString(name))
continue;
ctl_tool_call_t c;
memset(&c, 0, sizeof(c));
if (cst_tool_call_from_native(&c, id->valuestring, name->valuestring,
cJSON_IsString(args) ? args->valuestring
: "{}") == 0) {
int k = *n < 64 ? (*n)++ : 63;
snprintf(v[k].id, sizeof(v[k].id), "%s", id->valuestring);
tool_arg_summary(&c, v[k].title, sizeof(v[k].title));
}
for (int i = 0; i < c.nargs; i++)
free(c.args[i].value);
}
cJSON_Delete(arr);
}
static void reload_hist_from_session(tui_t *t) {
live_end_cleanup(t);
blocks_clear(&t->hist);
t->log_scroll = 0;
t->think_idx = -1;
t->last_tool_idx = -1;
t->focus_think_idx = -1;
cst_session_t *s = cst_agent_session(t->agent);
if (!s)
return;
cst_session_lock(s);
int n = cst_session_count(s);
int non_sys = 0;
for (int i = 0; i < n; i++) {
const cst_message_t *m = cst_session_get(s, i);
if (m && m->role != CST_ROLE_SYSTEM)
non_sys++;
}
int skip = non_sys > REPLAY_MSGS ? non_sys - REPLAY_MSGS : 0;
int seen = 0;
static call_title_t titles[64];
int ntitles = 0;
for (int i = 0; i < n; i++) {
const cst_message_t *m = cst_session_get(s, i);
if (!m || m->role == CST_ROLE_SYSTEM)
continue;
if (seen++ < skip)
continue;
if (m->role == CST_ROLE_ASSISTANT && m->tool_calls_json)
remember_call_titles(m->tool_calls_json, titles, &ntitles);
if (m->role == CST_ROLE_ASSISTANT &&
(!m->content || !m->content[strspn(m->content, " \t\r\n")]))
continue; /* tool calls only */
block_t *b = blocks_push(&t->hist);
if (!b)
continue;
if (msg_is_tool_result(m)) {
b->kind = BLK_TOOL;
snprintf(b->tool_name, sizeof(b->tool_name), "%s",
m->tool_name[0] ? m->tool_name : "tool");
snprintf(b->tool_id, sizeof(b->tool_id), "%s", m->tool_call_id);
snprintf(b->meta, sizeof(b->meta), "done");
const char *body;
size_t blen;
tool_result_view(m->content, &body, &blen);
block_set_body(b, body, blen);
{
char path[256];
diff_extract_path(b->body, NULL, path, sizeof(path));
const char *title = path;
for (int k = 0; !path[0] && k < ntitles; k++)
if (strcmp(titles[k].id, m->tool_call_id) == 0)
title = titles[k].title;
block_set_title(b, title);
}
/* OpenCode: write/edit cards open (still fold at DIFF_FOLD_LINES). */
if (cst_tool_is_write(b->tool_name))
b->expanded = 1;
else
b->expanded = 0;
t->last_tool_idx = t->hist.n - 1;
} else if (cst_message_is_note(m)) {
b->kind = BLK_SYSTEM;
const char *c = m->content ? m->content : "";
const char *nl = strchr(c, '\n');
block_set_body(b, c, nl ? (size_t)(nl - c) : strlen(c));
} else if (m->role == CST_ROLE_USER) {
b->kind = BLK_USER;
block_set_title(b, "You");
cst_buf_t ub = {0};
cst_buf_puts(&ub, m->content ? m->content : "");
for (int k = 0; k < m->nattach; k++) {
char d[128];
cst_attach_describe(&m->attach[k], d, sizeof(d));
cst_buf_printf(&ub, "\n[%s %s · %s]",
cst_attach_is_image(&m->attach[k]) ? "image"
: "document",
m->attach[k].name, d);
}
block_set_body(b, ub.buf ? ub.buf : "", ub.len);
cst_buf_free(&ub);
b->expanded = 1;
} else if (m->role == CST_ROLE_ASSISTANT) {
b->kind = BLK_ASSISTANT;
block_set_title(b, "Assistant");
if (m->content)
block_set_body(b, m->content, strlen(m->content));
b->expanded = 1;
block_render_assistant(b);
}
}
cst_session_unlock(s);
}
/* --- layout --------------------------------------------------------------- */
static int input_visual_rows(const tui_t *t) {
int cr, cc;
int rows = input_layout(t, NULL, NULL, &cr, &cc);
if (rows < 1)
rows = 1;
if (rows > INPUT_MAX_ROWS)
rows = INPUT_MAX_ROWS;
return rows;
}
#ifdef _WIN32
static DWORD g_con_in_mode;
static int g_con_in_saved;
/* QuickEdit makes the console select text itself and swallow the clicks;
* it is off while the TUI owns the mouse and restored on exit. */
static void win_console_mouse(int on) {
HANDLE h = GetStdHandle(STD_INPUT_HANDLE);
DWORD m;
if (!GetConsoleMode(h, &m))
return;
if (!g_con_in_saved) {
g_con_in_mode = m;
g_con_in_saved = 1;
}
if (on)
m = (m & ~(DWORD)ENABLE_QUICK_EDIT_MODE) | ENABLE_MOUSE_INPUT |
ENABLE_EXTENDED_FLAGS;
else
m = g_con_in_mode | ENABLE_EXTENDED_FLAGS;
SetConsoleMode(h, m);
}
static CONSOLE_FONT_INFOEX g_con_font;
static int g_con_font_saved;
/* conhost has no glyph fallback: NSimSun, the default on Chinese systems,
* draws U+2580 as an empty box. */
static int win_font_has(const WCHAR *face, WCHAR ch) {
HDC dc = CreateCompatibleDC(NULL);
if (!dc)
return 1;
HFONT font = CreateFontW(16, 0, 0, 0, FW_NORMAL, 0, 0, 0, DEFAULT_CHARSET,
0, 0, 0, FF_MODERN, face);
WORD idx = 0;
int ok = 1;
if (font) {
HGDIOBJ old = SelectObject(dc, font);
if (GetGlyphIndicesW(dc, &ch, 1, &idx, GGI_MARK_NONEXISTING_GLYPHS) == 1)
ok = idx != 0xffff;
SelectObject(dc, old);
DeleteObject(font);
}
DeleteDC(dc);
return ok;
}
/* Raster fonts cannot draw UTF-8 box drawing or CJK; switch the console to
* a TrueType font of the same height while the TUI runs. */
static void win_console_font(int on) {
HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE);
if (!on) {
if (g_con_font_saved)
SetCurrentConsoleFontEx(h, FALSE, &g_con_font);
g_con_font_saved = 0;
return;
}
CONSOLE_FONT_INFOEX f = {.cbSize = sizeof f};
if (!GetCurrentConsoleFontEx(h, FALSE, &f))
return;
if (!(f.FontFamily & TMPF_TRUETYPE)) {
g_con_font = f;
CONSOLE_FONT_INFOEX tt = {.cbSize = sizeof tt};
tt.dwFontSize.Y = f.dwFontSize.Y > 0 ? f.dwFontSize.Y : 16;
tt.FontFamily = FF_MODERN | TMPF_TRUETYPE;
tt.FontWeight = FW_NORMAL;
wcscpy(tt.FaceName, GetACP() == 936 ? L"NSimSun" : L"Consolas");
if (!SetCurrentConsoleFontEx(h, FALSE, &tt))
return;
g_con_font_saved = 1;
f = tt;
}
if (!getenv("WT_SESSION") && !win_font_has(f.FaceName, 0x2580))
g_logo_text = 1;
}
#endif
#define MOUSE_RUN_MAX 16
/* getmouse() hands out a run of queued events newest first (the Windows
* console driver queues press, motion and release together); returns them
* oldest first. */
static int get_mouse_run(MEVENT *out, int max) {
int n = 0;
while (n < max && getmouse(&out[n]) == OK)
n++;
for (int i = 0; i < n / 2; i++) {
MEVENT tmp = out[i];
out[i] = out[n - 1 - i];
out[n - 1 - i] = tmp;
}
return n;
}
static void apply_mouse(const tui_t *t) {
mousemask(t->mouse_on ? (mmask_t)(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION)
: 0,
NULL);
mouseinterval(0);
#ifdef _WIN32
win_console_mouse(t->mouse_on);
#elif defined(NCURSES_VERSION)
/* ncurses only asks for presses and releases (1000). 1002 also reports
* motion while a button is held, for drag selection; terminals treat it as
* replacing 1000, so it goes after mousemask and is turned off explicitly. */
printf(t->mouse_on ? "\033[?1002h" : "\033[?1002l");
fflush(stdout);
#endif
}
static void free_windows(tui_t *t) {
WINDOW **ws[] = {&t->header, &t->logw, &t->status,
&t->input, &t->todow, &t->side};
for (size_t i = 0; i < sizeof(ws) / sizeof(ws[0]); i++)
drop_win(ws[i]);
}
static int clampi(int v, int lo, int hi) {
return v < lo ? lo : v > hi ? hi : v;
}
static void draw_topbar(tui_t *t);
/* Start screen: centered brand + composer. Session: top chrome, transcript,
* input card, status; optional sidebar. */
static void layout(tui_t *t) {
getmaxyx(stdscr, t->rows, t->cols);
if (t->rows < 4)
t->rows = 4;
if (t->cols < 20)
t->cols = 20;
theme_paint_root();
free_windows(t);
t->home = is_home(t);
t->side_w = 0;
t->todo_rows = 0;
int pad = t->rows >= 20 ? 1 : 0;
int topbar = (!t->home && t->rows >= 12) ? 1 : 0;
if (t->home) {
t->in_w = t->cols - 8 < 72 ? t->cols - 8 : 72;
if (t->in_w < 24)
t->in_w = t->cols - 2;
t->in_x = (t->cols - t->in_w) / 2;
} else {
if (t->show_side && t->cols >= 110)
t->side_w = clampi(t->cols / 5, 28, 36);
int mw = t->cols - t->side_w;
t->in_x = 1;
t->in_w = mw - 2;
}
t->input_rows = input_visual_rows(t);
t->in_h = t->input_rows + 1 + 2 * pad;
if (t->in_h > t->rows - 2 - topbar) {
t->input_rows = clampi(t->rows - 3 - topbar, 1, INPUT_MAX_ROWS);
t->in_h = t->input_rows + 1;
}
if (t->home) {
/* Compact vertical rhythm: brand + tagline + composer, not a huge void. */
int block = 3 + t->in_h + 3;
int top = (t->rows - 1 - block) / 2;
if (top < 1)
top = 1;
t->in_y = clampi(top + 3, 0, t->rows - 1 - t->in_h);
t->log_x = t->log_y = 0;
t->logw = tui_newwin(t->rows - 1, t->cols, 0, 0);
t->status = tui_newwin(1, t->cols - 2, t->rows - 1, 1);
} else {
int mw = t->cols - t->side_w;
t->in_y = t->rows - 1 - t->in_h;
int total = 0;
int open = t->show_todo && !t->side_w ? todo_open_items(t->todo, &total) : 0;
if (open && t->rows >= 16)
t->todo_rows = 1 + (total < TODO_PANEL_MAX ? total : TODO_PANEL_MAX);
t->log_x = 1;
t->log_y = topbar;
/* Topbar spans the full width so the sidebar does not leave a dead
* hole next to "Hermit …" in the top-right corner. */
if (topbar)
t->header = tui_newwin(1, t->cols, 0, 0);
int log_h = t->in_y - 1 - t->todo_rows - t->log_y;
if (log_h < 1) {
t->todo_rows = 0;
log_h = t->in_y - t->log_y > 0 ? t->in_y - t->log_y : 1;
}
t->logw = tui_newwin(log_h, mw - 2, t->log_y, 1);
if (t->todo_rows)
t->todow = tui_newwin(t->todo_rows, mw - 2, t->log_y + log_h, 1);
t->status = tui_newwin(1, mw - 2, t->rows - 1, 1);
/* Sidebar starts under the topbar, aligned with the transcript. */
if (t->side_w)
t->side = tui_newwin(t->rows - topbar, t->side_w, topbar, mw);
}
t->input = tui_newwin(t->in_h, t->in_w, t->in_y, t->in_x);
if (HAS_COLOR()) {
wbkgdset(t->logw, COLOR_PAIR(CP_PANEL));
wbkgdset(t->status, COLOR_PAIR(CP_PANEL));
wbkgdset(t->input, COLOR_PAIR(CP_BOX));
if (t->header)
wbkgdset(t->header, COLOR_PAIR(CP_TOPBAR));
if (t->side)
wbkgdset(t->side, COLOR_PAIR(CP_SIDE));
}
/* Side/status refreshes must not steal the terminal cursor from input. */
if (t->side)
leaveok(t->side, TRUE);
if (t->status)
leaveok(t->status, TRUE);
if (t->header)
leaveok(t->header, TRUE);
if (t->todow)
leaveok(t->todow, TRUE);
scrollok(t->logw, FALSE);
keypad(t->input, TRUE);
wtimeout(t->input, 80);
apply_mouse(t);
}
static void redraw_all(tui_t *t);
static int start_turn(tui_t *t, char *prompt); /* takes ownership of prompt */
static void layout_maybe_grow_input(tui_t *t) {
int need = input_visual_rows(t);
if (need != t->input_rows) {
layout(t);
wtimeout(t->input, 80);
redraw_all(t);
} else {
draw_input(t);
}
}
static void update_completion(tui_t *t);
static void input_changed(tui_t *t) {
t->hist_browse = -1;
t->comp_off = 0;
update_completion(t);
}
static void input_insert(tui_t *t, const char *p, size_t n) {
if (t->in_len + n >= INPUT_CAP)
return;
memmove(t->inbuf + t->cur + n, t->inbuf + t->cur, t->in_len - t->cur);
memcpy(t->inbuf + t->cur, p, n);
t->in_len += n;
t->cur += n;
t->inbuf[t->in_len] = 0;
if (!t->in_paste)
input_changed(t);
}
static void input_erase(tui_t *t, size_t from, size_t to) {
if (to > t->in_len)
to = t->in_len;
if (from >= to)
return;
memmove(t->inbuf + from, t->inbuf + to, t->in_len - to);
t->in_len -= to - from;
t->inbuf[t->in_len] = 0;
if (t->cur >= to)
t->cur -= to - from;
else if (t->cur > from)
t->cur = from;
input_changed(t);
}
static void input_set(tui_t *t, const char *s) {
snprintf(t->inbuf, sizeof(t->inbuf), "%s", s ? s : "");
t->in_len = strlen(t->inbuf);
t->cur = t->in_len;
input_changed(t);
}
static void input_clear(tui_t *t) { input_set(t, ""); }
static void input_put_wc(tui_t *t, wchar_t wc) {
char mb[8];
int n = os_utf8_encode((unsigned long)wc, mb);
if (n <= 0)
return;
input_insert(t, mb, (size_t)n);
}
static void input_put_byte(tui_t *t, char ch) { input_insert(t, &ch, 1); }
static size_t next_len(const tui_t *t, size_t at) {
if (at >= t->in_len)
return 0;
size_t i = at + 1;
while (i < t->in_len && ((unsigned char)t->inbuf[i] & 0xC0) == 0x80)
i++;
return i - at;
}
static size_t line_start(const tui_t *t, size_t at) {
while (at > 0 && t->inbuf[at - 1] != '\n')
at--;
return at;
}
static size_t line_end(const tui_t *t, size_t at) {
while (at < t->in_len && t->inbuf[at] != '\n')
at++;
return at;
}
static int is_word(unsigned char c) { return c >= 0x80 || isalnum(c) || c == '_'; }
static size_t word_left(const tui_t *t, size_t at) {
while (at > 0 && !is_word((unsigned char)t->inbuf[at - 1]))
at--;
while (at > 0 && is_word((unsigned char)t->inbuf[at - 1]))
at--;
return at;
}
static size_t word_right(const tui_t *t, size_t at) {
while (at < t->in_len && !is_word((unsigned char)t->inbuf[at]))
at++;
while (at < t->in_len && is_word((unsigned char)t->inbuf[at]))
at++;
return at;
}
static size_t input_hit(const tui_t *t, int want_row, int want_col);
/* Move to the previous/next visual row (soft-wrapped or not) keeping the
* screen column. Returns 0 when already on the first/last row. */
static int input_line_move(tui_t *t, int dir) {
int cr, cc;
int rows = input_layout(t, NULL, NULL, &cr, &cc);
if (cr + dir < 0 || cr + dir >= rows)
return 0;
t->cur = input_hit(t, cr + dir, cc);
return 1;
}
static void prompt_hist_push(tui_t *t, const char *prompt) {
if (!prompt || !prompt[0])
return;
if (t->prompt_hist_n >= PROMPT_HIST_CAP) {
free(t->prompt_hist[0]);
memmove(t->prompt_hist, t->prompt_hist + 1,
(PROMPT_HIST_CAP - 1) * sizeof(char *));
t->prompt_hist_n = PROMPT_HIST_CAP - 1;
}
t->prompt_hist[t->prompt_hist_n++] = strdup(prompt);
t->hist_browse = -1;
}
static void hist_load_into_input(tui_t *t, int idx) {
if (idx < 0 || idx >= t->prompt_hist_n)
return;
input_set(t, t->prompt_hist[idx]);
t->hist_browse = idx;
}
static char *queue_pop(tui_t *t) {
if (t->queue_n <= 0)
return NULL;
char *p = t->queue[0];
memmove(t->queue, t->queue + 1, (size_t)(t->queue_n - 1) * sizeof(char *));
t->queue_n--;
t->queue[t->queue_n] = NULL;
return p;
}
static void queue_free_all(tui_t *t) {
for (int i = 0; i < t->queue_n; i++) {
free(t->queue[i]);
t->queue[i] = NULL;
}
t->queue_n = 0;
}
/* --- hooks (may run on worker thread) ------------------------------------- */
static void on_status(const char *msg, void *ud) {
tui_t *t = ud;
tui_lock(t);
set_status_locked(t, msg);
tui_unlock(t);
}
static void on_assistant_delta(const char *text, size_t len, void *ud) {
tui_t *t = ud;
tui_lock(t);
collapse_open_think(t);
if (!t->as_live)
live_begin(t);
live_feed(t, text, len);
t->log_scroll = 0;
set_status_locked(t, "streaming…");
tui_mark_dirty(t);
tui_unlock(t);
}
static void on_think_delta(const char *text, size_t len, void *ud) {
tui_t *t = ud;
tui_lock(t);
if (t->think_idx < 0) {
block_t *b = blocks_push(&t->hist);
if (b) {
b->kind = BLK_THINK;
block_set_title(b, "Thinking…");
b->expanded = t->show_thinking;
t->think_idx = t->hist.n - 1;
t->focus_think_idx = t->think_idx;
clock_gettime(CLOCK_MONOTONIC, &t->think_t0);
t->think_timing = 1;
}
}
if (t->think_idx >= 0 && t->think_idx < t->hist.n && text && len)
block_append_body(&t->hist.items[t->think_idx], text, len);
set_status_locked(t, "thinking…");
t->log_scroll = 0;
tui_mark_dirty(t);
tui_unlock(t);
}
static void on_tool_event(const char *phase, const ctl_tool_call_t *call,
const char *detail, void *ud) {
tui_t *t = ud;
tui_lock(t);
collapse_open_think(t);
if (t->as_live &&
(strcmp(phase, "open") == 0 || strcmp(phase, "ready") == 0))
live_seal(t);
if (!call) {
tui_unlock(t);
return;
}
int idx = find_tool_block(t, call->id);
block_t *b = NULL;
if (idx < 0) {
b = blocks_push(&t->hist);
if (!b) {
tui_unlock(t);
return;
}
b->kind = BLK_TOOL;
snprintf(b->tool_name, sizeof(b->tool_name), "%s", call->name);
snprintf(b->tool_id, sizeof(b->tool_id), "%s", call->id);
b->expanded = 0;
idx = t->hist.n - 1;
} else {
b = &t->hist.items[idx];
}
t->last_tool_idx = idx;
char args[160];
tool_arg_summary(call, args, sizeof(args));
block_set_title(b, args);
if (strcmp(phase, "open") == 0) {
snprintf(b->meta, sizeof(b->meta), "pending");
set_status_locked(t, "tool pending…");
} else if (strcmp(phase, "arg") == 0) {
snprintf(b->meta, sizeof(b->meta), "args…");
} else if (strcmp(phase, "ready") == 0) {
snprintf(b->meta, sizeof(b->meta), "ready");
set_status_locked(t, "tool confirmed");
} else if (strcmp(phase, "run") == 0) {
snprintf(b->meta, sizeof(b->meta), "running");
set_status_locked(t, "running tool…");
} else if (strcmp(phase, "progress") == 0) {
/* sub-agent steps: "explore · grep foo · read_file x" */
snprintf(b->meta, sizeof(b->meta), "%s", detail ? detail : "running");
set_status_locked(t, detail ? detail : "sub-agent…");
} else if (strcmp(phase, "denied") == 0) {
snprintf(b->meta, sizeof(b->meta), "denied");
block_set_body(b, "denied by user", strlen("denied by user"));
set_status_locked(t, "tool denied");
} else if (strcmp(phase, "done") == 0) {
snprintf(b->meta, sizeof(b->meta), "%s", detail ? detail : "done");
if (!b->body || !b->body[0])
attach_tool_output_from_session(t, b);
/* Auto-expand write/patch cards so diffs are visible like OpenCode. */
if (cst_tool_is_write(call->name))
b->expanded = 1;
set_status_locked(t, "tool done");
}
t->log_scroll = 0;
tui_mark_dirty(t);
tui_unlock(t);
}
static void on_tool_result(const ctl_tool_call_t *call, const char *out,
size_t len, int exit_code, void *ud) {
(void)exit_code;
tui_t *t = ud;
if (!call)
return;
tui_lock(t);
int idx = find_tool_block(t, call->id);
if (idx >= 0) {
block_t *b = &t->hist.items[idx];
block_set_body(b, out ? out : "", out ? len : 0);
char path[256];
diff_extract_path(b->body, NULL, path, sizeof(path));
if (path[0])
block_set_title(b, path);
tui_mark_dirty(t);
}
tui_unlock(t);
}
static int on_permission(const char *tool_name, const char *detail,
const char *preview, void *ud) {
tui_t *t = ud;
tui_lock(t);
snprintf(t->perm_tool, sizeof(t->perm_tool), "%s",
tool_name ? tool_name : "tool");
size_t sbl = strlen(CST_PERM_SANDBOX);
t->perm_sandbox = detail && strncmp(detail, CST_PERM_SANDBOX, sbl) == 0;
const char *dsrc = !detail ? "" : t->perm_sandbox ? detail + sbl : detail;
if (t->perm_sandbox) {
size_t dl = strlen(dsrc);
if (dl >= sizeof(t->perm_detail)) {
dsrc += dl - (sizeof(t->perm_detail) - 1);
while ((*dsrc & 0xC0) == 0x80)
dsrc++;
}
}
snprintf(t->perm_detail, sizeof(t->perm_detail), "%s", dsrc);
free(t->perm_preview);
t->perm_preview = preview ? xstrdup(preview) : NULL;
t->perm_prefix[0] = 0;
if (preview && strcmp(t->perm_tool, "run_shell") == 0)
cst_perms_suggest_prefix(preview, t->perm_prefix, sizeof(t->perm_prefix));
t->perm_scroll = 0;
t->awaiting_perm = 1;
t->perm_ans = -1;
t->perm_ask = 1;
tui_mark_dirty(t);
tui_unlock(t);
for (;;) {
tui_lock(t);
int ans = t->perm_ans;
if (ans >= 0) {
t->awaiting_perm = 0;
t->perm_ask = 0;
free(t->perm_preview);
t->perm_preview = NULL;
}
tui_unlock(t);
if (ans >= 0)
return ans;
usleep(20000);
}
}
static int on_ask_user(const char *q, const char *opts, char *ans,
size_t ans_sz, void *ud) {
tui_t *t = ud;
tui_lock(t);
snprintf(t->ask_q, sizeof(t->ask_q), "%s", q ? q : "");
t->ask_nopts = 0;
for (const char *p = opts; p && *p && t->ask_nopts < ASK_OPTS_MAX;) {
const char *nl = strchr(p, '\n');
size_t len = nl ? (size_t)(nl - p) : strlen(p);
while (len && (p[len - 1] == '\r' || p[len - 1] == ' '))
len--;
if (len)
snprintf(t->ask_opts[t->ask_nopts++], sizeof(t->ask_opts[0]), "%.*s",
(int)len, p);
if (!nl)
break;
p = nl + 1;
}
t->ask_sel = 0;
t->ask_buf[0] = 0;
t->ask_done = 0;
t->ask_pending = 1;
tui_mark_dirty(t);
tui_unlock(t);
for (;;) {
tui_lock(t);
int done = t->ask_done;
if (done) {
t->ask_pending = 0;
t->ask_done = 0;
snprintf(ans, ans_sz, "%s", t->ask_buf);
}
int rc = t->ask_rc;
tui_unlock(t);
if (done)
return rc;
usleep(20000);
}
}
static void on_todo(const char *text, void *ud) {
tui_t *t = ud;
tui_lock(t);
free(t->todo);
t->todo = text && text[0] ? xstrdup(text) : NULL;
t->need_layout = 1;
tui_mark_dirty(t);
tui_unlock(t);
}
/* --- modals drawn over the transcript ------------------------------------- */
static void modal_footer(tui_t *t, int h, int w, const char *keys) {
wmove(t->logw, h - 1, 0);
wclrtoeol(t->logw);
wattron(t->logw, A_REVERSE);
draw_ansi_line(t->logw, h - 1, w, keys, CP_PRIMARY, 1, 0);
wattroff(t->logw, A_REVERSE);
}
static void draw_perm_modal(tui_t *t) {
int h, w;
getmaxyx(t->logw, h, w);
werase(t->logw);
if (HAS_COLOR())
wbkgd(t->logw, COLOR_PAIR(CP_PANEL));
dlines_t dl = {0};
char head[700];
if (t->perm_sandbox)
snprintf(head, sizeof(head),
"Blocked by the sandbox. Rerun outside the sandbox?");
else
snprintf(head, sizeof(head), "Allow %s?", t->perm_tool);
dlines_push(&dl, head, CP_PRIMARY, 1, 0);
int is_shell = strcmp(t->perm_tool, "run_shell") == 0;
if (t->perm_sandbox) {
push_wrapped(&dl, " $ ", t->perm_preview ? t->perm_preview : "", w,
CP_TOOL, 1, 0, 0);
dlines_push(&dl, "", 0, 0, 0);
push_wrapped(&dl, " ", "sandboxed output (tail):", w, CP_MUTED, 0, 0, 0);
push_diff_or_body(&dl, t->perm_detail, w, CP_MUTED, 1, 100000, 0);
} else if (is_shell && t->perm_preview) {
push_wrapped(&dl, " $ ", t->perm_preview, w, CP_TOOL, 1, 0, 0);
} else {
push_wrapped(&dl, " ", t->perm_detail, w, CP_MUTED, 0, 0, 0);
if (t->perm_preview && t->perm_preview[0]) {
dlines_push(&dl, "", 0, 0, 0);
push_diff_or_body(&dl, t->perm_preview, w, CP_MUTED, 1, 100000, 1);
}
}
int body_h = h - 1;
int max_scroll = dl.n > body_h ? dl.n - body_h : 0;
if (t->perm_scroll > max_scroll)
t->perm_scroll = max_scroll;
if (t->perm_scroll < 0)
t->perm_scroll = 0;
for (int r = 0; r < body_h && t->perm_scroll + r < dl.n; r++) {
dline_t *L = &dl.lines[t->perm_scroll + r];
draw_ansi_line(t->logw, r, w, L->text, L->pair, L->bold, L->dim);
}
char keys[400];
int k = t->perm_sandbox
? snprintf(keys, sizeof(keys), " [y] rerun once outside")
: snprintf(keys, sizeof(keys),
" [y] allow once [a] always allow %s", t->perm_tool);
if (t->perm_prefix[0])
k += snprintf(keys + k, sizeof(keys) - (size_t)k,
" [p] always allow `%.60s …`", t->perm_prefix);
snprintf(keys + k, sizeof(keys) - (size_t)k, " [n/Esc] %s%s",
t->perm_sandbox ? "keep result" : "deny",
max_scroll ? " ↑↓ scroll" : "");
modal_footer(t, h, w, keys);
dlines_free(&dl);
wrefresh(t->logw);
}
static void draw_ask_modal(tui_t *t) {
int h, w;
getmaxyx(t->logw, h, w);
werase(t->logw);
if (HAS_COLOR())
wbkgd(t->logw, COLOR_PAIR(CP_PANEL));
dlines_t dl = {0};
dlines_push(&dl, "The agent asks:", CP_PRIMARY, 1, 0);
push_wrapped(&dl, " ", t->ask_q, w, CP_PANEL, 1, 0, 0);
dlines_push(&dl, "", 0, 0, 0);
for (int i = 0; i < t->ask_nopts; i++) {
char line[320];
snprintf(line, sizeof(line), "%s %d. %s",
i == t->ask_sel && !t->ask_buf[0] ? (g_ascii ? ">" : "▶") : " ",
i + 1, t->ask_opts[i]);
dlines_push(&dl, line, i == t->ask_sel ? CP_PRIMARY : CP_PANEL,
i == t->ask_sel, 0);
}
if (t->ask_nopts)
dlines_push(&dl, "", 0, 0, 0);
char typed[1100];
snprintf(typed, sizeof(typed), " answer: %s_", t->ask_buf);
dlines_push(&dl, typed, CP_ASSIST, 0, 0);
int start = dl.n > h - 1 ? dl.n - (h - 1) : 0;
for (int r = 0; r < h - 1 && start + r < dl.n; r++) {
dline_t *L = &dl.lines[start + r];
draw_ansi_line(t->logw, r, w, L->text, L->pair, L->bold, L->dim);
}
modal_footer(t, h, w,
t->ask_nopts ? " ↑↓/1-9 pick · type your own answer · Enter "
"send · Esc dismiss"
: " type your answer · Enter send · Esc dismiss");
dlines_free(&dl);
wrefresh(t->logw);
}
/* --- modal picker (↑↓ Enter Esc) ------------------------------------------ */
static int ci_contains(const char *hay, const char *needle) {
if (!needle[0])
return 1;
for (const char *h = hay; *h; h++) {
size_t i = 0;
while (needle[i] && h[i] &&
tolower((unsigned char)h[i]) == tolower((unsigned char)needle[i]))
i++;
if (!needle[i])
return 1;
}
return 0;
}
/* Centered dialog over the current screen: type to filter, ↑↓ / wheel to
* move, Enter or click to pick. items: n labels (each ≤127 chars). Returns
* the original index or -1. */
static int picker_run(tui_t *t, const char *title, char items[][128], int n,
int cur_idx) {
if (n <= 0)
return -1;
if (cur_idx >= n)
cur_idx = -1; /* -1: no current item */
int *vis = malloc((size_t)n * sizeof(int));
if (!vis)
return -1;
char query[64] = "";
size_t qlen = 0;
int sel = 0, top = 0, result = -1, nv = 0;
int first = 1, last_dh = -1, last_dw = -1;
WINDOW *dlg = NULL;
curs_set(0);
for (;;) {
nv = 0;
for (int i = 0; i < n; i++)
if (ci_contains(items[i], query))
vis[nv++] = i;
if (first) {
for (int k = 0; k < nv; k++)
if (vis[k] == cur_idx)
sel = k;
first = 0;
}
if (sel >= nv)
sel = nv > 0 ? nv - 1 : 0;
int rows, cols;
getmaxyx(stdscr, rows, cols);
int dw = cols - 4 < 80 ? cols - 4 : 80;
int list_h = rows - 10;
if (list_h > n)
list_h = n;
if (list_h < 1)
list_h = 1;
int dh = list_h + 5;
if (dh > rows)
dh = rows;
if (dw < 10)
dw = cols;
int dy = (rows - dh) / 2, dx = (cols - dw) / 2;
if (sel < top)
top = sel;
if (sel >= top + list_h)
top = sel - list_h + 1;
if (top > 0 && top + list_h > nv)
top = nv > list_h ? nv - list_h : 0;
drop_win(&dlg);
if (last_dh >= 0 && (dh != last_dh || dw != last_dw))
redraw_all(t); /* uncover what a larger dialog hid */
last_dh = dh;
last_dw = dw;
dlg = tui_newwin(dh, dw, dy, dx);
if (!dlg)
break;
keypad(dlg, TRUE);
wtimeout(dlg, -1);
if (HAS_COLOR())
wbkgd(dlg, COLOR_PAIR(CP_BOX));
werase(dlg);
draw_ansi_at(dlg, 1, 2, dw - 8, title ? title : "Select", CP_BOX_BRIGHT, 1,
0);
draw_ansi_at(dlg, 1, dw - 5, 3, "esc", CP_BOX_MUTED, 0, 0);
if (qlen)
draw_ansi_at(dlg, 2, 2, dw - 4, query, CP_BOX, 0, 0);
else
draw_ansi_at(dlg, 2, 2, dw - 4, "Search", CP_BOX_MUTED, 0, 0);
for (int i = 0; i < list_h && top + i < nv && 4 + i < dh; i++) {
int k = top + i, idx = vis[k], y = 3 + i + 1;
int on = k == sel;
int pair = on ? CP_SEL : CP_BOX;
int attrs = HAS_COLOR() ? (int)COLOR_PAIR(pair) : (on ? A_REVERSE : 0);
for (int x = 1; x < dw - 1; x++)
put_wc(dlg, y, x, L' ', attrs);
int used = draw_ansi_at(dlg, y, 2, 2,
idx == cur_idx ? (g_ascii ? "*" : "●") : " ",
on ? CP_SEL : CP_BOX_ACCENT, 0, 0);
draw_ansi_at(dlg, y, 2 + used + 1, dw - 6, items[idx],
on ? CP_SEL : CP_BOX, on, 0);
}
if (nv == 0)
draw_ansi_at(dlg, 4, 2, dw - 4, "No matches", CP_BOX_MUTED, 0, 0);
wrefresh(dlg);
wint_t wch = 0;
int rc = tui_wget_wch(dlg, &wch);
if (rc == ERR)
continue;
if (rc == KEY_CODE_YES) {
if (wch == KEY_UP && sel > 0)
sel--;
else if (wch == KEY_DOWN && sel + 1 < nv)
sel++;
else if (wch == KEY_PPAGE)
sel = sel > list_h ? sel - list_h : 0;
else if (wch == KEY_NPAGE)
sel = sel + list_h < nv ? sel + list_h : (nv > 0 ? nv - 1 : 0);
else if (wch == KEY_BACKSPACE && qlen)
query[qlen -= utf8_prev_len(query, qlen)] = 0, sel = 0, top = 0;
else if (wch == KEY_ENTER && nv > 0) {
result = vis[sel];
break;
} else if (wch == KEY_RESIZE) {
layout(t);
redraw_all(t);
} else if (wch == KEY_MOUSE) {
MEVENT evs[MOUSE_RUN_MAX], ev;
int ne = get_mouse_run(evs, MOUSE_RUN_MAX), k0 = ne - 1;
if (ne == 0)
continue;
for (int i = 0; i < ne; i++)
if (evs[i].bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED))
k0 = i; /* a click anywhere in the run wins over its release */
ev = evs[k0];
if (ev.bstate & BUTTON4_PRESSED) {
if (sel > 0)
sel--;
} else if (ev.bstate & BUTTON5_PRESSED) {
if (sel + 1 < nv)
sel++;
} else if (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) {
int k = top + (ev.y - dy - 4);
if (ev.y < dy || ev.y >= dy + dh || ev.x < dx || ev.x >= dx + dw)
break; /* click outside cancels */
if (ev.y >= dy + 4 && k >= 0 && k < nv) {
result = vis[k];
break;
}
}
}
continue;
}
if (wch == 10 || wch == 13) {
if (nv > 0) {
result = vis[sel];
break;
}
continue;
}
if (wch == 27 || wch == 3) /* Esc / Ctrl+C */
break;
if (wch == 127 || wch == 8) {
if (qlen)
query[qlen -= utf8_prev_len(query, qlen)] = 0;
sel = top = 0;
continue;
}
if (wch == 21) { /* Ctrl+U */
qlen = 0;
query[0] = 0;
continue;
}
if (wch == 14 || wch == 16) { /* Ctrl+N / Ctrl+P */
if (wch == 14 && sel + 1 < nv)
sel++;
else if (wch == 16 && sel > 0)
sel--;
continue;
}
if (iswprint(wch)) {
char mb[8];
int k = os_utf8_encode((unsigned long)(wchar_t)wch, mb);
if (k > 0 && qlen + (size_t)k < sizeof(query)) {
memcpy(query + qlen, mb, (size_t)k);
qlen += (size_t)k;
query[qlen] = 0;
sel = top = 0;
}
}
}
drop_win(&dlg);
free(vis);
curs_set(1);
layout(t);
redraw_all(t);
return result;
}
/* Full-screen hidden input (API keys). Returns 0 with out filled, -1 on
* Esc / Ctrl+C. */
static int secret_input_run(tui_t *t, const char *title, const char *hint,
char *out, size_t n) {
size_t len = 0;
out[0] = 0;
int rc = -1;
wtimeout(stdscr, -1);
keypad(stdscr, TRUE);
curs_set(1);
for (;;) {
int rows, cols;
getmaxyx(stdscr, rows, cols);
(void)rows;
werase(stdscr);
if (HAS_COLOR())
wbkgd(stdscr, COLOR_PAIR(CP_PANEL));
if (HAS_COLOR())
attron(COLOR_PAIR(CP_PRIMARY) | A_BOLD);
mvprintw(1, 2, "%.*s", cols - 4, title);
if (HAS_COLOR())
attroff(COLOR_PAIR(CP_PRIMARY) | A_BOLD);
if (HAS_COLOR())
attron(COLOR_PAIR(CP_MUTED));
mvprintw(2, 2, "%.*s", cols - 4, hint);
mvprintw(6, 2, "%.*s", cols - 4,
"Enter save · Esc cancel · Ctrl+U clear · paste works");
if (HAS_COLOR())
attroff(COLOR_PAIR(CP_MUTED));
int room = cols - 12 > 1 ? cols - 12 : 1;
int shown = (int)len < room ? (int)len : room;
mvprintw(4, 2, "key: ");
for (int i = 0; i < shown; i++)
addch('*');
if ((int)len > shown)
addstr("…");
move(4, 7 + shown);
refresh();
wint_t wch = 0;
int r = tui_wget_wch(stdscr, &wch);
if (r == ERR)
continue;
if (r == KEY_CODE_YES) {
if (wch == KEY_BACKSPACE && len > 0)
out[--len] = 0;
else if (wch == KEY_ENTER && len > 0) {
rc = 0;
break;
}
continue;
}
if (wch == 10 || wch == 13) {
if (len > 0) {
rc = 0;
break;
}
continue;
}
if (wch == 27 || wch == 3)
break;
if (wch == 127 || wch == 8) {
if (len > 0)
out[--len] = 0;
continue;
}
if (wch == 21) {
len = 0;
out[0] = 0;
continue;
}
if (wch > 32 && wch < 127 && len + 1 < n) {
out[len++] = (char)wch;
out[len] = 0;
}
}
if (rc != 0)
memset(out, 0, n);
layout(t);
wtimeout(t->input, 80);
keypad(t->input, TRUE);
redraw_all(t);
return rc;
}
/* Saved / env key for the provider, else ask for one and save it. */
/* --- /models probe ---------------------------------------------------------
* File-scope so a probe still running when the TUI exits writes nowhere
* stale. */
#define AM_MAX 512 /* routers list hundreds; the picker filters as you type */
enum { AM_NONE = 0, AM_RUNNING, AM_OK, AM_FAILED, AM_AUTH };
static pthread_mutex_t g_am_mu = PTHREAD_MUTEX_INITIALIZER;
static struct {
char provider[32];
char ids[AM_MAX][128];
int n;
int state;
char err[256];
} g_am;
static volatile int g_am_changed;
static volatile int g_am_threads;
typedef struct {
char provider[32];
char base[512];
char key[512];
int have_cache; /* a cached list is showing: keep it if the fetch fails */
} probe_arg_t;
static void am_store(const char *provider, int r, char ids[][128],
const char *err) {
pthread_mutex_lock(&g_am_mu);
if (strcmp(g_am.provider, provider) == 0) {
g_am.state = r >= 0 ? AM_OK : r == CST_MODELS_AUTH ? AM_AUTH : AM_FAILED;
g_am.n = r > 0 ? r : 0;
for (int i = 0; i < g_am.n; i++)
memcpy(g_am.ids[i], ids[i], 128);
snprintf(g_am.err, sizeof(g_am.err), "%s", err ? err : "");
g_am_changed = 1;
}
pthread_mutex_unlock(&g_am_mu);
}
static void *probe_thread(void *arg) {
probe_arg_t *a = arg;
static char ids[AM_MAX][128]; /* one probe runs per provider switch */
char err[256];
int r = cst_ps_fetch(a->provider, a->base, a->key, ids, AM_MAX, err,
sizeof(err));
if (r >= 0 || r == CST_MODELS_AUTH || !a->have_cache)
am_store(a->provider, r, ids, err);
memset(a->key, 0, sizeof(a->key));
free(a);
pthread_mutex_lock(&g_am_mu);
g_am_threads--;
pthread_mutex_unlock(&g_am_mu);
return NULL;
}
static void start_probe(tui_t *t) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
if (!cfg || !cfg->api_key[0] || g_am_threads > 0)
return;
static char ids[AM_MAX][128];
int fresh = 0;
int nc = cst_ps_cached(cfg->provider, ids, AM_MAX, &fresh);
pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
pthread_mutex_unlock(&g_am_mu);
if (nc > 0)
am_store(cfg->provider, nc, ids, "");
if (fresh)
return;
probe_arg_t *a = calloc(1, sizeof(*a));
if (!a)
return;
snprintf(a->provider, sizeof(a->provider), "%s", cfg->provider);
snprintf(a->base, sizeof(a->base), "%s", cfg->api_base);
snprintf(a->key, sizeof(a->key), "%s", cfg->api_key);
a->have_cache = nc > 0;
pthread_mutex_lock(&g_am_mu);
if (nc <= 0) {
g_am.state = AM_RUNNING;
g_am.n = 0;
}
g_am_threads++;
pthread_mutex_unlock(&g_am_mu);
pthread_t th;
if (pthread_create(&th, NULL, probe_thread, a) != 0) {
pthread_mutex_lock(&g_am_mu);
g_am_threads--;
pthread_mutex_unlock(&g_am_mu);
free(a);
return;
}
pthread_detach(th);
}
/* Copies the API model list for provider; returns the count, or -1 if none
* is known yet. */
static int am_models(const char *provider, char out[][128], int max) {
int n = -1;
pthread_mutex_lock(&g_am_mu);
if (strcmp(g_am.provider, provider) == 0 && g_am.state == AM_OK) {
n = g_am.n < max ? g_am.n : max;
for (int i = 0; i < n; i++)
memcpy(out[i], g_am.ids[i], 128);
}
pthread_mutex_unlock(&g_am_mu);
return n;
}
/* After a probe: flag a model the API does not list; with no model yet
* (router presets) take the first one listed. */
static void am_check_model(tui_t *t) {
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
char msg[400] = "";
int picked = 0;
pthread_mutex_lock(&g_am_mu);
if (cfg && strcmp(g_am.provider, cfg->provider) == 0) {
if (g_am.state == AM_AUTH) {
snprintf(msg, sizeof(msg), "the %s API key was rejected — /key to "
"replace it", cfg->provider);
} else if (g_am.state == AM_OK && g_am.n > 0 && !cfg->model[0]) {
snprintf(cfg->model, sizeof(cfg->model), "%s", g_am.ids[0]);
snprintf(msg, sizeof(msg), "model → %.127s (%d listed by %s) — /model "
"to change",
cfg->model, g_am.n, cfg->provider);
picked = 1;
} else if (g_am.state == AM_OK && g_am.n > 0) {
int found = 0;
for (int i = 0; i < g_am.n && !found; i++)
found = strcmp(g_am.ids[i], cfg->model) == 0;
if (!found)
snprintf(msg, sizeof(msg), "%s is not in %s's model list — /model "
"to pick one", cfg->model, cfg->provider);
}
}
pthread_mutex_unlock(&g_am_mu);
if (picked)
cst_settings_remember(cfg);
if (msg[0])
set_status(t, msg);
}
static int obtain_key(tui_t *t, const char *provider, char *out, size_t n,
int force_ask) {
if (!force_ask && cst_creds_resolve(provider, out, n, NULL) == 0)
return 0;
char dir[1024] = "the config directory";
os_config_dir(dir, sizeof(dir));
const char *base = cst_ps_base(cst_agent_config(t->agent), provider);
char title[128], hint[1300];
snprintf(title, sizeof(title), "API key for %s", provider);
snprintf(hint, sizeof(hint), "%.200s · saved to %.900s/credentials (only you "
"can read it)",
base, dir);
char note[300] = "";
int flags;
for (;;) {
if (secret_input_run(t, title, hint, out, n) != 0)
return -1;
set_status(t, "checking the key…");
draw_status(t);
static char ids[AM_MAX][128];
char err[256];
int r = -1;
flags = cst_ps_verify_save(provider, base, out, ids, AM_MAX, &r, err,
sizeof(err));
if (flags == CST_MODELS_AUTH) {
snprintf(hint, sizeof(hint), "%s rejected that key (%.200s) — paste "
"another one",
base, err);
continue;
}
pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", provider);
pthread_mutex_unlock(&g_am_mu);
am_store(provider, r, ids, err);
if (flags & CST_PS_UNVERIFIED)
snprintf(note, sizeof(note), "could not verify the key (%.200s); "
"saved anyway",
err);
break;
}
if (flags & CST_PS_UNSAVED)
set_status(t, "warning: could not save the key; it is used for this run "
"only");
else if (note[0])
set_status(t, note);
else
set_status(t, "API key verified and saved");
return 0;
}
/* --- slash commands ------------------------------------------------------- */
/* First-user dumps / JSON / ctl noise make a bad session chrome title. */
static int title_is_noisy(const char *s) {
if (!s || !s[0])
return 1;
while (*s == ' ' || *s == '\t')
s++;
if (*s == '{' || *s == '[')
return 1;
if (!strncmp(s, "```", 3))
return 1;
if (strstr(s, "\"version\"") || strstr(s, "task controls") ||
strstr(s, "\"revision\"") || strstr(s, "\"sequence\""))
return 1;
return 0;
}
/* One-line human summary into out; empty if nothing usable. */
static void title_summarize(char *out, size_t sz, const char *src) {
out[0] = 0;
if (!src || title_is_noisy(src))
return;
while (*src == ' ' || *src == '\t' || *src == '\n' || *src == '\r')
src++;
char buf[160];
size_t o = 0;
int sp = 0;
for (const char *p = src; *p && *p != '\n' && *p != '\r' && o + 1 < sizeof(buf);
p++) {
unsigned char c = (unsigned char)*p;
if (c == ' ' || c == '\t') {
if (o == 0 || sp)
continue;
buf[o++] = ' ';
sp = 1;
continue;
}
sp = 0;
buf[o++] = (char)c;
}
buf[o] = 0;
if (!buf[0] || title_is_noisy(buf))
return;
size_t fit = utf8_fit_cols(buf, strlen(buf), 40);
snprintf(out, sz, "%.*s", (int)fit, buf);
}
static void refresh_title(tui_t *t) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
char sid[128];
cst_agent_session_id(t->agent, sid, sizeof(sid));
cst_session_info_t in;
memset(&in, 0, sizeof(in));
t->title[0] = 0;
if (cfg)
(void)cst_agent_store_info(cfg->workspace_root, sid, &in);
if (in.title[0] && !title_is_noisy(in.title)) {
size_t fit = utf8_fit_cols(in.title, strlen(in.title), 40);
snprintf(t->title, sizeof(t->title), "%.*s", (int)fit, in.title);
} else {
title_summarize(t->title, sizeof(t->title), in.first_user);
}
if (!t->title[0])
snprintf(t->title, sizeof(t->title), "New session");
}
static void refresh_todo(tui_t *t) {
const char *td = cst_agent_todo(t->agent);
tui_lock(t);
free(t->todo);
t->todo = td && td[0] ? xstrdup(td) : NULL;
t->need_layout = 1;
tui_unlock(t);
}
static void push_lines(tui_t *t, const char *prefix, const char *text) {
for (const char *p = text; p && *p;) {
const char *nl = strchr(p, '\n');
int len = nl ? (int)(nl - p) : (int)strlen(p);
push_system_line(t, "%s%.*s", prefix, len > 900 ? 900 : len, p);
if (!nl)
break;
p = nl + 1;
}
}
static void after_session_change(tui_t *t, const char *note) {
reload_hist_from_session(t);
refresh_title(t);
refresh_todo(t);
if (note)
push_system_line(t, "%s", note);
status_idle(t);
}
typedef struct slash_cmd slash_cmd_t;
typedef void (*slash_fn)(tui_t *t, char *arg, int *quit);
struct slash_cmd {
const char *name;
const char *aliases; /* space separated */
const char *args;
const char *desc;
int busy_ok;
slash_fn fn;
};
static const slash_cmd_t *slash_table(int *n);
static void cmd_help(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
int n;
const slash_cmd_t *c = slash_table(&n);
push_system_line(t, "");
push_system_line(t, "Commands:");
for (int i = 0; i < n; i++) {
char name[64];
snprintf(name, sizeof(name), "/%s %s", c[i].name, c[i].args);
char also[64] = "";
if (c[i].aliases[0])
snprintf(also, sizeof(also), " (also: %s)", c[i].aliases);
push_system_line(t, " %-26s %s%s", name, c[i].desc, also);
}
push_system_line(t, "Keys: /keys (or F1) lists every shortcut; Ctrl+X is the "
"leader (Ctrl+X n new session, Ctrl+X m model, ...).");
push_system_line(t, " Enter send · Esc cancel turn · Ctrl+C twice or Ctrl+D "
"quit · Ctrl+P command palette");
push_system_line(t, " Mouse: click cards / Thought to expand, wheel scrolls, "
"drag copies (double-click word, triple line) · /mouse");
push_system_line(t, " Rebind in keybinds.json next to settings.json");
push_system_line(t, "");
}
static void cmd_new(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
char id[128] = {0};
if (cst_agent_new_session(t->agent, id, sizeof(id)) != 0) {
push_system_line(t, "Error: failed to create session");
return;
}
after_session_change(t, NULL); /* empty transcript → start screen */
}
static int session_labels(tui_t *t, char ids[][128], char labels[][128],
int max) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
int n = cst_agent_store_list(cfg->workspace_root, ids, max);
for (int i = 0; i < n; i++) {
cst_session_info_t in;
char when[24] = "?";
if (cst_agent_store_info(cfg->workspace_root, ids[i], &in) == 0) {
time_t tt = (time_t)in.mtime;
struct tm tm;
os_localtime(&tt, &tm);
strftime(when, sizeof(when), "%m-%d %H:%M", &tm);
snprintf(labels[i], 128, "%s %3d msgs %.60s", when, in.messages,
in.title[0] ? in.title : in.first_user);
} else {
snprintf(labels[i], 128, "%s", ids[i]);
}
}
return n;
}
static int picker_run(tui_t *t, const char *title, char items[][128], int n,
int cur_idx);
static void cmd_session(tui_t *t, char *arg, int *quit) {
(void)quit;
static char ids[64][128], labels[64][128];
char chosen[128];
if (arg && arg[0]) {
snprintf(chosen, sizeof(chosen), "%s", arg);
} else {
int n = session_labels(t, ids, labels, 64);
if (n <= 0) {
push_system_line(t, "Sessions: (none)");
return;
}
char cur[128];
cst_agent_session_id(t->agent, cur, sizeof(cur));
int cur_idx = 0;
for (int i = 0; i < n; i++)
if (strcmp(ids[i], cur) == 0)
cur_idx = i;
int pick = picker_run(t, "Session", labels, n, cur_idx);
if (pick < 0)
return;
snprintf(chosen, sizeof(chosen), "%s", ids[pick]);
}
if (cst_agent_switch_session(t->agent, chosen) != 0) {
push_system_line(t, "Error: cannot switch to session '%s'", chosen);
return;
}
char note[192];
snprintf(note, sizeof(note), "(switched to %s)", chosen);
after_session_change(t, note);
}
static void cmd_rename(tui_t *t, char *arg, int *quit) {
(void)quit;
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
char sid[128];
cst_agent_session_id(t->agent, sid, sizeof(sid));
if (cst_agent_store_title_set(cfg->workspace_root, sid, arg) != 0) {
push_system_line(t, "Error: cannot set the title");
return;
}
refresh_title(t);
push_system_line(t, arg && arg[0] ? "(session renamed to \"%s\")"
: "(session title cleared%s)",
arg ? arg : "");
}
static void cmd_delete(tui_t *t, char *arg, int *quit) {
(void)quit;
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
static char ids[64][128], labels[64][128];
char victim[128];
if (arg && arg[0]) {
snprintf(victim, sizeof(victim), "%s", arg);
} else {
int n = session_labels(t, ids, labels, 64);
if (n <= 0)
return;
int pick = picker_run(t, "Delete which session?", labels, n, 0);
if (pick < 0)
return;
snprintf(victim, sizeof(victim), "%s", ids[pick]);
}
char confirm[2][128];
snprintf(confirm[0], 128, "No, keep it");
snprintf(confirm[1], 128, "Yes, delete %.100s", victim);
if (picker_run(t, "Delete session permanently?", confirm, 2, 0) != 1)
return;
char cur[128];
cst_agent_session_id(t->agent, cur, sizeof(cur));
int was_current = strcmp(cur, victim) == 0;
if (was_current) {
char id[128];
if (cst_agent_new_session(t->agent, id, sizeof(id)) != 0) {
push_system_line(t, "Error: cannot open a new session first");
return;
}
}
if (cst_agent_store_delete(cfg->workspace_root, victim) != 0)
push_system_line(t, "Error: cannot delete %s", victim);
if (was_current)
after_session_change(t, NULL);
push_system_line(t, "(deleted session %s)", victim);
}
/* "name 1M · vision (api)" for the /model picker. */
static void model_label(const char *provider, const char *name, int api,
char *out, size_t n) {
cst_model_info_t mi;
cst_model_info(provider, name, &mi);
char tag[40];
cst_model_info_label(&mi, tag, sizeof(tag));
snprintf(out, n, "%-40.72s %.32s%s", name, tag, api ? " (api)" : "");
}
static void cmd_model(tui_t *t, char *arg, int *quit) {
(void)quit;
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
if (!arg || !arg[0]) {
static char names[AM_MAX + 32][128], labels[AM_MAX + 32][128];
int na = am_models(cfg->provider, names, AM_MAX);
if (na < 0 && cfg->api_key[0]) {
set_status(t, "fetching the model list…");
draw_status(t);
char err[256];
na = cst_ps_fetch(cfg->provider, cfg->api_base, cfg->api_key, names,
AM_MAX, err, sizeof(err));
pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
pthread_mutex_unlock(&g_am_mu);
am_store(cfg->provider, na, names, err);
if (na < 0)
set_status(t, err);
else
status_idle(t);
}
if (na < 0)
na = 0;
for (int i = 0; i < na; i++)
model_label(cfg->provider, names[i], 1, labels[i], 128);
char builtin[32][128];
int nb = cst_models_list(cfg->model, builtin, 32);
int n = na;
for (int i = 0; i < nb && n < AM_MAX + 32; i++) {
int dup = 0;
for (int k = 0; k < na && !dup; k++)
dup = strcmp(names[k], builtin[i]) == 0;
if (dup)
continue;
memcpy(names[n], builtin[i], 128);
model_label(cfg->provider, builtin[i], 0, labels[n], 128);
n++;
}
int cur_idx = -1;
for (int i = 0; i < n; i++)
if (strcmp(names[i], cfg->model) == 0)
cur_idx = i;
int pick = picker_run(t, "Model", labels, n, cur_idx);
if (pick < 0)
return;
snprintf(cfg->model, sizeof(cfg->model), "%s", names[pick]);
} else {
snprintf(cfg->model, sizeof(cfg->model), "%.127s", arg);
}
cst_settings_remember(cfg);
cst_model_info_t mi;
cst_model_info(cfg->provider, cfg->model, &mi);
push_system_line(t, "(model → %s, context %ld tokens%s)", cfg->model,
mi.context,
mi.vision == 1 ? ", takes images"
: mi.vision == 0 ? ", text only"
: "");
}
static void cmd_key(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
char key[512];
if (obtain_key(t, cfg->provider, key, sizeof(key), 1) != 0) {
push_system_line(t, "(API key unchanged)");
return;
}
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
memset(key, 0, sizeof(key));
}
static void cmd_effort(tui_t *t, char *arg, int *quit) {
(void)quit;
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
static const char *const names[] = {"low", "medium", "high", "max"};
static const char *const desc[] = {"quick answers, little thinking",
"balanced (default)",
"thinks longer on hard problems",
"maximum thinking, slowest"};
const char *want = NULL;
if (arg && arg[0]) {
if (!cst_effort_valid(arg)) {
push_system_line(t, "Unknown effort '%s' (low medium high max)", arg);
return;
}
want = arg;
} else {
char items[4][128];
int cur_idx = 1;
for (int i = 0; i < 4; i++) {
snprintf(items[i], 128, "%-7s %s", names[i], desc[i]);
if (strcmp(names[i], cfg->effort) == 0)
cur_idx = i;
}
int pick = picker_run(t, "Reasoning effort", items, 4, cur_idx);
if (pick < 0)
return;
want = names[pick];
}
snprintf(cfg->effort, sizeof(cfg->effort), "%s", want);
cst_settings_remember(cfg);
if (cst_effort_wire(cfg->provider, cfg->model, cfg->effort))
push_system_line(t, "(effort → %s)", cfg->effort);
else
push_system_line(t, "(effort → %s; %s/%s does not take an effort "
"setting, it applies to DeepSeek and OpenAI "
"reasoning models)",
cfg->effort, cfg->provider, cfg->model);
}
static void cmd_mode(tui_t *t, char *arg, int *quit) {
(void)quit;
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
static const char *const names[] = {"coding", "general"};
static const char *const desc[] = {
"code framed: repo, build, tests (default)",
"documents, data, research, everyday files"};
const char *want = NULL;
if (arg && arg[0]) {
if (!cst_mode_valid(arg)) {
push_system_line(t, "Unknown mode '%s' (coding | general)", arg);
return;
}
want = arg;
} else {
char items[2][128];
int cur_idx = 0;
for (int i = 0; i < 2; i++) {
snprintf(items[i], 128, "%-8s %s", names[i], desc[i]);
if (strcmp(names[i], cst_mode_name(cfg->mode)) == 0)
cur_idx = i;
}
int pick = picker_run(t, "Working mode", items, 2, cur_idx);
if (pick < 0)
return;
want = names[pick];
}
snprintf(cfg->mode, sizeof(cfg->mode), "%s", want);
cst_settings_remember_mode(cfg->mode);
push_system_line(t, "(mode → %s%s)", cfg->mode,
strcmp(cfg->mode, "general") == 0
? "; the system prompt is rewritten on the next turn"
: "");
}
static void cmd_provider(tui_t *t, char *arg, int *quit) {
(void)quit;
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
const cst_provider_t *p = NULL;
if (arg && arg[0]) {
p = cst_provider_find(arg);
if (!p) {
push_system_line(t, "Unknown provider '%s'", arg);
return;
}
} else {
int n = cst_provider_count();
char items[32][128];
int cur_idx = 0;
for (int i = 0; i < n && i < 32; i++) {
const cst_provider_t *q = cst_provider_at(i);
snprintf(items[i], 128, "%-11.24s %.48s (%.24s%s)", q->name,
q->api_base, q->model[0] ? q->model : "models from API",
strcmp(cst_provider_source(q), "user") == 0 ? " · models.json"
: "");
if (strcmp(q->name, cfg->provider) == 0)
cur_idx = i;
}
int pick = picker_run(t, "Provider", items, n < 32 ? n : 32, cur_idx);
if (pick < 0)
return;
p = cst_provider_at(pick);
}
char key[512];
if (obtain_key(t, p->name, key, sizeof(key), 0) != 0) {
push_system_line(t, "(provider unchanged: %s needs an API key)", p->name);
return;
}
cst_ps_apply_provider(cfg, p, key);
memset(key, 0, sizeof(key));
push_system_line(t, "(provider → %s · %s · model %s)", p->name, p->api_base,
cfg->model);
if (am_models(p->name, NULL, 0) < 0)
start_probe(t);
else
am_check_model(t);
}
static void cmd_protocol(tui_t *t, char *arg, int *quit) {
(void)quit;
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
cst_protocol_t p = cfg->protocol;
if (arg && arg[0]) {
if (cst_protocol_parse(arg, &p) != 0) {
push_system_line(t, "Unknown protocol '%s' (native-eos native-inc ctl "
"ctl-heredoc xml)", arg);
return;
}
} else {
static const cst_protocol_t all[] = {CST_PROTO_NATIVE_EOS,
CST_PROTO_NATIVE_INC, CST_PROTO_CTL,
CST_PROTO_CTL_HEREDOC, CST_PROTO_XML};
char names[5][128];
int cur_idx = 0;
for (int i = 0; i < 5; i++) {
snprintf(names[i], sizeof(names[i]), "%s", cst_protocol_name(all[i]));
if (all[i] == cfg->protocol)
cur_idx = i;
}
int pick = picker_run(t, "Protocol", names, 5, cur_idx);
if (pick < 0)
return;
p = all[pick];
}
cfg->protocol = p;
push_system_line(t, "(protocol → %s)", cst_protocol_name(p));
}
static void cmd_tools(tui_t *t, char *arg, int *quit) {
(void)quit;
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
if (arg && arg[0]) {
char verb[16] = "", name[64] = "";
sscanf(arg, "%15s %63s", verb, name);
int prof;
if (cst_tool_profile_parse(verb, &prof) == 0) {
cfg->tool_profile = prof;
push_system_line(t, "(tool profile → %s)", cst_tool_profile_name(prof));
} else if ((strcmp(verb, "on") == 0 || strcmp(verb, "off") == 0) &&
name[0]) {
if (cst_tool_index(name) < 0) {
push_system_line(t, "Unknown tool '%s'", name);
return;
}
cst_tool_set_disabled(cfg, name, verb[1] != 'n');
push_system_line(t, "(%s %s)", name, verb);
} else {
push_system_line(t, "Usage: /tools [on NAME | off NAME | default | "
"minimal]");
return;
}
}
static const char *const perm_names[] = {"read", "write", "shell",
"net", "ask", "meta"};
push_system_line(t, "Tools (profile %s):",
cst_tool_profile_name(cfg->tool_profile));
for (int i = 0; i < cst_tool_count(); i++) {
const cst_tool_def_t *d = cst_tool_at(i);
int on = cst_tool_active(cfg, d);
const char *desc = d->desc ? d->desc : "";
int dl = (int)strcspn(desc, ".\n");
push_system_line(t, " %s %-12s %-5s %.*s%s%s%s", on ? "[x]" : "[ ]",
d->name, (int)d->perm < 6 ? perm_names[d->perm] : "?",
dl > 70 ? 70 : dl, desc, d->owner ? " (" : "",
d->owner ? d->owner : "", d->owner ? ")" : "");
}
}
static void cmd_sandbox(tui_t *t, char *arg, int *quit) {
(void)quit;
if (arg && strcmp(arg, "reset-labels") == 0) {
char *rep = NULL;
int n = cst_sandbox_reset_labels(&rep);
if (rep && rep[0])
push_lines(t, " ", rep);
push_system_line(t, n < 0 ? "(some labels could not be restored; "
"they stay listed)"
: n == 0 ? "(no labeled directories)"
: "(labels restored)");
free(rep);
return;
}
int sk = cst_sandbox_probe();
push_system_line(t, "Shell sandbox: %s",
sk == CST_SANDBOX_NONE ? "none (rule-based approval)"
: cst_sandbox_name(sk));
char *l = cst_sandbox_labels();
if (l) {
push_system_line(t, "Labeled Low integrity (writable by sandboxed "
"commands):");
push_lines(t, " ", l);
push_system_line(t, "/sandbox reset-labels restores their old labels");
free(l);
}
}
static void cmd_permissions(tui_t *t, char *arg, int *quit) {
(void)quit;
cst_perms_t *p = cst_agent_perms(t->agent);
if (arg && strcmp(arg, "reset") == 0) {
cst_perms_reset(p);
push_system_line(t, "(saved permission rules cleared)");
return;
}
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
push_system_line(t, "Confirmation: shell %s · write %s · net %s%s",
cfg->confirm_shell ? "ask" : "auto",
cfg->confirm_write ? "ask" : "auto",
cfg->confirm_net ? "ask" : "auto",
cfg->auto_approve ? " · YOLO (everything auto)" : "");
char *d = cst_perms_describe(p);
push_lines(t, " ", d ? d : "(no saved rules)");
free(d);
push_system_line(t, " saved in .agent/permissions.json · /permissions reset");
}
static void set_approval(tui_t *t, int a) {
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
cfg->approval = a;
cfg->auto_approve = a == CST_APPROVAL_YOLO;
if (a != CST_APPROVAL_YOLO) /* YOLO lasts for this session only */
cst_settings_remember_approval(a);
}
static const char *approval_help(int a) {
switch (a) {
case CST_APPROVAL_YOLO:
return "shell, writes and web run without asking";
case CST_APPROVAL_AUTO:
return "edits run, shell runs in the sandbox or by safe rules, else asks";
default:
return "shell, writes and web ask first";
}
}
static void cmd_yolo(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
int on = approval_mode(t) != CST_APPROVAL_YOLO;
set_approval(t, on ? CST_APPROVAL_YOLO : CST_DEFAULT_APPROVAL);
push_system_line(t, "(approval %s: %s)", cst_approval_name(approval_mode(t)),
approval_help(approval_mode(t)));
}
static void cmd_approval(tui_t *t, char *arg, int *quit) {
(void)quit;
int a;
if (arg && arg[0]) {
if (cst_approval_parse(arg, &a) != 0) {
push_system_line(t, "Usage: /approval ask|auto|yolo");
return;
}
set_approval(t, a);
}
a = approval_mode(t);
char label[64];
approval_label(t, label, sizeof(label));
push_system_line(t, "(approval %s: %s)", label, approval_help(a));
}
static void cmd_sidebar(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
t->show_side = !t->show_side;
t->need_layout = 1;
if (t->show_side && t->cols < 110)
push_system_line(t, "(the Seat Deck shows when the terminal is at least "
"110 columns wide)");
}
static void cmd_motion(tui_t *t, char *arg, int *quit) {
(void)quit;
if (arg && arg[0]) {
if (!strcasecmp(arg, "on") || !strcmp(arg, "1"))
t->motion_on = 1;
else if (!strcasecmp(arg, "off") || !strcmp(arg, "0"))
t->motion_on = 0;
else {
push_system_line(t, "Usage: /motion [on|off]");
return;
}
} else {
t->motion_on = !t->motion_on;
}
push_system_line(t, t->motion_on ? "(Seat Deck motion on)"
: "(Seat Deck motion off)");
}
static void cmd_mouse(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
t->mouse_on = !t->mouse_on;
apply_mouse(t);
push_system_line(t, t->mouse_on
? "(mouse on · drag selects and copies (OSC 52) · "
"Shift+drag uses the terminal's own selection)"
: "(mouse off · the terminal selects text)");
}
static void cmd_max_steps(tui_t *t, char *arg, int *quit) {
(void)quit;
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
if (arg && atoi(arg) > 0)
cfg->max_steps = atoi(arg);
push_system_line(t, "(max steps per turn: %d)", cfg->max_steps);
}
static void cmd_realm(tui_t *t, char *arg, int *quit) {
(void)quit;
const char *sub = arg && arg[0] ? arg : "status";
if (!strcmp(sub, "status") || !strcmp(sub, "show")) {
char *pst = cst_parliament_status_text();
if (pst && pst[0]) {
push_system_line(t, "Realm / Parliament:");
char *p = pst;
while (*p) {
char *nl = strchr(p, '\n');
if (nl)
*nl = 0;
if (p[0])
push_system_line(t, " %s", p);
if (!nl)
break;
p = nl + 1;
}
} else {
push_system_line(t, "(no realm status)");
}
free(pst);
if (!cst_parliament_is_active())
push_system_line(t, "(solo mode: local task/worker tools on; set "
"CST_COLLABORATION=parliament or /realm login to "
"join a parliament)");
return;
}
if (!strcmp(sub, "stop")) {
if (cst_parliament_daemon_stop() != 0)
push_system_line(t, "Error: could not stop parliament service");
else
push_system_line(t, "(parliament service stopped)");
return;
}
if (!strncmp(sub, "login", 5)) {
char dir[OS_PATH_MAX];
if (cst_realm_dir(dir, sizeof(dir)) != 0 ||
!cst_realm_is_password_mode(dir)) {
push_system_line(t, "No password Realm yet. Outside TUI run:");
push_system_line(t, " hermit --realm-password-init");
return;
}
char pw[256];
if (secret_input_run(t, "Realm password",
"Unlocks this device as chair; starts the service",
pw, sizeof(pw)) != 0) {
push_system_line(t, "(login cancelled)");
return;
}
char iidbuf[64];
cst_parliament_default_instance(iidbuf, sizeof(iidbuf));
const char *iid = iidbuf;
char err[256];
char *hand = cst_parliament_login(pw, iid, NULL, err, sizeof(err));
if (!hand) {
memset(pw, 0, sizeof(pw));
push_system_line(t, "Login failed: %s", err[0] ? err : "unknown");
return;
}
/* Show a short summary, not the full handover blob. */
push_system_line(t, "(logged in as chair %s)", iid);
if (strstr(hand, "handover_from=") &&
!strstr(hand, "handover_from=\n") &&
!strstr(hand, "handover_from=\r"))
push_system_line(t, "(took chair from previous device)");
free(hand);
{
char serr[256];
if (cst_parliament_daemon_start(pw, iid, 9100, serr, sizeof(serr)) != 0)
push_system_line(t, "warning: service: %s",
serr[0] ? serr : "start failed");
else if (cst_parliament_daemon_running())
push_system_line(t, "(parliament service running)");
}
memset(pw, 0, sizeof(pw));
return;
}
if (!strcmp(sub, "join")) {
if (cst_parliament_session_ok(NULL, 0)) {
push_system_line(t, "(already seated; /realm status)");
return;
}
char pw[256];
if (secret_input_run(t, "Realm password",
"Join as a peer seat without stealing the chair",
pw, sizeof(pw)) != 0) {
push_system_line(t, "(join cancelled)");
return;
}
char iidbuf[64];
cst_parliament_default_instance(iidbuf, sizeof(iidbuf));
char err[256];
char *hand =
cst_parliament_seat_join(pw, iidbuf, NULL, err, sizeof(err));
if (!hand) {
memset(pw, 0, sizeof(pw));
push_system_line(t, "Join failed: %s", err[0] ? err : "unknown");
return;
}
const char *role = strstr(hand, "role=chair") ? "chair" : "member";
push_system_line(t, "(joined as %s seat %s)", role, iidbuf);
free(hand);
{
char serr[256];
if (cst_parliament_daemon_start(pw, iidbuf, 9100, serr, sizeof(serr)) != 0)
push_system_line(t, "warning: service: %s",
serr[0] ? serr : "start failed");
else if (cst_parliament_daemon_running())
push_system_line(t, "(parliament service running)");
}
memset(pw, 0, sizeof(pw));
return;
}
push_system_line(t, "Usage: /realm [status|login|join|stop]");
}
static void cmd_status(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
char sid[128];
cst_agent_session_id(t->agent, sid, sizeof(sid));
long win = cst_model_context_tokens(cfg->model);
long used = cst_agent_last_prompt_tokens(t->agent);
cst_session_t *s = cst_agent_session(t->agent);
int nmsg = 0;
if (s) {
cst_session_lock(s);
nmsg = cst_session_count(s);
cst_session_unlock(s);
}
push_system_line(t, "Status:");
push_system_line(t, " workspace %s", cfg->workspace_root);
push_system_line(t, " session %s (%d messages) %s", sid, nmsg, t->title);
push_system_line(t, " provider %s %s", cfg->provider, cfg->api_base);
push_system_line(t, " model %s context %ld/%ld tokens (%ld%%)",
cfg->model, used, win, win ? used * 100 / win : 0);
{
cst_model_info_t mi;
cst_model_info(cfg->provider, cfg->model, &mi);
char tag[48];
cst_model_info_label(&mi, tag, sizeof(tag));
push_system_line(t, " model info %s · from %s%s%s", tag, mi.source,
cst_models_load_error()[0] ? " · " : "",
cst_models_load_error());
}
push_system_line(t, " effort %s%s", cfg->effort,
cst_effort_wire(cfg->provider, cfg->model, cfg->effort)
? ""
: " (not used by this model)");
char key[512];
const char *from = NULL;
int have = cst_creds_resolve(cfg->provider, key, sizeof(key), &from) == 0;
int same = have && strcmp(key, cfg->api_key) == 0;
memset(key, 0, sizeof(key));
push_system_line(t, " api key %s",
!cfg->api_key[0] ? "missing — /key to enter one"
: !same ? "this run only (--api-key / CST_API_KEY)"
: strcmp(from, "saved") == 0 ? "saved (/key to replace)"
: from);
push_system_line(t, " protocol %s · tools %s · mode %s · max steps %d",
cst_protocol_name(cfg->protocol),
cst_tool_profile_name(cfg->tool_profile),
cst_mode_name(cfg->mode), cfg->max_steps);
char appr[64];
approval_label(t, appr, sizeof(appr));
push_system_line(t, " approval %s — %s", appr,
approval_help(approval_mode(t)));
push_system_line(t, " plan %s",
cfg->plan_mode ? "Plan (read-only tools)" : "Build");
const char *notes = cst_agent_notes(t->agent);
push_system_line(t, " notes %s",
notes && notes[0] ? "AGENTS.md loaded" : "(no AGENTS.md)");
push_system_line(t, " undo %d turn(s) can be undone",
cst_agent_undo_available(t->agent));
{
char *pst = cst_parliament_status_text();
if (pst && pst[0]) {
push_system_line(t, "Parliament / Realm:");
char *p = pst;
while (*p) {
char *nl = strchr(p, '\n');
if (nl)
*nl = 0;
if (p[0])
push_system_line(t, " %s", p);
if (!nl)
break;
p = nl + 1;
}
}
free(pst);
if (cst_parliament_is_active() && !cst_parliament_session_ok(NULL, 0))
push_system_line(t, " tip hermit --login (HERMIT_REALM_PASSWORD)");
}
cst_usage_t u;
cst_agent_usage(t->agent, &u, NULL);
if (u.requests > 0 && u.prompt > 0)
push_system_line(t, " cache %ld%% of %ld prompt tokens hit (/cost)",
u.cached * 100 / u.prompt, u.prompt);
}
static void push_usage(tui_t *t, const char *label, const cst_usage_t *u) {
if (u->requests == 0) {
push_system_line(t, " %-9s no requests yet", label);
return;
}
long hitp = u->prompt > 0 ? u->cached * 100 / u->prompt : 0;
push_system_line(t,
" %-9s %d request(s) · prompt %ld (hit %ld = %ld%%, miss "
"%ld) · output %ld",
label, u->requests, u->prompt, u->cached, hitp, u->miss,
u->out);
if (u->write)
push_system_line(t, " %-9s cache write %ld tokens", "", u->write);
if (u->priced)
push_system_line(t, " %-9s %s %s%.4f", "", g_ascii ? "~" : "≈",
money_sym(u->currency), u->cost);
}
static void cmd_cost(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
cst_usage_t s, last;
cst_agent_usage(t->agent, &s, &last);
push_system_line(t, "Token use and cost (%s):", cfg->model);
push_usage(t, "session", &s);
push_usage(t, "last turn", &last);
cst_price_t p;
if (cst_model_price(cfg->model, &p) != 0) {
push_system_line(t, " price unknown for this model; set "
"CST_PRICE=miss,hit,out[,write[,currency]] (per 1M "
"tokens)");
return;
}
const char *c = money_sym(p.currency);
push_system_line(t,
" price per 1M tokens: miss %s%.2f · hit %s%.2f · "
"output %s%.2f",
c, p.miss, c, p.hit, c, p.out);
if (p.offpeak_half)
push_system_line(t,
" now %s (off-peak is half price: outside "
"Mon–Fri 9–12 / 14–18 Beijing time)",
cst_price_is_peak(time(NULL)) ? "peak" : "off-peak");
}
static void cmd_todo(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
tui_lock(t);
char *td = t->todo ? xstrdup(t->todo) : NULL;
tui_unlock(t);
push_system_line(t, "Tasks:");
push_lines(t, " ", td && td[0] ? td : "(no tasks yet)");
free(td);
}
static void cmd_undo(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
char rep[4096];
int rc = cst_agent_undo(t->agent, rep, sizeof(rep));
push_system_line(t, rc == 0 ? "Undo:" : "Undo failed:");
push_lines(t, " ", rep);
}
static void cmd_retry(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
char *p = cst_agent_rewind_last_turn(t->agent);
if (!p) {
push_system_line(t, "Nothing to retry");
return;
}
reload_hist_from_session(t);
push_system_line(t, "(retrying the last prompt; file changes were kept — "
"/undo first to revert them)");
free(t->pending_prompt);
t->pending_prompt = p;
}
static void cmd_diff(tui_t *t, char *arg, int *quit) {
(void)quit;
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
os_run_result_t st = {0}, df = {0};
if (os_run_shell_c(cfg->workspace_root, "git status --short", 64 * 1024,
&st) != 0 ||
st.exit_code != 0) {
push_system_line(t, "/diff needs a git repository");
free(st.out);
return;
}
char cmd[512];
snprintf(cmd, sizeof(cmd), "git --no-pager diff --no-color %s%s",
arg && arg[0] ? "-- " : "", arg ? arg : "");
os_run_shell_c(cfg->workspace_root, cmd, 512 * 1024, &df);
push_system_line(t, "git status:");
push_lines(t, " ", st.out && st.out[0] ? st.out : "(clean)");
if (df.out && df.out[0]) {
block_t *b = blocks_push(&t->hist);
if (b) {
b->kind = BLK_TOOL;
snprintf(b->tool_name, sizeof(b->tool_name), "diff");
block_set_title(b, "git diff");
block_set_body(b, df.out, df.out_len);
snprintf(b->meta, sizeof(b->meta), "done");
b->expanded = 1;
}
}
free(st.out);
free(df.out);
}
static void cmd_export(tui_t *t, char *arg, int *quit) {
(void)quit;
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
char sid[128], path[OS_PATH_MAX + 256];
cst_agent_session_id(t->agent, sid, sizeof(sid));
if (arg && arg[0] == '/')
snprintf(path, sizeof(path), "%s", arg);
else if (arg && arg[0])
snprintf(path, sizeof(path), "%s/%s", cfg->workspace_root, arg);
else
snprintf(path, sizeof(path), "%s/.agent/exports/%s.md", cfg->workspace_root,
sid);
char dir[sizeof(path)];
snprintf(dir, sizeof(dir), "%s", path);
char *sl = strrchr(dir, '/');
if (sl) {
*sl = 0;
os_mkdir_p(dir, 0755);
}
char *md = cst_session_export_md(cst_agent_session(t->agent), t->title, sid,
cfg->model);
FILE *fp = md ? fopen(path, "wb") : NULL;
if (!fp) {
free(md);
push_system_line(t, "Error: cannot write %s", path);
return;
}
fputs(md, fp);
free(md);
if (fclose(fp) != 0) {
push_system_line(t, "Error: cannot write %s", path);
return;
}
push_system_line(t, "(exported to %s)", path);
}
/* The newest `added` picks, and a warning when the model would not see them. */
static void report_attached(tui_t *t, int added) {
int n = cst_agent_pending_count(t->agent);
for (int i = n - added; i < n; i++) {
char line[256];
if (i >= 0 &&
cst_agent_pending_describe(t->agent, i, line, sizeof(line)) == 0)
push_system_line(t, "Attached %s — it goes with your next message.",
line);
}
if (added > 0 && cst_agent_sees_images(t->agent) == 0)
push_system_line(t, "Note: %s does not take images; they are sent as a "
"text note until you /model to one that does.",
cst_agent_config(t->agent)->model);
}
/* /attach paste: the clipboard image if there is one, else its text goes
* into the composer (CR dropped, control bytes kept out, INPUT_CAP respected). */
static int paste_text_into_input(tui_t *t, const char *text, size_t n) {
char *buf = malloc(n + 1);
if (!buf)
return 0;
size_t k = 0;
for (size_t i = 0; i < n && t->in_len + k + 1 < INPUT_CAP; i++) {
unsigned char c = (unsigned char)text[i];
if (c == '\r' || (c < 0x20 && c != '\n' && c != '\t'))
continue;
buf[k++] = (char)c;
}
if (k)
input_insert(t, buf, k);
free(buf);
return (int)k;
}
/* /attach: stage files for the next user message -- images, PDFs (reduced to
* their text) and text. The payload is copied into the session's attachment
* directory now, so a file picked from /tmp keeps working after it is gone. */
static void cmd_attach(tui_t *t, char *arg, int *quit) {
(void)quit;
int n = cst_agent_pending_count(t->agent);
if (!arg || !arg[0] || strcmp(arg, "list") == 0) {
if (n == 0) {
push_system_line(t, "Nothing attached yet. Usage: /attach <path>, "
"/attach paste, /attach list, /attach clear");
return;
}
push_system_line(t, "%d attachment%s with the next message (sent once):",
n, n > 1 ? "s" : "");
for (int i = 0; i < n; i++) {
char line[256];
if (cst_agent_pending_describe(t->agent, i, line, sizeof(line)) == 0)
push_system_line(t, " %d. %s", i + 1, line);
}
push_system_line(t, "(/attach clear drops them)");
return;
}
if (strcmp(arg, "clear") == 0 || strcmp(arg, "none") == 0) {
cst_agent_pending_clear(t->agent);
push_system_line(t, "Dropped the attachments.");
return;
}
char err[256];
if (strcmp(arg, "paste") == 0) {
char *img = NULL;
size_t len = 0;
if (cst_clipboard_image(&img, &len, err, sizeof(err)) != 0) {
/* no image: put the clipboard text in the composer instead */
char *text = NULL;
size_t tn = 0;
char terr[192];
if (cst_clipboard_text(&text, &tn, terr, sizeof(terr)) != 0) {
push_system_line(t, "Error: %s", terr);
return;
}
int added = paste_text_into_input(t, text, tn);
free(text);
if (added <= 0) {
push_system_line(t, "(no room in the composer for the clipboard "
"text)");
return;
}
push_system_line(t, "(pasted %d bytes of clipboard text into the "
"composer)", added);
layout_maybe_grow_input(t);
return;
}
char mime[32], name[32];
const char *ext = cst_attach_sniff(img, len, mime, sizeof(mime)) == 0 &&
strchr(mime, '/')
? strchr(mime, '/') + 1
: "png";
snprintf(name, sizeof(name), "clipboard.%s", ext);
int rc = cst_agent_attach_bytes(t->agent, name, img, len, err, sizeof(err));
free(img);
if (rc != 0) {
push_system_line(t, "Error: %s", err);
return;
}
report_attached(t, 1);
return;
}
if (cst_agent_attach_file(t->agent, arg, err, sizeof(err)) != 0) {
push_system_line(t, "Error: %s", err);
return;
}
report_attached(t, 1);
}
/* A file worth attaching: an image, a PDF, or something that reads as text.
* Deciding here in the TUI only avoids turning a random dropped binary into
* a pasted path; the store checks the content for real. */
static int file_is_attachable(const char *path) {
FILE *f = fopen(path, "rb");
if (!f)
return 0;
unsigned char head[64];
size_t got = fread(head, 1, sizeof(head), f);
fclose(f);
char mime[32];
if (cst_attach_sniff(head, got, mime, sizeof(mime)) == 0)
return 1;
if (got >= 4 && memcmp(head, "%PDF", 4) == 0)
return 1;
for (size_t i = 0; i < got; i++)
if (head[i] == 0 || head[i] < 0x09)
return 0;
return got > 0;
}
/* A paste that is nothing but file paths (a file dropped on the terminal)
* attaches those files instead of typing the paths. */
static void paste_image_paths(tui_t *t) {
size_t from = t->paste_at, to = t->cur;
if (to <= from || to - from != t->in_len - t->paste_len0 ||
to - from > CST_PASTE_MAX_PATHS * CST_PASTE_PATH_MAX)
return;
static char paths[CST_PASTE_MAX_PATHS][CST_PASTE_PATH_MAX];
int n = cst_paste_paths(t->inbuf + from, to - from, paths,
CST_PASTE_MAX_PATHS);
if (n <= 0)
return;
for (int i = 0; i < n; i++)
if (!file_is_attachable(paths[i]))
return;
input_erase(t, from, to);
int added = 0;
for (int i = 0; i < n; i++) {
char err[256];
if (cst_agent_attach_file(t->agent, paths[i], err, sizeof(err)) == 0)
added++;
else
push_system_line(t, "Error: %s", err);
}
report_attached(t, added);
}
#ifdef _WIN32
#define COPY_VIA "clipboard"
#else
#define COPY_VIA "OSC 52"
#endif
/* Puts text on the terminal's clipboard; returns the bytes sent, 0 on
* failure. */
static size_t osc52_copy(const char *text, size_t n) {
#ifdef _WIN32
if (os_clipboard_set(text, n) == 0)
return n; /* conhost has no OSC 52 */
#endif
static const char b64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
if (n > 75000)
n = 75000; /* many terminals cap OSC 52 near 100 KB */
char *enc = malloc((n + 2) / 3 * 4 + 1);
if (!enc)
return 0;
size_t o = 0;
for (size_t i = 0; i < n; i += 3) {
unsigned v = (unsigned char)text[i] << 16;
if (i + 1 < n)
v |= (unsigned char)text[i + 1] << 8;
if (i + 2 < n)
v |= (unsigned char)text[i + 2];
enc[o++] = b64[(v >> 18) & 63];
enc[o++] = b64[(v >> 12) & 63];
enc[o++] = i + 1 < n ? b64[(v >> 6) & 63] : '=';
enc[o++] = i + 2 < n ? b64[v & 63] : '=';
}
enc[o] = 0;
if (getenv("TMUX"))
printf("\033Ptmux;\033\033]52;c;%s\a\033\\", enc);
else
printf("\033]52;c;%s\a", enc);
fflush(stdout);
free(enc);
return n;
}
static void cmd_copy(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
const char *text = NULL;
for (int i = t->hist.n - 1; i >= 0 && !text; i--)
if (t->hist.items[i].kind == BLK_ASSISTANT && t->hist.items[i].body)
text = t->hist.items[i].body;
if (!text || !text[0]) {
push_system_line(t, "Nothing to copy yet");
return;
}
size_t n = osc52_copy(text, strlen(text));
if (n)
push_system_line(t, "(copied %zu bytes of the last reply via " COPY_VIA
" — the terminal must allow clipboard access)", n);
}
static void cmd_init(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
free(t->pending_prompt);
t->pending_prompt = xstrdup(cst_init_prompt());
}
static void cmd_compact(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
char err[512] = {0};
set_status(t, "compacting…");
redraw_all(t);
if (cst_agent_compact(t->agent, err, sizeof(err)) != 0) {
push_system_line(t, "Error: compact failed: %s", err[0] ? err : "?");
return;
}
after_session_change(t, "(session compacted)");
}
static void set_thinking_all(tui_t *t, int on) {
for (int i = 0; i < t->hist.n; i++)
if (t->hist.items[i].kind == BLK_THINK)
t->hist.items[i].expanded = on;
}
static void cmd_thinking(tui_t *t, char *arg, int *quit) {
(void)quit;
if (arg && strcmp(arg, "on") == 0)
t->show_thinking = 1;
else if (arg && strcmp(arg, "off") == 0)
t->show_thinking = 0;
else
t->show_thinking = !t->show_thinking;
set_thinking_all(t, t->show_thinking);
cst_settings_remember_thinking(t->show_thinking);
push_system_line(t, "(thinking %s by default)",
t->show_thinking ? "expanded" : "collapsed");
}
static int run_keys_panel(tui_t *t, int busy, int *quit);
static void cmd_keys(tui_t *t, char *arg, int *quit) {
(void)arg;
run_keys_panel(t, t->busy, quit);
}
static void cmd_details(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
t->details_all = !t->details_all;
push_system_line(t, "tool details: %s",
t->details_all ? "expanded" : "collapsed");
}
static void cmd_skills(tui_t *t, char *arg, int *quit) {
(void)quit;
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
cst_skill_t *v = calloc(CST_SKILLS_MAX, sizeof(*v));
if (!v)
return;
int n = cst_skills_list(cfg->workspace_root, v, CST_SKILLS_MAX);
if (arg && strcmp(arg, "reload") == 0) {
if (n > 0 && cst_tool_find("skill") == NULL)
cst_skills_register_tool();
push_system_line(t, "(skills reloaded: %d found)", n);
}
if (n == 0) {
push_system_line(t, "No skills. Add <dir>/SKILL.md (frontmatter name + "
"description) under .agent/skills/ in the workspace "
"or skills/ in the config dir, then /skills reload.");
free(v);
return;
}
if (!arg || !arg[0]) {
static char items[CST_SKILLS_MAX][128];
for (int i = 0; i < n; i++)
snprintf(items[i], sizeof(items[i]), "%-20.20s %-9.9s %.90s", v[i].name,
v[i].source, v[i].desc);
int pick = picker_run(t, "Skills (Enter: /skill NAME …)", items, n, 0);
if (pick >= 0) {
char line[128];
snprintf(line, sizeof(line), "/skill %s ", v[pick].name);
input_set(t, line);
}
free(v);
return;
}
push_system_line(t, "Skills (%d):", n);
for (int i = 0; i < n; i++)
push_system_line(t, " %-20s %-10s %.100s", v[i].name, v[i].source,
v[i].desc);
free(v);
}
static void cmd_skill(tui_t *t, char *arg, int *quit) {
(void)quit;
char name[64] = "";
if (arg)
sscanf(arg, "%63s", name);
if (!name[0]) {
cmd_skills(t, NULL, quit);
return;
}
const char *task = arg + strlen(name);
while (*task == ' ')
task++;
char *p = cst_skill_prompt(cst_agent_config(t->agent)->workspace_root, name,
task);
if (!p) {
push_system_line(t, "No skill named '%s' (/skills lists them)", name);
return;
}
free(t->pending_prompt);
t->pending_prompt = p;
}
static const char *mcp_state_name(int st) {
switch (st) {
case CST_MCP_STARTING:
return "starting";
case CST_MCP_READY:
return "ready";
case CST_MCP_FAILED:
return "failed";
case CST_MCP_EXITED:
return "exited";
default:
return "off";
}
}
static void reload_extensions(tui_t *t) {
cst_extensions_unload();
char rep[256];
cst_extensions_load(cst_agent_config(t->agent), rep, sizeof(rep));
push_system_line(t, "(extensions reloaded%s%s)", rep[0] ? ": " : "", rep);
}
static void cmd_plugins(tui_t *t, char *arg, int *quit) {
(void)quit;
const char *ws = cst_agent_config(t->agent)->workspace_root;
if (arg && (strcmp(arg, "trust") == 0 || strcmp(arg, "untrust") == 0)) {
int on = arg[0] == 't';
if (cst_settings_trust(ws, on) != 0) {
push_system_line(t, "(could not save settings)");
return;
}
push_system_line(t, on ? "Workspace trusted: .agent/plugins and "
".agent/mcp.json will load."
: "Workspace no longer trusted.");
reload_extensions(t);
return;
}
if (arg && strcmp(arg, "reload") == 0) {
reload_extensions(t);
return;
}
cst_plugin_info_t info[CST_PLUGINS_MAX];
int n = cst_plugins_info(info, CST_PLUGINS_MAX);
push_system_line(t, "Workspace %s (/plugins %s)",
cst_settings_trusted(ws) ? "trusted" : "not trusted",
cst_settings_trusted(ws) ? "untrust" : "trust");
if (n == 0) {
push_system_line(t, "No plugins. Add <dir>/plugin.json under "
".agent/plugins/ (trusted workspaces) or plugins/ in "
"the config dir, then /plugins reload.");
return;
}
for (int i = 0; i < n; i++) {
push_system_line(t, " %-18s %-9s %d tool%s%s%s%s%s", info[i].name,
info[i].source, info[i].ntools,
info[i].ntools == 1 ? "" : "s",
info[i].nmcp ? " · mcp" : "",
info[i].nskills_root ? " · skills" : "",
info[i].error[0] ? " · error: " : "", info[i].error);
if (info[i].desc[0])
push_system_line(t, " %.110s", info[i].desc);
}
}
static void cmd_mcp(tui_t *t, char *arg, int *quit) {
(void)quit;
if (arg && (strcmp(arg, "reload") == 0 || strcmp(arg, "restart") == 0)) {
reload_extensions(t);
return;
}
cst_mcp_info_t mi[CST_MCP_MAX];
int n = cst_mcp_info(mi, CST_MCP_MAX);
if (n == 0) {
push_system_line(t, "No MCP servers. Add {\"mcpServers\": {\"name\": "
"{\"command\": …, \"args\": […]}}} to mcp.json in "
"the config dir (or .agent/mcp.json in a trusted "
"workspace), then /mcp reload.");
return;
}
push_system_line(t, "MCP servers (%d):", n);
for (int i = 0; i < n; i++) {
push_system_line(t, " %-16s %-9s %-14s %s", mi[i].name,
mcp_state_name(mi[i].state), mi[i].source,
mi[i].command);
if (mi[i].state == CST_MCP_READY)
push_system_line(t, " %d tool%s as mcp__%s__*", mi[i].ntools,
mi[i].ntools == 1 ? "" : "s", mi[i].name);
else if (mi[i].error[0])
push_system_line(t, " %s", mi[i].error);
}
}
static void show_subagent(tui_t *t, const char *pid,
const cst_subagent_rec_t *rec) {
const char *ws = cst_agent_config(t->agent)->workspace_root;
char path[OS_PATH_MAX];
if (cst_subagent_path(ws, pid, rec->file, path, sizeof(path)) != 0)
return;
cst_session_t *s = cst_session_open(path);
if (!s) {
push_system_line(t, "(cannot open %s)", path);
return;
}
push_system_line(t, "── %s · %s: %s (%s · %d steps · %d tool calls · %.1f s)",
rec->file, rec->role, rec->desc, rec->status, rec->steps,
rec->tools, rec->ms / 1000.0);
push_system_line(t, " model: %s/%s · requests: %d/%d · input/output: %ld/%ld",
rec->provider, rec->model, rec->requests, rec->max_steps,
rec->prompt_tokens, rec->output_tokens);
if (rec->cache_known)
push_system_line(t, " cache read/write: %ld/%ld", rec->cached_tokens, rec->cache_write_tokens);
else push_system_line(t, " cache: unknown (not reported by provider)");
if (rec->priced) push_system_line(t, " cost: %s%.6f", rec->currency, rec->cost);
int n = cst_session_count(s);
const char *final = NULL;
for (int i = 0; i < n; i++) {
const cst_message_t *m = cst_session_get(s, i);
if (!m->content || cst_message_is_note(m))
continue;
if (m->role == CST_ROLE_USER && !m->tool_call_id[0]) {
push_system_line(t, " prompt: %.300s", m->content);
} else if (m->tool_name[0]) {
int lines = 1;
for (const char *c = m->content; *c; c++)
lines += *c == '\n';
push_system_line(t, " · %s (%d line%s)", m->tool_name, lines,
lines == 1 ? "" : "s");
} else if (m->role == CST_ROLE_ASSISTANT && m->content[0] &&
!m->tool_calls_json) {
final = m->content;
}
}
if (final) {
push_system_line(t, " report:");
int shown = 0;
for (const char *p = final; *p && shown < 60; shown++) {
const char *nl = strchr(p, '\n');
int len = nl ? (int)(nl - p) : (int)strlen(p);
push_system_line(t, " %.*s", len > 400 ? 400 : len, p);
p = nl ? nl + 1 : p + len;
}
}
push_system_line(t, " transcript: %s", path);
cst_session_close(s);
}
static void cmd_task(tui_t *t, char *arg, int *quit) {
(void)quit;
if (!arg || !*arg || !strcmp(arg, "state")) {
char *s = cst_runtime_json(cst_agent_config(t->agent)->runtime);
push_system_line(t, "%s", s ? s : "{}"); free(s); return;
}
if (!strncmp(arg, "cancel ", 7) || !strncmp(arg, "force ", 6)) {
int force = !strncmp(arg, "force ", 6);
int rc = cst_job_cancel(cst_agent_config(t->agent)->runtime, arg + (force ? 6 : 7), force, "user");
push_system_line(t, "%s", rc ? "active job not found" : "job cancellation requested"); return;
}
int ro = !strcmp(arg, "readonly") ? 1 : !strcmp(arg, "write") ? 0 : -1;
int delegate = !strcmp(arg, "direct") ? 0 : !strcmp(arg, "auto") ? 1 : -1;
int paused = !strcmp(arg, "pause") ? 1 : !strcmp(arg, "resume") ? 0 : -1;
if (ro < 0 && delegate < 0 && paused < 0) { push_system_line(t, "Use /task state|readonly|write|direct|auto|pause|resume|cancel ID|force ID"); return; }
cst_agent_control(t->agent, ro, delegate, paused);
push_system_line(t, "Task controls updated; stale actions will not execute.");
}
static void cmd_agents(tui_t *t, char *arg, int *quit) {
(void)quit;
const char *ws = cst_agent_config(t->agent)->workspace_root;
if (arg && strcmp(arg, "budget") == 0) {
char *s = cst_workers_budget_json(cst_agent_config(t->agent));
push_system_line(t, "%s", s ? s : "unavailable"); free(s);
return;
}
if (arg && strncmp(arg, "config ", 7) == 0) {
char err[256];
int rc = cst_workers_config_set(ws, arg + 7, err, sizeof(err));
if (rc == 0) cst_workers_config_apply(cst_agent_config_mut(t->agent));
push_system_line(t, "%s", rc == 0 ? "Worker configuration saved" : err);
return;
}
if (arg && strcmp(arg, "config") == 0) {
char *s = cst_workers_config_get(ws);
push_system_line(t, "%s", s ? s : "cannot read worker config");
free(s);
return;
}
if (arg && strncmp(arg, "cancel ", 7) == 0) {
char sid[128]; cst_agent_session_id(t->agent, sid, sizeof(sid));
push_system_line(t, "%s", cst_worker_cancel(ws, sid, arg + 7) == 0 ?
"Worker cancellation requested" : "Running worker not found");
return;
}
cst_agent_role_t *roles = calloc(CST_ROLES_MAX, sizeof(*roles));
if (!roles)
return;
int nr = cst_roles_list(ws, roles, CST_ROLES_MAX);
char pid[256];
cst_agent_session_id(t->agent, pid, sizeof(pid));
static cst_subagent_rec_t recs[64];
int nrec = cst_subagent_list(ws, pid, recs, 64);
for (int i = 0; i < nrec; i++)
if (!recs[i].cache_known)
recs[i].cached_tokens = -1; /* unknown, not zero cache hits */
/* one picker: roles insert a prompt prefix, runs open their transcript */
static char items[CST_ROLES_MAX + 64][128];
int n = 0;
for (int i = 0; i < nr; i++)
snprintf(items[n++], sizeof(items[0]), "role %-14.14s %-9.9s %.90s",
roles[i].name, roles[i].profile[0] ? roles[i].profile : "inherit", roles[i].desc);
for (int i = 0; i < nrec; i++)
snprintf(items[n++], sizeof(items[0]), "run %.12s %.20s %.12s %d/%d req cache %ld/%ld",
recs[i].file, recs[i].model, recs[i].status, recs[i].requests,
recs[i].max_steps, recs[i].cached_tokens, recs[i].prompt_tokens);
if (cst_tool_find("task") == NULL)
push_system_line(t, "(the task tool is off: --tools minimal)");
int pick = picker_run(t, "Agents (Enter: role → ask for it · run → view "
"transcript)",
items, n, 0);
if (pick >= 0 && pick < nr) {
char line[160];
snprintf(line, sizeof(line), "Use the %s agent (task tool) to ",
roles[pick].name);
input_set(t, line);
} else if (pick >= nr) {
show_subagent(t, pid, &recs[pick - nr]);
}
cst_roles_free(roles, nr);
free(roles);
}
static void cmd_quit(tui_t *t, char *arg, int *quit) {
(void)t;
(void)arg;
*quit = 1;
}
/* /web: the WebUI is a second front end for the same engine and is designed to
* run as its own process, so this starts one on the same workspace and leaves
* the TUI holding the terminal. The child gets a fresh session: two writers on
* one session file is the one thing that would corrupt it. */
#ifndef _WIN32
#include <fcntl.h>
#include <sys/wait.h> /* waitpid: collect the WebUI started by the last /web */
#endif
#ifndef CST_WEBUI_DEFAULT_PORT
#define CST_WEBUI_DEFAULT_PORT 8787 /* keep in sync with include/cst_webui.h */
#endif
static void cmd_web(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
#ifdef _WIN32
push_system_line(t, "open another terminal and run: hermit --web");
#else
static pid_t spawned = 0;
if (spawned > 0) { /* collect the one we started last time */
waitpid(spawned, NULL, WNOHANG);
spawned = 0;
}
char dir[4096], exe[4096 + 16];
if (os_exe_dir(dir, sizeof(dir)) != 0) {
push_system_line(t, "cannot locate this binary: run hermit --web yourself");
return;
}
snprintf(exe, sizeof(exe), "%s/cst-agent", dir);
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
const char *ws = (cfg && cfg->workspace_root[0]) ? cfg->workspace_root : ".";
char logf[4096 + 32];
snprintf(logf, sizeof(logf), "%s/.agent", ws);
os_mkdir_p(logf, 0755);
snprintf(logf, sizeof(logf), "%s/.agent/webui.log", ws);
/* The WebUI talks to what this session talks to (-b, -m, a /provider
* switch...). The key goes through the environment, not argv (ps). */
const char *argv[20];
int na = 0;
argv[na++] = "cst-agent";
argv[na++] = "--web";
argv[na++] = "--workspace";
argv[na++] = ws;
argv[na++] = "--new";
if (cfg && cfg->provider[0])
argv[na++] = "--provider", argv[na++] = cfg->provider;
if (cfg && cfg->model[0])
argv[na++] = "--model", argv[na++] = cfg->model;
if (cfg && cfg->api_base[0])
argv[na++] = "--api-base", argv[na++] = cfg->api_base;
if (cfg && cfg->effort[0])
argv[na++] = "--effort", argv[na++] = cfg->effort;
if (cfg)
argv[na++] = "--approval", argv[na++] = cst_approval_name(cfg->approval);
argv[na] = NULL;
size_t nenv = 0;
while (environ[nenv])
nenv++;
char **envp = calloc(nenv + 2, sizeof(char *));
char keyvar[600] = "";
if (!envp) {
push_system_line(t, "out of memory");
return;
}
size_t ne = 0;
for (size_t i = 0; i < nenv; i++)
if (strncmp(environ[i], "CST_API_KEY=", 12) != 0)
envp[ne++] = environ[i];
if (cfg && cfg->api_key[0]) {
snprintf(keyvar, sizeof(keyvar), "CST_API_KEY=%s", cfg->api_key);
envp[ne++] = keyvar;
}
envp[ne] = NULL;
pid_t pid = fork();
if (pid < 0) {
free(envp);
push_system_line(t, "cannot start a second process");
return;
}
if (pid == 0) {
setsid();
/* never inherit the TUI's terminal: its output would land on our screen */
int in = open("/dev/null", O_RDONLY);
if (in >= 0 && in != STDIN_FILENO) {
dup2(in, STDIN_FILENO);
close(in);
}
int fd = open(logf, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0)
fd = open("/dev/null", O_WRONLY);
if (fd >= 0) {
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
if (fd > 2)
close(fd);
}
execve(exe, (char *const *)argv, envp);
_exit(127);
}
free(envp);
spawned = pid;
/* Wait (2 s at most) for the URL it prints once it listens: the port may
* come from CST_WEB_PORT, or it may not start at all. */
char url[512] = "";
int died = 0;
for (int i = 0; i < 40 && !url[0] && !died; i++) {
usleep(50000);
FILE *fp = fopen(logf, "r");
char line[512];
while (fp && fgets(line, sizeof(line), fp)) {
if (strncmp(line, "webui: http", 11) == 0) {
line[strcspn(line, "\r\n")] = 0;
snprintf(url, sizeof(url), "%s", line + 7);
break;
}
}
if (fp)
fclose(fp);
if (!url[0] && waitpid(pid, NULL, WNOHANG) == pid) {
died = 1;
spawned = 0;
}
}
/* its --new made the WebUI's session CURRENT; a plain `cst-agent` later
* should still resume this one */
char sid[128] = "";
cst_agent_session_id(t->agent, sid, sizeof(sid));
if (sid[0])
cst_agent_store_set_current(ws, sid);
char msg[4200];
if (died)
snprintf(msg, sizeof(msg), "the WebUI did not start (port busy?): see %s",
logf);
else if (url[0])
snprintf(msg, sizeof(msg), "WebUI started in the background: %s (new session)",
url);
else
snprintf(msg, sizeof(msg), "WebUI starting in the background "
"(http://127.0.0.1:%d/ unless CST_WEB_PORT says "
"otherwise)", CST_WEBUI_DEFAULT_PORT);
push_system_line(t, msg);
snprintf(msg, sizeof(msg), "its log: %s", logf);
push_system_line(t, msg);
#endif
}
static const slash_cmd_t SLASH[] = {
{"help", "?", "", "list commands and keys", 1, cmd_help},
{"new", "clear", "", "start a new session", 0, cmd_new},
{"session", "sessions resume", "[id]", "pick or switch session", 0,
cmd_session},
{"rename", "", "<title>", "name the current session", 1, cmd_rename},
{"delete", "", "[id]", "delete a session (asks first)", 0, cmd_delete},
{"model", "", "[name]", "pick or set the model", 0, cmd_model},
{"provider", "", "[name]", "switch provider (asks for its key)", 0,
cmd_provider},
{"key", "apikey", "", "enter or replace the provider's API key", 0,
cmd_key},
{"effort", "think", "[low|medium|high|max]", "reasoning effort", 0,
cmd_effort},
{"protocol", "", "[name]", "pick or set the tool-call protocol", 0,
cmd_protocol},
{"tools", "", "[on|off NAME|minimal]", "list or toggle tools", 0,
cmd_tools},
{"mode", "", "[coding|general]", "coding or general (non-code) framing", 1,
cmd_mode},
{"permissions", "perms", "[reset]", "show saved always-allow rules", 1,
cmd_permissions},
{"sandbox", "", "[reset-labels]", "shell sandbox and labeled folders", 1,
cmd_sandbox},
{"approval", "", "[ask|auto|yolo]", "how tools get approved (Shift+Tab)",
1, cmd_approval},
{"yolo", "", "", "toggle approving everything", 1, cmd_yolo},
{"sidebar", "", "", "show or hide the Seat Deck sidebar", 1, cmd_sidebar},
{"motion", "", "[on|off]", "Seat Deck ASCII micro-motion", 1, cmd_motion},
{"mouse", "", "", "toggle mouse support (drag copies)", 1,
cmd_mouse},
{"max-steps", "", "[N]", "tool steps allowed per turn", 1, cmd_max_steps},
{"status", "", "", "session, model, context and settings", 1, cmd_status},
{"realm", "parliament login", "[status|login|join|stop]",
"Realm / parliament status, claim chair, join as seat, or stop service", 1,
cmd_realm},
{"cost", "usage", "", "tokens, cache hit rate and estimated cost", 1,
cmd_cost},
{"todo", "tasks", "", "show the agent's task list", 1, cmd_todo},
{"undo", "", "", "revert file changes of the last turn", 0, cmd_undo},
{"retry", "", "", "drop the last reply and ask again", 0, cmd_retry},
{"diff", "", "[path]", "git status + diff of the workspace", 1, cmd_diff},
{"compact", "summarize", "", "summarize the context to save tokens", 0,
cmd_compact},
{"export", "", "[file]", "write the transcript as Markdown", 1,
cmd_export},
{"copy", "", "", "copy the last reply (OSC 52)", 1, cmd_copy},
{"attach", "image|file", "[path|paste|clear]",
"send files (image, PDF, text) or clipboard text with the next message",
1, cmd_attach},
{"init", "", "", "have the agent write AGENTS.md", 0, cmd_init},
{"details", "", "", "expand/collapse all tool output", 1, cmd_details},
{"thinking", "", "[on|off]", "show Thought blocks expanded or collapsed",
1, cmd_thinking},
{"keys", "shortcuts", "", "keyboard shortcuts (keybinds.json)", 1,
cmd_keys},
{"skills", "", "[reload|list]", "list skills (SKILL.md)", 0, cmd_skills},
{"plugins", "", "[trust|untrust|reload]", "plugins and workspace trust", 0,
cmd_plugins},
{"mcp", "", "[reload]", "MCP servers and their state", 0, cmd_mcp},
{"agents", "", "", "sub-agent roles and this session's runs", 0,
cmd_agents},
{"task", "jobs", "[state|readonly|write|direct|auto|pause|resume]", "task controls and jobs", 1, cmd_task},
{"skill", "", "<name> [task]", "run a task with a skill loaded", 0,
cmd_skill},
{"web", "", "", "start the WebUI in the background (127.0.0.1:8787)", 1,
cmd_web},
{"quit", "q exit", "", "quit", 1, cmd_quit},
};
static const slash_cmd_t *slash_table(int *n) {
*n = (int)(sizeof(SLASH) / sizeof(SLASH[0]));
return SLASH;
}
static int has_alias(const char *list, const char *name) {
size_t n = strlen(name);
for (const char *p = list; *p;) {
size_t w = strcspn(p, " ");
if (w == n && strncmp(p, name, n) == 0)
return 1;
p += w;
while (*p == ' ')
p++;
}
return 0;
}
/* Exact name, alias, or unique prefix. */
static const slash_cmd_t *slash_find(const char *name, int *ambiguous) {
int n;
const slash_cmd_t *tab = slash_table(&n), *hit = NULL;
*ambiguous = 0;
for (int i = 0; i < n; i++)
if (strcmp(tab[i].name, name) == 0 || has_alias(tab[i].aliases, name))
return &tab[i];
size_t len = strlen(name);
for (int i = 0; len && i < n; i++) {
if (strncmp(tab[i].name, name, len) == 0) {
if (hit)
*ambiguous = 1;
hit = &tab[i];
}
}
return *ambiguous ? NULL : hit;
}
static int handle_slash(tui_t *t, const char *line, int *should_quit) {
*should_quit = 0;
if (!line || line[0] != '/')
return 0;
char buf[INPUT_CAP];
snprintf(buf, sizeof(buf), "%s", line + 1);
char *cmd = buf;
while (*cmd == ' ')
cmd++;
char *arg = cmd + strcspn(cmd, " \n");
if (*arg) {
*arg++ = 0;
while (*arg == ' ')
arg++;
}
size_t al = strlen(arg);
while (al && (arg[al - 1] == ' ' || arg[al - 1] == '\n'))
arg[--al] = 0;
int amb = 0;
const slash_cmd_t *c = slash_find(cmd, &amb);
if (!c) {
push_system_line(t, amb ? "Ambiguous command /%s (try /help)"
: "Unknown command /%s (try /help)",
cmd);
return 1;
}
if (t->busy && !c->busy_ok) {
push_system_line(t, "/%s is not available while the agent is working "
"(Esc cancels the turn)",
c->name);
return 1;
}
char before[sizeof(t->status_msg)];
memcpy(before, t->status_msg, sizeof(before));
c->fn(t, arg, should_quit);
if (strcmp(before, t->status_msg) == 0)
status_idle(t);
return 1;
}
/* --- completion ----------------------------------------------------------- */
static void load_files(tui_t *t) {
if (t->files && since_ms(&t->files_at) < 10000)
return;
for (int i = 0; i < t->nfiles; i++)
free(t->files[i]);
free(t->files);
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
t->nfiles = cst_workspace_files(cfg->workspace_root, &t->files, 20000);
clock_gettime(CLOCK_MONOTONIC, &t->files_at);
}
static void update_completion(tui_t *t) {
t->comp_n = 0;
if (t->comp_off || t->in_paste)
return;
if (t->inbuf[0] == '/' && memchr(t->inbuf, ' ', t->cur) == NULL &&
memchr(t->inbuf, '\n', t->in_len) == NULL) {
int n;
const slash_cmd_t *tab = slash_table(&n);
size_t len = t->cur - 1;
for (int i = 0; i < n && t->comp_n < COMP_MAX; i++) {
if (strncmp(tab[i].name, t->inbuf + 1, len) != 0)
continue;
comp_item_t *c = &t->comp[t->comp_n++];
snprintf(c->text, sizeof(c->text), "/%s %s", tab[i].name, tab[i].args);
snprintf(c->desc, sizeof(c->desc), "%s", tab[i].desc);
}
t->comp_kind = 1;
t->comp_start = 0;
} else {
size_t st = t->cur;
while (st > 0 && t->inbuf[st - 1] != ' ' && t->inbuf[st - 1] != '\n')
st--;
if (st >= t->in_len || t->inbuf[st] != '@')
return;
char frag[256];
snprintf(frag, sizeof(frag), "%.*s", (int)(t->cur - st - 1),
t->inbuf + st + 1);
load_files(t);
/* basename prefix matches first, then any substring */
for (int pass = 0; pass < 2 && t->comp_n < COMP_MAX; pass++) {
for (int i = 0; i < t->nfiles && t->comp_n < COMP_MAX; i++) {
const char *f = t->files[i];
const char *base = strrchr(f, '/');
base = base ? base + 1 : f;
int hit = pass == 0 ? strncasecmp(base, frag, strlen(frag)) == 0
: (strncasecmp(base, frag, strlen(frag)) != 0 &&
strcasestr(f, frag) != NULL);
if (!hit)
continue;
comp_item_t *c = &t->comp[t->comp_n++];
snprintf(c->text, sizeof(c->text), "@%s", f);
c->desc[0] = 0;
}
}
t->comp_kind = 2;
t->comp_start = st;
}
if (t->comp_sel >= t->comp_n)
t->comp_sel = t->comp_n ? t->comp_n - 1 : 0;
}
/* Returns the chosen slash command when the popup was a slash list. */
static const slash_cmd_t *accept_completion(tui_t *t) {
if (t->comp_n <= 0)
return NULL;
const comp_item_t *c = &t->comp[t->comp_sel];
const slash_cmd_t *cmd = NULL;
char repl[300];
if (t->comp_kind == 1) {
char name[64];
snprintf(name, sizeof(name), "%.*s", (int)strcspn(c->text + 1, " "),
c->text + 1);
int amb;
cmd = slash_find(name, &amb);
snprintf(repl, sizeof(repl), "/%s ", name);
} else {
snprintf(repl, sizeof(repl), "%s ", c->text);
}
size_t from = t->comp_start, to = t->cur;
input_erase(t, from, to);
t->cur = from;
input_insert(t, repl, strlen(repl));
t->comp_n = 0;
t->comp_off = 1;
return cmd;
}
static void toggle_think(tui_t *t) {
int idx = t->focus_think_idx;
if (idx < 0) {
for (int i = t->hist.n - 1; i >= 0; i--) {
if (t->hist.items[i].kind == BLK_THINK) {
idx = i;
break;
}
}
}
if (idx < 0 || idx >= t->hist.n)
return;
block_t *b = &t->hist.items[idx];
if (b->kind != BLK_THINK)
return;
b->expanded = !b->expanded;
/* Keep timed title (Thought · Ns) if already set. */
if (b->expanded && (!b->title || !b->title[0]))
block_set_title(b, "Thought");
t->focus_think_idx = idx;
draw_log(t);
}
static void toggle_last_tool(tui_t *t) {
int idx = t->last_tool_idx;
if (idx < 0 || idx >= t->hist.n) {
for (int i = t->hist.n - 1; i >= 0; i--) {
if (t->hist.items[i].kind == BLK_TOOL) {
idx = i;
break;
}
}
}
if (idx < 0 || idx >= t->hist.n)
return;
block_t *b = &t->hist.items[idx];
if (b->kind != BLK_TOOL)
return;
b->expanded = !b->expanded;
t->last_tool_idx = idx;
draw_log(t);
}
/* --- turn worker ---------------------------------------------------------- */
typedef struct {
tui_t *t;
char *prompt;
} turn_arg_t;
static void *turn_thread(void *arg) {
turn_arg_t *a = arg;
tui_t *t = a->t;
cst_agent_hooks_t hooks = {0};
hooks.on_status = on_status;
hooks.on_assistant_delta = on_assistant_delta;
hooks.on_think_delta = on_think_delta;
hooks.on_tool_event = on_tool_event;
hooks.on_tool_result = on_tool_result;
hooks.on_permission = on_permission;
hooks.on_ask_user = on_ask_user;
hooks.on_todo = on_todo;
hooks.userdata = t;
char err[512];
err[0] = 0;
int rc = cst_agent_run_turn_ex(t->agent, a->prompt, &hooks, err, sizeof(err));
tui_lock(t);
t->turn_rc = rc;
snprintf(t->turn_err, sizeof(t->turn_err), "%s", err);
collapse_open_think(t);
if (t->as_live)
live_seal(t);
for (int i = 0; i < t->hist.n; i++) {
if (t->hist.items[i].kind == BLK_TOOL)
attach_tool_output_from_session(t, &t->hist.items[i]);
}
if (rc == 0) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
block_t *fb = blocks_push(&t->hist);
if (fb && cfg) {
char line[256];
int n = snprintf(line, sizeof(line), "\033[97m%s\033[39m %s %s %s %.1fs",
cfg->plan_mode ? "Plan" : "Build", g_ascii ? "-" : "·",
cfg->model, g_ascii ? "-" : "·",
since_ms(&t->turn_t0) / 1000.0);
fb->kind = BLK_FOOTER;
block_set_body(fb, line, n > 0 ? strlen(line) : 0);
}
}
t->turn_done = 1;
t->busy = 0;
tui_mark_dirty(t);
tui_unlock(t);
free(a->prompt);
free(a);
return NULL;
}
static void request_cancel_ui(tui_t *t) {
cst_agent_request_cancel(t->agent);
set_status(t, "cancelling…");
draw_status(t);
}
static int begin_turn(tui_t *t, char *prompt) {
prompt_hist_push(t, prompt);
if (!cst_agent_config(t->agent)->model[0]) {
set_status(t, "no model selected yet — /model to pick one (↑ recalls "
"your message)");
free(prompt);
return -1;
}
block_t *ub = blocks_push(&t->hist);
if (ub) {
ub->kind = BLK_USER;
block_set_title(ub, "You");
cst_buf_t body = {0};
cst_buf_puts(&body, prompt);
for (int k = 0; k < cst_agent_pending_count(t->agent); k++) {
char d[128];
if (cst_agent_pending_describe(t->agent, k, d, sizeof(d)) == 0)
cst_buf_printf(&body, "\n[image %s]", d);
}
block_set_body(ub, body.buf ? body.buf : "", body.len);
cst_buf_free(&body);
ub->expanded = 1;
}
t->think_idx = -1;
t->log_scroll = 0;
set_status(t, "streaming…");
turn_arg_t *ta = calloc(1, sizeof(*ta));
if (!ta) {
free(prompt);
return -1;
}
ta->t = t;
ta->prompt = prompt;
t->busy = 1;
t->turn_done = 0;
clock_gettime(CLOCK_MONOTONIC, &t->turn_t0);
redraw_all(t);
pthread_t th;
if (pthread_create(&th, NULL, turn_thread, ta) != 0) {
t->busy = 0;
free(prompt);
free(ta);
push_system_line(t, "Error: failed to start turn thread");
status_idle(t);
redraw_all(t);
return -1;
}
pthread_detach(th);
return 0;
}
/* Owns prompt. Returns 0 ok, -1 fail, -2 quit. */
static int start_turn(tui_t *t, char *prompt) {
if (!prompt)
return -1;
if (prompt[0] == '/') {
int should_quit = 0;
handle_slash(t, prompt, &should_quit);
free(prompt);
t->log_scroll = 0;
if (t->need_layout) {
t->need_layout = 0;
layout(t);
wtimeout(t->input, 80);
}
redraw_all(t);
if (should_quit)
return -2;
if (t->pending_prompt && !t->busy) {
char *next = t->pending_prompt;
t->pending_prompt = NULL;
return begin_turn(t, next);
}
return 0;
}
return begin_turn(t, prompt);
}
static int try_start_queued(tui_t *t) {
if (t->busy || t->queue_n <= 0)
return 0;
char *next = queue_pop(t);
if (!next)
return 0;
return start_turn(t, next);
}
static int submit_input(tui_t *t, int busy) {
if (t->in_len == 0) {
if (!busy && t->queue_n > 0)
return try_start_queued(t);
if (!busy)
toggle_last_tool(t);
return 0;
}
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
if (!busy && t->inbuf[0] != '/' && !cfg->api_key[0]) {
char key[512];
if (obtain_key(t, cfg->provider, key, sizeof(key), 0) != 0) {
push_system_line(t, "No API key for %s yet — /key to enter one, or "
"/provider to switch",
cfg->provider);
return 0;
}
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
memset(key, 0, sizeof(key));
}
char *prompt = strdup(t->inbuf);
input_clear(t);
layout_maybe_grow_input(t);
if (!prompt)
return 0;
if (busy && prompt[0] == '/') {
int q = 0;
handle_slash(t, prompt, &q);
free(prompt);
redraw_all(t);
return q ? -2 : 0;
}
if (busy) {
cst_agent_steer(t->agent, prompt, -1, -1);
push_system_line(t, "New instruction sent; conflicting queued actions stopped.");
set_status(t, "applying new instruction");
free(prompt);
draw_status(t);
return 0;
}
return start_turn(t, prompt);
}
/* Byte offset nearest to visual (row, col) of the input, matching
* input_layout's wrapping. */
static size_t input_hit(const tui_t *t, int want_row, int want_col) {
int row = 0, col = IN_COL;
int right = (t->in_w > 0 ? t->in_w : t->cols) - 2;
size_t i = 0, best = t->in_len, last = 0;
while (i < t->in_len) {
if (row == want_row && col >= want_col)
return i;
if (row > want_row)
return best;
if (t->inbuf[i] == '\n') {
if (row == want_row)
return i;
row++;
col = IN_COL;
i++;
continue;
}
wchar_t wc;
size_t eaten;
utf8_decode(t->inbuf + i, t->in_len - i, &wc, &eaten);
int cw = utf8_width(wc == L'\t' ? L' ' : wc);
if (col + cw > right && col > IN_COL) {
/* offset i would be drawn at the start of the next row */
if (row == want_row)
return last;
row++;
col = IN_COL;
}
best = i + eaten;
last = i;
col += cw;
i += eaten;
}
return t->in_len;
}
static void toggle_plan(tui_t *t);
static int run_palette(tui_t *t, int busy, int *quit);
static void after_edit(tui_t *t);
/* Returns -2 to quit (palette → /quit), else 0. */
static int handle_click(tui_t *t, int y, int x, int busy) {
/* completion rows */
if (g_comp_y0 >= 0 && t->comp_n > 0 && y >= g_comp_y0 &&
y < g_comp_y0 + t->comp_n && x >= g_comp_x0 && x < g_comp_x0 + g_comp_w) {
t->comp_sel = y - g_comp_y0;
accept_completion(t);
after_edit(t);
redraw_all(t);
return 0;
}
/* input box */
if (y >= t->in_y && y < t->in_y + t->in_h && x >= t->in_x &&
x < t->in_x + t->in_w) {
int h = t->input_rows > 0 ? t->input_rows : 1;
int pad = (t->in_h - h - 1) / 2;
int ry = y - t->in_y;
if (ry == pad + h) {
if (x - t->in_x < IN_COL + 6)
toggle_plan(t);
return 0;
}
int cr, cc;
input_layout(t, NULL, NULL, &cr, &cc);
int top = cr >= h ? cr - h + 1 : 0;
int row = ry - pad + top;
if (row < 0)
row = 0;
t->cur = input_hit(t, row, x - t->in_x);
draw_input(t);
return 0;
}
/* status row: "ctrl+p commands" */
if (y == t->rows - 1 && t->cmd_x0 >= 0 && x >= t->cmd_x0 && x <= t->cmd_x1) {
int quit = 0;
return run_palette(t, busy, &quit) == -2 || quit ? -2 : 0;
}
/* transcript blocks */
if (!t->home && t->logw) {
int lh, lw;
getmaxyx(t->logw, lh, lw);
int ry = y - t->log_y, rx = x - t->log_x;
if (ry < 0 || ry >= lh || rx < 0 || rx >= lw || ry >= t->row_blk_cap)
return 0;
int bi = t->row_blk[ry];
if (bi < 0 || bi >= t->hist.n)
return 0;
block_t *b = &t->hist.items[bi];
if (b->kind == BLK_THINK) {
t->focus_think_idx = bi;
b->expanded = !b->expanded;
} else if (b->kind == BLK_TOOL) {
t->last_tool_idx = bi;
b->expanded = !b->expanded;
} else {
return 0;
}
draw_log(t);
}
return 0;
}
/* Transcript cell under screen (y, x) as display line + column. Outside the
* transcript returns 0, unless `clamp` pulls the point to its nearest edge. */
static int log_cell(const tui_t *t, int y, int x, int clamp, int *li,
int *col) {
if (t->home || !t->logw)
return 0;
int lh, lw;
getmaxyx(t->logw, lh, lw);
int ry = y - t->log_y, rx = x - t->log_x;
if (!clamp && (ry < 0 || ry >= lh || rx < 0 || rx >= lw))
return 0;
*li = t->log_start + clampi(ry, 0, lh - 1);
*col = clampi(rx, 0, lw - 1);
return 1;
}
static void sel_clear(tui_t *t) {
int shown = t->sel_state == SEL_DRAG || t->sel_state == SEL_SHOWN;
t->sel_state = SEL_NONE;
if (shown)
draw_log(t);
}
static void sel_copy(tui_t *t) {
int h, w;
getmaxyx(t->logw, h, w);
(void)h;
dlines_t dl = {0};
build_display(t, &dl, w);
int y1, x1, y2, x2;
sel_range(t, &dl, &y1, &x1, &y2, &x2);
cst_selbuf_t b = {0};
for (int k = y1 < 0 ? 0 : y1; k <= y2 && k < dl.n; k++) {
const dline_t *L = &dl.lines[k];
int c1 = k == y1 ? x1 : 0, c2 = k == y2 ? x2 : -1;
if (k > y1) {
if (!L->cont)
cst_selbuf_putc(&b, '\n');
else if (c1 < L->indent + L->cont_pad)
c1 = L->indent + L->cont_pad;
}
int joined = k < y2 && k + 1 < dl.n && dl.lines[k + 1].cont;
cst_textsel_append(&b, L->text, L->indent, c1, c2, !joined);
}
dlines_free(&dl);
if (b.n && osc52_copy(b.s, b.n)) {
size_t chars = 0;
for (size_t i = 0; i < b.n; i++)
chars += ((unsigned char)b.s[i] & 0xC0) != 0x80;
char msg[96];
snprintf(msg, sizeof(msg), "Copied %zu characters (" COPY_VIA ")", chars);
set_status(t, msg);
draw_status(t);
}
cst_selbuf_free(&b);
}
/* Drag while the button is held: extend the selection, scrolling the
* transcript when the pointer is above or below it. */
static void sel_motion(tui_t *t, int y, int x) {
int lh, lw;
getmaxyx(t->logw, lh, lw);
(void)lw;
int ry = y - t->log_y;
if (ry < 0 || ry >= lh) {
int before = t->log_scroll;
t->log_scroll += ry < 0 ? 1 : -1;
if (t->log_scroll < 0)
t->log_scroll = 0;
if (t->log_scroll != before)
draw_log(t); /* updates log_start */
}
int li, col;
if (!log_cell(t, y, x, 1, &li, &col))
return;
if (t->sel_state == SEL_PRESS && li == t->sel_ay && col == t->sel_ax)
return;
t->sel_state = SEL_DRAG;
t->sel_y = li;
t->sel_x = col;
draw_log(t);
}
/* Wheel, clicks and drag selection; -2 means quit. */
static int on_mouse(tui_t *t, const MEVENT *ev, int busy) {
mmask_t b = ev->bstate;
if (b & BUTTON4_PRESSED) {
t->log_scroll += SCROLL_SPEED;
draw_log(t);
return 0;
}
if (b & BUTTON5_PRESSED) {
t->log_scroll -= SCROLL_SPEED;
if (t->log_scroll < 0)
t->log_scroll = 0;
draw_log(t);
return 0;
}
if (b & REPORT_MOUSE_POSITION) {
if (t->sel_state == SEL_PRESS || t->sel_state == SEL_DRAG)
sel_motion(t, ev->y, ev->x);
return 0;
}
if (b & (BUTTON1_PRESSED | BUTTON1_CLICKED)) {
sel_clear(t);
int li, col;
if (!(b & BUTTON1_PRESSED) || !log_cell(t, ev->y, ev->x, 0, &li, &col))
return handle_click(t, ev->y, ev->x, busy);
if (t->sel_clicks && since_ms(&t->sel_click_at) < MULTI_CLICK_MS &&
li == t->sel_click_y && abs(col - t->sel_click_x) <= 1)
t->sel_clicks = t->sel_clicks % 3 + 1;
else
t->sel_clicks = 1;
clock_gettime(CLOCK_MONOTONIC, &t->sel_click_at);
t->sel_click_y = li;
t->sel_click_x = col;
t->sel_mode = t->sel_clicks;
t->sel_ay = t->sel_y = li;
t->sel_ax = t->sel_x = col;
t->sel_press_y = ev->y;
t->sel_press_x = ev->x;
t->sel_state = t->sel_clicks > 1 ? SEL_DRAG : SEL_PRESS;
if (t->sel_state == SEL_DRAG)
draw_log(t);
return 0;
}
if (b & BUTTON1_RELEASED) {
if (t->sel_state == SEL_PRESS) {
t->sel_state = SEL_NONE;
return handle_click(t, t->sel_press_y, t->sel_press_x, busy);
}
if (t->sel_state == SEL_DRAG) {
sel_copy(t);
t->sel_state = SEL_SHOWN;
}
}
return 0;
}
/* Tab completes after "/" at the start or an "@word" under the cursor. */
static int wants_completion(const tui_t *t) {
if (t->in_len && t->inbuf[0] == '/')
return 1;
size_t i = t->cur;
while (i > 0 && t->inbuf[i - 1] != ' ' && t->inbuf[i - 1] != '\n')
i--;
return i < t->cur && t->inbuf[i] == '@';
}
static void toggle_plan(tui_t *t) {
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
cfg->plan_mode = !cfg->plan_mode;
set_status(t, cfg->plan_mode ? "Plan mode: read-only tools, answers with a "
"plan"
: "Build mode: all tools");
draw_status(t);
draw_input(t);
}
static int run_action(tui_t *t, int action, int busy);
static int run_keys_panel(tui_t *t, int busy, int *quit);
static void insert_newline(tui_t *t);
/* Keys bound to the action whose slash command is "/name", or "". */
static void slash_binding(const tui_t *t, const char *name, char *buf,
size_t n) {
buf[0] = 0;
for (int a = 1; a < CST_KA_COUNT; a++) {
const cst_key_action_info_t *in = cst_key_action_info(a);
if (in && in->slash && strcmp(in->slash + 1, name) == 0) {
cst_keymap_describe(&t->km, a, buf, n);
return;
}
}
}
/* Ctrl+P: every slash command plus key-only actions, filterable. */
static int run_palette(tui_t *t, int busy, int *quit) {
static const int extra[] = {CST_KA_MODE, CST_KA_APPROVAL,
CST_KA_TOGGLE_THOUGHT, CST_KA_TOGGLE_TOOL,
CST_KA_TODO, CST_KA_KEYS};
int ne = (int)(sizeof(extra) / sizeof(extra[0]));
int nc;
const slash_cmd_t *tab = slash_table(&nc);
static char items[96][128];
int map[96];
int n = 0;
char keys[64];
for (int i = 0; i < ne && n < 96; i++, n++) {
const cst_key_action_info_t *in = cst_key_action_info(extra[i]);
cst_keymap_describe(&t->km, extra[i], keys, sizeof(keys));
char d[64];
snprintf(d, sizeof(d), "%c%s", toupper((unsigned char)in->desc[0]),
in->desc + 1);
snprintf(items[n], sizeof(items[n]), "%-44s %s", d, keys);
map[n] = -1 - extra[i];
}
for (int i = 0; i < nc && n < 96; i++, n++) {
slash_binding(t, tab[i].name, keys, sizeof(keys));
char d[64];
snprintf(d, sizeof(d), "/%-12s %.30s", tab[i].name, tab[i].desc);
snprintf(items[n], sizeof(items[n]), "%-44s %s", d, keys);
map[n] = i;
}
int pick = picker_run(t, "Commands", items, n, -1);
if (pick < 0)
return 0;
if (map[pick] < 0) {
int rc = run_action(t, -1 - map[pick], busy);
if (rc == -2)
*quit = 1;
return rc;
}
const slash_cmd_t *c = &tab[map[pick]];
char line[64];
snprintf(line, sizeof(line), "/%s", c->name);
if (c->args[0] == '<') { /* needs an argument: let the user type it */
snprintf(line, sizeof(line), "/%s ", c->name);
input_set(t, line);
redraw_all(t);
return 0;
}
if (busy) {
handle_slash(t, line, quit);
redraw_all(t);
return *quit ? -2 : 0;
}
char *p = xstrdup(line);
return p ? start_turn(t, p) : 0;
}
/* --- key bindings ---------------------------------------------------------- */
/* Runs "/cmd" the way the palette does; -2 means quit. */
static int run_slash_line(tui_t *t, const char *line, int busy) {
if (busy) {
int quit = 0;
handle_slash(t, line, &quit);
redraw_all(t);
return quit ? -2 : 0;
}
char *p = xstrdup(line);
return p ? start_turn(t, p) : 0;
}
static void scroll_log(tui_t *t, int delta) {
t->log_scroll += delta;
if (t->log_scroll < 0)
t->log_scroll = 0;
draw_log(t);
}
static int run_action(tui_t *t, int action, int busy) {
int quit = 0;
switch (action) {
case CST_KA_PALETTE:
return run_palette(t, busy, &quit) == -2 || quit ? -2 : 0;
case CST_KA_KEYS:
return run_keys_panel(t, busy, &quit) == -2 || quit ? -2 : 0;
case CST_KA_MODE:
toggle_plan(t);
return 0;
case CST_KA_APPROVAL: {
set_approval(t, (approval_mode(t) + 1) % 3);
char label[64], msg[160];
approval_label(t, label, sizeof(label));
snprintf(msg, sizeof(msg), "approval: %s", label);
set_status(t, msg);
redraw_all(t);
return 0;
}
case CST_KA_NEWLINE:
insert_newline(t);
return 0;
case CST_KA_REDRAW:
clearok(curscr, TRUE);
redraw_all(t);
return 0;
case CST_KA_QUIT:
return -2;
case CST_KA_TOGGLE_TOOL:
toggle_last_tool(t);
return 0;
case CST_KA_TOGGLE_THOUGHT:
toggle_think(t);
return 0;
case CST_KA_TODO:
t->show_todo = !t->show_todo;
layout(t);
wtimeout(t->input, 80);
redraw_all(t);
return 0;
case CST_KA_SCROLL_UP:
scroll_log(t, t->rows / 2);
return 0;
case CST_KA_SCROLL_DOWN:
scroll_log(t, -(t->rows / 2));
return 0;
case CST_KA_LINE_UP:
scroll_log(t, 1);
return 0;
case CST_KA_LINE_DOWN:
scroll_log(t, -1);
return 0;
case CST_KA_TOP:
scroll_log(t, 1 << 28); /* clamped by draw_log */
return 0;
case CST_KA_BOTTOM:
t->log_scroll = 0;
draw_log(t);
return 0;
default: {
const cst_key_action_info_t *in = cst_key_action_info(action);
if (in && in->slash)
return run_slash_line(t, in->slash, busy);
return 0;
}
}
}
/* Leader pressed: show what can follow it. */
static void leader_hint(tui_t *t) {
char msg[sizeof(t->status_msg)], k[32];
size_t o = (size_t)snprintf(msg, sizeof(msg), "%s ›",
cst_key_name(t->km.leader, k, sizeof(k)));
for (int i = 0; i < t->km.n && o + 1 < sizeof(msg); i++) {
const cst_binding_t *b = &t->km.b[i];
if (b->nkeys != 2 || b->keys[0] != t->km.leader)
continue;
const cst_key_action_info_t *in = cst_key_action_info(b->action);
int w = snprintf(msg + o, sizeof(msg) - o, " %s %s",
cst_key_name(b->keys[1], k, sizeof(k)), in->name);
if (w < 0 || (size_t)w >= sizeof(msg) - o)
break;
o += (size_t)w;
}
set_status(t, msg);
draw_status(t);
}
/* 1 = handled, 0 = not bound (fall through to editing keys), -2 = quit. */
static int key_dispatch(tui_t *t, int code, int busy) {
if (code <= 0)
return 0;
if (t->leader_pending && since_ms(&t->leader_at) > 3000)
t->leader_pending = 0;
int action = 0;
if (t->leader_pending) {
int seq[2] = {t->km.leader, code};
t->leader_pending = 0;
status_idle(t);
if (code == 27) {
draw_status(t);
return 1;
}
if (cst_keymap_lookup(&t->km, seq, 2, &action) == CST_KM_MATCH)
return run_action(t, action, busy) == -2 ? -2 : 1;
char a[32], b[32], msg[96];
snprintf(msg, sizeof(msg), "no shortcut for %s %s",
cst_key_name(seq[0], a, sizeof(a)),
cst_key_name(code, b, sizeof(b)));
set_status(t, msg);
draw_status(t);
return 1;
}
/* Reserved: Enter, Ctrl+C, Ctrl+D, Esc; plain typing. */
if (code == 13 || code == 10 || code == 3 || code == 4 || code == 27)
return 0;
if (code >= 32 && code < CST_KEY_SPECIAL && code != 127)
return 0;
/* Completion popup keys win over bindings. */
if (code == 9 && (t->comp_n > 0 || wants_completion(t)))
return 0;
if (t->comp_n > 0 &&
(code == CST_K_BTAB || code == CST_K_UP || code == CST_K_DOWN))
return 0;
int seq[1] = {code};
int r = cst_keymap_lookup(&t->km, seq, 1, &action);
if (r == CST_KM_MATCH)
return run_action(t, action, busy) == -2 ? -2 : 1;
if (r == CST_KM_PREFIX) {
t->leader_pending = 1;
clock_gettime(CLOCK_MONOTONIC, &t->leader_at);
leader_hint(t);
return 1;
}
return 0;
}
/* /keys and F1: every action with its keys; picking one runs it. */
static int run_keys_panel(tui_t *t, int busy, int *quit) {
static char items[CST_KA_COUNT + 16][128];
int map[CST_KA_COUNT + 16];
int n = 0;
static const char *const GROUPS[] = {"General", "View", "Session", "Model",
"Agents"};
for (size_t g = 0; g < sizeof(GROUPS) / sizeof(GROUPS[0]); g++) {
for (int a = 1; a < CST_KA_COUNT; a++) {
const cst_key_action_info_t *in = cst_key_action_info(a);
if (!in || strcmp(in->group, GROUPS[g]) != 0)
continue;
char keys[64];
cst_keymap_describe(&t->km, a, keys, sizeof(keys));
int user = 0;
for (int i = 0; i < t->km.n; i++)
if (t->km.b[i].action == a && t->km.b[i].user)
user = 1;
snprintf(items[n], sizeof(items[n]), "%-8s %-36s %s%s", in->group,
in->desc, keys[0] ? keys : "(unbound)", user ? " *" : "");
map[n++] = a;
}
}
static const char *const FIXED[] = {
"Edit send enter",
"Edit cancel the turn / close popups esc",
"Edit clear input, twice to quit ctrl+c",
"Edit quit on an empty prompt ctrl+d",
"Edit new line (kitty keyboard terminals) shift+enter ctrl+enter",
"Edit move by char / word / line ← → ctrl+← → alt+b alt+f ctrl+a ctrl+e",
"Edit delete word back / forward ctrl+w alt+backspace ctrl+backspace ctrl+del",
"Edit delete to start / to end ctrl+u ctrl+k",
"Edit prompt history ↑ ↓",
};
for (size_t i = 0; i < sizeof(FIXED) / sizeof(FIXED[0]); i++) {
snprintf(items[n], sizeof(items[n]), "%s", FIXED[i]);
map[n++] = 0;
}
char conf[512];
if (cst_keymap_conflicts(&t->km, conf, sizeof(conf)) > 0)
push_system_line(t, "Key conflicts (the first binding wins):\n%s", conf);
int pick = picker_run(t, "Keys (* = keybinds.json)", items, n, -1);
if (pick < 0 || map[pick] == 0)
return 0;
int rc = run_action(t, map[pick], busy);
if (rc == -2)
*quit = 1;
return rc;
}
#ifdef NCURSES_VERSION
static int g_k_alt_up, g_k_alt_down, g_k_ctrl_home, g_k_ctrl_end,
g_k_ctrl_up, g_k_ctrl_down, g_k_ctrl_left, g_k_ctrl_right, g_k_alt_left,
g_k_alt_right, g_k_ctrl_dc, g_k_alt_dc;
/* The terminfo key, else the xterm sequence bound to our own code `own`. */
static int ext_key(const char *cap, const char *xterm, int own) {
char *s = tigetstr(cap);
int k = s && s != (char *)-1 ? key_defined(s) : 0;
if (k > 0)
return k;
#ifndef _WIN32
k = key_defined(xterm);
if (k > 0)
return k;
if (k == 0 && define_key(xterm, own) == OK)
return own;
#else
(void)xterm, (void)own;
#endif
return 0;
}
#endif
#if defined(NCURSES_VERSION) && !defined(_WIN32)
/* Terminfo lists one form per key, but terminals disagree: cursor keys come
* as CSI or SS3 whatever DECCKM says (tmux, mosh, a wrong TERM), Home/End as
* CSI H, SS3 H or CSI 1~/7~. Unknown forms would be read as Esc + text. */
static void vt_keys_init(void) {
static const struct {
const char *seq;
int key;
} K[] = {
{"\033[A", KEY_UP}, {"\033OA", KEY_UP}, {"\033[B", KEY_DOWN},
{"\033OB", KEY_DOWN}, {"\033[C", KEY_RIGHT}, {"\033OC", KEY_RIGHT},
{"\033[D", KEY_LEFT}, {"\033OD", KEY_LEFT}, {"\033[H", KEY_HOME},
{"\033OH", KEY_HOME}, {"\033[1~", KEY_HOME}, {"\033[7~", KEY_HOME},
{"\033[F", KEY_END}, {"\033OF", KEY_END}, {"\033[4~", KEY_END},
{"\033[8~", KEY_END}, {"\033[2~", KEY_IC}, {"\033[3~", KEY_DC},
{"\033[5~", KEY_PPAGE}, {"\033[6~", KEY_NPAGE}, {"\033[Z", KEY_BTAB},
{"\033[1;2A", KEY_SR}, {"\033[1;2B", KEY_SF}, {"\033[1;2C", KEY_SRIGHT},
{"\033[1;2D", KEY_SLEFT},
};
for (size_t i = 0; i < sizeof(K) / sizeof(K[0]); i++)
if (key_defined(K[i].seq) == 0)
define_key(K[i].seq, K[i].key);
}
#endif
static void keys_init(void) {
#ifdef NCURSES_VERSION
int own = KEY_OWN_BASE + 4; /* up to KEY_KITTY_BASE - 1 */
g_k_alt_up = ext_key("kUP3", "\033[1;3A", own++);
g_k_alt_down = ext_key("kDN3", "\033[1;3B", own++);
g_k_ctrl_home = ext_key("kHOM5", "\033[1;5H", own++);
g_k_ctrl_end = ext_key("kEND5", "\033[1;5F", own++);
g_k_ctrl_up = ext_key("kUP5", "\033[1;5A", own++);
g_k_ctrl_down = ext_key("kDN5", "\033[1;5B", own++);
g_k_ctrl_left = ext_key("kLFT5", "\033[1;5D", own++);
g_k_ctrl_right = ext_key("kRIT5", "\033[1;5C", own++);
g_k_alt_left = ext_key("kLFT3", "\033[1;3D", own++);
g_k_alt_right = ext_key("kRIT3", "\033[1;3C", own++);
g_k_ctrl_dc = ext_key("kDC5", "\033[3;5~", own++);
g_k_alt_dc = ext_key("kDC3", "\033[3;3~", own++);
#endif
}
/* ctrl/alt+left/right move by word, ctrl/alt+delete deletes the next word. */
static int word_key(int k, int *dir) {
#ifdef NCURSES_VERSION
if (k && (k == g_k_ctrl_left || k == g_k_alt_left))
return *dir = -1, 1;
if (k && (k == g_k_ctrl_right || k == g_k_alt_right))
return *dir = 1, 1;
if (k && (k == g_k_ctrl_dc || k == g_k_alt_dc))
return *dir = 0, 1;
#else
(void)k, (void)dir;
#endif
return 0;
}
/* ncurses event → cst_keymap code (0 for events that are not keys). */
static int tui_key_code(int rc, wint_t wch) {
if (rc != KEY_CODE_YES)
return (int)wch;
int k = (int)wch;
switch (k) {
case KEY_UP:
return CST_K_UP;
case KEY_DOWN:
return CST_K_DOWN;
case KEY_LEFT:
return CST_K_LEFT;
case KEY_RIGHT:
return CST_K_RIGHT;
case KEY_HOME:
return CST_K_HOME;
case KEY_END:
return CST_K_END;
case KEY_PPAGE:
return CST_K_PGUP;
case KEY_NPAGE:
return CST_K_PGDN;
case KEY_BTAB:
return CST_K_BTAB;
case KEY_DC:
return CST_K_DEL;
case KEY_IC:
return CST_K_INS;
case KEY_ENTER:
return 13;
case KEY_BACKSPACE:
return 127;
default:
break;
}
if (k >= KEY_F(1) && k <= KEY_F(12))
return CST_K_F1 + (k - KEY_F(1));
#ifdef NCURSES_VERSION
if (k && k == g_k_alt_up)
return CST_K_ALT_UP;
if (k && k == g_k_alt_down)
return CST_K_ALT_DOWN;
if (k && k == g_k_ctrl_home)
return CST_K_CTRL_HOME;
if (k && k == g_k_ctrl_end)
return CST_K_CTRL_END;
if (k && k == g_k_ctrl_up)
return CST_K_CTRL_UP;
if (k && k == g_k_ctrl_down)
return CST_K_CTRL_DOWN;
#endif
return 0;
}
/* --- modal key loops ------------------------------------------------------ */
static void perm_answer(tui_t *t, int ans) {
tui_lock(t);
t->perm_ans = ans;
tui_unlock(t);
}
static void run_perm_modal(tui_t *t) {
wtimeout(t->input, -1);
curs_set(0);
for (;;) {
draw_perm_modal(t);
draw_status(t);
wint_t wch = 0;
int rc = tui_wget_wch(t->input, &wch);
if (rc == ERR)
continue;
if (rc == KEY_CODE_YES) {
int h = getmaxy(t->logw);
if (wch == KEY_RESIZE)
layout(t), wtimeout(t->input, -1), redraw_all(t);
else if (wch == KEY_UP)
t->perm_scroll--;
else if (wch == KEY_DOWN)
t->perm_scroll++;
else if (wch == KEY_PPAGE)
t->perm_scroll -= h / 2;
else if (wch == KEY_NPAGE)
t->perm_scroll += h / 2;
else if (wch == KEY_ENTER) {
perm_answer(t, CST_PERM_ONCE);
break;
}
continue;
}
if (wch == L'y' || wch == L'Y' || wch == 10 || wch == 13) {
perm_answer(t, CST_PERM_ONCE);
break;
}
if (wch == L'n' || wch == L'N' || wch == 27) {
perm_answer(t, CST_PERM_DENY);
break;
}
if (wch == 3) { /* Ctrl+C: deny and stop the turn */
perm_answer(t, CST_PERM_DENY);
request_cancel_ui(t);
break;
}
if ((wch == L'a' || wch == L'A') && !t->perm_sandbox) {
perm_answer(t, CST_PERM_ALWAYS_TOOL);
break;
}
if ((wch == L'p' || wch == L'P') && t->perm_prefix[0]) {
perm_answer(t, CST_PERM_ALWAYS_PREFIX);
break;
}
}
curs_set(1);
wtimeout(t->input, 80);
redraw_all(t);
}
static void ask_finish(tui_t *t, int rc) {
tui_lock(t);
if (rc == 0 && !t->ask_buf[0] && t->ask_nopts)
snprintf(t->ask_buf, sizeof(t->ask_buf), "%s", t->ask_opts[t->ask_sel]);
t->ask_rc = rc;
t->ask_done = 1;
tui_unlock(t);
}
static void run_ask_modal(tui_t *t) {
wtimeout(t->input, -1);
curs_set(0);
for (;;) {
draw_ask_modal(t);
wint_t wch = 0;
int rc = tui_wget_wch(t->input, &wch);
if (rc == ERR)
continue;
if (rc == KEY_CODE_YES) {
if (wch == KEY_RESIZE)
layout(t), wtimeout(t->input, -1), redraw_all(t);
else if (wch == KEY_UP && t->ask_sel > 0)
t->ask_sel--;
else if (wch == KEY_DOWN && t->ask_sel + 1 < t->ask_nopts)
t->ask_sel++;
else if (wch == KEY_BACKSPACE) {
size_t n = strlen(t->ask_buf);
t->ask_buf[n - utf8_prev_len(t->ask_buf, n)] = 0;
} else if (wch == KEY_ENTER && (t->ask_buf[0] || t->ask_nopts)) {
ask_finish(t, 0);
break;
}
continue;
}
if (wch == 27 || wch == 3) {
ask_finish(t, -1);
break;
}
if (wch == 10 || wch == 13) {
if (t->ask_buf[0] || t->ask_nopts) {
ask_finish(t, 0);
break;
}
continue;
}
if (wch == 127 || wch == 8) {
size_t n = strlen(t->ask_buf);
t->ask_buf[n - utf8_prev_len(t->ask_buf, n)] = 0;
continue;
}
if (!t->ask_buf[0] && wch >= L'1' && wch <= L'9' &&
(int)(wch - L'1') < t->ask_nopts) {
t->ask_sel = (int)(wch - L'1');
ask_finish(t, 0);
break;
}
if (iswprint(wch)) {
char mb[8];
int k = os_utf8_encode((unsigned long)(wchar_t)wch, mb);
size_t n = strlen(t->ask_buf);
if (k > 0 && n + (size_t)k + 1 < sizeof(t->ask_buf)) {
memcpy(t->ask_buf + n, mb, (size_t)k);
t->ask_buf[n + k] = 0;
}
}
}
curs_set(1);
wtimeout(t->input, 80);
redraw_all(t);
}
/* --- main loop ------------------------------------------------------------ */
static void tui_free(tui_t *t) {
queue_free_all(t);
for (int i = 0; i < t->prompt_hist_n; i++)
free(t->prompt_hist[i]);
free(t->hist_draft);
for (int i = 0; i < t->nfiles; i++)
free(t->files[i]);
free(t->files);
free(t->todo);
free(t->pending_prompt);
free(t->perm_preview);
live_end_cleanup(t);
free_windows(t);
free(t->row_blk);
blocks_free(&t->hist);
pthread_mutex_destroy(&t->mu);
}
/* Redraw after an edit; the transcript only when the popup changed. */
static void after_edit(tui_t *t) {
layout_maybe_grow_input(t);
if (t->comp_n || t->comp_drawn) {
draw_log(t);
draw_input(t);
}
}
static void insert_newline(tui_t *t) {
input_put_byte(t, '\n');
layout_maybe_grow_input(t);
}
/* Paste mode: keep newlines/tabs literally; redraw once at the end. */
#ifdef _WIN32
static int win_key_pending(void) {
INPUT_RECORD rec[32];
DWORD n = 0;
HANDLE h = GetStdHandle(STD_INPUT_HANDLE);
if (!PeekConsoleInputW(h, rec, 32, &n))
return 0;
for (DWORD i = 0; i < n; i++)
if (rec[i].EventType == KEY_EVENT && rec[i].Event.KeyEvent.bKeyDown &&
rec[i].Event.KeyEvent.uChar.UnicodeChar)
return 1;
return 0;
}
#endif
static void paste_char(tui_t *t, wint_t wch) {
if (wch == 13) {
input_put_byte(t, '\n');
t->paste_cr = 1;
return;
}
if (wch == 10) {
if (!t->paste_cr)
input_put_byte(t, '\n');
t->paste_cr = 0;
return;
}
t->paste_cr = 0;
if (wch == L'\t')
input_insert(t, " ", 4);
else if (iswprint(wch) || wcwidth((wchar_t)wch) > 0)
input_put_wc(t, (wchar_t)wch);
}
int cst_tui_run(cst_agent_t *agent) {
#ifdef _WIN32
setlocale(LC_ALL, ".UTF-8");
#else
if (!setlocale(LC_ALL, ""))
setlocale(LC_ALL, "C.UTF-8");
#endif
#ifndef _WIN32
if (!getenv("ESCDELAY"))
setenv("ESCDELAY", "25", 0);
#endif
#ifdef _WIN32
win_console_font(1);
#endif
initscr();
#ifdef _WIN32
idcok(stdscr, FALSE);
#endif
raw(); /* Ctrl+C/Ctrl+Z reach us as keys instead of signals */
noecho();
keypad(stdscr, TRUE);
curs_set(1);
theme_init();
#if defined(NCURSES_VERSION) && !defined(_WIN32)
/* the Windows console driver reads key events, not VT sequences */
define_key("\033[200~", KEY_PASTE_BEGIN);
define_key("\033[201~", KEY_PASTE_END);
vt_keys_init();
printf("\033[?2004h");
fflush(stdout);
kitty_init();
#endif
tui_t t;
memset(&t, 0, sizeof(t));
t.agent = agent;
t.think_idx = -1;
t.last_tool_idx = -1;
t.focus_think_idx = -1;
t.hist_browse = -1;
t.show_todo = 1;
t.show_side = 1; /* Seat Deck; narrow terminals auto-hide in layout() */
{
const char *mo = hermit_getenv("ASCII_MOTION");
t.motion_on = !(mo && mo[0] == '0');
}
{
cst_settings_t st;
cst_settings_load(&st);
t.show_thinking = st.show_thinking;
}
keys_init();
char key_err[256] = "";
cst_keymap_load(&t.km, key_err, sizeof(key_err));
t.mouse_on = 1;
{
int sk = cst_sandbox_probe();
t.sandbox_name = sk == CST_SANDBOX_NONE ? "rules" : cst_sandbox_name(sk);
}
t.tip = TIPS[(unsigned)time(NULL) % (sizeof(TIPS) / sizeof(TIPS[0]))];
where_label(cst_agent_config(agent)->workspace_root, t.where,
sizeof(t.where));
pthread_mutex_init(&t.mu, NULL);
blocks_init(&t.hist);
if (!t.hist.items) {
endwin();
return -1;
}
refresh_title(&t);
refresh_todo(&t);
layout(&t);
t.need_layout = 0;
reload_hist_from_session(&t);
status_idle(&t);
if (key_err[0])
set_status(&t, key_err);
redraw_all(&t);
wtimeout(t.input, 80);
cst_agent_config_t *cfg = cst_agent_config_mut(agent);
cst_settings_t first;
if (!cfg->api_key[0] && cst_settings_load(&first) != 0) {
int ignore = 0;
cmd_provider(&t, NULL, &ignore);
}
if (!cfg->api_key[0]) {
char key[512];
if (obtain_key(&t, cfg->provider, key, sizeof(key), 0) == 0)
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
else
push_system_line(&t, "No API key for %s yet — /key to enter one, or "
"/provider to switch",
cfg->provider);
memset(key, 0, sizeof(key));
}
cst_settings_remember(cfg);
if (am_models(cfg->provider, NULL, 0) < 0)
start_probe(&t);
redraw_all(&t);
for (;;) {
if (g_am_changed) {
g_am_changed = 0;
am_check_model(&t);
draw_status(&t);
}
tui_lock(&t);
int dirty = t.dirty;
int turn_done = t.turn_done;
int turn_rc = t.turn_rc;
char turn_err[512];
snprintf(turn_err, sizeof(turn_err), "%s", t.turn_err);
int perm_ask = t.perm_ask && t.perm_ans < 0;
int ask = t.ask_pending && !t.ask_done;
int busy = t.busy;
int relayout = t.need_layout;
t.need_layout = 0;
t.dirty = 0;
if (turn_done)
t.turn_done = 0;
tui_unlock(&t);
if (drain_job_completions(&t))
dirty = 1;
if (relayout) {
layout(&t);
wtimeout(t.input, 80);
dirty = 1;
}
if (busy) {
tui_lock(&t);
if (t.as_live) {
if (live_drip(&t))
dirty = 1;
else if (t.md_rendered != t.md_shown &&
ms_since(&t.md_render_at) >= LIVE_RENDER_MS) {
live_render(&t);
dirty = 1;
}
}
if (t.think_timing && t.think_idx >= 0 && t.think_idx < t.hist.n) {
char title[64];
snprintf(title, sizeof(title), "Thinking… %.1fs",
(double)ms_since(&t.think_t0) / 1000.0);
block_set_title(&t.hist.items[t.think_idx], title);
dirty = 1;
}
tui_unlock(&t);
}
if (dirty)
redraw_all(&t);
if (busy) {
t.spin++;
draw_status(&t);
if (t.motion_on && t.as_live)
draw_log(&t); /* animate stream frontier glitch */
if (t.motion_on && t.side)
draw_side(&t);
draw_input(&t); /* always last — park cursor in the composer */
}
if (turn_done) {
if (turn_rc != 0) {
push_system_line(&t, "Error: %s", turn_err[0] ? turn_err : "?");
set_status(&t, turn_err[0] ? turn_err : "turn failed");
} else {
status_idle(&t);
}
cst_agent_reload_notes(t.agent);
refresh_title(&t);
t.log_scroll = 0;
redraw_all(&t);
if (try_start_queued(&t) == -2)
break;
continue;
}
if (perm_ask) {
run_perm_modal(&t);
continue;
}
if (ask) {
run_ask_modal(&t);
continue;
}
wint_t wch = 0;
int rc = tui_wget_wch(t.input, &wch);
if (rc == ERR) {
if (!busy && t.motion_on && t.side && !t.home) {
t.spin++;
draw_side(&t);
draw_input(&t); /* restore cursor after side refresh */
}
continue;
}
if (t.sel_state == SEL_SHOWN &&
!(rc == KEY_CODE_YES && (wch == KEY_MOUSE || wch == KEY_RESIZE)))
sel_clear(&t);
#ifdef _WIN32
/* The console has no bracketed paste: an Enter with more keys already
* queued is a pasted line break, not a send. */
if (rc == OK && !t.in_paste && (wch == 10 || wch == 13) &&
win_key_pending()) {
paste_char(&t, wch);
input_changed(&t);
after_edit(&t);
draw_input(&t);
continue;
}
#endif
if (!t.in_paste &&
!(rc == KEY_CODE_YES &&
(wch == KEY_PASTE_BEGIN || wch == KEY_PASTE_END ||
wch == KEY_RESIZE || wch == KEY_MOUSE))) {
int code = tui_key_code(rc, wch);
wint_t nx = 0;
int nrc = ERR;
if (code == 27 && !t.leader_pending) {
/* Alt+key arrives as Esc followed by the key */
wtimeout(t.input, 30);
nrc = tui_wget_wch(t.input, &nx);
wtimeout(t.input, 80);
if (nrc != ERR) {
int c2 = tui_key_code(nrc, nx);
if (c2 == 10)
c2 = 13;
code = c2 == CST_K_UP ? CST_K_ALT_UP
: c2 == CST_K_DOWN ? CST_K_ALT_DOWN
: c2 > 0 ? (c2 | CST_KEY_ALT)
: 0;
}
}
int kd = key_dispatch(&t, code, busy);
if (kd == -2)
goto out;
if (kd == 1) {
draw_input(&t);
continue;
}
if (nrc == OK) {
if (nx == L'b')
t.cur = word_left(&t, t.cur), draw_input(&t);
else if (nx == L'f')
t.cur = word_right(&t, t.cur), draw_input(&t);
else if (nx == 127 || nx == 8) {
input_erase(&t, word_left(&t, t.cur), t.cur);
layout_maybe_grow_input(&t);
}
continue;
}
if (nrc == KEY_CODE_YES)
continue;
if (code == 27) {
if (t.comp_n > 0) {
t.comp_off = 1;
t.comp_n = 0;
draw_log(&t);
} else if (busy) {
request_cancel_ui(&t);
} else if (t.in_len) {
input_clear(&t);
layout_maybe_grow_input(&t);
}
continue;
}
}
if (rc == KEY_CODE_YES) {
if (wch == KEY_PASTE_BEGIN) {
t.in_paste = 1;
t.paste_cr = 0;
t.paste_at = t.cur;
t.paste_len0 = t.in_len;
continue;
}
if (wch == KEY_PASTE_END) {
t.in_paste = 0;
paste_image_paths(&t);
input_changed(&t);
after_edit(&t);
continue;
}
if (wch == KEY_RESIZE) {
t.sel_state = SEL_NONE; /* display lines rewrap */
layout(&t);
wtimeout(t.input, 80);
redraw_all(&t);
continue;
}
if (wch == KEY_MOUSE) {
MEVENT evs[MOUSE_RUN_MAX];
int ne = get_mouse_run(evs, MOUSE_RUN_MAX);
for (int i = 0; i < ne; i++)
if (on_mouse(&t, &evs[i], busy) == -2)
goto out;
if (ne)
draw_input(&t); /* keep the terminal cursor in the box */
continue;
}
int wdir;
if (word_key((int)wch, &wdir)) {
if (wdir < 0)
t.cur = word_left(&t, t.cur), draw_input(&t);
else if (wdir > 0)
t.cur = word_right(&t, t.cur), draw_input(&t);
else {
input_erase(&t, t.cur, word_right(&t, t.cur));
after_edit(&t);
}
continue;
}
switch (wch) {
case KEY_HOME:
if (t.in_len) {
t.cur = line_start(&t, t.cur);
draw_input(&t);
} else {
t.log_scroll = 1 << 28; /* clamped by draw_log */
draw_log(&t);
}
continue;
case KEY_END:
if (t.in_len) {
t.cur = line_end(&t, t.cur);
draw_input(&t);
} else {
t.log_scroll = 0;
draw_log(&t);
}
continue;
case KEY_LEFT:
t.cur -= utf8_prev_len(t.inbuf, t.cur);
update_completion(&t);
redraw_all(&t);
continue;
case KEY_RIGHT:
t.cur += next_len(&t, t.cur);
update_completion(&t);
redraw_all(&t);
continue;
case KEY_UP:
case KEY_DOWN: {
int dir = wch == KEY_UP ? -1 : 1;
if (t.comp_n > 0) {
t.comp_sel = (t.comp_sel + dir + t.comp_n) % t.comp_n;
draw_log(&t);
draw_input(&t);
continue;
}
if (input_line_move(&t, dir)) {
draw_input(&t);
continue;
}
if (dir < 0) {
if (t.prompt_hist_n <= 0)
continue;
int idx = t.hist_browse;
if (idx < 0) {
free(t.hist_draft);
t.hist_draft = strdup(t.inbuf);
}
idx = idx < 0 ? t.prompt_hist_n - 1 : (idx > 0 ? idx - 1 : 0);
hist_load_into_input(&t, idx);
} else {
if (t.hist_browse < 0)
continue;
if (t.hist_browse + 1 >= t.prompt_hist_n) {
input_set(&t, t.hist_draft);
free(t.hist_draft);
t.hist_draft = NULL;
} else {
hist_load_into_input(&t, t.hist_browse + 1);
}
}
after_edit(&t);
continue;
}
case KEY_BACKSPACE:
input_erase(&t, t.cur - utf8_prev_len(t.inbuf, t.cur), t.cur);
after_edit(&t);
continue;
case KEY_DC:
input_erase(&t, t.cur, t.cur + next_len(&t, t.cur));
after_edit(&t);
continue;
case KEY_BTAB:
if (t.comp_n > 0) {
t.comp_sel = (t.comp_sel + t.comp_n - 1) % t.comp_n;
draw_log(&t);
draw_input(&t);
}
continue;
case KEY_ENTER:
break; /* handled with CR below */
default:
continue;
}
wch = 13;
}
if (t.in_paste) {
paste_char(&t, wch);
continue;
}
/* Enter: accept a completion, otherwise send. */
if (wch == 10 || wch == 13) {
if (t.comp_n > 0) {
int kind = t.comp_kind;
const slash_cmd_t *c = accept_completion(&t);
after_edit(&t);
/* "/he⏎" runs /help; commands that need an argument wait for it */
if (kind != 1 || !c || c->args[0] == '<')
continue;
}
int st = submit_input(&t, busy);
if (st == -2)
break;
continue;
}
if (wch == 3) { /* Ctrl+C */
if (busy) {
request_cancel_ui(&t);
} else if (t.in_len) {
input_clear(&t);
after_edit(&t);
} else if (t.ctrlc_at.tv_sec && since_ms(&t.ctrlc_at) < 2000) {
break;
} else {
clock_gettime(CLOCK_MONOTONIC, &t.ctrlc_at);
set_status(&t, "press Ctrl+C again to quit");
draw_status(&t);
}
continue;
}
if (wch == 4) { /* Ctrl+D on an empty prompt quits */
if (!busy && t.in_len == 0)
break;
input_erase(&t, t.cur, t.cur + next_len(&t, t.cur));
layout_maybe_grow_input(&t);
continue;
}
switch (wch) {
case 1: /* Ctrl+A */
t.cur = line_start(&t, t.cur);
draw_input(&t);
continue;
case 5: /* Ctrl+E */
t.cur = line_end(&t, t.cur);
draw_input(&t);
continue;
case 2: /* Ctrl+B */
t.cur -= utf8_prev_len(t.inbuf, t.cur);
draw_input(&t);
continue;
case 6: /* Ctrl+F */
t.cur += next_len(&t, t.cur);
draw_input(&t);
continue;
case 11: /* Ctrl+K */
input_erase(&t, t.cur, line_end(&t, t.cur) == t.cur && t.cur < t.in_len
? t.cur + 1
: line_end(&t, t.cur));
after_edit(&t);
continue;
case 21: /* Ctrl+U */
input_erase(&t, line_start(&t, t.cur), t.cur);
after_edit(&t);
continue;
case 23: /* Ctrl+W */
input_erase(&t, word_left(&t, t.cur), t.cur);
after_edit(&t);
continue;
case 26: /* Ctrl+Z is not job control in raw mode */
continue;
case L'\t':
if (t.comp_n > 0) {
accept_completion(&t);
after_edit(&t);
} else if (wants_completion(&t)) {
t.comp_off = 0;
update_completion(&t);
draw_log(&t);
draw_input(&t);
}
continue;
case 127:
case 8:
input_erase(&t, t.cur - utf8_prev_len(t.inbuf, t.cur), t.cur);
after_edit(&t);
continue;
default:
break;
}
if (iswprint(wch) || wcwidth((wchar_t)wch) > 0) {
input_put_wc(&t, (wchar_t)wch);
after_edit(&t);
}
}
out:
if (t.busy) {
cst_agent_request_cancel(t.agent);
for (int i = 0; i < 50 && t.busy; i++)
usleep(100000);
}
for (int i = 0; i < 20 && g_am_threads > 0; i++)
usleep(100000); /* let a /models probe finish before curl cleanup */
tui_free(&t);
#ifdef _WIN32
if (g_con_in_saved)
win_console_mouse(0);
win_console_font(0);
#elif defined(NCURSES_VERSION)
kitty_restore();
printf("\033[?1002l\033[?2004l");
fflush(stdout);
#endif
endwin();
return 0;
}