@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.
- package/CHANGELOG.md +10 -0
- package/dist/extensions/pi-style.js +72 -23
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +2 -0
- package/extension-src/pi-style/domain/config-normalization.ts +3 -0
- package/extension-src/pi-style/domain/config-types.ts +4 -0
- package/extension-src/pi-style/features/tools/boxed/index.ts +7 -2
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +3 -0
- package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +56 -11
- package/extension-src/pi-style/features/tools/index.ts +19 -4
- package/extension-src/pi-style/shared/box.ts +30 -6
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
6
|
-
// item of the turn renders zero lines.
|
|
7
|
-
// turns never collapse, and Pi's
|
|
8
|
-
//
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
/**
|
|
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]
|
|
327
|
-
|
|
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("
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
571
|
-
|
|
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
|
|
584
|
-
|
|
585
|
-
|
|
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(
|
|
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,
|