From 0f5d1f34f8687caa2fb63a9c585ed94d7a6dbf55 Mon Sep 17 00:00:00 2001 From: "Joseph R. Quinn" <423821+quinnjr@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:36:06 -0400 Subject: [PATCH] feat(opencode): add --resume session picker --- packages/opencode/src/cli/cmd/attach.ts | 16 +++ packages/opencode/src/cli/cmd/tui.ts | 16 +++ .../__snapshots__/help-snapshots.test.ts.snap | 1 + packages/opencode/test/cli/tui/thread.test.ts | 46 +++++++ packages/tui/src/app.tsx | 24 +++- packages/tui/src/context/args.tsx | 1 + packages/tui/src/context/sync.tsx | 10 +- packages/tui/test/app-lifecycle.test.tsx | 120 +++++++++++++++++- .../tui/test/cli/cmd/tui/sync-fixture.tsx | 6 +- .../tui/test/cli/cmd/tui/sync-resume.test.tsx | 93 ++++++++++++++ packages/web/src/content/docs/cli.mdx | 2 + 11 files changed, 326 insertions(+), 9 deletions(-) create mode 100644 packages/tui/test/cli/cmd/tui/sync-resume.test.tsx diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 6f5aea6a1248..a0c17dbcbb16 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -28,6 +28,10 @@ export const AttachCommand = cmd({ type: "string", describe: "session id to continue", }) + .option("resume", { + type: "boolean", + describe: "pick a session to resume from the sessions in this directory", + }) .option("fork", { type: "boolean", describe: "fork the session when continuing (use with --continue or --session)", @@ -67,6 +71,17 @@ export const AttachCommand = cmd({ } const noReplay = args.replay === false || args.noReplay === true + if (args.resume && (args.continue || args.session)) { + UI.error("--resume cannot be used with --continue or --session") + process.exitCode = 1 + return + } + if (args.resume && args.mini) { + UI.error("--resume is not supported with --mini; use --continue or --session") + process.exitCode = 1 + return + } + const directory = (() => { if (!args.dir) return undefined try { @@ -138,6 +153,7 @@ export const AttachCommand = cmd({ args: { continue: args.continue, sessionID: args.session, + resume: args.resume, fork: args.fork, }, directory, diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 95ffac7ea51d..3e547e971d82 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -93,6 +93,10 @@ export const TuiThreadCommand = cmd({ type: "string", describe: "session id to continue", }) + .option("resume", { + type: "boolean", + describe: "pick a session to resume from the sessions in this directory", + }) .option("fork", { type: "boolean", describe: "fork the session when continuing (use with --continue or --session)", @@ -149,6 +153,17 @@ export const TuiThreadCommand = cmd({ } const noReplay = args.replay === false || args.noReplay === true + if (args.resume && (args.continue || args.session)) { + UI.error("--resume cannot be used with --continue or --session") + process.exitCode = 1 + return + } + if (args.resume && args.mini) { + UI.error("--resume is not supported with --mini; use --continue or --session") + process.exitCode = 1 + return + } + if (args.mini) { const network = ["--port", "--hostname", "--mdns", "--no-mdns", "--mdns-domain", "--cors"].find((option) => process.argv.some((arg) => arg === option || arg.startsWith(option + "=")), @@ -287,6 +302,7 @@ export const TuiThreadCommand = cmd({ args: { continue: args.continue, sessionID: args.session, + resume: args.resume, agent: args.agent, model: args.model, prompt, diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index e9d3ad233807..9ffcc55c1eea 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -58,6 +58,7 @@ Options: --dir directory to run in [string] -c, --continue continue the last session [boolean] -s, --session session id to continue [string] + --resume pick a session to resume from the sessions in this directory [boolean] --fork fork the session when continuing (use with --continue or --session) [boolean] -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode') diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 2b2969808690..8330c52aa7f4 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -98,4 +98,50 @@ describe("tui thread", () => { expect(result.stderr).toContain("--port cannot be used with --mini") }), ) + + cliIt.live("rejects --resume with --continue", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["--resume", "--continue"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--resume cannot be used with --continue or --session") + }), + ) + + cliIt.live("rejects --resume with --mini", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["--resume", "--mini"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--resume is not supported with --mini; use --continue or --session") + }), + ) + + cliIt.live("rejects --resume with --fork", ({ opencode, home }) => + Effect.gen(function* () { + // The fork check runs after `await import("@/config/tui")`, which loads + // packages/tui .tsx files. Bun resolves JSX settings from the tsconfig at + // the subprocess cwd, so mirror the package tsconfig's @opentui/solid + // settings or transpilation falls back to react/jsx-dev-runtime. + yield* Effect.promise(() => + Bun.write( + path.join(home, "tsconfig.json"), + JSON.stringify({ compilerOptions: { jsx: "preserve", jsxImportSource: "@opentui/solid" } }), + ), + ) + const result = yield* opencode.spawn(["--resume", "--fork"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--fork requires --continue or --session") + }), + ) + + cliIt.live("rejects --resume with --continue on attach", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["attach", "http://localhost:4096", "--resume", "--continue"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("--resume cannot be used with --continue or --session") + }), + ) }) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 57f372ef709a..fd7ce52b8e8c 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -47,7 +47,7 @@ import { DialogDebug } from "./component/dialog-debug" import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" -import { DialogSessionList } from "./component/dialog-session-list" +import { DialogSessionList, loadDialogSessionList } from "./component/dialog-session-list" import { DialogWorkspaceList } from "./component/dialog-workspace-list" import { DialogConsoleOrg } from "./component/dialog-console-org" import { ThemeProvider, useTheme } from "./context/theme" @@ -521,6 +521,28 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi } }) + // Handle --resume: open the session picker so any session for this + // directory can be resumed, once the session list has loaded + let resumed = false + createEffect(() => { + if (resumed || sync.status === "loading" || !args.resume) return + // Provider setup replaces dialogs at "complete"; wait it out rather than lose the picker + if (sync.data.provider.length === 0) return + resumed = true + const filter = sync.session.query() + void loadDialogSessionList({ filter, list: (query) => sdk.client.session.list(query) }).then((sessions) => { + if (sessions === undefined || sessions.length > 0) { + dialog.replace(() => ) + return + } + toast.show({ + variant: "info", + message: filter.scope === "project" ? "No sessions to resume" : "No sessions to resume in this directory", + duration: 3000, + }) + }) + }) + // Handle --session with --fork: wait for sync to be fully complete before forking // (session list loads in non-blocking phase for --session, so we must wait for "complete" // to avoid a race where reconcile overwrites the newly forked session) diff --git a/packages/tui/src/context/args.tsx b/packages/tui/src/context/args.tsx index 9a528f2f0ece..a73ac3ea1936 100644 --- a/packages/tui/src/context/args.tsx +++ b/packages/tui/src/context/args.tsx @@ -5,6 +5,7 @@ export interface Args { agent?: string prompt?: string continue?: boolean + resume?: boolean sessionID?: string fork?: boolean auto?: boolean diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index d0511c5183e4..6abee22a84bd 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -448,7 +448,7 @@ export const { const projectPromise = project.sync() const sessionListPromise = projectPromise.then(() => listSessions()) - // blocking - include session.list when continuing a session + // blocking - include session.list when continuing or resuming a session const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true }) const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true }) const capabilitiesPromise = sdk.client.experimental.capabilities @@ -468,7 +468,7 @@ export const { agentsPromise, configPromise, projectPromise, - ...(args.continue ? [sessionListPromise] : []), + ...(args.continue || args.resume ? [sessionListPromise] : []), ]) .then(async () => { const providersResponse = providersPromise.then((x) => x.data!) @@ -477,7 +477,7 @@ export const { const consoleStateResponse = consoleStatePromise const agentsResponse = agentsPromise.then((x) => x.data ?? []) const configResponse = configPromise.then((x) => x.data!) - const sessionListResponse = args.continue ? sessionListPromise : undefined + const sessionListResponse = args.continue || args.resume ? sessionListPromise : undefined return Promise.all([ providersResponse, @@ -512,7 +512,9 @@ export const { if (store.status !== "complete") setStore("status", "partial") // non-blocking void Promise.all([ - ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), + ...(args.continue || args.resume + ? [] + : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))), sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))), sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))), diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 570663424718..9333f2e7b5c5 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1,12 +1,24 @@ import { expect, mock, test } from "bun:test" import type { TuiPluginApi } from "@opencode-ai/plugin/tui" -import { createTestRenderer } from "@opentui/core/testing" +import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing" import { Effect } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/core/global" import { createTuiResolvedConfig } from "./fixture/tui-runtime" import { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk" +async function waitForText(setup: TestRendererSetup, text: string) { + const start = Date.now() + let frame = setup.captureCharFrame() + while (!frame.includes(text)) { + if (Date.now() - start > 5000) throw new Error(`timed out waiting for frame text: ${text}`) + await setup.renderOnce() + await Bun.sleep(10) + frame = setup.captureCharFrame() + } + return frame +} + test("SIGHUP clears title and disposes scoped resources once", async () => { const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) const core = await import("@opentui/core") @@ -126,3 +138,109 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => { mock.restore() } }) + +// The --resume effect waits for sync.data.provider, so the fake server needs +// one connected provider to keep the provider-setup dialog from taking over. +const provider = { + providers: [{ id: "test", name: "Test", source: "custom", env: [], options: {}, models: {} }], + default: {}, +} + +test("--resume opens the session list dialog once sessions load", async () => { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const events = createEventSource() + const calls = createFetch((url) => { + if (url.pathname === "/config/providers") return json(provider) + if (url.pathname === "/session") + return json([ + { + id: "ses_resume", + title: "Resume target session", + slug: "resume-target", + projectID: "project", + directory, + version: "0.0.0-test", + time: { created: 0, updated: 0 }, + }, + ]) + }) + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + url: "http://test", + directory, + config: createTuiResolvedConfig({ plugin_enabled: {} }), + fetch: calls.fetch, + events: events.source, + args: { resume: true }, + pluginHost: { + async start() { + started() + }, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + await ready + const frame = await waitForText(setup, "Resume target session") + + expect(frame).toContain("Sessions") + process.emit("SIGHUP") + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + mock.restore() + } +}) + +test("--resume shows an info toast when there are no sessions to resume", async () => { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const events = createEventSource() + const calls = createFetch((url) => { + if (url.pathname === "/config/providers") return json(provider) + }) + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + url: "http://test", + directory, + config: createTuiResolvedConfig({ plugin_enabled: {} }), + fetch: calls.fetch, + events: events.source, + args: { resume: true }, + pluginHost: { + async start() { + started() + }, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + await ready + // common prefix of both message variants (project scope vs directory filter) + const frame = await waitForText(setup, "No sessions to resume") + + expect(frame).not.toContain("Sessions") + process.emit("SIGHUP") + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + mock.restore() + } +}) diff --git a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx index f81c4401a61a..95ac09b569d6 100644 --- a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ import { testRender } from "@opentui/solid" import { onMount } from "solid-js" -import { ArgsProvider } from "../../../../src/context/args" +import { ArgsProvider, type Args } from "../../../../src/context/args" import { KVProvider, useKV } from "../../../../src/context/kv" import { ProjectProvider, useProject } from "../../../../src/context/project" import { SDKProvider } from "../../../../src/context/sdk" @@ -22,7 +22,7 @@ export async function wait(fn: () => boolean, timeout = 2000) { type Ctx = { kv: ReturnType; project: ReturnType; sync: ReturnType } -export async function mount(override?: FetchHandler, state?: string) { +export async function mount(override?: FetchHandler, state?: string, options?: { args?: Args }) { const calls = createFetch(override) const events = createEventSource() let sync!: ReturnType @@ -46,7 +46,7 @@ export async function mount(override?: FetchHandler, state?: string) { const app = await testRender(() => ( - + diff --git a/packages/tui/test/cli/cmd/tui/sync-resume.test.tsx b/packages/tui/test/cli/cmd/tui/sync-resume.test.tsx new file mode 100644 index 000000000000..f23e97746a9b --- /dev/null +++ b/packages/tui/test/cli/cmd/tui/sync-resume.test.tsx @@ -0,0 +1,93 @@ +/** @jsxImportSource @opentui/solid */ +import { describe, expect, test } from "bun:test" +import { tmpdir } from "../../../fixture/fixture" +import { directory, json, mount, wait } from "./sync-fixture" + +const session = { + id: "ses_resume", + title: "Resume target", + slug: "resume-target", + projectID: "proj_test", + directory, + version: "1.15.13", + time: { created: 0, updated: 0 }, +} + +// /vcs is only fetched by bootstrap's non-blocking phase, so its position in +// the request log marks when bootstrap leaves the blocking ("loading") phase. +// The fixture's sync handle is gated behind sync.ready, so blocking-phase +// behavior is observed through the request log instead of sync.status. +describe("tui sync --resume", () => { + test("bootstrap blocks on the session list and fetches it once", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + + const order: string[] = [] + let release!: (response: Response) => void + const pending = new Promise((resolve) => { + release = resolve + }) + const mounted = mount( + (url) => { + if (url.pathname === "/session") { + order.push("session") + return pending + } + if (url.pathname === "/vcs") order.push("vcs") + return undefined + }, + tmp.path, + { args: { resume: true } }, + ) + + await wait(() => order.includes("session")) + await Bun.sleep(30) + const blocked = !order.includes("vcs") + release(json([session])) + const { app, sync, session: calls } = await mounted + + try { + expect(blocked).toBe(true) + expect(order).toEqual(["session", "vcs"]) + expect(sync.data.session.map((x) => x.id)).toEqual([session.id]) + expect(calls).toHaveLength(1) + } finally { + app.renderer.destroy() + } + }) + + test("without --resume the session list loads after bootstrap unblocks", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + + const order: string[] = [] + let release!: (response: Response) => void + const pending = new Promise((resolve) => { + release = resolve + }) + const mounted = mount( + (url) => { + if (url.pathname === "/session") { + order.push("session") + return pending + } + if (url.pathname === "/vcs") order.push("vcs") + return undefined + }, + tmp.path, + ) + + await wait(() => order.includes("session")) + await wait(() => order.includes("vcs")) + release(json([session])) + const { app, sync, session: calls } = await mounted + + try { + expect(order).toEqual(["session", "vcs"]) + expect(sync.data.session.map((x) => x.id)).toEqual([session.id]) + expect(calls).toHaveLength(1) + } finally { + app.renderer.destroy() + } + }) +}) diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 94d9ba3c75d4..332ad629462d 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -33,6 +33,7 @@ opencode [project] | ------------------------------------------- | ----- | ----------------------------------------------------------------------- | | {"--continue"} | `-c` | Continue the last session | | {"--session"} | `-s` | Session ID to continue | +| {"--resume"} | | Pick a session to resume from the sessions in this directory | | {"--fork"} | | Fork the session when continuing (use with `--continue` or `--session`) | | {"--prompt"} | | Prompt to use | | {"--model"} | `-m` | Model to use in the form of provider/model | @@ -121,6 +122,7 @@ opencode attach http://10.20.30.40:4096 | {"--dir"} | | Working directory to start TUI in | | {"--continue"} | `-c` | Continue the last session | | {"--session"} | `-s` | Session ID to continue | +| {"--resume"} | | Pick a session to resume from the sessions in this directory | | {"--fork"} | | Fork the session when continuing (use with `--continue` or `--session`) | | {"--password"} | `-p` | Basic auth password (defaults to `OPENCODE_SERVER_PASSWORD`) | | {"--username"} | `-u` | Basic auth username (defaults to `OPENCODE_SERVER_USERNAME` or `opencode`) |