From d97523adbd1f559dd05b4ae0c037e6792579c7ae Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 9 Jun 2026 10:38:48 -0400 Subject: [PATCH] refactor(tui): remove experimental session switcher --- packages/core/src/flag/flag.ts | 1 - packages/opencode/src/plugin/tui/internal.ts | 2 - packages/tui/src/feature-plugins/builtins.ts | 3 - .../src/feature-plugins/session/dialog.tsx | 356 ------------------ .../tui/src/feature-plugins/session/index.tsx | 32 -- .../feature-plugins/session/preview-pane.tsx | 288 -------------- .../tui/src/feature-plugins/session/util.tsx | 54 --- packages/tui/src/ui/dialog-select.tsx | 4 - 8 files changed, 740 deletions(-) delete mode 100644 packages/tui/src/feature-plugins/session/dialog.tsx delete mode 100644 packages/tui/src/feature-plugins/session/index.tsx delete mode 100644 packages/tui/src/feature-plugins/session/preview-pane.tsx delete mode 100644 packages/tui/src/feature-plugins/session/util.tsx diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 804a385bca3..b8b655c883c 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -46,7 +46,6 @@ export const Flag = { OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), - OPENCODE_EXPERIMENTAL_SESSION_SWITCHER: enabledByExperimental("OPENCODE_EXPERIMENTAL_SESSION_SWITCHER"), // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. diff --git a/packages/opencode/src/plugin/tui/internal.ts b/packages/opencode/src/plugin/tui/internal.ts index e429530dcfd..cc33b15beee 100644 --- a/packages/opencode/src/plugin/tui/internal.ts +++ b/packages/opencode/src/plugin/tui/internal.ts @@ -1,4 +1,3 @@ -import { Flag } from "@opencode-ai/core/flag/flag" import { createBuiltinPlugins, type BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" import type { RuntimeFlags } from "@/effect/runtime-flags" @@ -7,6 +6,5 @@ export type InternalTuiPlugin = BuiltinTuiPlugin export function internalTuiPlugins(flags: Pick): InternalTuiPlugin[] { return createBuiltinPlugins({ experimentalEventSystem: flags.experimentalEventSystem, - experimentalSessionSwitcher: Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHER, }) } diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts index 46e122d7130..2641a64f2bf 100644 --- a/packages/tui/src/feature-plugins/builtins.ts +++ b/packages/tui/src/feature-plugins/builtins.ts @@ -1,7 +1,6 @@ import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" import HomeFooter from "./home/footer" import HomeTips from "./home/tips" -import SessionSwitcher from "./session" import SidebarContext from "./sidebar/context" import SidebarFiles from "./sidebar/files" import SidebarFooter from "./sidebar/footer" @@ -22,7 +21,6 @@ export type BuiltinTuiPlugin = Omit & { export function createBuiltinPlugins(options: { experimentalEventSystem: boolean - experimentalSessionSwitcher: boolean }): BuiltinTuiPlugin[] { return [ HomeFooter, @@ -38,6 +36,5 @@ export function createBuiltinPlugins(options: { WhichKey, DiffViewer, ...(options.experimentalEventSystem ? [SessionV2Debug] : []), - ...(options.experimentalSessionSwitcher ? [SessionSwitcher] : []), ] } diff --git a/packages/tui/src/feature-plugins/session/dialog.tsx b/packages/tui/src/feature-plugins/session/dialog.tsx deleted file mode 100644 index 1907a92dd4c..00000000000 --- a/packages/tui/src/feature-plugins/session/dialog.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import { useDialog } from "../../ui/dialog" -import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "../../ui/dialog-select" -import { useRoute } from "../../context/route" -import { useSync } from "../../context/sync" -import { useProject } from "../../context/project" -import { useTheme } from "../../context/theme" -import { useSDK } from "../../context/sdk" -import { useLocal } from "../../context/local" -import { useToast } from "../../ui/toast" -import { useCommandShortcut } from "../../keymap" -import { createEffect, createMemo, createResource, createSignal, on, Show, untrack } from "solid-js" -import { useTerminalDimensions } from "@opentui/solid" -import { Spinner } from "../../component/spinner" -import { DialogSessionRename } from "../../component/dialog-session-rename" -import { DialogSessionDeleteFailed } from "../../component/dialog-session-delete-failed" -import { - openWorkspaceSelect, - type WorkspaceSelection, - warpWorkspaceSession, -} from "../../component/dialog-workspace-create" -import { createDebouncedSignal } from "../../util/signal" -import { errorMessage } from "../../util/error" -import { SessionPreviewPane, createLeadingTrailingSignal } from "./preview-pane" -import { relativeTime } from "./util" - -export function SessionSwitcherDialog() { - const dialog = useDialog() - const route = useRoute() - const sync = useSync() - const project = useProject() - const { theme } = useTheme() - const sdk = useSDK() - const local = useLocal() - const toast = useToast() - const dimensions = useTerminalDimensions() - const [toDelete, setToDelete] = createSignal() - const [search, setSearch] = createDebouncedSignal("", 150) - const deleteHint = useCommandShortcut("session.delete") - const quickSwitch1 = useCommandShortcut("session.quick_switch.1") - const quickSwitch9 = useCommandShortcut("session.quick_switch.9") - let select: DialogSelectRef | undefined - - const [searchResults, { refetch }] = createResource( - () => ({ query: search(), 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 ?? [] - }, - ) - - const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - const sessions = createMemo(() => searchResults() ?? sync.data.session) - const [focusedSession, setFocusedSession, scheduleFocused] = createLeadingTrailingSignal( - undefined, - 150, - ) - const focusedSessionInfo = createMemo(() => { - const id = focusedSession() - if (!id) return undefined - return sessions().find((session) => session.id === id) ?? sync.data.session.find((session) => session.id === id) - }) - - function recoverFailed(session: NonNullable[number]>) { - const workspace = project.workspace.get(session.workspaceID!) - const list = () => dialog.replace(() => ) - const warp = async (selection: WorkspaceSelection) => { - const workspaceID = await (async () => { - if (selection.type === "none") return null - if (selection.type === "existing") return selection.workspaceID - const result = await sdk.client.experimental.workspace - .create({ type: selection.workspaceType, branch: null }) - .catch(() => undefined) - const created = result?.data - if (!created) { - toast.show({ - message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, - variant: "error", - }) - return - } - await project.workspace.sync() - return created.id - })() - if (workspaceID === undefined) return - await warpWorkspaceSession({ - dialog, - sdk, - sync, - project, - toast, - sourceWorkspaceID: session.workspaceID, - workspaceID, - sessionID: session.id, - copyChanges: false, - done: list, - }) - } - dialog.replace(() => ( - { - const current = currentSessionID() - const info = current ? sync.data.session.find((item) => item.id === current) : undefined - const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! }) - if (result.error) { - toast.show({ - variant: "error", - title: "Failed to delete workspace", - message: errorMessage(result.error), - }) - return false - } - await project.workspace.sync() - await sync.session.refresh() - if (search()) await refetch() - if (info?.workspaceID === session.workspaceID) { - route.navigate({ type: "home" }) - } - return true - }} - onRestore={() => { - void openWorkspaceSelect({ - dialog, - sdk, - sync, - project, - toast, - onSelect: (selection) => { - void warp(selection) - }, - }) - return false - }} - /> - )) - } - - function orderByRecency(sessionsList: NonNullable>) { - return sessionsList - .filter((x) => x.parentID === undefined) - .toSorted((a, b) => b.time.updated - a.time.updated) - .map((x) => x.id) - } - - const [browseOrder] = createSignal(orderByRecency(sync.data.session)) - - const quickSwitchHint = createMemo(() => { - const first = quickSwitch1() - const last = quickSwitch9() - if (!first || !last) return undefined - return quickSwitchRange(first, last) - }) - const options = createMemo[]>(() => { - const today = new Date().toDateString() - const sessionMap = new Map( - sessions() - .filter((x) => x.parentID === undefined) - .map((x) => [x.id, x]), - ) - - const searchResult = searchResults() - const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() - - const pinned = local.session.pinned().filter((id) => sessionMap.has(id)) - const pinnedSet = new Set(pinned) - const slotByID = new Map(local.session.slots().map((id, i) => [id, i + 1])) - - function buildOption(id: string, category: string): DialogSelectOption | undefined { - const x = sessionMap.get(id) - if (!x) return undefined - const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined - - const footer = relativeTime(x.time.updated) - const isWorktree = workspace?.type === "worktree" - - const isDeleting = toDelete() === x.id - const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" || status?.type === "retry" - const slot = slotByID.get(x.id) - const gutter = - slot !== undefined || isWorking - ? () => ( - - - {slot} - - - - - - ) - : undefined - const titleText = isDeleting ? `Press ${deleteHint()} again to confirm` : isWorktree ? `⎇ ${x.title}` : x.title - return { - title: titleText, - bg: isDeleting ? theme.error : undefined, - value: x.id, - category, - categoryView: - category === "Pinned" ? ( - - - Pinned - - - {(hint) => · switch {hint()}} - - - ) : undefined, - footer, - gutter, - } - } - - const remaining = displayOrder - .filter((id) => !pinnedSet.has(id)) - .map((id) => { - const x = sessionMap.get(id) - if (!x) return undefined - const label = new Date(x.time.updated).toDateString() - return buildOption(id, label === today ? "Today" : label) - }) - .filter((x): x is DialogSelectOption => x !== undefined) - - return [ - ...pinned.map((id) => buildOption(id, "Pinned")).filter((x): x is DialogSelectOption => x !== undefined), - ...remaining, - ] - }) - - createEffect( - on([options, currentSessionID], ([items, current]) => { - const selected = untrack(() => select?.selected) - const selectedID = selected && items.some((item) => item.value === selected.value) ? selected.value : undefined - const currentID = current && items.some((item) => item.value === current) ? current : undefined - setFocusedSession(selectedID ?? currentID ?? items[0]?.value) - }), - ) - - const showPreview = createMemo(() => dimensions().width >= 100) - const height = createMemo(() => Math.max(8, Math.floor(dimensions().height / 2) - 4)) - - createEffect(() => { - dialog.setSize(showPreview() ? "xlarge" : "large") - }) - - const list = ( - (select = value)} - title="Sessions" - options={options()} - skipFilter={true} - current={currentSessionID()} - onFilter={setSearch} - onMove={(option) => { - setToDelete(undefined) - scheduleFocused(option.value) - }} - onSelect={(option) => { - route.navigate({ - type: "session", - sessionID: option.value, - }) - dialog.clear() - }} - actions={[ - { - command: "session.pin.toggle", - title: "pin/unpin", - onTrigger: (option: { value: string }) => { - local.session.togglePin(option.value) - queueMicrotask(() => select?.moveTo(option.value)) - }, - }, - { - command: "session.delete", - title: "delete", - onTrigger: async (option) => { - if (toDelete() === option.value) { - const session = sessions().find((item) => item.id === option.value) - const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined - - try { - const result = await sdk.client.session.delete({ - sessionID: option.value, - }) - if (result.error) { - if (session?.workspaceID) { - recoverFailed(session) - } else { - toast.show({ - variant: "error", - title: "Failed to delete session", - message: errorMessage(result.error), - }) - } - setToDelete(undefined) - return - } - } catch (err) { - if (session?.workspaceID) { - recoverFailed(session) - } else { - toast.show({ - variant: "error", - title: "Failed to delete session", - message: errorMessage(err), - }) - } - setToDelete(undefined) - return - } - if (status && status !== "connected") { - await sync.session.refresh() - } - if (search()) await refetch() - setToDelete(undefined) - return - } - setToDelete(option.value) - }, - }, - { - command: "session.rename", - title: "rename", - onTrigger: async (option) => { - dialog.replace(() => ) - }, - }, - ]} - /> - ) - - return ( - - - {list} - - - - - - - - - ) -} - -function quickSwitchRange(first: string, last: string) { - const prefix = first.slice(0, -1) - if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9` - return `${first} through ${last}` -} diff --git a/packages/tui/src/feature-plugins/session/index.tsx b/packages/tui/src/feature-plugins/session/index.tsx deleted file mode 100644 index 514c98e752d..00000000000 --- a/packages/tui/src/feature-plugins/session/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { TuiPlugin } from "@opencode-ai/plugin/tui" -import type { BuiltinTuiPlugin } from "../builtins" -import { SessionSwitcherDialog } from "./dialog" - -const id = "internal:session-switcher" - -const tui: TuiPlugin = async (api) => { - api.keymap.registerLayer({ - priority: 1000, - commands: [ - { - name: "session.list", - title: "Switch session", - category: "Session", - namespace: "palette", - suggested: () => api.state.session.count() > 0, - slashName: "sessions", - slashAliases: ["resume", "continue"], - run() { - api.ui.dialog.replace(() => ) - }, - }, - ], - }) -} - -const plugin: BuiltinTuiPlugin = { - id, - tui, -} - -export default plugin diff --git a/packages/tui/src/feature-plugins/session/preview-pane.tsx b/packages/tui/src/feature-plugins/session/preview-pane.tsx deleted file mode 100644 index 7031ccf8d3f..00000000000 --- a/packages/tui/src/feature-plugins/session/preview-pane.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import { createResource, Show, createMemo, createSignal, onMount, type Accessor, type JSX } from "solid-js" -import { TextAttributes } from "@opentui/core" -import { useTerminalDimensions } from "@opentui/solid" -import { debounce, leadingAndTrailing } from "@solid-primitives/scheduled" -import type { Message, Part, Session as SdkSession } from "@opencode-ai/sdk/v2" -import { useTheme } from "../../context/theme" -import { useSDK } from "../../context/sdk" -import { useSync } from "../../context/sync" -import { Locale } from "../../util/locale" -import { Spinner } from "../../component/spinner" -import { extractMessageMarkdown, extractMessageText, relativeTime } from "./util" - -type WithParts = { info: Message; parts: Part[] } - -type Sdk = ReturnType -type Sync = ReturnType - -const messageCache = new Map>() - -function cacheKey(sessionID: string, version: number) { - return `${sessionID}:${version}` -} - -function hydrateFromSync(sync: Sync, sessionID: string): WithParts[] | undefined { - const infos = sync.data.message[sessionID] - if (!infos || infos.length === 0) return undefined - return infos.map((info) => ({ info, parts: sync.data.part[info.id] ?? [] })) -} - -function loadMessages(sdk: Sdk, sessionID: string, version: number): Promise { - const key = cacheKey(sessionID, version) - const cached = messageCache.get(key) - if (cached) return cached - - const promise = sdk.client.session - .messages({ sessionID, limit: 50 }) - .then((res) => { - if (res.error) throw res.error - return (res.data as WithParts[] | undefined) ?? [] - }) - .catch((error) => { - messageCache.delete(key) - throw error - }) - messageCache.set(key, promise) - return promise -} - -export function prefetchPreviews(sdk: Sdk, sync: Sync, sessionIDs: readonly string[]) { - for (const id of sessionIDs) { - const version = sync.data.session.find((session) => session.id === id)?.time.updated ?? 0 - if (!hydrateFromSync(sync, id)) loadMessages(sdk, id, version).catch(() => {}) - } -} - -export function createLeadingTrailingSignal(initial: T, ms: number): [Accessor, (v: T) => void, (v: T) => void] { - const [get, set] = createSignal(initial) - const setNow = (v: T) => set(() => v) - const schedule = leadingAndTrailing(debounce, setNow, ms) - return [get, setNow, schedule] -} - -export function SessionPreviewPane(props: { - sessionID: Accessor - session?: Accessor -}) { - const { theme } = useTheme() - const sdk = useSDK() - const sync = useSync() - const dimensions = useTerminalDimensions() - - const maxHeight = createMemo(() => Math.max(8, Math.floor(dimensions().height / 2) - 4)) - const session = createMemo(() => { - const provided = props.session?.() - if (provided) return provided - const id = props.sessionID() - if (!id) return undefined - return sync.data.session.find((s) => s.id === id) - }) - - const status = createMemo(() => { - const id = props.sessionID() - if (!id) return undefined - return sync.data.session_status?.[id]?.type - }) - - onMount(() => { - const top = sync.data.session - .filter((s) => s.parentID === undefined) - .slice() - .sort((a, b) => b.time.updated - a.time.updated) - .slice(0, 5) - .map((s) => s.id) - prefetchPreviews(sdk, sync, top) - }) - - const syncedMessages = createMemo(() => { - const id = props.sessionID() - if (!id) return undefined - return hydrateFromSync(sync, id) - }) - - const [fetchedMessages] = createResource( - () => { - const id = props.sessionID() - if (!id || syncedMessages()) return undefined - return { sessionID: id, version: session()?.time.updated ?? 0 } - }, - async (input) => loadMessages(sdk, input.sessionID, input.version), - ) - - const messages = createMemo(() => syncedMessages() ?? fetchedMessages() ?? []) - - const exchange = createMemo(() => { - const items = messages() - if (!items || items.length === 0) return undefined - const sorted = items.toSorted((a, b) => messageCreated(a) - messageCreated(b)) - const user = sorted.findLast((item) => messageRole(item) === "user") - const assistant = user - ? sorted.findLast((item) => messageRole(item) === "assistant" && messageParentID(item) === user.info.id) - : sorted.findLast((item) => messageRole(item) === "assistant") - return { user, assistant } - }) - - const loading = createMemo(() => fetchedMessages.loading && !exchange()) - - const statusLabel = createMemo(() => { - const s = status() - if (s === "busy") return "working" - if (s === "retry") return "retrying" - return "idle" - }) - - return ( - - - No session selected - - } - > - {(s) => ( - <> -
- - loading preview... - - - - {fetchedMessages.error ? "Preview unavailable" : "No messages yet"} - - - } - > - {(ex) => } - - - )} - - - ) -} - -function messageRole(item: WithParts) { - return (item.info as { role?: string }).role -} - -function messageCreated(item: WithParts) { - return (item.info.time as { created?: number }).created ?? 0 -} - -function messageParentID(item: WithParts) { - return (item.info as { parentID?: string }).parentID -} - -const ROW_WIDTH = 40 - -function Header(props: { session: SdkSession; statusLabel: string }) { - const { theme } = useTheme() - const title = createMemo(() => Locale.truncate(props.session.title, ROW_WIDTH)) - const statusRest = createMemo(() => { - const joined = ` · ${relativeTime(props.session.time.updated)}` - return Locale.truncate(joined, Math.max(0, ROW_WIDTH - props.statusLabel.length)) - }) - - return ( - - - - {title()} - - - - - {props.statusLabel} - {statusRest()} - - - - ) -} - -function Row(props: { height: number; children: JSX.Element }) { - return ( - - {props.children} - - ) -} - -const PROMPT_MAX_CHARS = 240 -const REPLY_MAX_LINES = 12 -const REPLY_MAX_CHARS = 800 - -function Exchange(props: { exchange: { user?: WithParts; assistant?: WithParts } }) { - const { theme, syntax } = useTheme() - const userText = createMemo(() => - props.exchange.user ? extractMessageText(props.exchange.user.parts, PROMPT_MAX_CHARS) : undefined, - ) - const assistantMarkdown = createMemo(() => - props.exchange.assistant - ? extractMessageMarkdown(props.exchange.assistant.parts, REPLY_MAX_LINES, REPLY_MAX_CHARS) - : undefined, - ) - - return ( - - - - - {userText()!} - - - - - - - - - - ) -} - -function NonTextHint(props: { exchange: { user?: WithParts; assistant?: WithParts } }) { - const { theme } = useTheme() - const summary = createMemo(() => { - const counts: Record = {} - for (const item of [props.exchange.user, props.exchange.assistant]) { - if (!item) continue - for (const part of item.parts) { - counts[part.type] = (counts[part.type] ?? 0) + 1 - } - } - return Object.entries(counts) - .map(([k, n]) => `${n} ${k}`) - .join(", ") - }) - return ( - - - Latest exchange has no text content ({summary()}) - - - ) -} diff --git a/packages/tui/src/feature-plugins/session/util.tsx b/packages/tui/src/feature-plugins/session/util.tsx deleted file mode 100644 index 714b1fda0a1..00000000000 --- a/packages/tui/src/feature-plugins/session/util.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { Part } from "@opencode-ai/sdk/v2" -import { Locale } from "../../util/locale" - -export function relativeTime(timestamp: number): string { - const diff = Date.now() - timestamp - if (diff < 0) return "just now" - const seconds = Math.floor(diff / 1000) - if (seconds < 60) return "just now" - const minutes = Math.floor(seconds / 60) - if (minutes < 60) return `${minutes}m ago` - const hours = Math.floor(minutes / 60) - if (hours < 24) return `${hours}h ago` - const days = Math.floor(hours / 24) - if (days < 7) return `${days}d ago` - const d = new Date(timestamp) - return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) -} - -export function extractMessageText(parts: readonly Part[], maxLength: number): string { - const joined = collectTextParts(parts).join(" ").replace(/\s+/g, " ").trim() - return Locale.truncate(joined, maxLength) -} - -export function extractMessageMarkdown(parts: readonly Part[], maxLines: number, maxChars: number): string { - const joined = collectTextParts(parts).join("\n\n").trim() - if (!joined) return joined - - let truncated = joined - const lines = truncated.split("\n") - if (lines.length > maxLines) { - truncated = lines.slice(0, maxLines).join("\n") - } - if (truncated.length > maxChars) { - truncated = truncated.slice(0, maxChars).trimEnd() - } - if (truncated.length === joined.length) return joined - // Close any unterminated fenced code block so the renderer doesn't keep - // the rest of the panel in "code mode". - const fences = (truncated.match(/^```/gm) ?? []).length - if (fences % 2 === 1) truncated += "\n```" - return truncated + "\n\n…" -} - -function collectTextParts(parts: readonly Part[]): string[] { - const chunks: string[] = [] - for (const part of parts) { - if (part.type !== "text") continue - const p = part as Part & { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } - if (p.synthetic || p.ignored) continue - if (!p.text) continue - chunks.push(p.text) - } - return chunks -} diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 8b8b2ff2eb5..4b9b8dbe5cd 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -72,7 +72,6 @@ export interface DialogSelectOption { export type DialogSelectRef = { filter: string filtered: DialogSelectOption[] - selected: DialogSelectOption | undefined moveTo(value: T): void } @@ -409,9 +408,6 @@ export function DialogSelect(props: DialogSelectProps) { get filtered() { return filtered() }, - get selected() { - return selected() - }, moveTo(value) { const index = flat().findIndex((option) => isDeepEqual(option.value, value)) if (index >= 0) moveTo(index, true)