@quandev104/pi-style 0.1.7 → 0.2.1
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/dist/extensions/pi-style.js +763 -319
- 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/app/index.ts +3 -2
- package/extension-src/pi-style/app/runtime.ts +72 -84
- package/extension-src/pi-style/app/snapshot.ts +41 -2
- 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/editor/index.ts +80 -61
- package/extension-src/pi-style/features/messages/index.ts +194 -56
- package/extension-src/pi-style/features/tools/bash-execution.ts +12 -1
- package/extension-src/pi-style/features/tools/boxed/bash.ts +99 -31
- package/extension-src/pi-style/features/tools/boxed/batch.ts +44 -10
- package/extension-src/pi-style/features/tools/boxed/edit.ts +30 -15
- package/extension-src/pi-style/features/tools/boxed/index.ts +7 -2
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +31 -15
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +48 -14
- package/extension-src/pi-style/features/tools/boxed/shared.ts +25 -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/pi/compatibility-probe.ts +1 -1
- package/extension-src/pi-style/pi/index.ts +26 -13
- package/extension-src/pi-style/pi/session-usage.ts +204 -21
- package/extension-src/pi-style/shared/box.ts +43 -8
- package/package.json +1 -1
|
@@ -11,12 +11,11 @@ import {
|
|
|
11
11
|
import { requestToolPresentationRender } from "../features/tools/index.js";
|
|
12
12
|
import { registerPiStyleCommand } from "./commands.js";
|
|
13
13
|
import { type CompatibilityTestHooks, createPiStyleSessionCoordinator } from "./session-coordinator.js";
|
|
14
|
-
import { usageFromSession } from "./session-usage.js";
|
|
14
|
+
import { resetUsageFromSessionCache, usageFromSession } from "./session-usage.js";
|
|
15
15
|
|
|
16
|
-
/** Usage patch
|
|
16
|
+
/** Usage patch clears stale usage when the active branch/session has none. */
|
|
17
17
|
function usagePatch(ctx: ExtensionContext): StatusSnapshot {
|
|
18
|
-
|
|
19
|
-
return usage ? { usage } : {};
|
|
18
|
+
return { usage: usageFromSession(ctx.sessionManager) } as StatusSnapshot;
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
/**
|
|
@@ -65,6 +64,7 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
65
64
|
const coordinator = createPiStyleSessionCoordinator(pi, compatibilityTestHooks);
|
|
66
65
|
registerPiStyleCommand(pi, coordinator.app);
|
|
67
66
|
pi.on("session_start", async (event, ctx) => {
|
|
67
|
+
resetUsageFromSessionCache(ctx.sessionManager);
|
|
68
68
|
// Pi only activates read/bash/edit/write by default; grep/find/ls are
|
|
69
69
|
// registered but inactive (kept out of the model's tool list to keep the
|
|
70
70
|
// core small). Activate them so the TUI shows them and the model can call
|
|
@@ -110,7 +110,9 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
110
110
|
),
|
|
111
111
|
);
|
|
112
112
|
pi.on("thinking_level_select", (event) => coordinator.app.update({ thinkingLevel: event.level }, "immediate"));
|
|
113
|
-
pi.on("session_info_changed", (event) =>
|
|
113
|
+
pi.on("session_info_changed", (event) =>
|
|
114
|
+
coordinator.app.update({ sessionName: event.name }, "coalesced", { refreshExtensionStatuses: true }),
|
|
115
|
+
);
|
|
114
116
|
pi.on("message_start", () => {
|
|
115
117
|
// A new message is a batch boundary: quiet-tool (read/ls/find) calls of the
|
|
116
118
|
// new message start a fresh batch instead of joining the previous one.
|
|
@@ -123,11 +125,13 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
123
125
|
// Usage (tokens + cost) is aggregated from finalized session entries at
|
|
124
126
|
// message/turn boundaries, mirroring Pi's native footer; per-chunk updates
|
|
125
127
|
// stay usage-free to keep streaming cheap.
|
|
126
|
-
pi.on("message_end", (_event, ctx) =>
|
|
128
|
+
pi.on("message_end", (_event, ctx) =>
|
|
129
|
+
coordinator.app.update({ ...usagePatch(ctx) }, "coalesced", { refreshContextUsage: true }),
|
|
130
|
+
);
|
|
127
131
|
pi.on("turn_end", (event, ctx) => {
|
|
128
132
|
// Append the finalized assistant message's tool batch to the current run.
|
|
129
133
|
registerTurnFromMessage(event.message, event.toolResults);
|
|
130
|
-
coordinator.app.update({ ...usagePatch(ctx) }, "deferred");
|
|
134
|
+
coordinator.app.update({ ...usagePatch(ctx) }, "deferred", { refreshContextUsage: true });
|
|
131
135
|
});
|
|
132
136
|
pi.on("agent_end", () => {
|
|
133
137
|
// The run is complete: collapse its tool blocks into one summary line.
|
|
@@ -141,23 +145,32 @@ export default function piStyleExtension(pi: ExtensionAPI): void {
|
|
|
141
145
|
requestToolPresentationRender();
|
|
142
146
|
}
|
|
143
147
|
});
|
|
144
|
-
pi.on("agent_settled", (_event, ctx) =>
|
|
148
|
+
pi.on("agent_settled", (_event, ctx) =>
|
|
149
|
+
coordinator.app.update({ ...usagePatch(ctx) }, "coalesced", { refreshContextUsage: true }),
|
|
150
|
+
);
|
|
145
151
|
pi.on("session_tree", (_event, ctx) => {
|
|
152
|
+
resetUsageFromSessionCache(ctx.sessionManager);
|
|
146
153
|
// Rebuild the turn registry from session content so restored/branched
|
|
147
154
|
// history renders collapsed consistently (no in-process turn_end events).
|
|
148
155
|
rebuildTurnRegistryFromEntries(ctx.sessionManager.getEntries());
|
|
149
|
-
coordinator.app.update({ ...usagePatch(ctx) }, "deferred");
|
|
156
|
+
coordinator.app.update({ ...usagePatch(ctx) }, "deferred", { refreshContextUsage: true });
|
|
157
|
+
});
|
|
158
|
+
pi.on("session_compact", (_event, ctx) => {
|
|
159
|
+
resetUsageFromSessionCache(ctx.sessionManager);
|
|
160
|
+
coordinator.app.update({ ...usagePatch(ctx) }, "deferred", { refreshContextUsage: true });
|
|
150
161
|
});
|
|
151
|
-
pi.on("session_compact", (_event, ctx) => coordinator.app.update({ ...usagePatch(ctx) }, "deferred"));
|
|
152
162
|
pi.on("tool_result", (event, ctx) => {
|
|
153
163
|
if (["write", "edit", "bash"].includes(event.toolName)) {
|
|
154
164
|
coordinator.app.runtime.current?.invalidateGit();
|
|
155
|
-
coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry");
|
|
165
|
+
coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry", { refreshContextUsage: true });
|
|
156
166
|
}
|
|
157
167
|
});
|
|
158
168
|
pi.on("user_bash", (_event, ctx) => {
|
|
159
169
|
coordinator.app.runtime.current?.invalidateGit();
|
|
160
|
-
coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry");
|
|
170
|
+
coordinator.app.update({ ...usagePatch(ctx) }, "delayed-retry", { refreshContextUsage: true });
|
|
171
|
+
});
|
|
172
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
173
|
+
resetUsageFromSessionCache(ctx.sessionManager);
|
|
174
|
+
coordinator.shutdown();
|
|
161
175
|
});
|
|
162
|
-
pi.on("session_shutdown", () => coordinator.shutdown());
|
|
163
176
|
}
|
|
@@ -18,27 +18,197 @@ interface UsageTotals {
|
|
|
18
18
|
cost: number;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
interface UsageContribution extends UsageTotals {
|
|
22
|
+
sawUsage: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SessionUsageCacheState {
|
|
26
|
+
processedLength: number;
|
|
27
|
+
lastEntryId: string | undefined;
|
|
28
|
+
lastEntryRef: SessionEntry | undefined;
|
|
29
|
+
lastContribution: UsageContribution;
|
|
30
|
+
totals: UsageTotals;
|
|
31
|
+
cached: UsageSnapshot | undefined;
|
|
32
|
+
scannedEntries: number;
|
|
33
|
+
cacheHits: number;
|
|
34
|
+
rebuilds: number;
|
|
35
|
+
tailRefreshes: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SessionUsageCacheStats {
|
|
39
|
+
processedLength: number;
|
|
40
|
+
scannedEntries: number;
|
|
41
|
+
cacheHits: number;
|
|
42
|
+
rebuilds: number;
|
|
43
|
+
tailRefreshes: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let usageCache = new WeakMap<SessionUsageSource, SessionUsageCacheState>();
|
|
47
|
+
|
|
21
48
|
/**
|
|
22
|
-
* Aggregate cumulative token/cost usage from
|
|
23
|
-
* Pi's native footer
|
|
24
|
-
*
|
|
49
|
+
* Aggregate cumulative token/cost usage from finalized session entries,
|
|
50
|
+
* mirroring Pi's native footer. The result is cached incrementally so repeated
|
|
51
|
+
* reads only scan appended entries and refresh the finalized tail entry when it
|
|
52
|
+
* changes in place.
|
|
25
53
|
*/
|
|
26
54
|
export function usageFromSession(session: SessionUsageSource): UsageSnapshot | undefined {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
55
|
+
const entries = session.getEntries();
|
|
56
|
+
const state = usageCache.get(session) ?? createState();
|
|
57
|
+
usageCache.set(session, state);
|
|
58
|
+
|
|
59
|
+
if (entries.length === 0) {
|
|
60
|
+
state.cacheHits++;
|
|
61
|
+
state.processedLength = 0;
|
|
62
|
+
state.lastEntryId = undefined;
|
|
63
|
+
state.lastEntryRef = undefined;
|
|
64
|
+
state.lastContribution = emptyContribution();
|
|
65
|
+
state.totals = emptyTotals();
|
|
66
|
+
state.cached = undefined;
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (state.processedLength === 0) {
|
|
71
|
+
rebuildFrom(entries, state);
|
|
72
|
+
return state.cached;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (entries.length < state.processedLength) {
|
|
76
|
+
rebuildFrom(entries, state);
|
|
77
|
+
return state.cached;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const currentLast = entries.at(-1);
|
|
81
|
+
if (!currentLast) {
|
|
82
|
+
state.cacheHits++;
|
|
83
|
+
state.cached = undefined;
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (entries.length === state.processedLength) {
|
|
88
|
+
if (currentLast.id !== state.lastEntryId) {
|
|
89
|
+
rebuildFrom(entries, state);
|
|
90
|
+
return state.cached;
|
|
39
91
|
}
|
|
92
|
+
if (currentLast !== state.lastEntryRef) refreshTailEntry(currentLast, state);
|
|
93
|
+
else state.cacheHits++;
|
|
94
|
+
return state.cached;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const previousLast = entries[state.processedLength - 1];
|
|
98
|
+
if (!previousLast || previousLast.id !== state.lastEntryId) {
|
|
99
|
+
rebuildFrom(entries, state);
|
|
100
|
+
return state.cached;
|
|
40
101
|
}
|
|
41
|
-
if (
|
|
102
|
+
if (previousLast !== state.lastEntryRef) refreshTailEntry(previousLast, state);
|
|
103
|
+
for (const entry of entries.slice(state.processedLength)) appendEntry(entry, state);
|
|
104
|
+
return state.cached;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function resetUsageFromSessionCache(session?: SessionUsageSource): void {
|
|
108
|
+
if (session) {
|
|
109
|
+
usageCache.delete(session);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
usageCache = new WeakMap<SessionUsageSource, SessionUsageCacheState>();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function getUsageFromSessionCacheStats(session: SessionUsageSource): SessionUsageCacheStats {
|
|
116
|
+
const state = usageCache.get(session) ?? createState();
|
|
117
|
+
if (!usageCache.has(session)) usageCache.set(session, state);
|
|
118
|
+
return {
|
|
119
|
+
processedLength: state.processedLength,
|
|
120
|
+
scannedEntries: state.scannedEntries,
|
|
121
|
+
cacheHits: state.cacheHits,
|
|
122
|
+
rebuilds: state.rebuilds,
|
|
123
|
+
tailRefreshes: state.tailRefreshes,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createState(): SessionUsageCacheState {
|
|
128
|
+
return {
|
|
129
|
+
processedLength: 0,
|
|
130
|
+
lastEntryId: undefined,
|
|
131
|
+
lastEntryRef: undefined,
|
|
132
|
+
lastContribution: emptyContribution(),
|
|
133
|
+
totals: emptyTotals(),
|
|
134
|
+
cached: undefined,
|
|
135
|
+
scannedEntries: 0,
|
|
136
|
+
cacheHits: 0,
|
|
137
|
+
rebuilds: 0,
|
|
138
|
+
tailRefreshes: 0,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function rebuildFrom(entries: readonly SessionEntry[], state: SessionUsageCacheState): void {
|
|
143
|
+
state.rebuilds++;
|
|
144
|
+
state.processedLength = 0;
|
|
145
|
+
state.lastEntryId = undefined;
|
|
146
|
+
state.lastEntryRef = undefined;
|
|
147
|
+
state.lastContribution = emptyContribution();
|
|
148
|
+
state.totals = emptyTotals();
|
|
149
|
+
state.cached = undefined;
|
|
150
|
+
for (const entry of entries) appendEntry(entry, state);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function refreshTailEntry(entry: SessionEntry, state: SessionUsageCacheState): void {
|
|
154
|
+
state.tailRefreshes++;
|
|
155
|
+
subtractContribution(state.totals, state.lastContribution);
|
|
156
|
+
const contribution = usageContribution(entry);
|
|
157
|
+
addContribution(state.totals, contribution);
|
|
158
|
+
state.lastEntryId = entry.id;
|
|
159
|
+
state.lastEntryRef = entry;
|
|
160
|
+
state.lastContribution = contribution;
|
|
161
|
+
state.cached = snapshotFromTotals(state.totals);
|
|
162
|
+
state.scannedEntries++;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function appendEntry(entry: SessionEntry, state: SessionUsageCacheState): void {
|
|
166
|
+
const contribution = usageContribution(entry);
|
|
167
|
+
addContribution(state.totals, contribution);
|
|
168
|
+
state.processedLength++;
|
|
169
|
+
state.lastEntryId = entry.id;
|
|
170
|
+
state.lastEntryRef = entry;
|
|
171
|
+
state.lastContribution = contribution;
|
|
172
|
+
state.cached = snapshotFromTotals(state.totals);
|
|
173
|
+
state.scannedEntries++;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function usageContribution(entry: SessionEntry): UsageContribution {
|
|
177
|
+
if (entry.type === "message") {
|
|
178
|
+
if (entry.message.role !== "assistant" && entry.message.role !== "toolResult") return emptyContribution();
|
|
179
|
+
const usage = "usage" in entry.message ? entry.message.usage : undefined;
|
|
180
|
+
if (!usage) return emptyContribution();
|
|
181
|
+
return {
|
|
182
|
+
input: usage.input,
|
|
183
|
+
output: usage.output,
|
|
184
|
+
cacheRead: usage.cacheRead,
|
|
185
|
+
cacheWrite: usage.cacheWrite,
|
|
186
|
+
cost: usage.cost.total,
|
|
187
|
+
sawUsage: true,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
|
|
191
|
+
return {
|
|
192
|
+
input: entry.usage.input,
|
|
193
|
+
output: entry.usage.output,
|
|
194
|
+
cacheRead: entry.usage.cacheRead,
|
|
195
|
+
cacheWrite: entry.usage.cacheWrite,
|
|
196
|
+
cost: entry.usage.cost.total,
|
|
197
|
+
sawUsage: true,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return emptyContribution();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function snapshotFromTotals(totals: UsageTotals): UsageSnapshot | undefined {
|
|
204
|
+
if (
|
|
205
|
+
totals.input === 0 &&
|
|
206
|
+
totals.output === 0 &&
|
|
207
|
+
totals.cacheRead === 0 &&
|
|
208
|
+
totals.cacheWrite === 0 &&
|
|
209
|
+
totals.cost === 0
|
|
210
|
+
)
|
|
211
|
+
return undefined;
|
|
42
212
|
return {
|
|
43
213
|
inputTokens: totals.input,
|
|
44
214
|
outputTokens: totals.output,
|
|
@@ -50,13 +220,26 @@ export function usageFromSession(session: SessionUsageSource): UsageSnapshot | u
|
|
|
50
220
|
};
|
|
51
221
|
}
|
|
52
222
|
|
|
53
|
-
function
|
|
54
|
-
totals: UsageTotals,
|
|
55
|
-
usage: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } },
|
|
56
|
-
): void {
|
|
223
|
+
function addContribution(totals: UsageTotals, usage: UsageContribution): void {
|
|
57
224
|
totals.input += usage.input;
|
|
58
225
|
totals.output += usage.output;
|
|
59
226
|
totals.cacheRead += usage.cacheRead;
|
|
60
227
|
totals.cacheWrite += usage.cacheWrite;
|
|
61
|
-
totals.cost += usage.cost
|
|
228
|
+
totals.cost += usage.cost;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function subtractContribution(totals: UsageTotals, usage: UsageContribution): void {
|
|
232
|
+
totals.input -= usage.input;
|
|
233
|
+
totals.output -= usage.output;
|
|
234
|
+
totals.cacheRead -= usage.cacheRead;
|
|
235
|
+
totals.cacheWrite -= usage.cacheWrite;
|
|
236
|
+
totals.cost -= usage.cost;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function emptyTotals(): UsageTotals {
|
|
240
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function emptyContribution(): UsageContribution {
|
|
244
|
+
return { ...emptyTotals(), sawUsage: false };
|
|
62
245
|
}
|
|
@@ -36,6 +36,17 @@ export interface BoxTheme {
|
|
|
36
36
|
getColorMode?(): string;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
const THEME_CACHE_KEYS = new WeakMap<object, number>();
|
|
40
|
+
let nextThemeCacheKey = 1;
|
|
41
|
+
|
|
42
|
+
export function themeCacheKey(theme: object): number {
|
|
43
|
+
const cached = THEME_CACHE_KEYS.get(theme);
|
|
44
|
+
if (cached !== undefined) return cached;
|
|
45
|
+
const created = nextThemeCacheKey++;
|
|
46
|
+
THEME_CACHE_KEYS.set(theme, created);
|
|
47
|
+
return created;
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
export interface BoxedRenderOptions {
|
|
40
51
|
widthKey?: string;
|
|
41
52
|
/** Detail embedded in the top-border title after the tool name (e.g. the path). */
|
|
@@ -384,10 +395,10 @@ export function dimLine(text: string): string {
|
|
|
384
395
|
return `\x1b[2m${text}\x1b[22m`;
|
|
385
396
|
}
|
|
386
397
|
|
|
387
|
-
function boxText(
|
|
398
|
+
function boxText(_theme: BoxTheme, text: string): string {
|
|
388
399
|
return dimLine(text);
|
|
389
400
|
}
|
|
390
|
-
function boxFrameText(
|
|
401
|
+
function boxFrameText(_theme: BoxTheme, text: string): string {
|
|
391
402
|
return dimLine(text);
|
|
392
403
|
}
|
|
393
404
|
|
|
@@ -567,8 +578,20 @@ function renderBoxedOutputLines(
|
|
|
567
578
|
let truncated = false;
|
|
568
579
|
|
|
569
580
|
for (; nextInputIndex < outputLines.length; nextInputIndex++) {
|
|
570
|
-
|
|
571
|
-
|
|
581
|
+
// An output "line" may carry embedded newlines (raw tool error messages,
|
|
582
|
+
// JSON payloads). Split before boxing so every fragment gets its own
|
|
583
|
+
// border and truncation — otherwise the box frame visually breaks on the
|
|
584
|
+
// embedded rows.
|
|
585
|
+
const fragments = (outputLines[nextInputIndex] ?? "").split("\n");
|
|
586
|
+
let headExceeded = false;
|
|
587
|
+
for (const fragment of fragments) {
|
|
588
|
+
const line = boxedTruncatedLine(theme, fragment, width);
|
|
589
|
+
if (!pushBoundedLines(head, [line], headLimit)) {
|
|
590
|
+
headExceeded = true;
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (headExceeded) {
|
|
572
595
|
truncated = true;
|
|
573
596
|
nextInputIndex++;
|
|
574
597
|
break;
|
|
@@ -580,9 +603,12 @@ function renderBoxedOutputLines(
|
|
|
580
603
|
const tail: string[] = [];
|
|
581
604
|
const tailStart = Math.max(nextInputIndex, outputLines.length - tailLimit);
|
|
582
605
|
for (let i = tailStart; i < outputLines.length; i++) {
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
606
|
+
const fragments = (outputLines[i] ?? "").split("\n");
|
|
607
|
+
for (const fragment of fragments) {
|
|
608
|
+
const line = boxedTruncatedLine(theme, fragment, width);
|
|
609
|
+
tail.push(line);
|
|
610
|
+
if (tail.length > tailLimit) tail.splice(0, tail.length - tailLimit);
|
|
611
|
+
}
|
|
586
612
|
}
|
|
587
613
|
|
|
588
614
|
const skippedInputLines = Math.max(0, tailStart - nextInputIndex);
|
|
@@ -764,6 +790,10 @@ export function renderBoxedToolResult(
|
|
|
764
790
|
bodyLines.length > 0
|
|
765
791
|
? [...errorPrefix, ...bodyLines]
|
|
766
792
|
: [theme.fg("muted", `∅ ${options.emptyText ?? "(no output)"}`)];
|
|
793
|
+
// Split embedded newlines before budgeting so raw multi-line messages
|
|
794
|
+
// (tool validation errors, JSON payloads) render as proper bordered
|
|
795
|
+
// rows instead of one "line" whose embedded rows break the frame.
|
|
796
|
+
const outputFragments = outputLines.flatMap((line) => line.split("\n"));
|
|
767
797
|
const footerText = (options.footerLines ?? []).join(" · ");
|
|
768
798
|
const dividerText =
|
|
769
799
|
typeof options.dividerLabel === "function"
|
|
@@ -783,7 +813,12 @@ export function renderBoxedToolResult(
|
|
|
783
813
|
),
|
|
784
814
|
]),
|
|
785
815
|
boxBlankLine(theme, renderedWidth),
|
|
786
|
-
...renderBoxedOutputLines(
|
|
816
|
+
...renderBoxedOutputLines(
|
|
817
|
+
theme,
|
|
818
|
+
outputFragments,
|
|
819
|
+
renderedWidth,
|
|
820
|
+
options.renderLineBudget ?? outputFragments.length,
|
|
821
|
+
),
|
|
787
822
|
boxBlankLine(theme, renderedWidth),
|
|
788
823
|
boxLabeledBorder(
|
|
789
824
|
theme,
|