From f51f4959367842e0242d909e9c669d06c1c62c6e Mon Sep 17 00:00:00 2001 From: AidenNovak <178626503+AidenNovak@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:00:13 +0800 Subject: [PATCH 1/6] feat(tui): animate terminal title while agent is busy Prefix the terminal title with a spinner while a turn is streaming or compacting, so progress stays visible on the terminal tab even when the window is unfocused. The style is chosen once via KIMI_TITLE_SPINNER (moon by default; also sparkle, braille, orbit, pulse, line, hourglass, a state-mapped phase, and off to disable). --- .changeset/animated-terminal-title-spinner.md | 5 ++ .../src/tui/constant/title-spinners.ts | 55 +++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 70 ++++++++++++++++++- .../test/tui/constant/title-spinners.test.ts | 59 ++++++++++++++++ 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 .changeset/animated-terminal-title-spinner.md create mode 100644 apps/kimi-code/src/tui/constant/title-spinners.ts create mode 100644 apps/kimi-code/test/tui/constant/title-spinners.test.ts diff --git a/.changeset/animated-terminal-title-spinner.md b/.changeset/animated-terminal-title-spinner.md new file mode 100644 index 0000000000..42500a5890 --- /dev/null +++ b/.changeset/animated-terminal-title-spinner.md @@ -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). diff --git a/apps/kimi-code/src/tui/constant/title-spinners.ts b/apps/kimi-code/src/tui/constant/title-spinners.ts new file mode 100644 index 0000000000..7ee231089b --- /dev/null +++ b/apps/kimi-code/src/tui/constant/title-spinners.ts @@ -0,0 +1,55 @@ +/** + * 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'; + +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> = { + 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> = { + 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; + if (id !== undefined && id in TITLE_SPINNER_STYLES) return id; + return DEFAULT_TITLE_SPINNER_ID; +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f0764c82b9..66881ab0c1 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -99,6 +99,15 @@ import { PRODUCT_NAME, } from './constant/kimi-tui'; import { CHROME_GUTTER } from './constant/rendering'; +import { + OFF_TITLE_SPINNER_ID, + PHASE_TITLE_GLYPHS, + PHASE_TITLE_SPINNER_ID, + resolveTitleSpinnerId, + TITLE_SPINNER_ENV, + TITLE_SPINNER_STYLES, + type TitleSpinnerStyle, +} from './constant/title-spinners'; import { MAX_TERMINAL_TITLE_LENGTH } from './constant/terminal'; import { AuthFlowController } from './controllers/auth-flow'; import { BtwPanelController } from './controllers/btw-panel'; @@ -313,6 +322,9 @@ export class KimiTUI { aborted = false; private terminalFocusTrackingDispose: (() => void) | undefined; private terminalThemeTrackingDispose: (() => void) | undefined; + private titleSpinnerTimer: ReturnType | null = null; + private titleSpinnerFrame = 0; + private readonly titleSpinnerId = resolveTitleSpinnerId(process.env[TITLE_SPINNER_ENV]); private clipboardImageHintController: ClipboardImageHintController | undefined; private uninstallRainbowDance: () => void; private signalCleanupHandlers: Array<() => void> = []; @@ -838,6 +850,7 @@ export class KimiTUI { this.tasksBrowserController.close(); this.btwPanelController.clear(); this.stopActivitySpinner(); + this.stopTerminalTitleSpinner(); this.streamingUI.disposeActiveCompactionBlock(); this.streamingUI.resetToolUi(); this.disposeTranscriptChildren(); @@ -1504,6 +1517,7 @@ export class KimiTUI { if (busyChanged) { this.updateQueueDisplay(); this.sessionEventHandler.retryQueuedGoalPromotion(); + this.syncTerminalTitleSpinner(); } if (additionalDirsChanged) this.setupAutocomplete(); this.state.ui.requestRender(); @@ -1683,9 +1697,61 @@ export class KimiTUI { } updateTerminalTitle(): void { + this.syncTerminalTitleSpinner(); + } + + /** + * Reflects the current busy state on the terminal title. While the agent is + * working (streaming a turn or compacting) the title is prefixed with an + * animated glyph so progress stays visible on the terminal tab even when the + * window is not focused; when idle, the static session title (or product + * name) is restored. The style is resolved once from `KIMI_TITLE_SPINNER`: + * a cycling registry entry runs a single timer, `phase` derives a static + * glyph from the streaming phase (no timer), and `off` keeps the plain title. + */ + private syncTerminalTitleSpinner(): void { + const busy = this.state.appState.streamingPhase !== 'idle' || this.state.appState.isCompacting; + if (!busy || this.titleSpinnerId === OFF_TITLE_SPINNER_ID) { + this.stopTerminalTitleSpinner(); + this.state.terminal.setTitle(this.staticTerminalTitle()); + return; + } + if (this.titleSpinnerId === PHASE_TITLE_SPINNER_ID) { + this.state.terminal.setTitle(this.phaseTerminalTitle()); + return; + } + const style = TITLE_SPINNER_STYLES[this.titleSpinnerId]; + if (style === undefined) { + this.state.terminal.setTitle(this.staticTerminalTitle()); + return; + } + this.state.terminal.setTitle(this.frameTerminalTitle(style)); + this.titleSpinnerTimer ??= setInterval(() => { + this.titleSpinnerFrame = (this.titleSpinnerFrame + 1) % style.frames.length; + this.state.terminal.setTitle(this.frameTerminalTitle(style)); + }, style.interval); + } + + private stopTerminalTitleSpinner(): void { + if (this.titleSpinnerTimer !== null) { + clearInterval(this.titleSpinnerTimer); + this.titleSpinnerTimer = null; + } + } + + private staticTerminalTitle(): string { 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); + return trimmed.length > 0 ? trimmed.slice(0, MAX_TERMINAL_TITLE_LENGTH) : PRODUCT_NAME; + } + + private frameTerminalTitle(style: TitleSpinnerStyle): string { + const frame = style.frames[this.titleSpinnerFrame % style.frames.length] ?? ''; + return `${frame} ${this.staticTerminalTitle()}`; + } + + private phaseTerminalTitle(): string { + const glyph = PHASE_TITLE_GLYPHS[this.state.appState.streamingPhase]; + return glyph === undefined ? this.staticTerminalTitle() : `${glyph} ${this.staticTerminalTitle()}`; } resetSessionRuntime(): void { diff --git a/apps/kimi-code/test/tui/constant/title-spinners.test.ts b/apps/kimi-code/test/tui/constant/title-spinners.test.ts new file mode 100644 index 0000000000..6c62c676c0 --- /dev/null +++ b/apps/kimi-code/test/tui/constant/title-spinners.test.ts @@ -0,0 +1,59 @@ +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(); + }); +}); + +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); + } + }); +}); From a33e03cb7ea851cc60a115948debb3d93bbfd60d Mon Sep 17 00:00:00 2001 From: AidenNovak <178626503+AidenNovak@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:46:23 +0800 Subject: [PATCH 2/6] refactor(tui): extract terminal title spinner into a controller Address review feedback on #2238: - Move the title animation state machine (style resolution, timer, frame selection, rendering) out of KimiTUI into TerminalTitleSpinnerController; the coordinator now only syncs and disposes it. - Restore the static title on dispose so a busy indicator is not left behind when exiting mid-turn (Ctrl-D / SIGTERM). - Guard the spinner registry with an own-property check so inherited keys (constructor, toString, ...) fall back to the default instead of crashing. - Document KIMI_TITLE_SPINNER in the EN/ZH env-vars references. --- .../src/tui/constant/title-spinners.ts | 4 +- .../tui/controllers/terminal-title-spinner.ts | 89 ++++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 84 +++---------- .../test/tui/constant/title-spinners.test.ts | 7 ++ .../terminal-title-spinner.test.ts | 112 ++++++++++++++++++ docs/en/configuration/env-vars.md | 1 + docs/zh/configuration/env-vars.md | 1 + 7 files changed, 228 insertions(+), 70 deletions(-) create mode 100644 apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts create mode 100644 apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts diff --git a/apps/kimi-code/src/tui/constant/title-spinners.ts b/apps/kimi-code/src/tui/constant/title-spinners.ts index 7ee231089b..935c13b3c6 100644 --- a/apps/kimi-code/src/tui/constant/title-spinners.ts +++ b/apps/kimi-code/src/tui/constant/title-spinners.ts @@ -50,6 +50,8 @@ 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; - if (id !== undefined && id in TITLE_SPINNER_STYLES) return 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; } diff --git a/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts b/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts new file mode 100644 index 0000000000..ef58d73590 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts @@ -0,0 +1,89 @@ +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'; + +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 | 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); + } + + /** 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 `${frame} ${this.host.staticTitle()}`; + } + + private phaseTitle(): string { + const glyph = PHASE_TITLE_GLYPHS[this.host.streamingPhase()]; + const base = this.host.staticTitle(); + return glyph === undefined ? base : `${glyph} ${base}`; + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 66881ab0c1..cfd4a40e47 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -99,15 +99,6 @@ import { PRODUCT_NAME, } from './constant/kimi-tui'; import { CHROME_GUTTER } from './constant/rendering'; -import { - OFF_TITLE_SPINNER_ID, - PHASE_TITLE_GLYPHS, - PHASE_TITLE_SPINNER_ID, - resolveTitleSpinnerId, - TITLE_SPINNER_ENV, - TITLE_SPINNER_STYLES, - type TitleSpinnerStyle, -} from './constant/title-spinners'; import { MAX_TERMINAL_TITLE_LENGTH } from './constant/terminal'; import { AuthFlowController } from './controllers/auth-flow'; import { BtwPanelController } from './controllers/btw-panel'; @@ -117,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'; @@ -322,9 +314,6 @@ export class KimiTUI { aborted = false; private terminalFocusTrackingDispose: (() => void) | undefined; private terminalThemeTrackingDispose: (() => void) | undefined; - private titleSpinnerTimer: ReturnType | null = null; - private titleSpinnerFrame = 0; - private readonly titleSpinnerId = resolveTitleSpinnerId(process.env[TITLE_SPINNER_ENV]); private clipboardImageHintController: ClipboardImageHintController | undefined; private uninstallRainbowDance: () => void; private signalCleanupHandlers: Array<() => void> = []; @@ -351,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 | undefined; @@ -432,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; + }, + isBusy: () => + this.state.appState.streamingPhase !== 'idle' || this.state.appState.isCompacting, + streamingPhase: () => this.state.appState.streamingPhase, + }); this.buildLayout(); } @@ -850,7 +850,7 @@ export class KimiTUI { this.tasksBrowserController.close(); this.btwPanelController.clear(); this.stopActivitySpinner(); - this.stopTerminalTitleSpinner(); + this.titleSpinner.dispose(); this.streamingUI.disposeActiveCompactionBlock(); this.streamingUI.resetToolUi(); this.disposeTranscriptChildren(); @@ -1517,7 +1517,7 @@ export class KimiTUI { if (busyChanged) { this.updateQueueDisplay(); this.sessionEventHandler.retryQueuedGoalPromotion(); - this.syncTerminalTitleSpinner(); + this.titleSpinner.sync(); } if (additionalDirsChanged) this.setupAutocomplete(); this.state.ui.requestRender(); @@ -1697,61 +1697,7 @@ export class KimiTUI { } updateTerminalTitle(): void { - this.syncTerminalTitleSpinner(); - } - - /** - * Reflects the current busy state on the terminal title. While the agent is - * working (streaming a turn or compacting) the title is prefixed with an - * animated glyph so progress stays visible on the terminal tab even when the - * window is not focused; when idle, the static session title (or product - * name) is restored. The style is resolved once from `KIMI_TITLE_SPINNER`: - * a cycling registry entry runs a single timer, `phase` derives a static - * glyph from the streaming phase (no timer), and `off` keeps the plain title. - */ - private syncTerminalTitleSpinner(): void { - const busy = this.state.appState.streamingPhase !== 'idle' || this.state.appState.isCompacting; - if (!busy || this.titleSpinnerId === OFF_TITLE_SPINNER_ID) { - this.stopTerminalTitleSpinner(); - this.state.terminal.setTitle(this.staticTerminalTitle()); - return; - } - if (this.titleSpinnerId === PHASE_TITLE_SPINNER_ID) { - this.state.terminal.setTitle(this.phaseTerminalTitle()); - return; - } - const style = TITLE_SPINNER_STYLES[this.titleSpinnerId]; - if (style === undefined) { - this.state.terminal.setTitle(this.staticTerminalTitle()); - return; - } - this.state.terminal.setTitle(this.frameTerminalTitle(style)); - this.titleSpinnerTimer ??= setInterval(() => { - this.titleSpinnerFrame = (this.titleSpinnerFrame + 1) % style.frames.length; - this.state.terminal.setTitle(this.frameTerminalTitle(style)); - }, style.interval); - } - - private stopTerminalTitleSpinner(): void { - if (this.titleSpinnerTimer !== null) { - clearInterval(this.titleSpinnerTimer); - this.titleSpinnerTimer = null; - } - } - - private staticTerminalTitle(): string { - const trimmed = this.state.appState.sessionTitle?.trim() ?? ''; - return trimmed.length > 0 ? trimmed.slice(0, MAX_TERMINAL_TITLE_LENGTH) : PRODUCT_NAME; - } - - private frameTerminalTitle(style: TitleSpinnerStyle): string { - const frame = style.frames[this.titleSpinnerFrame % style.frames.length] ?? ''; - return `${frame} ${this.staticTerminalTitle()}`; - } - - private phaseTerminalTitle(): string { - const glyph = PHASE_TITLE_GLYPHS[this.state.appState.streamingPhase]; - return glyph === undefined ? this.staticTerminalTitle() : `${glyph} ${this.staticTerminalTitle()}`; + this.titleSpinner.sync(); } resetSessionRuntime(): void { diff --git a/apps/kimi-code/test/tui/constant/title-spinners.test.ts b/apps/kimi-code/test/tui/constant/title-spinners.test.ts index 6c62c676c0..7b9f8ef1f3 100644 --- a/apps/kimi-code/test/tui/constant/title-spinners.test.ts +++ b/apps/kimi-code/test/tui/constant/title-spinners.test.ts @@ -36,6 +36,13 @@ describe('resolveTitleSpinnerId', () => { 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', () => { diff --git a/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts b/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts new file mode 100644 index 0000000000..f21c23d9e3 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TITLE_SPINNER_STYLES } from '#/tui/constant/title-spinners'; +import { + TerminalTitleSpinnerController, + type TerminalTitleSpinnerHost, +} from '#/tui/controllers/terminal-title-spinner'; + +interface FakeHostState { + busy: boolean; + phase: string; + staticTitle: string; +} + +function createHost(state: FakeHostState): { host: TerminalTitleSpinnerHost; titles: string[] } { + const titles: string[] = []; + const host: TerminalTitleSpinnerHost = { + setTitle: vi.fn((label: string) => { + titles.push(label); + }), + staticTitle: () => state.staticTitle, + isBusy: () => state.busy, + streamingPhase: () => state.phase, + }; + return { host, titles }; +} + +describe('TerminalTitleSpinnerController', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('writes the static title and runs no timer when idle', () => { + const { host, titles } = createHost({ busy: false, phase: 'idle', staticTitle: 'My Session' }); + const controller = new TerminalTitleSpinnerController(host, 'moon'); + controller.sync(); + expect(titles).toEqual(['My Session']); + vi.advanceTimersByTime(2000); + expect(titles).toEqual(['My Session']); + }); + + it('animates the title with the selected style while busy', () => { + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: 'Fix bug' }); + const frames = TITLE_SPINNER_STYLES['line']!.frames; + const interval = TITLE_SPINNER_STYLES['line']!.interval; + const controller = new TerminalTitleSpinnerController(host, 'line'); + controller.sync(); + expect(titles[0]).toBe(`${frames[0]} Fix bug`); + vi.advanceTimersByTime(interval); + expect(titles[1]).toBe(`${frames[1]} Fix bug`); + controller.dispose(); + }); + + it('runs a single timer across repeated syncs while busy', () => { + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: 'x' }); + const interval = TITLE_SPINNER_STYLES['line']!.interval; + const controller = new TerminalTitleSpinnerController(host, 'line'); + controller.sync(); + controller.sync(); + controller.sync(); + const writesBefore = titles.length; + vi.advanceTimersByTime(interval); + // Exactly one timer tick fires, so exactly one extra write happens. + expect(titles.length).toBe(writesBefore + 1); + controller.dispose(); + }); + + it('off keeps the static title even while busy', () => { + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: 'S' }); + const controller = new TerminalTitleSpinnerController(host, 'off'); + controller.sync(); + vi.advanceTimersByTime(2000); + expect(titles).toEqual(['S']); + }); + + it('phase maps the streaming phase to a glyph without any timer', () => { + const state: FakeHostState = { busy: true, phase: 'thinking', staticTitle: 'S' }; + const { host, titles } = createHost(state); + const controller = new TerminalTitleSpinnerController(host, 'phase'); + controller.sync(); + expect(titles[0]).toBe('✦ S'); + state.phase = 'shell'; + controller.sync(); + expect(titles[1]).toBe('▸ S'); + vi.advanceTimersByTime(5000); + expect(titles).toHaveLength(2); + }); + + it('dispose stops the timer and restores the static title', () => { + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: 'Done' }); + const controller = new TerminalTitleSpinnerController(host, 'line'); + controller.sync(); + controller.dispose(); + expect(titles.at(-1)).toBe('Done'); + const countAfterDispose = titles.length; + vi.advanceTimersByTime(2000); + expect(titles).toHaveLength(countAfterDispose); + }); + + it('unknown style falls back to the default spinner', () => { + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: 'T' }); + const controller = new TerminalTitleSpinnerController(host, 'definitely-not-a-style'); + const firstFrame = TITLE_SPINNER_STYLES['moon']!.frames[0]; + controller.sync(); + expect(titles[0]).toBe(`${firstFrame} T`); + controller.dispose(); + }); +}); diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 1457746245..07a8082b5b 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -148,6 +148,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_MODEL_THINKING_KEEP` | Preserved-thinking passthrough; on `kimi` sent as `thinking.keep`, on `anthropic` (Claude and Kimi's Anthropic-compatible mode) sent as a `context_management` `clear_thinking_20251015` edit (enabling keep routes Anthropic requests to the beta Messages API); overrides `[thinking] keep` (which defaults to `"all"`); only injected while Thinking is on | A value the API accepts, e.g. `all`; an off-value (`false`/`0`/`no`/`off`/`none`/`null`) disables it | | `KIMI_CODE_NO_AUTO_UPDATE` | Fully disable the update preflight — no check, background install, or prompt. Legacy alias `KIMI_CLI_NO_AUTO_UPDATE` is also honored | Truthy: `1`/`true`/`yes`/`on` | | `KIMI_DISABLE_CRON` | Disable the scheduled-task tool (`CronCreate` rejects new schedules; existing tasks do not fire) | `1` to disable | +| `KIMI_TITLE_SPINNER` | Animate the terminal title while the agent is busy so the working state stays visible on the terminal tab; the static title is restored when idle | `moon` (default), `sparkle`, `braille`, `orbit`, `pulse`, `line`, `hourglass`, `phase` (shows the streaming phase instead of spinning), `off` (no animation); unknown values fall back to `moon` | ## Diagnostic logs diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index e5a654ebe0..f107a902a7 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -148,6 +148,7 @@ kimi | `KIMI_MODEL_THINKING_KEEP` | 保留思考透传;在 `kimi` 上以 `thinking.keep` 发送,在 `anthropic`(Claude 以及 Kimi 的 Anthropic 兼容模式)上以 `context_management` 的 `clear_thinking_20251015` 编辑发送(开启 keep 会让 Anthropic 请求走 beta Messages API);覆盖 `[thinking] keep`(其默认值为 `"all"`);仅在 Thinking 开启时注入 | API 接受的值,如 `all`;传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用 | | `KIMI_CODE_NO_AUTO_UPDATE` | 完全禁用更新预检——不检查、不后台安装、不提示。同时兼容旧名 `KIMI_CLI_NO_AUTO_UPDATE` | 真值:`1`/`true`/`yes`/`on` | | `KIMI_DISABLE_CRON` | 禁用定时任务工具(`CronCreate` 拒绝新计划,已有任务不触发) | `1` 表示禁用 | +| `KIMI_TITLE_SPINNER` | agent 忙碌时让终端标题动起来,使工作状态在终端标签页上可见;空闲时恢复静态标题 | `moon`(默认)、`sparkle`、`braille`、`orbit`、`pulse`、`line`、`hourglass`、`phase`(显示 streaming 相位而非旋转)、`off`(不动画);未知值回退到 `moon` | ## 诊断日志 From 70e0c0c02497b0d2f365a606204416bccdf1c542 Mon Sep 17 00:00:00 2001 From: AidenNovak <178626503+AidenNovak@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:54:05 +0800 Subject: [PATCH 3/6] fix(tui): address review feedback for terminal title spinner - Use the required capitalized Agent spelling in the zh env-vars doc. - Keep composed spinner and phase titles within MAX_TERMINAL_TITLE_LENGTH so long session titles do not defeat the readability cap while busy. - Guard PHASE_TITLE_GLYPHS with an own-property check, matching the spinner registry hardening. - Dispose title-spinner timers in the message-flow test fixture and silence setTitle writes so intervals and OSC output do not leak across tests. --- .../tui/controllers/terminal-title-spinner.ts | 17 +++++++++--- .../terminal-title-spinner.test.ts | 27 +++++++++++++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 11 +++++++- docs/zh/configuration/env-vars.md | 2 +- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts b/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts index ef58d73590..9736ff68b3 100644 --- a/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts +++ b/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts @@ -1,3 +1,4 @@ +import { MAX_TERMINAL_TITLE_LENGTH } from '#/tui/constant/terminal'; import { OFF_TITLE_SPINNER_ID, PHASE_TITLE_GLYPHS, @@ -78,12 +79,22 @@ export class TerminalTitleSpinnerController { private frameTitle(style: TitleSpinnerStyle): string { const frame = style.frames[this.frame % style.frames.length] ?? ''; - return `${frame} ${this.host.staticTitle()}`; + return this.fitTitle(`${frame} ${this.host.staticTitle()}`); } private phaseTitle(): string { - const glyph = PHASE_TITLE_GLYPHS[this.host.streamingPhase()]; + const phase = this.host.streamingPhase(); const base = this.host.staticTitle(); - return glyph === undefined ? base : `${glyph} ${base}`; + // 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 { + return title.length > MAX_TERMINAL_TITLE_LENGTH + ? title.slice(0, MAX_TERMINAL_TITLE_LENGTH) + : title; } } diff --git a/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts b/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts index f21c23d9e3..466891df85 100644 --- a/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts +++ b/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MAX_TERMINAL_TITLE_LENGTH } from '#/tui/constant/terminal'; import { TITLE_SPINNER_STYLES } from '#/tui/constant/title-spinners'; import { TerminalTitleSpinnerController, @@ -109,4 +110,30 @@ describe('TerminalTitleSpinnerController', () => { expect(titles[0]).toBe(`${firstFrame} T`); controller.dispose(); }); + + it('keeps the composed title within the terminal-title cap', () => { + const longTitle = 'x'.repeat(MAX_TERMINAL_TITLE_LENGTH + 8); + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: longTitle }); + const controller = new TerminalTitleSpinnerController(host, 'line'); + controller.sync(); + expect(titles[0]).toHaveLength(MAX_TERMINAL_TITLE_LENGTH); + expect(titles[0]).toBe(`| ${'x'.repeat(MAX_TERMINAL_TITLE_LENGTH - 2)}`); + controller.dispose(); + }); + + it('keeps the phase title within the terminal-title cap', () => { + const longTitle = 'y'.repeat(MAX_TERMINAL_TITLE_LENGTH + 8); + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: longTitle }); + const controller = new TerminalTitleSpinnerController(host, 'phase'); + controller.sync(); + expect(titles[0]).toHaveLength(MAX_TERMINAL_TITLE_LENGTH); + expect(titles[0]).toBe(`✦ ${'y'.repeat(MAX_TERMINAL_TITLE_LENGTH - 2)}`); + }); + + it('ignores inherited phase keys instead of stringifying them', () => { + const { host, titles } = createHost({ busy: true, phase: 'constructor', staticTitle: 'S' }); + const controller = new TerminalTitleSpinnerController(host, 'phase'); + controller.sync(); + expect(titles[0]).toBe('S'); + }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 7918d998f5..3b5ae523a7 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -308,9 +308,12 @@ async function makeDriver( harness: ReturnType; }> { const harness = makeHarness(session, harnessOverrides); - const driver = new KimiTUI(harness as never, makeStartupInput()) as unknown as MessageDriver; + const tui = new KimiTUI(harness as never, makeStartupInput()); + liveDrivers.push(tui); + const driver = tui as unknown as MessageDriver; vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'setTitle').mockImplementation(() => {}); driver.persistInputHistory = vi.fn(async () => {}); await driver.init(); return { driver, session, harness }; @@ -397,6 +400,7 @@ function countOccurrences(haystack: string, needle: string): number { } const tempDirs: string[] = []; +const liveDrivers: KimiTUI[] = []; const originalKimiCodeHome = process.env['KIMI_CODE_HOME']; const originalPluginMarketplaceUrl = process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL']; const originalVisual = process.env['VISUAL']; @@ -417,6 +421,11 @@ async function makeExportedSessionZip(content = 'session zip'): Promise } afterEach(async () => { + // Stop title-spinner intervals for drivers a test left busy, so timers do + // not outlive the test and keep writing OSC titles. + for (const tui of liveDrivers.splice(0)) { + tui.titleSpinner.dispose(); + } resetCapabilitiesCache(); for (const dir of tempDirs.splice(0)) { await rm(dir, { recursive: true, force: true }); diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index f107a902a7..db219b6e22 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -148,7 +148,7 @@ kimi | `KIMI_MODEL_THINKING_KEEP` | 保留思考透传;在 `kimi` 上以 `thinking.keep` 发送,在 `anthropic`(Claude 以及 Kimi 的 Anthropic 兼容模式)上以 `context_management` 的 `clear_thinking_20251015` 编辑发送(开启 keep 会让 Anthropic 请求走 beta Messages API);覆盖 `[thinking] keep`(其默认值为 `"all"`);仅在 Thinking 开启时注入 | API 接受的值,如 `all`;传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用 | | `KIMI_CODE_NO_AUTO_UPDATE` | 完全禁用更新预检——不检查、不后台安装、不提示。同时兼容旧名 `KIMI_CLI_NO_AUTO_UPDATE` | 真值:`1`/`true`/`yes`/`on` | | `KIMI_DISABLE_CRON` | 禁用定时任务工具(`CronCreate` 拒绝新计划,已有任务不触发) | `1` 表示禁用 | -| `KIMI_TITLE_SPINNER` | agent 忙碌时让终端标题动起来,使工作状态在终端标签页上可见;空闲时恢复静态标题 | `moon`(默认)、`sparkle`、`braille`、`orbit`、`pulse`、`line`、`hourglass`、`phase`(显示 streaming 相位而非旋转)、`off`(不动画);未知值回退到 `moon` | +| `KIMI_TITLE_SPINNER` | Agent 忙碌时让终端标题动起来,使工作状态在终端标签页上可见;空闲时恢复静态标题 | `moon`(默认)、`sparkle`、`braille`、`orbit`、`pulse`、`line`、`hourglass`、`phase`(显示 streaming 相位而非旋转)、`off`(不动画);未知值回退到 `moon` | ## 诊断日志 From a0152d87199fe512a7a20f3cf56c3350957883be Mon Sep 17 00:00:00 2001 From: AidenNovak <178626503+AidenNovak@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:31:30 +0800 Subject: [PATCH 4/6] fix(tui): keep title truncation on a surrogate-pair boundary fitTitle() sliced on raw UTF-16 units, so a composed title crossing the cap inside a surrogate pair (e.g. an emoji right at the boundary) kept a lone high surrogate and rendered as a replacement character on the terminal tab. Back up one unit when the code point at the cut straddles the cap; regression test covers the emoji-at-boundary case. --- .../src/tui/controllers/terminal-title-spinner.ts | 10 +++++++--- .../tui/controllers/terminal-title-spinner.test.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts b/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts index 9736ff68b3..bf6939d6a7 100644 --- a/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts +++ b/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts @@ -93,8 +93,12 @@ export class TerminalTitleSpinnerController { /** Keep the composed title within the terminal-title cap. */ private fitTitle(title: string): string { - return title.length > MAX_TERMINAL_TITLE_LENGTH - ? title.slice(0, MAX_TERMINAL_TITLE_LENGTH) - : title; + if (title.length <= MAX_TERMINAL_TITLE_LENGTH) return title; + let end = MAX_TERMINAL_TITLE_LENGTH; + // Never cut a surrogate pair (e.g. emoji) in half: a code point above + // U+FFFF starting before the cap straddles it, so back up one unit. + const codePoint = title.codePointAt(end - 1); + if (codePoint !== undefined && codePoint > 0xffff) end -= 1; + return title.slice(0, end); } } diff --git a/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts b/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts index 466891df85..825f66ae2c 100644 --- a/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts +++ b/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts @@ -121,6 +121,18 @@ describe('TerminalTitleSpinnerController', () => { controller.dispose(); }); + it('does not split a surrogate pair at the truncation boundary', () => { + // 29 ASCII chars plus one emoji = 31 UTF-16 units; with the `| ` prefix + // the composed title is 33 units, so a naive slice would keep a lone high + // surrogate at the cap. + const longTitle = `${'x'.repeat(29)}\u{1F311}`; + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: longTitle }); + const controller = new TerminalTitleSpinnerController(host, 'line'); + controller.sync(); + expect(titles[0]).toBe(`| ${'x'.repeat(29)}`); + controller.dispose(); + }); + it('keeps the phase title within the terminal-title cap', () => { const longTitle = 'y'.repeat(MAX_TERMINAL_TITLE_LENGTH + 8); const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: longTitle }); From 64d2a4b31029275e50e15be5537e12c9127d30b8 Mon Sep 17 00:00:00 2001 From: AidenNovak <178626503+AidenNovak@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:53:10 +0800 Subject: [PATCH 5/6] docs(zh): translate the streaming phase label in KIMI_TITLE_SPINNER row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the untranslated `streaming 相位` phrase with 流式输出阶段 per the jargon rule in docs/AGENTS.md. --- docs/zh/configuration/env-vars.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index db219b6e22..bf69d5a138 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -148,7 +148,7 @@ kimi | `KIMI_MODEL_THINKING_KEEP` | 保留思考透传;在 `kimi` 上以 `thinking.keep` 发送,在 `anthropic`(Claude 以及 Kimi 的 Anthropic 兼容模式)上以 `context_management` 的 `clear_thinking_20251015` 编辑发送(开启 keep 会让 Anthropic 请求走 beta Messages API);覆盖 `[thinking] keep`(其默认值为 `"all"`);仅在 Thinking 开启时注入 | API 接受的值,如 `all`;传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用 | | `KIMI_CODE_NO_AUTO_UPDATE` | 完全禁用更新预检——不检查、不后台安装、不提示。同时兼容旧名 `KIMI_CLI_NO_AUTO_UPDATE` | 真值:`1`/`true`/`yes`/`on` | | `KIMI_DISABLE_CRON` | 禁用定时任务工具(`CronCreate` 拒绝新计划,已有任务不触发) | `1` 表示禁用 | -| `KIMI_TITLE_SPINNER` | Agent 忙碌时让终端标题动起来,使工作状态在终端标签页上可见;空闲时恢复静态标题 | `moon`(默认)、`sparkle`、`braille`、`orbit`、`pulse`、`line`、`hourglass`、`phase`(显示 streaming 相位而非旋转)、`off`(不动画);未知值回退到 `moon` | +| `KIMI_TITLE_SPINNER` | Agent 忙碌时让终端标题动起来,使工作状态在终端标签页上可见;空闲时恢复静态标题 | `moon`(默认)、`sparkle`、`braille`、`orbit`、`pulse`、`line`、`hourglass`、`phase`(显示流式输出阶段而非旋转动画)、`off`(不动画);未知值回退到 `moon` | ## 诊断日志 From e9d4666fd983e99a774453e3a3cb0bd345fc9a0e Mon Sep 17 00:00:00 2001 From: AidenNovak <178626503+AidenNovak@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:31:12 +0800 Subject: [PATCH 6/6] fix(tui): truncate titles on grapheme boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surrogate-pair check kept individual pairs intact but still split multi-code-point graphemes (flags, ZWJ sequences) straddling the cap, rendering half a flag on the terminal tab. Truncate with a shared Intl.Segmenter grapheme iterator — the same idiom other TUI components use — so over-long clusters are dropped whole; regression test covers a flag emoji at the boundary. --- .../tui/controllers/terminal-title-spinner.ts | 16 ++++++++++------ .../controllers/terminal-title-spinner.test.ts | 12 ++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts b/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts index bf6939d6a7..8fcf95622a 100644 --- a/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts +++ b/apps/kimi-code/src/tui/controllers/terminal-title-spinner.ts @@ -9,6 +9,8 @@ import { 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; @@ -94,11 +96,13 @@ export class TerminalTitleSpinnerController { /** Keep the composed title within the terminal-title cap. */ private fitTitle(title: string): string { if (title.length <= MAX_TERMINAL_TITLE_LENGTH) return title; - let end = MAX_TERMINAL_TITLE_LENGTH; - // Never cut a surrogate pair (e.g. emoji) in half: a code point above - // U+FFFF starting before the cap straddles it, so back up one unit. - const codePoint = title.codePointAt(end - 1); - if (codePoint !== undefined && codePoint > 0xffff) end -= 1; - return title.slice(0, end); + // 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; } } diff --git a/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts b/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts index 825f66ae2c..911826c712 100644 --- a/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts +++ b/apps/kimi-code/test/tui/controllers/terminal-title-spinner.test.ts @@ -133,6 +133,18 @@ describe('TerminalTitleSpinnerController', () => { controller.dispose(); }); + it('drops a multi-code-point grapheme whole instead of splitting it', () => { + // 27 ASCII chars plus a flag emoji (two regional indicators, 4 UTF-16 + // units); with the `| ` prefix the composed title is 33 units, so the + // flag straddles the 32-unit cap and must be dropped as one grapheme. + const longTitle = `${'a'.repeat(27)}\u{1F1E8}\u{1F1F3}`; + const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: longTitle }); + const controller = new TerminalTitleSpinnerController(host, 'line'); + controller.sync(); + expect(titles[0]).toBe(`| ${'a'.repeat(27)}`); + controller.dispose(); + }); + it('keeps the phase title within the terminal-title cap', () => { const longTitle = 'y'.repeat(MAX_TERMINAL_TITLE_LENGTH + 8); const { host, titles } = createHost({ busy: true, phase: 'thinking', staticTitle: longTitle });