|
1 | 1 | /** @jsxImportSource @opentui/solid */ |
2 | 2 | import { expect, test } from "bun:test" |
3 | 3 | import { createSlot, createSolidSlotRegistry, testRender, useRenderer } from "@opentui/solid" |
| 4 | +import type { TuiPluginApi, TuiPluginMeta, TuiSlotPlugin } from "@opencode-ai/plugin/tui" |
4 | 5 | import { onMount } from "solid-js" |
5 | | -import type { TuiConfig } from "../../src/config" |
| 6 | +import { type TuiConfig, TuiConfigProvider } from "../../src/config" |
6 | 7 | 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" |
8 | 18 |
|
9 | 19 | type Slots = { |
10 | 20 | prompt: {} |
@@ -114,6 +124,144 @@ test("dragging a section moves it to the drop target's place", () => { |
114 | 124 | expect(moveSidebarSection(names, "mcp", "mcp")).toEqual(names) |
115 | 125 | }) |
116 | 126 |
|
| 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 | + |
117 | 265 | test("a section header toggles on click but not after a drag or a drop", () => { |
118 | 266 | let toggles = 0 |
119 | 267 | const header = onHeaderClick(() => toggles++) |
|
0 commit comments