pi-compact-tools 0.6.1 → 0.7.7
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 +29 -61
- package/examples/compact-tools-2.json +1 -4
- package/examples/compact-tools.json +1 -4
- package/extensions/compact-tools-color.ts +65 -0
- package/extensions/compact-tools-config.ts +7 -42
- package/extensions/compact-tools-core.ts +26 -0
- package/extensions/compact-tools-invocation.ts +166 -31
- package/extensions/compact-tools-layout.ts +23 -22
- package/extensions/compact-tools-progress.ts +158 -0
- package/extensions/compact-tools-runtime.ts +44 -70
- package/extensions/compact-tools-thinking.ts +53 -92
- package/extensions/compact-tools-types.ts +11 -8
- package/extensions/compact-tools.ts +126 -94
- package/package.json +1 -1
- package/schemas/compact-tools.schema.json +8 -24
- package/scripts/check-resources.mjs +13 -16
- package/extensions/compact-tools-input.ts +0 -8
|
@@ -117,28 +117,6 @@ export function renderToolCall(title: string, details: string | undefined, theme
|
|
|
117
117
|
});
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
export function wrapEditResult(component: Component, theme: Theme): Component {
|
|
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?.());
|
|
140
|
-
}
|
|
141
|
-
|
|
142
120
|
export function styleMultiline(text: string, style: (line: string) => string): string {
|
|
143
121
|
return normalizeLineEndings(text).split("\n").map(style).join("\n");
|
|
144
122
|
}
|
|
@@ -151,6 +129,29 @@ export function renderOutput(output: string, theme: Theme, isError: boolean): Co
|
|
|
151
129
|
return prefixedText(styled, theme.fg("border", " │ "));
|
|
152
130
|
}
|
|
153
131
|
|
|
132
|
+
/** Render an edit's changed lines without unchanged context, hunk metadata, or result prose. */
|
|
133
|
+
export function renderEditChanges(changes: string, theme: Theme): Component | undefined {
|
|
134
|
+
const normalized = normalizeLineEndings(changes).trimEnd();
|
|
135
|
+
if (!normalized) return undefined;
|
|
136
|
+
const styled = normalized.split("\n").map((line) =>
|
|
137
|
+
theme.fg(line.startsWith("+") ? "success" : "error", line)
|
|
138
|
+
).join("\n");
|
|
139
|
+
return prefixedText(styled, theme.fg("border", " │ "));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Limit a result preview by rendered rows while preserving the full source component for expansion. */
|
|
143
|
+
export function limitComponentLines(component: Component, maximumLines: number, theme: Theme): Component {
|
|
144
|
+
return new CachedComponent((width) => {
|
|
145
|
+
const lines = component.render(width);
|
|
146
|
+
if (lines.length <= maximumLines) return lines;
|
|
147
|
+
const omitted = lines.length - maximumLines;
|
|
148
|
+
return [
|
|
149
|
+
...lines.slice(0, maximumLines),
|
|
150
|
+
theme.fg("border", " │ ") + theme.fg("borderAccent", `… ${omitted} more ${omitted === 1 ? "line" : "lines"}`),
|
|
151
|
+
];
|
|
152
|
+
}, () => component.invalidate?.());
|
|
153
|
+
}
|
|
154
|
+
|
|
154
155
|
export function renderArguments(args: ToolArgs, theme: Theme): Component {
|
|
155
156
|
const json = JSON.stringify(args, null, 2) ?? "{}";
|
|
156
157
|
const styled = styleMultiline(json, (line) => theme.fg("toolOutput", line));
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { colorizeRgb, interpolateRgb, themeColorRgb, type Rgb } from "./compact-tools-color.ts";
|
|
4
|
+
import type { ToolArgs } from "./compact-tools-types.ts";
|
|
5
|
+
|
|
6
|
+
const GLOW_INTERVAL_MS = 80;
|
|
7
|
+
const WHITE: Rgb = { r: 255, g: 255, b: 255 };
|
|
8
|
+
|
|
9
|
+
const TOOL_PROGRESS_MESSAGES: Readonly<Record<string, string>> = {
|
|
10
|
+
read: "Reading file…",
|
|
11
|
+
write: "Writing file…",
|
|
12
|
+
edit: "Editing file…",
|
|
13
|
+
bash: "Running command…",
|
|
14
|
+
powershell: "Running command…",
|
|
15
|
+
grep: "Searching text…",
|
|
16
|
+
find: "Finding files…",
|
|
17
|
+
ls: "Listing files…",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function humanizeToolName(toolName: string, args: ToolArgs): string {
|
|
21
|
+
const routedName = (toolName === "mcp" || toolName.startsWith("mcp__")) && typeof args.tool === "string"
|
|
22
|
+
? args.tool
|
|
23
|
+
: toolName;
|
|
24
|
+
const readableName = stripTerminalSequences(routedName)
|
|
25
|
+
.replace(/[\u0000-\u001f\u007f]/gu, " ")
|
|
26
|
+
.replace(/^mcp__/iu, "")
|
|
27
|
+
.replace(/[_-]mcp$/iu, "")
|
|
28
|
+
.replace(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2")
|
|
29
|
+
.replace(/([a-z\d])([A-Z])/gu, "$1 $2")
|
|
30
|
+
.replace(/[_-]+/gu, " ")
|
|
31
|
+
.replace(/\s+/gu, " ")
|
|
32
|
+
.trim();
|
|
33
|
+
return truncateToWidth(readableName, 40, "…");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Keep the working row calm and private; invocation details remain in the tool card. */
|
|
37
|
+
export function formatToolProgress(toolName: string, args: ToolArgs): string {
|
|
38
|
+
const builtInMessage = TOOL_PROGRESS_MESSAGES[toolName];
|
|
39
|
+
if (builtInMessage) return builtInMessage;
|
|
40
|
+
const displayName = humanizeToolName(toolName, args);
|
|
41
|
+
return displayName ? `Using ${displayName}…` : "Running tool…";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function glowRadius(length: number): number {
|
|
45
|
+
return Math.max(2, Math.min(8, Math.ceil(length / 4)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Render a thinking-summary-colored label with a proportional white highlight sweeping left to right. */
|
|
49
|
+
export function glowProgressMessage(message: string, frame: number, theme: Theme): string {
|
|
50
|
+
const characters = [...message];
|
|
51
|
+
if (characters.length === 0) return "";
|
|
52
|
+
const radius = glowRadius(characters.length);
|
|
53
|
+
const center = frame % (characters.length + radius * 2) - radius;
|
|
54
|
+
const base = themeColorRgb(theme, "thinkingMax");
|
|
55
|
+
if (!base) {
|
|
56
|
+
return characters.map((character, index) =>
|
|
57
|
+
theme.fg(Math.abs(index - center) < radius ? "mdHeading" : "thinkingMax", character)).join("");
|
|
58
|
+
}
|
|
59
|
+
return characters.map((character, index) => {
|
|
60
|
+
const distance = Math.abs(index - center);
|
|
61
|
+
const strength = Math.max(0, 1 - distance / radius);
|
|
62
|
+
return colorizeRgb(theme, interpolateRgb(base, WHITE, strength), character);
|
|
63
|
+
}).join("");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createGlowFrames(message: string, theme: Theme): string[] {
|
|
67
|
+
const length = [...message].length;
|
|
68
|
+
if (length === 0) return [""];
|
|
69
|
+
return Array.from({ length: length + glowRadius(length) * 2 }, (_, frame) =>
|
|
70
|
+
glowProgressMessage(message, frame, theme));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Owns Pi's single working row so animation remains continuous across thinking and tool execution. */
|
|
74
|
+
export class ProgressController {
|
|
75
|
+
private context: ExtensionContext | undefined;
|
|
76
|
+
private readonly activeTools = new Map<string, string>();
|
|
77
|
+
private message: string | undefined;
|
|
78
|
+
private frames: string[] = [];
|
|
79
|
+
private frame = 0;
|
|
80
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
81
|
+
|
|
82
|
+
constructor(pi: ExtensionAPI) {
|
|
83
|
+
pi.on("agent_start", () => this.setMessage("Thinking…"));
|
|
84
|
+
pi.on("message_update", (event) => {
|
|
85
|
+
const type = event.assistantMessageEvent.type;
|
|
86
|
+
if (type === "thinking_start" || type === "thinking_delta") this.setMessage("Thinking…");
|
|
87
|
+
else if (type === "text_start" || type === "text_delta") this.setMessage("Responding…");
|
|
88
|
+
else if (type === "toolcall_start" || type === "toolcall_delta") this.setMessage("Preparing tools…");
|
|
89
|
+
});
|
|
90
|
+
pi.on("tool_execution_start", (event) => {
|
|
91
|
+
const message = formatToolProgress(event.toolName, event.args ?? {});
|
|
92
|
+
this.activeTools.set(event.toolCallId, message);
|
|
93
|
+
this.setMessage(message);
|
|
94
|
+
});
|
|
95
|
+
pi.on("tool_execution_end", (event) => {
|
|
96
|
+
this.activeTools.delete(event.toolCallId);
|
|
97
|
+
const remaining = [...this.activeTools.values()].at(-1);
|
|
98
|
+
this.setMessage(remaining ?? "Processing results…");
|
|
99
|
+
});
|
|
100
|
+
pi.on("agent_end", () => {
|
|
101
|
+
this.activeTools.clear();
|
|
102
|
+
this.clearMessage();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
bind(ctx: ExtensionContext): void {
|
|
107
|
+
this.stopAnimation();
|
|
108
|
+
this.context = ctx;
|
|
109
|
+
this.activeTools.clear();
|
|
110
|
+
this.message = undefined;
|
|
111
|
+
this.frames = [];
|
|
112
|
+
this.frame = 0;
|
|
113
|
+
ctx.ui.setWorkingVisible(true);
|
|
114
|
+
ctx.ui.setWorkingIndicator({ frames: [] });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
dispose(): void {
|
|
118
|
+
this.stopAnimation();
|
|
119
|
+
this.activeTools.clear();
|
|
120
|
+
this.context?.ui.setWorkingMessage();
|
|
121
|
+
this.context?.ui.setWorkingIndicator();
|
|
122
|
+
this.message = undefined;
|
|
123
|
+
this.frames = [];
|
|
124
|
+
this.context = undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private setMessage(message: string): void {
|
|
128
|
+
if (!this.context || (this.message === message && this.timer)) return;
|
|
129
|
+
this.message = message;
|
|
130
|
+
this.frames = createGlowFrames(message, this.context.ui.theme);
|
|
131
|
+
this.frame = 0;
|
|
132
|
+
this.renderMessage();
|
|
133
|
+
if (this.timer) return;
|
|
134
|
+
this.timer = setInterval(() => {
|
|
135
|
+
this.frame++;
|
|
136
|
+
this.renderMessage();
|
|
137
|
+
}, GLOW_INTERVAL_MS);
|
|
138
|
+
this.timer.unref?.();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private renderMessage(): void {
|
|
142
|
+
if (!this.message || !this.context || this.frames.length === 0) return;
|
|
143
|
+
this.context.ui.setWorkingMessage(this.frames[this.frame % this.frames.length]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private clearMessage(): void {
|
|
147
|
+
this.stopAnimation();
|
|
148
|
+
this.message = undefined;
|
|
149
|
+
this.frames = [];
|
|
150
|
+
this.frame = 0;
|
|
151
|
+
this.context?.ui.setWorkingMessage();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private stopAnimation(): void {
|
|
155
|
+
if (this.timer) clearInterval(this.timer);
|
|
156
|
+
this.timer = undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -9,32 +9,26 @@ import type {
|
|
|
9
9
|
} from "./compact-tools-types.ts";
|
|
10
10
|
|
|
11
11
|
const MAX_TRACKED_ROWS = 2_000;
|
|
12
|
-
const
|
|
12
|
+
const INDICATOR_INTERVAL_MS = 45;
|
|
13
13
|
const EXECUTION_TIMINGS_KEY = Symbol.for("pi.compact-tools.execution-timings");
|
|
14
14
|
|
|
15
15
|
type ExecutionTiming = { startedAt: number; endedAt?: number };
|
|
16
|
-
type TrackedRow = { state: RowState; name: CompactToolName; invalidate: () => void };
|
|
17
16
|
type TimedExecute = (...args: any[]) => Promise<AgentToolResult<unknown>>;
|
|
18
17
|
type SharedState = typeof globalThis & {
|
|
19
|
-
[SPINNING_ROWS_KEY]?: Set<RowState>;
|
|
20
18
|
[EXECUTION_TIMINGS_KEY]?: Map<string, ExecutionTiming>;
|
|
21
19
|
};
|
|
22
20
|
|
|
23
21
|
export class ToolRuntime {
|
|
24
22
|
private configValue = DEFAULT_CONFIG;
|
|
25
23
|
private configRevision: object = {};
|
|
26
|
-
private animateRows = false;
|
|
27
|
-
private readonly spinningRows = new Set<RowState>();
|
|
28
24
|
private readonly executionTimings: Map<string, ExecutionTiming>;
|
|
29
|
-
private readonly
|
|
25
|
+
private readonly indicatorInvalidators = new Map<string, () => void>();
|
|
26
|
+
private indicatorFrame = 0;
|
|
27
|
+
private indicatorTimer: ReturnType<typeof setInterval> | undefined;
|
|
30
28
|
|
|
31
29
|
constructor() {
|
|
32
30
|
const shared = globalThis as SharedState;
|
|
33
|
-
for (const state of shared[SPINNING_ROWS_KEY] ?? []) {
|
|
34
|
-
if (state.timer) clearInterval(state.timer);
|
|
35
|
-
}
|
|
36
31
|
this.executionTimings = shared[EXECUTION_TIMINGS_KEY] ?? new Map<string, ExecutionTiming>();
|
|
37
|
-
shared[SPINNING_ROWS_KEY] = this.spinningRows;
|
|
38
32
|
shared[EXECUTION_TIMINGS_KEY] = this.executionTimings;
|
|
39
33
|
}
|
|
40
34
|
|
|
@@ -42,14 +36,8 @@ export class ToolRuntime {
|
|
|
42
36
|
return this.configValue;
|
|
43
37
|
}
|
|
44
38
|
|
|
45
|
-
|
|
46
|
-
return this.animateRows;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
configure(config: CompactToolsConfig, animateRows: boolean): void {
|
|
50
|
-
this.reset(false);
|
|
39
|
+
configure(config: CompactToolsConfig): void {
|
|
51
40
|
this.configValue = config;
|
|
52
|
-
this.animateRows = animateRows;
|
|
53
41
|
this.configRevision = {};
|
|
54
42
|
}
|
|
55
43
|
|
|
@@ -58,40 +46,35 @@ export class ToolRuntime {
|
|
|
58
46
|
}
|
|
59
47
|
|
|
60
48
|
reset(clearTimings: boolean): void {
|
|
61
|
-
|
|
62
|
-
this.trackedRows.clear();
|
|
49
|
+
this.stopIndicators();
|
|
63
50
|
if (clearTimings) this.clearTimings();
|
|
64
51
|
}
|
|
65
52
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const oldest = this.trackedRows.keys().next().value;
|
|
71
|
-
if (oldest !== undefined) this.trackedRows.delete(oldest);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
toggleTrackedRows(): "expanded" | "collapsed" | undefined {
|
|
75
|
-
const rows: TrackedRow[] = [];
|
|
76
|
-
for (const row of this.trackedRows.values()) {
|
|
77
|
-
if (this.configValue.auto_compact[row.name] && row.state.hasResult) rows.push(row);
|
|
53
|
+
syncIndicator(toolCallId: string, running: boolean, invalidate: () => void): number {
|
|
54
|
+
if (!running) {
|
|
55
|
+
this.removeIndicator(toolCallId);
|
|
56
|
+
return 0;
|
|
78
57
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
58
|
+
this.indicatorInvalidators.set(toolCallId, invalidate);
|
|
59
|
+
if (!this.indicatorTimer) {
|
|
60
|
+
this.indicatorTimer = setInterval(() => {
|
|
61
|
+
this.indicatorFrame++;
|
|
62
|
+
for (const requestRender of this.indicatorInvalidators.values()) requestRender();
|
|
63
|
+
}, INDICATOR_INTERVAL_MS);
|
|
64
|
+
this.indicatorTimer.unref?.();
|
|
84
65
|
}
|
|
85
|
-
return
|
|
66
|
+
return this.indicatorFrame;
|
|
86
67
|
}
|
|
87
68
|
|
|
88
69
|
syncExpansion(state: RowState, hostExpanded: boolean, name: CompactToolName): boolean {
|
|
89
|
-
const initialized = this.initializeExpansion(state, name);
|
|
90
|
-
if (initialized
|
|
91
|
-
state.lastHostExpanded = hostExpanded;
|
|
92
|
-
} else if (state.lastHostExpanded !== hostExpanded) {
|
|
70
|
+
const initialized = this.initializeExpansion(state, name, hostExpanded);
|
|
71
|
+
if (!initialized && state.lastHostExpanded !== hostExpanded) {
|
|
93
72
|
state.lastHostExpanded = hostExpanded;
|
|
94
|
-
state.expanded =
|
|
73
|
+
state.expanded = hostExpanded;
|
|
74
|
+
// A non-auto-compacted row collapses back to its bounded preview. Hiding it
|
|
75
|
+
// here removes the clicked row from under the pointer, so the next click can
|
|
76
|
+
// accidentally hit the Thinking block that moved into the same coordinates.
|
|
77
|
+
state.preview = !hostExpanded && !this.configValue.auto_compact[name];
|
|
95
78
|
}
|
|
96
79
|
return state.expanded ?? false;
|
|
97
80
|
}
|
|
@@ -111,8 +94,8 @@ export class ToolRuntime {
|
|
|
111
94
|
const started = !ctx.argsComplete || ctx.executionStarted || running;
|
|
112
95
|
if (started && state.startedAt === undefined) state.startedAt = Date.now();
|
|
113
96
|
if (finished && state.startedAt !== undefined && state.endedAt === undefined) state.endedAt = Date.now();
|
|
97
|
+
if (finished) this.removeIndicator(ctx.toolCallId);
|
|
114
98
|
this.persistTiming(state, ctx.toolCallId);
|
|
115
|
-
this.syncSpinner(state, running, ctx.invalidate);
|
|
116
99
|
return state;
|
|
117
100
|
}
|
|
118
101
|
|
|
@@ -133,37 +116,28 @@ export class ToolRuntime {
|
|
|
133
116
|
};
|
|
134
117
|
}
|
|
135
118
|
|
|
136
|
-
private
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
119
|
+
private removeIndicator(toolCallId: string): void {
|
|
120
|
+
this.indicatorInvalidators.delete(toolCallId);
|
|
121
|
+
if (this.indicatorInvalidators.size > 0 || !this.indicatorTimer) return;
|
|
122
|
+
clearInterval(this.indicatorTimer);
|
|
123
|
+
this.indicatorTimer = undefined;
|
|
124
|
+
this.indicatorFrame = 0;
|
|
141
125
|
}
|
|
142
126
|
|
|
143
|
-
private
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
this.
|
|
127
|
+
private stopIndicators(): void {
|
|
128
|
+
if (this.indicatorTimer) clearInterval(this.indicatorTimer);
|
|
129
|
+
this.indicatorTimer = undefined;
|
|
130
|
+
this.indicatorInvalidators.clear();
|
|
131
|
+
this.indicatorFrame = 0;
|
|
147
132
|
}
|
|
148
133
|
|
|
149
|
-
private
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
state.frame ??= 0;
|
|
157
|
-
state.timer = setInterval(() => {
|
|
158
|
-
state.frame = ((state.frame ?? 0) + 1) % spinner.frames.length;
|
|
159
|
-
try {
|
|
160
|
-
invalidate();
|
|
161
|
-
} catch {
|
|
162
|
-
this.stopSpinner(state);
|
|
163
|
-
}
|
|
164
|
-
}, spinner.intervalMs);
|
|
165
|
-
state.timer.unref?.();
|
|
166
|
-
this.spinningRows.add(state);
|
|
134
|
+
private initializeExpansion(state: RowState, name: CompactToolName, hostExpanded = false): boolean {
|
|
135
|
+
if (state.configRevision === this.configRevision) return false;
|
|
136
|
+
state.configRevision = this.configRevision;
|
|
137
|
+
state.lastHostExpanded = hostExpanded;
|
|
138
|
+
state.expanded = hostExpanded;
|
|
139
|
+
state.preview = !hostExpanded && !this.configValue.auto_compact[name];
|
|
140
|
+
return true;
|
|
167
141
|
}
|
|
168
142
|
|
|
169
143
|
private restoreTiming(state: RowState, toolCallId: string): boolean {
|
|
@@ -17,7 +17,7 @@ export type ThinkingPhase = "summary" | "detail" | "summary-after-detail" | "hid
|
|
|
17
17
|
type ThinkingRenderOptions = {
|
|
18
18
|
styleSummary?: (summary: string, sectionIndex: number) => string;
|
|
19
19
|
styleDetailPrefix?: (prefix: string) => string;
|
|
20
|
-
controls?: string;
|
|
20
|
+
controls?: string | ((hasDetail: boolean) => string | undefined);
|
|
21
21
|
styleControlPrefix?: (prefix: string) => string;
|
|
22
22
|
};
|
|
23
23
|
|
|
@@ -56,7 +56,7 @@ function plainSummary(line: string): string {
|
|
|
56
56
|
|
|
57
57
|
type ThinkingSection = { summary: string; detail: string };
|
|
58
58
|
|
|
59
|
-
function splitThinkingSections(markdown: string
|
|
59
|
+
function splitThinkingSections(markdown: string): ThinkingSection[] {
|
|
60
60
|
const normalized = markdown.includes("\r") ? markdown.replace(/\r\n?/gu, "\n") : markdown;
|
|
61
61
|
const lines = normalized.split("\n");
|
|
62
62
|
let first = -1;
|
|
@@ -73,7 +73,7 @@ function splitThinkingSections(markdown: string, includeDetail: boolean): Thinki
|
|
|
73
73
|
let detail: string[] = [];
|
|
74
74
|
let inFence = false;
|
|
75
75
|
const flush = () => {
|
|
76
|
-
sections.push({ summary, detail:
|
|
76
|
+
sections.push({ summary, detail: detail.join("\n").trim() });
|
|
77
77
|
detail = [];
|
|
78
78
|
};
|
|
79
79
|
for (let index = first + 1; index < lines.length; index++) {
|
|
@@ -89,7 +89,7 @@ function splitThinkingSections(markdown: string, includeDetail: boolean): Thinki
|
|
|
89
89
|
continue;
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
-
|
|
92
|
+
detail.push(line);
|
|
93
93
|
if (fence) inFence = !inFence;
|
|
94
94
|
}
|
|
95
95
|
flush();
|
|
@@ -135,35 +135,59 @@ function hardBreak(lines: string[]): string {
|
|
|
135
135
|
return lines.map((line, index) => (index < lines.length - 1 ? `${line} ` : line)).join("\n");
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
function sectionsHaveDetail(sections: readonly ThinkingSection[]): boolean {
|
|
139
|
+
return sections.some(({ detail }) => detail.length > 0);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function hasThinkingDetail(markdown: string): boolean {
|
|
143
|
+
return sectionsHaveDetail(splitThinkingSections(markdown));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function renderThinkingSections(
|
|
147
|
+
sections: readonly ThinkingSection[],
|
|
141
148
|
view: ThinkingView,
|
|
142
149
|
availableWidth: number,
|
|
143
150
|
options: ThinkingRenderOptions = {},
|
|
144
151
|
): string {
|
|
145
152
|
if (view === "hidden") return "Thinking...";
|
|
146
|
-
const controls = options.controls ??
|
|
153
|
+
const controls = options.controls ?? ((hasDetail: boolean) =>
|
|
154
|
+
hasDetail ? "ctrl+t toggle • click to hide" : undefined);
|
|
147
155
|
const styleControlPrefix = options.styleControlPrefix ?? ((prefix: string) => prefix);
|
|
148
|
-
return
|
|
156
|
+
return sections
|
|
149
157
|
.map(({ summary, detail }, sectionIndex) => {
|
|
150
158
|
const fittedSummary = stripTerminalSequences(
|
|
151
159
|
truncateToWidth(summary, Math.max(1, availableWidth), "…"),
|
|
152
160
|
);
|
|
153
161
|
const title = options.styleSummary?.(fittedSummary, sectionIndex) ?? fittedSummary;
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
162
|
+
const sectionControls = view === "detail"
|
|
163
|
+
? typeof controls === "function" ? controls(detail.length > 0) : controls
|
|
164
|
+
: undefined;
|
|
165
|
+
const controlLines = sectionControls
|
|
166
|
+
? wrapTextWithAnsi(sectionControls, Math.max(1, availableWidth - 3)).map(
|
|
167
|
+
(line, index) => `${index === 0 ? styleControlPrefix("└─ ") : " "}${line}`,
|
|
168
|
+
)
|
|
169
|
+
: [];
|
|
157
170
|
if (view === "detail" && detail) {
|
|
158
171
|
const styleDetailPrefix = options.styleDetailPrefix ?? ((prefix: string) => prefix);
|
|
159
172
|
const connector = `${styleDetailPrefix("│")} `;
|
|
160
|
-
|
|
173
|
+
const detailOutput = `${title} \n${connector}\n${compactDetail(detail, availableWidth, styleDetailPrefix)}`;
|
|
174
|
+
return controlLines.length > 0 ? `${detailOutput}\n${hardBreak(controlLines)}` : detailOutput;
|
|
161
175
|
}
|
|
162
176
|
return hardBreak([title, ...controlLines]);
|
|
163
177
|
})
|
|
164
178
|
.join("\n\n");
|
|
165
179
|
}
|
|
166
180
|
|
|
181
|
+
/** Display-only transformation; the original thinking remains unchanged in session/model context. */
|
|
182
|
+
export function renderThinkingView(
|
|
183
|
+
markdown: string,
|
|
184
|
+
view: ThinkingView,
|
|
185
|
+
availableWidth: number,
|
|
186
|
+
options: ThinkingRenderOptions = {},
|
|
187
|
+
): string {
|
|
188
|
+
return renderThinkingSections(splitThinkingSections(markdown), view, availableWidth, options);
|
|
189
|
+
}
|
|
190
|
+
|
|
167
191
|
function getInitialThinkingPhase(): ThinkingPhase {
|
|
168
192
|
try {
|
|
169
193
|
const settings = JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8")) as {
|
|
@@ -175,108 +199,43 @@ function getInitialThinkingPhase(): ThinkingPhase {
|
|
|
175
199
|
}
|
|
176
200
|
}
|
|
177
201
|
|
|
178
|
-
export function isThinkingStreamEvent(eventType: string): boolean {
|
|
179
|
-
return eventType === "thinking_start" || eventType === "thinking_delta";
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const SHIMMER_FRAME_INTERVAL_MS = 80;
|
|
183
|
-
|
|
184
|
-
function shimmer(summary: string, theme: Theme, startedAt: number): string {
|
|
185
|
-
const characters = [...summary];
|
|
186
|
-
if (characters.length === 0) return summary;
|
|
187
|
-
const sweepTail = 5;
|
|
188
|
-
// Each logical section receives its own start time. The highlight therefore
|
|
189
|
-
// enters at its first character instead of inheriting another section's
|
|
190
|
-
// wall-clock phase.
|
|
191
|
-
const elapsed = Math.max(0, Date.now() - startedAt);
|
|
192
|
-
const center = Math.floor(elapsed / SHIMMER_FRAME_INTERVAL_MS) % (characters.length + sweepTail);
|
|
193
|
-
let currentColor: Parameters<Theme["fg"]>[0] | undefined;
|
|
194
|
-
let chunk = "";
|
|
195
|
-
let output = "";
|
|
196
|
-
for (let index = 0; index < characters.length; index++) {
|
|
197
|
-
const distance = Math.abs(index - center);
|
|
198
|
-
// Stay entirely in the sky-blue family: the active baseline is blue,
|
|
199
|
-
// and the sweep fades through light blue into a near-white core.
|
|
200
|
-
const color = distance === 0
|
|
201
|
-
? "userMessageText"
|
|
202
|
-
: distance === 1
|
|
203
|
-
? "text"
|
|
204
|
-
: distance === 2
|
|
205
|
-
? "thinkingMax"
|
|
206
|
-
: "thinkingXhigh";
|
|
207
|
-
if (currentColor !== undefined && color !== currentColor) {
|
|
208
|
-
output += theme.fg(currentColor, theme.bold(chunk));
|
|
209
|
-
chunk = "";
|
|
210
|
-
}
|
|
211
|
-
currentColor = color;
|
|
212
|
-
chunk += characters[index];
|
|
213
|
-
}
|
|
214
|
-
return currentColor === undefined ? output : output + theme.fg(currentColor, theme.bold(chunk));
|
|
215
|
-
}
|
|
216
|
-
|
|
217
202
|
export class ThinkingCycleController {
|
|
218
203
|
private phase: ThinkingPhase = "summary";
|
|
219
204
|
private unsubscribe: (() => void) | undefined;
|
|
220
205
|
private theme: Theme | undefined;
|
|
221
|
-
private
|
|
206
|
+
private hasToggleableThinking = false;
|
|
222
207
|
private refreshThinking: (() => void) | undefined;
|
|
223
|
-
private readonly summarySweepStartedAt = new Map<number, number>();
|
|
224
208
|
|
|
225
209
|
constructor(pi: ExtensionAPI) {
|
|
226
|
-
pi.on("message_start", () => this.resetSweep());
|
|
227
|
-
pi.on("message_update", (event) => {
|
|
228
|
-
// `context.isStreaming` describes the whole assistant message. Tool-call
|
|
229
|
-
// argument streaming therefore remains true after thinking has ended.
|
|
230
|
-
// Track the actual provider event so the sweep ends on thinking_end.
|
|
231
|
-
const eventType = event.assistantMessageEvent.type;
|
|
232
|
-
if (eventType === "thinking_start") this.summarySweepStartedAt.clear();
|
|
233
|
-
this.thinkingStreamActive = isThinkingStreamEvent(eventType);
|
|
234
|
-
});
|
|
235
|
-
pi.on("message_end", () => this.resetSweep());
|
|
236
|
-
pi.on("tool_call", () => this.resetSweep());
|
|
237
|
-
|
|
238
210
|
pi.registerMarkdownTransformer((markdown, context) => {
|
|
239
211
|
if (context.messageType !== "assistant-thinking") return markdown;
|
|
212
|
+
const sections = splitThinkingSections(markdown);
|
|
213
|
+
this.hasToggleableThinking ||= sectionsHaveDetail(sections);
|
|
240
214
|
const theme = this.theme;
|
|
241
215
|
const detailTextPrefix = theme?.getFgAnsi("thinkingText");
|
|
242
|
-
return
|
|
243
|
-
styleSummary: theme
|
|
244
|
-
? context.isStreaming && this.thinkingStreamActive
|
|
245
|
-
? (summary, sectionIndex) => {
|
|
246
|
-
let startedAt = this.summarySweepStartedAt.get(sectionIndex);
|
|
247
|
-
if (startedAt === undefined) {
|
|
248
|
-
startedAt = Date.now();
|
|
249
|
-
this.summarySweepStartedAt.set(sectionIndex, startedAt);
|
|
250
|
-
}
|
|
251
|
-
return shimmer(summary, theme, startedAt);
|
|
252
|
-
}
|
|
253
|
-
: (summary) => theme.fg("thinkingMax", theme.bold(summary))
|
|
254
|
-
: undefined,
|
|
216
|
+
return renderThinkingSections(sections, thinkingViewForPhase(this.phase), context.availableWidth, {
|
|
217
|
+
styleSummary: theme ? (summary) => theme.fg("thinkingMax", theme.bold(summary)) : undefined,
|
|
255
218
|
// border styling resets the foreground; resume detail gray immediately.
|
|
256
219
|
styleDetailPrefix: theme && detailTextPrefix
|
|
257
220
|
? (prefix) => theme.fg("border", prefix) + detailTextPrefix
|
|
258
221
|
: undefined,
|
|
259
222
|
controls: theme
|
|
260
|
-
?
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
223
|
+
? (hasDetail) => hasDetail
|
|
224
|
+
? theme.fg(
|
|
225
|
+
"borderAccent",
|
|
226
|
+
`${theme.italic("ctrl+t")} toggle • ${theme.italic("click")} to hide`,
|
|
227
|
+
)
|
|
228
|
+
: undefined
|
|
264
229
|
: undefined,
|
|
265
230
|
styleControlPrefix: theme ? (prefix) => theme.fg("border", prefix) : undefined,
|
|
266
231
|
});
|
|
267
232
|
});
|
|
268
233
|
}
|
|
269
234
|
|
|
270
|
-
private resetSweep(): void {
|
|
271
|
-
this.thinkingStreamActive = false;
|
|
272
|
-
this.summarySweepStartedAt.clear();
|
|
273
|
-
}
|
|
274
|
-
|
|
275
235
|
bind(ctx: ExtensionContext): void {
|
|
276
236
|
this.unsubscribe?.();
|
|
277
237
|
this.theme = ctx.ui.theme;
|
|
278
|
-
this.
|
|
279
|
-
this.summarySweepStartedAt.clear();
|
|
238
|
+
this.hasToggleableThinking = false;
|
|
280
239
|
// Match Pi's persisted host visibility after startup or /reload. When the
|
|
281
240
|
// host is hidden, Markdown transformers are not invoked at all. Pi wraps
|
|
282
241
|
// this label in thinkingText, so an inner thinkingMax span is required to
|
|
@@ -294,6 +253,9 @@ export class ThinkingCycleController {
|
|
|
294
253
|
// Kitty-capable macOS terminals report held keys as repeat events. Treating
|
|
295
254
|
// those as presses can skip detail before the user sees it.
|
|
296
255
|
if (input === "release" || input === "repeat") return { consume: true };
|
|
256
|
+
// With no provider-supplied detail, leave Ctrl+T to Pi's normal
|
|
257
|
+
// visible/hidden toggle instead of inserting an identical detail phase.
|
|
258
|
+
if (!this.hasToggleableThinking) return undefined;
|
|
297
259
|
this.phase = nextThinkingPhase(this.phase);
|
|
298
260
|
if (this.phase === "detail" || this.phase === "summary-after-detail") {
|
|
299
261
|
// Visible-to-visible transitions are owned by this extension. Rebuild the
|
|
@@ -312,8 +274,7 @@ export class ThinkingCycleController {
|
|
|
312
274
|
this.unsubscribe?.();
|
|
313
275
|
this.unsubscribe = undefined;
|
|
314
276
|
this.refreshThinking = undefined;
|
|
315
|
-
this.
|
|
316
|
-
this.summarySweepStartedAt.clear();
|
|
277
|
+
this.hasToggleableThinking = false;
|
|
317
278
|
this.theme = undefined;
|
|
318
279
|
}
|
|
319
280
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { Component } from "@earendil-works/pi-tui";
|
|
3
2
|
|
|
4
3
|
export const SUPPORTED_TOOLS = ["read", "write", "edit", "bash", "powershell", "grep", "find", "ls"] as const;
|
|
5
4
|
export const SUPPORTED_TOOL_SET = new Set<string>(SUPPORTED_TOOLS);
|
|
@@ -12,22 +11,26 @@ export type BuiltInDefinition = ToolDefinition<any, any, any>;
|
|
|
12
11
|
export interface CompactToolsConfig {
|
|
13
12
|
tools: CompactToolName[];
|
|
14
13
|
auto_compact: Record<CompactToolName, boolean>;
|
|
15
|
-
|
|
16
|
-
frames: string[];
|
|
17
|
-
intervalMs: number;
|
|
18
|
-
};
|
|
14
|
+
previewLines: number;
|
|
19
15
|
}
|
|
20
16
|
|
|
21
17
|
export interface RowState {
|
|
22
18
|
expanded?: boolean;
|
|
19
|
+
preview?: boolean;
|
|
23
20
|
hasResult?: boolean;
|
|
24
21
|
lastHostExpanded?: boolean;
|
|
25
22
|
configRevision?: object;
|
|
26
|
-
frame?: number;
|
|
27
23
|
startedAt?: number;
|
|
28
24
|
endedAt?: number;
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
resultLineSummary?: string;
|
|
26
|
+
resultLineSummaryComputed?: boolean;
|
|
27
|
+
lastResultContent?: unknown;
|
|
28
|
+
lastResultDetails?: unknown;
|
|
29
|
+
lastResultPartial?: boolean;
|
|
30
|
+
lastResultExpanded?: boolean;
|
|
31
|
+
lastResultPreview?: boolean;
|
|
32
|
+
lastResultError?: boolean;
|
|
33
|
+
lastResultConfigRevision?: object;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
type BaseRenderContext = Parameters<NonNullable<BuiltInDefinition["renderCall"]>>[2];
|