Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/working-tips.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Optimize the loading tips display.
42 changes: 2 additions & 40 deletions apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down
26 changes: 24 additions & 2 deletions apps/kimi-code/src/tui/components/chrome/moon-loader.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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';

Expand All @@ -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,
Expand Down Expand Up @@ -60,14 +63,33 @@ 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;
}

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();
}
Expand Down
31 changes: 31 additions & 0 deletions apps/kimi-code/src/tui/components/chrome/working-tips.ts
Original file line number Diff line number Diff line change
@@ -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];
}
7 changes: 5 additions & 2 deletions apps/kimi-code/src/tui/components/dialogs/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,19 @@ 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;
private canceled = false;
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.).
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 18 additions & 9 deletions apps/kimi-code/src/tui/components/panes/activity-pane.ts
Original file line number Diff line number Diff line change
@@ -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);
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}`);
}
return;
this.addChild(options.spinner);
}
}

if (options.mode === 'composing' && options.spinner !== undefined) {
this.addChild(new Spacer(1));
this.addChild(options.spinner);
override render(width: number): string[] {
if (this.spinnerRef && 'setAvailableWidth' in this.spinnerRef) {
this.spinnerRef.setAvailableWidth(width);
}
return super.render(width);
}
}
49 changes: 49 additions & 0 deletions apps/kimi-code/src/tui/constant/tips.ts
Original file line number Diff line number Diff line change
@@ -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 },
];
3 changes: 2 additions & 1 deletion apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
30 changes: 30 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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'}`;
Expand Down Expand Up @@ -1680,6 +1707,7 @@ export class KimiTUI {
new ActivityPaneComponent({
mode: 'waiting',
spinner,
tip: this.currentLoadingTip?.tip,
}),
);
break;
Expand All @@ -1698,6 +1726,7 @@ export class KimiTUI {
new ActivityPaneComponent({
mode: 'composing',
spinner,
tip: this.currentLoadingTip?.tip,
}),
);
break;
Expand All @@ -1710,6 +1739,7 @@ export class KimiTUI {
new ActivityPaneComponent({
mode: 'tool',
spinner,
tip: this.currentLoadingTip?.tip,
}),
);
break;
Expand Down
Loading
Loading