From f33b4455a189bf0f60c8bae329ddf12892895a73 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 16 May 2026 01:10:16 +0530 Subject: [PATCH] feat(tui): enable pinned session switching (#27780) --- packages/core/src/flag/flag.ts | 1 - packages/opencode/src/cli/cmd/tui/app.tsx | 27 +++++------ .../cmd/tui/component/dialog-session-list.tsx | 45 ++++++++++++------- .../tui/feature-plugins/home/tips-view.tsx | 18 +++----- .../src/cli/cmd/tui/ui/dialog-select.tsx | 12 +++-- 5 files changed, 54 insertions(+), 49 deletions(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 4b1d3d20ae..3ed67bb785 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -44,7 +44,6 @@ export const Flag = { OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), - 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. diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 7d58782101..af9df4d42f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -479,17 +479,15 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.clear() }, }, - ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING - ? 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) - }, - })) - : []), + ...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", @@ -804,12 +802,7 @@ function App(props: { onSnapshot?: () => Promise }) { useBindings(() => ({ enabled: command.matcher, - bindings: tuiConfig.keybinds.gather( - "app", - Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING - ? appBindingCommands - : appBindingCommands.filter((c) => !c.startsWith("session.quick_switch")), - ), + bindings: tuiConfig.keybinds.gather("app", appBindingCommands), })) useBindings(() => ({ diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 9bc1d79e30..17653af6b9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -31,6 +31,8 @@ export function DialogSessionList() { const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) const deleteHint = useCommandShortcut("session.delete") + const quickSwitch1 = useCommandShortcut("session.quick_switch.1") + const quickSwitch9 = useCommandShortcut("session.quick_switch.9") const [searchResults, { refetch }] = createResource( () => ({ query: search(), filter: sync.session.query() }), @@ -130,8 +132,18 @@ export function DialogSessionList() { const [browseOrder] = createSignal(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(() => { - const enabled = Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING const today = new Date().toDateString() const sessionMap = new Map( sessions() @@ -142,11 +154,9 @@ export function DialogSessionList() { const searchResult = searchResults() const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() - const pinned = enabled ? local.session.pinned().filter((id) => sessionMap.has(id)) : [] + const pinned = local.session.pinned().filter((id) => sessionMap.has(id)) const pinnedSet = new Set(pinned) - const slotByID = enabled - ? new Map(local.session.slots().map((id, i) => [id, i + 1])) - : new Map() + const slotByID = new Map(local.session.slots().map((id, i) => [id, i + 1])) function buildOption(id: string, category: string) { const x = sessionMap.get(id) @@ -224,17 +234,13 @@ 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.pin.toggle", + title: "pin/unpin", + onTrigger: (option: { value: string }) => { + local.session.togglePin(option.value) + }, + }, { command: "session.delete", title: "delete", @@ -291,6 +297,13 @@ export function DialogSessionList() { }, }, ]} + footerHints={quickSwitchFooterHints()} /> ) } + +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}` +} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx index f2a5f97f1e..d3b880325d 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips-view.tsx @@ -1,7 +1,6 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import { createMemo, For, type Accessor } from "solid-js" import { DEFAULT_THEMES, useTheme } from "@tui/context/theme" -import { Flag } from "@opencode-ai/core/flag/flag" import { useCommandShortcut } from "../../keymap" const themeCount = Object.keys(DEFAULT_THEMES).length @@ -170,17 +169,12 @@ const TIPS: Tip[] = [ (shortcuts) => `Use ${commandText("/models", shortcuts.modelList())} to see and switch between available AI models`, (shortcuts) => `Use ${commandText("/themes", shortcuts.themeList())} to switch between ${themeCount} built-in themes`, (shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`, - (shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list and continue previous conversations`, - ...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING - ? ([ - (shortcuts) => - press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"), - (shortcuts) => - shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9() - ? `Pinned sessions are bound to ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} for one-press switching` - : undefined, - ] satisfies Tip[]) - : []), + (shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`, + (shortcuts) => press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"), + (shortcuts) => + shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9() + ? `Pinned sessions are assigned quick slots; use ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} to switch` + : undefined, "Run {highlight}/compact{/highlight} to summarize long sessions near context limits", (shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`, (shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"), diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index a791aebc30..700735d38c 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -38,6 +38,11 @@ export interface DialogSelectProps { disabled?: boolean onTrigger: (option: DialogSelectOption) => void }[] + footerHints?: { + title: string + label: string + side?: "left" | "right" + }[] bindings?: readonly Binding[] current?: T } @@ -334,11 +339,12 @@ export function DialogSelect(props: DialogSelectProps) { } props.ref?.(ref) - const visibleActions = createMemo(() => - actions() + const visibleActions = createMemo(() => [ + ...actions() .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" })) .filter((item) => !item.disabled && item.label), - ) + ...(props.footerHints ?? []), + ]) const left = createMemo(() => visibleActions().filter((item) => item.side !== "right")) const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))