diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c2010c3730..e2b50ec0b0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Optional per-message timestamps in the interactive TUI, off by default and togglable from settings ([#1206](https://github.com/code-yeongyu/senpi/pull/1206)). + ### Fixed - The shared interactive host no longer surfaces raw `Error: Client not started` when its socket drops: the RPC client classifies transport loss with a typed error, the TUI runtime makes bounded reconnect attempts and then genuinely falls back to the local session with a single warning, and in-flight actions resolve as cancellations instead of error toasts ([#1220](https://github.com/code-yeongyu/senpi/pull/1220)). diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index 73e75094a2..d44007b8a2 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -1,22 +1,22 @@ # changes -## 2026-08-30 - Export the RPC transport-gone classifier +## 2026-08-30 - Export and normalize the RPC transport-gone classifier ### What changed -- `index.ts` and `modes/index.ts` re-export `RpcTransportGoneError` and `isTransportGoneError` beside `RpcClient` so embedders can classify shared-host transport loss. +- `packages/coding-agent/src/index.ts` and `packages/coding-agent/src/modes/index.ts` re-export `RpcTransportGoneError` and `isTransportGoneError` beside `RpcClient` so embedders can classify shared-host transport loss, with their mixed type/value export lists normalized to the repository's Biome order during integration. ### Why -- The reconnect-or-fallback orchestration rejects sends with the typed error; consumers of the public client surface need the classifier to distinguish transport loss from real failures. +- `packages/coding-agent/src/index.ts` and `packages/coding-agent/src/modes/index.ts` expose the classifier because reconnect-or-fallback orchestration rejects sends with the typed error; consumers of the public client surface need to distinguish transport loss from real failures, and the canonical gate rewrites both touched export lists into deterministic order. ### Why an extension could not handle it -- Package export surfaces are compile-time module structure; extensions cannot add public exports. +- `packages/coding-agent/src/index.ts` and `packages/coding-agent/src/modes/index.ts` are compile-time package export surfaces; extensions cannot add public exports or control their source ordering. ### Expected merge conflict zones -- LOW: export lists in `index.ts` and `modes/index.ts`. +- LOW: export lists in `packages/coding-agent/src/index.ts` and `packages/coding-agent/src/modes/index.ts`. ## 2026-08-30 - Dispatch the internal RPC host route through wrapper-injected argv diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index 196d2d0fbe..407c84574a 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,23 @@ # changes +## 2026-08-30 - Persist optional assistant message timestamps + +### What changed + +- `packages/coding-agent/src/core/settings-manager.ts`: adds the default-off `showMessageTimestamps` setting, getter, and setter. + +### Why + +- Operators can opt into compact local arrival times for assistant messages, and the choice survives later sessions. + +### Why an extension could not handle it + +- The built-in settings manager owns persisted interactive preferences and their reload semantics before extension UI hooks run. + +### Expected merge conflict zones + +- `packages/coding-agent/src/core/settings-manager.ts`: the settings schema, public getter set, and persisted setter block. + ## 2026-08-31 - Shared session host is OFF by default (opt-in) - Interactive sessions no longer join the shared RPC host implicitly. `main.ts` now gates @@ -62,7 +80,6 @@ so the new-behavior test exercises the lane the contract actually covers instead of the default print lane. - ## 2026-08-30 - Experimental workflow eval-only policy ### What changed diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 85d931f319..77bd29b20c 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -131,6 +131,7 @@ export interface Settings { branchSummary?: BranchSummarySettings; retry?: RetrySettingsConfig; hideThinkingBlock?: boolean; + showMessageTimestamps?: boolean; // default: false - prefix each rendered message with its local clock time smoothStreaming?: boolean; // default: true smoothStreamingFps?: number; // default: 60, clamped to 30-120 when read showCacheMissNotices?: boolean; // default: false - show prompt-cache miss and compaction cost notices @@ -1526,6 +1527,10 @@ export class SettingsManager { return this.settings.hideThinkingBlock ?? false; } + getShowMessageTimestamps(): boolean { + return this.settings.showMessageTimestamps ?? false; + } + getSmoothStreaming(): boolean { return this.settings.smoothStreaming ?? true; } @@ -1560,6 +1565,12 @@ export class SettingsManager { this.save(); } + setShowMessageTimestamps(show: boolean): void { + this.globalSettings.showMessageTimestamps = show; + this.markModified("showMessageTimestamps"); + this.save(); + } + setSmoothStreaming(enabled: boolean): void { this.globalSettings.smoothStreaming = enabled; this.markModified("smoothStreaming"); diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index c1584c2365..51d116c19e 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -361,13 +361,12 @@ export { type HostDaemonPaths, InteractiveMode, type InteractiveModeOptions, + isTransportGoneError, type JsonAgentSessionEvent, type ModelInfo, PINNED_HOST_CLIENT_CAPABILITIES, type PrintModeOptions, RpcClient, - RpcTransportGoneError, - isTransportGoneError, type RpcClientEvent, type RpcClientOptions, type RpcCommand, @@ -377,6 +376,7 @@ export { type RpcExtensionUIResponse, type RpcResponse, type RpcSessionState, + RpcTransportGoneError, runPrintMode, runRpcMode, } from "./modes/index.ts"; diff --git a/packages/coding-agent/src/modes/index.ts b/packages/coding-agent/src/modes/index.ts index be01e43746..a0d9fd3fcc 100644 --- a/packages/coding-agent/src/modes/index.ts +++ b/packages/coding-agent/src/modes/index.ts @@ -14,13 +14,13 @@ export { PINNED_HOST_CLIENT_CAPABILITIES, } from "./rpc/host-ensure.ts"; export { + isTransportGoneError, type ModelInfo, RpcClient, - RpcTransportGoneError, - isTransportGoneError, type RpcClientEvent, type RpcClientOptions, type RpcEventListener, + RpcTransportGoneError, } from "./rpc/rpc-client.ts"; export { runRpcMode } from "./rpc/rpc-mode.ts"; export type { diff --git a/packages/coding-agent/src/modes/interactive/changes.md b/packages/coding-agent/src/modes/interactive/changes.md index 353d93c042..e0bef76e9d 100644 --- a/packages/coding-agent/src/modes/interactive/changes.md +++ b/packages/coding-agent/src/modes/interactive/changes.md @@ -1,5 +1,27 @@ # changes +## 2026-08-30 - Render optional assistant message arrival timestamps + +### What changed + +- `packages/coding-agent/src/modes/interactive/components/assistant-message.ts`: stamps the first rendered content line with a dim local `HH:MM:SS` prefix when enabled, falls back to unprefixed rendering when a narrow viewport cannot fit the prefix plus content padding, preserves the first `updateContent()` arrival time across streaming updates, rerenders, and width changes, and toggles the prefix without rebuilding memoized content children. +- `packages/coding-agent/src/modes/interactive/components/settings-selector.ts`: exposes the default-off message timestamp toggle in `/settings`. +- `packages/coding-agent/src/modes/interactive/interactive-mode.ts`: wires persisted state into new, restored, and streaming assistant messages; coordinates tool-separated render segments so one logical message receives exactly one prefix with a shared arrival time; keeps that ownership through an initially empty smooth-stream frame; applies toggles immediately; and refreshes the setting before transcript reconstruction on reload. + +### Why + +- Long-running interactive sessions need an unobtrusive way to distinguish when assistant output first arrived without changing existing output unless the operator opts in. + +### Why an extension could not handle it + +- Assistant component construction, streaming updates, transcript reconstruction, and the built-in settings selector are private interactive-mode rendering boundaries without an extension hook that can add a stable prefix to every applicable component. + +### Expected merge conflict zones + +- `packages/coding-agent/src/modes/interactive/components/assistant-message.ts`: outer render-cache invalidation, narrow-width fallback, and message update lifecycle. +- `packages/coding-agent/src/modes/interactive/components/settings-selector.ts`: settings list construction and callbacks. +- `packages/coding-agent/src/modes/interactive/interactive-mode.ts`: assistant component construction, tool-separated timestamp eligibility, runtime settings, `/settings` callbacks, and reload transcript reconstruction. + ## 2026-08-30 - Harden shared-host reconnect fallback ### What changed @@ -34,7 +56,6 @@ - LOW: the entry-list reconciliation block inside `performRefresh()`. - ## 2026-08-30 - Rebind the shared-host proxy on session_replaced ### What changed @@ -53,7 +74,6 @@ - LOW: the wire-event switch in the proxy's `client.onEvent` handler, the replacement methods, and the `refresh` definition. - ## 2026-08-30 - Scope host UI dispatch to the shared-host lane ### What changed diff --git a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts index 55d511959d..4b0e264180 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -1,5 +1,13 @@ import type { AssistantMessage } from "@earendil-works/pi-ai"; -import { type Component, Container, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui"; +import { + type Component, + Container, + Markdown, + type MarkdownTheme, + Spacer, + Text, + visibleWidth, +} from "@earendil-works/pi-tui"; import type { MarkdownTransformer } from "../../../core/extensions/types.ts"; import { getMarkdownTheme, theme } from "../theme/theme.ts"; import { type AssistantRenderDescriptor, createAssistantRenderDescriptors } from "./assistant-render-descriptors.ts"; @@ -28,6 +36,12 @@ export class AssistantMessageComponent extends Container { private hasToolCalls = false; private expanded = false; private isStreaming = false; + private showTimestamps: boolean; + private timestampEligible: boolean; + // Stamped once when the message arrives, never at render time: render() is + // cache-backed and re-runs on resize, so reading the clock there would make a + // past message silently change its own timestamp. + private arrivedAt?: Date; constructor( message?: AssistantMessage, @@ -36,9 +50,14 @@ export class AssistantMessageComponent extends Container { hiddenThinkingLabel = "Thinking...", outputPad = 1, markdownTransformers: readonly MarkdownTransformer[] = [], + showTimestamps = false, + timestamp?: { readonly eligible: boolean; readonly arrivedAt?: Date }, ) { super(); + this.showTimestamps = showTimestamps; + this.timestampEligible = timestamp?.eligible ?? true; + this.arrivedAt = timestamp?.arrivedAt; this.hideThinkingBlock = hideThinkingBlock; this.markdownTheme = markdownTheme; this.hiddenThinkingLabel = hiddenThinkingLabel; @@ -53,7 +72,7 @@ export class AssistantMessageComponent extends Container { } override invalidate(): void { - this.renderCache = undefined; + this.invalidateRenderCache(); super.invalidate(); this.renderDescriptors = []; this.refreshContent(); @@ -83,13 +102,54 @@ export class AssistantMessageComponent extends Container { this.refreshContent(); } + setShowTimestamps(show: boolean): void { + if (this.showTimestamps === show) return; + this.showTimestamps = show; + this.invalidateRenderCache(); + } + + setTimestampEligible(eligible: boolean): void { + if (this.timestampEligible === eligible) return; + this.timestampEligible = eligible; + this.invalidateRenderCache(); + } + + hasVisibleContent(): boolean { + return this.renderDescriptors.some((descriptor) => descriptor.kind !== "spacer"); + } + + getArrivedAt(): Date | undefined { + return this.arrivedAt; + } + + private timestampPrefix(): string { + if (!this.showTimestamps || !this.timestampEligible) return ""; + const at = this.arrivedAt ?? new Date(); + const hh = String(at.getHours()).padStart(2, "0"); + const mm = String(at.getMinutes()).padStart(2, "0"); + const ss = String(at.getSeconds()).padStart(2, "0"); + return theme.fg("dim", `${hh}:${mm}:${ss} `); + } + override render(width: number): string[] { const signature = this.lastMessageSignature ?? ""; if (this.renderCache?.width === width && this.renderCache.signature === signature) { return [...this.renderCache.lines]; } - const lines = super.render(width); + const prefix = this.timestampPrefix(); + const prefixWidth = visibleWidth(prefix); + const showTimestamp = prefixWidth > 0 && width > prefixWidth; + let lines = super.render(showTimestamp ? width - prefixWidth : width); + const firstContentLineIndex = lines.findIndex((line) => visibleWidth(line) > 0); + if (firstContentLineIndex >= 0 && showTimestamp) { + const prefixedLine = prefix + lines[firstContentLineIndex]; + if (visibleWidth(prefixedLine) <= width) { + lines[firstContentLineIndex] = prefixedLine; + } else { + lines = super.render(width); + } + } if (this.hasToolCalls || lines.length === 0) { this.cacheRender(width, signature, lines); return lines; @@ -102,6 +162,10 @@ export class AssistantMessageComponent extends Container { } updateContent(message: AssistantMessage, isStreaming = this.isStreaming): void { + // First delta only. updateContent runs once per streaming chunk, so stamping + // unconditionally would march the clock forward while the message is still + // being written and land on the completion time rather than the start. + this.arrivedAt ??= new Date(); const previousMessage = this.lastMessage; const streamingChanged = this.isStreaming !== isStreaming; this.isStreaming = isStreaming; @@ -192,6 +256,10 @@ export class AssistantMessageComponent extends Container { this.renderCache = { lines: [...lines], signature, width }; } + private invalidateRenderCache(): void { + this.renderCache = undefined; + } + private refreshContent(): void { if (!this.lastMessage) return; this.lastMessageSignature = undefined; diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 3233eecdaf..f09dc30b0f 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -73,6 +73,7 @@ export interface SettingsConfig { terminalTheme: TerminalTheme; availableThemes: string[]; hideThinkingBlock: boolean; + showMessageTimestamps: boolean; smoothStreaming: boolean; smoothStreamingFps: number; mermaidRenderingMode: MermaidRenderingMode; @@ -110,6 +111,7 @@ export interface SettingsCallbacks { onThemeChange: (theme: string) => void; onThemePreview?: (theme: string) => void; onHideThinkingBlockChange: (hidden: boolean) => void; + onShowMessageTimestampsChange: (show: boolean) => void; onSmoothStreamingChange: (enabled: boolean) => void; onSmoothStreamingFpsChange: (fps: number) => void; onMermaidRenderingModeChange: (mode: MermaidRenderingMode) => void; @@ -543,6 +545,13 @@ export class SettingsSelectorComponent extends Container { currentValue: config.hideThinkingBlock ? "true" : "false", values: ["true", "false"], }, + { + id: "message-timestamps", + label: "Message timestamps", + description: "Prefix each assistant message with the local time it arrived", + currentValue: config.showMessageTimestamps ? "true" : "false", + values: ["true", "false"], + }, { id: "smooth-streaming", label: "Smooth streaming", @@ -835,6 +844,9 @@ export class SettingsSelectorComponent extends Container { case "hide-thinking": callbacks.onHideThinkingBlockChange(newValue === "true"); break; + case "message-timestamps": + callbacks.onShowMessageTimestampsChange(newValue === "true"); + break; case "smooth-streaming": callbacks.onSmoothStreamingChange(newValue === "true"); break; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 9db77286a7..d7b1355ec9 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -926,6 +926,7 @@ export class InteractiveMode { // Thinking block visibility state private hideThinkingBlock = false; + private showMessageTimestamps = false; private outputPad = 1; private readonly mermaidMarkdownTransformer: MarkdownTransformer = createMermaidMarkdownTransformer({ getMode: () => this.settingsManager.getMermaidRenderingMode(), @@ -1113,6 +1114,7 @@ export class InteractiveMode { // Load hide thinking block setting this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); + this.showMessageTimestamps = this.settingsManager.getShowMessageTimestamps(); this.outputPad = this.settingsManager.getOutputPad(); // Register themes from resource loader and initialize @@ -2618,6 +2620,7 @@ export class InteractiveMode { this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); this.footerDataProvider.setCwd(this.sessionManager.getCwd()); this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); + this.showMessageTimestamps = this.settingsManager.getShowMessageTimestamps(); this.outputPad = this.settingsManager.getOutputPad(); this.applySmoothStreamingRenderFps(); this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor()); @@ -4487,6 +4490,7 @@ export class InteractiveMode { this.hiddenThinkingLabel, this.outputPad, this.getMarkdownTransformers(), + this.showMessageTimestamps, ); this.streamingComponent.setExpanded(this.toolOutputExpanded); this.streamingMessage = event.message; @@ -5247,6 +5251,7 @@ export class InteractiveMode { this.hiddenThinkingLabel, this.outputPad, this.getMarkdownTransformers(), + this.showMessageTimestamps, ); assistantComponent.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(assistantComponent); @@ -5278,6 +5283,7 @@ export class InteractiveMode { const firstToolIndex = content.findIndex((block) => block.type === "toolCall"); if (firstToolIndex === -1) { this.detachAssistantTextSegments(); + this.syncStreamingMessageTimestampEligibility(); return; } let index = firstToolIndex + 1; @@ -5306,6 +5312,8 @@ export class InteractiveMode { this.hiddenThinkingLabel, this.outputPad, this.getMarkdownTransformers(), + this.showMessageTimestamps, + { eligible: false, arrivedAt: this.streamingComponent.getArrivedAt() }, ); segment.setExpanded(this.toolOutputExpanded); this.assistantTextSegments.set(runStart, segment); @@ -5322,6 +5330,17 @@ export class InteractiveMode { this.assistantTextSegments.delete(runStart); } } + this.syncStreamingMessageTimestampEligibility(); + } + + private syncStreamingMessageTimestampEligibility(): void { + if (!this.streamingComponent) return; + const segments = [...this.assistantTextSegments.entries()] + .sort(([left], [right]) => left - right) + .map(([, segment]) => segment); + const components = [this.streamingComponent, ...segments]; + const timestampOwner = components.find((component) => component.hasVisibleContent()) ?? this.streamingComponent; + for (const component of components) component.setTimestampEligible(component === timestampOwner); } private detachAssistantTextSegments(): void { @@ -6529,6 +6548,7 @@ export class InteractiveMode { terminalTheme: this.themeController.getTerminalTheme(), availableThemes: getAvailableThemes(), hideThinkingBlock: this.hideThinkingBlock, + showMessageTimestamps: this.showMessageTimestamps, smoothStreaming: this.settingsManager.getSmoothStreaming(), smoothStreamingFps: this.settingsManager.getSmoothStreamingFps(), mermaidRenderingMode: this.settingsManager.getMermaidRenderingMode(), @@ -6608,6 +6628,24 @@ export class InteractiveMode { void this.themeController.setThemeSetting(themeSetting); }, onThemePreview: (themeName) => this.themeController.preview(themeName), + onShowMessageTimestampsChange: (show) => { + this.showMessageTimestamps = show; + this.settingsManager.setShowMessageTimestamps(show); + // Only the rendered prefix changes, so unlike the thinking-block + // toggle this needs no chat rebuild: each component re-renders + // itself from the arrival time it already recorded. + for (const child of this.chatContainer.children) { + if (child instanceof AssistantMessageComponent) { + child.setShowTimestamps(show); + } + } + this.streamingComponent?.setShowTimestamps(show); + for (const segment of this.assistantTextSegments.values()) { + segment.setShowTimestamps(show); + } + this.syncStreamingMessageTimestampEligibility(); + this.ui.requestRender(); + }, onHideThinkingBlockChange: (hidden) => { this.hideThinkingBlock = hidden; this.settingsManager.setHideThinkingBlock(hidden); @@ -8049,6 +8087,7 @@ export class InteractiveMode { // self-repainting until an input event forced a frame. this.resetExtensionUI(); this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); + this.showMessageTimestamps = this.settingsManager.getShowMessageTimestamps(); this.outputPad = this.settingsManager.getOutputPad(); // Reload replaces the session runner: a genuine ownership boundary, so the // external-owner delegation episode ends here (settings-only rebuilds below diff --git a/packages/coding-agent/test/grok/chrome.test.ts b/packages/coding-agent/test/grok/chrome.test.ts index 22d0cc0f6a..e0c8f352ca 100644 --- a/packages/coding-agent/test/grok/chrome.test.ts +++ b/packages/coding-agent/test/grok/chrome.test.ts @@ -25,6 +25,7 @@ function createRuntime(): AgentSessionRuntime { getEditorPaddingX: () => 0, getHideThinkingBlock: () => false, getOutputPad: () => 1, + getShowMessageTimestamps: () => false, getPackages: () => [], getShowHardwareCursor: () => false, getTuiMode: () => "regular", diff --git a/packages/coding-agent/test/grok/classic-chrome-characterization.test.ts b/packages/coding-agent/test/grok/classic-chrome-characterization.test.ts index 0c11716d91..af7b737ffe 100644 --- a/packages/coding-agent/test/grok/classic-chrome-characterization.test.ts +++ b/packages/coding-agent/test/grok/classic-chrome-characterization.test.ts @@ -144,6 +144,7 @@ function createClassicRuntime(): AgentSessionRuntime { getEditorPaddingX: () => 0, getHideThinkingBlock: () => false, getOutputPad: () => 1, + getShowMessageTimestamps: () => false, getPackages: () => [], getShowHardwareCursor: () => false, getTuiMode: () => "regular", diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts index 45f927c0a1..394b79328b 100644 --- a/packages/coding-agent/test/interactive-tui.test.ts +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -240,7 +240,11 @@ describe("handleReloadCommand extension UI lifecycle", () => { const sentinel = new Error("boom after commit point"); const session = { isCompacting: false, - settingsManager: { getHideThinkingBlock: () => false, getOutputPad: () => 0 }, + settingsManager: { + getHideThinkingBlock: () => false, + getShowMessageTimestamps: () => false, + getOutputPad: () => 0, + }, checkReloadVeto: async () => ({ cancelled: false }), reload: async (options?: { beforeSessionStart?: () => void | Promise }) => { await options?.beforeSessionStart?.(); @@ -252,6 +256,12 @@ describe("handleReloadCommand extension UI lifecycle", () => { await proto.handleReloadCommand.call(context); expect(resetExtensionUI).toHaveBeenCalledOnce(); + expect( + (context as unknown as { rebuildChatFromMessages: ReturnType }).rebuildChatFromMessages, + ).toHaveBeenCalledOnce(); + expect((context as unknown as { showError: ReturnType }).showError).toHaveBeenCalledWith( + "Reload failed: boom after commit point", + ); ui.stop(); }); }); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 63bc431f01..0035277dfb 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -724,6 +724,21 @@ describe("SettingsManager", () => { }); }); + describe("message timestamps", () => { + it("defaults to off and persists an enabled value across restart", async () => { + const manager = SettingsManager.create(projectDir, agentDir); + expect(manager.getShowMessageTimestamps()).toBe(false); + + manager.setShowMessageTimestamps(true); + await manager.flush(); + + expect(JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8"))).toMatchObject({ + showMessageTimestamps: true, + }); + expect(SettingsManager.create(projectDir, agentDir).getShowMessageTimestamps()).toBe(true); + }); + }); + describe("smooth streaming", () => { it("defaults smooth streaming on at 60 fps", () => { // Given diff --git a/packages/coding-agent/test/settings-selector.test.ts b/packages/coding-agent/test/settings-selector.test.ts index 9c88fd2820..fe8aed33bb 100644 --- a/packages/coding-agent/test/settings-selector.test.ts +++ b/packages/coding-agent/test/settings-selector.test.ts @@ -17,7 +17,9 @@ describe("SettingsSelectorComponent", () => { it("cycles through fullscreen settings", () => { const onExitOutputChange = vi.fn(); const onScrollbarChange = vi.fn(); + const onShowMessageTimestampsChange = vi.fn(); const config = { + showMessageTimestamps: false, fullscreenExitOutput: "transcript", fullscreenScrollbar: "auto", warnings: {}, @@ -30,6 +32,7 @@ describe("SettingsSelectorComponent", () => { const callbacks = { onFullscreenExitOutputChange: onExitOutputChange, onFullscreenScrollbarChange: onScrollbarChange, + onShowMessageTimestampsChange, } as unknown as SettingsCallbacks; const cycle = (label: string, count: number) => { @@ -42,5 +45,7 @@ describe("SettingsSelectorComponent", () => { expect(onExitOutputChange.mock.calls.flat()).toEqual(["resume-hint", "transcript"]); cycle("Fullscreen scrollbar", 3); expect(onScrollbarChange.mock.calls.flat()).toEqual(["always", "hidden", "auto"]); + cycle("Message timestamps", 2); + expect(onShowMessageTimestampsChange.mock.calls.flat()).toEqual([true, false]); }); }); diff --git a/packages/coding-agent/test/suite/interactive-mode-scoped-settings.test.ts b/packages/coding-agent/test/suite/interactive-mode-scoped-settings.test.ts index eadb198053..1d6303da5b 100644 --- a/packages/coding-agent/test/suite/interactive-mode-scoped-settings.test.ts +++ b/packages/coding-agent/test/suite/interactive-mode-scoped-settings.test.ts @@ -1,5 +1,6 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Model } from "@earendil-works/pi-ai"; +import { Container } from "@earendil-works/pi-tui"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { InteractiveMode } from "../../src/modes/interactive/interactive-mode.ts"; import { initTheme } from "../../src/modes/interactive/theme/theme.ts"; @@ -158,6 +159,53 @@ describe("InteractiveMode scoped-setting caller compatibility", () => { expect(setModel).toHaveBeenCalledExactlyOnceWith(defaultModel); expect(setSessionModel).not.toHaveBeenCalled(); }); + + it("refreshes message timestamps before rebuilding the transcript during reload", async () => { + // Given: reload changes the persisted timestamp setting from disabled to enabled. + const events: string[] = []; + let fakeThis: { showMessageTimestamps: boolean } & Record; + fakeThis = { + showMessageTimestamps: false, + hideThinkingBlock: false, + outputPad: 1, + editor: {}, + editorContainer: new Container(), + ui: { setFocus: vi.fn(), requestRender: vi.fn() }, + settingsManager: { + getHideThinkingBlock: () => false, + getShowMessageTimestamps: () => true, + getOutputPad: () => 1, + }, + session: { + isStreaming: false, + isCompacting: false, + checkReloadVeto: async () => ({ cancelled: false }), + reload: async (options?: { beforeSessionStart?: () => void }) => { + events.push("reload"); + options?.beforeSessionStart?.(); + events.push(`start:${fakeThis.showMessageTimestamps}`); + throw new Error("stop after reload boundary"); + }, + }, + resetExtensionUI: vi.fn(), + rebuildChatFromMessages: () => { + events.push(`rebuild:${fakeThis.showMessageTimestamps}`); + }, + showWarning: vi.fn(), + showError: vi.fn(), + }; + const handleReloadCommand = Reflect.get(InteractiveMode.prototype, "handleReloadCommand"); + if (typeof handleReloadCommand !== "function") { + throw new Error("InteractiveMode.handleReloadCommand is missing"); + } + + // When: the interactive reload reaches its before-session-start reconstruction boundary. + await handleReloadCommand.call(fakeThis); + + // Then: the refreshed value is visible to both transcript rebuild and session start. + expect(fakeThis.showMessageTimestamps).toBe(true); + expect(events).toEqual(["reload", "rebuild:true", "start:true"]); + }); }); function createSettingsManagerStub() { diff --git a/packages/coding-agent/test/suite/regressions/1064-assistant-text-segment-teleport.test.ts b/packages/coding-agent/test/suite/regressions/1064-assistant-text-segment-teleport.test.ts index 09cbb74ac2..aa42d1f1db 100644 --- a/packages/coding-agent/test/suite/regressions/1064-assistant-text-segment-teleport.test.ts +++ b/packages/coding-agent/test/suite/regressions/1064-assistant-text-segment-teleport.test.ts @@ -44,6 +44,7 @@ type SyncContext = { chatContainer: Container; assistantTextSegments: Map; detachAssistantTextSegments: () => void; + syncStreamingMessageTimestampEligibility: () => void; hideThinkingBlock: boolean; toolOutputExpanded: boolean; hiddenThinkingLabel: string; @@ -62,6 +63,10 @@ function createStreamContext(): SyncContext { chatContainer, assistantTextSegments: new Map(), detachAssistantTextSegments: Reflect.get(InteractiveMode.prototype, "detachAssistantTextSegments"), + syncStreamingMessageTimestampEligibility: Reflect.get( + InteractiveMode.prototype, + "syncStreamingMessageTimestampEligibility", + ), hideThinkingBlock: false, toolOutputExpanded: false, hiddenThinkingLabel: "Thinking...", diff --git a/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts b/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts index d69c2bd096..ee342e9117 100644 --- a/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts +++ b/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts @@ -88,6 +88,7 @@ type RebindContext = { type ReloadCommandContext = { hideThinkingBlock: boolean; + showMessageTimestamps: boolean; session: { isStreaming: boolean; isCompacting: boolean; @@ -102,6 +103,7 @@ type ReloadCommandContext = { settingsManager: { getHttpIdleTimeoutMs: () => number; getHideThinkingBlock: () => boolean; + getShowMessageTimestamps: () => boolean; getOutputPad: () => 0 | 1; getEditorPaddingX: () => number; getAutocompleteMaxVisible: () => number; @@ -160,6 +162,7 @@ function createReloadCommandContext(overrides: ReloadCommandContextOverrides = { const editor = overrides.editor ?? {}; return { hideThinkingBlock: overrides.hideThinkingBlock ?? false, + showMessageTimestamps: false, session: { isStreaming: false, isCompacting: false, @@ -176,6 +179,7 @@ function createReloadCommandContext(overrides: ReloadCommandContextOverrides = { settingsManager: { getHttpIdleTimeoutMs: () => 0, getHideThinkingBlock: () => false, + getShowMessageTimestamps: () => false, getOutputPad: () => 1, getEditorPaddingX: () => 1, getAutocompleteMaxVisible: () => 10, diff --git a/packages/coding-agent/test/suite/regressions/issue-1205-message-timestamps.test.ts b/packages/coding-agent/test/suite/regressions/issue-1205-message-timestamps.test.ts new file mode 100644 index 0000000000..2ead79526a --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/issue-1205-message-timestamps.test.ts @@ -0,0 +1,250 @@ +import { type AssistantMessage, fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { type Component, Container, visibleWidth } from "@earendil-works/pi-tui"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { AssistantMessageComponent } from "../../../src/modes/interactive/components/assistant-message.ts"; +import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts"; +import { StreamingRevealController } from "../../../src/modes/interactive/streaming-reveal.ts"; +import { getMarkdownTheme, initTheme } from "../../../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../../../src/utils/ansi.ts"; + +function createAssistantMessage(text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: "openai-responses", + provider: "openai", + model: "gpt-4o-mini", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} + +function createTimestampedComponent(message?: AssistantMessage): AssistantMessageComponent { + return new AssistantMessageComponent(message, false, getMarkdownTheme(), "Thinking...", 1, [], true); +} + +type TimestampSyncContext = { + streamingComponent: AssistantMessageComponent; + streamingReveal: Pick; + chatContainer: Container; + assistantTextSegments: Map; + detachAssistantTextSegments: () => void; + syncStreamingMessageTimestampEligibility: () => void; + hideThinkingBlock: boolean; + showMessageTimestamps: boolean; + toolOutputExpanded: boolean; + hiddenThinkingLabel: string; + outputPad: number; + pendingTools: Map; + getMarkdownThemeWithSettings: () => ReturnType; + getMarkdownTransformers: () => []; +}; + +function createTimestampSyncContext(): TimestampSyncContext { + const streamingComponent = createTimestampedComponent(); + const chatContainer = new Container(); + chatContainer.addChild(streamingComponent); + return { + streamingComponent, + streamingReveal: { isPacingHead: () => false }, + chatContainer, + assistantTextSegments: new Map(), + detachAssistantTextSegments: () => {}, + syncStreamingMessageTimestampEligibility: Reflect.get( + InteractiveMode.prototype, + "syncStreamingMessageTimestampEligibility", + ), + hideThinkingBlock: false, + showMessageTimestamps: true, + toolOutputExpanded: false, + hiddenThinkingLabel: "Thinking...", + outputPad: 1, + pendingTools: new Map(), + getMarkdownThemeWithSettings: () => getMarkdownTheme(), + getMarkdownTransformers: () => [], + }; +} + +function syncTrailingAssistantText(context: TimestampSyncContext, message: AssistantMessage): void { + const sync: (this: TimestampSyncContext, value: AssistantMessage) => void = Reflect.get( + InteractiveMode.prototype, + "syncTrailingAssistantText", + ); + sync.call(context, message); +} + +describe("AssistantMessageComponent message timestamps", () => { + beforeAll(() => { + initTheme("dark"); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("omits the timestamp by default", () => { + // Given: local time is fixed and timestamp display uses its default setting. + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 30, 9, 8, 7)); + const component = new AssistantMessageComponent(createAssistantMessage("Default output")); + + // When: the public component output is rendered. + const lines = component.render(80).map(stripAnsi); + + // Then: no rendered line receives a clock prefix. + expect(lines.some((line) => /^\d{2}:\d{2}:\d{2} /.test(line))).toBe(false); + }); + + it("prefixes the first non-empty content line with local time when enabled", () => { + // Given: timestamp display is enabled at a fixed local time. + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 30, 9, 8, 7)); + const component = createTimestampedComponent(createAssistantMessage("Timestamped output")); + + // When: the public component output is rendered. + const lines = component.render(80).map(stripAnsi); + const contentLine = lines.find((line) => line.includes("Timestamped output")); + + // Then: the content line, and only that line, carries the local HH:MM:SS prefix. + expect(contentLine).toMatch(/^09:08:07 /); + expect(lines.filter((line) => /^\d{2}:\d{2}:\d{2} /.test(line))).toHaveLength(1); + }); + + it("toggles only the timestamp prefix without rebuilding rendered content children", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 30, 9, 8, 7)); + const component = new AssistantMessageComponent(createAssistantMessage("Stable content")); + const contentContainer = component.children[0]; + expect(contentContainer).toBeInstanceOf(Container); + if (!(contentContainer instanceof Container)) throw new TypeError("Expected assistant content container"); + const contentChildren = [...contentContainer.children]; + + expect(component.render(80).map(stripAnsi).join("\n")).not.toContain("09:08:07 "); + component.setShowTimestamps(true); + expect(component.render(80).map(stripAnsi).join("\n")).toMatch(/09:08:07\s+Stable content/); + component.setTimestampEligible(false); + expect(component.render(80).map(stripAnsi).join("\n")).not.toContain("09:08:07 "); + component.setTimestampEligible(true); + component.setShowTimestamps(false); + + expect(contentContainer.children).toHaveLength(contentChildren.length); + expect(contentContainer.children.every((child, index) => child === contentChildren[index])).toBe(true); + expect(component.render(80).map(stripAnsi).join("\n")).not.toContain("09:08:07 "); + }); + + it("retains the first update time across later updates and width rerenders", () => { + // Given: the first content delta arrives at a fixed local time. + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 30, 9, 8, 7)); + const component = createTimestampedComponent(); + component.updateContent(createAssistantMessage("First delta")); + + // When: a later delta arrives at another time and the component rerenders at two widths. + vi.setSystemTime(new Date(2026, 7, 30, 10, 11, 12)); + component.updateContent(createAssistantMessage("Second delta with enough text to wrap at a narrow width")); + const wideLines = component.render(80).map(stripAnsi); + const narrowLines = component.render(24).map(stripAnsi); + + // Then: both content renders retain the first arrival time rather than the update time. + expect(wideLines.find((line) => line.includes("Second delta"))).toMatch(/^09:08:07 /); + expect(narrowLines.find((line) => line.includes("Second delta"))).toMatch(/^09:08:07 /); + expect([...wideLines, ...narrowLines].join("\n")).not.toContain("10:11:12"); + expect(narrowLines.every((line) => visibleWidth(line) <= 24)).toBe(true); + }); + + it("never exceeds widths 9 through 12 when the timestamp and markdown padding cannot both fit", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 30, 9, 8, 7)); + const component = createTimestampedComponent(createAssistantMessage("a")); + + const renders = [9, 10, 11, 12].map((width) => ({ + width, + lines: component.render(width).map(stripAnsi), + })); + + for (const { width, lines } of renders) { + expect( + lines.every((line) => visibleWidth(line) <= width), + `render width ${width}`, + ).toBe(true); + } + expect( + renders + .slice(0, 3) + .flatMap(({ lines }) => lines) + .join("\n"), + ).not.toContain("09:08:07 "); + expect(renders[3]?.lines.join("\n")).toContain("09:08:07 "); + }); + + it("keeps the timestamp eligible until smooth streaming reveals visible content", () => { + // Given: smooth streaming has stamped an empty initial frame at message arrival. + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 30, 9, 8, 7)); + const context = createTimestampSyncContext(); + const reveal = new StreamingRevealController({ + getSmoothStreaming: () => true, + getSmoothStreamingFps: () => 60, + getHideThinkingBlock: () => false, + requestRender: () => {}, + }); + context.streamingReveal = reveal; + reveal.begin(context.streamingComponent, createAssistantMessage("")); + context.syncStreamingMessageTimestampEligibility(); + + // When: a later target becomes visible through reveal ticks before message_end. + vi.setSystemTime(new Date(2026, 7, 30, 10, 11, 12)); + reveal.setTarget(createAssistantMessage("First revealed content")); + vi.advanceTimersByTime(250); + const lines = context.chatContainer.render(80).map(stripAnsi); + reveal.stop(); + + // Then: the first revealed line keeps the message-arrival prefix. + expect(lines.find((line) => line.includes("First revealed"))).toMatch(/^09:08:07 /); + expect(lines.join("\n")).not.toContain("10:11:12 "); + }); + + it("prefixes a tool-segmented assistant message exactly once", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 30, 9, 8, 7)); + const context = createTimestampSyncContext(); + const message = fauxAssistantMessage([ + { type: "text", text: "before tool" }, + { type: "toolCall", id: "tool-1", name: "read", arguments: { path: "file.txt" } }, + { type: "text", text: "after tool" }, + ]); + + syncTrailingAssistantText(context, message); + const lines = context.chatContainer.render(80).map(stripAnsi); + + expect(lines.filter((line) => /^09:08:07 /.test(line))).toHaveLength(1); + expect(lines.join("\n")).toContain("before tool"); + expect(lines.join("\n")).toContain("after tool"); + }); + + it("uses the message arrival time when the first visible content follows a tool call", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 30, 9, 8, 7)); + const context = createTimestampSyncContext(); + const toolCall = { type: "toolCall" as const, id: "tool-1", name: "read", arguments: { path: "file.txt" } }; + syncTrailingAssistantText(context, fauxAssistantMessage([toolCall])); + + vi.setSystemTime(new Date(2026, 7, 30, 10, 11, 12)); + syncTrailingAssistantText( + context, + fauxAssistantMessage([toolCall, { type: "text", text: "visible after tool" }]), + ); + const lines = context.chatContainer.render(80).map(stripAnsi); + + expect(lines.filter((line) => /^09:08:07 /.test(line))).toHaveLength(1); + expect(lines.join("\n")).not.toContain("10:11:12 "); + }); +});