Compare commits

...

7 Commits

Author SHA1 Message Date
James Long 4778e6e031 fix(core): stream shell progress tail 2026-07-16 20:25:00 +00:00
James Long ac1b802820 fix(tui): restore selected action styling, fix subagent list (#37371) 2026-07-16 16:20:16 -04:00
Dustin Deus 5ee4c1082a feat(tui): add message navigation shortcuts (#37362) 2026-07-16 16:18:18 -04:00
starptech 8a70d70006 revert(tui): remove message navigation shortcuts 2026-07-16 21:45:40 +02:00
James Long 91b4634363 fix(tui): remove debug theme toggle (#37359) 2026-07-16 15:31:42 -04:00
starptech 4f60bde502 feat(tui): add message navigation shortcuts 2026-07-16 21:27:13 +02:00
Dax Raad 0d3b6d430e fix(tui): restore expandable tool hover state 2026-07-16 15:00:49 -04:00
20 changed files with 658 additions and 231 deletions
+19 -1
View File
@@ -17,6 +17,7 @@ export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000 export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024 export const MAX_CAPTURE_BYTES = 1024 * 1024
export const PROGRESS_LINES = 25
const BACKGROUND_STARTED = "The command was moved to the background." const BACKGROUND_STARTED = "The command was moved to the background."
const BACKGROUND_INSTRUCTION = const BACKGROUND_INSTRUCTION =
@@ -210,6 +211,23 @@ export const Plugin = {
} }
}) })
const captureProgress = Effect.fn("ShellTool.captureProgress")(function* () {
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const start = Math.max(0, latest.size - MAX_CAPTURE_BYTES)
const page = yield* shell.output(info.id, { cursor: start, limit: MAX_CAPTURE_BYTES })
const trailingNewline = page.output.endsWith("\n")
const lines = trailingNewline ? page.output.split("\n").slice(0, -1) : page.output.split("\n")
const truncated = start > 0 || lines.length > PROGRESS_LINES
const output = lines.slice(-PROGRESS_LINES).join("\n") + (trailingNewline ? "\n" : "")
const notice = truncated
? `[output truncated; showing last ${PROGRESS_LINES} lines. Full output saved to: ${info.file}]\n\n`
: ""
return {
output: `${notice}${output || "(no output)"}`,
truncated,
}
})
const settleShell = Effect.fn("ShellTool.settleShell")(function* () { const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id) const final = yield* shell.wait(info.id)
@@ -258,7 +276,7 @@ export const Plugin = {
const progress = yield* Effect.sleep("1 second").pipe( const progress = yield* Effect.sleep("1 second").pipe(
Effect.andThen( Effect.andThen(
captureShell().pipe( captureProgress().pipe(
Effect.flatMap((capture) => Effect.flatMap((capture) =>
context.progress({ context.progress({
structured: { truncated: capture.truncated }, structured: { truncated: capture.truncated },
+11 -8
View File
@@ -166,10 +166,10 @@ const overflowCommand = (bytes: number) =>
isWindows isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100` ? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'` : `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
const progressOverflowCommand = (bytes: number) => const progressLinesCommand = (lines: number) =>
isWindows isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 1500` ? `1..${lines} | ForEach-Object { [Console]::Out.WriteLine(('line {0:d2}' -f $_)) }; Start-Sleep -Milliseconds 1500`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; sleep 1.5` : `i=1; while [ $i -le ${lines} ]; do printf 'line %02d\\n' "$i"; i=$((i+1)); done; sleep 1.5`
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -417,17 +417,17 @@ describe("ShellTool", () => {
), ),
) )
it.live("reports bounded output progress for a running command", () => it.live("reports the latest output lines for a running command", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => { (tmp) => {
reset() reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024 const lines = ShellTool.PROGRESS_LINES + 10
return withSession(tmp.path, (registry) => return withSession(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
const progress: ToolRegistry.Progress[] = [] const progress: ToolRegistry.Progress[] = []
yield* settleTool(registry, { yield* settleTool(registry, {
...call({ command: progressOverflowCommand(bytes) }, "call-progress"), ...call({ command: progressLinesCommand(lines) }, "call-progress"),
progress: (update) => Effect.sync(() => progress.push(update)), progress: (update) => Effect.sync(() => progress.push(update)),
}) })
@@ -436,9 +436,12 @@ describe("ShellTool", () => {
const content = progress[0]?.content[0] const content = progress[0]?.content[0]
expect(content?.type).toBe("text") expect(content?.type).toBe("text")
if (content?.type !== "text") return if (content?.type !== "text") return
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe( expect(content.text).toStartWith(
ShellTool.MAX_CAPTURE_BYTES, `[output truncated; showing last ${ShellTool.PROGRESS_LINES} lines. Full output saved to:`,
) )
expect(content.text).not.toContain("line 10\n")
expect(content.text).toContain("line 11\n")
expect(content.text).toContain(`line ${lines}\n`)
}), }),
) )
}, },
+8 -4
View File
@@ -134,11 +134,13 @@ export const Definitions = {
messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"), messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"), messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
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,alt+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+down", "Navigate to next message"),
messages_previous: keybind("none", "Navigate to previous message"), messages_previous: keybind("alt+up", "Navigate to previous message"),
messages_last_user: keybind("none", "Navigate to last user message"), messages_next_user: keybind("alt+shift+down", "Navigate to next user message"),
messages_previous_user: keybind("alt+shift+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",
@@ -50,9 +50,8 @@ function Mcp(props: { context: Plugin.Context }) {
} }
function View(props: { context: Plugin.Context }) { function View(props: { context: Plugin.Context }) {
const { themeV2, mode, setMode } = useTheme() const { themeV2 } = useTheme()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const modeLabel = createMemo(() => (mode() === "dark" ? "Light mode" : "Dark mode"))
const mcpWidth = createMemo(() => { const mcpWidth = createMemo(() => {
const list = props.context.data.location.mcp.server.list(props.context.location) ?? [] const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
if (list.length === 0) return 0 if (list.length === 0) return 0
@@ -73,16 +72,10 @@ function View(props: { context: Plugin.Context }) {
> >
<Directory <Directory
context={props.context} context={props.context}
maxWidth={Math.max( maxWidth={Math.max(2, dimensions().width - 8 - Bun.stringWidth(InstallationVersion) - mcpWidth())}
2,
dimensions().width - 10 - Bun.stringWidth(InstallationVersion) - Bun.stringWidth(modeLabel()) - mcpWidth(),
)}
/> />
<Mcp context={props.context} /> <Mcp context={props.context} />
<box flexGrow={1} /> <box flexGrow={1} />
<box flexShrink={0} onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")}>
<text fg={themeV2.text.action.secondary()}>{modeLabel()}</text>
</box>
<box flexShrink={0}> <box flexShrink={0}>
<text fg={themeV2.text.subdued()}>{InstallationVersion}</text> <text fg={themeV2.text.subdued()}>{InstallationVersion}</text>
</box> </box>
@@ -13,7 +13,6 @@ export function ShellTab(props: { sessionID: string }) {
const location = useLocation() const location = useLocation()
const client = useClient() const client = useClient()
const { themeV2 } = useTheme() const { themeV2 } = useTheme()
const fg = themeV2.text.action.primary("focused")
const composer = useComposerTab() const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts() const shortcuts = Keymap.useShortcuts()
@@ -96,11 +95,7 @@ export function ShellTab(props: { sessionID: string }) {
return ( return (
<Show when={composer.active("shell")}> <Show when={composer.active("shell")}>
<scrollbox <scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
scrollbarOptions={{ visible: false }}
maxHeight={5}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
>
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No shell commands</text>}> <Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No shell commands</text>}>
<For each={entries()}> <For each={entries()}>
{(shell, index) => { {(shell, index) => {
@@ -110,11 +105,11 @@ export function ShellTab(props: { sessionID: string }) {
flexDirection="row" flexDirection="row"
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={active() ? themeV2.background.action.primary() : RGBA.fromInts(0, 0, 0, 0)} backgroundColor={active() ? themeV2.background.action.primary("selected") : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setStore("selected", index())} onMouseOver={() => setStore("selected", index())}
> >
<text <text
fg={active() ? fg : themeV2.text()} fg={themeV2.text.action.primary(active() ? "focused" : "default")}
attributes={active() ? TextAttributes.BOLD : undefined} attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none" wrapMode="none"
> >
@@ -22,7 +22,6 @@ export function SubagentsTab(props: { sessionID: string }) {
const data = useData() const data = useData()
const client = useClient() const client = useClient()
const { themeV2 } = useTheme() const { themeV2 } = useTheme()
const fg = themeV2.text.action.primary("focused")
const navigate = useRoute().navigate const navigate = useRoute().navigate
const composer = useComposerTab() const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts() const shortcuts = Keymap.useShortcuts()
@@ -39,7 +38,11 @@ export function SubagentsTab(props: { sessionID: string }) {
const siblings = data.session.list().filter((s) => s.parentID === current.parentID) const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
for (const sibling of siblings) { for (const sibling of siblings) {
const agentMatch = sibling.title.match(/@(\w+) subagent/) const agentMatch = sibling.title.match(/@(\w+) subagent/)
const agent = sibling.agent ? Locale.titlecase(sibling.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent" const agent = sibling.agent
? Locale.titlecase(sibling.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent"
const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title
result.push({ result.push({
sessionID: sibling.id, sessionID: sibling.id,
@@ -53,7 +56,11 @@ export function SubagentsTab(props: { sessionID: string }) {
const children = data.session.list().filter((s) => s.parentID === props.sessionID) const children = data.session.list().filter((s) => s.parentID === props.sessionID)
for (const child of children) { for (const child of children) {
const agentMatch = child.title.match(/@(\w+) subagent/) const agentMatch = child.title.match(/@(\w+) subagent/)
const agent = child.agent ? Locale.titlecase(child.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent" const agent = child.agent
? Locale.titlecase(child.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent"
const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title
result.push({ result.push({
sessionID: child.id, sessionID: child.id,
@@ -195,11 +202,7 @@ export function SubagentsTab(props: { sessionID: string }) {
return ( return (
<Show when={composer.active("subagents")}> <Show when={composer.active("subagents")}>
<scrollbox <scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
scrollbarOptions={{ visible: false }}
maxHeight={5}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
>
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No subagents</text>}> <Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No subagents</text>}>
<For each={entries()}> <For each={entries()}>
{(entry, index) => { {(entry, index) => {
@@ -213,7 +216,7 @@ export function SubagentsTab(props: { sessionID: string }) {
flexDirection="row" flexDirection="row"
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={active() ? themeV2.background.action.primary() : RGBA.fromInts(0, 0, 0, 0)} backgroundColor={themeV2.background.action.primary(active() ? "focused" : "default")}
onMouseOver={() => setStore("selected", index())} onMouseOver={() => setStore("selected", index())}
onMouseUp={() => { onMouseUp={() => {
setStore("selected", index()) setStore("selected", index())
@@ -222,7 +225,9 @@ export function SubagentsTab(props: { sessionID: string }) {
> >
<box flexGrow={1} minWidth={0} flexDirection="row"> <box flexGrow={1} minWidth={0} flexDirection="row">
<text <text
fg={active() ? fg : entry.current ? themeV2.background.action.primary() : themeV2.text()} fg={themeV2.text.action.primary(
active() ? "focused" : entry.current ? "selected" : "default",
)}
attributes={active() ? TextAttributes.BOLD : undefined} attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none" wrapMode="none"
> >
@@ -230,7 +235,7 @@ export function SubagentsTab(props: { sessionID: string }) {
</text> </text>
</box> </box>
<Show when={status()}> <Show when={status()}>
<text fg={active() ? fg : themeV2.text.subdued()} wrapMode="none"> <text fg={active() ? themeV2.text.action.primary() : themeV2.text.subdued()} wrapMode="none">
{status()} {status()}
</text> </text>
</Show> </Show>
+100 -49
View File
@@ -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 requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (message.type === "user") return Boolean(message.text.trim()) if (scroll.isDestroyed || navigationMessage() !== messageID) return
return ( scroll.scrollTo(top)
message.type === "assistant" &&
message.content.some((content) => content.type === "text" && content.text.trim())
)
}) })
.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}
@@ -2279,7 +2330,7 @@ function BlockTool(props: {
paddingLeft={2} paddingLeft={2}
gap={1} gap={1}
backgroundColor={ backgroundColor={
hover() ? themeV2.background.action.secondary() : themeV2.background() hover() ? themeV2.background.action.secondary("focused") : themeV2.background()
} }
customBorderChars={SplitBorder.customBorderChars} customBorderChars={SplitBorder.customBorderChars}
borderColor={themeV2.background()} borderColor={themeV2.background()}
@@ -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) { 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
+150 -128
View File
@@ -97,76 +97,87 @@ export const DEFAULT_THEME = {
neutral: "$hue.gray", neutral: "$hue.gray",
}, },
text: { text: {
default: "$hue.neutral.900",
subdued: "$hue.neutral.600",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.900", default: "$hue.neutral.900",
subdued: "$hue.neutral.600", $focused: "$text.action.primary.default",
action: { $pressed: "$hue.neutral.100",
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" }, $disabled: "$hue.neutral.500",
secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" }, $selected: "$hue.interactive.600",
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.900",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.600",
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
},
}, },
background: { feedback: {
default: "$hue.neutral.100", error: { default: "$hue.red.700", subdued: "$hue.red.600" },
surface: { warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
offset: "$hue.neutral.200", success: { default: "$hue.green.700", subdued: "$hue.green.600" },
overlay: "$hue.neutral.300", info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
}, },
action: { },
primary: { background: {
default: "$hue.interactive.600", $focused: "$hue.interactive.700", $pressed: "$hue.interactive.800", default: "$hue.neutral.100",
$disabled: "$hue.neutral.300", surface: {
}, offset: "$hue.neutral.200",
secondary: { overlay: "$hue.neutral.300",
default: "$hue.neutral.200", $focused: "$hue.neutral.300", $pressed: "$hue.neutral.400", },
$disabled: "$hue.neutral.200", action: {
}, primary: {
destructive: { default: "$hue.interactive.600",
default: "$hue.red.600", $focused: "$hue.red.700", $pressed: "$hue.red.800", $focused: "$hue.interactive.700",
$disabled: "$hue.neutral.300",
},
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800", $pressed: "$hue.interactive.800",
$disabled: "$background.default", $selected: "$hue.interactive.700",
$selected: "$background.formfield.default", $disabled: "$hue.neutral.300",
}, },
feedback: { secondary: {
error: { default: "$background.default" }, default: "$hue.neutral.200",
warning: { default: "$background.default" }, $focused: "$hue.neutral.300",
success: { default: "$background.default" }, $pressed: "$hue.neutral.400",
info: { default: "$background.default" }, $selected: "$hue.neutral.300",
$disabled: "$hue.neutral.200",
},
destructive: {
default: "$hue.red.600",
$focused: "$hue.red.700",
$pressed: "$hue.red.800",
$selected: "$hue.red.700",
$disabled: "$hue.neutral.300",
}, },
}, },
border: { default: "$hue.neutral.300" }, formfield: {
scrollbar: { default: "$hue.neutral.400" }, default: "$background.default",
diff: { $focused: "$background.action.primary.default",
text: { $pressed: "$hue.interactive.800",
added: "$hue.green.700", removed: "$hue.red.700", context: "$hue.neutral.900", $disabled: "$background.default",
hunkHeader: "$hue.purple.600", $selected: "$background.formfield.default",
},
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
lineNumber: {
text: "$hue.neutral.600",
background: { added: "$hue.green.200", removed: "$hue.red.200" },
},
}, },
syntax: { feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
},
border: { default: "$hue.neutral.300" },
scrollbar: { default: "$hue.neutral.400" },
diff: {
text: {
added: "$hue.green.700",
removed: "$hue.red.700",
context: "$hue.neutral.900",
hunkHeader: "$hue.purple.600",
},
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
lineNumber: {
text: "$hue.neutral.600",
background: { added: "$hue.green.200", removed: "$hue.red.200" },
},
},
syntax: {
comment: "$hue.neutral.600", comment: "$hue.neutral.600",
keyword: "$hue.purple.600", keyword: "$hue.purple.600",
function: "$hue.accent.600", function: "$hue.accent.600",
@@ -177,7 +188,7 @@ export const DEFAULT_THEME = {
operator: "$hue.cyan.600", operator: "$hue.cyan.600",
punctuation: "$hue.neutral.900", punctuation: "$hue.neutral.900",
}, },
markdown: { markdown: {
text: "$hue.neutral.900", text: "$hue.neutral.900",
heading: "$hue.purple.600", heading: "$hue.purple.600",
link: "$hue.accent.600", link: "$hue.accent.600",
@@ -303,76 +314,87 @@ export const DEFAULT_THEME = {
neutral: "$hue.gray", neutral: "$hue.gray",
}, },
text: { text: {
default: "$hue.neutral.100",
subdued: "$hue.neutral.400",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.100", default: "$hue.neutral.100",
subdued: "$hue.neutral.400", $focused: "$text.action.primary.default",
action: { $pressed: "$hue.neutral.100",
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" }, $disabled: "$hue.neutral.500",
secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" }, $selected: "$hue.interactive.500",
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.100",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.500",
},
feedback: {
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
},
}, },
background: { feedback: {
default: "$hue.neutral.900", error: { default: "$hue.red.300", subdued: "$hue.red.400" },
surface: { warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
offset: "$hue.neutral.800", success: { default: "$hue.green.300", subdued: "$hue.green.400" },
overlay: "$hue.neutral.700", info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
}, },
action: { },
primary: { background: {
default: "$hue.interactive.500", $focused: "$hue.interactive.600", $pressed: "$hue.interactive.800", default: "$hue.neutral.900",
$disabled: "$hue.neutral.800", surface: {
}, offset: "$hue.neutral.800",
secondary: { overlay: "$hue.neutral.700",
default: "$hue.neutral.800", $focused: "$hue.neutral.700", $pressed: "$hue.neutral.900", },
$disabled: "$hue.neutral.900", action: {
}, primary: {
destructive: { default: "$hue.interactive.500",
default: "$hue.red.600", $focused: "$hue.red.700", $pressed: "$hue.red.800", $focused: "$hue.interactive.600",
$disabled: "$hue.neutral.800",
},
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800", $pressed: "$hue.interactive.800",
$disabled: "$background.default", $selected: "$hue.interactive.600",
$selected: "$background.formfield.default", $disabled: "$hue.neutral.800",
}, },
feedback: { secondary: {
error: { default: "$background.default" }, default: "$hue.neutral.800",
warning: { default: "$background.default" }, $focused: "$hue.neutral.700",
success: { default: "$background.default" }, $pressed: "$hue.neutral.900",
info: { default: "$background.default" }, $selected: "$hue.neutral.700",
$disabled: "$hue.neutral.900",
},
destructive: {
default: "$hue.red.600",
$focused: "$hue.red.700",
$pressed: "$hue.red.800",
$selected: "$hue.red.700",
$disabled: "$hue.neutral.800",
}, },
}, },
border: { default: "$hue.neutral.700" }, formfield: {
scrollbar: { default: "$hue.neutral.600" }, default: "$background.default",
diff: { $focused: "$background.action.primary.default",
text: { $pressed: "$hue.interactive.800",
added: "$hue.green.300", removed: "$hue.red.300", context: "$hue.neutral.100", $disabled: "$background.default",
hunkHeader: "$hue.purple.400", $selected: "$background.formfield.default",
},
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
lineNumber: {
text: "$hue.neutral.400",
background: { added: "$hue.green.800", removed: "$hue.red.800" },
},
}, },
syntax: { feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
},
border: { default: "$hue.neutral.700" },
scrollbar: { default: "$hue.neutral.600" },
diff: {
text: {
added: "$hue.green.300",
removed: "$hue.red.300",
context: "$hue.neutral.100",
hunkHeader: "$hue.purple.400",
},
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
lineNumber: {
text: "$hue.neutral.400",
background: { added: "$hue.green.800", removed: "$hue.red.800" },
},
},
syntax: {
comment: "$hue.neutral.400", comment: "$hue.neutral.400",
keyword: "$hue.purple.400", keyword: "$hue.purple.400",
function: "$hue.accent.400", function: "$hue.accent.400",
@@ -383,7 +405,7 @@ export const DEFAULT_THEME = {
operator: "$hue.cyan.400", operator: "$hue.cyan.400",
punctuation: "$hue.neutral.100", punctuation: "$hue.neutral.100",
}, },
markdown: { markdown: {
text: "$hue.neutral.100", text: "$hue.neutral.100",
heading: "$hue.purple.400", heading: "$hue.purple.400",
link: "$hue.accent.400", link: "$hue.accent.400",
+2 -1
View File
@@ -12,7 +12,7 @@ export type HueAlias = Schema.Schema.Type<typeof HueAlias>
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"]) export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant> export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
export const ActionState = Schema.Literals(["focused", "pressed", "disabled"]) export const ActionState = Schema.Literals(["focused", "pressed", "selected", "disabled"])
export type ActionState = Schema.Schema.Type<typeof ActionState> export type ActionState = Schema.Schema.Type<typeof ActionState>
export type ActionStateKey = `$${ActionState}` export type ActionStateKey = `$${ActionState}`
@@ -77,6 +77,7 @@ const StatefulColorDefinition = Schema.Struct({
default: Schema.optional(ColorValue), default: Schema.optional(ColorValue),
$focused: Schema.optional(ColorValue), $focused: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue), $pressed: Schema.optional(ColorValue),
$selected: Schema.optional(ColorValue),
$disabled: Schema.optional(ColorValue), $disabled: Schema.optional(ColorValue),
}) })
export type StatefulColorDefinition = Schema.Schema.Type<typeof StatefulColorDefinition> export type StatefulColorDefinition = Schema.Schema.Type<typeof StatefulColorDefinition>
+3 -6
View File
@@ -54,6 +54,7 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
default: "$text.default", default: "$text.default",
$disabled: textMuted, $disabled: textMuted,
$focused: selected, $focused: selected,
$selected: primary,
}, },
secondary: { secondary: {
default: "$text.default", default: "$text.default",
@@ -82,7 +83,7 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
overlay: backgroundMenu, overlay: backgroundMenu,
}, },
action: { action: {
primary: { default: "transparent", $focused: primary }, primary: { default: "transparent", $focused: primary, $selected: primary },
secondary: { secondary: {
default: "$background.default", default: "$background.default",
$focused: color("backgroundElement"), $focused: color("backgroundElement"),
@@ -150,11 +151,7 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
imageText: color("markdownImageText"), imageText: color("markdownImageText"),
codeBlock: color("markdownCodeBlock"), codeBlock: color("markdownCodeBlock"),
}, },
"@context:elevated": { "@context:elevated": { background: { default: "$background.surface.offset" } },
background: {
default: "$background.surface.offset",
},
},
"@context:overlay": { background: { default: "$background.surface.overlay" } }, "@context:overlay": { background: { default: "$background.surface.overlay" } },
} }
} }
@@ -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 { 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[] = [
+11
View File
@@ -86,6 +86,17 @@ 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.first")).toMatchObject([{ key: "ctrl+g,home,alt+home" }])
expect(config.keybinds.get("session.message.previous")).toMatchObject([{ key: "alt+up" }])
expect(config.keybinds.get("session.message.next")).toMatchObject([{ key: "alt+down" }])
expect(config.keybinds.get("session.message.user.previous")).toMatchObject([{ key: "alt+shift+up" }])
expect(config.keybinds.get("session.message.user.next")).toMatchObject([{ key: "alt+shift+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 })
+63 -4
View File
@@ -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,64 @@ test("formats navigation keys as arrows", async () => {
} }
}) })
test("dispatches message navigation while the composer is focused", async () => {
for (const kittyKeyboard of [false, true]) {
const counts = {
"session.first": 0,
"session.message.previous": 0,
"session.message.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("HOME", { meta: true })
app.mockInput.pressKey("END", { meta: true })
expect(counts).toEqual({
"session.first": 1,
"session.message.previous": 1,
"session.message.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>> = {}
@@ -169,13 +228,13 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
const app = await testRender(() => <Harness />) const app = await testRender(() => <Harness />)
try { try {
expect(counts).toEqual({ expect(counts).toEqual({
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 }, base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 3, "model.list": 1 },
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 }, question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 3, "model.list": 0 },
autocomplete: { autocomplete: {
"session.list": 1, "session.list": 1,
"session.new": 1, "session.new": 1,
"session.page.up": 2, "session.page.up": 2,
"session.first": 2, "session.first": 3,
"model.list": 0, "model.list": 0,
}, },
}) })
@@ -21,6 +21,8 @@ test("provides reactive property, variant, state, and context accessors", () =>
expect(theme.text.subdued()).toBe(resolved().text.subdued) expect(theme.text.subdued()).toBe(resolved().text.subdued)
expect(theme.text.action()).toBe(resolved().text.action.primary.default) expect(theme.text.action()).toBe(resolved().text.action.primary.default)
expect(theme.text.action.primary("pressed")).toBe(resolved().text.action.primary.pressed) expect(theme.text.action.primary("pressed")).toBe(resolved().text.action.primary.pressed)
expect(theme.text.action.primary("selected")).toBe(resolved().text.action.primary.selected)
expect(theme.background.action.primary("selected")).toBe(resolved().background.action.primary.selected)
expect(theme.background.action.secondary("disabled")).toBe( expect(theme.background.action.secondary("disabled")).toBe(
resolved().background.action.secondary.disabled, resolved().background.action.secondary.disabled,
) )
@@ -139,7 +139,9 @@ test("resolves matched action variants and states", () => {
const theme = resolveTheme(light) const theme = resolveTheme(light)
expect(theme.text.action.primary.pressed).toBeInstanceOf(RGBA) expect(theme.text.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.text.action.primary.selected).toBeInstanceOf(RGBA)
expect(theme.background.action.primary.pressed).toBeInstanceOf(RGBA) expect(theme.background.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.background.action.primary.selected).toBeInstanceOf(RGBA)
expect(theme.text.action.secondary.default).toBeInstanceOf(RGBA) expect(theme.text.action.secondary.default).toBeInstanceOf(RGBA)
expect(theme.background.action.destructive.disabled).toBeInstanceOf(RGBA) expect(theme.background.action.destructive.disabled).toBeInstanceOf(RGBA)
}) })
+6 -1
View File
@@ -19,7 +19,11 @@ const background = {
default: "$hue.neutral.100", default: "$hue.neutral.100",
surface: { offset: "$hue.neutral.200", overlay: "$hue.neutral.300" }, surface: { offset: "$hue.neutral.200", overlay: "$hue.neutral.300" },
action: { action: {
primary: { default: "$hue.interactive.600", $pressed: "$hue.interactive.800" }, primary: {
default: "$hue.interactive.600",
$pressed: "$hue.interactive.800",
$selected: "$hue.interactive.700",
},
secondary: { default: "$hue.neutral.200" }, secondary: { default: "$hue.neutral.200" },
destructive: { default: "$hue.red.600" }, destructive: { default: "$hue.red.600" },
}, },
@@ -45,6 +49,7 @@ test("supports property-first definitions, variants, states, and contexts", () =
expect(text.action.primary.$pressed).toBe("$hue.neutral.200") expect(text.action.primary.$pressed).toBe("$hue.neutral.200")
expect(text.formfield.$selected).toBe("$hue.neutral.100") expect(text.formfield.$selected).toBe("$hue.neutral.100")
expect(background.action.destructive.default).toBe("$hue.red.600") expect(background.action.destructive.default).toBe("$hue.red.600")
expect(background.action.primary.$selected).toBe("$hue.interactive.700")
expect(background.surface.offset).toBe("$hue.neutral.200") expect(background.surface.offset).toBe("$hue.neutral.200")
expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800") expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800")
expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300") expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300")
@@ -26,6 +26,7 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
expect(migrated.dark.background?.surface?.offset).toBe("$hue.neutral.800") expect(migrated.dark.background?.surface?.offset).toBe("$hue.neutral.800")
expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.700") expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.700")
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default") expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
expect(migrated.light.background?.action?.primary?.$selected).toBe("$hue.interactive.900")
expect(migrated.light.scrollbar?.default).toBe(hex(legacy.borderActive)) expect(migrated.light.scrollbar?.default).toBe(hex(legacy.borderActive))
expect(migrated.light.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg)) expect(migrated.light.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
expect(migrated.light.markdown?.emphasis).toBe(hex(legacy.markdownEmph)) expect(migrated.light.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
@@ -39,6 +40,8 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
expect(resolved.text.formfield.focused.toInts()).toEqual(legacy.primary.toInts()) expect(resolved.text.formfield.focused.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.hue.accent[900].toInts()).toEqual(legacy.accent.toInts()) expect(resolved.hue.accent[900].toInts()).toEqual(legacy.accent.toInts())
expect(resolved.hue.interactive[900].toInts()).toEqual(legacy.primary.toInts()) expect(resolved.hue.interactive[900].toInts()).toEqual(legacy.primary.toInts())
expect(resolved.background.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts()) expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual( expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(
legacy.backgroundPanel.toInts(), legacy.backgroundPanel.toInts(),