pi-compact-tools 0.8.3 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/extensions/compact-tools-color.ts +66 -4
- package/extensions/compact-tools-core.ts +3 -3
- package/extensions/compact-tools-layout.ts +7 -5
- package/extensions/compact-tools-palette.ts +117 -0
- package/extensions/compact-tools-progress.ts +38 -12
- package/extensions/compact-tools-thinking.ts +16 -9
- package/extensions/compact-tools.ts +11 -8
- package/package.json +1 -1
- package/themes/github-dark-pro.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,8 @@ Compact, expandable rendering for Pi's built-in tools, with a GitHub Dark theme.
|
|
|
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
|
|
@@ -53,7 +53,14 @@ function rgbToAnsi256(rgb: Rgb): number {
|
|
|
53
53
|
|
|
54
54
|
export function themeColorRgb(theme: Theme, color: ThemeForeground): Rgb | undefined {
|
|
55
55
|
if (typeof theme.getFgAnsi !== "function") return undefined;
|
|
56
|
-
|
|
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
|
+
}
|
|
57
64
|
const trueColor = ansi.match(/\[38;2;(\d+);(\d+);(\d+)m/u);
|
|
58
65
|
if (trueColor) return { r: Number(trueColor[1]), g: Number(trueColor[2]), b: Number(trueColor[3]) };
|
|
59
66
|
const indexed = ansi.match(/\[38;5;(\d+)m/u);
|
|
@@ -83,20 +90,75 @@ export function colorizeRgb(theme: Theme, color: Rgb, text: string): string {
|
|
|
83
90
|
return paintRgb(theme, color, text, 38);
|
|
84
91
|
}
|
|
85
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;
|
|
96
|
+
}
|
|
97
|
+
|
|
86
98
|
function isDarkTheme(theme: Theme): boolean {
|
|
87
99
|
const text = themeColorRgb(theme, "text");
|
|
88
100
|
if (!text) return true;
|
|
89
|
-
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);
|
|
90
152
|
}
|
|
91
153
|
|
|
92
154
|
/**
|
|
93
155
|
* Diff rows need a tint that reads on the terminal background without hijacking `toolSuccessBg`
|
|
94
156
|
* and `toolErrorBg`, which Pi paints across the whole tool row.
|
|
95
157
|
*/
|
|
96
|
-
export function diffTintRgb(theme: Theme, color: ThemeForeground, amount = 0.
|
|
158
|
+
export function diffTintRgb(theme: Theme, color: ThemeForeground, amount = 0.20): Rgb | undefined {
|
|
97
159
|
const rgb = themeColorRgb(theme, color);
|
|
98
160
|
if (!rgb) return undefined;
|
|
99
|
-
const base
|
|
161
|
+
const base = isDarkTheme(theme) ? BLACK : WHITE;
|
|
100
162
|
return interpolateRgb(base, rgb, amount);
|
|
101
163
|
}
|
|
102
164
|
|
|
@@ -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 {
|
|
@@ -11,6 +11,7 @@ import { diffTintRgb, fillRgb } from "./compact-tools-color.ts";
|
|
|
11
11
|
import { normalizeLineEndings } from "./compact-tools-core.ts";
|
|
12
12
|
import { resolveLanguage } from "./compact-tools-language.ts";
|
|
13
13
|
import { highlightMarkdown } from "./compact-tools-markdown.ts";
|
|
14
|
+
import { paintChrome } from "./compact-tools-palette.ts";
|
|
14
15
|
import type { ToolArgs } from "./compact-tools-types.ts";
|
|
15
16
|
|
|
16
17
|
class CachedComponent implements Component {
|
|
@@ -112,7 +113,7 @@ export function hardWrapTextWithAnsi(text: string, width: number): string[] {
|
|
|
112
113
|
export function renderToolCall(title: string, details: string | undefined, theme: Theme): Component {
|
|
113
114
|
const text = details ? `${title} ${details}` : title;
|
|
114
115
|
const firstPrefix = " ";
|
|
115
|
-
const continuationPrefix = theme
|
|
116
|
+
const continuationPrefix = paintChrome(theme, " │ ");
|
|
116
117
|
const prefixWidth = visibleWidth(continuationPrefix);
|
|
117
118
|
return new CachedComponent((width) => {
|
|
118
119
|
const lines = hardWrapTextWithAnsi(text, width - prefixWidth);
|
|
@@ -129,7 +130,7 @@ export function renderOutput(output: string, theme: Theme, isError: boolean): Co
|
|
|
129
130
|
if (!normalized) return undefined;
|
|
130
131
|
const color = isError ? "error" : "toolOutput";
|
|
131
132
|
const styled = normalized.split("\n").map((line) => theme.fg(color, line)).join("\n");
|
|
132
|
-
return prefixedText(styled, theme
|
|
133
|
+
return prefixedText(styled, paintChrome(theme, " │ "));
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
export type CodeDiffLineKind = "context" | "add" | "remove" | "separator";
|
|
@@ -311,7 +312,7 @@ function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, optio
|
|
|
311
312
|
const shebangLine = first?.lineNumber === 1 && first.kind !== "separator" ? first.content : undefined;
|
|
312
313
|
const highlighted = highlightCodeLines(lines, resolveLanguage(path, shebangLine), theme);
|
|
313
314
|
const numberWidth = Math.max(1, ...lines.map((line) => String(line.lineNumber ?? "").length));
|
|
314
|
-
const prefix = theme
|
|
315
|
+
const prefix = paintChrome(theme, " \u2502 ");
|
|
315
316
|
const prefixWidth = visibleWidth(prefix);
|
|
316
317
|
const gutterWidth = numberWidth + (options.signs ? 3 : 2);
|
|
317
318
|
const addedTint = options.signs ? diffTintRgb(theme, "toolDiffAdded") : undefined;
|
|
@@ -392,7 +393,8 @@ export function limitComponentLines(component: Component, maximumLines: number,
|
|
|
392
393
|
const omitted = lines.length - maximumLines;
|
|
393
394
|
return [
|
|
394
395
|
...lines.slice(0, maximumLines),
|
|
395
|
-
theme
|
|
396
|
+
paintChrome(theme, " │ ")
|
|
397
|
+
+ theme.fg("toolOutput", `… ${omitted} more ${omitted === 1 ? "line" : "lines"}`),
|
|
396
398
|
];
|
|
397
399
|
}, () => component.invalidate?.());
|
|
398
400
|
}
|
|
@@ -400,5 +402,5 @@ export function limitComponentLines(component: Component, maximumLines: number,
|
|
|
400
402
|
export function renderArguments(args: ToolArgs, theme: Theme): Component {
|
|
401
403
|
const json = JSON.stringify(args, null, 2) ?? "{}";
|
|
402
404
|
const styled = styleMultiline(json, (line) => theme.fg("toolOutput", line));
|
|
403
|
-
return prefixedText(styled, theme
|
|
405
|
+
return prefixedText(styled, paintChrome(theme, " │ "));
|
|
404
406
|
}
|
|
@@ -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.72;
|
|
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,8 +90,9 @@ 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) {
|
|
95
|
+
constructor(private readonly pi: ExtensionAPI) {
|
|
83
96
|
// Every handler leaves early while unbound. Pi keeps dispatching to this
|
|
84
97
|
// extension in non-TUI modes, and guarding at the top keeps that work — and
|
|
85
98
|
// any future handler added here — out of the detached path.
|
|
@@ -111,11 +124,24 @@ export class ProgressController {
|
|
|
111
124
|
this.activeTools.clear();
|
|
112
125
|
this.clearMessage();
|
|
113
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();
|
|
114
139
|
}
|
|
115
140
|
|
|
116
141
|
bind(ctx: ExtensionContext): void {
|
|
117
142
|
this.stopAnimation();
|
|
118
143
|
this.context = ctx;
|
|
144
|
+
this.readLevel();
|
|
119
145
|
this.activeTools.clear();
|
|
120
146
|
this.message = undefined;
|
|
121
147
|
this.frames = [];
|
|
@@ -137,7 +163,7 @@ export class ProgressController {
|
|
|
137
163
|
private setMessage(message: string): void {
|
|
138
164
|
if (!this.context || (this.message === message && this.timer)) return;
|
|
139
165
|
this.message = message;
|
|
140
|
-
this.frames = createGlowFrames(message, this.context.ui.theme);
|
|
166
|
+
this.frames = createGlowFrames(message, this.context.ui.theme, this.level);
|
|
141
167
|
this.frame = 0;
|
|
142
168
|
this.renderMessage();
|
|
143
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 {
|
package/package.json
CHANGED