Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions packages/app/e2e/commands/panels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,11 @@ 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")
await expect(page.locator("#review-panel")).toBeVisible()

await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("#review-panel")).toHaveCount(0)
})
34 changes: 31 additions & 3 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1828,6 +1828,16 @@ function Write(props: ToolProps<typeof WriteTool>) {
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 (
<Switch>
Expand All @@ -1846,7 +1856,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
<InlineTool icon="←" pending={pending()} complete={props.input.filePath} part={props.part}>
Write {normalizePath(props.input.filePath!)}
</InlineTool>
</Match>
Expand Down Expand Up @@ -2033,6 +2043,12 @@ function Edit(props: ToolProps<typeof EditTool>) {

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 (
Expand Down Expand Up @@ -2064,7 +2080,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
<InlineTool icon="←" pending={pending()} complete={props.input.filePath} part={props.part}>
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
</InlineTool>
</Match>
Expand Down Expand Up @@ -2117,6 +2133,12 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
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 (
<Switch>
<Match when={files().length > 0}>
Expand All @@ -2139,7 +2161,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
</For>
</Match>
<Match when={true}>
<InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
<InlineTool icon="%" pending={pending()} complete={false} part={props.part}>
Patch
</InlineTool>
</Match>
Expand Down Expand Up @@ -2233,6 +2255,12 @@ function Diagnostics(props: { diagnostics?: Record<string, Record<string, any>[]
)
}

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 ""

Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 42 additions & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export namespace SessionProcessor {
let blocked = false
let attempt = 0
let needsCompaction = false
const deltas: Record<string, { text: string; bytes: number; path: string | undefined; last: number }> = {}
const PATH_RE = /"filePath"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/
const THROTTLE_MS = 500
const THROTTLE_BYTES = 16384

const result = {
get message() {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -175,6 +215,7 @@ export namespace SessionProcessor {
})
}
}
delete deltas[value.toolCallId]
break
}
case "tool-result": {
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/tool/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ export type ToolStatePending = {
[key: string]: unknown
}
raw: string
received?: number
}

export type ToolStateRunning = {
Expand Down
Loading