diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 6fa60339d848..72dc4c6fe813 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -31,7 +31,11 @@ import { DialogModel, useConnected } from "@tui/component/dialog-model" import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogStatus } from "@tui/component/dialog-status" import { DialogThemeList } from "@tui/component/dialog-theme-list" -import { DialogRemoteSessionList, selectRemoteSession } from "@tui/component/dialog-remote-session-list" +import { + DialogRemoteSessionList, + openRemoteSessionList, + selectRemoteSession, +} from "@tui/component/dialog-remote-session-list" import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" @@ -126,6 +130,21 @@ import { DialogVariant } from "./component/dialog-variant" export { selectRemoteSession } +export function getRemoteSessionCommand(input: { remote?: boolean; onSelect: () => void | Promise }) { + if (!input.remote) return + return { + title: "Browse remote sessions", + value: "remote.session.list", + category: "Session", + slash: { + name: "remote", + }, + onSelect: () => { + void input.onSelect() + }, + } +} + function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { return { externalOutputMode: "passthrough", @@ -368,6 +387,16 @@ function App(props: { onSnapshot?: () => Promise }) { }) const args = useArgs() + const remote = getRemoteSessionCommand({ + remote: args.remote, + onSelect: async () => { + await openRemoteSessionList({ + dialog, + sdk, + toast, + }) + }, + }) onMount(() => { batch(() => { if (args.agent) local.agent.set(args.agent) @@ -462,6 +491,7 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.replace(() => ) }, }, + ...(remote ? [remote] : []), ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES ? [ { diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 2483fc0578c3..8a15b9a2d0d1 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -101,6 +101,7 @@ export const AttachCommand = cmd({ url: args.url, config, args: { + remote: true, continue: target.remoteSessions || target.picked ? false : args.continue, sessionID: target.picked ? target.baseID : args.session, fork: args.fork, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-remote-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-remote-session-list.tsx index bd11b6cda2ae..2e612fec8236 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-remote-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-remote-session-list.tsx @@ -10,6 +10,48 @@ type Session = { title?: string } +type Listed = Session & { + parentID?: string +} + +export async function listRemoteSessions(input: { + sdk: { + client: { + session: { + list(input: { roots: true }): Promise<{ data?: Listed[] }> + } + } + } +}) { + const result = await input.sdk.client.session.list({ roots: true }) + return (result.data ?? []).filter((item) => !item.parentID).map((item) => ({ id: item.id, title: item.title })) +} + +export async function openRemoteSessionList(input: { + dialog: Pick + sdk: { + client: { + session: { + list(input: { roots: true }): Promise<{ data?: Listed[] }> + } + } + } + toast: { + show(input: { message: string; variant?: "error" | "warning" | "info" | "success" }): void + } + fork?: boolean +}) { + const sessions = await listRemoteSessions({ sdk: input.sdk }) + if (!sessions.length) { + input.toast.show({ + message: "No remote sessions found", + variant: "info", + }) + return + } + input.dialog.replace(() => ) +} + export function getRemoteBrowse(input: { root: Session; sessions: Session[]; fork?: boolean }) { return { title: input.fork ? "Fork from remote session" : "Continue remote session", diff --git a/packages/opencode/src/cli/cmd/tui/context/args.tsx b/packages/opencode/src/cli/cmd/tui/context/args.tsx index 4ab18c7a9af3..d8852e53c8be 100644 --- a/packages/opencode/src/cli/cmd/tui/context/args.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/args.tsx @@ -4,6 +4,7 @@ export interface Args { model?: string agent?: string prompt?: string + remote?: boolean continue?: boolean sessionID?: string fork?: boolean diff --git a/packages/opencode/test/cli/tui/attach-startup.test.ts b/packages/opencode/test/cli/tui/attach-startup.test.ts index 39bab741e519..408feaef0b6a 100644 --- a/packages/opencode/test/cli/tui/attach-startup.test.ts +++ b/packages/opencode/test/cli/tui/attach-startup.test.ts @@ -5,6 +5,129 @@ afterEach(() => { }) describe("attach startup", () => { + test("remote browser command exists for remote tui", async () => { + const mod: Record = await import("../../../src/cli/cmd/tui/app") + const fn = mod["getRemoteSessionCommand"] + expect(fn).toBeTypeOf("function") + + if (typeof fn !== "function") return + const result = fn({ + remote: true, + onSelect: mock(async () => {}), + }) + + expect(result).toEqual( + expect.objectContaining({ + title: "Browse remote sessions", + value: "remote.session.list", + category: "Session", + slash: { + name: "remote", + }, + }), + ) + }) + + test("remote browser fetches root remote sessions", async () => { + const mod: Record = await import("../../../src/cli/cmd/tui/component/dialog-remote-session-list") + const fn = mod["listRemoteSessions"] + expect(fn).toBeTypeOf("function") + + const list = mock(async () => ({ + data: [ + { + id: "sess_root", + title: "Root draft", + }, + { + id: "sess_child", + title: "Child fix", + parentID: "sess_root", + }, + { + id: "sess_next", + title: "Next root", + }, + ], + })) + + if (typeof fn !== "function") return + const result = await fn({ + sdk: { + client: { + session: { + list, + }, + }, + }, + }) + + expect(list).toHaveBeenCalledWith({ + roots: true, + }) + expect(result).toEqual([ + { + id: "sess_root", + title: "Root draft", + }, + { + id: "sess_next", + title: "Next root", + }, + ]) + }) + + test("remote browser selection reuses the existing child browse flow", async () => { + const mod: Record = await import("../../../src/cli/cmd/tui/component/dialog-remote-session-list") + const fn = mod["selectRemoteSession"] + expect(fn).toBeTypeOf("function") + + const route = { + navigate: mock(() => {}), + } + const dialog = { + clear: mock(() => {}), + replace: mock(() => {}), + } + const sdk = { + client: { + session: { + children: mock(async () => ({ + data: [ + { + id: "sess_child", + title: "Child fix", + }, + ], + })), + fork: mock(async () => ({ + data: { + id: "sess_forked", + }, + })), + }, + }, + } + const toast = { + show: mock(() => {}), + } + + if (typeof fn !== "function") return + await fn({ + id: "sess_root", + title: "Root draft", + route, + dialog, + sdk, + toast, + }) + + expect(dialog.replace).toHaveBeenCalledTimes(1) + expect(dialog.clear).not.toHaveBeenCalled() + expect(route.navigate).not.toHaveBeenCalled() + expect(sdk.client.session.fork).not.toHaveBeenCalled() + }) + test("fork browse copy makes the fork target explicit", async () => { const mod: Record = await import("../../../src/cli/cmd/tui/component/dialog-remote-session-list") const fn = mod["getRemoteBrowse"]