@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.
- package/CHANGELOG.md +30 -0
- package/README.md +8 -4
- package/dist/extensions/pi-style.js +3777 -958
- 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/domain/config-normalization.ts +21 -5
- package/extension-src/pi-style/domain/config-presets.ts +1 -1
- package/extension-src/pi-style/domain/config-types.ts +7 -3
- package/extension-src/pi-style/domain/status.ts +1 -1
- package/extension-src/pi-style/domain/theme.ts +6 -1
- package/extension-src/pi-style/features/editor/index.ts +169 -27
- package/extension-src/pi-style/features/messages/index.ts +66 -0
- package/extension-src/pi-style/features/tools/bash-execution.ts +112 -0
- package/extension-src/pi-style/features/tools/boxed/bash.ts +430 -172
- package/extension-src/pi-style/features/tools/boxed/batch.ts +50 -27
- package/extension-src/pi-style/features/tools/boxed/command-shape.ts +136 -0
- 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/find.ts +2 -2
- package/extension-src/pi-style/features/tools/boxed/gh.ts +1012 -0
- package/extension-src/pi-style/features/tools/boxed/git.ts +1960 -0
- package/extension-src/pi-style/features/tools/boxed/grep.ts +2 -2
- package/extension-src/pi-style/features/tools/boxed/output-tree.ts +9 -10
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +25 -4
- package/extension-src/pi-style/features/tools/boxed/read.ts +3 -3
- 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 +19 -2
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +17 -0
- package/extension-src/pi-style/pi/compatibility-probe.ts +104 -10
- package/extension-src/pi-style/pi/compatibility-registry.ts +19 -3
- package/extension-src/pi-style/pi/index.ts +18 -1
- package/extension-src/pi-style/pi/session-coordinator.ts +33 -1
- package/extension-src/pi-style/shared/box.ts +86 -25
- package/extension-src/pi-style/shared/split-diff.ts +9 -9
- package/package.json +1 -1
- package/themes/titanium-light.json +82 -0
- package/themes/titanium.json +79 -0
- 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).
|
|
19
|
-
// (
|
|
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
|
|
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
|
|
25
|
-
// output as a file subtree (flat
|
|
26
|
-
//
|
|
27
|
-
// fall back to
|
|
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: "
|
|
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 = `${
|
|
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}${
|
|
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}${
|
|
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: `
|
|
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 ? " " :
|
|
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}${
|
|
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}${
|
|
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}${
|
|
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}${
|
|
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}${
|
|
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 `
|
|
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
|
-
//
|
|
423
|
-
//
|
|
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 {
|
|
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 };
|
|
@@ -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 `
|
|
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: "
|
|
24
|
+
headerLabel: "Find",
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
function pathLabel(rawPath: string): string {
|