From 77fcc2ea31e074cbdedf23f31e682f2b76c188e2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:26:40 -0700 Subject: [PATCH] feat(display): honor display.timestamps across desktop transcript and TUI One config key everywhere (#41531): the same display.timestamps that stamps [HH:MM] on classic-CLI labels now gates the desktop transcript's timeline timestamps and renders dim [HH:MM] labels on TUI user/assistant rows. - desktop: $displayTimestamps store fed from config.yaml via use-hermes-config; TimelineTimestamp renders nothing while the key is off (the default). Hover tooltips with the exact time stay ungated (#70450). - TUI: tui_gateway forwards each persisted row's timestamp in the display projection; toTranscriptMessages threads it as Msg.createdAt; live rows are stamped at append (the #82840 rule); MessageLine shows a dim [HH:MM] above user/assistant rows when display.timestamps is on. - No new config keys, no HERMES_* env vars; display-only, prompt-cache safe. --- .../app/session/hooks/use-hermes-config.ts | 2 + .../thread/assistant-message.test.tsx | 5 ++ .../thread/system-message.test.tsx | 5 ++ .../thread/timeline-timestamp.test.tsx | 46 +++++++++++++++++++ .../thread/timeline-timestamp.tsx | 8 +++- .../assistant-ui/tool/tool-group.test.tsx | 4 ++ apps/desktop/src/store/display-timestamps.ts | 20 ++++++++ apps/desktop/src/types/hermes.ts | 1 + hermes_cli/config_defaults.py | 2 +- tui_gateway/server.py | 5 ++ ui-tui/src/__tests__/messages.test.ts | 38 ++++++++++++++- ui-tui/src/app/interfaces.ts | 3 ++ ui-tui/src/app/uiStore.ts | 1 + ui-tui/src/app/useConfigSync.ts | 5 +- ui-tui/src/components/appLayout.tsx | 1 + ui-tui/src/components/messageLine.tsx | 40 ++++++++++++++++ ui-tui/src/domain/messages.ts | 9 ++-- ui-tui/src/gatewayTypes.ts | 3 ++ ui-tui/src/lib/messages.ts | 7 ++- ui-tui/src/types.ts | 4 ++ 20 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/thread/timeline-timestamp.test.tsx create mode 100644 apps/desktop/src/store/display-timestamps.ts diff --git a/apps/desktop/src/app/session/hooks/use-hermes-config.ts b/apps/desktop/src/app/session/hooks/use-hermes-config.ts index 1250e4ca02..e18a53c441 100644 --- a/apps/desktop/src/app/session/hooks/use-hermes-config.ts +++ b/apps/desktop/src/app/session/hooks/use-hermes-config.ts @@ -4,6 +4,7 @@ import { setTerminalFontFamilyFromConfig } from '@/app/right-sidebar/terminal/te import { getHermesConfig, getHermesConfigDefaults } from '@/hermes' import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime' import { normalize } from '@/lib/text' +import { setDisplayTimestampsFromConfig } from '@/store/display-timestamps' import { getComposerSelectionGeneration, getCurrentModelSource, @@ -109,6 +110,7 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) { setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds)) setSttEnabled(config.stt?.enabled !== false) + setDisplayTimestampsFromConfig(config.display?.timestamps) setTerminalFontFamilyFromConfig(config.terminal?.font_family) applyAutoSpeakFromConfig(config) applyVoiceStopPhraseFromConfig(config) diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx index b75668f78e..986c4a4248 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx @@ -8,10 +8,15 @@ import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { $displayTimestamps } from '@/store/display-timestamps' + import { formatTimelineRange, formatTimelineTimestamp } from './timestamp' import { Thread } from '.' +// Timeline timestamps render only when `display.timestamps` is enabled. +$displayTimestamps.set(true) + const createdAt = new Date('2026-05-01T00:00:00.000Z') const completedAt = createdAt.getTime() / 1000 + 1.25 diff --git a/apps/desktop/src/components/assistant-ui/thread/system-message.test.tsx b/apps/desktop/src/components/assistant-ui/thread/system-message.test.tsx index df2b2219d3..d0249fc45a 100644 --- a/apps/desktop/src/components/assistant-ui/thread/system-message.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/system-message.test.tsx @@ -2,8 +2,13 @@ import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { $displayTimestamps } from '@/store/display-timestamps' + import { Thread } from '.' +// Timeline timestamps render only when `display.timestamps` is enabled. +$displayTimestamps.set(true) + const timestamp = new Date('2026-05-01T00:00:00.000Z') class TestResizeObserver { diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline-timestamp.test.tsx b/apps/desktop/src/components/assistant-ui/thread/timeline-timestamp.test.tsx new file mode 100644 index 0000000000..6263fc702e --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/timeline-timestamp.test.tsx @@ -0,0 +1,46 @@ +import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { $displayTimestamps, setDisplayTimestampsFromConfig } from '@/store/display-timestamps' + +import { TimelineTimestamp } from './timeline-timestamp' + +afterEach(cleanup) + +beforeEach(() => { + $displayTimestamps.set(false) +}) + +describe('setDisplayTimestampsFromConfig', () => { + it('accepts boolean and string forms, defaulting off', () => { + setDisplayTimestampsFromConfig(true) + expect($displayTimestamps.get()).toBe(true) + + setDisplayTimestampsFromConfig(false) + expect($displayTimestamps.get()).toBe(false) + + setDisplayTimestampsFromConfig('true') + expect($displayTimestamps.get()).toBe(true) + + setDisplayTimestampsFromConfig(undefined) + expect($displayTimestamps.get()).toBe(false) + }) +}) + +describe('TimelineTimestamp display.timestamps gate', () => { + const timestamp = new Date('2026-05-01T00:00:00.000Z').getTime() / 1000 + + it('renders nothing while display.timestamps is off (the default)', () => { + const { container } = render() + + expect(container.querySelector('[data-slot="timeline-timestamp"]')).toBeNull() + }) + + it('renders the stamp once display.timestamps is on', () => { + $displayTimestamps.set(true) + + const { container } = render() + + expect(container.querySelector('[data-slot="timeline-timestamp"]')).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/timeline-timestamp.tsx b/apps/desktop/src/components/assistant-ui/thread/timeline-timestamp.tsx index 1e74e7600e..8a920aeb92 100644 --- a/apps/desktop/src/components/assistant-ui/thread/timeline-timestamp.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/timeline-timestamp.tsx @@ -1,7 +1,9 @@ import { useAuiState } from '@assistant-ui/react' +import { useStore } from '@nanostores/react' import type { FC } from 'react' import { cn } from '@/lib/utils' +import { $displayTimestamps } from '@/store/display-timestamps' import { formatTimelineRange } from './timestamp' @@ -33,9 +35,13 @@ export const TimelineTimestamp: FC<{ completedAt?: number timestamp?: number }> = ({ className, completedAt, timestamp }) => { + // One config key everywhere (#41531): `display.timestamps` in config.yaml + // gates transcript timestamps here exactly as it gates the classic CLI's + // [HH:MM] labels. Display-only, so toggling never touches model context. + const enabled = useStore($displayTimestamps) const started = unixDate(timestamp) - if (!started || !validUnixSeconds(timestamp)) { + if (!enabled || !started || !validUnixSeconds(timestamp)) { return null } diff --git a/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx index decc0d03b2..7402bd9052 100644 --- a/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx @@ -2,6 +2,7 @@ import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { $displayTimestamps } from '@/store/display-timestamps' import { clearAllPrompts, setApprovalRequest } from '@/store/prompts' import { $activeSessionId } from '@/store/session' import { clearDismissedToolRows } from '@/store/tool-dismiss' @@ -10,6 +11,9 @@ import { $toolDisclosureStates } from '@/store/tool-view' import { Thread } from '../thread' import { formatTimelineRange } from '../thread/timestamp' +// Timeline timestamps render only when `display.timestamps` is enabled. +$displayTimestamps.set(true) + // A run of tool calls collapses to a one-line summary once it has settled, but // a run with anything still pending always renders its rows. That rule is what // keeps the "approval must never be buried" bug fixed: an inline ApprovalBar diff --git a/apps/desktop/src/store/display-timestamps.ts b/apps/desktop/src/store/display-timestamps.ts new file mode 100644 index 0000000000..75e30673f9 --- /dev/null +++ b/apps/desktop/src/store/display-timestamps.ts @@ -0,0 +1,20 @@ +/** + * `display.timestamps` — one config key for message timestamps everywhere. + * + * The same config.yaml key that puts [HH:MM] stamps on classic-CLI labels + * gates the desktop transcript's per-message / per-tool-run timeline + * timestamps (#41531, #65272, #68052). Off by default, matching the CLI + * default in hermes_cli/config_defaults.py. + * + * Display-only: reading or toggling it never mutates model context, so it is + * prompt-cache safe. Hover tooltips with the exact time (#70450) are NOT + * gated — they add no visual noise until the user asks for them. + */ + +import { atom } from 'nanostores' + +export const $displayTimestamps = atom(false) + +export function setDisplayTimestampsFromConfig(value: unknown): void { + $displayTimestamps.set(value === true || value === 'true' || value === 1) +} diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 5d04fdde54..fe954367c9 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -331,6 +331,7 @@ export interface HermesConfig { personality?: string skin?: string interim_assistant_messages?: boolean + timestamps?: boolean } desktop?: { repo_scan_enabled?: boolean diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index c4bc6386b0..d8c9ca62ab 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1215,7 +1215,7 @@ DEFAULT_CONFIG = { # "off" — no watcher messages at all "background_process_notifications": "concise", "streaming": False, - "timestamps": False, # Show timestamp on user and assistant labels + "timestamps": False, # Show message timestamps (CLI labels, TUI rows, desktop transcript) "timestamp_format": "%H:%M", # strftime format for timestamps (e.g. "%b-%d %H:%M") "final_response_markdown": "strip", # render | strip | raw # Preserve recent classic CLI output across Ctrl+L, /redraw, and diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 9638e88794..c34e42fc64 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -7425,6 +7425,11 @@ def _history_to_messages(history: list[dict]) -> list[dict]: if not content_text.strip() and not has_reasoning: continue msg = {"role": role, "text": content_text} + # Persisted authoring time (Unix seconds) for display.timestamps + # renderers (#41531). Display-only: never fed back into model context. + ts = m.get("timestamp") + if isinstance(ts, (int, float)) and ts > 0: + msg["timestamp"] = float(ts) # Durable row identity, stamped by _rows_to_conversation. The renderer's # own message ids are ephemeral (timestamp+index derived, and a # different shape for live vs rehydrated vs optimistic rows), so diff --git a/ui-tui/src/__tests__/messages.test.ts b/ui-tui/src/__tests__/messages.test.ts index e83fe2d311..a9b271b154 100644 --- a/ui-tui/src/__tests__/messages.test.ts +++ b/ui-tui/src/__tests__/messages.test.ts @@ -4,10 +4,10 @@ import { renderSync } from '@hermes/ink' import React from 'react' import { describe, expect, it } from 'vitest' -import { MessageLine } from '../components/messageLine.js' +import { fmtMsgTimestamp, MessageLine } from '../components/messageLine.js' import { MAX_HISTORY } from '../config/limits.js' import { toTranscriptMessages } from '../domain/messages.js' -import { capTranscriptHistory, upsert } from '../lib/messages.js' +import { appendTranscriptMessage, capTranscriptHistory, upsert } from '../lib/messages.js' import { stripAnsi } from '../lib/text.js' import { DEFAULT_THEME } from '../theme.js' @@ -237,3 +237,37 @@ describe('capTranscriptHistory', () => { expect(capped.at(-1)?.text).toBe('m1004') }) }) + +describe('display.timestamps (#41531)', () => { + it('formats a Unix-seconds timestamp as [HH:MM] and rejects garbage', () => { + const noon = new Date() + noon.setHours(13, 5, 0, 0) + + expect(fmtMsgTimestamp(noon.getTime() / 1000)).toBe('[13:05]') + expect(fmtMsgTimestamp(undefined)).toBeNull() + expect(fmtMsgTimestamp(0)).toBeNull() + expect(fmtMsgTimestamp(Number.NaN)).toBeNull() + }) + + it('threads persisted transcript timestamps onto rehydrated rows', () => { + const rows = [ + { role: 'user', text: 'when was this', timestamp: 1_750_000_000 }, + { role: 'assistant', text: 'right then', timestamp: 1_750_000_060 } + ] + + const result = toTranscriptMessages(rows) + expect(result[0]?.createdAt).toBe(1_750_000_000) + expect(result[1]?.createdAt).toBe(1_750_000_060) + }) + + it('stamps live rows at append and preserves supplied times', () => { + const before = Date.now() / 1000 + const [live] = appendTranscriptMessage([], { role: 'user', text: 'now' }) + + expect(live?.createdAt).toBeGreaterThanOrEqual(before - 1) + expect(live?.createdAt).toBeLessThanOrEqual(Date.now() / 1000 + 1) + + const [kept] = appendTranscriptMessage([], { createdAt: 123, role: 'user', text: 'then' }) + expect(kept?.createdAt).toBe(123) + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index e8a1a1d9ff..dffa3aeb50 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -345,6 +345,9 @@ export interface UiState { statusBar: StatusBarMode streaming: boolean theme: Theme + // `display.timestamps` — dim [HH:MM] labels on user/assistant transcript + // rows, the same config key the classic CLI honors (#41531). + timestamps: boolean usage: Usage } diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index e43b38e501..581d576a48 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -33,6 +33,7 @@ const buildUiState = (): UiState => ({ status: 'summoning hermes…', statusBar: 'top', streaming: true, + timestamps: false, // Last session's resolved theme paints frame one (flash-free boot, like // the desktop's hermes-boot-* keys); DEFAULT_THEME only on first launch. theme: bootTheme ?? DEFAULT_THEME, diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 9509f9b44d..cf471c4633 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -289,7 +289,10 @@ export const applyDisplay = ( sections: resolveSections(d.sections), showReasoning: !!d.show_reasoning, statusBar: normalizeStatusBar(d.tui_statusbar), - streaming: d.streaming !== false + streaming: d.streaming !== false, + // The SAME key that stamps [HH:MM] on classic-CLI labels (#41531) — + // no separate TUI knob. + timestamps: d.timestamps === true }) } diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index ad4726798f..569c969a11 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -232,6 +232,7 @@ const TranscriptPane = memo(function TranscriptPane({ })} sections={ui.sections} t={ui.theme} + timestamps={ui.timestamps} /> )} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index ba59f6a343..1d3d7e6761 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -28,6 +28,26 @@ import { TodoPanel } from './todoPanel.js' // Collapse threshold for long system messages (system prompt etc.) const SYSTEM_COLLAPSE_CHARS = 400 +// `display.timestamps` label — same HH:MM shape the classic CLI's default +// `display.timestamp_format` ("%H:%M") produces on its message labels, so +// one config key reads identically across surfaces (#41531). +export const fmtMsgTimestamp = (createdAt: number | undefined): null | string => { + if (typeof createdAt !== 'number' || !Number.isFinite(createdAt) || createdAt <= 0) { + return null + } + + const date = new Date(createdAt * 1000) + + if (Number.isNaN(date.getTime())) { + return null + } + + const hh = String(date.getHours()).padStart(2, '0') + const mm = String(date.getMinutes()).padStart(2, '0') + + return `[${hh}:${mm}]` +} + export const MessageLine = memo(function MessageLine({ cols, compact, @@ -40,6 +60,7 @@ export const MessageLine = memo(function MessageLine({ reasoningActive = false, sections, t, + timestamps = false, tools = [] }: MessageLineProps) { // Per-section overrides win over the global mode, so resolve each section @@ -238,6 +259,12 @@ export const MessageLine = memo(function MessageLine({ // against the prose around it. const isDiffSegment = msg.kind === 'diff' + // `display.timestamps`: dim [HH:MM] beside the gutter glyph on user and + // assistant rows only — event/trail/system chrome stays unstamped, matching + // the classic CLI which stamps its user/assistant labels (#41531). + const stamp = + timestamps && (msg.role === 'user' || msg.role === 'assistant') && !msg.kind ? fmtMsgTimestamp(msg.createdAt) : null + return ( )} + {stamp && ( + + + + + + {stamp} + + + )} + @@ -315,5 +353,7 @@ interface MessageLineProps { reasoningActive?: boolean sections?: SectionVisibility t: Theme + /** `display.timestamps` — dim [HH:MM] label on user/assistant rows. */ + timestamps?: boolean tools?: ActiveTool[] } diff --git a/ui-tui/src/domain/messages.ts b/ui-tui/src/domain/messages.ts index 5ec93cecce..1edac1e90f 100644 --- a/ui-tui/src/domain/messages.ts +++ b/ui-tui/src/domain/messages.ts @@ -29,7 +29,9 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => { continue } - const { context, display_kind, name, role, text } = row as TranscriptRow + const { context, display_kind, name, role, text, timestamp } = row as TranscriptRow + + const createdAt = typeof timestamp === 'number' && Number.isFinite(timestamp) && timestamp > 0 ? timestamp : undefined if (role === 'tool') { pending.push(buildToolTrailLine(name ?? 'tool', context ?? '')) @@ -84,10 +86,10 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => { } if (role === 'assistant') { - out.push({ role, text, ...(pending.length && { tools: pending }) }) + out.push({ role, text, ...(createdAt !== undefined && { createdAt }), ...(pending.length && { tools: pending }) }) pending = [] } else if (role === 'user' || role === 'system') { - out.push({ role, text }) + out.push({ role, text, ...(createdAt !== undefined && { createdAt }) }) pending = [] } } @@ -111,4 +113,5 @@ interface TranscriptRow { name?: string role?: string text?: string + timestamp?: number } diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index a701b45fd5..fcd634206f 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -90,6 +90,9 @@ export interface ConfigDisplayConfig { show_reasoning?: boolean streaming?: boolean thinking_mode?: string + /** Show [HH:MM] timestamps on transcript rows — same key the classic CLI + * honors on its user/assistant labels (#41531). */ + timestamps?: boolean /** * Nudge the user toward the /agents spawn-tree dashboard the first time a * turn starts delegating, via a one-time transient activity hint. Opens diff --git a/ui-tui/src/lib/messages.ts b/ui-tui/src/lib/messages.ts index 19a2877aca..bbbe4d4275 100644 --- a/ui-tui/src/lib/messages.ts +++ b/ui-tui/src/lib/messages.ts @@ -3,7 +3,12 @@ import type { Msg, Role } from '../types.js' import { appendToolShelfMessage } from './liveProgress.js' -export const appendTranscriptMessage = (prev: Msg[], msg: Msg): Msg[] => appendToolShelfMessage(prev, msg) +// Stamp live rows AT APPEND (wall clock, Unix seconds) rather than later: +// a message's authoring time is when it entered the transcript, not when it +// happened to be persisted or re-rendered (#82840-class rule). Rehydrated +// rows arrive with their persisted `createdAt` and keep it. +export const appendTranscriptMessage = (prev: Msg[], msg: Msg): Msg[] => + appendToolShelfMessage(prev, msg.createdAt === undefined ? { ...msg, createdAt: Date.now() / 1000 } : msg) export const capTranscriptHistory = (items: Msg[]): Msg[] => { if (items.length <= MAX_HISTORY) { diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index c976913522..f90d3b9c45 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -119,6 +119,10 @@ export interface Msg { panelData?: PanelData role: Role text: string + // Unix seconds the message was authored (persisted transcript timestamp on + // rehydrate, wall clock at append time for live rows). Rendered as a dim + // [HH:MM] label when `display.timestamps` is on (#41531). + createdAt?: number thinking?: string // MoA reference-model output stored in `thinking` (see turnController's // recordMoaReference): unlike ordinary model reasoning, this is the