Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7eba203
feat(tui): display generation timestamp for user and assistant messages
suntp Aug 5, 2026
c67e267
feat(vis-web): add completedAt field to AssistantMessage and display …
suntp Aug 6, 2026
766084c
fix(vis-web): allow title prop on Mono component
suntp Aug 6, 2026
dffdd6b
feat(tui): optimize timestamp layout to single line header and add du…
suntp Aug 6, 2026
19dd647
fix(tui): fix typecheck and config schema for showTimestamp
suntp Aug 6, 2026
ccbf9c7
feat(tui): complete single header timestamp layout with duration and …
suntp Aug 6, 2026
33cf21c
fix(tui): resolve unit test assertions for showTimestamp config and t…
suntp Aug 6, 2026
feb7fbd
style(tui): restore bright bullet symbols in header lines for user an…
suntp Aug 6, 2026
9200dcd
style(tui): use bold primary color for Assistant message bullet symbol
suntp Aug 6, 2026
5724bb2
style(tui): set Assistant message bullet to bright bold white textStrong
suntp Aug 6, 2026
9b64f6b
style(tui): restore original white fg text color for Assistant messag…
suntp Aug 6, 2026
3dadc5d
style(tui): use bold textStrong for Assistant status bullet
suntp Aug 6, 2026
f0c6f5a
style(tui): format duration as English (took Xs) / (took XmYs)
suntp Aug 6, 2026
0ed9283
fix(tui): handle historical session replay timestamp compatibility cl…
suntp Aug 6, 2026
ead1f27
fix(tui): clean single-line header layout for replay compatibility
suntp Aug 6, 2026
8ea6ebc
feat(core): populate message createdAt and completedAt from journal r…
suntp Aug 6, 2026
7806c0e
fix(tui): fallback to AgentReplayRecord.time when rendering message t…
suntp Aug 6, 2026
246f558
fix(tui): preserve replay timestamps from journal
suntp Aug 11, 2026
161c082
Merge remote-tracking branch 'origin/main' into feat/show-message-tim…
suntp Aug 11, 2026
7c00a40
fix(tui): apply message timing settings consistently
suntp Aug 11, 2026
7addf06
fix(tui): align live and replay step timing
suntp Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-replayed-message-timing.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export function currentTuiConfig(host: Pick<SlashCommandHost, 'state'>): TuiConf
theme: host.state.appState.theme,
editorCommand: host.state.appState.editorCommand,
disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst,
showTimestamp: host.state.appState.showTimestamp ?? DEFAULT_TUI_CONFIG.showTimestamp,
cacheExpiryHint: host.state.appState.cacheExpiryHint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint,
notifications: host.state.appState.notifications,
upgrade: host.state.appState.upgrade,
Expand Down
12 changes: 12 additions & 0 deletions apps/kimi-code/src/tui/commands/reload.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -55,6 +57,7 @@ export async function applyReloadedTuiConfig(
host: SlashCommandHost,
config: TuiConfig,
): Promise<void> {
const showTimestamp = config.showTimestamp ?? true;
const resolved = config.theme === 'auto'
? (currentTheme.palette === lightColors ? 'light' : 'dark')
: undefined;
Expand All @@ -63,12 +66,21 @@ export async function applyReloadedTuiConfig(
host.setAppState({
editorCommand: config.editorCommand,
disablePasteBurst: config.disablePasteBurst,
showTimestamp,
cacheExpiryHint: config.cacheExpiryHint,
notifications: config.notifications,
upgrade: config.upgrade,
statusLine: config.statusLine,
});
host.state.editor.setDisablePasteBurst(config.disablePasteBurst);
for (const component of host.state.transcriptContainer.children) {
if (
component instanceof UserMessageComponent ||
component instanceof AssistantMessageComponent
) {
component.setShowTimestamp(showTimestamp);
}
}
}

function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void {
Expand Down
50 changes: 39 additions & 11 deletions apps/kimi-code/src/tui/components/messages/assistant-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
* to align after the bullet.
*/

import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui';
import { Container, Markdown, truncateToWidth, type Component } from '@moonshot-ai/pi-tui';

import { MESSAGE_INDENT } from '#/tui/constant/rendering';
import { STATUS_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme';
import { formatTimestamp } from '#/tui/utils/format-time';
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';

type AssistantMarkdownOptions = {
Expand All @@ -24,11 +24,22 @@ export class AssistantMessageComponent implements Component {
private lastText = '';
private lastTransient = false;
private showBullet: boolean;
private timestamp?: number;
private endedAt?: number;
private showTimestamp = true;

private renderCache: { width: number; lines: string[] } | undefined;

constructor(showBullet: boolean = true) {
constructor(
showBullet: boolean = true,
timestamp?: number,
endedAt?: number,
showTimestamp = true,
) {
this.showBullet = showBullet;
this.timestamp = timestamp;
this.endedAt = endedAt;
this.showTimestamp = showTimestamp;
this.contentContainer = new Container();
}

Expand All @@ -42,6 +53,18 @@ export class AssistantMessageComponent implements Component {
this.markRenderDirty();
}

setEndedAt(endedAt?: number): void {
if (this.endedAt === endedAt) return;
this.endedAt = endedAt;
this.markRenderDirty();
}

setShowTimestamp(show: boolean): void {
if (this.showTimestamp === show) return;
this.showTimestamp = show;
this.markRenderDirty();
}

updateContent(text: string, opts?: AssistantMarkdownOptions): void {
const displayText = text.trim();
const transient = opts?.transient === true;
Expand Down Expand Up @@ -104,15 +127,20 @@ export class AssistantMessageComponent implements Component {
return this.renderCache.lines;
}

const prefix = this.showBullet ? STATUS_BULLET : MESSAGE_INDENT;
const contentWidth = Math.max(1, safeWidth - visibleWidth(prefix));
const contentLines = this.contentContainer.render(contentWidth);

const lines: string[] = [''];
for (let i = 0; i < contentLines.length; i++) {
const p =
i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT;
lines.push(p + contentLines[i]);
const formattedTime = this.showTimestamp ? formatTimestamp(this.timestamp, this.endedAt) : '';

if (this.showBullet) {
const bulletText = currentTheme.boldFg('textStrong', STATUS_BULLET);
const headerText = formattedTime.length > 0 ? `${bulletText}${currentTheme.dim(formattedTime)}` : bulletText;
lines.push(headerText);
} else if (formattedTime.length > 0) {
lines.push(currentTheme.dim(formattedTime));
}

const contentLines = this.contentContainer.render(safeWidth);
for (const line of contentLines) {
lines.push(line);
}
const rendered = lines.map((line) => truncateToWidth(line, safeWidth, '…'));
if (isRenderCacheEnabled()) {
Expand Down
56 changes: 34 additions & 22 deletions apps/kimi-code/src/tui/components/messages/user-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,46 @@
* Renders a user message in the transcript.
*/

import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui';
import { Spacer, Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui';

import { ImageThumbnail } from '#/tui/components/media/image-thumbnail';
import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import { formatTimestamp } from '#/tui/utils/format-time';
import type { ImageAttachment } from '#/tui/utils/image-attachment-store';
import { isRenderCacheEnabled } from '#/tui/utils/render-cache';

export class UserMessageComponent implements Component {
private text: string;
private readonly bullet?: string;
private readonly timestamp?: number;
private showTimestamp = true;
private spacerComponent: Spacer;
private imageThumbnails: ImageThumbnail[];

private renderCache: { width: number; lines: string[] } | undefined;

constructor(text: string, images?: ImageAttachment[], bullet?: string) {
constructor(
text: string,
images?: ImageAttachment[],
bullet?: string,
timestamp?: number,
showTimestamp = true,
) {
this.text = text;
this.bullet = bullet;
this.timestamp = timestamp;
this.showTimestamp = showTimestamp;
this.spacerComponent = new Spacer(1);
this.imageThumbnails = images?.map((img) => new ImageThumbnail(img)) ?? [];
}

setShowTimestamp(show: boolean): void {
if (this.showTimestamp === show) return;
this.showTimestamp = show;
this.markRenderDirty();
}

private markRenderDirty(): void {
this.renderCache = undefined;
}
Expand All @@ -48,41 +65,36 @@ export class UserMessageComponent implements Component {
return this.renderCache.lines;
}

const marker = this.bullet ?? USER_MESSAGE_BULLET;
const bullet = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : '';
const bulletWidth = visibleWidth(bullet);
const contentWidth = Math.max(1, safeWidth - bulletWidth);

const lines: string[] = [];

// Spacer
for (const line of this.spacerComponent.render(safeWidth)) {
lines.push(line);
}

// Text is re-dyed from the current theme; invalidate() (theme change) clears
// the render cache so the new colours are picked up on the next render.
const coloredText = currentTheme.boldFg('roleUser', this.text);
const textLines = new Text(coloredText, 0, 0).render(contentWidth);
for (let i = 0; i < textLines.length; i++) {
const prefix = i === 0 ? bullet : ' '.repeat(bulletWidth);
lines.push(prefix + textLines[i]);
const marker = this.bullet ?? USER_MESSAGE_BULLET;
const formattedTime = this.showTimestamp ? formatTimestamp(this.timestamp) : '';

if (formattedTime.length > 0) {
const headerMarker = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : '';
lines.push(`${headerMarker}${currentTheme.dim(formattedTime)}`);
} else if (marker.length > 0) {
lines.push(currentTheme.boldFg('roleUser', marker));
}

// Images — indented to align with text after the bullet
const coloredText = currentTheme.boldFg('roleUser', this.text);
const textLines = new Text(coloredText, 0, 0).render(safeWidth);
for (const line of textLines) {
lines.push(line);
}
for (const thumbnail of this.imageThumbnails) {
const imageLines = thumbnail.render(contentWidth);
const imageLines = thumbnail.render(safeWidth);
for (const line of imageLines) {
lines.push(' '.repeat(bulletWidth) + line);
lines.push(line);
}
}

const rendered = lines.map((line) => {
// Inline image sequences (Kitty / iTerm2) carry their own placement
// information and have zero visible width, but pi-tui's truncateToWidth
// treats the embedded base64 payload as visible text and would chop the
// escape sequence in half, leaving garbage like "0m...". Skip truncation
// for those lines; the image itself already respects maxWidthCells.
if (isImageLine(line)) return line;
return truncateToWidth(line, safeWidth, '…');
});
Expand Down
5 changes: 5 additions & 0 deletions apps/kimi-code/src/tui/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = {
export const TuiConfigFileSchema = z.object({
theme: TuiThemeSchema.optional(),
disable_paste_burst: z.boolean().optional(),
show_timestamp: z.boolean().optional(),
cache_expiry_hint: z.boolean().optional(),
editor: z
.object({
Expand All @@ -77,6 +78,7 @@ export const TuiConfigFileSchema = z.object({
export const TuiConfigSchema = z.object({
theme: TuiThemeSchema,
disablePasteBurst: z.boolean(),
showTimestamp: z.boolean().optional(),
/** Present in every normalized config; optional only so hand-built test
* fixtures from before this field existed still typecheck. */
cacheExpiryHint: z.boolean().optional(),
Expand Down Expand Up @@ -105,6 +107,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = {
export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({
theme: 'auto',
disablePasteBurst: false,
showTimestamp: true,
cacheExpiryHint: true,
editorCommand: null,
notifications: DEFAULT_NOTIFICATIONS_CONFIG,
Expand Down Expand Up @@ -191,6 +194,7 @@ export function normalizeTuiConfig(
return TuiConfigSchema.parse({
theme: config.theme ?? DEFAULT_TUI_CONFIG.theme,
disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst,
showTimestamp: config.show_timestamp ?? DEFAULT_TUI_CONFIG.showTimestamp,
cacheExpiryHint: config.cache_expiry_hint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint,
editorCommand: command === undefined || command.length === 0 ? null : command,
notifications: {
Expand Down Expand Up @@ -240,6 +244,7 @@ export function renderTuiConfig(config: TuiConfig): string {

theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name
disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback
show_timestamp = ${String(config.showTimestamp)} # true | false
cache_expiry_hint = ${String(config.cacheExpiryHint !== false)} # false disables the "cache expired" dialog on resume / idle submit

[editor]
Expand Down
8 changes: 6 additions & 2 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -404,12 +405,14 @@ export class SessionEventHandler {
});
this.host.setAppState({
streamingPhase: 'waiting',
streamingStartTime: Date.now(),
streamingStartTime: startedAt,
});
}

private handleStepCompleted(event: TurnStepCompletedEvent): void {
const completedAt = Date.now();
this.host.streamingUI.flushNow();
this.host.streamingUI.completeStep(String(event.turnId), event.step, completedAt);
this.host.noteStepUsage(event.usage);
this.maybeShowDebugTiming(event);

Expand Down Expand Up @@ -467,6 +470,7 @@ export class SessionEventHandler {
this.host.streamingUI.flushNow();
this.host.streamingUI.resetToolUi();
this.host.streamingUI.finalizeLiveTextBuffers('idle');
this.host.streamingUI.discardStep(String(event.turnId), event.step);
const reason = event.reason;
if (reason === 'error') return;
if (reason === 'aborted' || reason === undefined || reason === '') {
Expand Down
26 changes: 21 additions & 5 deletions apps/kimi-code/src/tui/controllers/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,16 +239,25 @@ export class SessionReplayRenderer {
case 'user':
this.renderUserMessage(context, message);
return;
case 'assistant':
case 'assistant': {
if (message.origin?.kind === 'hook_result') {
this.renderHookResult(context, message);
this.renderToolCalls(context, message.toolCalls);
return;
}
const msgTime = 'createdAt' in message && typeof message.createdAt === 'number' ? message.createdAt : undefined;
const msgCompletedTime = 'completedAt' in message && typeof message.completedAt === 'number' ? message.completedAt : undefined;
if (msgTime !== undefined && context.assistant.createdAt === undefined) {
context.assistant.createdAt = msgTime;
}
if (msgCompletedTime !== undefined) {
context.assistant.completedAt = msgCompletedTime;
}
collectReplayMessageContent(context.assistant, message.content);
this.flushAssistant(context);
this.renderToolCalls(context, message.toolCalls);
return;
}
case 'tool':
this.flushAssistant(context);
this.renderToolResult(context, message);
Expand Down Expand Up @@ -282,10 +291,12 @@ export class SessionReplayRenderer {
const text = contentPartsToText(message.content);
if (message.origin.phase === 'input') {
const cmd = (extractBashTag(text, 'bash-input') ?? text).trim();
const createdAt = typeof message.createdAt === 'number' ? message.createdAt : undefined;
this.advanceTurn(context);
this.host.appendTranscriptEntry(
replayEntry(context, 'user', currentTheme.fg('shellMode', `$ ${cmd}`), 'plain', {
bullet: '',
createdAt,
}),
);
} else {
Expand Down Expand Up @@ -339,9 +350,12 @@ export class SessionReplayRenderer {
return;
}

const userMsgTime = 'createdAt' in message && typeof message.createdAt === 'number' ? message.createdAt : undefined;
this.advanceTurn(context);
this.host.appendTranscriptEntry(
replayEntry(context, 'user', contentPartsToText(message.content), 'plain'),
replayEntry(context, 'user', contentPartsToText(message.content), 'plain', {
createdAt: userMsgTime,
}),
);
}

Expand Down Expand Up @@ -393,17 +407,19 @@ export class SessionReplayRenderer {
const { streamingUI } = this.host;
const thinking = context.assistant.thinking.join('');
const text = context.assistant.text.join('');
context.assistant = { thinking: [], text: [] };
const createdAt = context.assistant.createdAt;
const completedAt = context.assistant.completedAt;
context.assistant = { thinking: [], text: [], createdAt: undefined, completedAt: undefined };
this.applyStepContext(context);

if (thinking.length > 0) {
streamingUI.onThinkingUpdate(thinking);
streamingUI.onThinkingEnd();
}
if (text.length > 0) {
streamingUI.onStreamingTextStart();
streamingUI.onStreamingTextStart(createdAt);
streamingUI.onStreamingTextUpdate(text);
streamingUI.onStreamingTextEnd();
streamingUI.onStreamingTextEnd(completedAt);
streamingUI.clearAssistantDraft();
}
}
Expand Down
Loading