Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
})
Original file line number Diff line number Diff line change
@@ -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 (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Message sent
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingBottom={1} gap={1}>
<text fg={theme.text}>{Locale.datetime(props.created)}</text>
<text fg={theme.textMuted}>{relative(props.created, Date.now())}</text>
</box>
</box>
)
}

DialogTimestamp.show = (dialog: DialogContext, created: number) => {
dialog.replace(() => <DialogTimestamp created={created} />)
}
165 changes: 107 additions & 58 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -160,6 +168,7 @@ const context = createContext<{
thinkingMode: () => ThinkingMode
showThinking: () => boolean
showTimestamps: () => boolean
timestampsMode: () => TimestampsMode
showDetails: () => boolean
showGenericToolOutput: () => boolean
diffWrapMode: () => "word" | "none"
Expand Down Expand Up @@ -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<TimestampsMode>("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)
Expand All @@ -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<TimestampsMode>(() => timestamps())
const showTimestamps = createMemo(() => timestampsMode() === "footer")
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const providers = createMemo(() => Model.index(sync.data.provider))

Expand Down Expand Up @@ -673,15 +691,21 @@ 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: {
name: "timestamps",
aliases: ["toggle-timestamps"],
},
run: () => {
setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
const next = nextTimestampsMode(timestampsMode())
setTimestamps(() => next)
dialog.clear()
},
},
Expand Down Expand Up @@ -1096,6 +1120,7 @@ export function Session() {
thinkingMode,
showThinking,
showTimestamps,
timestampsMode,
showDetails,
showGenericToolOutput,
diffWrapMode,
Expand Down Expand Up @@ -1293,6 +1318,10 @@ const MIME_BADGE: Record<string, string> = {
"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[]
Expand All @@ -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) => {
Expand All @@ -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 (
<>
<Show when={text()}>
<box
id={props.message.id}
border={["left"]}
borderColor={color()}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
>
<box flexDirection="row" marginTop={props.index === 0 ? 0 : 1} flexShrink={0}>
<Show when={isGutter()}>
<box
width={TIMESTAMP_GUTTER_WIDTH}
paddingTop={1}
flexShrink={0}
onMouseUp={() => DialogTimestamp.show(dialog, props.message.time.created)}
>
<text fg={theme.textMuted}>{hourMinute(props.message.time.created)}</text>
</box>
</Show>
<box
onMouseOver={() => {
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 fg={theme.text}>{text()}</text>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
{(file) => {
const bg = createMemo(() => {
if (file.mime.startsWith("image/")) return theme.accent
if (file.mime === "application/pdf") return theme.primary
return theme.secondary
})
return (
<text fg={theme.text}>
<span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
</text>
)
}}
</For>
</box>
</Show>
<Show
when={queued()}
fallback={
<Show when={ctx.showTimestamps()}>
<text fg={theme.textMuted}>
<span style={{ fg: theme.textMuted }}>
{Locale.todayTimeOrDateTime(props.message.time.created)}
</span>
</text>
</Show>
}
<box
onMouseOver={() => {
setHover(true)
}}
onMouseOut={() => {
setHover(false)
}}
onMouseUp={props.onMouseUp}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
flexShrink={0}
>
<text fg={theme.textMuted}>
<span style={{ bg: color(), fg: queuedFg(), bold: true }}> QUEUED </span>
</text>
</Show>
<text fg={theme.text}>{text()}</text>
<Show when={files().length}>
<box
flexDirection="row"
paddingBottom={metadataVisible() ? 1 : 0}
paddingTop={1}
gap={1}
flexWrap="wrap"
>
<For each={files()}>
{(file) => {
const bg = createMemo(() => {
if (file.mime.startsWith("image/")) return theme.accent
if (file.mime === "application/pdf") return theme.primary
return theme.secondary
})
return (
<text fg={theme.text}>
<span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
</text>
)
}}
</For>
</box>
</Show>
<Show
when={queued()}
fallback={
<Show when={ctx.showTimestamps()}>
<text fg={theme.textMuted}>
<span style={{ fg: theme.textMuted }}>
{Locale.todayTimeOrDateTime(props.message.time.created)}
</span>
</text>
</Show>
}
>
<text fg={theme.textMuted}>
<span style={{ bg: color(), fg: queuedFg(), bold: true }}> QUEUED </span>
</text>
</Show>
</box>
</box>
</box>
</Show>
Expand Down
33 changes: 33 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/timestamps.ts
Original file line number Diff line number Diff line change
@@ -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<TuiConfig.Info, "timestamps_mode">): 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}`
}
Loading
Loading