Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thinking-live-stats.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
62 changes: 55 additions & 7 deletions apps/kimi-code/src/tui/components/messages/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -16,13 +22,17 @@ 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';

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;
Expand All @@ -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();
Expand Down Expand Up @@ -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();
}
Expand All @@ -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);
Comment thread
jfwang-moonshot marked this conversation as resolved.
Outdated
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[] = [''];
Expand Down Expand Up @@ -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`;
}
11 changes: 11 additions & 0 deletions apps/kimi-code/src/tui/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ThinkingLiveDisplaySchema>;

export const StatusLineFileConfigSchema = z.object({
items: z.array(z.string()).optional(),
command: z.string().optional(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Comment thread
jfwang-moonshot marked this conversation as resolved.
Outdated

[editor]
command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,7 @@ export class StreamingUIController {
true,
'live',
state.ui,
state.appState.thinkingLiveDisplay ?? 'preview',
Comment thread
jfwang-moonshot marked this conversation as resolved.
Outdated
);
if (state.toolOutputExpanded) this._activeThinkingComponent.setExpanded(true);
state.transcriptContainer.addChild(this._activeThinkingComponent);
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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. */
Expand Down
69 changes: 69 additions & 0 deletions apps/kimi-code/test/tui/components/messages/thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
14 changes: 14 additions & 0 deletions apps/kimi-code/test/tui/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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]
Expand All @@ -109,6 +121,7 @@ command = " "
renderLatex: true,
disablePasteBurst: false,
cacheExpiryHint: true,
thinkingLiveDisplay: 'preview',
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: true },
Expand Down Expand Up @@ -156,6 +169,7 @@ command = " "
renderLatex: true,
disablePasteBurst: false,
cacheExpiryHint: true,
thinkingLiveDisplay: 'preview',
editorCommand: 'vim',
notifications: { enabled: false, condition: 'always' },
upgrade: { autoInstall: false },
Expand Down
2 changes: 2 additions & 0 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`(总是) |
Expand All @@ -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
Expand Down