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.
@@ -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 SPINNING_ROWS_KEY = Symbol.for("pi.compact-tools.spinning-rows");
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 trackedRows = new Map<string, TrackedRow>();
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
- get animatesRows(): boolean {
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
- for (const state of this.spinningRows) this.stopSpinner(state);
62
- this.trackedRows.clear();
49
+ this.stopIndicators();
63
50
  if (clearTimings) this.clearTimings();
64
51
  }
65
52
 
66
- track(ctx: RenderContext, name: CompactToolName, state: RowState): void {
67
- this.trackedRows.delete(ctx.toolCallId);
68
- this.trackedRows.set(ctx.toolCallId, { state, name, invalidate: ctx.invalidate });
69
- if (this.trackedRows.size <= MAX_TRACKED_ROWS) return;
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
- if (rows.length === 0) return undefined;
80
- const expand = rows.some(({ state }) => !state.expanded);
81
- for (const { state, invalidate } of rows) {
82
- state.expanded = expand;
83
- invalidate();
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 expand ? "expanded" : "collapsed";
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 || state.lastHostExpanded === undefined) {
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 = !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 initializeExpansion(state: RowState, name: CompactToolName): boolean {
137
- if (state.configRevision === this.configRevision) return false;
138
- state.configRevision = this.configRevision;
139
- state.expanded = !this.configValue.auto_compact[name];
140
- return true;
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 stopSpinner(state: RowState): void {
144
- if (state.timer) clearInterval(state.timer);
145
- state.timer = undefined;
146
- this.spinningRows.delete(state);
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 syncSpinner(state: RowState, running: boolean, invalidate: () => void): void {
150
- const spinner = this.configValue.spinner;
151
- if (!running || !this.animateRows || spinner.frames.length < 2) {
152
- this.stopSpinner(state);
153
- return;
154
- }
155
- if (state.timer) return;
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, includeDetail: boolean): ThinkingSection[] {
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: includeDetail ? detail.join("\n").trim() : "" });
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
- if (includeDetail) detail.push(line);
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
- /** Display-only transformation; the original thinking remains unchanged in session/model context. */
139
- export function renderThinkingView(
140
- markdown: string,
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 ?? "ctrl+t toggle • click to hide";
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 splitThinkingSections(markdown, view === "detail")
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 controlLines = wrapTextWithAnsi(controls, Math.max(1, availableWidth - 3)).map(
155
- (line, index) => `${index === 0 ? styleControlPrefix("└─ ") : " "}${line}`,
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
- return `${title} \n${connector}\n${compactDetail(detail, availableWidth, styleDetailPrefix)}\n${hardBreak(controlLines)}`;
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 thinkingStreamActive = false;
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 renderThinkingView(markdown, thinkingViewForPhase(this.phase), context.availableWidth, {
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
- ? theme.fg(
261
- "borderAccent",
262
- `${theme.italic("ctrl+t")} toggle • ${theme.italic("click")} to hide`,
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.thinkingStreamActive = false;
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.thinkingStreamActive = false;
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
- spinner: {
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
- timer?: ReturnType<typeof setInterval>;
30
- originalResultComponent?: Component;
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];