pi-open-tui 0.1.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,154 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const GIT_TIMEOUT_MS = 2000;
8
+
9
+ export interface GitCommitInfo {
10
+ oid: string | null;
11
+ detached: boolean;
12
+ tag: string | null;
13
+ }
14
+
15
+ export interface GitStatus {
16
+ branch: string | undefined;
17
+ ahead: number;
18
+ behind: number;
19
+ modified: number;
20
+ untracked: number;
21
+ staged: number;
22
+ stashed: number;
23
+ conflicted: number;
24
+ renamed: number;
25
+ deleted: number;
26
+ commit: GitCommitInfo | null;
27
+ }
28
+
29
+ export function emptyGitStatus(): GitStatus {
30
+ return {
31
+ branch: undefined,
32
+ ahead: 0,
33
+ behind: 0,
34
+ modified: 0,
35
+ untracked: 0,
36
+ staged: 0,
37
+ stashed: 0,
38
+ conflicted: 0,
39
+ renamed: 0,
40
+ deleted: 0,
41
+ commit: null,
42
+ };
43
+ }
44
+
45
+ async function gitExec(args: string[], cwd: string): Promise<string | null> {
46
+ try {
47
+ const { stdout } = await execFileAsync("git", args, {
48
+ cwd,
49
+ timeout: GIT_TIMEOUT_MS,
50
+ maxBuffer: 1024 * 1024,
51
+ });
52
+ return stdout;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ export async function readGitStatus(
59
+ cwd: string,
60
+ options: { readCommit?: boolean; readTag?: boolean } = {},
61
+ ): Promise<GitStatus> {
62
+ if (!existsSync(join(cwd, ".git"))) {
63
+ return emptyGitStatus();
64
+ }
65
+
66
+ const stdout = await gitExec(
67
+ ["status", "--porcelain=v1", "--branch", "--show-stash"],
68
+ cwd,
69
+ );
70
+ if (stdout === null) {
71
+ return emptyGitStatus();
72
+ }
73
+
74
+ const status = emptyGitStatus();
75
+ const lines = stdout.split("\n");
76
+ let stashSupported = true;
77
+
78
+ for (const line of lines) {
79
+ if (line.startsWith("## ")) {
80
+ const branchPart = line.slice(3);
81
+ const detached = branchPart.startsWith("HEAD (no branch)");
82
+ if (detached) {
83
+ status.branch = undefined;
84
+ status.commit = { oid: null, detached: true, tag: null };
85
+ } else {
86
+ const branchMatch = branchPart.match(/^(\S+?)(?:\.\.\.(\S+))?(?:\s+\[(ahead|behind) (\d+)\])?/);
87
+ if (branchMatch) {
88
+ status.branch = branchMatch[1];
89
+ if (branchMatch[3] === "ahead") status.ahead = parseInt(branchMatch[4]!, 10);
90
+ if (branchMatch[3] === "behind") status.behind = parseInt(branchMatch[4]!, 10);
91
+ }
92
+ }
93
+ continue;
94
+ }
95
+
96
+ if (line.startsWith("# stash ")) {
97
+ const stashCount = parseInt(line.slice(8).trim(), 10);
98
+ if (!Number.isNaN(stashCount)) {
99
+ status.stashed = stashCount;
100
+ stashSupported = true;
101
+ }
102
+ continue;
103
+ }
104
+
105
+ if (line.length < 3) continue;
106
+ const x = line[0]!;
107
+ const y = line[1]!;
108
+
109
+ if (x === "U" || y === "U" || (x === "C" && y === "C")) status.conflicted++;
110
+ else if (x === "?" && y === "?") status.untracked++;
111
+ else if (x === "R") status.renamed++;
112
+ else if (x === "D" || y === "D") status.deleted++;
113
+ else {
114
+ if (x !== " " && x !== "?") status.staged++;
115
+ if (y === "M" || y === "D") status.modified++;
116
+ }
117
+ }
118
+
119
+ if (stashSupported && status.stashed === 0 && !stdout.includes("# stash")) {
120
+ const stashOut = await gitExec(["stash", "list", "--count"], cwd);
121
+ if (stashOut !== null) {
122
+ const count = parseInt(stashOut.trim(), 10);
123
+ if (!Number.isNaN(count)) status.stashed = count;
124
+ }
125
+ }
126
+
127
+ if (options.readCommit && status.commit?.detached) {
128
+ const oid = await gitExec(["rev-parse", "HEAD"], cwd);
129
+ if (oid) {
130
+ status.commit.oid = oid.trim();
131
+ }
132
+ if (options.readTag) {
133
+ const tag = await gitExec(["describe", "--tags", "--exact-match", "HEAD"], cwd);
134
+ if (tag) {
135
+ status.commit.tag = tag.trim();
136
+ }
137
+ }
138
+ }
139
+
140
+ return status;
141
+ }
142
+
143
+ export function hasGitChanges(s: GitStatus): boolean {
144
+ return (
145
+ s.modified > 0 ||
146
+ s.untracked > 0 ||
147
+ s.staged > 0 ||
148
+ s.conflicted > 0 ||
149
+ s.renamed > 0 ||
150
+ s.deleted > 0 ||
151
+ s.ahead > 0 ||
152
+ s.behind > 0
153
+ );
154
+ }
@@ -0,0 +1,177 @@
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 { center, truncateToWidth } from "./utils.ts";
4
+
5
+ const LOGO_CELL = "███";
6
+ const LOGO_ANIMATION_MS = 80;
7
+
8
+ type LogoColor = "panel" | "cyan" | "red" | "green" | "orange" | "white" | "flash" | "brand";
9
+ type LogoFrame = { phase: number; active: "left" | "top" | "right" | "none"; ax: number; ay: number; flash: boolean; white: boolean };
10
+
11
+ const LOGO_FRAMES: LogoFrame[] = [
12
+ ...Array.from({ length: 4 }, (_, ay) => ({ phase: 0, active: "left" as const, ax: 2, ay, flash: false, white: false })),
13
+ ...Array.from({ length: 3 }, (_, ay) => ({ phase: 1, active: "top" as const, ax: 2, ay, flash: false, white: false })),
14
+ ...Array.from({ length: 5 }, (_, ay) => ({ phase: 2, active: "right" as const, ax: 5, ay, flash: false, white: false })),
15
+ { phase: 3, active: "none", ax: 0, ay: 0, flash: false, white: false },
16
+ { phase: 3, active: "none", ax: 0, ay: 0, flash: true, white: false },
17
+ { phase: 3, active: "none", ax: 0, ay: 0, flash: false, white: false },
18
+ { phase: 3, active: "none", ax: 0, ay: 0, flash: true, white: false },
19
+ { phase: 4, active: "none", ax: 0, ay: 0, flash: false, white: false },
20
+ { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: false },
21
+ { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: true },
22
+ { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: false },
23
+ { phase: 5, active: "none", ax: 0, ay: 0, flash: false, white: true },
24
+ { phase: 6, active: "none", ax: 0, ay: 0, flash: false, white: false },
25
+ ];
26
+
27
+ function hasCell(y: number, x: number, cells: string): boolean {
28
+ return cells.split(" ").includes(`${y},${x}`);
29
+ }
30
+
31
+ function hasPiece(y: number, x: number, py: number, px: number, cells: string): boolean {
32
+ return cells.split(" ").some((item) => {
33
+ const [dy, dx] = item.split(",").map(Number);
34
+ return y === py + dy && x === px + dx;
35
+ });
36
+ }
37
+
38
+ function logoCellColor(frame: LogoFrame, y: number, x: number): LogoColor {
39
+ if (frame.white) {
40
+ 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";
41
+ }
42
+ if (frame.flash && y === 6 && x >= 1 && x <= 6) return "flash";
43
+
44
+ switch (frame.active) {
45
+ case "left":
46
+ if (hasPiece(y, x, frame.ay, frame.ax, "0,0 1,0 1,1 2,0")) return "red";
47
+ break;
48
+ case "top":
49
+ if (hasPiece(y, x, frame.ay, frame.ax, "0,0 0,1 0,2 1,2")) return "cyan";
50
+ break;
51
+ case "right":
52
+ if (hasPiece(y, x, frame.ay, frame.ax, "0,0 1,0 2,0 2,1")) return "green";
53
+ break;
54
+ }
55
+
56
+ if (frame.phase === 6) {
57
+ 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";
58
+ }
59
+ if (frame.phase === 4) {
60
+ if (hasCell(y, x, "2,2 2,3 2,4 3,4")) return "cyan";
61
+ if (hasCell(y, x, "3,2 4,2 4,3 5,2")) return "red";
62
+ if (hasCell(y, x, "4,5 5,5")) return "green";
63
+ return "panel";
64
+ }
65
+ if (frame.phase >= 5) {
66
+ if (hasCell(y, x, "3,2 3,3 3,4 4,4")) return "cyan";
67
+ if (hasCell(y, x, "4,2 5,2 5,3 6,2")) return "red";
68
+ if (hasCell(y, x, "5,5 6,5")) return "green";
69
+ return "panel";
70
+ }
71
+ if (frame.phase <= 3 && hasCell(y, x, "6,1 6,2 6,3 6,4")) return "orange";
72
+ if (frame.phase >= 2 && hasCell(y, x, "2,2 2,3 2,4 3,4")) return "cyan";
73
+ if (frame.phase >= 1 && hasCell(y, x, "3,2 4,2 4,3 5,2")) return "red";
74
+ if (frame.phase >= 3 && hasCell(y, x, "4,5 5,5 6,5 6,6")) return "green";
75
+ return "panel";
76
+ }
77
+
78
+ function colorCell(color: LogoColor, paintBrand: (text: string) => string): string {
79
+ switch (color) {
80
+ case "cyan": return `\x1b[36m${LOGO_CELL}\x1b[39m`;
81
+ case "red": return `\x1b[31m${LOGO_CELL}\x1b[39m`;
82
+ case "green": return `\x1b[32m${LOGO_CELL}\x1b[39m`;
83
+ case "orange":
84
+ case "flash": return `\x1b[33m${LOGO_CELL}\x1b[39m`;
85
+ case "white": return `\x1b[39m${LOGO_CELL}`;
86
+ case "brand": return paintBrand(LOGO_CELL);
87
+ default: return " ".repeat(LOGO_CELL.length);
88
+ }
89
+ }
90
+
91
+ function renderLogo(frameIndex: number, paintBrand: (text: string) => string): string[] {
92
+ const frame = LOGO_FRAMES[frameIndex % LOGO_FRAMES.length]!;
93
+ const grid: LogoColor[][] = [];
94
+ for (let y = 1; y <= 7; y++) {
95
+ const row: LogoColor[] = [];
96
+ for (let x = 1; x <= 8; x++) row.push(logoCellColor(frame, y, x));
97
+ grid.push(row);
98
+ }
99
+
100
+ let minX = 7;
101
+ let maxX = 0;
102
+ for (const row of grid) {
103
+ row.forEach((cell, x) => {
104
+ if (cell !== "panel") {
105
+ minX = Math.min(minX, x);
106
+ maxX = Math.max(maxX, x);
107
+ }
108
+ });
109
+ }
110
+ if (maxX < minX) { minX = 0; maxX = 7; }
111
+
112
+ return grid.map((row) => {
113
+ let line = "";
114
+ for (let x = minX; x <= maxX; x++) line += colorCell(row[x]!, paintBrand);
115
+ return line;
116
+ });
117
+ }
118
+
119
+ export class OpenTuiHeader implements Component {
120
+ private frame = 0;
121
+ private readonly timer: ReturnType<typeof setInterval>;
122
+ private readonly ctx: ExtensionContext;
123
+
124
+ constructor(_pi: ExtensionAPI, ctx: ExtensionContext, tui: TUI) {
125
+ this.ctx = ctx;
126
+ this.timer = setInterval(() => {
127
+ if (this.frame < LOGO_FRAMES.length - 1) {
128
+ this.frame++;
129
+ tui.requestRender();
130
+ } else {
131
+ clearInterval(this.timer);
132
+ }
133
+ }, LOGO_ANIMATION_MS);
134
+ this.timer.unref?.();
135
+ }
136
+
137
+ render(width: number): string[] {
138
+ const theme = this.ctx.ui.theme;
139
+ const paint = (s: string) => theme.fg("accent", s);
140
+ const muted = (s: string) => theme.fg("muted", s);
141
+ const bold = (s: string) => theme.bold(s);
142
+
143
+ if (width < 24) return [paint(`Pi v${VERSION}`)];
144
+
145
+ const lines: string[] = [];
146
+ lines.push(bold(theme.fg("accent", "pi")) + " " + muted(`v${VERSION}`));
147
+ lines.push("");
148
+
149
+ for (const logoLine of renderLogo(this.frame, paint)) {
150
+ lines.push(center(logoLine, width));
151
+ }
152
+
153
+ lines.push(center(bold("Let's build something great"), width));
154
+
155
+ return lines.map((line) => truncateToWidth(line, width, ""));
156
+ }
157
+
158
+ invalidate(): void {}
159
+
160
+ dispose(): void {
161
+ clearInterval(this.timer);
162
+ }
163
+ }
164
+
165
+ export function installHeader(pi: ExtensionAPI, ctx: ExtensionContext): () => void {
166
+ let header: OpenTuiHeader | undefined;
167
+ ctx.ui.setHeader((tui) => {
168
+ header?.dispose();
169
+ header = new OpenTuiHeader(pi, ctx, tui);
170
+ return header;
171
+ });
172
+ return () => {
173
+ header?.dispose();
174
+ header = undefined;
175
+ ctx.ui.setHeader(undefined);
176
+ };
177
+ }
@@ -0,0 +1,194 @@
1
+ export type IconMode = "auto" | "nerd" | "ascii";
2
+
3
+ export interface IconGlyphs {
4
+ cwd: string;
5
+ git: string;
6
+ working: string;
7
+ done: string;
8
+ context: string;
9
+ model: string;
10
+ thinking: string;
11
+ input: string;
12
+ output: string;
13
+ cacheHit: string;
14
+ cost: string;
15
+ extensions: string;
16
+ ahead: string;
17
+ behind: string;
18
+ diverged: string;
19
+ conflicted: string;
20
+ stashed: string;
21
+ modified: string;
22
+ staged: string;
23
+ untracked: string;
24
+ renamed: string;
25
+ deleted: string;
26
+ }
27
+
28
+ const NERD_GLYPHS: IconGlyphs = {
29
+ cwd: "",
30
+ git: "",
31
+ working: "",
32
+ done: "",
33
+ context: "",
34
+ model: "",
35
+ thinking: "",
36
+ input: "",
37
+ output: "",
38
+ cacheHit: "",
39
+ cost: "",
40
+ extensions: "",
41
+ ahead: "↑",
42
+ behind: "↓",
43
+ diverged: "⇕",
44
+ conflicted: "=",
45
+ stashed: "$",
46
+ modified: "!",
47
+ staged: "+",
48
+ untracked: "?",
49
+ renamed: "»",
50
+ deleted: "✘",
51
+ };
52
+
53
+ // ponytail: ASCII fallback uses compact symbols (not English words) to keep
54
+ // the footer's icon-like feel on non-Nerd-Font terminals. Symbols chosen to
55
+ // avoid collisions with the git-status set {= S ! A ? r x ^ v}.
56
+ const ASCII_GLYPHS: IconGlyphs = {
57
+ cwd: "@",
58
+ git: "*",
59
+ working: "o",
60
+ done: "+",
61
+ context: "%",
62
+ model: "M",
63
+ thinking: "~",
64
+ input: "↑",
65
+ output: "↓",
66
+ cacheHit: "c",
67
+ cost: "$",
68
+ extensions: "&",
69
+ ahead: "^",
70
+ behind: "v",
71
+ diverged: "^v",
72
+ conflicted: "=",
73
+ stashed: "S",
74
+ modified: "!",
75
+ staged: "A",
76
+ untracked: "?",
77
+ renamed: "r",
78
+ deleted: "x",
79
+ };
80
+
81
+ const NERD_FONT_TERMINALS = new Set([
82
+ "iTerm.app",
83
+ "Ghostty",
84
+ "WezTerm",
85
+ "kitty",
86
+ "rio",
87
+ "tabby",
88
+ "WindowsTerminal",
89
+ "vscode",
90
+ ]);
91
+
92
+ export function detectNerdFont(): boolean {
93
+ const termProgram = process.env.TERM_PROGRAM;
94
+ if (termProgram && NERD_FONT_TERMINALS.has(termProgram)) return true;
95
+
96
+ const lcTerminal = process.env.LC_TERMINAL;
97
+ if (lcTerminal && NERD_FONT_TERMINALS.has(lcTerminal)) return true;
98
+
99
+ if (process.env.TERM === "xterm-kitty") return true;
100
+
101
+ // Windows Terminal sets WT_SESSION (not TERM_PROGRAM)
102
+ if (process.env.WT_SESSION) return true;
103
+
104
+ // VS Code integrated terminal
105
+ if (process.env.TERM_PROGRAM === "vscode") return true;
106
+
107
+ return false;
108
+ }
109
+
110
+ export function resolveIconMode(mode: IconMode): "nerd" | "ascii" {
111
+ if (mode === "nerd") return "nerd";
112
+ if (mode === "ascii") return "ascii";
113
+ return detectNerdFont() ? "nerd" : "ascii";
114
+ }
115
+
116
+ export function resolveGlyphs(mode: IconMode): IconGlyphs {
117
+ const resolved = resolveIconMode(mode);
118
+ return resolved === "nerd" ? NERD_GLYPHS : ASCII_GLYPHS;
119
+ }
120
+
121
+ const RUNTIME_SYMBOLS: Record<string, string> = {
122
+ nodejs: "\uE718",
123
+ rust: "\uE7A8",
124
+ go: "\uE626",
125
+ python: "\uE73C",
126
+ ruby: "\uE739",
127
+ java: "\uE256",
128
+ cpp: "\uE61D",
129
+ c: "\uE61E",
130
+ swift: "\uE755",
131
+ kotlin: "\uE634",
132
+ deno: "\uE7FB",
133
+ bun: "\uE6FB",
134
+ php: "\uE73D",
135
+ haskell: "\uE777",
136
+ julia: "\uE624",
137
+ lua: "\uE620",
138
+ elixir: "\uE62B",
139
+ erlang: "\uE7B1",
140
+ gleam: "\uE6B4",
141
+ crystal: "\uE62F",
142
+ dart: "\uE7C0",
143
+ nim: "\uE677",
144
+ zig: "\uE6A9",
145
+ ocaml: "\uE67A",
146
+ clojure: "\uE76A",
147
+ scala: "\uE747",
148
+ perl: "\uE769",
149
+ r: "\uE68A",
150
+ elm: "\uE62C",
151
+ haxe: "\uE7B7",
152
+ vagrant: "\uE21A",
153
+ terraform: "\uE1A5",
154
+ };
155
+
156
+ const RUNTIME_ASCII_SYMBOLS: Record<string, string> = {
157
+ nodejs: "node",
158
+ rust: "rs",
159
+ go: "go",
160
+ python: "py",
161
+ ruby: "rb",
162
+ java: "java",
163
+ swift: "swift",
164
+ kotlin: "kt",
165
+ cpp: "c++",
166
+ c: "c",
167
+ deno: "deno",
168
+ bun: "bun",
169
+ php: "php",
170
+ haskell: "hs",
171
+ julia: "jl",
172
+ lua: "lua",
173
+ elixir: "ex",
174
+ erlang: "erl",
175
+ gleam: "gleam",
176
+ crystal: "cr",
177
+ dart: "dart",
178
+ nim: "nim",
179
+ zig: "zig",
180
+ ocaml: "ml",
181
+ clojure: "clj",
182
+ scala: "scala",
183
+ perl: "pl",
184
+ r: "R",
185
+ elm: "elm",
186
+ haxe: "hx",
187
+ vagrant: "vag",
188
+ terraform: "tf",
189
+ };
190
+
191
+ export function runtimeSymbol(name: string, mode: IconMode): string {
192
+ if (resolveIconMode(mode) === "ascii") return RUNTIME_ASCII_SYMBOLS[name] ?? name;
193
+ return RUNTIME_SYMBOLS[name] ?? "";
194
+ }