feat(tui): show working tips behind composing spinner (#1033)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

* 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
This commit is contained in:
liruifengv 2026-06-23 20:12:55 +08:00 committed by GitHub
parent 603a7679de
commit b1e6b64319
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 368 additions and 56 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Optimize the loading tips display.

View file

@ -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);

View file

@ -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();
}

View file

@ -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];
}

View file

@ -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<typeof setInterval> | 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 {

View file

@ -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);
}
}

View file

@ -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 },
];

View file

@ -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();

View file

@ -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;

View file

@ -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();

View file

@ -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']);
},
);
});

View file

@ -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);
}
});
});

View file

@ -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();
}
});
});