feat(tui): add message navigation shortcuts

This commit is contained in:
starptech
2026-07-16 21:27:13 +02:00
parent 0d3b6d430e
commit 4f60bde502
8 changed files with 430 additions and 53 deletions
+7 -3
View File
@@ -136,9 +136,11 @@ export const Definitions = {
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
messages_first: keybind("ctrl+g,home", "Navigate to first message"),
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
messages_next: keybind("none", "Navigate to next message"),
messages_previous: keybind("none", "Navigate to previous message"),
messages_last_user: keybind("none", "Navigate to last user message"),
messages_next: keybind("alt+shift+down", "Navigate to next message"),
messages_previous: keybind("alt+shift+up", "Navigate to previous message"),
messages_next_user: keybind("alt+down", "Navigate to next user message"),
messages_previous_user: keybind("alt+up", "Navigate to previous user message"),
messages_last_user: keybind("alt+end", "Navigate to last user message"),
messages_copy: keybind("<leader>y", "Copy message"),
messages_undo: keybind("<leader>u", "Undo message"),
messages_redo: keybind("<leader>r", "Redo message"),
@@ -335,6 +337,8 @@ export const CommandMap = {
messages_last: "session.last",
messages_next: "session.message.next",
messages_previous: "session.message.previous",
messages_next_user: "session.message.user.next",
messages_previous_user: "session.message.user.previous",
messages_last_user: "session.messages_last_user",
messages_copy: "messages.copy",
messages_undo: "session.undo",
+99 -48
View File
@@ -71,11 +71,15 @@ import { usePluginRuntime } from "../../plugin/runtime"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows"
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
addDefaultParsers(parsers.parsers)
// Exclude temporary bottom space when measuring the real transcript height.
const NAVIGATION_SLACK_ID = "session-navigation-slack"
const context = createContext<{
width: number
sessionID: string
@@ -180,6 +184,23 @@ export function Session() {
const client = useClient()
const editor = useEditorContext()
const rows = createSessionRows(() => route.sessionID)
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
const [navigationMessage, setNavigationMessage] = createSignal<string>()
const [navigationSlack, setNavigationSlack] = createSignal(0)
const clearMessageNavigation = () => {
setNavigationSlack(0)
setNavigationMessage(undefined)
}
createEffect(
on(
() => [dimensions().width, dimensions().height] as const,
(_, previous) => {
if (previous) clearMessageNavigation()
},
),
)
createEffect(
on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => {
@@ -239,53 +260,55 @@ export function Session() {
dialog.clear()
}
// Helper: Find next visible message boundary in direction
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
const children = scroll.getChildren()
const messagesList = messages()
const scrollTop = scroll.y
// Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
const visibleMessages = children
.filter((c) => {
if (!c.id) return false
const message = messagesList.find((m) => m.id === c.id)
if (!message) return false
if (message.type === "user") return Boolean(message.text.trim())
return (
message.type === "assistant" &&
message.content.some((content) => content.type === "text" && content.text.trim())
)
const alignMessage = (messageID: string, top: number) => {
scroll.stickyScroll = false
setNavigationMessage(messageID)
setNavigationSlack(
messageNavigationSlack({
top,
viewportHeight: scroll.viewport.height,
scrollHeight: scroll.scrollHeight,
currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0,
}),
)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (scroll.isDestroyed || navigationMessage() !== messageID) return
scroll.scrollTo(top)
})
.sort((a, b) => a.y - b.y)
if (visibleMessages.length === 0) return null
if (direction === "next") {
// Find first message below current position
return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null
}
// Find last message above current position
return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null
})
}
// Helper: Scroll to message in direction or fallback to page scroll
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>) => {
const targetID = findNextVisibleMessage(direction)
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>, userOnly = false) => {
const target = findMessageBoundary({
direction,
children: scroll.getChildren(),
messages: messages(),
scrollTop: scroll.scrollTop,
viewportY: scroll.viewport.y,
currentID: navigationMessage(),
userOnly,
})
if (!targetID) {
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
if (!target) {
dialog.clear()
return
}
const child = scroll.getChildren().find((c) => c.id === targetID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
alignMessage(target.id, target.top)
dialog.clear()
}
const jumpToMessage = (messageID: string) => {
const child = scroll.getRenderable(messageID)
if (!child) return
const y = scroll.scrollTop + child.y - scroll.viewport.y
const message = data.session.message.get(route.sessionID, messageID)
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
}
function toBottom() {
clearMessageNavigation()
setTimeout(() => {
if (!scroll || scroll.isDestroyed) return
scroll.scrollTo(scroll.scrollHeight)
@@ -299,6 +322,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 2)
dialog.clear()
},
@@ -309,6 +333,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 2)
dialog.clear()
},
@@ -319,6 +344,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-1)
dialog.clear()
},
@@ -329,6 +355,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(1)
dialog.clear()
},
@@ -339,6 +366,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 4)
dialog.clear()
},
@@ -349,6 +377,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 4)
dialog.clear()
},
@@ -362,6 +391,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollTo(0)
dialog.clear()
},
@@ -372,6 +402,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollTo(scroll.scrollHeight)
dialog.clear()
},
@@ -412,8 +443,7 @@ export function Session() {
sessionID={route.sessionID}
onMove={(messageID) => {
if (!messageID) return
const child = scroll.getChildren().find((child) => child.id === messageID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
jumpToMessage(messageID)
}}
/>
))
@@ -574,10 +604,7 @@ export function Session() {
const message = messages[i]
if (!message || message.type !== "user" || !message.text.trim()) continue
{
const child = scroll.getChildren().find((child) => {
return child.id === message.id
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
jumpToMessage(message.id)
break
}
}
@@ -597,6 +624,20 @@ export function Session() {
hidden: true,
run: () => scrollToMessage("prev", dialog),
},
{
title: "Next user message",
name: "session.message.user.next",
category: "Session",
hidden: true,
run: () => scrollToMessage("next", dialog, true),
},
{
title: "Previous user message",
name: "session.message.user.previous",
category: "Session",
hidden: true,
run: () => scrollToMessage("prev", dialog, true),
},
{
title: "Copy last assistant message",
name: "messages.copy",
@@ -810,7 +851,10 @@ export function Session() {
createEffect(
on(
() => route.sessionID,
() => setComposer("open", false),
() => {
setComposer("open", false)
clearMessageNavigation()
},
),
)
@@ -846,16 +890,17 @@ export function Session() {
foregroundColor: themeV2.border(),
},
}}
stickyScroll={true}
stickyScroll={!navigationMessage()}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={rows}>
{(row) => (
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index()]}
/>
)}
</For>
@@ -870,6 +915,9 @@ export function Session() {
files={session()!.revert!.files ?? []}
/>
</Show>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
<box flexShrink={0}>
<Composer
@@ -942,9 +990,13 @@ export function Session() {
)
}
function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessageInfo | undefined }) {
function SessionRowView(props: {
row: SessionRow
message: (messageID: string) => SessionMessageInfo | undefined
boundaryID?: string
}) {
return (
<box marginTop={1} flexShrink={0}>
<box id={props.boundaryID} marginTop={1} flexShrink={0}>
<Switch>
<Match when={props.row.type === "message" ? props.row : undefined}>
{(row) => (
@@ -1563,7 +1615,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
return (
<Show when={props.message.text.trim() || files().length}>
<box
id={props.message.id}
border={["left"]}
borderColor={queued() ? themeV2.border() : color()}
customBorderChars={SplitBorder.customBorderChars}
@@ -0,0 +1,48 @@
import type { SessionMessageInfo } from "@opencode-ai/client"
type MessageChild = {
readonly id?: string
readonly y: number
}
export function messageNavigationSlack(input: {
top: number
viewportHeight: number
scrollHeight: number
currentSlack: number
}) {
const contentHeight = input.scrollHeight - input.currentSlack
return Math.max(0, Math.ceil(input.top + input.viewportHeight - contentHeight))
}
export function findMessageBoundary(input: {
direction: "next" | "prev"
children: readonly MessageChild[]
messages: readonly SessionMessageInfo[]
scrollTop: number
viewportY: number
currentID?: string
userOnly?: boolean
}) {
const messages = new Map(input.messages.map((message) => [message.id, message]))
const visible = input.children
.flatMap((child) => {
if (!child.id) return []
const message = messages.get(child.id)
if (!message) return []
if (message.type === "user" && message.text.trim()) {
const y = input.scrollTop + child.y - input.viewportY
return [{ id: child.id, y, top: y }]
}
if (input.userOnly || message.type !== "assistant") return []
if (!message.content.some((content) => content.type === "text" && content.text.trim())) return []
const y = input.scrollTop + child.y - input.viewportY
return [{ id: child.id, y, top: Math.max(0, y - 1) }]
})
.sort((a, b) => a.y - b.y)
const current = visible.findIndex((child) => child.id === input.currentID)
if (current !== -1) return visible[current + (input.direction === "next" ? 1 : -1)] ?? null
if (input.direction === "next") return visible.find((child) => child.y > input.scrollTop) ?? null
return visible.findLast((child) => child.y < input.scrollTop) ?? null
}
+30
View File
@@ -285,6 +285,36 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
}, [])
}
export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageInfo[]) {
const byID = new Map(messages.map((message) => [message.id, message]))
const seen = new Set<string>()
return rows.map((row) => {
const id = rowBoundaryMessageID(row, byID)
if (!id || seen.has(id)) return undefined
seen.add(id)
return id
})
}
function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMessageInfo>) {
if (row.type === "message") {
const message = messages.get(row.messageID)
if (message?.type === "user" && message.text.trim()) return message.id
return undefined
}
const messageID =
row.type === "part"
? row.ref.messageID
: row.type === "group"
? row.refs[0]?.messageID
: row.type === "assistant-footer"
? row.messageID
: undefined
if (!messageID) return undefined
const message = messages.get(messageID)
if (message?.type === "assistant") return message.id
}
export function resolvePart(message: SessionMessageAssistant, partID: string) {
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
if (tool) return tool
@@ -0,0 +1,164 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { findMessageBoundary, messageNavigationSlack } from "../../../src/routes/session/message-navigation"
const messages: SessionMessageInfo[] = [
{ type: "user", id: "user-1", text: "First", time: { created: 0 } },
assistant("assistant-1", "Response"),
{ type: "user", id: "user-2", text: "Second", time: { created: 2 } },
]
const children = [
{ id: "user-1", y: 0 },
{ id: "assistant-1", y: 20 },
{ id: "user-2", y: 40 },
]
test("adds only enough slack to align the selected message", () => {
expect(messageNavigationSlack({ top: 80, viewportHeight: 50, scrollHeight: 100, currentSlack: 0 })).toBe(30)
expect(messageNavigationSlack({ top: 20, viewportHeight: 50, scrollHeight: 130, currentSlack: 30 })).toBe(0)
})
test("finds the next user message without stopping at an assistant message", () => {
expect(
findMessageBoundary({
direction: "next",
children,
messages,
scrollTop: 0,
viewportY: 0,
userOnly: true,
}),
).toEqual({ id: "user-2", y: 40, top: 40 })
})
test("finds the previous user message without stopping at an assistant message", () => {
expect(
findMessageBoundary({
direction: "prev",
children: children.map((child) => ({ ...child, y: child.y - 35 })),
messages,
scrollTop: 35,
viewportY: 0,
userOnly: true,
}),
).toEqual({ id: "user-1", y: 0, top: 0 })
})
test("preserves navigation across both user and assistant messages", () => {
expect(
findMessageBoundary({
direction: "next",
children,
messages,
scrollTop: 0,
viewportY: 0,
}),
).toEqual({ id: "assistant-1", y: 20, top: 19 })
expect(
findMessageBoundary({
direction: "prev",
children: children.map((child) => ({ ...child, y: child.y - 35 })),
messages,
scrollTop: 35,
viewportY: 0,
}),
).toEqual({ id: "assistant-1", y: 20, top: 19 })
})
test("uses the selected message when the viewport is too tall to scroll", () => {
expect(
findMessageBoundary({
direction: "next",
children,
messages,
scrollTop: 0,
viewportY: 0,
currentID: "user-1",
userOnly: true,
}),
).toEqual({ id: "user-2", y: 40, top: 40 })
expect(
findMessageBoundary({
direction: "prev",
children,
messages,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
userOnly: true,
}),
).toEqual({ id: "user-1", y: 0, top: 0 })
})
test("stops at the first and last selected user message", () => {
expect(
findMessageBoundary({
direction: "next",
children,
messages,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
userOnly: true,
}),
).toBeNull()
expect(
findMessageBoundary({
direction: "prev",
children,
messages,
scrollTop: 0,
viewportY: 0,
currentID: "user-1",
userOnly: true,
}),
).toBeNull()
})
test("keeps the logical boundary when layout temporarily moves it outside the viewport", () => {
expect(
findMessageBoundary({
direction: "next",
children,
messages,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
userOnly: true,
}),
).toBeNull()
})
test("stops at the first and last message", () => {
expect(
findMessageBoundary({
direction: "next",
children,
messages,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
}),
).toBeNull()
expect(
findMessageBoundary({
direction: "prev",
children,
messages,
scrollTop: 0,
viewportY: 0,
currentID: "user-1",
}),
).toBeNull()
})
function assistant(id: string, text: string): SessionMessageAssistant {
return {
type: "assistant",
id,
agent: "build",
model: { providerID: "test", id: "test" },
content: [{ type: "text", text }],
time: { created: 1, completed: 1 },
}
}
+15 -1
View File
@@ -1,6 +1,20 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { reduceSessionRows } from "../../../src/routes/session/rows"
import { messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
const messages: SessionMessageInfo[] = [
{ type: "user", id: "user-1", text: "Question", time: { created: 0 } },
assistant("assistant-1", [
{ type: "reasoning", text: "Thinking" },
{ type: "text", text: "First" },
{ type: "text", text: "Second" },
]),
]
const rows = reduceSessionRows(messages)
expect(messageBoundaryIDs(rows, messages)).toEqual(["user-1", "assistant-1", undefined, undefined])
})
test("groups exploration parts across assistant messages until a delimiter", () => {
const messages: SessionMessageInfo[] = [
+10
View File
@@ -86,6 +86,16 @@ test("resolves a session move keybind", () => {
expect(config.keybinds.get("session.move")).toMatchObject([{ key: "ctrl+o" }])
})
test("resolves message navigation defaults", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.message.previous")).toMatchObject([{ key: "alt+shift+up" }])
expect(config.keybinds.get("session.message.next")).toMatchObject([{ key: "alt+shift+down" }])
expect(config.keybinds.get("session.message.user.previous")).toMatchObject([{ key: "alt+up" }])
expect(config.keybinds.get("session.message.user.next")).toMatchObject([{ key: "alt+down" }])
expect(config.keybinds.get("session.messages_last_user")).toMatchObject([{ key: "alt+end" }])
})
test("opens the subagent picker with down", () => {
const config = resolve({}, { terminalSuspend: true })
+57 -1
View File
@@ -1,9 +1,10 @@
/** @jsxImportSource @opentui/solid */
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { createBindingLookup } from "@opentui/keymap/extras"
import { type TextareaRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onCleanup } from "solid-js"
import { onCleanup, onMount } from "solid-js"
import { TuiKeybind } from "../src/config/keybind"
import {
formatKeySequence,
@@ -110,6 +111,61 @@ test("formats navigation keys as arrows", async () => {
}
})
test("dispatches user message navigation while the composer is focused", async () => {
for (const kittyKeyboard of [false, true]) {
const counts = {
"session.message.user.previous": 0,
"session.message.user.next": 0,
"session.messages_last_user": 0,
}
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const config = createResolvedKeymapConfig()
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
const commands = Object.keys(counts) as (keyof typeof counts)[]
const offLayer = keymap.registerLayer({
commands: commands.map((name) => ({
name,
run() {
counts[name]++
},
})),
bindings: commands.flatMap((command) => config.keybinds.get(command)),
})
let textarea: TextareaRenderable
onMount(() => textarea.focus())
onCleanup(() => {
offLayer()
offKeymap()
})
return (
<OpencodeKeymapProvider keymap={keymap}>
<textarea ref={(value) => (textarea = value)} />
</OpencodeKeymapProvider>
)
}
const app = await testRender(() => <Harness />, { kittyKeyboard })
try {
await app.renderOnce()
app.mockInput.pressArrow("up", { meta: true })
app.mockInput.pressArrow("down", { meta: true })
app.mockInput.pressKey("END", { meta: true })
expect(counts).toEqual({
"session.message.user.previous": 1,
"session.message.user.next": 1,
"session.messages_last_user": 1,
})
} finally {
app.renderer.currentFocusedEditor?.blur()
app.renderer.destroy()
}
}
})
test("mode-less bindings stay active when opencode mode changes", async () => {
const counts: Record<string, Record<string, number>> = {}