Skip to content
Open
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
6 changes: 5 additions & 1 deletion packages/cli/src/commands/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,8 +443,12 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
globalFlags: ServerFlags,
commands: [
Spec.make("list", {
description: "List top-level sessions in the current project, newest first",
description: "List top-level sessions, newest first (current project unless --all)",
params: {
all: Flag.Boolean("all").pipe(
Flag.withDescription("List sessions across all projects"),
Flag.withDefault(false),
),
maxCount: Flag.Int("max-count").pipe(
Flag.withAlias("n"),
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
Expand Down
46 changes: 29 additions & 17 deletions packages/cli/src/commands/handlers/session/list.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { OpenCode, type SessionInfo } from "@opencode/client"
import { OpenCode, type OpenCodeClient, type SessionInfo } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { Effect, Option, Stream } from "effect"
import { EOL } from "node:os"
Expand All @@ -7,26 +7,36 @@ import { Runtime } from "../../../framework/runtime"
import { ServerConnection } from "../../../services/server-connection"
import { errorMessage } from "../../../util/error"

/**
* The first page of top-level sessions. `--all` drops the project scope entirely: the protocol treats `project` as
* optional, so omitting the key lists every project instead of passing `undefined` and asking for one project.
*/
export async function listSessionPage(
client: OpenCodeClient,
input: { all: boolean; maxCount: Option.Option<number> },
directory: string,
signal?: AbortSignal,
) {
const options = signal === undefined ? {} : { signal }
const project = input.all ? undefined : (await client.location.get({ location: { directory } }, options)).project.id
return client.session.list(
{
...(project === undefined ? {} : { project }),
parentID: null,
order: "desc",
limit: Option.getOrElse(input.maxCount, () => 100),
},
options,
)
}

const handler = Effect.fn("cli.session.list")(function* (
input: Runtime.Input<typeof Commands.commands.session.commands.list>,
) {
const server = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
const location = yield* Effect.tryPromise({
try: (signal) => client.location.get({ location: { directory: process.cwd() } }, { signal }),
catch: (cause) => cause,
})
const page = yield* Effect.tryPromise({
try: (signal) =>
client.session.list(
{
project: location.project.id,
parentID: null,
order: "desc",
limit: Option.getOrElse(input.maxCount, () => 100),
},
{ signal },
),
try: (signal) => listSessionPage(client, input, process.cwd(), signal),
catch: (cause) => cause,
})
if (input.format === "table" && page.data.length === 0) return
Expand All @@ -44,7 +54,7 @@ const handler = Effect.fn("cli.session.list")(function* (
null,
2,
)
: formatList(page.data)) + EOL
: formatList(page.data, input.all)) + EOL
const write = Effect.tryPromise(
() =>
new Promise<void>((resolve, reject) => {
Expand Down Expand Up @@ -93,13 +103,15 @@ export default Runtime.handler(Commands.commands.session.commands.list, (input)
),
)

function formatList(sessions: ReadonlyArray<SessionInfo>) {
/** Tab-separated rows: id, title and updated time, plus the directory when sessions come from every project. */
export function formatList(sessions: ReadonlyArray<SessionInfo>, all: boolean) {
return sessions
.map((session) =>
[
session.id,
(session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
new Date(session.time.updated).toLocaleString(),
...(all ? [session.location.directory.replace(/[\r\n\t]/g, " ")] : []),
].join("\t"),
)
.join(EOL)
Expand Down
95 changes: 95 additions & 0 deletions packages/cli/test/session-list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode, type LocationGetOutput, type SessionInfo } from "@opencode/client/promise"
import { Option } from "effect"
import { EOL } from "node:os"
import { formatList, listSessionPage } from "../src/commands/handlers/session/list"

function location(directory: string): LocationGetOutput {
return { directory, project: { id: "project-current", directory, canonical: directory } }
}

function page() {
return { data: [], cursor: {} }
}

function session(id: string, title: string, directory: string): SessionInfo {
return {
id,
projectID: "project",
title,
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
}
}

afterEach(() => mock.restore())

describe("session list", () => {
test("scopes the default listing to the current project", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const get = spyOn(client.location, "get").mockResolvedValue(location("/work"))
const list = spyOn(client.session, "list").mockResolvedValue(page())

await listSessionPage(client, { all: false, maxCount: Option.none() }, "/work")

expect(get).toHaveBeenCalledWith({ location: { directory: "/work" } }, {})
expect(list.mock.calls[0]![0]).toEqual({
project: "project-current",
parentID: null,
order: "desc",
limit: 100,
})
})

test("--all skips the location lookup and omits the project filter", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const get = spyOn(client.location, "get").mockResolvedValue(location("/work"))
const list = spyOn(client.session, "list").mockResolvedValue(page())

await listSessionPage(client, { all: true, maxCount: Option.none() }, "/work")

expect(get).not.toHaveBeenCalled()
const query = list.mock.calls[0]![0]!
// `project` must be absent, not undefined: the store filters whenever the key is present.
expect(Object.hasOwn(query, "project")).toBe(false)
expect(query).toEqual({ parentID: null, order: "desc", limit: 100 })
})

test("--max-count caps the page", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/work"))
const list = spyOn(client.session, "list").mockResolvedValue(page())

await listSessionPage(client, { all: false, maxCount: Option.some(5) }, "/work")

expect(list.mock.calls[0]![0]!.limit).toBe(5)
})
})

describe("session list table", () => {
const sessions = [
session("ses_a", "Fix theme preview lag", "/work/opencode"),
session("ses_b", "GEP unmerge nuw", "/work/llvm-project"),
]

test("keeps the default columns unchanged", () => {
const rows = formatList(sessions, false).split(EOL)

expect(rows.map((row) => row.split("\t").length)).toEqual([3, 3])
expect(rows[0]!.split("\t")[0]).toBe("ses_a")
})

test("--all adds the session's directory", () => {
const rows = formatList(sessions, true).split(EOL)

expect(rows[0]!.split("\t")).toEqual([
"ses_a",
"Fix theme preview lag",
expect.any(String),
"/work/opencode",
])
expect(rows[1]!.split("\t")[3]).toBe("/work/llvm-project")
})
})
6 changes: 6 additions & 0 deletions services/www/src/docs/content/cli/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ Limit the list and print JSON.
$ opencode session list --max-count 20 --format json
```

List sessions from every project instead of only the current one.

```bash
$ opencode session list --all
```

Delete a session and its child sessions.

```bash
Expand Down
Loading