feat(tui): enable pinned session switching (#27780)

This commit is contained in:
Shoubhit Dash
2026-05-16 01:10:16 +05:30
committed by GitHub
parent a24abd2b11
commit f33b4455a1
5 changed files with 54 additions and 49 deletions
-1
View File
@@ -44,7 +44,6 @@ export const Flag = {
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), 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 // Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime. // external tooling set these env vars at runtime.
+10 -17
View File
@@ -479,17 +479,15 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear() dialog.clear()
}, },
}, },
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING ...Array.from({ length: 9 }, (_, i) => ({
? Array.from({ length: 9 }, (_, i) => ({ name: `session.quick_switch.${i + 1}`,
name: `session.quick_switch.${i + 1}`, title: `Switch to session in quick slot ${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`, category: "Session",
category: "Session", hidden: true,
hidden: true, run: () => {
run: () => { local.session.quickSwitch(i + 1)
local.session.quickSwitch(i + 1) },
}, })),
}))
: []),
{ {
name: "model.list", name: "model.list",
title: "Switch model", title: "Switch model",
@@ -804,12 +802,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
useBindings(() => ({ useBindings(() => ({
enabled: command.matcher, enabled: command.matcher,
bindings: tuiConfig.keybinds.gather( bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
"app",
Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
? appBindingCommands
: appBindingCommands.filter((c) => !c.startsWith("session.quick_switch")),
),
})) }))
useBindings(() => ({ useBindings(() => ({
@@ -31,6 +31,8 @@ export function DialogSessionList() {
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
const deleteHint = useCommandShortcut("session.delete") const deleteHint = useCommandShortcut("session.delete")
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
const [searchResults, { refetch }] = createResource( const [searchResults, { refetch }] = createResource(
() => ({ query: search(), filter: sync.session.query() }), () => ({ query: search(), filter: sync.session.query() }),
@@ -130,8 +132,18 @@ export function DialogSessionList() {
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session)) 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(() => { const options = createMemo(() => {
const enabled = Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
const today = new Date().toDateString() const today = new Date().toDateString()
const sessionMap = new Map( const sessionMap = new Map(
sessions() sessions()
@@ -142,11 +154,9 @@ export function DialogSessionList() {
const searchResult = searchResults() const searchResult = searchResults()
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() 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 pinnedSet = new Set(pinned)
const slotByID = enabled const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
? new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
: new Map<string, number>()
function buildOption(id: string, category: string) { function buildOption(id: string, category: string) {
const x = sessionMap.get(id) const x = sessionMap.get(id)
@@ -224,17 +234,13 @@ export function DialogSessionList() {
dialog.clear() dialog.clear()
}} }}
actions={[ actions={[
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING {
? [ command: "session.pin.toggle",
{ title: "pin/unpin",
command: "session.pin.toggle", onTrigger: (option: { value: string }) => {
title: "pin/unpin", local.session.togglePin(option.value)
onTrigger: (option: { value: string }) => { },
local.session.togglePin(option.value) },
},
},
]
: []),
{ {
command: "session.delete", command: "session.delete",
title: "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}`
}
@@ -1,7 +1,6 @@
import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createMemo, For, type Accessor } from "solid-js" import { createMemo, For, type Accessor } from "solid-js"
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme" import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
import { Flag } from "@opencode-ai/core/flag/flag"
import { useCommandShortcut } from "../../keymap" import { useCommandShortcut } from "../../keymap"
const themeCount = Object.keys(DEFAULT_THEMES).length 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("/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("/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("/new", shortcuts.sessionNew())} to start a fresh conversation session`,
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list and continue previous conversations`, (shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`,
...(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) => shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9()
press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"), ? `Pinned sessions are assigned quick slots; use ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} to switch`
(shortcuts) => : undefined,
shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9()
? `Pinned sessions are bound to ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} for one-press switching`
: undefined,
] satisfies Tip[])
: []),
"Run {highlight}/compact{/highlight} to summarize long sessions near context limits", "Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
(shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`, (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"), (shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"),
@@ -38,6 +38,11 @@ export interface DialogSelectProps<T> {
disabled?: boolean disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void onTrigger: (option: DialogSelectOption<T>) => void
}[] }[]
footerHints?: {
title: string
label: string
side?: "left" | "right"
}[]
bindings?: readonly Binding<Renderable, KeyEvent>[] bindings?: readonly Binding<Renderable, KeyEvent>[]
current?: T current?: T
} }
@@ -334,11 +339,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
} }
props.ref?.(ref) props.ref?.(ref)
const visibleActions = createMemo(() => const visibleActions = createMemo(() => [
actions() ...actions()
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" })) .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
.filter((item) => !item.disabled && item.label), .filter((item) => !item.disabled && item.label),
) ...(props.footerHints ?? []),
])
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right")) const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
const right = createMemo(() => visibleActions().filter((item) => item.side === "right")) const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))