@oh-my-pi/pi-tui 18.14.2 → 18.15.0
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 +29 -33
- package/README.md +1 -1
- package/dist/types/chat/transcript-render-helpers.d.ts +8 -0
- package/dist/types/chrome/transcript-status.d.ts +2 -2
- package/dist/types/overlays/hook-editor.d.ts +2 -0
- package/dist/types/overlays/jobs-panel.d.ts +4 -0
- package/dist/types/render/render-utils.d.ts +17 -0
- package/dist/types/terminal-capabilities.d.ts +1 -1
- package/dist/types/tools/grouped-file-output.d.ts +76 -8
- package/dist/types/tools/hashline-format.d.ts +5 -0
- package/dist/types/tools/native-view.d.ts +2 -0
- package/dist/types/tools/read.d.ts +10 -0
- package/dist/types/tools/wait.d.ts +2 -0
- package/package.json +9 -9
- package/src/chat/chat-transcript-builder.ts +4 -2
- package/src/chat/read-tool-group.ts +48 -21
- package/src/chat/transcript-render-helpers.ts +36 -2
- package/src/chrome/transcript-status.ts +5 -3
- package/src/overlays/hook-editor.ts +6 -0
- package/src/overlays/jobs-panel.ts +45 -19
- package/src/render/file-list.ts +4 -7
- package/src/render/render-utils.ts +31 -3
- package/src/terminal-capabilities.ts +5 -0
- package/src/tmux.ts +1 -0
- package/src/tools/ast-edit.ts +48 -93
- package/src/tools/ast-grep.ts +22 -66
- package/src/tools/edit.ts +11 -2
- package/src/tools/find.ts +6 -3
- package/src/tools/glob.ts +2 -1
- package/src/tools/grep.ts +43 -287
- package/src/tools/grouped-file-output.ts +250 -64
- package/src/tools/hashline-format.ts +5 -0
- package/src/tools/lsp.ts +15 -15
- package/src/tools/native-view.ts +11 -5
- package/src/tools/proc-render.ts +2 -2
- package/src/tools/read.ts +14 -0
- package/src/tools/wait.ts +6 -3
|
@@ -6,10 +6,10 @@ import { type ThemeColor, theme } from "../theme";
|
|
|
6
6
|
import { TranscriptBlock } from "./transcript-container";
|
|
7
7
|
import { type NoticeTone, noticeRow } from "./transcript-notice";
|
|
8
8
|
|
|
9
|
-
/** One
|
|
9
|
+
/** One run of a status row's terminal line, in `color` unless the text is already styled. */
|
|
10
10
|
export interface TranscriptStatusPart {
|
|
11
11
|
readonly text: string;
|
|
12
|
-
readonly color
|
|
12
|
+
readonly color?: ThemeColor;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/** A status row in a native terminal: its {@link noticeRow}'s tone, icon and message. */
|
|
@@ -41,7 +41,9 @@ export class TranscriptStatusBlock extends TranscriptBlock {
|
|
|
41
41
|
constructor(rows: readonly TranscriptStatusRow[]) {
|
|
42
42
|
super();
|
|
43
43
|
for (const row of rows) {
|
|
44
|
-
const styled = row.parts.flatMap(part =>
|
|
44
|
+
const styled = row.parts.flatMap(part =>
|
|
45
|
+
part ? [part.color ? theme.fg(part.color, part.text) : part.text] : [],
|
|
46
|
+
);
|
|
45
47
|
this.addChild(new Text(styled.join(" "), row.indent ?? 1, 0));
|
|
46
48
|
}
|
|
47
49
|
this.#native = col(
|
|
@@ -407,6 +407,12 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
|
|
|
407
407
|
else this.#editor.pasteText(text);
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
+
/** Replace the draft, as Ctrl+R history search does for the main composer. */
|
|
411
|
+
setText(text: string): void {
|
|
412
|
+
if (this.#disposed) return;
|
|
413
|
+
this.#editor.setText(text);
|
|
414
|
+
}
|
|
415
|
+
|
|
410
416
|
/**
|
|
411
417
|
* Prompt-style: raw Enter submits; Editor owns newline-producing sequences.
|
|
412
418
|
* The follow-up chord (`app.message.followUp` → Ctrl+Q / Ctrl+Enter) also
|
|
@@ -26,6 +26,10 @@ export interface JobsPanelJob {
|
|
|
26
26
|
readonly type: string;
|
|
27
27
|
readonly status: "running" | "completed" | "failed" | "cancelled";
|
|
28
28
|
readonly label: string;
|
|
29
|
+
/** What the job is for, in the model's words; rows and the detail lead with it over a command `label`. */
|
|
30
|
+
readonly intent?: string;
|
|
31
|
+
/** One line on what a command does and when it ends, from the cheap model; the detail shows it under the intent. */
|
|
32
|
+
readonly caption?: string;
|
|
29
33
|
readonly startTime: number;
|
|
30
34
|
readonly endTime?: number;
|
|
31
35
|
/** Registry id of the agent that owns the job. */
|
|
@@ -146,13 +150,14 @@ export class JobsSheet implements Component {
|
|
|
146
150
|
const detail = selected && this.#inspect(selected.id);
|
|
147
151
|
const now = Date.now();
|
|
148
152
|
// Elapsed nodes tick on their own; list values and the output pane redraw
|
|
149
|
-
// when a job starts, settles, leaves or moves in the tree, its
|
|
150
|
-
//
|
|
153
|
+
// when a job starts, settles, leaves or moves in the tree, gets its
|
|
154
|
+
// caption (it can land after the job settles), its process changes, its
|
|
155
|
+
// output grows, or (while one runs) once a second.
|
|
151
156
|
const key = [
|
|
152
157
|
rows
|
|
153
158
|
.map(
|
|
154
159
|
entry =>
|
|
155
|
-
`${jobTreeGuide(entry, theme)}${entry.kind === "job" ? `${entry.job.id}:${entry.job.status}` : entry.agent.id}`,
|
|
160
|
+
`${jobTreeGuide(entry, theme)}${entry.kind === "job" ? `${entry.job.id}:${entry.job.status}:${entry.job.caption ?? ""}` : entry.agent.id}`,
|
|
156
161
|
)
|
|
157
162
|
.join(","),
|
|
158
163
|
selected?.id,
|
|
@@ -200,15 +205,20 @@ export class JobsSheet implements Component {
|
|
|
200
205
|
const cursor = job === selected ? ">" : " ";
|
|
201
206
|
const age = formatDuration(jobAge(job, now));
|
|
202
207
|
const agent = entry.agent && entry.agent.id !== job.label ? `${entry.agent.id}: ` : "";
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
width,
|
|
207
|
-
),
|
|
208
|
-
);
|
|
208
|
+
// The intent is model-written: strip terminal controls and flatten it to one row like the command.
|
|
209
|
+
const title = shortenEmbeddedPaths(collapseCommand(sanitizeText(job.intent ?? job.label)));
|
|
210
|
+
lines.push(truncateToWidth(`${cursor} ${guide}${job.status} ${job.type} ${age} ${agent}${title}`, width));
|
|
209
211
|
}
|
|
210
212
|
const detail = selected && this.#inspect(selected.id);
|
|
211
213
|
if (detail) {
|
|
214
|
+
if (selected.caption) {
|
|
215
|
+
for (const line of wrapTextWithAnsi(
|
|
216
|
+
shortenEmbeddedPaths(sanitizeText(selected.caption)),
|
|
217
|
+
Math.max(1, width - 3),
|
|
218
|
+
)) {
|
|
219
|
+
lines.push(` ${theme.fg("muted", line)}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
212
222
|
// Rows show the cut label; the selected job's command wraps here,
|
|
213
223
|
// bounded so a long script never pushes the facts and output off the sheet.
|
|
214
224
|
if (detail.command) {
|
|
@@ -227,7 +237,7 @@ export class JobsSheet implements Component {
|
|
|
227
237
|
detail.artifactId && `artifact://${detail.artifactId}`,
|
|
228
238
|
]);
|
|
229
239
|
if (facts.length > 0) lines.push(truncateToWidth(` ${facts.join(" · ")}`, width));
|
|
230
|
-
if (detail.widget) {
|
|
240
|
+
if (detail.widget && widgetHasData(detail.widget)) {
|
|
231
241
|
for (const line of renderJobWidgetLines(detail.widget, Math.max(0, width - 3), theme)) {
|
|
232
242
|
lines.push(` ${line}`);
|
|
233
243
|
}
|
|
@@ -331,6 +341,11 @@ function jobAge(job: JobsPanelJob, nowMs: number): number {
|
|
|
331
341
|
return Math.max(0, (job.endTime ?? nowMs) - job.startTime);
|
|
332
342
|
}
|
|
333
343
|
|
|
344
|
+
/** Whether a widget draws more than its title: one without data only repeats the job's heading. */
|
|
345
|
+
function widgetHasData(widget: JobWidgetView): boolean {
|
|
346
|
+
return widget.blocks.length > 0 || widget.headline !== undefined || Boolean(widget.stage);
|
|
347
|
+
}
|
|
348
|
+
|
|
334
349
|
/**
|
|
335
350
|
* The last `count` pieces of `text.trimEnd().split("\n")`, found by scanning
|
|
336
351
|
* back from the end so a long running output is never split as a whole.
|
|
@@ -373,8 +388,9 @@ function describeJobList(rows: readonly JobTreeRow<JobsPanelJob>[], selected: Jo
|
|
|
373
388
|
|
|
374
389
|
/**
|
|
375
390
|
* One list row: the type icon tinted by status, the tree connector, the
|
|
376
|
-
* subagent a task job runs, the one-line label (
|
|
377
|
-
*
|
|
391
|
+
* subagent a task job runs, the one-line label (what the job is for when the
|
|
392
|
+
* model said, else a process job's command in mono or a task's work in prose)
|
|
393
|
+
* and how long it ran.
|
|
378
394
|
*/
|
|
379
395
|
function describeJobItem(job: JobsPanelJob, agentName: string | undefined, guide: string, nowMs: number): NativeNode {
|
|
380
396
|
const status = STATUS_LABEL[job.status];
|
|
@@ -383,13 +399,17 @@ function describeJobItem(job: JobsPanelJob, agentName: string | undefined, guide
|
|
|
383
399
|
label: compact([
|
|
384
400
|
guide !== "" && span(guide, "mono dim"),
|
|
385
401
|
agentName !== undefined && agentName !== job.label && span(`${agentName} `, "strong"),
|
|
386
|
-
job.
|
|
402
|
+
job.intent
|
|
403
|
+
? span(job.intent)
|
|
404
|
+
: job.type === "task"
|
|
405
|
+
? span(job.label)
|
|
406
|
+
: span(collapseCommand(job.label), "mono"),
|
|
387
407
|
]),
|
|
388
408
|
value: [span(age, "mono")],
|
|
389
409
|
icon: TYPE_ICON[job.type] ?? "job",
|
|
390
410
|
tone: STATUS_TONE[job.status],
|
|
391
|
-
title: `${job.label}\n${status} · ${job.type} · ${job.id}`,
|
|
392
|
-
aria: `${job.label}, ${status.toLowerCase()}, ${age}`,
|
|
411
|
+
title: `${job.intent ? `${job.intent}\n` : ""}${job.caption ? `${job.caption}\n` : ""}${job.label}\n${status} · ${job.type} · ${job.id}`,
|
|
412
|
+
aria: `${job.intent ?? job.label}, ${status.toLowerCase()}, ${age}`,
|
|
393
413
|
});
|
|
394
414
|
}
|
|
395
415
|
|
|
@@ -422,8 +442,9 @@ function describeEmpty(): NativeNode {
|
|
|
422
442
|
|
|
423
443
|
/**
|
|
424
444
|
* The selected job: a status line (pill, live elapsed, type · id, Cancel job
|
|
425
|
-
* when `cancellable`),
|
|
426
|
-
* its
|
|
445
|
+
* when `cancellable`), what it is for, what its command does (the cheap
|
|
446
|
+
* model's caption), its full command, facts, its widget
|
|
447
|
+
* when it has data to draw and the output pane (or its empty state).
|
|
427
448
|
*/
|
|
428
449
|
function describeDetail(
|
|
429
450
|
job: JobsPanelJob,
|
|
@@ -435,6 +456,7 @@ function describeDetail(
|
|
|
435
456
|
const exit = detail?.exitCode;
|
|
436
457
|
const age = Math.round(jobAge(job, nowMs));
|
|
437
458
|
const command = detail?.command ?? (job.type === "task" ? undefined : job.label);
|
|
459
|
+
const title = job.intent ?? (command ? undefined : job.label);
|
|
438
460
|
const pids = detail?.pids ?? [];
|
|
439
461
|
const facts = compact([
|
|
440
462
|
job.agentId ? { k: "Agent", v: [span(job.agentId, "mono")] } : undefined,
|
|
@@ -464,11 +486,15 @@ function describeDetail(
|
|
|
464
486
|
]),
|
|
465
487
|
{ role: "omp.jobs.status", gap: "sm", align: "center" },
|
|
466
488
|
),
|
|
489
|
+
title ? text(title, { role: "omp.jobs.title", wrap: "word" }) : undefined,
|
|
490
|
+
job.caption ? text([span(job.caption, "muted")], { role: "omp.jobs.caption", wrap: "word" }) : undefined,
|
|
467
491
|
command
|
|
468
492
|
? code(command, { lang: "bash", wrap: true, preview: { lines: 4 }, role: "omp.jobs.command" })
|
|
469
|
-
:
|
|
493
|
+
: undefined,
|
|
470
494
|
facts.length > 0 && node("kv", { items: facts, role: "omp.jobs.facts" }),
|
|
471
|
-
detail?.widget &&
|
|
495
|
+
detail?.widget &&
|
|
496
|
+
widgetHasData(detail.widget) &&
|
|
497
|
+
node("col", { role: "omp.jobs.widget" }, [describeJobWidget(detail.widget)], "widget"),
|
|
472
498
|
node(
|
|
473
499
|
"col",
|
|
474
500
|
{ role: "omp.jobs.out", gap: "sm" },
|
package/src/render/file-list.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { node, span } from "../native/describe";
|
|
|
6
6
|
import type { NativeNode } from "../native/node";
|
|
7
7
|
import type { Theme } from "../theme/theme";
|
|
8
8
|
import { fileLinkSpan } from "./hyperlink";
|
|
9
|
+
import { formatStyledPath } from "./render-utils";
|
|
9
10
|
import { renderTreeList } from "./tree-list";
|
|
10
11
|
|
|
11
12
|
/** A file-list item with optional display metadata. */
|
|
@@ -69,15 +70,11 @@ export function renderFileList(options: FileListOptions, theme: Theme): string[]
|
|
|
69
70
|
const isDirectory = entry.isDirectory ?? entry.path.endsWith("/");
|
|
70
71
|
const displayPath = entry.path;
|
|
71
72
|
const lang = isDirectory ? undefined : getLanguageFromPath(displayPath);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
: isDirectory
|
|
75
|
-
? theme.fg("accent", theme.icon.folder)
|
|
76
|
-
: theme.fg("muted", theme.getLangIcon(lang));
|
|
77
|
-
const labelColor = isDirectory ? "accent" : "toolOutput";
|
|
73
|
+
// Type icons stay muted beside the dim directory and colored file name.
|
|
74
|
+
const icon = !showIcons ? "" : theme.fg("muted", isDirectory ? theme.icon.folder : theme.getLangIcon(lang));
|
|
78
75
|
const meta = entry.meta ? ` ${theme.fg("dim", entry.meta)}` : "";
|
|
79
76
|
const iconPrefix = icon ? `${icon} ` : "";
|
|
80
|
-
const pathStr =
|
|
77
|
+
const pathStr = formatStyledPath(displayPath, theme);
|
|
81
78
|
const linkedPath = entry.absPath && hyperlinkFn ? hyperlinkFn(entry.absPath, pathStr) : pathStr;
|
|
82
79
|
return `${iconPrefix}${linkedPath}${meta}`;
|
|
83
80
|
},
|
|
@@ -13,7 +13,7 @@ import { expandWindowsLongPath, getWindowsShortPath } from "@oh-my-pi/pi-natives
|
|
|
13
13
|
import { pluralize, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
14
14
|
import { formatKeyHint, type KeyId } from "../app-keybindings";
|
|
15
15
|
import { getKeybindings } from "../keybindings";
|
|
16
|
-
import type { Theme } from "../theme/theme";
|
|
16
|
+
import type { Theme, ThemeColor } from "../theme/theme";
|
|
17
17
|
import type { Component } from "../tui";
|
|
18
18
|
import { replaceTabs, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
19
19
|
import { Hasher } from "./utils";
|
|
@@ -435,6 +435,34 @@ export function formatEmptyMessage(message: string, theme: Theme): string {
|
|
|
435
435
|
return `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", message)}`;
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
/**
|
|
439
|
+
* Color of a file's name, or a whole URL, in tool bodies: it keeps paths apart
|
|
440
|
+
* from the tool titles above them (bronze in titanium).
|
|
441
|
+
*/
|
|
442
|
+
export const FILE_NAME_COLOR = "customMessageLabel" satisfies ThemeColor;
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Where a display path's final segment (file or folder name) starts, past its
|
|
446
|
+
* last `/` or `\` separator; a trailing-slash directory keeps its slash on the
|
|
447
|
+
* name. Shared by the terminal ({@link formatStyledPath}) and Tern file rows.
|
|
448
|
+
*/
|
|
449
|
+
export function pathNameStart(filePath: string): number {
|
|
450
|
+
let end = filePath.length;
|
|
451
|
+
while (end > 0 && (filePath[end - 1] === "/" || filePath[end - 1] === "\\")) end--;
|
|
452
|
+
return end === 0 ? 0 : Math.max(filePath.lastIndexOf("/", end - 1), filePath.lastIndexOf("\\", end - 1)) + 1;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* A file path in a tool body: the directory (with its trailing separator) dim,
|
|
457
|
+
* the final segment in {@link FILE_NAME_COLOR}. Tool headers keep paths in
|
|
458
|
+
* their plain subject color instead.
|
|
459
|
+
*/
|
|
460
|
+
export function formatStyledPath(filePath: string, theme: Theme): string {
|
|
461
|
+
const cut = pathNameStart(filePath);
|
|
462
|
+
const name = theme.fg(FILE_NAME_COLOR, filePath.slice(cut));
|
|
463
|
+
return cut > 0 ? `${theme.fg("dim", filePath.slice(0, cut))}${name}` : name;
|
|
464
|
+
}
|
|
465
|
+
|
|
438
466
|
// =============================================================================
|
|
439
467
|
// Code Frame Formatting
|
|
440
468
|
// =============================================================================
|
|
@@ -600,7 +628,7 @@ export function formatDiagnostics(
|
|
|
600
628
|
const fileBranch = isLastFileNode ? theme.tree.last : theme.tree.branch;
|
|
601
629
|
|
|
602
630
|
const fileIcon = theme.fg("muted", getLangIcon(filePath));
|
|
603
|
-
output += `\n ${theme.fg("dim", fileBranch)} ${fileIcon} ${
|
|
631
|
+
output += `\n ${theme.fg("dim", fileBranch)} ${fileIcon} ${formatStyledPath(filePath, theme)}`;
|
|
604
632
|
|
|
605
633
|
for (let di = 0; di < diagnostics.length && diagsShown < maxDiags; di++) {
|
|
606
634
|
const d = diagnostics[di];
|
|
@@ -624,7 +652,7 @@ export function formatDiagnostics(
|
|
|
624
652
|
: d.severity === "warning"
|
|
625
653
|
? theme.styledSymbol("status.warning", "warning")
|
|
626
654
|
: theme.styledSymbol("status.info", "muted");
|
|
627
|
-
const location = theme.fg("
|
|
655
|
+
const location = theme.fg("muted", `:${d.line}:${d.col}`);
|
|
628
656
|
const codeTag = d.code ? theme.fg("dim", ` (${d.code})`) : "";
|
|
629
657
|
const msgColor = d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "toolOutput";
|
|
630
658
|
|
|
@@ -67,6 +67,7 @@ export type TerminalId =
|
|
|
67
67
|
| "rio"
|
|
68
68
|
| "tern"
|
|
69
69
|
| "monstar"
|
|
70
|
+
| "rex"
|
|
70
71
|
| "base"
|
|
71
72
|
| "trueColor";
|
|
72
73
|
|
|
@@ -626,6 +627,8 @@ const KNOWN_TERMINALS = Object.freeze({
|
|
|
626
627
|
// OSC 99, and OSC 66 text sizing are outside rio's supported set and keep
|
|
627
628
|
// the conservative defaults.
|
|
628
629
|
rio: new TerminalInfo("rio", ImageProtocol.Kitty, true, true),
|
|
630
|
+
// Rex (TERM_PROGRAM=rex / TERM=xterm-rex): only Kitty graphics is confirmed.
|
|
631
|
+
rex: new TerminalInfo("rex", ImageProtocol.Kitty, true, false),
|
|
629
632
|
// Tern (Stencil's terminal, `stencil-term`) sets TERM_PROGRAM=tern and
|
|
630
633
|
// implements Kitty graphics, OSC 8 and OSC 9/99 notifications. Whether omp
|
|
631
634
|
// renders natively (Tern Surface Protocol) is decided by the `hello`
|
|
@@ -666,6 +669,7 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
|
|
|
666
669
|
if (caseEq(program, "rio")) return "rio";
|
|
667
670
|
if (caseEq(program, "tern")) return "tern";
|
|
668
671
|
if (caseEq(program, "monstar")) return "monstar";
|
|
672
|
+
if (caseEq(program, "rex")) return "rex";
|
|
669
673
|
return null;
|
|
670
674
|
}
|
|
671
675
|
|
|
@@ -717,6 +721,7 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
|
|
|
717
721
|
|
|
718
722
|
if (TERM?.toLowerCase().includes("ghostty")) return "ghostty";
|
|
719
723
|
if (TERM && caseEq(TERM, "monstar")) return "monstar";
|
|
724
|
+
if (TERM && caseEq(TERM, "xterm-rex")) return "rex";
|
|
720
725
|
|
|
721
726
|
if (COLORTERM) {
|
|
722
727
|
if (caseEq(COLORTERM, "truecolor") || caseEq(COLORTERM, "24bit")) return "trueColor";
|
package/src/tmux.ts
CHANGED
|
@@ -21,6 +21,7 @@ const CLIENT_TERMINAL_TIMEOUT_MS = 2_000;
|
|
|
21
21
|
const CLIENT_TERMNAME_PROGRAMS: ReadonlyArray<readonly [RegExp, string]> = [
|
|
22
22
|
[/^xterm-kitty$/u, "kitty"],
|
|
23
23
|
[/^xterm-ghostty$/u, "ghostty"],
|
|
24
|
+
[/^xterm-rex$/u, "rex"],
|
|
24
25
|
[/^wezterm$/u, "wezterm"],
|
|
25
26
|
];
|
|
26
27
|
let cachedClientTerminalNames: readonly string[] | undefined;
|
package/src/tools/ast-edit.ts
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
import { isRecord, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
2
1
|
import type { Component } from "../tui";
|
|
3
2
|
import { Text } from "../components/text";
|
|
4
|
-
import { replaceTabs } from "../utils";
|
|
5
3
|
import type { Theme } from "../theme/theme";
|
|
6
|
-
import { Ellipsis,
|
|
4
|
+
import { Ellipsis, renderStatusLine, truncateToWidth } from "../render";
|
|
7
5
|
import { framedToolCard } from "../render/tool-card";
|
|
8
6
|
import {
|
|
9
7
|
appendParseErrorsBulletList,
|
|
10
8
|
formatCount,
|
|
11
9
|
formatErrorDetail,
|
|
12
|
-
formatMoreItems,
|
|
13
10
|
formatParseErrorsCountLabel,
|
|
14
11
|
PREVIEW_LIMITS,
|
|
15
12
|
} from "../render/render-utils";
|
|
16
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
classifyGroupedLines,
|
|
15
|
+
createGroupedRowStyler,
|
|
16
|
+
fitGroupsToBudget,
|
|
17
|
+
groupedLinkTargets,
|
|
18
|
+
groupLineIndicesByBlank,
|
|
19
|
+
groupsByFile,
|
|
20
|
+
walkGroupedOutput,
|
|
21
|
+
} from "./grouped-file-output";
|
|
17
22
|
import type { OutputMeta } from "./output-meta";
|
|
18
23
|
import type {
|
|
19
24
|
NativeToolHead,
|
|
@@ -59,34 +64,24 @@ interface AstEditRenderArgs {
|
|
|
59
64
|
const COLLAPSED_CHANGE_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
|
|
60
65
|
|
|
61
66
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* noise. Collapsed mode always shows at least the first group, then fills up
|
|
66
|
-
* to `budget` lines before summarizing the rest as `… N more changes`.
|
|
67
|
+
* Frame body lines for per-file change groups, styled on demand: files apart by
|
|
68
|
+
* a blank line and without tree guides (the frame border is the container), fit
|
|
69
|
+
* to `budget` with the rest summarized as `… N more files`.
|
|
67
70
|
*/
|
|
68
71
|
function buildChangeBody(
|
|
69
72
|
groups: readonly (readonly number[])[],
|
|
70
73
|
styleLine: (index: number) => string,
|
|
71
|
-
|
|
74
|
+
isFileRow: (index: number) => boolean,
|
|
72
75
|
budget: number,
|
|
73
76
|
theme: Theme,
|
|
74
77
|
): string[] {
|
|
78
|
+
const { shown, summary } = fitGroupsToBudget(groups, budget, { isUnit: isFileRow, unit: "file", gap: 1 });
|
|
75
79
|
const lines: string[] = [];
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const group = groups[i]!;
|
|
79
|
-
const separator = shown > 0 ? 1 : 0;
|
|
80
|
-
const remainingAfter = groups.length - (i + 1);
|
|
81
|
-
const reserved = !expanded && remainingAfter > 0 ? 1 : 0;
|
|
82
|
-
// Always emit the first group; budget only gates subsequent ones.
|
|
83
|
-
if (!expanded && shown > 0 && lines.length + separator + group.length + reserved > budget) break;
|
|
84
|
-
if (separator) lines.push("");
|
|
80
|
+
for (const group of shown) {
|
|
81
|
+
if (lines.length > 0) lines.push("");
|
|
85
82
|
for (const index of group) lines.push(styleLine(index));
|
|
86
|
-
shown++;
|
|
87
83
|
}
|
|
88
|
-
|
|
89
|
-
if (!expanded && remaining > 0) lines.push(theme.fg("muted", formatMoreItems(remaining, "change")));
|
|
84
|
+
if (summary) lines.push(theme.fg("muted", summary));
|
|
90
85
|
return lines;
|
|
91
86
|
}
|
|
92
87
|
|
|
@@ -98,9 +93,7 @@ function patternPreview(pat: string | undefined): string | undefined {
|
|
|
98
93
|
return collapsed || undefined;
|
|
99
94
|
}
|
|
100
95
|
|
|
101
|
-
const AST_EDIT_HEADER_RE = /^(#+)\s+(.*)$/;
|
|
102
96
|
const AST_EDIT_CHANGE_LINE_RE = /^([+\- ])\s*(\d+)(?:│|[:|])(.*)$/;
|
|
103
|
-
const AST_EDIT_HEADER_SUFFIX_RE = /\s+\([^)]*\)\s*$/;
|
|
104
97
|
|
|
105
98
|
/** One file's proposed rewrite: its unified diff text and change counts. */
|
|
106
99
|
interface AstEditFileDiff {
|
|
@@ -117,29 +110,15 @@ interface AstEditFileDiff {
|
|
|
117
110
|
*/
|
|
118
111
|
function parseAstEditChanges(lines: readonly string[]): AstEditFileDiff[] {
|
|
119
112
|
const files: AstEditFileDiff[] = [];
|
|
120
|
-
const dirs: string[] = [];
|
|
121
113
|
let file: AstEditFileDiff | undefined;
|
|
122
|
-
const
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
for (const line of lines) {
|
|
127
|
-
const header = AST_EDIT_HEADER_RE.exec(line);
|
|
128
|
-
if (header) {
|
|
129
|
-
flush();
|
|
130
|
-
const depth = header[1]!.length;
|
|
131
|
-
const rest = header[2]!.trimEnd().replace(AST_EDIT_HEADER_SUFFIX_RE, "");
|
|
132
|
-
dirs.length = depth - 1;
|
|
133
|
-
if (rest.endsWith("/")) {
|
|
134
|
-
dirs[depth - 1] = rest.slice(0, -1);
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
const prefix = dirs.filter(Boolean).join("/");
|
|
138
|
-
file = { path: prefix ? `${prefix}/${rest}` : rest, lines: [], added: 0, removed: 0 };
|
|
114
|
+
for (const event of walkGroupedOutput(lines)) {
|
|
115
|
+
if (event.kind !== "line") {
|
|
116
|
+
if (file && file.lines.length > 0) files.push(file);
|
|
117
|
+
file = event.kind === "file" ? { path: event.path, lines: [], added: 0, removed: 0 } : undefined;
|
|
139
118
|
continue;
|
|
140
119
|
}
|
|
141
|
-
if (!file
|
|
142
|
-
const change = AST_EDIT_CHANGE_LINE_RE.exec(
|
|
120
|
+
if (!file) continue;
|
|
121
|
+
const change = AST_EDIT_CHANGE_LINE_RE.exec(event.text);
|
|
143
122
|
if (!change) continue;
|
|
144
123
|
const lineNumber = Number.parseInt(change[2]!, 10);
|
|
145
124
|
if (file.lastOld === undefined || lineNumber > file.lastOld + 1 || lineNumber < file.lastOld) {
|
|
@@ -150,7 +129,7 @@ function parseAstEditChanges(lines: readonly string[]): AstEditFileDiff[] {
|
|
|
150
129
|
else if (change[1] === "-") file.removed++;
|
|
151
130
|
file.lines.push(`${change[1]}${change[3]}`);
|
|
152
131
|
}
|
|
153
|
-
|
|
132
|
+
if (file && file.lines.length > 0) files.push(file);
|
|
154
133
|
return files;
|
|
155
134
|
}
|
|
156
135
|
|
|
@@ -248,53 +227,23 @@ export const astEditToolRenderer = {
|
|
|
248
227
|
|
|
249
228
|
const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
|
|
250
229
|
const allLines = textContent.split("\n");
|
|
251
|
-
//
|
|
252
|
-
// reconstruct across the blank-line groups the tree list collapses by.
|
|
230
|
+
// Classify the whole output so nested directory headers resolve across blank-line groups.
|
|
253
231
|
const contexts = classifyGroupedLines(allLines, details?.cwd ?? details?.searchPath, details?.searchPath);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
const styleLine = (index: number): string => {
|
|
270
|
-
const cached = styledLines[index];
|
|
271
|
-
if (cached !== undefined) return cached;
|
|
272
|
-
const ctx = contexts[index]!;
|
|
273
|
-
// Swap the inner code-frame gutter `│` for a space so it does not nest a
|
|
274
|
-
// second vertical bar inside the frame border.
|
|
275
|
-
const display = replaceTabs(allLines[index]!.replace("│", " "));
|
|
276
|
-
let styled: string;
|
|
277
|
-
const linkPath =
|
|
278
|
-
ctx.headerPath && rawContexts?.[index]?.kind === ctx.kind && rawContexts[index]?.depth === ctx.depth
|
|
279
|
-
? (rawContexts[index]?.headerPath ?? ctx.headerPath)
|
|
280
|
-
: ctx.headerPath;
|
|
281
|
-
if (ctx.kind === "dir") {
|
|
282
|
-
const accent = uiTheme.fg("accent", display);
|
|
283
|
-
styled = linkPath ? fileHyperlink(linkPath, accent) : accent;
|
|
284
|
-
} else if (ctx.kind === "file") {
|
|
285
|
-
const tinted = uiTheme.fg(ctx.depth === 1 ? "accent" : "dim", display);
|
|
286
|
-
styled = linkPath ? fileHyperlink(linkPath, tinted) : tinted;
|
|
287
|
-
} else if (display.startsWith("+")) {
|
|
288
|
-
styled = uiTheme.fg("toolDiffAdded", display);
|
|
289
|
-
} else if (display.startsWith("-")) {
|
|
290
|
-
styled = uiTheme.fg("toolDiffRemoved", display);
|
|
291
|
-
} else {
|
|
292
|
-
styled = uiTheme.fg("toolOutput", display);
|
|
293
|
-
}
|
|
294
|
-
styledLines[index] = styled;
|
|
295
|
-
return styled;
|
|
296
|
-
};
|
|
297
|
-
const changeGroups = groupLineIndicesByBlank(allLines).filter(indices => {
|
|
232
|
+
const styleLine = createGroupedRowStyler(allLines, contexts, uiTheme, {
|
|
233
|
+
linkTarget: groupedLinkTargets(contexts, textContent, details, options.renderContext?.rawDetails),
|
|
234
|
+
styleBody: index => {
|
|
235
|
+
// Swap the code-frame gutter `│` for a space so it does not nest a
|
|
236
|
+
// second vertical bar inside the frame border.
|
|
237
|
+
const line = allLines[index]!.replace("│", " ");
|
|
238
|
+
const color = line.startsWith("+")
|
|
239
|
+
? "toolDiffAdded"
|
|
240
|
+
: line.startsWith("-")
|
|
241
|
+
? "toolDiffRemoved"
|
|
242
|
+
: "toolOutput";
|
|
243
|
+
return uiTheme.fg(color, line);
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
const changeGroups = groupsByFile(groupLineIndicesByBlank(allLines), contexts).filter(indices => {
|
|
298
247
|
const first = allLines[indices[0]!]!;
|
|
299
248
|
return !first.startsWith("Safety cap reached") && !first.startsWith("Parse issues:");
|
|
300
249
|
});
|
|
@@ -321,7 +270,13 @@ export const astEditToolRenderer = {
|
|
|
321
270
|
({ contentWidth }) => {
|
|
322
271
|
const expanded = Boolean(options.expanded);
|
|
323
272
|
if (bodyMemo === undefined || bodyMemo.expanded !== expanded || bodyMemo.width !== contentWidth) {
|
|
324
|
-
const changeLines = buildChangeBody(
|
|
273
|
+
const changeLines = buildChangeBody(
|
|
274
|
+
changeGroups,
|
|
275
|
+
styleLine,
|
|
276
|
+
index => contexts[index]!.kind === "file",
|
|
277
|
+
expanded ? Number.POSITIVE_INFINITY : COLLAPSED_CHANGE_LIMIT,
|
|
278
|
+
uiTheme,
|
|
279
|
+
);
|
|
325
280
|
const lines = [...changeLines, ...extraLines].map(l => truncateToWidth(l, contentWidth, Ellipsis.Omit));
|
|
326
281
|
while (lines.length > 0 && lines[0].trim() === "") lines.shift();
|
|
327
282
|
bodyMemo = { expanded, width: contentWidth, lines };
|
package/src/tools/ast-grep.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { isRecord, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
2
1
|
import type { Component } from "../tui";
|
|
3
2
|
import { Text } from "../components/text";
|
|
4
3
|
import type { Theme } from "../theme/theme";
|
|
5
|
-
import {
|
|
4
|
+
import { renderStatusLine, renderTreeList } from "../render";
|
|
6
5
|
import {
|
|
7
6
|
appendParseErrorsBulletList,
|
|
8
7
|
createCachedComponent,
|
|
@@ -17,8 +16,13 @@ import {
|
|
|
17
16
|
import {
|
|
18
17
|
classifyGroupedLines,
|
|
19
18
|
createCodeFrameStyler,
|
|
19
|
+
createGroupedRowStyler,
|
|
20
20
|
describeGroupedOutput,
|
|
21
|
+
fitGroupsToBudget,
|
|
22
|
+
groupedLinkTargets,
|
|
21
23
|
groupLineIndicesByBlank,
|
|
24
|
+
groupsByFile,
|
|
25
|
+
isMatchRow,
|
|
22
26
|
} from "./grouped-file-output";
|
|
23
27
|
import { code, compact, node, span } from "../native/describe";
|
|
24
28
|
import type { NativeChild } from "../native/node";
|
|
@@ -128,64 +132,21 @@ export const astGrepToolRenderer = {
|
|
|
128
132
|
|
|
129
133
|
const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
|
|
130
134
|
const allLines = textContent.split("\n");
|
|
131
|
-
//
|
|
132
|
-
// reconstruct across the blank-line groups the tree list collapses by.
|
|
135
|
+
// Classify the whole output so nested directory headers resolve across blank-line groups.
|
|
133
136
|
const contexts = classifyGroupedLines(allLines, details?.cwd ?? details?.searchPath, details?.searchPath);
|
|
134
137
|
const styleCodeFrame = createCodeFrameStyler(allLines, contexts, "toolOutput", uiTheme, args?.lang);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
isRecord(rawDetails) && typeof rawDetails.searchPath === "string" ? rawDetails.searchPath : undefined;
|
|
145
|
-
const rawContexts =
|
|
146
|
-
rawContent !== undefined && rawLines?.length === allLines.length && sanitizeText(rawContent) === textContent
|
|
147
|
-
? classifyGroupedLines(rawLines, rawCwd ?? rawSearchPath, rawSearchPath)
|
|
148
|
-
: undefined;
|
|
149
|
-
// Style lazily: the collapsed tree list only renders the first few groups,
|
|
150
|
-
// so a large result never styles/hyperlinks rows nobody sees.
|
|
151
|
-
const styledLines: (string | undefined)[] = Array.from({ length: allLines.length }, () => undefined);
|
|
152
|
-
const styleLine = (index: number): string => {
|
|
153
|
-
const cached = styledLines[index];
|
|
154
|
-
if (cached !== undefined) return cached;
|
|
155
|
-
const line = allLines[index]!;
|
|
156
|
-
const ctx = contexts[index]!;
|
|
157
|
-
const rawContext = rawContexts?.[index];
|
|
158
|
-
const target =
|
|
159
|
-
ctx.headerPath && rawContext?.kind === ctx.kind && rawContext.depth === ctx.depth
|
|
160
|
-
? (rawContext.headerPath ?? ctx.headerPath)
|
|
161
|
-
: ctx.headerPath;
|
|
162
|
-
let styled: string;
|
|
163
|
-
if (ctx.kind === "dir") {
|
|
164
|
-
const accent = uiTheme.fg("accent", line);
|
|
165
|
-
styled = target ? fileHyperlink(target, accent) : accent;
|
|
166
|
-
} else if (ctx.kind === "file") {
|
|
167
|
-
const tinted = uiTheme.fg(ctx.depth === 1 ? "accent" : "dim", line);
|
|
168
|
-
styled = target ? fileHyperlink(target, tinted) : tinted;
|
|
169
|
-
} else {
|
|
170
|
-
styled = styleCodeFrame(index) ?? uiTheme.fg(line.startsWith(" meta:") ? "dim" : "toolOutput", line);
|
|
171
|
-
}
|
|
172
|
-
styledLines[index] = styled;
|
|
173
|
-
return styled;
|
|
174
|
-
};
|
|
175
|
-
const matchGroups = groupLineIndicesByBlank(allLines).filter(indices => {
|
|
138
|
+
const styleLine = createGroupedRowStyler(allLines, contexts, uiTheme, {
|
|
139
|
+
linkTarget: groupedLinkTargets(contexts, textContent, details, options.renderContext?.rawDetails),
|
|
140
|
+
styleBody: index => {
|
|
141
|
+
const line = allLines[index]!;
|
|
142
|
+
return styleCodeFrame(index) ?? uiTheme.fg(line.startsWith(" meta:") ? "dim" : "toolOutput", line);
|
|
143
|
+
},
|
|
144
|
+
linkLines: true,
|
|
145
|
+
});
|
|
146
|
+
const matchGroups = groupsByFile(groupLineIndicesByBlank(allLines), contexts).filter(indices => {
|
|
176
147
|
const first = allLines[indices[0]!]!;
|
|
177
148
|
return !first.startsWith("Result limit reached") && !first.startsWith("Parse issues:");
|
|
178
149
|
});
|
|
179
|
-
// Line offset at which each group starts. A collapsed tree list stops at the
|
|
180
|
-
// first group that overflows its line budget, so groups starting at or past
|
|
181
|
-
// the budget are never shown — hand it same-length placeholders instead of
|
|
182
|
-
// styling them (renderTreeList only needs their line counts).
|
|
183
|
-
const groupStarts: number[] = [];
|
|
184
|
-
let groupStart = 0;
|
|
185
|
-
for (const group of matchGroups) {
|
|
186
|
-
groupStarts.push(groupStart);
|
|
187
|
-
groupStart += group.length;
|
|
188
|
-
}
|
|
189
150
|
|
|
190
151
|
const extraLines: string[] = [];
|
|
191
152
|
if (limitReached) {
|
|
@@ -200,18 +161,13 @@ export const astGrepToolRenderer = {
|
|
|
200
161
|
return createCachedComponent(
|
|
201
162
|
() => options.expanded,
|
|
202
163
|
width => {
|
|
164
|
+
const { shown, summary } = fitGroupsToBudget(
|
|
165
|
+
matchGroups,
|
|
166
|
+
options.expanded ? Number.POSITIVE_INFINITY : COLLAPSED_MATCH_LIMIT,
|
|
167
|
+
{ isUnit: index => isMatchRow(allLines[index]!), unit: "match", total: matchCount },
|
|
168
|
+
);
|
|
203
169
|
const matchLines = renderTreeList(
|
|
204
|
-
{
|
|
205
|
-
items: matchGroups,
|
|
206
|
-
expanded: options.expanded,
|
|
207
|
-
maxCollapsed: matchGroups.length,
|
|
208
|
-
maxCollapsedLines: COLLAPSED_MATCH_LIMIT,
|
|
209
|
-
itemType: "match",
|
|
210
|
-
renderItem: (group, context) =>
|
|
211
|
-
!options.expanded && groupStarts[context.index]! >= COLLAPSED_MATCH_LIMIT
|
|
212
|
-
? Array.from({ length: group.length }, () => "")
|
|
213
|
-
: group.map(styleLine),
|
|
214
|
-
},
|
|
170
|
+
{ items: shown, trailingSummary: summary, renderItem: group => group.map(styleLine) },
|
|
215
171
|
uiTheme,
|
|
216
172
|
);
|
|
217
173
|
return fitHeaderedRows(header, [...matchLines, ...extraLines], width);
|