-
Notifications
You must be signed in to change notification settings - Fork 882
feat(tui): animate terminal title while agent is busy #2238
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f51f495
a33e03c
70e0c0c
a0152d8
64d2a4b
e9d4666
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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). |
| 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'; | ||
|
|
||
| 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; | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a 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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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; | ||
|
|
@@ -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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a session title already reaches Useful? React with 👍 / 👎. |
||
| }, | ||
| isBusy: () => | ||
| this.state.appState.streamingPhase !== 'idle' || this.state.appState.isCompacting, | ||
| streamingPhase: () => this.state.appState.streamingPhase, | ||
| }); | ||
| this.buildLayout(); | ||
| } | ||
|
|
||
|
|
@@ -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(); | ||
|
|
@@ -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(); | ||
|
|
@@ -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 { | ||
|
|
||
| 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); | ||
| } | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The repository's English and Chinese environment-variable references present themselves as the complete list of runtime variables, but
KIMI_TITLE_SPINNERand 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 mirroredconfiguration/env-vars.mdpages.Useful? React with 👍 / 👎.