From ff8395212f32bf70cd303284f8c6ec14046cdee7 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sun, 11 Jan 2026 21:17:59 -0500 Subject: [PATCH 01/19] feat: add session bookmarking to group important sessions at the top --- .../cmd/tui/component/dialog-session-list.tsx | 74 +++++++++++++------ packages/opencode/src/config/config.ts | 1 + packages/opencode/src/server/server.ts | 4 + packages/opencode/src/session/index.ts | 1 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 6 ++ 6 files changed, 63 insertions(+), 24 deletions(-) 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 07de4d47200..2306861d676 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 @@ -30,6 +30,7 @@ export function DialogSessionList() { }) const deleteKeybind = "ctrl+d" + const pinKeybind = "ctrl+b" const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) @@ -39,31 +40,43 @@ export function DialogSessionList() { const options = createMemo(() => { const today = new Date().toDateString() - return sessions() - .filter((x) => x.parentID === undefined) + const allSessions = sessions().filter((x) => x.parentID === undefined) + + const pinned = allSessions + .filter((x) => x.time.pinned !== undefined) + .toSorted((a, b) => (b.time.pinned ?? 0) - (a.time.pinned ?? 0)) + + const unpinned = allSessions + .filter((x) => x.time.pinned === undefined) .toSorted((a, b) => b.time.updated - a.time.updated) - .map((x) => { - const date = new Date(x.time.updated) - let category = date.toDateString() - if (category === today) { - category = "Today" - } - const isDeleting = toDelete() === x.id - const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" - return { - title: isDeleting ? `Press ${deleteKeybind} again to confirm` : x.title, - bg: isDeleting ? theme.error : undefined, - value: x.id, - category, - footer: Locale.time(x.time.updated), - gutter: isWorking ? ( - [⋯]}> - - - ) : undefined, - } - }) + + const mapSession = (session: typeof allSessions[number], category: string) => { + const isDeleting = toDelete() === session.id + const status = sync.data.session_status?.[session.id] + const isWorking = status?.type === "busy" + return { + title: isDeleting ? `Press ${deleteKeybind} again to confirm` : session.title, + bg: isDeleting ? theme.error : undefined, + value: session.id, + category, + footer: Locale.time(session.time.updated), + gutter: isWorking ? ( + [⋯]}> + + + ) : undefined, + } + } + + const pinnedOptions = pinned.map((x) => mapSession(x, "Bookmarks")) + + const unpinnedOptions = unpinned.map((x) => { + const date = new Date(x.time.updated) + const category = date.toDateString() === today ? "Today" : date.toDateString() + return mapSession(x, category) + }) + + return [...pinnedOptions, ...unpinnedOptions] }) onMount(() => { @@ -109,6 +122,19 @@ export function DialogSessionList() { dialog.replace(() => ) }, }, + { + keybind: Keybind.parse(pinKeybind)[0], + title: "bookmark", + onTrigger: async (option) => { + const session = sessions().find((s) => s.id === option.value) + if (!session) return + const isPinned = session.time.pinned !== undefined + await sdk.client.session.update({ + sessionID: option.value, + time: { pinned: isPinned ? null : Date.now() }, + }) + }, + }, ]} /> ) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ead3a0149b4..888c442fd57 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -629,6 +629,7 @@ export namespace Config { session_rename: z.string().optional().default("none").describe("Rename session"), session_share: z.string().optional().default("none").describe("Share current session"), session_unshare: z.string().optional().default("none").describe("Unshare current session"), + session_pin: z.string().optional().default("ctrl+b").describe("Bookmark/unbookmark session in list"), session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), session_compact: z.string().optional().default("c").describe("Compact the session"), messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"), diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 32d7a179555..47d60429c27 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -937,6 +937,7 @@ export namespace Server { time: z .object({ archived: z.number().optional(), + pinned: z.number().nullable().optional(), }) .optional(), }), @@ -950,6 +951,9 @@ export namespace Server { session.title = updates.title } if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived + if (updates.time?.pinned !== undefined) { + session.time.pinned = updates.time.pinned === null ? undefined : updates.time.pinned + } }) return c.json(updatedSession) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index a204913f77d..da4938d428e 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -62,6 +62,7 @@ export namespace Session { updated: z.number(), compacting: z.number().optional(), archived: z.number().optional(), + pinned: z.number().optional(), }), permission: PermissionNext.Ruleset.optional(), revert: z diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f83913ea5e1..0da1c6ee5b1 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -937,6 +937,7 @@ export class Session extends HeyApiClient { title?: string time?: { archived?: number + pinned?: number | null } }, options?: Options, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e423fecea42..db9924a29e6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -725,6 +725,7 @@ export type Session = { updated: number compacting?: number archived?: number + pinned?: number } permission?: PermissionRuleset revert?: { @@ -969,6 +970,10 @@ export type KeybindsConfig = { * Unshare current session */ session_unshare?: string + /** + * Pin/unpin session in list + */ + session_pin?: string /** * Interrupt current session */ @@ -2741,6 +2746,7 @@ export type SessionUpdateData = { title?: string time?: { archived?: number + pinned?: number | null } } path: { From 9a407e52c563e061c2b3790a3a12293ec95f0422 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sun, 11 Jan 2026 21:35:25 -0500 Subject: [PATCH 02/19] chore: remove unused session_pin config keybind (hardcoded in dialog) --- packages/opencode/src/config/config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 888c442fd57..ead3a0149b4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -629,7 +629,6 @@ export namespace Config { session_rename: z.string().optional().default("none").describe("Rename session"), session_share: z.string().optional().default("none").describe("Share current session"), session_unshare: z.string().optional().default("none").describe("Unshare current session"), - session_pin: z.string().optional().default("ctrl+b").describe("Bookmark/unbookmark session in list"), session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), session_compact: z.string().optional().default("c").describe("Compact the session"), messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"), From af039987ce68bc08ce29ad8688ca76e2ae4f96d9 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sun, 11 Jan 2026 21:37:49 -0500 Subject: [PATCH 03/19] chore: regenerate SDK types after removing unused config key --- packages/sdk/js/src/v2/gen/types.gen.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index db9924a29e6..bfb97623876 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -970,10 +970,6 @@ export type KeybindsConfig = { * Unshare current session */ session_unshare?: string - /** - * Pin/unpin session in list - */ - session_pin?: string /** * Interrupt current session */ From bdd45c6d217f88fc038adf80908072416d7978fd Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Tue, 13 Jan 2026 18:32:46 -0500 Subject: [PATCH 04/19] fix: improve session bookmark selection behavior - Add fallback to select most recently updated session when no active session exists - Merge search results with live session data to show bookmark changes immediately while filtering - Fixes issue where bookmarked session was selected by default instead of most recent activity - Fixes issue where bookmarked sessions didn't move between groups while filtering --- .../cmd/tui/component/dialog-session-list.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) 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 2306861d676..b787d8e6cb2 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 @@ -36,7 +36,20 @@ export function DialogSessionList() { const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] - const sessions = createMemo(() => searchResults() ?? sync.data.session) + const sessions = createMemo(() => { + const results = searchResults() + if (!results) return sync.data.session + return results.map((result) => { + const live = sync.data.session.find((s) => s.id === result.id) + return live ?? result + }) + }) + + const defaultSessionID = createMemo(() => { + const allSessions = sessions().filter((x) => x.parentID === undefined) + const sorted = allSessions.toSorted((a, b) => b.time.updated - a.time.updated) + return sorted[0]?.id + }) const options = createMemo(() => { const today = new Date().toDateString() @@ -88,7 +101,7 @@ export function DialogSessionList() { title="Sessions" options={options()} skipFilter={true} - current={currentSessionID()} + current={currentSessionID() ?? defaultSessionID()} onFilter={setSearch} onMove={() => { setToDelete(undefined) From 5887682e23c0587adb73612cdfab7a1c6a2d35d7 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Tue, 13 Jan 2026 18:59:27 -0500 Subject: [PATCH 05/19] fix: remember last session when navigating to home with /new - Store last_session_id in local KV store when using /new or session.new command - Use last_session_id as default selection when opening session list from home - Allows easily returning to the previous session by pressing Enter - Per-instance tracking prevents interference between multiple OpenCode instances - Falls back to most recently updated session if last_session_id is not found --- packages/opencode/src/cli/cmd/tui/app.tsx | 8 ++++++++ .../src/cli/cmd/tui/component/dialog-session-list.tsx | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 1fea3f4b305..e61b0a6fc2f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -307,6 +307,14 @@ function App() { const current = promptRef.current // Don't require focus - if there's any text, preserve it const currentPrompt = current?.current?.input ? current.current : undefined + + const currentSessionID = route.data.type === "session" ? route.data.sessionID : undefined + + // Store the last session ID so we can return to it easily + if (currentSessionID) { + const kv = useKV() + kv.set("last_session_id", currentSessionID) + } route.navigate({ type: "home", initialPrompt: currentPrompt, 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 b787d8e6cb2..c9acba2bfff 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 @@ -46,11 +46,21 @@ export function DialogSessionList() { }) const defaultSessionID = createMemo(() => { + const lastSessionID = kv.get("last_session_id") + + // First try the last session we were in + if (lastSessionID) { + const session = sessions().find((s) => s.id === lastSessionID) + if (session) return session.id + } + + // Fallback to most recently updated session const allSessions = sessions().filter((x) => x.parentID === undefined) const sorted = allSessions.toSorted((a, b) => b.time.updated - a.time.updated) return sorted[0]?.id }) + const options = createMemo(() => { const today = new Date().toDateString() const allSessions = sessions().filter((x) => x.parentID === undefined) From cc432288d174e5917ad305e602a3d467dece862e Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Tue, 13 Jan 2026 19:22:18 -0500 Subject: [PATCH 06/19] fix: remove duplicate useKV() hook call in session.new command Hook cannot be called inside onSelect callback. Use kv from component scope. --- packages/opencode/src/cli/cmd/tui/app.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index e61b0a6fc2f..525be9fd6da 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -312,7 +312,6 @@ function App() { // Store the last session ID so we can return to it easily if (currentSessionID) { - const kv = useKV() kv.set("last_session_id", currentSessionID) } route.navigate({ From 79c6530585f881ca3b5ef20be3cfc7f8ffbbba8c Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Wed, 14 Jan 2026 17:31:46 -0500 Subject: [PATCH 07/19] fix: keep selection visible when bookmarking/unbookmarking sessions --- .../src/cli/cmd/tui/component/dialog-session-list.tsx | 5 ++++- packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) 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 c9acba2bfff..b0fde032626 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 @@ -1,5 +1,5 @@ import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" +import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" @@ -22,6 +22,7 @@ export function DialogSessionList() { const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) + const [selectRef, setSelectRef] = createSignal>() const [searchResults] = createResource(search, async (query) => { if (!query) return undefined @@ -108,6 +109,7 @@ export function DialogSessionList() { return ( { export type DialogSelectRef = { filter: string filtered: DialogSelectOption[] + scrollToSelected: () => void } export function DialogSelect(props: DialogSelectProps) { @@ -185,6 +186,9 @@ export function DialogSelect(props: DialogSelectProps) { get filtered() { return filtered() }, + scrollToSelected() { + moveTo(store.selected) + }, } props.ref?.(ref) From 0a65d99fb6c0f9395aa3f487ef65dd674e99ab26 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Wed, 14 Jan 2026 17:43:12 -0500 Subject: [PATCH 08/19] fix: scroll to selected session on dialog open and after bookmark toggle - Add scrollToValue(value) method to DialogSelectRef that finds the value's index and scrolls to it - Change initial current effect to use moveTo() for proper viewport scrolling - Call scrollToValue() after bookmark toggle to follow the session to its new position --- .../src/cli/cmd/tui/component/dialog-session-list.tsx | 2 +- .../opencode/src/cli/cmd/tui/ui/dialog-select.tsx | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) 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 b0fde032626..ba51c5647b5 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 @@ -158,7 +158,7 @@ export function DialogSessionList() { sessionID: option.value, time: { pinned: isPinned ? null : Date.now() }, }) - selectRef()?.scrollToSelected() + selectRef()?.scrollToValue(option.value) }, }, ]} 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 b502f2e1537..a2a256fc00d 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -44,7 +44,7 @@ export interface DialogSelectOption { export type DialogSelectRef = { filter: string filtered: DialogSelectOption[] - scrollToSelected: () => void + scrollToValue: (value: T) => void } export function DialogSelect(props: DialogSelectProps) { @@ -62,7 +62,7 @@ export function DialogSelect(props: DialogSelectProps) { if (current) { const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) if (currentIndex >= 0) { - setStore("selected", currentIndex) + moveTo(currentIndex) } } }, @@ -186,8 +186,11 @@ export function DialogSelect(props: DialogSelectProps) { get filtered() { return filtered() }, - scrollToSelected() { - moveTo(store.selected) + scrollToValue(value: T) { + const index = flat().findIndex((opt) => isDeepEqual(opt.value, value)) + if (index >= 0) { + moveTo(index) + } }, } props.ref?.(ref) From e8610f2dd8c1180d840710c4e42c95455e293320 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Wed, 14 Jan 2026 17:51:42 -0500 Subject: [PATCH 09/19] fix: scroll viewport to selected item when dialog opens - Use moveTo() instead of setStore() + scrollTo(0) in filter/current effect - moveTo() properly scrolls to the selected item's position - Remove redundant effect that only handled props.current --- .../src/cli/cmd/tui/ui/dialog-select.tsx | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) 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 a2a256fc00d..a4d29598954 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -55,20 +55,6 @@ export function DialogSelect(props: DialogSelectProps) { filter: "", }) - createEffect( - on( - () => props.current, - (current) => { - if (current) { - const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) - if (currentIndex >= 0) { - moveTo(currentIndex) - } - } - }, - ), - ) - let input: InputRenderable const filtered = createMemo(() => { @@ -111,14 +97,13 @@ export function DialogSelect(props: DialogSelectProps) { createEffect( on([() => store.filter, () => props.current], ([filter, current]) => { if (filter.length > 0) { - setStore("selected", 0) + moveTo(0) } else if (current) { const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) if (currentIndex >= 0) { - setStore("selected", currentIndex) + moveTo(currentIndex) } } - scroll?.scrollTo(0) }), ) From 059ca292cc8340b10fd08a9f64a200ccca7dd22f Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Wed, 14 Jan 2026 17:55:40 -0500 Subject: [PATCH 10/19] fix: defer initial scroll to next tick to ensure component is rendered The scroll component wasn't ready when the effect first ran, causing moveTo() to return early. Using setTimeout(0) defers the scroll until after the component has rendered. --- .../src/cli/cmd/tui/ui/dialog-select.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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 a4d29598954..ab6b5d214f7 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -96,14 +96,16 @@ export function DialogSelect(props: DialogSelectProps) { createEffect( on([() => store.filter, () => props.current], ([filter, current]) => { - if (filter.length > 0) { - moveTo(0) - } else if (current) { - const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) - if (currentIndex >= 0) { - moveTo(currentIndex) + setTimeout(() => { + if (filter.length > 0) { + moveTo(0) + } else if (current) { + const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) + if (currentIndex >= 0) { + moveTo(currentIndex) + } } - } + }, 0) }), ) From ca4373c3939b57449d3020402c1d5f6902a3c320 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Wed, 14 Jan 2026 17:59:27 -0500 Subject: [PATCH 11/19] feat: center selected item in viewport when dialog opens or after bookmark - Add 'center' parameter to moveTo() function - Pass center=true from effect (dialog open) and scrollToValue (bookmark toggle) - Keyboard navigation still uses edge-scroll behavior for smooth UX --- .../src/cli/cmd/tui/ui/dialog-select.tsx | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) 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 ab6b5d214f7..d6f8344ccf3 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -98,11 +98,11 @@ export function DialogSelect(props: DialogSelectProps) { on([() => store.filter, () => props.current], ([filter, current]) => { setTimeout(() => { if (filter.length > 0) { - moveTo(0) + moveTo(0, true) } else if (current) { const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) if (currentIndex >= 0) { - moveTo(currentIndex) + moveTo(currentIndex, true) } } }, 0) @@ -117,7 +117,7 @@ export function DialogSelect(props: DialogSelectProps) { moveTo(next) } - function moveTo(next: number) { + function moveTo(next: number, center = false) { setStore("selected", next) props.onMove?.(selected()!) if (!scroll) return @@ -126,13 +126,18 @@ export function DialogSelect(props: DialogSelectProps) { }) if (!target) return const y = target.y - scroll.y - if (y >= scroll.height) { - scroll.scrollBy(y - scroll.height + 1) - } - if (y < 0) { - scroll.scrollBy(y) - if (isDeepEqual(flat()[0].value, selected()?.value)) { - scroll.scrollTo(0) + if (center) { + const centerOffset = Math.floor(scroll.height / 2) + scroll.scrollBy(y - centerOffset) + } else { + if (y >= scroll.height) { + scroll.scrollBy(y - scroll.height + 1) + } + if (y < 0) { + scroll.scrollBy(y) + if (isDeepEqual(flat()[0].value, selected()?.value)) { + scroll.scrollTo(0) + } } } } @@ -176,7 +181,7 @@ export function DialogSelect(props: DialogSelectProps) { scrollToValue(value: T) { const index = flat().findIndex((opt) => isDeepEqual(opt.value, value)) if (index >= 0) { - moveTo(index) + moveTo(index, true) } }, } From 1fbe08ab2b2dc26db2c28eb2ab034e28bba3fcbc Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Wed, 14 Jan 2026 19:10:08 -0500 Subject: [PATCH 12/19] refactor: remove viewport centering fix from bookmark branch Keep centering fix separate in fix/center-selected-session branch. This branch now only contains bookmark-specific changes. --- .../src/cli/cmd/tui/ui/dialog-select.tsx | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) 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 d6f8344ccf3..83424ae3f48 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -96,16 +96,15 @@ export function DialogSelect(props: DialogSelectProps) { createEffect( on([() => store.filter, () => props.current], ([filter, current]) => { - setTimeout(() => { - if (filter.length > 0) { - moveTo(0, true) - } else if (current) { - const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) - if (currentIndex >= 0) { - moveTo(currentIndex, true) - } + if (filter.length > 0) { + setStore("selected", 0) + } else if (current) { + const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) + if (currentIndex >= 0) { + setStore("selected", currentIndex) } - }, 0) + } + scroll?.scrollTo(0) }), ) @@ -117,7 +116,7 @@ export function DialogSelect(props: DialogSelectProps) { moveTo(next) } - function moveTo(next: number, center = false) { + function moveTo(next: number) { setStore("selected", next) props.onMove?.(selected()!) if (!scroll) return @@ -126,18 +125,13 @@ export function DialogSelect(props: DialogSelectProps) { }) if (!target) return const y = target.y - scroll.y - if (center) { - const centerOffset = Math.floor(scroll.height / 2) - scroll.scrollBy(y - centerOffset) - } else { - if (y >= scroll.height) { - scroll.scrollBy(y - scroll.height + 1) - } - if (y < 0) { - scroll.scrollBy(y) - if (isDeepEqual(flat()[0].value, selected()?.value)) { - scroll.scrollTo(0) - } + if (y >= scroll.height) { + scroll.scrollBy(y - scroll.height + 1) + } + if (y < 0) { + scroll.scrollBy(y) + if (isDeepEqual(flat()[0].value, selected()?.value)) { + scroll.scrollTo(0) } } } @@ -181,7 +175,7 @@ export function DialogSelect(props: DialogSelectProps) { scrollToValue(value: T) { const index = flat().findIndex((opt) => isDeepEqual(opt.value, value)) if (index >= 0) { - moveTo(index, true) + moveTo(index) } }, } From 22e1d1d06e1a1691b0b342c16baa13e7c5962c7f Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Wed, 14 Jan 2026 19:28:04 -0500 Subject: [PATCH 13/19] fix: make last_session_id ephemeral and per-process - Add getEphemeral/setEphemeral methods to KV context for in-memory storage - Change app.tsx to use setEphemeral for last_session_id - Change dialog-session-list to use getEphemeral for last_session_id - Ephemeral storage is per-process, not persisted to disk - This ensures each opencode process has its own last_session_id - After program restart, the topmost session is selected --- packages/opencode/src/cli/cmd/tui/app.tsx | 4 ++-- .../src/cli/cmd/tui/component/dialog-session-list.tsx | 4 ++-- packages/opencode/src/cli/cmd/tui/context/kv.tsx | 7 +++++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 525be9fd6da..6b7335d0d1a 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -310,9 +310,9 @@ function App() { const currentSessionID = route.data.type === "session" ? route.data.sessionID : undefined - // Store the last session ID so we can return to it easily + // Store the last session ID so we can return to it easily (ephemeral, per-process) if (currentSessionID) { - kv.set("last_session_id", currentSessionID) + kv.setEphemeral("last_session_id", currentSessionID) } route.navigate({ type: "home", 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 ba51c5647b5..d7e98481316 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 @@ -47,9 +47,9 @@ export function DialogSessionList() { }) const defaultSessionID = createMemo(() => { - const lastSessionID = kv.get("last_session_id") + const lastSessionID = kv.getEphemeral("last_session_id") - // First try the last session we were in + // First try last session we were in (ephemeral, per-process) if (lastSessionID) { const session = sessions().find((s) => s.id === lastSessionID) if (session) return session.id diff --git a/packages/opencode/src/cli/cmd/tui/context/kv.tsx b/packages/opencode/src/cli/cmd/tui/context/kv.tsx index 651c2dbc0c7..09c2ba50ecc 100644 --- a/packages/opencode/src/cli/cmd/tui/context/kv.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/kv.tsx @@ -9,6 +9,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ init: () => { const [ready, setReady] = createSignal(false) const [store, setStore] = createStore>() + const ephemeral: Record = {} const file = Bun.file(path.join(Global.Path.state, "kv.json")) file @@ -46,6 +47,12 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ setStore(key, value) Bun.write(file, JSON.stringify(store, null, 2)) }, + getEphemeral(key: string, defaultValue?: any) { + return ephemeral[key] ?? defaultValue + }, + setEphemeral(key: string, value: any) { + ephemeral[key] = value + }, } return result }, From 4ce67892aacd6e4d99cc4f7bcd9298c7f8c2c2ae Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sat, 17 Jan 2026 03:11:03 -0500 Subject: [PATCH 14/19] Fix: add server-side support for session pinned/bookmark field --- .../opencode/src/cli/cmd/tui/component/dialog-session-list.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 4b39cfa06b2..c160d66c139 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 @@ -4,7 +4,8 @@ import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" import { Locale } from "@/util/locale" -import { useKeybind, Keybind } from "../context/keybind" +import { useKeybind } from "../context/keybind" +import { Keybind } from "@/util/keybind" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { DialogSessionRename } from "./dialog-session-rename" From 86d462be83a5e8635630307a19b94f2394f8e342 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sat, 17 Jan 2026 03:12:56 -0500 Subject: [PATCH 15/19] Fix: add server-side support for session pinned/bookmark field --- packages/opencode/src/server/routes/session.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index a98624dfae2..9fd6cf2b63c 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -268,6 +268,7 @@ export const SessionRoutes = lazy(() => time: z .object({ archived: z.number().optional(), + pinned: z.number().nullable().optional(), }) .optional(), }), @@ -281,6 +282,7 @@ export const SessionRoutes = lazy(() => session.title = updates.title } if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived + if (updates.time?.pinned !== undefined) session.time.pinned = updates.time.pinned ?? undefined }) return c.json(updatedSession) From b9b3bbe345c2ff99dbc15da8d59b74fa5cd1ddc6 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sun, 18 Jan 2026 04:05:38 -0500 Subject: [PATCH 16/19] tui: show date with time for bookmarked sessions --- .../src/cli/cmd/tui/component/dialog-session-list.tsx | 8 ++++---- packages/opencode/src/util/locale.ts | 6 ++++++ 2 files changed, 10 insertions(+), 4 deletions(-) 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 c160d66c139..7b2987da896 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 @@ -75,7 +75,7 @@ export function DialogSessionList() { .filter((x) => x.time.pinned === undefined) .toSorted((a, b) => b.time.updated - a.time.updated) - const mapSession = (session: typeof allSessions[number], category: string) => { + const mapSession = (session: typeof allSessions[number], category: string, showDate: boolean) => { const isDeleting = toDelete() === session.id const status = sync.data.session_status?.[session.id] const isWorking = status?.type === "busy" @@ -84,7 +84,7 @@ export function DialogSessionList() { bg: isDeleting ? theme.error : undefined, value: session.id, category, - footer: Locale.time(session.time.updated), + footer: showDate ? Locale.shortDateTime(session.time.updated) : Locale.time(session.time.updated), gutter: isWorking ? ( [⋯]}> @@ -93,12 +93,12 @@ export function DialogSessionList() { } } - const pinnedOptions = pinned.map((x) => mapSession(x, "Bookmarks")) + const pinnedOptions = pinned.map((x) => mapSession(x, "Bookmarks", true)) const unpinnedOptions = unpinned.map((x) => { const date = new Date(x.time.updated) const category = date.toDateString() === today ? "Today" : date.toDateString() - return mapSession(x, category) + return mapSession(x, category, false) }) return [...pinnedOptions, ...unpinnedOptions] diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 653da09a0b7..89bf4a3bb82 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -28,6 +28,12 @@ export namespace Locale { } } + export function shortDateTime(input: number): string { + const date = new Date(input) + const dateStr = date.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + return `${dateStr}, ${time(input)}` + } + export function number(num: number): string { if (num >= 1000000) { return (num / 1000000).toFixed(1) + "M" From 958f88a01be55228b136efe28a11845cc2e7f86f Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Sun, 18 Jan 2026 04:17:28 -0500 Subject: [PATCH 17/19] tui: fix extra empty line for long session titles in dialog --- packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx | 1 + 1 file changed, 1 insertion(+) 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 969d881c3db..dcc08c283d3 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -330,6 +330,7 @@ function Option(props: { fg={props.active ? fg : props.current ? theme.primary : theme.text} attributes={props.active ? TextAttributes.BOLD : undefined} overflow="hidden" + wrapMode="none" paddingLeft={3} > {Locale.truncate(props.title, 61)} From daa08673e615c5f51d57b61c0adbe7cc5597235d Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 19 Jan 2026 13:19:54 -0500 Subject: [PATCH 18/19] tui: pad single-digit days in bookmark date display for alignment --- packages/opencode/src/util/locale.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 89bf4a3bb82..35f2d3efab9 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -30,8 +30,9 @@ export namespace Locale { export function shortDateTime(input: number): string { const date = new Date(input) - const dateStr = date.toLocaleDateString(undefined, { month: "short", day: "numeric" }) - return `${dateStr}, ${time(input)}` + const month = date.toLocaleDateString(undefined, { month: "short" }) + const day = date.getDate().toString().padStart(2, " ") + return `${month} ${day}, ${time(input)}` } export function number(num: number): string { From bbcb0c0206f87052f68358574e0c5c14b4acecb6 Mon Sep 17 00:00:00 2001 From: Ariane Emory Date: Mon, 19 Jan 2026 14:02:04 -0500 Subject: [PATCH 19/19] tui: pad single-digit hours in time display for alignment --- packages/opencode/src/util/locale.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 35f2d3efab9..581cf46a1dd 100644 --- a/packages/opencode/src/util/locale.ts +++ b/packages/opencode/src/util/locale.ts @@ -5,7 +5,10 @@ export namespace Locale { export function time(input: number): string { const date = new Date(input) - return date.toLocaleTimeString(undefined, { timeStyle: "short" }) + const str = date.toLocaleTimeString(undefined, { timeStyle: "short" }) + // Pad single-digit hours with leading space for alignment (e.g., "9:38 PM" -> " 9:38 PM") + if (/^\d:/.test(str)) return " " + str + return str } export function datetime(input: number): string {