0}>
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 1789cbfdc47..469169e1f0a 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -92,6 +92,7 @@ function AssistantMessageItem(props: {
responsePartId: string | undefined
hideResponsePart: boolean
hideReasoning: boolean
+ anchorId?: string
}) {
const data = useData()
const emptyParts: PartType[] = []
@@ -121,7 +122,7 @@ function AssistantMessageItem(props: {
return parts.filter((part) => part?.id !== responsePartId)
})
- return
+ return
}
export function SessionTurn(
@@ -605,18 +606,19 @@ export function SessionTurn(
{/* Response */}
- 0}>
-
-
- {(assistantMessage) => (
-
- )}
-
+
0}>
+
+
+ {(assistantMessage) => (
+
+ )}
+
{error()?.data?.message as string}
From 283f5ab293a114a38d92f7af1ea8d5d868085cf8 Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Mon, 26 Jan 2026 03:35:52 -0500
Subject: [PATCH 04/10] Add n and p keybinds for user message navigation in
timeline
- Added useKeyboard hook to intercept n and p keys in DialogTimeline
- Implemented navigation that skips assistant messages and only navigates user messages
- Pressing n moves to next user message in timeline
- Pressing p moves to previous user message in timeline
- Navigation respects message boundaries and works correctly with filters
- Tracks selected message ID to maintain state between navigation methods
---
.../tui/routes/session/dialog-timeline.tsx | 51 +++++++++++++++++--
1 file changed, 48 insertions(+), 3 deletions(-)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
index 4b608f2f83a..f08082d71d2 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
@@ -1,4 +1,4 @@
-import { createMemo, onMount } from "solid-js"
+import { createMemo, onMount, createSignal } from "solid-js"
import { useSync } from "@tui/context/sync"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select"
import type { Part, Message, AssistantMessage, ToolPart, FilePart } from "@opencode-ai/sdk/v2"
@@ -15,6 +15,7 @@ import path from "path"
import { produce } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { Global } from "@/global"
+import { useKeyboard } from "@opentui/solid"
// Module-level variable to store the selected message when opening details
let timelineSelection: string | undefined
@@ -118,6 +119,7 @@ export function DialogTimeline(props: {
timelineSelection = undefined
let selectRef: DialogSelectRef | undefined
+ const [selectedMessageID, setSelectedMessageID] = createSignal(undefined)
onMount(() => {
dialog.setSize("large")
@@ -130,6 +132,46 @@ export function DialogTimeline(props: {
}
})
+ useKeyboard((evt) => {
+ // Only handle 'n' and 'p' without any modifiers
+ if (evt.ctrl || evt.meta || evt.shift) return
+
+ const opts = options()
+ if (opts.length === 0) return
+
+ const currentIndex = opts.findIndex(opt => opt.value === selectedMessageID())
+
+ if (evt.name === "n") {
+ evt.preventDefault()
+ evt.stopPropagation()
+ // Find next user message
+ for (let i = currentIndex + 1; i < opts.length; i++) {
+ const msgID = opts[i].value
+ const msg = sync.message[props.sessionID]?.find(m => m.id === msgID)
+ if (msg && msg.role === "user") {
+ setSelectedMessageID(msgID)
+ props.onMove(msgID)
+ break
+ }
+ }
+ }
+
+ if (evt.name === "p") {
+ evt.preventDefault()
+ evt.stopPropagation()
+ // Find previous user message
+ for (let i = currentIndex - 1; i >= 0; i--) {
+ const msgID = opts[i].value
+ const msg = sync.message[props.sessionID]?.find(m => m.id === msgID)
+ if (msg && msg.role === "user") {
+ setSelectedMessageID(msgID)
+ props.onMove(msgID)
+ break
+ }
+ }
+ }
+ })
+
const options = createMemo((): DialogSelectOption[] => {
const messages = sync.message[props.sessionID] ?? []
const result = [] as DialogSelectOption[]
@@ -271,7 +313,10 @@ export function DialogTimeline(props: {
ref={(r) => {
selectRef = r
}}
- onMove={(option) => props.onMove(option.value)}
+ onMove={(option) => {
+ setSelectedMessageID(option.value)
+ props.onMove(option.value)
+ }}
title="Timeline"
options={options()}
keybind={[
@@ -289,7 +334,7 @@ export function DialogTimeline(props: {
const messageID = option.value
const message = sync.message[props.sessionID]?.find((m) => m.id === messageID)
const parts = sync.part[messageID] ?? []
-
+
if (message && message.role === "assistant") {
// Store the current selection before opening details
timelineSelection = messageID
From 8523d9cf791e770986f3264ddcc401c023436cd9 Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Mon, 26 Jan 2026 04:20:25 -0500
Subject: [PATCH 05/10] Add n and p keybinds for user message navigation in
timeline
Fixed navigation to properly move selection highlight in timeline dialog
by using selectRef.moveToValue() instead of calling onMove directly.
Keys now appear in help text and skip assistant messages.
---
.../tui/routes/session/dialog-timeline.tsx | 81 ++++++++-----------
1 file changed, 33 insertions(+), 48 deletions(-)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
index f08082d71d2..98cbb7280ec 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
@@ -1,4 +1,4 @@
-import { createMemo, onMount, createSignal } from "solid-js"
+import { createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select"
import type { Part, Message, AssistantMessage, ToolPart, FilePart } from "@opencode-ai/sdk/v2"
@@ -15,7 +15,6 @@ import path from "path"
import { produce } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { Global } from "@/global"
-import { useKeyboard } from "@opentui/solid"
// Module-level variable to store the selected message when opening details
let timelineSelection: string | undefined
@@ -119,7 +118,6 @@ export function DialogTimeline(props: {
timelineSelection = undefined
let selectRef: DialogSelectRef | undefined
- const [selectedMessageID, setSelectedMessageID] = createSignal(undefined)
onMount(() => {
dialog.setSize("large")
@@ -132,46 +130,6 @@ export function DialogTimeline(props: {
}
})
- useKeyboard((evt) => {
- // Only handle 'n' and 'p' without any modifiers
- if (evt.ctrl || evt.meta || evt.shift) return
-
- const opts = options()
- if (opts.length === 0) return
-
- const currentIndex = opts.findIndex(opt => opt.value === selectedMessageID())
-
- if (evt.name === "n") {
- evt.preventDefault()
- evt.stopPropagation()
- // Find next user message
- for (let i = currentIndex + 1; i < opts.length; i++) {
- const msgID = opts[i].value
- const msg = sync.message[props.sessionID]?.find(m => m.id === msgID)
- if (msg && msg.role === "user") {
- setSelectedMessageID(msgID)
- props.onMove(msgID)
- break
- }
- }
- }
-
- if (evt.name === "p") {
- evt.preventDefault()
- evt.stopPropagation()
- // Find previous user message
- for (let i = currentIndex - 1; i >= 0; i--) {
- const msgID = opts[i].value
- const msg = sync.message[props.sessionID]?.find(m => m.id === msgID)
- if (msg && msg.role === "user") {
- setSelectedMessageID(msgID)
- props.onMove(msgID)
- break
- }
- }
- }
- })
-
const options = createMemo((): DialogSelectOption[] => {
const messages = sync.message[props.sessionID] ?? []
const result = [] as DialogSelectOption[]
@@ -313,13 +271,40 @@ export function DialogTimeline(props: {
ref={(r) => {
selectRef = r
}}
- onMove={(option) => {
- setSelectedMessageID(option.value)
- props.onMove(option.value)
- }}
+ onMove={(option) => props.onMove(option.value)}
title="Timeline"
options={options()}
keybind={[
+ {
+ keybind: { name: "n", ctrl: false, meta: false, shift: false, leader: false },
+ title: "Next user",
+ onTrigger: (option) => {
+ const currentIdx = options().findIndex(opt => opt.value === option.value)
+ for (let i = currentIdx + 1; i < options().length; i++) {
+ const msgID = options()[i].value
+ const msg = sync.message[props.sessionID]?.find(m => m.id === msgID)
+ if (msg && msg.role === "user") {
+ selectRef?.moveToValue(msgID)
+ break
+ }
+ }
+ },
+ },
+ {
+ keybind: { name: "p", ctrl: false, meta: false, shift: false, leader: false },
+ title: "Previous user",
+ onTrigger: (option) => {
+ const currentIdx = options().findIndex(opt => opt.value === option.value)
+ for (let i = currentIdx - 1; i >= 0; i--) {
+ const msgID = options()[i].value
+ const msg = sync.message[props.sessionID]?.find(m => m.id === msgID)
+ if (msg && msg.role === "user") {
+ selectRef?.moveToValue(msgID)
+ break
+ }
+ }
+ },
+ },
{
keybind: { name: "delete", ctrl: false, meta: false, shift: false, leader: false },
title: "Delete",
@@ -334,7 +319,7 @@ export function DialogTimeline(props: {
const messageID = option.value
const message = sync.message[props.sessionID]?.find((m) => m.id === messageID)
const parts = sync.part[messageID] ?? []
-
+
if (message && message.role === "assistant") {
// Store the current selection before opening details
timelineSelection = messageID
From 760aee0b818b8dda44b4132ab793e97be4c7fb05 Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Mon, 26 Jan 2026 04:34:10 -0500
Subject: [PATCH 06/10] tidy: whitespace
---
.../opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
index 98cbb7280ec..07d4d80a17b 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
@@ -319,7 +319,7 @@ export function DialogTimeline(props: {
const messageID = option.value
const message = sync.message[props.sessionID]?.find((m) => m.id === messageID)
const parts = sync.part[messageID] ?? []
-
+
if (message && message.role === "assistant") {
// Store the current selection before opening details
timelineSelection = messageID
From 56b60f988bb7d90b8ea65f6fd955c108a39d2bb2 Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Mon, 26 Jan 2026 13:04:06 -0500
Subject: [PATCH 07/10] Fix missing createSignal import
---
packages/app/src/pages/session.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 3a81a6e1699..12af2f7d0a4 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1,4 +1,4 @@
-import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
+import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, createSignal, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
From 28e9fa708b53fba1fa0ff31d8a80bc03b3b5f1de Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Wed, 28 Jan 2026 23:25:48 -0500
Subject: [PATCH 08/10] Fix TypeScript errors introduced by merge
Fixed applyHash function logic to properly handle undefined message cases
and prevent TypeScript errors about potentially undefined values.
---
packages/app/src/pages/session.tsx | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 666f28a54b9..bc88544ab83 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1581,12 +1581,19 @@ export default function Page() {
return
}
- if (msg.role === "assistant") {
+ // Check if message exists in all messages (including hidden ones)
+ const allMsg = messages().find((m) => m.id === match[1])
+ if (!allMsg) {
+ if (visibleUserMessages().find((m) => m.id === match[1])) return
+ return
+ }
+
+ if (allMsg.role === "assistant") {
setPendingAssistantMessage(match[1])
return
}
- scrollToMessage(msg as UserMessage, behavior)
+ scrollToMessage(allMsg as UserMessage, behavior)
return
}
From 41a1b5ae21db80c302abef0a227d2279781fdd12 Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Fri, 30 Jan 2026 21:06:57 -0500
Subject: [PATCH 09/10] Change n/p keybinds to use Alt modifier
Changed 'n' and 'p' shortcuts to use Alt+N and Alt+P instead of plain keys.
This allows users to type 'n' and 'p' in the filter box while still having
convenient navigation shortcuts.
---
.../src/cli/cmd/tui/routes/session/dialog-timeline.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
index 07d4d80a17b..f3cfcf60b58 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
@@ -276,7 +276,7 @@ export function DialogTimeline(props: {
options={options()}
keybind={[
{
- keybind: { name: "n", ctrl: false, meta: false, shift: false, leader: false },
+ keybind: { name: "n", ctrl: false, meta: true, shift: false, leader: false },
title: "Next user",
onTrigger: (option) => {
const currentIdx = options().findIndex(opt => opt.value === option.value)
@@ -291,7 +291,7 @@ export function DialogTimeline(props: {
},
},
{
- keybind: { name: "p", ctrl: false, meta: false, shift: false, leader: false },
+ keybind: { name: "p", ctrl: false, meta: true, shift: false, leader: false },
title: "Previous user",
onTrigger: (option) => {
const currentIdx = options().findIndex(opt => opt.value === option.value)
From 77c6cb445ecc2d62006c6a82d2c0bd98c539c0d1 Mon Sep 17 00:00:00 2001
From: Ariane Emory
Date: Sat, 31 Jan 2026 02:09:16 -0500
Subject: [PATCH 10/10] Filter out '[no content]' messages from timeline
display and selection
Messages without text, tool, or file parts are now omitted from the
session_timeline to avoid cluttering the interface.
---
.../src/cli/cmd/tui/routes/session/dialog-timeline.tsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
index f3cfcf60b58..b2c19656f8e 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx
@@ -165,6 +165,9 @@ export function DialogTimeline(props: {
const summary = getMessageSummary(parts)
+ // Skip messages with no content
+ if (summary === "[no content]") continue
+
// Debug: Extract token breakdown for assistant messages
let tokenDebug = ""
if (message.role === "assistant") {