diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 0d5aefe7bc3..a84f624cc9c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -340,6 +340,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 775969bfcb3..6d76790bab4 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,33 +32,77 @@ 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 sessions = createMemo(() => searchResults() ?? sync.data.session) + const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + + 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 non-bookmarked session + const allSessions = sessions().filter((x) => x.parentID === undefined) + const unpinned = allSessions.filter((x) => x.time.pinned === undefined) + const sorted = unpinned.toSorted((a, b) => b.time.updated - a.time.updated) + // Fall back to bookmarked sessions only if no non-bookmarked sessions exist + return sorted[0]?.id ?? allSessions.toSorted((a, b) => b.time.updated - a.time.updated)[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(() => { @@ -65,10 +111,11 @@ export function DialogSessionList() { return ( { setToDelete(undefined) @@ -102,6 +149,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() }, + }) + setTimeout(() => selectRef()?.scrollToValue(option.value), 0) + }, + }, ]} /> ) 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 7792900bcfe..cb411a83942 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) { @@ -56,20 +57,6 @@ export function DialogSelect(props: DialogSelectProps) { input: "keyboard" as "keyboard" | "mouse", }) - 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(() => { @@ -215,6 +202,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) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 82e6f3121bf..62a8d9953a0 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(), }), @@ -283,6 +284,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 }, { touch: false }, ) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 556fad01f59..b143dece303 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -76,6 +76,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 @@ -193,6 +194,14 @@ export namespace Session { }) } } + + // Inherit bookmark status from original session + if (original.time.pinned !== undefined) { + await update(session.id, (draft) => { + draft.time.pinned = original.time.pinned + }, { touch: false }) + } + return session }, ) diff --git a/packages/opencode/src/tool/bookmark.ts b/packages/opencode/src/tool/bookmark.ts new file mode 100644 index 00000000000..e33c960b027 --- /dev/null +++ b/packages/opencode/src/tool/bookmark.ts @@ -0,0 +1,41 @@ +import z from "zod" +import { Tool } from "./tool" +import { Session } from "../session" + +const DESCRIPTION = `You MUST always use this tool if asked to bookmark the current session. + +Use this tool to bookmark the current session so it appears at the top of the session list. Bookmarking helps preserve important sessions for easy access later. + +Usage notes: +- This tool can only bookmark sessions, not unbookmark them (to prevent accidental loss of bookmarked sessions) +- If the session is already bookmarked, this tool will succeed but have no effect +- Bookmarked sessions appear in the "Bookmarks" category at the top of the session list +` + +export const BookmarkCurrentSessionTool = Tool.define("bookmark_current_session", { + description: DESCRIPTION, + parameters: z.object({ + _confirm: z.string().describe("Enter 'yes' to proceed"), + }), + async execute(params, ctx) { + const session = await Session.get(ctx.sessionID) + + if (session.time.pinned !== undefined) { + return { + title: "Session already bookmarked", + output: `The current session "${session.title}" is already bookmarked.`, + metadata: {}, + } + } + + await Session.update(ctx.sessionID, (draft) => { + draft.time.pinned = Date.now() + }, { touch: false }) + + return { + title: "Session bookmarked", + output: `Successfully bookmarked the current session "${session.title}". It will now appear in the Bookmarks section at the top of the session list.`, + metadata: {}, + } + }, +}) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 5ed5a879b48..8b5a9a70f9d 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -27,6 +27,7 @@ import { LspTool } from "./lsp" import { Truncate } from "./truncation" import { PlanExitTool, PlanEnterTool } from "./plan" import { ApplyPatchTool } from "./apply_patch" +import { BookmarkCurrentSessionTool } from "./bookmark" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -111,6 +112,7 @@ export namespace ToolRegistry { WebSearchTool, CodeSearchTool, SkillTool, + BookmarkCurrentSessionTool, ApplyPatchTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts index 653da09a0b7..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 { @@ -28,6 +31,13 @@ export namespace Locale { } } + export function shortDateTime(input: number): string { + const date = new Date(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 { 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 b757b753507..a0aee4ba2b3 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1092,6 +1092,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 d72c37a28b5..c91110b10af 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -777,6 +777,7 @@ export type Session = { updated: number compacting?: number archived?: number + pinned?: number } permission?: PermissionRuleset revert?: { @@ -2997,6 +2998,7 @@ export type SessionUpdateData = { title?: string time?: { archived?: number + pinned?: number | null } } path: {