feat(tui): add message navigation shortcuts

This commit is contained in:
starptech 2026-07-16 21:26:31 +02:00
commit 4f60bde502
8 changed files with 430 additions and 53 deletions

View file

@ -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 },
}
}

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[] = [

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 })

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>> = {}