@hank-warren/pi-statusline 0.2.4 → 0.4.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.
package/themes.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Statusline colour palettes. Every role is a full SGR prefix so a theme can use
3
+ * a true-colour value, a plain attribute (`\x1b[2m`), or both.
4
+ */
5
+ export interface StatuslinePalette {
6
+ /** Active model id. */
7
+ model: string;
8
+ /** Repository and directory names. */
9
+ path: string;
10
+ /** Git branch names. */
11
+ branch: string;
12
+ /** Neutral figures: the context window total and the usage provider icons. */
13
+ text: string;
14
+ /** Separators, the session id, and unknown values. */
15
+ dim: string;
16
+ /** Healthy: low context use, high remaining usage, an open PR. */
17
+ ok: string;
18
+ /** First warning step, and the dirty-checkout marker. */
19
+ warn: string;
20
+ /** Second warning step, and the commits-behind marker. */
21
+ caution: string;
22
+ /** Exhausted: high context use, low remaining usage, a closed PR. */
23
+ danger: string;
24
+ /** Merged pull requests. */
25
+ accent: string;
26
+ /** The two colours the cache-hit badge alternates between. */
27
+ celebration: readonly [string, string];
28
+ }
29
+
30
+ const rgb = (hex: string): string => {
31
+ const value = Number.parseInt(hex.slice(1), 16);
32
+ return `\x1b[38;2;${(value >> 16) & 0xff};${(value >> 8) & 0xff};${value & 0xff}m`;
33
+ };
34
+
35
+ /** The palette this package shipped before themes existed. */
36
+ const DEFAULT: StatuslinePalette = {
37
+ model: rgb("#0099ff"),
38
+ path: rgb("#dcdcdc"),
39
+ branch: rgb("#56b6c2"),
40
+ text: rgb("#dcdcdc"),
41
+ dim: "\x1b[2m",
42
+ ok: rgb("#00af50"),
43
+ warn: rgb("#e6c800"),
44
+ caution: rgb("#ffb055"),
45
+ danger: rgb("#ff5555"),
46
+ accent: rgb("#be78ff"),
47
+ celebration: [rgb("#ff00ff"), rgb("#00ffff")],
48
+ };
49
+
50
+ /** Dracula, with the pink branch colour from Hank's Herdr sidebar config. */
51
+ const DRACULA: StatuslinePalette = {
52
+ model: rgb("#bd93f9"),
53
+ path: rgb("#f8f8f2"),
54
+ branch: rgb("#ff79c6"),
55
+ text: rgb("#f8f8f2"),
56
+ dim: rgb("#6272a4"),
57
+ ok: rgb("#50fa7b"),
58
+ warn: rgb("#f1fa8c"),
59
+ caution: rgb("#ffb86c"),
60
+ danger: rgb("#ff5555"),
61
+ accent: rgb("#8be9fd"),
62
+ celebration: [rgb("#ff79c6"), rgb("#8be9fd")],
63
+ };
64
+
65
+ const GITHUB_DARK: StatuslinePalette = {
66
+ model: rgb("#58a6ff"),
67
+ path: rgb("#c9d1d9"),
68
+ branch: rgb("#39c5cf"),
69
+ text: rgb("#c9d1d9"),
70
+ dim: rgb("#8b949e"),
71
+ ok: rgb("#3fb950"),
72
+ warn: rgb("#d29922"),
73
+ caution: rgb("#db6d28"),
74
+ danger: rgb("#f85149"),
75
+ accent: rgb("#bc8cff"),
76
+ celebration: [rgb("#bc8cff"), rgb("#39c5cf")],
77
+ };
78
+
79
+ const CATPPUCCIN_MOCHA: StatuslinePalette = {
80
+ model: rgb("#cba6f7"),
81
+ path: rgb("#cdd6f4"),
82
+ branch: rgb("#89dceb"),
83
+ text: rgb("#cdd6f4"),
84
+ dim: rgb("#6c7086"),
85
+ ok: rgb("#a6e3a1"),
86
+ warn: rgb("#f9e2af"),
87
+ caution: rgb("#fab387"),
88
+ danger: rgb("#f38ba8"),
89
+ accent: rgb("#f5c2e7"),
90
+ celebration: [rgb("#cba6f7"), rgb("#89dceb")],
91
+ };
92
+
93
+ /** No colour at all: white text, dimmed punctuation, a grey badge flash. */
94
+ const WHITE: StatuslinePalette = {
95
+ model: rgb("#ffffff"),
96
+ path: rgb("#ffffff"),
97
+ branch: rgb("#ffffff"),
98
+ text: rgb("#ffffff"),
99
+ dim: "\x1b[2m",
100
+ ok: rgb("#ffffff"),
101
+ warn: rgb("#ffffff"),
102
+ caution: rgb("#ffffff"),
103
+ danger: rgb("#ffffff"),
104
+ accent: rgb("#ffffff"),
105
+ celebration: [rgb("#ffffff"), rgb("#888888")],
106
+ };
107
+
108
+ export const STATUSLINE_THEMES = {
109
+ default: DEFAULT,
110
+ dracula: DRACULA,
111
+ "github-dark": GITHUB_DARK,
112
+ "catppuccin-mocha": CATPPUCCIN_MOCHA,
113
+ white: WHITE,
114
+ } as const satisfies Record<string, StatuslinePalette>;
115
+
116
+ export type StatuslineThemeName = keyof typeof STATUSLINE_THEMES;
117
+
118
+ export const DEFAULT_THEME: StatuslineThemeName = "default";
119
+ export const THEME_NAMES = Object.keys(STATUSLINE_THEMES) as StatuslineThemeName[];
120
+
121
+ export function isThemeName(value: unknown): value is StatuslineThemeName {
122
+ // hasOwn, not `in`: "__proto__" and "toString" are on the prototype chain.
123
+ return typeof value === "string" && Object.hasOwn(STATUSLINE_THEMES, value);
124
+ }
125
+
126
+ /** Resolve a theme name to its palette; anything unknown falls back to the default. */
127
+ export function resolvePalette(name: string): StatuslinePalette {
128
+ return isThemeName(name) ? STATUSLINE_THEMES[name] : STATUSLINE_THEMES[DEFAULT_THEME];
129
+ }
package/worktrees.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
2
3
  import { basename, join, resolve } from "node:path";
3
4
  import type { ExecResult } from "@earendil-works/pi-coding-agent";
4
5
 
@@ -34,7 +35,10 @@ export interface WorktreeTrackerHost {
34
35
  args: string[],
35
36
  options: { cwd?: string; timeout?: number; signal?: AbortSignal },
36
37
  ): Promise<ExecResult>;
37
- home: string;
38
+ /** Directory whose immediate children are tracked as session worktrees. */
39
+ worktreeRoot: string;
40
+ /** Home directory used to recognise `~`/`$HOME` spellings of the root. */
41
+ home?: string;
38
42
  now?: () => number;
39
43
  onChange?: () => void;
40
44
  }
@@ -94,24 +98,48 @@ function visitStrings(value: unknown, visit: (value: string) => void): void {
94
98
  for (const item of Object.values(value)) visitStrings(item, visit);
95
99
  }
96
100
 
97
- export function extractWorktreePaths(value: unknown, home: string): string[] {
101
+ function trimTrailingSlashes(path: string): string {
102
+ return path.replace(/\/+$/, "") || path.slice(0, 1);
103
+ }
104
+
105
+ /**
106
+ * Spellings of the configured root that may appear in tool arguments: the
107
+ * absolute path, plus the `~/` and `$HOME/` forms when it lives under home.
108
+ */
109
+ export function worktreeRootNeedles(worktreeRoot: string, home: string = homedir()): string[] {
110
+ const root = trimTrailingSlashes(worktreeRoot);
111
+ if (root.length === 0) return [];
112
+ const needles = [`${root}/`];
113
+ const base = trimTrailingSlashes(home ?? "");
114
+ if (base.length > 0 && root.startsWith(`${base}/`)) {
115
+ const relative = root.slice(base.length + 1);
116
+ if (relative.length > 0) needles.push(`~/${relative}/`, `$HOME/${relative}/`);
117
+ }
118
+ return needles;
119
+ }
120
+
121
+ export function extractWorktreePaths(value: unknown, worktreeRoot: string, home: string = homedir()): string[] {
98
122
  const names = new Set<string>();
99
- const needle = "repos/worktrees/";
123
+ const needles = worktreeRootNeedles(worktreeRoot, home);
124
+ if (needles.length === 0) return [];
100
125
 
101
126
  visitStrings(value, (text) => {
102
- let rest = text;
103
- while (true) {
104
- const index = rest.indexOf(needle);
105
- if (index < 0) break;
106
- rest = rest.slice(index + needle.length);
107
- const name = rest.match(/^[a-zA-Z0-9._-]+/)?.[0];
108
- if (name) names.add(name);
109
- if (rest.length === 0) break;
110
- rest = rest.slice(Math.max(1, name?.length ?? 0));
127
+ for (const needle of needles) {
128
+ let rest = text;
129
+ while (true) {
130
+ const index = rest.indexOf(needle);
131
+ if (index < 0) break;
132
+ rest = rest.slice(index + needle.length);
133
+ const name = rest.match(/^[a-zA-Z0-9._-]+/)?.[0];
134
+ if (name) names.add(name);
135
+ if (rest.length === 0) break;
136
+ rest = rest.slice(Math.max(1, name?.length ?? 0));
137
+ }
111
138
  }
112
139
  });
113
140
 
114
- return [...names].sort().map((name) => join(home, "repos", "worktrees", name));
141
+ const root = trimTrailingSlashes(worktreeRoot);
142
+ return [...names].sort().map((name) => join(root, name));
115
143
  }
116
144
 
117
145
  function stripHeredocBodies(command: string): string {
@@ -137,17 +165,28 @@ function stripHeredocBodies(command: string): string {
137
165
  return visible.join("\n");
138
166
  }
139
167
 
140
- export function extractWorktreePathsFromToolCall(toolName: string, input: unknown, home: string): string[] {
168
+ export function extractWorktreePathsFromToolCall(
169
+ toolName: string,
170
+ input: unknown,
171
+ worktreeRoot: string,
172
+ home: string = homedir(),
173
+ ): string[] {
141
174
  const baseName = toolName.slice(toolName.lastIndexOf(".") + 1);
142
175
  if (baseName !== "bash" || !input || typeof input !== "object" || Array.isArray(input)) {
143
- return extractWorktreePaths(input, home);
176
+ return extractWorktreePaths(input, worktreeRoot, home);
144
177
  }
145
178
 
146
179
  const command = (input as { command?: unknown }).command;
147
- return typeof command === "string" ? extractWorktreePaths(stripHeredocBodies(command), home) : [];
180
+ return typeof command === "string"
181
+ ? extractWorktreePaths(stripHeredocBodies(command), worktreeRoot, home)
182
+ : [];
148
183
  }
149
184
 
150
- export function extractWorktreePathsFromEntries(entries: readonly unknown[], home: string): string[] {
185
+ export function extractWorktreePathsFromEntries(
186
+ entries: readonly unknown[],
187
+ worktreeRoot: string,
188
+ home: string = homedir(),
189
+ ): string[] {
151
190
  const paths = new Set<string>();
152
191
 
153
192
  for (const entry of entries) {
@@ -161,30 +200,15 @@ export function extractWorktreePathsFromEntries(entries: readonly unknown[], hom
161
200
  if (!block || typeof block !== "object") continue;
162
201
  const toolCall = block as { type?: unknown; name?: unknown; arguments?: unknown };
163
202
  if (toolCall.type !== "toolCall" || typeof toolCall.name !== "string") continue;
164
- for (const path of extractWorktreePathsFromToolCall(toolCall.name, toolCall.arguments, home)) paths.add(path);
203
+ for (const path of extractWorktreePathsFromToolCall(toolCall.name, toolCall.arguments, worktreeRoot, home)) {
204
+ paths.add(path);
205
+ }
165
206
  }
166
207
  }
167
208
 
168
209
  return [...paths].sort();
169
210
  }
170
211
 
171
- export function repoAlias(repo: string): string {
172
- switch (repo) {
173
- case "model-runtime-engine":
174
- return "mre";
175
- case "frontend":
176
- return "fe";
177
- case "infrastructure":
178
- return "infra";
179
- case "platform-releases":
180
- return "rel";
181
- case "np-integration-testing":
182
- return "tests";
183
- default:
184
- return repo.startsWith("platform-") ? repo.slice("platform-".length) : repo;
185
- }
186
- }
187
-
188
212
  export async function readWorktreeMetadata(path: string): Promise<WorktreeMetadata | undefined> {
189
213
  try {
190
214
  const gitFile = await readFile(join(path, ".git"), "utf8");
@@ -242,13 +266,15 @@ export class SessionWorktreeTracker {
242
266
  }
243
267
 
244
268
  async seedFromEntries(entries: readonly unknown[]): Promise<void> {
245
- for (const path of extractWorktreePathsFromEntries(entries, this.host.home)) this.trackedPaths.add(path);
269
+ for (const path of extractWorktreePathsFromEntries(entries, this.host.worktreeRoot, this.host.home)) {
270
+ this.trackedPaths.add(path);
271
+ }
246
272
  await this.refresh();
247
273
  }
248
274
 
249
275
  async observeToolInput(toolName: string, input: unknown): Promise<void> {
250
276
  const added: string[] = [];
251
- for (const path of extractWorktreePathsFromToolCall(toolName, input, this.host.home)) {
277
+ for (const path of extractWorktreePathsFromToolCall(toolName, input, this.host.worktreeRoot, this.host.home)) {
252
278
  if (this.trackedPaths.has(path)) continue;
253
279
  this.trackedPaths.add(path);
254
280
  added.push(path);