pi-compact-tools 0.6.0 → 0.6.3

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.
@@ -1,292 +1,337 @@
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
+ 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
+ isKeyRepeat,
7
+ Key,
8
+ matchesKey,
9
+ stripTerminalSequences,
10
+ truncateToWidth,
11
+ wrapTextWithAnsi,
12
+ } from "@earendil-works/pi-tui";
13
+
14
+ export type ThinkingView = "summary" | "detail" | "hidden";
15
+ export type ThinkingPhase = "summary" | "detail" | "summary-after-detail" | "hidden";
16
+
17
+ type ThinkingRenderOptions = {
18
+ styleSummary?: (summary: string, sectionIndex: number) => string;
19
+ styleDetailPrefix?: (prefix: string) => string;
20
+ controls?: string | ((hasDetail: boolean) => string | undefined);
21
+ styleControlPrefix?: (prefix: string) => string;
22
+ };
23
+
24
+ const NEXT_PHASE: Record<ThinkingPhase, ThinkingPhase> = {
25
+ summary: "detail",
26
+ detail: "summary-after-detail",
27
+ "summary-after-detail": "hidden",
28
+ hidden: "summary",
29
+ };
30
+
31
+ export function thinkingViewForPhase(phase: ThinkingPhase): ThinkingView {
32
+ return phase === "summary-after-detail" ? "summary" : phase;
33
+ }
34
+
35
+ export function nextThinkingPhase(phase: ThinkingPhase): ThinkingPhase {
36
+ return NEXT_PHASE[phase];
37
+ }
38
+
39
+ export type ThinkingToggleInput = "toggle" | "repeat" | "release";
40
+
41
+ export function classifyThinkingToggleInput(data: string): ThinkingToggleInput | undefined {
42
+ if (!matchesKey(data, Key.ctrl("t"))) return undefined;
43
+ if (isKeyRelease(data)) return "release";
44
+ return isKeyRepeat(data) ? "repeat" : "toggle";
45
+ }
46
+
47
+ function plainSummary(line: string): string {
48
+ return line
49
+ .replace(/^\s{0,3}#{1,6}\s+/u, "")
50
+ .replace(/^\s*[-*+]\s+/u, "")
51
+ .replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
52
+ .replace(/[*_~`]/gu, "")
53
+ .replace(/\s+/gu, " ")
54
+ .trim();
55
+ }
56
+
57
+ type ThinkingSection = { summary: string; detail: string };
58
+
59
+ function splitThinkingSections(markdown: string): ThinkingSection[] {
60
+ const normalized = markdown.includes("\r") ? markdown.replace(/\r\n?/gu, "\n") : markdown;
61
+ const lines = normalized.split("\n");
62
+ let first = -1;
63
+ let summary = "";
64
+ for (let index = 0; index < lines.length; index++) {
65
+ summary = plainSummary(lines[index]!);
66
+ if (summary) {
67
+ first = index;
68
+ break;
69
+ }
70
+ }
71
+ if (first < 0) return [{ summary: "Thinking...", detail: "" }];
72
+ const sections: ThinkingSection[] = [];
73
+ let detail: string[] = [];
74
+ let inFence = false;
75
+ const flush = () => {
76
+ sections.push({ summary, detail: detail.join("\n").trim() });
77
+ detail = [];
78
+ };
79
+ for (let index = first + 1; index < lines.length; index++) {
80
+ const line = lines[index]!;
81
+ const fence = /^\s*(```|~~~)/u.test(line);
82
+ if (!inFence) {
83
+ const boldHeading = line.match(/^\s*\*\*(.+?)\*\*\s*$/u)?.[1];
84
+ const markdownHeading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*$/u)?.[1];
85
+ const heading = boldHeading ?? markdownHeading;
86
+ if (heading) {
87
+ flush();
88
+ summary = plainSummary(heading);
89
+ continue;
90
+ }
91
+ }
92
+ detail.push(line);
93
+ if (fence) inFence = !inFence;
94
+ }
95
+ flush();
96
+ return sections;
97
+ }
98
+
99
+ function compactDetail(
100
+ detail: string,
101
+ availableWidth: number,
102
+ stylePrefix: (prefix: string) => string,
103
+ ): string {
104
+ const output: string[] = [];
105
+ let inFence = false;
106
+ for (const logicalLine of detail.replace(/\t/gu, " ").split("\n")) {
107
+ const fence = /^\s*(```|~~~)/u.test(logicalLine);
108
+ if (!inFence && fence) {
109
+ // Fences need a real blockquote and blank delimiter to remain valid Markdown.
110
+ output.push("", `> ${logicalLine}`);
111
+ inFence = true;
112
+ continue;
113
+ }
114
+ if (inFence) {
115
+ output.push(logicalLine ? `> ${logicalLine}` : ">");
116
+ if (fence) {
117
+ inFence = false;
118
+ output.push("");
119
+ }
120
+ continue;
121
+ }
122
+ if (!logicalLine) {
123
+ output.push(`${stylePrefix("│")} `);
124
+ continue;
125
+ }
126
+ const wrapped = wrapTextWithAnsi(logicalLine, Math.max(1, availableWidth - 2));
127
+ for (const line of wrapped) {
128
+ output.push(`${stylePrefix("│ ")}${stripTerminalSequences(line)} `);
129
+ }
130
+ }
131
+ return output.join("\n");
132
+ }
133
+
134
+ function hardBreak(lines: string[]): string {
135
+ return lines.map((line, index) => (index < lines.length - 1 ? `${line} ` : line)).join("\n");
136
+ }
137
+
138
+ export function hasThinkingDetail(markdown: string): boolean {
139
+ return splitThinkingSections(markdown).some(({ detail }) => detail.length > 0);
140
+ }
141
+
142
+ /** Display-only transformation; the original thinking remains unchanged in session/model context. */
143
+ export function renderThinkingView(
144
+ markdown: string,
145
+ view: ThinkingView,
146
+ availableWidth: number,
147
+ options: ThinkingRenderOptions = {},
148
+ ): string {
149
+ if (view === "hidden") return "Thinking...";
150
+ const controls = options.controls ?? ((hasDetail: boolean) =>
151
+ hasDetail ? "ctrl+t toggle • click to hide" : undefined);
152
+ const styleControlPrefix = options.styleControlPrefix ?? ((prefix: string) => prefix);
153
+ return splitThinkingSections(markdown)
154
+ .map(({ summary, detail }, sectionIndex) => {
155
+ const fittedSummary = stripTerminalSequences(
156
+ truncateToWidth(summary, Math.max(1, availableWidth), "…"),
157
+ );
158
+ const title = options.styleSummary?.(fittedSummary, sectionIndex) ?? fittedSummary;
159
+ const sectionControls = typeof controls === "function" ? controls(detail.length > 0) : controls;
160
+ const controlLines = sectionControls
161
+ ? wrapTextWithAnsi(sectionControls, Math.max(1, availableWidth - 3)).map(
162
+ (line, index) => `${index === 0 ? styleControlPrefix("└─ ") : " "}${line}`,
163
+ )
164
+ : [];
165
+ if (view === "detail" && detail) {
166
+ const styleDetailPrefix = options.styleDetailPrefix ?? ((prefix: string) => prefix);
167
+ const connector = `${styleDetailPrefix("│")} `;
168
+ const detailOutput = `${title} \n${connector}\n${compactDetail(detail, availableWidth, styleDetailPrefix)}`;
169
+ return controlLines.length > 0 ? `${detailOutput}\n${hardBreak(controlLines)}` : detailOutput;
170
+ }
171
+ return hardBreak([title, ...controlLines]);
172
+ })
173
+ .join("\n\n");
174
+ }
175
+
176
+ function getInitialThinkingPhase(): ThinkingPhase {
177
+ try {
178
+ const settings = JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8")) as {
179
+ hideThinkingBlock?: unknown;
180
+ };
181
+ return settings.hideThinkingBlock === true ? "hidden" : "summary";
182
+ } catch {
183
+ return "summary";
184
+ }
185
+ }
186
+
187
+ export function isThinkingStreamEvent(eventType: string): boolean {
188
+ return eventType === "thinking_start" || eventType === "thinking_delta";
189
+ }
190
+
191
+ const SHIMMER_FRAME_INTERVAL_MS = 80;
192
+
193
+ function shimmer(summary: string, theme: Theme, startedAt: number): string {
194
+ const characters = [...summary];
195
+ if (characters.length === 0) return summary;
196
+ const sweepTail = 5;
197
+ // Each logical section receives its own start time. The highlight therefore
198
+ // enters at its first character instead of inheriting another section's
199
+ // wall-clock phase.
200
+ const elapsed = Math.max(0, Date.now() - startedAt);
201
+ const center = Math.floor(elapsed / SHIMMER_FRAME_INTERVAL_MS) % (characters.length + sweepTail);
202
+ let currentColor: Parameters<Theme["fg"]>[0] | undefined;
203
+ let chunk = "";
204
+ let output = "";
205
+ for (let index = 0; index < characters.length; index++) {
206
+ const distance = Math.abs(index - center);
207
+ // Stay entirely in the sky-blue family: the active baseline is blue,
208
+ // and the sweep fades through light blue into a near-white core.
209
+ const color = distance === 0
210
+ ? "userMessageText"
211
+ : distance === 1
212
+ ? "text"
213
+ : distance === 2
214
+ ? "thinkingMax"
215
+ : "thinkingXhigh";
216
+ if (currentColor !== undefined && color !== currentColor) {
217
+ output += theme.fg(currentColor, theme.bold(chunk));
218
+ chunk = "";
219
+ }
220
+ currentColor = color;
221
+ chunk += characters[index];
222
+ }
223
+ return currentColor === undefined ? output : output + theme.fg(currentColor, theme.bold(chunk));
224
+ }
225
+
226
+ export class ThinkingCycleController {
227
+ private phase: ThinkingPhase = "summary";
228
+ private unsubscribe: (() => void) | undefined;
229
+ private theme: Theme | undefined;
230
+ private thinkingStreamActive = false;
231
+ private hasToggleableThinking = false;
232
+ private refreshThinking: (() => void) | undefined;
233
+ private readonly summarySweepStartedAt = new Map<number, number>();
234
+
235
+ constructor(pi: ExtensionAPI) {
236
+ pi.on("message_start", () => this.resetSweep());
237
+ pi.on("message_update", (event) => {
238
+ // `context.isStreaming` describes the whole assistant message. Tool-call
239
+ // argument streaming therefore remains true after thinking has ended.
240
+ // Track the actual provider event so the sweep ends on thinking_end.
241
+ const eventType = event.assistantMessageEvent.type;
242
+ if (eventType === "thinking_start") this.summarySweepStartedAt.clear();
243
+ this.thinkingStreamActive = isThinkingStreamEvent(eventType);
244
+ });
245
+ pi.on("message_end", () => this.resetSweep());
246
+ pi.on("tool_call", () => this.resetSweep());
247
+
248
+ pi.registerMarkdownTransformer((markdown, context) => {
249
+ if (context.messageType !== "assistant-thinking") return markdown;
250
+ this.hasToggleableThinking ||= hasThinkingDetail(markdown);
251
+ const theme = this.theme;
252
+ const detailTextPrefix = theme?.getFgAnsi("thinkingText");
253
+ return renderThinkingView(markdown, thinkingViewForPhase(this.phase), context.availableWidth, {
254
+ styleSummary: theme
255
+ ? context.isStreaming && this.thinkingStreamActive
256
+ ? (summary, sectionIndex) => {
257
+ let startedAt = this.summarySweepStartedAt.get(sectionIndex);
258
+ if (startedAt === undefined) {
259
+ startedAt = Date.now();
260
+ this.summarySweepStartedAt.set(sectionIndex, startedAt);
261
+ }
262
+ return shimmer(summary, theme, startedAt);
263
+ }
264
+ : (summary) => theme.fg("thinkingMax", theme.bold(summary))
265
+ : undefined,
266
+ // border styling resets the foreground; resume detail gray immediately.
267
+ styleDetailPrefix: theme && detailTextPrefix
268
+ ? (prefix) => theme.fg("border", prefix) + detailTextPrefix
269
+ : undefined,
270
+ controls: theme
271
+ ? (hasDetail) => hasDetail
272
+ ? theme.fg(
273
+ "borderAccent",
274
+ `${theme.italic("ctrl+t")} toggle • ${theme.italic("click")} to hide`,
275
+ )
276
+ : undefined
277
+ : undefined,
278
+ styleControlPrefix: theme ? (prefix) => theme.fg("border", prefix) : undefined,
279
+ });
280
+ });
281
+ }
282
+
283
+ private resetSweep(): void {
284
+ this.thinkingStreamActive = false;
285
+ this.summarySweepStartedAt.clear();
286
+ }
287
+
288
+ bind(ctx: ExtensionContext): void {
289
+ this.unsubscribe?.();
290
+ this.theme = ctx.ui.theme;
291
+ this.thinkingStreamActive = false;
292
+ this.hasToggleableThinking = false;
293
+ this.summarySweepStartedAt.clear();
294
+ // Match Pi's persisted host visibility after startup or /reload. When the
295
+ // host is hidden, Markdown transformers are not invoked at all. Pi wraps
296
+ // this label in thinkingText, so an inner thinkingMax span is required to
297
+ // keep it consistent with completed summary titles.
298
+ this.phase = getInitialThinkingPhase();
299
+ const hiddenLabel = this.theme.fg("thinkingMax", this.theme.bold("Thinking..."));
300
+ // Pi does not currently expose transcript invalidation directly. Updating
301
+ // this label rebuilds assistant Markdown and requests a render without
302
+ // changing the message or its hidden/visible state.
303
+ this.refreshThinking = () => ctx.ui.setHiddenThinkingLabel(hiddenLabel);
304
+ this.refreshThinking();
305
+ this.unsubscribe = ctx.ui.onTerminalInput((data) => {
306
+ const input = classifyThinkingToggleInput(data);
307
+ if (input === undefined) return undefined;
308
+ // Kitty-capable macOS terminals report held keys as repeat events. Treating
309
+ // those as presses can skip detail before the user sees it.
310
+ if (input === "release" || input === "repeat") return { consume: true };
311
+ // With no provider-supplied detail, leave Ctrl+T to Pi's normal
312
+ // visible/hidden toggle instead of inserting an identical detail phase.
313
+ if (!this.hasToggleableThinking) return undefined;
314
+ this.phase = nextThinkingPhase(this.phase);
315
+ if (this.phase === "detail" || this.phase === "summary-after-detail") {
316
+ // Visible-to-visible transitions are owned by this extension. Rebuild the
317
+ // Markdown and prevent Pi's built-in two-state toggle from hiding it.
318
+ this.refreshThinking?.();
319
+ return { consume: true };
320
+ }
321
+ // summary-after-detail -> hidden and hidden -> summary intentionally reach
322
+ // Pi's existing two-state handler. This keeps visibility/settings in sync
323
+ // and clears built-in per-block click overrides.
324
+ return undefined;
325
+ });
326
+ }
327
+
328
+ dispose(): void {
329
+ this.unsubscribe?.();
330
+ this.unsubscribe = undefined;
331
+ this.refreshThinking = undefined;
332
+ this.thinkingStreamActive = false;
333
+ this.hasToggleableThinking = false;
334
+ this.summarySweepStartedAt.clear();
335
+ this.theme = undefined;
336
+ }
337
+ }