Skip to content

Commit 6cc810a

Browse files
committed
feat(tui): pin, quick-switch, and cycle recent sessions
adds pinned and recent sections to the session picker, ctrl+f to pin/unpin, ctrl+h to toggle a session in/out of recent, <leader>1-9 to jump to numbered slots (pins + recent fall-through), and <leader>] / <leader>[ to cycle through visited sessions. state is persisted per-instance and auto-prunes on session delete.
1 parent c933504 commit 6cc810a

5 files changed

Lines changed: 344 additions & 37 deletions

File tree

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

Lines changed: 38 additions & 0 deletions
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,33 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
462473
dialog.clear()
463474
},
464475
},
476+
{
477+
name: "session.cycle_recent",
478+
title: "Cycle to previous recent session",
479+
category: "Session",
480+
hidden: true,
481+
run: () => {
482+
local.session.cycleRecent(1)
483+
},
484+
},
485+
{
486+
name: "session.cycle_recent_reverse",
487+
title: "Cycle to next recent session",
488+
category: "Session",
489+
hidden: true,
490+
run: () => {
491+
local.session.cycleRecent(-1)
492+
},
493+
},
494+
...Array.from({ length: 9 }, (_, i) => ({
495+
name: `session.quick_switch.${i + 1}`,
496+
title: `Switch to session in quick slot ${i + 1}`,
497+
category: "Session",
498+
hidden: true,
499+
run: () => {
500+
local.session.quickSwitch(i + 1)
501+
},
502+
})),
465503
{
466504
name: "model.list",
467505
title: "Switch model",

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

Lines changed: 88 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,6 +130,8 @@ export function DialogSessionList() {
128130

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

133+
const RECENT_LIMIT = 5
134+
131135
const options = createMemo(() => {
132136
const today = new Date().toDateString()
133137
const sessionMap = new Map(
@@ -139,46 +143,72 @@ export function DialogSessionList() {
139143
const searchResult = searchResults()
140144
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
141145

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
146+
const dismissed = new Set(local.session.dismissedRecent())
147+
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
148+
const pinnedSet = new Set(pinned)
149+
const slotByID = new Map(local.session.slots().map((id, i) => [id, i + 1]))
147150

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-
}
151+
const recent = displayOrder
152+
.filter((id) => !pinnedSet.has(id) && !dismissed.has(id))
153+
.slice(0, RECENT_LIMIT)
154+
const recentSet = new Set(recent)
164155

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

184214
onMount(() => {
@@ -203,6 +233,28 @@ export function DialogSessionList() {
203233
dialog.clear()
204234
}}
205235
actions={[
236+
{
237+
command: "session.pin.toggle",
238+
title: "pin/unpin",
239+
onTrigger: (option) => {
240+
local.session.togglePin(option.value)
241+
},
242+
},
243+
{
244+
command: "session.toggle.recent",
245+
title: "toggle recent",
246+
onTrigger: (option) => {
247+
if (local.session.isPinned(option.value)) {
248+
toast.show({
249+
variant: "info",
250+
message: "Unpin the session first to toggle it in Recent",
251+
duration: 3000,
252+
})
253+
return
254+
}
255+
local.session.toggleRecent(option.value)
256+
},
257+
},
206258
{
207259
command: "session.delete",
208260
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)