diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
index 2c99f2a5ef61..eda59a831179 100644
--- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
+++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
@@ -52,6 +52,11 @@ export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
})
+export const TimestampsMode = Schema.Literals(["hide", "footer", "gutter"]).annotate({
+ description:
+ "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",
+})
+
export const Attention = Schema.Struct({
enabled: Schema.optional(Schema.Boolean),
notifications: Schema.optional(Schema.Boolean),
@@ -75,4 +80,5 @@ export const TuiInfo = Schema.Struct({
scroll_acceleration: Schema.optional(ScrollAcceleration),
diff_style: Schema.optional(DiffStyle),
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
+ timestamps_mode: Schema.optional(TimestampsMode),
})
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timestamp.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timestamp.tsx
new file mode 100644
index 000000000000..6f6893ab7bb9
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timestamp.tsx
@@ -0,0 +1,61 @@
+import { TextAttributes } from "@opentui/core"
+import { useTheme } from "../../context/theme"
+import { useDialog, type DialogContext } from "../../ui/dialog"
+import { useBindings } from "../../keymap"
+import { Locale } from "@/util/locale"
+
+export type DialogTimestampProps = {
+ created: number
+}
+
+function relative(input: number, now: number): string {
+ const delta = Math.max(0, now - input)
+ if (delta < 60_000) return "just now"
+ if (delta < 3_600_000) {
+ const minutes = Math.floor(delta / 60_000)
+ return `${minutes}m ago`
+ }
+ if (delta < 86_400_000) {
+ const hours = Math.floor(delta / 3_600_000)
+ return `${hours}h ago`
+ }
+ const days = Math.floor(delta / 86_400_000)
+ return `${days}d ago`
+}
+
+export function DialogTimestamp(props: DialogTimestampProps) {
+ const dialog = useDialog()
+ const { theme } = useTheme()
+
+ useBindings(() => ({
+ bindings: [
+ {
+ key: "return",
+ desc: "Close",
+ group: "Dialog",
+ cmd: () => dialog.clear(),
+ },
+ ],
+ }))
+
+ return (
+
+
+
+ Message sent
+
+ dialog.clear()}>
+ esc
+
+
+
+ {Locale.datetime(props.created)}
+ {relative(props.created, Date.now())}
+
+
+ )
+}
+
+DialogTimestamp.show = (dialog: DialogContext, created: number) => {
+ dialog.replace(() => )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index ae23d58bcc95..74762008ddec 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -56,6 +56,7 @@ import { useEditorContext } from "@tui/context/editor"
import { useDialog } from "../../ui/dialog"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
+import { DialogTimestamp } from "./dialog-timestamp"
import type { PromptInfo } from "../../component/prompt/history"
import { DialogConfirm } from "@tui/ui/dialog-confirm"
import { DialogTimeline } from "./dialog-timeline"
@@ -83,6 +84,13 @@ import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
+import {
+ getTimestampsMode,
+ hourMinute,
+ nextTimestampsMode,
+ normalizeTimestampsMode,
+ type TimestampsMode,
+} from "../../util/timestamps"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action"
@@ -160,6 +168,7 @@ const context = createContext<{
thinkingMode: () => ThinkingMode
showThinking: () => boolean
showTimestamps: () => boolean
+ timestampsMode: () => TimestampsMode
showDetails: () => boolean
showGenericToolOutput: () => boolean
diffWrapMode: () => "word" | "none"
@@ -218,7 +227,12 @@ export function Session() {
const thinking = useThinkingMode()
const thinkingMode = thinking.mode
const showThinking = createMemo(() => true)
- const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
+ // The kv signal was historically "hide" | "show"; expanded to include "footer"
+ // and "gutter". Default seeded from tui.json (timestamps_mode, default "hide").
+ // Legacy "show" values are normalized to "footer" so users keep their toggle.
+ const timestampsDefault: TimestampsMode = getTimestampsMode(tuiConfig)
+ const [timestampsRaw, setTimestamps] = kv.signal("timestamps", timestampsDefault)
+ const timestamps = createMemo(() => normalizeTimestampsMode(timestampsRaw(), timestampsDefault))
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
@@ -233,7 +247,11 @@ export function Session() {
if (sidebar() === "auto" && wide()) return true
return false
})
- const showTimestamps = createMemo(() => timestamps() === "show")
+ // Backwards-compatible alias: existing call sites that gate the footer-style
+ // timestamp render check `showTimestamps()`. It now means "render the footer",
+ // which is the "footer" mode only — gutter mode renders the time elsewhere.
+ const timestampsMode = createMemo(() => timestamps())
+ const showTimestamps = createMemo(() => timestampsMode() === "footer")
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const providers = createMemo(() => Model.index(sync.data.provider))
@@ -673,7 +691,12 @@ export function Session() {
},
},
{
- title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
+ title: (() => {
+ const next = nextTimestampsMode(timestampsMode())
+ if (next === "hide") return "Hide timestamps"
+ if (next === "footer") return "Show timestamps under message"
+ return "Show timestamps in gutter"
+ })(),
value: "session.toggle.timestamps",
category: "Session",
slash: {
@@ -681,7 +704,8 @@ export function Session() {
aliases: ["toggle-timestamps"],
},
run: () => {
- setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
+ const next = nextTimestampsMode(timestampsMode())
+ setTimestamps(() => next)
dialog.clear()
},
},
@@ -1096,6 +1120,7 @@ export function Session() {
thinkingMode,
showThinking,
showTimestamps,
+ timestampsMode,
showDetails,
showGenericToolOutput,
diffWrapMode,
@@ -1293,6 +1318,10 @@ const MIME_BADGE: Record = {
"application/x-directory": "dir",
}
+// Fixed gutter width: 5 cells for "HH:MM" + 1 trailing space. Hardcoded so the
+// gutter column stays aligned across messages and never depends on locale.
+const TIMESTAMP_GUTTER_WIDTH = 6
+
function UserMessage(props: {
message: UserMessage
parts: Part[]
@@ -1302,6 +1331,7 @@ function UserMessage(props: {
}) {
const ctx = use()
const local = useLocal()
+ const dialog = useDialog()
const text = createMemo(() => {
const texts = props.parts
.map((x) => {
@@ -1320,69 +1350,88 @@ function UserMessage(props: {
const color = createMemo(() => local.agent.color(props.message.agent))
const queuedFg = createMemo(() => selectedForeground(theme, color()))
const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
+ const isGutter = createMemo(() => ctx.timestampsMode() === "gutter")
const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
return (
<>
-
+
+
+ DialogTimestamp.show(dialog, props.message.time.created)}
+ >
+ {hourMinute(props.message.time.created)}
+
+
{
- setHover(true)
- }}
- onMouseOut={() => {
- setHover(false)
- }}
- onMouseUp={props.onMouseUp}
- paddingTop={1}
- paddingBottom={1}
- paddingLeft={2}
- backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
- flexShrink={0}
+ id={props.message.id}
+ border={["left"]}
+ borderColor={color()}
+ customBorderChars={SplitBorder.customBorderChars}
+ flexGrow={1}
>
- {text()}
-
-
-
- {(file) => {
- const bg = createMemo(() => {
- if (file.mime.startsWith("image/")) return theme.accent
- if (file.mime === "application/pdf") return theme.primary
- return theme.secondary
- })
- return (
-
- {MIME_BADGE[file.mime] ?? file.mime}
- {file.filename}
-
- )
- }}
-
-
-
-
-
-
- {Locale.todayTimeOrDateTime(props.message.time.created)}
-
-
-
- }
+ {
+ setHover(true)
+ }}
+ onMouseOut={() => {
+ setHover(false)
+ }}
+ onMouseUp={props.onMouseUp}
+ paddingTop={1}
+ paddingBottom={1}
+ paddingLeft={2}
+ backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
+ flexShrink={0}
>
-
- QUEUED
-
-
+ {text()}
+
+
+
+ {(file) => {
+ const bg = createMemo(() => {
+ if (file.mime.startsWith("image/")) return theme.accent
+ if (file.mime === "application/pdf") return theme.primary
+ return theme.secondary
+ })
+ return (
+
+ {MIME_BADGE[file.mime] ?? file.mime}
+ {file.filename}
+
+ )
+ }}
+
+
+
+
+
+
+ {Locale.todayTimeOrDateTime(props.message.time.created)}
+
+
+
+ }
+ >
+
+ QUEUED
+
+
+
diff --git a/packages/opencode/src/cli/cmd/tui/util/timestamps.ts b/packages/opencode/src/cli/cmd/tui/util/timestamps.ts
new file mode 100644
index 000000000000..9cbae5793448
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/util/timestamps.ts
@@ -0,0 +1,33 @@
+import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
+
+export type TimestampsMode = "hide" | "footer" | "gutter"
+
+export const TIMESTAMPS_MODES: readonly TimestampsMode[] = ["hide", "footer", "gutter"] as const
+
+export function getTimestampsMode(tuiConfig?: Pick): TimestampsMode {
+ return tuiConfig?.timestamps_mode ?? "hide"
+}
+
+// Cycles in display-priority order so the slash command feels predictable:
+// hide → footer → gutter → hide.
+export function nextTimestampsMode(current: TimestampsMode): TimestampsMode {
+ const i = TIMESTAMPS_MODES.indexOf(current)
+ return TIMESTAMPS_MODES[(i + 1) % TIMESTAMPS_MODES.length]
+}
+
+// Normalize legacy KV values: an existing user toggled "show" before this
+// change shipped — preserve their intent by mapping it to "footer".
+export function normalizeTimestampsMode(value: unknown, fallback: TimestampsMode): TimestampsMode {
+ if (value === "show") return "footer"
+ if (value === "hide" || value === "footer" || value === "gutter") return value
+ return fallback
+}
+
+// Fixed 5-cell "HH:MM" in the user's local timezone, 24-hour. Locale-independent
+// so the gutter column stays aligned across en-US (12h) and en-GB (24h) users.
+export function hourMinute(input: number): string {
+ const date = new Date(input)
+ const hours = String(date.getHours()).padStart(2, "0")
+ const minutes = String(date.getMinutes()).padStart(2, "0")
+ return `${hours}:${minutes}`
+}
diff --git a/packages/opencode/test/cli/cmd/tui/timestamps.test.ts b/packages/opencode/test/cli/cmd/tui/timestamps.test.ts
new file mode 100644
index 000000000000..e303fe21db1a
--- /dev/null
+++ b/packages/opencode/test/cli/cmd/tui/timestamps.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, test } from "bun:test"
+import {
+ getTimestampsMode,
+ hourMinute,
+ nextTimestampsMode,
+ normalizeTimestampsMode,
+ TIMESTAMPS_MODES,
+} from "@/cli/cmd/tui/util/timestamps"
+
+describe("getTimestampsMode", () => {
+ test("returns 'hide' when config is undefined", () => {
+ expect(getTimestampsMode(undefined)).toBe("hide")
+ })
+
+ test("returns 'hide' when timestamps_mode is missing", () => {
+ expect(getTimestampsMode({})).toBe("hide")
+ })
+
+ test("returns the configured value when present", () => {
+ expect(getTimestampsMode({ timestamps_mode: "footer" })).toBe("footer")
+ expect(getTimestampsMode({ timestamps_mode: "gutter" })).toBe("gutter")
+ expect(getTimestampsMode({ timestamps_mode: "hide" })).toBe("hide")
+ })
+})
+
+describe("nextTimestampsMode", () => {
+ test("cycles hide → footer → gutter → hide", () => {
+ expect(nextTimestampsMode("hide")).toBe("footer")
+ expect(nextTimestampsMode("footer")).toBe("gutter")
+ expect(nextTimestampsMode("gutter")).toBe("hide")
+ })
+
+ test("covers every declared mode exactly once per full cycle", () => {
+ const seen = new Set()
+ let current = TIMESTAMPS_MODES[0]
+ for (let i = 0; i < TIMESTAMPS_MODES.length; i++) {
+ seen.add(current)
+ current = nextTimestampsMode(current)
+ }
+ expect(seen.size).toBe(TIMESTAMPS_MODES.length)
+ expect(current).toBe(TIMESTAMPS_MODES[0])
+ })
+})
+
+describe("normalizeTimestampsMode", () => {
+ test("maps legacy 'show' to 'footer'", () => {
+ expect(normalizeTimestampsMode("show", "hide")).toBe("footer")
+ })
+
+ test("passes through valid modes", () => {
+ expect(normalizeTimestampsMode("hide", "footer")).toBe("hide")
+ expect(normalizeTimestampsMode("footer", "hide")).toBe("footer")
+ expect(normalizeTimestampsMode("gutter", "hide")).toBe("gutter")
+ })
+
+ test("falls back for unknown values", () => {
+ expect(normalizeTimestampsMode(undefined, "hide")).toBe("hide")
+ expect(normalizeTimestampsMode(null, "footer")).toBe("footer")
+ expect(normalizeTimestampsMode("bogus", "gutter")).toBe("gutter")
+ expect(normalizeTimestampsMode(42, "hide")).toBe("hide")
+ })
+})
+
+describe("hourMinute", () => {
+ test("returns a 5-cell HH:MM string", () => {
+ // 2025-01-02T07:05:00 local — exact components depend on tz, but length is fixed.
+ const ms = new Date(2025, 0, 2, 7, 5, 0).getTime()
+ const out = hourMinute(ms)
+ expect(out).toHaveLength(5)
+ expect(out).toMatch(/^\d{2}:\d{2}$/)
+ })
+
+ test("pads single-digit hours and minutes", () => {
+ const ms = new Date(2025, 0, 2, 3, 9, 0).getTime()
+ expect(hourMinute(ms)).toBe("03:09")
+ })
+
+ test("uses 24-hour clock", () => {
+ const ms = new Date(2025, 0, 2, 23, 45, 0).getTime()
+ expect(hourMinute(ms)).toBe("23:45")
+ })
+})
diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx
index cd668a3beff8..8a9488eda10a 100644
--- a/packages/web/src/content/docs/tui.mdx
+++ b/packages/web/src/content/docs/tui.mdx
@@ -370,6 +370,7 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
},
"diff_style": "auto",
"mouse": true,
+ "timestamps_mode": "hide",
"attention": {
"enabled": true,
"notifications": true,
@@ -396,6 +397,7 @@ This is separate from `opencode.json`, which configures server/runtime behavior.
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
- `mouse` - Enable or disable mouse capture in the TUI (default: `true`). When disabled, the terminal's native mouse selection/scrolling behavior is preserved.
+- `timestamps_mode` - Sets the initial timestamp display mode for user messages. `"hide"` (default) shows nothing, `"footer"` shows the time under each user message, `"gutter"` shows a fixed `HH:MM` column to the left of each user message (click it to see the full date). The `/timestamps` slash command cycles between all three modes at runtime; the config key only seeds the default for new sessions.
- `attention` - Configures TUI desktop notifications and sounds. Disabled by default.
Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path.