Sfoglia il codice sorgente

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

Shoubhit Dash 2 mesi fa
parent
commit
8f8b161cae

+ 1 - 0
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.

+ 335 - 0
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<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}`
+}

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

+ 382 - 0
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<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>
+  )
+}

+ 69 - 0
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
+}

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

+ 4 - 0
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

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