@adeildo/pi-look 4.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.
@@ -0,0 +1,212 @@
1
+ // The start screen. A card with the π drawn in blocks, coloured along the theme's effort ramp, next to
2
+ // what the session starts with: the model, the folder, the machine, what loaded, and the keys worth
3
+ // knowing. Narrow terminals get two plain lines instead.
4
+ import { keyText } from "@earendil-works/pi-coding-agent";
5
+ import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
6
+
7
+ import { BOXES, border } from "../render/frame.ts";
8
+ import { EFFORT_LEVELS, type Paint } from "../render/paint.ts";
9
+ import { renderSegments, type SegmentId } from "../render/segments.ts";
10
+ import type { HeaderStyle } from "../settings.ts";
11
+ import type { Screen } from "./screen.ts";
12
+
13
+ /** The π, one character per pixel, drawn two pixels per row with half blocks. */
14
+ const PIXELS = [
15
+ "..............",
16
+ ".############.",
17
+ "##############",
18
+ "...##....##...",
19
+ "...##....##...",
20
+ "...##....##...",
21
+ "...##....##...",
22
+ "...##....##...",
23
+ "..##.....###..",
24
+ ".##.......####",
25
+ ];
26
+ const LOGO = halfBlocks(PIXELS);
27
+ /** One effort level per row, from cool at the top to hot at the bottom. */
28
+ const LOGO_RAMP = EFFORT_LEVELS.slice(1);
29
+ const LOGO_COLUMN = 20;
30
+ /** Under this the right column is too cramped to share with the logo. */
31
+ const MIN_RIGHT = 34;
32
+ const MIN_CARD = 48;
33
+
34
+ export interface LoadedCounts {
35
+ tools: number;
36
+ skills: number;
37
+ prompts: number;
38
+ extensions: number;
39
+ }
40
+
41
+ export interface HeaderSource {
42
+ style(): HeaderStyle;
43
+ counts(): LoadedCounts;
44
+ }
45
+
46
+ interface Hint {
47
+ key: string;
48
+ does: string;
49
+ }
50
+
51
+ export class HeaderComponent implements Component {
52
+ readonly #screen: Screen;
53
+ readonly #source: HeaderSource;
54
+
55
+ constructor(screen: Screen, source: HeaderSource) {
56
+ this.#screen = screen;
57
+ this.#source = source;
58
+ }
59
+
60
+ render(width: number): string[] {
61
+ if (width < MIN_CARD || this.#source.style() === "compact") return this.#compact(width);
62
+ return this.#card(width);
63
+ }
64
+
65
+ invalidate(): void {}
66
+
67
+ #card(width: number): string[] {
68
+ const screen = this.#screen;
69
+ const paint = screen.paint();
70
+ const box = BOXES.rounded;
71
+ const frame = paint.frame;
72
+ const title = this.#title(paint);
73
+ const inner = width - 4;
74
+ const withLogo = inner - LOGO_COLUMN - 3 >= MIN_RIGHT;
75
+ const rightWidth = withLogo ? inner - LOGO_COLUMN - 3 : inner;
76
+
77
+ const right = [
78
+ this.#segments(["model", "effort"], paint),
79
+ this.#segments(["path", "branch"], paint),
80
+ this.#loaded(paint),
81
+ paint.dim("─".repeat(Math.max(0, rightWidth))),
82
+ ...this.#hints(paint, rightWidth),
83
+ ];
84
+ const left = [
85
+ ...LOGO.map((row, index) => paint.effort(LOGO_RAMP[index] ?? "max", row)),
86
+ "",
87
+ paint.italic(paint.dim("ready when you are")),
88
+ ];
89
+
90
+ const rows = Math.max(left.length, right.length);
91
+ const lines = [
92
+ border(width, [{ text: title, priority: 1 }], [], {
93
+ box,
94
+ edge: "top",
95
+ paint: frame,
96
+ separator: " ",
97
+ }),
98
+ ];
99
+ for (let row = 0; row < rows; row++) {
100
+ const content = withLogo
101
+ ? `${center(left[row] ?? "", LOGO_COLUMN)} ${frame("│")} ${pad(right[row] ?? "", rightWidth)}`
102
+ : pad(right[row] ?? "", rightWidth);
103
+ lines.push(`${frame(box.vertical)} ${content} ${frame(box.vertical)}`);
104
+ }
105
+ lines.push(border(width, [], [], { box, edge: "bottom", paint: frame, separator: " " }));
106
+ lines.push("");
107
+ return lines.map((line) => truncateToWidth(line, width, ""));
108
+ }
109
+
110
+ #compact(width: number): string[] {
111
+ const paint = this.#screen.paint();
112
+ const separator = paint.dim(this.#screen.separator());
113
+ const first = [this.#title(paint), this.#segments(["model", "effort", "path", "branch"], paint)]
114
+ .filter((part) => part !== "")
115
+ .join(separator);
116
+ const hints = HINTS()
117
+ .map((hint) => `${paint.role("brand", hint.key)} ${paint.dim(hint.does)}`)
118
+ .join(separator);
119
+ return [first, hints, ""].map((line) => truncateToWidth(` ${line}`, width, "…"));
120
+ }
121
+
122
+ #title(paint: Paint): string {
123
+ const glyph = paint.bold(paint.role("brand", "π"));
124
+ return `${glyph} ${paint.bold("pi")} ${paint.dim(`v${this.#screen.snapshot().version}`)}`;
125
+ }
126
+
127
+ #segments(ids: readonly SegmentId[], paint: Paint): string {
128
+ const pieces = renderSegments(ids, {
129
+ snapshot: this.#screen.snapshot(),
130
+ glyphs: this.#screen.glyphs(),
131
+ paint,
132
+ options: this.#screen.options(),
133
+ });
134
+ return pieces.map((piece) => piece.text).join(paint.dim(this.#screen.separator()));
135
+ }
136
+
137
+ #loaded(paint: Paint): string {
138
+ const counts = this.#source.counts();
139
+ const parts = [
140
+ this.#segments(["host"], paint),
141
+ ...[
142
+ [counts.tools, "tools"],
143
+ [counts.skills, "skills"],
144
+ [counts.prompts, "prompts"],
145
+ [counts.extensions, "extensions"],
146
+ ]
147
+ .filter(([value]) => (value as number) > 0)
148
+ .map(([value, label]) => `${paint.text(String(value))} ${paint.dim(String(label))}`),
149
+ ];
150
+ return parts.filter((part) => part !== "").join(paint.dim(this.#screen.separator()));
151
+ }
152
+
153
+ /** Three rows of two hints each, or one column when narrow. */
154
+ #hints(paint: Paint, width: number): string[] {
155
+ const hints = HINTS();
156
+ const keyWidth = Math.max(...hints.map((hint) => visibleWidth(hint.key)));
157
+ const cell = (hint: Hint) =>
158
+ `${paint.role("brand", hint.key.padEnd(keyWidth))} ${paint.dim(hint.does)}`;
159
+ const cellWidth = keyWidth + 1 + Math.max(...hints.map((hint) => visibleWidth(hint.does)));
160
+ if (width < cellWidth * 2 + 3) return hints.slice(0, 3).map(cell);
161
+ const rows: string[] = [];
162
+ for (let index = 0; index < hints.length; index += 2) {
163
+ const first = hints[index];
164
+ const second = hints[index + 1];
165
+ if (first === undefined) break;
166
+ rows.push(
167
+ second === undefined ? cell(first) : `${pad(cell(first), cellWidth)} ${cell(second)}`,
168
+ );
169
+ }
170
+ return rows;
171
+ }
172
+ }
173
+
174
+ /** Read on every render, so a remapped key shows the new binding. */
175
+ function HINTS(): Hint[] {
176
+ return [
177
+ { key: keyText("app.model.select") || "/model", does: "model" },
178
+ { key: keyText("app.thinking.cycle") || "/thinking", does: "effort" },
179
+ { key: "/", does: "commands" },
180
+ { key: "!", does: "shell" },
181
+ { key: keyText("app.editor.external") || "/editor", does: "external editor" },
182
+ { key: "/tree", does: "session history" },
183
+ ];
184
+ }
185
+
186
+ export function halfBlocks(pixels: readonly string[]): string[] {
187
+ const rows: string[] = [];
188
+ for (let row = 0; row < pixels.length; row += 2) {
189
+ const top = pixels[row] ?? "";
190
+ const bottom = pixels[row + 1] ?? "";
191
+ let line = "";
192
+ for (let column = 0; column < Math.max(top.length, bottom.length); column++) {
193
+ const up = top[column] === "#";
194
+ const down = bottom[column] === "#";
195
+ line += up && down ? "█" : up ? "▀" : down ? "▄" : " ";
196
+ }
197
+ rows.push(line);
198
+ }
199
+ return rows;
200
+ }
201
+
202
+ function pad(text: string, width: number): string {
203
+ const clipped = truncateToWidth(text, Math.max(0, width), "…");
204
+ return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
205
+ }
206
+
207
+ function center(text: string, width: number): string {
208
+ const clipped = truncateToWidth(text, width, "");
209
+ const room = Math.max(0, width - visibleWidth(clipped));
210
+ const before = Math.floor(room / 2);
211
+ return `${" ".repeat(before)}${clipped}${" ".repeat(room - before)}`;
212
+ }
@@ -0,0 +1,39 @@
1
+ // What the components read. The feature implements it over the live session and the settings; tests
2
+ // implement it over a fixed snapshot. Everything is read at render time, so a setting or theme change
3
+ // shows on the next frame with nothing to invalidate.
4
+ import type { Snapshot } from "../data/snapshot.ts";
5
+ import type { Piece } from "../render/fit.ts";
6
+ import type { FrameStyle } from "../render/frame.ts";
7
+ import type { Glyphs } from "../render/glyphs.ts";
8
+ import type { Paint } from "../render/paint.ts";
9
+ import { renderSegments, type SegmentId, type SegmentOptions } from "../render/segments.ts";
10
+ import type { CursorStyle } from "../settings.ts";
11
+
12
+ export type SlotName = "strip" | "topLeft" | "topRight" | "bottomLeft" | "bottomRight" | "below";
13
+
14
+ export interface Screen {
15
+ snapshot(): Snapshot;
16
+ glyphs(): Glyphs;
17
+ /** Paints with the active theme. `frame` overrides the border colour, which the editor owns. */
18
+ paint(frame?: (text: string) => string): Paint;
19
+ separator(): string;
20
+ options(): SegmentOptions;
21
+ slot(name: SlotName): readonly SegmentId[];
22
+ frameStyle(): FrameStyle | "off";
23
+ cursor(): CursorStyle;
24
+ }
25
+
26
+ /** The rendered pieces of one slot. The separator is painted here, once, for everyone. */
27
+ export function slotPieces(
28
+ screen: Screen,
29
+ name: SlotName,
30
+ snapshot: Snapshot,
31
+ paint: Paint,
32
+ ): Piece[] {
33
+ return renderSegments(screen.slot(name), {
34
+ snapshot,
35
+ glyphs: screen.glyphs(),
36
+ paint,
37
+ options: screen.options(),
38
+ });
39
+ }
@@ -0,0 +1,73 @@
1
+ // What the spinner says. Pi's says "Working" for the whole run; this one says what is happening: the
2
+ // newest line of the model's thinking, the tool call it is writing, the command running. It only
3
+ // speaks through pi's public `setWorkingMessage`, so pi still owns the spinner and where it sits.
4
+
5
+ export type Activity =
6
+ | { kind: "waiting" }
7
+ | { kind: "thinking"; tail: string }
8
+ | { kind: "writing" }
9
+ | { kind: "calling"; tool: string }
10
+ | { kind: "running"; tool: string; detail: string };
11
+
12
+ /** One line, plain text: pi paints the message itself. */
13
+ export function describe(activity: Activity): string {
14
+ switch (activity.kind) {
15
+ case "waiting":
16
+ return "Waiting";
17
+ case "thinking":
18
+ return activity.tail === "" ? "Thinking" : `Thinking · ${activity.tail}`;
19
+ case "writing":
20
+ return "Writing";
21
+ case "calling":
22
+ return `Calling ${activity.tool}`;
23
+ case "running":
24
+ return activity.detail === "" ? activity.tool : `${activity.tool} · ${activity.detail}`;
25
+ }
26
+ }
27
+
28
+ interface ContentPart {
29
+ type?: unknown;
30
+ thinking?: unknown;
31
+ name?: unknown;
32
+ }
33
+
34
+ /** The newest line of the thinking so far. Model output, so control characters go. */
35
+ export function thinkingTail(content: unknown): string {
36
+ if (!Array.isArray(content)) return "";
37
+ for (let index = content.length - 1; index >= 0; index--) {
38
+ const part = content[index] as ContentPart | undefined;
39
+ if (part?.type !== "thinking" || typeof part.thinking !== "string") continue;
40
+ const lines = part.thinking.split("\n").map(clean).filter(Boolean);
41
+ return lines.at(-1) ?? "";
42
+ }
43
+ return "";
44
+ }
45
+
46
+ /** The name of the tool call being written, when the model is writing one. */
47
+ export function callingTool(content: unknown): string | undefined {
48
+ if (!Array.isArray(content)) return undefined;
49
+ const part = content.at(-1) as ContentPart | undefined;
50
+ return part?.type === "toolCall" && typeof part.name === "string" ? part.name : undefined;
51
+ }
52
+
53
+ const DETAIL_KEYS = ["command", "path", "file_path", "pattern", "query", "url", "prompt"];
54
+
55
+ /** The one argument that says what a tool is doing: the command, the file, the pattern. */
56
+ export function toolDetail(args: unknown): string {
57
+ if (typeof args !== "object" || args === null) return "";
58
+ const record = args as Record<string, unknown>;
59
+ for (const key of DETAIL_KEYS) {
60
+ const value = record[key];
61
+ if (typeof value === "string" && value.trim() !== "") {
62
+ return clean(value.split("\n").find((line) => line.trim() !== "") ?? "");
63
+ }
64
+ }
65
+ return "";
66
+ }
67
+
68
+ // oxlint-disable-next-line no-control-regex -- escape sequences and control characters are the target
69
+ const CONTROL = /\x1b\[[0-?]*[ -/]*[@-~]|[\x00-\x1f\x7f]/g;
70
+
71
+ function clean(text: string): string {
72
+ return text.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
73
+ }