pi-compact-tools 0.8.1 → 0.8.3
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 +3 -3
- package/extensions/compact-tools-color.ts +24 -8
- package/extensions/compact-tools-core.ts +11 -1
- package/extensions/compact-tools-language.ts +146 -0
- package/extensions/compact-tools-layout.ts +7 -2
- package/extensions/compact-tools-markdown.ts +3 -2
- package/extensions/compact-tools-progress.ts +11 -1
- package/extensions/compact-tools.ts +8 -3
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Compact, expandable rendering for Pi's built-in tools, with a GitHub Dark theme.
|
|
4
4
|
|
|
5
|
-

|
|
6
6
|
|
|
7
|
-

|
|
8
8
|
|
|
9
|
-

|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
@@ -25,16 +25,29 @@ function ansi256ToRgb(index: number): Rgb {
|
|
|
25
25
|
return { r: gray, g: gray, b: gray };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const ANSI_256_RGB: readonly Rgb[] = Array.from({ length: 256 }, (_, index) => ansi256ToRgb(index));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The nearest-color search runs per character on 256-color terminals, so both the
|
|
32
|
+
* palette and the answers are kept. Callers interpolate between a handful of theme
|
|
33
|
+
* colors, so the memo holds tens of entries rather than growing with output size.
|
|
34
|
+
*/
|
|
35
|
+
const ansi256ByRgb = new Map<number, number>();
|
|
36
|
+
|
|
28
37
|
function rgbToAnsi256(rgb: Rgb): number {
|
|
38
|
+
const key = (rgb.r << 16) | (rgb.g << 8) | rgb.b;
|
|
39
|
+
const memoized = ansi256ByRgb.get(key);
|
|
40
|
+
if (memoized !== undefined) return memoized;
|
|
29
41
|
let bestIndex = 0;
|
|
30
42
|
let bestDistance = Number.POSITIVE_INFINITY;
|
|
31
43
|
for (let index = 0; index < 256; index++) {
|
|
32
|
-
const candidate =
|
|
44
|
+
const candidate = ANSI_256_RGB[index]!;
|
|
33
45
|
const distance = (rgb.r - candidate.r) ** 2 + (rgb.g - candidate.g) ** 2 + (rgb.b - candidate.b) ** 2;
|
|
34
46
|
if (distance >= bestDistance) continue;
|
|
35
47
|
bestIndex = index;
|
|
36
48
|
bestDistance = distance;
|
|
37
49
|
}
|
|
50
|
+
ansi256ByRgb.set(key, bestIndex);
|
|
38
51
|
return bestIndex;
|
|
39
52
|
}
|
|
40
53
|
|
|
@@ -57,11 +70,17 @@ export function interpolateRgb(from: Rgb, to: Rgb, amount: number): Rgb {
|
|
|
57
70
|
};
|
|
58
71
|
}
|
|
59
72
|
|
|
60
|
-
|
|
73
|
+
/** Shared by the foreground (38/39) and background (48/49) SGR pairs. */
|
|
74
|
+
function paintRgb(theme: Theme, color: Rgb, text: string, set: 38 | 48): string {
|
|
75
|
+
const reset = set === 38 ? 39 : 49;
|
|
61
76
|
if (typeof theme.getColorMode === "function" && theme.getColorMode() === "256color") {
|
|
62
|
-
return `\x1b[
|
|
77
|
+
return `\x1b[${set};5;${rgbToAnsi256(color)}m${text}\x1b[${reset}m`;
|
|
63
78
|
}
|
|
64
|
-
return `\x1b[
|
|
79
|
+
return `\x1b[${set};2;${color.r};${color.g};${color.b}m${text}\x1b[${reset}m`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function colorizeRgb(theme: Theme, color: Rgb, text: string): string {
|
|
83
|
+
return paintRgb(theme, color, text, 38);
|
|
65
84
|
}
|
|
66
85
|
|
|
67
86
|
function isDarkTheme(theme: Theme): boolean {
|
|
@@ -82,8 +101,5 @@ export function diffTintRgb(theme: Theme, color: ThemeForeground, amount = 0.16)
|
|
|
82
101
|
}
|
|
83
102
|
|
|
84
103
|
export function fillRgb(theme: Theme, color: Rgb, text: string): string {
|
|
85
|
-
|
|
86
|
-
return `\x1b[48;5;${rgbToAnsi256(color)}m${text}\x1b[49m`;
|
|
87
|
-
}
|
|
88
|
-
return `\x1b[48;2;${color.r};${color.g};${color.b}m${text}\x1b[49m`;
|
|
104
|
+
return paintRgb(theme, color, text, 48);
|
|
89
105
|
}
|
|
@@ -38,6 +38,16 @@ export function classifyCallStatus(isError: boolean, executionStarted: boolean,
|
|
|
38
38
|
return executionStarted ? "running" : "pending";
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Sub-minute durations keep millisecond precision; longer runs switch to the
|
|
43
|
+
* minute and hour grouping Pi's own bash and powershell renderers use.
|
|
44
|
+
*/
|
|
41
45
|
export function formatDurationMs(elapsedMs: number): string {
|
|
42
|
-
|
|
46
|
+
const seconds = elapsedMs / 1000;
|
|
47
|
+
if (seconds < 60) return `${seconds.toFixed(3)}s`;
|
|
48
|
+
const totalSeconds = Math.floor(seconds);
|
|
49
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
50
|
+
const remainderSeconds = totalSeconds % 60;
|
|
51
|
+
if (minutes < 60) return `${minutes}m ${remainderSeconds}s`;
|
|
52
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m ${remainderSeconds}s`;
|
|
43
53
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { getLanguageFromPath } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pi resolves a language from the file extension and deliberately never falls back to
|
|
5
|
+
* content auto-detection, because highlight.js guesses confidently and wrongly on the
|
|
6
|
+
* things people read most: prose scores as AppleScript or LiveCode, logs and `git log`
|
|
7
|
+
* output as Apache, a one-line TypeScript statement as AutoIt. Relevance scores do not
|
|
8
|
+
* separate those from real matches, and the library is not reachable from this package
|
|
9
|
+
* anyway — Pi keeps it nested.
|
|
10
|
+
*
|
|
11
|
+
* So this module only widens the deterministic signals: names that identify a file
|
|
12
|
+
* without an extension, extensions Pi's table omits, and an interpreter the file
|
|
13
|
+
* declares itself. Anything still unknown stays unhighlighted rather than guessed.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Files whose name, not extension, states the language. Keyed by lowercased basename. */
|
|
17
|
+
const FILENAME_LANGUAGES: Readonly<Record<string, string>> = {
|
|
18
|
+
dockerfile: "dockerfile",
|
|
19
|
+
containerfile: "dockerfile",
|
|
20
|
+
makefile: "makefile",
|
|
21
|
+
gnumakefile: "makefile",
|
|
22
|
+
"cmakelists.txt": "cmake",
|
|
23
|
+
rakefile: "ruby",
|
|
24
|
+
gemfile: "ruby",
|
|
25
|
+
guardfile: "ruby",
|
|
26
|
+
podfile: "ruby",
|
|
27
|
+
brewfile: "ruby",
|
|
28
|
+
vagrantfile: "ruby",
|
|
29
|
+
jenkinsfile: "groovy",
|
|
30
|
+
".bashrc": "bash",
|
|
31
|
+
".bash_profile": "bash",
|
|
32
|
+
".bash_aliases": "bash",
|
|
33
|
+
".zshrc": "bash",
|
|
34
|
+
".zprofile": "bash",
|
|
35
|
+
".profile": "bash",
|
|
36
|
+
".editorconfig": "ini",
|
|
37
|
+
".npmrc": "ini",
|
|
38
|
+
".yarnrc": "ini",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Extensions Pi's own table does not cover. Unsupported names degrade to plain text. */
|
|
42
|
+
const EXTENSION_LANGUAGES: Readonly<Record<string, string>> = {
|
|
43
|
+
mts: "typescript",
|
|
44
|
+
cts: "typescript",
|
|
45
|
+
jsonc: "json",
|
|
46
|
+
json5: "json",
|
|
47
|
+
bat: "dos",
|
|
48
|
+
cmd: "dos",
|
|
49
|
+
psm1: "powershell",
|
|
50
|
+
psd1: "powershell",
|
|
51
|
+
ini: "ini",
|
|
52
|
+
cfg: "ini",
|
|
53
|
+
conf: "ini",
|
|
54
|
+
properties: "properties",
|
|
55
|
+
diff: "diff",
|
|
56
|
+
patch: "diff",
|
|
57
|
+
svg: "xml",
|
|
58
|
+
gradle: "groovy",
|
|
59
|
+
groovy: "groovy",
|
|
60
|
+
dart: "dart",
|
|
61
|
+
nix: "nix",
|
|
62
|
+
zig: "zig",
|
|
63
|
+
mm: "objectivec",
|
|
64
|
+
pl: "perl",
|
|
65
|
+
pm: "perl",
|
|
66
|
+
rake: "ruby",
|
|
67
|
+
kts: "kotlin",
|
|
68
|
+
sc: "scala",
|
|
69
|
+
vb: "vbnet",
|
|
70
|
+
jl: "julia",
|
|
71
|
+
cr: "crystal",
|
|
72
|
+
awk: "awk",
|
|
73
|
+
tcl: "tcl",
|
|
74
|
+
f90: "fortran",
|
|
75
|
+
f95: "fortran",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Interpreters seen after `#!`, with any version suffix already stripped. */
|
|
79
|
+
const INTERPRETER_LANGUAGES: Readonly<Record<string, string>> = {
|
|
80
|
+
bash: "bash",
|
|
81
|
+
sh: "bash",
|
|
82
|
+
zsh: "bash",
|
|
83
|
+
dash: "bash",
|
|
84
|
+
ksh: "bash",
|
|
85
|
+
fish: "fish",
|
|
86
|
+
python: "python",
|
|
87
|
+
node: "javascript",
|
|
88
|
+
deno: "typescript",
|
|
89
|
+
bun: "typescript",
|
|
90
|
+
ruby: "ruby",
|
|
91
|
+
perl: "perl",
|
|
92
|
+
php: "php",
|
|
93
|
+
lua: "lua",
|
|
94
|
+
awk: "awk",
|
|
95
|
+
gawk: "awk",
|
|
96
|
+
rscript: "r",
|
|
97
|
+
pwsh: "powershell",
|
|
98
|
+
powershell: "powershell",
|
|
99
|
+
tclsh: "tcl",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function basename(path: string): string {
|
|
103
|
+
const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
104
|
+
return separator === -1 ? path : path.slice(separator + 1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `python3.12` and `ruby2.7` name the same languages as `python` and `ruby`. */
|
|
108
|
+
function stripVersion(interpreter: string): string {
|
|
109
|
+
return interpreter.replace(/[\d.]+$/u, "");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Read the language a `#!` line declares, e.g. `#!/usr/bin/env -S python3 -u`. */
|
|
113
|
+
export function languageFromShebang(line: string): string | undefined {
|
|
114
|
+
const shebang = line.match(/^#!\s*(\S+)((?:\s+\S+)*)$/u);
|
|
115
|
+
if (!shebang) return undefined;
|
|
116
|
+
const command = basename(shebang[1]!).toLowerCase();
|
|
117
|
+
if (command !== "env") return INTERPRETER_LANGUAGES[stripVersion(command)];
|
|
118
|
+
// `env` forwards to the first argument that is not one of its own flags.
|
|
119
|
+
for (const argument of shebang[2]!.trim().split(/\s+/u)) {
|
|
120
|
+
if (!argument || argument.startsWith("-") || argument.includes("=")) continue;
|
|
121
|
+
return INTERPRETER_LANGUAGES[stripVersion(basename(argument).toLowerCase())];
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Resolve a language from the path alone, without looking at file contents. */
|
|
127
|
+
export function languageFromPath(path: string): string | undefined {
|
|
128
|
+
const fromPi = getLanguageFromPath(path);
|
|
129
|
+
if (fromPi) return fromPi;
|
|
130
|
+
const name = basename(path).toLowerCase();
|
|
131
|
+
const byName = FILENAME_LANGUAGES[name];
|
|
132
|
+
if (byName) return byName;
|
|
133
|
+
// `.env`, `.env.local`, and `.env.production` all hold the same KEY=value shape.
|
|
134
|
+
if (name === ".env" || name.startsWith(".env.")) return "ini";
|
|
135
|
+
const extension = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : undefined;
|
|
136
|
+
return extension ? EXTENSION_LANGUAGES[extension] : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve a language for a file view. `firstLine` is only consulted when it is genuinely
|
|
141
|
+
* the file's first line, so a `read` starting at an offset never mistakes a comment for
|
|
142
|
+
* a shebang.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveLanguage(path: string, firstLine?: string): string | undefined {
|
|
145
|
+
return languageFromPath(path) ?? (firstLine === undefined ? undefined : languageFromShebang(firstLine));
|
|
146
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Component } from "@earendil-works/pi-tui";
|
|
3
3
|
import {
|
|
4
4
|
Container,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "@earendil-works/pi-tui";
|
|
10
10
|
import { diffTintRgb, fillRgb } from "./compact-tools-color.ts";
|
|
11
11
|
import { normalizeLineEndings } from "./compact-tools-core.ts";
|
|
12
|
+
import { resolveLanguage } from "./compact-tools-language.ts";
|
|
12
13
|
import { highlightMarkdown } from "./compact-tools-markdown.ts";
|
|
13
14
|
import type { ToolArgs } from "./compact-tools-types.ts";
|
|
14
15
|
|
|
@@ -304,7 +305,11 @@ type CodeRowsOptions = {
|
|
|
304
305
|
|
|
305
306
|
/** Render numbered, syntax-highlighted code rows shared by edit diffs and read/write file views. */
|
|
306
307
|
function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, options: CodeRowsOptions): Component {
|
|
307
|
-
|
|
308
|
+
// A shebang only speaks for the file when the first row really is line 1, so a
|
|
309
|
+
// read at an offset or a diff hunk never reads one out of a comment.
|
|
310
|
+
const first = lines[0];
|
|
311
|
+
const shebangLine = first?.lineNumber === 1 && first.kind !== "separator" ? first.content : undefined;
|
|
312
|
+
const highlighted = highlightCodeLines(lines, resolveLanguage(path, shebangLine), theme);
|
|
308
313
|
const numberWidth = Math.max(1, ...lines.map((line) => String(line.lineNumber ?? "").length));
|
|
309
314
|
const prefix = theme.fg("border", " \u2502 ");
|
|
310
315
|
const prefixWidth = visibleWidth(prefix);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { languageFromPath } from "./compact-tools-language.ts";
|
|
2
3
|
|
|
3
4
|
type Fence = { char: string; length: number; language: string | undefined; start: number };
|
|
4
5
|
|
|
@@ -14,7 +15,7 @@ const INLINE = /(`+)(.+?)\1|\[([^\]\n]+)\]\(([^)\s]+)\)|\*\*(?=\S)(.+?)\*\*|__(?
|
|
|
14
15
|
function fenceLanguage(info: string): string | undefined {
|
|
15
16
|
const name = info.toLowerCase();
|
|
16
17
|
if (!name) return undefined;
|
|
17
|
-
return
|
|
18
|
+
return languageFromPath(`fence.${name}`) ?? name;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
function highlightInline(text: string, theme: Theme): string {
|
|
@@ -80,24 +80,34 @@ export class ProgressController {
|
|
|
80
80
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
81
81
|
|
|
82
82
|
constructor(pi: ExtensionAPI) {
|
|
83
|
-
|
|
83
|
+
// Every handler leaves early while unbound. Pi keeps dispatching to this
|
|
84
|
+
// extension in non-TUI modes, and guarding at the top keeps that work — and
|
|
85
|
+
// any future handler added here — out of the detached path.
|
|
86
|
+
pi.on("agent_start", () => {
|
|
87
|
+
if (!this.context) return;
|
|
88
|
+
this.setMessage("Thinking…");
|
|
89
|
+
});
|
|
84
90
|
pi.on("message_update", (event) => {
|
|
91
|
+
if (!this.context) return;
|
|
85
92
|
const type = event.assistantMessageEvent.type;
|
|
86
93
|
if (type === "thinking_start" || type === "thinking_delta") this.setMessage("Thinking…");
|
|
87
94
|
else if (type === "text_start" || type === "text_delta") this.setMessage("Responding…");
|
|
88
95
|
else if (type === "toolcall_start" || type === "toolcall_delta") this.setMessage("Preparing tools…");
|
|
89
96
|
});
|
|
90
97
|
pi.on("tool_execution_start", (event) => {
|
|
98
|
+
if (!this.context) return;
|
|
91
99
|
const message = formatToolProgress(event.toolName, event.args ?? {});
|
|
92
100
|
this.activeTools.set(event.toolCallId, message);
|
|
93
101
|
this.setMessage(message);
|
|
94
102
|
});
|
|
95
103
|
pi.on("tool_execution_end", (event) => {
|
|
104
|
+
if (!this.context) return;
|
|
96
105
|
this.activeTools.delete(event.toolCallId);
|
|
97
106
|
const remaining = [...this.activeTools.values()].at(-1);
|
|
98
107
|
this.setMessage(remaining ?? "Processing results…");
|
|
99
108
|
});
|
|
100
109
|
pi.on("agent_end", () => {
|
|
110
|
+
if (!this.context) return;
|
|
101
111
|
this.activeTools.clear();
|
|
102
112
|
this.clearMessage();
|
|
103
113
|
});
|
|
@@ -356,6 +356,14 @@ function configure(pi: ExtensionAPI, cwd?: string, projectTrusted = false): void
|
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
export default function compactTools(pi: ExtensionAPI): void {
|
|
359
|
+
// Pi caches this factory and re-invokes it with a fresh `pi` for every session
|
|
360
|
+
// replacement (/resume, /new, /fork) as well as /reload, each time starting from
|
|
361
|
+
// an empty tool registry. The module itself survives those invocations, so the
|
|
362
|
+
// memo below has to be dropped here or configure() mistakes the previous
|
|
363
|
+
// instance's registration for this one and never installs the renderers.
|
|
364
|
+
registeredConfiguration = undefined;
|
|
365
|
+
registeredTools.clear();
|
|
366
|
+
|
|
359
367
|
const thinkingCycle = new ThinkingCycleController(pi);
|
|
360
368
|
const progress = new ProgressController(pi);
|
|
361
369
|
// Register once while the extension runtime is being built. In particular, this
|
|
@@ -376,8 +384,5 @@ export default function compactTools(pi: ExtensionAPI): void {
|
|
|
376
384
|
thinkingCycle.dispose();
|
|
377
385
|
progress.dispose();
|
|
378
386
|
runtime.reset(event.reason !== "reload");
|
|
379
|
-
// Some Pi hosts rebuild the active tool registry during reload while keeping
|
|
380
|
-
// this module instance. Force the next session to restore our renderers.
|
|
381
|
-
if (event.reason === "reload") registeredConfiguration = undefined;
|
|
382
387
|
});
|
|
383
388
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-compact-tools",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Compact, expandable UI for pi's built-in tools, bundled with a polished GitHub Dark theme.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,15 +42,15 @@
|
|
|
42
42
|
"themes": [
|
|
43
43
|
"./themes/github-dark-pro.json"
|
|
44
44
|
],
|
|
45
|
-
"image": "https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/
|
|
45
|
+
"image": "https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/compact-workflow.gif"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@earendil-works/pi-coding-agent": "
|
|
49
|
-
"@earendil-works/pi-tui": "
|
|
48
|
+
"@earendil-works/pi-coding-agent": ">=0.85.1",
|
|
49
|
+
"@earendil-works/pi-tui": ">=0.85.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
53
|
-
"@earendil-works/pi-tui": "0.
|
|
52
|
+
"@earendil-works/pi-coding-agent": "0.86.0",
|
|
53
|
+
"@earendil-works/pi-tui": "0.86.0",
|
|
54
54
|
"@types/node": "^24.0.0",
|
|
55
55
|
"tsx": "^4.23.13",
|
|
56
56
|
"typescript": "^5.9.0"
|