Skip to content
Open
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/animated-terminal-title-spinner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Add an animated terminal-title indicator: while the agent is busy, the terminal title is prefixed with a spinner so progress stays visible on the terminal tab. The style is selectable via `KIMI_TITLE_SPINNER` (`moon` by default, plus `sparkle`, `braille`, `orbit`, `pulse`, `line`, `hourglass`, a state-mapped `phase`, and `off` to disable).
57 changes: 57 additions & 0 deletions apps/kimi-code/src/tui/constant/title-spinners.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Terminal-title spinner styles (OSC 0).
*
* While the agent is busy, the terminal title is prefixed with an animated
* glyph so progress stays visible on the terminal tab even when the window is
* unfocused. The active style is resolved once from `KIMI_TITLE_SPINNER`
* (default: `moon`).
*
* Each cycling style is a self-contained frame loop in `TITLE_SPINNER_STYLES`;
* adding or removing a style only touches that registry. Two special ids are
* not registry entries: `phase` maps the streaming phase to a static glyph
* (no animation timer), and `off` restores the plain static title.
*/

export interface TitleSpinnerStyle {
readonly frames: readonly string[];
readonly interval: number;
}

export const TITLE_SPINNER_ENV = 'KIMI_TITLE_SPINNER';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the new spinner environment variable

The repository's English and Chinese environment-variable references present themselves as the complete list of runtime variables, but KIMI_TITLE_SPINNER and its supported values appear only in the implementation and this release changeset. Once the release entry is no longer prominent, users cannot discover how to select a style or disable the new default animation; add the variable and accepted values to both mirrored configuration/env-vars.md pages.

Useful? React with 👍 / 👎.


export const DEFAULT_TITLE_SPINNER_ID = 'moon';
export const OFF_TITLE_SPINNER_ID = 'off';
export const PHASE_TITLE_SPINNER_ID = 'phase';

/** Cycling spinner styles. Entries are isolated; ordering is irrelevant. */
export const TITLE_SPINNER_STYLES: Readonly<Record<string, TitleSpinnerStyle>> = {
moon: { frames: ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'], interval: 120 },
sparkle: { frames: ['✦', '✧', '✩', '✧'], interval: 180 },
braille: { frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], interval: 80 },
orbit: { frames: ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'], interval: 100 },
pulse: {
frames: ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃', '▂'],
interval: 90,
},
line: { frames: ['|', '/', '-', '\\'], interval: 110 },
hourglass: { frames: ['⧗', '⧖'], interval: 400 },
};

/** Glyph shown per streaming phase by the `phase` style (no animation timer). */
export const PHASE_TITLE_GLYPHS: Readonly<Record<string, string>> = {
waiting: '◌',
thinking: '✦',
composing: '≋',
shell: '▸',
};

/** Resolve a raw env value to a valid spinner id, falling back to the default. */
export function resolveTitleSpinnerId(raw: string | undefined): string {
const id = raw?.trim().toLowerCase();
if (id === OFF_TITLE_SPINNER_ID) return OFF_TITLE_SPINNER_ID;
if (id === PHASE_TITLE_SPINNER_ID) return PHASE_TITLE_SPINNER_ID;
// Own-property check only: an inherited key such as `constructor` or
// `toString` must not be accepted as a style (its value has no `frames`).
if (id !== undefined && Object.hasOwn(TITLE_SPINNER_STYLES, id)) return id;
return DEFAULT_TITLE_SPINNER_ID;
}
108 changes: 108 additions & 0 deletions apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { MAX_TERMINAL_TITLE_LENGTH } from '#/tui/constant/terminal';
import {
OFF_TITLE_SPINNER_ID,
PHASE_TITLE_GLYPHS,
PHASE_TITLE_SPINNER_ID,
resolveTitleSpinnerId,
TITLE_SPINNER_ENV,
TITLE_SPINNER_STYLES,
type TitleSpinnerStyle,
} from '#/tui/constant/title-spinners';

const TITLE_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });

export interface TerminalTitleSpinnerHost {
/** Write the terminal title (OSC 0). */
setTitle(label: string): void;
/** Static title shown when idle (session title or product name). */
staticTitle(): string;
/** Whether the agent is currently busy (streaming a turn or compacting). */
isBusy(): boolean;
/** Current streaming phase. */
streamingPhase(): string;
}

/**
* Owns the terminal-title busy indicator: resolves the spinner style once,
* animates the title while the agent is busy, and restores the static title
* when idle or on shutdown. `KimiTUI` only calls `sync()` when the busy state
* changes and `dispose()` on teardown; all style, timer, and frame state lives
* here so the behavior stays independently testable.
*/
export class TerminalTitleSpinnerController {
private readonly host: TerminalTitleSpinnerHost;
private readonly spinnerId: string;
private timer: ReturnType<typeof setInterval> | null = null;
private frame = 0;

constructor(
host: TerminalTitleSpinnerHost,
spinnerEnv: string | undefined = process.env[TITLE_SPINNER_ENV],
) {
this.host = host;
this.spinnerId = resolveTitleSpinnerId(spinnerEnv);
}

/** Reflect the current busy state on the terminal title. */
sync(): void {
if (!this.host.isBusy() || this.spinnerId === OFF_TITLE_SPINNER_ID) {
this.stop();
this.host.setTitle(this.host.staticTitle());
return;
}
if (this.spinnerId === PHASE_TITLE_SPINNER_ID) {
this.host.setTitle(this.phaseTitle());
return;
}
const style = TITLE_SPINNER_STYLES[this.spinnerId];
if (style === undefined) {
this.host.setTitle(this.host.staticTitle());
return;
}
this.host.setTitle(this.frameTitle(style));
this.timer ??= setInterval(() => {
this.frame = (this.frame + 1) % style.frames.length;
this.host.setTitle(this.frameTitle(style));
}, style.interval);
Comment on lines +63 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Tear down spinner intervals in KimiTUI tests

When a KimiTUI test leaves the app busy, this referenced interval outlives the test because the shared makeDriver() fixture in kimi-tui-message-flow.test.ts creates drivers while its afterEach never calls stop() or titleSpinner.dispose(); for example, the reload test starts a request and then directly mutates streamingPhase, bypassing spinner synchronization. As the suite progresses, these timers retain each driver and continuously write OSC titles, accumulating work and polluting test output, so register fixture teardown or disable the spinner in the suite environment.

Useful? React with 👍 / 👎.

}

/** Stop animating and restore the static title (called on shutdown). */
dispose(): void {
this.stop();
this.host.setTitle(this.host.staticTitle());
}

private stop(): void {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
}

private frameTitle(style: TitleSpinnerStyle): string {
const frame = style.frames[this.frame % style.frames.length] ?? '';
return this.fitTitle(`${frame} ${this.host.staticTitle()}`);
}

private phaseTitle(): string {
const phase = this.host.streamingPhase();
const base = this.host.staticTitle();
// Own-property check only: an inherited key such as `constructor` must
// not be stringified into the title.
const glyph = Object.hasOwn(PHASE_TITLE_GLYPHS, phase) ? PHASE_TITLE_GLYPHS[phase] : undefined;
return glyph === undefined ? base : this.fitTitle(`${glyph} ${base}`);
}

/** Keep the composed title within the terminal-title cap. */
private fitTitle(title: string): string {
if (title.length <= MAX_TERMINAL_TITLE_LENGTH) return title;
// Truncate on grapheme boundaries so multi-code-point characters (emoji,
// flags, ZWJ sequences) are dropped whole rather than split mid-cluster.
let fitted = '';
for (const { segment } of TITLE_SEGMENTER.segment(title)) {
if (fitted.length + segment.length > MAX_TERMINAL_TITLE_LENGTH) break;
fitted += segment;
}
return fitted;
}
}
18 changes: 15 additions & 3 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import { SessionEventHandler } from './controllers/session-event-handler';
import { SessionReplayRenderer } from './controllers/session-replay';
import { StreamingUIController } from './controllers/streaming-ui';
import { TasksBrowserController } from './controllers/tasks-browser';
import { TerminalTitleSpinnerController } from './controllers/terminal-title-spinner';
import { installRainbowDance } from './easter-eggs/dance';
import { adaptPanelResponse } from './reverse-rpc/approval/adapter';
import { ApprovalController } from './reverse-rpc/approval/controller';
Expand Down Expand Up @@ -339,6 +340,7 @@ export class KimiTUI {
readonly sessionReplay: SessionReplayRenderer;
readonly tasksBrowserController: TasksBrowserController;
readonly editorKeyboard: EditorKeyboardController;
readonly titleSpinner: TerminalTitleSpinnerController;

/** Timer that auto-clears the one-shot "moved to background" footer hint. */
private detachHintClearTimer: ReturnType<typeof setTimeout> | undefined;
Expand Down Expand Up @@ -420,6 +422,16 @@ export class KimiTUI {
this.tasksBrowserController = new TasksBrowserController(this);
this.editorKeyboard = new EditorKeyboardController(this, this.imageStore);
this.editorKeyboard.install();
this.titleSpinner = new TerminalTitleSpinnerController({
setTitle: (label) => this.state.terminal.setTitle(label),
staticTitle: () => {
const trimmed = this.state.appState.sessionTitle?.trim() ?? '';
return trimmed.length > 0 ? trimmed.slice(0, MAX_TERMINAL_TITLE_LENGTH) : PRODUCT_NAME;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep spinner titles within the terminal-title cap

When a session title already reaches MAX_TERMINAL_TITLE_LENGTH (32), this callback returns all 32 characters and frameTitle() or phaseTitle() then prepends a glyph and space, producing a title longer than the repository's stated bound. Reserve space for the prefix or truncate the final composed title so long session names do not defeat the readability cap while busy.

Useful? React with 👍 / 👎.

},
isBusy: () =>
this.state.appState.streamingPhase !== 'idle' || this.state.appState.isCompacting,
streamingPhase: () => this.state.appState.streamingPhase,
});
this.buildLayout();
}

Expand Down Expand Up @@ -838,6 +850,7 @@ export class KimiTUI {
this.tasksBrowserController.close();
this.btwPanelController.clear();
this.stopActivitySpinner();
this.titleSpinner.dispose();
this.streamingUI.disposeActiveCompactionBlock();
this.streamingUI.resetToolUi();
this.disposeTranscriptChildren();
Expand Down Expand Up @@ -1504,6 +1517,7 @@ export class KimiTUI {
if (busyChanged) {
this.updateQueueDisplay();
this.sessionEventHandler.retryQueuedGoalPromotion();
this.titleSpinner.sync();
}
if (additionalDirsChanged) this.setupAutocomplete();
this.state.ui.requestRender();
Expand Down Expand Up @@ -1683,9 +1697,7 @@ export class KimiTUI {
}

updateTerminalTitle(): void {
const trimmed = this.state.appState.sessionTitle?.trim() ?? '';
const label = trimmed.length > 0 ? trimmed.slice(0, MAX_TERMINAL_TITLE_LENGTH) : PRODUCT_NAME;
this.state.terminal.setTitle(label);
this.titleSpinner.sync();
}

resetSessionRuntime(): void {
Expand Down
66 changes: 66 additions & 0 deletions apps/kimi-code/test/tui/constant/title-spinners.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';

import {
DEFAULT_TITLE_SPINNER_ID,
OFF_TITLE_SPINNER_ID,
PHASE_TITLE_GLYPHS,
PHASE_TITLE_SPINNER_ID,
resolveTitleSpinnerId,
TITLE_SPINNER_STYLES,
} from '#/tui/constant/title-spinners';

describe('resolveTitleSpinnerId', () => {
it('returns a registered style id unchanged', () => {
for (const id of Object.keys(TITLE_SPINNER_STYLES)) {
expect(resolveTitleSpinnerId(id)).toBe(id);
}
});

it('normalizes case and surrounding whitespace', () => {
expect(resolveTitleSpinnerId(' MOON ')).toBe('moon');
expect(resolveTitleSpinnerId('Braille')).toBe('braille');
});

it('accepts the special off and phase ids', () => {
expect(resolveTitleSpinnerId('off')).toBe(OFF_TITLE_SPINNER_ID);
expect(resolveTitleSpinnerId('PHASE')).toBe(PHASE_TITLE_SPINNER_ID);
});

it('falls back to the default for unknown or empty values', () => {
expect(resolveTitleSpinnerId(undefined)).toBe(DEFAULT_TITLE_SPINNER_ID);
expect(resolveTitleSpinnerId('')).toBe(DEFAULT_TITLE_SPINNER_ID);
expect(resolveTitleSpinnerId(' ')).toBe(DEFAULT_TITLE_SPINNER_ID);
expect(resolveTitleSpinnerId('nope')).toBe(DEFAULT_TITLE_SPINNER_ID);
});

it('default id is a registered style', () => {
expect(TITLE_SPINNER_STYLES[DEFAULT_TITLE_SPINNER_ID]).toBeDefined();
});

it('rejects inherited object keys instead of treating them as styles', () => {
expect(resolveTitleSpinnerId('constructor')).toBe(DEFAULT_TITLE_SPINNER_ID);
expect(resolveTitleSpinnerId('toString')).toBe(DEFAULT_TITLE_SPINNER_ID);
expect(resolveTitleSpinnerId('hasOwnProperty')).toBe(DEFAULT_TITLE_SPINNER_ID);
expect(resolveTitleSpinnerId('__proto__')).toBe(DEFAULT_TITLE_SPINNER_ID);
});
});

describe('TITLE_SPINNER_STYLES', () => {
it('every style has at least two non-empty frames and a positive interval', () => {
for (const [id, style] of Object.entries(TITLE_SPINNER_STYLES)) {
expect(style.frames.length, id).toBeGreaterThanOrEqual(2);
for (const frame of style.frames) {
expect(frame.length, id).toBeGreaterThan(0);
}
expect(style.interval, id).toBeGreaterThan(0);
}
});
});

describe('PHASE_TITLE_GLYPHS', () => {
it('every busy phase maps to a non-empty glyph', () => {
for (const phase of ['waiting', 'thinking', 'composing', 'shell']) {
expect(PHASE_TITLE_GLYPHS[phase]?.length, phase).toBeGreaterThan(0);
}
});
});
Loading