feat: add timestamps on user and assistant messages (#2368)

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).
This commit is contained in:
madjack 2026-03-25 06:18:42 +01:00 committed by GitHub
parent 61fcc0fbee
commit f21ad837ac
8 changed files with 147 additions and 4 deletions

View file

@ -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);
}
}

View file

@ -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");
});
});

View file

@ -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));
}
}
}

View file

@ -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,

View file

@ -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)}`;
}
}

View file

@ -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;

View file

@ -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);

View file

@ -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();