Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
18 changes: 18 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -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-30 - Experimental workflow eval-only policy

### What changed
Expand Down
11 changes: 11 additions & 0 deletions packages/coding-agent/src/core/settings-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,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
Expand Down Expand Up @@ -1618,6 +1619,10 @@ export class SettingsManager {
return this.settings.hideThinkingBlock ?? false;
}

getShowMessageTimestamps(): boolean {
return this.settings.showMessageTimestamps ?? false;
}

getSmoothStreaming(): boolean {
return this.settings.smoothStreaming ?? true;
}
Expand Down Expand Up @@ -1652,6 +1657,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");
Expand Down
22 changes: 22 additions & 0 deletions packages/coding-agent/src/modes/interactive/changes.md
Original file line number Diff line number Diff line change
@@ -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 and preserves the first `updateContent()` arrival time across streaming updates, rerenders, and width changes.
- `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, streaming, and segmented assistant components; 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`: render caching 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, runtime settings, `/settings` callbacks, and reload transcript reconstruction.

## 2026-08-30 - Forward shared-host switch cwd overrides

- `interactive-host-runtime.ts` forwards the `cwdOverride` field explicitly when the interactive proxy requests a shared-host session replacement, preserving the host-effective cwd through the RPC boundary and subsequent proxy refresh.
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -28,6 +36,11 @@ export class AssistantMessageComponent extends Container {
private hasToolCalls = false;
private expanded = false;
private isStreaming = false;
private showTimestamps: 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,
Expand All @@ -36,9 +49,11 @@ export class AssistantMessageComponent extends Container {
hiddenThinkingLabel = "Thinking...",
outputPad = 1,
markdownTransformers: readonly MarkdownTransformer[] = [],
showTimestamps = false,
) {
super();

this.showTimestamps = showTimestamps;
this.hideThinkingBlock = hideThinkingBlock;
this.markdownTheme = markdownTheme;
this.hiddenThinkingLabel = hiddenThinkingLabel;
Expand Down Expand Up @@ -83,13 +98,35 @@ export class AssistantMessageComponent extends Container {
this.refreshContent();
}

setShowTimestamps(show: boolean): void {
if (this.showTimestamps === show) return;
this.showTimestamps = show;
this.invalidate();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid rebuilding message trees when toggling timestamps

When this setting is toggled in a long transcript, InteractiveMode calls this method for every assistant component, and invalidate() clears renderDescriptors before refreshContent(), causing reconciliation to dispose and recreate every Markdown/Text child even though only the prefix changed. This makes a simple settings toggle synchronously rebuild the complete assistant tree and can stall large sessions; clear only the outer render cache here rather than invoking the full content invalidation path.

AGENTS.md reference: packages/coding-agent/src/modes/interactive/components/AGENTS.md:L41-L41

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head. setShowTimestamps() and setTimestampEligible() now invalidate only the outer render cache; they no longer clear or reconcile content descriptors. The regression test captures the memoized content children, toggles both states, and asserts object identity is unchanged.

}

private timestampPrefix(): string {
if (!this.showTimestamps) 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;
const lines = super.render(showTimestamp ? width - prefixWidth : width);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve full width after the timestamped line

When timestamps are enabled, this subtracts the nine-column prefix from the width used to render the entire assistant component, but the prefix is added only to the first non-empty line. Every continuation line therefore wraps nine columns early while still starting at column zero, unnecessarily increasing transcript height and narrowing tables or code blocks; only the prefixed line should surrender that width.

AGENTS.md reference: packages/coding-agent/src/modes/interactive/components/AGENTS.md:L34-L34

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The narrower Markdown render is intentional and follows #1205's stated contract to reserve the visible prefix width before Markdown rendering. Using one width for the component keeps Markdown/table/code wrapping internally consistent and provides a simple hard width bound; the prefix is still attached only to the first non-empty line. Boundary tests cover widths 9–12 and real TUI QA covers 120x34 to 80x24 resize.

const firstContentLineIndex = lines.findIndex((line) => visibleWidth(line) > 0);
if (firstContentLineIndex >= 0 && showTimestamp) {
lines[firstContentLineIndex] = prefix + lines[firstContentLineIndex];
}
if (this.hasToolCalls || lines.length === 0) {
this.cacheRender(width, signature, lines);
return lines;
Expand All @@ -102,6 +139,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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve arrival times when reconstructing messages

When a saved session is resumed, reloaded, or otherwise rebuilt with timestamps enabled, constructing each component immediately calls updateContent(message), so this line stamps every historical message with the current reconstruction time and ignores the persisted message.timestamp. As a result, old messages display the resume/reload time—and can change again after settings-triggered transcript rebuilds—instead of a stable historical time; reconstructed components need to derive or restore a persisted arrival value while empty streaming components can continue stamping their first update.

Useful? React with 👍 / 👎.

@codeg-dev codeg-dev Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional for the contract in #1205: arrivedAt is the time a component first receives content in the current TUI, not the provider/model timestamp persisted on the message. Reusing message.timestamp would change that meaning, and the feature deliberately avoids adding persisted transcript schema. A restored or reloaded transcript is reconstructed into the current UI and receives its current UI-arrival time; live updates remain fixed by arrivedAt ??=. The regression suite locks the two-update stability contract.

const previousMessage = this.lastMessage;
const streamingChanged = this.isStreaming !== isStreaming;
this.isStreaming = isStreaming;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export interface SettingsConfig {
terminalTheme: TerminalTheme;
availableThemes: string[];
hideThinkingBlock: boolean;
showMessageTimestamps: boolean;
smoothStreaming: boolean;
smoothStreamingFps: number;
mermaidRenderingMode: MermaidRenderingMode;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions packages/coding-agent/src/modes/interactive/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,7 @@

// Thinking block visibility state
private hideThinkingBlock = false;
private showMessageTimestamps = false;
private outputPad = 1;
private readonly mermaidMarkdownTransformer: MarkdownTransformer = createMermaidMarkdownTransformer({
getMode: () => this.settingsManager.getMermaidRenderingMode(),
Expand Down Expand Up @@ -1004,7 +1005,7 @@
await this.rebindCurrentSession({ renderBeforeBind: true });
await this.themeController.applyFromSettings();
});
this.runtimeHost.setHostUiHandler((request) => this.handleHostUiRequest(request as any));

Check failure on line 1008 in packages/coding-agent/src/modes/interactive/interactive-mode.ts

View workflow job for this annotation

GitHub Actions / Test (coding-agent 1/3)

test/grok/chrome.test.ts > GrokChrome (truecolor) > resolves the gate's grok option to the mode-owned strategy

TypeError: this.runtimeHost.setHostUiHandler is not a function ❯ new InteractiveMode src/modes/interactive/interactive-mode.ts:1008:20 ❯ test/grok/chrome.test.ts:110:17

Check failure on line 1008 in packages/coding-agent/src/modes/interactive/interactive-mode.ts

View workflow job for this annotation

GitHub Actions / Test (coding-agent 2/3)

test/grok/classic-chrome-characterization.test.ts > classic chrome characterization > keeps the classic base editor construction byte-identical

TypeError: this.runtimeHost.setHostUiHandler is not a function ❯ new InteractiveMode src/modes/interactive/interactive-mode.ts:1008:20 ❯ test/grok/classic-chrome-characterization.test.ts:245:16
this.version = DISPLAY_VERSION;
this.renderer = createInteractiveTui({
tuiMode,
Expand Down Expand Up @@ -1079,6 +1080,7 @@

// 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
Expand Down Expand Up @@ -2584,6 +2586,7 @@
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());
Expand Down Expand Up @@ -4473,6 +4476,7 @@
this.hiddenThinkingLabel,
this.outputPad,
this.getMarkdownTransformers(),
this.showMessageTimestamps,
);
this.streamingComponent.setExpanded(this.toolOutputExpanded);
this.streamingMessage = event.message;
Expand Down Expand Up @@ -5233,6 +5237,7 @@
this.hiddenThinkingLabel,
this.outputPad,
this.getMarkdownTransformers(),
this.showMessageTimestamps,
);
assistantComponent.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(assistantComponent);
Expand Down Expand Up @@ -5292,6 +5297,7 @@
this.hiddenThinkingLabel,
this.outputPad,
this.getMarkdownTransformers(),
this.showMessageTimestamps,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Timestamp each streamed assistant message only once

When a streamed assistant response contains text after a tool call, the head component already has a timestamp, but every synthetic trailing-text segment created here also receives timestamping and records a separate arrival time. The live transcript consequently shows multiple timestamps for one assistant message, while reconstructing the same persisted message produces one component and one timestamp; pass timestamp ownership only to the head component rather than each trailing segment.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the current head. Trailing segments share the streaming head arrivedAt, start ineligible, and syncStreamingMessageTimestampEligibility() assigns exactly one visible owner across the head and sorted segments. The tool-separated regression asserts exactly one timestamp and the tool-first regression asserts the original arrival time is retained.

);
segment.setExpanded(this.toolOutputExpanded);
this.assistantTextSegments.set(runStart, segment);
Expand Down Expand Up @@ -6515,6 +6521,7 @@
terminalTheme: this.themeController.getTerminalTheme(),
availableThemes: getAvailableThemes(),
hideThinkingBlock: this.hideThinkingBlock,
showMessageTimestamps: this.showMessageTimestamps,
smoothStreaming: this.settingsManager.getSmoothStreaming(),
smoothStreamingFps: this.settingsManager.getSmoothStreamingFps(),
mermaidRenderingMode: this.settingsManager.getMermaidRenderingMode(),
Expand Down Expand Up @@ -6594,6 +6601,23 @@
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.ui.requestRender();
},
onHideThinkingBlockChange: (hidden) => {
this.hideThinkingBlock = hidden;
this.settingsManager.setHideThinkingBlock(hidden);
Expand Down Expand Up @@ -8035,6 +8059,7 @@
// 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, unknown>;
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() {
Expand Down
Loading
Loading