@adeildo/pi-look 5.9.2 → 5.10.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/README.md +22 -8
- package/assets/preview.png +0 -0
- package/package.json +2 -2
- package/src/data/git.ts +12 -9
- package/src/data/github.ts +141 -0
- package/src/data/runner.ts +25 -0
- package/src/data/snapshot.ts +6 -3
- package/src/data/start-notes.ts +108 -0
- package/src/data/telemetry.ts +6 -1
- package/src/data/totals.ts +12 -11
- package/src/data/visits.ts +38 -0
- package/src/index.ts +147 -71
- package/src/render/fit.ts +2 -1
- package/src/render/format.ts +9 -12
- package/src/render/glyphs.ts +14 -0
- package/src/render/segments.ts +30 -31
- package/src/render/streaming.ts +217 -0
- package/src/render/tools.ts +76 -12
- package/src/settings.ts +61 -210
- package/src/ui/editor.ts +8 -2
- package/src/ui/footer.ts +64 -5
- package/src/ui/header.ts +175 -71
- package/src/ui/outputs.ts +173 -0
- package/src/ui/pi-entries.ts +185 -0
- package/src/ui/previews.ts +76 -0
- package/src/ui/queue.ts +75 -0
- package/src/ui/screen.ts +8 -0
- package/src/ui/tool-frame.ts +118 -39
package/src/ui/editor.ts
CHANGED
|
@@ -162,7 +162,10 @@ export class LookEditor extends CustomEditor {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
|
|
165
|
+
// Every line here is drawn to the width already, and measuring is cheaper than cutting.
|
|
166
|
+
return result.map((line) =>
|
|
167
|
+
visibleWidth(line) > width ? truncateToWidth(line, width, "") : line,
|
|
168
|
+
);
|
|
166
169
|
}
|
|
167
170
|
|
|
168
171
|
#lead(budget: number): string | undefined {
|
|
@@ -186,8 +189,11 @@ function withoutSoftwareCursor(line: string): string {
|
|
|
186
189
|
}
|
|
187
190
|
|
|
188
191
|
function fill(line: string, width: number): string {
|
|
192
|
+
const cells = visibleWidth(line);
|
|
193
|
+
// Measuring is cheap, and cutting a line that already fits is not.
|
|
194
|
+
if (cells <= width) return line + " ".repeat(width - cells);
|
|
189
195
|
const clipped = truncateToWidth(line, width, "");
|
|
190
|
-
return
|
|
196
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
191
197
|
}
|
|
192
198
|
|
|
193
199
|
export function applyCursor(tui: TUI, style: CursorStyle): void {
|
package/src/ui/footer.ts
CHANGED
|
@@ -8,6 +8,45 @@ import { type Screen, type SlotName, slotPieces } from "./screen.ts";
|
|
|
8
8
|
const GAP = 3;
|
|
9
9
|
/** Room reserved for the strip, so its changing width never moves the right side between lines. */
|
|
10
10
|
const STRIP_ROOM = 56;
|
|
11
|
+
/**
|
|
12
|
+
* How long the footer and the strip are drawn again without a reason. What they show moves with the
|
|
13
|
+
* clock, the live tokens and the context, and none of that reads differently 100 ms later, while a
|
|
14
|
+
* keystroke always redraws both. Anything that asks for a redraw drops the drawing anyway.
|
|
15
|
+
*/
|
|
16
|
+
const REDRAW_MS = 100;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A drawing kept per width and per revision of the screen for a moment. The transcript draws every
|
|
20
|
+
* entry on every keystroke, and the footer and the strip were built again each time.
|
|
21
|
+
*/
|
|
22
|
+
class KeptDrawing {
|
|
23
|
+
#drawn: { width: number; revision: number; at: number; lines: string[] } | undefined;
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly screen: Screen,
|
|
27
|
+
private readonly draw: (width: number) => string[],
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
render(width: number): string[] {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const revision = this.screen.revision();
|
|
33
|
+
const drawn = this.#drawn;
|
|
34
|
+
if (
|
|
35
|
+
drawn !== undefined &&
|
|
36
|
+
drawn.width === width &&
|
|
37
|
+
drawn.revision === revision &&
|
|
38
|
+
now - drawn.at < REDRAW_MS
|
|
39
|
+
)
|
|
40
|
+
return drawn.lines;
|
|
41
|
+
const lines = this.draw(width);
|
|
42
|
+
this.#drawn = { width, revision, at: now, lines };
|
|
43
|
+
return lines;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
invalidate(): void {
|
|
47
|
+
this.#drawn = undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
11
50
|
|
|
12
51
|
export function indentOf(screen: Screen): number {
|
|
13
52
|
const style = screen.frameStyle();
|
|
@@ -16,12 +55,22 @@ export function indentOf(screen: Screen): number {
|
|
|
16
55
|
|
|
17
56
|
export class StripComponent implements Component {
|
|
18
57
|
readonly #screen: Screen;
|
|
58
|
+
readonly #kept: KeptDrawing;
|
|
19
59
|
|
|
20
60
|
constructor(screen: Screen) {
|
|
21
61
|
this.#screen = screen;
|
|
62
|
+
this.#kept = new KeptDrawing(screen, (width) => this.#draw(width));
|
|
22
63
|
}
|
|
23
64
|
|
|
24
65
|
render(width: number): string[] {
|
|
66
|
+
return this.#kept.render(width);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
invalidate(): void {
|
|
70
|
+
this.#kept.invalidate();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#draw(width: number): string[] {
|
|
25
74
|
const screen = this.#screen;
|
|
26
75
|
const indent = indentOf(screen);
|
|
27
76
|
const room = width - indent * 2;
|
|
@@ -40,8 +89,6 @@ export class StripComponent implements Component {
|
|
|
40
89
|
}
|
|
41
90
|
return [pad(renderSlot(screen, "strip", room)), pad(renderSlot(screen, "stripRight", room))];
|
|
42
91
|
}
|
|
43
|
-
|
|
44
|
-
invalidate(): void {}
|
|
45
92
|
}
|
|
46
93
|
|
|
47
94
|
export interface FooterHooks {
|
|
@@ -53,15 +100,29 @@ export class FooterComponent implements Component {
|
|
|
53
100
|
readonly #screen: Screen;
|
|
54
101
|
readonly #hooks: FooterHooks;
|
|
55
102
|
readonly #stopWatching: () => void;
|
|
103
|
+
readonly #kept: KeptDrawing;
|
|
56
104
|
|
|
57
105
|
constructor(tui: TUI, data: ReadonlyFooterDataProvider, screen: Screen, hooks: FooterHooks) {
|
|
58
106
|
this.#screen = screen;
|
|
59
107
|
this.#hooks = hooks;
|
|
108
|
+
this.#kept = new KeptDrawing(screen, (width) => this.#draw(width));
|
|
60
109
|
hooks.attach(data, () => tui.requestRender());
|
|
61
|
-
this.#stopWatching = data.onBranchChange(() =>
|
|
110
|
+
this.#stopWatching = data.onBranchChange(() => {
|
|
111
|
+
// A new branch is drawn now, not on the next keystroke.
|
|
112
|
+
screen.bump();
|
|
113
|
+
tui.requestRender();
|
|
114
|
+
});
|
|
62
115
|
}
|
|
63
116
|
|
|
64
117
|
render(width: number): string[] {
|
|
118
|
+
return this.#kept.render(width);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
invalidate(): void {
|
|
122
|
+
this.#kept.invalidate();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#draw(width: number): string[] {
|
|
65
126
|
const screen = this.#screen;
|
|
66
127
|
const indent = indentOf(screen);
|
|
67
128
|
const room = width - indent * 2;
|
|
@@ -78,8 +139,6 @@ export class FooterComponent implements Component {
|
|
|
78
139
|
return kept.length === 0 ? [""] : kept;
|
|
79
140
|
}
|
|
80
141
|
|
|
81
|
-
invalidate(): void {}
|
|
82
|
-
|
|
83
142
|
dispose(): void {
|
|
84
143
|
this.#stopWatching();
|
|
85
144
|
this.#hooks.detach();
|
package/src/ui/header.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { CUT, GLYPH } from "@adeildo/pi-kit";
|
|
1
4
|
import { keyText } from "@earendil-works/pi-coding-agent";
|
|
2
5
|
import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
6
|
|
|
7
|
+
import type { GithubState } from "../data/github.ts";
|
|
8
|
+
import { type LastVisit, since, type StartNote, startNotes } from "../data/start-notes.ts";
|
|
4
9
|
import { BOXES, border } from "../render/frame.ts";
|
|
5
10
|
import { EFFORT_LEVELS, type Paint } from "../render/paint.ts";
|
|
6
11
|
import { renderSegments, type SegmentId } from "../render/segments.ts";
|
|
@@ -35,6 +40,10 @@ export interface LoadedCounts {
|
|
|
35
40
|
export interface HeaderSource {
|
|
36
41
|
style(): HeaderStyle;
|
|
37
42
|
counts(): LoadedCounts;
|
|
43
|
+
/** The last session in this folder, undefined for the first one. */
|
|
44
|
+
lastVisit(): LastVisit | undefined;
|
|
45
|
+
/** What GitHub answered for this folder, undefined while it is still being asked. */
|
|
46
|
+
github?(): GithubState | undefined;
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
interface Hint {
|
|
@@ -42,80 +51,195 @@ interface Hint {
|
|
|
42
51
|
does: string;
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
/** The π rises from its feet once per launch, one row of the art every step. */
|
|
55
|
+
const RISE_STEP_MS = 70;
|
|
56
|
+
const NOTES = 6;
|
|
57
|
+
/** How long a drawing of the card stands before it is drawn again. */
|
|
58
|
+
const REDRAW_MS = 1000;
|
|
59
|
+
/** The harness's version, read once from the package that ships it. */
|
|
60
|
+
export const HARNESS_VERSION = harnessVersion();
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The start screen: the π, the folder and the model, and under them only what changed or waits on
|
|
64
|
+
* you since the last session here. See DESIGN.md.
|
|
65
|
+
*/
|
|
45
66
|
export class HeaderComponent implements Component {
|
|
46
67
|
readonly #screen: Screen;
|
|
47
68
|
readonly #source: HeaderSource;
|
|
69
|
+
/** When the π started rising. Without a terminal to redraw it, it stands from the start. */
|
|
70
|
+
readonly #born: number;
|
|
48
71
|
|
|
49
|
-
constructor(screen: Screen, source: HeaderSource) {
|
|
72
|
+
constructor(screen: Screen, source: HeaderSource, tui?: { requestRender(): void }) {
|
|
50
73
|
this.#screen = screen;
|
|
51
74
|
this.#source = source;
|
|
75
|
+
this.#born = tui === undefined ? 0 : Date.now();
|
|
76
|
+
if (tui === undefined) return;
|
|
77
|
+
for (let row = 1; row <= LOGO.length; row++)
|
|
78
|
+
setTimeout(() => tui.requestRender(), row * RISE_STEP_MS).unref?.();
|
|
52
79
|
}
|
|
53
80
|
|
|
81
|
+
/** The last drawing, kept for a moment: the card changes rarely and pi redraws on every keystroke. */
|
|
82
|
+
#drawn:
|
|
83
|
+
| { width: number; at: number; github: GithubState | undefined; lines: string[] }
|
|
84
|
+
| undefined;
|
|
85
|
+
|
|
54
86
|
render(width: number): string[] {
|
|
55
|
-
|
|
56
|
-
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const github = this.#source.github?.();
|
|
89
|
+
const risen = now - this.#born > LOGO.length * RISE_STEP_MS;
|
|
90
|
+
const drawn = this.#drawn;
|
|
91
|
+
// GitHub answers after the card is up, and that answer must not wait for the next keystroke.
|
|
92
|
+
if (risen && drawn?.width === width && drawn.github === github && now - drawn.at < REDRAW_MS)
|
|
93
|
+
return drawn.lines;
|
|
94
|
+
const notes = startNotes(
|
|
95
|
+
this.#screen.snapshot(),
|
|
96
|
+
HARNESS_VERSION,
|
|
97
|
+
this.#source.lastVisit(),
|
|
98
|
+
github,
|
|
99
|
+
);
|
|
100
|
+
const lines =
|
|
101
|
+
width < MIN_CARD || this.#source.style() === "compact"
|
|
102
|
+
? this.#compact(width, notes)
|
|
103
|
+
: this.#card(width, notes);
|
|
104
|
+
this.#drawn = { width, at: now, github, lines };
|
|
105
|
+
return lines;
|
|
57
106
|
}
|
|
58
107
|
|
|
59
|
-
invalidate(): void {
|
|
108
|
+
invalidate(): void {
|
|
109
|
+
this.#drawn = undefined;
|
|
110
|
+
}
|
|
60
111
|
|
|
61
|
-
#card(width: number): string[] {
|
|
112
|
+
#card(width: number, notes: readonly StartNote[]): string[] {
|
|
62
113
|
const screen = this.#screen;
|
|
63
114
|
const paint = screen.paint();
|
|
64
115
|
const box = BOXES.rounded;
|
|
65
116
|
const frame = paint.frame;
|
|
66
|
-
const title = this.#title(paint);
|
|
67
117
|
const inner = width - 4;
|
|
68
|
-
const withLogo = inner - LOGO_COLUMN -
|
|
69
|
-
const rightWidth = withLogo ? inner - LOGO_COLUMN -
|
|
118
|
+
const withLogo = inner - LOGO_COLUMN - 2 >= MIN_RIGHT;
|
|
119
|
+
const rightWidth = withLogo ? inner - LOGO_COLUMN - 2 : inner;
|
|
120
|
+
const snapshot = screen.snapshot();
|
|
121
|
+
const last = this.#source.lastVisit();
|
|
70
122
|
|
|
71
123
|
const right = [
|
|
72
|
-
|
|
124
|
+
"",
|
|
73
125
|
this.#segments(["path", "branch"], paint),
|
|
74
|
-
this.#
|
|
75
|
-
paint.dim("─".repeat(Math.max(0, rightWidth))),
|
|
76
|
-
...this.#hints(paint, rightWidth),
|
|
77
|
-
];
|
|
78
|
-
const left = [
|
|
79
|
-
...LOGO.map((row, index) => paint.effort(LOGO_RAMP[index] ?? "max", row)),
|
|
126
|
+
this.#segments(["model", "effort"], paint),
|
|
80
127
|
"",
|
|
81
|
-
|
|
128
|
+
...this.#notes(notes, paint, last, snapshot.now),
|
|
82
129
|
];
|
|
130
|
+
const left = ["", ...this.#logo(paint), ""];
|
|
83
131
|
|
|
84
132
|
const rows = Math.max(left.length, right.length);
|
|
85
133
|
const lines = [
|
|
86
|
-
border(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
paint:
|
|
90
|
-
separator: " ",
|
|
91
|
-
|
|
134
|
+
border(
|
|
135
|
+
width,
|
|
136
|
+
[{ text: this.#title(paint), priority: 1 }],
|
|
137
|
+
[{ text: paint.dim(`pi ${snapshot.version}`), priority: 2 }],
|
|
138
|
+
{ box, edge: "top", paint: frame, separator: " " },
|
|
139
|
+
),
|
|
92
140
|
];
|
|
93
141
|
for (let row = 0; row < rows; row++) {
|
|
94
142
|
const content = withLogo
|
|
95
|
-
? `${center(left[row] ?? "", LOGO_COLUMN)}
|
|
143
|
+
? `${center(left[row] ?? "", LOGO_COLUMN)} ${pad(right[row] ?? "", rightWidth)}`
|
|
96
144
|
: pad(right[row] ?? "", rightWidth);
|
|
97
145
|
lines.push(`${frame(box.vertical)} ${content} ${frame(box.vertical)}`);
|
|
98
146
|
}
|
|
99
|
-
lines.push(
|
|
147
|
+
lines.push(
|
|
148
|
+
border(
|
|
149
|
+
width,
|
|
150
|
+
[
|
|
151
|
+
{ text: this.#closing(notes, paint, last, snapshot.now), priority: 1 },
|
|
152
|
+
...this.#hintPieces(paint),
|
|
153
|
+
],
|
|
154
|
+
[],
|
|
155
|
+
{
|
|
156
|
+
box,
|
|
157
|
+
edge: "bottom",
|
|
158
|
+
paint: frame,
|
|
159
|
+
separator: " ",
|
|
160
|
+
},
|
|
161
|
+
),
|
|
162
|
+
);
|
|
100
163
|
lines.push("");
|
|
101
|
-
|
|
164
|
+
// Every line is drawn to the width already, and measuring is cheaper than cutting.
|
|
165
|
+
return lines.map((line) =>
|
|
166
|
+
visibleWidth(line) > width ? truncateToWidth(line, width, "") : line,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The art, its rows lit from the feet up while it rises. */
|
|
171
|
+
#logo(paint: Paint): string[] {
|
|
172
|
+
const risen = Math.floor((Date.now() - this.#born) / RISE_STEP_MS) + 1;
|
|
173
|
+
return LOGO.map((row, index) =>
|
|
174
|
+
LOGO.length - index > risen
|
|
175
|
+
? " ".repeat(row.length)
|
|
176
|
+
: paint.effort(LOGO_RAMP[index] ?? "max", row),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#notes(
|
|
181
|
+
notes: readonly StartNote[],
|
|
182
|
+
paint: Paint,
|
|
183
|
+
last: LastVisit | undefined,
|
|
184
|
+
now: number,
|
|
185
|
+
): string[] {
|
|
186
|
+
if (notes.length === 0)
|
|
187
|
+
return [
|
|
188
|
+
paint.muted(
|
|
189
|
+
last === undefined
|
|
190
|
+
? "First session in this folder."
|
|
191
|
+
: `Nothing new since ${since(last.at, now)}.`,
|
|
192
|
+
),
|
|
193
|
+
];
|
|
194
|
+
const shown = notes.slice(0, NOTES).map((note) => {
|
|
195
|
+
const detail = note.detail === undefined ? "" : ` ${paint.dim(note.detail)}`;
|
|
196
|
+
return `${paint.raw(note.color, note.mark)} ${paint.text(note.text)}${detail}`;
|
|
197
|
+
});
|
|
198
|
+
if (notes.length > NOTES) shown.push(paint.dim(`and ${notes.length - NOTES} more`));
|
|
199
|
+
return shown;
|
|
102
200
|
}
|
|
103
201
|
|
|
104
|
-
#
|
|
202
|
+
#closing(
|
|
203
|
+
notes: readonly StartNote[],
|
|
204
|
+
paint: Paint,
|
|
205
|
+
last: LastVisit | undefined,
|
|
206
|
+
now: number,
|
|
207
|
+
): string {
|
|
208
|
+
if (notes.length === 0) return paint.italic(paint.dim("ready when you are"));
|
|
209
|
+
const things = `${notes.length} thing${notes.length === 1 ? "" : "s"}`;
|
|
210
|
+
return paint.raw(
|
|
211
|
+
"warning",
|
|
212
|
+
last === undefined ? things : `${things} since ${since(last.at, now)}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#hintPieces(paint: Paint): { text: string; priority: number }[] {
|
|
217
|
+
return HINTS()
|
|
218
|
+
.slice(0, 4)
|
|
219
|
+
.map((hint, index) => ({
|
|
220
|
+
text: `${paint.role("brand", hint.key)} ${paint.dim(hint.does)}`,
|
|
221
|
+
priority: 3 + index,
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#compact(width: number, notes: readonly StartNote[]): string[] {
|
|
105
226
|
const paint = this.#screen.paint();
|
|
106
227
|
const separator = paint.dim(this.#screen.separator());
|
|
107
228
|
const first = [this.#title(paint), this.#segments(["model", "effort", "path", "branch"], paint)]
|
|
108
229
|
.filter((part) => part !== "")
|
|
109
230
|
.join(separator);
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
231
|
+
const last = this.#source.lastVisit();
|
|
232
|
+
return [first, ...this.#notes(notes, paint, last, this.#screen.snapshot().now), ""].map(
|
|
233
|
+
(line) => {
|
|
234
|
+
const padded = ` ${line}`;
|
|
235
|
+
return visibleWidth(padded) > width ? truncateToWidth(padded, width, CUT) : padded;
|
|
236
|
+
},
|
|
237
|
+
);
|
|
114
238
|
}
|
|
115
239
|
|
|
116
240
|
#title(paint: Paint): string {
|
|
117
|
-
const glyph = paint.bold(paint.role("brand",
|
|
118
|
-
return `${glyph}
|
|
241
|
+
const glyph = paint.bold(paint.role("brand", GLYPH.pi));
|
|
242
|
+
return `${glyph}${paint.bold("-harness")} ${paint.dim(HARNESS_VERSION)}`;
|
|
119
243
|
}
|
|
120
244
|
|
|
121
245
|
#segments(ids: readonly SegmentId[], paint: Paint): string {
|
|
@@ -127,50 +251,24 @@ export class HeaderComponent implements Component {
|
|
|
127
251
|
});
|
|
128
252
|
return pieces.map((piece) => piece.text).join(paint.dim(this.#screen.separator()));
|
|
129
253
|
}
|
|
254
|
+
}
|
|
130
255
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
[counts.skills, "skills"],
|
|
138
|
-
[counts.prompts, "prompts"],
|
|
139
|
-
[counts.extensions, "extensions"],
|
|
140
|
-
]
|
|
141
|
-
.filter(([value]) => (value as number) > 0)
|
|
142
|
-
.map(([value, label]) => `${paint.text(String(value))} ${paint.dim(String(label))}`),
|
|
143
|
-
];
|
|
144
|
-
return parts.filter((part) => part !== "").join(paint.dim(this.#screen.separator()));
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
#hints(paint: Paint, width: number): string[] {
|
|
148
|
-
const hints = HINTS();
|
|
149
|
-
const keyWidth = Math.max(...hints.map((hint) => visibleWidth(hint.key)));
|
|
150
|
-
const cell = (hint: Hint) =>
|
|
151
|
-
`${paint.role("brand", hint.key.padEnd(keyWidth))} ${paint.dim(hint.does)}`;
|
|
152
|
-
const cellWidth = keyWidth + 1 + Math.max(...hints.map((hint) => visibleWidth(hint.does)));
|
|
153
|
-
if (width < cellWidth * 2 + 3) return hints.slice(0, 3).map(cell);
|
|
154
|
-
const rows: string[] = [];
|
|
155
|
-
for (let index = 0; index < hints.length; index += 2) {
|
|
156
|
-
const first = hints[index];
|
|
157
|
-
const second = hints[index + 1];
|
|
158
|
-
if (first === undefined) break;
|
|
159
|
-
rows.push(
|
|
160
|
-
second === undefined ? cell(first) : `${pad(cell(first), cellWidth)} ${cell(second)}`,
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
return rows;
|
|
256
|
+
function harnessVersion(): string {
|
|
257
|
+
try {
|
|
258
|
+
const file = new URL("../../package.json", import.meta.url);
|
|
259
|
+
return (JSON.parse(readFileSync(file, "utf8")) as { version?: string }).version ?? "";
|
|
260
|
+
} catch {
|
|
261
|
+
return "";
|
|
164
262
|
}
|
|
165
263
|
}
|
|
166
264
|
|
|
167
265
|
function HINTS(): Hint[] {
|
|
168
266
|
return [
|
|
267
|
+
{ key: "/", does: "commands" },
|
|
169
268
|
{ key: keyText("app.model.select") || "/model", does: "model" },
|
|
170
269
|
{ key: keyText("app.thinking.cycle") || "/thinking", does: "effort" },
|
|
171
|
-
{ key: "
|
|
270
|
+
{ key: "alt+s", does: "settings" },
|
|
172
271
|
{ key: "!", does: "shell" },
|
|
173
|
-
{ key: keyText("app.editor.external") || "/editor", does: "external editor" },
|
|
174
272
|
{ key: "/tree", does: "session history" },
|
|
175
273
|
];
|
|
176
274
|
}
|
|
@@ -192,13 +290,19 @@ export function halfBlocks(pixels: readonly string[]): string[] {
|
|
|
192
290
|
}
|
|
193
291
|
|
|
194
292
|
function pad(text: string, width: number): string {
|
|
195
|
-
const
|
|
196
|
-
|
|
293
|
+
const cells = visibleWidth(text);
|
|
294
|
+
// Measuring is cheap, and cutting a line that already fits is not.
|
|
295
|
+
if (cells <= width) return text + " ".repeat(width - cells);
|
|
296
|
+
const clipped = truncateToWidth(text, width, CUT);
|
|
297
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
197
298
|
}
|
|
198
299
|
|
|
199
300
|
function center(text: string, width: number): string {
|
|
200
|
-
const
|
|
201
|
-
const
|
|
301
|
+
const cells = visibleWidth(text);
|
|
302
|
+
const fits = cells <= width;
|
|
303
|
+
// Measuring is cheap, and cutting a line that already fits is not.
|
|
304
|
+
const clipped = fits ? text : truncateToWidth(text, width, "");
|
|
305
|
+
const room = Math.max(0, width - (fits ? cells : visibleWidth(clipped)));
|
|
202
306
|
const before = Math.floor(room / 2);
|
|
203
307
|
return `${" ".repeat(before)}${clipped}${" ".repeat(room - before)}`;
|
|
204
308
|
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Alt+O: a tool call's whole output in a frame you scroll, newest call first. Pi only shows a long
|
|
2
|
+
// output cut in the transcript, and Ctrl+O opens every call at once; here one call opens at a time,
|
|
3
|
+
// and the arrows move between calls.
|
|
4
|
+
import { callTarget, CUT, frame, frameInner, FRAME_Y, GLYPH, type KeyHint } from "@adeildo/pi-kit";
|
|
5
|
+
import type { SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
type Component,
|
|
8
|
+
Key,
|
|
9
|
+
matchesKey,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
wrapTextWithAnsi,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
/** One call and what it printed. */
|
|
15
|
+
export interface Output {
|
|
16
|
+
tool: string;
|
|
17
|
+
/** What it pointed at: the path, the command, the query. */
|
|
18
|
+
target: string;
|
|
19
|
+
lines: string[];
|
|
20
|
+
failed: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Every tool call on the branch that printed something, oldest first. */
|
|
24
|
+
export function outputsOf(branch: readonly SessionEntry[]): Output[] {
|
|
25
|
+
const calls = new Map<string, { tool: string; target: string }>();
|
|
26
|
+
const outputs: Output[] = [];
|
|
27
|
+
for (const entry of branch) {
|
|
28
|
+
if (entry.type !== "message") continue;
|
|
29
|
+
const message = entry.message;
|
|
30
|
+
if (message.role === "assistant") {
|
|
31
|
+
for (const part of message.content)
|
|
32
|
+
if (part.type === "toolCall")
|
|
33
|
+
calls.set(part.id, { tool: part.name, target: callTarget(part.arguments) });
|
|
34
|
+
}
|
|
35
|
+
if (message.role !== "toolResult") continue;
|
|
36
|
+
const call = calls.get(message.toolCallId);
|
|
37
|
+
const text = message.content
|
|
38
|
+
.map((part) => (part.type === "text" ? part.text : "[image]"))
|
|
39
|
+
.join("\n")
|
|
40
|
+
.trimEnd();
|
|
41
|
+
if (text === "") continue;
|
|
42
|
+
outputs.push({
|
|
43
|
+
tool: call?.tool ?? message.toolName,
|
|
44
|
+
target: call?.target ?? "",
|
|
45
|
+
lines: text.split("\n"),
|
|
46
|
+
failed: message.isError,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return outputs;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface OutputViewOptions {
|
|
53
|
+
theme: Theme;
|
|
54
|
+
outputs: readonly Output[];
|
|
55
|
+
rows: () => number;
|
|
56
|
+
close: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The frame: one output at a time, newest first, scrolled with the arrows and the page keys. */
|
|
60
|
+
export class OutputView implements Component {
|
|
61
|
+
readonly #options: OutputViewOptions;
|
|
62
|
+
/** Which output, counted from the newest. */
|
|
63
|
+
#index = 0;
|
|
64
|
+
#scroll = 0;
|
|
65
|
+
#width = 80;
|
|
66
|
+
/** The wrapped lines of what is on screen, wrapped again only when it or the width changes. */
|
|
67
|
+
#wrapped: { output: Output; inner: number; lines: string[] } | undefined;
|
|
68
|
+
/** The last drawing of the frame, drawn again only when a key moved something. */
|
|
69
|
+
#drawn:
|
|
70
|
+
| { output: Output; width: number; scroll: number; page: number; lines: string[] }
|
|
71
|
+
| undefined;
|
|
72
|
+
|
|
73
|
+
constructor(options: OutputViewOptions) {
|
|
74
|
+
this.#options = options;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
handleInput(data: string): void {
|
|
78
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.alt("o")) || data === "q")
|
|
79
|
+
return this.#options.close();
|
|
80
|
+
if (matchesKey(data, Key.up)) return this.#scrollBy(-1);
|
|
81
|
+
if (matchesKey(data, Key.down)) return this.#scrollBy(1);
|
|
82
|
+
if (matchesKey(data, Key.pageUp)) return this.#scrollBy(-this.#page());
|
|
83
|
+
if (matchesKey(data, Key.pageDown)) return this.#scrollBy(this.#page());
|
|
84
|
+
if (matchesKey(data, Key.home)) return this.#scrollBy(-Number.MAX_SAFE_INTEGER);
|
|
85
|
+
if (matchesKey(data, Key.end)) return this.#scrollBy(Number.MAX_SAFE_INTEGER);
|
|
86
|
+
if (matchesKey(data, Key.left)) return this.#move(1);
|
|
87
|
+
if (matchesKey(data, Key.right)) return this.#move(-1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
render(width: number): string[] {
|
|
91
|
+
this.#width = width;
|
|
92
|
+
const { theme, outputs } = this.#options;
|
|
93
|
+
const output = this.#current();
|
|
94
|
+
if (output === undefined)
|
|
95
|
+
return frame(theme, width, [theme.fg("muted", "No tool output in this session yet.")], {
|
|
96
|
+
title: "Tool output",
|
|
97
|
+
keys: [["esc", "close"]],
|
|
98
|
+
});
|
|
99
|
+
const lines = this.#lines(output);
|
|
100
|
+
const page = this.#page();
|
|
101
|
+
this.#scroll = Math.min(this.#scroll, Math.max(0, lines.length - page));
|
|
102
|
+
const known = this.#drawn;
|
|
103
|
+
if (
|
|
104
|
+
known !== undefined &&
|
|
105
|
+
known.output === output &&
|
|
106
|
+
known.width === width &&
|
|
107
|
+
known.scroll === this.#scroll &&
|
|
108
|
+
known.page === page
|
|
109
|
+
)
|
|
110
|
+
return known.lines;
|
|
111
|
+
|
|
112
|
+
const shown = lines
|
|
113
|
+
.slice(this.#scroll, this.#scroll + page)
|
|
114
|
+
.map((line) => theme.fg("toolOutput", line));
|
|
115
|
+
const last = Math.min(lines.length, this.#scroll + page);
|
|
116
|
+
const keys: KeyHint[] = [
|
|
117
|
+
["↑↓", "scroll"],
|
|
118
|
+
["←→", "calls"],
|
|
119
|
+
["esc", "close"],
|
|
120
|
+
];
|
|
121
|
+
const drawn = frame(theme, width, shown, {
|
|
122
|
+
title: this.#title(output),
|
|
123
|
+
status: theme.fg("muted", `${this.#index + 1} of ${outputs.length}`),
|
|
124
|
+
keys,
|
|
125
|
+
footnote: theme.fg("muted", `lines ${this.#scroll + 1}-${last} of ${lines.length}`),
|
|
126
|
+
});
|
|
127
|
+
this.#drawn = { output, width, scroll: this.#scroll, page, lines: drawn };
|
|
128
|
+
return drawn;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
invalidate(): void {
|
|
132
|
+
this.#wrapped = undefined;
|
|
133
|
+
this.#drawn = undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The mark, the tool and what it pointed at, cut so the count beside it still fits. */
|
|
137
|
+
#title(output: Output): string {
|
|
138
|
+
const { theme } = this.#options;
|
|
139
|
+
const mark = output.failed ? theme.fg("error", GLYPH.cross) : theme.fg("success", GLYPH.check);
|
|
140
|
+
const room = Math.max(8, Math.floor(this.#width / 2) - output.tool.length);
|
|
141
|
+
const target = truncateToWidth(output.target, room, CUT);
|
|
142
|
+
return `${mark} ${theme.fg("toolTitle", theme.bold(output.tool))} ${theme.fg("muted", target)}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#current(): Output | undefined {
|
|
146
|
+
const { outputs } = this.#options;
|
|
147
|
+
return outputs[outputs.length - 1 - this.#index];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#lines(output: Output): string[] {
|
|
151
|
+
const inner = frameInner(this.#width);
|
|
152
|
+
const known = this.#wrapped;
|
|
153
|
+
if (known !== undefined && known.output === output && known.inner === inner) return known.lines;
|
|
154
|
+
const lines = output.lines.flatMap((line) => wrapTextWithAnsi(line, inner));
|
|
155
|
+
this.#wrapped = { output, inner, lines };
|
|
156
|
+
return lines;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#page(): number {
|
|
160
|
+
return Math.max(1, this.#options.rows() - FRAME_Y);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#scrollBy(rows: number): void {
|
|
164
|
+
this.#scroll = Math.max(0, this.#scroll + rows);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#move(step: number): void {
|
|
168
|
+
const next = this.#index + step;
|
|
169
|
+
if (next < 0 || next >= this.#options.outputs.length) return;
|
|
170
|
+
this.#index = next;
|
|
171
|
+
this.#scroll = 0;
|
|
172
|
+
}
|
|
173
|
+
}
|