From b0fb2b8b05b5073e07cef64b0d98c47b5ee3ad28 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 15 Jun 2026 15:57:57 +0530 Subject: [PATCH] =?UTF-8?q?opentui(v6):=20skins/theming=20parity=20?= =?UTF-8?q?=E2=80=94=20live=20/skin=20switch,=20animated=20spinner,=20tool?= =?UTF-8?q?=5Femojis,=20status-bar=20colors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server resolve_skin() now serializes spinner + tool_emojis (were dropped on the wire; neither native engine could use them) - GatewaySkin schema gains optional spinner/tool_emojis (additive, back-compat) - theme.ts: SpinnerConfig + parseSpinner (crash-proof) threaded through fromSkin; status_bar_* keys now drive statusBg/Fg/Bad/Critical (were hardcoded — all dark skins looked identical in the status bar) - /skin CLIENT slash handler -> config.set -> skin.changed -> live retheme - composer: imperative ta.textColor/cursorColor on theme change (uncontrolled textarea recolor) + slash-token SyntaxStyle re-register - statusLine: animated face via bounded setInterval armed on running/cleared on stop - registry/toolPart: skin tool_emojis override tool glyphs --- tui_gateway/server.py | 5 + .../src/boundary/schema/GatewayEvent.ts | 7 +- ui-opentui/src/logic/slash.ts | 27 ++++ ui-opentui/src/logic/theme.ts | 74 +++++++++-- ui-opentui/src/test/inkBudget.test.tsx | 8 ++ ui-opentui/src/test/skinLiveOverlay.test.tsx | 101 +++++++++++++++ ui-opentui/src/test/slash.test.ts | 17 +++ .../src/test/statusLineSpinner.test.tsx | 47 +++++++ ui-opentui/src/test/store.test.ts | 115 ++++++++++++++++++ ui-opentui/src/view/composer.tsx | 46 +++++++ ui-opentui/src/view/statusLine.tsx | 88 ++++++++++++-- ui-opentui/src/view/toolPart.tsx | 8 +- ui-opentui/src/view/tools/registry.tsx | 13 +- 13 files changed, 527 insertions(+), 29 deletions(-) create mode 100644 ui-opentui/src/test/skinLiveOverlay.test.tsx create mode 100644 ui-opentui/src/test/statusLineSpinner.test.tsx diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5320c1990f7..f9e57f71f89 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1393,6 +1393,11 @@ def resolve_skin() -> dict: "banner_hero": skin.banner_hero, "tool_prefix": skin.tool_prefix, "help_header": (skin.branding or {}).get("help_header", ""), + # Native engines (Ink + OpenTUI) can now consume these too: spinner + # animation data (faces/verbs/wings) and per-tool emoji overrides. + # Additive + optional — old engines ignore unknown keys. + "spinner": skin.spinner or {}, + "tool_emojis": skin.tool_emojis or {}, } except Exception: return {} diff --git a/ui-opentui/src/boundary/schema/GatewayEvent.ts b/ui-opentui/src/boundary/schema/GatewayEvent.ts index 1c7d45558b3..7e145c63d6c 100644 --- a/ui-opentui/src/boundary/schema/GatewayEvent.ts +++ b/ui-opentui/src/boundary/schema/GatewayEvent.ts @@ -24,7 +24,12 @@ export const GatewaySkinSchema = Schema.Struct({ branding: opt(Schema.Record(Str, Str)), colors: opt(Schema.Record(Str, Str)), help_header: opt(Str), - tool_prefix: opt(Str) + tool_prefix: opt(Str), + // Spinner animation data (faces/verbs/wings) — mixed array/tuple shapes, kept + // loose at the boundary; the spinner component narrows what it reads. tool_emojis + // is a per-tool glyph override map. Both additive + optional (back-compat). + spinner: opt(Schema.Record(Str, Schema.Unknown)), + tool_emojis: opt(Schema.Record(Str, Str)) }) export type GatewaySkinDecoded = typeof GatewaySkinSchema.Type diff --git a/ui-opentui/src/logic/slash.ts b/ui-opentui/src/logic/slash.ts index a1edd5e7e1b..9fb980547f3 100644 --- a/ui-opentui/src/logic/slash.ts +++ b/ui-opentui/src/logic/slash.ts @@ -252,6 +252,7 @@ const CLIENT_HELP_LINES = [ '/model [name] — switch model (picker if bare)', '/copy [n] — copy the last (or n-th) response', '/skills — browse skills', + '/skin [name] — switch theme skin (live)', '/sessions [cron|gateways|all] — browse/resume sessions (tabbed picker)', '/resume [id|name] — resume directly, or open the picker', '/clear, /new — clear the transcript (confirm)', @@ -626,6 +627,31 @@ const detailsCmd: ClientHandler = async (arg, ctx) => { ctx.pushSystem(`details: ${next}`) } +/** `/skin [name]` — switch the active theme skin (Ink parity: + * ui-tui/src/app/slash/commands/session.ts). Bare `/skin` reports the persisted + * skin (`config.get skin`); `/skin ` persists via `config.set` which makes + * the gateway emit `skin.changed` → the store re-themes the running UI LIVE (no + * relaunch). Skin-name arg completion comes from the gateway's `complete.slash` + * for free. Fire-and-forget with a guarded notice, matching compact/details. */ +const skinCmd: ClientHandler = async (arg, ctx) => { + const name = arg.trim() + if (!name) { + try { + const r = await ctx.request('config.get', { key: 'skin' }) + ctx.pushSystem(`skin: ${readStr(r, 'value') || 'default'}`) + } catch { + ctx.pushSystem('skin: default') + } + return + } + try { + const r = await ctx.request('config.set', { key: 'skin', value: name }) + ctx.pushSystem(`skin → ${readStr(r, 'value') || name}`) + } catch (error) { + ctx.pushSystem(`/skin: ${error instanceof Error ? error.message : 'config.set failed'}`) + } +} + /** Fetch + map the session's archived spawn trees (`spawn_tree.list`). */ async function listSpawnTrees(ctx: SlashContext) { const r = await ctx.request('spawn_tree.list', { limit: 30, session_id: ctx.sessionId() ?? 'default' }) @@ -758,6 +784,7 @@ const CLIENT: Record = { session: sessionsCmd, sessions: sessionsCmd, skills: skillsCmd, + skin: skinCmd, switch: sessionsCmd, tasks: (_arg, ctx) => ctx.openDashboard(), tools: toolsCmd, diff --git a/ui-opentui/src/logic/theme.ts b/ui-opentui/src/logic/theme.ts index 4c22730e13d..ee2861da1e9 100644 --- a/ui-opentui/src/logic/theme.ts +++ b/ui-opentui/src/logic/theme.ts @@ -87,6 +87,10 @@ export interface Theme { brand: ThemeBrand bannerLogo: string bannerHero: string + /** Spinner animation config from the skin (empty = engine defaults). */ + spinner: SpinnerConfig + /** Per-tool glyph overrides from the skin (tool name → glyph); {} = registry defaults. */ + toolEmojis: Record } /** The skin payload as emitted by the gateway (mirror ui-tui/src/gatewayTypes.ts GatewaySkin). */ @@ -97,6 +101,44 @@ export interface GatewaySkin { colors?: Record help_header?: string tool_prefix?: string + /** Spinner animation data (faces/verbs/wings) — loose; SpinnerConfig narrows it. */ + spinner?: Record + /** Per-tool glyph overrides (tool name → emoji/char). */ + tool_emojis?: Record +} + +/** Normalized spinner animation config the busy indicator consumes. Empty arrays + * mean "use the engine defaults" (a skin that ships no spinner block). */ +export interface SpinnerConfig { + waitingFaces: string[] + thinkingFaces: string[] + thinkingVerbs: string[] + /** [left, right] decoration pairs. */ + wings: [string, string][] +} + +const EMPTY_SPINNER: SpinnerConfig = { waitingFaces: [], thinkingFaces: [], thinkingVerbs: [], wings: [] } + +/** Parse the loose gateway `spinner` record into a typed SpinnerConfig (defensive: + * any malformed field → empty, so a bad skin never crashes the spinner). */ +export function parseSpinner(raw: Record | undefined): SpinnerConfig { + if (!raw || typeof raw !== 'object') return EMPTY_SPINNER + const strArr = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []) + const wings: [string, string][] = [] + const rawWings = (raw as { wings?: unknown }).wings + if (Array.isArray(rawWings)) { + for (const pair of rawWings) { + if (Array.isArray(pair) && pair.length === 2 && typeof pair[0] === 'string' && typeof pair[1] === 'string') { + wings.push([pair[0], pair[1]]) + } + } + } + return { + waitingFaces: strArr((raw as { waiting_faces?: unknown }).waiting_faces), + thinkingFaces: strArr((raw as { thinking_faces?: unknown }).thinking_faces), + thinkingVerbs: strArr((raw as { thinking_verbs?: unknown }).thinking_verbs), + wings + } } // ── Color math ─────────────────────────────────────────────────────── @@ -306,7 +348,9 @@ export const DARK_THEME: Theme = { }, brand: BRAND, bannerLogo: '', - bannerHero: '' + bannerHero: '', + spinner: EMPTY_SPINNER, + toolEmojis: {} } export const LIGHT_THEME: Theme = { @@ -351,7 +395,9 @@ export const LIGHT_THEME: Theme = { }, brand: BRAND, bannerLogo: '', - bannerHero: '' + bannerHero: '', + spinner: EMPTY_SPINNER, + toolEmojis: {} } const LIGHT_DEFAULT_TERM_PROGRAMS = new Set(['Apple_Terminal']) @@ -447,7 +493,9 @@ export function fromSkin( bannerLogo = '', bannerHero = '', toolPrefix = '', - helpHeader = '' + helpHeader = '', + spinner: Record | undefined = undefined, + toolEmojis: Record | undefined = undefined ): Theme { const d = DEFAULT_THEME const c = (k: string) => colors[k] @@ -495,12 +543,12 @@ export function fromSkin( sessionLabel: c('session_label') ?? muted, sessionBorder: c('session_border') ?? muted, - statusBg: d.color.statusBg, - statusFg: d.color.statusFg, - statusGood: c('ui_ok') ?? d.color.statusGood, - statusWarn: c('ui_warn') ?? d.color.statusWarn, - statusBad: d.color.statusBad, - statusCritical: d.color.statusCritical, + statusBg: c('status_bar_bg') ?? d.color.statusBg, + statusFg: c('status_bar_text') ?? d.color.statusFg, + statusGood: c('status_bar_good') ?? c('ui_ok') ?? d.color.statusGood, + statusWarn: c('status_bar_warn') ?? c('ui_warn') ?? d.color.statusWarn, + statusBad: c('status_bar_bad') ?? d.color.statusBad, + statusCritical: c('status_bar_critical') ?? d.color.statusCritical, selectionBg: c('selection_bg') ?? c('completion_menu_current_bg') ?? @@ -526,7 +574,9 @@ export function fromSkin( }, bannerLogo, - bannerHero + bannerHero, + spinner: parseSpinner(spinner), + toolEmojis: toolEmojis ?? {} }, process.env, DEFAULT_LIGHT_MODE @@ -542,6 +592,8 @@ export function themeFromSkin(skin: GatewaySkin | undefined): Theme { skin.banner_logo ?? '', skin.banner_hero ?? '', skin.tool_prefix ?? '', - skin.help_header ?? '' + skin.help_header ?? '', + skin.spinner, + skin.tool_emojis ) } diff --git a/ui-opentui/src/test/inkBudget.test.tsx b/ui-opentui/src/test/inkBudget.test.tsx index fa4ddcd1d24..09379c864a8 100644 --- a/ui-opentui/src/test/inkBudget.test.tsx +++ b/ui-opentui/src/test/inkBudget.test.tsx @@ -86,6 +86,7 @@ describe('tool glyph vocabulary (registry) — identity survives the collapsed v skill_manage: '▲', skill_view: '▲', terminal: '$', + todo: '☰', web_extract: '●', web_search: '●', write_file: '◆' @@ -97,6 +98,13 @@ describe('tool glyph vocabulary (registry) — identity survives the collapsed v expect(glyphFor('totally_new_tool')).toBe('◦') expect(glyphFor('terminal')).toBe('$') }) + + test('skin tool_emojis override the default glyph; absent override keeps default', () => { + expect(glyphFor('terminal', { terminal: '⚔' })).toBe('⚔') + expect(glyphFor('read_file', { terminal: '⚔' })).toBe('◇') // not overridden → default + expect(glyphFor('mcp_x', { mcp_x: '🚀' })).toBe('🚀') + expect(glyphFor('terminal', undefined)).toBe('$') // no overrides → default + }) }) // ── 3. messageLine roles (pure) ────────────────────────────────────────── diff --git a/ui-opentui/src/test/skinLiveOverlay.test.tsx b/ui-opentui/src/test/skinLiveOverlay.test.tsx new file mode 100644 index 00000000000..9de34ff90f7 --- /dev/null +++ b/ui-opentui/src/test/skinLiveOverlay.test.tsx @@ -0,0 +1,101 @@ +/** + * Skin live re-theme — overlay/popup coverage. + * + * IMPORTANT (false-confidence caveat, learned from adversarial review): the + * headless test renderer FORCE-FLUSHES every frame (`renderProbe`/`settle` call + * renderOnce+flush), so a `captureSpans()` assertion that "the dropdown bg + * changed after skin.changed" passes EVEN IF the live native-repaint nudge is + * reverted — it proves the reactive DATA pipeline, NOT the live repaint. The + * actual live popup-repaint bug (popups keep old colors until restart) is a + * native-renderer flush issue the headless renderer cannot reproduce. So: + * - test 1 asserts the reactivity pipeline (honest scope: data, not repaint). + * - test 2 asserts real skins drive distinct status/completion backgrounds. + * + * NOTE: the live popup-repaint fix (header.tsx requestRender on skin change) is + * NOT guarded by an automated test — it is fundamentally not headless-testable + * (the windowing frame loop already calls requestRender ~60x/frame, so a spy + * cannot isolate the skin nudge). It needs a live-tmux smoke to verify. + */ +import type { RGBA } from '@opentui/core' +import { describe, expect, test } from 'vitest' + +import { createPromptHistory } from '../logic/history.ts' +import { planCompletion } from '../logic/slash.ts' +import { createSessionStore, type CompletionItem } from '../logic/store.ts' +import { fromSkin as fromSkinReal } from '../logic/theme.ts' +import { App } from '../view/App.tsx' +import { ThemeProvider } from '../view/theme.tsx' +import { renderProbe } from './lib/render.ts' + +const CATALOG: CompletionItem[] = [ + { display: '/clear', meta: 'clear', text: '/clear' }, + { display: '/commit', meta: 'commit', text: '/commit' }, + { display: '/copy', meta: 'copy', text: '/copy' } +] + +function hex(c: RGBA): string { + const h = (n: number) => + Math.round(n * 255) + .toString(16) + .padStart(2, '0') + return `#${h(c.r)}${h(c.g)}${h(c.b)}`.toUpperCase() +} + +async function mount(store: ReturnType) { + const history = createPromptHistory({ initial: [] }) + const onType = (text: string) => { + const plan = planCompletion(text) + if (!plan || plan.method !== 'complete.slash') return store.clearCompletions() + const q = String(plan.params.text).toLowerCase() + const items = CATALOG.filter(c => c.text.startsWith(q) && c.text !== q) + if (items.length) store.setCompletions(items, plan.from) + else store.clearCompletions() + } + return renderProbe( + () => ( + store.state.theme}> + {}} onType={onType} history={history} /> + + ), + { height: 24, kittyKeyboard: true, width: 70 } + ) +} + +describe('skin.changed re-themes the dropdown — reactivity pipeline (NOT live repaint)', () => { + test('dropdown row bg reflects the new completion_menu_bg after skin.changed', async () => { + // Honest scope: proves skin.changed → theme signal → dropdown binding. Does + // NOT prove the live native repaint (the harness force-flushes — see header). + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + const probe = await mount(store) + try { + await probe.keys.typeText('/c') + await probe.settle() + store.apply({ + type: 'skin.changed', + payload: { colors: { completion_menu_bg: '#123456', completion_menu_current_bg: '#654321' } } + }) + await probe.settle() + const bgs = new Set() + for (const line of probe.spans().lines) { + for (const s of line.spans) { + if (/\/c(lear|ommit|opy)/.test(s.text) && s.bg) bgs.add(hex(s.bg)) + } + } + expect(bgs.has('#123456')).toBe(true) + } finally { + probe.destroy() + } + }) +}) + +describe('real skins drive status/completion backgrounds (fix A)', () => { + test('status_bar_bg + completion differ across ares/poseidon (not all #1a1a2e)', () => { + const ares = fromSkinReal({ status_bar_bg: '#2A1212', banner_accent: '#DD4A3A', completion_menu_bg: '#2A1212' }, {}) + const pos = fromSkinReal({ status_bar_bg: '#0F2440', banner_accent: '#5DB8F5', completion_menu_bg: '#0F2440' }, {}) + expect(ares.color.statusBg).toBe('#2A1212') + expect(pos.color.statusBg).toBe('#0F2440') + expect(ares.color.statusBg).not.toBe(pos.color.statusBg) + expect(ares.color.completionBg).not.toBe(pos.color.completionBg) + }) +}) diff --git a/ui-opentui/src/test/slash.test.ts b/ui-opentui/src/test/slash.test.ts index 92abd5e5ea0..5c3bec6a730 100644 --- a/ui-opentui/src/test/slash.test.ts +++ b/ui-opentui/src/test/slash.test.ts @@ -366,6 +366,23 @@ describe('dispatchSlash — client commands', () => { expect(p.system.at(-1)).toContain('usage: /sessions') }) + test('/skin persists via config.set (the gateway then emits skin.changed)', async () => { + const p = makeCtx(async method => (method === 'config.set' ? { key: 'skin', value: 'ares' } : {})) + await dispatchSlash('/skin ares', p.ctx) + const set = p.calls.find(c => c.method === 'config.set') + expect(set).toBeDefined() + expect(set?.params).toMatchObject({ key: 'skin', value: 'ares' }) + expect(p.system.at(-1)).toContain('ares') + }) + + test('/skin bare reports the persisted skin via config.get', async () => { + const p = makeCtx(async method => (method === 'config.get' ? { value: 'poseidon' } : {})) + await dispatchSlash('/skin', p.ctx) + const get = p.calls.find(c => c.method === 'config.get') + expect(get?.params).toMatchObject({ key: 'skin' }) + expect(p.system.at(-1)).toContain('poseidon') + }) + test('/resume keeps the DIRECT path: resolves against session.list and resumes', async () => { const rows = { sessions: [ diff --git a/ui-opentui/src/test/statusLineSpinner.test.tsx b/ui-opentui/src/test/statusLineSpinner.test.tsx new file mode 100644 index 00000000000..47ee8ab0a9d --- /dev/null +++ b/ui-opentui/src/test/statusLineSpinner.test.tsx @@ -0,0 +1,47 @@ +/** + * StatusLine timer-lifecycle invariant (skins-spec §5b gate): the animated busy + * indicator's interval MUST be ARMED only while info.running and CLEARED when it + * flips false / on unmount — never a permanent timer (which would fight the + * windowing poll + defeat idle-GC). Asserted by spying setInterval/clearInterval + * around the store's running flag and checking the armed/cleared balance. + */ +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { createSessionStore } from '../logic/store.ts' +import { StatusLine } from '../view/statusLine.tsx' +import { ThemeProvider } from '../view/theme.tsx' +import { renderProbe, type RenderProbe } from './lib/render.ts' + +describe('StatusLine — spinner timer lifecycle (no permanent timer)', () => { + let probe: RenderProbe | undefined + afterEach(() => { + probe?.destroy() + probe = undefined + vi.restoreAllMocks() + }) + + test('interval armed only while info.running; cleared when it flips false', async () => { + const setSpy = vi.spyOn(globalThis, 'setInterval') + const clearSpy = vi.spyOn(globalThis, 'clearInterval') + const store = createSessionStore() + + probe = await renderProbe(() => ( + store.state.theme}> + + + )) + + const armedAtIdle = setSpy.mock.calls.length // idle: no spinner interval + + store.apply({ type: 'message.start' }) + await probe.settle() + const armedRunning = setSpy.mock.calls.length + expect(armedRunning).toBeGreaterThan(armedAtIdle) // a timer was armed on running + + const clearedBefore = clearSpy.mock.calls.length + store.apply({ type: 'message.complete', payload: { text: 'done' } }) + await probe.settle() + // flipping running false disposes the prior effect-run → clearInterval fires + expect(clearSpy.mock.calls.length).toBeGreaterThan(clearedBefore) + }) +}) diff --git a/ui-opentui/src/test/store.test.ts b/ui-opentui/src/test/store.test.ts index 01ab30c3129..abf1ae4c7bb 100644 --- a/ui-opentui/src/test/store.test.ts +++ b/ui-opentui/src/test/store.test.ts @@ -28,6 +28,42 @@ describe('session store — theming / dedup / hydrate (Phase 1)', () => { expect(store.state.theme.brand.name).toBe('Aurora') }) + test('skin survives /clear and resume (theme is NOT a session-scoped slice)', () => { + const store = createSessionStore() + store.apply({ + type: 'skin.changed', + payload: { branding: { agent_name: 'Aurora' }, colors: { ui_primary: '#abcdef' } } + }) + store.clearTranscript() + expect(store.state.theme.brand.name).toBe('Aurora') + expect(store.state.theme.color.primary).toBe('#abcdef') + // resume path (commitSnapshot) must also preserve the active skin + store.commitSnapshot([]) + expect(store.state.theme.brand.name).toBe('Aurora') + expect(store.state.theme.color.primary).toBe('#abcdef') + }) + + test('skin spinner + tool_emojis flow onto the theme (B wire-up)', () => { + const store = createSessionStore() + store.apply({ + type: 'skin.changed', + payload: { + spinner: { thinking_faces: ['(a)', '(b)'], wings: [['<', '>']], thinking_verbs: ['forging'] }, + tool_emojis: { terminal: '⚔' } + } + }) + expect(store.state.theme.spinner.thinkingFaces).toEqual(['(a)', '(b)']) + expect(store.state.theme.spinner.wings).toEqual([['<', '>']]) + expect(store.state.theme.toolEmojis.terminal).toBe('⚔') + }) + + test('ui_bg sets theme.color.bg; default stays transparent (D root-canvas opt-in)', () => { + const store = createSessionStore() + expect(store.state.theme.color.bg).toBe('transparent') + store.apply({ type: 'skin.changed', payload: { colors: { ui_bg: '#0A0A0A' } } }) + expect(store.state.theme.color.bg).toBe('#0A0A0A') + }) + test('LRU dedup: duplicate(id) returns false once, true after', () => { const store = createSessionStore() expect(store.duplicate('evt-1')).toBe(false) @@ -652,3 +688,82 @@ describe('session store — rolling message cap (bounds the Yoga node high-water expect(store.state.dropped).toBe(0) }) }) + +describe('session store — todo panel snapshot + draft + /new info reset', () => { + const todoComplete = ( + todos: Array<{ id: string; content: string; status: string }>, + summary?: Record + ) => + ({ + type: 'tool.complete', + payload: { + tool_id: 't1', + name: 'todo', + args: { todos }, + result: { todos, ...(summary ? { summary } : {}) }, + duration_s: 0 + } + }) as never + + test('captures latestTodos from a todo tool.complete (result.todos)', () => { + const store = createSessionStore() + store.apply( + todoComplete( + [ + { id: '0', content: 'a', status: 'completed' }, + { id: '1', content: 'b', status: 'in_progress' }, + { id: '2', content: 'c', status: 'pending' } + ], + { completed: 1, in_progress: 1, pending: 1, cancelled: 0 } + ) + ) + const snap = store.state.latestTodos + expect(snap).toBeDefined() + expect(snap?.todos).toHaveLength(3) + // list order is preserved (priority) — never re-sorted + expect(snap?.todos.map(t => t.content)).toEqual(['a', 'b', 'c']) + expect(snap?.counts).toEqual({ total: 3, completed: 1, in_progress: 1, pending: 1, cancelled: 0 }) + }) + + test('a malformed/empty todo call does not clobber a good prior snapshot', () => { + const store = createSessionStore() + store.apply(todoComplete([{ id: '0', content: 'keep', status: 'pending' }])) + expect(store.state.latestTodos?.todos).toHaveLength(1) + store.apply(todoComplete([])) + expect(store.state.latestTodos?.todos).toEqual([{ content: 'keep', status: 'pending' }]) + }) + + test('latestTodos clears on clearTranscript (/new starts a fresh plan)', () => { + const store = createSessionStore() + store.apply(todoComplete([{ id: '0', content: 'x', status: 'pending' }])) + expect(store.state.latestTodos).toBeDefined() + store.clearTranscript() + expect(store.state.latestTodos).toBeUndefined() + }) + + test('composerDraft persists via setComposerDraft', () => { + const store = createSessionStore() + expect(store.state.composerDraft).toBe('') + store.setComposerDraft('half-typed message') + expect(store.state.composerDraft).toBe('half-typed message') + store.setComposerDraft('') + expect(store.state.composerDraft).toBe('') + }) + + test('clearTranscript zeroes the usage gauges but keeps session identity', () => { + const store = createSessionStore() + store.applyInfo({ + model: 'm', + cwd: '/x', + usage: { context_used: 84000, context_percent: 42, cost_usd: 0.5, compressions: 2 } + } as never) + expect(store.state.info.contextUsed).toBe(84000) + store.clearTranscript() + expect(store.state.info.contextUsed).toBeUndefined() + expect(store.state.info.contextPercent).toBeUndefined() + expect(store.state.info.costUsd).toBeUndefined() + expect(store.state.info.compressions).toBeUndefined() + expect(store.state.info.model).toBe('m') + expect(store.state.info.cwd).toBe('/x') + }) +}) diff --git a/ui-opentui/src/view/composer.tsx b/ui-opentui/src/view/composer.tsx index 2d69c24119c..c8cc603152e 100644 --- a/ui-opentui/src/view/composer.tsx +++ b/ui-opentui/src/view/composer.tsx @@ -150,6 +150,12 @@ export function Composer(props: { * parent opens the session prompt-history viewer (or does nothing when the * session has no prompts yet — never an empty modal). */ onDoubleEsc?: (() => void) | undefined + /** The persisted draft to seed the buffer with on mount (survives the + * composer unmounting when a blocking prompt replaces it). */ + initialDraft?: (() => string) | undefined + /** Called with the live draft text on every edit (persist) and '' on submit + * (clear) — lets the parent stash it so it outlives a composer unmount. */ + onDraftChange?: ((text: string) => void) | undefined }) { const theme = useTheme() const dims = useDimensions() @@ -232,6 +238,36 @@ export function Composer(props: { syntax = undefined } }) + // Live re-theme: re-register the slash-token color when the skin changes so the + // composer's highlight repaints without waiting for a remount (skin.changed → + // theme() → here). registerStyle on the same name updates the style in place; + // the highlight createEffect below re-tracks `theme()` so ranges re-apply. + createEffect(() => { + const accent = theme().color.accent + if (!syntax) return + try { + tokenStyleId = syntax.registerStyle('slash-token', { bold: true, fg: accent }) + } catch { + /* cosmetic — never crash on a native restyle */ + } + }) + // Live re-theme of the native