@hank-warren/pi-statusline 0.2.4 → 0.3.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/CHANGELOG.md +10 -0
- package/README.md +29 -2
- package/index.ts +201 -67
- package/package.json +4 -1
- package/settings-menu.ts +315 -0
- package/settings.ts +231 -0
- package/themes.ts +128 -0
- package/worktrees.ts +63 -37
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @hank-warren/pi-statusline
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0e6c204: Add a `/statusline` settings menu for choosing a theme, toggling every footer element, setting the worktree-tracker root, and managing repo aliases. Settings persist to `~/.pi/agent/statusline-settings.json` and apply live.
|
|
8
|
+
|
|
9
|
+
Themes: `default` (unchanged), `dracula`, `github-dark`, `catppuccin-mocha`, and `white`. A theme maps eleven colour roles, so switching recolours every element without changing the rendered text.
|
|
10
|
+
|
|
11
|
+
Breaking: the built-in repo aliases and the automatic `platform-` prefix strip are gone — they were hardcoded to one machine. Repository names now render verbatim; add your own pairs under `/statusline → Repo aliases`.
|
|
12
|
+
|
|
3
13
|
## 0.2.4
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -14,7 +14,34 @@ gpt-5.6-sol | pi-extensions:main* ⇣1 | 40k/1.0m | 97·54 80
|
|
|
14
14
|
- **Worktree lines** — when the session works in or sends tool calls into linked worktrees, one line shows the same branch/dirty/behind state for each worktree plus its associated PR number.
|
|
15
15
|
- **Final line** — the full Pi session ID.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Colors come from a selectable [theme](#themes), with context warning thresholds. Every element can be turned off from [`/statusline`](#statusline).
|
|
18
|
+
|
|
19
|
+
## `/statusline`
|
|
20
|
+
|
|
21
|
+
`/statusline` opens a `/settings`-style menu (TUI mode only) for configuring the footer. Changes apply live and persist immediately:
|
|
22
|
+
|
|
23
|
+
- **Theme** — the color palette, cycled with Enter or Space. See [Themes](#themes).
|
|
24
|
+
- **Model**, **Directory & git**, **Context**, **Subscription usage**, **Worktree line**, **Session ID line**, **Cache celebration** — `on`/`off`, cycled with Enter or Space. Disabled segments are dropped from line 1 without leaving a stray ` | ` separator; hiding the worktree line also stops its `git`/`gh` polling, and hiding usage stops the usage poller. With every element off the footer collapses to a single blank row.
|
|
25
|
+
- **Worktree root** — the directory whose immediate children are tracked as session worktrees (default `~/repos/worktrees`). `~` and `$HOME` are expanded; a relative path is rejected and the previous value kept.
|
|
26
|
+
- **Repo aliases** — short display names for repositories on the worktree line. Enter edits the selected `repo → alias` pair, `d` deletes it, and `Add alias…` creates one from a `repo=alias` line.
|
|
27
|
+
|
|
28
|
+
Settings live in a single global file, `~/.pi/agent/statusline-settings.json`, written atomically. Only values differing from the defaults are stored, unknown keys from a newer version are preserved, and a missing or malformed file simply yields defaults. The file is read once at session start and is not watched, so hand edits apply to the next session. Concurrent pi sessions are last-writer-wins.
|
|
29
|
+
|
|
30
|
+
## Themes
|
|
31
|
+
|
|
32
|
+
| Name | Notes |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `default` | The palette this package has always used — blue model, cyan branch, neon magenta/cyan cache badge |
|
|
35
|
+
| `dracula` | [Dracula](https://draculatheme.com), with the pink `#ff79c6` branch color used in Herdr sidebar configs |
|
|
36
|
+
| `github-dark` | GitHub's dark default |
|
|
37
|
+
| `catppuccin-mocha` | [Catppuccin](https://catppuccin.com) Mocha |
|
|
38
|
+
| `white` | No color: white text, dimmed punctuation, a white/grey badge flash |
|
|
39
|
+
|
|
40
|
+
A theme maps eleven roles — `model`, `path`, `branch`, `text`, `dim`, `ok`, `warn`, `caution`, `danger`, `accent`, and the two `celebration` badge frames — so switching recolors every element at once without changing a single character of rendered text. `ok`/`warn`/`caution`/`danger` drive the context meter, the usage meters, and the dirty/behind markers alike, so the warning gradient stays legible in every theme. An unknown theme name in the settings file falls back to `default`.
|
|
41
|
+
|
|
42
|
+
### Repo aliases are no longer built in
|
|
43
|
+
|
|
44
|
+
Up to 0.2.x this package hardcoded five alias pairs and stripped a `platform-` prefix from every other repository. Those rules were specific to one machine and shipped to everyone. From 0.3.0 the alias map starts empty and repository names render verbatim — add whatever pairs you want under `/statusline → Repo aliases`.
|
|
18
45
|
|
|
19
46
|
## Subscription usage meters
|
|
20
47
|
|
|
@@ -49,7 +76,7 @@ Output and reasoning tokens are excluded because they are not prompt-cache candi
|
|
|
49
76
|
|
|
50
77
|
## Worktree/PR tracking behavior
|
|
51
78
|
|
|
52
|
-
- Only worktrees touched on the active Pi session branch are included.
|
|
79
|
+
- Only worktrees directly under the configured worktree root and touched on the active Pi session branch are included. Absolute, `~/`, and `$HOME/` spellings of that root all match.
|
|
53
80
|
- Worktree paths appearing only inside Bash heredoc payloads are ignored.
|
|
54
81
|
- Merged and closed PR worktrees are hidden.
|
|
55
82
|
- State is rebuilt on reload and tree navigation; deleted worktrees are pruned.
|
package/index.ts
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
1
2
|
import { basename } from "node:path";
|
|
2
|
-
import
|
|
3
|
-
|
|
3
|
+
import {
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionContext,
|
|
6
|
+
getSelectListTheme,
|
|
7
|
+
getSettingsListTheme,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { SettingsList, truncateToWidth } from "@earendil-works/pi-tui";
|
|
4
10
|
import {
|
|
5
11
|
CacheCelebrationController,
|
|
6
12
|
type CacheCelebrationSnapshot,
|
|
7
13
|
triggerCacheCelebrationForMessage,
|
|
8
14
|
} from "./cache-celebration.ts";
|
|
9
15
|
import { FullRedrawScheduler } from "./redraw.ts";
|
|
16
|
+
import {
|
|
17
|
+
applySettingChange,
|
|
18
|
+
buildSettingItems,
|
|
19
|
+
createAliasSubmenu,
|
|
20
|
+
createWorktreeRootSubmenu,
|
|
21
|
+
} from "./settings-menu.ts";
|
|
22
|
+
import { defaultSettings, repoAlias, SettingsStore, type StatuslineSettings } from "./settings.ts";
|
|
23
|
+
import { resolvePalette, type StatuslinePalette, STATUSLINE_THEMES } from "./themes.ts";
|
|
10
24
|
import { type UsageSnapshot, usageBand, UsageTracker } from "./usage.ts";
|
|
11
25
|
import {
|
|
12
26
|
type GitRepositoryStatus,
|
|
13
27
|
readGitStatus,
|
|
14
|
-
repoAlias,
|
|
15
28
|
type SessionWorktree,
|
|
16
29
|
SessionWorktreeTracker,
|
|
17
30
|
} from "./worktrees.ts";
|
|
@@ -28,19 +41,9 @@ export interface StatuslineData {
|
|
|
28
41
|
usage?: UsageSnapshot;
|
|
29
42
|
}
|
|
30
43
|
|
|
31
|
-
const BLUE = "\x1b[38;2;0;153;255m";
|
|
32
|
-
const ORANGE = "\x1b[38;2;255;176;85m";
|
|
33
|
-
const GREEN = "\x1b[38;2;0;175;80m";
|
|
34
|
-
const CYAN = "\x1b[38;2;86;182;194m";
|
|
35
|
-
const RED = "\x1b[38;2;255;85;85m";
|
|
36
|
-
const YELLOW = "\x1b[38;2;230;200;0m";
|
|
37
|
-
const WHITE = "\x1b[38;2;220;220;220m";
|
|
38
|
-
const MAGENTA = "\x1b[38;2;190;120;255m";
|
|
39
|
-
const NEON_CYAN = "\x1b[38;2;0;255;255m";
|
|
40
|
-
const NEON_MAGENTA = "\x1b[38;2;255;0;255m";
|
|
41
|
-
const DIM = "\x1b[2m";
|
|
42
44
|
const BOLD = "\x1b[1m";
|
|
43
45
|
const RESET = "\x1b[0m";
|
|
46
|
+
const DEFAULT_PALETTE = STATUSLINE_THEMES.default;
|
|
44
47
|
|
|
45
48
|
function styled(style: string, text: string): string {
|
|
46
49
|
return `${style}${text}${RESET}`;
|
|
@@ -48,32 +51,37 @@ function styled(style: string, text: string): string {
|
|
|
48
51
|
|
|
49
52
|
const CLAUDE_ICON = "\uec82";
|
|
50
53
|
const OPENAI_ICON = "\uec81";
|
|
51
|
-
const USAGE_BAND_COLORS = { green: GREEN, yellow: YELLOW, orange: ORANGE, red: RED } as const;
|
|
52
54
|
|
|
53
|
-
function
|
|
54
|
-
return
|
|
55
|
+
function bandColor(remaining: number, palette: StatuslinePalette): string {
|
|
56
|
+
return { green: palette.ok, yellow: palette.warn, orange: palette.caution, red: palette.danger }[
|
|
57
|
+
usageBand(remaining)
|
|
58
|
+
];
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
export function renderUsageSegment(
|
|
61
|
+
export function renderUsageSegment(
|
|
62
|
+
usage: UsageSnapshot,
|
|
63
|
+
palette: StatuslinePalette = DEFAULT_PALETTE,
|
|
64
|
+
): string | undefined {
|
|
65
|
+
const percent = (remaining: number) => styled(bandColor(remaining, palette), `${remaining}`);
|
|
58
66
|
const parts: string[] = [];
|
|
59
67
|
if (usage.claude) {
|
|
60
|
-
const dot = styled(
|
|
61
|
-
let claude = `${
|
|
62
|
-
if (usage.claude.scopedWeekly !== undefined) claude += `${dot}${
|
|
63
|
-
parts.push(`${styled(
|
|
68
|
+
const dot = styled(palette.dim, "\u00b7");
|
|
69
|
+
let claude = `${percent(usage.claude.fiveHour)}${dot}${percent(usage.claude.sevenDay)}`;
|
|
70
|
+
if (usage.claude.scopedWeekly !== undefined) claude += `${dot}${percent(usage.claude.scopedWeekly)}`;
|
|
71
|
+
parts.push(`${styled(palette.text, CLAUDE_ICON)} ${claude}`);
|
|
64
72
|
}
|
|
65
73
|
if (usage.codex) {
|
|
66
|
-
parts.push(`${styled(
|
|
74
|
+
parts.push(`${styled(palette.text, OPENAI_ICON)} ${percent(usage.codex.weekly)}`);
|
|
67
75
|
}
|
|
68
76
|
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
69
77
|
}
|
|
70
78
|
|
|
71
|
-
function contextColor(contextTokens: number, contextWindow: number): string {
|
|
79
|
+
function contextColor(contextTokens: number, contextWindow: number, palette: StatuslinePalette): string {
|
|
72
80
|
const percent = contextWindow > 0 ? Math.floor((contextTokens * 100) / contextWindow) : 0;
|
|
73
|
-
if (percent >= 90) return
|
|
74
|
-
if (percent >= 70) return
|
|
75
|
-
if (percent >= 50) return
|
|
76
|
-
return
|
|
81
|
+
if (percent >= 90) return palette.danger;
|
|
82
|
+
if (percent >= 70) return palette.warn;
|
|
83
|
+
if (percent >= 50) return palette.caution;
|
|
84
|
+
return palette.ok;
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
export function formatTokenCount(tokens: number): string {
|
|
@@ -86,65 +94,106 @@ export function formatTokenCount(tokens: number): string {
|
|
|
86
94
|
return `${safeTokens}`;
|
|
87
95
|
}
|
|
88
96
|
|
|
89
|
-
function renderRepository(
|
|
97
|
+
function renderRepository(
|
|
98
|
+
name: string,
|
|
99
|
+
status: GitRepositoryStatus,
|
|
100
|
+
palette: StatuslinePalette,
|
|
101
|
+
nameColor = palette.path,
|
|
102
|
+
): string {
|
|
90
103
|
let part = styled(nameColor, name);
|
|
91
|
-
part += styled(
|
|
92
|
-
part += styled(
|
|
93
|
-
if (status.dirty) part += styled(
|
|
94
|
-
if (status.behind > 0) part += ` ${styled(
|
|
104
|
+
part += styled(palette.dim, ":");
|
|
105
|
+
part += styled(palette.branch, status.branch);
|
|
106
|
+
if (status.dirty) part += styled(palette.warn, "*");
|
|
107
|
+
if (status.behind > 0) part += ` ${styled(palette.caution, `⇣${status.behind}`)}`;
|
|
95
108
|
return part;
|
|
96
109
|
}
|
|
97
110
|
|
|
98
111
|
export function renderCacheCelebrationLine(
|
|
99
112
|
summary: string,
|
|
100
113
|
celebration: CacheCelebrationSnapshot,
|
|
114
|
+
palette: StatuslinePalette = DEFAULT_PALETTE,
|
|
101
115
|
): string {
|
|
102
116
|
const badge = `⚡${celebration.percent}%·CACHE·HIT`;
|
|
103
|
-
const color = celebration.frame % 2 === 0 ?
|
|
117
|
+
const color = palette.celebration[celebration.frame % 2 === 0 ? 0 : 1];
|
|
104
118
|
const animatedBadge = `${BOLD}${color}${badge}${RESET}`;
|
|
105
|
-
return `${summary}${styled(
|
|
119
|
+
return `${summary}${styled(palette.dim, " | ")}${animatedBadge}`;
|
|
106
120
|
}
|
|
107
121
|
|
|
108
|
-
function renderWorktreeLine(
|
|
109
|
-
|
|
122
|
+
function renderWorktreeLine(
|
|
123
|
+
worktrees: SessionWorktree[],
|
|
124
|
+
settings: StatuslineSettings,
|
|
125
|
+
palette: StatuslinePalette,
|
|
126
|
+
): string {
|
|
127
|
+
const separator = styled(palette.dim, " | ");
|
|
110
128
|
const parts = worktrees.map((worktree) => {
|
|
111
|
-
let part = renderRepository(repoAlias(worktree.repo), worktree);
|
|
129
|
+
let part = renderRepository(repoAlias(worktree.repo, settings.repoAliases), worktree, palette);
|
|
112
130
|
if (worktree.pr !== undefined) {
|
|
113
131
|
const state = worktree.prState?.toUpperCase();
|
|
114
|
-
const color =
|
|
132
|
+
const color =
|
|
133
|
+
state === "OPEN"
|
|
134
|
+
? palette.ok
|
|
135
|
+
: state === "MERGED"
|
|
136
|
+
? palette.accent
|
|
137
|
+
: state === "CLOSED"
|
|
138
|
+
? palette.danger
|
|
139
|
+
: palette.dim;
|
|
115
140
|
part += ` ${styled(color, `#${worktree.pr}`)}`;
|
|
116
141
|
}
|
|
117
142
|
return part;
|
|
118
143
|
});
|
|
119
|
-
return `${styled(
|
|
144
|
+
return `${styled(palette.dim, "⑂")} ${parts.join(separator)}`;
|
|
120
145
|
}
|
|
121
146
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (width <= 0) return Array.from({ length: lineCount }, () => "");
|
|
147
|
+
/** Home-independent defaults; only `worktreeRoot` varies by host and rendering never reads it. */
|
|
148
|
+
const RENDER_DEFAULTS = defaultSettings("");
|
|
125
149
|
|
|
126
|
-
|
|
150
|
+
export function renderStatusline(
|
|
151
|
+
data: StatuslineData,
|
|
152
|
+
width: number,
|
|
153
|
+
settings: StatuslineSettings = RENDER_DEFAULTS,
|
|
154
|
+
): string[] {
|
|
155
|
+
const palette = resolvePalette(settings.theme);
|
|
156
|
+
const separator = styled(palette.dim, " | ");
|
|
127
157
|
const used =
|
|
128
158
|
data.contextTokens === null
|
|
129
|
-
? styled(
|
|
130
|
-
: styled(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
159
|
+
? styled(palette.dim, "?")
|
|
160
|
+
: styled(
|
|
161
|
+
contextColor(data.contextTokens, data.contextWindow, palette),
|
|
162
|
+
formatTokenCount(data.contextTokens),
|
|
163
|
+
);
|
|
164
|
+
const usageSegment = settings.showUsage && data.usage ? renderUsageSegment(data.usage, palette) : undefined;
|
|
165
|
+
const segments = [
|
|
166
|
+
settings.showModel ? styled(palette.model, data.model) : undefined,
|
|
167
|
+
settings.showDirectory
|
|
168
|
+
? data.cwdGit
|
|
169
|
+
? renderRepository(data.cwd, data.cwdGit, palette)
|
|
170
|
+
: styled(palette.branch, data.cwd)
|
|
171
|
+
: undefined,
|
|
172
|
+
settings.showContext
|
|
173
|
+
? `${used}${styled(palette.dim, "/")}${styled(palette.text, formatTokenCount(data.contextWindow))}`
|
|
174
|
+
: undefined,
|
|
175
|
+
usageSegment,
|
|
176
|
+
].filter((segment): segment is string => segment !== undefined);
|
|
177
|
+
|
|
178
|
+
const showWorktreeLine = settings.showWorktrees && data.worktrees.length > 0;
|
|
179
|
+
const lineCount = (segments.length > 0 ? 1 : 0) + (showWorktreeLine ? 1 : 0) + (settings.showSessionId ? 1 : 0);
|
|
180
|
+
// A footer that renders nothing would collapse the block; keep one stable row.
|
|
181
|
+
if (lineCount === 0) return [""];
|
|
182
|
+
if (width <= 0) return Array.from({ length: lineCount }, () => "");
|
|
144
183
|
|
|
145
|
-
const lines = [
|
|
146
|
-
if (
|
|
147
|
-
|
|
184
|
+
const lines: string[] = [];
|
|
185
|
+
if (segments.length > 0) {
|
|
186
|
+
const summary = segments.join(separator);
|
|
187
|
+
// A badge-only first line is separator soup, so the celebration needs a summary.
|
|
188
|
+
const celebration = settings.showCacheCelebration ? data.cacheCelebration : undefined;
|
|
189
|
+
lines.push(
|
|
190
|
+
truncateToWidth(celebration ? renderCacheCelebrationLine(summary, celebration, palette) : summary, width),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (showWorktreeLine) {
|
|
194
|
+
lines.push(truncateToWidth(renderWorktreeLine(data.worktrees, settings, palette), width, "…"));
|
|
195
|
+
}
|
|
196
|
+
if (settings.showSessionId) lines.push(truncateToWidth(styled(palette.dim, data.sessionId), width));
|
|
148
197
|
return lines;
|
|
149
198
|
}
|
|
150
199
|
|
|
@@ -157,6 +206,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
157
206
|
let cwdGit: GitRepositoryStatus | null = null;
|
|
158
207
|
let cwdStatusAbort: AbortController | undefined;
|
|
159
208
|
let cwdStatusInFlight: Promise<void> | undefined;
|
|
209
|
+
const home = homedir();
|
|
210
|
+
const settingsStore = new SettingsStore({ home });
|
|
211
|
+
let settings = settingsStore.get();
|
|
160
212
|
|
|
161
213
|
const runInBackground = (operation: Promise<void>): void => {
|
|
162
214
|
operation.catch(() => {
|
|
@@ -193,26 +245,105 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
193
245
|
|
|
194
246
|
const resetTracker = (ctx: ExtensionContext): void => {
|
|
195
247
|
tracker?.dispose();
|
|
248
|
+
tracker = undefined;
|
|
196
249
|
cwdStatusAbort?.abort();
|
|
197
250
|
cwdGit = null;
|
|
198
251
|
cwdStatusAbort = new AbortController();
|
|
199
252
|
cwdStatusInFlight = undefined;
|
|
253
|
+
runInBackground(refreshCwdStatus(ctx));
|
|
254
|
+
if (settings.showUsage) runInBackground(usageTracker.refresh());
|
|
255
|
+
// A hidden worktree line must not pay for git/gh polling.
|
|
256
|
+
if (!settings.showWorktrees) return;
|
|
200
257
|
const next = new SessionWorktreeTracker({
|
|
201
258
|
exec: (command, args, options) => pi.exec(command, args, options),
|
|
202
|
-
|
|
259
|
+
worktreeRoot: settings.worktreeRoot,
|
|
260
|
+
home,
|
|
203
261
|
onChange: () => requestRender?.(),
|
|
204
262
|
});
|
|
205
263
|
tracker = next;
|
|
206
|
-
runInBackground(refreshCwdStatus(ctx));
|
|
207
264
|
runInBackground(next.seedFromEntries(ctx.sessionManager.getBranch()));
|
|
208
265
|
runInBackground(next.includeCurrentWorktree(ctx.cwd));
|
|
209
|
-
runInBackground(usageTracker.refresh());
|
|
210
266
|
};
|
|
211
267
|
|
|
268
|
+
/** Adopt a new settings snapshot: apply it live, then persist in the background. */
|
|
269
|
+
const applySettings = (ctx: ExtensionContext, next: StatuslineSettings, persist = true): void => {
|
|
270
|
+
const previous = settings;
|
|
271
|
+
settings = next;
|
|
272
|
+
settingsStore.set(next);
|
|
273
|
+
|
|
274
|
+
if (!next.showWorktrees) {
|
|
275
|
+
tracker?.dispose();
|
|
276
|
+
tracker = undefined;
|
|
277
|
+
} else if (!previous.showWorktrees || !tracker || previous.worktreeRoot !== next.worktreeRoot) {
|
|
278
|
+
resetTracker(ctx);
|
|
279
|
+
}
|
|
280
|
+
if (next.showUsage && !previous.showUsage) runInBackground(usageTracker.refresh());
|
|
281
|
+
if (!next.showCacheCelebration) cacheCelebration.dispose();
|
|
282
|
+
// Dropping a row leaves a stale one behind in fullscreen mode.
|
|
283
|
+
if (previous.showSessionId !== next.showSessionId || previous.showWorktrees !== next.showWorktrees) {
|
|
284
|
+
fullRedraw.request();
|
|
285
|
+
}
|
|
286
|
+
requestRender?.();
|
|
287
|
+
|
|
288
|
+
if (!persist) return;
|
|
289
|
+
settingsStore.save(next).catch((error: unknown) => {
|
|
290
|
+
ctx.ui.notify(
|
|
291
|
+
`Could not save statusline settings: ${error instanceof Error ? error.message : String(error)}`,
|
|
292
|
+
"warning",
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
pi.registerCommand("statusline", {
|
|
298
|
+
description: "Configure the statusline",
|
|
299
|
+
handler: async (_args, ctx) => {
|
|
300
|
+
if (ctx.mode !== "tui") {
|
|
301
|
+
ctx.ui.notify("/statusline requires interactive TUI mode", "warning");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
|
306
|
+
const settingsTheme = getSettingsListTheme();
|
|
307
|
+
const submenuHost = {
|
|
308
|
+
getSettings: () => settings,
|
|
309
|
+
commit: (next: StatuslineSettings) => applySettings(ctx, next),
|
|
310
|
+
notify: (message: string) => ctx.ui.notify(message, "warning"),
|
|
311
|
+
requestRender: () => tui.requestRender(),
|
|
312
|
+
settingsTheme,
|
|
313
|
+
selectTheme: getSelectListTheme(),
|
|
314
|
+
home,
|
|
315
|
+
};
|
|
316
|
+
return new SettingsList(
|
|
317
|
+
buildSettingItems(
|
|
318
|
+
settings,
|
|
319
|
+
{
|
|
320
|
+
worktreeRoot: createWorktreeRootSubmenu(submenuHost),
|
|
321
|
+
repoAliases: createAliasSubmenu(submenuHost),
|
|
322
|
+
},
|
|
323
|
+
home,
|
|
324
|
+
),
|
|
325
|
+
10,
|
|
326
|
+
settingsTheme,
|
|
327
|
+
(id, value) => {
|
|
328
|
+
const change = applySettingChange(settings, id, value, home);
|
|
329
|
+
if (change.kind === "error") ctx.ui.notify(change.message, "warning");
|
|
330
|
+
else if (change.kind === "settings") applySettings(ctx, change.settings);
|
|
331
|
+
tui.requestRender();
|
|
332
|
+
},
|
|
333
|
+
() => done(undefined),
|
|
334
|
+
{ enableSearch: true },
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
|
|
212
340
|
pi.on("session_start", (_event, ctx) => {
|
|
213
341
|
cacheCelebration.dispose();
|
|
214
342
|
if (ctx.mode !== "tui") return;
|
|
215
343
|
|
|
344
|
+
// setFooter is synchronous, so the first frames render with defaults.
|
|
345
|
+
runInBackground(settingsStore.load().then((loaded) => applySettings(ctx, loaded, false)));
|
|
346
|
+
|
|
216
347
|
ctx.ui.setFooter((tui, _theme, footerData) => {
|
|
217
348
|
requestRender = () => tui.requestRender();
|
|
218
349
|
// Fullscreen mode never repaints unchanged rows; the session id line is
|
|
@@ -250,6 +381,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
250
381
|
usage: usageTracker.snapshot(),
|
|
251
382
|
},
|
|
252
383
|
width,
|
|
384
|
+
settings,
|
|
253
385
|
),
|
|
254
386
|
);
|
|
255
387
|
},
|
|
@@ -263,12 +395,14 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
263
395
|
if (tracker) runInBackground(tracker.observeToolInput(event.toolName, event.input));
|
|
264
396
|
});
|
|
265
397
|
pi.on("turn_end", (event, ctx) => {
|
|
266
|
-
if (requestRender
|
|
398
|
+
if (requestRender && settings.showCacheCelebration) {
|
|
399
|
+
triggerCacheCelebrationForMessage(event.message, cacheCelebration);
|
|
400
|
+
}
|
|
267
401
|
fullRedraw.request();
|
|
268
402
|
requestRender?.();
|
|
269
403
|
runInBackground(refreshCwdStatus(ctx));
|
|
270
404
|
if (tracker) runInBackground(tracker.refresh());
|
|
271
|
-
runInBackground(usageTracker.refresh());
|
|
405
|
+
if (settings.showUsage) runInBackground(usageTracker.refresh());
|
|
272
406
|
});
|
|
273
407
|
pi.on("model_select", () => requestRender?.());
|
|
274
408
|
pi.on("session_tree", (_event, ctx) => resetTracker(ctx));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-statusline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Compact Pi footer statusline with Git/worktree context, token usage, and neon celebrations for exceptional prompt-cache hits.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
"index.ts",
|
|
35
35
|
"cache-celebration.ts",
|
|
36
36
|
"redraw.ts",
|
|
37
|
+
"settings.ts",
|
|
38
|
+
"settings-menu.ts",
|
|
39
|
+
"themes.ts",
|
|
37
40
|
"usage.ts",
|
|
38
41
|
"worktrees.ts",
|
|
39
42
|
"README.md",
|
package/settings-menu.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
Input,
|
|
5
|
+
type SelectItem,
|
|
6
|
+
SelectList,
|
|
7
|
+
type SelectListTheme,
|
|
8
|
+
type SettingItem,
|
|
9
|
+
type SettingsListTheme,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
import { type BooleanSettingKey, collapseHome, resolveWorktreeRoot, type StatuslineSettings } from "./settings.ts";
|
|
13
|
+
import { isThemeName, THEME_NAMES } from "./themes.ts";
|
|
14
|
+
|
|
15
|
+
export const THEME_ID = "theme";
|
|
16
|
+
export const WORKTREE_ROOT_ID = "worktreeRoot";
|
|
17
|
+
export const REPO_ALIASES_ID = "repoAliases";
|
|
18
|
+
export const ADD_ALIAS_VALUE = "\u0000add";
|
|
19
|
+
|
|
20
|
+
export const ON = "on";
|
|
21
|
+
export const OFF = "off";
|
|
22
|
+
const TOGGLE_VALUES = [ON, OFF];
|
|
23
|
+
|
|
24
|
+
export interface BooleanRow {
|
|
25
|
+
id: BooleanSettingKey;
|
|
26
|
+
label: string;
|
|
27
|
+
description: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Toggle rows, in statusline render order. */
|
|
31
|
+
export const BOOLEAN_ROWS: readonly BooleanRow[] = [
|
|
32
|
+
{ id: "showModel", label: "Model", description: "Show the active model id." },
|
|
33
|
+
{ id: "showDirectory", label: "Directory & git", description: "Show the working directory and its git branch." },
|
|
34
|
+
{ id: "showContext", label: "Context", description: "Show context tokens used against the window." },
|
|
35
|
+
{ id: "showUsage", label: "Subscription usage", description: "Show Claude/Codex remaining-headroom meters." },
|
|
36
|
+
{ id: "showWorktrees", label: "Worktree line", description: "Show touched worktrees and their pull requests." },
|
|
37
|
+
{ id: "showSessionId", label: "Session ID line", description: "Show the full Pi session id on its own line." },
|
|
38
|
+
{
|
|
39
|
+
id: "showCacheCelebration",
|
|
40
|
+
label: "Cache celebration",
|
|
41
|
+
description: "Flash a badge after an exceptional prompt-cache hit.",
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export function toggleValue(enabled: boolean): string {
|
|
46
|
+
return enabled ? ON : OFF;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function aliasSummary(settings: StatuslineSettings): string {
|
|
50
|
+
const count = Object.keys(settings.repoAliases).length;
|
|
51
|
+
return `${count} alias${count === 1 ? "" : "es"}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SettingSubmenus {
|
|
55
|
+
worktreeRoot?: SettingItem["submenu"];
|
|
56
|
+
repoAliases?: SettingItem["submenu"];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Build the `/statusline` rows for a settings snapshot. */
|
|
60
|
+
export function buildSettingItems(
|
|
61
|
+
settings: StatuslineSettings,
|
|
62
|
+
submenus: SettingSubmenus = {},
|
|
63
|
+
home: string = homedir(),
|
|
64
|
+
): SettingItem[] {
|
|
65
|
+
const items: SettingItem[] = [
|
|
66
|
+
{
|
|
67
|
+
id: THEME_ID,
|
|
68
|
+
label: "Theme",
|
|
69
|
+
description: "Colour palette for every statusline element.",
|
|
70
|
+
currentValue: settings.theme,
|
|
71
|
+
values: [...THEME_NAMES],
|
|
72
|
+
},
|
|
73
|
+
...BOOLEAN_ROWS.map((row) => ({
|
|
74
|
+
id: row.id,
|
|
75
|
+
label: row.label,
|
|
76
|
+
description: row.description,
|
|
77
|
+
currentValue: toggleValue(settings[row.id]),
|
|
78
|
+
values: TOGGLE_VALUES,
|
|
79
|
+
})),
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
items.push({
|
|
83
|
+
id: WORKTREE_ROOT_ID,
|
|
84
|
+
label: "Worktree root",
|
|
85
|
+
description: "Directory whose children are tracked as session worktrees.",
|
|
86
|
+
currentValue: collapseHome(settings.worktreeRoot, home),
|
|
87
|
+
...(submenus.worktreeRoot ? { submenu: submenus.worktreeRoot } : {}),
|
|
88
|
+
});
|
|
89
|
+
items.push({
|
|
90
|
+
id: REPO_ALIASES_ID,
|
|
91
|
+
label: "Repo aliases",
|
|
92
|
+
description: "Short display names for repositories on the worktree line.",
|
|
93
|
+
currentValue: aliasSummary(settings),
|
|
94
|
+
...(submenus.repoAliases ? { submenu: submenus.repoAliases } : {}),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return items;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type SettingChange =
|
|
101
|
+
| { kind: "settings"; settings: StatuslineSettings }
|
|
102
|
+
| { kind: "error"; message: string }
|
|
103
|
+
| { kind: "ignored" };
|
|
104
|
+
|
|
105
|
+
/** Map one row's new display value onto the settings object. */
|
|
106
|
+
export function applySettingChange(
|
|
107
|
+
settings: StatuslineSettings,
|
|
108
|
+
id: string,
|
|
109
|
+
value: string,
|
|
110
|
+
home: string = homedir(),
|
|
111
|
+
): SettingChange {
|
|
112
|
+
if (BOOLEAN_ROWS.some((row) => row.id === id)) {
|
|
113
|
+
if (value !== ON && value !== OFF) return { kind: "error", message: `Unknown value for ${id}: ${value}` };
|
|
114
|
+
return { kind: "settings", settings: { ...settings, [id]: value === ON } };
|
|
115
|
+
}
|
|
116
|
+
if (id === THEME_ID) {
|
|
117
|
+
if (!isThemeName(value)) return { kind: "error", message: `Unknown statusline theme: ${value}` };
|
|
118
|
+
if (value === settings.theme) return { kind: "ignored" };
|
|
119
|
+
return { kind: "settings", settings: { ...settings, theme: value } };
|
|
120
|
+
}
|
|
121
|
+
if (id === WORKTREE_ROOT_ID) {
|
|
122
|
+
const resolved = resolveWorktreeRoot(value, home);
|
|
123
|
+
if (!resolved.path) return { kind: "error", message: resolved.error ?? "Invalid worktree root" };
|
|
124
|
+
if (resolved.path === settings.worktreeRoot) return { kind: "ignored" };
|
|
125
|
+
return { kind: "settings", settings: { ...settings, worktreeRoot: resolved.path } };
|
|
126
|
+
}
|
|
127
|
+
// Alias edits are committed by the submenu itself; its done() value is display text.
|
|
128
|
+
return { kind: "ignored" };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Parse an `repo=alias` (or `repo → alias`) submenu line. */
|
|
132
|
+
export function parseAliasEntry(input: string): { repo: string; alias: string } | undefined {
|
|
133
|
+
const [rawRepo, ...rest] = input.split(/=|→/);
|
|
134
|
+
const repo = rawRepo?.trim() ?? "";
|
|
135
|
+
const alias = rest.join("=").trim();
|
|
136
|
+
if (repo.length === 0 || alias.length === 0) return undefined;
|
|
137
|
+
return { repo, alias };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function aliasItems(settings: StatuslineSettings): SelectItem[] {
|
|
141
|
+
const items: SelectItem[] = Object.entries(settings.repoAliases)
|
|
142
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
143
|
+
.map(([repo, alias]) => ({ value: repo, label: `${repo} → ${alias}`, description: "Enter to edit · d to delete" }));
|
|
144
|
+
items.push({ value: ADD_ALIAS_VALUE, label: "Add alias…", description: "Enter a new repo=alias pair" });
|
|
145
|
+
return items;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface SubmenuHost {
|
|
149
|
+
/** Read the live settings; the menu edits a single shared snapshot. */
|
|
150
|
+
getSettings(): StatuslineSettings;
|
|
151
|
+
/** Commit an edit: persists, applies live, and repaints. */
|
|
152
|
+
commit(settings: StatuslineSettings): void;
|
|
153
|
+
notify(message: string): void;
|
|
154
|
+
requestRender(): void;
|
|
155
|
+
settingsTheme: SettingsListTheme;
|
|
156
|
+
selectTheme: SelectListTheme;
|
|
157
|
+
home?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Single-line text prompt used by both submenus. */
|
|
161
|
+
class PromptComponent implements Component {
|
|
162
|
+
private readonly input = new Input();
|
|
163
|
+
|
|
164
|
+
constructor(
|
|
165
|
+
private readonly title: string,
|
|
166
|
+
initialValue: string,
|
|
167
|
+
private readonly hint: (text: string) => string,
|
|
168
|
+
onSubmit: (value: string) => void,
|
|
169
|
+
onCancel: () => void,
|
|
170
|
+
) {
|
|
171
|
+
this.input.setValue(initialValue);
|
|
172
|
+
this.input.focused = true;
|
|
173
|
+
this.input.onSubmit = onSubmit;
|
|
174
|
+
this.input.onEscape = onCancel;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
invalidate(): void {
|
|
178
|
+
this.input.invalidate();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
render(width: number): string[] {
|
|
182
|
+
return [
|
|
183
|
+
truncateToWidth(this.hint(` ${this.title}`), width),
|
|
184
|
+
"",
|
|
185
|
+
...this.input.render(width),
|
|
186
|
+
"",
|
|
187
|
+
truncateToWidth(this.hint(" Enter to save · Esc to cancel"), width),
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
handleInput(data: string): void {
|
|
192
|
+
this.input.handleInput(data);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function createWorktreeRootSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
|
|
197
|
+
const home = host.home ?? homedir();
|
|
198
|
+
return (currentValue, done) =>
|
|
199
|
+
new PromptComponent(
|
|
200
|
+
"Worktree root directory",
|
|
201
|
+
currentValue,
|
|
202
|
+
host.settingsTheme.hint,
|
|
203
|
+
(value) => {
|
|
204
|
+
const resolved = resolveWorktreeRoot(value, home);
|
|
205
|
+
if (!resolved.path) {
|
|
206
|
+
host.notify(resolved.error ?? "Invalid worktree root");
|
|
207
|
+
done(undefined);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
host.commit({ ...host.getSettings(), worktreeRoot: resolved.path });
|
|
211
|
+
done(collapseHome(resolved.path, home));
|
|
212
|
+
},
|
|
213
|
+
() => done(undefined),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Alias list submenu: edit, add, delete, toggle the prefix rule, load the preset. */
|
|
218
|
+
class AliasSubmenu implements Component {
|
|
219
|
+
private list: SelectList;
|
|
220
|
+
private prompt: PromptComponent | undefined;
|
|
221
|
+
|
|
222
|
+
constructor(
|
|
223
|
+
private readonly host: SubmenuHost,
|
|
224
|
+
private readonly done: (value?: string) => void,
|
|
225
|
+
) {
|
|
226
|
+
this.list = this.buildList();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private buildList(selectedIndex = 0): SelectList {
|
|
230
|
+
const list = new SelectList(aliasItems(this.host.getSettings()), 10, this.host.selectTheme);
|
|
231
|
+
list.setSelectedIndex(selectedIndex);
|
|
232
|
+
list.onCancel = () => this.done(aliasSummary(this.host.getSettings()));
|
|
233
|
+
list.onSelect = (item) => this.activate(item);
|
|
234
|
+
return list;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private rebuild(selectedIndex: number): void {
|
|
238
|
+
this.list = this.buildList(selectedIndex);
|
|
239
|
+
this.host.requestRender();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private activate(item: SelectItem): void {
|
|
243
|
+
const settings = this.host.getSettings();
|
|
244
|
+
const editing = item.value === ADD_ALIAS_VALUE ? "" : `${item.value}=${settings.repoAliases[item.value] ?? ""}`;
|
|
245
|
+
this.prompt = new PromptComponent(
|
|
246
|
+
item.value === ADD_ALIAS_VALUE ? "New alias (repo=alias)" : "Edit alias (repo=alias)",
|
|
247
|
+
editing,
|
|
248
|
+
this.host.settingsTheme.hint,
|
|
249
|
+
(value) => this.submitAlias(item.value, value),
|
|
250
|
+
() => {
|
|
251
|
+
this.prompt = undefined;
|
|
252
|
+
this.host.requestRender();
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
this.host.requestRender();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private submitAlias(originalRepo: string, value: string): void {
|
|
259
|
+
this.prompt = undefined;
|
|
260
|
+
const parsed = parseAliasEntry(value);
|
|
261
|
+
if (!parsed) {
|
|
262
|
+
this.host.notify("Enter an alias as repo=alias");
|
|
263
|
+
this.host.requestRender();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const settings = this.host.getSettings();
|
|
267
|
+
const aliases = { ...settings.repoAliases };
|
|
268
|
+
if (originalRepo !== ADD_ALIAS_VALUE && originalRepo !== parsed.repo) delete aliases[originalRepo];
|
|
269
|
+
aliases[parsed.repo] = parsed.alias;
|
|
270
|
+
this.host.commit({ ...settings, repoAliases: aliases });
|
|
271
|
+
this.rebuild(aliasItems(this.host.getSettings()).findIndex((row) => row.value === parsed.repo));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private deleteSelected(): void {
|
|
275
|
+
const selected = this.list.getSelectedItem();
|
|
276
|
+
if (!selected || selected.value.startsWith("\u0000")) return;
|
|
277
|
+
const settings = this.host.getSettings();
|
|
278
|
+
const aliases = { ...settings.repoAliases };
|
|
279
|
+
delete aliases[selected.value];
|
|
280
|
+
this.host.commit({ ...settings, repoAliases: aliases });
|
|
281
|
+
this.rebuild(0);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
invalidate(): void {
|
|
285
|
+
this.prompt?.invalidate();
|
|
286
|
+
this.list.invalidate();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
render(width: number): string[] {
|
|
290
|
+
if (this.prompt) return this.prompt.render(width);
|
|
291
|
+
return [
|
|
292
|
+
truncateToWidth(this.host.settingsTheme.hint(" Repo aliases"), width),
|
|
293
|
+
"",
|
|
294
|
+
...this.list.render(width),
|
|
295
|
+
"",
|
|
296
|
+
truncateToWidth(this.host.settingsTheme.hint(" Enter to edit · d to delete · Esc to go back"), width),
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
handleInput(data: string): void {
|
|
301
|
+
if (this.prompt) {
|
|
302
|
+
this.prompt.handleInput(data);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (data === "d") {
|
|
306
|
+
this.deleteSelected();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
this.list.handleInput(data);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function createAliasSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
|
|
314
|
+
return (_currentValue, done) => new AliasSubmenu(host, done);
|
|
315
|
+
}
|
package/settings.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { pid } from "node:process";
|
|
5
|
+
import { DEFAULT_THEME, isThemeName, type StatuslineThemeName } from "./themes.ts";
|
|
6
|
+
|
|
7
|
+
/** Toggle keys, in the order the `/statusline` menu lists them. */
|
|
8
|
+
export const BOOLEAN_SETTING_KEYS = [
|
|
9
|
+
"showModel",
|
|
10
|
+
"showDirectory",
|
|
11
|
+
"showContext",
|
|
12
|
+
"showUsage",
|
|
13
|
+
"showWorktrees",
|
|
14
|
+
"showSessionId",
|
|
15
|
+
"showCacheCelebration",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export type BooleanSettingKey = (typeof BOOLEAN_SETTING_KEYS)[number];
|
|
19
|
+
|
|
20
|
+
export interface StatuslineSettings extends Record<BooleanSettingKey, boolean> {
|
|
21
|
+
/** Colour palette name; see themes.ts. */
|
|
22
|
+
theme: StatuslineThemeName;
|
|
23
|
+
/** Directory whose immediate children are treated as session worktrees. */
|
|
24
|
+
worktreeRoot: string;
|
|
25
|
+
/** `repository name -> display alias` overrides for the worktree line. */
|
|
26
|
+
repoAliases: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function defaultWorktreeRoot(home: string = homedir()): string {
|
|
30
|
+
return join(home || homedir(), "repos", "worktrees");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function defaultSettings(home: string = homedir()): StatuslineSettings {
|
|
34
|
+
return {
|
|
35
|
+
showModel: true,
|
|
36
|
+
showDirectory: true,
|
|
37
|
+
showContext: true,
|
|
38
|
+
showUsage: true,
|
|
39
|
+
showWorktrees: true,
|
|
40
|
+
showSessionId: true,
|
|
41
|
+
showCacheCelebration: true,
|
|
42
|
+
theme: DEFAULT_THEME,
|
|
43
|
+
worktreeRoot: defaultWorktreeRoot(home),
|
|
44
|
+
repoAliases: {},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function defaultSettingsPath(home: string = homedir()): string {
|
|
49
|
+
return join(home || homedir(), ".pi", "agent", "statusline-settings.json");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Expand a leading `~` (and `$HOME`) against `home`; other paths are returned as-is. */
|
|
53
|
+
export function expandHome(path: string, home: string = homedir()): string {
|
|
54
|
+
const trimmed = path.trim();
|
|
55
|
+
const base = home || homedir();
|
|
56
|
+
if (trimmed === "~" || trimmed === "$HOME") return base;
|
|
57
|
+
if (trimmed.startsWith("~/")) return join(base, trimmed.slice(2));
|
|
58
|
+
if (trimmed.startsWith("$HOME/")) return join(base, trimmed.slice("$HOME/".length));
|
|
59
|
+
return trimmed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Inverse of {@link expandHome}, for compact display in the menu. */
|
|
63
|
+
export function collapseHome(path: string, home: string = homedir()): string {
|
|
64
|
+
const base = home || homedir();
|
|
65
|
+
if (!base) return path;
|
|
66
|
+
if (path === base) return "~";
|
|
67
|
+
return path.startsWith(`${base}/`) ? `~/${path.slice(base.length + 1)}` : path;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface WorktreeRootResult {
|
|
71
|
+
path?: string;
|
|
72
|
+
error?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Validate a user-entered worktree root: `~`-expanded, and absolute afterwards. */
|
|
76
|
+
export function resolveWorktreeRoot(input: string, home: string = homedir()): WorktreeRootResult {
|
|
77
|
+
const expanded = expandHome(input, home);
|
|
78
|
+
if (expanded.length === 0) return { error: "Worktree root cannot be empty" };
|
|
79
|
+
if (!isAbsolute(expanded)) return { error: `Worktree root must be an absolute path: ${input.trim()}` };
|
|
80
|
+
return { path: expanded.replace(/\/+$/, "") || "/" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Resolve a repository's display alias; with an empty map this is the identity. */
|
|
84
|
+
export function repoAlias(repo: string, aliases: Record<string, string> = {}): string {
|
|
85
|
+
return aliases[repo] ?? repo;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
89
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeAliases(value: unknown): Record<string, string> | undefined {
|
|
93
|
+
if (!isPlainObject(value)) return undefined;
|
|
94
|
+
const aliases: Record<string, string> = {};
|
|
95
|
+
for (const [repo, alias] of Object.entries(value)) {
|
|
96
|
+
if (repo.length > 0 && typeof alias === "string" && alias.length > 0) aliases[repo] = alias;
|
|
97
|
+
}
|
|
98
|
+
return aliases;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface NormalizedSettings {
|
|
102
|
+
settings: StatuslineSettings;
|
|
103
|
+
/** Top-level keys this version does not know about, preserved on write. */
|
|
104
|
+
extra: Record<string, unknown>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Best-effort normalization: every key falls back to its own default without
|
|
109
|
+
* discarding valid siblings, and unknown keys are carried through untouched.
|
|
110
|
+
*/
|
|
111
|
+
export function normalizeSettings(value: unknown, home: string = homedir()): NormalizedSettings {
|
|
112
|
+
const settings = defaultSettings(home);
|
|
113
|
+
const extra: Record<string, unknown> = {};
|
|
114
|
+
if (!isPlainObject(value)) return { settings, extra };
|
|
115
|
+
|
|
116
|
+
const known = new Set<string>([...BOOLEAN_SETTING_KEYS, "theme", "worktreeRoot", "repoAliases"]);
|
|
117
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
118
|
+
if (!known.has(key)) {
|
|
119
|
+
extra[key] = raw;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (key === "worktreeRoot") {
|
|
123
|
+
if (typeof raw === "string") {
|
|
124
|
+
const resolved = resolveWorktreeRoot(raw, home);
|
|
125
|
+
if (resolved.path) settings.worktreeRoot = resolved.path;
|
|
126
|
+
}
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (key === "repoAliases") {
|
|
130
|
+
const aliases = normalizeAliases(raw);
|
|
131
|
+
if (aliases) settings.repoAliases = aliases;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (key === "theme") {
|
|
135
|
+
if (isThemeName(raw)) settings.theme = raw;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (typeof raw === "boolean") settings[key as BooleanSettingKey] = raw;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { settings, extra };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function sameAliases(a: Record<string, string>, b: Record<string, string>): boolean {
|
|
145
|
+
const aKeys = Object.keys(a);
|
|
146
|
+
if (aKeys.length !== Object.keys(b).length) return false;
|
|
147
|
+
return aKeys.every((key) => a[key] === b[key]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Sparse serialization: only values differing from the defaults are written, so
|
|
152
|
+
* a later default change still reaches hosts that never touched that key.
|
|
153
|
+
*/
|
|
154
|
+
export function serializeSettings(
|
|
155
|
+
settings: StatuslineSettings,
|
|
156
|
+
extra: Record<string, unknown> = {},
|
|
157
|
+
home: string = homedir(),
|
|
158
|
+
): Record<string, unknown> {
|
|
159
|
+
const defaults = defaultSettings(home);
|
|
160
|
+
const out: Record<string, unknown> = { ...extra };
|
|
161
|
+
for (const key of BOOLEAN_SETTING_KEYS) {
|
|
162
|
+
if (settings[key] !== defaults[key]) out[key] = settings[key];
|
|
163
|
+
}
|
|
164
|
+
if (settings.theme !== defaults.theme) out.theme = settings.theme;
|
|
165
|
+
if (settings.worktreeRoot !== defaults.worktreeRoot) out.worktreeRoot = settings.worktreeRoot;
|
|
166
|
+
if (!sameAliases(settings.repoAliases, defaults.repoAliases)) out.repoAliases = { ...settings.repoAliases };
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface SettingsStoreOptions {
|
|
171
|
+
path?: string;
|
|
172
|
+
home?: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Owns the single global settings file. Extensions get no settings API, so this
|
|
177
|
+
* mirrors what usage.ts already does for its shared cache.
|
|
178
|
+
*/
|
|
179
|
+
export class SettingsStore {
|
|
180
|
+
private readonly home: string;
|
|
181
|
+
private readonly path: string;
|
|
182
|
+
private extra: Record<string, unknown> = {};
|
|
183
|
+
private current: StatuslineSettings;
|
|
184
|
+
|
|
185
|
+
constructor(options: SettingsStoreOptions = {}) {
|
|
186
|
+
this.home = options.home ?? homedir();
|
|
187
|
+
this.path = options.path ?? defaultSettingsPath(this.home);
|
|
188
|
+
this.current = defaultSettings(this.home);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
getPath(): string {
|
|
192
|
+
return this.path;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
get(): StatuslineSettings {
|
|
196
|
+
return this.current;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
set(settings: StatuslineSettings): void {
|
|
200
|
+
this.current = settings;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Never throws: a missing, unreadable, or malformed file yields defaults. */
|
|
204
|
+
async load(): Promise<StatuslineSettings> {
|
|
205
|
+
let parsed: unknown;
|
|
206
|
+
try {
|
|
207
|
+
parsed = JSON.parse(await readFile(this.path, "utf8"));
|
|
208
|
+
} catch {
|
|
209
|
+
parsed = undefined;
|
|
210
|
+
}
|
|
211
|
+
const { settings, extra } = normalizeSettings(parsed, this.home);
|
|
212
|
+
this.extra = extra;
|
|
213
|
+
this.current = settings;
|
|
214
|
+
return settings;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Atomic write via temp file + rename. Rejects so the caller can notify. */
|
|
218
|
+
async save(settings: StatuslineSettings = this.current): Promise<void> {
|
|
219
|
+
this.current = settings;
|
|
220
|
+
const payload = `${JSON.stringify(serializeSettings(settings, this.extra, this.home), null, "\t")}\n`;
|
|
221
|
+
const temporary = `${this.path}.${pid}.tmp`;
|
|
222
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
223
|
+
try {
|
|
224
|
+
await writeFile(temporary, payload, { mode: 0o600 });
|
|
225
|
+
await rename(temporary, this.path);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
await unlink(temporary).catch(() => {});
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
package/themes.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
return typeof value === "string" && value in STATUSLINE_THEMES;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Resolve a theme name to its palette; anything unknown falls back to the default. */
|
|
126
|
+
export function resolvePalette(name: string): StatuslinePalette {
|
|
127
|
+
return isThemeName(name) ? STATUSLINE_THEMES[name] : STATUSLINE_THEMES[DEFAULT_THEME];
|
|
128
|
+
}
|
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
|
-
|
|
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
|
-
|
|
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
|
|
123
|
+
const needles = worktreeRootNeedles(worktreeRoot, home);
|
|
124
|
+
if (needles.length === 0) return [];
|
|
100
125
|
|
|
101
126
|
visitStrings(value, (text) => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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(
|
|
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"
|
|
180
|
+
return typeof command === "string"
|
|
181
|
+
? extractWorktreePaths(stripHeredocBodies(command), worktreeRoot, home)
|
|
182
|
+
: [];
|
|
148
183
|
}
|
|
149
184
|
|
|
150
|
-
export function extractWorktreePathsFromEntries(
|
|
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))
|
|
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.
|
|
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);
|