Skip to content

Commit 937fde8

Browse files
committed
feat(tui): reorder session sidebar sections by dragging their headers
Dragging a sidebar section by its header onto another section moves it there. The resulting order is saved in kv (`sidebar_order`) and wins over `sidebar.order` from tui.json until "Reset sidebar order" in the command palette clears it. Reordering applies live through the slot registry's `updateOrder`, for built-in and plugin sections alike. Every `sidebar_content` contribution is now wrapped in a host box that handles the drag. The box is hidden while the section renders nothing, so empty sections still take no space. Only a press on the section's first line, its header, starts a drag; a drag that starts anywhere below it never moves the section. opentui captures the renderable under the pointer at the first drag event, which may be a gap between sections, so the dragged section is the one pressed on mouse down and a drag is recognized from `over` events that carry a drag source. A press on selectable text starts a text selection instead, so the drag handle is the header's section name and arrow, which are non-selectable, plus its blank cells. Everything else stays selectable text as before, including the collapsed MCP summary, which moves into its own text so "(N active, M errors)" can be copied. Collapsible headers toggle only when pressed and released on the same cell, so a click still collapses while a drag, or a drop onto a header, does not; this also keeps the collapsed summary on screen while it is being selected. AI-Tool: opencode 1.18.33-vt-60-2fa3363c92 AI-Model: anthropic/claude-opus-5-5 AI-Variant: max AI-Platform: linux AI-Harness: Vibeterm b2921b0 AI-Session-ID: ses_ef81ad30fffeo7m3HoZisuJdli
1 parent 0406a6a commit 937fde8

10 files changed

Lines changed: 330 additions & 31 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
type Point = { x: number; y: number }
2+
3+
// A section header is also the handle that drags a section and the place another section
4+
// is dropped on, so it toggles only when it is pressed and released on the same cell.
5+
export function onHeaderClick(run: () => void) {
6+
let press: Point | undefined
7+
return {
8+
onMouseDown: (event: Point) => {
9+
press = { x: event.x, y: event.y }
10+
},
11+
onMouseUp: (event: Point) => {
12+
const click = press?.x === event.x && press.y === event.y
13+
press = undefined
14+
if (click) run()
15+
},
16+
}
17+
}

‎packages/tui/src/feature-plugins/sidebar/context.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
3636

3737
return (
3838
<box>
39-
<text fg={theme().text}>
39+
<text fg={theme().text} selectable={false}>
4040
<b>Context</b>
4141
</text>
4242
<text fg={theme().textMuted}>{state().tokens.toLocaleString()} tokens</text>

‎packages/tui/src/feature-plugins/sidebar/files.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
22
import type { BuiltinTuiPlugin } from "../builtins"
3+
import { onHeaderClick } from "./click"
34
import { createMemo, For, Show, createSignal } from "solid-js"
45
import { Locale } from "../../util/locale"
56

@@ -19,11 +20,13 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
1920
return (
2021
<Show when={list().length > 0}>
2122
<box>
22-
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
23+
<box flexDirection="row" gap={1} {...onHeaderClick(() => list().length > 2 && setOpen((x) => !x))}>
2324
<Show when={list().length > 2}>
24-
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
25+
<text fg={theme().text} selectable={false}>
26+
{open() ? "▼" : "▶"}
27+
</text>
2528
</Show>
26-
<text fg={theme().text}>
29+
<text fg={theme().text} selectable={false}>
2730
<b>Modified Files</b>
2831
</text>
2932
</box>

‎packages/tui/src/feature-plugins/sidebar/lsp.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
22
import type { BuiltinTuiPlugin } from "../builtins"
3+
import { onHeaderClick } from "./click"
34
import { createMemo, For, Show, createSignal } from "solid-js"
45

56
const id = "internal:sidebar-lsp"
@@ -12,11 +13,13 @@ function View(props: { api: TuiPluginApi }) {
1213

1314
return (
1415
<box>
15-
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
16+
<box flexDirection="row" gap={1} {...onHeaderClick(() => list().length > 2 && setOpen((x) => !x))}>
1617
<Show when={list().length > 2}>
17-
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
18+
<text fg={theme().text} selectable={false}>
19+
{open() ? "▼" : "▶"}
20+
</text>
1821
</Show>
19-
<text fg={theme().text}>
22+
<text fg={theme().text} selectable={false}>
2023
<b>LSP</b>
2124
</text>
2225
</box>

‎packages/tui/src/feature-plugins/sidebar/mcp.tsx‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
22
import type { BuiltinTuiPlugin } from "../builtins"
3+
import { onHeaderClick } from "./click"
34
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
45

56
const id = "internal:sidebar-mcp"
@@ -29,19 +30,20 @@ function View(props: { api: TuiPluginApi }) {
2930
return (
3031
<Show when={list().length > 0}>
3132
<box>
32-
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
33+
<box flexDirection="row" gap={1} {...onHeaderClick(() => list().length > 2 && setOpen((x) => !x))}>
3334
<Show when={list().length > 2}>
34-
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
35+
<text fg={theme().text} selectable={false}>
36+
{open() ? "▼" : "▶"}
37+
</text>
3538
</Show>
36-
<text fg={theme().text}>
39+
<text fg={theme().text} selectable={false}>
3740
<b>MCP</b>
38-
<Show when={!open()}>
39-
<span style={{ fg: theme().textMuted }}>
40-
{" "}
41-
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
42-
</span>
43-
</Show>
4441
</text>
42+
<Show when={!open()}>
43+
<text fg={theme().textMuted}>
44+
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
45+
</text>
46+
</Show>
4547
</box>
4648
<Show when={list().length <= 2 || open()}>
4749
<For each={list()}>

‎packages/tui/src/feature-plugins/sidebar/todo.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
22
import type { BuiltinTuiPlugin } from "../builtins"
3+
import { onHeaderClick } from "./click"
34
import { createMemo, For, Show, createSignal } from "solid-js"
45
import { TodoItem } from "../../component/todo-item"
56

@@ -14,11 +15,13 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
1415
return (
1516
<Show when={show()}>
1617
<box>
17-
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
18+
<box flexDirection="row" gap={1} {...onHeaderClick(() => list().length > 2 && setOpen((x) => !x))}>
1819
<Show when={list().length > 2}>
19-
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
20+
<text fg={theme().text} selectable={false}>
21+
{open() ? "▼" : "▶"}
22+
</text>
2023
</Show>
21-
<text fg={theme().text}>
24+
<text fg={theme().text} selectable={false}>
2225
<b>Todo</b>
2326
</text>
2427
</box>

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

Lines changed: 121 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@opencode-ai/plugin/tui"
22
import type { PluginContext } from "@opentui/core"
33
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
4-
import { createSignal } from "solid-js"
4+
import { children, createEffect, createRoot, createSignal } from "solid-js"
55
import type { TuiConfig } from "../config"
66
import { isRecord } from "../util/record"
77

@@ -32,33 +32,58 @@ export function sidebarSectionName(id: string) {
3232
return id.startsWith(BUILTIN_SIDEBAR_PREFIX) ? id.slice(BUILTIN_SIDEBAR_PREFIX.length) : id
3333
}
3434

35+
// Listed sections sort before every unlisted one, which keep their default order;
36+
// that way a section added later is never lost.
37+
export function sidebarSectionOrder(
38+
name: string,
39+
id: string,
40+
order: readonly string[] | undefined,
41+
fallback: number | undefined,
42+
) {
43+
const index = order?.findIndex((entry) => entry === name || entry === id) ?? -1
44+
return index === -1 ? fallback : Number.MIN_SAFE_INTEGER + index
45+
}
46+
47+
type SidebarRenderer<Slots extends Record<string, object>, Context extends PluginContext> = NonNullable<
48+
SolidPlugin<Slots, Context>["slots"]["sidebar_content"]
49+
>
50+
3551
/*
3652
* Applies `sidebar.order` / `sidebar.hidden` to one slot registration. Order belongs to
3753
* 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.
54+
* and its other slots keep their place.
4055
*/
4156
export function placeSidebarSection<
4257
Slots extends Record<string, object> & { sidebar_content: object },
4358
Context extends PluginContext,
44-
>(plugin: SolidPlugin<Slots, Context>, sidebar: TuiConfig.Sidebar | undefined): SolidPlugin<Slots, Context>[] {
59+
>(
60+
plugin: SolidPlugin<Slots, Context>,
61+
sidebar: TuiConfig.Sidebar | undefined,
62+
wrap?: (name: string, content: SidebarRenderer<Slots, Context>) => SidebarRenderer<Slots, Context>,
63+
): SolidPlugin<Slots, Context>[] {
4564
const content = plugin.slots.sidebar_content
46-
if (!content || (!sidebar?.order?.length && !sidebar?.hidden?.length)) return [plugin]
65+
if (!content || (!wrap && !sidebar?.order?.length && !sidebar?.hidden?.length)) return [plugin]
4766
const name = sidebarSectionName(plugin.id)
48-
const matches = (entry: string) => entry === name || entry === plugin.id
4967
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
68+
if (sidebar?.hidden?.some((entry) => entry === name || entry === plugin.id)) return [host]
5269
return [
5370
host,
5471
{
5572
id: `${plugin.id}:sidebar`,
56-
order: index === -1 ? plugin.order : Number.MIN_SAFE_INTEGER + index,
57-
slots: { sidebar_content: content },
73+
order: sidebarSectionOrder(name, plugin.id, sidebar?.order, plugin.order),
74+
slots: { sidebar_content: wrap ? wrap(name, content) : content },
5875
},
5976
]
6077
}
6178

79+
export const SIDEBAR_ORDER_KEY = "sidebar_order"
80+
81+
export function moveSidebarSection(names: readonly string[], from: string, to: string) {
82+
const next = names.filter((name) => name !== from)
83+
next.splice(names.indexOf(to), 0, from)
84+
return next
85+
}
86+
6287
export function createSlots() {
6388
const empty: SlotView = () => null
6489
const [view, setView] = createSignal<SlotView>(empty)
@@ -85,13 +110,98 @@ export function createSlots() {
85110
const slot = createSlot<RuntimeSlotMap, TuiSlotContext>(registry)
86111
setView(() => (props: TuiSlotProps<string>) => slot(props))
87112

113+
// A drag-and-drop order is saved in kv and wins over `sidebar.order` until reset.
114+
const sections = new Map<string, { name: string; pluginID: string; fallback?: number }>()
115+
const savedOrder = () => {
116+
const saved: unknown = api.kv.get(SIDEBAR_ORDER_KEY)
117+
if (!Array.isArray(saved)) return sidebar?.order
118+
return saved.filter((item): item is string => typeof item === "string")
119+
}
120+
const rank = (section: { name: string; pluginID: string; fallback?: number }, order = savedOrder()) =>
121+
sidebarSectionOrder(section.name, section.pluginID, order, section.fallback) ?? 0
122+
const disposeOrder = createRoot((dispose) => {
123+
createEffect(() => {
124+
const order = savedOrder()
125+
sections.forEach((section, id) => registry.updateOrder(id, rank(section, order)))
126+
})
127+
return dispose
128+
})
129+
130+
const [dragging, setDragging] = createSignal<string>()
131+
const [over, setOver] = createSignal<string>()
132+
let pressed: string | undefined
133+
const move = (from: string, to: string) => {
134+
const names = [...sections.values()].toSorted((a, b) => rank(a) - rank(b)).map((section) => section.name)
135+
api.kv.set(SIDEBAR_ORDER_KEY, moveSidebarSection(names, from, to))
136+
}
137+
138+
const release = () =>
139+
queueMicrotask(() => {
140+
pressed = undefined
141+
setDragging(undefined)
142+
})
143+
144+
/*
145+
* A section is dragged by its first line, its header; a press anywhere below it never
146+
* moves the section. The press is remembered on mouse down because opentui captures
147+
* whatever is under the pointer at the first drag event, which can be a gap between
148+
* sections or another one. Drag events then go to that capture only, so a drag is
149+
* recognized from the `over` events (they carry `source` while a capture drag is in
150+
* progress). A drag that started on selectable text is a text selection, which sends
151+
* neither, so only the header's unselectable name and blank cells are the handle.
152+
*/
153+
const wrap = (name: string, content: SidebarRenderer<RuntimeSlotMap, TuiSlotContext>) =>
154+
((ctx, props) => {
155+
const resolved = children(() => content(ctx, props))
156+
return (
157+
<box
158+
visible={resolved.toArray().some((item) => item !== null && item !== undefined && item !== false)}
159+
backgroundColor={
160+
dragging() !== undefined && dragging() !== name && over() === name
161+
? api.theme.current.backgroundElement
162+
: undefined
163+
}
164+
onMouseDown={function (event) {
165+
pressed = event.y === this.y ? name : undefined
166+
}}
167+
onMouseDrag={(event) => {
168+
if (!event.isDragging && pressed) setDragging(pressed)
169+
}}
170+
onMouseOver={(event) => {
171+
setOver(name)
172+
if (event.source && pressed) setDragging(pressed)
173+
}}
174+
onMouseOut={() => setOver(undefined)}
175+
onMouseUp={release}
176+
onMouseDragEnd={release}
177+
onMouseDrop={() => {
178+
const from = dragging()
179+
release()
180+
if (from && from !== name) move(from, name)
181+
}}
182+
>
183+
{resolved()}
184+
</box>
185+
)
186+
}) satisfies SidebarRenderer<RuntimeSlotMap, TuiSlotContext>
187+
88188
return {
89189
register(plugin: HostSlotPlugin) {
90190
if (!isHostSlotPlugin(plugin)) return () => {}
91-
const disposers = placeSidebarSection(plugin, sidebar).map((item) => registry.register(item))
191+
const items = placeSidebarSection(plugin, { order: savedOrder(), hidden: sidebar?.hidden }, wrap)
192+
const disposers = items.map((item) => {
193+
const dispose = registry.register(item)
194+
if (!item.slots.sidebar_content) return dispose
195+
sections.set(item.id, { name: sidebarSectionName(plugin.id), pluginID: plugin.id, fallback: plugin.order })
196+
return () => {
197+
sections.delete(item.id)
198+
dispose()
199+
}
200+
})
92201
return () => disposers.forEach((dispose) => dispose())
93202
},
94203
dispose() {
204+
disposeOrder()
95205
setView(() => empty)
96206
},
97207
}

‎packages/tui/src/routes/session/index.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode
7676
import { getScrollAcceleration } from "../../util/scroll"
7777
import { collapseToolOutput } from "../../util/collapse-tool-output"
7878
import { usePluginRuntime } from "../../plugin/runtime"
79+
import { SIDEBAR_ORDER_KEY } from "../../plugin/slots"
7980
import { DialogRetryAction } from "../../component/dialog-retry-action"
8081
import { getRevertDiffFiles } from "../../util/revert-diff"
8182
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap"
@@ -682,6 +683,16 @@ export function Session() {
682683
dialog.clear()
683684
},
684685
},
686+
{
687+
title: "Reset sidebar order",
688+
value: "session.sidebar.reset_order",
689+
category: "Session",
690+
enabled: kv.get(SIDEBAR_ORDER_KEY) !== undefined,
691+
run: () => {
692+
kv.set(SIDEBAR_ORDER_KEY, undefined)
693+
dialog.clear()
694+
},
695+
},
685696
{
686697
title: conceal() ? "Disable code concealment" : "Enable code concealment",
687698
value: "session.toggle.conceal",

0 commit comments

Comments
 (0)