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",