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 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
- ![Compact tool workflow](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/nerd-font-workflow-optimized.gif)
5
+ ![Compact rows for every built-in tool](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/compact-workflow.gif)
6
6
 
7
- ![Spinner and elapsed-time display](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/spinner-duration-optimized.gif)
7
+ ![Syntax-highlighted edit diff](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/edit-diff.gif)
8
8
 
9
- ![Edit diff](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/edit-diff.gif)
9
+ ![Numbered read code view](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/code-aware-results.gif)
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 = ansi256ToRgb(index);
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
- export function colorizeRgb(theme: Theme, color: Rgb, text: string): string {
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[38;5;${rgbToAnsi256(color)}m${text}\x1b[39m`;
77
+ return `\x1b[${set};5;${rgbToAnsi256(color)}m${text}\x1b[${reset}m`;
63
78
  }
64
- return `\x1b[38;2;${color.r};${color.g};${color.b}m${text}\x1b[39m`;
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
- if (typeof theme.getColorMode === "function" && theme.getColorMode() === "256color") {
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
- return `${(elapsedMs / 1000).toFixed(3)}s`;
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 { getLanguageFromPath, highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
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
- const highlighted = highlightCodeLines(lines, getLanguageFromPath(path), theme);
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 { getLanguageFromPath, highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
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 getLanguageFromPath(`fence.${name}`) ?? name;
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
- pi.on("agent_start", () => this.setMessage("Thinking…"));
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.1",
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/nerd-font-workflow-optimized.gif"
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.85.1",
53
- "@earendil-works/pi-tui": "0.85.1",
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"