-
Notifications
You must be signed in to change notification settings - Fork 91
feat(tui): add optional message timestamps #1206
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
f39ee77
320cd5b
497eb17
ca876b9
05a1816
825fe82
34c06c7
6b88d97
3b0ab56
d1e3c37
b21b150
eb921f5
4c1f542
d6606bf
9b0efac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,13 @@ | ||
| import type { AssistantMessage } from "@earendil-works/pi-ai"; | ||
| import { type Component, Container, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui"; | ||
| import { | ||
| type Component, | ||
| Container, | ||
| Markdown, | ||
| type MarkdownTheme, | ||
| Spacer, | ||
| Text, | ||
| visibleWidth, | ||
| } from "@earendil-works/pi-tui"; | ||
| import type { MarkdownTransformer } from "../../../core/extensions/types.ts"; | ||
| import { getMarkdownTheme, theme } from "../theme/theme.ts"; | ||
| import { type AssistantRenderDescriptor, createAssistantRenderDescriptors } from "./assistant-render-descriptors.ts"; | ||
|
|
@@ -28,6 +36,11 @@ export class AssistantMessageComponent extends Container { | |
| private hasToolCalls = false; | ||
| private expanded = false; | ||
| private isStreaming = false; | ||
| private showTimestamps: boolean; | ||
| // Stamped once when the message arrives, never at render time: render() is | ||
| // cache-backed and re-runs on resize, so reading the clock there would make a | ||
| // past message silently change its own timestamp. | ||
| private arrivedAt?: Date; | ||
|
|
||
| constructor( | ||
| message?: AssistantMessage, | ||
|
|
@@ -36,9 +49,11 @@ export class AssistantMessageComponent extends Container { | |
| hiddenThinkingLabel = "Thinking...", | ||
| outputPad = 1, | ||
| markdownTransformers: readonly MarkdownTransformer[] = [], | ||
| showTimestamps = false, | ||
| ) { | ||
| super(); | ||
|
|
||
| this.showTimestamps = showTimestamps; | ||
| this.hideThinkingBlock = hideThinkingBlock; | ||
| this.markdownTheme = markdownTheme; | ||
| this.hiddenThinkingLabel = hiddenThinkingLabel; | ||
|
|
@@ -83,13 +98,35 @@ export class AssistantMessageComponent extends Container { | |
| this.refreshContent(); | ||
| } | ||
|
|
||
| setShowTimestamps(show: boolean): void { | ||
| if (this.showTimestamps === show) return; | ||
| this.showTimestamps = show; | ||
| this.invalidate(); | ||
| } | ||
|
|
||
| private timestampPrefix(): string { | ||
| if (!this.showTimestamps) return ""; | ||
| const at = this.arrivedAt ?? new Date(); | ||
| const hh = String(at.getHours()).padStart(2, "0"); | ||
| const mm = String(at.getMinutes()).padStart(2, "0"); | ||
| const ss = String(at.getSeconds()).padStart(2, "0"); | ||
| return theme.fg("dim", `${hh}:${mm}:${ss} `); | ||
| } | ||
|
|
||
| override render(width: number): string[] { | ||
| const signature = this.lastMessageSignature ?? ""; | ||
| if (this.renderCache?.width === width && this.renderCache.signature === signature) { | ||
| return [...this.renderCache.lines]; | ||
| } | ||
|
|
||
| const lines = super.render(width); | ||
| const prefix = this.timestampPrefix(); | ||
| const prefixWidth = visibleWidth(prefix); | ||
| const showTimestamp = prefixWidth > 0 && width > prefixWidth; | ||
| const lines = super.render(showTimestamp ? width - prefixWidth : width); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When timestamps are enabled, this subtracts the nine-column prefix from the width used to render the entire assistant component, but the prefix is added only to the first non-empty line. Every continuation line therefore wraps nine columns early while still starting at column zero, unnecessarily increasing transcript height and narrowing tables or code blocks; only the prefixed line should surrender that width. AGENTS.md reference: packages/coding-agent/src/modes/interactive/components/AGENTS.md:L34-L34 Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The narrower Markdown render is intentional and follows #1205's stated contract to reserve the visible prefix width before Markdown rendering. Using one width for the component keeps Markdown/table/code wrapping internally consistent and provides a simple hard width bound; the prefix is still attached only to the first non-empty line. Boundary tests cover widths 9–12 and real TUI QA covers 120x34 to 80x24 resize. |
||
| const firstContentLineIndex = lines.findIndex((line) => visibleWidth(line) > 0); | ||
| if (firstContentLineIndex >= 0 && showTimestamp) { | ||
| lines[firstContentLineIndex] = prefix + lines[firstContentLineIndex]; | ||
| } | ||
| if (this.hasToolCalls || lines.length === 0) { | ||
| this.cacheRender(width, signature, lines); | ||
| return lines; | ||
|
|
@@ -102,6 +139,10 @@ export class AssistantMessageComponent extends Container { | |
| } | ||
|
|
||
| updateContent(message: AssistantMessage, isStreaming = this.isStreaming): void { | ||
| // First delta only. updateContent runs once per streaming chunk, so stamping | ||
| // unconditionally would march the clock forward while the message is still | ||
| // being written and land on the completion time rather than the start. | ||
| this.arrivedAt ??= new Date(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a saved session is resumed, reloaded, or otherwise rebuilt with timestamps enabled, constructing each component immediately calls Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is intentional for the contract in #1205: |
||
| const previousMessage = this.lastMessage; | ||
| const streamingChanged = this.isStreaming !== isStreaming; | ||
| this.isStreaming = isStreaming; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -895,6 +895,7 @@ | |
|
|
||
| // Thinking block visibility state | ||
| private hideThinkingBlock = false; | ||
| private showMessageTimestamps = false; | ||
| private outputPad = 1; | ||
| private readonly mermaidMarkdownTransformer: MarkdownTransformer = createMermaidMarkdownTransformer({ | ||
| getMode: () => this.settingsManager.getMermaidRenderingMode(), | ||
|
|
@@ -1004,7 +1005,7 @@ | |
| await this.rebindCurrentSession({ renderBeforeBind: true }); | ||
| await this.themeController.applyFromSettings(); | ||
| }); | ||
| this.runtimeHost.setHostUiHandler((request) => this.handleHostUiRequest(request as any)); | ||
|
Check failure on line 1008 in packages/coding-agent/src/modes/interactive/interactive-mode.ts
|
||
| this.version = DISPLAY_VERSION; | ||
| this.renderer = createInteractiveTui({ | ||
| tuiMode, | ||
|
|
@@ -1079,6 +1080,7 @@ | |
|
|
||
| // Load hide thinking block setting | ||
| this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); | ||
| this.showMessageTimestamps = this.settingsManager.getShowMessageTimestamps(); | ||
| this.outputPad = this.settingsManager.getOutputPad(); | ||
|
|
||
| // Register themes from resource loader and initialize | ||
|
|
@@ -2584,6 +2586,7 @@ | |
| this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); | ||
| this.footerDataProvider.setCwd(this.sessionManager.getCwd()); | ||
| this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); | ||
| this.showMessageTimestamps = this.settingsManager.getShowMessageTimestamps(); | ||
| this.outputPad = this.settingsManager.getOutputPad(); | ||
| this.applySmoothStreamingRenderFps(); | ||
| this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor()); | ||
|
|
@@ -4473,6 +4476,7 @@ | |
| this.hiddenThinkingLabel, | ||
| this.outputPad, | ||
| this.getMarkdownTransformers(), | ||
| this.showMessageTimestamps, | ||
| ); | ||
| this.streamingComponent.setExpanded(this.toolOutputExpanded); | ||
| this.streamingMessage = event.message; | ||
|
|
@@ -5233,6 +5237,7 @@ | |
| this.hiddenThinkingLabel, | ||
| this.outputPad, | ||
| this.getMarkdownTransformers(), | ||
| this.showMessageTimestamps, | ||
| ); | ||
| assistantComponent.setExpanded(this.toolOutputExpanded); | ||
| this.chatContainer.addChild(assistantComponent); | ||
|
|
@@ -5292,6 +5297,7 @@ | |
| this.hiddenThinkingLabel, | ||
| this.outputPad, | ||
| this.getMarkdownTransformers(), | ||
| this.showMessageTimestamps, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a streamed assistant response contains text after a tool call, the head component already has a timestamp, but every synthetic trailing-text segment created here also receives timestamping and records a separate arrival time. The live transcript consequently shows multiple timestamps for one assistant message, while reconstructing the same persisted message produces one component and one timestamp; pass timestamp ownership only to the head component rather than each trailing segment. Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in the current head. Trailing segments share the streaming head |
||
| ); | ||
| segment.setExpanded(this.toolOutputExpanded); | ||
| this.assistantTextSegments.set(runStart, segment); | ||
|
|
@@ -6515,6 +6521,7 @@ | |
| terminalTheme: this.themeController.getTerminalTheme(), | ||
| availableThemes: getAvailableThemes(), | ||
| hideThinkingBlock: this.hideThinkingBlock, | ||
| showMessageTimestamps: this.showMessageTimestamps, | ||
| smoothStreaming: this.settingsManager.getSmoothStreaming(), | ||
| smoothStreamingFps: this.settingsManager.getSmoothStreamingFps(), | ||
| mermaidRenderingMode: this.settingsManager.getMermaidRenderingMode(), | ||
|
|
@@ -6594,6 +6601,23 @@ | |
| void this.themeController.setThemeSetting(themeSetting); | ||
| }, | ||
| onThemePreview: (themeName) => this.themeController.preview(themeName), | ||
| onShowMessageTimestampsChange: (show) => { | ||
| this.showMessageTimestamps = show; | ||
| this.settingsManager.setShowMessageTimestamps(show); | ||
| // Only the rendered prefix changes, so unlike the thinking-block | ||
| // toggle this needs no chat rebuild: each component re-renders | ||
| // itself from the arrival time it already recorded. | ||
| for (const child of this.chatContainer.children) { | ||
| if (child instanceof AssistantMessageComponent) { | ||
| child.setShowTimestamps(show); | ||
| } | ||
| } | ||
| this.streamingComponent?.setShowTimestamps(show); | ||
| for (const segment of this.assistantTextSegments.values()) { | ||
| segment.setShowTimestamps(show); | ||
| } | ||
| this.ui.requestRender(); | ||
| }, | ||
| onHideThinkingBlockChange: (hidden) => { | ||
| this.hideThinkingBlock = hidden; | ||
| this.settingsManager.setHideThinkingBlock(hidden); | ||
|
|
@@ -8035,6 +8059,7 @@ | |
| // self-repainting until an input event forced a frame. | ||
| this.resetExtensionUI(); | ||
| this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); | ||
| this.showMessageTimestamps = this.settingsManager.getShowMessageTimestamps(); | ||
| this.outputPad = this.settingsManager.getOutputPad(); | ||
| // Reload replaces the session runner: a genuine ownership boundary, so the | ||
| // external-owner delegation episode ends here (settings-only rebuilds below | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When this setting is toggled in a long transcript,
InteractiveModecalls this method for every assistant component, andinvalidate()clearsrenderDescriptorsbeforerefreshContent(), causing reconciliation to dispose and recreate every Markdown/Text child even though only the prefix changed. This makes a simple settings toggle synchronously rebuild the complete assistant tree and can stall large sessions; clear only the outer render cache here rather than invoking the full content invalidation path.AGENTS.md reference: packages/coding-agent/src/modes/interactive/components/AGENTS.md:L41-L41
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in the current head.
setShowTimestamps()andsetTimestampEligible()now invalidate only the outer render cache; they no longer clear or reconcile content descriptors. The regression test captures the memoized content children, toggles both states, and asserts object identity is unchanged.