Skip to content

Commit 12583b1

Browse files
authored
feat(tui): pin, quick-switch, and cycle recent sessions (#26858)
1 parent df386bd commit 12583b1

6 files changed

Lines changed: 373 additions & 38 deletions

File tree

packages/core/src/flag/flag.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ export const Flag = {
8585
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
8686
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
8787
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
88+
OPENCODE_EXPERIMENTAL_SESSION_SWITCHING:
89+
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SESSION_SWITCHING"),
8890

8991
// Evaluated at access time (not module load) because tests, the CLI, and
9092
// external tooling set these env vars at runtime.

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,17 @@ const appBindingCommands = [
7474
"command.palette.show",
7575
"session.list",
7676
"session.new",
77+
"session.cycle_recent",
78+
"session.cycle_recent_reverse",
79+
"session.quick_switch.1",
80+
"session.quick_switch.2",
81+
"session.quick_switch.3",
82+
"session.quick_switch.4",
83+
"session.quick_switch.5",
84+
"session.quick_switch.6",
85+
"session.quick_switch.7",
86+
"session.quick_switch.8",
87+
"session.quick_switch.9",
7788
"model.list",
7889
"model.cycle_recent",
7990
"model.cycle_recent_reverse",
@@ -462,6 +473,37 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
462473
dialog.clear()
463474
},
464475
},
476+
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
477+
? [
478+
{
479+
name: "session.cycle_recent",
480+
title: "Cycle to previous recent session",
481+
category: "Session",
482+
hidden: true,
483+
run: () => {
484+
local.session.cycleRecent(1)
485+
},
486+
},
487+
{
488+
name: "session.cycle_recent_reverse",
489+
title: "Cycle to next recent session",
490+
category: "Session",
491+
hidden: true,
492+
run: () => {
493+
local.session.cycleRecent(-1)
494+
},
495+
},
496+
...Array.from({ length: 9 }, (_, i) => ({
497+
name: `session.quick_switch.${i + 1}`,
498+
title: `Switch to session in quick slot ${i + 1}`,
499+
category: "Session",
500+
hidden: true,
501+
run: () => {
502+
local.session.quickSwitch(i + 1)
503+
},
504+
})),
505+
]
506+
: []),
465507
{
466508
name: "model.list",
467509
title: "Switch model",
@@ -776,7 +818,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
776818

777819
useBindings(() => ({
778820
enabled: command.matcher,
779-
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
821+
bindings: tuiConfig.keybinds.gather(
822+
"app",
823+
Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
824+
? appBindingCommands
825+
: appBindingCommands.filter(
826+
(c) => !c.startsWith("session.cycle_recent") && !c.startsWith("session.quick_switch"),
827+
),
828+
),
780829
}))
781830

782831
useBindings(() => ({

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

Lines changed: 95 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Locale } from "@/util/locale"
77
import { useProject } from "@tui/context/project"
88
import { useTheme } from "../context/theme"
99
import { useSDK } from "../context/sdk"
10+
import { useLocal } from "../context/local"
1011
import { Flag } from "@opencode-ai/core/flag/flag"
1112
import { DialogSessionRename } from "./dialog-session-rename"
1213
import { createDebouncedSignal } from "../util/signal"
@@ -25,6 +26,7 @@ export function DialogSessionList() {
2526
const project = useProject()
2627
const { theme } = useTheme()
2728
const sdk = useSDK()
29+
const local = useLocal()
2830
const toast = useToast()
2931
const [toDelete, setToDelete] = createSignal<string>()
3032
const [search, setSearch] = createDebouncedSignal("", 150)
@@ -128,7 +130,10 @@ export function DialogSessionList() {
128130

129131
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
130132

133+
const RECENT_LIMIT = 5
134+
131135
const options = createMemo(() => {
136+
const enabled = Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
132137
const today = new Date().toDateString()
133138
const sessionMap = new Map(
134139
sessions()
@@ -139,46 +144,74 @@ export function DialogSessionList() {
139144
const searchResult = searchResults()
140145
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
141146

142-
return displayOrder
143-
.map((id) => sessionMap.get(id))
144-
.filter((x) => x !== undefined)
145-
.map((x) => {
146-
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
147+
const dismissed = enabled ? new Set(local.session.dismissedRecent()) : new Set<string>()
148+
const pinned = enabled ? local.session.pinned().filter((id) => sessionMap.has(id)) : []
149+
const pinnedSet = new Set(pinned)
150+
const slotByID = enabled
151+
? new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
152+
: new Map<string, number>()
147153

148-
let footer: JSX.Element | string = ""
149-
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
150-
if (x.workspaceID) {
151-
footer = workspace ? (
152-
<WorkspaceLabel
153-
type={workspace.type}
154-
name={workspace.name}
155-
status={project.workspace.status(x.workspaceID) ?? "error"}
156-
/>
157-
) : (
158-
<WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
159-
)
160-
}
161-
} else {
162-
footer = Locale.time(x.time.updated)
163-
}
154+
const recent = enabled
155+
? displayOrder.filter((id) => !pinnedSet.has(id) && !dismissed.has(id)).slice(0, RECENT_LIMIT)
156+
: []
157+
const recentSet = new Set(recent)
164158

165-
const date = new Date(x.time.updated)
166-
let category = date.toDateString()
167-
if (category === today) {
168-
category = "Today"
169-
}
170-
const isDeleting = toDelete() === x.id
171-
const status = sync.data.session_status?.[x.id]
172-
const isWorking = status?.type === "busy" || status?.type === "retry"
173-
return {
174-
title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
175-
bg: isDeleting ? theme.error : undefined,
176-
value: x.id,
177-
category,
178-
footer,
179-
gutter: isWorking ? () => <Spinner /> : undefined,
159+
function buildOption(id: string, category: string) {
160+
const x = sessionMap.get(id)
161+
if (!x) return undefined
162+
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
163+
164+
let footer: JSX.Element | string = ""
165+
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
166+
if (x.workspaceID) {
167+
footer = workspace ? (
168+
<WorkspaceLabel
169+
type={workspace.type}
170+
name={workspace.name}
171+
status={project.workspace.status(x.workspaceID) ?? "error"}
172+
/>
173+
) : (
174+
<WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
175+
)
180176
}
177+
} else {
178+
footer = Locale.time(x.time.updated)
179+
}
180+
181+
const isDeleting = toDelete() === x.id
182+
const status = sync.data.session_status?.[x.id]
183+
const isWorking = status?.type === "busy" || status?.type === "retry"
184+
const slot = slotByID.get(x.id)
185+
const gutter = isWorking
186+
? () => <Spinner />
187+
: slot !== undefined
188+
? () => <text fg={theme.accent}>{slot}</text>
189+
: undefined
190+
return {
191+
title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
192+
bg: isDeleting ? theme.error : undefined,
193+
value: x.id,
194+
category,
195+
footer,
196+
gutter,
197+
}
198+
}
199+
200+
const remaining = displayOrder
201+
.filter((id) => !pinnedSet.has(id) && !recentSet.has(id))
202+
.map((id) => {
203+
const x = sessionMap.get(id)
204+
if (!x) return undefined
205+
const label = new Date(x.time.updated).toDateString()
206+
return buildOption(id, label === today ? "Today" : label)
181207
})
208+
.filter((x) => x !== undefined)
209+
210+
return [
211+
...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined),
212+
...recent.map((id) => buildOption(id, "Recent")).filter((x) => x !== undefined),
213+
...remaining,
214+
]
182215
})
183216

184217
onMount(() => {
@@ -203,6 +236,32 @@ export function DialogSessionList() {
203236
dialog.clear()
204237
}}
205238
actions={[
239+
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
240+
? [
241+
{
242+
command: "session.pin.toggle",
243+
title: "pin/unpin",
244+
onTrigger: (option: { value: string }) => {
245+
local.session.togglePin(option.value)
246+
},
247+
},
248+
{
249+
command: "session.toggle.recent",
250+
title: "toggle recent",
251+
onTrigger: (option: { value: string }) => {
252+
if (local.session.isPinned(option.value)) {
253+
toast.show({
254+
variant: "info",
255+
message: "Unpin the session first to toggle it in Recent",
256+
duration: 3000,
257+
})
258+
return
259+
}
260+
local.session.toggleRecent(option.value)
261+
},
262+
},
263+
]
264+
: []),
206265
{
207266
command: "session.delete",
208267
title: "delete",

packages/opencode/src/cli/cmd/tui/config/keybind.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,19 @@ const Definitions = {
8080
session_child_cycle: keybind("right", "Go to next child session"),
8181
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
8282
session_parent: keybind("up", "Go to parent session"),
83+
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
84+
session_toggle_recent: keybind("ctrl+h", "Show or hide session in the Recent group"),
85+
session_cycle_recent: keybind("<leader>]", "Cycle to the previous recent session"),
86+
session_cycle_recent_reverse: keybind("<leader>[", "Cycle to the next recent session"),
87+
session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
88+
session_quick_switch_2: keybind("<leader>2", "Switch to session in quick slot 2"),
89+
session_quick_switch_3: keybind("<leader>3", "Switch to session in quick slot 3"),
90+
session_quick_switch_4: keybind("<leader>4", "Switch to session in quick slot 4"),
91+
session_quick_switch_5: keybind("<leader>5", "Switch to session in quick slot 5"),
92+
session_quick_switch_6: keybind("<leader>6", "Switch to session in quick slot 6"),
93+
session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
94+
session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
95+
session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
8396

8497
stash_delete: keybind("ctrl+d", "Delete stash entry"),
8598
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
@@ -257,6 +270,19 @@ export const CommandMap = {
257270
session_child_cycle: "session.child.next",
258271
session_child_cycle_reverse: "session.child.previous",
259272
session_parent: "session.parent",
273+
session_pin_toggle: "session.pin.toggle",
274+
session_toggle_recent: "session.toggle.recent",
275+
session_cycle_recent: "session.cycle_recent",
276+
session_cycle_recent_reverse: "session.cycle_recent_reverse",
277+
session_quick_switch_1: "session.quick_switch.1",
278+
session_quick_switch_2: "session.quick_switch.2",
279+
session_quick_switch_3: "session.quick_switch.3",
280+
session_quick_switch_4: "session.quick_switch.4",
281+
session_quick_switch_5: "session.quick_switch.5",
282+
session_quick_switch_6: "session.quick_switch.6",
283+
session_quick_switch_7: "session.quick_switch.7",
284+
session_quick_switch_8: "session.quick_switch.8",
285+
session_quick_switch_9: "session.quick_switch.9",
260286
stash_delete: "stash.delete",
261287
model_provider_list: "model.dialog.provider",
262288
model_favorite_toggle: "model.dialog.favorite",

0 commit comments

Comments
 (0)