diff --git a/docs/screenshots/tui-home-after.png b/docs/screenshots/tui-home-after.png new file mode 100644 index 0000000..19694ce Binary files /dev/null and b/docs/screenshots/tui-home-after.png differ diff --git a/docs/screenshots/tui-home-before.png b/docs/screenshots/tui-home-before.png new file mode 100644 index 0000000..708a2f1 Binary files /dev/null and b/docs/screenshots/tui-home-before.png differ diff --git a/docs/screenshots/tui-home-r2.png b/docs/screenshots/tui-home-r2.png new file mode 100644 index 0000000..cb17544 Binary files /dev/null and b/docs/screenshots/tui-home-r2.png differ diff --git a/docs/screenshots/tui-home-r3.png b/docs/screenshots/tui-home-r3.png new file mode 100644 index 0000000..5ba8dca Binary files /dev/null and b/docs/screenshots/tui-home-r3.png differ diff --git a/docs/screenshots/tui-session-r2.png b/docs/screenshots/tui-session-r2.png new file mode 100644 index 0000000..ed05265 Binary files /dev/null and b/docs/screenshots/tui-session-r2.png differ diff --git a/docs/screenshots/tui-session-r3.png b/docs/screenshots/tui-session-r3.png new file mode 100644 index 0000000..53ea3ac Binary files /dev/null and b/docs/screenshots/tui-session-r3.png differ diff --git a/include/cst_version.h b/include/cst_version.h index decc8dc..496819e 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 9 -#define CST_VERSION_PATCH 23 +#define CST_VERSION_PATCH 24 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/src/tui.c b/src/tui.c index 04e5db4..d3fa8c9 100644 --- a/src/tui.c +++ b/src/tui.c @@ -260,7 +260,10 @@ enum { CP_ACCENT, /* blue on panel */ CP_BRIGHT, /* near-white on panel */ CP_LOGO_DIM, - CP_SEL /* selected list row */ + CP_SEL, /* selected list row */ + CP_TOPBAR, /* session chrome strip */ + CP_TOPBAR_MUTED, + CP_TOPBAR_ACCENT }; typedef enum { @@ -487,7 +490,7 @@ static char *xstrndup(const char *s, size_t n) { /* --- theme ---------------------------------------------------------------- */ -/* OpenCode dark panel — never inherit terminal default bg. */ +/* Dark panel chrome — cool steel accents (not peach OpenCode clone). */ static void theme_init(void) { const char *nc = getenv("NO_COLOR"); g_nocolor = nc && nc[0]; @@ -504,7 +507,7 @@ static void theme_init(void) { start_color(); /* Do NOT call use_default_colors(): -1 would leak the terminal theme. */ - short primary = COLOR_YELLOW; + short primary = COLOR_CYAN; short panel = COLOR_BLACK; short muted = COLOR_CYAN; short success = COLOR_GREEN; @@ -517,42 +520,39 @@ static void theme_init(void) { if (COLORS >= 256) { box = 235; side = 234; - accent = 75; + accent = 75; /* #5c9cf5 steel blue */ bright = 255; logo_dim = 242; - /* Prefer xterm-256 slots so we don't bleach the 8 ANSI base colors. - * panel #121212→233, primary #fab283→216, muted #808080→244 */ - primary = 216; - panel = 233; /* #121212 — darker than 234 to avoid washed look */ + primary = 75; /* brand/user accent: blue, not peach */ + panel = 233; /* #121212 */ muted = 244; success = 114; err = 167; - assist = 74; /* soft cyan, not neon */ - text = 250; /* #bcbcbc — not near-white */ + assist = 74; /* soft cyan assistant */ + text = 252; /* brighter body for readability */ 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(75, 430, 660, 996); /* #6ea8fe brand */ + init_color(233, 55, 58, 70); /* #0e0f12 deeper panel */ + init_color(244, 502, 520, 560); /* cool muted */ + init_color(252, 820, 830, 850); /* body */ init_color(114, 400, 750, 450); init_color(167, 850, 320, 320); - init_color(74, 350, 700, 750); - init_color(235, 118, 118, 118); /* #1e1e1e raised box */ - init_color(234, 90, 90, 90); /* #171717 sidebar */ - init_color(75, 361, 612, 961); /* #5c9cf5 accent */ + init_color(74, 380, 720, 780); + init_color(235, 100, 105, 120); /* raised box */ + init_color(234, 78, 82, 96); /* sidebar */ + init_color(216, 980, 720, 420); /* warm tip only */ } } 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_BLACK, 55, 58, 70); + init_color(COLOR_CYAN, 430, 660, 996); + init_color(COLOR_WHITE, 820, 830, 850); init_color(COLOR_GREEN, 400, 750, 450); init_color(COLOR_RED, 850, 320, 320); - init_color(COLOR_BLUE, 350, 700, 750); /* assist */ + init_color(COLOR_BLUE, 380, 720, 780); + init_color(COLOR_YELLOW, 980, 720, 420); panel = COLOR_BLACK; - primary = COLOR_YELLOW; - muted = COLOR_CYAN; + primary = COLOR_CYAN; + muted = COLOR_WHITE; text = COLOR_WHITE; success = COLOR_GREEN; err = COLOR_RED; @@ -608,6 +608,9 @@ static void theme_init(void) { init_pair(CP_BRIGHT, bright, panel); init_pair(CP_LOGO_DIM, logo_dim, panel); init_pair(CP_SEL, panel, accent); + init_pair(CP_TOPBAR, bright, side); + init_pair(CP_TOPBAR_MUTED, muted, side); + init_pair(CP_TOPBAR_ACCENT, accent, side); } /* Blank the window into the virtual screen before deleting it: refreshing @@ -2196,8 +2199,22 @@ static void draw_input(tui_t *t) { if (pad < 0) pad = 0; int bar = HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX_ACCENT) : 0; - for (int r = 0; r < bh; r++) + int edge = HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX_MUTED) : 0; + /* Composer card frame. */ + if (bh >= 2 && bw >= 4) { + put_wc(w, 0, 0, g_ascii ? L'+' : L'┌', bar); + for (int x = 1; x < bw - 1; x++) + put_wc(w, 0, x, g_ascii ? L'-' : L'─', edge); + put_wc(w, 0, bw - 1, g_ascii ? L'+' : L'┐', edge); + put_wc(w, bh - 1, 0, g_ascii ? L'+' : L'└', edge); + for (int x = 1; x < bw - 1; x++) + put_wc(w, bh - 1, x, g_ascii ? L'-' : L'─', edge); + put_wc(w, bh - 1, bw - 1, g_ascii ? L'+' : L'┘', edge); + } + for (int r = 1; r < bh - 1; r++) { put_wc(w, r, 0, L'┃', bar); + put_wc(w, r, bw - 1, g_ascii ? L'|' : L'│', edge); + } draw_mode_line(t, w, pad + h, bw); int cr, cc; input_layout(t, NULL, NULL, &cr, &cc); @@ -2370,43 +2387,79 @@ static void draw_home(tui_t *t) { getmaxyx(t->logw, h, w); int box_top = t->in_y - t->log_y; int box_bot = box_top + t->in_h; - if (g_ascii || !g_utf8 || g_logo_text) { - int x = (w - 8) / 2, y = box_top - 2; - if (y >= 0) { - draw_ansi_at(t->logw, y, x, 3, "cst", CP_LOGO_DIM, 1, 0); - draw_ansi_at(t->logw, y, x + 3, 5, "agent", CP_BRIGHT, 1, 0); - } - } else if (box_top - LOGO_H - 1 >= 0 && w >= LOGO_W) { - int x0 = (w - LOGO_W) / 2, y0 = box_top - LOGO_H - 1; + /* Tight home stack: wordmark (default) or CST_LOGO=block, tagline, chip. */ + int brand_y = box_top - 2; + if (brand_y < 0) + brand_y = 0; + int want_block = 0; + { + const char *lb = getenv("CST_LOGO"); + want_block = lb && !strcmp(lb, "block"); + } + if (want_block && !(g_ascii || !g_utf8 || g_logo_text) && + box_top - LOGO_H - 2 >= 0 && w >= LOGO_W) { + int x0 = (w - LOGO_W) / 2, y0 = box_top - LOGO_H - 2; for (int g = 0; g < 8; g++) for (int r = 0; r < LOGO_H; r++) draw_ansi_at(t->logw, y0 + r, x0 + g * 5, 4, LOGO[g][r], - g < 3 ? CP_LOGO_DIM : CP_BRIGHT, 0, 0); + g < 3 ? CP_ACCENT : CP_BRIGHT, 0, 0); + brand_y = y0 + LOGO_H; + } else { + const char *mark = "CST-agent"; + int mw = (int)strlen(mark); + int x = (w - mw) / 2; + if (x < 0) + x = 0; + if (brand_y >= 1) + brand_y -= 1; + draw_ansi_at(t->logw, brand_y, x, 3, "CST", CP_ACCENT, 1, 0); + draw_ansi_at(t->logw, brand_y, x + 3, mw - 3, "-agent", CP_BRIGHT, 1, 0); + } + int ty = brand_y + 1; + if (ty < box_top) { + const char *tag = + g_ascii ? "code . plan . parliament" : "code · plan · parliament"; + int tw = (int)strlen(tag); + int x = (w - tw) / 2; + if (x < 0) + x = 0; + draw_ansi_at(t->logw, ty, x, tw, tag, CP_MUTED, 0, 0); + } + if (cst_parliament_is_active() && ty + 1 < box_top) { + char *st = cst_parliament_status_text(); + if (st && st[0]) { + char chip[160]; + snprintf(chip, sizeof(chip), "%s %s", g_ascii ? "*" : "◆", st); + int cw = vis_cols(chip); + if (cw > w - 2) + cw = w - 2; + draw_ansi_at(t->logw, ty + 1, (w - cw) / 2, cw, chip, CP_ACCENT, 0, 0); + } + free(st); } int right = t->in_x - t->log_x + t->in_w; - if (box_bot + 1 < h) { + if (box_bot < h) { char hint[96]; snprintf(hint, sizeof(hint), - "\033[97mtab\033[39m modes \033[97mctrl+p\033[39m commands"); + "\033[97mtab\033[39m modes \033[97mctrl+p\033[39m commands"); int hw = vis_cols(hint); if (right - hw >= 0) - draw_ansi_at(t->logw, box_bot + 1, right - hw, hw, hint, CP_MUTED, 0, 0); + draw_ansi_at(t->logw, box_bot, right - hw, hw, hint, CP_MUTED, 0, 0); } - if (t->tip && box_bot + 3 < h) { + if (t->tip && box_bot + 2 < h) { char tip[256]; - snprintf(tip, sizeof(tip), "\033[33m%s Tip\033[39m %s", - g_ascii ? "*" : "●", t->tip); + snprintf(tip, sizeof(tip), "%s %s", g_ascii ? "*" : "●", t->tip); int tw = vis_cols(tip); - if (tw > w - 2) - tw = w - 2; - draw_ansi_at(t->logw, box_bot + 3, (w - tw) / 2, tw, tip, CP_MUTED, 0, 0); + if (tw > w - 4) + tw = w - 4; + draw_ansi_at(t->logw, box_bot + 2, (w - tw) / 2, tw, tip, CP_MUTED, 0, 0); } const char *ext = cst_extensions_report(); - if (ext[0] && box_bot + 5 < h) { + if (ext[0] && box_bot + 3 < h) { int ew = vis_cols(ext); if (ew > w - 2) ew = w - 2; - draw_ansi_at(t->logw, box_bot + 5, (w - ew) / 2, ew, ext, CP_MUTED, 0, 0); + draw_ansi_at(t->logw, box_bot + 3, (w - ew) / 2, ew, ext, CP_MUTED, 0, 0); } int room = h - box_bot; draw_completion(t, box_bot, t->in_x - t->log_x, t->in_w, room, 0); @@ -2704,10 +2757,61 @@ static int is_home(const tui_t *t) { static void layout(tui_t *t); +static void draw_topbar(tui_t *t) { + if (!t->header) + return; + WINDOW *w = t->header; + if (HAS_COLOR()) + wbkgd(w, COLOR_PAIR(CP_TOPBAR)); + werase(w); + int bw = getmaxx(w); + const cst_agent_config_t *cfg = cst_agent_config(t->agent); + char left[256]; + const char *title = t->title[0] ? t->title : "New session"; + /* Session store ids look like hex prefixes — keep chrome human. */ + { + int hexish = 1; + size_t n = strlen(title); + if (n >= 8 && n <= 40) { + for (size_t i = 0; i < n; i++) { + char c = title[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F') || c == '-')) { + hexish = 0; + break; + } + } + } else { + hexish = 0; + } + if (hexish) + title = "New session"; + } + snprintf(left, sizeof(left), " %s", title); + int x = draw_ansi_at(w, 0, 0, bw, left, CP_TOPBAR, 1, 0); + if (cfg && x + 8 < bw) { + char mid[160]; + snprintf(mid, sizeof(mid), " %.48s%s%.24s", cfg->model, + cfg->provider[0] ? " · " : "", cfg->provider); + x += draw_ansi_at(w, 0, x, bw - x, mid, CP_TOPBAR_MUTED, 0, 0); + } + if (cst_parliament_is_active() && bw > 24) { + char chip[64]; + if (cst_parliament_is_chair()) + snprintf(chip, sizeof(chip), " chair "); + else + snprintf(chip, sizeof(chip), " seat "); + int cw = (int)strlen(chip); + draw_ansi_at(w, 0, bw - cw - 1, cw, chip, CP_TOPBAR_ACCENT, 1, 0); + } + wrefresh(w); +} + static void redraw_all(tui_t *t) { if (is_home(t) != t->home) layout(t); theme_paint_root(); + draw_topbar(t); draw_log(t); draw_todo(t); draw_side(t); @@ -2988,8 +3092,10 @@ static int clampi(int v, int lo, int hi) { return v < lo ? lo : v > hi ? hi : v; } -/* Start screen: centered logo + box. Session: transcript column, input box - * and status row at the bottom, optional sidebar on the right. */ +static void draw_topbar(tui_t *t); + +/* Start screen: centered brand + composer. Session: top chrome, transcript, + * input card, status; optional sidebar. */ static void layout(tui_t *t) { getmaxyx(stdscr, t->rows, t->cols); if (t->rows < 4) @@ -3002,6 +3108,7 @@ static void layout(tui_t *t) { t->side_w = 0; t->todo_rows = 0; int pad = t->rows >= 20 ? 1 : 0; + int topbar = (!t->home && t->rows >= 12) ? 1 : 0; if (t->home) { t->in_w = t->cols - 8 < 72 ? t->cols - 8 : 72; @@ -3009,23 +3116,26 @@ static void layout(tui_t *t) { t->in_w = t->cols - 2; t->in_x = (t->cols - t->in_w) / 2; } else { - if (t->show_side && t->cols >= 120) - t->side_w = clampi(t->cols / 4, 32, 42); + if (t->show_side && t->cols >= 110) + t->side_w = clampi(t->cols / 4, 30, 40); int mw = t->cols - t->side_w; t->in_x = 1; t->in_w = mw - 2; } t->input_rows = input_visual_rows(t); t->in_h = t->input_rows + 1 + 2 * pad; - if (t->in_h > t->rows - 2) { - t->input_rows = clampi(t->rows - 3, 1, INPUT_MAX_ROWS); + if (t->in_h > t->rows - 2 - topbar) { + t->input_rows = clampi(t->rows - 3 - topbar, 1, INPUT_MAX_ROWS); t->in_h = t->input_rows + 1; } if (t->home) { - int block = LOGO_H + 1 + t->in_h + 4; + /* Compact vertical rhythm: brand + tagline + composer, not a huge void. */ + int block = 3 + t->in_h + 3; int top = (t->rows - 1 - block) / 2; - t->in_y = clampi(top + LOGO_H + 1, 0, t->rows - 1 - t->in_h); + if (top < 1) + top = 1; + t->in_y = clampi(top + 3, 0, t->rows - 1 - t->in_h); t->log_x = t->log_y = 0; t->logw = tui_newwin(t->rows - 1, t->cols, 0, 0); t->status = tui_newwin(1, t->cols - 2, t->rows - 1, 1); @@ -3037,7 +3147,9 @@ static void layout(tui_t *t) { if (open && t->rows >= 16) t->todo_rows = 1 + (total < TODO_PANEL_MAX ? total : TODO_PANEL_MAX); t->log_x = 1; - t->log_y = t->rows >= 12 ? 1 : 0; + t->log_y = topbar; + if (topbar) + t->header = tui_newwin(1, mw, 0, 0); int log_h = t->in_y - 1 - t->todo_rows - t->log_y; if (log_h < 1) { t->todo_rows = 0; @@ -3055,6 +3167,8 @@ static void layout(tui_t *t) { wbkgdset(t->logw, COLOR_PAIR(CP_PANEL)); wbkgdset(t->status, COLOR_PAIR(CP_PANEL)); wbkgdset(t->input, COLOR_PAIR(CP_BOX)); + if (t->header) + wbkgdset(t->header, COLOR_PAIR(CP_TOPBAR)); if (t->side) wbkgdset(t->side, COLOR_PAIR(CP_SIDE)); } @@ -7088,7 +7202,7 @@ int cst_tui_run(cst_agent_t *agent) { t.focus_think_idx = -1; t.hist_browse = -1; t.show_todo = 1; - t.show_side = 1; + t.show_side = 0; /* /sidebar to open; keeps the main column calm by default */ { cst_settings_t st; cst_settings_load(&st); diff --git a/tools/tui_shot.py b/tools/tui_shot.py new file mode 100644 index 0000000..0bfdd28 --- /dev/null +++ b/tools/tui_shot.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""ANSI pane dump → HTML → PNG (chrome headless). Usage: tui_shot.py in.ansi out.png""" +from __future__ import annotations + +import html +import subprocess +import sys +from pathlib import Path + +SPECIAL = { + 75: "#6ea8fe", + 74: "#61b8c7", + 216: "#faa06b", + 233: "#0e0f12", + 234: "#14161e", + 235: "#1a1c24", + 244: "#80848f", + 250: "#bcbcbc", + 252: "#d1d4d9", + 255: "#ffffff", + 242: "#6a6a6a", + 114: "#66bf73", + 167: "#d95252", + 157: "#afff87", + 22: "#005f00", + 203: "#ff5f5f", + 52: "#5f0000", +} + + +def ansi_to_html(text: str) -> str: + cmap = { + 30: "#3c3c46", + 31: "#e65a5a", + 32: "#5ad278", + 33: "#faa06b", + 34: "#6ea8fe", + 35: "#d282d2", + 36: "#5ad2e6", + 37: "#bcbcbc", + 90: "#80848f", + 91: "#ff8282", + 92: "#82ff96", + 93: "#ffeb82", + 94: "#96beff", + 95: "#eba0eb", + 96: "#82ebff", + 97: "#ffffff", + } + out: list[str] = [] + i = 0 + bold = dim = False + fg = "#d1d4d9" + open_span = False + + def close() -> None: + nonlocal open_span + if open_span: + out.append("") + open_span = False + + def reopen() -> None: + nonlocal open_span + close() + styles = [f"color:{fg}"] + if bold: + styles.append("font-weight:700") + if dim: + styles.append("opacity:.65") + out.append(f'') + open_span = True + + reopen() + while i < len(text): + if text[i] == "\x1b" and i + 1 < len(text) and text[i + 1] == "[": + j = i + 2 + while j < len(text) and text[j] != "m": + j += 1 + seq = text[i + 2 : j] + i = j + 1 if j < len(text) else len(text) + parts = [int(p) for p in seq.split(";") if p.isdigit()] if seq else [0] + k = 0 + while k < len(parts): + c = parts[k] + if c == 0: + bold = dim = False + fg = "#d1d4d9" + elif c == 1: + bold = True + elif c == 2: + dim = True + elif c == 22: + bold = dim = False + elif c == 38 and k + 2 < len(parts) and parts[k + 1] == 5: + fg = SPECIAL.get(parts[k + 2], fg) + k += 2 + elif c in cmap: + fg = cmap[c] + elif c == 39: + fg = "#d1d4d9" + k += 1 + reopen() + continue + if text[i] == "\n": + out.append("\n") + i += 1 + continue + if text[i] == "\r": + i += 1 + continue + out.append(html.escape(text[i])) + i += 1 + close() + body = "".join(out) + return f""" +
{body}
""" + + +def main() -> int: + if len(sys.argv) < 3: + print("usage: tui_shot.py in.ansi out.png", file=sys.stderr) + return 2 + ansi = Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace") + html_path = Path(sys.argv[2]).with_suffix(".html") + html_path.write_text(ansi_to_html(ansi), encoding="utf-8") + png = Path(sys.argv[2]) + chrome = "google-chrome" + subprocess.run( + [ + chrome, + "--headless=new", + "--disable-gpu", + "--no-sandbox", + "--hide-scrollbars", + "--window-size=1280,800", + f"--screenshot={png}", + html_path.resolve().as_uri(), + ], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + print(png, png.stat().st_size if png.exists() else 0) + return 0 if png.exists() else 1 + + +if __name__ == "__main__": + raise SystemExit(main())