Skip to content

Commit c7e5a4f

Browse files
committed
feat(opencode): timestamp gutter mode for messages
The /timestamps slash command now cycles hide → footer → gutter → hide. Gutter mode renders a fixed 5-char HH:MM column to the left of each user message; clicking the gutter opens a popup with the full local datetime. Adds a new tui.json key `timestamps_mode` ("hide" | "footer" | "gutter", default "hide") that seeds the default mode for new users. The kv signal that backs the toggle was previously "hide" | "show"; "show" is mapped to "footer" at read time so existing toggles keep working.
1 parent 0cf99cf commit c7e5a4f

6 files changed

Lines changed: 291 additions & 58 deletions

File tree

packages/opencode/src/cli/cmd/tui/config/tui-schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
5252
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
5353
})
5454

55+
export const TimestampsMode = Schema.Literals(["hide", "footer", "gutter"]).annotate({
56+
description:
57+
"Control how message timestamps render: 'hide' (default) shows nothing, 'footer' shows the time under each user message, 'gutter' shows a HH:MM column to the left of each user message that opens a full date popup on click",
58+
})
59+
5560
export const Attention = Schema.Struct({
5661
enabled: Schema.optional(Schema.Boolean),
5762
notifications: Schema.optional(Schema.Boolean),
@@ -75,4 +80,5 @@ export const TuiInfo = Schema.Struct({
7580
scroll_acceleration: Schema.optional(ScrollAcceleration),
7681
diff_style: Schema.optional(DiffStyle),
7782
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
83+
timestamps_mode: Schema.optional(TimestampsMode),
7884
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { TextAttributes } from "@opentui/core"
2+
import { useTheme } from "../../context/theme"
3+
import { useDialog, type DialogContext } from "../../ui/dialog"
4+
import { useBindings } from "../../keymap"
5+
import { Locale } from "@/util/locale"
6+
7+
export type DialogTimestampProps = {
8+
created: number
9+
}
10+
11+
function relative(input: number, now: number): string {
12+
const delta = Math.max(0, now - input)
13+
if (delta < 60_000) return "just now"
14+
if (delta < 3_600_000) {
15+
const minutes = Math.floor(delta / 60_000)
16+
return `${minutes}m ago`
17+
}
18+
if (delta < 86_400_000) {
19+
const hours = Math.floor(delta / 3_600_000)
20+
return `${hours}h ago`
21+
}
22+
const days = Math.floor(delta / 86_400_000)
23+
return `${days}d ago`
24+
}
25+
26+
export function DialogTimestamp(props: DialogTimestampProps) {
27+
const dialog = useDialog()
28+
const { theme } = useTheme()
29+
30+
useBindings(() => ({
31+
bindings: [
32+
{
33+
key: "return",
34+
desc: "Close",
35+
group: "Dialog",
36+
cmd: () => dialog.clear(),
37+
},
38+
],
39+
}))
40+
41+
return (
42+
<box paddingLeft={2} paddingRight={2} gap={1}>
43+
<box flexDirection="row" justifyContent="space-between">
44+
<text attributes={TextAttributes.BOLD} fg={theme.text}>
45+
Message sent
46+
</text>
47+
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
48+
esc
49+
</text>
50+
</box>
51+
<box paddingBottom={1} gap={1}>
52+
<text fg={theme.text}>{Locale.datetime(props.created)}</text>
53+
<text fg={theme.textMuted}>{relative(props.created, Date.now())}</text>
54+
</box>
55+
</box>
56+
)
57+
}
58+
59+
DialogTimestamp.show = (dialog: DialogContext, created: number) => {
60+
dialog.replace(() => <DialogTimestamp created={created} />)
61+
}

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 107 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import { useEditorContext } from "@tui/context/editor"
5656
import { useDialog } from "../../ui/dialog"
5757
import { TodoItem } from "../../component/todo-item"
5858
import { DialogMessage } from "./dialog-message"
59+
import { DialogTimestamp } from "./dialog-timestamp"
5960
import type { PromptInfo } from "../../component/prompt/history"
6061
import { DialogConfirm } from "@tui/ui/dialog-confirm"
6162
import { DialogTimeline } from "./dialog-timeline"
@@ -83,6 +84,13 @@ import { UI } from "@/cli/ui.ts"
8384
import { useTuiConfig } from "../../context/tui-config"
8485
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
8586
import { getScrollAcceleration } from "../../util/scroll"
87+
import {
88+
getTimestampsMode,
89+
hourMinute,
90+
nextTimestampsMode,
91+
normalizeTimestampsMode,
92+
type TimestampsMode,
93+
} from "../../util/timestamps"
8694
import { collapseToolOutput } from "../../util/collapse-tool-output"
8795
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
8896
import { DialogRetryAction } from "../../component/dialog-retry-action"
@@ -160,6 +168,7 @@ const context = createContext<{
160168
thinkingMode: () => ThinkingMode
161169
showThinking: () => boolean
162170
showTimestamps: () => boolean
171+
timestampsMode: () => TimestampsMode
163172
showDetails: () => boolean
164173
showGenericToolOutput: () => boolean
165174
diffWrapMode: () => "word" | "none"
@@ -218,7 +227,12 @@ export function Session() {
218227
const thinking = useThinkingMode()
219228
const thinkingMode = thinking.mode
220229
const showThinking = createMemo(() => true)
221-
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
230+
// The kv signal was historically "hide" | "show"; expanded to include "footer"
231+
// and "gutter". Default seeded from tui.json (timestamps_mode, default "hide").
232+
// Legacy "show" values are normalized to "footer" so users keep their toggle.
233+
const timestampsDefault: TimestampsMode = getTimestampsMode(tuiConfig)
234+
const [timestampsRaw, setTimestamps] = kv.signal<TimestampsMode>("timestamps", timestampsDefault)
235+
const timestamps = createMemo(() => normalizeTimestampsMode(timestampsRaw(), timestampsDefault))
222236
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
223237
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
224238
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
@@ -233,7 +247,11 @@ export function Session() {
233247
if (sidebar() === "auto" && wide()) return true
234248
return false
235249
})
236-
const showTimestamps = createMemo(() => timestamps() === "show")
250+
// Backwards-compatible alias: existing call sites that gate the footer-style
251+
// timestamp render check `showTimestamps()`. It now means "render the footer",
252+
// which is the "footer" mode only — gutter mode renders the time elsewhere.
253+
const timestampsMode = createMemo<TimestampsMode>(() => timestamps())
254+
const showTimestamps = createMemo(() => timestampsMode() === "footer")
237255
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
238256
const providers = createMemo(() => Model.index(sync.data.provider))
239257

@@ -673,15 +691,21 @@ export function Session() {
673691
},
674692
},
675693
{
676-
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
694+
title: (() => {
695+
const next = nextTimestampsMode(timestampsMode())
696+
if (next === "hide") return "Hide timestamps"
697+
if (next === "footer") return "Show timestamps under message"
698+
return "Show timestamps in gutter"
699+
})(),
677700
value: "session.toggle.timestamps",
678701
category: "Session",
679702
slash: {
680703
name: "timestamps",
681704
aliases: ["toggle-timestamps"],
682705
},
683706
run: () => {
684-
setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
707+
const next = nextTimestampsMode(timestampsMode())
708+
setTimestamps(() => next)
685709
dialog.clear()
686710
},
687711
},
@@ -1096,6 +1120,7 @@ export function Session() {
10961120
thinkingMode,
10971121
showThinking,
10981122
showTimestamps,
1123+
timestampsMode,
10991124
showDetails,
11001125
showGenericToolOutput,
11011126
diffWrapMode,
@@ -1293,6 +1318,10 @@ const MIME_BADGE: Record<string, string> = {
12931318
"application/x-directory": "dir",
12941319
}
12951320

1321+
// Fixed gutter width: 5 cells for "HH:MM" + 1 trailing space. Hardcoded so the
1322+
// gutter column stays aligned across messages and never depends on locale.
1323+
const TIMESTAMP_GUTTER_WIDTH = 6
1324+
12961325
function UserMessage(props: {
12971326
message: UserMessage
12981327
parts: Part[]
@@ -1302,6 +1331,7 @@ function UserMessage(props: {
13021331
}) {
13031332
const ctx = use()
13041333
const local = useLocal()
1334+
const dialog = useDialog()
13051335
const text = createMemo(() => {
13061336
const texts = props.parts
13071337
.map((x) => {
@@ -1320,69 +1350,88 @@ function UserMessage(props: {
13201350
const color = createMemo(() => local.agent.color(props.message.agent))
13211351
const queuedFg = createMemo(() => selectedForeground(theme, color()))
13221352
const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
1353+
const isGutter = createMemo(() => ctx.timestampsMode() === "gutter")
13231354

13241355
const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
13251356

13261357
return (
13271358
<>
13281359
<Show when={text()}>
1329-
<box
1330-
id={props.message.id}
1331-
border={["left"]}
1332-
borderColor={color()}
1333-
customBorderChars={SplitBorder.customBorderChars}
1334-
marginTop={props.index === 0 ? 0 : 1}
1335-
>
1360+
<box flexDirection="row" marginTop={props.index === 0 ? 0 : 1} flexShrink={0}>
1361+
<Show when={isGutter()}>
1362+
<box
1363+
width={TIMESTAMP_GUTTER_WIDTH}
1364+
paddingTop={1}
1365+
flexShrink={0}
1366+
onMouseUp={() => DialogTimestamp.show(dialog, props.message.time.created)}
1367+
>
1368+
<text fg={theme.textMuted}>{hourMinute(props.message.time.created)}</text>
1369+
</box>
1370+
</Show>
13361371
<box
1337-
onMouseOver={() => {
1338-
setHover(true)
1339-
}}
1340-
onMouseOut={() => {
1341-
setHover(false)
1342-
}}
1343-
onMouseUp={props.onMouseUp}
1344-
paddingTop={1}
1345-
paddingBottom={1}
1346-
paddingLeft={2}
1347-
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
1348-
flexShrink={0}
1372+
id={props.message.id}
1373+
border={["left"]}
1374+
borderColor={color()}
1375+
customBorderChars={SplitBorder.customBorderChars}
1376+
flexGrow={1}
13491377
>
1350-
<text fg={theme.text}>{text()}</text>
1351-
<Show when={files().length}>
1352-
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
1353-
<For each={files()}>
1354-
{(file) => {
1355-
const bg = createMemo(() => {
1356-
if (file.mime.startsWith("image/")) return theme.accent
1357-
if (file.mime === "application/pdf") return theme.primary
1358-
return theme.secondary
1359-
})
1360-
return (
1361-
<text fg={theme.text}>
1362-
<span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
1363-
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
1364-
</text>
1365-
)
1366-
}}
1367-
</For>
1368-
</box>
1369-
</Show>
1370-
<Show
1371-
when={queued()}
1372-
fallback={
1373-
<Show when={ctx.showTimestamps()}>
1374-
<text fg={theme.textMuted}>
1375-
<span style={{ fg: theme.textMuted }}>
1376-
{Locale.todayTimeOrDateTime(props.message.time.created)}
1377-
</span>
1378-
</text>
1379-
</Show>
1380-
}
1378+
<box
1379+
onMouseOver={() => {
1380+
setHover(true)
1381+
}}
1382+
onMouseOut={() => {
1383+
setHover(false)
1384+
}}
1385+
onMouseUp={props.onMouseUp}
1386+
paddingTop={1}
1387+
paddingBottom={1}
1388+
paddingLeft={2}
1389+
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
1390+
flexShrink={0}
13811391
>
1382-
<text fg={theme.textMuted}>
1383-
<span style={{ bg: color(), fg: queuedFg(), bold: true }}> QUEUED </span>
1384-
</text>
1385-
</Show>
1392+
<text fg={theme.text}>{text()}</text>
1393+
<Show when={files().length}>
1394+
<box
1395+
flexDirection="row"
1396+
paddingBottom={metadataVisible() ? 1 : 0}
1397+
paddingTop={1}
1398+
gap={1}
1399+
flexWrap="wrap"
1400+
>
1401+
<For each={files()}>
1402+
{(file) => {
1403+
const bg = createMemo(() => {
1404+
if (file.mime.startsWith("image/")) return theme.accent
1405+
if (file.mime === "application/pdf") return theme.primary
1406+
return theme.secondary
1407+
})
1408+
return (
1409+
<text fg={theme.text}>
1410+
<span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
1411+
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
1412+
</text>
1413+
)
1414+
}}
1415+
</For>
1416+
</box>
1417+
</Show>
1418+
<Show
1419+
when={queued()}
1420+
fallback={
1421+
<Show when={ctx.showTimestamps()}>
1422+
<text fg={theme.textMuted}>
1423+
<span style={{ fg: theme.textMuted }}>
1424+
{Locale.todayTimeOrDateTime(props.message.time.created)}
1425+
</span>
1426+
</text>
1427+
</Show>
1428+
}
1429+
>
1430+
<text fg={theme.textMuted}>
1431+
<span style={{ bg: color(), fg: queuedFg(), bold: true }}> QUEUED </span>
1432+
</text>
1433+
</Show>
1434+
</box>
13861435
</box>
13871436
</box>
13881437
</Show>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
2+
3+
export type TimestampsMode = "hide" | "footer" | "gutter"
4+
5+
export const TIMESTAMPS_MODES: readonly TimestampsMode[] = ["hide", "footer", "gutter"] as const
6+
7+
export function getTimestampsMode(tuiConfig?: Pick<TuiConfig.Info, "timestamps_mode">): TimestampsMode {
8+
return tuiConfig?.timestamps_mode ?? "hide"
9+
}
10+
11+
// Cycles in display-priority order so the slash command feels predictable:
12+
// hide → footer → gutter → hide.
13+
export function nextTimestampsMode(current: TimestampsMode): TimestampsMode {
14+
const i = TIMESTAMPS_MODES.indexOf(current)
15+
return TIMESTAMPS_MODES[(i + 1) % TIMESTAMPS_MODES.length]
16+
}
17+
18+
// Normalize legacy KV values: an existing user toggled "show" before this
19+
// change shipped — preserve their intent by mapping it to "footer".
20+
export function normalizeTimestampsMode(value: unknown, fallback: TimestampsMode): TimestampsMode {
21+
if (value === "show") return "footer"
22+
if (value === "hide" || value === "footer" || value === "gutter") return value
23+
return fallback
24+
}
25+
26+
// Fixed 5-cell "HH:MM" in the user's local timezone, 24-hour. Locale-independent
27+
// so the gutter column stays aligned across en-US (12h) and en-GB (24h) users.
28+
export function hourMinute(input: number): string {
29+
const date = new Date(input)
30+
const hours = String(date.getHours()).padStart(2, "0")
31+
const minutes = String(date.getMinutes()).padStart(2, "0")
32+
return `${hours}:${minutes}`
33+
}

0 commit comments

Comments
 (0)