@quandev104/pi-style 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -2
  3. package/dist/extensions/pi-style.js +5510 -4865
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/command-service.ts +2 -2
  6. package/extension-src/pi-style/app/index.ts +0 -1
  7. package/extension-src/pi-style/domain/config-authorization.ts +1 -2
  8. package/extension-src/pi-style/domain/config-normalization.ts +3 -3
  9. package/extension-src/pi-style/domain/config-types.ts +2 -2
  10. package/extension-src/pi-style/domain/theme.ts +3 -0
  11. package/extension-src/pi-style/features/messages/index.ts +2 -8
  12. package/extension-src/pi-style/features/tools/boxed/bash.ts +384 -0
  13. package/extension-src/pi-style/features/tools/boxed/batch.ts +459 -0
  14. package/extension-src/pi-style/features/tools/boxed/find.ts +48 -48
  15. package/extension-src/pi-style/features/tools/boxed/grep.ts +161 -89
  16. package/extension-src/pi-style/features/tools/boxed/index.ts +4 -0
  17. package/extension-src/pi-style/features/tools/boxed/ls.ts +39 -47
  18. package/extension-src/pi-style/features/tools/boxed/output-tree.ts +368 -0
  19. package/extension-src/pi-style/features/tools/boxed/read.ts +32 -189
  20. package/extension-src/pi-style/features/tools/boxed/session-config.ts +6 -0
  21. package/extension-src/pi-style/features/tools/boxed/write.ts +91 -49
  22. package/extension-src/pi-style/features/tools/index.ts +14 -0
  23. package/extension-src/pi-style/pi/compatibility-coordinator.ts +4 -26
  24. package/extension-src/pi-style/pi/compatibility-probe.ts +3 -33
  25. package/extension-src/pi-style/pi/compatibility-registry.ts +0 -1
  26. package/extension-src/pi-style/pi/config-session.ts +0 -2
  27. package/extension-src/pi-style/pi/index.ts +35 -1
  28. package/extension-src/pi-style/pi/session-coordinator.ts +39 -3
  29. package/extension-src/pi-style/shared/box.ts +41 -7
  30. package/extension-src/pi-style/shared/theme-extras.ts +0 -2
  31. package/package.json +1 -1
@@ -1,8 +1,26 @@
1
1
  // Boxed write tool renderer
2
2
  // (renderCall/renderResult only).
3
+ //
4
+ // The write call renders a compact preview box: the file path in the top
5
+ // border, the written content as numbered lines in the body (cat -n style),
6
+ // and the metrics footer in the bottom border. The footer lives in the shared
7
+ // renderer state — the result renderer stores it (elapsed + words), the call
8
+ // component reads it at paint time and closes the box. The preview is capped
9
+ // at the collapsed line budget with a `Ctrl+O for more` hint on the bottom
10
+ // border when truncated; expanded shows the expanded budget. Errors keep the
11
+ // plain open call box so the boxed error result never duplicates a box.
3
12
 
13
+ import type { Component } from "@earendil-works/pi-tui";
4
14
  import { stripAnsi } from "../../../shared/ansi.js";
5
- import { boxedToolWidthKey, getTextOutput, renderBoxedToolResult, stripTrailingNotice } from "../../../shared/box.js";
15
+ import {
16
+ type BoxTheme,
17
+ boxedToolWidthKey,
18
+ getTextOutput,
19
+ renderBoxedToolResult,
20
+ renderCompactBoxedToolCall,
21
+ replaceTabs,
22
+ } from "../../../shared/box.js";
23
+ import { getToolsRenderConfig } from "./session-config.js";
6
24
  import {
7
25
  type BoxedToolDefinition,
8
26
  clearFooterState,
@@ -13,39 +31,86 @@ import {
13
31
  resultFooterLines,
14
32
  } from "./shared.js";
15
33
 
16
- function parseWriteSummary(output: string): string | undefined {
17
- const normalized = stripTrailingNotice(stripAnsi(output ?? "")).trim();
18
- if (!normalized) return undefined;
34
+ /** Right-side bottom-border hint shown when the compact preview is truncated. */
35
+ const WRITE_EXPAND_HINT = "Ctrl+O for more";
19
36
 
20
- const byteMatch = normalized.match(/\bwrote\s+(\d+)\s+bytes?\b/i);
21
- if (byteMatch) {
22
- const bytes = Number(byteMatch[1]);
23
- if (Number.isFinite(bytes)) {
24
- return `↳ Wrote ${bytes} ${bytes === 1 ? "byte" : "bytes"}.`;
25
- }
26
- }
37
+ type NumberedLine = { number: string; content: string };
27
38
 
28
- const lineMatch = normalized.match(/\bwrote\s+(\d+)\s+lines?\b/i);
29
- if (lineMatch) {
30
- const count = Number(lineMatch[1]);
31
- if (Number.isFinite(count)) {
32
- return `↳ Wrote ${count} ${count === 1 ? "line" : "lines"}.`;
33
- }
34
- }
39
+ /**
40
+ * Numbered preview lines for the written content, `cat -n` style: every split
41
+ * line keeps its number (including a trailing empty line produced by a final
42
+ * newline), right-aligned to the widest line number.
43
+ */
44
+ function numberedPreviewLines(content: string): NumberedLine[] {
45
+ const normalized = replaceTabs(String(content ?? "")).replace(/\r/g, "");
46
+ if (!normalized) return [];
47
+ const lines = normalized.split("\n");
48
+ const gutterWidth = Math.max(1, String(lines.length).length);
49
+ return lines.map((line, index) => ({
50
+ number: String(index + 1).padStart(gutterWidth),
51
+ content: line,
52
+ }));
53
+ }
54
+
55
+ /** One boxed preview row: dim gutter + toolOutput content. */
56
+ function formatNumberedLine(theme: BoxTheme, line: NumberedLine): string {
57
+ return `${theme.fg("borderMuted", `${line.number} `)}${theme.fg("toolOutput", line.content)}`;
58
+ }
35
59
 
36
- return undefined;
60
+ /** Compact write box: path header, numbered content preview, metrics footer. */
61
+ function renderWritePreviewBox(
62
+ theme: BoxTheme,
63
+ detailLine: string,
64
+ content: string,
65
+ options: {
66
+ state?: Record<string, unknown>;
67
+ isError: boolean;
68
+ isPending: boolean;
69
+ expanded: boolean;
70
+ },
71
+ ): Component {
72
+ const preview = numberedPreviewLines(content);
73
+ const config = getToolsRenderConfig();
74
+ const budget = options.expanded ? config.maxExpandedLines : config.maxCollapsedLines;
75
+ const truncated = preview.length > budget;
76
+
77
+ return renderCompactBoxedToolCall(theme, "Write", detailLine, {
78
+ ...(options.state ? { state: options.state } : {}),
79
+ isError: options.isError,
80
+ isPending: options.isPending,
81
+ bodyLines: () => {
82
+ if (preview.length === 0) return [];
83
+ const shown = preview.slice(0, budget).map((line) => formatNumberedLine(theme, line));
84
+ if (!truncated) return shown;
85
+ const omitted = preview.length - budget;
86
+ const note = options.expanded ? `… ${omitted} more lines omitted by render budget` : `… ${omitted} more lines`;
87
+ return [...shown, theme.fg("muted", note)];
88
+ },
89
+ ...(options.expanded || options.isPending || !truncated ? {} : { bottomRightLabel: WRITE_EXPAND_HINT }),
90
+ });
37
91
  }
38
92
 
39
93
  export const writeTool: BoxedToolDefinition = {
40
94
  call(args, theme, context) {
41
95
  noteExecutionStart(context);
42
96
  const detail = displayPath(String(args?.path ?? args?.file_path ?? ""), context);
43
- return compactCall(theme, "Write", `${theme.fg("dim", "Path: ")}${detail}`, {
44
- detailKey: detail,
45
- context,
97
+ const detailLine = `${theme.fg("dim", "Path: ")}${detail}`;
98
+ // On error keep the plain open box: the result renderer continues it with
99
+ // the boxed error body, so call and result never duplicate a box.
100
+ if (context.isError) {
101
+ return compactCall(theme, "Write", detailLine, {
102
+ detailKey: detail,
103
+ context,
104
+ });
105
+ }
106
+ return renderWritePreviewBox(theme, detailLine, String(args?.content ?? ""), {
107
+ state: context.state,
108
+ isError: Boolean(context.isError),
109
+ isPending: Boolean(context.isPartial),
110
+ expanded: Boolean(context.expanded),
46
111
  });
47
112
  },
48
- result(result, options, theme, context) {
113
+ result(result, _options, theme, context) {
49
114
  clearFooterState(context);
50
115
  const output = getTextOutput(result);
51
116
  const detail = displayPath(String(context?.args?.path ?? context?.args?.file_path ?? ""), context);
@@ -59,31 +124,8 @@ export const writeTool: BoxedToolDefinition = {
59
124
  });
60
125
  }
61
126
 
62
- if (!options.expanded) return compactFooterWithState(theme, result, context);
63
-
64
- const content = String(context?.args?.content ?? "");
65
- const lineCount = content ? content.split("\n").length : 0;
66
- if (lineCount > 0) {
67
- const summary = `↳ Wrote ${lineCount} ${lineCount === 1 ? "line" : "lines"}.`;
68
- return renderBoxedToolResult(theme, () => [theme.fg("dim", summary)], {
69
- widthKey,
70
- footerLines: resultFooterLines(theme, result, context),
71
- });
72
- }
73
-
74
- const summary = parseWriteSummary(output);
75
- if (summary) {
76
- return renderBoxedToolResult(theme, () => [theme.fg("dim", summary)], {
77
- widthKey,
78
- footerLines: resultFooterLines(theme, result, context),
79
- });
80
- }
81
-
82
- const normalized = stripTrailingNotice(stripAnsi(output)).trim();
83
- const fallback = normalized ? `↳ ${normalized}` : "↳ Wrote file.";
84
- return renderBoxedToolResult(theme, () => [theme.fg("dim", fallback)], {
85
- widthKey,
86
- footerLines: resultFooterLines(theme, result, context),
87
- });
127
+ // Success (compact and expanded): the preview box closes with the metrics
128
+ // footer stored into the shared renderer state; the result adds nothing.
129
+ return compactFooterWithState(theme, result, context);
88
130
  },
89
131
  };
@@ -1,6 +1,19 @@
1
1
  import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import { EMPTY_BATCH_COMPONENT } from "./boxed/batch.js";
2
3
  import { renderBoxedToolCall, renderBoxedToolResult } from "./boxed/index.js";
3
4
 
5
+ /**
6
+ * Batch members render zero lines. Pi's ToolExecutionComponent always adds a
7
+ * built-in Spacer child (one blank line) and only sets hideComponent when
8
+ * hasContent is false — but adding an empty renderer still marks hasContent
9
+ * true. Mark the instance hidden so members contribute zero lines (no stray
10
+ * blank margin after the batch panel). updateDisplay resets hideComponent on
11
+ * every pass; the wrapper re-applies it on each dispatch.
12
+ */
13
+ function hideBatchMember(instance: object): void {
14
+ (instance as { hideComponent?: boolean }).hideComponent = true;
15
+ }
16
+
4
17
  /**
5
18
  * Neutralize the native ToolExecutionComponent status background for boxed
6
19
  * rendering: Pi's updateDisplay sets contentBox/selfRenderContainer bgFn to
@@ -434,6 +447,7 @@ export function createToolDecorationOwner(snapshot: Partial<ToolDecorationSnapsh
434
447
  );
435
448
  })();
436
449
  neutralizeToolContainerBackground(instance);
450
+ if (component === EMPTY_BATCH_COMPONENT) hideBatchMember(instance);
437
451
  return component;
438
452
  };
439
453
  }
@@ -11,7 +11,6 @@ import {
11
11
  export interface CompatibilityCoordinator {
12
12
  captureAuthorization(
13
13
  coreFlag: boolean,
14
- userFlag: boolean,
15
14
  assistantFlag: boolean,
16
15
  specialBlocksFlag: boolean,
17
16
  toolsFlag: boolean,
@@ -33,7 +32,6 @@ export function createCompatibilityCoordinator(dispose = disposePiCompatibilityP
33
32
  let authorization:
34
33
  | {
35
34
  core: boolean;
36
- user: boolean;
37
35
  assistant: boolean;
38
36
  specialBlocks: boolean;
39
37
  tools: boolean;
@@ -44,15 +42,13 @@ export function createCompatibilityCoordinator(dispose = disposePiCompatibilityP
44
42
  get report() {
45
43
  return report;
46
44
  },
47
- captureAuthorization(core, user, assistant, specialBlocks, tools, ascii) {
48
- authorization = { core, user, assistant, specialBlocks, tools, ascii };
45
+ captureAuthorization(core, assistant, specialBlocks, tools, ascii) {
46
+ authorization = { core, assistant, specialBlocks, tools, ascii };
49
47
  },
50
48
  state(config) {
51
49
  const version = detectPiVersion();
52
50
  const messagesConfigured =
53
- config.enabled &&
54
- config.messages.enabled &&
55
- (config.messages.userPrefix || config.messages.assistantPrefix || config.messages.specialBlocks);
51
+ config.enabled && config.messages.enabled && (config.messages.assistantPrefix || config.messages.specialBlocks);
56
52
  const toolsConfigured = config.enabled && config.tools.enabled;
57
53
  const surface = (
58
54
  feature: "messages" | "tools",
@@ -91,12 +87,6 @@ export function createCompatibilityCoordinator(dispose = disposePiCompatibilityP
91
87
  nativeFallbacks: report?.unsupported.filter((item) => item.reason.includes("fallback")).length ?? 0,
92
88
  piVersion: version.version ?? report?.piVersion ?? "unknown",
93
89
  versionRange: report?.versionRange ?? ">=0.83.0 <0.84.0",
94
- userMessage: surface(
95
- "messages",
96
- config.enabled && config.messages.enabled && config.messages.userPrefix,
97
- Boolean(authorization?.user),
98
- "native-user-message",
99
- ),
100
90
  assistantMessage: surface(
101
91
  "messages",
102
92
  config.enabled && config.messages.enabled && config.messages.assistantPrefix,
@@ -116,15 +106,6 @@ export function createCompatibilityCoordinator(dispose = disposePiCompatibilityP
116
106
  if (cleanupPending && !report) cleanupPending = false;
117
107
  if (cleanupPending || !tui || !config.enabled || !authorization?.core || productDenied) return undefined;
118
108
  const certifiedHost = detectPiVersion().version === "0.83.0";
119
- const userEnabled =
120
- authorization.user &&
121
- isTierCAuthorized({
122
- certifiedHost,
123
- coreFlag: authorization.core,
124
- surfaceFlag: true,
125
- surface: "userMessage",
126
- config,
127
- });
128
109
  const assistantEnabled =
129
110
  authorization.assistant &&
130
111
  isTierCAuthorized({
@@ -143,7 +124,7 @@ export function createCompatibilityCoordinator(dispose = disposePiCompatibilityP
143
124
  surface: "specialBlocks",
144
125
  config,
145
126
  });
146
- const messagesEnabled = (userEnabled || assistantEnabled || specialBlocksEnabled) && config.messages.enabled;
127
+ const messagesEnabled = (assistantEnabled || specialBlocksEnabled) && config.messages.enabled;
147
128
  const toolsEnabled =
148
129
  authorization.tools &&
149
130
  isTierCAuthorized({ certifiedHost, coreFlag: authorization.core, surfaceFlag: true, surface: "tools", config });
@@ -155,7 +136,6 @@ export function createCompatibilityCoordinator(dispose = disposePiCompatibilityP
155
136
  messages: {
156
137
  ...config.messages,
157
138
  enabled: messagesEnabled,
158
- userPrefix: userEnabled,
159
139
  assistantPrefix: assistantEnabled,
160
140
  specialBlocks: messagesEnabled && config.messages.specialBlocks && specialBlocksEnabled,
161
141
  },
@@ -165,9 +145,7 @@ export function createCompatibilityCoordinator(dispose = disposePiCompatibilityP
165
145
  },
166
146
  },
167
147
  messageSnapshot: {
168
- userPrefix: authorization.ascii ? "[user] " : "❯ ",
169
148
  assistantPrefix: authorization.ascii ? "[assistant] " : "│ ",
170
- userEnabled,
171
149
  assistantEnabled,
172
150
  },
173
151
  toolSnapshot: {
@@ -8,7 +8,6 @@ import {
8
8
  CustomMessageComponent,
9
9
  SkillInvocationMessageComponent,
10
10
  ToolExecutionComponent,
11
- UserMessageComponent,
12
11
  } from "@earendil-works/pi-coding-agent";
13
12
  import { decorateMessageRender, type MessageDecorationSnapshot } from "../features/messages/index.js";
14
13
  import { renderSpecialMessageBlock, type SpecialBlockSubtype } from "../features/messages/special-blocks.js";
@@ -31,7 +30,6 @@ const reportStates = new WeakMap<
31
30
  // code loaded before this module could spoof the same function source. We therefore
32
31
  // fail closed on every unrecorded Pi build and never use module-load capture as trust.
33
32
  export const TRUSTED_NATIVE_FINGERPRINTS: Readonly<Record<string, string>> = Object.freeze({
34
- "native-user-message:render": "b442a17c",
35
33
  "native-assistant-message:render": "2a39243f",
36
34
  "native-compaction-message:updateDisplay": "f8c44e78",
37
35
  "native-branch-message:updateDisplay": "415d57b7",
@@ -43,19 +41,6 @@ export const TRUSTED_NATIVE_FINGERPRINTS: Readonly<Record<string, string>> = Obj
43
41
 
44
42
  export const CERTIFICATION_TABLE = Object.freeze({
45
43
  "0.83.0": Object.freeze({
46
- "native-user-message:render": Object.freeze({
47
- feature: "messages",
48
- subtype: "native-user-message",
49
- target: UserMessageComponent.prototype,
50
- method: "render",
51
- writable: true,
52
- configurable: true,
53
- name: "render",
54
- arity: 1,
55
- fingerprint: TRUSTED_NATIVE_FINGERPRINTS["native-user-message:render"],
56
- adapterId: "message-prefix-osc133-v1",
57
- status: "certified" as const,
58
- }),
59
44
  "native-assistant-message:render": Object.freeze({
60
45
  feature: "messages",
61
46
  subtype: "native-assistant-message",
@@ -232,14 +217,6 @@ function trustedNativeIdentity(spec: TargetSpec, piVersion: string | undefined):
232
217
  }
233
218
 
234
219
  export const targetSpecs: readonly TargetSpec[] = [
235
- {
236
- feature: "messages",
237
- subtype: "native-user-message",
238
- target: UserMessageComponent.prototype,
239
- method: "render",
240
- adapterId: "message-prefix-osc133-v1",
241
- status: "certified",
242
- },
243
220
  {
244
221
  feature: "messages",
245
222
  subtype: "native-assistant-message",
@@ -404,7 +381,7 @@ function shape(target: object, method: string): boolean {
404
381
  export interface CompatibilityProbeOptions {
405
382
  markers?: Set<string>;
406
383
  config?: Readonly<{
407
- messages: { enabled: boolean; userPrefix: boolean; assistantPrefix: boolean; specialBlocks: boolean };
384
+ messages: { enabled: boolean; assistantPrefix: boolean; specialBlocks: boolean };
408
385
  tools: { enabled: boolean; style: string; maxCollapsedLines: number; maxExpandedLines: number; dimOutput: boolean };
409
386
  preset: string;
410
387
  }>;
@@ -450,7 +427,6 @@ function surfaceDisabled(spec: TargetSpec, config: CompatibilityProbeOptions["co
450
427
  if (!config) return false;
451
428
  if (spec.feature === "tools") return !config.tools.enabled;
452
429
  if (!config.messages.enabled) return true;
453
- if (spec.subtype === "native-user-message") return !config.messages.userPrefix;
454
430
  if (spec.subtype === "native-assistant-message") return !config.messages.assistantPrefix;
455
431
  if (isSpecialBlock(spec)) return !config.messages.specialBlocks;
456
432
  return true;
@@ -496,14 +472,8 @@ function probeSpec(options: {
496
472
  args,
497
473
  ) ?? Reflect.apply(original as (...values: unknown[]) => unknown, target, args)
498
474
  );
499
- if (spec.subtype === "native-user-message" || spec.subtype === "native-assistant-message")
500
- return decorateMessageRender(
501
- spec.subtype as "native-user-message" | "native-assistant-message",
502
- original,
503
- target,
504
- args,
505
- messageSnapshot,
506
- );
475
+ if (spec.subtype === "native-assistant-message")
476
+ return decorateMessageRender(original, target, args, messageSnapshot);
507
477
  return renderSpecialMessageBlock(spec.subtype as SpecialBlockSubtype, original, target, args);
508
478
  },
509
479
  });
@@ -1,5 +1,4 @@
1
1
  export type CompatibilitySubtype =
2
- | "native-user-message"
3
2
  | "native-assistant-message"
4
3
  | "native-compaction-message"
5
4
  | "native-branch-message"
@@ -8,7 +8,6 @@ export interface SessionFlagReader {
8
8
  }
9
9
  export interface SessionAuthorization {
10
10
  core: boolean;
11
- user: boolean;
12
11
  assistant: boolean;
13
12
  specialBlocks: boolean;
14
13
  tools: boolean;
@@ -59,7 +58,6 @@ export function resolveProductGate(
59
58
  export function readSessionAuthorization(pi: SessionFlagReader): SessionAuthorization {
60
59
  return {
61
60
  core: pi.getFlag("pi-style-core-patches") === true,
62
- user: pi.getFlag("pi-style-message-user") === true,
63
61
  assistant: pi.getFlag("pi-style-message-assistant") === true,
64
62
  specialBlocks: pi.getFlag("pi-style-message-special-blocks") === true,
65
63
  tools: pi.getFlag("pi-style-tools") === true,
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { StatusSnapshot } from "../domain/status.js";
3
+ import { closeActiveBatch } from "../features/tools/boxed/batch.js";
3
4
  import { registerPiStyleCommand } from "./commands.js";
4
5
  import { type CompatibilityTestHooks, createPiStyleSessionCoordinator } from "./session-coordinator.js";
5
6
  import { usageFromSession } from "./session-usage.js";
@@ -10,6 +11,24 @@ function usagePatch(ctx: ExtensionContext): StatusSnapshot {
10
11
  return usage ? { usage } : {};
11
12
  }
12
13
 
14
+ /**
15
+ * Add Pi's read-only tools (grep/find/ls) to the active tool set if they are
16
+ * registered. Preserves any other active tools (e.g. extension tools).
17
+ * Only calls setActiveTools when something actually changed.
18
+ */
19
+ function activateReadOnlyTools(pi: ExtensionAPI): void {
20
+ const available = new Set(pi.getAllTools().map((tool) => tool.name));
21
+ const active = new Set(pi.getActiveTools());
22
+ let changed = false;
23
+ for (const name of ["grep", "find", "ls"] as const) {
24
+ if (available.has(name) && !active.has(name)) {
25
+ active.add(name);
26
+ changed = true;
27
+ }
28
+ }
29
+ if (changed) pi.setActiveTools([...active]);
30
+ }
31
+
13
32
  let compatibilityTestHooks: CompatibilityTestHooks = {};
14
33
  export function __setCompatibilityTestHooks(hooks: CompatibilityTestHooks): () => void {
15
34
  const previous = compatibilityTestHooks;
@@ -26,10 +45,10 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
26
45
  // product gate `compatibility.allowCorePatches: false` (or `enabled: false`) in config.
27
46
  for (const [name, description] of [
28
47
  ["pi-style-core-patches", "Enable pi-style message/tool core patches"],
29
- ["pi-style-message-user", "Enable pi-style user message prefix"],
30
48
  ["pi-style-message-assistant", "Enable pi-style assistant message prefix"],
31
49
  ["pi-style-message-special-blocks", "Enable pi-style boxed compaction/skill/branch/custom message blocks"],
32
50
  ["pi-style-tools", "Enable pi-style tool renderer decoration"],
51
+ ["pi-style-readonly-tools", "Enable grep/find/ls read-only tools in the active tool set"],
33
52
  ] as const)
34
53
  pi.registerFlag(name, { type: "boolean", description, default: true });
35
54
  // ASCII markers stay opt-in; unicode markers are the default.
@@ -37,6 +56,13 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
37
56
  const coordinator = createPiStyleSessionCoordinator(pi, compatibilityTestHooks);
38
57
  registerPiStyleCommand(pi, coordinator.app);
39
58
  pi.on("session_start", async (event, ctx) => {
59
+ // Pi only activates read/bash/edit/write by default; grep/find/ls are
60
+ // registered but inactive (kept out of the model's tool list to keep the
61
+ // core small). Activate them so the TUI shows them and the model can call
62
+ // them directly, mirroring Claude Code's glob/grep/read tool set.
63
+ if (pi.getFlag("pi-style-readonly-tools") === true) {
64
+ activateReadOnlyTools(pi);
65
+ }
40
66
  await coordinator.start(event, ctx);
41
67
  });
42
68
  pi.on("agent_start", () => coordinator.app.runtime.current?.dismissStartup());
@@ -54,6 +80,14 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
54
80
  );
55
81
  pi.on("thinking_level_select", (event) => coordinator.app.update({ thinkingLevel: event.level }, "immediate"));
56
82
  pi.on("session_info_changed", (event) => coordinator.app.update({ sessionName: event.name }, "coalesced"));
83
+ pi.on("message_start", () => {
84
+ // A new message is a batch boundary: quiet-tool (read/ls/find) calls of the
85
+ // new message start a fresh batch instead of joining the previous one.
86
+ closeActiveBatch();
87
+ // grep/bash tree panels are NOT cleared here: historical panels must keep
88
+ // their state so Pi re-renders of previous messages (scroll/resume) stay
89
+ // intact. Only session boundaries reset them (session-coordinator).
90
+ });
57
91
  pi.on("message_update", () => coordinator.app.update({}, "coalesced"));
58
92
  // Usage (tokens + cost) is aggregated from finalized session entries at
59
93
  // message/turn boundaries, mirroring Pi's native footer; per-chunk updates
@@ -2,8 +2,12 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
3
3
  import type { ConfigFilePort } from "../app/config-storage.js";
4
4
  import { createPiStyleApp, type PiStyleApp } from "../app/index.js";
5
+ import { resolveTheme } from "../domain/theme.js";
5
6
  import { setSpecialBlockTheme } from "../features/messages/special-blocks.js";
6
- import { setToolsRenderConfig } from "../features/tools/boxed/session-config.js";
7
+ import { resetBashTreeRegistry } from "../features/tools/boxed/bash.js";
8
+ import { resetBatchRegistry } from "../features/tools/boxed/batch.js";
9
+ import { resetGrepRegistry } from "../features/tools/boxed/grep.js";
10
+ import { setToolsRenderConfig, type ToolsRenderConfig } from "../features/tools/boxed/session-config.js";
7
11
  import { createCompatibilityCoordinator } from "./compatibility-coordinator.js";
8
12
  import {
9
13
  type CompatibilityCleanupResult,
@@ -45,6 +49,8 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
45
49
  let active = false;
46
50
  let tuiSession = false;
47
51
  let terminalInputUnsubscribe: (() => void) | undefined;
52
+ let sessionTheme: unknown;
53
+ let sessionUi: import("@earendil-works/pi-coding-agent").ExtensionUIContext | undefined;
48
54
  const source = createConfigSourceAdapter(
49
55
  pi,
50
56
  filePort,
@@ -68,6 +74,22 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
68
74
  ),
69
75
  );
70
76
  };
77
+ /** Render-scoped tool config: line budgets + the resolved open-tree glyph. */
78
+ const applyToolsRenderConfig = (config: import("../domain/config-types.js").NormalizedPiStyleConfig) => {
79
+ setToolsRenderConfig({
80
+ ...config.tools,
81
+ batchOpenGlyph: resolveTheme(sessionTheme as never, config, process.env).glyph("batchOpen"),
82
+ nerdFonts: resolveTheme(sessionTheme as never, config, process.env).mode === "nerd",
83
+ } satisfies ToolsRenderConfig);
84
+ };
85
+ /**
86
+ * Hide Pi's "Thinking..." placeholder label: an empty label renders zero
87
+ * lines, so the thinking block leaves no trace while content stays hidden.
88
+ * Passing undefined restores the default label.
89
+ */
90
+ const applyMessagesConfig = (config: import("../domain/config-types.js").NormalizedPiStyleConfig) => {
91
+ sessionUi?.setHiddenThinkingLabel?.(config.messages.hideThinkingLabel ? "" : undefined);
92
+ };
71
93
  const app: PiStyleApp = createPiStyleApp(
72
94
  undefined,
73
95
  {
@@ -79,6 +101,10 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
79
101
  (config) => {
80
102
  productGate = app.productPolicy.corePatchGate;
81
103
  if (!active) return;
104
+ // Apply render-scoped tool config live so `/pi-style set tools.*` takes
105
+ // effect immediately (line budgets, dimOutput, open-tree glyph, …).
106
+ applyToolsRenderConfig(config);
107
+ applyMessagesConfig(config);
82
108
  if (compatibility.report) {
83
109
  const cleanup = compatibility.dispose();
84
110
  if (!cleanup.complete) {
@@ -100,7 +126,6 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
100
126
  authorization = readSessionAuthorization(pi);
101
127
  compatibility.captureAuthorization(
102
128
  authorization.core,
103
- authorization.user,
104
129
  authorization.assistant,
105
130
  authorization.specialBlocks,
106
131
  authorization.tools,
@@ -120,6 +145,11 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
120
145
  tuiSession = ctx.mode === "tui";
121
146
  app.setProjectTrusted(ctx.isProjectTrusted());
122
147
  source.setSession(cwd, ctx.isProjectTrusted());
148
+ // Drop any batch state carried over from the previous session (Pi renders
149
+ // the restored chat between session_shutdown and the next session_start).
150
+ resetBatchRegistry();
151
+ resetGrepRegistry();
152
+ resetBashTreeRegistry();
123
153
  active = false;
124
154
  await app.reload();
125
155
  productGate = app.productPolicy.corePatchGate;
@@ -127,7 +157,10 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
127
157
  compatibility.install(app.config, ctx.mode === "tui", productGate);
128
158
  // Session-scoped render configuration for the boxed tool/message surfaces.
129
159
  // Populated once per session (never inside render).
130
- setToolsRenderConfig(app.config.tools);
160
+ sessionTheme = ctx.ui?.theme as never;
161
+ sessionUi = ctx.ui as import("@earendil-works/pi-coding-agent").ExtensionUIContext | undefined;
162
+ applyToolsRenderConfig(app.config);
163
+ applyMessagesConfig(app.config);
131
164
  if (ctx.ui?.theme) setSpecialBlockTheme(ctx.ui.theme as never);
132
165
  const toolDetails = collectToolDetails(pi.getActiveTools?.(), pi.getAllTools?.());
133
166
  app.sessionStart(
@@ -180,6 +213,9 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
180
213
  tuiSession = false;
181
214
  terminalInputUnsubscribe?.();
182
215
  terminalInputUnsubscribe = undefined;
216
+ resetBatchRegistry();
217
+ resetGrepRegistry();
218
+ resetBashTreeRegistry();
183
219
  app.sessionShutdown();
184
220
  // Tier C prototype patches stay installed across session switches. Pi renders
185
221
  // the restored chat (renderBeforeBind) AFTER session_shutdown but BEFORE the
@@ -47,6 +47,14 @@ export interface BoxedRenderOptions {
47
47
  state?: Record<string, unknown>;
48
48
  /** Wall-clock elapsed override (used when metrics are not in result.details). */
49
49
  elapsedMs?: number;
50
+ /** Width-dependent content lines rendered between the top border and the
51
+ * footer border of a compact box (replaces the blank breathing line).
52
+ * Each returned line is truncated to the box inner width by the renderer.
53
+ * Returns an empty array for no body (blank line preserved). */
54
+ bodyLines?: (contentWidth: number) => string[];
55
+ /** Right-side label embedded in the compact box bottom border before the
56
+ * corner (e.g. an expand hint such as `Ctrl+O for more`). */
57
+ bottomRightLabel?: string;
50
58
  }
51
59
 
52
60
  export function isExpanded(options: { expanded?: boolean } | undefined): boolean {
@@ -133,7 +141,7 @@ export function countWords(text: string): number {
133
141
  return count;
134
142
  }
135
143
 
136
- function formatCompactCount(value: number): string {
144
+ export function formatCompactCount(value: number): string {
137
145
  if (value < 1000) return `${Math.round(value)}`;
138
146
  if (value < 10000) return `${(value / 1000).toFixed(1)}k`;
139
147
  if (value < 1000000) return `${Math.round(value / 1000)}k`;
@@ -323,14 +331,21 @@ function formatBoxedStatusIcon(theme: BoxTheme, isError?: boolean): string {
323
331
  return theme.fg(isError ? "error" : "success", icon);
324
332
  }
325
333
 
334
+ /**
335
+ * Colored `➔ Name` prefix for tool titles (identity color). The status glyph
336
+ * (✓/✗) is appended separately by formatBoxedToolTitle.
337
+ */
338
+ export function formatToolTitlePrefix(theme: BoxTheme, name: string): string {
339
+ return colorFromExtra(theme, "bashPromptColor", "bashMode", `➔ ${name}`);
340
+ }
341
+
326
342
  export function formatBoxedToolTitle(theme: BoxTheme, name: string, isError?: boolean): string {
327
- const rawTitle = `➔ ${name}`;
328
343
  // On failure the whole title turns error-colored (not just the ✗) so a failed
329
344
  // tool reads instantly; on success the tool keeps its identity color and only
330
345
  // the ✓ carries the success color.
331
346
  const coloredTitle = isError
332
- ? theme.fg("error", `${rawTitle} ✗`)
333
- : `${colorFromExtra(theme, "bashPromptColor", "bashMode", rawTitle)} ${formatBoxedStatusIcon(theme, false)}`;
347
+ ? theme.fg("error", `➔ ${name} ✗`)
348
+ : `${formatToolTitlePrefix(theme, name)} ${formatBoxedStatusIcon(theme, false)}`;
334
349
  return typeof theme?.bold === "function" ? theme.bold(coloredTitle) : coloredTitle;
335
350
  }
336
351
 
@@ -423,6 +438,22 @@ export function boxBlankLine(theme: BoxTheme, width: number): string {
423
438
  return `${boxFrameText(theme, BOX_VERTICAL)}${sidePad}${" ".repeat(contentWidth)}${sidePad}${boxFrameText(theme, BOX_VERTICAL)}`;
424
439
  }
425
440
 
441
+ export function boxLineAligned(theme: BoxTheme, left: string, right: string, width: number): string {
442
+ const renderedWidth = boxWidth(width);
443
+ const contentWidth = boxInnerWidth(renderedWidth);
444
+ const rightWidth = safeVisibleWidth(right);
445
+ const sidePad = " ".repeat(BOX_SIDE_PADDING);
446
+
447
+ if (!right || rightWidth >= contentWidth) {
448
+ return boxLine(theme, right || left, renderedWidth);
449
+ }
450
+
451
+ const maxLeftWidth = Math.max(1, contentWidth - rightWidth - 1);
452
+ const truncatedLeft = safeTruncateToWidth(left, maxLeftWidth, "…");
453
+ const gap = " ".repeat(Math.max(1, contentWidth - safeVisibleWidth(truncatedLeft) - rightWidth));
454
+ return `${boxFrameText(theme, BOX_VERTICAL)}${sidePad}${truncatedLeft}${gap}${right}${sidePad}${boxFrameText(theme, BOX_VERTICAL)}`;
455
+ }
456
+
426
457
  export function boxLineWithRight(theme: BoxTheme, left: string, right: string, width: number): string {
427
458
  const renderedWidth = boxWidth(width);
428
459
  const contentWidth = boxInnerWidth(renderedWidth);
@@ -599,9 +630,12 @@ export function renderCompactBoxedToolCall(
599
630
  typeof options.state?.[COMPACT_FOOTER_KEY] === "string" ? options.state[COMPACT_FOOTER_KEY] : "";
600
631
  const _footerIsError = Boolean(options.state?.[COMPACT_FOOTER_ERROR_KEY]);
601
632
  const _footerIsPartial = Boolean(options.state?.[COMPACT_FOOTER_PARTIAL_KEY]);
633
+ const bodyLines = options.bodyLines ? options.bodyLines(boxInnerWidth(renderedWidth)) : [];
602
634
  const lines = [
603
635
  boxLabeledBorder(theme, BOX_ROUND_TOP_LEFT, BOX_ROUND_TOP_RIGHT, headerLabel, undefined, renderedWidth),
604
- boxBlankLine(theme, renderedWidth),
636
+ ...(bodyLines.length > 0
637
+ ? bodyLines.map((line) => boxLine(theme, line, renderedWidth))
638
+ : [boxBlankLine(theme, renderedWidth)]),
605
639
  ];
606
640
  if (compactFooter) {
607
641
  lines.push(
@@ -610,7 +644,7 @@ export function renderCompactBoxedToolCall(
610
644
  BOX_ROUND_BOTTOM_LEFT,
611
645
  BOX_ROUND_BOTTOM_RIGHT,
612
646
  compactFooter,
613
- undefined,
647
+ options.bottomRightLabel,
614
648
  renderedWidth,
615
649
  ),
616
650
  );
@@ -622,7 +656,7 @@ export function renderCompactBoxedToolCall(
622
656
  BOX_ROUND_BOTTOM_LEFT,
623
657
  BOX_ROUND_BOTTOM_RIGHT,
624
658
  theme.fg("dim", `… ${pendingText}`),
625
- undefined,
659
+ options.bottomRightLabel,
626
660
  renderedWidth,
627
661
  ),
628
662
  );