feat(tui): improve experimental session switcher (#30738)

This commit is contained in:
Shoubhit Dash
2026-06-04 19:22:01 +05:30
committed by GitHub
parent caea930074
commit 6d4f3b4ab2
4 changed files with 62 additions and 146 deletions
@@ -8,7 +8,8 @@ import { useSDK } from "@tui/context/sdk"
import { useLocal } from "@tui/context/local" import { useLocal } from "@tui/context/local"
import { useToast } from "@tui/ui/toast" import { useToast } from "@tui/ui/toast"
import { useCommandShortcut } from "@tui/keymap" import { useCommandShortcut } from "@tui/keymap"
import { createEffect, createMemo, createResource, createSignal, on, onMount, untrack } from "solid-js" import { createEffect, createMemo, createResource, createSignal, on, Show, untrack } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { Spinner } from "@tui/component/spinner" import { Spinner } from "@tui/component/spinner"
import { DialogSessionRename } from "@tui/component/dialog-session-rename" import { DialogSessionRename } from "@tui/component/dialog-session-rename"
import { DialogSessionDeleteFailed } from "@tui/component/dialog-session-delete-failed" import { DialogSessionDeleteFailed } from "@tui/component/dialog-session-delete-failed"
@@ -31,6 +32,7 @@ export function SessionSwitcherDialog() {
const sdk = useSDK() const sdk = useSDK()
const local = useLocal() const local = useLocal()
const toast = useToast() const toast = useToast()
const dimensions = useTerminalDimensions()
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
const deleteHint = useCommandShortcut("session.delete") const deleteHint = useCommandShortcut("session.delete")
@@ -151,11 +153,6 @@ export function SessionSwitcherDialog() {
if (!first || !last) return undefined if (!first || !last) return undefined
return quickSwitchRange(first, last) 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 options = createMemo<DialogSelectOption<string>[]>(() => {
const today = new Date().toDateString() const today = new Date().toDateString()
const sessionMap = new Map( const sessionMap = new Map(
@@ -183,10 +180,18 @@ export function SessionSwitcherDialog() {
const status = sync.data.session_status?.[x.id] const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy" || status?.type === "retry" const isWorking = status?.type === "busy" || status?.type === "retry"
const slot = slotByID.get(x.id) const slot = slotByID.get(x.id)
const gutter = isWorking const gutter =
? () => <Spinner /> slot !== undefined || isWorking
: slot !== undefined ? () => (
? () => <text fg={theme.accent}>{slot}</text> <box flexDirection="row" gap={1}>
<Show when={slot !== undefined}>
<text fg={theme.accent}>{slot}</text>
</Show>
<Show when={isWorking}>
<Spinner />
</Show>
</box>
)
: undefined : undefined
const titleText = isDeleting ? `Press ${deleteHint()} again to confirm` : isWorktree ? `${x.title}` : x.title const titleText = isDeleting ? `Press ${deleteHint()} again to confirm` : isWorktree ? `${x.title}` : x.title
return { return {
@@ -194,6 +199,17 @@ export function SessionSwitcherDialog() {
bg: isDeleting ? theme.error : undefined, bg: isDeleting ? theme.error : undefined,
value: x.id, value: x.id,
category, category,
categoryView:
category === "Pinned" ? (
<text>
<span style={{ fg: theme.accent }}>
<b>Pinned</b>
</span>
<Show when={quickSwitchHint()}>
{(hint) => <span style={{ fg: theme.textMuted }}> · switch {hint()}</span>}
</Show>
</text>
) : undefined,
footer, footer,
gutter, gutter,
} }
@@ -224,8 +240,11 @@ export function SessionSwitcherDialog() {
}), }),
) )
onMount(() => { const showPreview = createMemo(() => dimensions().width >= 100)
dialog.setSize("xlarge") const height = createMemo(() => Math.max(8, Math.floor(dimensions().height / 2) - 4))
createEffect(() => {
dialog.setSize(showPreview() ? "xlarge" : "large")
}) })
const list = ( const list = (
@@ -253,6 +272,7 @@ export function SessionSwitcherDialog() {
title: "pin/unpin", title: "pin/unpin",
onTrigger: (option: { value: string }) => { onTrigger: (option: { value: string }) => {
local.session.togglePin(option.value) local.session.togglePin(option.value)
queueMicrotask(() => select?.moveTo(option.value))
}, },
}, },
{ {
@@ -311,19 +331,20 @@ export function SessionSwitcherDialog() {
}, },
}, },
]} ]}
footerHints={quickSwitchFooterHints()}
/> />
) )
return ( return (
<box flexDirection="row" width="100%"> <box flexDirection="row" width="100%" height={height()}>
<box flexBasis={68} flexShrink={0}> <box flexBasis={showPreview() ? 68 : undefined} flexGrow={showPreview() ? 0 : 1} flexShrink={0}>
{list} {list}
</box> </box>
<box width={1} flexShrink={0} border={["left"]} borderColor={theme.borderSubtle} /> <Show when={showPreview()}>
<box flexGrow={1} flexShrink={1} flexDirection="column"> <box width={1} height={height() - 1} flexShrink={0} border={["left"]} borderColor={theme.borderSubtle} />
<SessionPreviewPane sessionID={focusedSession} session={focusedSessionInfo} /> <box flexGrow={1} flexShrink={1} flexDirection="column">
</box> <SessionPreviewPane sessionID={focusedSession} session={focusedSessionInfo} />
</box>
</Show>
</box> </box>
) )
} }
@@ -1,14 +1,14 @@
import { createResource, Show, createMemo, createSignal, onMount, type Accessor, type JSX } from "solid-js" import { createResource, Show, createMemo, createSignal, onMount, type Accessor, type JSX } from "solid-js"
import { TextAttributes, type RGBA } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import { debounce, leadingAndTrailing } from "@solid-primitives/scheduled" import { debounce, leadingAndTrailing } from "@solid-primitives/scheduled"
import type { Message, Part, Session as SdkSession, SnapshotFileDiff } from "@opencode-ai/sdk/v2" import type { Message, Part, Session as SdkSession } from "@opencode-ai/sdk/v2"
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { Spinner } from "@tui/component/spinner" import { Spinner } from "@tui/component/spinner"
import { extractMessageMarkdown, extractMessageText, formatDiffSummary, relativeTime, shortModelLabel } from "./util" import { extractMessageMarkdown, extractMessageText, relativeTime } from "./util"
type WithParts = { info: Message; parts: Part[] } type WithParts = { info: Message; parts: Part[] }
@@ -16,7 +16,6 @@ type Sdk = ReturnType<typeof useSDK>
type Sync = ReturnType<typeof useSync> type Sync = ReturnType<typeof useSync>
const messageCache = new Map<string, Promise<WithParts[]>>() const messageCache = new Map<string, Promise<WithParts[]>>()
const diffCache = new Map<string, Promise<SnapshotFileDiff[]>>()
function cacheKey(sessionID: string, version: number) { function cacheKey(sessionID: string, version: number) {
return `${sessionID}:${version}` return `${sessionID}:${version}`
@@ -36,41 +35,21 @@ function loadMessages(sdk: Sdk, sessionID: string, version: number): Promise<Wit
const promise = sdk.client.session const promise = sdk.client.session
.messages({ sessionID, limit: 50 }) .messages({ sessionID, limit: 50 })
.then((res) => { .then((res) => {
if (res.error) messageCache.delete(key) if (res.error) throw res.error
return (res.data as WithParts[] | undefined) ?? [] return (res.data as WithParts[] | undefined) ?? []
}) })
.catch(() => { .catch((error) => {
messageCache.delete(key) messageCache.delete(key)
return [] as WithParts[] throw error
}) })
messageCache.set(key, promise) messageCache.set(key, promise)
return 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[]) { export function prefetchPreviews(sdk: Sdk, sync: Sync, sessionIDs: readonly string[]) {
for (const id of sessionIDs) { for (const id of sessionIDs) {
const version = sync.data.session.find((session) => session.id === id)?.time.updated ?? 0 const version = sync.data.session.find((session) => session.id === id)?.time.updated ?? 0
if (!hydrateFromSync(sync, id)) loadMessages(sdk, id, version).catch(() => {}) if (!hydrateFromSync(sync, id)) loadMessages(sdk, id, version).catch(() => {})
if (!sync.data.session_diff[id]?.length) loadDiff(sdk, id, version).catch(() => {})
} }
} }
@@ -121,13 +100,6 @@ export function SessionPreviewPane(props: {
return hydrateFromSync(sync, id) 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 [fetchedMessages] = createResource(
() => { () => {
const id = props.sessionID() const id = props.sessionID()
@@ -137,31 +109,7 @@ export function SessionPreviewPane(props: {
async (input) => loadMessages(sdk, input.sessionID, input.version), 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 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 exchange = createMemo(() => {
const items = messages() const items = messages()
@@ -174,13 +122,13 @@ export function SessionPreviewPane(props: {
return { user, assistant } return { user, assistant }
}) })
const loading = createMemo(() => (fetchedMessages.loading || fetchedDiff.loading) && !exchange()) const loading = createMemo(() => fetchedMessages.loading && !exchange())
const statusLabel = createMemo(() => { const statusLabel = createMemo(() => {
const s = status() const s = status()
if (s === "busy") return { text: "working", color: theme.warning } if (s === "busy") return "working"
if (s === "retry") return { text: "retrying", color: theme.warning } if (s === "retry") return "retrying"
return { text: "idle", color: theme.textMuted } return "idle"
}) })
return ( return (
@@ -191,7 +139,7 @@ export function SessionPreviewPane(props: {
paddingTop={1} paddingTop={1}
paddingBottom={1} paddingBottom={1}
gap={1} gap={1}
maxHeight={maxHeight()} height={maxHeight()}
overflow="hidden" overflow="hidden"
> >
<Show <Show
@@ -204,7 +152,7 @@ export function SessionPreviewPane(props: {
> >
{(s) => ( {(s) => (
<> <>
<Header session={s()} statusLabel={statusLabel()} diff={diffSummary()} /> <Header session={s()} statusLabel={statusLabel()} />
<Show when={loading()}> <Show when={loading()}>
<Spinner>loading preview...</Spinner> <Spinner>loading preview...</Spinner>
</Show> </Show>
@@ -213,7 +161,7 @@ export function SessionPreviewPane(props: {
fallback={ fallback={
<Show when={!loading()}> <Show when={!loading()}>
<text fg={theme.textMuted} wrapMode="word"> <text fg={theme.textMuted} wrapMode="word">
No messages yet {fetchedMessages.error ? "Preview unavailable" : "No messages yet"}
</text> </text>
</Show> </Show>
} }
@@ -241,24 +189,12 @@ function messageParentID(item: WithParts) {
const ROW_WIDTH = 40 const ROW_WIDTH = 40
function Header(props: { function Header(props: { session: SdkSession; statusLabel: string }) {
session: SdkSession
statusLabel: { text: string; color: RGBA }
diff: { additions: number; deletions: number; files: number } | undefined
}) {
const { theme } = useTheme() const { theme } = useTheme()
const title = createMemo(() => Locale.truncate(props.session.title, ROW_WIDTH)) 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 statusRest = createMemo(() => {
const joined = ` · ${relativeTime(props.session.time.updated)}` const joined = ` · ${relativeTime(props.session.time.updated)}`
return Locale.truncate(joined, Math.max(0, ROW_WIDTH - props.statusLabel.text.length)) return Locale.truncate(joined, Math.max(0, ROW_WIDTH - props.statusLabel.length))
}) })
return ( return (
@@ -268,20 +204,12 @@ function Header(props: {
{title()} {title()}
</text> </text>
</Row> </Row>
<Show when={modelAgent()}>
<Row height={1}>
<text fg={theme.text} wrapMode="none" overflow="hidden">
{modelAgent()}
</text>
</Row>
</Show>
<Row height={1}> <Row height={1}>
<text fg={theme.textMuted} wrapMode="none" overflow="hidden"> <text fg={theme.textMuted} wrapMode="none" overflow="hidden">
<span style={{ fg: props.statusLabel.color }}>{props.statusLabel.text}</span> <span>{props.statusLabel}</span>
<span>{statusRest()}</span> <span>{statusRest()}</span>
</text> </text>
</Row> </Row>
<Show when={props.diff}>{(d) => <DiffRow diff={d()} />}</Show>
</box> </box>
) )
} }
@@ -294,28 +222,6 @@ function Row(props: { height: number; children: JSX.Element }) {
) )
} }
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 PROMPT_MAX_CHARS = 240
const REPLY_MAX_LINES = 12 const REPLY_MAX_LINES = 12
const REPLY_MAX_CHARS = 800 const REPLY_MAX_CHARS = 800
@@ -52,19 +52,3 @@ function collectTextParts(parts: readonly Part[]): string[] {
} }
return chunks 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
}
@@ -66,6 +66,7 @@ export type DialogSelectRef<T> = {
filter: string filter: string
filtered: DialogSelectOption<T>[] filtered: DialogSelectOption<T>[]
selected: DialogSelectOption<T> | undefined selected: DialogSelectOption<T> | undefined
moveTo(value: T): void
} }
export function DialogSelect<T>(props: DialogSelectProps<T>) { export function DialogSelect<T>(props: DialogSelectProps<T>) {
@@ -341,6 +342,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
get selected() { get selected() {
return selected() return selected()
}, },
moveTo(value) {
const index = flat().findIndex((option) => isDeepEqual(option.value, value))
if (index >= 0) moveTo(index, true)
},
} }
props.ref?.(ref) props.ref?.(ref)
@@ -551,7 +556,7 @@ function Option(props: {
</text> </text>
</Show> </Show>
<Show when={!props.current && props.gutter}> <Show when={props.gutter}>
<box flexShrink={0} marginRight={0}> <box flexShrink={0} marginRight={0}>
{props.gutter?.()} {props.gutter?.()}
</box> </box>