Ver Fonte

feat(tui): pin, quick-switch, and cycle recent sessions (#26858)

Shoubhit Dash há 3 meses atrás
pai
commit
12583b18f0

+ 2 - 0
packages/core/src/flag/flag.ts

@@ -85,6 +85,8 @@ export const Flag = {
   OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
   OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
   OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
+  OPENCODE_EXPERIMENTAL_SESSION_SWITCHING:
+    OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SESSION_SWITCHING"),
 
   // Evaluated at access time (not module load) because tests, the CLI, and
   // external tooling set these env vars at runtime.

+ 50 - 1
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -74,6 +74,17 @@ const appBindingCommands = [
   "command.palette.show",
   "session.list",
   "session.new",
+  "session.cycle_recent",
+  "session.cycle_recent_reverse",
+  "session.quick_switch.1",
+  "session.quick_switch.2",
+  "session.quick_switch.3",
+  "session.quick_switch.4",
+  "session.quick_switch.5",
+  "session.quick_switch.6",
+  "session.quick_switch.7",
+  "session.quick_switch.8",
+  "session.quick_switch.9",
   "model.list",
   "model.cycle_recent",
   "model.cycle_recent_reverse",
@@ -462,6 +473,37 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
           dialog.clear()
         },
       },
+      ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
+        ? [
+            {
+              name: "session.cycle_recent",
+              title: "Cycle to previous recent session",
+              category: "Session",
+              hidden: true,
+              run: () => {
+                local.session.cycleRecent(1)
+              },
+            },
+            {
+              name: "session.cycle_recent_reverse",
+              title: "Cycle to next recent session",
+              category: "Session",
+              hidden: true,
+              run: () => {
+                local.session.cycleRecent(-1)
+              },
+            },
+            ...Array.from({ length: 9 }, (_, i) => ({
+              name: `session.quick_switch.${i + 1}`,
+              title: `Switch to session in quick slot ${i + 1}`,
+              category: "Session",
+              hidden: true,
+              run: () => {
+                local.session.quickSwitch(i + 1)
+              },
+            })),
+          ]
+        : []),
       {
         name: "model.list",
         title: "Switch model",
@@ -776,7 +818,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
 
   useBindings(() => ({
     enabled: command.matcher,
-    bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
+    bindings: tuiConfig.keybinds.gather(
+      "app",
+      Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
+        ? appBindingCommands
+        : appBindingCommands.filter(
+            (c) => !c.startsWith("session.cycle_recent") && !c.startsWith("session.quick_switch"),
+          ),
+    ),
   }))
 
   useBindings(() => ({

+ 95 - 36
packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

@@ -7,6 +7,7 @@ import { Locale } from "@/util/locale"
 import { useProject } from "@tui/context/project"
 import { useTheme } from "../context/theme"
 import { useSDK } from "../context/sdk"
+import { useLocal } from "../context/local"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { DialogSessionRename } from "./dialog-session-rename"
 import { createDebouncedSignal } from "../util/signal"
@@ -25,6 +26,7 @@ export function DialogSessionList() {
   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)
@@ -128,7 +130,10 @@ export function DialogSessionList() {
 
   const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
 
+  const RECENT_LIMIT = 5
+
   const options = createMemo(() => {
+    const enabled = Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
     const today = new Date().toDateString()
     const sessionMap = new Map(
       sessions()
@@ -139,46 +144,74 @@ export function DialogSessionList() {
     const searchResult = searchResults()
     const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
 
-    return displayOrder
-      .map((id) => sessionMap.get(id))
-      .filter((x) => x !== undefined)
-      .map((x) => {
-        const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
+    const dismissed = enabled ? new Set(local.session.dismissedRecent()) : new Set<string>()
+    const pinned = enabled ? local.session.pinned().filter((id) => sessionMap.has(id)) : []
+    const pinnedSet = new Set(pinned)
+    const slotByID = enabled
+      ? new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
+      : new Map<string, number>()
 
-        let footer: JSX.Element | string = ""
-        if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
-          if (x.workspaceID) {
-            footer = workspace ? (
-              <WorkspaceLabel
-                type={workspace.type}
-                name={workspace.name}
-                status={project.workspace.status(x.workspaceID) ?? "error"}
-              />
-            ) : (
-              <WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
-            )
-          }
-        } else {
-          footer = Locale.time(x.time.updated)
-        }
+    const recent = enabled
+      ? displayOrder.filter((id) => !pinnedSet.has(id) && !dismissed.has(id)).slice(0, RECENT_LIMIT)
+      : []
+    const recentSet = new Set(recent)
 
-        const date = new Date(x.time.updated)
-        let category = date.toDateString()
-        if (category === today) {
-          category = "Today"
-        }
-        const isDeleting = toDelete() === x.id
-        const status = sync.data.session_status?.[x.id]
-        const isWorking = status?.type === "busy" || status?.type === "retry"
-        return {
-          title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
-          bg: isDeleting ? theme.error : undefined,
-          value: x.id,
-          category,
-          footer,
-          gutter: isWorking ? () => <Spinner /> : undefined,
+    function buildOption(id: string, category: string) {
+      const x = sessionMap.get(id)
+      if (!x) return undefined
+      const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
+
+      let footer: JSX.Element | string = ""
+      if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
+        if (x.workspaceID) {
+          footer = workspace ? (
+            <WorkspaceLabel
+              type={workspace.type}
+              name={workspace.name}
+              status={project.workspace.status(x.workspaceID) ?? "error"}
+            />
+          ) : (
+            <WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
+          )
         }
+      } else {
+        footer = Locale.time(x.time.updated)
+      }
+
+      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
+      return {
+        title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
+        bg: isDeleting ? theme.error : undefined,
+        value: x.id,
+        category,
+        footer,
+        gutter,
+      }
+    }
+
+    const remaining = displayOrder
+      .filter((id) => !pinnedSet.has(id) && !recentSet.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 !== undefined)
+
+    return [
+      ...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined),
+      ...recent.map((id) => buildOption(id, "Recent")).filter((x) => x !== undefined),
+      ...remaining,
+    ]
   })
 
   onMount(() => {
@@ -203,6 +236,32 @@ export function DialogSessionList() {
         dialog.clear()
       }}
       actions={[
+        ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
+          ? [
+              {
+                command: "session.pin.toggle",
+                title: "pin/unpin",
+                onTrigger: (option: { value: string }) => {
+                  local.session.togglePin(option.value)
+                },
+              },
+              {
+                command: "session.toggle.recent",
+                title: "toggle recent",
+                onTrigger: (option: { value: string }) => {
+                  if (local.session.isPinned(option.value)) {
+                    toast.show({
+                      variant: "info",
+                      message: "Unpin the session first to toggle it in Recent",
+                      duration: 3000,
+                    })
+                    return
+                  }
+                  local.session.toggleRecent(option.value)
+                },
+              },
+            ]
+          : []),
         {
           command: "session.delete",
           title: "delete",

+ 26 - 0
packages/opencode/src/cli/cmd/tui/config/keybind.ts

@@ -80,6 +80,19 @@ const Definitions = {
   session_child_cycle: keybind("right", "Go to next child session"),
   session_child_cycle_reverse: keybind("left", "Go to previous child session"),
   session_parent: keybind("up", "Go to parent session"),
+  session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
+  session_toggle_recent: keybind("ctrl+h", "Show or hide session in the Recent group"),
+  session_cycle_recent: keybind("<leader>]", "Cycle to the previous recent session"),
+  session_cycle_recent_reverse: keybind("<leader>[", "Cycle to the next recent session"),
+  session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
+  session_quick_switch_2: keybind("<leader>2", "Switch to session in quick slot 2"),
+  session_quick_switch_3: keybind("<leader>3", "Switch to session in quick slot 3"),
+  session_quick_switch_4: keybind("<leader>4", "Switch to session in quick slot 4"),
+  session_quick_switch_5: keybind("<leader>5", "Switch to session in quick slot 5"),
+  session_quick_switch_6: keybind("<leader>6", "Switch to session in quick slot 6"),
+  session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
+  session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
+  session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
 
   stash_delete: keybind("ctrl+d", "Delete stash entry"),
   model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
@@ -257,6 +270,19 @@ export const CommandMap = {
   session_child_cycle: "session.child.next",
   session_child_cycle_reverse: "session.child.previous",
   session_parent: "session.parent",
+  session_pin_toggle: "session.pin.toggle",
+  session_toggle_recent: "session.toggle.recent",
+  session_cycle_recent: "session.cycle_recent",
+  session_cycle_recent_reverse: "session.cycle_recent_reverse",
+  session_quick_switch_1: "session.quick_switch.1",
+  session_quick_switch_2: "session.quick_switch.2",
+  session_quick_switch_3: "session.quick_switch.3",
+  session_quick_switch_4: "session.quick_switch.4",
+  session_quick_switch_5: "session.quick_switch.5",
+  session_quick_switch_6: "session.quick_switch.6",
+  session_quick_switch_7: "session.quick_switch.7",
+  session_quick_switch_8: "session.quick_switch.8",
+  session_quick_switch_9: "session.quick_switch.9",
   stash_delete: "stash.delete",
   model_provider_list: "model.dialog.provider",
   model_favorite_toggle: "model.dialog.favorite",

+ 191 - 1
packages/opencode/src/cli/cmd/tui/context/local.tsx

@@ -1,11 +1,14 @@
 import { createStore } from "solid-js/store"
 import { createSimpleContext } from "./helper"
-import { batch, createEffect, createMemo } from "solid-js"
+import { batch, createEffect, createMemo, on } from "solid-js"
 import { useSync } from "@tui/context/sync"
 import { useTheme } from "@tui/context/theme"
+import { useRoute } from "@tui/context/route"
+import { useEvent } from "@tui/context/event"
 import { uniqueBy } from "remeda"
 import path from "path"
 import { Global } from "@opencode-ai/core/global"
+import { Flag } from "@opencode-ai/core/flag/flag"
 import { iife } from "@/util/iife"
 import { useToast } from "../ui/toast"
 import { useArgs } from "./args"
@@ -380,6 +383,192 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       }
     })
 
+    const session = iife(() => {
+      const [sessionStore, setSessionStore] = createStore<{
+        ready: boolean
+        pinned: string[]
+        dismissedRecent: string[]
+        recentOrder: string[]
+      }>({
+        ready: false,
+        pinned: [],
+        dismissedRecent: [],
+        recentOrder: [],
+      })
+
+      const filePath = path.join(Global.Path.state, "session.json")
+      const state = {
+        pending: false,
+      }
+
+      function save() {
+        if (!sessionStore.ready) {
+          state.pending = true
+          return
+        }
+        state.pending = false
+        void Filesystem.writeJson(filePath, {
+          pinned: sessionStore.pinned,
+          dismissedRecent: sessionStore.dismissedRecent,
+          recentOrder: sessionStore.recentOrder,
+        })
+      }
+
+      Filesystem.readJson(filePath)
+        .then((x: any) => {
+          if (Array.isArray(x.pinned)) setSessionStore("pinned", x.pinned)
+          if (Array.isArray(x.dismissedRecent)) setSessionStore("dismissedRecent", x.dismissedRecent)
+          if (Array.isArray(x.recentOrder)) setSessionStore("recentOrder", x.recentOrder)
+        })
+        .catch(() => {})
+        .finally(() => {
+          setSessionStore("ready", true)
+          if (state.pending) save()
+        })
+
+      const route = useRoute()
+      const event = useEvent()
+      let cycling = false
+
+      const slots = createMemo(() => {
+        const rootSessions = sync.data.session.filter((x) => x.parentID === undefined)
+        const existing = new Set(rootSessions.map((x) => x.id))
+        const dismissed = new Set(sessionStore.dismissedRecent)
+        const pins = sessionStore.pinned.filter((id) => existing.has(id))
+        const pinnedSet = new Set(pins)
+        const recent = rootSessions
+          .filter((x) => !pinnedSet.has(x.id) && !dismissed.has(x.id))
+          .toSorted((a, b) => b.time.updated - a.time.updated)
+          .map((x) => x.id)
+        return [...pins, ...recent].slice(0, 9)
+      })
+
+      function prune(sessionID: string) {
+        batch(() => {
+          if (sessionStore.pinned.includes(sessionID)) {
+            setSessionStore(
+              "pinned",
+              sessionStore.pinned.filter((x) => x !== sessionID),
+            )
+          }
+          if (sessionStore.dismissedRecent.includes(sessionID)) {
+            setSessionStore(
+              "dismissedRecent",
+              sessionStore.dismissedRecent.filter((x) => x !== sessionID),
+            )
+          }
+          if (sessionStore.recentOrder.includes(sessionID)) {
+            setSessionStore(
+              "recentOrder",
+              sessionStore.recentOrder.filter((x) => x !== sessionID),
+            )
+          }
+          save()
+        })
+      }
+
+      event.on("session.deleted", (evt) => {
+        prune(evt.properties.info.id)
+      })
+
+      if (Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING) {
+        createEffect(
+          on(
+            () => (sessionStore.ready && route.data.type === "session" ? route.data.sessionID : undefined),
+            (sessionID) => {
+              if (!sessionID) return
+              if (cycling) {
+                cycling = false
+                return
+              }
+              const filtered = sessionStore.recentOrder.filter((x) => x !== sessionID)
+              const next = [sessionID, ...filtered].slice(0, 20)
+              setSessionStore("recentOrder", next)
+              save()
+            },
+          ),
+        )
+      }
+
+      return {
+        get ready() {
+          return sessionStore.ready
+        },
+        pinned() {
+          return sessionStore.pinned
+        },
+        dismissedRecent() {
+          return sessionStore.dismissedRecent
+        },
+        recentOrder() {
+          return sessionStore.recentOrder
+        },
+        slots,
+        isPinned(sessionID: string) {
+          return sessionStore.pinned.includes(sessionID)
+        },
+        isDismissed(sessionID: string) {
+          return sessionStore.dismissedRecent.includes(sessionID)
+        },
+        togglePin(sessionID: string) {
+          batch(() => {
+            const exists = sessionStore.pinned.includes(sessionID)
+            const next = exists
+              ? sessionStore.pinned.filter((x) => x !== sessionID)
+              : [sessionID, ...sessionStore.pinned]
+            setSessionStore("pinned", next)
+            save()
+          })
+        },
+        toggleRecent(sessionID: string) {
+          batch(() => {
+            const exists = sessionStore.dismissedRecent.includes(sessionID)
+            const next = exists
+              ? sessionStore.dismissedRecent.filter((x) => x !== sessionID)
+              : [sessionID, ...sessionStore.dismissedRecent]
+            setSessionStore("dismissedRecent", next)
+            save()
+          })
+        },
+        quickSwitch(slot: number) {
+          const target = slots()[slot - 1]
+          if (!target) return
+          if (route.data.type === "session" && route.data.sessionID === target) return
+          route.navigate({ type: "session", sessionID: target })
+        },
+        cycleRecent(direction: 1 | -1) {
+          if (route.data.type !== "session") {
+            toast.show({
+              variant: "info",
+              message: "Open a session first to cycle between recent sessions",
+              duration: 3000,
+            })
+            return
+          }
+          const current = route.data.sessionID
+          const order = sessionStore.recentOrder.filter((id) =>
+            sync.data.session.some((s) => s.id === id && s.parentID === undefined),
+          )
+          if (order.length < 2) {
+            toast.show({
+              variant: "info",
+              message: "No other recent sessions to cycle to",
+              duration: 3000,
+            })
+            return
+          }
+          const index = order.indexOf(current)
+          if (index === -1) return
+          const next = index + direction
+          if (next < 0 || next >= order.length) return
+          const target = order[next]
+          if (!target || target === current) return
+          cycling = true
+          route.navigate({ type: "session", sessionID: target })
+        },
+      }
+    })
+
     const mcp = {
       isEnabled(name: string) {
         const status = sync.data.mcp[name]
@@ -412,6 +601,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       model,
       agent,
       mcp,
+      session,
     }
     return result
   },

+ 9 - 0
packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx

@@ -1,5 +1,6 @@
 import { createMemo, For } from "solid-js"
 import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
+import { Flag } from "@opencode-ai/core/flag/flag"
 
 const themeCount = Object.keys(DEFAULT_THEMES).length
 const themeTip = `Use {highlight}/themes{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between ${themeCount} built-in themes`
@@ -66,6 +67,14 @@ const TIPS = [
   themeTip,
   "Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session",
   "Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations",
+  ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
+    ? [
+        "Press {highlight}Ctrl+F{/highlight} in the session list to pin a session so it stays at the top",
+        "Pinned and recent sessions are bound to {highlight}Ctrl+X 1{/highlight} through {highlight}Ctrl+X 9{/highlight} for one-press switching",
+        "Press {highlight}Ctrl+X ]{/highlight} / {highlight}Ctrl+X [{/highlight} to cycle through recently visited sessions",
+        "Press {highlight}Ctrl+H{/highlight} in the session list to show or hide a session in the Recent group",
+      ]
+    : []),
   "Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
   "Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown",
   "Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard",