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,154 @@
1
+ import type {
2
+ Component,
3
+ OverlayHandle,
4
+ OverlayOptions,
5
+ TUI,
6
+ TuiInputListener,
7
+ } from "@earendil-works/pi-tui";
8
+
9
+ const SGR_MOUSE = /^\u001b\[<(\d+);(\d+);(\d+)([Mm])$/;
10
+ const PREVIEW_FRAME_INTERVAL_MS = 16;
11
+
12
+ export type SgrMouseEvent = {
13
+ readonly button: number;
14
+ readonly x: number;
15
+ readonly y: number;
16
+ readonly release: boolean;
17
+ readonly motion: boolean;
18
+ };
19
+
20
+ export function parseSgrMouseEvent(data: string): SgrMouseEvent | undefined {
21
+ const match = data.match(SGR_MOUSE);
22
+ if (!match) return undefined;
23
+ const button = Number(match[1]);
24
+ const x = Number(match[2]);
25
+ const y = Number(match[3]);
26
+ if (![button, x, y].every(Number.isFinite) || x < 1 || y < 1) return undefined;
27
+ return { button, x, y, release: match[4] === "m", motion: (button & 32) !== 0 };
28
+ }
29
+
30
+ export class SidebarDivider implements Component {
31
+ constructor(
32
+ private readonly paint: (line: string) => string,
33
+ private readonly getHeight: () => number,
34
+ ) {}
35
+ render(): string[] { return Array.from({ length: Math.max(1, this.getHeight()) }, () => this.paint("│")); }
36
+ invalidate(): void {}
37
+ }
38
+
39
+ /** Install ahead of Pi's built-in fullscreen mouse handler so sidebar gestures can consume events first. */
40
+ export function installPriorityInputListener(tui: TUI, listener: TuiInputListener): () => void {
41
+ const unsubscribe = tui.addInputListener(listener);
42
+ const listeners = (tui as unknown as { inputListeners?: Set<TuiInputListener> }).inputListeners;
43
+ if (listeners instanceof Set && listeners.delete(listener)) {
44
+ const existing = [...listeners];
45
+ listeners.clear();
46
+ listeners.add(listener);
47
+ for (const item of existing) listeners.add(item);
48
+ }
49
+ return unsubscribe;
50
+ }
51
+
52
+ export type MouseResizeOptions = {
53
+ readonly tui: TUI;
54
+ readonly preview: Component;
55
+ readonly isVisible: () => boolean;
56
+ readonly getDividerX: () => number;
57
+ readonly clampSidebarWidth: (width: number) => number;
58
+ readonly commitSidebarWidth: (width: number) => void;
59
+ };
60
+
61
+ export function installMouseResize(options: MouseResizeOptions): () => void {
62
+ let dragging = false;
63
+ let dragColumns = 0;
64
+ let dragRows = 0;
65
+ let geometryTimer: ReturnType<typeof setInterval> | undefined;
66
+ let previewHandle: OverlayHandle | undefined;
67
+ let pendingX: number | undefined;
68
+ let frameTimer: ReturnType<typeof setTimeout> | undefined;
69
+ let lastFrameAt = 0;
70
+ let disposed = false;
71
+ const previewOptions: OverlayOptions = {
72
+ width: 1,
73
+ maxHeight: "100%",
74
+ row: 0,
75
+ col: Math.max(0, options.getDividerX() - 1),
76
+ nonCapturing: true,
77
+ };
78
+
79
+ const clearFrameTimer = (): void => {
80
+ if (frameTimer) clearTimeout(frameTimer);
81
+ frameTimer = undefined;
82
+ };
83
+ const hidePreview = (): void => {
84
+ previewHandle?.hide();
85
+ previewHandle = undefined;
86
+ };
87
+ const cancelDrag = (): void => {
88
+ dragging = false;
89
+ pendingX = undefined;
90
+ clearFrameTimer();
91
+ if (geometryTimer) clearInterval(geometryTimer);
92
+ geometryTimer = undefined;
93
+ hidePreview();
94
+ };
95
+ const geometryValid = (): boolean => options.isVisible()
96
+ && (!dragging || (dragColumns === options.tui.terminal.columns && dragRows === options.tui.terminal.rows));
97
+ const flushPreview = (): void => {
98
+ clearFrameTimer();
99
+ if (disposed || !geometryValid()) { cancelDrag(); return; }
100
+ if (pendingX === undefined) return;
101
+ previewOptions.col = Math.max(0, pendingX - 1);
102
+ pendingX = undefined;
103
+ lastFrameAt = performance.now();
104
+ options.tui.requestRender();
105
+ };
106
+ const schedulePreview = (x: number): void => {
107
+ pendingX = x;
108
+ if (frameTimer) return;
109
+ const elapsed = performance.now() - lastFrameAt;
110
+ frameTimer = setTimeout(flushPreview, Math.max(0, PREVIEW_FRAME_INTERVAL_MS - elapsed));
111
+ frameTimer.unref?.();
112
+ };
113
+ const sidebarWidthAt = (x: number): number => options.clampSidebarWidth(options.tui.terminal.columns - x);
114
+ const previewXFor = (x: number): number => options.tui.terminal.columns - sidebarWidthAt(x);
115
+
116
+ const listener: TuiInputListener = (data) => {
117
+ if (disposed || !geometryValid()) { cancelDrag(); return undefined; }
118
+ const mouse = parseSgrMouseEvent(data);
119
+ if (!mouse) return undefined;
120
+
121
+ if (mouse.release) {
122
+ if (!dragging) return undefined;
123
+ cancelDrag();
124
+ options.commitSidebarWidth(sidebarWidthAt(mouse.x));
125
+ return { consume: true };
126
+ }
127
+ if (!mouse.motion && (mouse.button & 3) === 0 && (mouse.button & 64) === 0) {
128
+ if (Math.abs(mouse.x - options.getDividerX()) > 1) return undefined;
129
+ if (dragging) return { consume: true };
130
+ dragging = true;
131
+ dragColumns = options.tui.terminal.columns;
132
+ dragRows = options.tui.terminal.rows;
133
+ // Only watch geometry during a drag, including resizes with no further mouse input.
134
+ geometryTimer = setInterval(() => { if (!geometryValid()) cancelDrag(); }, PREVIEW_FRAME_INTERVAL_MS);
135
+ geometryTimer.unref?.();
136
+ previewOptions.col = Math.max(0, options.getDividerX() - 1);
137
+ previewHandle = options.tui.showOverlay(options.preview, previewOptions);
138
+ return { consume: true };
139
+ }
140
+ if (mouse.motion && dragging) {
141
+ schedulePreview(previewXFor(mouse.x));
142
+ return { consume: true };
143
+ }
144
+ return dragging ? { consume: true } : undefined;
145
+ };
146
+
147
+ const unsubscribe = installPriorityInputListener(options.tui, listener);
148
+
149
+ return () => {
150
+ disposed = true;
151
+ cancelDrag();
152
+ unsubscribe();
153
+ };
154
+ }
@@ -0,0 +1,57 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { installToolViewController } from "./controller.ts";
7
+
8
+ type Handler = (event: any, ctx: any) => unknown;
9
+
10
+ test("applies a mode change from another context wrapper in the active session", async () => {
11
+ const directory = await mkdtemp(join(tmpdir(), "tool-view-controller-"));
12
+ const modeFile = join(directory, "mode.json");
13
+ const handlers = new Map<string, Handler[]>();
14
+ const statuses: string[] = [];
15
+ const notices: string[] = [];
16
+ const expandedValues: boolean[] = [];
17
+ const sessionManager = { getSessionId: () => "session-1" };
18
+ let toolsExpanded = false;
19
+
20
+ const pi = {
21
+ registerTool() {},
22
+ registerCommand() {},
23
+ registerShortcut() {},
24
+ getCommands: () => [],
25
+ on(event: string, handler: Handler) {
26
+ handlers.set(event, [...(handlers.get(event) ?? []), handler]);
27
+ },
28
+ } as any;
29
+ const ui = {
30
+ setStatus(_id: string, value: string | undefined) { if (value) statuses.push(value); },
31
+ notify(message: string) { notices.push(message); },
32
+ getToolsExpanded: () => toolsExpanded,
33
+ setToolsExpanded(value: boolean) {
34
+ toolsExpanded = value;
35
+ expandedValues.push(value);
36
+ },
37
+ };
38
+
39
+ try {
40
+ const controller = await installToolViewController(pi, { modeFile });
41
+ const sessionCtx = { mode: "tui", ui, sessionManager } as any;
42
+ for (const handler of handlers.get("session_start") ?? []) handler({}, sessionCtx);
43
+
44
+ // Pi may create another ExtensionContext wrapper for a panel/command while
45
+ // retaining the same active SessionManager identity.
46
+ const panelCtx = { mode: "tui", ui, sessionManager } as any;
47
+ await controller.setMode("normal", panelCtx);
48
+
49
+ assert.equal(controller.getMode(), "normal");
50
+ assert.deepEqual(JSON.parse(await readFile(modeFile, "utf8")), { mode: "normal" });
51
+ assert.equal(statuses.at(-1), "工具:完整");
52
+ assert.equal(notices.at(-1), "工具显示模式:完整(normal)");
53
+ assert.deepEqual(expandedValues.slice(-2), [true, false]);
54
+ } finally {
55
+ await rm(directory, { recursive: true, force: true });
56
+ }
57
+ });