pi-ui-extension 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +43 -0
  2. package/extensions/ctx/README.md +35 -0
  3. package/extensions/ctx/activity.ts +226 -0
  4. package/extensions/ctx/data.test.ts +65 -0
  5. package/extensions/ctx/data.ts +230 -0
  6. package/extensions/ctx/panel.test.ts +103 -0
  7. package/extensions/ctx/panel.ts +185 -0
  8. package/extensions/ctx/tools.test.ts +64 -0
  9. package/extensions/fabric-detection.test.ts +15 -0
  10. package/extensions/fabric-detection.ts +7 -0
  11. package/extensions/footer/index.ts +62 -0
  12. package/extensions/index.ts +577 -0
  13. package/extensions/mcp/integration.test.ts +64 -0
  14. package/extensions/mcp/resources.test.ts +27 -0
  15. package/extensions/mcp/resources.ts +31 -0
  16. package/extensions/resource-dialog.test.ts +45 -0
  17. package/extensions/session/mutations.test.ts +30 -0
  18. package/extensions/session/mutations.ts +17 -0
  19. package/extensions/session/panel.test.ts +238 -0
  20. package/extensions/session/panel.ts +461 -0
  21. package/extensions/sidebar/default-panels.test.ts +97 -0
  22. package/extensions/sidebar/default-panels.ts +155 -0
  23. package/extensions/sidebar/index.ts +212 -0
  24. package/extensions/sidebar/layout.test.ts +68 -0
  25. package/extensions/sidebar/layout.ts +75 -0
  26. package/extensions/sidebar/panel.ts +49 -0
  27. package/extensions/sidebar/registry.ts +35 -0
  28. package/extensions/sidebar/resize.test.ts +71 -0
  29. package/extensions/sidebar/resize.ts +154 -0
  30. package/extensions/tool-view/controller.test.ts +57 -0
  31. package/extensions/tool-view/controller.ts +519 -0
  32. package/extensions/tool-view/panel.test.ts +73 -0
  33. package/extensions/tool-view/panel.ts +221 -0
  34. package/extensions/tool-view/trust.test.ts +41 -0
  35. package/extensions/tool-view/types.ts +6 -0
  36. package/extensions/tool-view/usage.test.ts +231 -0
  37. package/extensions/tool-view/usage.ts +303 -0
  38. package/extensions/tool-view/wheel.test.ts +32 -0
  39. package/package.json +53 -0
@@ -0,0 +1,461 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ stripTerminalSequences,
4
+ truncateToWidth,
5
+ visibleWidth,
6
+ type Component,
7
+ type TUI,
8
+ type TuiInputListener,
9
+ } from "@earendil-works/pi-tui";
10
+ import { panelBottom, panelContent } from "../sidebar/panel.ts";
11
+ import { installPriorityInputListener, parseSgrMouseEvent } from "../sidebar/resize.ts";
12
+
13
+ const MAX_VISIBLE_SESSIONS = 5;
14
+ const SESSION_ACTION_WIDTH = 4;
15
+ const SESSION_TITLE_CELLS = " SESSIONS ↻ ".length;
16
+ const CLEAR_OTHERS_CELL = " ⊗ ";
17
+
18
+ export type SessionListItem = {
19
+ readonly id: string;
20
+ readonly title: string;
21
+ readonly modified: Date;
22
+ readonly messageCount: number;
23
+ readonly current: boolean;
24
+ };
25
+
26
+ export type ResourceButtonId = "agents" | "skills" | "extensions" | "mcp";
27
+
28
+ export type SessionPanelOptions = {
29
+ readonly tui: TUI;
30
+ readonly getDesiredWidth: () => number;
31
+ readonly loadSessions: () => Promise<readonly SessionListItem[]>;
32
+ readonly onSelect: (session: SessionListItem) => void;
33
+ readonly onRename: (session: SessionListItem) => void;
34
+ readonly onDelete: (session: SessionListItem) => void;
35
+ readonly onDeleteOthers: () => void;
36
+ readonly onOpenResources: (button: ResourceButtonId) => void;
37
+ readonly now?: () => number;
38
+ };
39
+
40
+ const RESOURCE_BUTTONS: ReadonlyArray<{ id: ResourceButtonId; label: string }> = [
41
+ { id: "agents", label: "AGENTS.md" },
42
+ { id: "skills", label: "Skills" },
43
+ { id: "extensions", label: "Extensions" },
44
+ { id: "mcp", label: "MCP" },
45
+ ];
46
+
47
+ type SessionRowAction = "select" | "rename" | "delete";
48
+ type SessionActionTarget = {
49
+ readonly session: SessionListItem;
50
+ readonly action: SessionRowAction;
51
+ };
52
+ type LayoutRect = { readonly x: number; readonly y: number; readonly width: number; readonly height: number };
53
+ type LayoutBox = {
54
+ readonly component: Component;
55
+ readonly rect: LayoutRect;
56
+ readonly clip: LayoutRect;
57
+ readonly children: readonly LayoutBox[];
58
+ };
59
+ type LayoutTui = TUI & { readonly currentLayout?: { readonly root: LayoutBox } };
60
+
61
+ function contains(rect: LayoutRect, x: number, y: number): boolean {
62
+ return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
63
+ }
64
+
65
+ function findLayoutBox(box: LayoutBox, component: Component): LayoutBox | undefined {
66
+ if (box.component === component) return box;
67
+ for (const child of box.children) {
68
+ const match = findLayoutBox(child, component);
69
+ if (match) return match;
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ function normalizeTitle(title: string): string {
75
+ const plainText = stripTerminalSequences(title).replace(/[\u0000-\u001f\u007f]/g, " ");
76
+ return plainText.replace(/\s+/g, " ").trim() || "(untitled session)";
77
+ }
78
+
79
+ function formatRelativeTime(modified: Date, now: number): string {
80
+ const elapsedMs = Math.max(0, now - modified.getTime());
81
+ const minutes = Math.floor(elapsedMs / 60_000);
82
+ if (minutes < 1) return "now";
83
+ if (minutes < 60) return `${minutes}m`;
84
+ const hours = Math.floor(minutes / 60);
85
+ if (hours < 24) return `${hours}h`;
86
+ const days = Math.floor(hours / 24);
87
+ return days < 30 ? `${days}d` : `${Math.floor(days / 30)}mo`;
88
+ }
89
+
90
+ function padToWidth(text: string, width: number): string {
91
+ return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
92
+ }
93
+
94
+ /**
95
+ * Content-column span of the clear-others button on the title row, or undefined
96
+ * when the panel is too narrow to render it.
97
+ */
98
+ function clearOthersSpan(renderWidth: number): { readonly start: number; readonly end: number } | undefined {
99
+ const innerWidth = Math.max(1, renderWidth - 2);
100
+ if (innerWidth < SESSION_TITLE_CELLS + CLEAR_OTHERS_CELL.length + 2) return undefined;
101
+ const glyphStart = innerWidth - CLEAR_OTHERS_CELL.length - 1;
102
+ return { start: glyphStart - 1, end: glyphStart + CLEAR_OTHERS_CELL.length - 2 };
103
+ }
104
+
105
+ /** Title row with a right-aligned ⊗ button that trashes every other session. */
106
+ function renderSessionTitleRow(theme: Theme, title: string, renderWidth: number, clearPressed: boolean): string {
107
+ const innerWidth = Math.max(1, renderWidth - 2);
108
+ const span = clearOthersSpan(renderWidth);
109
+ const reserved = span ? CLEAR_OTHERS_CELL.length + 1 : 0;
110
+ const borderWidth = Math.max(0, innerWidth - SESSION_TITLE_CELLS - reserved);
111
+ const left = Math.floor(borderWidth / 2);
112
+ const right = borderWidth - left;
113
+ const border = theme.fg("accent", `╭${"─".repeat(left)} ${title} ${"─".repeat(right)}`);
114
+ if (!span) return `${border}${theme.fg("accent", "╮")}`;
115
+ return border +
116
+ theme.fg(clearPressed ? "warning" : "accent", CLEAR_OTHERS_CELL) +
117
+ theme.fg("accent", "─╮");
118
+ }
119
+
120
+ function formatSessionSummary(session: SessionListItem, width: number, now: number): string {
121
+ const marker = session.current ? "● " : "○ ";
122
+ const metadata = `${session.messageCount} · ${formatRelativeTime(session.modified, now)}`;
123
+ const fixedWidth = visibleWidth(marker) + visibleWidth(metadata) + 1;
124
+ if (fixedWidth >= width) {
125
+ return truncateToWidth(`${marker}${normalizeTitle(session.title)}`, width, "");
126
+ }
127
+
128
+ const titleWidth = width - fixedWidth;
129
+ const title = truncateToWidth(normalizeTitle(session.title), titleWidth, "…");
130
+ return `${marker}${padToWidth(title, titleWidth)} ${metadata}`;
131
+ }
132
+
133
+ function formatSessionRow(session: SessionListItem, contentWidth: number, now: number): string {
134
+ if (contentWidth <= SESSION_ACTION_WIDTH) {
135
+ return formatSessionSummary(session, contentWidth, now);
136
+ }
137
+ const summaryWidth = contentWidth - SESSION_ACTION_WIDTH;
138
+ const summary = padToWidth(formatSessionSummary(session, summaryWidth, now), summaryWidth);
139
+ return `${summary} ✎ ${session.current ? "·" : "×"}`;
140
+ }
141
+
142
+ function sameTarget(left: SessionActionTarget | undefined, right: SessionActionTarget | undefined): boolean {
143
+ return Boolean(left && right && left.session.id === right.session.id && left.action === right.action);
144
+ }
145
+
146
+ /** Recent-session panel: click the title to refresh, a row to switch, ✎ to rename, × to delete, or ⊗ to trash every other session. */
147
+ export class SessionPanel implements Component {
148
+ private pressedResource: ResourceButtonId | undefined;
149
+ private clearPressed = false;
150
+
151
+ private sessions: readonly SessionListItem[] = [];
152
+ private loading = true;
153
+ private disposed = false;
154
+ private loadInFlight = false;
155
+ private refreshPending = false;
156
+ private refreshPressed = false;
157
+ private pressedTarget: SessionActionTarget | undefined;
158
+ private readonly uninstallInput: () => void;
159
+
160
+ constructor(
161
+ private readonly theme: Theme,
162
+ private readonly options: SessionPanelOptions,
163
+ ) {
164
+ const listener: TuiInputListener = (data) => this.handleMouseInput(data);
165
+ this.uninstallInput = installPriorityInputListener(options.tui, listener);
166
+ }
167
+
168
+ private getLayoutBox(): LayoutBox | undefined {
169
+ const root = (this.options.tui as LayoutTui).currentLayout?.root;
170
+ return root ? findLayoutBox(root, this) : undefined;
171
+ }
172
+
173
+ private sessionAt(box: LayoutBox, x: number, y: number): SessionListItem | undefined {
174
+ if (!contains(box.clip, x, y) || !contains(box.rect, x, y)) return undefined;
175
+ const row = y - box.rect.y - 1;
176
+ return row >= 0 && row < this.sessions.length ? this.sessions[row] : undefined;
177
+ }
178
+
179
+ /** Buttons row sits after the session list: title(1) + up to 5 rows + spacing. */
180
+ private resourceButtonAt(box: LayoutBox, x: number, y: number): ResourceButtonId | undefined {
181
+ // Render rows: title(1) + sessions/listing (1..5 rows) + buttons(1). The
182
+ // button row is the last content row, i.e. rect.height - 2.
183
+ if (y - box.rect.y !== Math.max(2, box.rect.height - 2)) return undefined;
184
+ const renderWidth = Math.max(1, Math.min(box.rect.width, this.options.getDesiredWidth()));
185
+ const contentWidth = Math.max(1, renderWidth - 4);
186
+ const contentColumn = x - box.rect.x - 2;
187
+ if (contentColumn < 0 || contentColumn >= contentWidth) return undefined;
188
+ const index = Math.min(
189
+ RESOURCE_BUTTONS.length - 1,
190
+ Math.floor(contentColumn * RESOURCE_BUTTONS.length / contentWidth),
191
+ );
192
+ return RESOURCE_BUTTONS[index]!.id;
193
+ }
194
+
195
+ private targetAt(x: number, y: number): SessionActionTarget | undefined {
196
+ const box = this.getLayoutBox();
197
+ if (!box) return undefined;
198
+ const session = this.sessionAt(box, x, y);
199
+ if (!session) return undefined;
200
+
201
+ const renderWidth = Math.max(1, Math.min(box.rect.width, this.options.getDesiredWidth()));
202
+ const contentWidth = Math.max(1, renderWidth - 4);
203
+ const contentColumn = x - box.rect.x - 2;
204
+ if (contentColumn < 0 || contentColumn >= contentWidth) return undefined;
205
+ if (contentWidth <= SESSION_ACTION_WIDTH || contentColumn < contentWidth - SESSION_ACTION_WIDTH) {
206
+ return { session, action: "select" };
207
+ }
208
+ if (contentColumn < contentWidth - 2) return { session, action: "rename" };
209
+ return session.current ? undefined : { session, action: "delete" };
210
+ }
211
+
212
+ private isInsidePanel(x: number, y: number): boolean {
213
+ const box = this.getLayoutBox();
214
+ return Boolean(box && contains(box.clip, x, y) && contains(box.rect, x, y));
215
+ }
216
+
217
+ private isRefreshTarget(x: number, y: number): boolean {
218
+ const box = this.getLayoutBox();
219
+ return Boolean(
220
+ box &&
221
+ contains(box.clip, x, y) &&
222
+ contains(box.rect, x, y) &&
223
+ y === box.rect.y &&
224
+ !this.isClearOthersTarget(x, y),
225
+ );
226
+ }
227
+
228
+ private isClearOthersTarget(x: number, y: number): boolean {
229
+ const box = this.getLayoutBox();
230
+ if (!box || !contains(box.clip, x, y) || !contains(box.rect, x, y)) return false;
231
+ if (y !== box.rect.y) return false;
232
+ const renderWidth = Math.max(1, Math.min(box.rect.width, this.options.getDesiredWidth()));
233
+ const span = clearOthersSpan(renderWidth);
234
+ if (!span) return false;
235
+ const contentColumn = x - box.rect.x - 2;
236
+ return contentColumn >= span.start && contentColumn <= span.end;
237
+ }
238
+
239
+ private invoke(target: SessionActionTarget): void {
240
+ switch (target.action) {
241
+ case "select":
242
+ if (!target.session.current) this.options.onSelect(target.session);
243
+ return;
244
+ case "rename":
245
+ this.options.onRename(target.session);
246
+ return;
247
+ case "delete":
248
+ this.options.onDelete(target.session);
249
+ }
250
+ }
251
+
252
+ private handleMouseInput(data: string): { consume: true } | undefined {
253
+ const mouse = parseSgrMouseEvent(data);
254
+ if (!mouse) return undefined;
255
+
256
+ // SGR coordinates are one-based; Pi's layout frame uses zero-based coordinates.
257
+ const x = mouse.x - 1;
258
+ const y = mouse.y - 1;
259
+ const inside = this.isInsidePanel(x, y);
260
+ const isWheel = (mouse.button & 64) !== 0;
261
+ const isLeft = (mouse.button & 3) === 0;
262
+
263
+ if (mouse.release) {
264
+ if (this.pressedResource) {
265
+ const pressed = this.pressedResource;
266
+ this.pressedResource = undefined;
267
+ const box = this.getLayoutBox();
268
+ const released = box ? this.resourceButtonAt(box, x, y) : undefined;
269
+ if (released === pressed) {
270
+ queueMicrotask(() => {
271
+ if (!this.disposed) this.options.onOpenResources(pressed);
272
+ });
273
+ }
274
+ this.options.tui.requestRender();
275
+ return { consume: true };
276
+ }
277
+ if (this.clearPressed) {
278
+ this.clearPressed = false;
279
+ if (this.isClearOthersTarget(x, y)) {
280
+ queueMicrotask(() => {
281
+ if (!this.disposed) this.options.onDeleteOthers();
282
+ });
283
+ }
284
+ this.options.tui.requestRender();
285
+ return { consume: true };
286
+ }
287
+ if (this.refreshPressed) {
288
+ this.refreshPressed = false;
289
+ if (this.isRefreshTarget(x, y)) {
290
+ queueMicrotask(() => {
291
+ if (!this.disposed) this.refresh();
292
+ });
293
+ }
294
+ this.options.tui.requestRender();
295
+ return { consume: true };
296
+ }
297
+
298
+ const pressed = this.pressedTarget;
299
+ if (!pressed) return undefined;
300
+ this.pressedTarget = undefined;
301
+ const released = this.targetAt(x, y);
302
+ if (sameTarget(pressed, released) && released) {
303
+ queueMicrotask(() => {
304
+ if (!this.disposed) this.invoke(released);
305
+ });
306
+ }
307
+ this.options.tui.requestRender();
308
+ return { consume: true };
309
+ }
310
+
311
+ if (mouse.motion) {
312
+ if (this.refreshPressed) {
313
+ if (!this.isRefreshTarget(x, y)) {
314
+ this.refreshPressed = false;
315
+ this.options.tui.requestRender();
316
+ }
317
+ return { consume: true };
318
+ }
319
+ if (this.clearPressed) {
320
+ if (!this.isClearOthersTarget(x, y)) {
321
+ this.clearPressed = false;
322
+ this.options.tui.requestRender();
323
+ }
324
+ return { consume: true };
325
+ }
326
+ if (this.pressedResource) {
327
+ const box = this.getLayoutBox();
328
+ if (!box || this.resourceButtonAt(box, x, y) !== this.pressedResource) {
329
+ this.pressedResource = undefined;
330
+ this.options.tui.requestRender();
331
+ }
332
+ return { consume: true };
333
+ }
334
+ if (!this.pressedTarget) return undefined;
335
+ if (!sameTarget(this.pressedTarget, this.targetAt(x, y))) {
336
+ this.pressedTarget = undefined;
337
+ this.options.tui.requestRender();
338
+ }
339
+ return { consume: true };
340
+ }
341
+
342
+ if (!inside) return undefined;
343
+ // Do not let wheel events over the fixed session panel scroll the main transcript.
344
+ if (isWheel) return { consume: true };
345
+ if (!isLeft) return undefined;
346
+
347
+ if (this.isClearOthersTarget(x, y)) {
348
+ this.clearPressed = true;
349
+ this.options.tui.requestRender();
350
+ return { consume: true };
351
+ }
352
+
353
+ if (this.isRefreshTarget(x, y)) {
354
+ this.refreshPressed = true;
355
+ this.options.tui.requestRender();
356
+ return { consume: true };
357
+ }
358
+
359
+ const box = this.getLayoutBox();
360
+ const resourceButton = box ? this.resourceButtonAt(box, x, y) : undefined;
361
+ if (resourceButton) {
362
+ this.pressedResource = resourceButton;
363
+ this.options.tui.requestRender();
364
+ return { consume: true };
365
+ }
366
+
367
+ this.pressedTarget = this.targetAt(x, y);
368
+ if (this.pressedTarget) this.options.tui.requestRender();
369
+ return this.pressedTarget ? { consume: true } : undefined;
370
+ }
371
+
372
+ refresh(): void {
373
+ if (this.disposed) return;
374
+ if (this.loadInFlight) {
375
+ this.refreshPending = true;
376
+ return;
377
+ }
378
+
379
+ this.loadInFlight = true;
380
+ if (this.sessions.length === 0) this.loading = true;
381
+ this.options.tui.requestRender();
382
+ void this.options.loadSessions()
383
+ .then((sessions) => {
384
+ if (this.disposed) return;
385
+ this.sessions = sessions.slice(0, MAX_VISIBLE_SESSIONS);
386
+ this.loading = false;
387
+ })
388
+ .catch(() => {
389
+ if (this.disposed) return;
390
+ this.sessions = [];
391
+ this.loading = false;
392
+ })
393
+ .finally(() => {
394
+ this.loadInFlight = false;
395
+ if (this.disposed) return;
396
+ this.options.tui.requestRender();
397
+ if (this.refreshPending) {
398
+ this.refreshPending = false;
399
+ this.refresh();
400
+ }
401
+ });
402
+ }
403
+
404
+ render(width: number): string[] {
405
+ const renderWidth = Math.max(1, Math.min(width, this.options.getDesiredWidth()));
406
+ const contentWidth = Math.max(1, renderWidth - 4);
407
+ const now = this.options.now?.() ?? Date.now();
408
+ const body = this.sessions.length > 0
409
+ ? this.sessions.map((session) => panelContent(
410
+ this.theme,
411
+ formatSessionRow(session, contentWidth, now),
412
+ this.pressedTarget?.session.id === session.id
413
+ ? "warning"
414
+ : session.current
415
+ ? "accent"
416
+ : "dim",
417
+ contentWidth,
418
+ ))
419
+ : [panelContent(
420
+ this.theme,
421
+ this.loading ? "Loading sessions…" : "No saved sessions",
422
+ "dim",
423
+ contentWidth,
424
+ )];
425
+
426
+ const contentWidthButtons = Math.max(1, renderWidth - 4);
427
+ const baseWidth = Math.floor(contentWidthButtons / RESOURCE_BUTTONS.length);
428
+ const remainder = contentWidthButtons % RESOURCE_BUTTONS.length;
429
+ const buttonRow = RESOURCE_BUTTONS.map((button, index) => {
430
+ const segmentWidth = baseWidth + (index < remainder ? 1 : 0);
431
+ const clipped = truncateToWidth(button.label, Math.max(0, segmentWidth), "");
432
+ const padding = Math.max(0, segmentWidth - visibleWidth(clipped));
433
+ const text = " ".repeat(Math.floor(padding / 2)) + clipped + " ".repeat(padding - Math.floor(padding / 2));
434
+ return this.pressedResource === button.id
435
+ ? this.theme.fg("warning", text)
436
+ : this.theme.fg("accent", text);
437
+ }).join("");
438
+ const resourceRow = panelContent(this.theme, buttonRow, "accent", contentWidthButtons);
439
+
440
+ const divider = this.theme.fg("dim", `├${"─".repeat(Math.max(1, renderWidth - 2))}┤`);
441
+ return [
442
+ renderSessionTitleRow(this.theme, this.loading || this.loadInFlight ? "SESSIONS …" : "SESSIONS ↻", renderWidth, this.clearPressed),
443
+ ...body,
444
+ divider,
445
+ resourceRow,
446
+ panelBottom(this.theme, renderWidth),
447
+ ].map((line) => truncateToWidth(line, renderWidth, ""));
448
+ }
449
+
450
+ invalidate(): void {}
451
+
452
+ dispose(): void {
453
+ if (this.disposed) return;
454
+ this.disposed = true;
455
+ this.refreshPressed = false;
456
+ this.clearPressed = false;
457
+ this.pressedResource = undefined;
458
+ this.pressedTarget = undefined;
459
+ this.uninstallInput();
460
+ }
461
+ }
@@ -0,0 +1,97 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createDefaultSidebarPanels, createToolViewScrollView, SESSION_PANEL_ID, TOOL_VIEW_PANEL_ID } from "./default-panels.ts";
4
+ import { renderLayoutFrame } from "@earendil-works/pi-tui/dist/layout.js";
5
+ import { ToolViewPanel } from "../tool-view/panel.ts";
6
+
7
+ function options(showToolViewModeSelector?: boolean): Parameters<typeof createDefaultSidebarPanels>[0] {
8
+ return {
9
+ getContextUsage: () => undefined,
10
+ getModel: () => "test-model",
11
+ getThinkingLevel: () => "off",
12
+ getToolViewMode: () => "compact",
13
+ getToolUsage: () => undefined,
14
+ onSelectToolViewMode() {},
15
+ showToolViewModeSelector: () => showToolViewModeSelector ?? true,
16
+ loadSessions: async () => [],
17
+ onSelectSession() {},
18
+ onRenameSession() {},
19
+ onDeleteSession() {},
20
+ onDeleteOthers() {},
21
+ onOpenResources() {},
22
+ };
23
+ }
24
+
25
+ test("registers sessions without removed standalone panels", () => {
26
+ const ids = createDefaultSidebarPanels(options(true)).map((panel) => panel.id);
27
+ assert.deepEqual(ids, [SESSION_PANEL_ID, "context", TOOL_VIEW_PANEL_ID]);
28
+ assert.ok(!ids.includes("activity"));
29
+ assert.ok(ids.includes("context"));
30
+ const context = createDefaultSidebarPanels(options(true)).find(panel => panel.id === "context")!;
31
+ const sessions = createDefaultSidebarPanels(options(true)).find(panel => panel.id === SESSION_PANEL_ID)!;
32
+ assert.equal(sessions.layout?.maxSize, 9);
33
+ assert.equal(context.layout?.minSize, 10);
34
+ assert.ok(!ids.includes("resources"));
35
+ assert.ok(!ids.includes("mcp"));
36
+ });
37
+
38
+
39
+ test("always registers Tool View without Fabric", () => {
40
+ const ids = createDefaultSidebarPanels(options(true)).map((panel) => panel.id);
41
+ assert.ok(ids.includes(TOOL_VIEW_PANEL_ID));
42
+ });
43
+
44
+ test("keeps Tool View registered when Fabric hides only its selector", () => {
45
+ const ids = createDefaultSidebarPanels(options(false)).map((panel) => panel.id);
46
+ assert.ok(ids.includes(TOOL_VIEW_PANEL_ID));
47
+ });
48
+
49
+ test("keeps Tool View anchored at the top when usage rows overflow", () => {
50
+ const tui = { addInputListener: () => () => {}, requestRender() {} } as any;
51
+ const theme = { fg: (_color: string, text: string) => text } as any;
52
+ const panel = new ToolViewPanel(theme, {
53
+ tui,
54
+ getDesiredWidth: () => 60,
55
+ getMode: () => "compact",
56
+ getUsage: () => ({ counts: Array.from({ length: 30 }, (_, index) => ({
57
+ name: `tool-${index}`,
58
+ kind: "tool" as const,
59
+ exchange: 0,
60
+ session: index + 1,
61
+ })) }),
62
+ onSelect() {},
63
+ });
64
+ const scroll = createToolViewScrollView(panel, theme);
65
+ scroll.updateLayout(8, 10, () => {});
66
+ assert.equal(scroll.scrollbar, "hidden");
67
+
68
+ scroll.updateLayout(10, 10, () => {});
69
+ assert.equal(scroll.scrollbar, "hidden");
70
+
71
+ scroll.updateLayout(40, 10, () => {});
72
+ assert.equal(scroll.scrollTop, 0);
73
+ assert.equal(scroll.isFollowingEnd, false);
74
+ assert.equal(scroll.scrollbar, "always");
75
+ panel.dispose();
76
+ });
77
+
78
+ test("keeps the Tool View bottom border visible when usage rows overflow", () => {
79
+ const tui = { addInputListener: () => () => {}, requestRender() {} } as any;
80
+ const theme = { fg: (_color: string, text: string) => text } as any;
81
+ const panel = new ToolViewPanel(theme, {
82
+ tui,
83
+ getDesiredWidth: () => 30,
84
+ getMode: () => "compact",
85
+ getUsage: () => ({ counts: Array.from({ length: 20 }, (_, index) => ({
86
+ name: `tool-${index}`,
87
+ kind: "tool" as const,
88
+ exchange: 0,
89
+ session: index + 1,
90
+ })) }),
91
+ onSelect() {},
92
+ });
93
+ const scroll = createToolViewScrollView(panel, theme);
94
+ const frame = renderLayoutFrame(scroll, 30, 8, () => {});
95
+ assert.equal(frame.lines.at(-1), "╰────────────────────────────╯");
96
+ panel.dispose();
97
+ });