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
16 changes: 8 additions & 8 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "../../plugin"
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
import { SessionRetry } from "@/session/retry"
import { sessionChildren, sessionPermissionRequest, sessionQuestionRequest } from "./request-tree"

addDefaultParsers(parsers.parsers)

Expand Down Expand Up @@ -125,20 +126,19 @@ export function Session() {
const { theme } = useTheme()
const promptRef = usePromptRef()
const session = createMemo(() => sync.session.get(route.sessionID))
const children = createMemo(() => {
const parentID = session()?.parentID ?? session()?.id
return sync.data.session
.filter((x) => x.parentID === parentID || x.id === parentID)
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
})
const children = createMemo(() => sessionChildren(sync.data.session, session()))
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const permissions = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
const request = sessionPermissionRequest(sync.data.session, sync.data.permission, session())
if (!request) return []
return [request]
})
const questions = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.question[x.id] ?? [])
const request = sessionQuestionRequest(sync.data.session, sync.data.question, session())
if (!request) return []
return [request]
})
const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0)
const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)
Expand Down
62 changes: 62 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/request-tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2"

export function sessionChildren(list: Session[], current?: Session) {
const id = current?.parentID ?? current?.id
if (!id) return []
return list
.filter((item) => item.parentID === id || item.id === id)
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
}

function sessionTreeRequest<T>(
sessions: Session[],
requests: Record<string, T[] | undefined>,
current?: Session,
include: (item: T) => boolean = () => true,
) {
const id = current?.parentID ?? current?.id
if (!id) return

const map = sessions.reduce((acc, item) => {
if (!item.parentID) return acc
const list = acc.get(item.parentID)
if (list) list.push(item.id)
if (!list) acc.set(item.parentID, [item.id])
return acc
}, new Map<string, string[]>())

// Breadth-first traversal so root requests are preferred over descendants.
const seen = new Set([id])
const ids = [id]
for (const item of ids) {
const list = map.get(item)
if (!list) continue
for (const child of list) {
if (seen.has(child)) continue
seen.add(child)
ids.push(child)
}
}

const hit = ids.find((item) => requests[item]?.some(include))
if (!hit) return
return requests[hit]?.find(include)
}

export function sessionPermissionRequest(
session: Session[],
request: Record<string, PermissionRequest[] | undefined>,
current?: Session,
include?: (item: PermissionRequest) => boolean,
) {
return sessionTreeRequest(session, request, current, include)
}

export function sessionQuestionRequest(
session: Session[],
request: Record<string, QuestionRequest[] | undefined>,
current?: Session,
include?: (item: QuestionRequest) => boolean,
) {
return sessionTreeRequest(session, request, current, include)
}
36 changes: 36 additions & 0 deletions packages/opencode/test/cli/tui/session-request-tree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2"
import { sessionPermissionRequest, sessionQuestionRequest } from "../../../src/cli/cmd/tui/routes/session/request-tree"

const session = (id: string, parentID?: string) => ({
id,
parentID,
}) as Session

const permission = (id: string, sessionID: string) => ({
id,
sessionID,
}) as PermissionRequest

const question = (id: string, sessionID: string) => ({
id,
sessionID,
}) as QuestionRequest

describe("session request tree", () => {
test("finds nested grandchild permission request from root session", () => {
const root = session("root")
const all = [root, session("child", "root"), session("grand", "child")]
const result = sessionPermissionRequest(all, { grand: [permission("perm-grand", "grand")] }, root)

expect(result?.id).toBe("perm-grand")
})

test("finds nested grandchild question request from root session", () => {
const root = session("root")
const all = [root, session("child", "root"), session("grand", "child")]
const result = sessionQuestionRequest(all, { grand: [question("q-grand", "grand")] }, root)

expect(result?.id).toBe("q-grand")
})
})
155 changes: 153 additions & 2 deletions packages/opencode/test/cli/tui/sync-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
import { afterEach, describe, expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args"
import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit"
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync"
import { sessionPermissionRequest, sessionQuestionRequest } from "../../../src/cli/cmd/tui/routes/session/request-tree"

const sighup = new Set(process.listeners("SIGHUP"))

Expand Down Expand Up @@ -87,6 +89,94 @@ type Hit = {
workspace?: string
}

function event(payload: Event, input: { directory: string; workspace?: string }): GlobalEvent {
return {
directory: input.directory,
workspace: input.workspace,
payload,
}
}

function createSource() {
let fn: ((event: GlobalEvent) => void) | undefined

return {
source: {
subscribe: async (handler: (event: GlobalEvent) => void) => {
fn = handler
return () => {
if (fn === handler) fn = undefined
}
},
},
emit(evt: GlobalEvent) {
if (!fn) throw new Error("event source not ready")
fn(evt)
},
}
}

function ses(id: string, updated: number, parentID?: string) {
return event(
{
type: "session.updated",
properties: {
info: {
id,
parentID,
title: id,
time: { updated },
},
},
} as Event,
{ directory: "/tmp/root" },
)
}

function perm(id: string, sessionID: string) {
return event(
{
type: "permission.asked",
properties: {
id,
sessionID,
permission: "bash",
patterns: ["*"],
metadata: {},
always: [],
},
} as Event,
{ directory: "/tmp/root" },
)
}

function ask(id: string, sessionID: string) {
return event(
{
type: "question.asked",
properties: {
id,
sessionID,
questions: [
{
question: "Pick one",
header: "pick",
options: [{ label: "one", description: "first" }],
multiple: false,
},
],
},
} as Event,
{ directory: "/tmp/root" },
)
}

function tree(source: ReturnType<typeof createSource>) {
source.emit(ses("root", 1))
source.emit(ses("child", 2, "root"))
source.emit(ses("grand", 3, "child"))
}

function createFetch(log: Hit[]) {
return Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
Expand Down Expand Up @@ -173,7 +263,7 @@ function createFetch(log: Hit[]) {
) satisfies typeof fetch
}

async function mount(log: Hit[]) {
async function mount(log: Hit[], events?: { subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void> }) {
let project!: ReturnType<typeof useProject>
let sync!: ReturnType<typeof useSync>
let done!: () => void
Expand All @@ -186,7 +276,7 @@ async function mount(log: Hit[]) {
url="http://test"
directory="/tmp/root"
fetch={createFetch(log)}
events={{ subscribe: async () => () => {} }}
events={events ?? { subscribe: async () => () => {} }}
>
<ArgsProvider continue={false}>
<ExitProvider>
Expand Down Expand Up @@ -289,4 +379,65 @@ describe("SyncProvider", () => {
app.renderer.destroy()
}
})

test("surfaces nested grandchild permission request from sync events", async () => {
const log: Hit[] = []
const source = createSource()
const { app, sync } = await mount(log, source.source)

try {
await waitBoot(log)

tree(source)
source.emit(perm("perm-grand", "grand"))

await wait(() => (sync.data.permission.grand?.length ?? 0) === 1)

const req = sessionPermissionRequest(sync.data.session, sync.data.permission, sync.session.get("root"))
expect(req?.id).toBe("perm-grand")
} finally {
app.renderer.destroy()
}
})

test("surfaces nested grandchild question request from sync events", async () => {
const log: Hit[] = []
const source = createSource()
const { app, sync } = await mount(log, source.source)

try {
await waitBoot(log)

tree(source)
source.emit(ask("q-grand", "grand"))

await wait(() => (sync.data.question.grand?.length ?? 0) === 1)

const req = sessionQuestionRequest(sync.data.session, sync.data.question, sync.session.get("root"))
expect(req?.id).toBe("q-grand")
} finally {
app.renderer.destroy()
}
})

test("prefers root permission request over descendant", async () => {
const log: Hit[] = []
const source = createSource()
const { app, sync } = await mount(log, source.source)

try {
await waitBoot(log)

tree(source)
source.emit(perm("perm-root", "root"))
source.emit(perm("perm-grand", "grand"))

await wait(() => (sync.data.permission.root?.length ?? 0) === 1 && (sync.data.permission.grand?.length ?? 0) === 1)

const req = sessionPermissionRequest(sync.data.session, sync.data.permission, sync.session.get("root"))
expect(req?.id).toBe("perm-root")
} finally {
app.renderer.destroy()
}
})
})
Loading