Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
ff83952
feat: add session bookmarking to group important sessions at the top
ariane-emory Jan 12, 2026
9a407e5
chore: remove unused session_pin config keybind (hardcoded in dialog)
ariane-emory Jan 12, 2026
af03998
chore: regenerate SDK types after removing unused config key
ariane-emory Jan 12, 2026
2f31c16
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 13, 2026
26da5fa
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 13, 2026
082faa7
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 13, 2026
bdd45c6
fix: improve session bookmark selection behavior
ariane-emory Jan 13, 2026
5887682
fix: remember last session when navigating to home with /new
ariane-emory Jan 13, 2026
cc43228
fix: remove duplicate useKV() hook call in session.new command
ariane-emory Jan 14, 2026
79c6530
fix: keep selection visible when bookmarking/unbookmarking sessions
ariane-emory Jan 14, 2026
0a65d99
fix: scroll to selected session on dialog open and after bookmark toggle
ariane-emory Jan 14, 2026
e8610f2
fix: scroll viewport to selected item when dialog opens
ariane-emory Jan 14, 2026
059ca29
fix: defer initial scroll to next tick to ensure component is rendered
ariane-emory Jan 14, 2026
ca4373c
feat: center selected item in viewport when dialog opens or after boo…
ariane-emory Jan 14, 2026
1fbe08a
refactor: remove viewport centering fix from bookmark branch
ariane-emory Jan 15, 2026
22e1d1d
fix: make last_session_id ephemeral and per-process
ariane-emory Jan 15, 2026
f4875af
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 15, 2026
836ed90
Merge dev into feat/session-bookmarks
ariane-emory Jan 16, 2026
402ffb5
Merge dev into feat/session-bookmarks
ariane-emory Jan 17, 2026
4ce6789
Fix: add server-side support for session pinned/bookmark field
ariane-emory Jan 17, 2026
86d462b
Fix: add server-side support for session pinned/bookmark field
ariane-emory Jan 17, 2026
251ee95
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 17, 2026
b9b3bbe
tui: show date with time for bookmarked sessions
ariane-emory Jan 18, 2026
958f88a
tui: fix extra empty line for long session titles in dialog
ariane-emory Jan 18, 2026
b0d2f53
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 19, 2026
daa0867
tui: pad single-digit days in bookmark date display for alignment
ariane-emory Jan 19, 2026
bbcb0c0
tui: pad single-digit hours in time display for alignment
ariane-emory Jan 19, 2026
66d8650
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 19, 2026
1bfc913
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 22, 2026
cd89284
fix: defer scrollToValue to next tick after bookmark toggle
ariane-emory Jan 22, 2026
db4e09b
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 23, 2026
7ecba91
feat: add bookmark_current_session tool for agent use
ariane-emory Jan 23, 2026
df11786
fix: session list initial selection prefers non-bookmarked sessions
ariane-emory Jan 24, 2026
9d62ebb
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 25, 2026
d6b7c70
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 25, 2026
9071b1a
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 26, 2026
f02b879
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 27, 2026
7548118
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 29, 2026
b65cc14
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 29, 2026
2d44e47
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 29, 2026
f36851e
Merge branch 'feat/session-bookmarks' of github.com:ariane-emory/open…
ariane-emory Jan 29, 2026
a5f5228
feat: inherit bookmark status when forking session
ariane-emory Jan 29, 2026
e7f3e55
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Jan 30, 2026
5ab4229
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Feb 1, 2026
64f3e5b
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Feb 2, 2026
80d4138
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Feb 3, 2026
e7c4197
Merge dev into feat/session-bookmarks
ariane-emory Feb 3, 2026
ee18143
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Feb 5, 2026
c1bdacb
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Feb 5, 2026
23e7f3d
Merge branch 'dev' into feat/session-bookmarks
ariane-emory Feb 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,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,
Expand Down
107 changes: 84 additions & 23 deletions packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -23,40 +24,85 @@ export function DialogSessionList() {

const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [selectRef, setSelectRef] = createSignal<DialogSelectRef<string>>()

const [searchResults] = createResource(search, async (query) => {
if (!query) return undefined
const result = await sdk.client.session.list({ search: query, limit: 30 })
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 ? <Spinner /> : 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 ? (
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
<spinner frames={spinnerFrames} interval={80} color={theme.primary} />
</Show>
) : 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(() => {
Expand All @@ -65,10 +111,11 @@ export function DialogSessionList() {

return (
<DialogSelect
ref={setSelectRef}
title="Sessions"
options={options()}
skipFilter={true}
current={currentSessionID()}
current={currentSessionID() ?? defaultSessionID()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
Expand Down Expand Up @@ -102,6 +149,20 @@ export function DialogSessionList() {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
{
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)
},
},
]}
/>
)
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/kv.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
init: () => {
const [ready, setReady] = createSignal(false)
const [store, setStore] = createStore<Record<string, any>>()
const ephemeral: Record<string, any> = {}
const file = Bun.file(path.join(Global.Path.state, "kv.json"))

file
Expand Down Expand Up @@ -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
},
Expand Down
21 changes: 7 additions & 14 deletions packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface DialogSelectOption<T = any> {
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
scrollToValue: (value: T) => void
}

export function DialogSelect<T>(props: DialogSelectProps<T>) {
Expand All @@ -56,20 +57,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
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(() => {
Expand Down Expand Up @@ -215,6 +202,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
get filtered() {
return filtered()
},
scrollToValue(value: T) {
const index = flat().findIndex((opt) => isDeepEqual(opt.value, value))
if (index >= 0) {
moveTo(index)
}
},
}
props.ref?.(ref)

Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ export const SessionRoutes = lazy(() =>
time: z
.object({
archived: z.number().optional(),
pinned: z.number().nullable().optional(),
})
.optional(),
}),
Expand All @@ -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 },
)
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
},
)
Expand Down
41 changes: 41 additions & 0 deletions packages/opencode/src/tool/bookmark.ts
Original file line number Diff line number Diff line change
@@ -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: {},
}
},
})
2 changes: 2 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -111,6 +112,7 @@ export namespace ToolRegistry {
WebSearchTool,
CodeSearchTool,
SkillTool,
BookmarkCurrentSessionTool,
ApplyPatchTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
Expand Down
12 changes: 11 additions & 1 deletion packages/opencode/src/util/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,7 @@ export class Session extends HeyApiClient {
title?: string
time?: {
archived?: number
pinned?: number | null
}
},
options?: Options<never, ThrowOnError>,
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,7 @@ export type Session = {
updated: number
compacting?: number
archived?: number
pinned?: number
}
permission?: PermissionRuleset
revert?: {
Expand Down Expand Up @@ -2996,6 +2997,7 @@ export type SessionUpdateData = {
title?: string
time?: {
archived?: number
pinned?: number | null
}
}
path: {
Expand Down