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:
parent
61fcc0fbee
commit
f21ad837ac
8 changed files with 147 additions and 4 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue