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 4/6] 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 5/6] 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 6/6] 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