pi-extensions-tool-display 0.1.1 → 0.2.1

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.
@@ -0,0 +1,34 @@
1
+ // Track cleanup callbacks for reload safety
2
+ let cleanupCallbacks: Array<() => void> = [];
3
+ let isDisposed = false;
4
+
5
+ export function registerCleanup(callback: () => void): void {
6
+ if (isDisposed) {
7
+ callback();
8
+ return;
9
+ }
10
+ cleanupCallbacks.push(callback);
11
+ }
12
+
13
+ export function registerTimer(timer: ReturnType<typeof setInterval> | ReturnType<typeof setTimeout>): void {
14
+ registerCleanup(() => clearInterval(timer as ReturnType<typeof setInterval>));
15
+ }
16
+
17
+ export function disposeAll(): void {
18
+ if (isDisposed) return;
19
+ isDisposed = true;
20
+ // Run in reverse order (LIFO)
21
+ for (let i = cleanupCallbacks.length - 1; i >= 0; i--) {
22
+ try { cleanupCallbacks[i](); } catch (cleanupError) { void cleanupError; }
23
+ }
24
+ cleanupCallbacks = [];
25
+ }
26
+
27
+ export function resetDisposed(): void {
28
+ isDisposed = false;
29
+ cleanupCallbacks = [];
30
+ }
31
+
32
+ export function getCleanupCount(): number {
33
+ return cleanupCallbacks.length;
34
+ }
@@ -0,0 +1,14 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * Register a session_shutdown handler that runs cleanup only when the
5
+ * session is shutting down for a reload. Centralizes the reload-detection
6
+ * check shared by extension registration modules.
7
+ */
8
+ export function onReloadShutdown(pi: ExtensionAPI, cleanup: () => void): void {
9
+ pi.on("session_shutdown", async (event: { reason?: string }) => {
10
+ if (event?.reason === "reload") {
11
+ cleanup();
12
+ }
13
+ });
14
+ }
package/src/index.ts CHANGED
@@ -1,98 +1,151 @@
1
- import { Container, type Component } from "@earendil-works/pi-tui";
2
-
3
- export const TOOL_DISPLAY_API_KEY = Symbol.for("pi-tool-display.api.v1");
4
- export const PENDING_MIDDLEWARES_KEY = Symbol.for("pi-tool-display.pendingResultRenderMiddlewares.v1");
5
-
6
- export type RenderTheme = {
7
- fg(color: string, text: string): string;
8
- bold(text: string): string;
9
- };
10
-
11
- export type ResultMiddlewareContext = {
12
- toolName: string;
13
- result: unknown;
14
- options: { expanded?: boolean };
15
- theme: RenderTheme;
16
- };
17
-
18
- export type ResultMiddleware = (
19
- context: ResultMiddlewareContext,
20
- next: () => unknown,
21
- ) => unknown;
22
-
23
- export type MiddlewareRegistration = {
24
- id: string;
25
- toolName: string;
26
- middleware: ResultMiddleware;
27
- };
28
-
29
- export type ToolDisplayApi = {
30
- registerResultRenderMiddleware?(registration: MiddlewareRegistration): string;
31
- unregisterResultRenderMiddleware?(id: string): boolean;
32
- hasResultRenderMiddleware?(id: string): boolean;
33
- isResultRenderPipelineActive?(toolName: string): boolean;
34
- };
35
-
36
- type GlobalProtocol = typeof globalThis & {
37
- [TOOL_DISPLAY_API_KEY]?: ToolDisplayApi;
38
- [PENDING_MIDDLEWARES_KEY]?: MiddlewareRegistration[];
39
- };
40
-
41
- function getApi(): ToolDisplayApi | undefined {
42
- return (globalThis as GlobalProtocol)[TOOL_DISPLAY_API_KEY];
43
- }
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ loadToolDisplayConfig,
7
+ normalizeToolDisplayConfig,
8
+ saveToolDisplayConfig,
9
+ } from "./config-store.js";
10
+ import {
11
+ applyCapabilityConfigGuards,
12
+ detectToolDisplayCapabilities,
13
+ type ToolDisplayCapabilities,
14
+ } from "./capabilities.js";
15
+ import { registerToolDisplayOverrides } from "./tool-overrides.js";
16
+ import { disposeAll, resetDisposed } from "./disposable.js";
17
+ import { registerThinkingLabeling } from "./thinking-label.js";
18
+ import registerNativeUserMessageBox from "./user-message-box-native.js";
19
+ import {
20
+ BUILT_IN_TOOL_OVERRIDE_NAMES,
21
+ type ConfigLoadResult,
22
+ type ToolDisplayConfig,
23
+ } from "./types.js";
24
+
25
+ export * from "./result-render-middleware.js";
44
26
 
45
- function queueRegistration(registration: MiddlewareRegistration): void {
46
- const globalProtocol = globalThis as GlobalProtocol;
47
- const queue = Array.isArray(globalProtocol[PENDING_MIDDLEWARES_KEY])
48
- ? globalProtocol[PENDING_MIDDLEWARES_KEY]!
49
- : [];
50
- const index = queue.findIndex((entry) => entry?.id === registration.id);
51
- if (index >= 0) queue[index] = registration;
52
- else queue.push(registration);
53
- globalProtocol[PENDING_MIDDLEWARES_KEY] = queue;
27
+ function ownershipChanged(
28
+ previous: ToolDisplayConfig,
29
+ next: ToolDisplayConfig,
30
+ ): boolean {
31
+ return BUILT_IN_TOOL_OVERRIDE_NAMES.some(
32
+ (toolName) =>
33
+ previous.registerToolOverrides[toolName] !==
34
+ next.registerToolOverrides[toolName],
35
+ );
54
36
  }
55
37
 
56
- function removeQueuedRegistration(id: string): void {
57
- const queue = (globalThis as GlobalProtocol)[PENDING_MIDDLEWARES_KEY];
58
- if (!Array.isArray(queue)) return;
59
- const index = queue.findIndex((entry) => entry?.id === id);
60
- if (index >= 0) queue.splice(index, 1);
38
+ export function toolDisplayReloadRequired(
39
+ previous: ToolDisplayConfig,
40
+ next: ToolDisplayConfig,
41
+ ): boolean {
42
+ return previous.enabled !== next.enabled || ownershipChanged(previous, next);
61
43
  }
62
44
 
63
- export function registerResultRenderMiddleware(
64
- registration: MiddlewareRegistration,
65
- ): () => void {
66
- const api = getApi();
67
- if (typeof api?.registerResultRenderMiddleware === "function") {
68
- api.registerResultRenderMiddleware(registration);
69
- } else {
70
- queueRegistration(registration);
45
+ const initializedHosts = new WeakSet<ExtensionAPI>();
46
+
47
+ function hasRegisteredToolDisplayCommand(pi: ExtensionAPI): boolean {
48
+ try {
49
+ return pi.getCommands().some((command) => command.name === "tool-display");
50
+ } catch {
51
+ return false;
71
52
  }
53
+ }
54
+
55
+ /**
56
+ * Load the display host when a feature extension uses this package as a
57
+ * dependency. Pi only auto-loads top-level packages, so relying on the
58
+ * dependency's `pi.extensions` metadata alone would leave the middleware
59
+ * queue undrained for users who install `pi-distill` or `pi-tool-supervisor`.
60
+ */
61
+ export function ensureToolDisplayHost(
62
+ pi: ExtensionAPI,
63
+ initial: ConfigLoadResult = loadToolDisplayConfig(),
64
+ ): void {
65
+ if (initializedHosts.has(pi)) return;
66
+ initializedHosts.add(pi);
72
67
 
73
- return () => {
74
- getApi()?.unregisterResultRenderMiddleware?.(registration.id);
75
- removeQueuedRegistration(registration.id);
68
+ resetDisposed();
69
+
70
+ pi.on("session_shutdown", (event: { reason: string }) => {
71
+ if (event.reason === "reload") {
72
+ disposeAll();
73
+ initializedHosts.delete(pi);
74
+ }
75
+ });
76
+
77
+ let config: ToolDisplayConfig = initial.config;
78
+ let pendingLoadError = initial.error;
79
+ let capabilities: ToolDisplayCapabilities = {
80
+ hasMcpTooling: false,
81
+ hasRtkOptimizer: false,
76
82
  };
77
- }
78
83
 
79
- export function isResultRenderMiddlewareActive(id: string, toolName: string): boolean {
80
- const api = getApi();
81
- return api?.hasResultRenderMiddleware?.(id) === true
82
- && api.isResultRenderPipelineActive?.(toolName) === true;
83
- }
84
+ const refreshCapabilities = (): void => {
85
+ capabilities = detectToolDisplayCapabilities(pi, process.cwd());
86
+ };
87
+
88
+ const getConfig = (): ToolDisplayConfig => config;
89
+ const getCapabilities = (): ToolDisplayCapabilities => capabilities;
90
+ const getEffectiveConfig = (): ToolDisplayConfig =>
91
+ applyCapabilityConfigGuards(config, capabilities);
92
+
93
+ const setConfig = (
94
+ next: ToolDisplayConfig,
95
+ ctx: ExtensionCommandContext,
96
+ ): void => {
97
+ const normalized = normalizeToolDisplayConfig(next);
98
+ const requiresReload = toolDisplayReloadRequired(config, normalized);
99
+ config = normalized;
100
+
101
+ const saved = saveToolDisplayConfig(normalized);
102
+ if (!saved.success && saved.error) {
103
+ ctx.ui.notify(saved.error, "error");
104
+ }
105
+
106
+ if (requiresReload) {
107
+ ctx.ui.notify(
108
+ "Global switch or tool ownership updates apply after /reload.",
109
+ "warning",
110
+ );
111
+ }
112
+ };
113
+
114
+ if (initial.config.enabled) {
115
+ registerToolDisplayOverrides(pi, getEffectiveConfig);
116
+ registerNativeUserMessageBox(pi, getConfig);
117
+ registerThinkingLabeling(pi);
118
+ }
119
+
120
+ // Each Pi extension has its own registration map, while getCommands() sees
121
+ // the shared runtime. Check the latter so feature extensions do not expose
122
+ // duplicate /tool-display entries when they initialize this host separately.
123
+ if (!hasRegisteredToolDisplayCommand(pi)) {
124
+ pi.registerCommand("tool-display", {
125
+ description: "Configure tool output rendering (OpenCode-style)",
126
+ handler: async (args, ctx) => {
127
+ const { runToolDisplayCommandHandler } = await import("./config-modal.js");
128
+ await runToolDisplayCommandHandler(args, ctx, { getConfig, setConfig, getCapabilities });
129
+ },
130
+ });
131
+ }
132
+
133
+ pi.on("session_start", async (_event, ctx) => {
134
+ refreshCapabilities();
135
+ if (pendingLoadError) {
136
+ ctx.ui.notify(pendingLoadError, "warning");
137
+ pendingLoadError = undefined;
138
+ }
139
+ });
84
140
 
85
- export function asComponent(value: unknown): Component | undefined {
86
- return value && typeof value === "object" && typeof (value as Component).render === "function"
87
- ? value as Component
88
- : undefined;
141
+ pi.on("before_agent_start", async () => {
142
+ refreshCapabilities();
143
+ });
89
144
  }
90
145
 
91
- export function appendResultRenderPanel(baseValue: unknown, panel: Component): Component {
92
- const base = asComponent(baseValue);
93
- if (!base) return panel;
94
- const container = new Container();
95
- container.addChild(base);
96
- container.addChild(panel);
97
- return container;
146
+ export default function toolDisplayExtension(
147
+ pi: ExtensionAPI,
148
+ initial: ConfigLoadResult = loadToolDisplayConfig(),
149
+ ): void {
150
+ ensureToolDisplayHost(pi, initial);
98
151
  }
@@ -0,0 +1,93 @@
1
+ import { normalizeDiffRenderWidth } from "./diff-presentation.js";
2
+ import { pluralize } from "./render-utils.js";
3
+
4
+ export interface WidthMeasurementOps {
5
+ measure(text: string): number;
6
+ truncate(text: string, maxWidth: number): string;
7
+ }
8
+
9
+ export interface CollapsedDiffHintOptions {
10
+ remainingLines: number;
11
+ hiddenHunks: number;
12
+ }
13
+
14
+ function guardSafeWidth(width: number): number | undefined {
15
+ const safe = normalizeDiffRenderWidth(width);
16
+ return safe === 0 ? undefined : safe;
17
+ }
18
+
19
+ function renderWithSafeWidth<T>(
20
+ width: number,
21
+ fallback: T,
22
+ render: (safeWidth: number) => T,
23
+ ): T {
24
+ const safeWidth = guardSafeWidth(width);
25
+ if (safeWidth === undefined) {
26
+ return fallback;
27
+ }
28
+ return render(safeWidth);
29
+ }
30
+
31
+ export function clampRenderedLineToWidth(
32
+ text: string,
33
+ width: number,
34
+ ops: WidthMeasurementOps,
35
+ ): string {
36
+ return renderWithSafeWidth(width, "", (safeWidth) => {
37
+ if (ops.measure(text) <= safeWidth) {
38
+ return text;
39
+ }
40
+
41
+ for (let targetWidth = safeWidth; targetWidth >= 0; targetWidth--) {
42
+ const candidate = ops.truncate(text, targetWidth);
43
+ if (ops.measure(candidate) <= safeWidth) {
44
+ return candidate;
45
+ }
46
+ }
47
+
48
+ return "";
49
+ });
50
+ }
51
+
52
+ export function clampRenderedLinesToWidth(
53
+ lines: string[],
54
+ width: number,
55
+ ops: WidthMeasurementOps,
56
+ ): string[] {
57
+ return lines.map((line) => clampRenderedLineToWidth(line, width, ops));
58
+ }
59
+
60
+ export function buildCollapsedDiffHintText(
61
+ options: CollapsedDiffHintOptions,
62
+ width: number,
63
+ ops: WidthMeasurementOps,
64
+ ): string {
65
+ return renderWithSafeWidth(width, "", (safeWidth) => {
66
+ const remainingText = `${options.remainingLines} more ${pluralize(options.remainingLines, "diff line")}`;
67
+ const hiddenHunksText = options.hiddenHunks > 0
68
+ ? `${options.hiddenHunks} more ${pluralize(options.hiddenHunks, "hunk")}`
69
+ : undefined;
70
+ const shortRemainingText = `${options.remainingLines} more ${pluralize(options.remainingLines, "line")}`;
71
+ const shortHiddenHunksText = options.hiddenHunks > 0
72
+ ? `${options.hiddenHunks} ${pluralize(options.hiddenHunks, "hunk")}`
73
+ : undefined;
74
+
75
+ const candidates = [
76
+ `… (${[remainingText, hiddenHunksText, "Ctrl+O to expand"].filter(Boolean).join(" • ")})`,
77
+ `… (${[remainingText, hiddenHunksText].filter(Boolean).join(" • ")})`,
78
+ `… (${[shortRemainingText, shortHiddenHunksText].filter(Boolean).join(" • ")})`,
79
+ options.hiddenHunks > 0
80
+ ? `… (+${options.remainingLines} • +${options.hiddenHunks}h)`
81
+ : `… (+${options.remainingLines})`,
82
+ "…",
83
+ ];
84
+
85
+ for (const candidate of candidates) {
86
+ if (ops.measure(candidate) <= safeWidth) {
87
+ return candidate;
88
+ }
89
+ }
90
+
91
+ return clampRenderedLineToWidth(candidates[candidates.length - 1] ?? "", safeWidth, ops);
92
+ });
93
+ }
@@ -0,0 +1,57 @@
1
+ export interface ModalIconSet {
2
+ search: string;
3
+ }
4
+
5
+ const NERD_MODAL_ICONS: ModalIconSet = {
6
+ search: "\uF002",
7
+ };
8
+
9
+ const EMOJI_MODAL_ICONS: ModalIconSet = {
10
+ search: "🔍",
11
+ };
12
+
13
+ const NERD_FONT_TERMINAL_HINTS = [
14
+ "iterm",
15
+ "wezterm",
16
+ "kitty",
17
+ "ghostty",
18
+ "alacritty",
19
+ "xfce4-terminal",
20
+ "gnome-terminal",
21
+ "tilix",
22
+ "terminator",
23
+ "konsole",
24
+ ] as const;
25
+
26
+ function parseBooleanEnv(value: string | undefined): boolean | undefined {
27
+ if (value === "1" || value?.toLowerCase() === "true") {
28
+ return true;
29
+ }
30
+
31
+ if (value === "0" || value?.toLowerCase() === "false") {
32
+ return false;
33
+ }
34
+
35
+ return undefined;
36
+ }
37
+
38
+ function detectNerdFonts(): boolean {
39
+ const explicitPreference =
40
+ parseBooleanEnv(process.env.PI_NERD_FONTS) ?? parseBooleanEnv(process.env.POWERLINE_NERD_FONTS);
41
+ if (explicitPreference !== undefined) {
42
+ return explicitPreference;
43
+ }
44
+
45
+ if (process.env.GHOSTTY_RESOURCES_DIR) {
46
+ return true;
47
+ }
48
+
49
+ const termProgram = (process.env.TERM_PROGRAM || "").toLowerCase();
50
+ const term = (process.env.TERM || "").toLowerCase();
51
+ const fingerprint = `${termProgram} ${term}`;
52
+ return NERD_FONT_TERMINAL_HINTS.some((terminal) => fingerprint.includes(terminal));
53
+ }
54
+
55
+ export function getModalIcons(): ModalIconSet {
56
+ return detectNerdFonts() ? NERD_MODAL_ICONS : EMOJI_MODAL_ICONS;
57
+ }