Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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);

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.

skews low for CJK characters

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

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 stats preference when saving other TUI settings

When a user has thinking_live_display = "stats" and then runs a path that rewrites tui.toml without touching this option (for example /theme, /editor, the update preference, or the cache-expiry “never” action), those paths save { ...currentTuiConfig(host), ... }, but currentTuiConfig still omits thinkingLiveDisplay. Because this line serializes an omitted value as "preview", those unrelated saves silently reset the user's stats preference; include the new field in currentTuiConfig before defaulting on write.

Useful? React with 👍 / 👎.


[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',

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 Don't apply live stats timing to replayed thinking

When thinkingLiveDisplay is stats, replayed sessions also hit this constructor through SessionReplayRenderer.flushAssistant, which calls onThinkingUpdate(thinking) and then immediately onThinkingEnd() for historical messages. That creates a fresh live component with startedAt = Date.now() and finalizes it right away, so every resumed thinking block is hidden behind a misleading Thought for 0s summary instead of preserving the stored thinking preview; keep replay/finalized rendering out of the live stats mode or pass a replay-safe elapsed value.

Useful? React with 👍 / 👎.

);
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