feat(tui): add message navigation shortcuts
This commit is contained in:
parent
0d3b6d430e
commit
4f60bde502
8 changed files with 430 additions and 53 deletions
164
packages/tui/test/cli/tui/message-navigation.test.ts
Normal file
164
packages/tui/test/cli/tui/message-navigation.test.ts
Normal 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 },
|
||||
}
|
||||
}
|
||||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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>> = {}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue