Skip to content

Commit 6d4f3b4

Browse files
authored
feat(tui): improve experimental session switcher (#30738)
1 parent caea930 commit 6d4f3b4

4 files changed

Lines changed: 62 additions & 146 deletions

File tree

packages/opencode/src/cli/cmd/tui/feature-plugins/session/dialog.tsx

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { useSDK } from "@tui/context/sdk"
88
import { useLocal } from "@tui/context/local"
99
import { useToast } from "@tui/ui/toast"
1010
import { useCommandShortcut } from "@tui/keymap"
11-
import { createEffect, createMemo, createResource, createSignal, on, onMount, untrack } from "solid-js"
11+
import { createEffect, createMemo, createResource, createSignal, on, Show, untrack } from "solid-js"
12+
import { useTerminalDimensions } from "@opentui/solid"
1213
import { Spinner } from "@tui/component/spinner"
1314
import { DialogSessionRename } from "@tui/component/dialog-session-rename"
1415
import { DialogSessionDeleteFailed } from "@tui/component/dialog-session-delete-failed"
@@ -31,6 +32,7 @@ export function SessionSwitcherDialog() {
3132
const sdk = useSDK()
3233
const local = useLocal()
3334
const toast = useToast()
35+
const dimensions = useTerminalDimensions()
3436
const [toDelete, setToDelete] = createSignal<string>()
3537
const [search, setSearch] = createDebouncedSignal("", 150)
3638
const deleteHint = useCommandShortcut("session.delete")
@@ -151,11 +153,6 @@ export function SessionSwitcherDialog() {
151153
if (!first || !last) return undefined
152154
return quickSwitchRange(first, last)
153155
})
154-
const quickSwitchFooterHints = createMemo(() => {
155-
const hint = quickSwitchHint()
156-
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
157-
})
158-
159156
const options = createMemo<DialogSelectOption<string>[]>(() => {
160157
const today = new Date().toDateString()
161158
const sessionMap = new Map(
@@ -183,17 +180,36 @@ export function SessionSwitcherDialog() {
183180
const status = sync.data.session_status?.[x.id]
184181
const isWorking = status?.type === "busy" || status?.type === "retry"
185182
const slot = slotByID.get(x.id)
186-
const gutter = isWorking
187-
? () => <Spinner />
188-
: slot !== undefined
189-
? () => <text fg={theme.accent}>{slot}</text>
183+
const gutter =
184+
slot !== undefined || isWorking
185+
? () => (
186+
<box flexDirection="row" gap={1}>
187+
<Show when={slot !== undefined}>
188+
<text fg={theme.accent}>{slot}</text>
189+
</Show>
190+
<Show when={isWorking}>
191+
<Spinner />
192+
</Show>
193+
</box>
194+
)
190195
: undefined
191196
const titleText = isDeleting ? `Press ${deleteHint()} again to confirm` : isWorktree ? `⎇ ${x.title}` : x.title
192197
return {
193198
title: titleText,
194199
bg: isDeleting ? theme.error : undefined,
195200
value: x.id,
196201
category,
202+
categoryView:
203+
category === "Pinned" ? (
204+
<text>
205+
<span style={{ fg: theme.accent }}>
206+
<b>Pinned</b>
207+
</span>
208+
<Show when={quickSwitchHint()}>
209+
{(hint) => <span style={{ fg: theme.textMuted }}> · switch {hint()}</span>}
210+
</Show>
211+
</text>
212+
) : undefined,
197213
footer,
198214
gutter,
199215
}
@@ -224,8 +240,11 @@ export function SessionSwitcherDialog() {
224240
}),
225241
)
226242

227-
onMount(() => {
228-
dialog.setSize("xlarge")
243+
const showPreview = createMemo(() => dimensions().width >= 100)
244+
const height = createMemo(() => Math.max(8, Math.floor(dimensions().height / 2) - 4))
245+
246+
createEffect(() => {
247+
dialog.setSize(showPreview() ? "xlarge" : "large")
229248
})
230249

231250
const list = (
@@ -253,6 +272,7 @@ export function SessionSwitcherDialog() {
253272
title: "pin/unpin",
254273
onTrigger: (option: { value: string }) => {
255274
local.session.togglePin(option.value)
275+
queueMicrotask(() => select?.moveTo(option.value))
256276
},
257277
},
258278
{
@@ -311,19 +331,20 @@ export function SessionSwitcherDialog() {
311331
},
312332
},
313333
]}
314-
footerHints={quickSwitchFooterHints()}
315334
/>
316335
)
317336

318337
return (
319-
<box flexDirection="row" width="100%">
320-
<box flexBasis={68} flexShrink={0}>
338+
<box flexDirection="row" width="100%" height={height()}>
339+
<box flexBasis={showPreview() ? 68 : undefined} flexGrow={showPreview() ? 0 : 1} flexShrink={0}>
321340
{list}
322341
</box>
323-
<box width={1} flexShrink={0} border={["left"]} borderColor={theme.borderSubtle} />
324-
<box flexGrow={1} flexShrink={1} flexDirection="column">
325-
<SessionPreviewPane sessionID={focusedSession} session={focusedSessionInfo} />
326-
</box>
342+
<Show when={showPreview()}>
343+
<box width={1} height={height() - 1} flexShrink={0} border={["left"]} borderColor={theme.borderSubtle} />
344+
<box flexGrow={1} flexShrink={1} flexDirection="column">
345+
<SessionPreviewPane sessionID={focusedSession} session={focusedSessionInfo} />
346+
</box>
347+
</Show>
327348
</box>
328349
)
329350
}

packages/opencode/src/cli/cmd/tui/feature-plugins/session/preview-pane.tsx

Lines changed: 16 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
import { createResource, Show, createMemo, createSignal, onMount, type Accessor, type JSX } from "solid-js"
2-
import { TextAttributes, type RGBA } from "@opentui/core"
2+
import { TextAttributes } from "@opentui/core"
33
import { useTerminalDimensions } from "@opentui/solid"
44
import { debounce, leadingAndTrailing } from "@solid-primitives/scheduled"
5-
import type { Message, Part, Session as SdkSession, SnapshotFileDiff } from "@opencode-ai/sdk/v2"
5+
import type { Message, Part, Session as SdkSession } from "@opencode-ai/sdk/v2"
66
import { useTheme } from "@tui/context/theme"
77
import { useSDK } from "@tui/context/sdk"
88
import { useSync } from "@tui/context/sync"
99
import { Locale } from "@/util/locale"
1010
import { Spinner } from "@tui/component/spinner"
11-
import { extractMessageMarkdown, extractMessageText, formatDiffSummary, relativeTime, shortModelLabel } from "./util"
11+
import { extractMessageMarkdown, extractMessageText, relativeTime } from "./util"
1212

1313
type WithParts = { info: Message; parts: Part[] }
1414

1515
type Sdk = ReturnType<typeof useSDK>
1616
type Sync = ReturnType<typeof useSync>
1717

1818
const messageCache = new Map<string, Promise<WithParts[]>>()
19-
const diffCache = new Map<string, Promise<SnapshotFileDiff[]>>()
2019

2120
function cacheKey(sessionID: string, version: number) {
2221
return `${sessionID}:${version}`
@@ -36,41 +35,21 @@ function loadMessages(sdk: Sdk, sessionID: string, version: number): Promise<Wit
3635
const promise = sdk.client.session
3736
.messages({ sessionID, limit: 50 })
3837
.then((res) => {
39-
if (res.error) messageCache.delete(key)
38+
if (res.error) throw res.error
4039
return (res.data as WithParts[] | undefined) ?? []
4140
})
42-
.catch(() => {
41+
.catch((error) => {
4342
messageCache.delete(key)
44-
return [] as WithParts[]
43+
throw error
4544
})
4645
messageCache.set(key, promise)
4746
return promise
4847
}
4948

50-
function loadDiff(sdk: Sdk, sessionID: string, version: number): Promise<SnapshotFileDiff[]> {
51-
const key = cacheKey(sessionID, version)
52-
const cached = diffCache.get(key)
53-
if (cached) return cached
54-
55-
const promise = sdk.client.session
56-
.diff({ sessionID })
57-
.then((res) => {
58-
if (res.error) diffCache.delete(key)
59-
return (res.data as SnapshotFileDiff[] | undefined) ?? []
60-
})
61-
.catch(() => {
62-
diffCache.delete(key)
63-
return [] as SnapshotFileDiff[]
64-
})
65-
diffCache.set(key, promise)
66-
return promise
67-
}
68-
6949
export function prefetchPreviews(sdk: Sdk, sync: Sync, sessionIDs: readonly string[]) {
7050
for (const id of sessionIDs) {
7151
const version = sync.data.session.find((session) => session.id === id)?.time.updated ?? 0
7252
if (!hydrateFromSync(sync, id)) loadMessages(sdk, id, version).catch(() => {})
73-
if (!sync.data.session_diff[id]?.length) loadDiff(sdk, id, version).catch(() => {})
7453
}
7554
}
7655

@@ -121,13 +100,6 @@ export function SessionPreviewPane(props: {
121100
return hydrateFromSync(sync, id)
122101
})
123102

124-
const syncedDiff = createMemo(() => {
125-
const id = props.sessionID()
126-
if (!id) return undefined
127-
const diff = sync.data.session_diff[id]
128-
return diff && diff.length > 0 ? (diff as SnapshotFileDiff[]) : undefined
129-
})
130-
131103
const [fetchedMessages] = createResource(
132104
() => {
133105
const id = props.sessionID()
@@ -137,31 +109,7 @@ export function SessionPreviewPane(props: {
137109
async (input) => loadMessages(sdk, input.sessionID, input.version),
138110
)
139111

140-
const [fetchedDiff] = createResource(
141-
() => {
142-
const id = props.sessionID()
143-
if (!id || syncedDiff()) return undefined
144-
return { sessionID: id, version: session()?.time.updated ?? 0 }
145-
},
146-
async (input) => loadDiff(sdk, input.sessionID, input.version),
147-
)
148-
149112
const messages = createMemo(() => syncedMessages() ?? fetchedMessages() ?? [])
150-
const diff = createMemo(() => syncedDiff() ?? fetchedDiff() ?? [])
151-
152-
const diffSummary = createMemo(() => {
153-
const live = diff()
154-
if (live && live.length > 0) {
155-
let additions = 0
156-
let deletions = 0
157-
for (const file of live) {
158-
additions += file.additions ?? 0
159-
deletions += file.deletions ?? 0
160-
}
161-
return formatDiffSummary({ additions, deletions, files: live.length })
162-
}
163-
return formatDiffSummary(session()?.summary)
164-
})
165113

166114
const exchange = createMemo(() => {
167115
const items = messages()
@@ -174,13 +122,13 @@ export function SessionPreviewPane(props: {
174122
return { user, assistant }
175123
})
176124

177-
const loading = createMemo(() => (fetchedMessages.loading || fetchedDiff.loading) && !exchange())
125+
const loading = createMemo(() => fetchedMessages.loading && !exchange())
178126

179127
const statusLabel = createMemo(() => {
180128
const s = status()
181-
if (s === "busy") return { text: "working", color: theme.warning }
182-
if (s === "retry") return { text: "retrying", color: theme.warning }
183-
return { text: "idle", color: theme.textMuted }
129+
if (s === "busy") return "working"
130+
if (s === "retry") return "retrying"
131+
return "idle"
184132
})
185133

186134
return (
@@ -191,7 +139,7 @@ export function SessionPreviewPane(props: {
191139
paddingTop={1}
192140
paddingBottom={1}
193141
gap={1}
194-
maxHeight={maxHeight()}
142+
height={maxHeight()}
195143
overflow="hidden"
196144
>
197145
<Show
@@ -204,7 +152,7 @@ export function SessionPreviewPane(props: {
204152
>
205153
{(s) => (
206154
<>
207-
<Header session={s()} statusLabel={statusLabel()} diff={diffSummary()} />
155+
<Header session={s()} statusLabel={statusLabel()} />
208156
<Show when={loading()}>
209157
<Spinner>loading preview...</Spinner>
210158
</Show>
@@ -213,7 +161,7 @@ export function SessionPreviewPane(props: {
213161
fallback={
214162
<Show when={!loading()}>
215163
<text fg={theme.textMuted} wrapMode="word">
216-
No messages yet
164+
{fetchedMessages.error ? "Preview unavailable" : "No messages yet"}
217165
</text>
218166
</Show>
219167
}
@@ -241,24 +189,12 @@ function messageParentID(item: WithParts) {
241189

242190
const ROW_WIDTH = 40
243191

244-
function Header(props: {
245-
session: SdkSession
246-
statusLabel: { text: string; color: RGBA }
247-
diff: { additions: number; deletions: number; files: number } | undefined
248-
}) {
192+
function Header(props: { session: SdkSession; statusLabel: string }) {
249193
const { theme } = useTheme()
250194
const title = createMemo(() => Locale.truncate(props.session.title, ROW_WIDTH))
251-
const modelAgent = createMemo(() => {
252-
const m = shortModelLabel(props.session.model)
253-
const a = props.session.agent ?? ""
254-
if (m && a) return Locale.truncate(`${m} · ${a}`, ROW_WIDTH)
255-
if (m) return Locale.truncate(m, ROW_WIDTH)
256-
if (a) return Locale.truncate(a, ROW_WIDTH)
257-
return ""
258-
})
259195
const statusRest = createMemo(() => {
260196
const joined = ` · ${relativeTime(props.session.time.updated)}`
261-
return Locale.truncate(joined, Math.max(0, ROW_WIDTH - props.statusLabel.text.length))
197+
return Locale.truncate(joined, Math.max(0, ROW_WIDTH - props.statusLabel.length))
262198
})
263199

264200
return (
@@ -268,20 +204,12 @@ function Header(props: {
268204
{title()}
269205
</text>
270206
</Row>
271-
<Show when={modelAgent()}>
272-
<Row height={1}>
273-
<text fg={theme.text} wrapMode="none" overflow="hidden">
274-
{modelAgent()}
275-
</text>
276-
</Row>
277-
</Show>
278207
<Row height={1}>
279208
<text fg={theme.textMuted} wrapMode="none" overflow="hidden">
280-
<span style={{ fg: props.statusLabel.color }}>{props.statusLabel.text}</span>
209+
<span>{props.statusLabel}</span>
281210
<span>{statusRest()}</span>
282211
</text>
283212
</Row>
284-
<Show when={props.diff}>{(d) => <DiffRow diff={d()} />}</Show>
285213
</box>
286214
)
287215
}
@@ -294,28 +222,6 @@ function Row(props: { height: number; children: JSX.Element }) {
294222
)
295223
}
296224

297-
function DiffRow(props: { diff: { additions: number; deletions: number; files: number } }) {
298-
const { theme } = useTheme()
299-
const showAdds = () => props.diff.additions > 0
300-
const showDels = () => props.diff.deletions > 0
301-
if (!showAdds() && !showDels()) return null
302-
return (
303-
<Row height={1}>
304-
<text wrapMode="none" overflow="hidden">
305-
<Show when={showAdds()}>
306-
<span style={{ fg: theme.diffAdded }}>+{props.diff.additions}</span>
307-
</Show>
308-
<Show when={showAdds() && showDels()}>
309-
<span> </span>
310-
</Show>
311-
<Show when={showDels()}>
312-
<span style={{ fg: theme.diffRemoved }}>{props.diff.deletions}</span>
313-
</Show>
314-
</text>
315-
</Row>
316-
)
317-
}
318-
319225
const PROMPT_MAX_CHARS = 240
320226
const REPLY_MAX_LINES = 12
321227
const REPLY_MAX_CHARS = 800

packages/opencode/src/cli/cmd/tui/feature-plugins/session/util.tsx

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -52,19 +52,3 @@ function collectTextParts(parts: readonly Part[]): string[] {
5252
}
5353
return chunks
5454
}
55-
56-
export function formatDiffSummary(
57-
summary: { additions: number; deletions: number; files: number } | undefined,
58-
): { additions: number; deletions: number; files: number } | undefined {
59-
if (!summary) return undefined
60-
if (!summary.additions && !summary.deletions && !summary.files) return undefined
61-
return summary
62-
}
63-
64-
export function shortModelLabel(model: { id: string; providerID?: string; variant?: string } | undefined): string {
65-
if (!model) return ""
66-
const id = model.id ?? ""
67-
const stripped =
68-
model.providerID && id.startsWith(`${model.providerID}/`) ? id.slice(model.providerID.length + 1) : id
69-
return model.variant ? `${stripped} (${model.variant})` : stripped
70-
}

0 commit comments

Comments
 (0)