Compare commits

..

3 Commits

Author SHA1 Message Date
Simon Klee cff1c6f333 cli: recover pending questions from plan_exit tool
The question recovery path only matched the "question" tool, so
plan_exit prompts would get stuck when the question.asked event
was missed during reconnection. Extend recovery to match any
tool-owned question request and drop the tool-name guard in
syncQuestion so completions clear the footer correctly.
2026-05-18 15:23:34 +02:00
Simon Klee f7493d41cb tui: dismiss question when tool part ends
Questions remained visible after the underlying tool call
completed or errored because nothing removed them from the
store. Clear each question once its associated tool part
reaches a terminal state, and fix recovery to preserve
correct blocker priority across all active questions.
2026-05-18 10:09:06 +02:00
Simon Klee f2f8efa411 cli: fix question recovery matching wrong session
The recovery logic matched questions by checking if the list was
non-empty, which caused it to pick up stale questions from earlier
turns. When a re-ask fired, the wrong question could resolve the
blocker, leaving the real question undelivered and the process stuck.

Match questions by messageID and callID from the originating tool part
so only the correct question unblocks the prompt.

Fixes #27503
2026-05-18 10:09:06 +02:00
14 changed files with 290 additions and 282 deletions
@@ -469,9 +469,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</>
}
>
<div data-slot="question-text" class="overflow-auto">
{question()?.question}
</div>
<div data-slot="question-text">{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,14 +360,10 @@ function syncPermission(data: SessionData, part: ToolPart): FooterOutput | undef
}
}
// 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.
// 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.
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 } from "@opencode-ai/sdk/v2"
import type { Event, GlobalEvent, OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"
import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect"
import { makeRuntime } from "@/effect/run-service"
import {
@@ -505,7 +505,10 @@ function createLayer(input: StreamInput) {
state.footerView = current
}
const recoverQuestion = Effect.fn("RunStreamTransport.recoverQuestion")(function* (partID: string) {
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
if (recovering.has(partID)) {
return
}
@@ -513,7 +516,7 @@ function createLayer(input: StreamInput) {
recovering.add(partID)
try {
while (!closed && !abort.signal.aborted && !input.footer.isClosed) {
if (state.data.questions.length > 0 || !state.data.tools.has(partID)) {
if (state.data.questions.some(matches) || !state.data.tools.has(partID)) {
return
}
@@ -521,11 +524,14 @@ function createLayer(input: StreamInput) {
Effect.map((item) => (item.data ?? []).filter((request) => request.sessionID === input.sessionID)),
Effect.orElseSucceed(() => []),
)
if (state.data.questions.length > 0 || !state.data.tools.has(partID)) {
if (state.data.questions.some(matches) || !state.data.tools.has(partID)) {
return
}
if (questions.length > 0) {
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))
bootstrapSessionData({
data: state.data,
messages: [],
@@ -535,9 +541,11 @@ 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: questions.map((request) => request.id),
requests: matching.map((request) => request.id),
})
syncFooter([])
return
@@ -783,11 +791,10 @@ 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.state.status === "running" &&
state.data.questions.length === 0
(event.properties.part.tool === "question" || event.properties.part.tool === "plan_exit") &&
event.properties.part.state.status === "running"
) {
yield* recoverQuestion(event.properties.part.id).pipe(
yield* recoverQuestion(event.properties.part).pipe(
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
)
@@ -34,6 +34,14 @@ 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: () => {
@@ -304,23 +312,32 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "message.part.updated": {
const parts = store.part[event.properties.part.messageID]
const part = event.properties.part
const parts = store.part[part.messageID]
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
break
setStore("part", part.messageID, [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
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)
}),
)
}
setStore(
"part",
event.properties.part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
)
const index = questionToolRequestIndex(store.question[part.sessionID], part)
if (index !== -1)
setStore(
"question",
part.sessionID,
produce((draft) => {
draft.splice(index, 1)
}),
)
break
}
@@ -773,7 +773,7 @@ function getSyntaxRules(theme: Theme) {
{
scope: ["extmark.paste"],
style: {
foreground: selectedForeground(theme, theme.warning),
foreground: theme.background,
background: theme.warning,
bold: true,
},
@@ -30,7 +30,6 @@ 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"
@@ -199,28 +198,26 @@ 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 maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const limited = createMemo(() => {
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
})
return (
<BlockTool
title="# Shell"
spinner={!props.message.time.completed}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
onClick={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={collapsed().overflow}>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -521,15 +518,14 @@ 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 maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const overflow = createMemo(() => lines().length > maxLines)
const limited = createMemo(() => {
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
if (expanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
})
return (
<Show
@@ -543,11 +539,11 @@ function GenericTool(props: ToolProps) {
<BlockTool
title={`# ${props.part.name} ${input(props.input)}`}
part={props.part}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={collapsed().overflow}>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -706,17 +702,15 @@ 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 maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const limited = createMemo(() => {
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
})
return (
<Switch>
@@ -725,12 +719,12 @@ function Bash(props: ToolProps) {
title={title()}
part={props.part}
spinner={props.part.state.status === "running"}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {command()}</text>
<text fg={theme.text}>{limited()}</text>
<Show when={collapsed().overflow}>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -84,7 +84,6 @@ 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"
@@ -1697,12 +1696,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 maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const overflow = createMemo(() => lines().length > maxLines)
const limited = createMemo(() => {
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
if (expanded() || !overflow()) return output()
return [...lines().slice(0, maxLines), "…"].join("\n")
})
return (
@@ -1717,11 +1716,11 @@ function GenericTool(props: ToolProps<any>) {
<BlockTool
title={`# ${props.tool} ${input(props.input)}`}
part={props.part}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={collapsed().overflow}>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -1872,16 +1871,14 @@ 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 maxLines = 10
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
const lines = createMemo(() => output().split("\n"))
const overflow = createMemo(() => lines().length > 10)
const limited = createMemo(() => {
if (expanded() || !collapsed().overflow) return output()
return collapsed().output
if (expanded() || !overflow()) return output()
return [...lines().slice(0, 10), "…"].join("\n")
})
const workdirDisplay = createMemo(() => {
@@ -1905,14 +1902,14 @@ function Shell(props: ToolProps<typeof ShellTool>) {
title={title()}
part={props.part}
spinner={isRunning()}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
onClick={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={collapsed().overflow}>
<Show when={overflow()}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
@@ -1,13 +0,0 @@
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 }
}
+33 -63
View File
@@ -85,10 +85,6 @@ 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 }) {
@@ -126,7 +122,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
"-z",
],
{
cwd: state.worktree,
cwd: state.directory,
stdin: feed(files),
},
)
@@ -142,8 +138,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.worktree,
stdin: feedSpec(files),
cwd: state.directory,
stdin: feed(files),
},
)
})
@@ -153,8 +149,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.worktree,
stdin: feedSpec(files),
cwd: state.directory,
stdin: feed(files),
},
)
if (result.code === 0) return
@@ -201,11 +197,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", "--", spec])], {
cwd: state.worktree,
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
cwd: state.directory,
}),
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", spec])], {
cwd: state.worktree,
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
cwd: state.directory,
}),
],
{ concurrency: 2 },
@@ -243,7 +239,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
(yield* Effect.all(
allow.map((item) =>
fs
.stat(path.join(state.worktree, item))
.stat(path.join(state.directory, item))
.pipe(Effect.catch(() => Effect.void))
.pipe(
Effect.map((stat) => {
@@ -310,9 +306,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, "--", spec])],
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
{
cwd: state.worktree,
cwd: state.directory,
},
)
if (result.code !== 0) {
@@ -342,47 +338,24 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
return yield* locked(
Effect.gen(function* () {
log.info("restore", { commit: 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", {
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", {
snapshot,
exitCode: listed.code,
stderr: listed.stderr,
exitCode: checkout.code,
stderr: checkout.stderr,
})
return
}
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)))
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
})
}),
)
})
@@ -506,12 +479,9 @@ 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, "--", spec])],
{
cwd: state.worktree,
},
)
const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
cwd: state.worktree,
})
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
@@ -667,8 +637,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, "--", spec])],
{ cwd: state.worktree },
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
{ cwd: state.directory },
)
for (const line of statuses.text.trim().split("\n")) {
@@ -679,9 +649,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, "--", spec])],
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
{
cwd: state.worktree,
cwd: state.directory,
},
)
@@ -835,12 +835,17 @@ 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 ? [request] : [])
return ok(questionCalls === 1 ? [other] : [request])
},
promptAsync: async () => {
queueMicrotask(() => {
@@ -885,7 +890,9 @@ 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 : undefined
return item?.type === "stream.view" && item.view.type === "question" && item.view.request.id === request.id
? item.view
: undefined
})
expect(view).toEqual({
@@ -901,6 +908,7 @@ describe("run stream transport", () => {
},
})
const count = ui.events.length
src.push(
toolUpdated(
completedTool({
@@ -930,6 +938,121 @@ 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 {
@@ -0,0 +1,39 @@
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,99 +476,6 @@ 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-shrink: 0;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
+9 -36
View File
@@ -58,30 +58,6 @@ 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
@@ -1088,10 +1064,9 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const handleCopy = async () => {
const content = text()
if (!content) return
if (await writeClipboard(content)) {
setState("copied", true)
setTimeout(() => setState("copied", false), 2000)
}
await navigator.clipboard.writeText(content)
setState("copied", true)
setTimeout(() => setState("copied", false), 2000)
}
const revert = () => {
@@ -1515,10 +1490,9 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const handleCopy = async () => {
const content = text()
if (!content) return
if (await writeClipboard(content)) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
@@ -1860,10 +1834,9 @@ ToolRegistry.register({
const handleCopy = async () => {
const content = text()
if (!content) return
if (await writeClipboard(content)) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (