@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.
@@ -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["context_bar"]?.width as number | undefined) ?? CONTEXT_BAR_WIDTH;
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
- formatBoxedFooter,
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 { getStateElapsedMs, getToolsRenderConfig } from "./session-config.js";
34
- import { type BoxedToolContext, type BoxedToolDefinition, noteExecutionStart } from "./shared.js";
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
- return renderBoxedToolCall(theme, "Bash", detailLines, {
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 formatTimeout(context: BoxedToolContext): string {
194
- const timeout = context?.args?.timeout ?? 300;
195
- return `${timeout}s`;
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
- function renderBoxedBashResult(
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
- inner: Component,
201
- result: unknown,
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
- const referenceLines = rawCommand.split("\n").map((line, index) => `${index === 0 ? "$ " : "> "}${line}`);
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
- footerLines: [
211
- formatBoxedFooter(theme, result as never, [`timeout ${formatTimeout(context)}`], getElapsed(context)),
212
- ],
213
- ...(expandHint ? { expandHint } : {}),
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 getElapsed(context: BoxedToolContext): number | undefined {
220
- return getStateElapsedMs(context.state);
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
- const outputColor = context.isError ? "error" : "toolOutput";
700
-
701
- if (!options.expanded) {
702
- const scanLines = getToolsRenderConfig().maxCollapsedLines + 10;
703
- let nlCount = 0;
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
- const output = stripBashToolNoticeLines(stripAnsi(raw));
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 { type BoxTheme, getTextOutput, renderBoxedToolCall, renderBoxedToolResult } from "../../../shared/box.js";
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
- formatBoxedFooter,
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: [formatBoxedFooter(theme, result, [], elapsedMs)],
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
- boxInnerWidth,
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 { type BoxedToolContext, type BoxedToolDefinition, displayPath, noteExecutionStart } from "./shared.js";
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) {