From b1e6b6431903fde002fdddbdfcabfab39f3ef5c5 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 20:12:55 +0800 Subject: [PATCH] feat(tui): show working tips behind composing spinner (#1033) * refactor(tui): extract toolbar tip constants to tui/constant/tips.ts * feat(tui): add WORKING_TIPS subset to tip constants * feat(tui): allow MoonLoader to render a dim tip suffix * feat(tui): pass optional tip through ActivityPaneComponent * feat(tui): show working tip behind composing spinner * feat(tui): hide working tip when spinner line does not fit * chore: add changeset for working tips * feat(tui): guard setAvailableWidth to skip unchanged widths * feat(tui): show working tips on moon loader and compaction * feat(tui): use singular 'Tip:' label for loading tips * fix(tui): keep the same loading tip across waiting/thinking/tool/composing within one turn * feat: show contextual working tips behind loading spinners - Add pickRandomWorkingTip() for per-step tip selection - Cache tips by loading kind (moon/composing) so continuous tool bursts keep the same tip - Update tip inventory with /web, /plugins, /goal, /sessions, etc. - Add unit tests for random tip selection * docs: update working-tips changeset summary --- .changeset/working-tips.md | 5 ++ .../src/tui/components/chrome/footer.ts | 42 +---------- .../src/tui/components/chrome/moon-loader.ts | 26 ++++++- .../src/tui/components/chrome/working-tips.ts | 31 ++++++++ .../src/tui/components/dialogs/compaction.ts | 7 +- .../src/tui/components/panes/activity-pane.ts | 29 +++++--- apps/kimi-code/src/tui/constant/tips.ts | 49 +++++++++++++ .../src/tui/controllers/streaming-ui.ts | 3 +- apps/kimi-code/src/tui/kimi-tui.ts | 30 ++++++++ .../tui/components/dialogs/compaction.test.ts | 28 ++++++++ .../components/panes/activity-pane.test.ts | 70 ++++++++++++++++++- apps/kimi-code/test/tui/constant/tips.test.ts | 50 +++++++++++++ apps/kimi-code/test/tui/working-tips.test.ts | 54 ++++++++++++++ 13 files changed, 368 insertions(+), 56 deletions(-) create mode 100644 .changeset/working-tips.md create mode 100644 apps/kimi-code/src/tui/components/chrome/working-tips.ts create mode 100644 apps/kimi-code/src/tui/constant/tips.ts create mode 100644 apps/kimi-code/test/tui/constant/tips.test.ts create mode 100644 apps/kimi-code/test/tui/working-tips.test.ts diff --git a/.changeset/working-tips.md b/.changeset/working-tips.md new file mode 100644 index 000000000..7e157aa88 --- /dev/null +++ b/.changeset/working-tips.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Optimize the loading tips display. diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index daf08b3ce..e77f1a18c 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -10,6 +10,7 @@ import type { Component } from '@earendil-works/pi-tui'; import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import chalk from 'chalk'; +import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips'; import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance'; import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; @@ -31,48 +32,9 @@ const GOAL_TIMER_INTERVAL_MS = 1_000; // important enough to take the whole slot on their own. A `priority` weight // makes a tip recur more often in the rotation (default 1). Width is always // the final arbiter (a pair that doesn't fit falls back to its first tip). -// -// This is deliberately code-level configuration: edit the interval and the -// TOOLBAR_TIPS array below to change what the footer advertises. const TIP_ROTATE_INTERVAL_MS = 10_000; const TIP_SEPARATOR = ' | '; -export interface ToolbarTip { - readonly text: string; - /** - * Long/important tips render on their own. They never pair with a - * neighbour and never appear as the second half of someone else's pair. - */ - readonly solo?: boolean; - /** - * Rotation weight: a higher value makes the tip recur more often. Defaults - * to 1. Used to give newer/important features more airtime. - */ - readonly priority?: number; -} - -const TOOLBAR_TIPS: readonly ToolbarTip[] = [ - { text: 'shift+tab: plan mode' }, - { text: '/model: switch model' }, - { text: 'ctrl+s: steer mid-turn', priority: 2 }, - { text: 'ctrl+b: background task', priority: 2 }, - { text: '/compact: compact context', priority: 2 }, - { text: 'ctrl+o: expand tool output' }, - { text: 'ctrl+t: expand todo list' }, - { text: '/tasks: background tasks' }, - { text: 'shift+enter: newline' }, - { text: '/init: generate AGENTS.md', priority: 2 }, - { text: '@: mention files' }, - { text: 'ctrl+c: cancel' }, - { text: '/theme: switch theme' }, - { text: '/auto: auto permission mode' }, - { text: '/yolo: toggle yolo' }, - { text: '/help: show commands' }, - { text: '/dance: rainbow mode, because why not' }, - { text: '/plugins: manage plugins — try the "superpowers" plugin', solo: true, priority: 3 }, - { text: 'ask Kimi to schedule tasks, e.g. "remind me at 5pm"', solo: true, priority: 3 }, -]; - /** * Expand tips into a rotation sequence using smooth weighted round-robin * (the nginx SWRR algorithm). Higher-`priority` tips appear more often while @@ -100,7 +62,7 @@ export function buildWeightedTips(tips: readonly ToolbarTip[]): readonly Toolbar return seq; } -const ROTATION: readonly ToolbarTip[] = buildWeightedTips(TOOLBAR_TIPS); +const ROTATION: readonly ToolbarTip[] = buildWeightedTips(ALL_TIPS); function currentTipIndex(): number { return Math.floor(Date.now() / TIP_ROTATE_INTERVAL_MS); diff --git a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts index 0f9c971ea..9b03bff70 100644 --- a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts +++ b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts @@ -1,4 +1,4 @@ -import { Text } from '@earendil-works/pi-tui'; +import { Text, visibleWidth } from '@earendil-works/pi-tui'; import type { TUI } from '@earendil-works/pi-tui'; import { @@ -7,6 +7,7 @@ import { MOON_SPINNER_FRAMES, MOON_SPINNER_INTERVAL_MS, } from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; export type SpinnerStyle = 'moon' | 'braille'; @@ -19,6 +20,8 @@ export class MoonLoader extends Text { private colorFn?: (s: string) => string; private label: string; private displayText = ''; + private tip: string = ''; + private availableWidth = 0; constructor( ui: TUI, @@ -60,6 +63,17 @@ export class MoonLoader extends Text { this.updateDisplay(); } + setTip(tip: string): void { + this.tip = tip; + this.updateDisplay(); + } + + setAvailableWidth(width: number): void { + if (this.availableWidth === width) return; + this.availableWidth = width; + this.updateDisplay(); + } + renderInline(): string { return this.displayText; } @@ -67,7 +81,15 @@ export class MoonLoader extends Text { private updateDisplay(): void { const frame = this.frames[this.currentFrame]!; const coloredFrame = this.colorFn ? this.colorFn(frame) : frame; - this.displayText = this.label ? `${coloredFrame} ${this.label}` : coloredFrame; + const baseText = this.label ? `${coloredFrame} ${this.label}` : coloredFrame; + let text = baseText; + if (this.tip) { + const withTip = baseText + currentTheme.fg('textDim', this.tip); + if (this.availableWidth === 0 || visibleWidth(withTip) <= this.availableWidth) { + text = withTip; + } + } + this.displayText = text; this.setText(this.displayText); this.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/components/chrome/working-tips.ts b/apps/kimi-code/src/tui/components/chrome/working-tips.ts new file mode 100644 index 000000000..23d002738 --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/working-tips.ts @@ -0,0 +1,31 @@ +import { WORKING_TIPS, type ToolbarTip } from '#/tui/constant/tips'; + +import { buildWeightedTips } from './footer'; + +export { WORKING_TIPS }; + +const TIP_ROTATE_INTERVAL_MS = 10_000; + +const WORKING_TIP_ROTATION = buildWeightedTips(WORKING_TIPS); + +export function currentWorkingTip(now = Date.now()): ToolbarTip | undefined { + if (WORKING_TIP_ROTATION.length === 0) return undefined; + const index = Math.floor(now / TIP_ROTATE_INTERVAL_MS) % WORKING_TIP_ROTATION.length; + return WORKING_TIP_ROTATION[index]; +} + +/** + * Pick a random tip from the weighted working-tip rotation. + * If `excludeText` is provided and there are other tips available, avoid + * returning the same text twice in a row. + */ +export function pickRandomWorkingTip(excludeText?: string): ToolbarTip | undefined { + if (WORKING_TIP_ROTATION.length === 0) return undefined; + const candidates = + excludeText === undefined || WORKING_TIP_ROTATION.length === 1 + ? WORKING_TIP_ROTATION + : WORKING_TIP_ROTATION.filter((t) => t.text !== excludeText); + const pool = candidates.length > 0 ? candidates : WORKING_TIP_ROTATION; + const index = Math.floor(Math.random() * pool.length); + return pool[index]; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/compaction.ts b/apps/kimi-code/src/tui/components/dialogs/compaction.ts index f2ef1a75c..90a924757 100644 --- a/apps/kimi-code/src/tui/components/dialogs/compaction.ts +++ b/apps/kimi-code/src/tui/components/dialogs/compaction.ts @@ -25,6 +25,7 @@ export class CompactionComponent extends Container { private readonly ui: TUI | undefined; private readonly headerText: Text; private readonly instruction: string | undefined; + private readonly tip: string | undefined; private blinkOn = true; private blinkTimer: ReturnType | null = null; private done = false; @@ -32,10 +33,11 @@ export class CompactionComponent extends Container { private tokensBefore: number | undefined; private tokensAfter: number | undefined; - constructor(ui?: TUI, instruction?: string | undefined) { + constructor(ui?: TUI, instruction?: string | undefined, tip?: string) { super(); this.ui = ui; this.instruction = instruction; + this.tip = tip; // Top margin so the block isn't glued to the previous transcript // entry (status line, tool result, etc.). @@ -107,7 +109,8 @@ export class CompactionComponent extends Container { } const bullet = this.blinkOn ? currentTheme.fg('text', STATUS_BULLET) : ' '; const label = currentTheme.boldFg('primary', 'Compacting context...'); - return `${bullet}${label}`; + const tip = this.tip ? currentTheme.fg('textDim', ` · Tip: ${this.tip}`) : ''; + return `${bullet}${label}${tip}`; } private startBlink(): void { diff --git a/apps/kimi-code/src/tui/components/panes/activity-pane.ts b/apps/kimi-code/src/tui/components/panes/activity-pane.ts index 2a1d8ea1d..443f3aa74 100644 --- a/apps/kimi-code/src/tui/components/panes/activity-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -1,29 +1,38 @@ import { Container, Spacer } from '@earendil-works/pi-tui'; -import type { MoonLoader } from '../chrome/moon-loader'; +import type { MoonLoader } from '#/tui/components/chrome/moon-loader'; export type ActivityPaneMode = 'hidden' | 'waiting' | 'thinking' | 'composing' | 'tool'; export interface ActivityPaneOptions { readonly mode: ActivityPaneMode; readonly spinner?: MoonLoader; + readonly tip?: string; } export class ActivityPaneComponent extends Container { + private spinnerRef?: MoonLoader; + constructor(options: ActivityPaneOptions) { super(); + this.spinnerRef = options.spinner; - if (options.mode === 'waiting' || options.mode === 'tool') { - if (options.spinner !== undefined) { - this.addChild(new Spacer(1)); - this.addChild(options.spinner); - } - return; - } - - if (options.mode === 'composing' && options.spinner !== undefined) { + if ( + (options.mode === 'waiting' || options.mode === 'tool' || options.mode === 'composing') && + options.spinner !== undefined + ) { this.addChild(new Spacer(1)); + if (options.tip) { + options.spinner.setTip(` · Tip: ${options.tip}`); + } this.addChild(options.spinner); } } + + override render(width: number): string[] { + if (this.spinnerRef && 'setAvailableWidth' in this.spinnerRef) { + this.spinnerRef.setAvailableWidth(width); + } + return super.render(width); + } } diff --git a/apps/kimi-code/src/tui/constant/tips.ts b/apps/kimi-code/src/tui/constant/tips.ts new file mode 100644 index 000000000..c04b5a763 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/tips.ts @@ -0,0 +1,49 @@ +export interface ToolbarTip { + readonly text: string; + /** + * Long/important tips render on their own. They never pair with a + * neighbour and never appear as the second half of someone else's pair. + */ + readonly solo?: boolean; + /** + * Rotation weight: a higher value makes the tip recur more often. Defaults + * to 1. Used to give newer/important features more airtime. + */ + readonly priority?: number; +} + +/** + * Subset of toolbar tips shown behind the composing spinner. + */ +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: '/plugins: manage plugins — try the "superpowers" plugin', solo: true, priority: 3 }, + { + text: '/plugins: manage plugins — try the "Kimi Datasource" for reliable financial, economic, and academic data', + solo: true, + priority: 3, + }, + { text: 'ask Kimi to schedule tasks, e.g. "remind me at 5pm"', solo: true, priority: 3 }, + { text: '/sessions to browse and resume earlier sessions', solo: true }, + { text: '/goal for multi-step work with a clear finish line', priority: 2, solo: true }, + { text: '/goal next to queue follow-up work while the current goal keeps running', solo: true }, + { text: '/web: use the Web UI for a better experience', solo: true }, + { text: '@: mention files', priority: 2 }, +]; + +export const ALL_TIPS: readonly ToolbarTip[] = [ + ...WORKING_TIPS, + { text: 'shift+enter: newline' }, + { text: 'ctrl+c: cancel' }, + { text: '/theme to switch the terminal UI theme' }, + { text: '/auto when you want Kimi to handle approvals and keep going unattended' }, + { text: '/yolo to skip most approvals for trusted batch work, only use it in repos you trust' }, + { 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 Kimi edits files.', priority: 2 }, + { text: '/model: switch model', priority: 2 }, +]; diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 35aa951ca..4581fc326 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -2,6 +2,7 @@ import type { Session } from '@moonshot-ai/kimi-code-sdk'; import { AgentGroupComponent } from '../components/messages/agent-group'; import { AssistantMessageComponent } from '../components/messages/assistant-message'; +import { currentWorkingTip } from '../components/chrome/working-tips'; import { CompactionComponent } from '../components/dialogs/compaction'; import { ReadGroupComponent } from '../components/messages/read-group'; import { ThinkingComponent } from '../components/messages/thinking'; @@ -711,7 +712,7 @@ export class StreamingUIController { this._activeCompactionBlock.markDone(); this._activeCompactionBlock = undefined; } - const block = new CompactionComponent(state.ui, instruction); + const block = new CompactionComponent(state.ui, instruction, currentWorkingTip()?.text); this._activeCompactionBlock = block; state.transcriptContainer.addChild(block); state.ui.requestRender(); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 67d8375c2..b18125711 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -48,6 +48,7 @@ import { DeviceCodeBoxComponent } from './components/chrome/device-code-box'; import { GutterContainer } from './components/chrome/gutter-container'; import { MoonLoader, type SpinnerStyle } from './components/chrome/moon-loader'; import { WelcomeComponent } from './components/chrome/welcome'; +import { pickRandomWorkingTip } from './components/chrome/working-tips'; import { ApprovalPanelComponent, type ApprovalPanelResponse, @@ -158,6 +159,13 @@ export interface KimiTUIStartupInput { } type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; +type LoadingTipKind = 'moon' | 'composing'; + +function loadingTipKind(mode: EffectiveActivityPaneMode): LoadingTipKind | undefined { + if (mode === 'waiting' || mode === 'tool') return 'moon'; + if (mode === 'composing') return 'composing'; + return undefined; +} function sameStringArrays(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && a.every((value, index) => value === b[index]); @@ -238,6 +246,8 @@ export class KimiTUI { private readonly migrateOnly: boolean; private startupNotice: string | undefined; private lastActivityMode: string | undefined; + private currentLoadingTip: { kind: LoadingTipKind; tip: string | undefined } | undefined = + undefined; private lastHistoryContent: string | undefined; readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; @@ -1649,6 +1659,23 @@ export class KimiTUI { updateActivityPane(): void { const effectiveMode = this.resolveActivityPaneMode(); + const tipKind = loadingTipKind(effectiveMode); + // Pick a fresh loading tip when the loading kind changes. The same kind + // covers waiting/tool (both moon spinners) and any intermediate thinking + // phase, so a continuous burst of tool calls does not flip tips. Clear the + // cache only when there is no loading UI at all. + if (effectiveMode === 'idle' || effectiveMode === 'session' || effectiveMode === 'hidden') { + this.currentLoadingTip = undefined; + } else if ( + tipKind !== undefined && + (this.currentLoadingTip === undefined || this.currentLoadingTip.kind !== tipKind) + ) { + const previousTip = this.currentLoadingTip?.tip; + this.currentLoadingTip = { + kind: tipKind, + tip: pickRandomWorkingTip(previousTip)?.text, + }; + } this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); const placeSpinnerInAgentSwarm = this.shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode); const activityModeKey = `${effectiveMode}:${placeSpinnerInAgentSwarm ? 'swarm' : 'pane'}`; @@ -1680,6 +1707,7 @@ export class KimiTUI { new ActivityPaneComponent({ mode: 'waiting', spinner, + tip: this.currentLoadingTip?.tip, }), ); break; @@ -1698,6 +1726,7 @@ export class KimiTUI { new ActivityPaneComponent({ mode: 'composing', spinner, + tip: this.currentLoadingTip?.tip, }), ); break; @@ -1710,6 +1739,7 @@ export class KimiTUI { new ActivityPaneComponent({ mode: 'tool', spinner, + tip: this.currentLoadingTip?.tip, }), ); break; diff --git a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts index 80aaa6e79..f4a9286f8 100644 --- a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts @@ -27,6 +27,34 @@ describe('CompactionComponent', () => { } }); + it('renders a tip suffix while compacting', () => { + const component = new CompactionComponent(undefined, undefined, 'ctrl+s: steer mid-turn'); + + try { + const lines = component.render(120).map(strip); + const text = lines.join('\n'); + + expect(text).toContain('Compacting context... · Tip: ctrl+s: steer mid-turn'); + } finally { + component.dispose(); + } + }); + + it('does not render a tip after compaction completes', () => { + const component = new CompactionComponent(undefined, undefined, 'ctrl+s: steer mid-turn'); + + try { + component.markDone(1000, 500); + const lines = component.render(120).map(strip); + const text = lines.join('\n'); + + expect(text).toContain('Compaction complete'); + expect(text).not.toContain('Tip:'); + } finally { + component.dispose(); + } + }); + it('renders a cancelled terminal state', () => { const component = new CompactionComponent(); diff --git a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts index 383c43693..56601592d 100644 --- a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts @@ -1,8 +1,31 @@ -import { Text } from '@earendil-works/pi-tui'; +import { Text, visibleWidth } from '@earendil-works/pi-tui'; import { describe, expect, it } from 'vitest'; import { ActivityPaneComponent } from '#/tui/components/panes/activity-pane'; +function createMockSpinner(initialText = 'working') { + const spinner = new Text(initialText, 0, 0); + let tip = ''; + let availableWidth = 0; + const update = () => { + const fullText = initialText + tip; + spinner.setText(availableWidth > 0 && visibleWidth(fullText) > availableWidth ? initialText : fullText); + }; + return { + spinner: Object.assign(spinner, { + setTip(value: string) { + tip = value; + update(); + }, + setAvailableWidth(width: number) { + availableWidth = width; + update(); + }, + }) as unknown as import('#/tui/components/chrome/moon-loader').MoonLoader, + getTip: () => tip, + }; +} + describe('ActivityPaneComponent', () => { it('renders waiting loader after a spacer', () => { const component = new ActivityPaneComponent({ @@ -22,8 +45,53 @@ describe('ActivityPaneComponent', () => { expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); }); + it.each(['waiting', 'tool', 'composing'] as const)( + 'renders %s spinner with tip after a spacer', + (mode) => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode, + spinner, + tip: 'ctrl+s: steer mid-turn', + }); + + expect(component.render(80).map((line) => line.trimEnd())).toEqual([ + '', + 'working · Tip: ctrl+s: steer mid-turn', + ]); + }, + ); + + it.each(['waiting', 'tool', 'composing'] as const)( + 'does not render a tip for %s when none is provided', + (mode) => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode, + spinner, + }); + + expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); + }, + ); + it('renders nothing for hidden and thinking modes', () => { expect(new ActivityPaneComponent({ mode: 'hidden' }).render(80)).toEqual([]); expect(new ActivityPaneComponent({ mode: 'thinking' }).render(80)).toEqual([]); }); + + it.each(['waiting', 'tool', 'composing'] as const)( + 'hides the tip for %s when the terminal is too narrow', + (mode) => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode, + spinner, + tip: 'ctrl+s: steer mid-turn', + }); + + // Width 8 is exactly the width of "working" (no spinner frame in the mock). + expect(component.render(8).map((line) => line.trimEnd())).toEqual(['', 'working']); + }, + ); }); diff --git a/apps/kimi-code/test/tui/constant/tips.test.ts b/apps/kimi-code/test/tui/constant/tips.test.ts new file mode 100644 index 000000000..de0f69ef3 --- /dev/null +++ b/apps/kimi-code/test/tui/constant/tips.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { ALL_TIPS, WORKING_TIPS } from '#/tui/constant/tips'; + +describe('tips constants', () => { + it('ALL_TIPS is non-empty', () => { + expect(ALL_TIPS.length).toBeGreaterThan(0); + }); + + it('tip texts are unique across ALL_TIPS', () => { + const texts = ALL_TIPS.map((tip) => tip.text); + expect(new Set(texts).size).toBe(texts.length); + }); + + it('every tip has a non-empty text', () => { + for (const tip of ALL_TIPS) { + expect(tip.text.length).toBeGreaterThan(0); + } + }); + + it('every tip has valid optional properties', () => { + for (const tip of ALL_TIPS) { + if (tip.priority !== undefined) { + expect(tip.priority).toBeGreaterThan(0); + } + if (tip.solo !== undefined) { + expect(typeof tip.solo).toBe('boolean'); + } + } + }); + + it('WORKING_TIPS is non-empty', () => { + expect(WORKING_TIPS.length).toBeGreaterThan(0); + }); + + it('every working tip is included in ALL_TIPS', () => { + for (const workingTip of WORKING_TIPS) { + expect(ALL_TIPS.some((tip) => tip.text === workingTip.text)).toBe(true); + } + }); + + it('shared working tips match ALL_TIPS priority and solo values', () => { + for (const workingTip of WORKING_TIPS) { + const allTip = ALL_TIPS.find((tip) => tip.text === workingTip.text); + expect(allTip).toBeDefined(); + expect(allTip?.priority).toBe(workingTip.priority); + expect(allTip?.solo).toBe(workingTip.solo); + } + }); +}); diff --git a/apps/kimi-code/test/tui/working-tips.test.ts b/apps/kimi-code/test/tui/working-tips.test.ts new file mode 100644 index 000000000..e2cf96c54 --- /dev/null +++ b/apps/kimi-code/test/tui/working-tips.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + WORKING_TIPS, + currentWorkingTip, + pickRandomWorkingTip, +} from '#/tui/components/chrome/working-tips'; + +describe('currentWorkingTip', () => { + it('returns a tip from WORKING_TIPS', () => { + const now = Date.now(); + const tip = currentWorkingTip(now); + expect(tip).toBeDefined(); + expect(WORKING_TIPS.some((t) => t.text === tip!.text)).toBe(true); + }); + + it('returns the same tip for the same timestamp', () => { + const now = 1_000_000; + const first = currentWorkingTip(now); + const second = currentWorkingTip(now); + expect(first).toBe(second); + }); +}); + +describe('pickRandomWorkingTip', () => { + it('returns a tip from WORKING_TIPS', () => { + const tip = pickRandomWorkingTip(); + expect(tip).toBeDefined(); + expect(WORKING_TIPS.some((t) => t.text === tip!.text)).toBe(true); + }); + + it('avoids the excluded text when possible', () => { + const first = pickRandomWorkingTip()!; + let different = false; + for (let i = 0; i < 50; i++) { + const next = pickRandomWorkingTip(first.text); + if (next !== undefined && next.text !== first.text) { + different = true; + break; + } + } + if (WORKING_TIPS.length > 1) { + expect(different).toBe(true); + } + }); + + it('falls back to the rotation when every tip would be excluded', () => { + // If all working tips share the same text, exclusion cannot be satisfied. + const onlyTip = WORKING_TIPS[0]; + if (onlyTip !== undefined && WORKING_TIPS.every((t) => t.text === onlyTip.text)) { + expect(pickRandomWorkingTip(onlyTip.text)).toBeDefined(); + } + }); +});