diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2ec1fb703f9..13203bf76df 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -302,6 +302,13 @@ 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 (ephemeral, per-process) + if (currentSessionID) { + kv.setEphemeral("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 85c174c1dcb..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 @@ -1,10 +1,11 @@ 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" import { Locale } from "@/util/locale" 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" @@ -23,6 +24,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 @@ -30,39 +32,76 @@ export function DialogSessionList() { return result.data ?? [] }) + const deleteKeybind = "ctrl+d" + const pinKeybind = "ctrl+b" const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) 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 lastSessionID = kv.getEphemeral("last_session_id") + + // 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 + } + + // 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() - 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 ${keybind.print("session_delete")} 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, showDate: boolean) => { + const isDeleting = toDelete() === session.id + const status = sync.data.session_status?.[session.id] + const isWorking = status?.type === "busy" + return { + title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : session.title, + bg: isDeleting ? theme.error : undefined, + value: session.id, + category, + footer: showDate ? Locale.shortDateTime(session.time.updated) : Locale.time(session.time.updated), + gutter: isWorking ? ( + [⋯]}> + + + ) : undefined, + } + } + + 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, false) + }) + + return [...pinnedOptions, ...unpinnedOptions] }) onMount(() => { @@ -71,10 +110,11 @@ export function DialogSessionList() { return ( { setToDelete(undefined) @@ -108,6 +148,20 @@ 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() }, + }) + selectRef()?.scrollToValue(option.value) + }, + }, ]} /> ) 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 }, 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 f1cdaaa5292..dcc08c283d3 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -44,6 +44,7 @@ export interface DialogSelectOption { export type DialogSelectRef = { filter: string filtered: DialogSelectOption[] + scrollToValue: (value: T) => void } export function DialogSelect(props: DialogSelectProps) { @@ -54,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) { - setStore("selected", currentIndex) - } - } - }, - ), - ) - let input: InputRenderable const filtered = createMemo(() => { @@ -193,6 +180,12 @@ export function DialogSelect(props: DialogSelectProps) { get filtered() { return filtered() }, + scrollToValue(value: T) { + const index = flat().findIndex((opt) => isDeepEqual(opt.value, value)) + if (index >= 0) { + moveTo(index) + } + }, } props.ref?.(ref) @@ -337,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)} 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) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 3fcdab5238c..ccd726d3de1 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -66,6 +66,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/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" diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6f699319965..4cc41ffa342 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -904,6 +904,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 e47c4f5f7f1..aed0d14cbd5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -742,6 +742,7 @@ export type Session = { updated: number compacting?: number archived?: number + pinned?: number } permission?: PermissionRuleset revert?: { @@ -2778,6 +2779,7 @@ export type SessionUpdateData = { title?: string time?: { archived?: number + pinned?: number | null } } path: {