diff --git a/.changeset/thinking-live-stats.md b/.changeset/thinking-live-stats.md new file mode 100644 index 0000000000..3185c0687f --- /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 the elapsed thinking time, leaving a one-line "Thought for …" summary when thinking finishes. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 18f7edb5d8..b248076da4 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -59,6 +59,8 @@ export function currentTuiConfig(host: Pick): TuiConf disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, renderLatex: host.state.appState.renderLatex ?? DEFAULT_TUI_CONFIG.renderLatex ?? true, cacheExpiryHint: host.state.appState.cacheExpiryHint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, + thinkingLiveDisplay: + host.state.appState.thinkingLiveDisplay ?? DEFAULT_TUI_CONFIG.thinkingLiveDisplay, notifications: host.state.appState.notifications, upgrade: host.state.appState.upgrade, statusLine: host.state.appState.statusLine ?? DEFAULT_TUI_CONFIG.statusLine, 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..c2ff6056a5 100644 --- a/apps/kimi-code/src/tui/components/messages/thinking.ts +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -3,10 +3,18 @@ * 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' shows just the + * elapsed thinking time, leaving a one-line "Thought for …" summary once + * thinking finishes (ctrl+o reveals the streaming text in both modes). + * Replayed thinking has no persisted duration, so untimed blocks fall back + * to a plain "Thought for a while" summary instead of a fabricated 0s. */ 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, @@ -19,10 +27,23 @@ import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; export type ThinkingRenderMode = 'live' | 'finalized'; +export interface ThinkingComponentOptions { + mode?: ThinkingRenderMode; + ui?: TUI; + liveDisplay?: ThinkingLiveDisplay; + /** False for replayed blocks: their duration is not persisted, so no elapsed + * time is shown anywhere. */ + timed?: boolean; +} + export class ThinkingComponent implements Component { private text: string; private showMarker: boolean; private mode: ThinkingRenderMode; + private readonly liveDisplay: ThinkingLiveDisplay; + private readonly startedAt: number; + private readonly timed: boolean; + private finalizedElapsedSeconds: number | undefined; private expanded = false; private readonly ui: TUI | undefined; private spinnerFrame = 0; @@ -38,15 +59,17 @@ export class ThinkingComponent implements Component { constructor( text: string, showMarker: boolean = true, - mode: ThinkingRenderMode = 'finalized', - ui?: TUI, + options: ThinkingComponentOptions = {}, ) { this.text = text; this.showMarker = showMarker; - this.mode = mode; - this.ui = ui; + this.mode = options.mode ?? 'finalized'; + this.ui = options.ui; + this.liveDisplay = options.liveDisplay ?? 'preview'; + this.timed = options.timed ?? true; + this.startedAt = Date.now(); this.textComponent = new Text(this.styled(text), 0, 0); - if (mode === 'live') { + if (this.mode === 'live') { this.startSpinner(); } } @@ -73,6 +96,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 +121,49 @@ 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' && !this.expanded) { + const label = this.timed + ? `thinking... (${formatThinkingDuration(Math.floor((Date.now() - this.startedAt) / 1000))})` + : 'thinking...'; + rendered = ['', spinner + currentTheme.fg('textDim', label)]; + } else { + // Preview tail — also the expanded view of a live stats block (ctrl+o). + 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. Untimed (replayed) blocks have no + // persisted duration, so they get a plain "a while" instead of a fake 0s. + const p = this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT; + const hint = this.text.length > 0 ? ' (ctrl+o to expand)' : ''; + const duration = this.timed + ? formatThinkingDuration(this.finalizedElapsedSeconds ?? 0) + : 'a while'; + const summary = `Thought for ${duration}${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 +209,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..6cb99f348a 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 the 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..4199b15949 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -636,12 +636,13 @@ export class StreamingUIController { if (this._activeThinkingComponent === undefined) { this._pendingAgentGroup = null; this._pendingReadGroup = null; - this._activeThinkingComponent = new ThinkingComponent( - fullText, - true, - 'live', - state.ui, - ); + this._activeThinkingComponent = new ThinkingComponent(fullText, true, { + mode: 'live', + ui: state.ui, + liveDisplay: state.appState.thinkingLiveDisplay ?? 'preview', + // Replayed thinking has no persisted duration — mark it untimed. + timed: !state.appState.isReplaying, + }); if (state.toolOutputExpanded) this._activeThinkingComponent.setExpanded(true); state.transcriptContainer.addChild(this._activeThinkingComponent); } else { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 70a34bd8ea..9e9203d32c 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -275,6 +275,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 29700f18fd..139578283f 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/commands/update-preferences.test.ts b/apps/kimi-code/test/tui/commands/update-preferences.test.ts index 8e79bfe910..0c698abe8a 100644 --- a/apps/kimi-code/test/tui/commands/update-preferences.test.ts +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -45,6 +45,7 @@ describe('update preference commands', () => { disablePasteBurst: false, renderLatex: true, cacheExpiryHint: true, + thinkingLiveDisplay: 'preview', notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: false }, statusLine: { items: null, command: null }, @@ -78,4 +79,29 @@ describe('update preference commands', () => { expect.objectContaining({ renderLatex: false }), ); }); + + it('preserves a thinking_live_display opt-in when saving an unrelated preference', async () => { + mocks.saveTuiConfig.mockClear(); + const host = { + state: { + appState: { + theme: 'auto' as const, + editorCommand: null, + thinkingLiveDisplay: 'stats' as const, + notifications: { enabled: true, condition: 'unfocused' as const }, + upgrade: { autoInstall: true }, + }, + theme: { palette: darkColors }, + }, + setAppState: vi.fn(), + showStatus: vi.fn(), + track: vi.fn(), + }; + + await applyUpdatePreferenceChoice(host, false); + + expect(mocks.saveTuiConfig).toHaveBeenCalledWith( + expect.objectContaining({ thinkingLiveDisplay: 'stats' }), + ); + }); }); 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..4286b4167e 100644 --- a/apps/kimi-code/test/tui/components/messages/thinking.test.ts +++ b/apps/kimi-code/test/tui/components/messages/thinking.test.ts @@ -12,7 +12,7 @@ const longThinking = ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'lin describe('ThinkingComponent', () => { it('shows the live spinner header before thinking content', () => { - const component = new ThinkingComponent('working it out', true, 'live'); + const component = new ThinkingComponent('working it out', true, { mode: 'live' }); const out = strip(component.render(80).join('\n')); expect(out).toContain('⠋ thinking...'); @@ -22,7 +22,7 @@ describe('ThinkingComponent', () => { }); it('keeps live thinking height-limited to the tail', () => { - const component = new ThinkingComponent(longThinking, true, 'live'); + const component = new ThinkingComponent(longThinking, true, { mode: 'live' }); const out = strip(component.render(80).join('\n')); expect(out).not.toContain('line1'); @@ -36,9 +36,10 @@ describe('ThinkingComponent', () => { it('animates the live spinner and stops on finalize', () => { vi.useFakeTimers(); const requestRender = vi.fn(); - const component = new ThinkingComponent('step', true, 'live', { - requestRender, - } as unknown as TUI); + const component = new ThinkingComponent('step', true, { + mode: 'live', + ui: { requestRender } as unknown as TUI, + }); expect(strip(component.render(80).join('\n'))).toContain('⠋ thinking...'); @@ -54,7 +55,7 @@ describe('ThinkingComponent', () => { }); it('finalizes in place into a collapsed preview', () => { - const component = new ThinkingComponent(longThinking, true, 'live'); + const component = new ThinkingComponent(longThinking, true, { mode: 'live' }); component.finalize(); @@ -67,7 +68,7 @@ describe('ThinkingComponent', () => { }); it('expands and collapses after finalization', () => { - const component = new ThinkingComponent(longThinking, true, 'live'); + const component = new ThinkingComponent(longThinking, true, { mode: 'live' }); component.finalize(); component.setExpanded(true); @@ -82,11 +83,125 @@ describe('ThinkingComponent', () => { }); it('keeps the finalized truncation footer within the requested render width', () => { - const component = new ThinkingComponent(longThinking, true, 'live'); + const component = new ThinkingComponent(longThinking, true, { mode: 'live' }); component.finalize(); for (const line of component.render(37)) { expect(visibleWidth(line)).toBeLessThanOrEqual(37); } }); + + it('shows only the elapsed time instead of content in live stats mode', () => { + const component = new ThinkingComponent(longThinking, true, { mode: 'live', liveDisplay: 'stats' }); + const out = strip(component.render(80).join('\n')); + + expect(out).toContain('⠋ thinking... (0s)'); + expect(out).not.toContain('tokens'); + expect(out).not.toContain('line6'); + expect(out).not.toContain('line7'); + }); + + it('reveals the streaming tail on expand in live stats mode, like preview mode', () => { + const component = new ThinkingComponent(longThinking, true, { mode: 'live', liveDisplay: 'stats' }); + + component.setExpanded(true); + const expanded = strip(component.render(80).join('\n')); + expect(expanded).toContain('⠋ thinking...'); + expect(expanded).not.toContain('(0s)'); + expect(expanded).not.toContain('line5'); + expect(expanded).toContain('line6'); + expect(expanded).toContain('line7'); + + component.setExpanded(false); + const collapsed = strip(component.render(80).join('\n')); + expect(collapsed).toContain('thinking... (0s)'); + expect(collapsed).not.toContain('line7'); + }); + + it('ticks the elapsed time in live stats mode', () => { + vi.useFakeTimers(); + const component = new ThinkingComponent('working it out', true, { mode: 'live', liveDisplay: '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, { mode: 'live', liveDisplay: '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, { mode: 'live', liveDisplay: '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, { mode: 'live', liveDisplay: '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'); + }); + + it('shows "Thought for a while" for untimed (replayed) stats blocks', () => { + const component = new ThinkingComponent(longThinking, true, { + mode: 'live', + liveDisplay: 'stats', + timed: false, + }); + + component.finalize(); + + const out = strip(component.render(80).join('\n')); + expect(out).toContain(`${STATUS_BULLET}Thought for a while`); + expect(out).toContain('(ctrl+o to expand)'); + expect(out).not.toContain('0s'); + expect(out).not.toContain('line1'); + }); + + it('omits the stats from the untimed live line', () => { + const component = new ThinkingComponent('working it out', true, { + mode: 'live', + liveDisplay: 'stats', + timed: false, + }); + + const out = strip(component.render(80).join('\n')); + expect(out).toContain('⠋ thinking...'); + expect(out).not.toContain('(0s)'); + expect(out).not.toContain('tokens'); + expect(out).not.toContain('working it out'); + }); }); 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/apps/kimi-code/test/tui/render-memo.bench.ts b/apps/kimi-code/test/tui/render-memo.bench.ts index 63bf79df6c..295b8cd3de 100644 --- a/apps/kimi-code/test/tui/render-memo.bench.ts +++ b/apps/kimi-code/test/tui/render-memo.bench.ts @@ -67,7 +67,7 @@ function buildMessages(turns: number): Component[] { assistant.updateContent(`[${i}] ${ASSISTANT_TEXT}`); components.push(assistant); - components.push(new ThinkingComponent(`[${i}] ${THINKING_TEXT}`, true, 'finalized')); + components.push(new ThinkingComponent(`[${i}] ${THINKING_TEXT}`, true, { mode: 'finalized' })); } return components; } diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index e8a969fbbc..568b00626c 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -475,6 +475,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 the elapsed thinking time instead, leaving a one-line "Thought for …" summary when thinking finishes (Ctrl-O reveals the text in both modes) | | `[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` | @@ -488,6 +489,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 the 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 f272fb3a48..6d5253a064 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -474,6 +474,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` 隐藏正文,改为显示已用时间,思考结束后保留一行 "Thought for …" 摘要(两种模式下都可用 Ctrl-O 查看正文) | | `[editor].command` | `string` | `""` | 编写长输入用的外部编辑器命令;留空则回退到 `$VISUAL` / `$EDITOR` | | `[notifications].enabled` | `boolean` | `true` | 是否发送桌面通知 | | `[notifications].notification_condition` | `string` | `unfocused` | 何时通知:`unfocused`(仅终端失去焦点时)或 `always`(总是) | @@ -487,6 +488,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" 显示已用时间 [editor] command = "" # 留空则使用 $VISUAL / $EDITOR