From 2c02d8f7cce4251dc0c2c637283d70ebf3f00a59 Mon Sep 17 00:00:00 2001 From: Jianfei Wang Date: Fri, 14 Aug 2026 16:49:38 +0800 Subject: [PATCH] feat(tui): add stats display mode for live thinking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a thinking_live_display preference in tui.toml. The default "preview" keeps the scrolling two-line tail while thinking streams; "stats" hides the text and shows an approximate token count plus elapsed thinking time, leaving a one-line "Thought for …" summary once thinking finishes. --- .changeset/thinking-live-stats.md | 5 ++ apps/kimi-code/src/tui/commands/reload.ts | 1 + .../src/tui/components/messages/thinking.ts | 62 +++++++++++++++-- apps/kimi-code/src/tui/config.ts | 11 +++ .../src/tui/controllers/streaming-ui.ts | 1 + apps/kimi-code/src/tui/kimi-tui.ts | 1 + apps/kimi-code/src/tui/types.ts | 9 ++- .../tui/components/messages/thinking.test.ts | 69 +++++++++++++++++++ apps/kimi-code/test/tui/config.test.ts | 14 ++++ docs/en/configuration/config-files.md | 2 + docs/zh/configuration/config-files.md | 2 + 11 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 .changeset/thinking-live-stats.md diff --git a/.changeset/thinking-live-stats.md b/.changeset/thinking-live-stats.md new file mode 100644 index 0000000000..85e46c0763 --- /dev/null +++ b/.changeset/thinking-live-stats.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add a `thinking_live_display` TUI preference. Set it to `"stats"` in `tui.toml` to replace the scrolling thinking preview with an approximate token count and elapsed thinking time, leaving a one-line "Thought for …" summary when thinking finishes. diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 041ec2d246..30325a0390 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -70,6 +70,7 @@ export async function applyReloadedTuiConfig( disablePasteBurst: config.disablePasteBurst, renderLatex: config.renderLatex, cacheExpiryHint: config.cacheExpiryHint, + thinkingLiveDisplay: config.thinkingLiveDisplay, notifications: config.notifications, upgrade: config.upgrade, statusLine: config.statusLine, diff --git a/apps/kimi-code/src/tui/components/messages/thinking.ts b/apps/kimi-code/src/tui/components/messages/thinking.ts index 23a038c707..2edca24e94 100644 --- a/apps/kimi-code/src/tui/components/messages/thinking.ts +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -3,10 +3,16 @@ * Supports live in-place updates while thinking streams, then finalizes * without replacing the component. * Supports expand/collapse via Ctrl+O (shared with tool output). + * + * The live display has two modes (tui.toml `thinking_live_display`): + * 'preview' scrolls the last few streamed lines; 'stats' hides the text and + * shows an approximate token count plus the elapsed thinking time instead, + * leaving a one-line "Thought for …" summary once thinking finishes. */ import { Text, truncateToWidth, type Component, type TUI } from '@moonshot-ai/pi-tui'; +import type { ThinkingLiveDisplay } from '#/tui/config'; import { BRAILLE_SPINNER_FRAMES, BRAILLE_SPINNER_INTERVAL_MS, @@ -16,6 +22,7 @@ import { import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; +import { formatTokenCount } from '#/utils/usage/usage-format'; export type ThinkingRenderMode = 'live' | 'finalized'; @@ -23,6 +30,9 @@ export class ThinkingComponent implements Component { private text: string; private showMarker: boolean; private mode: ThinkingRenderMode; + private readonly liveDisplay: ThinkingLiveDisplay; + private readonly startedAt: number; + private finalizedElapsedSeconds: number | undefined; private expanded = false; private readonly ui: TUI | undefined; private spinnerFrame = 0; @@ -40,11 +50,14 @@ export class ThinkingComponent implements Component { showMarker: boolean = true, mode: ThinkingRenderMode = 'finalized', ui?: TUI, + liveDisplay: ThinkingLiveDisplay = 'preview', ) { this.text = text; this.showMarker = showMarker; this.mode = mode; this.ui = ui; + this.liveDisplay = liveDisplay; + this.startedAt = Date.now(); this.textComponent = new Text(this.styled(text), 0, 0); if (mode === 'live') { this.startSpinner(); @@ -73,6 +86,7 @@ export class ThinkingComponent implements Component { finalize(): void { this.mode = 'finalized'; + this.finalizedElapsedSeconds = Math.floor((Date.now() - this.startedAt) / 1000); this.markRenderDirty(); this.stopSpinner(); } @@ -97,22 +111,46 @@ export class ThinkingComponent implements Component { } const contentWidth = Math.max(1, width - MESSAGE_INDENT.length); - const contentLines = this.text.length > 0 ? this.textComponent.render(contentWidth) : ['']; + // Stats mode hides the text unless explicitly expanded, so skip the re-wrap. + const showContent = this.liveDisplay === 'preview' || this.expanded; + const contentLines = + showContent && this.text.length > 0 ? this.textComponent.render(contentWidth) : ['']; let rendered: string[]; if (this.mode === 'live') { - const visibleLines = - contentLines.length > THINKING_PREVIEW_LINES - ? contentLines.slice(contentLines.length - THINKING_PREVIEW_LINES) - : contentLines; const spinner = currentTheme.fg( 'textDim', `${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `, ); + if (this.liveDisplay === 'stats') { + // No tokenizer is available to the client, and reasoning-token usage + // only arrives at step end — so the live count is a chars/4 estimate. + const approxTokens = Math.ceil(this.text.length / 4); + const elapsedSeconds = Math.floor((Date.now() - this.startedAt) / 1000); + const stats = `(~${formatTokenCount(approxTokens)} tokens · ${formatThinkingDuration(elapsedSeconds)})`; + rendered = ['', spinner + currentTheme.fg('textDim', `thinking... ${stats}`)]; + } else { + const visibleLines = + contentLines.length > THINKING_PREVIEW_LINES + ? contentLines.slice(contentLines.length - THINKING_PREVIEW_LINES) + : contentLines; + rendered = [ + '', + spinner + currentTheme.fg('textDim', 'thinking...'), + ...visibleLines.map((line) => MESSAGE_INDENT + line), + ]; + } + } else if (this.liveDisplay === 'stats' && !this.expanded) { + // Stats mode leaves a one-line summary instead of the content preview; + // ctrl+o expands into the full text. + const p = this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT; + const hint = this.text.length > 0 ? ' (ctrl+o to expand)' : ''; + const summary = `Thought for ${formatThinkingDuration(this.finalizedElapsedSeconds ?? 0)}${hint}`; + // Both prefixes occupy two cells (STATUS_BULLET is '● '). + const summaryWidth = Math.max(0, width - MESSAGE_INDENT.length); rendered = [ '', - spinner + currentTheme.fg('textDim', 'thinking...'), - ...visibleLines.map((line) => MESSAGE_INDENT + line), + p + currentTheme.fg('textDim', truncateToWidth(summary, summaryWidth, '…')), ]; } else { const lines: string[] = ['']; @@ -158,3 +196,13 @@ export class ThinkingComponent implements Component { this.spinnerInterval = undefined; } } + +/** Compact elapsed time for the live stats line: 10s, 1m12s, 5h3m33s. */ +function formatThinkingDuration(totalSeconds: number): string { + const seconds = totalSeconds % 60; + const minutes = Math.floor(totalSeconds / 60) % 60; + const hours = Math.floor(totalSeconds / 3600); + if (hours > 0) return `${String(hours)}h${String(minutes)}m${String(seconds)}s`; + if (minutes > 0) return `${String(minutes)}m${String(seconds)}s`; + return `${String(seconds)}s`; +} diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index 5a08af8ff7..c0166a9ee8 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -33,6 +33,9 @@ export const UpgradePreferencesSchema = z.object({ export const STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git', 'tips'] as const; export type StatusLineItem = (typeof STATUS_LINE_ITEMS)[number]; +export const ThinkingLiveDisplaySchema = z.enum(['preview', 'stats']); +export type ThinkingLiveDisplay = z.infer; + export const StatusLineFileConfigSchema = z.object({ items: z.array(z.string()).optional(), command: z.string().optional(), @@ -56,6 +59,7 @@ export const TuiConfigFileSchema = z.object({ render_latex: z.boolean().optional(), disable_paste_burst: z.boolean().optional(), cache_expiry_hint: z.boolean().optional(), + thinking_live_display: ThinkingLiveDisplaySchema.optional(), editor: z .object({ command: z.string().optional(), @@ -84,6 +88,9 @@ export const TuiConfigSchema = z.object({ /** Present in every normalized config; optional only so hand-built test * fixtures from before this field existed still typecheck. */ cacheExpiryHint: z.boolean().optional(), + /** Present in every normalized config; optional only so hand-built test + * fixtures from before this field existed still typecheck. */ + thinkingLiveDisplay: ThinkingLiveDisplaySchema.optional(), editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, upgrade: UpgradePreferencesSchema, @@ -111,6 +118,7 @@ export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, + thinkingLiveDisplay: 'preview', editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, upgrade: DEFAULT_UPGRADE_PREFERENCES, @@ -198,6 +206,8 @@ export function normalizeTuiConfig( renderLatex: config.render_latex ?? DEFAULT_TUI_CONFIG.renderLatex, disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, cacheExpiryHint: config.cache_expiry_hint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, + thinkingLiveDisplay: + config.thinking_live_display ?? DEFAULT_TUI_CONFIG.thinkingLiveDisplay, editorCommand: command === undefined || command.length === 0 ? null : command, notifications: { enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled, @@ -248,6 +258,7 @@ theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | c render_latex = ${String(config.renderLatex !== false)} # false keeps LaTeX math in assistant messages as raw source disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback cache_expiry_hint = ${String(config.cacheExpiryHint !== false)} # false disables the "cache expired" dialog on resume / idle submit +thinking_live_display = "${config.thinkingLiveDisplay ?? 'preview'}" # "preview" scrolls the last lines while thinking streams; "stats" shows ~tokens and elapsed time [editor] command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 5b6a35d7f5..ce5c583a40 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -641,6 +641,7 @@ export class StreamingUIController { true, 'live', state.ui, + state.appState.thinkingLiveDisplay ?? 'preview', ); if (state.toolOutputExpanded) this._activeThinkingComponent.setExpanded(true); state.transcriptContainer.addChild(this._activeThinkingComponent); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 614b694668..c0cf6e967b 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -258,6 +258,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { disablePasteBurst: input.tuiConfig.disablePasteBurst, renderLatex: input.tuiConfig.renderLatex, cacheExpiryHint: input.tuiConfig.cacheExpiryHint, + thinkingLiveDisplay: input.tuiConfig.thinkingLiveDisplay, notifications: input.tuiConfig.notifications, upgrade: input.tuiConfig.upgrade, statusLine: input.tuiConfig.statusLine, diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 275ac35d48..7b4482e7c3 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -9,7 +9,12 @@ import type { ToolInputDisplay, } from '@moonshot-ai/kimi-code-sdk'; -import type { NotificationsConfig, StatusLineConfig, UpgradePreferences } from './config'; +import type { + NotificationsConfig, + StatusLineConfig, + ThinkingLiveDisplay, + UpgradePreferences, +} from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { ColorToken, ThemeName } from './theme'; @@ -75,6 +80,8 @@ export interface AppState { renderLatex?: boolean; /** Mirrors the TUI config toggle; defaults to true when absent from older fixtures. */ cacheExpiryHint?: boolean; + /** Live thinking display mode; defaults to 'preview' when absent from older fixtures. */ + thinkingLiveDisplay?: ThinkingLiveDisplay; notifications: NotificationsConfig; upgrade: UpgradePreferences; /** Footer status line customization from tui.toml; absent means the default layout. */ diff --git a/apps/kimi-code/test/tui/components/messages/thinking.test.ts b/apps/kimi-code/test/tui/components/messages/thinking.test.ts index e615d7f5cd..156b5d0cc5 100644 --- a/apps/kimi-code/test/tui/components/messages/thinking.test.ts +++ b/apps/kimi-code/test/tui/components/messages/thinking.test.ts @@ -89,4 +89,73 @@ describe('ThinkingComponent', () => { expect(visibleWidth(line)).toBeLessThanOrEqual(37); } }); + + it('shows approx tokens and elapsed time instead of content in live stats mode', () => { + const component = new ThinkingComponent(longThinking, true, 'live', undefined, 'stats'); + const out = strip(component.render(80).join('\n')); + + expect(out).toContain('⠋ thinking...'); + // longThinking is 41 chars → ceil(41 / 4) = 11 approximate tokens. + expect(out).toContain('~11 tokens'); + expect(out).toContain('0s'); + expect(out).not.toContain('line6'); + expect(out).not.toContain('line7'); + }); + + it('ticks the elapsed time in live stats mode', () => { + vi.useFakeTimers(); + const component = new ThinkingComponent('working it out', true, 'live', undefined, 'stats'); + + vi.advanceTimersByTime(72_000); + component.invalidate(); + expect(strip(component.render(80).join('\n'))).toContain('1m12s'); + + vi.advanceTimersByTime(18_213_000 - 72_000); + component.invalidate(); + expect(strip(component.render(80).join('\n'))).toContain('5h3m33s'); + + vi.useRealTimers(); + }); + + it('finalizes stats mode into a "Thought for" summary line', () => { + const component = new ThinkingComponent(longThinking, true, 'live', undefined, 'stats'); + + component.finalize(); + + const out = strip(component.render(80).join('\n')); + expect(out).toContain(`${STATUS_BULLET}Thought for 0s`); + expect(out).toContain('(ctrl+o to expand)'); + expect(out).not.toContain('line1'); + expect(out).not.toContain('line7'); + }); + + it('freezes the elapsed time in the stats summary on finalize', () => { + vi.useFakeTimers(); + const component = new ThinkingComponent('working it out', true, 'live', undefined, 'stats'); + + vi.advanceTimersByTime(72_000); + component.finalize(); + expect(strip(component.render(80).join('\n'))).toContain('Thought for 1m12s'); + + vi.advanceTimersByTime(60_000); + component.invalidate(); + expect(strip(component.render(80).join('\n'))).toContain('Thought for 1m12s'); + + vi.useRealTimers(); + }); + + it('expands a finalized stats summary into the full thinking text', () => { + const component = new ThinkingComponent(longThinking, true, 'live', undefined, 'stats'); + component.finalize(); + + component.setExpanded(true); + const expanded = strip(component.render(80).join('\n')); + expect(expanded).toContain('line7'); + expect(expanded).not.toContain('Thought for'); + + component.setExpanded(false); + const collapsed = strip(component.render(80).join('\n')); + expect(collapsed).toContain('Thought for 0s'); + expect(collapsed).not.toContain('line7'); + }); }); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 48df473030..1105c1b2a7 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -35,6 +35,7 @@ describe('TUI config', () => { expect(text).toContain('Client preferences for kimi-code.'); expect(text).toContain('theme = "auto"'); expect(text).toContain('cache_expiry_hint = true'); + expect(text).toContain('thinking_live_display = "preview"'); expect(text).toContain('command = ""'); expect(text).toContain('[upgrade]'); expect(text).toContain('auto_install = true'); @@ -63,6 +64,7 @@ auto_install = false renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, + thinkingLiveDisplay: 'preview', editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -98,6 +100,16 @@ cache_expiry_hint = false expect(config.cacheExpiryHint).toBe(false); }); + it('defaults thinking_live_display to preview and parses stats', () => { + expect(parseTuiConfig('').thinkingLiveDisplay).toBe('preview'); + + const config = parseTuiConfig(` +thinking_live_display = "stats" +`); + + expect(config.thinkingLiveDisplay).toBe('stats'); + }); + it('normalizes an empty editor command to auto-detect', () => { const config = parseTuiConfig(` [editor] @@ -109,6 +121,7 @@ command = " " renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, + thinkingLiveDisplay: 'preview', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -156,6 +169,7 @@ command = " " renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, + thinkingLiveDisplay: 'preview', editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 7551345b03..a65fd1e78b 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -472,6 +472,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | `render_latex` | `boolean` | `true` | Render LaTeX math expressions (`$…$`, `$$…$$`) in Markdown messages as Unicode text; `false` keeps the raw source | | `disable_paste_burst` | `boolean` | `false` | Disable the non-bracketed paste-burst fallback that keeps rapid multi-line pastes from submitting line by line | | `cache_expiry_hint` | `boolean` | `true` | Show a dialog when resuming a long-idle session or submitting after a long idle stretch, warning that the context cache has likely expired and offering to compact or start a new session (v2 engine only) | +| `thinking_live_display` | `string` | `preview` | What to show while thinking streams: `preview` scrolls the last lines of the thinking text; `stats` hides the text and shows an approximate token count plus the elapsed thinking time instead, leaving a one-line "Thought for …" summary when thinking finishes (Ctrl-O expands the full text) | | `[editor].command` | `string` | `""` | External editor command for composing long input; empty falls back to `$VISUAL` / `$EDITOR` | | `[notifications].enabled` | `boolean` | `true` | Whether desktop notifications are sent | | `[notifications].notification_condition` | `string` | `unfocused` | When to notify: `unfocused` (only when the terminal is not focused) or `always` | @@ -485,6 +486,7 @@ theme = "auto" # "auto" | "dark" | "light" | custom theme name render_latex = true # false keeps LaTeX math in messages as raw source disable_paste_burst = false # true disables non-bracketed paste-burst fallback cache_expiry_hint = true # false disables the "cache expired" dialog on resume / idle submit +thinking_live_display = "preview" # "preview" scrolls the last lines while thinking streams; "stats" shows ~tokens and elapsed time [editor] command = "" # empty uses $VISUAL / $EDITOR diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index d840b67953..f424fa63c6 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -471,6 +471,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod | `render_latex` | `boolean` | `true` | 将 Markdown 消息中的 LaTeX 公式(`$…$`、`$$…$$`)渲染为 Unicode 文本;`false` 则保留原始源码 | | `disable_paste_burst` | `boolean` | `false` | 禁用非 bracketed paste 的粘贴突发兜底;默认开启,避免快速多行粘贴被逐行提交 | | `cache_expiry_hint` | `boolean` | `true` | resume 长时间未活动的会话、或长时间空闲后发送消息时,若上下文缓存可能已过期则弹出提醒,可选择先压缩或新建会话(仅 v2 引擎) | +| `thinking_live_display` | `string` | `preview` | Thinking 流式输出时的实时显示方式:`preview` 滚动展示思考内容的末尾几行;`stats` 隐藏正文,改为显示估算的 token 数和已用时间,思考结束后保留一行 "Thought for …" 摘要(Ctrl-O 可展开全文) | | `[editor].command` | `string` | `""` | 编写长输入用的外部编辑器命令;留空则回退到 `$VISUAL` / `$EDITOR` | | `[notifications].enabled` | `boolean` | `true` | 是否发送桌面通知 | | `[notifications].notification_condition` | `string` | `unfocused` | 何时通知:`unfocused`(仅终端失去焦点时)或 `always`(总是) | @@ -484,6 +485,7 @@ theme = "auto" # "auto" | "dark" | "light" | 自定义主题名 render_latex = true # false 表示消息中的 LaTeX 公式保留原始源码 disable_paste_burst = false # true 表示禁用非 bracketed paste 的粘贴突发兜底 cache_expiry_hint = true # false 表示关闭 resume / 空闲提交时的"缓存已过期"提醒弹窗 +thinking_live_display = "preview" # "preview" 滚动展示思考内容末尾几行;"stats" 显示估算 token 数和已用时间 [editor] command = "" # 留空则使用 $VISUAL / $EDITOR