Skip to content

Commit 1c06f29

Browse files
committed
fix(tui): keep sidebar header values selectable next to the drag handle
dc68262 made each sidebar section header the drag handle by marking the whole header text unselectable. That took copy-on-select away from the values merged onto those headers since: the compact Context line (tokens, percent, cost), the MCP status summary and counts, the Todo summary, and "are disabled" after the LSP heading. It also let a press on any blank cell of a section, such as the gap after an MCP row's bullet, start a section drag. Only the section name and its arrow stay unselectable now; the values after them on the header line are their own selectable text. A drag reorders only when it starts on the section's first line, its header. Clicking anywhere on the header, values included, still toggles it, because toggling needs a press and release on the same cell, which a drag-select never is. This is the same handle rule as the amended upstream PR anomalyco#53217 head 937fde8. 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 bd50370 AI-Session-ID: ses_ef81ad30fffeo7m3HoZisuJdli
1 parent 79251bc commit 1c06f29

6 files changed

Lines changed: 179 additions & 26 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
111111
<text fg={theme().text} selectable={false}>
112112
▶
113113
</text>
114-
<text fg={theme().textMuted} selectable={false}>
114+
<text fg={theme().textMuted}>
115115
<span style={{ fg: colors().context }}>{line()[0]}</span>
116116
{" · "}
117117
<span style={{ fg: colors().context }}>{line()[1]}</span>

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ function View(props: { api: TuiPluginApi }) {
2121
</Show>
2222
<text fg={theme().text} selectable={false}>
2323
<b>{off() ? "LSPs" : "LSP"}</b>
24-
<Show when={off()}>
25-
<span style={{ fg: theme().textMuted }}> are disabled</span>
26-
</Show>
2724
</text>
25+
<Show when={off()}>
26+
<text fg={theme().textMuted}>are disabled</text>
27+
</Show>
2828
</box>
2929
<Show when={list().length <= 2 || open()}>
3030
<Show when={list().length === 0 && !off()}>

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

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,23 +49,24 @@ function View(props: { api: TuiPluginApi }) {
4949
</Show>
5050
<text fg={theme().text} selectable={false}>
5151
<b>MCP</b>
52-
<Show when={summary() === "default" && !open()}>
53-
<span style={{ fg: theme().textMuted }}>
54-
{" "}
55-
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
56-
</span>
57-
</Show>
58-
<Show when={summary() === "always" || (summary() === "collapsed" && !open())}>
52+
</text>
53+
<Show when={summary() === "default" && !open()}>
54+
<text fg={theme().textMuted}>
55+
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
56+
</text>
57+
</Show>
58+
<Show when={summary() === "always" || (summary() === "collapsed" && !open())}>
59+
<text>
5960
<For each={counts()}>
60-
{(item) => (
61+
{(item, index) => (
6162
<>
62-
<span style={{ fg: dot(item.status) }}> •</span>
63+
<span style={{ fg: dot(item.status) }}>{index() > 0 ? " •" : "•"}</span>
6364
<span style={{ fg: theme().textMuted }}>{item.count}</span>
6465
</>
6566
)}
6667
</For>
67-
</Show>
68-
</text>
68+
</text>
69+
</Show>
6970
</box>
7071
<Show when={list().length <= 2 || open()}>
7172
<For each={list()}>

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,10 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
4141
</Show>
4242
<text fg={theme().text} selectable={false}>
4343
<b>Todo</b>
44-
<Show when={summary()}>
45-
<span style={{ fg: theme().text }}> {summary()}</span>
46-
</Show>
4744
</text>
45+
<Show when={summary()}>
46+
<text fg={theme().text}>{summary()}</text>
47+
</Show>
4848
</box>
4949
<Show when={list().length <= 2 || open()}>
5050
<For each={list()}>{(item) => <TodoItem status={item.status} content={item.content} />}</For>

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

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,13 @@ export function createSlots() {
142142
})
143143

144144
/*
145-
* The press is remembered on mouse down because opentui captures whatever is under the
146-
* pointer at the first drag event, which can be a gap between sections or another one.
147-
* Drag events then go to that capture only, so a drag is recognized from the `over`
148-
* events (they carry `source` while a capture drag is in progress). A drag that started
149-
* on selectable text is a text selection, which sends neither, and never reorders.
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.
150152
*/
151153
const wrap = (name: string, content: SidebarRenderer<RuntimeSlotMap, TuiSlotContext>) =>
152154
((ctx, props) => {
@@ -159,7 +161,9 @@ export function createSlots() {
159161
? api.theme.current.backgroundElement
160162
: undefined
161163
}
162-
onMouseDown={() => (pressed = name)}
164+
onMouseDown={function (event) {
165+
pressed = event.y === this.y ? name : undefined
166+
}}
163167
onMouseDrag={(event) => {
164168
if (!event.isDragging && pressed) setDragging(pressed)
165169
}}

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

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
/** @jsxImportSource @opentui/solid */
22
import { expect, test } from "bun:test"
33
import { createSlot, createSolidSlotRegistry, testRender, useRenderer } from "@opentui/solid"
4+
import type { TuiPluginApi, TuiPluginMeta, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
45
import { onMount } from "solid-js"
5-
import type { TuiConfig } from "../../src/config"
6+
import { type TuiConfig, TuiConfigProvider } from "../../src/config"
67
import { onHeaderClick } from "../../src/feature-plugins/sidebar/click"
7-
import { moveSidebarSection, placeSidebarSection, sidebarSectionName } from "../../src/plugin/slots"
8+
import SidebarMcp from "../../src/feature-plugins/sidebar/mcp"
9+
import {
10+
createSlots,
11+
moveSidebarSection,
12+
placeSidebarSection,
13+
SIDEBAR_ORDER_KEY,
14+
sidebarSectionName,
15+
} from "../../src/plugin/slots"
16+
import { createTuiPluginApi } from "../fixture/tui-plugin"
17+
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
818

919
type Slots = {
1020
prompt: {}
@@ -114,6 +124,144 @@ test("dragging a section moves it to the drop target's place", () => {
114124
expect(moveSidebarSection(names, "mcp", "mcp")).toEqual(names)
115125
})
116126

127+
// Two sections shaped like the built-in ones: an unselectable name with selectable data after
128+
// it, then a row whose gap after the bullet is not text.
129+
// y0 "Context 12 tokens", y1 "• row", y3 "MCP •3", y4 "• row"
130+
async function renderDraggableSidebar() {
131+
const slots = createSlots()
132+
const base = createTuiPluginApi()
133+
const section = (title: string, data: string) => () => (
134+
<box>
135+
<box flexDirection="row" gap={1}>
136+
<text selectable={false}>{title}</text>
137+
<text>{data}</text>
138+
</box>
139+
<box flexDirection="row" gap={1}>
140+
<text>•</text>
141+
<text>row</text>
142+
</box>
143+
</box>
144+
)
145+
const App = () => {
146+
const host = slots.setup({ ...base, renderer: useRenderer() } satisfies TuiPluginApi)
147+
host.register({
148+
id: "internal:sidebar-context",
149+
order: 100,
150+
slots: { sidebar_content: section("Context", "12 tokens") },
151+
})
152+
host.register({ id: "internal:sidebar-mcp", order: 200, slots: { sidebar_content: section("MCP", "•3") } })
153+
return (
154+
<box gap={1}>
155+
<slots.Slot name="sidebar_content" session_id="ses_test" />
156+
</box>
157+
)
158+
}
159+
const app = await testRender(() => <App />, { width: 30, height: 8 })
160+
await app.renderOnce()
161+
return { app, order: () => base.kv.get(SIDEBAR_ORDER_KEY) }
162+
}
163+
164+
test("a section drags by its header", async () => {
165+
const sidebar = await renderDraggableSidebar()
166+
try {
167+
await sidebar.app.mockMouse.drag(0, 3, 0, 0)
168+
expect(sidebar.order()).toEqual(["mcp", "context"])
169+
} finally {
170+
sidebar.app.renderer.destroy()
171+
}
172+
})
173+
174+
test("a drag that starts below a section's header never moves the section", async () => {
175+
const sidebar = await renderDraggableSidebar()
176+
try {
177+
await sidebar.app.mockMouse.drag(1, 4, 1, 0)
178+
expect(sidebar.order()).toBeUndefined()
179+
} finally {
180+
sidebar.app.renderer.destroy()
181+
}
182+
})
183+
184+
test("text after a section name selects instead of dragging the section", async () => {
185+
const sidebar = await renderDraggableSidebar()
186+
try {
187+
await sidebar.app.mockMouse.drag(4, 3, 6, 3)
188+
expect(sidebar.app.renderer.getSelection()?.getSelectedText()).toBe("•3")
189+
expect(sidebar.order()).toBeUndefined()
190+
} finally {
191+
sidebar.app.renderer.destroy()
192+
}
193+
})
194+
195+
async function renderMcpSection(sidebar: TuiConfig.Sidebar) {
196+
const slots = createSlots()
197+
const base = createTuiPluginApi()
198+
const servers = ["a", "b", "c"].map((name) => ({ name, status: "failed" as const, error: "boom" }))
199+
const App = () => {
200+
const hostApi = { ...base, renderer: useRenderer() } satisfies TuiPluginApi
201+
const host = slots.setup(hostApi)
202+
const api = {
203+
...hostApi,
204+
state: { ...base.state, mcp: () => servers },
205+
slots: {
206+
register(plugin: TuiSlotPlugin) {
207+
host.register({ ...plugin, id: "internal:sidebar-mcp" })
208+
return "internal:sidebar-mcp"
209+
},
210+
},
211+
} satisfies TuiPluginApi
212+
void SidebarMcp.tui(api, undefined, mcpMeta)
213+
return (
214+
<TuiConfigProvider config={createTuiResolvedConfig({ sidebar })}>
215+
<slots.Slot name="sidebar_content" session_id="ses_test" />
216+
</TuiConfigProvider>
217+
)
218+
}
219+
const app = await testRender(() => <App />, { width: 40, height: 10 })
220+
await app.renderOnce()
221+
return { app, heading: () => app.captureCharFrame().split("\n")[0].trim() }
222+
}
223+
224+
test("the collapsed MCP summary selects as text and still toggles on click", async () => {
225+
const mcp = await renderMcpSection({})
226+
try {
227+
await mcp.app.mockMouse.click(0, 0)
228+
await mcp.app.renderOnce()
229+
expect(mcp.heading()).toBe("▶ MCP (0 active, 3 errors)")
230+
await mcp.app.mockMouse.drag(6, 0, 15, 0)
231+
expect(mcp.app.renderer.getSelection()?.getSelectedText()).toBe("(0 active")
232+
mcp.app.renderer.clearSelection()
233+
await mcp.app.mockMouse.click(8, 0)
234+
await mcp.app.renderOnce()
235+
expect(mcp.heading()).toBe("▼ MCP")
236+
} finally {
237+
mcp.app.renderer.destroy()
238+
}
239+
})
240+
241+
test("the MCP status counts after the heading select as text", async () => {
242+
const mcp = await renderMcpSection({ mcp_summary: "always" })
243+
try {
244+
expect(mcp.heading()).toBe("▼ MCP •3")
245+
await mcp.app.mockMouse.drag(6, 0, 8, 0)
246+
expect(mcp.app.renderer.getSelection()?.getSelectedText()).toBe("•3")
247+
} finally {
248+
mcp.app.renderer.destroy()
249+
}
250+
})
251+
252+
const mcpMeta = {
253+
id: "internal:sidebar-mcp",
254+
source: "internal",
255+
spec: "internal:sidebar-mcp",
256+
target: "internal:sidebar-mcp",
257+
first_time: 0,
258+
last_time: 0,
259+
time_changed: 0,
260+
load_count: 1,
261+
fingerprint: "test",
262+
state: "same",
263+
} satisfies TuiPluginMeta
264+
117265
test("a section header toggles on click but not after a drag or a drop", () => {
118266
let toggles = 0
119267
const header = onHeaderClick(() => toggles++)

0 commit comments

Comments
 (0)