pi-compact-tools 0.8.1 → 0.9.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.
package/README.md CHANGED
@@ -2,17 +2,18 @@
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
 
13
13
  - Compact rows for `read`, `write`, `edit`, `bash`, `powershell`, `grep`, `find`, and `ls`, with status, duration, and line count
14
14
  - Syntax-highlighted edit diffs and numbered `read`/`write` code views, including Markdown code fences
15
- - Animated working label above the prompt and a `Ctrl+T` thinking summary/detail cycle
15
+ - Animated working label above the prompt, lit in the session's own thinking-level color, and a `Ctrl+T` thinking summary/detail cycle
16
+ - Theme-agnostic chrome: rails, the tool indicator, and the working label are derived from the active theme and stay legible on light and dark backgrounds
16
17
  - No Nerd Font required
17
18
 
18
19
  ## Install
@@ -25,22 +25,42 @@ 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
 
41
54
  export function themeColorRgb(theme: Theme, color: ThemeForeground): Rgb | undefined {
42
55
  if (typeof theme.getFgAnsi !== "function") return undefined;
43
- const ansi = theme.getFgAnsi(color);
56
+ // Pi throws on a color it does not know, and callers reach this with names computed from
57
+ // host state — a thinking level Pi adds later would otherwise take down a render.
58
+ let ansi: string;
59
+ try {
60
+ ansi = theme.getFgAnsi(color);
61
+ } catch {
62
+ return undefined;
63
+ }
44
64
  const trueColor = ansi.match(/\[38;2;(\d+);(\d+);(\d+)m/u);
45
65
  if (trueColor) return { r: Number(trueColor[1]), g: Number(trueColor[2]), b: Number(trueColor[3]) };
46
66
  const indexed = ansi.match(/\[38;5;(\d+)m/u);
@@ -57,17 +77,78 @@ export function interpolateRgb(from: Rgb, to: Rgb, amount: number): Rgb {
57
77
  };
58
78
  }
59
79
 
60
- export function colorizeRgb(theme: Theme, color: Rgb, text: string): string {
80
+ /** Shared by the foreground (38/39) and background (48/49) SGR pairs. */
81
+ function paintRgb(theme: Theme, color: Rgb, text: string, set: 38 | 48): string {
82
+ const reset = set === 38 ? 39 : 49;
61
83
  if (typeof theme.getColorMode === "function" && theme.getColorMode() === "256color") {
62
- return `\x1b[38;5;${rgbToAnsi256(color)}m${text}\x1b[39m`;
84
+ return `\x1b[${set};5;${rgbToAnsi256(color)}m${text}\x1b[${reset}m`;
63
85
  }
64
- return `\x1b[38;2;${color.r};${color.g};${color.b}m${text}\x1b[39m`;
86
+ return `\x1b[${set};2;${color.r};${color.g};${color.b}m${text}\x1b[${reset}m`;
87
+ }
88
+
89
+ export function colorizeRgb(theme: Theme, color: Rgb, text: string): string {
90
+ return paintRgb(theme, color, text, 38);
91
+ }
92
+
93
+ /** Perceived brightness on the same 0-255 scale as the channels, and linear under interpolation. */
94
+ function luminanceRgb(rgb: Rgb): number {
95
+ return rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114;
65
96
  }
66
97
 
67
98
  function isDarkTheme(theme: Theme): boolean {
68
99
  const text = themeColorRgb(theme, "text");
69
100
  if (!text) return true;
70
- return text.r * 0.299 + text.g * 0.587 + text.b * 0.114 > 128;
101
+ return luminanceRgb(text) > 128;
102
+ }
103
+
104
+ /** Themes never expose their terminal background, so the poles stand in for it. */
105
+ const BLACK: Rgb = { r: 0, g: 0, b: 0 };
106
+ const WHITE: Rgb = { r: 255, g: 255, b: 255 };
107
+
108
+ /** Mix a color toward its own gray so a vivid theme hue cannot turn chrome into a highlight. */
109
+ export function neutralizeRgb(rgb: Rgb, keepHue: number): Rgb {
110
+ const gray = Math.round(luminanceRgb(rgb));
111
+ return interpolateRgb({ r: gray, g: gray, b: gray }, rgb, keepHue);
112
+ }
113
+
114
+ /**
115
+ * Reach a luminance by scaling every channel, which moves the color along its own ray from
116
+ * black and so leaves hue and saturation untouched — the same color, brighter or darker.
117
+ * Scaling up stops where the first channel would clip; past that only white can add light,
118
+ * and the color washes out the way a real highlight does.
119
+ */
120
+ function atBrightness(rgb: Rgb, target: number): Rgb {
121
+ const brightness = luminanceRgb(rgb);
122
+ if (brightness <= 0) return interpolateRgb(BLACK, WHITE, target / 255);
123
+ const headroom = 255 / Math.max(rgb.r, rgb.g, rgb.b, 1);
124
+ const scale = Math.min(target / brightness, headroom);
125
+ const scaled: Rgb = { r: rgb.r * scale, g: rgb.g * scale, b: rgb.b * scale };
126
+ const reached = luminanceRgb(scaled);
127
+ const rounded: Rgb = { r: Math.round(scaled.r), g: Math.round(scaled.g), b: Math.round(scaled.b) };
128
+ return reached >= target - 0.5 ? rounded : interpolateRgb(rounded, WHITE, (target - reached) / (255 - reached));
129
+ }
130
+
131
+ /** Lift a color part of the way to maximum contrast, keeping its hue for as long as it can. */
132
+ export function liftContrast(theme: Theme, rgb: Rgb, amount: number): Rgb {
133
+ const dark = isDarkTheme(theme);
134
+ const brightness = luminanceRgb(rgb) / 255;
135
+ const level = dark ? brightness : 1 - brightness;
136
+ const target = level + (1 - level) * amount;
137
+ return atBrightness(rgb, (dark ? target : 1 - target) * 255);
138
+ }
139
+
140
+ /**
141
+ * Move a color until how far it stands off the theme background — 0 for invisible, 1 for
142
+ * the opposite pole — lands inside the band. A color already inside is returned untouched,
143
+ * so a theme's own choice survives wherever it is legible to begin with.
144
+ */
145
+ export function withContrast(theme: Theme, rgb: Rgb, minimum: number, maximum: number): Rgb {
146
+ const dark = isDarkTheme(theme);
147
+ const brightness = luminanceRgb(rgb) / 255;
148
+ const level = dark ? brightness : 1 - brightness;
149
+ const target = level > maximum ? maximum : level < minimum ? minimum : level;
150
+ if (target === level) return rgb;
151
+ return atBrightness(rgb, (dark ? target : 1 - target) * 255);
71
152
  }
72
153
 
73
154
  /**
@@ -77,13 +158,10 @@ function isDarkTheme(theme: Theme): boolean {
77
158
  export function diffTintRgb(theme: Theme, color: ThemeForeground, amount = 0.16): Rgb | undefined {
78
159
  const rgb = themeColorRgb(theme, color);
79
160
  if (!rgb) return undefined;
80
- const base: Rgb = isDarkTheme(theme) ? { r: 0, g: 0, b: 0 } : { r: 255, g: 255, b: 255 };
161
+ const base = isDarkTheme(theme) ? BLACK : WHITE;
81
162
  return interpolateRgb(base, rgb, amount);
82
163
  }
83
164
 
84
165
  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`;
166
+ return paintRgb(theme, color, text, 48);
89
167
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  export type RowStatus = "pending" | "running" | "success" | "error";
4
4
 
5
- export type IndicatorTone = "borderAccent" | "border" | "borderMuted" | "success" | "error";
5
+ export type IndicatorTone = "muted" | "dim" | "borderMuted" | "success" | "error";
6
6
 
7
7
  export const RUNNING_INDICATOR_FRAME_COUNT = 14;
8
8
  const RUNNING_INDICATOR_MIN_STRENGTH = 0.08;
@@ -19,9 +19,9 @@ export function indicatorStrength(status: RowStatus, frame = 0): number {
19
19
  export function indicatorTone(status: RowStatus, frame = 0): IndicatorTone {
20
20
  if (status === "success") return "success";
21
21
  if (status === "error") return "error";
22
- if (status === "pending") return "borderAccent";
22
+ if (status === "pending") return "muted";
23
23
  const strength = indicatorStrength(status, frame);
24
- return strength >= 0.7 ? "borderAccent" : strength >= 0.35 ? "border" : "borderMuted";
24
+ return strength >= 0.7 ? "muted" : strength >= 0.35 ? "dim" : "borderMuted";
25
25
  }
26
26
 
27
27
  export function indicatorGlyph(_status: RowStatus, _frame = 0): string {
@@ -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,7 +9,9 @@ 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";
14
+ import { paintChrome } from "./compact-tools-palette.ts";
13
15
  import type { ToolArgs } from "./compact-tools-types.ts";
14
16
 
15
17
  class CachedComponent implements Component {
@@ -111,7 +113,7 @@ export function hardWrapTextWithAnsi(text: string, width: number): string[] {
111
113
  export function renderToolCall(title: string, details: string | undefined, theme: Theme): Component {
112
114
  const text = details ? `${title} ${details}` : title;
113
115
  const firstPrefix = " ";
114
- const continuationPrefix = theme.fg("border", " │ ");
116
+ const continuationPrefix = paintChrome(theme, " │ ");
115
117
  const prefixWidth = visibleWidth(continuationPrefix);
116
118
  return new CachedComponent((width) => {
117
119
  const lines = hardWrapTextWithAnsi(text, width - prefixWidth);
@@ -128,7 +130,7 @@ export function renderOutput(output: string, theme: Theme, isError: boolean): Co
128
130
  if (!normalized) return undefined;
129
131
  const color = isError ? "error" : "toolOutput";
130
132
  const styled = normalized.split("\n").map((line) => theme.fg(color, line)).join("\n");
131
- return prefixedText(styled, theme.fg("border", " │ "));
133
+ return prefixedText(styled, paintChrome(theme, " │ "));
132
134
  }
133
135
 
134
136
  export type CodeDiffLineKind = "context" | "add" | "remove" | "separator";
@@ -304,9 +306,13 @@ type CodeRowsOptions = {
304
306
 
305
307
  /** Render numbered, syntax-highlighted code rows shared by edit diffs and read/write file views. */
306
308
  function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, options: CodeRowsOptions): Component {
307
- const highlighted = highlightCodeLines(lines, getLanguageFromPath(path), theme);
309
+ // A shebang only speaks for the file when the first row really is line 1, so a
310
+ // read at an offset or a diff hunk never reads one out of a comment.
311
+ const first = lines[0];
312
+ const shebangLine = first?.lineNumber === 1 && first.kind !== "separator" ? first.content : undefined;
313
+ const highlighted = highlightCodeLines(lines, resolveLanguage(path, shebangLine), theme);
308
314
  const numberWidth = Math.max(1, ...lines.map((line) => String(line.lineNumber ?? "").length));
309
- const prefix = theme.fg("border", " \u2502 ");
315
+ const prefix = paintChrome(theme, " \u2502 ");
310
316
  const prefixWidth = visibleWidth(prefix);
311
317
  const gutterWidth = numberWidth + (options.signs ? 3 : 2);
312
318
  const addedTint = options.signs ? diffTintRgb(theme, "toolDiffAdded") : undefined;
@@ -387,7 +393,8 @@ export function limitComponentLines(component: Component, maximumLines: number,
387
393
  const omitted = lines.length - maximumLines;
388
394
  return [
389
395
  ...lines.slice(0, maximumLines),
390
- theme.fg("border", " │ ") + theme.fg("borderAccent", `… ${omitted} more ${omitted === 1 ? "line" : "lines"}`),
396
+ paintChrome(theme, " │ ")
397
+ + theme.fg("toolOutput", `… ${omitted} more ${omitted === 1 ? "line" : "lines"}`),
391
398
  ];
392
399
  }, () => component.invalidate?.());
393
400
  }
@@ -395,5 +402,5 @@ export function limitComponentLines(component: Component, maximumLines: number,
395
402
  export function renderArguments(args: ToolArgs, theme: Theme): Component {
396
403
  const json = JSON.stringify(args, null, 2) ?? "{}";
397
404
  const styled = styleMultiline(json, (line) => theme.fg("toolOutput", line));
398
- return prefixedText(styled, theme.fg("border", " │ "));
405
+ return prefixedText(styled, paintChrome(theme, " │ "));
399
406
  }
@@ -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 {
@@ -0,0 +1,117 @@
1
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ colorizeRgb,
4
+ liftContrast,
5
+ neutralizeRgb,
6
+ themeColorRgb,
7
+ withContrast,
8
+ type Rgb,
9
+ } from "./compact-tools-color.ts";
10
+
11
+ /**
12
+ * Colors this extension draws with, derived from the active theme rather than named on it.
13
+ *
14
+ * Naming a color does not travel. `borderAccent` is a quiet gray in github-dark-pro and
15
+ * electric cyan in Pi's own dark theme; `dim` is the dimmest name a theme offers yet is
16
+ * still sized for body text, a step louder than a rail should be; and a hard-coded white
17
+ * highlight disappears entirely on a light background. So each value here takes a theme
18
+ * color for its hue and then places it: chrome and the indicator give up most of their
19
+ * saturation and are pulled into a fixed contrast band, while the working label keeps its
20
+ * hue exactly and is moved only in brightness. Everything is measured against the theme
21
+ * background, so the same code reads the same way on light and dark alike: against a light
22
+ * background the moves invert, and standing out means going darker.
23
+ */
24
+
25
+ type ThemeForeground = Parameters<Theme["getFgAnsi"]>[0];
26
+
27
+ type ContrastBand = { color: ThemeForeground; keepHue: number; minimum: number; maximum: number };
28
+
29
+ /** A band with no color attached, for a derivation that supplies its own source. */
30
+ type ContrastRange = { minimum: number; maximum: number };
31
+
32
+ /**
33
+ * One tone for the whole frame — rails, the result connector, and the status line it leads
34
+ * to — so the frame reads as a single quiet object rather than competing with the output.
35
+ */
36
+ const CHROME: ContrastBand = { color: "dim", keepHue: 0.35, minimum: 0.22, maximum: 0.28 };
37
+ /** The indicator breathes either side of the chrome tone, dipping below it and peaking above. */
38
+ const PULSE_LOW: ContrastBand = { color: "dim", keepHue: 0.4, minimum: 0.18, maximum: 0.3 };
39
+ const PULSE_HIGH: ContrastBand = { color: "muted", keepHue: 0.4, minimum: 0.4, maximum: 0.6 };
40
+ /**
41
+ * The working label is painted in the session's own thinking level, the piece of state the
42
+ * row is already reporting on. Themes size those colors for a one-word status chip, so the
43
+ * label takes a brighter shade of the same color and the sweep lifts it again: the row
44
+ * carries the level's hue at a weight that reads across the width of a sentence, and the
45
+ * highlight is that color catching light rather than white laid over it. Both lifts are a
46
+ * share of the light left between the color and full contrast, so a level the theme
47
+ * already paints brightly moves least.
48
+ */
49
+ const GLOW_REST_LIFT = 0.3;
50
+ const GLOW_CREST_LIFT = 0.35;
51
+ /** Themes paint the lowest levels at rail weight; a floor keeps the label off the background. */
52
+ const GLOW_FLOOR: ContrastRange = { minimum: 0.4, maximum: 1 };
53
+
54
+ export type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
55
+
56
+ const THINKING_LEVEL_COLOR = {
57
+ off: "thinkingOff",
58
+ minimal: "thinkingMinimal",
59
+ low: "thinkingLow",
60
+ medium: "thinkingMedium",
61
+ high: "thinkingHigh",
62
+ xhigh: "thinkingXhigh",
63
+ max: "thinkingMax",
64
+ } as const satisfies Record<ThinkingLevel, ThemeForeground>;
65
+
66
+ /** The theme color a thinking level is painted with. */
67
+ function thinkingLevelColor(level: ThinkingLevel): ThemeForeground {
68
+ return THINKING_LEVEL_COLOR[level] ?? "thinkingMedium";
69
+ }
70
+
71
+ /**
72
+ * Derivation is deliberately not cached against the Theme: `/theme` can swap a palette behind
73
+ * the same instance, and a stale entry would outlive the switch. Callers that redraw per line
74
+ * hold the derived value themselves instead.
75
+ */
76
+ function bandedRgb(theme: Theme, band: ContrastBand): Rgb | undefined {
77
+ const rgb = themeColorRgb(theme, band.color);
78
+ if (!rgb) return undefined;
79
+ return withContrast(theme, neutralizeRgb(rgb, band.keepHue), band.minimum, band.maximum);
80
+ }
81
+
82
+ /**
83
+ * A chrome painter bound to one theme. Deriving the tone costs a parse and a dozen floating
84
+ * point operations, and the frame is drawn once per line of tool output or thinking detail,
85
+ * so callers in a loop take a painter once and callers with a single rail use `paintChrome`.
86
+ */
87
+ export function chromePainter(theme: Theme): (text: string) => string {
88
+ const chrome = bandedRgb(theme, CHROME);
89
+ return chrome ? (text) => colorizeRgb(theme, chrome, text) : (text) => theme.fg("dim", text);
90
+ }
91
+
92
+ /** Paint one rail, connector, or status line in the chrome tone. */
93
+ export function paintChrome(theme: Theme, text: string): string {
94
+ return chromePainter(theme)(text);
95
+ }
96
+
97
+ export type ColorRamp = { from: Rgb; to: Rgb };
98
+
99
+ function ramp(theme: Theme, low: ContrastBand, high: ContrastBand): ColorRamp | undefined {
100
+ const from = bandedRgb(theme, low);
101
+ const to = bandedRgb(theme, high);
102
+ return from && to ? { from, to } : undefined;
103
+ }
104
+
105
+ /** Fade endpoints for the running tool indicator, or undefined when the theme resolves no RGB. */
106
+ export function indicatorPulse(theme: Theme): ColorRamp | undefined {
107
+ return ramp(theme, PULSE_LOW, PULSE_HIGH);
108
+ }
109
+
110
+ /** Sweep endpoints for the working label at a thinking level, resting color to crest. */
111
+ export function progressGlow(theme: Theme, level: ThinkingLevel): ColorRamp | undefined {
112
+ const raw = themeColorRgb(theme, thinkingLevelColor(level));
113
+ if (!raw) return undefined;
114
+ const lifted = liftContrast(theme, raw, GLOW_REST_LIFT);
115
+ const from = withContrast(theme, lifted, GLOW_FLOOR.minimum, GLOW_FLOOR.maximum);
116
+ return { from, to: liftContrast(theme, from, GLOW_CREST_LIFT) };
117
+ }
@@ -1,10 +1,10 @@
1
1
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
3
- import { colorizeRgb, interpolateRgb, themeColorRgb, type Rgb } from "./compact-tools-color.ts";
3
+ import { colorizeRgb, interpolateRgb } from "./compact-tools-color.ts";
4
+ import { progressGlow, type ColorRamp, type ThinkingLevel } from "./compact-tools-palette.ts";
4
5
  import type { ToolArgs } from "./compact-tools-types.ts";
5
6
 
6
7
  const GLOW_INTERVAL_MS = 80;
7
- const WHITE: Rgb = { r: 255, g: 255, b: 255 };
8
8
 
9
9
  const TOOL_PROGRESS_MESSAGES: Readonly<Record<string, string>> = {
10
10
  read: "Reading file…",
@@ -45,29 +45,41 @@ function glowRadius(length: number): number {
45
45
  return Math.max(2, Math.min(8, Math.ceil(length / 4)));
46
46
  }
47
47
 
48
- /** Render a thinking-summary-colored label with a proportional white highlight sweeping left to right. */
49
- export function glowProgressMessage(message: string, frame: number, theme: Theme): string {
48
+ /** Render the working label with a proportional highlight sweeping left to right. */
49
+ export function glowProgressMessage(
50
+ message: string,
51
+ frame: number,
52
+ theme: Theme,
53
+ level: ThinkingLevel,
54
+ ): string {
55
+ return paintGlowFrame(message, frame, theme, progressGlow(theme, level));
56
+ }
57
+
58
+ function paintGlowFrame(message: string, frame: number, theme: Theme, glow: ColorRamp | undefined): string {
50
59
  const characters = [...message];
51
60
  if (characters.length === 0) return "";
52
61
  const radius = glowRadius(characters.length);
53
62
  const center = frame % (characters.length + radius * 2) - radius;
54
- const base = themeColorRgb(theme, "thinkingMax");
55
- if (!base) {
63
+ // Reached only when the level's color did not resolve, so the sweep falls back to names
64
+ // every theme carries rather than to the one that just failed.
65
+ if (!glow) {
56
66
  return characters.map((character, index) =>
57
- theme.fg(Math.abs(index - center) < radius ? "mdHeading" : "thinkingMax", character)).join("");
67
+ theme.fg(Math.abs(index - center) < radius ? "text" : "muted", character)).join("");
58
68
  }
59
69
  return characters.map((character, index) => {
60
70
  const distance = Math.abs(index - center);
61
71
  const strength = Math.max(0, 1 - distance / radius);
62
- return colorizeRgb(theme, interpolateRgb(base, WHITE, strength), character);
72
+ return colorizeRgb(theme, interpolateRgb(glow.from, glow.to, strength), character);
63
73
  }).join("");
64
74
  }
65
75
 
66
- function createGlowFrames(message: string, theme: Theme): string[] {
76
+ function createGlowFrames(message: string, theme: Theme, level: ThinkingLevel): string[] {
67
77
  const length = [...message].length;
68
78
  if (length === 0) return [""];
79
+ // One ramp for the whole cycle; only the sweep position changes between frames.
80
+ const glow = progressGlow(theme, level);
69
81
  return Array.from({ length: length + glowRadius(length) * 2 }, (_, frame) =>
70
- glowProgressMessage(message, frame, theme));
82
+ paintGlowFrame(message, frame, theme, glow));
71
83
  }
72
84
 
73
85
  /** Owns Pi's single working row so animation remains continuous across thinking and tool execution. */
@@ -78,34 +90,58 @@ export class ProgressController {
78
90
  private frames: string[] = [];
79
91
  private frame = 0;
80
92
  private timer: ReturnType<typeof setInterval> | undefined;
93
+ private level: ThinkingLevel = "medium";
81
94
 
82
- constructor(pi: ExtensionAPI) {
83
- pi.on("agent_start", () => this.setMessage("Thinking…"));
95
+ constructor(private readonly pi: ExtensionAPI) {
96
+ // Every handler leaves early while unbound. Pi keeps dispatching to this
97
+ // extension in non-TUI modes, and guarding at the top keeps that work — and
98
+ // any future handler added here — out of the detached path.
99
+ pi.on("agent_start", () => {
100
+ if (!this.context) return;
101
+ this.setMessage("Thinking…");
102
+ });
84
103
  pi.on("message_update", (event) => {
104
+ if (!this.context) return;
85
105
  const type = event.assistantMessageEvent.type;
86
106
  if (type === "thinking_start" || type === "thinking_delta") this.setMessage("Thinking…");
87
107
  else if (type === "text_start" || type === "text_delta") this.setMessage("Responding…");
88
108
  else if (type === "toolcall_start" || type === "toolcall_delta") this.setMessage("Preparing tools…");
89
109
  });
90
110
  pi.on("tool_execution_start", (event) => {
111
+ if (!this.context) return;
91
112
  const message = formatToolProgress(event.toolName, event.args ?? {});
92
113
  this.activeTools.set(event.toolCallId, message);
93
114
  this.setMessage(message);
94
115
  });
95
116
  pi.on("tool_execution_end", (event) => {
117
+ if (!this.context) return;
96
118
  this.activeTools.delete(event.toolCallId);
97
119
  const remaining = [...this.activeTools.values()].at(-1);
98
120
  this.setMessage(remaining ?? "Processing results…");
99
121
  });
100
122
  pi.on("agent_end", () => {
123
+ if (!this.context) return;
101
124
  this.activeTools.clear();
102
125
  this.clearMessage();
103
126
  });
127
+ pi.on("thinking_level_select", (event) => {
128
+ if (!this.context || event.level === this.level) return;
129
+ this.level = event.level;
130
+ // Recolor in place: the label and its animation phase are unchanged.
131
+ this.frames = createGlowFrames(this.message ?? "", this.context.ui.theme, this.level);
132
+ this.renderMessage();
133
+ });
134
+ }
135
+
136
+ /** Guarded, like the Theme accessors, so a host supplying a partial API cannot break binding. */
137
+ private readLevel(): void {
138
+ if (typeof this.pi.getThinkingLevel === "function") this.level = this.pi.getThinkingLevel();
104
139
  }
105
140
 
106
141
  bind(ctx: ExtensionContext): void {
107
142
  this.stopAnimation();
108
143
  this.context = ctx;
144
+ this.readLevel();
109
145
  this.activeTools.clear();
110
146
  this.message = undefined;
111
147
  this.frames = [];
@@ -127,7 +163,7 @@ export class ProgressController {
127
163
  private setMessage(message: string): void {
128
164
  if (!this.context || (this.message === message && this.timer)) return;
129
165
  this.message = message;
130
- this.frames = createGlowFrames(message, this.context.ui.theme);
166
+ this.frames = createGlowFrames(message, this.context.ui.theme, this.level);
131
167
  this.frame = 0;
132
168
  this.renderMessage();
133
169
  if (this.timer) return;
@@ -10,6 +10,7 @@ import {
10
10
  truncateToWidth,
11
11
  wrapTextWithAnsi,
12
12
  } from "@earendil-works/pi-tui";
13
+ import { chromePainter } from "./compact-tools-palette.ts";
13
14
 
14
15
  export type ThinkingView = "summary" | "detail" | "hidden";
15
16
  export type ThinkingPhase = "summary" | "detail" | "summary-after-detail" | "hidden";
@@ -213,21 +214,27 @@ export class ThinkingCycleController {
213
214
  this.hasToggleableThinking ||= sectionsHaveDetail(sections);
214
215
  const theme = this.theme;
215
216
  const detailTextPrefix = theme?.getFgAnsi("thinkingText");
217
+ // Derived once per transform: the rail is drawn for every wrapped line of detail.
218
+ const rail = theme ? chromePainter(theme) : undefined;
216
219
  return renderThinkingSections(sections, thinkingViewForPhase(this.phase), context.availableWidth, {
217
- styleSummary: theme ? (summary) => theme.fg("thinkingMax", theme.bold(summary)) : undefined,
218
- // border styling resets the foreground; resume detail gray immediately.
219
- styleDetailPrefix: theme && detailTextPrefix
220
- ? (prefix) => theme.fg("border", prefix) + detailTextPrefix
220
+ // Keep thinking chrome neutral across themes: effort-level and border colors can
221
+ // be intentionally vivid and are not content hierarchy colors.
222
+ styleSummary: theme ? (summary) => theme.fg("text", theme.bold(summary)) : undefined,
223
+ // Prefix styling resets the foreground; resume the host's thinking text color.
224
+ // The rail and connector are the same frame the tool rows draw, so they take
225
+ // the same chrome tone rather than a color name of their own.
226
+ styleDetailPrefix: rail && detailTextPrefix
227
+ ? (prefix) => rail(prefix) + detailTextPrefix
221
228
  : undefined,
222
229
  controls: theme
223
230
  ? (hasDetail) => hasDetail
224
231
  ? theme.fg(
225
- "borderAccent",
232
+ "dim",
226
233
  `${theme.italic("ctrl+t")} toggle • ${theme.italic("click")} to hide`,
227
234
  )
228
235
  : undefined
229
236
  : undefined,
230
- styleControlPrefix: theme ? (prefix) => theme.fg("border", prefix) : undefined,
237
+ styleControlPrefix: rail,
231
238
  });
232
239
  });
233
240
  }
@@ -238,10 +245,10 @@ export class ThinkingCycleController {
238
245
  this.hasToggleableThinking = false;
239
246
  // Match Pi's persisted host visibility after startup or /reload. When the
240
247
  // host is hidden, Markdown transformers are not invoked at all. Pi wraps
241
- // this label in thinkingText, so an inner thinkingMax span is required to
242
- // keep it consistent with completed summary titles.
248
+ // this label in thinkingText, so an inner text span keeps it consistent with
249
+ // completed summary titles.
243
250
  this.phase = getInitialThinkingPhase();
244
- const hiddenLabel = this.theme.fg("thinkingMax", this.theme.bold("Thinking..."));
251
+ const hiddenLabel = this.theme.fg("text", this.theme.bold("Thinking..."));
245
252
  // Pi does not currently expose transcript invalidation directly. Updating
246
253
  // this label rebuilds assistant Markdown and requests a render without
247
254
  // changing the message or its hidden/visible state.
@@ -27,7 +27,7 @@ import {
27
27
  normalizeLineEndings,
28
28
  type RowStatus,
29
29
  } from "./compact-tools-core.ts";
30
- import { colorizeRgb, interpolateRgb, themeColorRgb } from "./compact-tools-color.ts";
30
+ import { colorizeRgb, interpolateRgb } from "./compact-tools-color.ts";
31
31
  import { loadConfig } from "./compact-tools-config.ts";
32
32
  import {
33
33
  formatResultLineSummary,
@@ -52,6 +52,7 @@ import {
52
52
  renderToolCall,
53
53
  styleMultiline,
54
54
  } from "./compact-tools-layout.ts";
55
+ import { chromePainter, indicatorPulse } from "./compact-tools-palette.ts";
55
56
  import { ProgressController } from "./compact-tools-progress.ts";
56
57
  import { ToolRuntime } from "./compact-tools-runtime.ts";
57
58
  import { ThinkingCycleController } from "./compact-tools-thinking.ts";
@@ -83,10 +84,9 @@ function renderIndicator(theme: Theme, ctx: RenderContext, status: RowStatus): s
83
84
  const frame = runtime.syncIndicator(ctx.toolCallId, status === "running", () => ctx.invalidate());
84
85
  const glyph = indicatorGlyph(status, frame);
85
86
  if (status === "running") {
86
- const bright = themeColorRgb(theme, "borderAccent");
87
- const dim = themeColorRgb(theme, "borderMuted");
88
- if (bright && dim) {
89
- return colorizeRgb(theme, interpolateRgb(dim, bright, indicatorStrength(status, frame)), glyph);
87
+ const pulse = indicatorPulse(theme);
88
+ if (pulse) {
89
+ return colorizeRgb(theme, interpolateRgb(pulse.from, pulse.to, indicatorStrength(status, frame)), glyph);
90
90
  }
91
91
  }
92
92
  return theme.fg(indicatorTone(status, frame), glyph);
@@ -139,9 +139,12 @@ function renderControls(
139
139
  const status = running
140
140
  ? (duration ?? "Running")
141
141
  : `${isError ? "Failed" : "Done"}${duration ? ` in ${duration}` : ""}`;
142
- let details = theme.fg("borderAccent", status);
143
- if (lineSummary && !running) details += theme.fg("borderAccent", ` (${lineSummary})`);
144
- return prefixedText(details, theme.fg("border", " └─ "), " ");
142
+ // A failed row already reads as an error through its output color and Pi's row
143
+ // background, so the status word stays chrome rather than repeating that signal.
144
+ const chrome = chromePainter(theme);
145
+ let details = chrome(status);
146
+ if (lineSummary && !running) details += chrome(` (${lineSummary})`);
147
+ return prefixedText(details, chrome(" └─ "), " ");
145
148
  }
146
149
 
147
150
  function renderCallTitle(name: string, theme: Theme, ctx: RenderContext, state: RowState): string {
@@ -356,6 +359,14 @@ function configure(pi: ExtensionAPI, cwd?: string, projectTrusted = false): void
356
359
  }
357
360
 
358
361
  export default function compactTools(pi: ExtensionAPI): void {
362
+ // Pi caches this factory and re-invokes it with a fresh `pi` for every session
363
+ // replacement (/resume, /new, /fork) as well as /reload, each time starting from
364
+ // an empty tool registry. The module itself survives those invocations, so the
365
+ // memo below has to be dropped here or configure() mistakes the previous
366
+ // instance's registration for this one and never installs the renderers.
367
+ registeredConfiguration = undefined;
368
+ registeredTools.clear();
369
+
359
370
  const thinkingCycle = new ThinkingCycleController(pi);
360
371
  const progress = new ProgressController(pi);
361
372
  // Register once while the extension runtime is being built. In particular, this
@@ -376,8 +387,5 @@ export default function compactTools(pi: ExtensionAPI): void {
376
387
  thinkingCycle.dispose();
377
388
  progress.dispose();
378
389
  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
390
  });
383
391
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-compact-tools",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
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"
@@ -41,7 +41,7 @@
41
41
  "selectedBg": "surfaceMuted",
42
42
  "searchMatchBg": "#3B2E10",
43
43
  "searchMatchText": "textBright",
44
- "userMessageBg": "#16181C",
44
+ "userMessageBg": "#0E1014",
45
45
  "userMessageText": "textBright",
46
46
  "customMessageBg": "",
47
47
  "customMessageText": "text",