@adeildo/pi-harness 4.0.0 → 5.0.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.
@@ -1,237 +0,0 @@
1
- // Every setting of the look, under `look.` in the shared settings file. The slots are plain lists of
2
- // segment ids, so moving a piece from the frame to the footer is editing one array.
3
- import {
4
- boolean,
5
- type Decoder,
6
- integer,
7
- literal,
8
- pass,
9
- problem,
10
- setting,
11
- string,
12
- } from "@adeildo/pi-kit";
13
-
14
- import type { FrameStyle } from "./render/frame.ts";
15
- import type { IconMode } from "./render/glyphs.ts";
16
- import { isSegmentId, type SegmentId } from "./render/segments.ts";
17
-
18
- const GROUP = "Look";
19
-
20
- export type HeaderStyle = "card" | "compact" | "off";
21
- export type SeparatorStyle = "dot" | "bar" | "slash" | "space";
22
- export type CursorStyle = "block" | "bar" | "underline";
23
-
24
- export const SEPARATORS: Record<SeparatorStyle, string> = {
25
- dot: " · ",
26
- bar: " │ ",
27
- slash: " / ",
28
- space: " ",
29
- };
30
-
31
- /** A list of known segment ids. Unknown ones are dropped with a warning, not the whole list. */
32
- function segments(fallback: readonly SegmentId[]): Decoder<SegmentId[]> {
33
- return {
34
- decode(input, path) {
35
- if (input === undefined) return pass([...fallback]);
36
- if (!Array.isArray(input))
37
- return pass([...fallback], [problem(path, "expected an array of segment ids")]);
38
- const value: SegmentId[] = [];
39
- const unknown: string[] = [];
40
- for (const entry of input) {
41
- if (typeof entry === "string" && isSegmentId(entry)) value.push(entry);
42
- else unknown.push(String(entry));
43
- }
44
- const problems =
45
- unknown.length === 0
46
- ? []
47
- : [problem(path, `ignored unknown segments: ${unknown.join(", ")}`)];
48
- return pass(value, problems);
49
- },
50
- };
51
- }
52
-
53
- function slot(id: string, label: string, description: string, fallback: readonly SegmentId[]) {
54
- return setting<SegmentId[]>({
55
- id: `look.${id}`,
56
- default: [...fallback],
57
- decoder: segments(fallback),
58
- project: true,
59
- ui: { group: GROUP, label, description },
60
- });
61
- }
62
-
63
- export const strip = slot(
64
- "strip",
65
- "Answer strip",
66
- "The line above the editor: how long the agent has worked, and the last call that finished.",
67
- ["elapsed", "last"],
68
- );
69
- export const topLeft = slot("frame.topLeft", "Frame, top left", "After the working spinner.", [
70
- "branch",
71
- "session",
72
- ]);
73
- export const topRight = slot("frame.topRight", "Frame, top right", "Where you are.", [
74
- "path",
75
- "host",
76
- ]);
77
- /** Where pi-ask-permission reports its mode. It says what happens to what you type, so it sits by the editor. */
78
- export const PERMISSION_MODE: SegmentId = "status:pi-ask-permission:mode";
79
-
80
- export const bottomLeft = slot(
81
- "frame.bottomLeft",
82
- "Frame, bottom left",
83
- "How what you type is handled, and who does the work.",
84
- [PERMISSION_MODE, "model", "effort"],
85
- );
86
- export const bottomRight = slot("frame.bottomRight", "Frame, bottom right", "How full it is.", [
87
- "context",
88
- ]);
89
- export const below = slot(
90
- "below",
91
- "Below the editor",
92
- "The session: cost, tokens, cache, average speeds, and what other packages report, last because it changes the most.",
93
- ["cost", "tokens", "cache", "average", "statuses"],
94
- );
95
-
96
- export const frame = setting<FrameStyle | "off">({
97
- id: "look.frame.style",
98
- default: "rounded",
99
- decoder: literal("rounded", "square", "heavy", "line", "off"),
100
- project: true,
101
- ui: {
102
- group: GROUP,
103
- label: "Frame",
104
- description:
105
- "The box around the editor. `line` keeps pi's two rules, `off` leaves the editor alone and moves the frame slots to the footer.",
106
- },
107
- });
108
-
109
- export const cursor = setting<CursorStyle>({
110
- id: "look.frame.cursor",
111
- default: "bar",
112
- decoder: literal("block", "bar", "underline"),
113
- ui: {
114
- group: GROUP,
115
- label: "Cursor",
116
- description: "Bar and underline use the terminal's own cursor, so it blinks the way it does.",
117
- },
118
- });
119
-
120
- export const header = setting<HeaderStyle>({
121
- id: "look.header",
122
- default: "card",
123
- decoder: literal("card", "compact", "off"),
124
- project: true,
125
- ui: {
126
- group: GROUP,
127
- label: "Start screen",
128
- description: "The card pi opens with. Off keeps pi's own.",
129
- },
130
- });
131
-
132
- export const icons = setting<IconMode>({
133
- id: "look.icons",
134
- default: "auto",
135
- decoder: literal("auto", "nerd", "unicode", "ascii"),
136
- ui: {
137
- group: GROUP,
138
- label: "Icons",
139
- description: "Auto uses Nerd Font glyphs locally, plain Unicode over SSH.",
140
- },
141
- });
142
-
143
- export const separator = setting<SeparatorStyle>({
144
- id: "look.separator",
145
- default: "dot",
146
- decoder: literal("dot", "bar", "slash", "space"),
147
- project: true,
148
- ui: { group: GROUP, label: "Separator", description: "Between the pieces of a slot." },
149
- });
150
-
151
- export const pathLength = setting({
152
- id: "look.pathLength",
153
- default: 40,
154
- decoder: integer(0, 500),
155
- project: true,
156
- ui: {
157
- group: GROUP,
158
- label: "Path length",
159
- description: "Longest the folder may be before it is shortened from the left. 0 keeps it all.",
160
- },
161
- });
162
-
163
- export const gaugeCells = setting({
164
- id: "look.gaugeCells",
165
- default: 8,
166
- decoder: integer(0, 40),
167
- project: true,
168
- ui: { group: GROUP, label: "Gauge", description: "Cells in the context gauge. 0 hides it." },
169
- });
170
-
171
- export const labels = setting({
172
- id: "look.labels",
173
- default: true,
174
- decoder: boolean,
175
- project: true,
176
- ui: {
177
- group: GROUP,
178
- label: "Labels",
179
- description:
180
- "A short word before each number (out, in, ttft, took, ctx, cache). Off is denser.",
181
- },
182
- });
183
-
184
- export const peek = setting({
185
- id: "look.peek",
186
- default: true,
187
- decoder: boolean,
188
- ui: {
189
- group: GROUP,
190
- label: "Working line",
191
- description:
192
- "The spinner says what the model is doing: the tail of its thinking, the tool it calls.",
193
- },
194
- });
195
-
196
- export const desktop = setting({
197
- id: "look.desktop.theme",
198
- default: true,
199
- decoder: boolean,
200
- ui: {
201
- group: GROUP,
202
- label: "Desktop theme",
203
- description:
204
- "Write the matugen palette from DankMaterialShell as the pi theme `desktop`, and keep it in sync.",
205
- },
206
- });
207
-
208
- export const desktopSource = setting({
209
- id: "look.desktop.source",
210
- default: "~/.cache/DankMaterialShell/dms-colors.json",
211
- decoder: string,
212
- ui: {
213
- group: GROUP,
214
- label: "Desktop palette",
215
- description: "The DankMaterialShell colour file the desktop theme is made from.",
216
- },
217
- });
218
-
219
- export const LOOK_SETTINGS = [
220
- strip,
221
- topLeft,
222
- topRight,
223
- bottomLeft,
224
- bottomRight,
225
- below,
226
- frame,
227
- cursor,
228
- header,
229
- icons,
230
- separator,
231
- pathLength,
232
- gaugeCells,
233
- labels,
234
- peek,
235
- desktop,
236
- desktopSource,
237
- ];
@@ -1,194 +0,0 @@
1
- // The editor, framed. Pi's editor draws a rule above and below the text; this one draws a box, writes
2
- // four slots into its borders, and puts the working spinner in the top-left corner. Typing, history, autocomplete and every app key stay pi's: the class only
3
- // changes what the lines look like, and shifts mouse clicks by the width of the frame.
4
- import { CustomEditor, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
5
- import {
6
- type EditorTheme,
7
- type TUI,
8
- type TuiMouseEvent,
9
- type TuiMouseEventResult,
10
- truncateToWidth,
11
- visibleWidth,
12
- } from "@earendil-works/pi-tui";
13
-
14
- import type { Piece } from "../render/fit.ts";
15
- import { BOXES, border } from "../render/frame.ts";
16
- import type { CursorStyle } from "../settings.ts";
17
- import { type Screen, type SlotName, slotPieces } from "./screen.ts";
18
-
19
- type WorkingIndicator = Parameters<CustomEditor["setWorkingStatusIndicator"]>[0];
20
-
21
- /** Pi calls the border hooks inside its render; these stand in for the lines this class draws. */
22
- const TOP = "\u0000look:top\u0000";
23
- const BOTTOM = "\u0000look:bottom\u0000";
24
- // oxlint-disable-next-line no-control-regex -- pi's software cursor is an escape sequence
25
- const INVERTED_CELL = /\x1b\[7m([\s\S]*?)\x1b\[0m/;
26
- /** Below this the frame is noise, so pi's plain editor draws instead. */
27
- const MIN_WIDTH = 24;
28
- /** The spinner always gets this much of the top border, enough for "⠋ Thinking". */
29
- const LEAD_MIN = 10;
30
-
31
- const CURSOR_SEQUENCES: Record<CursorStyle, string> = {
32
- block: "\x1b[0 q",
33
- bar: "\x1b[6 q",
34
- underline: "\x1b[4 q",
35
- };
36
-
37
- export class LookEditor extends CustomEditor {
38
- readonly #screen: Screen;
39
- #indicator: WorkingIndicator;
40
- #framing = false;
41
- #hiddenAbove = 0;
42
- #hiddenBelow = 0;
43
- /** Columns the frame takes on each side, for mouse clicks. */
44
- #inset = 0;
45
-
46
- constructor(
47
- tui: TUI,
48
- theme: EditorTheme,
49
- keybindings: KeybindingsManager,
50
- screen: Screen,
51
- options: { autocompleteMaxVisible?: number } = {},
52
- ) {
53
- super(tui, theme, keybindings, { ...options, paddingX: 0, embedWorkingStatus: true });
54
- this.#screen = screen;
55
- }
56
-
57
- /** The frame owns the horizontal inset, so pi's padding setting would double it. */
58
- override setPaddingX(_padding: number): void {
59
- super.setPaddingX(0);
60
- }
61
-
62
- override setWorkingStatusIndicator(indicator: WorkingIndicator): void {
63
- super.setWorkingStatusIndicator(indicator);
64
- this.#indicator = indicator;
65
- this.tui.requestRender();
66
- }
67
-
68
- override handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
69
- if (this.#inset === 0) return super.handleMouse(event);
70
- return super.handleMouse({
71
- ...event,
72
- x: event.x - this.#inset,
73
- width: Math.max(1, event.width - 2 * this.#inset),
74
- });
75
- }
76
-
77
- protected override renderTopBorder(width: number, hiddenLineCount: number): string {
78
- if (!this.#framing) return super.renderTopBorder(width, hiddenLineCount);
79
- this.#hiddenAbove = hiddenLineCount;
80
- return TOP;
81
- }
82
-
83
- protected override renderBottomBorder(width: number, hiddenLineCount: number): string {
84
- if (!this.#framing) return super.renderBottomBorder(width, hiddenLineCount);
85
- this.#hiddenBelow = hiddenLineCount;
86
- return BOTTOM;
87
- }
88
-
89
- override render(width: number): string[] {
90
- const style = this.#screen.frameStyle();
91
- if (style === "off" || width < MIN_WIDTH) {
92
- this.#inset = 0;
93
- return super.render(width);
94
- }
95
-
96
- const box = BOXES[style];
97
- const glyphs = this.#screen.glyphs();
98
- this.#inset = box.vertical === "" ? 0 : 2;
99
- const inner = Math.max(1, width - 2 * this.#inset);
100
-
101
- const cursor = this.#screen.cursor();
102
- // Pi puts its own cursor setting back after a reload, which would hide a bar cursor.
103
- if (cursor !== "block" && !this.tui.getShowHardwareCursor()) applyCursor(this.tui, cursor);
104
-
105
- this.#framing = true;
106
- let lines: string[];
107
- try {
108
- lines = super.render(inner);
109
- } finally {
110
- this.#framing = false;
111
- }
112
-
113
- const paint = this.#screen.paint((text) => this.borderColor(text));
114
- const snapshot = this.#screen.snapshot();
115
- const separator = paint.dim(this.#screen.separator());
116
- const pieces = (name: SlotName): Piece[] => slotPieces(this.#screen, name, snapshot, paint);
117
- const scroll = (arrow: string, hidden: number): Piece[] =>
118
- hidden > 0 ? [{ text: paint.dim(`${arrow} ${hidden} more`), priority: 1000 }] : [];
119
- const options = { box, paint: paint.frame, separator, ellipsis: glyphs.ellipsis };
120
-
121
- const left = style === "line" ? "" : `${paint.frame(box.vertical)} `;
122
- const right = style === "line" ? "" : ` ${paint.frame(box.vertical)}`;
123
- const result: string[] = [];
124
- let inside = false;
125
-
126
- for (const line of lines) {
127
- if (line === TOP) {
128
- const lead =
129
- this.#indicator === undefined ? undefined : (budget: number) => this.#lead(budget);
130
- result.push(
131
- border(
132
- width,
133
- pieces("topLeft"),
134
- [...scroll("↑", this.#hiddenAbove), ...pieces("topRight")],
135
- {
136
- ...options,
137
- edge: "top",
138
- lead,
139
- leadMin: LEAD_MIN,
140
- },
141
- ),
142
- );
143
- inside = true;
144
- } else if (line === BOTTOM) {
145
- result.push(
146
- border(
147
- width,
148
- pieces("bottomLeft"),
149
- [...scroll("↓", this.#hiddenBelow), ...pieces("bottomRight")],
150
- { ...options, edge: "bottom" },
151
- ),
152
- );
153
- inside = false;
154
- } else if (inside) {
155
- const text = cursor === "block" ? line : withoutSoftwareCursor(line);
156
- result.push(`${left}${fill(text, inner)}${right}`);
157
- } else {
158
- // The autocomplete list, lined up with the text so clicks land where they look.
159
- result.push(`${" ".repeat(this.#inset)}${line}`);
160
- }
161
- }
162
-
163
- return result.map((line) => truncateToWidth(line, width, ""));
164
- }
165
-
166
- /** The spinner and its message, in the room the slots left. Just the spinner when it is tight. */
167
- #lead(budget: number): string | undefined {
168
- const indicator = this.#indicator;
169
- if (indicator === undefined) return undefined;
170
- const full = indicator.renderInBorder(budget);
171
- if (visibleWidth(full) > 0 && visibleWidth(full) <= budget) return full;
172
- return indicator.renderSpinnerInBorder(budget);
173
- }
174
- }
175
-
176
- /**
177
- * Pi draws its cursor as an inverted cell. With the terminal's own cursor showing, that would be two
178
- * cursors, so the inversion goes and the character stays.
179
- */
180
- function withoutSoftwareCursor(line: string): string {
181
- return line.replace(INVERTED_CELL, "$1");
182
- }
183
-
184
- /** Pads a content line to the inner width, so the right rail stays straight. */
185
- function fill(line: string, width: number): string {
186
- const clipped = truncateToWidth(line, width, "");
187
- return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
188
- }
189
-
190
- /** Sets the terminal cursor shape. Pi draws its own block cursor, so the others need the hardware one. */
191
- export function applyCursor(tui: TUI, style: CursorStyle): void {
192
- if (style !== "block") tui.setShowHardwareCursor(true);
193
- tui.terminal.write(CURSOR_SEQUENCES[style]);
194
- }
@@ -1,103 +0,0 @@
1
- // The lines around the editor that are not the frame. The strip above it is the answer being written;
2
- // the footer below it is the session. With the frame off, the footer also carries the four frame
3
- // slots, so turning the box off never hides data.
4
- import type { ReadonlyFooterDataProvider } from "@earendil-works/pi-coding-agent";
5
- import { type Component, type TUI, visibleWidth } from "@earendil-works/pi-tui";
6
-
7
- import { fitLine, fitRegions, type Piece } from "../render/fit.ts";
8
- import type { Paint } from "../render/paint.ts";
9
- import { type Screen, type SlotName, slotPieces } from "./screen.ts";
10
-
11
- /** At least this much between a left and a right group sharing a line. */
12
- const GAP = 3;
13
-
14
- /** Where the text starts, so the strip and the footer line up with the text inside the frame. */
15
- export function indentOf(screen: Screen): number {
16
- const style = screen.frameStyle();
17
- return style === "off" || style === "line" ? 1 : 2;
18
- }
19
-
20
- /** The strip above the editor. Blank until the first answer, but its line is always there. */
21
- export class StripComponent implements Component {
22
- readonly #screen: Screen;
23
-
24
- constructor(screen: Screen) {
25
- this.#screen = screen;
26
- }
27
-
28
- render(width: number): string[] {
29
- const indent = indentOf(this.#screen);
30
- const line = renderSlot(this.#screen, "strip", width - indent * 2);
31
- // Always one line, empty or not: a strip that appears with the first answer moves the editor.
32
- return [line === "" ? "" : `${" ".repeat(indent)}${line}`];
33
- }
34
-
35
- invalidate(): void {}
36
- }
37
-
38
- export interface FooterHooks {
39
- /** The footer data is where the branch and other packages' statuses come from. */
40
- attach(data: ReadonlyFooterDataProvider, repaint: () => void): void;
41
- detach(): void;
42
- }
43
-
44
- export class FooterComponent implements Component {
45
- readonly #screen: Screen;
46
- readonly #hooks: FooterHooks;
47
- readonly #stopWatching: () => void;
48
-
49
- constructor(tui: TUI, data: ReadonlyFooterDataProvider, screen: Screen, hooks: FooterHooks) {
50
- this.#screen = screen;
51
- this.#hooks = hooks;
52
- hooks.attach(data, () => tui.requestRender());
53
- this.#stopWatching = data.onBranchChange(() => tui.requestRender());
54
- }
55
-
56
- render(width: number): string[] {
57
- const screen = this.#screen;
58
- const indent = indentOf(screen);
59
- const room = width - indent * 2;
60
- const pad = " ".repeat(indent);
61
- const lines: string[] = [];
62
-
63
- if (screen.frameStyle() === "off") {
64
- lines.push(spread(screen, "topLeft", "topRight", room));
65
- lines.push(spread(screen, "bottomLeft", "bottomRight", room));
66
- }
67
- lines.push(renderSlot(screen, "below", room));
68
-
69
- const kept = lines.filter((line) => line !== "").map((line) => `${pad}${line}`);
70
- // At least one line, for the same reason as the strip: the editor must not jump when it fills.
71
- return kept.length === 0 ? [""] : kept;
72
- }
73
-
74
- invalidate(): void {}
75
-
76
- dispose(): void {
77
- this.#stopWatching();
78
- this.#hooks.detach();
79
- }
80
- }
81
-
82
- function renderSlot(screen: Screen, name: SlotName, width: number): string {
83
- if (width <= 0) return "";
84
- const paint = screen.paint();
85
- const pieces = slotPieces(screen, name, screen.snapshot(), paint);
86
- return fitLine(pieces, width, fitOptions(screen, paint));
87
- }
88
-
89
- /** A left group and a right group on one line, pushed apart. */
90
- function spread(screen: Screen, leftSlot: SlotName, rightSlot: SlotName, width: number): string {
91
- if (width <= 0) return "";
92
- const paint = screen.paint();
93
- const snapshot = screen.snapshot();
94
- const left: Piece[] = slotPieces(screen, leftSlot, snapshot, paint);
95
- const right: Piece[] = slotPieces(screen, rightSlot, snapshot, paint);
96
- const [l = "", r = ""] = fitRegions([left, right], width - GAP, fitOptions(screen, paint));
97
- if (r === "") return l;
98
- return `${l}${" ".repeat(Math.max(GAP, width - visibleWidth(l) - visibleWidth(r)))}${r}`;
99
- }
100
-
101
- function fitOptions(screen: Screen, paint: Paint) {
102
- return { separator: paint.dim(screen.separator()), ellipsis: screen.glyphs().ellipsis };
103
- }