From 5a2f0bc6053d348640c427ddfa32bd7859cda9b1 Mon Sep 17 00:00:00 2001 From: Onur Micoogullari <204898@skane.se> Date: Mon, 22 Jun 2026 23:45:55 +0200 Subject: [PATCH] feat(tui): add global session picker toggle Summary Adds an explicit global mode to the TUI session picker so users can discover and resume sessions from other projects without changing the default scoped picker behavior. Addresses #31932. Key changes - Added a session.toggle.global_list command and session_toggle_global_list keybind config to switch the picker between scoped and global listing. - Routed picker browsing and search through a scoped/global list path that uses /experimental/session for global rows while leaving sync.session.refresh() and --continue scoped. - Rendered project/path context for global rows and added spacing for multi-line select details so cross-project sessions are distinguishable. - Covered scoped refresh, global root filtering, hydrated global session cleanup, and pinned global session behavior with TUI tests. --- packages/tui/src/app.tsx | 10 + .../tui/src/component/dialog-session-list.tsx | 45 +++-- .../src/component/dialog-session-rename.tsx | 3 +- packages/tui/src/config/keybind.ts | 2 + packages/tui/src/context/sync.tsx | 35 +++- packages/tui/src/ui/dialog-select.tsx | 3 +- .../cli/cmd/tui/dialog-session-list.test.tsx | 191 ++++++++++++++++++ .../tui/test/cli/cmd/tui/sync-fixture.tsx | 27 ++- packages/tui/test/cli/cmd/tui/sync.test.tsx | 38 ++++ packages/tui/test/config.test.tsx | 5 +- packages/tui/test/fixture/tui-sdk.ts | 5 +- packages/web/src/content/docs/keybinds.mdx | 1 + 12 files changed, 339 insertions(+), 26 deletions(-) create mode 100644 packages/tui/test/cli/cmd/tui/dialog-session-list.test.tsx diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 39aa35993f76..b137ecc5c1d8 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -86,6 +86,7 @@ import { cliErrorMessage, errorFormat } from "./util/error" const appGlobalBindingCommands = [ "session.list", + "session.toggle.global_list", "session.new", "session.quick_switch.1", "session.quick_switch.2", @@ -567,6 +568,15 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi dialog.replace(() => ) }, }, + { + name: "session.toggle.global_list", + title: kv.get("session_list_global_enabled", false) ? "Hide global sessions" : "Show global sessions", + category: "Session", + run: () => { + kv.set("session_list_global_enabled", !kv.get("session_list_global_enabled", false)) + dialog.clear() + }, + }, { name: "session.new", title: "New session", diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 2965b3692e94..48a2c99c0a6d 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -17,6 +17,9 @@ import { Spinner } from "./spinner" import { errorMessage } from "../util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" import { useCommandShortcut } from "../keymap" +import type { GlobalSession, Session } from "@opencode-ai/sdk/v2" + +type PickerSession = Session & { project?: GlobalSession["project"] } export function DialogSessionList() { const dialog = useDialog() @@ -34,18 +37,26 @@ export function DialogSessionList() { const quickSwitch9 = useCommandShortcut("session.quick_switch.9") const [searchResults, { refetch }] = createResource( - () => ({ query: search(), filter: sync.session.query() }), + () => ({ query: search(), global: sync.session.globalListEnabled(), filter: sync.session.query() }), async (input) => { if (!input.query) return undefined - const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter }) - return result.data ?? [] + return sync.session.list({ search: input.query, limit: 30, roots: true }) + }, + ) + const [browseSessions, { refetch: refetchBrowse }] = createResource( + () => ({ global: sync.session.globalListEnabled(), filter: sync.session.query() }), + async () => { + return sync.session.list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, limit: 100, roots: true }) }, ) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - const sessions = createMemo(() => searchResults() ?? sync.data.session) + const sessions = createMemo(() => { + const listed = searchResults() ?? browseSessions() + return (listed ?? []) as PickerSession[] + }) - function recover(session: NonNullable[number]>) { + function recover(session: PickerSession) { const workspace = project.workspace.get(session.workspaceID!) const list = () => dialog.replace(() => ) const warp = async (selection: WorkspaceSelection) => { @@ -109,6 +120,7 @@ export function DialogSessionList() { await project.workspace.sync() await sync.session.refresh() if (search()) await refetch() + else await refetchBrowse() if (info?.workspaceID === session.workspaceID) { route.navigate({ type: "home" }) } @@ -138,8 +150,6 @@ export function DialogSessionList() { .map((x) => x.id) } - const [browseOrder] = createSignal(orderByRecency(sync.data.session)) - const quickSwitchHint = createMemo(() => { const first = quickSwitch1() const last = quickSwitch9() @@ -160,7 +170,7 @@ export function DialogSessionList() { ) const searchResult = searchResults() - const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() + const displayOrder = searchResult ? orderByRecency(searchResult as PickerSession[]) : orderByRecency(sessions()) const pinned = local.session.pinned().filter((id) => sessionMap.has(id)) const pinnedSet = new Set(pinned) @@ -174,8 +184,15 @@ export function DialogSessionList() { ? x.directory.slice(0, -x.path.length).replace(/\/$/, "") : undefined : x.directory - const footer = - directory && directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : "" + const projectLabel = x.project?.name ?? (x.project ? path.basename(x.project.worktree) : undefined) + const detail = x.directory + const footer = sync.session.globalListEnabled() + ? projectLabel + ? Locale.truncate(projectLabel, 20) + : Locale.truncate(path.basename(directory ?? x.directory), 20) + : directory && directory !== project.data.project.mainDir + ? Locale.truncate(path.basename(directory), 20) + : "" const isDeleting = toDelete() === x.id const status = sync.data.session_status?.[x.id] @@ -192,6 +209,7 @@ export function DialogSessionList() { value: x.id, category, footer, + details: sync.session.globalListEnabled() && detail ? [detail] : undefined, gutter, } } @@ -215,7 +233,7 @@ export function DialogSessionList() { return ( { - dialog.replace(() => ) + dialog.replace(() => ( + item.id === option.value)?.title} /> + )) }, }, ]} diff --git a/packages/tui/src/component/dialog-session-rename.tsx b/packages/tui/src/component/dialog-session-rename.tsx index 9fd8049e1e4a..3780532778b9 100644 --- a/packages/tui/src/component/dialog-session-rename.tsx +++ b/packages/tui/src/component/dialog-session-rename.tsx @@ -6,6 +6,7 @@ import { useSDK } from "../context/sdk" interface DialogSessionRenameProps { session: string + title?: string } export function DialogSessionRename(props: DialogSessionRenameProps) { @@ -17,7 +18,7 @@ export function DialogSessionRename(props: DialogSessionRenameProps) { return ( { void sdk.client.session.update({ sessionID: props.session, diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 0028b610f2a3..f138c4bb05fa 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -85,6 +85,7 @@ export const Definitions = { session_copy: keybind("none", "Copy session transcript"), session_new: keybind("n", "Create a new session"), session_list: keybind("l", "List all sessions"), + session_toggle_global_list: keybind("none", "Toggle global sessions in the session list"), session_timeline: keybind("g", "Show session timeline"), session_fork: keybind("none", "Fork session from message"), session_rename: keybind("ctrl+r", "Rename session"), @@ -289,6 +290,7 @@ export const CommandMap = { session_copy: "session.copy", session_new: "session.new", session_list: "session.list", + session_toggle_global_list: "session.toggle.global_list", session_timeline: "session.timeline", session_fork: "session.fork", session_rename: "session.rename", diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 03db8784de49..b21deeeafc67 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -3,6 +3,7 @@ import type { Agent, Provider, Session, + GlobalSession, Part, Config, Todo, @@ -37,6 +38,14 @@ const emptyConsoleState: ConsoleState = { switchableOrgCount: 0, } +type PickerSession = Session | GlobalSession +type PickerSessionListInput = { + start?: number + search?: string + limit?: number + roots?: boolean +} + function search(items: T[], target: string, key: (item: T) => string) { let left = 0 let right = items.length - 1 @@ -159,10 +168,28 @@ export const { } } + function globalSessionListEnabled() { + return kv.get("session_list_global_enabled", false) + } + + function sortSessionsByID(sessions: T[]) { + return sessions.toSorted((a, b) => a.id.localeCompare(b.id)) + } + function listSessions() { return sdk.client.session .list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() }) - .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) + .then((x) => sortSessionsByID(x.data ?? [])) + } + + function listPickerSessions(input: PickerSessionListInput = {}): Promise { + if (globalSessionListEnabled()) { + return sdk.client.experimental.session + // directory="" blocks SDK directory injection while staying unfiltered on the server. + .list({ directory: "", ...input }) + .then((x) => sortSessionsByID(x.data ?? [])) + } + return sdk.client.session.list({ ...input, ...sessionListQuery() }).then((x) => sortSessionsByID(x.data ?? [])) } event.subscribe((event, { workspace }) => { @@ -560,6 +587,12 @@ export const { query() { return sessionListQuery() }, + globalListEnabled() { + return globalSessionListEnabled() + }, + list(input?: PickerSessionListInput) { + return listPickerSessions(input) + }, async refresh() { const list = await listSessions() setStore("session", reconcile(list)) diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 99299f4e22ac..8c3d468e4ee0 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -201,7 +201,7 @@ export function DialogSelect(props: DialogSelectProps) { if (!category) return acc return acc + (i > 0 ? 2 : 1) }, 0) - return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers) + return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0) + (option.details?.length ? 1 : 0), headers) }) const dimensions = useTerminalDimensions() @@ -591,6 +591,7 @@ export function DialogSelect(props: DialogSelectProps) { if (index === -1) return moveTo(index) }} + paddingBottom={option.details?.length ? 1 : 0} > boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +const now = Date.now() +const scopedSession = session({ id: "ses_scoped_root", title: "Scoped root", directory, time: now - 1 }) +const globalSession: GlobalSession = { + ...session({ + id: "ses_global_root", + title: "Global root", + directory: `${worktree}/other-project/app`, + time: now, + }), + project: { id: "proj_other", name: "Other project", worktree: `${worktree}/other-project` }, +} +const globalChild: GlobalSession = { + ...session({ + id: "ses_global_child", + title: "Global child", + directory: `${worktree}/other-project/app`, + parentID: globalSession.id, + time: now + 1, + }), + project: globalSession.project, +} + +test("session picker toggles global rows without leaking hydrated sessions into scoped browse", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + await Bun.write(`${tmp.path}/session.json`, "{}") + const mounted = await mountDialog(tmp.path, (url) => { + if (url.pathname === "/session") return json([scopedSession]) + if (url.pathname === "/experimental/session") return json([globalChild, globalSession]) + if (url.pathname === `/session/${globalSession.id}`) return json(globalSession) + if (url.pathname === `/session/${globalSession.id}/message`) return json([]) + if (url.pathname === `/session/${globalSession.id}/todo`) return json([]) + if (url.pathname === `/session/${globalSession.id}/diff`) return json([]) + }) + + try { + await wait(() => mounted.sync.status === "complete" && mounted.local.session.ready) + await wait(() => mounted.frame().includes("Scoped root")) + + expect(mounted.frame()).toContain("Sessions") + expect(mounted.frame()).toContain("Scoped root") + expect(mounted.frame()).not.toContain("Global root") + + mounted.kv.set("session_list_global_enabled", true) + await wait(() => mounted.frame().includes("Sessions (global)") && mounted.frame().includes("Global root")) + + expect(mounted.frame()).toContain("Other project") + expect(mounted.frame()).toContain(`${worktree}/other-project/app`) + expect(mounted.frame()).not.toContain("Global child") + expect(mounted.experimentalSession.at(-1)?.searchParams.get("roots")).toBe("true") + + mounted.local.session.togglePin(globalSession.id) + expect(mounted.local.session.pinned()).toContain(globalSession.id) + expect(mounted.local.session.slots()).not.toContain(globalSession.id) + + mounted.app.mockInput.pressEnter() + await wait(() => mounted.route.data.type === "session" && mounted.route.data.sessionID === globalSession.id) + await mounted.sync.session.sync(globalSession.id) + await wait(() => mounted.sync.session.get(globalSession.id) !== undefined) + await wait(() => mounted.local.session.slots().includes(globalSession.id)) + + mounted.kv.set("session_list_global_enabled", false) + await wait(() => mounted.frame().includes("Scoped root") && !mounted.frame().includes("Global root")) + + expect(mounted.session.at(-1)?.searchParams.get("roots")).toBe("true") + expect(mounted.local.session.pinned()).toContain(globalSession.id) + } finally { + mounted.app.renderer.destroy() + } +}) + +async function mountDialog(state: string, override: FetchHandler) { + const calls = createFetch(override) + const events = createEventSource() + let kv!: ReturnType + let local!: ReturnType + let route!: ReturnType + let sync!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + function Probe() { + kv = useKV() + local = useLocal() + route = useRoute() + sync = useSync() + onMount(done) + return + } + + function Harness() { + const renderer = useRenderer() + const config = createTuiResolvedConfig() + const keymap = createDefaultOpenTuiKeymap(renderer) + const dispose = registerOpencodeKeymap(keymap, renderer, config) + onCleanup(dispose) + + return ( + + + + + + { throw error }}> + + + + + + + + + + + + + + + + + + + + + + + + ) + } + + const app = await testRender(() => , { kittyKeyboard: true }) + await ready + return { + app, + experimentalSession: calls.experimentalSession, + frame: () => app.captureCharFrame(), + kv, + local, + route, + session: calls.session, + sync, + } +} + +function session(input: { id: string; title: string; directory: string; parentID?: string; time: number }): Session { + return { + id: input.id, + slug: input.id.replace("ses_", ""), + projectID: "proj_test", + directory: input.directory, + parentID: input.parentID, + title: input.title, + version: "test", + time: { created: input.time, updated: input.time }, + } +} diff --git a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx index a212f5be1db1..021081e41121 100644 --- a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx @@ -2,6 +2,7 @@ import { testRender } from "@opentui/solid" import { onMount } from "solid-js" import { ArgsProvider } from "../../../../src/context/args" +import { ExitProvider } from "../../../../src/context/exit" import { KVProvider, useKV } from "../../../../src/context/kv" import { ProjectProvider, useProject } from "../../../../src/context/project" import { SDKProvider } from "../../../../src/context/sdk" @@ -46,13 +47,15 @@ export async function mount(override?: FetchHandler, state?: string) { - - - - - - - + { throw error }}> + + + + + + + + @@ -60,5 +63,13 @@ export async function mount(override?: FetchHandler, state?: string) { await ready await wait(() => sync.status === "complete") - return { app, emit: events.emit, kv, project, sync, session: calls.session } + return { + app, + emit: events.emit, + kv, + project, + sync, + session: calls.session, + experimentalSession: calls.experimentalSession, + } } diff --git a/packages/tui/test/cli/cmd/tui/sync.test.tsx b/packages/tui/test/cli/cmd/tui/sync.test.tsx index a7472ab1d6b6..031b5c8f87cb 100644 --- a/packages/tui/test/cli/cmd/tui/sync.test.tsx +++ b/packages/tui/test/cli/cmd/tui/sync.test.tsx @@ -38,6 +38,44 @@ describe("tui sync", () => { } }) + test("picker list can use global sessions without changing refresh scope", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, experimentalSession, kv, session, sync } = await mount(undefined, tmp.path) + + try { + await sync.session.list({ search: "scoped", limit: 30, roots: true }) + + expect(session.at(-1)?.pathname).toBe("/session") + expect(session.at(-1)?.searchParams.get("search")).toBe("scoped") + expect(session.at(-1)?.searchParams.get("path")).toBe("packages/tui") + expect(session.at(-1)?.searchParams.get("roots")).toBe("true") + + kv.set("session_list_global_enabled", true) + await sync.session.list({ search: "global", limit: 30, roots: true }) + + expect(experimentalSession.at(-1)?.pathname).toBe("/experimental/session") + expect(experimentalSession.at(-1)?.searchParams.get("directory")).toBe("") + expect(experimentalSession.at(-1)?.searchParams.get("search")).toBe("global") + expect(experimentalSession.at(-1)?.searchParams.get("roots")).toBe("true") + + kv.set("session_list_global_enabled", false) + await sync.session.list({ search: "scoped-again", limit: 30, roots: true }) + + expect(session.at(-1)?.pathname).toBe("/session") + expect(session.at(-1)?.searchParams.get("search")).toBe("scoped-again") + expect(session.at(-1)?.searchParams.get("path")).toBe("packages/tui") + expect(session.at(-1)?.searchParams.get("roots")).toBe("true") + + await sync.session.refresh() + + expect(session.at(-1)?.pathname).toBe("/session") + expect(session.at(-1)?.searchParams.get("path")).toBe("packages/tui") + } finally { + app.renderer.destroy() + } + }) + test("vcs branch updates only apply for the active workspace", async () => { await using tmp = await tmpdir() await Bun.write(`${tmp.path}/kv.json`, "{}") diff --git a/packages/tui/test/config.test.tsx b/packages/tui/test/config.test.tsx index 1ccb0986d15f..d2b61a879b6e 100644 --- a/packages/tui/test/config.test.tsx +++ b/packages/tui/test/config.test.tsx @@ -71,13 +71,14 @@ test("resolves overrides without mutating input", () => { sound_pack: "custom.pack", sounds: { question: "/sounds/question.wav" }, }, - keybinds: { session_list: "ctrl+l" }, + keybinds: { session_list: "ctrl+l", session_toggle_global_list: "ctrl+g" }, } const config = resolve(input, { terminalSuspend: true }) expect(config).toMatchObject({ theme: "custom", mouse: false, leader_timeout: 750, attention: input.attention }) expect(config.keybinds.get("session.list")).toHaveLength(1) - expect(input.keybinds).toEqual({ session_list: "ctrl+l" }) + expect(config.keybinds.get("session.toggle.global_list")?.[0]?.key).toBe("ctrl+g") + expect(input.keybinds).toEqual({ session_list: "ctrl+l", session_toggle_global_list: "ctrl+g" }) }) test("disables suspend and assigns ctrl+z to undo when unsupported", () => { diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index d1cf3c7dfc2a..676e99bf14be 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -63,9 +63,11 @@ export type FetchHandler = (url: URL) => Response | Promise | undefine export function createFetch(override?: FetchHandler, events?: ReturnType) { const session = [] as URL[] + const experimentalSession = [] as URL[] const fetch = (async (input: RequestInfo | URL) => { const url = new URL(input instanceof Request ? input.url : String(input)) if (url.pathname === "/session") session.push(url) + if (url.pathname === "/experimental/session") experimentalSession.push(url) const overridden = await override?.(url) if (overridden) return overridden if (url.pathname === "/api/event" && events) return events.response() @@ -101,9 +103,10 @@ export function createFetch(override?: FetchHandler, events?: ReturnTypen", "session_list": "l", + "session_toggle_global_list": "none", "session_timeline": "g", "session_fork": "none", "session_rename": "ctrl+r",