diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 7fb5fda97b9..c26e6290e8a 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -73,6 +73,11 @@ export const SessionListCommand = cmd({ describe: "list sessions", builder: (yargs: Argv) => { return yargs + .option("all", { + alias: "a", + describe: "list sessions across all projects", + type: "boolean", + }) .option("max-count", { alias: "n", describe: "limit to N most recent sessions", @@ -87,7 +92,9 @@ export const SessionListCommand = cmd({ }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const sessions = [...Session.list({ roots: true, limit: args.maxCount })] + const sessions = args.all + ? [...Session.listGlobal({ roots: true, limit: args.maxCount })] + : [...Session.list({ roots: true, limit: args.maxCount })] if (sessions.length === 0) { return @@ -97,7 +104,7 @@ export const SessionListCommand = cmd({ if (args.format === "json") { output = formatSessionJSON(sessions) } else { - output = formatSessionTable(sessions) + output = formatSessionTable(sessions, args.all) } const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" @@ -124,26 +131,31 @@ export const SessionListCommand = cmd({ }, }) -function formatSessionTable(sessions: Session.Info[]): string { +export function formatSessionTable(sessions: Session.Info[], all?: boolean): string { const lines: string[] = [] const maxIdWidth = Math.max(20, ...sessions.map((s) => s.id.length)) + const maxProjectWidth = all ? Math.max(10, ...sessions.map((s) => s.projectID.length)) : 10 const maxTitleWidth = Math.max(25, ...sessions.map((s) => s.title.length)) - const header = `Session ID${" ".repeat(maxIdWidth - 10)} Title${" ".repeat(maxTitleWidth - 5)} Updated` + const header = all + ? `Session ID${" ".repeat(maxIdWidth - 10)} Project ID${" ".repeat(maxProjectWidth - 10)} Title${" ".repeat(maxTitleWidth - 5)} Updated` + : `Session ID${" ".repeat(maxIdWidth - 10)} Title${" ".repeat(maxTitleWidth - 5)} Updated` lines.push(header) lines.push("─".repeat(header.length)) for (const session of sessions) { const truncatedTitle = Locale.truncate(session.title, maxTitleWidth) const timeStr = Locale.todayTimeOrDateTime(session.time.updated) - const line = `${session.id.padEnd(maxIdWidth)} ${truncatedTitle.padEnd(maxTitleWidth)} ${timeStr}` + const line = all + ? `${session.id.padEnd(maxIdWidth)} ${session.projectID.padEnd(maxProjectWidth)} ${truncatedTitle.padEnd(maxTitleWidth)} ${timeStr}` + : `${session.id.padEnd(maxIdWidth)} ${truncatedTitle.padEnd(maxTitleWidth)} ${timeStr}` lines.push(line) } return lines.join(EOL) } -function formatSessionJSON(sessions: Session.Info[]): string { +export function formatSessionJSON(sessions: Session.Info[]): string { const jsonData = sessions.map((session) => ({ id: session.id, title: session.title, diff --git a/packages/opencode/test/cli/session-list.test.ts b/packages/opencode/test/cli/session-list.test.ts new file mode 100644 index 00000000000..4620c0843ce --- /dev/null +++ b/packages/opencode/test/cli/session-list.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test" +import { formatSessionJSON, formatSessionTable } from "../../src/cli/cmd/session" +import type { Session } from "../../src/session" + +function buildSession(input: { id: string; projectID: string; title: string; updated: number }): Session.Info { + return { + id: input.id, + slug: "test-slug", + projectID: input.projectID, + directory: "/tmp/project", + title: input.title, + version: "1.0.0", + time: { + created: input.updated, + updated: input.updated, + }, + } +} + +describe("session list formatting", () => { + test("shows Project ID column for all sessions table", () => { + const sessions = [ + buildSession({ + id: "ses_1", + projectID: "proj_1", + title: "Session One", + updated: 1710000000000, + }), + ] + + const output = formatSessionTable(sessions, true) + const lines = output.split("\n") + + expect(lines[0]).toContain("Project ID") + expect(lines[2]).toContain("proj_1") + }) + + test("hides Project ID column for default table", () => { + const sessions = [ + buildSession({ + id: "ses_1", + projectID: "proj_1", + title: "Session One", + updated: 1710000000000, + }), + ] + + const output = formatSessionTable(sessions) + const lines = output.split("\n") + + expect(lines[0]).not.toContain("Project ID") + expect(lines[2]).not.toContain("proj_1") + }) + + test("json output includes project id", () => { + const sessions = [ + buildSession({ + id: "ses_1", + projectID: "proj_1", + title: "Session One", + updated: 1710000000000, + }), + ] + + const output = formatSessionJSON(sessions) + const parsed = JSON.parse(output) as { projectId: string }[] + + expect(parsed[0].projectId).toBe("proj_1") + }) +}) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 05d6de04b1b..7e3aef1beeb 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -86,4 +86,29 @@ describe("Session.listGlobal", () => { expect(ids).toContain(first.id) expect(ids).not.toContain(second.id) }) + + test("filters root sessions across projects", async () => { + await using first = await tmpdir({ git: true }) + await using second = await tmpdir({ git: true }) + + const root = await Instance.provide({ + directory: first.path, + fn: async () => Session.create({ title: "root-session" }), + }) + const child = await Instance.provide({ + directory: first.path, + fn: async () => Session.create({ title: "child-session", parentID: root.id }), + }) + const otherRoot = await Instance.provide({ + directory: second.path, + fn: async () => Session.create({ title: "other-root-session" }), + }) + + const sessions = [...Session.listGlobal({ roots: true, limit: 200 })] + const ids = sessions.map((session) => session.id) + + expect(ids).toContain(root.id) + expect(ids).toContain(otherRoot.id) + expect(ids).not.toContain(child.id) + }) })