diff --git a/README.md b/README.md
index 422147bf9c3..e1c1122d93c 100644
--- a/README.md
+++ b/README.md
@@ -89,6 +89,25 @@ This is used internally and can be invoked using `@general` in messages.
Learn more about [agents](https://opencode.ai/docs/agents).
+### Sessions Sidebar
+
+A NERDTree-style sidebar for managing sessions. Toggle with `ctrl+n`.
+
+| Key | Action |
+| --- | --- |
+| `j/k` or `↑/↓` | Move cursor |
+| `Enter` or `o` | Open session / Toggle expand |
+| `O` | Expand all children |
+| `x` | Collapse parent |
+| `X` | Collapse all |
+| `p` | Go to parent |
+| `g/G` | Jump to top/bottom |
+| `n` | New session |
+| `r` | Rename session |
+| `d` | Delete session |
+| `?` | Show help |
+| `q` or `Esc` | Close sidebar |
+
### Documentation
For more info on how to configure OpenCode [**head over to our docs**](https://opencode.ai/docs).
diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts
index af3d7a9f605..c71279de3d6 100755
--- a/packages/opencode/script/build.ts
+++ b/packages/opencode/script/build.ts
@@ -1,6 +1,6 @@
#!/usr/bin/env bun
-import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
+import solidPlugin from "@opentui/solid/bun-plugin"
import path from "path"
import fs from "fs"
import { $ } from "bun"
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 336aa6a869a..c845dcfcd4e 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -22,6 +22,7 @@ import { KeybindProvider } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
+import { SessionsSidebar } from "@tui/routes/session/sidebar-sessions"
import { PromptHistoryProvider } from "./component/prompt/history"
import { DialogAlert } from "./ui/dialog-alert"
import { ToastProvider, useToast } from "./ui/toast"
@@ -178,6 +179,7 @@ function App() {
const promptRef = usePromptRef()
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
+ const [sessionsSidebarVisible, setSessionsSidebarVisible] = createSignal(false)
createEffect(() => {
console.log(JSON.stringify(route.data))
@@ -280,6 +282,16 @@ function App() {
dialog.clear()
},
},
+ {
+ title: sessionsSidebarVisible() ? "Hide sessions" : "Show sessions",
+ value: "session.sessions_sidebar.toggle",
+ keybind: "sessions_sidebar_toggle",
+ category: "Session",
+ onSelect: (dialog) => {
+ setSessionsSidebarVisible((prev) => !prev)
+ dialog.clear()
+ },
+ },
{
title: "Switch model",
value: "model.list",
@@ -631,14 +643,21 @@ function App() {
}
}}
>
-
-
-
-
-
-
-
-
+
+
+ setSessionsSidebarVisible(false)} />
+
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
index 699ac805565..b25de3d1b00 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
@@ -468,9 +468,8 @@ export function Autocomplete(props: {
onKeyDown(e: KeyEvent) {
if (store.visible) {
const name = e.name?.toLowerCase()
- const ctrlOnly = e.ctrl && !e.meta && !e.shift
- const isNavUp = name === "up" || (ctrlOnly && name === "p")
- const isNavDown = name === "down" || (ctrlOnly && name === "n")
+ const isNavUp = name === "up"
+ const isNavDown = name === "down"
if (isNavUp) {
move(-1)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-sessions.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-sessions.tsx
new file mode 100644
index 00000000000..60c36c758d9
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-sessions.tsx
@@ -0,0 +1,375 @@
+import { useSync } from "@tui/context/sync"
+import { createMemo, createSignal, For, Show, onMount, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useTheme } from "../../context/theme"
+import { useRoute } from "@tui/context/route"
+import { useSDK } from "../../context/sdk"
+import { useDialog } from "../../ui/dialog"
+import { DialogConfirm } from "@tui/ui/dialog-confirm"
+import { DialogPrompt } from "@tui/ui/dialog-prompt"
+import { useKeyboard } from "@opentui/solid"
+import { useCommandDialog } from "@tui/component/dialog-command"
+import { Locale } from "@/util/locale"
+import type { Session, AssistantMessage } from "@opencode-ai/sdk/v2"
+
+interface FlatNode {
+ session: Session
+ depth: number
+ hasChildren: boolean
+}
+
+function getContextCircle(percentage: number): { char: string; colorKey: "error" | "warning" | "info" | "success" } {
+ if (percentage >= 90) return { char: "●", colorKey: "error" }
+ if (percentage >= 60) return { char: "◕", colorKey: "warning" }
+ if (percentage >= 40) return { char: "◐", colorKey: "info" }
+ if (percentage >= 10) return { char: "◔", colorKey: "success" }
+ return { char: "○", colorKey: "success" }
+}
+
+export function SessionsSidebar(props: { onClose: () => void }) {
+ const sync = useSync()
+ const { theme } = useTheme()
+ const route = useRoute()
+ const sdk = useSDK()
+ const dialog = useDialog()
+ const command = useCommandDialog()
+
+ // Suspend global keybinds while sidebar is open
+ command.keybinds(false)
+ onCleanup(() => command.keybinds(true))
+
+ const [cursor, setCursor] = createSignal(0)
+ const [expanded, setExpanded] = createStore>({})
+ const [showHelp, setShowHelp] = createSignal(false)
+
+ const currentSessionID = () => (route.data.type === "session" ? route.data.sessionID : undefined)
+
+ // Group sessions by parent
+ const sessionsByParent = createMemo(() => {
+ const byParent = new Map()
+ for (const session of sync.data.session) {
+ const parentId = session.parentID
+ if (!byParent.has(parentId)) byParent.set(parentId, [])
+ byParent.get(parentId)!.push(session)
+ }
+ for (const [, children] of byParent) {
+ children.sort((a, b) => b.time.updated - a.time.updated)
+ }
+ return byParent
+ })
+
+ // Build flat list for keyboard navigation
+ const flatList = createMemo(() => {
+ const result: FlatNode[] = []
+ const byParent = sessionsByParent()
+
+ function addNodes(parentId: string | undefined, depth: number) {
+ const children = byParent.get(parentId) ?? []
+ for (const session of children) {
+ const hasChildren = (byParent.get(session.id)?.length ?? 0) > 0
+ result.push({ session, depth, hasChildren })
+ if (expanded[session.id] && hasChildren) {
+ addNodes(session.id, depth + 1)
+ }
+ }
+ }
+ addNodes(undefined, 0)
+ return result
+ })
+
+ const currentNode = () => flatList()[cursor()]
+
+ function getContextPercentage(sessionId: string): number {
+ const messages = sync.data.message[sessionId] ?? []
+ const lastAssistant = messages.findLast((x) => x.role === "assistant" && x.tokens.output > 0) as
+ | AssistantMessage
+ | undefined
+ if (!lastAssistant) return 0
+ const total =
+ lastAssistant.tokens.input +
+ lastAssistant.tokens.output +
+ lastAssistant.tokens.reasoning +
+ lastAssistant.tokens.cache.read +
+ lastAssistant.tokens.cache.write
+ const model = sync.data.provider.find((x) => x.id === lastAssistant.providerID)?.models[lastAssistant.modelID]
+ if (!model?.limit.context) return 0
+ return Math.round((total / model.limit.context) * 100)
+ }
+
+ function toggleExpand(sessionId: string) {
+ setExpanded(sessionId, !expanded[sessionId])
+ }
+
+ function expandAll(sessionId: string) {
+ const byParent = sessionsByParent()
+ function expand(id: string) {
+ const children = byParent.get(id) ?? []
+ if (children.length > 0) {
+ setExpanded(id, true)
+ children.forEach((c) => expand(c.id))
+ }
+ }
+ expand(sessionId)
+ }
+
+ function collapseAll() {
+ for (const key of Object.keys(expanded)) {
+ setExpanded(key, false)
+ }
+ }
+
+ function openSession(sessionId: string) {
+ route.navigate({ type: "session", sessionID: sessionId })
+ }
+
+ async function deleteSession(sessionId: string) {
+ const confirmed = await DialogConfirm.show(dialog, "Delete Session", "Are you sure you want to delete this session?")
+ if (confirmed) sdk.client.session.delete({ sessionID: sessionId })
+ }
+
+ async function renameSession(session: Session) {
+ const newTitle = await DialogPrompt.show(dialog, "Rename Session", { value: session.title, placeholder: "Enter new name" })
+ if (newTitle && newTitle !== session.title) {
+ sdk.client.session.update({ sessionID: session.id, title: newTitle })
+ }
+ }
+
+ function findParentIndex(idx: number): number {
+ const list = flatList()
+ const node = list[idx]
+ if (!node || node.depth === 0) return idx
+ for (let i = idx - 1; i >= 0; i--) {
+ if (list[i].depth < node.depth) return i
+ }
+ return idx
+ }
+
+ // Keyboard navigation
+ useKeyboard((evt) => {
+ const list = flatList()
+ const node = currentNode()
+
+ if (showHelp()) {
+ setShowHelp(false)
+ evt.preventDefault()
+ return
+ }
+
+ switch (evt.name) {
+ case "j":
+ case "down":
+ setCursor((i) => Math.min(i + 1, list.length - 1))
+ evt.preventDefault()
+ break
+ case "k":
+ case "up":
+ setCursor((i) => Math.max(i - 1, 0))
+ evt.preventDefault()
+ break
+ case "g":
+ setCursor(0)
+ evt.preventDefault()
+ break
+ case "G":
+ setCursor(list.length - 1)
+ evt.preventDefault()
+ break
+ case "return":
+ if (node) {
+ openSession(node.session.id)
+ props.onClose()
+ }
+ evt.preventDefault()
+ break
+ case "o":
+ if (node) {
+ if (node.hasChildren) {
+ toggleExpand(node.session.id)
+ } else {
+ openSession(node.session.id)
+ props.onClose()
+ }
+ }
+ evt.preventDefault()
+ break
+ case "O":
+ if (node) expandAll(node.session.id)
+ evt.preventDefault()
+ break
+ case "x":
+ if (node) {
+ const parentIdx = findParentIndex(cursor())
+ const parent = list[parentIdx]
+ if (parent && parent.session.id !== node.session.id) {
+ setExpanded(parent.session.id, false)
+ setCursor(parentIdx)
+ }
+ }
+ evt.preventDefault()
+ break
+ case "X":
+ collapseAll()
+ evt.preventDefault()
+ break
+ case "p":
+ setCursor(findParentIndex(cursor()))
+ evt.preventDefault()
+ break
+ case "d":
+ if (node) deleteSession(node.session.id)
+ evt.preventDefault()
+ break
+ case "r":
+ if (node) renameSession(node.session)
+ evt.preventDefault()
+ break
+ case "n":
+ route.navigate({ type: "home" })
+ props.onClose()
+ evt.preventDefault()
+ break
+ case "q":
+ case "escape":
+ props.onClose()
+ evt.preventDefault()
+ break
+ case "?":
+ setShowHelp((v) => !v)
+ evt.preventDefault()
+ break
+ }
+ })
+
+ // Set cursor to current session on mount
+ onMount(() => {
+ const sessionID = currentSessionID()
+ if (!sessionID) return
+
+ // Expand parents to show current session
+ const byParent = sessionsByParent()
+ let session = sync.data.session.find((s) => s.id === sessionID)
+ while (session?.parentID) {
+ setExpanded(session.parentID, true)
+ session = sync.data.session.find((s) => s.id === session!.parentID)
+ }
+
+ // Find and set cursor position
+ setTimeout(() => {
+ const idx = flatList().findIndex((n) => n.session.id === sessionID)
+ if (idx >= 0) setCursor(idx)
+ }, 0)
+ })
+
+ return (
+
+
+
+ Navigation
+
+ ─────────────────────────────
+
+ j/k, ↑/↓ Move cursor
+
+
+ Enter, o Open / Toggle
+
+
+ O Expand all
+
+
+ x Collapse parent
+
+
+ X Collapse all
+
+
+ p Go to parent
+
+
+ g/G Top/Bottom
+
+ ─────────────────────────────
+
+ n New session
+
+
+ r Rename
+
+
+ d Delete
+
+ ─────────────────────────────
+
+ q, Esc Close sidebar
+
+
+ Press any key to close
+
+
+ }
+ >
+
+
+ SESSIONS
+
+ ?=help
+
+
+
+ {(node, index) => {
+ const isActive = () => currentSessionID() === node.session.id
+ const isCursor = () => cursor() === index()
+ const status = () => sync.data.session_status?.[node.session.id]
+ const isBusy = () => status()?.type === "busy"
+ const contextInfo = () => getContextCircle(getContextPercentage(node.session.id))
+ const relTime = () => Locale.relativeTime(node.session.time.updated)
+
+ const indent = " ".repeat(node.depth)
+ const expandChar = node.hasChildren ? (expanded[node.session.id] ? "▼" : "▶") : " "
+
+ return (
+ {
+ setCursor(index())
+ if (node.hasChildren) {
+ toggleExpand(node.session.id)
+ } else {
+ openSession(node.session.id)
+ props.onClose()
+ }
+ }}
+ >
+
+
+ {indent}
+ {expandChar}
+
+
+ {Locale.truncate(node.session.title, 18)}
+
+ {relTime().padStart(3)}
+ {isBusy() ? "●" : "○"}
+ {contextInfo().char}
+
+
+ )
+ }}
+
+
+
+
+ )
+}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 8d3d5151e2b..437c5a046ad 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -434,6 +434,7 @@ export namespace Config {
editor_open: z.string().optional().default("e").describe("Open external editor"),
theme_list: z.string().optional().default("t").describe("List available themes"),
sidebar_toggle: z.string().optional().default("b").describe("Toggle sidebar"),
+ sessions_sidebar_toggle: z.string().optional().default("ctrl+n").describe("Toggle sessions sidebar"),
scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"),
header_toggle: z.string().optional().default("none").describe("Toggle session header visibility"),
username_toggle: z.string().optional().default("none").describe("Toggle username visibility"),
diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts
index 625809af9a8..ba4ce7d478e 100644
--- a/packages/opencode/src/mcp/index.ts
+++ b/packages/opencode/src/mcp/index.ts
@@ -407,7 +407,7 @@ export namespace MCP {
for (const [toolName, tool] of Object.entries(tools)) {
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
const sanitizedToolName = toolName.replace(/[^a-zA-Z0-9_-]/g, "_")
- result[sanitizedClientName + "_" + sanitizedToolName] = tool
+ result[sanitizedClientName + "_" + sanitizedToolName] = tool as Tool
}
}
return result
diff --git a/packages/opencode/src/util/locale.ts b/packages/opencode/src/util/locale.ts
index 653da09a0b7..acf94f996be 100644
--- a/packages/opencode/src/util/locale.ts
+++ b/packages/opencode/src/util/locale.ts
@@ -28,6 +28,22 @@ export namespace Locale {
}
}
+ export function relativeTime(input: number): string {
+ const now = Date.now()
+ const diff = now - input
+ const seconds = Math.floor(diff / 1000)
+ const minutes = Math.floor(seconds / 60)
+ const hours = Math.floor(minutes / 60)
+ const days = Math.floor(hours / 24)
+
+ if (seconds < 60) return "now"
+ if (minutes < 60) return `${minutes}m`
+ if (hours < 24) return `${hours}h`
+ if (days < 7) return `${days}d`
+ if (days < 30) return `${Math.floor(days / 7)}w`
+ return `${Math.floor(days / 30)}mo`
+ }
+
export function number(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M"
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index b06ab3c660c..cd3f167fa35 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -905,6 +905,10 @@ export type KeybindsConfig = {
* Toggle sidebar
*/
sidebar_toggle?: string
+ /**
+ * Toggle sessions sidebar
+ */
+ sessions_sidebar_toggle?: string
/**
* Toggle session scrollbar
*/