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.