From 646aa39818202cc52cf7762a5906c2266a709f26 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 19:20:55 -0400 Subject: [PATCH 01/11] feat(tui): state-tinted tool cards, session status bar, mode borders, cosine shimmer Port four signature visual elements: tool-call blocks carry a full-width background tint keyed to their state; a one-line status bar above the editor shows model, effort, and mode chips joined by a session-accent rule; the editor border colors yolo and auto permission modes; and the shimmer sweep uses a cosine band at constant velocity. --- .changeset/tui-signature-design.md | 5 + .../src/tui/components/chrome/status-bar.ts | 93 +++++++++++++++ .../src/tui/components/messages/tool-call.ts | 16 ++- apps/pythinker-code/src/tui/pythinker-tui.ts | 18 ++- apps/pythinker-code/src/tui/theme/colors.ts | 12 ++ .../src/tui/theme/theme-schema.json | 3 + apps/pythinker-code/src/tui/tui-state.ts | 8 ++ .../src/tui/utils/session-accent.ts | 30 +++++ apps/pythinker-code/src/tui/utils/shimmer.ts | 46 +++---- .../dynamic-workflow-mission-control.test.ts | 8 +- .../tui/components/messages/tool-call.test.ts | 39 ++++++ .../test/tui/components/status-bar.test.ts | 112 ++++++++++++++++++ .../tui/pythinker-tui-message-flow.test.ts | 5 +- .../test/tui/pythinker-tui-startup.test.ts | 5 +- .../test/tui/theme/palette.test.ts | 5 +- .../test/tui/utils/session-accent.test.ts | 28 +++++ docs/customization/themes.md | 3 + .../src/skill/builtin/custom-theme.md | 3 + 18 files changed, 411 insertions(+), 28 deletions(-) create mode 100644 .changeset/tui-signature-design.md create mode 100644 apps/pythinker-code/src/tui/components/chrome/status-bar.ts create mode 100644 apps/pythinker-code/src/tui/utils/session-accent.ts create mode 100644 apps/pythinker-code/test/tui/components/status-bar.test.ts create mode 100644 apps/pythinker-code/test/tui/utils/session-accent.test.ts diff --git a/.changeset/tui-signature-design.md b/.changeset/tui-signature-design.md new file mode 100644 index 00000000..c4342fa9 --- /dev/null +++ b/.changeset/tui-signature-design.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": minor +--- + +Redesign core TUI surfaces: tool cards get state-tinted backgrounds (running, success, error — three new theme tokens), a status bar with a per-session accent color appears above the input box, the input border reflects yolo and auto permission modes, and the working-label shimmer uses a smoother constant-velocity sweep. diff --git a/apps/pythinker-code/src/tui/components/chrome/status-bar.ts b/apps/pythinker-code/src/tui/components/chrome/status-bar.ts new file mode 100644 index 00000000..ff68d28a --- /dev/null +++ b/apps/pythinker-code/src/tui/components/chrome/status-bar.ts @@ -0,0 +1,93 @@ +import { sep } from 'node:path'; + +import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import type { FooterStatus } from '#/tui/runtime/footer/footer-model'; +import { currentTheme } from '#/tui/theme'; +import { themeFromHexChannels } from '#/tui/theme/terminal-background'; +import { effortColorToken, shortEffortLabel } from '#/tui/utils/thinking-levels'; +import { sessionAccentHex } from '#/tui/utils/session-accent'; + +export type StatusBarStatus = Pick< + FooterStatus, + | 'model' + | 'thinkingLevel' + | 'cwd' + | 'homeDir' + | 'permissionMode' + | 'planMode' + | 'dynamicWorkflowMode' +> & { readonly sessionKey: string }; + +export class StatusBarComponent implements Component { + private status: StatusBarStatus | undefined; + + update(status: StatusBarStatus): void { + this.status = status; + } + + render(width: number): string[] { + const status = this.status; + if (status === undefined) return []; + + const modelChip = chip( + `${currentTheme.fg('text', status.model)}${currentTheme.fg('textDim', ' · ')}${currentTheme.fg( + effortColorToken(status.thinkingLevel), + shortEffortLabel(status.thinkingLevel), + )}`, + ); + let modesChip = renderModesChip(status); + let cwdChip: string | undefined = chip( + currentTheme.fg('textDim', shortenCwd(status.cwd, status.homeDir)), + ); + const left = (): string => `${modelChip}${modesChip === undefined ? '' : ` ${modesChip}`}`; + const fullGapWidth = + width - visibleWidth(left()) - (cwdChip === undefined ? 1 : visibleWidth(cwdChip) + 2); + + let line: string; + if (fullGapWidth > 0) { + const background = currentTheme.color('background'); + const mode = themeFromHexChannels( + background.slice(1, 3), + background.slice(3, 5), + background.slice(5, 7), + ); + const gap = chalk.hex(sessionAccentHex(status.sessionKey, mode))('─'.repeat(fullGapWidth)); + line = cwdChip === undefined ? `${left()} ${gap}` : `${left()} ${gap} ${cwdChip}`; + } else { + line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`; + if (visibleWidth(line) > width && modesChip !== undefined) { + modesChip = undefined; + line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`; + } + if (visibleWidth(line) > width && cwdChip !== undefined) { + cwdChip = undefined; + line = left(); + } + } + + return [truncateToWidth(line, Math.max(0, width))]; + } + + invalidate(): void {} +} + +function chip(content: string): string { + return currentTheme.bg('surfaceHighlight', ` ${content} `); +} + +function renderModesChip(status: StatusBarStatus): string | undefined { + const modes: string[] = []; + if (status.planMode) modes.push(currentTheme.fg('modePlan', 'plan')); + if (status.permissionMode === 'auto') modes.push(currentTheme.fg('modePermission', 'auto')); + if (status.permissionMode === 'yolo') modes.push(currentTheme.fg('modeAutoAccept', 'yolo')); + if (status.dynamicWorkflowMode) modes.push(currentTheme.fg('modeFast', 'workflow')); + return modes.length === 0 ? undefined : chip(modes.join(' ')); +} + +function shortenCwd(cwd: string, homeDir: string | null): string { + if (homeDir === null || homeDir.length === 0) return cwd; + if (cwd === homeDir) return '~'; + return cwd.startsWith(`${homeDir}${sep}`) ? `~${cwd.slice(homeDir.length)}` : cwd; +} diff --git a/apps/pythinker-code/src/tui/components/messages/tool-call.ts b/apps/pythinker-code/src/tui/components/messages/tool-call.ts index ed3a16af..d0f1b18e 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-call.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-call.ts @@ -658,7 +658,21 @@ export class ToolCallComponent extends Container { override render(width: number): string[] { this.headerText.setText(truncateToWidth(this.buildHeader(), Math.max(0, width))); - return super.render(width); + const lines = super.render(width); + const background = + this.result === undefined && this.toolCall.truncated !== true + ? 'toolPendingBg' + : this.result !== undefined && this.result.is_error !== true + ? 'toolSuccessBg' + : 'toolErrorBg'; + return lines.map((line, index) => + index === 0 + ? line + : currentTheme.bg( + background, + `${line}${' '.repeat(Math.max(0, width - visibleWidth(line)))}`, + ), + ); } setExpanded(expanded: boolean): void { diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index 63b44b9c..bc288822 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -964,6 +964,7 @@ export class PythinkerTUI { ui.addChild(this.state.queueContainer); ui.addChild(this.state.btwPanelContainer); ui.addChild(this.state.mcpStatusContainer); + ui.addChild(this.state.statusBarContainer); ui.addChild(this.state.editorContainer); // Footer is mounted later (mountFooter), not here. } @@ -974,6 +975,8 @@ export class PythinkerTUI { // only once init() succeeds. FooterComponent isn't a Container, so wrap it to // pick up the same outer gutter as the panels above. private mountFooter(): void { + this.state.statusBarContainer.clear(); + this.state.statusBarContainer.addChild(this.state.statusBar); if (this.state.layout === 'fixed') { this.state.layoutRoot.setFooterMounted(true); return; @@ -1333,7 +1336,7 @@ export class PythinkerTUI { if (!hasPatchChanges(this.state.appState, patch)) return; const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch; Object.assign(this.state.appState, patch); - if ('planMode' in patch) this.updateEditorBorderHighlight(); + if ('planMode' in patch || 'permissionMode' in patch) this.updateEditorBorderHighlight(); this.state.footer.syncAppState(this.state.appState); this.syncFooterState(); this.updateActivityPane(); @@ -1391,6 +1394,13 @@ export class PythinkerTUI { this.state.appState.statusLine, ), ); + this.state.statusBar.update({ + ...this.state.footerState.status, + sessionKey: + this.state.appState.sessionTitle?.trim() || + this.state.appState.sessionId || + this.state.appState.workDir, + }); } private footerGoal(): FooterGoal | null { @@ -2115,6 +2125,12 @@ export class PythinkerTUI { // recolors the prompt box on the next render without re-wiring the closure. this.state.editor.borderColor = (s: string) => { if (highlighted) return currentTheme.fg('primary', s); + if (this.state.appState.permissionMode === 'yolo') { + return currentTheme.fg('modeAutoAccept', s); + } + if (this.state.appState.permissionMode === 'auto') { + return currentTheme.fg('modePermission', s); + } const level = this.state.appState.thinkingLevel; if (level === 'off' || level.trim().length === 0) return currentTheme.fg('border', s); return currentTheme.fg(effortColorToken(level), s); diff --git a/apps/pythinker-code/src/tui/theme/colors.ts b/apps/pythinker-code/src/tui/theme/colors.ts index b8425402..82a89505 100644 --- a/apps/pythinker-code/src/tui/theme/colors.ts +++ b/apps/pythinker-code/src/tui/theme/colors.ts @@ -164,6 +164,12 @@ export interface ColorPalette { selectionBg: string; /** Subtle fill for highlighted rows and message surfaces, including user transcript rows. */ surfaceHighlight: string; + /** Background tint for a tool card while the call is running. */ + toolPendingBg: string; + /** Background tint for a tool card after a successful result. */ + toolSuccessBg: string; + /** Background tint for a tool card after an error result. */ + toolErrorBg: string; // ── Progress ── /** Filled segment of the Dynamic Workflow aggregate progress line. */ @@ -242,6 +248,9 @@ export const darkColors: ColorPalette = { inverseText: '#FFFFFF', selectionBg: '#344274', surfaceHighlight: '#1C2238', + toolPendingBg: '#1D2129', + toolSuccessBg: '#14171B', + toolErrorBg: '#291D1D', progressFill: '#25764A', progressHead: '#4EC87E', @@ -316,6 +325,9 @@ export const lightColors: ColorPalette = { inverseText: '#0B1020', selectionBg: '#C9D1FA', surfaceHighlight: '#E8EBFC', + toolPendingBg: '#E8EEF7', + toolSuccessBg: '#F1F3F5', + toolErrorBg: '#F9E9E9', progressFill: '#3B9A65', progressHead: '#0E7A38', diff --git a/apps/pythinker-code/src/tui/theme/theme-schema.json b/apps/pythinker-code/src/tui/theme/theme-schema.json index 3cb1288d..b4c0c2bc 100644 --- a/apps/pythinker-code/src/tui/theme/theme-schema.json +++ b/apps/pythinker-code/src/tui/theme/theme-schema.json @@ -82,6 +82,9 @@ "inverseText": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Foreground for active `/model` provider and `AskUserQuestion` tabs; pair with `selectionBg` at 4.5:1 contrast or higher." }, "selectionBg": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Background for active `/model` provider and `AskUserQuestion` tabs; pair with `inverseText` at 4.5:1 contrast or higher." }, "surfaceHighlight": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Highlighted row and message fill" }, + "toolPendingBg": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Background tint for a running tool card" }, + "toolSuccessBg": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Background tint for a successful tool card" }, + "toolErrorBg": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Background tint for a failed tool card" }, "progressFill": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Filled segment of the Dynamic Workflow aggregate progress line." }, "progressHead": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Static head of the Dynamic Workflow aggregate progress track." }, "progressEmpty": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Empty segment of the Dynamic Workflow aggregate progress line." } diff --git a/apps/pythinker-code/src/tui/tui-state.ts b/apps/pythinker-code/src/tui/tui-state.ts index 2e5c9618..e942924d 100644 --- a/apps/pythinker-code/src/tui/tui-state.ts +++ b/apps/pythinker-code/src/tui/tui-state.ts @@ -7,6 +7,7 @@ import { import { FooterComponent } from './components/chrome/footer'; import { GutterContainer } from './components/chrome/gutter-container'; import type { ActivityLoader } from './components/chrome/activity-loader'; +import { StatusBarComponent } from './components/chrome/status-bar'; import { TodoPanelComponent } from './components/chrome/todo-panel'; import { TranscriptViewport } from './components/chrome/transcript-viewport'; import { ViewportLayoutRoot } from './components/chrome/viewport-layout'; @@ -43,6 +44,8 @@ export interface TUIState { queueContainer: Container; btwPanelContainer: Container; mcpStatusContainer: Container; + statusBarContainer: Container; + statusBar: StatusBarComponent; editorContainer: Container; footer: FooterComponent; footerState: FooterState; @@ -90,6 +93,8 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState { const queueContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const btwPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const mcpStatusContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const statusBarContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const statusBar = new StatusBarComponent(); const editorContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const editor = new CustomEditor(ui); const footer = new FooterComponent({ ...initialAppState }, () => { @@ -109,6 +114,7 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState { queueContainer, btwPanelContainer, mcpStatusContainer, + statusBarContainer, editorContainer, ], footerWrap, @@ -129,6 +135,8 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState { queueContainer, btwPanelContainer, mcpStatusContainer, + statusBarContainer, + statusBar, editorContainer, editor, footer, diff --git a/apps/pythinker-code/src/tui/utils/session-accent.ts b/apps/pythinker-code/src/tui/utils/session-accent.ts new file mode 100644 index 00000000..bd111501 --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/session-accent.ts @@ -0,0 +1,30 @@ +/** Stable accent color for a session key (title or id). */ +export function sessionAccentHex(key: string, mode: 'dark' | 'light'): string { + let hash = 5381; + for (let index = 0; index < key.length; index++) { + hash = Math.imul(hash, 33) + key.codePointAt(index); + } + + return hslToHex((hash >>> 0) % 360, 0.9, mode === 'dark' ? 0.72 : 0.42); +} + +function hslToHex(hue: number, saturation: number, lightness: number): string { + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1)); + const match = lightness - chroma / 2; + const [red, green, blue] = + hue < 60 + ? [chroma, x, 0] + : hue < 120 + ? [x, chroma, 0] + : hue < 180 + ? [0, chroma, x] + : hue < 240 + ? [0, x, chroma] + : hue < 300 + ? [x, 0, chroma] + : [chroma, 0, x]; + return `#${[red, green, blue] + .map((channel) => Math.round((channel + match) * 255).toString(16).padStart(2, '0')) + .join('')}`.toUpperCase(); +} diff --git a/apps/pythinker-code/src/tui/utils/shimmer.ts b/apps/pythinker-code/src/tui/utils/shimmer.ts index 916dd15d..2aec475f 100644 --- a/apps/pythinker-code/src/tui/utils/shimmer.ts +++ b/apps/pythinker-code/src/tui/utils/shimmer.ts @@ -8,52 +8,58 @@ export interface ShimmerTextOptions { phaseOffset?: number; } -const MIN_WINDOW_SIZE = 2; -const MAX_WINDOW_SIZE = 6; +const CELLS_PER_SECOND = 30; +const BAND_HALF_WIDTH = 6; -function resolveWindowSize(length: number, requested?: number): number { - if (length <= 0) return 0; - const fallback = Math.max(MIN_WINDOW_SIZE, Math.min(MAX_WINDOW_SIZE, Math.ceil(length / 3))); - return Math.max(1, Math.min(length, requested ?? fallback)); -} +type ShimmerTier = 'dim' | 'base' | 'shimmer'; export function shimmerText(text: string, options: ShimmerTextOptions): string { const chars = Array.from(text); if (chars.length === 0) return ''; - const windowSize = resolveWindowSize(chars.length, options.windowSize); - const cycleLength = chars.length + windowSize; - const start = ((options.frame + (options.phaseOffset ?? 0)) % cycleLength) - windowSize; - const end = start + windowSize; + const halfWidth = Math.max(1, options.windowSize ?? BAND_HALF_WIDTH); + const cycleLength = chars.length + halfWidth * 2; + const center = + ((Date.now() / 1_000 * CELLS_PER_SECOND + (options.phaseOffset ?? 0)) % cycleLength) - + halfWidth; let result = ''; let segment = ''; - let activeToken: ColorToken | undefined; + let activeTier: ShimmerTier | undefined; for (let index = 0; index < chars.length; index++) { const char = chars[index]; if (char === undefined) continue; - const token = index >= start && index < end ? options.shimmerToken : options.baseToken; - if (activeToken === undefined) { - activeToken = token; + const distance = Math.abs(index - center); + const intensity = + distance >= halfWidth ? 0 : (Math.cos(Math.PI * distance / halfWidth) + 1) / 2; + const tier: ShimmerTier = intensity < 0.22 ? 'dim' : intensity < 0.65 ? 'base' : 'shimmer'; + if (activeTier === undefined) { + activeTier = tier; segment = char; continue; } - if (token === activeToken) { + if (tier === activeTier) { segment += char; continue; } - result += currentTheme.fg(activeToken, segment); - activeToken = token; + result += paintTier(activeTier, segment, options); + activeTier = tier; segment = char; } - if (activeToken !== undefined) { - result += currentTheme.fg(activeToken, segment); + if (activeTier !== undefined) { + result += paintTier(activeTier, segment, options); } return result; } + +function paintTier(tier: ShimmerTier, text: string, options: ShimmerTextOptions): string { + if (tier === 'dim') return currentTheme.fg('textDim', text); + if (tier === 'shimmer') return currentTheme.boldFg(options.shimmerToken, text); + return currentTheme.fg(options.baseToken, text); +} diff --git a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts index be2764dc..915eceb1 100644 --- a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts @@ -383,7 +383,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { } }); - it('colours running progress and Orchestrating in periwinkle', () => { + it('colours running progress and gives Orchestrating three shimmer tiers', () => { vi.useFakeTimers(); vi.setSystemTime(0); const previousLevel = chalk.level; @@ -417,8 +417,10 @@ describe('DynamicWorkflowMissionControlComponent', () => { vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS); const aggregate = aggregateLine(component.render(100).join('\n')); - expect(aggregate).toContain(chalk.hex(darkColors.primary)('rchestrating')); - expect(aggregate).not.toContain(chalk.hex(darkColors.text)('rchestrating')); + expect(strip(aggregate)).toContain('Orchestrating'); + expect(aggregate).toContain(chalk.hex(darkColors.primaryShimmer).bold('O')); + expect(aggregate).toContain(chalk.hex(darkColors.primary)('r')); + expect(aggregate).toContain(chalk.hex(darkColors.textDim)('chestrating')); } finally { vi.useRealTimers(); chalk.level = previousLevel; diff --git a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts index 7e28a357..a2919fae 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts @@ -49,6 +49,45 @@ describe('ToolCallComponent', () => { expect(out).not.toContain(`${String.fromCodePoint(0x23fa, 0xfe0e)} Used Read`); }); + it('tints the tool card for pending, successful, and failed states', () => { + const previousLevel = chalk.level; + chalk.level = 3; + const component = new ToolCallComponent( + { + id: 'call_tint', + name: 'Read', + args: { path: 'foo.ts' }, + }, + undefined, + ); + + try { + const pending = component.render(40); + expect(pending[0]).not.toContain('\u001B[48;2;29;33;41m'); + expect( + pending.slice(1).every((line) => line.includes('\u001B[48;2;29;33;41m')), + ).toBe(true); + + component.setResult({ tool_call_id: 'call_tint', output: 'content', is_error: false }); + expect( + component + .render(40) + .slice(1) + .every((line) => line.includes('\u001B[48;2;20;23;27m')), + ).toBe(true); + + component.setResult({ tool_call_id: 'call_tint', output: 'failed', is_error: true }); + expect( + component + .render(40) + .slice(1) + .every((line) => line.includes('\u001B[48;2;41;29;29m')), + ).toBe(true); + } finally { + chalk.level = previousLevel; + } + }); + it('renders MCP resource tools with friendly labels, context, and counts', () => { const list = new ToolCallComponent( { diff --git a/apps/pythinker-code/test/tui/components/status-bar.test.ts b/apps/pythinker-code/test/tui/components/status-bar.test.ts new file mode 100644 index 00000000..4ed8e9bd --- /dev/null +++ b/apps/pythinker-code/test/tui/components/status-bar.test.ts @@ -0,0 +1,112 @@ +import { visibleWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + StatusBarComponent, + type StatusBarStatus, +} from '#/tui/components/chrome/status-bar'; +import { shimmerText } from '#/tui/utils/shimmer'; + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/gu, ''); +} + +function status(overrides: Partial = {}): StatusBarStatus { + return { + model: 'Model Alpha', + thinkingLevel: 'high', + cwd: '/Users/test/project', + homeDir: '/Users/test', + permissionMode: 'auto', + planMode: true, + dynamicWorkflowMode: true, + sessionKey: 'session-alpha', + ...overrides, + }; +} + +describe('StatusBarComponent', () => { + it('renders one line with the model and effort label', () => { + const component = new StatusBarComponent(); + component.update(status()); + + const lines = component.render(80); + + expect(lines).toHaveLength(1); + expect(stripAnsi(lines[0] ?? '')).toContain('Model Alpha · high'); + }); + + it('drops the gap, modes, and cwd in that order as width shrinks', () => { + const component = new StatusBarComponent(); + component.update(status()); + + const wide = stripAnsi(component.render(60)[0] ?? ''); + const withoutGap = stripAnsi(component.render(53)[0] ?? ''); + const withoutModes = stripAnsi(component.render(45)[0] ?? ''); + const modelOnly = stripAnsi(component.render(25)[0] ?? ''); + + expect(wide).toContain('─'); + expect(withoutGap).not.toContain('─'); + expect(withoutGap).toContain('workflow'); + expect(withoutGap).toContain('~/project'); + expect(withoutModes).not.toContain('workflow'); + expect(withoutModes).toContain('~/project'); + expect(modelOnly).toContain('Model Alpha'); + expect(modelOnly).not.toContain('~/project'); + }); + + it('never renders past the available width', () => { + const component = new StatusBarComponent(); + component.update(status()); + + for (const width of [0, 1, 10, 25, 45, 53, 80]) { + expect(visibleWidth(component.render(width)[0] ?? '')).toBeLessThanOrEqual(width); + } + }); +}); + +describe('shimmerText', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('preserves the input text when ANSI is removed', () => { + vi.spyOn(Date, 'now').mockReturnValue(0); + const text = 'Thinking carefully'; + + expect( + stripAnsi( + shimmerText(text, { + baseToken: 'primary', + shimmerToken: 'primaryShimmer', + frame: 0, + }), + ), + ).toBe(text); + }); + + it('moves the cosine band with wall-clock time', () => { + const previousLevel = chalk.level; + chalk.level = 3; + const now = vi.spyOn(Date, 'now'); + try { + now.mockReturnValue(0); + const first = shimmerText('abcdefghijklmno', { + baseToken: 'primary', + shimmerToken: 'primaryShimmer', + frame: 0, + }); + now.mockReturnValue(100); + const second = shimmerText('abcdefghijklmno', { + baseToken: 'primary', + shimmerToken: 'primaryShimmer', + frame: 0, + }); + + expect(second).not.toBe(first); + } finally { + chalk.level = previousLevel; + } + }); +}); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index c3c35aac..a0fed00b 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -3064,7 +3064,7 @@ command = "vim" expect(transcript).not.toContain('I am implementing the dedicated /btw panel.'); }); - it('keeps the /btw panel above MCP status and the input after later transcript output', async () => { + it('keeps the /btw panel above MCP status, the status bar, and the input', async () => { const session = makeSession(); const { driver } = await makeDriver(session); await openBtwPanel(driver, session); @@ -3119,6 +3119,9 @@ command = "vim" rootChildren.indexOf(driver.state.mcpStatusContainer) - 1, ); expect(rootChildren.indexOf(driver.state.mcpStatusContainer)).toBe( + rootChildren.indexOf(driver.state.statusBarContainer) - 1, + ); + expect(rootChildren.indexOf(driver.state.statusBarContainer)).toBe( rootChildren.indexOf(driver.state.editorContainer) - 1, ); expect(transcript).toContain('main answer after btw'); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts index d73bea0a..dcd5f6da 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts @@ -551,12 +551,15 @@ describe('PythinkerTUI startup', () => { expect(driver.state.ui.children).toEqual([driver.state.layoutRoot]); }); - it('places MCP startup status immediately above the editor in inline layout', () => { + it('places the status bar between MCP startup status and the editor in inline layout', () => { const harness = makeHarness(); const driver = makeDriver(harness, makeStartupInput({}, { layout: 'inline' })); const children = driver.state.ui.children; expect(children[0]).toBe(driver.state.transcriptContainer); expect(children.indexOf(driver.state.mcpStatusContainer)).toBe( + children.indexOf(driver.state.statusBarContainer) - 1, + ); + expect(children.indexOf(driver.state.statusBarContainer)).toBe( children.indexOf(driver.state.editorContainer) - 1, ); }); diff --git a/apps/pythinker-code/test/tui/theme/palette.test.ts b/apps/pythinker-code/test/tui/theme/palette.test.ts index 77e880a8..ed637842 100644 --- a/apps/pythinker-code/test/tui/theme/palette.test.ts +++ b/apps/pythinker-code/test/tui/theme/palette.test.ts @@ -81,6 +81,9 @@ const exemptTokens = [ 'inverseText', 'selectionBg', 'surfaceHighlight', + 'toolPendingBg', + 'toolSuccessBg', + 'toolErrorBg', 'progressEmpty', ] as const; @@ -348,7 +351,7 @@ describe('theme palettes', () => { const lightTokens = Object.keys(lightColors).toSorted(); const schemaProperties = schema.properties.colors.properties; - expect(darkTokens).toHaveLength(57); + expect(darkTokens).toHaveLength(60); expect(lightTokens).toEqual(darkTokens); expect(Object.keys(schemaProperties).toSorted()).toEqual(darkTokens); expect( diff --git a/apps/pythinker-code/test/tui/utils/session-accent.test.ts b/apps/pythinker-code/test/tui/utils/session-accent.test.ts new file mode 100644 index 00000000..2b30ccca --- /dev/null +++ b/apps/pythinker-code/test/tui/utils/session-accent.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { sessionAccentHex } from '#/tui/utils/session-accent'; + +function channelSum(hex: string): number { + return [1, 3, 5].reduce((sum, start) => sum + Number.parseInt(hex.slice(start, start + 2), 16), 0); +} + +describe('sessionAccentHex', () => { + it('returns a stable six-digit hex color for each key', () => { + const accent = sessionAccentHex('session-alpha', 'dark'); + + expect(accent).toBe(sessionAccentHex('session-alpha', 'dark')); + expect(accent).toMatch(/^#[0-9a-fA-F]{6}$/u); + }); + + it('gives known session keys different hues', () => { + expect(sessionAccentHex('session-alpha', 'dark')).not.toBe( + sessionAccentHex('session-beta', 'dark'), + ); + }); + + it('uses a darker light-theme variant', () => { + expect(channelSum(sessionAccentHex('session-alpha', 'light'))).toBeLessThan( + channelSum(sessionAccentHex('session-alpha', 'dark')), + ); + }); +}); diff --git a/docs/customization/themes.md b/docs/customization/themes.md index b070b76d..8bf4c44a 100644 --- a/docs/customization/themes.md +++ b/docs/customization/themes.md @@ -64,6 +64,9 @@ Active `/model` provider and `AskUserQuestion` tabs use `selectionBg` for the ba | `inverseText` | `#FFFFFF` | `#0B1020` | Foreground for active `/model` provider and `AskUserQuestion` tabs; pair with `selectionBg` at 4.5:1 contrast or higher. | | `selectionBg` | `#344274` | `#C9D1FA` | Background for active `/model` provider and `AskUserQuestion` tabs; pair with `inverseText` at 4.5:1 contrast or higher. | | `surfaceHighlight` | `#1C2238` | `#E8EBFC` | Subtle fill for highlighted rows and message surfaces, including user transcript rows. | +| `toolPendingBg` | `#1D2129` | `#E8EEF7` | Background tint for a tool card while the call is running. | +| `toolSuccessBg` | `#14171B` | `#F1F3F5` | Background tint for a tool card after a successful result. | +| `toolErrorBg` | `#291D1D` | `#F9E9E9` | Background tint for a tool card after an error result. | | `progressFill` | `#25764A` | `#3B9A65` | Filled segment of the Dynamic Workflow aggregate progress line. | | `progressHead` | `#4EC87E` | `#0E7A38` | Static head of the Dynamic Workflow aggregate progress track. | | `progressEmpty` | `#D9DEE8` | `#6B7280` | Empty segment of the Dynamic Workflow aggregate progress line. | diff --git a/packages/agent-core/src/skill/builtin/custom-theme.md b/packages/agent-core/src/skill/builtin/custom-theme.md index 2829c7db..7f60af38 100644 --- a/packages/agent-core/src/skill/builtin/custom-theme.md +++ b/packages/agent-core/src/skill/builtin/custom-theme.md @@ -117,6 +117,9 @@ Active `/model` provider and `AskUserQuestion` tabs use `selectionBg` for the ba | `inverseText` | Foreground for active `/model` provider and `AskUserQuestion` tabs; pair with `selectionBg` at 4.5:1 contrast or higher. | | `selectionBg` | Background for active `/model` provider and `AskUserQuestion` tabs; pair with `inverseText` at 4.5:1 contrast or higher. | | `surfaceHighlight` | Subtle fill for highlighted rows and message surfaces, including user transcript rows. | +| `toolPendingBg` | Background tint for a tool card while the call is running. | +| `toolSuccessBg` | Background tint for a tool card after a successful result. | +| `toolErrorBg` | Background tint for a tool card after an error result. | | `progressFill` | Filled segment of the Dynamic Workflow aggregate progress line. | | `progressHead` | Static head of the Dynamic Workflow aggregate progress track. | | `progressEmpty` | Empty segment of the Dynamic Workflow aggregate progress line. | From 5b3b9ffc192302ead68a8e7b335c44b19c38885e Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 19:25:52 -0400 Subject: [PATCH 02/11] fix(tui): guard codePointAt result in session accent hash --- apps/pythinker-code/src/tui/utils/session-accent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/pythinker-code/src/tui/utils/session-accent.ts b/apps/pythinker-code/src/tui/utils/session-accent.ts index bd111501..d4e7955d 100644 --- a/apps/pythinker-code/src/tui/utils/session-accent.ts +++ b/apps/pythinker-code/src/tui/utils/session-accent.ts @@ -2,7 +2,7 @@ export function sessionAccentHex(key: string, mode: 'dark' | 'light'): string { let hash = 5381; for (let index = 0; index < key.length; index++) { - hash = Math.imul(hash, 33) + key.codePointAt(index); + hash = Math.imul(hash, 33) + (key.codePointAt(index) ?? 0); } return hslToHex((hash >>> 0) % 360, 0.9, mode === 'dark' ? 0.72 : 0.42); From 65150bd713dec4af5ffabce00c33db0068581a2c Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 20:57:08 -0400 Subject: [PATCH 03/11] fix: address review feedback on TUI signature design --- .../src/tui/components/chrome/status-bar.ts | 4 ++- .../tui/components/messages/tool-call.test.ts | 26 ++++++++----------- .../test/tui/components/status-bar.test.ts | 18 ++++++++++++- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/chrome/status-bar.ts b/apps/pythinker-code/src/tui/components/chrome/status-bar.ts index ff68d28a..1b6885e4 100644 --- a/apps/pythinker-code/src/tui/components/chrome/status-bar.ts +++ b/apps/pythinker-code/src/tui/components/chrome/status-bar.ts @@ -17,6 +17,7 @@ export type StatusBarStatus = Pick< | 'homeDir' | 'permissionMode' | 'planMode' + | 'fastMode' | 'dynamicWorkflowMode' > & { readonly sessionKey: string }; @@ -82,7 +83,8 @@ function renderModesChip(status: StatusBarStatus): string | undefined { if (status.planMode) modes.push(currentTheme.fg('modePlan', 'plan')); if (status.permissionMode === 'auto') modes.push(currentTheme.fg('modePermission', 'auto')); if (status.permissionMode === 'yolo') modes.push(currentTheme.fg('modeAutoAccept', 'yolo')); - if (status.dynamicWorkflowMode) modes.push(currentTheme.fg('modeFast', 'workflow')); + if (status.fastMode) modes.push(currentTheme.fg('modeFast', '↯ fast')); + if (status.dynamicWorkflowMode) modes.push(currentTheme.fg('accent', 'workflow')); return modes.length === 0 ? undefined : chip(modes.join(' ')); } diff --git a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts index a2919fae..5bcb71c5 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts @@ -63,26 +63,22 @@ describe('ToolCallComponent', () => { try { const pending = component.render(40); + const pendingBody = pending.slice(1); expect(pending[0]).not.toContain('\u001B[48;2;29;33;41m'); - expect( - pending.slice(1).every((line) => line.includes('\u001B[48;2;29;33;41m')), - ).toBe(true); + expect(pendingBody.length).toBeGreaterThan(0); + expect(pendingBody.every((line) => line.includes('\u001B[48;2;29;33;41m'))).toBe(true); component.setResult({ tool_call_id: 'call_tint', output: 'content', is_error: false }); - expect( - component - .render(40) - .slice(1) - .every((line) => line.includes('\u001B[48;2;20;23;27m')), - ).toBe(true); + const success = component.render(40); + const successBody = success.slice(1); + expect(successBody.length).toBeGreaterThan(0); + expect(successBody.every((line) => line.includes('\u001B[48;2;20;23;27m'))).toBe(true); component.setResult({ tool_call_id: 'call_tint', output: 'failed', is_error: true }); - expect( - component - .render(40) - .slice(1) - .every((line) => line.includes('\u001B[48;2;41;29;29m')), - ).toBe(true); + const error = component.render(40); + const errorBody = error.slice(1); + expect(errorBody.length).toBeGreaterThan(0); + expect(errorBody.every((line) => line.includes('\u001B[48;2;41;29;29m'))).toBe(true); } finally { chalk.level = previousLevel; } diff --git a/apps/pythinker-code/test/tui/components/status-bar.test.ts b/apps/pythinker-code/test/tui/components/status-bar.test.ts index 4ed8e9bd..05cf1c43 100644 --- a/apps/pythinker-code/test/tui/components/status-bar.test.ts +++ b/apps/pythinker-code/test/tui/components/status-bar.test.ts @@ -20,6 +20,7 @@ function status(overrides: Partial = {}): StatusBarStatus { homeDir: '/Users/test', permissionMode: 'auto', planMode: true, + fastMode: false, dynamicWorkflowMode: true, sessionKey: 'session-alpha', ...overrides, @@ -61,7 +62,22 @@ describe('StatusBarComponent', () => { component.update(status()); for (const width of [0, 1, 10, 25, 45, 53, 80]) { - expect(visibleWidth(component.render(width)[0] ?? '')).toBeLessThanOrEqual(width); + const lines = component.render(width); + expect(lines).toHaveLength(1); + expect(visibleWidth(lines[0]!)).toBeLessThanOrEqual(width); + } + }); + + it('renders fast mode', () => { + const previousLevel = chalk.level; + chalk.level = 3; + const component = new StatusBarComponent(); + component.update(status({ fastMode: true })); + + try { + expect(stripAnsi(component.render(80)[0] ?? '')).toContain('↯ fast'); + } finally { + chalk.level = previousLevel; } }); }); From 72c030eed35bd14b6412dccac351ef954f5e54e9 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 21:28:18 -0400 Subject: [PATCH 04/11] feat(tui): render the status bar below the editor as the single status line --- .../src/tui/components/chrome/footer.ts | 21 +--- .../src/tui/components/chrome/status-bar.ts | 17 ++- .../dynamic-workflow-mission-control.ts | 13 +- .../src/tui/constant/rendering.ts | 8 +- apps/pythinker-code/src/tui/pythinker-tui.ts | 8 +- .../src/tui/runtime/footer/footer-model.ts | 117 ++++++++++++------ apps/pythinker-code/src/tui/tui-state.ts | 2 +- .../test/tui/components/chrome/footer.test.ts | 52 +------- .../dynamic-workflow-mission-control.test.ts | 34 ++--- .../panels/footer-bg-agents.test.ts | 50 ++++---- .../components/panels/footer-context.test.ts | 63 ++++------ .../panels/footer-goal-badge.test.ts | 68 ++++++---- .../test/tui/components/status-bar.test.ts | 25 ++++ .../tui/pythinker-tui-message-flow.test.ts | 8 +- .../test/tui/pythinker-tui-startup.test.ts | 8 +- .../test/tui/runtime/footer-model.test.ts | 26 ++++ 16 files changed, 300 insertions(+), 220 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/chrome/footer.ts b/apps/pythinker-code/src/tui/components/chrome/footer.ts index 5545e487..9f6b7df3 100644 --- a/apps/pythinker-code/src/tui/components/chrome/footer.ts +++ b/apps/pythinker-code/src/tui/components/chrome/footer.ts @@ -10,7 +10,6 @@ import chalk from 'chalk'; import { createFooterState, - formatStatusRow, reduceFooterState, selectFooterViewModel, type FooterBackgroundCounts, @@ -18,11 +17,9 @@ import { type FooterGoal, type FooterState, type FooterStatus, - type FooterStatusRowViewModel, type FooterViewModel, type FooterViewModelRow, } from '#/tui/runtime/footer/footer-model'; -import { currentTheme } from '#/tui/theme'; import type { AppState } from '#/tui/types'; import { createGitStatusCache, @@ -275,7 +272,7 @@ export class FooterComponent implements Component { this.state.statusLine, ); return viewModel.rows.flatMap((row) => { - if (row.kind === 'activity' || row.kind === 'composer') return []; + if (row.kind === 'composer' || row.kind === 'status') return []; return [truncateToWidth(renderLegacyRow(row), width, '…')]; }); } @@ -376,16 +373,12 @@ export class FooterComponent implements Component { } } -/** Keep the persistent status quiet; danger rows remain explicitly red. */ -function paintStatusRow( - row: string, - _modelName: string | null, - emphasis: FooterStatusRowViewModel['emphasis'], +function renderLegacyRow( + row: Exclude< + FooterViewModelRow, + { readonly kind: 'composer' } | { readonly kind: 'status' } + >, ): string { - return currentTheme.fg(emphasis === 'danger' ? 'error' : 'textDim', row); -} - -function renderLegacyRow(row: Exclude): string { switch (row.kind) { case 'activity': return row.primary.length === 0 @@ -395,8 +388,6 @@ function renderLegacyRow(row: Exclude & { readonly sessionKey: string }; +> & { + readonly extras: readonly string[]; + readonly sessionKey: string; +}; export class StatusBarComponent implements Component { private status: StatusBarStatus | undefined; @@ -39,10 +42,16 @@ export class StatusBarComponent implements Component { )}`, ); let modesChip = renderModesChip(status); + const extraChips = status.extras.map((extra) => + chip(currentTheme.fg('textDim', extra)), + ); let cwdChip: string | undefined = chip( currentTheme.fg('textDim', shortenCwd(status.cwd, status.homeDir)), ); - const left = (): string => `${modelChip}${modesChip === undefined ? '' : ` ${modesChip}`}`; + const left = (): string => + [modelChip, modesChip, ...extraChips] + .filter((item): item is string => item !== undefined) + .join(' '); const fullGapWidth = width - visibleWidth(left()) - (cwdChip === undefined ? 1 : visibleWidth(cwdChip) + 2); @@ -58,6 +67,10 @@ export class StatusBarComponent implements Component { line = cwdChip === undefined ? `${left()} ${gap}` : `${left()} ${gap} ${cwdChip}`; } else { line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`; + while (visibleWidth(line) > width && extraChips.length > 0) { + extraChips.pop(); + line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`; + } if (visibleWidth(line) > width && modesChip !== undefined) { modesChip = undefined; line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`; diff --git a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts index 2b8deeb8..021456da 100644 --- a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts +++ b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts @@ -507,9 +507,20 @@ export class DynamicWorkflowMissionControlComponent implements Component { private renderAggregate(width: number, nowMs: number): string { const terminal = isTerminalRequestPhase(this.model.requestPhase); + const frame = Math.floor( + Math.max(0, nowMs - this.model.startedAtMs) / + DYNAMIC_WORKFLOW_RENDERING.progressFrameMs, + ); const loader = terminal ? currentTheme.fg(requestPhaseColor(this.model.requestPhase), requestPhaseSymbol(this.model.requestPhase)) - : this.activitySpinnerText?.() ?? currentTheme.fg('primary', '●'); + : this.activitySpinnerText === undefined + ? currentTheme.fg('primary', '●') + : currentTheme.fg( + 'primary', + DYNAMIC_WORKFLOW_RENDERING.progressFrames[ + frame % DYNAMIC_WORKFLOW_RENDERING.progressFrames.length + ] ?? DYNAMIC_WORKFLOW_RENDERING.progressFrames[0], + ); const aggregateMembers = this.aggregateMembers(); // All spawned agents are done but the tool result has not arrived yet: // the label says so instead of pretending orchestration is still active. diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index 837e504b..119ce1eb 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -32,10 +32,10 @@ export const DYNAMIC_WORKFLOW_RENDERING = { stateColumnWidth: 6, /** Cadence for the live aggregate-label shimmer. */ aggregateShimmerFrameMs: BRAILLE_SPINNER_INTERVAL_MS, - /** Thin-arc frames for a running row; all rows share one clock. */ - progressFrames: ['◜', '◝', '◞', '◟'], - /** Arc cadence in milliseconds. */ - progressFrameMs: 120, + /** Half-circle frames for a running row; all rows share one clock. */ + progressFrames: ['◐', '◓', '◑', '◒'], + /** Rotation cadence in milliseconds — deliberately slow; this is ambience, not progress. */ + progressFrameMs: 300, /** Least room the task keeps before the detail may claim any of the row. */ memberTaskMinWidth: 12, /** Share of the free row the task may take before the detail gets the rest. */ diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index bc288822..8b42d5df 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -141,6 +141,7 @@ import type { TuiPresentation } from './runtime/contracts'; import { foldFooterEvents, selectFooterViewModel, + selectStatusBarExtras, type FooterActivity, type FooterEvent, type FooterGoal, @@ -964,8 +965,8 @@ export class PythinkerTUI { ui.addChild(this.state.queueContainer); ui.addChild(this.state.btwPanelContainer); ui.addChild(this.state.mcpStatusContainer); - ui.addChild(this.state.statusBarContainer); ui.addChild(this.state.editorContainer); + ui.addChild(this.state.statusBarContainer); // Footer is mounted later (mountFooter), not here. } @@ -1396,6 +1397,11 @@ export class PythinkerTUI { ); this.state.statusBar.update({ ...this.state.footerState.status, + extras: selectStatusBarExtras( + this.state.footerState, + Date.now(), + this.state.appState.statusLine, + ), sessionKey: this.state.appState.sessionTitle?.trim() || this.state.appState.sessionId || diff --git a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts b/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts index 802535ee..b00bb59f 100644 --- a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts +++ b/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts @@ -464,16 +464,27 @@ function selectActivityRow(state: FooterState): FooterActivityRowViewModel { }); } -function selectStatusItems( +function selectStatusItemParts( state: FooterState, clockMs: number, statusLine: StatusLineConfig, -): string[] { - const items: string[] = []; +): { + readonly update: string | null; + readonly model: string | null; + readonly speed: string | null; + readonly spend: string | null; + readonly context: string | null; + readonly git: string | null; + readonly modes: string | null; + readonly elapsed: string | null; + readonly goal: string | null; + readonly background: readonly string[]; +} { const update = formatUpdate(state.update); - if (update !== null) items.push(update); - const model = normalizeSingleLine(state.status.model); - if (statusLine.showModel && model.length > 0) { + const modelName = normalizeSingleLine(state.status.model); + const speed = statusLine.showTokenSpeed ? formatTokenSpeed(state.status) : null; + let model: string | null = null; + if (statusLine.showModel && modelName.length > 0) { const effortSuffix = statusLine.showEffort && state.status.thinkingLevel !== 'off' ? ` · ${shortEffortLabel(state.status.thinkingLevel)}` @@ -481,51 +492,85 @@ function selectStatusItems( // Fast rides on the model item and only while mode badges are visible, // so it can never appear twice in the row. const fastSuffix = statusLine.showModes && state.status.fastMode ? ' · ↯ fast' : ''; - const speed = statusLine.showTokenSpeed ? formatTokenSpeed(state.status) : null; - items.push(`${model}${effortSuffix}${fastSuffix}${speed === null ? '' : ` · ${speed}`}`); - } - - if (statusLine.showModel) { - const spend = formatSessionSpend(state.status.sessionSpendUsd); - if (spend !== null) items.push(spend); - } - - if (statusLine.showContextBar) items.push(formatContext(state.status)); - - if (statusLine.showGit) { - const git = formatGitStatus(state.status.git); - if (git !== null) items.push(git); + model = `${modelName}${effortSuffix}${fastSuffix}${speed === null ? '' : ` · ${speed}`}`; } + let modes: string | null = null; if (statusLine.showModes) { - const modes: string[] = []; - if (state.status.dynamicWorkflowMode) modes.push('workflow'); - if (state.status.permissionMode === 'auto') modes.push('auto'); - if (state.status.planMode) modes.push('plan'); - if (modes.length > 0) items.push(modes.join(' ')); - } - - if (statusLine.showElapsed && state.status.elapsedMs !== null) { - items.push(`elapsed ${formatStatusElapsed(state.status.elapsedMs)}`); - } - - if (statusLine.showGoal) { - const goal = formatGoal(state.goal, clockMs); - if (goal !== null) items.push(goal); + const modeItems: string[] = []; + if (state.status.dynamicWorkflowMode) modeItems.push('workflow'); + if (state.status.permissionMode === 'auto') modeItems.push('auto'); + if (state.status.planMode) modeItems.push('plan'); + if (modeItems.length > 0) modes = modeItems.join(' '); } + const background: string[] = []; if (statusLine.showBackgroundTasks) { const bashTasks = nonNegativeInteger(state.background.bashTasks); if (bashTasks > 0) { - items.push(`[${String(bashTasks)} ${plural(bashTasks, 'task')} running]`); + background.push(`[${String(bashTasks)} ${plural(bashTasks, 'task')} running]`); } const agentTasks = nonNegativeInteger(state.background.agentTasks); if (agentTasks > 0) { - items.push( + background.push( `[${String(agentTasks)} ${plural(agentTasks, 'agent')} running]`, ); } } + + return { + update, + model, + speed, + spend: statusLine.showModel ? formatSessionSpend(state.status.sessionSpendUsd) : null, + context: statusLine.showContextBar ? formatContext(state.status) : null, + git: statusLine.showGit ? formatGitStatus(state.status.git) : null, + modes, + elapsed: + statusLine.showElapsed && state.status.elapsedMs !== null + ? `elapsed ${formatStatusElapsed(state.status.elapsedMs)}` + : null, + goal: statusLine.showGoal ? formatGoal(state.goal, clockMs) : null, + background, + }; +} + +function selectStatusItems( + state: FooterState, + clockMs: number, + statusLine: StatusLineConfig, +): string[] { + const parts = selectStatusItemParts(state, clockMs, statusLine); + const items = [ + parts.update, + parts.model, + parts.spend, + parts.context, + parts.git, + parts.modes, + parts.elapsed, + parts.goal, + ].filter((item): item is string => item !== null); + items.push(...parts.background); + return items; +} + +export function selectStatusBarExtras( + state: FooterState, + clockMs: number, + statusLine: StatusLineConfig, +): string[] { + const parts = selectStatusItemParts(state, clockMs, statusLine); + const items = [ + parts.update, + parts.speed, + parts.spend, + parts.context, + parts.git, + parts.elapsed, + parts.goal, + ].filter((item): item is string => item !== null); + items.push(...parts.background); return items; } diff --git a/apps/pythinker-code/src/tui/tui-state.ts b/apps/pythinker-code/src/tui/tui-state.ts index e942924d..6d997fcb 100644 --- a/apps/pythinker-code/src/tui/tui-state.ts +++ b/apps/pythinker-code/src/tui/tui-state.ts @@ -114,8 +114,8 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState { queueContainer, btwPanelContainer, mcpStatusContainer, - statusBarContainer, editorContainer, + statusBarContainer, ], footerWrap, ); diff --git a/apps/pythinker-code/test/tui/components/chrome/footer.test.ts b/apps/pythinker-code/test/tui/components/chrome/footer.test.ts index 80b32e0a..e98233d3 100644 --- a/apps/pythinker-code/test/tui/components/chrome/footer.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/footer.test.ts @@ -9,7 +9,6 @@ import { foldFooterEvents, selectFooterViewModel, } from '#/tui/runtime/footer/footer-model'; -import { currentTheme } from '#/tui/theme'; import type { AppState } from '#/tui/types'; import type { GitStatusCache } from '#/utils/git/git-status'; @@ -113,49 +112,13 @@ describe('FooterComponent', () => { vi.useRealTimers(); }); - it('renders one faint status hierarchy without path, bar, tips, or a wall clock', () => { + it('does not render shared status rows', () => { const footer = new FooterComponent(appState); - footer.setTokenSpeed(75.7); - - const rendered = footer.render(160); - const rows = rendered.map(stripAnsi); - - expect(rows).toHaveLength(1); - expect(rendered[0]).toBe(currentTheme.fg('textDim', rows[0]!)); - expect(rows[0]).toContain('DeepSeek V4 Flash · max · 75.7 t/s'); - expect(rows[0]).toContain('▱▱▱▱▱▱▱▱ 5%'); - expect(rows[0]).toContain('workflow'); - expect(rows[0]).toContain('elapsed 04:12'); - expect(rows.join('\n')).not.toContain('/Users/example/work/pythinker-code'); - expect(rows.join('\n')).not.toContain('shift+tab: plan mode'); - expect(rows.join('\n')).not.toContain('00:04'); - expect(rows.join('\n')).not.toContain('◆'); - }); - - it('renders YOLO at the start of a second row beneath the model', () => { - const footer = new FooterComponent({ ...appState, permissionMode: 'yolo' }); - - const rendered = footer.render(160); - const rows = rendered.map(stripAnsi); - expect(rows).toHaveLength(2); - expect(rows[0]).toContain('DeepSeek V4 Flash'); - expect(rows[0]).not.toContain('yolo'); - expect(rows[1]).toBe(' yolo'); - expect(rendered[1]).toBe(currentTheme.fg('error', ' yolo')); + expect(footer.render(160)).toEqual([]); }); - it('marks estimated token speed without changing the single status row', () => { - const footer = new FooterComponent(appState); - footer.setTokenSpeed(75.7, true); - - const rows = footer.render(160).map(stripAnsi); - - expect(rows).toHaveLength(1); - expect(rows[0]).toContain('~75.7 t/s'); - }); - - it('suppresses legacy activity only and retains validation errors', () => { + it('renders activity and validation rows but suppresses shared status rows', () => { const footer = new FooterComponent(appState); const activity = selectFooterViewModel( foldFooterEvents(createFooterState(), [ @@ -174,7 +137,7 @@ describe('FooterComponent', () => { ); footer.setViewModel(activity); - expect(footer.render(120).map(stripAnsi)).toEqual([' ▱▱▱▱▱▱▱▱ 0%']); + expect(footer.render(120).map(stripAnsi)).toEqual(['⠋ Waiting for response']); const validation = selectFooterViewModel( foldFooterEvents(createFooterState(), [ @@ -188,10 +151,7 @@ describe('FooterComponent', () => { ); footer.setViewModel(validation); - expect(footer.render(120).map(stripAnsi)).toEqual([ - 'error: Fix the request', - ' ▱▱▱▱▱▱▱▱ 0%', - ]); + expect(footer.render(120).map(stripAnsi)).toEqual(['error: Fix the request']); }); it('omits elapsed for an idle workflow after its completed turn', () => { @@ -340,7 +300,7 @@ describe('FooterComponent', () => { footer.setTransientHint('Press Ctrl-C again to exit'); const rows = footer.render(40).map(stripAnsi); - expect(rows).toEqual(['Press Ctrl-C again to exit', '']); + expect(rows).toEqual(['Press Ctrl-C again to exit']); expect(rows.every((row) => visibleWidth(row) <= 40)).toBe(true); footer.dispose(); diff --git a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts index 915eceb1..53d27640 100644 --- a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts @@ -21,7 +21,7 @@ function renderText(component: DynamicWorkflowMissionControlComponent, width = 1 } /** Lifecycle progress glyph and label for a running row. */ -const RUNNING_CELL = /[◜◝◞◟]\s+RUN/u; +const RUNNING_CELL = /[◐◓◑◒]\s+RUN/u; /** Head of a task cell that lost the preamble every row shared. */ const TASK_ELISION_MARK = '…'; @@ -321,7 +321,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(aggregateLine(output)).toContain('2/3 complete'); expect(aggregateLine(output)).not.toMatch(/\b\d+%/u); expect(aggregateLine(output)).not.toContain('━'); - expect(memberLine(output, 1)).toMatch(/[◜◝◞◟]\s+RUN\s+Layout hierarchy/u); + expect(memberLine(output, 1)).toMatch(/[◐◓◑◒]\s+RUN\s+Layout hierarchy/u); expect(memberLine(output, 2)).toMatch(/✓\s+DONE\s+Interaction audit/u); expect(output).not.toMatch(/[⣿⣷⣯⣟⡿⢿⣻⣽]{4,}/u); }); @@ -408,11 +408,11 @@ describe('DynamicWorkflowMissionControlComponent', () => { }; const first = colouredMemberLine(); - vi.setSystemTime(120); + vi.setSystemTime(300); const second = colouredMemberLine(); - expect(first).toContain(chalk.hex(darkColors.primary)('◜')); - expect(second).toContain(chalk.hex(darkColors.primary)('◝')); + expect(first).toContain(chalk.hex(darkColors.primary)('◐')); + expect(second).toContain(chalk.hex(darkColors.primary)('◓')); expect(first).toContain(chalk.hex(darkColors.primary)('RUN')); vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS); @@ -441,7 +441,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { const output = renderText(component, 120); expect(output).toContain('– Cancelled'); - expect(memberLine(output, 1)).toMatch(/[◜◝◞◟]\s+RUN\s+Running work/u); + expect(memberLine(output, 1)).toMatch(/[◐◓◑◒]\s+RUN\s+Running work/u); expect(memberLine(output, 2)).toMatch(/○\s+WAIT\s+Queued work/u); expect(output).not.toContain('– STOP'); expect(output).not.toContain('⠋ Orchestrating'); @@ -517,7 +517,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(renderText(component, 100)).toContain('Rate limited'); component.markStarted('agent-1'); - expect(memberLine(renderText(component, 100), 1)).toMatch(/[◜◝◞◟]\s+RUN/u); + expect(memberLine(renderText(component, 100), 1)).toMatch(/[◐◓◑◒]\s+RUN/u); }); it('prefers a suspension detail over stale model progress in the member row', () => { @@ -646,12 +646,12 @@ describe('DynamicWorkflowMissionControlComponent', () => { const running = renderText(component, 100); expect(running).toContain('PROGRESS'); expect(running).not.toContain('WORK IDLE'); - expect(memberLine(running, 1)).toMatch(/◜\s+RUN\s+Live work/u); + expect(memberLine(running, 1)).toMatch(/◐\s+RUN\s+Live work/u); expect(memberLine(running, 2)).toMatch(/○\s+WAIT\s+Queued work/u); expect(running).not.toMatch(/\b\d+%|⚒|━/u); }); - it('rotates the running arc from the shared workflow clock and freezes completion', () => { + it('rotates the running indicators from the shared workflow clock and freezes completion', () => { vi.useFakeTimers(); vi.setSystemTime(0); const previousLevel = chalk.level; @@ -663,13 +663,17 @@ describe('DynamicWorkflowMissionControlComponent', () => { const component = createComponent(); component.updateArgs({ items: ['Live work'] }); component.markInputComplete(); + component.setActivitySpinnerText(() => '⠋'); component.registerSubagent({ agentId: 'agent-1' }); component.markStarted('agent-1'); - for (const [time, glyph] of [[0, '◜'], [120, '◝'], [240, '◞'], [360, '◟']] as const) { + for (const [time, glyph] of [[0, '◐'], [300, '◓'], [600, '◑'], [900, '◒']] as const) { vi.setSystemTime(time); const line = component.render(100).find((candidate) => strip(candidate).includes('001')); expect(line).toContain(chalk.hex(darkColors.primary)(glyph)); + expect(aggregateLine(component.render(100).join('\n'))).toContain( + chalk.hex(darkColors.primary)(glyph), + ); } component.markCompleted('agent-1', 'Done'); @@ -700,7 +704,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { const output = renderText(component, 100); expect(memberLine(output, 1)).toMatch(/○\s+WAIT/u); - expect(memberLine(output, 2)).toMatch(/[◜◝◞◟]\s+RUN/u); + expect(memberLine(output, 2)).toMatch(/[◐◓◑◒]\s+RUN/u); expect(memberLine(output, 3)).toMatch(/✓\s+DONE/u); } finally { chalk.level = previousLevel; @@ -719,7 +723,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { const output = renderText(component, 100); const glyphColumns = [ memberLine(output, 1).indexOf('○'), - memberLine(output, 2).search(/[◜◝◞◟]/u), + memberLine(output, 2).search(/[◐◓◑◒]/u), memberLine(output, 3).indexOf('✓'), ]; expect(glyphColumns[0]).toBeGreaterThan(0); @@ -885,7 +889,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { const output = strip(rendered.join('\n')); expect(rendered.every((line) => visibleWidth(line) <= width)).toBe(true); - expect(memberLine(output, 1)).toMatch(/[◜◝◞◟]\s+RUN/u); + expect(memberLine(output, 1)).toMatch(/[◐◓◑◒]\s+RUN/u); expect(output.includes('PROGRESS')).toBe(expectedProgress); expect(output.includes('STATUS')).toBe(expectedStatus); expect(output).not.toContain('WORK IDLE'); @@ -902,7 +906,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.markStarted('agent-1'); vi.setSystemTime(30_000); - const before = memberLine(renderText(component, 100), 1).match(/[◜◝◞◟]/u)?.[0]; + const before = memberLine(renderText(component, 100), 1).match(/[◐◓◑◒]/u)?.[0]; component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); for (let index = 0; index < 200; index += 1) { component.appendModelDelta({ agentId: 'agent-1', delta: `chunk ${String(index)} ` }); @@ -910,7 +914,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.recordToolCall({ agentId: 'agent-1', name: 'Bash' }); const output = renderText(component, 100); - const after = memberLine(output, 1).match(/[◜◝◞◟]/u)?.[0]; + const after = memberLine(output, 1).match(/[◐◓◑◒]/u)?.[0]; expect(before).toBeDefined(); expect(after).toBe(before); expect(output).not.toMatch(/\b\d+%|⚒/u); diff --git a/apps/pythinker-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/pythinker-code/test/tui/components/panels/footer-bg-agents.test.ts index f2947b20..696e63bd 100644 --- a/apps/pythinker-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/pythinker-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -2,13 +2,13 @@ import { describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; +import { + createFooterState, + reduceFooterState, + selectStatusBarExtras, +} from '#/tui/runtime/footer/footer-model'; import type { AppState } from '#/tui/types'; -const ANSI_SGR = /\[[0-9;]*m/g; -function strip(text: string): string { - return text.replaceAll(ANSI_SGR, ''); -} - function baseState(overrides: Partial = {}): AppState { return { model: 'k2', @@ -34,19 +34,25 @@ thinkingLevel: 'off', } as AppState; } +function backgroundExtras(bashTasks: number, agentTasks: number): string { + const state = reduceFooterState(createFooterState(), { + type: 'background-counts.updated', + counts: { bashTasks, agentTasks }, + }); + return selectStatusBarExtras(state, Date.now(), DEFAULT_STATUS_LINE_CONFIG).join(' '); +} + describe('FooterComponent — background task / agent badges', () => { it('omits both badges when counts are 0', () => { const footer = new FooterComponent(baseState()); - const [line1] = footer.render(120); - expect(line1).toBeDefined(); - expect(strip(line1!)).not.toMatch(/tasks? running/); - expect(strip(line1!)).not.toMatch(/agents? running/); + expect(footer.actionItems()).toEqual([]); + expect(backgroundExtras(0, 0)).toBe('▱▱▱▱▱▱▱▱ 0%'); }); it('renders the task badge alone when only bash tasks are running', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 0 }); - const out = strip(footer.render(120)[0]!); + const out = backgroundExtras(1, 0); expect(out).toMatch(/\[1 task running\]/); expect(out).not.toMatch(/agents? running/); }); @@ -54,7 +60,7 @@ describe('FooterComponent — background task / agent badges', () => { it('renders the agent badge alone when only agent tasks are running', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 1 }); - const out = strip(footer.render(120)[0]!); + const out = backgroundExtras(0, 1); expect(out).toMatch(/\[1 agent running\]/); expect(out).not.toMatch(/tasks? running/); }); @@ -62,7 +68,7 @@ describe('FooterComponent — background task / agent badges', () => { it('renders both badges side by side when both are non-zero', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 3 }); - const out = strip(footer.render(120)[0]!); + const out = backgroundExtras(2, 3); expect(out).toMatch(/\[2 tasks running\]/); expect(out).toMatch(/\[3 agents running\]/); // Task badge appears before agent badge in the line. @@ -72,7 +78,7 @@ describe('FooterComponent — background task / agent badges', () => { it('pluralizes correctly across both badges', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 1 }); - const out = strip(footer.render(120)[0]!); + const out = backgroundExtras(1, 1); expect(out).toMatch(/\[1 task running\]/); expect(out).toMatch(/\[1 agent running\]/); }); @@ -80,11 +86,9 @@ describe('FooterComponent — background task / agent badges', () => { it('updates badges live via setBackgroundCounts', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 1 }); - expect(strip(footer.render(120)[0]!)).toMatch(/\[2 tasks running\]/); + expect(footer.actionItems().map((item) => item.id)).toEqual(['shell-tasks', 'agents']); footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); - const after = strip(footer.render(120)[0]!); - expect(after).not.toMatch(/tasks? running/); - expect(after).not.toMatch(/agents? running/); + expect(footer.actionItems()).toEqual([]); }); it('clears selection when the selected task badge disappears', () => { @@ -101,18 +105,12 @@ describe('FooterComponent — background task / agent badges', () => { it('clamps negative counts to 0', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: -5, agentTasks: -2 }); - const out = strip(footer.render(120)[0]!); - expect(out).not.toMatch(/tasks? running/); - expect(out).not.toMatch(/agents? running/); + expect(footer.actionItems()).toEqual([]); }); - it('drops the badges when terminal is too narrow to fit them', () => { + it('does not render status badges in the footer at any width', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 4, agentTasks: 3 }); - // Extremely narrow width: footer primary content fills the line, so leftLine wins. - const [line1] = footer.render(20); - expect(line1).toBeDefined(); - expect(strip(line1!)).not.toMatch(/\[4 tasks running\]/); - expect(strip(line1!)).not.toMatch(/\[3 agents running\]/); + expect(footer.render(20)).toEqual([]); }); }); diff --git a/apps/pythinker-code/test/tui/components/panels/footer-context.test.ts b/apps/pythinker-code/test/tui/components/panels/footer-context.test.ts index 51c84093..6ad5c017 100644 --- a/apps/pythinker-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/pythinker-code/test/tui/components/panels/footer-context.test.ts @@ -1,8 +1,16 @@ import { describe, it, expect } from 'vitest'; import chalk from 'chalk'; -import { FooterComponent, formatFooterGitBadge } from '#/tui/components/chrome/footer'; +import { + FooterComponent, + footerStatusFromAppState, + formatFooterGitBadge, +} from '#/tui/components/chrome/footer'; import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; +import { + createFooterState, + selectStatusBarExtras, +} from '#/tui/runtime/footer/footer-model'; import { darkColors } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; @@ -44,80 +52,57 @@ function baseState(overrides: Partial = {}): AppState { } as AppState; } +function statusExtras(state: AppState): string { + return selectStatusBarExtras( + createFooterState(footerStatusFromAppState(state, null)), + Date.now(), + state.statusLine, + ).join(''); +} + describe('FooterComponent — quiet context status', () => { it('NaN usage → renders 0% (never literal "NaN%")', () => { - const fc = new FooterComponent(baseState({ contextUsage: Number.NaN })); - const rows = fc.render(120); - const out = strip(rows.join('')); - expect(rows).toHaveLength(1); + const out = statusExtras(baseState({ contextUsage: Number.NaN })); expect(out).not.toMatch(/NaN/); expect(out).toContain('▱▱▱▱▱▱▱▱ 0%'); }); it('undefined-ish (coerced) usage → renders 0%', () => { - const fc = new FooterComponent( + const out = statusExtras( baseState({ contextUsage: undefined as unknown as number }), ); - const out = strip(fc.render(120).join('')); expect(out).not.toMatch(/NaN/); expect(out).toContain('▱▱▱▱▱▱▱▱ 0%'); }); it('clamps ratios above 1.0 → renders 100%', () => { - const fc = new FooterComponent(baseState({ contextUsage: 1.5 })); - const out = strip(fc.render(120).join('')); + const out = statusExtras(baseState({ contextUsage: 1.5 })); expect(out).toContain('▰▰▰▰▰▰▰▰ 100%'); }); it('ratio 0.427 → renders 43%', () => { - const fc = new FooterComponent(baseState({ contextUsage: 0.427 })); - const out = strip(fc.render(200).join('')); + const out = statusExtras(baseState({ contextUsage: 0.427 })); expect(out).toContain('▰▰▰▱▱▱▱▱ 43%'); }); it('tokens provided but max=0 → falls back to contextUsage without division-by-zero artefacts', () => { - const fc = new FooterComponent( + const out = statusExtras( baseState({ contextUsage: 0, contextTokens: 500, maxContextTokens: 0 }), ); - const out = strip(fc.render(200).join('')); expect(out).not.toMatch(/Infinity|NaN/); expect(out).toMatch(/[▰▱]{8} \d+%/u); // With maxTokens=0, token-count annotation is suppressed. expect(out).not.toMatch(/500\//); }); - it('setState updates visible model and context values', () => { - const footer = new FooterComponent(baseState({ model: 'k2', contextUsage: 0 })); - - footer.setState(baseState({ model: 'pythinker-k2-5', contextUsage: 0.5 })); - - const rows = footer.render(200); - const out = strip(rows.join('')); - expect(rows).toHaveLength(1); - expect(out).toMatch(/^ {2}pythinker-k2-5/); - expect(out).not.toContain(' k2 '); - expect(out).toContain('▰▰▰▰▱▱▱▱ 50%'); - expect(out).not.toContain('/tmp'); - expect(out).not.toContain('shift+tab: plan mode'); - }); - - it('shows a dim effort suffix when thinking is enabled, hides it when off', () => { - const on = new FooterComponent(baseState({ model: 'k2', thinkingLevel: 'medium' })); - const off = new FooterComponent(baseState({ model: 'k2', thinkingLevel: 'off' })); - - expect(strip(on.render(120)[0]!)).toContain('k2 · med'); - expect(strip(on.render(120)[0]!)).not.toMatch(/[◆*]/u); - expect(strip(off.render(120)[0]!)).not.toContain(' · med'); - }); - - it('renders transient hints on the context line', () => { + it('renders transient hints without the suppressed status row', () => { const footer = new FooterComponent(baseState()); footer.setTransientHint('Press Ctrl-C again to exit'); const output = strip(footer.render(120).join('\n')); expect(output).toContain('Press Ctrl-C again to exit'); - expect(output).toContain('▱▱▱▱▱▱▱▱ 0%'); + expect(output).not.toContain('▱▱▱▱▱▱▱▱ 0%'); expect(output).not.toContain('shift+tab: plan mode'); }); diff --git a/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts b/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts index 81bbb551..5e72e6e0 100644 --- a/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts +++ b/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts @@ -1,15 +1,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { FooterComponent } from '#/tui/components/chrome/footer'; +import { FooterComponent, footerStatusFromAppState } from '#/tui/components/chrome/footer'; import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; +import { + createFooterState, + reduceFooterState, + selectStatusBarExtras, +} from '#/tui/runtime/footer/footer-model'; import type { GoalSnapshot } from '@pythoughts/pythinker-code-sdk'; import type { AppState } from '#/tui/types'; -const ANSI_SGR = /\u001B\[[0-9;]*m/g; -function strip(text: string): string { - return text.replaceAll(ANSI_SGR, ''); -} - function baseState(overrides: Partial = {}): AppState { return { model: 'k2', @@ -52,19 +52,42 @@ function goal(overrides: Partial = {}): GoalSnapshot { } as GoalSnapshot; } +function goalExtras( + state: AppState, + observedAtMs = Date.now(), + clockMs = Date.now(), +): string { + const snapshot = state.goal; + const footerState = reduceFooterState( + createFooterState(footerStatusFromAppState(state, null)), + { + type: 'goal.updated', + goal: + snapshot === null || snapshot === undefined + ? null + : { + status: snapshot.status, + turnsUsed: snapshot.turnsUsed, + turnBudget: snapshot.budget.turnBudget, + wallClockMs: snapshot.wallClockMs, + observedAtMs, + }, + }, + ); + return selectStatusBarExtras(footerState, clockMs, state.statusLine).join(' '); +} + describe('FooterComponent — goal badge', () => { afterEach(() => { vi.useRealTimers(); }); it('omits the badge when there is no goal', () => { - const footer = new FooterComponent(baseState({ goal: null })); - expect(strip(footer.render(160)[0]!)).not.toMatch(/goal/); + expect(goalExtras(baseState({ goal: null }))).not.toMatch(/goal/); }); it('shows status, elapsed, and a raw turn count for an unbounded active goal', () => { - const footer = new FooterComponent(baseState({ goal: goal() })); - const out = strip(footer.render(160)[0]!); + const out = goalExtras(baseState({ goal: goal() })); expect(out).toContain('[goal'); expect(out).toContain('active'); expect(out).toContain('4m'); @@ -77,13 +100,10 @@ describe('FooterComponent — goal badge', () => { vi.useFakeTimers(); vi.setSystemTime(0); - const footer = new FooterComponent( - baseState({ goal: goal({ wallClockMs: 0, turnsUsed: 0 }) }), - ); - - expect(strip(footer.render(160)[0]!)).toContain('0s'); + const state = baseState({ goal: goal({ wallClockMs: 0, turnsUsed: 0 }) }); + expect(goalExtras(state, 0, 0)).toContain('0s'); vi.setSystemTime(2_500); - expect(strip(footer.render(160)[0]!)).toContain('3s'); + expect(goalExtras(state, 0, 2_500)).toContain('3s'); }); it('requests a repaint while an active goal timer is visible', () => { @@ -97,27 +117,24 @@ describe('FooterComponent — goal badge', () => { }); it('shows used/limit turns only when a turn budget is set', () => { - const footer = new FooterComponent( + const out = goalExtras( baseState({ goal: goal({ budget: { turnBudget: 20, tokenBudget: null, wallClockBudgetMs: null } } as Partial) }), ); - expect(strip(footer.render(160)[0]!)).toContain('7/20 turns'); + expect(out).toContain('7/20 turns'); }); it('shows a paused badge', () => { - const footer = new FooterComponent(baseState({ goal: goal({ status: 'paused' }) })); - expect(strip(footer.render(160)[0]!)).toContain('paused'); + expect(goalExtras(baseState({ goal: goal({ status: 'paused' }) }))).toContain('paused'); }); it('shows a blocked badge (resumable, still present)', () => { - const footer = new FooterComponent(baseState({ goal: goal({ status: 'blocked' }) })); - const out = strip(footer.render(160)[0]!); + const out = goalExtras(baseState({ goal: goal({ status: 'blocked' }) })); expect(out).toContain('[goal'); expect(out).toContain('blocked'); }); it('hides the badge for a completed goal', () => { - const footer = new FooterComponent(baseState({ goal: goal({ status: 'complete' }) })); - expect(strip(footer.render(160)[0]!)).not.toMatch(/goal/); + expect(goalExtras(baseState({ goal: goal({ status: 'complete' }) }))).not.toMatch(/goal/); }); it('clears selection when the selected goal disappears', () => { @@ -131,8 +148,7 @@ describe('FooterComponent — goal badge', () => { }); it('singularizes a single turn', () => { - const footer = new FooterComponent(baseState({ goal: goal({ turnsUsed: 1 }) })); - const out = strip(footer.render(160)[0]!); + const out = goalExtras(baseState({ goal: goal({ turnsUsed: 1 }) })); expect(out).toContain('1 turn'); expect(out).not.toContain('1 turns'); }); diff --git a/apps/pythinker-code/test/tui/components/status-bar.test.ts b/apps/pythinker-code/test/tui/components/status-bar.test.ts index 05cf1c43..785f3900 100644 --- a/apps/pythinker-code/test/tui/components/status-bar.test.ts +++ b/apps/pythinker-code/test/tui/components/status-bar.test.ts @@ -22,6 +22,7 @@ function status(overrides: Partial = {}): StatusBarStatus { planMode: true, fastMode: false, dynamicWorkflowMode: true, + extras: [], sessionKey: 'session-alpha', ...overrides, }; @@ -80,6 +81,30 @@ describe('StatusBarComponent', () => { chalk.level = previousLevel; } }); + + it('renders extras in order between modes and cwd', () => { + const component = new StatusBarComponent(); + component.update(status({ extras: ['6% · 55.6k/1M', 'main ± [PR#1]'] })); + + const line = stripAnsi(component.render(160)[0] ?? ''); + + expect(line.indexOf('workflow')).toBeLessThan(line.indexOf('6% · 55.6k/1M')); + expect(line.indexOf('6% · 55.6k/1M')).toBeLessThan(line.indexOf('main ± [PR#1]')); + expect(line.indexOf('main ± [PR#1]')).toBeLessThan(line.indexOf('~/project')); + }); + + it('drops extras from the tail before the modes and cwd chips', () => { + const component = new StatusBarComponent(); + component.update(status({ extras: ['first', 'second'] })); + + const line = stripAnsi(component.render(62)[0] ?? ''); + + expect(line).toContain('Model Alpha'); + expect(line).toContain('first'); + expect(line).not.toContain('second'); + expect(line).toContain('workflow'); + expect(line).toContain('~/project'); + }); }); describe('shimmerText', () => { diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index a0fed00b..fd582d23 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -3119,11 +3119,11 @@ command = "vim" rootChildren.indexOf(driver.state.mcpStatusContainer) - 1, ); expect(rootChildren.indexOf(driver.state.mcpStatusContainer)).toBe( - rootChildren.indexOf(driver.state.statusBarContainer) - 1, - ); - expect(rootChildren.indexOf(driver.state.statusBarContainer)).toBe( rootChildren.indexOf(driver.state.editorContainer) - 1, ); + expect(rootChildren.indexOf(driver.state.editorContainer)).toBe( + rootChildren.indexOf(driver.state.statusBarContainer) - 1, + ); expect(transcript).toContain('main answer after btw'); expect(transcript).not.toContain('side answer'); expect(panel).toContain('BTW'); @@ -3947,7 +3947,7 @@ command = "vim" const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('Dynamic Workflow'); // The running row advances through the approved progress-glyph frames. - expect(transcript).toMatch(/001\s+[◜◝◞◟]\s+RUN\s+src\/a.ts/u); + expect(transcript).toMatch(/001\s+[◐◓◑◒]\s+RUN\s+src\/a.ts/u); expect(transcript).toMatch(/002\s+✓\s+DONE\s+src\/b.ts/u); expect(transcript).toMatch(/Orchestrating\s+1\/2 complete/u); expect(transcript).not.toContain('━'); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts index dcd5f6da..6af68ba2 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts @@ -551,17 +551,17 @@ describe('PythinkerTUI startup', () => { expect(driver.state.ui.children).toEqual([driver.state.layoutRoot]); }); - it('places the status bar between MCP startup status and the editor in inline layout', () => { + it('places the status bar directly below the editor in inline layout', () => { const harness = makeHarness(); const driver = makeDriver(harness, makeStartupInput({}, { layout: 'inline' })); const children = driver.state.ui.children; expect(children[0]).toBe(driver.state.transcriptContainer); expect(children.indexOf(driver.state.mcpStatusContainer)).toBe( - children.indexOf(driver.state.statusBarContainer) - 1, - ); - expect(children.indexOf(driver.state.statusBarContainer)).toBe( children.indexOf(driver.state.editorContainer) - 1, ); + expect(children.indexOf(driver.state.editorContainer)).toBe( + children.indexOf(driver.state.statusBarContainer) - 1, + ); }); it('places MCP startup status immediately above the editor in fixed layout', () => { diff --git a/apps/pythinker-code/test/tui/runtime/footer-model.test.ts b/apps/pythinker-code/test/tui/runtime/footer-model.test.ts index 095f748a..e2a0fa46 100644 --- a/apps/pythinker-code/test/tui/runtime/footer-model.test.ts +++ b/apps/pythinker-code/test/tui/runtime/footer-model.test.ts @@ -11,6 +11,7 @@ import { foldFooterEvents, formatStatusRow, selectFooterViewModel as selectFooterViewModelBase, + selectStatusBarExtras, type FooterEvent, type FooterStatus, type FooterStatusRowViewModel, @@ -301,6 +302,31 @@ describe('footer model', () => { }); }); + it('projects status-bar extras without the model and modes items', () => { + const state = foldFooterEvents( + createFooterState({ + model: 'DeepSeek V4 Flash', + contextUsage: 0.05, + dynamicWorkflowMode: true, + git: workflowStatus().git, + }), + [ + { + type: 'update.updated', + update: { version: '0.11.0', state: 'available', percent: null }, + }, + ], + ); + const status = selectFooterViewModel(state, CLOCK_MS).rows.at(-1); + if (status?.kind !== 'status') throw new Error('Expected a status row'); + + expect(selectStatusBarExtras(state, CLOCK_MS, DEFAULT_STATUS_LINE_CONFIG)).toEqual( + status.items.filter( + (item) => item !== 'DeepSeek V4 Flash' && item !== 'workflow', + ), + ); + }); + it('hides model metadata and spend together when the model item is disabled', () => { const row = mainStatusRow(statusConfig({ showModel: false })); From d439110287da76a3aac79987626ba44e0f1453d6 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 21:52:27 -0400 Subject: [PATCH 05/11] fix(tui): clamp light accent contrast, neutral truncated cards, footer renders validation only --- .../src/tui/components/chrome/footer.ts | 18 +++---------- .../src/tui/components/messages/tool-call.ts | 9 ++++--- .../src/tui/utils/session-accent.ts | 21 ++++++++++++++- .../test/tui/components/chrome/footer.test.ts | 4 +-- .../tui/components/messages/tool-call.test.ts | 27 +++++++++++++++++++ .../test/tui/utils/session-accent.test.ts | 21 ++++++++++++++- 6 files changed, 78 insertions(+), 22 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/chrome/footer.ts b/apps/pythinker-code/src/tui/components/chrome/footer.ts index 9f6b7df3..5de1c347 100644 --- a/apps/pythinker-code/src/tui/components/chrome/footer.ts +++ b/apps/pythinker-code/src/tui/components/chrome/footer.ts @@ -272,7 +272,7 @@ export class FooterComponent implements Component { this.state.statusLine, ); return viewModel.rows.flatMap((row) => { - if (row.kind === 'composer' || row.kind === 'status') return []; + if (row.kind === 'composer' || row.kind === 'status' || row.kind === 'activity') return []; return [truncateToWidth(renderLegacyRow(row), width, '…')]; }); } @@ -374,21 +374,9 @@ export class FooterComponent implements Component { } function renderLegacyRow( - row: Exclude< - FooterViewModelRow, - { readonly kind: 'composer' } | { readonly kind: 'status' } - >, + row: Extract, ): string { - switch (row.kind) { - case 'activity': - return row.primary.length === 0 - ? row.indicators.join(' ') - : row.indicators.length === 0 - ? row.primary - : `${row.primary} ${row.indicators.join(' ')}`; - case 'validation': - return row.level === 'info' ? row.message : `${row.level}: ${row.message}`; - } + return row.level === 'info' ? row.message : `${row.level}: ${row.message}`; } function hasGoalBadge(goal: AppState['goal']): boolean { diff --git a/apps/pythinker-code/src/tui/components/messages/tool-call.ts b/apps/pythinker-code/src/tui/components/messages/tool-call.ts index d0f1b18e..b8f3ca51 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-call.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-call.ts @@ -660,11 +660,14 @@ export class ToolCallComponent extends Container { this.headerText.setText(truncateToWidth(this.buildHeader(), Math.max(0, width))); const lines = super.render(width); const background = - this.result === undefined && this.toolCall.truncated !== true - ? 'toolPendingBg' - : this.result !== undefined && this.result.is_error !== true + this.result === undefined + ? this.toolCall.truncated === true + ? undefined + : 'toolPendingBg' + : this.result.is_error !== true ? 'toolSuccessBg' : 'toolErrorBg'; + if (background === undefined) return lines; return lines.map((line, index) => index === 0 ? line diff --git a/apps/pythinker-code/src/tui/utils/session-accent.ts b/apps/pythinker-code/src/tui/utils/session-accent.ts index d4e7955d..b4b4efaa 100644 --- a/apps/pythinker-code/src/tui/utils/session-accent.ts +++ b/apps/pythinker-code/src/tui/utils/session-accent.ts @@ -5,7 +5,26 @@ export function sessionAccentHex(key: string, mode: 'dark' | 'light'): string { hash = Math.imul(hash, 33) + (key.codePointAt(index) ?? 0); } - return hslToHex((hash >>> 0) % 360, 0.9, mode === 'dark' ? 0.72 : 0.42); + return accentHexForHue((hash >>> 0) % 360, mode); +} + +export function accentHexForHue(hue: number, mode: 'dark' | 'light'): string { + if (mode === 'dark') return hslToHex(hue, 0.9, 0.72); + + for (let step = 0; step <= 11; step++) { + const accent = hslToHex(hue, 0.9, Math.max(0.2, 0.42 - step * 0.02)); + if (1.05 / (relativeLuminance(accent) + 0.05) >= 3) return accent; + } + return hslToHex(hue, 0.9, 0.2); +} + +function relativeLuminance(hex: string): number { + const linear = (channel: number) => + channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + const red = linear(Number.parseInt(hex.slice(1, 3), 16) / 255); + const green = linear(Number.parseInt(hex.slice(3, 5), 16) / 255); + const blue = linear(Number.parseInt(hex.slice(5, 7), 16) / 255); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; } function hslToHex(hue: number, saturation: number, lightness: number): string { diff --git a/apps/pythinker-code/test/tui/components/chrome/footer.test.ts b/apps/pythinker-code/test/tui/components/chrome/footer.test.ts index e98233d3..96efa96a 100644 --- a/apps/pythinker-code/test/tui/components/chrome/footer.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/footer.test.ts @@ -118,7 +118,7 @@ describe('FooterComponent', () => { expect(footer.render(160)).toEqual([]); }); - it('renders activity and validation rows but suppresses shared status rows', () => { + it('renders validation rows but suppresses activity and shared status rows', () => { const footer = new FooterComponent(appState); const activity = selectFooterViewModel( foldFooterEvents(createFooterState(), [ @@ -137,7 +137,7 @@ describe('FooterComponent', () => { ); footer.setViewModel(activity); - expect(footer.render(120).map(stripAnsi)).toEqual(['⠋ Waiting for response']); + expect(footer.render(120).map(stripAnsi)).toEqual([]); const validation = selectFooterViewModel( foldFooterEvents(createFooterState(), [ diff --git a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts index 5bcb71c5..e1ecf0db 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts @@ -84,6 +84,33 @@ describe('ToolCallComponent', () => { } }); + it('does not tint a truncated tool call without a result', () => { + const previousLevel = chalk.level; + chalk.level = 3; + const component = new ToolCallComponent( + { + id: 'call_truncated_tint', + name: 'Read', + args: { path: 'foo.ts' }, + truncated: true, + }, + undefined, + ); + + try { + const backgrounds = [ + '\u001B[48;2;29;33;41m', + '\u001B[48;2;20;23;27m', + '\u001B[48;2;41;29;29m', + ]; + expect( + component.render(40).every((line) => backgrounds.every((code) => !line.includes(code))), + ).toBe(true); + } finally { + chalk.level = previousLevel; + } + }); + it('renders MCP resource tools with friendly labels, context, and counts', () => { const list = new ToolCallComponent( { diff --git a/apps/pythinker-code/test/tui/utils/session-accent.test.ts b/apps/pythinker-code/test/tui/utils/session-accent.test.ts index 2b30ccca..5b748bde 100644 --- a/apps/pythinker-code/test/tui/utils/session-accent.test.ts +++ b/apps/pythinker-code/test/tui/utils/session-accent.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it } from 'vitest'; -import { sessionAccentHex } from '#/tui/utils/session-accent'; +import { accentHexForHue, sessionAccentHex } from '#/tui/utils/session-accent'; function channelSum(hex: string): number { return [1, 3, 5].reduce((sum, start) => sum + Number.parseInt(hex.slice(start, start + 2), 16), 0); } +function relativeLuminance(hex: string): number { + const channels = [1, 3, 5].map((start) => Number.parseInt(hex.slice(start, start + 2), 16) / 255); + const [red, green, blue] = channels.map((channel) => + channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4, + ); + return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!; +} + describe('sessionAccentHex', () => { it('returns a stable six-digit hex color for each key', () => { const accent = sessionAccentHex('session-alpha', 'dark'); @@ -25,4 +33,15 @@ describe('sessionAccentHex', () => { channelSum(sessionAccentHex('session-alpha', 'dark')), ); }); + + it('keeps every light-theme hue above the chrome contrast floor', () => { + for (let hue = 0; hue < 360; hue++) { + const contrast = 1.05 / (relativeLuminance(accentHexForHue(hue, 'light')) + 0.05); + expect(contrast, `hue ${String(hue)}`).toBeGreaterThanOrEqual(3); + } + }); + + it('keeps the dark-theme hue mapping unchanged', () => { + expect(accentHexForHue(60, 'dark')).toBe('#F8F877'); + }); }); From 307424d789db6cbeb2366718fa1c2faffc7d29a1 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 22:12:51 -0400 Subject: [PATCH 06/11] test: follow the status line move and calm loader in suite assertions --- .../test/tui/activity-pane.test.ts | 8 +-- .../session-event-handler-goal-queue.test.ts | 60 ++++++++++--------- 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/apps/pythinker-code/test/tui/activity-pane.test.ts b/apps/pythinker-code/test/tui/activity-pane.test.ts index adc5f8fc..49ad1415 100644 --- a/apps/pythinker-code/test/tui/activity-pane.test.ts +++ b/apps/pythinker-code/test/tui/activity-pane.test.ts @@ -220,7 +220,7 @@ describe('updateActivityPane terminal progress', () => { expect(state.activityContainer.children).toHaveLength(0); expect(vi.getTimerCount()).toBe(timersBeforeMissionControl); const output = strip(missionControl.render(100).join('\n')); - expect(output).toContain('⠋ Orchestrating'); + expect(output).toMatch(/[◐◓◑◒] Orchestrating/); expect(output).not.toContain(formatThinkingSpinnerLabel()); state.activitySpinner?.instance.stop(); @@ -288,7 +288,7 @@ describe('updateActivityPane terminal progress', () => { expect(vi.getTimerCount()).toBe(hostTimerCount); const output = strip(missionControl.render(100).join('\n')); expect(output).toContain('✓ Completed'); - expect(output).not.toContain('⠋ Orchestrating'); + expect(output).not.toMatch(/[◐◓◑◒] Orchestrating/); state.activitySpinner?.instance.stop(); driver.sessionEventHandler.clearDynamicWorkflowMissionControls(); @@ -369,14 +369,14 @@ describe('updateActivityPane terminal progress', () => { state.livePane = { ...state.livePane, mode: 'tool' }; driver.updateActivityPane(); const missionControl = startDynamicWorkflow(driver, state); - expect(strip(missionControl.render(100).join('\n'))).toContain('⠋ Orchestrating'); + expect(strip(missionControl.render(100).join('\n'))).toMatch(/[◐◓◑◒] Orchestrating/); cleanup(driver); driver.updateActivityPane(); const output = strip(missionControl.render(100).join('\n')); expect(output).toContain('– Cancelled'); - expect(output).not.toContain('⠋ Orchestrating'); + expect(output).not.toMatch(/[◐◓◑◒] Orchestrating/); state.activitySpinner?.instance.stop(); } finally { vi.useRealTimers(); diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index 952a982e..bc524b22 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -8,6 +8,7 @@ import { createFooterState, reduceFooterState, selectFooterViewModel, + selectStatusBarExtras, type FooterEvent, } from '#/tui/runtime/footer/footer-model'; import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; @@ -174,11 +175,12 @@ function makeTokenSpeedHost() { ), ); }); - return { host, footer }; -} - -function renderFooter(footer: FooterComponent): string { - return footer.render(160)[0]?.replaceAll(/\u001B\[[0-9;]*m/g, '') ?? ''; + return { + host, + footer, + renderStatusBarExtras: () => + selectStatusBarExtras(footerState, Date.now(), DEFAULT_STATUS_LINE_CONFIG).join(' '), + }; } function sendQueuedViaHost(host: ReturnType['host'], session: unknown) { @@ -368,7 +370,7 @@ describe('SessionEventHandler Dynamic Workflow routing', () => { describe('SessionEventHandler token speed', () => { it('projects spend into the footer and retains pricing for /cost', () => { - const { host, footer } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarExtras } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -382,9 +384,9 @@ describe('SessionEventHandler token speed', () => { vi.fn(), ); - expect(renderFooter(footer)).not.toContain('in $3/M out $15/M'); - expect(renderFooter(footer)).toContain('$0.13'); - expect(renderFooter(footer)).not.toContain('spent'); + expect(renderStatusBarExtras()).not.toContain('in $3/M out $15/M'); + expect(renderStatusBarExtras()).toContain('$0.13'); + expect(renderStatusBarExtras()).not.toContain('spent'); expect(host.state.appState.modelCostRates).toEqual({ input: 3, output: 15 }); expect(host.state.appState.totalCostUsd).toBe(0.125); @@ -445,7 +447,7 @@ describe('SessionEventHandler token speed', () => { ])('updates a live estimate from $name and replaces it with exact usage', ({ event }) => { vi.useFakeTimers(); vi.setSystemTime(0); - const { host, footer } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarExtras } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -464,7 +466,7 @@ describe('SessionEventHandler token speed', () => { vi.setSystemTime(3_000); handler.handleEvent(event('x'.repeat(400)), vi.fn()); - expect(renderFooter(footer)).toContain('~100.0 t/s'); + expect(renderStatusBarExtras()).toContain('~100.0 t/s'); handler.handleEvent( { @@ -483,8 +485,8 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderFooter(footer)).toContain('42.0 t/s'); - expect(renderFooter(footer)).not.toContain('~42.0 t/s'); + expect(renderStatusBarExtras()).toContain('42.0 t/s'); + expect(renderStatusBarExtras()).not.toContain('~42.0 t/s'); } finally { footer.dispose(); } @@ -493,7 +495,7 @@ describe('SessionEventHandler token speed', () => { it('keeps concurrent agent stream estimates separate', () => { vi.useFakeTimers(); vi.setSystemTime(0); - const { host, footer } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarExtras } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); const event = (agentId: string, delta: string) => ({ type: 'assistant.delta' as const, @@ -508,18 +510,18 @@ describe('SessionEventHandler token speed', () => { handler.handleEvent(event('agent-b', 'abcd'), vi.fn()); vi.setSystemTime(1_000); handler.handleEvent(event('agent-a', 'x'.repeat(400)), vi.fn()); - expect(renderFooter(footer)).toContain('~100.0 t/s'); + expect(renderStatusBarExtras()).toContain('~100.0 t/s'); vi.setSystemTime(1_500); handler.handleEvent(event('agent-b', 'x'.repeat(200)), vi.fn()); - expect(renderFooter(footer)).toContain('~50.0 t/s'); + expect(renderStatusBarExtras()).toContain('~50.0 t/s'); } finally { footer.dispose(); } }); it('uses the latest valid main or child completed stream', () => { - const { host, footer } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarExtras } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -539,7 +541,7 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderFooter(footer)).toContain('42.0 t/s'); + expect(renderStatusBarExtras()).toContain('42.0 t/s'); handler.handleEvent( { @@ -558,7 +560,7 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderFooter(footer)).toContain('50.0 t/s'); + expect(renderStatusBarExtras()).toContain('50.0 t/s'); } finally { footer.dispose(); } @@ -591,7 +593,7 @@ describe('SessionEventHandler token speed', () => { output: 10, }, Number.NaN], ] as const)('ignores %s', (_label, usage, llmStreamDurationMs) => { - const { host, footer } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarExtras } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -611,7 +613,7 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderFooter(footer)).toContain('42.0 t/s'); + expect(renderStatusBarExtras()).toContain('42.0 t/s'); handler.handleEvent( { @@ -625,14 +627,14 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderFooter(footer)).toContain('42.0 t/s'); + expect(renderStatusBarExtras()).toContain('42.0 t/s'); } finally { footer.dispose(); } }); it('clears completed throughput when the turn ends', () => { - const { host, footer } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarExtras } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -652,18 +654,18 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderFooter(footer)).toContain('42.0 t/s'); + expect(renderStatusBarExtras()).toContain('42.0 t/s'); handler.handleEvent(turnEndedEvent(), vi.fn()); - expect(renderFooter(footer)).not.toContain('t/s'); + expect(renderStatusBarExtras()).not.toContain('t/s'); } finally { footer.dispose(); } }); it('ignores replayed completion metrics and clears on runtime reset', () => { - const { host, footer } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarExtras } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -683,7 +685,7 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderFooter(footer)).toContain('10.0 t/s'); + expect(renderStatusBarExtras()).toContain('10.0 t/s'); host.state.appState.isReplaying = true; handler.handleEvent( @@ -703,10 +705,10 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderFooter(footer)).toContain('10.0 t/s'); + expect(renderStatusBarExtras()).toContain('10.0 t/s'); handler.resetRuntimeState(); - expect(renderFooter(footer)).not.toContain('t/s'); + expect(renderStatusBarExtras()).not.toContain('t/s'); } finally { footer.dispose(); } From f833012460b0c2793eebc92a536c8bed804cf372 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 22:19:16 -0400 Subject: [PATCH 07/11] feat(tui): default context gauge, short cwd chip, braille orchestrating loader --- .../src/tui/components/chrome/status-bar.ts | 16 +++++++++++++--- .../messages/dynamic-workflow-mission-control.ts | 12 +++++++----- .../src/tui/runtime/footer/footer-model.ts | 4 ++-- .../test/tui/activity-pane.test.ts | 6 ++++-- .../dynamic-workflow-mission-control.test.ts | 11 +++++++---- .../test/tui/components/status-bar.test.ts | 16 ++++++++++++++++ .../test/tui/runtime/footer-model.test.ts | 9 ++------- 7 files changed, 51 insertions(+), 23 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/chrome/status-bar.ts b/apps/pythinker-code/src/tui/components/chrome/status-bar.ts index 818c3739..22b4ec50 100644 --- a/apps/pythinker-code/src/tui/components/chrome/status-bar.ts +++ b/apps/pythinker-code/src/tui/components/chrome/status-bar.ts @@ -102,7 +102,17 @@ function renderModesChip(status: StatusBarStatus): string | undefined { } function shortenCwd(cwd: string, homeDir: string | null): string { - if (homeDir === null || homeDir.length === 0) return cwd; - if (cwd === homeDir) return '~'; - return cwd.startsWith(`${homeDir}${sep}`) ? `~${cwd.slice(homeDir.length)}` : cwd; + const path = homeDir !== null && homeDir.length > 0 + ? cwd === homeDir + ? '~' + : cwd.startsWith(`${homeDir}${sep}`) + ? `~${cwd.slice(homeDir.length)}` + : cwd + : cwd; + const segments = path.startsWith(`~${sep}`) + ? path.slice(2).split(sep) + : path.startsWith(sep) + ? path.slice(1).split(sep) + : []; + return segments.length > 2 ? `…${sep}${segments.slice(-2).join(sep)}` : path; } diff --git a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts index 021456da..fa9563ad 100644 --- a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts +++ b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts @@ -1,6 +1,10 @@ import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; -import { DYNAMIC_WORKFLOW_RENDERING } from '#/tui/constant/rendering'; +import { + BRAILLE_SPINNER_FRAMES, + BRAILLE_SPINNER_INTERVAL_MS, + DYNAMIC_WORKFLOW_RENDERING, +} from '#/tui/constant/rendering'; import { currentTheme } from '#/tui/theme'; import { shimmerText } from '#/tui/utils/shimmer'; @@ -509,7 +513,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { const terminal = isTerminalRequestPhase(this.model.requestPhase); const frame = Math.floor( Math.max(0, nowMs - this.model.startedAtMs) / - DYNAMIC_WORKFLOW_RENDERING.progressFrameMs, + BRAILLE_SPINNER_INTERVAL_MS, ); const loader = terminal ? currentTheme.fg(requestPhaseColor(this.model.requestPhase), requestPhaseSymbol(this.model.requestPhase)) @@ -517,9 +521,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { ? currentTheme.fg('primary', '●') : currentTheme.fg( 'primary', - DYNAMIC_WORKFLOW_RENDERING.progressFrames[ - frame % DYNAMIC_WORKFLOW_RENDERING.progressFrames.length - ] ?? DYNAMIC_WORKFLOW_RENDERING.progressFrames[0], + BRAILLE_SPINNER_FRAMES[frame % BRAILLE_SPINNER_FRAMES.length]!, ); const aggregateMembers = this.aggregateMembers(); // All spawned agents are done but the tool result has not arrived yet: diff --git a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts b/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts index b00bb59f..f606af75 100644 --- a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts +++ b/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts @@ -562,11 +562,11 @@ export function selectStatusBarExtras( ): string[] { const parts = selectStatusItemParts(state, clockMs, statusLine); const items = [ + parts.context, + parts.git, parts.update, parts.speed, parts.spend, - parts.context, - parts.git, parts.elapsed, parts.goal, ].filter((item): item is string => item !== null); diff --git a/apps/pythinker-code/test/tui/activity-pane.test.ts b/apps/pythinker-code/test/tui/activity-pane.test.ts index 49ad1415..82597f63 100644 --- a/apps/pythinker-code/test/tui/activity-pane.test.ts +++ b/apps/pythinker-code/test/tui/activity-pane.test.ts @@ -220,7 +220,7 @@ describe('updateActivityPane terminal progress', () => { expect(state.activityContainer.children).toHaveLength(0); expect(vi.getTimerCount()).toBe(timersBeforeMissionControl); const output = strip(missionControl.render(100).join('\n')); - expect(output).toMatch(/[◐◓◑◒] Orchestrating/); + expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Orchestrating/); expect(output).not.toContain(formatThinkingSpinnerLabel()); state.activitySpinner?.instance.stop(); @@ -369,7 +369,9 @@ describe('updateActivityPane terminal progress', () => { state.livePane = { ...state.livePane, mode: 'tool' }; driver.updateActivityPane(); const missionControl = startDynamicWorkflow(driver, state); - expect(strip(missionControl.render(100).join('\n'))).toMatch(/[◐◓◑◒] Orchestrating/); + expect(strip(missionControl.render(100).join('\n'))).toMatch( + /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Orchestrating/, + ); cleanup(driver); driver.updateActivityPane(); diff --git a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts index 53d27640..5ac2e998 100644 --- a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts @@ -356,7 +356,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(vi.getTimerCount()).toBe(timerCount); }); - it('shimmers Orchestrating without changing its text or creating a timer', () => { + it('animates the braille header and shimmers Orchestrating without creating a timer', () => { vi.useFakeTimers(); vi.setSystemTime(0); const previousLevel = chalk.level; @@ -373,8 +373,11 @@ describe('DynamicWorkflowMissionControlComponent', () => { vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS); const after = aggregateLine(component.render(100).join('\n')); - expect(strip(after)).toBe(strip(before)); + expect(strip(after).replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/u, '')).toBe( + strip(before).replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/u, ''), + ); expect(after).not.toBe(before); + expect(strip(after)).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Orchestrating/); expect(strip(after)).toContain('Orchestrating'); expect(vi.getTimerCount()).toBe(timerCount); } finally { @@ -671,8 +674,8 @@ describe('DynamicWorkflowMissionControlComponent', () => { vi.setSystemTime(time); const line = component.render(100).find((candidate) => strip(candidate).includes('001')); expect(line).toContain(chalk.hex(darkColors.primary)(glyph)); - expect(aggregateLine(component.render(100).join('\n'))).toContain( - chalk.hex(darkColors.primary)(glyph), + expect(strip(aggregateLine(component.render(100).join('\n')))).toMatch( + /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Orchestrating/, ); } diff --git a/apps/pythinker-code/test/tui/components/status-bar.test.ts b/apps/pythinker-code/test/tui/components/status-bar.test.ts index 785f3900..50a6afa1 100644 --- a/apps/pythinker-code/test/tui/components/status-bar.test.ts +++ b/apps/pythinker-code/test/tui/components/status-bar.test.ts @@ -105,6 +105,22 @@ describe('StatusBarComponent', () => { expect(line).toContain('workflow'); expect(line).toContain('~/project'); }); + + it.each([ + [ + '/Users/test/Projects/active/pythinker-code-tsc/apps/pythinker-code', + '/Users/test', + '…/apps/pythinker-code', + ], + ['/Users/test/Projects/active', '/Users/test', '~/Projects/active'], + ['/Users/test', '/Users/test', '~'], + ['/a/b/c/d', '/Users/test', '…/c/d'], + ])('shortens cwd %s to %s', (cwd, homeDir, expected) => { + const component = new StatusBarComponent(); + component.update(status({ cwd, homeDir })); + + expect(stripAnsi(component.render(240)[0] ?? '')).toContain(expected); + }); }); describe('shimmerText', () => { diff --git a/apps/pythinker-code/test/tui/runtime/footer-model.test.ts b/apps/pythinker-code/test/tui/runtime/footer-model.test.ts index e2a0fa46..7d515012 100644 --- a/apps/pythinker-code/test/tui/runtime/footer-model.test.ts +++ b/apps/pythinker-code/test/tui/runtime/footer-model.test.ts @@ -302,7 +302,7 @@ describe('footer model', () => { }); }); - it('projects status-bar extras without the model and modes items', () => { + it('projects status-bar extras in priority order without the model and modes items', () => { const state = foldFooterEvents( createFooterState({ model: 'DeepSeek V4 Flash', @@ -317,13 +317,8 @@ describe('footer model', () => { }, ], ); - const status = selectFooterViewModel(state, CLOCK_MS).rows.at(-1); - if (status?.kind !== 'status') throw new Error('Expected a status row'); - expect(selectStatusBarExtras(state, CLOCK_MS, DEFAULT_STATUS_LINE_CONFIG)).toEqual( - status.items.filter( - (item) => item !== 'DeepSeek V4 Flash' && item !== 'workflow', - ), + ['▱▱▱▱▱▱▱▱ 5%', 'main ↑15', '↑ v0.11.0'], ); }); From 345c24c00f63341613af6102d83e6ebf4dd3cfa0 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 23:23:04 -0400 Subject: [PATCH 08/11] fix(tui): honor status_line toggles and calm the shimmer sweep Gate the status bar model, effort, and mode chips on their status_line settings, drop the effort suffix when thinking is off, and paint the yolo badge with the error token. Slow the shimmer sweep and alternate the mission-control peak with a warning highlight; remove the dead frame option in favor of a documented bandHalfWidth. --- .changeset/tui-signature-design.md | 2 +- .../tui/components/chrome/activity-loader.ts | 1 - .../src/tui/components/chrome/status-bar.ts | 21 ++-- .../src/tui/components/dialogs/compaction.ts | 1 - .../dynamic-workflow-mission-control.ts | 9 +- .../src/tui/components/messages/thinking.ts | 3 +- apps/pythinker-code/src/tui/pythinker-tui.ts | 1 + apps/pythinker-code/src/tui/utils/shimmer.ts | 33 ++++-- .../dynamic-workflow-mission-control.test.ts | 2 +- .../test/tui/components/status-bar.test.ts | 112 ++++++++++-------- .../test/tui/utils/shimmer.test.ts | 105 ++++++++++++++++ 11 files changed, 210 insertions(+), 80 deletions(-) create mode 100644 apps/pythinker-code/test/tui/utils/shimmer.test.ts diff --git a/.changeset/tui-signature-design.md b/.changeset/tui-signature-design.md index c4342fa9..e34ff208 100644 --- a/.changeset/tui-signature-design.md +++ b/.changeset/tui-signature-design.md @@ -2,4 +2,4 @@ "@pythoughts/pythinker-code": minor --- -Redesign core TUI surfaces: tool cards get state-tinted backgrounds (running, success, error — three new theme tokens), a status bar with a per-session accent color appears above the input box, the input border reflects yolo and auto permission modes, and the working-label shimmer uses a smoother constant-velocity sweep. +Redesign core TUI surfaces: tool cards get state-tinted backgrounds with three new theme tokens, a status bar with a per-session accent color appears between the input box and footer, and the input border reflects yolo and auto permission modes. The working-label shimmer uses a calmer constant-velocity sweep with alternating mission-control highlights. diff --git a/apps/pythinker-code/src/tui/components/chrome/activity-loader.ts b/apps/pythinker-code/src/tui/components/chrome/activity-loader.ts index fdbc369a..d7425397 100644 --- a/apps/pythinker-code/src/tui/components/chrome/activity-loader.ts +++ b/apps/pythinker-code/src/tui/components/chrome/activity-loader.ts @@ -89,7 +89,6 @@ export class ActivityLoader extends Text { ? shimmerText(this.label, { baseToken: 'primary', shimmerToken: 'primaryShimmer', - frame: this.animationFrame, }) : this.label; this.displayText = label ? `${coloredFrame} ${label}` : coloredFrame; diff --git a/apps/pythinker-code/src/tui/components/chrome/status-bar.ts b/apps/pythinker-code/src/tui/components/chrome/status-bar.ts index 22b4ec50..35ea5e2b 100644 --- a/apps/pythinker-code/src/tui/components/chrome/status-bar.ts +++ b/apps/pythinker-code/src/tui/components/chrome/status-bar.ts @@ -3,6 +3,7 @@ import { sep } from 'node:path'; import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; import chalk from 'chalk'; +import type { StatusLineConfig } from '#/tui/config'; import type { FooterStatus } from '#/tui/runtime/footer/footer-model'; import { currentTheme } from '#/tui/theme'; import { themeFromHexChannels } from '#/tui/theme/terminal-background'; @@ -22,6 +23,7 @@ export type StatusBarStatus = Pick< > & { readonly extras: readonly string[]; readonly sessionKey: string; + readonly statusLine: StatusLineConfig; }; export class StatusBarComponent implements Component { @@ -35,13 +37,16 @@ export class StatusBarComponent implements Component { const status = this.status; if (status === undefined) return []; - const modelChip = chip( - `${currentTheme.fg('text', status.model)}${currentTheme.fg('textDim', ' · ')}${currentTheme.fg( - effortColorToken(status.thinkingLevel), - shortEffortLabel(status.thinkingLevel), - )}`, - ); - let modesChip = renderModesChip(status); + const effortSuffix = status.statusLine.showEffort && status.thinkingLevel !== 'off' + ? `${currentTheme.fg('textDim', ' · ')}${currentTheme.fg( + effortColorToken(status.thinkingLevel), + shortEffortLabel(status.thinkingLevel), + )}` + : ''; + const modelChip = status.statusLine.showModel + ? chip(`${currentTheme.fg('text', status.model)}${effortSuffix}`) + : undefined; + let modesChip = status.statusLine.showModes ? renderModesChip(status) : undefined; const extraChips = status.extras.map((extra) => chip(currentTheme.fg('textDim', extra)), ); @@ -95,7 +100,7 @@ function renderModesChip(status: StatusBarStatus): string | undefined { const modes: string[] = []; if (status.planMode) modes.push(currentTheme.fg('modePlan', 'plan')); if (status.permissionMode === 'auto') modes.push(currentTheme.fg('modePermission', 'auto')); - if (status.permissionMode === 'yolo') modes.push(currentTheme.fg('modeAutoAccept', 'yolo')); + if (status.permissionMode === 'yolo') modes.push(currentTheme.fg('error', 'yolo')); if (status.fastMode) modes.push(currentTheme.fg('modeFast', '↯ fast')); if (status.dynamicWorkflowMode) modes.push(currentTheme.fg('accent', 'workflow')); return modes.length === 0 ? undefined : chip(modes.join(' ')); diff --git a/apps/pythinker-code/src/tui/components/dialogs/compaction.ts b/apps/pythinker-code/src/tui/components/dialogs/compaction.ts index 8b00231e..45d49a7b 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/compaction.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/compaction.ts @@ -182,7 +182,6 @@ export class CompactionComponent extends Container { shimmerText('Compacting conversation…', { baseToken: 'primary', shimmerToken: 'primaryShimmer', - frame: this.animationFrame, }), ); return `${label}${currentTheme.dim(` (${String(this.elapsedSeconds())}s)`)}`; diff --git a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts index fa9563ad..5199508c 100644 --- a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts +++ b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts @@ -538,15 +538,10 @@ export class DynamicWorkflowMissionControlComponent implements Component { const label = terminal ? currentTheme.fg('text', requestPhaseLabel(this.model.requestPhase)) : shimmerText(finalizing ? FINALIZING_LABEL : ORCHESTRATING_LABEL, { - // `primary` / `primaryShimmer` are a designed pair, so the sweep stays - // periwinkle throughout; the old grey `text` base washed it out. baseToken: 'primary', shimmerToken: 'primaryShimmer', - frame: Math.floor( - Math.max(0, nowMs - this.model.startedAtMs) / - DYNAMIC_WORKFLOW_RENDERING.aggregateShimmerFrameMs, - ), - windowSize: 4, + altShimmerToken: 'warningShimmer', + bandHalfWidth: 4, }); const paddedLabel = padToWidth(label, LIVE_LABEL_WIDTH); const prefix = `${loader} ${paddedLabel}`; diff --git a/apps/pythinker-code/src/tui/components/messages/thinking.ts b/apps/pythinker-code/src/tui/components/messages/thinking.ts index 26789a8a..9c7b7b56 100644 --- a/apps/pythinker-code/src/tui/components/messages/thinking.ts +++ b/apps/pythinker-code/src/tui/components/messages/thinking.ts @@ -100,8 +100,7 @@ export class ThinkingComponent implements Component { const label = shimmerText(formatThinkingSpinnerLabel(), { baseToken: 'primary', shimmerToken: 'primaryShimmer', - frame: this.animationFrame, - windowSize: 4, + bandHalfWidth: 4, }); return ['', spinner + label, ...visibleLines.map((line) => MESSAGE_INDENT + line)]; } diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index 8b42d5df..83ff6f1d 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -1406,6 +1406,7 @@ export class PythinkerTUI { this.state.appState.sessionTitle?.trim() || this.state.appState.sessionId || this.state.appState.workDir, + statusLine: this.state.appState.statusLine, }); } diff --git a/apps/pythinker-code/src/tui/utils/shimmer.ts b/apps/pythinker-code/src/tui/utils/shimmer.ts index 2aec475f..3f588830 100644 --- a/apps/pythinker-code/src/tui/utils/shimmer.ts +++ b/apps/pythinker-code/src/tui/utils/shimmer.ts @@ -3,12 +3,13 @@ import { currentTheme, type ColorToken } from '#/tui/theme'; export interface ShimmerTextOptions { baseToken: ColorToken; shimmerToken: ColorToken; - frame: number; - windowSize?: number; + altShimmerToken?: ColorToken; + /** Half-width of the cosine shimmer band, in terminal cells. */ + bandHalfWidth?: number; phaseOffset?: number; } -const CELLS_PER_SECOND = 30; +const CELLS_PER_SECOND = 20; const BAND_HALF_WIDTH = 6; type ShimmerTier = 'dim' | 'base' | 'shimmer'; @@ -17,11 +18,14 @@ export function shimmerText(text: string, options: ShimmerTextOptions): string { const chars = Array.from(text); if (chars.length === 0) return ''; - const halfWidth = Math.max(1, options.windowSize ?? BAND_HALF_WIDTH); + const halfWidth = Math.max(1, options.bandHalfWidth ?? BAND_HALF_WIDTH); const cycleLength = chars.length + halfWidth * 2; - const center = - ((Date.now() / 1_000 * CELLS_PER_SECOND + (options.phaseOffset ?? 0)) % cycleLength) - - halfWidth; + const rawPosition = Date.now() / 1_000 * CELLS_PER_SECOND + (options.phaseOffset ?? 0); + const center = rawPosition % cycleLength - halfWidth; + const passIndex = Math.floor(rawPosition / cycleLength); + const peakToken = options.altShimmerToken !== undefined && passIndex % 2 !== 0 + ? options.altShimmerToken + : options.shimmerToken; let result = ''; let segment = ''; @@ -46,20 +50,25 @@ export function shimmerText(text: string, options: ShimmerTextOptions): string { continue; } - result += paintTier(activeTier, segment, options); + result += paintTier(activeTier, segment, options.baseToken, peakToken); activeTier = tier; segment = char; } if (activeTier !== undefined) { - result += paintTier(activeTier, segment, options); + result += paintTier(activeTier, segment, options.baseToken, peakToken); } return result; } -function paintTier(tier: ShimmerTier, text: string, options: ShimmerTextOptions): string { +function paintTier( + tier: ShimmerTier, + text: string, + baseToken: ColorToken, + peakToken: ColorToken, +): string { if (tier === 'dim') return currentTheme.fg('textDim', text); - if (tier === 'shimmer') return currentTheme.boldFg(options.shimmerToken, text); - return currentTheme.fg(options.baseToken, text); + if (tier === 'shimmer') return currentTheme.boldFg(peakToken, text); + return currentTheme.fg(baseToken, text); } diff --git a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts index 5ac2e998..23d2005f 100644 --- a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts @@ -417,7 +417,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(first).toContain(chalk.hex(darkColors.primary)('◐')); expect(second).toContain(chalk.hex(darkColors.primary)('◓')); expect(first).toContain(chalk.hex(darkColors.primary)('RUN')); - vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS); + vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS * 1.5); const aggregate = aggregateLine(component.render(100).join('\n')); expect(strip(aggregate)).toContain('Orchestrating'); diff --git a/apps/pythinker-code/test/tui/components/status-bar.test.ts b/apps/pythinker-code/test/tui/components/status-bar.test.ts index 50a6afa1..1ceaaa9b 100644 --- a/apps/pythinker-code/test/tui/components/status-bar.test.ts +++ b/apps/pythinker-code/test/tui/components/status-bar.test.ts @@ -1,12 +1,13 @@ import { visibleWidth } from '@earendil-works/pi-tui'; import chalk from 'chalk'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { StatusBarComponent, type StatusBarStatus, } from '#/tui/components/chrome/status-bar'; -import { shimmerText } from '#/tui/utils/shimmer'; +import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; +import { currentTheme, darkColors } from '#/tui/theme'; function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/gu, ''); @@ -24,6 +25,7 @@ function status(overrides: Partial = {}): StatusBarStatus { dynamicWorkflowMode: true, extras: [], sessionKey: 'session-alpha', + statusLine: DEFAULT_STATUS_LINE_CONFIG, ...overrides, }; } @@ -39,6 +41,67 @@ describe('StatusBarComponent', () => { expect(stripAnsi(lines[0] ?? '')).toContain('Model Alpha · high'); }); + it('omits the effort suffix when thinking is off', () => { + const component = new StatusBarComponent(); + component.update(status({ thinkingLevel: 'off' })); + + const line = stripAnsi(component.render(80)[0] ?? ''); + + expect(line).toContain('Model Alpha'); + expect(line).not.toContain('· off'); + }); + + it('hides the model chip when showModel is false', () => { + const component = new StatusBarComponent(); + component.update(status({ + statusLine: { ...DEFAULT_STATUS_LINE_CONFIG, showModel: false }, + })); + + expect(stripAnsi(component.render(80)[0] ?? '')).not.toContain('Model Alpha'); + }); + + it('hides the modes chip when showModes is false', () => { + const component = new StatusBarComponent(); + component.update(status({ + statusLine: { ...DEFAULT_STATUS_LINE_CONFIG, showModes: false }, + })); + + const line = stripAnsi(component.render(80)[0] ?? ''); + + expect(line).not.toContain('plan'); + expect(line).not.toContain('auto'); + expect(line).not.toContain('workflow'); + }); + + it('hides only the effort suffix when showEffort is false', () => { + const component = new StatusBarComponent(); + component.update(status({ + statusLine: { ...DEFAULT_STATUS_LINE_CONFIG, showEffort: false }, + })); + + const line = stripAnsi(component.render(80)[0] ?? ''); + + expect(line).toContain('Model Alpha'); + expect(line).not.toContain('· high'); + }); + + it('renders yolo with the error colour', () => { + const previousLevel = chalk.level; + const previousPalette = currentTheme.palette; + chalk.level = 3; + currentTheme.setPalette(darkColors); + + try { + const component = new StatusBarComponent(); + component.update(status({ permissionMode: 'yolo' })); + + expect(component.render(80)[0] ?? '').toContain(chalk.hex(darkColors.error)('yolo')); + } finally { + chalk.level = previousLevel; + currentTheme.setPalette(previousPalette); + } + }); + it('drops the gap, modes, and cwd in that order as width shrinks', () => { const component = new StatusBarComponent(); component.update(status()); @@ -122,48 +185,3 @@ describe('StatusBarComponent', () => { expect(stripAnsi(component.render(240)[0] ?? '')).toContain(expected); }); }); - -describe('shimmerText', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('preserves the input text when ANSI is removed', () => { - vi.spyOn(Date, 'now').mockReturnValue(0); - const text = 'Thinking carefully'; - - expect( - stripAnsi( - shimmerText(text, { - baseToken: 'primary', - shimmerToken: 'primaryShimmer', - frame: 0, - }), - ), - ).toBe(text); - }); - - it('moves the cosine band with wall-clock time', () => { - const previousLevel = chalk.level; - chalk.level = 3; - const now = vi.spyOn(Date, 'now'); - try { - now.mockReturnValue(0); - const first = shimmerText('abcdefghijklmno', { - baseToken: 'primary', - shimmerToken: 'primaryShimmer', - frame: 0, - }); - now.mockReturnValue(100); - const second = shimmerText('abcdefghijklmno', { - baseToken: 'primary', - shimmerToken: 'primaryShimmer', - frame: 0, - }); - - expect(second).not.toBe(first); - } finally { - chalk.level = previousLevel; - } - }); -}); diff --git a/apps/pythinker-code/test/tui/utils/shimmer.test.ts b/apps/pythinker-code/test/tui/utils/shimmer.test.ts new file mode 100644 index 00000000..cce1c38a --- /dev/null +++ b/apps/pythinker-code/test/tui/utils/shimmer.test.ts @@ -0,0 +1,105 @@ +import chalk from 'chalk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { currentTheme, darkColors } from '#/tui/theme'; +import { shimmerText } from '#/tui/utils/shimmer'; + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/gu, ''); +} + +describe('shimmerText', () => { + let previousLevel = chalk.level; + let previousPalette = currentTheme.palette; + + beforeEach(() => { + previousLevel = chalk.level; + previousPalette = currentTheme.palette; + chalk.level = 3; + currentTheme.setPalette(darkColors); + }); + + afterEach(() => { + vi.restoreAllMocks(); + chalk.level = previousLevel; + currentTheme.setPalette(previousPalette); + }); + + it('preserves the input text when ANSI is removed', () => { + vi.spyOn(Date, 'now').mockReturnValue(0); + const text = 'Thinking carefully'; + + expect( + stripAnsi( + shimmerText(text, { + baseToken: 'primary', + shimmerToken: 'primaryShimmer', + }), + ), + ).toBe(text); + }); + + it('moves the cosine band with wall-clock time', () => { + const now = vi.spyOn(Date, 'now'); + now.mockReturnValue(0); + const first = shimmerText('abcdefghijklmno', { + baseToken: 'primary', + shimmerToken: 'primaryShimmer', + }); + now.mockReturnValue(100); + const second = shimmerText('abcdefghijklmno', { + baseToken: 'primary', + shimmerToken: 'primaryShimmer', + }); + + expect(second).not.toBe(first); + }); + + it('alternates the peak token after one full sweep', () => { + const now = vi.spyOn(Date, 'now'); + const options = { + baseToken: 'primary' as const, + shimmerToken: 'primaryShimmer' as const, + altShimmerToken: 'warningShimmer' as const, + bandHalfWidth: 1, + }; + + now.mockReturnValue(50); + const first = shimmerText('abcde', options); + now.mockReturnValue(400); + const second = shimmerText('abcde', options); + + expect(first).toContain(chalk.hex(darkColors.primaryShimmer).bold('a')); + expect(second).toContain(chalk.hex(darkColors.warningShimmer).bold('a')); + }); + + it('keeps the primary peak token when no alternate is set', () => { + const now = vi.spyOn(Date, 'now'); + const options = { + baseToken: 'primary' as const, + shimmerToken: 'primaryShimmer' as const, + bandHalfWidth: 1, + }; + + now.mockReturnValue(50); + const first = shimmerText('abcde', options); + now.mockReturnValue(400); + const second = shimmerText('abcde', options); + + expect(first).toContain(chalk.hex(darkColors.primaryShimmer).bold('a')); + expect(second).toContain(chalk.hex(darkColors.primaryShimmer).bold('a')); + }); + + it('advances the band at twenty cells per second', () => { + vi.spyOn(Date, 'now').mockReturnValue(100); + + const output = shimmerText('abcdefghijklmno', { + baseToken: 'primary', + shimmerToken: 'primaryShimmer', + bandHalfWidth: 1, + }); + + expect(output).toContain(chalk.hex(darkColors.primaryShimmer).bold('b')); + expect(output).not.toContain(chalk.hex(darkColors.primaryShimmer).bold('c')); + }); +}); From 431c89609625b3f813ba4d11ff78d8cd51ddc283 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 11 Aug 2026 23:40:09 -0400 Subject: [PATCH 09/11] fix(tui): neutral prompt border and token speed on the model chip The prompt box no longer tints by permission mode or thinking effort; it uses the neutral border color, and yolo stays visible as a status bar badge. Token speed returns to the model chip, matching the footer composition, instead of sitting mid-ladder in the extras. --- .changeset/tui-signature-design.md | 2 +- .../src/tui/components/chrome/status-bar.ts | 18 ++++++++-- .../src/tui/constant/rendering.ts | 2 -- apps/pythinker-code/src/tui/pythinker-tui.ts | 13 +------ .../src/tui/runtime/footer/footer-model.ts | 5 +-- .../test/tui/components/status-bar.test.ts | 36 +++++++++++++++++++ .../tui/pythinker-tui-message-flow.test.ts | 20 +++++++---- .../test/tui/runtime/footer-model.test.ts | 2 ++ 8 files changed, 71 insertions(+), 27 deletions(-) diff --git a/.changeset/tui-signature-design.md b/.changeset/tui-signature-design.md index e34ff208..45887c6b 100644 --- a/.changeset/tui-signature-design.md +++ b/.changeset/tui-signature-design.md @@ -2,4 +2,4 @@ "@pythoughts/pythinker-code": minor --- -Redesign core TUI surfaces: tool cards get state-tinted backgrounds with three new theme tokens, a status bar with a per-session accent color appears between the input box and footer, and the input border reflects yolo and auto permission modes. The working-label shimmer uses a calmer constant-velocity sweep with alternating mission-control highlights. +Redesign core TUI surfaces: tool cards get state-tinted backgrounds with three new theme tokens, a status bar with a per-session accent color appears between the input box and footer, and the prompt box uses a neutral border while permission mode appears in the status bar. The working-label shimmer uses a calmer constant-velocity sweep with alternating mission-control highlights. diff --git a/apps/pythinker-code/src/tui/components/chrome/status-bar.ts b/apps/pythinker-code/src/tui/components/chrome/status-bar.ts index 35ea5e2b..329e05b0 100644 --- a/apps/pythinker-code/src/tui/components/chrome/status-bar.ts +++ b/apps/pythinker-code/src/tui/components/chrome/status-bar.ts @@ -4,7 +4,10 @@ import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/p import chalk from 'chalk'; import type { StatusLineConfig } from '#/tui/config'; -import type { FooterStatus } from '#/tui/runtime/footer/footer-model'; +import { + formatTokenSpeed, + type FooterStatus, +} from '#/tui/runtime/footer/footer-model'; import { currentTheme } from '#/tui/theme'; import { themeFromHexChannels } from '#/tui/theme/terminal-background'; import { effortColorToken, shortEffortLabel } from '#/tui/utils/thinking-levels'; @@ -20,6 +23,8 @@ export type StatusBarStatus = Pick< | 'planMode' | 'fastMode' | 'dynamicWorkflowMode' + | 'tokenSpeed' + | 'tokenSpeedEstimated' > & { readonly extras: readonly string[]; readonly sessionKey: string; @@ -43,8 +48,16 @@ export class StatusBarComponent implements Component { shortEffortLabel(status.thinkingLevel), )}` : ''; + const fastSuffix = status.statusLine.showModes && status.fastMode + ? `${currentTheme.fg('textDim', ' · ')}${currentTheme.fg('modeFast', '↯ fast')}` + : ''; + const speed = status.statusLine.showTokenSpeed ? formatTokenSpeed(status) : null; const modelChip = status.statusLine.showModel - ? chip(`${currentTheme.fg('text', status.model)}${effortSuffix}`) + ? chip( + `${currentTheme.fg('text', status.model)}${effortSuffix}${fastSuffix}${ + speed === null ? '' : currentTheme.fg('textDim', ` · ${speed}`) + }`, + ) : undefined; let modesChip = status.statusLine.showModes ? renderModesChip(status) : undefined; const extraChips = status.extras.map((extra) => @@ -101,7 +114,6 @@ function renderModesChip(status: StatusBarStatus): string | undefined { if (status.planMode) modes.push(currentTheme.fg('modePlan', 'plan')); if (status.permissionMode === 'auto') modes.push(currentTheme.fg('modePermission', 'auto')); if (status.permissionMode === 'yolo') modes.push(currentTheme.fg('error', 'yolo')); - if (status.fastMode) modes.push(currentTheme.fg('modeFast', '↯ fast')); if (status.dynamicWorkflowMode) modes.push(currentTheme.fg('accent', 'workflow')); return modes.length === 0 ? undefined : chip(modes.join(' ')); } diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index 119ce1eb..b2abe4f9 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -30,8 +30,6 @@ export const DYNAMIC_WORKFLOW_RENDERING = { memberProgressWidth: 8, /** Least width of the lifecycle STATE column in member rows. */ stateColumnWidth: 6, - /** Cadence for the live aggregate-label shimmer. */ - aggregateShimmerFrameMs: BRAILLE_SPINNER_INTERVAL_MS, /** Half-circle frames for a running row; all rows share one clock. */ progressFrames: ['◐', '◓', '◑', '◒'], /** Rotation cadence in milliseconds — deliberately slow; this is ambience, not progress. */ diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index 83ff6f1d..aba9e0c6 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -27,7 +27,6 @@ import { readUpdateInstallState } from '#/cli/update/install-state'; import { detectInstallSource } from '#/cli/update/source'; import type { InstallSource } from '#/cli/update/types'; import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index'; -import { effortColorToken } from '#/tui/utils/thinking-levels'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { appendInputHistory, @@ -2128,19 +2127,9 @@ export class PythinkerTUI { const highlighted = this.state.appState.planMode || findSlashAutocompleteContext(currentLine, col) !== null; this.state.editor.borderHighlighted = highlighted; - // Reads thinkingLevel at paint time so cycling effort (Shift-Tab/Ctrl-T) - // recolors the prompt box on the next render without re-wiring the closure. this.state.editor.borderColor = (s: string) => { if (highlighted) return currentTheme.fg('primary', s); - if (this.state.appState.permissionMode === 'yolo') { - return currentTheme.fg('modeAutoAccept', s); - } - if (this.state.appState.permissionMode === 'auto') { - return currentTheme.fg('modePermission', s); - } - const level = this.state.appState.thinkingLevel; - if (level === 'off' || level.trim().length === 0) return currentTheme.fg('border', s); - return currentTheme.fg(effortColorToken(level), s); + return currentTheme.fg('border', s); }; this.state.ui.requestRender(); } diff --git a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts b/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts index f606af75..1493da0e 100644 --- a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts +++ b/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts @@ -565,7 +565,6 @@ export function selectStatusBarExtras( parts.context, parts.git, parts.update, - parts.speed, parts.spend, parts.elapsed, parts.goal, @@ -638,7 +637,9 @@ function formatStatusElapsed(ms: number): string { return totalMinutes < 60 ? clock : `${String(Math.floor(totalMinutes / 60))}:${clock}`; } -function formatTokenSpeed(status: FooterStatus): string | null { +export function formatTokenSpeed( + status: Pick, +): string | null { const speed = status.tokenSpeed; if (speed === null || !Number.isFinite(speed) || speed < 0) return null; return `${status.tokenSpeedEstimated ? '~' : ''}${speed.toFixed(1)} t/s`; diff --git a/apps/pythinker-code/test/tui/components/status-bar.test.ts b/apps/pythinker-code/test/tui/components/status-bar.test.ts index 1ceaaa9b..feff8bc3 100644 --- a/apps/pythinker-code/test/tui/components/status-bar.test.ts +++ b/apps/pythinker-code/test/tui/components/status-bar.test.ts @@ -23,6 +23,8 @@ function status(overrides: Partial = {}): StatusBarStatus { planMode: true, fastMode: false, dynamicWorkflowMode: true, + tokenSpeed: null, + tokenSpeedEstimated: false, extras: [], sessionKey: 'session-alpha', statusLine: DEFAULT_STATUS_LINE_CONFIG, @@ -145,6 +147,40 @@ describe('StatusBarComponent', () => { } }); + it('renders token speed at the end of the model chip', () => { + const component = new StatusBarComponent(); + component.update(status({ + fastMode: true, + tokenSpeed: 75.7, + tokenSpeedEstimated: true, + })); + + const modelChip = stripAnsi(component.render(120)[0] ?? '').split(' ')[0]?.trim(); + + expect(modelChip).toBe('Model Alpha · high · ↯ fast · ~75.7 t/s'); + }); + + it('hides token speed when showTokenSpeed is false', () => { + const component = new StatusBarComponent(); + component.update(status({ + tokenSpeed: 75.7, + statusLine: { ...DEFAULT_STATUS_LINE_CONFIG, showTokenSpeed: false }, + })); + + const modelChip = stripAnsi(component.render(120)[0] ?? '').split(' ')[0]?.trim(); + + expect(modelChip).toBe('Model Alpha · high'); + }); + + it('does not leave a separator when token speed is null', () => { + const component = new StatusBarComponent(); + component.update(status({ fastMode: true, tokenSpeed: null })); + + const modelChip = stripAnsi(component.render(120)[0] ?? '').split(' ')[0]?.trim(); + + expect(modelChip).toBe('Model Alpha · high · ↯ fast'); + }); + it('renders extras in order between modes and cwd', () => { const component = new StatusBarComponent(); component.update(status({ extras: ['6% · 55.6k/1M', 'main ± [PR#1]'] })); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index fd582d23..b1df40d3 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -50,6 +50,7 @@ import { promptFeedbackInput, runModelSelector, } from '#/tui/commands/prompts'; +import { currentTheme } from '#/tui/theme'; import type { QueuedMessage } from '#/tui/types'; import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; import { LEGACY_TEST_PATHS, PARITY_CASES } from './parity/feature-matrix'; @@ -5435,7 +5436,7 @@ command = "vim" expect(driver.state.appState.thinkingLevel).toBe('off'); }); - it('tints the prompt-box border by the current thinking effort', async () => { + it('keeps the prompt-box border neutral across thinking effort and permission mode', async () => { const session = makeSession(); const { driver } = await makeDriver(session, { getConfig: vi.fn(async () => ({ @@ -5459,16 +5460,21 @@ command = "vim" chalk.level = 3; try { const tui = driver as unknown as PythinkerTUI; - const paintAt = (level: string): string => { - tui.setAppState({ thinkingLevel: level }); + const paintAt = (thinkingLevel: string): string => { + tui.setAppState({ thinkingLevel }); return driver.state.editor.borderColor('─'); }; const offPaint = paintAt('off'); + tui.setAppState({ permissionMode: 'yolo' }); + expect(driver.state.editor.borderColor('─')).toBe(offPaint); + + tui.setAppState({ permissionMode: 'manual' }); const perLevel = ['low', 'medium', 'high'].map(paintAt); - // Effort levels tint the border away from the default, each with the - // theme's own gradient stop for that level. - for (const painted of perLevel) expect(painted).not.toBe(offPaint); - expect(new Set(perLevel).size).toBe(perLevel.length); + for (const painted of perLevel) expect(painted).toBe(offPaint); + + tui.setAppState({ planMode: true }); + expect(driver.state.editor.borderColor('─')).toBe(currentTheme.fg('primary', '─')); + expect(driver.state.editor.borderColor('─')).not.toBe(offPaint); } finally { chalk.level = previousLevel; } diff --git a/apps/pythinker-code/test/tui/runtime/footer-model.test.ts b/apps/pythinker-code/test/tui/runtime/footer-model.test.ts index 7d515012..13e15881 100644 --- a/apps/pythinker-code/test/tui/runtime/footer-model.test.ts +++ b/apps/pythinker-code/test/tui/runtime/footer-model.test.ts @@ -309,6 +309,8 @@ describe('footer model', () => { contextUsage: 0.05, dynamicWorkflowMode: true, git: workflowStatus().git, + tokenSpeed: 75.7, + tokenSpeedEstimated: true, }), [ { From ebfb6749b60496c9354803f1a13e0e44d1bf32f0 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 12 Aug 2026 00:09:01 -0400 Subject: [PATCH 10/11] test: follow the token speed move and rate-independent shimmer checks --- .../src/tui/runtime/footer/footer-model.ts | 2 +- .../tui/components/dialogs/compaction.test.ts | 34 +++++++++------ .../tui/components/messages/thinking.test.ts | 15 +++++-- .../session-event-handler-goal-queue.test.ts | 43 ++++++++++--------- 4 files changed, 57 insertions(+), 37 deletions(-) diff --git a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts b/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts index 1493da0e..ae567f57 100644 --- a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts +++ b/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts @@ -464,7 +464,7 @@ function selectActivityRow(state: FooterState): FooterActivityRowViewModel { }); } -function selectStatusItemParts( +export function selectStatusItemParts( state: FooterState, clockMs: number, statusLine: StatusLineConfig, diff --git a/apps/pythinker-code/test/tui/components/dialogs/compaction.test.ts b/apps/pythinker-code/test/tui/components/dialogs/compaction.test.ts index 28dffb4e..4066bcae 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/compaction.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/compaction.test.ts @@ -81,26 +81,34 @@ describe('CompactionComponent', () => { const firstHeader = firstRender.find((line) => strip(line).includes('Compacting conversation…')); const firstBar = firstRender.find((line) => strip(line).includes('▱')); - vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS); - const animationRender = component.render(120); - const animationHeader = animationRender.find((line) => - strip(line).includes('Compacting conversation…'), - ); - const animationBar = animationRender.find((line) => strip(line).includes('▱')); - expect(firstHeader).toBeDefined(); expect(firstBar).toBeDefined(); - expect(animationHeader).toBeDefined(); - expect(animationBar).toBeDefined(); - expect(strip(animationHeader)).toBe(strip(firstHeader)); - expect(animationHeader).not.toBe(firstHeader); - expect(animationBar).toBe(firstBar); + + const headerSamples = [firstHeader]; + const barSamples = [firstBar]; + const shimmerSampleCount = 12; + for (let sample = 0; sample < shimmerSampleCount; sample++) { + vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS); + const render = component.render(120); + headerSamples.push( + render.find((line) => strip(line).includes('Compacting conversation…')), + ); + barSamples.push(render.find((line) => strip(line).includes('▱'))); + } + + expect(headerSamples.every((header) => header !== undefined)).toBe(true); + expect(barSamples.every((bar) => bar !== undefined)).toBe(true); + expect(new Set(headerSamples.map(strip))).toEqual(new Set([strip(firstHeader)])); + expect(new Set(headerSamples).size).toBeGreaterThan(1); + expect(new Set(barSamples)).toEqual(new Set([firstBar])); expect(strip(firstBar).trimEnd()).toBe(` ${'▰'.repeat(11)}${'▱'.repeat(29)} 27%`); expect(firstBar).toContain(currentTheme.fg('primary', '▰'.repeat(11))); expect(firstBar).not.toContain(currentTheme.fg('progressFill', '▰'.repeat(11))); expect(firstBar).toContain(currentTheme.fg('progressEmpty', '▱'.repeat(29))); - vi.advanceTimersByTime(1_000 - BRAILLE_SPINNER_INTERVAL_MS); + vi.advanceTimersByTime( + 1_000 - BRAILLE_SPINNER_INTERVAL_MS * shimmerSampleCount, + ); const elapsedRender = component.render(120); const elapsedHeader = elapsedRender.find((line) => strip(line).includes('Compacting conversation…'), diff --git a/apps/pythinker-code/test/tui/components/messages/thinking.test.ts b/apps/pythinker-code/test/tui/components/messages/thinking.test.ts index 05d41a7e..5110180e 100644 --- a/apps/pythinker-code/test/tui/components/messages/thinking.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/thinking.test.ts @@ -89,9 +89,18 @@ describe('ThinkingComponent', () => { expect(strip(secondHeader ?? '')).toBe(`⠙ ${formatThinkingSpinnerLabel()}`); vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS * (BRAILLE_SPINNER_FRAMES.length - 1)); - const shimmerHeader = component.render(80)[1]; - expect(strip(shimmerHeader ?? '')).toBe(strip(firstHeader ?? '')); - expect(shimmerHeader).not.toBe(firstHeader); + const fullCycleHeader = component.render(80)[1]; + expect(strip(fullCycleHeader ?? '')).toBe(strip(firstHeader ?? '')); + + const shimmerHeaders = [firstHeader, fullCycleHeader]; + for (let sample = 0; sample < 3; sample++) { + vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS * BRAILLE_SPINNER_FRAMES.length); + shimmerHeaders.push(component.render(80)[1]); + } + expect(shimmerHeaders.map((header) => strip(header ?? ''))).toEqual( + shimmerHeaders.map(() => strip(firstHeader ?? '')), + ); + expect(new Set(shimmerHeaders).size).toBeGreaterThan(1); component.finalize(); requestRender.mockClear(); diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index bc524b22..9e389f91 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -9,6 +9,7 @@ import { reduceFooterState, selectFooterViewModel, selectStatusBarExtras, + selectStatusItemParts, type FooterEvent, } from '#/tui/runtime/footer/footer-model'; import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; @@ -180,6 +181,8 @@ function makeTokenSpeedHost() { footer, renderStatusBarExtras: () => selectStatusBarExtras(footerState, Date.now(), DEFAULT_STATUS_LINE_CONFIG).join(' '), + renderStatusBarModel: () => + selectStatusItemParts(footerState, Date.now(), DEFAULT_STATUS_LINE_CONFIG).model, }; } @@ -447,7 +450,7 @@ describe('SessionEventHandler token speed', () => { ])('updates a live estimate from $name and replaces it with exact usage', ({ event }) => { vi.useFakeTimers(); vi.setSystemTime(0); - const { host, footer, renderStatusBarExtras } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -466,7 +469,7 @@ describe('SessionEventHandler token speed', () => { vi.setSystemTime(3_000); handler.handleEvent(event('x'.repeat(400)), vi.fn()); - expect(renderStatusBarExtras()).toContain('~100.0 t/s'); + expect(renderStatusBarModel()).toContain('~100.0 t/s'); handler.handleEvent( { @@ -485,8 +488,8 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderStatusBarExtras()).toContain('42.0 t/s'); - expect(renderStatusBarExtras()).not.toContain('~42.0 t/s'); + expect(renderStatusBarModel()).toContain('42.0 t/s'); + expect(renderStatusBarModel()).not.toContain('~42.0 t/s'); } finally { footer.dispose(); } @@ -495,7 +498,7 @@ describe('SessionEventHandler token speed', () => { it('keeps concurrent agent stream estimates separate', () => { vi.useFakeTimers(); vi.setSystemTime(0); - const { host, footer, renderStatusBarExtras } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); const event = (agentId: string, delta: string) => ({ type: 'assistant.delta' as const, @@ -510,18 +513,18 @@ describe('SessionEventHandler token speed', () => { handler.handleEvent(event('agent-b', 'abcd'), vi.fn()); vi.setSystemTime(1_000); handler.handleEvent(event('agent-a', 'x'.repeat(400)), vi.fn()); - expect(renderStatusBarExtras()).toContain('~100.0 t/s'); + expect(renderStatusBarModel()).toContain('~100.0 t/s'); vi.setSystemTime(1_500); handler.handleEvent(event('agent-b', 'x'.repeat(200)), vi.fn()); - expect(renderStatusBarExtras()).toContain('~50.0 t/s'); + expect(renderStatusBarModel()).toContain('~50.0 t/s'); } finally { footer.dispose(); } }); it('uses the latest valid main or child completed stream', () => { - const { host, footer, renderStatusBarExtras } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -541,7 +544,7 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderStatusBarExtras()).toContain('42.0 t/s'); + expect(renderStatusBarModel()).toContain('42.0 t/s'); handler.handleEvent( { @@ -560,7 +563,7 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderStatusBarExtras()).toContain('50.0 t/s'); + expect(renderStatusBarModel()).toContain('50.0 t/s'); } finally { footer.dispose(); } @@ -593,7 +596,7 @@ describe('SessionEventHandler token speed', () => { output: 10, }, Number.NaN], ] as const)('ignores %s', (_label, usage, llmStreamDurationMs) => { - const { host, footer, renderStatusBarExtras } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -613,7 +616,7 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderStatusBarExtras()).toContain('42.0 t/s'); + expect(renderStatusBarModel()).toContain('42.0 t/s'); handler.handleEvent( { @@ -627,14 +630,14 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderStatusBarExtras()).toContain('42.0 t/s'); + expect(renderStatusBarModel()).toContain('42.0 t/s'); } finally { footer.dispose(); } }); it('clears completed throughput when the turn ends', () => { - const { host, footer, renderStatusBarExtras } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -654,18 +657,18 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderStatusBarExtras()).toContain('42.0 t/s'); + expect(renderStatusBarModel()).toContain('42.0 t/s'); handler.handleEvent(turnEndedEvent(), vi.fn()); - expect(renderStatusBarExtras()).not.toContain('t/s'); + expect(renderStatusBarModel()).not.toContain('t/s'); } finally { footer.dispose(); } }); it('ignores replayed completion metrics and clears on runtime reset', () => { - const { host, footer, renderStatusBarExtras } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -685,7 +688,7 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderStatusBarExtras()).toContain('10.0 t/s'); + expect(renderStatusBarModel()).toContain('10.0 t/s'); host.state.appState.isReplaying = true; handler.handleEvent( @@ -705,10 +708,10 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderStatusBarExtras()).toContain('10.0 t/s'); + expect(renderStatusBarModel()).toContain('10.0 t/s'); handler.resetRuntimeState(); - expect(renderStatusBarExtras()).not.toContain('t/s'); + expect(renderStatusBarModel()).not.toContain('t/s'); } finally { footer.dispose(); } From 66d0076e4dd8728c996ba6b17ab15307050f6948 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 12 Aug 2026 03:07:22 -0400 Subject: [PATCH 11/11] test: make TUI render assertions fail on an empty render Assertions that indexed or folded over a render result treated a missing row as an empty string or an empty array, so they passed when the component rendered nothing. They now assert a row exists first. Also match braille loader frames when checking that a terminal workflow stopped animating. --- .../test/tui/activity-pane.test.ts | 6 ++- .../tui/components/messages/thinking.test.ts | 9 ++-- .../tui/components/messages/tool-call.test.ts | 6 +-- .../panels/footer-bg-agents.test.ts | 8 ++-- .../test/tui/components/status-bar.test.ts | 42 ++++++++++--------- 5 files changed, 40 insertions(+), 31 deletions(-) diff --git a/apps/pythinker-code/test/tui/activity-pane.test.ts b/apps/pythinker-code/test/tui/activity-pane.test.ts index 82597f63..783f821d 100644 --- a/apps/pythinker-code/test/tui/activity-pane.test.ts +++ b/apps/pythinker-code/test/tui/activity-pane.test.ts @@ -220,7 +220,7 @@ describe('updateActivityPane terminal progress', () => { expect(state.activityContainer.children).toHaveLength(0); expect(vi.getTimerCount()).toBe(timersBeforeMissionControl); const output = strip(missionControl.render(100).join('\n')); - expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Orchestrating/); + expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Orchestrating/u); expect(output).not.toContain(formatThinkingSpinnerLabel()); state.activitySpinner?.instance.stop(); @@ -289,6 +289,7 @@ describe('updateActivityPane terminal progress', () => { const output = strip(missionControl.render(100).join('\n')); expect(output).toContain('✓ Completed'); expect(output).not.toMatch(/[◐◓◑◒] Orchestrating/); + for (const frame of BRAILLE_SPINNER_FRAMES) expect(output).not.toContain(frame); state.activitySpinner?.instance.stop(); driver.sessionEventHandler.clearDynamicWorkflowMissionControls(); @@ -370,7 +371,7 @@ describe('updateActivityPane terminal progress', () => { driver.updateActivityPane(); const missionControl = startDynamicWorkflow(driver, state); expect(strip(missionControl.render(100).join('\n'))).toMatch( - /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Orchestrating/, + /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Orchestrating/u, ); cleanup(driver); @@ -379,6 +380,7 @@ describe('updateActivityPane terminal progress', () => { const output = strip(missionControl.render(100).join('\n')); expect(output).toContain('– Cancelled'); expect(output).not.toMatch(/[◐◓◑◒] Orchestrating/); + for (const frame of BRAILLE_SPINNER_FRAMES) expect(output).not.toContain(frame); state.activitySpinner?.instance.stop(); } finally { vi.useRealTimers(); diff --git a/apps/pythinker-code/test/tui/components/messages/thinking.test.ts b/apps/pythinker-code/test/tui/components/messages/thinking.test.ts index 5110180e..b419f2d7 100644 --- a/apps/pythinker-code/test/tui/components/messages/thinking.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/thinking.test.ts @@ -90,15 +90,18 @@ describe('ThinkingComponent', () => { vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS * (BRAILLE_SPINNER_FRAMES.length - 1)); const fullCycleHeader = component.render(80)[1]; - expect(strip(fullCycleHeader ?? '')).toBe(strip(firstHeader ?? '')); + expect(fullCycleHeader).toBeDefined(); + expect(firstHeader).toBeDefined(); + expect(strip(fullCycleHeader as string)).toBe(strip(firstHeader as string)); const shimmerHeaders = [firstHeader, fullCycleHeader]; for (let sample = 0; sample < 3; sample++) { vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS * BRAILLE_SPINNER_FRAMES.length); shimmerHeaders.push(component.render(80)[1]); } - expect(shimmerHeaders.map((header) => strip(header ?? ''))).toEqual( - shimmerHeaders.map(() => strip(firstHeader ?? '')), + for (const header of shimmerHeaders) expect(header).toBeDefined(); + expect(shimmerHeaders.map((header) => strip(header as string))).toEqual( + shimmerHeaders.map(() => strip(firstHeader as string)), ); expect(new Set(shimmerHeaders).size).toBeGreaterThan(1); diff --git a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts index e1ecf0db..e6052c2f 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts @@ -60,7 +60,6 @@ describe('ToolCallComponent', () => { }, undefined, ); - try { const pending = component.render(40); const pendingBody = pending.slice(1); @@ -96,15 +95,16 @@ describe('ToolCallComponent', () => { }, undefined, ); - try { const backgrounds = [ '\u001B[48;2;29;33;41m', '\u001B[48;2;20;23;27m', '\u001B[48;2;41;29;29m', ]; + const lines = component.render(40); + expect(lines.length).toBeGreaterThan(0); expect( - component.render(40).every((line) => backgrounds.every((code) => !line.includes(code))), + lines.every((line) => backgrounds.every((code) => !line.includes(code))), ).toBe(true); } finally { chalk.level = previousLevel; diff --git a/apps/pythinker-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/pythinker-code/test/tui/components/panels/footer-bg-agents.test.ts index 696e63bd..b8363f41 100644 --- a/apps/pythinker-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/pythinker-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -53,16 +53,16 @@ describe('FooterComponent — background task / agent badges', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 0 }); const out = backgroundExtras(1, 0); - expect(out).toMatch(/\[1 task running\]/); - expect(out).not.toMatch(/agents? running/); + expect(out).toMatch(/\[1 task running\]/u); + expect(out).not.toMatch(/agents? running/u); }); it('renders the agent badge alone when only agent tasks are running', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 1 }); const out = backgroundExtras(0, 1); - expect(out).toMatch(/\[1 agent running\]/); - expect(out).not.toMatch(/tasks? running/); + expect(out).toMatch(/\[1 agent running\]/u); + expect(out).not.toMatch(/tasks? running/u); }); it('renders both badges side by side when both are non-zero', () => { diff --git a/apps/pythinker-code/test/tui/components/status-bar.test.ts b/apps/pythinker-code/test/tui/components/status-bar.test.ts index feff8bc3..f358f376 100644 --- a/apps/pythinker-code/test/tui/components/status-bar.test.ts +++ b/apps/pythinker-code/test/tui/components/status-bar.test.ts @@ -13,6 +13,12 @@ function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/gu, ''); } +function renderRow(component: StatusBarComponent, width: number): string { + const rows = component.render(width); + expect(rows.length).toBeGreaterThan(0); + return rows[0] as string; +} + function status(overrides: Partial = {}): StatusBarStatus { return { model: 'Model Alpha', @@ -40,14 +46,14 @@ describe('StatusBarComponent', () => { const lines = component.render(80); expect(lines).toHaveLength(1); - expect(stripAnsi(lines[0] ?? '')).toContain('Model Alpha · high'); + expect(stripAnsi(lines[0] as string)).toContain('Model Alpha · high'); }); it('omits the effort suffix when thinking is off', () => { const component = new StatusBarComponent(); component.update(status({ thinkingLevel: 'off' })); - const line = stripAnsi(component.render(80)[0] ?? ''); + const line = stripAnsi(renderRow(component, 80)); expect(line).toContain('Model Alpha'); expect(line).not.toContain('· off'); @@ -58,8 +64,7 @@ describe('StatusBarComponent', () => { component.update(status({ statusLine: { ...DEFAULT_STATUS_LINE_CONFIG, showModel: false }, })); - - expect(stripAnsi(component.render(80)[0] ?? '')).not.toContain('Model Alpha'); + expect(stripAnsi(renderRow(component, 80))).not.toContain('Model Alpha'); }); it('hides the modes chip when showModes is false', () => { @@ -67,8 +72,7 @@ describe('StatusBarComponent', () => { component.update(status({ statusLine: { ...DEFAULT_STATUS_LINE_CONFIG, showModes: false }, })); - - const line = stripAnsi(component.render(80)[0] ?? ''); + const line = stripAnsi(renderRow(component, 80)); expect(line).not.toContain('plan'); expect(line).not.toContain('auto'); @@ -81,7 +85,7 @@ describe('StatusBarComponent', () => { statusLine: { ...DEFAULT_STATUS_LINE_CONFIG, showEffort: false }, })); - const line = stripAnsi(component.render(80)[0] ?? ''); + const line = stripAnsi(renderRow(component, 80)); expect(line).toContain('Model Alpha'); expect(line).not.toContain('· high'); @@ -97,7 +101,7 @@ describe('StatusBarComponent', () => { const component = new StatusBarComponent(); component.update(status({ permissionMode: 'yolo' })); - expect(component.render(80)[0] ?? '').toContain(chalk.hex(darkColors.error)('yolo')); + expect(renderRow(component, 80)).toContain(chalk.hex(darkColors.error)('yolo')); } finally { chalk.level = previousLevel; currentTheme.setPalette(previousPalette); @@ -108,10 +112,10 @@ describe('StatusBarComponent', () => { const component = new StatusBarComponent(); component.update(status()); - const wide = stripAnsi(component.render(60)[0] ?? ''); - const withoutGap = stripAnsi(component.render(53)[0] ?? ''); - const withoutModes = stripAnsi(component.render(45)[0] ?? ''); - const modelOnly = stripAnsi(component.render(25)[0] ?? ''); + const wide = stripAnsi(renderRow(component, 60)); + const withoutGap = stripAnsi(renderRow(component, 53)); + const withoutModes = stripAnsi(renderRow(component, 45)); + const modelOnly = stripAnsi(renderRow(component, 25)); expect(wide).toContain('─'); expect(withoutGap).not.toContain('─'); @@ -141,7 +145,7 @@ describe('StatusBarComponent', () => { component.update(status({ fastMode: true })); try { - expect(stripAnsi(component.render(80)[0] ?? '')).toContain('↯ fast'); + expect(stripAnsi(renderRow(component, 80))).toContain('↯ fast'); } finally { chalk.level = previousLevel; } @@ -155,7 +159,7 @@ describe('StatusBarComponent', () => { tokenSpeedEstimated: true, })); - const modelChip = stripAnsi(component.render(120)[0] ?? '').split(' ')[0]?.trim(); + const modelChip = stripAnsi(renderRow(component, 120)).split(' ')[0]?.trim(); expect(modelChip).toBe('Model Alpha · high · ↯ fast · ~75.7 t/s'); }); @@ -167,7 +171,7 @@ describe('StatusBarComponent', () => { statusLine: { ...DEFAULT_STATUS_LINE_CONFIG, showTokenSpeed: false }, })); - const modelChip = stripAnsi(component.render(120)[0] ?? '').split(' ')[0]?.trim(); + const modelChip = stripAnsi(renderRow(component, 120)).split(' ')[0]?.trim(); expect(modelChip).toBe('Model Alpha · high'); }); @@ -176,7 +180,7 @@ describe('StatusBarComponent', () => { const component = new StatusBarComponent(); component.update(status({ fastMode: true, tokenSpeed: null })); - const modelChip = stripAnsi(component.render(120)[0] ?? '').split(' ')[0]?.trim(); + const modelChip = stripAnsi(renderRow(component, 120)).split(' ')[0]?.trim(); expect(modelChip).toBe('Model Alpha · high · ↯ fast'); }); @@ -185,7 +189,7 @@ describe('StatusBarComponent', () => { const component = new StatusBarComponent(); component.update(status({ extras: ['6% · 55.6k/1M', 'main ± [PR#1]'] })); - const line = stripAnsi(component.render(160)[0] ?? ''); + const line = stripAnsi(renderRow(component, 160)); expect(line.indexOf('workflow')).toBeLessThan(line.indexOf('6% · 55.6k/1M')); expect(line.indexOf('6% · 55.6k/1M')).toBeLessThan(line.indexOf('main ± [PR#1]')); @@ -196,7 +200,7 @@ describe('StatusBarComponent', () => { const component = new StatusBarComponent(); component.update(status({ extras: ['first', 'second'] })); - const line = stripAnsi(component.render(62)[0] ?? ''); + const line = stripAnsi(renderRow(component, 62)); expect(line).toContain('Model Alpha'); expect(line).toContain('first'); @@ -218,6 +222,6 @@ describe('StatusBarComponent', () => { const component = new StatusBarComponent(); component.update(status({ cwd, homeDir })); - expect(stripAnsi(component.render(240)[0] ?? '')).toContain(expected); + expect(stripAnsi(renderRow(component, 240))).toContain(expected); }); });