Tools - Table-driven tool registry with permission classes and default/minimal profiles; new tools: edit_file, list_dir, glob, grep, delete_file, move_file, todo_write, web_fetch, ask_user (plus read_file paging). - Built-in Myers diff; write tools return unified diffs. - Persistent permission rules (.agent/permissions.json), per-turn undo snapshots (.agent/undo), AGENTS.md project notes, auto-compact at 80% of the model context, cancellable run_shell with process-group kill. TUI - Table-driven slash commands: /provider /key /effort /status /tools /permissions /yolo /undo /retry /diff /export /copy /rename /delete /init /max-steps /todo, with / and @ completion. - Permission modal with diff and prefix rules, ask_user modal, todo panel, header with model/effort/context, bracketed paste, line editing, wide-char cursor, NO_COLOR and ASCII fallback; Esc never quits. Startup and configuration - API keys are entered in the TUI (hidden) and saved per provider in <config>/credentials (0600); env variables are only a fallback. - Last provider, per-provider model and reasoning effort are remembered in <config>/settings.json; --effort/-e and /effort send reasoning_effort (low|medium|high|max) to models that accept it. - DeepSeek preset uses deepseek-flash / deepseek-v4-pro. - Session flags --new/--continue/--resume/--list-sessions, --yolo, --confirm. Compatibility and tests - ncurses detection, macOS/Homebrew support, make install, XDG config dir, POSIX cleanups; Gitea CI (gcc/clang/asan/musl/macOS). - Unit tests for diff, fs tools, registry, undo/perms and config; e2e fixtures multi_file_refactor and ask_or_todo; vendored cJSON.
4516 lines
126 KiB
C
4516 lines
126 KiB
C
#ifndef _GNU_SOURCE
|
||
#define _GNU_SOURCE
|
||
#endif
|
||
|
||
#include "cJSON.h"
|
||
#include "cst_agent.h"
|
||
#include "cst_perms.h"
|
||
#include "cst_tools.h"
|
||
#include "cst_util.h"
|
||
#include "os_compat.h"
|
||
#include "sashidown.h"
|
||
|
||
#include <locale.h>
|
||
#include <pthread.h>
|
||
#include <stdarg.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <time.h>
|
||
#include <wchar.h>
|
||
#include <ctype.h>
|
||
#include <wctype.h>
|
||
|
||
#ifdef _WIN32
|
||
#include <io.h>
|
||
#include <windows.h>
|
||
#else
|
||
#include <langinfo.h>
|
||
#include <strings.h>
|
||
#include <unistd.h>
|
||
#endif
|
||
|
||
static int g_nocolor; /* NO_COLOR */
|
||
static int g_ascii; /* non-UTF-8 locale or CST_ASCII=1 */
|
||
static int g_utf8 = 1; /* locale can show non-ASCII text */
|
||
#define HAS_COLOR() (!g_nocolor && has_colors())
|
||
|
||
/* Curses: ncursesw on Unix, PDCurses (wide) on Windows. */
|
||
#if defined(CST_USE_PDCURSES) || defined(_WIN32)
|
||
#ifndef PDC_WIDE
|
||
#define PDC_WIDE 1
|
||
#endif
|
||
#ifndef NCURSES_MOUSE_VERSION
|
||
#define NCURSES_MOUSE_VERSION 2
|
||
#endif
|
||
#include <curses.h>
|
||
#ifndef BUTTON4_PRESSED
|
||
#define BUTTON4_PRESSED 0
|
||
#endif
|
||
#ifndef BUTTON5_PRESSED
|
||
#define BUTTON5_PRESSED 0
|
||
#endif
|
||
#ifndef KEY_RESIZE
|
||
#define KEY_RESIZE 0632
|
||
#endif
|
||
/* MinGW often lacks wcwidth; simple East-Asian / ASCII heuristic. */
|
||
static int cst_wcwidth(wchar_t wc) {
|
||
if (wc == 0)
|
||
return 0;
|
||
if (wc < 32 || (wc >= 0x7f && wc < 0xa0))
|
||
return -1;
|
||
if (wc >= 0x1100 &&
|
||
(wc <= 0x115f || wc == 0x2329 || wc == 0x232a ||
|
||
(wc >= 0x2e80 && wc <= 0xa4cf && wc != 0x303f) ||
|
||
(wc >= 0xac00 && wc <= 0xd7a3) || (wc >= 0xf900 && wc <= 0xfaff) ||
|
||
(wc >= 0xfe10 && wc <= 0xfe19) || (wc >= 0xfe30 && wc <= 0xfe6f) ||
|
||
(wc >= 0xff00 && wc <= 0xff60) || (wc >= 0xffe0 && wc <= 0xffe6) ||
|
||
(wc >= 0x20000 && wc <= 0x2fffd) || (wc >= 0x30000 && wc <= 0x3fffd)))
|
||
return 2;
|
||
return 1;
|
||
}
|
||
#define wcwidth cst_wcwidth
|
||
#else
|
||
#include <ncurses.h>
|
||
#endif
|
||
|
||
#ifdef _WIN32
|
||
static void cst_usleep(unsigned usec) {
|
||
Sleep((usec + 999) / 1000);
|
||
}
|
||
static int cst_clock_gettime_mono(struct timespec *ts) {
|
||
static LARGE_INTEGER freq;
|
||
static int init;
|
||
LARGE_INTEGER c;
|
||
if (!init) {
|
||
QueryPerformanceFrequency(&freq);
|
||
init = 1;
|
||
}
|
||
QueryPerformanceCounter(&c);
|
||
ts->tv_sec = (time_t)(c.QuadPart / freq.QuadPart);
|
||
ts->tv_nsec =
|
||
(long)(((c.QuadPart % freq.QuadPart) * 1000000000ULL) / freq.QuadPart);
|
||
return 0;
|
||
}
|
||
#define usleep(us) cst_usleep((unsigned)(us))
|
||
#define clock_gettime(clk, ts) cst_clock_gettime_mono(ts)
|
||
#ifndef CLOCK_MONOTONIC
|
||
#define CLOCK_MONOTONIC 1
|
||
#endif
|
||
#endif
|
||
|
||
/*
|
||
* OpenCode-inspired C ncurses/PDCurses TUI:
|
||
* structured transcript blocks, thinking/tool cards, permissions, cancel.
|
||
*/
|
||
|
||
#define INPUT_CAP 32768
|
||
#define SCROLL_SPEED 3
|
||
#define REPLAY_MSGS 30
|
||
#define MD_INIT (64 * 1024)
|
||
#define ANSI_INIT (64 * 1024)
|
||
#define TOOL_PREVIEW_LINES 3
|
||
#define DIFF_FOLD_LINES 40
|
||
#define BLOCKS_INIT 64
|
||
#define MAX_RENDER_LINES 65536
|
||
#define PROMPT_HIST_CAP 50
|
||
#define QUEUE_CAP 3
|
||
#define INPUT_MAX_ROWS 8
|
||
#define COMP_MAX 8
|
||
#define TODO_PANEL_MAX 6
|
||
#define ASK_OPTS_MAX 9
|
||
/* Bracketed paste markers, bound with define_key() above KEY_MAX. */
|
||
#define KEY_PASTE_BEGIN (KEY_MAX + 1)
|
||
#define KEY_PASTE_END (KEY_MAX + 2)
|
||
|
||
/* Color pair indices */
|
||
enum {
|
||
CP_HEADER = 1,
|
||
CP_STATUS,
|
||
CP_INPUT,
|
||
CP_PRIMARY, /* user / accents */
|
||
CP_ASSIST, /* cyan assistant */
|
||
CP_MUTED,
|
||
CP_SUCCESS,
|
||
CP_ERROR,
|
||
CP_TOOL,
|
||
CP_PANEL, /* transcript bg */
|
||
CP_SGR_GREEN,
|
||
CP_SGR_YELLOW,
|
||
CP_SGR_CYAN,
|
||
CP_SGR_MAGENTA,
|
||
/* OpenCode-style structured diff (bg distinguishes add/del/context) */
|
||
CP_DIFF_ADD,
|
||
CP_DIFF_DEL,
|
||
CP_DIFF_CTX
|
||
};
|
||
|
||
typedef enum {
|
||
BLK_USER = 0,
|
||
BLK_ASSISTANT,
|
||
BLK_THINK,
|
||
BLK_TOOL,
|
||
BLK_STATUS,
|
||
BLK_SYSTEM
|
||
} block_kind_t;
|
||
|
||
typedef struct {
|
||
block_kind_t kind;
|
||
char *title;
|
||
char *body; /* raw text / markdown / tool output */
|
||
char *render; /* ANSI for assistant; NULL → draw body */
|
||
int expanded;
|
||
char meta[256]; /* phase / timing / detail */
|
||
char tool_name[CTL_MAX_NAME];
|
||
char tool_id[CTL_MAX_ID];
|
||
} block_t;
|
||
|
||
typedef struct {
|
||
block_t *items;
|
||
int n;
|
||
int cap;
|
||
} blocks_t;
|
||
|
||
typedef struct {
|
||
char text[256];
|
||
char desc[112];
|
||
} comp_item_t;
|
||
|
||
typedef struct {
|
||
cst_agent_t *agent;
|
||
WINDOW *header;
|
||
WINDOW *logw;
|
||
WINDOW *todow;
|
||
WINDOW *status;
|
||
WINDOW *input;
|
||
int rows, cols;
|
||
int input_rows; /* 1..INPUT_MAX_ROWS */
|
||
int todo_rows;
|
||
|
||
blocks_t hist; /* sealed blocks */
|
||
block_t live; /* streaming assistant (kind set when as_live) */
|
||
int as_live;
|
||
char *md_live;
|
||
size_t md_len;
|
||
size_t md_cap;
|
||
|
||
int log_scroll;
|
||
char inbuf[INPUT_CAP];
|
||
size_t in_len;
|
||
size_t cur; /* cursor byte offset into inbuf */
|
||
int in_paste;
|
||
int paste_cr; /* last pasted byte was CR (fold CRLF) */
|
||
char status_msg[512];
|
||
char title[128]; /* session title or first prompt, for the header */
|
||
struct timespec ctrlc_at;
|
||
struct timespec turn_t0;
|
||
int spin;
|
||
|
||
/* "/" and "@" completion popup */
|
||
comp_item_t comp[COMP_MAX];
|
||
int comp_n, comp_sel, comp_kind; /* kind 1 = slash, 2 = file */
|
||
size_t comp_start;
|
||
int comp_off; /* dismissed with Esc until the input changes */
|
||
int comp_drawn; /* popup rows currently on screen */
|
||
char **files;
|
||
int nfiles;
|
||
struct timespec files_at;
|
||
|
||
char *todo;
|
||
int show_todo;
|
||
volatile int need_layout;
|
||
char *pending_prompt; /* set by /retry and /init */
|
||
|
||
int busy;
|
||
int details_all; /* /details: expand all tool bodies */
|
||
int awaiting_perm;
|
||
int think_idx; /* hist index of open think, or -1 */
|
||
int last_tool_idx;
|
||
int focus_think_idx;
|
||
struct timespec think_t0;
|
||
int think_timing; /* think_t0 valid */
|
||
|
||
/* Prompt history (newest at end). hist_browse=-1 means live buffer. */
|
||
char *prompt_hist[PROMPT_HIST_CAP];
|
||
int prompt_hist_n;
|
||
int hist_browse;
|
||
|
||
/* Busy-turn FIFO */
|
||
char *queue[QUEUE_CAP];
|
||
int queue_n;
|
||
|
||
/* Permission handshake (worker → main). */
|
||
volatile int perm_ask;
|
||
volatile int perm_ans; /* -1 waiting, else CST_PERM_* */
|
||
char perm_tool[CTL_MAX_NAME];
|
||
char perm_detail[512];
|
||
char *perm_preview;
|
||
char perm_prefix[128];
|
||
int perm_scroll;
|
||
|
||
/* ask_user handshake (worker → main). */
|
||
volatile int ask_pending;
|
||
volatile int ask_done;
|
||
int ask_rc;
|
||
int ask_sel;
|
||
int ask_nopts;
|
||
char ask_q[1024];
|
||
char ask_opts[ASK_OPTS_MAX][256];
|
||
char ask_buf[1024];
|
||
|
||
pthread_mutex_t mu;
|
||
volatile int dirty;
|
||
volatile int turn_done;
|
||
int turn_rc;
|
||
char turn_err[512];
|
||
} tui_t;
|
||
|
||
/* --- UTF-8 helpers -------------------------------------------------------- */
|
||
|
||
static size_t utf8_prev_len(const char *s, size_t len) {
|
||
if (len == 0)
|
||
return 0;
|
||
size_t i = len - 1;
|
||
while (i > 0 && ((unsigned char)s[i] & 0xC0) == 0x80)
|
||
i--;
|
||
return len - i;
|
||
}
|
||
|
||
static int utf8_decode(const char *s, size_t avail, wchar_t *wc, size_t *eaten) {
|
||
if (avail == 0)
|
||
return -1;
|
||
mbstate_t st;
|
||
memset(&st, 0, sizeof(st));
|
||
size_t n = mbrtowc(wc, s, avail, &st);
|
||
if (n == (size_t)-1 || n == (size_t)-2 || n == 0) {
|
||
*wc = (unsigned char)s[0];
|
||
*eaten = 1;
|
||
return 0;
|
||
}
|
||
*eaten = n;
|
||
return 0;
|
||
}
|
||
|
||
static int utf8_width(wchar_t wc) {
|
||
int w = wcwidth(wc);
|
||
if (w < 0)
|
||
return 1;
|
||
return w;
|
||
}
|
||
|
||
static int utf8_cols(const char *s, size_t n) {
|
||
int cols = 0;
|
||
size_t i = 0;
|
||
while (i < n) {
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(s + i, n - i, &wc, &eaten);
|
||
cols += utf8_width(wc);
|
||
i += eaten;
|
||
}
|
||
return cols;
|
||
}
|
||
|
||
static size_t utf8_fit_cols(const char *s, size_t n, int max_cols) {
|
||
int cols = 0;
|
||
size_t i = 0;
|
||
while (i < n) {
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(s + i, n - i, &wc, &eaten);
|
||
int w = utf8_width(wc);
|
||
if (cols + w > max_cols)
|
||
break;
|
||
cols += w;
|
||
i += eaten;
|
||
}
|
||
return i;
|
||
}
|
||
|
||
static size_t ansi_skip(const char *s, size_t n) {
|
||
if (n < 2 || (unsigned char)s[0] != 0x1b)
|
||
return 0;
|
||
if (s[1] == '[') {
|
||
size_t i = 2;
|
||
while (i < n) {
|
||
unsigned char c = (unsigned char)s[i++];
|
||
if (c >= 0x40 && c <= 0x7e)
|
||
return i;
|
||
}
|
||
return 0;
|
||
}
|
||
return 2;
|
||
}
|
||
|
||
static char *xstrdup(const char *s) {
|
||
if (!s)
|
||
s = "";
|
||
size_t n = strlen(s);
|
||
char *p = malloc(n + 1);
|
||
if (!p)
|
||
return NULL;
|
||
memcpy(p, s, n + 1);
|
||
return p;
|
||
}
|
||
|
||
static char *xstrndup(const char *s, size_t n) {
|
||
char *p = malloc(n + 1);
|
||
if (!p)
|
||
return NULL;
|
||
memcpy(p, s, n);
|
||
p[n] = 0;
|
||
return p;
|
||
}
|
||
|
||
/* --- theme ---------------------------------------------------------------- */
|
||
|
||
/* OpenCode dark panel — never inherit terminal default bg. */
|
||
static void theme_init(void) {
|
||
const char *nc = getenv("NO_COLOR");
|
||
g_nocolor = nc && nc[0];
|
||
const char *asc = getenv("CST_ASCII");
|
||
#ifndef _WIN32
|
||
const char *cs = nl_langinfo(CODESET);
|
||
g_utf8 = cs && (strcasecmp(cs, "UTF-8") == 0 || strcasecmp(cs, "utf8") == 0);
|
||
#endif
|
||
g_ascii = asc && asc[0] ? asc[0] != '0' : !g_utf8;
|
||
if (!HAS_COLOR())
|
||
return;
|
||
start_color();
|
||
/* Do NOT call use_default_colors(): -1 would leak the terminal theme. */
|
||
|
||
short primary = COLOR_YELLOW;
|
||
short panel = COLOR_BLACK;
|
||
short muted = COLOR_CYAN;
|
||
short success = COLOR_GREEN;
|
||
short err = COLOR_RED;
|
||
short assist = COLOR_CYAN;
|
||
short text = COLOR_WHITE;
|
||
|
||
if (COLORS >= 256) {
|
||
/* Prefer xterm-256 slots so we don't bleach the 8 ANSI base colors.
|
||
* panel #121212→233, primary #fab283→216, muted #808080→244 */
|
||
primary = 216;
|
||
panel = 233; /* #121212 — darker than 234 to avoid washed look */
|
||
muted = 244;
|
||
success = 114;
|
||
err = 167;
|
||
assist = 74; /* soft cyan, not neon */
|
||
text = 250; /* #bcbcbc — not near-white */
|
||
if (can_change_color()) {
|
||
/* Tune those slots toward exact OpenCode hex */
|
||
init_color(216, 980, 698, 514); /* #fab283 */
|
||
init_color(233, 70, 70, 70); /* #121212 */
|
||
init_color(244, 502, 502, 502); /* #808080 */
|
||
init_color(250, 720, 720, 720); /* body text */
|
||
init_color(114, 400, 750, 450);
|
||
init_color(167, 850, 320, 320);
|
||
init_color(74, 350, 700, 750);
|
||
}
|
||
} else if (can_change_color() && COLORS >= 8) {
|
||
init_color(COLOR_BLACK, 70, 70, 70); /* panel #121212 */
|
||
init_color(COLOR_YELLOW, 980, 698, 514); /* primary */
|
||
init_color(COLOR_CYAN, 502, 502, 502); /* muted (reuse cyan slot) */
|
||
init_color(COLOR_WHITE, 720, 720, 720); /* body text */
|
||
init_color(COLOR_GREEN, 400, 750, 450);
|
||
init_color(COLOR_RED, 850, 320, 320);
|
||
init_color(COLOR_BLUE, 350, 700, 750); /* assist */
|
||
panel = COLOR_BLACK;
|
||
primary = COLOR_YELLOW;
|
||
muted = COLOR_CYAN;
|
||
text = COLOR_WHITE;
|
||
success = COLOR_GREEN;
|
||
err = COLOR_RED;
|
||
assist = COLOR_BLUE;
|
||
}
|
||
|
||
/* Header is dark too — primary only as text accent (not a bright bar). */
|
||
init_pair(CP_HEADER, primary, panel);
|
||
init_pair(CP_STATUS, muted, panel);
|
||
init_pair(CP_INPUT, text, panel);
|
||
init_pair(CP_PRIMARY, primary, panel);
|
||
init_pair(CP_ASSIST, assist, panel);
|
||
init_pair(CP_MUTED, muted, panel);
|
||
init_pair(CP_SUCCESS, success, panel);
|
||
init_pair(CP_ERROR, err, panel);
|
||
init_pair(CP_TOOL, primary, panel);
|
||
init_pair(CP_PANEL, text, panel);
|
||
init_pair(CP_SGR_GREEN, success, panel);
|
||
init_pair(CP_SGR_YELLOW, primary, panel);
|
||
init_pair(CP_SGR_CYAN, assist, panel);
|
||
if (COLORS >= 256)
|
||
init_pair(CP_SGR_MAGENTA, 176, panel);
|
||
else
|
||
init_pair(CP_SGR_MAGENTA, COLOR_MAGENTA, panel);
|
||
|
||
/* Diff rows: high-contrast xterm-256 palette only.
|
||
* Do NOT init_color() cube indices 16–21 — many terminals ignore it and leave
|
||
* default blues, which made add/del rows unreadable (dark-on-blue). */
|
||
if (COLORS >= 256) {
|
||
init_pair(CP_DIFF_ADD, 157, 22); /* #afff87 on #005f00 */
|
||
init_pair(CP_DIFF_DEL, 203, 52); /* #ff5f5f on #5f0000 */
|
||
init_pair(CP_DIFF_CTX, 252, panel); /* bright gray on panel */
|
||
} else {
|
||
init_pair(CP_DIFF_ADD, COLOR_GREEN, COLOR_BLACK);
|
||
init_pair(CP_DIFF_DEL, COLOR_RED, COLOR_BLACK);
|
||
init_pair(CP_DIFF_CTX, COLOR_WHITE, COLOR_BLACK);
|
||
}
|
||
}
|
||
|
||
static void theme_paint_root(void) {
|
||
if (!HAS_COLOR())
|
||
return;
|
||
wbkgd(stdscr, COLOR_PAIR(CP_PANEL));
|
||
werase(stdscr);
|
||
wnoutrefresh(stdscr);
|
||
}
|
||
|
||
/* --- blocks --------------------------------------------------------------- */
|
||
|
||
static void block_clear(block_t *b) {
|
||
if (!b)
|
||
return;
|
||
free(b->title);
|
||
free(b->body);
|
||
free(b->render);
|
||
memset(b, 0, sizeof(*b));
|
||
}
|
||
|
||
static void blocks_init(blocks_t *bs) {
|
||
bs->items = calloc((size_t)BLOCKS_INIT, sizeof(block_t));
|
||
bs->n = 0;
|
||
bs->cap = bs->items ? BLOCKS_INIT : 0;
|
||
}
|
||
|
||
static void blocks_free(blocks_t *bs) {
|
||
if (!bs)
|
||
return;
|
||
for (int i = 0; i < bs->n; i++)
|
||
block_clear(&bs->items[i]);
|
||
free(bs->items);
|
||
bs->items = NULL;
|
||
bs->n = bs->cap = 0;
|
||
}
|
||
|
||
static void blocks_clear(blocks_t *bs) {
|
||
for (int i = 0; i < bs->n; i++)
|
||
block_clear(&bs->items[i]);
|
||
bs->n = 0;
|
||
}
|
||
|
||
static block_t *blocks_push(blocks_t *bs) {
|
||
if (!bs->items)
|
||
return NULL;
|
||
if (bs->n >= bs->cap) {
|
||
int nc = bs->cap * 2;
|
||
block_t *ni = realloc(bs->items, (size_t)nc * sizeof(block_t));
|
||
if (!ni)
|
||
return NULL;
|
||
memset(ni + bs->cap, 0, (size_t)(nc - bs->cap) * sizeof(block_t));
|
||
bs->items = ni;
|
||
bs->cap = nc;
|
||
}
|
||
block_t *b = &bs->items[bs->n++];
|
||
memset(b, 0, sizeof(*b));
|
||
return b;
|
||
}
|
||
|
||
static void block_set_title(block_t *b, const char *t) {
|
||
free(b->title);
|
||
b->title = xstrdup(t ? t : "");
|
||
}
|
||
|
||
static void block_set_body(block_t *b, const char *s, size_t n) {
|
||
free(b->body);
|
||
b->body = n ? xstrndup(s, n) : xstrdup("");
|
||
}
|
||
|
||
static void block_append_body(block_t *b, const char *s, size_t n) {
|
||
if (!s || !n)
|
||
return;
|
||
size_t old = b->body ? strlen(b->body) : 0;
|
||
char *nb = realloc(b->body, old + n + 1);
|
||
if (!nb)
|
||
return;
|
||
memcpy(nb + old, s, n);
|
||
nb[old + n] = 0;
|
||
b->body = nb;
|
||
}
|
||
|
||
/* Markdown → ANSI via Sashidown. */
|
||
static int md_to_ansi_dyn(const char *md, size_t md_len, char **outp,
|
||
size_t *out_len) {
|
||
if (!md || !outp)
|
||
return -1;
|
||
if (md_len == 0) {
|
||
*outp = NULL;
|
||
if (out_len)
|
||
*out_len = 0;
|
||
return 0;
|
||
}
|
||
size_t cap = ANSI_INIT;
|
||
char *out = malloc(cap);
|
||
if (!out)
|
||
return -1;
|
||
for (;;) {
|
||
size_t n = 0;
|
||
int rc = sashidown_convert(md, md_len, out, cap, &n, SASHIDOWN_FMT_ANSI);
|
||
if (rc == 0) {
|
||
*outp = out;
|
||
if (out_len)
|
||
*out_len = n;
|
||
return 0;
|
||
}
|
||
size_t nc = cap * 2;
|
||
if (nc < md_len * 4)
|
||
nc = md_len * 4;
|
||
if (nc > 16 * 1024 * 1024) {
|
||
free(out);
|
||
out = malloc(md_len + 1);
|
||
if (!out)
|
||
return -1;
|
||
memcpy(out, md, md_len);
|
||
out[md_len] = 0;
|
||
*outp = out;
|
||
if (out_len)
|
||
*out_len = md_len;
|
||
return 0;
|
||
}
|
||
char *nb = realloc(out, nc);
|
||
if (!nb) {
|
||
free(out);
|
||
return -1;
|
||
}
|
||
out = nb;
|
||
cap = nc;
|
||
}
|
||
}
|
||
|
||
static void block_render_assistant(block_t *b) {
|
||
free(b->render);
|
||
b->render = NULL;
|
||
if (!b->body || !b->body[0])
|
||
return;
|
||
char *ansi = NULL;
|
||
size_t n = 0;
|
||
if (md_to_ansi_dyn(b->body, strlen(b->body), &ansi, &n) == 0 && ansi) {
|
||
b->render = ansi;
|
||
}
|
||
}
|
||
|
||
static const char *tool_prefix(const char *name) {
|
||
if (!name)
|
||
return "•";
|
||
if (strcmp(name, "run_shell") == 0)
|
||
return "$";
|
||
if (cst_tool_is_write(name))
|
||
return "←";
|
||
if (strcmp(name, "read_file") == 0)
|
||
return "✱";
|
||
if (strcmp(name, "web_fetch") == 0)
|
||
return "%";
|
||
return "•";
|
||
}
|
||
|
||
static void tool_arg_summary(const ctl_tool_call_t *call, char *out,
|
||
size_t out_sz) {
|
||
static const struct {
|
||
const char *tool, *key;
|
||
} PREFER[] = {{"run_shell", "cmd"}, {"read_file", "path"},
|
||
{"write_file", "path"}, {"edit_file", "path"},
|
||
{"delete_file", "path"}, {"move_file", "from"},
|
||
{"list_dir", "path"}, {"glob", "pattern"},
|
||
{"grep", "pattern"}, {"web_fetch", "url"},
|
||
{"ask_user", "question"}, {"apply_patch", "patch"}};
|
||
out[0] = 0;
|
||
if (!call || out_sz == 0)
|
||
return;
|
||
if (strcmp(call->name, "todo_write") == 0) {
|
||
snprintf(out, out_sz, "update task list");
|
||
return;
|
||
}
|
||
const char *prefer = NULL;
|
||
for (size_t i = 0; i < sizeof(PREFER) / sizeof(PREFER[0]); i++)
|
||
if (strcmp(call->name, PREFER[i].tool) == 0)
|
||
prefer = PREFER[i].key;
|
||
int pick = -1;
|
||
for (int i = 0; i < call->nargs; i++)
|
||
if (prefer && strcmp(call->args[i].key, prefer) == 0)
|
||
pick = i;
|
||
if (pick < 0 && call->nargs > 0)
|
||
pick = 0;
|
||
if (pick < 0 || !call->args[pick].value)
|
||
return;
|
||
const char *v = call->args[pick].value;
|
||
size_t len = call->args[pick].value_len ? call->args[pick].value_len
|
||
: strlen(v);
|
||
size_t nl = strcspn(v, "\n");
|
||
int cut = len > 80 || nl < len;
|
||
if (nl < len)
|
||
len = nl;
|
||
if (len > 80)
|
||
len = cst_utf8_floor(v, 80);
|
||
snprintf(out, out_sz, "%.*s%s", (int)len, v, cut ? "…" : "");
|
||
}
|
||
|
||
static void tui_lock(tui_t *t) { pthread_mutex_lock(&t->mu); }
|
||
static void tui_unlock(tui_t *t) { pthread_mutex_unlock(&t->mu); }
|
||
static void tui_mark_dirty(tui_t *t) { t->dirty = 1; }
|
||
|
||
static void set_status_locked(tui_t *t, const char *msg) {
|
||
snprintf(t->status_msg, sizeof(t->status_msg), "%s", msg ? msg : "");
|
||
tui_mark_dirty(t);
|
||
}
|
||
|
||
static void set_status(tui_t *t, const char *msg) {
|
||
tui_lock(t);
|
||
set_status_locked(t, msg);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void status_idle(tui_t *t) {
|
||
if (t->queue_n > 0)
|
||
snprintf(t->status_msg, sizeof(t->status_msg),
|
||
"ready · %d queued · empty Enter sends the next one", t->queue_n);
|
||
else
|
||
snprintf(t->status_msg, sizeof(t->status_msg),
|
||
"ready · / commands · @ files · Tab thought · PgUp scroll · "
|
||
"Ctrl+C twice quits");
|
||
}
|
||
|
||
/* --- live assistant ------------------------------------------------------- */
|
||
|
||
static void live_end_cleanup(tui_t *t) {
|
||
block_clear(&t->live);
|
||
free(t->md_live);
|
||
t->md_live = NULL;
|
||
t->md_len = 0;
|
||
t->md_cap = 0;
|
||
t->as_live = 0;
|
||
}
|
||
|
||
static void live_begin(tui_t *t) {
|
||
live_end_cleanup(t);
|
||
t->md_cap = MD_INIT;
|
||
t->md_live = calloc(1, t->md_cap);
|
||
t->md_len = 0;
|
||
t->live.kind = BLK_ASSISTANT;
|
||
block_set_title(&t->live, "Assistant");
|
||
t->live.expanded = 1;
|
||
t->as_live = 1;
|
||
}
|
||
|
||
static void live_feed(tui_t *t, const char *text, size_t len) {
|
||
if (!t->as_live || !text || !len)
|
||
return;
|
||
if (t->md_len + len + 1 > t->md_cap) {
|
||
size_t nc = t->md_cap * 2;
|
||
while (nc < t->md_len + len + 1)
|
||
nc *= 2;
|
||
char *nb = realloc(t->md_live, nc);
|
||
if (!nb)
|
||
return;
|
||
t->md_live = nb;
|
||
t->md_cap = nc;
|
||
}
|
||
memcpy(t->md_live + t->md_len, text, len);
|
||
t->md_len += len;
|
||
t->md_live[t->md_len] = 0;
|
||
/* During stream show raw markdown; convert on seal. */
|
||
free(t->live.body);
|
||
t->live.body = xstrndup(t->md_live, t->md_len);
|
||
free(t->live.render);
|
||
t->live.render = NULL;
|
||
}
|
||
|
||
static void live_seal(tui_t *t) {
|
||
if (!t->as_live)
|
||
return;
|
||
block_t *b = blocks_push(&t->hist);
|
||
if (b) {
|
||
b->kind = BLK_ASSISTANT;
|
||
block_set_title(b, "Assistant");
|
||
if (t->md_len)
|
||
block_set_body(b, t->md_live, t->md_len);
|
||
else if (t->live.body)
|
||
block_set_body(b, t->live.body, strlen(t->live.body));
|
||
b->expanded = 1;
|
||
block_render_assistant(b);
|
||
}
|
||
live_end_cleanup(t);
|
||
}
|
||
|
||
static void collapse_open_think(tui_t *t) {
|
||
if (t->think_idx < 0 || t->think_idx >= t->hist.n)
|
||
return;
|
||
block_t *th = &t->hist.items[t->think_idx];
|
||
if (th->kind != BLK_THINK)
|
||
return;
|
||
th->expanded = 0;
|
||
char title[64];
|
||
if (t->think_timing) {
|
||
struct timespec now;
|
||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||
double sec = (double)(now.tv_sec - t->think_t0.tv_sec) +
|
||
(double)(now.tv_nsec - t->think_t0.tv_nsec) / 1e9;
|
||
if (sec < 0)
|
||
sec = 0;
|
||
if (sec < 1.0)
|
||
snprintf(title, sizeof(title), "Thought · %.1fs", sec);
|
||
else
|
||
snprintf(title, sizeof(title), "Thought · %.1fs", sec);
|
||
t->think_timing = 0;
|
||
} else {
|
||
snprintf(title, sizeof(title), "Thought");
|
||
}
|
||
block_set_title(th, title);
|
||
t->think_idx = -1;
|
||
}
|
||
|
||
static int find_tool_block(tui_t *t, const char *id) {
|
||
if (!id || !id[0])
|
||
return -1;
|
||
for (int i = t->hist.n - 1; i >= 0; i--) {
|
||
block_t *b = &t->hist.items[i];
|
||
if (b->kind == BLK_TOOL && strcmp(b->tool_id, id) == 0)
|
||
return i;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
static int msg_is_tool_result(const cst_message_t *m) {
|
||
return m && (m->role == CST_ROLE_TOOL ||
|
||
(m->role == CST_ROLE_USER && m->tool_call_id[0]));
|
||
}
|
||
|
||
/* Strip the "[tool …]" / "result <id> exit=N <n>" header and "end" trailer. */
|
||
static void tool_result_view(const char *content, const char **body,
|
||
size_t *len) {
|
||
const char *s = content ? content : "";
|
||
size_t n = strlen(s);
|
||
int framed = strncmp(s, "result ", 7) == 0;
|
||
if (framed || strncmp(s, "[tool ", 6) == 0) {
|
||
const char *nl = strchr(s, '\n');
|
||
if (nl) {
|
||
n -= (size_t)(nl + 1 - s);
|
||
s = nl + 1;
|
||
} else {
|
||
n = 0;
|
||
}
|
||
}
|
||
if (framed && n >= 4 && memcmp(s + n - 4, "\nend", 4) == 0)
|
||
n -= 4;
|
||
else if (framed && n == 3 && memcmp(s, "end", 3) == 0)
|
||
n = 0;
|
||
*body = s;
|
||
*len = n;
|
||
}
|
||
|
||
static void attach_tool_output_from_session(tui_t *t, block_t *b) {
|
||
if (!b || b->kind != BLK_TOOL || !b->tool_id[0])
|
||
return;
|
||
cst_session_t *s = cst_agent_session(t->agent);
|
||
if (!s)
|
||
return;
|
||
cst_session_lock(s);
|
||
int n = cst_session_count(s);
|
||
for (int i = n - 1; i >= 0; i--) {
|
||
const cst_message_t *m = cst_session_get(s, i);
|
||
if (!msg_is_tool_result(m))
|
||
continue;
|
||
if (strcmp(m->tool_call_id, b->tool_id) != 0)
|
||
continue;
|
||
const char *body;
|
||
size_t len;
|
||
tool_result_view(m->content, &body, &len);
|
||
block_set_body(b, body, len);
|
||
break;
|
||
}
|
||
cst_session_unlock(s);
|
||
}
|
||
|
||
/* --- drawing: SGR --------------------------------------------------------- */
|
||
|
||
typedef struct {
|
||
int attrs;
|
||
int pair;
|
||
} sty_t;
|
||
|
||
static void sgr_apply(sty_t *st, int code) {
|
||
switch (code) {
|
||
case 0:
|
||
st->attrs = 0;
|
||
st->pair = 0;
|
||
break;
|
||
case 1:
|
||
st->attrs |= A_BOLD;
|
||
break;
|
||
case 2:
|
||
st->attrs |= A_DIM;
|
||
break;
|
||
case 3:
|
||
#ifdef A_ITALIC
|
||
st->attrs |= A_ITALIC;
|
||
#endif
|
||
break;
|
||
case 4:
|
||
st->attrs |= A_UNDERLINE;
|
||
break;
|
||
case 7:
|
||
st->attrs |= A_REVERSE;
|
||
break;
|
||
case 22:
|
||
st->attrs &= ~(A_BOLD | A_DIM);
|
||
break;
|
||
case 23:
|
||
#ifdef A_ITALIC
|
||
st->attrs &= ~A_ITALIC;
|
||
#endif
|
||
break;
|
||
case 24:
|
||
st->attrs &= ~A_UNDERLINE;
|
||
break;
|
||
case 27:
|
||
st->attrs &= ~A_REVERSE;
|
||
break;
|
||
case 31:
|
||
case 91:
|
||
st->pair = CP_ERROR;
|
||
break;
|
||
case 32:
|
||
case 92:
|
||
st->pair = CP_SGR_GREEN;
|
||
break;
|
||
case 33:
|
||
case 93:
|
||
st->pair = CP_SGR_YELLOW;
|
||
break;
|
||
case 36:
|
||
case 96:
|
||
st->pair = CP_SGR_CYAN;
|
||
break;
|
||
case 35:
|
||
case 95:
|
||
st->pair = CP_SGR_MAGENTA;
|
||
break;
|
||
case 39:
|
||
st->pair = 0;
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
static size_t sgr_parse(const char *s, size_t n, sty_t *st) {
|
||
if (n < 3 || (unsigned char)s[0] != 0x1b || s[1] != '[')
|
||
return 0;
|
||
size_t i = 2;
|
||
int val = 0, have = 0;
|
||
while (i < n) {
|
||
unsigned char c = (unsigned char)s[i++];
|
||
if (c >= '0' && c <= '9') {
|
||
val = val * 10 + (c - '0');
|
||
have = 1;
|
||
} else if (c == ';') {
|
||
sgr_apply(st, have ? val : 0);
|
||
val = 0;
|
||
have = 0;
|
||
} else if (c == 'm') {
|
||
sgr_apply(st, have ? val : 0);
|
||
return i;
|
||
} else {
|
||
return 0;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
static wchar_t ascii_glyph(wchar_t wc) {
|
||
if (!g_ascii || wc < 128)
|
||
return wc;
|
||
switch (wc) {
|
||
case 0x2503: /* ┃ */
|
||
case 0x2502: /* │ */
|
||
return L'|';
|
||
case 0x2500: /* ─ */
|
||
case 0x00b7: /* · */
|
||
return L'-';
|
||
case 0x25bc: /* ▼ */
|
||
case 0x2193: /* ↓ */
|
||
return L'v';
|
||
case 0x25b6: /* ▶ */
|
||
case 0x25b8: /* ▸ */
|
||
case 0x2192: /* → */
|
||
return L'>';
|
||
case 0x2190: /* ← */
|
||
return L'<';
|
||
case 0x2191: /* ↑ */
|
||
return L'^';
|
||
case 0x2026: /* … */
|
||
return L'.';
|
||
case 0x2713: /* ✓ */
|
||
return L'x';
|
||
case 0x25cb: /* ○ */
|
||
return L'o';
|
||
default:
|
||
return g_utf8 ? wc : L'?';
|
||
}
|
||
}
|
||
|
||
static int put_wc(WINDOW *win, int y, int col, wchar_t wc, int attrs) {
|
||
wc = ascii_glyph(wc);
|
||
cchar_t cc;
|
||
wchar_t wstr[2] = {wc, 0};
|
||
short pair = (short)PAIR_NUMBER(attrs);
|
||
attr_t a = (attr_t)(attrs & ~(attr_t)A_COLOR);
|
||
if (setcchar(&cc, wstr, a, pair, NULL) == ERR) {
|
||
if (wc < 128)
|
||
mvwaddch(win, y, col, (chtype)wc | (chtype)attrs);
|
||
else
|
||
mvwaddstr(win, y, col, "?");
|
||
return 1;
|
||
}
|
||
mvwadd_wch(win, y, col, &cc);
|
||
return utf8_width(wc);
|
||
}
|
||
|
||
/* Flatten blocks into display lines (plain text with optional ANSI in body). */
|
||
typedef struct {
|
||
char *text; /* owned line content (may include ANSI) */
|
||
int pair; /* base color pair for whole line (0 = default) */
|
||
int bold;
|
||
int dim;
|
||
int indent; /* leading columns already accounted in text */
|
||
} dline_t;
|
||
|
||
typedef struct {
|
||
dline_t *lines;
|
||
int n;
|
||
int cap;
|
||
} dlines_t;
|
||
|
||
static void dlines_free(dlines_t *dl) {
|
||
if (!dl)
|
||
return;
|
||
for (int i = 0; i < dl->n; i++)
|
||
free(dl->lines[i].text);
|
||
free(dl->lines);
|
||
dl->lines = NULL;
|
||
dl->n = dl->cap = 0;
|
||
}
|
||
|
||
static int dlines_push(dlines_t *dl, const char *text, int pair, int bold,
|
||
int dim) {
|
||
if (dl->n >= dl->cap) {
|
||
int nc = dl->cap ? dl->cap * 2 : 256;
|
||
dline_t *ni = realloc(dl->lines, (size_t)nc * sizeof(dline_t));
|
||
if (!ni)
|
||
return -1;
|
||
dl->lines = ni;
|
||
dl->cap = nc;
|
||
}
|
||
dline_t *L = &dl->lines[dl->n++];
|
||
L->text = xstrdup(text ? text : "");
|
||
L->pair = pair;
|
||
L->bold = bold;
|
||
L->dim = dim;
|
||
L->indent = 0;
|
||
return 0;
|
||
}
|
||
|
||
static void push_wrapped(dlines_t *dl, const char *prefix, const char *body,
|
||
int width, int pair, int bold, int dim, int ansi) {
|
||
(void)ansi;
|
||
char line[4096];
|
||
int pref_cols = prefix ? utf8_cols(prefix, strlen(prefix)) : 0;
|
||
int body_w = width - pref_cols;
|
||
if (body_w < 8)
|
||
body_w = 8;
|
||
|
||
if (!body || !body[0]) {
|
||
snprintf(line, sizeof(line), "%s", prefix ? prefix : "");
|
||
dlines_push(dl, line, pair, bold, dim);
|
||
return;
|
||
}
|
||
|
||
/* Walk body; for ANSI text, wrap on visible columns skipping SGR. */
|
||
size_t n = strlen(body);
|
||
size_t i = 0;
|
||
int first = 1;
|
||
while (i < n && dl->n < MAX_RENDER_LINES) {
|
||
size_t start = i;
|
||
int col = 0;
|
||
while (i < n) {
|
||
size_t sg = 0;
|
||
if ((unsigned char)body[i] == 0x1b) {
|
||
sty_t dummy = {0};
|
||
sg = sgr_parse(body + i, n - i, &dummy);
|
||
if (!sg)
|
||
sg = ansi_skip(body + i, n - i);
|
||
if (sg) {
|
||
i += sg;
|
||
continue;
|
||
}
|
||
}
|
||
if (body[i] == '\n') {
|
||
i++;
|
||
break;
|
||
}
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(body + i, n - i, &wc, &eaten);
|
||
int cw = utf8_width(wc);
|
||
if (col + cw > body_w && col > 0)
|
||
break;
|
||
col += cw;
|
||
i += eaten;
|
||
if (col >= body_w)
|
||
break;
|
||
}
|
||
size_t chunk = i - start;
|
||
/* trim trailing newline already consumed */
|
||
if (chunk > 0 && body[start + chunk - 1] == '\n')
|
||
chunk--;
|
||
if (first && prefix) {
|
||
if (chunk + strlen(prefix) + 1 < sizeof(line)) {
|
||
memcpy(line, prefix, strlen(prefix));
|
||
memcpy(line + strlen(prefix), body + start, chunk);
|
||
line[strlen(prefix) + chunk] = 0;
|
||
} else {
|
||
snprintf(line, sizeof(line), "%s", prefix);
|
||
}
|
||
first = 0;
|
||
} else {
|
||
/* continuation indent matching prefix visual width */
|
||
int pad = pref_cols;
|
||
if (pad < 0)
|
||
pad = 0;
|
||
if (pad > 40)
|
||
pad = 40;
|
||
memset(line, ' ', (size_t)pad);
|
||
if (chunk + (size_t)pad + 1 < sizeof(line)) {
|
||
memcpy(line + pad, body + start, chunk);
|
||
line[pad + chunk] = 0;
|
||
} else {
|
||
line[pad] = 0;
|
||
}
|
||
}
|
||
dlines_push(dl, line, pair, bold, dim);
|
||
if (i < n && body[i - 1] != '\n' && col >= body_w) {
|
||
/* soft wrap — already advanced */
|
||
}
|
||
}
|
||
}
|
||
|
||
static void push_body_preview(dlines_t *dl, const char *body, int width,
|
||
int pair, int expanded, int max_lines) {
|
||
if (!body || !body[0])
|
||
return;
|
||
if (expanded) {
|
||
push_wrapped(dl, " ", body, width, pair, 0, 1, 0);
|
||
return;
|
||
}
|
||
const char *p = body;
|
||
int shown = 0;
|
||
while (*p && shown < max_lines) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
char tmp[1024];
|
||
if (len >= sizeof(tmp))
|
||
len = sizeof(tmp) - 1;
|
||
memcpy(tmp, p, len);
|
||
tmp[len] = 0;
|
||
push_wrapped(dl, " ", tmp, width, pair, 0, 1, 0);
|
||
shown++;
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
if (*p)
|
||
dlines_push(dl, " …", pair, 0, 1);
|
||
}
|
||
|
||
static int body_looks_like_diff(const char *body) {
|
||
if (!body || !body[0])
|
||
return 0;
|
||
return strstr(body, "\n--- ") || strstr(body, "\n+++ ") ||
|
||
strstr(body, "\n@@ ") || strstr(body, "\n@@-") ||
|
||
strncmp(body, "--- ", 4) == 0 || strncmp(body, "diff ", 5) == 0 ||
|
||
strstr(body, "\n--- a/") || strstr(body, "\n+++ b/");
|
||
}
|
||
|
||
static void diff_extract_path(const char *body, const char *fallback,
|
||
char *out, size_t out_sz) {
|
||
if (!out || out_sz == 0)
|
||
return;
|
||
out[0] = 0;
|
||
if (body) {
|
||
const char *p = body;
|
||
while (*p) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
if (len > 6 && strncmp(p, "wrote ", 6) == 0) {
|
||
const char *s = p + 6;
|
||
size_t n = len - 6;
|
||
/* trim " (N bytes)" */
|
||
const char *sp = memchr(s, ' ', n);
|
||
if (sp)
|
||
n = (size_t)(sp - s);
|
||
if (n >= out_sz)
|
||
n = out_sz - 1;
|
||
memcpy(out, s, n);
|
||
out[n] = 0;
|
||
return;
|
||
}
|
||
if (len > 6 && strncmp(p, "+++ b/", 6) == 0) {
|
||
size_t n = len - 6;
|
||
if (n >= out_sz)
|
||
n = out_sz - 1;
|
||
memcpy(out, p + 6, n);
|
||
out[n] = 0;
|
||
return;
|
||
}
|
||
if (len > 6 && strncmp(p, "--- a/", 6) == 0) {
|
||
size_t n = len - 6;
|
||
if (n >= out_sz)
|
||
n = out_sz - 1;
|
||
memcpy(out, p + 6, n);
|
||
out[n] = 0;
|
||
/* keep scanning for +++ / wrote */
|
||
}
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
}
|
||
if (!out[0] && fallback && fallback[0]) {
|
||
snprintf(out, out_sz, "%s", fallback);
|
||
}
|
||
}
|
||
|
||
static int parse_hunk_at(const char *line, int *old_ln, int *new_ln) {
|
||
/* @@ -old[,n] +new[,n] @@ */
|
||
if (line[0] != '@' || line[1] != '@')
|
||
return 0;
|
||
const char *p = line;
|
||
while (*p && *p != '-')
|
||
p++;
|
||
if (*p != '-')
|
||
return 0;
|
||
p++;
|
||
int o = 0, n = 0;
|
||
if (sscanf(p, "%d", &o) != 1)
|
||
return 0;
|
||
while (*p && *p != '+')
|
||
p++;
|
||
if (*p != '+')
|
||
return 0;
|
||
p++;
|
||
if (sscanf(p, "%d", &n) != 1)
|
||
return 0;
|
||
if (old_ln)
|
||
*old_ln = o;
|
||
if (new_ln)
|
||
*new_ln = n;
|
||
return 1;
|
||
}
|
||
|
||
typedef struct {
|
||
int additions;
|
||
int deletions;
|
||
int content_lines;
|
||
} diff_stats_t;
|
||
|
||
static void diff_count_stats(const char *body, diff_stats_t *st) {
|
||
memset(st, 0, sizeof(*st));
|
||
if (!body)
|
||
return;
|
||
const char *p = body;
|
||
while (*p) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
if (len == 0) {
|
||
/* empty */
|
||
} else if (strncmp(p, "---", 3) == 0 || strncmp(p, "+++", 3) == 0 ||
|
||
strncmp(p, "diff ", 5) == 0 || strncmp(p, "index ", 6) == 0 ||
|
||
strncmp(p, "@@", 2) == 0 || strncmp(p, "wrote ", 6) == 0 ||
|
||
(p[0] == '[' && strstr(p, "tool "))) {
|
||
/* skip meta */
|
||
} else if (p[0] == '+') {
|
||
st->additions++;
|
||
st->content_lines++;
|
||
} else if (p[0] == '-') {
|
||
st->deletions++;
|
||
st->content_lines++;
|
||
} else if (p[0] == ' ' || p[0] == '\\') {
|
||
st->content_lines++;
|
||
}
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
}
|
||
|
||
/* OpenCode-style: strip ---/+++/@@, line numbers, add/del backgrounds. */
|
||
static void push_structured_diff(dlines_t *dl, const char *body, int width,
|
||
int unlimited, int fold_limit) {
|
||
if (!body || !body[0])
|
||
return;
|
||
int old_ln = 0, new_ln = 0;
|
||
int shown = 0;
|
||
int total_content = 0;
|
||
/* First pass: count content lines for fold message */
|
||
{
|
||
diff_stats_t st;
|
||
diff_count_stats(body, &st);
|
||
total_content = st.content_lines;
|
||
}
|
||
int limit = unlimited ? 1000000 : fold_limit;
|
||
if (limit < 1)
|
||
limit = 1;
|
||
|
||
const char *p = body;
|
||
while (*p) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
char raw[1024];
|
||
if (len >= sizeof(raw))
|
||
len = sizeof(raw) - 1;
|
||
memcpy(raw, p, len);
|
||
raw[len] = 0;
|
||
|
||
int skip = 0;
|
||
if (raw[0] == 0)
|
||
skip = 1;
|
||
else if (strncmp(raw, "wrote ", 6) == 0)
|
||
skip = 1;
|
||
else if (raw[0] == '[' && strstr(raw, "tool "))
|
||
skip = 1;
|
||
else if (strncmp(raw, "---", 3) == 0 || strncmp(raw, "+++", 3) == 0 ||
|
||
strncmp(raw, "diff ", 5) == 0 || strncmp(raw, "index ", 6) == 0)
|
||
skip = 1;
|
||
else if (strncmp(raw, "@@", 2) == 0) {
|
||
parse_hunk_at(raw, &old_ln, &new_ln);
|
||
skip = 1;
|
||
} else if (raw[0] == '\\') {
|
||
/* "\ No newline at end of file" */
|
||
skip = 1;
|
||
}
|
||
|
||
if (!skip && (raw[0] == '+' || raw[0] == '-' || raw[0] == ' ')) {
|
||
if (shown >= limit) {
|
||
/* keep counting via total_content; stop emitting */
|
||
break;
|
||
}
|
||
char kind = raw[0];
|
||
const char *content = raw + 1;
|
||
int lnum = 0;
|
||
char mark = ' ';
|
||
int pair = CP_DIFF_CTX;
|
||
if (kind == '+') {
|
||
lnum = new_ln++;
|
||
mark = '+';
|
||
pair = CP_DIFF_ADD;
|
||
} else if (kind == '-') {
|
||
lnum = old_ln++;
|
||
mark = '-';
|
||
pair = CP_DIFF_DEL;
|
||
} else {
|
||
lnum = new_ln;
|
||
old_ln++;
|
||
new_ln++;
|
||
mark = ' ';
|
||
pair = CP_DIFF_CTX;
|
||
}
|
||
|
||
char line[1200];
|
||
/* │ 12+│ content — left border + gutter + body */
|
||
int body_w = width - 8;
|
||
if (body_w < 8)
|
||
body_w = 8;
|
||
char clipped[1024];
|
||
size_t clen = strlen(content);
|
||
/* column-clip roughly by bytes for MVP */
|
||
if ((int)clen > body_w) {
|
||
int keep = body_w > 3 ? body_w - 3 : body_w;
|
||
memcpy(clipped, content, (size_t)keep);
|
||
if (body_w > 3)
|
||
memcpy(clipped + keep, "...", 4);
|
||
else
|
||
clipped[keep] = 0;
|
||
content = clipped;
|
||
}
|
||
snprintf(line, sizeof(line), "│%4d%c│ %s", lnum, mark, content);
|
||
/* Pad to width so row background fills like OpenCode. */
|
||
{
|
||
int cols = utf8_cols(line, strlen(line));
|
||
size_t llen = strlen(line);
|
||
while (cols < width && llen + 1 < sizeof(line)) {
|
||
line[llen++] = ' ';
|
||
line[llen] = 0;
|
||
cols++;
|
||
}
|
||
}
|
||
dlines_push(dl, line, pair, kind != ' ', 0);
|
||
shown++;
|
||
}
|
||
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
|
||
if (!unlimited && total_content > shown) {
|
||
char more[96];
|
||
snprintf(more, sizeof(more), "│ … %d more lines · /details",
|
||
total_content - shown);
|
||
dlines_push(dl, more, CP_MUTED, 0, 1);
|
||
}
|
||
}
|
||
|
||
static void push_diff_or_body(dlines_t *dl, const char *body, int width,
|
||
int default_pair, int expanded, int max_lines,
|
||
int details_all) {
|
||
if (!body || !body[0])
|
||
return;
|
||
if (!body_looks_like_diff(body)) {
|
||
push_body_preview(dl, body, width, default_pair, expanded || details_all,
|
||
max_lines);
|
||
return;
|
||
}
|
||
/* Auto-expanded write cards still fold at DIFF_FOLD_LINES unless /details. */
|
||
int unlimited = details_all;
|
||
int fold = expanded ? DIFF_FOLD_LINES : max_lines;
|
||
if (!expanded && !details_all)
|
||
fold = 0; /* title-only when collapsed */
|
||
if (fold == 0 && !unlimited)
|
||
return;
|
||
push_structured_diff(dl, body, width, unlimited, fold);
|
||
}
|
||
|
||
static void block_to_lines(const block_t *b, int width, int details_all,
|
||
dlines_t *dl) {
|
||
char prefix[128];
|
||
char head[512];
|
||
|
||
switch (b->kind) {
|
||
case BLK_USER:
|
||
snprintf(prefix, sizeof(prefix), "┃ ");
|
||
dlines_push(dl, "┃ You", CP_PRIMARY, 1, 0);
|
||
push_wrapped(dl, prefix, b->body ? b->body : "", width, CP_PRIMARY, 0, 0,
|
||
0);
|
||
dlines_push(dl, "", 0, 0, 0);
|
||
break;
|
||
|
||
case BLK_ASSISTANT: {
|
||
dlines_push(dl, "┃ Assistant", CP_ASSIST, 1, 0);
|
||
const char *src = b->render ? b->render : (b->body ? b->body : "");
|
||
push_wrapped(dl, "┃ ", src, width, CP_ASSIST, 0, 0, 1);
|
||
dlines_push(dl, "", 0, 0, 0);
|
||
break;
|
||
}
|
||
|
||
case BLK_THINK:
|
||
if (b->expanded) {
|
||
snprintf(head, sizeof(head), "▼ %s", b->title ? b->title : "Thought");
|
||
dlines_push(dl, head, CP_MUTED, 0, 1);
|
||
push_wrapped(dl, " ", b->body ? b->body : "", width, CP_MUTED, 0, 1, 0);
|
||
} else {
|
||
/* One-liner: title + truncated body hint */
|
||
const char *body = b->body ? b->body : "";
|
||
size_t bl = strlen(body);
|
||
while (bl > 0 && (body[bl - 1] == '\n' || body[bl - 1] == ' '))
|
||
bl--;
|
||
char one[200];
|
||
size_t take = bl > 60 ? 60 : bl;
|
||
/* skip to first non-space for preview */
|
||
size_t off = 0;
|
||
while (off < take && (body[off] == ' ' || body[off] == '\n'))
|
||
off++;
|
||
snprintf(one, sizeof(one), "▶ %s %.*s%s",
|
||
b->title ? b->title : "Thought", (int)(take > off ? take - off : 0),
|
||
body + off, bl > 60 ? "…" : "");
|
||
dlines_push(dl, one, CP_MUTED, 0, 1);
|
||
}
|
||
break;
|
||
|
||
case BLK_TOOL: {
|
||
const char *px = tool_prefix(b->tool_name);
|
||
int exp = details_all || b->expanded;
|
||
int denied = (strncmp(b->meta, "denied", 6) == 0);
|
||
int is_write = (strcmp(b->tool_name, "write_file") == 0);
|
||
int is_file_edit = cst_tool_is_write(b->tool_name);
|
||
const char *verb_done = is_write ? "Wrote" : "Edit";
|
||
const char *verb_run = is_write ? "Write" : "Edit";
|
||
if (strcmp(b->tool_name, "delete_file") == 0)
|
||
verb_done = "Deleted", verb_run = "Delete";
|
||
else if (strcmp(b->tool_name, "move_file") == 0)
|
||
verb_done = "Moved", verb_run = "Move";
|
||
int done = (strncmp(b->meta, "exit=", 5) == 0) ||
|
||
(strcmp(b->meta, "done") == 0) ||
|
||
(b->body && body_looks_like_diff(b->body));
|
||
|
||
if (is_file_edit && !denied) {
|
||
char path[256];
|
||
diff_extract_path(b->body, b->title, path, sizeof(path));
|
||
if (!path[0] && b->title && b->title[0])
|
||
snprintf(path, sizeof(path), "%s", b->title);
|
||
if (!path[0])
|
||
snprintf(path, sizeof(path), "file");
|
||
|
||
diff_stats_t st = {0};
|
||
if (b->body)
|
||
diff_count_stats(b->body, &st);
|
||
|
||
char head[512];
|
||
if (done) {
|
||
/* OpenCode: ← Wrote path / ← Edit path +N -M */
|
||
snprintf(head, sizeof(head),
|
||
"│ ← %s %.200s \033[32m+%d\033[0m \033[31m-%d\033[0m",
|
||
verb_done, path, st.additions, st.deletions);
|
||
} else {
|
||
snprintf(head, sizeof(head), "│ ← %s %.200s %.100s", verb_run, path,
|
||
b->meta[0] ? b->meta : "…");
|
||
}
|
||
dlines_push(dl, head, CP_MUTED, 0, 0);
|
||
if (done || exp)
|
||
push_diff_or_body(dl, b->body, width, CP_MUTED, b->expanded,
|
||
TOOL_PREVIEW_LINES, details_all);
|
||
} else {
|
||
char status_short[96];
|
||
status_short[0] = 0;
|
||
if (denied) {
|
||
snprintf(status_short, sizeof(status_short), "denied");
|
||
} else if (strncmp(b->meta, "exit=", 5) == 0) {
|
||
snprintf(status_short, sizeof(status_short), "done %.80s", b->meta);
|
||
} else if (b->meta[0]) {
|
||
snprintf(status_short, sizeof(status_short), "%.80s", b->meta);
|
||
}
|
||
snprintf(head, sizeof(head), "%s %s %s%s%s", px,
|
||
b->tool_name[0] ? b->tool_name : "tool",
|
||
b->title ? b->title : "",
|
||
status_short[0] ? " " : "", status_short);
|
||
dlines_push(dl, head, denied ? CP_ERROR : CP_TOOL, 1, 0);
|
||
push_diff_or_body(dl, b->body, width, denied ? CP_ERROR : CP_MUTED, exp,
|
||
TOOL_PREVIEW_LINES, details_all);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case BLK_STATUS:
|
||
case BLK_SYSTEM:
|
||
snprintf(head, sizeof(head), "%s%s%s", b->title ? b->title : "",
|
||
(b->title && b->body && b->body[0]) ? " " : "",
|
||
b->body ? b->body : "");
|
||
dlines_push(dl, head, CP_MUTED, 0, 1);
|
||
break;
|
||
}
|
||
}
|
||
|
||
static void build_display(tui_t *t, dlines_t *dl, int width) {
|
||
dlines_free(dl);
|
||
dl->lines = NULL;
|
||
dl->n = dl->cap = 0;
|
||
for (int i = 0; i < t->hist.n; i++)
|
||
block_to_lines(&t->hist.items[i], width, t->details_all, dl);
|
||
if (t->as_live)
|
||
block_to_lines(&t->live, width, t->details_all, dl);
|
||
}
|
||
|
||
static void draw_ansi_line(WINDOW *win, int y, int maxw, const char *s,
|
||
int base_pair, int base_bold, int base_dim) {
|
||
sty_t sty = {0};
|
||
sty.pair = base_pair;
|
||
if (base_bold)
|
||
sty.attrs |= A_BOLD;
|
||
if (base_dim)
|
||
sty.attrs |= A_DIM;
|
||
int col = 0;
|
||
size_t n = s ? strlen(s) : 0;
|
||
size_t i = 0;
|
||
while (i < n && col < maxw) {
|
||
size_t sg = sgr_parse(s + i, n - i, &sty);
|
||
if (sg) {
|
||
i += sg;
|
||
continue;
|
||
}
|
||
size_t skip = ansi_skip(s + i, n - i);
|
||
if (skip) {
|
||
i += skip;
|
||
continue;
|
||
}
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(s + i, n - i, &wc, &eaten);
|
||
wc = ascii_glyph(wc);
|
||
int cw = utf8_width(wc);
|
||
if (col + cw > maxw)
|
||
break;
|
||
int attrs = sty.attrs;
|
||
if (sty.pair && HAS_COLOR())
|
||
attrs |= COLOR_PAIR(sty.pair);
|
||
else if (base_pair && HAS_COLOR())
|
||
attrs |= COLOR_PAIR(base_pair);
|
||
put_wc(win, y, col, wc, attrs);
|
||
col += cw;
|
||
i += eaten;
|
||
}
|
||
}
|
||
|
||
static double since_ms(const struct timespec *t0) {
|
||
struct timespec now;
|
||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||
return (double)(now.tv_sec - t0->tv_sec) * 1000.0 +
|
||
(double)(now.tv_nsec - t0->tv_nsec) / 1e6;
|
||
}
|
||
|
||
static void draw_header(tui_t *t) {
|
||
if (!t->header)
|
||
return;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
werase(t->header);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->header, COLOR_PAIR(CP_HEADER));
|
||
if (!cfg) {
|
||
wrefresh(t->header);
|
||
return;
|
||
}
|
||
char line[640], right[96] = "";
|
||
const char *dot = g_ascii ? " | " : " · ";
|
||
long win = cst_model_context_tokens(cfg->model);
|
||
long used = cst_agent_last_prompt_tokens(t->agent);
|
||
int pct = win > 0 ? (int)(used * 100 / win) : 0;
|
||
const char *pc = pct >= 80 ? "\033[31m" : pct >= 60 ? "\033[33m" : "";
|
||
const char *eff = cst_effort_wire(cfg->provider, cfg->model, cfg->effort)
|
||
? cfg->effort
|
||
: NULL;
|
||
int k = snprintf(line, sizeof(line),
|
||
" CST-Agent%s%s/%s%s%s%s%s%sctx %s%d%%\033[39m", dot,
|
||
cfg->provider, cfg->model, eff ? dot : "", eff ? eff : "",
|
||
dot, cst_protocol_name(cfg->protocol), dot, pc, pct);
|
||
if (k > 0 && (size_t)k < sizeof(line) && t->title[0])
|
||
k += snprintf(line + k, sizeof(line) - (size_t)k, "%s%.40s", dot, t->title);
|
||
if (k > 0 && (size_t)k < sizeof(line) && cfg->auto_approve)
|
||
snprintf(line + k, sizeof(line) - (size_t)k, "%s\033[31;1mYOLO\033[0m", dot);
|
||
if (t->busy) {
|
||
static const char *const uni[] = {"⠋", "⠙", "⠹", "⠸", "⠼",
|
||
"⠴", "⠦", "⠧", "⠇", "⠏"};
|
||
static const char *const asc[] = {"|", "/", "-", "\\"};
|
||
const char *f = g_ascii ? asc[t->spin % 4] : uni[t->spin % 10];
|
||
snprintf(right, sizeof(right), "%s working %.0fs ", f,
|
||
since_ms(&t->turn_t0) / 1000.0);
|
||
}
|
||
int rw = utf8_cols(right, strlen(right));
|
||
draw_ansi_line(t->header, 0, t->cols - rw - 1, line, CP_HEADER, 1, 0);
|
||
if (rw) {
|
||
if (HAS_COLOR())
|
||
wattron(t->header, COLOR_PAIR(CP_PRIMARY));
|
||
mvwaddstr(t->header, 0, t->cols - rw - 1, right);
|
||
if (HAS_COLOR())
|
||
wattroff(t->header, COLOR_PAIR(CP_PRIMARY));
|
||
}
|
||
wrefresh(t->header);
|
||
}
|
||
|
||
static void draw_status(tui_t *t) {
|
||
if (!t->status)
|
||
return;
|
||
werase(t->status);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->status, COLOR_PAIR(CP_STATUS));
|
||
const char *msg = t->status_msg[0] ? t->status_msg : "ready";
|
||
char line[600];
|
||
snprintf(line, sizeof(line), " %s", msg);
|
||
draw_ansi_line(t->status, 0, t->cols - 1, line, CP_STATUS, 0, 0);
|
||
wrefresh(t->status);
|
||
}
|
||
|
||
/* Visual position of every byte offset: soft wrap at the window edge plus
|
||
* hard newlines. Calls fn(ud, offset, row, col, wc, width) per character and
|
||
* returns the row count; cursor row/col are reported for t->cur. */
|
||
typedef void (*input_cell_fn)(void *ud, int row, int col, wchar_t wc, int cw);
|
||
|
||
static int input_layout(const tui_t *t, input_cell_fn fn, void *ud,
|
||
int *cur_row, int *cur_col) {
|
||
int row = 0, col = 3, right = t->cols - 1;
|
||
*cur_row = 0;
|
||
*cur_col = 3;
|
||
size_t i = 0;
|
||
while (i < t->in_len) {
|
||
if (i == t->cur) {
|
||
*cur_row = row;
|
||
*cur_col = col;
|
||
}
|
||
if (t->inbuf[i] == '\n') {
|
||
row++;
|
||
col = 3;
|
||
i++;
|
||
continue;
|
||
}
|
||
wchar_t wc;
|
||
size_t eaten;
|
||
utf8_decode(t->inbuf + i, t->in_len - i, &wc, &eaten);
|
||
if (wc == L'\t')
|
||
wc = L' ';
|
||
int cw = utf8_width(wc);
|
||
if (col + cw > right && col > 3) {
|
||
row++;
|
||
col = 3;
|
||
}
|
||
if (fn)
|
||
fn(ud, row, col, wc, cw);
|
||
col += cw;
|
||
i += eaten;
|
||
}
|
||
if (t->cur >= t->in_len) {
|
||
if (col >= right) {
|
||
row++;
|
||
col = 3;
|
||
}
|
||
*cur_row = row;
|
||
*cur_col = col;
|
||
}
|
||
return row + 1;
|
||
}
|
||
|
||
typedef struct {
|
||
WINDOW *win;
|
||
int top, h, attrs;
|
||
} input_draw_t;
|
||
|
||
static void input_draw_cell(void *ud, int row, int col, wchar_t wc, int cw) {
|
||
(void)cw;
|
||
input_draw_t *d = ud;
|
||
if (row < d->top || row >= d->top + d->h)
|
||
return;
|
||
put_wc(d->win, row - d->top, col, wc, d->attrs);
|
||
}
|
||
|
||
static void draw_input(tui_t *t) {
|
||
if (!t->input)
|
||
return;
|
||
werase(t->input);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->input, COLOR_PAIR(CP_INPUT));
|
||
int h = t->input_rows > 0 ? t->input_rows : 1;
|
||
int cr, cc;
|
||
input_layout(t, NULL, NULL, &cr, &cc);
|
||
int top = cr >= h ? cr - h + 1 : 0;
|
||
for (int r = 0; r < h; r++)
|
||
mvwaddstr(t->input, r, 0,
|
||
r + top == 0 ? (t->busy ? (g_ascii ? " * " : " … ") : " > ")
|
||
: " ");
|
||
if (top > 0)
|
||
mvwaddstr(t->input, 0, 0, g_ascii ? " ^ " : " ↑ ");
|
||
if (t->in_len == 0) {
|
||
const char *hint =
|
||
t->busy ? "working… Enter queues a message · Esc cancels"
|
||
: "Ask anything · / commands · @ files · Ctrl+N newline";
|
||
int attrs = A_DIM | (HAS_COLOR() ? (int)COLOR_PAIR(CP_MUTED) : 0);
|
||
wattron(t->input, attrs);
|
||
if (g_ascii)
|
||
hint = t->busy ? "working... Enter queues a message - Esc cancels"
|
||
: "Ask anything - / commands - @ files - Ctrl+N newline";
|
||
size_t fit = utf8_fit_cols(hint, strlen(hint), t->cols - 5);
|
||
mvwaddnstr(t->input, 0, 3, hint, (int)fit);
|
||
wattroff(t->input, attrs);
|
||
wmove(t->input, 0, 3);
|
||
wrefresh(t->input);
|
||
return;
|
||
}
|
||
input_draw_t d = {t->input, top, h,
|
||
HAS_COLOR() ? (int)COLOR_PAIR(CP_INPUT) : 0};
|
||
input_layout(t, input_draw_cell, &d, &cr, &cc);
|
||
if (cc >= t->cols)
|
||
cc = t->cols - 1;
|
||
wmove(t->input, cr - top, cc);
|
||
wrefresh(t->input);
|
||
}
|
||
|
||
/* Popup rows at the bottom of the transcript, just above the input. */
|
||
static void draw_completion(tui_t *t, int h, int w) {
|
||
t->comp_drawn = t->comp_n;
|
||
if (t->comp_n <= 0 || h < 2)
|
||
return;
|
||
int n = t->comp_n < h - 1 ? t->comp_n : h - 1;
|
||
int bw = w - 2 < 90 ? w - 2 : 90;
|
||
for (int i = 0; i < n; i++) {
|
||
int y = h - n + i;
|
||
const comp_item_t *c = &t->comp[i];
|
||
char line[420];
|
||
snprintf(line, sizeof(line), " %-22s %s", c->text, c->desc);
|
||
int sel = i == t->comp_sel;
|
||
wmove(t->logw, y, 1);
|
||
for (int x = 0; x < bw; x++)
|
||
waddch(t->logw, ' ' | (sel ? A_REVERSE : 0) |
|
||
(HAS_COLOR() ? COLOR_PAIR(CP_HEADER) : 0));
|
||
if (sel)
|
||
wattron(t->logw, A_REVERSE);
|
||
size_t fit = utf8_fit_cols(line, strlen(line), bw);
|
||
char cut[420];
|
||
snprintf(cut, sizeof(cut), "%.*s", (int)fit, line);
|
||
if (HAS_COLOR())
|
||
wattron(t->logw, COLOR_PAIR(sel ? CP_PRIMARY : CP_HEADER));
|
||
mvwaddstr(t->logw, y, 1, cut);
|
||
if (HAS_COLOR())
|
||
wattroff(t->logw, COLOR_PAIR(sel ? CP_PRIMARY : CP_HEADER));
|
||
if (sel)
|
||
wattroff(t->logw, A_REVERSE);
|
||
}
|
||
}
|
||
|
||
/* Task list panel above the status bar while todo_write has open items. */
|
||
static int todo_open_items(const char *todo, int *total) {
|
||
int open = 0, n = 0;
|
||
for (const char *p = todo; p && *p;) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
if (len > 0) {
|
||
n++;
|
||
if (!(len >= 3 && strncmp(p, "[x]", 3) == 0))
|
||
open++;
|
||
}
|
||
p = nl ? nl + 1 : p + len;
|
||
}
|
||
if (total)
|
||
*total = n;
|
||
return open;
|
||
}
|
||
|
||
static void draw_todo(tui_t *t) {
|
||
if (!t->todow)
|
||
return;
|
||
werase(t->todow);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->todow, COLOR_PAIR(CP_PANEL));
|
||
int total = 0, open = todo_open_items(t->todo, &total);
|
||
char head[96];
|
||
snprintf(head, sizeof(head), g_ascii ? " Tasks %d/%d done (Ctrl+T hides)"
|
||
: " Tasks %d/%d done · Ctrl+T hides",
|
||
total - open, total);
|
||
draw_ansi_line(t->todow, 0, t->cols - 1, head, CP_PRIMARY, 1, 0);
|
||
int row = 1, skipped = 0;
|
||
int room = t->todo_rows - 1;
|
||
/* keep the first unfinished item visible */
|
||
int first_open = 0, idx = 0;
|
||
for (const char *p = t->todo; p && *p; idx++) {
|
||
const char *nl = strchr(p, '\n');
|
||
if (strncmp(p, "[x]", 3) != 0) {
|
||
first_open = idx;
|
||
break;
|
||
}
|
||
p = nl ? nl + 1 : p + strlen(p);
|
||
}
|
||
int skip = first_open > 1 ? first_open - 1 : 0;
|
||
if (total - skip < room)
|
||
skip = total > room ? total - room : 0;
|
||
idx = 0;
|
||
for (const char *p = t->todo; p && *p && row <= room;) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
if (len >= 3 && idx++ >= skip) {
|
||
const char *mark = "○";
|
||
int pair = CP_PANEL, dim = 0;
|
||
if (strncmp(p, "[x]", 3) == 0) {
|
||
mark = "✓";
|
||
pair = CP_SUCCESS;
|
||
dim = 1;
|
||
} else if (strncmp(p, "[~]", 3) == 0) {
|
||
mark = "▸";
|
||
pair = CP_PRIMARY;
|
||
}
|
||
char line[400];
|
||
if (row == room && total - idx > 0) {
|
||
snprintf(line, sizeof(line), " … %d more", total - idx + 1);
|
||
draw_ansi_line(t->todow, row, t->cols - 1, line, CP_MUTED, 0, 1);
|
||
break;
|
||
}
|
||
const char *txt = p + 3;
|
||
while (txt < p + len && *txt == ' ')
|
||
txt++;
|
||
snprintf(line, sizeof(line), " %s %.*s", mark, (int)(p + len - txt),
|
||
txt);
|
||
draw_ansi_line(t->todow, row++, t->cols - 1, line, pair, 0, dim);
|
||
} else if (len >= 3) {
|
||
skipped++;
|
||
}
|
||
p = nl ? nl + 1 : p + len;
|
||
}
|
||
(void)skipped;
|
||
wrefresh(t->todow);
|
||
}
|
||
|
||
static void draw_log(tui_t *t) {
|
||
if (!t->logw)
|
||
return;
|
||
werase(t->logw);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->logw, COLOR_PAIR(CP_PANEL));
|
||
int h, w;
|
||
getmaxyx(t->logw, h, w);
|
||
if (h < 1 || w < 1)
|
||
return;
|
||
|
||
dlines_t dl = {0};
|
||
build_display(t, &dl, w);
|
||
int total = dl.n > 0 ? dl.n : 1;
|
||
int max_scroll = total - h;
|
||
if (max_scroll < 0)
|
||
max_scroll = 0;
|
||
if (t->log_scroll > max_scroll)
|
||
t->log_scroll = max_scroll;
|
||
if (t->log_scroll < 0)
|
||
t->log_scroll = 0;
|
||
int start = total - h - t->log_scroll;
|
||
if (start < 0)
|
||
start = 0;
|
||
|
||
for (int row = 0; row < h; row++) {
|
||
int li = start + row;
|
||
if (li < 0 || li >= dl.n)
|
||
continue;
|
||
dline_t *L = &dl.lines[li];
|
||
draw_ansi_line(t->logw, row, w, L->text, L->pair, L->bold, L->dim);
|
||
}
|
||
if (t->log_scroll > 0 && h > 2) {
|
||
char more[128];
|
||
snprintf(more, sizeof(more),
|
||
g_ascii ? " -- %d more lines below - End/PgDn - %d above -- "
|
||
: " ── %d more lines below · End/PgDn · %d above ── ",
|
||
t->log_scroll, start);
|
||
wmove(t->logw, h - 1, 0);
|
||
wclrtoeol(t->logw);
|
||
wattron(t->logw, A_REVERSE);
|
||
draw_ansi_line(t->logw, h - 1, w, more, CP_PRIMARY, 1, 0);
|
||
wattroff(t->logw, A_REVERSE);
|
||
}
|
||
dlines_free(&dl);
|
||
draw_completion(t, h, w);
|
||
wrefresh(t->logw);
|
||
}
|
||
|
||
static void redraw_all(tui_t *t) {
|
||
theme_paint_root();
|
||
draw_header(t);
|
||
draw_log(t);
|
||
draw_todo(t);
|
||
draw_status(t);
|
||
draw_input(t);
|
||
}
|
||
|
||
/* --- session reload ------------------------------------------------------- */
|
||
|
||
static void push_system_line(tui_t *t, const char *fmt, ...) {
|
||
char tmp[1024];
|
||
va_list ap;
|
||
va_start(ap, fmt);
|
||
vsnprintf(tmp, sizeof(tmp), fmt, ap);
|
||
va_end(ap);
|
||
block_t *b = blocks_push(&t->hist);
|
||
if (!b)
|
||
return;
|
||
b->kind = BLK_SYSTEM;
|
||
block_set_body(b, tmp, strlen(tmp));
|
||
}
|
||
|
||
static void print_welcome(tui_t *t) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
push_system_line(t, "Welcome to CST-Agent");
|
||
push_system_line(t, "/ for commands (/help lists them) · @ to mention a file "
|
||
"· Esc cancels a turn · Ctrl+C twice quits");
|
||
if (cfg)
|
||
push_system_line(t, "Model %s @ %s · protocol %s · tools %s", cfg->model,
|
||
cfg->api_base, cst_protocol_name(cfg->protocol),
|
||
cst_tool_profile_name(cfg->tool_profile));
|
||
if (cfg && cst_agent_notes(t->agent) && cst_agent_notes(t->agent)[0])
|
||
push_system_line(t, "Project notes loaded from AGENTS.md");
|
||
push_system_line(t, "");
|
||
}
|
||
|
||
typedef struct {
|
||
char id[CTL_MAX_ID];
|
||
char title[160];
|
||
} call_title_t;
|
||
|
||
/* Native tool_calls JSON → per-call one-line titles for replayed cards. */
|
||
static void remember_call_titles(const char *json, call_title_t *v, int *n) {
|
||
cJSON *arr = cJSON_Parse(json);
|
||
const cJSON *it;
|
||
cJSON_ArrayForEach(it, arr) {
|
||
const cJSON *id = cJSON_GetObjectItemCaseSensitive(it, "id");
|
||
const cJSON *fn = cJSON_GetObjectItemCaseSensitive(it, "function");
|
||
const cJSON *name = cJSON_GetObjectItemCaseSensitive(fn, "name");
|
||
const cJSON *args = cJSON_GetObjectItemCaseSensitive(fn, "arguments");
|
||
if (!cJSON_IsString(id) || !cJSON_IsString(name))
|
||
continue;
|
||
ctl_tool_call_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
if (cst_tool_call_from_native(&c, id->valuestring, name->valuestring,
|
||
cJSON_IsString(args) ? args->valuestring
|
||
: "{}") == 0) {
|
||
int k = *n < 64 ? (*n)++ : 63;
|
||
snprintf(v[k].id, sizeof(v[k].id), "%s", id->valuestring);
|
||
tool_arg_summary(&c, v[k].title, sizeof(v[k].title));
|
||
}
|
||
for (int i = 0; i < c.nargs; i++)
|
||
free(c.args[i].value);
|
||
}
|
||
cJSON_Delete(arr);
|
||
}
|
||
|
||
static void reload_hist_from_session(tui_t *t) {
|
||
live_end_cleanup(t);
|
||
blocks_clear(&t->hist);
|
||
t->log_scroll = 0;
|
||
t->think_idx = -1;
|
||
t->last_tool_idx = -1;
|
||
t->focus_think_idx = -1;
|
||
|
||
print_welcome(t);
|
||
|
||
cst_session_t *s = cst_agent_session(t->agent);
|
||
if (!s)
|
||
return;
|
||
cst_session_lock(s);
|
||
int n = cst_session_count(s);
|
||
int non_sys = 0;
|
||
for (int i = 0; i < n; i++) {
|
||
const cst_message_t *m = cst_session_get(s, i);
|
||
if (m && m->role != CST_ROLE_SYSTEM)
|
||
non_sys++;
|
||
}
|
||
int skip = non_sys > REPLAY_MSGS ? non_sys - REPLAY_MSGS : 0;
|
||
int seen = 0;
|
||
static call_title_t titles[64];
|
||
int ntitles = 0;
|
||
for (int i = 0; i < n; i++) {
|
||
const cst_message_t *m = cst_session_get(s, i);
|
||
if (!m || m->role == CST_ROLE_SYSTEM)
|
||
continue;
|
||
if (seen++ < skip)
|
||
continue;
|
||
if (m->role == CST_ROLE_ASSISTANT && m->tool_calls_json)
|
||
remember_call_titles(m->tool_calls_json, titles, &ntitles);
|
||
if (m->role == CST_ROLE_ASSISTANT &&
|
||
(!m->content || !m->content[strspn(m->content, " \t\r\n")]))
|
||
continue; /* tool calls only */
|
||
block_t *b = blocks_push(&t->hist);
|
||
if (!b)
|
||
continue;
|
||
if (msg_is_tool_result(m)) {
|
||
b->kind = BLK_TOOL;
|
||
snprintf(b->tool_name, sizeof(b->tool_name), "%s",
|
||
m->tool_name[0] ? m->tool_name : "tool");
|
||
snprintf(b->tool_id, sizeof(b->tool_id), "%s", m->tool_call_id);
|
||
snprintf(b->meta, sizeof(b->meta), "done");
|
||
const char *body;
|
||
size_t blen;
|
||
tool_result_view(m->content, &body, &blen);
|
||
block_set_body(b, body, blen);
|
||
{
|
||
char path[256];
|
||
diff_extract_path(b->body, NULL, path, sizeof(path));
|
||
const char *title = path;
|
||
for (int k = 0; !path[0] && k < ntitles; k++)
|
||
if (strcmp(titles[k].id, m->tool_call_id) == 0)
|
||
title = titles[k].title;
|
||
block_set_title(b, title);
|
||
}
|
||
/* OpenCode: write/edit cards open (still fold at DIFF_FOLD_LINES). */
|
||
if (cst_tool_is_write(b->tool_name))
|
||
b->expanded = 1;
|
||
else
|
||
b->expanded = 0;
|
||
t->last_tool_idx = t->hist.n - 1;
|
||
} else if (m->role == CST_ROLE_USER) {
|
||
b->kind = BLK_USER;
|
||
block_set_title(b, "You");
|
||
block_set_body(b, m->content ? m->content : "",
|
||
m->content ? strlen(m->content) : 0);
|
||
b->expanded = 1;
|
||
} else if (m->role == CST_ROLE_ASSISTANT) {
|
||
b->kind = BLK_ASSISTANT;
|
||
block_set_title(b, "Assistant");
|
||
if (m->content)
|
||
block_set_body(b, m->content, strlen(m->content));
|
||
b->expanded = 1;
|
||
block_render_assistant(b);
|
||
}
|
||
}
|
||
cst_session_unlock(s);
|
||
}
|
||
|
||
/* --- layout --------------------------------------------------------------- */
|
||
|
||
static int input_visual_rows(const tui_t *t) {
|
||
int cr, cc;
|
||
int rows = input_layout(t, NULL, NULL, &cr, &cc);
|
||
if (rows < 1)
|
||
rows = 1;
|
||
if (rows > INPUT_MAX_ROWS)
|
||
rows = INPUT_MAX_ROWS;
|
||
return rows;
|
||
}
|
||
|
||
static void layout(tui_t *t) {
|
||
getmaxyx(stdscr, t->rows, t->cols);
|
||
theme_paint_root();
|
||
if (t->header)
|
||
delwin(t->header);
|
||
if (t->logw)
|
||
delwin(t->logw);
|
||
if (t->status)
|
||
delwin(t->status);
|
||
if (t->input)
|
||
delwin(t->input);
|
||
if (t->todow)
|
||
delwin(t->todow);
|
||
t->todow = NULL;
|
||
|
||
t->input_rows = input_visual_rows(t);
|
||
int total = 0, open = t->show_todo ? todo_open_items(t->todo, &total) : 0;
|
||
t->todo_rows = open ? 1 + (total < TODO_PANEL_MAX ? total : TODO_PANEL_MAX)
|
||
: 0;
|
||
if (t->rows < 16)
|
||
t->todo_rows = 0;
|
||
int chrome = 1 + 1 + t->input_rows + t->todo_rows;
|
||
if (chrome >= t->rows)
|
||
chrome = t->rows - 1;
|
||
int log_h = t->rows - chrome;
|
||
if (log_h < 1)
|
||
log_h = 1;
|
||
|
||
t->header = newwin(1, t->cols, 0, 0);
|
||
t->logw = newwin(log_h, t->cols, 1, 0);
|
||
if (t->todo_rows)
|
||
t->todow = newwin(t->todo_rows, t->cols, 1 + log_h, 0);
|
||
t->status = newwin(1, t->cols, 1 + log_h + t->todo_rows, 0);
|
||
t->input = newwin(t->input_rows, t->cols, 1 + log_h + t->todo_rows + 1, 0);
|
||
if (HAS_COLOR()) {
|
||
wbkgdset(t->header, COLOR_PAIR(CP_HEADER));
|
||
wbkgdset(t->logw, COLOR_PAIR(CP_PANEL));
|
||
wbkgdset(t->status, COLOR_PAIR(CP_STATUS));
|
||
wbkgdset(t->input, COLOR_PAIR(CP_INPUT));
|
||
}
|
||
scrollok(t->logw, FALSE);
|
||
keypad(t->input, TRUE);
|
||
mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, NULL);
|
||
mouseinterval(0);
|
||
}
|
||
|
||
static void redraw_all(tui_t *t);
|
||
static int start_turn(tui_t *t, char *prompt); /* takes ownership of prompt */
|
||
|
||
static void layout_maybe_grow_input(tui_t *t) {
|
||
int need = input_visual_rows(t);
|
||
if (need != t->input_rows) {
|
||
layout(t);
|
||
wtimeout(t->input, 80);
|
||
redraw_all(t);
|
||
} else {
|
||
draw_input(t);
|
||
}
|
||
}
|
||
|
||
static void update_completion(tui_t *t);
|
||
|
||
static void input_changed(tui_t *t) {
|
||
t->hist_browse = -1;
|
||
t->comp_off = 0;
|
||
update_completion(t);
|
||
}
|
||
|
||
static void input_insert(tui_t *t, const char *p, size_t n) {
|
||
if (t->in_len + n >= INPUT_CAP)
|
||
return;
|
||
memmove(t->inbuf + t->cur + n, t->inbuf + t->cur, t->in_len - t->cur);
|
||
memcpy(t->inbuf + t->cur, p, n);
|
||
t->in_len += n;
|
||
t->cur += n;
|
||
t->inbuf[t->in_len] = 0;
|
||
if (!t->in_paste)
|
||
input_changed(t);
|
||
}
|
||
|
||
static void input_erase(tui_t *t, size_t from, size_t to) {
|
||
if (to > t->in_len)
|
||
to = t->in_len;
|
||
if (from >= to)
|
||
return;
|
||
memmove(t->inbuf + from, t->inbuf + to, t->in_len - to);
|
||
t->in_len -= to - from;
|
||
t->inbuf[t->in_len] = 0;
|
||
if (t->cur >= to)
|
||
t->cur -= to - from;
|
||
else if (t->cur > from)
|
||
t->cur = from;
|
||
input_changed(t);
|
||
}
|
||
|
||
static void input_set(tui_t *t, const char *s) {
|
||
snprintf(t->inbuf, sizeof(t->inbuf), "%s", s ? s : "");
|
||
t->in_len = strlen(t->inbuf);
|
||
t->cur = t->in_len;
|
||
input_changed(t);
|
||
}
|
||
|
||
static void input_clear(tui_t *t) { input_set(t, ""); }
|
||
|
||
static void input_put_wc(tui_t *t, wchar_t wc) {
|
||
char mb[8];
|
||
mbstate_t st;
|
||
memset(&st, 0, sizeof(st));
|
||
size_t n = wcrtomb(mb, wc, &st);
|
||
if (n == (size_t)-1 || n == 0)
|
||
return;
|
||
input_insert(t, mb, n);
|
||
}
|
||
|
||
static void input_put_byte(tui_t *t, char ch) { input_insert(t, &ch, 1); }
|
||
|
||
static size_t next_len(const tui_t *t, size_t at) {
|
||
if (at >= t->in_len)
|
||
return 0;
|
||
size_t i = at + 1;
|
||
while (i < t->in_len && ((unsigned char)t->inbuf[i] & 0xC0) == 0x80)
|
||
i++;
|
||
return i - at;
|
||
}
|
||
|
||
static size_t line_start(const tui_t *t, size_t at) {
|
||
while (at > 0 && t->inbuf[at - 1] != '\n')
|
||
at--;
|
||
return at;
|
||
}
|
||
|
||
static size_t line_end(const tui_t *t, size_t at) {
|
||
while (at < t->in_len && t->inbuf[at] != '\n')
|
||
at++;
|
||
return at;
|
||
}
|
||
|
||
static int is_word(unsigned char c) { return c >= 0x80 || isalnum(c) || c == '_'; }
|
||
|
||
static size_t word_left(const tui_t *t, size_t at) {
|
||
while (at > 0 && !is_word((unsigned char)t->inbuf[at - 1]))
|
||
at--;
|
||
while (at > 0 && is_word((unsigned char)t->inbuf[at - 1]))
|
||
at--;
|
||
return at;
|
||
}
|
||
|
||
static size_t word_right(const tui_t *t, size_t at) {
|
||
while (at < t->in_len && !is_word((unsigned char)t->inbuf[at]))
|
||
at++;
|
||
while (at < t->in_len && is_word((unsigned char)t->inbuf[at]))
|
||
at++;
|
||
return at;
|
||
}
|
||
|
||
/* Move to the previous/next logical line keeping the column (in chars).
|
||
* Returns 0 when already on the first/last line. */
|
||
static int input_line_move(tui_t *t, int dir) {
|
||
size_t ls = line_start(t, t->cur);
|
||
int col = 0;
|
||
for (size_t i = ls; i < t->cur; i += next_len(t, i))
|
||
col++;
|
||
size_t target;
|
||
if (dir < 0) {
|
||
if (ls == 0)
|
||
return 0;
|
||
target = line_start(t, ls - 1);
|
||
} else {
|
||
size_t le = line_end(t, t->cur);
|
||
if (le >= t->in_len)
|
||
return 0;
|
||
target = le + 1;
|
||
}
|
||
size_t te = line_end(t, target);
|
||
while (col-- > 0 && target < te)
|
||
target += next_len(t, target);
|
||
t->cur = target;
|
||
return 1;
|
||
}
|
||
|
||
static void prompt_hist_push(tui_t *t, const char *prompt) {
|
||
if (!prompt || !prompt[0])
|
||
return;
|
||
if (t->prompt_hist_n >= PROMPT_HIST_CAP) {
|
||
free(t->prompt_hist[0]);
|
||
memmove(t->prompt_hist, t->prompt_hist + 1,
|
||
(PROMPT_HIST_CAP - 1) * sizeof(char *));
|
||
t->prompt_hist_n = PROMPT_HIST_CAP - 1;
|
||
}
|
||
t->prompt_hist[t->prompt_hist_n++] = strdup(prompt);
|
||
t->hist_browse = -1;
|
||
}
|
||
|
||
static void hist_load_into_input(tui_t *t, int idx) {
|
||
if (idx < 0 || idx >= t->prompt_hist_n)
|
||
return;
|
||
input_set(t, t->prompt_hist[idx]);
|
||
t->hist_browse = idx;
|
||
}
|
||
|
||
static int queue_push(tui_t *t, const char *prompt) {
|
||
if (!prompt || !prompt[0])
|
||
return -1;
|
||
if (t->queue_n >= QUEUE_CAP)
|
||
return -1;
|
||
t->queue[t->queue_n] = strdup(prompt);
|
||
if (!t->queue[t->queue_n])
|
||
return -1;
|
||
t->queue_n++;
|
||
return 0;
|
||
}
|
||
|
||
static char *queue_pop(tui_t *t) {
|
||
if (t->queue_n <= 0)
|
||
return NULL;
|
||
char *p = t->queue[0];
|
||
memmove(t->queue, t->queue + 1, (size_t)(t->queue_n - 1) * sizeof(char *));
|
||
t->queue_n--;
|
||
t->queue[t->queue_n] = NULL;
|
||
return p;
|
||
}
|
||
|
||
static void queue_free_all(tui_t *t) {
|
||
for (int i = 0; i < t->queue_n; i++) {
|
||
free(t->queue[i]);
|
||
t->queue[i] = NULL;
|
||
}
|
||
t->queue_n = 0;
|
||
}
|
||
|
||
/* --- hooks (may run on worker thread) ------------------------------------- */
|
||
|
||
static void on_status(const char *msg, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
set_status_locked(t, msg);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void on_assistant_delta(const char *text, size_t len, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
collapse_open_think(t);
|
||
if (!t->as_live)
|
||
live_begin(t);
|
||
live_feed(t, text, len);
|
||
t->log_scroll = 0;
|
||
set_status_locked(t, "streaming…");
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void on_think_delta(const char *text, size_t len, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
if (t->think_idx < 0) {
|
||
block_t *b = blocks_push(&t->hist);
|
||
if (b) {
|
||
b->kind = BLK_THINK;
|
||
block_set_title(b, "Thinking…");
|
||
b->expanded = 1;
|
||
t->think_idx = t->hist.n - 1;
|
||
t->focus_think_idx = t->think_idx;
|
||
clock_gettime(CLOCK_MONOTONIC, &t->think_t0);
|
||
t->think_timing = 1;
|
||
}
|
||
}
|
||
if (t->think_idx >= 0 && t->think_idx < t->hist.n && text && len)
|
||
block_append_body(&t->hist.items[t->think_idx], text, len);
|
||
set_status_locked(t, "thinking…");
|
||
t->log_scroll = 0;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void on_tool_event(const char *phase, const ctl_tool_call_t *call,
|
||
const char *detail, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
collapse_open_think(t);
|
||
if (t->as_live &&
|
||
(strcmp(phase, "open") == 0 || strcmp(phase, "ready") == 0))
|
||
live_seal(t);
|
||
if (!call) {
|
||
tui_unlock(t);
|
||
return;
|
||
}
|
||
|
||
int idx = find_tool_block(t, call->id);
|
||
block_t *b = NULL;
|
||
if (idx < 0) {
|
||
b = blocks_push(&t->hist);
|
||
if (!b) {
|
||
tui_unlock(t);
|
||
return;
|
||
}
|
||
b->kind = BLK_TOOL;
|
||
snprintf(b->tool_name, sizeof(b->tool_name), "%s", call->name);
|
||
snprintf(b->tool_id, sizeof(b->tool_id), "%s", call->id);
|
||
b->expanded = 0;
|
||
idx = t->hist.n - 1;
|
||
} else {
|
||
b = &t->hist.items[idx];
|
||
}
|
||
t->last_tool_idx = idx;
|
||
|
||
char args[160];
|
||
tool_arg_summary(call, args, sizeof(args));
|
||
block_set_title(b, args);
|
||
|
||
if (strcmp(phase, "open") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "pending");
|
||
set_status_locked(t, "tool pending…");
|
||
} else if (strcmp(phase, "arg") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "args…");
|
||
} else if (strcmp(phase, "ready") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "ready");
|
||
set_status_locked(t, "tool confirmed");
|
||
} else if (strcmp(phase, "run") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "running");
|
||
set_status_locked(t, "running tool…");
|
||
} else if (strcmp(phase, "denied") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "denied");
|
||
block_set_body(b, "denied by user", strlen("denied by user"));
|
||
set_status_locked(t, "tool denied");
|
||
} else if (strcmp(phase, "done") == 0) {
|
||
snprintf(b->meta, sizeof(b->meta), "%s", detail ? detail : "done");
|
||
if (!b->body || !b->body[0])
|
||
attach_tool_output_from_session(t, b);
|
||
/* Auto-expand write/patch cards so diffs are visible like OpenCode. */
|
||
if (cst_tool_is_write(call->name))
|
||
b->expanded = 1;
|
||
set_status_locked(t, "tool done");
|
||
}
|
||
|
||
t->log_scroll = 0;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void on_tool_result(const ctl_tool_call_t *call, const char *out,
|
||
size_t len, int exit_code, void *ud) {
|
||
(void)exit_code;
|
||
tui_t *t = ud;
|
||
if (!call)
|
||
return;
|
||
tui_lock(t);
|
||
int idx = find_tool_block(t, call->id);
|
||
if (idx >= 0) {
|
||
block_t *b = &t->hist.items[idx];
|
||
block_set_body(b, out ? out : "", out ? len : 0);
|
||
char path[256];
|
||
diff_extract_path(b->body, NULL, path, sizeof(path));
|
||
if (path[0])
|
||
block_set_title(b, path);
|
||
tui_mark_dirty(t);
|
||
}
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static int on_permission(const char *tool_name, const char *detail,
|
||
const char *preview, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
snprintf(t->perm_tool, sizeof(t->perm_tool), "%s",
|
||
tool_name ? tool_name : "tool");
|
||
snprintf(t->perm_detail, sizeof(t->perm_detail), "%s", detail ? detail : "");
|
||
free(t->perm_preview);
|
||
t->perm_preview = preview ? xstrdup(preview) : NULL;
|
||
t->perm_prefix[0] = 0;
|
||
if (preview && strcmp(t->perm_tool, "run_shell") == 0)
|
||
cst_perms_suggest_prefix(preview, t->perm_prefix, sizeof(t->perm_prefix));
|
||
t->perm_scroll = 0;
|
||
t->awaiting_perm = 1;
|
||
t->perm_ans = -1;
|
||
t->perm_ask = 1;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
|
||
for (;;) {
|
||
tui_lock(t);
|
||
int ans = t->perm_ans;
|
||
if (ans >= 0) {
|
||
t->awaiting_perm = 0;
|
||
t->perm_ask = 0;
|
||
free(t->perm_preview);
|
||
t->perm_preview = NULL;
|
||
}
|
||
tui_unlock(t);
|
||
if (ans >= 0)
|
||
return ans;
|
||
usleep(20000);
|
||
}
|
||
}
|
||
|
||
static int on_ask_user(const char *q, const char *opts, char *ans,
|
||
size_t ans_sz, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
snprintf(t->ask_q, sizeof(t->ask_q), "%s", q ? q : "");
|
||
t->ask_nopts = 0;
|
||
for (const char *p = opts; p && *p && t->ask_nopts < ASK_OPTS_MAX;) {
|
||
const char *nl = strchr(p, '\n');
|
||
size_t len = nl ? (size_t)(nl - p) : strlen(p);
|
||
while (len && (p[len - 1] == '\r' || p[len - 1] == ' '))
|
||
len--;
|
||
if (len)
|
||
snprintf(t->ask_opts[t->ask_nopts++], sizeof(t->ask_opts[0]), "%.*s",
|
||
(int)len, p);
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
t->ask_sel = 0;
|
||
t->ask_buf[0] = 0;
|
||
t->ask_done = 0;
|
||
t->ask_pending = 1;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
for (;;) {
|
||
tui_lock(t);
|
||
int done = t->ask_done;
|
||
if (done) {
|
||
t->ask_pending = 0;
|
||
t->ask_done = 0;
|
||
snprintf(ans, ans_sz, "%s", t->ask_buf);
|
||
}
|
||
int rc = t->ask_rc;
|
||
tui_unlock(t);
|
||
if (done)
|
||
return rc;
|
||
usleep(20000);
|
||
}
|
||
}
|
||
|
||
static void on_todo(const char *text, void *ud) {
|
||
tui_t *t = ud;
|
||
tui_lock(t);
|
||
free(t->todo);
|
||
t->todo = text && text[0] ? xstrdup(text) : NULL;
|
||
t->need_layout = 1;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
}
|
||
|
||
/* --- modals drawn over the transcript ------------------------------------- */
|
||
|
||
static void modal_footer(tui_t *t, int h, int w, const char *keys) {
|
||
wmove(t->logw, h - 1, 0);
|
||
wclrtoeol(t->logw);
|
||
wattron(t->logw, A_REVERSE);
|
||
draw_ansi_line(t->logw, h - 1, w, keys, CP_PRIMARY, 1, 0);
|
||
wattroff(t->logw, A_REVERSE);
|
||
}
|
||
|
||
static void draw_perm_modal(tui_t *t) {
|
||
int h, w;
|
||
getmaxyx(t->logw, h, w);
|
||
werase(t->logw);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->logw, COLOR_PAIR(CP_PANEL));
|
||
dlines_t dl = {0};
|
||
char head[700];
|
||
snprintf(head, sizeof(head), "Allow %s?", t->perm_tool);
|
||
dlines_push(&dl, head, CP_PRIMARY, 1, 0);
|
||
int is_shell = strcmp(t->perm_tool, "run_shell") == 0;
|
||
if (is_shell && t->perm_preview) {
|
||
push_wrapped(&dl, " $ ", t->perm_preview, w, CP_TOOL, 1, 0, 0);
|
||
} else {
|
||
push_wrapped(&dl, " ", t->perm_detail, w, CP_MUTED, 0, 0, 0);
|
||
if (t->perm_preview && t->perm_preview[0]) {
|
||
dlines_push(&dl, "", 0, 0, 0);
|
||
push_diff_or_body(&dl, t->perm_preview, w, CP_MUTED, 1, 100000, 1);
|
||
}
|
||
}
|
||
int body_h = h - 1;
|
||
int max_scroll = dl.n > body_h ? dl.n - body_h : 0;
|
||
if (t->perm_scroll > max_scroll)
|
||
t->perm_scroll = max_scroll;
|
||
if (t->perm_scroll < 0)
|
||
t->perm_scroll = 0;
|
||
for (int r = 0; r < body_h && t->perm_scroll + r < dl.n; r++) {
|
||
dline_t *L = &dl.lines[t->perm_scroll + r];
|
||
draw_ansi_line(t->logw, r, w, L->text, L->pair, L->bold, L->dim);
|
||
}
|
||
char keys[400];
|
||
int k = snprintf(keys, sizeof(keys), " [y] allow once [a] always allow %s",
|
||
t->perm_tool);
|
||
if (t->perm_prefix[0])
|
||
k += snprintf(keys + k, sizeof(keys) - (size_t)k,
|
||
" [p] always allow `%.60s …`", t->perm_prefix);
|
||
snprintf(keys + k, sizeof(keys) - (size_t)k, " [n/Esc] deny%s",
|
||
max_scroll ? " ↑↓ scroll" : "");
|
||
modal_footer(t, h, w, keys);
|
||
dlines_free(&dl);
|
||
wrefresh(t->logw);
|
||
}
|
||
|
||
static void draw_ask_modal(tui_t *t) {
|
||
int h, w;
|
||
getmaxyx(t->logw, h, w);
|
||
werase(t->logw);
|
||
if (HAS_COLOR())
|
||
wbkgd(t->logw, COLOR_PAIR(CP_PANEL));
|
||
dlines_t dl = {0};
|
||
dlines_push(&dl, "The agent asks:", CP_PRIMARY, 1, 0);
|
||
push_wrapped(&dl, " ", t->ask_q, w, CP_PANEL, 1, 0, 0);
|
||
dlines_push(&dl, "", 0, 0, 0);
|
||
for (int i = 0; i < t->ask_nopts; i++) {
|
||
char line[320];
|
||
snprintf(line, sizeof(line), "%s %d. %s",
|
||
i == t->ask_sel && !t->ask_buf[0] ? (g_ascii ? ">" : "▶") : " ",
|
||
i + 1, t->ask_opts[i]);
|
||
dlines_push(&dl, line, i == t->ask_sel ? CP_PRIMARY : CP_PANEL,
|
||
i == t->ask_sel, 0);
|
||
}
|
||
if (t->ask_nopts)
|
||
dlines_push(&dl, "", 0, 0, 0);
|
||
char typed[1100];
|
||
snprintf(typed, sizeof(typed), " answer: %s_", t->ask_buf);
|
||
dlines_push(&dl, typed, CP_ASSIST, 0, 0);
|
||
int start = dl.n > h - 1 ? dl.n - (h - 1) : 0;
|
||
for (int r = 0; r < h - 1 && start + r < dl.n; r++) {
|
||
dline_t *L = &dl.lines[start + r];
|
||
draw_ansi_line(t->logw, r, w, L->text, L->pair, L->bold, L->dim);
|
||
}
|
||
modal_footer(t, h, w,
|
||
t->ask_nopts ? " ↑↓/1-9 pick · type your own answer · Enter "
|
||
"send · Esc dismiss"
|
||
: " type your answer · Enter send · Esc dismiss");
|
||
dlines_free(&dl);
|
||
wrefresh(t->logw);
|
||
}
|
||
|
||
/* --- modal picker (↑↓ Enter Esc) ------------------------------------------ */
|
||
|
||
/* items: n rows of labels (each ≤127 chars). Returns index or -1. */
|
||
static int picker_run(tui_t *t, const char *title, char items[][128], int n,
|
||
int cur_idx) {
|
||
if (n <= 0)
|
||
return -1;
|
||
if (cur_idx < 0 || cur_idx >= n)
|
||
cur_idx = 0;
|
||
|
||
int sel = cur_idx;
|
||
int top = 0;
|
||
wtimeout(stdscr, -1);
|
||
keypad(stdscr, TRUE);
|
||
curs_set(0);
|
||
|
||
for (;;) {
|
||
int rows, cols;
|
||
getmaxyx(stdscr, rows, cols);
|
||
int box_h = rows > 4 ? rows - 2 : rows;
|
||
if (box_h > n + 2)
|
||
box_h = n + 2;
|
||
if (box_h < 3)
|
||
box_h = rows > 0 ? rows : 3;
|
||
int visible = box_h - 2;
|
||
if (visible < 1)
|
||
visible = 1;
|
||
if (sel < top)
|
||
top = sel;
|
||
if (sel >= top + visible)
|
||
top = sel - visible + 1;
|
||
|
||
werase(stdscr);
|
||
if (HAS_COLOR())
|
||
wbkgd(stdscr, COLOR_PAIR(CP_PANEL));
|
||
mvprintw(0, 1, "%s (↑↓ Enter · Esc cancel)", title ? title : "Select");
|
||
for (int i = 0; i < visible && top + i < n; i++) {
|
||
int idx = top + i;
|
||
int y = 1 + i;
|
||
if (idx == sel) {
|
||
if (HAS_COLOR())
|
||
attron(COLOR_PAIR(CP_PRIMARY) | A_BOLD);
|
||
else
|
||
attron(A_REVERSE);
|
||
} else if (HAS_COLOR()) {
|
||
attron(COLOR_PAIR(CP_MUTED));
|
||
}
|
||
mvprintw(y, 2, "%s %.*s", idx == cur_idx ? "*" : " ", cols - 6,
|
||
items[idx]);
|
||
if (idx == sel) {
|
||
if (HAS_COLOR())
|
||
attroff(COLOR_PAIR(CP_PRIMARY) | A_BOLD);
|
||
else
|
||
attroff(A_REVERSE);
|
||
} else if (HAS_COLOR()) {
|
||
attroff(COLOR_PAIR(CP_MUTED));
|
||
}
|
||
}
|
||
refresh();
|
||
|
||
wint_t wch = 0;
|
||
int rc = get_wch(&wch);
|
||
if (rc == ERR)
|
||
continue;
|
||
if (rc == KEY_CODE_YES) {
|
||
if (wch == KEY_UP) {
|
||
if (sel > 0)
|
||
sel--;
|
||
continue;
|
||
}
|
||
if (wch == KEY_DOWN) {
|
||
if (sel + 1 < n)
|
||
sel++;
|
||
continue;
|
||
}
|
||
if (wch == KEY_RESIZE)
|
||
continue;
|
||
if (wch == KEY_ENTER)
|
||
break;
|
||
continue;
|
||
}
|
||
if (wch == 10 || wch == 13) /* LF/CR */
|
||
break;
|
||
if (wch == 27) { /* Esc */
|
||
sel = -1;
|
||
break;
|
||
}
|
||
}
|
||
|
||
curs_set(1);
|
||
layout(t);
|
||
wtimeout(t->input, 80);
|
||
keypad(t->input, TRUE);
|
||
redraw_all(t);
|
||
return sel;
|
||
}
|
||
|
||
/* Full-screen hidden input (API keys). Returns 0 with out filled, -1 on
|
||
* Esc / Ctrl+C. */
|
||
static int secret_input_run(tui_t *t, const char *title, const char *hint,
|
||
char *out, size_t n) {
|
||
size_t len = 0;
|
||
out[0] = 0;
|
||
int rc = -1;
|
||
wtimeout(stdscr, -1);
|
||
keypad(stdscr, TRUE);
|
||
curs_set(1);
|
||
for (;;) {
|
||
int rows, cols;
|
||
getmaxyx(stdscr, rows, cols);
|
||
(void)rows;
|
||
werase(stdscr);
|
||
if (HAS_COLOR())
|
||
wbkgd(stdscr, COLOR_PAIR(CP_PANEL));
|
||
if (HAS_COLOR())
|
||
attron(COLOR_PAIR(CP_PRIMARY) | A_BOLD);
|
||
mvprintw(1, 2, "%.*s", cols - 4, title);
|
||
if (HAS_COLOR())
|
||
attroff(COLOR_PAIR(CP_PRIMARY) | A_BOLD);
|
||
if (HAS_COLOR())
|
||
attron(COLOR_PAIR(CP_MUTED));
|
||
mvprintw(2, 2, "%.*s", cols - 4, hint);
|
||
mvprintw(6, 2, "%.*s", cols - 4,
|
||
"Enter save · Esc cancel · Ctrl+U clear · paste works");
|
||
if (HAS_COLOR())
|
||
attroff(COLOR_PAIR(CP_MUTED));
|
||
int room = cols - 12 > 1 ? cols - 12 : 1;
|
||
int shown = (int)len < room ? (int)len : room;
|
||
mvprintw(4, 2, "key: ");
|
||
for (int i = 0; i < shown; i++)
|
||
addch('*');
|
||
if ((int)len > shown)
|
||
addstr("…");
|
||
move(4, 7 + shown);
|
||
refresh();
|
||
|
||
wint_t wch = 0;
|
||
int r = get_wch(&wch);
|
||
if (r == ERR)
|
||
continue;
|
||
if (r == KEY_CODE_YES) {
|
||
if (wch == KEY_BACKSPACE && len > 0)
|
||
out[--len] = 0;
|
||
else if (wch == KEY_ENTER && len > 0) {
|
||
rc = 0;
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 10 || wch == 13) {
|
||
if (len > 0) {
|
||
rc = 0;
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 27 || wch == 3)
|
||
break;
|
||
if (wch == 127 || wch == 8) {
|
||
if (len > 0)
|
||
out[--len] = 0;
|
||
continue;
|
||
}
|
||
if (wch == 21) {
|
||
len = 0;
|
||
out[0] = 0;
|
||
continue;
|
||
}
|
||
if (wch > 32 && wch < 127 && len + 1 < n) {
|
||
out[len++] = (char)wch;
|
||
out[len] = 0;
|
||
}
|
||
}
|
||
if (rc != 0)
|
||
memset(out, 0, n);
|
||
layout(t);
|
||
wtimeout(t->input, 80);
|
||
keypad(t->input, TRUE);
|
||
redraw_all(t);
|
||
return rc;
|
||
}
|
||
|
||
/* Saved / env key for the provider, else ask for one and save it. */
|
||
static int obtain_key(tui_t *t, const char *provider, char *out, size_t n,
|
||
int force_ask) {
|
||
if (!force_ask && cst_creds_resolve(provider, out, n, NULL) == 0)
|
||
return 0;
|
||
const cst_provider_t *p = cst_provider_find(provider);
|
||
char dir[1024] = "the config directory";
|
||
os_config_dir(dir, sizeof(dir));
|
||
char title[128], hint[1300];
|
||
snprintf(title, sizeof(title), "API key for %s", provider);
|
||
snprintf(hint, sizeof(hint), "%s · saved to %s/credentials (only you can "
|
||
"read it)",
|
||
p ? p->api_base : "", dir);
|
||
if (secret_input_run(t, title, hint, out, n) != 0)
|
||
return -1;
|
||
if (cst_creds_save(provider, out) != 0)
|
||
push_system_line(t, "warning: could not save the key; it is used for "
|
||
"this run only");
|
||
else
|
||
push_system_line(t, "(saved the %s API key)", provider);
|
||
return 0;
|
||
}
|
||
|
||
/* --- slash commands ------------------------------------------------------- */
|
||
|
||
static void refresh_title(tui_t *t) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
char sid[128];
|
||
cst_agent_session_id(t->agent, sid, sizeof(sid));
|
||
cst_session_info_t in;
|
||
t->title[0] = 0;
|
||
if (cfg && cst_agent_store_info(cfg->workspace_root, sid, &in) == 0)
|
||
snprintf(t->title, sizeof(t->title), "%s",
|
||
in.title[0] ? in.title : in.first_user);
|
||
if (!t->title[0])
|
||
snprintf(t->title, sizeof(t->title), "%.8s", sid);
|
||
}
|
||
|
||
static void refresh_todo(tui_t *t) {
|
||
const char *td = cst_agent_todo(t->agent);
|
||
tui_lock(t);
|
||
free(t->todo);
|
||
t->todo = td && td[0] ? xstrdup(td) : NULL;
|
||
t->need_layout = 1;
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void push_lines(tui_t *t, const char *prefix, const char *text) {
|
||
for (const char *p = text; p && *p;) {
|
||
const char *nl = strchr(p, '\n');
|
||
int len = nl ? (int)(nl - p) : (int)strlen(p);
|
||
push_system_line(t, "%s%.*s", prefix, len > 900 ? 900 : len, p);
|
||
if (!nl)
|
||
break;
|
||
p = nl + 1;
|
||
}
|
||
}
|
||
|
||
static void after_session_change(tui_t *t, const char *note) {
|
||
reload_hist_from_session(t);
|
||
refresh_title(t);
|
||
refresh_todo(t);
|
||
if (note)
|
||
push_system_line(t, "%s", note);
|
||
status_idle(t);
|
||
}
|
||
|
||
typedef struct slash_cmd slash_cmd_t;
|
||
typedef void (*slash_fn)(tui_t *t, char *arg, int *quit);
|
||
struct slash_cmd {
|
||
const char *name;
|
||
const char *aliases; /* space separated */
|
||
const char *args;
|
||
const char *desc;
|
||
int busy_ok;
|
||
slash_fn fn;
|
||
};
|
||
|
||
static const slash_cmd_t *slash_table(int *n);
|
||
|
||
static void cmd_help(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
int n;
|
||
const slash_cmd_t *c = slash_table(&n);
|
||
push_system_line(t, "");
|
||
push_system_line(t, "Commands:");
|
||
for (int i = 0; i < n; i++) {
|
||
char name[64];
|
||
snprintf(name, sizeof(name), "/%s %s", c[i].name, c[i].args);
|
||
char also[64] = "";
|
||
if (c[i].aliases[0])
|
||
snprintf(also, sizeof(also), " (also: %s)", c[i].aliases);
|
||
push_system_line(t, " %-26s %s%s", name, c[i].desc, also);
|
||
}
|
||
push_system_line(t, "Keys:");
|
||
push_system_line(t, " Enter send · Ctrl+N / Alt+Enter newline · paste keeps "
|
||
"newlines");
|
||
push_system_line(t, " ←→ Home End Ctrl+A/E move · Ctrl+W/U/K delete word/"
|
||
"to start/to end · Delete");
|
||
push_system_line(t, " ↑↓ history (or lines) · Tab complete / toggle thought "
|
||
"· Ctrl+O last tool · Ctrl+T tasks");
|
||
push_system_line(t, " PgUp/PgDn wheel scroll · Esc cancel turn · Ctrl+C "
|
||
"twice or Ctrl+D quit");
|
||
push_system_line(t, "");
|
||
}
|
||
|
||
static void cmd_new(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
char id[128] = {0};
|
||
if (cst_agent_new_session(t->agent, id, sizeof(id)) != 0) {
|
||
push_system_line(t, "Error: failed to create session");
|
||
return;
|
||
}
|
||
char note[192];
|
||
snprintf(note, sizeof(note), "(new session %s)", id);
|
||
after_session_change(t, note);
|
||
}
|
||
|
||
static int session_labels(tui_t *t, char ids[][128], char labels[][128],
|
||
int max) {
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
int n = cst_agent_store_list(cfg->workspace_root, ids, max);
|
||
for (int i = 0; i < n; i++) {
|
||
cst_session_info_t in;
|
||
char when[24] = "?";
|
||
if (cst_agent_store_info(cfg->workspace_root, ids[i], &in) == 0) {
|
||
time_t tt = (time_t)in.mtime;
|
||
struct tm tm;
|
||
os_localtime(&tt, &tm);
|
||
strftime(when, sizeof(when), "%m-%d %H:%M", &tm);
|
||
snprintf(labels[i], 128, "%s %3d msgs %.60s", when, in.messages,
|
||
in.title[0] ? in.title : in.first_user);
|
||
} else {
|
||
snprintf(labels[i], 128, "%s", ids[i]);
|
||
}
|
||
}
|
||
return n;
|
||
}
|
||
|
||
static int picker_run(tui_t *t, const char *title, char items[][128], int n,
|
||
int cur_idx);
|
||
|
||
static void cmd_session(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
static char ids[64][128], labels[64][128];
|
||
char chosen[128];
|
||
if (arg && arg[0]) {
|
||
snprintf(chosen, sizeof(chosen), "%s", arg);
|
||
} else {
|
||
int n = session_labels(t, ids, labels, 64);
|
||
if (n <= 0) {
|
||
push_system_line(t, "Sessions: (none)");
|
||
return;
|
||
}
|
||
char cur[128];
|
||
cst_agent_session_id(t->agent, cur, sizeof(cur));
|
||
int cur_idx = 0;
|
||
for (int i = 0; i < n; i++)
|
||
if (strcmp(ids[i], cur) == 0)
|
||
cur_idx = i;
|
||
int pick = picker_run(t, "Session", labels, n, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
snprintf(chosen, sizeof(chosen), "%s", ids[pick]);
|
||
}
|
||
if (cst_agent_switch_session(t->agent, chosen) != 0) {
|
||
push_system_line(t, "Error: cannot switch to session '%s'", chosen);
|
||
return;
|
||
}
|
||
char note[192];
|
||
snprintf(note, sizeof(note), "(switched to %s)", chosen);
|
||
after_session_change(t, note);
|
||
}
|
||
|
||
static void cmd_rename(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
char sid[128];
|
||
cst_agent_session_id(t->agent, sid, sizeof(sid));
|
||
if (cst_agent_store_title_set(cfg->workspace_root, sid, arg) != 0) {
|
||
push_system_line(t, "Error: cannot set the title");
|
||
return;
|
||
}
|
||
refresh_title(t);
|
||
push_system_line(t, arg && arg[0] ? "(session renamed to \"%s\")"
|
||
: "(session title cleared%s)",
|
||
arg ? arg : "");
|
||
}
|
||
|
||
static void cmd_delete(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
static char ids[64][128], labels[64][128];
|
||
char victim[128];
|
||
if (arg && arg[0]) {
|
||
snprintf(victim, sizeof(victim), "%s", arg);
|
||
} else {
|
||
int n = session_labels(t, ids, labels, 64);
|
||
if (n <= 0)
|
||
return;
|
||
int pick = picker_run(t, "Delete which session?", labels, n, 0);
|
||
if (pick < 0)
|
||
return;
|
||
snprintf(victim, sizeof(victim), "%s", ids[pick]);
|
||
}
|
||
char confirm[2][128];
|
||
snprintf(confirm[0], 128, "No, keep it");
|
||
snprintf(confirm[1], 128, "Yes, delete %.100s", victim);
|
||
if (picker_run(t, "Delete session permanently?", confirm, 2, 0) != 1)
|
||
return;
|
||
char cur[128];
|
||
cst_agent_session_id(t->agent, cur, sizeof(cur));
|
||
int was_current = strcmp(cur, victim) == 0;
|
||
if (was_current) {
|
||
char id[128];
|
||
if (cst_agent_new_session(t->agent, id, sizeof(id)) != 0) {
|
||
push_system_line(t, "Error: cannot open a new session first");
|
||
return;
|
||
}
|
||
}
|
||
if (cst_agent_store_delete(cfg->workspace_root, victim) != 0)
|
||
push_system_line(t, "Error: cannot delete %s", victim);
|
||
if (was_current)
|
||
after_session_change(t, NULL);
|
||
push_system_line(t, "(deleted session %s)", victim);
|
||
}
|
||
|
||
static void cmd_model(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
if (!arg || !arg[0]) {
|
||
char models[32][128];
|
||
int n = cst_models_list(cfg->model, models, 32);
|
||
int cur_idx = 0;
|
||
for (int i = 0; i < n; i++)
|
||
if (strcmp(models[i], cfg->model) == 0)
|
||
cur_idx = i;
|
||
int pick = picker_run(t, "Model", models, n, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
arg = models[pick];
|
||
snprintf(cfg->model, sizeof(cfg->model), "%s", arg);
|
||
} else {
|
||
snprintf(cfg->model, sizeof(cfg->model), "%.127s", arg);
|
||
}
|
||
cst_settings_remember(cfg);
|
||
push_system_line(t, "(model → %s, context %ld tokens)", cfg->model,
|
||
cst_model_context_tokens(cfg->model));
|
||
}
|
||
|
||
static void cmd_key(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
char key[512];
|
||
if (obtain_key(t, cfg->provider, key, sizeof(key), 1) != 0) {
|
||
push_system_line(t, "(API key unchanged)");
|
||
return;
|
||
}
|
||
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
|
||
memset(key, 0, sizeof(key));
|
||
}
|
||
|
||
static void cmd_effort(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
static const char *const names[] = {"low", "medium", "high", "max"};
|
||
static const char *const desc[] = {"quick answers, little thinking",
|
||
"balanced (default)",
|
||
"thinks longer on hard problems",
|
||
"maximum thinking, slowest"};
|
||
const char *want = NULL;
|
||
if (arg && arg[0]) {
|
||
if (!cst_effort_valid(arg)) {
|
||
push_system_line(t, "Unknown effort '%s' (low medium high max)", arg);
|
||
return;
|
||
}
|
||
want = arg;
|
||
} else {
|
||
char items[4][128];
|
||
int cur_idx = 1;
|
||
for (int i = 0; i < 4; i++) {
|
||
snprintf(items[i], 128, "%-7s %s", names[i], desc[i]);
|
||
if (strcmp(names[i], cfg->effort) == 0)
|
||
cur_idx = i;
|
||
}
|
||
int pick = picker_run(t, "Reasoning effort", items, 4, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
want = names[pick];
|
||
}
|
||
snprintf(cfg->effort, sizeof(cfg->effort), "%s", want);
|
||
cst_settings_remember(cfg);
|
||
if (cst_effort_wire(cfg->provider, cfg->model, cfg->effort))
|
||
push_system_line(t, "(effort → %s)", cfg->effort);
|
||
else
|
||
push_system_line(t, "(effort → %s; %s/%s does not take an effort "
|
||
"setting, it applies to DeepSeek and OpenAI "
|
||
"reasoning models)",
|
||
cfg->effort, cfg->provider, cfg->model);
|
||
}
|
||
|
||
static void cmd_provider(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
const cst_provider_t *p = NULL;
|
||
if (arg && arg[0]) {
|
||
p = cst_provider_find(arg);
|
||
if (!p) {
|
||
push_system_line(t, "Unknown provider '%s'", arg);
|
||
return;
|
||
}
|
||
} else {
|
||
int n = cst_provider_count();
|
||
char items[16][128];
|
||
int cur_idx = 0;
|
||
for (int i = 0; i < n && i < 16; i++) {
|
||
const cst_provider_t *q = cst_provider_at(i);
|
||
snprintf(items[i], 128, "%-9s %s (%s)", q->name, q->api_base, q->model);
|
||
if (strcmp(q->name, cfg->provider) == 0)
|
||
cur_idx = i;
|
||
}
|
||
int pick = picker_run(t, "Provider", items, n < 16 ? n : 16, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
p = cst_provider_at(pick);
|
||
}
|
||
char key[512];
|
||
if (obtain_key(t, p->name, key, sizeof(key), 0) != 0) {
|
||
push_system_line(t, "(provider unchanged: %s needs an API key)", p->name);
|
||
return;
|
||
}
|
||
cst_settings_t s;
|
||
cst_settings_load(&s);
|
||
const char *model = cst_settings_model(&s, p->name);
|
||
snprintf(cfg->provider, sizeof(cfg->provider), "%s", p->name);
|
||
snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", p->api_base);
|
||
snprintf(cfg->model, sizeof(cfg->model), "%s", model ? model : p->model);
|
||
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
|
||
memset(key, 0, sizeof(key));
|
||
cst_settings_remember(cfg);
|
||
push_system_line(t, "(provider → %s · %s · model %s)", p->name, p->api_base,
|
||
cfg->model);
|
||
}
|
||
|
||
static void cmd_protocol(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
cst_protocol_t p = cfg->protocol;
|
||
if (arg && arg[0]) {
|
||
if (cst_protocol_parse(arg, &p) != 0) {
|
||
push_system_line(t, "Unknown protocol '%s' (native-eos native-inc ctl "
|
||
"ctl-heredoc xml)", arg);
|
||
return;
|
||
}
|
||
} else {
|
||
static const cst_protocol_t all[] = {CST_PROTO_NATIVE_EOS,
|
||
CST_PROTO_NATIVE_INC, CST_PROTO_CTL,
|
||
CST_PROTO_CTL_HEREDOC, CST_PROTO_XML};
|
||
char names[5][128];
|
||
int cur_idx = 0;
|
||
for (int i = 0; i < 5; i++) {
|
||
snprintf(names[i], sizeof(names[i]), "%s", cst_protocol_name(all[i]));
|
||
if (all[i] == cfg->protocol)
|
||
cur_idx = i;
|
||
}
|
||
int pick = picker_run(t, "Protocol", names, 5, cur_idx);
|
||
if (pick < 0)
|
||
return;
|
||
p = all[pick];
|
||
}
|
||
cfg->protocol = p;
|
||
push_system_line(t, "(protocol → %s)", cst_protocol_name(p));
|
||
}
|
||
|
||
static void cmd_tools(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
if (arg && arg[0]) {
|
||
char verb[16] = "", name[64] = "";
|
||
sscanf(arg, "%15s %63s", verb, name);
|
||
int prof;
|
||
if (cst_tool_profile_parse(verb, &prof) == 0) {
|
||
cfg->tool_profile = prof;
|
||
push_system_line(t, "(tool profile → %s)", cst_tool_profile_name(prof));
|
||
} else if ((strcmp(verb, "on") == 0 || strcmp(verb, "off") == 0) &&
|
||
name[0]) {
|
||
int idx = cst_tool_index(name);
|
||
if (idx < 0) {
|
||
push_system_line(t, "Unknown tool '%s'", name);
|
||
return;
|
||
}
|
||
if (verb[1] == 'n')
|
||
cfg->tools_off &= ~(1ULL << idx);
|
||
else
|
||
cfg->tools_off |= 1ULL << idx;
|
||
push_system_line(t, "(%s %s)", name, verb);
|
||
} else {
|
||
push_system_line(t, "Usage: /tools [on NAME | off NAME | default | "
|
||
"minimal]");
|
||
return;
|
||
}
|
||
}
|
||
static const char *const perm_names[] = {"read", "write", "shell",
|
||
"net", "ask", "meta"};
|
||
push_system_line(t, "Tools (profile %s):",
|
||
cst_tool_profile_name(cfg->tool_profile));
|
||
for (int i = 0; i < cst_tool_count(); i++) {
|
||
const cst_tool_def_t *d = cst_tool_at(i);
|
||
int on = cst_tool_active(cfg, d);
|
||
const char *desc = d->desc ? d->desc : "";
|
||
int dl = (int)strcspn(desc, ".\n");
|
||
push_system_line(t, " %s %-12s %-5s %.*s", on ? "[x]" : "[ ]", d->name,
|
||
(int)d->perm < 6 ? perm_names[d->perm] : "?",
|
||
dl > 70 ? 70 : dl, desc);
|
||
}
|
||
}
|
||
|
||
static void cmd_permissions(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_perms_t *p = cst_agent_perms(t->agent);
|
||
if (arg && strcmp(arg, "reset") == 0) {
|
||
cst_perms_reset(p);
|
||
push_system_line(t, "(saved permission rules cleared)");
|
||
return;
|
||
}
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
push_system_line(t, "Confirmation: shell %s · write %s · net %s%s",
|
||
cfg->confirm_shell ? "ask" : "auto",
|
||
cfg->confirm_write ? "ask" : "auto",
|
||
cfg->confirm_net ? "ask" : "auto",
|
||
cfg->auto_approve ? " · YOLO (everything auto)" : "");
|
||
char *d = cst_perms_describe(p);
|
||
push_lines(t, " ", d ? d : "(no saved rules)");
|
||
free(d);
|
||
push_system_line(t, " saved in .agent/permissions.json · /permissions reset");
|
||
}
|
||
|
||
static void cmd_yolo(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
cfg->auto_approve = !cfg->auto_approve;
|
||
push_system_line(t, cfg->auto_approve
|
||
? "(YOLO on: shell, writes and web run without asking)"
|
||
: "(YOLO off: risky tools ask again)");
|
||
}
|
||
|
||
static void cmd_max_steps(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
if (arg && atoi(arg) > 0)
|
||
cfg->max_steps = atoi(arg);
|
||
push_system_line(t, "(max steps per turn: %d)", cfg->max_steps);
|
||
}
|
||
|
||
static void cmd_status(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
char sid[128];
|
||
cst_agent_session_id(t->agent, sid, sizeof(sid));
|
||
long win = cst_model_context_tokens(cfg->model);
|
||
long used = cst_agent_last_prompt_tokens(t->agent);
|
||
cst_session_t *s = cst_agent_session(t->agent);
|
||
int nmsg = 0;
|
||
if (s) {
|
||
cst_session_lock(s);
|
||
nmsg = cst_session_count(s);
|
||
cst_session_unlock(s);
|
||
}
|
||
push_system_line(t, "Status:");
|
||
push_system_line(t, " workspace %s", cfg->workspace_root);
|
||
push_system_line(t, " session %s (%d messages) %s", sid, nmsg, t->title);
|
||
push_system_line(t, " provider %s %s", cfg->provider, cfg->api_base);
|
||
push_system_line(t, " model %s context %ld/%ld tokens (%ld%%)",
|
||
cfg->model, used, win, win ? used * 100 / win : 0);
|
||
push_system_line(t, " effort %s%s", cfg->effort,
|
||
cst_effort_wire(cfg->provider, cfg->model, cfg->effort)
|
||
? ""
|
||
: " (not used by this model)");
|
||
char key[512];
|
||
const char *from = NULL;
|
||
int have = cst_creds_resolve(cfg->provider, key, sizeof(key), &from) == 0;
|
||
int same = have && strcmp(key, cfg->api_key) == 0;
|
||
memset(key, 0, sizeof(key));
|
||
push_system_line(t, " api key %s",
|
||
!cfg->api_key[0] ? "missing — /key to enter one"
|
||
: !same ? "this run only (--api-key / CST_API_KEY)"
|
||
: strcmp(from, "saved") == 0 ? "saved (/key to replace)"
|
||
: from);
|
||
push_system_line(t, " protocol %s · tools %s · max steps %d",
|
||
cst_protocol_name(cfg->protocol),
|
||
cst_tool_profile_name(cfg->tool_profile), cfg->max_steps);
|
||
push_system_line(t, " approval %s",
|
||
cfg->auto_approve ? "YOLO (no confirmation)"
|
||
: "asks for shell / writes / web");
|
||
const char *notes = cst_agent_notes(t->agent);
|
||
push_system_line(t, " notes %s",
|
||
notes && notes[0] ? "AGENTS.md loaded" : "(no AGENTS.md)");
|
||
push_system_line(t, " undo %d turn(s) can be undone",
|
||
cst_agent_undo_available(t->agent));
|
||
}
|
||
|
||
static void cmd_todo(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
tui_lock(t);
|
||
char *td = t->todo ? xstrdup(t->todo) : NULL;
|
||
tui_unlock(t);
|
||
push_system_line(t, "Tasks:");
|
||
push_lines(t, " ", td && td[0] ? td : "(no tasks yet)");
|
||
free(td);
|
||
}
|
||
|
||
static void cmd_undo(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
char rep[4096];
|
||
int rc = cst_agent_undo(t->agent, rep, sizeof(rep));
|
||
push_system_line(t, rc == 0 ? "Undo:" : "Undo failed:");
|
||
push_lines(t, " ", rep);
|
||
}
|
||
|
||
static void cmd_retry(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
char *p = cst_agent_rewind_last_turn(t->agent);
|
||
if (!p) {
|
||
push_system_line(t, "Nothing to retry");
|
||
return;
|
||
}
|
||
reload_hist_from_session(t);
|
||
push_system_line(t, "(retrying the last prompt; file changes were kept — "
|
||
"/undo first to revert them)");
|
||
free(t->pending_prompt);
|
||
t->pending_prompt = p;
|
||
}
|
||
|
||
static void cmd_diff(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
os_run_result_t st = {0}, df = {0};
|
||
if (os_run_shell_c(cfg->workspace_root, "git status --short", 64 * 1024,
|
||
&st) != 0 ||
|
||
st.exit_code != 0) {
|
||
push_system_line(t, "/diff needs a git repository");
|
||
free(st.out);
|
||
return;
|
||
}
|
||
char cmd[512];
|
||
snprintf(cmd, sizeof(cmd), "git --no-pager diff --no-color %s%s",
|
||
arg && arg[0] ? "-- " : "", arg ? arg : "");
|
||
os_run_shell_c(cfg->workspace_root, cmd, 512 * 1024, &df);
|
||
push_system_line(t, "git status:");
|
||
push_lines(t, " ", st.out && st.out[0] ? st.out : "(clean)");
|
||
if (df.out && df.out[0]) {
|
||
block_t *b = blocks_push(&t->hist);
|
||
if (b) {
|
||
b->kind = BLK_TOOL;
|
||
snprintf(b->tool_name, sizeof(b->tool_name), "diff");
|
||
block_set_title(b, "git diff");
|
||
block_set_body(b, df.out, df.out_len);
|
||
snprintf(b->meta, sizeof(b->meta), "done");
|
||
b->expanded = 1;
|
||
}
|
||
}
|
||
free(st.out);
|
||
free(df.out);
|
||
}
|
||
|
||
static void md_fence(FILE *fp, const char *s, size_t max) {
|
||
size_t n = strlen(s);
|
||
fprintf(fp, "````\n%.*s%s\n````\n\n", (int)(n > max ? max : n), s,
|
||
n > max ? "\n…(truncated)" : "");
|
||
}
|
||
|
||
static void cmd_export(tui_t *t, char *arg, int *quit) {
|
||
(void)quit;
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
char sid[128], path[OS_PATH_MAX + 256];
|
||
cst_agent_session_id(t->agent, sid, sizeof(sid));
|
||
if (arg && arg[0] == '/')
|
||
snprintf(path, sizeof(path), "%s", arg);
|
||
else if (arg && arg[0])
|
||
snprintf(path, sizeof(path), "%s/%s", cfg->workspace_root, arg);
|
||
else
|
||
snprintf(path, sizeof(path), "%s/.agent/exports/%s.md", cfg->workspace_root,
|
||
sid);
|
||
char dir[sizeof(path)];
|
||
snprintf(dir, sizeof(dir), "%s", path);
|
||
char *sl = strrchr(dir, '/');
|
||
if (sl) {
|
||
*sl = 0;
|
||
os_mkdir_p(dir, 0755);
|
||
}
|
||
FILE *fp = fopen(path, "w");
|
||
if (!fp) {
|
||
push_system_line(t, "Error: cannot write %s", path);
|
||
return;
|
||
}
|
||
fprintf(fp, "# %s\n\nsession `%s` · model `%s`\n\n", t->title, sid,
|
||
cfg->model);
|
||
cst_session_t *s = cst_agent_session(t->agent);
|
||
cst_session_lock(s);
|
||
for (int i = 0; i < cst_session_count(s); i++) {
|
||
const cst_message_t *m = cst_session_get(s, i);
|
||
const char *c = m->content ? m->content : "";
|
||
if (m->role == CST_ROLE_SYSTEM)
|
||
continue;
|
||
if (msg_is_tool_result(m)) {
|
||
fprintf(fp, "**Tool result** `%s`\n\n", m->tool_name);
|
||
md_fence(fp, c, 4000);
|
||
} else if (m->role == CST_ROLE_USER) {
|
||
fprintf(fp, "## User\n\n%s\n\n", c);
|
||
} else {
|
||
fprintf(fp, "## Assistant\n\n%s\n\n", c);
|
||
if (m->tool_calls_json && m->tool_calls_json[0]) {
|
||
fprintf(fp, "**Tool calls**\n\n");
|
||
md_fence(fp, m->tool_calls_json, 4000);
|
||
}
|
||
}
|
||
}
|
||
cst_session_unlock(s);
|
||
fclose(fp);
|
||
push_system_line(t, "(exported to %s)", path);
|
||
}
|
||
|
||
static void cmd_copy(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
const char *text = NULL;
|
||
for (int i = t->hist.n - 1; i >= 0 && !text; i--)
|
||
if (t->hist.items[i].kind == BLK_ASSISTANT && t->hist.items[i].body)
|
||
text = t->hist.items[i].body;
|
||
if (!text || !text[0]) {
|
||
push_system_line(t, "Nothing to copy yet");
|
||
return;
|
||
}
|
||
static const char b64[] =
|
||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||
size_t n = strlen(text);
|
||
if (n > 75000)
|
||
n = 75000; /* many terminals cap OSC 52 near 100 KB */
|
||
char *enc = malloc((n + 2) / 3 * 4 + 1);
|
||
if (!enc)
|
||
return;
|
||
size_t o = 0;
|
||
for (size_t i = 0; i < n; i += 3) {
|
||
unsigned v = (unsigned char)text[i] << 16;
|
||
if (i + 1 < n)
|
||
v |= (unsigned char)text[i + 1] << 8;
|
||
if (i + 2 < n)
|
||
v |= (unsigned char)text[i + 2];
|
||
enc[o++] = b64[(v >> 18) & 63];
|
||
enc[o++] = b64[(v >> 12) & 63];
|
||
enc[o++] = i + 1 < n ? b64[(v >> 6) & 63] : '=';
|
||
enc[o++] = i + 2 < n ? b64[v & 63] : '=';
|
||
}
|
||
enc[o] = 0;
|
||
if (getenv("TMUX"))
|
||
printf("\033Ptmux;\033\033]52;c;%s\a\033\\", enc);
|
||
else
|
||
printf("\033]52;c;%s\a", enc);
|
||
fflush(stdout);
|
||
free(enc);
|
||
push_system_line(t, "(copied %zu bytes of the last reply via OSC 52 — the "
|
||
"terminal must allow clipboard access)", n);
|
||
}
|
||
|
||
static void cmd_init(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
free(t->pending_prompt);
|
||
t->pending_prompt = xstrdup(
|
||
"Analyze this repository and write an AGENTS.md file at the workspace "
|
||
"root for future coding agents. Cover: what the project is, how to "
|
||
"build, test and run it (exact commands), the directory layout, coding "
|
||
"conventions you can infer from the code, and anything surprising. "
|
||
"Explore with list_dir/glob/grep/read_file before writing. Keep it "
|
||
"under 150 lines. If AGENTS.md already exists, improve it rather than "
|
||
"starting over.");
|
||
}
|
||
|
||
static void cmd_compact(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
char err[512] = {0};
|
||
set_status(t, "compacting…");
|
||
redraw_all(t);
|
||
if (cst_agent_compact(t->agent, err, sizeof(err)) != 0) {
|
||
push_system_line(t, "Error: compact failed: %s", err[0] ? err : "?");
|
||
return;
|
||
}
|
||
after_session_change(t, "(session compacted)");
|
||
}
|
||
|
||
static void cmd_details(tui_t *t, char *arg, int *quit) {
|
||
(void)arg;
|
||
(void)quit;
|
||
t->details_all = !t->details_all;
|
||
push_system_line(t, "tool details: %s",
|
||
t->details_all ? "expanded" : "collapsed");
|
||
}
|
||
|
||
static void cmd_quit(tui_t *t, char *arg, int *quit) {
|
||
(void)t;
|
||
(void)arg;
|
||
*quit = 1;
|
||
}
|
||
|
||
static const slash_cmd_t SLASH[] = {
|
||
{"help", "?", "", "list commands and keys", 1, cmd_help},
|
||
{"new", "clear", "", "start a new session", 0, cmd_new},
|
||
{"session", "sessions resume", "[id]", "pick or switch session", 0,
|
||
cmd_session},
|
||
{"rename", "", "<title>", "name the current session", 1, cmd_rename},
|
||
{"delete", "", "[id]", "delete a session (asks first)", 0, cmd_delete},
|
||
{"model", "", "[name]", "pick or set the model", 0, cmd_model},
|
||
{"provider", "", "[name]", "switch provider (asks for its key)", 0,
|
||
cmd_provider},
|
||
{"key", "apikey", "", "enter or replace the provider's API key", 0,
|
||
cmd_key},
|
||
{"effort", "think", "[low|medium|high|max]", "reasoning effort", 0,
|
||
cmd_effort},
|
||
{"protocol", "", "[name]", "pick or set the tool-call protocol", 0,
|
||
cmd_protocol},
|
||
{"tools", "", "[on|off NAME|minimal]", "list or toggle tools", 0,
|
||
cmd_tools},
|
||
{"permissions", "perms", "[reset]", "show saved always-allow rules", 1,
|
||
cmd_permissions},
|
||
{"yolo", "", "", "toggle auto-approve for this run", 1, cmd_yolo},
|
||
{"max-steps", "", "[N]", "tool steps allowed per turn", 1, cmd_max_steps},
|
||
{"status", "", "", "session, model, context and settings", 1, cmd_status},
|
||
{"todo", "tasks", "", "show the agent's task list", 1, cmd_todo},
|
||
{"undo", "", "", "revert file changes of the last turn", 0, cmd_undo},
|
||
{"retry", "", "", "drop the last reply and ask again", 0, cmd_retry},
|
||
{"diff", "", "[path]", "git status + diff of the workspace", 1, cmd_diff},
|
||
{"compact", "summarize", "", "summarize the context to save tokens", 0,
|
||
cmd_compact},
|
||
{"export", "", "[file]", "write the transcript as Markdown", 1,
|
||
cmd_export},
|
||
{"copy", "", "", "copy the last reply (OSC 52)", 1, cmd_copy},
|
||
{"init", "", "", "have the agent write AGENTS.md", 0, cmd_init},
|
||
{"details", "", "", "expand/collapse all tool output", 1, cmd_details},
|
||
{"quit", "q exit", "", "quit", 1, cmd_quit},
|
||
};
|
||
|
||
static const slash_cmd_t *slash_table(int *n) {
|
||
*n = (int)(sizeof(SLASH) / sizeof(SLASH[0]));
|
||
return SLASH;
|
||
}
|
||
|
||
static int has_alias(const char *list, const char *name) {
|
||
size_t n = strlen(name);
|
||
for (const char *p = list; *p;) {
|
||
size_t w = strcspn(p, " ");
|
||
if (w == n && strncmp(p, name, n) == 0)
|
||
return 1;
|
||
p += w;
|
||
while (*p == ' ')
|
||
p++;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* Exact name, alias, or unique prefix. */
|
||
static const slash_cmd_t *slash_find(const char *name, int *ambiguous) {
|
||
int n;
|
||
const slash_cmd_t *tab = slash_table(&n), *hit = NULL;
|
||
*ambiguous = 0;
|
||
for (int i = 0; i < n; i++)
|
||
if (strcmp(tab[i].name, name) == 0 || has_alias(tab[i].aliases, name))
|
||
return &tab[i];
|
||
size_t len = strlen(name);
|
||
for (int i = 0; len && i < n; i++) {
|
||
if (strncmp(tab[i].name, name, len) == 0) {
|
||
if (hit)
|
||
*ambiguous = 1;
|
||
hit = &tab[i];
|
||
}
|
||
}
|
||
return *ambiguous ? NULL : hit;
|
||
}
|
||
|
||
static int handle_slash(tui_t *t, const char *line, int *should_quit) {
|
||
*should_quit = 0;
|
||
if (!line || line[0] != '/')
|
||
return 0;
|
||
char buf[INPUT_CAP];
|
||
snprintf(buf, sizeof(buf), "%s", line + 1);
|
||
char *cmd = buf;
|
||
while (*cmd == ' ')
|
||
cmd++;
|
||
char *arg = cmd + strcspn(cmd, " \n");
|
||
if (*arg) {
|
||
*arg++ = 0;
|
||
while (*arg == ' ')
|
||
arg++;
|
||
}
|
||
size_t al = strlen(arg);
|
||
while (al && (arg[al - 1] == ' ' || arg[al - 1] == '\n'))
|
||
arg[--al] = 0;
|
||
int amb = 0;
|
||
const slash_cmd_t *c = slash_find(cmd, &amb);
|
||
if (!c) {
|
||
push_system_line(t, amb ? "Ambiguous command /%s (try /help)"
|
||
: "Unknown command /%s (try /help)",
|
||
cmd);
|
||
return 1;
|
||
}
|
||
if (t->busy && !c->busy_ok) {
|
||
push_system_line(t, "/%s is not available while the agent is working "
|
||
"(Esc cancels the turn)",
|
||
c->name);
|
||
return 1;
|
||
}
|
||
c->fn(t, arg, should_quit);
|
||
status_idle(t);
|
||
return 1;
|
||
}
|
||
|
||
/* --- completion ----------------------------------------------------------- */
|
||
|
||
static void load_files(tui_t *t) {
|
||
if (t->files && since_ms(&t->files_at) < 10000)
|
||
return;
|
||
for (int i = 0; i < t->nfiles; i++)
|
||
free(t->files[i]);
|
||
free(t->files);
|
||
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
|
||
t->nfiles = cst_workspace_files(cfg->workspace_root, &t->files, 20000);
|
||
clock_gettime(CLOCK_MONOTONIC, &t->files_at);
|
||
}
|
||
|
||
static void update_completion(tui_t *t) {
|
||
t->comp_n = 0;
|
||
if (t->comp_off || t->in_paste)
|
||
return;
|
||
if (t->inbuf[0] == '/' && memchr(t->inbuf, ' ', t->cur) == NULL &&
|
||
memchr(t->inbuf, '\n', t->in_len) == NULL) {
|
||
int n;
|
||
const slash_cmd_t *tab = slash_table(&n);
|
||
size_t len = t->cur - 1;
|
||
for (int i = 0; i < n && t->comp_n < COMP_MAX; i++) {
|
||
if (strncmp(tab[i].name, t->inbuf + 1, len) != 0)
|
||
continue;
|
||
comp_item_t *c = &t->comp[t->comp_n++];
|
||
snprintf(c->text, sizeof(c->text), "/%s %s", tab[i].name, tab[i].args);
|
||
snprintf(c->desc, sizeof(c->desc), "%s", tab[i].desc);
|
||
}
|
||
t->comp_kind = 1;
|
||
t->comp_start = 0;
|
||
} else {
|
||
size_t st = t->cur;
|
||
while (st > 0 && t->inbuf[st - 1] != ' ' && t->inbuf[st - 1] != '\n')
|
||
st--;
|
||
if (st >= t->in_len || t->inbuf[st] != '@')
|
||
return;
|
||
char frag[256];
|
||
snprintf(frag, sizeof(frag), "%.*s", (int)(t->cur - st - 1),
|
||
t->inbuf + st + 1);
|
||
load_files(t);
|
||
/* basename prefix matches first, then any substring */
|
||
for (int pass = 0; pass < 2 && t->comp_n < COMP_MAX; pass++) {
|
||
for (int i = 0; i < t->nfiles && t->comp_n < COMP_MAX; i++) {
|
||
const char *f = t->files[i];
|
||
const char *base = strrchr(f, '/');
|
||
base = base ? base + 1 : f;
|
||
int hit = pass == 0 ? strncasecmp(base, frag, strlen(frag)) == 0
|
||
: (strncasecmp(base, frag, strlen(frag)) != 0 &&
|
||
strcasestr(f, frag) != NULL);
|
||
if (!hit)
|
||
continue;
|
||
comp_item_t *c = &t->comp[t->comp_n++];
|
||
snprintf(c->text, sizeof(c->text), "@%s", f);
|
||
c->desc[0] = 0;
|
||
}
|
||
}
|
||
t->comp_kind = 2;
|
||
t->comp_start = st;
|
||
}
|
||
if (t->comp_sel >= t->comp_n)
|
||
t->comp_sel = t->comp_n ? t->comp_n - 1 : 0;
|
||
}
|
||
|
||
/* Returns the chosen slash command when the popup was a slash list. */
|
||
static const slash_cmd_t *accept_completion(tui_t *t) {
|
||
if (t->comp_n <= 0)
|
||
return NULL;
|
||
const comp_item_t *c = &t->comp[t->comp_sel];
|
||
const slash_cmd_t *cmd = NULL;
|
||
char repl[300];
|
||
if (t->comp_kind == 1) {
|
||
char name[64];
|
||
snprintf(name, sizeof(name), "%.*s", (int)strcspn(c->text + 1, " "),
|
||
c->text + 1);
|
||
int amb;
|
||
cmd = slash_find(name, &amb);
|
||
snprintf(repl, sizeof(repl), "/%s ", name);
|
||
} else {
|
||
snprintf(repl, sizeof(repl), "%s ", c->text);
|
||
}
|
||
size_t from = t->comp_start, to = t->cur;
|
||
input_erase(t, from, to);
|
||
t->cur = from;
|
||
input_insert(t, repl, strlen(repl));
|
||
t->comp_n = 0;
|
||
t->comp_off = 1;
|
||
return cmd;
|
||
}
|
||
|
||
static void toggle_think(tui_t *t) {
|
||
int idx = t->focus_think_idx;
|
||
if (idx < 0) {
|
||
for (int i = t->hist.n - 1; i >= 0; i--) {
|
||
if (t->hist.items[i].kind == BLK_THINK) {
|
||
idx = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (idx < 0 || idx >= t->hist.n)
|
||
return;
|
||
block_t *b = &t->hist.items[idx];
|
||
if (b->kind != BLK_THINK)
|
||
return;
|
||
b->expanded = !b->expanded;
|
||
/* Keep timed title (Thought · Ns) if already set. */
|
||
if (b->expanded && (!b->title || !b->title[0]))
|
||
block_set_title(b, "Thought");
|
||
t->focus_think_idx = idx;
|
||
draw_log(t);
|
||
}
|
||
|
||
static void toggle_last_tool(tui_t *t) {
|
||
int idx = t->last_tool_idx;
|
||
if (idx < 0 || idx >= t->hist.n) {
|
||
for (int i = t->hist.n - 1; i >= 0; i--) {
|
||
if (t->hist.items[i].kind == BLK_TOOL) {
|
||
idx = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (idx < 0 || idx >= t->hist.n)
|
||
return;
|
||
block_t *b = &t->hist.items[idx];
|
||
if (b->kind != BLK_TOOL)
|
||
return;
|
||
b->expanded = !b->expanded;
|
||
t->last_tool_idx = idx;
|
||
draw_log(t);
|
||
}
|
||
|
||
/* --- turn worker ---------------------------------------------------------- */
|
||
|
||
typedef struct {
|
||
tui_t *t;
|
||
char *prompt;
|
||
} turn_arg_t;
|
||
|
||
static void *turn_thread(void *arg) {
|
||
turn_arg_t *a = arg;
|
||
tui_t *t = a->t;
|
||
cst_agent_hooks_t hooks = {0};
|
||
hooks.on_status = on_status;
|
||
hooks.on_assistant_delta = on_assistant_delta;
|
||
hooks.on_think_delta = on_think_delta;
|
||
hooks.on_tool_event = on_tool_event;
|
||
hooks.on_tool_result = on_tool_result;
|
||
hooks.on_permission = on_permission;
|
||
hooks.on_ask_user = on_ask_user;
|
||
hooks.on_todo = on_todo;
|
||
hooks.userdata = t;
|
||
|
||
char err[512];
|
||
err[0] = 0;
|
||
int rc = cst_agent_run_turn_ex(t->agent, a->prompt, &hooks, err, sizeof(err));
|
||
|
||
tui_lock(t);
|
||
t->turn_rc = rc;
|
||
snprintf(t->turn_err, sizeof(t->turn_err), "%s", err);
|
||
collapse_open_think(t);
|
||
if (t->as_live)
|
||
live_seal(t);
|
||
for (int i = 0; i < t->hist.n; i++) {
|
||
if (t->hist.items[i].kind == BLK_TOOL)
|
||
attach_tool_output_from_session(t, &t->hist.items[i]);
|
||
}
|
||
t->turn_done = 1;
|
||
t->busy = 0;
|
||
tui_mark_dirty(t);
|
||
tui_unlock(t);
|
||
|
||
free(a->prompt);
|
||
free(a);
|
||
return NULL;
|
||
}
|
||
|
||
static void request_cancel_ui(tui_t *t) {
|
||
cst_agent_request_cancel(t->agent);
|
||
set_status(t, "cancelling…");
|
||
draw_status(t);
|
||
}
|
||
|
||
static int begin_turn(tui_t *t, char *prompt) {
|
||
prompt_hist_push(t, prompt);
|
||
block_t *ub = blocks_push(&t->hist);
|
||
if (ub) {
|
||
ub->kind = BLK_USER;
|
||
block_set_title(ub, "You");
|
||
block_set_body(ub, prompt, strlen(prompt));
|
||
ub->expanded = 1;
|
||
}
|
||
t->think_idx = -1;
|
||
t->log_scroll = 0;
|
||
set_status(t, "streaming…");
|
||
|
||
turn_arg_t *ta = calloc(1, sizeof(*ta));
|
||
if (!ta) {
|
||
free(prompt);
|
||
return -1;
|
||
}
|
||
ta->t = t;
|
||
ta->prompt = prompt;
|
||
t->busy = 1;
|
||
t->turn_done = 0;
|
||
clock_gettime(CLOCK_MONOTONIC, &t->turn_t0);
|
||
redraw_all(t);
|
||
pthread_t th;
|
||
if (pthread_create(&th, NULL, turn_thread, ta) != 0) {
|
||
t->busy = 0;
|
||
free(prompt);
|
||
free(ta);
|
||
push_system_line(t, "Error: failed to start turn thread");
|
||
status_idle(t);
|
||
redraw_all(t);
|
||
return -1;
|
||
}
|
||
pthread_detach(th);
|
||
return 0;
|
||
}
|
||
|
||
/* Owns prompt. Returns 0 ok, -1 fail, -2 quit. */
|
||
static int start_turn(tui_t *t, char *prompt) {
|
||
if (!prompt)
|
||
return -1;
|
||
if (prompt[0] == '/') {
|
||
int should_quit = 0;
|
||
handle_slash(t, prompt, &should_quit);
|
||
free(prompt);
|
||
t->log_scroll = 0;
|
||
if (t->need_layout) {
|
||
t->need_layout = 0;
|
||
layout(t);
|
||
wtimeout(t->input, 80);
|
||
}
|
||
redraw_all(t);
|
||
if (should_quit)
|
||
return -2;
|
||
if (t->pending_prompt && !t->busy) {
|
||
char *next = t->pending_prompt;
|
||
t->pending_prompt = NULL;
|
||
return begin_turn(t, next);
|
||
}
|
||
return 0;
|
||
}
|
||
return begin_turn(t, prompt);
|
||
}
|
||
|
||
static int try_start_queued(tui_t *t) {
|
||
if (t->busy || t->queue_n <= 0)
|
||
return 0;
|
||
char *next = queue_pop(t);
|
||
if (!next)
|
||
return 0;
|
||
return start_turn(t, next);
|
||
}
|
||
|
||
static int submit_input(tui_t *t, int busy) {
|
||
if (t->in_len == 0) {
|
||
if (!busy && t->queue_n > 0)
|
||
return try_start_queued(t);
|
||
if (!busy)
|
||
toggle_last_tool(t);
|
||
return 0;
|
||
}
|
||
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(t->agent);
|
||
if (!busy && t->inbuf[0] != '/' && !cfg->api_key[0]) {
|
||
char key[512];
|
||
if (obtain_key(t, cfg->provider, key, sizeof(key), 0) != 0) {
|
||
push_system_line(t, "No API key for %s yet — /key to enter one, or "
|
||
"/provider to switch",
|
||
cfg->provider);
|
||
return 0;
|
||
}
|
||
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
|
||
memset(key, 0, sizeof(key));
|
||
}
|
||
|
||
char *prompt = strdup(t->inbuf);
|
||
input_clear(t);
|
||
layout_maybe_grow_input(t);
|
||
if (!prompt)
|
||
return 0;
|
||
|
||
if (busy && prompt[0] == '/') {
|
||
int q = 0;
|
||
handle_slash(t, prompt, &q);
|
||
free(prompt);
|
||
redraw_all(t);
|
||
return q ? -2 : 0;
|
||
}
|
||
if (busy) {
|
||
if (queue_push(t, prompt) != 0) {
|
||
push_system_line(t, "(queue full — max %d)", QUEUE_CAP);
|
||
set_status(t, "queue full");
|
||
} else {
|
||
char msg[64];
|
||
snprintf(msg, sizeof(msg), "queued (%d)", t->queue_n);
|
||
set_status(t, msg);
|
||
}
|
||
free(prompt);
|
||
draw_status(t);
|
||
return 0;
|
||
}
|
||
return start_turn(t, prompt);
|
||
}
|
||
|
||
/* --- modal key loops ------------------------------------------------------ */
|
||
|
||
static void perm_answer(tui_t *t, int ans) {
|
||
tui_lock(t);
|
||
t->perm_ans = ans;
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void run_perm_modal(tui_t *t) {
|
||
wtimeout(t->input, -1);
|
||
curs_set(0);
|
||
for (;;) {
|
||
draw_perm_modal(t);
|
||
draw_status(t);
|
||
wint_t wch = 0;
|
||
int rc = wget_wch(t->input, &wch);
|
||
if (rc == ERR)
|
||
continue;
|
||
if (rc == KEY_CODE_YES) {
|
||
int h = getmaxy(t->logw);
|
||
if (wch == KEY_RESIZE)
|
||
layout(t), wtimeout(t->input, -1), redraw_all(t);
|
||
else if (wch == KEY_UP)
|
||
t->perm_scroll--;
|
||
else if (wch == KEY_DOWN)
|
||
t->perm_scroll++;
|
||
else if (wch == KEY_PPAGE)
|
||
t->perm_scroll -= h / 2;
|
||
else if (wch == KEY_NPAGE)
|
||
t->perm_scroll += h / 2;
|
||
else if (wch == KEY_ENTER) {
|
||
perm_answer(t, CST_PERM_ONCE);
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == L'y' || wch == L'Y' || wch == 10 || wch == 13) {
|
||
perm_answer(t, CST_PERM_ONCE);
|
||
break;
|
||
}
|
||
if (wch == L'n' || wch == L'N' || wch == 27) {
|
||
perm_answer(t, CST_PERM_DENY);
|
||
break;
|
||
}
|
||
if (wch == 3) { /* Ctrl+C: deny and stop the turn */
|
||
perm_answer(t, CST_PERM_DENY);
|
||
request_cancel_ui(t);
|
||
break;
|
||
}
|
||
if (wch == L'a' || wch == L'A') {
|
||
perm_answer(t, CST_PERM_ALWAYS_TOOL);
|
||
break;
|
||
}
|
||
if ((wch == L'p' || wch == L'P') && t->perm_prefix[0]) {
|
||
perm_answer(t, CST_PERM_ALWAYS_PREFIX);
|
||
break;
|
||
}
|
||
}
|
||
curs_set(1);
|
||
wtimeout(t->input, 80);
|
||
redraw_all(t);
|
||
}
|
||
|
||
static void ask_finish(tui_t *t, int rc) {
|
||
tui_lock(t);
|
||
if (rc == 0 && !t->ask_buf[0] && t->ask_nopts)
|
||
snprintf(t->ask_buf, sizeof(t->ask_buf), "%s", t->ask_opts[t->ask_sel]);
|
||
t->ask_rc = rc;
|
||
t->ask_done = 1;
|
||
tui_unlock(t);
|
||
}
|
||
|
||
static void run_ask_modal(tui_t *t) {
|
||
wtimeout(t->input, -1);
|
||
curs_set(0);
|
||
for (;;) {
|
||
draw_ask_modal(t);
|
||
wint_t wch = 0;
|
||
int rc = wget_wch(t->input, &wch);
|
||
if (rc == ERR)
|
||
continue;
|
||
if (rc == KEY_CODE_YES) {
|
||
if (wch == KEY_RESIZE)
|
||
layout(t), wtimeout(t->input, -1), redraw_all(t);
|
||
else if (wch == KEY_UP && t->ask_sel > 0)
|
||
t->ask_sel--;
|
||
else if (wch == KEY_DOWN && t->ask_sel + 1 < t->ask_nopts)
|
||
t->ask_sel++;
|
||
else if (wch == KEY_BACKSPACE) {
|
||
size_t n = strlen(t->ask_buf);
|
||
t->ask_buf[n - utf8_prev_len(t->ask_buf, n)] = 0;
|
||
} else if (wch == KEY_ENTER && (t->ask_buf[0] || t->ask_nopts)) {
|
||
ask_finish(t, 0);
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 27 || wch == 3) {
|
||
ask_finish(t, -1);
|
||
break;
|
||
}
|
||
if (wch == 10 || wch == 13) {
|
||
if (t->ask_buf[0] || t->ask_nopts) {
|
||
ask_finish(t, 0);
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 127 || wch == 8) {
|
||
size_t n = strlen(t->ask_buf);
|
||
t->ask_buf[n - utf8_prev_len(t->ask_buf, n)] = 0;
|
||
continue;
|
||
}
|
||
if (!t->ask_buf[0] && wch >= L'1' && wch <= L'9' &&
|
||
(int)(wch - L'1') < t->ask_nopts) {
|
||
t->ask_sel = (int)(wch - L'1');
|
||
ask_finish(t, 0);
|
||
break;
|
||
}
|
||
if (iswprint(wch)) {
|
||
char mb[8];
|
||
mbstate_t st;
|
||
memset(&st, 0, sizeof(st));
|
||
size_t k = wcrtomb(mb, (wchar_t)wch, &st);
|
||
size_t n = strlen(t->ask_buf);
|
||
if (k != (size_t)-1 && n + k + 1 < sizeof(t->ask_buf)) {
|
||
memcpy(t->ask_buf + n, mb, k);
|
||
t->ask_buf[n + k] = 0;
|
||
}
|
||
}
|
||
}
|
||
curs_set(1);
|
||
wtimeout(t->input, 80);
|
||
redraw_all(t);
|
||
}
|
||
|
||
/* --- main loop ------------------------------------------------------------ */
|
||
|
||
static void tui_free(tui_t *t) {
|
||
queue_free_all(t);
|
||
for (int i = 0; i < t->prompt_hist_n; i++)
|
||
free(t->prompt_hist[i]);
|
||
for (int i = 0; i < t->nfiles; i++)
|
||
free(t->files[i]);
|
||
free(t->files);
|
||
free(t->todo);
|
||
free(t->pending_prompt);
|
||
free(t->perm_preview);
|
||
live_end_cleanup(t);
|
||
if (t->header)
|
||
delwin(t->header);
|
||
if (t->logw)
|
||
delwin(t->logw);
|
||
if (t->todow)
|
||
delwin(t->todow);
|
||
if (t->status)
|
||
delwin(t->status);
|
||
if (t->input)
|
||
delwin(t->input);
|
||
blocks_free(&t->hist);
|
||
pthread_mutex_destroy(&t->mu);
|
||
}
|
||
|
||
/* Redraw after an edit; the transcript only when the popup changed. */
|
||
static void after_edit(tui_t *t) {
|
||
layout_maybe_grow_input(t);
|
||
if (t->comp_n || t->comp_drawn) {
|
||
draw_log(t);
|
||
draw_input(t);
|
||
}
|
||
}
|
||
|
||
static void insert_newline(tui_t *t) {
|
||
input_put_byte(t, '\n');
|
||
layout_maybe_grow_input(t);
|
||
}
|
||
|
||
/* Paste mode: keep newlines/tabs literally; redraw once at the end. */
|
||
static void paste_char(tui_t *t, wint_t wch) {
|
||
if (wch == 13) {
|
||
input_put_byte(t, '\n');
|
||
t->paste_cr = 1;
|
||
return;
|
||
}
|
||
if (wch == 10) {
|
||
if (!t->paste_cr)
|
||
input_put_byte(t, '\n');
|
||
t->paste_cr = 0;
|
||
return;
|
||
}
|
||
t->paste_cr = 0;
|
||
if (wch == L'\t')
|
||
input_insert(t, " ", 4);
|
||
else if (iswprint(wch) || wcwidth((wchar_t)wch) > 0)
|
||
input_put_wc(t, (wchar_t)wch);
|
||
}
|
||
|
||
int cst_tui_run(cst_agent_t *agent) {
|
||
if (!setlocale(LC_ALL, ""))
|
||
setlocale(LC_ALL, "C.UTF-8");
|
||
#ifndef _WIN32
|
||
if (!getenv("ESCDELAY"))
|
||
setenv("ESCDELAY", "25", 0);
|
||
#endif
|
||
|
||
initscr();
|
||
raw(); /* Ctrl+C/Ctrl+Z reach us as keys instead of signals */
|
||
noecho();
|
||
keypad(stdscr, TRUE);
|
||
curs_set(1);
|
||
theme_init();
|
||
#ifdef NCURSES_VERSION
|
||
define_key("\033[200~", KEY_PASTE_BEGIN);
|
||
define_key("\033[201~", KEY_PASTE_END);
|
||
printf("\033[?2004h");
|
||
fflush(stdout);
|
||
#endif
|
||
|
||
tui_t t;
|
||
memset(&t, 0, sizeof(t));
|
||
t.agent = agent;
|
||
t.think_idx = -1;
|
||
t.last_tool_idx = -1;
|
||
t.focus_think_idx = -1;
|
||
t.hist_browse = -1;
|
||
t.show_todo = 1;
|
||
pthread_mutex_init(&t.mu, NULL);
|
||
blocks_init(&t.hist);
|
||
if (!t.hist.items) {
|
||
endwin();
|
||
return -1;
|
||
}
|
||
|
||
refresh_title(&t);
|
||
refresh_todo(&t);
|
||
layout(&t);
|
||
t.need_layout = 0;
|
||
reload_hist_from_session(&t);
|
||
status_idle(&t);
|
||
redraw_all(&t);
|
||
wtimeout(t.input, 80);
|
||
|
||
cst_agent_config_t *cfg = cst_agent_config_mut(agent);
|
||
cst_settings_t first;
|
||
if (!cfg->api_key[0] && cst_settings_load(&first) != 0) {
|
||
int ignore = 0;
|
||
cmd_provider(&t, NULL, &ignore);
|
||
}
|
||
if (!cfg->api_key[0]) {
|
||
char key[512];
|
||
if (obtain_key(&t, cfg->provider, key, sizeof(key), 0) == 0)
|
||
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
|
||
else
|
||
push_system_line(&t, "No API key for %s yet — /key to enter one, or "
|
||
"/provider to switch",
|
||
cfg->provider);
|
||
memset(key, 0, sizeof(key));
|
||
}
|
||
cst_settings_remember(cfg);
|
||
redraw_all(&t);
|
||
|
||
for (;;) {
|
||
tui_lock(&t);
|
||
int dirty = t.dirty;
|
||
int turn_done = t.turn_done;
|
||
int turn_rc = t.turn_rc;
|
||
char turn_err[512];
|
||
snprintf(turn_err, sizeof(turn_err), "%s", t.turn_err);
|
||
int perm_ask = t.perm_ask && t.perm_ans < 0;
|
||
int ask = t.ask_pending && !t.ask_done;
|
||
int busy = t.busy;
|
||
int relayout = t.need_layout;
|
||
t.need_layout = 0;
|
||
t.dirty = 0;
|
||
if (turn_done)
|
||
t.turn_done = 0;
|
||
tui_unlock(&t);
|
||
|
||
if (relayout) {
|
||
layout(&t);
|
||
wtimeout(t.input, 80);
|
||
dirty = 1;
|
||
}
|
||
if (dirty)
|
||
redraw_all(&t);
|
||
if (busy) {
|
||
t.spin++;
|
||
draw_header(&t);
|
||
draw_input(&t);
|
||
}
|
||
|
||
if (turn_done) {
|
||
if (turn_rc != 0) {
|
||
push_system_line(&t, "Error: %s", turn_err[0] ? turn_err : "?");
|
||
set_status(&t, turn_err[0] ? turn_err : "turn failed");
|
||
} else {
|
||
status_idle(&t);
|
||
}
|
||
cst_agent_reload_notes(t.agent);
|
||
refresh_title(&t);
|
||
t.log_scroll = 0;
|
||
redraw_all(&t);
|
||
if (try_start_queued(&t) == -2)
|
||
break;
|
||
continue;
|
||
}
|
||
|
||
if (perm_ask) {
|
||
run_perm_modal(&t);
|
||
continue;
|
||
}
|
||
if (ask) {
|
||
run_ask_modal(&t);
|
||
continue;
|
||
}
|
||
|
||
wint_t wch = 0;
|
||
int rc = wget_wch(t.input, &wch);
|
||
if (rc == ERR)
|
||
continue;
|
||
|
||
if (rc == KEY_CODE_YES) {
|
||
if (wch == KEY_PASTE_BEGIN) {
|
||
t.in_paste = 1;
|
||
t.paste_cr = 0;
|
||
continue;
|
||
}
|
||
if (wch == KEY_PASTE_END) {
|
||
t.in_paste = 0;
|
||
input_changed(&t);
|
||
after_edit(&t);
|
||
continue;
|
||
}
|
||
if (wch == KEY_RESIZE) {
|
||
layout(&t);
|
||
wtimeout(t.input, 80);
|
||
redraw_all(&t);
|
||
continue;
|
||
}
|
||
if (wch == KEY_MOUSE) {
|
||
MEVENT ev;
|
||
if (getmouse(&ev) == OK) {
|
||
if (ev.bstate & BUTTON4_PRESSED) {
|
||
t.log_scroll += SCROLL_SPEED;
|
||
draw_log(&t);
|
||
} else if (ev.bstate & BUTTON5_PRESSED) {
|
||
t.log_scroll -= SCROLL_SPEED;
|
||
if (t.log_scroll < 0)
|
||
t.log_scroll = 0;
|
||
draw_log(&t);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
switch (wch) {
|
||
case KEY_PPAGE:
|
||
t.log_scroll += t.rows / 2;
|
||
draw_log(&t);
|
||
continue;
|
||
case KEY_NPAGE:
|
||
t.log_scroll -= t.rows / 2;
|
||
if (t.log_scroll < 0)
|
||
t.log_scroll = 0;
|
||
draw_log(&t);
|
||
continue;
|
||
case KEY_HOME:
|
||
if (t.in_len) {
|
||
t.cur = line_start(&t, t.cur);
|
||
draw_input(&t);
|
||
} else {
|
||
t.log_scroll = 1 << 28; /* clamped by draw_log */
|
||
draw_log(&t);
|
||
}
|
||
continue;
|
||
case KEY_END:
|
||
if (t.in_len) {
|
||
t.cur = line_end(&t, t.cur);
|
||
draw_input(&t);
|
||
} else {
|
||
t.log_scroll = 0;
|
||
draw_log(&t);
|
||
}
|
||
continue;
|
||
case KEY_LEFT:
|
||
t.cur -= utf8_prev_len(t.inbuf, t.cur);
|
||
update_completion(&t);
|
||
redraw_all(&t);
|
||
continue;
|
||
case KEY_RIGHT:
|
||
t.cur += next_len(&t, t.cur);
|
||
update_completion(&t);
|
||
redraw_all(&t);
|
||
continue;
|
||
case KEY_UP:
|
||
case KEY_DOWN: {
|
||
int dir = wch == KEY_UP ? -1 : 1;
|
||
if (t.comp_n > 0) {
|
||
t.comp_sel = (t.comp_sel + dir + t.comp_n) % t.comp_n;
|
||
draw_log(&t);
|
||
draw_input(&t);
|
||
continue;
|
||
}
|
||
if (input_line_move(&t, dir)) {
|
||
draw_input(&t);
|
||
continue;
|
||
}
|
||
if (dir < 0) {
|
||
if (t.prompt_hist_n <= 0)
|
||
continue;
|
||
int idx = t.hist_browse;
|
||
idx = idx < 0 ? t.prompt_hist_n - 1 : (idx > 0 ? idx - 1 : 0);
|
||
hist_load_into_input(&t, idx);
|
||
} else {
|
||
if (t.hist_browse < 0)
|
||
continue;
|
||
if (t.hist_browse + 1 >= t.prompt_hist_n)
|
||
input_clear(&t);
|
||
else
|
||
hist_load_into_input(&t, t.hist_browse + 1);
|
||
}
|
||
after_edit(&t);
|
||
continue;
|
||
}
|
||
case KEY_BACKSPACE:
|
||
input_erase(&t, t.cur - utf8_prev_len(t.inbuf, t.cur), t.cur);
|
||
after_edit(&t);
|
||
continue;
|
||
case KEY_DC:
|
||
input_erase(&t, t.cur, t.cur + next_len(&t, t.cur));
|
||
after_edit(&t);
|
||
continue;
|
||
case KEY_BTAB:
|
||
if (t.comp_n > 0) {
|
||
t.comp_sel = (t.comp_sel + t.comp_n - 1) % t.comp_n;
|
||
draw_log(&t);
|
||
}
|
||
continue;
|
||
case KEY_ENTER:
|
||
break; /* handled with CR below */
|
||
default:
|
||
continue;
|
||
}
|
||
wch = 13;
|
||
}
|
||
|
||
if (t.in_paste) {
|
||
paste_char(&t, wch);
|
||
continue;
|
||
}
|
||
|
||
/* Enter: accept a completion, otherwise send. */
|
||
if (wch == 10 || wch == 13) {
|
||
if (t.comp_n > 0) {
|
||
int kind = t.comp_kind;
|
||
const slash_cmd_t *c = accept_completion(&t);
|
||
after_edit(&t);
|
||
/* "/he⏎" runs /help; commands that need an argument wait for it */
|
||
if (kind != 1 || !c || c->args[0] == '<')
|
||
continue;
|
||
}
|
||
int st = submit_input(&t, busy);
|
||
if (st == -2)
|
||
break;
|
||
continue;
|
||
}
|
||
|
||
if (wch == 3) { /* Ctrl+C */
|
||
if (busy) {
|
||
request_cancel_ui(&t);
|
||
} else if (t.in_len) {
|
||
input_clear(&t);
|
||
after_edit(&t);
|
||
} else if (t.ctrlc_at.tv_sec && since_ms(&t.ctrlc_at) < 2000) {
|
||
break;
|
||
} else {
|
||
clock_gettime(CLOCK_MONOTONIC, &t.ctrlc_at);
|
||
set_status(&t, "press Ctrl+C again to quit");
|
||
draw_status(&t);
|
||
}
|
||
continue;
|
||
}
|
||
if (wch == 4) { /* Ctrl+D on an empty prompt quits */
|
||
if (!busy && t.in_len == 0)
|
||
break;
|
||
input_erase(&t, t.cur, t.cur + next_len(&t, t.cur));
|
||
layout_maybe_grow_input(&t);
|
||
continue;
|
||
}
|
||
|
||
if (wch == 27) {
|
||
/* Alt+key arrives as Esc followed by the key */
|
||
wint_t nx = 0;
|
||
wtimeout(t.input, 30);
|
||
int r2 = wget_wch(t.input, &nx);
|
||
wtimeout(t.input, 80);
|
||
if (r2 == OK) {
|
||
if (nx == 13 || nx == 10)
|
||
insert_newline(&t);
|
||
else if (nx == L'b')
|
||
t.cur = word_left(&t, t.cur), draw_input(&t);
|
||
else if (nx == L'f')
|
||
t.cur = word_right(&t, t.cur), draw_input(&t);
|
||
else if (nx == 127 || nx == 8) {
|
||
input_erase(&t, word_left(&t, t.cur), t.cur);
|
||
layout_maybe_grow_input(&t);
|
||
}
|
||
continue;
|
||
}
|
||
if (t.comp_n > 0) {
|
||
t.comp_off = 1;
|
||
t.comp_n = 0;
|
||
draw_log(&t);
|
||
} else if (busy) {
|
||
request_cancel_ui(&t);
|
||
} else if (t.in_len) {
|
||
input_clear(&t);
|
||
layout_maybe_grow_input(&t);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
switch (wch) {
|
||
case 1: /* Ctrl+A */
|
||
t.cur = line_start(&t, t.cur);
|
||
draw_input(&t);
|
||
continue;
|
||
case 5: /* Ctrl+E */
|
||
t.cur = line_end(&t, t.cur);
|
||
draw_input(&t);
|
||
continue;
|
||
case 2: /* Ctrl+B */
|
||
t.cur -= utf8_prev_len(t.inbuf, t.cur);
|
||
draw_input(&t);
|
||
continue;
|
||
case 6: /* Ctrl+F */
|
||
t.cur += next_len(&t, t.cur);
|
||
draw_input(&t);
|
||
continue;
|
||
case 11: /* Ctrl+K */
|
||
input_erase(&t, t.cur, line_end(&t, t.cur) == t.cur && t.cur < t.in_len
|
||
? t.cur + 1
|
||
: line_end(&t, t.cur));
|
||
after_edit(&t);
|
||
continue;
|
||
case 21: /* Ctrl+U */
|
||
input_erase(&t, line_start(&t, t.cur), t.cur);
|
||
after_edit(&t);
|
||
continue;
|
||
case 23: /* Ctrl+W */
|
||
input_erase(&t, word_left(&t, t.cur), t.cur);
|
||
after_edit(&t);
|
||
continue;
|
||
case 14: /* Ctrl+N */
|
||
insert_newline(&t);
|
||
continue;
|
||
case 15: /* Ctrl+O */
|
||
toggle_last_tool(&t);
|
||
continue;
|
||
case 20: /* Ctrl+T */
|
||
t.show_todo = !t.show_todo;
|
||
layout(&t);
|
||
wtimeout(t.input, 80);
|
||
redraw_all(&t);
|
||
continue;
|
||
case 12: /* Ctrl+L */
|
||
clearok(curscr, TRUE);
|
||
redraw_all(&t);
|
||
continue;
|
||
case 26: /* Ctrl+Z is not job control in raw mode */
|
||
continue;
|
||
case L'\t':
|
||
if (t.comp_n > 0) {
|
||
accept_completion(&t);
|
||
after_edit(&t);
|
||
} else if (t.in_len == 0) {
|
||
toggle_think(&t);
|
||
} else {
|
||
t.comp_off = 0;
|
||
update_completion(&t);
|
||
draw_log(&t);
|
||
draw_input(&t);
|
||
}
|
||
continue;
|
||
case 127:
|
||
case 8:
|
||
input_erase(&t, t.cur - utf8_prev_len(t.inbuf, t.cur), t.cur);
|
||
after_edit(&t);
|
||
continue;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
if (iswprint(wch) || wcwidth((wchar_t)wch) > 0) {
|
||
input_put_wc(&t, (wchar_t)wch);
|
||
after_edit(&t);
|
||
}
|
||
}
|
||
|
||
if (t.busy) {
|
||
cst_agent_request_cancel(t.agent);
|
||
for (int i = 0; i < 50 && t.busy; i++)
|
||
usleep(100000);
|
||
}
|
||
|
||
tui_free(&t);
|
||
#ifdef NCURSES_VERSION
|
||
printf("\033[?2004l");
|
||
fflush(stdout);
|
||
#endif
|
||
endwin();
|
||
return 0;
|
||
}
|