feat(tui): enable pinned session switching (#27780)
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user