#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 #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #include #include #else #include #include #include #endif static int g_nocolor; /* NO_COLOR */ static int g_ascii; /* non-UTF-8 locale or CST_ASCII=1 */ static int g_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 #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 #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 exit=N " header and "end" trailer. */ static void tool_result_view(const char *content, const char **body, size_t *len) { const char *s = content ? content : ""; size_t n = strlen(s); int framed = strncmp(s, "result ", 7) == 0; if (framed || strncmp(s, "[tool ", 6) == 0) { const char *nl = strchr(s, '\n'); if (nl) { n -= (size_t)(nl + 1 - s); s = nl + 1; } else { n = 0; } } if (framed && n >= 4 && memcmp(s + n - 4, "\nend", 4) == 0) n -= 4; else if (framed && n == 3 && memcmp(s, "end", 3) == 0) n = 0; *body = s; *len = n; } static void attach_tool_output_from_session(tui_t *t, block_t *b) { if (!b || b->kind != BLK_TOOL || !b->tool_id[0]) return; cst_session_t *s = cst_agent_session(t->agent); if (!s) return; cst_session_lock(s); int n = cst_session_count(s); for (int i = n - 1; i >= 0; i--) { const cst_message_t *m = cst_session_get(s, i); if (!msg_is_tool_result(m)) continue; if (strcmp(m->tool_call_id, b->tool_id) != 0) continue; const char *body; size_t len; tool_result_view(m->content, &body, &len); block_set_body(b, body, len); break; } cst_session_unlock(s); } /* --- drawing: SGR --------------------------------------------------------- */ typedef struct { int attrs; int pair; } sty_t; static void sgr_apply(sty_t *st, int code) { switch (code) { case 0: st->attrs = 0; st->pair = 0; break; case 1: st->attrs |= A_BOLD; break; case 2: st->attrs |= A_DIM; break; case 3: #ifdef A_ITALIC st->attrs |= A_ITALIC; #endif break; case 4: st->attrs |= A_UNDERLINE; break; case 7: st->attrs |= A_REVERSE; break; case 22: st->attrs &= ~(A_BOLD | A_DIM); break; case 23: #ifdef A_ITALIC st->attrs &= ~A_ITALIC; #endif break; case 24: st->attrs &= ~A_UNDERLINE; break; case 27: st->attrs &= ~A_REVERSE; break; case 31: case 91: st->pair = CP_ERROR; break; case 32: case 92: st->pair = CP_SGR_GREEN; break; case 33: case 93: st->pair = CP_SGR_YELLOW; break; case 36: case 96: st->pair = CP_SGR_CYAN; break; case 35: case 95: st->pair = CP_SGR_MAGENTA; break; case 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", "", "", "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; }