@quandev104/pi-style 0.1.3 → 0.1.5

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +8 -4
  3. package/dist/extensions/pi-style.js +3777 -958
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/command-service.ts +2 -0
  6. package/extension-src/pi-style/domain/config-normalization.ts +21 -5
  7. package/extension-src/pi-style/domain/config-presets.ts +1 -1
  8. package/extension-src/pi-style/domain/config-types.ts +7 -3
  9. package/extension-src/pi-style/domain/status.ts +1 -1
  10. package/extension-src/pi-style/domain/theme.ts +6 -1
  11. package/extension-src/pi-style/features/editor/index.ts +169 -27
  12. package/extension-src/pi-style/features/messages/index.ts +66 -0
  13. package/extension-src/pi-style/features/tools/bash-execution.ts +112 -0
  14. package/extension-src/pi-style/features/tools/boxed/bash.ts +430 -172
  15. package/extension-src/pi-style/features/tools/boxed/batch.ts +50 -27
  16. package/extension-src/pi-style/features/tools/boxed/command-shape.ts +136 -0
  17. package/extension-src/pi-style/features/tools/boxed/edit.ts +28 -2
  18. package/extension-src/pi-style/features/tools/boxed/fallback.ts +47 -4
  19. package/extension-src/pi-style/features/tools/boxed/find.ts +2 -2
  20. package/extension-src/pi-style/features/tools/boxed/gh.ts +1012 -0
  21. package/extension-src/pi-style/features/tools/boxed/git.ts +1960 -0
  22. package/extension-src/pi-style/features/tools/boxed/grep.ts +2 -2
  23. package/extension-src/pi-style/features/tools/boxed/output-tree.ts +9 -10
  24. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +25 -4
  25. package/extension-src/pi-style/features/tools/boxed/read.ts +3 -3
  26. package/extension-src/pi-style/features/tools/boxed/session-config.ts +64 -4
  27. package/extension-src/pi-style/features/tools/boxed/shared.ts +41 -1
  28. package/extension-src/pi-style/features/tools/boxed/write.ts +19 -2
  29. package/extension-src/pi-style/pi/compatibility-coordinator.ts +17 -0
  30. package/extension-src/pi-style/pi/compatibility-probe.ts +104 -10
  31. package/extension-src/pi-style/pi/compatibility-registry.ts +19 -3
  32. package/extension-src/pi-style/pi/index.ts +18 -1
  33. package/extension-src/pi-style/pi/session-coordinator.ts +33 -1
  34. package/extension-src/pi-style/shared/box.ts +86 -25
  35. package/extension-src/pi-style/shared/split-diff.ts +9 -9
  36. package/package.json +1 -1
  37. package/themes/titanium-light.json +82 -0
  38. package/themes/titanium.json +79 -0
  39. package/themes/.gitkeep +0 -0
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { Component } from "@earendil-works/pi-tui";
15
15
  import { stripAnsi } from "../../../shared/ansi.js";
16
- import { type BoxTheme, getTextOutput, shortenPath } from "../../../shared/box.js";
16
+ import { type BoxTheme, dimLine, getTextOutput, shortenPath } from "../../../shared/box.js";
17
17
  import { safeTruncateToWidth } from "../../../shared/render-budget.js";
18
18
  import {
19
19
  type GrepMatch,
@@ -111,7 +111,7 @@ function renderErrorLines(theme: BoxTheme, errorText: string, width: number): st
111
111
  .map((line) => line.trim())
112
112
  .filter((line) => line.length > 0);
113
113
  if (raw.length === 0) return [];
114
- const prefix = `${TREE_INDENT}${theme.fg("borderMuted", "└─")} `;
114
+ const prefix = `${TREE_INDENT}${dimLine("└─")} `;
115
115
  const out = raw
116
116
  .slice(0, GREP_ERROR_LINES)
117
117
  .map((line) => safeTruncateToWidth(`${prefix}${theme.fg("error", line)}`, Math.max(1, width), "…"));
@@ -20,6 +20,7 @@
20
20
  // as one visual family.
21
21
 
22
22
  import type { BoxTheme } from "../../../shared/box.js";
23
+ import { dimLine } from "../../../shared/box.js";
23
24
  import { safeTruncateToWidth } from "../../../shared/render-budget.js";
24
25
 
25
26
  /** Indent for top-level tree rows; matches the quiet-tool batch panel. */
@@ -251,11 +252,11 @@ export function renderOutputTree(
251
252
  const lastIndex = visible.length - 1;
252
253
  for (let i = 0; i < visible.length; i++) {
253
254
  const branch = i < lastIndex || more > 0 ? "├─" : "└─";
254
- const line = `${indent}${theme.fg("borderMuted", branch)} ${theme.fg(entryColor, label(visible[i] ?? ""))}`;
255
+ const line = `${indent}${dimLine(branch)} ${theme.fg(entryColor, label(visible[i] ?? ""))}`;
255
256
  out.push(safeTruncateToWidth(line, safeWidth, "…"));
256
257
  }
257
258
  if (more > 0) {
258
- const line = `${indent}${theme.fg("borderMuted", "└─")} ${theme.fg("dim", `… ${more} more ${pluralForm(moreUnit, more)}`)}`;
259
+ const line = `${indent}${dimLine("└─")} ${theme.fg("dim", `… ${more} more ${pluralForm(moreUnit, more)}`)}`;
259
260
  out.push(safeTruncateToWidth(line, safeWidth, "…"));
260
261
  }
261
262
  return out;
@@ -274,7 +275,7 @@ function formatMatchRow(theme: BoxTheme, match: GrepMatch): string {
274
275
  // Match rows render in the output text color (not primary) so they read like
275
276
  // the matched code; only the file nodes carry the primary color.
276
277
  const label = theme.fg("toolOutput", `*${match.line}`);
277
- const sep = theme.fg("borderMuted", "│");
278
+ const sep = dimLine("│");
278
279
  return `${label}${sep} ${theme.fg("toolOutput", match.content)}`;
279
280
  }
280
281
 
@@ -313,7 +314,7 @@ export function renderGrepTree(
313
314
  if (singleFile) {
314
315
  budget.forEach((match, index) => {
315
316
  const isLast = index === totalVisible - 1 && !truncated;
316
- push(`${indent}${theme.fg("borderMuted", isLast ? "└─" : "├─")} ${formatMatchRow(theme, match)}`);
317
+ push(`${indent}${dimLine(isLast ? "└─" : "├─")} ${formatMatchRow(theme, match)}`);
317
318
  });
318
319
  } else {
319
320
  // Walk the budget, tracking position within each file group so the file
@@ -323,7 +324,7 @@ export function renderGrepTree(
323
324
  const group = groups[gi];
324
325
  if (!group) continue;
325
326
  const isLastGroup = gi === groups.length - 1;
326
- const trunk = isLastGroup ? " " : theme.fg("borderMuted", "│");
327
+ const trunk = isLastGroup ? " " : dimLine("│");
327
328
 
328
329
  const visibleHere: GrepMatch[] = [];
329
330
  for (const match of group.matches) {
@@ -336,22 +337,20 @@ export function renderGrepTree(
336
337
  const groupIsLastRendered = shown >= totalVisible && !truncated;
337
338
  const fileLabel = options.withIcons ? `${fileIcon(group.file)} ${group.file}` : group.file;
338
339
  // File nodes use the primary (accent) color, matching read/ls/find paths.
339
- push(`${indent}${theme.fg("borderMuted", groupIsLastRendered ? "└─" : "├─")} ${theme.fg("accent", fileLabel)}`);
340
+ push(`${indent}${dimLine(groupIsLastRendered ? "└─" : "├─")} ${theme.fg("accent", fileLabel)}`);
340
341
 
341
342
  visibleHere.forEach((match, index) => {
342
343
  const isLastInGroup = index === visibleHere.length - 1;
343
344
  const isLastOverall = groupIsLastRendered && isLastInGroup;
344
345
  push(
345
- `${indent}${trunk}${TREE_CHILD_INDENT}${theme.fg("borderMuted", isLastOverall ? "└─" : "├─")} ${formatMatchRow(theme, match)}`,
346
+ `${indent}${trunk}${TREE_CHILD_INDENT}${dimLine(isLastOverall ? "└─" : "├─")} ${formatMatchRow(theme, match)}`,
346
347
  );
347
348
  });
348
349
  }
349
350
  }
350
351
 
351
352
  if (truncated) {
352
- push(
353
- `${indent}${theme.fg("borderMuted", "└─")} ${theme.fg("dim", `… ${remaining} more ${pluralForm("match", remaining)}`)}`,
354
- );
353
+ push(`${indent}${dimLine("└─")} ${theme.fg("dim", `… ${remaining} more ${pluralForm("match", remaining)}`)}`);
355
354
  }
356
355
  return out;
357
356
  }
@@ -4,19 +4,35 @@ import { getLanguageFromPath } from "@earendil-works/pi-coding-agent";
4
4
  import { stripAnsi } from "../../../shared/ansi.js";
5
5
  import {
6
6
  type BoxTheme,
7
- boxInnerWidth,
7
+ formatBoxedRunningStatus,
8
8
  getTextOutput,
9
9
  renderBoxedToolCall,
10
10
  renderBoxedToolResult,
11
11
  } from "../../../shared/box.js";
12
12
  import { formatElapsedMs, getElapsedMs } from "../../../shared/elapsed.js";
13
13
  import { AdaptiveDiffComponent, buildSplitRows, countDiffStats } from "../../../shared/split-diff.js";
14
- import { getStateElapsedMs } from "./session-config.js";
15
- import { type BoxedToolContext, type BoxedToolDefinition, displayPath, noteExecutionStart } from "./shared.js";
14
+ import { getStateElapsedMs, isResultSeen } from "./session-config.js";
15
+ import {
16
+ type BoxedToolContext,
17
+ type BoxedToolDefinition,
18
+ displayPath,
19
+ noteBoxedCallState,
20
+ noteBoxedResultPhase,
21
+ noteExecutionStart,
22
+ stateElapsedMs,
23
+ } from "./shared.js";
16
24
 
17
25
  const MAX_HIGHLIGHT_DIFF_CHARS = 12000;
18
26
  const MAX_HIGHLIGHT_DIFF_ROWS = 120;
19
27
 
28
+ /** First-partial-pass result: the pending/running call card stands alone. */
29
+ const EMPTY_QUICK_EDIT_RESULT = Object.freeze({
30
+ invalidate() {},
31
+ render() {
32
+ return [];
33
+ },
34
+ });
35
+
20
36
  interface QuickEditToolConfig {
21
37
  toolLabel: string;
22
38
  applyingLabel: string;
@@ -131,10 +147,12 @@ function renderQuickEditResult(
131
147
  config: QuickEditToolConfig,
132
148
  ) {
133
149
  if (options.isPartial) {
150
+ const firstResultPass = noteBoxedResultPhase(context, options.isPartial);
151
+ if (firstResultPass) return EMPTY_QUICK_EDIT_RESULT;
134
152
  return renderBoxedToolResult(
135
153
  theme,
136
154
  () => [`${theme.fg("dim", "↳")} ${theme.fg("muted", `Applying ${config.applyingLabel}...`)}`],
137
- { isPartial: true },
155
+ { showDivider: false, footerLines: [formatBoxedRunningStatus(theme, stateElapsedMs(context))], isPartial: true },
138
156
  );
139
157
  }
140
158
 
@@ -196,12 +214,15 @@ export function quickEditTool(config: QuickEditToolConfig): BoxedToolDefinition
196
214
  return {
197
215
  call(args, theme, context) {
198
216
  noteExecutionStart(context);
217
+ noteBoxedCallState(context);
199
218
  const detail = displayPath(String(args?.path ?? ""), context);
200
219
  return renderBoxedToolCall(theme, config.toolLabel, [], {
201
220
  headerDetail: detail,
202
221
  isError: Boolean(context.isError),
203
222
  isPartial: Boolean(context.isPartial),
204
223
  isPending: Boolean(context.isPartial),
224
+ running: Boolean(context.executionStarted),
225
+ resultSeen: isResultSeen(context.state),
205
226
  });
206
227
  },
207
228
  result(result, options, theme, context) {
@@ -1,9 +1,9 @@
1
1
  // Boxed read tool renderer
2
2
  // (renderCall/renderResult only; no tool re-registration).
3
3
  //
4
- // Read calls render as a boxless tree panel — a lone read is a batch of one,
5
- // consecutive reads group into one panel (see batch.ts). There is no boxed
6
- // single-call special case.
4
+ // Read calls render boxless: a lone read is a single inline line
5
+ // (`➔ Read <path>`), consecutive reads group into one tree panel (see
6
+ // batch.ts).
7
7
 
8
8
  import { stripAnsi } from "../../../shared/ansi.js";
9
9
  import { getTextOutput } from "../../../shared/box.js";
@@ -33,19 +33,79 @@ export function getToolsRenderConfig(): ToolsRenderConfig {
33
33
 
34
34
  // Wall-clock elapsed tracking through the renderer context state (no tool
35
35
  // re-registration, so result.details has no execution timing).
36
+ //
37
+ // Elapsed is computed live from the recorded start on every read, so a running
38
+ // tool keeps growing its displayed time. The value only freezes once the
39
+ // execution end is recorded (terminal result), which keeps the completed footer
40
+ // stable across later re-renders (expand toggles, terminal resizes).
36
41
 
37
42
  const STARTED_AT_KEY = "__piStyleStartedAt";
38
- const ELAPSED_MS_KEY = "__piStyleElapsedMs";
43
+ const ENDED_AT_KEY = "__piStyleEndedAt";
44
+ const RESULT_SEEN_KEY = "__piStyleResultSeen";
45
+ const TICKER_KEY = "__piStyleElapsedTicker";
39
46
 
40
47
  export function recordExecutionStarted(state: Record<string, unknown> | undefined, executionStarted: boolean): void {
41
48
  if (!executionStarted || !state || typeof state !== "object") return;
42
49
  if (typeof state[STARTED_AT_KEY] !== "number") state[STARTED_AT_KEY] = performance.now();
43
50
  }
44
51
 
52
+ /** Freeze the elapsed at the first terminal render (idempotent). */
53
+ export function recordExecutionEnded(state: Record<string, unknown> | undefined): void {
54
+ if (!state || typeof state !== "object") return;
55
+ if (typeof state[ENDED_AT_KEY] !== "number") state[ENDED_AT_KEY] = performance.now();
56
+ }
57
+
45
58
  export function getStateElapsedMs(state: Record<string, unknown> | undefined): number | undefined {
46
59
  if (!state || typeof state !== "object") return undefined;
47
- if (typeof state[ELAPSED_MS_KEY] !== "number" && typeof state[STARTED_AT_KEY] === "number") {
48
- state[ELAPSED_MS_KEY] = performance.now() - state[STARTED_AT_KEY];
60
+ const started = state[STARTED_AT_KEY];
61
+ if (typeof started !== "number") return undefined;
62
+ const ended = state[ENDED_AT_KEY];
63
+ if (typeof ended === "number") return Math.max(0, ended - started);
64
+ return Math.max(0, performance.now() - started);
65
+ }
66
+
67
+ /** Whether a result renderer already produced a continuation for this call. */
68
+ export function isResultSeen(state: Record<string, unknown> | undefined): boolean {
69
+ return Boolean(state && typeof state === "object" && state[RESULT_SEEN_KEY] === true);
70
+ }
71
+
72
+ /** Record that a result renderer ran for this call (streaming or final). */
73
+ export function markResultSeen(state: Record<string, unknown> | undefined): void {
74
+ if (!state || typeof state !== "object") return;
75
+ state[RESULT_SEEN_KEY] = true;
76
+ }
77
+
78
+ type TickerHandle = ReturnType<typeof setInterval>;
79
+
80
+ /** States that currently own a 1s elapsed-render interval, for session cleanup. */
81
+ const tickerStates = new Set<Record<string, unknown>>();
82
+
83
+ /**
84
+ * While a tool is running, re-render once per second so live elapsed labels
85
+ * tick without any output events. Idempotent per state.
86
+ */
87
+ export function startElapsedTicker(state: Record<string, unknown> | undefined, invalidate: () => void): void {
88
+ if (!state || typeof state !== "object") return;
89
+ if (state[TICKER_KEY] !== undefined) return;
90
+ state[TICKER_KEY] = setInterval(() => invalidate(), 1000) as unknown as TickerHandle;
91
+ tickerStates.add(state);
92
+ }
93
+
94
+ /** Stop a running tool's elapsed ticker (terminal result, error, session end). */
95
+ export function stopElapsedTicker(state: Record<string, unknown> | undefined): void {
96
+ if (!state || typeof state !== "object") return;
97
+ const handle = state[TICKER_KEY] as TickerHandle | undefined;
98
+ if (handle !== undefined) clearInterval(handle);
99
+ delete state[TICKER_KEY];
100
+ tickerStates.delete(state);
101
+ }
102
+
103
+ /** Stop every elapsed ticker (session start/shutdown). */
104
+ export function stopAllElapsedTickers(): void {
105
+ for (const state of tickerStates) {
106
+ const handle = state[TICKER_KEY] as TickerHandle | undefined;
107
+ if (handle !== undefined) clearInterval(handle);
108
+ delete state[TICKER_KEY];
49
109
  }
50
- return typeof state[ELAPSED_MS_KEY] === "number" ? state[ELAPSED_MS_KEY] : undefined;
110
+ tickerStates.clear();
51
111
  }
@@ -13,12 +13,21 @@ import {
13
13
  resolveRelativePath,
14
14
  shortenPath,
15
15
  } from "../../../shared/box.js";
16
- import { getStateElapsedMs, recordExecutionStarted } from "./session-config.js";
16
+ import {
17
+ getStateElapsedMs,
18
+ isResultSeen,
19
+ markResultSeen,
20
+ recordExecutionEnded,
21
+ recordExecutionStarted,
22
+ startElapsedTicker,
23
+ stopElapsedTicker,
24
+ } from "./session-config.js";
17
25
 
18
26
  /** Renderer context delivered by Pi's ToolExecutionComponent (getRenderContext). */
19
27
  export interface BoxedToolContext {
20
28
  readonly args: Record<string, unknown>;
21
29
  readonly toolCallId: string;
30
+ readonly invalidate: () => void;
22
31
  readonly state: Record<string, unknown>;
23
32
  readonly cwd: string;
24
33
  readonly executionStarted: boolean;
@@ -88,6 +97,7 @@ export function compactCall(
88
97
  isError: Boolean(options.context.isError),
89
98
  isPartial: Boolean(options.context.isPartial),
90
99
  isPending: pendingFlag(options.context),
100
+ running: Boolean(options.context.executionStarted),
91
101
  });
92
102
  }
93
103
 
@@ -96,6 +106,36 @@ export function noteExecutionStart(context: BoxedToolContext): void {
96
106
  recordExecutionStarted(context.state, context.executionStarted);
97
107
  }
98
108
 
109
+ /**
110
+ * Keep running/ended execution state in sync from a call renderer pass. While
111
+ * the tool runs, a 1s re-render ticker keeps live elapsed labels current; once
112
+ * the call renders in its terminal form the elapsed freezes.
113
+ */
114
+ export function noteBoxedCallState(context: BoxedToolContext): void {
115
+ if (!context.executionStarted) return;
116
+ if (context.isPartial) startElapsedTicker(context.state, context.invalidate);
117
+ else {
118
+ recordExecutionEnded(context.state);
119
+ stopElapsedTicker(context.state);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Record a result renderer pass and keep the ticker/ended state in sync.
125
+ * Returns whether this is the first result pass for the call, so renderers can
126
+ * render nothing while the pending/running call card stands alone.
127
+ */
128
+ export function noteBoxedResultPhase(context: BoxedToolContext, isPartial: boolean): boolean {
129
+ const firstResultPass = !isResultSeen(context.state);
130
+ markResultSeen(context.state);
131
+ if (isPartial) startElapsedTicker(context.state, context.invalidate);
132
+ else {
133
+ recordExecutionEnded(context.state);
134
+ stopElapsedTicker(context.state);
135
+ }
136
+ return firstResultPass;
137
+ }
138
+
99
139
  export function stateElapsedMs(context: BoxedToolContext): number | undefined {
100
140
  return getStateElapsedMs(context.state);
101
141
  }
@@ -15,6 +15,7 @@ import { stripAnsi } from "../../../shared/ansi.js";
15
15
  import {
16
16
  type BoxTheme,
17
17
  boxedToolWidthKey,
18
+ dimLine,
18
19
  getTextOutput,
19
20
  renderBoxedToolResult,
20
21
  renderCompactBoxedToolCall,
@@ -34,6 +35,14 @@ import {
34
35
  /** Right-side bottom-border hint shown when the compact preview is truncated. */
35
36
  const WRITE_EXPAND_HINT = "Ctrl+O for more";
36
37
 
38
+ /** Partial-pass result: the compact call keeps its `◌ Running` card. */
39
+ const EMPTY_WRITE_RESULT: Component = Object.freeze({
40
+ invalidate() {},
41
+ render() {
42
+ return [];
43
+ },
44
+ });
45
+
37
46
  type NumberedLine = { number: string; content: string };
38
47
 
39
48
  /**
@@ -54,7 +63,7 @@ function numberedPreviewLines(content: string): NumberedLine[] {
54
63
 
55
64
  /** One boxed preview row: dim gutter + toolOutput content. */
56
65
  function formatNumberedLine(theme: BoxTheme, line: NumberedLine): string {
57
- return `${theme.fg("borderMuted", `${line.number} `)}${theme.fg("toolOutput", line.content)}`;
66
+ return `${dimLine(`${line.number} `)}${theme.fg("toolOutput", line.content)}`;
58
67
  }
59
68
 
60
69
  /** Compact write box: path header, numbered content preview, metrics footer. */
@@ -66,6 +75,7 @@ function renderWritePreviewBox(
66
75
  state?: Record<string, unknown>;
67
76
  isError: boolean;
68
77
  isPending: boolean;
78
+ running?: boolean;
69
79
  expanded: boolean;
70
80
  },
71
81
  ): Component {
@@ -78,6 +88,7 @@ function renderWritePreviewBox(
78
88
  ...(options.state ? { state: options.state } : {}),
79
89
  isError: options.isError,
80
90
  isPending: options.isPending,
91
+ running: Boolean(options.running),
81
92
  bodyLines: () => {
82
93
  if (preview.length === 0) return [];
83
94
  const shown = preview.slice(0, budget).map((line) => formatNumberedLine(theme, line));
@@ -107,10 +118,11 @@ export const writeTool: BoxedToolDefinition = {
107
118
  state: context.state,
108
119
  isError: Boolean(context.isError),
109
120
  isPending: Boolean(context.isPartial),
121
+ running: Boolean(context.executionStarted),
110
122
  expanded: Boolean(context.expanded),
111
123
  });
112
124
  },
113
- result(result, _options, theme, context) {
125
+ result(result, options, theme, context) {
114
126
  clearFooterState(context);
115
127
  const output = getTextOutput(result);
116
128
  const detail = displayPath(String(context?.args?.path ?? context?.args?.file_path ?? ""), context);
@@ -124,6 +136,11 @@ export const writeTool: BoxedToolDefinition = {
124
136
  });
125
137
  }
126
138
 
139
+ // While the result is still streaming, don't stamp a metrics footer into
140
+ // the shared state: the compact call keeps its `◌ Running` card and only
141
+ // closes with `elapsed · words` once the tool settles.
142
+ if (options.isPartial) return EMPTY_WRITE_RESULT;
143
+
127
144
  // Success (compact and expanded): the preview box closes with the metrics
128
145
  // footer stored into the shared renderer state; the result adds nothing.
129
146
  return compactFooterWithState(theme, result, context);
@@ -125,6 +125,21 @@ export function createCompatibilityCoordinator(dispose = disposePiCompatibilityP
125
125
  config,
126
126
  });
127
127
  const messagesEnabled = (assistantEnabled || specialBlocksEnabled) && config.messages.enabled;
128
+ // The hidden-thinking collapse is an assistant-message surface patch: it needs
129
+ // the assistant flag and `messages.hideThinkingLabel`, independent of the
130
+ // assistant prefix feature.
131
+ const thinkingCollapseEnabled = Boolean(
132
+ authorization.assistant &&
133
+ config.messages.enabled &&
134
+ config.messages.hideThinkingLabel &&
135
+ isTierCAuthorized({
136
+ certifiedHost,
137
+ coreFlag: authorization.core,
138
+ surfaceFlag: true,
139
+ surface: "messages",
140
+ config,
141
+ }),
142
+ );
128
143
  const toolsEnabled =
129
144
  authorization.tools &&
130
145
  isTierCAuthorized({ certifiedHost, coreFlag: authorization.core, surfaceFlag: true, surface: "tools", config });
@@ -137,6 +152,7 @@ export function createCompatibilityCoordinator(dispose = disposePiCompatibilityP
137
152
  ...config.messages,
138
153
  enabled: messagesEnabled,
139
154
  assistantPrefix: assistantEnabled,
155
+ hideThinkingLabel: thinkingCollapseEnabled,
140
156
  specialBlocks: messagesEnabled && config.messages.specialBlocks && specialBlocksEnabled,
141
157
  },
142
158
  tools: {
@@ -147,6 +163,7 @@ export function createCompatibilityCoordinator(dispose = disposePiCompatibilityP
147
163
  messageSnapshot: {
148
164
  assistantPrefix: authorization.ascii ? "[assistant] " : "│ ",
149
165
  assistantEnabled,
166
+ collapseHiddenThinking: thinkingCollapseEnabled,
150
167
  },
151
168
  toolSnapshot: {
152
169
  callMarker: authorization.ascii ? "[tool] " : "[tool] ",
@@ -3,14 +3,20 @@ import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import {
5
5
  AssistantMessageComponent,
6
+ BashExecutionComponent,
6
7
  BranchSummaryMessageComponent,
7
8
  CompactionSummaryMessageComponent,
8
9
  CustomMessageComponent,
9
10
  SkillInvocationMessageComponent,
10
11
  ToolExecutionComponent,
11
12
  } from "@earendil-works/pi-coding-agent";
12
- import { decorateMessageRender, type MessageDecorationSnapshot } from "../features/messages/index.js";
13
+ import {
14
+ decorateMessageRender,
15
+ decorateMessageUpdate,
16
+ type MessageDecorationSnapshot,
17
+ } from "../features/messages/index.js";
13
18
  import { renderSpecialMessageBlock, type SpecialBlockSubtype } from "../features/messages/special-blocks.js";
19
+ import { renderBashExecutionBox } from "../features/tools/bash-execution.js";
14
20
  import { createToolDecorationOwner } from "../features/tools/index.js";
15
21
  import {
16
22
  type CompatibilityRecord,
@@ -31,12 +37,14 @@ const reportStates = new WeakMap<
31
37
  // fail closed on every unrecorded Pi build and never use module-load capture as trust.
32
38
  export const TRUSTED_NATIVE_FINGERPRINTS: Readonly<Record<string, string>> = Object.freeze({
33
39
  "native-assistant-message:render": "2a39243f",
40
+ "native-assistant-message:updateContent": "4a2f15ff",
34
41
  "native-compaction-message:updateDisplay": "f8c44e78",
35
42
  "native-branch-message:updateDisplay": "415d57b7",
36
43
  "native-skill-message:updateDisplay": "48099ea6",
37
44
  "native-custom-message:rebuild": "76ae2e3a",
38
45
  "tool-call-renderer:getCallRenderer": "951ea0e0",
39
46
  "tool-result-renderer:getResultRenderer": "8a25cd71",
47
+ "native-bash-execution:render": "a5b5abca",
40
48
  });
41
49
 
42
50
  export const CERTIFICATION_TABLE = Object.freeze({
@@ -80,6 +88,19 @@ export const CERTIFICATION_TABLE = Object.freeze({
80
88
  adapterId: "tool-renderer-component-v1",
81
89
  status: "certified" as const,
82
90
  }),
91
+ "native-assistant-message:updateContent": Object.freeze({
92
+ feature: "messages",
93
+ subtype: "native-assistant-message",
94
+ target: AssistantMessageComponent.prototype,
95
+ method: "updateContent",
96
+ writable: true,
97
+ configurable: true,
98
+ name: "updateContent",
99
+ arity: 1,
100
+ fingerprint: TRUSTED_NATIVE_FINGERPRINTS["native-assistant-message:updateContent"],
101
+ adapterId: "message-thinking-collapse-v1",
102
+ status: "certified" as const,
103
+ }),
83
104
  "native-compaction-message:updateDisplay": Object.freeze({
84
105
  feature: "messages",
85
106
  subtype: "native-compaction-message",
@@ -120,6 +141,23 @@ export const CERTIFICATION_TABLE = Object.freeze({
120
141
  adapterId: "message-block-boxed-v1",
121
142
  status: "certified" as const,
122
143
  }),
144
+ "native-bash-execution:render": Object.freeze({
145
+ feature: "tools",
146
+ subtype: "native-bash-execution",
147
+ target: BashExecutionComponent.prototype,
148
+ method: "render",
149
+ writable: true,
150
+ configurable: true,
151
+ // The additive render patch is certified by the class constructor identity
152
+ // (name/arity/source fingerprint): the class defines no own `render`, so
153
+ // the installed own method is the only one and the inherited Container
154
+ // render is the native fallback.
155
+ name: "BashExecutionComponent",
156
+ arity: 2,
157
+ fingerprint: TRUSTED_NATIVE_FINGERPRINTS["native-bash-execution:render"],
158
+ adapterId: "bash-execution-box-v1",
159
+ status: "certified" as const,
160
+ }),
123
161
  }),
124
162
  });
125
163
 
@@ -187,6 +225,12 @@ export interface TargetSpec {
187
225
  adapterId: string | undefined;
188
226
  status: "certified" | "native-fallback";
189
227
  fallbackReason?: string;
228
+ /** "add-method" installs a new own method; the class constructor fingerprint certifies the target. */
229
+ kind?: "method" | "add-method";
230
+ /** Function name to verify (defaults to `method`; additive patches verify the class constructor name). */
231
+ identityName?: string;
232
+ /** Expected arity (defaults to the standard per-method rule; additive patches verify the constructor arity). */
233
+ arity?: number;
190
234
  }
191
235
 
192
236
  export function fingerprint(value: unknown): string | undefined {
@@ -201,15 +245,33 @@ export function fingerprint(value: unknown): string | undefined {
201
245
 
202
246
  function trustedNativeIdentity(spec: TargetSpec, piVersion: string | undefined): unknown {
203
247
  if (piVersion !== TRUSTED_PI_VERSION) return undefined;
248
+ if (spec.kind === "add-method") {
249
+ // Additive install: the prototype must not already own the method (it is
250
+ // inherited), the class constructor identity must match the recorded build,
251
+ // and the inherited method becomes the native fallback for the delegate.
252
+ if (Object.getOwnPropertyDescriptor(spec.target, spec.method)) return undefined;
253
+ const ctor = Object.getOwnPropertyDescriptor(spec.target, "constructor")?.value;
254
+ const key = `${spec.subtype}:${spec.method}`;
255
+ if (
256
+ typeof ctor !== "function" ||
257
+ ctor.name !== (spec.identityName ?? spec.method) ||
258
+ ctor.length !== (spec.arity ?? 0) ||
259
+ fingerprint(ctor) !== TRUSTED_NATIVE_FINGERPRINTS[key]
260
+ )
261
+ return undefined;
262
+ const inherited = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(spec.target), spec.method)?.value;
263
+ return typeof inherited === "function" ? inherited : undefined;
264
+ }
204
265
  const descriptor = Object.getOwnPropertyDescriptor(spec.target, spec.method);
205
266
  const value = descriptor?.value;
206
267
  const key = `${spec.subtype}:${spec.method}`;
268
+ const expectedArity = spec.arity ?? (spec.method === "render" || spec.method === "updateContent" ? 1 : 0);
207
269
  if (
208
270
  descriptor?.writable !== true ||
209
271
  descriptor.configurable !== true ||
210
272
  typeof value !== "function" ||
211
- value.name !== spec.method ||
212
- value.length !== (spec.method === "render" ? 1 : 0) ||
273
+ value.name !== (spec.identityName ?? spec.method) ||
274
+ value.length !== expectedArity ||
213
275
  fingerprint(value) !== TRUSTED_NATIVE_FINGERPRINTS[key]
214
276
  )
215
277
  return undefined;
@@ -225,6 +287,14 @@ export const targetSpecs: readonly TargetSpec[] = [
225
287
  adapterId: "message-prefix-osc133-v1",
226
288
  status: "certified",
227
289
  },
290
+ {
291
+ feature: "messages",
292
+ subtype: "native-assistant-message",
293
+ target: AssistantMessageComponent.prototype,
294
+ method: "updateContent",
295
+ adapterId: "message-thinking-collapse-v1",
296
+ status: "certified",
297
+ },
228
298
  {
229
299
  feature: "messages",
230
300
  subtype: "native-compaction-message",
@@ -273,6 +343,17 @@ export const targetSpecs: readonly TargetSpec[] = [
273
343
  adapterId: "tool-renderer-component-v1",
274
344
  status: "certified",
275
345
  },
346
+ {
347
+ feature: "tools",
348
+ subtype: "native-bash-execution",
349
+ target: BashExecutionComponent.prototype,
350
+ method: "render",
351
+ kind: "add-method",
352
+ identityName: "BashExecutionComponent",
353
+ arity: 2,
354
+ adapterId: "bash-execution-box-v1",
355
+ status: "certified",
356
+ },
276
357
  ];
277
358
 
278
359
  export interface PiVersionResolution {
@@ -373,15 +454,18 @@ function versionInRange(version: string | undefined): boolean {
373
454
  return version === TRUSTED_PI_VERSION;
374
455
  }
375
456
 
376
- function shape(target: object, method: string): boolean {
377
- const descriptor = Object.getOwnPropertyDescriptor(target, method);
457
+ function shape(spec: TargetSpec): boolean {
458
+ const descriptor = Object.getOwnPropertyDescriptor(spec.target, spec.method);
459
+ // Additive installs need an unowned slot (the method is inherited); every
460
+ // other patch requires the native own writable/configurable method.
461
+ if (spec.kind === "add-method") return descriptor === undefined;
378
462
  return typeof descriptor?.value === "function" && descriptor.writable === true && descriptor.configurable === true;
379
463
  }
380
464
 
381
465
  export interface CompatibilityProbeOptions {
382
466
  markers?: Set<string>;
383
467
  config?: Readonly<{
384
- messages: { enabled: boolean; assistantPrefix: boolean; specialBlocks: boolean };
468
+ messages: { enabled: boolean; assistantPrefix: boolean; specialBlocks: boolean; hideThinkingLabel: boolean };
385
469
  tools: { enabled: boolean; style: string; maxCollapsedLines: number; maxExpandedLines: number; dimOutput: boolean };
386
470
  preset: string;
387
471
  }>;
@@ -418,7 +502,7 @@ function createFallbackRecord(
418
502
 
419
503
  function probeDiagnostic(spec: TargetSpec, piVersion: string | undefined, identity: unknown): string {
420
504
  if (!versionInRange(piVersion)) return "Pi version is unknown or outside the recorded 0.83.0 support build";
421
- if (!shape(spec.target, spec.method)) return "target method shape is not an own writable/configurable function";
505
+ if (!shape(spec)) return "target method shape is not an own writable/configurable function";
422
506
  if (identity === undefined) return "recorded 0.83.0 native fingerprint, name, or arity did not match";
423
507
  return "exact native identity verified; certified guarded decoration enabled";
424
508
  }
@@ -427,7 +511,9 @@ function surfaceDisabled(spec: TargetSpec, config: CompatibilityProbeOptions["co
427
511
  if (!config) return false;
428
512
  if (spec.feature === "tools") return !config.tools.enabled;
429
513
  if (!config.messages.enabled) return true;
430
- if (spec.subtype === "native-assistant-message") return !config.messages.assistantPrefix;
514
+ if (spec.subtype === "native-assistant-message" && spec.method === "render") return !config.messages.assistantPrefix;
515
+ if (spec.subtype === "native-assistant-message" && spec.method === "updateContent")
516
+ return !config.messages.hideThinkingLabel;
431
517
  if (isSpecialBlock(spec)) return !config.messages.specialBlocks;
432
518
  return true;
433
519
  }
@@ -456,13 +542,19 @@ function probeSpec(options: {
456
542
  method: spec.method,
457
543
  piVersion: piVersion ?? "unknown",
458
544
  versionRange: PI_VERSION_RANGE,
459
- shape: identity !== undefined && versionInRange(piVersion) && shape(spec.target, spec.method),
545
+ shape: identity !== undefined && versionInRange(piVersion) && shape(spec),
460
546
  generation,
461
547
  expectedIdentity: identity,
462
548
  hasExpectedIdentity: true,
463
549
  diagnostic,
550
+ ...(spec.kind ? { kind: spec.kind } : {}),
464
551
  delegate: (original, target, args) => {
465
552
  markers.add(`${spec.subtype}:delegated`);
553
+ if (spec.subtype === "native-bash-execution")
554
+ return (
555
+ renderBashExecutionBox(target, args) ??
556
+ Reflect.apply(original as (...values: unknown[]) => unknown, target, args)
557
+ );
466
558
  if (spec.feature === "tools")
467
559
  return (
468
560
  toolOwner?.decorateToolRendererSelection(
@@ -472,8 +564,10 @@ function probeSpec(options: {
472
564
  args,
473
565
  ) ?? Reflect.apply(original as (...values: unknown[]) => unknown, target, args)
474
566
  );
475
- if (spec.subtype === "native-assistant-message")
567
+ if (spec.subtype === "native-assistant-message") {
568
+ if (spec.method === "updateContent") return decorateMessageUpdate(original, target, args, messageSnapshot);
476
569
  return decorateMessageRender(original, target, args, messageSnapshot);
570
+ }
477
571
  return renderSpecialMessageBlock(spec.subtype as SpecialBlockSubtype, original, target, args);
478
572
  },
479
573
  });