mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 01:00:54 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53f7a1c63c | |||
| 611e48c4ac | |||
| 836a33198e | |||
| 7afd477d1a | |||
| fe143df151 | |||
| e94aecaa08 | |||
| 4a608b2026 |
@@ -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 }
|
||||
}
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user