@adeildo/pi-look 5.9.1 → 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.
@@ -0,0 +1,185 @@
1
+ // Pi's own entries in the transcript wear the harness's frame: your messages, compactions, branch
2
+ // summaries, skills, `!` commands and the messages of extensions that bring no renderer. Pi sets them
3
+ // apart with a background and blank rows; the frame does that job here, so the text keeps its colour,
4
+ // loses the background, and sits against the edges. Pi has no hook for drawing these, so this wraps
5
+ // the render of each component.
6
+ import { ENTRY_CUT, ENTRY_X, entryFrame, type EntryFrame, GLYPH } from "@adeildo/pi-kit";
7
+ import {
8
+ BashExecutionComponent,
9
+ BranchSummaryMessageComponent,
10
+ CompactionSummaryMessageComponent,
11
+ CustomMessageComponent,
12
+ SkillInvocationMessageComponent,
13
+ type Theme,
14
+ UserMessageComponent,
15
+ } from "@earendil-works/pi-coding-agent";
16
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
17
+
18
+ import type { IconSet } from "../render/glyphs.ts";
19
+ import { isBlank } from "./tool-frame.ts";
20
+
21
+ /** What the frames need from the session. Undefined leaves pi's entries as they are. */
22
+ export type EntryPaint = () => { theme: Theme; set: IconSet; you: boolean } | undefined;
23
+
24
+ const YOU: Readonly<Record<IconSet, string>> = {
25
+ nerd: "\u{f007}", // nf-fa-user
26
+ unicode: GLYPH.triangle,
27
+ ascii: ">",
28
+ };
29
+ /** The shell integration markers pi puts on the first and last line of your message. */
30
+ // oxlint-disable-next-line no-control-regex -- the markers are escape sequences
31
+ const MARKERS = /^(?:\u001b\]133;[^\u0007]*\u0007)*/;
32
+ /** Background colour codes, which pi paints its entries with. */
33
+ // oxlint-disable-next-line no-control-regex -- colour codes are escape sequences
34
+ const BACKGROUND = /\u001b\[(?:48;[25];[\d;]+|49|4[0-7]|10[0-7])m/g;
35
+ /** Pi's bracketed label, as `[compaction]`, which the frame's top edge replaces. */
36
+ // oxlint-disable-next-line no-control-regex -- the label is wrapped in bold codes
37
+ const LABEL = /(\u001b\[1m)?\[[\w:.-]+\](\u001b\[22m)? ?/;
38
+
39
+ type Paint = NonNullable<ReturnType<EntryPaint>>;
40
+
41
+ /** A frame, and what of pi's own drawing it replaces. */
42
+ interface Framing extends EntryFrame {
43
+ /** Pi's bracketed label on the first line, as `[compaction]`. */
44
+ label?: boolean;
45
+ /** The rules pi draws above and below a `!` command. */
46
+ rules?: boolean;
47
+ }
48
+ type Render = (width: number) => string[];
49
+
50
+ let installed = false;
51
+ let paint: EntryPaint = () => undefined;
52
+
53
+ /** Frames pi's entries from now on. Calling it again only changes where the paint comes from. */
54
+ export function framePiEntries(source: EntryPaint): void {
55
+ paint = source;
56
+ if (installed) return;
57
+ installed = true;
58
+ wrap(UserMessageComponent, (_self, look) => (look.you ? youFrame(look) : undefined));
59
+ wrap(CompactionSummaryMessageComponent, (_self, look) => pi(look, "compacted"));
60
+ wrap(BranchSummaryMessageComponent, (_self, look) => pi(look, "branch summary"));
61
+ wrap(SkillInvocationMessageComponent, (_self, look) => ({
62
+ top: look.theme.fg("muted", "skill"),
63
+ color: "borderMuted",
64
+ label: true,
65
+ }));
66
+ wrap(CustomMessageComponent, (self, look) => {
67
+ // A renderer of an extension draws its own message.
68
+ const own = self as unknown as { customComponent?: unknown; message: { customType: string } };
69
+ if (own.customComponent !== undefined) return undefined;
70
+ const top = look.theme.fg("muted", own.message.customType);
71
+ return { top, color: "borderMuted", label: true };
72
+ });
73
+ wrap(BashExecutionComponent, (self, look) => shellFrame(self, look));
74
+ }
75
+
76
+ function youFrame(look: Paint): Framing {
77
+ const top = `${look.theme.fg("accent", YOU[look.set])} ${look.theme.fg("muted", "you")}`;
78
+ return { top, color: "accent" };
79
+ }
80
+
81
+ function pi(look: Paint, what: string): Framing {
82
+ return { top: look.theme.fg("muted", `${GLYPH.pi} ${what}`), color: "borderMuted", label: true };
83
+ }
84
+
85
+ interface Shell {
86
+ status?: "running" | "complete" | "cancelled" | "error";
87
+ exitCode?: number;
88
+ colorKey?: "bashMode" | "dim";
89
+ }
90
+
91
+ function shellFrame(self: object, look: Paint): Framing {
92
+ const { status, exitCode, colorKey = "bashMode" } = self as Shell;
93
+ const { theme } = look;
94
+ const top = `${theme.fg(colorKey, "!")} ${theme.fg("muted", "shell")}`;
95
+ return { top, bottom: shellPhase(theme, status, exitCode), color: colorKey, rules: true };
96
+ }
97
+
98
+ function shellPhase(theme: Theme, status: Shell["status"], exitCode: number | undefined): string {
99
+ switch (status) {
100
+ case "running":
101
+ return theme.fg("muted", "running");
102
+ case "cancelled":
103
+ return theme.fg("warning", "cancelled");
104
+ case "error":
105
+ return theme.fg("error", `${GLYPH.cross} exit ${exitCode ?? "?"}`);
106
+ default:
107
+ return theme.fg("success", `${GLYPH.check} done`);
108
+ }
109
+ }
110
+
111
+ function wrap(
112
+ component: { prototype: object },
113
+ frameOf: (self: object, look: Paint) => Framing | undefined,
114
+ ): void {
115
+ const prototype = component.prototype as { render: Render };
116
+ const own = prototype.render;
117
+ prototype.render = function render(this: object, width: number) {
118
+ const look = paint();
119
+ const frame = look === undefined || width < ENTRY_X * 2 ? undefined : frameOf(this, look);
120
+ if (look === undefined || frame === undefined) return own.call(this, width);
121
+ const source = own.call(this, width - 2);
122
+ // The transcript draws every entry on every keystroke. Pi caches its own lines, so the frame
123
+ // is only drawn again when they, the width or the paint change.
124
+ const known = drawnBefore.get(this);
125
+ const key = `${width}\u0000${frame.top}\u0000${frame.bottom}\u0000${frame.color}`;
126
+ if (known?.key === key && known.theme === look.theme && sameLines(known.source, source))
127
+ return known.lines;
128
+ const lines = framed(source, width, look.theme, frame);
129
+ drawnBefore.set(this, { key, theme: look.theme, source, lines });
130
+ return lines;
131
+ };
132
+ }
133
+
134
+ interface Drawn {
135
+ /** The width and the frame's labels. */
136
+ key: string;
137
+ theme: Theme;
138
+ source: readonly string[];
139
+ lines: string[];
140
+ }
141
+
142
+ const drawnBefore = new WeakMap<object, Drawn>();
143
+
144
+ function sameLines(a: readonly string[], b: readonly string[]): boolean {
145
+ if (a === b) return true;
146
+ if (a.length !== b.length) return false;
147
+ for (let index = 0; index < a.length; index++) if (a[index] !== b[index]) return false;
148
+ return true;
149
+ }
150
+
151
+ /** Pi's lines, drawn two columns narrower with a column of padding each side, in the frame. */
152
+ export function framed(
153
+ body: readonly string[],
154
+ width: number,
155
+ theme: Theme,
156
+ framing: Framing,
157
+ ): string[] {
158
+ const { label, rules, ...frame } = framing;
159
+ const first = body[0]?.match(MARKERS)?.[0] ?? "";
160
+ const last = body.at(-1)?.match(MARKERS)?.[0] ?? "";
161
+ let lines = body.map((line) => line.replace(MARKERS, "").replace(BACKGROUND, ""));
162
+ if (rules) {
163
+ lines = lines.filter((line) => !/^\u2500+$/.test(stripTerminalSequences(line).trim()));
164
+ // The blank line between a command and what it printed.
165
+ const gap = lines.findIndex((line, index) => index > 0 && isBlank(line));
166
+ if (gap > 0 && gap < lines.length - 1) lines[gap] = ENTRY_CUT;
167
+ }
168
+ // The blank rows pi pads its entries with.
169
+ const trim = () => {
170
+ while (lines.length > 1 && isBlank(lines[0] ?? "")) lines.shift();
171
+ while (lines.length > 1 && isBlank(lines.at(-1) ?? "")) lines.pop();
172
+ };
173
+ trim();
174
+ if (label && lines[0] !== undefined) {
175
+ // Alone on its line it goes with the line, otherwise it leaves the rest, as `[skill] name`.
176
+ const alone = /^\[[\w:.-]+\]$/.test(stripTerminalSequences(lines[0]).trim());
177
+ if (alone) lines.shift();
178
+ else lines[0] = lines[0].replace(LABEL, "$2");
179
+ trim();
180
+ }
181
+ const drawn = entryFrame(lines, width, theme, { ...frame, pad: 0 });
182
+ drawn[0] = first + drawn[0];
183
+ drawn[drawn.length - 1] = last + drawn.at(-1);
184
+ return drawn;
185
+ }
@@ -0,0 +1,76 @@
1
+ // What each look setting changes, drawn under the list in Alt+S while it or one of its values is
2
+ // selected: the marks of the icon set, the editor in the frame style, your message framed or not.
3
+ import { entryFrame, GLYPH, type Json } from "@adeildo/pi-kit";
4
+ import type { Theme } from "@earendil-works/pi-coding-agent";
5
+ import { visibleWidth } from "@earendil-works/pi-tui";
6
+
7
+ import { BOXES, border, type FrameStyle } from "../render/frame.ts";
8
+ import { type IconMode, resolveIcons } from "../render/glyphs.ts";
9
+ import { stateMark, toolIcon } from "../render/tools.ts";
10
+
11
+ const WIDTH = 56;
12
+ const TOOLS = [
13
+ "bash",
14
+ "read",
15
+ "edit",
16
+ "grep",
17
+ "web_fetch",
18
+ "ask_questions",
19
+ "peers",
20
+ "memory_write",
21
+ ];
22
+
23
+ /** The tool marks and the phase marks of an icon set. */
24
+ export function iconsPreview(value: Json, theme: Theme): string[] {
25
+ const set = resolveIcons(value as IconMode).set;
26
+ const tools = TOOLS.map(
27
+ (tool) => `${theme.fg("toolTitle", toolIcon(tool, set))} ${theme.fg("muted", tool)}`,
28
+ );
29
+ const phases = (["writing", "waiting", "running", "done", "failed"] as const).map(
30
+ (phase) => `${theme.fg("accent", stateMark(phase, set, 0))} ${theme.fg("muted", phase)}`,
31
+ );
32
+ return [tools.slice(0, 4).join(" "), tools.slice(4).join(" "), "", phases.join(" ")];
33
+ }
34
+
35
+ /** The editor, in the frame's lines. */
36
+ export function framePreview(value: Json, theme: Theme, width: number): string[] {
37
+ const style = value as FrameStyle | "off";
38
+ const room = Math.min(WIDTH, width);
39
+ const line = (color: "borderAccent", text: string) => theme.fg(color, text);
40
+ if (style === "off")
41
+ return [
42
+ theme.fg("dim", "pi's editor, as pi draws it, with the edges' information in the footer"),
43
+ ];
44
+ const box = BOXES[style];
45
+ // The preview draws its edges with the frame's own border, so it cannot drift from the editor.
46
+ const edge = (which: "top" | "bottom", label: string, rest: string) =>
47
+ border(room, [{ text: label, priority: 1 }], [{ text: rest, priority: 2 }], {
48
+ box,
49
+ edge: which,
50
+ paint: (text) => line("borderAccent", text),
51
+ separator: " ",
52
+ });
53
+ const side = style === "line" ? " " : line("borderAccent", box.vertical);
54
+ return [
55
+ edge("top", theme.fg("warning", "main"), theme.fg("muted", "~/project")),
56
+ `${side} ${theme.fg("accent", GLYPH.cursor)}${" ".repeat(Math.max(0, room - 4))}${side}`,
57
+ edge("bottom", theme.fg("text", "Opus 5.5"), theme.fg("muted", "ctx 34%")),
58
+ ];
59
+ }
60
+
61
+ /** Your message, framed or in pi's block. */
62
+ export function userFramePreview(value: Json, theme: Theme, width: number): string[] {
63
+ const room = Math.min(WIDTH, width);
64
+ const text = theme.fg("text", "commit with a semantic message and push");
65
+ if (value === true)
66
+ return entryFrame([text], room, theme, {
67
+ top: `${theme.fg("accent", GLYPH.triangle)} ${theme.fg("muted", "you")}`,
68
+ color: "accent",
69
+ });
70
+ const blank = theme.bg("userMessageBg", " ".repeat(room));
71
+ const body = theme.bg(
72
+ "userMessageBg",
73
+ ` ${text}${" ".repeat(Math.max(0, room - 1 - visibleWidth(text)))}`,
74
+ );
75
+ return [blank, body, blank];
76
+ }
@@ -0,0 +1,75 @@
1
+ // The messages you send while the agent works wait in a dashed frame above the editor: dashed
2
+ // because they have not happened yet. Each says when it goes in, `steer` between the agent's calls
3
+ // and `next` after its run, and the bottom edge names the key that takes them back to the editor.
4
+ // Pi lists them as plain grey lines and has no hook for drawing them, so this wraps its method.
5
+ import { CUT, drawnByWidth, ENTRY_X, entryFrame } from "@adeildo/pi-kit";
6
+ import { InteractiveMode, type Theme } from "@earendil-works/pi-coding-agent";
7
+ import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
8
+
9
+ /** The theme to draw with. Undefined leaves pi's list as it is. */
10
+ export type QueuePaint = () => Theme | undefined;
11
+
12
+ interface Queued {
13
+ steering: readonly string[];
14
+ followUp: readonly string[];
15
+ }
16
+
17
+ interface Host {
18
+ pendingMessagesContainer: { clear(): void; addChild(component: Component): void };
19
+ getAllQueuedMessages(): Queued;
20
+ getAppKeyDisplay(action: string): string;
21
+ }
22
+
23
+ let installed = false;
24
+ let paint: QueuePaint = () => undefined;
25
+
26
+ export function frameQueue(source: QueuePaint): void {
27
+ paint = source;
28
+ if (installed) return;
29
+ installed = true;
30
+ const prototype = InteractiveMode.prototype as unknown as {
31
+ updatePendingMessagesDisplay(): void;
32
+ };
33
+ const own = prototype.updatePendingMessagesDisplay;
34
+ prototype.updatePendingMessagesDisplay = function update(this: Host) {
35
+ const theme = paint();
36
+ if (theme === undefined) return own.call(this);
37
+ const queued = this.getAllQueuedMessages();
38
+ this.pendingMessagesContainer.clear();
39
+ if (queued.steering.length + queued.followUp.length === 0) return;
40
+ const key = this.getAppKeyDisplay("app.message.dequeue");
41
+ this.pendingMessagesContainer.addChild(queueBlock(queued, key, theme));
42
+ };
43
+ }
44
+
45
+ const MODES = [
46
+ ["steer", "steering"],
47
+ ["next", "followUp"],
48
+ ] as const;
49
+ const LABEL = Math.max(...MODES.map(([label]) => label.length));
50
+
51
+ export function queueBlock(queued: Queued, key: string, theme: Theme): Component {
52
+ return drawnByWidth((width) => {
53
+ const room = Math.max(1, width - ENTRY_X - LABEL - 2);
54
+ const rows = MODES.flatMap(([label, mode]) =>
55
+ queued[mode].map((text) => {
56
+ const color = label === "steer" ? "accent" : "muted";
57
+ return `${theme.fg(color, label.padEnd(LABEL))} ${theme.fg("text", oneLine(text, room))}`;
58
+ }),
59
+ );
60
+ return entryFrame(rows, width, theme, {
61
+ top: theme.fg("dim", "queued"),
62
+ bottom: `${theme.fg("muted", key)} ${theme.fg("dim", "to edit")}`,
63
+ color: "borderMuted",
64
+ dashed: true,
65
+ });
66
+ });
67
+ }
68
+
69
+ /** The first line, cut to the room, with a mark when there is more. */
70
+ function oneLine(text: string, room: number): string {
71
+ const lines = text.trim().split("\n");
72
+ const first = lines[0] ?? "";
73
+ if (lines.length === 1 && visibleWidth(first) <= room) return first;
74
+ return `${truncateToWidth(first, Math.max(1, room - CUT.length), "")}${CUT}`;
75
+ }
package/src/ui/screen.ts CHANGED
@@ -29,6 +29,14 @@ export interface Screen {
29
29
  decorate(line: string): string;
30
30
  /** True when a skills feature expands `/skill:` references. */
31
31
  skills(): boolean;
32
+ /**
33
+ * How many times the screen was told that what it draws changed. Something that keeps a drawing
34
+ * for a moment compares this: a branch, a model or a probe that answered must not wait for the
35
+ * next keystroke to show up.
36
+ */
37
+ revision(): number;
38
+ /** Records that something the screen draws changed, such as an event or a probe that answered. */
39
+ bump(): void;
32
40
  }
33
41
 
34
42
  export function slotPieces(