forked from WHC/Hermit
Query CSI ? u at startup and push the disambiguate flag on the alternate screen when the terminal answers; CSI u reports are mapped back to legacy input (Shift/Ctrl+Enter insert a newline, Ctrl+Backspace deletes a word). Ctrl/Alt+Left/Right move by word, Ctrl/Alt+Delete delete the next word. Our define_key codes move above the range ncurses uses for terminfo extended keys.
6504 lines
189 KiB
C
6504 lines
189 KiB
C
#ifndef _GNU_SOURCE
|
||
#define _GNU_SOURCE
|
||
#endif
|
||
|
||
#include "cJSON.h"
|
||
#include "cst_agent.h"
|
||
#include "cst_keymap.h"
|
||
#include "cst_ext.h"
|
||
#include "cst_mcp.h"
|
||
#include "cst_plugins.h"
|
||
#include "cst_skills.h"
|
||
#include "cst_subagent.h"
|
||
#include "cst_perms.h"
|
||
#include "cst_sandbox.h"
|
||
#include "cst_tools.h"
|
||
#include "cst_util.h"
|
||
#include "cst_version.h"
|
||
#include "os_compat.h"
|
||
#include "sashidown.h"
|
||
|
||
#include <locale.h>
|
||
#include <pthread.h>
|
||
#include <stdarg.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <time.h>
|
||
#include <wchar.h>
|
||
#include <ctype.h>
|
||
#include <wctype.h>
|
||
|
||
#ifdef _WIN32
|
||
#include <io.h>
|
||
#include <windows.h>
|
||
#else
|
||
#include <langinfo.h>
|
||
#include <strings.h>
|
||
#include <unistd.h>
|
||
#endif
|
||
|
||
static int g_nocolor; /* NO_COLOR */
|
||
static int g_ascii; /* non-UTF-8 locale or CST_ASCII=1 */
|
||
static int g_utf8 = 1; /* locale can show non-ASCII text */
|
||
#define HAS_COLOR() (!g_nocolor && has_colors())
|
||
|
||
/* Curses: ncursesw on Unix, PDCurses (wide) on Windows. */
|
||
#if defined(CST_USE_PDCURSES) || defined(_WIN32)
|
||
#ifndef PDC_WIDE
|
||
#define PDC_WIDE 1
|
||
#endif
|
||
#ifndef NCURSES_MOUSE_VERSION
|
||
#define NCURSES_MOUSE_VERSION 2
|
||
#endif
|
||
#include <curses.h>
|
||
#ifndef BUTTON4_PRESSED
|
||
#define BUTTON4_PRESSED 0
|
||
#endif
|
||
#ifndef BUTTON5_PRESSED
|
||
#define BUTTON5_PRESSED 0
|
||
#endif
|
||
#ifndef KEY_RESIZE
|
||
#define KEY_RESIZE 0632
|
||
#endif
|
||
/* MinGW often lacks wcwidth; simple East-Asian / ASCII heuristic. */
|
||
static int cst_wcwidth(wchar_t wc) {
|
||
if (wc == 0)
|
||
return 0;
|
||
if (wc < 32 || (wc >= 0x7f && wc < 0xa0))
|
||
return -1;
|
||
if (wc >= 0x1100 &&
|
||
(wc <= 0x115f || wc == 0x2329 || wc == 0x232a ||
|
||
(wc >= 0x2e80 && wc <= 0xa4cf && wc != 0x303f) ||
|
||
(wc >= 0xac00 && wc <= 0xd7a3) || (wc >= 0xf900 && wc <= 0xfaff) ||
|
||
(wc >= 0xfe10 && wc <= 0xfe19) || (wc >= 0xfe30 && wc <= 0xfe6f) ||
|
||
(wc >= 0xff00 && wc <= 0xff60) || (wc >= 0xffe0 && wc <= 0xffe6) ||
|
||
(wc >= 0x20000 && wc <= 0x2fffd) || (wc >= 0x30000 && wc <= 0x3fffd)))
|
||
return 2;
|
||
return 1;
|
||
}
|
||
#define wcwidth cst_wcwidth
|
||
#else
|
||
#include <ncurses.h>
|
||
#endif
|
||
|
||
#ifdef _WIN32
|
||
static void cst_usleep(unsigned usec) {
|
||
Sleep((usec + 999) / 1000);
|
||
}
|
||
static int cst_clock_gettime_mono(struct timespec *ts) {
|
||
static LARGE_INTEGER freq;
|
||
static int init;
|
||
LARGE_INTEGER c;
|
||
if (!init) {
|
||
QueryPerformanceFrequency(&freq);
|
||
init = 1;
|
||
}
|
||
QueryPerformanceCounter(&c);
|
||
ts->tv_sec = (time_t)(c.QuadPart / freq.QuadPart);
|
||
ts->tv_nsec =
|
||
(long)(((c.QuadPart % freq.QuadPart) * 1000000000ULL) / freq.QuadPart);
|
||
return 0;
|
||
}
|
||
#define usleep(us) cst_usleep((unsigned)(us))
|
||
#define clock_gettime(clk, ts) cst_clock_gettime_mono(ts)
|
||
#ifndef CLOCK_MONOTONIC
|
||
#define CLOCK_MONOTONIC 1
|
||
#endif
|
||
#endif
|
||
|
||
/*
|
||
* OpenCode-inspired C ncurses/PDCurses TUI:
|
||
* structured transcript blocks, thinking/tool cards, permissions, cancel.
|
||
*/
|
||
|
||
#define INPUT_CAP 32768
|
||
#define SCROLL_SPEED 3
|
||
#define REPLAY_MSGS 30
|
||
#define MD_INIT (64 * 1024)
|
||
#define ANSI_INIT (64 * 1024)
|
||
#define TOOL_PREVIEW_LINES 3
|
||
#define DIFF_FOLD_LINES 40
|
||
#define BLOCKS_INIT 64
|
||
#define MAX_RENDER_LINES 65536
|
||
#define PROMPT_HIST_CAP 50
|
||
#define QUEUE_CAP 3
|
||
#define INPUT_MAX_ROWS 8
|
||
#define COMP_MAX 8
|
||
#define TODO_PANEL_MAX 6
|
||
#define ASK_OPTS_MAX 9
|
||
/* Our define_key() codes. ncurses numbers the terminfo extended keys (kLFT5,
|
||
* kDC3, ...) from KEY_MAX up, and key codes must fit in 16 bits, so stay well
|
||
* clear of the first and below the second. */
|
||
#define KEY_OWN_BASE (KEY_MAX + 0x1000)
|
||
/* Bracketed paste markers. */
|
||
#define KEY_PASTE_BEGIN (KEY_OWN_BASE + 1)
|
||
#define KEY_PASTE_END (KEY_OWN_BASE + 2)
|
||
/* Kitty keyboard protocol: the reply to CSI ? u, and CSI cp;1+mods u reports
|
||
* bound as KEY_KITTY_BASE + cp * 8 + mods for ASCII code points. */
|
||
#define KEY_KITTY_REPLY (KEY_OWN_BASE + 3)
|
||
#define KEY_KITTY_BASE (KEY_OWN_BASE + 16)
|
||
#define KITTY_CP_END 128
|
||
|
||
#ifdef NCURSES_VERSION
|
||
static int g_kitty_on;
|
||
|
||
/* Bind the reports and ask whether the terminal speaks the protocol; the
|
||
* flags are pushed once the reply arrives (see tui_wget_wch). */
|
||
static void kitty_init(void) {
|
||
char seq[24];
|
||
for (int f = 0; f < 32; f++) {
|
||
snprintf(seq, sizeof(seq), "\033[?%du", f);
|
||
define_key(seq, KEY_KITTY_REPLY);
|
||
}
|
||
for (int cp = 9; cp < KITTY_CP_END; cp++) {
|
||
if (cp != 9 && cp != 13 && cp != 27 && cp < ' ')
|
||
continue;
|
||
if (cp == 27)
|
||
define_key("\033[27u", KEY_KITTY_BASE + cp * 8);
|
||
for (int m = 1; m < 8; m++) {
|
||
snprintf(seq, sizeof(seq), "\033[%d;%du", cp, m + 1);
|
||
define_key(seq, KEY_KITTY_BASE + cp * 8 + m);
|
||
}
|
||
}
|
||
printf("\033[?u");
|
||
fflush(stdout);
|
||
}
|
||
|
||
/* Pop our flags before leaving; harmless if they were never pushed. */
|
||
static void kitty_restore(void) {
|
||
if (g_kitty_on) {
|
||
printf("\033[<u");
|
||
fflush(stdout);
|
||
g_kitty_on = 0;
|
||
}
|
||
}
|
||
#endif
|
||
|
||
/* wget_wch() that turns kitty key reports back into legacy input. */
|
||
static int tui_wget_wch(WINDOW *w, wint_t *wch) {
|
||
for (;;) {
|
||
int rc = wget_wch(w, wch);
|
||
#ifdef NCURSES_VERSION
|
||
if (rc != KEY_CODE_YES)
|
||
return rc;
|
||
if (*wch == KEY_KITTY_REPLY) {
|
||
if (!g_kitty_on) {
|
||
/* disambiguate: Esc, alt+x, ctrl+x, shift+enter... as CSI u */
|
||
printf("\033[>1u");
|
||
fflush(stdout);
|
||
g_kitty_on = 1;
|
||
}
|
||
continue;
|
||
}
|
||
int k = (int)*wch - KEY_KITTY_BASE;
|
||
if (k < 0 || k >= KITTY_CP_END * 8)
|
||
return rc;
|
||
int out[2];
|
||
int n = cst_key_from_kitty(k / 8, k % 8, out);
|
||
if (n == 0)
|
||
continue;
|
||
if (n == 2)
|
||
unget_wch((wchar_t)out[1]);
|
||
if (out[0] == CST_K_BTAB) {
|
||
*wch = KEY_BTAB;
|
||
return KEY_CODE_YES;
|
||
}
|
||
*wch = (wint_t)out[0];
|
||
return OK;
|
||
#else
|
||
return rc;
|
||
#endif
|
||
}
|
||
}
|
||
|
||
/* Color pair indices */
|
||
enum {
|
||
CP_HEADER = 1,
|
||
CP_STATUS,
|
||
CP_INPUT,
|
||
CP_PRIMARY, /* user / accents */
|
||
CP_ASSIST, /* cyan assistant */
|
||
CP_MUTED,
|
||
CP_SUCCESS,
|
||
CP_ERROR,
|
||
CP_TOOL,
|
||
CP_PANEL, /* transcript bg */
|
||
CP_SGR_GREEN,
|
||
CP_SGR_YELLOW,
|
||
CP_SGR_CYAN,
|
||
CP_SGR_MAGENTA,
|
||
/* OpenCode-style structured diff (bg distinguishes add/del/context) */
|
||
CP_DIFF_ADD,
|
||
CP_DIFF_DEL,
|
||
CP_DIFF_CTX,
|
||
/* raised surfaces: input box / user message, sidebar */
|
||
CP_BOX,
|
||
CP_BOX_MUTED,
|
||
CP_BOX_ACCENT,
|
||
CP_BOX_PRIMARY,
|
||
CP_BOX_BRIGHT,
|
||
CP_SIDE,
|
||
CP_SIDE_MUTED,
|
||
CP_SIDE_BRIGHT,
|
||
CP_SIDE_SUCCESS,
|
||
CP_SIDE_PRIMARY,
|
||
CP_ACCENT, /* blue on panel */
|
||
CP_BRIGHT, /* near-white on panel */
|
||
CP_LOGO_DIM,
|
||
CP_SEL /* selected list row */
|
||
};
|
||
|
||
typedef enum {
|
||
BLK_USER = 0,
|
||
BLK_ASSISTANT,
|
||
BLK_THINK,
|
||
BLK_TOOL,
|
||
BLK_STATUS,
|
||
BLK_SYSTEM,
|
||
BLK_FOOTER /* "▣ Build · model · 4.7s" after a turn */
|
||
} block_kind_t;
|
||
|
||
typedef struct {
|
||
block_kind_t kind;
|
||
char *title;
|
||
char *body; /* raw text / markdown / tool output */
|
||
char *render; /* ANSI for assistant; NULL → draw body */
|
||
int expanded;
|
||
char meta[256]; /* phase / timing / detail */
|
||
char tool_name[CTL_MAX_NAME];
|
||
char tool_id[CTL_MAX_ID];
|
||
} block_t;
|
||
|
||
typedef struct {
|
||
block_t *items;
|
||
int n;
|
||
int cap;
|
||
} blocks_t;
|
||
|
||
typedef struct {
|
||
char text[256];
|
||
char desc[112];
|
||
} comp_item_t;
|
||
|
||
typedef struct {
|
||
cst_agent_t *agent;
|
||
WINDOW *header;
|
||
WINDOW *logw;
|
||
WINDOW *todow;
|
||
WINDOW *status;
|
||
WINDOW *input;
|
||
WINDOW *side;
|
||
int rows, cols;
|
||
int input_rows; /* 1..INPUT_MAX_ROWS */
|
||
int todo_rows;
|
||
int home; /* empty transcript: centered start screen */
|
||
int show_side; /* /sidebar */
|
||
int side_w;
|
||
int in_x, in_y, in_w, in_h; /* input box, screen coords */
|
||
int log_x, log_y; /* logw origin, screen coords */
|
||
int *row_blk; /* logw row → hist block (-1 none) */
|
||
int row_blk_cap;
|
||
int cmd_x0, cmd_x1; /* "ctrl+p commands" in the status row */
|
||
int mouse_on;
|
||
const char *tip;
|
||
const char *sandbox_name; /* "landlock" / "seatbelt" / "rules" */
|
||
char where[300]; /* ~/path:branch */
|
||
|
||
blocks_t hist; /* sealed blocks */
|
||
block_t live; /* streaming assistant (kind set when as_live) */
|
||
int as_live;
|
||
char *md_live;
|
||
size_t md_len;
|
||
size_t md_cap;
|
||
size_t md_rendered; /* md_len at the last live_render */
|
||
char *md_prefix_ansi; /* rendered md_live[0, md_prefix) for long replies */
|
||
size_t md_prefix;
|
||
struct timespec md_render_at;
|
||
int show_thinking; /* /thinking: Thought blocks start expanded */
|
||
cst_keymap_t km;
|
||
int leader_pending;
|
||
struct timespec leader_at;
|
||
|
||
int log_scroll;
|
||
char inbuf[INPUT_CAP];
|
||
size_t in_len;
|
||
size_t cur; /* cursor byte offset into inbuf */
|
||
int in_paste;
|
||
int paste_cr; /* last pasted byte was CR (fold CRLF) */
|
||
char status_msg[512];
|
||
char title[128]; /* session title or first prompt, for the header */
|
||
struct timespec ctrlc_at;
|
||
struct timespec turn_t0;
|
||
int spin;
|
||
|
||
/* "/" and "@" completion popup */
|
||
comp_item_t comp[COMP_MAX];
|
||
int comp_n, comp_sel, comp_kind; /* kind 1 = slash, 2 = file */
|
||
size_t comp_start;
|
||
int comp_off; /* dismissed with Esc until the input changes */
|
||
int comp_drawn; /* popup rows currently on screen */
|
||
char **files;
|
||
int nfiles;
|
||
struct timespec files_at;
|
||
|
||
char *todo;
|
||
int show_todo;
|
||
volatile int need_layout;
|
||
char *pending_prompt; /* set by /retry and /init */
|
||
|
||
int busy;
|
||
int details_all; /* /details: expand all tool bodies */
|
||
int awaiting_perm;
|
||
int think_idx; /* hist index of open think, or -1 */
|
||
int last_tool_idx;
|
||
int focus_think_idx;
|
||
struct timespec think_t0;
|
||
int think_timing; /* think_t0 valid */
|
||
|
||
/* Prompt history (newest at end). hist_browse=-1 means live buffer. */
|
||
char *prompt_hist[PROMPT_HIST_CAP];
|
||
int prompt_hist_n;
|
||
int hist_browse;
|
||
|
||
/* Busy-turn FIFO */
|
||
char *queue[QUEUE_CAP];
|
||
int queue_n;
|
||
|
||
/* Permission handshake (worker → main). */
|
||
volatile int perm_ask;
|
||
volatile int perm_ans; /* -1 waiting, else CST_PERM_* */
|
||
char perm_tool[CTL_MAX_NAME];
|
||
char perm_detail[512];
|
||
char *perm_preview;
|
||
char perm_prefix[128];
|
||
int perm_sandbox; /* asking to rerun a blocked command outside the sandbox */
|
||
int perm_scroll;
|
||
|
||
/* ask_user handshake (worker → main). */
|
||
volatile int ask_pending;
|
||
volatile int ask_done;
|
||
int ask_rc;
|
||
int ask_sel;
|
||
int ask_nopts;
|
||
char ask_q[1024];
|
||
char ask_opts[ASK_OPTS_MAX][256];
|
||
char ask_buf[1024];
|
||
|
||
pthread_mutex_t mu;
|
||
volatile int dirty;
|
||
volatile int turn_done;
|
||
int turn_rc;
|
||
char turn_err[512];
|
||
} tui_t;
|
||
|
||
/* --- UTF-8 helpers -------------------------------------------------------- */
|
||
|
||
static size_t utf8_prev_len(const char *s, size_t len) {
|
||
if (len == 0)
|
||
return 0;
|
||
size_t i = len - 1;
|
||
while (i > 0 && ((unsigned char)s[i] & 0xC0) == 0x80)
|
||
i--;
|
||
return len - i;
|
||
}
|
||
|
||
static int utf8_decode(const char *s, size_t avail, wchar_t *wc, size_t *eaten) {
|
||
if (avail == 0)
|
||
return -1;
|
||
mbstate_t st;
|
||
memset(&st, 0, sizeof(st));
|
||
size_t n = mbrtowc(wc, s, avail, &st);
|
||
if (n == (size_t)-1 || n == (size_t)-2 || n == 0) {
|
||
*wc = (unsigned char)s[0];
|
||
*eaten = 1;
|
||
return 0;
|
||
}
|
||
*eaten = n;
|
||
return 0;
|
||
}
|
||
|
||
static int utf8_width(wchar_t wc) {
|
||
int w = wcwidth(wc);
|
||
if (w < 0)
|
||
return 1;
|
||
return w;
|
||
}
|
||
|
||
static int utf8_cols(const char *s, size_t n) {
|
||
int cols = 0;
|
||
size_t i = 0;
|
||
while (i < n) {
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(s + i, n - i, &wc, &eaten);
|
||
cols += utf8_width(wc);
|
||
i += eaten;
|
||
}
|
||
return cols;
|
||
}
|
||
|
||
static size_t utf8_fit_cols(const char *s, size_t n, int max_cols) {
|
||
int cols = 0;
|
||
size_t i = 0;
|
||
while (i < n) {
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(s + i, n - i, &wc, &eaten);
|
||
int w = utf8_width(wc);
|
||
if (cols + w > max_cols)
|
||
break;
|
||
cols += w;
|
||
i += eaten;
|
||
}
|
||
return i;
|
||
}
|
||
|
||
static size_t ansi_skip(const char *s, size_t n) {
|
||
if (n < 2 || (unsigned char)s[0] != 0x1b)
|
||
return 0;
|
||
if (s[1] == '[') {
|
||
size_t i = 2;
|
||
while (i < n) {
|
||
unsigned char c = (unsigned char)s[i++];
|
||
if (c >= 0x40 && c <= 0x7e)
|
||
return i;
|
||
}
|
||
return 0;
|
||
}
|
||
return 2;
|
||
}
|
||
|
||
static char *xstrdup(const char *s) {
|
||
if (!s)
|
||
s = "";
|
||
size_t n = strlen(s);
|
||
char *p = malloc(n + 1);
|
||
if (!p)
|
||
return NULL;
|
||
memcpy(p, s, n + 1);
|
||
return p;
|
||
}
|
||
|
||
static char *xstrndup(const char *s, size_t n) {
|
||
char *p = malloc(n + 1);
|
||
if (!p)
|
||
return NULL;
|
||
memcpy(p, s, n);
|
||
p[n] = 0;
|
||
return p;
|
||
}
|
||
|
||
/* --- theme ---------------------------------------------------------------- */
|
||
|
||
/* OpenCode dark panel — never inherit terminal default bg. */
|
||
static void theme_init(void) {
|
||
const char *nc = getenv("NO_COLOR");
|
||
g_nocolor = nc && nc[0];
|
||
const char *asc = getenv("CST_ASCII");
|
||
#if defined(__ANDROID__)
|
||
g_utf8 = 1; /* bionic is UTF-8 only; nl_langinfo needs API 26 */
|
||
#elif !defined(_WIN32)
|
||
const char *cs = nl_langinfo(CODESET);
|
||
g_utf8 = cs && (strcasecmp(cs, "UTF-8") == 0 || strcasecmp(cs, "utf8") == 0);
|
||
#endif
|
||
g_ascii = asc && asc[0] ? asc[0] != '0' : !g_utf8;
|
||
if (!HAS_COLOR())
|
||
return;
|
||
start_color();
|
||
/* Do NOT call use_default_colors(): -1 would leak the terminal theme. */
|
||
|
||
short primary = COLOR_YELLOW;
|
||
short panel = COLOR_BLACK;
|
||
short muted = COLOR_CYAN;
|
||
short success = COLOR_GREEN;
|
||
short err = COLOR_RED;
|
||
short assist = COLOR_CYAN;
|
||
short text = COLOR_WHITE;
|
||
short box = COLOR_BLACK, side = COLOR_BLACK;
|
||
short accent = COLOR_BLUE, bright = COLOR_WHITE, logo_dim = COLOR_CYAN;
|
||
|
||
if (COLORS >= 256) {
|
||
box = 235;
|
||
side = 234;
|
||
accent = 75;
|
||
bright = 255;
|
||
logo_dim = 242;
|
||
/* Prefer xterm-256 slots so we don't bleach the 8 ANSI base colors.
|
||
* panel #121212→233, primary #fab283→216, muted #808080→244 */
|
||
primary = 216;
|
||
panel = 233; /* #121212 — darker than 234 to avoid washed look */
|
||
muted = 244;
|
||
success = 114;
|
||
err = 167;
|
||
assist = 74; /* soft cyan, not neon */
|
||
text = 250; /* #bcbcbc — not near-white */
|
||
if (can_change_color()) {
|
||
/* Tune those slots toward exact OpenCode hex */
|
||
init_color(216, 980, 698, 514); /* #fab283 */
|
||
init_color(233, 70, 70, 70); /* #121212 */
|
||
init_color(244, 502, 502, 502); /* #808080 */
|
||
init_color(250, 720, 720, 720); /* body text */
|
||
init_color(114, 400, 750, 450);
|
||
init_color(167, 850, 320, 320);
|
||
init_color(74, 350, 700, 750);
|
||
init_color(235, 118, 118, 118); /* #1e1e1e raised box */
|
||
init_color(234, 90, 90, 90); /* #171717 sidebar */
|
||
init_color(75, 361, 612, 961); /* #5c9cf5 accent */
|
||
}
|
||
} else if (can_change_color() && COLORS >= 8) {
|
||
init_color(COLOR_BLACK, 70, 70, 70); /* panel #121212 */
|
||
init_color(COLOR_YELLOW, 980, 698, 514); /* primary */
|
||
init_color(COLOR_CYAN, 502, 502, 502); /* muted (reuse cyan slot) */
|
||
init_color(COLOR_WHITE, 720, 720, 720); /* body text */
|
||
init_color(COLOR_GREEN, 400, 750, 450);
|
||
init_color(COLOR_RED, 850, 320, 320);
|
||
init_color(COLOR_BLUE, 350, 700, 750); /* assist */
|
||
panel = COLOR_BLACK;
|
||
primary = COLOR_YELLOW;
|
||
muted = COLOR_CYAN;
|
||
text = COLOR_WHITE;
|
||
success = COLOR_GREEN;
|
||
err = COLOR_RED;
|
||
assist = COLOR_BLUE;
|
||
}
|
||
|
||
/* Header is dark too — primary only as text accent (not a bright bar). */
|
||
init_pair(CP_HEADER, primary, panel);
|
||
init_pair(CP_STATUS, muted, panel);
|
||
init_pair(CP_INPUT, text, panel);
|
||
init_pair(CP_PRIMARY, primary, panel);
|
||
init_pair(CP_ASSIST, assist, panel);
|
||
init_pair(CP_MUTED, muted, panel);
|
||
init_pair(CP_SUCCESS, success, panel);
|
||
init_pair(CP_ERROR, err, panel);
|
||
init_pair(CP_TOOL, primary, panel);
|
||
init_pair(CP_PANEL, text, panel);
|
||
init_pair(CP_SGR_GREEN, success, panel);
|
||
init_pair(CP_SGR_YELLOW, primary, panel);
|
||
init_pair(CP_SGR_CYAN, assist, panel);
|
||
if (COLORS >= 256)
|
||
init_pair(CP_SGR_MAGENTA, 176, panel);
|
||
else
|
||
init_pair(CP_SGR_MAGENTA, COLOR_MAGENTA, panel);
|
||
|
||
/* Diff rows: high-contrast xterm-256 palette only.
|
||
* Do NOT init_color() cube indices 16–21 — many terminals ignore it and leave
|
||
* default blues, which made add/del rows unreadable (dark-on-blue). */
|
||
if (COLORS >= 256) {
|
||
init_pair(CP_DIFF_ADD, 157, 22); /* #afff87 on #005f00 */
|
||
init_pair(CP_DIFF_DEL, 203, 52); /* #ff5f5f on #5f0000 */
|
||
init_pair(CP_DIFF_CTX, 252, panel); /* bright gray on panel */
|
||
} else {
|
||
init_pair(CP_DIFF_ADD, COLOR_GREEN, COLOR_BLACK);
|
||
init_pair(CP_DIFF_DEL, COLOR_RED, COLOR_BLACK);
|
||
init_pair(CP_DIFF_CTX, COLOR_WHITE, COLOR_BLACK);
|
||
}
|
||
if (COLORS < 256) {
|
||
box = side = panel;
|
||
accent = COLORS >= 8 && !can_change_color() ? COLOR_BLUE : assist;
|
||
}
|
||
init_pair(CP_BOX, text, box);
|
||
init_pair(CP_BOX_MUTED, muted, box);
|
||
init_pair(CP_BOX_ACCENT, accent, box);
|
||
init_pair(CP_BOX_PRIMARY, primary, box);
|
||
init_pair(CP_BOX_BRIGHT, bright, box);
|
||
init_pair(CP_SIDE, text, side);
|
||
init_pair(CP_SIDE_MUTED, muted, side);
|
||
init_pair(CP_SIDE_BRIGHT, bright, side);
|
||
init_pair(CP_SIDE_SUCCESS, success, side);
|
||
init_pair(CP_SIDE_PRIMARY, primary, side);
|
||
init_pair(CP_ACCENT, accent, panel);
|
||
init_pair(CP_BRIGHT, bright, panel);
|
||
init_pair(CP_LOGO_DIM, logo_dim, panel);
|
||
init_pair(CP_SEL, panel, accent);
|
||
}
|
||
|
||
/* Blank the window into the virtual screen before deleting it: refreshing
|
||
* stdscr doesn't reliably cover the cells a deleted window leaves behind. */
|
||
static void drop_win(WINDOW **w) {
|
||
if (*w) {
|
||
werase(*w);
|
||
wnoutrefresh(*w);
|
||
delwin(*w);
|
||
}
|
||
*w = NULL;
|
||
}
|
||
|
||
static void theme_paint_root(void) {
|
||
if (!HAS_COLOR())
|
||
return;
|
||
wbkgd(stdscr, COLOR_PAIR(CP_PANEL));
|
||
werase(stdscr);
|
||
wnoutrefresh(stdscr);
|
||
}
|
||
|
||
/* --- blocks --------------------------------------------------------------- */
|
||
|
||
static void block_clear(block_t *b) {
|
||
if (!b)
|
||
return;
|
||
free(b->title);
|
||
free(b->body);
|
||
free(b->render);
|
||
memset(b, 0, sizeof(*b));
|
||
}
|
||
|
||
static void blocks_init(blocks_t *bs) {
|
||
bs->items = calloc((size_t)BLOCKS_INIT, sizeof(block_t));
|
||
bs->n = 0;
|
||
bs->cap = bs->items ? BLOCKS_INIT : 0;
|
||
}
|
||
|
||
static void blocks_free(blocks_t *bs) {
|
||
if (!bs)
|
||
return;
|
||
for (int i = 0; i < bs->n; i++)
|
||
block_clear(&bs->items[i]);
|
||
free(bs->items);
|
||
bs->items = NULL;
|
||
bs->n = bs->cap = 0;
|
||
}
|
||
|
||
static void blocks_clear(blocks_t *bs) {
|
||
for (int i = 0; i < bs->n; i++)
|
||
block_clear(&bs->items[i]);
|
||
bs->n = 0;
|
||
}
|
||
|
||
static block_t *blocks_push(blocks_t *bs) {
|
||
if (!bs->items)
|
||
return NULL;
|
||
if (bs->n >= bs->cap) {
|
||
int nc = bs->cap * 2;
|
||
block_t *ni = realloc(bs->items, (size_t)nc * sizeof(block_t));
|
||
if (!ni)
|
||
return NULL;
|
||
memset(ni + bs->cap, 0, (size_t)(nc - bs->cap) * sizeof(block_t));
|
||
bs->items = ni;
|
||
bs->cap = nc;
|
||
}
|
||
block_t *b = &bs->items[bs->n++];
|
||
memset(b, 0, sizeof(*b));
|
||
return b;
|
||
}
|
||
|
||
static void block_set_title(block_t *b, const char *t) {
|
||
free(b->title);
|
||
b->title = xstrdup(t ? t : "");
|
||
}
|
||
|
||
static void block_set_body(block_t *b, const char *s, size_t n) {
|
||
free(b->body);
|
||
b->body = n ? xstrndup(s, n) : xstrdup("");
|
||
}
|
||
|
||
static void block_append_body(block_t *b, const char *s, size_t n) {
|
||
if (!s || !n)
|
||
return;
|
||
size_t old = b->body ? strlen(b->body) : 0;
|
||
char *nb = realloc(b->body, old + n + 1);
|
||
if (!nb)
|
||
return;
|
||
memcpy(nb + old, s, n);
|
||
nb[old + n] = 0;
|
||
b->body = nb;
|
||
}
|
||
|
||
/* Markdown → ANSI via Sashidown. */
|
||
static int md_to_ansi_dyn(const char *md, size_t md_len, char **outp,
|
||
size_t *out_len) {
|
||
if (!md || !outp)
|
||
return -1;
|
||
if (md_len == 0) {
|
||
*outp = NULL;
|
||
if (out_len)
|
||
*out_len = 0;
|
||
return 0;
|
||
}
|
||
size_t cap = ANSI_INIT;
|
||
char *out = malloc(cap);
|
||
if (!out)
|
||
return -1;
|
||
for (;;) {
|
||
size_t n = 0;
|
||
int rc = sashidown_convert(md, md_len, out, cap, &n, SASHIDOWN_FMT_ANSI);
|
||
if (rc == 0) {
|
||
*outp = out;
|
||
if (out_len)
|
||
*out_len = n;
|
||
return 0;
|
||
}
|
||
size_t nc = cap * 2;
|
||
if (nc < md_len * 4)
|
||
nc = md_len * 4;
|
||
if (nc > 16 * 1024 * 1024) {
|
||
free(out);
|
||
out = malloc(md_len + 1);
|
||
if (!out)
|
||
return -1;
|
||
memcpy(out, md, md_len);
|
||
out[md_len] = 0;
|
||
*outp = out;
|
||
if (out_len)
|
||
*out_len = md_len;
|
||
return 0;
|
||
}
|
||
char *nb = realloc(out, nc);
|
||
if (!nb) {
|
||
free(out);
|
||
return -1;
|
||
}
|
||
out = nb;
|
||
cap = nc;
|
||
}
|
||
}
|
||
|
||
static void block_render_assistant(block_t *b) {
|
||
free(b->render);
|
||
b->render = NULL;
|
||
if (!b->body || !b->body[0])
|
||
return;
|
||
char *ansi = NULL;
|
||
size_t n = 0;
|
||
if (md_to_ansi_dyn(b->body, strlen(b->body), &ansi, &n) == 0 && ansi) {
|
||
b->render = ansi;
|
||
}
|
||
}
|
||
|
||
static const char *tool_prefix(const char *name) {
|
||
if (!name)
|
||
return "•";
|
||
if (strcmp(name, "run_shell") == 0)
|
||
return "$";
|
||
if (cst_tool_is_write(name))
|
||
return "←";
|
||
if (strcmp(name, "read_file") == 0)
|
||
return "✱";
|
||
if (strcmp(name, "web_fetch") == 0)
|
||
return "%";
|
||
return "•";
|
||
}
|
||
|
||
static void tool_arg_summary(const ctl_tool_call_t *call, char *out,
|
||
size_t out_sz) {
|
||
static const struct {
|
||
const char *tool, *key;
|
||
} PREFER[] = {{"run_shell", "cmd"}, {"read_file", "path"},
|
||
{"write_file", "path"}, {"edit_file", "path"},
|
||
{"delete_file", "path"}, {"move_file", "from"},
|
||
{"list_dir", "path"}, {"glob", "pattern"},
|
||
{"grep", "pattern"}, {"web_fetch", "url"},
|
||
{"ask_user", "question"}, {"apply_patch", "patch"},
|
||
{"task", "description"}, {"skill", "name"}};
|
||
out[0] = 0;
|
||
if (!call || out_sz == 0)
|
||
return;
|
||
if (strcmp(call->name, "todo_write") == 0) {
|
||
snprintf(out, out_sz, "update task list");
|
||
return;
|
||
}
|
||
const char *prefer = NULL;
|
||
for (size_t i = 0; i < sizeof(PREFER) / sizeof(PREFER[0]); i++)
|
||
if (strcmp(call->name, PREFER[i].tool) == 0)
|
||
prefer = PREFER[i].key;
|
||
int pick = -1;
|
||
for (int i = 0; i < call->nargs; i++)
|
||
if (prefer && strcmp(call->args[i].key, prefer) == 0)
|
||
pick = i;
|
||
if (pick < 0 && call->nargs > 0)
|
||
pick = 0;
|
||
if (pick < 0 || !call->args[pick].value)
|
||
return;
|
||
const char *v = call->args[pick].value;
|
||
size_t len = call->args[pick].value_len ? call->args[pick].value_len
|
||
: strlen(v);
|
||
size_t nl = strcspn(v, "\n");
|
||
int cut = len > 80 || nl < len;
|
||
if (nl < len)
|
||
len = nl;
|
||
if (len > 80)
|
||
len = cst_utf8_floor(v, 80);
|
||
snprintf(out, out_sz, "%.*s%s", (int)len, v, cut ? "…" : "");
|
||
}
|
||
|
||
static void tui_lock(tui_t *t) { pthread_mutex_lock(&t->mu); }
|
||
static void tui_unlock(tui_t *t) { pthread_mutex_unlock(&t->mu); }
|
||
static void tui_mark_dirty(tui_t *t) { t->dirty = 1; }
|
||
|
||
static void set_status_locked(tui_t *t, const char *msg) {
|
||
snprintf(t->status_msg, sizeof(t->status_msg), "%s", msg ? msg : "");
|
||
tui_mark_dirty(t);
|
||
}
|
||
|
||
static void set_status(tui_t *t, const char *msg) {
|
||
tui_lock(t);
|
||
set_status_locked(t, msg);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
/* Empty status_msg means "show ~/path:branch" in the status row. */
|
||
static void status_idle(tui_t *t) {
|
||
if (t->queue_n > 0)
|
||
snprintf(t->status_msg, sizeof(t->status_msg),
|
||
"%d queued · empty Enter sends the next one", t->queue_n);
|
||
else
|
||
t->status_msg[0] = 0;
|
||
}
|
||
|
||
/* --- live assistant ------------------------------------------------------- */
|
||
|
||
static void live_end_cleanup(tui_t *t) {
|
||
block_clear(&t->live);
|
||
free(t->md_live);
|
||
t->md_live = NULL;
|
||
t->md_len = 0;
|
||
t->md_cap = 0;
|
||
t->md_rendered = 0;
|
||
free(t->md_prefix_ansi);
|
||
t->md_prefix_ansi = NULL;
|
||
t->md_prefix = 0;
|
||
t->as_live = 0;
|
||
}
|
||
|
||
#define LIVE_PREFIX_MIN (16 * 1024)
|
||
#define LIVE_RENDER_MS 50
|
||
|
||
static int is_fence_line(const char *s, size_t n) {
|
||
size_t i = 0;
|
||
while (i < n && i < 3 && s[i] == ' ')
|
||
i++;
|
||
return i + 3 <= n && ((s[i] == '`' && s[i + 1] == '`' && s[i + 2] == '`') ||
|
||
(s[i] == '~' && s[i + 1] == '~' && s[i + 2] == '~'));
|
||
}
|
||
|
||
/* Scans md[from, n): returns whether it ends inside a code fence, and the
|
||
* offset just past the last blank line outside any fence (or from). */
|
||
static int md_scan(const char *md, size_t from, size_t n, size_t *stable) {
|
||
int fence = 0;
|
||
*stable = from;
|
||
size_t i = from;
|
||
while (i < n) {
|
||
const char *nl = memchr(md + i, '\n', n - i);
|
||
size_t e = nl ? (size_t)(nl - md) : n;
|
||
if (!nl)
|
||
break; /* the last, unfinished line never counts */
|
||
if (is_fence_line(md + i, e - i))
|
||
fence = !fence;
|
||
else if (!fence && e == i)
|
||
*stable = e + 1;
|
||
i = e + 1;
|
||
}
|
||
if (i < n && is_fence_line(md + i, n - i))
|
||
fence = !fence;
|
||
return fence;
|
||
}
|
||
|
||
static char *md_render_part(const char *md, size_t n, int close_fence) {
|
||
char *src = malloc(n + 6);
|
||
if (!src)
|
||
return NULL;
|
||
memcpy(src, md, n);
|
||
size_t sn = n;
|
||
if (close_fence) {
|
||
memcpy(src + sn, "\n```\n", 5);
|
||
sn += 5;
|
||
}
|
||
src[sn] = 0;
|
||
char *ansi = NULL;
|
||
size_t an = 0;
|
||
if (md_to_ansi_dyn(src, sn, &ansi, &an) != 0)
|
||
ansi = NULL;
|
||
free(src);
|
||
return ansi;
|
||
}
|
||
|
||
/* Markdown → ANSI while streaming: unfinished fences are closed for display,
|
||
* and long replies keep the finished prefix rendered once. Caller locks. */
|
||
static void live_render(tui_t *t) {
|
||
if (!t->as_live || !t->md_live || t->md_rendered == t->md_len)
|
||
return;
|
||
const char *md = t->md_live;
|
||
size_t n = t->md_len, stable = 0;
|
||
if (n >= LIVE_PREFIX_MIN) {
|
||
md_scan(md, t->md_prefix, n, &stable);
|
||
if (stable > t->md_prefix + 4096) {
|
||
char *pre = md_render_part(md, stable, 0);
|
||
if (pre) {
|
||
free(t->md_prefix_ansi);
|
||
t->md_prefix_ansi = pre;
|
||
t->md_prefix = stable;
|
||
}
|
||
}
|
||
}
|
||
size_t from = t->md_prefix_ansi ? t->md_prefix : 0;
|
||
size_t dummy;
|
||
int fence = md_scan(md, from, n, &dummy);
|
||
char *tail = md_render_part(md + from, n - from, fence);
|
||
if (!tail)
|
||
return;
|
||
free(t->live.render);
|
||
if (from && t->md_prefix_ansi) {
|
||
size_t a = strlen(t->md_prefix_ansi), b = strlen(tail);
|
||
t->live.render = malloc(a + b + 1);
|
||
if (t->live.render) {
|
||
memcpy(t->live.render, t->md_prefix_ansi, a);
|
||
memcpy(t->live.render + a, tail, b + 1);
|
||
}
|
||
free(tail);
|
||
} else {
|
||
t->live.render = tail;
|
||
}
|
||
t->md_rendered = n;
|
||
clock_gettime(CLOCK_MONOTONIC, &t->md_render_at);
|
||
}
|
||
|
||
static long ms_since(const struct timespec *a) {
|
||
struct timespec now;
|
||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||
return (long)(now.tv_sec - a->tv_sec) * 1000 +
|
||
(long)(now.tv_nsec - a->tv_nsec) / 1000000;
|
||
}
|
||
|
||
static void live_begin(tui_t *t) {
|
||
live_end_cleanup(t);
|
||
t->md_cap = MD_INIT;
|
||
t->md_live = calloc(1, t->md_cap);
|
||
t->md_len = 0;
|
||
t->live.kind = BLK_ASSISTANT;
|
||
block_set_title(&t->live, "Assistant");
|
||
t->live.expanded = 1;
|
||
t->as_live = 1;
|
||
}
|
||
|
||
static void live_feed(tui_t *t, const char *text, size_t len) {
|
||
if (!t->as_live || !text || !len)
|
||
return;
|
||
if (t->md_len + len + 1 > t->md_cap) {
|
||
size_t nc = t->md_cap * 2;
|
||
while (nc < t->md_len + len + 1)
|
||
nc *= 2;
|
||
char *nb = realloc(t->md_live, nc);
|
||
if (!nb)
|
||
return;
|
||
t->md_live = nb;
|
||
t->md_cap = nc;
|
||
}
|
||
memcpy(t->md_live + t->md_len, text, len);
|
||
t->md_len += len;
|
||
t->md_live[t->md_len] = 0;
|
||
/* The last render stays on screen until the next throttled live_render;
|
||
* before the first one the raw text shows. */
|
||
free(t->live.body);
|
||
t->live.body = xstrndup(t->md_live, t->md_len);
|
||
if (!t->live.render || ms_since(&t->md_render_at) >= LIVE_RENDER_MS)
|
||
live_render(t);
|
||
}
|
||
|
||
static void live_seal(tui_t *t) {
|
||
if (!t->as_live)
|
||
return;
|
||
block_t *b = blocks_push(&t->hist);
|
||
if (b) {
|
||
b->kind = BLK_ASSISTANT;
|
||
block_set_title(b, "Assistant");
|
||
if (t->md_len)
|
||
block_set_body(b, t->md_live, t->md_len);
|
||
else if (t->live.body)
|
||
block_set_body(b, t->live.body, strlen(t->live.body));
|
||
b->expanded = 1;
|
||
block_render_assistant(b);
|
||
}
|
||
live_end_cleanup(t);
|
||
}
|
||
|
||
static void collapse_open_think(tui_t *t) {
|
||
if (t->think_idx < 0 || t->think_idx >= t->hist.n)
|
||
return;
|
||
block_t *th = &t->hist.items[t->think_idx];
|
||
if (th->kind != BLK_THINK)
|
||
return;
|
||
if (!t->show_thinking)
|
||
th->expanded = 0;
|
||
char title[64];
|
||
if (t->think_timing) {
|
||
struct timespec now;
|
||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||
double sec = (double)(now.tv_sec - t->think_t0.tv_sec) +
|
||
(double)(now.tv_nsec - t->think_t0.tv_nsec) / 1e9;
|
||
if (sec < 0)
|
||
sec = 0;
|
||
if (sec < 1.0)
|
||
snprintf(title, sizeof(title), "Thought: %dms", (int)(sec * 1000));
|
||
else
|
||
snprintf(title, sizeof(title), "Thought: %.1fs", sec);
|
||
t->think_timing = 0;
|
||
} else {
|
||
snprintf(title, sizeof(title), "Thought");
|
||
}
|
||
block_set_title(th, title);
|
||
t->think_idx = -1;
|
||
}
|
||
|
||
static int find_tool_block(tui_t *t, const char *id) {
|
||
if (!id || !id[0])
|
||
return -1;
|
||
for (int i = t->hist.n - 1; i >= 0; i--) {
|
||
block_t *b = &t->hist.items[i];
|
||
if (b->kind == BLK_TOOL && strcmp(b->tool_id, id) == 0)
|
||
return i;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
static int msg_is_tool_result(const cst_message_t *m) {
|
||
return m && (m->role == CST_ROLE_TOOL ||
|
||
(m->role == CST_ROLE_USER && m->tool_call_id[0]));
|
||
}
|
||
|
||
/* Strip the "[tool …]" / "result <id> exit=N <n>" header and "end" trailer. */
|
||
static void tool_result_view(const char *content, const char **body,
|
||
size_t *len) {
|
||
const char *s = content ? content : "";
|
||
size_t n = strlen(s);
|
||
int framed = strncmp(s, "result ", 7) == 0;
|
||
if (framed || strncmp(s, "[tool ", 6) == 0) {
|
||
const char *nl = strchr(s, '\n');
|
||
if (nl) {
|
||
n -= (size_t)(nl + 1 - s);
|
||
s = nl + 1;
|
||
} else {
|
||
n = 0;
|
||
}
|
||
}
|
||
if (framed && n >= 4 && memcmp(s + n - 4, "\nend", 4) == 0)
|
||
n -= 4;
|
||
else if (framed && n == 3 && memcmp(s, "end", 3) == 0)
|
||
n = 0;
|
||
*body = s;
|
||
*len = n;
|
||
}
|
||
|
||
static void attach_tool_output_from_session(tui_t *t, block_t *b) {
|
||
if (!b || b->kind != BLK_TOOL || !b->tool_id[0])
|
||
return;
|
||
cst_session_t *s = cst_agent_session(t->agent);
|
||
if (!s)
|
||
return;
|
||
cst_session_lock(s);
|
||
int n = cst_session_count(s);
|
||
for (int i = n - 1; i >= 0; i--) {
|
||
const cst_message_t *m = cst_session_get(s, i);
|
||
if (!msg_is_tool_result(m))
|
||
continue;
|
||
if (strcmp(m->tool_call_id, b->tool_id) != 0)
|
||
continue;
|
||
const char *body;
|
||
size_t len;
|
||
tool_result_view(m->content, &body, &len);
|
||
block_set_body(b, body, len);
|
||
break;
|
||
}
|
||
cst_session_unlock(s);
|
||
}
|
||
|
||
/* --- drawing: SGR --------------------------------------------------------- */
|
||
|
||
typedef struct {
|
||
int attrs;
|
||
int pair;
|
||
} sty_t;
|
||
|
||
static void sgr_apply(sty_t *st, int code) {
|
||
switch (code) {
|
||
case 0:
|
||
st->attrs = 0;
|
||
st->pair = 0;
|
||
break;
|
||
case 1:
|
||
st->attrs |= A_BOLD;
|
||
break;
|
||
case 2:
|
||
st->attrs |= A_DIM;
|
||
break;
|
||
case 3:
|
||
#ifdef A_ITALIC
|
||
st->attrs |= A_ITALIC;
|
||
#endif
|
||
break;
|
||
case 4:
|
||
st->attrs |= A_UNDERLINE;
|
||
break;
|
||
case 7:
|
||
st->attrs |= A_REVERSE;
|
||
break;
|
||
case 22:
|
||
st->attrs &= ~(A_BOLD | A_DIM);
|
||
break;
|
||
case 23:
|
||
#ifdef A_ITALIC
|
||
st->attrs &= ~A_ITALIC;
|
||
#endif
|
||
break;
|
||
case 24:
|
||
st->attrs &= ~A_UNDERLINE;
|
||
break;
|
||
case 27:
|
||
st->attrs &= ~A_REVERSE;
|
||
break;
|
||
case 31:
|
||
case 91:
|
||
st->pair = CP_ERROR;
|
||
break;
|
||
case 32:
|
||
case 92:
|
||
st->pair = CP_SGR_GREEN;
|
||
break;
|
||
case 33:
|
||
case 93:
|
||
st->pair = CP_SGR_YELLOW;
|
||
break;
|
||
case 36:
|
||
case 96:
|
||
st->pair = CP_SGR_CYAN;
|
||
break;
|
||
case 35:
|
||
case 95:
|
||
st->pair = CP_SGR_MAGENTA;
|
||
break;
|
||
case 34:
|
||
case 94:
|
||
st->pair = CP_ACCENT;
|
||
break;
|
||
case 90:
|
||
st->pair = CP_MUTED;
|
||
break;
|
||
case 97:
|
||
st->pair = CP_BRIGHT;
|
||
break;
|
||
case 39:
|
||
st->pair = 0;
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
static size_t sgr_parse(const char *s, size_t n, sty_t *st) {
|
||
if (n < 3 || (unsigned char)s[0] != 0x1b || s[1] != '[')
|
||
return 0;
|
||
size_t i = 2;
|
||
int val = 0, have = 0;
|
||
while (i < n) {
|
||
unsigned char c = (unsigned char)s[i++];
|
||
if (c >= '0' && c <= '9') {
|
||
val = val * 10 + (c - '0');
|
||
have = 1;
|
||
} else if (c == ';') {
|
||
sgr_apply(st, have ? val : 0);
|
||
val = 0;
|
||
have = 0;
|
||
} else if (c == 'm') {
|
||
sgr_apply(st, have ? val : 0);
|
||
return i;
|
||
} else {
|
||
return 0;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
static wchar_t ascii_glyph(wchar_t wc) {
|
||
if (!g_ascii || wc < 128)
|
||
return wc;
|
||
switch (wc) {
|
||
case 0x2503: /* ┃ */
|
||
case 0x2502: /* │ */
|
||
return L'|';
|
||
case 0x2500: /* ─ */
|
||
case 0x00b7: /* · */
|
||
return L'-';
|
||
case 0x25bc: /* ▼ */
|
||
case 0x2193: /* ↓ */
|
||
return L'v';
|
||
case 0x25b6: /* ▶ */
|
||
case 0x25b8: /* ▸ */
|
||
case 0x2192: /* → */
|
||
return L'>';
|
||
case 0x2190: /* ← */
|
||
return L'<';
|
||
case 0x2191: /* ↑ */
|
||
return L'^';
|
||
case 0x2026: /* … */
|
||
return L'.';
|
||
case 0x2713: /* ✓ */
|
||
return L'x';
|
||
case 0x25cb: /* ○ */
|
||
return L'o';
|
||
case 0x25cf: /* ● */
|
||
case 0x2022: /* • */
|
||
return L'*';
|
||
case 0x25a3: /* ▣ */
|
||
return L'#';
|
||
default:
|
||
return g_utf8 ? wc : L'?';
|
||
}
|
||
}
|
||
|
||
static int put_wc(WINDOW *win, int y, int col, wchar_t wc, int attrs) {
|
||
wc = ascii_glyph(wc);
|
||
cchar_t cc;
|
||
wchar_t wstr[2] = {wc, 0};
|
||
short pair = (short)PAIR_NUMBER(attrs);
|
||
attr_t a = (attr_t)(attrs & ~(attr_t)A_COLOR);
|
||
if (setcchar(&cc, wstr, a, pair, NULL) == ERR) {
|
||
if (wc < 128)
|
||
mvwaddch(win, y, col, (chtype)wc | (chtype)attrs);
|
||
else
|
||
mvwaddstr(win, y, col, "?");
|
||
return 1;
|
||
}
|
||
mvwadd_wch(win, y, col, &cc);
|
||
return utf8_width(wc);
|
||
}
|
||
|
||
/* Flatten blocks into display lines (plain text with optional ANSI in body). */
|
||
typedef struct {
|
||
char *text; /* owned line content (may include ANSI) */
|
||
int pair; /* base color pair for whole line (0 = default) */
|
||
int bold;
|
||
int dim;
|
||
int indent; /* columns left blank before text */
|
||
int fill; /* pair painted across the whole row (0 = none) */
|
||
int bar; /* pair for a "┃" in column 0 (0 = none) */
|
||
int blk; /* hist block index, -1 for live / none */
|
||
} dline_t;
|
||
|
||
static int g_dl_blk = -1; /* block being flattened, stamped into dline_t */
|
||
|
||
typedef struct {
|
||
dline_t *lines;
|
||
int n;
|
||
int cap;
|
||
} dlines_t;
|
||
|
||
static void dlines_free(dlines_t *dl) {
|
||
if (!dl)
|
||
return;
|
||
for (int i = 0; i < dl->n; i++)
|
||
free(dl->lines[i].text);
|
||
free(dl->lines);
|
||
dl->lines = NULL;
|
||
dl->n = dl->cap = 0;
|
||
}
|
||
|
||
static int dlines_push(dlines_t *dl, const char *text, int pair, int bold,
|
||
int dim) {
|
||
if (dl->n >= dl->cap) {
|
||
int nc = dl->cap ? dl->cap * 2 : 256;
|
||
dline_t *ni = realloc(dl->lines, (size_t)nc * sizeof(dline_t));
|
||
if (!ni)
|
||
return -1;
|
||
dl->lines = ni;
|
||
dl->cap = nc;
|
||
}
|
||
dline_t *L = &dl->lines[dl->n++];
|
||
L->text = xstrdup(text ? text : "");
|
||
L->pair = pair;
|
||
L->bold = bold;
|
||
L->dim = dim;
|
||
L->indent = 0;
|
||
L->fill = 0;
|
||
L->bar = 0;
|
||
L->blk = g_dl_blk;
|
||
return 0;
|
||
}
|
||
|
||
/* Raised row: bar in column 0, the rest painted with `fill`. */
|
||
static void dlines_push_box(dlines_t *dl, const char *text, int pair,
|
||
int fill, int bar) {
|
||
if (dlines_push(dl, text, pair, 0, 0) == 0) {
|
||
dl->lines[dl->n - 1].fill = fill;
|
||
dl->lines[dl->n - 1].bar = bar;
|
||
}
|
||
}
|
||
|
||
static void push_wrapped(dlines_t *dl, const char *prefix, const char *body,
|
||
int width, int pair, int bold, int dim, int ansi) {
|
||
(void)ansi;
|
||
char line[4096];
|
||
int pref_cols = prefix ? utf8_cols(prefix, strlen(prefix)) : 0;
|
||
int body_w = width - pref_cols;
|
||
if (body_w < 8)
|
||
body_w = 8;
|
||
|
||
if (!body || !body[0]) {
|
||
snprintf(line, sizeof(line), "%s", prefix ? prefix : "");
|
||
dlines_push(dl, line, pair, bold, dim);
|
||
return;
|
||
}
|
||
|
||
/* Walk body; for ANSI text, wrap on visible columns skipping SGR. */
|
||
size_t n = strlen(body);
|
||
size_t i = 0;
|
||
int first = 1;
|
||
while (i < n && dl->n < MAX_RENDER_LINES) {
|
||
size_t start = i;
|
||
int col = 0;
|
||
while (i < n) {
|
||
size_t sg = 0;
|
||
if ((unsigned char)body[i] == 0x1b) {
|
||
sty_t dummy = {0};
|
||
sg = sgr_parse(body + i, n - i, &dummy);
|
||
if (!sg)
|
||
sg = ansi_skip(body + i, n - i);
|
||
if (sg) {
|
||
i += sg;
|
||
continue;
|
||
}
|
||
}
|
||
if (body[i] == '\n') {
|
||
i++;
|
||
break;
|
||
}
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(body + i, n - i, &wc, &eaten);
|
||
int cw = utf8_width(wc);
|
||
if (col + cw > body_w && col > 0)
|
||
break;
|
||
col += cw;
|
||
i += eaten;
|
||
if (col >= body_w)
|
||
break;
|
||
}
|
||
size_t chunk = i - start;
|
||
/* trim trailing newline already consumed */
|
||
if (chunk > 0 && body[start + chunk - 1] == '\n')
|
||
chunk--;
|
||
if (first && prefix) {
|
||
if (chunk + strlen(prefix) + 1 < sizeof(line)) {
|
||
memcpy(line, prefix, strlen(prefix));
|
||
memcpy(line + strlen(prefix), body + start, chunk);
|
||
line[strlen(prefix) + chunk] = 0;
|
||
} else {
|
||
snprintf(line, sizeof(line), "%s", prefix);
|
||
}
|
||
first = 0;
|
||
} else {
|
||
/* continuation indent matching prefix visual width */
|
||
int pad = pref_cols;
|
||
if (pad < 0)
|
||
pad = 0;
|
||
if (pad > 40)
|
||
pad = 40;
|
||
memset(line, ' ', (size_t)pad);
|
||
if (chunk + (size_t)pad + 1 < sizeof(line)) {
|
||
memcpy(line + pad, body + start, chunk);
|
||
line[pad + chunk] = 0;
|
||
} else {
|
||
line[pad] = 0;
|
||
}
|
||
}
|
||
dlines_push(dl, line, pair, bold, dim);
|
||
if (i < n && body[i - 1] != '\n' && col >= body_w) {
|
||
/* soft wrap — already advanced */
|
||
}
|
||
}
|
||
}
|
||
|
||
static void push_body_preview(dlines_t *dl, const char *body, int width,
|
||
int pair, int expanded, int max_lines) {
|
||
if (!body || !body[0])
|
||
return;
|
||
if (expanded) {
|
||
push_wrapped(dl, " ", body, width, pair, 0, 1, 0);
|
||
return;
|
||
}
|
||
const char *p = body;
|
||
int shown = 0;
|
||
while (*p && shown < max_lines) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
char tmp[1024];
|
||
if (len >= sizeof(tmp))
|
||
len = sizeof(tmp) - 1;
|
||
memcpy(tmp, p, len);
|
||
tmp[len] = 0;
|
||
push_wrapped(dl, " ", tmp, width, pair, 0, 1, 0);
|
||
shown++;
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
if (*p)
|
||
dlines_push(dl, " …", pair, 0, 1);
|
||
}
|
||
|
||
static int body_looks_like_diff(const char *body) {
|
||
if (!body || !body[0])
|
||
return 0;
|
||
return strstr(body, "\n--- ") || strstr(body, "\n+++ ") ||
|
||
strstr(body, "\n@@ ") || strstr(body, "\n@@-") ||
|
||
strncmp(body, "--- ", 4) == 0 || strncmp(body, "diff ", 5) == 0 ||
|
||
strstr(body, "\n--- a/") || strstr(body, "\n+++ b/");
|
||
}
|
||
|
||
static void diff_extract_path(const char *body, const char *fallback,
|
||
char *out, size_t out_sz) {
|
||
if (!out || out_sz == 0)
|
||
return;
|
||
out[0] = 0;
|
||
if (body) {
|
||
const char *p = body;
|
||
while (*p) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
if (len > 6 && strncmp(p, "wrote ", 6) == 0) {
|
||
const char *s = p + 6;
|
||
size_t n = len - 6;
|
||
/* trim " (N bytes)" */
|
||
const char *sp = memchr(s, ' ', n);
|
||
if (sp)
|
||
n = (size_t)(sp - s);
|
||
if (n >= out_sz)
|
||
n = out_sz - 1;
|
||
memcpy(out, s, n);
|
||
out[n] = 0;
|
||
return;
|
||
}
|
||
if (len > 6 && strncmp(p, "+++ b/", 6) == 0) {
|
||
size_t n = len - 6;
|
||
if (n >= out_sz)
|
||
n = out_sz - 1;
|
||
memcpy(out, p + 6, n);
|
||
out[n] = 0;
|
||
return;
|
||
}
|
||
if (len > 6 && strncmp(p, "--- a/", 6) == 0) {
|
||
size_t n = len - 6;
|
||
if (n >= out_sz)
|
||
n = out_sz - 1;
|
||
memcpy(out, p + 6, n);
|
||
out[n] = 0;
|
||
/* keep scanning for +++ / wrote */
|
||
}
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
}
|
||
if (!out[0] && fallback && fallback[0]) {
|
||
snprintf(out, out_sz, "%s", fallback);
|
||
}
|
||
}
|
||
|
||
static int parse_hunk_at(const char *line, int *old_ln, int *new_ln) {
|
||
/* @@ -old[,n] +new[,n] @@ */
|
||
if (line[0] != '@' || line[1] != '@')
|
||
return 0;
|
||
const char *p = line;
|
||
while (*p && *p != '-')
|
||
p++;
|
||
if (*p != '-')
|
||
return 0;
|
||
p++;
|
||
int o = 0, n = 0;
|
||
if (sscanf(p, "%d", &o) != 1)
|
||
return 0;
|
||
while (*p && *p != '+')
|
||
p++;
|
||
if (*p != '+')
|
||
return 0;
|
||
p++;
|
||
if (sscanf(p, "%d", &n) != 1)
|
||
return 0;
|
||
if (old_ln)
|
||
*old_ln = o;
|
||
if (new_ln)
|
||
*new_ln = n;
|
||
return 1;
|
||
}
|
||
|
||
typedef struct {
|
||
int additions;
|
||
int deletions;
|
||
int content_lines;
|
||
} diff_stats_t;
|
||
|
||
static void diff_count_stats(const char *body, diff_stats_t *st) {
|
||
memset(st, 0, sizeof(*st));
|
||
if (!body)
|
||
return;
|
||
const char *p = body;
|
||
while (*p) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
if (len == 0) {
|
||
/* empty */
|
||
} else if (strncmp(p, "---", 3) == 0 || strncmp(p, "+++", 3) == 0 ||
|
||
strncmp(p, "diff ", 5) == 0 || strncmp(p, "index ", 6) == 0 ||
|
||
strncmp(p, "@@", 2) == 0 || strncmp(p, "wrote ", 6) == 0 ||
|
||
(p[0] == '[' && strstr(p, "tool "))) {
|
||
/* skip meta */
|
||
} else if (p[0] == '+') {
|
||
st->additions++;
|
||
st->content_lines++;
|
||
} else if (p[0] == '-') {
|
||
st->deletions++;
|
||
st->content_lines++;
|
||
} else if (p[0] == ' ' || p[0] == '\\') {
|
||
st->content_lines++;
|
||
}
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
}
|
||
|
||
/* OpenCode-style: strip ---/+++/@@, line numbers, add/del backgrounds. */
|
||
static void push_structured_diff(dlines_t *dl, const char *body, int width,
|
||
int unlimited, int fold_limit) {
|
||
if (!body || !body[0])
|
||
return;
|
||
int old_ln = 0, new_ln = 0;
|
||
int shown = 0;
|
||
int total_content = 0;
|
||
/* First pass: count content lines for fold message */
|
||
{
|
||
diff_stats_t st;
|
||
diff_count_stats(body, &st);
|
||
total_content = st.content_lines;
|
||
}
|
||
int limit = unlimited ? 1000000 : fold_limit;
|
||
if (limit < 1)
|
||
limit = 1;
|
||
|
||
const char *p = body;
|
||
while (*p) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
char raw[1024];
|
||
if (len >= sizeof(raw))
|
||
len = sizeof(raw) - 1;
|
||
memcpy(raw, p, len);
|
||
raw[len] = 0;
|
||
|
||
int skip = 0;
|
||
if (raw[0] == 0)
|
||
skip = 1;
|
||
else if (strncmp(raw, "wrote ", 6) == 0)
|
||
skip = 1;
|
||
else if (raw[0] == '[' && strstr(raw, "tool "))
|
||
skip = 1;
|
||
else if (strncmp(raw, "---", 3) == 0 || strncmp(raw, "+++", 3) == 0 ||
|
||
strncmp(raw, "diff ", 5) == 0 || strncmp(raw, "index ", 6) == 0)
|
||
skip = 1;
|
||
else if (strncmp(raw, "@@", 2) == 0) {
|
||
parse_hunk_at(raw, &old_ln, &new_ln);
|
||
skip = 1;
|
||
} else if (raw[0] == '\\') {
|
||
/* "\ No newline at end of file" */
|
||
skip = 1;
|
||
}
|
||
|
||
if (!skip && (raw[0] == '+' || raw[0] == '-' || raw[0] == ' ')) {
|
||
if (shown >= limit) {
|
||
/* keep counting via total_content; stop emitting */
|
||
break;
|
||
}
|
||
char kind = raw[0];
|
||
const char *content = raw + 1;
|
||
int lnum = 0;
|
||
char mark = ' ';
|
||
int pair = CP_DIFF_CTX;
|
||
if (kind == '+') {
|
||
lnum = new_ln++;
|
||
mark = '+';
|
||
pair = CP_DIFF_ADD;
|
||
} else if (kind == '-') {
|
||
lnum = old_ln++;
|
||
mark = '-';
|
||
pair = CP_DIFF_DEL;
|
||
} else {
|
||
lnum = new_ln;
|
||
old_ln++;
|
||
new_ln++;
|
||
mark = ' ';
|
||
pair = CP_DIFF_CTX;
|
||
}
|
||
|
||
char line[1200];
|
||
/* │ 12+│ content — left border + gutter + body */
|
||
int body_w = width - 8;
|
||
if (body_w < 8)
|
||
body_w = 8;
|
||
char clipped[1024];
|
||
size_t clen = strlen(content);
|
||
/* column-clip roughly by bytes for MVP */
|
||
if ((int)clen > body_w) {
|
||
int keep = body_w > 3 ? body_w - 3 : body_w;
|
||
memcpy(clipped, content, (size_t)keep);
|
||
if (body_w > 3)
|
||
memcpy(clipped + keep, "...", 4);
|
||
else
|
||
clipped[keep] = 0;
|
||
content = clipped;
|
||
}
|
||
snprintf(line, sizeof(line), "│%4d%c│ %s", lnum, mark, content);
|
||
/* Pad to width so row background fills like OpenCode. */
|
||
{
|
||
int cols = utf8_cols(line, strlen(line));
|
||
size_t llen = strlen(line);
|
||
while (cols < width && llen + 1 < sizeof(line)) {
|
||
line[llen++] = ' ';
|
||
line[llen] = 0;
|
||
cols++;
|
||
}
|
||
}
|
||
dlines_push(dl, line, pair, kind != ' ', 0);
|
||
shown++;
|
||
}
|
||
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
|
||
if (!unlimited && total_content > shown) {
|
||
char more[96];
|
||
snprintf(more, sizeof(more), "│ … %d more lines · /details",
|
||
total_content - shown);
|
||
dlines_push(dl, more, CP_MUTED, 0, 1);
|
||
}
|
||
}
|
||
|
||
static void push_diff_or_body(dlines_t *dl, const char *body, int width,
|
||
int default_pair, int expanded, int max_lines,
|
||
int details_all) {
|
||
if (!body || !body[0])
|
||
return;
|
||
if (!body_looks_like_diff(body)) {
|
||
push_body_preview(dl, body, width, default_pair, expanded || details_all,
|
||
max_lines);
|
||
return;
|
||
}
|
||
/* Auto-expanded write cards still fold at DIFF_FOLD_LINES unless /details. */
|
||
int unlimited = details_all;
|
||
int fold = expanded ? DIFF_FOLD_LINES : max_lines;
|
||
if (!expanded && !details_all)
|
||
fold = 0; /* title-only when collapsed */
|
||
if (fold == 0 && !unlimited)
|
||
return;
|
||
push_structured_diff(dl, body, width, unlimited, fold);
|
||
}
|
||
|
||
static void block_to_lines(const block_t *b, int width, int details_all,
|
||
dlines_t *dl) {
|
||
char head[512];
|
||
|
||
switch (b->kind) {
|
||
case BLK_USER: {
|
||
int n0 = dl->n;
|
||
dlines_push_box(dl, "", CP_BOX, CP_BOX, CP_BOX_ACCENT);
|
||
push_wrapped(dl, " ", b->body ? b->body : "", width - 1, CP_BOX, 0, 0, 0);
|
||
for (int i = n0 + 1; i < dl->n; i++) {
|
||
dl->lines[i].fill = CP_BOX;
|
||
dl->lines[i].bar = CP_BOX_ACCENT;
|
||
}
|
||
dlines_push_box(dl, "", CP_BOX, CP_BOX, CP_BOX_ACCENT);
|
||
dlines_push(dl, "", 0, 0, 0);
|
||
break;
|
||
}
|
||
|
||
case BLK_ASSISTANT: {
|
||
const char *src = b->render ? b->render : (b->body ? b->body : "");
|
||
push_wrapped(dl, "", src, width, CP_PANEL, 0, 0, 1);
|
||
dlines_push(dl, "", 0, 0, 0);
|
||
break;
|
||
}
|
||
|
||
case BLK_THINK:
|
||
snprintf(head, sizeof(head), "%s %s", b->expanded ? "-" : "+",
|
||
b->title ? b->title : "Thought");
|
||
dlines_push(dl, head, CP_PRIMARY, 0, 0);
|
||
if (b->expanded)
|
||
push_wrapped(dl, " ", b->body ? b->body : "", width, CP_MUTED, 0, 1, 0);
|
||
dlines_push(dl, "", 0, 0, 0);
|
||
break;
|
||
|
||
case BLK_FOOTER:
|
||
snprintf(head, sizeof(head), "\033[34m%s\033[0m %s",
|
||
g_ascii ? "#" : "▣", b->body ? b->body : "");
|
||
dlines_push(dl, head, CP_MUTED, 0, 0);
|
||
dlines_push(dl, "", 0, 0, 0);
|
||
break;
|
||
|
||
case BLK_TOOL: {
|
||
const char *px = tool_prefix(b->tool_name);
|
||
int exp = details_all || b->expanded;
|
||
int denied = (strncmp(b->meta, "denied", 6) == 0);
|
||
int is_write = (strcmp(b->tool_name, "write_file") == 0);
|
||
int is_file_edit = cst_tool_is_write(b->tool_name);
|
||
const char *verb_done = is_write ? "Wrote" : "Edit";
|
||
const char *verb_run = is_write ? "Write" : "Edit";
|
||
if (strcmp(b->tool_name, "delete_file") == 0)
|
||
verb_done = "Deleted", verb_run = "Delete";
|
||
else if (strcmp(b->tool_name, "move_file") == 0)
|
||
verb_done = "Moved", verb_run = "Move";
|
||
int done = (strncmp(b->meta, "exit=", 5) == 0) ||
|
||
(strcmp(b->meta, "done") == 0) ||
|
||
(b->body && body_looks_like_diff(b->body));
|
||
|
||
if (is_file_edit && !denied) {
|
||
char path[256];
|
||
diff_extract_path(b->body, b->title, path, sizeof(path));
|
||
if (!path[0] && b->title && b->title[0])
|
||
snprintf(path, sizeof(path), "%s", b->title);
|
||
if (!path[0])
|
||
snprintf(path, sizeof(path), "file");
|
||
|
||
diff_stats_t st = {0};
|
||
if (b->body)
|
||
diff_count_stats(b->body, &st);
|
||
|
||
char head[512];
|
||
if (done) {
|
||
/* OpenCode: ← Wrote path / ← Edit path +N -M */
|
||
snprintf(head, sizeof(head),
|
||
"│ ← %s %.200s \033[32m+%d\033[0m \033[31m-%d\033[0m",
|
||
verb_done, path, st.additions, st.deletions);
|
||
} else {
|
||
snprintf(head, sizeof(head), "│ ← %s %.200s %.100s", verb_run, path,
|
||
b->meta[0] ? b->meta : "…");
|
||
}
|
||
dlines_push(dl, head, CP_MUTED, 0, 0);
|
||
if (done || exp)
|
||
push_diff_or_body(dl, b->body, width, CP_MUTED, b->expanded,
|
||
TOOL_PREVIEW_LINES, details_all);
|
||
} else {
|
||
char status_short[96];
|
||
status_short[0] = 0;
|
||
if (denied) {
|
||
snprintf(status_short, sizeof(status_short), "denied");
|
||
} else if (strncmp(b->meta, "exit=", 5) == 0) {
|
||
snprintf(status_short, sizeof(status_short), "done %.80s", b->meta);
|
||
} else if (b->meta[0]) {
|
||
snprintf(status_short, sizeof(status_short), "%.80s", b->meta);
|
||
}
|
||
snprintf(head, sizeof(head), "%s %s %s%s%s", px,
|
||
b->tool_name[0] ? b->tool_name : "tool",
|
||
b->title ? b->title : "",
|
||
status_short[0] ? " " : "", status_short);
|
||
dlines_push(dl, head, denied ? CP_ERROR : CP_TOOL, 1, 0);
|
||
push_diff_or_body(dl, b->body, width, denied ? CP_ERROR : CP_MUTED, exp,
|
||
TOOL_PREVIEW_LINES, details_all);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case BLK_STATUS:
|
||
case BLK_SYSTEM:
|
||
snprintf(head, sizeof(head), "%s%s%s", b->title ? b->title : "",
|
||
(b->title && b->body && b->body[0]) ? " " : "",
|
||
b->body ? b->body : "");
|
||
push_wrapped(dl, "", head, width, CP_MUTED, 0, 1, 0);
|
||
break;
|
||
}
|
||
}
|
||
|
||
/* Non-box lines sit two columns right of the user-message bar. */
|
||
#define LOG_INDENT 2
|
||
|
||
static void block_to_lines_indented(const block_t *b, int width,
|
||
int details_all, dlines_t *dl) {
|
||
int n0 = dl->n;
|
||
if (b->kind == BLK_USER) {
|
||
block_to_lines(b, width, details_all, dl);
|
||
return;
|
||
}
|
||
block_to_lines(b, width - LOG_INDENT, details_all, dl);
|
||
for (int i = n0; i < dl->n; i++)
|
||
if (!dl->lines[i].fill)
|
||
dl->lines[i].indent = LOG_INDENT;
|
||
}
|
||
|
||
static void build_display(tui_t *t, dlines_t *dl, int width) {
|
||
dlines_free(dl);
|
||
dl->lines = NULL;
|
||
dl->n = dl->cap = 0;
|
||
for (int i = 0; i < t->hist.n; i++) {
|
||
g_dl_blk = i;
|
||
block_to_lines_indented(&t->hist.items[i], width, t->details_all, dl);
|
||
}
|
||
g_dl_blk = -1;
|
||
if (t->as_live)
|
||
block_to_lines_indented(&t->live, width, t->details_all, dl);
|
||
}
|
||
|
||
/* Draws s at (y, x0) within maxw columns; returns the columns used. */
|
||
static int draw_ansi_at(WINDOW *win, int y, int x0, int maxw, const char *s,
|
||
int base_pair, int base_bold, int base_dim) {
|
||
sty_t sty = {0};
|
||
sty.pair = base_pair;
|
||
if (base_bold)
|
||
sty.attrs |= A_BOLD;
|
||
if (base_dim)
|
||
sty.attrs |= A_DIM;
|
||
int col = 0;
|
||
size_t n = s ? strlen(s) : 0;
|
||
size_t i = 0;
|
||
while (i < n && col < maxw) {
|
||
size_t sg = sgr_parse(s + i, n - i, &sty);
|
||
if (sg) {
|
||
i += sg;
|
||
continue;
|
||
}
|
||
size_t skip = ansi_skip(s + i, n - i);
|
||
if (skip) {
|
||
i += skip;
|
||
continue;
|
||
}
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(s + i, n - i, &wc, &eaten);
|
||
wc = ascii_glyph(wc);
|
||
int cw = utf8_width(wc);
|
||
if (col + cw > maxw)
|
||
break;
|
||
int attrs = sty.attrs;
|
||
if (sty.pair && HAS_COLOR())
|
||
attrs |= COLOR_PAIR(sty.pair);
|
||
else if (base_pair && HAS_COLOR())
|
||
attrs |= COLOR_PAIR(base_pair);
|
||
put_wc(win, y, x0 + col, wc, attrs);
|
||
col += cw;
|
||
i += eaten;
|
||
}
|
||
return col;
|
||
}
|
||
|
||
static void draw_ansi_line(WINDOW *win, int y, int maxw, const char *s,
|
||
int base_pair, int base_bold, int base_dim) {
|
||
draw_ansi_at(win, y, 0, maxw, s, base_pair, base_bold, base_dim);
|
||
}
|
||
|
||
static double since_ms(const struct timespec *t0) {
|
||
struct timespec now;
|
||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||
return (double)(now.tv_sec - t0->tv_sec) * 1000.0 +
|
||
(double)(now.tv_nsec - t0->tv_nsec) / 1e6;
|
||
}
|
||
|
||
static const char *dot_sep(void) { return g_ascii ? " - " : " · "; }
|
||
|
||
/* Branch from the nearest .git/HEAD at or above dir (worktree files too). */
|
||
static void git_branch(const char *dir, char *out, size_t sz) {
|
||
out[0] = 0;
|
||
char d[OS_PATH_MAX], p[OS_PATH_MAX + 64], line[OS_PATH_MAX];
|
||
snprintf(d, sizeof(d), "%s", dir);
|
||
FILE *f = NULL;
|
||
for (int depth = 0; depth < 64 && d[0]; depth++) {
|
||
snprintf(p, sizeof(p), "%s/.git/HEAD", d);
|
||
if ((f = fopen(p, "r")) != NULL)
|
||
break;
|
||
snprintf(p, sizeof(p), "%s/.git", d);
|
||
FILE *g = fopen(p, "r");
|
||
if (g) {
|
||
int ok = fgets(line, sizeof(line), g) && strncmp(line, "gitdir: ", 8) == 0;
|
||
fclose(g);
|
||
if (ok) {
|
||
line[strcspn(line, "\r\n")] = 0;
|
||
int n = line[8] == '/'
|
||
? snprintf(p, sizeof(p), "%s/HEAD", line + 8)
|
||
: snprintf(p, sizeof(p), "%s/%s/HEAD", d, line + 8);
|
||
if (n > 0 && (size_t)n < sizeof(p))
|
||
f = fopen(p, "r");
|
||
break;
|
||
}
|
||
}
|
||
char *slash = strrchr(d, '/');
|
||
if (!slash || slash == d)
|
||
break;
|
||
*slash = 0;
|
||
}
|
||
if (!f)
|
||
return;
|
||
if (fgets(line, sizeof(line), f)) {
|
||
line[strcspn(line, "\r\n")] = 0;
|
||
const char *ref = strncmp(line, "ref: refs/heads/", 16) == 0 ? line + 16
|
||
: line;
|
||
size_t n = ref == line ? 7 : strlen(ref);
|
||
if (n >= sz)
|
||
n = sz - 1;
|
||
memcpy(out, ref, n);
|
||
out[n] = 0;
|
||
}
|
||
fclose(f);
|
||
}
|
||
|
||
static void where_label(const char *ws, char *out, size_t sz) {
|
||
const char *home = getenv("HOME");
|
||
size_t hl = home ? strlen(home) : 0;
|
||
char br[128];
|
||
git_branch(ws, br, sizeof(br));
|
||
int tilde = hl > 1 && strncmp(ws, home, hl) == 0 && (ws[hl] == '/' || !ws[hl]);
|
||
int n = snprintf(out, sz, "%s%s%s%s", tilde ? "~" : "", tilde ? ws + hl : ws,
|
||
br[0] ? ":" : "", br);
|
||
if (n < 0)
|
||
out[0] = 0;
|
||
}
|
||
|
||
/* Display columns of s, ignoring escape sequences. */
|
||
static int vis_cols(const char *s) {
|
||
int cols = 0;
|
||
size_t n = strlen(s), i = 0;
|
||
while (i < n) {
|
||
size_t sk = ansi_skip(s + i, n - i);
|
||
if (sk) {
|
||
i += sk;
|
||
continue;
|
||
}
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(s + i, n - i, &wc, &eaten);
|
||
cols += utf8_width(wc);
|
||
i += eaten;
|
||
}
|
||
return cols;
|
||
}
|
||
|
||
static const char *spinner_frame(const tui_t *t) {
|
||
static const char *const uni[] = {"⠋", "⠙", "⠹", "⠸", "⠼",
|
||
"⠴", "⠦", "⠧", "⠇", "⠏"};
|
||
static const char *const asc[] = {"|", "/", "-", "\\"};
|
||
return g_ascii ? asc[t->spin % 4] : uni[t->spin % 10];
|
||
}
|
||
|
||
static void fmt_tokens(long n, char *out, size_t sz) {
|
||
if (n >= 1000000)
|
||
snprintf(out, sz, "%.1fM", (double)n / 1e6);
|
||
else if (n >= 1000)
|
||
snprintf(out, sz, "%.1fK", (double)n / 1e3);
|
||
else
|
||
snprintf(out, sz, "%ld", n);
|
||
}
|
||
|
||
static int ctx_percent(const tui_t *t, long *used) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
long win = cfg ? cst_model_context_tokens(cfg->model) : 0;
|
||
*used = cst_agent_last_prompt_tokens(t->agent);
|
||
return win > 0 ? (int)(*used * 100 / win) : 0;
|
||
}
|
||
|
||
static int approval_mode(const tui_t *t) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
if (!cfg)
|
||
return CST_APPROVAL_ASK;
|
||
return cfg->auto_approve ? CST_APPROVAL_YOLO : cfg->approval;
|
||
}
|
||
|
||
static void approval_label(const tui_t *t, char *out, size_t sz) {
|
||
int a = approval_mode(t);
|
||
if (a == CST_APPROVAL_YOLO)
|
||
snprintf(out, sz, "YOLO");
|
||
else if (a == CST_APPROVAL_AUTO)
|
||
snprintf(out, sz, "auto%s%s", dot_sep(), t->sandbox_name);
|
||
else
|
||
snprintf(out, sz, "ask");
|
||
}
|
||
|
||
/* Bottom row: where/status on the left, context + palette hint on the right. */
|
||
static void draw_status(tui_t *t) {
|
||
if (!t->status)
|
||
return;
|
||
werase(t->status);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->status, COLOR_PAIR(CP_PANEL));
|
||
int w = getmaxx(t->status);
|
||
int sx = getbegx(t->status);
|
||
char right[160];
|
||
int rw;
|
||
t->cmd_x0 = t->cmd_x1 = -1;
|
||
if (t->home) {
|
||
snprintf(right, sizeof(right), "%s", CST_VERSION);
|
||
rw = utf8_cols(right, strlen(right));
|
||
draw_ansi_at(t->status, 0, w - rw, rw, right, CP_MUTED, 0, 0);
|
||
} else {
|
||
long used;
|
||
int pct = ctx_percent(t, &used);
|
||
char tok[32];
|
||
fmt_tokens(used, tok, sizeof(tok));
|
||
snprintf(right, sizeof(right), "%s (%d%%)%s\033[97mctrl+p\033[39m commands",
|
||
tok, pct, dot_sep());
|
||
rw = vis_cols(right);
|
||
if (rw > w)
|
||
rw = w;
|
||
draw_ansi_at(t->status, 0, w - rw, rw, right,
|
||
pct >= 80 ? CP_ERROR : CP_MUTED, 0, 0);
|
||
t->cmd_x1 = sx + w - 1;
|
||
t->cmd_x0 = t->cmd_x1 - (int)strlen("ctrl+p commands") + 1;
|
||
}
|
||
int lw = w - rw - 2;
|
||
if (lw < 1)
|
||
return (void)wrefresh(t->status);
|
||
if (t->busy) {
|
||
char line[600];
|
||
snprintf(line, sizeof(line), "\033[33m%s\033[39m %s", spinner_frame(t),
|
||
t->status_msg[0] ? t->status_msg : "working");
|
||
draw_ansi_at(t->status, 0, 0, lw, line, CP_MUTED, 0, 0);
|
||
} else if (t->status_msg[0]) {
|
||
draw_ansi_at(t->status, 0, 0, lw, t->status_msg, CP_MUTED, 0, 0);
|
||
} else {
|
||
draw_ansi_at(t->status, 0, 0, lw, t->where, CP_MUTED, 0, 0);
|
||
}
|
||
wrefresh(t->status);
|
||
}
|
||
|
||
/* "Build · model provider · effort" on the input box's last row. */
|
||
static void draw_mode_line(tui_t *t, WINDOW *w, int y, int maxw) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
if (!cfg)
|
||
return;
|
||
const char *eff = cst_effort_wire(cfg->provider, cfg->model, cfg->effort)
|
||
? cfg->effort
|
||
: NULL;
|
||
char right[160];
|
||
int rpair = CP_BOX_MUTED;
|
||
if (t->busy) {
|
||
snprintf(right, sizeof(right), "%s %.0fs esc interrupt", spinner_frame(t),
|
||
since_ms(&t->turn_t0) / 1000.0);
|
||
rpair = CP_BOX_PRIMARY;
|
||
} else {
|
||
approval_label(t, right, sizeof(right));
|
||
if (approval_mode(t) == CST_APPROVAL_YOLO)
|
||
rpair = CP_BOX_PRIMARY;
|
||
}
|
||
int rw = utf8_cols(right, strlen(right));
|
||
int x = 2, room = maxw - x - rw - 2;
|
||
if (room < 8) {
|
||
rw = 0;
|
||
room = maxw - x - 1;
|
||
}
|
||
x += draw_ansi_at(w, y, x, room, cfg->plan_mode ? "Plan" : "Build",
|
||
cfg->plan_mode ? CP_BOX_PRIMARY : CP_BOX_ACCENT, 1, 0);
|
||
x += draw_ansi_at(w, y, x, room - x + 2, dot_sep(), CP_BOX_MUTED, 0, 0);
|
||
x += draw_ansi_at(w, y, x, room - x + 2, cfg->model, CP_BOX_BRIGHT, 0, 0);
|
||
x += draw_ansi_at(w, y, x, room - x + 2, " ", CP_BOX, 0, 0);
|
||
x += draw_ansi_at(w, y, x, room - x + 2, cfg->provider, CP_BOX_MUTED, 0, 0);
|
||
if (eff) {
|
||
x += draw_ansi_at(w, y, x, room - x + 2, dot_sep(), CP_BOX_MUTED, 0, 0);
|
||
draw_ansi_at(w, y, x, room - x + 2, eff, CP_BOX_PRIMARY, 0, 0);
|
||
}
|
||
if (rw)
|
||
draw_ansi_at(w, y, maxw - rw - 2, rw, right, rpair, 0, 0);
|
||
}
|
||
|
||
/* Visual position of every byte offset: soft wrap at the window edge plus
|
||
* hard newlines. Calls fn(ud, offset, row, col, wc, width) per character and
|
||
* returns the row count; cursor row/col are reported for t->cur. */
|
||
typedef void (*input_cell_fn)(void *ud, int row, int col, wchar_t wc, int cw);
|
||
|
||
#define IN_COL 2 /* text column inside the input box (bar at 0) */
|
||
|
||
static int input_layout(const tui_t *t, input_cell_fn fn, void *ud,
|
||
int *cur_row, int *cur_col) {
|
||
int row = 0, col = IN_COL;
|
||
int right = (t->in_w > 0 ? t->in_w : t->cols) - 2;
|
||
*cur_row = 0;
|
||
*cur_col = IN_COL;
|
||
size_t i = 0;
|
||
while (i < t->in_len) {
|
||
if (i == t->cur) {
|
||
*cur_row = row;
|
||
*cur_col = col;
|
||
}
|
||
if (t->inbuf[i] == '\n') {
|
||
row++;
|
||
col = IN_COL;
|
||
i++;
|
||
continue;
|
||
}
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(t->inbuf + i, t->in_len - i, &wc, &eaten);
|
||
if (wc == L'\t')
|
||
wc = L' ';
|
||
int cw = utf8_width(wc);
|
||
if (col + cw > right && col > IN_COL) {
|
||
row++;
|
||
col = IN_COL;
|
||
}
|
||
if (fn)
|
||
fn(ud, row, col, wc, cw);
|
||
col += cw;
|
||
i += eaten;
|
||
}
|
||
if (t->cur >= t->in_len) {
|
||
if (col >= right) {
|
||
row++;
|
||
col = IN_COL;
|
||
}
|
||
*cur_row = row;
|
||
*cur_col = col;
|
||
}
|
||
return row + 1;
|
||
}
|
||
|
||
typedef struct {
|
||
WINDOW *win;
|
||
int top, h, attrs;
|
||
int row0; /* window row of the first visible input line */
|
||
} input_draw_t;
|
||
|
||
static void input_draw_cell(void *ud, int row, int col, wchar_t wc, int cw) {
|
||
(void)cw;
|
||
input_draw_t *d = ud;
|
||
if (row < d->top || row >= d->top + d->h)
|
||
return;
|
||
put_wc(d->win, d->row0 + row - d->top, col, wc, d->attrs);
|
||
}
|
||
|
||
static void draw_input(tui_t *t) {
|
||
if (!t->input)
|
||
return;
|
||
WINDOW *w = t->input;
|
||
if (HAS_COLOR())
|
||
wbkgd(w, COLOR_PAIR(CP_BOX));
|
||
werase(w);
|
||
int bh, bw;
|
||
getmaxyx(w, bh, bw);
|
||
int h = t->input_rows > 0 ? t->input_rows : 1;
|
||
int pad = (bh - h - 1) / 2; /* rows above the text */
|
||
if (pad < 0)
|
||
pad = 0;
|
||
int bar = HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX_ACCENT) : 0;
|
||
for (int r = 0; r < bh; r++)
|
||
put_wc(w, r, 0, L'┃', bar);
|
||
draw_mode_line(t, w, pad + h, bw);
|
||
int cr, cc;
|
||
input_layout(t, NULL, NULL, &cr, &cc);
|
||
int top = cr >= h ? cr - h + 1 : 0;
|
||
if (top > 0)
|
||
put_wc(w, pad, 1, g_ascii ? L'^' : L'↑',
|
||
HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX_MUTED) : 0);
|
||
if (t->in_len == 0) {
|
||
const char *hint =
|
||
t->busy ? "Enter queues a message"
|
||
: (g_ascii ? "Ask anything... \"Fix a TODO in this repo\""
|
||
: "Ask anything… \"Fix a TODO in this repo\"");
|
||
draw_ansi_at(w, pad, IN_COL, bw - IN_COL - 1, hint, CP_BOX_MUTED, 0, 0);
|
||
wmove(w, pad, IN_COL);
|
||
wrefresh(w);
|
||
return;
|
||
}
|
||
input_draw_t d = {w, top, h, HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX) : 0, pad};
|
||
input_layout(t, input_draw_cell, &d, &cr, &cc);
|
||
if (cc >= bw)
|
||
cc = bw - 1;
|
||
wmove(w, pad + cr - top, cc);
|
||
wrefresh(w);
|
||
}
|
||
|
||
/* Popup of up to `room` rows at logw (y, x): growing upward from y when
|
||
* up, else downward. Rows are recorded for mouse picks. */
|
||
static int g_comp_y0 = -1, g_comp_x0, g_comp_w;
|
||
|
||
static void draw_completion(tui_t *t, int y, int x, int bw, int room, int up) {
|
||
t->comp_drawn = t->comp_n;
|
||
g_comp_y0 = -1;
|
||
if (t->comp_n <= 0 || room < 1 || bw < 4)
|
||
return;
|
||
int n = t->comp_n < room ? t->comp_n : room;
|
||
int y0 = up ? y - n + 1 : y;
|
||
g_comp_y0 = t->log_y + y0;
|
||
g_comp_x0 = t->log_x + x;
|
||
g_comp_w = bw;
|
||
for (int i = 0; i < n; i++) {
|
||
const comp_item_t *c = &t->comp[i];
|
||
int sel = i == t->comp_sel;
|
||
int attrs = HAS_COLOR() ? (int)COLOR_PAIR(sel ? CP_SEL : CP_BOX)
|
||
: (sel ? A_REVERSE : 0);
|
||
for (int k = 0; k < bw; k++)
|
||
put_wc(t->logw, y0 + i, x + k, L' ', attrs);
|
||
char line[420];
|
||
snprintf(line, sizeof(line), "%-22s ", c->text);
|
||
int used = draw_ansi_at(t->logw, y0 + i, x + 1, bw - 2, line,
|
||
sel ? CP_SEL : CP_BOX_BRIGHT, sel, 0);
|
||
if (bw - 2 - used > 0)
|
||
draw_ansi_at(t->logw, y0 + i, x + 1 + used, bw - 2 - used, c->desc,
|
||
sel ? CP_SEL : CP_BOX_MUTED, 0, 0);
|
||
}
|
||
}
|
||
|
||
/* Task list panel above the status bar while todo_write has open items. */
|
||
static int todo_open_items(const char *todo, int *total) {
|
||
int open = 0, n = 0;
|
||
for (const char *p = todo; p && *p;) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
if (len > 0) {
|
||
n++;
|
||
if (!(len >= 3 && strncmp(p, "[x]", 3) == 0))
|
||
open++;
|
||
}
|
||
p = nl ? nl + 1 : p + len;
|
||
}
|
||
if (total)
|
||
*total = n;
|
||
return open;
|
||
}
|
||
|
||
static void draw_todo(tui_t *t) {
|
||
if (!t->todow)
|
||
return;
|
||
werase(t->todow);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->todow, COLOR_PAIR(CP_PANEL));
|
||
int total = 0, open = todo_open_items(t->todo, &total);
|
||
char head[96];
|
||
snprintf(head, sizeof(head), g_ascii ? " Tasks %d/%d done (Ctrl+T hides)"
|
||
: " Tasks %d/%d done · Ctrl+T hides",
|
||
total - open, total);
|
||
draw_ansi_line(t->todow, 0, getmaxx(t->todow) - 1, head, CP_PRIMARY, 1, 0);
|
||
int row = 1, skipped = 0;
|
||
int room = t->todo_rows - 1;
|
||
/* keep the first unfinished item visible */
|
||
int first_open = 0, idx = 0;
|
||
for (const char *p = t->todo; p && *p; idx++) {
|
||
const char *nl = strchr(p, '\n');
|
||
if (strncmp(p, "[x]", 3) != 0) {
|
||
first_open = idx;
|
||
break;
|
||
}
|
||
p = nl ? nl + 1 : p + strlen(p);
|
||
}
|
||
int skip = first_open > 1 ? first_open - 1 : 0;
|
||
if (total - skip < room)
|
||
skip = total > room ? total - room : 0;
|
||
idx = 0;
|
||
for (const char *p = t->todo; p && *p && row <= room;) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
if (len >= 3 && idx++ >= skip) {
|
||
const char *mark = "○";
|
||
int pair = CP_PANEL, dim = 0;
|
||
if (strncmp(p, "[x]", 3) == 0) {
|
||
mark = "✓";
|
||
pair = CP_SUCCESS;
|
||
dim = 1;
|
||
} else if (strncmp(p, "[~]", 3) == 0) {
|
||
mark = "▸";
|
||
pair = CP_PRIMARY;
|
||
}
|
||
char line[400];
|
||
if (row == room && total - idx > 0) {
|
||
snprintf(line, sizeof(line), " … %d more", total - idx + 1);
|
||
draw_ansi_line(t->todow, row, getmaxx(t->todow) - 1, line, CP_MUTED, 0, 1);
|
||
break;
|
||
}
|
||
const char *txt = p + 3;
|
||
while (txt < p + len && *txt == ' ')
|
||
txt++;
|
||
snprintf(line, sizeof(line), " %s %.*s", mark, (int)(p + len - txt),
|
||
txt);
|
||
draw_ansi_line(t->todow, row++, getmaxx(t->todow) - 1, line, pair, 0, dim);
|
||
} else if (len >= 3) {
|
||
skipped++;
|
||
}
|
||
p = nl ? nl + 1 : p + len;
|
||
}
|
||
(void)skipped;
|
||
wrefresh(t->todow);
|
||
}
|
||
|
||
/* --- start screen ---------------------------------------------------------- */
|
||
|
||
/* 3-row block font for "cstagent"; each glyph is 4 columns wide. */
|
||
static const char *const LOGO[8][3] = {
|
||
{"█▀▀▀", "█ ", "▀▀▀▀"}, /* c */
|
||
{"█▀▀▀", "▀▀▀█", "▀▀▀▀"}, /* s */
|
||
{"▀█▀▀", " █ ", " ▀▀▀"}, /* t */
|
||
{"█▀▀█", "█▀▀█", "▀ ▀"}, /* a */
|
||
{"█▀▀▀", "█ ▀█", "▀▀▀▀"}, /* g */
|
||
{"█▀▀▀", "█▀▀ ", "▀▀▀▀"}, /* e */
|
||
{"█▀▀▄", "█ █", "▀ ▀"}, /* n */
|
||
{"▀█▀▀", " █ ", " ▀▀▀"}, /* t */
|
||
};
|
||
#define LOGO_W (8 * 5 - 1)
|
||
#define LOGO_H 3
|
||
|
||
static const char *const TIPS[] = {
|
||
"Tab switches between Build and Plan (read-only) mode",
|
||
"Shift+Tab cycles approval: ask, auto, yolo",
|
||
"Ctrl+P opens the command palette",
|
||
"Type @ to mention a file in your message",
|
||
"/effort sets reasoning effort: low, medium, high, max",
|
||
"/model lists the models your API key can use",
|
||
"/undo reverts the file changes of the last turn",
|
||
"/provider switches provider; keys are saved per provider",
|
||
"Click a tool card or Thought to expand it",
|
||
"Shift+drag selects text while mouse support is on",
|
||
};
|
||
|
||
static void draw_home(tui_t *t) {
|
||
int h, w;
|
||
getmaxyx(t->logw, h, w);
|
||
int box_top = t->in_y - t->log_y;
|
||
int box_bot = box_top + t->in_h;
|
||
if (g_ascii || !g_utf8) {
|
||
int x = (w - 8) / 2, y = box_top - 2;
|
||
if (y >= 0) {
|
||
draw_ansi_at(t->logw, y, x, 3, "cst", CP_LOGO_DIM, 1, 0);
|
||
draw_ansi_at(t->logw, y, x + 3, 5, "agent", CP_BRIGHT, 1, 0);
|
||
}
|
||
} else if (box_top - LOGO_H - 1 >= 0 && w >= LOGO_W) {
|
||
int x0 = (w - LOGO_W) / 2, y0 = box_top - LOGO_H - 1;
|
||
for (int g = 0; g < 8; g++)
|
||
for (int r = 0; r < LOGO_H; r++)
|
||
draw_ansi_at(t->logw, y0 + r, x0 + g * 5, 4, LOGO[g][r],
|
||
g < 3 ? CP_LOGO_DIM : CP_BRIGHT, 0, 0);
|
||
}
|
||
int right = t->in_x - t->log_x + t->in_w;
|
||
if (box_bot + 1 < h) {
|
||
char hint[96];
|
||
snprintf(hint, sizeof(hint),
|
||
"\033[97mtab\033[39m modes \033[97mctrl+p\033[39m commands");
|
||
int hw = vis_cols(hint);
|
||
if (right - hw >= 0)
|
||
draw_ansi_at(t->logw, box_bot + 1, right - hw, hw, hint, CP_MUTED, 0, 0);
|
||
}
|
||
if (t->tip && box_bot + 3 < h) {
|
||
char tip[256];
|
||
snprintf(tip, sizeof(tip), "\033[33m%s Tip\033[39m %s",
|
||
g_ascii ? "*" : "●", t->tip);
|
||
int tw = vis_cols(tip);
|
||
if (tw > w - 2)
|
||
tw = w - 2;
|
||
draw_ansi_at(t->logw, box_bot + 3, (w - tw) / 2, tw, tip, CP_MUTED, 0, 0);
|
||
}
|
||
const char *ext = cst_extensions_report();
|
||
if (ext[0] && box_bot + 5 < h) {
|
||
int ew = vis_cols(ext);
|
||
if (ew > w - 2)
|
||
ew = w - 2;
|
||
draw_ansi_at(t->logw, box_bot + 5, (w - ew) / 2, ew, ext, CP_MUTED, 0, 0);
|
||
}
|
||
int room = h - box_bot;
|
||
draw_completion(t, box_bot, t->in_x - t->log_x, t->in_w, room, 0);
|
||
}
|
||
|
||
/* --- sidebar --------------------------------------------------------------- */
|
||
|
||
typedef struct {
|
||
char path[200];
|
||
int add, del;
|
||
} mod_file_t;
|
||
|
||
/* Files changed by write tools this session, newest stats merged per path. */
|
||
static int collect_modified(const tui_t *t, mod_file_t *out, int max) {
|
||
int n = 0;
|
||
for (int i = 0; i < t->hist.n; i++) {
|
||
const block_t *b = &t->hist.items[i];
|
||
if (b->kind != BLK_TOOL || !cst_tool_is_write(b->tool_name) ||
|
||
strncmp(b->meta, "denied", 6) == 0 || !b->body || !b->body[0])
|
||
continue;
|
||
char path[256];
|
||
diff_extract_path(b->body, b->title, path, sizeof(path));
|
||
if (!path[0])
|
||
continue;
|
||
diff_stats_t st;
|
||
diff_count_stats(b->body, &st);
|
||
int k = 0;
|
||
while (k < n && strcmp(out[k].path, path) != 0)
|
||
k++;
|
||
if (k == n) {
|
||
if (n >= max)
|
||
continue;
|
||
snprintf(out[n].path, sizeof(out[n].path), "%.199s", path);
|
||
out[n].add = out[n].del = 0;
|
||
n++;
|
||
}
|
||
out[k].add += st.additions;
|
||
out[k].del += st.deletions;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
static void draw_side(tui_t *t) {
|
||
if (!t->side)
|
||
return;
|
||
WINDOW *s = t->side;
|
||
if (HAS_COLOR())
|
||
wbkgd(s, COLOR_PAIR(CP_SIDE));
|
||
werase(s);
|
||
int h, w;
|
||
getmaxyx(s, h, w);
|
||
int x = 2, mw = w - 4, y = 1;
|
||
char line[512];
|
||
|
||
const char *title = t->title[0] ? t->title : "New session";
|
||
size_t fit = utf8_fit_cols(title, strlen(title), mw);
|
||
draw_ansi_at(s, y++, x, mw, title, CP_SIDE_BRIGHT, 1, 0);
|
||
if (fit < strlen(title) && y < h)
|
||
draw_ansi_at(s, y++, x, mw, title + fit, CP_SIDE_BRIGHT, 1, 0);
|
||
y++;
|
||
|
||
long used;
|
||
int pct = ctx_percent(t, &used);
|
||
draw_ansi_at(s, y++, x, mw, "Context", CP_SIDE_BRIGHT, 1, 0);
|
||
snprintf(line, sizeof(line), "%ld tokens", used);
|
||
draw_ansi_at(s, y++, x, mw, line, CP_SIDE_MUTED, 0, 0);
|
||
snprintf(line, sizeof(line), "%d%% used", pct);
|
||
draw_ansi_at(s, y++, x, mw, line, CP_SIDE_MUTED, 0, 0);
|
||
y++;
|
||
|
||
int bottom = h - 3; /* keep two rows for where + version */
|
||
char appr[64];
|
||
approval_label(t, appr, sizeof(appr));
|
||
if (y + 2 < bottom) {
|
||
draw_ansi_at(s, y++, x, mw, "Approval", CP_SIDE_BRIGHT, 1, 0);
|
||
draw_ansi_at(s, y++, x, mw, appr,
|
||
approval_mode(t) == CST_APPROVAL_YOLO ? CP_SIDE_PRIMARY
|
||
: CP_SIDE_MUTED,
|
||
0, 0);
|
||
y++;
|
||
}
|
||
|
||
int total = 0;
|
||
if (t->show_todo && t->todo && todo_open_items(t->todo, &total) >= 0 &&
|
||
total > 0 && y + 2 < bottom) {
|
||
draw_ansi_at(s, y++, x, mw, "Todo", CP_SIDE_BRIGHT, 1, 0);
|
||
for (const char *p = t->todo; *p && y < bottom - 1;) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
if (len >= 3) {
|
||
int pair = CP_SIDE;
|
||
const char *mark = "[ ]";
|
||
if (strncmp(p, "[x]", 3) == 0)
|
||
pair = CP_SIDE_MUTED, mark = "[✓]";
|
||
else if (strncmp(p, "[~]", 3) == 0)
|
||
pair = CP_SIDE_PRIMARY, mark = "[•]";
|
||
const char *txt = p + 3;
|
||
while (txt < p + len && *txt == ' ')
|
||
txt++;
|
||
snprintf(line, sizeof(line), "%s %.*s", mark, (int)(p + len - txt),
|
||
txt);
|
||
draw_ansi_at(s, y++, x, mw, line, pair, 0, 0);
|
||
}
|
||
p = nl ? nl + 1 : p + len;
|
||
}
|
||
y++;
|
||
}
|
||
|
||
int mready = 0, mtotal = 0;
|
||
cst_mcp_counts(&mready, &mtotal);
|
||
if (mtotal > 0 && y + 2 < bottom) {
|
||
cst_mcp_info_t mi[CST_MCP_MAX];
|
||
int n = cst_mcp_info(mi, CST_MCP_MAX);
|
||
snprintf(line, sizeof(line), "MCP %d/%d", mready, mtotal);
|
||
draw_ansi_at(s, y++, x, mw, line, CP_SIDE_BRIGHT, 1, 0);
|
||
for (int i = 0; i < n && y < bottom - 1; i++) {
|
||
if (mi[i].state == CST_MCP_OFF)
|
||
continue;
|
||
int ok = mi[i].state == CST_MCP_READY;
|
||
int pend = mi[i].state == CST_MCP_STARTING;
|
||
int dx = x + draw_ansi_at(s, y, x, 2, g_ascii ? "*" : "●",
|
||
ok ? CP_SIDE_SUCCESS
|
||
: pend ? CP_SIDE_MUTED : CP_SIDE_PRIMARY,
|
||
0, 0);
|
||
if (ok)
|
||
snprintf(line, sizeof(line), "%.63s · %d tools", mi[i].name,
|
||
mi[i].ntools);
|
||
else
|
||
snprintf(line, sizeof(line), "%.63s · %s", mi[i].name,
|
||
pend ? "starting" : "failed");
|
||
draw_ansi_at(s, y++, dx + 1, mw - (dx + 1 - x), line, CP_SIDE_MUTED, 0,
|
||
0);
|
||
}
|
||
y++;
|
||
}
|
||
|
||
mod_file_t mods[16];
|
||
int nm = collect_modified(t, mods, 16);
|
||
if (nm > 0 && y + 2 < bottom) {
|
||
draw_ansi_at(s, y++, x, mw, "Modified Files", CP_SIDE_BRIGHT, 1, 0);
|
||
for (int i = 0; i < nm && y < bottom; i++) {
|
||
char stat[48];
|
||
snprintf(stat, sizeof(stat), "+%d -%d", mods[i].add, mods[i].del);
|
||
int sw = (int)strlen(stat);
|
||
draw_ansi_at(s, y, x, mw - sw - 1, mods[i].path, CP_SIDE_MUTED, 0, 0);
|
||
char pl[16], mi[16];
|
||
snprintf(pl, sizeof(pl), "+%d", mods[i].add);
|
||
snprintf(mi, sizeof(mi), "-%d", mods[i].del);
|
||
int px = x + mw - sw;
|
||
px += draw_ansi_at(s, y, px, (int)strlen(pl), pl, CP_SIDE_SUCCESS, 0, 0);
|
||
draw_ansi_at(s, y, px + 1, (int)strlen(mi), mi, CP_SIDE_PRIMARY, 0, 0);
|
||
y++;
|
||
}
|
||
}
|
||
|
||
if (h >= 4) {
|
||
draw_ansi_at(s, h - 3, x, mw, t->where, CP_SIDE_MUTED, 0, 0);
|
||
int dx = x + draw_ansi_at(s, h - 2, x, 2, g_ascii ? "*" : "●",
|
||
CP_SIDE_SUCCESS, 0, 0);
|
||
dx += draw_ansi_at(s, h - 2, dx + 1, mw, "CST-Agent", CP_SIDE_BRIGHT, 1, 0);
|
||
draw_ansi_at(s, h - 2, dx + 2, mw - dx, CST_VERSION, CP_SIDE_MUTED, 0, 0);
|
||
}
|
||
wrefresh(s);
|
||
}
|
||
|
||
static void draw_log(tui_t *t) {
|
||
if (!t->logw)
|
||
return;
|
||
werase(t->logw);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->logw, COLOR_PAIR(CP_PANEL));
|
||
int h, w;
|
||
getmaxyx(t->logw, h, w);
|
||
if (h < 1 || w < 1)
|
||
return;
|
||
if (t->home) {
|
||
draw_home(t);
|
||
wrefresh(t->logw);
|
||
if (t->input) {
|
||
touchwin(t->input);
|
||
wrefresh(t->input);
|
||
}
|
||
return;
|
||
}
|
||
|
||
dlines_t dl = {0};
|
||
build_display(t, &dl, w);
|
||
int total = dl.n > 0 ? dl.n : 1;
|
||
int max_scroll = total - h;
|
||
if (max_scroll < 0)
|
||
max_scroll = 0;
|
||
if (t->log_scroll > max_scroll)
|
||
t->log_scroll = max_scroll;
|
||
if (t->log_scroll < 0)
|
||
t->log_scroll = 0;
|
||
int start = total - h - t->log_scroll;
|
||
if (start < 0)
|
||
start = 0;
|
||
|
||
if (t->row_blk_cap < h) {
|
||
int *nb = realloc(t->row_blk, (size_t)h * sizeof(int));
|
||
if (nb) {
|
||
t->row_blk = nb;
|
||
t->row_blk_cap = h;
|
||
}
|
||
}
|
||
for (int row = 0; row < h; row++) {
|
||
int li = start + row;
|
||
if (row < t->row_blk_cap)
|
||
t->row_blk[row] = li >= 0 && li < dl.n ? dl.lines[li].blk : -1;
|
||
if (li < 0 || li >= dl.n)
|
||
continue;
|
||
dline_t *L = &dl.lines[li];
|
||
if (L->fill && HAS_COLOR())
|
||
for (int x = 0; x < w; x++)
|
||
put_wc(t->logw, row, x, L' ', (int)COLOR_PAIR(L->fill));
|
||
draw_ansi_at(t->logw, row, L->indent, w - L->indent, L->text, L->pair,
|
||
L->bold, L->dim);
|
||
if (L->bar)
|
||
put_wc(t->logw, row, 0, L'┃', HAS_COLOR() ? (int)COLOR_PAIR(L->bar) : 0);
|
||
}
|
||
if (t->log_scroll > 0 && h > 2) {
|
||
char more[128];
|
||
snprintf(more, sizeof(more),
|
||
g_ascii ? " -- %d more lines below - End/PgDn - %d above -- "
|
||
: " ── %d more lines below · End/PgDn · %d above ── ",
|
||
t->log_scroll, start);
|
||
wmove(t->logw, h - 1, 0);
|
||
wclrtoeol(t->logw);
|
||
wattron(t->logw, A_REVERSE);
|
||
draw_ansi_line(t->logw, h - 1, w, more, CP_PRIMARY, 1, 0);
|
||
wattroff(t->logw, A_REVERSE);
|
||
}
|
||
dlines_free(&dl);
|
||
draw_completion(t, h - 1, 0, w < 90 ? w : 90, h - 1, 1);
|
||
wrefresh(t->logw);
|
||
}
|
||
|
||
static int is_home(const tui_t *t) {
|
||
return t->hist.n == 0 && !t->as_live && !t->busy;
|
||
}
|
||
|
||
static void layout(tui_t *t);
|
||
|
||
static void redraw_all(tui_t *t) {
|
||
if (is_home(t) != t->home)
|
||
layout(t);
|
||
theme_paint_root();
|
||
draw_log(t);
|
||
draw_todo(t);
|
||
draw_side(t);
|
||
draw_status(t);
|
||
draw_input(t);
|
||
}
|
||
|
||
/* --- session reload ------------------------------------------------------- */
|
||
|
||
static void push_system_line(tui_t *t, const char *fmt, ...) {
|
||
char tmp[1024];
|
||
va_list ap;
|
||
va_start(ap, fmt);
|
||
vsnprintf(tmp, sizeof(tmp), fmt, ap);
|
||
va_end(ap);
|
||
block_t *b = blocks_push(&t->hist);
|
||
if (!b)
|
||
return;
|
||
b->kind = BLK_SYSTEM;
|
||
block_set_body(b, tmp, strlen(tmp));
|
||
}
|
||
|
||
typedef struct {
|
||
char id[CTL_MAX_ID];
|
||
char title[160];
|
||
} call_title_t;
|
||
|
||
/* Native tool_calls JSON → per-call one-line titles for replayed cards. */
|
||
static void remember_call_titles(const char *json, call_title_t *v, int *n) {
|
||
cJSON *arr = cJSON_Parse(json);
|
||
const cJSON *it;
|
||
cJSON_ArrayForEach(it, arr) {
|
||
const cJSON *id = cJSON_GetObjectItemCaseSensitive(it, "id");
|
||
const cJSON *fn = cJSON_GetObjectItemCaseSensitive(it, "function");
|
||
const cJSON *name = cJSON_GetObjectItemCaseSensitive(fn, "name");
|
||
const cJSON *args = cJSON_GetObjectItemCaseSensitive(fn, "arguments");
|
||
if (!cJSON_IsString(id) || !cJSON_IsString(name))
|
||
continue;
|
||
ctl_tool_call_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
if (cst_tool_call_from_native(&c, id->valuestring, name->valuestring,
|
||
cJSON_IsString(args) ? args->valuestring
|
||
: "{}") == 0) {
|
||
int k = *n < 64 ? (*n)++ : 63;
|
||
snprintf(v[k].id, sizeof(v[k].id), "%s", id->valuestring);
|
||
tool_arg_summary(&c, v[k].title, sizeof(v[k].title));
|
||
}
|
||
for (int i = 0; i < c.nargs; i++)
|
||
free(c.args[i].value);
|
||
}
|
||
cJSON_Delete(arr);
|
||
}
|
||
|
||
static void reload_hist_from_session(tui_t *t) {
|
||
live_end_cleanup(t);
|
||
blocks_clear(&t->hist);
|
||
t->log_scroll = 0;
|
||
t->think_idx = -1;
|
||
t->last_tool_idx = -1;
|
||
t->focus_think_idx = -1;
|
||
|
||
cst_session_t *s = cst_agent_session(t->agent);
|
||
if (!s)
|
||
return;
|
||
cst_session_lock(s);
|
||
int n = cst_session_count(s);
|
||
int non_sys = 0;
|
||
for (int i = 0; i < n; i++) {
|
||
const cst_message_t *m = cst_session_get(s, i);
|
||
if (m && m->role != CST_ROLE_SYSTEM)
|
||
non_sys++;
|
||
}
|
||
int skip = non_sys > REPLAY_MSGS ? non_sys - REPLAY_MSGS : 0;
|
||
int seen = 0;
|
||
static call_title_t titles[64];
|
||
int ntitles = 0;
|
||
for (int i = 0; i < n; i++) {
|
||
const cst_message_t *m = cst_session_get(s, i);
|
||
if (!m || m->role == CST_ROLE_SYSTEM)
|
||
continue;
|
||
if (seen++ < skip)
|
||
continue;
|
||
if (m->role == CST_ROLE_ASSISTANT && m->tool_calls_json)
|
||
remember_call_titles(m->tool_calls_json, titles, &ntitles);
|
||
if (m->role == CST_ROLE_ASSISTANT &&
|
||
(!m->content || !m->content[strspn(m->content, " \t\r\n")]))
|
||
continue; /* tool calls only */
|
||
block_t *b = blocks_push(&t->hist);
|
||
if (!b)
|
||
continue;
|
||
if (msg_is_tool_result(m)) {
|
||
b->kind = BLK_TOOL;
|
||
snprintf(b->tool_name, sizeof(b->tool_name), "%s",
|
||
m->tool_name[0] ? m->tool_name : "tool");
|
||
snprintf(b->tool_id, sizeof(b->tool_id), "%s", m->tool_call_id);
|
||
snprintf(b->meta, sizeof(b->meta), "done");
|
||
const char *body;
|
||
size_t blen;
|
||
tool_result_view(m->content, &body, &blen);
|
||
block_set_body(b, body, blen);
|
||
{
|
||
char path[256];
|
||
diff_extract_path(b->body, NULL, path, sizeof(path));
|
||
const char *title = path;
|
||
for (int k = 0; !path[0] && k < ntitles; k++)
|
||
if (strcmp(titles[k].id, m->tool_call_id) == 0)
|
||
title = titles[k].title;
|
||
block_set_title(b, title);
|
||
}
|
||
/* OpenCode: write/edit cards open (still fold at DIFF_FOLD_LINES). */
|
||
if (cst_tool_is_write(b->tool_name))
|
||
b->expanded = 1;
|
||
else
|
||
b->expanded = 0;
|
||
t->last_tool_idx = t->hist.n - 1;
|
||
} else if (m->role == CST_ROLE_USER) {
|
||
b->kind = BLK_USER;
|
||
block_set_title(b, "You");
|
||
block_set_body(b, m->content ? m->content : "",
|
||
m->content ? strlen(m->content) : 0);
|
||
b->expanded = 1;
|
||
} else if (m->role == CST_ROLE_ASSISTANT) {
|
||
b->kind = BLK_ASSISTANT;
|
||
block_set_title(b, "Assistant");
|
||
if (m->content)
|
||
block_set_body(b, m->content, strlen(m->content));
|
||
b->expanded = 1;
|
||
block_render_assistant(b);
|
||
}
|
||
}
|
||
cst_session_unlock(s);
|
||
}
|
||
|
||
/* --- layout --------------------------------------------------------------- */
|
||
|
||
static int input_visual_rows(const tui_t *t) {
|
||
int cr, cc;
|
||
int rows = input_layout(t, NULL, NULL, &cr, &cc);
|
||
if (rows < 1)
|
||
rows = 1;
|
||
if (rows > INPUT_MAX_ROWS)
|
||
rows = INPUT_MAX_ROWS;
|
||
return rows;
|
||
}
|
||
|
||
static void apply_mouse(const tui_t *t) {
|
||
mousemask(t->mouse_on ? (mmask_t)ALL_MOUSE_EVENTS : 0, NULL);
|
||
mouseinterval(0);
|
||
}
|
||
|
||
static void free_windows(tui_t *t) {
|
||
WINDOW **ws[] = {&t->header, &t->logw, &t->status,
|
||
&t->input, &t->todow, &t->side};
|
||
for (size_t i = 0; i < sizeof(ws) / sizeof(ws[0]); i++)
|
||
drop_win(ws[i]);
|
||
}
|
||
|
||
static int clampi(int v, int lo, int hi) {
|
||
return v < lo ? lo : v > hi ? hi : v;
|
||
}
|
||
|
||
/* Start screen: centered logo + box. Session: transcript column, input box
|
||
* and status row at the bottom, optional sidebar on the right. */
|
||
static void layout(tui_t *t) {
|
||
getmaxyx(stdscr, t->rows, t->cols);
|
||
if (t->rows < 4)
|
||
t->rows = 4;
|
||
if (t->cols < 20)
|
||
t->cols = 20;
|
||
theme_paint_root();
|
||
free_windows(t);
|
||
t->home = is_home(t);
|
||
t->side_w = 0;
|
||
t->todo_rows = 0;
|
||
int pad = t->rows >= 20 ? 1 : 0;
|
||
|
||
if (t->home) {
|
||
t->in_w = t->cols - 8 < 72 ? t->cols - 8 : 72;
|
||
if (t->in_w < 24)
|
||
t->in_w = t->cols - 2;
|
||
t->in_x = (t->cols - t->in_w) / 2;
|
||
} else {
|
||
if (t->show_side && t->cols >= 120)
|
||
t->side_w = clampi(t->cols / 4, 32, 42);
|
||
int mw = t->cols - t->side_w;
|
||
t->in_x = 1;
|
||
t->in_w = mw - 2;
|
||
}
|
||
t->input_rows = input_visual_rows(t);
|
||
t->in_h = t->input_rows + 1 + 2 * pad;
|
||
if (t->in_h > t->rows - 2) {
|
||
t->input_rows = clampi(t->rows - 3, 1, INPUT_MAX_ROWS);
|
||
t->in_h = t->input_rows + 1;
|
||
}
|
||
|
||
if (t->home) {
|
||
int block = LOGO_H + 1 + t->in_h + 4;
|
||
int top = (t->rows - 1 - block) / 2;
|
||
t->in_y = clampi(top + LOGO_H + 1, 0, t->rows - 1 - t->in_h);
|
||
t->log_x = t->log_y = 0;
|
||
t->logw = newwin(t->rows - 1, t->cols, 0, 0);
|
||
t->status = newwin(1, t->cols - 2, t->rows - 1, 1);
|
||
} else {
|
||
int mw = t->cols - t->side_w;
|
||
t->in_y = t->rows - 1 - t->in_h;
|
||
int total = 0;
|
||
int open = t->show_todo && !t->side_w ? todo_open_items(t->todo, &total) : 0;
|
||
if (open && t->rows >= 16)
|
||
t->todo_rows = 1 + (total < TODO_PANEL_MAX ? total : TODO_PANEL_MAX);
|
||
t->log_x = 1;
|
||
t->log_y = t->rows >= 12 ? 1 : 0;
|
||
int log_h = t->in_y - 1 - t->todo_rows - t->log_y;
|
||
if (log_h < 1) {
|
||
t->todo_rows = 0;
|
||
log_h = t->in_y - t->log_y > 0 ? t->in_y - t->log_y : 1;
|
||
}
|
||
t->logw = newwin(log_h, mw - 2, t->log_y, 1);
|
||
if (t->todo_rows)
|
||
t->todow = newwin(t->todo_rows, mw - 2, t->log_y + log_h, 1);
|
||
t->status = newwin(1, mw - 2, t->rows - 1, 1);
|
||
if (t->side_w)
|
||
t->side = newwin(t->rows, t->side_w, 0, mw);
|
||
}
|
||
t->input = newwin(t->in_h, t->in_w, t->in_y, t->in_x);
|
||
if (HAS_COLOR()) {
|
||
wbkgdset(t->logw, COLOR_PAIR(CP_PANEL));
|
||
wbkgdset(t->status, COLOR_PAIR(CP_PANEL));
|
||
wbkgdset(t->input, COLOR_PAIR(CP_BOX));
|
||
if (t->side)
|
||
wbkgdset(t->side, COLOR_PAIR(CP_SIDE));
|
||
}
|
||
scrollok(t->logw, FALSE);
|
||
keypad(t->input, TRUE);
|
||
wtimeout(t->input, 80);
|
||
apply_mouse(t);
|
||
}
|
||
|
||
static void redraw_all(tui_t *t);
|
||
static int start_turn(tui_t *t, char *prompt); /* takes ownership of prompt */
|
||
|
||
static void layout_maybe_grow_input(tui_t *t) {
|
||
int need = input_visual_rows(t);
|
||
if (need != t->input_rows) {
|
||
layout(t);
|
||
wtimeout(t->input, 80);
|
||
redraw_all(t);
|
||
} else {
|
||
draw_input(t);
|
||
}
|
||
}
|
||
|
||
static void update_completion(tui_t *t);
|
||
|
||
static void input_changed(tui_t *t) {
|
||
t->hist_browse = -1;
|
||
t->comp_off = 0;
|
||
update_completion(t);
|
||
}
|
||
|
||
static void input_insert(tui_t *t, const char *p, size_t n) {
|
||
if (t->in_len + n >= INPUT_CAP)
|
||
return;
|
||
memmove(t->inbuf + t->cur + n, t->inbuf + t->cur, t->in_len - t->cur);
|
||
memcpy(t->inbuf + t->cur, p, n);
|
||
t->in_len += n;
|
||
t->cur += n;
|
||
t->inbuf[t->in_len] = 0;
|
||
if (!t->in_paste)
|
||
input_changed(t);
|
||
}
|
||
|
||
static void input_erase(tui_t *t, size_t from, size_t to) {
|
||
if (to > t->in_len)
|
||
to = t->in_len;
|
||
if (from >= to)
|
||
return;
|
||
memmove(t->inbuf + from, t->inbuf + to, t->in_len - to);
|
||
t->in_len -= to - from;
|
||
t->inbuf[t->in_len] = 0;
|
||
if (t->cur >= to)
|
||
t->cur -= to - from;
|
||
else if (t->cur > from)
|
||
t->cur = from;
|
||
input_changed(t);
|
||
}
|
||
|
||
static void input_set(tui_t *t, const char *s) {
|
||
snprintf(t->inbuf, sizeof(t->inbuf), "%s", s ? s : "");
|
||
t->in_len = strlen(t->inbuf);
|
||
t->cur = t->in_len;
|
||
input_changed(t);
|
||
}
|
||
|
||
static void input_clear(tui_t *t) { input_set(t, ""); }
|
||
|
||
static void input_put_wc(tui_t *t, wchar_t wc) {
|
||
char mb[8];
|
||
mbstate_t st;
|
||
memset(&st, 0, sizeof(st));
|
||
size_t n = wcrtomb(mb, wc, &st);
|
||
if (n == (size_t)-1 || n == 0)
|
||
return;
|
||
input_insert(t, mb, n);
|
||
}
|
||
|
||
static void input_put_byte(tui_t *t, char ch) { input_insert(t, &ch, 1); }
|
||
|
||
static size_t next_len(const tui_t *t, size_t at) {
|
||
if (at >= t->in_len)
|
||
return 0;
|
||
size_t i = at + 1;
|
||
while (i < t->in_len && ((unsigned char)t->inbuf[i] & 0xC0) == 0x80)
|
||
i++;
|
||
return i - at;
|
||
}
|
||
|
||
static size_t line_start(const tui_t *t, size_t at) {
|
||
while (at > 0 && t->inbuf[at - 1] != '\n')
|
||
at--;
|
||
return at;
|
||
}
|
||
|
||
static size_t line_end(const tui_t *t, size_t at) {
|
||
while (at < t->in_len && t->inbuf[at] != '\n')
|
||
at++;
|
||
return at;
|
||
}
|
||
|
||
static int is_word(unsigned char c) { return c >= 0x80 || isalnum(c) || c == '_'; }
|
||
|
||
static size_t word_left(const tui_t *t, size_t at) {
|
||
while (at > 0 && !is_word((unsigned char)t->inbuf[at - 1]))
|
||
at--;
|
||
while (at > 0 && is_word((unsigned char)t->inbuf[at - 1]))
|
||
at--;
|
||
return at;
|
||
}
|
||
|
||
static size_t word_right(const tui_t *t, size_t at) {
|
||
while (at < t->in_len && !is_word((unsigned char)t->inbuf[at]))
|
||
at++;
|
||
while (at < t->in_len && is_word((unsigned char)t->inbuf[at]))
|
||
at++;
|
||
return at;
|
||
}
|
||
|
||
/* Move to the previous/next logical line keeping the column (in chars).
|
||
* Returns 0 when already on the first/last line. */
|
||
static int input_line_move(tui_t *t, int dir) {
|
||
size_t ls = line_start(t, t->cur);
|
||
int col = 0;
|
||
for (size_t i = ls; i < t->cur; i += next_len(t, i))
|
||
col++;
|
||
size_t target;
|
||
if (dir < 0) {
|
||
if (ls == 0)
|
||
return 0;
|
||
target = line_start(t, ls - 1);
|
||
} else {
|
||
size_t le = line_end(t, t->cur);
|
||
if (le >= t->in_len)
|
||
return 0;
|
||
target = le + 1;
|
||
}
|
||
size_t te = line_end(t, target);
|
||
while (col-- > 0 && target < te)
|
||
target += next_len(t, target);
|
||
t->cur = target;
|
||
return 1;
|
||
}
|
||
|
||
static void prompt_hist_push(tui_t *t, const char *prompt) {
|
||
if (!prompt || !prompt[0])
|
||
return;
|
||
if (t->prompt_hist_n >= PROMPT_HIST_CAP) {
|
||
free(t->prompt_hist[0]);
|
||
memmove(t->prompt_hist, t->prompt_hist + 1,
|
||
(PROMPT_HIST_CAP - 1) * sizeof(char *));
|
||
t->prompt_hist_n = PROMPT_HIST_CAP - 1;
|
||
}
|
||
t->prompt_hist[t->prompt_hist_n++] = strdup(prompt);
|
||
t->hist_browse = -1;
|
||
}
|
||
|
||
static void hist_load_into_input(tui_t *t, int idx) {
|
||
if (idx < 0 || idx >= t->prompt_hist_n)
|
||
return;
|
||
input_set(t, t->prompt_hist[idx]);
|
||
t->hist_browse = idx;
|
||
}
|
||
|
||
static int queue_push(tui_t *t, const char *prompt) {
|
||
if (!prompt || !prompt[0])
|
||
return -1;
|
||
if (t->queue_n >= QUEUE_CAP)
|
||
return -1;
|
||
t->queue[t->queue_n] = strdup(prompt);
|
||
if (!t->queue[t->queue_n])
|
||
return -1;
|
||
t->queue_n++;
|
||
return 0;
|
||
}
|
||
|
||
static char *queue_pop(tui_t *t) {
|
||
if (t->queue_n <= 0)
|
||
return NULL;
|
||
char *p = t->queue[0];
|
||
memmove(t->queue, t->queue + 1, (size_t)(t->queue_n - 1) * sizeof(char *));
|
||
t->queue_n--;
|
||
t->queue[t->queue_n] = NULL;
|
||
return p;
|
||
}
|
||
|
||
static void queue_free_all(tui_t *t) {
|
||
for (int i = 0; i < t->queue_n; i++) {
|
||
free(t->queue[i]);
|
||
t->queue[i] = NULL;
|
||
}
|
||
t->queue_n = 0;
|
||
}
|
||
|
||
/* --- hooks (may run on worker thread) ------------------------------------- */
|
||
|
||
static void on_status(const char *msg, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
set_status_locked(t, msg);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void on_assistant_delta(const char *text, size_t len, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
collapse_open_think(t);
|
||
if (!t->as_live)
|
||
live_begin(t);
|
||
live_feed(t, text, len);
|
||
t->log_scroll = 0;
|
||
set_status_locked(t, "streaming…");
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void on_think_delta(const char *text, size_t len, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
if (t->think_idx < 0) {
|
||
block_t *b = blocks_push(&t->hist);
|
||
if (b) {
|
||
b->kind = BLK_THINK;
|
||
block_set_title(b, "Thinking…");
|
||
b->expanded = t->show_thinking;
|
||
t->think_idx = t->hist.n - 1;
|
||
t->focus_think_idx = t->think_idx;
|
||
clock_gettime(CLOCK_MONOTONIC, &t->think_t0);
|
||
t->think_timing = 1;
|
||
}
|
||
}
|
||
if (t->think_idx >= 0 && t->think_idx < t->hist.n && text && len)
|
||
block_append_body(&t->hist.items[t->think_idx], text, len);
|
||
set_status_locked(t, "thinking…");
|
||
t->log_scroll = 0;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void on_tool_event(const char *phase, const ctl_tool_call_t *call,
|
||
const char *detail, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
collapse_open_think(t);
|
||
if (t->as_live &&
|
||
(strcmp(phase, "open") == 0 || strcmp(phase, "ready") == 0))
|
||
live_seal(t);
|
||
if (!call) {
|
||
tui_unlock(t);
|
||
return;
|
||
}
|
||
|
||
int idx = find_tool_block(t, call->id);
|
||
block_t *b = NULL;
|
||
if (idx < 0) {
|
||
b = blocks_push(&t->hist);
|
||
if (!b) {
|
||
tui_unlock(t);
|
||
return;
|
||
}
|
||
b->kind = BLK_TOOL;
|
||
snprintf(b->tool_name, sizeof(b->tool_name), "%s", call->name);
|
||
snprintf(b->tool_id, sizeof(b->tool_id), "%s", call->id);
|
||
b->expanded = 0;
|
||
idx = t->hist.n - 1;
|
||
} else {
|
||
b = &t->hist.items[idx];
|
||
}
|
||
t->last_tool_idx = idx;
|
||
|
||
char args[160];
|
||
tool_arg_summary(call, args, sizeof(args));
|
||
block_set_title(b, args);
|
||
|
||
if (strcmp(phase, "open") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "pending");
|
||
set_status_locked(t, "tool pending…");
|
||
} else if (strcmp(phase, "arg") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "args…");
|
||
} else if (strcmp(phase, "ready") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "ready");
|
||
set_status_locked(t, "tool confirmed");
|
||
} else if (strcmp(phase, "run") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "running");
|
||
set_status_locked(t, "running tool…");
|
||
} else if (strcmp(phase, "progress") == 0) {
|
||
/* sub-agent steps: "explore · grep foo · read_file x" */
|
||
snprintf(b->meta, sizeof(b->meta), "%s", detail ? detail : "running");
|
||
set_status_locked(t, detail ? detail : "sub-agent…");
|
||
} else if (strcmp(phase, "denied") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "denied");
|
||
block_set_body(b, "denied by user", strlen("denied by user"));
|
||
set_status_locked(t, "tool denied");
|
||
} else if (strcmp(phase, "done") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "%s", detail ? detail : "done");
|
||
if (!b->body || !b->body[0])
|
||
attach_tool_output_from_session(t, b);
|
||
/* Auto-expand write/patch cards so diffs are visible like OpenCode. */
|
||
if (cst_tool_is_write(call->name))
|
||
b->expanded = 1;
|
||
set_status_locked(t, "tool done");
|
||
}
|
||
|
||
t->log_scroll = 0;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void on_tool_result(const ctl_tool_call_t *call, const char *out,
|
||
size_t len, int exit_code, void *ud) {
|
||
(void)exit_code;
|
||
tui_t *t = ud;
|
||
if (!call)
|
||
return;
|
||
tui_lock(t);
|
||
int idx = find_tool_block(t, call->id);
|
||
if (idx >= 0) {
|
||
block_t *b = &t->hist.items[idx];
|
||
block_set_body(b, out ? out : "", out ? len : 0);
|
||
char path[256];
|
||
diff_extract_path(b->body, NULL, path, sizeof(path));
|
||
if (path[0])
|
||
block_set_title(b, path);
|
||
tui_mark_dirty(t);
|
||
}
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static int on_permission(const char *tool_name, const char *detail,
|
||
const char *preview, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
snprintf(t->perm_tool, sizeof(t->perm_tool), "%s",
|
||
tool_name ? tool_name : "tool");
|
||
size_t sbl = strlen(CST_PERM_SANDBOX);
|
||
t->perm_sandbox = detail && strncmp(detail, CST_PERM_SANDBOX, sbl) == 0;
|
||
const char *dsrc = !detail ? "" : t->perm_sandbox ? detail + sbl : detail;
|
||
if (t->perm_sandbox) {
|
||
size_t dl = strlen(dsrc);
|
||
if (dl >= sizeof(t->perm_detail)) {
|
||
dsrc += dl - (sizeof(t->perm_detail) - 1);
|
||
while ((*dsrc & 0xC0) == 0x80)
|
||
dsrc++;
|
||
}
|
||
}
|
||
snprintf(t->perm_detail, sizeof(t->perm_detail), "%s", dsrc);
|
||
free(t->perm_preview);
|
||
t->perm_preview = preview ? xstrdup(preview) : NULL;
|
||
t->perm_prefix[0] = 0;
|
||
if (preview && strcmp(t->perm_tool, "run_shell") == 0)
|
||
cst_perms_suggest_prefix(preview, t->perm_prefix, sizeof(t->perm_prefix));
|
||
t->perm_scroll = 0;
|
||
t->awaiting_perm = 1;
|
||
t->perm_ans = -1;
|
||
t->perm_ask = 1;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
|
||
for (;;) {
|
||
tui_lock(t);
|
||
int ans = t->perm_ans;
|
||
if (ans >= 0) {
|
||
t->awaiting_perm = 0;
|
||
t->perm_ask = 0;
|
||
free(t->perm_preview);
|
||
t->perm_preview = NULL;
|
||
}
|
||
tui_unlock(t);
|
||
if (ans >= 0)
|
||
return ans;
|
||
usleep(20000);
|
||
}
|
||
}
|
||
|
||
static int on_ask_user(const char *q, const char *opts, char *ans,
|
||
size_t ans_sz, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
snprintf(t->ask_q, sizeof(t->ask_q), "%s", q ? q : "");
|
||
t->ask_nopts = 0;
|
||
for (const char *p = opts; p && *p && t->ask_nopts < ASK_OPTS_MAX;) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
while (len && (p[len - 1] == '\r' || p[len - 1] == ' '))
|
||
len--;
|
||
if (len)
|
||
snprintf(t->ask_opts[t->ask_nopts++], sizeof(t->ask_opts[0]), "%.*s",
|
||
(int)len, p);
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
t->ask_sel = 0;
|
||
t->ask_buf[0] = 0;
|
||
t->ask_done = 0;
|
||
t->ask_pending = 1;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
for (;;) {
|
||
tui_lock(t);
|
||
int done = t->ask_done;
|
||
if (done) {
|
||
t->ask_pending = 0;
|
||
t->ask_done = 0;
|
||
snprintf(ans, ans_sz, "%s", t->ask_buf);
|
||
}
|
||
int rc = t->ask_rc;
|
||
tui_unlock(t);
|
||
if (done)
|
||
return rc;
|
||
usleep(20000);
|
||
}
|
||
}
|
||
|
||
static void on_todo(const char *text, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
free(t->todo);
|
||
t->todo = text && text[0] ? xstrdup(text) : NULL;
|
||
t->need_layout = 1;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
/* --- modals drawn over the transcript ------------------------------------- */
|
||
|
||
static void modal_footer(tui_t *t, int h, int w, const char *keys) {
|
||
wmove(t->logw, h - 1, 0);
|
||
wclrtoeol(t->logw);
|
||
wattron(t->logw, A_REVERSE);
|
||
draw_ansi_line(t->logw, h - 1, w, keys, CP_PRIMARY, 1, 0);
|
||
wattroff(t->logw, A_REVERSE);
|
||
}
|
||
|
||
static void draw_perm_modal(tui_t *t) {
|
||
int h, w;
|
||
getmaxyx(t->logw, h, w);
|
||
werase(t->logw);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->logw, COLOR_PAIR(CP_PANEL));
|
||
dlines_t dl = {0};
|
||
char head[700];
|
||
if (t->perm_sandbox)
|
||
snprintf(head, sizeof(head),
|
||
"Blocked by the sandbox. Rerun outside the sandbox?");
|
||
else
|
||
snprintf(head, sizeof(head), "Allow %s?", t->perm_tool);
|
||
dlines_push(&dl, head, CP_PRIMARY, 1, 0);
|
||
int is_shell = strcmp(t->perm_tool, "run_shell") == 0;
|
||
if (t->perm_sandbox) {
|
||
push_wrapped(&dl, " $ ", t->perm_preview ? t->perm_preview : "", w,
|
||
CP_TOOL, 1, 0, 0);
|
||
dlines_push(&dl, "", 0, 0, 0);
|
||
push_wrapped(&dl, " ", "sandboxed output (tail):", w, CP_MUTED, 0, 0, 0);
|
||
push_diff_or_body(&dl, t->perm_detail, w, CP_MUTED, 1, 100000, 0);
|
||
} else if (is_shell && t->perm_preview) {
|
||
push_wrapped(&dl, " $ ", t->perm_preview, w, CP_TOOL, 1, 0, 0);
|
||
} else {
|
||
push_wrapped(&dl, " ", t->perm_detail, w, CP_MUTED, 0, 0, 0);
|
||
if (t->perm_preview && t->perm_preview[0]) {
|
||
dlines_push(&dl, "", 0, 0, 0);
|
||
push_diff_or_body(&dl, t->perm_preview, w, CP_MUTED, 1, 100000, 1);
|
||
}
|
||
}
|
||
int body_h = h - 1;
|
||
int max_scroll = dl.n > body_h ? dl.n - body_h : 0;
|
||
if (t->perm_scroll > max_scroll)
|
||
t->perm_scroll = max_scroll;
|
||
if (t->perm_scroll < 0)
|
||
t->perm_scroll = 0;
|
||
for (int r = 0; r < body_h && t->perm_scroll + r < dl.n; r++) {
|
||
dline_t *L = &dl.lines[t->perm_scroll + r];
|
||
draw_ansi_line(t->logw, r, w, L->text, L->pair, L->bold, L->dim);
|
||
}
|
||
char keys[400];
|
||
int k = t->perm_sandbox
|
||
? snprintf(keys, sizeof(keys), " [y] rerun once outside")
|
||
: snprintf(keys, sizeof(keys),
|
||
" [y] allow once [a] always allow %s", t->perm_tool);
|
||
if (t->perm_prefix[0])
|
||
k += snprintf(keys + k, sizeof(keys) - (size_t)k,
|
||
" [p] always allow `%.60s …`", t->perm_prefix);
|
||
snprintf(keys + k, sizeof(keys) - (size_t)k, " [n/Esc] %s%s",
|
||
t->perm_sandbox ? "keep result" : "deny",
|
||
max_scroll ? " ↑↓ scroll" : "");
|
||
modal_footer(t, h, w, keys);
|
||
dlines_free(&dl);
|
||
wrefresh(t->logw);
|
||
}
|
||
|
||
static void draw_ask_modal(tui_t *t) {
|
||
int h, w;
|
||
getmaxyx(t->logw, h, w);
|
||
werase(t->logw);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->logw, COLOR_PAIR(CP_PANEL));
|
||
dlines_t dl = {0};
|
||
dlines_push(&dl, "The agent asks:", CP_PRIMARY, 1, 0);
|
||
push_wrapped(&dl, " ", t->ask_q, w, CP_PANEL, 1, 0, 0);
|
||
dlines_push(&dl, "", 0, 0, 0);
|
||
for (int i = 0; i < t->ask_nopts; i++) {
|
||
char line[320];
|
||
snprintf(line, sizeof(line), "%s %d. %s",
|
||
i == t->ask_sel && !t->ask_buf[0] ? (g_ascii ? ">" : "▶") : " ",
|
||
i + 1, t->ask_opts[i]);
|
||
dlines_push(&dl, line, i == t->ask_sel ? CP_PRIMARY : CP_PANEL,
|
||
i == t->ask_sel, 0);
|
||
}
|
||
if (t->ask_nopts)
|
||
dlines_push(&dl, "", 0, 0, 0);
|
||
char typed[1100];
|
||
snprintf(typed, sizeof(typed), " answer: %s_", t->ask_buf);
|
||
dlines_push(&dl, typed, CP_ASSIST, 0, 0);
|
||
int start = dl.n > h - 1 ? dl.n - (h - 1) : 0;
|
||
for (int r = 0; r < h - 1 && start + r < dl.n; r++) {
|
||
dline_t *L = &dl.lines[start + r];
|
||
draw_ansi_line(t->logw, r, w, L->text, L->pair, L->bold, L->dim);
|
||
}
|
||
modal_footer(t, h, w,
|
||
t->ask_nopts ? " ↑↓/1-9 pick · type your own answer · Enter "
|
||
"send · Esc dismiss"
|
||
: " type your answer · Enter send · Esc dismiss");
|
||
dlines_free(&dl);
|
||
wrefresh(t->logw);
|
||
}
|
||
|
||
/* --- modal picker (↑↓ Enter Esc) ------------------------------------------ */
|
||
|
||
static int ci_contains(const char *hay, const char *needle) {
|
||
if (!needle[0])
|
||
return 1;
|
||
for (const char *h = hay; *h; h++) {
|
||
size_t i = 0;
|
||
while (needle[i] && h[i] &&
|
||
tolower((unsigned char)h[i]) == tolower((unsigned char)needle[i]))
|
||
i++;
|
||
if (!needle[i])
|
||
return 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* Centered dialog over the current screen: type to filter, ↑↓ / wheel to
|
||
* move, Enter or click to pick. items: n labels (each ≤127 chars). Returns
|
||
* the original index or -1. */
|
||
static int picker_run(tui_t *t, const char *title, char items[][128], int n,
|
||
int cur_idx) {
|
||
if (n <= 0)
|
||
return -1;
|
||
if (cur_idx >= n)
|
||
cur_idx = -1; /* -1: no current item */
|
||
int *vis = malloc((size_t)n * sizeof(int));
|
||
if (!vis)
|
||
return -1;
|
||
char query[64] = "";
|
||
size_t qlen = 0;
|
||
int sel = 0, top = 0, result = -1, nv = 0;
|
||
int first = 1, last_dh = -1, last_dw = -1;
|
||
WINDOW *dlg = NULL;
|
||
curs_set(0);
|
||
|
||
for (;;) {
|
||
nv = 0;
|
||
for (int i = 0; i < n; i++)
|
||
if (ci_contains(items[i], query))
|
||
vis[nv++] = i;
|
||
if (first) {
|
||
for (int k = 0; k < nv; k++)
|
||
if (vis[k] == cur_idx)
|
||
sel = k;
|
||
first = 0;
|
||
}
|
||
if (sel >= nv)
|
||
sel = nv > 0 ? nv - 1 : 0;
|
||
|
||
int rows, cols;
|
||
getmaxyx(stdscr, rows, cols);
|
||
int dw = cols - 4 < 80 ? cols - 4 : 80;
|
||
int list_h = rows - 10;
|
||
if (list_h > n)
|
||
list_h = n;
|
||
if (list_h < 1)
|
||
list_h = 1;
|
||
int dh = list_h + 5;
|
||
if (dh > rows)
|
||
dh = rows;
|
||
if (dw < 10)
|
||
dw = cols;
|
||
int dy = (rows - dh) / 2, dx = (cols - dw) / 2;
|
||
if (sel < top)
|
||
top = sel;
|
||
if (sel >= top + list_h)
|
||
top = sel - list_h + 1;
|
||
if (top > 0 && top + list_h > nv)
|
||
top = nv > list_h ? nv - list_h : 0;
|
||
|
||
drop_win(&dlg);
|
||
if (last_dh >= 0 && (dh != last_dh || dw != last_dw))
|
||
redraw_all(t); /* uncover what a larger dialog hid */
|
||
last_dh = dh;
|
||
last_dw = dw;
|
||
dlg = newwin(dh, dw, dy, dx);
|
||
if (!dlg)
|
||
break;
|
||
keypad(dlg, TRUE);
|
||
wtimeout(dlg, -1);
|
||
if (HAS_COLOR())
|
||
wbkgd(dlg, COLOR_PAIR(CP_BOX));
|
||
werase(dlg);
|
||
draw_ansi_at(dlg, 1, 2, dw - 8, title ? title : "Select", CP_BOX_BRIGHT, 1,
|
||
0);
|
||
draw_ansi_at(dlg, 1, dw - 5, 3, "esc", CP_BOX_MUTED, 0, 0);
|
||
if (qlen)
|
||
draw_ansi_at(dlg, 2, 2, dw - 4, query, CP_BOX, 0, 0);
|
||
else
|
||
draw_ansi_at(dlg, 2, 2, dw - 4, "Search", CP_BOX_MUTED, 0, 0);
|
||
for (int i = 0; i < list_h && top + i < nv && 4 + i < dh; i++) {
|
||
int k = top + i, idx = vis[k], y = 3 + i + 1;
|
||
int on = k == sel;
|
||
int pair = on ? CP_SEL : CP_BOX;
|
||
int attrs = HAS_COLOR() ? (int)COLOR_PAIR(pair) : (on ? A_REVERSE : 0);
|
||
for (int x = 1; x < dw - 1; x++)
|
||
put_wc(dlg, y, x, L' ', attrs);
|
||
int used = draw_ansi_at(dlg, y, 2, 2,
|
||
idx == cur_idx ? (g_ascii ? "*" : "●") : " ",
|
||
on ? CP_SEL : CP_BOX_ACCENT, 0, 0);
|
||
draw_ansi_at(dlg, y, 2 + used + 1, dw - 6, items[idx],
|
||
on ? CP_SEL : CP_BOX, on, 0);
|
||
}
|
||
if (nv == 0)
|
||
draw_ansi_at(dlg, 4, 2, dw - 4, "No matches", CP_BOX_MUTED, 0, 0);
|
||
wrefresh(dlg);
|
||
|
||
wint_t wch = 0;
|
||
int rc = tui_wget_wch(dlg, &wch);
|
||
if (rc == ERR)
|
||
continue;
|
||
if (rc == KEY_CODE_YES) {
|
||
if (wch == KEY_UP && sel > 0)
|
||
sel--;
|
||
else if (wch == KEY_DOWN && sel + 1 < nv)
|
||
sel++;
|
||
else if (wch == KEY_PPAGE)
|
||
sel = sel > list_h ? sel - list_h : 0;
|
||
else if (wch == KEY_NPAGE)
|
||
sel = sel + list_h < nv ? sel + list_h : (nv > 0 ? nv - 1 : 0);
|
||
else if (wch == KEY_BACKSPACE && qlen)
|
||
query[qlen -= utf8_prev_len(query, qlen)] = 0, sel = 0, top = 0;
|
||
else if (wch == KEY_ENTER && nv > 0) {
|
||
result = vis[sel];
|
||
break;
|
||
} else if (wch == KEY_RESIZE) {
|
||
layout(t);
|
||
redraw_all(t);
|
||
} else if (wch == KEY_MOUSE) {
|
||
MEVENT ev;
|
||
if (getmouse(&ev) != OK)
|
||
continue;
|
||
if (ev.bstate & BUTTON4_PRESSED) {
|
||
if (sel > 0)
|
||
sel--;
|
||
} else if (ev.bstate & BUTTON5_PRESSED) {
|
||
if (sel + 1 < nv)
|
||
sel++;
|
||
} else if (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) {
|
||
int k = top + (ev.y - dy - 4);
|
||
if (ev.y < dy || ev.y >= dy + dh || ev.x < dx || ev.x >= dx + dw)
|
||
break; /* click outside cancels */
|
||
if (ev.y >= dy + 4 && k >= 0 && k < nv) {
|
||
result = vis[k];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 10 || wch == 13) {
|
||
if (nv > 0) {
|
||
result = vis[sel];
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 27 || wch == 3) /* Esc / Ctrl+C */
|
||
break;
|
||
if (wch == 127 || wch == 8) {
|
||
if (qlen)
|
||
query[qlen -= utf8_prev_len(query, qlen)] = 0;
|
||
sel = top = 0;
|
||
continue;
|
||
}
|
||
if (wch == 21) { /* Ctrl+U */
|
||
qlen = 0;
|
||
query[0] = 0;
|
||
continue;
|
||
}
|
||
if (wch == 14 || wch == 16) { /* Ctrl+N / Ctrl+P */
|
||
if (wch == 14 && sel + 1 < nv)
|
||
sel++;
|
||
else if (wch == 16 && sel > 0)
|
||
sel--;
|
||
continue;
|
||
}
|
||
if (iswprint(wch)) {
|
||
char mb[8];
|
||
mbstate_t st;
|
||
memset(&st, 0, sizeof(st));
|
||
size_t k = wcrtomb(mb, (wchar_t)wch, &st);
|
||
if (k != (size_t)-1 && qlen + k < sizeof(query)) {
|
||
memcpy(query + qlen, mb, k);
|
||
qlen += k;
|
||
query[qlen] = 0;
|
||
sel = top = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
drop_win(&dlg);
|
||
free(vis);
|
||
curs_set(1);
|
||
layout(t);
|
||
redraw_all(t);
|
||
return result;
|
||
}
|
||
|
||
/* Full-screen hidden input (API keys). Returns 0 with out filled, -1 on
|
||
* Esc / Ctrl+C. */
|
||
static int secret_input_run(tui_t *t, const char *title, const char *hint,
|
||
char *out, size_t n) {
|
||
size_t len = 0;
|
||
out[0] = 0;
|
||
int rc = -1;
|
||
wtimeout(stdscr, -1);
|
||
keypad(stdscr, TRUE);
|
||
curs_set(1);
|
||
for (;;) {
|
||
int rows, cols;
|
||
getmaxyx(stdscr, rows, cols);
|
||
(void)rows;
|
||
werase(stdscr);
|
||
if (HAS_COLOR())
|
||
wbkgd(stdscr, COLOR_PAIR(CP_PANEL));
|
||
if (HAS_COLOR())
|
||
attron(COLOR_PAIR(CP_PRIMARY) | A_BOLD);
|
||
mvprintw(1, 2, "%.*s", cols - 4, title);
|
||
if (HAS_COLOR())
|
||
attroff(COLOR_PAIR(CP_PRIMARY) | A_BOLD);
|
||
if (HAS_COLOR())
|
||
attron(COLOR_PAIR(CP_MUTED));
|
||
mvprintw(2, 2, "%.*s", cols - 4, hint);
|
||
mvprintw(6, 2, "%.*s", cols - 4,
|
||
"Enter save · Esc cancel · Ctrl+U clear · paste works");
|
||
if (HAS_COLOR())
|
||
attroff(COLOR_PAIR(CP_MUTED));
|
||
int room = cols - 12 > 1 ? cols - 12 : 1;
|
||
int shown = (int)len < room ? (int)len : room;
|
||
mvprintw(4, 2, "key: ");
|
||
for (int i = 0; i < shown; i++)
|
||
addch('*');
|
||
if ((int)len > shown)
|
||
addstr("…");
|
||
move(4, 7 + shown);
|
||
refresh();
|
||
|
||
wint_t wch = 0;
|
||
int r = tui_wget_wch(stdscr, &wch);
|
||
if (r == ERR)
|
||
continue;
|
||
if (r == KEY_CODE_YES) {
|
||
if (wch == KEY_BACKSPACE && len > 0)
|
||
out[--len] = 0;
|
||
else if (wch == KEY_ENTER && len > 0) {
|
||
rc = 0;
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 10 || wch == 13) {
|
||
if (len > 0) {
|
||
rc = 0;
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 27 || wch == 3)
|
||
break;
|
||
if (wch == 127 || wch == 8) {
|
||
if (len > 0)
|
||
out[--len] = 0;
|
||
continue;
|
||
}
|
||
if (wch == 21) {
|
||
len = 0;
|
||
out[0] = 0;
|
||
continue;
|
||
}
|
||
if (wch > 32 && wch < 127 && len + 1 < n) {
|
||
out[len++] = (char)wch;
|
||
out[len] = 0;
|
||
}
|
||
}
|
||
if (rc != 0)
|
||
memset(out, 0, n);
|
||
layout(t);
|
||
wtimeout(t->input, 80);
|
||
keypad(t->input, TRUE);
|
||
redraw_all(t);
|
||
return rc;
|
||
}
|
||
|
||
/* Saved / env key for the provider, else ask for one and save it. */
|
||
/* --- /models probe ---------------------------------------------------------
|
||
* File-scope so a probe still running when the TUI exits writes nowhere
|
||
* stale. */
|
||
|
||
#define AM_MAX 64
|
||
enum { AM_NONE = 0, AM_RUNNING, AM_OK, AM_FAILED, AM_AUTH };
|
||
|
||
static pthread_mutex_t g_am_mu = PTHREAD_MUTEX_INITIALIZER;
|
||
static struct {
|
||
char provider[32];
|
||
char ids[AM_MAX][128];
|
||
int n;
|
||
int state;
|
||
char err[256];
|
||
} g_am;
|
||
static volatile int g_am_changed;
|
||
static volatile int g_am_threads;
|
||
|
||
typedef struct {
|
||
char provider[32];
|
||
char base[512];
|
||
char key[512];
|
||
} probe_arg_t;
|
||
|
||
static void am_store(const char *provider, int r, char ids[][128],
|
||
const char *err) {
|
||
pthread_mutex_lock(&g_am_mu);
|
||
if (strcmp(g_am.provider, provider) == 0) {
|
||
g_am.state = r >= 0 ? AM_OK : r == CST_MODELS_AUTH ? AM_AUTH : AM_FAILED;
|
||
g_am.n = r > 0 ? r : 0;
|
||
for (int i = 0; i < g_am.n; i++)
|
||
memcpy(g_am.ids[i], ids[i], 128);
|
||
snprintf(g_am.err, sizeof(g_am.err), "%s", err ? err : "");
|
||
g_am_changed = 1;
|
||
}
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
}
|
||
|
||
static void *probe_thread(void *arg) {
|
||
probe_arg_t *a = arg;
|
||
static char ids[AM_MAX][128]; /* one probe runs per provider switch */
|
||
char err[256];
|
||
int r = cst_llm_list_models(a->base, a->key, ids, AM_MAX, err, sizeof(err));
|
||
am_store(a->provider, r, ids, err);
|
||
memset(a->key, 0, sizeof(a->key));
|
||
free(a);
|
||
pthread_mutex_lock(&g_am_mu);
|
||
g_am_threads--;
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
return NULL;
|
||
}
|
||
|
||
/* Base URL for a provider: the live config for the current one (it may be
|
||
* overridden), else the preset. */
|
||
static const char *provider_base(const tui_t *t, const char *provider) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
if (cfg && strcmp(cfg->provider, provider) == 0)
|
||
return cfg->api_base;
|
||
const cst_provider_t *p = cst_provider_find(provider);
|
||
return p ? p->api_base : "";
|
||
}
|
||
|
||
static void start_probe(tui_t *t) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
if (!cfg || !cfg->api_key[0] || g_am_threads > 0)
|
||
return;
|
||
probe_arg_t *a = calloc(1, sizeof(*a));
|
||
if (!a)
|
||
return;
|
||
snprintf(a->provider, sizeof(a->provider), "%s", cfg->provider);
|
||
snprintf(a->base, sizeof(a->base), "%s", cfg->api_base);
|
||
snprintf(a->key, sizeof(a->key), "%s", cfg->api_key);
|
||
pthread_mutex_lock(&g_am_mu);
|
||
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
|
||
g_am.state = AM_RUNNING;
|
||
g_am.n = 0;
|
||
g_am_threads++;
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
pthread_t th;
|
||
if (pthread_create(&th, NULL, probe_thread, a) != 0) {
|
||
pthread_mutex_lock(&g_am_mu);
|
||
g_am_threads--;
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
free(a);
|
||
return;
|
||
}
|
||
pthread_detach(th);
|
||
}
|
||
|
||
/* Copies the API model list for provider; returns the count, or -1 if none
|
||
* is known yet. */
|
||
static int am_models(const char *provider, char out[][128], int max) {
|
||
int n = -1;
|
||
pthread_mutex_lock(&g_am_mu);
|
||
if (strcmp(g_am.provider, provider) == 0 && g_am.state == AM_OK) {
|
||
n = g_am.n < max ? g_am.n : max;
|
||
for (int i = 0; i < n; i++)
|
||
memcpy(out[i], g_am.ids[i], 128);
|
||
}
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
return n;
|
||
}
|
||
|
||
/* After a probe: flag a model the API does not list. */
|
||
static void am_check_model(tui_t *t) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
char msg[400] = "";
|
||
pthread_mutex_lock(&g_am_mu);
|
||
if (cfg && strcmp(g_am.provider, cfg->provider) == 0) {
|
||
if (g_am.state == AM_AUTH) {
|
||
snprintf(msg, sizeof(msg), "the %s API key was rejected — /key to "
|
||
"replace it", cfg->provider);
|
||
} else if (g_am.state == AM_OK && g_am.n > 0) {
|
||
int found = 0;
|
||
for (int i = 0; i < g_am.n && !found; i++)
|
||
found = strcmp(g_am.ids[i], cfg->model) == 0;
|
||
if (!found)
|
||
snprintf(msg, sizeof(msg), "%s is not in %s's model list — /model "
|
||
"to pick one", cfg->model, cfg->provider);
|
||
}
|
||
}
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
if (msg[0])
|
||
set_status(t, msg);
|
||
}
|
||
|
||
static int obtain_key(tui_t *t, const char *provider, char *out, size_t n,
|
||
int force_ask) {
|
||
if (!force_ask && cst_creds_resolve(provider, out, n, NULL) == 0)
|
||
return 0;
|
||
char dir[1024] = "the config directory";
|
||
os_config_dir(dir, sizeof(dir));
|
||
const char *base = provider_base(t, provider);
|
||
char title[128], hint[1300];
|
||
snprintf(title, sizeof(title), "API key for %s", provider);
|
||
snprintf(hint, sizeof(hint), "%.200s · saved to %.900s/credentials (only you "
|
||
"can read it)",
|
||
base, dir);
|
||
char note[300] = "";
|
||
for (;;) {
|
||
if (secret_input_run(t, title, hint, out, n) != 0)
|
||
return -1;
|
||
set_status(t, "checking the key…");
|
||
draw_status(t);
|
||
static char ids[AM_MAX][128];
|
||
char err[256];
|
||
int r = cst_llm_list_models(base, out, ids, AM_MAX, err, sizeof(err));
|
||
if (r == CST_MODELS_AUTH) {
|
||
snprintf(hint, sizeof(hint), "%s rejected that key (%.200s) — paste "
|
||
"another one",
|
||
base, err);
|
||
continue;
|
||
}
|
||
pthread_mutex_lock(&g_am_mu);
|
||
snprintf(g_am.provider, sizeof(g_am.provider), "%s", provider);
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
am_store(provider, r, ids, err);
|
||
if (r < 0)
|
||
snprintf(note, sizeof(note), "could not verify the key (%.200s); "
|
||
"saved anyway",
|
||
err);
|
||
break;
|
||
}
|
||
if (cst_creds_save(provider, out) != 0)
|
||
set_status(t, "warning: could not save the key; it is used for this run "
|
||
"only");
|
||
else if (note[0])
|
||
set_status(t, note);
|
||
else
|
||
set_status(t, "API key verified and saved");
|
||
return 0;
|
||
}
|
||
|
||
/* --- slash commands ------------------------------------------------------- */
|
||
|
||
static void refresh_title(tui_t *t) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
char sid[128];
|
||
cst_agent_session_id(t->agent, sid, sizeof(sid));
|
||
cst_session_info_t in;
|
||
t->title[0] = 0;
|
||
if (cfg && cst_agent_store_info(cfg->workspace_root, sid, &in) == 0)
|
||
snprintf(t->title, sizeof(t->title), "%s",
|
||
in.title[0] ? in.title : in.first_user);
|
||
if (!t->title[0])
|
||
snprintf(t->title, sizeof(t->title), "%.8s", sid);
|
||
}
|
||
|
||
static void refresh_todo(tui_t *t) {
|
||
const char *td = cst_agent_todo(t->agent);
|
||
tui_lock(t);
|
||
free(t->todo);
|
||
t->todo = td && td[0] ? xstrdup(td) : NULL;
|
||
t->need_layout = 1;
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void push_lines(tui_t *t, const char *prefix, const char *text) {
|
||
for (const char *p = text; p && *p;) {
|
||
const char *nl = strchr(p, '\n');
|
||
int len = nl ? (int)(nl - p) : (int)strlen(p);
|
||
push_system_line(t, "%s%.*s", prefix, len > 900 ? 900 : len, p);
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
}
|
||
|
||
static void after_session_change(tui_t *t, const char *note) {
|
||
reload_hist_from_session(t);
|
||
refresh_title(t);
|
||
refresh_todo(t);
|
||
if (note)
|
||
push_system_line(t, "%s", note);
|
||
status_idle(t);
|
||
}
|
||
|
||
typedef struct slash_cmd slash_cmd_t;
|
||
typedef void (*slash_fn)(tui_t *t, char *arg, int *quit);
|
||
struct slash_cmd {
|
||
const char *name;
|
||
const char *aliases; /* space separated */
|
||
const char *args;
|
||
const char *desc;
|
||
int busy_ok;
|
||
slash_fn fn;
|
||
};
|
||
|
||
static const slash_cmd_t *slash_table(int *n);
|
||
|
||
static void cmd_help(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
int n;
|
||
const slash_cmd_t *c = slash_table(&n);
|
||
push_system_line(t, "");
|
||
push_system_line(t, "Commands:");
|
||
for (int i = 0; i < n; i++) {
|
||
char name[64];
|
||
snprintf(name, sizeof(name), "/%s %s", c[i].name, c[i].args);
|
||
char also[64] = "";
|
||
if (c[i].aliases[0])
|
||
snprintf(also, sizeof(also), " (also: %s)", c[i].aliases);
|
||
push_system_line(t, " %-26s %s%s", name, c[i].desc, also);
|
||
}
|
||
push_system_line(t, "Keys: /keys (or F1) lists every shortcut; Ctrl+X is the "
|
||
"leader (Ctrl+X n new session, Ctrl+X m model, ...).");
|
||
push_system_line(t, " Enter send · Esc cancel turn · Ctrl+C twice or Ctrl+D "
|
||
"quit · Ctrl+P command palette");
|
||
push_system_line(t, " Mouse: click cards / Thought to expand, wheel scrolls, "
|
||
"Shift+drag selects · /mouse");
|
||
push_system_line(t, " Rebind in keybinds.json next to settings.json");
|
||
push_system_line(t, "");
|
||
}
|
||
|
||
static void cmd_new(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
char id[128] = {0};
|
||
if (cst_agent_new_session(t->agent, id, sizeof(id)) != 0) {
|
||
push_system_line(t, "Error: failed to create session");
|
||
return;
|
||
}
|
||
after_session_change(t, NULL); /* empty transcript → start screen */
|
||
}
|
||
|
||
static int session_labels(tui_t *t, char ids[][128], char labels[][128],
|
||
int max) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
int n = cst_agent_store_list(cfg->workspace_root, ids, max);
|
||
for (int i = 0; i < n; i++) {
|
||
cst_session_info_t in;
|
||
char when[24] = "?";
|
||
if (cst_agent_store_info(cfg->workspace_root, ids[i], &in) == 0) {
|
||
time_t tt = (time_t)in.mtime;
|
||
struct tm tm;
|
||
os_localtime(&tt, &tm);
|
||
strftime(when, sizeof(when), "%m-%d %H:%M", &tm);
|
||
snprintf(labels[i], 128, "%s %3d msgs %.60s", when, in.messages,
|
||
in.title[0] ? in.title : in.first_user);
|
||
} else {
|
||
snprintf(labels[i], 128, "%s", ids[i]);
|
||
}
|
||
}
|
||
return n;
|
||
}
|
||
|
||
static int picker_run(tui_t *t, const char *title, char items[][128], int n,
|
||
int cur_idx);
|
||
|
||
static void cmd_session(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
static char ids[64][128], labels[64][128];
|
||
char chosen[128];
|
||
if (arg && arg[0]) {
|
||
snprintf(chosen, sizeof(chosen), "%s", arg);
|
||
} else {
|
||
int n = session_labels(t, ids, labels, 64);
|
||
if (n <= 0) {
|
||
push_system_line(t, "Sessions: (none)");
|
||
return;
|
||
}
|
||
char cur[128];
|
||
cst_agent_session_id(t->agent, cur, sizeof(cur));
|
||
int cur_idx = 0;
|
||
for (int i = 0; i < n; i++)
|
||
if (strcmp(ids[i], cur) == 0)
|
||
cur_idx = i;
|
||
int pick = picker_run(t, "Session", labels, n, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
snprintf(chosen, sizeof(chosen), "%s", ids[pick]);
|
||
}
|
||
if (cst_agent_switch_session(t->agent, chosen) != 0) {
|
||
push_system_line(t, "Error: cannot switch to session '%s'", chosen);
|
||
return;
|
||
}
|
||
char note[192];
|
||
snprintf(note, sizeof(note), "(switched to %s)", chosen);
|
||
after_session_change(t, note);
|
||
}
|
||
|
||
static void cmd_rename(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
char sid[128];
|
||
cst_agent_session_id(t->agent, sid, sizeof(sid));
|
||
if (cst_agent_store_title_set(cfg->workspace_root, sid, arg) != 0) {
|
||
push_system_line(t, "Error: cannot set the title");
|
||
return;
|
||
}
|
||
refresh_title(t);
|
||
push_system_line(t, arg && arg[0] ? "(session renamed to \"%s\")"
|
||
: "(session title cleared%s)",
|
||
arg ? arg : "");
|
||
}
|
||
|
||
static void cmd_delete(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
static char ids[64][128], labels[64][128];
|
||
char victim[128];
|
||
if (arg && arg[0]) {
|
||
snprintf(victim, sizeof(victim), "%s", arg);
|
||
} else {
|
||
int n = session_labels(t, ids, labels, 64);
|
||
if (n <= 0)
|
||
return;
|
||
int pick = picker_run(t, "Delete which session?", labels, n, 0);
|
||
if (pick < 0)
|
||
return;
|
||
snprintf(victim, sizeof(victim), "%s", ids[pick]);
|
||
}
|
||
char confirm[2][128];
|
||
snprintf(confirm[0], 128, "No, keep it");
|
||
snprintf(confirm[1], 128, "Yes, delete %.100s", victim);
|
||
if (picker_run(t, "Delete session permanently?", confirm, 2, 0) != 1)
|
||
return;
|
||
char cur[128];
|
||
cst_agent_session_id(t->agent, cur, sizeof(cur));
|
||
int was_current = strcmp(cur, victim) == 0;
|
||
if (was_current) {
|
||
char id[128];
|
||
if (cst_agent_new_session(t->agent, id, sizeof(id)) != 0) {
|
||
push_system_line(t, "Error: cannot open a new session first");
|
||
return;
|
||
}
|
||
}
|
||
if (cst_agent_store_delete(cfg->workspace_root, victim) != 0)
|
||
push_system_line(t, "Error: cannot delete %s", victim);
|
||
if (was_current)
|
||
after_session_change(t, NULL);
|
||
push_system_line(t, "(deleted session %s)", victim);
|
||
}
|
||
|
||
static void cmd_model(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
if (!arg || !arg[0]) {
|
||
static char names[AM_MAX + 32][128], labels[AM_MAX + 32][128];
|
||
int na = am_models(cfg->provider, names, AM_MAX);
|
||
if (na < 0 && cfg->api_key[0]) {
|
||
set_status(t, "fetching the model list…");
|
||
draw_status(t);
|
||
char err[256];
|
||
na = cst_llm_list_models(cfg->api_base, cfg->api_key, names, AM_MAX, err,
|
||
sizeof(err));
|
||
pthread_mutex_lock(&g_am_mu);
|
||
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
|
||
pthread_mutex_unlock(&g_am_mu);
|
||
am_store(cfg->provider, na, names, err);
|
||
if (na < 0)
|
||
set_status(t, err);
|
||
else
|
||
status_idle(t);
|
||
}
|
||
if (na < 0)
|
||
na = 0;
|
||
for (int i = 0; i < na; i++)
|
||
snprintf(labels[i], 128, "%.100s (api)", names[i]);
|
||
char builtin[32][128];
|
||
int nb = cst_models_list(cfg->model, builtin, 32);
|
||
int n = na;
|
||
for (int i = 0; i < nb && n < AM_MAX + 32; i++) {
|
||
int dup = 0;
|
||
for (int k = 0; k < na && !dup; k++)
|
||
dup = strcmp(names[k], builtin[i]) == 0;
|
||
if (dup)
|
||
continue;
|
||
memcpy(names[n], builtin[i], 128);
|
||
memcpy(labels[n], builtin[i], 128);
|
||
n++;
|
||
}
|
||
int cur_idx = -1;
|
||
for (int i = 0; i < n; i++)
|
||
if (strcmp(names[i], cfg->model) == 0)
|
||
cur_idx = i;
|
||
int pick = picker_run(t, "Model", labels, n, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
snprintf(cfg->model, sizeof(cfg->model), "%s", names[pick]);
|
||
} else {
|
||
snprintf(cfg->model, sizeof(cfg->model), "%.127s", arg);
|
||
}
|
||
cst_settings_remember(cfg);
|
||
push_system_line(t, "(model → %s, context %ld tokens)", cfg->model,
|
||
cst_model_context_tokens(cfg->model));
|
||
}
|
||
|
||
static void cmd_key(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
char key[512];
|
||
if (obtain_key(t, cfg->provider, key, sizeof(key), 1) != 0) {
|
||
push_system_line(t, "(API key unchanged)");
|
||
return;
|
||
}
|
||
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
|
||
memset(key, 0, sizeof(key));
|
||
}
|
||
|
||
static void cmd_effort(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
static const char *const names[] = {"low", "medium", "high", "max"};
|
||
static const char *const desc[] = {"quick answers, little thinking",
|
||
"balanced (default)",
|
||
"thinks longer on hard problems",
|
||
"maximum thinking, slowest"};
|
||
const char *want = NULL;
|
||
if (arg && arg[0]) {
|
||
if (!cst_effort_valid(arg)) {
|
||
push_system_line(t, "Unknown effort '%s' (low medium high max)", arg);
|
||
return;
|
||
}
|
||
want = arg;
|
||
} else {
|
||
char items[4][128];
|
||
int cur_idx = 1;
|
||
for (int i = 0; i < 4; i++) {
|
||
snprintf(items[i], 128, "%-7s %s", names[i], desc[i]);
|
||
if (strcmp(names[i], cfg->effort) == 0)
|
||
cur_idx = i;
|
||
}
|
||
int pick = picker_run(t, "Reasoning effort", items, 4, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
want = names[pick];
|
||
}
|
||
snprintf(cfg->effort, sizeof(cfg->effort), "%s", want);
|
||
cst_settings_remember(cfg);
|
||
if (cst_effort_wire(cfg->provider, cfg->model, cfg->effort))
|
||
push_system_line(t, "(effort → %s)", cfg->effort);
|
||
else
|
||
push_system_line(t, "(effort → %s; %s/%s does not take an effort "
|
||
"setting, it applies to DeepSeek and OpenAI "
|
||
"reasoning models)",
|
||
cfg->effort, cfg->provider, cfg->model);
|
||
}
|
||
|
||
static void cmd_provider(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
const cst_provider_t *p = NULL;
|
||
if (arg && arg[0]) {
|
||
p = cst_provider_find(arg);
|
||
if (!p) {
|
||
push_system_line(t, "Unknown provider '%s'", arg);
|
||
return;
|
||
}
|
||
} else {
|
||
int n = cst_provider_count();
|
||
char items[16][128];
|
||
int cur_idx = 0;
|
||
for (int i = 0; i < n && i < 16; i++) {
|
||
const cst_provider_t *q = cst_provider_at(i);
|
||
snprintf(items[i], 128, "%-9s %s (%s)", q->name, q->api_base, q->model);
|
||
if (strcmp(q->name, cfg->provider) == 0)
|
||
cur_idx = i;
|
||
}
|
||
int pick = picker_run(t, "Provider", items, n < 16 ? n : 16, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
p = cst_provider_at(pick);
|
||
}
|
||
char key[512];
|
||
if (obtain_key(t, p->name, key, sizeof(key), 0) != 0) {
|
||
push_system_line(t, "(provider unchanged: %s needs an API key)", p->name);
|
||
return;
|
||
}
|
||
cst_settings_t s;
|
||
cst_settings_load(&s);
|
||
const char *model = cst_settings_model(&s, p->name);
|
||
snprintf(cfg->provider, sizeof(cfg->provider), "%s", p->name);
|
||
snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", p->api_base);
|
||
snprintf(cfg->model, sizeof(cfg->model), "%s", model ? model : p->model);
|
||
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
|
||
memset(key, 0, sizeof(key));
|
||
cst_settings_remember(cfg);
|
||
push_system_line(t, "(provider → %s · %s · model %s)", p->name, p->api_base,
|
||
cfg->model);
|
||
if (am_models(p->name, NULL, 0) < 0)
|
||
start_probe(t);
|
||
else
|
||
am_check_model(t);
|
||
}
|
||
|
||
static void cmd_protocol(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
cst_protocol_t p = cfg->protocol;
|
||
if (arg && arg[0]) {
|
||
if (cst_protocol_parse(arg, &p) != 0) {
|
||
push_system_line(t, "Unknown protocol '%s' (native-eos native-inc ctl "
|
||
"ctl-heredoc xml)", arg);
|
||
return;
|
||
}
|
||
} else {
|
||
static const cst_protocol_t all[] = {CST_PROTO_NATIVE_EOS,
|
||
CST_PROTO_NATIVE_INC, CST_PROTO_CTL,
|
||
CST_PROTO_CTL_HEREDOC, CST_PROTO_XML};
|
||
char names[5][128];
|
||
int cur_idx = 0;
|
||
for (int i = 0; i < 5; i++) {
|
||
snprintf(names[i], sizeof(names[i]), "%s", cst_protocol_name(all[i]));
|
||
if (all[i] == cfg->protocol)
|
||
cur_idx = i;
|
||
}
|
||
int pick = picker_run(t, "Protocol", names, 5, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
p = all[pick];
|
||
}
|
||
cfg->protocol = p;
|
||
push_system_line(t, "(protocol → %s)", cst_protocol_name(p));
|
||
}
|
||
|
||
static void cmd_tools(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
if (arg && arg[0]) {
|
||
char verb[16] = "", name[64] = "";
|
||
sscanf(arg, "%15s %63s", verb, name);
|
||
int prof;
|
||
if (cst_tool_profile_parse(verb, &prof) == 0) {
|
||
cfg->tool_profile = prof;
|
||
push_system_line(t, "(tool profile → %s)", cst_tool_profile_name(prof));
|
||
} else if ((strcmp(verb, "on") == 0 || strcmp(verb, "off") == 0) &&
|
||
name[0]) {
|
||
if (cst_tool_index(name) < 0) {
|
||
push_system_line(t, "Unknown tool '%s'", name);
|
||
return;
|
||
}
|
||
cst_tool_set_disabled(cfg, name, verb[1] != 'n');
|
||
push_system_line(t, "(%s %s)", name, verb);
|
||
} else {
|
||
push_system_line(t, "Usage: /tools [on NAME | off NAME | default | "
|
||
"minimal]");
|
||
return;
|
||
}
|
||
}
|
||
static const char *const perm_names[] = {"read", "write", "shell",
|
||
"net", "ask", "meta"};
|
||
push_system_line(t, "Tools (profile %s):",
|
||
cst_tool_profile_name(cfg->tool_profile));
|
||
for (int i = 0; i < cst_tool_count(); i++) {
|
||
const cst_tool_def_t *d = cst_tool_at(i);
|
||
int on = cst_tool_active(cfg, d);
|
||
const char *desc = d->desc ? d->desc : "";
|
||
int dl = (int)strcspn(desc, ".\n");
|
||
push_system_line(t, " %s %-12s %-5s %.*s%s%s%s", on ? "[x]" : "[ ]",
|
||
d->name, (int)d->perm < 6 ? perm_names[d->perm] : "?",
|
||
dl > 70 ? 70 : dl, desc, d->owner ? " (" : "",
|
||
d->owner ? d->owner : "", d->owner ? ")" : "");
|
||
}
|
||
}
|
||
|
||
static void cmd_permissions(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_perms_t *p = cst_agent_perms(t->agent);
|
||
if (arg && strcmp(arg, "reset") == 0) {
|
||
cst_perms_reset(p);
|
||
push_system_line(t, "(saved permission rules cleared)");
|
||
return;
|
||
}
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
push_system_line(t, "Confirmation: shell %s · write %s · net %s%s",
|
||
cfg->confirm_shell ? "ask" : "auto",
|
||
cfg->confirm_write ? "ask" : "auto",
|
||
cfg->confirm_net ? "ask" : "auto",
|
||
cfg->auto_approve ? " · YOLO (everything auto)" : "");
|
||
char *d = cst_perms_describe(p);
|
||
push_lines(t, " ", d ? d : "(no saved rules)");
|
||
free(d);
|
||
push_system_line(t, " saved in .agent/permissions.json · /permissions reset");
|
||
}
|
||
|
||
static void set_approval(tui_t *t, int a) {
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
cfg->approval = a;
|
||
cfg->auto_approve = a == CST_APPROVAL_YOLO;
|
||
if (a != CST_APPROVAL_YOLO) /* YOLO lasts for this session only */
|
||
cst_settings_remember_approval(a);
|
||
}
|
||
|
||
static const char *approval_help(int a) {
|
||
switch (a) {
|
||
case CST_APPROVAL_YOLO:
|
||
return "shell, writes and web run without asking";
|
||
case CST_APPROVAL_AUTO:
|
||
return "edits run, shell runs in the sandbox or by safe rules, else asks";
|
||
default:
|
||
return "shell, writes and web ask first";
|
||
}
|
||
}
|
||
|
||
static void cmd_yolo(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
int on = approval_mode(t) != CST_APPROVAL_YOLO;
|
||
set_approval(t, on ? CST_APPROVAL_YOLO : CST_DEFAULT_APPROVAL);
|
||
push_system_line(t, "(approval %s: %s)", cst_approval_name(approval_mode(t)),
|
||
approval_help(approval_mode(t)));
|
||
}
|
||
|
||
static void cmd_approval(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
int a;
|
||
if (arg && arg[0]) {
|
||
if (cst_approval_parse(arg, &a) != 0) {
|
||
push_system_line(t, "Usage: /approval ask|auto|yolo");
|
||
return;
|
||
}
|
||
set_approval(t, a);
|
||
}
|
||
a = approval_mode(t);
|
||
char label[64];
|
||
approval_label(t, label, sizeof(label));
|
||
push_system_line(t, "(approval %s: %s)", label, approval_help(a));
|
||
}
|
||
|
||
static void cmd_sidebar(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
t->show_side = !t->show_side;
|
||
t->need_layout = 1;
|
||
if (t->show_side && t->cols < 120)
|
||
push_system_line(t, "(the sidebar shows when the terminal is at least 120 "
|
||
"columns wide)");
|
||
}
|
||
|
||
static void cmd_mouse(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
t->mouse_on = !t->mouse_on;
|
||
apply_mouse(t);
|
||
push_system_line(t, t->mouse_on ? "(mouse on · Shift+drag selects text)"
|
||
: "(mouse off · the terminal selects text)");
|
||
}
|
||
|
||
static void cmd_max_steps(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
if (arg && atoi(arg) > 0)
|
||
cfg->max_steps = atoi(arg);
|
||
push_system_line(t, "(max steps per turn: %d)", cfg->max_steps);
|
||
}
|
||
|
||
static void cmd_status(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
char sid[128];
|
||
cst_agent_session_id(t->agent, sid, sizeof(sid));
|
||
long win = cst_model_context_tokens(cfg->model);
|
||
long used = cst_agent_last_prompt_tokens(t->agent);
|
||
cst_session_t *s = cst_agent_session(t->agent);
|
||
int nmsg = 0;
|
||
if (s) {
|
||
cst_session_lock(s);
|
||
nmsg = cst_session_count(s);
|
||
cst_session_unlock(s);
|
||
}
|
||
push_system_line(t, "Status:");
|
||
push_system_line(t, " workspace %s", cfg->workspace_root);
|
||
push_system_line(t, " session %s (%d messages) %s", sid, nmsg, t->title);
|
||
push_system_line(t, " provider %s %s", cfg->provider, cfg->api_base);
|
||
push_system_line(t, " model %s context %ld/%ld tokens (%ld%%)",
|
||
cfg->model, used, win, win ? used * 100 / win : 0);
|
||
push_system_line(t, " effort %s%s", cfg->effort,
|
||
cst_effort_wire(cfg->provider, cfg->model, cfg->effort)
|
||
? ""
|
||
: " (not used by this model)");
|
||
char key[512];
|
||
const char *from = NULL;
|
||
int have = cst_creds_resolve(cfg->provider, key, sizeof(key), &from) == 0;
|
||
int same = have && strcmp(key, cfg->api_key) == 0;
|
||
memset(key, 0, sizeof(key));
|
||
push_system_line(t, " api key %s",
|
||
!cfg->api_key[0] ? "missing — /key to enter one"
|
||
: !same ? "this run only (--api-key / CST_API_KEY)"
|
||
: strcmp(from, "saved") == 0 ? "saved (/key to replace)"
|
||
: from);
|
||
push_system_line(t, " protocol %s · tools %s · max steps %d",
|
||
cst_protocol_name(cfg->protocol),
|
||
cst_tool_profile_name(cfg->tool_profile), cfg->max_steps);
|
||
char appr[64];
|
||
approval_label(t, appr, sizeof(appr));
|
||
push_system_line(t, " approval %s — %s", appr,
|
||
approval_help(approval_mode(t)));
|
||
push_system_line(t, " mode %s",
|
||
cfg->plan_mode ? "Plan (read-only tools)" : "Build");
|
||
const char *notes = cst_agent_notes(t->agent);
|
||
push_system_line(t, " notes %s",
|
||
notes && notes[0] ? "AGENTS.md loaded" : "(no AGENTS.md)");
|
||
push_system_line(t, " undo %d turn(s) can be undone",
|
||
cst_agent_undo_available(t->agent));
|
||
}
|
||
|
||
static void cmd_todo(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
tui_lock(t);
|
||
char *td = t->todo ? xstrdup(t->todo) : NULL;
|
||
tui_unlock(t);
|
||
push_system_line(t, "Tasks:");
|
||
push_lines(t, " ", td && td[0] ? td : "(no tasks yet)");
|
||
free(td);
|
||
}
|
||
|
||
static void cmd_undo(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
char rep[4096];
|
||
int rc = cst_agent_undo(t->agent, rep, sizeof(rep));
|
||
push_system_line(t, rc == 0 ? "Undo:" : "Undo failed:");
|
||
push_lines(t, " ", rep);
|
||
}
|
||
|
||
static void cmd_retry(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
char *p = cst_agent_rewind_last_turn(t->agent);
|
||
if (!p) {
|
||
push_system_line(t, "Nothing to retry");
|
||
return;
|
||
}
|
||
reload_hist_from_session(t);
|
||
push_system_line(t, "(retrying the last prompt; file changes were kept — "
|
||
"/undo first to revert them)");
|
||
free(t->pending_prompt);
|
||
t->pending_prompt = p;
|
||
}
|
||
|
||
static void cmd_diff(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
os_run_result_t st = {0}, df = {0};
|
||
if (os_run_shell_c(cfg->workspace_root, "git status --short", 64 * 1024,
|
||
&st) != 0 ||
|
||
st.exit_code != 0) {
|
||
push_system_line(t, "/diff needs a git repository");
|
||
free(st.out);
|
||
return;
|
||
}
|
||
char cmd[512];
|
||
snprintf(cmd, sizeof(cmd), "git --no-pager diff --no-color %s%s",
|
||
arg && arg[0] ? "-- " : "", arg ? arg : "");
|
||
os_run_shell_c(cfg->workspace_root, cmd, 512 * 1024, &df);
|
||
push_system_line(t, "git status:");
|
||
push_lines(t, " ", st.out && st.out[0] ? st.out : "(clean)");
|
||
if (df.out && df.out[0]) {
|
||
block_t *b = blocks_push(&t->hist);
|
||
if (b) {
|
||
b->kind = BLK_TOOL;
|
||
snprintf(b->tool_name, sizeof(b->tool_name), "diff");
|
||
block_set_title(b, "git diff");
|
||
block_set_body(b, df.out, df.out_len);
|
||
snprintf(b->meta, sizeof(b->meta), "done");
|
||
b->expanded = 1;
|
||
}
|
||
}
|
||
free(st.out);
|
||
free(df.out);
|
||
}
|
||
|
||
static void md_fence(FILE *fp, const char *s, size_t max) {
|
||
size_t n = strlen(s);
|
||
fprintf(fp, "````\n%.*s%s\n````\n\n", (int)(n > max ? max : n), s,
|
||
n > max ? "\n…(truncated)" : "");
|
||
}
|
||
|
||
static void cmd_export(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
char sid[128], path[OS_PATH_MAX + 256];
|
||
cst_agent_session_id(t->agent, sid, sizeof(sid));
|
||
if (arg && arg[0] == '/')
|
||
snprintf(path, sizeof(path), "%s", arg);
|
||
else if (arg && arg[0])
|
||
snprintf(path, sizeof(path), "%s/%s", cfg->workspace_root, arg);
|
||
else
|
||
snprintf(path, sizeof(path), "%s/.agent/exports/%s.md", cfg->workspace_root,
|
||
sid);
|
||
char dir[sizeof(path)];
|
||
snprintf(dir, sizeof(dir), "%s", path);
|
||
char *sl = strrchr(dir, '/');
|
||
if (sl) {
|
||
*sl = 0;
|
||
os_mkdir_p(dir, 0755);
|
||
}
|
||
FILE *fp = fopen(path, "w");
|
||
if (!fp) {
|
||
push_system_line(t, "Error: cannot write %s", path);
|
||
return;
|
||
}
|
||
fprintf(fp, "# %s\n\nsession `%s` · model `%s`\n\n", t->title, sid,
|
||
cfg->model);
|
||
cst_session_t *s = cst_agent_session(t->agent);
|
||
cst_session_lock(s);
|
||
for (int i = 0; i < cst_session_count(s); i++) {
|
||
const cst_message_t *m = cst_session_get(s, i);
|
||
const char *c = m->content ? m->content : "";
|
||
if (m->role == CST_ROLE_SYSTEM)
|
||
continue;
|
||
if (msg_is_tool_result(m)) {
|
||
fprintf(fp, "**Tool result** `%s`\n\n", m->tool_name);
|
||
md_fence(fp, c, 4000);
|
||
} else if (m->role == CST_ROLE_USER) {
|
||
fprintf(fp, "## User\n\n%s\n\n", c);
|
||
} else {
|
||
fprintf(fp, "## Assistant\n\n%s\n\n", c);
|
||
if (m->tool_calls_json && m->tool_calls_json[0]) {
|
||
fprintf(fp, "**Tool calls**\n\n");
|
||
md_fence(fp, m->tool_calls_json, 4000);
|
||
}
|
||
}
|
||
}
|
||
cst_session_unlock(s);
|
||
fclose(fp);
|
||
push_system_line(t, "(exported to %s)", path);
|
||
}
|
||
|
||
static void cmd_copy(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
const char *text = NULL;
|
||
for (int i = t->hist.n - 1; i >= 0 && !text; i--)
|
||
if (t->hist.items[i].kind == BLK_ASSISTANT && t->hist.items[i].body)
|
||
text = t->hist.items[i].body;
|
||
if (!text || !text[0]) {
|
||
push_system_line(t, "Nothing to copy yet");
|
||
return;
|
||
}
|
||
static const char b64[] =
|
||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||
size_t n = strlen(text);
|
||
if (n > 75000)
|
||
n = 75000; /* many terminals cap OSC 52 near 100 KB */
|
||
char *enc = malloc((n + 2) / 3 * 4 + 1);
|
||
if (!enc)
|
||
return;
|
||
size_t o = 0;
|
||
for (size_t i = 0; i < n; i += 3) {
|
||
unsigned v = (unsigned char)text[i] << 16;
|
||
if (i + 1 < n)
|
||
v |= (unsigned char)text[i + 1] << 8;
|
||
if (i + 2 < n)
|
||
v |= (unsigned char)text[i + 2];
|
||
enc[o++] = b64[(v >> 18) & 63];
|
||
enc[o++] = b64[(v >> 12) & 63];
|
||
enc[o++] = i + 1 < n ? b64[(v >> 6) & 63] : '=';
|
||
enc[o++] = i + 2 < n ? b64[v & 63] : '=';
|
||
}
|
||
enc[o] = 0;
|
||
if (getenv("TMUX"))
|
||
printf("\033Ptmux;\033\033]52;c;%s\a\033\\", enc);
|
||
else
|
||
printf("\033]52;c;%s\a", enc);
|
||
fflush(stdout);
|
||
free(enc);
|
||
push_system_line(t, "(copied %zu bytes of the last reply via OSC 52 — the "
|
||
"terminal must allow clipboard access)", n);
|
||
}
|
||
|
||
static void cmd_init(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
free(t->pending_prompt);
|
||
t->pending_prompt = xstrdup(
|
||
"Analyze this repository and write an AGENTS.md file at the workspace "
|
||
"root for future coding agents. Cover: what the project is, how to "
|
||
"build, test and run it (exact commands), the directory layout, coding "
|
||
"conventions you can infer from the code, and anything surprising. "
|
||
"Explore with list_dir/glob/grep/read_file before writing. Keep it "
|
||
"under 150 lines. If AGENTS.md already exists, improve it rather than "
|
||
"starting over.");
|
||
}
|
||
|
||
static void cmd_compact(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
char err[512] = {0};
|
||
set_status(t, "compacting…");
|
||
redraw_all(t);
|
||
if (cst_agent_compact(t->agent, err, sizeof(err)) != 0) {
|
||
push_system_line(t, "Error: compact failed: %s", err[0] ? err : "?");
|
||
return;
|
||
}
|
||
after_session_change(t, "(session compacted)");
|
||
}
|
||
|
||
static void set_thinking_all(tui_t *t, int on) {
|
||
for (int i = 0; i < t->hist.n; i++)
|
||
if (t->hist.items[i].kind == BLK_THINK)
|
||
t->hist.items[i].expanded = on;
|
||
}
|
||
|
||
static void cmd_thinking(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
if (arg && strcmp(arg, "on") == 0)
|
||
t->show_thinking = 1;
|
||
else if (arg && strcmp(arg, "off") == 0)
|
||
t->show_thinking = 0;
|
||
else
|
||
t->show_thinking = !t->show_thinking;
|
||
set_thinking_all(t, t->show_thinking);
|
||
cst_settings_remember_thinking(t->show_thinking);
|
||
push_system_line(t, "(thinking %s by default)",
|
||
t->show_thinking ? "expanded" : "collapsed");
|
||
}
|
||
|
||
static int run_keys_panel(tui_t *t, int busy, int *quit);
|
||
|
||
static void cmd_keys(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
run_keys_panel(t, t->busy, quit);
|
||
}
|
||
|
||
static void cmd_details(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
t->details_all = !t->details_all;
|
||
push_system_line(t, "tool details: %s",
|
||
t->details_all ? "expanded" : "collapsed");
|
||
}
|
||
|
||
static void cmd_skills(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
cst_skill_t *v = calloc(CST_SKILLS_MAX, sizeof(*v));
|
||
if (!v)
|
||
return;
|
||
int n = cst_skills_list(cfg->workspace_root, v, CST_SKILLS_MAX);
|
||
if (arg && strcmp(arg, "reload") == 0) {
|
||
if (n > 0 && cst_tool_find("skill") == NULL)
|
||
cst_skills_register_tool();
|
||
push_system_line(t, "(skills reloaded: %d found)", n);
|
||
}
|
||
if (n == 0) {
|
||
push_system_line(t, "No skills. Add <dir>/SKILL.md (frontmatter name + "
|
||
"description) under .agent/skills/ in the workspace "
|
||
"or skills/ in the config dir, then /skills reload.");
|
||
free(v);
|
||
return;
|
||
}
|
||
if (!arg || !arg[0]) {
|
||
static char items[CST_SKILLS_MAX][128];
|
||
for (int i = 0; i < n; i++)
|
||
snprintf(items[i], sizeof(items[i]), "%-20.20s %-9.9s %.90s", v[i].name,
|
||
v[i].source, v[i].desc);
|
||
int pick = picker_run(t, "Skills (Enter: /skill NAME …)", items, n, 0);
|
||
if (pick >= 0) {
|
||
char line[128];
|
||
snprintf(line, sizeof(line), "/skill %s ", v[pick].name);
|
||
input_set(t, line);
|
||
}
|
||
free(v);
|
||
return;
|
||
}
|
||
push_system_line(t, "Skills (%d):", n);
|
||
for (int i = 0; i < n; i++)
|
||
push_system_line(t, " %-20s %-10s %.100s", v[i].name, v[i].source,
|
||
v[i].desc);
|
||
free(v);
|
||
}
|
||
|
||
static void cmd_skill(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
char name[64] = "";
|
||
if (arg)
|
||
sscanf(arg, "%63s", name);
|
||
if (!name[0]) {
|
||
cmd_skills(t, NULL, quit);
|
||
return;
|
||
}
|
||
const char *task = arg + strlen(name);
|
||
while (*task == ' ')
|
||
task++;
|
||
char *p = cst_skill_prompt(cst_agent_config(t->agent)->workspace_root, name,
|
||
task);
|
||
if (!p) {
|
||
push_system_line(t, "No skill named '%s' (/skills lists them)", name);
|
||
return;
|
||
}
|
||
free(t->pending_prompt);
|
||
t->pending_prompt = p;
|
||
}
|
||
|
||
static const char *mcp_state_name(int st) {
|
||
switch (st) {
|
||
case CST_MCP_STARTING:
|
||
return "starting";
|
||
case CST_MCP_READY:
|
||
return "ready";
|
||
case CST_MCP_FAILED:
|
||
return "failed";
|
||
case CST_MCP_EXITED:
|
||
return "exited";
|
||
default:
|
||
return "off";
|
||
}
|
||
}
|
||
|
||
static void reload_extensions(tui_t *t) {
|
||
cst_extensions_unload();
|
||
char rep[256];
|
||
cst_extensions_load(cst_agent_config(t->agent), rep, sizeof(rep));
|
||
push_system_line(t, "(extensions reloaded%s%s)", rep[0] ? ": " : "", rep);
|
||
}
|
||
|
||
static void cmd_plugins(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const char *ws = cst_agent_config(t->agent)->workspace_root;
|
||
if (arg && (strcmp(arg, "trust") == 0 || strcmp(arg, "untrust") == 0)) {
|
||
int on = arg[0] == 't';
|
||
if (cst_settings_trust(ws, on) != 0) {
|
||
push_system_line(t, "(could not save settings)");
|
||
return;
|
||
}
|
||
push_system_line(t, on ? "Workspace trusted: .agent/plugins and "
|
||
".agent/mcp.json will load."
|
||
: "Workspace no longer trusted.");
|
||
reload_extensions(t);
|
||
return;
|
||
}
|
||
if (arg && strcmp(arg, "reload") == 0) {
|
||
reload_extensions(t);
|
||
return;
|
||
}
|
||
cst_plugin_info_t info[CST_PLUGINS_MAX];
|
||
int n = cst_plugins_info(info, CST_PLUGINS_MAX);
|
||
push_system_line(t, "Workspace %s (/plugins %s)",
|
||
cst_settings_trusted(ws) ? "trusted" : "not trusted",
|
||
cst_settings_trusted(ws) ? "untrust" : "trust");
|
||
if (n == 0) {
|
||
push_system_line(t, "No plugins. Add <dir>/plugin.json under "
|
||
".agent/plugins/ (trusted workspaces) or plugins/ in "
|
||
"the config dir, then /plugins reload.");
|
||
return;
|
||
}
|
||
for (int i = 0; i < n; i++) {
|
||
push_system_line(t, " %-18s %-9s %d tool%s%s%s%s%s", info[i].name,
|
||
info[i].source, info[i].ntools,
|
||
info[i].ntools == 1 ? "" : "s",
|
||
info[i].nmcp ? " · mcp" : "",
|
||
info[i].nskills_root ? " · skills" : "",
|
||
info[i].error[0] ? " · error: " : "", info[i].error);
|
||
if (info[i].desc[0])
|
||
push_system_line(t, " %.110s", info[i].desc);
|
||
}
|
||
}
|
||
|
||
static void cmd_mcp(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
if (arg && (strcmp(arg, "reload") == 0 || strcmp(arg, "restart") == 0)) {
|
||
reload_extensions(t);
|
||
return;
|
||
}
|
||
cst_mcp_info_t mi[CST_MCP_MAX];
|
||
int n = cst_mcp_info(mi, CST_MCP_MAX);
|
||
if (n == 0) {
|
||
push_system_line(t, "No MCP servers. Add {\"mcpServers\": {\"name\": "
|
||
"{\"command\": …, \"args\": […]}}} to mcp.json in "
|
||
"the config dir (or .agent/mcp.json in a trusted "
|
||
"workspace), then /mcp reload.");
|
||
return;
|
||
}
|
||
push_system_line(t, "MCP servers (%d):", n);
|
||
for (int i = 0; i < n; i++) {
|
||
push_system_line(t, " %-16s %-9s %-14s %s", mi[i].name,
|
||
mcp_state_name(mi[i].state), mi[i].source,
|
||
mi[i].command);
|
||
if (mi[i].state == CST_MCP_READY)
|
||
push_system_line(t, " %d tool%s as mcp__%s__*", mi[i].ntools,
|
||
mi[i].ntools == 1 ? "" : "s", mi[i].name);
|
||
else if (mi[i].error[0])
|
||
push_system_line(t, " %s", mi[i].error);
|
||
}
|
||
}
|
||
|
||
static void show_subagent(tui_t *t, const char *pid,
|
||
const cst_subagent_rec_t *rec) {
|
||
const char *ws = cst_agent_config(t->agent)->workspace_root;
|
||
char path[OS_PATH_MAX];
|
||
if (cst_subagent_path(ws, pid, rec->file, path, sizeof(path)) != 0)
|
||
return;
|
||
cst_session_t *s = cst_session_open(path);
|
||
if (!s) {
|
||
push_system_line(t, "(cannot open %s)", path);
|
||
return;
|
||
}
|
||
push_system_line(t, "── %s · %s: %s (%s · %d steps · %d tool calls · %.1f s)",
|
||
rec->file, rec->role, rec->desc, rec->status, rec->steps,
|
||
rec->tools, rec->ms / 1000.0);
|
||
int n = cst_session_count(s);
|
||
const char *final = NULL;
|
||
for (int i = 0; i < n; i++) {
|
||
const cst_message_t *m = cst_session_get(s, i);
|
||
if (!m->content)
|
||
continue;
|
||
if (m->role == CST_ROLE_USER && !m->tool_call_id[0]) {
|
||
push_system_line(t, " prompt: %.300s", m->content);
|
||
} else if (m->tool_name[0]) {
|
||
int lines = 1;
|
||
for (const char *c = m->content; *c; c++)
|
||
lines += *c == '\n';
|
||
push_system_line(t, " · %s (%d line%s)", m->tool_name, lines,
|
||
lines == 1 ? "" : "s");
|
||
} else if (m->role == CST_ROLE_ASSISTANT && m->content[0] &&
|
||
!m->tool_calls_json) {
|
||
final = m->content;
|
||
}
|
||
}
|
||
if (final) {
|
||
push_system_line(t, " report:");
|
||
int shown = 0;
|
||
for (const char *p = final; *p && shown < 60; shown++) {
|
||
const char *nl = strchr(p, '\n');
|
||
int len = nl ? (int)(nl - p) : (int)strlen(p);
|
||
push_system_line(t, " %.*s", len > 400 ? 400 : len, p);
|
||
p = nl ? nl + 1 : p + len;
|
||
}
|
||
}
|
||
push_system_line(t, " transcript: %s", path);
|
||
cst_session_close(s);
|
||
}
|
||
|
||
static void cmd_agents(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
(void)arg;
|
||
const char *ws = cst_agent_config(t->agent)->workspace_root;
|
||
cst_agent_role_t *roles = calloc(CST_ROLES_MAX, sizeof(*roles));
|
||
if (!roles)
|
||
return;
|
||
int nr = cst_roles_list(ws, roles, CST_ROLES_MAX);
|
||
char pid[256];
|
||
cst_agent_session_id(t->agent, pid, sizeof(pid));
|
||
static cst_subagent_rec_t recs[64];
|
||
int nrec = cst_subagent_list(ws, pid, recs, 64);
|
||
/* one picker: roles insert a prompt prefix, runs open their transcript */
|
||
static char items[CST_ROLES_MAX + 64][128];
|
||
int n = 0;
|
||
for (int i = 0; i < nr; i++)
|
||
snprintf(items[n++], sizeof(items[0]), "role %-14.14s %-9.9s %.90s",
|
||
roles[i].name, roles[i].source, roles[i].desc);
|
||
for (int i = 0; i < nrec; i++)
|
||
snprintf(items[n++], sizeof(items[0]), "run %-7.7s %-10.10s %-9.9s %.80s",
|
||
recs[i].file, recs[i].role, recs[i].status, recs[i].desc);
|
||
if (cst_tool_find("task") == NULL)
|
||
push_system_line(t, "(the task tool is off: --tools minimal)");
|
||
int pick = picker_run(t, "Agents (Enter: role → ask for it · run → view "
|
||
"transcript)",
|
||
items, n, 0);
|
||
if (pick >= 0 && pick < nr) {
|
||
char line[160];
|
||
snprintf(line, sizeof(line), "Use the %s agent (task tool) to ",
|
||
roles[pick].name);
|
||
input_set(t, line);
|
||
} else if (pick >= nr) {
|
||
show_subagent(t, pid, &recs[pick - nr]);
|
||
}
|
||
cst_roles_free(roles, nr);
|
||
free(roles);
|
||
}
|
||
|
||
static void cmd_quit(tui_t *t, char *arg, int *quit) {
|
||
(void)t;
|
||
(void)arg;
|
||
*quit = 1;
|
||
}
|
||
|
||
static const slash_cmd_t SLASH[] = {
|
||
{"help", "?", "", "list commands and keys", 1, cmd_help},
|
||
{"new", "clear", "", "start a new session", 0, cmd_new},
|
||
{"session", "sessions resume", "[id]", "pick or switch session", 0,
|
||
cmd_session},
|
||
{"rename", "", "<title>", "name the current session", 1, cmd_rename},
|
||
{"delete", "", "[id]", "delete a session (asks first)", 0, cmd_delete},
|
||
{"model", "", "[name]", "pick or set the model", 0, cmd_model},
|
||
{"provider", "", "[name]", "switch provider (asks for its key)", 0,
|
||
cmd_provider},
|
||
{"key", "apikey", "", "enter or replace the provider's API key", 0,
|
||
cmd_key},
|
||
{"effort", "think", "[low|medium|high|max]", "reasoning effort", 0,
|
||
cmd_effort},
|
||
{"protocol", "", "[name]", "pick or set the tool-call protocol", 0,
|
||
cmd_protocol},
|
||
{"tools", "", "[on|off NAME|minimal]", "list or toggle tools", 0,
|
||
cmd_tools},
|
||
{"permissions", "perms", "[reset]", "show saved always-allow rules", 1,
|
||
cmd_permissions},
|
||
{"approval", "", "[ask|auto|yolo]", "how tools get approved (Shift+Tab)",
|
||
1, cmd_approval},
|
||
{"yolo", "", "", "toggle approving everything", 1, cmd_yolo},
|
||
{"sidebar", "", "", "show or hide the session sidebar", 1, cmd_sidebar},
|
||
{"mouse", "", "", "toggle mouse support (Shift+drag selects)", 1,
|
||
cmd_mouse},
|
||
{"max-steps", "", "[N]", "tool steps allowed per turn", 1, cmd_max_steps},
|
||
{"status", "", "", "session, model, context and settings", 1, cmd_status},
|
||
{"todo", "tasks", "", "show the agent's task list", 1, cmd_todo},
|
||
{"undo", "", "", "revert file changes of the last turn", 0, cmd_undo},
|
||
{"retry", "", "", "drop the last reply and ask again", 0, cmd_retry},
|
||
{"diff", "", "[path]", "git status + diff of the workspace", 1, cmd_diff},
|
||
{"compact", "summarize", "", "summarize the context to save tokens", 0,
|
||
cmd_compact},
|
||
{"export", "", "[file]", "write the transcript as Markdown", 1,
|
||
cmd_export},
|
||
{"copy", "", "", "copy the last reply (OSC 52)", 1, cmd_copy},
|
||
{"init", "", "", "have the agent write AGENTS.md", 0, cmd_init},
|
||
{"details", "", "", "expand/collapse all tool output", 1, cmd_details},
|
||
{"thinking", "", "[on|off]", "show Thought blocks expanded or collapsed",
|
||
1, cmd_thinking},
|
||
{"keys", "shortcuts", "", "keyboard shortcuts (keybinds.json)", 1,
|
||
cmd_keys},
|
||
{"skills", "", "[reload|list]", "list skills (SKILL.md)", 0, cmd_skills},
|
||
{"plugins", "", "[trust|untrust|reload]", "plugins and workspace trust", 0,
|
||
cmd_plugins},
|
||
{"mcp", "", "[reload]", "MCP servers and their state", 0, cmd_mcp},
|
||
{"agents", "", "", "sub-agent roles and this session's runs", 0,
|
||
cmd_agents},
|
||
{"skill", "", "<name> [task]", "run a task with a skill loaded", 0,
|
||
cmd_skill},
|
||
{"quit", "q exit", "", "quit", 1, cmd_quit},
|
||
};
|
||
|
||
static const slash_cmd_t *slash_table(int *n) {
|
||
*n = (int)(sizeof(SLASH) / sizeof(SLASH[0]));
|
||
return SLASH;
|
||
}
|
||
|
||
static int has_alias(const char *list, const char *name) {
|
||
size_t n = strlen(name);
|
||
for (const char *p = list; *p;) {
|
||
size_t w = strcspn(p, " ");
|
||
if (w == n && strncmp(p, name, n) == 0)
|
||
return 1;
|
||
p += w;
|
||
while (*p == ' ')
|
||
p++;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* Exact name, alias, or unique prefix. */
|
||
static const slash_cmd_t *slash_find(const char *name, int *ambiguous) {
|
||
int n;
|
||
const slash_cmd_t *tab = slash_table(&n), *hit = NULL;
|
||
*ambiguous = 0;
|
||
for (int i = 0; i < n; i++)
|
||
if (strcmp(tab[i].name, name) == 0 || has_alias(tab[i].aliases, name))
|
||
return &tab[i];
|
||
size_t len = strlen(name);
|
||
for (int i = 0; len && i < n; i++) {
|
||
if (strncmp(tab[i].name, name, len) == 0) {
|
||
if (hit)
|
||
*ambiguous = 1;
|
||
hit = &tab[i];
|
||
}
|
||
}
|
||
return *ambiguous ? NULL : hit;
|
||
}
|
||
|
||
static int handle_slash(tui_t *t, const char *line, int *should_quit) {
|
||
*should_quit = 0;
|
||
if (!line || line[0] != '/')
|
||
return 0;
|
||
char buf[INPUT_CAP];
|
||
snprintf(buf, sizeof(buf), "%s", line + 1);
|
||
char *cmd = buf;
|
||
while (*cmd == ' ')
|
||
cmd++;
|
||
char *arg = cmd + strcspn(cmd, " \n");
|
||
if (*arg) {
|
||
*arg++ = 0;
|
||
while (*arg == ' ')
|
||
arg++;
|
||
}
|
||
size_t al = strlen(arg);
|
||
while (al && (arg[al - 1] == ' ' || arg[al - 1] == '\n'))
|
||
arg[--al] = 0;
|
||
int amb = 0;
|
||
const slash_cmd_t *c = slash_find(cmd, &amb);
|
||
if (!c) {
|
||
push_system_line(t, amb ? "Ambiguous command /%s (try /help)"
|
||
: "Unknown command /%s (try /help)",
|
||
cmd);
|
||
return 1;
|
||
}
|
||
if (t->busy && !c->busy_ok) {
|
||
push_system_line(t, "/%s is not available while the agent is working "
|
||
"(Esc cancels the turn)",
|
||
c->name);
|
||
return 1;
|
||
}
|
||
char before[sizeof(t->status_msg)];
|
||
memcpy(before, t->status_msg, sizeof(before));
|
||
c->fn(t, arg, should_quit);
|
||
if (strcmp(before, t->status_msg) == 0)
|
||
status_idle(t);
|
||
return 1;
|
||
}
|
||
|
||
/* --- completion ----------------------------------------------------------- */
|
||
|
||
static void load_files(tui_t *t) {
|
||
if (t->files && since_ms(&t->files_at) < 10000)
|
||
return;
|
||
for (int i = 0; i < t->nfiles; i++)
|
||
free(t->files[i]);
|
||
free(t->files);
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
t->nfiles = cst_workspace_files(cfg->workspace_root, &t->files, 20000);
|
||
clock_gettime(CLOCK_MONOTONIC, &t->files_at);
|
||
}
|
||
|
||
static void update_completion(tui_t *t) {
|
||
t->comp_n = 0;
|
||
if (t->comp_off || t->in_paste)
|
||
return;
|
||
if (t->inbuf[0] == '/' && memchr(t->inbuf, ' ', t->cur) == NULL &&
|
||
memchr(t->inbuf, '\n', t->in_len) == NULL) {
|
||
int n;
|
||
const slash_cmd_t *tab = slash_table(&n);
|
||
size_t len = t->cur - 1;
|
||
for (int i = 0; i < n && t->comp_n < COMP_MAX; i++) {
|
||
if (strncmp(tab[i].name, t->inbuf + 1, len) != 0)
|
||
continue;
|
||
comp_item_t *c = &t->comp[t->comp_n++];
|
||
snprintf(c->text, sizeof(c->text), "/%s %s", tab[i].name, tab[i].args);
|
||
snprintf(c->desc, sizeof(c->desc), "%s", tab[i].desc);
|
||
}
|
||
t->comp_kind = 1;
|
||
t->comp_start = 0;
|
||
} else {
|
||
size_t st = t->cur;
|
||
while (st > 0 && t->inbuf[st - 1] != ' ' && t->inbuf[st - 1] != '\n')
|
||
st--;
|
||
if (st >= t->in_len || t->inbuf[st] != '@')
|
||
return;
|
||
char frag[256];
|
||
snprintf(frag, sizeof(frag), "%.*s", (int)(t->cur - st - 1),
|
||
t->inbuf + st + 1);
|
||
load_files(t);
|
||
/* basename prefix matches first, then any substring */
|
||
for (int pass = 0; pass < 2 && t->comp_n < COMP_MAX; pass++) {
|
||
for (int i = 0; i < t->nfiles && t->comp_n < COMP_MAX; i++) {
|
||
const char *f = t->files[i];
|
||
const char *base = strrchr(f, '/');
|
||
base = base ? base + 1 : f;
|
||
int hit = pass == 0 ? strncasecmp(base, frag, strlen(frag)) == 0
|
||
: (strncasecmp(base, frag, strlen(frag)) != 0 &&
|
||
strcasestr(f, frag) != NULL);
|
||
if (!hit)
|
||
continue;
|
||
comp_item_t *c = &t->comp[t->comp_n++];
|
||
snprintf(c->text, sizeof(c->text), "@%s", f);
|
||
c->desc[0] = 0;
|
||
}
|
||
}
|
||
t->comp_kind = 2;
|
||
t->comp_start = st;
|
||
}
|
||
if (t->comp_sel >= t->comp_n)
|
||
t->comp_sel = t->comp_n ? t->comp_n - 1 : 0;
|
||
}
|
||
|
||
/* Returns the chosen slash command when the popup was a slash list. */
|
||
static const slash_cmd_t *accept_completion(tui_t *t) {
|
||
if (t->comp_n <= 0)
|
||
return NULL;
|
||
const comp_item_t *c = &t->comp[t->comp_sel];
|
||
const slash_cmd_t *cmd = NULL;
|
||
char repl[300];
|
||
if (t->comp_kind == 1) {
|
||
char name[64];
|
||
snprintf(name, sizeof(name), "%.*s", (int)strcspn(c->text + 1, " "),
|
||
c->text + 1);
|
||
int amb;
|
||
cmd = slash_find(name, &amb);
|
||
snprintf(repl, sizeof(repl), "/%s ", name);
|
||
} else {
|
||
snprintf(repl, sizeof(repl), "%s ", c->text);
|
||
}
|
||
size_t from = t->comp_start, to = t->cur;
|
||
input_erase(t, from, to);
|
||
t->cur = from;
|
||
input_insert(t, repl, strlen(repl));
|
||
t->comp_n = 0;
|
||
t->comp_off = 1;
|
||
return cmd;
|
||
}
|
||
|
||
static void toggle_think(tui_t *t) {
|
||
int idx = t->focus_think_idx;
|
||
if (idx < 0) {
|
||
for (int i = t->hist.n - 1; i >= 0; i--) {
|
||
if (t->hist.items[i].kind == BLK_THINK) {
|
||
idx = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (idx < 0 || idx >= t->hist.n)
|
||
return;
|
||
block_t *b = &t->hist.items[idx];
|
||
if (b->kind != BLK_THINK)
|
||
return;
|
||
b->expanded = !b->expanded;
|
||
/* Keep timed title (Thought · Ns) if already set. */
|
||
if (b->expanded && (!b->title || !b->title[0]))
|
||
block_set_title(b, "Thought");
|
||
t->focus_think_idx = idx;
|
||
draw_log(t);
|
||
}
|
||
|
||
static void toggle_last_tool(tui_t *t) {
|
||
int idx = t->last_tool_idx;
|
||
if (idx < 0 || idx >= t->hist.n) {
|
||
for (int i = t->hist.n - 1; i >= 0; i--) {
|
||
if (t->hist.items[i].kind == BLK_TOOL) {
|
||
idx = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (idx < 0 || idx >= t->hist.n)
|
||
return;
|
||
block_t *b = &t->hist.items[idx];
|
||
if (b->kind != BLK_TOOL)
|
||
return;
|
||
b->expanded = !b->expanded;
|
||
t->last_tool_idx = idx;
|
||
draw_log(t);
|
||
}
|
||
|
||
/* --- turn worker ---------------------------------------------------------- */
|
||
|
||
typedef struct {
|
||
tui_t *t;
|
||
char *prompt;
|
||
} turn_arg_t;
|
||
|
||
static void *turn_thread(void *arg) {
|
||
turn_arg_t *a = arg;
|
||
tui_t *t = a->t;
|
||
cst_agent_hooks_t hooks = {0};
|
||
hooks.on_status = on_status;
|
||
hooks.on_assistant_delta = on_assistant_delta;
|
||
hooks.on_think_delta = on_think_delta;
|
||
hooks.on_tool_event = on_tool_event;
|
||
hooks.on_tool_result = on_tool_result;
|
||
hooks.on_permission = on_permission;
|
||
hooks.on_ask_user = on_ask_user;
|
||
hooks.on_todo = on_todo;
|
||
hooks.userdata = t;
|
||
|
||
char err[512];
|
||
err[0] = 0;
|
||
int rc = cst_agent_run_turn_ex(t->agent, a->prompt, &hooks, err, sizeof(err));
|
||
|
||
tui_lock(t);
|
||
t->turn_rc = rc;
|
||
snprintf(t->turn_err, sizeof(t->turn_err), "%s", err);
|
||
collapse_open_think(t);
|
||
if (t->as_live)
|
||
live_seal(t);
|
||
for (int i = 0; i < t->hist.n; i++) {
|
||
if (t->hist.items[i].kind == BLK_TOOL)
|
||
attach_tool_output_from_session(t, &t->hist.items[i]);
|
||
}
|
||
if (rc == 0) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
block_t *fb = blocks_push(&t->hist);
|
||
if (fb && cfg) {
|
||
char line[256];
|
||
int n = snprintf(line, sizeof(line), "\033[97m%s\033[39m %s %s %s %.1fs",
|
||
cfg->plan_mode ? "Plan" : "Build", g_ascii ? "-" : "·",
|
||
cfg->model, g_ascii ? "-" : "·",
|
||
since_ms(&t->turn_t0) / 1000.0);
|
||
fb->kind = BLK_FOOTER;
|
||
block_set_body(fb, line, n > 0 ? strlen(line) : 0);
|
||
}
|
||
}
|
||
t->turn_done = 1;
|
||
t->busy = 0;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
|
||
free(a->prompt);
|
||
free(a);
|
||
return NULL;
|
||
}
|
||
|
||
static void request_cancel_ui(tui_t *t) {
|
||
cst_agent_request_cancel(t->agent);
|
||
set_status(t, "cancelling…");
|
||
draw_status(t);
|
||
}
|
||
|
||
static int begin_turn(tui_t *t, char *prompt) {
|
||
prompt_hist_push(t, prompt);
|
||
block_t *ub = blocks_push(&t->hist);
|
||
if (ub) {
|
||
ub->kind = BLK_USER;
|
||
block_set_title(ub, "You");
|
||
block_set_body(ub, prompt, strlen(prompt));
|
||
ub->expanded = 1;
|
||
}
|
||
t->think_idx = -1;
|
||
t->log_scroll = 0;
|
||
set_status(t, "streaming…");
|
||
|
||
turn_arg_t *ta = calloc(1, sizeof(*ta));
|
||
if (!ta) {
|
||
free(prompt);
|
||
return -1;
|
||
}
|
||
ta->t = t;
|
||
ta->prompt = prompt;
|
||
t->busy = 1;
|
||
t->turn_done = 0;
|
||
clock_gettime(CLOCK_MONOTONIC, &t->turn_t0);
|
||
redraw_all(t);
|
||
pthread_t th;
|
||
if (pthread_create(&th, NULL, turn_thread, ta) != 0) {
|
||
t->busy = 0;
|
||
free(prompt);
|
||
free(ta);
|
||
push_system_line(t, "Error: failed to start turn thread");
|
||
status_idle(t);
|
||
redraw_all(t);
|
||
return -1;
|
||
}
|
||
pthread_detach(th);
|
||
return 0;
|
||
}
|
||
|
||
/* Owns prompt. Returns 0 ok, -1 fail, -2 quit. */
|
||
static int start_turn(tui_t *t, char *prompt) {
|
||
if (!prompt)
|
||
return -1;
|
||
if (prompt[0] == '/') {
|
||
int should_quit = 0;
|
||
handle_slash(t, prompt, &should_quit);
|
||
free(prompt);
|
||
t->log_scroll = 0;
|
||
if (t->need_layout) {
|
||
t->need_layout = 0;
|
||
layout(t);
|
||
wtimeout(t->input, 80);
|
||
}
|
||
redraw_all(t);
|
||
if (should_quit)
|
||
return -2;
|
||
if (t->pending_prompt && !t->busy) {
|
||
char *next = t->pending_prompt;
|
||
t->pending_prompt = NULL;
|
||
return begin_turn(t, next);
|
||
}
|
||
return 0;
|
||
}
|
||
return begin_turn(t, prompt);
|
||
}
|
||
|
||
static int try_start_queued(tui_t *t) {
|
||
if (t->busy || t->queue_n <= 0)
|
||
return 0;
|
||
char *next = queue_pop(t);
|
||
if (!next)
|
||
return 0;
|
||
return start_turn(t, next);
|
||
}
|
||
|
||
static int submit_input(tui_t *t, int busy) {
|
||
if (t->in_len == 0) {
|
||
if (!busy && t->queue_n > 0)
|
||
return try_start_queued(t);
|
||
if (!busy)
|
||
toggle_last_tool(t);
|
||
return 0;
|
||
}
|
||
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
if (!busy && t->inbuf[0] != '/' && !cfg->api_key[0]) {
|
||
char key[512];
|
||
if (obtain_key(t, cfg->provider, key, sizeof(key), 0) != 0) {
|
||
push_system_line(t, "No API key for %s yet — /key to enter one, or "
|
||
"/provider to switch",
|
||
cfg->provider);
|
||
return 0;
|
||
}
|
||
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
|
||
memset(key, 0, sizeof(key));
|
||
}
|
||
|
||
char *prompt = strdup(t->inbuf);
|
||
input_clear(t);
|
||
layout_maybe_grow_input(t);
|
||
if (!prompt)
|
||
return 0;
|
||
|
||
if (busy && prompt[0] == '/') {
|
||
int q = 0;
|
||
handle_slash(t, prompt, &q);
|
||
free(prompt);
|
||
redraw_all(t);
|
||
return q ? -2 : 0;
|
||
}
|
||
if (busy) {
|
||
if (queue_push(t, prompt) != 0) {
|
||
push_system_line(t, "(queue full — max %d)", QUEUE_CAP);
|
||
set_status(t, "queue full");
|
||
} else {
|
||
char msg[64];
|
||
snprintf(msg, sizeof(msg), "queued (%d)", t->queue_n);
|
||
set_status(t, msg);
|
||
}
|
||
free(prompt);
|
||
draw_status(t);
|
||
return 0;
|
||
}
|
||
return start_turn(t, prompt);
|
||
}
|
||
|
||
/* Byte offset nearest to visual (row, col) of the input, matching
|
||
* input_layout's wrapping. */
|
||
static size_t input_hit(const tui_t *t, int want_row, int want_col) {
|
||
int row = 0, col = IN_COL;
|
||
int right = (t->in_w > 0 ? t->in_w : t->cols) - 2;
|
||
size_t i = 0, best = t->in_len;
|
||
while (i < t->in_len) {
|
||
if (row == want_row && col >= want_col)
|
||
return i;
|
||
if (row > want_row)
|
||
return best;
|
||
if (t->inbuf[i] == '\n') {
|
||
if (row == want_row)
|
||
return i;
|
||
row++;
|
||
col = IN_COL;
|
||
i++;
|
||
continue;
|
||
}
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(t->inbuf + i, t->in_len - i, &wc, &eaten);
|
||
int cw = utf8_width(wc == L'\t' ? L' ' : wc);
|
||
if (col + cw > right && col > IN_COL) {
|
||
if (row == want_row)
|
||
return i;
|
||
row++;
|
||
col = IN_COL;
|
||
}
|
||
best = i + eaten;
|
||
col += cw;
|
||
i += eaten;
|
||
}
|
||
return t->in_len;
|
||
}
|
||
|
||
static void toggle_plan(tui_t *t);
|
||
static int run_palette(tui_t *t, int busy, int *quit);
|
||
static void after_edit(tui_t *t);
|
||
|
||
/* Returns -2 to quit (palette → /quit), else 0. */
|
||
static int handle_click(tui_t *t, int y, int x, int busy) {
|
||
/* completion rows */
|
||
if (g_comp_y0 >= 0 && t->comp_n > 0 && y >= g_comp_y0 &&
|
||
y < g_comp_y0 + t->comp_n && x >= g_comp_x0 && x < g_comp_x0 + g_comp_w) {
|
||
t->comp_sel = y - g_comp_y0;
|
||
accept_completion(t);
|
||
after_edit(t);
|
||
redraw_all(t);
|
||
return 0;
|
||
}
|
||
/* input box */
|
||
if (y >= t->in_y && y < t->in_y + t->in_h && x >= t->in_x &&
|
||
x < t->in_x + t->in_w) {
|
||
int h = t->input_rows > 0 ? t->input_rows : 1;
|
||
int pad = (t->in_h - h - 1) / 2;
|
||
int ry = y - t->in_y;
|
||
if (ry == pad + h) {
|
||
if (x - t->in_x < IN_COL + 6)
|
||
toggle_plan(t);
|
||
return 0;
|
||
}
|
||
int cr, cc;
|
||
input_layout(t, NULL, NULL, &cr, &cc);
|
||
int top = cr >= h ? cr - h + 1 : 0;
|
||
int row = ry - pad + top;
|
||
if (row < 0)
|
||
row = 0;
|
||
t->cur = input_hit(t, row, x - t->in_x);
|
||
draw_input(t);
|
||
return 0;
|
||
}
|
||
/* status row: "ctrl+p commands" */
|
||
if (y == t->rows - 1 && t->cmd_x0 >= 0 && x >= t->cmd_x0 && x <= t->cmd_x1) {
|
||
int quit = 0;
|
||
return run_palette(t, busy, &quit) == -2 || quit ? -2 : 0;
|
||
}
|
||
/* transcript blocks */
|
||
if (!t->home && t->logw) {
|
||
int lh, lw;
|
||
getmaxyx(t->logw, lh, lw);
|
||
int ry = y - t->log_y, rx = x - t->log_x;
|
||
if (ry < 0 || ry >= lh || rx < 0 || rx >= lw || ry >= t->row_blk_cap)
|
||
return 0;
|
||
int bi = t->row_blk[ry];
|
||
if (bi < 0 || bi >= t->hist.n)
|
||
return 0;
|
||
block_t *b = &t->hist.items[bi];
|
||
if (b->kind == BLK_THINK) {
|
||
t->focus_think_idx = bi;
|
||
b->expanded = !b->expanded;
|
||
} else if (b->kind == BLK_TOOL) {
|
||
t->last_tool_idx = bi;
|
||
b->expanded = !b->expanded;
|
||
} else {
|
||
return 0;
|
||
}
|
||
draw_log(t);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* Tab completes after "/" at the start or an "@word" under the cursor. */
|
||
static int wants_completion(const tui_t *t) {
|
||
if (t->in_len && t->inbuf[0] == '/')
|
||
return 1;
|
||
size_t i = t->cur;
|
||
while (i > 0 && t->inbuf[i - 1] != ' ' && t->inbuf[i - 1] != '\n')
|
||
i--;
|
||
return i < t->cur && t->inbuf[i] == '@';
|
||
}
|
||
|
||
static void toggle_plan(tui_t *t) {
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
cfg->plan_mode = !cfg->plan_mode;
|
||
set_status(t, cfg->plan_mode ? "Plan mode: read-only tools, answers with a "
|
||
"plan"
|
||
: "Build mode: all tools");
|
||
draw_status(t);
|
||
draw_input(t);
|
||
}
|
||
|
||
static int run_action(tui_t *t, int action, int busy);
|
||
static int run_keys_panel(tui_t *t, int busy, int *quit);
|
||
static void insert_newline(tui_t *t);
|
||
|
||
/* Keys bound to the action whose slash command is "/name", or "". */
|
||
static void slash_binding(const tui_t *t, const char *name, char *buf,
|
||
size_t n) {
|
||
buf[0] = 0;
|
||
for (int a = 1; a < CST_KA_COUNT; a++) {
|
||
const cst_key_action_info_t *in = cst_key_action_info(a);
|
||
if (in && in->slash && strcmp(in->slash + 1, name) == 0) {
|
||
cst_keymap_describe(&t->km, a, buf, n);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Ctrl+P: every slash command plus key-only actions, filterable. */
|
||
static int run_palette(tui_t *t, int busy, int *quit) {
|
||
static const int extra[] = {CST_KA_MODE, CST_KA_APPROVAL,
|
||
CST_KA_TOGGLE_THOUGHT, CST_KA_TOGGLE_TOOL,
|
||
CST_KA_TODO, CST_KA_KEYS};
|
||
int ne = (int)(sizeof(extra) / sizeof(extra[0]));
|
||
int nc;
|
||
const slash_cmd_t *tab = slash_table(&nc);
|
||
static char items[96][128];
|
||
int map[96];
|
||
int n = 0;
|
||
char keys[64];
|
||
for (int i = 0; i < ne && n < 96; i++, n++) {
|
||
const cst_key_action_info_t *in = cst_key_action_info(extra[i]);
|
||
cst_keymap_describe(&t->km, extra[i], keys, sizeof(keys));
|
||
char d[64];
|
||
snprintf(d, sizeof(d), "%c%s", toupper((unsigned char)in->desc[0]),
|
||
in->desc + 1);
|
||
snprintf(items[n], sizeof(items[n]), "%-44s %s", d, keys);
|
||
map[n] = -1 - extra[i];
|
||
}
|
||
for (int i = 0; i < nc && n < 96; i++, n++) {
|
||
slash_binding(t, tab[i].name, keys, sizeof(keys));
|
||
char d[64];
|
||
snprintf(d, sizeof(d), "/%-12s %.30s", tab[i].name, tab[i].desc);
|
||
snprintf(items[n], sizeof(items[n]), "%-44s %s", d, keys);
|
||
map[n] = i;
|
||
}
|
||
int pick = picker_run(t, "Commands", items, n, -1);
|
||
if (pick < 0)
|
||
return 0;
|
||
if (map[pick] < 0) {
|
||
int rc = run_action(t, -1 - map[pick], busy);
|
||
if (rc == -2)
|
||
*quit = 1;
|
||
return rc;
|
||
}
|
||
const slash_cmd_t *c = &tab[map[pick]];
|
||
char line[64];
|
||
snprintf(line, sizeof(line), "/%s", c->name);
|
||
if (c->args[0] == '<') { /* needs an argument: let the user type it */
|
||
snprintf(line, sizeof(line), "/%s ", c->name);
|
||
input_set(t, line);
|
||
redraw_all(t);
|
||
return 0;
|
||
}
|
||
if (busy) {
|
||
handle_slash(t, line, quit);
|
||
redraw_all(t);
|
||
return *quit ? -2 : 0;
|
||
}
|
||
char *p = xstrdup(line);
|
||
return p ? start_turn(t, p) : 0;
|
||
}
|
||
|
||
/* --- key bindings ---------------------------------------------------------- */
|
||
|
||
/* Runs "/cmd" the way the palette does; -2 means quit. */
|
||
static int run_slash_line(tui_t *t, const char *line, int busy) {
|
||
if (busy) {
|
||
int quit = 0;
|
||
handle_slash(t, line, &quit);
|
||
redraw_all(t);
|
||
return quit ? -2 : 0;
|
||
}
|
||
char *p = xstrdup(line);
|
||
return p ? start_turn(t, p) : 0;
|
||
}
|
||
|
||
static void scroll_log(tui_t *t, int delta) {
|
||
t->log_scroll += delta;
|
||
if (t->log_scroll < 0)
|
||
t->log_scroll = 0;
|
||
draw_log(t);
|
||
}
|
||
|
||
static int run_action(tui_t *t, int action, int busy) {
|
||
int quit = 0;
|
||
switch (action) {
|
||
case CST_KA_PALETTE:
|
||
return run_palette(t, busy, &quit) == -2 || quit ? -2 : 0;
|
||
case CST_KA_KEYS:
|
||
return run_keys_panel(t, busy, &quit) == -2 || quit ? -2 : 0;
|
||
case CST_KA_MODE:
|
||
toggle_plan(t);
|
||
return 0;
|
||
case CST_KA_APPROVAL: {
|
||
set_approval(t, (approval_mode(t) + 1) % 3);
|
||
char label[64], msg[160];
|
||
approval_label(t, label, sizeof(label));
|
||
snprintf(msg, sizeof(msg), "approval: %s", label);
|
||
set_status(t, msg);
|
||
redraw_all(t);
|
||
return 0;
|
||
}
|
||
case CST_KA_NEWLINE:
|
||
insert_newline(t);
|
||
return 0;
|
||
case CST_KA_REDRAW:
|
||
clearok(curscr, TRUE);
|
||
redraw_all(t);
|
||
return 0;
|
||
case CST_KA_QUIT:
|
||
return -2;
|
||
case CST_KA_TOGGLE_TOOL:
|
||
toggle_last_tool(t);
|
||
return 0;
|
||
case CST_KA_TOGGLE_THOUGHT:
|
||
toggle_think(t);
|
||
return 0;
|
||
case CST_KA_TODO:
|
||
t->show_todo = !t->show_todo;
|
||
layout(t);
|
||
wtimeout(t->input, 80);
|
||
redraw_all(t);
|
||
return 0;
|
||
case CST_KA_SCROLL_UP:
|
||
scroll_log(t, t->rows / 2);
|
||
return 0;
|
||
case CST_KA_SCROLL_DOWN:
|
||
scroll_log(t, -(t->rows / 2));
|
||
return 0;
|
||
case CST_KA_LINE_UP:
|
||
scroll_log(t, 1);
|
||
return 0;
|
||
case CST_KA_LINE_DOWN:
|
||
scroll_log(t, -1);
|
||
return 0;
|
||
case CST_KA_TOP:
|
||
scroll_log(t, 1 << 28); /* clamped by draw_log */
|
||
return 0;
|
||
case CST_KA_BOTTOM:
|
||
t->log_scroll = 0;
|
||
draw_log(t);
|
||
return 0;
|
||
default: {
|
||
const cst_key_action_info_t *in = cst_key_action_info(action);
|
||
if (in && in->slash)
|
||
return run_slash_line(t, in->slash, busy);
|
||
return 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Leader pressed: show what can follow it. */
|
||
static void leader_hint(tui_t *t) {
|
||
char msg[sizeof(t->status_msg)], k[32];
|
||
size_t o = (size_t)snprintf(msg, sizeof(msg), "%s ›",
|
||
cst_key_name(t->km.leader, k, sizeof(k)));
|
||
for (int i = 0; i < t->km.n && o + 1 < sizeof(msg); i++) {
|
||
const cst_binding_t *b = &t->km.b[i];
|
||
if (b->nkeys != 2 || b->keys[0] != t->km.leader)
|
||
continue;
|
||
const cst_key_action_info_t *in = cst_key_action_info(b->action);
|
||
int w = snprintf(msg + o, sizeof(msg) - o, " %s %s",
|
||
cst_key_name(b->keys[1], k, sizeof(k)), in->name);
|
||
if (w < 0 || (size_t)w >= sizeof(msg) - o)
|
||
break;
|
||
o += (size_t)w;
|
||
}
|
||
set_status(t, msg);
|
||
draw_status(t);
|
||
}
|
||
|
||
/* 1 = handled, 0 = not bound (fall through to editing keys), -2 = quit. */
|
||
static int key_dispatch(tui_t *t, int code, int busy) {
|
||
if (code <= 0)
|
||
return 0;
|
||
if (t->leader_pending && since_ms(&t->leader_at) > 3000)
|
||
t->leader_pending = 0;
|
||
int action = 0;
|
||
if (t->leader_pending) {
|
||
int seq[2] = {t->km.leader, code};
|
||
t->leader_pending = 0;
|
||
status_idle(t);
|
||
if (code == 27) {
|
||
draw_status(t);
|
||
return 1;
|
||
}
|
||
if (cst_keymap_lookup(&t->km, seq, 2, &action) == CST_KM_MATCH)
|
||
return run_action(t, action, busy) == -2 ? -2 : 1;
|
||
char a[32], b[32], msg[96];
|
||
snprintf(msg, sizeof(msg), "no shortcut for %s %s",
|
||
cst_key_name(seq[0], a, sizeof(a)),
|
||
cst_key_name(code, b, sizeof(b)));
|
||
set_status(t, msg);
|
||
draw_status(t);
|
||
return 1;
|
||
}
|
||
/* Reserved: Enter, Ctrl+C, Ctrl+D, Esc; plain typing. */
|
||
if (code == 13 || code == 10 || code == 3 || code == 4 || code == 27)
|
||
return 0;
|
||
if (code >= 32 && code < CST_KEY_SPECIAL && code != 127)
|
||
return 0;
|
||
/* Completion popup keys win over bindings. */
|
||
if (code == 9 && (t->comp_n > 0 || wants_completion(t)))
|
||
return 0;
|
||
if (t->comp_n > 0 &&
|
||
(code == CST_K_BTAB || code == CST_K_UP || code == CST_K_DOWN))
|
||
return 0;
|
||
int seq[1] = {code};
|
||
int r = cst_keymap_lookup(&t->km, seq, 1, &action);
|
||
if (r == CST_KM_MATCH)
|
||
return run_action(t, action, busy) == -2 ? -2 : 1;
|
||
if (r == CST_KM_PREFIX) {
|
||
t->leader_pending = 1;
|
||
clock_gettime(CLOCK_MONOTONIC, &t->leader_at);
|
||
leader_hint(t);
|
||
return 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* /keys and F1: every action with its keys; picking one runs it. */
|
||
static int run_keys_panel(tui_t *t, int busy, int *quit) {
|
||
static char items[CST_KA_COUNT + 16][128];
|
||
int map[CST_KA_COUNT + 16];
|
||
int n = 0;
|
||
static const char *const GROUPS[] = {"General", "View", "Session", "Model",
|
||
"Agents"};
|
||
for (size_t g = 0; g < sizeof(GROUPS) / sizeof(GROUPS[0]); g++) {
|
||
for (int a = 1; a < CST_KA_COUNT; a++) {
|
||
const cst_key_action_info_t *in = cst_key_action_info(a);
|
||
if (!in || strcmp(in->group, GROUPS[g]) != 0)
|
||
continue;
|
||
char keys[64];
|
||
cst_keymap_describe(&t->km, a, keys, sizeof(keys));
|
||
int user = 0;
|
||
for (int i = 0; i < t->km.n; i++)
|
||
if (t->km.b[i].action == a && t->km.b[i].user)
|
||
user = 1;
|
||
snprintf(items[n], sizeof(items[n]), "%-8s %-36s %s%s", in->group,
|
||
in->desc, keys[0] ? keys : "(unbound)", user ? " *" : "");
|
||
map[n++] = a;
|
||
}
|
||
}
|
||
static const char *const FIXED[] = {
|
||
"Edit send enter",
|
||
"Edit cancel the turn / close popups esc",
|
||
"Edit clear input, twice to quit ctrl+c",
|
||
"Edit quit on an empty prompt ctrl+d",
|
||
"Edit new line (kitty keyboard terminals) shift+enter ctrl+enter",
|
||
"Edit move by char / word / line ← → ctrl+← → alt+b alt+f ctrl+a ctrl+e",
|
||
"Edit delete word back / forward ctrl+w alt+backspace ctrl+backspace ctrl+del",
|
||
"Edit delete to start / to end ctrl+u ctrl+k",
|
||
"Edit prompt history ↑ ↓",
|
||
};
|
||
for (size_t i = 0; i < sizeof(FIXED) / sizeof(FIXED[0]); i++) {
|
||
snprintf(items[n], sizeof(items[n]), "%s", FIXED[i]);
|
||
map[n++] = 0;
|
||
}
|
||
char conf[512];
|
||
if (cst_keymap_conflicts(&t->km, conf, sizeof(conf)) > 0)
|
||
push_system_line(t, "Key conflicts (the first binding wins):\n%s", conf);
|
||
int pick = picker_run(t, "Keys (* = keybinds.json)", items, n, -1);
|
||
if (pick < 0 || map[pick] == 0)
|
||
return 0;
|
||
int rc = run_action(t, map[pick], busy);
|
||
if (rc == -2)
|
||
*quit = 1;
|
||
return rc;
|
||
}
|
||
|
||
#ifdef NCURSES_VERSION
|
||
static int g_k_alt_up, g_k_alt_down, g_k_ctrl_home, g_k_ctrl_end,
|
||
g_k_ctrl_up, g_k_ctrl_down, g_k_ctrl_left, g_k_ctrl_right, g_k_alt_left,
|
||
g_k_alt_right, g_k_ctrl_dc, g_k_alt_dc;
|
||
|
||
static int ext_key(const char *cap) {
|
||
char *s = tigetstr(cap);
|
||
if (!s || s == (char *)-1)
|
||
return 0;
|
||
int k = key_defined(s);
|
||
return k > 0 ? k : 0;
|
||
}
|
||
#endif
|
||
|
||
static void keys_init(void) {
|
||
#ifdef NCURSES_VERSION
|
||
g_k_alt_up = ext_key("kUP3");
|
||
g_k_alt_down = ext_key("kDN3");
|
||
g_k_ctrl_home = ext_key("kHOM5");
|
||
g_k_ctrl_end = ext_key("kEND5");
|
||
g_k_ctrl_up = ext_key("kUP5");
|
||
g_k_ctrl_down = ext_key("kDN5");
|
||
g_k_ctrl_left = ext_key("kLFT5");
|
||
g_k_ctrl_right = ext_key("kRIT5");
|
||
g_k_alt_left = ext_key("kLFT3");
|
||
g_k_alt_right = ext_key("kRIT3");
|
||
g_k_ctrl_dc = ext_key("kDC5");
|
||
g_k_alt_dc = ext_key("kDC3");
|
||
#endif
|
||
}
|
||
|
||
/* ctrl/alt+left/right move by word, ctrl/alt+delete deletes the next word. */
|
||
static int word_key(int k, int *dir) {
|
||
#ifdef NCURSES_VERSION
|
||
if (k && (k == g_k_ctrl_left || k == g_k_alt_left))
|
||
return *dir = -1, 1;
|
||
if (k && (k == g_k_ctrl_right || k == g_k_alt_right))
|
||
return *dir = 1, 1;
|
||
if (k && (k == g_k_ctrl_dc || k == g_k_alt_dc))
|
||
return *dir = 0, 1;
|
||
#else
|
||
(void)k, (void)dir;
|
||
#endif
|
||
return 0;
|
||
}
|
||
|
||
/* ncurses event → cst_keymap code (0 for events that are not keys). */
|
||
static int tui_key_code(int rc, wint_t wch) {
|
||
if (rc != KEY_CODE_YES)
|
||
return (int)wch;
|
||
int k = (int)wch;
|
||
switch (k) {
|
||
case KEY_UP:
|
||
return CST_K_UP;
|
||
case KEY_DOWN:
|
||
return CST_K_DOWN;
|
||
case KEY_LEFT:
|
||
return CST_K_LEFT;
|
||
case KEY_RIGHT:
|
||
return CST_K_RIGHT;
|
||
case KEY_HOME:
|
||
return CST_K_HOME;
|
||
case KEY_END:
|
||
return CST_K_END;
|
||
case KEY_PPAGE:
|
||
return CST_K_PGUP;
|
||
case KEY_NPAGE:
|
||
return CST_K_PGDN;
|
||
case KEY_BTAB:
|
||
return CST_K_BTAB;
|
||
case KEY_DC:
|
||
return CST_K_DEL;
|
||
case KEY_IC:
|
||
return CST_K_INS;
|
||
case KEY_ENTER:
|
||
return 13;
|
||
case KEY_BACKSPACE:
|
||
return 127;
|
||
default:
|
||
break;
|
||
}
|
||
if (k >= KEY_F(1) && k <= KEY_F(12))
|
||
return CST_K_F1 + (k - KEY_F(1));
|
||
#ifdef NCURSES_VERSION
|
||
if (k && k == g_k_alt_up)
|
||
return CST_K_ALT_UP;
|
||
if (k && k == g_k_alt_down)
|
||
return CST_K_ALT_DOWN;
|
||
if (k && k == g_k_ctrl_home)
|
||
return CST_K_CTRL_HOME;
|
||
if (k && k == g_k_ctrl_end)
|
||
return CST_K_CTRL_END;
|
||
if (k && k == g_k_ctrl_up)
|
||
return CST_K_CTRL_UP;
|
||
if (k && k == g_k_ctrl_down)
|
||
return CST_K_CTRL_DOWN;
|
||
#endif
|
||
return 0;
|
||
}
|
||
|
||
/* --- modal key loops ------------------------------------------------------ */
|
||
|
||
static void perm_answer(tui_t *t, int ans) {
|
||
tui_lock(t);
|
||
t->perm_ans = ans;
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void run_perm_modal(tui_t *t) {
|
||
wtimeout(t->input, -1);
|
||
curs_set(0);
|
||
for (;;) {
|
||
draw_perm_modal(t);
|
||
draw_status(t);
|
||
wint_t wch = 0;
|
||
int rc = tui_wget_wch(t->input, &wch);
|
||
if (rc == ERR)
|
||
continue;
|
||
if (rc == KEY_CODE_YES) {
|
||
int h = getmaxy(t->logw);
|
||
if (wch == KEY_RESIZE)
|
||
layout(t), wtimeout(t->input, -1), redraw_all(t);
|
||
else if (wch == KEY_UP)
|
||
t->perm_scroll--;
|
||
else if (wch == KEY_DOWN)
|
||
t->perm_scroll++;
|
||
else if (wch == KEY_PPAGE)
|
||
t->perm_scroll -= h / 2;
|
||
else if (wch == KEY_NPAGE)
|
||
t->perm_scroll += h / 2;
|
||
else if (wch == KEY_ENTER) {
|
||
perm_answer(t, CST_PERM_ONCE);
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == L'y' || wch == L'Y' || wch == 10 || wch == 13) {
|
||
perm_answer(t, CST_PERM_ONCE);
|
||
break;
|
||
}
|
||
if (wch == L'n' || wch == L'N' || wch == 27) {
|
||
perm_answer(t, CST_PERM_DENY);
|
||
break;
|
||
}
|
||
if (wch == 3) { /* Ctrl+C: deny and stop the turn */
|
||
perm_answer(t, CST_PERM_DENY);
|
||
request_cancel_ui(t);
|
||
break;
|
||
}
|
||
if ((wch == L'a' || wch == L'A') && !t->perm_sandbox) {
|
||
perm_answer(t, CST_PERM_ALWAYS_TOOL);
|
||
break;
|
||
}
|
||
if ((wch == L'p' || wch == L'P') && t->perm_prefix[0]) {
|
||
perm_answer(t, CST_PERM_ALWAYS_PREFIX);
|
||
break;
|
||
}
|
||
}
|
||
curs_set(1);
|
||
wtimeout(t->input, 80);
|
||
redraw_all(t);
|
||
}
|
||
|
||
static void ask_finish(tui_t *t, int rc) {
|
||
tui_lock(t);
|
||
if (rc == 0 && !t->ask_buf[0] && t->ask_nopts)
|
||
snprintf(t->ask_buf, sizeof(t->ask_buf), "%s", t->ask_opts[t->ask_sel]);
|
||
t->ask_rc = rc;
|
||
t->ask_done = 1;
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void run_ask_modal(tui_t *t) {
|
||
wtimeout(t->input, -1);
|
||
curs_set(0);
|
||
for (;;) {
|
||
draw_ask_modal(t);
|
||
wint_t wch = 0;
|
||
int rc = tui_wget_wch(t->input, &wch);
|
||
if (rc == ERR)
|
||
continue;
|
||
if (rc == KEY_CODE_YES) {
|
||
if (wch == KEY_RESIZE)
|
||
layout(t), wtimeout(t->input, -1), redraw_all(t);
|
||
else if (wch == KEY_UP && t->ask_sel > 0)
|
||
t->ask_sel--;
|
||
else if (wch == KEY_DOWN && t->ask_sel + 1 < t->ask_nopts)
|
||
t->ask_sel++;
|
||
else if (wch == KEY_BACKSPACE) {
|
||
size_t n = strlen(t->ask_buf);
|
||
t->ask_buf[n - utf8_prev_len(t->ask_buf, n)] = 0;
|
||
} else if (wch == KEY_ENTER && (t->ask_buf[0] || t->ask_nopts)) {
|
||
ask_finish(t, 0);
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 27 || wch == 3) {
|
||
ask_finish(t, -1);
|
||
break;
|
||
}
|
||
if (wch == 10 || wch == 13) {
|
||
if (t->ask_buf[0] || t->ask_nopts) {
|
||
ask_finish(t, 0);
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 127 || wch == 8) {
|
||
size_t n = strlen(t->ask_buf);
|
||
t->ask_buf[n - utf8_prev_len(t->ask_buf, n)] = 0;
|
||
continue;
|
||
}
|
||
if (!t->ask_buf[0] && wch >= L'1' && wch <= L'9' &&
|
||
(int)(wch - L'1') < t->ask_nopts) {
|
||
t->ask_sel = (int)(wch - L'1');
|
||
ask_finish(t, 0);
|
||
break;
|
||
}
|
||
if (iswprint(wch)) {
|
||
char mb[8];
|
||
mbstate_t st;
|
||
memset(&st, 0, sizeof(st));
|
||
size_t k = wcrtomb(mb, (wchar_t)wch, &st);
|
||
size_t n = strlen(t->ask_buf);
|
||
if (k != (size_t)-1 && n + k + 1 < sizeof(t->ask_buf)) {
|
||
memcpy(t->ask_buf + n, mb, k);
|
||
t->ask_buf[n + k] = 0;
|
||
}
|
||
}
|
||
}
|
||
curs_set(1);
|
||
wtimeout(t->input, 80);
|
||
redraw_all(t);
|
||
}
|
||
|
||
/* --- main loop ------------------------------------------------------------ */
|
||
|
||
static void tui_free(tui_t *t) {
|
||
queue_free_all(t);
|
||
for (int i = 0; i < t->prompt_hist_n; i++)
|
||
free(t->prompt_hist[i]);
|
||
for (int i = 0; i < t->nfiles; i++)
|
||
free(t->files[i]);
|
||
free(t->files);
|
||
free(t->todo);
|
||
free(t->pending_prompt);
|
||
free(t->perm_preview);
|
||
live_end_cleanup(t);
|
||
free_windows(t);
|
||
free(t->row_blk);
|
||
blocks_free(&t->hist);
|
||
pthread_mutex_destroy(&t->mu);
|
||
}
|
||
|
||
/* Redraw after an edit; the transcript only when the popup changed. */
|
||
static void after_edit(tui_t *t) {
|
||
layout_maybe_grow_input(t);
|
||
if (t->comp_n || t->comp_drawn) {
|
||
draw_log(t);
|
||
draw_input(t);
|
||
}
|
||
}
|
||
|
||
static void insert_newline(tui_t *t) {
|
||
input_put_byte(t, '\n');
|
||
layout_maybe_grow_input(t);
|
||
}
|
||
|
||
/* Paste mode: keep newlines/tabs literally; redraw once at the end. */
|
||
static void paste_char(tui_t *t, wint_t wch) {
|
||
if (wch == 13) {
|
||
input_put_byte(t, '\n');
|
||
t->paste_cr = 1;
|
||
return;
|
||
}
|
||
if (wch == 10) {
|
||
if (!t->paste_cr)
|
||
input_put_byte(t, '\n');
|
||
t->paste_cr = 0;
|
||
return;
|
||
}
|
||
t->paste_cr = 0;
|
||
if (wch == L'\t')
|
||
input_insert(t, " ", 4);
|
||
else if (iswprint(wch) || wcwidth((wchar_t)wch) > 0)
|
||
input_put_wc(t, (wchar_t)wch);
|
||
}
|
||
|
||
int cst_tui_run(cst_agent_t *agent) {
|
||
if (!setlocale(LC_ALL, ""))
|
||
setlocale(LC_ALL, "C.UTF-8");
|
||
#ifndef _WIN32
|
||
if (!getenv("ESCDELAY"))
|
||
setenv("ESCDELAY", "25", 0);
|
||
#endif
|
||
|
||
initscr();
|
||
raw(); /* Ctrl+C/Ctrl+Z reach us as keys instead of signals */
|
||
noecho();
|
||
keypad(stdscr, TRUE);
|
||
curs_set(1);
|
||
theme_init();
|
||
#ifdef NCURSES_VERSION
|
||
define_key("\033[200~", KEY_PASTE_BEGIN);
|
||
define_key("\033[201~", KEY_PASTE_END);
|
||
printf("\033[?2004h");
|
||
fflush(stdout);
|
||
kitty_init();
|
||
#endif
|
||
|
||
tui_t t;
|
||
memset(&t, 0, sizeof(t));
|
||
t.agent = agent;
|
||
t.think_idx = -1;
|
||
t.last_tool_idx = -1;
|
||
t.focus_think_idx = -1;
|
||
t.hist_browse = -1;
|
||
t.show_todo = 1;
|
||
t.show_side = 1;
|
||
{
|
||
cst_settings_t st;
|
||
cst_settings_load(&st);
|
||
t.show_thinking = st.show_thinking;
|
||
}
|
||
keys_init();
|
||
char key_err[256] = "";
|
||
cst_keymap_load(&t.km, key_err, sizeof(key_err));
|
||
t.mouse_on = 1;
|
||
{
|
||
int sk = cst_sandbox_probe();
|
||
t.sandbox_name = sk == CST_SANDBOX_NONE ? "rules" : cst_sandbox_name(sk);
|
||
}
|
||
t.tip = TIPS[(unsigned)time(NULL) % (sizeof(TIPS) / sizeof(TIPS[0]))];
|
||
where_label(cst_agent_config(agent)->workspace_root, t.where,
|
||
sizeof(t.where));
|
||
pthread_mutex_init(&t.mu, NULL);
|
||
blocks_init(&t.hist);
|
||
if (!t.hist.items) {
|
||
endwin();
|
||
return -1;
|
||
}
|
||
|
||
refresh_title(&t);
|
||
refresh_todo(&t);
|
||
layout(&t);
|
||
t.need_layout = 0;
|
||
reload_hist_from_session(&t);
|
||
status_idle(&t);
|
||
if (key_err[0])
|
||
set_status(&t, key_err);
|
||
redraw_all(&t);
|
||
wtimeout(t.input, 80);
|
||
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(agent);
|
||
cst_settings_t first;
|
||
if (!cfg->api_key[0] && cst_settings_load(&first) != 0) {
|
||
int ignore = 0;
|
||
cmd_provider(&t, NULL, &ignore);
|
||
}
|
||
if (!cfg->api_key[0]) {
|
||
char key[512];
|
||
if (obtain_key(&t, cfg->provider, key, sizeof(key), 0) == 0)
|
||
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
|
||
else
|
||
push_system_line(&t, "No API key for %s yet — /key to enter one, or "
|
||
"/provider to switch",
|
||
cfg->provider);
|
||
memset(key, 0, sizeof(key));
|
||
}
|
||
cst_settings_remember(cfg);
|
||
if (am_models(cfg->provider, NULL, 0) < 0)
|
||
start_probe(&t);
|
||
redraw_all(&t);
|
||
|
||
for (;;) {
|
||
if (g_am_changed) {
|
||
g_am_changed = 0;
|
||
am_check_model(&t);
|
||
draw_status(&t);
|
||
}
|
||
tui_lock(&t);
|
||
int dirty = t.dirty;
|
||
int turn_done = t.turn_done;
|
||
int turn_rc = t.turn_rc;
|
||
char turn_err[512];
|
||
snprintf(turn_err, sizeof(turn_err), "%s", t.turn_err);
|
||
int perm_ask = t.perm_ask && t.perm_ans < 0;
|
||
int ask = t.ask_pending && !t.ask_done;
|
||
int busy = t.busy;
|
||
int relayout = t.need_layout;
|
||
t.need_layout = 0;
|
||
t.dirty = 0;
|
||
if (turn_done)
|
||
t.turn_done = 0;
|
||
tui_unlock(&t);
|
||
|
||
if (relayout) {
|
||
layout(&t);
|
||
wtimeout(t.input, 80);
|
||
dirty = 1;
|
||
}
|
||
if (busy) {
|
||
tui_lock(&t);
|
||
if (t.as_live && t.md_rendered != t.md_len &&
|
||
ms_since(&t.md_render_at) >= LIVE_RENDER_MS) {
|
||
live_render(&t);
|
||
dirty = 1;
|
||
}
|
||
if (t.think_timing && t.think_idx >= 0 && t.think_idx < t.hist.n) {
|
||
char title[64];
|
||
snprintf(title, sizeof(title), "Thinking… %.1fs",
|
||
(double)ms_since(&t.think_t0) / 1000.0);
|
||
block_set_title(&t.hist.items[t.think_idx], title);
|
||
dirty = 1;
|
||
}
|
||
tui_unlock(&t);
|
||
}
|
||
if (dirty)
|
||
redraw_all(&t);
|
||
if (busy) {
|
||
t.spin++;
|
||
draw_status(&t);
|
||
draw_input(&t);
|
||
}
|
||
|
||
if (turn_done) {
|
||
if (turn_rc != 0) {
|
||
push_system_line(&t, "Error: %s", turn_err[0] ? turn_err : "?");
|
||
set_status(&t, turn_err[0] ? turn_err : "turn failed");
|
||
} else {
|
||
status_idle(&t);
|
||
}
|
||
cst_agent_reload_notes(t.agent);
|
||
refresh_title(&t);
|
||
t.log_scroll = 0;
|
||
redraw_all(&t);
|
||
if (try_start_queued(&t) == -2)
|
||
break;
|
||
continue;
|
||
}
|
||
|
||
if (perm_ask) {
|
||
run_perm_modal(&t);
|
||
continue;
|
||
}
|
||
if (ask) {
|
||
run_ask_modal(&t);
|
||
continue;
|
||
}
|
||
|
||
wint_t wch = 0;
|
||
int rc = tui_wget_wch(t.input, &wch);
|
||
if (rc == ERR)
|
||
continue;
|
||
|
||
if (!t.in_paste &&
|
||
!(rc == KEY_CODE_YES &&
|
||
(wch == KEY_PASTE_BEGIN || wch == KEY_PASTE_END ||
|
||
wch == KEY_RESIZE || wch == KEY_MOUSE))) {
|
||
int code = tui_key_code(rc, wch);
|
||
wint_t nx = 0;
|
||
int nrc = ERR;
|
||
if (code == 27 && !t.leader_pending) {
|
||
/* Alt+key arrives as Esc followed by the key */
|
||
wtimeout(t.input, 30);
|
||
nrc = tui_wget_wch(t.input, &nx);
|
||
wtimeout(t.input, 80);
|
||
if (nrc != ERR) {
|
||
int c2 = tui_key_code(nrc, nx);
|
||
if (c2 == 10)
|
||
c2 = 13;
|
||
code = c2 == CST_K_UP ? CST_K_ALT_UP
|
||
: c2 == CST_K_DOWN ? CST_K_ALT_DOWN
|
||
: c2 > 0 ? (c2 | CST_KEY_ALT)
|
||
: 0;
|
||
}
|
||
}
|
||
int kd = key_dispatch(&t, code, busy);
|
||
if (kd == -2)
|
||
goto out;
|
||
if (kd == 1) {
|
||
draw_input(&t);
|
||
continue;
|
||
}
|
||
if (nrc == OK) {
|
||
if (nx == L'b')
|
||
t.cur = word_left(&t, t.cur), draw_input(&t);
|
||
else if (nx == L'f')
|
||
t.cur = word_right(&t, t.cur), draw_input(&t);
|
||
else if (nx == 127 || nx == 8) {
|
||
input_erase(&t, word_left(&t, t.cur), t.cur);
|
||
layout_maybe_grow_input(&t);
|
||
}
|
||
continue;
|
||
}
|
||
if (nrc == KEY_CODE_YES)
|
||
continue;
|
||
if (code == 27) {
|
||
if (t.comp_n > 0) {
|
||
t.comp_off = 1;
|
||
t.comp_n = 0;
|
||
draw_log(&t);
|
||
} else if (busy) {
|
||
request_cancel_ui(&t);
|
||
} else if (t.in_len) {
|
||
input_clear(&t);
|
||
layout_maybe_grow_input(&t);
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (rc == KEY_CODE_YES) {
|
||
if (wch == KEY_PASTE_BEGIN) {
|
||
t.in_paste = 1;
|
||
t.paste_cr = 0;
|
||
continue;
|
||
}
|
||
if (wch == KEY_PASTE_END) {
|
||
t.in_paste = 0;
|
||
input_changed(&t);
|
||
after_edit(&t);
|
||
continue;
|
||
}
|
||
if (wch == KEY_RESIZE) {
|
||
layout(&t);
|
||
wtimeout(t.input, 80);
|
||
redraw_all(&t);
|
||
continue;
|
||
}
|
||
if (wch == KEY_MOUSE) {
|
||
MEVENT ev;
|
||
if (getmouse(&ev) == OK) {
|
||
if (ev.bstate & BUTTON4_PRESSED) {
|
||
t.log_scroll += SCROLL_SPEED;
|
||
draw_log(&t);
|
||
} else if (ev.bstate & BUTTON5_PRESSED) {
|
||
t.log_scroll -= SCROLL_SPEED;
|
||
if (t.log_scroll < 0)
|
||
t.log_scroll = 0;
|
||
draw_log(&t);
|
||
} else if (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) {
|
||
if (handle_click(&t, ev.y, ev.x, busy) == -2)
|
||
goto out;
|
||
}
|
||
draw_input(&t); /* keep the terminal cursor in the box */
|
||
}
|
||
continue;
|
||
}
|
||
int wdir;
|
||
if (word_key((int)wch, &wdir)) {
|
||
if (wdir < 0)
|
||
t.cur = word_left(&t, t.cur), draw_input(&t);
|
||
else if (wdir > 0)
|
||
t.cur = word_right(&t, t.cur), draw_input(&t);
|
||
else {
|
||
input_erase(&t, t.cur, word_right(&t, t.cur));
|
||
after_edit(&t);
|
||
}
|
||
continue;
|
||
}
|
||
switch (wch) {
|
||
case KEY_HOME:
|
||
if (t.in_len) {
|
||
t.cur = line_start(&t, t.cur);
|
||
draw_input(&t);
|
||
} else {
|
||
t.log_scroll = 1 << 28; /* clamped by draw_log */
|
||
draw_log(&t);
|
||
}
|
||
continue;
|
||
case KEY_END:
|
||
if (t.in_len) {
|
||
t.cur = line_end(&t, t.cur);
|
||
draw_input(&t);
|
||
} else {
|
||
t.log_scroll = 0;
|
||
draw_log(&t);
|
||
}
|
||
continue;
|
||
case KEY_LEFT:
|
||
t.cur -= utf8_prev_len(t.inbuf, t.cur);
|
||
update_completion(&t);
|
||
redraw_all(&t);
|
||
continue;
|
||
case KEY_RIGHT:
|
||
t.cur += next_len(&t, t.cur);
|
||
update_completion(&t);
|
||
redraw_all(&t);
|
||
continue;
|
||
case KEY_UP:
|
||
case KEY_DOWN: {
|
||
int dir = wch == KEY_UP ? -1 : 1;
|
||
if (t.comp_n > 0) {
|
||
t.comp_sel = (t.comp_sel + dir + t.comp_n) % t.comp_n;
|
||
draw_log(&t);
|
||
draw_input(&t);
|
||
continue;
|
||
}
|
||
if (input_line_move(&t, dir)) {
|
||
draw_input(&t);
|
||
continue;
|
||
}
|
||
if (dir < 0) {
|
||
if (t.prompt_hist_n <= 0)
|
||
continue;
|
||
int idx = t.hist_browse;
|
||
idx = idx < 0 ? t.prompt_hist_n - 1 : (idx > 0 ? idx - 1 : 0);
|
||
hist_load_into_input(&t, idx);
|
||
} else {
|
||
if (t.hist_browse < 0)
|
||
continue;
|
||
if (t.hist_browse + 1 >= t.prompt_hist_n)
|
||
input_clear(&t);
|
||
else
|
||
hist_load_into_input(&t, t.hist_browse + 1);
|
||
}
|
||
after_edit(&t);
|
||
continue;
|
||
}
|
||
case KEY_BACKSPACE:
|
||
input_erase(&t, t.cur - utf8_prev_len(t.inbuf, t.cur), t.cur);
|
||
after_edit(&t);
|
||
continue;
|
||
case KEY_DC:
|
||
input_erase(&t, t.cur, t.cur + next_len(&t, t.cur));
|
||
after_edit(&t);
|
||
continue;
|
||
case KEY_BTAB:
|
||
if (t.comp_n > 0) {
|
||
t.comp_sel = (t.comp_sel + t.comp_n - 1) % t.comp_n;
|
||
draw_log(&t);
|
||
draw_input(&t);
|
||
}
|
||
continue;
|
||
case KEY_ENTER:
|
||
break; /* handled with CR below */
|
||
default:
|
||
continue;
|
||
}
|
||
wch = 13;
|
||
}
|
||
|
||
if (t.in_paste) {
|
||
paste_char(&t, wch);
|
||
continue;
|
||
}
|
||
|
||
/* Enter: accept a completion, otherwise send. */
|
||
if (wch == 10 || wch == 13) {
|
||
if (t.comp_n > 0) {
|
||
int kind = t.comp_kind;
|
||
const slash_cmd_t *c = accept_completion(&t);
|
||
after_edit(&t);
|
||
/* "/he⏎" runs /help; commands that need an argument wait for it */
|
||
if (kind != 1 || !c || c->args[0] == '<')
|
||
continue;
|
||
}
|
||
int st = submit_input(&t, busy);
|
||
if (st == -2)
|
||
break;
|
||
continue;
|
||
}
|
||
|
||
if (wch == 3) { /* Ctrl+C */
|
||
if (busy) {
|
||
request_cancel_ui(&t);
|
||
} else if (t.in_len) {
|
||
input_clear(&t);
|
||
after_edit(&t);
|
||
} else if (t.ctrlc_at.tv_sec && since_ms(&t.ctrlc_at) < 2000) {
|
||
break;
|
||
} else {
|
||
clock_gettime(CLOCK_MONOTONIC, &t.ctrlc_at);
|
||
set_status(&t, "press Ctrl+C again to quit");
|
||
draw_status(&t);
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 4) { /* Ctrl+D on an empty prompt quits */
|
||
if (!busy && t.in_len == 0)
|
||
break;
|
||
input_erase(&t, t.cur, t.cur + next_len(&t, t.cur));
|
||
layout_maybe_grow_input(&t);
|
||
continue;
|
||
}
|
||
|
||
switch (wch) {
|
||
case 1: /* Ctrl+A */
|
||
t.cur = line_start(&t, t.cur);
|
||
draw_input(&t);
|
||
continue;
|
||
case 5: /* Ctrl+E */
|
||
t.cur = line_end(&t, t.cur);
|
||
draw_input(&t);
|
||
continue;
|
||
case 2: /* Ctrl+B */
|
||
t.cur -= utf8_prev_len(t.inbuf, t.cur);
|
||
draw_input(&t);
|
||
continue;
|
||
case 6: /* Ctrl+F */
|
||
t.cur += next_len(&t, t.cur);
|
||
draw_input(&t);
|
||
continue;
|
||
case 11: /* Ctrl+K */
|
||
input_erase(&t, t.cur, line_end(&t, t.cur) == t.cur && t.cur < t.in_len
|
||
? t.cur + 1
|
||
: line_end(&t, t.cur));
|
||
after_edit(&t);
|
||
continue;
|
||
case 21: /* Ctrl+U */
|
||
input_erase(&t, line_start(&t, t.cur), t.cur);
|
||
after_edit(&t);
|
||
continue;
|
||
case 23: /* Ctrl+W */
|
||
input_erase(&t, word_left(&t, t.cur), t.cur);
|
||
after_edit(&t);
|
||
continue;
|
||
case 26: /* Ctrl+Z is not job control in raw mode */
|
||
continue;
|
||
case L'\t':
|
||
if (t.comp_n > 0) {
|
||
accept_completion(&t);
|
||
after_edit(&t);
|
||
} else if (wants_completion(&t)) {
|
||
t.comp_off = 0;
|
||
update_completion(&t);
|
||
draw_log(&t);
|
||
draw_input(&t);
|
||
}
|
||
continue;
|
||
case 127:
|
||
case 8:
|
||
input_erase(&t, t.cur - utf8_prev_len(t.inbuf, t.cur), t.cur);
|
||
after_edit(&t);
|
||
continue;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
if (iswprint(wch) || wcwidth((wchar_t)wch) > 0) {
|
||
input_put_wc(&t, (wchar_t)wch);
|
||
after_edit(&t);
|
||
}
|
||
}
|
||
out:
|
||
if (t.busy) {
|
||
cst_agent_request_cancel(t.agent);
|
||
for (int i = 0; i < 50 && t.busy; i++)
|
||
usleep(100000);
|
||
}
|
||
for (int i = 0; i < 20 && g_am_threads > 0; i++)
|
||
usleep(100000); /* let a /models probe finish before curl cleanup */
|
||
|
||
tui_free(&t);
|
||
#ifdef NCURSES_VERSION
|
||
kitty_restore();
|
||
printf("\033[?2004l");
|
||
fflush(stdout);
|
||
#endif
|
||
endwin();
|
||
return 0;
|
||
}
|