From a69e2ead51be9a82da090306c3c869a47ad14395 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 18:00:14 +0800 Subject: [PATCH 01/13] refactor(tui): extract toolbar tip constants to tui/constant/tips.ts --- .../src/tui/components/chrome/footer.ts | 42 +------------------ apps/kimi-code/src/tui/constant/tips.ts | 35 ++++++++++++++++ apps/kimi-code/test/tui/constant/tips.test.ts | 31 ++++++++++++++ 3 files changed, 68 insertions(+), 40 deletions(-) create mode 100644 apps/kimi-code/src/tui/constant/tips.ts create mode 100644 apps/kimi-code/test/tui/constant/tips.test.ts diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index daf08b3ce0..e77f1a18cf 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/constant/tips.ts b/apps/kimi-code/src/tui/constant/tips.ts new file mode 100644 index 0000000000..20cda3f486 --- /dev/null +++ b/apps/kimi-code/src/tui/constant/tips.ts @@ -0,0 +1,35 @@ +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; +} + +export const ALL_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 }, +]; 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 0000000000..4d7a454a1c --- /dev/null +++ b/apps/kimi-code/test/tui/constant/tips.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { ALL_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'); + } + } + }); +}); From 9e963d0c72d967e0b9b0f0dc686b5b5a059d489f Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 18:07:03 +0800 Subject: [PATCH 02/13] feat(tui): add WORKING_TIPS subset to tip constants --- apps/kimi-code/src/tui/constant/tips.ts | 17 ++++++++++----- apps/kimi-code/test/tui/constant/tips.test.ts | 21 ++++++++++++++++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/kimi-code/src/tui/constant/tips.ts b/apps/kimi-code/src/tui/constant/tips.ts index 20cda3f486..ab6ce4ac77 100644 --- a/apps/kimi-code/src/tui/constant/tips.ts +++ b/apps/kimi-code/src/tui/constant/tips.ts @@ -12,18 +12,25 @@ export interface ToolbarTip { readonly priority?: number; } -export const ALL_TIPS: readonly ToolbarTip[] = [ - { text: 'shift+tab: plan mode' }, - { text: '/model: switch model' }, +/** + * Subset of toolbar tips shown behind the composing spinner. + */ +export const WORKING_TIPS: readonly ToolbarTip[] = [ { text: 'ctrl+s: steer mid-turn', priority: 2 }, { text: 'ctrl+b: background task', priority: 2 }, { text: '/compact: compact context', priority: 2 }, + { text: 'shift+tab: plan mode' }, + { text: '/init: generate AGENTS.md', priority: 2 }, + { text: '@: mention files' }, +]; + +export const ALL_TIPS: readonly ToolbarTip[] = [ + ...WORKING_TIPS, + { text: '/model: switch model' }, { 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' }, diff --git a/apps/kimi-code/test/tui/constant/tips.test.ts b/apps/kimi-code/test/tui/constant/tips.test.ts index 4d7a454a1c..de0f69ef3d 100644 --- a/apps/kimi-code/test/tui/constant/tips.test.ts +++ b/apps/kimi-code/test/tui/constant/tips.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { ALL_TIPS } from '#/tui/constant/tips'; +import { ALL_TIPS, WORKING_TIPS } from '#/tui/constant/tips'; describe('tips constants', () => { it('ALL_TIPS is non-empty', () => { @@ -28,4 +28,23 @@ describe('tips constants', () => { } } }); + + 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); + } + }); }); From c5c15f2a596dc6708c7f0481c72ab189e551b0e2 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 18:11:41 +0800 Subject: [PATCH 03/13] feat(tui): allow MoonLoader to render a dim tip suffix --- .../src/tui/components/chrome/moon-loader.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 0f9c971eaa..42cac6ad0e 100644 --- a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts +++ b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts @@ -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,7 @@ export class MoonLoader extends Text { private colorFn?: (s: string) => string; private label: string; private displayText = ''; + private tip: string = ''; constructor( ui: TUI, @@ -60,6 +62,11 @@ export class MoonLoader extends Text { this.updateDisplay(); } + setTip(tip: string): void { + this.tip = tip; + this.updateDisplay(); + } + renderInline(): string { return this.displayText; } @@ -67,7 +74,11 @@ 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; + let text = this.label ? `${coloredFrame} ${this.label}` : coloredFrame; + if (this.tip) { + text += currentTheme.fg('textDim', this.tip); + } + this.displayText = text; this.setText(this.displayText); this.ui.requestRender(); } From 55fedc31b450fed54f86d9dea0b2335ef5f1f150 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 18:13:52 +0800 Subject: [PATCH 04/13] feat(tui): pass optional tip through ActivityPaneComponent --- .../src/tui/components/panes/activity-pane.ts | 4 ++ .../components/panes/activity-pane.test.ts | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+) 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 2a1d8ea1d4..030ec88062 100644 --- a/apps/kimi-code/src/tui/components/panes/activity-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -7,6 +7,7 @@ export type ActivityPaneMode = 'hidden' | 'waiting' | 'thinking' | 'composing' | export interface ActivityPaneOptions { readonly mode: ActivityPaneMode; readonly spinner?: MoonLoader; + readonly tip?: string; } export class ActivityPaneComponent extends Container { @@ -23,6 +24,9 @@ export class ActivityPaneComponent extends Container { if (options.mode === 'composing' && options.spinner !== undefined) { this.addChild(new Spacer(1)); + if (options.tip) { + options.spinner.setTip(` · Tips: ${options.tip}`); + } this.addChild(options.spinner); } } 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 383c436934..1ff31462bc 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 @@ -3,6 +3,20 @@ 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 = ''; + return { + spinner: Object.assign(spinner, { + setTip(value: string) { + tip = value; + spinner.setText(initialText + tip); + }, + }) 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,6 +36,30 @@ describe('ActivityPaneComponent', () => { expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); }); + it('renders composing spinner with tip after a spacer', () => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode: 'composing', + spinner, + tip: 'ctrl+s: steer mid-turn', + }); + + expect(component.render(80).map((line) => line.trimEnd())).toEqual([ + '', + 'working · Tips: ctrl+s: steer mid-turn', + ]); + }); + + it('does not render a tip when none is provided', () => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode: 'composing', + 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([]); From 5329dbd237a19af4b99fb13ef6b026ce259df560 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 18:15:48 +0800 Subject: [PATCH 05/13] feat(tui): show working tip behind composing spinner --- .../src/tui/components/chrome/working-tips.ts | 15 +++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 3 +++ apps/kimi-code/test/tui/working-tips.test.ts | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 apps/kimi-code/src/tui/components/chrome/working-tips.ts create mode 100644 apps/kimi-code/test/tui/working-tips.test.ts 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 0000000000..b74a12a6b5 --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/working-tips.ts @@ -0,0 +1,15 @@ +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]; +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 67d8375c2c..45290ef1fc 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 { currentWorkingTip } from './components/chrome/working-tips'; import { ApprovalPanelComponent, type ApprovalPanelResponse, @@ -1693,11 +1694,13 @@ export class KimiTUI { const spinner = this.ensureActivitySpinner('braille', 'working...', (s) => currentTheme.fg('primary', s), ); + const tip = currentWorkingTip(); this.syncAgentSwarmActivitySpinner(undefined); this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'composing', spinner, + tip: tip?.text, }), ); break; 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 0000000000..58524fb864 --- /dev/null +++ b/apps/kimi-code/test/tui/working-tips.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { WORKING_TIPS, currentWorkingTip } 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); + }); +}); From da78b7b688f7ad279314a927cd130c3f03d604f4 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 18:18:51 +0800 Subject: [PATCH 06/13] feat(tui): hide working tip when spinner line does not fit --- .../src/tui/components/chrome/moon-loader.ts | 16 +++++++++--- .../src/tui/components/panes/activity-pane.ts | 12 ++++++++- apps/kimi-code/src/tui/kimi-tui.ts | 10 ++++++-- .../components/panes/activity-pane.test.ts | 25 +++++++++++++++++-- 4 files changed, 55 insertions(+), 8 deletions(-) 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 42cac6ad0e..93a9afeb36 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 { @@ -21,6 +21,7 @@ export class MoonLoader extends Text { private label: string; private displayText = ''; private tip: string = ''; + private availableWidth = 0; constructor( ui: TUI, @@ -67,6 +68,11 @@ export class MoonLoader extends Text { this.updateDisplay(); } + setAvailableWidth(width: number): void { + this.availableWidth = width; + this.updateDisplay(); + } + renderInline(): string { return this.displayText; } @@ -74,9 +80,13 @@ export class MoonLoader extends Text { private updateDisplay(): void { const frame = this.frames[this.currentFrame]!; const coloredFrame = this.colorFn ? this.colorFn(frame) : frame; - let text = this.label ? `${coloredFrame} ${this.label}` : coloredFrame; + const baseText = this.label ? `${coloredFrame} ${this.label}` : coloredFrame; + let text = baseText; if (this.tip) { - text += currentTheme.fg('textDim', 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); 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 030ec88062..d68674f52e 100644 --- a/apps/kimi-code/src/tui/components/panes/activity-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -1,6 +1,6 @@ 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'; @@ -11,8 +11,11 @@ export interface ActivityPaneOptions { } 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) { @@ -30,4 +33,11 @@ export class ActivityPaneComponent extends Container { 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/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 45290ef1fc..79f941ed00 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -239,6 +239,7 @@ export class KimiTUI { private readonly migrateOnly: boolean; private startupNotice: string | undefined; private lastActivityMode: string | undefined; + private currentComposingTip: string | undefined = undefined; private lastHistoryContent: string | undefined; readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; @@ -1650,6 +1651,9 @@ export class KimiTUI { updateActivityPane(): void { const effectiveMode = this.resolveActivityPaneMode(); + if (effectiveMode !== 'composing') { + this.currentComposingTip = undefined; + } this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); const placeSpinnerInAgentSwarm = this.shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode); const activityModeKey = `${effectiveMode}:${placeSpinnerInAgentSwarm ? 'swarm' : 'pane'}`; @@ -1694,13 +1698,15 @@ export class KimiTUI { const spinner = this.ensureActivitySpinner('braille', 'working...', (s) => currentTheme.fg('primary', s), ); - const tip = currentWorkingTip(); + if (this.currentComposingTip === undefined) { + this.currentComposingTip = currentWorkingTip()?.text; + } this.syncAgentSwarmActivitySpinner(undefined); this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'composing', spinner, - tip: tip?.text, + tip: this.currentComposingTip, }), ); break; 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 1ff31462bc..f53cf1438b 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,4 +1,4 @@ -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'; @@ -6,11 +6,20 @@ 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; - spinner.setText(initialText + tip); + update(); + }, + setAvailableWidth(width: number) { + availableWidth = width; + update(); }, }) as unknown as import('#/tui/components/chrome/moon-loader').MoonLoader, getTip: () => tip, @@ -64,4 +73,16 @@ describe('ActivityPaneComponent', () => { expect(new ActivityPaneComponent({ mode: 'hidden' }).render(80)).toEqual([]); expect(new ActivityPaneComponent({ mode: 'thinking' }).render(80)).toEqual([]); }); + + it('hides the tip when the terminal is too narrow', () => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode: 'composing', + 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']); + }); }); From 896b3af4e752daa4d69dbf553ef78ca8f25de27d Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 18:27:02 +0800 Subject: [PATCH 07/13] chore: add changeset for working tips --- .changeset/working-tips.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/working-tips.md diff --git a/.changeset/working-tips.md b/.changeset/working-tips.md new file mode 100644 index 0000000000..ae6eb596ec --- /dev/null +++ b/.changeset/working-tips.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show contextual tips behind the composing spinner to help users discover shortcuts while waiting. From 21792e036adde8b39bdd3884b7051d655fb2ecbe Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 18:27:11 +0800 Subject: [PATCH 08/13] feat(tui): guard setAvailableWidth to skip unchanged widths --- apps/kimi-code/src/tui/components/chrome/moon-loader.ts | 1 + apps/kimi-code/src/tui/kimi-tui.ts | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) 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 93a9afeb36..9b03bff70a 100644 --- a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts +++ b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts @@ -69,6 +69,7 @@ export class MoonLoader extends Text { } setAvailableWidth(width: number): void { + if (this.availableWidth === width) return; this.availableWidth = width; this.updateDisplay(); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 79f941ed00..c61ca4ec1b 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1698,9 +1698,7 @@ export class KimiTUI { const spinner = this.ensureActivitySpinner('braille', 'working...', (s) => currentTheme.fg('primary', s), ); - if (this.currentComposingTip === undefined) { - this.currentComposingTip = currentWorkingTip()?.text; - } + this.currentComposingTip ??= currentWorkingTip()?.text; this.syncAgentSwarmActivitySpinner(undefined); this.state.activityContainer.addChild( new ActivityPaneComponent({ From caef41a434b5caf8a35d20c4d2e1f72291bc5a25 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 19:12:28 +0800 Subject: [PATCH 09/13] feat(tui): show working tips on moon loader and compaction --- .../src/tui/components/dialogs/compaction.ts | 7 +- .../src/tui/components/panes/activity-pane.ts | 13 ++-- .../src/tui/controllers/streaming-ui.ts | 3 +- apps/kimi-code/src/tui/kimi-tui.ts | 14 ++-- .../tui/components/dialogs/compaction.test.ts | 28 ++++++++ .../components/panes/activity-pane.test.ts | 69 +++++++++++-------- 6 files changed, 87 insertions(+), 47 deletions(-) diff --git a/apps/kimi-code/src/tui/components/dialogs/compaction.ts b/apps/kimi-code/src/tui/components/dialogs/compaction.ts index f2ef1a75c7..0bd9596b35 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', ` · Tips: ${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 d68674f52e..9d821edd4a 100644 --- a/apps/kimi-code/src/tui/components/panes/activity-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -17,15 +17,10 @@ export class ActivityPaneComponent extends Container { 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(` · Tips: ${options.tip}`); diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 35aa951ca9..4581fc3268 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 c61ca4ec1b..fbd25d8a57 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -239,7 +239,7 @@ export class KimiTUI { private readonly migrateOnly: boolean; private startupNotice: string | undefined; private lastActivityMode: string | undefined; - private currentComposingTip: string | undefined = undefined; + private currentLoadingTip: string | undefined = undefined; private lastHistoryContent: string | undefined; readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; @@ -1651,8 +1651,8 @@ export class KimiTUI { updateActivityPane(): void { const effectiveMode = this.resolveActivityPaneMode(); - if (effectiveMode !== 'composing') { - this.currentComposingTip = undefined; + if (effectiveMode !== 'waiting' && effectiveMode !== 'tool' && effectiveMode !== 'composing') { + this.currentLoadingTip = undefined; } this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); const placeSpinnerInAgentSwarm = this.shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode); @@ -1679,12 +1679,14 @@ export class KimiTUI { return; case 'waiting': { const spinner = this.ensureActivitySpinner('moon'); + this.currentLoadingTip ??= currentWorkingTip()?.text; this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined); if (placeSpinnerInAgentSwarm) break; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'waiting', spinner, + tip: this.currentLoadingTip, }), ); break; @@ -1698,25 +1700,27 @@ export class KimiTUI { const spinner = this.ensureActivitySpinner('braille', 'working...', (s) => currentTheme.fg('primary', s), ); - this.currentComposingTip ??= currentWorkingTip()?.text; + this.currentLoadingTip ??= currentWorkingTip()?.text; this.syncAgentSwarmActivitySpinner(undefined); this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'composing', spinner, - tip: this.currentComposingTip, + tip: this.currentLoadingTip, }), ); break; } case 'tool': { const spinner = this.ensureActivitySpinner('moon'); + this.currentLoadingTip ??= currentWorkingTip()?.text; this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined); if (placeSpinnerInAgentSwarm) break; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'tool', spinner, + tip: this.currentLoadingTip, }), ); 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 80aaa6e79d..24803d2cc8 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... · Tips: 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('Tips:'); + } 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 f53cf1438b..09fed3769d 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 @@ -45,44 +45,53 @@ describe('ActivityPaneComponent', () => { expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); }); - it('renders composing spinner with tip after a spacer', () => { - const { spinner } = createMockSpinner('working'); - const component = new ActivityPaneComponent({ - mode: 'composing', - spinner, - tip: 'ctrl+s: steer mid-turn', - }); + 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 · Tips: ctrl+s: steer mid-turn', - ]); - }); + expect(component.render(80).map((line) => line.trimEnd())).toEqual([ + '', + 'working · Tips: ctrl+s: steer mid-turn', + ]); + }, + ); - it('does not render a tip when none is provided', () => { - const { spinner } = createMockSpinner('working'); - const component = new ActivityPaneComponent({ - mode: 'composing', - spinner, - }); + 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']); - }); + 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('hides the tip when the terminal is too narrow', () => { - const { spinner } = createMockSpinner('working'); - const component = new ActivityPaneComponent({ - mode: 'composing', - spinner, - tip: 'ctrl+s: steer mid-turn', - }); + 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']); - }); + // Width 8 is exactly the width of "working" (no spinner frame in the mock). + expect(component.render(8).map((line) => line.trimEnd())).toEqual(['', 'working']); + }, + ); }); From 4a55b145c4e217a5ec7c1cebfa9ab66eb9022188 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 19:18:04 +0800 Subject: [PATCH 10/13] feat(tui): use singular 'Tip:' label for loading tips --- apps/kimi-code/src/tui/components/dialogs/compaction.ts | 2 +- apps/kimi-code/src/tui/components/panes/activity-pane.ts | 2 +- apps/kimi-code/test/tui/components/dialogs/compaction.test.ts | 4 ++-- .../kimi-code/test/tui/components/panes/activity-pane.test.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/tui/components/dialogs/compaction.ts b/apps/kimi-code/src/tui/components/dialogs/compaction.ts index 0bd9596b35..90a924757a 100644 --- a/apps/kimi-code/src/tui/components/dialogs/compaction.ts +++ b/apps/kimi-code/src/tui/components/dialogs/compaction.ts @@ -109,7 +109,7 @@ export class CompactionComponent extends Container { } const bullet = this.blinkOn ? currentTheme.fg('text', STATUS_BULLET) : ' '; const label = currentTheme.boldFg('primary', 'Compacting context...'); - const tip = this.tip ? currentTheme.fg('textDim', ` · Tips: ${this.tip}`) : ''; + const tip = this.tip ? currentTheme.fg('textDim', ` · Tip: ${this.tip}`) : ''; return `${bullet}${label}${tip}`; } 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 9d821edd4a..443f3aa748 100644 --- a/apps/kimi-code/src/tui/components/panes/activity-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -23,7 +23,7 @@ export class ActivityPaneComponent extends Container { ) { this.addChild(new Spacer(1)); if (options.tip) { - options.spinner.setTip(` · Tips: ${options.tip}`); + options.spinner.setTip(` · Tip: ${options.tip}`); } this.addChild(options.spinner); } 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 24803d2cc8..f4a9286f8e 100644 --- a/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/compaction.test.ts @@ -34,7 +34,7 @@ describe('CompactionComponent', () => { const lines = component.render(120).map(strip); const text = lines.join('\n'); - expect(text).toContain('Compacting context... · Tips: ctrl+s: steer mid-turn'); + expect(text).toContain('Compacting context... · Tip: ctrl+s: steer mid-turn'); } finally { component.dispose(); } @@ -49,7 +49,7 @@ describe('CompactionComponent', () => { const text = lines.join('\n'); expect(text).toContain('Compaction complete'); - expect(text).not.toContain('Tips:'); + expect(text).not.toContain('Tip:'); } finally { component.dispose(); } 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 09fed3769d..56601592d8 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 @@ -57,7 +57,7 @@ describe('ActivityPaneComponent', () => { expect(component.render(80).map((line) => line.trimEnd())).toEqual([ '', - 'working · Tips: ctrl+s: steer mid-turn', + 'working · Tip: ctrl+s: steer mid-turn', ]); }, ); From 3eae24054958f4e1087c52a9b26996fa62044d2c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 19:22:15 +0800 Subject: [PATCH 11/13] fix(tui): keep the same loading tip across waiting/thinking/tool/composing within one turn --- apps/kimi-code/src/tui/kimi-tui.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index fbd25d8a57..23d9991c44 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1651,7 +1651,12 @@ export class KimiTUI { updateActivityPane(): void { const effectiveMode = this.resolveActivityPaneMode(); - if (effectiveMode !== 'waiting' && effectiveMode !== 'tool' && effectiveMode !== 'composing') { + // Keep the same loading tip for the entire turn; only clear it when the + // session is truly idle (thinking is an intermediate phase, not a reset). + if ( + this.state.appState.streamingPhase === 'idle' && + !this.state.appState.isCompacting + ) { this.currentLoadingTip = undefined; } this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); From 4a45136d5876dd70c702a185690c4574c41b5798 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 20:01:03 +0800 Subject: [PATCH 12/13] 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 --- .../src/tui/components/chrome/working-tips.ts | 16 +++++++ apps/kimi-code/src/tui/constant/tips.ts | 37 +++++++++------- apps/kimi-code/src/tui/kimi-tui.ts | 42 ++++++++++++------- apps/kimi-code/test/tui/working-tips.test.ts | 37 +++++++++++++++- 4 files changed, 102 insertions(+), 30 deletions(-) diff --git a/apps/kimi-code/src/tui/components/chrome/working-tips.ts b/apps/kimi-code/src/tui/components/chrome/working-tips.ts index b74a12a6b5..23d0027389 100644 --- a/apps/kimi-code/src/tui/components/chrome/working-tips.ts +++ b/apps/kimi-code/src/tui/components/chrome/working-tips.ts @@ -13,3 +13,19 @@ export function currentWorkingTip(now = Date.now()): ToolbarTip | 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/constant/tips.ts b/apps/kimi-code/src/tui/constant/tips.ts index ab6ce4ac77..c04b5a763a 100644 --- a/apps/kimi-code/src/tui/constant/tips.ts +++ b/apps/kimi-code/src/tui/constant/tips.ts @@ -16,27 +16,34 @@ export interface ToolbarTip { * Subset of toolbar tips shown behind the composing spinner. */ export const WORKING_TIPS: readonly ToolbarTip[] = [ - { text: 'ctrl+s: steer mid-turn', priority: 2 }, - { text: 'ctrl+b: background task', priority: 2 }, - { text: '/compact: compact context', priority: 2 }, - { text: 'shift+tab: plan mode' }, + { 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: '@: mention files' }, + { 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: '/model: switch model' }, - { text: 'ctrl+o: expand tool output' }, - { text: 'ctrl+t: expand todo list' }, - { text: '/tasks: background tasks' }, { text: 'shift+enter: newline' }, { text: 'ctrl+c: cancel' }, - { text: '/theme: switch theme' }, - { text: '/auto: auto permission mode' }, - { text: '/yolo: toggle yolo' }, + { 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: '/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 }, + { 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/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 23d9991c44..b18125711d 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -48,7 +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 { currentWorkingTip } from './components/chrome/working-tips'; +import { pickRandomWorkingTip } from './components/chrome/working-tips'; import { ApprovalPanelComponent, type ApprovalPanelResponse, @@ -159,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]); @@ -239,7 +246,8 @@ export class KimiTUI { private readonly migrateOnly: boolean; private startupNotice: string | undefined; private lastActivityMode: string | undefined; - private currentLoadingTip: string | undefined = undefined; + private currentLoadingTip: { kind: LoadingTipKind; tip: string | undefined } | undefined = + undefined; private lastHistoryContent: string | undefined; readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; @@ -1651,13 +1659,22 @@ export class KimiTUI { updateActivityPane(): void { const effectiveMode = this.resolveActivityPaneMode(); - // Keep the same loading tip for the entire turn; only clear it when the - // session is truly idle (thinking is an intermediate phase, not a reset). - if ( - this.state.appState.streamingPhase === 'idle' && - !this.state.appState.isCompacting - ) { + 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); @@ -1684,14 +1701,13 @@ export class KimiTUI { return; case 'waiting': { const spinner = this.ensureActivitySpinner('moon'); - this.currentLoadingTip ??= currentWorkingTip()?.text; this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined); if (placeSpinnerInAgentSwarm) break; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'waiting', spinner, - tip: this.currentLoadingTip, + tip: this.currentLoadingTip?.tip, }), ); break; @@ -1705,27 +1721,25 @@ export class KimiTUI { const spinner = this.ensureActivitySpinner('braille', 'working...', (s) => currentTheme.fg('primary', s), ); - this.currentLoadingTip ??= currentWorkingTip()?.text; this.syncAgentSwarmActivitySpinner(undefined); this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'composing', spinner, - tip: this.currentLoadingTip, + tip: this.currentLoadingTip?.tip, }), ); break; } case 'tool': { const spinner = this.ensureActivitySpinner('moon'); - this.currentLoadingTip ??= currentWorkingTip()?.text; this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined); if (placeSpinnerInAgentSwarm) break; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'tool', spinner, - tip: this.currentLoadingTip, + tip: this.currentLoadingTip?.tip, }), ); break; diff --git a/apps/kimi-code/test/tui/working-tips.test.ts b/apps/kimi-code/test/tui/working-tips.test.ts index 58524fb864..e2cf96c540 100644 --- a/apps/kimi-code/test/tui/working-tips.test.ts +++ b/apps/kimi-code/test/tui/working-tips.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { WORKING_TIPS, currentWorkingTip } from '#/tui/components/chrome/working-tips'; +import { + WORKING_TIPS, + currentWorkingTip, + pickRandomWorkingTip, +} from '#/tui/components/chrome/working-tips'; describe('currentWorkingTip', () => { it('returns a tip from WORKING_TIPS', () => { @@ -17,3 +21,34 @@ describe('currentWorkingTip', () => { 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(); + } + }); +}); From 8ad55d4192593ec2cf5d001a32ae2ee298845c14 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 23 Jun 2026 20:04:27 +0800 Subject: [PATCH 13/13] docs: update working-tips changeset summary --- .changeset/working-tips.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/working-tips.md b/.changeset/working-tips.md index ae6eb596ec..7e157aa886 100644 --- a/.changeset/working-tips.md +++ b/.changeset/working-tips.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Show contextual tips behind the composing spinner to help users discover shortcuts while waiting. +Optimize the loading tips display.