Skip to content

feat(tui): animate terminal title while agent is busy - #2238

Open
AidenNovak wants to merge 6 commits into
MoonshotAI:mainfrom
AidenNovak:feat/animated-terminal-title
Open

feat(tui): animate terminal title while agent is busy#2238
AidenNovak wants to merge 6 commits into
MoonshotAI:mainfrom
AidenNovak:feat/animated-terminal-title

Conversation

@AidenNovak

@AidenNovak AidenNovak commented Jul 27, 2026

Copy link
Copy Markdown

相关 Issue

Implements #1885

提交后才发现 @ColorC 已在 #1885 中更早、更完整地提出了相同需求(含参考补丁与 Claude Code 先例)。本 PR 可作为 #1885 的一个完整实现供讨论;不使用自动关闭关键字,关闭与否留给维护者决定。我此前另开的 #2239 与之重复,已标记为重复并关闭。

问题

当 agent 在后台长时间工作(多轮 goal、大型重构、跑测试)时,如果终端窗口不在前台,用户无法只凭终端标签页判断它"还在干活"还是"已经完成 / 卡住"。当前终端标题只显示静态会话标题,缺少一个轻量的工作状态指示。

改动内容

在 agent 忙碌期间(turn 流式输出或上下文压缩时),给终端标题加上一个转动的图形前缀,让用户即使不看窗口、只瞥一眼终端标签页也能知道 agent 正在工作;空闲时自动恢复为原来的静态标题。

设计遵循三个原则:

  • 隔离:所有动效帧数据集中在新文件 apps/kimi-code/src/tui/constant/title-spinners.ts,每个样式是注册表里一条独立记录,增删任一样式互不影响;kimi-tui.ts 只负责定时器编排。
  • 最小改动:复用现有 setTitle()(OSC 0)与会话标题逻辑,原有静态标题行为完整保留;只在 setAppState 已有的 busyChanged 分支和关闭清理处各新增一个调用点。
  • 性能:任意时刻最多一个 setInterval,且仅在忙碌时运行,每次 tick 只向 stdout 写一小段转义序列。phase 样式直接由 streaming phase 推导图形、完全不开定时器;off 则完全不改变现有行为。

样式通过环境变量 KIMI_TITLE_SPINNER 选择(启动时解析一次):

效果
moon(默认) 月相 🌑→🌕,与 Kimi 月亮意象同源
sparkle 星光 ✦✧ 轻盈明灭
braille 经典盲文旋转 ⠋⠙⠹
orbit 点绕八向轨道 ⠁⠂⠄
pulse 电平表式律动 ▁▄█
line 复古 Unix 旋转线 |/-\
hourglass 沙漏翻转 ⧗⧖
phase 按状态显示 waiting◌ / thinking✦ / composing≋ / shell▸(无定时器)
off 关闭,保持原始静态标题

验证

  • 新增 test/tui/constant/title-spinners.test.ts(样式解析与注册表不变量,7 个用例全部通过)。
  • pnpm --filter @moonshot-ai/kimi-code run typecheck 通过。
  • oxlint --type-aware 对新增 / 修改文件 0 警告 0 错误。
  • 现有 TUI 测试套件通过(全量并行执行时 editor-keyboard-image-paste 因本机 CPU 争用偶发超时,单独运行稳定通过,与本改动无关)。

Checklist

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-bot

changeset-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e9d4666

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f51f495936

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/kimi-code/src/tui/kimi-tui.ts Outdated
Comment on lines +1712 to +1713
private syncTerminalTitleSpinner(): void {
const busy = this.state.appState.streamingPhase !== 'idle' || this.state.appState.isCompacting;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Extract terminal-title animation from KimiTUI

This block makes KimiTUI own environment resolution, timer lifecycle, frame selection, and title rendering even though the behavior is self-contained and independently testable. Move this state machine into a controller with observable title/timer tests, leaving the coordinator responsible only for syncing and disposing it.

AGENTS.md reference: apps/kimi-code/AGENTS.md:L38-L39

Useful? React with 👍 / 👎.

Comment thread apps/kimi-code/src/tui/kimi-tui.ts Outdated
this.tasksBrowserController.close();
this.btwPanelController.clear();
this.stopActivitySpinner();
this.stopTerminalTitleSpinner();

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 Restore the static terminal title during shutdown

When double Ctrl-D or SIGTERM invokes stop() while a turn or compaction is active, this only clears the interval and leaves its last animated frame as the terminal title. Neither TUI.stop() nor ProcessTerminal.stop() replaces the title, so terminals whose shells do not set their own prompt title continue to display a stale busy indicator after Kimi exits; write the static title before terminal teardown.

Useful? React with 👍 / 👎.

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 👍 / 👎.

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;

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 Check only own spinner registry entries

When KIMI_TITLE_SPINNER is set to an inherited object key such as constructor, toString, or __proto__, the in check accepts it as a valid style instead of falling back to moon. On the first busy transition, the inherited function/object has no frames, so frameTerminalTitle() throws and crashes the TUI; use an own-property check or a null-prototype registry.

Useful? React with 👍 / 👎.

Address review feedback on MoonshotAI#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.
@AidenNovak

Copy link
Copy Markdown
Author

感谢审查!四条建议已在 a33e03c 中全部处理:

  • P1 抽成 controller:标题动画的状态机(样式解析 / 定时器 / 帧选择 / 渲染)已移入新的 TerminalTitleSpinnerControllercontrollers/terminal-title-spinner.ts),KimiTUI 现在只负责 sync()dispose(),并补充了 7 个可独立观察 title/timer 的测试。
  • P2 退出恢复静态标题dispose() 现在会停止定时器并写回静态标题,turn 进行中退出(Ctrl-D / SIGTERM)不会再残留最后一帧动画。
  • P2 自有属性检查resolveTitleSpinnerIdin 改为 Object.hasOwnconstructor / toString / __proto__ 等继承键会回退到 moon,不再因缺少 frames 而崩溃(已加回归测试)。
  • P2 文档补充:已在中英文 configuration/env-vars.md 的「运行时开关」表格中加入 KIMI_TITLE_SPINNER 及全部取值说明。

验证:typecheck、oxlint 通过;单元测试与相关集成测试(221 例)全部通过。

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a33e03cb7e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/zh/configuration/env-vars.md Outdated
| `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` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use the required Chinese Agent spelling

For Chinese readers, this new row spells the repository-defined Chinese term as lowercase agent; the documentation term table requires Agent in Chinese, so capitalize it to keep terminology consistent across the locale.

AGENTS.md reference: docs/AGENTS.md:L69-L69

Useful? React with 👍 / 👎.

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 👍 / 👎.

Comment on lines +60 to +63
this.timer ??= setInterval(() => {
this.frame = (this.frame + 1) % style.frames.length;
this.host.setTitle(this.frameTitle(style));
}, style.interval);

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 👍 / 👎.

- 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.
@AidenNovak

Copy link
Copy Markdown
Author

第二轮审查意见已在 70e0c0c 中处理:

  • P1 中文 Agent 拼写docs/zh/configuration/env-vars.md 已改为大写 Agent
  • P2 标题超出上限:新增 fitTitle()frameTitle() / phaseTitle() 合成后的标题统一截断到 MAX_TERMINAL_TITLE_LENGTH(截断发生在合成之后,idle 静态标题不受影响);补充两条回归测试(cycling 与 phase 各一条)。
  • P2 测试夹具泄漏kimi-tui-message-flow.test.tsmakeDriver() 现在会登记创建的 KimiTUIafterEach 统一 titleSpinner.dispose(),并按 setProgress 的既有模式 mock 掉 setTitle,定时器与 OSC 输出都不再跨测试泄漏;两个既有标题断言测试(toHaveBeenCalledWith)不受影响。

另外顺手加固了 phaseTitle()PHASE_TITLE_GLYPHS 的原型链 key 检查(与第一轮注册表修复同类),并补了测试。

验证:spinner 相关 18 个测试、message-flow 162 个测试、其余 4 个 KimiTUI 套件 104 个测试全部通过,tsc --noEmitoxlint --type-aware 无告警。

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 70e0c0c024

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +96 to +98
return title.length > MAX_TERMINAL_TITLE_LENGTH
? title.slice(0, MAX_TERMINAL_TITLE_LENGTH)
: title;

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 Preserve complete Unicode characters when truncating titles

When the composed title crosses the cap immediately inside a surrogate pair—for example, the line style with a title of 29 ASCII characters followed by an emoji—slice(0, 32) retains only the emoji's high surrogate, so the terminal tab displays a replacement character instead of the intended title. Truncate on a Unicode code-point or grapheme boundary while reserving room for the spinner prefix.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

已在 a0152d8 修复:fitTitle() 截断时用 codePointAt 检查切点,若跨越边界的码点大于 U+FFFF(即 surrogate pair 被切半)就回退一个单元;补了 emoji 正好落在截断边界的回归测试。静态标题路径的 slice 保持上游既有行为不变。

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a0152d8719

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/zh/configuration/env-vars.md Outdated
| `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` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Translate the streaming phase label

For Chinese readers selecting phase, the phrase streaming 相位 introduces an untranslated technical term that is not explained anywhere on this page. Replace it with a clear Chinese description such as 流式输出阶段, or add a first-use gloss, as required by the documentation jargon rule in docs/AGENTS.md.

AGENTS.md reference: docs/AGENTS.md:L44-L44

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

已在 64d2a4b 修复:改为「显示流式输出阶段而非旋转动画」。

Replace the untranslated `streaming 相位` phrase with 流式输出阶段 per
the jargon rule in docs/AGENTS.md.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 64d2a4b310

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +100 to +102
const codePoint = title.codePointAt(end - 1);
if (codePoint !== undefined && codePoint > 0xffff) end -= 1;
return title.slice(0, end);

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 Preserve complete grapheme clusters when truncating titles

When an animated title crosses the cap inside a multi-code-point grapheme, this check preserves individual surrogate pairs but still splits the displayed character. For example, a 28-character ASCII prefix followed by 🇨🇳 is exactly 32 UTF-16 units as a static title; adding the spinner causes slice(0, 32) to retain only 🇨, so the flag changes or renders incorrectly. Fresh evidence beyond the earlier surrogate-pair report is that complete code points can still form one grapheme; truncate using grapheme boundaries rather than only detecting a straddling surrogate pair.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

已在 e9d4666 修复:改用 Intl.Segmenter(grapheme 粒度)按字位簇边界截断,多码点字符(旗帜、ZWJ 序列)整体丢弃而非切半——与仓库里 goal-queue-manager / session-picker 等 TUI 组件的既有写法一致;补了旗帜 emoji 落在截断边界的回归测试。

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant