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.
This commit is contained in:
Teknium
2026-08-14 23:26:40 -07:00
parent 4a663dd693
commit 77fcc2ea31
20 changed files with 200 additions and 9 deletions
@@ -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)
@@ -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
@@ -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 {
@@ -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(<TimelineTimestamp timestamp={timestamp} />)
expect(container.querySelector('[data-slot="timeline-timestamp"]')).toBeNull()
})
it('renders the stamp once display.timestamps is on', () => {
$displayTimestamps.set(true)
const { container } = render(<TimelineTimestamp timestamp={timestamp} />)
expect(container.querySelector('[data-slot="timeline-timestamp"]')).toBeTruthy()
})
})
@@ -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
}
@@ -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
@@ -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<boolean>(false)
export function setDisplayTimestampsFromConfig(value: unknown): void {
$displayTimestamps.set(value === true || value === 'true' || value === 1)
}
+1
View File
@@ -331,6 +331,7 @@ export interface HermesConfig {
personality?: string
skin?: string
interim_assistant_messages?: boolean
timestamps?: boolean
}
desktop?: {
repo_scan_enabled?: boolean
+1 -1
View File
@@ -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
+5
View File
@@ -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
+36 -2
View File
@@ -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)
})
})
+3
View File
@@ -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
}
+1
View File
@@ -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,
+4 -1
View File
@@ -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
})
}
+1
View File
@@ -232,6 +232,7 @@ const TranscriptPane = memo(function TranscriptPane({
})}
sections={ui.sections}
t={ui.theme}
timestamps={ui.timestamps}
/>
)}
+40
View File
@@ -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 (
<Box
flexDirection="column"
@@ -272,6 +299,17 @@ export const MessageLine = memo(function MessageLine({
</Box>
)}
{stamp && (
<Box>
<NoSelect flexShrink={0} fromLeftEdge width={gutterWidth}>
<Text> </Text>
</NoSelect>
<Text color={t.color.muted} dim>
{stamp}
</Text>
</Box>
)}
<Box>
<NoSelect flexShrink={0} fromLeftEdge width={gutterWidth}>
<Text bold={msg.role === 'user'} color={prefix}>
@@ -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[]
}
+6 -3
View File
@@ -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
}
+3
View File
@@ -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
+6 -1
View File
@@ -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) {
+4
View File
@@ -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