opentui(v6): skins/theming parity — live /skin switch, animated spinner, tool_emojis, status-bar colors

- 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 <name> 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
This commit is contained in:
alt-glitch 2026-06-15 15:57:57 +05:30
parent 1ddf7a1021
commit b0fb2b8b05
13 changed files with 527 additions and 29 deletions

View file

@ -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 {}

View file

@ -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

View file

@ -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 <name>` 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<string, ClientHandler> = {
session: sessionsCmd,
sessions: sessionsCmd,
skills: skillsCmd,
skin: skinCmd,
switch: sessionsCmd,
tasks: (_arg, ctx) => ctx.openDashboard(),
tools: toolsCmd,

View file

@ -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<string, string>
}
/** The skin payload as emitted by the gateway (mirror ui-tui/src/gatewayTypes.ts GatewaySkin). */
@ -97,6 +101,44 @@ export interface GatewaySkin {
colors?: Record<string, string>
help_header?: string
tool_prefix?: string
/** Spinner animation data (faces/verbs/wings) — loose; SpinnerConfig narrows it. */
spinner?: Record<string, unknown>
/** Per-tool glyph overrides (tool name → emoji/char). */
tool_emojis?: Record<string, string>
}
/** 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<string, unknown> | 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<string>(['Apple_Terminal'])
@ -447,7 +493,9 @@ export function fromSkin(
bannerLogo = '',
bannerHero = '',
toolPrefix = '',
helpHeader = ''
helpHeader = '',
spinner: Record<string, unknown> | undefined = undefined,
toolEmojis: Record<string, string> | 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
)
}

View file

@ -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) ──────────────────────────────────────────

View file

@ -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<typeof createSessionStore>) {
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(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} onSubmit={() => {}} onType={onType} history={history} />
</ThemeProvider>
),
{ 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<string>()
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)
})
})

View file

@ -366,6 +366,23 @@ describe('dispatchSlash — client commands', () => {
expect(p.system.at(-1)).toContain('usage: /sessions')
})
test('/skin <name> 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 <id|name> keeps the DIRECT path: resolves against session.list and resumes', async () => {
const rows = {
sessions: [

View file

@ -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(() => (
<ThemeProvider theme={() => store.state.theme}>
<StatusLine store={store} />
</ThemeProvider>
))
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)
})
})

View file

@ -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<string, number>
) =>
({
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')
})
})

View file

@ -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 <textarea>: the uncontrolled TextareaRenderable
// caches its color props, so a reactive skin change (skin.changed → theme())
// does NOT recolor the input or the text being typed until remount. Push the
// colors imperatively onto the ref each time the theme changes (the native
// setters call editBuffer.setDefaultFg + requestRender, so existing text
// recolors too). placeholderColor has no native setter, so it can only update
// on remount — accepted (the placeholder is only visible when empty/unfocused).
createEffect(() => {
const t = theme().color
if (!ta || ta.isDestroyed) return
try {
ta.textColor = t.text
ta.cursorColor = t.accent
} catch {
/* cosmetic — never crash on a native restyle */
}
})
onCleanup(() => {
try {
if (ta && !ta.isDestroyed) ta.syntaxStyle = null
@ -243,6 +279,7 @@ export function Composer(props: {
})
createEffect(() => {
const a = analysis()
void theme().color.accent // re-track: re-apply highlights after a live re-theme
if (!ta || !syntax || ta.isDestroyed) return
try {
const text = bufText()
@ -311,6 +348,7 @@ export function Composer(props: {
props.onSubmit(text)
props.history?.push(text)
ta.clear()
props.onDraftChange?.('') // submitted → drop the persisted draft (explicit; don't rely on clear()'s onContentChange)
props.pasteStore?.clear()
props.onDismiss?.()
submitting = false
@ -480,6 +518,13 @@ export function Composer(props: {
})
onMount(() => {
// Restore a persisted draft (e.g. typed before a clarify prompt replaced
// the composer in the <Switch>, which unmounts it + its native buffer).
const draft = props.initialDraft?.() ?? ''
if (draft) {
setBuffer(draft)
setBufText(draft)
}
ta?.focus()
props.registerFocus?.(() => ta?.focus())
})
@ -587,6 +632,7 @@ export function Composer(props: {
onContentChange={() => {
const text = ta?.plainText ?? ''
setBufText(text) // drives the token analysis (highlight + suggestion)
props.onDraftChange?.(text) // persist so it survives a clarify-prompt unmount
syncCursorLine()
props.onType?.(text, ta?.cursorOffset ?? text.length)
}}

View file

@ -3,29 +3,91 @@
* Shows EITHER:
* - a `hint` (e.g. "Ctrl+C again to quit" item 11), in the warn colour and
* taking priority; or
* - the kaomoji busy face/verb from `thinking.delta`/`status.update` WHILE a
* turn runs (Ink's FaceTicker), dim, cleared on `message.complete`.
* - an ANIMATED busy indicator (kaomoji face + optional wings, cycling) plus
* the verb from `thinking.delta`/`status.update` WHILE a turn runs, dim,
* cleared on `message.complete`. Faces/verbs/wings come from the active skin
* (`theme.spinner`); empty skin data falls back to the engine defaults below.
* This keeps those transient indicators OUT of the transcript. Renders nothing
* when both are idle.
*
* ARCHITECTURE (per the skins-spec adversarial gate): the animation is a BOUNDED
* `setInterval` ARMED only while `info.running` and CLEARED on stop/cleanup
* NOT a `setFrameCallback` (the lone permanent frame callback is the windowing
* poll; a second per-frame timer would fight it and defeat idle-GC). The tick
* rate is ~10fps, far below a render-loop cadence, and the timer never runs while
* idle, so it adds zero idle cost.
*/
import { Show } from 'solid-js'
import { createEffect, createSignal, onCleanup, Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { useTheme } from './theme.tsx'
// Engine-default spinner faces (used when the skin ships no `spinner` block).
const DEFAULT_FACES = ['(·)', '(•)', '(◦)', '(•)']
const TICK_MS = 100 // ~10fps — well below the render loop; never runs while idle.
export function StatusLine(props: { store: SessionStore }) {
const theme = useTheme()
const line = () => props.store.state.hint ?? props.store.state.status
const isHint = () => props.store.state.hint !== undefined
const verb = () => props.store.state.status
const running = () => props.store.state.info.running === true && props.store.state.hint === undefined
// Animation frame counter — only advances while a turn runs.
const [frame, setFrame] = createSignal(0)
createEffect(() => {
if (!running()) return // disarmed when idle — no timer, no cost.
const id = setInterval(() => setFrame(f => f + 1), TICK_MS)
onCleanup(() => clearInterval(id))
})
// Skin faces/wings (theme.spinner) with engine-default fallback.
const faces = () => {
const f = theme().spinner.thinkingFaces
return f.length ? f : DEFAULT_FACES
}
const wings = () => theme().spinner.wings
const face = () => {
const fs = faces()
return fs[frame() % fs.length] ?? DEFAULT_FACES[0]
}
const wing = () => {
const w = wings()
return w.length ? w[frame() % w.length] : undefined
}
// The animated busy prefix: optional left-wing + face (+ right-wing).
const busyPrefix = () => {
const w = wing()
return w ? `${w[0]} ${face()} ${w[1]}` : face()
}
const hintText = () => props.store.state.hint
// Three display states, in priority order:
// running → animated face (+ verb if present), accent
// hint → the hint text, warn
// idle → nothing
return (
<Show when={line()}>
{text => (
<box style={{ flexShrink: 0 }}>
<text selectable={false}>
<span style={{ fg: isHint() ? theme().color.warn : theme().color.muted }}>{text()}</span>
</text>
</box>
)}
<Show
when={running()}
fallback={
<Show when={hintText()}>
{text => (
<box style={{ flexShrink: 0 }}>
<text selectable={false}>
<span style={{ fg: theme().color.warn }}>{text()}</span>
</text>
</box>
)}
</Show>
}
>
<box style={{ flexShrink: 0 }}>
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{busyPrefix()}</span>
<Show when={verb()}>
<span style={{ fg: theme().color.muted }}>{` ${verb()}`}</span>
</Show>
</text>
</box>
</Show>
)
}

View file

@ -128,7 +128,13 @@ export function ToolPart(props: { part: ToolPartState }) {
// `error` only lands on tool.complete, so running stays ⚡.
const failed = () => !running() && Boolean(props.part.error)
const headGlyph = () =>
failed() ? '✗' : running() ? '⚡' : collapsible() && expanded() ? '▼' : glyphFor(props.part.name)
failed()
? '✗'
: running()
? '⚡'
: collapsible() && expanded()
? '▼'
: glyphFor(props.part.name, theme().toolEmojis)
// Settled machinery is BLUE (`shellDollar` — "blue is the hum of machinery");
// running is accent heat; failed is error. The NAME is muted-bright bold (see
// toolNameStyle); subtitle/metadata stay muted grey — the machinery tier

View file

@ -25,6 +25,7 @@ import { fileRenderer } from './fileTool.tsx'
import { readRenderer } from './readTool.tsx'
import { searchRenderer } from './searchTool.tsx'
import { skillRenderer } from './skillTool.tsx'
import { todoRenderer } from './todoTool.tsx'
/** Props every tool Body receives: the part + usable content columns. */
export interface ToolBodyProps {
@ -71,6 +72,7 @@ export const TOOL_GLYPHS: Record<string, string> = {
skill_manage: '▲',
skill_view: '▲',
terminal: '$',
todo: '☰',
web_extract: '●',
web_search: '●',
write_file: '◆'
@ -79,9 +81,11 @@ export const TOOL_GLYPHS: Record<string, string> = {
/** Fallback glyph for MCP/unmapped tools. */
export const DEFAULT_TOOL_GLYPH = '◦'
/** The settled head glyph for a tool name (vocabulary survives the default view). */
export function glyphFor(name: string): string {
return TOOL_GLYPHS[name] ?? DEFAULT_TOOL_GLYPH
/** The settled head glyph for a tool name (vocabulary survives the default view).
* Skin `tool_emojis` overrides win over the built-in vocabulary, then the
* per-tool default, then the MCP/unknown fallback. */
export function glyphFor(name: string, overrides?: Record<string, string>): string {
return overrides?.[name] ?? TOOL_GLYPHS[name] ?? DEFAULT_TOOL_GLYPH
}
const TOOLS: Record<string, ToolRenderer> = {
@ -110,6 +114,9 @@ const TOOLS: Record<string, ToolRenderer> = {
// skill_view (item 5): WHICH skill was loaded (+ one-line description) —
// never the full skill contents.
skill_view: skillRenderer,
// todo (panel-primary): collapsed = "N tasks · …" summary; expanded = clean
// checklist. The LIVE list is the pinned TodoPanel above the composer.
todo: todoRenderer,
write_file: fileRenderer
}