Ship hermit binary (cst-agent symlink), ~/.config/hermit with legacy fallback, Hermit color theme and mini crab frames, plus SVG→PUA font build pipeline. Co-authored-by: Cursor <cursoragent@cursor.com>
8296 lines
249 KiB
C
8296 lines
249 KiB
C
#ifndef _GNU_SOURCE
|
||
#define _GNU_SOURCE
|
||
#endif
|
||
|
||
#include "cJSON.h"
|
||
#include "cst_agent.h"
|
||
#include "cst_attach.h"
|
||
#include "cst_clipboard.h"
|
||
#include "cst_keymap.h"
|
||
#include "cst_ext.h"
|
||
#include "cst_mcp.h"
|
||
#include "cst_models.h"
|
||
#include "cst_discover.h"
|
||
#include "cst_parliament.h"
|
||
#include "cst_plugins.h"
|
||
#include "cst_realm.h"
|
||
#include "cst_provider_switch.h"
|
||
#include "cst_skills.h"
|
||
#include "cst_subagent.h"
|
||
#include "cst_runtime.h"
|
||
#include "cst_textsel.h"
|
||
#include "cst_perms.h"
|
||
#include "cst_sandbox.h"
|
||
#include "cst_tools.h"
|
||
#include "cst_util.h"
|
||
#include "cst_version.h"
|
||
#include "cst_hermit_glyph.h"
|
||
#include "os_compat.h"
|
||
#include "sashidown.h"
|
||
|
||
#include <locale.h>
|
||
#include <pthread.h>
|
||
#include <stdarg.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <time.h>
|
||
#include <wchar.h>
|
||
#include <ctype.h>
|
||
#include <wctype.h>
|
||
|
||
#ifdef _WIN32
|
||
#include <io.h>
|
||
#include <windows.h>
|
||
#else
|
||
#include <langinfo.h>
|
||
#include <strings.h>
|
||
#include <unistd.h>
|
||
#endif
|
||
|
||
static int g_nocolor; /* NO_COLOR */
|
||
static int g_ascii; /* non-UTF-8 locale or CST_ASCII=1 */
|
||
static int g_logo_text; /* console font lacks the logo's block glyphs */
|
||
static int g_utf8 = 1; /* locale can show non-ASCII text */
|
||
#define HAS_COLOR() (!g_nocolor && has_colors())
|
||
|
||
/* Curses: ncursesw (Unix and Windows), or PDCurses (wide) on Windows. */
|
||
#if defined(CST_USE_PDCURSES)
|
||
#ifndef PDC_WIDE
|
||
#define PDC_WIDE 1
|
||
#endif
|
||
#ifndef NCURSES_MOUSE_VERSION
|
||
#define NCURSES_MOUSE_VERSION 2
|
||
#endif
|
||
#include <curses.h>
|
||
#ifndef BUTTON4_PRESSED
|
||
#define BUTTON4_PRESSED 0
|
||
#endif
|
||
#ifndef BUTTON5_PRESSED
|
||
#define BUTTON5_PRESSED 0
|
||
#endif
|
||
#ifndef KEY_RESIZE
|
||
#define KEY_RESIZE 0632
|
||
#endif
|
||
#else
|
||
#include <ncurses.h>
|
||
#endif
|
||
#ifdef _WIN32
|
||
#define wcwidth cst_wcwidth /* MinGW often lacks wcwidth */
|
||
#endif
|
||
|
||
/* The Windows console (conhost/ConPTY) loses wide characters on insert or
|
||
* delete-character, so curses must repaint cells instead. */
|
||
static WINDOW *tui_newwin(int rows, int cols, int y, int x) {
|
||
WINDOW *w = newwin(rows, cols, y, x);
|
||
#ifdef _WIN32
|
||
if (w)
|
||
idcok(w, FALSE);
|
||
#endif
|
||
return w;
|
||
}
|
||
|
||
#ifdef _WIN32
|
||
static void cst_usleep(unsigned usec) {
|
||
Sleep((usec + 999) / 1000);
|
||
}
|
||
static int cst_clock_gettime_mono(struct timespec *ts) {
|
||
static LARGE_INTEGER freq;
|
||
static int init;
|
||
LARGE_INTEGER c;
|
||
if (!init) {
|
||
QueryPerformanceFrequency(&freq);
|
||
init = 1;
|
||
}
|
||
QueryPerformanceCounter(&c);
|
||
ts->tv_sec = (time_t)(c.QuadPart / freq.QuadPart);
|
||
ts->tv_nsec =
|
||
(long)(((c.QuadPart % freq.QuadPart) * 1000000000ULL) / freq.QuadPart);
|
||
return 0;
|
||
}
|
||
#define usleep(us) cst_usleep((unsigned)(us))
|
||
#define clock_gettime(clk, ts) cst_clock_gettime_mono(ts)
|
||
#ifndef CLOCK_MONOTONIC
|
||
#define CLOCK_MONOTONIC 1
|
||
#endif
|
||
#endif
|
||
|
||
/*
|
||
* OpenCode-inspired C ncurses/PDCurses TUI:
|
||
* structured transcript blocks, thinking/tool cards, permissions, cancel.
|
||
*/
|
||
|
||
#define INPUT_CAP 32768
|
||
#define SCROLL_SPEED 3
|
||
#define REPLAY_MSGS 30
|
||
#define MD_INIT (64 * 1024)
|
||
#define ANSI_INIT (64 * 1024)
|
||
#define TOOL_PREVIEW_LINES 3
|
||
#define DIFF_FOLD_LINES 40
|
||
#define BLOCKS_INIT 64
|
||
#define MAX_RENDER_LINES 65536
|
||
#define PROMPT_HIST_CAP 50
|
||
#define QUEUE_CAP 3
|
||
#define INPUT_MAX_ROWS 8
|
||
#define COMP_MAX 8
|
||
#define TODO_PANEL_MAX 6
|
||
#define ASK_OPTS_MAX 9
|
||
/* Our define_key() codes. ncurses numbers the terminfo extended keys (kLFT5,
|
||
* kDC3, ...) from KEY_MAX up, and key codes must fit in 16 bits, so stay well
|
||
* clear of the first and below the second. */
|
||
#define KEY_OWN_BASE (KEY_MAX + 0x1000)
|
||
/* Bracketed paste markers. */
|
||
#define KEY_PASTE_BEGIN (KEY_OWN_BASE + 1)
|
||
#define KEY_PASTE_END (KEY_OWN_BASE + 2)
|
||
/* Kitty keyboard protocol: the reply to CSI ? u, and CSI cp;1+mods u reports
|
||
* bound as KEY_KITTY_BASE + cp * 8 + mods for ASCII code points. */
|
||
#define KEY_KITTY_REPLY (KEY_OWN_BASE + 3)
|
||
#define KEY_KITTY_BASE (KEY_OWN_BASE + 16)
|
||
#define KITTY_CP_END 128
|
||
/* Transcript selection states. */
|
||
#define SEL_NONE 0
|
||
#define SEL_PRESS 1 /* button down, not moved off the pressed cell yet */
|
||
#define SEL_DRAG 2
|
||
#define SEL_SHOWN 3 /* released; highlighted until the next input */
|
||
#define MULTI_CLICK_MS 400
|
||
|
||
#ifdef NCURSES_VERSION
|
||
static int g_kitty_on;
|
||
|
||
#ifndef _WIN32
|
||
/* Bind the reports and ask whether the terminal speaks the protocol; the
|
||
* flags are pushed once the reply arrives (see tui_wget_wch). */
|
||
static void kitty_init(void) {
|
||
char seq[24];
|
||
for (int f = 0; f < 32; f++) {
|
||
snprintf(seq, sizeof(seq), "\033[?%du", f);
|
||
define_key(seq, KEY_KITTY_REPLY);
|
||
}
|
||
for (int cp = 9; cp < KITTY_CP_END; cp++) {
|
||
if (cp != 9 && cp != 13 && cp != 27 && cp < ' ')
|
||
continue;
|
||
if (cp == 27)
|
||
define_key("\033[27u", KEY_KITTY_BASE + cp * 8);
|
||
for (int m = 1; m < 8; m++) {
|
||
snprintf(seq, sizeof(seq), "\033[%d;%du", cp, m + 1);
|
||
define_key(seq, KEY_KITTY_BASE + cp * 8 + m);
|
||
}
|
||
}
|
||
printf("\033[?u");
|
||
fflush(stdout);
|
||
}
|
||
|
||
/* Pop our flags before leaving; harmless if they were never pushed. */
|
||
static void kitty_restore(void) {
|
||
if (g_kitty_on) {
|
||
printf("\033[<u");
|
||
fflush(stdout);
|
||
g_kitty_on = 0;
|
||
}
|
||
}
|
||
#endif /* !_WIN32 */
|
||
#endif
|
||
|
||
/* wget_wch() that turns kitty key reports back into legacy input. */
|
||
static int tui_wget_wch(WINDOW *w, wint_t *wch) {
|
||
for (;;) {
|
||
int rc = wget_wch(w, wch);
|
||
#ifdef NCURSES_VERSION
|
||
if (rc != KEY_CODE_YES)
|
||
return rc;
|
||
if (*wch == KEY_KITTY_REPLY) {
|
||
if (!g_kitty_on) {
|
||
/* disambiguate: Esc, alt+x, ctrl+x, shift+enter... as CSI u */
|
||
printf("\033[>1u");
|
||
fflush(stdout);
|
||
g_kitty_on = 1;
|
||
}
|
||
continue;
|
||
}
|
||
int k = (int)*wch - KEY_KITTY_BASE;
|
||
if (k < 0 || k >= KITTY_CP_END * 8)
|
||
return rc;
|
||
int out[2];
|
||
int n = cst_key_from_kitty(k / 8, k % 8, out);
|
||
if (n == 0)
|
||
continue;
|
||
if (n == 2)
|
||
unget_wch((wchar_t)out[1]);
|
||
if (out[0] == CST_K_BTAB) {
|
||
*wch = KEY_BTAB;
|
||
return KEY_CODE_YES;
|
||
}
|
||
*wch = (wint_t)out[0];
|
||
return OK;
|
||
#else
|
||
return rc;
|
||
#endif
|
||
}
|
||
}
|
||
|
||
/* Color pair indices */
|
||
enum {
|
||
CP_HEADER = 1,
|
||
CP_STATUS,
|
||
CP_INPUT,
|
||
CP_PRIMARY, /* user / accents */
|
||
CP_ASSIST, /* cyan assistant */
|
||
CP_MUTED,
|
||
CP_SUCCESS,
|
||
CP_ERROR,
|
||
CP_TOOL,
|
||
CP_PANEL, /* transcript bg */
|
||
CP_SGR_GREEN,
|
||
CP_SGR_YELLOW,
|
||
CP_SGR_CYAN,
|
||
CP_SGR_MAGENTA,
|
||
/* OpenCode-style structured diff (bg distinguishes add/del/context) */
|
||
CP_DIFF_ADD,
|
||
CP_DIFF_DEL,
|
||
CP_DIFF_CTX,
|
||
/* raised surfaces: input box / user message, sidebar */
|
||
CP_BOX,
|
||
CP_BOX_MUTED,
|
||
CP_BOX_ACCENT,
|
||
CP_BOX_PRIMARY,
|
||
CP_BOX_BRIGHT,
|
||
CP_SIDE,
|
||
CP_SIDE_MUTED,
|
||
CP_SIDE_BRIGHT,
|
||
CP_SIDE_SUCCESS,
|
||
CP_SIDE_PRIMARY,
|
||
CP_ACCENT, /* blue on panel */
|
||
CP_BRIGHT, /* near-white on panel */
|
||
CP_LOGO_DIM,
|
||
CP_SEL, /* selected list row */
|
||
CP_TOPBAR, /* session chrome strip */
|
||
CP_TOPBAR_MUTED,
|
||
CP_TOPBAR_ACCENT
|
||
};
|
||
|
||
typedef enum {
|
||
BLK_USER = 0,
|
||
BLK_ASSISTANT,
|
||
BLK_THINK,
|
||
BLK_TOOL,
|
||
BLK_STATUS,
|
||
BLK_SYSTEM,
|
||
BLK_FOOTER /* "▣ Build · model · 4.7s" after a turn */
|
||
} block_kind_t;
|
||
|
||
typedef struct {
|
||
block_kind_t kind;
|
||
char *title;
|
||
char *body; /* raw text / markdown / tool output */
|
||
char *render; /* ANSI for assistant; NULL → draw body */
|
||
int expanded;
|
||
char meta[256]; /* phase / timing / detail */
|
||
char tool_name[CTL_MAX_NAME];
|
||
char tool_id[CTL_MAX_ID];
|
||
} block_t;
|
||
|
||
typedef struct {
|
||
block_t *items;
|
||
int n;
|
||
int cap;
|
||
} blocks_t;
|
||
|
||
typedef struct {
|
||
char text[256];
|
||
char desc[112];
|
||
} comp_item_t;
|
||
|
||
typedef struct {
|
||
cst_agent_t *agent;
|
||
WINDOW *header;
|
||
WINDOW *logw;
|
||
WINDOW *todow;
|
||
WINDOW *status;
|
||
WINDOW *input;
|
||
WINDOW *side;
|
||
int rows, cols;
|
||
int input_rows; /* 1..INPUT_MAX_ROWS */
|
||
int todo_rows;
|
||
int home; /* empty transcript: centered start screen */
|
||
int show_side; /* /sidebar */
|
||
int motion_on; /* /motion — Seat Deck ASCII micro-motion */
|
||
int side_w;
|
||
int in_x, in_y, in_w, in_h; /* input box, screen coords */
|
||
int log_x, log_y; /* logw origin, screen coords */
|
||
int *row_blk; /* logw row → hist block (-1 none) */
|
||
int row_blk_cap;
|
||
int cmd_x0, cmd_x1; /* "ctrl+p commands" in the status row */
|
||
int mouse_on;
|
||
int log_start; /* display line shown in logw row 0 */
|
||
/* Mouse selection in the transcript: display line + logw column. */
|
||
int sel_state; /* SEL_* */
|
||
int sel_mode; /* 1 characters, 2 words, 3 lines */
|
||
int sel_ay, sel_ax; /* anchor */
|
||
int sel_y, sel_x; /* current end */
|
||
int sel_press_y, sel_press_x; /* screen cell of the press */
|
||
int sel_clicks, sel_click_y, sel_click_x;
|
||
struct timespec sel_click_at;
|
||
const char *tip;
|
||
const char *sandbox_name; /* "landlock" / "seatbelt" / "rules" */
|
||
char where[300]; /* ~/path:branch */
|
||
|
||
blocks_t hist; /* sealed blocks */
|
||
block_t live; /* streaming assistant (kind set when as_live) */
|
||
int as_live;
|
||
char *md_live;
|
||
size_t md_len;
|
||
size_t md_cap;
|
||
size_t md_shown; /* bytes of md_live revealed on screen (drip buffer) */
|
||
size_t md_rendered; /* md_shown at the last live_render */
|
||
double drip_carry; /* fractional codepoints awaiting reveal */
|
||
struct timespec drip_at;
|
||
char *md_prefix_ansi; /* rendered md_live[0, md_prefix) for long replies */
|
||
size_t md_prefix;
|
||
struct timespec md_render_at;
|
||
int show_thinking; /* /thinking: Thought blocks start expanded */
|
||
cst_keymap_t km;
|
||
int leader_pending;
|
||
struct timespec leader_at;
|
||
|
||
int log_scroll;
|
||
char inbuf[INPUT_CAP];
|
||
size_t in_len;
|
||
size_t cur; /* cursor byte offset into inbuf */
|
||
int in_paste;
|
||
int paste_cr; /* last pasted byte was CR (fold CRLF) */
|
||
size_t paste_at, paste_len0; /* cursor and length when the paste began */
|
||
char status_msg[512];
|
||
char title[128]; /* session title or first prompt, for the header */
|
||
struct timespec ctrlc_at;
|
||
struct timespec turn_t0;
|
||
int spin;
|
||
|
||
/* "/" and "@" completion popup */
|
||
comp_item_t comp[COMP_MAX];
|
||
int comp_n, comp_sel, comp_kind; /* kind 1 = slash, 2 = file */
|
||
size_t comp_start;
|
||
int comp_off; /* dismissed with Esc until the input changes */
|
||
int comp_drawn; /* popup rows currently on screen */
|
||
char **files;
|
||
int nfiles;
|
||
struct timespec files_at;
|
||
|
||
char *todo;
|
||
int show_todo;
|
||
volatile int need_layout;
|
||
char *pending_prompt; /* set by /retry and /init */
|
||
|
||
int busy;
|
||
int details_all; /* /details: expand all tool bodies */
|
||
int awaiting_perm;
|
||
int think_idx; /* hist index of open think, or -1 */
|
||
int last_tool_idx;
|
||
int focus_think_idx;
|
||
struct timespec think_t0;
|
||
int think_timing; /* think_t0 valid */
|
||
|
||
/* Prompt history (newest at end). hist_browse=-1 means live buffer. */
|
||
char *prompt_hist[PROMPT_HIST_CAP];
|
||
int prompt_hist_n;
|
||
int hist_browse;
|
||
char *hist_draft; /* the live input while browsing */
|
||
|
||
/* Busy-turn FIFO */
|
||
char *queue[QUEUE_CAP];
|
||
int queue_n;
|
||
|
||
/* Permission handshake (worker → main). */
|
||
volatile int perm_ask;
|
||
volatile int perm_ans; /* -1 waiting, else CST_PERM_* */
|
||
char perm_tool[CTL_MAX_NAME];
|
||
char perm_detail[512];
|
||
char *perm_preview;
|
||
char perm_prefix[128];
|
||
int perm_sandbox; /* asking to rerun a blocked command outside the sandbox */
|
||
int perm_scroll;
|
||
|
||
/* ask_user handshake (worker → main). */
|
||
volatile int ask_pending;
|
||
volatile int ask_done;
|
||
int ask_rc;
|
||
int ask_sel;
|
||
int ask_nopts;
|
||
char ask_q[1024];
|
||
char ask_opts[ASK_OPTS_MAX][256];
|
||
char ask_buf[1024];
|
||
|
||
pthread_mutex_t mu;
|
||
volatile int dirty;
|
||
volatile int turn_done;
|
||
int turn_rc;
|
||
char turn_err[512];
|
||
int jobs_done; /* background completions drained this session */
|
||
} tui_t;
|
||
|
||
/* --- UTF-8 helpers -------------------------------------------------------- */
|
||
|
||
static size_t utf8_prev_len(const char *s, size_t len) {
|
||
if (len == 0)
|
||
return 0;
|
||
size_t i = len - 1;
|
||
while (i > 0 && ((unsigned char)s[i] & 0xC0) == 0x80)
|
||
i--;
|
||
return len - i;
|
||
}
|
||
|
||
static int utf8_decode(const char *s, size_t avail, wchar_t *wc, size_t *eaten) {
|
||
return cst_utf8_decode(s, avail, wc, eaten);
|
||
}
|
||
|
||
static int utf8_width(wchar_t wc) { return cst_utf8_width(wc); }
|
||
|
||
static int utf8_cols(const char *s, size_t n) {
|
||
int cols = 0;
|
||
size_t i = 0;
|
||
while (i < n) {
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(s + i, n - i, &wc, &eaten);
|
||
cols += utf8_width(wc);
|
||
i += eaten;
|
||
}
|
||
return cols;
|
||
}
|
||
|
||
static size_t utf8_fit_cols(const char *s, size_t n, int max_cols) {
|
||
int cols = 0;
|
||
size_t i = 0;
|
||
while (i < n) {
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(s + i, n - i, &wc, &eaten);
|
||
int w = utf8_width(wc);
|
||
if (cols + w > max_cols)
|
||
break;
|
||
cols += w;
|
||
i += eaten;
|
||
}
|
||
return i;
|
||
}
|
||
|
||
static size_t ansi_skip(const char *s, size_t n) { return cst_ansi_skip(s, n); }
|
||
|
||
static char *xstrdup(const char *s) {
|
||
if (!s)
|
||
s = "";
|
||
size_t n = strlen(s);
|
||
char *p = malloc(n + 1);
|
||
if (!p)
|
||
return NULL;
|
||
memcpy(p, s, n + 1);
|
||
return p;
|
||
}
|
||
|
||
static char *xstrndup(const char *s, size_t n) {
|
||
char *p = malloc(n + 1);
|
||
if (!p)
|
||
return NULL;
|
||
memcpy(p, s, n);
|
||
p[n] = 0;
|
||
return p;
|
||
}
|
||
|
||
/* --- theme ---------------------------------------------------------------- */
|
||
|
||
/* Hermit palette: brand teal, crab orange, eye purple (design mock). */
|
||
static void theme_init(void) {
|
||
const char *nc = getenv("NO_COLOR");
|
||
g_nocolor = nc && nc[0];
|
||
const char *asc = hermit_getenv("ASCII");
|
||
#if defined(__ANDROID__)
|
||
g_utf8 = 1; /* bionic is UTF-8 only; nl_langinfo needs API 26 */
|
||
#elif !defined(_WIN32)
|
||
const char *cs = nl_langinfo(CODESET);
|
||
g_utf8 = cs && (strcasecmp(cs, "UTF-8") == 0 || strcasecmp(cs, "utf8") == 0);
|
||
#endif
|
||
g_ascii = asc[0] ? asc[0] != '0' : !g_utf8;
|
||
if (!HAS_COLOR())
|
||
return;
|
||
start_color();
|
||
/* Do NOT call use_default_colors(): -1 would leak the terminal theme. */
|
||
|
||
short primary = COLOR_CYAN;
|
||
short panel = COLOR_BLACK;
|
||
short muted = COLOR_WHITE;
|
||
short success = COLOR_GREEN;
|
||
short err = COLOR_RED;
|
||
short assist = COLOR_MAGENTA;
|
||
short text = COLOR_WHITE;
|
||
short box = COLOR_BLACK, side = COLOR_BLACK;
|
||
short accent = COLOR_CYAN, bright = COLOR_WHITE, logo_dim = COLOR_CYAN;
|
||
short tool = COLOR_YELLOW;
|
||
short prompt_c = COLOR_CYAN;
|
||
|
||
if (COLORS >= 256) {
|
||
box = 235;
|
||
side = 234;
|
||
accent = 75; /* #277F94 brand teal */
|
||
bright = 255;
|
||
logo_dim = 242;
|
||
primary = 75;
|
||
panel = 233;
|
||
muted = 244;
|
||
success = 114;
|
||
err = 167;
|
||
assist = 177; /* #9957AD eye purple */
|
||
tool = 216; /* #F68042 crab orange */
|
||
prompt_c = 152; /* #A9D9D7 prompt cyan */
|
||
text = 252;
|
||
if (can_change_color()) {
|
||
init_color(75, 153, 498, 580); /* #277F94 brand */
|
||
init_color(152, 663, 851, 843); /* #A9D9D7 prompt */
|
||
init_color(216, 965, 502, 259); /* #F68042 crab */
|
||
init_color(177, 600, 341, 678); /* #9957AD eye */
|
||
init_color(167, 850, 320, 320); /* error red */
|
||
init_color(168, 643, 341, 310); /* #A4574F brick legs */
|
||
init_color(233, 55, 58, 70); /* #0e0f12 panel */
|
||
init_color(244, 502, 520, 560); /* muted */
|
||
init_color(252, 820, 830, 850); /* body text */
|
||
init_color(114, 400, 750, 450);
|
||
init_color(235, 100, 105, 120);
|
||
init_color(234, 78, 82, 96);
|
||
}
|
||
} else if (can_change_color() && COLORS >= 8) {
|
||
init_color(COLOR_BLACK, 55, 58, 70);
|
||
init_color(COLOR_CYAN, 153, 498, 580);
|
||
init_color(COLOR_WHITE, 820, 830, 850);
|
||
init_color(COLOR_GREEN, 400, 750, 450);
|
||
init_color(COLOR_RED, 850, 320, 320);
|
||
init_color(COLOR_MAGENTA, 600, 341, 678);
|
||
init_color(COLOR_YELLOW, 965, 502, 259);
|
||
panel = COLOR_BLACK;
|
||
primary = COLOR_CYAN;
|
||
muted = COLOR_WHITE;
|
||
text = COLOR_WHITE;
|
||
success = COLOR_GREEN;
|
||
err = COLOR_RED;
|
||
assist = COLOR_MAGENTA;
|
||
tool = COLOR_YELLOW;
|
||
prompt_c = COLOR_CYAN;
|
||
}
|
||
|
||
/* Header is dark too — primary only as text accent (not a bright bar). */
|
||
init_pair(CP_HEADER, primary, panel);
|
||
init_pair(CP_STATUS, muted, panel);
|
||
init_pair(CP_INPUT, text, panel);
|
||
init_pair(CP_PRIMARY, primary, panel);
|
||
init_pair(CP_ASSIST, assist, panel);
|
||
init_pair(CP_MUTED, muted, panel);
|
||
init_pair(CP_SUCCESS, success, panel);
|
||
init_pair(CP_ERROR, err, panel);
|
||
init_pair(CP_TOOL, tool, panel);
|
||
init_pair(CP_PANEL, text, panel);
|
||
init_pair(CP_SGR_GREEN, success, panel);
|
||
init_pair(CP_SGR_YELLOW, tool, panel);
|
||
init_pair(CP_SGR_CYAN, prompt_c, panel);
|
||
if (COLORS >= 256)
|
||
init_pair(CP_SGR_MAGENTA, 176, panel);
|
||
else
|
||
init_pair(CP_SGR_MAGENTA, COLOR_MAGENTA, panel);
|
||
|
||
/* Diff rows: high-contrast xterm-256 palette only.
|
||
* Do NOT init_color() cube indices 16–21 — many terminals ignore it and leave
|
||
* default blues, which made add/del rows unreadable (dark-on-blue). */
|
||
if (COLORS >= 256) {
|
||
init_pair(CP_DIFF_ADD, 157, 22); /* #afff87 on #005f00 */
|
||
init_pair(CP_DIFF_DEL, 203, 52); /* #ff5f5f on #5f0000 */
|
||
init_pair(CP_DIFF_CTX, 252, panel); /* bright gray on panel */
|
||
} else {
|
||
init_pair(CP_DIFF_ADD, COLOR_GREEN, COLOR_BLACK);
|
||
init_pair(CP_DIFF_DEL, COLOR_RED, COLOR_BLACK);
|
||
init_pair(CP_DIFF_CTX, COLOR_WHITE, COLOR_BLACK);
|
||
}
|
||
if (COLORS < 256) {
|
||
box = side = panel;
|
||
accent = COLORS >= 8 && !can_change_color() ? COLOR_BLUE : assist;
|
||
}
|
||
init_pair(CP_BOX, text, box);
|
||
init_pair(CP_BOX_MUTED, muted, box);
|
||
init_pair(CP_BOX_ACCENT, accent, box);
|
||
init_pair(CP_BOX_PRIMARY, primary, box);
|
||
init_pair(CP_BOX_BRIGHT, bright, box);
|
||
init_pair(CP_SIDE, text, side);
|
||
init_pair(CP_SIDE_MUTED, muted, side);
|
||
init_pair(CP_SIDE_BRIGHT, bright, side);
|
||
init_pair(CP_SIDE_SUCCESS, success, side);
|
||
init_pair(CP_SIDE_PRIMARY, primary, side);
|
||
init_pair(CP_ACCENT, accent, panel);
|
||
init_pair(CP_BRIGHT, bright, panel);
|
||
init_pair(CP_LOGO_DIM, logo_dim, panel);
|
||
init_pair(CP_SEL, panel, accent);
|
||
/* Same panel ground as the transcript — no raised strip / hairline seam. */
|
||
init_pair(CP_TOPBAR, bright, panel);
|
||
init_pair(CP_TOPBAR_MUTED, muted, panel);
|
||
init_pair(CP_TOPBAR_ACCENT, accent, panel);
|
||
}
|
||
|
||
/* Blank the window into the virtual screen before deleting it: refreshing
|
||
* stdscr doesn't reliably cover the cells a deleted window leaves behind. */
|
||
static void drop_win(WINDOW **w) {
|
||
if (*w) {
|
||
werase(*w);
|
||
wnoutrefresh(*w);
|
||
delwin(*w);
|
||
}
|
||
*w = NULL;
|
||
}
|
||
|
||
static void theme_paint_root(void) {
|
||
if (!HAS_COLOR())
|
||
return;
|
||
wbkgd(stdscr, COLOR_PAIR(CP_PANEL));
|
||
werase(stdscr);
|
||
wnoutrefresh(stdscr);
|
||
}
|
||
|
||
/* --- blocks --------------------------------------------------------------- */
|
||
|
||
static void block_clear(block_t *b) {
|
||
if (!b)
|
||
return;
|
||
free(b->title);
|
||
free(b->body);
|
||
free(b->render);
|
||
memset(b, 0, sizeof(*b));
|
||
}
|
||
|
||
static void blocks_init(blocks_t *bs) {
|
||
bs->items = calloc((size_t)BLOCKS_INIT, sizeof(block_t));
|
||
bs->n = 0;
|
||
bs->cap = bs->items ? BLOCKS_INIT : 0;
|
||
}
|
||
|
||
static void blocks_free(blocks_t *bs) {
|
||
if (!bs)
|
||
return;
|
||
for (int i = 0; i < bs->n; i++)
|
||
block_clear(&bs->items[i]);
|
||
free(bs->items);
|
||
bs->items = NULL;
|
||
bs->n = bs->cap = 0;
|
||
}
|
||
|
||
static void blocks_clear(blocks_t *bs) {
|
||
for (int i = 0; i < bs->n; i++)
|
||
block_clear(&bs->items[i]);
|
||
bs->n = 0;
|
||
}
|
||
|
||
static block_t *blocks_push(blocks_t *bs) {
|
||
if (!bs->items)
|
||
return NULL;
|
||
if (bs->n >= bs->cap) {
|
||
int nc = bs->cap * 2;
|
||
block_t *ni = realloc(bs->items, (size_t)nc * sizeof(block_t));
|
||
if (!ni)
|
||
return NULL;
|
||
memset(ni + bs->cap, 0, (size_t)(nc - bs->cap) * sizeof(block_t));
|
||
bs->items = ni;
|
||
bs->cap = nc;
|
||
}
|
||
block_t *b = &bs->items[bs->n++];
|
||
memset(b, 0, sizeof(*b));
|
||
return b;
|
||
}
|
||
|
||
static void block_set_title(block_t *b, const char *t) {
|
||
free(b->title);
|
||
b->title = xstrdup(t ? t : "");
|
||
}
|
||
|
||
static void block_set_body(block_t *b, const char *s, size_t n) {
|
||
free(b->body);
|
||
b->body = n ? xstrndup(s, n) : xstrdup("");
|
||
}
|
||
|
||
static void block_append_body(block_t *b, const char *s, size_t n) {
|
||
if (!s || !n)
|
||
return;
|
||
size_t old = b->body ? strlen(b->body) : 0;
|
||
char *nb = realloc(b->body, old + n + 1);
|
||
if (!nb)
|
||
return;
|
||
memcpy(nb + old, s, n);
|
||
nb[old + n] = 0;
|
||
b->body = nb;
|
||
}
|
||
|
||
/* Markdown → ANSI via Sashidown. */
|
||
static int md_to_ansi_dyn(const char *md, size_t md_len, char **outp,
|
||
size_t *out_len) {
|
||
if (!md || !outp)
|
||
return -1;
|
||
if (md_len == 0) {
|
||
*outp = NULL;
|
||
if (out_len)
|
||
*out_len = 0;
|
||
return 0;
|
||
}
|
||
size_t cap = ANSI_INIT;
|
||
char *out = malloc(cap);
|
||
if (!out)
|
||
return -1;
|
||
for (;;) {
|
||
size_t n = 0;
|
||
int rc = sashidown_convert(md, md_len, out, cap, &n, SASHIDOWN_FMT_ANSI);
|
||
if (rc == 0) {
|
||
*outp = out;
|
||
if (out_len)
|
||
*out_len = n;
|
||
return 0;
|
||
}
|
||
size_t nc = cap * 2;
|
||
if (nc < md_len * 4)
|
||
nc = md_len * 4;
|
||
if (nc > 16 * 1024 * 1024) {
|
||
free(out);
|
||
out = malloc(md_len + 1);
|
||
if (!out)
|
||
return -1;
|
||
memcpy(out, md, md_len);
|
||
out[md_len] = 0;
|
||
*outp = out;
|
||
if (out_len)
|
||
*out_len = md_len;
|
||
return 0;
|
||
}
|
||
char *nb = realloc(out, nc);
|
||
if (!nb) {
|
||
free(out);
|
||
return -1;
|
||
}
|
||
out = nb;
|
||
cap = nc;
|
||
}
|
||
}
|
||
|
||
static void block_render_assistant(block_t *b) {
|
||
free(b->render);
|
||
b->render = NULL;
|
||
if (!b->body || !b->body[0])
|
||
return;
|
||
char *ansi = NULL;
|
||
size_t n = 0;
|
||
if (md_to_ansi_dyn(b->body, strlen(b->body), &ansi, &n) == 0 && ansi) {
|
||
b->render = ansi;
|
||
}
|
||
}
|
||
|
||
static const char *tool_prefix(const char *name) {
|
||
if (!name)
|
||
return "•";
|
||
if (strcmp(name, "run_shell") == 0)
|
||
return "$";
|
||
if (cst_tool_is_write(name))
|
||
return "←";
|
||
if (strcmp(name, "read_file") == 0)
|
||
return "✱";
|
||
if (strcmp(name, "web_fetch") == 0)
|
||
return "%";
|
||
return "•";
|
||
}
|
||
|
||
static void tool_arg_summary(const ctl_tool_call_t *call, char *out,
|
||
size_t out_sz) {
|
||
static const struct {
|
||
const char *tool, *key;
|
||
} PREFER[] = {{"run_shell", "cmd"}, {"read_file", "path"},
|
||
{"write_file", "path"}, {"edit_file", "path"},
|
||
{"delete_file", "path"}, {"move_file", "from"},
|
||
{"list_dir", "path"}, {"glob", "pattern"},
|
||
{"grep", "pattern"}, {"web_fetch", "url"},
|
||
{"ask_user", "question"}, {"apply_patch", "patch"},
|
||
{"task", "description"}, {"skill", "name"}};
|
||
out[0] = 0;
|
||
if (!call || out_sz == 0)
|
||
return;
|
||
if (strcmp(call->name, "todo_write") == 0) {
|
||
snprintf(out, out_sz, "update task list");
|
||
return;
|
||
}
|
||
const char *prefer = NULL;
|
||
for (size_t i = 0; i < sizeof(PREFER) / sizeof(PREFER[0]); i++)
|
||
if (strcmp(call->name, PREFER[i].tool) == 0)
|
||
prefer = PREFER[i].key;
|
||
int pick = -1;
|
||
for (int i = 0; i < call->nargs; i++)
|
||
if (prefer && strcmp(call->args[i].key, prefer) == 0)
|
||
pick = i;
|
||
if (pick < 0 && call->nargs > 0)
|
||
pick = 0;
|
||
if (pick < 0 || !call->args[pick].value)
|
||
return;
|
||
const char *v = call->args[pick].value;
|
||
size_t len = call->args[pick].value_len ? call->args[pick].value_len
|
||
: strlen(v);
|
||
size_t nl = strcspn(v, "\n");
|
||
int cut = len > 80 || nl < len;
|
||
if (nl < len)
|
||
len = nl;
|
||
if (len > 80)
|
||
len = cst_utf8_floor(v, 80);
|
||
snprintf(out, out_sz, "%.*s%s", (int)len, v, cut ? "…" : "");
|
||
}
|
||
|
||
static void tui_lock(tui_t *t) { pthread_mutex_lock(&t->mu); }
|
||
static void tui_unlock(tui_t *t) { pthread_mutex_unlock(&t->mu); }
|
||
static void tui_mark_dirty(tui_t *t) { t->dirty = 1; }
|
||
|
||
static void set_status_locked(tui_t *t, const char *msg) {
|
||
snprintf(t->status_msg, sizeof(t->status_msg), "%s", msg ? msg : "");
|
||
tui_mark_dirty(t);
|
||
}
|
||
|
||
static void set_status(tui_t *t, const char *msg) {
|
||
tui_lock(t);
|
||
set_status_locked(t, msg);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
/* Empty status_msg means "show ~/path:branch" in the status row. */
|
||
static void status_idle(tui_t *t) {
|
||
if (t->queue_n > 0)
|
||
snprintf(t->status_msg, sizeof(t->status_msg),
|
||
"%d queued · empty Enter sends the next one", t->queue_n);
|
||
else
|
||
t->status_msg[0] = 0;
|
||
}
|
||
|
||
/* --- live assistant ------------------------------------------------------- */
|
||
|
||
static void live_end_cleanup(tui_t *t) {
|
||
block_clear(&t->live);
|
||
free(t->md_live);
|
||
t->md_live = NULL;
|
||
t->md_len = 0;
|
||
t->md_cap = 0;
|
||
t->md_shown = 0;
|
||
t->md_rendered = 0;
|
||
t->drip_carry = 0;
|
||
free(t->md_prefix_ansi);
|
||
t->md_prefix_ansi = NULL;
|
||
t->md_prefix = 0;
|
||
t->as_live = 0;
|
||
}
|
||
|
||
#define LIVE_PREFIX_MIN (16 * 1024)
|
||
#define LIVE_RENDER_MS 50
|
||
|
||
static int is_fence_line(const char *s, size_t n) {
|
||
size_t i = 0;
|
||
while (i < n && i < 3 && s[i] == ' ')
|
||
i++;
|
||
return i + 3 <= n && ((s[i] == '`' && s[i + 1] == '`' && s[i + 2] == '`') ||
|
||
(s[i] == '~' && s[i + 1] == '~' && s[i + 2] == '~'));
|
||
}
|
||
|
||
/* Scans md[from, n): returns whether it ends inside a code fence, and the
|
||
* offset just past the last blank line outside any fence (or from). */
|
||
static int md_scan(const char *md, size_t from, size_t n, size_t *stable) {
|
||
int fence = 0;
|
||
*stable = from;
|
||
size_t i = from;
|
||
while (i < n) {
|
||
const char *nl = memchr(md + i, '\n', n - i);
|
||
size_t e = nl ? (size_t)(nl - md) : n;
|
||
if (!nl)
|
||
break; /* the last, unfinished line never counts */
|
||
if (is_fence_line(md + i, e - i))
|
||
fence = !fence;
|
||
else if (!fence && e == i)
|
||
*stable = e + 1;
|
||
i = e + 1;
|
||
}
|
||
if (i < n && is_fence_line(md + i, n - i))
|
||
fence = !fence;
|
||
return fence;
|
||
}
|
||
|
||
static char *md_render_part(const char *md, size_t n, int close_fence) {
|
||
char *src = malloc(n + 6);
|
||
if (!src)
|
||
return NULL;
|
||
memcpy(src, md, n);
|
||
size_t sn = n;
|
||
if (close_fence) {
|
||
memcpy(src + sn, "\n```\n", 5);
|
||
sn += 5;
|
||
}
|
||
src[sn] = 0;
|
||
char *ansi = NULL;
|
||
size_t an = 0;
|
||
if (md_to_ansi_dyn(src, sn, &ansi, &an) != 0)
|
||
ansi = NULL;
|
||
free(src);
|
||
return ansi;
|
||
}
|
||
|
||
/* Markdown → ANSI while streaming: unfinished fences are closed for display,
|
||
* and long replies keep the finished prefix rendered once. Renders only the
|
||
* dripped prefix [0, md_shown). Caller locks. */
|
||
static void live_render(tui_t *t) {
|
||
if (!t->as_live || !t->md_live || t->md_rendered == t->md_shown)
|
||
return;
|
||
const char *md = t->md_live;
|
||
size_t n = t->md_shown, stable = 0;
|
||
if (n >= LIVE_PREFIX_MIN) {
|
||
md_scan(md, t->md_prefix, n, &stable);
|
||
if (stable > t->md_prefix + 4096) {
|
||
char *pre = md_render_part(md, stable, 0);
|
||
if (pre) {
|
||
free(t->md_prefix_ansi);
|
||
t->md_prefix_ansi = pre;
|
||
t->md_prefix = stable;
|
||
}
|
||
}
|
||
}
|
||
size_t from = t->md_prefix_ansi ? t->md_prefix : 0;
|
||
size_t dummy;
|
||
int fence = md_scan(md, from, n, &dummy);
|
||
char *tail = md_render_part(md + from, n - from, fence);
|
||
if (!tail)
|
||
return;
|
||
free(t->live.render);
|
||
if (from && t->md_prefix_ansi) {
|
||
size_t a = strlen(t->md_prefix_ansi), b = strlen(tail);
|
||
t->live.render = malloc(a + b + 1);
|
||
if (t->live.render) {
|
||
memcpy(t->live.render, t->md_prefix_ansi, a);
|
||
memcpy(t->live.render + a, tail, b + 1);
|
||
}
|
||
free(tail);
|
||
} else {
|
||
t->live.render = tail;
|
||
}
|
||
t->md_rendered = n;
|
||
clock_gettime(CLOCK_MONOTONIC, &t->md_render_at);
|
||
}
|
||
|
||
static long ms_since(const struct timespec *a) {
|
||
struct timespec now;
|
||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||
return (long)(now.tv_sec - a->tv_sec) * 1000 +
|
||
(long)(now.tv_nsec - a->tv_nsec) / 1000000;
|
||
}
|
||
|
||
/* Stream frontier as a decode wave: plasma tip → braille wake → lock flash →
|
||
* plaintext. Returns a malloc'd ANSI copy, or NULL to use the source as-is. */
|
||
#define GLITCH_FRONT 42
|
||
|
||
static unsigned glitch_mix(unsigned x) {
|
||
x ^= x << 13;
|
||
x ^= x >> 17;
|
||
x ^= x << 5;
|
||
return x;
|
||
}
|
||
|
||
static size_t glitch_put(char *out, size_t o, const char *s) {
|
||
size_t n = strlen(s);
|
||
memcpy(out + o, s, n);
|
||
return o + n;
|
||
}
|
||
|
||
static char *glitch_stream_frontier(const char *src, int spin) {
|
||
if (!src || !src[0])
|
||
return NULL;
|
||
size_t n = strlen(src);
|
||
/* SGR wraps + glyph swaps need headroom. */
|
||
char *out = malloc(n + (size_t)GLITCH_FRONT * 24 + 64);
|
||
if (!out)
|
||
return NULL;
|
||
size_t starts[GLITCH_FRONT];
|
||
size_t lens[GLITCH_FRONT];
|
||
int nv = 0;
|
||
size_t i = 0;
|
||
while (i < n) {
|
||
if (src[i] == '\033' && i + 1 < n && src[i + 1] == '[') {
|
||
size_t j = i + 2;
|
||
while (j < n && src[j] != 'm')
|
||
j++;
|
||
i = j < n ? j + 1 : n;
|
||
continue;
|
||
}
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(src + i, n - i, &wc, &eaten);
|
||
if (eaten == 0)
|
||
break;
|
||
if (wc != L'\n' && wc != L'\r') {
|
||
if (nv < GLITCH_FRONT) {
|
||
starts[nv] = i;
|
||
lens[nv] = eaten;
|
||
nv++;
|
||
} else {
|
||
memmove(starts, starts + 1, (GLITCH_FRONT - 1) * sizeof(starts[0]));
|
||
memmove(lens, lens + 1, (GLITCH_FRONT - 1) * sizeof(lens[0]));
|
||
starts[GLITCH_FRONT - 1] = i;
|
||
lens[GLITCH_FRONT - 1] = eaten;
|
||
}
|
||
}
|
||
i += eaten;
|
||
}
|
||
if (nv < 3) {
|
||
free(out);
|
||
return NULL;
|
||
}
|
||
|
||
/* Braille / phosphor — quieter than katakana soup, still cyber. */
|
||
static const char *const uni_plasma[] = {
|
||
"⣿", "⣾", "⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾", "⠁", "⠂",
|
||
"⠄", "⡀", "⢀", "⠠", "⠐", "⠈", "░", "▒", "▓"};
|
||
static const char *const uni_wake[] = {
|
||
"·", "˙", "˚", "˳", "⠁", "⠉", "⠋", "⠛", "⠟", "⠿", "∙", "•"};
|
||
static const char *const uni_caret[] = {"▍", "▋", "▌", "▎", "▏", "▎", "▌", "▋"};
|
||
static const char *const asc_plasma[] = {
|
||
"#", "@", "%", "&", "*", "+", "=", "?", "/", "\\", "|", ":",
|
||
"0", "1", "X", "Z", "~", "^", ".", ":", ";"};
|
||
static const char *const asc_wake[] = {
|
||
".", ":", "-", "~", "*", "+", "o", "x", "=", ";", "`", "'"};
|
||
static const char *const asc_caret[] = {"#", "=", "#", "-", "#", "=", "#", "-"};
|
||
const char *const *plasma = g_ascii ? asc_plasma : uni_plasma;
|
||
const char *const *wake = g_ascii ? asc_wake : uni_wake;
|
||
const char *const *caret = g_ascii ? asc_caret : uni_caret;
|
||
const int nplasma = g_ascii ? (int)(sizeof(asc_plasma) / sizeof(asc_plasma[0]))
|
||
: (int)(sizeof(uni_plasma) / sizeof(uni_plasma[0]));
|
||
const int nwake = g_ascii ? (int)(sizeof(asc_wake) / sizeof(asc_wake[0]))
|
||
: (int)(sizeof(uni_wake) / sizeof(uni_wake[0]));
|
||
const int ncaret = g_ascii ? (int)(sizeof(asc_caret) / sizeof(asc_caret[0]))
|
||
: (int)(sizeof(uni_caret) / sizeof(uni_caret[0]));
|
||
|
||
/* Traveling decode crest — sweeps from older wake toward the tip. */
|
||
int wave = (spin * 2) % (nv + 6);
|
||
|
||
size_t o = 0;
|
||
size_t cursor = 0;
|
||
for (int v = 0; v < nv; v++) {
|
||
size_t st = starts[v];
|
||
if (st > cursor) {
|
||
memcpy(out + o, src + cursor, st - cursor);
|
||
o += st - cursor;
|
||
}
|
||
int age = nv - 1 - v; /* 0 = newest (tip) */
|
||
unsigned h = glitch_mix((unsigned)spin * 131u + (unsigned)v * 17u +
|
||
(unsigned)st * 3u);
|
||
int on_wave = (age >= wave - 2 && age <= wave + 1);
|
||
|
||
if (age == 0) {
|
||
/* Write head sits ON the tip glyph (never appended after a newline,
|
||
* which would park a lonely block at column 0 of the next row). */
|
||
o = glitch_put(out, o, "\033[1;96m");
|
||
o = glitch_put(out, o, caret[(unsigned)spin % (unsigned)ncaret]);
|
||
o = glitch_put(out, o, "\033[22;39m");
|
||
} else if (age <= 3) {
|
||
/* Plasma just behind the head. */
|
||
const char *g = plasma[(h + (unsigned)spin) % (unsigned)nplasma];
|
||
o = glitch_put(out, o, "\033[1;96m");
|
||
o = glitch_put(out, o, g);
|
||
o = glitch_put(out, o, "\033[22;39m");
|
||
} else if (age <= 18) {
|
||
/* Wake: ghost real text; crest braids phosphor as it sweeps. */
|
||
int dens = 20 - age;
|
||
int hit = on_wave || (int)(h % 16) < dens / 3;
|
||
if (on_wave) {
|
||
const char *g = wake[h % (unsigned)nwake];
|
||
o = glitch_put(out, o, "\033[1;96m");
|
||
o = glitch_put(out, o, g);
|
||
o = glitch_put(out, o, "\033[22;39m");
|
||
} else if (hit && (spin + v) % 4 != 0) {
|
||
const char *g = wake[h % (unsigned)nwake];
|
||
o = glitch_put(out, o, "\033[2;36m");
|
||
o = glitch_put(out, o, g);
|
||
o = glitch_put(out, o, "\033[22;39m");
|
||
} else if (age <= 8 && (spin + age) % 5 == 0) {
|
||
o = glitch_put(out, o, "\033[1;97m");
|
||
memcpy(out + o, src + st, lens[v]);
|
||
o += lens[v];
|
||
o = glitch_put(out, o, "\033[22;39m");
|
||
} else {
|
||
o = glitch_put(out, o, "\033[2m");
|
||
memcpy(out + o, src + st, lens[v]);
|
||
o += lens[v];
|
||
o = glitch_put(out, o, "\033[22m");
|
||
}
|
||
} else if (age <= 22 && (spin + age) % 6 == 0) {
|
||
o = glitch_put(out, o, "\033[1m");
|
||
memcpy(out + o, src + st, lens[v]);
|
||
o += lens[v];
|
||
o = glitch_put(out, o, "\033[22m");
|
||
} else {
|
||
memcpy(out + o, src + st, lens[v]);
|
||
o += lens[v];
|
||
}
|
||
cursor = st + lens[v];
|
||
}
|
||
/* Trailing newlines / SGR after the last glyph stay as-is — caret already
|
||
* sits on the tip character above, so it tracks the text, not col 0. */
|
||
if (cursor < n) {
|
||
memcpy(out + o, src + cursor, n - cursor);
|
||
o += n - cursor;
|
||
}
|
||
out[o] = 0;
|
||
return out;
|
||
}
|
||
|
||
static size_t utf8_codepoints(const char *s, size_t n) {
|
||
size_t i = 0, c = 0;
|
||
while (i < n) {
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(s + i, n - i, &wc, &eaten);
|
||
if (eaten == 0)
|
||
break;
|
||
i += eaten;
|
||
c++;
|
||
}
|
||
return c;
|
||
}
|
||
|
||
static size_t utf8_advance_n(const char *s, size_t n, size_t max_chars) {
|
||
size_t i = 0, c = 0;
|
||
while (i < n && c < max_chars) {
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(s + i, n - i, &wc, &eaten);
|
||
if (eaten == 0)
|
||
break;
|
||
i += eaten;
|
||
c++;
|
||
}
|
||
return i;
|
||
}
|
||
|
||
static int isqrt_sz(size_t n) {
|
||
if (n <= 1)
|
||
return (int)n;
|
||
size_t x = n, y = (x + 1) / 2;
|
||
while (y < x) {
|
||
x = y;
|
||
y = (x + n / x) / 2;
|
||
}
|
||
return (int)x;
|
||
}
|
||
|
||
/* Non-linear drip: slow and readable near the tip; accelerates with backlog
|
||
* so a fast model never leaves us many seconds behind. */
|
||
static double drip_cps(size_t backlog_chars) {
|
||
if (backlog_chars == 0)
|
||
return 0;
|
||
if (backlog_chars <= 6)
|
||
return 10.0; /* hold the tip so the decode wave is readable */
|
||
if (backlog_chars <= 40)
|
||
return 16.0 + (double)backlog_chars * 0.85;
|
||
/* ~ 22 + 14*sqrt(backlog) */
|
||
double cps = 22.0 + 14.0 * (double)isqrt_sz(backlog_chars);
|
||
if (backlog_chars > 600)
|
||
cps = (double)backlog_chars * 8.0; /* drain hard */
|
||
if (cps > 900.0)
|
||
cps = 900.0;
|
||
return cps;
|
||
}
|
||
|
||
static int live_apply_shown(tui_t *t) {
|
||
free(t->live.body);
|
||
t->live.body = t->md_shown ? xstrndup(t->md_live, t->md_shown) : xstrdup("");
|
||
t->md_rendered = 0; /* force live_render */
|
||
live_render(t);
|
||
return 1;
|
||
}
|
||
|
||
/* Advance md_shown toward md_len. Returns 1 if the display changed. */
|
||
static int live_drip(tui_t *t) {
|
||
if (!t->as_live || !t->md_live || t->md_shown >= t->md_len)
|
||
return 0;
|
||
long dt = ms_since(&t->drip_at);
|
||
if (dt < 12)
|
||
return 0;
|
||
if (dt > 200)
|
||
dt = 200;
|
||
clock_gettime(CLOCK_MONOTONIC, &t->drip_at);
|
||
|
||
size_t backlog =
|
||
utf8_codepoints(t->md_live + t->md_shown, t->md_len - t->md_shown);
|
||
if (backlog == 0) {
|
||
t->md_shown = t->md_len;
|
||
return live_apply_shown(t);
|
||
}
|
||
t->drip_carry += drip_cps(backlog) * ((double)dt / 1000.0);
|
||
size_t want = (size_t)t->drip_carry;
|
||
if (want < 1) {
|
||
if (dt >= 70 && backlog > 0) {
|
||
want = 1; /* guarantee tip motion */
|
||
t->drip_carry = 0;
|
||
} else {
|
||
return 0;
|
||
}
|
||
} else {
|
||
t->drip_carry -= (double)want;
|
||
}
|
||
if (want > backlog)
|
||
want = backlog;
|
||
if (want > 64)
|
||
want = 64; /* hard per-frame cap for readability */
|
||
size_t adv =
|
||
utf8_advance_n(t->md_live + t->md_shown, t->md_len - t->md_shown, want);
|
||
if (adv == 0)
|
||
return 0;
|
||
t->md_shown += adv;
|
||
return live_apply_shown(t);
|
||
}
|
||
|
||
static void live_flush(tui_t *t) {
|
||
if (!t->as_live || !t->md_live)
|
||
return;
|
||
if (t->md_shown >= t->md_len)
|
||
return;
|
||
t->md_shown = t->md_len;
|
||
t->drip_carry = 0;
|
||
(void)live_apply_shown(t);
|
||
}
|
||
|
||
static void live_begin(tui_t *t) {
|
||
live_end_cleanup(t);
|
||
t->md_cap = MD_INIT;
|
||
t->md_live = calloc(1, t->md_cap);
|
||
t->md_len = 0;
|
||
t->md_shown = 0;
|
||
t->drip_carry = 0;
|
||
clock_gettime(CLOCK_MONOTONIC, &t->drip_at);
|
||
t->live.kind = BLK_ASSISTANT;
|
||
block_set_title(&t->live, "Assistant");
|
||
t->live.expanded = 1;
|
||
t->as_live = 1;
|
||
}
|
||
|
||
static void live_feed(tui_t *t, const char *text, size_t len) {
|
||
if (!t->as_live || !text || !len)
|
||
return;
|
||
if (t->md_len + len + 1 > t->md_cap) {
|
||
size_t nc = t->md_cap * 2;
|
||
while (nc < t->md_len + len + 1)
|
||
nc *= 2;
|
||
char *nb = realloc(t->md_live, nc);
|
||
if (!nb)
|
||
return;
|
||
t->md_live = nb;
|
||
t->md_cap = nc;
|
||
}
|
||
memcpy(t->md_live + t->md_len, text, len);
|
||
t->md_len += len;
|
||
t->md_live[t->md_len] = 0;
|
||
/* Network buffer only — screen catch-up is live_drip on the UI tick. */
|
||
}
|
||
|
||
static void live_seal(tui_t *t) {
|
||
if (!t->as_live)
|
||
return;
|
||
live_flush(t);
|
||
block_t *b = blocks_push(&t->hist);
|
||
if (b) {
|
||
b->kind = BLK_ASSISTANT;
|
||
block_set_title(b, "Assistant");
|
||
if (t->md_len)
|
||
block_set_body(b, t->md_live, t->md_len);
|
||
else if (t->live.body)
|
||
block_set_body(b, t->live.body, strlen(t->live.body));
|
||
b->expanded = 1;
|
||
block_render_assistant(b);
|
||
}
|
||
live_end_cleanup(t);
|
||
}
|
||
|
||
static void collapse_open_think(tui_t *t) {
|
||
if (t->think_idx < 0 || t->think_idx >= t->hist.n)
|
||
return;
|
||
block_t *th = &t->hist.items[t->think_idx];
|
||
if (th->kind != BLK_THINK)
|
||
return;
|
||
if (!t->show_thinking)
|
||
th->expanded = 0;
|
||
char title[64];
|
||
if (t->think_timing) {
|
||
struct timespec now;
|
||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||
double sec = (double)(now.tv_sec - t->think_t0.tv_sec) +
|
||
(double)(now.tv_nsec - t->think_t0.tv_nsec) / 1e9;
|
||
if (sec < 0)
|
||
sec = 0;
|
||
if (sec < 1.0)
|
||
snprintf(title, sizeof(title), "Thought: %dms", (int)(sec * 1000));
|
||
else
|
||
snprintf(title, sizeof(title), "Thought: %.1fs", sec);
|
||
t->think_timing = 0;
|
||
} else {
|
||
snprintf(title, sizeof(title), "Thought");
|
||
}
|
||
block_set_title(th, title);
|
||
t->think_idx = -1;
|
||
}
|
||
|
||
static int find_tool_block(tui_t *t, const char *id) {
|
||
if (!id || !id[0])
|
||
return -1;
|
||
for (int i = t->hist.n - 1; i >= 0; i--) {
|
||
block_t *b = &t->hist.items[i];
|
||
if (b->kind == BLK_TOOL && strcmp(b->tool_id, id) == 0)
|
||
return i;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
#define msg_is_tool_result cst_message_is_tool_result
|
||
|
||
/* Strip the "[tool …]" / "result <id> exit=N <n>" header and "end" trailer. */
|
||
static void tool_result_view(const char *content, const char **body,
|
||
size_t *len) {
|
||
const char *s = content ? content : "";
|
||
size_t n = strlen(s);
|
||
int framed = strncmp(s, "result ", 7) == 0;
|
||
if (framed || strncmp(s, "[tool ", 6) == 0) {
|
||
const char *nl = strchr(s, '\n');
|
||
if (nl) {
|
||
n -= (size_t)(nl + 1 - s);
|
||
s = nl + 1;
|
||
} else {
|
||
n = 0;
|
||
}
|
||
}
|
||
if (framed && n >= 4 && memcmp(s + n - 4, "\nend", 4) == 0)
|
||
n -= 4;
|
||
else if (framed && n == 3 && memcmp(s, "end", 3) == 0)
|
||
n = 0;
|
||
*body = s;
|
||
*len = n;
|
||
}
|
||
|
||
static void attach_tool_output_from_session(tui_t *t, block_t *b) {
|
||
if (!b || b->kind != BLK_TOOL || !b->tool_id[0])
|
||
return;
|
||
cst_session_t *s = cst_agent_session(t->agent);
|
||
if (!s)
|
||
return;
|
||
cst_session_lock(s);
|
||
int n = cst_session_count(s);
|
||
for (int i = n - 1; i >= 0; i--) {
|
||
const cst_message_t *m = cst_session_get(s, i);
|
||
if (!msg_is_tool_result(m))
|
||
continue;
|
||
if (strcmp(m->tool_call_id, b->tool_id) != 0)
|
||
continue;
|
||
const char *body;
|
||
size_t len;
|
||
tool_result_view(m->content, &body, &len);
|
||
block_set_body(b, body, len);
|
||
break;
|
||
}
|
||
cst_session_unlock(s);
|
||
}
|
||
|
||
/* --- drawing: SGR --------------------------------------------------------- */
|
||
|
||
typedef struct {
|
||
int attrs;
|
||
int pair;
|
||
} sty_t;
|
||
|
||
static void sgr_apply(sty_t *st, int code) {
|
||
switch (code) {
|
||
case 0:
|
||
st->attrs = 0;
|
||
st->pair = 0;
|
||
break;
|
||
case 1:
|
||
st->attrs |= A_BOLD;
|
||
break;
|
||
case 2:
|
||
st->attrs |= A_DIM;
|
||
break;
|
||
case 3:
|
||
#ifdef A_ITALIC
|
||
st->attrs |= A_ITALIC;
|
||
#endif
|
||
break;
|
||
case 4:
|
||
st->attrs |= A_UNDERLINE;
|
||
break;
|
||
case 7:
|
||
st->attrs |= A_REVERSE;
|
||
break;
|
||
case 22:
|
||
st->attrs &= ~(A_BOLD | A_DIM);
|
||
break;
|
||
case 23:
|
||
#ifdef A_ITALIC
|
||
st->attrs &= ~A_ITALIC;
|
||
#endif
|
||
break;
|
||
case 24:
|
||
st->attrs &= ~A_UNDERLINE;
|
||
break;
|
||
case 27:
|
||
st->attrs &= ~A_REVERSE;
|
||
break;
|
||
case 31:
|
||
case 91:
|
||
st->pair = CP_ERROR;
|
||
break;
|
||
case 32:
|
||
case 92:
|
||
st->pair = CP_SGR_GREEN;
|
||
break;
|
||
case 33:
|
||
case 93:
|
||
st->pair = CP_SGR_YELLOW;
|
||
break;
|
||
case 36:
|
||
case 96:
|
||
st->pair = CP_SGR_CYAN;
|
||
break;
|
||
case 35:
|
||
case 95:
|
||
st->pair = CP_SGR_MAGENTA;
|
||
break;
|
||
case 34:
|
||
case 94:
|
||
st->pair = CP_ACCENT;
|
||
break;
|
||
case 90:
|
||
st->pair = CP_MUTED;
|
||
break;
|
||
case 97:
|
||
st->pair = CP_BRIGHT;
|
||
break;
|
||
case 39:
|
||
st->pair = 0;
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
static size_t sgr_parse(const char *s, size_t n, sty_t *st) {
|
||
if (n < 3 || (unsigned char)s[0] != 0x1b || s[1] != '[')
|
||
return 0;
|
||
size_t i = 2;
|
||
int val = 0, have = 0;
|
||
while (i < n) {
|
||
unsigned char c = (unsigned char)s[i++];
|
||
if (c >= '0' && c <= '9') {
|
||
val = val * 10 + (c - '0');
|
||
have = 1;
|
||
} else if (c == ';') {
|
||
sgr_apply(st, have ? val : 0);
|
||
val = 0;
|
||
have = 0;
|
||
} else if (c == 'm') {
|
||
sgr_apply(st, have ? val : 0);
|
||
return i;
|
||
} else {
|
||
return 0;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
static wchar_t ascii_glyph(wchar_t wc) {
|
||
if (!g_ascii || wc < 128)
|
||
return wc;
|
||
switch (wc) {
|
||
case 0x2503: /* ┃ */
|
||
case 0x2502: /* │ */
|
||
return L'|';
|
||
case 0x2500: /* ─ */
|
||
case 0x00b7: /* · */
|
||
return L'-';
|
||
case 0x25bc: /* ▼ */
|
||
case 0x2193: /* ↓ */
|
||
return L'v';
|
||
case 0x25b6: /* ▶ */
|
||
case 0x25b8: /* ▸ */
|
||
case 0x2192: /* → */
|
||
return L'>';
|
||
case 0x2190: /* ← */
|
||
return L'<';
|
||
case 0x2191: /* ↑ */
|
||
return L'^';
|
||
case 0x2026: /* … */
|
||
return L'.';
|
||
case 0x2713: /* ✓ */
|
||
return L'x';
|
||
case 0x25cb: /* ○ */
|
||
return L'o';
|
||
case 0x25cf: /* ● */
|
||
case 0x2022: /* • */
|
||
return L'*';
|
||
case 0x25a3: /* ▣ */
|
||
return L'#';
|
||
default:
|
||
return g_utf8 ? wc : L'?';
|
||
}
|
||
}
|
||
|
||
static int put_wc(WINDOW *win, int y, int col, wchar_t wc, int attrs) {
|
||
wc = ascii_glyph(wc);
|
||
cchar_t cc;
|
||
wchar_t wstr[2] = {wc, 0};
|
||
short pair = (short)PAIR_NUMBER(attrs);
|
||
attr_t a = (attr_t)(attrs & ~(attr_t)A_COLOR);
|
||
if (setcchar(&cc, wstr, a, pair, NULL) == ERR) {
|
||
if (wc < 128)
|
||
mvwaddch(win, y, col, (chtype)wc | (chtype)attrs);
|
||
else
|
||
mvwaddstr(win, y, col, "?");
|
||
return 1;
|
||
}
|
||
mvwadd_wch(win, y, col, &cc);
|
||
return utf8_width(wc);
|
||
}
|
||
|
||
/* Flatten blocks into display lines (plain text with optional ANSI in body). */
|
||
typedef struct {
|
||
char *text; /* owned line content (may include ANSI) */
|
||
int pair; /* base color pair for whole line (0 = default) */
|
||
int bold;
|
||
int dim;
|
||
int indent; /* columns left blank before text */
|
||
int fill; /* pair painted across the whole row (0 = none) */
|
||
int bar; /* pair for a "┃" in column 0 (0 = none) */
|
||
int blk; /* hist block index, -1 for live / none */
|
||
int cont; /* soft-wrapped continuation of the previous row */
|
||
int cont_pad; /* leading columns of a continuation that only indent */
|
||
} dline_t;
|
||
|
||
static int g_dl_blk = -1; /* block being flattened, stamped into dline_t */
|
||
|
||
typedef struct {
|
||
dline_t *lines;
|
||
int n;
|
||
int cap;
|
||
} dlines_t;
|
||
|
||
static void dlines_free(dlines_t *dl) {
|
||
if (!dl)
|
||
return;
|
||
for (int i = 0; i < dl->n; i++)
|
||
free(dl->lines[i].text);
|
||
free(dl->lines);
|
||
dl->lines = NULL;
|
||
dl->n = dl->cap = 0;
|
||
}
|
||
|
||
static int dlines_push(dlines_t *dl, const char *text, int pair, int bold,
|
||
int dim) {
|
||
if (dl->n >= dl->cap) {
|
||
int nc = dl->cap ? dl->cap * 2 : 256;
|
||
dline_t *ni = realloc(dl->lines, (size_t)nc * sizeof(dline_t));
|
||
if (!ni)
|
||
return -1;
|
||
dl->lines = ni;
|
||
dl->cap = nc;
|
||
}
|
||
dline_t *L = &dl->lines[dl->n++];
|
||
L->text = xstrdup(text ? text : "");
|
||
L->pair = pair;
|
||
L->bold = bold;
|
||
L->dim = dim;
|
||
L->indent = 0;
|
||
L->fill = 0;
|
||
L->bar = 0;
|
||
L->blk = g_dl_blk;
|
||
L->cont = 0;
|
||
L->cont_pad = 0;
|
||
return 0;
|
||
}
|
||
|
||
/* Raised row: bar in column 0, the rest painted with `fill`. */
|
||
static void dlines_push_box(dlines_t *dl, const char *text, int pair,
|
||
int fill, int bar) {
|
||
if (dlines_push(dl, text, pair, 0, 0) == 0) {
|
||
dl->lines[dl->n - 1].fill = fill;
|
||
dl->lines[dl->n - 1].bar = bar;
|
||
}
|
||
}
|
||
|
||
static void push_wrapped(dlines_t *dl, const char *prefix, const char *body,
|
||
int width, int pair, int bold, int dim, int ansi) {
|
||
(void)ansi;
|
||
char line[4096];
|
||
int pref_cols = prefix ? utf8_cols(prefix, strlen(prefix)) : 0;
|
||
int body_w = width - pref_cols;
|
||
if (body_w < 8)
|
||
body_w = 8;
|
||
|
||
if (!body || !body[0]) {
|
||
snprintf(line, sizeof(line), "%s", prefix ? prefix : "");
|
||
dlines_push(dl, line, pair, bold, dim);
|
||
return;
|
||
}
|
||
|
||
/* Walk body; for ANSI text, wrap on visible columns skipping SGR. */
|
||
size_t n = strlen(body);
|
||
size_t i = 0;
|
||
int first = 1, soft = 0;
|
||
while (i < n && dl->n < MAX_RENDER_LINES) {
|
||
size_t start = i;
|
||
int col = 0, pad = 0;
|
||
while (i < n) {
|
||
size_t sg = 0;
|
||
if ((unsigned char)body[i] == 0x1b) {
|
||
sty_t dummy = {0};
|
||
sg = sgr_parse(body + i, n - i, &dummy);
|
||
if (!sg)
|
||
sg = ansi_skip(body + i, n - i);
|
||
if (sg) {
|
||
i += sg;
|
||
continue;
|
||
}
|
||
}
|
||
if (body[i] == '\n') {
|
||
i++;
|
||
break;
|
||
}
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(body + i, n - i, &wc, &eaten);
|
||
int cw = utf8_width(wc);
|
||
if (col + cw > body_w && col > 0)
|
||
break;
|
||
col += cw;
|
||
i += eaten;
|
||
if (col >= body_w)
|
||
break;
|
||
}
|
||
size_t chunk = i - start;
|
||
/* trim trailing newline already consumed */
|
||
if (chunk > 0 && body[start + chunk - 1] == '\n')
|
||
chunk--;
|
||
if (first && prefix) {
|
||
if (chunk + strlen(prefix) + 1 < sizeof(line)) {
|
||
memcpy(line, prefix, strlen(prefix));
|
||
memcpy(line + strlen(prefix), body + start, chunk);
|
||
line[strlen(prefix) + chunk] = 0;
|
||
} else {
|
||
snprintf(line, sizeof(line), "%s", prefix);
|
||
}
|
||
first = 0;
|
||
} else {
|
||
/* continuation indent matching prefix visual width */
|
||
pad = pref_cols;
|
||
if (pad < 0)
|
||
pad = 0;
|
||
if (pad > 40)
|
||
pad = 40;
|
||
memset(line, ' ', (size_t)pad);
|
||
if (chunk + (size_t)pad + 1 < sizeof(line)) {
|
||
memcpy(line + pad, body + start, chunk);
|
||
line[pad + chunk] = 0;
|
||
} else {
|
||
line[pad] = 0;
|
||
}
|
||
}
|
||
if (dlines_push(dl, line, pair, bold, dim) == 0 && soft) {
|
||
dl->lines[dl->n - 1].cont = 1;
|
||
dl->lines[dl->n - 1].cont_pad = pad;
|
||
}
|
||
soft = body[i - 1] != '\n';
|
||
}
|
||
}
|
||
|
||
static void push_body_preview(dlines_t *dl, const char *body, int width,
|
||
int pair, int expanded, int max_lines) {
|
||
if (!body || !body[0])
|
||
return;
|
||
if (expanded) {
|
||
push_wrapped(dl, " ", body, width, pair, 0, 1, 0);
|
||
return;
|
||
}
|
||
const char *p = body;
|
||
int shown = 0;
|
||
while (*p && shown < max_lines) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
char tmp[1024];
|
||
if (len >= sizeof(tmp))
|
||
len = sizeof(tmp) - 1;
|
||
memcpy(tmp, p, len);
|
||
tmp[len] = 0;
|
||
push_wrapped(dl, " ", tmp, width, pair, 0, 1, 0);
|
||
shown++;
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
if (*p)
|
||
dlines_push(dl, " …", pair, 0, 1);
|
||
}
|
||
|
||
static int body_looks_like_diff(const char *body) {
|
||
if (!body || !body[0])
|
||
return 0;
|
||
return strstr(body, "\n--- ") || strstr(body, "\n+++ ") ||
|
||
strstr(body, "\n@@ ") || strstr(body, "\n@@-") ||
|
||
strncmp(body, "--- ", 4) == 0 || strncmp(body, "diff ", 5) == 0 ||
|
||
strstr(body, "\n--- a/") || strstr(body, "\n+++ b/");
|
||
}
|
||
|
||
static void diff_extract_path(const char *body, const char *fallback,
|
||
char *out, size_t out_sz) {
|
||
if (!out || out_sz == 0)
|
||
return;
|
||
out[0] = 0;
|
||
if (body) {
|
||
const char *p = body;
|
||
while (*p) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
if (len > 6 && strncmp(p, "wrote ", 6) == 0) {
|
||
const char *s = p + 6;
|
||
size_t n = len - 6;
|
||
/* trim " (N bytes)" */
|
||
const char *sp = memchr(s, ' ', n);
|
||
if (sp)
|
||
n = (size_t)(sp - s);
|
||
if (n >= out_sz)
|
||
n = out_sz - 1;
|
||
memcpy(out, s, n);
|
||
out[n] = 0;
|
||
return;
|
||
}
|
||
if (len > 6 && strncmp(p, "+++ b/", 6) == 0) {
|
||
size_t n = len - 6;
|
||
if (n >= out_sz)
|
||
n = out_sz - 1;
|
||
memcpy(out, p + 6, n);
|
||
out[n] = 0;
|
||
return;
|
||
}
|
||
if (len > 6 && strncmp(p, "--- a/", 6) == 0) {
|
||
size_t n = len - 6;
|
||
if (n >= out_sz)
|
||
n = out_sz - 1;
|
||
memcpy(out, p + 6, n);
|
||
out[n] = 0;
|
||
/* keep scanning for +++ / wrote */
|
||
}
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
}
|
||
if (!out[0] && fallback && fallback[0]) {
|
||
snprintf(out, out_sz, "%s", fallback);
|
||
}
|
||
}
|
||
|
||
static int parse_hunk_at(const char *line, int *old_ln, int *new_ln) {
|
||
/* @@ -old[,n] +new[,n] @@ */
|
||
if (line[0] != '@' || line[1] != '@')
|
||
return 0;
|
||
const char *p = line;
|
||
while (*p && *p != '-')
|
||
p++;
|
||
if (*p != '-')
|
||
return 0;
|
||
p++;
|
||
int o = 0, n = 0;
|
||
if (sscanf(p, "%d", &o) != 1)
|
||
return 0;
|
||
while (*p && *p != '+')
|
||
p++;
|
||
if (*p != '+')
|
||
return 0;
|
||
p++;
|
||
if (sscanf(p, "%d", &n) != 1)
|
||
return 0;
|
||
if (old_ln)
|
||
*old_ln = o;
|
||
if (new_ln)
|
||
*new_ln = n;
|
||
return 1;
|
||
}
|
||
|
||
typedef struct {
|
||
int additions;
|
||
int deletions;
|
||
int content_lines;
|
||
} diff_stats_t;
|
||
|
||
static void diff_count_stats(const char *body, diff_stats_t *st) {
|
||
memset(st, 0, sizeof(*st));
|
||
if (!body)
|
||
return;
|
||
const char *p = body;
|
||
while (*p) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
if (len == 0) {
|
||
/* empty */
|
||
} else if (strncmp(p, "---", 3) == 0 || strncmp(p, "+++", 3) == 0 ||
|
||
strncmp(p, "diff ", 5) == 0 || strncmp(p, "index ", 6) == 0 ||
|
||
strncmp(p, "@@", 2) == 0 || strncmp(p, "wrote ", 6) == 0 ||
|
||
(p[0] == '[' && strstr(p, "tool "))) {
|
||
/* skip meta */
|
||
} else if (p[0] == '+') {
|
||
st->additions++;
|
||
st->content_lines++;
|
||
} else if (p[0] == '-') {
|
||
st->deletions++;
|
||
st->content_lines++;
|
||
} else if (p[0] == ' ' || p[0] == '\\') {
|
||
st->content_lines++;
|
||
}
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
}
|
||
|
||
/* OpenCode-style: strip ---/+++/@@, line numbers, add/del backgrounds. */
|
||
static void push_structured_diff(dlines_t *dl, const char *body, int width,
|
||
int unlimited, int fold_limit) {
|
||
if (!body || !body[0])
|
||
return;
|
||
int old_ln = 0, new_ln = 0;
|
||
int shown = 0;
|
||
int total_content = 0;
|
||
/* First pass: count content lines for fold message */
|
||
{
|
||
diff_stats_t st;
|
||
diff_count_stats(body, &st);
|
||
total_content = st.content_lines;
|
||
}
|
||
int limit = unlimited ? 1000000 : fold_limit;
|
||
if (limit < 1)
|
||
limit = 1;
|
||
|
||
const char *p = body;
|
||
while (*p) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
char raw[1024];
|
||
if (len >= sizeof(raw))
|
||
len = sizeof(raw) - 1;
|
||
memcpy(raw, p, len);
|
||
raw[len] = 0;
|
||
|
||
int skip = 0;
|
||
if (raw[0] == 0)
|
||
skip = 1;
|
||
else if (strncmp(raw, "wrote ", 6) == 0)
|
||
skip = 1;
|
||
else if (raw[0] == '[' && strstr(raw, "tool "))
|
||
skip = 1;
|
||
else if (strncmp(raw, "---", 3) == 0 || strncmp(raw, "+++", 3) == 0 ||
|
||
strncmp(raw, "diff ", 5) == 0 || strncmp(raw, "index ", 6) == 0)
|
||
skip = 1;
|
||
else if (strncmp(raw, "@@", 2) == 0) {
|
||
parse_hunk_at(raw, &old_ln, &new_ln);
|
||
skip = 1;
|
||
} else if (raw[0] == '\\') {
|
||
/* "\ No newline at end of file" */
|
||
skip = 1;
|
||
}
|
||
|
||
if (!skip && (raw[0] == '+' || raw[0] == '-' || raw[0] == ' ')) {
|
||
if (shown >= limit) {
|
||
/* keep counting via total_content; stop emitting */
|
||
break;
|
||
}
|
||
char kind = raw[0];
|
||
const char *content = raw + 1;
|
||
int lnum = 0;
|
||
char mark = ' ';
|
||
int pair = CP_DIFF_CTX;
|
||
if (kind == '+') {
|
||
lnum = new_ln++;
|
||
mark = '+';
|
||
pair = CP_DIFF_ADD;
|
||
} else if (kind == '-') {
|
||
lnum = old_ln++;
|
||
mark = '-';
|
||
pair = CP_DIFF_DEL;
|
||
} else {
|
||
lnum = new_ln;
|
||
old_ln++;
|
||
new_ln++;
|
||
mark = ' ';
|
||
pair = CP_DIFF_CTX;
|
||
}
|
||
|
||
char line[1200];
|
||
/* │ 12+│ content — left border + gutter + body */
|
||
int body_w = width - 8;
|
||
if (body_w < 8)
|
||
body_w = 8;
|
||
char clipped[1024];
|
||
size_t clen = strlen(content);
|
||
/* column-clip roughly by bytes for MVP */
|
||
if ((int)clen > body_w) {
|
||
int keep = body_w > 3 ? body_w - 3 : body_w;
|
||
memcpy(clipped, content, (size_t)keep);
|
||
if (body_w > 3)
|
||
memcpy(clipped + keep, "...", 4);
|
||
else
|
||
clipped[keep] = 0;
|
||
content = clipped;
|
||
}
|
||
snprintf(line, sizeof(line), "│%4d%c│ %s", lnum, mark, content);
|
||
/* Pad to width so row background fills like OpenCode. */
|
||
{
|
||
int cols = utf8_cols(line, strlen(line));
|
||
size_t llen = strlen(line);
|
||
while (cols < width && llen + 1 < sizeof(line)) {
|
||
line[llen++] = ' ';
|
||
line[llen] = 0;
|
||
cols++;
|
||
}
|
||
}
|
||
dlines_push(dl, line, pair, kind != ' ', 0);
|
||
shown++;
|
||
}
|
||
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
|
||
if (!unlimited && total_content > shown) {
|
||
char more[96];
|
||
snprintf(more, sizeof(more), "│ … %d more lines · /details",
|
||
total_content - shown);
|
||
dlines_push(dl, more, CP_MUTED, 0, 1);
|
||
}
|
||
}
|
||
|
||
static void push_diff_or_body(dlines_t *dl, const char *body, int width,
|
||
int default_pair, int expanded, int max_lines,
|
||
int details_all) {
|
||
if (!body || !body[0])
|
||
return;
|
||
if (!body_looks_like_diff(body)) {
|
||
push_body_preview(dl, body, width, default_pair, expanded || details_all,
|
||
max_lines);
|
||
return;
|
||
}
|
||
/* Auto-expanded write cards still fold at DIFF_FOLD_LINES unless /details. */
|
||
int unlimited = details_all;
|
||
int fold = expanded ? DIFF_FOLD_LINES : max_lines;
|
||
if (!expanded && !details_all)
|
||
fold = 0; /* title-only when collapsed */
|
||
if (fold == 0 && !unlimited)
|
||
return;
|
||
push_structured_diff(dl, body, width, unlimited, fold);
|
||
}
|
||
|
||
static void block_to_lines(const block_t *b, int width, int details_all,
|
||
dlines_t *dl) {
|
||
char head[512];
|
||
|
||
switch (b->kind) {
|
||
case BLK_USER: {
|
||
int n0 = dl->n;
|
||
dlines_push_box(dl, "", CP_BOX, CP_BOX, CP_BOX_ACCENT);
|
||
push_wrapped(dl, " ", b->body ? b->body : "", width - 1, CP_BOX, 0, 0, 0);
|
||
for (int i = n0 + 1; i < dl->n; i++) {
|
||
dl->lines[i].fill = CP_BOX;
|
||
dl->lines[i].bar = CP_BOX_ACCENT;
|
||
}
|
||
dlines_push_box(dl, "", CP_BOX, CP_BOX, CP_BOX_ACCENT);
|
||
dlines_push(dl, "", 0, 0, 0);
|
||
break;
|
||
}
|
||
|
||
case BLK_ASSISTANT: {
|
||
const char *src = b->render ? b->render : (b->body ? b->body : "");
|
||
push_wrapped(dl, "", src, width, CP_PANEL, 0, 0, 1);
|
||
dlines_push(dl, "", 0, 0, 0);
|
||
break;
|
||
}
|
||
|
||
case BLK_THINK:
|
||
snprintf(head, sizeof(head), "%s %s", b->expanded ? "-" : "+",
|
||
b->title ? b->title : "Thought");
|
||
dlines_push(dl, head, CP_PRIMARY, 0, 0);
|
||
if (b->expanded)
|
||
push_wrapped(dl, " ", b->body ? b->body : "", width, CP_MUTED, 0, 1, 0);
|
||
dlines_push(dl, "", 0, 0, 0);
|
||
break;
|
||
|
||
case BLK_FOOTER:
|
||
snprintf(head, sizeof(head), "\033[34m%s\033[0m %s",
|
||
g_ascii ? "#" : "▣", b->body ? b->body : "");
|
||
dlines_push(dl, head, CP_MUTED, 0, 0);
|
||
dlines_push(dl, "", 0, 0, 0);
|
||
break;
|
||
|
||
case BLK_TOOL: {
|
||
const char *px = tool_prefix(b->tool_name);
|
||
int exp = details_all || b->expanded;
|
||
int denied = (strncmp(b->meta, "denied", 6) == 0);
|
||
int is_write = (strcmp(b->tool_name, "write_file") == 0);
|
||
int is_file_edit = cst_tool_is_write(b->tool_name);
|
||
const char *verb_done = is_write ? "Wrote" : "Edit";
|
||
const char *verb_run = is_write ? "Write" : "Edit";
|
||
if (strcmp(b->tool_name, "delete_file") == 0)
|
||
verb_done = "Deleted", verb_run = "Delete";
|
||
else if (strcmp(b->tool_name, "move_file") == 0)
|
||
verb_done = "Moved", verb_run = "Move";
|
||
int done = (strncmp(b->meta, "exit=", 5) == 0) ||
|
||
(strcmp(b->meta, "done") == 0) ||
|
||
(b->body && body_looks_like_diff(b->body));
|
||
|
||
if (is_file_edit && !denied) {
|
||
char path[256];
|
||
diff_extract_path(b->body, b->title, path, sizeof(path));
|
||
if (!path[0] && b->title && b->title[0])
|
||
snprintf(path, sizeof(path), "%s", b->title);
|
||
if (!path[0])
|
||
snprintf(path, sizeof(path), "file");
|
||
|
||
diff_stats_t st = {0};
|
||
if (b->body)
|
||
diff_count_stats(b->body, &st);
|
||
|
||
char head[512];
|
||
if (done) {
|
||
/* OpenCode: ← Wrote path / ← Edit path +N -M */
|
||
snprintf(head, sizeof(head),
|
||
"│ ← %s %.200s \033[32m+%d\033[0m \033[31m-%d\033[0m",
|
||
verb_done, path, st.additions, st.deletions);
|
||
} else {
|
||
snprintf(head, sizeof(head), "│ ← %s %.200s %.100s", verb_run, path,
|
||
b->meta[0] ? b->meta : "…");
|
||
}
|
||
dlines_push(dl, head, CP_MUTED, 0, 0);
|
||
if (done || exp)
|
||
push_diff_or_body(dl, b->body, width, CP_MUTED, b->expanded,
|
||
TOOL_PREVIEW_LINES, details_all);
|
||
} else {
|
||
char status_short[96];
|
||
status_short[0] = 0;
|
||
if (denied) {
|
||
snprintf(status_short, sizeof(status_short), "denied");
|
||
} else if (strncmp(b->meta, "exit=", 5) == 0) {
|
||
snprintf(status_short, sizeof(status_short), "done %.80s", b->meta);
|
||
} else if (b->meta[0]) {
|
||
snprintf(status_short, sizeof(status_short), "%.80s", b->meta);
|
||
}
|
||
snprintf(head, sizeof(head), "%s %s %s%s%s", px,
|
||
b->tool_name[0] ? b->tool_name : "tool",
|
||
b->title ? b->title : "",
|
||
status_short[0] ? " " : "", status_short);
|
||
dlines_push(dl, head, denied ? CP_ERROR : CP_TOOL, 1, 0);
|
||
push_diff_or_body(dl, b->body, width, denied ? CP_ERROR : CP_MUTED, exp,
|
||
TOOL_PREVIEW_LINES, details_all);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case BLK_STATUS:
|
||
case BLK_SYSTEM:
|
||
snprintf(head, sizeof(head), "%s%s%s", b->title ? b->title : "",
|
||
(b->title && b->body && b->body[0]) ? " " : "",
|
||
b->body ? b->body : "");
|
||
push_wrapped(dl, "", head, width, CP_MUTED, 0, 1, 0);
|
||
break;
|
||
}
|
||
}
|
||
|
||
/* Non-box lines sit two columns right of the user-message bar. */
|
||
#define LOG_INDENT 2
|
||
|
||
static void block_to_lines_indented(const block_t *b, int width,
|
||
int details_all, dlines_t *dl) {
|
||
int n0 = dl->n;
|
||
if (b->kind == BLK_USER) {
|
||
block_to_lines(b, width, details_all, dl);
|
||
return;
|
||
}
|
||
block_to_lines(b, width - LOG_INDENT, details_all, dl);
|
||
for (int i = n0; i < dl->n; i++)
|
||
if (!dl->lines[i].fill)
|
||
dl->lines[i].indent = LOG_INDENT;
|
||
}
|
||
|
||
static void build_display(tui_t *t, dlines_t *dl, int width) {
|
||
dlines_free(dl);
|
||
dl->lines = NULL;
|
||
dl->n = dl->cap = 0;
|
||
for (int i = 0; i < t->hist.n; i++) {
|
||
g_dl_blk = i;
|
||
block_to_lines_indented(&t->hist.items[i], width, t->details_all, dl);
|
||
}
|
||
g_dl_blk = -1;
|
||
if (t->as_live) {
|
||
block_t live = t->live;
|
||
char *glitch = NULL;
|
||
if (t->motion_on && t->busy) {
|
||
const char *src =
|
||
t->live.render ? t->live.render : (t->live.body ? t->live.body : "");
|
||
glitch = glitch_stream_frontier(src, t->spin);
|
||
if (glitch)
|
||
live.render = glitch;
|
||
}
|
||
block_to_lines_indented(&live, width, t->details_all, dl);
|
||
free(glitch);
|
||
}
|
||
}
|
||
|
||
/* Draws s at (y, x0) within maxw columns; returns the columns used. */
|
||
static int draw_ansi_at(WINDOW *win, int y, int x0, int maxw, const char *s,
|
||
int base_pair, int base_bold, int base_dim) {
|
||
sty_t sty = {0};
|
||
sty.pair = base_pair;
|
||
if (base_bold)
|
||
sty.attrs |= A_BOLD;
|
||
if (base_dim)
|
||
sty.attrs |= A_DIM;
|
||
int col = 0;
|
||
size_t n = s ? strlen(s) : 0;
|
||
size_t i = 0;
|
||
while (i < n && col < maxw) {
|
||
size_t sg = sgr_parse(s + i, n - i, &sty);
|
||
if (sg) {
|
||
i += sg;
|
||
continue;
|
||
}
|
||
size_t skip = ansi_skip(s + i, n - i);
|
||
if (skip) {
|
||
i += skip;
|
||
continue;
|
||
}
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(s + i, n - i, &wc, &eaten);
|
||
wc = ascii_glyph(wc);
|
||
int cw = utf8_width(wc);
|
||
if (col + cw > maxw)
|
||
break;
|
||
int attrs = sty.attrs;
|
||
if (sty.pair && HAS_COLOR())
|
||
attrs |= COLOR_PAIR(sty.pair);
|
||
else if (base_pair && HAS_COLOR())
|
||
attrs |= COLOR_PAIR(base_pair);
|
||
put_wc(win, y, x0 + col, wc, attrs);
|
||
col += cw;
|
||
i += eaten;
|
||
}
|
||
return col;
|
||
}
|
||
|
||
static void draw_ansi_line(WINDOW *win, int y, int maxw, const char *s,
|
||
int base_pair, int base_bold, int base_dim) {
|
||
draw_ansi_at(win, y, 0, maxw, s, base_pair, base_bold, base_dim);
|
||
}
|
||
|
||
static double since_ms(const struct timespec *t0) {
|
||
struct timespec now;
|
||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||
return (double)(now.tv_sec - t0->tv_sec) * 1000.0 +
|
||
(double)(now.tv_nsec - t0->tv_nsec) / 1e6;
|
||
}
|
||
|
||
static const char *dot_sep(void) { return g_ascii ? " - " : " · "; }
|
||
|
||
/* Branch from the nearest .git/HEAD at or above dir (worktree files too). */
|
||
static void git_branch(const char *dir, char *out, size_t sz) {
|
||
out[0] = 0;
|
||
char d[OS_PATH_MAX], p[OS_PATH_MAX + 64], line[OS_PATH_MAX];
|
||
snprintf(d, sizeof(d), "%s", dir);
|
||
FILE *f = NULL;
|
||
for (int depth = 0; depth < 64 && d[0]; depth++) {
|
||
snprintf(p, sizeof(p), "%s/.git/HEAD", d);
|
||
if ((f = fopen(p, "rb")) != NULL)
|
||
break;
|
||
snprintf(p, sizeof(p), "%s/.git", d);
|
||
FILE *g = fopen(p, "rb");
|
||
if (g) {
|
||
int ok = fgets(line, sizeof(line), g) && strncmp(line, "gitdir: ", 8) == 0;
|
||
fclose(g);
|
||
if (ok) {
|
||
line[strcspn(line, "\r\n")] = 0;
|
||
int n = line[8] == '/'
|
||
? snprintf(p, sizeof(p), "%s/HEAD", line + 8)
|
||
: snprintf(p, sizeof(p), "%s/%s/HEAD", d, line + 8);
|
||
if (n > 0 && (size_t)n < sizeof(p))
|
||
f = fopen(p, "rb");
|
||
break;
|
||
}
|
||
}
|
||
char *slash = strrchr(d, '/');
|
||
if (!slash || slash == d)
|
||
break;
|
||
*slash = 0;
|
||
}
|
||
if (!f)
|
||
return;
|
||
if (fgets(line, sizeof(line), f)) {
|
||
line[strcspn(line, "\r\n")] = 0;
|
||
const char *ref = strncmp(line, "ref: refs/heads/", 16) == 0 ? line + 16
|
||
: line;
|
||
size_t n = ref == line ? 7 : strlen(ref);
|
||
if (n >= sz)
|
||
n = sz - 1;
|
||
memcpy(out, ref, n);
|
||
out[n] = 0;
|
||
}
|
||
fclose(f);
|
||
}
|
||
|
||
static void where_label(const char *ws, char *out, size_t sz) {
|
||
const char *home = getenv("HOME");
|
||
size_t hl = home ? strlen(home) : 0;
|
||
char br[128];
|
||
git_branch(ws, br, sizeof(br));
|
||
int tilde = hl > 1 && strncmp(ws, home, hl) == 0 && (ws[hl] == '/' || !ws[hl]);
|
||
int n = snprintf(out, sz, "%s%s%s%s", tilde ? "~" : "", tilde ? ws + hl : ws,
|
||
br[0] ? ":" : "", br);
|
||
if (n < 0)
|
||
out[0] = 0;
|
||
}
|
||
|
||
/* Display columns of s, ignoring escape sequences. */
|
||
static int vis_cols(const char *s) {
|
||
int cols = 0;
|
||
size_t n = strlen(s), i = 0;
|
||
while (i < n) {
|
||
size_t sk = ansi_skip(s + i, n - i);
|
||
if (sk) {
|
||
i += sk;
|
||
continue;
|
||
}
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(s + i, n - i, &wc, &eaten);
|
||
cols += utf8_width(wc);
|
||
i += eaten;
|
||
}
|
||
return cols;
|
||
}
|
||
|
||
static const char *spinner_frame(const tui_t *t) {
|
||
static const char *const uni[] = {"⠋", "⠙", "⠹", "⠸", "⠼",
|
||
"⠴", "⠦", "⠧", "⠇", "⠏"};
|
||
static const char *const asc[] = {"|", "/", "-", "\\"};
|
||
return g_ascii ? asc[t->spin % 4] : uni[t->spin % 10];
|
||
}
|
||
|
||
/* Seat Deck motion: short glyph sequences driven by t->spin (80ms ticks). */
|
||
static const char *motion_fleet_frame(const tui_t *t) {
|
||
static const char *const uni[] = {"✦ · ·", "· ✦ ·", "· · ✦", "· ✦ ·"};
|
||
static const char *const asc[] = {"* . .", ". * .", ". . *", ". * ."};
|
||
int i = (t->spin / 2) % 4;
|
||
return g_ascii ? asc[i] : uni[i];
|
||
}
|
||
|
||
static const char *seat_pulse_dot(const tui_t *t) {
|
||
int on = 1;
|
||
if (t->motion_on) {
|
||
if (t->busy)
|
||
on = (t->spin % 2) == 0;
|
||
else
|
||
on = ((t->spin / 6) % 2) == 0;
|
||
}
|
||
if (g_ascii)
|
||
return on ? "*" : ".";
|
||
return on ? "●" : "○";
|
||
}
|
||
|
||
static int realm_fp_short(char out[9]) {
|
||
out[0] = 0;
|
||
cst_peer_info_t peers[1];
|
||
if (cst_discover_local_list(NULL, peers, 1) > 0 && peers[0].realm_fp[0]) {
|
||
snprintf(out, 9, "%.8s", peers[0].realm_fp);
|
||
return 0;
|
||
}
|
||
char dir[OS_PATH_MAX];
|
||
cst_realm_keys_t k;
|
||
char fp[33];
|
||
if (cst_realm_dir(dir, sizeof(dir)) == 0 && cst_realm_load(dir, &k) == 0 &&
|
||
cst_realm_fingerprint(k.pk, fp) == 0) {
|
||
snprintf(out, 9, "%.8s", fp);
|
||
return 0;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
static void draw_ctx_bar(char *out, size_t sz, int pct, int pulse_on) {
|
||
int bar_w = 12;
|
||
int fill = pct <= 0 ? 0 : pct >= 100 ? bar_w : (pct * bar_w) / 100;
|
||
size_t o = 0;
|
||
out[0] = 0;
|
||
for (int i = 0; i < bar_w && o + 4 < sz; i++) {
|
||
const char *cell;
|
||
if (i < fill) {
|
||
if (pulse_on && pct >= 80 && i == fill - 1)
|
||
cell = g_ascii ? "*" : "▓";
|
||
else
|
||
cell = g_ascii ? "=" : "█";
|
||
} else {
|
||
cell = g_ascii ? "." : "░";
|
||
}
|
||
size_t n = strlen(cell);
|
||
memcpy(out + o, cell, n);
|
||
o += n;
|
||
out[o] = 0;
|
||
}
|
||
}
|
||
|
||
static void fmt_tokens(long n, char *out, size_t sz) {
|
||
if (n >= 1000000)
|
||
snprintf(out, sz, "%.1fM", (double)n / 1e6);
|
||
else if (n >= 1000)
|
||
snprintf(out, sz, "%.1fK", (double)n / 1e3);
|
||
else
|
||
snprintf(out, sz, "%ld", n);
|
||
}
|
||
|
||
static int ctx_percent(const tui_t *t, long *used) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
long win = cfg ? cst_model_context_tokens(cfg->model) : 0;
|
||
*used = cst_agent_last_prompt_tokens(t->agent);
|
||
return win > 0 ? (int)(*used * 100 / win) : 0;
|
||
}
|
||
|
||
static const char *money_sym(const char *cur) {
|
||
if (g_ascii && strcmp(cur, "¥") == 0)
|
||
return "CNY ";
|
||
return cur;
|
||
}
|
||
|
||
static int approval_mode(const tui_t *t) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
if (!cfg)
|
||
return CST_APPROVAL_ASK;
|
||
return cfg->auto_approve ? CST_APPROVAL_YOLO : cfg->approval;
|
||
}
|
||
|
||
static void approval_label(const tui_t *t, char *out, size_t sz) {
|
||
int a = approval_mode(t);
|
||
if (a == CST_APPROVAL_YOLO)
|
||
snprintf(out, sz, "YOLO");
|
||
else if (a == CST_APPROVAL_AUTO)
|
||
snprintf(out, sz, "auto%s%s", dot_sep(), t->sandbox_name);
|
||
else
|
||
snprintf(out, sz, "ask");
|
||
}
|
||
|
||
/* Bottom row: path / status left; palette hint right. CTX lives in Seat Deck. */
|
||
static void draw_status(tui_t *t) {
|
||
if (!t->status)
|
||
return;
|
||
werase(t->status);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->status, COLOR_PAIR(CP_PANEL));
|
||
int w = getmaxx(t->status);
|
||
int sx = getbegx(t->status);
|
||
char right[160];
|
||
int rw;
|
||
t->cmd_x0 = t->cmd_x1 = -1;
|
||
if (t->home) {
|
||
snprintf(right, sizeof(right), "%s", CST_VERSION);
|
||
rw = utf8_cols(right, strlen(right));
|
||
draw_ansi_at(t->status, 0, w - rw, rw, right, CP_MUTED, 0, 0);
|
||
} else {
|
||
snprintf(right, sizeof(right), "\033[97mctrl+p\033[39m commands");
|
||
rw = vis_cols(right);
|
||
if (rw > w)
|
||
rw = w;
|
||
draw_ansi_at(t->status, 0, w - rw, rw, right, CP_MUTED, 0, 0);
|
||
t->cmd_x1 = sx + w - 1;
|
||
t->cmd_x0 = t->cmd_x1 - (int)strlen("ctrl+p commands") + 1;
|
||
}
|
||
int lw = w - rw - 2;
|
||
if (lw < 1)
|
||
return (void)wrefresh(t->status);
|
||
if (t->busy) {
|
||
char line[600];
|
||
snprintf(line, sizeof(line), "\033[33m%s\033[39m %s", spinner_frame(t),
|
||
t->status_msg[0] ? t->status_msg : "working");
|
||
draw_ansi_at(t->status, 0, 0, lw, line, CP_MUTED, 0, 0);
|
||
} else if (t->status_msg[0]) {
|
||
draw_ansi_at(t->status, 0, 0, lw, t->status_msg, CP_MUTED, 0, 0);
|
||
} else {
|
||
draw_ansi_at(t->status, 0, 0, lw, t->where, CP_MUTED, 0, 0);
|
||
}
|
||
wrefresh(t->status);
|
||
}
|
||
|
||
/* Short mode row: Build/Plan · effort — model lives in the topbar. */
|
||
static void draw_mode_line(tui_t *t, WINDOW *w, int y, int maxw) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
if (!cfg)
|
||
return;
|
||
const char *eff = cst_effort_wire(cfg->provider, cfg->model, cfg->effort)
|
||
? cfg->effort
|
||
: NULL;
|
||
char right[160];
|
||
int rpair = CP_BOX_MUTED;
|
||
if (t->busy) {
|
||
snprintf(right, sizeof(right), "%s %.0fs esc", spinner_frame(t),
|
||
since_ms(&t->turn_t0) / 1000.0);
|
||
rpair = CP_BOX_PRIMARY;
|
||
} else {
|
||
approval_label(t, right, sizeof(right));
|
||
if (approval_mode(t) == CST_APPROVAL_YOLO)
|
||
rpair = CP_BOX_PRIMARY;
|
||
}
|
||
int rw = utf8_cols(right, strlen(right));
|
||
int x = 2, room = maxw - x - rw - 2;
|
||
if (room < 8) {
|
||
rw = 0;
|
||
room = maxw - x - 1;
|
||
}
|
||
x += draw_ansi_at(w, y, x, room, cfg->plan_mode ? "Plan" : "Build",
|
||
cfg->plan_mode ? CP_BOX_PRIMARY : CP_BOX_ACCENT, 1, 0);
|
||
if (eff) {
|
||
x += draw_ansi_at(w, y, x, room - x + 2, dot_sep(), CP_BOX_MUTED, 0, 0);
|
||
x += draw_ansi_at(w, y, x, room - x + 2, eff, CP_BOX_PRIMARY, 0, 0);
|
||
}
|
||
int npick = cst_agent_pending_count(t->agent);
|
||
if (npick > 0) {
|
||
char mark[32];
|
||
snprintf(mark, sizeof(mark), "%d img", npick);
|
||
x += draw_ansi_at(w, y, x, room - x + 2, dot_sep(), CP_BOX_MUTED, 0, 0);
|
||
draw_ansi_at(w, y, x, room - x + 2, mark, CP_BOX_PRIMARY, 0, 0);
|
||
}
|
||
if (rw)
|
||
draw_ansi_at(w, y, maxw - rw - 2, rw, right, rpair, 0, 0);
|
||
}
|
||
|
||
/* Visual position of every byte offset: soft wrap at the window edge plus
|
||
* hard newlines. Calls fn(ud, offset, row, col, wc, width) per character and
|
||
* returns the row count; cursor row/col are reported for t->cur. */
|
||
typedef void (*input_cell_fn)(void *ud, int row, int col, wchar_t wc, int cw);
|
||
|
||
#define IN_COL 2 /* text column inside the input box (bar at 0) */
|
||
|
||
static int input_layout(const tui_t *t, input_cell_fn fn, void *ud,
|
||
int *cur_row, int *cur_col) {
|
||
int row = 0, col = IN_COL;
|
||
int right = (t->in_w > 0 ? t->in_w : t->cols) - 2;
|
||
*cur_row = 0;
|
||
*cur_col = IN_COL;
|
||
size_t i = 0;
|
||
while (i < t->in_len) {
|
||
if (i == t->cur) {
|
||
*cur_row = row;
|
||
*cur_col = col;
|
||
}
|
||
if (t->inbuf[i] == '\n') {
|
||
row++;
|
||
col = IN_COL;
|
||
i++;
|
||
continue;
|
||
}
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(t->inbuf + i, t->in_len - i, &wc, &eaten);
|
||
if (wc == L'\t')
|
||
wc = L' ';
|
||
int cw = utf8_width(wc);
|
||
if (col + cw > right && col > IN_COL) {
|
||
row++;
|
||
col = IN_COL;
|
||
}
|
||
if (fn)
|
||
fn(ud, row, col, wc, cw);
|
||
col += cw;
|
||
i += eaten;
|
||
}
|
||
if (t->cur >= t->in_len) {
|
||
if (col >= right) {
|
||
row++;
|
||
col = IN_COL;
|
||
}
|
||
*cur_row = row;
|
||
*cur_col = col;
|
||
}
|
||
return row + 1;
|
||
}
|
||
|
||
typedef struct {
|
||
WINDOW *win;
|
||
int top, h, attrs;
|
||
int row0; /* window row of the first visible input line */
|
||
} input_draw_t;
|
||
|
||
static void input_draw_cell(void *ud, int row, int col, wchar_t wc, int cw) {
|
||
(void)cw;
|
||
input_draw_t *d = ud;
|
||
if (row < d->top || row >= d->top + d->h)
|
||
return;
|
||
put_wc(d->win, d->row0 + row - d->top, col, wc, d->attrs);
|
||
}
|
||
|
||
static void draw_input(tui_t *t) {
|
||
if (!t->input)
|
||
return;
|
||
WINDOW *w = t->input;
|
||
if (HAS_COLOR())
|
||
wbkgd(w, COLOR_PAIR(CP_BOX));
|
||
werase(w);
|
||
int bh, bw;
|
||
getmaxyx(w, bh, bw);
|
||
int h = t->input_rows > 0 ? t->input_rows : 1;
|
||
int pad = (bh - h - 1) / 2; /* rows above the text */
|
||
if (pad < 0)
|
||
pad = 0;
|
||
int bar = HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX_ACCENT) : 0;
|
||
int edge = HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX_MUTED) : 0;
|
||
/* Composer card frame. */
|
||
if (bh >= 2 && bw >= 4) {
|
||
put_wc(w, 0, 0, g_ascii ? L'+' : L'┌', bar);
|
||
for (int x = 1; x < bw - 1; x++)
|
||
put_wc(w, 0, x, g_ascii ? L'-' : L'─', edge);
|
||
put_wc(w, 0, bw - 1, g_ascii ? L'+' : L'┐', edge);
|
||
put_wc(w, bh - 1, 0, g_ascii ? L'+' : L'└', edge);
|
||
for (int x = 1; x < bw - 1; x++)
|
||
put_wc(w, bh - 1, x, g_ascii ? L'-' : L'─', edge);
|
||
put_wc(w, bh - 1, bw - 1, g_ascii ? L'+' : L'┘', edge);
|
||
}
|
||
for (int r = 1; r < bh - 1; r++) {
|
||
put_wc(w, r, 0, L'┃', bar);
|
||
put_wc(w, r, bw - 1, g_ascii ? L'|' : L'│', edge);
|
||
}
|
||
draw_mode_line(t, w, pad + h, bw);
|
||
int cr, cc;
|
||
input_layout(t, NULL, NULL, &cr, &cc);
|
||
int top = cr >= h ? cr - h + 1 : 0;
|
||
if (top > 0)
|
||
put_wc(w, pad, 1, g_ascii ? L'^' : L'↑',
|
||
HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX_MUTED) : 0);
|
||
if (t->in_len == 0) {
|
||
const char *hint =
|
||
t->busy ? "Enter redirects the active task"
|
||
: (g_ascii ? "Ask anything... \"Fix a TODO in this repo\""
|
||
: "Ask anything… \"Fix a TODO in this repo\"");
|
||
draw_ansi_at(w, pad, IN_COL, bw - IN_COL - 1, hint, CP_BOX_MUTED, 0, 0);
|
||
wmove(w, pad, IN_COL);
|
||
wrefresh(w);
|
||
return;
|
||
}
|
||
input_draw_t d = {w, top, h, HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX) : 0, pad};
|
||
input_layout(t, input_draw_cell, &d, &cr, &cc);
|
||
if (cc >= bw)
|
||
cc = bw - 1;
|
||
wmove(w, pad + cr - top, cc);
|
||
wrefresh(w);
|
||
}
|
||
|
||
/* Popup of up to `room` rows at logw (y, x): growing upward from y when
|
||
* up, else downward. Rows are recorded for mouse picks. */
|
||
static int g_comp_y0 = -1, g_comp_x0, g_comp_w;
|
||
|
||
static void draw_completion(tui_t *t, int y, int x, int bw, int room, int up) {
|
||
t->comp_drawn = t->comp_n;
|
||
g_comp_y0 = -1;
|
||
if (t->comp_n <= 0 || room < 1 || bw < 4)
|
||
return;
|
||
int n = t->comp_n < room ? t->comp_n : room;
|
||
int y0 = up ? y - n + 1 : y;
|
||
g_comp_y0 = t->log_y + y0;
|
||
g_comp_x0 = t->log_x + x;
|
||
g_comp_w = bw;
|
||
for (int i = 0; i < n; i++) {
|
||
const comp_item_t *c = &t->comp[i];
|
||
int sel = i == t->comp_sel;
|
||
int attrs = HAS_COLOR() ? (int)COLOR_PAIR(sel ? CP_SEL : CP_BOX)
|
||
: (sel ? A_REVERSE : 0);
|
||
for (int k = 0; k < bw; k++)
|
||
put_wc(t->logw, y0 + i, x + k, L' ', attrs);
|
||
char line[420];
|
||
snprintf(line, sizeof(line), "%-22s ", c->text);
|
||
int used = draw_ansi_at(t->logw, y0 + i, x + 1, bw - 2, line,
|
||
sel ? CP_SEL : CP_BOX_BRIGHT, sel, 0);
|
||
if (bw - 2 - used > 0)
|
||
draw_ansi_at(t->logw, y0 + i, x + 1 + used, bw - 2 - used, c->desc,
|
||
sel ? CP_SEL : CP_BOX_MUTED, 0, 0);
|
||
}
|
||
}
|
||
|
||
/* Task list panel above the status bar while todo_write has open items. */
|
||
static int todo_open_items(const char *todo, int *total) {
|
||
int open = 0, n = 0;
|
||
for (const char *p = todo; p && *p;) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
if (len > 0) {
|
||
n++;
|
||
if (!(len >= 3 && strncmp(p, "[x]", 3) == 0))
|
||
open++;
|
||
}
|
||
p = nl ? nl + 1 : p + len;
|
||
}
|
||
if (total)
|
||
*total = n;
|
||
return open;
|
||
}
|
||
|
||
static void draw_todo(tui_t *t) {
|
||
if (!t->todow)
|
||
return;
|
||
werase(t->todow);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->todow, COLOR_PAIR(CP_PANEL));
|
||
int total = 0, open = todo_open_items(t->todo, &total);
|
||
char head[96];
|
||
snprintf(head, sizeof(head), g_ascii ? " Tasks %d/%d done (Ctrl+T hides)"
|
||
: " Tasks %d/%d done · Ctrl+T hides",
|
||
total - open, total);
|
||
draw_ansi_line(t->todow, 0, getmaxx(t->todow) - 1, head, CP_PRIMARY, 1, 0);
|
||
int row = 1, skipped = 0;
|
||
int room = t->todo_rows - 1;
|
||
/* keep the first unfinished item visible */
|
||
int first_open = 0, idx = 0;
|
||
for (const char *p = t->todo; p && *p; idx++) {
|
||
const char *nl = strchr(p, '\n');
|
||
if (strncmp(p, "[x]", 3) != 0) {
|
||
first_open = idx;
|
||
break;
|
||
}
|
||
p = nl ? nl + 1 : p + strlen(p);
|
||
}
|
||
int skip = first_open > 1 ? first_open - 1 : 0;
|
||
if (total - skip < room)
|
||
skip = total > room ? total - room : 0;
|
||
idx = 0;
|
||
for (const char *p = t->todo; p && *p && row <= room;) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
if (len >= 3 && idx++ >= skip) {
|
||
const char *mark = "○";
|
||
int pair = CP_PANEL, dim = 0;
|
||
if (strncmp(p, "[x]", 3) == 0) {
|
||
mark = "✓";
|
||
pair = CP_SUCCESS;
|
||
dim = 1;
|
||
} else if (strncmp(p, "[~]", 3) == 0) {
|
||
mark = "▸";
|
||
pair = CP_PRIMARY;
|
||
}
|
||
char line[400];
|
||
if (row == room && total - idx > 0) {
|
||
snprintf(line, sizeof(line), " … %d more", total - idx + 1);
|
||
draw_ansi_line(t->todow, row, getmaxx(t->todow) - 1, line, CP_MUTED, 0, 1);
|
||
break;
|
||
}
|
||
const char *txt = p + 3;
|
||
while (txt < p + len && *txt == ' ')
|
||
txt++;
|
||
snprintf(line, sizeof(line), " %s %.*s", mark, (int)(p + len - txt),
|
||
txt);
|
||
draw_ansi_line(t->todow, row++, getmaxx(t->todow) - 1, line, pair, 0, dim);
|
||
} else if (len >= 3) {
|
||
skipped++;
|
||
}
|
||
p = nl ? nl + 1 : p + len;
|
||
}
|
||
(void)skipped;
|
||
wrefresh(t->todow);
|
||
}
|
||
|
||
/* --- start screen ---------------------------------------------------------- */
|
||
|
||
/* 3-row block font for "HERMIT"; each glyph is 4 columns wide. */
|
||
static const char *const LOGO[6][3] = {
|
||
{"█ █", "█▀▀█", "█ █"}, /* H */
|
||
{"█▀▀▀", "█▀▀ ", "▀▀▀▀"}, /* E */
|
||
{"█▀▀█", "█▀▀█", "▀ ▀"}, /* R */
|
||
{"█▀▄█", "█ █", "▀ ▀"}, /* M */
|
||
{"▀█▀▀", " █ ", " ▀▀▀"}, /* I */
|
||
{"▀█▀▀", " █ ", " ▀▀▀"}, /* T */
|
||
};
|
||
#define LOGO_N 6
|
||
#define LOGO_W (LOGO_N * 5 - 1)
|
||
#define LOGO_H 3
|
||
|
||
static int hermit_role_pair(int role) {
|
||
switch (role) {
|
||
case 0:
|
||
return CP_TOOL; /* body */
|
||
case 1:
|
||
return CP_ACCENT; /* shell */
|
||
case 2:
|
||
return CP_ASSIST; /* eye */
|
||
case 3:
|
||
return COLORS >= 256 ? CP_MUTED : CP_ERROR; /* brick legs */
|
||
case 4:
|
||
return CP_SGR_CYAN; /* >_ */
|
||
default:
|
||
return CP_MUTED;
|
||
}
|
||
}
|
||
|
||
static void draw_hermit_icon(WINDOW *win, int y0, int x0, hermit_state_t st,
|
||
int with_shell, int frame) {
|
||
hermit_icon_mode_t mode = hermit_icon_mode_eff(g_ascii);
|
||
if (mode == HERMIT_ICON_FONT) {
|
||
wchar_t wc = hermit_font_codepoint(st, frame, with_shell);
|
||
cchar_t cc;
|
||
wchar_t wstr[2] = {wc, 0};
|
||
if (setcchar(&cc, wstr, 0, 0, NULL) == OK) {
|
||
if (HAS_COLOR())
|
||
wattron(win, COLOR_PAIR(CP_TOOL));
|
||
mvwadd_wch(win, y0, x0, &cc);
|
||
if (HAS_COLOR())
|
||
wattroff(win, COLOR_PAIR(CP_TOOL));
|
||
return;
|
||
}
|
||
}
|
||
const char *lines[8];
|
||
int n = hermit_shell_art(st, with_shell, g_ascii, lines, 8);
|
||
for (int r = 0; r < n; r++) {
|
||
const char *row = lines[r];
|
||
for (int c = 0; row[c]; c++) {
|
||
if (row[c] == ' ')
|
||
continue;
|
||
char ch[2] = {row[c], 0};
|
||
int pair = hermit_role_pair(hermit_shell_cell_role(st, with_shell, r, c));
|
||
draw_ansi_at(win, y0 + r, x0 + c, 1, ch, pair, 0, 0);
|
||
}
|
||
}
|
||
}
|
||
|
||
static void draw_hermit_badge(WINDOW *win, int y, int x, const tui_t *t) {
|
||
hermit_state_t st = hermit_state_from_ui(
|
||
t->busy, t->ask_pending || t->awaiting_perm, 0);
|
||
hermit_icon_mode_t mode = hermit_icon_mode_eff(g_ascii);
|
||
if (mode == HERMIT_ICON_FONT) {
|
||
wchar_t wc = hermit_font_codepoint(st, t->spin, 0);
|
||
cchar_t cc;
|
||
wchar_t wstr[2] = {wc, 0};
|
||
if (setcchar(&cc, wstr, 0, 0, NULL) == OK) {
|
||
if (HAS_COLOR())
|
||
wattron(win, COLOR_PAIR(CP_TOOL));
|
||
mvwadd_wch(win, y, x, &cc);
|
||
if (HAS_COLOR())
|
||
wattroff(win, COLOR_PAIR(CP_TOOL));
|
||
return;
|
||
}
|
||
}
|
||
const char *b = hermit_mini_badge(st, t->spin, g_ascii);
|
||
draw_ansi_at(win, y, x, (int)strlen(b), b, CP_TOOL, 0, 0);
|
||
}
|
||
|
||
static const char *const TIPS[] = {
|
||
"Tab switches between Build and Plan (read-only) mode",
|
||
"Shift+Tab cycles approval: ask, auto, yolo",
|
||
"Ctrl+P opens the command palette",
|
||
"Type @ to mention a file in your message",
|
||
"/effort sets reasoning effort: low, medium, high, max",
|
||
"/mode general reframes the agent for non-code work",
|
||
"/model lists the models your API key can use",
|
||
"/undo reverts the file changes of the last turn",
|
||
"/provider switches provider; keys are saved per provider",
|
||
"Click a tool card or Thought to expand it",
|
||
"Drag over the transcript to copy it; double-click copies a word",
|
||
};
|
||
|
||
static void draw_home(tui_t *t) {
|
||
int h, w;
|
||
getmaxyx(t->logw, h, w);
|
||
int box_top = t->in_y - t->log_y;
|
||
int box_bot = box_top + t->in_h;
|
||
int brand_y = box_top - 2;
|
||
if (brand_y < 0)
|
||
brand_y = 0;
|
||
int want_block = 0;
|
||
{
|
||
const char *lb = hermit_getenv("LOGO");
|
||
want_block = lb[0] && !strcmp(lb, "block");
|
||
}
|
||
/* Crab on the right of the brand stack when there is room. */
|
||
int crab_w = 14, crab_h = 6;
|
||
int crab_x = w - crab_w - 2;
|
||
int show_crab = crab_x > LOGO_W + 4 && box_top >= crab_h + 1;
|
||
if (want_block && !(g_ascii || !g_utf8 || g_logo_text) &&
|
||
box_top - LOGO_H - 2 >= 0 && w >= LOGO_W) {
|
||
int x0 = show_crab ? (crab_x - LOGO_W) / 2 : (w - LOGO_W) / 2;
|
||
if (x0 < 0)
|
||
x0 = 0;
|
||
int y0 = box_top - LOGO_H - 2;
|
||
for (int g = 0; g < LOGO_N; g++)
|
||
for (int r = 0; r < LOGO_H; r++)
|
||
draw_ansi_at(t->logw, y0 + r, x0 + g * 5, 4, LOGO[g][r], CP_ACCENT, 0,
|
||
0);
|
||
brand_y = y0 + LOGO_H;
|
||
} else {
|
||
const char *mark = "HERMIT";
|
||
int mw = (int)strlen(mark);
|
||
int x = show_crab ? (crab_x - mw) / 2 : (w - mw) / 2;
|
||
if (x < 0)
|
||
x = 0;
|
||
if (brand_y >= 1)
|
||
brand_y -= 1;
|
||
draw_ansi_at(t->logw, brand_y, x, mw, mark, CP_ACCENT, 1, 0);
|
||
}
|
||
if (show_crab) {
|
||
int cy = box_top - crab_h;
|
||
if (cy < 0)
|
||
cy = 0;
|
||
draw_hermit_icon(t->logw, cy, crab_x, HERMIT_ST_IDLE, 1, t->spin);
|
||
}
|
||
int ty = brand_y + 1;
|
||
if (ty < box_top) {
|
||
const char *tag =
|
||
g_ascii ? "code . plan . parliament" : "code · plan · parliament";
|
||
int tw = (int)strlen(tag);
|
||
int x = show_crab ? (crab_x - tw) / 2 : (w - tw) / 2;
|
||
if (x < 0)
|
||
x = 0;
|
||
draw_ansi_at(t->logw, ty, x, tw, tag, CP_MUTED, 0, 0);
|
||
}
|
||
if (cst_parliament_is_active() && ty + 1 < box_top) {
|
||
char *st = cst_parliament_status_text();
|
||
if (st && st[0]) {
|
||
char chip[160];
|
||
snprintf(chip, sizeof(chip), "%s %s", g_ascii ? "*" : "◆", st);
|
||
int cw = vis_cols(chip);
|
||
if (cw > w - 2)
|
||
cw = w - 2;
|
||
draw_ansi_at(t->logw, ty + 1, (w - cw) / 2, cw, chip, CP_ACCENT, 0, 0);
|
||
}
|
||
free(st);
|
||
}
|
||
int right = t->in_x - t->log_x + t->in_w;
|
||
if (box_bot < h) {
|
||
char hint[128];
|
||
snprintf(hint, sizeof(hint),
|
||
"\033[97mtab\033[39m modes \033[97mctrl+p\033[39m commands "
|
||
"\033[97mshift+tab\033[39m approval");
|
||
int hw = vis_cols(hint);
|
||
if (right - hw >= 0)
|
||
draw_ansi_at(t->logw, box_bot, right - hw, hw, hint, CP_MUTED, 0, 0);
|
||
}
|
||
if (t->tip && box_bot + 2 < h) {
|
||
char tip[256];
|
||
snprintf(tip, sizeof(tip), "%s %s", g_ascii ? "*" : "●", t->tip);
|
||
int tw = vis_cols(tip);
|
||
if (tw > w - 4)
|
||
tw = w - 4;
|
||
draw_ansi_at(t->logw, box_bot + 2, (w - tw) / 2, tw, tip, CP_TOOL, 0, 0);
|
||
}
|
||
const char *ext = cst_extensions_report();
|
||
if (ext[0] && box_bot + 3 < h) {
|
||
int ew = vis_cols(ext);
|
||
if (ew > w - 2)
|
||
ew = w - 2;
|
||
draw_ansi_at(t->logw, box_bot + 3, (w - ew) / 2, ew, ext, CP_MUTED, 0, 0);
|
||
}
|
||
int room = h - box_bot;
|
||
draw_completion(t, box_bot, t->in_x - t->log_x, t->in_w, room, 0);
|
||
}
|
||
|
||
/* --- sidebar --------------------------------------------------------------- */
|
||
|
||
typedef struct {
|
||
char path[200];
|
||
int add, del;
|
||
} mod_file_t;
|
||
|
||
/* Files changed by write tools this session, newest stats merged per path. */
|
||
static int collect_modified(const tui_t *t, mod_file_t *out, int max) {
|
||
int n = 0;
|
||
for (int i = 0; i < t->hist.n; i++) {
|
||
const block_t *b = &t->hist.items[i];
|
||
if (b->kind != BLK_TOOL || !cst_tool_is_write(b->tool_name) ||
|
||
strncmp(b->meta, "denied", 6) == 0 || !b->body || !b->body[0])
|
||
continue;
|
||
char path[256];
|
||
diff_extract_path(b->body, b->title, path, sizeof(path));
|
||
if (!path[0])
|
||
continue;
|
||
diff_stats_t st;
|
||
diff_count_stats(b->body, &st);
|
||
int k = 0;
|
||
while (k < n && strcmp(out[k].path, path) != 0)
|
||
k++;
|
||
if (k == n) {
|
||
if (n >= max)
|
||
continue;
|
||
snprintf(out[n].path, sizeof(out[n].path), "%.199s", path);
|
||
out[n].add = out[n].del = 0;
|
||
n++;
|
||
}
|
||
out[k].add += st.additions;
|
||
out[k].del += st.deletions;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
static void draw_side(tui_t *t) {
|
||
if (!t->side)
|
||
return;
|
||
WINDOW *s = t->side;
|
||
if (HAS_COLOR())
|
||
wbkgd(s, COLOR_PAIR(CP_SIDE));
|
||
werase(s);
|
||
int h, w;
|
||
getmaxyx(s, h, w);
|
||
const char *rail = g_ascii ? "|" : "┃";
|
||
for (int ry = 0; ry < h; ry++)
|
||
draw_ansi_at(s, ry, 0, 1, rail, CP_SIDE_PRIMARY, 0, 0);
|
||
int x = 2, mw = w - 3, y = 1;
|
||
if (mw < 8)
|
||
mw = w > 2 ? w - 2 : 1;
|
||
char line[512];
|
||
int bottom = h - 3; /* where + version */
|
||
|
||
/* SEAT — local role + instance + short realm fp */
|
||
draw_ansi_at(s, y++, x, mw, "seat", CP_SIDE_MUTED, 0, 0);
|
||
{
|
||
char sid[64] = {0};
|
||
int sess = cst_parliament_session_ok(sid, sizeof(sid));
|
||
int chair = sess && cst_parliament_is_chair();
|
||
const char *role = !sess ? "logged-out" : chair ? "chair" : "member";
|
||
int pair = !sess ? CP_SIDE_MUTED
|
||
: chair ? CP_SIDE_SUCCESS : CP_SIDE_PRIMARY;
|
||
int dx = x + draw_ansi_at(s, y, x, 2, seat_pulse_dot(t), pair, 0, 0);
|
||
snprintf(line, sizeof(line), "%s", role);
|
||
dx += draw_ansi_at(s, y, dx + 1, mw - (dx + 1 - x), line, pair, 1, 0);
|
||
y++;
|
||
if (!sid[0])
|
||
cst_parliament_default_instance(sid, sizeof(sid));
|
||
if (sid[0] && y < bottom) {
|
||
size_t fit = utf8_fit_cols(sid, strlen(sid), mw);
|
||
snprintf(line, sizeof(line), "%.*s", (int)fit, sid);
|
||
draw_ansi_at(s, y++, x, mw, line, CP_SIDE_MUTED, 0, 0);
|
||
}
|
||
char fp[9];
|
||
if (realm_fp_short(fp) == 0 && y < bottom) {
|
||
snprintf(line, sizeof(line), "realm %.8s", fp);
|
||
draw_ansi_at(s, y++, x, mw, line, CP_SIDE_MUTED, 0, 0);
|
||
}
|
||
}
|
||
y++;
|
||
|
||
/* FLEET — peers / daemon / revision */
|
||
if (y + 1 < bottom) {
|
||
draw_ansi_at(s, y++, x, mw, "fleet", CP_SIDE_MUTED, 0, 0);
|
||
int pid = 0, port = 0;
|
||
char diid[64] = {0};
|
||
int svc = cst_parliament_daemon_info(&pid, diid, sizeof(diid), &port) == 0;
|
||
cst_parliament_meta_t meta;
|
||
memset(&meta, 0, sizeof(meta));
|
||
int have_meta = cst_parliament_load(&meta) == 0;
|
||
cst_peer_info_t peers[16];
|
||
int np = cst_discover_local_list(NULL, peers, 16);
|
||
if (!svc) {
|
||
draw_ansi_at(s, y++, x, mw, "offline", CP_SIDE_MUTED, 0, 0);
|
||
} else {
|
||
snprintf(line, sizeof(line), "port %d · %d peer%s", port, np,
|
||
np == 1 ? "" : "s");
|
||
draw_ansi_at(s, y++, x, mw, line, CP_SIDE, 0, 0);
|
||
if (have_meta && y < bottom) {
|
||
int dx = x;
|
||
snprintf(line, sizeof(line), "rev=%lu", meta.revision);
|
||
dx += draw_ansi_at(s, y, x, mw, line, CP_SIDE_BRIGHT, 0, 0);
|
||
if (t->motion_on && np > 0 && dx + 8 < x + mw)
|
||
draw_ansi_at(s, y, dx + 1, x + mw - (dx + 1), motion_fleet_frame(t),
|
||
CP_SIDE_PRIMARY, 0, 0);
|
||
y++;
|
||
}
|
||
if (np == 0 && y < bottom)
|
||
draw_ansi_at(s, y++, x, mw, "no peers", CP_SIDE_MUTED, 0, 0);
|
||
else {
|
||
for (int i = 0; i < np && y < bottom && i < 4; i++) {
|
||
int is_chair =
|
||
have_meta && meta.chair_instance[0] &&
|
||
!strcmp(peers[i].instance_id, meta.chair_instance);
|
||
snprintf(line, sizeof(line), "%s%.28s", is_chair ? "+ " : " ",
|
||
peers[i].instance_id);
|
||
draw_ansi_at(s, y++, x, mw, line,
|
||
is_chair ? CP_SIDE_SUCCESS : CP_SIDE_MUTED, 0, 0);
|
||
}
|
||
}
|
||
}
|
||
y++;
|
||
}
|
||
|
||
/* CTX — context meter */
|
||
if (y + 2 < bottom) {
|
||
draw_ansi_at(s, y++, x, mw, "ctx", CP_SIDE_MUTED, 0, 0);
|
||
long used;
|
||
int pct = ctx_percent(t, &used);
|
||
char tok[32], bar[40];
|
||
fmt_tokens(used, tok, sizeof(tok));
|
||
int pulse = t->motion_on && pct >= 80 && (t->spin % 2) == 0;
|
||
draw_ctx_bar(bar, sizeof(bar), pct, pulse);
|
||
snprintf(line, sizeof(line), "%s %d%% %s%s", bar, pct, g_ascii ? "" : "· ",
|
||
tok);
|
||
draw_ansi_at(s, y++, x, mw, line, pct >= 80 ? CP_SIDE_PRIMARY : CP_SIDE,
|
||
0, 0);
|
||
y++;
|
||
}
|
||
|
||
/* WORK — todo + modified, or a designed idle line (no empty void). */
|
||
if (y + 2 < bottom) {
|
||
if (t->jobs_done > 0) {
|
||
snprintf(line, sizeof(line), "work %d done", t->jobs_done);
|
||
draw_ansi_at(s, y++, x, mw, line, CP_SIDE_MUTED, 0, 0);
|
||
} else
|
||
draw_ansi_at(s, y++, x, mw, "work", CP_SIDE_MUTED, 0, 0);
|
||
int total = 0;
|
||
int todo_n = 0;
|
||
if (t->show_todo && t->todo && todo_open_items(t->todo, &total) >= 0 &&
|
||
total > 0) {
|
||
for (const char *p = t->todo; *p && y < bottom - 1 && todo_n < 4;) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
if (len >= 3) {
|
||
int pair = CP_SIDE;
|
||
const char *mark = "[ ]";
|
||
if (strncmp(p, "[x]", 3) == 0)
|
||
pair = CP_SIDE_MUTED, mark = g_ascii ? "[x]" : "[✓]";
|
||
else if (strncmp(p, "[~]", 3) == 0)
|
||
pair = CP_SIDE_PRIMARY, mark = g_ascii ? "[~]" : "[•]";
|
||
const char *txt = p + 3;
|
||
while (txt < p + len && *txt == ' ')
|
||
txt++;
|
||
snprintf(line, sizeof(line), "%s %.*s", mark, (int)(p + len - txt),
|
||
txt);
|
||
draw_ansi_at(s, y++, x, mw, line, pair, 0, 0);
|
||
todo_n++;
|
||
}
|
||
p = nl ? nl + 1 : p + len;
|
||
}
|
||
}
|
||
mod_file_t mods[16];
|
||
int nm = collect_modified(t, mods, 16);
|
||
if (nm > 5)
|
||
nm = 5;
|
||
for (int i = 0; i < nm && y < bottom; i++) {
|
||
char pl[16], mi[16];
|
||
snprintf(pl, sizeof(pl), "+%d", mods[i].add);
|
||
snprintf(mi, sizeof(mi), "-%d", mods[i].del);
|
||
int sw = (int)strlen(pl) + 1 + (int)strlen(mi);
|
||
draw_ansi_at(s, y, x, mw - sw - 1, mods[i].path, CP_SIDE_MUTED, 0, 0);
|
||
int px = x + mw - sw;
|
||
px += draw_ansi_at(s, y, px, (int)strlen(pl), pl, CP_SIDE_SUCCESS, 0, 0);
|
||
draw_ansi_at(s, y, px + 1, (int)strlen(mi), mi, CP_SIDE_PRIMARY, 0, 0);
|
||
y++;
|
||
}
|
||
if (todo_n == 0 && nm == 0 && y < bottom)
|
||
draw_ansi_at(s, y++, x, mw, "idle", CP_SIDE_MUTED, 0, 0);
|
||
y++;
|
||
}
|
||
|
||
/* SYNC — parliament pending/quiet, or solo (local workers) */
|
||
if (y + 1 < bottom) {
|
||
if (t->jobs_done > 0 && !cst_parliament_is_active()) {
|
||
snprintf(line, sizeof(line), "sync %d done", t->jobs_done);
|
||
draw_ansi_at(s, y++, x, mw, line, CP_SIDE_MUTED, 0, 0);
|
||
} else
|
||
draw_ansi_at(s, y++, x, mw, "sync", CP_SIDE_MUTED, 0, 0);
|
||
if (!cst_parliament_is_active())
|
||
draw_ansi_at(s, y++, x, mw, "solo", CP_SIDE_MUTED, 0, 0);
|
||
else if (cst_parliament_sync_pending())
|
||
draw_ansi_at(s, y++, x, mw, "pending", CP_SIDE_PRIMARY, 0, 0);
|
||
else
|
||
draw_ansi_at(s, y++, x, mw, "quiet", CP_SIDE_MUTED, 0, 0);
|
||
y++;
|
||
}
|
||
|
||
/* KEYS — always one designed hint row */
|
||
if (y + 1 < bottom) {
|
||
draw_ansi_at(s, y++, x, mw, "keys", CP_SIDE_MUTED, 0, 0);
|
||
draw_ansi_at(s, y++, x, mw,
|
||
g_ascii ? "tab mode . ^p cmd" : "tab mode · ^p cmd",
|
||
CP_SIDE_MUTED, 0, 0);
|
||
}
|
||
|
||
if (h >= 4) {
|
||
draw_ansi_at(s, h - 3, x, mw, t->where, CP_SIDE_MUTED, 0, 0);
|
||
snprintf(line, sizeof(line), "Hermit %s", CST_VERSION);
|
||
draw_ansi_at(s, h - 2, x, mw, line, CP_SIDE_MUTED, 0, 0);
|
||
}
|
||
wrefresh(s);
|
||
}
|
||
|
||
/* Ordered selection [y1:x1, y2:x2] over display lines, snapped to words or
|
||
* whole lines by the click count; x2 < 0 means the end of the line. */
|
||
static void sel_range(const tui_t *t, const dlines_t *dl, int *y1, int *x1,
|
||
int *y2, int *x2) {
|
||
int ay = t->sel_ay, ax = t->sel_ax, by = t->sel_y, bx = t->sel_x;
|
||
if (by < ay || (by == ay && bx < ax)) {
|
||
int ty = ay, tx = ax;
|
||
ay = by, ax = bx, by = ty, bx = tx;
|
||
}
|
||
if (t->sel_mode == 2) {
|
||
int c1, c2;
|
||
if (ay >= 0 && ay < dl->n) {
|
||
cst_textsel_word(dl->lines[ay].text, dl->lines[ay].indent, ax, &c1, &c2);
|
||
ax = c1;
|
||
}
|
||
if (by >= 0 && by < dl->n) {
|
||
cst_textsel_word(dl->lines[by].text, dl->lines[by].indent, bx, &c1, &c2);
|
||
bx = c2;
|
||
}
|
||
} else if (t->sel_mode == 3) {
|
||
while (ay > 0 && ay < dl->n && dl->lines[ay].cont)
|
||
ay--;
|
||
while (by + 1 < dl->n && dl->lines[by + 1].cont)
|
||
by++;
|
||
ax = 0;
|
||
bx = -1;
|
||
}
|
||
*y1 = ay, *x1 = ax, *y2 = by, *x2 = bx;
|
||
}
|
||
|
||
static void draw_selection(tui_t *t, const dlines_t *dl, int h, int w) {
|
||
if (t->sel_state != SEL_DRAG && t->sel_state != SEL_SHOWN)
|
||
return;
|
||
int y1, x1, y2, x2;
|
||
sel_range(t, dl, &y1, &x1, &y2, &x2);
|
||
for (int row = 0; row < h; row++) {
|
||
int li = t->log_start + row;
|
||
if (li < y1 || li > y2)
|
||
continue;
|
||
int a = li == y1 ? x1 : 0;
|
||
int b = li == y2 && x2 >= 0 ? x2 : w - 1;
|
||
if (li < dl->n)
|
||
cst_textsel_snap(dl->lines[li].text, dl->lines[li].indent, &a, &b);
|
||
if (b >= w)
|
||
b = w - 1;
|
||
if (a <= b)
|
||
mvwchgat(t->logw, row, a, b - a + 1, A_REVERSE,
|
||
HAS_COLOR() ? CP_PANEL : 0, NULL);
|
||
}
|
||
}
|
||
|
||
static void draw_log(tui_t *t) {
|
||
if (!t->logw)
|
||
return;
|
||
werase(t->logw);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->logw, COLOR_PAIR(CP_PANEL));
|
||
int h, w;
|
||
getmaxyx(t->logw, h, w);
|
||
if (h < 1 || w < 1)
|
||
return;
|
||
if (t->home) {
|
||
draw_home(t);
|
||
wrefresh(t->logw);
|
||
if (t->input) {
|
||
touchwin(t->input);
|
||
wrefresh(t->input);
|
||
}
|
||
return;
|
||
}
|
||
|
||
dlines_t dl = {0};
|
||
build_display(t, &dl, w);
|
||
int total = dl.n > 0 ? dl.n : 1;
|
||
int max_scroll = total - h;
|
||
if (max_scroll < 0)
|
||
max_scroll = 0;
|
||
if (t->log_scroll > max_scroll)
|
||
t->log_scroll = max_scroll;
|
||
if (t->log_scroll < 0)
|
||
t->log_scroll = 0;
|
||
int start = total - h - t->log_scroll;
|
||
if (start < 0)
|
||
start = 0;
|
||
t->log_start = start;
|
||
|
||
if (t->row_blk_cap < h) {
|
||
int *nb = realloc(t->row_blk, (size_t)h * sizeof(int));
|
||
if (nb) {
|
||
t->row_blk = nb;
|
||
t->row_blk_cap = h;
|
||
}
|
||
}
|
||
for (int row = 0; row < h; row++) {
|
||
int li = start + row;
|
||
if (row < t->row_blk_cap)
|
||
t->row_blk[row] = li >= 0 && li < dl.n ? dl.lines[li].blk : -1;
|
||
if (li < 0 || li >= dl.n)
|
||
continue;
|
||
dline_t *L = &dl.lines[li];
|
||
if (L->fill && HAS_COLOR())
|
||
for (int x = 0; x < w; x++)
|
||
put_wc(t->logw, row, x, L' ', (int)COLOR_PAIR(L->fill));
|
||
draw_ansi_at(t->logw, row, L->indent, w - L->indent, L->text, L->pair,
|
||
L->bold, L->dim);
|
||
if (L->bar)
|
||
put_wc(t->logw, row, 0, L'┃', HAS_COLOR() ? (int)COLOR_PAIR(L->bar) : 0);
|
||
}
|
||
draw_selection(t, &dl, h, w);
|
||
if (t->log_scroll > 0 && h > 2) {
|
||
char more[64];
|
||
snprintf(more, sizeof(more), g_ascii ? " v%d" : " ↓%d", t->log_scroll);
|
||
wmove(t->logw, h - 1, 0);
|
||
wclrtoeol(t->logw);
|
||
int mw = utf8_cols(more, strlen(more));
|
||
draw_ansi_at(t->logw, h - 1, w - mw - 1 > 0 ? w - mw - 1 : 0, mw, more,
|
||
CP_MUTED, 0, 0);
|
||
}
|
||
dlines_free(&dl);
|
||
draw_completion(t, h - 1, 0, w < 90 ? w : 90, h - 1, 1);
|
||
wrefresh(t->logw);
|
||
}
|
||
|
||
static int is_home(const tui_t *t) {
|
||
return t->hist.n == 0 && !t->as_live && !t->busy;
|
||
}
|
||
|
||
static void layout(tui_t *t);
|
||
|
||
static void draw_topbar(tui_t *t) {
|
||
if (!t->header)
|
||
return;
|
||
WINDOW *w = t->header;
|
||
if (HAS_COLOR())
|
||
wbkgd(w, COLOR_PAIR(CP_TOPBAR));
|
||
werase(w);
|
||
int bw = getmaxx(w);
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
char left[256];
|
||
const char *title = t->title[0] ? t->title : "New session";
|
||
/* Session store ids look like hex prefixes — keep chrome human. */
|
||
{
|
||
int hexish = 1;
|
||
size_t n = strlen(title);
|
||
if (n >= 8 && n <= 40) {
|
||
for (size_t i = 0; i < n; i++) {
|
||
char c = title[i];
|
||
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') ||
|
||
(c >= 'A' && c <= 'F') || c == '-')) {
|
||
hexish = 0;
|
||
break;
|
||
}
|
||
}
|
||
} else {
|
||
hexish = 0;
|
||
}
|
||
if (hexish)
|
||
title = "New session";
|
||
}
|
||
/* Hermit badge + title + model. Role lives only in the Seat Deck. */
|
||
int x = 1;
|
||
draw_hermit_badge(w, 0, x, t);
|
||
x += 4;
|
||
x += draw_ansi_at(w, 0, x, 6, "Hermit", CP_TOPBAR_ACCENT, 1, 0);
|
||
x += draw_ansi_at(w, 0, x, 1, " ", CP_TOPBAR_MUTED, 0, 0);
|
||
int model_room = 0;
|
||
char mid[160] = "";
|
||
if (cfg) {
|
||
snprintf(mid, sizeof(mid), "%.40s%s%.20s", cfg->model,
|
||
cfg->provider[0] ? " · " : "", cfg->provider);
|
||
model_room = utf8_cols(mid, strlen(mid)) + 2;
|
||
}
|
||
int title_max = bw - x - 1 - model_room;
|
||
if (title_max < 8)
|
||
title_max = 8;
|
||
size_t tfit = utf8_fit_cols(title, strlen(title), title_max);
|
||
snprintf(left, sizeof(left), "%.*s", (int)tfit, title);
|
||
x += draw_ansi_at(w, 0, x, bw - x, left, CP_TOPBAR, 1, 0);
|
||
if (mid[0] && x + 2 < bw)
|
||
draw_ansi_at(w, 0, x + 2, bw - (x + 2), mid, CP_TOPBAR_MUTED, 0, 0);
|
||
wrefresh(w);
|
||
}
|
||
|
||
static void redraw_all(tui_t *t) {
|
||
if (is_home(t) != t->home)
|
||
layout(t);
|
||
theme_paint_root();
|
||
draw_topbar(t);
|
||
draw_log(t);
|
||
draw_todo(t);
|
||
draw_side(t);
|
||
draw_status(t);
|
||
draw_input(t);
|
||
}
|
||
|
||
/* --- session reload ------------------------------------------------------- */
|
||
|
||
static void push_system_line(tui_t *t, const char *fmt, ...);
|
||
|
||
/* Push finished background jobs into the transcript (non-modal). */
|
||
static int drain_job_completions(tui_t *t) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
if (!cfg || !cfg->runtime)
|
||
return 0;
|
||
cst_job_completion_t comps[8];
|
||
int n = cst_runtime_take_ui_completions(cfg->runtime, comps, 8);
|
||
for (int i = 0; i < n; i++) {
|
||
const char *mark =
|
||
!strcmp(comps[i].status, "completed")
|
||
? (g_ascii ? "*" : "✓")
|
||
: (!strcmp(comps[i].status, "cancelled") ? "-"
|
||
: (g_ascii ? "x" : "✗"));
|
||
if (comps[i].ms > 0)
|
||
push_system_line(t, "%s job %s %s exit=%d %.1fs%s%s", mark, comps[i].id,
|
||
comps[i].status, comps[i].exit_code,
|
||
comps[i].ms / 1000.0,
|
||
comps[i].summary[0] ? " — " : "",
|
||
comps[i].summary[0] ? comps[i].summary : "");
|
||
else
|
||
push_system_line(t, "%s job %s %s exit=%d%s%s", mark, comps[i].id,
|
||
comps[i].status, comps[i].exit_code,
|
||
comps[i].summary[0] ? " — " : "",
|
||
comps[i].summary[0] ? comps[i].summary : "");
|
||
}
|
||
if (n > 0)
|
||
t->jobs_done += n;
|
||
return n;
|
||
}
|
||
|
||
static void push_system_line(tui_t *t, const char *fmt, ...) {
|
||
char tmp[1024];
|
||
va_list ap;
|
||
va_start(ap, fmt);
|
||
vsnprintf(tmp, sizeof(tmp), fmt, ap);
|
||
va_end(ap);
|
||
block_t *b = blocks_push(&t->hist);
|
||
if (!b)
|
||
return;
|
||
b->kind = BLK_SYSTEM;
|
||
block_set_body(b, tmp, strlen(tmp));
|
||
}
|
||
|
||
typedef struct {
|
||
char id[CTL_MAX_ID];
|
||
char title[160];
|
||
} call_title_t;
|
||
|
||
/* Native tool_calls JSON → per-call one-line titles for replayed cards. */
|
||
static void remember_call_titles(const char *json, call_title_t *v, int *n) {
|
||
cJSON *arr = cJSON_Parse(json);
|
||
const cJSON *it;
|
||
cJSON_ArrayForEach(it, arr) {
|
||
const cJSON *id = cJSON_GetObjectItemCaseSensitive(it, "id");
|
||
const cJSON *fn = cJSON_GetObjectItemCaseSensitive(it, "function");
|
||
const cJSON *name = cJSON_GetObjectItemCaseSensitive(fn, "name");
|
||
const cJSON *args = cJSON_GetObjectItemCaseSensitive(fn, "arguments");
|
||
if (!cJSON_IsString(id) || !cJSON_IsString(name))
|
||
continue;
|
||
ctl_tool_call_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
if (cst_tool_call_from_native(&c, id->valuestring, name->valuestring,
|
||
cJSON_IsString(args) ? args->valuestring
|
||
: "{}") == 0) {
|
||
int k = *n < 64 ? (*n)++ : 63;
|
||
snprintf(v[k].id, sizeof(v[k].id), "%s", id->valuestring);
|
||
tool_arg_summary(&c, v[k].title, sizeof(v[k].title));
|
||
}
|
||
for (int i = 0; i < c.nargs; i++)
|
||
free(c.args[i].value);
|
||
}
|
||
cJSON_Delete(arr);
|
||
}
|
||
|
||
static void reload_hist_from_session(tui_t *t) {
|
||
live_end_cleanup(t);
|
||
blocks_clear(&t->hist);
|
||
t->log_scroll = 0;
|
||
t->think_idx = -1;
|
||
t->last_tool_idx = -1;
|
||
t->focus_think_idx = -1;
|
||
|
||
cst_session_t *s = cst_agent_session(t->agent);
|
||
if (!s)
|
||
return;
|
||
cst_session_lock(s);
|
||
int n = cst_session_count(s);
|
||
int non_sys = 0;
|
||
for (int i = 0; i < n; i++) {
|
||
const cst_message_t *m = cst_session_get(s, i);
|
||
if (m && m->role != CST_ROLE_SYSTEM)
|
||
non_sys++;
|
||
}
|
||
int skip = non_sys > REPLAY_MSGS ? non_sys - REPLAY_MSGS : 0;
|
||
int seen = 0;
|
||
static call_title_t titles[64];
|
||
int ntitles = 0;
|
||
for (int i = 0; i < n; i++) {
|
||
const cst_message_t *m = cst_session_get(s, i);
|
||
if (!m || m->role == CST_ROLE_SYSTEM)
|
||
continue;
|
||
if (seen++ < skip)
|
||
continue;
|
||
if (m->role == CST_ROLE_ASSISTANT && m->tool_calls_json)
|
||
remember_call_titles(m->tool_calls_json, titles, &ntitles);
|
||
if (m->role == CST_ROLE_ASSISTANT &&
|
||
(!m->content || !m->content[strspn(m->content, " \t\r\n")]))
|
||
continue; /* tool calls only */
|
||
block_t *b = blocks_push(&t->hist);
|
||
if (!b)
|
||
continue;
|
||
if (msg_is_tool_result(m)) {
|
||
b->kind = BLK_TOOL;
|
||
snprintf(b->tool_name, sizeof(b->tool_name), "%s",
|
||
m->tool_name[0] ? m->tool_name : "tool");
|
||
snprintf(b->tool_id, sizeof(b->tool_id), "%s", m->tool_call_id);
|
||
snprintf(b->meta, sizeof(b->meta), "done");
|
||
const char *body;
|
||
size_t blen;
|
||
tool_result_view(m->content, &body, &blen);
|
||
block_set_body(b, body, blen);
|
||
{
|
||
char path[256];
|
||
diff_extract_path(b->body, NULL, path, sizeof(path));
|
||
const char *title = path;
|
||
for (int k = 0; !path[0] && k < ntitles; k++)
|
||
if (strcmp(titles[k].id, m->tool_call_id) == 0)
|
||
title = titles[k].title;
|
||
block_set_title(b, title);
|
||
}
|
||
/* OpenCode: write/edit cards open (still fold at DIFF_FOLD_LINES). */
|
||
if (cst_tool_is_write(b->tool_name))
|
||
b->expanded = 1;
|
||
else
|
||
b->expanded = 0;
|
||
t->last_tool_idx = t->hist.n - 1;
|
||
} else if (cst_message_is_note(m)) {
|
||
b->kind = BLK_SYSTEM;
|
||
const char *c = m->content ? m->content : "";
|
||
const char *nl = strchr(c, '\n');
|
||
block_set_body(b, c, nl ? (size_t)(nl - c) : strlen(c));
|
||
} else if (m->role == CST_ROLE_USER) {
|
||
b->kind = BLK_USER;
|
||
block_set_title(b, "You");
|
||
cst_buf_t ub = {0};
|
||
cst_buf_puts(&ub, m->content ? m->content : "");
|
||
for (int k = 0; k < m->nattach; k++) {
|
||
char d[128];
|
||
cst_attach_describe(&m->attach[k], d, sizeof(d));
|
||
cst_buf_printf(&ub, "\n[%s %s · %s]",
|
||
cst_attach_is_image(&m->attach[k]) ? "image"
|
||
: "document",
|
||
m->attach[k].name, d);
|
||
}
|
||
block_set_body(b, ub.buf ? ub.buf : "", ub.len);
|
||
cst_buf_free(&ub);
|
||
b->expanded = 1;
|
||
} else if (m->role == CST_ROLE_ASSISTANT) {
|
||
b->kind = BLK_ASSISTANT;
|
||
block_set_title(b, "Assistant");
|
||
if (m->content)
|
||
block_set_body(b, m->content, strlen(m->content));
|
||
b->expanded = 1;
|
||
block_render_assistant(b);
|
||
}
|
||
}
|
||
cst_session_unlock(s);
|
||
}
|
||
|
||
/* --- layout --------------------------------------------------------------- */
|
||
|
||
static int input_visual_rows(const tui_t *t) {
|
||
int cr, cc;
|
||
int rows = input_layout(t, NULL, NULL, &cr, &cc);
|
||
if (rows < 1)
|
||
rows = 1;
|
||
if (rows > INPUT_MAX_ROWS)
|
||
rows = INPUT_MAX_ROWS;
|
||
return rows;
|
||
}
|
||
|
||
#ifdef _WIN32
|
||
static DWORD g_con_in_mode;
|
||
static int g_con_in_saved;
|
||
|
||
/* QuickEdit makes the console select text itself and swallow the clicks;
|
||
* it is off while the TUI owns the mouse and restored on exit. */
|
||
static void win_console_mouse(int on) {
|
||
HANDLE h = GetStdHandle(STD_INPUT_HANDLE);
|
||
DWORD m;
|
||
if (!GetConsoleMode(h, &m))
|
||
return;
|
||
if (!g_con_in_saved) {
|
||
g_con_in_mode = m;
|
||
g_con_in_saved = 1;
|
||
}
|
||
if (on)
|
||
m = (m & ~(DWORD)ENABLE_QUICK_EDIT_MODE) | ENABLE_MOUSE_INPUT |
|
||
ENABLE_EXTENDED_FLAGS;
|
||
else
|
||
m = g_con_in_mode | ENABLE_EXTENDED_FLAGS;
|
||
SetConsoleMode(h, m);
|
||
}
|
||
|
||
static CONSOLE_FONT_INFOEX g_con_font;
|
||
static int g_con_font_saved;
|
||
|
||
/* conhost has no glyph fallback: NSimSun, the default on Chinese systems,
|
||
* draws U+2580 as an empty box. */
|
||
static int win_font_has(const WCHAR *face, WCHAR ch) {
|
||
HDC dc = CreateCompatibleDC(NULL);
|
||
if (!dc)
|
||
return 1;
|
||
HFONT font = CreateFontW(16, 0, 0, 0, FW_NORMAL, 0, 0, 0, DEFAULT_CHARSET,
|
||
0, 0, 0, FF_MODERN, face);
|
||
WORD idx = 0;
|
||
int ok = 1;
|
||
if (font) {
|
||
HGDIOBJ old = SelectObject(dc, font);
|
||
if (GetGlyphIndicesW(dc, &ch, 1, &idx, GGI_MARK_NONEXISTING_GLYPHS) == 1)
|
||
ok = idx != 0xffff;
|
||
SelectObject(dc, old);
|
||
DeleteObject(font);
|
||
}
|
||
DeleteDC(dc);
|
||
return ok;
|
||
}
|
||
|
||
/* Raster fonts cannot draw UTF-8 box drawing or CJK; switch the console to
|
||
* a TrueType font of the same height while the TUI runs. */
|
||
static void win_console_font(int on) {
|
||
HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE);
|
||
if (!on) {
|
||
if (g_con_font_saved)
|
||
SetCurrentConsoleFontEx(h, FALSE, &g_con_font);
|
||
g_con_font_saved = 0;
|
||
return;
|
||
}
|
||
CONSOLE_FONT_INFOEX f = {.cbSize = sizeof f};
|
||
if (!GetCurrentConsoleFontEx(h, FALSE, &f))
|
||
return;
|
||
if (!(f.FontFamily & TMPF_TRUETYPE)) {
|
||
g_con_font = f;
|
||
CONSOLE_FONT_INFOEX tt = {.cbSize = sizeof tt};
|
||
tt.dwFontSize.Y = f.dwFontSize.Y > 0 ? f.dwFontSize.Y : 16;
|
||
tt.FontFamily = FF_MODERN | TMPF_TRUETYPE;
|
||
tt.FontWeight = FW_NORMAL;
|
||
wcscpy(tt.FaceName, GetACP() == 936 ? L"NSimSun" : L"Consolas");
|
||
if (!SetCurrentConsoleFontEx(h, FALSE, &tt))
|
||
return;
|
||
g_con_font_saved = 1;
|
||
f = tt;
|
||
}
|
||
if (!getenv("WT_SESSION") && !win_font_has(f.FaceName, 0x2580))
|
||
g_logo_text = 1;
|
||
}
|
||
#endif
|
||
|
||
#define MOUSE_RUN_MAX 16
|
||
/* getmouse() hands out a run of queued events newest first (the Windows
|
||
* console driver queues press, motion and release together); returns them
|
||
* oldest first. */
|
||
static int get_mouse_run(MEVENT *out, int max) {
|
||
int n = 0;
|
||
while (n < max && getmouse(&out[n]) == OK)
|
||
n++;
|
||
for (int i = 0; i < n / 2; i++) {
|
||
MEVENT tmp = out[i];
|
||
out[i] = out[n - 1 - i];
|
||
out[n - 1 - i] = tmp;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
static void apply_mouse(const tui_t *t) {
|
||
mousemask(t->mouse_on ? (mmask_t)(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION)
|
||
: 0,
|
||
NULL);
|
||
mouseinterval(0);
|
||
#ifdef _WIN32
|
||
win_console_mouse(t->mouse_on);
|
||
#elif defined(NCURSES_VERSION)
|
||
/* ncurses only asks for presses and releases (1000). 1002 also reports
|
||
* motion while a button is held, for drag selection; terminals treat it as
|
||
* replacing 1000, so it goes after mousemask and is turned off explicitly. */
|
||
printf(t->mouse_on ? "\033[?1002h" : "\033[?1002l");
|
||
fflush(stdout);
|
||
#endif
|
||
}
|
||
|
||
static void free_windows(tui_t *t) {
|
||
WINDOW **ws[] = {&t->header, &t->logw, &t->status,
|
||
&t->input, &t->todow, &t->side};
|
||
for (size_t i = 0; i < sizeof(ws) / sizeof(ws[0]); i++)
|
||
drop_win(ws[i]);
|
||
}
|
||
|
||
static int clampi(int v, int lo, int hi) {
|
||
return v < lo ? lo : v > hi ? hi : v;
|
||
}
|
||
|
||
static void draw_topbar(tui_t *t);
|
||
|
||
/* Start screen: centered brand + composer. Session: top chrome, transcript,
|
||
* input card, status; optional sidebar. */
|
||
static void layout(tui_t *t) {
|
||
getmaxyx(stdscr, t->rows, t->cols);
|
||
if (t->rows < 4)
|
||
t->rows = 4;
|
||
if (t->cols < 20)
|
||
t->cols = 20;
|
||
theme_paint_root();
|
||
free_windows(t);
|
||
t->home = is_home(t);
|
||
t->side_w = 0;
|
||
t->todo_rows = 0;
|
||
int pad = t->rows >= 20 ? 1 : 0;
|
||
int topbar = (!t->home && t->rows >= 12) ? 1 : 0;
|
||
|
||
if (t->home) {
|
||
t->in_w = t->cols - 8 < 72 ? t->cols - 8 : 72;
|
||
if (t->in_w < 24)
|
||
t->in_w = t->cols - 2;
|
||
t->in_x = (t->cols - t->in_w) / 2;
|
||
} else {
|
||
if (t->show_side && t->cols >= 110)
|
||
t->side_w = clampi(t->cols / 5, 28, 36);
|
||
int mw = t->cols - t->side_w;
|
||
t->in_x = 1;
|
||
t->in_w = mw - 2;
|
||
}
|
||
t->input_rows = input_visual_rows(t);
|
||
t->in_h = t->input_rows + 1 + 2 * pad;
|
||
if (t->in_h > t->rows - 2 - topbar) {
|
||
t->input_rows = clampi(t->rows - 3 - topbar, 1, INPUT_MAX_ROWS);
|
||
t->in_h = t->input_rows + 1;
|
||
}
|
||
|
||
if (t->home) {
|
||
/* Compact vertical rhythm: brand + tagline + composer, not a huge void. */
|
||
int block = 3 + t->in_h + 3;
|
||
int top = (t->rows - 1 - block) / 2;
|
||
if (top < 1)
|
||
top = 1;
|
||
t->in_y = clampi(top + 3, 0, t->rows - 1 - t->in_h);
|
||
t->log_x = t->log_y = 0;
|
||
t->logw = tui_newwin(t->rows - 1, t->cols, 0, 0);
|
||
t->status = tui_newwin(1, t->cols - 2, t->rows - 1, 1);
|
||
} else {
|
||
int mw = t->cols - t->side_w;
|
||
t->in_y = t->rows - 1 - t->in_h;
|
||
int total = 0;
|
||
int open = t->show_todo && !t->side_w ? todo_open_items(t->todo, &total) : 0;
|
||
if (open && t->rows >= 16)
|
||
t->todo_rows = 1 + (total < TODO_PANEL_MAX ? total : TODO_PANEL_MAX);
|
||
t->log_x = 1;
|
||
t->log_y = topbar;
|
||
if (topbar)
|
||
t->header = tui_newwin(1, mw, 0, 0);
|
||
int log_h = t->in_y - 1 - t->todo_rows - t->log_y;
|
||
if (log_h < 1) {
|
||
t->todo_rows = 0;
|
||
log_h = t->in_y - t->log_y > 0 ? t->in_y - t->log_y : 1;
|
||
}
|
||
t->logw = tui_newwin(log_h, mw - 2, t->log_y, 1);
|
||
if (t->todo_rows)
|
||
t->todow = tui_newwin(t->todo_rows, mw - 2, t->log_y + log_h, 1);
|
||
t->status = tui_newwin(1, mw - 2, t->rows - 1, 1);
|
||
if (t->side_w)
|
||
t->side = tui_newwin(t->rows, t->side_w, 0, mw);
|
||
}
|
||
t->input = tui_newwin(t->in_h, t->in_w, t->in_y, t->in_x);
|
||
if (HAS_COLOR()) {
|
||
wbkgdset(t->logw, COLOR_PAIR(CP_PANEL));
|
||
wbkgdset(t->status, COLOR_PAIR(CP_PANEL));
|
||
wbkgdset(t->input, COLOR_PAIR(CP_BOX));
|
||
if (t->header)
|
||
wbkgdset(t->header, COLOR_PAIR(CP_TOPBAR));
|
||
if (t->side)
|
||
wbkgdset(t->side, COLOR_PAIR(CP_SIDE));
|
||
}
|
||
/* Side/status refreshes must not steal the terminal cursor from input. */
|
||
if (t->side)
|
||
leaveok(t->side, TRUE);
|
||
if (t->status)
|
||
leaveok(t->status, TRUE);
|
||
if (t->header)
|
||
leaveok(t->header, TRUE);
|
||
if (t->todow)
|
||
leaveok(t->todow, TRUE);
|
||
scrollok(t->logw, FALSE);
|
||
keypad(t->input, TRUE);
|
||
wtimeout(t->input, 80);
|
||
apply_mouse(t);
|
||
}
|
||
|
||
static void redraw_all(tui_t *t);
|
||
static int start_turn(tui_t *t, char *prompt); /* takes ownership of prompt */
|
||
|
||
static void layout_maybe_grow_input(tui_t *t) {
|
||
int need = input_visual_rows(t);
|
||
if (need != t->input_rows) {
|
||
layout(t);
|
||
wtimeout(t->input, 80);
|
||
redraw_all(t);
|
||
} else {
|
||
draw_input(t);
|
||
}
|
||
}
|
||
|
||
static void update_completion(tui_t *t);
|
||
|
||
static void input_changed(tui_t *t) {
|
||
t->hist_browse = -1;
|
||
t->comp_off = 0;
|
||
update_completion(t);
|
||
}
|
||
|
||
static void input_insert(tui_t *t, const char *p, size_t n) {
|
||
if (t->in_len + n >= INPUT_CAP)
|
||
return;
|
||
memmove(t->inbuf + t->cur + n, t->inbuf + t->cur, t->in_len - t->cur);
|
||
memcpy(t->inbuf + t->cur, p, n);
|
||
t->in_len += n;
|
||
t->cur += n;
|
||
t->inbuf[t->in_len] = 0;
|
||
if (!t->in_paste)
|
||
input_changed(t);
|
||
}
|
||
|
||
static void input_erase(tui_t *t, size_t from, size_t to) {
|
||
if (to > t->in_len)
|
||
to = t->in_len;
|
||
if (from >= to)
|
||
return;
|
||
memmove(t->inbuf + from, t->inbuf + to, t->in_len - to);
|
||
t->in_len -= to - from;
|
||
t->inbuf[t->in_len] = 0;
|
||
if (t->cur >= to)
|
||
t->cur -= to - from;
|
||
else if (t->cur > from)
|
||
t->cur = from;
|
||
input_changed(t);
|
||
}
|
||
|
||
static void input_set(tui_t *t, const char *s) {
|
||
snprintf(t->inbuf, sizeof(t->inbuf), "%s", s ? s : "");
|
||
t->in_len = strlen(t->inbuf);
|
||
t->cur = t->in_len;
|
||
input_changed(t);
|
||
}
|
||
|
||
static void input_clear(tui_t *t) { input_set(t, ""); }
|
||
|
||
static void input_put_wc(tui_t *t, wchar_t wc) {
|
||
char mb[8];
|
||
int n = os_utf8_encode((unsigned long)wc, mb);
|
||
if (n <= 0)
|
||
return;
|
||
input_insert(t, mb, (size_t)n);
|
||
}
|
||
|
||
static void input_put_byte(tui_t *t, char ch) { input_insert(t, &ch, 1); }
|
||
|
||
static size_t next_len(const tui_t *t, size_t at) {
|
||
if (at >= t->in_len)
|
||
return 0;
|
||
size_t i = at + 1;
|
||
while (i < t->in_len && ((unsigned char)t->inbuf[i] & 0xC0) == 0x80)
|
||
i++;
|
||
return i - at;
|
||
}
|
||
|
||
static size_t line_start(const tui_t *t, size_t at) {
|
||
while (at > 0 && t->inbuf[at - 1] != '\n')
|
||
at--;
|
||
return at;
|
||
}
|
||
|
||
static size_t line_end(const tui_t *t, size_t at) {
|
||
while (at < t->in_len && t->inbuf[at] != '\n')
|
||
at++;
|
||
return at;
|
||
}
|
||
|
||
static int is_word(unsigned char c) { return c >= 0x80 || isalnum(c) || c == '_'; }
|
||
|
||
static size_t word_left(const tui_t *t, size_t at) {
|
||
while (at > 0 && !is_word((unsigned char)t->inbuf[at - 1]))
|
||
at--;
|
||
while (at > 0 && is_word((unsigned char)t->inbuf[at - 1]))
|
||
at--;
|
||
return at;
|
||
}
|
||
|
||
static size_t word_right(const tui_t *t, size_t at) {
|
||
while (at < t->in_len && !is_word((unsigned char)t->inbuf[at]))
|
||
at++;
|
||
while (at < t->in_len && is_word((unsigned char)t->inbuf[at]))
|
||
at++;
|
||
return at;
|
||
}
|
||
|
||
static size_t input_hit(const tui_t *t, int want_row, int want_col);
|
||
|
||
/* Move to the previous/next visual row (soft-wrapped or not) keeping the
|
||
* screen column. Returns 0 when already on the first/last row. */
|
||
static int input_line_move(tui_t *t, int dir) {
|
||
int cr, cc;
|
||
int rows = input_layout(t, NULL, NULL, &cr, &cc);
|
||
if (cr + dir < 0 || cr + dir >= rows)
|
||
return 0;
|
||
t->cur = input_hit(t, cr + dir, cc);
|
||
return 1;
|
||
}
|
||
|
||
static void prompt_hist_push(tui_t *t, const char *prompt) {
|
||
if (!prompt || !prompt[0])
|
||
return;
|
||
if (t->prompt_hist_n >= PROMPT_HIST_CAP) {
|
||
free(t->prompt_hist[0]);
|
||
memmove(t->prompt_hist, t->prompt_hist + 1,
|
||
(PROMPT_HIST_CAP - 1) * sizeof(char *));
|
||
t->prompt_hist_n = PROMPT_HIST_CAP - 1;
|
||
}
|
||
t->prompt_hist[t->prompt_hist_n++] = strdup(prompt);
|
||
t->hist_browse = -1;
|
||
}
|
||
|
||
static void hist_load_into_input(tui_t *t, int idx) {
|
||
if (idx < 0 || idx >= t->prompt_hist_n)
|
||
return;
|
||
input_set(t, t->prompt_hist[idx]);
|
||
t->hist_browse = idx;
|
||
}
|
||
|
||
static char *queue_pop(tui_t *t) {
|
||
if (t->queue_n <= 0)
|
||
return NULL;
|
||
char *p = t->queue[0];
|
||
memmove(t->queue, t->queue + 1, (size_t)(t->queue_n - 1) * sizeof(char *));
|
||
t->queue_n--;
|
||
t->queue[t->queue_n] = NULL;
|
||
return p;
|
||
}
|
||
|
||
static void queue_free_all(tui_t *t) {
|
||
for (int i = 0; i < t->queue_n; i++) {
|
||
free(t->queue[i]);
|
||
t->queue[i] = NULL;
|
||
}
|
||
t->queue_n = 0;
|
||
}
|
||
|
||
/* --- hooks (may run on worker thread) ------------------------------------- */
|
||
|
||
static void on_status(const char *msg, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
set_status_locked(t, msg);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void on_assistant_delta(const char *text, size_t len, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
collapse_open_think(t);
|
||
if (!t->as_live)
|
||
live_begin(t);
|
||
live_feed(t, text, len);
|
||
t->log_scroll = 0;
|
||
set_status_locked(t, "streaming…");
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void on_think_delta(const char *text, size_t len, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
if (t->think_idx < 0) {
|
||
block_t *b = blocks_push(&t->hist);
|
||
if (b) {
|
||
b->kind = BLK_THINK;
|
||
block_set_title(b, "Thinking…");
|
||
b->expanded = t->show_thinking;
|
||
t->think_idx = t->hist.n - 1;
|
||
t->focus_think_idx = t->think_idx;
|
||
clock_gettime(CLOCK_MONOTONIC, &t->think_t0);
|
||
t->think_timing = 1;
|
||
}
|
||
}
|
||
if (t->think_idx >= 0 && t->think_idx < t->hist.n && text && len)
|
||
block_append_body(&t->hist.items[t->think_idx], text, len);
|
||
set_status_locked(t, "thinking…");
|
||
t->log_scroll = 0;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void on_tool_event(const char *phase, const ctl_tool_call_t *call,
|
||
const char *detail, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
collapse_open_think(t);
|
||
if (t->as_live &&
|
||
(strcmp(phase, "open") == 0 || strcmp(phase, "ready") == 0))
|
||
live_seal(t);
|
||
if (!call) {
|
||
tui_unlock(t);
|
||
return;
|
||
}
|
||
|
||
int idx = find_tool_block(t, call->id);
|
||
block_t *b = NULL;
|
||
if (idx < 0) {
|
||
b = blocks_push(&t->hist);
|
||
if (!b) {
|
||
tui_unlock(t);
|
||
return;
|
||
}
|
||
b->kind = BLK_TOOL;
|
||
snprintf(b->tool_name, sizeof(b->tool_name), "%s", call->name);
|
||
snprintf(b->tool_id, sizeof(b->tool_id), "%s", call->id);
|
||
b->expanded = 0;
|
||
idx = t->hist.n - 1;
|
||
} else {
|
||
b = &t->hist.items[idx];
|
||
}
|
||
t->last_tool_idx = idx;
|
||
|
||
char args[160];
|
||
tool_arg_summary(call, args, sizeof(args));
|
||
block_set_title(b, args);
|
||
|
||
if (strcmp(phase, "open") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "pending");
|
||
set_status_locked(t, "tool pending…");
|
||
} else if (strcmp(phase, "arg") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "args…");
|
||
} else if (strcmp(phase, "ready") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "ready");
|
||
set_status_locked(t, "tool confirmed");
|
||
} else if (strcmp(phase, "run") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "running");
|
||
set_status_locked(t, "running tool…");
|
||
} else if (strcmp(phase, "progress") == 0) {
|
||
/* sub-agent steps: "explore · grep foo · read_file x" */
|
||
snprintf(b->meta, sizeof(b->meta), "%s", detail ? detail : "running");
|
||
set_status_locked(t, detail ? detail : "sub-agent…");
|
||
} else if (strcmp(phase, "denied") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "denied");
|
||
block_set_body(b, "denied by user", strlen("denied by user"));
|
||
set_status_locked(t, "tool denied");
|
||
} else if (strcmp(phase, "done") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "%s", detail ? detail : "done");
|
||
if (!b->body || !b->body[0])
|
||
attach_tool_output_from_session(t, b);
|
||
/* Auto-expand write/patch cards so diffs are visible like OpenCode. */
|
||
if (cst_tool_is_write(call->name))
|
||
b->expanded = 1;
|
||
set_status_locked(t, "tool done");
|
||
}
|
||
|
||
t->log_scroll = 0;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void on_tool_result(const ctl_tool_call_t *call, const char *out,
|
||
size_t len, int exit_code, void *ud) {
|
||
(void)exit_code;
|
||
tui_t *t = ud;
|
||
if (!call)
|
||
return;
|
||
tui_lock(t);
|
||
int idx = find_tool_block(t, call->id);
|
||
if (idx >= 0) {
|
||
block_t *b = &t->hist.items[idx];
|
||
block_set_body(b, out ? out : "", out ? len : 0);
|
||
char path[256];
|
||
diff_extract_path(b->body, NULL, path, sizeof(path));
|
||
if (path[0])
|
||
block_set_title(b, path);
|
||
tui_mark_dirty(t);
|
||
}
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static int on_permission(const char *tool_name, const char *detail,
|
||
const char *preview, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
snprintf(t->perm_tool, sizeof(t->perm_tool), "%s",
|
||
tool_name ? tool_name : "tool");
|
||
size_t sbl = strlen(CST_PERM_SANDBOX);
|
||
t->perm_sandbox = detail && strncmp(detail, CST_PERM_SANDBOX, sbl) == 0;
|
||
const char *dsrc = !detail ? "" : t->perm_sandbox ? detail + sbl : detail;
|
||
if (t->perm_sandbox) {
|
||
size_t dl = strlen(dsrc);
|
||
if (dl >= sizeof(t->perm_detail)) {
|
||
dsrc += dl - (sizeof(t->perm_detail) - 1);
|
||
while ((*dsrc & 0xC0) == 0x80)
|
||
dsrc++;
|
||
}
|
||
}
|
||
snprintf(t->perm_detail, sizeof(t->perm_detail), "%s", dsrc);
|
||
free(t->perm_preview);
|
||
t->perm_preview = preview ? xstrdup(preview) : NULL;
|
||
t->perm_prefix[0] = 0;
|
||
if (preview && strcmp(t->perm_tool, "run_shell") == 0)
|
||
cst_perms_suggest_prefix(preview, t->perm_prefix, sizeof(t->perm_prefix));
|
||
t->perm_scroll = 0;
|
||
t->awaiting_perm = 1;
|
||
t->perm_ans = -1;
|
||
t->perm_ask = 1;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
|
||
for (;;) {
|
||
tui_lock(t);
|
||
int ans = t->perm_ans;
|
||
if (ans >= 0) {
|
||
t->awaiting_perm = 0;
|
||
t->perm_ask = 0;
|
||
free(t->perm_preview);
|
||
t->perm_preview = NULL;
|
||
}
|
||
tui_unlock(t);
|
||
if (ans >= 0)
|
||
return ans;
|
||
usleep(20000);
|
||
}
|
||
}
|
||
|
||
static int on_ask_user(const char *q, const char *opts, char *ans,
|
||
size_t ans_sz, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
snprintf(t->ask_q, sizeof(t->ask_q), "%s", q ? q : "");
|
||
t->ask_nopts = 0;
|
||
for (const char *p = opts; p && *p && t->ask_nopts < ASK_OPTS_MAX;) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
while (len && (p[len - 1] == '\r' || p[len - 1] == ' '))
|
||
len--;
|
||
if (len)
|
||
snprintf(t->ask_opts[t->ask_nopts++], sizeof(t->ask_opts[0]), "%.*s",
|
||
(int)len, p);
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
t->ask_sel = 0;
|
||
t->ask_buf[0] = 0;
|
||
t->ask_done = 0;
|
||
t->ask_pending = 1;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
for (;;) {
|
||
tui_lock(t);
|
||
int done = t->ask_done;
|
||
if (done) {
|
||
t->ask_pending = 0;
|
||
t->ask_done = 0;
|
||
snprintf(ans, ans_sz, "%s", t->ask_buf);
|
||
}
|
||
int rc = t->ask_rc;
|
||
tui_unlock(t);
|
||
if (done)
|
||
return rc;
|
||
usleep(20000);
|
||
}
|
||
}
|
||
|
||
static void on_todo(const char *text, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
free(t->todo);
|
||
t->todo = text && text[0] ? xstrdup(text) : NULL;
|
||
t->need_layout = 1;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
/* --- modals drawn over the transcript ------------------------------------- */
|
||
|
||
static void modal_footer(tui_t *t, int h, int w, const char *keys) {
|
||
wmove(t->logw, h - 1, 0);
|
||
wclrtoeol(t->logw);
|
||
wattron(t->logw, A_REVERSE);
|
||
draw_ansi_line(t->logw, h - 1, w, keys, CP_PRIMARY, 1, 0);
|
||
wattroff(t->logw, A_REVERSE);
|
||
}
|
||
|
||
static void draw_perm_modal(tui_t *t) {
|
||
int h, w;
|
||
getmaxyx(t->logw, h, w);
|
||
werase(t->logw);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->logw, COLOR_PAIR(CP_PANEL));
|
||
dlines_t dl = {0};
|
||
char head[700];
|
||
if (t->perm_sandbox)
|
||
snprintf(head, sizeof(head),
|
||
"Blocked by the sandbox. Rerun outside the sandbox?");
|
||
else
|
||
snprintf(head, sizeof(head), "Allow %s?", t->perm_tool);
|
||
dlines_push(&dl, head, CP_PRIMARY, 1, 0);
|
||
int is_shell = strcmp(t->perm_tool, "run_shell") == 0;
|
||
if (t->perm_sandbox) {
|
||
push_wrapped(&dl, " $ ", t->perm_preview ? t->perm_preview : "", w,
|
||
CP_TOOL, 1, 0, 0);
|
||
dlines_push(&dl, "", 0, 0, 0);
|
||
push_wrapped(&dl, " ", "sandboxed output (tail):", w, CP_MUTED, 0, 0, 0);
|
||
push_diff_or_body(&dl, t->perm_detail, w, CP_MUTED, 1, 100000, 0);
|
||
} else if (is_shell && t->perm_preview) {
|
||
push_wrapped(&dl, " $ ", t->perm_preview, w, CP_TOOL, 1, 0, 0);
|
||
} else {
|
||
push_wrapped(&dl, " ", t->perm_detail, w, CP_MUTED, 0, 0, 0);
|
||
if (t->perm_preview && t->perm_preview[0]) {
|
||
dlines_push(&dl, "", 0, 0, 0);
|
||
push_diff_or_body(&dl, t->perm_preview, w, CP_MUTED, 1, 100000, 1);
|
||
}
|
||
}
|
||
int body_h = h - 1;
|
||
int max_scroll = dl.n > body_h ? dl.n - body_h : 0;
|
||
if (t->perm_scroll > max_scroll)
|
||
t->perm_scroll = max_scroll;
|
||
if (t->perm_scroll < 0)
|
||
t->perm_scroll = 0;
|
||
for (int r = 0; r < body_h && t->perm_scroll + r < dl.n; r++) {
|
||
dline_t *L = &dl.lines[t->perm_scroll + r];
|
||
draw_ansi_line(t->logw, r, w, L->text, L->pair, L->bold, L->dim);
|
||
}
|
||
char keys[400];
|
||
int k = t->perm_sandbox
|
||
? snprintf(keys, sizeof(keys), " [y] rerun once outside")
|
||
: snprintf(keys, sizeof(keys),
|
||
" [y] allow once [a] always allow %s", t->perm_tool);
|
||
if (t->perm_prefix[0])
|
||
k += snprintf(keys + k, sizeof(keys) - (size_t)k,
|
||
" [p] always allow `%.60s …`", t->perm_prefix);
|
||
snprintf(keys + k, sizeof(keys) - (size_t)k, " [n/Esc] %s%s",
|
||
t->perm_sandbox ? "keep result" : "deny",
|
||
max_scroll ? " ↑↓ scroll" : "");
|
||
modal_footer(t, h, w, keys);
|
||
dlines_free(&dl);
|
||
wrefresh(t->logw);
|
||
}
|
||
|
||
static void draw_ask_modal(tui_t *t) {
|
||
int h, w;
|
||
getmaxyx(t->logw, h, w);
|
||
werase(t->logw);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->logw, COLOR_PAIR(CP_PANEL));
|
||
dlines_t dl = {0};
|
||
dlines_push(&dl, "The agent asks:", CP_PRIMARY, 1, 0);
|
||
push_wrapped(&dl, " ", t->ask_q, w, CP_PANEL, 1, 0, 0);
|
||
dlines_push(&dl, "", 0, 0, 0);
|
||
for (int i = 0; i < t->ask_nopts; i++) {
|
||
char line[320];
|
||
snprintf(line, sizeof(line), "%s %d. %s",
|
||
i == t->ask_sel && !t->ask_buf[0] ? (g_ascii ? ">" : "▶") : " ",
|
||
i + 1, t->ask_opts[i]);
|
||
dlines_push(&dl, line, i == t->ask_sel ? CP_PRIMARY : CP_PANEL,
|
||
i == t->ask_sel, 0);
|
||
}
|
||
if (t->ask_nopts)
|
||
dlines_push(&dl, "", 0, 0, 0);
|
||
char typed[1100];
|
||
snprintf(typed, sizeof(typed), " answer: %s_", t->ask_buf);
|
||
dlines_push(&dl, typed, CP_ASSIST, 0, 0);
|
||
int start = dl.n > h - 1 ? dl.n - (h - 1) : 0;
|
||
for (int r = 0; r < h - 1 && start + r < dl.n; r++) {
|
||
dline_t *L = &dl.lines[start + r];
|
||
draw_ansi_line(t->logw, r, w, L->text, L->pair, L->bold, L->dim);
|
||
}
|
||
modal_footer(t, h, w,
|
||
t->ask_nopts ? " ↑↓/1-9 pick · type your own answer · Enter "
|
||
"send · Esc dismiss"
|
||
: " type your answer · Enter send · Esc dismiss");
|
||
dlines_free(&dl);
|
||
wrefresh(t->logw);
|
||
}
|
||
|
||
/* --- modal picker (↑↓ Enter Esc) ------------------------------------------ */
|
||
|
||
static int ci_contains(const char *hay, const char *needle) {
|
||
if (!needle[0])
|
||
return 1;
|
||
for (const char *h = hay; *h; h++) {
|
||
size_t i = 0;
|
||
while (needle[i] && h[i] &&
|
||
tolower((unsigned char)h[i]) == tolower((unsigned char)needle[i]))
|
||
i++;
|
||
if (!needle[i])
|
||
return 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* Centered dialog over the current screen: type to filter, ↑↓ / wheel to
|
||
* move, Enter or click to pick. items: n labels (each ≤127 chars). Returns
|
||
* the original index or -1. */
|
||
static int picker_run(tui_t *t, const char *title, char items[][128], int n,
|
||
int cur_idx) {
|
||
if (n <= 0)
|
||
return -1;
|
||
if (cur_idx >= n)
|
||
cur_idx = -1; /* -1: no current item */
|
||
int *vis = malloc((size_t)n * sizeof(int));
|
||
if (!vis)
|
||
return -1;
|
||
char query[64] = "";
|
||
size_t qlen = 0;
|
||
int sel = 0, top = 0, result = -1, nv = 0;
|
||
int first = 1, last_dh = -1, last_dw = -1;
|
||
WINDOW *dlg = NULL;
|
||
curs_set(0);
|
||
|
||
for (;;) {
|
||
nv = 0;
|
||
for (int i = 0; i < n; i++)
|
||
if (ci_contains(items[i], query))
|
||
vis[nv++] = i;
|
||
if (first) {
|
||
for (int k = 0; k < nv; k++)
|
||
if (vis[k] == cur_idx)
|
||
sel = k;
|
||
first = 0;
|
||
}
|
||
if (sel >= nv)
|
||
sel = nv > 0 ? nv - 1 : 0;
|
||
|
||
int rows, cols;
|
||
getmaxyx(stdscr, rows, cols);
|
||
int dw = cols - 4 < 80 ? cols - 4 : 80;
|
||
int list_h = rows - 10;
|
||
if (list_h > n)
|
||
list_h = n;
|
||
if (list_h < 1)
|
||
list_h = 1;
|
||
int dh = list_h + 5;
|
||
if (dh > rows)
|
||
dh = rows;
|
||
if (dw < 10)
|
||
dw = cols;
|
||
int dy = (rows - dh) / 2, dx = (cols - dw) / 2;
|
||
if (sel < top)
|
||
top = sel;
|
||
if (sel >= top + list_h)
|
||
top = sel - list_h + 1;
|
||
if (top > 0 && top + list_h > nv)
|
||
top = nv > list_h ? nv - list_h : 0;
|
||
|
||
drop_win(&dlg);
|
||
if (last_dh >= 0 && (dh != last_dh || dw != last_dw))
|
||
redraw_all(t); /* uncover what a larger dialog hid */
|
||
last_dh = dh;
|
||
last_dw = dw;
|
||
dlg = tui_newwin(dh, dw, dy, dx);
|
||
if (!dlg)
|
||
break;
|
||
keypad(dlg, TRUE);
|
||
wtimeout(dlg, -1);
|
||
if (HAS_COLOR())
|
||
wbkgd(dlg, COLOR_PAIR(CP_BOX));
|
||
werase(dlg);
|
||
draw_ansi_at(dlg, 1, 2, dw - 8, title ? title : "Select", CP_BOX_BRIGHT, 1,
|
||
0);
|
||
draw_ansi_at(dlg, 1, dw - 5, 3, "esc", CP_BOX_MUTED, 0, 0);
|
||
if (qlen)
|
||
draw_ansi_at(dlg, 2, 2, dw - 4, query, CP_BOX, 0, 0);
|
||
else
|
||
draw_ansi_at(dlg, 2, 2, dw - 4, "Search", CP_BOX_MUTED, 0, 0);
|
||
for (int i = 0; i < list_h && top + i < nv && 4 + i < dh; i++) {
|
||
int k = top + i, idx = vis[k], y = 3 + i + 1;
|
||
int on = k == sel;
|
||
int pair = on ? CP_SEL : CP_BOX;
|
||
int attrs = HAS_COLOR() ? (int)COLOR_PAIR(pair) : (on ? A_REVERSE : 0);
|
||
for (int x = 1; x < dw - 1; x++)
|
||
put_wc(dlg, y, x, L' ', attrs);
|
||
int used = draw_ansi_at(dlg, y, 2, 2,
|
||
idx == cur_idx ? (g_ascii ? "*" : "●") : " ",
|
||
on ? CP_SEL : CP_BOX_ACCENT, 0, 0);
|
||
draw_ansi_at(dlg, y, 2 + used + 1, dw - 6, items[idx],
|
||
on ? CP_SEL : CP_BOX, on, 0);
|
||
}
|
||
if (nv == 0)
|
||
draw_ansi_at(dlg, 4, 2, dw - 4, "No matches", CP_BOX_MUTED, 0, 0);
|
||
wrefresh(dlg);
|
||
|
||
wint_t wch = 0;
|
||
int rc = tui_wget_wch(dlg, &wch);
|
||
if (rc == ERR)
|
||
continue;
|
||
if (rc == KEY_CODE_YES) {
|
||
if (wch == KEY_UP && sel > 0)
|
||
sel--;
|
||
else if (wch == KEY_DOWN && sel + 1 < nv)
|
||
sel++;
|
||
else if (wch == KEY_PPAGE)
|
||
sel = sel > list_h ? sel - list_h : 0;
|
||
else if (wch == KEY_NPAGE)
|
||
sel = sel + list_h < nv ? sel + list_h : (nv > 0 ? nv - 1 : 0);
|
||
else if (wch == KEY_BACKSPACE && qlen)
|
||
query[qlen -= utf8_prev_len(query, qlen)] = 0, sel = 0, top = 0;
|
||
else if (wch == KEY_ENTER && nv > 0) {
|
||
result = vis[sel];
|
||
break;
|
||
} else if (wch == KEY_RESIZE) {
|
||
layout(t);
|
||
redraw_all(t);
|
||
} else if (wch == KEY_MOUSE) {
|
||
MEVENT evs[MOUSE_RUN_MAX], ev;
|
||
int ne = get_mouse_run(evs, MOUSE_RUN_MAX), k0 = ne - 1;
|
||
if (ne == 0)
|
||
continue;
|
||
for (int i = 0; i < ne; i++)
|
||
if (evs[i].bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED))
|
||
k0 = i; /* a click anywhere in the run wins over its release */
|
||
ev = evs[k0];
|
||
if (ev.bstate & BUTTON4_PRESSED) {
|
||
if (sel > 0)
|
||
sel--;
|
||
} else if (ev.bstate & BUTTON5_PRESSED) {
|
||
if (sel + 1 < nv)
|
||
sel++;
|
||
} else if (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) {
|
||
int k = top + (ev.y - dy - 4);
|
||
if (ev.y < dy || ev.y >= dy + dh || ev.x < dx || ev.x >= dx + dw)
|
||
break; /* click outside cancels */
|
||
if (ev.y >= dy + 4 && k >= 0 && k < nv) {
|
||
result = vis[k];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 10 || wch == 13) {
|
||
if (nv > 0) {
|
||
result = vis[sel];
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 27 || wch == 3) /* Esc / Ctrl+C */
|
||
break;
|
||
if (wch == 127 || wch == 8) {
|
||
if (qlen)
|
||
query[qlen -= utf8_prev_len(query, qlen)] = 0;
|
||
sel = top = 0;
|
||
continue;
|
||
}
|
||
if (wch == 21) { /* Ctrl+U */
|
||
qlen = 0;
|
||
query[0] = 0;
|
||
continue;
|
||
}
|
||
if (wch == 14 || wch == 16) { /* Ctrl+N / Ctrl+P */
|
||
if (wch == 14 && sel + 1 < nv)
|
||
sel++;
|
||
else if (wch == 16 && sel > 0)
|
||
sel--;
|
||
continue;
|
||
}
|
||
if (iswprint(wch)) {
|
||
char mb[8];
|
||
int k = os_utf8_encode((unsigned long)(wchar_t)wch, mb);
|
||
if (k > 0 && qlen + (size_t)k < sizeof(query)) {
|
||
memcpy(query + qlen, mb, (size_t)k);
|
||
qlen += (size_t)k;
|
||
query[qlen] = 0;
|
||
sel = top = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
drop_win(&dlg);
|
||
free(vis);
|
||
curs_set(1);
|
||
layout(t);
|
||
redraw_all(t);
|
||
return result;
|
||
}
|
||
|
||
/* Full-screen hidden input (API keys). Returns 0 with out filled, -1 on
|
||
* Esc / Ctrl+C. */
|
||
static int secret_input_run(tui_t *t, const char *title, const char *hint,
|
||
char *out, size_t n) {
|
||
size_t len = 0;
|
||
out[0] = 0;
|
||
int rc = -1;
|
||
wtimeout(stdscr, -1);
|
||
keypad(stdscr, TRUE);
|
||
curs_set(1);
|
||
for (;;) {
|
||
int rows, cols;
|
||
getmaxyx(stdscr, rows, cols);
|
||
(void)rows;
|
||
werase(stdscr);
|
||
if (HAS_COLOR())
|
||
wbkgd(stdscr, COLOR_PAIR(CP_PANEL));
|
||
if (HAS_COLOR())
|
||
attron(COLOR_PAIR(CP_PRIMARY) | A_BOLD);
|
||
mvprintw(1, 2, "%.*s", cols - 4, title);
|
||
if (HAS_COLOR())
|
||
attroff(COLOR_PAIR(CP_PRIMARY) | A_BOLD);
|
||
if (HAS_COLOR())
|
||
attron(COLOR_PAIR(CP_MUTED));
|
||
mvprintw(2, 2, "%.*s", cols - 4, hint);
|
||
mvprintw(6, 2, "%.*s", cols - 4,
|
||
"Enter save · Esc cancel · Ctrl+U clear · paste works");
|
||
if (HAS_COLOR())
|
||
attroff(COLOR_PAIR(CP_MUTED));
|
||
int room = cols - 12 > 1 ? cols - 12 : 1;
|
||
int shown = (int)len < room ? (int)len : room;
|
||
mvprintw(4, 2, "key: ");
|
||
for (int i = 0; i < shown; i++)
|
||
addch('*');
|
||
if ((int)len > shown)
|
||
addstr("…");
|
||
move(4, 7 + shown);
|
||
refresh();
|
||
|
||
wint_t wch = 0;
|
||
int r = tui_wget_wch(stdscr, &wch);
|
||
if (r == ERR)
|
||
continue;
|
||
if (r == KEY_CODE_YES) {
|
||
if (wch == KEY_BACKSPACE && len > 0)
|
||
out[--len] = 0;
|
||
else if (wch == KEY_ENTER && len > 0) {
|
||
rc = 0;
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 10 || wch == 13) {
|
||
if (len > 0) {
|
||
rc = 0;
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 27 || wch == 3)
|
||
break;
|
||
if (wch == 127 || wch == 8) {
|
||
if (len > 0)
|
||
out[--len] = 0;
|
||
continue;
|
||
}
|
||
if (wch == 21) {
|
||
len = 0;
|
||
out[0] = 0;
|
||
continue;
|
||
}
|
||
if (wch > 32 && wch < 127 && len + 1 < n) {
|
||
out[len++] = (char)wch;
|
||
out[len] = 0;
|
||
}
|
||
}
|
||
if (rc != 0)
|
||
memset(out, 0, n);
|
||
layout(t);
|
||
wtimeout(t->input, 80);
|
||
keypad(t->input, TRUE);
|
||
redraw_all(t);
|
||
return rc;
|
||
}
|
||
|
||
/* Saved / env key for the provider, else ask for one and save it. */
|
||
/* --- /models probe ---------------------------------------------------------
|
||
* File-scope so a probe still running when the TUI exits writes nowhere
|
||
* stale. */
|
||
|
||
#define AM_MAX 512 /* routers list hundreds; the picker filters as you type */
|
||
enum { AM_NONE = 0, AM_RUNNING, AM_OK, AM_FAILED, AM_AUTH };
|
||
|
||
static pthread_mutex_t g_am_mu = PTHREAD_MUTEX_INITIALIZER;
|
||
static struct {
|
||
char provider[32];
|
||
char ids[AM_MAX][128];
|
||
int n;
|
||
int state;
|
||
char err[256];
|
||
} g_am;
|
||
static volatile int g_am_changed;
|
||
static volatile int g_am_threads;
|
||
|
||
typedef struct {
|
||
char provider[32];
|
||
char base[512];
|
||
char key[512];
|
||
int have_cache; /* a cached list is showing: keep it if the fetch fails */
|
||
} probe_arg_t;
|
||
|
||
static void am_store(const char *provider, int r, char ids[][128],
|
||
const char *err) {
|
||
pthread_mutex_lock(&g_am_mu);
|
||
if (strcmp(g_am.provider, provider) == 0) {
|
||
g_am.state = r >= 0 ? AM_OK : r == CST_MODELS_AUTH ? AM_AUTH : AM_FAILED;
|
||
g_am.n = r > 0 ? r : 0;
|
||
for (int i = 0; i < g_am.n; i++)
|
||
memcpy(g_am.ids[i], ids[i], 128);
|
||
snprintf(g_am.err, sizeof(g_am.err), "%s", err ? err : "");
|
||
g_am_changed = 1;
|
||
}
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
}
|
||
|
||
static void *probe_thread(void *arg) {
|
||
probe_arg_t *a = arg;
|
||
static char ids[AM_MAX][128]; /* one probe runs per provider switch */
|
||
char err[256];
|
||
int r = cst_ps_fetch(a->provider, a->base, a->key, ids, AM_MAX, err,
|
||
sizeof(err));
|
||
if (r >= 0 || r == CST_MODELS_AUTH || !a->have_cache)
|
||
am_store(a->provider, r, ids, err);
|
||
memset(a->key, 0, sizeof(a->key));
|
||
free(a);
|
||
pthread_mutex_lock(&g_am_mu);
|
||
g_am_threads--;
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
return NULL;
|
||
}
|
||
|
||
static void start_probe(tui_t *t) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
if (!cfg || !cfg->api_key[0] || g_am_threads > 0)
|
||
return;
|
||
static char ids[AM_MAX][128];
|
||
int fresh = 0;
|
||
int nc = cst_ps_cached(cfg->provider, ids, AM_MAX, &fresh);
|
||
pthread_mutex_lock(&g_am_mu);
|
||
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
if (nc > 0)
|
||
am_store(cfg->provider, nc, ids, "");
|
||
if (fresh)
|
||
return;
|
||
probe_arg_t *a = calloc(1, sizeof(*a));
|
||
if (!a)
|
||
return;
|
||
snprintf(a->provider, sizeof(a->provider), "%s", cfg->provider);
|
||
snprintf(a->base, sizeof(a->base), "%s", cfg->api_base);
|
||
snprintf(a->key, sizeof(a->key), "%s", cfg->api_key);
|
||
a->have_cache = nc > 0;
|
||
pthread_mutex_lock(&g_am_mu);
|
||
if (nc <= 0) {
|
||
g_am.state = AM_RUNNING;
|
||
g_am.n = 0;
|
||
}
|
||
g_am_threads++;
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
pthread_t th;
|
||
if (pthread_create(&th, NULL, probe_thread, a) != 0) {
|
||
pthread_mutex_lock(&g_am_mu);
|
||
g_am_threads--;
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
free(a);
|
||
return;
|
||
}
|
||
pthread_detach(th);
|
||
}
|
||
|
||
/* Copies the API model list for provider; returns the count, or -1 if none
|
||
* is known yet. */
|
||
static int am_models(const char *provider, char out[][128], int max) {
|
||
int n = -1;
|
||
pthread_mutex_lock(&g_am_mu);
|
||
if (strcmp(g_am.provider, provider) == 0 && g_am.state == AM_OK) {
|
||
n = g_am.n < max ? g_am.n : max;
|
||
for (int i = 0; i < n; i++)
|
||
memcpy(out[i], g_am.ids[i], 128);
|
||
}
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
return n;
|
||
}
|
||
|
||
/* After a probe: flag a model the API does not list; with no model yet
|
||
* (router presets) take the first one listed. */
|
||
static void am_check_model(tui_t *t) {
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
char msg[400] = "";
|
||
int picked = 0;
|
||
pthread_mutex_lock(&g_am_mu);
|
||
if (cfg && strcmp(g_am.provider, cfg->provider) == 0) {
|
||
if (g_am.state == AM_AUTH) {
|
||
snprintf(msg, sizeof(msg), "the %s API key was rejected — /key to "
|
||
"replace it", cfg->provider);
|
||
} else if (g_am.state == AM_OK && g_am.n > 0 && !cfg->model[0]) {
|
||
snprintf(cfg->model, sizeof(cfg->model), "%s", g_am.ids[0]);
|
||
snprintf(msg, sizeof(msg), "model → %.127s (%d listed by %s) — /model "
|
||
"to change",
|
||
cfg->model, g_am.n, cfg->provider);
|
||
picked = 1;
|
||
} else if (g_am.state == AM_OK && g_am.n > 0) {
|
||
int found = 0;
|
||
for (int i = 0; i < g_am.n && !found; i++)
|
||
found = strcmp(g_am.ids[i], cfg->model) == 0;
|
||
if (!found)
|
||
snprintf(msg, sizeof(msg), "%s is not in %s's model list — /model "
|
||
"to pick one", cfg->model, cfg->provider);
|
||
}
|
||
}
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
if (picked)
|
||
cst_settings_remember(cfg);
|
||
if (msg[0])
|
||
set_status(t, msg);
|
||
}
|
||
|
||
static int obtain_key(tui_t *t, const char *provider, char *out, size_t n,
|
||
int force_ask) {
|
||
if (!force_ask && cst_creds_resolve(provider, out, n, NULL) == 0)
|
||
return 0;
|
||
char dir[1024] = "the config directory";
|
||
os_config_dir(dir, sizeof(dir));
|
||
const char *base = cst_ps_base(cst_agent_config(t->agent), provider);
|
||
char title[128], hint[1300];
|
||
snprintf(title, sizeof(title), "API key for %s", provider);
|
||
snprintf(hint, sizeof(hint), "%.200s · saved to %.900s/credentials (only you "
|
||
"can read it)",
|
||
base, dir);
|
||
char note[300] = "";
|
||
int flags;
|
||
for (;;) {
|
||
if (secret_input_run(t, title, hint, out, n) != 0)
|
||
return -1;
|
||
set_status(t, "checking the key…");
|
||
draw_status(t);
|
||
static char ids[AM_MAX][128];
|
||
char err[256];
|
||
int r = -1;
|
||
flags = cst_ps_verify_save(provider, base, out, ids, AM_MAX, &r, err,
|
||
sizeof(err));
|
||
if (flags == CST_MODELS_AUTH) {
|
||
snprintf(hint, sizeof(hint), "%s rejected that key (%.200s) — paste "
|
||
"another one",
|
||
base, err);
|
||
continue;
|
||
}
|
||
pthread_mutex_lock(&g_am_mu);
|
||
snprintf(g_am.provider, sizeof(g_am.provider), "%s", provider);
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
am_store(provider, r, ids, err);
|
||
if (flags & CST_PS_UNVERIFIED)
|
||
snprintf(note, sizeof(note), "could not verify the key (%.200s); "
|
||
"saved anyway",
|
||
err);
|
||
break;
|
||
}
|
||
if (flags & CST_PS_UNSAVED)
|
||
set_status(t, "warning: could not save the key; it is used for this run "
|
||
"only");
|
||
else if (note[0])
|
||
set_status(t, note);
|
||
else
|
||
set_status(t, "API key verified and saved");
|
||
return 0;
|
||
}
|
||
|
||
/* --- slash commands ------------------------------------------------------- */
|
||
|
||
/* First-user dumps / JSON / ctl noise make a bad session chrome title. */
|
||
static int title_is_noisy(const char *s) {
|
||
if (!s || !s[0])
|
||
return 1;
|
||
while (*s == ' ' || *s == '\t')
|
||
s++;
|
||
if (*s == '{' || *s == '[')
|
||
return 1;
|
||
if (!strncmp(s, "```", 3))
|
||
return 1;
|
||
if (strstr(s, "\"version\"") || strstr(s, "task controls") ||
|
||
strstr(s, "\"revision\"") || strstr(s, "\"sequence\""))
|
||
return 1;
|
||
return 0;
|
||
}
|
||
|
||
/* One-line human summary into out; empty if nothing usable. */
|
||
static void title_summarize(char *out, size_t sz, const char *src) {
|
||
out[0] = 0;
|
||
if (!src || title_is_noisy(src))
|
||
return;
|
||
while (*src == ' ' || *src == '\t' || *src == '\n' || *src == '\r')
|
||
src++;
|
||
char buf[160];
|
||
size_t o = 0;
|
||
int sp = 0;
|
||
for (const char *p = src; *p && *p != '\n' && *p != '\r' && o + 1 < sizeof(buf);
|
||
p++) {
|
||
unsigned char c = (unsigned char)*p;
|
||
if (c == ' ' || c == '\t') {
|
||
if (o == 0 || sp)
|
||
continue;
|
||
buf[o++] = ' ';
|
||
sp = 1;
|
||
continue;
|
||
}
|
||
sp = 0;
|
||
buf[o++] = (char)c;
|
||
}
|
||
buf[o] = 0;
|
||
if (!buf[0] || title_is_noisy(buf))
|
||
return;
|
||
size_t fit = utf8_fit_cols(buf, strlen(buf), 40);
|
||
snprintf(out, sz, "%.*s", (int)fit, buf);
|
||
}
|
||
|
||
static void refresh_title(tui_t *t) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
char sid[128];
|
||
cst_agent_session_id(t->agent, sid, sizeof(sid));
|
||
cst_session_info_t in;
|
||
memset(&in, 0, sizeof(in));
|
||
t->title[0] = 0;
|
||
if (cfg)
|
||
(void)cst_agent_store_info(cfg->workspace_root, sid, &in);
|
||
if (in.title[0] && !title_is_noisy(in.title)) {
|
||
size_t fit = utf8_fit_cols(in.title, strlen(in.title), 40);
|
||
snprintf(t->title, sizeof(t->title), "%.*s", (int)fit, in.title);
|
||
} else {
|
||
title_summarize(t->title, sizeof(t->title), in.first_user);
|
||
}
|
||
if (!t->title[0])
|
||
snprintf(t->title, sizeof(t->title), "New session");
|
||
}
|
||
|
||
static void refresh_todo(tui_t *t) {
|
||
const char *td = cst_agent_todo(t->agent);
|
||
tui_lock(t);
|
||
free(t->todo);
|
||
t->todo = td && td[0] ? xstrdup(td) : NULL;
|
||
t->need_layout = 1;
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void push_lines(tui_t *t, const char *prefix, const char *text) {
|
||
for (const char *p = text; p && *p;) {
|
||
const char *nl = strchr(p, '\n');
|
||
int len = nl ? (int)(nl - p) : (int)strlen(p);
|
||
push_system_line(t, "%s%.*s", prefix, len > 900 ? 900 : len, p);
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
}
|
||
|
||
static void after_session_change(tui_t *t, const char *note) {
|
||
reload_hist_from_session(t);
|
||
refresh_title(t);
|
||
refresh_todo(t);
|
||
if (note)
|
||
push_system_line(t, "%s", note);
|
||
status_idle(t);
|
||
}
|
||
|
||
typedef struct slash_cmd slash_cmd_t;
|
||
typedef void (*slash_fn)(tui_t *t, char *arg, int *quit);
|
||
struct slash_cmd {
|
||
const char *name;
|
||
const char *aliases; /* space separated */
|
||
const char *args;
|
||
const char *desc;
|
||
int busy_ok;
|
||
slash_fn fn;
|
||
};
|
||
|
||
static const slash_cmd_t *slash_table(int *n);
|
||
|
||
static void cmd_help(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
int n;
|
||
const slash_cmd_t *c = slash_table(&n);
|
||
push_system_line(t, "");
|
||
push_system_line(t, "Commands:");
|
||
for (int i = 0; i < n; i++) {
|
||
char name[64];
|
||
snprintf(name, sizeof(name), "/%s %s", c[i].name, c[i].args);
|
||
char also[64] = "";
|
||
if (c[i].aliases[0])
|
||
snprintf(also, sizeof(also), " (also: %s)", c[i].aliases);
|
||
push_system_line(t, " %-26s %s%s", name, c[i].desc, also);
|
||
}
|
||
push_system_line(t, "Keys: /keys (or F1) lists every shortcut; Ctrl+X is the "
|
||
"leader (Ctrl+X n new session, Ctrl+X m model, ...).");
|
||
push_system_line(t, " Enter send · Esc cancel turn · Ctrl+C twice or Ctrl+D "
|
||
"quit · Ctrl+P command palette");
|
||
push_system_line(t, " Mouse: click cards / Thought to expand, wheel scrolls, "
|
||
"drag copies (double-click word, triple line) · /mouse");
|
||
push_system_line(t, " Rebind in keybinds.json next to settings.json");
|
||
push_system_line(t, "");
|
||
}
|
||
|
||
static void cmd_new(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
char id[128] = {0};
|
||
if (cst_agent_new_session(t->agent, id, sizeof(id)) != 0) {
|
||
push_system_line(t, "Error: failed to create session");
|
||
return;
|
||
}
|
||
after_session_change(t, NULL); /* empty transcript → start screen */
|
||
}
|
||
|
||
static int session_labels(tui_t *t, char ids[][128], char labels[][128],
|
||
int max) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
int n = cst_agent_store_list(cfg->workspace_root, ids, max);
|
||
for (int i = 0; i < n; i++) {
|
||
cst_session_info_t in;
|
||
char when[24] = "?";
|
||
if (cst_agent_store_info(cfg->workspace_root, ids[i], &in) == 0) {
|
||
time_t tt = (time_t)in.mtime;
|
||
struct tm tm;
|
||
os_localtime(&tt, &tm);
|
||
strftime(when, sizeof(when), "%m-%d %H:%M", &tm);
|
||
snprintf(labels[i], 128, "%s %3d msgs %.60s", when, in.messages,
|
||
in.title[0] ? in.title : in.first_user);
|
||
} else {
|
||
snprintf(labels[i], 128, "%s", ids[i]);
|
||
}
|
||
}
|
||
return n;
|
||
}
|
||
|
||
static int picker_run(tui_t *t, const char *title, char items[][128], int n,
|
||
int cur_idx);
|
||
|
||
static void cmd_session(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
static char ids[64][128], labels[64][128];
|
||
char chosen[128];
|
||
if (arg && arg[0]) {
|
||
snprintf(chosen, sizeof(chosen), "%s", arg);
|
||
} else {
|
||
int n = session_labels(t, ids, labels, 64);
|
||
if (n <= 0) {
|
||
push_system_line(t, "Sessions: (none)");
|
||
return;
|
||
}
|
||
char cur[128];
|
||
cst_agent_session_id(t->agent, cur, sizeof(cur));
|
||
int cur_idx = 0;
|
||
for (int i = 0; i < n; i++)
|
||
if (strcmp(ids[i], cur) == 0)
|
||
cur_idx = i;
|
||
int pick = picker_run(t, "Session", labels, n, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
snprintf(chosen, sizeof(chosen), "%s", ids[pick]);
|
||
}
|
||
if (cst_agent_switch_session(t->agent, chosen) != 0) {
|
||
push_system_line(t, "Error: cannot switch to session '%s'", chosen);
|
||
return;
|
||
}
|
||
char note[192];
|
||
snprintf(note, sizeof(note), "(switched to %s)", chosen);
|
||
after_session_change(t, note);
|
||
}
|
||
|
||
static void cmd_rename(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
char sid[128];
|
||
cst_agent_session_id(t->agent, sid, sizeof(sid));
|
||
if (cst_agent_store_title_set(cfg->workspace_root, sid, arg) != 0) {
|
||
push_system_line(t, "Error: cannot set the title");
|
||
return;
|
||
}
|
||
refresh_title(t);
|
||
push_system_line(t, arg && arg[0] ? "(session renamed to \"%s\")"
|
||
: "(session title cleared%s)",
|
||
arg ? arg : "");
|
||
}
|
||
|
||
static void cmd_delete(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
static char ids[64][128], labels[64][128];
|
||
char victim[128];
|
||
if (arg && arg[0]) {
|
||
snprintf(victim, sizeof(victim), "%s", arg);
|
||
} else {
|
||
int n = session_labels(t, ids, labels, 64);
|
||
if (n <= 0)
|
||
return;
|
||
int pick = picker_run(t, "Delete which session?", labels, n, 0);
|
||
if (pick < 0)
|
||
return;
|
||
snprintf(victim, sizeof(victim), "%s", ids[pick]);
|
||
}
|
||
char confirm[2][128];
|
||
snprintf(confirm[0], 128, "No, keep it");
|
||
snprintf(confirm[1], 128, "Yes, delete %.100s", victim);
|
||
if (picker_run(t, "Delete session permanently?", confirm, 2, 0) != 1)
|
||
return;
|
||
char cur[128];
|
||
cst_agent_session_id(t->agent, cur, sizeof(cur));
|
||
int was_current = strcmp(cur, victim) == 0;
|
||
if (was_current) {
|
||
char id[128];
|
||
if (cst_agent_new_session(t->agent, id, sizeof(id)) != 0) {
|
||
push_system_line(t, "Error: cannot open a new session first");
|
||
return;
|
||
}
|
||
}
|
||
if (cst_agent_store_delete(cfg->workspace_root, victim) != 0)
|
||
push_system_line(t, "Error: cannot delete %s", victim);
|
||
if (was_current)
|
||
after_session_change(t, NULL);
|
||
push_system_line(t, "(deleted session %s)", victim);
|
||
}
|
||
|
||
/* "name 1M · vision (api)" for the /model picker. */
|
||
static void model_label(const char *provider, const char *name, int api,
|
||
char *out, size_t n) {
|
||
cst_model_info_t mi;
|
||
cst_model_info(provider, name, &mi);
|
||
char tag[40];
|
||
cst_model_info_label(&mi, tag, sizeof(tag));
|
||
snprintf(out, n, "%-40.72s %.32s%s", name, tag, api ? " (api)" : "");
|
||
}
|
||
|
||
static void cmd_model(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
if (!arg || !arg[0]) {
|
||
static char names[AM_MAX + 32][128], labels[AM_MAX + 32][128];
|
||
int na = am_models(cfg->provider, names, AM_MAX);
|
||
if (na < 0 && cfg->api_key[0]) {
|
||
set_status(t, "fetching the model list…");
|
||
draw_status(t);
|
||
char err[256];
|
||
na = cst_ps_fetch(cfg->provider, cfg->api_base, cfg->api_key, names,
|
||
AM_MAX, err, sizeof(err));
|
||
pthread_mutex_lock(&g_am_mu);
|
||
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
am_store(cfg->provider, na, names, err);
|
||
if (na < 0)
|
||
set_status(t, err);
|
||
else
|
||
status_idle(t);
|
||
}
|
||
if (na < 0)
|
||
na = 0;
|
||
for (int i = 0; i < na; i++)
|
||
model_label(cfg->provider, names[i], 1, labels[i], 128);
|
||
char builtin[32][128];
|
||
int nb = cst_models_list(cfg->model, builtin, 32);
|
||
int n = na;
|
||
for (int i = 0; i < nb && n < AM_MAX + 32; i++) {
|
||
int dup = 0;
|
||
for (int k = 0; k < na && !dup; k++)
|
||
dup = strcmp(names[k], builtin[i]) == 0;
|
||
if (dup)
|
||
continue;
|
||
memcpy(names[n], builtin[i], 128);
|
||
model_label(cfg->provider, builtin[i], 0, labels[n], 128);
|
||
n++;
|
||
}
|
||
int cur_idx = -1;
|
||
for (int i = 0; i < n; i++)
|
||
if (strcmp(names[i], cfg->model) == 0)
|
||
cur_idx = i;
|
||
int pick = picker_run(t, "Model", labels, n, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
snprintf(cfg->model, sizeof(cfg->model), "%s", names[pick]);
|
||
} else {
|
||
snprintf(cfg->model, sizeof(cfg->model), "%.127s", arg);
|
||
}
|
||
cst_settings_remember(cfg);
|
||
cst_model_info_t mi;
|
||
cst_model_info(cfg->provider, cfg->model, &mi);
|
||
push_system_line(t, "(model → %s, context %ld tokens%s)", cfg->model,
|
||
mi.context,
|
||
mi.vision == 1 ? ", takes images"
|
||
: mi.vision == 0 ? ", text only"
|
||
: "");
|
||
}
|
||
|
||
static void cmd_key(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
char key[512];
|
||
if (obtain_key(t, cfg->provider, key, sizeof(key), 1) != 0) {
|
||
push_system_line(t, "(API key unchanged)");
|
||
return;
|
||
}
|
||
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
|
||
memset(key, 0, sizeof(key));
|
||
}
|
||
|
||
static void cmd_effort(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
static const char *const names[] = {"low", "medium", "high", "max"};
|
||
static const char *const desc[] = {"quick answers, little thinking",
|
||
"balanced (default)",
|
||
"thinks longer on hard problems",
|
||
"maximum thinking, slowest"};
|
||
const char *want = NULL;
|
||
if (arg && arg[0]) {
|
||
if (!cst_effort_valid(arg)) {
|
||
push_system_line(t, "Unknown effort '%s' (low medium high max)", arg);
|
||
return;
|
||
}
|
||
want = arg;
|
||
} else {
|
||
char items[4][128];
|
||
int cur_idx = 1;
|
||
for (int i = 0; i < 4; i++) {
|
||
snprintf(items[i], 128, "%-7s %s", names[i], desc[i]);
|
||
if (strcmp(names[i], cfg->effort) == 0)
|
||
cur_idx = i;
|
||
}
|
||
int pick = picker_run(t, "Reasoning effort", items, 4, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
want = names[pick];
|
||
}
|
||
snprintf(cfg->effort, sizeof(cfg->effort), "%s", want);
|
||
cst_settings_remember(cfg);
|
||
if (cst_effort_wire(cfg->provider, cfg->model, cfg->effort))
|
||
push_system_line(t, "(effort → %s)", cfg->effort);
|
||
else
|
||
push_system_line(t, "(effort → %s; %s/%s does not take an effort "
|
||
"setting, it applies to DeepSeek and OpenAI "
|
||
"reasoning models)",
|
||
cfg->effort, cfg->provider, cfg->model);
|
||
}
|
||
|
||
static void cmd_mode(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
static const char *const names[] = {"coding", "general"};
|
||
static const char *const desc[] = {
|
||
"code framed: repo, build, tests (default)",
|
||
"documents, data, research, everyday files"};
|
||
const char *want = NULL;
|
||
if (arg && arg[0]) {
|
||
if (!cst_mode_valid(arg)) {
|
||
push_system_line(t, "Unknown mode '%s' (coding | general)", arg);
|
||
return;
|
||
}
|
||
want = arg;
|
||
} else {
|
||
char items[2][128];
|
||
int cur_idx = 0;
|
||
for (int i = 0; i < 2; i++) {
|
||
snprintf(items[i], 128, "%-8s %s", names[i], desc[i]);
|
||
if (strcmp(names[i], cst_mode_name(cfg->mode)) == 0)
|
||
cur_idx = i;
|
||
}
|
||
int pick = picker_run(t, "Working mode", items, 2, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
want = names[pick];
|
||
}
|
||
snprintf(cfg->mode, sizeof(cfg->mode), "%s", want);
|
||
cst_settings_remember_mode(cfg->mode);
|
||
push_system_line(t, "(mode → %s%s)", cfg->mode,
|
||
strcmp(cfg->mode, "general") == 0
|
||
? "; the system prompt is rewritten on the next turn"
|
||
: "");
|
||
}
|
||
|
||
static void cmd_provider(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
const cst_provider_t *p = NULL;
|
||
if (arg && arg[0]) {
|
||
p = cst_provider_find(arg);
|
||
if (!p) {
|
||
push_system_line(t, "Unknown provider '%s'", arg);
|
||
return;
|
||
}
|
||
} else {
|
||
int n = cst_provider_count();
|
||
char items[32][128];
|
||
int cur_idx = 0;
|
||
for (int i = 0; i < n && i < 32; i++) {
|
||
const cst_provider_t *q = cst_provider_at(i);
|
||
snprintf(items[i], 128, "%-11.24s %.48s (%.24s%s)", q->name,
|
||
q->api_base, q->model[0] ? q->model : "models from API",
|
||
strcmp(cst_provider_source(q), "user") == 0 ? " · models.json"
|
||
: "");
|
||
if (strcmp(q->name, cfg->provider) == 0)
|
||
cur_idx = i;
|
||
}
|
||
int pick = picker_run(t, "Provider", items, n < 32 ? n : 32, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
p = cst_provider_at(pick);
|
||
}
|
||
char key[512];
|
||
if (obtain_key(t, p->name, key, sizeof(key), 0) != 0) {
|
||
push_system_line(t, "(provider unchanged: %s needs an API key)", p->name);
|
||
return;
|
||
}
|
||
cst_ps_apply_provider(cfg, p, key);
|
||
memset(key, 0, sizeof(key));
|
||
push_system_line(t, "(provider → %s · %s · model %s)", p->name, p->api_base,
|
||
cfg->model);
|
||
if (am_models(p->name, NULL, 0) < 0)
|
||
start_probe(t);
|
||
else
|
||
am_check_model(t);
|
||
}
|
||
|
||
static void cmd_protocol(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
cst_protocol_t p = cfg->protocol;
|
||
if (arg && arg[0]) {
|
||
if (cst_protocol_parse(arg, &p) != 0) {
|
||
push_system_line(t, "Unknown protocol '%s' (native-eos native-inc ctl "
|
||
"ctl-heredoc xml)", arg);
|
||
return;
|
||
}
|
||
} else {
|
||
static const cst_protocol_t all[] = {CST_PROTO_NATIVE_EOS,
|
||
CST_PROTO_NATIVE_INC, CST_PROTO_CTL,
|
||
CST_PROTO_CTL_HEREDOC, CST_PROTO_XML};
|
||
char names[5][128];
|
||
int cur_idx = 0;
|
||
for (int i = 0; i < 5; i++) {
|
||
snprintf(names[i], sizeof(names[i]), "%s", cst_protocol_name(all[i]));
|
||
if (all[i] == cfg->protocol)
|
||
cur_idx = i;
|
||
}
|
||
int pick = picker_run(t, "Protocol", names, 5, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
p = all[pick];
|
||
}
|
||
cfg->protocol = p;
|
||
push_system_line(t, "(protocol → %s)", cst_protocol_name(p));
|
||
}
|
||
|
||
static void cmd_tools(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
if (arg && arg[0]) {
|
||
char verb[16] = "", name[64] = "";
|
||
sscanf(arg, "%15s %63s", verb, name);
|
||
int prof;
|
||
if (cst_tool_profile_parse(verb, &prof) == 0) {
|
||
cfg->tool_profile = prof;
|
||
push_system_line(t, "(tool profile → %s)", cst_tool_profile_name(prof));
|
||
} else if ((strcmp(verb, "on") == 0 || strcmp(verb, "off") == 0) &&
|
||
name[0]) {
|
||
if (cst_tool_index(name) < 0) {
|
||
push_system_line(t, "Unknown tool '%s'", name);
|
||
return;
|
||
}
|
||
cst_tool_set_disabled(cfg, name, verb[1] != 'n');
|
||
push_system_line(t, "(%s %s)", name, verb);
|
||
} else {
|
||
push_system_line(t, "Usage: /tools [on NAME | off NAME | default | "
|
||
"minimal]");
|
||
return;
|
||
}
|
||
}
|
||
static const char *const perm_names[] = {"read", "write", "shell",
|
||
"net", "ask", "meta"};
|
||
push_system_line(t, "Tools (profile %s):",
|
||
cst_tool_profile_name(cfg->tool_profile));
|
||
for (int i = 0; i < cst_tool_count(); i++) {
|
||
const cst_tool_def_t *d = cst_tool_at(i);
|
||
int on = cst_tool_active(cfg, d);
|
||
const char *desc = d->desc ? d->desc : "";
|
||
int dl = (int)strcspn(desc, ".\n");
|
||
push_system_line(t, " %s %-12s %-5s %.*s%s%s%s", on ? "[x]" : "[ ]",
|
||
d->name, (int)d->perm < 6 ? perm_names[d->perm] : "?",
|
||
dl > 70 ? 70 : dl, desc, d->owner ? " (" : "",
|
||
d->owner ? d->owner : "", d->owner ? ")" : "");
|
||
}
|
||
}
|
||
|
||
static void cmd_sandbox(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
if (arg && strcmp(arg, "reset-labels") == 0) {
|
||
char *rep = NULL;
|
||
int n = cst_sandbox_reset_labels(&rep);
|
||
if (rep && rep[0])
|
||
push_lines(t, " ", rep);
|
||
push_system_line(t, n < 0 ? "(some labels could not be restored; "
|
||
"they stay listed)"
|
||
: n == 0 ? "(no labeled directories)"
|
||
: "(labels restored)");
|
||
free(rep);
|
||
return;
|
||
}
|
||
int sk = cst_sandbox_probe();
|
||
push_system_line(t, "Shell sandbox: %s",
|
||
sk == CST_SANDBOX_NONE ? "none (rule-based approval)"
|
||
: cst_sandbox_name(sk));
|
||
char *l = cst_sandbox_labels();
|
||
if (l) {
|
||
push_system_line(t, "Labeled Low integrity (writable by sandboxed "
|
||
"commands):");
|
||
push_lines(t, " ", l);
|
||
push_system_line(t, "/sandbox reset-labels restores their old labels");
|
||
free(l);
|
||
}
|
||
}
|
||
|
||
static void cmd_permissions(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_perms_t *p = cst_agent_perms(t->agent);
|
||
if (arg && strcmp(arg, "reset") == 0) {
|
||
cst_perms_reset(p);
|
||
push_system_line(t, "(saved permission rules cleared)");
|
||
return;
|
||
}
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
push_system_line(t, "Confirmation: shell %s · write %s · net %s%s",
|
||
cfg->confirm_shell ? "ask" : "auto",
|
||
cfg->confirm_write ? "ask" : "auto",
|
||
cfg->confirm_net ? "ask" : "auto",
|
||
cfg->auto_approve ? " · YOLO (everything auto)" : "");
|
||
char *d = cst_perms_describe(p);
|
||
push_lines(t, " ", d ? d : "(no saved rules)");
|
||
free(d);
|
||
push_system_line(t, " saved in .agent/permissions.json · /permissions reset");
|
||
}
|
||
|
||
static void set_approval(tui_t *t, int a) {
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
cfg->approval = a;
|
||
cfg->auto_approve = a == CST_APPROVAL_YOLO;
|
||
if (a != CST_APPROVAL_YOLO) /* YOLO lasts for this session only */
|
||
cst_settings_remember_approval(a);
|
||
}
|
||
|
||
static const char *approval_help(int a) {
|
||
switch (a) {
|
||
case CST_APPROVAL_YOLO:
|
||
return "shell, writes and web run without asking";
|
||
case CST_APPROVAL_AUTO:
|
||
return "edits run, shell runs in the sandbox or by safe rules, else asks";
|
||
default:
|
||
return "shell, writes and web ask first";
|
||
}
|
||
}
|
||
|
||
static void cmd_yolo(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
int on = approval_mode(t) != CST_APPROVAL_YOLO;
|
||
set_approval(t, on ? CST_APPROVAL_YOLO : CST_DEFAULT_APPROVAL);
|
||
push_system_line(t, "(approval %s: %s)", cst_approval_name(approval_mode(t)),
|
||
approval_help(approval_mode(t)));
|
||
}
|
||
|
||
static void cmd_approval(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
int a;
|
||
if (arg && arg[0]) {
|
||
if (cst_approval_parse(arg, &a) != 0) {
|
||
push_system_line(t, "Usage: /approval ask|auto|yolo");
|
||
return;
|
||
}
|
||
set_approval(t, a);
|
||
}
|
||
a = approval_mode(t);
|
||
char label[64];
|
||
approval_label(t, label, sizeof(label));
|
||
push_system_line(t, "(approval %s: %s)", label, approval_help(a));
|
||
}
|
||
|
||
static void cmd_sidebar(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
t->show_side = !t->show_side;
|
||
t->need_layout = 1;
|
||
if (t->show_side && t->cols < 110)
|
||
push_system_line(t, "(the Seat Deck shows when the terminal is at least "
|
||
"110 columns wide)");
|
||
}
|
||
|
||
static void cmd_motion(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
if (arg && arg[0]) {
|
||
if (!strcasecmp(arg, "on") || !strcmp(arg, "1"))
|
||
t->motion_on = 1;
|
||
else if (!strcasecmp(arg, "off") || !strcmp(arg, "0"))
|
||
t->motion_on = 0;
|
||
else {
|
||
push_system_line(t, "Usage: /motion [on|off]");
|
||
return;
|
||
}
|
||
} else {
|
||
t->motion_on = !t->motion_on;
|
||
}
|
||
push_system_line(t, t->motion_on ? "(Seat Deck motion on)"
|
||
: "(Seat Deck motion off)");
|
||
}
|
||
|
||
static void cmd_mouse(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
t->mouse_on = !t->mouse_on;
|
||
apply_mouse(t);
|
||
push_system_line(t, t->mouse_on
|
||
? "(mouse on · drag selects and copies (OSC 52) · "
|
||
"Shift+drag uses the terminal's own selection)"
|
||
: "(mouse off · the terminal selects text)");
|
||
}
|
||
|
||
static void cmd_max_steps(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
if (arg && atoi(arg) > 0)
|
||
cfg->max_steps = atoi(arg);
|
||
push_system_line(t, "(max steps per turn: %d)", cfg->max_steps);
|
||
}
|
||
|
||
static void cmd_realm(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const char *sub = arg && arg[0] ? arg : "status";
|
||
if (!strcmp(sub, "status") || !strcmp(sub, "show")) {
|
||
char *pst = cst_parliament_status_text();
|
||
if (pst && pst[0]) {
|
||
push_system_line(t, "Realm / Parliament:");
|
||
char *p = pst;
|
||
while (*p) {
|
||
char *nl = strchr(p, '\n');
|
||
if (nl)
|
||
*nl = 0;
|
||
if (p[0])
|
||
push_system_line(t, " %s", p);
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
} else {
|
||
push_system_line(t, "(no realm status)");
|
||
}
|
||
free(pst);
|
||
if (!cst_parliament_is_active())
|
||
push_system_line(t, "(solo mode: local task/worker tools on; set "
|
||
"CST_COLLABORATION=parliament or /realm login to "
|
||
"join a parliament)");
|
||
return;
|
||
}
|
||
if (!strcmp(sub, "stop")) {
|
||
if (cst_parliament_daemon_stop() != 0)
|
||
push_system_line(t, "Error: could not stop parliament service");
|
||
else
|
||
push_system_line(t, "(parliament service stopped)");
|
||
return;
|
||
}
|
||
if (!strncmp(sub, "login", 5)) {
|
||
char dir[OS_PATH_MAX];
|
||
if (cst_realm_dir(dir, sizeof(dir)) != 0 ||
|
||
!cst_realm_is_password_mode(dir)) {
|
||
push_system_line(t, "No password Realm yet. Outside TUI run:");
|
||
push_system_line(t, " cst-agent --realm-password-init");
|
||
return;
|
||
}
|
||
char pw[256];
|
||
if (secret_input_run(t, "Realm password",
|
||
"Unlocks this device as chair; starts the service",
|
||
pw, sizeof(pw)) != 0) {
|
||
push_system_line(t, "(login cancelled)");
|
||
return;
|
||
}
|
||
char iidbuf[64];
|
||
cst_parliament_default_instance(iidbuf, sizeof(iidbuf));
|
||
const char *iid = iidbuf;
|
||
char err[256];
|
||
char *hand = cst_parliament_login(pw, iid, NULL, err, sizeof(err));
|
||
if (!hand) {
|
||
memset(pw, 0, sizeof(pw));
|
||
push_system_line(t, "Login failed: %s", err[0] ? err : "unknown");
|
||
return;
|
||
}
|
||
/* Show a short summary, not the full handover blob. */
|
||
push_system_line(t, "(logged in as chair %s)", iid);
|
||
if (strstr(hand, "handover_from=") &&
|
||
!strstr(hand, "handover_from=\n") &&
|
||
!strstr(hand, "handover_from=\r"))
|
||
push_system_line(t, "(took chair from previous device)");
|
||
free(hand);
|
||
{
|
||
char serr[256];
|
||
if (cst_parliament_daemon_start(pw, iid, 9100, serr, sizeof(serr)) != 0)
|
||
push_system_line(t, "warning: service: %s",
|
||
serr[0] ? serr : "start failed");
|
||
else if (cst_parliament_daemon_running())
|
||
push_system_line(t, "(parliament service running)");
|
||
}
|
||
memset(pw, 0, sizeof(pw));
|
||
return;
|
||
}
|
||
if (!strcmp(sub, "join")) {
|
||
if (cst_parliament_session_ok(NULL, 0)) {
|
||
push_system_line(t, "(already seated; /realm status)");
|
||
return;
|
||
}
|
||
char pw[256];
|
||
if (secret_input_run(t, "Realm password",
|
||
"Join as a peer seat without stealing the chair",
|
||
pw, sizeof(pw)) != 0) {
|
||
push_system_line(t, "(join cancelled)");
|
||
return;
|
||
}
|
||
char iidbuf[64];
|
||
cst_parliament_default_instance(iidbuf, sizeof(iidbuf));
|
||
char err[256];
|
||
char *hand =
|
||
cst_parliament_seat_join(pw, iidbuf, NULL, err, sizeof(err));
|
||
if (!hand) {
|
||
memset(pw, 0, sizeof(pw));
|
||
push_system_line(t, "Join failed: %s", err[0] ? err : "unknown");
|
||
return;
|
||
}
|
||
const char *role = strstr(hand, "role=chair") ? "chair" : "member";
|
||
push_system_line(t, "(joined as %s seat %s)", role, iidbuf);
|
||
free(hand);
|
||
{
|
||
char serr[256];
|
||
if (cst_parliament_daemon_start(pw, iidbuf, 9100, serr, sizeof(serr)) != 0)
|
||
push_system_line(t, "warning: service: %s",
|
||
serr[0] ? serr : "start failed");
|
||
else if (cst_parliament_daemon_running())
|
||
push_system_line(t, "(parliament service running)");
|
||
}
|
||
memset(pw, 0, sizeof(pw));
|
||
return;
|
||
}
|
||
push_system_line(t, "Usage: /realm [status|login|join|stop]");
|
||
}
|
||
|
||
static void cmd_status(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
char sid[128];
|
||
cst_agent_session_id(t->agent, sid, sizeof(sid));
|
||
long win = cst_model_context_tokens(cfg->model);
|
||
long used = cst_agent_last_prompt_tokens(t->agent);
|
||
cst_session_t *s = cst_agent_session(t->agent);
|
||
int nmsg = 0;
|
||
if (s) {
|
||
cst_session_lock(s);
|
||
nmsg = cst_session_count(s);
|
||
cst_session_unlock(s);
|
||
}
|
||
push_system_line(t, "Status:");
|
||
push_system_line(t, " workspace %s", cfg->workspace_root);
|
||
push_system_line(t, " session %s (%d messages) %s", sid, nmsg, t->title);
|
||
push_system_line(t, " provider %s %s", cfg->provider, cfg->api_base);
|
||
push_system_line(t, " model %s context %ld/%ld tokens (%ld%%)",
|
||
cfg->model, used, win, win ? used * 100 / win : 0);
|
||
{
|
||
cst_model_info_t mi;
|
||
cst_model_info(cfg->provider, cfg->model, &mi);
|
||
char tag[48];
|
||
cst_model_info_label(&mi, tag, sizeof(tag));
|
||
push_system_line(t, " model info %s · from %s%s%s", tag, mi.source,
|
||
cst_models_load_error()[0] ? " · " : "",
|
||
cst_models_load_error());
|
||
}
|
||
push_system_line(t, " effort %s%s", cfg->effort,
|
||
cst_effort_wire(cfg->provider, cfg->model, cfg->effort)
|
||
? ""
|
||
: " (not used by this model)");
|
||
char key[512];
|
||
const char *from = NULL;
|
||
int have = cst_creds_resolve(cfg->provider, key, sizeof(key), &from) == 0;
|
||
int same = have && strcmp(key, cfg->api_key) == 0;
|
||
memset(key, 0, sizeof(key));
|
||
push_system_line(t, " api key %s",
|
||
!cfg->api_key[0] ? "missing — /key to enter one"
|
||
: !same ? "this run only (--api-key / CST_API_KEY)"
|
||
: strcmp(from, "saved") == 0 ? "saved (/key to replace)"
|
||
: from);
|
||
push_system_line(t, " protocol %s · tools %s · mode %s · max steps %d",
|
||
cst_protocol_name(cfg->protocol),
|
||
cst_tool_profile_name(cfg->tool_profile),
|
||
cst_mode_name(cfg->mode), cfg->max_steps);
|
||
char appr[64];
|
||
approval_label(t, appr, sizeof(appr));
|
||
push_system_line(t, " approval %s — %s", appr,
|
||
approval_help(approval_mode(t)));
|
||
push_system_line(t, " plan %s",
|
||
cfg->plan_mode ? "Plan (read-only tools)" : "Build");
|
||
const char *notes = cst_agent_notes(t->agent);
|
||
push_system_line(t, " notes %s",
|
||
notes && notes[0] ? "AGENTS.md loaded" : "(no AGENTS.md)");
|
||
push_system_line(t, " undo %d turn(s) can be undone",
|
||
cst_agent_undo_available(t->agent));
|
||
{
|
||
char *pst = cst_parliament_status_text();
|
||
if (pst && pst[0]) {
|
||
push_system_line(t, "Parliament / Realm:");
|
||
char *p = pst;
|
||
while (*p) {
|
||
char *nl = strchr(p, '\n');
|
||
if (nl)
|
||
*nl = 0;
|
||
if (p[0])
|
||
push_system_line(t, " %s", p);
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
}
|
||
free(pst);
|
||
if (cst_parliament_is_active() && !cst_parliament_session_ok(NULL, 0))
|
||
push_system_line(t, " tip cst-agent --login (CST_REALM_PASSWORD)");
|
||
}
|
||
cst_usage_t u;
|
||
cst_agent_usage(t->agent, &u, NULL);
|
||
if (u.requests > 0 && u.prompt > 0)
|
||
push_system_line(t, " cache %ld%% of %ld prompt tokens hit (/cost)",
|
||
u.cached * 100 / u.prompt, u.prompt);
|
||
}
|
||
|
||
static void push_usage(tui_t *t, const char *label, const cst_usage_t *u) {
|
||
if (u->requests == 0) {
|
||
push_system_line(t, " %-9s no requests yet", label);
|
||
return;
|
||
}
|
||
long hitp = u->prompt > 0 ? u->cached * 100 / u->prompt : 0;
|
||
push_system_line(t,
|
||
" %-9s %d request(s) · prompt %ld (hit %ld = %ld%%, miss "
|
||
"%ld) · output %ld",
|
||
label, u->requests, u->prompt, u->cached, hitp, u->miss,
|
||
u->out);
|
||
if (u->write)
|
||
push_system_line(t, " %-9s cache write %ld tokens", "", u->write);
|
||
if (u->priced)
|
||
push_system_line(t, " %-9s %s %s%.4f", "", g_ascii ? "~" : "≈",
|
||
money_sym(u->currency), u->cost);
|
||
}
|
||
|
||
static void cmd_cost(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
cst_usage_t s, last;
|
||
cst_agent_usage(t->agent, &s, &last);
|
||
push_system_line(t, "Token use and cost (%s):", cfg->model);
|
||
push_usage(t, "session", &s);
|
||
push_usage(t, "last turn", &last);
|
||
cst_price_t p;
|
||
if (cst_model_price(cfg->model, &p) != 0) {
|
||
push_system_line(t, " price unknown for this model; set "
|
||
"CST_PRICE=miss,hit,out[,write[,currency]] (per 1M "
|
||
"tokens)");
|
||
return;
|
||
}
|
||
const char *c = money_sym(p.currency);
|
||
push_system_line(t,
|
||
" price per 1M tokens: miss %s%.2f · hit %s%.2f · "
|
||
"output %s%.2f",
|
||
c, p.miss, c, p.hit, c, p.out);
|
||
if (p.offpeak_half)
|
||
push_system_line(t,
|
||
" now %s (off-peak is half price: outside "
|
||
"Mon–Fri 9–12 / 14–18 Beijing time)",
|
||
cst_price_is_peak(time(NULL)) ? "peak" : "off-peak");
|
||
}
|
||
|
||
static void cmd_todo(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
tui_lock(t);
|
||
char *td = t->todo ? xstrdup(t->todo) : NULL;
|
||
tui_unlock(t);
|
||
push_system_line(t, "Tasks:");
|
||
push_lines(t, " ", td && td[0] ? td : "(no tasks yet)");
|
||
free(td);
|
||
}
|
||
|
||
static void cmd_undo(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
char rep[4096];
|
||
int rc = cst_agent_undo(t->agent, rep, sizeof(rep));
|
||
push_system_line(t, rc == 0 ? "Undo:" : "Undo failed:");
|
||
push_lines(t, " ", rep);
|
||
}
|
||
|
||
static void cmd_retry(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
char *p = cst_agent_rewind_last_turn(t->agent);
|
||
if (!p) {
|
||
push_system_line(t, "Nothing to retry");
|
||
return;
|
||
}
|
||
reload_hist_from_session(t);
|
||
push_system_line(t, "(retrying the last prompt; file changes were kept — "
|
||
"/undo first to revert them)");
|
||
free(t->pending_prompt);
|
||
t->pending_prompt = p;
|
||
}
|
||
|
||
static void cmd_diff(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
os_run_result_t st = {0}, df = {0};
|
||
if (os_run_shell_c(cfg->workspace_root, "git status --short", 64 * 1024,
|
||
&st) != 0 ||
|
||
st.exit_code != 0) {
|
||
push_system_line(t, "/diff needs a git repository");
|
||
free(st.out);
|
||
return;
|
||
}
|
||
char cmd[512];
|
||
snprintf(cmd, sizeof(cmd), "git --no-pager diff --no-color %s%s",
|
||
arg && arg[0] ? "-- " : "", arg ? arg : "");
|
||
os_run_shell_c(cfg->workspace_root, cmd, 512 * 1024, &df);
|
||
push_system_line(t, "git status:");
|
||
push_lines(t, " ", st.out && st.out[0] ? st.out : "(clean)");
|
||
if (df.out && df.out[0]) {
|
||
block_t *b = blocks_push(&t->hist);
|
||
if (b) {
|
||
b->kind = BLK_TOOL;
|
||
snprintf(b->tool_name, sizeof(b->tool_name), "diff");
|
||
block_set_title(b, "git diff");
|
||
block_set_body(b, df.out, df.out_len);
|
||
snprintf(b->meta, sizeof(b->meta), "done");
|
||
b->expanded = 1;
|
||
}
|
||
}
|
||
free(st.out);
|
||
free(df.out);
|
||
}
|
||
|
||
static void cmd_export(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
char sid[128], path[OS_PATH_MAX + 256];
|
||
cst_agent_session_id(t->agent, sid, sizeof(sid));
|
||
if (arg && arg[0] == '/')
|
||
snprintf(path, sizeof(path), "%s", arg);
|
||
else if (arg && arg[0])
|
||
snprintf(path, sizeof(path), "%s/%s", cfg->workspace_root, arg);
|
||
else
|
||
snprintf(path, sizeof(path), "%s/.agent/exports/%s.md", cfg->workspace_root,
|
||
sid);
|
||
char dir[sizeof(path)];
|
||
snprintf(dir, sizeof(dir), "%s", path);
|
||
char *sl = strrchr(dir, '/');
|
||
if (sl) {
|
||
*sl = 0;
|
||
os_mkdir_p(dir, 0755);
|
||
}
|
||
char *md = cst_session_export_md(cst_agent_session(t->agent), t->title, sid,
|
||
cfg->model);
|
||
FILE *fp = md ? fopen(path, "wb") : NULL;
|
||
if (!fp) {
|
||
free(md);
|
||
push_system_line(t, "Error: cannot write %s", path);
|
||
return;
|
||
}
|
||
fputs(md, fp);
|
||
free(md);
|
||
if (fclose(fp) != 0) {
|
||
push_system_line(t, "Error: cannot write %s", path);
|
||
return;
|
||
}
|
||
push_system_line(t, "(exported to %s)", path);
|
||
}
|
||
|
||
/* The newest `added` picks, and a warning when the model would not see them. */
|
||
static void report_attached(tui_t *t, int added) {
|
||
int n = cst_agent_pending_count(t->agent);
|
||
for (int i = n - added; i < n; i++) {
|
||
char line[256];
|
||
if (i >= 0 &&
|
||
cst_agent_pending_describe(t->agent, i, line, sizeof(line)) == 0)
|
||
push_system_line(t, "Attached %s — it goes with your next message.",
|
||
line);
|
||
}
|
||
if (added > 0 && cst_agent_sees_images(t->agent) == 0)
|
||
push_system_line(t, "Note: %s does not take images; they are sent as a "
|
||
"text note until you /model to one that does.",
|
||
cst_agent_config(t->agent)->model);
|
||
}
|
||
|
||
/* /attach paste: the clipboard image if there is one, else its text goes
|
||
* into the composer (CR dropped, control bytes kept out, INPUT_CAP respected). */
|
||
static int paste_text_into_input(tui_t *t, const char *text, size_t n) {
|
||
char *buf = malloc(n + 1);
|
||
if (!buf)
|
||
return 0;
|
||
size_t k = 0;
|
||
for (size_t i = 0; i < n && t->in_len + k + 1 < INPUT_CAP; i++) {
|
||
unsigned char c = (unsigned char)text[i];
|
||
if (c == '\r' || (c < 0x20 && c != '\n' && c != '\t'))
|
||
continue;
|
||
buf[k++] = (char)c;
|
||
}
|
||
if (k)
|
||
input_insert(t, buf, k);
|
||
free(buf);
|
||
return (int)k;
|
||
}
|
||
|
||
/* /attach: stage files for the next user message -- images, PDFs (reduced to
|
||
* their text) and text. The payload is copied into the session's attachment
|
||
* directory now, so a file picked from /tmp keeps working after it is gone. */
|
||
static void cmd_attach(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
int n = cst_agent_pending_count(t->agent);
|
||
if (!arg || !arg[0] || strcmp(arg, "list") == 0) {
|
||
if (n == 0) {
|
||
push_system_line(t, "Nothing attached yet. Usage: /attach <path>, "
|
||
"/attach paste, /attach list, /attach clear");
|
||
return;
|
||
}
|
||
push_system_line(t, "%d attachment%s with the next message (sent once):",
|
||
n, n > 1 ? "s" : "");
|
||
for (int i = 0; i < n; i++) {
|
||
char line[256];
|
||
if (cst_agent_pending_describe(t->agent, i, line, sizeof(line)) == 0)
|
||
push_system_line(t, " %d. %s", i + 1, line);
|
||
}
|
||
push_system_line(t, "(/attach clear drops them)");
|
||
return;
|
||
}
|
||
if (strcmp(arg, "clear") == 0 || strcmp(arg, "none") == 0) {
|
||
cst_agent_pending_clear(t->agent);
|
||
push_system_line(t, "Dropped the attachments.");
|
||
return;
|
||
}
|
||
char err[256];
|
||
if (strcmp(arg, "paste") == 0) {
|
||
char *img = NULL;
|
||
size_t len = 0;
|
||
if (cst_clipboard_image(&img, &len, err, sizeof(err)) != 0) {
|
||
/* no image: put the clipboard text in the composer instead */
|
||
char *text = NULL;
|
||
size_t tn = 0;
|
||
char terr[192];
|
||
if (cst_clipboard_text(&text, &tn, terr, sizeof(terr)) != 0) {
|
||
push_system_line(t, "Error: %s", terr);
|
||
return;
|
||
}
|
||
int added = paste_text_into_input(t, text, tn);
|
||
free(text);
|
||
if (added <= 0) {
|
||
push_system_line(t, "(no room in the composer for the clipboard "
|
||
"text)");
|
||
return;
|
||
}
|
||
push_system_line(t, "(pasted %d bytes of clipboard text into the "
|
||
"composer)", added);
|
||
layout_maybe_grow_input(t);
|
||
return;
|
||
}
|
||
char mime[32], name[32];
|
||
const char *ext = cst_attach_sniff(img, len, mime, sizeof(mime)) == 0 &&
|
||
strchr(mime, '/')
|
||
? strchr(mime, '/') + 1
|
||
: "png";
|
||
snprintf(name, sizeof(name), "clipboard.%s", ext);
|
||
int rc = cst_agent_attach_bytes(t->agent, name, img, len, err, sizeof(err));
|
||
free(img);
|
||
if (rc != 0) {
|
||
push_system_line(t, "Error: %s", err);
|
||
return;
|
||
}
|
||
report_attached(t, 1);
|
||
return;
|
||
}
|
||
if (cst_agent_attach_file(t->agent, arg, err, sizeof(err)) != 0) {
|
||
push_system_line(t, "Error: %s", err);
|
||
return;
|
||
}
|
||
report_attached(t, 1);
|
||
}
|
||
|
||
/* A file worth attaching: an image, a PDF, or something that reads as text.
|
||
* Deciding here in the TUI only avoids turning a random dropped binary into
|
||
* a pasted path; the store checks the content for real. */
|
||
static int file_is_attachable(const char *path) {
|
||
FILE *f = fopen(path, "rb");
|
||
if (!f)
|
||
return 0;
|
||
unsigned char head[64];
|
||
size_t got = fread(head, 1, sizeof(head), f);
|
||
fclose(f);
|
||
char mime[32];
|
||
if (cst_attach_sniff(head, got, mime, sizeof(mime)) == 0)
|
||
return 1;
|
||
if (got >= 4 && memcmp(head, "%PDF", 4) == 0)
|
||
return 1;
|
||
for (size_t i = 0; i < got; i++)
|
||
if (head[i] == 0 || head[i] < 0x09)
|
||
return 0;
|
||
return got > 0;
|
||
}
|
||
|
||
/* A paste that is nothing but file paths (a file dropped on the terminal)
|
||
* attaches those files instead of typing the paths. */
|
||
static void paste_image_paths(tui_t *t) {
|
||
size_t from = t->paste_at, to = t->cur;
|
||
if (to <= from || to - from != t->in_len - t->paste_len0 ||
|
||
to - from > CST_PASTE_MAX_PATHS * CST_PASTE_PATH_MAX)
|
||
return;
|
||
static char paths[CST_PASTE_MAX_PATHS][CST_PASTE_PATH_MAX];
|
||
int n = cst_paste_paths(t->inbuf + from, to - from, paths,
|
||
CST_PASTE_MAX_PATHS);
|
||
if (n <= 0)
|
||
return;
|
||
for (int i = 0; i < n; i++)
|
||
if (!file_is_attachable(paths[i]))
|
||
return;
|
||
input_erase(t, from, to);
|
||
int added = 0;
|
||
for (int i = 0; i < n; i++) {
|
||
char err[256];
|
||
if (cst_agent_attach_file(t->agent, paths[i], err, sizeof(err)) == 0)
|
||
added++;
|
||
else
|
||
push_system_line(t, "Error: %s", err);
|
||
}
|
||
report_attached(t, added);
|
||
}
|
||
|
||
#ifdef _WIN32
|
||
#define COPY_VIA "clipboard"
|
||
#else
|
||
#define COPY_VIA "OSC 52"
|
||
#endif
|
||
|
||
/* Puts text on the terminal's clipboard; returns the bytes sent, 0 on
|
||
* failure. */
|
||
static size_t osc52_copy(const char *text, size_t n) {
|
||
#ifdef _WIN32
|
||
if (os_clipboard_set(text, n) == 0)
|
||
return n; /* conhost has no OSC 52 */
|
||
#endif
|
||
static const char b64[] =
|
||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||
if (n > 75000)
|
||
n = 75000; /* many terminals cap OSC 52 near 100 KB */
|
||
char *enc = malloc((n + 2) / 3 * 4 + 1);
|
||
if (!enc)
|
||
return 0;
|
||
size_t o = 0;
|
||
for (size_t i = 0; i < n; i += 3) {
|
||
unsigned v = (unsigned char)text[i] << 16;
|
||
if (i + 1 < n)
|
||
v |= (unsigned char)text[i + 1] << 8;
|
||
if (i + 2 < n)
|
||
v |= (unsigned char)text[i + 2];
|
||
enc[o++] = b64[(v >> 18) & 63];
|
||
enc[o++] = b64[(v >> 12) & 63];
|
||
enc[o++] = i + 1 < n ? b64[(v >> 6) & 63] : '=';
|
||
enc[o++] = i + 2 < n ? b64[v & 63] : '=';
|
||
}
|
||
enc[o] = 0;
|
||
if (getenv("TMUX"))
|
||
printf("\033Ptmux;\033\033]52;c;%s\a\033\\", enc);
|
||
else
|
||
printf("\033]52;c;%s\a", enc);
|
||
fflush(stdout);
|
||
free(enc);
|
||
return n;
|
||
}
|
||
|
||
static void cmd_copy(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
const char *text = NULL;
|
||
for (int i = t->hist.n - 1; i >= 0 && !text; i--)
|
||
if (t->hist.items[i].kind == BLK_ASSISTANT && t->hist.items[i].body)
|
||
text = t->hist.items[i].body;
|
||
if (!text || !text[0]) {
|
||
push_system_line(t, "Nothing to copy yet");
|
||
return;
|
||
}
|
||
size_t n = osc52_copy(text, strlen(text));
|
||
if (n)
|
||
push_system_line(t, "(copied %zu bytes of the last reply via " COPY_VIA
|
||
" — the terminal must allow clipboard access)", n);
|
||
}
|
||
|
||
static void cmd_init(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
free(t->pending_prompt);
|
||
t->pending_prompt = xstrdup(cst_init_prompt());
|
||
}
|
||
|
||
static void cmd_compact(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
char err[512] = {0};
|
||
set_status(t, "compacting…");
|
||
redraw_all(t);
|
||
if (cst_agent_compact(t->agent, err, sizeof(err)) != 0) {
|
||
push_system_line(t, "Error: compact failed: %s", err[0] ? err : "?");
|
||
return;
|
||
}
|
||
after_session_change(t, "(session compacted)");
|
||
}
|
||
|
||
static void set_thinking_all(tui_t *t, int on) {
|
||
for (int i = 0; i < t->hist.n; i++)
|
||
if (t->hist.items[i].kind == BLK_THINK)
|
||
t->hist.items[i].expanded = on;
|
||
}
|
||
|
||
static void cmd_thinking(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
if (arg && strcmp(arg, "on") == 0)
|
||
t->show_thinking = 1;
|
||
else if (arg && strcmp(arg, "off") == 0)
|
||
t->show_thinking = 0;
|
||
else
|
||
t->show_thinking = !t->show_thinking;
|
||
set_thinking_all(t, t->show_thinking);
|
||
cst_settings_remember_thinking(t->show_thinking);
|
||
push_system_line(t, "(thinking %s by default)",
|
||
t->show_thinking ? "expanded" : "collapsed");
|
||
}
|
||
|
||
static int run_keys_panel(tui_t *t, int busy, int *quit);
|
||
|
||
static void cmd_keys(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
run_keys_panel(t, t->busy, quit);
|
||
}
|
||
|
||
static void cmd_details(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
t->details_all = !t->details_all;
|
||
push_system_line(t, "tool details: %s",
|
||
t->details_all ? "expanded" : "collapsed");
|
||
}
|
||
|
||
static void cmd_skills(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
cst_skill_t *v = calloc(CST_SKILLS_MAX, sizeof(*v));
|
||
if (!v)
|
||
return;
|
||
int n = cst_skills_list(cfg->workspace_root, v, CST_SKILLS_MAX);
|
||
if (arg && strcmp(arg, "reload") == 0) {
|
||
if (n > 0 && cst_tool_find("skill") == NULL)
|
||
cst_skills_register_tool();
|
||
push_system_line(t, "(skills reloaded: %d found)", n);
|
||
}
|
||
if (n == 0) {
|
||
push_system_line(t, "No skills. Add <dir>/SKILL.md (frontmatter name + "
|
||
"description) under .agent/skills/ in the workspace "
|
||
"or skills/ in the config dir, then /skills reload.");
|
||
free(v);
|
||
return;
|
||
}
|
||
if (!arg || !arg[0]) {
|
||
static char items[CST_SKILLS_MAX][128];
|
||
for (int i = 0; i < n; i++)
|
||
snprintf(items[i], sizeof(items[i]), "%-20.20s %-9.9s %.90s", v[i].name,
|
||
v[i].source, v[i].desc);
|
||
int pick = picker_run(t, "Skills (Enter: /skill NAME …)", items, n, 0);
|
||
if (pick >= 0) {
|
||
char line[128];
|
||
snprintf(line, sizeof(line), "/skill %s ", v[pick].name);
|
||
input_set(t, line);
|
||
}
|
||
free(v);
|
||
return;
|
||
}
|
||
push_system_line(t, "Skills (%d):", n);
|
||
for (int i = 0; i < n; i++)
|
||
push_system_line(t, " %-20s %-10s %.100s", v[i].name, v[i].source,
|
||
v[i].desc);
|
||
free(v);
|
||
}
|
||
|
||
static void cmd_skill(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
char name[64] = "";
|
||
if (arg)
|
||
sscanf(arg, "%63s", name);
|
||
if (!name[0]) {
|
||
cmd_skills(t, NULL, quit);
|
||
return;
|
||
}
|
||
const char *task = arg + strlen(name);
|
||
while (*task == ' ')
|
||
task++;
|
||
char *p = cst_skill_prompt(cst_agent_config(t->agent)->workspace_root, name,
|
||
task);
|
||
if (!p) {
|
||
push_system_line(t, "No skill named '%s' (/skills lists them)", name);
|
||
return;
|
||
}
|
||
free(t->pending_prompt);
|
||
t->pending_prompt = p;
|
||
}
|
||
|
||
static const char *mcp_state_name(int st) {
|
||
switch (st) {
|
||
case CST_MCP_STARTING:
|
||
return "starting";
|
||
case CST_MCP_READY:
|
||
return "ready";
|
||
case CST_MCP_FAILED:
|
||
return "failed";
|
||
case CST_MCP_EXITED:
|
||
return "exited";
|
||
default:
|
||
return "off";
|
||
}
|
||
}
|
||
|
||
static void reload_extensions(tui_t *t) {
|
||
cst_extensions_unload();
|
||
char rep[256];
|
||
cst_extensions_load(cst_agent_config(t->agent), rep, sizeof(rep));
|
||
push_system_line(t, "(extensions reloaded%s%s)", rep[0] ? ": " : "", rep);
|
||
}
|
||
|
||
static void cmd_plugins(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const char *ws = cst_agent_config(t->agent)->workspace_root;
|
||
if (arg && (strcmp(arg, "trust") == 0 || strcmp(arg, "untrust") == 0)) {
|
||
int on = arg[0] == 't';
|
||
if (cst_settings_trust(ws, on) != 0) {
|
||
push_system_line(t, "(could not save settings)");
|
||
return;
|
||
}
|
||
push_system_line(t, on ? "Workspace trusted: .agent/plugins and "
|
||
".agent/mcp.json will load."
|
||
: "Workspace no longer trusted.");
|
||
reload_extensions(t);
|
||
return;
|
||
}
|
||
if (arg && strcmp(arg, "reload") == 0) {
|
||
reload_extensions(t);
|
||
return;
|
||
}
|
||
cst_plugin_info_t info[CST_PLUGINS_MAX];
|
||
int n = cst_plugins_info(info, CST_PLUGINS_MAX);
|
||
push_system_line(t, "Workspace %s (/plugins %s)",
|
||
cst_settings_trusted(ws) ? "trusted" : "not trusted",
|
||
cst_settings_trusted(ws) ? "untrust" : "trust");
|
||
if (n == 0) {
|
||
push_system_line(t, "No plugins. Add <dir>/plugin.json under "
|
||
".agent/plugins/ (trusted workspaces) or plugins/ in "
|
||
"the config dir, then /plugins reload.");
|
||
return;
|
||
}
|
||
for (int i = 0; i < n; i++) {
|
||
push_system_line(t, " %-18s %-9s %d tool%s%s%s%s%s", info[i].name,
|
||
info[i].source, info[i].ntools,
|
||
info[i].ntools == 1 ? "" : "s",
|
||
info[i].nmcp ? " · mcp" : "",
|
||
info[i].nskills_root ? " · skills" : "",
|
||
info[i].error[0] ? " · error: " : "", info[i].error);
|
||
if (info[i].desc[0])
|
||
push_system_line(t, " %.110s", info[i].desc);
|
||
}
|
||
}
|
||
|
||
static void cmd_mcp(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
if (arg && (strcmp(arg, "reload") == 0 || strcmp(arg, "restart") == 0)) {
|
||
reload_extensions(t);
|
||
return;
|
||
}
|
||
cst_mcp_info_t mi[CST_MCP_MAX];
|
||
int n = cst_mcp_info(mi, CST_MCP_MAX);
|
||
if (n == 0) {
|
||
push_system_line(t, "No MCP servers. Add {\"mcpServers\": {\"name\": "
|
||
"{\"command\": …, \"args\": […]}}} to mcp.json in "
|
||
"the config dir (or .agent/mcp.json in a trusted "
|
||
"workspace), then /mcp reload.");
|
||
return;
|
||
}
|
||
push_system_line(t, "MCP servers (%d):", n);
|
||
for (int i = 0; i < n; i++) {
|
||
push_system_line(t, " %-16s %-9s %-14s %s", mi[i].name,
|
||
mcp_state_name(mi[i].state), mi[i].source,
|
||
mi[i].command);
|
||
if (mi[i].state == CST_MCP_READY)
|
||
push_system_line(t, " %d tool%s as mcp__%s__*", mi[i].ntools,
|
||
mi[i].ntools == 1 ? "" : "s", mi[i].name);
|
||
else if (mi[i].error[0])
|
||
push_system_line(t, " %s", mi[i].error);
|
||
}
|
||
}
|
||
|
||
static void show_subagent(tui_t *t, const char *pid,
|
||
const cst_subagent_rec_t *rec) {
|
||
const char *ws = cst_agent_config(t->agent)->workspace_root;
|
||
char path[OS_PATH_MAX];
|
||
if (cst_subagent_path(ws, pid, rec->file, path, sizeof(path)) != 0)
|
||
return;
|
||
cst_session_t *s = cst_session_open(path);
|
||
if (!s) {
|
||
push_system_line(t, "(cannot open %s)", path);
|
||
return;
|
||
}
|
||
push_system_line(t, "── %s · %s: %s (%s · %d steps · %d tool calls · %.1f s)",
|
||
rec->file, rec->role, rec->desc, rec->status, rec->steps,
|
||
rec->tools, rec->ms / 1000.0);
|
||
push_system_line(t, " model: %s/%s · requests: %d/%d · input/output: %ld/%ld",
|
||
rec->provider, rec->model, rec->requests, rec->max_steps,
|
||
rec->prompt_tokens, rec->output_tokens);
|
||
if (rec->cache_known)
|
||
push_system_line(t, " cache read/write: %ld/%ld", rec->cached_tokens, rec->cache_write_tokens);
|
||
else push_system_line(t, " cache: unknown (not reported by provider)");
|
||
if (rec->priced) push_system_line(t, " cost: %s%.6f", rec->currency, rec->cost);
|
||
int n = cst_session_count(s);
|
||
const char *final = NULL;
|
||
for (int i = 0; i < n; i++) {
|
||
const cst_message_t *m = cst_session_get(s, i);
|
||
if (!m->content || cst_message_is_note(m))
|
||
continue;
|
||
if (m->role == CST_ROLE_USER && !m->tool_call_id[0]) {
|
||
push_system_line(t, " prompt: %.300s", m->content);
|
||
} else if (m->tool_name[0]) {
|
||
int lines = 1;
|
||
for (const char *c = m->content; *c; c++)
|
||
lines += *c == '\n';
|
||
push_system_line(t, " · %s (%d line%s)", m->tool_name, lines,
|
||
lines == 1 ? "" : "s");
|
||
} else if (m->role == CST_ROLE_ASSISTANT && m->content[0] &&
|
||
!m->tool_calls_json) {
|
||
final = m->content;
|
||
}
|
||
}
|
||
if (final) {
|
||
push_system_line(t, " report:");
|
||
int shown = 0;
|
||
for (const char *p = final; *p && shown < 60; shown++) {
|
||
const char *nl = strchr(p, '\n');
|
||
int len = nl ? (int)(nl - p) : (int)strlen(p);
|
||
push_system_line(t, " %.*s", len > 400 ? 400 : len, p);
|
||
p = nl ? nl + 1 : p + len;
|
||
}
|
||
}
|
||
push_system_line(t, " transcript: %s", path);
|
||
cst_session_close(s);
|
||
}
|
||
|
||
static void cmd_task(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
if (!arg || !*arg || !strcmp(arg, "state")) {
|
||
char *s = cst_runtime_json(cst_agent_config(t->agent)->runtime);
|
||
push_system_line(t, "%s", s ? s : "{}"); free(s); return;
|
||
}
|
||
if (!strncmp(arg, "cancel ", 7) || !strncmp(arg, "force ", 6)) {
|
||
int force = !strncmp(arg, "force ", 6);
|
||
int rc = cst_job_cancel(cst_agent_config(t->agent)->runtime, arg + (force ? 6 : 7), force, "user");
|
||
push_system_line(t, "%s", rc ? "active job not found" : "job cancellation requested"); return;
|
||
}
|
||
int ro = !strcmp(arg, "readonly") ? 1 : !strcmp(arg, "write") ? 0 : -1;
|
||
int delegate = !strcmp(arg, "direct") ? 0 : !strcmp(arg, "auto") ? 1 : -1;
|
||
int paused = !strcmp(arg, "pause") ? 1 : !strcmp(arg, "resume") ? 0 : -1;
|
||
if (ro < 0 && delegate < 0 && paused < 0) { push_system_line(t, "Use /task state|readonly|write|direct|auto|pause|resume|cancel ID|force ID"); return; }
|
||
cst_agent_control(t->agent, ro, delegate, paused);
|
||
push_system_line(t, "Task controls updated; stale actions will not execute.");
|
||
}
|
||
|
||
static void cmd_agents(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const char *ws = cst_agent_config(t->agent)->workspace_root;
|
||
if (arg && strcmp(arg, "budget") == 0) {
|
||
char *s = cst_workers_budget_json(cst_agent_config(t->agent));
|
||
push_system_line(t, "%s", s ? s : "unavailable"); free(s);
|
||
return;
|
||
}
|
||
if (arg && strncmp(arg, "config ", 7) == 0) {
|
||
char err[256];
|
||
int rc = cst_workers_config_set(ws, arg + 7, err, sizeof(err));
|
||
if (rc == 0) cst_workers_config_apply(cst_agent_config_mut(t->agent));
|
||
push_system_line(t, "%s", rc == 0 ? "Worker configuration saved" : err);
|
||
return;
|
||
}
|
||
if (arg && strcmp(arg, "config") == 0) {
|
||
char *s = cst_workers_config_get(ws);
|
||
push_system_line(t, "%s", s ? s : "cannot read worker config");
|
||
free(s);
|
||
return;
|
||
}
|
||
if (arg && strncmp(arg, "cancel ", 7) == 0) {
|
||
char sid[128]; cst_agent_session_id(t->agent, sid, sizeof(sid));
|
||
push_system_line(t, "%s", cst_worker_cancel(ws, sid, arg + 7) == 0 ?
|
||
"Worker cancellation requested" : "Running worker not found");
|
||
return;
|
||
}
|
||
cst_agent_role_t *roles = calloc(CST_ROLES_MAX, sizeof(*roles));
|
||
if (!roles)
|
||
return;
|
||
int nr = cst_roles_list(ws, roles, CST_ROLES_MAX);
|
||
char pid[256];
|
||
cst_agent_session_id(t->agent, pid, sizeof(pid));
|
||
static cst_subagent_rec_t recs[64];
|
||
int nrec = cst_subagent_list(ws, pid, recs, 64);
|
||
for (int i = 0; i < nrec; i++)
|
||
if (!recs[i].cache_known)
|
||
recs[i].cached_tokens = -1; /* unknown, not zero cache hits */
|
||
/* one picker: roles insert a prompt prefix, runs open their transcript */
|
||
static char items[CST_ROLES_MAX + 64][128];
|
||
int n = 0;
|
||
for (int i = 0; i < nr; i++)
|
||
snprintf(items[n++], sizeof(items[0]), "role %-14.14s %-9.9s %.90s",
|
||
roles[i].name, roles[i].profile[0] ? roles[i].profile : "inherit", roles[i].desc);
|
||
for (int i = 0; i < nrec; i++)
|
||
snprintf(items[n++], sizeof(items[0]), "run %.12s %.20s %.12s %d/%d req cache %ld/%ld",
|
||
recs[i].file, recs[i].model, recs[i].status, recs[i].requests,
|
||
recs[i].max_steps, recs[i].cached_tokens, recs[i].prompt_tokens);
|
||
if (cst_tool_find("task") == NULL)
|
||
push_system_line(t, "(the task tool is off: --tools minimal)");
|
||
int pick = picker_run(t, "Agents (Enter: role → ask for it · run → view "
|
||
"transcript)",
|
||
items, n, 0);
|
||
if (pick >= 0 && pick < nr) {
|
||
char line[160];
|
||
snprintf(line, sizeof(line), "Use the %s agent (task tool) to ",
|
||
roles[pick].name);
|
||
input_set(t, line);
|
||
} else if (pick >= nr) {
|
||
show_subagent(t, pid, &recs[pick - nr]);
|
||
}
|
||
cst_roles_free(roles, nr);
|
||
free(roles);
|
||
}
|
||
|
||
static void cmd_quit(tui_t *t, char *arg, int *quit) {
|
||
(void)t;
|
||
(void)arg;
|
||
*quit = 1;
|
||
}
|
||
|
||
/* /web: the WebUI is a second front end for the same engine and is designed to
|
||
* run as its own process, so this starts one on the same workspace and leaves
|
||
* the TUI holding the terminal. The child gets a fresh session: two writers on
|
||
* one session file is the one thing that would corrupt it. */
|
||
#ifndef _WIN32
|
||
#include <fcntl.h>
|
||
#include <sys/wait.h> /* waitpid: collect the WebUI started by the last /web */
|
||
#endif
|
||
#ifndef CST_WEBUI_DEFAULT_PORT
|
||
#define CST_WEBUI_DEFAULT_PORT 8787 /* keep in sync with include/cst_webui.h */
|
||
#endif
|
||
static void cmd_web(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
#ifdef _WIN32
|
||
push_system_line(t, "open another terminal and run: cst-agent --web");
|
||
#else
|
||
static pid_t spawned = 0;
|
||
if (spawned > 0) { /* collect the one we started last time */
|
||
waitpid(spawned, NULL, WNOHANG);
|
||
spawned = 0;
|
||
}
|
||
char dir[4096], exe[4096 + 16];
|
||
if (os_exe_dir(dir, sizeof(dir)) != 0) {
|
||
push_system_line(t, "cannot locate this binary: run cst-agent --web yourself");
|
||
return;
|
||
}
|
||
snprintf(exe, sizeof(exe), "%s/cst-agent", dir);
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
const char *ws = (cfg && cfg->workspace_root[0]) ? cfg->workspace_root : ".";
|
||
char logf[4096 + 32];
|
||
snprintf(logf, sizeof(logf), "%s/.agent", ws);
|
||
os_mkdir_p(logf, 0755);
|
||
snprintf(logf, sizeof(logf), "%s/.agent/webui.log", ws);
|
||
/* The WebUI talks to what this session talks to (-b, -m, a /provider
|
||
* switch...). The key goes through the environment, not argv (ps). */
|
||
const char *argv[20];
|
||
int na = 0;
|
||
argv[na++] = "cst-agent";
|
||
argv[na++] = "--web";
|
||
argv[na++] = "--workspace";
|
||
argv[na++] = ws;
|
||
argv[na++] = "--new";
|
||
if (cfg && cfg->provider[0])
|
||
argv[na++] = "--provider", argv[na++] = cfg->provider;
|
||
if (cfg && cfg->model[0])
|
||
argv[na++] = "--model", argv[na++] = cfg->model;
|
||
if (cfg && cfg->api_base[0])
|
||
argv[na++] = "--api-base", argv[na++] = cfg->api_base;
|
||
if (cfg && cfg->effort[0])
|
||
argv[na++] = "--effort", argv[na++] = cfg->effort;
|
||
if (cfg)
|
||
argv[na++] = "--approval", argv[na++] = cst_approval_name(cfg->approval);
|
||
argv[na] = NULL;
|
||
size_t nenv = 0;
|
||
while (environ[nenv])
|
||
nenv++;
|
||
char **envp = calloc(nenv + 2, sizeof(char *));
|
||
char keyvar[600] = "";
|
||
if (!envp) {
|
||
push_system_line(t, "out of memory");
|
||
return;
|
||
}
|
||
size_t ne = 0;
|
||
for (size_t i = 0; i < nenv; i++)
|
||
if (strncmp(environ[i], "CST_API_KEY=", 12) != 0)
|
||
envp[ne++] = environ[i];
|
||
if (cfg && cfg->api_key[0]) {
|
||
snprintf(keyvar, sizeof(keyvar), "CST_API_KEY=%s", cfg->api_key);
|
||
envp[ne++] = keyvar;
|
||
}
|
||
envp[ne] = NULL;
|
||
pid_t pid = fork();
|
||
if (pid < 0) {
|
||
free(envp);
|
||
push_system_line(t, "cannot start a second process");
|
||
return;
|
||
}
|
||
if (pid == 0) {
|
||
setsid();
|
||
/* never inherit the TUI's terminal: its output would land on our screen */
|
||
int in = open("/dev/null", O_RDONLY);
|
||
if (in >= 0 && in != STDIN_FILENO) {
|
||
dup2(in, STDIN_FILENO);
|
||
close(in);
|
||
}
|
||
int fd = open(logf, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||
if (fd < 0)
|
||
fd = open("/dev/null", O_WRONLY);
|
||
if (fd >= 0) {
|
||
dup2(fd, STDOUT_FILENO);
|
||
dup2(fd, STDERR_FILENO);
|
||
if (fd > 2)
|
||
close(fd);
|
||
}
|
||
execve(exe, (char *const *)argv, envp);
|
||
_exit(127);
|
||
}
|
||
free(envp);
|
||
spawned = pid;
|
||
/* Wait (2 s at most) for the URL it prints once it listens: the port may
|
||
* come from CST_WEB_PORT, or it may not start at all. */
|
||
char url[512] = "";
|
||
int died = 0;
|
||
for (int i = 0; i < 40 && !url[0] && !died; i++) {
|
||
usleep(50000);
|
||
FILE *fp = fopen(logf, "r");
|
||
char line[512];
|
||
while (fp && fgets(line, sizeof(line), fp)) {
|
||
if (strncmp(line, "webui: http", 11) == 0) {
|
||
line[strcspn(line, "\r\n")] = 0;
|
||
snprintf(url, sizeof(url), "%s", line + 7);
|
||
break;
|
||
}
|
||
}
|
||
if (fp)
|
||
fclose(fp);
|
||
if (!url[0] && waitpid(pid, NULL, WNOHANG) == pid) {
|
||
died = 1;
|
||
spawned = 0;
|
||
}
|
||
}
|
||
/* its --new made the WebUI's session CURRENT; a plain `cst-agent` later
|
||
* should still resume this one */
|
||
char sid[128] = "";
|
||
cst_agent_session_id(t->agent, sid, sizeof(sid));
|
||
if (sid[0])
|
||
cst_agent_store_set_current(ws, sid);
|
||
char msg[4200];
|
||
if (died)
|
||
snprintf(msg, sizeof(msg), "the WebUI did not start (port busy?): see %s",
|
||
logf);
|
||
else if (url[0])
|
||
snprintf(msg, sizeof(msg), "WebUI started in the background: %s (new session)",
|
||
url);
|
||
else
|
||
snprintf(msg, sizeof(msg), "WebUI starting in the background "
|
||
"(http://127.0.0.1:%d/ unless CST_WEB_PORT says "
|
||
"otherwise)", CST_WEBUI_DEFAULT_PORT);
|
||
push_system_line(t, msg);
|
||
snprintf(msg, sizeof(msg), "its log: %s", logf);
|
||
push_system_line(t, msg);
|
||
#endif
|
||
}
|
||
|
||
static const slash_cmd_t SLASH[] = {
|
||
{"help", "?", "", "list commands and keys", 1, cmd_help},
|
||
{"new", "clear", "", "start a new session", 0, cmd_new},
|
||
{"session", "sessions resume", "[id]", "pick or switch session", 0,
|
||
cmd_session},
|
||
{"rename", "", "<title>", "name the current session", 1, cmd_rename},
|
||
{"delete", "", "[id]", "delete a session (asks first)", 0, cmd_delete},
|
||
{"model", "", "[name]", "pick or set the model", 0, cmd_model},
|
||
{"provider", "", "[name]", "switch provider (asks for its key)", 0,
|
||
cmd_provider},
|
||
{"key", "apikey", "", "enter or replace the provider's API key", 0,
|
||
cmd_key},
|
||
{"effort", "think", "[low|medium|high|max]", "reasoning effort", 0,
|
||
cmd_effort},
|
||
{"protocol", "", "[name]", "pick or set the tool-call protocol", 0,
|
||
cmd_protocol},
|
||
{"tools", "", "[on|off NAME|minimal]", "list or toggle tools", 0,
|
||
cmd_tools},
|
||
{"mode", "", "[coding|general]", "coding or general (non-code) framing", 1,
|
||
cmd_mode},
|
||
{"permissions", "perms", "[reset]", "show saved always-allow rules", 1,
|
||
cmd_permissions},
|
||
{"sandbox", "", "[reset-labels]", "shell sandbox and labeled folders", 1,
|
||
cmd_sandbox},
|
||
{"approval", "", "[ask|auto|yolo]", "how tools get approved (Shift+Tab)",
|
||
1, cmd_approval},
|
||
{"yolo", "", "", "toggle approving everything", 1, cmd_yolo},
|
||
{"sidebar", "", "", "show or hide the Seat Deck sidebar", 1, cmd_sidebar},
|
||
{"motion", "", "[on|off]", "Seat Deck ASCII micro-motion", 1, cmd_motion},
|
||
{"mouse", "", "", "toggle mouse support (drag copies)", 1,
|
||
cmd_mouse},
|
||
{"max-steps", "", "[N]", "tool steps allowed per turn", 1, cmd_max_steps},
|
||
{"status", "", "", "session, model, context and settings", 1, cmd_status},
|
||
{"realm", "parliament login", "[status|login|join|stop]",
|
||
"Realm / parliament status, claim chair, join as seat, or stop service", 1,
|
||
cmd_realm},
|
||
{"cost", "usage", "", "tokens, cache hit rate and estimated cost", 1,
|
||
cmd_cost},
|
||
{"todo", "tasks", "", "show the agent's task list", 1, cmd_todo},
|
||
{"undo", "", "", "revert file changes of the last turn", 0, cmd_undo},
|
||
{"retry", "", "", "drop the last reply and ask again", 0, cmd_retry},
|
||
{"diff", "", "[path]", "git status + diff of the workspace", 1, cmd_diff},
|
||
{"compact", "summarize", "", "summarize the context to save tokens", 0,
|
||
cmd_compact},
|
||
{"export", "", "[file]", "write the transcript as Markdown", 1,
|
||
cmd_export},
|
||
{"copy", "", "", "copy the last reply (OSC 52)", 1, cmd_copy},
|
||
{"attach", "image|file", "[path|paste|clear]",
|
||
"send files (image, PDF, text) or clipboard text with the next message",
|
||
1, cmd_attach},
|
||
{"init", "", "", "have the agent write AGENTS.md", 0, cmd_init},
|
||
{"details", "", "", "expand/collapse all tool output", 1, cmd_details},
|
||
{"thinking", "", "[on|off]", "show Thought blocks expanded or collapsed",
|
||
1, cmd_thinking},
|
||
{"keys", "shortcuts", "", "keyboard shortcuts (keybinds.json)", 1,
|
||
cmd_keys},
|
||
{"skills", "", "[reload|list]", "list skills (SKILL.md)", 0, cmd_skills},
|
||
{"plugins", "", "[trust|untrust|reload]", "plugins and workspace trust", 0,
|
||
cmd_plugins},
|
||
{"mcp", "", "[reload]", "MCP servers and their state", 0, cmd_mcp},
|
||
{"agents", "", "", "sub-agent roles and this session's runs", 0,
|
||
cmd_agents},
|
||
{"task", "jobs", "[state|readonly|write|direct|auto|pause|resume]", "task controls and jobs", 1, cmd_task},
|
||
{"skill", "", "<name> [task]", "run a task with a skill loaded", 0,
|
||
cmd_skill},
|
||
{"web", "", "", "start the WebUI in the background (127.0.0.1:8787)", 1,
|
||
cmd_web},
|
||
{"quit", "q exit", "", "quit", 1, cmd_quit},
|
||
};
|
||
|
||
static const slash_cmd_t *slash_table(int *n) {
|
||
*n = (int)(sizeof(SLASH) / sizeof(SLASH[0]));
|
||
return SLASH;
|
||
}
|
||
|
||
static int has_alias(const char *list, const char *name) {
|
||
size_t n = strlen(name);
|
||
for (const char *p = list; *p;) {
|
||
size_t w = strcspn(p, " ");
|
||
if (w == n && strncmp(p, name, n) == 0)
|
||
return 1;
|
||
p += w;
|
||
while (*p == ' ')
|
||
p++;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* Exact name, alias, or unique prefix. */
|
||
static const slash_cmd_t *slash_find(const char *name, int *ambiguous) {
|
||
int n;
|
||
const slash_cmd_t *tab = slash_table(&n), *hit = NULL;
|
||
*ambiguous = 0;
|
||
for (int i = 0; i < n; i++)
|
||
if (strcmp(tab[i].name, name) == 0 || has_alias(tab[i].aliases, name))
|
||
return &tab[i];
|
||
size_t len = strlen(name);
|
||
for (int i = 0; len && i < n; i++) {
|
||
if (strncmp(tab[i].name, name, len) == 0) {
|
||
if (hit)
|
||
*ambiguous = 1;
|
||
hit = &tab[i];
|
||
}
|
||
}
|
||
return *ambiguous ? NULL : hit;
|
||
}
|
||
|
||
static int handle_slash(tui_t *t, const char *line, int *should_quit) {
|
||
*should_quit = 0;
|
||
if (!line || line[0] != '/')
|
||
return 0;
|
||
char buf[INPUT_CAP];
|
||
snprintf(buf, sizeof(buf), "%s", line + 1);
|
||
char *cmd = buf;
|
||
while (*cmd == ' ')
|
||
cmd++;
|
||
char *arg = cmd + strcspn(cmd, " \n");
|
||
if (*arg) {
|
||
*arg++ = 0;
|
||
while (*arg == ' ')
|
||
arg++;
|
||
}
|
||
size_t al = strlen(arg);
|
||
while (al && (arg[al - 1] == ' ' || arg[al - 1] == '\n'))
|
||
arg[--al] = 0;
|
||
int amb = 0;
|
||
const slash_cmd_t *c = slash_find(cmd, &amb);
|
||
if (!c) {
|
||
push_system_line(t, amb ? "Ambiguous command /%s (try /help)"
|
||
: "Unknown command /%s (try /help)",
|
||
cmd);
|
||
return 1;
|
||
}
|
||
if (t->busy && !c->busy_ok) {
|
||
push_system_line(t, "/%s is not available while the agent is working "
|
||
"(Esc cancels the turn)",
|
||
c->name);
|
||
return 1;
|
||
}
|
||
char before[sizeof(t->status_msg)];
|
||
memcpy(before, t->status_msg, sizeof(before));
|
||
c->fn(t, arg, should_quit);
|
||
if (strcmp(before, t->status_msg) == 0)
|
||
status_idle(t);
|
||
return 1;
|
||
}
|
||
|
||
/* --- completion ----------------------------------------------------------- */
|
||
|
||
static void load_files(tui_t *t) {
|
||
if (t->files && since_ms(&t->files_at) < 10000)
|
||
return;
|
||
for (int i = 0; i < t->nfiles; i++)
|
||
free(t->files[i]);
|
||
free(t->files);
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
t->nfiles = cst_workspace_files(cfg->workspace_root, &t->files, 20000);
|
||
clock_gettime(CLOCK_MONOTONIC, &t->files_at);
|
||
}
|
||
|
||
static void update_completion(tui_t *t) {
|
||
t->comp_n = 0;
|
||
if (t->comp_off || t->in_paste)
|
||
return;
|
||
if (t->inbuf[0] == '/' && memchr(t->inbuf, ' ', t->cur) == NULL &&
|
||
memchr(t->inbuf, '\n', t->in_len) == NULL) {
|
||
int n;
|
||
const slash_cmd_t *tab = slash_table(&n);
|
||
size_t len = t->cur - 1;
|
||
for (int i = 0; i < n && t->comp_n < COMP_MAX; i++) {
|
||
if (strncmp(tab[i].name, t->inbuf + 1, len) != 0)
|
||
continue;
|
||
comp_item_t *c = &t->comp[t->comp_n++];
|
||
snprintf(c->text, sizeof(c->text), "/%s %s", tab[i].name, tab[i].args);
|
||
snprintf(c->desc, sizeof(c->desc), "%s", tab[i].desc);
|
||
}
|
||
t->comp_kind = 1;
|
||
t->comp_start = 0;
|
||
} else {
|
||
size_t st = t->cur;
|
||
while (st > 0 && t->inbuf[st - 1] != ' ' && t->inbuf[st - 1] != '\n')
|
||
st--;
|
||
if (st >= t->in_len || t->inbuf[st] != '@')
|
||
return;
|
||
char frag[256];
|
||
snprintf(frag, sizeof(frag), "%.*s", (int)(t->cur - st - 1),
|
||
t->inbuf + st + 1);
|
||
load_files(t);
|
||
/* basename prefix matches first, then any substring */
|
||
for (int pass = 0; pass < 2 && t->comp_n < COMP_MAX; pass++) {
|
||
for (int i = 0; i < t->nfiles && t->comp_n < COMP_MAX; i++) {
|
||
const char *f = t->files[i];
|
||
const char *base = strrchr(f, '/');
|
||
base = base ? base + 1 : f;
|
||
int hit = pass == 0 ? strncasecmp(base, frag, strlen(frag)) == 0
|
||
: (strncasecmp(base, frag, strlen(frag)) != 0 &&
|
||
strcasestr(f, frag) != NULL);
|
||
if (!hit)
|
||
continue;
|
||
comp_item_t *c = &t->comp[t->comp_n++];
|
||
snprintf(c->text, sizeof(c->text), "@%s", f);
|
||
c->desc[0] = 0;
|
||
}
|
||
}
|
||
t->comp_kind = 2;
|
||
t->comp_start = st;
|
||
}
|
||
if (t->comp_sel >= t->comp_n)
|
||
t->comp_sel = t->comp_n ? t->comp_n - 1 : 0;
|
||
}
|
||
|
||
/* Returns the chosen slash command when the popup was a slash list. */
|
||
static const slash_cmd_t *accept_completion(tui_t *t) {
|
||
if (t->comp_n <= 0)
|
||
return NULL;
|
||
const comp_item_t *c = &t->comp[t->comp_sel];
|
||
const slash_cmd_t *cmd = NULL;
|
||
char repl[300];
|
||
if (t->comp_kind == 1) {
|
||
char name[64];
|
||
snprintf(name, sizeof(name), "%.*s", (int)strcspn(c->text + 1, " "),
|
||
c->text + 1);
|
||
int amb;
|
||
cmd = slash_find(name, &amb);
|
||
snprintf(repl, sizeof(repl), "/%s ", name);
|
||
} else {
|
||
snprintf(repl, sizeof(repl), "%s ", c->text);
|
||
}
|
||
size_t from = t->comp_start, to = t->cur;
|
||
input_erase(t, from, to);
|
||
t->cur = from;
|
||
input_insert(t, repl, strlen(repl));
|
||
t->comp_n = 0;
|
||
t->comp_off = 1;
|
||
return cmd;
|
||
}
|
||
|
||
static void toggle_think(tui_t *t) {
|
||
int idx = t->focus_think_idx;
|
||
if (idx < 0) {
|
||
for (int i = t->hist.n - 1; i >= 0; i--) {
|
||
if (t->hist.items[i].kind == BLK_THINK) {
|
||
idx = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (idx < 0 || idx >= t->hist.n)
|
||
return;
|
||
block_t *b = &t->hist.items[idx];
|
||
if (b->kind != BLK_THINK)
|
||
return;
|
||
b->expanded = !b->expanded;
|
||
/* Keep timed title (Thought · Ns) if already set. */
|
||
if (b->expanded && (!b->title || !b->title[0]))
|
||
block_set_title(b, "Thought");
|
||
t->focus_think_idx = idx;
|
||
draw_log(t);
|
||
}
|
||
|
||
static void toggle_last_tool(tui_t *t) {
|
||
int idx = t->last_tool_idx;
|
||
if (idx < 0 || idx >= t->hist.n) {
|
||
for (int i = t->hist.n - 1; i >= 0; i--) {
|
||
if (t->hist.items[i].kind == BLK_TOOL) {
|
||
idx = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (idx < 0 || idx >= t->hist.n)
|
||
return;
|
||
block_t *b = &t->hist.items[idx];
|
||
if (b->kind != BLK_TOOL)
|
||
return;
|
||
b->expanded = !b->expanded;
|
||
t->last_tool_idx = idx;
|
||
draw_log(t);
|
||
}
|
||
|
||
/* --- turn worker ---------------------------------------------------------- */
|
||
|
||
typedef struct {
|
||
tui_t *t;
|
||
char *prompt;
|
||
} turn_arg_t;
|
||
|
||
static void *turn_thread(void *arg) {
|
||
turn_arg_t *a = arg;
|
||
tui_t *t = a->t;
|
||
cst_agent_hooks_t hooks = {0};
|
||
hooks.on_status = on_status;
|
||
hooks.on_assistant_delta = on_assistant_delta;
|
||
hooks.on_think_delta = on_think_delta;
|
||
hooks.on_tool_event = on_tool_event;
|
||
hooks.on_tool_result = on_tool_result;
|
||
hooks.on_permission = on_permission;
|
||
hooks.on_ask_user = on_ask_user;
|
||
hooks.on_todo = on_todo;
|
||
hooks.userdata = t;
|
||
|
||
char err[512];
|
||
err[0] = 0;
|
||
int rc = cst_agent_run_turn_ex(t->agent, a->prompt, &hooks, err, sizeof(err));
|
||
|
||
tui_lock(t);
|
||
t->turn_rc = rc;
|
||
snprintf(t->turn_err, sizeof(t->turn_err), "%s", err);
|
||
collapse_open_think(t);
|
||
if (t->as_live)
|
||
live_seal(t);
|
||
for (int i = 0; i < t->hist.n; i++) {
|
||
if (t->hist.items[i].kind == BLK_TOOL)
|
||
attach_tool_output_from_session(t, &t->hist.items[i]);
|
||
}
|
||
if (rc == 0) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
block_t *fb = blocks_push(&t->hist);
|
||
if (fb && cfg) {
|
||
char line[256];
|
||
int n = snprintf(line, sizeof(line), "\033[97m%s\033[39m %s %s %s %.1fs",
|
||
cfg->plan_mode ? "Plan" : "Build", g_ascii ? "-" : "·",
|
||
cfg->model, g_ascii ? "-" : "·",
|
||
since_ms(&t->turn_t0) / 1000.0);
|
||
fb->kind = BLK_FOOTER;
|
||
block_set_body(fb, line, n > 0 ? strlen(line) : 0);
|
||
}
|
||
}
|
||
t->turn_done = 1;
|
||
t->busy = 0;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
|
||
free(a->prompt);
|
||
free(a);
|
||
return NULL;
|
||
}
|
||
|
||
static void request_cancel_ui(tui_t *t) {
|
||
cst_agent_request_cancel(t->agent);
|
||
set_status(t, "cancelling…");
|
||
draw_status(t);
|
||
}
|
||
|
||
static int begin_turn(tui_t *t, char *prompt) {
|
||
prompt_hist_push(t, prompt);
|
||
if (!cst_agent_config(t->agent)->model[0]) {
|
||
set_status(t, "no model selected yet — /model to pick one (↑ recalls "
|
||
"your message)");
|
||
free(prompt);
|
||
return -1;
|
||
}
|
||
block_t *ub = blocks_push(&t->hist);
|
||
if (ub) {
|
||
ub->kind = BLK_USER;
|
||
block_set_title(ub, "You");
|
||
cst_buf_t body = {0};
|
||
cst_buf_puts(&body, prompt);
|
||
for (int k = 0; k < cst_agent_pending_count(t->agent); k++) {
|
||
char d[128];
|
||
if (cst_agent_pending_describe(t->agent, k, d, sizeof(d)) == 0)
|
||
cst_buf_printf(&body, "\n[image %s]", d);
|
||
}
|
||
block_set_body(ub, body.buf ? body.buf : "", body.len);
|
||
cst_buf_free(&body);
|
||
ub->expanded = 1;
|
||
}
|
||
t->think_idx = -1;
|
||
t->log_scroll = 0;
|
||
set_status(t, "streaming…");
|
||
|
||
turn_arg_t *ta = calloc(1, sizeof(*ta));
|
||
if (!ta) {
|
||
free(prompt);
|
||
return -1;
|
||
}
|
||
ta->t = t;
|
||
ta->prompt = prompt;
|
||
t->busy = 1;
|
||
t->turn_done = 0;
|
||
clock_gettime(CLOCK_MONOTONIC, &t->turn_t0);
|
||
redraw_all(t);
|
||
pthread_t th;
|
||
if (pthread_create(&th, NULL, turn_thread, ta) != 0) {
|
||
t->busy = 0;
|
||
free(prompt);
|
||
free(ta);
|
||
push_system_line(t, "Error: failed to start turn thread");
|
||
status_idle(t);
|
||
redraw_all(t);
|
||
return -1;
|
||
}
|
||
pthread_detach(th);
|
||
return 0;
|
||
}
|
||
|
||
/* Owns prompt. Returns 0 ok, -1 fail, -2 quit. */
|
||
static int start_turn(tui_t *t, char *prompt) {
|
||
if (!prompt)
|
||
return -1;
|
||
if (prompt[0] == '/') {
|
||
int should_quit = 0;
|
||
handle_slash(t, prompt, &should_quit);
|
||
free(prompt);
|
||
t->log_scroll = 0;
|
||
if (t->need_layout) {
|
||
t->need_layout = 0;
|
||
layout(t);
|
||
wtimeout(t->input, 80);
|
||
}
|
||
redraw_all(t);
|
||
if (should_quit)
|
||
return -2;
|
||
if (t->pending_prompt && !t->busy) {
|
||
char *next = t->pending_prompt;
|
||
t->pending_prompt = NULL;
|
||
return begin_turn(t, next);
|
||
}
|
||
return 0;
|
||
}
|
||
return begin_turn(t, prompt);
|
||
}
|
||
|
||
static int try_start_queued(tui_t *t) {
|
||
if (t->busy || t->queue_n <= 0)
|
||
return 0;
|
||
char *next = queue_pop(t);
|
||
if (!next)
|
||
return 0;
|
||
return start_turn(t, next);
|
||
}
|
||
|
||
static int submit_input(tui_t *t, int busy) {
|
||
if (t->in_len == 0) {
|
||
if (!busy && t->queue_n > 0)
|
||
return try_start_queued(t);
|
||
if (!busy)
|
||
toggle_last_tool(t);
|
||
return 0;
|
||
}
|
||
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
if (!busy && t->inbuf[0] != '/' && !cfg->api_key[0]) {
|
||
char key[512];
|
||
if (obtain_key(t, cfg->provider, key, sizeof(key), 0) != 0) {
|
||
push_system_line(t, "No API key for %s yet — /key to enter one, or "
|
||
"/provider to switch",
|
||
cfg->provider);
|
||
return 0;
|
||
}
|
||
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
|
||
memset(key, 0, sizeof(key));
|
||
}
|
||
|
||
char *prompt = strdup(t->inbuf);
|
||
input_clear(t);
|
||
layout_maybe_grow_input(t);
|
||
if (!prompt)
|
||
return 0;
|
||
|
||
if (busy && prompt[0] == '/') {
|
||
int q = 0;
|
||
handle_slash(t, prompt, &q);
|
||
free(prompt);
|
||
redraw_all(t);
|
||
return q ? -2 : 0;
|
||
}
|
||
if (busy) {
|
||
cst_agent_steer(t->agent, prompt, -1, -1);
|
||
push_system_line(t, "New instruction sent; conflicting queued actions stopped.");
|
||
set_status(t, "applying new instruction");
|
||
free(prompt);
|
||
draw_status(t);
|
||
return 0;
|
||
}
|
||
return start_turn(t, prompt);
|
||
}
|
||
|
||
/* Byte offset nearest to visual (row, col) of the input, matching
|
||
* input_layout's wrapping. */
|
||
static size_t input_hit(const tui_t *t, int want_row, int want_col) {
|
||
int row = 0, col = IN_COL;
|
||
int right = (t->in_w > 0 ? t->in_w : t->cols) - 2;
|
||
size_t i = 0, best = t->in_len, last = 0;
|
||
while (i < t->in_len) {
|
||
if (row == want_row && col >= want_col)
|
||
return i;
|
||
if (row > want_row)
|
||
return best;
|
||
if (t->inbuf[i] == '\n') {
|
||
if (row == want_row)
|
||
return i;
|
||
row++;
|
||
col = IN_COL;
|
||
i++;
|
||
continue;
|
||
}
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(t->inbuf + i, t->in_len - i, &wc, &eaten);
|
||
int cw = utf8_width(wc == L'\t' ? L' ' : wc);
|
||
if (col + cw > right && col > IN_COL) {
|
||
/* offset i would be drawn at the start of the next row */
|
||
if (row == want_row)
|
||
return last;
|
||
row++;
|
||
col = IN_COL;
|
||
}
|
||
best = i + eaten;
|
||
last = i;
|
||
col += cw;
|
||
i += eaten;
|
||
}
|
||
return t->in_len;
|
||
}
|
||
|
||
static void toggle_plan(tui_t *t);
|
||
static int run_palette(tui_t *t, int busy, int *quit);
|
||
static void after_edit(tui_t *t);
|
||
|
||
/* Returns -2 to quit (palette → /quit), else 0. */
|
||
static int handle_click(tui_t *t, int y, int x, int busy) {
|
||
/* completion rows */
|
||
if (g_comp_y0 >= 0 && t->comp_n > 0 && y >= g_comp_y0 &&
|
||
y < g_comp_y0 + t->comp_n && x >= g_comp_x0 && x < g_comp_x0 + g_comp_w) {
|
||
t->comp_sel = y - g_comp_y0;
|
||
accept_completion(t);
|
||
after_edit(t);
|
||
redraw_all(t);
|
||
return 0;
|
||
}
|
||
/* input box */
|
||
if (y >= t->in_y && y < t->in_y + t->in_h && x >= t->in_x &&
|
||
x < t->in_x + t->in_w) {
|
||
int h = t->input_rows > 0 ? t->input_rows : 1;
|
||
int pad = (t->in_h - h - 1) / 2;
|
||
int ry = y - t->in_y;
|
||
if (ry == pad + h) {
|
||
if (x - t->in_x < IN_COL + 6)
|
||
toggle_plan(t);
|
||
return 0;
|
||
}
|
||
int cr, cc;
|
||
input_layout(t, NULL, NULL, &cr, &cc);
|
||
int top = cr >= h ? cr - h + 1 : 0;
|
||
int row = ry - pad + top;
|
||
if (row < 0)
|
||
row = 0;
|
||
t->cur = input_hit(t, row, x - t->in_x);
|
||
draw_input(t);
|
||
return 0;
|
||
}
|
||
/* status row: "ctrl+p commands" */
|
||
if (y == t->rows - 1 && t->cmd_x0 >= 0 && x >= t->cmd_x0 && x <= t->cmd_x1) {
|
||
int quit = 0;
|
||
return run_palette(t, busy, &quit) == -2 || quit ? -2 : 0;
|
||
}
|
||
/* transcript blocks */
|
||
if (!t->home && t->logw) {
|
||
int lh, lw;
|
||
getmaxyx(t->logw, lh, lw);
|
||
int ry = y - t->log_y, rx = x - t->log_x;
|
||
if (ry < 0 || ry >= lh || rx < 0 || rx >= lw || ry >= t->row_blk_cap)
|
||
return 0;
|
||
int bi = t->row_blk[ry];
|
||
if (bi < 0 || bi >= t->hist.n)
|
||
return 0;
|
||
block_t *b = &t->hist.items[bi];
|
||
if (b->kind == BLK_THINK) {
|
||
t->focus_think_idx = bi;
|
||
b->expanded = !b->expanded;
|
||
} else if (b->kind == BLK_TOOL) {
|
||
t->last_tool_idx = bi;
|
||
b->expanded = !b->expanded;
|
||
} else {
|
||
return 0;
|
||
}
|
||
draw_log(t);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* Transcript cell under screen (y, x) as display line + column. Outside the
|
||
* transcript returns 0, unless `clamp` pulls the point to its nearest edge. */
|
||
static int log_cell(const tui_t *t, int y, int x, int clamp, int *li,
|
||
int *col) {
|
||
if (t->home || !t->logw)
|
||
return 0;
|
||
int lh, lw;
|
||
getmaxyx(t->logw, lh, lw);
|
||
int ry = y - t->log_y, rx = x - t->log_x;
|
||
if (!clamp && (ry < 0 || ry >= lh || rx < 0 || rx >= lw))
|
||
return 0;
|
||
*li = t->log_start + clampi(ry, 0, lh - 1);
|
||
*col = clampi(rx, 0, lw - 1);
|
||
return 1;
|
||
}
|
||
|
||
static void sel_clear(tui_t *t) {
|
||
int shown = t->sel_state == SEL_DRAG || t->sel_state == SEL_SHOWN;
|
||
t->sel_state = SEL_NONE;
|
||
if (shown)
|
||
draw_log(t);
|
||
}
|
||
|
||
static void sel_copy(tui_t *t) {
|
||
int h, w;
|
||
getmaxyx(t->logw, h, w);
|
||
(void)h;
|
||
dlines_t dl = {0};
|
||
build_display(t, &dl, w);
|
||
int y1, x1, y2, x2;
|
||
sel_range(t, &dl, &y1, &x1, &y2, &x2);
|
||
cst_selbuf_t b = {0};
|
||
for (int k = y1 < 0 ? 0 : y1; k <= y2 && k < dl.n; k++) {
|
||
const dline_t *L = &dl.lines[k];
|
||
int c1 = k == y1 ? x1 : 0, c2 = k == y2 ? x2 : -1;
|
||
if (k > y1) {
|
||
if (!L->cont)
|
||
cst_selbuf_putc(&b, '\n');
|
||
else if (c1 < L->indent + L->cont_pad)
|
||
c1 = L->indent + L->cont_pad;
|
||
}
|
||
int joined = k < y2 && k + 1 < dl.n && dl.lines[k + 1].cont;
|
||
cst_textsel_append(&b, L->text, L->indent, c1, c2, !joined);
|
||
}
|
||
dlines_free(&dl);
|
||
if (b.n && osc52_copy(b.s, b.n)) {
|
||
size_t chars = 0;
|
||
for (size_t i = 0; i < b.n; i++)
|
||
chars += ((unsigned char)b.s[i] & 0xC0) != 0x80;
|
||
char msg[96];
|
||
snprintf(msg, sizeof(msg), "Copied %zu characters (" COPY_VIA ")", chars);
|
||
set_status(t, msg);
|
||
draw_status(t);
|
||
}
|
||
cst_selbuf_free(&b);
|
||
}
|
||
|
||
/* Drag while the button is held: extend the selection, scrolling the
|
||
* transcript when the pointer is above or below it. */
|
||
static void sel_motion(tui_t *t, int y, int x) {
|
||
int lh, lw;
|
||
getmaxyx(t->logw, lh, lw);
|
||
(void)lw;
|
||
int ry = y - t->log_y;
|
||
if (ry < 0 || ry >= lh) {
|
||
int before = t->log_scroll;
|
||
t->log_scroll += ry < 0 ? 1 : -1;
|
||
if (t->log_scroll < 0)
|
||
t->log_scroll = 0;
|
||
if (t->log_scroll != before)
|
||
draw_log(t); /* updates log_start */
|
||
}
|
||
int li, col;
|
||
if (!log_cell(t, y, x, 1, &li, &col))
|
||
return;
|
||
if (t->sel_state == SEL_PRESS && li == t->sel_ay && col == t->sel_ax)
|
||
return;
|
||
t->sel_state = SEL_DRAG;
|
||
t->sel_y = li;
|
||
t->sel_x = col;
|
||
draw_log(t);
|
||
}
|
||
|
||
/* Wheel, clicks and drag selection; -2 means quit. */
|
||
static int on_mouse(tui_t *t, const MEVENT *ev, int busy) {
|
||
mmask_t b = ev->bstate;
|
||
if (b & BUTTON4_PRESSED) {
|
||
t->log_scroll += SCROLL_SPEED;
|
||
draw_log(t);
|
||
return 0;
|
||
}
|
||
if (b & BUTTON5_PRESSED) {
|
||
t->log_scroll -= SCROLL_SPEED;
|
||
if (t->log_scroll < 0)
|
||
t->log_scroll = 0;
|
||
draw_log(t);
|
||
return 0;
|
||
}
|
||
if (b & REPORT_MOUSE_POSITION) {
|
||
if (t->sel_state == SEL_PRESS || t->sel_state == SEL_DRAG)
|
||
sel_motion(t, ev->y, ev->x);
|
||
return 0;
|
||
}
|
||
if (b & (BUTTON1_PRESSED | BUTTON1_CLICKED)) {
|
||
sel_clear(t);
|
||
int li, col;
|
||
if (!(b & BUTTON1_PRESSED) || !log_cell(t, ev->y, ev->x, 0, &li, &col))
|
||
return handle_click(t, ev->y, ev->x, busy);
|
||
if (t->sel_clicks && since_ms(&t->sel_click_at) < MULTI_CLICK_MS &&
|
||
li == t->sel_click_y && abs(col - t->sel_click_x) <= 1)
|
||
t->sel_clicks = t->sel_clicks % 3 + 1;
|
||
else
|
||
t->sel_clicks = 1;
|
||
clock_gettime(CLOCK_MONOTONIC, &t->sel_click_at);
|
||
t->sel_click_y = li;
|
||
t->sel_click_x = col;
|
||
t->sel_mode = t->sel_clicks;
|
||
t->sel_ay = t->sel_y = li;
|
||
t->sel_ax = t->sel_x = col;
|
||
t->sel_press_y = ev->y;
|
||
t->sel_press_x = ev->x;
|
||
t->sel_state = t->sel_clicks > 1 ? SEL_DRAG : SEL_PRESS;
|
||
if (t->sel_state == SEL_DRAG)
|
||
draw_log(t);
|
||
return 0;
|
||
}
|
||
if (b & BUTTON1_RELEASED) {
|
||
if (t->sel_state == SEL_PRESS) {
|
||
t->sel_state = SEL_NONE;
|
||
return handle_click(t, t->sel_press_y, t->sel_press_x, busy);
|
||
}
|
||
if (t->sel_state == SEL_DRAG) {
|
||
sel_copy(t);
|
||
t->sel_state = SEL_SHOWN;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* Tab completes after "/" at the start or an "@word" under the cursor. */
|
||
static int wants_completion(const tui_t *t) {
|
||
if (t->in_len && t->inbuf[0] == '/')
|
||
return 1;
|
||
size_t i = t->cur;
|
||
while (i > 0 && t->inbuf[i - 1] != ' ' && t->inbuf[i - 1] != '\n')
|
||
i--;
|
||
return i < t->cur && t->inbuf[i] == '@';
|
||
}
|
||
|
||
static void toggle_plan(tui_t *t) {
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
cfg->plan_mode = !cfg->plan_mode;
|
||
set_status(t, cfg->plan_mode ? "Plan mode: read-only tools, answers with a "
|
||
"plan"
|
||
: "Build mode: all tools");
|
||
draw_status(t);
|
||
draw_input(t);
|
||
}
|
||
|
||
static int run_action(tui_t *t, int action, int busy);
|
||
static int run_keys_panel(tui_t *t, int busy, int *quit);
|
||
static void insert_newline(tui_t *t);
|
||
|
||
/* Keys bound to the action whose slash command is "/name", or "". */
|
||
static void slash_binding(const tui_t *t, const char *name, char *buf,
|
||
size_t n) {
|
||
buf[0] = 0;
|
||
for (int a = 1; a < CST_KA_COUNT; a++) {
|
||
const cst_key_action_info_t *in = cst_key_action_info(a);
|
||
if (in && in->slash && strcmp(in->slash + 1, name) == 0) {
|
||
cst_keymap_describe(&t->km, a, buf, n);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Ctrl+P: every slash command plus key-only actions, filterable. */
|
||
static int run_palette(tui_t *t, int busy, int *quit) {
|
||
static const int extra[] = {CST_KA_MODE, CST_KA_APPROVAL,
|
||
CST_KA_TOGGLE_THOUGHT, CST_KA_TOGGLE_TOOL,
|
||
CST_KA_TODO, CST_KA_KEYS};
|
||
int ne = (int)(sizeof(extra) / sizeof(extra[0]));
|
||
int nc;
|
||
const slash_cmd_t *tab = slash_table(&nc);
|
||
static char items[96][128];
|
||
int map[96];
|
||
int n = 0;
|
||
char keys[64];
|
||
for (int i = 0; i < ne && n < 96; i++, n++) {
|
||
const cst_key_action_info_t *in = cst_key_action_info(extra[i]);
|
||
cst_keymap_describe(&t->km, extra[i], keys, sizeof(keys));
|
||
char d[64];
|
||
snprintf(d, sizeof(d), "%c%s", toupper((unsigned char)in->desc[0]),
|
||
in->desc + 1);
|
||
snprintf(items[n], sizeof(items[n]), "%-44s %s", d, keys);
|
||
map[n] = -1 - extra[i];
|
||
}
|
||
for (int i = 0; i < nc && n < 96; i++, n++) {
|
||
slash_binding(t, tab[i].name, keys, sizeof(keys));
|
||
char d[64];
|
||
snprintf(d, sizeof(d), "/%-12s %.30s", tab[i].name, tab[i].desc);
|
||
snprintf(items[n], sizeof(items[n]), "%-44s %s", d, keys);
|
||
map[n] = i;
|
||
}
|
||
int pick = picker_run(t, "Commands", items, n, -1);
|
||
if (pick < 0)
|
||
return 0;
|
||
if (map[pick] < 0) {
|
||
int rc = run_action(t, -1 - map[pick], busy);
|
||
if (rc == -2)
|
||
*quit = 1;
|
||
return rc;
|
||
}
|
||
const slash_cmd_t *c = &tab[map[pick]];
|
||
char line[64];
|
||
snprintf(line, sizeof(line), "/%s", c->name);
|
||
if (c->args[0] == '<') { /* needs an argument: let the user type it */
|
||
snprintf(line, sizeof(line), "/%s ", c->name);
|
||
input_set(t, line);
|
||
redraw_all(t);
|
||
return 0;
|
||
}
|
||
if (busy) {
|
||
handle_slash(t, line, quit);
|
||
redraw_all(t);
|
||
return *quit ? -2 : 0;
|
||
}
|
||
char *p = xstrdup(line);
|
||
return p ? start_turn(t, p) : 0;
|
||
}
|
||
|
||
/* --- key bindings ---------------------------------------------------------- */
|
||
|
||
/* Runs "/cmd" the way the palette does; -2 means quit. */
|
||
static int run_slash_line(tui_t *t, const char *line, int busy) {
|
||
if (busy) {
|
||
int quit = 0;
|
||
handle_slash(t, line, &quit);
|
||
redraw_all(t);
|
||
return quit ? -2 : 0;
|
||
}
|
||
char *p = xstrdup(line);
|
||
return p ? start_turn(t, p) : 0;
|
||
}
|
||
|
||
static void scroll_log(tui_t *t, int delta) {
|
||
t->log_scroll += delta;
|
||
if (t->log_scroll < 0)
|
||
t->log_scroll = 0;
|
||
draw_log(t);
|
||
}
|
||
|
||
static int run_action(tui_t *t, int action, int busy) {
|
||
int quit = 0;
|
||
switch (action) {
|
||
case CST_KA_PALETTE:
|
||
return run_palette(t, busy, &quit) == -2 || quit ? -2 : 0;
|
||
case CST_KA_KEYS:
|
||
return run_keys_panel(t, busy, &quit) == -2 || quit ? -2 : 0;
|
||
case CST_KA_MODE:
|
||
toggle_plan(t);
|
||
return 0;
|
||
case CST_KA_APPROVAL: {
|
||
set_approval(t, (approval_mode(t) + 1) % 3);
|
||
char label[64], msg[160];
|
||
approval_label(t, label, sizeof(label));
|
||
snprintf(msg, sizeof(msg), "approval: %s", label);
|
||
set_status(t, msg);
|
||
redraw_all(t);
|
||
return 0;
|
||
}
|
||
case CST_KA_NEWLINE:
|
||
insert_newline(t);
|
||
return 0;
|
||
case CST_KA_REDRAW:
|
||
clearok(curscr, TRUE);
|
||
redraw_all(t);
|
||
return 0;
|
||
case CST_KA_QUIT:
|
||
return -2;
|
||
case CST_KA_TOGGLE_TOOL:
|
||
toggle_last_tool(t);
|
||
return 0;
|
||
case CST_KA_TOGGLE_THOUGHT:
|
||
toggle_think(t);
|
||
return 0;
|
||
case CST_KA_TODO:
|
||
t->show_todo = !t->show_todo;
|
||
layout(t);
|
||
wtimeout(t->input, 80);
|
||
redraw_all(t);
|
||
return 0;
|
||
case CST_KA_SCROLL_UP:
|
||
scroll_log(t, t->rows / 2);
|
||
return 0;
|
||
case CST_KA_SCROLL_DOWN:
|
||
scroll_log(t, -(t->rows / 2));
|
||
return 0;
|
||
case CST_KA_LINE_UP:
|
||
scroll_log(t, 1);
|
||
return 0;
|
||
case CST_KA_LINE_DOWN:
|
||
scroll_log(t, -1);
|
||
return 0;
|
||
case CST_KA_TOP:
|
||
scroll_log(t, 1 << 28); /* clamped by draw_log */
|
||
return 0;
|
||
case CST_KA_BOTTOM:
|
||
t->log_scroll = 0;
|
||
draw_log(t);
|
||
return 0;
|
||
default: {
|
||
const cst_key_action_info_t *in = cst_key_action_info(action);
|
||
if (in && in->slash)
|
||
return run_slash_line(t, in->slash, busy);
|
||
return 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Leader pressed: show what can follow it. */
|
||
static void leader_hint(tui_t *t) {
|
||
char msg[sizeof(t->status_msg)], k[32];
|
||
size_t o = (size_t)snprintf(msg, sizeof(msg), "%s ›",
|
||
cst_key_name(t->km.leader, k, sizeof(k)));
|
||
for (int i = 0; i < t->km.n && o + 1 < sizeof(msg); i++) {
|
||
const cst_binding_t *b = &t->km.b[i];
|
||
if (b->nkeys != 2 || b->keys[0] != t->km.leader)
|
||
continue;
|
||
const cst_key_action_info_t *in = cst_key_action_info(b->action);
|
||
int w = snprintf(msg + o, sizeof(msg) - o, " %s %s",
|
||
cst_key_name(b->keys[1], k, sizeof(k)), in->name);
|
||
if (w < 0 || (size_t)w >= sizeof(msg) - o)
|
||
break;
|
||
o += (size_t)w;
|
||
}
|
||
set_status(t, msg);
|
||
draw_status(t);
|
||
}
|
||
|
||
/* 1 = handled, 0 = not bound (fall through to editing keys), -2 = quit. */
|
||
static int key_dispatch(tui_t *t, int code, int busy) {
|
||
if (code <= 0)
|
||
return 0;
|
||
if (t->leader_pending && since_ms(&t->leader_at) > 3000)
|
||
t->leader_pending = 0;
|
||
int action = 0;
|
||
if (t->leader_pending) {
|
||
int seq[2] = {t->km.leader, code};
|
||
t->leader_pending = 0;
|
||
status_idle(t);
|
||
if (code == 27) {
|
||
draw_status(t);
|
||
return 1;
|
||
}
|
||
if (cst_keymap_lookup(&t->km, seq, 2, &action) == CST_KM_MATCH)
|
||
return run_action(t, action, busy) == -2 ? -2 : 1;
|
||
char a[32], b[32], msg[96];
|
||
snprintf(msg, sizeof(msg), "no shortcut for %s %s",
|
||
cst_key_name(seq[0], a, sizeof(a)),
|
||
cst_key_name(code, b, sizeof(b)));
|
||
set_status(t, msg);
|
||
draw_status(t);
|
||
return 1;
|
||
}
|
||
/* Reserved: Enter, Ctrl+C, Ctrl+D, Esc; plain typing. */
|
||
if (code == 13 || code == 10 || code == 3 || code == 4 || code == 27)
|
||
return 0;
|
||
if (code >= 32 && code < CST_KEY_SPECIAL && code != 127)
|
||
return 0;
|
||
/* Completion popup keys win over bindings. */
|
||
if (code == 9 && (t->comp_n > 0 || wants_completion(t)))
|
||
return 0;
|
||
if (t->comp_n > 0 &&
|
||
(code == CST_K_BTAB || code == CST_K_UP || code == CST_K_DOWN))
|
||
return 0;
|
||
int seq[1] = {code};
|
||
int r = cst_keymap_lookup(&t->km, seq, 1, &action);
|
||
if (r == CST_KM_MATCH)
|
||
return run_action(t, action, busy) == -2 ? -2 : 1;
|
||
if (r == CST_KM_PREFIX) {
|
||
t->leader_pending = 1;
|
||
clock_gettime(CLOCK_MONOTONIC, &t->leader_at);
|
||
leader_hint(t);
|
||
return 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* /keys and F1: every action with its keys; picking one runs it. */
|
||
static int run_keys_panel(tui_t *t, int busy, int *quit) {
|
||
static char items[CST_KA_COUNT + 16][128];
|
||
int map[CST_KA_COUNT + 16];
|
||
int n = 0;
|
||
static const char *const GROUPS[] = {"General", "View", "Session", "Model",
|
||
"Agents"};
|
||
for (size_t g = 0; g < sizeof(GROUPS) / sizeof(GROUPS[0]); g++) {
|
||
for (int a = 1; a < CST_KA_COUNT; a++) {
|
||
const cst_key_action_info_t *in = cst_key_action_info(a);
|
||
if (!in || strcmp(in->group, GROUPS[g]) != 0)
|
||
continue;
|
||
char keys[64];
|
||
cst_keymap_describe(&t->km, a, keys, sizeof(keys));
|
||
int user = 0;
|
||
for (int i = 0; i < t->km.n; i++)
|
||
if (t->km.b[i].action == a && t->km.b[i].user)
|
||
user = 1;
|
||
snprintf(items[n], sizeof(items[n]), "%-8s %-36s %s%s", in->group,
|
||
in->desc, keys[0] ? keys : "(unbound)", user ? " *" : "");
|
||
map[n++] = a;
|
||
}
|
||
}
|
||
static const char *const FIXED[] = {
|
||
"Edit send enter",
|
||
"Edit cancel the turn / close popups esc",
|
||
"Edit clear input, twice to quit ctrl+c",
|
||
"Edit quit on an empty prompt ctrl+d",
|
||
"Edit new line (kitty keyboard terminals) shift+enter ctrl+enter",
|
||
"Edit move by char / word / line ← → ctrl+← → alt+b alt+f ctrl+a ctrl+e",
|
||
"Edit delete word back / forward ctrl+w alt+backspace ctrl+backspace ctrl+del",
|
||
"Edit delete to start / to end ctrl+u ctrl+k",
|
||
"Edit prompt history ↑ ↓",
|
||
};
|
||
for (size_t i = 0; i < sizeof(FIXED) / sizeof(FIXED[0]); i++) {
|
||
snprintf(items[n], sizeof(items[n]), "%s", FIXED[i]);
|
||
map[n++] = 0;
|
||
}
|
||
char conf[512];
|
||
if (cst_keymap_conflicts(&t->km, conf, sizeof(conf)) > 0)
|
||
push_system_line(t, "Key conflicts (the first binding wins):\n%s", conf);
|
||
int pick = picker_run(t, "Keys (* = keybinds.json)", items, n, -1);
|
||
if (pick < 0 || map[pick] == 0)
|
||
return 0;
|
||
int rc = run_action(t, map[pick], busy);
|
||
if (rc == -2)
|
||
*quit = 1;
|
||
return rc;
|
||
}
|
||
|
||
#ifdef NCURSES_VERSION
|
||
static int g_k_alt_up, g_k_alt_down, g_k_ctrl_home, g_k_ctrl_end,
|
||
g_k_ctrl_up, g_k_ctrl_down, g_k_ctrl_left, g_k_ctrl_right, g_k_alt_left,
|
||
g_k_alt_right, g_k_ctrl_dc, g_k_alt_dc;
|
||
|
||
/* The terminfo key, else the xterm sequence bound to our own code `own`. */
|
||
static int ext_key(const char *cap, const char *xterm, int own) {
|
||
char *s = tigetstr(cap);
|
||
int k = s && s != (char *)-1 ? key_defined(s) : 0;
|
||
if (k > 0)
|
||
return k;
|
||
#ifndef _WIN32
|
||
k = key_defined(xterm);
|
||
if (k > 0)
|
||
return k;
|
||
if (k == 0 && define_key(xterm, own) == OK)
|
||
return own;
|
||
#else
|
||
(void)xterm, (void)own;
|
||
#endif
|
||
return 0;
|
||
}
|
||
#endif
|
||
|
||
#if defined(NCURSES_VERSION) && !defined(_WIN32)
|
||
/* Terminfo lists one form per key, but terminals disagree: cursor keys come
|
||
* as CSI or SS3 whatever DECCKM says (tmux, mosh, a wrong TERM), Home/End as
|
||
* CSI H, SS3 H or CSI 1~/7~. Unknown forms would be read as Esc + text. */
|
||
static void vt_keys_init(void) {
|
||
static const struct {
|
||
const char *seq;
|
||
int key;
|
||
} K[] = {
|
||
{"\033[A", KEY_UP}, {"\033OA", KEY_UP}, {"\033[B", KEY_DOWN},
|
||
{"\033OB", KEY_DOWN}, {"\033[C", KEY_RIGHT}, {"\033OC", KEY_RIGHT},
|
||
{"\033[D", KEY_LEFT}, {"\033OD", KEY_LEFT}, {"\033[H", KEY_HOME},
|
||
{"\033OH", KEY_HOME}, {"\033[1~", KEY_HOME}, {"\033[7~", KEY_HOME},
|
||
{"\033[F", KEY_END}, {"\033OF", KEY_END}, {"\033[4~", KEY_END},
|
||
{"\033[8~", KEY_END}, {"\033[2~", KEY_IC}, {"\033[3~", KEY_DC},
|
||
{"\033[5~", KEY_PPAGE}, {"\033[6~", KEY_NPAGE}, {"\033[Z", KEY_BTAB},
|
||
{"\033[1;2A", KEY_SR}, {"\033[1;2B", KEY_SF}, {"\033[1;2C", KEY_SRIGHT},
|
||
{"\033[1;2D", KEY_SLEFT},
|
||
};
|
||
for (size_t i = 0; i < sizeof(K) / sizeof(K[0]); i++)
|
||
if (key_defined(K[i].seq) == 0)
|
||
define_key(K[i].seq, K[i].key);
|
||
}
|
||
#endif
|
||
|
||
static void keys_init(void) {
|
||
#ifdef NCURSES_VERSION
|
||
int own = KEY_OWN_BASE + 4; /* up to KEY_KITTY_BASE - 1 */
|
||
g_k_alt_up = ext_key("kUP3", "\033[1;3A", own++);
|
||
g_k_alt_down = ext_key("kDN3", "\033[1;3B", own++);
|
||
g_k_ctrl_home = ext_key("kHOM5", "\033[1;5H", own++);
|
||
g_k_ctrl_end = ext_key("kEND5", "\033[1;5F", own++);
|
||
g_k_ctrl_up = ext_key("kUP5", "\033[1;5A", own++);
|
||
g_k_ctrl_down = ext_key("kDN5", "\033[1;5B", own++);
|
||
g_k_ctrl_left = ext_key("kLFT5", "\033[1;5D", own++);
|
||
g_k_ctrl_right = ext_key("kRIT5", "\033[1;5C", own++);
|
||
g_k_alt_left = ext_key("kLFT3", "\033[1;3D", own++);
|
||
g_k_alt_right = ext_key("kRIT3", "\033[1;3C", own++);
|
||
g_k_ctrl_dc = ext_key("kDC5", "\033[3;5~", own++);
|
||
g_k_alt_dc = ext_key("kDC3", "\033[3;3~", own++);
|
||
#endif
|
||
}
|
||
|
||
/* ctrl/alt+left/right move by word, ctrl/alt+delete deletes the next word. */
|
||
static int word_key(int k, int *dir) {
|
||
#ifdef NCURSES_VERSION
|
||
if (k && (k == g_k_ctrl_left || k == g_k_alt_left))
|
||
return *dir = -1, 1;
|
||
if (k && (k == g_k_ctrl_right || k == g_k_alt_right))
|
||
return *dir = 1, 1;
|
||
if (k && (k == g_k_ctrl_dc || k == g_k_alt_dc))
|
||
return *dir = 0, 1;
|
||
#else
|
||
(void)k, (void)dir;
|
||
#endif
|
||
return 0;
|
||
}
|
||
|
||
/* ncurses event → cst_keymap code (0 for events that are not keys). */
|
||
static int tui_key_code(int rc, wint_t wch) {
|
||
if (rc != KEY_CODE_YES)
|
||
return (int)wch;
|
||
int k = (int)wch;
|
||
switch (k) {
|
||
case KEY_UP:
|
||
return CST_K_UP;
|
||
case KEY_DOWN:
|
||
return CST_K_DOWN;
|
||
case KEY_LEFT:
|
||
return CST_K_LEFT;
|
||
case KEY_RIGHT:
|
||
return CST_K_RIGHT;
|
||
case KEY_HOME:
|
||
return CST_K_HOME;
|
||
case KEY_END:
|
||
return CST_K_END;
|
||
case KEY_PPAGE:
|
||
return CST_K_PGUP;
|
||
case KEY_NPAGE:
|
||
return CST_K_PGDN;
|
||
case KEY_BTAB:
|
||
return CST_K_BTAB;
|
||
case KEY_DC:
|
||
return CST_K_DEL;
|
||
case KEY_IC:
|
||
return CST_K_INS;
|
||
case KEY_ENTER:
|
||
return 13;
|
||
case KEY_BACKSPACE:
|
||
return 127;
|
||
default:
|
||
break;
|
||
}
|
||
if (k >= KEY_F(1) && k <= KEY_F(12))
|
||
return CST_K_F1 + (k - KEY_F(1));
|
||
#ifdef NCURSES_VERSION
|
||
if (k && k == g_k_alt_up)
|
||
return CST_K_ALT_UP;
|
||
if (k && k == g_k_alt_down)
|
||
return CST_K_ALT_DOWN;
|
||
if (k && k == g_k_ctrl_home)
|
||
return CST_K_CTRL_HOME;
|
||
if (k && k == g_k_ctrl_end)
|
||
return CST_K_CTRL_END;
|
||
if (k && k == g_k_ctrl_up)
|
||
return CST_K_CTRL_UP;
|
||
if (k && k == g_k_ctrl_down)
|
||
return CST_K_CTRL_DOWN;
|
||
#endif
|
||
return 0;
|
||
}
|
||
|
||
/* --- modal key loops ------------------------------------------------------ */
|
||
|
||
static void perm_answer(tui_t *t, int ans) {
|
||
tui_lock(t);
|
||
t->perm_ans = ans;
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void run_perm_modal(tui_t *t) {
|
||
wtimeout(t->input, -1);
|
||
curs_set(0);
|
||
for (;;) {
|
||
draw_perm_modal(t);
|
||
draw_status(t);
|
||
wint_t wch = 0;
|
||
int rc = tui_wget_wch(t->input, &wch);
|
||
if (rc == ERR)
|
||
continue;
|
||
if (rc == KEY_CODE_YES) {
|
||
int h = getmaxy(t->logw);
|
||
if (wch == KEY_RESIZE)
|
||
layout(t), wtimeout(t->input, -1), redraw_all(t);
|
||
else if (wch == KEY_UP)
|
||
t->perm_scroll--;
|
||
else if (wch == KEY_DOWN)
|
||
t->perm_scroll++;
|
||
else if (wch == KEY_PPAGE)
|
||
t->perm_scroll -= h / 2;
|
||
else if (wch == KEY_NPAGE)
|
||
t->perm_scroll += h / 2;
|
||
else if (wch == KEY_ENTER) {
|
||
perm_answer(t, CST_PERM_ONCE);
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == L'y' || wch == L'Y' || wch == 10 || wch == 13) {
|
||
perm_answer(t, CST_PERM_ONCE);
|
||
break;
|
||
}
|
||
if (wch == L'n' || wch == L'N' || wch == 27) {
|
||
perm_answer(t, CST_PERM_DENY);
|
||
break;
|
||
}
|
||
if (wch == 3) { /* Ctrl+C: deny and stop the turn */
|
||
perm_answer(t, CST_PERM_DENY);
|
||
request_cancel_ui(t);
|
||
break;
|
||
}
|
||
if ((wch == L'a' || wch == L'A') && !t->perm_sandbox) {
|
||
perm_answer(t, CST_PERM_ALWAYS_TOOL);
|
||
break;
|
||
}
|
||
if ((wch == L'p' || wch == L'P') && t->perm_prefix[0]) {
|
||
perm_answer(t, CST_PERM_ALWAYS_PREFIX);
|
||
break;
|
||
}
|
||
}
|
||
curs_set(1);
|
||
wtimeout(t->input, 80);
|
||
redraw_all(t);
|
||
}
|
||
|
||
static void ask_finish(tui_t *t, int rc) {
|
||
tui_lock(t);
|
||
if (rc == 0 && !t->ask_buf[0] && t->ask_nopts)
|
||
snprintf(t->ask_buf, sizeof(t->ask_buf), "%s", t->ask_opts[t->ask_sel]);
|
||
t->ask_rc = rc;
|
||
t->ask_done = 1;
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void run_ask_modal(tui_t *t) {
|
||
wtimeout(t->input, -1);
|
||
curs_set(0);
|
||
for (;;) {
|
||
draw_ask_modal(t);
|
||
wint_t wch = 0;
|
||
int rc = tui_wget_wch(t->input, &wch);
|
||
if (rc == ERR)
|
||
continue;
|
||
if (rc == KEY_CODE_YES) {
|
||
if (wch == KEY_RESIZE)
|
||
layout(t), wtimeout(t->input, -1), redraw_all(t);
|
||
else if (wch == KEY_UP && t->ask_sel > 0)
|
||
t->ask_sel--;
|
||
else if (wch == KEY_DOWN && t->ask_sel + 1 < t->ask_nopts)
|
||
t->ask_sel++;
|
||
else if (wch == KEY_BACKSPACE) {
|
||
size_t n = strlen(t->ask_buf);
|
||
t->ask_buf[n - utf8_prev_len(t->ask_buf, n)] = 0;
|
||
} else if (wch == KEY_ENTER && (t->ask_buf[0] || t->ask_nopts)) {
|
||
ask_finish(t, 0);
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 27 || wch == 3) {
|
||
ask_finish(t, -1);
|
||
break;
|
||
}
|
||
if (wch == 10 || wch == 13) {
|
||
if (t->ask_buf[0] || t->ask_nopts) {
|
||
ask_finish(t, 0);
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 127 || wch == 8) {
|
||
size_t n = strlen(t->ask_buf);
|
||
t->ask_buf[n - utf8_prev_len(t->ask_buf, n)] = 0;
|
||
continue;
|
||
}
|
||
if (!t->ask_buf[0] && wch >= L'1' && wch <= L'9' &&
|
||
(int)(wch - L'1') < t->ask_nopts) {
|
||
t->ask_sel = (int)(wch - L'1');
|
||
ask_finish(t, 0);
|
||
break;
|
||
}
|
||
if (iswprint(wch)) {
|
||
char mb[8];
|
||
int k = os_utf8_encode((unsigned long)(wchar_t)wch, mb);
|
||
size_t n = strlen(t->ask_buf);
|
||
if (k > 0 && n + (size_t)k + 1 < sizeof(t->ask_buf)) {
|
||
memcpy(t->ask_buf + n, mb, (size_t)k);
|
||
t->ask_buf[n + k] = 0;
|
||
}
|
||
}
|
||
}
|
||
curs_set(1);
|
||
wtimeout(t->input, 80);
|
||
redraw_all(t);
|
||
}
|
||
|
||
/* --- main loop ------------------------------------------------------------ */
|
||
|
||
static void tui_free(tui_t *t) {
|
||
queue_free_all(t);
|
||
for (int i = 0; i < t->prompt_hist_n; i++)
|
||
free(t->prompt_hist[i]);
|
||
free(t->hist_draft);
|
||
for (int i = 0; i < t->nfiles; i++)
|
||
free(t->files[i]);
|
||
free(t->files);
|
||
free(t->todo);
|
||
free(t->pending_prompt);
|
||
free(t->perm_preview);
|
||
live_end_cleanup(t);
|
||
free_windows(t);
|
||
free(t->row_blk);
|
||
blocks_free(&t->hist);
|
||
pthread_mutex_destroy(&t->mu);
|
||
}
|
||
|
||
/* Redraw after an edit; the transcript only when the popup changed. */
|
||
static void after_edit(tui_t *t) {
|
||
layout_maybe_grow_input(t);
|
||
if (t->comp_n || t->comp_drawn) {
|
||
draw_log(t);
|
||
draw_input(t);
|
||
}
|
||
}
|
||
|
||
static void insert_newline(tui_t *t) {
|
||
input_put_byte(t, '\n');
|
||
layout_maybe_grow_input(t);
|
||
}
|
||
|
||
/* Paste mode: keep newlines/tabs literally; redraw once at the end. */
|
||
#ifdef _WIN32
|
||
static int win_key_pending(void) {
|
||
INPUT_RECORD rec[32];
|
||
DWORD n = 0;
|
||
HANDLE h = GetStdHandle(STD_INPUT_HANDLE);
|
||
if (!PeekConsoleInputW(h, rec, 32, &n))
|
||
return 0;
|
||
for (DWORD i = 0; i < n; i++)
|
||
if (rec[i].EventType == KEY_EVENT && rec[i].Event.KeyEvent.bKeyDown &&
|
||
rec[i].Event.KeyEvent.uChar.UnicodeChar)
|
||
return 1;
|
||
return 0;
|
||
}
|
||
#endif
|
||
|
||
static void paste_char(tui_t *t, wint_t wch) {
|
||
if (wch == 13) {
|
||
input_put_byte(t, '\n');
|
||
t->paste_cr = 1;
|
||
return;
|
||
}
|
||
if (wch == 10) {
|
||
if (!t->paste_cr)
|
||
input_put_byte(t, '\n');
|
||
t->paste_cr = 0;
|
||
return;
|
||
}
|
||
t->paste_cr = 0;
|
||
if (wch == L'\t')
|
||
input_insert(t, " ", 4);
|
||
else if (iswprint(wch) || wcwidth((wchar_t)wch) > 0)
|
||
input_put_wc(t, (wchar_t)wch);
|
||
}
|
||
|
||
int cst_tui_run(cst_agent_t *agent) {
|
||
#ifdef _WIN32
|
||
setlocale(LC_ALL, ".UTF-8");
|
||
#else
|
||
if (!setlocale(LC_ALL, ""))
|
||
setlocale(LC_ALL, "C.UTF-8");
|
||
#endif
|
||
#ifndef _WIN32
|
||
if (!getenv("ESCDELAY"))
|
||
setenv("ESCDELAY", "25", 0);
|
||
#endif
|
||
|
||
#ifdef _WIN32
|
||
win_console_font(1);
|
||
#endif
|
||
initscr();
|
||
#ifdef _WIN32
|
||
idcok(stdscr, FALSE);
|
||
#endif
|
||
raw(); /* Ctrl+C/Ctrl+Z reach us as keys instead of signals */
|
||
noecho();
|
||
keypad(stdscr, TRUE);
|
||
curs_set(1);
|
||
theme_init();
|
||
#if defined(NCURSES_VERSION) && !defined(_WIN32)
|
||
/* the Windows console driver reads key events, not VT sequences */
|
||
define_key("\033[200~", KEY_PASTE_BEGIN);
|
||
define_key("\033[201~", KEY_PASTE_END);
|
||
vt_keys_init();
|
||
printf("\033[?2004h");
|
||
fflush(stdout);
|
||
kitty_init();
|
||
#endif
|
||
|
||
tui_t t;
|
||
memset(&t, 0, sizeof(t));
|
||
t.agent = agent;
|
||
t.think_idx = -1;
|
||
t.last_tool_idx = -1;
|
||
t.focus_think_idx = -1;
|
||
t.hist_browse = -1;
|
||
t.show_todo = 1;
|
||
t.show_side = 1; /* Seat Deck; narrow terminals auto-hide in layout() */
|
||
{
|
||
const char *mo = getenv("CST_ASCII_MOTION");
|
||
t.motion_on = !(mo && mo[0] == '0');
|
||
}
|
||
{
|
||
cst_settings_t st;
|
||
cst_settings_load(&st);
|
||
t.show_thinking = st.show_thinking;
|
||
}
|
||
keys_init();
|
||
char key_err[256] = "";
|
||
cst_keymap_load(&t.km, key_err, sizeof(key_err));
|
||
t.mouse_on = 1;
|
||
{
|
||
int sk = cst_sandbox_probe();
|
||
t.sandbox_name = sk == CST_SANDBOX_NONE ? "rules" : cst_sandbox_name(sk);
|
||
}
|
||
t.tip = TIPS[(unsigned)time(NULL) % (sizeof(TIPS) / sizeof(TIPS[0]))];
|
||
where_label(cst_agent_config(agent)->workspace_root, t.where,
|
||
sizeof(t.where));
|
||
pthread_mutex_init(&t.mu, NULL);
|
||
blocks_init(&t.hist);
|
||
if (!t.hist.items) {
|
||
endwin();
|
||
return -1;
|
||
}
|
||
|
||
refresh_title(&t);
|
||
refresh_todo(&t);
|
||
layout(&t);
|
||
t.need_layout = 0;
|
||
reload_hist_from_session(&t);
|
||
status_idle(&t);
|
||
if (key_err[0])
|
||
set_status(&t, key_err);
|
||
redraw_all(&t);
|
||
wtimeout(t.input, 80);
|
||
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(agent);
|
||
cst_settings_t first;
|
||
if (!cfg->api_key[0] && cst_settings_load(&first) != 0) {
|
||
int ignore = 0;
|
||
cmd_provider(&t, NULL, &ignore);
|
||
}
|
||
if (!cfg->api_key[0]) {
|
||
char key[512];
|
||
if (obtain_key(&t, cfg->provider, key, sizeof(key), 0) == 0)
|
||
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
|
||
else
|
||
push_system_line(&t, "No API key for %s yet — /key to enter one, or "
|
||
"/provider to switch",
|
||
cfg->provider);
|
||
memset(key, 0, sizeof(key));
|
||
}
|
||
cst_settings_remember(cfg);
|
||
if (am_models(cfg->provider, NULL, 0) < 0)
|
||
start_probe(&t);
|
||
redraw_all(&t);
|
||
|
||
for (;;) {
|
||
if (g_am_changed) {
|
||
g_am_changed = 0;
|
||
am_check_model(&t);
|
||
draw_status(&t);
|
||
}
|
||
tui_lock(&t);
|
||
int dirty = t.dirty;
|
||
int turn_done = t.turn_done;
|
||
int turn_rc = t.turn_rc;
|
||
char turn_err[512];
|
||
snprintf(turn_err, sizeof(turn_err), "%s", t.turn_err);
|
||
int perm_ask = t.perm_ask && t.perm_ans < 0;
|
||
int ask = t.ask_pending && !t.ask_done;
|
||
int busy = t.busy;
|
||
int relayout = t.need_layout;
|
||
t.need_layout = 0;
|
||
t.dirty = 0;
|
||
if (turn_done)
|
||
t.turn_done = 0;
|
||
tui_unlock(&t);
|
||
|
||
if (drain_job_completions(&t))
|
||
dirty = 1;
|
||
|
||
if (relayout) {
|
||
layout(&t);
|
||
wtimeout(t.input, 80);
|
||
dirty = 1;
|
||
}
|
||
if (busy) {
|
||
tui_lock(&t);
|
||
if (t.as_live) {
|
||
if (live_drip(&t))
|
||
dirty = 1;
|
||
else if (t.md_rendered != t.md_shown &&
|
||
ms_since(&t.md_render_at) >= LIVE_RENDER_MS) {
|
||
live_render(&t);
|
||
dirty = 1;
|
||
}
|
||
}
|
||
if (t.think_timing && t.think_idx >= 0 && t.think_idx < t.hist.n) {
|
||
char title[64];
|
||
snprintf(title, sizeof(title), "Thinking… %.1fs",
|
||
(double)ms_since(&t.think_t0) / 1000.0);
|
||
block_set_title(&t.hist.items[t.think_idx], title);
|
||
dirty = 1;
|
||
}
|
||
tui_unlock(&t);
|
||
}
|
||
if (dirty)
|
||
redraw_all(&t);
|
||
if (busy) {
|
||
t.spin++;
|
||
draw_status(&t);
|
||
if (t.motion_on && t.as_live)
|
||
draw_log(&t); /* animate stream frontier glitch */
|
||
if (t.motion_on && t.side)
|
||
draw_side(&t);
|
||
draw_input(&t); /* always last — park cursor in the composer */
|
||
}
|
||
|
||
if (turn_done) {
|
||
if (turn_rc != 0) {
|
||
push_system_line(&t, "Error: %s", turn_err[0] ? turn_err : "?");
|
||
set_status(&t, turn_err[0] ? turn_err : "turn failed");
|
||
} else {
|
||
status_idle(&t);
|
||
}
|
||
cst_agent_reload_notes(t.agent);
|
||
refresh_title(&t);
|
||
t.log_scroll = 0;
|
||
redraw_all(&t);
|
||
if (try_start_queued(&t) == -2)
|
||
break;
|
||
continue;
|
||
}
|
||
|
||
if (perm_ask) {
|
||
run_perm_modal(&t);
|
||
continue;
|
||
}
|
||
if (ask) {
|
||
run_ask_modal(&t);
|
||
continue;
|
||
}
|
||
|
||
wint_t wch = 0;
|
||
int rc = tui_wget_wch(t.input, &wch);
|
||
if (rc == ERR) {
|
||
if (!busy && t.motion_on && t.side && !t.home) {
|
||
t.spin++;
|
||
draw_side(&t);
|
||
draw_input(&t); /* restore cursor after side refresh */
|
||
}
|
||
continue;
|
||
}
|
||
if (t.sel_state == SEL_SHOWN &&
|
||
!(rc == KEY_CODE_YES && (wch == KEY_MOUSE || wch == KEY_RESIZE)))
|
||
sel_clear(&t);
|
||
#ifdef _WIN32
|
||
/* The console has no bracketed paste: an Enter with more keys already
|
||
* queued is a pasted line break, not a send. */
|
||
if (rc == OK && !t.in_paste && (wch == 10 || wch == 13) &&
|
||
win_key_pending()) {
|
||
paste_char(&t, wch);
|
||
input_changed(&t);
|
||
after_edit(&t);
|
||
draw_input(&t);
|
||
continue;
|
||
}
|
||
#endif
|
||
|
||
if (!t.in_paste &&
|
||
!(rc == KEY_CODE_YES &&
|
||
(wch == KEY_PASTE_BEGIN || wch == KEY_PASTE_END ||
|
||
wch == KEY_RESIZE || wch == KEY_MOUSE))) {
|
||
int code = tui_key_code(rc, wch);
|
||
wint_t nx = 0;
|
||
int nrc = ERR;
|
||
if (code == 27 && !t.leader_pending) {
|
||
/* Alt+key arrives as Esc followed by the key */
|
||
wtimeout(t.input, 30);
|
||
nrc = tui_wget_wch(t.input, &nx);
|
||
wtimeout(t.input, 80);
|
||
if (nrc != ERR) {
|
||
int c2 = tui_key_code(nrc, nx);
|
||
if (c2 == 10)
|
||
c2 = 13;
|
||
code = c2 == CST_K_UP ? CST_K_ALT_UP
|
||
: c2 == CST_K_DOWN ? CST_K_ALT_DOWN
|
||
: c2 > 0 ? (c2 | CST_KEY_ALT)
|
||
: 0;
|
||
}
|
||
}
|
||
int kd = key_dispatch(&t, code, busy);
|
||
if (kd == -2)
|
||
goto out;
|
||
if (kd == 1) {
|
||
draw_input(&t);
|
||
continue;
|
||
}
|
||
if (nrc == OK) {
|
||
if (nx == L'b')
|
||
t.cur = word_left(&t, t.cur), draw_input(&t);
|
||
else if (nx == L'f')
|
||
t.cur = word_right(&t, t.cur), draw_input(&t);
|
||
else if (nx == 127 || nx == 8) {
|
||
input_erase(&t, word_left(&t, t.cur), t.cur);
|
||
layout_maybe_grow_input(&t);
|
||
}
|
||
continue;
|
||
}
|
||
if (nrc == KEY_CODE_YES)
|
||
continue;
|
||
if (code == 27) {
|
||
if (t.comp_n > 0) {
|
||
t.comp_off = 1;
|
||
t.comp_n = 0;
|
||
draw_log(&t);
|
||
} else if (busy) {
|
||
request_cancel_ui(&t);
|
||
} else if (t.in_len) {
|
||
input_clear(&t);
|
||
layout_maybe_grow_input(&t);
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (rc == KEY_CODE_YES) {
|
||
if (wch == KEY_PASTE_BEGIN) {
|
||
t.in_paste = 1;
|
||
t.paste_cr = 0;
|
||
t.paste_at = t.cur;
|
||
t.paste_len0 = t.in_len;
|
||
continue;
|
||
}
|
||
if (wch == KEY_PASTE_END) {
|
||
t.in_paste = 0;
|
||
paste_image_paths(&t);
|
||
input_changed(&t);
|
||
after_edit(&t);
|
||
continue;
|
||
}
|
||
if (wch == KEY_RESIZE) {
|
||
t.sel_state = SEL_NONE; /* display lines rewrap */
|
||
layout(&t);
|
||
wtimeout(t.input, 80);
|
||
redraw_all(&t);
|
||
continue;
|
||
}
|
||
if (wch == KEY_MOUSE) {
|
||
MEVENT evs[MOUSE_RUN_MAX];
|
||
int ne = get_mouse_run(evs, MOUSE_RUN_MAX);
|
||
for (int i = 0; i < ne; i++)
|
||
if (on_mouse(&t, &evs[i], busy) == -2)
|
||
goto out;
|
||
if (ne)
|
||
draw_input(&t); /* keep the terminal cursor in the box */
|
||
continue;
|
||
}
|
||
int wdir;
|
||
if (word_key((int)wch, &wdir)) {
|
||
if (wdir < 0)
|
||
t.cur = word_left(&t, t.cur), draw_input(&t);
|
||
else if (wdir > 0)
|
||
t.cur = word_right(&t, t.cur), draw_input(&t);
|
||
else {
|
||
input_erase(&t, t.cur, word_right(&t, t.cur));
|
||
after_edit(&t);
|
||
}
|
||
continue;
|
||
}
|
||
switch (wch) {
|
||
case KEY_HOME:
|
||
if (t.in_len) {
|
||
t.cur = line_start(&t, t.cur);
|
||
draw_input(&t);
|
||
} else {
|
||
t.log_scroll = 1 << 28; /* clamped by draw_log */
|
||
draw_log(&t);
|
||
}
|
||
continue;
|
||
case KEY_END:
|
||
if (t.in_len) {
|
||
t.cur = line_end(&t, t.cur);
|
||
draw_input(&t);
|
||
} else {
|
||
t.log_scroll = 0;
|
||
draw_log(&t);
|
||
}
|
||
continue;
|
||
case KEY_LEFT:
|
||
t.cur -= utf8_prev_len(t.inbuf, t.cur);
|
||
update_completion(&t);
|
||
redraw_all(&t);
|
||
continue;
|
||
case KEY_RIGHT:
|
||
t.cur += next_len(&t, t.cur);
|
||
update_completion(&t);
|
||
redraw_all(&t);
|
||
continue;
|
||
case KEY_UP:
|
||
case KEY_DOWN: {
|
||
int dir = wch == KEY_UP ? -1 : 1;
|
||
if (t.comp_n > 0) {
|
||
t.comp_sel = (t.comp_sel + dir + t.comp_n) % t.comp_n;
|
||
draw_log(&t);
|
||
draw_input(&t);
|
||
continue;
|
||
}
|
||
if (input_line_move(&t, dir)) {
|
||
draw_input(&t);
|
||
continue;
|
||
}
|
||
if (dir < 0) {
|
||
if (t.prompt_hist_n <= 0)
|
||
continue;
|
||
int idx = t.hist_browse;
|
||
if (idx < 0) {
|
||
free(t.hist_draft);
|
||
t.hist_draft = strdup(t.inbuf);
|
||
}
|
||
idx = idx < 0 ? t.prompt_hist_n - 1 : (idx > 0 ? idx - 1 : 0);
|
||
hist_load_into_input(&t, idx);
|
||
} else {
|
||
if (t.hist_browse < 0)
|
||
continue;
|
||
if (t.hist_browse + 1 >= t.prompt_hist_n) {
|
||
input_set(&t, t.hist_draft);
|
||
free(t.hist_draft);
|
||
t.hist_draft = NULL;
|
||
} else {
|
||
hist_load_into_input(&t, t.hist_browse + 1);
|
||
}
|
||
}
|
||
after_edit(&t);
|
||
continue;
|
||
}
|
||
case KEY_BACKSPACE:
|
||
input_erase(&t, t.cur - utf8_prev_len(t.inbuf, t.cur), t.cur);
|
||
after_edit(&t);
|
||
continue;
|
||
case KEY_DC:
|
||
input_erase(&t, t.cur, t.cur + next_len(&t, t.cur));
|
||
after_edit(&t);
|
||
continue;
|
||
case KEY_BTAB:
|
||
if (t.comp_n > 0) {
|
||
t.comp_sel = (t.comp_sel + t.comp_n - 1) % t.comp_n;
|
||
draw_log(&t);
|
||
draw_input(&t);
|
||
}
|
||
continue;
|
||
case KEY_ENTER:
|
||
break; /* handled with CR below */
|
||
default:
|
||
continue;
|
||
}
|
||
wch = 13;
|
||
}
|
||
|
||
if (t.in_paste) {
|
||
paste_char(&t, wch);
|
||
continue;
|
||
}
|
||
|
||
/* Enter: accept a completion, otherwise send. */
|
||
if (wch == 10 || wch == 13) {
|
||
if (t.comp_n > 0) {
|
||
int kind = t.comp_kind;
|
||
const slash_cmd_t *c = accept_completion(&t);
|
||
after_edit(&t);
|
||
/* "/he⏎" runs /help; commands that need an argument wait for it */
|
||
if (kind != 1 || !c || c->args[0] == '<')
|
||
continue;
|
||
}
|
||
int st = submit_input(&t, busy);
|
||
if (st == -2)
|
||
break;
|
||
continue;
|
||
}
|
||
|
||
if (wch == 3) { /* Ctrl+C */
|
||
if (busy) {
|
||
request_cancel_ui(&t);
|
||
} else if (t.in_len) {
|
||
input_clear(&t);
|
||
after_edit(&t);
|
||
} else if (t.ctrlc_at.tv_sec && since_ms(&t.ctrlc_at) < 2000) {
|
||
break;
|
||
} else {
|
||
clock_gettime(CLOCK_MONOTONIC, &t.ctrlc_at);
|
||
set_status(&t, "press Ctrl+C again to quit");
|
||
draw_status(&t);
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 4) { /* Ctrl+D on an empty prompt quits */
|
||
if (!busy && t.in_len == 0)
|
||
break;
|
||
input_erase(&t, t.cur, t.cur + next_len(&t, t.cur));
|
||
layout_maybe_grow_input(&t);
|
||
continue;
|
||
}
|
||
|
||
switch (wch) {
|
||
case 1: /* Ctrl+A */
|
||
t.cur = line_start(&t, t.cur);
|
||
draw_input(&t);
|
||
continue;
|
||
case 5: /* Ctrl+E */
|
||
t.cur = line_end(&t, t.cur);
|
||
draw_input(&t);
|
||
continue;
|
||
case 2: /* Ctrl+B */
|
||
t.cur -= utf8_prev_len(t.inbuf, t.cur);
|
||
draw_input(&t);
|
||
continue;
|
||
case 6: /* Ctrl+F */
|
||
t.cur += next_len(&t, t.cur);
|
||
draw_input(&t);
|
||
continue;
|
||
case 11: /* Ctrl+K */
|
||
input_erase(&t, t.cur, line_end(&t, t.cur) == t.cur && t.cur < t.in_len
|
||
? t.cur + 1
|
||
: line_end(&t, t.cur));
|
||
after_edit(&t);
|
||
continue;
|
||
case 21: /* Ctrl+U */
|
||
input_erase(&t, line_start(&t, t.cur), t.cur);
|
||
after_edit(&t);
|
||
continue;
|
||
case 23: /* Ctrl+W */
|
||
input_erase(&t, word_left(&t, t.cur), t.cur);
|
||
after_edit(&t);
|
||
continue;
|
||
case 26: /* Ctrl+Z is not job control in raw mode */
|
||
continue;
|
||
case L'\t':
|
||
if (t.comp_n > 0) {
|
||
accept_completion(&t);
|
||
after_edit(&t);
|
||
} else if (wants_completion(&t)) {
|
||
t.comp_off = 0;
|
||
update_completion(&t);
|
||
draw_log(&t);
|
||
draw_input(&t);
|
||
}
|
||
continue;
|
||
case 127:
|
||
case 8:
|
||
input_erase(&t, t.cur - utf8_prev_len(t.inbuf, t.cur), t.cur);
|
||
after_edit(&t);
|
||
continue;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
if (iswprint(wch) || wcwidth((wchar_t)wch) > 0) {
|
||
input_put_wc(&t, (wchar_t)wch);
|
||
after_edit(&t);
|
||
}
|
||
}
|
||
out:
|
||
if (t.busy) {
|
||
cst_agent_request_cancel(t.agent);
|
||
for (int i = 0; i < 50 && t.busy; i++)
|
||
usleep(100000);
|
||
}
|
||
for (int i = 0; i < 20 && g_am_threads > 0; i++)
|
||
usleep(100000); /* let a /models probe finish before curl cleanup */
|
||
|
||
tui_free(&t);
|
||
#ifdef _WIN32
|
||
if (g_con_in_saved)
|
||
win_console_mouse(0);
|
||
win_console_font(0);
|
||
#elif defined(NCURSES_VERSION)
|
||
kitty_restore();
|
||
printf("\033[?1002l\033[?2004l");
|
||
fflush(stdout);
|
||
#endif
|
||
endwin();
|
||
return 0;
|
||
}
|