Skip to content
Merged
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
8 changes: 6 additions & 2 deletions packages/opencode/src/cli/cmd/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ type TargetInput = {
continue?: boolean
sessionID?: string
fork?: boolean
pick?: (items: { id: string; title?: string; parentID?: string }[]) => Promise<string | undefined>
}

type Target = {
baseID?: string
title?: string
picked?: boolean
}

function suffix(dir?: string) {
Expand Down Expand Up @@ -65,8 +67,10 @@ export async function resolveRemoteTarget(input: TargetInput): Promise<Target> {
const result = await input.sdk.session.list({ roots: true }, { throwOnError: true }).catch((error) => {
throw new Error(`Failed to resolve remote continue target${suffix(input.directory)}: ${message(error)}`)
})
const item = result.data?.find((item) => !item.parentID)
const items = (result.data ?? []).filter((item) => !item.parentID)
const picked = items.length > 1 && input.pick ? await input.pick(items) : items[0]?.id
const item = items.find((item) => item.id === picked)
const baseID = item?.id
if (!baseID) throw new Error(`No remote session found to continue${suffix(input.directory)}`)
return { baseID, title: item?.title }
return { baseID, title: item?.title, picked: items.length > 1 && !!input.pick }
}
28 changes: 26 additions & 2 deletions packages/opencode/src/cli/cmd/tui/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
import { existsSync } from "fs"
import { preflightRemote, resolveRemoteTarget } from "../remote"
import { createInterface } from "readline/promises"

async function pick(items: { id: string; title?: string }[]) {
if (items.length < 2) return items[0]?.id
UI.println(UI.Style.TEXT_INFO_BOLD + "Select remote session" + UI.Style.TEXT_NORMAL)
items.forEach((item, i) => {
UI.println(` ${i + 1}. ${item.title ?? item.id} ${UI.Style.TEXT_DIM}(${item.id})${UI.Style.TEXT_NORMAL}`)
})
const rl = createInterface({
input: process.stdin,
output: process.stdout,
})
try {
while (true) {
const txt = (await rl.question("Enter session number: ")).trim()
const n = Number(txt)
if (Number.isInteger(n) && n >= 1 && n <= items.length) return items[n - 1]?.id
UI.error(`Choose a number between 1 and ${items.length}`)
}
} finally {
rl.close()
}
}

export const AttachCommand = cmd({
command: "attach <url>",
Expand Down Expand Up @@ -81,6 +104,7 @@ export const AttachCommand = cmd({
continue: args.continue,
sessionID: args.session,
fork: args.fork,
pick: args.continue && !args.session ? pick : undefined,
}).catch((error) => {
UI.error(error instanceof Error ? error.message : String(error))
process.exit(1)
Expand All @@ -100,8 +124,8 @@ export const AttachCommand = cmd({
url: args.url,
config,
args: {
continue: args.continue,
sessionID: args.session,
continue: target.picked ? false : args.continue,
sessionID: target.picked ? target.baseID : args.session,
fork: args.fork,
},
directory,
Expand Down
34 changes: 34 additions & 0 deletions packages/opencode/test/cli/remote-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as SDK from "@opencode-ai/sdk/v2"
import * as App from "../../src/cli/cmd/tui/app"
import { AttachCommand } from "../../src/cli/cmd/tui/attach"
import { RunCommand } from "../../src/cli/cmd/run"
import { resolveRemoteTarget } from "../../src/cli/cmd/remote"
import * as Win32 from "../../src/cli/cmd/tui/win32"
import { TuiConfig } from "../../src/config/tui"
import { Instance } from "../../src/project/instance"
Expand Down Expand Up @@ -249,6 +250,39 @@ describe("remote preflight", () => {
expect(tui).toHaveBeenCalledTimes(1)
})

test("resolveRemoteTarget lets attach choose among multiple remote root sessions", async () => {
const list = mock(async () => ({
data: [
{
id: "sess_123",
title: "Remote draft",
parentID: undefined,
},
{
id: "sess_456",
title: "Remote fix",
parentID: undefined,
},
],
}))

const result = await resolveRemoteTarget({
sdk: client({
session: { list },
}),
directory: "/srv/app",
continue: true,
pick: async (items) => items[1]?.id,
})

expect(list).toHaveBeenCalledWith({ roots: true }, { throwOnError: true })
expect(result).toEqual({
baseID: "sess_456",
picked: true,
title: "Remote fix",
})
})

test("run --attach fails before creating a session when the remote is unreachable", async () => {
stopExit()
const err = spyOn(UI, "error").mockImplementation(() => {})
Expand Down