feat(tui): add message navigation shortcuts
This commit is contained in:
@@ -136,9 +136,11 @@ export const Definitions = {
|
|||||||
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
|
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_first: keybind("ctrl+g,home", "Navigate to first message"),
|
||||||
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
|
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
|
||||||
messages_next: keybind("none", "Navigate to next message"),
|
messages_next: keybind("alt+shift+down", "Navigate to next message"),
|
||||||
messages_previous: keybind("none", "Navigate to previous message"),
|
messages_previous: keybind("alt+shift+up", "Navigate to previous message"),
|
||||||
messages_last_user: keybind("none", "Navigate to last user 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_copy: keybind("<leader>y", "Copy message"),
|
||||||
messages_undo: keybind("<leader>u", "Undo message"),
|
messages_undo: keybind("<leader>u", "Undo message"),
|
||||||
messages_redo: keybind("<leader>r", "Redo message"),
|
messages_redo: keybind("<leader>r", "Redo message"),
|
||||||
@@ -335,6 +337,8 @@ export const CommandMap = {
|
|||||||
messages_last: "session.last",
|
messages_last: "session.last",
|
||||||
messages_next: "session.message.next",
|
messages_next: "session.message.next",
|
||||||
messages_previous: "session.message.previous",
|
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_last_user: "session.messages_last_user",
|
||||||
messages_copy: "messages.copy",
|
messages_copy: "messages.copy",
|
||||||
messages_undo: "session.undo",
|
messages_undo: "session.undo",
|
||||||
|
|||||||
@@ -71,11 +71,15 @@ import { usePluginRuntime } from "../../plugin/runtime"
|
|||||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
|
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
|
||||||
import { usePathFormatter } from "../../context/path-format"
|
import { usePathFormatter } from "../../context/path-format"
|
||||||
import { useLocation } from "../../context/location"
|
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 { switchLabel } from "../../util/model"
|
||||||
|
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
|
// Exclude temporary bottom space when measuring the real transcript height.
|
||||||
|
const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
||||||
|
|
||||||
const context = createContext<{
|
const context = createContext<{
|
||||||
width: number
|
width: number
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -180,6 +184,23 @@ export function Session() {
|
|||||||
const client = useClient()
|
const client = useClient()
|
||||||
const editor = useEditorContext()
|
const editor = useEditorContext()
|
||||||
const rows = createSessionRows(() => route.sessionID)
|
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(
|
createEffect(
|
||||||
on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => {
|
on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => {
|
||||||
@@ -239,53 +260,55 @@ export function Session() {
|
|||||||
dialog.clear()
|
dialog.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Find next visible message boundary in direction
|
const alignMessage = (messageID: string, top: number) => {
|
||||||
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
|
scroll.stickyScroll = false
|
||||||
const children = scroll.getChildren()
|
setNavigationMessage(messageID)
|
||||||
const messagesList = messages()
|
setNavigationSlack(
|
||||||
const scrollTop = scroll.y
|
messageNavigationSlack({
|
||||||
|
top,
|
||||||
// Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
|
viewportHeight: scroll.viewport.height,
|
||||||
const visibleMessages = children
|
scrollHeight: scroll.scrollHeight,
|
||||||
.filter((c) => {
|
currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0,
|
||||||
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())
|
|
||||||
)
|
)
|
||||||
|
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>, userOnly = false) => {
|
||||||
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>) => {
|
const target = findMessageBoundary({
|
||||||
const targetID = findNextVisibleMessage(direction)
|
direction,
|
||||||
|
children: scroll.getChildren(),
|
||||||
|
messages: messages(),
|
||||||
|
scrollTop: scroll.scrollTop,
|
||||||
|
viewportY: scroll.viewport.y,
|
||||||
|
currentID: navigationMessage(),
|
||||||
|
userOnly,
|
||||||
|
})
|
||||||
|
|
||||||
if (!targetID) {
|
if (!target) {
|
||||||
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
|
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = scroll.getChildren().find((c) => c.id === targetID)
|
alignMessage(target.id, target.top)
|
||||||
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
|
||||||
dialog.clear()
|
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() {
|
function toBottom() {
|
||||||
|
clearMessageNavigation()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!scroll || scroll.isDestroyed) return
|
if (!scroll || scroll.isDestroyed) return
|
||||||
scroll.scrollTo(scroll.scrollHeight)
|
scroll.scrollTo(scroll.scrollHeight)
|
||||||
@@ -299,6 +322,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(-scroll.height / 2)
|
scroll.scrollBy(-scroll.height / 2)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -309,6 +333,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(scroll.height / 2)
|
scroll.scrollBy(scroll.height / 2)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -319,6 +344,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(-1)
|
scroll.scrollBy(-1)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -329,6 +355,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(1)
|
scroll.scrollBy(1)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -339,6 +366,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(-scroll.height / 4)
|
scroll.scrollBy(-scroll.height / 4)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -349,6 +377,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollBy(scroll.height / 4)
|
scroll.scrollBy(scroll.height / 4)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -362,6 +391,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollTo(0)
|
scroll.scrollTo(0)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -372,6 +402,7 @@ export function Session() {
|
|||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
clearMessageNavigation()
|
||||||
scroll.scrollTo(scroll.scrollHeight)
|
scroll.scrollTo(scroll.scrollHeight)
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
@@ -412,8 +443,7 @@ export function Session() {
|
|||||||
sessionID={route.sessionID}
|
sessionID={route.sessionID}
|
||||||
onMove={(messageID) => {
|
onMove={(messageID) => {
|
||||||
if (!messageID) return
|
if (!messageID) return
|
||||||
const child = scroll.getChildren().find((child) => child.id === messageID)
|
jumpToMessage(messageID)
|
||||||
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -574,10 +604,7 @@ export function Session() {
|
|||||||
const message = messages[i]
|
const message = messages[i]
|
||||||
if (!message || message.type !== "user" || !message.text.trim()) continue
|
if (!message || message.type !== "user" || !message.text.trim()) continue
|
||||||
{
|
{
|
||||||
const child = scroll.getChildren().find((child) => {
|
jumpToMessage(message.id)
|
||||||
return child.id === message.id
|
|
||||||
})
|
|
||||||
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -597,6 +624,20 @@ export function Session() {
|
|||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => scrollToMessage("prev", dialog),
|
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",
|
title: "Copy last assistant message",
|
||||||
name: "messages.copy",
|
name: "messages.copy",
|
||||||
@@ -810,7 +851,10 @@ export function Session() {
|
|||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => route.sessionID,
|
() => route.sessionID,
|
||||||
() => setComposer("open", false),
|
() => {
|
||||||
|
setComposer("open", false)
|
||||||
|
clearMessageNavigation()
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -846,16 +890,17 @@ export function Session() {
|
|||||||
foregroundColor: themeV2.border(),
|
foregroundColor: themeV2.border(),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
stickyScroll={true}
|
stickyScroll={!navigationMessage()}
|
||||||
stickyStart="bottom"
|
stickyStart="bottom"
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
scrollAcceleration={scrollAcceleration()}
|
scrollAcceleration={scrollAcceleration()}
|
||||||
>
|
>
|
||||||
<For each={rows}>
|
<For each={rows}>
|
||||||
{(row) => (
|
{(row, index) => (
|
||||||
<SessionRowView
|
<SessionRowView
|
||||||
row={row}
|
row={row}
|
||||||
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
|
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
|
||||||
|
boundaryID={boundaries()[index()]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -870,6 +915,9 @@ export function Session() {
|
|||||||
files={session()!.revert!.files ?? []}
|
files={session()!.revert!.files ?? []}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={navigationSlack()}>
|
||||||
|
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
|
||||||
|
</Show>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
<box flexShrink={0}>
|
<box flexShrink={0}>
|
||||||
<Composer
|
<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 (
|
return (
|
||||||
<box marginTop={1} flexShrink={0}>
|
<box id={props.boundaryID} marginTop={1} flexShrink={0}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.row.type === "message" ? props.row : undefined}>
|
<Match when={props.row.type === "message" ? props.row : undefined}>
|
||||||
{(row) => (
|
{(row) => (
|
||||||
@@ -1563,7 +1615,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
return (
|
return (
|
||||||
<Show when={props.message.text.trim() || files().length}>
|
<Show when={props.message.text.trim() || files().length}>
|
||||||
<box
|
<box
|
||||||
id={props.message.id}
|
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
borderColor={queued() ? themeV2.border() : color()}
|
borderColor={queued() ? themeV2.border() : color()}
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
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
|
||||||
|
}
|
||||||
@@ -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) {
|
export function resolvePart(message: SessionMessageAssistant, partID: string) {
|
||||||
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
|
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
|
||||||
if (tool) return tool
|
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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
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", () => {
|
test("groups exploration parts across assistant messages until a delimiter", () => {
|
||||||
const messages: SessionMessageInfo[] = [
|
const messages: SessionMessageInfo[] = [
|
||||||
|
|||||||
@@ -86,6 +86,16 @@ test("resolves a session move keybind", () => {
|
|||||||
expect(config.keybinds.get("session.move")).toMatchObject([{ key: "ctrl+o" }])
|
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", () => {
|
test("opens the subagent picker with down", () => {
|
||||||
const config = resolve({}, { terminalSuspend: true })
|
const config = resolve({}, { terminalSuspend: true })
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||||
|
import { type TextareaRenderable } from "@opentui/core"
|
||||||
import { testRender, useRenderer } from "@opentui/solid"
|
import { testRender, useRenderer } from "@opentui/solid"
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { onCleanup } from "solid-js"
|
import { onCleanup, onMount } from "solid-js"
|
||||||
import { TuiKeybind } from "../src/config/keybind"
|
import { TuiKeybind } from "../src/config/keybind"
|
||||||
import {
|
import {
|
||||||
formatKeySequence,
|
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 () => {
|
test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||||
const counts: Record<string, Record<string, number>> = {}
|
const counts: Record<string, Record<string, number>> = {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user