@quandev104/pi-style 0.1.4 → 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 (33) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +8 -4
  3. package/dist/extensions/pi-style.js +3789 -1278
  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/theme.ts +6 -1
  10. package/extension-src/pi-style/features/editor/index.ts +169 -27
  11. package/extension-src/pi-style/features/messages/index.ts +66 -0
  12. package/extension-src/pi-style/features/tools/bash-execution.ts +112 -0
  13. package/extension-src/pi-style/features/tools/boxed/bash.ts +154 -130
  14. package/extension-src/pi-style/features/tools/boxed/batch.ts +50 -27
  15. package/extension-src/pi-style/features/tools/boxed/command-shape.ts +136 -0
  16. package/extension-src/pi-style/features/tools/boxed/find.ts +2 -2
  17. package/extension-src/pi-style/features/tools/boxed/gh.ts +1012 -0
  18. package/extension-src/pi-style/features/tools/boxed/git.ts +1960 -0
  19. package/extension-src/pi-style/features/tools/boxed/grep.ts +2 -2
  20. package/extension-src/pi-style/features/tools/boxed/output-tree.ts +9 -10
  21. package/extension-src/pi-style/features/tools/boxed/read.ts +3 -3
  22. package/extension-src/pi-style/features/tools/boxed/write.ts +2 -1
  23. package/extension-src/pi-style/pi/compatibility-coordinator.ts +17 -0
  24. package/extension-src/pi-style/pi/compatibility-probe.ts +104 -10
  25. package/extension-src/pi-style/pi/compatibility-registry.ts +19 -3
  26. package/extension-src/pi-style/pi/index.ts +18 -1
  27. package/extension-src/pi-style/pi/session-coordinator.ts +24 -0
  28. package/extension-src/pi-style/shared/box.ts +8 -4
  29. package/extension-src/pi-style/shared/split-diff.ts +9 -9
  30. package/package.json +1 -1
  31. package/themes/titanium-light.json +82 -0
  32. package/themes/titanium.json +79 -0
  33. 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
  }
@@ -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";
@@ -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,
@@ -62,7 +63,7 @@ function numberedPreviewLines(content: string): NumberedLine[] {
62
63
 
63
64
  /** One boxed preview row: dim gutter + toolOutput content. */
64
65
  function formatNumberedLine(theme: BoxTheme, line: NumberedLine): string {
65
- return `${theme.fg("borderMuted", `${line.number} `)}${theme.fg("toolOutput", line.content)}`;
66
+ return `${dimLine(`${line.number} `)}${theme.fg("toolOutput", line.content)}`;
66
67
  }
67
68
 
68
69
  /** Compact write box: path header, numbered content preview, metrics footer. */
@@ -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
  });
@@ -5,7 +5,8 @@ export type CompatibilitySubtype =
5
5
  | "native-skill-message"
6
6
  | "native-custom-message"
7
7
  | "tool-call-renderer"
8
- | "tool-result-renderer";
8
+ | "tool-result-renderer"
9
+ | "native-bash-execution";
9
10
 
10
11
  type CompatibilityShape = "supported" | "unsupported" | "conflict" | "installed" | "skipped";
11
12
 
@@ -171,6 +172,13 @@ function validateInstall(
171
172
  if (!options.shape) return skippedResult(options, "unsupported", options.diagnostic ?? "unsupported shape", current);
172
173
  const conflict = existing && activeConflict(existing, current, options.generation);
173
174
  if (conflict) return conflict;
175
+ if (options.kind === "add-method") {
176
+ // Additive install: the slot must be unowned (the method is inherited); the
177
+ // delegate receives the inherited native function as its fallback identity.
178
+ if (current !== undefined || ownDescriptor !== undefined)
179
+ return skippedResult(options, "conflict", "target already owns the additive method", current);
180
+ return undefined;
181
+ }
174
182
  if (typeof current !== "function")
175
183
  return skippedResult(
176
184
  options,
@@ -225,6 +233,8 @@ export function installDelegatingPatch(options: {
225
233
  expectedIdentity?: unknown;
226
234
  hasExpectedIdentity?: boolean;
227
235
  diagnostic?: string | undefined;
236
+ /** "add-method" installs a new own method on the prototype (nothing may already own the slot); the delegate receives the inherited native function as the fallback. */
237
+ kind?: "method" | "add-method";
228
238
  delegate: (original: unknown, thisArg: object, args: unknown[]) => unknown;
229
239
  }): InstallResult {
230
240
  const { target, method } = options;
@@ -235,7 +245,9 @@ export function installDelegatingPatch(options: {
235
245
  const validation = validateInstall(options, current, records.get(method), inspection.descriptor);
236
246
  if (validation) return validation;
237
247
  const originalDescriptor = Object.getOwnPropertyDescriptor(target, method);
238
- const originalIdentity = current;
248
+ // Additive installs have no current owner; the captured inherited native
249
+ // function (expectedIdentity) is both the fallback and the delegate's original.
250
+ const originalIdentity = options.kind === "add-method" ? options.expectedIdentity : current;
239
251
  let active = true;
240
252
  const installed = function (this: object, ...args: unknown[]): unknown {
241
253
  if (!active) return Reflect.apply(originalIdentity as (...values: unknown[]) => unknown, this, args);
@@ -260,7 +272,11 @@ export function installDelegatingPatch(options: {
260
272
  };
261
273
  try {
262
274
  Object.defineProperty(installed, "__piStyleCompatibilityRecord", { value: record, configurable: false });
263
- const descriptor = { ...originalDescriptor, value: installed };
275
+ // Additive installs have no original descriptor; write a fresh writable,
276
+ // configurable, non-enumerable own method so the slot stays reversible.
277
+ const descriptor = originalDescriptor
278
+ ? { ...originalDescriptor, value: installed }
279
+ : { value: installed, writable: true, enumerable: false, configurable: true };
264
280
  const wrote = Reflect.defineProperty(target, method, descriptor);
265
281
  registryTestHooks.afterWrite?.();
266
282
  const currentDescriptor = Object.getOwnPropertyDescriptor(target, method);
@@ -66,7 +66,24 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
66
66
  await coordinator.start(event, ctx);
67
67
  });
68
68
  pi.on("agent_start", () => coordinator.app.runtime.current?.dismissStartup());
69
- pi.on("input", () => coordinator.app.runtime.current?.dismissStartup());
69
+ pi.on("input", (event, _ctx) => {
70
+ coordinator.app.runtime.current?.dismissStartup();
71
+ // Bare `!`/`!!` submit guard: Pi treats `!`-prefixed input as a direct bash
72
+ // command but falls through to normal message submission when the bang has
73
+ // no command after it — sending a literal `!` to the agent. Drop those
74
+ // accidental submits instead; Pi's submit path already cleared the editor
75
+ // (onChange("") resets isBashMode), so the input returns to the normal
76
+ // prompt without sending anything. Only the interactive input box is
77
+ // guarded; rpc/extension sources keep sending text verbatim.
78
+ if (event.source === "interactive") {
79
+ const trimmed = event.text.trimStart();
80
+ if (trimmed.startsWith("!")) {
81
+ const bangLength = trimmed.startsWith("!!") ? 2 : 1;
82
+ if (trimmed.slice(bangLength).trim() === "") return { action: "handled" };
83
+ }
84
+ }
85
+ return undefined;
86
+ });
70
87
  pi.on("tool_execution_start", () => coordinator.app.runtime.current?.dismissStartup());
71
88
  pi.on("model_select", (event) =>
72
89
  coordinator.app.update(
@@ -4,6 +4,7 @@ import type { ConfigFilePort } from "../app/config-storage.js";
4
4
  import { createPiStyleApp, type PiStyleApp } from "../app/index.js";
5
5
  import { resolveTheme } from "../domain/theme.js";
6
6
  import { setSpecialBlockTheme } from "../features/messages/special-blocks.js";
7
+ import { setBashExecutionTheme } from "../features/tools/bash-execution.js";
7
8
  import { resetBashTreeRegistry } from "../features/tools/boxed/bash.js";
8
9
  import { resetBatchRegistry } from "../features/tools/boxed/batch.js";
9
10
  import { resetGrepRegistry } from "../features/tools/boxed/grep.js";
@@ -94,6 +95,26 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
94
95
  const applyMessagesConfig = (config: import("../domain/config-types.js").NormalizedPiStyleConfig) => {
95
96
  sessionUi?.setHiddenThinkingLabel?.(config.messages.hideThinkingLabel ? "" : undefined);
96
97
  };
98
+ /**
99
+ * Auto-apply the configured pi-style theme (default "titanium") once per TUI
100
+ * session before any surface captures the active theme, so a fresh install
101
+ * renders with the intended palette. Failure-safe: an unresolvable target is
102
+ * never passed to Pi (its setTheme falls back to the dark theme on load
103
+ * error, which would clobber the user's theme), and "off" disables the
104
+ * surface for users who keep their own theme.
105
+ */
106
+ const applyAutoTheme = (
107
+ config: import("../domain/config-types.js").NormalizedPiStyleConfig,
108
+ ctx: ExtensionContext,
109
+ ) => {
110
+ const target = config.theme.autoApply;
111
+ if (ctx.mode !== "tui" || !target || target === "off") return;
112
+ const ui = ctx.ui;
113
+ if (ui?.theme?.name === target) return;
114
+ // Resolve before switching (see failure-safe note above).
115
+ if (!ui?.getTheme?.(target)) return;
116
+ ui.setTheme?.(target);
117
+ };
97
118
  const app: PiStyleApp = createPiStyleApp(
98
119
  undefined,
99
120
  {
@@ -162,6 +183,8 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
162
183
  productGate = app.productPolicy.corePatchGate;
163
184
  active = true;
164
185
  compatibility.install(app.config, ctx.mode === "tui", productGate);
186
+ // Auto-apply the configured theme before surfaces capture the active one.
187
+ applyAutoTheme(app.config, ctx);
165
188
  // Session-scoped render configuration for the boxed tool/message surfaces.
166
189
  // Populated once per session (never inside render).
167
190
  sessionTheme = ctx.ui?.theme as never;
@@ -169,6 +192,7 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
169
192
  applyToolsRenderConfig(app.config);
170
193
  applyMessagesConfig(app.config);
171
194
  if (ctx.ui?.theme) setSpecialBlockTheme(ctx.ui.theme as never);
195
+ if (ctx.ui?.theme) setBashExecutionTheme(ctx.ui.theme as never);
172
196
  const toolDetails = collectToolDetails(pi.getActiveTools?.(), pi.getAllTools?.());
173
197
  app.sessionStart(
174
198
  {
@@ -319,8 +319,6 @@ export function formatToolParamLines(args: unknown, theme?: BoxTheme): string[]
319
319
  return lines;
320
320
  }
321
321
 
322
- const RESET_INTENSITY = "\x1b[22m";
323
-
324
322
  function colorFromExtra(theme: BoxTheme, extraKey: string, fallbackColor: string, text: string): string {
325
323
  const color = getThemeExtra(theme, extraKey);
326
324
  if (color) {
@@ -380,11 +378,17 @@ export function formatBoxedRunningStatus(theme: BoxTheme, elapsedMs: number | un
380
378
  return `${theme.fg("dim", "◌ Running")}${elapsed}`;
381
379
  }
382
380
 
381
+ /** Structural line — box frame, tree branch, divider, gutter — wrapped in
382
+ * dim terminal-default intensity. Visible in every theme; matches omp. */
383
+ export function dimLine(text: string): string {
384
+ return `\x1b[2m${text}\x1b[22m`;
385
+ }
386
+
383
387
  function boxText(theme: BoxTheme, text: string): string {
384
- return `${RESET_INTENSITY}${theme.fg("borderMuted", text)}`;
388
+ return dimLine(text);
385
389
  }
386
390
  function boxFrameText(theme: BoxTheme, text: string): string {
387
- return `${RESET_INTENSITY}${theme.fg("border", text)}`;
391
+ return dimLine(text);
388
392
  }
389
393
 
390
394
  export function boxedToolBgName(isError?: boolean, isPartial?: boolean): string {
@@ -7,6 +7,7 @@ import { highlightCode } from "@earendil-works/pi-coding-agent";
7
7
  import type { Component } from "@earendil-works/pi-tui";
8
8
 
9
9
  import { stripAnsi } from "./ansi.js";
10
+ import { dimLine } from "./box.js";
10
11
  import { safeTruncateToWidth, safeVisibleWidth } from "./render-budget.js";
11
12
 
12
13
  // ── Types ──────────────────────────────────────────────────────────
@@ -761,7 +762,7 @@ class SplitDiffRenderer {
761
762
  lineKind === "add" ? "toolDiffAdded" : lineKind === "remove" ? "toolDiffRemoved" : "borderMuted";
762
763
  const marker = this.ctx.fg(markerColor, markerChar);
763
764
  const lineNumber = this.ctx.fg("dim", " ".repeat(this.ctx.lineNumberWidth));
764
- const divider = this.ctx.fg("borderMuted", " │ ");
765
+ const divider = dimLine(" │ ");
765
766
  const prefix = `${marker} ${lineNumber}${divider}`;
766
767
  const prefixPlain = `${markerChar} ${" ".repeat(this.ctx.lineNumberWidth)} │ `;
767
768
  const tailWidth = Math.max(0, columnWidth - safeVisibleWidth(prefixPlain));
@@ -791,14 +792,14 @@ class SplitDiffRenderer {
791
792
  this.ctx.fg(markerColor, markerChar) +
792
793
  " " +
793
794
  this.ctx.fg(this.getNumberColor(lineKind), lineNumber) +
794
- this.ctx.fg("borderMuted", " │ ");
795
+ dimLine(" │ ");
795
796
  const firstPrefixPlain = `${markerChar} ${lineNumber} │ `;
796
797
 
797
798
  const contPrefixAnsi =
798
799
  this.ctx.fg(markerColor, markerChar) +
799
800
  " " +
800
801
  this.ctx.fg("dim", " ".repeat(this.ctx.lineNumberWidth)) +
801
- this.ctx.fg("borderMuted", " │ ");
802
+ dimLine(" │ ");
802
803
  const contPrefixPlain = `${markerChar} ${" ".repeat(this.ctx.lineNumberWidth)} │ `;
803
804
 
804
805
  const codeWidth = Math.max(1, columnWidth - safeVisibleWidth(firstPrefixPlain));
@@ -865,7 +866,7 @@ class SplitDiffRenderer {
865
866
 
866
867
  render(width: number): string[] {
867
868
  const safeWidth = Math.max(20, width);
868
- const columnSeparator = this.ctx.fg("borderMuted", " │ ");
869
+ const columnSeparator = dimLine(" │ ");
869
870
  const separatorWidth = safeVisibleWidth(stripAnsi(columnSeparator));
870
871
  const leftWidth = Math.max(20, Math.floor((safeWidth - separatorWidth) / 2));
871
872
  const rightWidth = Math.max(20, safeWidth - separatorWidth - leftWidth);
@@ -877,15 +878,14 @@ class SplitDiffRenderer {
877
878
  if (dividerIndex >= 0 && dividerIndex < chars.length) {
878
879
  chars[dividerIndex] = junction;
879
880
  }
880
- return this.ctx.fg("borderMuted", chars.join(""));
881
+ return dimLine(chars.join(""));
881
882
  };
882
883
 
883
884
  const formatHeaderCell = (label: string, columnWidth: number): string => {
884
885
  // Keep marker+space columns, then place label inside the line-number column.
885
886
  const markerPad = " ";
886
887
  const lineNumberLabel = fitToWidth(label, this.ctx.lineNumberWidth);
887
- const prefixAnsi =
888
- this.ctx.fg("borderMuted", markerPad) + this.ctx.fg("dim", lineNumberLabel) + this.ctx.fg("borderMuted", " │ ");
888
+ const prefixAnsi = dimLine(markerPad) + this.ctx.fg("dim", lineNumberLabel) + dimLine(" │ ");
889
889
  const prefixPlain = `${markerPad}${stripAnsi(lineNumberLabel)} │ `;
890
890
  const codeWidth = Math.max(0, columnWidth - safeVisibleWidth(prefixPlain));
891
891
  return padRenderedLineWidth(prefixAnsi + " ".repeat(codeWidth), columnWidth);
@@ -894,7 +894,7 @@ class SplitDiffRenderer {
894
894
  const lines: string[] = [];
895
895
  lines.push(
896
896
  padRenderedLineWidth(
897
- formatBorderCell(leftWidth, "┬") + this.ctx.fg("borderMuted", "─┬─") + formatBorderCell(rightWidth, "┬"),
897
+ formatBorderCell(leftWidth, "┬") + dimLine("─┬─") + formatBorderCell(rightWidth, "┬"),
898
898
  safeWidth,
899
899
  ),
900
900
  );
@@ -931,7 +931,7 @@ class SplitDiffRenderer {
931
931
 
932
932
  lines.push(
933
933
  padRenderedLineWidth(
934
- formatBorderCell(leftWidth, "┴") + this.ctx.fg("borderMuted", "─┴─") + formatBorderCell(rightWidth, "┴"),
934
+ formatBorderCell(leftWidth, "┴") + dimLine("─┴─") + formatBorderCell(rightWidth, "┴"),
935
935
  safeWidth,
936
936
  ),
937
937
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quandev104/pi-style",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "A native-layout, cohesive visual style package for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",