Files
CST-agent/src/tui.c
T
WHC b28bca2cfc TUI: kitty keyboard protocol, word movement and deletion keys
Query CSI ? u at startup and push the disambiguate flag on the alternate
screen when the terminal answers; CSI u reports are mapped back to legacy
input (Shift/Ctrl+Enter insert a newline, Ctrl+Backspace deletes a word).
Ctrl/Alt+Left/Right move by word, Ctrl/Alt+Delete delete the next word.
Our define_key codes move above the range ncurses uses for terminfo
extended keys.
2026-10-06 01:52:48 +08:00

6504 lines
189 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include "cJSON.h"
#include "cst_agent.h"
#include "cst_keymap.h"
#include "cst_ext.h"
#include "cst_mcp.h"
#include "cst_plugins.h"
#include "cst_skills.h"
#include "cst_subagent.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 <locale.h>
#include <pthread.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <wchar.h>
#include <ctype.h>
#include <wctype.h>
#ifdef _WIN32
#include <io.h>
#include <windows.h>
#else
#include <langinfo.h>
#include <strings.h>
#include <unistd.h>
#endif
static int g_nocolor; /* NO_COLOR */
static int g_ascii; /* non-UTF-8 locale or CST_ASCII=1 */
static int g_utf8 = 1; /* locale can show non-ASCII text */
#define HAS_COLOR() (!g_nocolor && has_colors())
/* Curses: ncursesw on Unix, PDCurses (wide) on Windows. */
#if defined(CST_USE_PDCURSES) || defined(_WIN32)
#ifndef PDC_WIDE
#define PDC_WIDE 1
#endif
#ifndef NCURSES_MOUSE_VERSION
#define NCURSES_MOUSE_VERSION 2
#endif
#include <curses.h>
#ifndef BUTTON4_PRESSED
#define BUTTON4_PRESSED 0
#endif
#ifndef BUTTON5_PRESSED
#define BUTTON5_PRESSED 0
#endif
#ifndef KEY_RESIZE
#define KEY_RESIZE 0632
#endif
/* MinGW often lacks wcwidth; simple East-Asian / ASCII heuristic. */
static int cst_wcwidth(wchar_t wc) {
if (wc == 0)
return 0;
if (wc < 32 || (wc >= 0x7f && wc < 0xa0))
return -1;
if (wc >= 0x1100 &&
(wc <= 0x115f || wc == 0x2329 || wc == 0x232a ||
(wc >= 0x2e80 && wc <= 0xa4cf && wc != 0x303f) ||
(wc >= 0xac00 && wc <= 0xd7a3) || (wc >= 0xf900 && wc <= 0xfaff) ||
(wc >= 0xfe10 && wc <= 0xfe19) || (wc >= 0xfe30 && wc <= 0xfe6f) ||
(wc >= 0xff00 && wc <= 0xff60) || (wc >= 0xffe0 && wc <= 0xffe6) ||
(wc >= 0x20000 && wc <= 0x2fffd) || (wc >= 0x30000 && wc <= 0x3fffd)))
return 2;
return 1;
}
#define wcwidth cst_wcwidth
#else
#include <ncurses.h>
#endif
#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
#ifdef NCURSES_VERSION
static int g_kitty_on;
/* Bind the reports and ask whether the terminal speaks the protocol; the
* flags are pushed once the reply arrives (see tui_wget_wch). */
static void kitty_init(void) {
char seq[24];
for (int f = 0; f < 32; f++) {
snprintf(seq, sizeof(seq), "\033[?%du", f);
define_key(seq, KEY_KITTY_REPLY);
}
for (int cp = 9; cp < KITTY_CP_END; cp++) {
if (cp != 9 && cp != 13 && cp != 27 && cp < ' ')
continue;
if (cp == 27)
define_key("\033[27u", KEY_KITTY_BASE + cp * 8);
for (int m = 1; m < 8; m++) {
snprintf(seq, sizeof(seq), "\033[%d;%du", cp, m + 1);
define_key(seq, KEY_KITTY_BASE + cp * 8 + m);
}
}
printf("\033[?u");
fflush(stdout);
}
/* Pop our flags before leaving; harmless if they were never pushed. */
static void kitty_restore(void) {
if (g_kitty_on) {
printf("\033[<u");
fflush(stdout);
g_kitty_on = 0;
}
}
#endif
/* wget_wch() that turns kitty key reports back into legacy input. */
static int tui_wget_wch(WINDOW *w, wint_t *wch) {
for (;;) {
int rc = wget_wch(w, wch);
#ifdef NCURSES_VERSION
if (rc != KEY_CODE_YES)
return rc;
if (*wch == KEY_KITTY_REPLY) {
if (!g_kitty_on) {
/* disambiguate: Esc, alt+x, ctrl+x, shift+enter... as CSI u */
printf("\033[>1u");
fflush(stdout);
g_kitty_on = 1;
}
continue;
}
int k = (int)*wch - KEY_KITTY_BASE;
if (k < 0 || k >= KITTY_CP_END * 8)
return rc;
int out[2];
int n = cst_key_from_kitty(k / 8, k % 8, out);
if (n == 0)
continue;
if (n == 2)
unget_wch((wchar_t)out[1]);
if (out[0] == CST_K_BTAB) {
*wch = KEY_BTAB;
return KEY_CODE_YES;
}
*wch = (wint_t)out[0];
return OK;
#else
return rc;
#endif
}
}
/* Color pair indices */
enum {
CP_HEADER = 1,
CP_STATUS,
CP_INPUT,
CP_PRIMARY, /* user / accents */
CP_ASSIST, /* cyan assistant */
CP_MUTED,
CP_SUCCESS,
CP_ERROR,
CP_TOOL,
CP_PANEL, /* transcript bg */
CP_SGR_GREEN,
CP_SGR_YELLOW,
CP_SGR_CYAN,
CP_SGR_MAGENTA,
/* OpenCode-style structured diff (bg distinguishes add/del/context) */
CP_DIFF_ADD,
CP_DIFF_DEL,
CP_DIFF_CTX,
/* raised surfaces: input box / user message, sidebar */
CP_BOX,
CP_BOX_MUTED,
CP_BOX_ACCENT,
CP_BOX_PRIMARY,
CP_BOX_BRIGHT,
CP_SIDE,
CP_SIDE_MUTED,
CP_SIDE_BRIGHT,
CP_SIDE_SUCCESS,
CP_SIDE_PRIMARY,
CP_ACCENT, /* blue on panel */
CP_BRIGHT, /* near-white on panel */
CP_LOGO_DIM,
CP_SEL /* selected list row */
};
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 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;
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_rendered; /* md_len at the last live_render */
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) */
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;
/* 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];
} 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) {
if (avail == 0)
return -1;
mbstate_t st;
memset(&st, 0, sizeof(st));
size_t n = mbrtowc(wc, s, avail, &st);
if (n == (size_t)-1 || n == (size_t)-2 || n == 0) {
*wc = (unsigned char)s[0];
*eaten = 1;
return 0;
}
*eaten = n;
return 0;
}
static int utf8_width(wchar_t wc) {
int w = wcwidth(wc);
if (w < 0)
return 1;
return w;
}
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) {
if (n < 2 || (unsigned char)s[0] != 0x1b)
return 0;
if (s[1] == '[') {
size_t i = 2;
while (i < n) {
unsigned char c = (unsigned char)s[i++];
if (c >= 0x40 && c <= 0x7e)
return i;
}
return 0;
}
return 2;
}
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 ---------------------------------------------------------------- */
/* OpenCode dark panel — never inherit terminal default bg. */
static void theme_init(void) {
const char *nc = getenv("NO_COLOR");
g_nocolor = nc && nc[0];
const char *asc = getenv("CST_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 && 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_YELLOW;
short panel = COLOR_BLACK;
short muted = COLOR_CYAN;
short success = COLOR_GREEN;
short err = COLOR_RED;
short assist = COLOR_CYAN;
short text = COLOR_WHITE;
short box = COLOR_BLACK, side = COLOR_BLACK;
short accent = COLOR_BLUE, bright = COLOR_WHITE, logo_dim = COLOR_CYAN;
if (COLORS >= 256) {
box = 235;
side = 234;
accent = 75;
bright = 255;
logo_dim = 242;
/* Prefer xterm-256 slots so we don't bleach the 8 ANSI base colors.
* panel #121212→233, primary #fab283→216, muted #808080→244 */
primary = 216;
panel = 233; /* #121212 — darker than 234 to avoid washed look */
muted = 244;
success = 114;
err = 167;
assist = 74; /* soft cyan, not neon */
text = 250; /* #bcbcbc — not near-white */
if (can_change_color()) {
/* Tune those slots toward exact OpenCode hex */
init_color(216, 980, 698, 514); /* #fab283 */
init_color(233, 70, 70, 70); /* #121212 */
init_color(244, 502, 502, 502); /* #808080 */
init_color(250, 720, 720, 720); /* body text */
init_color(114, 400, 750, 450);
init_color(167, 850, 320, 320);
init_color(74, 350, 700, 750);
init_color(235, 118, 118, 118); /* #1e1e1e raised box */
init_color(234, 90, 90, 90); /* #171717 sidebar */
init_color(75, 361, 612, 961); /* #5c9cf5 accent */
}
} else if (can_change_color() && COLORS >= 8) {
init_color(COLOR_BLACK, 70, 70, 70); /* panel #121212 */
init_color(COLOR_YELLOW, 980, 698, 514); /* primary */
init_color(COLOR_CYAN, 502, 502, 502); /* muted (reuse cyan slot) */
init_color(COLOR_WHITE, 720, 720, 720); /* body text */
init_color(COLOR_GREEN, 400, 750, 450);
init_color(COLOR_RED, 850, 320, 320);
init_color(COLOR_BLUE, 350, 700, 750); /* assist */
panel = COLOR_BLACK;
primary = COLOR_YELLOW;
muted = COLOR_CYAN;
text = COLOR_WHITE;
success = COLOR_GREEN;
err = COLOR_RED;
assist = COLOR_BLUE;
}
/* 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, primary, panel);
init_pair(CP_PANEL, text, panel);
init_pair(CP_SGR_GREEN, success, panel);
init_pair(CP_SGR_YELLOW, primary, panel);
init_pair(CP_SGR_CYAN, assist, 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);
}
/* 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_rendered = 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. Caller locks. */
static void live_render(tui_t *t) {
if (!t->as_live || !t->md_live || t->md_rendered == t->md_len)
return;
const char *md = t->md_live;
size_t n = t->md_len, 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;
}
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->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;
/* The last render stays on screen until the next throttled live_render;
* before the first one the raw text shows. */
free(t->live.body);
t->live.body = xstrndup(t->md_live, t->md_len);
if (!t->live.render || ms_since(&t->md_render_at) >= LIVE_RENDER_MS)
live_render(t);
}
static void live_seal(tui_t *t) {
if (!t->as_live)
return;
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;
}
static int msg_is_tool_result(const cst_message_t *m) {
return m && (m->role == CST_ROLE_TOOL ||
(m->role == CST_ROLE_USER && m->tool_call_id[0]));
}
/* Strip the "[tool …]" / "result <id> exit=N <n>" header and "end" trailer. */
static void tool_result_view(const char *content, const char **body,
size_t *len) {
const char *s = content ? content : "";
size_t n = strlen(s);
int framed = strncmp(s, "result ", 7) == 0;
if (framed || strncmp(s, "[tool ", 6) == 0) {
const char *nl = strchr(s, '\n');
if (nl) {
n -= (size_t)(nl + 1 - s);
s = nl + 1;
} else {
n = 0;
}
}
if (framed && n >= 4 && memcmp(s + n - 4, "\nend", 4) == 0)
n -= 4;
else if (framed && n == 3 && memcmp(s, "end", 3) == 0)
n = 0;
*body = s;
*len = n;
}
static void attach_tool_output_from_session(tui_t *t, block_t *b) {
if (!b || b->kind != BLK_TOOL || !b->tool_id[0])
return;
cst_session_t *s = cst_agent_session(t->agent);
if (!s)
return;
cst_session_lock(s);
int n = cst_session_count(s);
for (int i = n - 1; i >= 0; i--) {
const cst_message_t *m = cst_session_get(s, i);
if (!msg_is_tool_result(m))
continue;
if (strcmp(m->tool_call_id, b->tool_id) != 0)
continue;
const char *body;
size_t len;
tool_result_view(m->content, &body, &len);
block_set_body(b, body, len);
break;
}
cst_session_unlock(s);
}
/* --- drawing: SGR --------------------------------------------------------- */
typedef struct {
int attrs;
int pair;
} sty_t;
static void sgr_apply(sty_t *st, int code) {
switch (code) {
case 0:
st->attrs = 0;
st->pair = 0;
break;
case 1:
st->attrs |= A_BOLD;
break;
case 2:
st->attrs |= A_DIM;
break;
case 3:
#ifdef A_ITALIC
st->attrs |= A_ITALIC;
#endif
break;
case 4:
st->attrs |= A_UNDERLINE;
break;
case 7:
st->attrs |= A_REVERSE;
break;
case 22:
st->attrs &= ~(A_BOLD | A_DIM);
break;
case 23:
#ifdef A_ITALIC
st->attrs &= ~A_ITALIC;
#endif
break;
case 24:
st->attrs &= ~A_UNDERLINE;
break;
case 27:
st->attrs &= ~A_REVERSE;
break;
case 31:
case 91:
st->pair = CP_ERROR;
break;
case 32:
case 92:
st->pair = CP_SGR_GREEN;
break;
case 33:
case 93:
st->pair = CP_SGR_YELLOW;
break;
case 36:
case 96:
st->pair = CP_SGR_CYAN;
break;
case 35:
case 95:
st->pair = CP_SGR_MAGENTA;
break;
case 34:
case 94:
st->pair = CP_ACCENT;
break;
case 90:
st->pair = CP_MUTED;
break;
case 97:
st->pair = CP_BRIGHT;
break;
case 39:
st->pair = 0;
break;
default:
break;
}
}
static size_t sgr_parse(const char *s, size_t n, sty_t *st) {
if (n < 3 || (unsigned char)s[0] != 0x1b || s[1] != '[')
return 0;
size_t i = 2;
int val = 0, have = 0;
while (i < n) {
unsigned char c = (unsigned char)s[i++];
if (c >= '0' && c <= '9') {
val = val * 10 + (c - '0');
have = 1;
} else if (c == ';') {
sgr_apply(st, have ? val : 0);
val = 0;
have = 0;
} else if (c == 'm') {
sgr_apply(st, have ? val : 0);
return i;
} else {
return 0;
}
}
return 0;
}
static wchar_t ascii_glyph(wchar_t wc) {
if (!g_ascii || wc < 128)
return wc;
switch (wc) {
case 0x2503: /* ┃ */
case 0x2502: /* │ */
return L'|';
case 0x2500: /* ─ */
case 0x00b7: /* · */
return L'-';
case 0x25bc: /* ▼ */
case 0x2193: /* ↓ */
return L'v';
case 0x25b6: /* ▶ */
case 0x25b8: /* ▸ */
case 0x2192: /* → */
return L'>';
case 0x2190: /* ← */
return L'<';
case 0x2191: /* ↑ */
return L'^';
case 0x2026: /* … */
return L'.';
case 0x2713: /* ✓ */
return L'x';
case 0x25cb: /* ○ */
return L'o';
case 0x25cf: /* ● */
case 0x2022: /* • */
return L'*';
case 0x25a3: /* ▣ */
return L'#';
default:
return g_utf8 ? wc : L'?';
}
}
static int put_wc(WINDOW *win, int y, int col, wchar_t wc, int attrs) {
wc = ascii_glyph(wc);
cchar_t cc;
wchar_t wstr[2] = {wc, 0};
short pair = (short)PAIR_NUMBER(attrs);
attr_t a = (attr_t)(attrs & ~(attr_t)A_COLOR);
if (setcchar(&cc, wstr, a, pair, NULL) == ERR) {
if (wc < 128)
mvwaddch(win, y, col, (chtype)wc | (chtype)attrs);
else
mvwaddstr(win, y, col, "?");
return 1;
}
mvwadd_wch(win, y, col, &cc);
return utf8_width(wc);
}
/* Flatten blocks into display lines (plain text with optional ANSI in body). */
typedef struct {
char *text; /* owned line content (may include ANSI) */
int pair; /* base color pair for whole line (0 = default) */
int bold;
int dim;
int indent; /* columns left blank before text */
int fill; /* pair painted across the whole row (0 = none) */
int bar; /* pair for a "┃" in column 0 (0 = none) */
int blk; /* hist block index, -1 for live / none */
} 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;
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;
while (i < n && dl->n < MAX_RENDER_LINES) {
size_t start = i;
int col = 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 */
int 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;
}
}
dlines_push(dl, line, pair, bold, dim);
if (i < n && body[i - 1] != '\n' && col >= body_w) {
/* soft wrap — already advanced */
}
}
}
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_to_lines_indented(&t->live, width, t->details_all, dl);
}
/* 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, "r")) != NULL)
break;
snprintf(p, sizeof(p), "%s/.git", d);
FILE *g = fopen(p, "r");
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, "r");
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];
}
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 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: where/status on the left, context + palette hint on the right. */
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 {
long used;
int pct = ctx_percent(t, &used);
char tok[32];
fmt_tokens(used, tok, sizeof(tok));
snprintf(right, sizeof(right), "%s (%d%%)%s\033[97mctrl+p\033[39m commands",
tok, pct, dot_sep());
rw = vis_cols(right);
if (rw > w)
rw = w;
draw_ansi_at(t->status, 0, w - rw, rw, right,
pct >= 80 ? CP_ERROR : 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);
}
/* "Build · model provider · effort" on the input box's last row. */
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 interrupt", 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);
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, cfg->model, CP_BOX_BRIGHT, 0, 0);
x += draw_ansi_at(w, y, x, room - x + 2, " ", CP_BOX, 0, 0);
x += draw_ansi_at(w, y, x, room - x + 2, cfg->provider, CP_BOX_MUTED, 0, 0);
if (eff) {
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, eff, 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;
for (int r = 0; r < bh; r++)
put_wc(w, r, 0, L'┃', bar);
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 queues a message"
: (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 "cstagent"; each glyph is 4 columns wide. */
static const char *const LOGO[8][3] = {
{"█▀▀▀", "█ ", "▀▀▀▀"}, /* c */
{"█▀▀▀", "▀▀▀█", "▀▀▀▀"}, /* s */
{"▀█▀▀", " █ ", " ▀▀▀"}, /* t */
{"█▀▀█", "█▀▀█", "▀ ▀"}, /* a */
{"█▀▀▀", "█ ▀█", "▀▀▀▀"}, /* g */
{"█▀▀▀", "█▀▀ ", "▀▀▀▀"}, /* e */
{"█▀▀▄", "█ █", "▀ ▀"}, /* n */
{"▀█▀▀", " █ ", " ▀▀▀"}, /* t */
};
#define LOGO_W (8 * 5 - 1)
#define LOGO_H 3
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",
"/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",
"Shift+drag selects text while mouse support is on",
};
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;
if (g_ascii || !g_utf8) {
int x = (w - 8) / 2, y = box_top - 2;
if (y >= 0) {
draw_ansi_at(t->logw, y, x, 3, "cst", CP_LOGO_DIM, 1, 0);
draw_ansi_at(t->logw, y, x + 3, 5, "agent", CP_BRIGHT, 1, 0);
}
} else if (box_top - LOGO_H - 1 >= 0 && w >= LOGO_W) {
int x0 = (w - LOGO_W) / 2, y0 = box_top - LOGO_H - 1;
for (int g = 0; g < 8; g++)
for (int r = 0; r < LOGO_H; r++)
draw_ansi_at(t->logw, y0 + r, x0 + g * 5, 4, LOGO[g][r],
g < 3 ? CP_LOGO_DIM : CP_BRIGHT, 0, 0);
}
int right = t->in_x - t->log_x + t->in_w;
if (box_bot + 1 < h) {
char hint[96];
snprintf(hint, sizeof(hint),
"\033[97mtab\033[39m modes \033[97mctrl+p\033[39m commands");
int hw = vis_cols(hint);
if (right - hw >= 0)
draw_ansi_at(t->logw, box_bot + 1, right - hw, hw, hint, CP_MUTED, 0, 0);
}
if (t->tip && box_bot + 3 < h) {
char tip[256];
snprintf(tip, sizeof(tip), "\033[33m%s Tip\033[39m %s",
g_ascii ? "*" : "●", t->tip);
int tw = vis_cols(tip);
if (tw > w - 2)
tw = w - 2;
draw_ansi_at(t->logw, box_bot + 3, (w - tw) / 2, tw, tip, CP_MUTED, 0, 0);
}
const char *ext = cst_extensions_report();
if (ext[0] && box_bot + 5 < h) {
int ew = vis_cols(ext);
if (ew > w - 2)
ew = w - 2;
draw_ansi_at(t->logw, box_bot + 5, (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);
int x = 2, mw = w - 4, y = 1;
char line[512];
const char *title = t->title[0] ? t->title : "New session";
size_t fit = utf8_fit_cols(title, strlen(title), mw);
draw_ansi_at(s, y++, x, mw, title, CP_SIDE_BRIGHT, 1, 0);
if (fit < strlen(title) && y < h)
draw_ansi_at(s, y++, x, mw, title + fit, CP_SIDE_BRIGHT, 1, 0);
y++;
long used;
int pct = ctx_percent(t, &used);
draw_ansi_at(s, y++, x, mw, "Context", CP_SIDE_BRIGHT, 1, 0);
snprintf(line, sizeof(line), "%ld tokens", used);
draw_ansi_at(s, y++, x, mw, line, CP_SIDE_MUTED, 0, 0);
snprintf(line, sizeof(line), "%d%% used", pct);
draw_ansi_at(s, y++, x, mw, line, CP_SIDE_MUTED, 0, 0);
y++;
int bottom = h - 3; /* keep two rows for where + version */
char appr[64];
approval_label(t, appr, sizeof(appr));
if (y + 2 < bottom) {
draw_ansi_at(s, y++, x, mw, "Approval", CP_SIDE_BRIGHT, 1, 0);
draw_ansi_at(s, y++, x, mw, appr,
approval_mode(t) == CST_APPROVAL_YOLO ? CP_SIDE_PRIMARY
: CP_SIDE_MUTED,
0, 0);
y++;
}
int total = 0;
if (t->show_todo && t->todo && todo_open_items(t->todo, &total) >= 0 &&
total > 0 && y + 2 < bottom) {
draw_ansi_at(s, y++, x, mw, "Todo", CP_SIDE_BRIGHT, 1, 0);
for (const char *p = t->todo; *p && y < bottom - 1;) {
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 = "[✓]";
else if (strncmp(p, "[~]", 3) == 0)
pair = CP_SIDE_PRIMARY, mark = "[•]";
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);
}
p = nl ? nl + 1 : p + len;
}
y++;
}
int mready = 0, mtotal = 0;
cst_mcp_counts(&mready, &mtotal);
if (mtotal > 0 && y + 2 < bottom) {
cst_mcp_info_t mi[CST_MCP_MAX];
int n = cst_mcp_info(mi, CST_MCP_MAX);
snprintf(line, sizeof(line), "MCP %d/%d", mready, mtotal);
draw_ansi_at(s, y++, x, mw, line, CP_SIDE_BRIGHT, 1, 0);
for (int i = 0; i < n && y < bottom - 1; i++) {
if (mi[i].state == CST_MCP_OFF)
continue;
int ok = mi[i].state == CST_MCP_READY;
int pend = mi[i].state == CST_MCP_STARTING;
int dx = x + draw_ansi_at(s, y, x, 2, g_ascii ? "*" : "●",
ok ? CP_SIDE_SUCCESS
: pend ? CP_SIDE_MUTED : CP_SIDE_PRIMARY,
0, 0);
if (ok)
snprintf(line, sizeof(line), "%.63s · %d tools", mi[i].name,
mi[i].ntools);
else
snprintf(line, sizeof(line), "%.63s · %s", mi[i].name,
pend ? "starting" : "failed");
draw_ansi_at(s, y++, dx + 1, mw - (dx + 1 - x), line, CP_SIDE_MUTED, 0,
0);
}
y++;
}
mod_file_t mods[16];
int nm = collect_modified(t, mods, 16);
if (nm > 0 && y + 2 < bottom) {
draw_ansi_at(s, y++, x, mw, "Modified Files", CP_SIDE_BRIGHT, 1, 0);
for (int i = 0; i < nm && y < bottom; i++) {
char stat[48];
snprintf(stat, sizeof(stat), "+%d -%d", mods[i].add, mods[i].del);
int sw = (int)strlen(stat);
draw_ansi_at(s, y, x, mw - sw - 1, mods[i].path, CP_SIDE_MUTED, 0, 0);
char pl[16], mi[16];
snprintf(pl, sizeof(pl), "+%d", mods[i].add);
snprintf(mi, sizeof(mi), "-%d", mods[i].del);
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 (h >= 4) {
draw_ansi_at(s, h - 3, x, mw, t->where, CP_SIDE_MUTED, 0, 0);
int dx = x + draw_ansi_at(s, h - 2, x, 2, g_ascii ? "*" : "●",
CP_SIDE_SUCCESS, 0, 0);
dx += draw_ansi_at(s, h - 2, dx + 1, mw, "CST-Agent", CP_SIDE_BRIGHT, 1, 0);
draw_ansi_at(s, h - 2, dx + 2, mw - dx, CST_VERSION, CP_SIDE_MUTED, 0, 0);
}
wrefresh(s);
}
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;
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);
}
if (t->log_scroll > 0 && h > 2) {
char more[128];
snprintf(more, sizeof(more),
g_ascii ? " -- %d more lines below - End/PgDn - %d above -- "
: " ── %d more lines below · End/PgDn · %d above ── ",
t->log_scroll, start);
wmove(t->logw, h - 1, 0);
wclrtoeol(t->logw);
wattron(t->logw, A_REVERSE);
draw_ansi_line(t->logw, h - 1, w, more, CP_PRIMARY, 1, 0);
wattroff(t->logw, A_REVERSE);
}
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 redraw_all(tui_t *t) {
if (is_home(t) != t->home)
layout(t);
theme_paint_root();
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, ...) {
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 (m->role == CST_ROLE_USER) {
b->kind = BLK_USER;
block_set_title(b, "You");
block_set_body(b, m->content ? m->content : "",
m->content ? strlen(m->content) : 0);
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;
}
static void apply_mouse(const tui_t *t) {
mousemask(t->mouse_on ? (mmask_t)ALL_MOUSE_EVENTS : 0, NULL);
mouseinterval(0);
}
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;
}
/* Start screen: centered logo + box. Session: transcript column, input box
* and status row at the bottom, optional sidebar on the right. */
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;
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 >= 120)
t->side_w = clampi(t->cols / 4, 32, 42);
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) {
t->input_rows = clampi(t->rows - 3, 1, INPUT_MAX_ROWS);
t->in_h = t->input_rows + 1;
}
if (t->home) {
int block = LOGO_H + 1 + t->in_h + 4;
int top = (t->rows - 1 - block) / 2;
t->in_y = clampi(top + LOGO_H + 1, 0, t->rows - 1 - t->in_h);
t->log_x = t->log_y = 0;
t->logw = newwin(t->rows - 1, t->cols, 0, 0);
t->status = 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 = t->rows >= 12 ? 1 : 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 = newwin(log_h, mw - 2, t->log_y, 1);
if (t->todo_rows)
t->todow = newwin(t->todo_rows, mw - 2, t->log_y + log_h, 1);
t->status = newwin(1, mw - 2, t->rows - 1, 1);
if (t->side_w)
t->side = newwin(t->rows, t->side_w, 0, mw);
}
t->input = 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->side)
wbkgdset(t->side, COLOR_PAIR(CP_SIDE));
}
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];
mbstate_t st;
memset(&st, 0, sizeof(st));
size_t n = wcrtomb(mb, wc, &st);
if (n == (size_t)-1 || n == 0)
return;
input_insert(t, mb, 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;
}
/* Move to the previous/next logical line keeping the column (in chars).
* Returns 0 when already on the first/last line. */
static int input_line_move(tui_t *t, int dir) {
size_t ls = line_start(t, t->cur);
int col = 0;
for (size_t i = ls; i < t->cur; i += next_len(t, i))
col++;
size_t target;
if (dir < 0) {
if (ls == 0)
return 0;
target = line_start(t, ls - 1);
} else {
size_t le = line_end(t, t->cur);
if (le >= t->in_len)
return 0;
target = le + 1;
}
size_t te = line_end(t, target);
while (col-- > 0 && target < te)
target += next_len(t, target);
t->cur = target;
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 int queue_push(tui_t *t, const char *prompt) {
if (!prompt || !prompt[0])
return -1;
if (t->queue_n >= QUEUE_CAP)
return -1;
t->queue[t->queue_n] = strdup(prompt);
if (!t->queue[t->queue_n])
return -1;
t->queue_n++;
return 0;
}
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 = 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 ev;
if (getmouse(&ev) != OK)
continue;
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];
mbstate_t st;
memset(&st, 0, sizeof(st));
size_t k = wcrtomb(mb, (wchar_t)wch, &st);
if (k != (size_t)-1 && qlen + k < sizeof(query)) {
memcpy(query + qlen, mb, k);
qlen += 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 64
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];
} 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_llm_list_models(a->base, a->key, ids, AM_MAX, err, sizeof(err));
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;
}
/* Base URL for a provider: the live config for the current one (it may be
* overridden), else the preset. */
static const char *provider_base(const tui_t *t, const char *provider) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
if (cfg && strcmp(cfg->provider, provider) == 0)
return cfg->api_base;
const cst_provider_t *p = cst_provider_find(provider);
return p ? p->api_base : "";
}
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;
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);
pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
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. */
static void am_check_model(tui_t *t) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
char msg[400] = "";
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) {
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 (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 = provider_base(t, 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] = "";
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 = cst_llm_list_models(base, out, ids, AM_MAX, err, sizeof(err));
if (r == 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 (r < 0)
snprintf(note, sizeof(note), "could not verify the key (%.200s); "
"saved anyway",
err);
break;
}
if (cst_creds_save(provider, out) != 0)
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 ------------------------------------------------------- */
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;
t->title[0] = 0;
if (cfg && cst_agent_store_info(cfg->workspace_root, sid, &in) == 0)
snprintf(t->title, sizeof(t->title), "%s",
in.title[0] ? in.title : in.first_user);
if (!t->title[0])
snprintf(t->title, sizeof(t->title), "%.8s", sid);
}
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, "
"Shift+drag selects · /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);
}
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_llm_list_models(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++)
snprintf(labels[i], 128, "%.100s (api)", names[i]);
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);
memcpy(labels[n], builtin[i], 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);
push_system_line(t, "(model → %s, context %ld tokens)", cfg->model,
cst_model_context_tokens(cfg->model));
}
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_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[16][128];
int cur_idx = 0;
for (int i = 0; i < n && i < 16; i++) {
const cst_provider_t *q = cst_provider_at(i);
snprintf(items[i], 128, "%-9s %s (%s)", q->name, q->api_base, q->model);
if (strcmp(q->name, cfg->provider) == 0)
cur_idx = i;
}
int pick = picker_run(t, "Provider", items, n < 16 ? n : 16, 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_settings_t s;
cst_settings_load(&s);
const char *model = cst_settings_model(&s, p->name);
snprintf(cfg->provider, sizeof(cfg->provider), "%s", p->name);
snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", p->api_base);
snprintf(cfg->model, sizeof(cfg->model), "%s", model ? model : p->model);
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
memset(key, 0, sizeof(key));
cst_settings_remember(cfg);
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_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 < 120)
push_system_line(t, "(the sidebar shows when the terminal is at least 120 "
"columns wide)");
}
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 · Shift+drag selects text)"
: "(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_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);
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 · max steps %d",
cst_protocol_name(cfg->protocol),
cst_tool_profile_name(cfg->tool_profile), 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, " mode %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));
}
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 md_fence(FILE *fp, const char *s, size_t max) {
size_t n = strlen(s);
fprintf(fp, "````\n%.*s%s\n````\n\n", (int)(n > max ? max : n), s,
n > max ? "\n…(truncated)" : "");
}
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);
}
FILE *fp = fopen(path, "w");
if (!fp) {
push_system_line(t, "Error: cannot write %s", path);
return;
}
fprintf(fp, "# %s\n\nsession `%s` · model `%s`\n\n", t->title, sid,
cfg->model);
cst_session_t *s = cst_agent_session(t->agent);
cst_session_lock(s);
for (int i = 0; i < cst_session_count(s); i++) {
const cst_message_t *m = cst_session_get(s, i);
const char *c = m->content ? m->content : "";
if (m->role == CST_ROLE_SYSTEM)
continue;
if (msg_is_tool_result(m)) {
fprintf(fp, "**Tool result** `%s`\n\n", m->tool_name);
md_fence(fp, c, 4000);
} else if (m->role == CST_ROLE_USER) {
fprintf(fp, "## User\n\n%s\n\n", c);
} else {
fprintf(fp, "## Assistant\n\n%s\n\n", c);
if (m->tool_calls_json && m->tool_calls_json[0]) {
fprintf(fp, "**Tool calls**\n\n");
md_fence(fp, m->tool_calls_json, 4000);
}
}
}
cst_session_unlock(s);
fclose(fp);
push_system_line(t, "(exported to %s)", path);
}
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;
}
static const char b64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
size_t n = strlen(text);
if (n > 75000)
n = 75000; /* many terminals cap OSC 52 near 100 KB */
char *enc = malloc((n + 2) / 3 * 4 + 1);
if (!enc)
return;
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);
push_system_line(t, "(copied %zu bytes of the last reply via OSC 52 — 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(
"Analyze this repository and write an AGENTS.md file at the workspace "
"root for future coding agents. Cover: what the project is, how to "
"build, test and run it (exact commands), the directory layout, coding "
"conventions you can infer from the code, and anything surprising. "
"Explore with list_dir/glob/grep/read_file before writing. Keep it "
"under 150 lines. If AGENTS.md already exists, improve it rather than "
"starting over.");
}
static void cmd_compact(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
char err[512] = {0};
set_status(t, "compacting…");
redraw_all(t);
if (cst_agent_compact(t->agent, err, sizeof(err)) != 0) {
push_system_line(t, "Error: compact failed: %s", err[0] ? err : "?");
return;
}
after_session_change(t, "(session compacted)");
}
static void set_thinking_all(tui_t *t, int on) {
for (int i = 0; i < t->hist.n; i++)
if (t->hist.items[i].kind == BLK_THINK)
t->hist.items[i].expanded = on;
}
static void cmd_thinking(tui_t *t, char *arg, int *quit) {
(void)quit;
if (arg && strcmp(arg, "on") == 0)
t->show_thinking = 1;
else if (arg && strcmp(arg, "off") == 0)
t->show_thinking = 0;
else
t->show_thinking = !t->show_thinking;
set_thinking_all(t, t->show_thinking);
cst_settings_remember_thinking(t->show_thinking);
push_system_line(t, "(thinking %s by default)",
t->show_thinking ? "expanded" : "collapsed");
}
static int run_keys_panel(tui_t *t, int busy, int *quit);
static void cmd_keys(tui_t *t, char *arg, int *quit) {
(void)arg;
run_keys_panel(t, t->busy, quit);
}
static void cmd_details(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
t->details_all = !t->details_all;
push_system_line(t, "tool details: %s",
t->details_all ? "expanded" : "collapsed");
}
static void cmd_skills(tui_t *t, char *arg, int *quit) {
(void)quit;
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
cst_skill_t *v = calloc(CST_SKILLS_MAX, sizeof(*v));
if (!v)
return;
int n = cst_skills_list(cfg->workspace_root, v, CST_SKILLS_MAX);
if (arg && strcmp(arg, "reload") == 0) {
if (n > 0 && cst_tool_find("skill") == NULL)
cst_skills_register_tool();
push_system_line(t, "(skills reloaded: %d found)", n);
}
if (n == 0) {
push_system_line(t, "No skills. Add <dir>/SKILL.md (frontmatter name + "
"description) under .agent/skills/ in the workspace "
"or skills/ in the config dir, then /skills reload.");
free(v);
return;
}
if (!arg || !arg[0]) {
static char items[CST_SKILLS_MAX][128];
for (int i = 0; i < n; i++)
snprintf(items[i], sizeof(items[i]), "%-20.20s %-9.9s %.90s", v[i].name,
v[i].source, v[i].desc);
int pick = picker_run(t, "Skills (Enter: /skill NAME …)", items, n, 0);
if (pick >= 0) {
char line[128];
snprintf(line, sizeof(line), "/skill %s ", v[pick].name);
input_set(t, line);
}
free(v);
return;
}
push_system_line(t, "Skills (%d):", n);
for (int i = 0; i < n; i++)
push_system_line(t, " %-20s %-10s %.100s", v[i].name, v[i].source,
v[i].desc);
free(v);
}
static void cmd_skill(tui_t *t, char *arg, int *quit) {
(void)quit;
char name[64] = "";
if (arg)
sscanf(arg, "%63s", name);
if (!name[0]) {
cmd_skills(t, NULL, quit);
return;
}
const char *task = arg + strlen(name);
while (*task == ' ')
task++;
char *p = cst_skill_prompt(cst_agent_config(t->agent)->workspace_root, name,
task);
if (!p) {
push_system_line(t, "No skill named '%s' (/skills lists them)", name);
return;
}
free(t->pending_prompt);
t->pending_prompt = p;
}
static const char *mcp_state_name(int st) {
switch (st) {
case CST_MCP_STARTING:
return "starting";
case CST_MCP_READY:
return "ready";
case CST_MCP_FAILED:
return "failed";
case CST_MCP_EXITED:
return "exited";
default:
return "off";
}
}
static void reload_extensions(tui_t *t) {
cst_extensions_unload();
char rep[256];
cst_extensions_load(cst_agent_config(t->agent), rep, sizeof(rep));
push_system_line(t, "(extensions reloaded%s%s)", rep[0] ? ": " : "", rep);
}
static void cmd_plugins(tui_t *t, char *arg, int *quit) {
(void)quit;
const char *ws = cst_agent_config(t->agent)->workspace_root;
if (arg && (strcmp(arg, "trust") == 0 || strcmp(arg, "untrust") == 0)) {
int on = arg[0] == 't';
if (cst_settings_trust(ws, on) != 0) {
push_system_line(t, "(could not save settings)");
return;
}
push_system_line(t, on ? "Workspace trusted: .agent/plugins and "
".agent/mcp.json will load."
: "Workspace no longer trusted.");
reload_extensions(t);
return;
}
if (arg && strcmp(arg, "reload") == 0) {
reload_extensions(t);
return;
}
cst_plugin_info_t info[CST_PLUGINS_MAX];
int n = cst_plugins_info(info, CST_PLUGINS_MAX);
push_system_line(t, "Workspace %s (/plugins %s)",
cst_settings_trusted(ws) ? "trusted" : "not trusted",
cst_settings_trusted(ws) ? "untrust" : "trust");
if (n == 0) {
push_system_line(t, "No plugins. Add <dir>/plugin.json under "
".agent/plugins/ (trusted workspaces) or plugins/ in "
"the config dir, then /plugins reload.");
return;
}
for (int i = 0; i < n; i++) {
push_system_line(t, " %-18s %-9s %d tool%s%s%s%s%s", info[i].name,
info[i].source, info[i].ntools,
info[i].ntools == 1 ? "" : "s",
info[i].nmcp ? " · mcp" : "",
info[i].nskills_root ? " · skills" : "",
info[i].error[0] ? " · error: " : "", info[i].error);
if (info[i].desc[0])
push_system_line(t, " %.110s", info[i].desc);
}
}
static void cmd_mcp(tui_t *t, char *arg, int *quit) {
(void)quit;
if (arg && (strcmp(arg, "reload") == 0 || strcmp(arg, "restart") == 0)) {
reload_extensions(t);
return;
}
cst_mcp_info_t mi[CST_MCP_MAX];
int n = cst_mcp_info(mi, CST_MCP_MAX);
if (n == 0) {
push_system_line(t, "No MCP servers. Add {\"mcpServers\": {\"name\": "
"{\"command\": …, \"args\": […]}}} to mcp.json in "
"the config dir (or .agent/mcp.json in a trusted "
"workspace), then /mcp reload.");
return;
}
push_system_line(t, "MCP servers (%d):", n);
for (int i = 0; i < n; i++) {
push_system_line(t, " %-16s %-9s %-14s %s", mi[i].name,
mcp_state_name(mi[i].state), mi[i].source,
mi[i].command);
if (mi[i].state == CST_MCP_READY)
push_system_line(t, " %d tool%s as mcp__%s__*", mi[i].ntools,
mi[i].ntools == 1 ? "" : "s", mi[i].name);
else if (mi[i].error[0])
push_system_line(t, " %s", mi[i].error);
}
}
static void show_subagent(tui_t *t, const char *pid,
const cst_subagent_rec_t *rec) {
const char *ws = cst_agent_config(t->agent)->workspace_root;
char path[OS_PATH_MAX];
if (cst_subagent_path(ws, pid, rec->file, path, sizeof(path)) != 0)
return;
cst_session_t *s = cst_session_open(path);
if (!s) {
push_system_line(t, "(cannot open %s)", path);
return;
}
push_system_line(t, "── %s · %s: %s (%s · %d steps · %d tool calls · %.1f s)",
rec->file, rec->role, rec->desc, rec->status, rec->steps,
rec->tools, rec->ms / 1000.0);
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)
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_agents(tui_t *t, char *arg, int *quit) {
(void)quit;
(void)arg;
const char *ws = cst_agent_config(t->agent)->workspace_root;
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);
/* 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].source, roles[i].desc);
for (int i = 0; i < nrec; i++)
snprintf(items[n++], sizeof(items[0]), "run %-7.7s %-10.10s %-9.9s %.80s",
recs[i].file, recs[i].role, recs[i].status, recs[i].desc);
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;
}
static const slash_cmd_t SLASH[] = {
{"help", "?", "", "list commands and keys", 1, cmd_help},
{"new", "clear", "", "start a new session", 0, cmd_new},
{"session", "sessions resume", "[id]", "pick or switch session", 0,
cmd_session},
{"rename", "", "<title>", "name the current session", 1, cmd_rename},
{"delete", "", "[id]", "delete a session (asks first)", 0, cmd_delete},
{"model", "", "[name]", "pick or set the model", 0, cmd_model},
{"provider", "", "[name]", "switch provider (asks for its key)", 0,
cmd_provider},
{"key", "apikey", "", "enter or replace the provider's API key", 0,
cmd_key},
{"effort", "think", "[low|medium|high|max]", "reasoning effort", 0,
cmd_effort},
{"protocol", "", "[name]", "pick or set the tool-call protocol", 0,
cmd_protocol},
{"tools", "", "[on|off NAME|minimal]", "list or toggle tools", 0,
cmd_tools},
{"permissions", "perms", "[reset]", "show saved always-allow rules", 1,
cmd_permissions},
{"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 session sidebar", 1, cmd_sidebar},
{"mouse", "", "", "toggle mouse support (Shift+drag selects)", 1,
cmd_mouse},
{"max-steps", "", "[N]", "tool steps allowed per turn", 1, cmd_max_steps},
{"status", "", "", "session, model, context and settings", 1, cmd_status},
{"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},
{"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},
{"skill", "", "<name> [task]", "run a task with a skill loaded", 0,
cmd_skill},
{"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);
block_t *ub = blocks_push(&t->hist);
if (ub) {
ub->kind = BLK_USER;
block_set_title(ub, "You");
block_set_body(ub, prompt, strlen(prompt));
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) {
if (queue_push(t, prompt) != 0) {
push_system_line(t, "(queue full — max %d)", QUEUE_CAP);
set_status(t, "queue full");
} else {
char msg[64];
snprintf(msg, sizeof(msg), "queued (%d)", t->queue_n);
set_status(t, msg);
}
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;
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) {
if (row == want_row)
return i;
row++;
col = IN_COL;
}
best = i + eaten;
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;
}
/* 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;
static int ext_key(const char *cap) {
char *s = tigetstr(cap);
if (!s || s == (char *)-1)
return 0;
int k = key_defined(s);
return k > 0 ? k : 0;
}
#endif
static void keys_init(void) {
#ifdef NCURSES_VERSION
g_k_alt_up = ext_key("kUP3");
g_k_alt_down = ext_key("kDN3");
g_k_ctrl_home = ext_key("kHOM5");
g_k_ctrl_end = ext_key("kEND5");
g_k_ctrl_up = ext_key("kUP5");
g_k_ctrl_down = ext_key("kDN5");
g_k_ctrl_left = ext_key("kLFT5");
g_k_ctrl_right = ext_key("kRIT5");
g_k_alt_left = ext_key("kLFT3");
g_k_alt_right = ext_key("kRIT3");
g_k_ctrl_dc = ext_key("kDC5");
g_k_alt_dc = ext_key("kDC3");
#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];
mbstate_t st;
memset(&st, 0, sizeof(st));
size_t k = wcrtomb(mb, (wchar_t)wch, &st);
size_t n = strlen(t->ask_buf);
if (k != (size_t)-1 && n + k + 1 < sizeof(t->ask_buf)) {
memcpy(t->ask_buf + n, mb, 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]);
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. */
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) {
if (!setlocale(LC_ALL, ""))
setlocale(LC_ALL, "C.UTF-8");
#ifndef _WIN32
if (!getenv("ESCDELAY"))
setenv("ESCDELAY", "25", 0);
#endif
initscr();
raw(); /* Ctrl+C/Ctrl+Z reach us as keys instead of signals */
noecho();
keypad(stdscr, TRUE);
curs_set(1);
theme_init();
#ifdef NCURSES_VERSION
define_key("\033[200~", KEY_PASTE_BEGIN);
define_key("\033[201~", KEY_PASTE_END);
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;
{
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 (relayout) {
layout(&t);
wtimeout(t.input, 80);
dirty = 1;
}
if (busy) {
tui_lock(&t);
if (t.as_live && t.md_rendered != t.md_len &&
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);
draw_input(&t);
}
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)
continue;
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;
continue;
}
if (wch == KEY_PASTE_END) {
t.in_paste = 0;
input_changed(&t);
after_edit(&t);
continue;
}
if (wch == KEY_RESIZE) {
layout(&t);
wtimeout(t.input, 80);
redraw_all(&t);
continue;
}
if (wch == KEY_MOUSE) {
MEVENT ev;
if (getmouse(&ev) == OK) {
if (ev.bstate & BUTTON4_PRESSED) {
t.log_scroll += SCROLL_SPEED;
draw_log(&t);
} else if (ev.bstate & BUTTON5_PRESSED) {
t.log_scroll -= SCROLL_SPEED;
if (t.log_scroll < 0)
t.log_scroll = 0;
draw_log(&t);
} else if (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) {
if (handle_click(&t, ev.y, ev.x, busy) == -2)
goto out;
}
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;
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_clear(&t);
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 NCURSES_VERSION
kitty_restore();
printf("\033[?2004l");
fflush(stdout);
#endif
endwin();
return 0;
}