pi-compact-tools 0.5.9 → 0.6.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/LICENSE +21 -21
- package/README.md +22 -26
- package/examples/compact-tools-2.json +1 -7
- package/examples/compact-tools.json +1 -7
- package/extensions/compact-tools-config.ts +1 -19
- package/extensions/compact-tools-core.ts +1 -73
- package/extensions/compact-tools-layout.ts +66 -31
- package/extensions/compact-tools-thinking.ts +292 -0
- package/extensions/compact-tools-types.ts +0 -2
- package/extensions/compact-tools.ts +11 -36
- package/package.json +64 -64
- package/schemas/compact-tools.schema.json +0 -38
- package/scripts/check-resources.mjs +10 -21
- package/themes/github-dark-pro.json +4 -4
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Dong Hyeong Lee
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dong Hyeong Lee
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Compact, expandable rendering for Pi's built-in tools, with a polished GitHub Da
|
|
|
6
6
|
|
|
7
7
|
## Demo
|
|
8
8
|
|
|
9
|
-
The recordings below use the optional [Nerd Font](#nerd-font-preset) preset. **The default configuration does not require a Nerd Font
|
|
9
|
+
The recordings below use the optional [Nerd Font](#nerd-font-spinner-preset) spinner preset. **The default configuration does not require a Nerd Font**.
|
|
10
10
|
|
|
11
11
|
### Compact tool workflow
|
|
12
12
|
|
|
@@ -14,11 +14,11 @@ Active, successful, and failed tool calls stay compact while preserving useful s
|
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
17
|
-
### Spinner and duration
|
|
17
|
+
### Spinner and duration display
|
|
18
18
|
|
|
19
|
-
The spinner animates during execution, then
|
|
19
|
+
The spinner animates during execution, then returns to a green success circle or red failure circle. Elapsed time remains visible in the neutral control color.
|
|
20
20
|
|
|
21
|
-

|
|
22
22
|
|
|
23
23
|
### Initially visible edit diffs
|
|
24
24
|
|
|
@@ -30,11 +30,13 @@ Completed `edit` rows show their full diff initially by default. The per-tool `a
|
|
|
30
30
|
|
|
31
31
|
- Compact rendering for `read`, `write`, `edit`, `bash`, `powershell`, `grep`, `find`, and `ls`
|
|
32
32
|
- Animated configurable spinner in fullscreen TUI mode
|
|
33
|
-
-
|
|
33
|
+
- Three-state `Ctrl+T` thinking view: summary, detail, then hidden
|
|
34
|
+
- Millisecond-precision duration with neutral status text
|
|
34
35
|
- Per-tool initial compaction and global-toggle participation with `auto_compact: true` or `false`
|
|
35
36
|
- Tool invocation details are always fully visible
|
|
36
37
|
- Click toggles one result between hidden and fully visible; `Ctrl+O` toggles all participating rows
|
|
37
38
|
- Full output on expansion and initially visible edit diffs by default
|
|
39
|
+
- Width-cached expanded results and edit-diff processing for responsive fullscreen scrolling
|
|
38
40
|
- Dark `│` and `└─` visual grouping
|
|
39
41
|
- Distinct cool-blue thinking text
|
|
40
42
|
- Windows, Unix, and classic Mac line-ending support
|
|
@@ -90,19 +92,13 @@ Default configuration:
|
|
|
90
92
|
"spinner": {
|
|
91
93
|
"frames": ["◐", "◓", "◑", "◒"],
|
|
92
94
|
"intervalMs": 120
|
|
93
|
-
}
|
|
94
|
-
"durationIndicators": [
|
|
95
|
-
{ "underMs": 1000, "icon": "⚡️", "color": "warning" },
|
|
96
|
-
{ "underMs": 10000, "icon": "🔥", "color": "#D95C3F" },
|
|
97
|
-
{ "underMs": 30000, "icon": "○", "color": "#79C0FF" },
|
|
98
|
-
{ "icon": "⏳", "color": "#D2A8FF" }
|
|
99
|
-
]
|
|
95
|
+
}
|
|
100
96
|
}
|
|
101
97
|
```
|
|
102
98
|
|
|
103
|
-
|
|
99
|
+
Successful call titles use a green `●`, while failed call titles use a red `●`. `Done`, `Failed`, and elapsed time use the same neutral control color. Fast tools finish immediately; no artificial spinner delay is added.
|
|
104
100
|
|
|
105
|
-
For a minimal blinking spinner
|
|
101
|
+
For a minimal blinking spinner, copy [`examples/compact-tools-2.json`](examples/compact-tools-2.json). Its frames use `● ● (blank) ● ●` at 120 ms intervals. The renderer's built-in frame dimming turns this into a fade-out/fade-in animation. Rename it to `compact-tools.json` or copy its contents to the active configuration path.
|
|
106
102
|
|
|
107
103
|
`auto_compact` controls each tool's initial state and whether it participates in the global `Ctrl+O` toggle:
|
|
108
104
|
|
|
@@ -112,8 +108,6 @@ For a minimal blinking spinner that reuses the successful-tool circle, copy [`ex
|
|
|
112
108
|
|
|
113
109
|
Unspecified entries inherit the previous configuration layer. By default, `read`, `write`, and `bash` start compact and participate in the global toggle, while `edit` starts expanded and remains unaffected so code diffs stay visible.
|
|
114
110
|
|
|
115
|
-
`durationIndicators` must have ascending `underMs` values, with a final fallback entry that omits `underMs`. `color` is optional and accepts a supported theme color or six-digit hex value. Existing icon-only configurations remain compatible.
|
|
116
|
-
|
|
117
111
|
To enable all Unix-compatible tools:
|
|
118
112
|
|
|
119
113
|
```json
|
|
@@ -124,9 +118,9 @@ To enable all Unix-compatible tools:
|
|
|
124
118
|
|
|
125
119
|
Add `"powershell"` on Windows if desired.
|
|
126
120
|
|
|
127
|
-
## Nerd Font preset
|
|
121
|
+
## Nerd Font spinner preset
|
|
128
122
|
|
|
129
|
-
If your terminal uses a [Nerd Font](https://www.nerdfonts.com/), this preset enables all eight tools and the
|
|
123
|
+
If your terminal uses a [Nerd Font](https://www.nerdfonts.com/), this preset enables all eight tools and the spinner shown in the demos:
|
|
130
124
|
|
|
131
125
|
```json
|
|
132
126
|
{
|
|
@@ -135,20 +129,22 @@ If your terminal uses a [Nerd Font](https://www.nerdfonts.com/), this preset ena
|
|
|
135
129
|
"spinner": {
|
|
136
130
|
"frames": ["✽", "✻", "✲", "✢", "✲", "✻"],
|
|
137
131
|
"intervalMs": 80
|
|
138
|
-
}
|
|
139
|
-
"durationIndicators": [
|
|
140
|
-
{ "underMs": 1000, "icon": "\uf0e7", "color": "warning" },
|
|
141
|
-
{ "underMs": 10000, "icon": "\uf490", "color": "#D95C3F" },
|
|
142
|
-
{ "underMs": 30000, "icon": "\udb81\udde3", "color": "#79C0FF" },
|
|
143
|
-
{ "icon": "\udb81\udd1f", "color": "#D2A8FF" }
|
|
144
|
-
]
|
|
132
|
+
}
|
|
145
133
|
}
|
|
146
134
|
```
|
|
147
135
|
|
|
148
|
-
|
|
136
|
+
Remove `"powershell"` on systems where it is unavailable.
|
|
149
137
|
|
|
150
138
|
## Controls
|
|
151
139
|
|
|
140
|
+
`Ctrl+T` cycles every thinking block through:
|
|
141
|
+
|
|
142
|
+
```text
|
|
143
|
+
one-line summary + controls → summary + │-indented detail + controls → Thinking...
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Standalone bold lines and Markdown headings split one provider thinking run into logical sections. Each section gets its own summary, optional `│`-indented detail, and control row; `Ctrl+T` cycles all sections together. While thinking streams, a text-color highlight sweeps across summaries using streaming updates; no spinner glyph or title circle is shown. The transformation is display-only; complete thinking remains unchanged in the session and model context.
|
|
147
|
+
|
|
152
148
|
Relevant invocation details stay visible, including paths, patterns, shell commands, and auxiliary arguments. Read calls omit offset and limit noise, then report the number of lines read after completion. Large payloads represented as results, such as write content and edit diffs, follow the result toggle.
|
|
153
149
|
|
|
154
150
|
A mouse click toggles one row's result:
|
|
@@ -10,11 +10,5 @@
|
|
|
10
10
|
"spinner": {
|
|
11
11
|
"frames": ["●", "●", " ", "●", "●"],
|
|
12
12
|
"intervalMs": 120
|
|
13
|
-
}
|
|
14
|
-
"durationIndicators": [
|
|
15
|
-
{ "underMs": 1000, "icon": "•", "color": "#E0A052" },
|
|
16
|
-
{ "underMs": 10000, "icon": "•", "color": "#D95C3F" },
|
|
17
|
-
{ "underMs": 30000, "icon": "•", "color": "#79C0FF" },
|
|
18
|
-
{ "icon": "•", "color": "#D2A8FF" }
|
|
19
|
-
]
|
|
13
|
+
}
|
|
20
14
|
}
|
|
@@ -10,11 +10,5 @@
|
|
|
10
10
|
"spinner": {
|
|
11
11
|
"frames": ["◐", "◓", "◑", "◒"],
|
|
12
12
|
"intervalMs": 120
|
|
13
|
-
}
|
|
14
|
-
"durationIndicators": [
|
|
15
|
-
{ "underMs": 1000, "icon": "⚡️", "color": "warning" },
|
|
16
|
-
{ "underMs": 10000, "icon": "🔥", "color": "#D95C3F" },
|
|
17
|
-
{ "underMs": 30000, "icon": "○", "color": "#79C0FF" },
|
|
18
|
-
{ "icon": "⏳", "color": "#D2A8FF" }
|
|
19
|
-
]
|
|
13
|
+
}
|
|
20
14
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { parseDurationIndicators } from "./compact-tools-core.ts";
|
|
5
4
|
import {
|
|
6
5
|
SUPPORTED_TOOL_SET,
|
|
7
6
|
type CompactToolName,
|
|
@@ -27,12 +26,6 @@ export const DEFAULT_CONFIG: CompactToolsConfig = {
|
|
|
27
26
|
frames: ["◐", "◓", "◑", "◒"],
|
|
28
27
|
intervalMs: 120,
|
|
29
28
|
},
|
|
30
|
-
durationIndicators: [
|
|
31
|
-
{ underMs: 1_000, icon: "⚡️", color: "warning" },
|
|
32
|
-
{ underMs: 10_000, icon: "🔥", color: "#D95C3F" },
|
|
33
|
-
{ underMs: 30_000, icon: "○", color: "#79C0FF" },
|
|
34
|
-
{ icon: "⏳", color: "#D2A8FF" },
|
|
35
|
-
],
|
|
36
29
|
};
|
|
37
30
|
|
|
38
31
|
function isObject(value: unknown): value is JsonObject {
|
|
@@ -120,18 +113,7 @@ export function mergeConfig(base: CompactToolsConfig, value: unknown, path: stri
|
|
|
120
113
|
const tools = value.tools === undefined ? base.tools : (parseTools(value.tools, path) ?? base.tools);
|
|
121
114
|
const auto_compact = parseAutoCompact(base.auto_compact, value.auto_compact, path);
|
|
122
115
|
const spinner = parseSpinner(base.spinner, value.spinner, path);
|
|
123
|
-
|
|
124
|
-
? undefined
|
|
125
|
-
: parseDurationIndicators(value.durationIndicators);
|
|
126
|
-
if (value.durationIndicators !== undefined && !parsedIndicators) {
|
|
127
|
-
warn(path, "invalid durationIndicators; using previous values");
|
|
128
|
-
}
|
|
129
|
-
return {
|
|
130
|
-
tools,
|
|
131
|
-
auto_compact,
|
|
132
|
-
spinner,
|
|
133
|
-
durationIndicators: parsedIndicators ?? base.durationIndicators,
|
|
134
|
-
};
|
|
116
|
+
return { tools, auto_compact, spinner };
|
|
135
117
|
}
|
|
136
118
|
|
|
137
119
|
function readJson(path: string): unknown {
|
|
@@ -1,58 +1,11 @@
|
|
|
1
|
-
/** Pure
|
|
1
|
+
/** Pure timing and text helpers for compact tool rendering. */
|
|
2
2
|
|
|
3
|
-
const DURATION_INDICATOR_COLORS = [
|
|
4
|
-
"accent", "border", "borderAccent", "borderMuted", "success", "error", "warning", "muted", "dim", "text",
|
|
5
|
-
"thinkingText", "toolTitle", "toolOutput", "toolDiffAdded", "toolDiffRemoved", "toolDiffContext", "bashMode",
|
|
6
|
-
] as const;
|
|
7
|
-
|
|
8
|
-
export type ThemeDurationIndicatorColor = (typeof DURATION_INDICATOR_COLORS)[number];
|
|
9
|
-
export type DurationIndicatorColor = ThemeDurationIndicatorColor | `#${string}`;
|
|
10
|
-
export type DurationIndicatorConfig = {
|
|
11
|
-
underMs?: number;
|
|
12
|
-
icon: string;
|
|
13
|
-
color?: DurationIndicatorColor;
|
|
14
|
-
};
|
|
15
3
|
export type RowStatus = "pending" | "running" | "success" | "error";
|
|
16
|
-
const DURATION_INDICATOR_COLOR_SET = new Set<string>(DURATION_INDICATOR_COLORS);
|
|
17
|
-
export const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
|
18
|
-
|
|
19
|
-
type JsonObject = Record<string, unknown>;
|
|
20
|
-
|
|
21
|
-
function isObject(value: unknown): value is JsonObject {
|
|
22
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function isPositiveInteger(value: unknown, minimum: number): value is number {
|
|
26
|
-
return typeof value === "number" && Number.isInteger(value) && value >= minimum;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function parseDurationIndicators(value: unknown): DurationIndicatorConfig[] | undefined {
|
|
30
|
-
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
31
|
-
const rules: DurationIndicatorConfig[] = [];
|
|
32
|
-
let previousLimit = 0;
|
|
33
|
-
for (const [index, item] of value.entries()) {
|
|
34
|
-
if (!isObject(item) || typeof item.icon !== "string" || item.icon.length === 0) return undefined;
|
|
35
|
-
if (
|
|
36
|
-
item.color !== undefined &&
|
|
37
|
-
(typeof item.color !== "string" ||
|
|
38
|
-
(!DURATION_INDICATOR_COLOR_SET.has(item.color) && !HEX_COLOR_PATTERN.test(item.color)))
|
|
39
|
-
) return undefined;
|
|
40
|
-
const color = item.color as DurationIndicatorColor | undefined;
|
|
41
|
-
const isLast = index === value.length - 1;
|
|
42
|
-
if (isLast && item.underMs === undefined) rules.push({ icon: item.icon, color });
|
|
43
|
-
else if (isPositiveInteger(item.underMs, previousLimit + 1)) {
|
|
44
|
-
previousLimit = item.underMs;
|
|
45
|
-
rules.push({ underMs: item.underMs, icon: item.icon, color });
|
|
46
|
-
} else return undefined;
|
|
47
|
-
}
|
|
48
|
-
return rules.at(-1)?.underMs === undefined ? rules : undefined;
|
|
49
|
-
}
|
|
50
4
|
|
|
51
5
|
export function normalizeLineEndings(value: string): string {
|
|
52
6
|
return value.replace(/\r\n?|\n/g, "\n");
|
|
53
7
|
}
|
|
54
8
|
|
|
55
|
-
|
|
56
9
|
export function classifyCallStatus(isError: boolean, executionStarted: boolean, completed: boolean): RowStatus {
|
|
57
10
|
if (isError) return "error";
|
|
58
11
|
if (completed) return "success";
|
|
@@ -62,28 +15,3 @@ export function classifyCallStatus(isError: boolean, executionStarted: boolean,
|
|
|
62
15
|
export function formatDurationMs(elapsedMs: number): string {
|
|
63
16
|
return `${(elapsedMs / 1000).toFixed(3)}s`;
|
|
64
17
|
}
|
|
65
|
-
|
|
66
|
-
export function selectDurationIndicator(
|
|
67
|
-
rules: readonly DurationIndicatorConfig[],
|
|
68
|
-
elapsedMs: number | undefined,
|
|
69
|
-
): DurationIndicatorConfig | undefined {
|
|
70
|
-
if (elapsedMs === undefined) return rules[0];
|
|
71
|
-
return rules.find((rule) => rule.underMs === undefined || elapsedMs < rule.underMs);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function colorDistanceSquared(red: number, green: number, blue: number, candidate: readonly number[]): number {
|
|
75
|
-
return (red - candidate[0]!) ** 2 + (green - candidate[1]!) ** 2 + (blue - candidate[2]!) ** 2;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function rgbToAnsi256(red: number, green: number, blue: number): number {
|
|
79
|
-
const cube = [red, green, blue].map((channel) => Math.round(channel / 51));
|
|
80
|
-
const cubeRgb = cube.map((channel) => channel * 51);
|
|
81
|
-
const cubeIndex = 16 + 36 * cube[0]! + 6 * cube[1]! + cube[2]!;
|
|
82
|
-
const average = (red + green + blue) / 3;
|
|
83
|
-
const grayStep = Math.max(0, Math.min(23, Math.round((average - 8) / 10)));
|
|
84
|
-
const grayLevel = 8 + grayStep * 10;
|
|
85
|
-
const grayRgb = [grayLevel, grayLevel, grayLevel];
|
|
86
|
-
return colorDistanceSquared(red, green, blue, grayRgb) < colorDistanceSquared(red, green, blue, cubeRgb)
|
|
87
|
-
? 232 + grayStep
|
|
88
|
-
: cubeIndex;
|
|
89
|
-
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Component } from "@earendil-works/pi-tui";
|
|
3
3
|
import {
|
|
4
|
+
Container,
|
|
4
5
|
sliceByColumn,
|
|
5
6
|
stripTerminalSequences,
|
|
6
7
|
visibleWidth,
|
|
@@ -13,7 +14,10 @@ class CachedComponent implements Component {
|
|
|
13
14
|
private cachedWidth?: number;
|
|
14
15
|
private cachedLines?: string[];
|
|
15
16
|
|
|
16
|
-
constructor(
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly renderLines: (width: number) => string[],
|
|
19
|
+
private readonly invalidateSource?: () => void,
|
|
20
|
+
) {}
|
|
17
21
|
|
|
18
22
|
render(width: number): string[] {
|
|
19
23
|
if (this.cachedWidth !== width || !this.cachedLines) {
|
|
@@ -24,11 +28,51 @@ class CachedComponent implements Component {
|
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
invalidate(): void {
|
|
31
|
+
this.invalidateSource?.();
|
|
27
32
|
this.cachedWidth = undefined;
|
|
28
33
|
this.cachedLines = undefined;
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
/** A Container that does not re-copy all child lines on every fullscreen scroll frame. */
|
|
38
|
+
export class CachedContainer extends Container {
|
|
39
|
+
private cachedWidth?: number;
|
|
40
|
+
private cachedLines?: string[];
|
|
41
|
+
|
|
42
|
+
private clearRenderCache(): void {
|
|
43
|
+
this.cachedWidth = undefined;
|
|
44
|
+
this.cachedLines = undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
override addChild(component: Component): void {
|
|
48
|
+
super.addChild(component);
|
|
49
|
+
this.clearRenderCache();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
override removeChild(component: Component): void {
|
|
53
|
+
super.removeChild(component);
|
|
54
|
+
this.clearRenderCache();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
override clear(): void {
|
|
58
|
+
super.clear();
|
|
59
|
+
this.clearRenderCache();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
override render(width: number): string[] {
|
|
63
|
+
if (this.cachedWidth !== width || !this.cachedLines) {
|
|
64
|
+
this.cachedWidth = width;
|
|
65
|
+
this.cachedLines = super.render(width);
|
|
66
|
+
}
|
|
67
|
+
return this.cachedLines;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
override invalidate(): void {
|
|
71
|
+
super.invalidate();
|
|
72
|
+
this.clearRenderCache();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
32
76
|
export function prefixedText(text: string, firstPrefix: string, continuationPrefix = firstPrefix): Component {
|
|
33
77
|
const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix));
|
|
34
78
|
const normalized = text.replace(/\t/g, " ");
|
|
@@ -74,45 +118,36 @@ export function renderToolCall(title: string, details: string | undefined, theme
|
|
|
74
118
|
}
|
|
75
119
|
|
|
76
120
|
export function wrapEditResult(component: Component, theme: Theme): Component {
|
|
77
|
-
return {
|
|
78
|
-
render(width
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
},
|
|
97
|
-
};
|
|
121
|
+
return new CachedComponent((width) => {
|
|
122
|
+
const lines = component.render(Math.max(1, width - 3));
|
|
123
|
+
const first = lines.findIndex((line) => visibleWidth(line.trim()) > 0);
|
|
124
|
+
if (first < 0) return [];
|
|
125
|
+
const contentLines = lines.slice(first);
|
|
126
|
+
let commonIndent = Number.POSITIVE_INFINITY;
|
|
127
|
+
for (const line of contentLines) {
|
|
128
|
+
const plain = stripTerminalSequences(line);
|
|
129
|
+
if (plain.trim().length === 0) continue;
|
|
130
|
+
commonIndent = Math.min(commonIndent, plain.match(/^ */)?.[0].length ?? 0);
|
|
131
|
+
if (commonIndent === 0) break;
|
|
132
|
+
}
|
|
133
|
+
if (!Number.isFinite(commonIndent)) commonIndent = 0;
|
|
134
|
+
const prefix = theme.fg("border", " │ ");
|
|
135
|
+
return contentLines.map((line) => {
|
|
136
|
+
const content = sliceByColumn(line, commonIndent, Math.max(0, visibleWidth(line) - commonIndent), true);
|
|
137
|
+
return `${prefix}${content}`;
|
|
138
|
+
});
|
|
139
|
+
}, () => component.invalidate?.());
|
|
98
140
|
}
|
|
99
141
|
|
|
100
142
|
export function styleMultiline(text: string, style: (line: string) => string): string {
|
|
101
143
|
return normalizeLineEndings(text).split("\n").map(style).join("\n");
|
|
102
144
|
}
|
|
103
145
|
|
|
104
|
-
function styleOutputLine(text: string, theme: Theme, isError: boolean): string {
|
|
105
|
-
const color = isError ? "error" : "toolOutput";
|
|
106
|
-
return text
|
|
107
|
-
.split(/(⚡️?)/u)
|
|
108
|
-
.map((part) => theme.fg(!isError && part.startsWith("⚡") ? "warning" : color, part))
|
|
109
|
-
.join("");
|
|
110
|
-
}
|
|
111
|
-
|
|
112
146
|
export function renderOutput(output: string, theme: Theme, isError: boolean): Component | undefined {
|
|
113
147
|
const normalized = normalizeLineEndings(output).trimEnd();
|
|
114
148
|
if (!normalized) return undefined;
|
|
115
|
-
const
|
|
149
|
+
const color = isError ? "error" : "toolOutput";
|
|
150
|
+
const styled = normalized.split("\n").map((line) => theme.fg(color, line)).join("\n");
|
|
116
151
|
return prefixedText(styled, theme.fg("border", " │ "));
|
|
117
152
|
}
|
|
118
153
|
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { getAgentDir, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
isKeyRelease,
|
|
6
|
+
Key,
|
|
7
|
+
matchesKey,
|
|
8
|
+
stripTerminalSequences,
|
|
9
|
+
truncateToWidth,
|
|
10
|
+
wrapTextWithAnsi,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
|
|
13
|
+
export type ThinkingView = "summary" | "detail" | "hidden";
|
|
14
|
+
|
|
15
|
+
type ThinkingRenderOptions = {
|
|
16
|
+
styleSummary?: (summary: string, sectionIndex: number) => string;
|
|
17
|
+
styleDetailPrefix?: (prefix: string) => string;
|
|
18
|
+
controls?: string;
|
|
19
|
+
styleControlPrefix?: (prefix: string) => string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const NEXT_VIEW: Record<ThinkingView, ThinkingView> = {
|
|
23
|
+
summary: "detail",
|
|
24
|
+
detail: "hidden",
|
|
25
|
+
hidden: "summary",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function plainSummary(line: string): string {
|
|
29
|
+
return line
|
|
30
|
+
.replace(/^\s{0,3}#{1,6}\s+/u, "")
|
|
31
|
+
.replace(/^\s*[-*+]\s+/u, "")
|
|
32
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
|
|
33
|
+
.replace(/[*_~`]/gu, "")
|
|
34
|
+
.replace(/\s+/gu, " ")
|
|
35
|
+
.trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type ThinkingSection = { summary: string; detail: string };
|
|
39
|
+
|
|
40
|
+
function splitThinkingSections(markdown: string, includeDetail: boolean): ThinkingSection[] {
|
|
41
|
+
const normalized = markdown.includes("\r") ? markdown.replace(/\r\n?/gu, "\n") : markdown;
|
|
42
|
+
const lines = normalized.split("\n");
|
|
43
|
+
let first = -1;
|
|
44
|
+
let summary = "";
|
|
45
|
+
for (let index = 0; index < lines.length; index++) {
|
|
46
|
+
summary = plainSummary(lines[index]!);
|
|
47
|
+
if (summary) {
|
|
48
|
+
first = index;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (first < 0) return [{ summary: "Thinking...", detail: "" }];
|
|
53
|
+
const sections: ThinkingSection[] = [];
|
|
54
|
+
let detail: string[] = [];
|
|
55
|
+
let inFence = false;
|
|
56
|
+
const flush = () => {
|
|
57
|
+
sections.push({ summary, detail: includeDetail ? detail.join("\n").trim() : "" });
|
|
58
|
+
detail = [];
|
|
59
|
+
};
|
|
60
|
+
for (let index = first + 1; index < lines.length; index++) {
|
|
61
|
+
const line = lines[index]!;
|
|
62
|
+
const fence = /^\s*(```|~~~)/u.test(line);
|
|
63
|
+
if (!inFence) {
|
|
64
|
+
const boldHeading = line.match(/^\s*\*\*(.+?)\*\*\s*$/u)?.[1];
|
|
65
|
+
const markdownHeading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*$/u)?.[1];
|
|
66
|
+
const heading = boldHeading ?? markdownHeading;
|
|
67
|
+
if (heading) {
|
|
68
|
+
flush();
|
|
69
|
+
summary = plainSummary(heading);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (includeDetail) detail.push(line);
|
|
74
|
+
if (fence) inFence = !inFence;
|
|
75
|
+
}
|
|
76
|
+
flush();
|
|
77
|
+
return sections;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function compactDetail(
|
|
81
|
+
detail: string,
|
|
82
|
+
availableWidth: number,
|
|
83
|
+
stylePrefix: (prefix: string) => string,
|
|
84
|
+
): string {
|
|
85
|
+
const output: string[] = [];
|
|
86
|
+
let inFence = false;
|
|
87
|
+
for (const logicalLine of detail.replace(/\t/gu, " ").split("\n")) {
|
|
88
|
+
const fence = /^\s*(```|~~~)/u.test(logicalLine);
|
|
89
|
+
if (!inFence && fence) {
|
|
90
|
+
// Fences need a real blockquote and blank delimiter to remain valid Markdown.
|
|
91
|
+
output.push("", `> ${logicalLine}`);
|
|
92
|
+
inFence = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (inFence) {
|
|
96
|
+
output.push(logicalLine ? `> ${logicalLine}` : ">");
|
|
97
|
+
if (fence) {
|
|
98
|
+
inFence = false;
|
|
99
|
+
output.push("");
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!logicalLine) {
|
|
104
|
+
output.push(`${stylePrefix("│")} `);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const wrapped = wrapTextWithAnsi(logicalLine, Math.max(1, availableWidth - 2));
|
|
108
|
+
for (const line of wrapped) {
|
|
109
|
+
output.push(`${stylePrefix("│ ")}${stripTerminalSequences(line)} `);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return output.join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function hardBreak(lines: string[]): string {
|
|
116
|
+
return lines.map((line, index) => (index < lines.length - 1 ? `${line} ` : line)).join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Display-only transformation; the original thinking remains unchanged in session/model context. */
|
|
120
|
+
export function renderThinkingView(
|
|
121
|
+
markdown: string,
|
|
122
|
+
view: ThinkingView,
|
|
123
|
+
availableWidth: number,
|
|
124
|
+
options: ThinkingRenderOptions = {},
|
|
125
|
+
): string {
|
|
126
|
+
if (view === "hidden") return "Thinking...";
|
|
127
|
+
const controls = options.controls ?? "ctrl+t toggle • click to hide";
|
|
128
|
+
const styleControlPrefix = options.styleControlPrefix ?? ((prefix: string) => prefix);
|
|
129
|
+
return splitThinkingSections(markdown, view === "detail")
|
|
130
|
+
.map(({ summary, detail }, sectionIndex) => {
|
|
131
|
+
const fittedSummary = stripTerminalSequences(
|
|
132
|
+
truncateToWidth(summary, Math.max(1, availableWidth), "…"),
|
|
133
|
+
);
|
|
134
|
+
const title = options.styleSummary?.(fittedSummary, sectionIndex) ?? fittedSummary;
|
|
135
|
+
const controlLines = wrapTextWithAnsi(controls, Math.max(1, availableWidth - 3)).map(
|
|
136
|
+
(line, index) => `${index === 0 ? styleControlPrefix("└─ ") : " "}${line}`,
|
|
137
|
+
);
|
|
138
|
+
if (view === "detail" && detail) {
|
|
139
|
+
const styleDetailPrefix = options.styleDetailPrefix ?? ((prefix: string) => prefix);
|
|
140
|
+
const connector = `${styleDetailPrefix("│")} `;
|
|
141
|
+
return `${title} \n${connector}\n${compactDetail(detail, availableWidth, styleDetailPrefix)}\n${hardBreak(controlLines)}`;
|
|
142
|
+
}
|
|
143
|
+
return hardBreak([title, ...controlLines]);
|
|
144
|
+
})
|
|
145
|
+
.join("\n\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getInitialThinkingView(): ThinkingView {
|
|
149
|
+
try {
|
|
150
|
+
const settings = JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8")) as {
|
|
151
|
+
hideThinkingBlock?: unknown;
|
|
152
|
+
};
|
|
153
|
+
return settings.hideThinkingBlock === true ? "hidden" : "summary";
|
|
154
|
+
} catch {
|
|
155
|
+
return "summary";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isThinkingStreamEvent(eventType: string): boolean {
|
|
160
|
+
return eventType === "thinking_start" || eventType === "thinking_delta";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function shimmer(summary: string, theme: Theme, startedAt: number): string {
|
|
164
|
+
const characters = [...summary];
|
|
165
|
+
if (characters.length === 0) return summary;
|
|
166
|
+
const sweepTail = 5;
|
|
167
|
+
// Each logical section receives its own start time. The highlight therefore
|
|
168
|
+
// enters at its first character instead of inheriting another section's
|
|
169
|
+
// wall-clock phase.
|
|
170
|
+
const elapsed = Math.max(0, Date.now() - startedAt);
|
|
171
|
+
const center = Math.floor(elapsed / 80) % (characters.length + sweepTail);
|
|
172
|
+
let currentColor: Parameters<Theme["fg"]>[0] | undefined;
|
|
173
|
+
let chunk = "";
|
|
174
|
+
let output = "";
|
|
175
|
+
for (let index = 0; index < characters.length; index++) {
|
|
176
|
+
const distance = Math.abs(index - center);
|
|
177
|
+
// Stay entirely in the sky-blue family: the active baseline is blue,
|
|
178
|
+
// and the sweep fades through light blue into a near-white core.
|
|
179
|
+
const color = distance === 0
|
|
180
|
+
? "userMessageText"
|
|
181
|
+
: distance === 1
|
|
182
|
+
? "text"
|
|
183
|
+
: distance === 2
|
|
184
|
+
? "thinkingMax"
|
|
185
|
+
: "thinkingXhigh";
|
|
186
|
+
if (currentColor !== undefined && color !== currentColor) {
|
|
187
|
+
output += theme.fg(currentColor, theme.bold(chunk));
|
|
188
|
+
chunk = "";
|
|
189
|
+
}
|
|
190
|
+
currentColor = color;
|
|
191
|
+
chunk += characters[index];
|
|
192
|
+
}
|
|
193
|
+
return currentColor === undefined ? output : output + theme.fg(currentColor, theme.bold(chunk));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export class ThinkingCycleController {
|
|
197
|
+
private view: ThinkingView = "summary";
|
|
198
|
+
private unsubscribe: (() => void) | undefined;
|
|
199
|
+
private theme: Theme | undefined;
|
|
200
|
+
private thinkingStreamActive = false;
|
|
201
|
+
private readonly summarySweepStartedAt = new Map<number, number>();
|
|
202
|
+
|
|
203
|
+
constructor(pi: ExtensionAPI) {
|
|
204
|
+
pi.on("message_start", () => {
|
|
205
|
+
this.thinkingStreamActive = false;
|
|
206
|
+
this.summarySweepStartedAt.clear();
|
|
207
|
+
});
|
|
208
|
+
pi.on("message_update", (event) => {
|
|
209
|
+
// `context.isStreaming` describes the whole assistant message. Tool-call
|
|
210
|
+
// argument streaming therefore remains true after thinking has ended.
|
|
211
|
+
// Track the actual provider event so the sweep ends on thinking_end.
|
|
212
|
+
const eventType = event.assistantMessageEvent.type;
|
|
213
|
+
if (eventType === "thinking_start") this.summarySweepStartedAt.clear();
|
|
214
|
+
this.thinkingStreamActive = isThinkingStreamEvent(eventType);
|
|
215
|
+
});
|
|
216
|
+
pi.on("message_end", () => {
|
|
217
|
+
this.thinkingStreamActive = false;
|
|
218
|
+
this.summarySweepStartedAt.clear();
|
|
219
|
+
});
|
|
220
|
+
pi.on("tool_call", () => {
|
|
221
|
+
this.thinkingStreamActive = false;
|
|
222
|
+
this.summarySweepStartedAt.clear();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
pi.registerMarkdownTransformer((markdown, context) => {
|
|
226
|
+
if (context.messageType !== "assistant-thinking") return markdown;
|
|
227
|
+
const theme = this.theme;
|
|
228
|
+
const detailTextPrefix = theme?.getFgAnsi("thinkingText");
|
|
229
|
+
return renderThinkingView(markdown, this.view, context.availableWidth, {
|
|
230
|
+
styleSummary: theme
|
|
231
|
+
? context.isStreaming && this.thinkingStreamActive
|
|
232
|
+
? (summary, sectionIndex) => {
|
|
233
|
+
let startedAt = this.summarySweepStartedAt.get(sectionIndex);
|
|
234
|
+
if (startedAt === undefined) {
|
|
235
|
+
startedAt = Date.now();
|
|
236
|
+
this.summarySweepStartedAt.set(sectionIndex, startedAt);
|
|
237
|
+
}
|
|
238
|
+
return shimmer(summary, theme, startedAt);
|
|
239
|
+
}
|
|
240
|
+
: (summary) => theme.fg("thinkingMax", theme.bold(summary))
|
|
241
|
+
: undefined,
|
|
242
|
+
// border styling resets the foreground; resume detail gray immediately.
|
|
243
|
+
styleDetailPrefix: theme && detailTextPrefix
|
|
244
|
+
? (prefix) => theme.fg("border", prefix) + detailTextPrefix
|
|
245
|
+
: undefined,
|
|
246
|
+
controls: theme
|
|
247
|
+
? theme.fg(
|
|
248
|
+
"borderAccent",
|
|
249
|
+
`${theme.italic("ctrl+t")} toggle • ${theme.italic("click")} to hide`,
|
|
250
|
+
)
|
|
251
|
+
: undefined,
|
|
252
|
+
styleControlPrefix: theme ? (prefix) => theme.fg("border", prefix) : undefined,
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
bind(ctx: ExtensionContext): void {
|
|
258
|
+
this.unsubscribe?.();
|
|
259
|
+
this.theme = ctx.ui.theme;
|
|
260
|
+
this.thinkingStreamActive = false;
|
|
261
|
+
this.summarySweepStartedAt.clear();
|
|
262
|
+
// Match Pi's persisted host visibility after startup or /reload. When the
|
|
263
|
+
// host is hidden, Markdown transformers are not invoked at all. Pi wraps
|
|
264
|
+
// this label in thinkingText, so an inner thinkingMax span is required to
|
|
265
|
+
// keep it consistent with completed summary titles.
|
|
266
|
+
this.view = getInitialThinkingView();
|
|
267
|
+
const hiddenLabel = this.theme.fg("thinkingMax", this.theme.bold("Thinking..."));
|
|
268
|
+
ctx.ui.setHiddenThinkingLabel(hiddenLabel);
|
|
269
|
+
this.unsubscribe = ctx.ui.onTerminalInput((data) => {
|
|
270
|
+
if (!matchesKey(data, Key.ctrl("t"))) return undefined;
|
|
271
|
+
if (isKeyRelease(data)) return { consume: true };
|
|
272
|
+
this.view = NEXT_VIEW[this.view];
|
|
273
|
+
if (this.view === "detail") {
|
|
274
|
+
// summary -> detail stays host-visible; rebuild to apply the transformer.
|
|
275
|
+
ctx.ui.setHiddenThinkingLabel(hiddenLabel);
|
|
276
|
+
return { consume: true };
|
|
277
|
+
}
|
|
278
|
+
// detail -> hidden and hidden -> summary intentionally reach Pi's existing
|
|
279
|
+
// two-state handler. This keeps host visibility/settings synchronized and
|
|
280
|
+
// clears built-in per-block click overrides without modifying Pi itself.
|
|
281
|
+
return undefined;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
dispose(): void {
|
|
286
|
+
this.unsubscribe?.();
|
|
287
|
+
this.unsubscribe = undefined;
|
|
288
|
+
this.thinkingStreamActive = false;
|
|
289
|
+
this.summarySweepStartedAt.clear();
|
|
290
|
+
this.theme = undefined;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Component } from "@earendil-works/pi-tui";
|
|
3
|
-
import type { DurationIndicatorConfig } from "./compact-tools-core.ts";
|
|
4
3
|
|
|
5
4
|
export const SUPPORTED_TOOLS = ["read", "write", "edit", "bash", "powershell", "grep", "find", "ls"] as const;
|
|
6
5
|
export const SUPPORTED_TOOL_SET = new Set<string>(SUPPORTED_TOOLS);
|
|
@@ -17,7 +16,6 @@ export interface CompactToolsConfig {
|
|
|
17
16
|
frames: string[];
|
|
18
17
|
intervalMs: number;
|
|
19
18
|
};
|
|
20
|
-
durationIndicators: DurationIndicatorConfig[];
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
export interface RowState {
|
|
@@ -18,18 +18,12 @@ import {
|
|
|
18
18
|
createReadToolDefinition,
|
|
19
19
|
createWriteToolDefinition,
|
|
20
20
|
} from "@earendil-works/pi-coding-agent";
|
|
21
|
-
import type { Component } from "@earendil-works/pi-tui";
|
|
22
|
-
import { Container } from "@earendil-works/pi-tui";
|
|
21
|
+
import type { Component, Container } from "@earendil-works/pi-tui";
|
|
23
22
|
import {
|
|
24
23
|
classifyCallStatus,
|
|
25
24
|
formatDurationMs,
|
|
26
|
-
HEX_COLOR_PATTERN,
|
|
27
25
|
normalizeLineEndings,
|
|
28
|
-
rgbToAnsi256,
|
|
29
|
-
selectDurationIndicator,
|
|
30
|
-
type DurationIndicatorConfig,
|
|
31
26
|
type RowStatus,
|
|
32
|
-
type ThemeDurationIndicatorColor,
|
|
33
27
|
} from "./compact-tools-core.ts";
|
|
34
28
|
import { isFullscreenMode, loadConfig } from "./compact-tools-config.ts";
|
|
35
29
|
import { classifyToggleInput } from "./compact-tools-input.ts";
|
|
@@ -41,6 +35,7 @@ import {
|
|
|
41
35
|
getTextResult,
|
|
42
36
|
} from "./compact-tools-invocation.ts";
|
|
43
37
|
import {
|
|
38
|
+
CachedContainer,
|
|
44
39
|
prefixedText,
|
|
45
40
|
renderArguments,
|
|
46
41
|
renderOutput,
|
|
@@ -49,6 +44,7 @@ import {
|
|
|
49
44
|
wrapEditResult,
|
|
50
45
|
} from "./compact-tools-layout.ts";
|
|
51
46
|
import { ToolRuntime } from "./compact-tools-runtime.ts";
|
|
47
|
+
import { ThinkingCycleController } from "./compact-tools-thinking.ts";
|
|
52
48
|
import type {
|
|
53
49
|
BuiltInDefinition,
|
|
54
50
|
CompactToolName,
|
|
@@ -86,7 +82,7 @@ function callStatus(ctx: RenderContext, state: RowState): RowStatus {
|
|
|
86
82
|
}
|
|
87
83
|
|
|
88
84
|
function renderIndicator(theme: Theme, state: RowState, status: RowStatus): string {
|
|
89
|
-
if (status === "error") return theme.fg("error", "
|
|
85
|
+
if (status === "error") return theme.fg("error", "●");
|
|
90
86
|
if (status === "success") return theme.fg("success", "●");
|
|
91
87
|
const frames = runtime.config.spinner.frames;
|
|
92
88
|
const frameIndex = state.frame ?? 0;
|
|
@@ -98,27 +94,6 @@ function formatDuration(state: RowState): string | undefined {
|
|
|
98
94
|
return formatDurationMs((state.endedAt ?? Date.now()) - state.startedAt);
|
|
99
95
|
}
|
|
100
96
|
|
|
101
|
-
function durationIndicator(state: RowState): DurationIndicatorConfig | undefined {
|
|
102
|
-
const elapsed = state.startedAt !== undefined && state.endedAt !== undefined
|
|
103
|
-
? state.endedAt - state.startedAt
|
|
104
|
-
: undefined;
|
|
105
|
-
return selectDurationIndicator(runtime.config.durationIndicators, elapsed);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function styleDurationIcon(theme: Theme, indicator: DurationIndicatorConfig): string {
|
|
109
|
-
const color = indicator.color ?? "dim";
|
|
110
|
-
if (!HEX_COLOR_PATTERN.test(color)) {
|
|
111
|
-
return theme.fg(color as ThemeDurationIndicatorColor, indicator.icon);
|
|
112
|
-
}
|
|
113
|
-
const red = Number.parseInt(color.slice(1, 3), 16);
|
|
114
|
-
const green = Number.parseInt(color.slice(3, 5), 16);
|
|
115
|
-
const blue = Number.parseInt(color.slice(5, 7), 16);
|
|
116
|
-
const ansi = theme.getColorMode() === "truecolor"
|
|
117
|
-
? `\x1b[38;2;${red};${green};${blue}m`
|
|
118
|
-
: `\x1b[38;5;${rgbToAnsi256(red, green, blue)}m`;
|
|
119
|
-
return `${ansi}${indicator.icon}\x1b[39m`;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
97
|
function renderControls(
|
|
123
98
|
theme: Theme,
|
|
124
99
|
state: RowState,
|
|
@@ -127,13 +102,10 @@ function renderControls(
|
|
|
127
102
|
summary?: string,
|
|
128
103
|
): Component {
|
|
129
104
|
const duration = running && !runtime.animatesRows ? undefined : formatDuration(state);
|
|
130
|
-
const indicator = !running && !isError ? durationIndicator(state) : undefined;
|
|
131
105
|
const status = running
|
|
132
106
|
? (duration ?? "Running")
|
|
133
107
|
: `${isError ? "Failed" : "Done"}${duration ? ` in ${duration}` : ""}`;
|
|
134
|
-
let details =
|
|
135
|
-
? `${styleDurationIcon(theme, indicator)} ${theme.fg("borderAccent", status)}`
|
|
136
|
-
: theme.fg("borderAccent", status);
|
|
108
|
+
let details = theme.fg("borderAccent", status);
|
|
137
109
|
if (summary) details += theme.fg("borderAccent", ` • ${summary}`);
|
|
138
110
|
if (state.hasResult) {
|
|
139
111
|
const clickAction = state.expanded ? "to hide" : "for result";
|
|
@@ -163,7 +135,7 @@ function renderFileCall(
|
|
|
163
135
|
const details = callDetails
|
|
164
136
|
? styleMultiline(callDetails, (line) => theme.fg("toolOutput", line))
|
|
165
137
|
: undefined;
|
|
166
|
-
const container = new
|
|
138
|
+
const container = new CachedContainer();
|
|
167
139
|
container.addChild(renderToolCall(renderCallTitle(name, theme, ctx, state), details, theme));
|
|
168
140
|
const arguments_ = getArgumentDetails(name, args);
|
|
169
141
|
if (Object.keys(arguments_).length > 0) container.addChild(renderArguments(arguments_, theme));
|
|
@@ -217,7 +189,7 @@ function renderFileResult(
|
|
|
217
189
|
const output = getFileOutput(name, ctx.args, result, ctx.isError);
|
|
218
190
|
runtime.setResultAvailable(state, name, name === "edit" || output.length > 0);
|
|
219
191
|
updateEditResult(definition, result, options, theme, ctx, state);
|
|
220
|
-
const container = new
|
|
192
|
+
const container = new CachedContainer();
|
|
221
193
|
appendFileResult(container, definition, state, output, theme, ctx.isError);
|
|
222
194
|
const summary = name === "read" && !ctx.isError ? formatReadResultSummary(result) : undefined;
|
|
223
195
|
container.addChild(renderControls(theme, state, options.isPartial, ctx.isError, summary));
|
|
@@ -250,7 +222,7 @@ function renderShellResult(
|
|
|
250
222
|
const output = getTextResult(result);
|
|
251
223
|
runtime.setResultAvailable(state, name, output.length > 0);
|
|
252
224
|
if (!state.expanded) return renderControls(theme, state, options.isPartial, ctx.isError);
|
|
253
|
-
const container = new
|
|
225
|
+
const container = new CachedContainer();
|
|
254
226
|
const component = renderOutput(output, theme, ctx.isError);
|
|
255
227
|
if (component) container.addChild(component);
|
|
256
228
|
container.addChild(renderControls(theme, state, options.isPartial, ctx.isError));
|
|
@@ -324,14 +296,17 @@ function configure(pi: ExtensionAPI, cwd?: string, projectTrusted = false): void
|
|
|
324
296
|
}
|
|
325
297
|
|
|
326
298
|
export default function compactTools(pi: ExtensionAPI): void {
|
|
299
|
+
const thinkingCycle = new ThinkingCycleController(pi);
|
|
327
300
|
configure(pi);
|
|
328
301
|
pi.on("session_start", (event, ctx) => {
|
|
329
302
|
if (event.reason !== "reload") runtime.clearTimings();
|
|
330
303
|
configure(pi, ctx.cwd, ctx.isProjectTrusted());
|
|
331
304
|
bindTerminalInput(ctx);
|
|
305
|
+
thinkingCycle.bind(ctx);
|
|
332
306
|
});
|
|
333
307
|
pi.on("resources_discover", (_event, ctx) => configure(pi, ctx.cwd, ctx.isProjectTrusted()));
|
|
334
308
|
pi.on("session_shutdown", (event) => {
|
|
309
|
+
thinkingCycle.dispose();
|
|
335
310
|
unsubscribeTerminalInput?.();
|
|
336
311
|
unsubscribeTerminalInput = undefined;
|
|
337
312
|
runtime.reset(event.reason !== "reload");
|
package/package.json
CHANGED
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-compact-tools",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Compact, expandable UI for pi's built-in tools, bundled with a polished GitHub Dark theme.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"author": "Dong Hyeong Lee",
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/nedleeds/pi-compact-tools.git"
|
|
11
|
-
},
|
|
12
|
-
"homepage": "https://github.com/nedleeds/pi-compact-tools#readme",
|
|
13
|
-
"bugs": {
|
|
14
|
-
"url": "https://github.com/nedleeds/pi-compact-tools/issues"
|
|
15
|
-
},
|
|
16
|
-
"keywords": [
|
|
17
|
-
"pi-package",
|
|
18
|
-
"pi-coding-agent",
|
|
19
|
-
"pi-extension",
|
|
20
|
-
"terminal-ui",
|
|
21
|
-
"theme"
|
|
22
|
-
],
|
|
23
|
-
"files": [
|
|
24
|
-
"extensions",
|
|
25
|
-
"themes",
|
|
26
|
-
"examples",
|
|
27
|
-
"scripts",
|
|
28
|
-
"schemas",
|
|
29
|
-
"README.md",
|
|
30
|
-
"LICENSE"
|
|
31
|
-
],
|
|
32
|
-
"scripts": {
|
|
33
|
-
"test": "tsx --test tests/*.test.ts",
|
|
34
|
-
"check": "tsc --noEmit && npm test && npm run check:resources",
|
|
35
|
-
"check:resources": "node scripts/check-resources.mjs",
|
|
36
|
-
"prepublishOnly": "npm run check"
|
|
37
|
-
},
|
|
38
|
-
"pi": {
|
|
39
|
-
"extensions": [
|
|
40
|
-
"./extensions/compact-tools.ts"
|
|
41
|
-
],
|
|
42
|
-
"themes": [
|
|
43
|
-
"./themes/github-dark-pro.json"
|
|
44
|
-
],
|
|
45
|
-
"image": "https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/nerd-font-workflow-optimized.gif"
|
|
46
|
-
},
|
|
47
|
-
"peerDependencies": {
|
|
48
|
-
"@earendil-works/pi-coding-agent": "*",
|
|
49
|
-
"@earendil-works/pi-tui": "*"
|
|
50
|
-
},
|
|
51
|
-
"devDependencies": {
|
|
52
|
-
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
53
|
-
"@earendil-works/pi-tui": "0.85.1",
|
|
54
|
-
"@types/node": "^24.0.0",
|
|
55
|
-
"tsx": "^4.23.13",
|
|
56
|
-
"typescript": "^5.9.0"
|
|
57
|
-
},
|
|
58
|
-
"engines": {
|
|
59
|
-
"node": ">=22.19.0"
|
|
60
|
-
},
|
|
61
|
-
"publishConfig": {
|
|
62
|
-
"access": "public"
|
|
63
|
-
}
|
|
64
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-compact-tools",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Compact, expandable UI for pi's built-in tools, bundled with a polished GitHub Dark theme.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Dong Hyeong Lee",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/nedleeds/pi-compact-tools.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/nedleeds/pi-compact-tools#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/nedleeds/pi-compact-tools/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi-coding-agent",
|
|
19
|
+
"pi-extension",
|
|
20
|
+
"terminal-ui",
|
|
21
|
+
"theme"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
"extensions",
|
|
25
|
+
"themes",
|
|
26
|
+
"examples",
|
|
27
|
+
"scripts",
|
|
28
|
+
"schemas",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "tsx --test tests/*.test.ts",
|
|
34
|
+
"check": "tsc --noEmit && npm test && npm run check:resources",
|
|
35
|
+
"check:resources": "node scripts/check-resources.mjs",
|
|
36
|
+
"prepublishOnly": "npm run check"
|
|
37
|
+
},
|
|
38
|
+
"pi": {
|
|
39
|
+
"extensions": [
|
|
40
|
+
"./extensions/compact-tools.ts"
|
|
41
|
+
],
|
|
42
|
+
"themes": [
|
|
43
|
+
"./themes/github-dark-pro.json"
|
|
44
|
+
],
|
|
45
|
+
"image": "https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/nerd-font-workflow-optimized.gif"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
49
|
+
"@earendil-works/pi-tui": "*"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
53
|
+
"@earendil-works/pi-tui": "0.85.1",
|
|
54
|
+
"@types/node": "^24.0.0",
|
|
55
|
+
"tsx": "^4.23.13",
|
|
56
|
+
"typescript": "^5.9.0"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=22.19.0"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -61,44 +61,6 @@
|
|
|
61
61
|
"default": 120
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
-
},
|
|
65
|
-
"durationIndicators": {
|
|
66
|
-
"description": "Ordered duration thresholds. The final item must omit underMs and acts as the fallback.",
|
|
67
|
-
"type": "array",
|
|
68
|
-
"minItems": 1,
|
|
69
|
-
"items": {
|
|
70
|
-
"type": "object",
|
|
71
|
-
"additionalProperties": false,
|
|
72
|
-
"required": ["icon"],
|
|
73
|
-
"properties": {
|
|
74
|
-
"underMs": {
|
|
75
|
-
"type": "integer",
|
|
76
|
-
"minimum": 1
|
|
77
|
-
},
|
|
78
|
-
"icon": {
|
|
79
|
-
"type": "string",
|
|
80
|
-
"minLength": 1
|
|
81
|
-
},
|
|
82
|
-
"color": {
|
|
83
|
-
"description": "Optional semantic theme color or six-digit hex color used only for this duration icon.",
|
|
84
|
-
"anyOf": [
|
|
85
|
-
{
|
|
86
|
-
"type": "string",
|
|
87
|
-
"enum": [
|
|
88
|
-
"accent", "border", "borderAccent", "borderMuted", "success", "error", "warning",
|
|
89
|
-
"muted", "dim", "text", "thinkingText", "toolTitle", "toolOutput", "toolDiffAdded",
|
|
90
|
-
"toolDiffRemoved", "toolDiffContext", "bashMode"
|
|
91
|
-
]
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
"type": "string",
|
|
95
|
-
"pattern": "^#[0-9A-Fa-f]{6}$"
|
|
96
|
-
}
|
|
97
|
-
],
|
|
98
|
-
"default": "dim"
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
64
|
}
|
|
103
65
|
}
|
|
104
66
|
}
|
|
@@ -23,8 +23,11 @@ if (theme.name !== "github-dark-pro" || !theme.colors || typeof theme.colors !==
|
|
|
23
23
|
if (!theme.colors.border || !theme.colors.dim || !theme.colors.muted) {
|
|
24
24
|
throw new Error("github-dark-pro must define spinner and separator colors");
|
|
25
25
|
}
|
|
26
|
-
if (!theme.vars?.thinkingText) {
|
|
27
|
-
throw new Error("github-dark-pro must define
|
|
26
|
+
if (!theme.vars?.thinkingText || !theme.colors.thinkingMax) {
|
|
27
|
+
throw new Error("github-dark-pro must define thinking text and summary colors");
|
|
28
|
+
}
|
|
29
|
+
if (!theme.colors.mdQuote || !theme.colors.mdCodeBlockBorder) {
|
|
30
|
+
throw new Error("github-dark-pro must define thinking detail and code fence colors");
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
const example = JSON.parse(
|
|
@@ -51,31 +54,17 @@ const blinkExample = JSON.parse(
|
|
|
51
54
|
if (JSON.stringify(blinkExample.spinner?.frames) !== JSON.stringify(["●", "●", " ", "●", "●"])) {
|
|
52
55
|
throw new Error("compact-tools-2 spinner must fade through repeated circles and a blank frame");
|
|
53
56
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
blinkExample.durationIndicators[0]?.color !== "#E0A052"
|
|
58
|
-
) {
|
|
59
|
-
throw new Error("compact-tools-2 must use bullet duration indicators and the explicit warning hex color");
|
|
60
|
-
}
|
|
61
|
-
const indicators = example.durationIndicators;
|
|
62
|
-
if (!Array.isArray(indicators) || indicators.length === 0 || indicators.at(-1)?.underMs !== undefined) {
|
|
63
|
-
throw new Error("example durationIndicators must end with a fallback rule");
|
|
64
|
-
}
|
|
65
|
-
for (let index = 0; index < indicators.length - 1; index++) {
|
|
66
|
-
if (!(indicators[index].underMs > (indicators[index - 1]?.underMs ?? 0))) {
|
|
67
|
-
throw new Error("example durationIndicators thresholds must be ascending");
|
|
57
|
+
for (const [name, config] of [["compact-tools", example], ["compact-tools-2", blinkExample]]) {
|
|
58
|
+
if ("durationIndicators" in config) {
|
|
59
|
+
throw new Error(`${name} must not contain the removed durationIndicators setting`);
|
|
68
60
|
}
|
|
69
61
|
}
|
|
70
|
-
if (indicators.some((indicator) => indicator.color !== undefined && typeof indicator.color !== "string")) {
|
|
71
|
-
throw new Error("example duration indicator colors must be strings");
|
|
72
|
-
}
|
|
73
62
|
|
|
74
63
|
const schema = JSON.parse(
|
|
75
64
|
await readFile(new URL("../schemas/compact-tools.schema.json", import.meta.url), "utf8"),
|
|
76
65
|
);
|
|
77
|
-
if (
|
|
78
|
-
throw new Error("schema must
|
|
66
|
+
if (schema.properties?.durationIndicators) {
|
|
67
|
+
throw new Error("schema must not expose the removed durationIndicators setting");
|
|
79
68
|
}
|
|
80
69
|
if (!schema.properties?.auto_compact?.properties?.edit) {
|
|
81
70
|
throw new Error("schema must describe per-tool auto_compact policies");
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"muted": "muted",
|
|
37
37
|
"dim": "dim",
|
|
38
38
|
"text": "text",
|
|
39
|
-
"thinkingText": "
|
|
39
|
+
"thinkingText": "toolText",
|
|
40
40
|
|
|
41
41
|
"selectedBg": "surfaceMuted",
|
|
42
42
|
"searchMatchBg": "#3B2E10",
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"mdLinkUrl": "dim",
|
|
60
60
|
"mdCode": "inlineCode",
|
|
61
61
|
"mdCodeBlock": "text",
|
|
62
|
-
"mdCodeBlockBorder": "#
|
|
63
|
-
"mdQuote": "
|
|
62
|
+
"mdCodeBlockBorder": "#0D1117",
|
|
63
|
+
"mdQuote": "toolText",
|
|
64
64
|
"mdQuoteBorder": "border",
|
|
65
65
|
"mdHr": "borderSubtle",
|
|
66
66
|
"mdListBullet": "muted",
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"thinkingMedium": "muted",
|
|
86
86
|
"thinkingHigh": "blue",
|
|
87
87
|
"thinkingXhigh": "blueSoft",
|
|
88
|
-
"thinkingMax": "
|
|
88
|
+
"thinkingMax": "#9FCFF8",
|
|
89
89
|
"bashMode": "green"
|
|
90
90
|
},
|
|
91
91
|
"export": {
|