pi-open-tui 0.3.14 → 0.4.1

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.
@@ -58,7 +58,7 @@ export async function readGitStatus(
58
58
  options: { readCommit?: boolean; readTag?: boolean; readCounts?: boolean } = {},
59
59
  ): Promise<GitStatus> {
60
60
  const stdout = await gitExec(
61
- ["status", "--porcelain=v1", "--branch", "--show-stash"],
61
+ ["status", "--porcelain=v1", "--branch"],
62
62
  cwd,
63
63
  );
64
64
  if (stdout === null) {
@@ -67,7 +67,6 @@ export async function readGitStatus(
67
67
 
68
68
  const status = emptyGitStatus();
69
69
  const lines = stdout.split("\n");
70
- let stashSupported = true;
71
70
 
72
71
  for (const line of lines) {
73
72
  if (line.startsWith("## ")) {
@@ -89,17 +88,7 @@ export async function readGitStatus(
89
88
  continue;
90
89
  }
91
90
 
92
- if (line.startsWith("# stash ")) {
93
- const stashCount = parseInt(line.slice(8).trim(), 10);
94
- if (!Number.isNaN(stashCount)) {
95
- status.stashed = stashCount;
96
- stashSupported = true;
97
- }
98
- continue;
99
- }
100
-
101
- // ponytail: skip file-entry counting when only branch/commit display is on;
102
- // the first `git status` call itself can't be split, but the stash fallback below can.
91
+ // Skip file-entry counting and the separate stash query for branch-only display.
103
92
  if (options.readCounts === false) continue;
104
93
  if (line.length < 3) continue;
105
94
  const x = line[0]!;
@@ -107,19 +96,21 @@ export async function readGitStatus(
107
96
 
108
97
  if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) status.conflicted++;
109
98
  else if (x === "?" && y === "?") status.untracked++;
110
- else if (x === "R") status.renamed++;
111
- else if (x === "D" || y === "D") status.deleted++;
99
+ else if (x === "R") {
100
+ status.renamed++;
101
+ if (y === "M") status.modified++;
102
+ else if (y === "D") status.deleted++;
103
+ } else if (x === "D" || y === "D") status.deleted++;
112
104
  else {
113
105
  if (x !== " " && x !== "?") status.staged++;
114
106
  if (y === "M" || y === "D") status.modified++;
115
107
  }
116
108
  }
117
109
 
118
- if (options.readCounts !== false && stashSupported && status.stashed === 0 && !stdout.includes("# stash")) {
119
- const stashOut = await gitExec(["stash", "list", "--count"], cwd);
110
+ if (options.readCounts !== false) {
111
+ const stashOut = await gitExec(["stash", "list", "--format=%H"], cwd);
120
112
  if (stashOut !== null) {
121
- const count = parseInt(stashOut.trim(), 10);
122
- if (!Number.isNaN(count)) status.stashed = count;
113
+ status.stashed = stashOut.split("\n").filter((line) => line.trim() !== "").length;
123
114
  }
124
115
  }
125
116
 
@@ -138,16 +129,3 @@ export async function readGitStatus(
138
129
 
139
130
  return status;
140
131
  }
141
-
142
- export function hasGitChanges(s: GitStatus): boolean {
143
- return (
144
- s.modified > 0 ||
145
- s.untracked > 0 ||
146
- s.staged > 0 ||
147
- s.conflicted > 0 ||
148
- s.renamed > 0 ||
149
- s.deleted > 0 ||
150
- s.ahead > 0 ||
151
- s.behind > 0
152
- );
153
- }
@@ -1,244 +1,133 @@
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: ["open-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
- }
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 = [
17
+ " ",
18
+ " ",
19
+ "█████████ ",
20
+ "███ ███ ",
21
+ "██████ ███",
22
+ "███ ███",
23
+ " ",
24
+ ];
25
+
26
+ function renderLogo(paintBrand: (text: string) => string): string[] {
27
+ return LOGO.map((line) => line.replace(/███/g, paintBrand));
28
+ }
29
+
30
+ function borderLine(
31
+ left: string,
32
+ label: string,
33
+ right: string,
34
+ width: number,
35
+ paint: (text: string) => string,
36
+ ): string {
37
+ if (width <= 1) return "";
38
+ if (width < 8 || label.length === 0) {
39
+ return paint(truncateToWidth(left + "─".repeat(Math.max(0, width - 2)) + right, width, ""));
40
+ }
41
+
42
+ const before = "─── ";
43
+ const after = " ─────";
44
+ const fixedWidth = visibleWidth(before) + visibleWidth(label) + visibleWidth(after);
45
+ const fill = Math.max(0, width - 2 - fixedWidth);
46
+ return `${paint(left)}${paint(before)}${label}${paint(after)}${paint("─".repeat(fill))}${paint(right)}`;
47
+ }
48
+
49
+ function boxedLine(content: string, width: number, paint: (text: string) => string): string {
50
+ if (width <= 2) return truncateToWidth(content, width, "");
51
+ return `${paint("│")}${padRight(content, width - 2)}${paint("│")}`;
52
+ }
53
+
54
+ function twoColumn(
55
+ left: string,
56
+ right: string,
57
+ leftWidth: number,
58
+ rightWidth: number,
59
+ paint: (text: string) => string,
60
+ ): string {
61
+ return `${padRight(left, leftWidth)} ${paint("│")} ${padRight(right, rightWidth, "…")}`;
62
+ }
63
+
64
+ export class OpenTuiHeader implements Component {
65
+ private readonly pi: ExtensionAPI;
66
+ private readonly ctx: ExtensionContext;
67
+ private readonly tipCommands: string[];
68
+
69
+ constructor(pi: ExtensionAPI, ctx: ExtensionContext, _tui: TUI) {
70
+ this.pi = pi;
71
+ this.ctx = ctx;
72
+ const pool = collectPiCommandNames(pi.getCommands());
73
+ this.tipCommands = pickSlashCommandTips(pool, {
74
+ fixed: ["open-tui"],
75
+ count: 3,
76
+ });
77
+ }
78
+
79
+ render(width: number): string[] {
80
+ const theme = this.ctx.ui.theme;
81
+ const paint = (s: string) => theme.fg("accent", s);
82
+ const muted = (s: string) => theme.fg("muted", s);
83
+ const dim = (s: string) => theme.fg("dim", s);
84
+ const bold = (s: string) => theme.bold(s);
85
+
86
+ if (width < 24) return [truncateToWidth(paint(`Pi v${VERSION}`), width, "")];
87
+
88
+ const innerWidth = width - 2;
89
+ const { leftWidth, rightWidth, useTips } = headerColumnWidths(innerWidth);
90
+ const model = formatModelLabel(this.ctx.model);
91
+ const effort = formatThinkingLabel(this.pi.getThinkingLevel());
92
+ const cwd = formatCwd(this.ctx.cwd);
93
+
94
+ const leftLines = [
95
+ ...renderLogo(paint).map((line) => center(line, leftWidth)),
96
+ center(bold("Let's build something great"), leftWidth),
97
+ center(muted(`${model} · ${effort}`), leftWidth),
98
+ center(dim(cwd), leftWidth),
99
+ ];
100
+
101
+ const tipDivider = paint("─".repeat(Math.max(8, Math.min(rightWidth, 22))));
102
+ const [cmd0 = "", cmd1 = "", cmd2 = "", cmd3 = ""] = this.tipCommands;
103
+ const tipLines = [
104
+ "",
105
+ paint(bold("Welcome")),
106
+ muted("Ask Pi anything"),
107
+ tipDivider,
108
+ paint(bold("Commands")),
109
+ muted(cmd0),
110
+ muted(cmd1),
111
+ muted(cmd2),
112
+ muted(cmd3),
113
+ "",
114
+ ];
115
+
116
+ const lines = [borderLine("╭", `${paint("Pi")} v${VERSION}`, "╮", width, paint)];
117
+ for (let i = 0; i < leftLines.length; i++) {
118
+ const content = useTips
119
+ ? twoColumn(leftLines[i] ?? "", tipLines[i] ?? "", leftWidth, rightWidth, paint)
120
+ : padRight(leftLines[i] ?? "", leftWidth);
121
+ lines.push(boxedLine(content, width, paint));
122
+ }
123
+ lines.push(borderLine("╰", "", "╯", width, paint));
124
+ return lines.map((line) => truncateToWidth(line, width, ""));
125
+ }
126
+
127
+ invalidate(): void {}
128
+ }
129
+
130
+ export function installHeader(pi: ExtensionAPI, ctx: ExtensionContext): () => void {
131
+ ctx.ui.setHeader((tui) => new OpenTuiHeader(pi, ctx, tui));
132
+ return () => ctx.ui.setHeader(undefined);
133
+ }
@@ -28,6 +28,9 @@ export interface IconGlyphs {
28
28
  untracked: string;
29
29
  renamed: string;
30
30
  deleted: string;
31
+ /** Compact tool view: call marker and result gutter. */
32
+ toolCall: string;
33
+ toolResult: string;
31
34
  }
32
35
 
33
36
  const NERD_GLYPHS: IconGlyphs = {
@@ -59,6 +62,8 @@ const NERD_GLYPHS: IconGlyphs = {
59
62
  untracked: "?",
60
63
  renamed: "»",
61
64
  deleted: "✘",
65
+ toolCall: "●",
66
+ toolResult: "⎿",
62
67
  };
63
68
 
64
69
  // ponytail: ASCII fallback uses compact symbols (not English words) to keep
@@ -92,6 +97,8 @@ const ASCII_GLYPHS: IconGlyphs = {
92
97
  untracked: "?",
93
98
  renamed: "r",
94
99
  deleted: "x",
100
+ toolCall: "*",
101
+ toolResult: "`-",
95
102
  };
96
103
 
97
104
  // Portable Unicode icon set for terminals without a Nerd Font. Keeps the
@@ -129,6 +136,8 @@ const UNICODE_GLYPHS: IconGlyphs = {
129
136
  untracked: "?",
130
137
  renamed: "→",
131
138
  deleted: "✗",
139
+ toolCall: "●",
140
+ toolResult: "└",
132
141
  };
133
142
 
134
143
  function isSshSession(): boolean {