#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 #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #include #include #else #include #include #include #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 #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 #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[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 exit=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 , " "/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 /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 /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 #include /* 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", "", "", "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; }