diff --git a/.changeset/tui-signature-design.md b/.changeset/tui-signature-design.md new file mode 100644 index 00000000..45887c6b --- /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 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/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/footer.ts b/apps/pythinker-code/src/tui/components/chrome/footer.ts index 5545e487..5de1c347 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' || row.kind === 'activity') return []; return [truncateToWidth(renderLegacyRow(row), width, '…')]; }); } @@ -376,28 +373,10 @@ 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: Extract, ): string { - return currentTheme.fg(emphasis === 'danger' ? 'error' : 'textDim', row); -} - -function renderLegacyRow(row: Exclude): 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}`; - case 'status': - return paintStatusRow(formatStatusRow(row.items), row.modelName, row.emphasis); - } + 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/chrome/status-bar.ts b/apps/pythinker-code/src/tui/components/chrome/status-bar.ts new file mode 100644 index 00000000..329e05b0 --- /dev/null +++ b/apps/pythinker-code/src/tui/components/chrome/status-bar.ts @@ -0,0 +1,135 @@ +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 { + 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'; +import { sessionAccentHex } from '#/tui/utils/session-accent'; + +export type StatusBarStatus = Pick< + FooterStatus, + | 'model' + | 'thinkingLevel' + | 'cwd' + | 'homeDir' + | 'permissionMode' + | 'planMode' + | 'fastMode' + | 'dynamicWorkflowMode' + | 'tokenSpeed' + | 'tokenSpeedEstimated' +> & { + readonly extras: readonly string[]; + readonly sessionKey: string; + readonly statusLine: StatusLineConfig; +}; + +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 effortSuffix = status.statusLine.showEffort && status.thinkingLevel !== 'off' + ? `${currentTheme.fg('textDim', ' · ')}${currentTheme.fg( + effortColorToken(status.thinkingLevel), + 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}${fastSuffix}${ + speed === null ? '' : currentTheme.fg('textDim', ` · ${speed}`) + }`, + ) + : undefined; + let modesChip = status.statusLine.showModes ? renderModesChip(status) : undefined; + 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, ...extraChips] + .filter((item): item is string => item !== undefined) + .join(' '); + 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}`}`; + 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}`}`; + } + 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('error', 'yolo')); + if (status.dynamicWorkflowMode) modes.push(currentTheme.fg('accent', 'workflow')); + return modes.length === 0 ? undefined : chip(modes.join(' ')); +} + +function shortenCwd(cwd: string, homeDir: string | null): string { + 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/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 2b8deeb8..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 @@ -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'; @@ -507,9 +511,18 @@ 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) / + BRAILLE_SPINNER_INTERVAL_MS, + ); 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', + 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: // the label says so instead of pretending orchestration is still active. @@ -525,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/components/messages/tool-call.ts b/apps/pythinker-code/src/tui/components/messages/tool-call.ts index ed3a16af..b8f3ca51 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,24 @@ 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 + ? undefined + : 'toolPendingBg' + : this.result.is_error !== true + ? 'toolSuccessBg' + : 'toolErrorBg'; + if (background === undefined) return lines; + 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/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index 837e504b..b2abe4f9 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -30,12 +30,10 @@ 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, - /** 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 63b44b9c..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, @@ -141,6 +140,7 @@ import type { TuiPresentation } from './runtime/contracts'; import { foldFooterEvents, selectFooterViewModel, + selectStatusBarExtras, type FooterActivity, type FooterEvent, type FooterGoal, @@ -965,6 +965,7 @@ export class PythinkerTUI { ui.addChild(this.state.btwPanelContainer); ui.addChild(this.state.mcpStatusContainer); ui.addChild(this.state.editorContainer); + ui.addChild(this.state.statusBarContainer); // 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,19 @@ export class PythinkerTUI { this.state.appState.statusLine, ), ); + 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 || + this.state.appState.workDir, + statusLine: this.state.appState.statusLine, + }); } private footerGoal(): FooterGoal | null { @@ -2111,13 +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); - 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 802535ee..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,16 +464,27 @@ function selectActivityRow(state: FooterState): FooterActivityRowViewModel { }); } -function selectStatusItems( +export 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,84 @@ 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.context, + parts.git, + parts.update, + parts.spend, + parts.elapsed, + parts.goal, + ].filter((item): item is string => item !== null); + items.push(...parts.background); return items; } @@ -593,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/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..6d997fcb 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 }, () => { @@ -110,6 +115,7 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState { btwPanelContainer, mcpStatusContainer, editorContainer, + statusBarContainer, ], 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..b4b4efaa --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/session-accent.ts @@ -0,0 +1,49 @@ +/** 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) ?? 0); + } + + 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 { + 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..3f588830 100644 --- a/apps/pythinker-code/src/tui/utils/shimmer.ts +++ b/apps/pythinker-code/src/tui/utils/shimmer.ts @@ -3,57 +3,72 @@ 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 MIN_WINDOW_SIZE = 2; -const MAX_WINDOW_SIZE = 6; +const CELLS_PER_SECOND = 20; +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.bandHalfWidth ?? BAND_HALF_WIDTH); + const cycleLength = chars.length + halfWidth * 2; + 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 = ''; - 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.baseToken, peakToken); + activeTier = tier; segment = char; } - if (activeToken !== undefined) { - result += currentTheme.fg(activeToken, segment); + if (activeTier !== undefined) { + result += paintTier(activeTier, segment, options.baseToken, peakToken); } return result; } + +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(peakToken, text); + return currentTheme.fg(baseToken, text); +} diff --git a/apps/pythinker-code/test/tui/activity-pane.test.ts b/apps/pythinker-code/test/tui/activity-pane.test.ts index adc5f8fc..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).toContain('⠋ Orchestrating'); + expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Orchestrating/u); expect(output).not.toContain(formatThinkingSpinnerLabel()); state.activitySpinner?.instance.stop(); @@ -288,7 +288,8 @@ 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/); + for (const frame of BRAILLE_SPINNER_FRAMES) expect(output).not.toContain(frame); state.activitySpinner?.instance.stop(); driver.sessionEventHandler.clearDynamicWorkflowMissionControls(); @@ -369,14 +370,17 @@ 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/u, + ); 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/); + 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/chrome/footer.test.ts b/apps/pythinker-code/test/tui/components/chrome/footer.test.ts index 80b32e0a..96efa96a 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 validation rows but suppresses activity and 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([]); 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/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/dynamic-workflow-mission-control.test.ts b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts index be2764dc..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 @@ -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); }); @@ -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 { @@ -383,7 +386,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; @@ -408,17 +411,19 @@ 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); + vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS * 1.5); 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; @@ -439,7 +444,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'); @@ -515,7 +520,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', () => { @@ -644,12 +649,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; @@ -661,13 +666,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(strip(aggregateLine(component.render(100).join('\n')))).toMatch( + /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Orchestrating/, + ); } component.markCompleted('agent-1', 'Done'); @@ -698,7 +707,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; @@ -717,7 +726,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); @@ -883,7 +892,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'); @@ -900,7 +909,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)} ` }); @@ -908,7 +917,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/messages/thinking.test.ts b/apps/pythinker-code/test/tui/components/messages/thinking.test.ts index 05d41a7e..b419f2d7 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,21 @@ 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(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]); + } + 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); component.finalize(); requestRender.mockClear(); 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..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 @@ -49,6 +49,68 @@ 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); + const pendingBody = pending.slice(1); + expect(pending[0]).not.toContain('\u001B[48;2;29;33;41m'); + 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 }); + 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 }); + 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; + } + }); + + 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', + ]; + const lines = component.render(40); + expect(lines.length).toBeGreaterThan(0); + expect( + lines.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/components/panels/footer-bg-agents.test.ts b/apps/pythinker-code/test/tui/components/panels/footer-bg-agents.test.ts index f2947b20..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 @@ -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,35 +34,41 @@ 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]!); - expect(out).toMatch(/\[1 task running\]/); - expect(out).not.toMatch(/agents? running/); + const out = backgroundExtras(1, 0); + 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 = strip(footer.render(120)[0]!); - expect(out).toMatch(/\[1 agent running\]/); - expect(out).not.toMatch(/tasks? running/); + const out = backgroundExtras(0, 1); + 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', () => { 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 new file mode 100644 index 00000000..f358f376 --- /dev/null +++ b/apps/pythinker-code/test/tui/components/status-bar.test.ts @@ -0,0 +1,227 @@ +import { visibleWidth } from '@earendil-works/pi-tui'; +import chalk from 'chalk'; +import { describe, expect, it } from 'vitest'; + +import { + StatusBarComponent, + type StatusBarStatus, +} from '#/tui/components/chrome/status-bar'; +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, ''); +} + +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', + thinkingLevel: 'high', + cwd: '/Users/test/project', + homeDir: '/Users/test', + permissionMode: 'auto', + planMode: true, + fastMode: false, + dynamicWorkflowMode: true, + tokenSpeed: null, + tokenSpeedEstimated: false, + extras: [], + sessionKey: 'session-alpha', + statusLine: DEFAULT_STATUS_LINE_CONFIG, + ...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] 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(renderRow(component, 80)); + + 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(renderRow(component, 80))).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(renderRow(component, 80)); + + 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(renderRow(component, 80)); + + 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(renderRow(component, 80)).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()); + + 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('─'); + 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]) { + 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(renderRow(component, 80))).toContain('↯ fast'); + } finally { + chalk.level = previousLevel; + } + }); + + 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(renderRow(component, 120)).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(renderRow(component, 120)).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(renderRow(component, 120)).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]'] })); + + 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]')); + 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(renderRow(component, 62)); + + expect(line).toContain('Model Alpha'); + expect(line).toContain('first'); + expect(line).not.toContain('second'); + 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(renderRow(component, 240))).toContain(expected); + }); +}); 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..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 @@ -8,6 +8,8 @@ import { createFooterState, reduceFooterState, selectFooterViewModel, + selectStatusBarExtras, + selectStatusItemParts, type FooterEvent, } from '#/tui/runtime/footer/footer-model'; import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; @@ -174,11 +176,14 @@ 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(' '), + renderStatusBarModel: () => + selectStatusItemParts(footerState, Date.now(), DEFAULT_STATUS_LINE_CONFIG).model, + }; } function sendQueuedViaHost(host: ReturnType['host'], session: unknown) { @@ -368,7 +373,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 +387,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 +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 } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -464,7 +469,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(renderStatusBarModel()).toContain('~100.0 t/s'); handler.handleEvent( { @@ -483,8 +488,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(renderStatusBarModel()).toContain('42.0 t/s'); + expect(renderStatusBarModel()).not.toContain('~42.0 t/s'); } finally { footer.dispose(); } @@ -493,7 +498,7 @@ describe('SessionEventHandler token speed', () => { it('keeps concurrent agent stream estimates separate', () => { vi.useFakeTimers(); vi.setSystemTime(0); - const { host, footer } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); const event = (agentId: string, delta: string) => ({ type: 'assistant.delta' as const, @@ -508,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(renderFooter(footer)).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(renderFooter(footer)).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 } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -539,7 +544,7 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderFooter(footer)).toContain('42.0 t/s'); + expect(renderStatusBarModel()).toContain('42.0 t/s'); handler.handleEvent( { @@ -558,7 +563,7 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderFooter(footer)).toContain('50.0 t/s'); + expect(renderStatusBarModel()).toContain('50.0 t/s'); } finally { footer.dispose(); } @@ -591,7 +596,7 @@ describe('SessionEventHandler token speed', () => { output: 10, }, Number.NaN], ] as const)('ignores %s', (_label, usage, llmStreamDurationMs) => { - const { host, footer } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -611,7 +616,7 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderFooter(footer)).toContain('42.0 t/s'); + expect(renderStatusBarModel()).toContain('42.0 t/s'); handler.handleEvent( { @@ -625,14 +630,14 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderFooter(footer)).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 } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -652,18 +657,18 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderFooter(footer)).toContain('42.0 t/s'); + expect(renderStatusBarModel()).toContain('42.0 t/s'); handler.handleEvent(turnEndedEvent(), vi.fn()); - expect(renderFooter(footer)).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 } = makeTokenSpeedHost(); + const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); const handler = new SessionEventHandler(host); try { handler.handleEvent( @@ -683,7 +688,7 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderFooter(footer)).toContain('10.0 t/s'); + expect(renderStatusBarModel()).toContain('10.0 t/s'); host.state.appState.isReplaying = true; handler.handleEvent( @@ -703,10 +708,10 @@ describe('SessionEventHandler token speed', () => { }, vi.fn(), ); - expect(renderFooter(footer)).toContain('10.0 t/s'); + expect(renderStatusBarModel()).toContain('10.0 t/s'); handler.resetRuntimeState(); - expect(renderFooter(footer)).not.toContain('t/s'); + expect(renderStatusBarModel()).not.toContain('t/s'); } finally { footer.dispose(); } 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..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'; @@ -3064,7 +3065,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); @@ -3121,6 +3122,9 @@ command = "vim" expect(rootChildren.indexOf(driver.state.mcpStatusContainer)).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'); @@ -3944,7 +3948,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('━'); @@ -5432,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 () => ({ @@ -5456,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/pythinker-tui-startup.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts index d73bea0a..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,7 +551,7 @@ 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 directly below the editor in inline layout', () => { const harness = makeHarness(); const driver = makeDriver(harness, makeStartupInput({}, { layout: 'inline' })); const children = driver.state.ui.children; @@ -559,6 +559,9 @@ describe('PythinkerTUI startup', () => { expect(children.indexOf(driver.state.mcpStatusContainer)).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..13e15881 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,28 @@ describe('footer model', () => { }); }); + it('projects status-bar extras in priority order without the model and modes items', () => { + const state = foldFooterEvents( + createFooterState({ + model: 'DeepSeek V4 Flash', + contextUsage: 0.05, + dynamicWorkflowMode: true, + git: workflowStatus().git, + tokenSpeed: 75.7, + tokenSpeedEstimated: true, + }), + [ + { + type: 'update.updated', + update: { version: '0.11.0', state: 'available', percent: null }, + }, + ], + ); + expect(selectStatusBarExtras(state, CLOCK_MS, DEFAULT_STATUS_LINE_CONFIG)).toEqual( + ['▱▱▱▱▱▱▱▱ 5%', 'main ↑15', '↑ v0.11.0'], + ); + }); + it('hides model metadata and spend together when the model item is disabled', () => { const row = mainStatusRow(statusConfig({ showModel: false })); 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..5b748bde --- /dev/null +++ b/apps/pythinker-code/test/tui/utils/session-accent.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +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'); + + 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')), + ); + }); + + 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'); + }); +}); 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')); + }); +}); 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. |