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
123 changed files with 659 additions and 1944 deletions
+19 -1
View File
@@ -17,6 +17,7 @@ export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
export const PROGRESS_LINES = 25
const BACKGROUND_STARTED = "The command was moved to the background."
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 final = yield* shell.wait(info.id)
@@ -258,7 +276,7 @@ export const Plugin = {
const progress = yield* Effect.sleep("1 second").pipe(
Effect.andThen(
captureShell().pipe(
captureProgress().pipe(
Effect.flatMap((capture) =>
context.progress({
structured: { truncated: capture.truncated },
+11 -8
View File
@@ -166,10 +166,10 @@ const overflowCommand = (bytes: number) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
const progressOverflowCommand = (bytes: number) =>
const progressLinesCommand = (lines: number) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 1500`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; sleep 1.5`
? `1..${lines} | ForEach-Object { [Console]::Out.WriteLine(('line {0:d2}' -f $_)) }; Start-Sleep -Milliseconds 1500`
: `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>) =>
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.promise(() => tmpdir()),
(tmp) => {
reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
const lines = ShellTool.PROGRESS_LINES + 10
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const progress: ToolRegistry.Progress[] = []
yield* settleTool(registry, {
...call({ command: progressOverflowCommand(bytes) }, "call-progress"),
...call({ command: progressLinesCommand(lines) }, "call-progress"),
progress: (update) => Effect.sync(() => progress.push(update)),
})
@@ -436,9 +436,12 @@ describe("ShellTool", () => {
const content = progress[0]?.content[0]
expect(content?.type).toBe("text")
if (content?.type !== "text") return
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
ShellTool.MAX_CAPTURE_BYTES,
expect(content.text).toStartWith(
`[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_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_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_next: keybind("none", "Navigate to next message"),
messages_previous: keybind("none", "Navigate to previous message"),
messages_last_user: keybind("none", "Navigate to last user message"),
messages_next: keybind("alt+down", "Navigate to next message"),
messages_previous: keybind("alt+up", "Navigate to previous 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_undo: keybind("<leader>u", "Undo message"),
messages_redo: keybind("<leader>r", "Redo message"),
@@ -335,6 +337,8 @@ export const CommandMap = {
messages_last: "session.last",
messages_next: "session.message.next",
messages_previous: "session.message.previous",
messages_next_user: "session.message.user.next",
messages_previous_user: "session.message.user.previous",
messages_last_user: "session.messages_last_user",
messages_copy: "messages.copy",
messages_undo: "session.undo",
@@ -50,9 +50,8 @@ function Mcp(props: { context: Plugin.Context }) {
}
function View(props: { context: Plugin.Context }) {
const { themeV2, mode, setMode } = useTheme()
const { themeV2 } = useTheme()
const dimensions = useTerminalDimensions()
const modeLabel = createMemo(() => (mode() === "dark" ? "Switch to light" : "Switch to dark"))
const mcpWidth = createMemo(() => {
const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
if (list.length === 0) return 0
@@ -73,16 +72,10 @@ function View(props: { context: Plugin.Context }) {
>
<Directory
context={props.context}
maxWidth={Math.max(
2,
dimensions().width - 10 - Bun.stringWidth(InstallationVersion) - Bun.stringWidth(modeLabel()) - mcpWidth(),
)}
maxWidth={Math.max(2, dimensions().width - 8 - Bun.stringWidth(InstallationVersion) - mcpWidth())}
/>
<Mcp context={props.context} />
<box flexGrow={1} />
<box flexShrink={0} onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")}>
<text fg={themeV2.text.action.secondary()}>{modeLabel()}</text>
</box>
<box flexShrink={0}>
<text fg={themeV2.text.subdued()}>{InstallationVersion}</text>
</box>
@@ -13,7 +13,6 @@ export function ShellTab(props: { sessionID: string }) {
const location = useLocation()
const client = useClient()
const { themeV2 } = useTheme()
const fg = themeV2.text.action.primary("focused")
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
@@ -96,11 +95,7 @@ export function ShellTab(props: { sessionID: string }) {
return (
<Show when={composer.active("shell")}>
<scrollbox
scrollbarOptions={{ visible: false }}
maxHeight={5}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
>
<scrollbox 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>}>
<For each={entries()}>
{(shell, index) => {
@@ -110,11 +105,11 @@ export function ShellTab(props: { sessionID: string }) {
flexDirection="row"
paddingLeft={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())}
>
<text
fg={active() ? fg : themeV2.text()}
fg={themeV2.text.action.primary(active() ? "focused" : "default")}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
@@ -22,7 +22,6 @@ export function SubagentsTab(props: { sessionID: string }) {
const data = useData()
const client = useClient()
const { themeV2 } = useTheme()
const fg = themeV2.text.action.primary("focused")
const navigate = useRoute().navigate
const composer = useComposerTab()
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)
for (const sibling of siblings) {
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
result.push({
sessionID: sibling.id,
@@ -53,7 +56,11 @@ export function SubagentsTab(props: { sessionID: string }) {
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
for (const child of children) {
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
result.push({
sessionID: child.id,
@@ -195,11 +202,7 @@ export function SubagentsTab(props: { sessionID: string }) {
return (
<Show when={composer.active("subagents")}>
<scrollbox
scrollbarOptions={{ visible: false }}
maxHeight={5}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
>
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No subagents</text>}>
<For each={entries()}>
{(entry, index) => {
@@ -213,7 +216,7 @@ export function SubagentsTab(props: { sessionID: string }) {
flexDirection="row"
paddingLeft={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())}
onMouseUp={() => {
setStore("selected", index())
@@ -222,7 +225,9 @@ export function SubagentsTab(props: { sessionID: string }) {
>
<box flexGrow={1} minWidth={0} flexDirection="row">
<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}
wrapMode="none"
>
@@ -230,7 +235,7 @@ export function SubagentsTab(props: { sessionID: string }) {
</text>
</box>
<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()}
</text>
</Show>
+100 -49
View File
@@ -71,11 +71,15 @@ import { usePluginRuntime } from "../../plugin/runtime"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows"
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
addDefaultParsers(parsers.parsers)
// Exclude temporary bottom space when measuring the real transcript height.
const NAVIGATION_SLACK_ID = "session-navigation-slack"
const context = createContext<{
width: number
sessionID: string
@@ -180,6 +184,23 @@ export function Session() {
const client = useClient()
const editor = useEditorContext()
const rows = createSessionRows(() => route.sessionID)
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
const [navigationMessage, setNavigationMessage] = createSignal<string>()
const [navigationSlack, setNavigationSlack] = createSignal(0)
const clearMessageNavigation = () => {
setNavigationSlack(0)
setNavigationMessage(undefined)
}
createEffect(
on(
() => [dimensions().width, dimensions().height] as const,
(_, previous) => {
if (previous) clearMessageNavigation()
},
),
)
createEffect(
on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => {
@@ -239,53 +260,55 @@ export function Session() {
dialog.clear()
}
// Helper: Find next visible message boundary in direction
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
const children = scroll.getChildren()
const messagesList = messages()
const scrollTop = scroll.y
// Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
const visibleMessages = children
.filter((c) => {
if (!c.id) return false
const message = messagesList.find((m) => m.id === c.id)
if (!message) return false
if (message.type === "user") return Boolean(message.text.trim())
return (
message.type === "assistant" &&
message.content.some((content) => content.type === "text" && content.text.trim())
)
const alignMessage = (messageID: string, top: number) => {
scroll.stickyScroll = false
setNavigationMessage(messageID)
setNavigationSlack(
messageNavigationSlack({
top,
viewportHeight: scroll.viewport.height,
scrollHeight: scroll.scrollHeight,
currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0,
}),
)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (scroll.isDestroyed || navigationMessage() !== messageID) return
scroll.scrollTo(top)
})
.sort((a, b) => a.y - b.y)
if (visibleMessages.length === 0) return null
if (direction === "next") {
// Find first message below current position
return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null
}
// Find last message above current position
return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null
})
}
// Helper: Scroll to message in direction or fallback to page scroll
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>) => {
const targetID = findNextVisibleMessage(direction)
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>, userOnly = false) => {
const target = findMessageBoundary({
direction,
children: scroll.getChildren(),
messages: messages(),
scrollTop: scroll.scrollTop,
viewportY: scroll.viewport.y,
currentID: navigationMessage(),
userOnly,
})
if (!targetID) {
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
if (!target) {
dialog.clear()
return
}
const child = scroll.getChildren().find((c) => c.id === targetID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
alignMessage(target.id, target.top)
dialog.clear()
}
const jumpToMessage = (messageID: string) => {
const child = scroll.getRenderable(messageID)
if (!child) return
const y = scroll.scrollTop + child.y - scroll.viewport.y
const message = data.session.message.get(route.sessionID, messageID)
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
}
function toBottom() {
clearMessageNavigation()
setTimeout(() => {
if (!scroll || scroll.isDestroyed) return
scroll.scrollTo(scroll.scrollHeight)
@@ -299,6 +322,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 2)
dialog.clear()
},
@@ -309,6 +333,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 2)
dialog.clear()
},
@@ -319,6 +344,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-1)
dialog.clear()
},
@@ -329,6 +355,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(1)
dialog.clear()
},
@@ -339,6 +366,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 4)
dialog.clear()
},
@@ -349,6 +377,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 4)
dialog.clear()
},
@@ -362,6 +391,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollTo(0)
dialog.clear()
},
@@ -372,6 +402,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollTo(scroll.scrollHeight)
dialog.clear()
},
@@ -412,8 +443,7 @@ export function Session() {
sessionID={route.sessionID}
onMove={(messageID) => {
if (!messageID) return
const child = scroll.getChildren().find((child) => child.id === messageID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
jumpToMessage(messageID)
}}
/>
))
@@ -574,10 +604,7 @@ export function Session() {
const message = messages[i]
if (!message || message.type !== "user" || !message.text.trim()) continue
{
const child = scroll.getChildren().find((child) => {
return child.id === message.id
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
jumpToMessage(message.id)
break
}
}
@@ -597,6 +624,20 @@ export function Session() {
hidden: true,
run: () => scrollToMessage("prev", dialog),
},
{
title: "Next user message",
name: "session.message.user.next",
category: "Session",
hidden: true,
run: () => scrollToMessage("next", dialog, true),
},
{
title: "Previous user message",
name: "session.message.user.previous",
category: "Session",
hidden: true,
run: () => scrollToMessage("prev", dialog, true),
},
{
title: "Copy last assistant message",
name: "messages.copy",
@@ -810,7 +851,10 @@ export function Session() {
createEffect(
on(
() => route.sessionID,
() => setComposer("open", false),
() => {
setComposer("open", false)
clearMessageNavigation()
},
),
)
@@ -846,16 +890,17 @@ export function Session() {
foregroundColor: themeV2.border(),
},
}}
stickyScroll={true}
stickyScroll={!navigationMessage()}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={rows}>
{(row) => (
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index()]}
/>
)}
</For>
@@ -870,6 +915,9 @@ export function Session() {
files={session()!.revert!.files ?? []}
/>
</Show>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
<box flexShrink={0}>
<Composer
@@ -942,9 +990,13 @@ export function Session() {
)
}
function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessageInfo | undefined }) {
function SessionRowView(props: {
row: SessionRow
message: (messageID: string) => SessionMessageInfo | undefined
boundaryID?: string
}) {
return (
<box marginTop={1} flexShrink={0}>
<box id={props.boundaryID} marginTop={1} flexShrink={0}>
<Switch>
<Match when={props.row.type === "message" ? props.row : undefined}>
{(row) => (
@@ -1563,7 +1615,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
return (
<Show when={props.message.text.trim() || files().length}>
<box
id={props.message.id}
border={["left"]}
borderColor={queued() ? themeV2.border() : color()}
customBorderChars={SplitBorder.customBorderChars}
@@ -2279,7 +2330,7 @@ function BlockTool(props: {
paddingLeft={2}
gap={1}
backgroundColor={
hover() ? themeV2.background.action.secondary() : themeV2.background()
hover() ? themeV2.background.action.secondary("focused") : themeV2.background()
}
customBorderChars={SplitBorder.customBorderChars}
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) {
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
if (tool) return tool
+150 -128
View File
@@ -97,76 +97,87 @@ export const DEFAULT_THEME = {
neutral: "$hue.gray",
},
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",
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",
$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" },
},
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.600",
},
background: {
default: "$hue.neutral.100",
surface: {
offset: "$hue.neutral.200",
overlay: "$hue.neutral.300",
},
action: {
primary: {
default: "$hue.interactive.600", $focused: "$hue.interactive.700", $pressed: "$hue.interactive.800",
$disabled: "$hue.neutral.300",
},
secondary: {
default: "$hue.neutral.200", $focused: "$hue.neutral.300", $pressed: "$hue.neutral.400",
$disabled: "$hue.neutral.200",
},
destructive: {
default: "$hue.red.600", $focused: "$hue.red.700", $pressed: "$hue.red.800",
$disabled: "$hue.neutral.300",
},
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
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: {
default: "$hue.neutral.100",
surface: {
offset: "$hue.neutral.200",
overlay: "$hue.neutral.300",
},
action: {
primary: {
default: "$hue.interactive.600",
$focused: "$hue.interactive.700",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
$selected: "$hue.interactive.700",
$disabled: "$hue.neutral.300",
},
feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
secondary: {
default: "$hue.neutral.200",
$focused: "$hue.neutral.300",
$pressed: "$hue.neutral.400",
$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" },
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" },
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
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",
keyword: "$hue.purple.600",
function: "$hue.accent.600",
@@ -177,7 +188,7 @@ export const DEFAULT_THEME = {
operator: "$hue.cyan.600",
punctuation: "$hue.neutral.900",
},
markdown: {
markdown: {
text: "$hue.neutral.900",
heading: "$hue.purple.600",
link: "$hue.accent.600",
@@ -303,76 +314,87 @@ export const DEFAULT_THEME = {
neutral: "$hue.gray",
},
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",
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",
$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" },
},
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.500",
},
background: {
default: "$hue.neutral.900",
surface: {
offset: "$hue.neutral.800",
overlay: "$hue.neutral.700",
},
action: {
primary: {
default: "$hue.interactive.500", $focused: "$hue.interactive.600", $pressed: "$hue.interactive.800",
$disabled: "$hue.neutral.800",
},
secondary: {
default: "$hue.neutral.800", $focused: "$hue.neutral.700", $pressed: "$hue.neutral.900",
$disabled: "$hue.neutral.900",
},
destructive: {
default: "$hue.red.600", $focused: "$hue.red.700", $pressed: "$hue.red.800",
$disabled: "$hue.neutral.800",
},
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
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: {
default: "$hue.neutral.900",
surface: {
offset: "$hue.neutral.800",
overlay: "$hue.neutral.700",
},
action: {
primary: {
default: "$hue.interactive.500",
$focused: "$hue.interactive.600",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
$selected: "$hue.interactive.600",
$disabled: "$hue.neutral.800",
},
feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
secondary: {
default: "$hue.neutral.800",
$focused: "$hue.neutral.700",
$pressed: "$hue.neutral.900",
$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" },
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" },
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
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",
keyword: "$hue.purple.400",
function: "$hue.accent.400",
@@ -383,7 +405,7 @@ export const DEFAULT_THEME = {
operator: "$hue.cyan.400",
punctuation: "$hue.neutral.100",
},
markdown: {
markdown: {
text: "$hue.neutral.100",
heading: "$hue.purple.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 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 ActionStateKey = `$${ActionState}`
@@ -77,6 +77,7 @@ const StatefulColorDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
$focused: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue),
$selected: Schema.optional(ColorValue),
$disabled: Schema.optional(ColorValue),
})
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",
$disabled: textMuted,
$focused: selected,
$selected: primary,
},
secondary: {
default: "$text.default",
@@ -82,7 +83,7 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
overlay: backgroundMenu,
},
action: {
primary: { default: "transparent", $focused: primary },
primary: { default: "transparent", $focused: primary, $selected: primary },
secondary: {
default: "$background.default",
$focused: color("backgroundElement"),
@@ -150,11 +151,7 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
imageText: color("markdownImageText"),
codeBlock: color("markdownCodeBlock"),
},
"@context:elevated": {
background: {
default: "$background.surface.offset",
},
},
"@context:elevated": { background: { default: "$background.surface.offset" } },
"@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 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[] = [
+11
View File
@@ -86,6 +86,17 @@ 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.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", () => {
const config = resolve({}, { terminalSuspend: true })
+63 -4
View File
@@ -1,9 +1,10 @@
/** @jsxImportSource @opentui/solid */
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { createBindingLookup } from "@opentui/keymap/extras"
import { type TextareaRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onCleanup } from "solid-js"
import { onCleanup, onMount } from "solid-js"
import { TuiKeybind } from "../src/config/keybind"
import {
formatKeySequence,
@@ -110,6 +111,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 () => {
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 />)
try {
expect(counts).toEqual({
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
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": 3, "model.list": 0 },
autocomplete: {
"session.list": 1,
"session.new": 1,
"session.page.up": 2,
"session.first": 2,
"session.first": 3,
"model.list": 0,
},
})
-37
View File
@@ -1,37 +0,0 @@
# TUI theme screenshot gallery
This gallery runs one repeatable OpenCode Drive workflow for every V1 theme in a directory. Each theme runs in
light and dark mode and produces a flat set of screenshots covering the home screen, markdown, permission prompt,
form prompt, and session switcher.
It expects `opencode-drive` to be installed globally and available on `PATH`.
The checked-in fixtures include selected built-in OpenCode themes and community themes from
[`vaprdev/opencode-themes`](https://github.com/vaprdev/opencode-themes). Theme-specific attribution and licenses
remain documented in that source repository.
## Run
Place theme JSON files in `themes/`, then run from the repository root:
```sh
bun packages/tui/test/theme/gallery/run.ts
```
Custom input and output directories can be passed as positional arguments:
```sh
bun packages/tui/test/theme/gallery/run.ts ./my-themes ./theme-screenshots
```
Output files are named `<theme>-<mode>-<state>.png` in one flat directory. Existing files with the same names are
overwritten. Runs are sequential so each isolated OpenCode Drive instance owns its own ports and artifacts.
The current TUI discovers V1 theme files and migrates them to V2 at runtime. Native V2 JSON files are reported as
unsupported and make the runner exit nonzero; they are not converted or silently rendered with a fallback theme.
Before changing the scenario, typecheck it with:
```sh
opencode-drive check packages/tui/test/theme/gallery/scenario.ts
```
-79
View File
@@ -1,79 +0,0 @@
import path from "node:path"
import { mkdir } from "node:fs/promises"
const root = path.resolve(import.meta.dir, "../../../../..")
const themes = path.resolve(Bun.argv[2] ?? path.join(import.meta.dir, "themes"))
const screenshots = path.resolve(Bun.argv[3] ?? path.join(import.meta.dir, "screenshots"))
const scenario = path.join(import.meta.dir, "scenario.ts")
const files = await Array.fromAsync(new Bun.Glob("**/*.json").scan({ cwd: themes, absolute: true }))
if (!files.length) {
console.error(`No JSON themes found in ${themes}`)
process.exit(1)
}
await mkdir(screenshots, { recursive: true })
const failures: string[] = []
const slugs = new Set<string>()
for (const file of files.sort()) {
const source = await Bun.file(file).json().catch(() => undefined)
const slug = path.basename(file, ".json").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
if (!slug) {
failures.push(`${file}: filename does not produce a usable theme prefix`)
continue
}
if (slugs.has(slug)) {
failures.push(`${file}: duplicate theme prefix "${slug}"`)
continue
}
slugs.add(slug)
if (!isRecord(source)) {
failures.push(`${file}: invalid JSON theme object`)
continue
}
if (source.version === 2) {
failures.push(`${file}: native V2 theme loading is not supported by the TUI yet`)
continue
}
if (!isRecord(source.theme)) {
failures.push(`${file}: expected a V1 theme with a "theme" object`)
continue
}
for (const mode of ["light", "dark"] as const) {
const name = `theme-gallery-${slug}-${mode}-${process.pid}`
console.log(`\n[${slug}/${mode}] capturing screenshots`)
const child = Bun.spawn(
["opencode-drive", "start", "--name", name, "--script", scenario, "--dev", root],
{
cwd: root,
env: {
...process.env,
OPENCODE_THEME_GALLERY_FILE: file,
OPENCODE_THEME_GALLERY_MODE: mode,
OPENCODE_THEME_GALLERY_OUTPUT: screenshots,
OPENCODE_THEME_GALLERY_SLUG: slug,
},
stdout: "inherit",
stderr: "inherit",
},
)
const exit = await child.exited
if (exit !== 0) failures.push(`${file} (${mode}): OpenCode Drive exited with ${exit}`)
}
}
if (failures.length) {
console.error("\nTheme gallery completed with errors:")
failures.forEach((failure) => console.error(`- ${failure}`))
process.exit(1)
}
console.log(`\nScreenshots written to ${screenshots}`)
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
-123
View File
@@ -1,123 +0,0 @@
import path from "node:path"
import { mkdir } from "node:fs/promises"
import { defineScript, wait } from "opencode-drive"
const file = required("OPENCODE_THEME_GALLERY_FILE")
const mode = required("OPENCODE_THEME_GALLERY_MODE")
const output = required("OPENCODE_THEME_GALLERY_OUTPUT")
const slug = required("OPENCODE_THEME_GALLERY_SLUG")
const themeName = `gallery-${slug}`
export default defineScript({
async setup({ fs, config }) {
await fs.writeFile("README.md", "# Theme gallery fixture\n\nA stable project for OpenCode TUI screenshots.\n")
await fs.writeFile("src/example.ts", "export const palette = ['neutral', 'accent', 'interactive']\n")
await fs.writeFile(`.opencode/themes/${themeName}.json`, await Bun.file(file).text())
await fs.writeFile(
".opencode/cli.json",
`${JSON.stringify({
theme: { name: themeName, mode },
}, undefined, 2)}\n`,
)
config.permissions = [
{ action: "*", resource: "*", effect: "allow" },
{ action: "shell", resource: "*", effect: "ask" },
]
},
async run({ llm, ui }) {
await mkdir(output, { recursive: true })
llm.title((_request, index) => (index === 0 ? "Theme gallery" : `Theme gallery ${index + 1}`))
await ui.waitFor((state) => state.focused.editor)
await ui.waitFor("local")
await wait(750)
await capture(ui, "01-home")
await ui.submit("Show me a compact markdown theme specimen")
await llm.send(
llm.reasoning("I will provide a stable markdown sample for the theme gallery."),
llm.text(
[
"# Theme Review",
"",
"A compact response with **strong text**, *emphasis*, and an `inline token`.",
"",
"> Good themes preserve hierarchy without losing contrast.",
"",
"- Neutral surfaces",
"- Interactive accents",
"- Success, warning, and error feedback",
"",
"```ts",
"const mode = 'gallery'",
"```",
].join("\n"),
),
)
await ui.waitFor("Theme Review")
await capture(ui, "02-markdown")
const permission = llm.send(
llm.toolCall({
id: "theme-gallery-permission",
index: 0,
name: "shell",
input: { command: "printf 'theme gallery permission'" },
}),
)
await ui.submit("Run a protected command so I can inspect the permission prompt")
await ui.waitFor("Permission required")
await capture(ui, "03-permission")
await ui.enter()
await permission
await llm.send(llm.text("The protected command completed successfully."))
await ui.waitFor("completed successfully")
const form = llm.send(
llm.toolCall({
id: "theme-gallery-question",
index: 0,
name: "question",
input: {
questions: [
{
header: "Theme direction",
question: "Which direction should this theme emphasize?",
options: [
{ label: "Balanced", description: "Keep surfaces and accents evenly weighted" },
{ label: "Expressive", description: "Give accent colors more visual presence" },
{ label: "Quiet", description: "Favor neutral surfaces and restrained contrast" },
],
},
],
},
}),
)
await ui.submit("Ask me for a theme direction")
await ui.waitFor("Which direction should this theme emphasize?")
await capture(ui, "04-form")
await ui.enter()
await form
await llm.send(llm.text("The theme review form was submitted."))
await ui.waitFor("form was submitted")
await ui.submit("/new")
await ui.waitFor((state) => state.focused.editor)
await ui.submit("Create a second session for the session switcher gallery")
await llm.send(llm.text("This second session makes the switcher state visible."))
await ui.submit("/sessions")
await ui.waitFor("Sessions")
await capture(ui, "05-session-switcher")
},
})
async function capture(ui: { screenshot(name?: string): Promise<string> }, state: string) {
const source = await ui.screenshot(`${slug}-${mode}-${state}`)
await Bun.write(path.join(output, `${slug}-${mode}-${state}.png`), Bun.file(source))
}
function required(name: string) {
const value = process.env[name]
if (!value) throw new Error(`${name} is required`)
return value
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Some files were not shown because too many files have changed in this diff Show More