mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4778e6e031 | |||
| ac1b802820 | |||
| 5ee4c1082a | |||
| 8a70d70006 | |||
| 91b4634363 | |||
| 4f60bde502 | |||
| 0d3b6d430e |
@@ -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 },
|
||||||
|
|||||||
@@ -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`)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||||
import { reduceSessionRows } from "../../../src/routes/session/rows"
|
import { messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
|
||||||
|
|
||||||
|
test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
|
||||||
|
const messages: SessionMessageInfo[] = [
|
||||||
|
{ type: "user", id: "user-1", text: "Question", time: { created: 0 } },
|
||||||
|
assistant("assistant-1", [
|
||||||
|
{ type: "reasoning", text: "Thinking" },
|
||||||
|
{ type: "text", text: "First" },
|
||||||
|
{ type: "text", text: "Second" },
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
const rows = reduceSessionRows(messages)
|
||||||
|
|
||||||
|
expect(messageBoundaryIDs(rows, messages)).toEqual(["user-1", "assistant-1", undefined, undefined])
|
||||||
|
})
|
||||||
|
|
||||||
test("groups exploration parts across assistant messages until a delimiter", () => {
|
test("groups exploration parts across assistant messages until a delimiter", () => {
|
||||||
const messages: SessionMessageInfo[] = [
|
const messages: SessionMessageInfo[] = [
|
||||||
|
|||||||
@@ -86,6 +86,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 })
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user