From 7eba203c232a428d74d3839a57c02b578657f104 Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Wed, 5 Aug 2026 23:48:55 +0800 Subject: [PATCH 01/20] feat(tui): display generation timestamp for user and assistant messages --- .../components/messages/assistant-message.ts | 19 ++++++++++++++----- .../tui/components/messages/user-message.ts | 14 ++++++++++---- .../src/tui/controllers/session-replay.ts | 12 ++++++++++-- .../src/tui/controllers/streaming-ui.ts | 6 ++++-- apps/kimi-code/src/tui/kimi-tui.ts | 7 +++++-- apps/kimi-code/src/tui/types.ts | 1 + apps/kimi-code/src/tui/utils/format-time.ts | 14 ++++++++++++++ .../kimi-code/src/tui/utils/message-replay.ts | 4 +++- .../messages/assistant-message.test.ts | 9 +++++++++ .../components/messages/user-message.test.ts | 10 ++++++++++ 10 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 apps/kimi-code/src/tui/utils/format-time.ts 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..e333856949 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -11,6 +11,7 @@ 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 +25,13 @@ export class AssistantMessageComponent implements Component { private lastText = ''; private lastTransient = false; private showBullet: boolean; + private timestamp?: number; private renderCache: { width: number; lines: string[] } | undefined; - constructor(showBullet: boolean = true) { + constructor(showBullet: boolean = true, timestamp?: number) { this.showBullet = showBullet; + this.timestamp = timestamp; this.contentContainer = new Container(); } @@ -104,14 +107,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 formattedTime = formatTimestamp(this.timestamp); + const timeText = this.timestamp && this.showBullet && formattedTime.length > 0 + ? currentTheme.dim(`[${formattedTime}] `) + : ''; + const prefixHeader = this.showBullet + ? currentTheme.fg('text', STATUS_BULLET) + timeText + : MESSAGE_INDENT; + const prefixWidth = visibleWidth(prefixHeader); + const contentWidth = Math.max(1, safeWidth - prefixWidth); 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; + const p = i === 0 ? prefixHeader : ' '.repeat(prefixWidth); lines.push(p + contentLines[i]); } const rendered = lines.map((line) => truncateToWidth(line, safeWidth, '…')); 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..90ea606fce 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -7,20 +7,23 @@ import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@mo 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 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) { this.text = text; this.bullet = bullet; + this.timestamp = timestamp; this.spacerComponent = new Spacer(1); this.imageThumbnails = images?.map((img) => new ImageThumbnail(img)) ?? []; } @@ -49,8 +52,11 @@ export class UserMessageComponent implements Component { } const marker = this.bullet ?? USER_MESSAGE_BULLET; - const bullet = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : ''; - const bulletWidth = visibleWidth(bullet); + const bulletStr = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : ''; + const formattedTime = formatTimestamp(this.timestamp); + const timeTag = formattedTime.length > 0 ? currentTheme.dim(`[${formattedTime}] `) : ''; + const prefixHeader = bulletStr + timeTag; + const bulletWidth = visibleWidth(prefixHeader); const contentWidth = Math.max(1, safeWidth - bulletWidth); const lines: string[] = []; @@ -65,7 +71,7 @@ export class UserMessageComponent implements Component { 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); + const prefix = i === 0 ? prefixHeader : ' '.repeat(bulletWidth); lines.push(prefix + textLines[i]); } diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index f1a027a021..b4e795ba93 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -244,6 +244,10 @@ export class SessionReplayRenderer { this.renderToolCalls(context, message.toolCalls); return; } + const msgTime = 'createdAt' in message && typeof message.createdAt === 'number' ? message.createdAt : undefined; + if (msgTime !== undefined && context.assistant.createdAt === undefined) { + context.assistant.createdAt = msgTime; + } collectReplayMessageContent(context.assistant, message.content); this.flushAssistant(context); this.renderToolCalls(context, message.toolCalls); @@ -338,9 +342,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, + }), ); } @@ -392,6 +399,7 @@ export class SessionReplayRenderer { const { streamingUI } = this.host; const thinking = context.assistant.thinking.join(''); const text = context.assistant.text.join(''); + const createdAt = context.assistant.createdAt; context.assistant = { thinking: [], text: [] }; this.applyStepContext(context); @@ -400,7 +408,7 @@ export class SessionReplayRenderer { streamingUI.onThinkingEnd(); } if (text.length > 0) { - streamingUI.onStreamingTextStart(); + streamingUI.onStreamingTextStart(createdAt); streamingUI.onStreamingTextUpdate(text); streamingUI.onStreamingTextEnd(); 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..93c64ea951 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -590,10 +590,11 @@ export class StreamingUIController { // Live Render Hooks // --------------------------------------------------------------------------- - onStreamingTextStart(): void { + onStreamingTextStart(createdAt?: number): void { const { state } = this.host; this._pendingAgentGroup = null; this._pendingReadGroup = null; + const timestamp = createdAt ?? Date.now(); const entry = { id: nextTranscriptId(), kind: 'assistant' as const, @@ -601,8 +602,9 @@ export class StreamingUIController { renderMode: 'markdown' as const, content: '', modelText: true, + createdAt: timestamp, }; - const component = new AssistantMessageComponent(); + const component = new AssistantMessageComponent(true, timestamp); this._streamingBlock = { component, entry }; this.host.pushTranscriptEntry(entry); state.transcriptContainer.addChild(component); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 845c48bf7e..5bbccc9afc 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1140,6 +1140,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 @@ -1419,6 +1420,7 @@ export class KimiTUI { renderMode: 'plain', content: input, imageAttachmentIds, + createdAt: Date.now(), }); this.beginSessionRequest(); @@ -1531,6 +1533,7 @@ export class KimiTUI { item.imageAttachmentIds !== undefined && item.imageAttachmentIds.length > 0 ? item.imageAttachmentIds : undefined, + createdAt: Date.now(), }); } @@ -2162,7 +2165,7 @@ export class KimiTUI { 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); } case 'skill_activation': return new SkillActivationComponent( @@ -2189,7 +2192,7 @@ 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); 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 8ff0041a04..f072c49f42 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -209,6 +209,7 @@ export interface TranscriptEntry { skillArgs?: string; skillTrigger?: SkillActivationTrigger; pluginCommandData?: PluginCommandTranscriptData; + createdAt?: 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..6f37f698b5 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/format-time.ts @@ -0,0 +1,14 @@ +/** + * Formats an epoch millisecond timestamp into HH:MM:SS format (local time). + * Returns empty string if timestamp is invalid or undefined. + */ +export function formatTimestamp(timestampMs?: number): string { + if (timestampMs === undefined || timestampMs === 0 || !Number.isFinite(timestampMs)) { + return ''; + } + const date = new Date(timestampMs); + const h = String(date.getHours()).padStart(2, '0'); + const m = String(date.getMinutes()).padStart(2, '0'); + const s = String(date.getSeconds()).padStart(2, '0'); + return `${h}:${m}:${s}`; +} diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index d778b9a472..4bc16fa568 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -29,6 +29,7 @@ export interface ReplayRenderContext { assistant: { thinking: string[]; text: string[]; + createdAt?: number; }; toolCalls: Map; completedToolCallIds: Set; @@ -144,7 +145,7 @@ export function replayEntry( kind: TranscriptEntry['kind'], content: string, renderMode: TranscriptEntry['renderMode'], - extras: { detail?: string; bullet?: string } = {}, + extras: { detail?: string; bullet?: string; createdAt?: number } = {}, ): TranscriptEntry { return { id: nextTranscriptId(), @@ -154,6 +155,7 @@ export function replayEntry( content, detail: extras.detail, bullet: extras.bullet, + createdAt: extras.createdAt, }; } 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..0c815ee921 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 @@ -125,4 +125,13 @@ 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}[14:23:45] 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..082668bc26 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,14 @@ 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('✨ [14:23:45] hello world'); + }); }); From c67e267e6490e8f245432271d606676d42d18e86 Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Thu, 6 Aug 2026 22:15:03 +0800 Subject: [PATCH 02/20] feat(vis-web): add completedAt field to AssistantMessage and display in vis-web --- apps/vis/web/src/components/wire/parts.tsx | 7 +++++++ apps/vis/web/src/components/wire/renderers.tsx | 6 +++++- .../src/agent/contextMemory/loopEventFold.ts | 2 +- packages/agent-core-v2/src/kosong/contract/message.ts | 8 +++++++- packages/agent-core/src/agent/context/index.ts | 3 +++ 5 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/vis/web/src/components/wire/parts.tsx b/apps/vis/web/src/components/wire/parts.tsx index 246900bf48..dfdcb90ea2 100644 --- a/apps/vis/web/src/components/wire/parts.tsx +++ b/apps/vis/web/src/components/wire/parts.tsx @@ -11,6 +11,7 @@ import type { ReactNode } from 'react'; import type { ContentPart, ContextMessage, LoopRecordedEvent, ToolCall } from '../../types'; +import { formatAbsoluteTime, formatWallClock } from '../../util/time'; import { ImagePreview } from '../shared/ImagePreview'; import { JsonViewer } from '../shared/JsonViewer'; import { SizePreview } from '../shared/SizePreview'; @@ -176,12 +177,18 @@ function ToolCallView({ call }: { call: ToolCall }) { } export function MessageDetail({ message }: { message: ContextMessage }) { + const completedAt = (message as { completedAt?: number }).completedAt; return (
"{message.role}" + {completedAt ? ( + + {formatWallClock(completedAt)} + + ) : null} {message.toolCallId ? ( {message.toolCallId} diff --git a/apps/vis/web/src/components/wire/renderers.tsx b/apps/vis/web/src/components/wire/renderers.tsx index d59b239cf0..5776926893 100644 --- a/apps/vis/web/src/components/wire/renderers.tsx +++ b/apps/vis/web/src/components/wire/renderers.tsx @@ -23,8 +23,9 @@ import { truncate, loopEventSummary, } from './parts'; -import { SizePreview } from '../shared/SizePreview'; import { JsonViewer } from '../shared/JsonViewer'; +import { SizePreview } from '../shared/SizePreview'; +import { formatWallClock } from '../../util/time'; export type RecordType = AgentRecord['type']; @@ -214,6 +215,9 @@ export const WIRE_RENDERERS: RendererMap = { ({m.content.length} part{m.content.length === 1 ? '' : 's'}) {tc ? · {tc} : null} {m.origin?.kind ? · origin={m.origin.kind} : null} + {(m as { completedAt?: number }).completedAt ? ( + · completed at {formatWallClock((m as { completedAt?: number }).completedAt!)} + ) : null} ), right: m.isError === true ? ( diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 325b94ba90..35fc946869 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -216,7 +216,7 @@ function settleOpenStep( return [...closed.slice(0, index), ...closed.slice(index + 1)]; } const next = closed.slice(); - next[index] = { ...open, partial: undefined }; + next[index] = { ...open, partial: undefined, completedAt: open.completedAt ?? Date.now() }; return next; } diff --git a/packages/agent-core-v2/src/kosong/contract/message.ts b/packages/agent-core-v2/src/kosong/contract/message.ts index 0c44556e70..5007a0ac38 100644 --- a/packages/agent-core-v2/src/kosong/contract/message.ts +++ b/packages/agent-core-v2/src/kosong/contract/message.ts @@ -67,6 +67,7 @@ export interface Message { readonly toolCallId?: string; readonly partial?: boolean; readonly tools?: readonly Tool[]; + readonly completedAt?: number; } export function isContentPart(part: StreamedMessagePart): part is ContentPart { @@ -142,11 +143,16 @@ export function createUserMessage(content: string): Message { }; } -export function createAssistantMessage(content: ContentPart[], toolCalls?: ToolCall[]): Message { +export function createAssistantMessage( + content: ContentPart[], + toolCalls?: ToolCall[], + completedAt?: number, +): Message { return { role: 'assistant', content, toolCalls: toolCalls ?? [], + ...(completedAt !== undefined ? { completedAt } : {}), }; } diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 52f8c6c4fb..45de032d0b 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -681,6 +681,9 @@ export class ContextMemory { case 'step.end': { const openStep = this.openSteps.get(event.uuid); this.openSteps.delete(event.uuid); + if (openStep !== undefined && (openStep as { completedAt?: number }).completedAt === undefined) { + (openStep as { completedAt?: number }).completedAt = Date.now(); + } if (event.usage !== undefined) { const openStepIndex = openStep === undefined ? -1 : this._history.indexOf(openStep); const coveredCount = From 766084cd52ea22c11de51322624a2a66f2c1aafe Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Thu, 6 Aug 2026 22:15:58 +0800 Subject: [PATCH 03/20] fix(vis-web): allow title prop on Mono component --- apps/vis/web/src/components/wire/parts.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/vis/web/src/components/wire/parts.tsx b/apps/vis/web/src/components/wire/parts.tsx index dfdcb90ea2..1c1417e7b4 100644 --- a/apps/vis/web/src/components/wire/parts.tsx +++ b/apps/vis/web/src/components/wire/parts.tsx @@ -77,8 +77,8 @@ export function loopEventSummary(ev: LoopRecordedEvent): string { // ─── tiny presentational helpers ─── -export function Mono({ children, className = '' }: { children: ReactNode; className?: string }) { - return {children}; +export function Mono({ children, className = '', title }: { children: ReactNode; className?: string; title?: string }) { + return {children}; } export function Dim({ children, className = '' }: { children: ReactNode; className?: string }) { From dffdd6b3f0cf0fce526aa2b899699e87670f9664 Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Thu, 6 Aug 2026 23:24:29 +0800 Subject: [PATCH 04/20] feat(tui): optimize timestamp layout to single line header and add duration and config toggle --- apps/kimi-code/src/tui/commands/reload.ts | 1 + .../components/messages/assistant-message.ts | 47 ++++++++++++----- .../tui/components/messages/user-message.ts | 51 +++++++++++-------- apps/kimi-code/src/tui/config.ts | 5 ++ .../src/tui/controllers/session-replay.ts | 7 ++- .../src/tui/controllers/streaming-ui.ts | 8 ++- apps/kimi-code/src/tui/kimi-tui.ts | 16 +++++- apps/kimi-code/src/tui/types.ts | 1 + apps/kimi-code/src/tui/utils/format-time.ts | 36 ++++++++++--- .../kimi-code/src/tui/utils/message-replay.ts | 4 +- .../messages/assistant-message.test.ts | 6 ++- .../components/messages/user-message.test.ts | 3 +- .../test/tui/utils/format-time.test.ts | 48 +++++++++++++++++ 13 files changed, 183 insertions(+), 50 deletions(-) create mode 100644 apps/kimi-code/test/tui/utils/format-time.test.ts diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 15dc411651..234ec30354 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -63,6 +63,7 @@ export async function applyReloadedTuiConfig( host.setAppState({ editorCommand: config.editorCommand, disablePasteBurst: config.disablePasteBurst, + showTimestamp: config.showTimestamp, notifications: config.notifications, upgrade: config.upgrade, statusLine: config.statusLine, 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 e333856949..96b8b53420 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -26,12 +26,21 @@ export class AssistantMessageComponent implements Component { 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, timestamp?: number) { + 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(); } @@ -45,6 +54,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; @@ -107,21 +128,19 @@ export class AssistantMessageComponent implements Component { return this.renderCache.lines; } - const formattedTime = formatTimestamp(this.timestamp); - const timeText = this.timestamp && this.showBullet && formattedTime.length > 0 - ? currentTheme.dim(`[${formattedTime}] `) - : ''; - const prefixHeader = this.showBullet - ? currentTheme.fg('text', STATUS_BULLET) + timeText - : MESSAGE_INDENT; - const prefixWidth = visibleWidth(prefixHeader); - const contentWidth = Math.max(1, safeWidth - prefixWidth); - const contentLines = this.contentContainer.render(contentWidth); - const lines: string[] = ['']; + const formattedTime = this.showTimestamp ? formatTimestamp(this.timestamp, this.endedAt) : ''; + + if (this.showBullet) { + const headerText = formattedTime.length > 0 ? `${STATUS_BULLET}${formattedTime}` : STATUS_BULLET; + lines.push(currentTheme.dim(headerText)); + } else if (formattedTime.length > 0) { + lines.push(currentTheme.dim(formattedTime)); + } + + const contentLines = this.contentContainer.render(safeWidth); for (let i = 0; i < contentLines.length; i++) { - const p = i === 0 ? prefixHeader : ' '.repeat(prefixWidth); - lines.push(p + contentLines[i]); + lines.push(contentLines[i]); } 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 90ea606fce..790984938b 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -15,19 +15,33 @@ 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, timestamp?: number) { + 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; } @@ -51,14 +65,6 @@ export class UserMessageComponent implements Component { return this.renderCache.lines; } - const marker = this.bullet ?? USER_MESSAGE_BULLET; - const bulletStr = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : ''; - const formattedTime = formatTimestamp(this.timestamp); - const timeTag = formattedTime.length > 0 ? currentTheme.dim(`[${formattedTime}] `) : ''; - const prefixHeader = bulletStr + timeTag; - const bulletWidth = visibleWidth(prefixHeader); - const contentWidth = Math.max(1, safeWidth - bulletWidth); - const lines: string[] = []; // Spacer @@ -66,29 +72,32 @@ export class UserMessageComponent implements Component { lines.push(line); } + const marker = this.bullet ?? USER_MESSAGE_BULLET; + const formattedTime = this.showTimestamp ? formatTimestamp(this.timestamp) : ''; + + if (formattedTime.length > 0) { + lines.push(currentTheme.dim(`${marker}${formattedTime}`)); + } else if (marker.length > 0) { + lines.push(currentTheme.boldFg('roleUser', marker)); + } + // 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 ? prefixHeader : ' '.repeat(bulletWidth); - lines.push(prefix + textLines[i]); + const textLines = new Text(coloredText, 0, 0).render(safeWidth); + for (const line of textLines) { + lines.push(line); } - // Images — indented to align with text after the bullet + // Images 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 36f43ba07b..69ee797843 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(), editor: z .object({ command: z.string().optional(), @@ -76,6 +77,7 @@ export const TuiConfigFileSchema = z.object({ export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, disablePasteBurst: z.boolean(), + showTimestamp: z.boolean(), editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, upgrade: UpgradePreferencesSchema, @@ -101,6 +103,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', disablePasteBurst: false, + showTimestamp: true, editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, upgrade: DEFAULT_UPGRADE_PREFERENCES, @@ -186,6 +189,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, editorCommand: command === undefined || command.length === 0 ? null : command, notifications: { enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled, @@ -234,6 +238,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 [editor] command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index b4e795ba93..14dc01b856 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -245,9 +245,13 @@ export class SessionReplayRenderer { 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); @@ -400,6 +404,7 @@ export class SessionReplayRenderer { const thinking = context.assistant.thinking.join(''); const text = context.assistant.text.join(''); const createdAt = context.assistant.createdAt; + const completedAt = context.assistant.completedAt; context.assistant = { thinking: [], text: [] }; this.applyStepContext(context); @@ -410,7 +415,7 @@ export class SessionReplayRenderer { if (text.length > 0) { 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 93c64ea951..b897a3b63a 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -595,6 +595,7 @@ export class StreamingUIController { this._pendingAgentGroup = null; this._pendingReadGroup = null; const timestamp = createdAt ?? Date.now(); + const showTimestamp = state.appState.showTimestamp ?? true; const entry = { id: nextTranscriptId(), kind: 'assistant' as const, @@ -604,7 +605,7 @@ export class StreamingUIController { modelText: true, createdAt: timestamp, }; - const component = new AssistantMessageComponent(true, timestamp); + const component = new AssistantMessageComponent(true, timestamp, undefined, showTimestamp); this._streamingBlock = { component, entry }; this.host.pushTranscriptEntry(entry); state.transcriptContainer.addChild(component); @@ -620,9 +621,12 @@ export class StreamingUIController { } } - onStreamingTextEnd(): void { + onStreamingTextEnd(endedAt?: number): void { const block = this._streamingBlock; if (block !== null) { + const endTimestamp = endedAt ?? Date.now(); + block.entry.endedAt = endTimestamp; + block.component.setEndedAt(endTimestamp); block.component.updateContent(block.entry.content, { transient: false }); } this._streamingBlock = null; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 5bbccc9afc..824b788786 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2160,12 +2160,19 @@ export class KimiTUI { return block; } + const showTimestamp = this.state.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, entry.createdAt); + return new UserMessageComponent( + entry.content, + images, + entry.bullet, + entry.createdAt, + showTimestamp, + ); } case 'skill_activation': return new SkillActivationComponent( @@ -2192,7 +2199,12 @@ export class KimiTUI { if (entry.content.trimStart().startsWith('✓ Goal complete')) { return new GoalCompletionMessageComponent(entry.content); } - const component = new AssistantMessageComponent(true, entry.createdAt); + 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 f072c49f42..34ebd32d70 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; notifications: NotificationsConfig; upgrade: UpgradePreferences; /** Footer status line customization from tui.toml; absent means the default layout. */ diff --git a/apps/kimi-code/src/tui/utils/format-time.ts b/apps/kimi-code/src/tui/utils/format-time.ts index 6f37f698b5..022a34b30a 100644 --- a/apps/kimi-code/src/tui/utils/format-time.ts +++ b/apps/kimi-code/src/tui/utils/format-time.ts @@ -1,14 +1,38 @@ /** - * Formats an epoch millisecond timestamp into HH:MM:SS format (local time). + * 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. (耗时 3s). * Returns empty string if timestamp is invalid or undefined. */ -export function formatTimestamp(timestampMs?: number): string { +export function formatTimestamp(timestampMs?: number, endedAtMs?: number): string { if (timestampMs === undefined || timestampMs === 0 || !Number.isFinite(timestampMs)) { return ''; } const date = new Date(timestampMs); - const h = String(date.getHours()).padStart(2, '0'); - const m = String(date.getMinutes()).padStart(2, '0'); - const s = String(date.getSeconds()).padStart(2, '0'); - return `${h}:${m}:${s}`; + 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 += ` (耗时 ${durationSec}s)`; + } else { + const min = Math.floor(durationSec / 60); + const sec = durationSec % 60; + text += ` (耗时 ${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 4bc16fa568..dab63e3abe 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -30,6 +30,7 @@ export interface ReplayRenderContext { thinking: string[]; text: string[]; createdAt?: number; + completedAt?: number; }; toolCalls: Map; completedToolCallIds: Set; @@ -145,7 +146,7 @@ export function replayEntry( kind: TranscriptEntry['kind'], content: string, renderMode: TranscriptEntry['renderMode'], - extras: { detail?: string; bullet?: string; createdAt?: number } = {}, + extras: { detail?: string; bullet?: string; createdAt?: number; endedAt?: number } = {}, ): TranscriptEntry { return { id: nextTranscriptId(), @@ -156,6 +157,7 @@ export function replayEntry( detail: extras.detail, bullet: extras.bullet, createdAt: extras.createdAt, + endedAt: extras.endedAt, }; } 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 0c815ee921..be47758cde 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,7 +32,8 @@ describe('AssistantMessageComponent', () => { component.updateContent('abcdef'); const lines = component.render(8).map(strip); - expect(lines).toEqual(['', `${STATUS_BULLET}abcdef`]); + expect(lines).toContain(`${STATUS_BULLET}`); + expect(lines).toContain('abcdef'); expect(visibleWidth(lines[1] ?? '')).toBe(8); }); @@ -132,6 +133,7 @@ describe('AssistantMessageComponent', () => { component.updateContent('hello assistant'); const lines = component.render(80).map(strip); - expect(lines.some((l) => l.startsWith(`${STATUS_BULLET}[14:23:45] hello assistant`))).toBe(true); + expect(lines.some((l) => l.startsWith(`${STATUS_BULLET}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 082668bc26..df665af607 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 @@ -112,6 +112,7 @@ describe('UserMessageComponent', () => { const component = new UserMessageComponent('hello world', [], undefined, timestamp); const out = stripAnsi(component.render(80).join('\n')); - expect(out).toContain('✨ [14:23:45] hello world'); + expect(out).toContain('✨ 14:23:45'); + expect(out).toContain('hello world'); }); }); 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..d211049054 --- /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 (耗时 3s)'); + + const longEnd = start + 75000; // 75 seconds -> 1m15s + expect(formatTimestamp(start, longEnd)).toBe('10:00:00 (耗时 1m15s)'); + }); +}); From 19dd64792eb40de7db02b02b0cfe4430e142c32d Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Thu, 6 Aug 2026 23:29:46 +0800 Subject: [PATCH 05/20] fix(tui): fix typecheck and config schema for showTimestamp --- apps/kimi-code/src/tui/commands/config.ts | 1 + .../src/tui/components/messages/assistant-message.ts | 4 ++-- apps/kimi-code/src/tui/config.ts | 2 +- apps/kimi-code/src/tui/kimi-tui.ts | 2 +- apps/kimi-code/src/tui/types.ts | 1 + 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 221b39ecfb..b8c175fcb2 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -57,6 +57,7 @@ function currentTuiConfig(host: SlashCommandHost): TuiConfig { 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, notifications: host.state.appState.notifications, upgrade: host.state.appState.upgrade, }; 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 96b8b53420..2298956ef3 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -139,8 +139,8 @@ export class AssistantMessageComponent implements Component { } const contentLines = this.contentContainer.render(safeWidth); - for (let i = 0; i < contentLines.length; i++) { - lines.push(contentLines[i]); + 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/config.ts b/apps/kimi-code/src/tui/config.ts index 69ee797843..06212f9b4c 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -77,7 +77,7 @@ export const TuiConfigFileSchema = z.object({ export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, disablePasteBurst: z.boolean(), - showTimestamp: z.boolean(), + showTimestamp: z.boolean().optional(), editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, upgrade: UpgradePreferencesSchema, diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 824b788786..ae3e005d1e 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2160,7 +2160,7 @@ export class KimiTUI { return block; } - const showTimestamp = this.state.showTimestamp ?? true; + const showTimestamp = this.state.appState.showTimestamp ?? true; switch (entry.kind) { case 'user': { const images = entry.imageAttachmentIds diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 34ebd32d70..0dff6d8dd2 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -211,6 +211,7 @@ export interface TranscriptEntry { skillTrigger?: SkillActivationTrigger; pluginCommandData?: PluginCommandTranscriptData; createdAt?: number; + endedAt?: number; } export type LivePaneMode = From ccbf9c77bcec217a31bc0a550df9dc2e0b4ab641 Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Thu, 6 Aug 2026 23:33:28 +0800 Subject: [PATCH 06/20] feat(tui): complete single header timestamp layout with duration and hot-reloading show_timestamp config --- apps/kimi-code/test/tui/commands/update-preferences.test.ts | 1 + .../test/tui/components/messages/assistant-message.test.ts | 2 +- .../test/tui/components/messages/user-message.test.ts | 2 +- apps/kimi-code/test/tui/config.test.ts | 4 ++++ 4 files changed, 7 insertions(+), 2 deletions(-) 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 b584c33d63..b81c544cdf 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, 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 be47758cde..f48eab8f9c 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 @@ -133,7 +133,7 @@ describe('AssistantMessageComponent', () => { component.updateContent('hello assistant'); const lines = component.render(80).map(strip); - expect(lines.some((l) => l.startsWith(`${STATUS_BULLET}14:23:45`))).toBe(true); + 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 df665af607..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 @@ -112,7 +112,7 @@ describe('UserMessageComponent', () => { const component = new UserMessageComponent('hello world', [], undefined, timestamp); const out = stripAnsi(component.render(80).join('\n')); - expect(out).toContain('✨ 14:23:45'); + 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 664fda616f..2214a188c8 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -60,6 +60,7 @@ auto_install = false expect(config).toEqual({ theme: 'light', disablePasteBurst: false, + showTimestamp: true, editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -85,6 +86,7 @@ command = " " expect(config).toEqual({ theme: 'auto', disablePasteBurst: false, + showTimestamp: true, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -118,6 +120,7 @@ command = " " { theme: 'light', disablePasteBurst: false, + showTimestamp: true, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -129,6 +132,7 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', disablePasteBurst: false, + showTimestamp: true, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, From 33cf21c1b4b925b6f39366d7c29cb4246affcf55 Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Thu, 6 Aug 2026 23:35:45 +0800 Subject: [PATCH 07/20] fix(tui): resolve unit test assertions for showTimestamp config and timestamp header layout --- .../test/tui/components/messages/assistant-message.test.ts | 4 ++-- apps/kimi-code/test/tui/config.test.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) 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 f48eab8f9c..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 @@ -33,8 +33,8 @@ describe('AssistantMessageComponent', () => { const lines = component.render(8).map(strip); expect(lines).toContain(`${STATUS_BULLET}`); - expect(lines).toContain('abcdef'); - expect(visibleWidth(lines[1] ?? '')).toBe(8); + expect(lines.some((l) => l.includes('abcdef'))).toBe(true); + expect(visibleWidth(lines[1] ?? '')).toBe(2); }); it('keeps assistant lines within very narrow widths', () => { diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 2214a188c8..352d51bdd0 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -146,6 +146,7 @@ command = " " { theme, disablePasteBurst: DEFAULT_TUI_CONFIG.disablePasteBurst, + showTimestamp: DEFAULT_TUI_CONFIG.showTimestamp, editorCommand: null, notifications: DEFAULT_TUI_CONFIG.notifications, upgrade: DEFAULT_TUI_CONFIG.upgrade, From feb7fbd8a8cc6e53b0cbef53eb4163f8dc2f0720 Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Thu, 6 Aug 2026 23:48:46 +0800 Subject: [PATCH 08/20] style(tui): restore bright bullet symbols in header lines for user and assistant messages --- .../src/tui/components/messages/assistant-message.ts | 5 +++-- apps/kimi-code/src/tui/components/messages/user-message.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) 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 2298956ef3..b9b873d7be 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -132,8 +132,9 @@ export class AssistantMessageComponent implements Component { const formattedTime = this.showTimestamp ? formatTimestamp(this.timestamp, this.endedAt) : ''; if (this.showBullet) { - const headerText = formattedTime.length > 0 ? `${STATUS_BULLET}${formattedTime}` : STATUS_BULLET; - lines.push(currentTheme.dim(headerText)); + const bulletText = currentTheme.fg('text', 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)); } 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 790984938b..f258e2a675 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -76,7 +76,8 @@ export class UserMessageComponent implements Component { const formattedTime = this.showTimestamp ? formatTimestamp(this.timestamp) : ''; if (formattedTime.length > 0) { - lines.push(currentTheme.dim(`${marker}${formattedTime}`)); + 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)); } From 9200dcd53ab2a9f366b7d3140743338119f81d0f Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Thu, 6 Aug 2026 23:52:15 +0800 Subject: [PATCH 09/20] style(tui): use bold primary color for Assistant message bullet symbol --- apps/kimi-code/src/tui/components/messages/assistant-message.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b9b873d7be..c46b60b8c0 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -132,7 +132,7 @@ export class AssistantMessageComponent implements Component { const formattedTime = this.showTimestamp ? formatTimestamp(this.timestamp, this.endedAt) : ''; if (this.showBullet) { - const bulletText = currentTheme.fg('text', STATUS_BULLET); + const bulletText = currentTheme.boldFg('primary', STATUS_BULLET); const headerText = formattedTime.length > 0 ? `${bulletText}${currentTheme.dim(formattedTime)}` : bulletText; lines.push(headerText); } else if (formattedTime.length > 0) { From 5724bb2babda6354440c71567d4cfb68076a8f27 Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Thu, 6 Aug 2026 23:55:28 +0800 Subject: [PATCH 10/20] style(tui): set Assistant message bullet to bright bold white textStrong --- apps/kimi-code/src/tui/components/messages/assistant-message.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c46b60b8c0..04de4e3438 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -132,7 +132,7 @@ export class AssistantMessageComponent implements Component { const formattedTime = this.showTimestamp ? formatTimestamp(this.timestamp, this.endedAt) : ''; if (this.showBullet) { - const bulletText = currentTheme.boldFg('primary', STATUS_BULLET); + 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) { From 9b64f6bf34a3f0608ef8bb68f735105ce65cafe6 Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Thu, 6 Aug 2026 23:57:31 +0800 Subject: [PATCH 11/20] style(tui): restore original white fg text color for Assistant message bullet symbol --- apps/kimi-code/src/tui/components/messages/assistant-message.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 04de4e3438..b9b873d7be 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -132,7 +132,7 @@ export class AssistantMessageComponent implements Component { const formattedTime = this.showTimestamp ? formatTimestamp(this.timestamp, this.endedAt) : ''; if (this.showBullet) { - const bulletText = currentTheme.boldFg('textStrong', STATUS_BULLET); + const bulletText = currentTheme.fg('text', STATUS_BULLET); const headerText = formattedTime.length > 0 ? `${bulletText}${currentTheme.dim(formattedTime)}` : bulletText; lines.push(headerText); } else if (formattedTime.length > 0) { From 3dadc5d280785b084c00a74fbc865b33b867ddb3 Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Thu, 6 Aug 2026 23:59:38 +0800 Subject: [PATCH 12/20] style(tui): use bold textStrong for Assistant status bullet --- apps/kimi-code/src/tui/components/messages/assistant-message.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b9b873d7be..04de4e3438 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -132,7 +132,7 @@ export class AssistantMessageComponent implements Component { const formattedTime = this.showTimestamp ? formatTimestamp(this.timestamp, this.endedAt) : ''; if (this.showBullet) { - const bulletText = currentTheme.fg('text', STATUS_BULLET); + 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) { From f0c6f5a2a4275a65ef141bca6ae5fcda4ed65ff7 Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Fri, 7 Aug 2026 00:19:06 +0800 Subject: [PATCH 13/20] style(tui): format duration as English (took Xs) / (took XmYs) --- apps/kimi-code/src/tui/utils/format-time.ts | 4 ++-- apps/kimi-code/test/tui/utils/format-time.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/kimi-code/src/tui/utils/format-time.ts b/apps/kimi-code/src/tui/utils/format-time.ts index 022a34b30a..275af8c91d 100644 --- a/apps/kimi-code/src/tui/utils/format-time.ts +++ b/apps/kimi-code/src/tui/utils/format-time.ts @@ -26,11 +26,11 @@ export function formatTimestamp(timestampMs?: number, endedAtMs?: number): strin if (endedAtMs !== undefined && Number.isFinite(endedAtMs) && endedAtMs >= timestampMs) { const durationSec = Math.max(0, Math.round((endedAtMs - timestampMs) / 1000)); if (durationSec < 60) { - text += ` (耗时 ${durationSec}s)`; + text += ` (took ${durationSec}s)`; } else { const min = Math.floor(durationSec / 60); const sec = durationSec % 60; - text += ` (耗时 ${min}m${sec}s)`; + text += ` (took ${min}m${sec}s)`; } } diff --git a/apps/kimi-code/test/tui/utils/format-time.test.ts b/apps/kimi-code/test/tui/utils/format-time.test.ts index d211049054..6d0759cc5a 100644 --- a/apps/kimi-code/test/tui/utils/format-time.test.ts +++ b/apps/kimi-code/test/tui/utils/format-time.test.ts @@ -40,9 +40,9 @@ describe('formatTimestamp', () => { ).getTime(); const end = start + 3400; // 3.4 seconds -> 3s - expect(formatTimestamp(start, end)).toBe('10:00:00 (耗时 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 (耗时 1m15s)'); + expect(formatTimestamp(start, longEnd)).toBe('10:00:00 (took 1m15s)'); }); }); From 0ed92830c9036be3c0d07e64d4d0d1f9d29dc5ab Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Fri, 7 Aug 2026 00:26:36 +0800 Subject: [PATCH 14/20] fix(tui): handle historical session replay timestamp compatibility cleanly --- .../components/messages/assistant-message.ts | 33 ++++++++++----- .../tui/components/messages/user-message.ts | 40 ++++++++++++------- .../src/tui/controllers/streaming-ui.ts | 12 ++++-- 3 files changed, 55 insertions(+), 30 deletions(-) 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 04de4e3438..0ea40e9eb4 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -131,17 +131,28 @@ export class AssistantMessageComponent implements Component { const lines: string[] = ['']; 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); + if (formattedTime.length > 0) { + if (this.showBullet) { + const bulletText = currentTheme.boldFg('textStrong', STATUS_BULLET); + lines.push(`${bulletText}${currentTheme.dim(formattedTime)}`); + } else { + lines.push(currentTheme.dim(formattedTime)); + } + const contentLines = this.contentContainer.render(safeWidth); + for (const line of contentLines) { + lines.push(line); + } + } else { + const prefixHeader = this.showBullet + ? currentTheme.boldFg('textStrong', STATUS_BULLET) + : MESSAGE_INDENT; + const prefixWidth = visibleWidth(prefixHeader); + const contentWidth = Math.max(1, safeWidth - prefixWidth); + const contentLines = this.contentContainer.render(contentWidth); + for (let i = 0; i < contentLines.length; i++) { + const p = i === 0 ? prefixHeader : ' '.repeat(prefixWidth); + lines.push(p + contentLines[i]); + } } 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 f258e2a675..2fd206433a 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -78,24 +78,34 @@ export class UserMessageComponent implements Component { 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)); - } - - // 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(safeWidth); - for (const line of textLines) { - lines.push(line); - } - // Images - for (const thumbnail of this.imageThumbnails) { - const imageLines = thumbnail.render(safeWidth); - for (const line of imageLines) { + 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(safeWidth); + for (const line of imageLines) { + lines.push(line); + } + } + } else { + const bulletStr = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : ''; + const bulletWidth = visibleWidth(bulletStr); + const contentWidth = Math.max(1, safeWidth - bulletWidth); + 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 ? bulletStr : ' '.repeat(bulletWidth); + lines.push(prefix + textLines[i]); + } + for (const thumbnail of this.imageThumbnails) { + const imageLines = thumbnail.render(contentWidth); + for (const line of imageLines) { + lines.push(' '.repeat(bulletWidth) + line); + } + } } const rendered = lines.map((line) => { diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index b897a3b63a..0cf47ed0e9 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -594,7 +594,8 @@ export class StreamingUIController { const { state } = this.host; this._pendingAgentGroup = null; this._pendingReadGroup = null; - const timestamp = createdAt ?? Date.now(); + const isReplaying = state.appState.isReplaying ?? false; + const timestamp = createdAt ?? (isReplaying ? undefined : Date.now()); const showTimestamp = state.appState.showTimestamp ?? true; const entry = { id: nextTranscriptId(), @@ -624,9 +625,12 @@ export class StreamingUIController { onStreamingTextEnd(endedAt?: number): void { const block = this._streamingBlock; if (block !== null) { - const endTimestamp = endedAt ?? Date.now(); - block.entry.endedAt = endTimestamp; - block.component.setEndedAt(endTimestamp); + const isReplaying = this.host.state.appState.isReplaying ?? false; + const endTimestamp = endedAt ?? (isReplaying ? undefined : Date.now()); + if (endTimestamp !== undefined) { + block.entry.endedAt = endTimestamp; + block.component.setEndedAt(endTimestamp); + } block.component.updateContent(block.entry.content, { transient: false }); } this._streamingBlock = null; From ead1f27975a347fb1cf825bb17a37528b8f1b868 Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Fri, 7 Aug 2026 00:29:16 +0800 Subject: [PATCH 15/20] fix(tui): clean single-line header layout for replay compatibility --- .../components/messages/assistant-message.ts | 33 ++++++----------- .../tui/components/messages/user-message.ts | 36 ++++++------------- 2 files changed, 22 insertions(+), 47 deletions(-) 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 0ea40e9eb4..04de4e3438 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -131,28 +131,17 @@ export class AssistantMessageComponent implements Component { const lines: string[] = ['']; const formattedTime = this.showTimestamp ? formatTimestamp(this.timestamp, this.endedAt) : ''; - if (formattedTime.length > 0) { - if (this.showBullet) { - const bulletText = currentTheme.boldFg('textStrong', STATUS_BULLET); - lines.push(`${bulletText}${currentTheme.dim(formattedTime)}`); - } else { - lines.push(currentTheme.dim(formattedTime)); - } - const contentLines = this.contentContainer.render(safeWidth); - for (const line of contentLines) { - lines.push(line); - } - } else { - const prefixHeader = this.showBullet - ? currentTheme.boldFg('textStrong', STATUS_BULLET) - : MESSAGE_INDENT; - const prefixWidth = visibleWidth(prefixHeader); - const contentWidth = Math.max(1, safeWidth - prefixWidth); - const contentLines = this.contentContainer.render(contentWidth); - for (let i = 0; i < contentLines.length; i++) { - const p = i === 0 ? prefixHeader : ' '.repeat(prefixWidth); - lines.push(p + contentLines[i]); - } + 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 2fd206433a..e6cc5ebbf9 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -78,34 +78,20 @@ export class UserMessageComponent implements Component { 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)); + } - const coloredText = currentTheme.boldFg('roleUser', this.text); - const textLines = new Text(coloredText, 0, 0).render(safeWidth); - for (const line of textLines) { + 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(safeWidth); + for (const line of imageLines) { lines.push(line); } - for (const thumbnail of this.imageThumbnails) { - const imageLines = thumbnail.render(safeWidth); - for (const line of imageLines) { - lines.push(line); - } - } - } else { - const bulletStr = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : ''; - const bulletWidth = visibleWidth(bulletStr); - const contentWidth = Math.max(1, safeWidth - bulletWidth); - 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 ? bulletStr : ' '.repeat(bulletWidth); - lines.push(prefix + textLines[i]); - } - for (const thumbnail of this.imageThumbnails) { - const imageLines = thumbnail.render(contentWidth); - for (const line of imageLines) { - lines.push(' '.repeat(bulletWidth) + line); - } - } } const rendered = lines.map((line) => { From 8ea6ebcb929eabcd796b00f5ed7c9d100ad2401b Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Fri, 7 Aug 2026 00:33:44 +0800 Subject: [PATCH 16/20] feat(core): populate message createdAt and completedAt from journal record timestamps during session restore --- .../src/agent/contextMemory/contextTranscript.ts | 16 +++++++++++++++- .../src/agent/contextMemory/loopEventFold.ts | 2 +- .../agent-core-v2/src/kosong/contract/message.ts | 6 +++++- packages/agent-core/src/agent/context/index.ts | 2 ++ packages/agent-core/src/agent/context/types.ts | 2 ++ 5 files changed, 25 insertions(+), 3 deletions(-) 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/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 35fc946869..7d8a36d047 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -144,7 +144,7 @@ export function foldLoopEvent( switch (event.type) { case 'step.begin': { const settled = settleOpenStep(state, ctx); - const assistant: ContextMessage = { role: 'assistant', content: [], toolCalls: [], partial: true }; + const assistant: ContextMessage = { role: 'assistant', content: [], toolCalls: [], partial: true, createdAt: Date.now() }; ctx.openStepUuid = event.uuid; return bind([...settled, assistant], ctx); } diff --git a/packages/agent-core-v2/src/kosong/contract/message.ts b/packages/agent-core-v2/src/kosong/contract/message.ts index 5007a0ac38..98df5ab39b 100644 --- a/packages/agent-core-v2/src/kosong/contract/message.ts +++ b/packages/agent-core-v2/src/kosong/contract/message.ts @@ -67,6 +67,7 @@ export interface Message { readonly toolCallId?: string; readonly partial?: boolean; readonly tools?: readonly Tool[]; + readonly createdAt?: number; readonly completedAt?: number; } @@ -135,11 +136,12 @@ export function getTextContent(message: Message): string { return extractText(message); } -export function createUserMessage(content: string): Message { +export function createUserMessage(content: string, createdAt = Date.now()): Message { return { role: 'user', content: [{ type: 'text', text: content }], toolCalls: [], + createdAt, }; } @@ -147,11 +149,13 @@ export function createAssistantMessage( content: ContentPart[], toolCalls?: ToolCall[], completedAt?: number, + createdAt?: number, ): Message { return { role: 'assistant', content, toolCalls: toolCalls ?? [], + ...(createdAt !== undefined ? { createdAt } : {}), ...(completedAt !== undefined ? { completedAt } : {}), }; } diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 45de032d0b..bed4b1f897 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -91,6 +91,7 @@ export class ContextMemory { content: parts, toolCalls: [], origin, + createdAt: Date.now(), }); } @@ -673,6 +674,7 @@ export class ContextMemory { role: 'assistant', content: [], toolCalls: [], + createdAt: Date.now(), }; this.pushHistory(message); this.openSteps.set(event.uuid, message); diff --git a/packages/agent-core/src/agent/context/types.ts b/packages/agent-core/src/agent/context/types.ts index f4f6f7a4e9..95c606fe38 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 | undefined; + readonly completedAt?: number | undefined; readonly origin?: PromptOrigin | undefined; readonly isError?: boolean; /** From 7806c0ecdbc66ef58f0c316dadf350f7f2bfe423 Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Fri, 7 Aug 2026 00:40:41 +0800 Subject: [PATCH 17/20] fix(tui): fallback to AgentReplayRecord.time when rendering message timestamps in session replay --- .../src/tui/controllers/session-replay.ts | 17 ++++++++++------- apps/kimi-code/src/tui/utils/message-replay.ts | 3 ++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 14dc01b856..20d333fa39 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -201,7 +201,7 @@ export class SessionReplayRenderer { private renderRecord(context: ReplayRenderContext, record: AgentReplayRecord): void { switch (record.type) { case 'message': - this.renderMessage(context, record.message); + this.renderMessage(context, record.message, record.time); return; case 'compaction': this.renderCompaction(context, record); @@ -233,10 +233,10 @@ export class SessionReplayRenderer { } } - private renderMessage(context: ReplayRenderContext, message: ContextMessage): void { + private renderMessage(context: ReplayRenderContext, message: ContextMessage, recordTime?: number): void { switch (message.role) { case 'user': - this.renderUserMessage(context, message); + this.renderUserMessage(context, message, recordTime); return; case 'assistant': if (message.origin?.kind === 'hook_result') { @@ -244,7 +244,7 @@ export class SessionReplayRenderer { this.renderToolCalls(context, message.toolCalls); return; } - const msgTime = 'createdAt' in message && typeof message.createdAt === 'number' ? message.createdAt : undefined; + const msgTime = 'createdAt' in message && typeof message.createdAt === 'number' ? message.createdAt : recordTime; const msgCompletedTime = 'completedAt' in message && typeof message.completedAt === 'number' ? message.completedAt : undefined; if (msgTime !== undefined && context.assistant.createdAt === undefined) { context.assistant.createdAt = msgTime; @@ -252,6 +252,9 @@ export class SessionReplayRenderer { if (msgCompletedTime !== undefined) { context.assistant.completedAt = msgCompletedTime; } + if (recordTime !== undefined) { + context.assistant.lastTime = recordTime; + } collectReplayMessageContent(context.assistant, message.content); this.flushAssistant(context); this.renderToolCalls(context, message.toolCalls); @@ -346,7 +349,7 @@ export class SessionReplayRenderer { return; } - const userMsgTime = 'createdAt' in message && typeof message.createdAt === 'number' ? message.createdAt : undefined; + const userMsgTime = 'createdAt' in message && typeof message.createdAt === 'number' ? message.createdAt : recordTime; this.advanceTurn(context); this.host.appendTranscriptEntry( replayEntry(context, 'user', contentPartsToText(message.content), 'plain', { @@ -404,8 +407,8 @@ export class SessionReplayRenderer { const thinking = context.assistant.thinking.join(''); const text = context.assistant.text.join(''); const createdAt = context.assistant.createdAt; - const completedAt = context.assistant.completedAt; - context.assistant = { thinking: [], text: [] }; + const completedAt = context.assistant.completedAt ?? context.assistant.lastTime; + context.assistant = { thinking: [], text: [], createdAt: undefined, completedAt: undefined, lastTime: undefined }; this.applyStepContext(context); if (thinking.length > 0) { diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index dab63e3abe..0391ef177b 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -31,6 +31,7 @@ export interface ReplayRenderContext { text: string[]; createdAt?: number; completedAt?: number; + lastTime?: number; }; toolCalls: Map; completedToolCallIds: Set; @@ -122,7 +123,7 @@ export function createReplayRenderContext(): ReplayRenderContext { turnIndex: 0, stepIndex: 0, currentTurnId: undefined, - assistant: { thinking: [], text: [] }, + assistant: { thinking: [], text: [], createdAt: undefined, completedAt: undefined, lastTime: undefined }, toolCalls: new Map(), completedToolCallIds: new Set(), skillActivationIds: new Set(), From 246f558cd59d3cbf09dcddceec8461b6a9608915 Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Tue, 11 Aug 2026 11:06:56 +0800 Subject: [PATCH 18/20] fix(tui): preserve replay timestamps from journal --- .changeset/fix-replayed-message-timing.md | 5 + .../components/messages/assistant-message.ts | 3 +- .../tui/components/messages/user-message.ts | 2 +- .../src/tui/controllers/session-replay.ts | 20 ++-- apps/kimi-code/src/tui/utils/format-time.ts | 2 +- .../kimi-code/src/tui/utils/message-replay.ts | 3 +- .../kimi-code/test/tui/message-replay.test.ts | 52 +++++++++ apps/vis/web/src/components/wire/parts.tsx | 11 +- .../vis/web/src/components/wire/renderers.tsx | 6 +- docs/en/configuration/data-locations.md | 4 +- docs/zh/configuration/data-locations.md | 4 +- .../agent-core-v2/docs/wire-manifest.d.ts | 2 + .../src/agent/contextMemory/loopEventFold.ts | 4 +- .../src/agent/contextMemory/types.ts | 2 + .../src/kosong/contract/message.ts | 14 +-- .../contextMemory/contextTranscript.test.ts | 31 +++++ .../agent-core/src/agent/context/index.ts | 28 ++++- .../agent-core/src/agent/context/types.ts | 4 +- .../agent-core/src/agent/records/index.ts | 2 +- packages/agent-core/src/agent/replay/index.ts | 39 +++++-- packages/agent-core/test/agent/resume.test.ts | 106 ++++++++++++++++++ .../node-sdk/test/sdk-rpc-client-v2.test.ts | 65 ++++++++++- 22 files changed, 343 insertions(+), 66 deletions(-) create mode 100644 .changeset/fix-replayed-message-timing.md 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/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index 04de4e3438..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,9 +5,8 @@ * 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'; 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 e6cc5ebbf9..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,7 +2,7 @@ * 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'; diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 20d333fa39..56fc55ef06 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -201,7 +201,7 @@ export class SessionReplayRenderer { private renderRecord(context: ReplayRenderContext, record: AgentReplayRecord): void { switch (record.type) { case 'message': - this.renderMessage(context, record.message, record.time); + this.renderMessage(context, record.message); return; case 'compaction': this.renderCompaction(context, record); @@ -233,18 +233,18 @@ export class SessionReplayRenderer { } } - private renderMessage(context: ReplayRenderContext, message: ContextMessage, recordTime?: number): void { + private renderMessage(context: ReplayRenderContext, message: ContextMessage): void { switch (message.role) { case 'user': - this.renderUserMessage(context, message, recordTime); + 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 : recordTime; + 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; @@ -252,13 +252,11 @@ export class SessionReplayRenderer { if (msgCompletedTime !== undefined) { context.assistant.completedAt = msgCompletedTime; } - if (recordTime !== undefined) { - context.assistant.lastTime = recordTime; - } collectReplayMessageContent(context.assistant, message.content); this.flushAssistant(context); this.renderToolCalls(context, message.toolCalls); return; + } case 'tool': this.flushAssistant(context); this.renderToolResult(context, message); @@ -349,7 +347,7 @@ export class SessionReplayRenderer { return; } - const userMsgTime = 'createdAt' in message && typeof message.createdAt === 'number' ? message.createdAt : recordTime; + 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', { @@ -407,8 +405,8 @@ export class SessionReplayRenderer { const thinking = context.assistant.thinking.join(''); const text = context.assistant.text.join(''); const createdAt = context.assistant.createdAt; - const completedAt = context.assistant.completedAt ?? context.assistant.lastTime; - context.assistant = { thinking: [], text: [], createdAt: undefined, completedAt: undefined, lastTime: undefined }; + const completedAt = context.assistant.completedAt; + context.assistant = { thinking: [], text: [], createdAt: undefined, completedAt: undefined }; this.applyStepContext(context); if (thinking.length > 0) { diff --git a/apps/kimi-code/src/tui/utils/format-time.ts b/apps/kimi-code/src/tui/utils/format-time.ts index 275af8c91d..d53b457f06 100644 --- a/apps/kimi-code/src/tui/utils/format-time.ts +++ b/apps/kimi-code/src/tui/utils/format-time.ts @@ -1,6 +1,6 @@ /** * 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. (耗时 3s). + * 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 { diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index 0391ef177b..e2414a95c2 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -31,7 +31,6 @@ export interface ReplayRenderContext { text: string[]; createdAt?: number; completedAt?: number; - lastTime?: number; }; toolCalls: Map; completedToolCallIds: Set; @@ -123,7 +122,7 @@ export function createReplayRenderContext(): ReplayRenderContext { turnIndex: 0, stepIndex: 0, currentTurnId: undefined, - assistant: { thinking: [], text: [], createdAt: undefined, completedAt: undefined, lastTime: undefined }, + assistant: { thinking: [], text: [], createdAt: undefined, completedAt: undefined }, toolCalls: new Map(), completedToolCallIds: new Set(), skillActivationIds: new Set(), diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index d944a485f8..657ce0b941 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -1,3 +1,9 @@ +/** + * 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 { @@ -80,6 +86,8 @@ function message( readonly toolCallId?: string; readonly origin?: PromptOrigin; readonly isError?: boolean; + readonly createdAt?: number; + readonly completedAt?: number; } = {}, ): AgentReplayRecord { return { @@ -92,6 +100,8 @@ function message( toolCallId: extra.toolCallId, origin: extra.origin, isError: extra.isError, + createdAt: extra.createdAt, + completedAt: extra.completedAt, }, }; } @@ -299,6 +309,48 @@ 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('does not render legacy goal completion context reminders as transcript messages', async () => { const driver = await replayIntoDriver([ message( diff --git a/apps/vis/web/src/components/wire/parts.tsx b/apps/vis/web/src/components/wire/parts.tsx index 1c1417e7b4..246900bf48 100644 --- a/apps/vis/web/src/components/wire/parts.tsx +++ b/apps/vis/web/src/components/wire/parts.tsx @@ -11,7 +11,6 @@ import type { ReactNode } from 'react'; import type { ContentPart, ContextMessage, LoopRecordedEvent, ToolCall } from '../../types'; -import { formatAbsoluteTime, formatWallClock } from '../../util/time'; import { ImagePreview } from '../shared/ImagePreview'; import { JsonViewer } from '../shared/JsonViewer'; import { SizePreview } from '../shared/SizePreview'; @@ -77,8 +76,8 @@ export function loopEventSummary(ev: LoopRecordedEvent): string { // ─── tiny presentational helpers ─── -export function Mono({ children, className = '', title }: { children: ReactNode; className?: string; title?: string }) { - return {children}; +export function Mono({ children, className = '' }: { children: ReactNode; className?: string }) { + return {children}; } export function Dim({ children, className = '' }: { children: ReactNode; className?: string }) { @@ -177,18 +176,12 @@ function ToolCallView({ call }: { call: ToolCall }) { } export function MessageDetail({ message }: { message: ContextMessage }) { - const completedAt = (message as { completedAt?: number }).completedAt; return (
"{message.role}" - {completedAt ? ( - - {formatWallClock(completedAt)} - - ) : null} {message.toolCallId ? ( {message.toolCallId} diff --git a/apps/vis/web/src/components/wire/renderers.tsx b/apps/vis/web/src/components/wire/renderers.tsx index 5776926893..d59b239cf0 100644 --- a/apps/vis/web/src/components/wire/renderers.tsx +++ b/apps/vis/web/src/components/wire/renderers.tsx @@ -23,9 +23,8 @@ import { truncate, loopEventSummary, } from './parts'; -import { JsonViewer } from '../shared/JsonViewer'; import { SizePreview } from '../shared/SizePreview'; -import { formatWallClock } from '../../util/time'; +import { JsonViewer } from '../shared/JsonViewer'; export type RecordType = AgentRecord['type']; @@ -215,9 +214,6 @@ export const WIRE_RENDERERS: RendererMap = { ({m.content.length} part{m.content.length === 1 ? '' : 's'}) {tc ? · {tc} : null} {m.origin?.kind ? · origin={m.origin.kind} : null} - {(m as { completedAt?: number }).completedAt ? ( - · completed at {formatWallClock((m as { completedAt?: number }).completedAt!)} - ) : null} ), right: m.isError === true ? ( 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 5aac50f61e..8f7700c2b1 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/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 7d8a36d047..325b94ba90 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -144,7 +144,7 @@ export function foldLoopEvent( switch (event.type) { case 'step.begin': { const settled = settleOpenStep(state, ctx); - const assistant: ContextMessage = { role: 'assistant', content: [], toolCalls: [], partial: true, createdAt: Date.now() }; + const assistant: ContextMessage = { role: 'assistant', content: [], toolCalls: [], partial: true }; ctx.openStepUuid = event.uuid; return bind([...settled, assistant], ctx); } @@ -216,7 +216,7 @@ function settleOpenStep( return [...closed.slice(0, index), ...closed.slice(index + 1)]; } const next = closed.slice(); - next[index] = { ...open, partial: undefined, completedAt: open.completedAt ?? Date.now() }; + next[index] = { ...open, partial: undefined }; return next; } 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/src/kosong/contract/message.ts b/packages/agent-core-v2/src/kosong/contract/message.ts index 98df5ab39b..0c44556e70 100644 --- a/packages/agent-core-v2/src/kosong/contract/message.ts +++ b/packages/agent-core-v2/src/kosong/contract/message.ts @@ -67,8 +67,6 @@ export interface Message { readonly toolCallId?: string; readonly partial?: boolean; readonly tools?: readonly Tool[]; - readonly createdAt?: number; - readonly completedAt?: number; } export function isContentPart(part: StreamedMessagePart): part is ContentPart { @@ -136,27 +134,19 @@ export function getTextContent(message: Message): string { return extractText(message); } -export function createUserMessage(content: string, createdAt = Date.now()): Message { +export function createUserMessage(content: string): Message { return { role: 'user', content: [{ type: 'text', text: content }], toolCalls: [], - createdAt, }; } -export function createAssistantMessage( - content: ContentPart[], - toolCalls?: ToolCall[], - completedAt?: number, - createdAt?: number, -): Message { +export function createAssistantMessage(content: ContentPart[], toolCalls?: ToolCall[]): Message { return { role: 'assistant', content, toolCalls: toolCalls ?? [], - ...(createdAt !== undefined ? { createdAt } : {}), - ...(completedAt !== undefined ? { completedAt } : {}), }; } 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 c016a62bdb..57c87fde93 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -147,6 +147,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 bed4b1f897..32628d08e1 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -91,7 +91,6 @@ export class ContextMemory { content: parts, toolCalls: [], origin, - createdAt: Date.now(), }); } @@ -650,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,8 +676,8 @@ export class ContextMemory { role: 'assistant', content: [], toolCalls: [], - createdAt: Date.now(), }; + this.agent.replayBuilder.setMessageTiming(message, { createdAt: eventTime }); this.pushHistory(message); this.openSteps.set(event.uuid, message); return; @@ -683,8 +685,8 @@ export class ContextMemory { case 'step.end': { const openStep = this.openSteps.get(event.uuid); this.openSteps.delete(event.uuid); - if (openStep !== undefined && (openStep as { completedAt?: number }).completedAt === undefined) { - (openStep as { completedAt?: number }).completedAt = Date.now(); + if (openStep !== undefined) { + this.agent.replayBuilder.setMessageTiming(openStep, { completedAt: eventTime }); } if (event.usage !== undefined) { const openStepIndex = openStep === undefined ? -1 : this._history.indexOf(openStep); @@ -767,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 { @@ -807,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 95c606fe38..e972854d97 100644 --- a/packages/agent-core/src/agent/context/types.ts +++ b/packages/agent-core/src/agent/context/types.ts @@ -102,8 +102,8 @@ export type PromptOrigin = | RetryOrigin; export type ContextMessage = Message & { - readonly createdAt?: number | undefined; - readonly completedAt?: number | undefined; + 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 aa2a3b2e5b..630f5651ea 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -313,7 +313,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 }, @@ -322,6 +327,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); From 7c00a40b206ad7bfb2d737f4593eb00fa1923b5d Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Tue, 11 Aug 2026 12:51:44 +0800 Subject: [PATCH 19/20] fix(tui): apply message timing settings consistently --- apps/kimi-code/src/tui/commands/reload.ts | 13 ++++- .../tui/controllers/session-event-handler.ts | 5 +- .../src/tui/controllers/streaming-ui.ts | 6 ++- apps/kimi-code/src/tui/kimi-tui.ts | 1 + .../test/tui/commands/reload.test.ts | 31 ++++++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 48 +++++++++++++++++++ .../test/tui/kimi-tui-startup.test.ts | 7 +++ 7 files changed, 106 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 289eea3c1b..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,13 +66,21 @@ export async function applyReloadedTuiConfig( host.setAppState({ editorCommand: config.editorCommand, disablePasteBurst: config.disablePasteBurst, - showTimestamp: config.showTimestamp, + 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/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 0eff25feb1..528bdb78d0 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,7 +405,7 @@ export class SessionEventHandler { }); this.host.setAppState({ streamingPhase: 'waiting', - streamingStartTime: Date.now(), + streamingStartTime: startedAt, }); } diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 0cf47ed0e9..023a769b29 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -52,6 +52,7 @@ 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; @@ -90,8 +91,9 @@ export class StreamingUIController { this._currentTurnId = turnId; } - setStep(step: number): void { + setStep(step: number, startedAt?: number): void { this._currentStep = step; + this._currentStepStartedAt = startedAt; } hasActiveTurn(): boolean { @@ -109,7 +111,7 @@ export class StreamingUIController { appendAssistantDelta(delta: string): void { if (this._streamingBlock === null) { - this.onStreamingTextStart(); + this.onStreamingTextStart(this._currentStepStartedAt); } this._assistantDraft += delta; this.pendingAssistantFlush = true; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 02bf38767f..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, 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/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index acd25f34ad..ea299f17bb 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,54 @@ 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.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + reason: 'completed', + } 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('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 () => ({ From 7addf06e6c0fb84434a281527dc4e660b0ded97d Mon Sep 17 00:00:00 2001 From: suntinapei <605682931@qq.com> Date: Tue, 11 Aug 2026 14:41:16 +0800 Subject: [PATCH 20/20] fix(tui): align live and replay step timing --- .../tui/controllers/session-event-handler.ts | 3 + .../src/tui/controllers/session-replay.ts | 2 + .../src/tui/controllers/streaming-ui.ts | 45 +++++-- .../test/tui/kimi-tui-message-flow.test.ts | 58 ++++++++- .../kimi-code/test/tui/message-replay.test.ts | 117 ++++++++++++++++++ 5 files changed, 216 insertions(+), 9 deletions(-) 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 528bdb78d0..8707eab94b 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -410,7 +410,9 @@ export class SessionEventHandler { } 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); @@ -468,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 1cf3e49627..570f45ca4f 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -291,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 { diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 023a769b29..b650ccdc26 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -55,7 +55,15 @@ export class StreamingUIController { 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(); @@ -96,6 +104,20 @@ export class StreamingUIController { 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 { return this._currentTurnId !== undefined; } @@ -394,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(); @@ -528,6 +552,7 @@ export class StreamingUIController { this.clearFlushTimerIfIdle(); this._assistantDraft = ''; this._streamingBlock = null; + this._assistantBlocksByStep.clear(); this._thinkingDraft = ''; this.disposeActiveThinkingComponent(); } @@ -561,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; @@ -609,7 +635,9 @@ export class StreamingUIController { createdAt: timestamp, }; const component = new AssistantMessageComponent(true, timestamp, undefined, showTimestamp); - this._streamingBlock = { component, entry }; + 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(); @@ -627,11 +655,10 @@ export class StreamingUIController { onStreamingTextEnd(endedAt?: number): void { const block = this._streamingBlock; if (block !== null) { - const isReplaying = this.host.state.appState.isReplaying ?? false; - const endTimestamp = endedAt ?? (isReplaying ? undefined : Date.now()); - if (endTimestamp !== undefined) { - block.entry.endedAt = endTimestamp; - block.component.setEndedAt(endTimestamp); + if (endedAt !== undefined) { + block.entry.endedAt = endedAt; + block.component.setEndedAt(endedAt); + this._assistantBlocksByStep.delete(block.stepKey); } block.component.updateContent(block.entry.content, { transient: false }); } @@ -919,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/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index ea299f17bb..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 @@ -491,11 +491,11 @@ describe('KimiTUI message flow', () => { ); driver.sessionEventHandler.handleEvent( { - type: 'turn.ended', + type: 'turn.step.completed', agentId: 'main', sessionId: 'ses-1', turnId: 1, - reason: 'completed', + step: 1, } as Event, vi.fn(), ); @@ -511,6 +511,60 @@ describe('KimiTUI message flow', () => { } }); + 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/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index be5849adcb..526fd6ba4e 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -10,6 +10,7 @@ import type { AgentReplayRecord, BackgroundTaskInfo, ContentPart, + Event, GoalSnapshot, PromptOrigin, ResumedAgentState, @@ -353,6 +354,106 @@ describe('KimiTUI resume message replay', () => { ); }); + 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( @@ -390,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(