pi-zen 0.1.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/LICENSE +21 -0
- package/README.md +48 -0
- package/extensions/zen.ts +200 -0
- package/package.json +62 -0
- package/src/backgroundless-theme.ts +158 -0
- package/src/builtin-render.ts +69 -0
- package/src/call-group.ts +190 -0
- package/src/compact-tools.ts +565 -0
- package/src/display-path.ts +23 -0
- package/src/edit-diff.ts +169 -0
- package/src/editor-rail.ts +68 -0
- package/src/markdown-compaction.ts +40 -0
- package/src/silent-header.ts +15 -0
- package/src/thinking-tail.ts +51 -0
- package/src/tool-output.ts +184 -0
- package/src/tool-row.ts +124 -0
- package/src/working-indicator.ts +29 -0
- package/src/zen-editor.ts +40 -0
package/src/edit-diff.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
import type { RowPalette } from "./tool-row.ts";
|
|
4
|
+
|
|
5
|
+
/** One line of a compact diff. */
|
|
6
|
+
export type DiffLine =
|
|
7
|
+
| {
|
|
8
|
+
readonly kind: "added" | "removed" | "context";
|
|
9
|
+
/** The line's text, as it appears in the file. */
|
|
10
|
+
readonly text: string;
|
|
11
|
+
/** Line number in the file after the edit. */
|
|
12
|
+
readonly number: number;
|
|
13
|
+
}
|
|
14
|
+
| {
|
|
15
|
+
readonly kind: "omission";
|
|
16
|
+
/** How many source lines the diff is not showing here. */
|
|
17
|
+
readonly hidden: number;
|
|
18
|
+
/** Whether the omission removes only context or enforces the collapsed-view budget. */
|
|
19
|
+
readonly reason: "context" | "budget";
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Lines of unchanged context kept on each side of a change. */
|
|
23
|
+
const CONTEXT = 1;
|
|
24
|
+
|
|
25
|
+
/** Most lines a collapsed diff will show before it defers to the expanded view. */
|
|
26
|
+
export const MAX_DIFF_LINES = 8;
|
|
27
|
+
|
|
28
|
+
const HUNK = /^@@\s+-\d+(?:,\d+)?\s+\+(\d+)/;
|
|
29
|
+
const ELLIPSIS = "…";
|
|
30
|
+
const INDENT = " ";
|
|
31
|
+
|
|
32
|
+
type Entry = {
|
|
33
|
+
readonly kind: "added" | "removed" | "context";
|
|
34
|
+
readonly text: string;
|
|
35
|
+
readonly number: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function parsePatch(patch: string): Entry[] {
|
|
39
|
+
const entries: Entry[] = [];
|
|
40
|
+
let line = 0;
|
|
41
|
+
|
|
42
|
+
for (const raw of patch.split("\n")) {
|
|
43
|
+
const hunk = HUNK.exec(raw);
|
|
44
|
+
if (hunk?.[1] !== undefined) {
|
|
45
|
+
line = Number.parseInt(hunk[1], 10);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
// File headers and the no-newline marker are not part of the change.
|
|
49
|
+
if (raw.startsWith("+++") || raw.startsWith("---") || raw.startsWith("\\")) continue;
|
|
50
|
+
if (line === 0) continue;
|
|
51
|
+
|
|
52
|
+
const body = raw.slice(1);
|
|
53
|
+
if (raw.startsWith("+")) {
|
|
54
|
+
entries.push({ kind: "added", text: body, number: line });
|
|
55
|
+
line += 1;
|
|
56
|
+
} else if (raw.startsWith("-")) {
|
|
57
|
+
// A removed line is numbered where it used to sit, so the pair reads together.
|
|
58
|
+
entries.push({ kind: "removed", text: body, number: line });
|
|
59
|
+
} else if (raw.startsWith(" ")) {
|
|
60
|
+
entries.push({ kind: "context", text: body, number: line });
|
|
61
|
+
line += 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return entries;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reduce a unified patch to the lines worth reading.
|
|
70
|
+
*
|
|
71
|
+
* Every changed line is kept, with one line of context on each side of a change
|
|
72
|
+
* group. Context the patch carried but the eye does not need becomes a single
|
|
73
|
+
* omission marker, so two changes far apart in a file stay two change groups
|
|
74
|
+
* rather than one wall of unchanged code.
|
|
75
|
+
*
|
|
76
|
+
* @param patch - A standard unified patch.
|
|
77
|
+
* @returns The lines to render, in file order.
|
|
78
|
+
*/
|
|
79
|
+
export function compactDiff(patch: string): DiffLine[] {
|
|
80
|
+
const entries = parsePatch(patch);
|
|
81
|
+
const keep = entries.map((entry) => entry.kind !== "context");
|
|
82
|
+
entries.forEach((entry, index) => {
|
|
83
|
+
if (entry.kind === "context") return;
|
|
84
|
+
for (let offset = 1; offset <= CONTEXT; offset += 1) {
|
|
85
|
+
if (index - offset >= 0) keep[index - offset] = true;
|
|
86
|
+
if (index + offset < entries.length) keep[index + offset] = true;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const lines: DiffLine[] = [];
|
|
91
|
+
let hidden = 0;
|
|
92
|
+
for (const [index, entry] of entries.entries()) {
|
|
93
|
+
if (keep[index] !== true) {
|
|
94
|
+
hidden += 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (hidden > 0) {
|
|
98
|
+
lines.push({ kind: "omission", hidden, reason: "context" });
|
|
99
|
+
hidden = 0;
|
|
100
|
+
}
|
|
101
|
+
lines.push(entry);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (lines.length <= MAX_DIFF_LINES) return lines;
|
|
105
|
+
|
|
106
|
+
// Keep both ends: the opening identifies the change and the tail shows where
|
|
107
|
+
// it landed. The expanded renderer remains the complete source of truth.
|
|
108
|
+
const visibleLines = MAX_DIFF_LINES - 1;
|
|
109
|
+
const leadingCount = Math.ceil(visibleLines / 2);
|
|
110
|
+
const trailingCount = Math.floor(visibleLines / 2);
|
|
111
|
+
const leading = lines.slice(0, leadingCount);
|
|
112
|
+
const trailing = lines.slice(lines.length - trailingCount);
|
|
113
|
+
const omitted = lines.slice(leadingCount, lines.length - trailingCount);
|
|
114
|
+
const budgetHidden = omitted.reduce((total, line) => total + (line.kind === "omission" ? line.hidden : 1), 0);
|
|
115
|
+
return [...leading, { kind: "omission", hidden: budgetHidden, reason: "budget" }, ...trailing];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function markerOf(line: DiffLine): string {
|
|
119
|
+
if (line.kind === "added") return "+";
|
|
120
|
+
if (line.kind === "removed") return "-";
|
|
121
|
+
if (line.kind === "omission") return ELLIPSIS;
|
|
122
|
+
return " ";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function colorOf(line: DiffLine): "toolDiffAdded" | "toolDiffRemoved" | "toolDiffContext" {
|
|
126
|
+
if (line.kind === "added") return "toolDiffAdded";
|
|
127
|
+
if (line.kind === "removed") return "toolDiffRemoved";
|
|
128
|
+
return "toolDiffContext";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Render a compact diff under a collapsed edit row.
|
|
133
|
+
*
|
|
134
|
+
* The gutter is as wide as the largest line number, so the code column starts in
|
|
135
|
+
* the same place on every line. A line too long for the terminal is truncated
|
|
136
|
+
* rather than wrapped, because a wrapped diff stops looking like one.
|
|
137
|
+
*
|
|
138
|
+
* @param lines - The lines from `compactDiff`.
|
|
139
|
+
* @param width - Visible terminal width.
|
|
140
|
+
* @param palette - Theme slice used to color the diff.
|
|
141
|
+
* @returns One string per line, none wider than `width`.
|
|
142
|
+
*/
|
|
143
|
+
export function renderDiff(lines: readonly DiffLine[], width: number, palette: RowPalette): string[] {
|
|
144
|
+
if (lines.length === 0) return [];
|
|
145
|
+
|
|
146
|
+
let gutter = 1;
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
if (line.kind !== "omission") gutter = Math.max(gutter, String(line.number).length);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const rendered: string[] = [];
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
// The marker and the number are aligned as one token, so the digits line up
|
|
154
|
+
// however wide the numbers get and the marker leans in from the left.
|
|
155
|
+
const number = line.kind === "omission" ? "" : String(line.number);
|
|
156
|
+
const prefix = INDENT + (markerOf(line) + number).padStart(gutter + 1, " ") + " ";
|
|
157
|
+
const room = width - visibleWidth(prefix);
|
|
158
|
+
if (room <= 0) continue;
|
|
159
|
+
|
|
160
|
+
const text =
|
|
161
|
+
line.kind === "omission"
|
|
162
|
+
? line.reason === "context"
|
|
163
|
+
? `${line.hidden} unchanged`
|
|
164
|
+
: `${line.hidden} more lines`
|
|
165
|
+
: truncateToWidth(line.text, room, ELLIPSIS);
|
|
166
|
+
rendered.push(palette.fg(colorOf(line), prefix + text));
|
|
167
|
+
}
|
|
168
|
+
return rendered;
|
|
169
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
/** Glyph drawn in the editor's left padding in place of Pi's horizontal frame. */
|
|
4
|
+
export const RAIL_GLYPH = "┃";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Smallest editor padding the rail can occupy: one column for the glyph and one
|
|
8
|
+
* for the gap before the text. Pi's editor keeps its own layout maths, so the
|
|
9
|
+
* rail only ever overwrites padding columns that are already blank.
|
|
10
|
+
*/
|
|
11
|
+
export const MIN_RAIL_PADDING = 2;
|
|
12
|
+
|
|
13
|
+
/** Paints the rail glyph, normally with the editor's current border color. */
|
|
14
|
+
export type RailPaint = (glyph: string) => string;
|
|
15
|
+
|
|
16
|
+
function isFrameRow(row: string): boolean {
|
|
17
|
+
return stripTerminalSequences(row).startsWith("─");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function carriesScrollHint(row: string): boolean {
|
|
21
|
+
const plain = stripTerminalSequences(row);
|
|
22
|
+
return plain.includes("↑") || plain.includes("↓");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function railBodyRow(row: string, paddingX: number, paint: RailPaint): string {
|
|
26
|
+
const indent = " ".repeat(paddingX);
|
|
27
|
+
if (!row.startsWith(indent)) return row;
|
|
28
|
+
return paint(RAIL_GLYPH) + " ".repeat(paddingX - 1) + row.slice(paddingX);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Rewrite the rows Pi's editor rendered into the Zen presentation: the two
|
|
33
|
+
* horizontal frame rows disappear and the input gains a single left rail.
|
|
34
|
+
*
|
|
35
|
+
* Frame rows that carry a scroll hint (`─── ↑ 3 more ───`) are kept, because
|
|
36
|
+
* they report content the editor is hiding. Body rows keep their exact visible
|
|
37
|
+
* width, so cursor geometry, the hardware cursor marker, selection, and
|
|
38
|
+
* wrapping stay identical to the base editor. Rows below the bottom frame
|
|
39
|
+
* (the autocomplete list) pass through untouched.
|
|
40
|
+
*
|
|
41
|
+
* @param rows - Rows returned by the base editor's `render`.
|
|
42
|
+
* @param paddingX - The editor's current horizontal padding.
|
|
43
|
+
* @param paint - Styles the rail glyph.
|
|
44
|
+
* @returns The rows to render. Returned unchanged when the padding is too small
|
|
45
|
+
* for a rail or when the rows do not look like Pi's framed editor.
|
|
46
|
+
*/
|
|
47
|
+
export function applyRail(rows: readonly string[], paddingX: number, paint: RailPaint): string[] {
|
|
48
|
+
if (paddingX < MIN_RAIL_PADDING || rows.length < 2) return [...rows];
|
|
49
|
+
|
|
50
|
+
const frames: number[] = [];
|
|
51
|
+
for (const [index, row] of rows.entries()) {
|
|
52
|
+
if (isFrameRow(row)) frames.push(index);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const top = frames.at(0);
|
|
56
|
+
const bottom = frames.at(-1);
|
|
57
|
+
if (top !== 0 || bottom === undefined || bottom <= top) return [...rows];
|
|
58
|
+
|
|
59
|
+
const railed: string[] = [];
|
|
60
|
+
for (const [index, row] of rows.entries()) {
|
|
61
|
+
if (index === top || index === bottom) {
|
|
62
|
+
if (carriesScrollHint(row)) railed.push(row);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
railed.push(index > bottom ? row : railBodyRow(row, paddingX, paint));
|
|
66
|
+
}
|
|
67
|
+
return railed;
|
|
68
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const FENCE = /^\s{0,3}(`{3,}|~{3,})/;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Collapse runs of blank lines down to one, leaving fenced code untouched.
|
|
5
|
+
*
|
|
6
|
+
* Pi's markdown renderer adds its own spacing after headings, paragraphs, and
|
|
7
|
+
* lists, so a model that also emits double blank lines produces a transcript
|
|
8
|
+
* with holes in it. Code fences keep every blank line, because there the
|
|
9
|
+
* spacing is content.
|
|
10
|
+
*
|
|
11
|
+
* @param markdown - The markdown about to be rendered.
|
|
12
|
+
* @returns The same markdown with no run of more than one blank line.
|
|
13
|
+
*/
|
|
14
|
+
export function squeezeBlankLines(markdown: string): string {
|
|
15
|
+
const lines = markdown.split("\n");
|
|
16
|
+
const kept: string[] = [];
|
|
17
|
+
let fence: string | undefined;
|
|
18
|
+
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
const marker = FENCE.exec(line)?.[1];
|
|
21
|
+
if (fence === undefined) {
|
|
22
|
+
if (marker !== undefined) {
|
|
23
|
+
fence = marker;
|
|
24
|
+
kept.push(line);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
} else {
|
|
28
|
+
// A closing fence must be at least as long as the one that opened it.
|
|
29
|
+
if (marker !== undefined && marker[0] === fence[0] && marker.length >= fence.length) fence = undefined;
|
|
30
|
+
kept.push(line);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const blank = line.trim() === "";
|
|
35
|
+
if (blank && kept.at(-1)?.trim() === "") continue;
|
|
36
|
+
kept.push(line);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return kept.join("\n");
|
|
40
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Remove the startup header.
|
|
6
|
+
*
|
|
7
|
+
* This covers the logo and the key-hint banner. The loaded-resource listing and
|
|
8
|
+
* package-update notices are decided by the `quietStartup` setting before
|
|
9
|
+
* extensions run, so a fully silent startup still wants `quietStartup: true`.
|
|
10
|
+
*
|
|
11
|
+
* @param ctx - The session's extension context.
|
|
12
|
+
*/
|
|
13
|
+
export function installBlankHeader(ctx: ExtensionContext): void {
|
|
14
|
+
ctx.ui.setHeader(() => new Container());
|
|
15
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const FENCE = /^\s{0,3}(?:`{3,}|~{3,})/;
|
|
2
|
+
|
|
3
|
+
/** Lines retained on terminals with enough vertical room. */
|
|
4
|
+
export const DEFAULT_THINKING_TAIL_LINES = 8;
|
|
5
|
+
|
|
6
|
+
/** Lines retained when the terminal is short. */
|
|
7
|
+
export const SHORT_THINKING_TAIL_LINES = 5;
|
|
8
|
+
|
|
9
|
+
/** A terminal at or below this height gets the shorter reasoning tail. */
|
|
10
|
+
export const SHORT_TERMINAL_ROWS = 24;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Choose a reasoning-tail budget from the terminal's current height.
|
|
14
|
+
*
|
|
15
|
+
* @param terminalRows - Current terminal rows, or undefined outside a measurable TTY.
|
|
16
|
+
* @returns The number of streaming reasoning lines to retain.
|
|
17
|
+
*/
|
|
18
|
+
export function thinkingTailLineBudget(terminalRows: number | undefined): number {
|
|
19
|
+
return terminalRows !== undefined && terminalRows <= SHORT_TERMINAL_ROWS
|
|
20
|
+
? SHORT_THINKING_TAIL_LINES
|
|
21
|
+
: DEFAULT_THINKING_TAIL_LINES;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Keep only the end of a reasoning block while it is still streaming.
|
|
26
|
+
*
|
|
27
|
+
* Reasoning is worth reading, but a block that grows without limit pushes the
|
|
28
|
+
* rest of the turn off the screen and scrolls while you read it. Holding it to a
|
|
29
|
+
* fixed number of lines keeps the transcript still: the block stays the same
|
|
30
|
+
* height and the newest sentence stays where your eye already is. Once the block
|
|
31
|
+
* settles it is shown whole, because by then it is history rather than motion.
|
|
32
|
+
*
|
|
33
|
+
* @param markdown - The reasoning text so far.
|
|
34
|
+
* @param maxLines - How many lines to keep.
|
|
35
|
+
* @returns The last `maxLines` lines, marked as a tail when anything was cut.
|
|
36
|
+
*/
|
|
37
|
+
export function thinkingTail(markdown: string, maxLines: number): string {
|
|
38
|
+
if (maxLines <= 0) return "";
|
|
39
|
+
|
|
40
|
+
const lines = markdown.split("\n");
|
|
41
|
+
if (lines.length <= maxLines) return markdown;
|
|
42
|
+
|
|
43
|
+
const tail = lines.slice(lines.length - maxLines);
|
|
44
|
+
// Cutting into a code fence would leave the renderer with an unclosed one, so
|
|
45
|
+
// an odd number of fences in the tail gets an opener of its own.
|
|
46
|
+
let fences = 0;
|
|
47
|
+
for (const line of tail) if (FENCE.test(line)) fences += 1;
|
|
48
|
+
if (fences % 2 === 1) tail.unshift("```");
|
|
49
|
+
|
|
50
|
+
return ["…", ...tail].join("\n");
|
|
51
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/** Added and removed line counts for an edit. */
|
|
2
|
+
export type EditChangeCount = {
|
|
3
|
+
/** Lines the edit added. */
|
|
4
|
+
readonly added: number;
|
|
5
|
+
/** Lines the edit removed. */
|
|
6
|
+
readonly removed: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Count the lines a unified patch adds and removes.
|
|
11
|
+
*
|
|
12
|
+
* @param patch - The `patch` field of an edit result.
|
|
13
|
+
* @returns Added and removed line counts, both zero for an empty patch.
|
|
14
|
+
*/
|
|
15
|
+
export function countPatchChanges(patch: string): EditChangeCount {
|
|
16
|
+
let added = 0;
|
|
17
|
+
let removed = 0;
|
|
18
|
+
|
|
19
|
+
for (const line of patch.split("\n")) {
|
|
20
|
+
if (line.startsWith("+++") || line.startsWith("---")) continue;
|
|
21
|
+
if (line.startsWith("+")) added++;
|
|
22
|
+
else if (line.startsWith("-")) removed++;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { added, removed };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Count the hunks in a unified patch.
|
|
30
|
+
*
|
|
31
|
+
* @param patch - The `patch` field of an edit result.
|
|
32
|
+
* @returns The number of hunk headers in the patch.
|
|
33
|
+
*/
|
|
34
|
+
export function countPatchHunks(patch: string): number {
|
|
35
|
+
let hunks = 0;
|
|
36
|
+
for (const line of patch.split("\n")) if (line.startsWith("@@ ")) hunks += 1;
|
|
37
|
+
return hunks;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Render an edit's line counts the way diffs read.
|
|
42
|
+
*
|
|
43
|
+
* @param change - Added and removed line counts.
|
|
44
|
+
* @returns A string such as `+8 −3`, or undefined when nothing changed.
|
|
45
|
+
*/
|
|
46
|
+
export function formatEditChange(change: EditChangeCount): string | undefined {
|
|
47
|
+
if (change.added === 0 && change.removed === 0) return undefined;
|
|
48
|
+
return `+${change.added} −${change.removed}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Count the matches in grep output.
|
|
53
|
+
*
|
|
54
|
+
* Ripgrep prints `path:line:text` per match, so lines carrying a line number
|
|
55
|
+
* are matches and context lines are not.
|
|
56
|
+
*
|
|
57
|
+
* @param output - Text the grep tool returned.
|
|
58
|
+
* @returns The number of matching lines.
|
|
59
|
+
*/
|
|
60
|
+
export function countMatchLines(output: string): number {
|
|
61
|
+
let matches = 0;
|
|
62
|
+
for (const line of output.split("\n")) {
|
|
63
|
+
if (/:\d+:/.test(line)) matches++;
|
|
64
|
+
}
|
|
65
|
+
return matches;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Count the non-empty lines of output, for tools that print one result per line.
|
|
70
|
+
*
|
|
71
|
+
* @param output - Text the tool returned.
|
|
72
|
+
* @returns The number of non-empty lines.
|
|
73
|
+
*/
|
|
74
|
+
export function countResultLines(output: string): number {
|
|
75
|
+
let lines = 0;
|
|
76
|
+
for (const line of output.split("\n")) {
|
|
77
|
+
if (line.trim() !== "") lines++;
|
|
78
|
+
}
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Node appends the syscall and path to errno messages; the row already names the path. */
|
|
83
|
+
const ERRNO_PATH_CLAUSE =
|
|
84
|
+
/,\s*(?:access|open|stat|lstat|scandir|read|write|unlink|mkdir|rmdir|copyfile|rename)\s+'[^']*'\s*$/;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Pick the line that tells the user what went wrong.
|
|
88
|
+
*
|
|
89
|
+
* A node errno message repeats the path the row already shows, so that clause is
|
|
90
|
+
* dropped to leave room for the part that explains the failure.
|
|
91
|
+
*
|
|
92
|
+
* @param output - Text the failed tool returned.
|
|
93
|
+
* @returns The first non-empty line, or undefined when the output is blank.
|
|
94
|
+
*/
|
|
95
|
+
export function firstActionableLine(output: string): string | undefined {
|
|
96
|
+
for (const line of output.split("\n")) {
|
|
97
|
+
const trimmed = line.trim();
|
|
98
|
+
if (trimmed !== "") return trimmed.replace(ERRNO_PATH_CLAUSE, "");
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const BASH_STATUS = /^Command (?:exited with code (\d+)|aborted|timed out after (\S+) seconds)$/;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Summarise why a bash call failed.
|
|
107
|
+
*
|
|
108
|
+
* Pi appends its own status sentence to the command output — an exit code, an
|
|
109
|
+
* abort, or a timeout — so the status is the last line, and the first line of
|
|
110
|
+
* output is what explains it.
|
|
111
|
+
*
|
|
112
|
+
* @param output - Text the failed bash call returned.
|
|
113
|
+
* @returns A short reason such as `exit 1 · error TS2345: ...`, or undefined
|
|
114
|
+
* when the call produced nothing at all.
|
|
115
|
+
*/
|
|
116
|
+
export function bashFailureSummary(output: string): string | undefined {
|
|
117
|
+
const lines = output
|
|
118
|
+
.split("\n")
|
|
119
|
+
.map((line) => line.trim())
|
|
120
|
+
.filter((line) => line !== "");
|
|
121
|
+
const last = lines.at(-1);
|
|
122
|
+
const status = last === undefined ? null : BASH_STATUS.exec(last);
|
|
123
|
+
if (status === null) return lines.at(0);
|
|
124
|
+
|
|
125
|
+
const code = status[1];
|
|
126
|
+
const timeout = status[2];
|
|
127
|
+
const label = code !== undefined ? `exit ${code}` : timeout !== undefined ? `timeout ${timeout}s` : "aborted";
|
|
128
|
+
const first = lines.at(0);
|
|
129
|
+
if (first === undefined || first === last || first === "(no output)") return label;
|
|
130
|
+
return `${label} · ${first}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Reduce a shell command to its first line, so a heredoc or a multi-line
|
|
135
|
+
* pipeline still occupies one transcript row.
|
|
136
|
+
*
|
|
137
|
+
* @param command - The command that was run.
|
|
138
|
+
* @returns The first non-empty line, with a marker when more lines follow.
|
|
139
|
+
*/
|
|
140
|
+
export function commandHead(command: string): string {
|
|
141
|
+
const lines = command.split("\n").filter((line) => line.trim() !== "");
|
|
142
|
+
const head = lines.at(0)?.trim() ?? "";
|
|
143
|
+
return lines.length > 1 ? `${head} …` : head;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Format an elapsed duration for a tool row.
|
|
148
|
+
*
|
|
149
|
+
* @param elapsedMs - Milliseconds the tool ran.
|
|
150
|
+
* @returns A short duration such as `240ms`, `2.1s`, or `3m04s`.
|
|
151
|
+
*/
|
|
152
|
+
export function formatDuration(elapsedMs: number): string {
|
|
153
|
+
if (elapsedMs < 1_000) return `${Math.max(0, Math.round(elapsedMs))}ms`;
|
|
154
|
+
if (elapsedMs < 60_000) return `${(elapsedMs / 1_000).toFixed(1)}s`;
|
|
155
|
+
const minutes = Math.floor(elapsedMs / 60_000);
|
|
156
|
+
const seconds = Math.round((elapsedMs % 60_000) / 1_000);
|
|
157
|
+
return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether a tool returned a picture.
|
|
162
|
+
*
|
|
163
|
+
* A collapsed row cannot show an image, so it says one is there and leaves the
|
|
164
|
+
* showing to the expanded view.
|
|
165
|
+
*
|
|
166
|
+
* @param content - Content blocks from the tool result.
|
|
167
|
+
* @returns True when any block is an image.
|
|
168
|
+
*/
|
|
169
|
+
export function hasImage(content: readonly { readonly type: string }[]): boolean {
|
|
170
|
+
return content.some((block) => block.type === "image");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Pull the text a tool returned out of its content blocks.
|
|
175
|
+
*
|
|
176
|
+
* @param content - Content blocks from the tool result.
|
|
177
|
+
* @returns The concatenated text, ignoring image blocks.
|
|
178
|
+
*/
|
|
179
|
+
export function textOf(content: readonly { readonly type: string; readonly text?: string }[]): string {
|
|
180
|
+
return content
|
|
181
|
+
.filter((block) => block.type === "text")
|
|
182
|
+
.map((block) => block.text ?? "")
|
|
183
|
+
.join("");
|
|
184
|
+
}
|
package/src/tool-row.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
/** The slice of Pi's theme a tool row needs to paint itself. */
|
|
5
|
+
export type RowPalette = {
|
|
6
|
+
readonly fg: (color: ThemeColor, text: string) => string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Where a tool call has got to. */
|
|
10
|
+
export type RowOutcome =
|
|
11
|
+
| { readonly kind: "running" }
|
|
12
|
+
| { readonly kind: "settled" }
|
|
13
|
+
| { readonly kind: "failed"; readonly reason: string | undefined };
|
|
14
|
+
|
|
15
|
+
/** The thing a tool acted on. */
|
|
16
|
+
export type RowSubject = {
|
|
17
|
+
/** Path, command, or pattern. */
|
|
18
|
+
readonly text: string;
|
|
19
|
+
/** Which end survives truncation: paths keep their end, commands their start. */
|
|
20
|
+
readonly keep: "start" | "end";
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Short outcome detail such as `+8 −3` or `2.1s`. */
|
|
24
|
+
export type RowDetail = {
|
|
25
|
+
/** The detail text. */
|
|
26
|
+
readonly text: string;
|
|
27
|
+
/** `attention` for detail the user may need to act on, such as truncation. */
|
|
28
|
+
readonly emphasis: "quiet" | "attention";
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** One collapsed tool row. */
|
|
32
|
+
export type ToolRow = {
|
|
33
|
+
/** Present-tense action, lower case, at most five columns. */
|
|
34
|
+
readonly verb: string;
|
|
35
|
+
/** What the tool acted on. */
|
|
36
|
+
readonly subject: RowSubject;
|
|
37
|
+
/** Outcome detail, when it adds something. */
|
|
38
|
+
readonly detail: RowDetail | undefined;
|
|
39
|
+
/** Where the call has got to. */
|
|
40
|
+
readonly outcome: RowOutcome;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Marker for a pending or settled row. */
|
|
44
|
+
export const ROW_MARKER = "-";
|
|
45
|
+
|
|
46
|
+
/** Marker for a row whose tool failed. */
|
|
47
|
+
export const FAILED_MARKER = "✗";
|
|
48
|
+
|
|
49
|
+
const VERB_WIDTH = 5;
|
|
50
|
+
/** Between the verb column and the subject. */
|
|
51
|
+
const GAP = " ";
|
|
52
|
+
/** Before an outcome detail, which needs more separation than the subject. */
|
|
53
|
+
const DETAIL_GAP = " ";
|
|
54
|
+
const ELLIPSIS = "…";
|
|
55
|
+
const MIN_SUBJECT_WIDTH = 12;
|
|
56
|
+
const MIN_DETAIL_WIDTH = 10;
|
|
57
|
+
|
|
58
|
+
function elide(subject: RowSubject, max: number): string {
|
|
59
|
+
if (max <= 0) return "";
|
|
60
|
+
if (visibleWidth(subject.text) <= max) return subject.text;
|
|
61
|
+
if (subject.keep === "start") return truncateToWidth(subject.text, max, ELLIPSIS);
|
|
62
|
+
|
|
63
|
+
const kept = max - visibleWidth(ELLIPSIS);
|
|
64
|
+
if (kept <= 0) return ELLIPSIS.slice(0, max);
|
|
65
|
+
return ELLIPSIS + subject.text.slice(subject.text.length - kept);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function markerColor(outcome: RowOutcome): ThemeColor {
|
|
69
|
+
return outcome.kind === "failed" ? "error" : "dim";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function detailFor(row: ToolRow): { readonly text: string; readonly color: ThemeColor } | undefined {
|
|
73
|
+
if (row.outcome.kind === "failed") {
|
|
74
|
+
const reason = row.outcome.reason;
|
|
75
|
+
return reason === undefined ? undefined : { text: reason, color: "error" };
|
|
76
|
+
}
|
|
77
|
+
if (row.detail === undefined) return undefined;
|
|
78
|
+
return { text: row.detail.text, color: row.detail.emphasis === "attention" ? "warning" : "dim" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Format one collapsed tool row.
|
|
83
|
+
*
|
|
84
|
+
* The verb column is fixed so consecutive rows align. As the row narrows the
|
|
85
|
+
* subject shrinks first, then the detail is truncated, and a detail left with no
|
|
86
|
+
* useful room is dropped so the subject stays readable.
|
|
87
|
+
*
|
|
88
|
+
* @param row - The row to format.
|
|
89
|
+
* @param width - Visible terminal width.
|
|
90
|
+
* @param palette - Theme slice used to color the row.
|
|
91
|
+
* @returns One line, never wider than `width`.
|
|
92
|
+
*/
|
|
93
|
+
export function formatToolRow(row: ToolRow, width: number, palette: RowPalette): string {
|
|
94
|
+
if (width <= 0) return "";
|
|
95
|
+
|
|
96
|
+
const marker = row.outcome.kind === "failed" ? FAILED_MARKER : ROW_MARKER;
|
|
97
|
+
const verb = row.verb.padEnd(VERB_WIDTH, " ");
|
|
98
|
+
const prefixWidth = visibleWidth(marker) + 1 + visibleWidth(verb) + GAP.length;
|
|
99
|
+
const detail = detailFor(row);
|
|
100
|
+
|
|
101
|
+
let subjectBudget = width - prefixWidth;
|
|
102
|
+
let detailText: string | undefined;
|
|
103
|
+
if (detail !== undefined) {
|
|
104
|
+
// The detail may claim what is left once the subject keeps its floor. A
|
|
105
|
+
// truncated failure reason still says what went wrong, so it is worth a
|
|
106
|
+
// shorter subject; a detail with no room at all is dropped instead.
|
|
107
|
+
const wanted = visibleWidth(detail.text);
|
|
108
|
+
const subjectNeed = Math.min(visibleWidth(row.subject.text), MIN_SUBJECT_WIDTH);
|
|
109
|
+
const available = Math.max(0, subjectBudget - DETAIL_GAP.length - subjectNeed);
|
|
110
|
+
const granted = Math.min(wanted, available);
|
|
111
|
+
if (granted === wanted || granted >= MIN_DETAIL_WIDTH) {
|
|
112
|
+
detailText = truncateToWidth(detail.text, granted, ELLIPSIS);
|
|
113
|
+
subjectBudget -= visibleWidth(detailText) + DETAIL_GAP.length;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const subject = elide(row.subject, subjectBudget);
|
|
118
|
+
let line = palette.fg(markerColor(row.outcome), marker) + " " + palette.fg("text", verb) + GAP;
|
|
119
|
+
line += palette.fg(row.outcome.kind === "running" ? "dim" : "muted", subject);
|
|
120
|
+
if (detailText !== undefined && detail !== undefined) {
|
|
121
|
+
line += DETAIL_GAP + palette.fg(detail.color, detailText);
|
|
122
|
+
}
|
|
123
|
+
return line;
|
|
124
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Frame interval that gives the four-frame pulse roughly one calm second per cycle. */
|
|
4
|
+
export const QUIET_INTERVAL_MS = 240;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Replace Pi's spinner with a single dot that swells and fades.
|
|
8
|
+
*
|
|
9
|
+
* Frames are rendered verbatim by Pi, so they are colored here from the theme
|
|
10
|
+
* that is active when the indicator is installed.
|
|
11
|
+
*
|
|
12
|
+
* @param ui - The session's UI context.
|
|
13
|
+
*/
|
|
14
|
+
export function installQuietIndicator(ui: ExtensionUIContext): void {
|
|
15
|
+
const theme = ui.theme;
|
|
16
|
+
ui.setWorkingIndicator({
|
|
17
|
+
frames: [theme.fg("dim", "·"), theme.fg("dim", "•"), theme.fg("muted", "●"), theme.fg("dim", "•")],
|
|
18
|
+
intervalMs: QUIET_INTERVAL_MS,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Restore Pi's default spinner.
|
|
24
|
+
*
|
|
25
|
+
* @param ui - The session's UI context.
|
|
26
|
+
*/
|
|
27
|
+
export function restoreDefaultIndicator(ui: ExtensionUIContext): void {
|
|
28
|
+
ui.setWorkingIndicator();
|
|
29
|
+
}
|