Files
Hermit/src/tui.c
T
WHC 2fde898da0
ci / linux (clang) (push) Canceled after 0s
ci / linux (gcc) (push) Canceled after 0s
ci / sanitizers (push) Canceled after 0s
ci / musl (push) Canceled after 0s
ci / macos (push) Canceled after 0s
Usability pass: tool registry, TUI polish, saved keys and settings
Tools
- Table-driven tool registry with permission classes and default/minimal
  profiles; new tools: edit_file, list_dir, glob, grep, delete_file,
  move_file, todo_write, web_fetch, ask_user (plus read_file paging).
- Built-in Myers diff; write tools return unified diffs.
- Persistent permission rules (.agent/permissions.json), per-turn undo
  snapshots (.agent/undo), AGENTS.md project notes, auto-compact at 80%
  of the model context, cancellable run_shell with process-group kill.

TUI
- Table-driven slash commands: /provider /key /effort /status /tools
  /permissions /yolo /undo /retry /diff /export /copy /rename /delete
  /init /max-steps /todo, with / and @ completion.
- Permission modal with diff and prefix rules, ask_user modal, todo
  panel, header with model/effort/context, bracketed paste, line editing,
  wide-char cursor, NO_COLOR and ASCII fallback; Esc never quits.

Startup and configuration
- API keys are entered in the TUI (hidden) and saved per provider in
  <config>/credentials (0600); env variables are only a fallback.
- Last provider, per-provider model and reasoning effort are remembered
  in <config>/settings.json; --effort/-e and /effort send
  reasoning_effort (low|medium|high|max) to models that accept it.
- DeepSeek preset uses deepseek-flash / deepseek-v4-pro.
- Session flags --new/--continue/--resume/--list-sessions, --yolo,
  --confirm.

Compatibility and tests
- ncurses detection, macOS/Homebrew support, make install, XDG config
  dir, POSIX cleanups; Gitea CI (gcc/clang/asan/musl/macOS).
- Unit tests for diff, fs tools, registry, undo/perms and config;
  e2e fixtures multi_file_refactor and ask_or_todo; vendored cJSON.
2026-10-05 01:47:01 +08:00

4516 lines
126 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_perms.h"
#include "cst_tools.h"
#include "cst_util.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
/* Bracketed paste markers, bound with define_key() above KEY_MAX. */
#define KEY_PASTE_BEGIN (KEY_MAX + 1)
#define KEY_PASTE_END (KEY_MAX + 2)
/* 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
};
typedef enum {
BLK_USER = 0,
BLK_ASSISTANT,
BLK_THINK,
BLK_TOOL,
BLK_STATUS,
BLK_SYSTEM
} 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;
int rows, cols;
int input_rows; /* 1..INPUT_MAX_ROWS */
int todo_rows;
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;
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_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");
#ifndef _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;
if (COLORS >= 256) {
/* 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);
}
} 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);
}
}
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"}};
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);
}
static void status_idle(tui_t *t) {
if (t->queue_n > 0)
snprintf(t->status_msg, sizeof(t->status_msg),
"ready · %d queued · empty Enter sends the next one", t->queue_n);
else
snprintf(t->status_msg, sizeof(t->status_msg),
"ready · / commands · @ files · Tab thought · PgUp scroll · "
"Ctrl+C twice quits");
}
/* --- 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->as_live = 0;
}
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;
/* During stream show raw markdown; convert on seal. */
free(t->live.body);
t->live.body = xstrndup(t->md_live, t->md_len);
free(t->live.render);
t->live.render = NULL;
}
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;
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 · %.1fs", sec);
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 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';
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; /* leading columns already accounted in text */
} 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;
return 0;
}
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 prefix[128];
char head[512];
switch (b->kind) {
case BLK_USER:
snprintf(prefix, sizeof(prefix), "┃ ");
dlines_push(dl, "┃ You", CP_PRIMARY, 1, 0);
push_wrapped(dl, prefix, b->body ? b->body : "", width, CP_PRIMARY, 0, 0,
0);
dlines_push(dl, "", 0, 0, 0);
break;
case BLK_ASSISTANT: {
dlines_push(dl, "┃ Assistant", CP_ASSIST, 1, 0);
const char *src = b->render ? b->render : (b->body ? b->body : "");
push_wrapped(dl, "┃ ", src, width, CP_ASSIST, 0, 0, 1);
dlines_push(dl, "", 0, 0, 0);
break;
}
case BLK_THINK:
if (b->expanded) {
snprintf(head, sizeof(head), "▼ %s", b->title ? b->title : "Thought");
dlines_push(dl, head, CP_MUTED, 0, 1);
push_wrapped(dl, " ", b->body ? b->body : "", width, CP_MUTED, 0, 1, 0);
} else {
/* One-liner: title + truncated body hint */
const char *body = b->body ? b->body : "";
size_t bl = strlen(body);
while (bl > 0 && (body[bl - 1] == '\n' || body[bl - 1] == ' '))
bl--;
char one[200];
size_t take = bl > 60 ? 60 : bl;
/* skip to first non-space for preview */
size_t off = 0;
while (off < take && (body[off] == ' ' || body[off] == '\n'))
off++;
snprintf(one, sizeof(one), "▶ %s %.*s%s",
b->title ? b->title : "Thought", (int)(take > off ? take - off : 0),
body + off, bl > 60 ? "…" : "");
dlines_push(dl, one, CP_MUTED, 0, 1);
}
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 : "");
dlines_push(dl, head, CP_MUTED, 0, 1);
break;
}
}
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++)
block_to_lines(&t->hist.items[i], width, t->details_all, dl);
if (t->as_live)
block_to_lines(&t->live, width, t->details_all, dl);
}
static void draw_ansi_line(WINDOW *win, int y, 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, col, wc, attrs);
col += cw;
i += eaten;
}
}
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 void draw_header(tui_t *t) {
if (!t->header)
return;
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
werase(t->header);
if (HAS_COLOR())
wbkgd(t->header, COLOR_PAIR(CP_HEADER));
if (!cfg) {
wrefresh(t->header);
return;
}
char line[640], right[96] = "";
const char *dot = g_ascii ? " | " : " · ";
long win = cst_model_context_tokens(cfg->model);
long used = cst_agent_last_prompt_tokens(t->agent);
int pct = win > 0 ? (int)(used * 100 / win) : 0;
const char *pc = pct >= 80 ? "\033[31m" : pct >= 60 ? "\033[33m" : "";
const char *eff = cst_effort_wire(cfg->provider, cfg->model, cfg->effort)
? cfg->effort
: NULL;
int k = snprintf(line, sizeof(line),
" CST-Agent%s%s/%s%s%s%s%s%sctx %s%d%%\033[39m", dot,
cfg->provider, cfg->model, eff ? dot : "", eff ? eff : "",
dot, cst_protocol_name(cfg->protocol), dot, pc, pct);
if (k > 0 && (size_t)k < sizeof(line) && t->title[0])
k += snprintf(line + k, sizeof(line) - (size_t)k, "%s%.40s", dot, t->title);
if (k > 0 && (size_t)k < sizeof(line) && cfg->auto_approve)
snprintf(line + k, sizeof(line) - (size_t)k, "%s\033[31;1mYOLO\033[0m", dot);
if (t->busy) {
static const char *const uni[] = {"⠋", "⠙", "⠹", "⠸", "⠼",
"⠴", "⠦", "⠧", "⠇", "⠏"};
static const char *const asc[] = {"|", "/", "-", "\\"};
const char *f = g_ascii ? asc[t->spin % 4] : uni[t->spin % 10];
snprintf(right, sizeof(right), "%s working %.0fs ", f,
since_ms(&t->turn_t0) / 1000.0);
}
int rw = utf8_cols(right, strlen(right));
draw_ansi_line(t->header, 0, t->cols - rw - 1, line, CP_HEADER, 1, 0);
if (rw) {
if (HAS_COLOR())
wattron(t->header, COLOR_PAIR(CP_PRIMARY));
mvwaddstr(t->header, 0, t->cols - rw - 1, right);
if (HAS_COLOR())
wattroff(t->header, COLOR_PAIR(CP_PRIMARY));
}
wrefresh(t->header);
}
static void draw_status(tui_t *t) {
if (!t->status)
return;
werase(t->status);
if (HAS_COLOR())
wbkgd(t->status, COLOR_PAIR(CP_STATUS));
const char *msg = t->status_msg[0] ? t->status_msg : "ready";
char line[600];
snprintf(line, sizeof(line), " %s", msg);
draw_ansi_line(t->status, 0, t->cols - 1, line, CP_STATUS, 0, 0);
wrefresh(t->status);
}
/* 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);
static int input_layout(const tui_t *t, input_cell_fn fn, void *ud,
int *cur_row, int *cur_col) {
int row = 0, col = 3, right = t->cols - 1;
*cur_row = 0;
*cur_col = 3;
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 = 3;
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 > 3) {
row++;
col = 3;
}
if (fn)
fn(ud, row, col, wc, cw);
col += cw;
i += eaten;
}
if (t->cur >= t->in_len) {
if (col >= right) {
row++;
col = 3;
}
*cur_row = row;
*cur_col = col;
}
return row + 1;
}
typedef struct {
WINDOW *win;
int top, h, attrs;
} 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, row - d->top, col, wc, d->attrs);
}
static void draw_input(tui_t *t) {
if (!t->input)
return;
werase(t->input);
if (HAS_COLOR())
wbkgd(t->input, COLOR_PAIR(CP_INPUT));
int h = t->input_rows > 0 ? t->input_rows : 1;
int cr, cc;
input_layout(t, NULL, NULL, &cr, &cc);
int top = cr >= h ? cr - h + 1 : 0;
for (int r = 0; r < h; r++)
mvwaddstr(t->input, r, 0,
r + top == 0 ? (t->busy ? (g_ascii ? " * " : " … ") : " > ")
: " ");
if (top > 0)
mvwaddstr(t->input, 0, 0, g_ascii ? " ^ " : " ↑ ");
if (t->in_len == 0) {
const char *hint =
t->busy ? "working… Enter queues a message · Esc cancels"
: "Ask anything · / commands · @ files · Ctrl+N newline";
int attrs = A_DIM | (HAS_COLOR() ? (int)COLOR_PAIR(CP_MUTED) : 0);
wattron(t->input, attrs);
if (g_ascii)
hint = t->busy ? "working... Enter queues a message - Esc cancels"
: "Ask anything - / commands - @ files - Ctrl+N newline";
size_t fit = utf8_fit_cols(hint, strlen(hint), t->cols - 5);
mvwaddnstr(t->input, 0, 3, hint, (int)fit);
wattroff(t->input, attrs);
wmove(t->input, 0, 3);
wrefresh(t->input);
return;
}
input_draw_t d = {t->input, top, h,
HAS_COLOR() ? (int)COLOR_PAIR(CP_INPUT) : 0};
input_layout(t, input_draw_cell, &d, &cr, &cc);
if (cc >= t->cols)
cc = t->cols - 1;
wmove(t->input, cr - top, cc);
wrefresh(t->input);
}
/* Popup rows at the bottom of the transcript, just above the input. */
static void draw_completion(tui_t *t, int h, int w) {
t->comp_drawn = t->comp_n;
if (t->comp_n <= 0 || h < 2)
return;
int n = t->comp_n < h - 1 ? t->comp_n : h - 1;
int bw = w - 2 < 90 ? w - 2 : 90;
for (int i = 0; i < n; i++) {
int y = h - n + i;
const comp_item_t *c = &t->comp[i];
char line[420];
snprintf(line, sizeof(line), " %-22s %s", c->text, c->desc);
int sel = i == t->comp_sel;
wmove(t->logw, y, 1);
for (int x = 0; x < bw; x++)
waddch(t->logw, ' ' | (sel ? A_REVERSE : 0) |
(HAS_COLOR() ? COLOR_PAIR(CP_HEADER) : 0));
if (sel)
wattron(t->logw, A_REVERSE);
size_t fit = utf8_fit_cols(line, strlen(line), bw);
char cut[420];
snprintf(cut, sizeof(cut), "%.*s", (int)fit, line);
if (HAS_COLOR())
wattron(t->logw, COLOR_PAIR(sel ? CP_PRIMARY : CP_HEADER));
mvwaddstr(t->logw, y, 1, cut);
if (HAS_COLOR())
wattroff(t->logw, COLOR_PAIR(sel ? CP_PRIMARY : CP_HEADER));
if (sel)
wattroff(t->logw, A_REVERSE);
}
}
/* 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, t->cols - 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, t->cols - 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++, t->cols - 1, line, pair, 0, dim);
} else if (len >= 3) {
skipped++;
}
p = nl ? nl + 1 : p + len;
}
(void)skipped;
wrefresh(t->todow);
}
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;
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;
for (int row = 0; row < h; row++) {
int li = start + row;
if (li < 0 || li >= dl.n)
continue;
dline_t *L = &dl.lines[li];
draw_ansi_line(t->logw, row, w, L->text, L->pair, L->bold, L->dim);
}
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, w);
wrefresh(t->logw);
}
static void redraw_all(tui_t *t) {
theme_paint_root();
draw_header(t);
draw_log(t);
draw_todo(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));
}
static void print_welcome(tui_t *t) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
push_system_line(t, "Welcome to CST-Agent");
push_system_line(t, "/ for commands (/help lists them) · @ to mention a file "
"· Esc cancels a turn · Ctrl+C twice quits");
if (cfg)
push_system_line(t, "Model %s @ %s · protocol %s · tools %s", cfg->model,
cfg->api_base, cst_protocol_name(cfg->protocol),
cst_tool_profile_name(cfg->tool_profile));
if (cfg && cst_agent_notes(t->agent) && cst_agent_notes(t->agent)[0])
push_system_line(t, "Project notes loaded from AGENTS.md");
push_system_line(t, "");
}
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;
print_welcome(t);
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 layout(tui_t *t) {
getmaxyx(stdscr, t->rows, t->cols);
theme_paint_root();
if (t->header)
delwin(t->header);
if (t->logw)
delwin(t->logw);
if (t->status)
delwin(t->status);
if (t->input)
delwin(t->input);
if (t->todow)
delwin(t->todow);
t->todow = NULL;
t->input_rows = input_visual_rows(t);
int total = 0, open = t->show_todo ? todo_open_items(t->todo, &total) : 0;
t->todo_rows = open ? 1 + (total < TODO_PANEL_MAX ? total : TODO_PANEL_MAX)
: 0;
if (t->rows < 16)
t->todo_rows = 0;
int chrome = 1 + 1 + t->input_rows + t->todo_rows;
if (chrome >= t->rows)
chrome = t->rows - 1;
int log_h = t->rows - chrome;
if (log_h < 1)
log_h = 1;
t->header = newwin(1, t->cols, 0, 0);
t->logw = newwin(log_h, t->cols, 1, 0);
if (t->todo_rows)
t->todow = newwin(t->todo_rows, t->cols, 1 + log_h, 0);
t->status = newwin(1, t->cols, 1 + log_h + t->todo_rows, 0);
t->input = newwin(t->input_rows, t->cols, 1 + log_h + t->todo_rows + 1, 0);
if (HAS_COLOR()) {
wbkgdset(t->header, COLOR_PAIR(CP_HEADER));
wbkgdset(t->logw, COLOR_PAIR(CP_PANEL));
wbkgdset(t->status, COLOR_PAIR(CP_STATUS));
wbkgdset(t->input, COLOR_PAIR(CP_INPUT));
}
scrollok(t->logw, FALSE);
keypad(t->input, TRUE);
mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, NULL);
mouseinterval(0);
}
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 = 1;
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, "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");
snprintf(t->perm_detail, sizeof(t->perm_detail), "%s", detail ? detail : "");
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];
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 (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 = 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] deny%s",
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) ------------------------------------------ */
/* items: n rows of labels (each ≤127 chars). Returns 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 < 0 || cur_idx >= n)
cur_idx = 0;
int sel = cur_idx;
int top = 0;
wtimeout(stdscr, -1);
keypad(stdscr, TRUE);
curs_set(0);
for (;;) {
int rows, cols;
getmaxyx(stdscr, rows, cols);
int box_h = rows > 4 ? rows - 2 : rows;
if (box_h > n + 2)
box_h = n + 2;
if (box_h < 3)
box_h = rows > 0 ? rows : 3;
int visible = box_h - 2;
if (visible < 1)
visible = 1;
if (sel < top)
top = sel;
if (sel >= top + visible)
top = sel - visible + 1;
werase(stdscr);
if (HAS_COLOR())
wbkgd(stdscr, COLOR_PAIR(CP_PANEL));
mvprintw(0, 1, "%s (↑↓ Enter · Esc cancel)", title ? title : "Select");
for (int i = 0; i < visible && top + i < n; i++) {
int idx = top + i;
int y = 1 + i;
if (idx == sel) {
if (HAS_COLOR())
attron(COLOR_PAIR(CP_PRIMARY) | A_BOLD);
else
attron(A_REVERSE);
} else if (HAS_COLOR()) {
attron(COLOR_PAIR(CP_MUTED));
}
mvprintw(y, 2, "%s %.*s", idx == cur_idx ? "*" : " ", cols - 6,
items[idx]);
if (idx == sel) {
if (HAS_COLOR())
attroff(COLOR_PAIR(CP_PRIMARY) | A_BOLD);
else
attroff(A_REVERSE);
} else if (HAS_COLOR()) {
attroff(COLOR_PAIR(CP_MUTED));
}
}
refresh();
wint_t wch = 0;
int rc = get_wch(&wch);
if (rc == ERR)
continue;
if (rc == KEY_CODE_YES) {
if (wch == KEY_UP) {
if (sel > 0)
sel--;
continue;
}
if (wch == KEY_DOWN) {
if (sel + 1 < n)
sel++;
continue;
}
if (wch == KEY_RESIZE)
continue;
if (wch == KEY_ENTER)
break;
continue;
}
if (wch == 10 || wch == 13) /* LF/CR */
break;
if (wch == 27) { /* Esc */
sel = -1;
break;
}
}
curs_set(1);
layout(t);
wtimeout(t->input, 80);
keypad(t->input, TRUE);
redraw_all(t);
return sel;
}
/* 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 = get_wch(&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. */
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;
const cst_provider_t *p = cst_provider_find(provider);
char dir[1024] = "the config directory";
os_config_dir(dir, sizeof(dir));
char title[128], hint[1300];
snprintf(title, sizeof(title), "API key for %s", provider);
snprintf(hint, sizeof(hint), "%s · saved to %s/credentials (only you can "
"read it)",
p ? p->api_base : "", dir);
if (secret_input_run(t, title, hint, out, n) != 0)
return -1;
if (cst_creds_save(provider, out) != 0)
push_system_line(t, "warning: could not save the key; it is used for "
"this run only");
else
push_system_line(t, "(saved the %s API key)", provider);
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:");
push_system_line(t, " Enter send · Ctrl+N / Alt+Enter newline · paste keeps "
"newlines");
push_system_line(t, " ←→ Home End Ctrl+A/E move · Ctrl+W/U/K delete word/"
"to start/to end · Delete");
push_system_line(t, " ↑↓ history (or lines) · Tab complete / toggle thought "
"· Ctrl+O last tool · Ctrl+T tasks");
push_system_line(t, " PgUp/PgDn wheel scroll · Esc cancel turn · Ctrl+C "
"twice or Ctrl+D quit");
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;
}
char note[192];
snprintf(note, sizeof(note), "(new session %s)", id);
after_session_change(t, note);
}
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]) {
char models[32][128];
int n = cst_models_list(cfg->model, models, 32);
int cur_idx = 0;
for (int i = 0; i < n; i++)
if (strcmp(models[i], cfg->model) == 0)
cur_idx = i;
int pick = picker_run(t, "Model", models, n, cur_idx);
if (pick < 0)
return;
arg = models[pick];
snprintf(cfg->model, sizeof(cfg->model), "%s", arg);
} 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);
}
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]) {
int idx = cst_tool_index(name);
if (idx < 0) {
push_system_line(t, "Unknown tool '%s'", name);
return;
}
if (verb[1] == 'n')
cfg->tools_off &= ~(1ULL << idx);
else
cfg->tools_off |= 1ULL << idx;
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", on ? "[x]" : "[ ]", d->name,
(int)d->perm < 6 ? perm_names[d->perm] : "?",
dl > 70 ? 70 : dl, desc);
}
}
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 cmd_yolo(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
cfg->auto_approve = !cfg->auto_approve;
push_system_line(t, cfg->auto_approve
? "(YOLO on: shell, writes and web run without asking)"
: "(YOLO off: risky tools ask again)");
}
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);
push_system_line(t, " approval %s",
cfg->auto_approve ? "YOLO (no confirmation)"
: "asks for shell / writes / web");
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 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_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},
{"yolo", "", "", "toggle auto-approve for this run", 1, cmd_yolo},
{"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},
{"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;
}
c->fn(t, arg, should_quit);
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]);
}
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);
}
/* --- 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 = 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') {
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 = 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);
if (t->header)
delwin(t->header);
if (t->logw)
delwin(t->logw);
if (t->todow)
delwin(t->todow);
if (t->status)
delwin(t->status);
if (t->input)
delwin(t->input);
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);
#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;
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);
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);
redraw_all(&t);
for (;;) {
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 (dirty)
redraw_all(&t);
if (busy) {
t.spin++;
draw_header(&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 = wget_wch(t.input, &wch);
if (rc == ERR)
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);
}
}
continue;
}
switch (wch) {
case KEY_PPAGE:
t.log_scroll += t.rows / 2;
draw_log(&t);
continue;
case KEY_NPAGE:
t.log_scroll -= t.rows / 2;
if (t.log_scroll < 0)
t.log_scroll = 0;
draw_log(&t);
continue;
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);
}
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;
}
if (wch == 27) {
/* Alt+key arrives as Esc followed by the key */
wint_t nx = 0;
wtimeout(t.input, 30);
int r2 = wget_wch(t.input, &nx);
wtimeout(t.input, 80);
if (r2 == OK) {
if (nx == 13 || nx == 10)
insert_newline(&t);
else 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 (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;
}
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 14: /* Ctrl+N */
insert_newline(&t);
continue;
case 15: /* Ctrl+O */
toggle_last_tool(&t);
continue;
case 20: /* Ctrl+T */
t.show_todo = !t.show_todo;
layout(&t);
wtimeout(t.input, 80);
redraw_all(&t);
continue;
case 12: /* Ctrl+L */
clearok(curscr, TRUE);
redraw_all(&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 (t.in_len == 0) {
toggle_think(&t);
} else {
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);
}
}
if (t.busy) {
cst_agent_request_cancel(t.agent);
for (int i = 0; i < 50 && t.busy; i++)
usleep(100000);
}
tui_free(&t);
#ifdef NCURSES_VERSION
printf("\033[?2004l");
fflush(stdout);
#endif
endwin();
return 0;
}