Skip to content
Closed
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: 16 additions & 0 deletions packages/opencode/src/cli/cmd/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -138,6 +153,7 @@ export const AttachCommand = cmd({
args: {
continue: args.continue,
sessionID: args.session,
resume: args.resume,
fork: args.fork,
},
directory,
Expand Down
16 changes: 16 additions & 0 deletions packages/opencode/src/cli/cmd/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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 + "=")),
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
46 changes: 46 additions & 0 deletions packages/opencode/test/cli/tui/thread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}),
)
})
24 changes: 23 additions & 1 deletion packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -521,6 +521,28 @@ function App(props: { onSnapshot?: () => Promise<string[]>; 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(() => <DialogSessionList />)
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)
Expand Down
1 change: 1 addition & 0 deletions packages/tui/src/context/args.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface Args {
agent?: string
prompt?: string
continue?: boolean
resume?: boolean
sessionID?: string
fork?: boolean
auto?: boolean
Expand Down
10 changes: 6 additions & 4 deletions packages/tui/src/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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!)
Expand All @@ -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,
Expand Down Expand Up @@ -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 ?? []))),
Expand Down
120 changes: 119 additions & 1 deletion packages/tui/test/app-lifecycle.test.tsx
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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<void>((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<void>((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()
}
})
6 changes: 3 additions & 3 deletions packages/tui/test/cli/cmd/tui/sync-fixture.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -22,7 +22,7 @@ export async function wait(fn: () => boolean, timeout = 2000) {

type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }

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<typeof useSync>
Expand All @@ -46,7 +46,7 @@ export async function mount(override?: FetchHandler, state?: string) {

const app = await testRender(() => (
<TestTuiContexts paths={state ? { state } : undefined}>
<ArgsProvider>
<ArgsProvider {...(options?.args ?? {})}>
<KVProvider>
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
<PermissionProvider>
Expand Down
Loading
Loading