@quandev104/pi-style 0.1.7 → 0.2.0

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.
@@ -56,6 +56,7 @@ const allowedPaths = new Set([
56
56
  "tools.dimOutput",
57
57
  "tools.showElapsed",
58
58
  "tools.collapseAfterTurn",
59
+ "tools.collapseMutatingTools",
59
60
  "theme.nerdFonts",
60
61
  "theme.terminalBackgroundSync",
61
62
  "theme.autoApply",
@@ -85,6 +86,7 @@ function validatePathValue(path: string, value: unknown): boolean {
85
86
  "tools.showElapsed",
86
87
  "tools.dimOutput",
87
88
  "tools.collapseAfterTurn",
89
+ "tools.collapseMutatingTools",
88
90
  "compatibility.allowSafePatches",
89
91
  "compatibility.allowCorePatches",
90
92
  "compatibility.preferExistingEditor",
@@ -43,6 +43,7 @@ export const DEFAULT_CONFIG: NormalizedPiStyleConfig = Object.freeze({
43
43
  dimOutput: false,
44
44
  showElapsed: true,
45
45
  collapseAfterTurn: true,
46
+ collapseMutatingTools: false,
46
47
  }),
47
48
  theme: Object.freeze({
48
49
  nerdFonts: "auto",
@@ -186,6 +187,7 @@ export function normalizeConfig(
186
187
  dimOutput: bool(tools.dimOutput, defaults.tools.dimOutput),
187
188
  showElapsed: bool(tools.showElapsed, defaults.tools.showElapsed),
188
189
  collapseAfterTurn: bool(tools.collapseAfterTurn, defaults.tools.collapseAfterTurn),
190
+ collapseMutatingTools: bool(tools.collapseMutatingTools, defaults.tools.collapseMutatingTools),
189
191
  }),
190
192
  theme: Object.freeze({
191
193
  nerdFonts: stringEnum(theme.nerdFonts, ["auto", "on", "off"], defaults.theme.nerdFonts),
@@ -241,6 +243,7 @@ const BOOL_PATHS = new Set([
241
243
  "tools.showElapsed",
242
244
  "tools.dimOutput",
243
245
  "tools.collapseAfterTurn",
246
+ "tools.collapseMutatingTools",
244
247
  "compatibility.allowSafePatches",
245
248
  "compatibility.allowCorePatches",
246
249
  "compatibility.preferExistingEditor",
@@ -48,6 +48,8 @@ export interface PiStyleConfig {
48
48
  showElapsed?: boolean;
49
49
  /** Collapse a completed turn's tool blocks into one summary line (ADR 0007). */
50
50
  collapseAfterTurn?: boolean;
51
+ /** Also collapse mutating tools (edit/write/…) into the summary; off keeps them visible. */
52
+ collapseMutatingTools?: boolean;
51
53
  };
52
54
  theme?: {
53
55
  nerdFonts?: string;
@@ -105,6 +107,8 @@ export interface NormalizedPiStyleConfig {
105
107
  showElapsed: boolean;
106
108
  /** Collapse a completed turn's tool blocks into one summary line (ADR 0007). */
107
109
  collapseAfterTurn: boolean;
110
+ /** Also collapse mutating tools (edit/write/…) into the summary; off keeps them visible. */
111
+ collapseMutatingTools: boolean;
108
112
  };
109
113
  readonly theme: {
110
114
  nerdFonts: NerdFontsMode;
@@ -20,6 +20,7 @@ import type { BoxedToolContext, BoxedToolDefinition } from "./shared.js";
20
20
  import {
21
21
  emptyTurnResult,
22
22
  getTurnEntry,
23
+ isMutatingTool,
23
24
  noteTurnMemberElapsed,
24
25
  noteTurnMemberRender,
25
26
  renderTurnSummaryCall,
@@ -53,12 +54,16 @@ export function hasBoxedRenderer(toolName: unknown): boolean {
53
54
  /**
54
55
  * Turn-summary gate (ADR 0007): the member belongs to an ended turn, Pi's
55
56
  * global tool-output state is collapsed, the surface is enabled, and the block
56
- * itself is not an error (errors always stay visible).
57
+ * itself is not an error (errors always stay visible). Mutating tools
58
+ * (edit/write/…) are exempt unless `tools.collapseMutatingTools` is on — their
59
+ * blocks are the record of what was done and stay visible by default.
57
60
  */
58
61
  function collapsedTurnFor(toolCallId: string, expanded: boolean): TurnState | undefined {
59
- if (expanded || !getToolsRenderConfig().collapseAfterTurn) return undefined;
62
+ const config = getToolsRenderConfig();
63
+ if (expanded || !config.collapseAfterTurn) return undefined;
60
64
  const entry = getTurnEntry(toolCallId);
61
65
  if (!entry?.turn.ended || entry.member.isError) return undefined;
66
+ if (isMutatingTool(entry.member.toolName) && !config.collapseMutatingTools) return undefined;
62
67
  return entry.turn;
63
68
  }
64
69
 
@@ -14,6 +14,8 @@ export interface ToolsRenderConfig {
14
14
  nerdFonts: boolean;
15
15
  /** Collapse a completed turn's tool blocks into one summary line (ADR 0007). */
16
16
  collapseAfterTurn: boolean;
17
+ /** Also collapse mutating tools (edit/write/…) into the summary; off keeps them visible. */
18
+ collapseMutatingTools: boolean;
17
19
  }
18
20
 
19
21
  let sessionToolsConfig: ToolsRenderConfig = {
@@ -24,6 +26,7 @@ let sessionToolsConfig: ToolsRenderConfig = {
24
26
  batchOpenGlyph: "●",
25
27
  nerdFonts: false,
26
28
  collapseAfterTurn: true,
29
+ collapseMutatingTools: false,
27
30
  };
28
31
 
29
32
  export function setToolsRenderConfig(config: Partial<ToolsRenderConfig>): void {
@@ -2,10 +2,18 @@
2
2
  //
3
3
  // When a turn completes, its finalized tool blocks collapse into a single
4
4
  // summary line (`➔ Read 2 files, ran 4 shell commands · 3.1s`) rendered by the
5
- // turn's leader (its first non-error tool call); every other non-error tool
6
- // item of the turn renders zero lines. Error results stay visible, interrupted
7
- // turns never collapse, and Pi's global tool-output toggle (Ctrl+O) expands
8
- // everything again (`options.expanded` is read, never written).
5
+ // turn's leader (its first non-error tool call that collapses under the render
6
+ // config); every other collapsible tool item of the turn renders zero lines.
7
+ // Error results stay visible, interrupted turns never collapse, and Pi's
8
+ // global tool-output toggle (Ctrl+O) expands everything again
9
+ // (`options.expanded` is read, never written).
10
+ //
11
+ // Mutating tools (edit/write/quick_edit/substitute_edit/target_edit) are
12
+ // exempt from the summary by default (`tools.collapseMutatingTools: off`):
13
+ // their blocks are the record of what was done to the user's files, so they
14
+ // always stay visible (compact preview) even in an ended turn — the summary
15
+ // covers only read-only tools (read/ls/find/grep/bash). Turning the leaf on
16
+ // restores the full collapse.
9
17
  //
10
18
  // Design notes:
11
19
  // - The registry is populated from **session content**, never from runtime
@@ -27,6 +35,7 @@ import type { Component } from "@earendil-works/pi-tui";
27
35
  import type { BoxTheme } from "../../../shared/box.js";
28
36
  import { safeTruncateToWidth } from "../../../shared/render-budget.js";
29
37
  import { pluralForm } from "./output-tree.js";
38
+ import { getToolsRenderConfig } from "./session-config.js";
30
39
 
31
40
  export interface TurnMemberInfo {
32
41
  readonly toolCallId: string;
@@ -39,12 +48,37 @@ export interface TurnMemberInfo {
39
48
  }
40
49
 
41
50
  export interface TurnState {
42
- /** First non-error member; renders the summary line. Empty when every member errored. */
51
+ /**
52
+ * First non-error member that collapses under the current render config
53
+ * (mutating members are skipped unless `tools.collapseMutatingTools` is
54
+ * on); renders the summary line. Empty when every member errored or when
55
+ * the turn's members are all mutating with the exemption active (such a
56
+ * turn collapses nothing).
57
+ */
43
58
  leaderId: string;
44
59
  ended: boolean;
45
60
  members: readonly TurnMemberInfo[];
46
61
  }
47
62
 
63
+ /**
64
+ * Tools that change the user's files. Their blocks are the record of what was
65
+ * done — they stay visible after the turn and are excluded from the summary
66
+ * unless `tools.collapseMutatingTools` is on. bash is deliberately NOT here:
67
+ * read-only and mutating commands are indistinguishable without parsing the
68
+ * command text.
69
+ */
70
+ const MUTATING_TOOLS: ReadonlySet<string> = new Set(["edit", "write", "quick_edit", "substitute_edit", "target_edit"]);
71
+
72
+ /** Whether the tool changes the user's files (exempt from turn collapse). */
73
+ export function isMutatingTool(toolName: string): boolean {
74
+ return MUTATING_TOOLS.has(toolName);
75
+ }
76
+
77
+ /** Whether the summary should also cover mutating tools (render config). */
78
+ function mutatingCollapses(): boolean {
79
+ return getToolsRenderConfig().collapseMutatingTools;
80
+ }
81
+
48
82
  interface TurnEntry {
49
83
  readonly turn: TurnState;
50
84
  readonly member: TurnMemberInfo;
@@ -96,7 +130,7 @@ function registerTurn(
96
130
  isError: isErrorById.get(toolCallId) === true,
97
131
  };
98
132
  });
99
- const leader = members.find((member) => !member.isError);
133
+ const leader = members.find((member) => !member.isError && (!isMutatingTool(member.toolName) || mutatingCollapses()));
100
134
  const turn: TurnState = {
101
135
  leaderId: leader?.toolCallId ?? "",
102
136
  ended: ended && complete,
@@ -145,7 +179,9 @@ export function registerTurnFromMessage(message: unknown, toolResults: readonly
145
179
  isError: isErrorById.get(toolCallId) === true,
146
180
  };
147
181
  });
148
- const leader = newMembers.find((member) => !member.isError);
182
+ const leader = newMembers.find(
183
+ (member) => !member.isError && (!isMutatingTool(member.toolName) || mutatingCollapses()),
184
+ );
149
185
  if (!currentRun) {
150
186
  currentRun = {
151
187
  leaderId: leader?.toolCallId ?? "",
@@ -303,17 +339,24 @@ export interface TurnSummaryParts {
303
339
  readonly elapsedMs: number | undefined;
304
340
  }
305
341
 
306
- /** Aggregate a turn's non-error members into summary parts (pure). */
342
+ /**
343
+ * Aggregate a turn's collapsed members into summary parts (pure). Mutating
344
+ * members are excluded unless `tools.collapseMutatingTools` is on — by default
345
+ * their visible blocks are the record; the summary describes only what it
346
+ * hides.
347
+ */
307
348
  export function turnSummaryParts(turn: TurnState): TurnSummaryParts {
308
349
  const counts = new Map<string, number>();
309
350
  const order: string[] = [];
310
351
  let failedCount = 0;
311
352
  let elapsedMs: number | undefined;
353
+ const collapseMutating = mutatingCollapses();
312
354
  for (const member of turn.members) {
313
355
  if (member.isError) {
314
356
  failedCount++;
315
357
  continue;
316
358
  }
359
+ if (!collapseMutating && isMutatingTool(member.toolName)) continue;
317
360
  if (member.elapsedMs !== undefined) elapsedMs = (elapsedMs ?? 0) + member.elapsedMs;
318
361
  const existing = counts.get(member.toolName);
319
362
  if (existing === undefined) {
@@ -323,8 +366,10 @@ export function turnSummaryParts(turn: TurnState): TurnSummaryParts {
323
366
  }
324
367
  const parts = order.map((toolName) => {
325
368
  const count = counts.get(toolName) ?? 0;
326
- const style = TURN_SUMMARY_STYLE[toolName] ?? { verb: "ran", unit: `${toolName} call` };
327
- return `${style.verb} ${count} ${pluralForm(style.unit, count)}`;
369
+ const style = TURN_SUMMARY_STYLE[toolName];
370
+ // Unknown tools (extension tools like TaskCreate/ask_user_question) use a
371
+ // neutral phrasing with the invariant tool name: `used 5 TaskCreate`.
372
+ return style ? `${style.verb} ${count} ${pluralForm(style.unit, count)}` : `used ${count} ${toolName}`;
328
373
  });
329
374
  return { parts, failedCount, elapsedMs };
330
375
  }
@@ -337,7 +382,7 @@ function formatTurnSummaryLine(theme: BoxTheme, turn: TurnState): string {
337
382
  const parts = summary.parts.join(", ");
338
383
  let line = `${theme.fg("dim", `➔ ${parts}`)}`;
339
384
  if (summary.failedCount > 0)
340
- line += theme.fg("error", ` · ${summary.failedCount} ${pluralForm("failed", summary.failedCount)}`);
385
+ line += theme.fg("error", ` · ${summary.failedCount} ${pluralForm("failure", summary.failedCount)}`);
341
386
  if (summary.elapsedMs !== undefined) line += theme.fg("dim", ` · ${(summary.elapsedMs / 1000).toFixed(2)}s`);
342
387
  return line;
343
388
  }
@@ -423,16 +423,31 @@ export function createToolDecorationOwner(snapshot: Partial<ToolDecorationSnapsh
423
423
  if (typeof renderer !== "function") {
424
424
  neutralizeToolContainerBackground(instance);
425
425
  if (subtype === "tool-call-renderer")
426
- return (callArgs: unknown, theme: unknown, context: unknown) =>
427
- renderBoxedToolCall(toolName, callArgs as Record<string, unknown>, theme as never, context as never);
428
- return (result: unknown, options: unknown, theme: unknown, context: unknown) =>
429
- renderBoxedToolResult(
426
+ return (callArgs: unknown, theme: unknown, context: unknown) => {
427
+ const component = renderBoxedToolCall(
428
+ toolName,
429
+ callArgs as Record<string, unknown>,
430
+ theme as never,
431
+ context as never,
432
+ );
433
+ // Same batch-member contract as the native-renderer path: a
434
+ // collapsed turn member (or quiet batch member) returns the
435
+ // singleton and must be hidden, or Pi leaves a stray native
436
+ // placeholder row per block after the collapse.
437
+ if (component === EMPTY_BATCH_COMPONENT) hideBatchMember(instance);
438
+ return component;
439
+ };
440
+ return (result: unknown, options: unknown, theme: unknown, context: unknown) => {
441
+ const component = renderBoxedToolResult(
430
442
  toolName,
431
443
  result as { content?: readonly unknown[]; details?: unknown },
432
444
  options as { expanded: boolean; isPartial: boolean },
433
445
  theme as never,
434
446
  context as never,
435
447
  );
448
+ if (component === EMPTY_BATCH_COMPONENT) hideBatchMember(instance);
449
+ return component;
450
+ };
436
451
  }
437
452
  return function (this: unknown, ...rendererArgs: unknown[]) {
438
453
  const valid = subtype === "tool-call-renderer" ? validCallArgs(rendererArgs) : validResultArgs(rendererArgs);
@@ -567,8 +567,20 @@ function renderBoxedOutputLines(
567
567
  let truncated = false;
568
568
 
569
569
  for (; nextInputIndex < outputLines.length; nextInputIndex++) {
570
- const line = boxedTruncatedLine(theme, outputLines[nextInputIndex] ?? "", width);
571
- if (!pushBoundedLines(head, [line], headLimit)) {
570
+ // An output "line" may carry embedded newlines (raw tool error messages,
571
+ // JSON payloads). Split before boxing so every fragment gets its own
572
+ // border and truncation — otherwise the box frame visually breaks on the
573
+ // embedded rows.
574
+ const fragments = (outputLines[nextInputIndex] ?? "").split("\n");
575
+ let headExceeded = false;
576
+ for (const fragment of fragments) {
577
+ const line = boxedTruncatedLine(theme, fragment, width);
578
+ if (!pushBoundedLines(head, [line], headLimit)) {
579
+ headExceeded = true;
580
+ break;
581
+ }
582
+ }
583
+ if (headExceeded) {
572
584
  truncated = true;
573
585
  nextInputIndex++;
574
586
  break;
@@ -580,9 +592,12 @@ function renderBoxedOutputLines(
580
592
  const tail: string[] = [];
581
593
  const tailStart = Math.max(nextInputIndex, outputLines.length - tailLimit);
582
594
  for (let i = tailStart; i < outputLines.length; i++) {
583
- const line = boxedTruncatedLine(theme, outputLines[i] ?? "", width);
584
- tail.push(line);
585
- if (tail.length > tailLimit) tail.splice(0, tail.length - tailLimit);
595
+ const fragments = (outputLines[i] ?? "").split("\n");
596
+ for (const fragment of fragments) {
597
+ const line = boxedTruncatedLine(theme, fragment, width);
598
+ tail.push(line);
599
+ if (tail.length > tailLimit) tail.splice(0, tail.length - tailLimit);
600
+ }
586
601
  }
587
602
 
588
603
  const skippedInputLines = Math.max(0, tailStart - nextInputIndex);
@@ -764,6 +779,10 @@ export function renderBoxedToolResult(
764
779
  bodyLines.length > 0
765
780
  ? [...errorPrefix, ...bodyLines]
766
781
  : [theme.fg("muted", `∅ ${options.emptyText ?? "(no output)"}`)];
782
+ // Split embedded newlines before budgeting so raw multi-line messages
783
+ // (tool validation errors, JSON payloads) render as proper bordered
784
+ // rows instead of one "line" whose embedded rows break the frame.
785
+ const outputFragments = outputLines.flatMap((line) => line.split("\n"));
767
786
  const footerText = (options.footerLines ?? []).join(" · ");
768
787
  const dividerText =
769
788
  typeof options.dividerLabel === "function"
@@ -783,7 +802,12 @@ export function renderBoxedToolResult(
783
802
  ),
784
803
  ]),
785
804
  boxBlankLine(theme, renderedWidth),
786
- ...renderBoxedOutputLines(theme, outputLines, renderedWidth, options.renderLineBudget ?? outputLines.length),
805
+ ...renderBoxedOutputLines(
806
+ theme,
807
+ outputFragments,
808
+ renderedWidth,
809
+ options.renderLineBudget ?? outputFragments.length,
810
+ ),
787
811
  boxBlankLine(theme, renderedWidth),
788
812
  boxLabeledBorder(
789
813
  theme,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quandev104/pi-style",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "description": "A native-layout, cohesive visual style package for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",