From f21ad837accc98d9a40071981af39800ca817d85 Mon Sep 17 00:00:00 2001 From: madjack <148759141+m4djack@users.noreply.github.com> Date: Wed, 25 Mar 2026 06:18:42 +0100 Subject: [PATCH] feat: add timestamps on user and assistant messages (#2368) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows absolute timestamps (date + time) on user prompts (right-aligned above the message) and assistant replies (below the response). Format is configurable via /settings → Timestamp format: - date-time-iso: 2026-03-24 10:34 (default) - date-time-us: 03-24-2026 10:34 AM Setting persists in settings.json as timestampFormat. - Added formatTimestamp utility with ISO and US format support - Updated UserMessageComponent and AssistantMessageComponent - Added timestampFormat to SettingsManager with getter/setter - Added to /settings UI for runtime switching - Unit tests for all format variants including AM/PM edge cases AI-assisted: This change was authored with Claude (AI pair programming). --- .../src/core/settings-manager.ts | 9 ++++ .../components/__tests__/timestamp.test.ts | 38 +++++++++++++++ .../components/assistant-message.ts | 10 ++++ .../components/settings-selector.ts | 15 ++++++ .../modes/interactive/components/timestamp.ts | 48 +++++++++++++++++++ .../interactive/components/user-message.ts | 21 ++++++-- .../controllers/chat-controller.ts | 1 + .../src/modes/interactive/interactive-mode.ts | 9 +++- 8 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 packages/pi-coding-agent/src/modes/interactive/components/__tests__/timestamp.test.ts create mode 100644 packages/pi-coding-agent/src/modes/interactive/components/timestamp.ts diff --git a/packages/pi-coding-agent/src/core/settings-manager.ts b/packages/pi-coding-agent/src/core/settings-manager.ts index 341f27ca0..092f86315 100644 --- a/packages/pi-coding-agent/src/core/settings-manager.ts +++ b/packages/pi-coding-agent/src/core/settings-manager.ts @@ -151,6 +151,7 @@ export interface Settings { fallback?: FallbackSettings; modelDiscovery?: ModelDiscoverySettings; editMode?: "standard" | "hashline"; // Edit tool mode: "standard" (text match) or "hashline" (LINE#ID anchors). Default: "standard" + timestampFormat?: "date-time-iso" | "date-time-us"; // Timestamp display format for messages. Default: "date-time-iso" } /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ @@ -1087,4 +1088,12 @@ export class SettingsManager { setEditMode(mode: "standard" | "hashline"): void { this.setGlobalSetting("editMode", mode); } + + getTimestampFormat(): "date-time-iso" | "date-time-us" { + return this.settings.timestampFormat ?? "date-time-iso"; + } + + setTimestampFormat(format: "date-time-iso" | "date-time-us"): void { + this.setGlobalSetting("timestampFormat", format); + } } diff --git a/packages/pi-coding-agent/src/modes/interactive/components/__tests__/timestamp.test.ts b/packages/pi-coding-agent/src/modes/interactive/components/__tests__/timestamp.test.ts new file mode 100644 index 000000000..c5eb4ce74 --- /dev/null +++ b/packages/pi-coding-agent/src/modes/interactive/components/__tests__/timestamp.test.ts @@ -0,0 +1,38 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { formatTimestamp } from "../timestamp.js"; + +describe("formatTimestamp", () => { + // Use a fixed local timestamp to avoid timezone issues + const d = new Date(2026, 2, 24, 10, 34, 0); // Mar 24, 2026 10:34:00 local time + const ts = d.getTime(); + + test("date-time-iso format (default)", () => { + assert.equal(formatTimestamp(ts, "date-time-iso"), "2026-03-24 10:34"); + assert.equal(formatTimestamp(ts), "2026-03-24 10:34"); // default + }); + + test("date-time-us format", () => { + assert.equal(formatTimestamp(ts, "date-time-us"), "03-24-2026 10:34 AM"); + }); + + test("US format handles PM correctly", () => { + const pm = new Date(2026, 2, 24, 14, 5, 0).getTime(); + assert.equal(formatTimestamp(pm, "date-time-us"), "03-24-2026 2:05 PM"); + }); + + test("US format handles noon as 12 PM", () => { + const noon = new Date(2026, 2, 24, 12, 0, 0).getTime(); + assert.equal(formatTimestamp(noon, "date-time-us"), "03-24-2026 12:00 PM"); + }); + + test("US format handles midnight as 12 AM", () => { + const midnight = new Date(2026, 2, 24, 0, 0, 0).getTime(); + assert.equal(formatTimestamp(midnight, "date-time-us"), "03-24-2026 12:00 AM"); + }); + + test("ISO format pads single digit months and days", () => { + const jan1 = new Date(2026, 0, 1, 9, 5, 0).getTime(); + assert.equal(formatTimestamp(jan1, "date-time-iso"), "2026-01-01 09:05"); + }); +}); diff --git a/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts b/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts index fe78c54e9..b0e8bb716 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts @@ -1,6 +1,7 @@ import type { AssistantMessage } from "@gsd/pi-ai"; import { Container, Markdown, type MarkdownTheme, Spacer, Text } from "@gsd/pi-tui"; import { getMarkdownTheme, theme } from "../theme/theme.js"; +import { formatTimestamp, type TimestampFormat } from "./timestamp.js"; /** * Component that renders a complete assistant message @@ -10,16 +11,19 @@ export class AssistantMessageComponent extends Container { private hideThinkingBlock: boolean; private markdownTheme: MarkdownTheme; private lastMessage?: AssistantMessage; + private timestampFormat: TimestampFormat; constructor( message?: AssistantMessage, hideThinkingBlock = false, markdownTheme: MarkdownTheme = getMarkdownTheme(), + timestampFormat: TimestampFormat = "date-time-iso", ) { super(); this.hideThinkingBlock = hideThinkingBlock; this.markdownTheme = markdownTheme; + this.timestampFormat = timestampFormat; // Container for text/thinking content this.contentContainer = new Container(); @@ -111,5 +115,11 @@ export class AssistantMessageComponent extends Container { this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0)); } } + + // Show timestamp when the message is complete (has a stop reason) + if (message.stopReason && message.timestamp) { + const timeStr = formatTimestamp(message.timestamp, this.timestampFormat); + this.contentContainer.addChild(new Text(theme.fg("dim", timeStr), 1, 0)); + } } } diff --git a/packages/pi-coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/pi-coding-agent/src/modes/interactive/components/settings-selector.ts index 425154982..5b324af2c 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/settings-selector.ts @@ -45,6 +45,7 @@ export interface SettingsConfig { respectGitignoreInPicker: boolean; quietStartup: boolean; clearOnShrink: boolean; + timestampFormat: "date-time-iso" | "date-time-us"; } export interface SettingsCallbacks { @@ -69,6 +70,7 @@ export interface SettingsCallbacks { onRespectGitignoreInPickerChange: (enabled: boolean) => void; onQuietStartupChange: (enabled: boolean) => void; onClearOnShrinkChange: (enabled: boolean) => void; + onTimestampFormatChange: (format: "date-time-iso" | "date-time-us") => void; onCancel: () => void; } @@ -355,6 +357,16 @@ export class SettingsSelectorComponent extends Container { values: ["true", "false"], }); + // Timestamp format (insert after respect-gitignore-in-picker) + const gitignoreIndex = items.findIndex((item) => item.id === "respect-gitignore-in-picker"); + items.splice(gitignoreIndex + 1, 0, { + id: "timestamp-format", + label: "Timestamp format", + description: "Date/time format for message timestamps", + currentValue: config.timestampFormat, + values: ["date-time-iso", "date-time-us"], + }); + // Add borders this.addChild(new DynamicBorder()); @@ -420,6 +432,9 @@ export class SettingsSelectorComponent extends Container { case "respect-gitignore-in-picker": callbacks.onRespectGitignoreInPickerChange(newValue === "true"); break; + case "timestamp-format": + callbacks.onTimestampFormatChange(newValue as "date-time-iso" | "date-time-us"); + break; } }, callbacks.onCancel, diff --git a/packages/pi-coding-agent/src/modes/interactive/components/timestamp.ts b/packages/pi-coding-agent/src/modes/interactive/components/timestamp.ts new file mode 100644 index 000000000..0380571ca --- /dev/null +++ b/packages/pi-coding-agent/src/modes/interactive/components/timestamp.ts @@ -0,0 +1,48 @@ +/** + * Timestamp formatting for message display. + * + * Formats: + * - "time-date-iso": 10:34 2025-03-24 (default) + * - "date-time-iso": 2025-03-24 10:34 + * - "time-date-us": 10:34 AM 03/24/2025 + * - "date-time-us": 03/24/2025 10:34 AM + */ + +export type TimestampFormat = "date-time-iso" | "date-time-us"; + +function pad2(n: number): string { + return n.toString().padStart(2, "0"); +} + +function isoDate(d: Date): string { + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +function isoTime(d: Date): string { + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; +} + +function usDate(d: Date): string { + return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}-${d.getFullYear()}`; +} + +function usTime(d: Date): string { + const hours = d.getHours(); + const period = hours >= 12 ? "PM" : "AM"; + const h = hours % 12 || 12; + return `${h}:${pad2(d.getMinutes())} ${period}`; +} + +/** + * Format a timestamp for message display using the specified format. + */ +export function formatTimestamp(timestamp: number, format: TimestampFormat = "date-time-iso"): string { + const d = new Date(timestamp); + + switch (format) { + case "date-time-iso": + return `${isoDate(d)} ${isoTime(d)}`; + case "date-time-us": + return `${usDate(d)} ${usTime(d)}`; + } +} diff --git a/packages/pi-coding-agent/src/modes/interactive/components/user-message.ts b/packages/pi-coding-agent/src/modes/interactive/components/user-message.ts index a6de30a62..8aab303ba 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/user-message.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/user-message.ts @@ -1,15 +1,21 @@ -import { Container, Markdown, type MarkdownTheme, Spacer } from "@gsd/pi-tui"; +import { Container, Markdown, type MarkdownTheme, Spacer, Text } from "@gsd/pi-tui"; import { getMarkdownTheme, theme } from "../theme/theme.js"; +import { formatTimestamp, type TimestampFormat } from "./timestamp.js"; const OSC133_ZONE_START = "\x1b]133;A\x07"; const OSC133_ZONE_END = "\x1b]133;B\x07"; /** - * Component that renders a user message + * Component that renders a user message with a right-aligned timestamp. */ export class UserMessageComponent extends Container { - constructor(text: string, markdownTheme: MarkdownTheme = getMarkdownTheme()) { + private timestamp: number | undefined; + private timestampFormat: TimestampFormat; + + constructor(text: string, markdownTheme: MarkdownTheme = getMarkdownTheme(), timestamp?: number, timestampFormat: TimestampFormat = "date-time-iso") { super(); + this.timestamp = timestamp; + this.timestampFormat = timestampFormat; this.addChild(new Spacer(1)); this.addChild( new Markdown(text, 1, 1, markdownTheme, { @@ -25,6 +31,15 @@ export class UserMessageComponent extends Container { return lines; } + // Insert right-aligned timestamp above the message content + if (this.timestamp) { + const timeStr = formatTimestamp(this.timestamp, this.timestampFormat); + const label = theme.fg("dim", timeStr); + const padding = Math.max(0, width - timeStr.length - 1); + const timestampLine = " ".repeat(padding) + label; + lines.splice(0, 0, timestampLine); + } + lines[0] = OSC133_ZONE_START + lines[0]; lines[lines.length - 1] = lines[lines.length - 1] + OSC133_ZONE_END; return lines; diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts index ddb65f518..7f9fe7044 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts @@ -100,6 +100,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { undefined, host.hideThinkingBlock, host.getMarkdownThemeWithSettings(), + host.settingsManager.getTimestampFormat(), ); host.streamingMessage = event.message; host.chatContainer.addChild(host.streamingComponent); diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 2f0beb331..a47753241 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -2099,11 +2099,13 @@ export class InteractiveMode { const userComponent = new UserMessageComponent( skillBlock.userMessage, this.getMarkdownThemeWithSettings(), + message.timestamp, + this.settingsManager.getTimestampFormat(), ); this.chatContainer.addChild(userComponent); } } else { - const userComponent = new UserMessageComponent(textContent, this.getMarkdownThemeWithSettings()); + const userComponent = new UserMessageComponent(textContent, this.getMarkdownThemeWithSettings(), message.timestamp, this.settingsManager.getTimestampFormat()); this.chatContainer.addChild(userComponent); } if (options?.populateHistory) { @@ -2117,6 +2119,7 @@ export class InteractiveMode { message, this.hideThinkingBlock, this.getMarkdownThemeWithSettings(), + this.settingsManager.getTimestampFormat(), ); this.chatContainer.addChild(assistantComponent); break; @@ -2795,6 +2798,7 @@ export class InteractiveMode { respectGitignoreInPicker: this.settingsManager.getRespectGitignoreInPicker(), quietStartup: this.settingsManager.getQuietStartup(), clearOnShrink: this.settingsManager.getClearOnShrink(), + timestampFormat: this.settingsManager.getTimestampFormat(), }, { onAutoCompactChange: (enabled) => { @@ -2898,6 +2902,9 @@ export class InteractiveMode { this.settingsManager.setRespectGitignoreInPicker(enabled); this.autocompleteProvider?.setRespectGitignore(enabled); }, + onTimestampFormatChange: (format) => { + this.settingsManager.setTimestampFormat(format); + }, onCancel: () => { done(); this.ui.requestRender();