pi-compact-tools 0.6.3 → 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.
@@ -1,337 +1,280 @@
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
- }
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
+ 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[],
148
+ view: ThinkingView,
149
+ availableWidth: number,
150
+ options: ThinkingRenderOptions = {},
151
+ ): string {
152
+ if (view === "hidden") return "Thinking...";
153
+ const controls = options.controls ?? ((hasDetail: boolean) =>
154
+ hasDetail ? "ctrl+t toggle • click to hide" : undefined);
155
+ const styleControlPrefix = options.styleControlPrefix ?? ((prefix: string) => prefix);
156
+ return sections
157
+ .map(({ summary, detail }, sectionIndex) => {
158
+ const fittedSummary = stripTerminalSequences(
159
+ truncateToWidth(summary, Math.max(1, availableWidth), "…"),
160
+ );
161
+ const title = options.styleSummary?.(fittedSummary, sectionIndex) ?? fittedSummary;
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
+ : [];
170
+ if (view === "detail" && detail) {
171
+ const styleDetailPrefix = options.styleDetailPrefix ?? ((prefix: string) => prefix);
172
+ const connector = `${styleDetailPrefix("│")} `;
173
+ const detailOutput = `${title} \n${connector}\n${compactDetail(detail, availableWidth, styleDetailPrefix)}`;
174
+ return controlLines.length > 0 ? `${detailOutput}\n${hardBreak(controlLines)}` : detailOutput;
175
+ }
176
+ return hardBreak([title, ...controlLines]);
177
+ })
178
+ .join("\n\n");
179
+ }
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
+
191
+ function getInitialThinkingPhase(): ThinkingPhase {
192
+ try {
193
+ const settings = JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8")) as {
194
+ hideThinkingBlock?: unknown;
195
+ };
196
+ return settings.hideThinkingBlock === true ? "hidden" : "summary";
197
+ } catch {
198
+ return "summary";
199
+ }
200
+ }
201
+
202
+ export class ThinkingCycleController {
203
+ private phase: ThinkingPhase = "summary";
204
+ private unsubscribe: (() => void) | undefined;
205
+ private theme: Theme | undefined;
206
+ private hasToggleableThinking = false;
207
+ private refreshThinking: (() => void) | undefined;
208
+
209
+ constructor(pi: ExtensionAPI) {
210
+ pi.registerMarkdownTransformer((markdown, context) => {
211
+ if (context.messageType !== "assistant-thinking") return markdown;
212
+ const sections = splitThinkingSections(markdown);
213
+ this.hasToggleableThinking ||= sectionsHaveDetail(sections);
214
+ const theme = this.theme;
215
+ const detailTextPrefix = theme?.getFgAnsi("thinkingText");
216
+ return renderThinkingSections(sections, thinkingViewForPhase(this.phase), context.availableWidth, {
217
+ styleSummary: theme ? (summary) => theme.fg("thinkingMax", theme.bold(summary)) : undefined,
218
+ // border styling resets the foreground; resume detail gray immediately.
219
+ styleDetailPrefix: theme && detailTextPrefix
220
+ ? (prefix) => theme.fg("border", prefix) + detailTextPrefix
221
+ : undefined,
222
+ controls: theme
223
+ ? (hasDetail) => hasDetail
224
+ ? theme.fg(
225
+ "borderAccent",
226
+ `${theme.italic("ctrl+t")} toggle • ${theme.italic("click")} to hide`,
227
+ )
228
+ : undefined
229
+ : undefined,
230
+ styleControlPrefix: theme ? (prefix) => theme.fg("border", prefix) : undefined,
231
+ });
232
+ });
233
+ }
234
+
235
+ bind(ctx: ExtensionContext): void {
236
+ this.unsubscribe?.();
237
+ this.theme = ctx.ui.theme;
238
+ this.hasToggleableThinking = false;
239
+ // Match Pi's persisted host visibility after startup or /reload. When the
240
+ // host is hidden, Markdown transformers are not invoked at all. Pi wraps
241
+ // this label in thinkingText, so an inner thinkingMax span is required to
242
+ // keep it consistent with completed summary titles.
243
+ this.phase = getInitialThinkingPhase();
244
+ const hiddenLabel = this.theme.fg("thinkingMax", this.theme.bold("Thinking..."));
245
+ // Pi does not currently expose transcript invalidation directly. Updating
246
+ // this label rebuilds assistant Markdown and requests a render without
247
+ // changing the message or its hidden/visible state.
248
+ this.refreshThinking = () => ctx.ui.setHiddenThinkingLabel(hiddenLabel);
249
+ this.refreshThinking();
250
+ this.unsubscribe = ctx.ui.onTerminalInput((data) => {
251
+ const input = classifyThinkingToggleInput(data);
252
+ if (input === undefined) return undefined;
253
+ // Kitty-capable macOS terminals report held keys as repeat events. Treating
254
+ // those as presses can skip detail before the user sees it.
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;
259
+ this.phase = nextThinkingPhase(this.phase);
260
+ if (this.phase === "detail" || this.phase === "summary-after-detail") {
261
+ // Visible-to-visible transitions are owned by this extension. Rebuild the
262
+ // Markdown and prevent Pi's built-in two-state toggle from hiding it.
263
+ this.refreshThinking?.();
264
+ return { consume: true };
265
+ }
266
+ // summary-after-detail -> hidden and hidden -> summary intentionally reach
267
+ // Pi's existing two-state handler. This keeps visibility/settings in sync
268
+ // and clears built-in per-block click overrides.
269
+ return undefined;
270
+ });
271
+ }
272
+
273
+ dispose(): void {
274
+ this.unsubscribe?.();
275
+ this.unsubscribe = undefined;
276
+ this.refreshThinking = undefined;
277
+ this.hasToggleableThinking = false;
278
+ this.theme = undefined;
279
+ }
280
+ }