From 8f8b161cae248687810e80477ef3baa561e099bb Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 29 May 2026 15:39:34 +0530 Subject: [PATCH] feat(tui): add session switcher plugin (#29861) --- packages/core/src/flag/flag.ts | 1 + .../tui/feature-plugins/session/dialog.tsx | 335 +++++++++++++++ .../cmd/tui/feature-plugins/session/index.tsx | 32 ++ .../feature-plugins/session/preview-pane.tsx | 382 ++++++++++++++++++ .../cmd/tui/feature-plugins/session/util.tsx | 69 ++++ .../src/cli/cmd/tui/plugin/internal.ts | 3 + .../src/cli/cmd/tui/ui/dialog-select.tsx | 4 + 7 files changed, 826 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/session/dialog.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/session/index.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/session/preview-pane.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/session/util.tsx diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 504e156ac2..c9269b9c26 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -47,6 +47,7 @@ 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/cli/cmd/tui/feature-plugins/session/dialog.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/dialog.tsx new file mode 100644 index 0000000000..816546b868 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/dialog.tsx @@ -0,0 +1,335 @@ +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select" +import { useRoute } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { useProject } from "@tui/context/project" +import { useTheme } from "@tui/context/theme" +import { useSDK } from "@tui/context/sdk" +import { useLocal } from "@tui/context/local" +import { useToast } from "@tui/ui/toast" +import { useCommandShortcut } from "@tui/keymap" +import { createEffect, createMemo, createResource, createSignal, on, onMount, untrack } from "solid-js" +import { Spinner } from "@tui/component/spinner" +import { DialogSessionRename } from "@tui/component/dialog-session-rename" +import { DialogSessionDeleteFailed } from "@tui/component/dialog-session-delete-failed" +import { + openWorkspaceSelect, + type WorkspaceSelection, + warpWorkspaceSession, +} from "@tui/component/dialog-workspace-create" +import { createDebouncedSignal } from "@tui/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 [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 quickSwitchFooterHints = createMemo(() => { + const hint = quickSwitchHint() + return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : [] + }) + + 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 = isWorking + ? () => + : slot !== undefined + ? () => {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, + 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) + }), + ) + + onMount(() => { + dialog.setSize("xlarge") + }) + + 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) + }, + }, + { + 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(() => ) + }, + }, + ]} + footerHints={quickSwitchFooterHints()} + /> + ) + + 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/opencode/src/cli/cmd/tui/feature-plugins/session/index.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/index.tsx new file mode 100644 index 0000000000..50055d3bdb --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/index.tsx @@ -0,0 +1,32 @@ +import type { TuiPlugin } from "@opencode-ai/plugin/tui" +import type { InternalTuiPlugin } from "../../plugin/internal" +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: InternalTuiPlugin = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/preview-pane.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/preview-pane.tsx new file mode 100644 index 0000000000..8e75745a6c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/preview-pane.tsx @@ -0,0 +1,382 @@ +import { createResource, Show, createMemo, createSignal, onMount, type Accessor, type JSX } from "solid-js" +import { TextAttributes, type RGBA } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" +import { debounce, leadingAndTrailing } from "@solid-primitives/scheduled" +import type { Message, Part, Session as SdkSession, SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import { useTheme } from "@tui/context/theme" +import { useSDK } from "@tui/context/sdk" +import { useSync } from "@tui/context/sync" +import { Locale } from "@/util/locale" +import { Spinner } from "@tui/component/spinner" +import { extractMessageMarkdown, extractMessageText, formatDiffSummary, relativeTime, shortModelLabel } from "./util" + +type WithParts = { info: Message; parts: Part[] } + +type Sdk = ReturnType +type Sync = ReturnType + +const messageCache = new Map>() +const diffCache = 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) messageCache.delete(key) + return (res.data as WithParts[] | undefined) ?? [] + }) + .catch(() => { + messageCache.delete(key) + return [] as WithParts[] + }) + messageCache.set(key, promise) + return promise +} + +function loadDiff(sdk: Sdk, sessionID: string, version: number): Promise { + const key = cacheKey(sessionID, version) + const cached = diffCache.get(key) + if (cached) return cached + + const promise = sdk.client.session + .diff({ sessionID }) + .then((res) => { + if (res.error) diffCache.delete(key) + return (res.data as SnapshotFileDiff[] | undefined) ?? [] + }) + .catch(() => { + diffCache.delete(key) + return [] as SnapshotFileDiff[] + }) + diffCache.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(() => {}) + if (!sync.data.session_diff[id]?.length) loadDiff(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 syncedDiff = createMemo(() => { + const id = props.sessionID() + if (!id) return undefined + const diff = sync.data.session_diff[id] + return diff && diff.length > 0 ? (diff as SnapshotFileDiff[]) : undefined + }) + + 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 [fetchedDiff] = createResource( + () => { + const id = props.sessionID() + if (!id || syncedDiff()) return undefined + return { sessionID: id, version: session()?.time.updated ?? 0 } + }, + async (input) => loadDiff(sdk, input.sessionID, input.version), + ) + + const messages = createMemo(() => syncedMessages() ?? fetchedMessages() ?? []) + const diff = createMemo(() => syncedDiff() ?? fetchedDiff() ?? []) + + const diffSummary = createMemo(() => { + const live = diff() + if (live && live.length > 0) { + let additions = 0 + let deletions = 0 + for (const file of live) { + additions += file.additions ?? 0 + deletions += file.deletions ?? 0 + } + return formatDiffSummary({ additions, deletions, files: live.length }) + } + return formatDiffSummary(session()?.summary) + }) + + 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 || fetchedDiff.loading) && !exchange()) + + const statusLabel = createMemo(() => { + const s = status() + if (s === "busy") return { text: "working", color: theme.warning } + if (s === "retry") return { text: "retrying", color: theme.warning } + return { text: "idle", color: theme.textMuted } + }) + + return ( + + + No session selected + + } + > + {(s) => ( + <> +
+ + loading preview... + + + + 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: { text: string; color: RGBA } + diff: { additions: number; deletions: number; files: number } | undefined +}) { + const { theme } = useTheme() + const title = createMemo(() => Locale.truncate(props.session.title, ROW_WIDTH)) + const modelAgent = createMemo(() => { + const m = shortModelLabel(props.session.model) + const a = props.session.agent ?? "" + if (m && a) return Locale.truncate(`${m} · ${a}`, ROW_WIDTH) + if (m) return Locale.truncate(m, ROW_WIDTH) + if (a) return Locale.truncate(a, ROW_WIDTH) + return "" + }) + const statusRest = createMemo(() => { + const joined = ` · ${relativeTime(props.session.time.updated)}` + return Locale.truncate(joined, Math.max(0, ROW_WIDTH - props.statusLabel.text.length)) + }) + + return ( + + + + {title()} + + + + + + {modelAgent()} + + + + + + {props.statusLabel.text} + {statusRest()} + + + {(d) => } + + ) +} + +function Row(props: { height: number; children: JSX.Element }) { + return ( + + {props.children} + + ) +} + +function DiffRow(props: { diff: { additions: number; deletions: number; files: number } }) { + const { theme } = useTheme() + const showAdds = () => props.diff.additions > 0 + const showDels = () => props.diff.deletions > 0 + if (!showAdds() && !showDels()) return null + return ( + + + + +{props.diff.additions} + + + + + + −{props.diff.deletions} + + + + ) +} + +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/opencode/src/cli/cmd/tui/feature-plugins/session/util.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/util.tsx new file mode 100644 index 0000000000..7b6507c4a8 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/util.tsx @@ -0,0 +1,69 @@ +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 +} + +export function formatDiffSummary(summary: { additions: number; deletions: number; files: number } | undefined): + | { additions: number; deletions: number; files: number } + | undefined { + if (!summary) return undefined + if (!summary.additions && !summary.deletions && !summary.files) return undefined + return summary +} + +export function shortModelLabel(model: { id: string; providerID?: string; variant?: string } | undefined): string { + if (!model) return "" + const id = model.id ?? "" + const stripped = model.providerID && id.startsWith(`${model.providerID}/`) ? id.slice(model.providerID.length + 1) : id + return model.variant ? `${stripped} (${model.variant})` : stripped +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index d85b38c569..0180ea69d2 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -11,6 +11,8 @@ import Notifications from "../feature-plugins/system/notifications" import SessionV2Debug from "../feature-plugins/system/session-v2" import WhichKey from "../feature-plugins/system/which-key" import DiffViewer from "../feature-plugins/system/diff-viewer" +import SessionSwitcher from "../feature-plugins/session" +import { Flag } from "@opencode-ai/core/flag/flag" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" import type { RuntimeFlags } from "@/effect/runtime-flags" @@ -35,5 +37,6 @@ export function internalTuiPlugins(flags: Pick { export type DialogSelectRef = { filter: string filtered: DialogSelectOption[] + selected: DialogSelectOption | undefined } export function DialogSelect(props: DialogSelectProps) { @@ -337,6 +338,9 @@ export function DialogSelect(props: DialogSelectProps) { get filtered() { return filtered() }, + get selected() { + return selected() + }, } props.ref?.(ref)