@quandev104/pi-style 0.1.3 → 0.1.4
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 +6 -0
- package/dist/extensions/pi-style.js +379 -71
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/domain/status.ts +1 -1
- package/extension-src/pi-style/features/tools/boxed/bash.ts +279 -45
- package/extension-src/pi-style/features/tools/boxed/edit.ts +28 -2
- package/extension-src/pi-style/features/tools/boxed/fallback.ts +47 -4
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +25 -4
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +64 -4
- package/extension-src/pi-style/features/tools/boxed/shared.ts +41 -1
- package/extension-src/pi-style/features/tools/boxed/write.ts +17 -1
- package/extension-src/pi-style/pi/session-coordinator.ts +9 -1
- package/extension-src/pi-style/shared/box.ts +78 -21
- package/package.json +1 -1
|
@@ -254,7 +254,7 @@ export function createBuiltinSegments(): ReadonlyMap<StatusSegmentId, StatusSegm
|
|
|
254
254
|
const token = contextBarToken(percent);
|
|
255
255
|
const window = snapshot.context?.windowTokens;
|
|
256
256
|
const label = `ctx${window !== undefined ? ` (${formatTokens(window)})` : ""}:`;
|
|
257
|
-
const width = (options
|
|
257
|
+
const width = (options.context_bar?.width as number | undefined) ?? CONTEXT_BAR_WIDTH;
|
|
258
258
|
return {
|
|
259
259
|
visible: true,
|
|
260
260
|
content: `${theme.apply("muted", label)} ${theme.apply(token, contextBar(percent, width))} ${theme.apply(token, `${Math.round(percent)}%`)}`,
|
|
@@ -7,7 +7,8 @@ import { stripAnsi } from "../../../shared/ansi.js";
|
|
|
7
7
|
import type { BoxTheme } from "../../../shared/box.js";
|
|
8
8
|
import {
|
|
9
9
|
boxedToolWidthKey,
|
|
10
|
-
|
|
10
|
+
formatBoxedRunningStatus,
|
|
11
|
+
formatBoxedWords,
|
|
11
12
|
formatToolOutputLine,
|
|
12
13
|
getTextOutput,
|
|
13
14
|
renderBoxedToolCall,
|
|
@@ -30,8 +31,16 @@ import {
|
|
|
30
31
|
SEARCH_ICON,
|
|
31
32
|
TREE_INDENT,
|
|
32
33
|
} from "./output-tree.js";
|
|
33
|
-
import {
|
|
34
|
-
|
|
34
|
+
import {
|
|
35
|
+
getStateElapsedMs,
|
|
36
|
+
getToolsRenderConfig,
|
|
37
|
+
isResultSeen,
|
|
38
|
+
markResultSeen,
|
|
39
|
+
recordExecutionEnded,
|
|
40
|
+
startElapsedTicker,
|
|
41
|
+
stopElapsedTicker,
|
|
42
|
+
} from "./session-config.js";
|
|
43
|
+
import { type BoxedToolContext, type BoxedToolDefinition, noteBoxedCallState, noteExecutionStart } from "./shared.js";
|
|
35
44
|
|
|
36
45
|
const MAX_LINE_CHARS = 2000;
|
|
37
46
|
const ESC = "\x1b";
|
|
@@ -182,44 +191,271 @@ function renderBoxedBashCall(
|
|
|
182
191
|
if (commandLines.length > maxCommandLines + 1) {
|
|
183
192
|
detailLines.push(theme.fg("muted", `... ${commandLines.length - maxCommandLines - 1} more lines`));
|
|
184
193
|
}
|
|
185
|
-
|
|
194
|
+
const running = Boolean(context.executionStarted);
|
|
195
|
+
const resultSeen = isResultSeen(context.state);
|
|
196
|
+
const base = {
|
|
186
197
|
widthKey,
|
|
187
198
|
isError: Boolean(context.isError),
|
|
188
199
|
isPartial: Boolean(context.isPartial),
|
|
189
200
|
isPending: Boolean(context.isPartial),
|
|
190
|
-
|
|
201
|
+
running,
|
|
202
|
+
};
|
|
203
|
+
if (running && context.isPartial && !resultSeen) {
|
|
204
|
+
// Pre-result running card: the call closes the box with a live running
|
|
205
|
+
// footer and a `No output received yet` line. The first partial result
|
|
206
|
+
// renders nothing, so this card is never duplicated below.
|
|
207
|
+
detailLines.push(theme.fg("dim", "No output received yet"));
|
|
208
|
+
return renderBoxedToolCall(theme, "Bash", detailLines, {
|
|
209
|
+
...base,
|
|
210
|
+
pendingLabel: formatBoxedRunningStatus(theme, getStateElapsedMs(context.state)),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
// Streaming (a result renderer already continues this box) and terminal
|
|
214
|
+
// (settled) passes leave the box open so the result closes it.
|
|
215
|
+
return renderBoxedToolCall(theme, "Bash", detailLines, { ...base, resultSeen });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Terminal status detection ────────────────────────────────────────────────
|
|
219
|
+
// The bash tool appends a `\n\n<status>` suffix to failed results (nonzero exit,
|
|
220
|
+
// timeout, abort). Parse it off the raw text so the footer can carry the real
|
|
221
|
+
// status instead of displaying the suffix as output.
|
|
222
|
+
|
|
223
|
+
type BashTerminalStatus =
|
|
224
|
+
| { kind: "exit"; exitCode: number }
|
|
225
|
+
| { kind: "timeout"; seconds: number }
|
|
226
|
+
| { kind: "cancelled" };
|
|
227
|
+
|
|
228
|
+
const BASH_STATUS_PATTERNS: ReadonlyArray<{
|
|
229
|
+
re: RegExp;
|
|
230
|
+
build: (match: RegExpMatchArray) => BashTerminalStatus;
|
|
231
|
+
}> = [
|
|
232
|
+
{
|
|
233
|
+
re: /(?:^|\n\n)Command timed out after ([\d.]+) seconds$/i,
|
|
234
|
+
build: (match) => ({ kind: "timeout", seconds: Number(match[1]) }),
|
|
235
|
+
},
|
|
236
|
+
{ re: /(?:^|\n\n)[^\n]*aborted$/i, build: () => ({ kind: "cancelled" }) },
|
|
237
|
+
{
|
|
238
|
+
re: /(?:^|\n\n)Command exited with code (\d+)$/i,
|
|
239
|
+
build: (match) => ({ kind: "exit", exitCode: Number(match[1]) }),
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
function parseBashTerminalStatus(text: string): { status: BashTerminalStatus | undefined; body: string } {
|
|
244
|
+
const clean = String(text ?? "").replace(/\r/g, "");
|
|
245
|
+
for (const { re, build } of BASH_STATUS_PATTERNS) {
|
|
246
|
+
const match = clean.match(re);
|
|
247
|
+
if (match && match.index !== undefined) {
|
|
248
|
+
return { status: build(match), body: clean.slice(0, match.index).trimEnd() };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Pi's message_end error path (agent aborted) sends a bare status text with
|
|
252
|
+
// no bash output shape; recognize it as a cancelled state.
|
|
253
|
+
if (/^(?:operation )?aborted(?: after \d+ retry attempts?)?$/i.test(clean.trim())) {
|
|
254
|
+
return { status: { kind: "cancelled" }, body: "" };
|
|
255
|
+
}
|
|
256
|
+
return { status: undefined, body: clean };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function bashErrorLabel(status: BashTerminalStatus | undefined): string | undefined {
|
|
260
|
+
if (status?.kind === "timeout") return "✗ Timed out";
|
|
261
|
+
if (status?.kind === "cancelled") return "✗ Cancelled";
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Body text shown when a terminal bash result produced no output. */
|
|
266
|
+
function bashEmptyBodyText(status: BashTerminalStatus | undefined, isError: boolean): string {
|
|
267
|
+
if (status?.kind === "timeout") return "No output was received before the timeout";
|
|
268
|
+
if (status?.kind === "cancelled") return "Command was cancelled without producing output";
|
|
269
|
+
if (isError) return "Command failed without producing output";
|
|
270
|
+
return "Command completed without producing output";
|
|
191
271
|
}
|
|
192
272
|
|
|
193
|
-
function
|
|
194
|
-
|
|
195
|
-
|
|
273
|
+
function bashFooter(
|
|
274
|
+
theme: BoxTheme,
|
|
275
|
+
status: BashTerminalStatus | undefined,
|
|
276
|
+
elapsedMs: number | undefined,
|
|
277
|
+
bodyText: string,
|
|
278
|
+
isError: boolean,
|
|
279
|
+
): string {
|
|
280
|
+
const elapsed = elapsedMs === undefined ? "--" : `${(elapsedMs / 1000).toFixed(2)}s`;
|
|
281
|
+
const words = bodyText.trim() ? formatBoxedWords(bodyText) : "";
|
|
282
|
+
|
|
283
|
+
if (status?.kind === "timeout") {
|
|
284
|
+
const seconds = Number.isFinite(status.seconds) && status.seconds > 0 ? status.seconds : Number.NaN;
|
|
285
|
+
return theme.fg(
|
|
286
|
+
"warning",
|
|
287
|
+
Number.isFinite(seconds) ? `Terminated after ${seconds.toFixed(1)}s` : "Terminated by timeout",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (status?.kind === "cancelled") {
|
|
291
|
+
return [theme.fg("warning", "Cancelled"), theme.fg("text", elapsed)].join(theme.fg("dim", " · "));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const exitLabel = status?.kind === "exit" ? `Exit ${status.exitCode}` : isError ? "Failed" : "Exit 0";
|
|
295
|
+
const exitColor = status?.kind === "exit" && status.exitCode !== 0 ? "error" : "text";
|
|
296
|
+
const parts = [theme.fg(exitColor, exitLabel), theme.fg("text", elapsed)];
|
|
297
|
+
if (words) parts.push(theme.fg("dim", words));
|
|
298
|
+
return parts.join(theme.fg("dim", " · "));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Interactive command heuristics ───────────────────────────────────────────
|
|
302
|
+
// Terminal programs that read stdin or own the screen produce no pipe output;
|
|
303
|
+
// when one runs silently we hint that it may be waiting for terminal input.
|
|
304
|
+
|
|
305
|
+
const INTERACTIVE_COMMANDS = new Set([
|
|
306
|
+
"pi",
|
|
307
|
+
"vim",
|
|
308
|
+
"vi",
|
|
309
|
+
"nvim",
|
|
310
|
+
"nano",
|
|
311
|
+
"less",
|
|
312
|
+
"more",
|
|
313
|
+
"man",
|
|
314
|
+
"top",
|
|
315
|
+
"htop",
|
|
316
|
+
"btop",
|
|
317
|
+
"ssh",
|
|
318
|
+
"telnet",
|
|
319
|
+
"python",
|
|
320
|
+
"python3",
|
|
321
|
+
"node",
|
|
322
|
+
"sqlite3",
|
|
323
|
+
"mysql",
|
|
324
|
+
"psql",
|
|
325
|
+
"redis-cli",
|
|
326
|
+
"mongosh",
|
|
327
|
+
"bc",
|
|
328
|
+
"irssi",
|
|
329
|
+
]);
|
|
330
|
+
|
|
331
|
+
function isInteractiveCommand(command: unknown): boolean {
|
|
332
|
+
const base =
|
|
333
|
+
(
|
|
334
|
+
String(command ?? "")
|
|
335
|
+
.trim()
|
|
336
|
+
.split(/\s+/)[0] ?? ""
|
|
337
|
+
)
|
|
338
|
+
.split("/")
|
|
339
|
+
.pop() ?? "";
|
|
340
|
+
return INTERACTIVE_COMMANDS.has(base);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Wrap an output preview so an empty result renders state text instead of `∅`. */
|
|
344
|
+
function bashBodyComponent(preview: Component, emptyLines: string[] | undefined): Component {
|
|
345
|
+
if (!emptyLines) return preview;
|
|
346
|
+
return {
|
|
347
|
+
invalidate: () => preview.invalidate(),
|
|
348
|
+
render(width: number): string[] {
|
|
349
|
+
const lines = preview.render(width);
|
|
350
|
+
return lines.length > 0 ? lines : emptyLines;
|
|
351
|
+
},
|
|
352
|
+
};
|
|
196
353
|
}
|
|
197
354
|
|
|
198
|
-
|
|
355
|
+
/** Streaming continuation: streamed output (or `No output received yet`), a
|
|
356
|
+
* live running footer, and no `Response` divider until the tool settles. */
|
|
357
|
+
function renderBashStreamingResult(
|
|
199
358
|
theme: BoxTheme,
|
|
200
|
-
|
|
201
|
-
|
|
359
|
+
raw: string,
|
|
360
|
+
options: { expanded: boolean },
|
|
202
361
|
context: BoxedToolContext,
|
|
203
|
-
expandHint?: string,
|
|
204
362
|
): Component {
|
|
363
|
+
const body = stripBashToolNoticeLines(stripAnsi(raw));
|
|
364
|
+
const hasOutput = body.trim().length > 0;
|
|
365
|
+
const elapsed = getStateElapsedMs(context.state);
|
|
366
|
+
const emptyLines: string[] = [theme.fg("dim", "No output received yet")];
|
|
367
|
+
if (!hasOutput && isInteractiveCommand(context?.args?.command) && (elapsed ?? 0) >= 1000) {
|
|
368
|
+
emptyLines.push(theme.fg("dim", "The process may be waiting for terminal input"));
|
|
369
|
+
}
|
|
370
|
+
const preview = createBashResultPreview(theme, body, options, "toolOutput");
|
|
205
371
|
const rawCommand = String(context?.args?.command ?? "...");
|
|
206
|
-
|
|
207
|
-
return renderBoxedToolResult(theme, inner, {
|
|
372
|
+
return renderBoxedToolResult(theme, bashBodyComponent(preview, hasOutput ? undefined : emptyLines), {
|
|
208
373
|
widthKey: bashWidthKey(rawCommand, context?.args?.timeout),
|
|
209
|
-
referenceLines,
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
],
|
|
213
|
-
|
|
214
|
-
isError: context.isError,
|
|
215
|
-
isPartial: Boolean(context.isPartial),
|
|
374
|
+
referenceLines: rawCommand.split("\n").map((line, index) => `${index === 0 ? "$ " : "> "}${line}`),
|
|
375
|
+
dividerLabel: "Output",
|
|
376
|
+
showDivider: hasOutput,
|
|
377
|
+
footerLines: [formatBoxedRunningStatus(theme, elapsed)],
|
|
378
|
+
isPartial: true,
|
|
216
379
|
});
|
|
217
380
|
}
|
|
218
381
|
|
|
219
|
-
function
|
|
220
|
-
|
|
382
|
+
function renderBashFinalResult(
|
|
383
|
+
theme: BoxTheme,
|
|
384
|
+
raw: string,
|
|
385
|
+
options: { expanded: boolean },
|
|
386
|
+
context: BoxedToolContext,
|
|
387
|
+
): Component {
|
|
388
|
+
const isError = Boolean(context.isError);
|
|
389
|
+
const clean = stripAnsi(raw);
|
|
390
|
+
const { status, body: statusStripped } = parseBashTerminalStatus(clean);
|
|
391
|
+
const output = stripBashToolNoticeLines(statusStripped);
|
|
392
|
+
const elapsed = getStateElapsedMs(context.state);
|
|
393
|
+
const outputColor = isError ? "error" : "toolOutput";
|
|
394
|
+
const footer = bashFooter(theme, status, elapsed, output, isError);
|
|
395
|
+
const errorLabel = isError ? (bashErrorLabel(status) ?? "✗ Error") : undefined;
|
|
396
|
+
|
|
397
|
+
const rawCommand = String(context?.args?.command ?? "...");
|
|
398
|
+
const widthKey = bashWidthKey(rawCommand, context?.args?.timeout);
|
|
399
|
+
const referenceLines = rawCommand.split("\n").map((line, index) => `${index === 0 ? "$ " : "> "}${line}`);
|
|
400
|
+
|
|
401
|
+
if (!options.expanded) {
|
|
402
|
+
// Collapsed: only process the tail of the output (notices stripped per line).
|
|
403
|
+
const scanLines = getToolsRenderConfig().maxCollapsedLines + 10;
|
|
404
|
+
let nlCount = 0;
|
|
405
|
+
let tailStart = 0;
|
|
406
|
+
for (let i = statusStripped.length - 1; i >= 0; i--) {
|
|
407
|
+
if (statusStripped.charCodeAt(i) === 10) {
|
|
408
|
+
nlCount++;
|
|
409
|
+
if (nlCount >= scanLines) {
|
|
410
|
+
tailStart = i + 1;
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const tail = stripBashToolNoticeLines(stripAnsi(statusStripped.slice(tailStart)));
|
|
416
|
+
const totalLinesBefore = tailStart > 0 ? countNewlines(statusStripped, 0, tailStart) : 0;
|
|
417
|
+
const preview = createBashResultPreview(theme, tail, options, outputColor);
|
|
418
|
+
return renderBoxedToolResult(
|
|
419
|
+
theme,
|
|
420
|
+
bashBodyComponent(
|
|
421
|
+
preview,
|
|
422
|
+
statusStripped.trim() ? undefined : [theme.fg("muted", bashEmptyBodyText(status, isError))],
|
|
423
|
+
),
|
|
424
|
+
{
|
|
425
|
+
widthKey,
|
|
426
|
+
referenceLines,
|
|
427
|
+
footerLines: [footer],
|
|
428
|
+
...(totalLinesBefore > 0 ? { expandHint: "Ctrl+O for more" } : {}),
|
|
429
|
+
isError,
|
|
430
|
+
isPartial: false,
|
|
431
|
+
...(errorLabel ? { errorLabel } : {}),
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const preview = createBashResultPreview(theme, output, options, outputColor);
|
|
437
|
+
return renderBoxedToolResult(
|
|
438
|
+
theme,
|
|
439
|
+
bashBodyComponent(preview, output.trim() ? undefined : [theme.fg("muted", bashEmptyBodyText(status, isError))]),
|
|
440
|
+
{
|
|
441
|
+
widthKey,
|
|
442
|
+
referenceLines,
|
|
443
|
+
footerLines: [footer],
|
|
444
|
+
isError,
|
|
445
|
+
isPartial: false,
|
|
446
|
+
...(errorLabel ? { errorLabel } : {}),
|
|
447
|
+
},
|
|
448
|
+
);
|
|
221
449
|
}
|
|
222
450
|
|
|
451
|
+
/** First-partial-pass result: the pending/running call card stands alone. */
|
|
452
|
+
const EMPTY_BASH_RESULT: Component = Object.freeze({
|
|
453
|
+
invalidate() {},
|
|
454
|
+
render() {
|
|
455
|
+
return [];
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
|
|
223
459
|
function createBashResultPreview(
|
|
224
460
|
theme: BoxTheme,
|
|
225
461
|
text: string,
|
|
@@ -677,12 +913,21 @@ export const bashTool: BoxedToolDefinition = {
|
|
|
677
913
|
bashTreeStates.set(context.toolCallId, { cls, command: String(args?.command ?? "") });
|
|
678
914
|
return renderBashTreePanel(theme, context.toolCallId, context);
|
|
679
915
|
}
|
|
916
|
+
noteBoxedCallState(context);
|
|
680
917
|
const rawCommand = String(args?.command ?? "...");
|
|
681
918
|
return renderBoxedBashCall(theme, rawCommand.split("\n"), context, bashWidthKey(rawCommand, args?.timeout));
|
|
682
919
|
},
|
|
683
920
|
result(result, options, theme, context) {
|
|
921
|
+
const firstResultPass = !isResultSeen(context.state);
|
|
922
|
+
markResultSeen(context.state);
|
|
684
923
|
const cls = classifyBashCommand(String(context?.args?.command ?? ""));
|
|
685
924
|
if (cls && !context.isError) {
|
|
925
|
+
// Tree-classified commands render in the call panel; the result adds
|
|
926
|
+
// nothing. Keep terminal state in sync without an elapsed ticker.
|
|
927
|
+
if (!options.isPartial) {
|
|
928
|
+
recordExecutionEnded(context.state);
|
|
929
|
+
stopElapsedTicker(context.state);
|
|
930
|
+
}
|
|
686
931
|
const output = stripBashToolNoticeLines(stripAnsi(getTextOutput(result)));
|
|
687
932
|
const parsed = parseBashTreeOutput(cls, output);
|
|
688
933
|
const state = bashTreeStates.get(context.toolCallId);
|
|
@@ -694,30 +939,19 @@ export const bashTool: BoxedToolDefinition = {
|
|
|
694
939
|
// Unparseable output (ls -l, raw rg summary): the boxed shell owns the
|
|
695
940
|
// result; flag the call panel to render nothing so the two don't duplicate.
|
|
696
941
|
if (state) state.fallback = true;
|
|
942
|
+
} else if (options.isPartial) {
|
|
943
|
+
startElapsedTicker(context.state, context.invalidate);
|
|
944
|
+
} else {
|
|
945
|
+
recordExecutionEnded(context.state);
|
|
946
|
+
stopElapsedTicker(context.state);
|
|
697
947
|
}
|
|
698
948
|
const raw = getTextOutput(result);
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
let tailStart = 0;
|
|
705
|
-
for (let i = raw.length - 1; i >= 0; i--) {
|
|
706
|
-
if (raw.charCodeAt(i) === 10) {
|
|
707
|
-
nlCount++;
|
|
708
|
-
if (nlCount >= scanLines) {
|
|
709
|
-
tailStart = i + 1;
|
|
710
|
-
break;
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
const tail = stripBashToolNoticeLines(stripAnsi(raw.slice(tailStart)));
|
|
715
|
-
const totalLinesBefore = tailStart > 0 ? countNewlines(raw, 0, tailStart) : 0;
|
|
716
|
-
const inner = createBashResultPreview(theme, tail, options, outputColor);
|
|
717
|
-
return renderBoxedBashResult(theme, inner, result, context, totalLinesBefore > 0 ? "Ctrl+O for more" : undefined);
|
|
949
|
+
if (options.isPartial) {
|
|
950
|
+
// First partial pass: the running call card stands alone. Later passes
|
|
951
|
+
// stream output into the open continuation without a Response divider.
|
|
952
|
+
if (firstResultPass) return EMPTY_BASH_RESULT;
|
|
953
|
+
return renderBashStreamingResult(theme, raw, options, context);
|
|
718
954
|
}
|
|
719
|
-
|
|
720
|
-
const inner = createBashResultPreview(theme, output, options, outputColor);
|
|
721
|
-
return renderBoxedBashResult(theme, inner, result, context);
|
|
955
|
+
return renderBashFinalResult(theme, raw, options, context);
|
|
722
956
|
},
|
|
723
957
|
};
|
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
// (renderCall/renderResult only; no edit-core re-registration).
|
|
3
3
|
|
|
4
4
|
import { getLanguageFromPath } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
5
6
|
import { stripAnsi } from "../../../shared/ansi.js";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type BoxTheme,
|
|
9
|
+
formatBoxedRunningStatus,
|
|
10
|
+
getTextOutput,
|
|
11
|
+
renderBoxedToolCall,
|
|
12
|
+
renderBoxedToolResult,
|
|
13
|
+
} from "../../../shared/box.js";
|
|
7
14
|
import { formatElapsedMs, getElapsedMs } from "../../../shared/elapsed.js";
|
|
8
15
|
import {
|
|
9
16
|
AdaptiveDiffComponent,
|
|
@@ -12,10 +19,13 @@ import {
|
|
|
12
19
|
extractEditedPath,
|
|
13
20
|
firstText,
|
|
14
21
|
} from "../../../shared/split-diff.js";
|
|
22
|
+
import { isResultSeen } from "./session-config.js";
|
|
15
23
|
import {
|
|
16
24
|
type BoxedToolContext,
|
|
17
25
|
type BoxedToolDefinition,
|
|
18
26
|
displayPath,
|
|
27
|
+
noteBoxedCallState,
|
|
28
|
+
noteBoxedResultPhase,
|
|
19
29
|
noteExecutionStart,
|
|
20
30
|
resultFooterLines,
|
|
21
31
|
stateElapsedMs,
|
|
@@ -24,6 +34,14 @@ import {
|
|
|
24
34
|
const MAX_HIGHLIGHT_DIFF_CHARS = 12000;
|
|
25
35
|
const MAX_HIGHLIGHT_DIFF_ROWS = 120;
|
|
26
36
|
|
|
37
|
+
/** First-partial-pass result: the pending/running call card stands alone. */
|
|
38
|
+
const EMPTY_EDIT_RESULT: Component = Object.freeze({
|
|
39
|
+
invalidate() {},
|
|
40
|
+
render() {
|
|
41
|
+
return [];
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
27
45
|
type EditResultDetails = { diff?: string; path?: string } | undefined;
|
|
28
46
|
|
|
29
47
|
/** `Diff · +3 -0` divider label. */
|
|
@@ -52,18 +70,26 @@ function editDiffFooter(
|
|
|
52
70
|
export const editTool: BoxedToolDefinition = {
|
|
53
71
|
call(args, theme, context) {
|
|
54
72
|
noteExecutionStart(context);
|
|
73
|
+
noteBoxedCallState(context);
|
|
55
74
|
const detail = displayPath(String(args?.path ?? args?.file_path ?? ""), context);
|
|
56
75
|
return renderBoxedToolCall(theme, "Edit", [], {
|
|
57
76
|
headerDetail: detail,
|
|
58
77
|
isError: Boolean(context.isError),
|
|
59
78
|
isPartial: Boolean(context.isPartial),
|
|
60
79
|
isPending: Boolean(context.isPartial),
|
|
80
|
+
running: Boolean(context.executionStarted),
|
|
81
|
+
resultSeen: isResultSeen(context.state),
|
|
61
82
|
});
|
|
62
83
|
},
|
|
63
84
|
result(result, options, theme, context) {
|
|
64
|
-
// Handle partial/streaming state
|
|
85
|
+
// Handle partial/streaming state: continue the open call box with the
|
|
86
|
+
// applying hint (no Response divider until the tool settles).
|
|
65
87
|
if (options.isPartial) {
|
|
88
|
+
const firstResultPass = noteBoxedResultPhase(context, options.isPartial);
|
|
89
|
+
if (firstResultPass) return EMPTY_EDIT_RESULT;
|
|
66
90
|
return renderBoxedToolResult(theme, () => [`${theme.fg("dim", "↳")} ${theme.fg("muted", "Applying edit...")}`], {
|
|
91
|
+
showDivider: false,
|
|
92
|
+
footerLines: [formatBoxedRunningStatus(theme, stateElapsedMs(context))],
|
|
67
93
|
isPartial: true,
|
|
68
94
|
});
|
|
69
95
|
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import type { Component } from "@earendil-works/pi-tui";
|
|
5
5
|
import type { BoxTheme, MetricResultLike } from "../../../shared/box.js";
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
formatBoxedRunningStatus,
|
|
8
|
+
formatBoxedWords,
|
|
8
9
|
formatToolName,
|
|
9
10
|
formatToolOutputLine,
|
|
10
11
|
formatToolParamLines,
|
|
@@ -13,8 +14,8 @@ import {
|
|
|
13
14
|
renderBoxedToolResult,
|
|
14
15
|
selectRenderLines,
|
|
15
16
|
} from "../../../shared/box.js";
|
|
16
|
-
import { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
|
|
17
|
-
import { type BoxedToolContext, noteExecutionStart } from "./shared.js";
|
|
17
|
+
import { getStateElapsedMs, getToolsRenderConfig, isResultSeen } from "./session-config.js";
|
|
18
|
+
import { type BoxedToolContext, noteBoxedCallState, noteBoxedResultPhase, noteExecutionStart } from "./shared.js";
|
|
18
19
|
|
|
19
20
|
const MAX_FALLBACK_PREVIEW_LINES = 10;
|
|
20
21
|
|
|
@@ -25,10 +26,13 @@ export function renderFallbackCall(
|
|
|
25
26
|
context: BoxedToolContext,
|
|
26
27
|
): Component {
|
|
27
28
|
noteExecutionStart(context);
|
|
29
|
+
noteBoxedCallState(context);
|
|
28
30
|
return renderBoxedToolCall(theme, formatToolName(String(toolName ?? "Tool")), formatToolParamLines(args, theme), {
|
|
29
31
|
isError: Boolean(context.isError),
|
|
30
32
|
isPartial: Boolean(context.isPartial),
|
|
31
33
|
isPending: Boolean(context.isPartial),
|
|
34
|
+
running: Boolean(context.executionStarted),
|
|
35
|
+
resultSeen: isResultSeen(context.state),
|
|
32
36
|
});
|
|
33
37
|
}
|
|
34
38
|
|
|
@@ -39,6 +43,7 @@ export function renderFallbackResult(
|
|
|
39
43
|
theme: BoxTheme,
|
|
40
44
|
context: BoxedToolContext,
|
|
41
45
|
): Component {
|
|
46
|
+
const firstResultPass = noteBoxedResultPhase(context, options.isPartial);
|
|
42
47
|
const isError = Boolean(context.isError);
|
|
43
48
|
const expanded = Boolean(options.expanded);
|
|
44
49
|
const maxLines = expanded ? getToolsRenderConfig().maxExpandedLines : MAX_FALLBACK_PREVIEW_LINES;
|
|
@@ -46,6 +51,28 @@ export function renderFallbackResult(
|
|
|
46
51
|
const elapsedMs = getStateElapsedMs(context.state);
|
|
47
52
|
const { lines, omitted } = selectRenderLines(output, maxLines);
|
|
48
53
|
|
|
54
|
+
if (options.isPartial) {
|
|
55
|
+
// Streaming continuation into the open call box: no Response divider and
|
|
56
|
+
// no metrics footer until the tool settles. The first partial pass renders
|
|
57
|
+
// nothing so the pending/running call card stands alone.
|
|
58
|
+
if (firstResultPass) return EMPTY_FALLBACK_RESULT;
|
|
59
|
+
const hasOutput = output.trim().length > 0;
|
|
60
|
+
return renderBoxedToolResult(
|
|
61
|
+
theme,
|
|
62
|
+
() => {
|
|
63
|
+
const body = lines.map((line) => formatToolOutputLine(theme, line, "toolOutput"));
|
|
64
|
+
if (!hasOutput) body.push(theme.fg("dim", "No output received yet"));
|
|
65
|
+
return body;
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
dividerLabel: "Output",
|
|
69
|
+
showDivider: hasOutput,
|
|
70
|
+
footerLines: [formatBoxedRunningStatus(theme, elapsedMs)],
|
|
71
|
+
isPartial: true,
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
49
76
|
return renderBoxedToolResult(
|
|
50
77
|
theme,
|
|
51
78
|
() => {
|
|
@@ -56,7 +83,7 @@ export function renderFallbackResult(
|
|
|
56
83
|
return body;
|
|
57
84
|
},
|
|
58
85
|
{
|
|
59
|
-
footerLines: [
|
|
86
|
+
footerLines: [formatBoxedFooterWithElapsed(theme, elapsedMs, output)],
|
|
60
87
|
renderLineBudget: maxLines,
|
|
61
88
|
...(expanded || omitted <= 0 ? {} : { expandHint: "Ctrl+O for more" }),
|
|
62
89
|
isError,
|
|
@@ -65,5 +92,21 @@ export function renderFallbackResult(
|
|
|
65
92
|
);
|
|
66
93
|
}
|
|
67
94
|
|
|
95
|
+
/** First-partial-pass result: the pending/running call card stands alone. */
|
|
96
|
+
const EMPTY_FALLBACK_RESULT: Component = Object.freeze({
|
|
97
|
+
invalidate() {},
|
|
98
|
+
render() {
|
|
99
|
+
return [];
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
function formatBoxedFooterWithElapsed(theme: BoxTheme, elapsedMs: number | undefined, output: string): string {
|
|
104
|
+
const elapsed = elapsedMs === undefined ? "--" : `${(elapsedMs / 1000).toFixed(2)}s`;
|
|
105
|
+
const words = output.trim() ? formatBoxedWords(output) : "";
|
|
106
|
+
const parts = [theme.fg("text", elapsed)];
|
|
107
|
+
if (words) parts.push(theme.fg("dim", words));
|
|
108
|
+
return parts.join(theme.fg("dim", " · "));
|
|
109
|
+
}
|
|
110
|
+
|
|
68
111
|
// Re-exported for callers that need the tool-name label normalization.
|
|
69
112
|
export { formatToolName };
|
|
@@ -4,19 +4,35 @@ import { getLanguageFromPath } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import { stripAnsi } from "../../../shared/ansi.js";
|
|
5
5
|
import {
|
|
6
6
|
type BoxTheme,
|
|
7
|
-
|
|
7
|
+
formatBoxedRunningStatus,
|
|
8
8
|
getTextOutput,
|
|
9
9
|
renderBoxedToolCall,
|
|
10
10
|
renderBoxedToolResult,
|
|
11
11
|
} from "../../../shared/box.js";
|
|
12
12
|
import { formatElapsedMs, getElapsedMs } from "../../../shared/elapsed.js";
|
|
13
13
|
import { AdaptiveDiffComponent, buildSplitRows, countDiffStats } from "../../../shared/split-diff.js";
|
|
14
|
-
import { getStateElapsedMs } from "./session-config.js";
|
|
15
|
-
import {
|
|
14
|
+
import { getStateElapsedMs, isResultSeen } from "./session-config.js";
|
|
15
|
+
import {
|
|
16
|
+
type BoxedToolContext,
|
|
17
|
+
type BoxedToolDefinition,
|
|
18
|
+
displayPath,
|
|
19
|
+
noteBoxedCallState,
|
|
20
|
+
noteBoxedResultPhase,
|
|
21
|
+
noteExecutionStart,
|
|
22
|
+
stateElapsedMs,
|
|
23
|
+
} from "./shared.js";
|
|
16
24
|
|
|
17
25
|
const MAX_HIGHLIGHT_DIFF_CHARS = 12000;
|
|
18
26
|
const MAX_HIGHLIGHT_DIFF_ROWS = 120;
|
|
19
27
|
|
|
28
|
+
/** First-partial-pass result: the pending/running call card stands alone. */
|
|
29
|
+
const EMPTY_QUICK_EDIT_RESULT = Object.freeze({
|
|
30
|
+
invalidate() {},
|
|
31
|
+
render() {
|
|
32
|
+
return [];
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
20
36
|
interface QuickEditToolConfig {
|
|
21
37
|
toolLabel: string;
|
|
22
38
|
applyingLabel: string;
|
|
@@ -131,10 +147,12 @@ function renderQuickEditResult(
|
|
|
131
147
|
config: QuickEditToolConfig,
|
|
132
148
|
) {
|
|
133
149
|
if (options.isPartial) {
|
|
150
|
+
const firstResultPass = noteBoxedResultPhase(context, options.isPartial);
|
|
151
|
+
if (firstResultPass) return EMPTY_QUICK_EDIT_RESULT;
|
|
134
152
|
return renderBoxedToolResult(
|
|
135
153
|
theme,
|
|
136
154
|
() => [`${theme.fg("dim", "↳")} ${theme.fg("muted", `Applying ${config.applyingLabel}...`)}`],
|
|
137
|
-
{ isPartial: true },
|
|
155
|
+
{ showDivider: false, footerLines: [formatBoxedRunningStatus(theme, stateElapsedMs(context))], isPartial: true },
|
|
138
156
|
);
|
|
139
157
|
}
|
|
140
158
|
|
|
@@ -196,12 +214,15 @@ export function quickEditTool(config: QuickEditToolConfig): BoxedToolDefinition
|
|
|
196
214
|
return {
|
|
197
215
|
call(args, theme, context) {
|
|
198
216
|
noteExecutionStart(context);
|
|
217
|
+
noteBoxedCallState(context);
|
|
199
218
|
const detail = displayPath(String(args?.path ?? ""), context);
|
|
200
219
|
return renderBoxedToolCall(theme, config.toolLabel, [], {
|
|
201
220
|
headerDetail: detail,
|
|
202
221
|
isError: Boolean(context.isError),
|
|
203
222
|
isPartial: Boolean(context.isPartial),
|
|
204
223
|
isPending: Boolean(context.isPartial),
|
|
224
|
+
running: Boolean(context.executionStarted),
|
|
225
|
+
resultSeen: isResultSeen(context.state),
|
|
205
226
|
});
|
|
206
227
|
},
|
|
207
228
|
result(result, options, theme, context) {
|