feat(tui): add session switcher plugin (#29861)

This commit is contained in:
Shoubhit Dash 2026-05-29 15:39:34 +05:30 committed by GitHub
parent 710ed7cb33
commit 8f8b161cae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 826 additions and 0 deletions

View file

@ -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.

View file

@ -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<string>()
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<string> | 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<string | undefined>(
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<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <SessionSwitcherDialog />)
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(() => (
<DialogSessionDeleteFailed
session={session.title}
workspace={workspace?.name ?? session.workspaceID!}
onDone={list}
onDelete={async () => {
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<ReturnType<typeof sessions>>) {
return sessionsList
.filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => x.id)
}
const [browseOrder] = createSignal<string[]>(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<DialogSelectOption<string>[]>(() => {
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<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
function buildOption(id: string, category: string): DialogSelectOption<string> | 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
? () => <Spinner />
: slot !== undefined
? () => <text fg={theme.accent}>{slot}</text>
: 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<string> => x !== undefined)
return [
...pinned.map((id) => buildOption(id, "Pinned")).filter((x): x is DialogSelectOption<string> => 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 = (
<DialogSelect
ref={(value) => (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(() => <DialogSessionRename session={option.value} />)
},
},
]}
footerHints={quickSwitchFooterHints()}
/>
)
return (
<box flexDirection="row" width="100%">
<box flexBasis={68} flexShrink={0}>
{list}
</box>
<box width={1} flexShrink={0} border={["left"]} borderColor={theme.borderSubtle} />
<box flexGrow={1} flexShrink={1} flexDirection="column">
<SessionPreviewPane sessionID={focusedSession} session={focusedSessionInfo} />
</box>
</box>
)
}
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}`
}

View file

@ -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(() => <SessionSwitcherDialog />)
},
},
],
})
}
const plugin: InternalTuiPlugin = {
id,
tui,
}
export default plugin

View file

@ -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<typeof useSDK>
type Sync = ReturnType<typeof useSync>
const messageCache = new Map<string, Promise<WithParts[]>>()
const diffCache = new Map<string, Promise<SnapshotFileDiff[]>>()
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<WithParts[]> {
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<SnapshotFileDiff[]> {
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<T>(initial: T, ms: number): [Accessor<T>, (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<string | undefined>
session?: Accessor<SdkSession | undefined>
}) {
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 (
<box
flexDirection="column"
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
gap={1}
maxHeight={maxHeight()}
overflow="hidden"
>
<Show
when={session()}
fallback={
<text fg={theme.textMuted} wrapMode="word">
No session selected
</text>
}
>
{(s) => (
<>
<Header session={s()} statusLabel={statusLabel()} diff={diffSummary()} />
<Show when={loading()}>
<Spinner>loading preview...</Spinner>
</Show>
<Show
when={exchange()}
fallback={
<Show when={!loading()}>
<text fg={theme.textMuted} wrapMode="word">
No messages yet
</text>
</Show>
}
>
{(ex) => <Exchange exchange={ex()} />}
</Show>
</>
)}
</Show>
</box>
)
}
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 (
<box flexDirection="column" gap={0} flexShrink={0}>
<Row height={1}>
<text fg={theme.text} attributes={TextAttributes.BOLD} wrapMode="none" overflow="hidden">
{title()}
</text>
</Row>
<Show when={modelAgent()}>
<Row height={1}>
<text fg={theme.text} wrapMode="none" overflow="hidden">
{modelAgent()}
</text>
</Row>
</Show>
<Row height={1}>
<text fg={theme.textMuted} wrapMode="none" overflow="hidden">
<span style={{ fg: props.statusLabel.color }}>{props.statusLabel.text}</span>
<span>{statusRest()}</span>
</text>
</Row>
<Show when={props.diff}>{(d) => <DiffRow diff={d()} />}</Show>
</box>
)
}
function Row(props: { height: number; children: JSX.Element }) {
return (
<box height={props.height} flexShrink={0} overflow="hidden">
{props.children}
</box>
)
}
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 (
<Row height={1}>
<text wrapMode="none" overflow="hidden">
<Show when={showAdds()}>
<span style={{ fg: theme.diffAdded }}>+{props.diff.additions}</span>
</Show>
<Show when={showAdds() && showDels()}>
<span> </span>
</Show>
<Show when={showDels()}>
<span style={{ fg: theme.diffRemoved }}>{props.diff.deletions}</span>
</Show>
</text>
</Row>
)
}
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 (
<box flexDirection="column" gap={1}>
<Show when={userText()}>
<text fg={theme.textMuted} wrapMode="word">
<span style={{ fg: theme.textMuted }}> </span>
{userText()!}
</text>
</Show>
<Show when={assistantMarkdown()}>
<markdown
content={assistantMarkdown()!}
syntaxStyle={syntax()}
streaming={false}
internalBlockMode="top-level"
tableOptions={{ style: "columns" }}
conceal={false}
fg={theme.markdownText}
bg={theme.backgroundPanel}
/>
</Show>
<Show when={!userText() && !assistantMarkdown()}>
<NonTextHint exchange={props.exchange} />
</Show>
</box>
)
}
function NonTextHint(props: { exchange: { user?: WithParts; assistant?: WithParts } }) {
const { theme } = useTheme()
const summary = createMemo(() => {
const counts: Record<string, number> = {}
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 (
<text fg={theme.textMuted} wrapMode="word">
<Show when={summary()} fallback="No text content in the latest messages">
Latest exchange has no text content ({summary()})
</Show>
</text>
)
}

View file

@ -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
}

View file

@ -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<RuntimeFlags.Info, "experimentalE
WhichKey,
DiffViewer,
...(flags.experimentalEventSystem ? [SessionV2Debug] : []),
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHER ? [SessionSwitcher] : []),
]
}

View file

@ -65,6 +65,7 @@ export interface DialogSelectOption<T = any> {
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
selected: DialogSelectOption<T> | undefined
}
export function DialogSelect<T>(props: DialogSelectProps<T>) {
@ -337,6 +338,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
get filtered() {
return filtered()
},
get selected() {
return selected()
},
}
props.ref?.(ref)