mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
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:
parent
1ddf7a1021
commit
b0fb2b8b05
13 changed files with 527 additions and 29 deletions
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ──────────────────────────────────────────
|
||||
|
|
|
|||
101
ui-opentui/src/test/skinLiveOverlay.test.tsx
Normal file
101
ui-opentui/src/test/skinLiveOverlay.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
47
ui-opentui/src/test/statusLineSpinner.test.tsx
Normal file
47
ui-opentui/src/test/statusLineSpinner.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue