@quandev104/pi-style 0.1.6 → 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.
@@ -48,12 +48,15 @@ const allowedPaths = new Set([
48
48
  "messages.assistantPrefix",
49
49
  "messages.specialBlocks",
50
50
  "messages.hideThinkingLabel",
51
+ "messages.hideInterimText",
51
52
  "tools.enabled",
52
53
  "tools.style",
53
54
  "tools.maxCollapsedLines",
54
55
  "tools.maxExpandedLines",
55
56
  "tools.dimOutput",
56
57
  "tools.showElapsed",
58
+ "tools.collapseAfterTurn",
59
+ "tools.collapseMutatingTools",
57
60
  "theme.nerdFonts",
58
61
  "theme.terminalBackgroundSync",
59
62
  "theme.autoApply",
@@ -78,9 +81,12 @@ function validatePathValue(path: string, value: unknown): boolean {
78
81
  "messages.assistantPrefix",
79
82
  "messages.specialBlocks",
80
83
  "messages.hideThinkingLabel",
84
+ "messages.hideInterimText",
81
85
  "tools.enabled",
82
86
  "tools.showElapsed",
83
87
  "tools.dimOutput",
88
+ "tools.collapseAfterTurn",
89
+ "tools.collapseMutatingTools",
84
90
  "compatibility.allowSafePatches",
85
91
  "compatibility.allowCorePatches",
86
92
  "compatibility.preferExistingEditor",
@@ -28,7 +28,13 @@ export const DEFAULT_CONFIG: NormalizedPiStyleConfig = Object.freeze({
28
28
  contextBarWidth: 10,
29
29
  }),
30
30
  editor: Object.freeze({ enabled: true, style: "dock", frame: "rounded", showMetadata: false, hint: "" }),
31
- messages: Object.freeze({ enabled: true, assistantPrefix: true, specialBlocks: true, hideThinkingLabel: true }),
31
+ messages: Object.freeze({
32
+ enabled: true,
33
+ assistantPrefix: true,
34
+ specialBlocks: true,
35
+ hideThinkingLabel: true,
36
+ hideInterimText: true,
37
+ }),
32
38
  tools: Object.freeze({
33
39
  enabled: true,
34
40
  style: "compact-box",
@@ -36,6 +42,8 @@ export const DEFAULT_CONFIG: NormalizedPiStyleConfig = Object.freeze({
36
42
  maxExpandedLines: 50,
37
43
  dimOutput: false,
38
44
  showElapsed: true,
45
+ collapseAfterTurn: true,
46
+ collapseMutatingTools: false,
39
47
  }),
40
48
  theme: Object.freeze({
41
49
  nerdFonts: "auto",
@@ -169,6 +177,7 @@ export function normalizeConfig(
169
177
  assistantPrefix: bool(messages.assistantPrefix, defaults.messages.assistantPrefix),
170
178
  specialBlocks: bool(messages.specialBlocks, defaults.messages.specialBlocks),
171
179
  hideThinkingLabel: bool(messages.hideThinkingLabel, defaults.messages.hideThinkingLabel),
180
+ hideInterimText: bool(messages.hideInterimText, defaults.messages.hideInterimText),
172
181
  }),
173
182
  tools: Object.freeze({
174
183
  enabled: bool(tools.enabled, defaults.tools.enabled),
@@ -177,6 +186,8 @@ export function normalizeConfig(
177
186
  maxExpandedLines: maxExpanded,
178
187
  dimOutput: bool(tools.dimOutput, defaults.tools.dimOutput),
179
188
  showElapsed: bool(tools.showElapsed, defaults.tools.showElapsed),
189
+ collapseAfterTurn: bool(tools.collapseAfterTurn, defaults.tools.collapseAfterTurn),
190
+ collapseMutatingTools: bool(tools.collapseMutatingTools, defaults.tools.collapseMutatingTools),
180
191
  }),
181
192
  theme: Object.freeze({
182
193
  nerdFonts: stringEnum(theme.nerdFonts, ["auto", "on", "off"], defaults.theme.nerdFonts),
@@ -227,9 +238,12 @@ const BOOL_PATHS = new Set([
227
238
  "messages.assistantPrefix",
228
239
  "messages.specialBlocks",
229
240
  "messages.hideThinkingLabel",
241
+ "messages.hideInterimText",
230
242
  "tools.enabled",
231
243
  "tools.showElapsed",
232
244
  "tools.dimOutput",
245
+ "tools.collapseAfterTurn",
246
+ "tools.collapseMutatingTools",
233
247
  "compatibility.allowSafePatches",
234
248
  "compatibility.allowCorePatches",
235
249
  "compatibility.preferExistingEditor",
@@ -10,7 +10,7 @@ export const CONFIG_PRESETS: Readonly<Record<PresetName, Readonly<PiStyleConfig>
10
10
  editor: { style: "native", frame: "native", showMetadata: false },
11
11
  startup: { mode: "off" },
12
12
  messages: { enabled: false },
13
- tools: { enabled: false },
13
+ tools: { enabled: false, collapseAfterTurn: false },
14
14
  theme: { terminalBackgroundSync: "off" },
15
15
  }),
16
16
  compact: Object.freeze({
@@ -41,7 +41,7 @@ export const CONFIG_PRESETS: Readonly<Record<PresetName, Readonly<PiStyleConfig>
41
41
  editor: { style: "native", frame: "native", showMetadata: false },
42
42
  startup: { mode: "off" },
43
43
  messages: { enabled: false },
44
- tools: { enabled: false },
44
+ tools: { enabled: false, collapseAfterTurn: false },
45
45
  theme: { terminalBackgroundSync: "off", autoApply: "off" },
46
46
  }),
47
47
  });
@@ -32,7 +32,13 @@ export interface PiStyleConfig {
32
32
  contextBarWidth?: number;
33
33
  };
34
34
  editor?: { enabled?: boolean; style?: string; frame?: string; showMetadata?: boolean; hint?: string };
35
- messages?: { enabled?: boolean; assistantPrefix?: boolean; specialBlocks?: boolean; hideThinkingLabel?: boolean };
35
+ messages?: {
36
+ enabled?: boolean;
37
+ assistantPrefix?: boolean;
38
+ specialBlocks?: boolean;
39
+ hideThinkingLabel?: boolean;
40
+ hideInterimText?: boolean;
41
+ };
36
42
  tools?: {
37
43
  enabled?: boolean;
38
44
  style?: string;
@@ -40,6 +46,10 @@ export interface PiStyleConfig {
40
46
  maxExpandedLines?: number;
41
47
  dimOutput?: boolean;
42
48
  showElapsed?: boolean;
49
+ /** Collapse a completed turn's tool blocks into one summary line (ADR 0007). */
50
+ collapseAfterTurn?: boolean;
51
+ /** Also collapse mutating tools (edit/write/…) into the summary; off keeps them visible. */
52
+ collapseMutatingTools?: boolean;
43
53
  };
44
54
  theme?: {
45
55
  nerdFonts?: string;
@@ -81,7 +91,13 @@ export interface NormalizedPiStyleConfig {
81
91
  contextBarWidth: number;
82
92
  };
83
93
  readonly editor: { enabled: boolean; style: EditorStyle; frame: EditorFrame; showMetadata: boolean; hint: string };
84
- readonly messages: { enabled: boolean; assistantPrefix: boolean; specialBlocks: boolean; hideThinkingLabel: boolean };
94
+ readonly messages: {
95
+ enabled: boolean;
96
+ assistantPrefix: boolean;
97
+ specialBlocks: boolean;
98
+ hideThinkingLabel: boolean;
99
+ hideInterimText: boolean;
100
+ };
85
101
  readonly tools: {
86
102
  enabled: boolean;
87
103
  style: string;
@@ -89,6 +105,10 @@ export interface NormalizedPiStyleConfig {
89
105
  maxExpandedLines: number;
90
106
  dimOutput: boolean;
91
107
  showElapsed: boolean;
108
+ /** Collapse a completed turn's tool blocks into one summary line (ADR 0007). */
109
+ collapseAfterTurn: boolean;
110
+ /** Also collapse mutating tools (edit/write/…) into the summary; off keeps them visible. */
111
+ collapseMutatingTools: boolean;
92
112
  };
93
113
  readonly theme: {
94
114
  nerdFonts: NerdFontsMode;
@@ -178,6 +178,8 @@ export type MessageDecorationSnapshot = Readonly<{
178
178
  assistantEnabled: boolean;
179
179
  /** Drop the hidden-thinking label row and its trailing spacer (zero-trace collapse). */
180
180
  collapseHiddenThinking: boolean;
181
+ /** Hide the text of assistant messages that also carry tool calls (interim narration). */
182
+ hideInterimText: boolean;
181
183
  }>;
182
184
 
183
185
  export function decorateMessageRender(
@@ -188,6 +190,7 @@ export function decorateMessageRender(
188
190
  assistantPrefix: "│ ",
189
191
  assistantEnabled: true,
190
192
  collapseHiddenThinking: false,
193
+ hideInterimText: false,
191
194
  },
192
195
  ): unknown {
193
196
  if (typeof original !== "function") return undefined;
@@ -207,6 +210,34 @@ function isSpacerChild(child: unknown): boolean {
207
210
  return typeof (child as { setLines?: unknown } | undefined)?.setLines === "function";
208
211
  }
209
212
 
213
+ /**
214
+ * The assistant TEXT Markdown block: `Markdown(text, pad, 0, theme, undefined,
215
+ * { transform })` — duck-typed by setText + options object + no defaultTextStyle.
216
+ * Thinking Markdown passes a `{ color, italic }` defaultTextStyle and is
217
+ * therefore excluded; plain Text components carry no `options`.
218
+ */
219
+ function isInterimTextChild(child: unknown): boolean {
220
+ const candidate = child as { setText?: unknown; options?: unknown; defaultTextStyle?: unknown } | undefined;
221
+ return (
222
+ typeof candidate?.setText === "function" &&
223
+ candidate.options !== undefined &&
224
+ typeof candidate.options === "object" &&
225
+ candidate.defaultTextStyle === undefined
226
+ );
227
+ }
228
+
229
+ /** Whether the message content includes a toolCall item (interim narration marker). */
230
+ function hasToolCallItems(message: unknown): boolean {
231
+ if (!message || typeof message !== "object") return false;
232
+ const content = (message as { content?: unknown }).content;
233
+ return (
234
+ Array.isArray(content) &&
235
+ content.some(
236
+ (item) => item !== null && typeof item === "object" && (item as { type?: unknown }).type === "toolCall",
237
+ )
238
+ );
239
+ }
240
+
210
241
  /**
211
242
  * The hidden-thinking placeholder: a Text (setCustomBgFn) whose ANSI-stripped
212
243
  * rendered content is empty. Duck-typed on the public shape because
@@ -240,11 +271,11 @@ export function decorateMessageUpdate(
240
271
  assistantPrefix: "│ ",
241
272
  assistantEnabled: true,
242
273
  collapseHiddenThinking: false,
274
+ hideInterimText: false,
243
275
  },
244
276
  ): unknown {
245
277
  if (typeof original !== "function") return undefined;
246
278
  const result = Reflect.apply(original, instance, args);
247
- if (!snapshot.collapseHiddenThinking) return result;
248
279
  const target = instance as {
249
280
  hideThinkingBlock?: boolean;
250
281
  hiddenThinkingLabel?: string;
@@ -252,15 +283,32 @@ export function decorateMessageUpdate(
252
283
  };
253
284
  // Only meaningful when Pi renders the hidden-block label (hideThinkingBlock)
254
285
  // and the extension has blanked that label out ("" — the zero-trace mode).
255
- if (target.hideThinkingBlock !== true || target.hiddenThinkingLabel !== "") return result;
256
- const children = target.contentContainer?.children;
257
- if (!children) return result;
258
- for (let index = children.length - 1; index >= 0; index--) {
259
- if (!isBlankTextChild(children[index])) continue;
260
- children.splice(index, 1);
261
- // Drop the Spacer(1) the native layout appends after the thinking run when
262
- // another visible block follows; the message keeps only its shared top padding.
263
- if (isSpacerChild(children[index])) children.splice(index, 1);
286
+ if (snapshot.collapseHiddenThinking && target.hideThinkingBlock === true && target.hiddenThinkingLabel === "") {
287
+ const children = target.contentContainer?.children;
288
+ if (children) {
289
+ for (let index = children.length - 1; index >= 0; index--) {
290
+ if (!isBlankTextChild(children[index])) continue;
291
+ children.splice(index, 1);
292
+ // Drop the Spacer(1) the native layout appends after the thinking run when
293
+ // another visible block follows; the message keeps only its shared top padding.
294
+ if (isSpacerChild(children[index])) children.splice(index, 1);
295
+ }
296
+ }
297
+ }
298
+ // Interim narration: assistant messages that carry tool calls use their text
299
+ // only to narrate while working; the tool blocks tell the story. Hide the text
300
+ // so the feed shows the run's summary and the final answer. Deterministic per
301
+ // content — streaming, scroll-back, and resume behave identically. Errors and
302
+ // truncation notices are Text children and stay; if only spacers remain the
303
+ // message becomes zero-trace.
304
+ if (snapshot.hideInterimText && hasToolCallItems(args[0])) {
305
+ const children = target.contentContainer?.children;
306
+ if (children) {
307
+ for (let index = children.length - 1; index >= 0; index--) {
308
+ if (isInterimTextChild(children[index])) children.splice(index, 1);
309
+ }
310
+ if (children.every((child) => isSpacerChild(child))) children.length = 0;
311
+ }
264
312
  }
265
313
  return result;
266
314
  }
@@ -7,7 +7,7 @@
7
7
  import type { Component } from "@earendil-works/pi-tui";
8
8
  import type { BoxTheme } from "../../../shared/box.js";
9
9
  import { bashTool } from "./bash.js";
10
- import { closeActiveBatch, isBatchableTool } from "./batch.js";
10
+ import { closeActiveBatch, EMPTY_BATCH_COMPONENT, isBatchableTool } from "./batch.js";
11
11
  import { editTool } from "./edit.js";
12
12
  import { renderFallbackCall, renderFallbackResult } from "./fallback.js";
13
13
  import { findTool } from "./find.js";
@@ -15,7 +15,17 @@ import { grepTool } from "./grep.js";
15
15
  import { lsTool } from "./ls.js";
16
16
  import { getQuickEditToolConfig, quickEditTool } from "./quick-edit.js";
17
17
  import { readTool } from "./read.js";
18
+ import { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
18
19
  import type { BoxedToolContext, BoxedToolDefinition } from "./shared.js";
20
+ import {
21
+ emptyTurnResult,
22
+ getTurnEntry,
23
+ isMutatingTool,
24
+ noteTurnMemberElapsed,
25
+ noteTurnMemberRender,
26
+ renderTurnSummaryCall,
27
+ type TurnState,
28
+ } from "./turn-summary.js";
19
29
  import { writeTool } from "./write.js";
20
30
 
21
31
  function quickEditToolFor(toolName: string): BoxedToolDefinition {
@@ -41,6 +51,22 @@ export function hasBoxedRenderer(toolName: unknown): boolean {
41
51
  return typeof toolName === "string" && Object.hasOwn(REGISTRY, toolName);
42
52
  }
43
53
 
54
+ /**
55
+ * Turn-summary gate (ADR 0007): the member belongs to an ended turn, Pi's
56
+ * global tool-output state is collapsed, the surface is enabled, and the block
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.
60
+ */
61
+ function collapsedTurnFor(toolCallId: string, expanded: boolean): TurnState | undefined {
62
+ const config = getToolsRenderConfig();
63
+ if (expanded || !config.collapseAfterTurn) return undefined;
64
+ const entry = getTurnEntry(toolCallId);
65
+ if (!entry?.turn.ended || entry.member.isError) return undefined;
66
+ if (isMutatingTool(entry.member.toolName) && !config.collapseMutatingTools) return undefined;
67
+ return entry.turn;
68
+ }
69
+
44
70
  export function renderBoxedToolCall(
45
71
  toolName: unknown,
46
72
  args: Record<string, unknown>,
@@ -50,6 +76,16 @@ export function renderBoxedToolCall(
50
76
  // Any non-batchable tool call is a batch boundary: the next quiet call starts
51
77
  // a fresh batch instead of joining the previous one.
52
78
  if (!isBatchableTool(toolName)) closeActiveBatch();
79
+ const turn = collapsedTurnFor(context.toolCallId, context.expanded);
80
+ if (turn) {
81
+ if (turn.leaderId === context.toolCallId) return renderTurnSummaryCall(theme, turn);
82
+ // Same singleton the batch machinery uses: the decoration's hideBatchMember
83
+ // (identity-compared) removes the instance so members consume zero lines.
84
+ return EMPTY_BATCH_COMPONENT;
85
+ }
86
+ // Capture the component invalidate so the turn_end path can force this block
87
+ // to re-run the renderer selectors (pi only re-invokes them from updateDisplay).
88
+ noteTurnMemberRender(context.toolCallId, context.invalidate);
53
89
  const tool = typeof toolName === "string" ? REGISTRY[toolName] : undefined;
54
90
  if (tool) return tool.call(args, theme, context);
55
91
  return renderFallbackCall(toolName, args, theme, context);
@@ -62,6 +98,15 @@ export function renderBoxedToolResult(
62
98
  theme: BoxTheme,
63
99
  context: BoxedToolContext,
64
100
  ): Component {
101
+ const turn = collapsedTurnFor(context.toolCallId, options.expanded);
102
+ if (turn) {
103
+ // Freeze the member's wall-clock elapsed into the registry once the turn
104
+ // collapsed (the value is already frozen by the renderer context state).
105
+ if (!options.isPartial) noteTurnMemberElapsed(context.toolCallId, getStateElapsedMs(context.state));
106
+ if (turn.leaderId === context.toolCallId) return emptyTurnResult();
107
+ return EMPTY_BATCH_COMPONENT;
108
+ }
109
+ noteTurnMemberRender(context.toolCallId, context.invalidate);
65
110
  const tool = typeof toolName === "string" ? REGISTRY[toolName] : undefined;
66
111
  if (tool) return tool.result(result, options, theme, context);
67
112
  return renderFallbackResult(toolName, result, options, theme, context);
@@ -12,6 +12,10 @@ export interface ToolsRenderConfig {
12
12
  batchOpenGlyph: string;
13
13
  /** Nerd Font mode is active: file-type icons render in output trees. */
14
14
  nerdFonts: boolean;
15
+ /** Collapse a completed turn's tool blocks into one summary line (ADR 0007). */
16
+ collapseAfterTurn: boolean;
17
+ /** Also collapse mutating tools (edit/write/…) into the summary; off keeps them visible. */
18
+ collapseMutatingTools: boolean;
15
19
  }
16
20
 
17
21
  let sessionToolsConfig: ToolsRenderConfig = {
@@ -21,6 +25,8 @@ let sessionToolsConfig: ToolsRenderConfig = {
21
25
  showElapsed: true,
22
26
  batchOpenGlyph: "●",
23
27
  nerdFonts: false,
28
+ collapseAfterTurn: true,
29
+ collapseMutatingTools: false,
24
30
  };
25
31
 
26
32
  export function setToolsRenderConfig(config: Partial<ToolsRenderConfig>): void {