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:
@@ -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)
|
||||
}
|
||||
@@ -331,6 +331,7 @@ export interface HermesConfig {
|
||||
personality?: string
|
||||
skin?: string
|
||||
interim_assistant_messages?: boolean
|
||||
timestamps?: boolean
|
||||
}
|
||||
desktop?: {
|
||||
repo_scan_enabled?: boolean
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -232,6 +232,7 @@ const TranscriptPane = memo(function TranscriptPane({
|
||||
})}
|
||||
sections={ui.sections}
|
||||
t={ui.theme}
|
||||
timestamps={ui.timestamps}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user