pi-tui-footer 1.2.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,244 @@
1
+ import { VERSION, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Component, TUI } from "@earendil-works/pi-tui";
3
+ import {
4
+ center,
5
+ collectPiCommandNames,
6
+ formatCwd,
7
+ formatModelLabel,
8
+ formatThinkingLabel,
9
+ headerColumnWidths,
10
+ padRight,
11
+ pickSlashCommandTips,
12
+ truncateToWidth,
13
+ visibleWidth,
14
+ } from "./utils.ts";
15
+
16
+ const LOGO_CELL = "███";
17
+
18
+ type LogoColor = "panel" | "cyan" | "red" | "green" | "orange" | "white" | "flash" | "brand";
19
+ type LogoFrame = { phase: number; active: "left" | "top" | "right" | "none"; ax: number; ay: number; flash: boolean; white: boolean };
20
+
21
+ const LOGO_FRAMES: LogoFrame[] = [
22
+ ...Array.from({ length: 4 }, (_, ay) => ({ phase: 0, active: "left" as const, ax: 2, ay, flash: false, white: false })),
23
+ ...Array.from({ length: 3 }, (_, ay) => ({ phase: 1, active: "top" as const, ax: 2, ay, flash: false, white: false })),
24
+ ...Array.from({ length: 5 }, (_, ay) => ({ phase: 2, active: "right" as const, ax: 5, ay, flash: false, white: false })),
25
+ { phase: 3, active: "none", ax: 0, ay: 0, flash: false, white: false },
26
+ { phase: 3, active: "none", ax: 0, ay: 0, flash: true, white: false },
27
+ { phase: 3, active: "none", ax: 0, ay: 0, flash: false, white: false },
28
+ { phase: 3, active: "none", ax: 0, ay: 0, flash: true, white: false },
29
+ { phase: 4, active: "none", ax: 0, ay: 0, flash: false, white: false },
30
+ { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: false },
31
+ { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: true },
32
+ { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: false },
33
+ { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: true },
34
+ { phase: 6, active: "none", ax: 0, ay: 0, flash: false, white: false },
35
+ ];
36
+
37
+ function hasCell(y: number, x: number, cells: string): boolean {
38
+ return cells.split(" ").includes(`${y},${x}`);
39
+ }
40
+
41
+ function hasPiece(y: number, x: number, py: number, px: number, cells: string): boolean {
42
+ return cells.split(" ").some((item) => {
43
+ const [dy, dx] = item.split(",").map(Number);
44
+ return y === py + dy && x === px + dx;
45
+ });
46
+ }
47
+
48
+ function logoCellColor(frame: LogoFrame, y: number, x: number): LogoColor {
49
+ if (frame.white) {
50
+ return hasCell(y, x, "3,2 3,3 3,4 4,2 4,4 5,2 5,3 5,5 6,2 6,5") ? "white" : "panel";
51
+ }
52
+ if (frame.flash && y === 6 && x >= 1 && x <= 6) return "flash";
53
+
54
+ switch (frame.active) {
55
+ case "left":
56
+ if (hasPiece(y, x, frame.ay, frame.ax, "0,0 1,0 1,1 2,0")) return "red";
57
+ break;
58
+ case "top":
59
+ if (hasPiece(y, x, frame.ay, frame.ax, "0,0 0,1 0,2 1,2")) return "cyan";
60
+ break;
61
+ case "right":
62
+ if (hasPiece(y, x, frame.ay, frame.ax, "0,0 1,0 2,0 2,1")) return "green";
63
+ break;
64
+ }
65
+
66
+ if (frame.phase === 6) {
67
+ return hasCell(y, x, "3,2 3,3 3,4 4,4 4,2 5,2 5,3 5,5 6,2 6,5") ? "brand" : "panel";
68
+ }
69
+ if (frame.phase === 4) {
70
+ if (hasCell(y, x, "2,2 2,3 2,4 3,4")) return "cyan";
71
+ if (hasCell(y, x, "3,2 4,2 4,3 5,2")) return "red";
72
+ if (hasCell(y, x, "4,5 5,5")) return "green";
73
+ return "panel";
74
+ }
75
+ if (frame.phase >= 5) {
76
+ if (hasCell(y, x, "3,2 3,3 3,4 4,4")) return "cyan";
77
+ if (hasCell(y, x, "4,2 5,2 5,3 6,2")) return "red";
78
+ if (hasCell(y, x, "5,5 6,5")) return "green";
79
+ return "panel";
80
+ }
81
+ if (frame.phase <= 3 && hasCell(y, x, "6,1 6,2 6,3 6,4")) return "orange";
82
+ if (frame.phase >= 2 && hasCell(y, x, "2,2 2,3 2,4 3,4")) return "cyan";
83
+ if (frame.phase >= 1 && hasCell(y, x, "3,2 4,2 4,3 5,2")) return "red";
84
+ if (frame.phase >= 3 && hasCell(y, x, "4,5 5,5 6,5 6,6")) return "green";
85
+ return "panel";
86
+ }
87
+
88
+ function colorCell(color: LogoColor, paintBrand: (text: string) => string): string {
89
+ switch (color) {
90
+ case "cyan": return `\x1b[36m${LOGO_CELL}\x1b[39m`;
91
+ case "red": return `\x1b[31m${LOGO_CELL}\x1b[39m`;
92
+ case "green": return `\x1b[32m${LOGO_CELL}\x1b[39m`;
93
+ case "orange":
94
+ case "flash": return `\x1b[33m${LOGO_CELL}\x1b[39m`;
95
+ case "white": return `\x1b[39m${LOGO_CELL}`;
96
+ case "brand": return paintBrand(LOGO_CELL);
97
+ default: return " ".repeat(LOGO_CELL.length);
98
+ }
99
+ }
100
+
101
+ function renderLogo(frameIndex: number, paintBrand: (text: string) => string): string[] {
102
+ const frame = LOGO_FRAMES[frameIndex % LOGO_FRAMES.length]!;
103
+ const grid: LogoColor[][] = [];
104
+ for (let y = 1; y <= 7; y++) {
105
+ const row: LogoColor[] = [];
106
+ for (let x = 1; x <= 8; x++) row.push(logoCellColor(frame, y, x));
107
+ grid.push(row);
108
+ }
109
+
110
+ let minX = 7;
111
+ let maxX = 0;
112
+ for (const row of grid) {
113
+ row.forEach((cell, x) => {
114
+ if (cell !== "panel") {
115
+ minX = Math.min(minX, x);
116
+ maxX = Math.max(maxX, x);
117
+ }
118
+ });
119
+ }
120
+ if (maxX < minX) { minX = 0; maxX = 7; }
121
+
122
+ return grid.map((row) => {
123
+ let line = "";
124
+ for (let x = minX; x <= maxX; x++) line += colorCell(row[x]!, paintBrand);
125
+ return line;
126
+ });
127
+ }
128
+
129
+ function borderLine(
130
+ left: string,
131
+ label: string,
132
+ right: string,
133
+ width: number,
134
+ paint: (text: string) => string,
135
+ ): string {
136
+ if (width <= 1) return "";
137
+ if (width < 8 || label.length === 0) {
138
+ return paint(truncateToWidth(left + "─".repeat(Math.max(0, width - 2)) + right, width, ""));
139
+ }
140
+
141
+ const before = "─── ";
142
+ const after = " ─────";
143
+ const fixedWidth = visibleWidth(before) + visibleWidth(label) + visibleWidth(after);
144
+ const fill = Math.max(0, width - 2 - fixedWidth);
145
+ return `${paint(left)}${paint(before)}${label}${paint(after)}${paint("─".repeat(fill))}${paint(right)}`;
146
+ }
147
+
148
+ function boxedLine(content: string, width: number, paint: (text: string) => string): string {
149
+ if (width <= 2) return truncateToWidth(content, width, "");
150
+ return `${paint("│")}${padRight(content, width - 2)}${paint("│")}`;
151
+ }
152
+
153
+ function twoColumn(
154
+ left: string,
155
+ right: string,
156
+ leftWidth: number,
157
+ rightWidth: number,
158
+ paint: (text: string) => string,
159
+ ): string {
160
+ return `${padRight(left, leftWidth)} ${paint("│")} ${padRight(right, rightWidth, "…")}`;
161
+ }
162
+
163
+ export class OpenTuiHeader implements Component {
164
+ private readonly pi: ExtensionAPI;
165
+ private readonly ctx: ExtensionContext;
166
+ private readonly frame = LOGO_FRAMES.length - 1;
167
+ private readonly tipCommands: string[];
168
+
169
+ constructor(pi: ExtensionAPI, ctx: ExtensionContext, _tui: TUI) {
170
+ this.pi = pi;
171
+ this.ctx = ctx;
172
+ const pool = collectPiCommandNames(pi.getCommands());
173
+ this.tipCommands = pickSlashCommandTips(pool, {
174
+ fixed: ["pi-tui"],
175
+ count: 3,
176
+ });
177
+ }
178
+
179
+ render(width: number): string[] {
180
+ const theme = this.ctx.ui.theme;
181
+ const paint = (s: string) => theme.fg("accent", s);
182
+ const muted = (s: string) => theme.fg("muted", s);
183
+ const dim = (s: string) => theme.fg("dim", s);
184
+ const bold = (s: string) => theme.bold(s);
185
+
186
+ if (width < 24) return [paint(`Pi v${VERSION}`)];
187
+
188
+ const innerWidth = width - 2;
189
+ const { leftWidth, rightWidth, useTips } = headerColumnWidths(innerWidth);
190
+ const model = formatModelLabel(this.ctx.model);
191
+ const effort = formatThinkingLabel(this.pi.getThinkingLevel());
192
+ const cwd = formatCwd(this.ctx.cwd);
193
+
194
+ const leftLines = [
195
+ ...renderLogo(this.frame, paint).map((line) => center(line, leftWidth)),
196
+ center(bold("Let's build something great"), leftWidth),
197
+ center(muted(`${model} · ${effort}`), leftWidth),
198
+ center(dim(cwd), leftWidth),
199
+ ];
200
+
201
+ const tipDivider = paint("─".repeat(Math.max(8, Math.min(rightWidth, 22))));
202
+ const [cmd0 = "", cmd1 = "", cmd2 = "", cmd3 = ""] = this.tipCommands;
203
+ const tipLines = [
204
+ "",
205
+ paint(bold("Welcome")),
206
+ muted("Ask Pi anything"),
207
+ tipDivider,
208
+ paint(bold("Commands")),
209
+ muted(cmd0),
210
+ muted(cmd1),
211
+ muted(cmd2),
212
+ muted(cmd3),
213
+ "",
214
+ ];
215
+
216
+ const lines = [borderLine("╭", `${paint("Pi")} v${VERSION}`, "╮", width, paint)];
217
+ for (let i = 0; i < leftLines.length; i++) {
218
+ const content = useTips
219
+ ? twoColumn(leftLines[i] ?? "", tipLines[i] ?? "", leftWidth, rightWidth, paint)
220
+ : padRight(leftLines[i] ?? "", leftWidth);
221
+ lines.push(boxedLine(content, width, paint));
222
+ }
223
+ lines.push(borderLine("╰", "", "╯", width, paint));
224
+ return lines.map((line) => truncateToWidth(line, width, ""));
225
+ }
226
+
227
+ invalidate(): void {}
228
+
229
+ dispose(): void {}
230
+ }
231
+
232
+ export function installHeader(pi: ExtensionAPI, ctx: ExtensionContext): () => void {
233
+ let header: OpenTuiHeader | undefined;
234
+ ctx.ui.setHeader((tui) => {
235
+ header?.dispose();
236
+ header = new OpenTuiHeader(pi, ctx, tui);
237
+ return header;
238
+ });
239
+ return () => {
240
+ header?.dispose();
241
+ header = undefined;
242
+ ctx.ui.setHeader(undefined);
243
+ };
244
+ }
@@ -0,0 +1,190 @@
1
+ export type IconMode = "auto" | "nerd" | "ascii";
2
+
3
+ export interface IconGlyphs {
4
+ cwd: string;
5
+ host: string;
6
+ session: string;
7
+ git: string;
8
+ working: string;
9
+ done: string;
10
+ context: string;
11
+ model: string;
12
+ thinking: string;
13
+ input: string;
14
+ output: string;
15
+ cacheHit: string;
16
+ cost: string;
17
+ speed: string;
18
+ latency: string;
19
+ stall: string;
20
+ extensions: string;
21
+ ahead: string;
22
+ behind: string;
23
+ diverged: string;
24
+ conflicted: string;
25
+ stashed: string;
26
+ modified: string;
27
+ staged: string;
28
+ untracked: string;
29
+ renamed: string;
30
+ deleted: string;
31
+ }
32
+
33
+ const NERD_GLYPHS: IconGlyphs = {
34
+ cwd: "",
35
+ host: "",
36
+ session: "",
37
+ git: "",
38
+ working: "",
39
+ done: "",
40
+ context: "",
41
+ model: "",
42
+ thinking: "",
43
+ // client network view: input = upload to API, output = download from API
44
+ input: "",
45
+ output: "",
46
+ cacheHit: "",
47
+ cost: "",
48
+ speed: "󰓅",
49
+ latency: "",
50
+ stall: "",
51
+ extensions: "",
52
+ ahead: "↑",
53
+ behind: "↓",
54
+ diverged: "⇕",
55
+ conflicted: "=",
56
+ stashed: "$",
57
+ modified: "!",
58
+ staged: "+",
59
+ untracked: "?",
60
+ renamed: "»",
61
+ deleted: "✘",
62
+ };
63
+
64
+ // ponytail: ASCII fallback uses compact symbols (not English words) to keep
65
+ // the footer's icon-like feel on non-Nerd-Font terminals. Symbols chosen to
66
+ // avoid collisions with the git-status set {= S ! A ? r x ^ v}.
67
+ const ASCII_GLYPHS: IconGlyphs = {
68
+ cwd: "@",
69
+ host: "h",
70
+ session: "s",
71
+ git: "*",
72
+ working: "o",
73
+ done: "+",
74
+ context: "#",
75
+ model: "M",
76
+ thinking: "~",
77
+ input: "↑",
78
+ output: "↓",
79
+ cacheHit: "c",
80
+ cost: "$",
81
+ speed: ">",
82
+ latency: "~",
83
+ stall: "!",
84
+ extensions: "&",
85
+ ahead: "^",
86
+ behind: "v",
87
+ diverged: "^v",
88
+ conflicted: "=",
89
+ stashed: "S",
90
+ modified: "!",
91
+ staged: "A",
92
+ untracked: "?",
93
+ renamed: "r",
94
+ deleted: "x",
95
+ };
96
+
97
+ export function detectNerdFont(): boolean {
98
+ // TTY and locale checks are the only reliable signals available here. The
99
+ // terminal emulator owns font selection, so auto mode is optimistic once
100
+ // output is an interactive UTF-8 TTY.
101
+ if (process.env.TERM === "dumb" || process.stdout.isTTY !== true) return false;
102
+ const locale = [process.env.LC_ALL, process.env.LC_CTYPE, process.env.LANG].find(Boolean);
103
+ return locale === undefined || /utf-?8/i.test(locale);
104
+ }
105
+
106
+ export function resolveIconMode(mode: IconMode): "nerd" | "ascii" {
107
+ if (mode === "nerd") return "nerd";
108
+ if (mode === "ascii") return "ascii";
109
+ return detectNerdFont() ? "nerd" : "ascii";
110
+ }
111
+
112
+ export function resolveGlyphs(mode: IconMode): IconGlyphs {
113
+ const resolved = resolveIconMode(mode);
114
+ return resolved === "nerd" ? NERD_GLYPHS : ASCII_GLYPHS;
115
+ }
116
+
117
+ const RUNTIME_SYMBOLS: Record<string, string> = {
118
+ nodejs: "\uE718",
119
+ rust: "\uE7A8",
120
+ go: "\uE626",
121
+ python: "\uE73C",
122
+ ruby: "\uE739",
123
+ java: "\uE256",
124
+ cpp: "\uE61D",
125
+ c: "\uE61E",
126
+ swift: "\uE755",
127
+ kotlin: "\uE634",
128
+ deno: "\uE7FB",
129
+ bun: "\uE76F",
130
+ php: "\uE73D",
131
+ haskell: "\uE777",
132
+ julia: "\uE624",
133
+ lua: "\uE620",
134
+ elixir: "\uE62B",
135
+ erlang: "\uE7B1",
136
+ gleam: "\uE6B4",
137
+ crystal: "\uE62F",
138
+ dart: "\uE7C0",
139
+ nim: "\uE677",
140
+ zig: "\uE6A9",
141
+ ocaml: "\uE67A",
142
+ clojure: "\uE76A",
143
+ scala: "\uE747",
144
+ perl: "\uE769",
145
+ r: "\uE68A",
146
+ elm: "\uE62C",
147
+ haxe: "\uE7B7",
148
+ vagrant: "\uE21A",
149
+ terraform: "\uE1A5",
150
+ };
151
+
152
+ const RUNTIME_ASCII_SYMBOLS: Record<string, string> = {
153
+ nodejs: "node",
154
+ rust: "rs",
155
+ go: "go",
156
+ python: "py",
157
+ ruby: "rb",
158
+ java: "java",
159
+ swift: "swift",
160
+ kotlin: "kt",
161
+ cpp: "c++",
162
+ c: "c",
163
+ deno: "deno",
164
+ bun: "bun",
165
+ php: "php",
166
+ haskell: "hs",
167
+ julia: "jl",
168
+ lua: "lua",
169
+ elixir: "ex",
170
+ erlang: "erl",
171
+ gleam: "gleam",
172
+ crystal: "cr",
173
+ dart: "dart",
174
+ nim: "nim",
175
+ zig: "zig",
176
+ ocaml: "ml",
177
+ clojure: "clj",
178
+ scala: "scala",
179
+ perl: "pl",
180
+ r: "R",
181
+ elm: "elm",
182
+ haxe: "hx",
183
+ vagrant: "vag",
184
+ terraform: "tf",
185
+ };
186
+
187
+ export function runtimeSymbol(name: string, mode: IconMode): string {
188
+ if (resolveIconMode(mode) === "ascii") return RUNTIME_ASCII_SYMBOLS[name] ?? name;
189
+ return RUNTIME_SYMBOLS[name] ?? "";
190
+ }