@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.
- package/CHANGELOG.md +16 -0
- package/README.md +3 -1
- package/dist/extensions/pi-style.js +8345 -8004
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +6 -0
- package/extension-src/pi-style/domain/config-normalization.ts +15 -1
- package/extension-src/pi-style/domain/config-presets.ts +2 -2
- package/extension-src/pi-style/domain/config-types.ts +22 -2
- package/extension-src/pi-style/features/messages/index.ts +58 -10
- package/extension-src/pi-style/features/tools/boxed/index.ts +46 -1
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +6 -0
- package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +413 -0
- package/extension-src/pi-style/features/tools/index.ts +34 -4
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +1 -0
- package/extension-src/pi-style/pi/compatibility-probe.ts +8 -2
- package/extension-src/pi-style/pi/index.ts +37 -3
- package/extension-src/pi-style/pi/session-coordinator.ts +7 -0
- package/extension-src/pi-style/shared/box.ts +30 -6
- package/package.json +1 -1
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// Turn tool summary registry (ADR 0007).
|
|
2
|
+
//
|
|
3
|
+
// When a turn completes, its finalized tool blocks collapse into a single
|
|
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 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.
|
|
17
|
+
//
|
|
18
|
+
// Design notes:
|
|
19
|
+
// - The registry is populated from **session content**, never from runtime
|
|
20
|
+
// event flags: the live path registers the final assistant message +
|
|
21
|
+
// toolResults at `turn_end`; the restore path rebuilds the registry from
|
|
22
|
+
// `sessionManager.getEntries()` at session start / `session_tree`, so
|
|
23
|
+
// scroll-back and session resume render identically.
|
|
24
|
+
// - A turn is "ended" only when every tool call of its message has a matching
|
|
25
|
+
// tool result AND (live) turn_end fired / (restore) the message is
|
|
26
|
+
// finalized (`stopReason`) or a later user/assistant message exists.
|
|
27
|
+
// - Elapsed per member is frozen from the renderer wall-clock state
|
|
28
|
+
// (STARTED_AT/ENDED_AT) at the first post-turn result pass; the summary
|
|
29
|
+
// totals the members' frozen elapsed. No render-time I/O.
|
|
30
|
+
// - No new Pi-core patch identity: the dispatcher (boxed/index.ts) decides
|
|
31
|
+
// collapse before the per-tool renderers run, so every certified renderer
|
|
32
|
+
// surface stays untouched when the turn is not collapsed.
|
|
33
|
+
|
|
34
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
35
|
+
import type { BoxTheme } from "../../../shared/box.js";
|
|
36
|
+
import { safeTruncateToWidth } from "../../../shared/render-budget.js";
|
|
37
|
+
import { pluralForm } from "./output-tree.js";
|
|
38
|
+
import { getToolsRenderConfig } from "./session-config.js";
|
|
39
|
+
|
|
40
|
+
export interface TurnMemberInfo {
|
|
41
|
+
readonly toolCallId: string;
|
|
42
|
+
readonly toolName: string;
|
|
43
|
+
/** Whether a tool result was registered for this call (run completeness). */
|
|
44
|
+
readonly hasResult: boolean;
|
|
45
|
+
isError: boolean;
|
|
46
|
+
/** Frozen wall-clock elapsed (ms), recorded from the renderer context state. */
|
|
47
|
+
elapsedMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TurnState {
|
|
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
|
+
*/
|
|
58
|
+
leaderId: string;
|
|
59
|
+
ended: boolean;
|
|
60
|
+
members: readonly TurnMemberInfo[];
|
|
61
|
+
}
|
|
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
|
+
|
|
82
|
+
interface TurnEntry {
|
|
83
|
+
readonly turn: TurnState;
|
|
84
|
+
readonly member: TurnMemberInfo;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const memberByCallId = new Map<string, TurnEntry>();
|
|
88
|
+
|
|
89
|
+
/** Per-member component invalidate callbacks captured during render passes. */
|
|
90
|
+
const invalidateByCallId = new Map<string, () => void>();
|
|
91
|
+
|
|
92
|
+
/** Reset all turn state (session start/shutdown). */
|
|
93
|
+
export function resetTurnRegistry(): void {
|
|
94
|
+
memberByCallId.clear();
|
|
95
|
+
invalidateByCallId.clear();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface ToolCallLike {
|
|
99
|
+
readonly type?: unknown;
|
|
100
|
+
readonly id?: unknown;
|
|
101
|
+
readonly name?: unknown;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toolCallsOf(message: unknown): ToolCallLike[] {
|
|
105
|
+
if (!message || typeof message !== "object") return [];
|
|
106
|
+
const content = (message as { readonly content?: unknown }).content;
|
|
107
|
+
if (!Array.isArray(content)) return [];
|
|
108
|
+
const calls: ToolCallLike[] = [];
|
|
109
|
+
for (const item of content) {
|
|
110
|
+
if (!item || typeof item !== "object") continue;
|
|
111
|
+
const candidate = item as ToolCallLike;
|
|
112
|
+
if (candidate.type === "toolCall") calls.push(candidate);
|
|
113
|
+
}
|
|
114
|
+
return calls;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function registerTurn(
|
|
118
|
+
calls: readonly ToolCallLike[],
|
|
119
|
+
isErrorById: ReadonlyMap<string, boolean>,
|
|
120
|
+
ended: boolean,
|
|
121
|
+
): TurnState | undefined {
|
|
122
|
+
if (calls.length === 0) return undefined;
|
|
123
|
+
const complete = calls.every((call) => typeof call.id === "string" && isErrorById.has(call.id));
|
|
124
|
+
const members: TurnMemberInfo[] = calls.map((call) => {
|
|
125
|
+
const toolCallId = String(call.id ?? "");
|
|
126
|
+
return {
|
|
127
|
+
toolCallId,
|
|
128
|
+
toolName: typeof call.name === "string" ? call.name : "tool",
|
|
129
|
+
hasResult: isErrorById.has(toolCallId),
|
|
130
|
+
isError: isErrorById.get(toolCallId) === true,
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
const leader = members.find((member) => !member.isError && (!isMutatingTool(member.toolName) || mutatingCollapses()));
|
|
134
|
+
const turn: TurnState = {
|
|
135
|
+
leaderId: leader?.toolCallId ?? "",
|
|
136
|
+
ended: ended && complete,
|
|
137
|
+
members: Object.freeze(members),
|
|
138
|
+
};
|
|
139
|
+
for (const member of members) memberByCallId.set(member.toolCallId, { turn, member });
|
|
140
|
+
return turn;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface TurnResultLike {
|
|
144
|
+
readonly toolCallId: string;
|
|
145
|
+
readonly isError?: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One summary group = one agent run (user request → `agent_end`). Pi emits
|
|
150
|
+
* `turn_end` per assistant message, so tool batches of the same request are
|
|
151
|
+
* appended to the same run and collapse into ONE summary line at `agent_end`.
|
|
152
|
+
*/
|
|
153
|
+
let currentRun: TurnState | undefined;
|
|
154
|
+
|
|
155
|
+
/** Live path: start a fresh run group (`agent_start`). */
|
|
156
|
+
export function beginAgentRun(): void {
|
|
157
|
+
currentRun = undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Live path: append the finalized assistant message's tool calls and results
|
|
162
|
+
* to the current run (`turn_end` event). The run stays expanded until
|
|
163
|
+
* `finishAgentRun`; a batch interrupted mid-tool never collapses.
|
|
164
|
+
*/
|
|
165
|
+
export function registerTurnFromMessage(message: unknown, toolResults: readonly TurnResultLike[]): void {
|
|
166
|
+
const calls = toolCallsOf(message);
|
|
167
|
+
if (calls.length === 0) return;
|
|
168
|
+
const isErrorById = new Map<string, boolean>();
|
|
169
|
+
for (const result of toolResults) {
|
|
170
|
+
if (typeof result?.toolCallId !== "string") continue;
|
|
171
|
+
isErrorById.set(result.toolCallId, result.isError === true);
|
|
172
|
+
}
|
|
173
|
+
const newMembers: TurnMemberInfo[] = calls.map((call) => {
|
|
174
|
+
const toolCallId = String(call.id ?? "");
|
|
175
|
+
return {
|
|
176
|
+
toolCallId,
|
|
177
|
+
toolName: typeof call.name === "string" ? call.name : "tool",
|
|
178
|
+
hasResult: isErrorById.has(toolCallId),
|
|
179
|
+
isError: isErrorById.get(toolCallId) === true,
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
const leader = newMembers.find(
|
|
183
|
+
(member) => !member.isError && (!isMutatingTool(member.toolName) || mutatingCollapses()),
|
|
184
|
+
);
|
|
185
|
+
if (!currentRun) {
|
|
186
|
+
currentRun = {
|
|
187
|
+
leaderId: leader?.toolCallId ?? "",
|
|
188
|
+
ended: false,
|
|
189
|
+
members: Object.freeze(newMembers),
|
|
190
|
+
};
|
|
191
|
+
} else {
|
|
192
|
+
if (currentRun.leaderId === "" && leader) currentRun.leaderId = leader.toolCallId;
|
|
193
|
+
currentRun.members = Object.freeze([...currentRun.members, ...newMembers]);
|
|
194
|
+
}
|
|
195
|
+
for (const member of newMembers) memberByCallId.set(member.toolCallId, { turn: currentRun, member });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Live path: finalize the current run (`agent_end`). Returns the run so the
|
|
200
|
+
* caller can invalidate its blocks; undefined when the run had no tool calls
|
|
201
|
+
* or is interrupted (a call without a result stays expanded).
|
|
202
|
+
*/
|
|
203
|
+
export function finishAgentRun(): TurnState | undefined {
|
|
204
|
+
const run = currentRun;
|
|
205
|
+
currentRun = undefined;
|
|
206
|
+
if (!run) return undefined;
|
|
207
|
+
// A member without a result means the run was interrupted before every call
|
|
208
|
+
// settled; such a run never collapses.
|
|
209
|
+
if (run.members.every((member) => member.hasResult)) run.ended = true;
|
|
210
|
+
return run.ended ? run : undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface TurnEntryLike {
|
|
214
|
+
readonly type?: unknown;
|
|
215
|
+
readonly message?: {
|
|
216
|
+
readonly role?: unknown;
|
|
217
|
+
readonly content?: unknown;
|
|
218
|
+
readonly stopReason?: unknown;
|
|
219
|
+
readonly toolCallId?: unknown;
|
|
220
|
+
readonly isError?: unknown;
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Restore path: rebuild the registry from session entries (session start /
|
|
226
|
+
* `session_tree`). Consecutive assistant messages between user messages form
|
|
227
|
+
* one run; a run is ended when every tool call has a result AND a later user
|
|
228
|
+
* message exists (historical) or its last assistant message is finalized
|
|
229
|
+
* (`stopReason`). The currently streaming run stays expanded.
|
|
230
|
+
*/
|
|
231
|
+
export function rebuildTurnRegistryFromEntries(entries: readonly TurnEntryLike[] | undefined): void {
|
|
232
|
+
memberByCallId.clear();
|
|
233
|
+
if (!Array.isArray(entries)) return;
|
|
234
|
+
const isErrorById = new Map<string, boolean>();
|
|
235
|
+
const resultById = new Set<string>();
|
|
236
|
+
const runs: Array<{
|
|
237
|
+
calls: ToolCallLike[];
|
|
238
|
+
lastStopReason: string | undefined;
|
|
239
|
+
followedByUser: boolean;
|
|
240
|
+
}> = [];
|
|
241
|
+
let current: (typeof runs)[number] | undefined;
|
|
242
|
+
const closeRun = () => {
|
|
243
|
+
if (current && current.calls.length > 0) runs.push(current);
|
|
244
|
+
current = undefined;
|
|
245
|
+
};
|
|
246
|
+
entries.forEach((entry) => {
|
|
247
|
+
if (entry?.type !== "message") return;
|
|
248
|
+
const message = entry.message;
|
|
249
|
+
if (message?.role === "toolResult" && typeof message.toolCallId === "string") {
|
|
250
|
+
resultById.add(message.toolCallId);
|
|
251
|
+
isErrorById.set(message.toolCallId, message.isError === true);
|
|
252
|
+
} else if (message?.role === "assistant") {
|
|
253
|
+
if (!current) current = { calls: [], lastStopReason: undefined, followedByUser: false };
|
|
254
|
+
const calls = toolCallsOf(message);
|
|
255
|
+
current.calls.push(...calls);
|
|
256
|
+
if (typeof message.stopReason === "string" && message.stopReason !== "")
|
|
257
|
+
current.lastStopReason = message.stopReason;
|
|
258
|
+
} else if (message?.role === "user") {
|
|
259
|
+
if (current) {
|
|
260
|
+
current.followedByUser = true;
|
|
261
|
+
closeRun();
|
|
262
|
+
} else {
|
|
263
|
+
// A user message with no preceding open run is a plain boundary.
|
|
264
|
+
closeRun();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
closeRun();
|
|
269
|
+
for (const run of runs) {
|
|
270
|
+
const complete = run.calls.every((call) => typeof call.id === "string" && resultById.has(call.id));
|
|
271
|
+
const ended = complete && (run.followedByUser || run.lastStopReason !== undefined);
|
|
272
|
+
registerTurn(run.calls, isErrorById, ended);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Registry lookup for the render dispatcher. */
|
|
277
|
+
export function getTurnEntry(toolCallId: string): TurnEntry | undefined {
|
|
278
|
+
return memberByCallId.get(toolCallId);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Capture a member's component invalidate callback during a render pass. Pi
|
|
283
|
+
* only re-invokes the tool renderer selectors from updateDisplay(); calling
|
|
284
|
+
* the captured callback after turn_end rebuilds the block with the collapsed
|
|
285
|
+
* summary. Idempotent per toolCallId (latest component wins).
|
|
286
|
+
*/
|
|
287
|
+
export function noteTurnMemberRender(toolCallId: string, invalidate: () => void): void {
|
|
288
|
+
if (typeof invalidate !== "function") return;
|
|
289
|
+
invalidateByCallId.set(toolCallId, invalidate);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Force the just-finished turn's tool blocks to re-render (updateDisplay).
|
|
294
|
+
* Components that never rendered (headless/print) have no captured callback.
|
|
295
|
+
*/
|
|
296
|
+
export function invalidateTurnMembers(turn: TurnState): void {
|
|
297
|
+
for (const member of turn.members) {
|
|
298
|
+
const invalidate = invalidateByCallId.get(member.toolCallId);
|
|
299
|
+
if (!invalidate) continue;
|
|
300
|
+
try {
|
|
301
|
+
invalidate();
|
|
302
|
+
} catch {
|
|
303
|
+
// A detached component must not break the turn-end path.
|
|
304
|
+
invalidateByCallId.delete(member.toolCallId);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Freeze a member's wall-clock elapsed into the registry (idempotent; the
|
|
311
|
+
* value is frozen by the renderer state once the terminal result rendered).
|
|
312
|
+
*/
|
|
313
|
+
export function noteTurnMemberElapsed(toolCallId: string, elapsedMs: number | undefined): void {
|
|
314
|
+
if (elapsedMs === undefined) return;
|
|
315
|
+
const entry = memberByCallId.get(toolCallId);
|
|
316
|
+
if (!entry || entry.member.elapsedMs !== undefined) return;
|
|
317
|
+
entry.member.elapsedMs = elapsedMs;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Per-tool summary phrasing: `Read 2 files` / `ran 4 shell commands`. */
|
|
321
|
+
const TURN_SUMMARY_STYLE: Readonly<Record<string, { readonly verb: string; readonly unit: string }>> = Object.freeze({
|
|
322
|
+
read: { verb: "Read", unit: "file" },
|
|
323
|
+
bash: { verb: "ran", unit: "shell command" },
|
|
324
|
+
ls: { verb: "Listed", unit: "path" },
|
|
325
|
+
find: { verb: "Found", unit: "file" },
|
|
326
|
+
grep: { verb: "Grepped", unit: "pattern" },
|
|
327
|
+
edit: { verb: "Edited", unit: "file" },
|
|
328
|
+
write: { verb: "Wrote", unit: "file" },
|
|
329
|
+
quick_edit: { verb: "Edited", unit: "file" },
|
|
330
|
+
substitute_edit: { verb: "Edited", unit: "file" },
|
|
331
|
+
target_edit: { verb: "Edited", unit: "file" },
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
export interface TurnSummaryParts {
|
|
335
|
+
/** `Read 2 files`, `ran 4 shell commands`, ... in first-use order. */
|
|
336
|
+
readonly parts: readonly string[];
|
|
337
|
+
readonly failedCount: number;
|
|
338
|
+
/** Sum of members' frozen elapsed; undefined when nothing was recorded. */
|
|
339
|
+
readonly elapsedMs: number | undefined;
|
|
340
|
+
}
|
|
341
|
+
|
|
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
|
+
*/
|
|
348
|
+
export function turnSummaryParts(turn: TurnState): TurnSummaryParts {
|
|
349
|
+
const counts = new Map<string, number>();
|
|
350
|
+
const order: string[] = [];
|
|
351
|
+
let failedCount = 0;
|
|
352
|
+
let elapsedMs: number | undefined;
|
|
353
|
+
const collapseMutating = mutatingCollapses();
|
|
354
|
+
for (const member of turn.members) {
|
|
355
|
+
if (member.isError) {
|
|
356
|
+
failedCount++;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (!collapseMutating && isMutatingTool(member.toolName)) continue;
|
|
360
|
+
if (member.elapsedMs !== undefined) elapsedMs = (elapsedMs ?? 0) + member.elapsedMs;
|
|
361
|
+
const existing = counts.get(member.toolName);
|
|
362
|
+
if (existing === undefined) {
|
|
363
|
+
counts.set(member.toolName, 1);
|
|
364
|
+
order.push(member.toolName);
|
|
365
|
+
} else counts.set(member.toolName, existing + 1);
|
|
366
|
+
}
|
|
367
|
+
const parts = order.map((toolName) => {
|
|
368
|
+
const count = counts.get(toolName) ?? 0;
|
|
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}`;
|
|
373
|
+
});
|
|
374
|
+
return { parts, failedCount, elapsedMs };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function formatTurnSummaryLine(theme: BoxTheme, turn: TurnState): string {
|
|
378
|
+
const summary = turnSummaryParts(turn);
|
|
379
|
+
// The summary is deliberately quiet: the whole line renders dim so completed
|
|
380
|
+
// tool work recedes behind the assistant's answer. Only the failed marker
|
|
381
|
+
// stays error-colored (errors must remain visible).
|
|
382
|
+
const parts = summary.parts.join(", ");
|
|
383
|
+
let line = `${theme.fg("dim", `➔ ${parts}`)}`;
|
|
384
|
+
if (summary.failedCount > 0)
|
|
385
|
+
line += theme.fg("error", ` · ${summary.failedCount} ${pluralForm("failure", summary.failedCount)}`);
|
|
386
|
+
if (summary.elapsedMs !== undefined) line += theme.fg("dim", ` · ${(summary.elapsedMs / 1000).toFixed(2)}s`);
|
|
387
|
+
return line;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Leader call component: renders the live turn summary line on every pass. */
|
|
391
|
+
export function renderTurnSummaryCall(theme: BoxTheme, turn: TurnState): Component {
|
|
392
|
+
return {
|
|
393
|
+
invalidate() {},
|
|
394
|
+
render(width: number): string[] {
|
|
395
|
+
return [safeTruncateToWidth(formatTurnSummaryLine(theme, turn), Math.max(1, width), "…")];
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Empty result component for the turn-summary leader. The summary lives in the
|
|
402
|
+
* call component; the result adds nothing. Deliberately NOT the shared
|
|
403
|
+
* EMPTY_BATCH_COMPONENT singleton, so the decoration's hideBatchMember
|
|
404
|
+
* (identity-compared) never hides the leader.
|
|
405
|
+
*/
|
|
406
|
+
export function emptyTurnResult(): Component {
|
|
407
|
+
return {
|
|
408
|
+
invalidate() {},
|
|
409
|
+
render() {
|
|
410
|
+
return [];
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
@@ -113,6 +113,19 @@ const DEFAULT_SNAPSHOT: ToolDecorationSnapshot = Object.freeze({
|
|
|
113
113
|
});
|
|
114
114
|
let ownerGeneration = 0;
|
|
115
115
|
const piStyleWrappers = new WeakSet<RenderFunction>();
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The real Tui instance captured from decorated tool components (`instance.ui`),
|
|
119
|
+
* used to request a repaint after the turn registry flips a turn to collapsed
|
|
120
|
+
* (pi only re-paints after its own events; the renderer selectors re-run on
|
|
121
|
+
* updateDisplay, but the screen refresh still needs a requestRender).
|
|
122
|
+
*/
|
|
123
|
+
let capturedToolUi: { requestRender?: (force?: boolean) => void } | undefined;
|
|
124
|
+
|
|
125
|
+
/** Request a screen repaint through the captured tool Tui (no-op headless). */
|
|
126
|
+
export function requestToolPresentationRender(): void {
|
|
127
|
+
capturedToolUi?.requestRender?.();
|
|
128
|
+
}
|
|
116
129
|
let toolTestHooks: { defineProperty?: typeof Reflect.defineProperty; deleteProperty?: typeof Reflect.deleteProperty } =
|
|
117
130
|
{};
|
|
118
131
|
export function __setToolDecorationTestHooks(hooks: typeof toolTestHooks): () => void {
|
|
@@ -392,12 +405,14 @@ export function createToolDecorationOwner(snapshot: Partial<ToolDecorationSnapsh
|
|
|
392
405
|
} else if (outcome === "later-owner") state.active.delete(record);
|
|
393
406
|
else state.failed++;
|
|
394
407
|
}
|
|
408
|
+
capturedToolUi = undefined;
|
|
395
409
|
const archive = state.active.size === 0 ? finalize(state) : undefined;
|
|
396
410
|
return { restored: state.restored, failed: state.failed, diagnostics: new Map(state.diagnostics), archive };
|
|
397
411
|
};
|
|
398
412
|
return Object.freeze({
|
|
399
413
|
decorateToolRendererSelection(subtype: RendererSubtype, original: unknown, instance: object, args: unknown[]) {
|
|
400
414
|
if (typeof original !== "function") return undefined;
|
|
415
|
+
capturedToolUi = (instance as { ui?: { requestRender?: (force?: boolean) => void } }).ui ?? capturedToolUi;
|
|
401
416
|
const renderer = Reflect.apply(original, instance, args);
|
|
402
417
|
if (state.snapshot.style === "compact-box") {
|
|
403
418
|
const toolName = (instance as { toolName?: unknown }).toolName;
|
|
@@ -408,16 +423,31 @@ export function createToolDecorationOwner(snapshot: Partial<ToolDecorationSnapsh
|
|
|
408
423
|
if (typeof renderer !== "function") {
|
|
409
424
|
neutralizeToolContainerBackground(instance);
|
|
410
425
|
if (subtype === "tool-call-renderer")
|
|
411
|
-
return (callArgs: unknown, theme: unknown, context: unknown) =>
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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(
|
|
415
442
|
toolName,
|
|
416
443
|
result as { content?: readonly unknown[]; details?: unknown },
|
|
417
444
|
options as { expanded: boolean; isPartial: boolean },
|
|
418
445
|
theme as never,
|
|
419
446
|
context as never,
|
|
420
447
|
);
|
|
448
|
+
if (component === EMPTY_BATCH_COMPONENT) hideBatchMember(instance);
|
|
449
|
+
return component;
|
|
450
|
+
};
|
|
421
451
|
}
|
|
422
452
|
return function (this: unknown, ...rendererArgs: unknown[]) {
|
|
423
453
|
const valid = subtype === "tool-call-renderer" ? validCallArgs(rendererArgs) : validResultArgs(rendererArgs);
|
|
@@ -168,6 +168,7 @@ export function createCompatibilityCoordinator(dispose = disposePiCompatibilityP
|
|
|
168
168
|
assistantPrefix: authorization.ascii ? "[assistant] " : "│ ",
|
|
169
169
|
assistantEnabled,
|
|
170
170
|
collapseHiddenThinking: thinkingCollapseEnabled,
|
|
171
|
+
hideInterimText: config.messages.hideInterimText && messagesEnabled,
|
|
171
172
|
},
|
|
172
173
|
toolSnapshot: {
|
|
173
174
|
callMarker: authorization.ascii ? "[tool] " : "[tool] ",
|
|
@@ -369,7 +369,13 @@ function shape(spec: TargetSpec): boolean {
|
|
|
369
369
|
export interface CompatibilityProbeOptions {
|
|
370
370
|
markers?: Set<string>;
|
|
371
371
|
config?: Readonly<{
|
|
372
|
-
messages: {
|
|
372
|
+
messages: {
|
|
373
|
+
enabled: boolean;
|
|
374
|
+
assistantPrefix: boolean;
|
|
375
|
+
specialBlocks: boolean;
|
|
376
|
+
hideThinkingLabel: boolean;
|
|
377
|
+
hideInterimText: boolean;
|
|
378
|
+
};
|
|
373
379
|
tools: { enabled: boolean; style: string; maxCollapsedLines: number; maxExpandedLines: number; dimOutput: boolean };
|
|
374
380
|
preset: string;
|
|
375
381
|
}>;
|
|
@@ -417,7 +423,7 @@ function surfaceDisabled(spec: TargetSpec, config: CompatibilityProbeOptions["co
|
|
|
417
423
|
if (!config.messages.enabled) return true;
|
|
418
424
|
if (spec.subtype === "native-assistant-message" && spec.method === "render") return !config.messages.assistantPrefix;
|
|
419
425
|
if (spec.subtype === "native-assistant-message" && spec.method === "updateContent")
|
|
420
|
-
return !config.messages.hideThinkingLabel;
|
|
426
|
+
return !config.messages.hideThinkingLabel && !config.messages.hideInterimText;
|
|
421
427
|
if (isSpecialBlock(spec)) return !config.messages.specialBlocks;
|
|
422
428
|
return true;
|
|
423
429
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { StatusSnapshot } from "../domain/status.js";
|
|
3
3
|
import { closeActiveBatch } from "../features/tools/boxed/batch.js";
|
|
4
|
+
import {
|
|
5
|
+
beginAgentRun,
|
|
6
|
+
finishAgentRun,
|
|
7
|
+
invalidateTurnMembers,
|
|
8
|
+
rebuildTurnRegistryFromEntries,
|
|
9
|
+
registerTurnFromMessage,
|
|
10
|
+
} from "../features/tools/boxed/turn-summary.js";
|
|
11
|
+
import { requestToolPresentationRender } from "../features/tools/index.js";
|
|
4
12
|
import { registerPiStyleCommand } from "./commands.js";
|
|
5
13
|
import { type CompatibilityTestHooks, createPiStyleSessionCoordinator } from "./session-coordinator.js";
|
|
6
14
|
import { usageFromSession } from "./session-usage.js";
|
|
@@ -66,7 +74,12 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
66
74
|
}
|
|
67
75
|
await coordinator.start(event, ctx);
|
|
68
76
|
});
|
|
69
|
-
pi.on("agent_start", () =>
|
|
77
|
+
pi.on("agent_start", () => {
|
|
78
|
+
coordinator.app.runtime.current?.dismissStartup();
|
|
79
|
+
// Turn summary (ADR 0007): a summary group spans the whole agent run
|
|
80
|
+
// (user request → agent_end), not pi's per-message turn_end.
|
|
81
|
+
beginAgentRun();
|
|
82
|
+
});
|
|
70
83
|
pi.on("input", (event, _ctx) => {
|
|
71
84
|
coordinator.app.runtime.current?.dismissStartup();
|
|
72
85
|
// Bare `!`/`!!` submit guard: Pi treats `!`-prefixed input as a direct bash
|
|
@@ -111,9 +124,30 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
111
124
|
// message/turn boundaries, mirroring Pi's native footer; per-chunk updates
|
|
112
125
|
// stay usage-free to keep streaming cheap.
|
|
113
126
|
pi.on("message_end", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "coalesced"));
|
|
114
|
-
pi.on("turn_end", (
|
|
127
|
+
pi.on("turn_end", (event, ctx) => {
|
|
128
|
+
// Append the finalized assistant message's tool batch to the current run.
|
|
129
|
+
registerTurnFromMessage(event.message, event.toolResults);
|
|
130
|
+
coordinator.app.update({ ...usagePatch(ctx) }, "deferred");
|
|
131
|
+
});
|
|
132
|
+
pi.on("agent_end", () => {
|
|
133
|
+
// The run is complete: collapse its tool blocks into one summary line.
|
|
134
|
+
// Pi only re-invokes the tool renderer selectors from updateDisplay(), so
|
|
135
|
+
// the captured per-block invalidate callbacks force the collapse and the
|
|
136
|
+
// captured Tui repaints. Interrupted runs (a call without a result) stay
|
|
137
|
+
// expanded.
|
|
138
|
+
const run = finishAgentRun();
|
|
139
|
+
if (run) {
|
|
140
|
+
invalidateTurnMembers(run);
|
|
141
|
+
requestToolPresentationRender();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
115
144
|
pi.on("agent_settled", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "coalesced"));
|
|
116
|
-
pi.on("session_tree", (_event, ctx) =>
|
|
145
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
146
|
+
// Rebuild the turn registry from session content so restored/branched
|
|
147
|
+
// history renders collapsed consistently (no in-process turn_end events).
|
|
148
|
+
rebuildTurnRegistryFromEntries(ctx.sessionManager.getEntries());
|
|
149
|
+
coordinator.app.update({ ...usagePatch(ctx) }, "deferred");
|
|
150
|
+
});
|
|
117
151
|
pi.on("session_compact", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "deferred"));
|
|
118
152
|
pi.on("tool_result", (event, ctx) => {
|
|
119
153
|
if (["write", "edit", "bash"].includes(event.toolName)) {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
stopAllElapsedTickers,
|
|
14
14
|
type ToolsRenderConfig,
|
|
15
15
|
} from "../features/tools/boxed/session-config.js";
|
|
16
|
+
import { rebuildTurnRegistryFromEntries, resetTurnRegistry } from "../features/tools/boxed/turn-summary.js";
|
|
16
17
|
import { createCompatibilityCoordinator } from "./compatibility-coordinator.js";
|
|
17
18
|
import {
|
|
18
19
|
type CompatibilityCleanupResult,
|
|
@@ -175,6 +176,11 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
|
|
|
175
176
|
resetBatchRegistry();
|
|
176
177
|
resetGrepRegistry();
|
|
177
178
|
resetBashTreeRegistry();
|
|
179
|
+
// Turn summaries (ADR 0007): rebuild the registry from session content so
|
|
180
|
+
// restored/forked history renders collapsed before the first render pass
|
|
181
|
+
// (deterministic; no in-process turn_end events needed).
|
|
182
|
+
resetTurnRegistry();
|
|
183
|
+
rebuildTurnRegistryFromEntries(ctx.sessionManager.getEntries());
|
|
178
184
|
// Stop any 1s elapsed re-render ticker left by a tool that was still
|
|
179
185
|
// running when the session ended.
|
|
180
186
|
stopAllElapsedTickers();
|
|
@@ -247,6 +253,7 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
|
|
|
247
253
|
resetBatchRegistry();
|
|
248
254
|
resetGrepRegistry();
|
|
249
255
|
resetBashTreeRegistry();
|
|
256
|
+
resetTurnRegistry();
|
|
250
257
|
stopAllElapsedTickers();
|
|
251
258
|
app.sessionShutdown();
|
|
252
259
|
// Tier C prototype patches stay installed across session switches. Pi renders
|
|
@@ -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,
|