diff --git a/packages/app/e2e/commands/panels.spec.ts b/packages/app/e2e/commands/panels.spec.ts index 58c1f0a9af3..e24f5f72f45 100644 --- a/packages/app/e2e/commands/panels.spec.ts +++ b/packages/app/e2e/commands/panels.spec.ts @@ -19,7 +19,6 @@ test("review panel can be toggled via keybind", async ({ page, gotoSession }) => await expect(reviewToggle).toBeVisible() if (await expanded(reviewToggle)) await reviewToggle.click() await expect(reviewToggle).toHaveAttribute("aria-expanded", "false") - await expect(page.locator("#review-panel")).toHaveCount(0) await page.keyboard.press(`${modKey}+Shift+R`) await expect(reviewToggle).toHaveAttribute("aria-expanded", "true") @@ -27,5 +26,4 @@ test("review panel can be toggled via keybind", async ({ page, gotoSession }) => await page.keyboard.press(`${modKey}+Shift+R`) await expect(reviewToggle).toHaveAttribute("aria-expanded", "false") - await expect(page.locator("#review-panel")).toHaveCount(0) }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d3a4ff81e01..081caaa67b4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1828,6 +1828,16 @@ function Write(props: ToolProps) { if (!props.input.content) return "" return props.input.content }) + const pending = createMemo(() => { + const state = props.part.state + if (state.status === "pending" && state.received) { + const bytes = formatKB(state.received) + if (state.input.filePath) return `Write ${normalizePath(state.input.filePath as string)} (receiving… ${bytes})` + return `Preparing write… (${bytes})` + } + return "Preparing write..." + }) + return ( @@ -1846,7 +1856,7 @@ function Write(props: ToolProps) { - + Write {normalizePath(props.input.filePath!)} @@ -2033,6 +2043,12 @@ function Edit(props: ToolProps) { const ft = createMemo(() => filetype(props.input.filePath)) + const pending = createMemo(() => { + const state = props.part.state + if (state.status === "pending" && state.received) return `Preparing edit… (${formatKB(state.received)})` + return "Preparing edit..." + }) + const diffContent = createMemo(() => props.metadata.diff) return ( @@ -2064,7 +2080,7 @@ function Edit(props: ToolProps) { - + Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })} @@ -2117,6 +2133,12 @@ function ApplyPatch(props: ToolProps) { return "← Patched " + file.relativePath } + const pending = createMemo(() => { + const state = props.part.state + if (state.status === "pending" && state.received) return `Preparing patch… (${formatKB(state.received)})` + return "Preparing patch..." + }) + return ( 0}> @@ -2139,7 +2161,7 @@ function ApplyPatch(props: ToolProps) { - + Patch @@ -2233,6 +2255,12 @@ function Diagnostics(props: { diagnostics?: Record[] ) } +function formatKB(bytes: number) { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1048576) return `${Math.round(bytes / 1024)}KB` + return `${(bytes / 1048576).toFixed(1)}MB` +} + function normalizePath(input?: string) { if (!input) return "" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 5b4e7bdbc04..dd671f746ce 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -268,6 +268,7 @@ export namespace MessageV2 { status: z.literal("pending"), input: z.record(z.string(), z.any()), raw: z.string(), + received: z.number().optional(), }) .meta({ ref: "ToolStatePending", diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 67edc0ecfe3..6f540788334 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -34,6 +34,10 @@ export namespace SessionProcessor { let blocked = false let attempt = 0 let needsCompaction = false + const deltas: Record = {} + const PATH_RE = /"filePath"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/ + const THROTTLE_MS = 500 + const THROTTLE_BYTES = 16384 const result = { get message() { @@ -125,10 +129,46 @@ export namespace SessionProcessor { toolcalls[value.id] = part as MessageV2.ToolPart break - case "tool-input-delta": + case "tool-input-delta": { + const match = toolcalls[value.id] + if (!match || match.state.status !== "pending") break + const acc = deltas[value.id] ?? (deltas[value.id] = { text: "", bytes: 0, path: undefined, last: 0 }) + if (!acc.path && acc.text.length < 8192) acc.text += value.delta + acc.bytes += Buffer.byteLength(value.delta, "utf8") + if (!acc.path) { + const m = PATH_RE.exec(acc.text) + if (m) { + try { acc.path = JSON.parse('"' + m[1] + '"') } catch { acc.path = m[1] } + acc.text = "" + } + } + const now = Date.now() + const found = acc.path && !match.state.input.filePath + const elapsed = now - acc.last >= THROTTLE_MS + const grown = acc.bytes - (match.state.received ?? 0) >= THROTTLE_BYTES + if (found || elapsed || grown) { + acc.last = now + try { + const updated = await Session.updatePart({ + ...match, + state: { + status: "pending", + input: acc.path ? { ...match.state.input, filePath: acc.path } : match.state.input, + raw: match.state.raw, + received: acc.bytes, + }, + }) + toolcalls[value.id] = updated as MessageV2.ToolPart + } catch (e: any) { + if (e?.code === "SQLITE_CONSTRAINT_FOREIGNKEY") break + throw e + } + } break + } case "tool-input-end": + delete deltas[value.id] break case "tool-call": { @@ -175,6 +215,7 @@ export namespace SessionProcessor { }) } } + delete deltas[value.toolCallId] break } case "tool-result": { diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 8c1e53ccaf3..e9c590ca915 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -19,8 +19,8 @@ const MAX_PROJECT_DIAGNOSTICS_FILES = 5 export const WriteTool = Tool.define("write", { description: DESCRIPTION, parameters: z.object({ - content: z.string().describe("The content to write to the file"), filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"), + content: z.string().describe("The content to write to the file"), }), async execute(params, ctx) { const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 71e075b3916..65465fec384 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -365,6 +365,7 @@ export type ToolStatePending = { [key: string]: unknown } raw: string + received?: number } export type ToolStateRunning = {