diff --git a/.changeset/fix-replayed-message-timing.md b/.changeset/fix-replayed-message-timing.md new file mode 100644 index 0000000000..b038ab785e --- /dev/null +++ b/.changeset/fix-replayed-message-timing.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show timestamps and assistant response durations in TUI message headers, with a reloadable setting and accurate historical-session replay. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index a3a0f9999d..94df2d3910 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -57,6 +57,7 @@ export function currentTuiConfig(host: Pick): TuiConf theme: host.state.appState.theme, editorCommand: host.state.appState.editorCommand, disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + showTimestamp: host.state.appState.showTimestamp ?? DEFAULT_TUI_CONFIG.showTimestamp, cacheExpiryHint: host.state.appState.cacheExpiryHint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, notifications: host.state.appState.notifications, upgrade: host.state.appState.upgrade, diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 482b852ff3..8ebd61115e 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -1,5 +1,7 @@ import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { UserMessageComponent } from '#/tui/components/messages/user-message'; import { currentTheme, lightColors } from '#/tui/theme'; import { loadTuiConfig, type TuiConfig } from '../config'; import type { SlashCommandHost } from './dispatch'; @@ -55,6 +57,7 @@ export async function applyReloadedTuiConfig( host: SlashCommandHost, config: TuiConfig, ): Promise { + const showTimestamp = config.showTimestamp ?? true; const resolved = config.theme === 'auto' ? (currentTheme.palette === lightColors ? 'light' : 'dark') : undefined; @@ -63,12 +66,21 @@ export async function applyReloadedTuiConfig( host.setAppState({ editorCommand: config.editorCommand, disablePasteBurst: config.disablePasteBurst, + showTimestamp, cacheExpiryHint: config.cacheExpiryHint, notifications: config.notifications, upgrade: config.upgrade, statusLine: config.statusLine, }); host.state.editor.setDisablePasteBurst(config.disablePasteBurst); + for (const component of host.state.transcriptContainer.children) { + if ( + component instanceof UserMessageComponent || + component instanceof AssistantMessageComponent + ) { + component.setShowTimestamp(showTimestamp); + } + } } function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void { diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index c1b39537d4..ba700e05ac 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -5,12 +5,12 @@ * to align after the bullet. */ -import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; +import { Container, Markdown, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; -import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { formatTimestamp } from '#/tui/utils/format-time'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; type AssistantMarkdownOptions = { @@ -24,11 +24,22 @@ export class AssistantMessageComponent implements Component { private lastText = ''; private lastTransient = false; private showBullet: boolean; + private timestamp?: number; + private endedAt?: number; + private showTimestamp = true; private renderCache: { width: number; lines: string[] } | undefined; - constructor(showBullet: boolean = true) { + constructor( + showBullet: boolean = true, + timestamp?: number, + endedAt?: number, + showTimestamp = true, + ) { this.showBullet = showBullet; + this.timestamp = timestamp; + this.endedAt = endedAt; + this.showTimestamp = showTimestamp; this.contentContainer = new Container(); } @@ -42,6 +53,18 @@ export class AssistantMessageComponent implements Component { this.markRenderDirty(); } + setEndedAt(endedAt?: number): void { + if (this.endedAt === endedAt) return; + this.endedAt = endedAt; + this.markRenderDirty(); + } + + setShowTimestamp(show: boolean): void { + if (this.showTimestamp === show) return; + this.showTimestamp = show; + this.markRenderDirty(); + } + updateContent(text: string, opts?: AssistantMarkdownOptions): void { const displayText = text.trim(); const transient = opts?.transient === true; @@ -104,15 +127,20 @@ export class AssistantMessageComponent implements Component { return this.renderCache.lines; } - const prefix = this.showBullet ? STATUS_BULLET : MESSAGE_INDENT; - const contentWidth = Math.max(1, safeWidth - visibleWidth(prefix)); - const contentLines = this.contentContainer.render(contentWidth); - const lines: string[] = ['']; - for (let i = 0; i < contentLines.length; i++) { - const p = - i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT; - lines.push(p + contentLines[i]); + const formattedTime = this.showTimestamp ? formatTimestamp(this.timestamp, this.endedAt) : ''; + + if (this.showBullet) { + const bulletText = currentTheme.boldFg('textStrong', STATUS_BULLET); + const headerText = formattedTime.length > 0 ? `${bulletText}${currentTheme.dim(formattedTime)}` : bulletText; + lines.push(headerText); + } else if (formattedTime.length > 0) { + lines.push(currentTheme.dim(formattedTime)); + } + + const contentLines = this.contentContainer.render(safeWidth); + for (const line of contentLines) { + lines.push(line); } const rendered = lines.map((line) => truncateToWidth(line, safeWidth, '…')); if (isRenderCacheEnabled()) { diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index e7241e963a..d295aceeda 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -2,29 +2,46 @@ * Renders a user message in the transcript. */ -import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; +import { Spacer, Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; +import { formatTimestamp } from '#/tui/utils/format-time'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; export class UserMessageComponent implements Component { private text: string; private readonly bullet?: string; + private readonly timestamp?: number; + private showTimestamp = true; private spacerComponent: Spacer; private imageThumbnails: ImageThumbnail[]; private renderCache: { width: number; lines: string[] } | undefined; - constructor(text: string, images?: ImageAttachment[], bullet?: string) { + constructor( + text: string, + images?: ImageAttachment[], + bullet?: string, + timestamp?: number, + showTimestamp = true, + ) { this.text = text; this.bullet = bullet; + this.timestamp = timestamp; + this.showTimestamp = showTimestamp; this.spacerComponent = new Spacer(1); this.imageThumbnails = images?.map((img) => new ImageThumbnail(img)) ?? []; } + setShowTimestamp(show: boolean): void { + if (this.showTimestamp === show) return; + this.showTimestamp = show; + this.markRenderDirty(); + } + private markRenderDirty(): void { this.renderCache = undefined; } @@ -48,11 +65,6 @@ export class UserMessageComponent implements Component { return this.renderCache.lines; } - const marker = this.bullet ?? USER_MESSAGE_BULLET; - const bullet = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : ''; - const bulletWidth = visibleWidth(bullet); - const contentWidth = Math.max(1, safeWidth - bulletWidth); - const lines: string[] = []; // Spacer @@ -60,29 +72,29 @@ export class UserMessageComponent implements Component { lines.push(line); } - // Text is re-dyed from the current theme; invalidate() (theme change) clears - // the render cache so the new colours are picked up on the next render. - const coloredText = currentTheme.boldFg('roleUser', this.text); - const textLines = new Text(coloredText, 0, 0).render(contentWidth); - for (let i = 0; i < textLines.length; i++) { - const prefix = i === 0 ? bullet : ' '.repeat(bulletWidth); - lines.push(prefix + textLines[i]); + const marker = this.bullet ?? USER_MESSAGE_BULLET; + const formattedTime = this.showTimestamp ? formatTimestamp(this.timestamp) : ''; + + if (formattedTime.length > 0) { + const headerMarker = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : ''; + lines.push(`${headerMarker}${currentTheme.dim(formattedTime)}`); + } else if (marker.length > 0) { + lines.push(currentTheme.boldFg('roleUser', marker)); } - // Images — indented to align with text after the bullet + const coloredText = currentTheme.boldFg('roleUser', this.text); + const textLines = new Text(coloredText, 0, 0).render(safeWidth); + for (const line of textLines) { + lines.push(line); + } for (const thumbnail of this.imageThumbnails) { - const imageLines = thumbnail.render(contentWidth); + const imageLines = thumbnail.render(safeWidth); for (const line of imageLines) { - lines.push(' '.repeat(bulletWidth) + line); + lines.push(line); } } const rendered = lines.map((line) => { - // Inline image sequences (Kitty / iTerm2) carry their own placement - // information and have zero visible width, but pi-tui's truncateToWidth - // treats the embedded base64 payload as visible text and would chop the - // escape sequence in half, leaving garbage like "0m...". Skip truncation - // for those lines; the image itself already respects maxWidthCells. if (isImageLine(line)) return line; return truncateToWidth(line, safeWidth, '…'); }); diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index 95f40d6bbb..9ddb412b6d 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -54,6 +54,7 @@ export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = { export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), disable_paste_burst: z.boolean().optional(), + show_timestamp: z.boolean().optional(), cache_expiry_hint: z.boolean().optional(), editor: z .object({ @@ -77,6 +78,7 @@ export const TuiConfigFileSchema = z.object({ export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, disablePasteBurst: z.boolean(), + showTimestamp: z.boolean().optional(), /** Present in every normalized config; optional only so hand-built test * fixtures from before this field existed still typecheck. */ cacheExpiryHint: z.boolean().optional(), @@ -105,6 +107,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', disablePasteBurst: false, + showTimestamp: true, cacheExpiryHint: true, editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, @@ -191,6 +194,7 @@ export function normalizeTuiConfig( return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + showTimestamp: config.show_timestamp ?? DEFAULT_TUI_CONFIG.showTimestamp, cacheExpiryHint: config.cache_expiry_hint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, editorCommand: command === undefined || command.length === 0 ? null : command, notifications: { @@ -240,6 +244,7 @@ export function renderTuiConfig(config: TuiConfig): string { theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback +show_timestamp = ${String(config.showTimestamp)} # true | false cache_expiry_hint = ${String(config.cacheExpiryHint !== false)} # false disables the "cache expired" dialog on resume / idle submit [editor] diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 0eff25feb1..8707eab94b 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -393,8 +393,9 @@ export class SessionEventHandler { } private handleStepBegin(event: TurnStepStartedEvent): void { + const startedAt = Date.now(); this.host.streamingUI.flushNow(); - this.host.streamingUI.setStep(event.step); + this.host.streamingUI.setStep(event.step, startedAt); this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeLiveTextBuffers('waiting'); this.host.patchLivePane({ @@ -404,12 +405,14 @@ export class SessionEventHandler { }); this.host.setAppState({ streamingPhase: 'waiting', - streamingStartTime: Date.now(), + streamingStartTime: startedAt, }); } private handleStepCompleted(event: TurnStepCompletedEvent): void { + const completedAt = Date.now(); this.host.streamingUI.flushNow(); + this.host.streamingUI.completeStep(String(event.turnId), event.step, completedAt); this.host.noteStepUsage(event.usage); this.maybeShowDebugTiming(event); @@ -467,6 +470,7 @@ export class SessionEventHandler { this.host.streamingUI.flushNow(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeLiveTextBuffers('idle'); + this.host.streamingUI.discardStep(String(event.turnId), event.step); const reason = event.reason; if (reason === 'error') return; if (reason === 'aborted' || reason === undefined || reason === '') { diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 1eebd5a720..570f45ca4f 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -239,16 +239,25 @@ export class SessionReplayRenderer { case 'user': this.renderUserMessage(context, message); return; - case 'assistant': + case 'assistant': { if (message.origin?.kind === 'hook_result') { this.renderHookResult(context, message); this.renderToolCalls(context, message.toolCalls); return; } + const msgTime = 'createdAt' in message && typeof message.createdAt === 'number' ? message.createdAt : undefined; + const msgCompletedTime = 'completedAt' in message && typeof message.completedAt === 'number' ? message.completedAt : undefined; + if (msgTime !== undefined && context.assistant.createdAt === undefined) { + context.assistant.createdAt = msgTime; + } + if (msgCompletedTime !== undefined) { + context.assistant.completedAt = msgCompletedTime; + } collectReplayMessageContent(context.assistant, message.content); this.flushAssistant(context); this.renderToolCalls(context, message.toolCalls); return; + } case 'tool': this.flushAssistant(context); this.renderToolResult(context, message); @@ -282,10 +291,12 @@ export class SessionReplayRenderer { const text = contentPartsToText(message.content); if (message.origin.phase === 'input') { const cmd = (extractBashTag(text, 'bash-input') ?? text).trim(); + const createdAt = typeof message.createdAt === 'number' ? message.createdAt : undefined; this.advanceTurn(context); this.host.appendTranscriptEntry( replayEntry(context, 'user', currentTheme.fg('shellMode', `$ ${cmd}`), 'plain', { bullet: '', + createdAt, }), ); } else { @@ -339,9 +350,12 @@ export class SessionReplayRenderer { return; } + const userMsgTime = 'createdAt' in message && typeof message.createdAt === 'number' ? message.createdAt : undefined; this.advanceTurn(context); this.host.appendTranscriptEntry( - replayEntry(context, 'user', contentPartsToText(message.content), 'plain'), + replayEntry(context, 'user', contentPartsToText(message.content), 'plain', { + createdAt: userMsgTime, + }), ); } @@ -393,7 +407,9 @@ export class SessionReplayRenderer { const { streamingUI } = this.host; const thinking = context.assistant.thinking.join(''); const text = context.assistant.text.join(''); - context.assistant = { thinking: [], text: [] }; + const createdAt = context.assistant.createdAt; + const completedAt = context.assistant.completedAt; + context.assistant = { thinking: [], text: [], createdAt: undefined, completedAt: undefined }; this.applyStepContext(context); if (thinking.length > 0) { @@ -401,9 +417,9 @@ export class SessionReplayRenderer { streamingUI.onThinkingEnd(); } if (text.length > 0) { - streamingUI.onStreamingTextStart(); + streamingUI.onStreamingTextStart(createdAt); streamingUI.onStreamingTextUpdate(text); - streamingUI.onStreamingTextEnd(); + streamingUI.onStreamingTextEnd(completedAt); streamingUI.clearAssistantDraft(); } } diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 5b6a35d7f5..b650ccdc26 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -52,9 +52,18 @@ export class StreamingUIController { private _currentTurnId: string | undefined = undefined; private _currentStep = 0; + private _currentStepStartedAt: number | undefined = undefined; private _assistantDraft = ''; private _thinkingDraft = ''; - private _streamingBlock: { component: AssistantMessageComponent; entry: TranscriptEntry } | null = null; + private _streamingBlock: { + component: AssistantMessageComponent; + entry: TranscriptEntry; + stepKey: string; + } | null = null; + private readonly _assistantBlocksByStep = new Map< + string, + { component: AssistantMessageComponent; entry: TranscriptEntry } + >(); private _activeThinkingComponent: ThinkingComponent | undefined = undefined; private _activeCompactionBlock: CompactionComponent | undefined = undefined; private _activeToolCalls = new Map(); @@ -90,8 +99,23 @@ export class StreamingUIController { this._currentTurnId = turnId; } - setStep(step: number): void { + setStep(step: number, startedAt?: number): void { this._currentStep = step; + this._currentStepStartedAt = startedAt; + } + + completeStep(turnId: string, step: number, completedAt: number): void { + const key = assistantStepKey(turnId, step); + const block = this._assistantBlocksByStep.get(key); + if (block === undefined) return; + block.entry.endedAt = completedAt; + block.component.setEndedAt(completedAt); + this._assistantBlocksByStep.delete(key); + this.host.state.ui.requestRender(); + } + + discardStep(turnId: string, step: number): void { + this._assistantBlocksByStep.delete(assistantStepKey(turnId, step)); } hasActiveTurn(): boolean { @@ -109,7 +133,7 @@ export class StreamingUIController { appendAssistantDelta(delta: string): void { if (this._streamingBlock === null) { - this.onStreamingTextStart(); + this.onStreamingTextStart(this._currentStepStartedAt); } this._assistantDraft += delta; this.pendingAssistantFlush = true; @@ -392,6 +416,8 @@ export class StreamingUIController { this._pendingReadGroup = null; this._currentTurnId = undefined; this._currentStep = 0; + this._currentStepStartedAt = undefined; + this._assistantBlocksByStep.clear(); this._streamingToolCallArguments.clear(); this.pendingToolCallFlushIds.clear(); this.host.state.ui.requestRender(); @@ -526,6 +552,7 @@ export class StreamingUIController { this.clearFlushTimerIfIdle(); this._assistantDraft = ''; this._streamingBlock = null; + this._assistantBlocksByStep.clear(); this._thinkingDraft = ''; this.disposeActiveThinkingComponent(); } @@ -559,6 +586,7 @@ export class StreamingUIController { // The finished turn keeps only its conclusion-bearing tail; intermediate // chatter folds into the step summary. this.host.mergeCompletedTurnAssistants(); + this._assistantBlocksByStep.clear(); this.resetToolCallState(); this._currentTurnId = undefined; @@ -590,10 +618,13 @@ export class StreamingUIController { // Live Render Hooks // --------------------------------------------------------------------------- - onStreamingTextStart(): void { + onStreamingTextStart(createdAt?: number): void { const { state } = this.host; this._pendingAgentGroup = null; this._pendingReadGroup = null; + const isReplaying = state.appState.isReplaying ?? false; + const timestamp = createdAt ?? (isReplaying ? undefined : Date.now()); + const showTimestamp = state.appState.showTimestamp ?? true; const entry = { id: nextTranscriptId(), kind: 'assistant' as const, @@ -601,9 +632,12 @@ export class StreamingUIController { renderMode: 'markdown' as const, content: '', modelText: true, + createdAt: timestamp, }; - const component = new AssistantMessageComponent(); - this._streamingBlock = { component, entry }; + const component = new AssistantMessageComponent(true, timestamp, undefined, showTimestamp); + const stepKey = assistantStepKey(this._currentTurnId, this._currentStep); + this._streamingBlock = { component, entry, stepKey }; + this._assistantBlocksByStep.set(stepKey, { component, entry }); this.host.pushTranscriptEntry(entry); state.transcriptContainer.addChild(component); state.ui.requestRender(); @@ -618,9 +652,14 @@ export class StreamingUIController { } } - onStreamingTextEnd(): void { + onStreamingTextEnd(endedAt?: number): void { const block = this._streamingBlock; if (block !== null) { + if (endedAt !== undefined) { + block.entry.endedAt = endedAt; + block.component.setEndedAt(endedAt); + this._assistantBlocksByStep.delete(block.stepKey); + } block.component.updateContent(block.entry.content, { transient: false }); } this._streamingBlock = null; @@ -907,3 +946,7 @@ export class StreamingUIController { return group; } } + +function assistantStepKey(turnId: string | undefined, step: number): string { + return `${turnId ?? ''}:${String(step)}`; +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 7c118e57a3..18728adc4d 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -245,6 +245,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { version: input.version, editorCommand: input.tuiConfig.editorCommand, disablePasteBurst: input.tuiConfig.disablePasteBurst, + showTimestamp: input.tuiConfig.showTimestamp, cacheExpiryHint: input.tuiConfig.cacheExpiryHint, notifications: input.tuiConfig.notifications, upgrade: input.tuiConfig.upgrade, @@ -1148,6 +1149,7 @@ export class KimiTUI { renderMode: 'plain', content: currentTheme.fg('shellMode', `$ ${command}`), bullet: '', + createdAt: Date.now(), }); // Create the live output entry up front. ShellRunComponent owns its own // rendering (running card → final view) and is mutated in place as output @@ -1436,6 +1438,7 @@ export class KimiTUI { renderMode: 'plain', content: input, imageAttachmentIds, + createdAt: Date.now(), }); this.beginSessionRequest(); @@ -1548,6 +1551,7 @@ export class KimiTUI { item.imageAttachmentIds !== undefined && item.imageAttachmentIds.length > 0 ? item.imageAttachmentIds : undefined, + createdAt: Date.now(), }); } @@ -2178,12 +2182,19 @@ export class KimiTUI { return block; } + const showTimestamp = this.state.appState.showTimestamp ?? true; switch (entry.kind) { case 'user': { const images = entry.imageAttachmentIds ?.map((id) => this.imageStore.get(id)) .filter((a): a is ImageAttachment => a?.kind === 'image'); - return new UserMessageComponent(entry.content, images, entry.bullet); + return new UserMessageComponent( + entry.content, + images, + entry.bullet, + entry.createdAt, + showTimestamp, + ); } case 'skill_activation': return new SkillActivationComponent( @@ -2210,7 +2221,12 @@ export class KimiTUI { if (entry.content.trimStart().startsWith('✓ Goal complete')) { return new GoalCompletionMessageComponent(entry.content); } - const component = new AssistantMessageComponent(); + const component = new AssistantMessageComponent( + true, + entry.createdAt, + entry.endedAt, + showTimestamp, + ); component.updateContent(entry.content); return component; } diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index d423aec705..b4a63970c5 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -69,6 +69,7 @@ export interface AppState { editorCommand: string | null; /** Mirrors the TUI config toggle; defaults to false when absent from older fixtures. */ disablePasteBurst?: boolean; + showTimestamp?: boolean; /** Mirrors the TUI config toggle; defaults to true when absent from older fixtures. */ cacheExpiryHint?: boolean; notifications: NotificationsConfig; @@ -215,6 +216,8 @@ export interface TranscriptEntry { skillArgs?: string; skillTrigger?: SkillActivationTrigger; pluginCommandData?: PluginCommandTranscriptData; + createdAt?: number; + endedAt?: number; } export type LivePaneMode = diff --git a/apps/kimi-code/src/tui/utils/format-time.ts b/apps/kimi-code/src/tui/utils/format-time.ts new file mode 100644 index 0000000000..d53b457f06 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/format-time.ts @@ -0,0 +1,38 @@ +/** + * Formats an epoch millisecond timestamp into HH:MM:SS (today) or YYYY-MM-DD HH:MM:SS (before today). + * If endedAtMs is provided, appends duration in seconds e.g. (took 3s). + * Returns empty string if timestamp is invalid or undefined. + */ +export function formatTimestamp(timestampMs?: number, endedAtMs?: number): string { + if (timestampMs === undefined || timestampMs === 0 || !Number.isFinite(timestampMs)) { + return ''; + } + const date = new Date(timestampMs); + const now = new Date(); + const isToday = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + + const pad = (n: number) => String(n).padStart(2, '0'); + const h = pad(date.getHours()); + const m = pad(date.getMinutes()); + const s = pad(date.getSeconds()); + + let text = isToday + ? `${h}:${m}:${s}` + : `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${h}:${m}:${s}`; + + if (endedAtMs !== undefined && Number.isFinite(endedAtMs) && endedAtMs >= timestampMs) { + const durationSec = Math.max(0, Math.round((endedAtMs - timestampMs) / 1000)); + if (durationSec < 60) { + text += ` (took ${durationSec}s)`; + } else { + const min = Math.floor(durationSec / 60); + const sec = durationSec % 60; + text += ` (took ${min}m${sec}s)`; + } + } + + return text; +} diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index c068ac1068..a9da6a99e6 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -31,6 +31,8 @@ export interface ReplayRenderContext { assistant: { thinking: string[]; text: string[]; + createdAt?: number; + completedAt?: number; }; toolCalls: Map; completedToolCallIds: Set; @@ -137,7 +139,7 @@ export function createReplayRenderContext(): ReplayRenderContext { turnIndex: 0, stepIndex: 0, currentTurnId: undefined, - assistant: { thinking: [], text: [] }, + assistant: { thinking: [], text: [], createdAt: undefined, completedAt: undefined }, toolCalls: new Map(), completedToolCallIds: new Set(), skillActivationIds: new Set(), @@ -161,7 +163,7 @@ export function replayEntry( kind: TranscriptEntry['kind'], content: string, renderMode: TranscriptEntry['renderMode'], - extras: { detail?: string; bullet?: string } = {}, + extras: { detail?: string; bullet?: string; createdAt?: number; endedAt?: number } = {}, ): TranscriptEntry { return { id: nextTranscriptId(), @@ -171,6 +173,8 @@ export function replayEntry( content, detail: extras.detail, bullet: extras.bullet, + createdAt: extras.createdAt, + endedAt: extras.endedAt, }; } diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index b36f96213a..7a06d4fdda 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -2,12 +2,15 @@ import { mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { Container } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { handleReloadCommand, handleReloadTuiCommand, } from '#/tui/commands/reload'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { UserMessageComponent } from '#/tui/components/messages/user-message'; import { currentTheme } from '#/tui/theme'; import type { SlashCommandHost } from '#/tui/commands'; import { @@ -31,6 +34,29 @@ afterEach(async () => { }); describe('reload slash commands', () => { + it('re-renders mounted transcript timestamps when the preference is reloaded', async () => { + await writeTuiConfig('show_timestamp = false\n'); + const host = makeHost(); + const startedAt = new Date(2000, 0, 2, 10, 0, 0).getTime(); + const user = new UserMessageComponent('existing user message', [], undefined, startedAt); + const assistant = new AssistantMessageComponent(true, startedAt, startedAt + 5_000); + assistant.updateContent('existing assistant message'); + host.state.transcriptContainer.addChild(user); + host.state.transcriptContainer.addChild(assistant); + + expect(stripSgr(host.state.transcriptContainer.render(120).join('\n'))).toContain( + '2000-01-02 10:00:00', + ); + + await handleReloadTuiCommand(host); + + const transcript = stripSgr(host.state.transcriptContainer.render(120).join('\n')); + expect(transcript).not.toContain('2000-01-02 10:00:00'); + expect(transcript).not.toContain('(took 5s)'); + expect(transcript).toContain('existing user message'); + expect(transcript).toContain('existing assistant message'); + }); + it('reloads tui.toml without touching Core session state', async () => { await writeTuiConfig(` theme = "light" @@ -153,6 +179,10 @@ async function writeTuiConfig(text: string): Promise { await writeFile(join(dir, 'tui.toml'), text, 'utf-8'); } +function stripSgr(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + function makeHost({ session, }: { @@ -170,6 +200,7 @@ function makeHost({ editor: { setDisablePasteBurst: vi.fn(), }, + transcriptContainer: new Container(), theme: { palette: { success: '#00ff00', 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 bf56ba018e..13bf2a26d6 100644 --- a/apps/kimi-code/test/tui/commands/update-preferences.test.ts +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -43,6 +43,7 @@ describe('update preference commands', () => { theme: 'auto', editorCommand: null, disablePasteBurst: false, + showTimestamp: true, cacheExpiryHint: true, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: false }, diff --git a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts index e078e6dd2a..3253058e57 100644 --- a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts @@ -32,8 +32,9 @@ describe('AssistantMessageComponent', () => { component.updateContent('abcdef'); const lines = component.render(8).map(strip); - expect(lines).toEqual(['', `${STATUS_BULLET}abcdef`]); - expect(visibleWidth(lines[1] ?? '')).toBe(8); + expect(lines).toContain(`${STATUS_BULLET}`); + expect(lines.some((l) => l.includes('abcdef'))).toBe(true); + expect(visibleWidth(lines[1] ?? '')).toBe(2); }); it('keeps assistant lines within very narrow widths', () => { @@ -125,4 +126,14 @@ describe('AssistantMessageComponent', () => { finalTheme.highlightCode?.(code, 'typescript'); expect(highlightSpy).toHaveBeenCalled(); }); + + it('renders timestamp tag when timestamp is provided', () => { + const timestamp = new Date(2026, 7, 5, 14, 23, 45).getTime(); + const component = new AssistantMessageComponent(true, timestamp); + component.updateContent('hello assistant'); + + const lines = component.render(80).map(strip); + expect(lines.some((l) => l.startsWith(`${STATUS_BULLET}2026-08-05 14:23:45`))).toBe(true); + expect(lines.some((l) => l.includes('hello assistant'))).toBe(true); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/user-message.test.ts b/apps/kimi-code/test/tui/components/messages/user-message.test.ts index e6a10a05c0..49da473a16 100644 --- a/apps/kimi-code/test/tui/components/messages/user-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/user-message.test.ts @@ -104,4 +104,15 @@ describe('UserMessageComponent', () => { // The `$` sits at the leading column where the bullet used to be. expect(contentLine?.startsWith('$ ls')).toBe(true); }); + + it('renders timestamp in dim format when timestamp is provided', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + + const timestamp = new Date(2026, 7, 5, 14, 23, 45).getTime(); + const component = new UserMessageComponent('hello world', [], undefined, timestamp); + + const out = stripAnsi(component.render(80).join('\n')); + expect(out).toContain('✨ 2026-08-05 14:23:45'); + expect(out).toContain('hello world'); + }); }); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 9ae144a2b4..91ea67e48a 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -61,6 +61,7 @@ auto_install = false expect(config).toEqual({ theme: 'light', disablePasteBurst: false, + showTimestamp: true, cacheExpiryHint: true, editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, @@ -96,6 +97,7 @@ command = " " expect(config).toEqual({ theme: 'auto', disablePasteBurst: false, + showTimestamp: true, cacheExpiryHint: true, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, @@ -130,6 +132,7 @@ command = " " { theme: 'light', disablePasteBurst: false, + showTimestamp: true, cacheExpiryHint: true, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, @@ -142,6 +145,7 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', disablePasteBurst: false, + showTimestamp: true, cacheExpiryHint: true, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, @@ -156,6 +160,7 @@ command = " " { theme, disablePasteBurst: DEFAULT_TUI_CONFIG.disablePasteBurst, + showTimestamp: DEFAULT_TUI_CONFIG.showTimestamp, cacheExpiryHint: DEFAULT_TUI_CONFIG.cacheExpiryHint, editorCommand: null, notifications: DEFAULT_TUI_CONFIG.notifications, diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index acd25f34ad..761b468e83 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -463,6 +463,108 @@ afterEach(async () => { }); describe('KimiTUI message flow', () => { + it('measures a live assistant response from the step start', async () => { + const { driver } = await makeDriver(); + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000); + try { + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + stepId: 'step-1', + } as Event, + vi.fn(), + ); + now.mockReturnValue(5_000); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + delta: 'response after model latency', + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.completed', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + } as Event, + vi.fn(), + ); + + expect(driver.state.transcriptEntries.at(-1)).toMatchObject({ + kind: 'assistant', + createdAt: 1_000, + endedAt: 5_000, + }); + expect(stripSgr(renderTranscript(driver))).toContain('(took 4s)'); + } finally { + now.mockRestore(); + } + }); + + it('does not fabricate an assistant completion time when a step is interrupted', async () => { + const { driver } = await makeDriver(); + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000); + try { + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + stepId: 'step-1', + } as Event, + vi.fn(), + ); + now.mockReturnValue(2_000); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + delta: 'partial response', + } as Event, + vi.fn(), + ); + now.mockReturnValue(5_000); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.interrupted', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + reason: 'aborted', + } as Event, + vi.fn(), + ); + + const interrupted = driver.state.transcriptEntries.find( + (entry) => entry.content === 'partial response', + ); + expect(interrupted).toMatchObject({ + kind: 'assistant', + content: 'partial response', + createdAt: 1_000, + }); + expect(interrupted?.endedAt).toBeUndefined(); + expect(stripSgr(renderTranscript(driver))).not.toContain('(took '); + } finally { + now.mockRestore(); + } + }); + it('tracks editor shortcut and paste hooks', async () => { const { driver, harness } = await makeDriver(); harness.track.mockClear(); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index fe816442b6..ba3387e168 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -243,6 +243,13 @@ function captureInputListeners(driver: StartupDriver) { } describe('KimiTUI startup', () => { + it('disables timestamps at startup when configured off', () => { + const harness = makeHarness(); + const driver = makeDriver(harness, makeStartupInput({}, { showTimestamp: false })); + + expect(driver.state.appState.showTimestamp).toBe(false); + }); + it('creates a fresh session from startup flags and syncs runtime state', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index be5446a08e..526fd6ba4e 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -1,9 +1,16 @@ +/** + * Scenario: replaying persisted session records into the CLI transcript. + * Responsibilities: preserve live rendering behavior, grouping, and compatibility for historical records. + * Wiring: real KimiTUI controllers with an in-memory resumed Session and stubbed SDK boundaries. + * Run: pnpm --filter @moonshot-ai/kimi-code test -- test/tui/message-replay.test.ts + */ import { AsyncLocalStorage } from 'node:async_hooks'; import type { AgentReplayRecord, BackgroundTaskInfo, ContentPart, + Event, GoalSnapshot, PromptOrigin, ResumedAgentState, @@ -82,6 +89,8 @@ function message( readonly toolCallId?: string; readonly origin?: PromptOrigin | TaskNotificationOrigin; readonly isError?: boolean; + readonly createdAt?: number; + readonly completedAt?: number; } = {}, ): AgentReplayRecord { return { @@ -94,6 +103,8 @@ function message( toolCallId: extra.toolCallId, origin: extra.origin as PromptOrigin | undefined, isError: extra.isError, + createdAt: extra.createdAt, + completedAt: extra.completedAt, }, }; } @@ -301,6 +312,148 @@ function backgroundTask( } describe('KimiTUI resume message replay', () => { + it('renders persisted timing fields for replayed user and assistant messages', async () => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'timed prompt' }], { createdAt: 1_700_000_000_000 }), + message('assistant', [{ type: 'text', text: 'timed response' }], { + createdAt: 1_700_000_001_000, + completedAt: 1_700_000_005_000, + }), + ]); + + const user = driver.state.transcriptEntries.find((entry) => entry.content === 'timed prompt'); + const assistant = driver.state.transcriptEntries.find( + (entry) => entry.content === 'timed response', + ); + expect(user).toMatchObject({ kind: 'user', createdAt: 1_700_000_000_000 }); + expect(assistant).toMatchObject({ + kind: 'assistant', + createdAt: 1_700_000_001_000, + endedAt: 1_700_000_005_000, + }); + expect(stripAnsi(driver.state.transcriptContainer.render(140).join('\n'))).toContain( + '(took 4s)', + ); + }); + + it('omits timing when replayed legacy messages do not carry timing fields', async () => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'legacy prompt' }]), + message('assistant', [{ type: 'text', text: 'legacy response' }]), + ]); + + const user = driver.state.transcriptEntries.find((entry) => entry.content === 'legacy prompt'); + const assistant = driver.state.transcriptEntries.find( + (entry) => entry.content === 'legacy response', + ); + expect(user?.createdAt).toBeUndefined(); + expect(assistant?.createdAt).toBeUndefined(); + expect(assistant?.endedAt).toBeUndefined(); + expect(stripAnsi(driver.state.transcriptContainer.render(140).join('\n'))).not.toContain( + '(took ', + ); + }); + + it('keeps tool-using assistant timing consistent between live rendering and replay', async () => { + const driver = await makeDriver(makeSession([])); + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000); + try { + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.started', + agentId: 'main', + sessionId: 'ses-live', + turnId: 1, + step: 1, + stepId: 'step-1', + } as Event, + vi.fn(), + ); + now.mockReturnValue(2_000); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'main', + sessionId: 'ses-live', + turnId: 1, + delta: 'checking the workspace', + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-live', + turnId: 1, + toolCallId: 'call-1', + name: 'Bash', + args: { command: 'echo ok' }, + } as Event, + vi.fn(), + ); + + const liveBeforeToolResult = driver.state.transcriptEntries.find( + (entry) => entry.content === 'checking the workspace', + ); + expect(liveBeforeToolResult?.endedAt).toBeUndefined(); + + now.mockReturnValue(5_000); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.result', + agentId: 'main', + sessionId: 'ses-live', + turnId: 1, + toolCallId: 'call-1', + output: 'ok', + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.completed', + agentId: 'main', + sessionId: 'ses-live', + turnId: 1, + step: 1, + } as Event, + vi.fn(), + ); + + const live = driver.state.transcriptEntries.find( + (entry) => entry.content === 'checking the workspace', + ); + expect(live).toMatchObject({ createdAt: 1_000, endedAt: 5_000 }); + + await driver.switchToSession( + makeSession([ + message( + 'assistant', + [{ type: 'text', text: 'checking the workspace' }], + { + toolCalls: [toolCall('call-1', 'Bash', { command: 'echo ok' })], + createdAt: 1_000, + completedAt: 5_000, + }, + ), + message('tool', [{ type: 'text', text: 'ok' }], { toolCallId: 'call-1' }), + ]), + 'Resumed session (ses-replay).', + ); + + const replayed = driver.state.transcriptEntries.find( + (entry) => entry.content === 'checking the workspace', + ); + expect(replayed).toMatchObject({ createdAt: live?.createdAt, endedAt: live?.endedAt }); + expect(stripAnsi(driver.state.transcriptContainer.render(140).join('\n'))).toContain( + '(took 4s)', + ); + } finally { + now.mockRestore(); + } + }); + it('does not render legacy goal completion context reminders as transcript messages', async () => { const driver = await replayIntoDriver([ message( @@ -338,6 +491,22 @@ describe('KimiTUI resume message replay', () => { expect(transcript).toContain('prepost'); }); + it('restores the recorded timestamp for replayed shell input', async () => { + const createdAt = new Date(2000, 0, 2, 10, 0, 0).getTime(); + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'echo ok' }], { + origin: { kind: 'shell_command', phase: 'input' }, + createdAt, + }), + ]); + + const shellInput = driver.state.transcriptEntries.find((entry) => entry.content.includes('$')); + expect(shellInput).toMatchObject({ kind: 'user', createdAt }); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('2000-01-02 10:00:00'); + expect(transcript).toContain('$ echo ok'); + }); + it('does not render neutral goal completion context reminders as transcript messages', async () => { const driver = await replayIntoDriver([ message( diff --git a/apps/kimi-code/test/tui/utils/format-time.test.ts b/apps/kimi-code/test/tui/utils/format-time.test.ts new file mode 100644 index 0000000000..6d0759cc5a --- /dev/null +++ b/apps/kimi-code/test/tui/utils/format-time.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { formatTimestamp } from '#/tui/utils/format-time'; + +describe('formatTimestamp', () => { + it('returns empty string for undefined or invalid timestamps', () => { + expect(formatTimestamp(undefined)).toBe(''); + expect(formatTimestamp(0)).toBe(''); + expect(formatTimestamp(NaN)).toBe(''); + }); + + it('formats today timestamp as HH:MM:SS', () => { + const now = new Date(); + const timestamp = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + 14, + 23, + 45, + ).getTime(); + + expect(formatTimestamp(timestamp)).toBe('14:23:45'); + }); + + it('formats older timestamp as YYYY-MM-DD HH:MM:SS', () => { + const oldTimestamp = new Date(2025, 4, 12, 9, 8, 7).getTime(); + expect(formatTimestamp(oldTimestamp)).toBe('2025-05-12 09:08:07'); + }); + + it('appends duration in seconds when endedAtMs is provided', () => { + const now = new Date(); + const start = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + 10, + 0, + 0, + ).getTime(); + const end = start + 3400; // 3.4 seconds -> 3s + + expect(formatTimestamp(start, end)).toBe('10:00:00 (took 3s)'); + + const longEnd = start + 75000; // 75 seconds -> 1m15s + expect(formatTimestamp(start, longEnd)).toBe('10:00:00 (took 1m15s)'); + }); +}); diff --git a/docs/en/configuration/data-locations.md b/docs/en/configuration/data-locations.md index fa7bb44643..1ade424d45 100644 --- a/docs/en/configuration/data-locations.md +++ b/docs/en/configuration/data-locations.md @@ -28,7 +28,7 @@ Once set, **all** Kimi Code data — config, sessions, logs, OAuth credentials, ``` $KIMI_CODE_HOME (default: ~/.kimi-code) ├── config.toml # User configuration -├── tui.toml # Terminal UI preferences (including auto-update toggle) +├── tui.toml # Terminal UI preferences (timestamps, theme, updates, and more) ├── AGENTS.md # Global Kimi-specific agent instructions (optional) ├── mcp.json # User-level MCP server declarations (optional) ├── skills/ # Kimi-specific user-level Skills (optional) @@ -61,7 +61,7 @@ $KIMI_CODE_HOME (default: ~/.kimi-code) Each top-level file under the data root serves a specific purpose; most are managed automatically by the CLI: - **`config.toml`**: the main runtime configuration file, storing user-level settings such as providers, models, and loop control. See [Configuration files](./config-files.md). -- **`tui.toml`**: terminal UI client preferences, including `[upgrade].auto_install` (auto-update, on by default). You can disable it in `/settings` or by manually setting `auto_install = false`. +- **`tui.toml`**: terminal UI client preferences. Message timestamps are shown by default; set `show_timestamp = false` to hide timestamps and assistant response durations. Run `/reload-tui` to apply the change without restarting. The file also controls `[upgrade].auto_install` (auto-update, on by default), which you can disable in `/settings` or by setting `auto_install = false`. - **`AGENTS.md`**: global Kimi-specific agent instructions. This file moves with `KIMI_CODE_HOME`; generic cross-tool instructions can still live under `~/.agents/AGENTS.md`. - **`mcp.json`**: user-level MCP server declarations, merged with the project-local `.kimi-code/mcp.json` on startup. See [MCP](../customization/mcp.md). - **`skills/`**: Kimi-specific user-level Skills. This directory moves with `KIMI_CODE_HOME`; generic cross-tool Skills can still live under `~/.agents/skills/`. See [Agent Skills](../customization/skills.md). diff --git a/docs/zh/configuration/data-locations.md b/docs/zh/configuration/data-locations.md index 5ab5aaa027..065a36023a 100644 --- a/docs/zh/configuration/data-locations.md +++ b/docs/zh/configuration/data-locations.md @@ -28,7 +28,7 @@ export KIMI_CODE_HOME="$HOME/.config/kimi-code" ``` $KIMI_CODE_HOME (默认 ~/.kimi-code) ├── config.toml # 用户配置 -├── tui.toml # 终端界面偏好(含自动更新开关) +├── tui.toml # 终端界面偏好(时间戳、主题、更新等) ├── AGENTS.md # 全局 Kimi 专属 Agent 指令(可选) ├── mcp.json # 用户级 MCP server 声明(可选) ├── skills/ # Kimi 专属用户级 Skills(可选) @@ -61,7 +61,7 @@ $KIMI_CODE_HOME (默认 ~/.kimi-code) 数据根下的顶层文件各有用途,大部分由 CLI 自动管理: - **`config.toml`**:主运行时配置,存放供应商、模型、循环控制等用户级设置。详见[配置文件](./config-files.md)。 -- **`tui.toml`**:终端界面客户端偏好,包括 `[upgrade].auto_install`(自动更新,默认开启)。可在 `/settings` 关闭,或手动设为 `auto_install = false`。 +- **`tui.toml`**:终端界面客户端偏好。消息时间戳默认显示;设置 `show_timestamp = false` 可隐藏时间戳和 Assistant 回复耗时。运行 `/reload-tui` 即可应用修改,无需重启。该文件还控制 `[upgrade].auto_install`(自动更新,默认开启),可在 `/settings` 关闭,或设置 `auto_install = false`。 - **`AGENTS.md`**:全局 Kimi 专属 Agent 指令。该文件会随 `KIMI_CODE_HOME` 移动;跨工具通用指令仍可放在 `~/.agents/AGENTS.md`。 - **`mcp.json`**:用户级 MCP server 声明,启动时与项目内的 `.kimi-code/mcp.json` 合并加载。详见 [MCP](../customization/mcp.md)。 - **`skills/`**:Kimi 专属用户级 Skills。该目录会随 `KIMI_CODE_HOME` 移动;跨工具通用 Skills 仍可放在 `~/.agents/skills/`。详见 [Agent Skills](../customization/skills.md)。 diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index aaf5e4a414..aac1b29a65 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -133,6 +133,8 @@ interface ContextAppendMessagePayload { }[]; id?: string; providerMessageId?: string; + createdAt?: number; + completedAt?: number; origin?: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry' | undefined; isError?: boolean; note?: string; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 7733783170..9b4d5fe436 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -40,6 +40,8 @@ interface MutableMessage { toolCallId?: string; isError?: boolean; origin?: ContextMessage['origin']; + createdAt?: number; + completedAt?: number; } interface MutableEntry { @@ -113,7 +115,12 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { closePendingToolResults(time); if (lastOpenStepUuid !== undefined) settleStep(lastOpenStepUuid); const entry: MutableEntry = { - message: { role: 'assistant', content: [], toolCalls: [] }, + message: { + role: 'assistant', + content: [], + toolCalls: [], + ...(time !== undefined ? { createdAt: time } : {}), + }, time, }; push(entry); @@ -122,6 +129,10 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { return; } case 'step.end': { + const openStep = openSteps.get(event.uuid); + if (openStep !== undefined && openStep.message.completedAt === undefined && time !== undefined) { + openStep.message.completedAt = time; + } settleStep(event.uuid); if (lastOpenStepUuid === event.uuid) lastOpenStepUuid = undefined; flushDeferredIfToolExchangeClosed(); @@ -240,6 +251,7 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { } function toMutableEntry(message: ContextMessage, time: number | undefined): MutableEntry { + const msgTime = message.createdAt ?? time; return { message: { ...(message.id !== undefined ? { id: message.id } : {}), @@ -249,6 +261,8 @@ function toMutableEntry(message: ContextMessage, time: number | undefined): Muta ...(message.toolCallId !== undefined ? { toolCallId: message.toolCallId } : {}), ...(message.isError !== undefined ? { isError: message.isError } : {}), ...(message.origin !== undefined ? { origin: message.origin } : {}), + ...(msgTime !== undefined ? { createdAt: msgTime } : {}), + ...(message.completedAt !== undefined ? { completedAt: message.completedAt } : {}), }, time, }; diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index 5b8c59cdb3..fbfdc037c6 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -108,6 +108,8 @@ export type PromptOrigin = export type ContextMessage = Message & { readonly id?: string; readonly providerMessageId?: string; + readonly createdAt?: number; + readonly completedAt?: number; readonly origin?: PromptOrigin | undefined; readonly isError?: boolean; readonly note?: string; diff --git a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts index c96e90900b..9bc69ecac6 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -146,6 +146,37 @@ describe('reduceContextTranscript', () => { expect(result.times).toEqual([100, 200, 220, undefined]); }); + it('projects recorded message and step times into transcript timing fields', () => { + const result = reduceContextTranscript([ + { type: 'context.append_message', message: userMessage('u1'), time: 100 }, + { type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's1' }, time: 200 }, + { + type: 'context.append_loop_event', + event: { type: 'content.part', stepUuid: 's1', part: { type: 'text', text: 'a1' } }, + time: 250, + }, + { type: 'context.append_loop_event', event: { type: 'step.end', uuid: 's1' }, time: 500 }, + ]); + + expect(result.entries[0]).toMatchObject({ role: 'user', createdAt: 100 }); + expect(result.entries[1]).toMatchObject({ + role: 'assistant', + createdAt: 200, + completedAt: 500, + }); + }); + + it('keeps transcript timing absent when legacy records have no timestamps', () => { + const result = reduceContextTranscript([ + appendMessage(userMessage('u1')), + ...assistantStep('s1', 'a1'), + ]); + + expect(result.entries[0]?.createdAt).toBeUndefined(); + expect(result.entries[1]?.createdAt).toBeUndefined(); + expect(result.entries[1]?.completedAt).toBeUndefined(); + }); + it('preserves the pre-compaction assistant reply after a later undo', () => { const result = reduceContextTranscript([ appendMessage(userMessage('message A')), diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 52f8c6c4fb..32628d08e1 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -649,9 +649,12 @@ export class ContextMemory { } appendLoopEvent(event: LoopRecordedEvent): void { + const eventTime = + this.agent.records.restoring === null ? Date.now() : this.agent.records.restoring.time; this.agent.records.logRecord({ type: 'context.append_loop_event', event, + time: eventTime, }); switch (event.type) { case 'step.begin': { @@ -674,6 +677,7 @@ export class ContextMemory { content: [], toolCalls: [], }; + this.agent.replayBuilder.setMessageTiming(message, { createdAt: eventTime }); this.pushHistory(message); this.openSteps.set(event.uuid, message); return; @@ -681,6 +685,9 @@ export class ContextMemory { case 'step.end': { const openStep = this.openSteps.get(event.uuid); this.openSteps.delete(event.uuid); + if (openStep !== undefined) { + this.agent.replayBuilder.setMessageTiming(openStep, { completedAt: eventTime }); + } if (event.usage !== undefined) { const openStepIndex = openStep === undefined ? -1 : this._history.indexOf(openStep); const coveredCount = @@ -762,15 +769,23 @@ export class ContextMemory { } appendMessage(message: ContextMessage): void { + const messageTime = + this.agent.records.restoring === null ? Date.now() : this.agent.records.restoring.time; this.agent.records.logRecord({ type: 'context.append_message', message, + time: messageTime, + }); + const restoredMessage = withoutMessageTiming(message); + this.agent.replayBuilder.setMessageTiming(restoredMessage, { + createdAt: message.createdAt ?? messageTime, + completedAt: message.completedAt, }); if (this.hasOpenToolExchange()) { - this.deferredMessages.push(message); + this.deferredMessages.push(restoredMessage); return; } - this.pushHistory(message); + this.pushHistory(restoredMessage); } private flushDeferredMessagesIfToolExchangeClosed(): void { @@ -802,6 +817,12 @@ export class ContextMemory { } } +function withoutMessageTiming(message: ContextMessage): ContextMessage { + if (message.createdAt === undefined && message.completedAt === undefined) return message; + const { createdAt: _createdAt, completedAt: _completedAt, ...rest } = message; + return rest; +} + // Split inline image-compression captions (see buildImageCompressionCaption) // out of user prompt content. A caption may be a standalone text part (server // route, ACP) or merged into an adjacent text segment (TUI paste), so each diff --git a/packages/agent-core/src/agent/context/types.ts b/packages/agent-core/src/agent/context/types.ts index f4f6f7a4e9..e972854d97 100644 --- a/packages/agent-core/src/agent/context/types.ts +++ b/packages/agent-core/src/agent/context/types.ts @@ -102,6 +102,8 @@ export type PromptOrigin = | RetryOrigin; export type ContextMessage = Message & { + readonly createdAt?: number; + readonly completedAt?: number; readonly origin?: PromptOrigin | undefined; readonly isError?: boolean; /** diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index 309c79edaf..bdf1607b8d 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -271,7 +271,7 @@ export class AgentRecords { } restore(record: AgentRecord): boolean { - this._restoring = { time: record.time ?? Date.now() }; + this._restoring = { time: record.time }; try { restoreAgentRecord(this.agent, record); return this.agent.replayBuilder.finishRestoringRecord(record.type); diff --git a/packages/agent-core/src/agent/replay/index.ts b/packages/agent-core/src/agent/replay/index.ts index aea14be969..3beeae2806 100644 --- a/packages/agent-core/src/agent/replay/index.ts +++ b/packages/agent-core/src/agent/replay/index.ts @@ -11,12 +11,18 @@ export interface ReplayBuilderOptions { readonly range?: ReplayRangeOptions; } +interface ReplayMessageTiming { + readonly createdAt?: number; + readonly completedAt?: number; +} + const UNDO_BOUNDARY_RECORD_TYPES = new Set(['context.clear', 'context.apply_compaction']); export class ReplayBuilder { postRestoring = false; captureLiveRecords = false; protected readonly records: AgentReplayRecord[] = []; + private readonly messageTiming = new WeakMap(); private frozen = false; private segmentStart = 0; @@ -49,6 +55,14 @@ export class ReplayBuilder { } } + setMessageTiming(message: ContextMessage, timing: ReplayMessageTiming): void { + const current = this.messageTiming.get(message); + const createdAt = timing.createdAt ?? current?.createdAt; + const completedAt = timing.completedAt ?? current?.completedAt; + if (createdAt === undefined && completedAt === undefined) return; + this.messageTiming.set(message, { createdAt, completedAt }); + } + removeLastMessages(removedMessages: ReadonlySet): void { if (this.frozen) return; if (removedMessages.size === 0) return; @@ -75,19 +89,30 @@ export class ReplayBuilder { } buildResult(): readonly AgentReplayRecord[] { + let result: readonly AgentReplayRecord[]; const range = this.options.range; if (range !== undefined) { if (range.start === undefined && range.count !== undefined) { const offset = Math.max(0, this.records.length - range.count); - return this.records.slice(offset); + result = this.records.slice(offset); + } else { + const start = range.start ?? 0; + const offset = Math.max(0, start - this.segmentStart); + const count = range.count; + const end = count === undefined ? undefined : offset + count; + result = this.records.slice(offset, end); } - const start = range.start ?? 0; - const offset = Math.max(0, start - this.segmentStart); - const count = range.count; - const end = count === undefined ? undefined : offset + count; - return this.records.slice(offset, end); + } else { + result = this.records; } - return this.records; + return result.map((record) => this.withMessageTiming(record)); + } + + private withMessageTiming(record: AgentReplayRecord): AgentReplayRecord { + if (record.type !== 'message') return record; + const timing = this.messageTiming.get(record.message); + if (timing === undefined) return record; + return { ...record, message: { ...record.message, ...timing } }; } private removeMessagesFrom( diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index 0bb76debe9..53ebcac544 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -1,3 +1,9 @@ +/** + * Scenario: restoring an agent from persisted wire records. + * Responsibilities: rebuild runtime state and replay data without live side effects while preserving recorded facts. + * Wiring: real Agent test harness with in-memory record persistence and stubbed external boundaries. + * Run: pnpm --filter @moonshot-ai/agent-core test -- test/agent/resume.test.ts + */ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; @@ -24,6 +30,106 @@ const MOCK_PROVIDER = { } as const; describe('Agent resume', () => { + it('restores message timing from the timestamps on persisted records', async () => { + const persistence = new RecordingAgentPersistence([ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'timed prompt' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + time: 1_000, + }, + { + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'timed-step', turnId: '0', step: 1 }, + time: 2_000, + }, + { + type: 'context.append_loop_event', + event: { + type: 'content.part', + uuid: 'timed-part', + turnId: '0', + step: 1, + stepUuid: 'timed-step', + part: { type: 'text', text: 'timed response' }, + }, + time: 2_500, + }, + { + type: 'context.append_loop_event', + event: { type: 'step.end', uuid: 'timed-step', turnId: '0', step: 1 }, + time: 5_000, + }, + ]); + const ctx = testAgent({ persistence }); + + await ctx.agent.resume(); + + expect(ctx.agent.replayBuilder.buildResult()).toEqual([ + expect.objectContaining({ + type: 'message', + message: expect.objectContaining({ role: 'user', createdAt: 1_000 }), + }), + expect.objectContaining({ + type: 'message', + message: expect.objectContaining({ + role: 'assistant', + createdAt: 2_000, + completedAt: 5_000, + }), + }), + ]); + }); + + it('leaves message timing absent when legacy records have no timestamps', async () => { + const persistence = new RecordingAgentPersistence([ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'legacy prompt' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'legacy-step', turnId: '0', step: 1 }, + }, + { + type: 'context.append_loop_event', + event: { + type: 'content.part', + uuid: 'legacy-part', + turnId: '0', + step: 1, + stepUuid: 'legacy-step', + part: { type: 'text', text: 'legacy response' }, + }, + }, + { + type: 'context.append_loop_event', + event: { type: 'step.end', uuid: 'legacy-step', turnId: '0', step: 1 }, + }, + ]); + const ctx = testAgent({ persistence }); + + await ctx.agent.resume(); + + const messages = ctx.agent.replayBuilder + .buildResult() + .filter((record) => record.type === 'message') + .map((record) => record.message); + expect(messages).toHaveLength(2); + expect(messages[0]?.createdAt).toBeUndefined(); + expect(messages[1]?.createdAt).toBeUndefined(); + expect(messages[1]?.completedAt).toBeUndefined(); + }); + it('does not append metadata when resuming records that include legacy app version', async () => { const persistence = new RecordingAgentPersistence([ { diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 5c6bad93a4..1316b7a909 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -400,7 +400,12 @@ describe('foldAgentWireReplay', () => { expect(folded.replay).toEqual([ { type: 'message', - message: { role: 'user', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, + message: { + role: 'user', + content: [{ type: 'text', text: 'hello' }], + toolCalls: [], + createdAt: 1001, + }, time: 1001, }, { type: 'permission_updated', mode: 'auto', time: 1002 }, @@ -409,6 +414,64 @@ describe('foldAgentWireReplay', () => { expect(folded.toolStore).toEqual({ todo: [{ title: 'new', status: 'pending' }] }); }); + it('folds persisted step timestamps into assistant timing fields', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-fold-')); + tempDirs.push(dir); + const wirePath = join(dir, 'wire.jsonl'); + const records = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1000 }, + { + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'step-1', turnId: '0', step: 1 }, + time: 2000, + }, + { + type: 'context.append_loop_event', + event: { + type: 'content.part', + stepUuid: 'step-1', + part: { type: 'text', text: 'answer' }, + }, + time: 2500, + }, + { + type: 'context.append_loop_event', + event: { type: 'step.end', uuid: 'step-1', turnId: '0', step: 1 }, + time: 5000, + }, + ]; + await writeFile(wirePath, records.map((record) => JSON.stringify(record)).join('\n') + '\n'); + + const folded = await foldAgentWireReplay(wirePath); + + expect(folded.replay[0]).toMatchObject({ + type: 'message', + message: { role: 'assistant', createdAt: 2000, completedAt: 5000 }, + }); + }); + + it('keeps message timing absent when legacy journal records have no timestamps', async () => { + const dir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-fold-')); + tempDirs.push(dir); + const wirePath = join(dir, 'wire.jsonl'); + const records = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1000 }, + { + type: 'context.append_message', + message: { role: 'user', content: [{ type: 'text', text: 'legacy' }], toolCalls: [] }, + }, + ]; + await writeFile(wirePath, records.map((record) => JSON.stringify(record)).join('\n') + '\n'); + + const folded = await foldAgentWireReplay(wirePath); + const record = folded.replay[0]; + + expect(record?.type).toBe('message'); + if (record?.type !== 'message') throw new Error('Expected a replayed message'); + expect(record.message.createdAt).toBeUndefined(); + expect(record.message.completedAt).toBeUndefined(); + }); + it('degrades to an empty fold on a missing or corrupt journal', async () => { const dir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-fold-')); tempDirs.push(dir);