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
64 changes: 55 additions & 9 deletions packages/opencode/src/acp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
import { Log } from "../util/log"
import { pathToFileURL } from "bun"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import type { ACPConfig, ACPSessionState } from "./types"
import { Provider } from "../provider/provider"
import { Agent as AgentModule } from "../agent/agent"
import { Installation } from "@/installation"
Expand All @@ -42,6 +42,7 @@ import { z } from "zod"
import { LoadAPIKeyError } from "ai"
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import type { Session } from "@opencode-ai/sdk"

type ModeOption = { id: string; name: string; description?: string }
type ModelOption = { modelId: string; name: string }
Expand Down Expand Up @@ -175,21 +176,66 @@ export namespace ACP {
}
}

/**
* Resolve session for an event, checking parent sessions if the session is a child (e.g., from task tool).
* Recursively walks up the parent chain to handle nested tasks.
* Returns the ACP session state and the actual event sessionID.
*/
private async getParentACPSessionForEvent(eventSessionID: string): Promise<{
session: ACPSessionState
eventSessionID: string
} | undefined> {

let currentSessionID: string | undefined = eventSessionID;
const visited = new Set<string>();

// Traverse up the agent call stack until we hit one that is registered as an ACP agent
while(currentSessionID && !visited.has(currentSessionID)) {
visited.add(currentSessionID);

const session = this.sessionManager.tryGet(currentSessionID)
if(session){
return { session, eventSessionID }
}

const sessionInfo: Session | undefined = await this.sdk.session.get(
{
sessionID: currentSessionID,
directory: ".",
},
{ throwOnError: true },
)
.then((x) => x.data)
.catch(() => undefined)

if(!sessionInfo){
break
}

currentSessionID = sessionInfo.parentID

}

return undefined
}

private async handleEvent(event: Event) {
switch (event.type) {
case "permission.asked": {
const permission = event.properties
const session = this.sessionManager.tryGet(permission.sessionID)
if (!session) return
// Get parent session, in case this was passed from a subagent tool call
const resolved = await this.getParentACPSessionForEvent(permission.sessionID)
if (!resolved) return
const { session, eventSessionID } = resolved

const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve()
const prev = this.permissionQueues.get(eventSessionID) ?? Promise.resolve()
const next = prev
.then(async () => {
const directory = session.cwd

const res = await this.connection
.requestPermission({
sessionId: permission.sessionID,
sessionId: session.id,
toolCall: {
toolCallId: permission.tool?.callID ?? permission.id,
status: "pending",
Expand All @@ -204,7 +250,7 @@ export namespace ACP {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
sessionID: eventSessionID,
})
await this.sdk.permission.reply({
requestID: permission.id,
Expand Down Expand Up @@ -251,11 +297,11 @@ export namespace ACP {
log.error("failed to handle permission", { error, permissionID: permission.id })
})
.finally(() => {
if (this.permissionQueues.get(permission.sessionID) === next) {
this.permissionQueues.delete(permission.sessionID)
if (this.permissionQueues.get(eventSessionID) === next) {
this.permissionQueues.delete(eventSessionID)
}
})
this.permissionQueues.set(permission.sessionID, next)
this.permissionQueues.set(eventSessionID, next)
return
}

Expand Down
170 changes: 88 additions & 82 deletions packages/ui/src/components/code.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs"
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Portal } from "solid-js/web"
import { createDefaultOptions, styleVariables } from "../pierre"
import { getWorkerPool } from "../pierre/worker"
import { Icon } from "./icon"
Expand Down Expand Up @@ -125,11 +126,9 @@ export function Code<T>(props: CodeProps<T>) {
let wrapper!: HTMLDivElement
let container!: HTMLDivElement
let findInput: HTMLInputElement | undefined
let findBar: HTMLDivElement | undefined
let findOverlay!: HTMLDivElement
let findOverlayFrame: number | undefined
let findOverlayScroll: HTMLElement[] = []
let findScroll: HTMLElement | undefined
let observer: MutationObserver | undefined
let renderToken = 0
let selectionFrame: number | undefined
Expand Down Expand Up @@ -159,6 +158,8 @@ export function Code<T>(props: CodeProps<T>) {
let findMode: "highlights" | "overlay" = "overlay"
let findHits: Range[] = []

const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 })

const file = createMemo(
() =>
new File<T>(
Expand Down Expand Up @@ -291,23 +292,26 @@ export function Code<T>(props: CodeProps<T>) {
setFindIndex(0)
}

const getScrollParent = (el: HTMLElement): HTMLElement | null => {
const getScrollParent = (el: HTMLElement): HTMLElement | undefined => {
let parent = el.parentElement
while (parent) {
const style = getComputedStyle(parent)
if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
parent = parent.parentElement
}
return null
}

const positionFindBar = () => {
if (!findBar || !wrapper) return
const scrollTop = findScroll ? findScroll.scrollTop : window.scrollY
findBar.style.position = "absolute"
findBar.style.top = `${scrollTop + 8}px`
findBar.style.right = "8px"
findBar.style.left = ""
if (typeof window === "undefined") return

const root = getScrollParent(wrapper) ?? wrapper
const rect = root.getBoundingClientRect()
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
const header = Number.isNaN(title) ? 0 : title
setFindPos({
top: Math.round(rect.top) + header - 4,
right: Math.round(window.innerWidth - rect.right) + 8,
})
}

const scanFind = (root: ShadowRoot, query: string) => {
Expand Down Expand Up @@ -426,7 +430,6 @@ export function Code<T>(props: CodeProps<T>) {
}
if (opts?.scroll && active) {
scrollToRange(active)
positionFindBar()
}
return
}
Expand All @@ -435,7 +438,6 @@ export function Code<T>(props: CodeProps<T>) {
syncOverlayScroll()
if (opts?.scroll && active) {
scrollToRange(active)
positionFindBar()
}
scheduleOverlay()
}
Expand Down Expand Up @@ -464,14 +466,12 @@ export function Code<T>(props: CodeProps<T>) {
return
}
scrollToRange(active)
positionFindBar()
return
}

clearHighlightFind()
syncOverlayScroll()
scrollToRange(active)
positionFindBar()
scheduleOverlay()
}

Expand All @@ -484,11 +484,9 @@ export function Code<T>(props: CodeProps<T>) {
findCurrent = host
findTarget = host

findScroll = getScrollParent(wrapper) ?? undefined
if (!findOpen()) setFindOpen(true)
requestAnimationFrame(() => {
applyFind({ scroll: true })
positionFindBar()
findInput?.focus()
findInput?.select()
})
Expand All @@ -514,18 +512,18 @@ export function Code<T>(props: CodeProps<T>) {

createEffect(() => {
if (!findOpen()) return
findScroll = getScrollParent(wrapper) ?? undefined
const target = findScroll ?? window

const handler = () => positionFindBar()
target.addEventListener("scroll", handler, { passive: true })
window.addEventListener("resize", handler, { passive: true })
handler()
const update = () => positionFindBar()
requestAnimationFrame(update)
window.addEventListener("resize", update, { passive: true })

const root = getScrollParent(wrapper) ?? wrapper
const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update())
observer?.observe(root)

onCleanup(() => {
target.removeEventListener("scroll", handler)
window.removeEventListener("resize", handler)
findScroll = undefined
window.removeEventListener("resize", update)
observer?.disconnect()
})
})

Expand Down Expand Up @@ -916,6 +914,64 @@ export function Code<T>(props: CodeProps<T>) {
pendingSelectionEnd = false
})

const FindBar = (barProps: { class: string; style?: ComponentProps<"div">["style"] }) => (
<div class={barProps.class} style={barProps.style} onPointerDown={(e) => e.stopPropagation()}>
<Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
<input
ref={findInput}
placeholder="Find"
value={findQuery()}
class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
onInput={(e) => {
setFindQuery(e.currentTarget.value)
setFindIndex(0)
applyFind({ reset: true, scroll: true })
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
closeFind()
return
}
if (e.key !== "Enter") return
e.preventDefault()
stepFind(e.shiftKey ? -1 : 1)
}}
/>
<div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
{findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
</div>
<div class="flex items-center">
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={findCount() === 0}
aria-label="Previous match"
onClick={() => stepFind(-1)}
>
<Icon name="chevron-down" size="small" class="rotate-180" />
</button>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={findCount() === 0}
aria-label="Next match"
onClick={() => stepFind(1)}
>
<Icon name="chevron-down" size="small" />
</button>
</div>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
aria-label="Close search"
onClick={closeFind}
>
<Icon name="close-small" size="small" />
</button>
</div>
)

return (
<div
data-component="code"
Expand All @@ -936,65 +992,15 @@ export function Code<T>(props: CodeProps<T>) {
}}
>
<Show when={findOpen()}>
<div
ref={findBar}
class="z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
onPointerDown={(e) => e.stopPropagation()}
>
<Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
<input
ref={findInput}
placeholder="Find"
value={findQuery()}
class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
onInput={(e) => {
setFindQuery(e.currentTarget.value)
setFindIndex(0)
applyFind({ reset: true, scroll: true })
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
closeFind()
return
}
if (e.key !== "Enter") return
e.preventDefault()
stepFind(e.shiftKey ? -1 : 1)
<Portal>
<FindBar
class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
style={{
top: `${findPos().top}px`,
right: `${findPos().right}px`,
}}
/>
<div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
{findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
</div>
<div class="flex items-center">
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={findCount() === 0}
aria-label="Previous match"
onClick={() => stepFind(-1)}
>
<Icon name="chevron-down" size="small" class="rotate-180" />
</button>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={findCount() === 0}
aria-label="Next match"
onClick={() => stepFind(1)}
>
<Icon name="chevron-down" size="small" />
</button>
</div>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
aria-label="Close search"
onClick={closeFind}
>
<Icon name="close-small" size="small" />
</button>
</div>
</Portal>
</Show>
<div ref={container} />
<div ref={findOverlay} class="pointer-events-none absolute inset-0 z-0" />
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/ar/agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ permission:
webfetch: deny
---

حلل الشفرة فقط واقترح التغييرات.
Only analyze code and suggest changes.
```

يمكنك ضبط الأذونات لأوامر bash محددة.
Expand Down
Loading
Loading