소스 검색

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

Shoubhit Dash 2 달 전
부모
커밋
6d4f3b4ab2

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

@@ -8,7 +8,8 @@ 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 { createEffect, createMemo, createResource, createSignal, on, Show, untrack } from "solid-js"
+import { useTerminalDimensions } from "@opentui/solid"
 import { Spinner } from "@tui/component/spinner"
 import { DialogSessionRename } from "@tui/component/dialog-session-rename"
 import { DialogSessionDeleteFailed } from "@tui/component/dialog-session-delete-failed"
@@ -31,6 +32,7 @@ export function SessionSwitcherDialog() {
   const sdk = useSDK()
   const local = useLocal()
   const toast = useToast()
+  const dimensions = useTerminalDimensions()
   const [toDelete, setToDelete] = createSignal<string>()
   const [search, setSearch] = createDebouncedSignal("", 150)
   const deleteHint = useCommandShortcut("session.delete")
@@ -151,11 +153,6 @@ export function SessionSwitcherDialog() {
     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(
@@ -183,10 +180,18 @@ export function SessionSwitcherDialog() {
       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>
+      const gutter =
+        slot !== undefined || isWorking
+          ? () => (
+              <box flexDirection="row" gap={1}>
+                <Show when={slot !== undefined}>
+                  <text fg={theme.accent}>{slot}</text>
+                </Show>
+                <Show when={isWorking}>
+                  <Spinner />
+                </Show>
+              </box>
+            )
           : undefined
       const titleText = isDeleting ? `Press ${deleteHint()} again to confirm` : isWorktree ? `⎇ ${x.title}` : x.title
       return {
@@ -194,6 +199,17 @@ export function SessionSwitcherDialog() {
         bg: isDeleting ? theme.error : undefined,
         value: x.id,
         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,
         gutter,
       }
@@ -224,8 +240,11 @@ export function SessionSwitcherDialog() {
     }),
   )
 
-  onMount(() => {
-    dialog.setSize("xlarge")
+  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 = (
@@ -253,6 +272,7 @@ export function SessionSwitcherDialog() {
           title: "pin/unpin",
           onTrigger: (option: { value: string }) => {
             local.session.togglePin(option.value)
+            queueMicrotask(() => select?.moveTo(option.value))
           },
         },
         {
@@ -311,19 +331,20 @@ export function SessionSwitcherDialog() {
           },
         },
       ]}
-      footerHints={quickSwitchFooterHints()}
     />
   )
 
   return (
-    <box flexDirection="row" width="100%">
-      <box flexBasis={68} flexShrink={0}>
+    <box flexDirection="row" width="100%" height={height()}>
+      <box flexBasis={showPreview() ? 68 : undefined} flexGrow={showPreview() ? 0 : 1} 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>
+      <Show when={showPreview()}>
+        <box width={1} height={height() - 1} flexShrink={0} border={["left"]} borderColor={theme.borderSubtle} />
+        <box flexGrow={1} flexShrink={1} flexDirection="column">
+          <SessionPreviewPane sessionID={focusedSession} session={focusedSessionInfo} />
+        </box>
+      </Show>
     </box>
   )
 }

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

@@ -1,14 +1,14 @@
 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 { 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 { 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"
+import { extractMessageMarkdown, extractMessageText, relativeTime } from "./util"
 
 type WithParts = { info: Message; parts: Part[] }
 
@@ -16,7 +16,6 @@ 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}`
@@ -36,41 +35,21 @@ function loadMessages(sdk: Sdk, sessionID: string, version: number): Promise<Wit
   const promise = sdk.client.session
     .messages({ sessionID, limit: 50 })
     .then((res) => {
-      if (res.error) messageCache.delete(key)
+      if (res.error) throw res.error
       return (res.data as WithParts[] | undefined) ?? []
     })
-    .catch(() => {
+    .catch((error) => {
       messageCache.delete(key)
-      return [] as WithParts[]
+      throw error
     })
   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(() => {})
   }
 }
 
@@ -121,13 +100,6 @@ export function SessionPreviewPane(props: {
     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()
@@ -137,31 +109,7 @@ export function SessionPreviewPane(props: {
     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()
@@ -174,13 +122,13 @@ export function SessionPreviewPane(props: {
     return { user, assistant }
   })
 
-  const loading = createMemo(() => (fetchedMessages.loading || fetchedDiff.loading) && !exchange())
+  const loading = createMemo(() => fetchedMessages.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 }
+    if (s === "busy") return "working"
+    if (s === "retry") return "retrying"
+    return "idle"
   })
 
   return (
@@ -191,7 +139,7 @@ export function SessionPreviewPane(props: {
       paddingTop={1}
       paddingBottom={1}
       gap={1}
-      maxHeight={maxHeight()}
+      height={maxHeight()}
       overflow="hidden"
     >
       <Show
@@ -204,7 +152,7 @@ export function SessionPreviewPane(props: {
       >
         {(s) => (
           <>
-            <Header session={s()} statusLabel={statusLabel()} diff={diffSummary()} />
+            <Header session={s()} statusLabel={statusLabel()} />
             <Show when={loading()}>
               <Spinner>loading preview...</Spinner>
             </Show>
@@ -213,7 +161,7 @@ export function SessionPreviewPane(props: {
               fallback={
                 <Show when={!loading()}>
                   <text fg={theme.textMuted} wrapMode="word">
-                    No messages yet
+                    {fetchedMessages.error ? "Preview unavailable" : "No messages yet"}
                   </text>
                 </Show>
               }
@@ -241,24 +189,12 @@ function messageParentID(item: WithParts) {
 
 const ROW_WIDTH = 40
 
-function Header(props: {
-  session: SdkSession
-  statusLabel: { text: string; color: RGBA }
-  diff: { additions: number; deletions: number; files: number } | undefined
-}) {
+function Header(props: { session: SdkSession; statusLabel: string }) {
   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 Locale.truncate(joined, Math.max(0, ROW_WIDTH - props.statusLabel.length))
   })
 
   return (
@@ -268,20 +204,12 @@ function Header(props: {
           {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>{props.statusLabel}</span>
           <span>{statusRest()}</span>
         </text>
       </Row>
-      <Show when={props.diff}>{(d) => <DiffRow diff={d()} />}</Show>
     </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 REPLY_MAX_LINES = 12
 const REPLY_MAX_CHARS = 800

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

@@ -52,19 +52,3 @@ function collectTextParts(parts: readonly Part[]): string[] {
   }
   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
-}

+ 6 - 1
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -66,6 +66,7 @@ export type DialogSelectRef<T> = {
   filter: string
   filtered: DialogSelectOption<T>[]
   selected: DialogSelectOption<T> | undefined
+  moveTo(value: T): void
 }
 
 export function DialogSelect<T>(props: DialogSelectProps<T>) {
@@ -341,6 +342,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
     get selected() {
       return selected()
     },
+    moveTo(value) {
+      const index = flat().findIndex((option) => isDeepEqual(option.value, value))
+      if (index >= 0) moveTo(index, true)
+    },
   }
   props.ref?.(ref)
 
@@ -551,7 +556,7 @@ function Option(props: {
         </text>
       </Show>
-      <Show when={!props.current && props.gutter}>
+      <Show when={props.gutter}>
         <box flexShrink={0} marginRight={0}>
           {props.gutter?.()}
         </box>