Skip to content

Commit bc5aa4e

Browse files
committed
feat(tui): order and hide session sidebar sections from tui.json
Add `sidebar.order` and `sidebar.hidden` to tui.json. Sections are named `context`, `mcp`, `lsp`, `todo`, `files`, or by the slot id a plugin's registration received, so plugin-contributed sidebar sections can be ordered and hidden the same way as built-in ones. Listed sections render first, in the given order; every unlisted section keeps its default order after them, so a section added later is never silently dropped. The session title is a separate slot and always stays first. Without the option nothing changes. Slot order belongs to a whole registration, so the host moves a registration's `sidebar_content` into its own registration and leaves its other slots (e.g. `sidebar_footer`) where they were. AI-Tool: opencode 1.18.33-vt-60-2fa3363c92 AI-Model: anthropic/claude-opus-5-5 AI-Variant: high AI-Platform: linux AI-Harness: Vibeterm e8a11eb AI-Session-ID: ses_ef81ad30fffeo7m3HoZisuJdli
1 parent 6ff168d commit bc5aa4e

7 files changed

Lines changed: 130 additions & 7 deletions

File tree

‎packages/opencode/src/plugin/tui/runtime.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1060,7 +1060,7 @@ async function load(input: {
10601060
}) {
10611061
const { api, config } = input
10621062
const cwd = process.cwd()
1063-
const slots = input.runtime.setupSlots(api)
1063+
const slots = input.runtime.setupSlots(api, config.sidebar)
10641064
const next: RuntimeState = {
10651065
directory: cwd,
10661066
api,

‎packages/tui/src/config/index.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,15 @@ export const Sidebar = Schema.Struct({
6060
pin_title: Schema.optional(Schema.Boolean).annotate({
6161
description: "Keep the session title at the top of the sidebar instead of scrolling it with the sidebar content",
6262
}),
63+
order: Schema.optional(Schema.Array(Schema.String)).annotate({
64+
description:
65+
"Sidebar sections in display order, by name (context, mcp, lsp, todo, files) or plugin id. Sections not listed follow in their default order",
66+
}),
67+
hidden: Schema.optional(Schema.Array(Schema.String)).annotate({
68+
description: "Sidebar sections to hide, by name (context, mcp, lsp, todo, files) or plugin id",
69+
}),
6370
}).annotate({ description: "Session sidebar settings" })
71+
export type Sidebar = Schema.Schema.Type<typeof Sidebar>
6472

6573
export const AttentionSounds = Schema.Record(AttentionSoundName, Schema.optionalKey(Schema.String))
6674
export type AttentionSoundPaths = Schema.Schema.Type<typeof AttentionSounds>

‎packages/tui/src/plugin/runtime.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ export function createPluginRuntime() {
2828
setStatus([])
2929
slots.clear()
3030
},
31-
setupSlots(api: TuiPluginApi): HostSlots {
32-
return slots.setup(api)
31+
setupSlots(api: TuiPluginApi, sidebar?: TuiConfig.Sidebar): HostSlots {
32+
return slots.setup(api, sidebar)
3333
},
3434
}
3535
}

‎packages/tui/src/plugin/slots.tsx‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@opencode-ai/plugin/tui"
2+
import type { PluginContext } from "@opentui/core"
23
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
34
import { createSignal } from "solid-js"
5+
import type { TuiConfig } from "../config"
46
import { isRecord } from "../util/record"
57

68
type RuntimeSlotMap = TuiSlotMap<Record<string, object>>
@@ -22,14 +24,49 @@ function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string
2224
return isRecord(value.slots)
2325
}
2426

27+
const BUILTIN_SIDEBAR_PREFIX = "internal:sidebar-"
28+
29+
// Built-in sections are addressed by their short name ("mcp"), plugin sections by
30+
// the id their slot registration received ("my-plugin", "my-plugin:1").
31+
export function sidebarSectionName(id: string) {
32+
return id.startsWith(BUILTIN_SIDEBAR_PREFIX) ? id.slice(BUILTIN_SIDEBAR_PREFIX.length) : id
33+
}
34+
35+
/*
36+
* Applies `sidebar.order` / `sidebar.hidden` to one slot registration. Order belongs to
37+
* a whole registration, so its `sidebar_content` moves into a registration of its own
38+
* and its other slots keep their place. Listed sections sort before every unlisted one,
39+
* which keep their default order; that way a section added later is never lost.
40+
*/
41+
export function placeSidebarSection<
42+
Slots extends Record<string, object> & { sidebar_content: object },
43+
Context extends PluginContext,
44+
>(plugin: SolidPlugin<Slots, Context>, sidebar: TuiConfig.Sidebar | undefined): SolidPlugin<Slots, Context>[] {
45+
const content = plugin.slots.sidebar_content
46+
if (!content || (!sidebar?.order?.length && !sidebar?.hidden?.length)) return [plugin]
47+
const name = sidebarSectionName(plugin.id)
48+
const matches = (entry: string) => entry === name || entry === plugin.id
49+
const host = { ...plugin, slots: { ...plugin.slots, sidebar_content: undefined } }
50+
if (sidebar.hidden?.some(matches)) return [host]
51+
const index = sidebar.order?.findIndex(matches) ?? -1
52+
return [
53+
host,
54+
{
55+
id: `${plugin.id}:sidebar`,
56+
order: index === -1 ? plugin.order : Number.MIN_SAFE_INTEGER + index,
57+
slots: { sidebar_content: content },
58+
},
59+
]
60+
}
61+
2562
export function createSlots() {
2663
const empty: SlotView = () => null
2764
const [view, setView] = createSignal<SlotView>(empty)
2865
const Slot: SlotView = (props) => view()(props)
2966

3067
return {
3168
Slot,
32-
setup(api: HostPluginApi): HostSlots {
69+
setup(api: HostPluginApi, sidebar?: TuiConfig.Sidebar): HostSlots {
3370
const registry = createSolidSlotRegistry<RuntimeSlotMap, TuiSlotContext>(
3471
api.renderer,
3572
{ theme: api.theme },
@@ -51,7 +88,8 @@ export function createSlots() {
5188
return {
5289
register(plugin: HostSlotPlugin) {
5390
if (!isHostSlotPlugin(plugin)) return () => {}
54-
return registry.register(plugin)
91+
const disposers = placeSidebarSection(plugin, sidebar).map((item) => registry.register(item))
92+
return () => disposers.forEach((dispose) => dispose())
5593
},
5694
dispose() {
5795
setView(() => empty)

‎packages/tui/test/config.test.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,17 @@ test("validates config constraints", () => {
3232
scroll_speed: 0.001,
3333
diff_style: "stacked",
3434
cursor: { blinking: false },
35-
sidebar: { context: "compact" },
35+
sidebar: { context: "compact", order: ["mcp", "context"], hidden: ["lsp"] },
3636
plugin: ["example-plugin"],
3737
}),
3838
).toMatchObject({
3939
leader_timeout: 250,
4040
attention: { volume: 1 },
4141
diff_style: "stacked",
4242
cursor: { blinking: false },
43-
sidebar: { context: "compact" },
43+
sidebar: { context: "compact", order: ["mcp", "context"], hidden: ["lsp"] },
4444
})
45+
expect(() => decodeInfo({ sidebar: { order: "mcp" } })).toThrow()
4546
expect(() => decodeInfo({ leader_timeout: 0 })).toThrow()
4647
expect(() => decodeInfo({ attention: { volume: 1.1 } })).toThrow()
4748
expect(() => decodeInfo({ prompt: { max_width: 0 } })).toThrow()

‎packages/tui/test/plugin/slots.test.tsx‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import { expect, test } from "bun:test"
33
import { createSlot, createSolidSlotRegistry, testRender, useRenderer } from "@opentui/solid"
44
import { onMount } from "solid-js"
5+
import type { TuiConfig } from "../../src/config"
6+
import { placeSidebarSection, sidebarSectionName } from "../../src/plugin/slots"
57

68
type Slots = {
79
prompt: {}
@@ -36,3 +38,75 @@ test("replace slot mounts plugin content once", async () => {
3638
app.renderer.destroy()
3739
}
3840
})
41+
42+
type SidebarSlots = {
43+
sidebar_content: {}
44+
sidebar_footer: {}
45+
}
46+
47+
async function renderSidebar(sidebar: TuiConfig.Sidebar | undefined) {
48+
const section = (id: string, order: number, label: string, footer?: string) => ({
49+
id,
50+
order,
51+
slots: {
52+
sidebar_content: () => <text>{label}</text>,
53+
...(footer ? { sidebar_footer: () => <text>{footer}</text> } : {}),
54+
},
55+
})
56+
const App = () => {
57+
const registry = createSolidSlotRegistry<SidebarSlots>(useRenderer(), {})
58+
const Slot = createSlot(registry)
59+
for (const plugin of [
60+
section("internal:sidebar-context", 100, "Context", "Footer-context"),
61+
section("internal:sidebar-mcp", 200, "MCP"),
62+
section("internal:sidebar-lsp", 300, "LSP"),
63+
section("my-plugin", 50, "Plugin", "Footer-plugin"),
64+
]) {
65+
placeSidebarSection(plugin, sidebar).forEach((item) => registry.register(item))
66+
}
67+
return (
68+
<box>
69+
<Slot name="sidebar_content" />
70+
<Slot name="sidebar_footer" />
71+
</box>
72+
)
73+
}
74+
const app = await testRender(() => <App />, { width: 30, height: 10 })
75+
await app.renderOnce()
76+
const lines = app
77+
.captureCharFrame()
78+
.split("\n")
79+
.map((line) => line.trim())
80+
.filter(Boolean)
81+
app.renderer.destroy()
82+
return lines
83+
}
84+
85+
test("sidebar sections keep their default order without config", async () => {
86+
expect(await renderSidebar(undefined)).toEqual(["Plugin", "Context", "MCP", "LSP", "Footer-plugin", "Footer-context"])
87+
})
88+
89+
test("sidebar.order puts listed sections first and keeps unlisted ones in default order", async () => {
90+
expect(await renderSidebar({ order: ["lsp", "context"] })).toEqual([
91+
"LSP",
92+
"Context",
93+
"Plugin",
94+
"MCP",
95+
"Footer-plugin",
96+
"Footer-context",
97+
])
98+
})
99+
100+
test("sidebar.hidden removes a section by name or plugin id but keeps its other slots", async () => {
101+
expect(await renderSidebar({ order: ["mcp"], hidden: ["context", "my-plugin"] })).toEqual([
102+
"MCP",
103+
"LSP",
104+
"Footer-plugin",
105+
"Footer-context",
106+
])
107+
})
108+
109+
test("built-in sections are named without the internal prefix", () => {
110+
expect(sidebarSectionName("internal:sidebar-files")).toBe("files")
111+
expect(sidebarSectionName("my-plugin:1")).toBe("my-plugin:1")
112+
})

‎packages/web/src/content/docs/tui.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,8 @@ This is separate from `opencode.json`, which configures server/runtime behavior.
410410
- `keep_scroll_on_submit` - Keep the session scroll position when a prompt is submitted instead of jumping to the bottom (default: `false`). Toggle it at runtime with `/keep-scroll`; the toggle is remembered and takes precedence over this option.
411411
- `sidebar.pin_title` - Keep the session title at the top of the sidebar while the rest of the sidebar scrolls (default: `false`). The `/pin-title` command toggles it for the current machine.
412412
- `attention` - Configures TUI desktop notifications and sounds. Disabled by default.
413+
- `sidebar.order` - Session sidebar sections in display order, for example `["todo", "context"]`. Built-in sections are `context`, `mcp`, `lsp`, `todo`, and `files`; plugin sections use their plugin id. Sections you do not list follow in their default order. The session title always stays first.
414+
- `sidebar.hidden` - Session sidebar sections to hide, using the same names as `sidebar.order`.
413415

414416
Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path.
415417

0 commit comments

Comments
 (0)