mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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
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:
parent
603a7679de
commit
b1e6b64319
13 changed files with 368 additions and 56 deletions
5
.changeset/working-tips.md
Normal file
5
.changeset/working-tips.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Optimize the loading tips display.
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
31
apps/kimi-code/src/tui/components/chrome/working-tips.ts
Normal file
31
apps/kimi-code/src/tui/components/chrome/working-tips.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
apps/kimi-code/src/tui/constant/tips.ts
Normal file
49
apps/kimi-code/src/tui/constant/tips.ts
Normal 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 },
|
||||
];
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
50
apps/kimi-code/test/tui/constant/tips.test.ts
Normal file
50
apps/kimi-code/test/tui/constant/tips.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
54
apps/kimi-code/test/tui/working-tips.test.ts
Normal file
54
apps/kimi-code/test/tui/working-tips.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue