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.
- package/README.md +13 -3
- package/README.zh-CN.md +14 -4
- package/config.example.json +40 -0
- package/index.ts +6 -0
- package/package.json +16 -5
- package/src/agent-dir.ts +32 -0
- package/src/ansi-utils.ts +116 -0
- package/src/bash-display.ts +191 -0
- package/src/capabilities.ts +85 -0
- package/src/config-modal.ts +540 -0
- package/src/config-store.ts +303 -0
- package/src/debug-logger.ts +150 -0
- package/src/diff-presentation.ts +72 -0
- package/src/diff-renderer.ts +2517 -0
- package/src/disposable.ts +34 -0
- package/src/extension-lifecycle.ts +14 -0
- package/src/index.ts +138 -85
- package/src/line-width-safety.ts +93 -0
- package/src/modal-icons.ts +57 -0
- package/src/pending-diff-preview.ts +371 -0
- package/src/presets.ts +108 -0
- package/src/render-utils.ts +186 -0
- package/src/result-render-middleware.ts +98 -0
- package/src/settings-inspector-modal.ts +475 -0
- package/src/thinking-label.ts +343 -0
- package/src/tool-metadata.ts +139 -0
- package/src/tool-overrides.ts +2237 -0
- package/src/types.ts +105 -0
- package/src/user-message-box-markdown.ts +64 -0
- package/src/user-message-box-native.ts +66 -0
- package/src/user-message-box-patch.ts +72 -0
- package/src/user-message-box-renderer.ts +419 -0
- package/src/user-message-box-utils.ts +106 -0
- package/src/write-display-utils.ts +31 -0
- package/src/zellij-modal.ts +1027 -0
- package/tool-display-api-consumer.d.ts +68 -0
- package/tool-display-api-consumer.js +82 -0
- package/tsconfig.json +1 -0
|
@@ -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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
: undefined;
|
|
141
|
+
pi.on("before_agent_start", async () => {
|
|
142
|
+
refreshCapabilities();
|
|
143
|
+
});
|
|
89
144
|
}
|
|
90
145
|
|
|
91
|
-
export function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
}
|