diff --git a/packages/opencode/src/cli/cmd/remote.ts b/packages/opencode/src/cli/cmd/remote.ts index b99e9b16a79a..2a3f921c62f3 100644 --- a/packages/opencode/src/cli/cmd/remote.ts +++ b/packages/opencode/src/cli/cmd/remote.ts @@ -17,6 +17,7 @@ type TargetInput = { type Target = { baseID?: string + title?: string } function suffix(dir?: string) { @@ -64,7 +65,8 @@ export async function resolveRemoteTarget(input: TargetInput): Promise { 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 baseID = result.data?.find((item) => !item.parentID)?.id + const item = result.data?.find((item) => !item.parentID) + const baseID = item?.id if (!baseID) throw new Error(`No remote session found to continue${suffix(input.directory)}`) - return { baseID } + return { baseID, title: item?.title } } diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 2e212acfdd6d..70e2214ba441 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -75,7 +75,7 @@ export const AttachCommand = cmd({ UI.error(error instanceof Error ? error.message : String(error)) process.exit(1) }) - await resolveRemoteTarget({ + const target = await resolveRemoteTarget({ sdk, directory, continue: args.continue, @@ -85,6 +85,13 @@ export const AttachCommand = cmd({ UI.error(error instanceof Error ? error.message : String(error)) process.exit(1) }) + if (args.continue && target.baseID) { + UI.println( + UI.Style.TEXT_INFO_BOLD + "Continuing remote session" + UI.Style.TEXT_NORMAL, + target.title ?? target.baseID, + UI.Style.TEXT_DIM + `(${target.baseID})` + UI.Style.TEXT_NORMAL, + ) + } const config = await Instance.provide({ directory: directory && existsSync(directory) ? directory : process.cwd(), fn: () => TuiConfig.get(), diff --git a/packages/opencode/test/cli/remote-preflight.test.ts b/packages/opencode/test/cli/remote-preflight.test.ts index b489d27a1984..b1493963cbff 100644 --- a/packages/opencode/test/cli/remote-preflight.test.ts +++ b/packages/opencode/test/cli/remote-preflight.test.ts @@ -66,7 +66,7 @@ describe("remote preflight", () => { expect(tui).toHaveBeenCalledTimes(1) }) - test("attach fails clearly when the remote directory does not match", () => { + test("attach fails clearly when the remote directory does not match", async () => { stopExit() mockAttach() const err = spyOn(UI, "error").mockImplementation(() => {}) @@ -87,24 +87,30 @@ describe("remote preflight", () => { }), ) - return expect( - AttachCommand.handler({ - _: [], - $0: "opencode", - url: "http://remote.test", - dir: "/srv/app", - continue: false, - session: undefined, - fork: false, - password: undefined, - }), - ).rejects.toBe(exit) + let thrown: unknown + try { + await Promise.resolve( + AttachCommand.handler({ + _: [], + $0: "opencode", + url: "http://remote.test", + dir: "/srv/app", + continue: false, + session: undefined, + fork: false, + password: undefined, + }), + ) + } catch (error) { + thrown = error + } + expect(thrown).toBe(exit) expect(err).toHaveBeenCalled() expect(tui).not.toHaveBeenCalled() }) - test("attach fails before starting tui when the remote session is missing", () => { + test("attach fails before starting tui when the remote session is missing", async () => { stopExit() mockAttach() const err = spyOn(UI, "error").mockImplementation(() => {}) @@ -129,19 +135,25 @@ describe("remote preflight", () => { }), ) - return expect( - AttachCommand.handler({ - _: [], - $0: "opencode", - url: "http://remote.test", - dir: "/srv/app", - continue: false, - session: "missing", - fork: false, - password: undefined, - }), - ).rejects.toBe(exit) + let thrown: unknown + try { + await Promise.resolve( + AttachCommand.handler({ + _: [], + $0: "opencode", + url: "http://remote.test", + dir: "/srv/app", + continue: false, + session: "missing", + fork: false, + password: undefined, + }), + ) + } catch (error) { + thrown = error + } + expect(thrown).toBe(exit) expect(get).toHaveBeenCalledWith({ sessionID: "missing" }, { throwOnError: true }) expect(err).toHaveBeenCalledWith(expect.stringContaining('Remote session "missing"')) expect(tui).not.toHaveBeenCalled() @@ -187,7 +199,57 @@ describe("remote preflight", () => { expect(tui).toHaveBeenCalledTimes(1) }) - test("run --attach fails before creating a session when the remote is unreachable", () => { + test("attach announces the remote continue target before starting tui", async () => { + mockAttach() + const info = spyOn(UI, "println").mockImplementation(() => {}) + const list = mock(async () => ({ + data: [ + { + id: "sess_123", + title: "Remote draft", + parentID: undefined, + }, + ], + })) + const tui = spyOn(App, "tui").mockResolvedValue() + spyOn(SDK, "createOpencodeClient").mockReturnValue( + client({ + path: { + get: mock(async () => ({ + data: { + home: "/home/me", + state: "/state", + config: "/config", + worktree: "/srv/app", + directory: "/srv/app", + }, + })), + }, + session: { list }, + }), + ) + + await AttachCommand.handler({ + _: [], + $0: "opencode", + url: "http://remote.test", + dir: "/srv/app", + continue: true, + session: undefined, + fork: false, + password: undefined, + }) + + expect(list).toHaveBeenCalledWith({ roots: true }, { throwOnError: true }) + expect(info).toHaveBeenCalledWith( + expect.stringContaining("Continuing remote session"), + expect.stringContaining("Remote draft"), + expect.stringContaining("sess_123"), + ) + expect(tui).toHaveBeenCalledTimes(1) + }) + + test("run --attach fails before creating a session when the remote is unreachable", async () => { stopExit() const err = spyOn(UI, "error").mockImplementation(() => {}) const create = mock(async () => { @@ -213,29 +275,35 @@ describe("remote preflight", () => { }) try { - return expect( - RunCommand.handler({ - _: [], - $0: "opencode", - message: ["hi"], - command: undefined, - continue: false, - session: undefined, - fork: false, - share: false, - model: undefined, - agent: undefined, - format: "default", - file: undefined, - title: undefined, - attach: "http://remote.test", - password: undefined, - dir: undefined, - port: undefined, - variant: undefined, - thinking: false, - }), - ).rejects.toBe(exit) + let thrown: unknown + try { + await Promise.resolve( + RunCommand.handler({ + _: [], + $0: "opencode", + message: ["hi"], + command: undefined, + continue: false, + session: undefined, + fork: false, + share: false, + model: undefined, + agent: undefined, + format: "default", + file: undefined, + title: undefined, + attach: "http://remote.test", + password: undefined, + dir: undefined, + port: undefined, + variant: undefined, + thinking: false, + }), + ) + } catch (error) { + thrown = error + } + expect(thrown).toBe(exit) } finally { if (tty) Object.defineProperty(process.stdin, "isTTY", tty) else delete (process.stdin as { isTTY?: boolean }).isTTY @@ -245,7 +313,7 @@ describe("remote preflight", () => { expect(create).not.toHaveBeenCalled() }) - test("run --attach fails before creating a session when the remote continue target is missing", () => { + test("run --attach fails before creating a session when the remote continue target is missing", async () => { stopExit() const err = spyOn(UI, "error").mockImplementation(() => {}) const create = mock(async () => { @@ -281,29 +349,35 @@ describe("remote preflight", () => { }) try { - return expect( - RunCommand.handler({ - _: [], - $0: "opencode", - message: ["hi"], - command: undefined, - continue: true, - session: undefined, - fork: false, - share: false, - model: undefined, - agent: undefined, - format: "default", - file: undefined, - title: undefined, - attach: "http://remote.test", - password: undefined, - dir: "/srv/app", - port: undefined, - variant: undefined, - thinking: false, - }), - ).rejects.toBe(exit) + let thrown: unknown + try { + await Promise.resolve( + RunCommand.handler({ + _: [], + $0: "opencode", + message: ["hi"], + command: undefined, + continue: true, + session: undefined, + fork: false, + share: false, + model: undefined, + agent: undefined, + format: "default", + file: undefined, + title: undefined, + attach: "http://remote.test", + password: undefined, + dir: "/srv/app", + port: undefined, + variant: undefined, + thinking: false, + }), + ) + } catch (error) { + thrown = error + } + expect(thrown).toBe(exit) } finally { if (tty) Object.defineProperty(process.stdin, "isTTY", tty) else delete (process.stdin as { isTTY?: boolean }).isTTY @@ -314,7 +388,7 @@ describe("remote preflight", () => { expect(create).not.toHaveBeenCalled() }) - test("run --attach fails before forking when the remote fork base is missing", () => { + test("run --attach fails before forking when the remote fork base is missing", async () => { stopExit() const err = spyOn(UI, "error").mockImplementation(() => {}) const get = mock(async () => { @@ -350,29 +424,35 @@ describe("remote preflight", () => { }) try { - return expect( - RunCommand.handler({ - _: [], - $0: "opencode", - message: ["hi"], - command: undefined, - continue: false, - session: "missing", - fork: true, - share: false, - model: undefined, - agent: undefined, - format: "default", - file: undefined, - title: undefined, - attach: "http://remote.test", - password: undefined, - dir: "/srv/app", - port: undefined, - variant: undefined, - thinking: false, - }), - ).rejects.toBe(exit) + let thrown: unknown + try { + await Promise.resolve( + RunCommand.handler({ + _: [], + $0: "opencode", + message: ["hi"], + command: undefined, + continue: false, + session: "missing", + fork: true, + share: false, + model: undefined, + agent: undefined, + format: "default", + file: undefined, + title: undefined, + attach: "http://remote.test", + password: undefined, + dir: "/srv/app", + port: undefined, + variant: undefined, + thinking: false, + }), + ) + } catch (error) { + thrown = error + } + expect(thrown).toBe(exit) } finally { if (tty) Object.defineProperty(process.stdin, "isTTY", tty) else delete (process.stdin as { isTTY?: boolean }).isTTY