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 +5 -4
- package/extensions/compact-tools-color.ts +89 -11
- package/extensions/compact-tools-core.ts +14 -4
- package/extensions/compact-tools-language.ts +146 -0
- package/extensions/compact-tools-layout.ts +14 -7
- package/extensions/compact-tools-markdown.ts +3 -2
- package/extensions/compact-tools-palette.ts +117 -0
- package/extensions/compact-tools-progress.ts +49 -13
- package/extensions/compact-tools-thinking.ts +16 -9
- package/extensions/compact-tools.ts +19 -11
- package/package.json +6 -6
- package/themes/github-dark-pro.json +1 -1
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
|
-

|
|
6
6
|
|
|
7
|
-

|
|
8
8
|
|
|
9
|
-

|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
84
|
+
return `\x1b[${set};5;${rgbToAnsi256(color)}m${text}\x1b[${reset}m`;
|
|
63
85
|
}
|
|
64
|
-
return `\x1b[
|
|
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
|
|
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
|
|
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
|
-
|
|
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 = "
|
|
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 "
|
|
22
|
+
if (status === "pending") return "muted";
|
|
23
23
|
const strength = indicatorStrength(status, frame);
|
|
24
|
-
return strength >= 0.7 ? "
|
|
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
|
-
|
|
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,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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
405
|
+
return prefixedText(styled, paintChrome(theme, " │ "));
|
|
399
406
|
}
|
|
@@ -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 {
|
|
@@ -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
|
|
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
|
|
49
|
-
export function glowProgressMessage(
|
|
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
|
-
|
|
55
|
-
|
|
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 ? "
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
"
|
|
232
|
+
"dim",
|
|
226
233
|
`${theme.italic("ctrl+t")} toggle • ${theme.italic("click")} to hide`,
|
|
227
234
|
)
|
|
228
235
|
: undefined
|
|
229
236
|
: undefined,
|
|
230
|
-
styleControlPrefix:
|
|
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
|
|
242
|
-
//
|
|
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("
|
|
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
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
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/
|
|
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"
|