Compare commits

..

7 Commits

Author SHA1 Message Date
Simon Klee 53f7a1c63c Merge branch 'dev' into oc-snapshot-subdir 2026-05-18 13:05:17 +02:00
Shoubhit Dash 611e48c4ac fix(tui): collapse long tool output lines (#28148) 2026-05-18 16:34:34 +05:30
Brendan Allan 836a33198e fix(ui): fix question dock overflow and message part flex layout (#28142) 2026-05-18 17:55:27 +08:00
opencode-agent[bot] 7afd477d1a chore: generate 2026-05-18 09:28:20 +00:00
SpiritChen51 fe143df151 fix(ui): fallback to execCommand for clipboard copy when navigator.clipboard fails (#27993)
Co-authored-by: SpiritChen51 <spiritchen51@users.noreply.github.com>
2026-05-18 17:26:45 +08:00
Kagura e94aecaa08 fix(tui): use contrast-aware foreground for paste summary badge (#27969)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-05-18 08:38:40 +00:00
Simon Klee 4a608b2026 snapshot: fix cwd for git commands in subdirs
When opencode is launched from a git repository subdirectory,
the snapshot service passes file paths relative to the worktree
root but sets cwd to the launch directory. Git cannot resolve
the paths and fails with "did not match any files".

Use the worktree root as cwd so paths resolve correctly
regardless of which subdirectory opencode is started from.

Fix #27688
Close #27737
2026-05-18 09:58:08 +02:00
14 changed files with 282 additions and 290 deletions
@@ -469,7 +469,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text">{question()?.question}</div>
<div data-slot="question-text" class="overflow-auto">
{question()?.question}
</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
@@ -360,10 +360,14 @@ function syncPermission(data: SessionData, part: ToolPart): FooterOutput | undef
}
}
// Tool-owned question requests can complete without a matching question.replied
// event. When that happens, drop the recovered pending request tied to this tool
// call so the footer can return to the next blocker or to the prompt.
// Question tool replies can complete without a matching question.replied event.
// When that happens, drop the recovered pending request tied to this tool call so
// the footer can return to the next blocker or to the prompt.
function syncQuestion(data: SessionData, part: ToolPart): FooterOutput | undefined {
if (part.tool !== "question") {
return undefined
}
if (part.state.status !== "completed" && part.state.status !== "error") {
return undefined
}
@@ -15,7 +15,7 @@
// The tick counter prevents stale idle events from resolving the wrong turn.
// We also re-check live session status before resolving an idle event so a
// delayed idle from an older turn cannot complete a newer busy turn.
import type { Event, GlobalEvent, OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"
import type { Event, GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2"
import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect"
import { makeRuntime } from "@/effect/run-service"
import {
@@ -505,10 +505,7 @@ function createLayer(input: StreamInput) {
state.footerView = current
}
const recoverQuestion = Effect.fn("RunStreamTransport.recoverQuestion")(function* (part: ToolPart) {
const partID = part.id
const matches = (request: SessionData["questions"][number]) =>
request.tool?.messageID === part.messageID && request.tool?.callID === part.callID
const recoverQuestion = Effect.fn("RunStreamTransport.recoverQuestion")(function* (partID: string) {
if (recovering.has(partID)) {
return
}
@@ -516,7 +513,7 @@ function createLayer(input: StreamInput) {
recovering.add(partID)
try {
while (!closed && !abort.signal.aborted && !input.footer.isClosed) {
if (state.data.questions.some(matches) || !state.data.tools.has(partID)) {
if (state.data.questions.length > 0 || !state.data.tools.has(partID)) {
return
}
@@ -524,14 +521,11 @@ function createLayer(input: StreamInput) {
Effect.map((item) => (item.data ?? []).filter((request) => request.sessionID === input.sessionID)),
Effect.orElseSucceed(() => []),
)
if (state.data.questions.some(matches) || !state.data.tools.has(partID)) {
if (state.data.questions.length > 0 || !state.data.tools.has(partID)) {
return
}
const matching = questions.filter(matches)
if (matching.length > 0) {
const active = new Set(questions.map((request) => request.id))
state.data.questions = state.data.questions.filter((request) => active.has(request.id))
if (questions.length > 0) {
bootstrapSessionData({
data: state.data,
messages: [],
@@ -541,11 +535,9 @@ function createLayer(input: StreamInput) {
for (const request of questions) {
seedBlocker(request.id)
}
const priority = Math.min(0, ...state.blockers.values()) - 1
for (const request of matching) state.blockers.set(request.id, priority)
input.trace?.write("question.recover", {
sessionID: input.sessionID,
requests: matching.map((request) => request.id),
requests: questions.map((request) => request.id),
})
syncFooter([])
return
@@ -791,10 +783,11 @@ function createLayer(input: StreamInput) {
event.type === "message.part.updated" &&
event.properties.part.sessionID === input.sessionID &&
event.properties.part.type === "tool" &&
(event.properties.part.tool === "question" || event.properties.part.tool === "plan_exit") &&
event.properties.part.state.status === "running"
event.properties.part.tool === "question" &&
event.properties.part.state.status === "running" &&
state.data.questions.length === 0
) {
yield* recoverQuestion(event.properties.part).pipe(
yield* recoverQuestion(event.properties.part.id).pipe(
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
)
@@ -34,14 +34,6 @@ import path from "path"
import { useKV } from "./kv"
import { aggregateFailures } from "./aggregate-failures"
export function questionToolRequestIndex(requests: readonly QuestionRequest[] | undefined, part: Part) {
if (part.type !== "tool") return -1
if (part.state.status !== "completed" && part.state.status !== "error") return -1
return requests?.findIndex(
(request) => request.tool?.messageID === part.messageID && request.tool?.callID === part.callID,
) ?? -1
}
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
@@ -312,32 +304,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "message.part.updated": {
const part = event.properties.part
const parts = store.part[part.messageID]
const parts = store.part[event.properties.part.messageID]
if (!parts) {
setStore("part", part.messageID, [part])
setStore("part", event.properties.part.messageID, [event.properties.part])
break
}
if (parts) {
const result = Binary.search(parts, part.id, (p) => p.id)
if (result.found) setStore("part", part.messageID, result.index, reconcile(part))
if (!result.found)
setStore(
"part",
part.messageID,
produce((draft) => {
draft.splice(result.index, 0, part)
}),
)
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
break
}
const index = questionToolRequestIndex(store.question[part.sessionID], part)
if (index !== -1)
setStore(
"question",
part.sessionID,
produce((draft) => {
draft.splice(index, 1)
}),
)
setStore(
"part",
event.properties.part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
)
break
}
@@ -773,7 +773,7 @@ function getSyntaxRules(theme: Theme) {
{
scope: ["extmark.paste"],
style: {
foreground: theme.background,
foreground: selectedForeground(theme, theme.warning),
background: theme.warning,
bold: true,
},
@@ -30,6 +30,7 @@ import type {
ToolTextContent,
} from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { collapseToolOutput } from "../../util/collapse-tool-output"
const id = "internal:session-v2-debug"
const route = "session.v2.messages"
@@ -198,26 +199,28 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
function ShellMessage(props: { message: SessionMessageShell }) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => stripAnsi(props.message.output.trim()))
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
<BlockTool
title="# Shell"
spinner={!props.message.time.completed}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.message.command}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -518,14 +521,15 @@ type ToolProps = {
function GenericTool(props: ToolProps) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const maxLines = 3
const overflow = createMemo(() => lines().length > maxLines)
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
<Show
@@ -539,11 +543,11 @@ function GenericTool(props: ToolProps) {
<BlockTool
title={`# ${props.part.name} ${input(props.input)}`}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -702,15 +706,17 @@ function BlockTool(props: {
function Bash(props: ToolProps) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim()))
const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part))
const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`)
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
<Switch>
@@ -719,12 +725,12 @@ function Bash(props: ToolProps) {
title={title()}
part={props.part}
spinner={props.part.state.status === "running"}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {command()}</text>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -84,6 +84,7 @@ import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action"
import { SessionRetry } from "@/session/retry"
@@ -1696,12 +1697,12 @@ function GenericTool(props: ToolProps<any>) {
const ctx = use()
const output = createMemo(() => props.output?.trim() ?? "")
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const maxLines = 3
const overflow = createMemo(() => lines().length > maxLines)
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
return (
@@ -1716,11 +1717,11 @@ function GenericTool(props: ToolProps<any>) {
<BlockTool
title={`# ${props.tool} ${input(props.input)}`}
part={props.part}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -1871,14 +1872,16 @@ function BlockTool(props: {
function Shell(props: ToolProps<typeof ShellTool>) {
const { theme } = useTheme()
const pathFormatter = usePathFormatter()
const ctx = use()
const isRunning = createMemo(() => props.part.state.status === "running")
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false)
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const limited = createMemo(() => {
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
})
const workdirDisplay = createMemo(() => {
@@ -1902,14 +1905,14 @@ function Shell(props: ToolProps<typeof ShellTool>) {
title={title()}
part={props.part}
spinner={isRunning()}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.input.command}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={overflow()}>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -0,0 +1,13 @@
export function collapseToolOutput(output: string, maxLines: number, maxChars: number) {
const lines = output.split("\n")
if (lines.length <= maxLines && Array.from(output).length <= maxChars) {
return { output, overflow: false }
}
const preview = lines.slice(0, maxLines).join("\n")
if (Array.from(preview).length > maxChars) {
return { output: Array.from(preview).slice(0, Math.max(0, maxChars - 1)).join("") + "…", overflow: true }
}
return { output: [...lines.slice(0, maxLines), "…"].join("\n"), overflow: true }
}
+63 -33
View File
@@ -85,6 +85,10 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
const feed = (list: string[]) => list.join("\0") + "\0"
const feedSpec = (list: string[]) => feed(list.map((item) => `:(top,literal)${item}`))
const scope = path.relative(state.worktree, state.directory).replaceAll("\\", "/")
const spec = scope ? `:(top,literal)${scope}` : "."
const git = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string>; stdin?: string }) {
@@ -122,7 +126,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
"-z",
],
{
cwd: state.directory,
cwd: state.worktree,
stdin: feed(files),
},
)
@@ -138,8 +142,8 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
],
{
cwd: state.directory,
stdin: feed(files),
cwd: state.worktree,
stdin: feedSpec(files),
},
)
})
@@ -149,8 +153,8 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
const result = yield* git(
[...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
{
cwd: state.directory,
stdin: feed(files),
cwd: state.worktree,
stdin: feedSpec(files),
},
)
if (result.code === 0) return
@@ -197,11 +201,11 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
yield* sync()
const [diff, other] = yield* Effect.all(
[
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
cwd: state.directory,
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", spec])], {
cwd: state.worktree,
}),
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
cwd: state.directory,
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", spec])], {
cwd: state.worktree,
}),
],
{ concurrency: 2 },
@@ -239,7 +243,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
(yield* Effect.all(
allow.map((item) =>
fs
.stat(path.join(state.directory, item))
.stat(path.join(state.worktree, item))
.pipe(Effect.catch(() => Effect.void))
.pipe(
Effect.map((stat) => {
@@ -306,9 +310,9 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
Effect.gen(function* () {
yield* add()
const result = yield* git(
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", spec])],
{
cwd: state.directory,
cwd: state.worktree,
},
)
if (result.code !== 0) {
@@ -338,24 +342,47 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
return yield* locked(
Effect.gen(function* () {
log.info("restore", { commit: snapshot })
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
if (result.code === 0) {
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
cwd: state.worktree,
})
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
const listed = yield* git([...quote, ...args(["ls-tree", "-r", "-z", "--name-only", snapshot, "--", spec])], {
cwd: state.worktree,
})
if (listed.code !== 0) {
log.error("failed to list snapshot files", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr,
exitCode: listed.code,
stderr: listed.stderr,
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
})
const files = listed.text.split("\0").filter(Boolean)
if (!files.length) return
const index = path.join(state.gitdir, "restore.index")
yield* remove(index)
yield* Effect.gen(function* () {
const result = yield* git([...core, ...args(["read-tree", `--index-output=${index}`, snapshot])], {
cwd: state.worktree,
})
if (result.code === 0) {
const checkout = yield* git([...core, ...args(["checkout-index", "-f", "--stdin", "-z"])], {
cwd: state.worktree,
env: { GIT_INDEX_FILE: index },
stdin: feed(files),
})
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr,
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
})
}).pipe(Effect.ensuring(remove(index)))
}),
)
})
@@ -479,9 +506,12 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
return yield* locked(
Effect.gen(function* () {
yield* add()
const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
cwd: state.worktree,
})
const result = yield* git(
[...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", spec])],
{
cwd: state.worktree,
},
)
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
@@ -637,8 +667,8 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
{ cwd: state.directory },
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", spec])],
{ cwd: state.worktree },
)
for (const line of statuses.text.trim().split("\n")) {
@@ -649,9 +679,9 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
}
const numstat = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", spec])],
{
cwd: state.directory,
cwd: state.worktree,
},
)
@@ -835,17 +835,12 @@ describe("run stream transport", () => {
callID: "call-question-1",
},
}
const other = {
...request,
id: "question-old",
tool: { messageID: "msg-old", callID: "call-question-old" },
}
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
questions: async () => {
questionCalls += 1
return ok(questionCalls === 1 ? [other] : [request])
return ok(questionCalls > 1 ? [request] : [])
},
promptAsync: async () => {
queueMicrotask(() => {
@@ -890,9 +885,7 @@ describe("run stream transport", () => {
const view = await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.view")
return item?.type === "stream.view" && item.view.type === "question" && item.view.request.id === request.id
? item.view
: undefined
return item?.type === "stream.view" && item.view.type === "question" ? item.view : undefined
})
expect(view).toEqual({
@@ -908,7 +901,6 @@ describe("run stream transport", () => {
},
})
const count = ui.events.length
src.push(
toolUpdated(
completedTool({
@@ -938,121 +930,6 @@ describe("run stream transport", () => {
view: { type: "prompt" },
})
expect(
ui.events.slice(count).findLast(
(event) => event.type === "stream.view" && event.view.type === "question" && event.view.request.id === other.id,
),
).toBeUndefined()
ctrl.abort()
await run
} finally {
src.close()
await transport.close()
}
})
test("recovers pending plan_exit questions from question.list when question.asked is missed", async () => {
const src = eventFeed()
const ui = footer()
let questionCalls = 0
const request = {
id: "question-plan-1",
sessionID: "session-1",
questions: [
{
question: "Plan is complete. Start implementing it now?",
header: "Build Agent",
options: [{ label: "Yes", description: "Switch to build agent and start implementing." }],
multiple: false,
},
],
tool: {
messageID: "msg-plan-1",
callID: "call-plan-exit-1",
},
}
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
questions: async () => {
questionCalls += 1
return ok(questionCalls === 1 ? [] : [request])
},
promptAsync: async () => {
queueMicrotask(() => {
src.push(busy())
src.push(assistant("msg-plan-1"))
src.push(
toolUpdated(
runningTool({
sessionID: "session-1",
messageID: "msg-plan-1",
id: "plan-exit-tool-1",
callID: "call-plan-exit-1",
tool: "plan_exit",
body: {},
}),
),
)
})
return ok(undefined)
},
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
const ctrl = new AbortController()
try {
const run = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "hello", parts: [] },
files: [],
includeFiles: false,
signal: ctrl.signal,
})
expect(
await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.view")
return item?.type === "stream.view" && item.view.type === "question" ? item.view : undefined
}),
).toEqual({
type: "question",
request,
})
src.push(
toolUpdated(
completedTool({
sessionID: "session-1",
messageID: "msg-plan-1",
id: "plan-exit-tool-1",
callID: "call-plan-exit-1",
tool: "plan_exit",
body: {},
output: "User approved switching to build agent.",
metadata: {},
}),
),
)
expect(
await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.view")
return item?.type === "stream.view" && item.view.type === "prompt" ? item : undefined
}),
).toEqual({
type: "stream.view",
view: { type: "prompt" },
})
ctrl.abort()
await run
} finally {
@@ -1,39 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
import { questionToolRequestIndex } from "@/cli/cmd/tui/context/sync"
const request = {
id: "question-new",
sessionID: "session-1",
questions: [],
tool: { messageID: "msg-new", callID: "call-new" },
} satisfies QuestionRequest
function part(status: "running" | "completed" | "error", tool = "question", callID = "call-new"): ToolPart {
return {
id: "part-new",
sessionID: "session-1",
messageID: "msg-new",
type: "tool",
callID,
tool,
state:
status === "running"
? { status, input: {}, time: { start: 1 } }
: status === "completed"
? { status, input: {}, output: "", title: "question", metadata: {}, time: { start: 1, end: 2 } }
: { status, input: {}, error: "Tool execution aborted", time: { start: 1, end: 2 } },
}
}
describe("tui sync", () => {
test("matches terminal tool-owned question requests", () => {
const stale = { ...request, id: "question-old", tool: { messageID: "msg-old", callID: "call-old" } }
expect(questionToolRequestIndex([stale, request], part("error"))).toBe(1)
expect(questionToolRequestIndex([stale, request], part("completed"))).toBe(1)
expect(questionToolRequestIndex([stale, request], part("completed", "plan_exit"))).toBe(1)
expect(questionToolRequestIndex([stale, request], part("running"))).toBe(-1)
expect(questionToolRequestIndex([stale, request], part("error", "bash", "call-other"))).toBe(-1)
})
})
@@ -476,6 +476,99 @@ it.instance(
{ git: true },
)
it.live(
"subdirectory instances stage snapshot files relative to the worktree root",
Effect.gen(function* () {
const dir = yield* scopedGitTmpdir()
const subdir = `${dir}/src`
yield* mkdirp(subdir)
yield* write(`${subdir}/tracked.txt`, "tracked content")
yield* exec(dir, ["git", "add", "."])
yield* exec(dir, ["git", "commit", "-m", "add subdir"])
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.track()
expect(before).toBeTruthy()
yield* write(`${subdir}/date.txt`, "subdirectory content")
const patch = yield* snapshot.patch(before!)
expect(patch.files).toContain(fwd(subdir, "date.txt"))
}).pipe(provideInstance(subdir))
}),
)
it.live(
"subdirectory instances keep gitignored snapshot files out of patches",
Effect.gen(function* () {
const dir = yield* scopedGitTmpdir()
const subdir = `${dir}/src`
yield* mkdirp(subdir)
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
yield* write(`${subdir}/later-ignored.txt`, "initial content")
const before = yield* snapshot.track()
expect(before).toBeTruthy()
yield* write(`${subdir}/later-ignored.txt`, "modified content")
yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n")
yield* write(`${subdir}/still-tracked.txt`, "new tracked file")
const patch = yield* snapshot.patch(before!)
expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt"))
expect(patch.files).toContain(fwd(subdir, ".gitignore"))
expect(patch.files).toContain(fwd(subdir, "still-tracked.txt"))
}).pipe(provideInstance(subdir))
}),
)
it.live(
"subdirectory restore does not overwrite files outside the subdirectory",
Effect.gen(function* () {
const dir = yield* scopedGitTmpdir()
const subdir = `${dir}/src`
yield* write(`${dir}/root.txt`, "original root")
yield* write(`${subdir}/file.txt`, "original src")
yield* exec(dir, ["git", "add", "."])
yield* exec(dir, ["git", "commit", "-m", "init"])
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
yield* write(`${dir}/root.txt`, "root snapshot")
expect(yield* snapshot.track()).toBeTruthy()
}).pipe(provideInstance(dir))
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
yield* write(`${subdir}/file.txt`, "src snapshot")
const before = yield* snapshot.track()
expect(before).toBeTruthy()
yield* write(`${dir}/root.txt`, "root current")
yield* write(`${subdir}/file.txt`, "src current")
yield* snapshot.restore(before!)
expect(yield* readText(`${dir}/root.txt`)).toBe("root current")
expect(yield* readText(`${subdir}/file.txt`)).toBe("src snapshot")
}).pipe(provideInstance(subdir))
}),
)
it.live(
"subdirectory scope is treated as a literal git pathspec",
Effect.gen(function* () {
const dir = yield* scopedGitTmpdir()
const subdir = `${dir}/src*`
const sibling = `${dir}/srca`
yield* write(`${subdir}/file.txt`, "literal original")
yield* write(`${sibling}/file.txt`, "sibling original")
yield* exec(dir, ["git", "add", "."])
yield* exec(dir, ["git", "commit", "-m", "init"])
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.track()
expect(before).toBeTruthy()
yield* write(`${subdir}/file.txt`, "literal modified")
yield* write(`${sibling}/file.txt`, "sibling modified")
const patch = yield* snapshot.patch(before!)
expect(patch.files).toContain(fwd(subdir, "file.txt"))
expect(patch.files).not.toContain(fwd(sibling, "file.txt"))
}).pipe(provideInstance(subdir))
}),
)
it.instance(
"gitignore updated between track calls filters from diff",
withTrackedSnapshot(({ tmp, snapshot, before }) =>
+1 -1
View File
@@ -935,7 +935,7 @@
gap: 6px;
margin-top: 12px;
padding: 1px 1px 8px;
flex: 1;
flex-shrink: 0;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
+36 -9
View File
@@ -58,6 +58,30 @@ import { animate } from "motion"
import { useLocation } from "@solidjs/router"
import { attached, inline, kind } from "./message-file"
async function writeClipboard(text: string): Promise<boolean> {
const body = typeof document === "undefined" ? undefined : document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = text
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
textarea.style.pointerEvents = "none"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return true
}
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return false
return clipboard.writeText(text).then(
() => true,
() => false,
)
}
function ShellSubmessage(props: { text: string; animate?: boolean }) {
let widthRef: HTMLSpanElement | undefined
let valueRef: HTMLSpanElement | undefined
@@ -1064,9 +1088,10 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const handleCopy = async () => {
const content = text()
if (!content) return
await navigator.clipboard.writeText(content)
setState("copied", true)
setTimeout(() => setState("copied", false), 2000)
if (await writeClipboard(content)) {
setState("copied", true)
setTimeout(() => setState("copied", false), 2000)
}
}
const revert = () => {
@@ -1490,9 +1515,10 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const handleCopy = async () => {
const content = text()
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
if (await writeClipboard(content)) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
@@ -1834,9 +1860,10 @@ ToolRegistry.register({
const handleCopy = async () => {
const content = text()
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
if (await writeClipboard(content)) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (