From 8ed8f69dd9c4b4e4c2d8522832fe0c24ef224213 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 22 Aug 2026 00:57:37 -0400 Subject: [PATCH 1/6] feat(tui): show step decode speed in the footer --- .../src/tui/components/chrome/footer.ts | 38 +++++++++++---- .../tui/controllers/session-event-handler.ts | 7 ++- apps/pythinker-code/src/tui/pythinker-tui.ts | 12 ++--- .../src/utils/usage/debug-timing.ts | 22 +++++++-- .../test/tui/components/chrome/footer.test.ts | 47 +++++++++++++++---- ...sion-event-handler-background-task.test.ts | 2 +- .../session-event-handler-compaction.test.ts | 1 + .../session-event-handler-goal-queue.test.ts | 1 + ...ssion-event-handler-plugin-updates.test.ts | 1 + .../session-event-handler-step-retry.test.ts | 1 + .../session-event-handler-todo.test.ts | 1 + .../tui/pythinker-tui-message-flow.test.ts | 24 +++++++--- 12 files changed, 120 insertions(+), 37 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/chrome/footer.ts b/apps/pythinker-code/src/tui/components/chrome/footer.ts index dcf056b11..a28e5a18f 100644 --- a/apps/pythinker-code/src/tui/components/chrome/footer.ts +++ b/apps/pythinker-code/src/tui/components/chrome/footer.ts @@ -12,7 +12,7 @@ import chalk from 'chalk'; import { effectiveModelAlias } from '@pymodel/pythinker-code-sdk'; import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips'; -import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance'; +import { isRainbowHatching, renderHatchFooterModel } from '#/tui/easter-eggs/hatch'; import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; @@ -195,6 +195,7 @@ export class FooterComponent implements Component { private gitCacheWorkDir: string; private transientHint: string | null = null; private warningHint: string | null = null; + private streamSpeedTps: number | null = null; private goalSnapshotKey: string | null = null; private goalObservedAtMs = Date.now(); private goalTimer: ReturnType | null = null; @@ -245,6 +246,17 @@ export class FooterComponent implements Component { } } + /** + * Decode speed of the most recently completed step (tokens/s), shown next + * to the context readout on line 2. `null` hides it (turn end, or a step + * too short to measure — see `computeDecodeTps`). + */ + setStreamSpeed(tps: number | null): void { + if (this.streamSpeedTps === tps) return; + this.streamSpeedTps = tps; + this.onRefresh(); + } + /** * Short-lived hint that replaces the rotating toolbar tips on line 1. * Used by the exit-confirmation double-tap flow to show "Press Ctrl+C @@ -337,28 +349,34 @@ export class FooterComponent implements Component { } } - // ── Line 2: hint (bottom-left) + context (right) ── + // ── Line 2: hint (bottom-left) + context + stream speed (right) ── const contextText = formatContextStatus( state.contextUsage, state.contextTokens, state.maxContextTokens, ); - const contextWidth = visibleWidth(contextText); + const speedSuffix = + this.streamSpeedTps === null ? '' : ` · ${this.streamSpeedTps.toFixed(1)} t/s`; + const rightWidth = visibleWidth(contextText) + visibleWidth(speedSuffix); let line2: string; const hint = this.transientHint ?? this.warningHint; if (hint) { - const maxHintWidth = Math.max(0, width - contextWidth - 1); + const maxHintWidth = Math.max(0, width - rightWidth - 1); const shownHint = visibleWidth(hint) <= maxHintWidth ? hint : truncateToWidth(hint, maxHintWidth, '…'); const hintWidth = visibleWidth(shownHint); - const pad = Math.max(0, width - hintWidth - contextWidth); + const pad = Math.max(0, width - hintWidth - rightWidth); line2 = chalk.hex(colors.warning).bold(shownHint) + ' '.repeat(pad) + - chalk.hex(colors.text)(contextText); + chalk.hex(colors.text)(contextText) + + chalk.hex(colors.textDim)(speedSuffix); } else { - const leftPad = Math.max(0, width - contextWidth); - line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); + const leftPad = Math.max(0, width - rightWidth); + line2 = + ' '.repeat(leftPad) + + chalk.hex(colors.text)(contextText) + + chalk.hex(colors.textDim)(speedSuffix); } return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; @@ -413,8 +431,8 @@ export class FooterComponent implements Component { : ''; const modelLabel = `${model}${thinkingLabel}`; let renderedModelLabel = chalk.hex(colors.text)(modelLabel); - if (isRainbowDancing()) { - renderedModelLabel = renderDanceFooterModel(modelLabel); + if (isRainbowHatching()) { + renderedModelLabel = renderHatchFooterModel(modelLabel); } slots['model'] = [renderedModelLabel]; } diff --git a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts index ac6fa0c77..15c2fe3da 100644 --- a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts +++ b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts @@ -71,7 +71,7 @@ import { openUrl } from '#/utils/open-url'; import { currentTheme } from '#/tui/theme'; import type { ColorToken } from '#/tui/theme'; import { errorReportHintLine } from '../constant/feedback'; -import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; +import { computeDecodeTps, formatStepDebugTiming } from '#/utils/usage/debug-timing'; import { nextTranscriptId } from '../utils/transcript-id'; import type { BtwPanelController } from './btw-panel'; import { isPluginMcpToolName, PluginUpdateNotifier } from './plugin-update-notifier'; @@ -373,6 +373,8 @@ export class SessionEventHandler { this.host.handleTurnEnded?.(event); this.host.streamingUI.flushNow(); this.clearStepRetry(); + // The last step's decode speed no longer applies once the turn ends. + this.host.state.footer.setStreamSpeed(null); if (event.reason === 'cancelled') { this.markActiveAgentDynamicWorkflowsCancelled(); } @@ -436,6 +438,9 @@ export class SessionEventHandler { this.clearStepRetry(); this.host.noteStepUsage(event.usage); this.maybeShowDebugTiming(event); + this.host.state.footer.setStreamSpeed( + computeDecodeTps(event.usage?.output, event.llmStreamDurationMs), + ); if (event.providerFinishReason === 'filtered') { this.host.showNotice( diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index 87250d6ca..621b0e1fe 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -120,7 +120,7 @@ import { SessionReplayRenderer } from './controllers/session-replay'; import { StagingLeaseTracker, type StagingLease } from './controllers/staging-leases'; import { StreamingUIController } from './controllers/streaming-ui'; import { TasksBrowserController } from './controllers/tasks-browser'; -import { installRainbowDance } from './easter-eggs/dance'; +import { installRainbowHatch } from './easter-eggs/hatch'; import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; import { ApprovalController } from './reverse-rpc/approval/controller'; import { createApprovalRequestHandler } from './reverse-rpc/approval/handler'; @@ -321,7 +321,7 @@ export class PythinkerTUI { private terminalFocusTrackingDispose: (() => void) | undefined; private terminalThemeTrackingDispose: (() => void) | undefined; private clipboardImageHintController: ClipboardImageHintController | undefined; - private uninstallRainbowDance: () => void; + private uninstallRainbowHatch: () => void; private signalCleanupHandlers: Array<() => void> = []; private isShuttingDown = false; private backgroundRefreshPromise: Promise | undefined; @@ -417,7 +417,7 @@ export class PythinkerTUI { this.engineV2 = startupInput.engineV2 ?? false; this.startupNotice = startupInput.startupNotice; this.state = createTUIState(tuiOptions); - this.uninstallRainbowDance = installRainbowDance(() => { + this.uninstallRainbowHatch = installRainbowHatch(() => { this.state.ui.requestRender(); }); @@ -907,7 +907,7 @@ export class PythinkerTUI { } finally { this.sessionEventHandler.stopAllMcpServerStatusSpinners(); this.sessionEventHandler.clearStepRetryAttemptTimer(); - this.uninstallRainbowDance(); + this.uninstallRainbowHatch(); try { await this.state.terminal.drainInput(); } catch { @@ -1061,10 +1061,6 @@ export class PythinkerTUI { // Input Dispatch // ========================================================================= - handlePlanToggle(next: boolean): void { - void slashCommands.handlePlanCommand(this, next ? 'on' : 'off'); - } - handleInputModeChange(mode: 'prompt' | 'bash'): void { this.setAppState({ inputMode: mode }); this.updateEditorBorderHighlight(); diff --git a/apps/pythinker-code/src/utils/usage/debug-timing.ts b/apps/pythinker-code/src/utils/usage/debug-timing.ts index 87f72696c..07baf09d4 100644 --- a/apps/pythinker-code/src/utils/usage/debug-timing.ts +++ b/apps/pythinker-code/src/utils/usage/debug-timing.ts @@ -35,6 +35,22 @@ export interface StepTimingInput { // instead of a meaningless ratio. const MIN_STREAM_MS_FOR_TPS = 50; +/** + * Step decode speed in raw tokens/s, or null when the ratio would be + * meaningless: unknown/zero output, or a decode window below + * `MIN_STREAM_MS_FOR_TPS` where `Date.now()`'s ~1ms quantization dominates + * and short tool-call turns would report inflated rates like tens of + * thousands of tok/s. Callers format the ratio (one decimal is conventional). + */ +export function computeDecodeTps( + outputTokens: number | undefined, + streamMs: number | undefined, +): number | null { + if (outputTokens === undefined || outputTokens <= 0) return null; + if (streamMs === undefined || streamMs < MIN_STREAM_MS_FOR_TPS) return null; + return outputTokens / (streamMs / 1000); +} + export function formatStepDebugTiming(input: StepTimingInput): string | undefined { const latency = input.llmFirstTokenLatencyMs; const streamMs = input.llmStreamDurationMs; @@ -43,10 +59,10 @@ export function formatStepDebugTiming(input: StepTimingInput): string | undefine const parts: string[] = [`TTFT: ${formatTtft(input)}`]; const outputTokens = input.usage?.output; if (outputTokens !== undefined && outputTokens > 0) { - if (streamMs >= MIN_STREAM_MS_FOR_TPS) { - const tps = (outputTokens / (streamMs / 1000)).toFixed(1); + const tps = computeDecodeTps(outputTokens, streamMs); + if (tps !== null) { parts.push( - `TPS: ${tps} tok/s (${outputTokens} tokens in ${formatDuration(streamMs)}${formatDecodeSplit(input)})`, + `TPS: ${tps.toFixed(1)} tok/s (${outputTokens} tokens in ${formatDuration(streamMs)}${formatDecodeSplit(input)})`, ); } else { parts.push( 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 f79b19ac1..e1d79d0cf 100644 --- a/apps/pythinker-code/test/tui/components/chrome/footer.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/footer.test.ts @@ -2,7 +2,7 @@ import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; -import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; +import { setRainbowHatch, type RainbowHatchController } from '#/tui/easter-eggs/hatch'; import { currentTheme, darkColors, lightColors } from '#/tui/theme'; import type { ModelAlias } from '@pymodel/pythinker-code-sdk'; import type { AppState } from '#/tui/types'; @@ -17,19 +17,19 @@ function truecolorCodes(text: string): Set { return codes; } -// Dark dance colors the footer never uses outside of /dance. +// Dark hatch colors the footer never uses outside of /hatch. const RAINBOW_CYAN = '91,192,190'; const RAINBOW_GREEN = '78,200,126'; -function setDanceView(colored: boolean, phase: number): void { - const dance: RainbowDanceController = { +function setHatchView(colored: boolean, phase: number): void { + const hatch: RainbowHatchController = { colored, phase, start: () => {}, stop: () => {}, dispose: () => {}, }; - setRainbowDance(dance); + setRainbowHatch(hatch); } const appState: AppState = { @@ -70,11 +70,11 @@ describe('FooterComponent', () => { afterEach(() => { chalk.level = previousChalkLevel; - setRainbowDance(undefined); + setRainbowHatch(undefined); }); it('paints the model name in rainbow while colored', () => { - setDanceView(true, 0); + setHatchView(true, 0); const footer = new FooterComponent(appState); const codes = truecolorCodes(footer.render(120).join('\n')); @@ -85,7 +85,7 @@ describe('FooterComponent', () => { expect(codes.has(RAINBOW_GREEN)).toBe(true); }); - it('renders the model name in its normal color when not dancing', () => { + it('renders the model name in its normal color when not hatching', () => { const footer = new FooterComponent(appState); const codes = truecolorCodes(footer.render(120).join('\n')); @@ -223,3 +223,34 @@ describe('FooterComponent line-2 hints', () => { expect(stripAnsi(footer.render(120)[1] ?? '')).not.toContain('Goal objective is too long'); }); }); + + +describe('FooterComponent stream speed', () => { + function stripAnsiSpeed(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); + } + + it('hides the speed badge until a step completes', () => { + const footer = new FooterComponent(appState); + + expect(stripAnsiSpeed(footer.render(120)[1] ?? '')).not.toContain('t/s'); + }); + + it('shows the last step decode speed next to the context readout', () => { + const footer = new FooterComponent(appState); + footer.setStreamSpeed(38.44); + + const line2 = stripAnsiSpeed(footer.render(120)[1] ?? ''); + + expect(line2).toContain('context:'); + expect(line2).toContain('· 38.4 t/s'); + }); + + it('clears the speed badge on null', () => { + const footer = new FooterComponent(appState); + footer.setStreamSpeed(12.5); + footer.setStreamSpeed(null); + + expect(stripAnsiSpeed(footer.render(120)[1] ?? '')).not.toContain('t/s'); + }); +}); diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts index 9bf46ace0..a2021ad1f 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts @@ -124,7 +124,7 @@ function makeSessionEventHost() { todoPanel: { getTodos: vi.fn(() => []) }, transcriptContainer: { addChild: vi.fn() }, tasksBrowser: undefined, - footer: { setBackgroundCounts: vi.fn() }, + footer: { setBackgroundCounts: vi.fn(), setStreamSpeed: vi.fn() }, ui: { requestRender: vi.fn() }, }, session: { id: 's1' }, diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-compaction.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-compaction.test.ts index 452e250a1..75ba0e521 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-compaction.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-compaction.test.ts @@ -6,6 +6,7 @@ import { getBuiltInPalette } from '#/tui/theme'; function makeHost() { const host = { state: { + footer: { setStreamSpeed: vi.fn() }, appState: { sessionId: 's1', streamingPhase: 'waiting', 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 4fd92f80a..5f5847849 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 @@ -47,6 +47,7 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { }; const host = { state: { + footer: { setStreamSpeed: vi.fn() }, appState: { sessionId: 's1', streamingPhase: 'waiting', diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts index 9c53a9405..4259a7745 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts @@ -20,6 +20,7 @@ function makeHost() { }; const host = { state: { + footer: { setStreamSpeed: vi.fn() }, appState: { sessionId: 's1', streamingPhase: 'waiting', diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-step-retry.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-step-retry.test.ts index e21dc6f3c..9ebc86c2f 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-step-retry.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-step-retry.test.ts @@ -6,6 +6,7 @@ import { getBuiltInPalette } from '#/tui/theme'; function makeHost() { const host = { state: { + footer: { setStreamSpeed: vi.fn() }, appState: { sessionId: 's1', streamingPhase: 'waiting', diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-todo.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-todo.test.ts index 78a0966e3..cef697f4a 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-todo.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-todo.test.ts @@ -23,6 +23,7 @@ function makeHarness() { }; const host = { state: { + footer: { setStreamSpeed: vi.fn() }, appState: { availableModels: {}, workDir: '/tmp/work', stepRetry: null }, ui: { requestRender: vi.fn() }, transcriptContainer: { addChild: vi.fn() }, 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 3992fac6e..daad08a1f 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 @@ -820,11 +820,11 @@ describe('PythinkerTUI message flow', () => { driver as unknown as { refreshSkillCommands(): Promise } ).refreshSkillCommands(); - driver.handleUserInput('/dance please use /skill:review'); + driver.handleUserInput('/hatch please use /skill:review'); await vi.waitFor(() => { expect(session.promptWithSkills).toHaveBeenCalledWith( - '/dance please use /skill:review', + '/hatch please use /skill:review', [{ name: 'review' }], ); }); @@ -2586,17 +2586,29 @@ command = "vim" expect(failedSession.onEvent).toHaveBeenCalledOnce(); }); - it('tracks Shift-Tab mode switches through the editor handler', async () => { + it('tracks Shift-Tab effort cycling through the editor handler', async () => { const { driver, session, harness } = await makeDriver(); harness.track.mockClear(); + driver.state.appState.availableModels = { + k2: { + provider: 'openai', + model: 'gpt-x', + maxContextSize: 100, + supportEfforts: ['low', 'high'], + }, + }; driver.state.editor.onShiftTab?.(); await vi.waitFor(() => { - expect(session.setPlanMode).toHaveBeenCalledWith(true); + expect(session.setThinking).toHaveBeenCalledWith('low'); + }); + expect(driver.state.appState.thinkingEffort).toBe('low'); + expect(harness.track).toHaveBeenCalledWith('thinking_toggle', { + enabled: true, + effort: 'low', + from: 'off', }); - expect(harness.track).toHaveBeenCalledWith('shortcut_plan_toggle', { enabled: true }); - expect(harness.track).toHaveBeenCalledWith('shortcut_mode_switch', { to_mode: 'plan' }); }); it('routes /yolo through session permission state without app-layer telemetry duplication', async () => { From 3bebe9e537caf91defcfd28efd423d0030f22dd1 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 22 Aug 2026 00:57:46 -0400 Subject: [PATCH 2/6] feat(tui): rename the dance easter egg to hatch --- .../src/tui/commands/dispatch.ts | 6 +- .../src/tui/components/chrome/welcome.ts | 20 +-- apps/pythinker-code/src/tui/constant/tips.ts | 2 +- .../tui/easter-eggs/{dance.ts => hatch.ts} | 96 +++++------ .../tui/components/chrome/welcome.test.ts | 16 +- .../{dance.test.ts => hatch.test.ts} | 156 +++++++++--------- 6 files changed, 148 insertions(+), 148 deletions(-) rename apps/pythinker-code/src/tui/easter-eggs/{dance.ts => hatch.ts} (68%) rename apps/pythinker-code/test/tui/easter-eggs/{dance.test.ts => hatch.test.ts} (56%) diff --git a/apps/pythinker-code/src/tui/commands/dispatch.ts b/apps/pythinker-code/src/tui/commands/dispatch.ts index 7b4aa4fe1..beda56029 100644 --- a/apps/pythinker-code/src/tui/commands/dispatch.ts +++ b/apps/pythinker-code/src/tui/commands/dispatch.ts @@ -9,7 +9,7 @@ import type { AuthFlowController } from '../controllers/auth-flow'; import type { BtwPanelController } from '../controllers/btw-panel'; import type { StreamingUIController } from '../controllers/streaming-ui'; import type { TasksBrowserController } from '../controllers/tasks-browser'; -import { tryHandleDanceCommand } from '../easter-eggs/dance'; +import { tryHandleHatchCommand } from '../easter-eggs/hatch'; import type { ResolvedTheme } from '../theme/colors'; import type { TUIState } from '../tui-state'; import type { @@ -378,10 +378,10 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi return; } case 'message': - // Unknown slash command: let /dance claim it before it falls through to + // Unknown slash command: let /hatch claim it before it falls through to // the model as a normal message. This runs *after* builtin and skill // resolution, so a real command or a same-named skill always wins. - if (parsedCommand !== null && tryHandleDanceCommand(host, parsedCommand)) { + if (parsedCommand !== null && tryHandleHatchCommand(host, parsedCommand)) { return; } host.sendNormalUserInput(intent.input); diff --git a/apps/pythinker-code/src/tui/components/chrome/welcome.ts b/apps/pythinker-code/src/tui/components/chrome/welcome.ts index aa111589a..a7157cc85 100644 --- a/apps/pythinker-code/src/tui/components/chrome/welcome.ts +++ b/apps/pythinker-code/src/tui/components/chrome/welcome.ts @@ -6,10 +6,10 @@ import type { Component } from '@pymodel/pi-tui'; import { - isRainbowDancing, - renderDanceWelcomeLogo, - renderDanceWelcomeText, -} from '#/tui/easter-eggs/dance'; + isRainbowHatching, + renderHatchWelcomeLogo, + renderHatchWelcomeText, +} from '#/tui/easter-eggs/hatch'; import type { AppState } from '#/tui/types'; import type { GitStatusCache } from '#/utils/git/git-status'; @@ -45,7 +45,7 @@ export class WelcomeComponent implements Component, WelcomeLogoAnimationHost { ) { this.state = state; this.gitCache = createWelcomeGitCache(state.workDir); - if (requestRender !== undefined && welcomeLogoAnimationEnabled() && !isRainbowDancing()) { + if (requestRender !== undefined && welcomeLogoAnimationEnabled() && !isRainbowHatching()) { this.eyeAnimator = new WelcomeLogoAnimator(this, requestRender); queueMicrotask(() => this.eyeAnimator?.start()); } @@ -70,13 +70,13 @@ export class WelcomeComponent implements Component, WelcomeLogoAnimationHost { render(width: number): string[] { const isLoggedOut = !this.state.model; - const copy = isRainbowDancing() + const copy = isRainbowHatching() ? (() => { const text = buildWelcomeCopyText(isLoggedOut); return { - head: renderDanceWelcomeText(text.head, 2, true), - strapline: renderDanceWelcomeText(text.strapline, 5), - prompt: renderDanceWelcomeText(text.prompt), + head: renderHatchWelcomeText(text.head, 2, true), + strapline: renderHatchWelcomeText(text.strapline, 5), + prompt: renderHatchWelcomeText(text.prompt), }; })() : buildWelcomeCopy(isLoggedOut); @@ -84,7 +84,7 @@ export class WelcomeComponent implements Component, WelcomeLogoAnimationHost { this.eyeBlinkState, this.antennaFrame ?? undefined, ); - const renderedLogo = isRainbowDancing() ? renderDanceWelcomeLogo(logoLines) : logoLines; + const renderedLogo = isRainbowHatching() ? renderHatchWelcomeLogo(logoLines) : logoLines; return renderWelcomeBanner({ width, diff --git a/apps/pythinker-code/src/tui/constant/tips.ts b/apps/pythinker-code/src/tui/constant/tips.ts index bc4384310..d67e2fe13 100644 --- a/apps/pythinker-code/src/tui/constant/tips.ts +++ b/apps/pythinker-code/src/tui/constant/tips.ts @@ -19,7 +19,7 @@ export const WORKING_TIPS: readonly ToolbarTip[] = [ { text: 'ctrl-s to add guidance without waiting for the turn to finish', priority: 2, solo: true }, { text: '/tasks to check progress and status for background tasks', priority: 2 }, { text: '/init: generate AGENTS.md', priority: 2 }, - { text: 'Try /dance for a hidden Easter egg' }, + { text: 'Try /hatch for a hidden Easter egg' }, { text: '/plugins: manage plugins — try the "Pythinker Datasource" for reliable financial, economic, and academic data', solo: true, diff --git a/apps/pythinker-code/src/tui/easter-eggs/dance.ts b/apps/pythinker-code/src/tui/easter-eggs/hatch.ts similarity index 68% rename from apps/pythinker-code/src/tui/easter-eggs/dance.ts rename to apps/pythinker-code/src/tui/easter-eggs/hatch.ts index 150363f83..efca365a2 100644 --- a/apps/pythinker-code/src/tui/easter-eggs/dance.ts +++ b/apps/pythinker-code/src/tui/easter-eggs/hatch.ts @@ -1,5 +1,5 @@ /** - * `/dance` easter egg — everything it needs lives in this one file: the + * `/hatch` easter egg — everything it needs lives in this one file: the * rainbow text coloring, the animation state machine, and the command handler. * Removing the feature is "delete this file + its import sites". * @@ -16,9 +16,9 @@ import type { ParsedSlashInput } from '../commands/types'; import { currentTheme } from '../theme'; /** Frame interval for the rainbow flow animation. */ -export const DANCE_FRAME_MS = 110; +export const HATCH_FRAME_MS = 110; /** How long the rainbow flows before settling (fading out, or freezing). */ -export const DANCE_FLOW_MS = 3000; +export const HATCH_FLOW_MS = 3000; const DARK_RAINBOW = [ '#4FA8FF', @@ -43,7 +43,7 @@ const LIGHT_RAINBOW = [ '#354CB5', ] as const; -function getDanceRainbowPalette(): readonly [string, ...string[]] { +function getHatchRainbowPalette(): readonly [string, ...string[]] { return currentTheme.palette.text === '#1A1A1A' ? LIGHT_RAINBOW : DARK_RAINBOW; } @@ -66,69 +66,69 @@ export function rainbowText( .join(''); } -/** Read-only view of the dance state for components that only render it. */ -export interface RainbowDanceView { +/** Read-only view of the hatch state for components that only render it. */ +export interface RainbowHatchView { /** Whether consumers should paint themselves in rainbow at all. */ readonly colored: boolean; /** Palette offset, advancing while the rainbow flows. */ readonly phase: number; } -export interface RainbowDanceController extends RainbowDanceView { +export interface RainbowHatchController extends RainbowHatchView { start(opts: { hold: boolean }): void; stop(): void; dispose(): void; } -let currentDanceController: RainbowDanceController | undefined; -let currentDanceView: RainbowDanceView | undefined; +let currentHatchController: RainbowHatchController | undefined; +let currentHatchView: RainbowHatchView | undefined; -export function setRainbowDance(dance: RainbowDanceController | undefined): void { - currentDanceController = dance; - currentDanceView = dance; +export function setRainbowHatch(hatch: RainbowHatchController | undefined): void { + currentHatchController = hatch; + currentHatchView = hatch; } -export function installRainbowDance(requestRender: () => void): () => void { - currentDanceController?.dispose(); - const dance = new RainbowDance(requestRender); - setRainbowDance(dance); +export function installRainbowHatch(requestRender: () => void): () => void { + currentHatchController?.dispose(); + const hatch = new RainbowHatch(requestRender); + setRainbowHatch(hatch); return () => { - dance.dispose(); - if (currentDanceController === dance) { - setRainbowDance(undefined); + hatch.dispose(); + if (currentHatchController === hatch) { + setRainbowHatch(undefined); } }; } -export function getRainbowDanceView(): RainbowDanceView | undefined { - return currentDanceView; +export function getRainbowHatchView(): RainbowHatchView | undefined { + return currentHatchView; } -export function isRainbowDancing(): boolean { - return currentDanceView?.colored === true; +export function isRainbowHatching(): boolean { + return currentHatchView?.colored === true; } -export function renderDanceWelcomeText( +export function renderHatchWelcomeText( text: string, offset = 0, bold = false, ): string { return rainbowText( text, - getDanceRainbowPalette(), - (currentDanceView?.phase ?? 0) + offset, + getHatchRainbowPalette(), + (currentHatchView?.phase ?? 0) + offset, bold, ); } -export function renderDanceWelcomeLogo(logoLines: readonly string[]): string[] { - const phase = currentDanceView?.phase ?? 0; - const palette = getDanceRainbowPalette(); +export function renderHatchWelcomeLogo(logoLines: readonly string[]): string[] { + const phase = currentHatchView?.phase ?? 0; + const palette = getHatchRainbowPalette(); return logoLines.map((line, index) => rainbowText(line, palette, phase + index * 3)); } -export function renderDanceFooterModel(modelLabel: string): string { - return rainbowText(modelLabel, getDanceRainbowPalette(), currentDanceView?.phase ?? 0); +export function renderHatchFooterModel(modelLabel: string): string { + return rainbowText(modelLabel, getHatchRainbowPalette(), currentHatchView?.phase ?? 0); } /** @@ -137,7 +137,7 @@ export function renderDanceFooterModel(modelLabel: string): string { * scrolling away or being rebuilt never disturbs the animation. Three states: * off (default), flowing, and a frozen static rainbow. */ -export class RainbowDance implements RainbowDanceController { +export class RainbowHatch implements RainbowHatchController { private currentPhase = 0; private isColored = false; private frameTimer: ReturnType | null = null; @@ -157,7 +157,7 @@ export class RainbowDance implements RainbowDanceController { } /** - * Flow the rainbow for `DANCE_FLOW_MS`, then settle: + * Flow the rainbow for `HATCH_FLOW_MS`, then settle: * - `hold: false` → fade back to the default (uncolored) banner. * - `hold: true` → freeze into a static rainbow that stays on. */ @@ -166,13 +166,13 @@ export class RainbowDance implements RainbowDanceController { this.isColored = true; this.frameTimer = setInterval(() => { // Phase just increments; rainbowText() takes it modulo the *current* - // palette length, so the dance never needs to know the palette size. + // palette length, so the hatch never needs to know the palette size. this.currentPhase += 1; this.requestRender(); - }, DANCE_FRAME_MS); + }, HATCH_FRAME_MS); this.flowStopTimer = setTimeout(() => { this.settle(opts.hold); - }, DANCE_FLOW_MS); + }, HATCH_FLOW_MS); this.requestRender(); } @@ -215,16 +215,16 @@ export class RainbowDance implements RainbowDanceController { } /** - * Handle `/dance`: - * /dance flow for a few seconds, then fade back to the default colors - * /dance on flow, then freeze into a static rainbow that stays on - * /dance off turn the rainbow off + * Handle `/hatch`: + * /hatch flow for a few seconds, then fade back to the default colors + * /hatch on flow, then freeze into a static rainbow that stays on + * /hatch off turn the rainbow off * * Returns true when it claimed the input. */ -export function tryHandleDanceCommand(host: SlashCommandHost, parsed: ParsedSlashInput): boolean { - if (parsed.name !== 'dance') return false; - if (currentDanceController === undefined) return false; +export function tryHandleHatchCommand(host: SlashCommandHost, parsed: ParsedSlashInput): boolean { + if (parsed.name !== 'hatch') return false; + if (currentHatchController === undefined) return false; // The status line dims the whole message, which buried the command in the // hint. Paint just the command in the brand color (bold) so it reads as a @@ -233,13 +233,13 @@ export function tryHandleDanceCommand(host: SlashCommandHost, parsed: ParsedSlas const sub = parsed.args.trim().toLowerCase(); if (sub === 'off') { - currentDanceController.stop(); + currentHatchController.stop(); } else if (sub === 'on') { - currentDanceController.start({ hold: true }); - host.showStatus(`Dancing — use ${cmd('/dance off')} to turn it off.`); + currentHatchController.start({ hold: true }); + host.showStatus(`Hatching — use ${cmd('/hatch off')} to turn it off.`); } else { - currentDanceController.start({ hold: false }); - host.showStatus(`Use ${cmd('/dance on')} to keep the rainbow on.`); + currentHatchController.start({ hold: false }); + host.showStatus(`Use ${cmd('/hatch on')} to keep the rainbow on.`); } return true; } diff --git a/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts b/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts index f335a7666..c38c88e81 100644 --- a/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts @@ -8,7 +8,7 @@ import { renderWelcomeBanner, } from '#/tui/components/chrome/welcome-banner'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; -import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; +import { setRainbowHatch, type RainbowHatchController } from '#/tui/easter-eggs/hatch'; import type { AppState } from '#/tui/types'; const TRUECOLOR_PATTERN = /\u001B\[38;2;(\d+);(\d+);(\d+)m/g; @@ -59,15 +59,15 @@ function headerOf(lines: string[]): string { return [lines[3], lines[4]].join('\n'); } -function setDanceView(colored: boolean, phase: number): void { - const dance: RainbowDanceController = { +function setHatchView(colored: boolean, phase: number): void { + const hatch: RainbowHatchController = { colored, phase, start: () => {}, stop: () => {}, dispose: () => {}, }; - setRainbowDance(dance); + setRainbowHatch(hatch); } describe('WelcomeComponent', () => { @@ -79,19 +79,19 @@ describe('WelcomeComponent', () => { afterEach(() => { chalk.level = previousChalkLevel; - setRainbowDance(undefined); + setRainbowHatch(undefined); }); it('renders the branded banner with semantic logo colors by default', () => { const codes = truecolorCodes(headerOf(new WelcomeComponent(appState).render(80))); // The static logo uses themed accent, body, and border tokens; rainbow is - // still off until /dance is activated. + // still off until /hatch is activated. expect(codes.size).toBeGreaterThanOrEqual(3); }); it('paints the banner in rainbow while colored', () => { - setDanceView(true, 0); + setHatchView(true, 0); const codes = truecolorCodes(headerOf(new WelcomeComponent(appState).render(80))); expect(codes.size).toBeGreaterThanOrEqual(5); @@ -99,7 +99,7 @@ describe('WelcomeComponent', () => { it('renders exactly the default banner when not colored', () => { const base = headerOf(new WelcomeComponent(appState).render(80)); - setDanceView(false, 5); + setHatchView(false, 5); const off = headerOf(new WelcomeComponent(appState).render(80)); expect(off).toBe(base); diff --git a/apps/pythinker-code/test/tui/easter-eggs/dance.test.ts b/apps/pythinker-code/test/tui/easter-eggs/hatch.test.ts similarity index 56% rename from apps/pythinker-code/test/tui/easter-eggs/dance.test.ts rename to apps/pythinker-code/test/tui/easter-eggs/hatch.test.ts index 5406a2998..a29da4d97 100644 --- a/apps/pythinker-code/test/tui/easter-eggs/dance.test.ts +++ b/apps/pythinker-code/test/tui/easter-eggs/hatch.test.ts @@ -2,15 +2,15 @@ import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - DANCE_FLOW_MS, - DANCE_FRAME_MS, - getRainbowDanceView, - installRainbowDance, - RainbowDance, + HATCH_FLOW_MS, + HATCH_FRAME_MS, + getRainbowHatchView, + installRainbowHatch, + RainbowHatch, rainbowText, - setRainbowDance, - tryHandleDanceCommand, -} from '#/tui/easter-eggs/dance'; + setRainbowHatch, + tryHandleHatchCommand, +} from '#/tui/easter-eggs/hatch'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; import { darkColors } from '#/tui/theme/colors'; @@ -21,103 +21,103 @@ function truecolorCodes(text: string): string[] { return [...text.matchAll(TRUECOLOR_PATTERN)].map((m) => `${m[1]},${m[2]},${m[3]}`); } -describe('RainbowDance', () => { +describe('RainbowHatch', () => { afterEach(() => { vi.useRealTimers(); }); it('starts uncolored — the banner keeps its default look', () => { - const dance = new RainbowDance(vi.fn()); + const hatch = new RainbowHatch(vi.fn()); - expect(dance.colored).toBe(false); - expect(dance.phase).toBe(0); + expect(hatch.colored).toBe(false); + expect(hatch.phase).toBe(0); }); - it('flows while dancing and requests renders', () => { + it('flows while hatching and requests renders', () => { vi.useFakeTimers(); const requestRender = vi.fn(); - const dance = new RainbowDance(requestRender); + const hatch = new RainbowHatch(requestRender); - dance.start({ hold: false }); - expect(dance.colored).toBe(true); + hatch.start({ hold: false }); + expect(hatch.colored).toBe(true); - const before = dance.phase; - vi.advanceTimersByTime(DANCE_FRAME_MS); - expect(dance.phase).not.toBe(before); + const before = hatch.phase; + vi.advanceTimersByTime(HATCH_FRAME_MS); + expect(hatch.phase).not.toBe(before); expect(requestRender).toHaveBeenCalled(); }); it('fades back to default after the flow when not holding', () => { vi.useFakeTimers(); - const dance = new RainbowDance(vi.fn()); + const hatch = new RainbowHatch(vi.fn()); - dance.start({ hold: false }); - vi.advanceTimersByTime(DANCE_FLOW_MS + DANCE_FRAME_MS); + hatch.start({ hold: false }); + vi.advanceTimersByTime(HATCH_FLOW_MS + HATCH_FRAME_MS); - expect(dance.colored).toBe(false); - expect(dance.phase).toBe(0); + expect(hatch.colored).toBe(false); + expect(hatch.phase).toBe(0); }); it('freezes into a static rainbow after the flow when holding', () => { vi.useFakeTimers(); - const dance = new RainbowDance(vi.fn()); + const hatch = new RainbowHatch(vi.fn()); - dance.start({ hold: true }); - vi.advanceTimersByTime(DANCE_FLOW_MS + DANCE_FRAME_MS); + hatch.start({ hold: true }); + vi.advanceTimersByTime(HATCH_FLOW_MS + HATCH_FRAME_MS); - expect(dance.colored).toBe(true); - const frozen = dance.phase; - vi.advanceTimersByTime(DANCE_FRAME_MS * 10); - expect(dance.phase).toBe(frozen); + expect(hatch.colored).toBe(true); + const frozen = hatch.phase; + vi.advanceTimersByTime(HATCH_FRAME_MS * 10); + expect(hatch.phase).toBe(frozen); }); it('stops on demand back to the default colors and clears its timers', () => { vi.useFakeTimers(); const requestRender = vi.fn(); - const dance = new RainbowDance(requestRender); + const hatch = new RainbowHatch(requestRender); - dance.start({ hold: true }); - vi.advanceTimersByTime(DANCE_FRAME_MS * 3); - expect(dance.phase).toBeGreaterThan(0); + hatch.start({ hold: true }); + vi.advanceTimersByTime(HATCH_FRAME_MS * 3); + expect(hatch.phase).toBeGreaterThan(0); requestRender.mockClear(); - dance.stop(); - expect(dance.colored).toBe(false); - expect(dance.phase).toBe(0); + hatch.stop(); + expect(hatch.colored).toBe(false); + expect(hatch.phase).toBe(0); expect(requestRender).toHaveBeenCalled(); requestRender.mockClear(); - vi.advanceTimersByTime(DANCE_FRAME_MS * 5); + vi.advanceTimersByTime(HATCH_FRAME_MS * 5); expect(requestRender).not.toHaveBeenCalled(); }); it('dispose clears timers silently, without a final render', () => { vi.useFakeTimers(); const requestRender = vi.fn(); - const dance = new RainbowDance(requestRender); + const hatch = new RainbowHatch(requestRender); - dance.start({ hold: false }); - vi.advanceTimersByTime(DANCE_FRAME_MS * 2); + hatch.start({ hold: false }); + vi.advanceTimersByTime(HATCH_FRAME_MS * 2); requestRender.mockClear(); - dance.dispose(); + hatch.dispose(); expect(requestRender).not.toHaveBeenCalled(); - vi.advanceTimersByTime(DANCE_FLOW_MS + DANCE_FRAME_MS * 10); + vi.advanceTimersByTime(HATCH_FLOW_MS + HATCH_FRAME_MS * 10); expect(requestRender).not.toHaveBeenCalled(); }); it('advances the phase by one per frame while flowing', () => { vi.useFakeTimers(); - const dance = new RainbowDance(vi.fn()); + const hatch = new RainbowHatch(vi.fn()); - dance.start({ hold: true }); - vi.advanceTimersByTime(DANCE_FRAME_MS * 5); - expect(dance.phase).toBe(5); + hatch.start({ hold: true }); + vi.advanceTimersByTime(HATCH_FRAME_MS * 5); + expect(hatch.phase).toBe(5); - // Monotonic — the dance state itself has no palette-length cycle. - vi.advanceTimersByTime(DANCE_FRAME_MS * 5); - expect(dance.phase).toBe(10); + // Monotonic — the hatch state itself has no palette-length cycle. + vi.advanceTimersByTime(HATCH_FRAME_MS * 5); + expect(hatch.phase).toBe(10); }); }); @@ -156,50 +156,50 @@ describe('rainbowText', () => { }); }); -describe('installRainbowDance', () => { +describe('installRainbowHatch', () => { afterEach(() => { - setRainbowDance(undefined); + setRainbowHatch(undefined); vi.useRealTimers(); }); it('returns a disposer that clears timers and uninstalls the controller', () => { vi.useFakeTimers(); const requestRender = vi.fn(); - const dispose = installRainbowDance(requestRender); + const dispose = installRainbowHatch(requestRender); const host = { showStatus: vi.fn(), state: { theme: { palette: darkColors } }, } as unknown as SlashCommandHost; - tryHandleDanceCommand(host, { name: 'dance', args: 'on' }); - vi.advanceTimersByTime(DANCE_FRAME_MS * 2); + tryHandleHatchCommand(host, { name: 'hatch', args: 'on' }); + vi.advanceTimersByTime(HATCH_FRAME_MS * 2); expect(requestRender).toHaveBeenCalled(); requestRender.mockClear(); dispose(); - expect(getRainbowDanceView()).toBeUndefined(); - vi.advanceTimersByTime(DANCE_FLOW_MS + DANCE_FRAME_MS * 10); + expect(getRainbowHatchView()).toBeUndefined(); + vi.advanceTimersByTime(HATCH_FLOW_MS + HATCH_FRAME_MS * 10); expect(requestRender).not.toHaveBeenCalled(); }); }); -interface DanceCall { +interface HatchCall { fn: 'start' | 'stop'; hold?: boolean; } -function makeHost(): { host: SlashCommandHost; calls: DanceCall[]; status: string[] } { - const calls: DanceCall[] = []; +function makeHost(): { host: SlashCommandHost; calls: HatchCall[]; status: string[] } { + const calls: HatchCall[] = []; const status: string[] = []; - const rainbowDance = { + const rainbowHatch = { colored: false, phase: 0, start: (opts: { hold: boolean }) => calls.push({ fn: 'start', hold: opts.hold }), stop: () => calls.push({ fn: 'stop' }), dispose: () => {}, }; - setRainbowDance(rainbowDance); + setRainbowHatch(rainbowHatch); const host = { showStatus: (msg: string) => status.push(msg), state: { theme: { palette: darkColors } }, @@ -207,9 +207,9 @@ function makeHost(): { host: SlashCommandHost; calls: DanceCall[]; status: strin return { host, calls, status }; } -describe('tryHandleDanceCommand', () => { +describe('tryHandleHatchCommand', () => { let host: SlashCommandHost; - let calls: DanceCall[]; + let calls: HatchCall[]; let status: string[]; beforeEach(() => { @@ -217,46 +217,46 @@ describe('tryHandleDanceCommand', () => { }); afterEach(() => { - setRainbowDance(undefined); + setRainbowHatch(undefined); }); - it('claims /dance, flowing then fading, and hints at /dance on', () => { - const handled = tryHandleDanceCommand(host, { name: 'dance', args: '' }); + it('claims /hatch, flowing then fading, and hints at /hatch on', () => { + const handled = tryHandleHatchCommand(host, { name: 'hatch', args: '' }); expect(handled).toBe(true); expect(calls).toEqual([{ fn: 'start', hold: false }]); - expect(status.join(' ')).toContain('/dance on'); + expect(status.join(' ')).toContain('/hatch on'); }); - it('holds the rainbow for /dance on and hints at /dance off', () => { - const handled = tryHandleDanceCommand(host, { name: 'dance', args: 'on' }); + it('holds the rainbow for /hatch on and hints at /hatch off', () => { + const handled = tryHandleHatchCommand(host, { name: 'hatch', args: 'on' }); expect(handled).toBe(true); expect(calls).toEqual([{ fn: 'start', hold: true }]); - expect(status.join(' ')).toContain('/dance off'); + expect(status.join(' ')).toContain('/hatch off'); }); - it('turns the rainbow off for /dance off', () => { - const handled = tryHandleDanceCommand(host, { name: 'dance', args: 'off' }); + it('turns the rainbow off for /hatch off', () => { + const handled = tryHandleHatchCommand(host, { name: 'hatch', args: 'off' }); expect(handled).toBe(true); expect(calls).toEqual([{ fn: 'stop' }]); }); it('ignores case and surrounding whitespace in the sub-command', () => { - tryHandleDanceCommand(host, { name: 'dance', args: ' ON ' }); + tryHandleHatchCommand(host, { name: 'hatch', args: ' ON ' }); expect(calls).toEqual([{ fn: 'start', hold: true }]); }); - it('treats an unknown sub-command as a one-off dance', () => { - tryHandleDanceCommand(host, { name: 'dance', args: 'wiggle' }); + it('treats an unknown sub-command as a one-off hatch', () => { + tryHandleHatchCommand(host, { name: 'hatch', args: 'wiggle' }); expect(calls).toEqual([{ fn: 'start', hold: false }]); }); it('does not claim other commands, so they fall through normally', () => { - const handled = tryHandleDanceCommand(host, { name: 'help', args: '' }); + const handled = tryHandleHatchCommand(host, { name: 'help', args: '' }); expect(handled).toBe(false); expect(calls).toEqual([]); From ad27bb47b4a06fbefc034715cbbc195c9f3eb7a9 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 22 Aug 2026 00:57:55 -0400 Subject: [PATCH 3/6] feat(tui): cycle thinking effort with shift-tab --- apps/pythinker-code/src/tui/constant/tips.ts | 2 +- .../src/tui/controllers/editor-keyboard.ts | 106 ++++++++--- .../tui/controllers/editor-keyboard.test.ts | 168 ++++++++++++++---- 3 files changed, 217 insertions(+), 59 deletions(-) diff --git a/apps/pythinker-code/src/tui/constant/tips.ts b/apps/pythinker-code/src/tui/constant/tips.ts index d67e2fe13..ad2dbf0e5 100644 --- a/apps/pythinker-code/src/tui/constant/tips.ts +++ b/apps/pythinker-code/src/tui/constant/tips.ts @@ -44,6 +44,6 @@ export const ALL_TIPS: readonly ToolbarTip[] = [ { text: '/help: show commands' }, { text: '/compact compresses context when it gets long', priority: 2 }, { text: 'ctrl-o to hide or reveal tool output switching between a clean chat view and full execution details', priority: 2 }, - { text: 'shift-tab to Plan mode to review the approach before Pythinker edits files.', priority: 2 }, + { text: '/plan to review the approach before Pythinker edits files.', priority: 2 }, { text: '/model: switch model', priority: 2 }, ]; diff --git a/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts b/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts index 78f8ed2f1..0a7c00f10 100644 --- a/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts @@ -1,6 +1,11 @@ import { readFile } from 'node:fs/promises'; -import type { FileMeta, PythinkerHarness, Session } from '@pymodel/pythinker-code-sdk'; +import type { + FileMeta, + PythinkerHarness, + Session, + ThinkingEffort, +} from '@pymodel/pythinker-code-sdk'; import { compressImageForModel } from '@pymodel/pythinker-code-sdk'; import { @@ -11,6 +16,7 @@ import { import { parseImageMeta } from '#/utils/image/image-mime'; import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor'; +import { segmentsFor } from '../components/dialogs/model-selector'; import { CTRL_C_HINT, CTRL_D_HINT, @@ -28,7 +34,13 @@ import type { } from '../utils/image-attachment-store'; import { extractMediaAttachments, imageExtensionForMime } from '../utils/image-placeholder'; import { extractInlineSkillActivations } from '../utils/inline-skill-tokens'; -import type { PendingExit, QueuedMessage, SteerInputItem } from '../types'; +import { thinkingEffortToConfig } from '../utils/thinking-config'; +import type { + AppState, + PendingExit, + QueuedMessage, + SteerInputItem, +} from '../types'; import type { TUIState } from '../tui-state'; import type { BtwPanelController } from './btw-panel'; @@ -63,6 +75,8 @@ export interface EditorKeyboardHost { releaseStagingMedia(mediaAttachmentIds: readonly number[]): void; recallLastQueued(): QueuedMessage | undefined; showError(msg: string): void; + showNotice(title: string, detail?: string): void; + setAppState(patch: Partial): void; track(event: string, props?: Record): void; updateEditorBorderHighlight(text?: string): void; /** `undefined` means the input cannot be a `/goal` command (clear without measuring). */ @@ -76,7 +90,6 @@ export interface EditorKeyboardHost { openUndoSelector(): void; stop(exitCode?: number): Promise; ensureSession(): Promise; - handlePlanToggle(next: boolean): void; handleInputModeChange(mode: 'prompt' | 'bash'): void; clearQueuedMessages(): void; setExternalEditorRunning(running: boolean): void; @@ -256,25 +269,7 @@ export class EditorKeyboardController { }; editor.onShiftTab = () => { - const togglePlan = (): void => { - const next = !host.state.appState.planMode; - host.track('shortcut_plan_toggle', { enabled: next }); - host.track('shortcut_mode_switch', { to_mode: next ? 'plan' : 'agent' }); - host.handlePlanToggle(next); - }; - if (host.session === undefined) { - if (!host.engineV2) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - // v2 session-less: lazy-create the session, then toggle — the same - // path /plan takes. - void host.ensureSession().then((session) => { - if (session !== undefined) togglePlan(); - }); - return; - } - togglePlan(); + void this.cycleThinkingEffort(); }; editor.onInputModeChange = (mode) => { @@ -532,6 +527,73 @@ export class EditorKeyboardController { void this.host.session?.cancel(); } + /** Shift-Tab: cycle the thinking effort to the current model's next level (wraps). */ + private async cycleThinkingEffort(): Promise { + const { host } = this; + if (host.state.appState.streamingPhase !== 'idle' || host.state.appState.isCompacting) { + host.showError('Cannot change thinking effort while streaming — press Esc or Ctrl-C first.'); + return; + } + const alias = host.state.appState.model; + if (alias.trim().length === 0) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + const model = host.state.appState.availableModels[alias]; + if (model === undefined) { + host.showError('No model selected. Run /model to select one first.'); + return; + } + const levels = segmentsFor(model); + if (levels.length <= 1) { + host.showNotice(`${alias} does not offer selectable thinking effort levels.`); + return; + } + const prev = host.state.appState.thinkingEffort; + const currentIndex = levels.indexOf(prev); + // An out-of-list live effort (e.g. a provider-specific value) restarts the + // cycle from the off entry when offered, else from the first level. + const startIndex = currentIndex !== -1 ? currentIndex + 1 : Math.max(0, levels.indexOf('off')); + const next = levels[startIndex % levels.length] ?? levels[0]!; + if (host.session !== undefined) { + try { + await host.session.setThinking(next); + } catch (error) { + host.showError(`Failed to set thinking effort: ${formatErrorMessage(error)}`); + return; + } + } else if (!host.engineV2) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + // v2 session-less: carry the choice into the first lazy-created session, + // the same way a session-only Alt+S choice is applied on creation. + const patch: Partial = { thinkingEffort: next }; + if (host.session === undefined) patch.lazySessionThinking = next; + host.setAppState(patch); + host.track('thinking_toggle', { enabled: next !== 'off', effort: next, from: prev }); + // No transcript notice: the footer already shows the new level live, and + // rapid cycling would stack a line per keypress in the chat history. + await this.persistDefaultEffort(alias, model, next); + } + + /** Best-effort persist of the cycled effort as the config default. */ + private async persistDefaultEffort( + alias: string, + model: Parameters[0], + effort: ThinkingEffort, + ): Promise { + const harness = this.host.harness; + if (harness === undefined || alias !== this.host.state.appState.model) return; + try { + await harness.setConfig({ thinking: thinkingEffortToConfig(effort, model.supportEfforts) }); + } catch (error) { + this.host.showError( + `Thinking effort set to ${effort}, but failed to save default: ${formatErrorMessage(error)}`, + ); + } + } + private cancelCurrentCompaction(): void { const session = this.host.session; if (session === undefined) return; diff --git a/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts b/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts index bba93007a..a438aec56 100644 --- a/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts @@ -383,84 +383,180 @@ describe('EditorKeyboardController input changes', () => { }); }); -describe('EditorKeyboardController Shift-Tab plan toggle', () => { - function createShiftTabHarness(options: { sessionless?: boolean; engineV2?: boolean } = {}) { +describe('EditorKeyboardController Shift-Tab effort cycle', () => { + function createEffortHarness( + options: { + supportEfforts?: string[]; + capabilities?: string[]; + thinkingEffort?: string; + streamingPhase?: string; + sessionless?: boolean; + engineV2?: boolean; + setThinkingError?: Error; + } = {}, + ) { const editor: Record unknown) | undefined> = { setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown, }; - const handlePlanToggle = vi.fn(); + const setThinking = vi.fn(async () => {}); + if (options.setThinkingError !== undefined) { + setThinking.mockRejectedValue(options.setThinkingError); + } + const setConfig = vi.fn(async () => {}); const track = vi.fn(); const showError = vi.fn(); - const ensureSession = vi.fn(async (): Promise<{ id: string } | undefined> => ({ id: 'ses-lazy' })); + const showNotice = vi.fn(); + const appState: Record = { + streamingPhase: options.streamingPhase ?? 'idle', + isCompacting: false, + model: 'kimi-k2', + thinkingEffort: options.thinkingEffort ?? 'off', + availableModels: { + 'kimi-k2': + options.supportEfforts === undefined + ? { + provider: 'managed:pythinker-code', + model: 'kimi-k2', + maxContextSize: 262144, + capabilities: options.capabilities ?? ['thinking'], + } + : { + provider: 'managed:pythinker-code', + model: 'kimi-k2', + maxContextSize: 262144, + supportEfforts: options.supportEfforts, + }, + }, + }; + const statePatches: Array> = []; const host = { state: { editor, activeDialog: null, - appState: { streamingPhase: 'idle', isCompacting: false, planMode: false }, + appState, footer: { setTransientHint: vi.fn() }, ui: { requestRender: vi.fn() }, }, - session: options.sessionless ? undefined : { cancel: vi.fn(async () => {}) }, + session: + options.sessionless === true + ? undefined + : { cancel: vi.fn(async () => {}), setThinking }, engineV2: options.engineV2 ?? false, - ensureSession, - handlePlanToggle, + harness: { setConfig }, + // Merge like the real host so successive presses read fresh effort. + setAppState: (patch: Record) => { + Object.assign(appState, patch); + statePatches.push(patch); + }, track, showError, + showNotice, btwPanelController: { cancelRunning: vi.fn(), closeOrCancel: vi.fn() }, } as unknown as EditorKeyboardHost; new EditorKeyboardController(host, undefined as unknown as ImageAttachmentStore).install(); const onShiftTab = editor['onShiftTab'] as unknown as () => void; - return { onShiftTab, handlePlanToggle, track, showError, ensureSession }; + return { onShiftTab, setThinking, setConfig, track, showError, showNotice, statePatches }; } - it('toggles plan mode directly with an active session', () => { - const { onShiftTab, handlePlanToggle, ensureSession } = createShiftTabHarness(); + async function settle(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + } - onShiftTab(); + it('cycles off → low → high → max → off and persists the default', async () => { + const h = createEffortHarness({ supportEfforts: ['low', 'high', 'max'] }); + const press = async (): Promise => { + h.onShiftTab(); + await settle(); + return h.statePatches.at(-1)?.['thinkingEffort']; + }; - expect(ensureSession).not.toHaveBeenCalled(); - expect(handlePlanToggle).toHaveBeenCalledWith(true); + await expect(press()).resolves.toBe('low'); + expect(h.setThinking).toHaveBeenCalledWith('low'); + expect(h.setConfig).toHaveBeenCalledWith({ thinking: { enabled: true, effort: 'low' } }); + + await expect(press()).resolves.toBe('high'); + await expect(press()).resolves.toBe('max'); + // The top declared level never becomes the stored default effort. + expect(h.setConfig).toHaveBeenLastCalledWith({ thinking: { enabled: true } }); + + await expect(press()).resolves.toBe('off'); + expect(h.setConfig).toHaveBeenLastCalledWith({ thinking: { enabled: false } }); + expect(h.track).toHaveBeenLastCalledWith('thinking_toggle', { + enabled: false, + effort: 'off', + from: 'max', + }); }); - it('reports no active session on v1 when session-less', () => { - const { onShiftTab, showError, handlePlanToggle } = createShiftTabHarness({ - sessionless: true, + it('refuses to cycle while a turn is streaming', async () => { + const h = createEffortHarness({ + supportEfforts: ['low', 'high'], + streamingPhase: 'composing', }); - onShiftTab(); + h.onShiftTab(); + await settle(); + + expect(h.showError).toHaveBeenCalledWith( + 'Cannot change thinking effort while streaming — press Esc or Ctrl-C first.', + ); + expect(h.setThinking).not.toHaveBeenCalled(); + }); + + it('reports no active session on v1 when session-less', async () => { + const h = createEffortHarness({ supportEfforts: ['low', 'high'], sessionless: true }); + + h.onShiftTab(); + await settle(); - expect(showError).toHaveBeenCalledWith(NO_ACTIVE_SESSION_MESSAGE); - expect(handlePlanToggle).not.toHaveBeenCalled(); + expect(h.showError).toHaveBeenCalledWith(NO_ACTIVE_SESSION_MESSAGE); + expect(h.statePatches).toEqual([]); }); - it('lazy-creates the session before toggling on v2 when session-less', async () => { - const { onShiftTab, ensureSession, handlePlanToggle, track } = createShiftTabHarness({ + it('carries the cycled effort into the lazy v2 session when session-less', async () => { + const h = createEffortHarness({ + supportEfforts: ['low', 'high'], sessionless: true, engineV2: true, }); - onShiftTab(); - expect(handlePlanToggle).not.toHaveBeenCalled(); + h.onShiftTab(); + await settle(); - await vi.waitFor(() => { - expect(handlePlanToggle).toHaveBeenCalledWith(true); + expect(h.setThinking).not.toHaveBeenCalled(); + expect(h.statePatches.at(-1)).toMatchObject({ + thinkingEffort: 'low', + lazySessionThinking: 'low', }); - expect(ensureSession).toHaveBeenCalledOnce(); - expect(track).toHaveBeenCalledWith('shortcut_plan_toggle', { enabled: true }); + expect(h.showError).not.toHaveBeenCalled(); }); - it('does not toggle when the lazy creation fails on v2', async () => { - const { onShiftTab, ensureSession, handlePlanToggle } = createShiftTabHarness({ - sessionless: true, - engineV2: true, + it('notifies when the model offers no selectable levels', async () => { + const h = createEffortHarness({ capabilities: ['always_thinking'] }); + + h.onShiftTab(); + await settle(); + + expect(h.showNotice).toHaveBeenCalledWith( + 'kimi-k2 does not offer selectable thinking effort levels.', + ); + expect(h.setThinking).not.toHaveBeenCalled(); + }); + + it('surfaces a setThinking failure without changing state', async () => { + const h = createEffortHarness({ + supportEfforts: ['low', 'high'], + setThinkingError: new Error('boom'), }); - ensureSession.mockResolvedValue(undefined); - onShiftTab(); - await new Promise((resolve) => setImmediate(resolve)); + h.onShiftTab(); + await settle(); - expect(handlePlanToggle).not.toHaveBeenCalled(); + expect(h.showError).toHaveBeenCalledWith('Failed to set thinking effort: boom'); + expect(h.statePatches).toEqual([]); + expect(h.setConfig).not.toHaveBeenCalled(); }); }); From 501cd5686dd30433488f7ac5d098e248286e1105 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 22 Aug 2026 00:57:55 -0400 Subject: [PATCH 4/6] fix(web): hold running tool rows at full emphasis --- apps/pythinker-web/src/components/chat/ToolRow.vue | 13 ++++++++++++- .../src/components/chat/tool-calls/EditTool.vue | 6 ++++-- .../src/components/chat/tool-calls/ReadTool.vue | 6 ++++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/pythinker-web/src/components/chat/ToolRow.vue b/apps/pythinker-web/src/components/chat/ToolRow.vue index 7cda4af6e..5fcb2bf4b 100644 --- a/apps/pythinker-web/src/components/chat/ToolRow.vue +++ b/apps/pythinker-web/src/components/chat/ToolRow.vue @@ -47,6 +47,7 @@ function onHeadClick(): void { :class="{ open, stacked, + run: status === 'running' || status === 'suspended', err: status === 'error', 'stack-first': stackPosition === 'first', 'stack-middle': stackPosition === 'middle', @@ -110,6 +111,10 @@ function onHeadClick(): void { border-top: 1px solid var(--color-line); } +/* Head text emphasis, inherited by slotted titles too: a settled row sits + dimmed, hover/open restores legibility, and a running/suspended row holds + full strength — then fades back out once it finishes. Slotted content + (Edit/Read tool titles) picks this up via custom-property inheritance. */ .bh { display: flex; align-items: center; @@ -119,10 +124,15 @@ function onHeadClick(): void { cursor: pointer; font: var(--text-sm) var(--font-mono); color: var(--color-text); + --emph: var(--color-text-muted); } .box.open .bh, .bh:hover { background: var(--color-surface-sunken); + --emph: var(--color-text); +} +.box.run .bh { + --emph: var(--color-text-strong); } .box.err .bh { background: color-mix(in srgb, var(--color-danger) 4%, var(--bg)); @@ -145,9 +155,10 @@ function onHeadClick(): void { min-width: 0; } .a { - color: var(--color-text); + color: var(--emph); font-weight: var(--weight-medium); flex: none; + transition: color var(--duration-slow) var(--ease-out); } .p { color: var(--color-text-muted); diff --git a/apps/pythinker-web/src/components/chat/tool-calls/EditTool.vue b/apps/pythinker-web/src/components/chat/tool-calls/EditTool.vue index 5c6258ef7..48c24f896 100644 --- a/apps/pythinker-web/src/components/chat/tool-calls/EditTool.vue +++ b/apps/pythinker-web/src/components/chat/tool-calls/EditTool.vue @@ -118,9 +118,10 @@ function openFile(): void {