From 1849dad6a7ab9cbed54f79c19f25760fbf2432bf Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Fri, 19 Dec 2025 16:50:52 +0200 Subject: [PATCH 1/5] feat(tui): add vim NERDTree-style sessions sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new sessions sidebar that can be toggled with ctrl+n from any screen. The sidebar displays sessions in a tree structure with vim-style navigation: - j/k, ↑/↓: Move cursor - Enter, 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 Features: - Tree structure showing parent/child session relationships - Context usage indicator with colored circles - Session status (busy/idle) indicator - Works globally from both home and session pages --- README.md | 19 + packages/opencode/src/cli/cmd/tui/app.tsx | 35 +- .../cmd/tui/component/prompt/autocomplete.tsx | 5 +- .../tui/routes/session/sidebar-sessions.tsx | 373 ++++++++++++++++++ packages/opencode/src/config/config.ts | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 4 + 6 files changed, 426 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/sidebar-sessions.tsx 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/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..fde0cf322c6 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-sessions.tsx @@ -0,0 +1,373 @@ +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 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, 20)} + + {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/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 */ From dcb778d257f4b9e6bac689edba69e15566f646bc Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Fri, 19 Dec 2025 16:59:11 +0200 Subject: [PATCH 2/5] fix: resolve typecheck errors in build.ts and mcp/index.ts --- packages/opencode/script/build.ts | 2 +- packages/opencode/src/mcp/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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 From 7b27fe04878740c659057c410979207794ee8a89 Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Fri, 19 Dec 2025 17:06:07 +0200 Subject: [PATCH 3/5] feat(sidebar): widen to 50 chars, add timestamp and message count --- .../cmd/tui/routes/session/sidebar-sessions.tsx | 14 +++++++++----- packages/opencode/src/util/locale.ts | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) 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 index fde0cf322c6..c8e2ec7c36e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-sessions.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-sessions.tsx @@ -262,7 +262,7 @@ export function SessionsSidebar(props: { onClose: () => void }) { return ( void }) { Navigation - ───────────────────────── + ──────────────────────────────────────── j/k, ↑/↓ Move cursor @@ -300,7 +300,7 @@ export function SessionsSidebar(props: { onClose: () => void }) { g/G Top/Bottom - ───────────────────────── + ──────────────────────────────────────── n New session @@ -310,7 +310,7 @@ export function SessionsSidebar(props: { onClose: () => void }) { d Delete - ───────────────────────── + ──────────────────────────────────────── q, Esc Close sidebar @@ -334,6 +334,8 @@ export function SessionsSidebar(props: { onClose: () => void }) { const status = () => sync.data.session_status?.[node.session.id] const isBusy = () => status()?.type === "busy" const contextInfo = () => getContextCircle(getContextPercentage(node.session.id)) + const messageCount = () => (sync.data.message[node.session.id] ?? []).length + const relTime = () => Locale.relativeTime(node.session.time.updated) const indent = " ".repeat(node.depth) const expandChar = node.hasChildren ? (expanded[node.session.id] ? "▼" : "▶") : " " @@ -357,8 +359,10 @@ export function SessionsSidebar(props: { onClose: () => void }) { {expandChar} - {Locale.truncate(node.session.title, 20)} + {Locale.truncate(node.session.title, 28)} + {relTime().padStart(3)} + {String(messageCount()).padStart(2)}m {isBusy() ? "●" : "○"} {contextInfo().char} 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" From 5551b472768eea8e17f18259a55daec31a9987f2 Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Fri, 19 Dec 2025 17:08:57 +0200 Subject: [PATCH 4/5] refactor(sidebar): remove message count, keep title/time/status/context --- .../src/cli/cmd/tui/routes/session/sidebar-sessions.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 index c8e2ec7c36e..e09051acfe6 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-sessions.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-sessions.tsx @@ -334,7 +334,6 @@ export function SessionsSidebar(props: { onClose: () => void }) { const status = () => sync.data.session_status?.[node.session.id] const isBusy = () => status()?.type === "busy" const contextInfo = () => getContextCircle(getContextPercentage(node.session.id)) - const messageCount = () => (sync.data.message[node.session.id] ?? []).length const relTime = () => Locale.relativeTime(node.session.time.updated) const indent = " ".repeat(node.depth) @@ -359,10 +358,9 @@ export function SessionsSidebar(props: { onClose: () => void }) { {expandChar} - {Locale.truncate(node.session.title, 28)} + {Locale.truncate(node.session.title, 30)} {relTime().padStart(3)} - {String(messageCount()).padStart(2)}m {isBusy() ? "●" : "○"} {contextInfo().char} From 9e98794f3b533d302cc3f0509e67669e982f5597 Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Fri, 19 Dec 2025 17:18:04 +0200 Subject: [PATCH 5/5] refactor(sidebar): reduce width to 35 --- .../cli/cmd/tui/routes/session/sidebar-sessions.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index e09051acfe6..60c36c758d9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-sessions.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar-sessions.tsx @@ -262,7 +262,7 @@ export function SessionsSidebar(props: { onClose: () => void }) { return ( void }) { Navigation - ──────────────────────────────────────── + ───────────────────────────── j/k, ↑/↓ Move cursor @@ -300,7 +300,7 @@ export function SessionsSidebar(props: { onClose: () => void }) { g/G Top/Bottom - ──────────────────────────────────────── + ───────────────────────────── n New session @@ -310,7 +310,7 @@ export function SessionsSidebar(props: { onClose: () => void }) { d Delete - ──────────────────────────────────────── + ───────────────────────────── q, Esc Close sidebar @@ -358,7 +358,7 @@ export function SessionsSidebar(props: { onClose: () => void }) { {expandChar} - {Locale.truncate(node.session.title, 30)} + {Locale.truncate(node.session.title, 18)} {relTime().padStart(3)} {isBusy() ? "●" : "○"}