@quandev104/pi-style 0.1.3 → 0.1.5

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +8 -4
  3. package/dist/extensions/pi-style.js +3777 -958
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/command-service.ts +2 -0
  6. package/extension-src/pi-style/domain/config-normalization.ts +21 -5
  7. package/extension-src/pi-style/domain/config-presets.ts +1 -1
  8. package/extension-src/pi-style/domain/config-types.ts +7 -3
  9. package/extension-src/pi-style/domain/status.ts +1 -1
  10. package/extension-src/pi-style/domain/theme.ts +6 -1
  11. package/extension-src/pi-style/features/editor/index.ts +169 -27
  12. package/extension-src/pi-style/features/messages/index.ts +66 -0
  13. package/extension-src/pi-style/features/tools/bash-execution.ts +112 -0
  14. package/extension-src/pi-style/features/tools/boxed/bash.ts +430 -172
  15. package/extension-src/pi-style/features/tools/boxed/batch.ts +50 -27
  16. package/extension-src/pi-style/features/tools/boxed/command-shape.ts +136 -0
  17. package/extension-src/pi-style/features/tools/boxed/edit.ts +28 -2
  18. package/extension-src/pi-style/features/tools/boxed/fallback.ts +47 -4
  19. package/extension-src/pi-style/features/tools/boxed/find.ts +2 -2
  20. package/extension-src/pi-style/features/tools/boxed/gh.ts +1012 -0
  21. package/extension-src/pi-style/features/tools/boxed/git.ts +1960 -0
  22. package/extension-src/pi-style/features/tools/boxed/grep.ts +2 -2
  23. package/extension-src/pi-style/features/tools/boxed/output-tree.ts +9 -10
  24. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +25 -4
  25. package/extension-src/pi-style/features/tools/boxed/read.ts +3 -3
  26. package/extension-src/pi-style/features/tools/boxed/session-config.ts +64 -4
  27. package/extension-src/pi-style/features/tools/boxed/shared.ts +41 -1
  28. package/extension-src/pi-style/features/tools/boxed/write.ts +19 -2
  29. package/extension-src/pi-style/pi/compatibility-coordinator.ts +17 -0
  30. package/extension-src/pi-style/pi/compatibility-probe.ts +104 -10
  31. package/extension-src/pi-style/pi/compatibility-registry.ts +19 -3
  32. package/extension-src/pi-style/pi/index.ts +18 -1
  33. package/extension-src/pi-style/pi/session-coordinator.ts +33 -1
  34. package/extension-src/pi-style/shared/box.ts +86 -25
  35. package/extension-src/pi-style/shared/split-diff.ts +9 -9
  36. package/package.json +1 -1
  37. package/themes/titanium-light.json +82 -0
  38. package/themes/titanium.json +79 -0
  39. package/themes/.gitkeep +0 -0
@@ -15,20 +15,23 @@
15
15
  // - Batch boundaries: a new batch starts when the active batch is closed. The
16
16
  // active batch closes when a non-batchable tool call is dispatched
17
17
  // (boxed/index.ts), when a new message starts (pi/index.ts), and on session
18
- // reset (session-coordinator.ts). Lone calls render the same boxless tree
19
- // (a batch of one) there is no boxed single-call special case.
18
+ // reset (session-coordinator.ts). A lone `read` call renders as a single
19
+ // inline line (`➔ Read <path>`); lone ls/find calls render a flat output
20
+ // tree (a batch of one).
20
21
  // - No surrounding box: indentation and tree glyphs (├─/└─) carry the
21
- // hierarchy; the header line is the summary (` Read (N) · 0.08s`).
22
+ // hierarchy; the header line of a batched panel is the summary
23
+ // (` Read (N) · 0.08s`).
22
24
  // - Errors stay visible: failed members are always rendered inline (even in the
23
25
  // collapsed state), with their error text indented beneath the path.
24
- // - read members render a single path row. ls/find members render their parsed
25
- // output as a file subtree (flat for a lone call, nested per member when
26
- // batched) see renderOutputBatchPanel. Pending/failed members without output
27
- // fall back to the path row.
26
+ // - read members render a single path row (a lone read collapses to one inline
27
+ // line). ls/find members render their parsed output as a file subtree (flat
28
+ // for a lone call, nested per member when batched) — see
29
+ // renderOutputBatchPanel. Pending/failed members without output fall back to
30
+ // the path row.
28
31
 
29
32
  import type { Component } from "@earendil-works/pi-tui";
30
33
  import { stripAnsi } from "../../../shared/ansi.js";
31
- import { type BoxTheme, formatToolTitlePrefix } from "../../../shared/box.js";
34
+ import { type BoxTheme, dimLine, formatToolTitlePrefix } from "../../../shared/box.js";
32
35
  import { safeTruncateToWidth } from "../../../shared/render-budget.js";
33
36
  import {
34
37
  fileIcon,
@@ -53,7 +56,7 @@ export interface BatchToolMeta {
53
56
  readonly toolName: string;
54
57
  /** Human label shown in the batch header (e.g. "Read", "List", "Find"). */
55
58
  readonly label: string;
56
- /** Header label for output-tree panels: "Glob" for find, "List" for ls. */
59
+ /** Header label for output-tree panels: "Find" for find, "List" for ls. */
57
60
  readonly headerLabel?: string;
58
61
  }
59
62
 
@@ -283,7 +286,7 @@ function renderErrorLines(theme: BoxTheme, errorText: string, width: number): st
283
286
  .map((line) => line.trim())
284
287
  .filter((line) => line.length > 0);
285
288
  if (raw.length === 0) return [];
286
- const prefix = `${theme.fg("borderMuted", " │ ")}`;
289
+ const prefix = `${dimLine(" │ ")}`;
287
290
  const out = raw
288
291
  .slice(0, BATCH_ERROR_LINES)
289
292
  .map((line) => safeTruncateToWidth(`${prefix}${theme.fg("error", line)}`, Math.max(1, width), "…"));
@@ -305,14 +308,14 @@ function renderBatchTree(theme: BoxTheme, batch: BatchState, status: BatchStatus
305
308
  const glyph = memberGlyph(theme, member, showGlyphs);
306
309
  // Primary color for files read successfully, error red for failures.
307
310
  const pathColor = member.isError ? "error" : member.status === "done" ? "accent" : "text";
308
- const line = `${BATCH_TREE_INDENT}${theme.fg("borderMuted", branch)}${glyph ? ` ${glyph}` : ""} ${theme.fg(pathColor, member.detail)}`;
311
+ const line = `${BATCH_TREE_INDENT}${dimLine(branch)}${glyph ? ` ${glyph}` : ""} ${theme.fg(pathColor, member.detail)}`;
309
312
  out.push(safeTruncateToWidth(line, Math.max(1, width), "…"));
310
313
  if (member.isError && member.errorText) out.push(...renderErrorLines(theme, member.errorText, width));
311
314
  }
312
315
  if (more > 0) {
313
316
  out.push(
314
317
  safeTruncateToWidth(
315
- `${BATCH_TREE_INDENT}${theme.fg("borderMuted", "└─")} ${theme.fg("dim", `${more} more`)}`,
318
+ `${BATCH_TREE_INDENT}${dimLine("└─")} ${theme.fg("dim", `${more} more`)}`,
316
319
  Math.max(1, width),
317
320
  "…",
318
321
  ),
@@ -321,7 +324,7 @@ function renderBatchTree(theme: BoxTheme, batch: BatchState, status: BatchStatus
321
324
  return out;
322
325
  }
323
326
 
324
- /** Header for a lone (batch-of-one) ls/find output panel: `Glob: <pattern> <N> files · in <path>`. */
327
+ /** Header for a lone (batch-of-one) ls/find output panel: `Find: <pattern> <N> files · in <path>`. */
325
328
  function formatLoneOutputHeader(theme: BoxTheme, meta: BatchToolMeta, member: BatchMember): string {
326
329
  const label = meta.headerLabel ?? meta.label;
327
330
  const count = member.outputEntries?.length ?? 0;
@@ -337,26 +340,26 @@ function formatLoneOutputHeader(theme: BoxTheme, meta: BatchToolMeta, member: Ba
337
340
  /** Nested file subtree for one member inside a batched (2+) output panel. */
338
341
  function renderMemberSubtree(theme: BoxTheme, member: BatchMember, isLastMember: boolean, width: number): string[] {
339
342
  const safeWidth = Math.max(1, width);
340
- const trunk = isLastMember ? " " : theme.fg("borderMuted", "│");
343
+ const trunk = isLastMember ? " " : dimLine("│");
341
344
  const out: string[] = [];
342
345
 
343
346
  // Member header row: path + file count (or status glyph when not done).
344
347
  const entries = member.outputEntries ?? [];
345
348
  if (member.isError) {
346
- const line = `${BATCH_TREE_INDENT}${theme.fg("borderMuted", isLastMember ? "└─" : "├─")} ${theme.fg("error", "✗")} ${theme.fg("error", member.pathLabel ?? member.detail)}`;
349
+ const line = `${BATCH_TREE_INDENT}${dimLine(isLastMember ? "└─" : "├─")} ${theme.fg("error", "✗")} ${theme.fg("error", member.pathLabel ?? member.detail)}`;
347
350
  out.push(safeTruncateToWidth(line, safeWidth, "…"));
348
351
  if (member.errorText) out.push(...renderErrorLines(theme, member.errorText, width));
349
352
  return out;
350
353
  }
351
354
  if (member.status !== "done" || member.outputEntries === undefined) {
352
355
  const glyph = member.status === "done" ? theme.fg("success", "✓") : theme.fg("text", "◌");
353
- const line = `${BATCH_TREE_INDENT}${theme.fg("borderMuted", isLastMember ? "└─" : "├─")} ${glyph} ${theme.fg("text", member.pathLabel ?? member.detail)}`;
356
+ const line = `${BATCH_TREE_INDENT}${dimLine(isLastMember ? "└─" : "├─")} ${glyph} ${theme.fg("text", member.pathLabel ?? member.detail)}`;
354
357
  out.push(safeTruncateToWidth(line, safeWidth, "…"));
355
358
  return out;
356
359
  }
357
360
 
358
361
  const countLabel = theme.fg("dim", ` · ${entries.length} ${pluralForm("file", entries.length)}`);
359
- const headerLine = `${BATCH_TREE_INDENT}${theme.fg("borderMuted", isLastMember ? "└─" : "├─")} ${theme.fg("accent", member.pathLabel ?? member.detail)}${countLabel}`;
362
+ const headerLine = `${BATCH_TREE_INDENT}${dimLine(isLastMember ? "└─" : "├─")} ${theme.fg("accent", member.pathLabel ?? member.detail)}${countLabel}`;
360
363
  out.push(safeTruncateToWidth(headerLine, safeWidth, "…"));
361
364
 
362
365
  const visible = entries.slice(0, BATCH_MEMBER_FILE_HEAD_LIMIT);
@@ -367,11 +370,11 @@ function renderMemberSubtree(theme: BoxTheme, member: BatchMember, isLastMember:
367
370
  const entry = visible[i] ?? "";
368
371
  const label = icons && entry ? `${fileIcon(entry)} ${entry}` : entry;
369
372
  const branch = i < lastIndex || more > 0 ? "├─" : "└─";
370
- const line = `${BATCH_TREE_INDENT}${trunk}${TREE_CHILD_INDENT}${theme.fg("borderMuted", branch)} ${theme.fg("toolOutput", label)}`;
373
+ const line = `${BATCH_TREE_INDENT}${trunk}${TREE_CHILD_INDENT}${dimLine(branch)} ${theme.fg("toolOutput", label)}`;
371
374
  out.push(safeTruncateToWidth(line, safeWidth, "…"));
372
375
  }
373
376
  if (more > 0) {
374
- const line = `${BATCH_TREE_INDENT}${trunk}${TREE_CHILD_INDENT}${theme.fg("borderMuted", "└─")} ${theme.fg("dim", `… ${more} more ${pluralForm("file", more)}`)}`;
377
+ const line = `${BATCH_TREE_INDENT}${trunk}${TREE_CHILD_INDENT}${dimLine("└─")} ${theme.fg("dim", `… ${more} more ${pluralForm("file", more)}`)}`;
375
378
  out.push(safeTruncateToWidth(line, safeWidth, "…"));
376
379
  }
377
380
  return out;
@@ -381,7 +384,7 @@ function renderMemberSubtree(theme: BoxTheme, member: BatchMember, isLastMember:
381
384
  function renderOutputBatchPanel(theme: BoxTheme, batch: BatchState, status: BatchStatus, width: number): string[] {
382
385
  const safeWidth = Math.max(1, width);
383
386
 
384
- // Lone successful call with output: flat tree under a `Glob:/List:` header.
387
+ // Lone successful call with output: flat tree under a `Find:/List:` header.
385
388
  if (batch.members.length === 1) {
386
389
  const member = batch.members[0];
387
390
  if (member && member.outputEntries !== undefined && !member.isError) {
@@ -408,19 +411,39 @@ function renderOutputBatchPanel(theme: BoxTheme, batch: BatchState, status: Batc
408
411
  });
409
412
  if (more > 0) {
410
413
  out.push(
411
- safeTruncateToWidth(
412
- `${BATCH_TREE_INDENT}${theme.fg("borderMuted", "└─")} ${theme.fg("dim", `${more} more`)}`,
413
- safeWidth,
414
- "…",
415
- ),
414
+ safeTruncateToWidth(`${BATCH_TREE_INDENT}${dimLine("└─")} ${theme.fg("dim", `${more} more`)}`, safeWidth, "…"),
416
415
  );
417
416
  }
418
417
  return out;
419
418
  }
420
419
 
420
+ /** Lone `read` call: single inline line `➔ Read <path>` — no count, no tree. */
421
+ function isLoneRead(batch: BatchState): boolean {
422
+ return batch.meta.toolName === "read" && batch.members.length === 1;
423
+ }
424
+
425
+ /** Lone read renders `➔ Read <path>` on one line; errors keep their error text. */
426
+ function renderLoneReadPanel(theme: BoxTheme, batch: BatchState, status: BatchStatus, width: number): string[] {
427
+ const member = batch.members[0];
428
+ if (!member) return [];
429
+ const prefix = bold(theme, formatToolTitlePrefix(theme, batch.meta.label));
430
+ const glyph = memberGlyph(theme, member, !status.allDone || status.failed > 0);
431
+ const pathColor = member.isError ? "error" : member.status === "done" ? "accent" : "text";
432
+ const out = [
433
+ safeTruncateToWidth(
434
+ `${prefix}${glyph ? ` ${glyph}` : ""} ${theme.fg(pathColor, member.detail)}`,
435
+ Math.max(1, width),
436
+ "…",
437
+ ),
438
+ ];
439
+ if (member.isError && member.errorText) out.push(...renderErrorLines(theme, member.errorText, width));
440
+ return out;
441
+ }
442
+
421
443
  function renderBatchPanelLines(theme: BoxTheme, batch: BatchState, status: BatchStatus, width: number): string[] {
422
- // The tree stays open in every state, including for a lone call: no boxed
423
- // single-call special case, no collapsed single-line summary.
444
+ // Lone read collapses to a single inline line; batched reads and lone
445
+ // ls/find calls keep their tree panels.
446
+ if (isLoneRead(batch)) return renderLoneReadPanel(theme, batch, status, width);
424
447
  if (isOutputTool(batch.meta) && batch.members.some((member) => member.outputEntries !== undefined)) {
425
448
  return renderOutputBatchPanel(theme, batch, status, width);
426
449
  }
@@ -0,0 +1,136 @@
1
+ // Simple bash command shape detection, shared by the bash tool renderer and
2
+ // the git/gh semantic classifiers.
3
+ //
4
+ // A command is "simple" when pi-style can reason about it purely from its
5
+ // token list: single line, no shell metacharacters (pipes, redirects,
6
+ // substitutions), and no `&&`/`;`/`&` outside a leading `cd X &&` chain.
7
+ // Anything ambiguous returns null so the boxed command/response shell stays
8
+ // the fallback (ADR 0005 — no approximate rendering).
9
+
10
+ /** Tokens of a classifiable command after env/prefix/cd-chain stripping. */
11
+ export interface SimpleBashCommandShape {
12
+ /** Tokens after leading env assignments, prefix commands, and `cd X &&` chains. */
13
+ readonly tokens: string[];
14
+ /** Last directory from a leading `cd <dir> &&` / `cd <dir>;` chain. */
15
+ readonly cdDir?: string;
16
+ }
17
+
18
+ const BASH_PREFIX_COMMANDS = new Set(["sudo", "env", "time", "nice", "nohup", "command", "stdbuf", "ionice", "watch"]);
19
+ // Pipes (`|`), `;`, and `&` are excluded here: the classifier validates them
20
+ // explicitly (allowing `cd X && cmd` chains and a trailing `| head/tail`).
21
+ const BASH_SHELL_META_CHARS = new Set(["<", ">", "(", ")", "`"]);
22
+
23
+ /** Tokenize a single command line, stripping quotes. Returns null on an
24
+ * unterminated quote. `hasMeta` is true if any shell metacharacter appears
25
+ * *outside* quotes (so `grep 'a|b' f` stays classifiable). */
26
+ function tokenizeCommandLine(line: string): { tokens: string[]; hasMeta: boolean } | null {
27
+ const tokens: string[] = [];
28
+ let current = "";
29
+ let inToken = false;
30
+ let quote: string | null = null;
31
+ let hasMeta = false;
32
+ for (let i = 0; i < line.length; i++) {
33
+ const char = line[i] ?? "";
34
+ if (quote) {
35
+ if (char === "\\" && quote === '"') {
36
+ current += line[++i] ?? "";
37
+ continue;
38
+ }
39
+ if (char === quote) {
40
+ quote = null;
41
+ continue;
42
+ }
43
+ current += char;
44
+ continue;
45
+ }
46
+ if (char === '"' || char === "'") {
47
+ quote = char;
48
+ inToken = true;
49
+ continue;
50
+ }
51
+ if (char === " " || char === "\t") {
52
+ if (inToken) {
53
+ tokens.push(current);
54
+ current = "";
55
+ inToken = false;
56
+ }
57
+ continue;
58
+ }
59
+ if (BASH_SHELL_META_CHARS.has(char) || (char === "$" && (line[i + 1] ?? "") === "(")) {
60
+ hasMeta = true;
61
+ continue;
62
+ }
63
+ current += char;
64
+ inToken = true;
65
+ }
66
+ if (quote) return null;
67
+ if (inToken) tokens.push(current);
68
+ return { tokens, hasMeta };
69
+ }
70
+
71
+ /** `head [-n N]` / `tail [-n N]` truncation pipe tail (allowed at the end). */
72
+ function isHeadOrTailTail(tokens: readonly string[]): boolean {
73
+ if (tokens.length === 0 || (tokens[0] !== "head" && tokens[0] !== "tail")) return false;
74
+ for (let i = 1; i < tokens.length; i++) {
75
+ const token = tokens[i] ?? "";
76
+ if (token === "-n") continue;
77
+ if (/^\d+$/.test(token)) continue;
78
+ if (/^-\d+$/.test(token)) continue;
79
+ return false;
80
+ }
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * Tokenize a single-line bash command and verify it is simple enough to
86
+ * classify: no shell metacharacters (`<`, `>`, `(`, `)`, backtick, `$(`), no
87
+ * `&&`/`;`/`&` outside a leading `cd X &&` chain, and — unless
88
+ * `allowTrailingTruncationPipe` — no pipes at all. Returns null for anything
89
+ * ambiguous so callers fall back to the boxed shell. Newlines and unterminated
90
+ * quotes are rejected.
91
+ */
92
+ export function parseSimpleBashCommand(
93
+ command: string,
94
+ options: { allowTrailingTruncationPipe?: boolean } = {},
95
+ ): SimpleBashCommandShape | null {
96
+ const commandText = String(command ?? "").trim();
97
+ if (!commandText || commandText.includes("\n")) return null;
98
+ const tokenized = tokenizeCommandLine(commandText);
99
+ if (!tokenized || tokenized.hasMeta || tokenized.tokens.length === 0) return null;
100
+ let tokens = tokenized.tokens;
101
+
102
+ if (options.allowTrailingTruncationPipe) {
103
+ // Allow a single trailing truncation pipe: `cmd | head [-n] N` / `| tail …`.
104
+ const pipes = tokens.flatMap((token, i) => (token === "|" ? [i] : []));
105
+ if (pipes.length > 0) {
106
+ if (pipes.length > 1) return null;
107
+ const last = pipes[0] ?? -1;
108
+ if (!isHeadOrTailTail(tokens.slice(last + 1))) return null;
109
+ tokens = tokens.slice(0, last);
110
+ }
111
+ } else if (tokens.includes("|")) {
112
+ // git/gh classification keeps the pipe rule strict (ADR 0005): any pipe
113
+ // falls back to the raw boxed shell.
114
+ return null;
115
+ }
116
+
117
+ let index = 0;
118
+ // Skip leading environment assignments (FOO=bar ...) and prefix commands.
119
+ while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index] ?? "")) index++;
120
+ while (index < tokens.length && BASH_PREFIX_COMMANDS.has(tokens[index] ?? "")) index++;
121
+ // `cd <dir> &&` / `cd <dir>;` chains: the last directory becomes the default
122
+ // path when the command itself carries none.
123
+ let cdDir: string | undefined;
124
+ while (
125
+ tokens[index] === "cd" &&
126
+ index + 2 < tokens.length &&
127
+ tokens[index + 1] !== undefined &&
128
+ (tokens[index + 2] === "&&" || tokens[index + 2] === ";")
129
+ ) {
130
+ cdDir = tokens[index + 1];
131
+ index += 3;
132
+ }
133
+ const rest = tokens.slice(index);
134
+ if (rest.length === 0 || rest.some((token) => token === "&&" || token === ";" || token === "&")) return null;
135
+ return { tokens: rest, ...(cdDir !== undefined ? { cdDir } : {}) };
136
+ }
@@ -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 };
@@ -1,7 +1,7 @@
1
1
  // Boxed find tool renderer.
2
2
  //
3
3
  // find calls render as a boxless tree panel — a lone find shows its parsed
4
- // output as a flat `Glob: <pattern> <N> files · in <path>` tree; consecutive
4
+ // output as a flat `Find: <pattern> <N> files · in <path>` tree; consecutive
5
5
  // find calls group into one panel with per-member nested subtrees (see
6
6
  // batch.ts). Pending/failed calls without output fall back to a path row.
7
7
 
@@ -21,7 +21,7 @@ import { type BoxedToolDefinition, noteExecutionStart } from "./shared.js";
21
21
  const FIND_META: BatchToolMeta = Object.freeze({
22
22
  toolName: "find",
23
23
  label: "Find",
24
- headerLabel: "Glob",
24
+ headerLabel: "Find",
25
25
  });
26
26
 
27
27
  function pathLabel(rawPath: string): string {