@xzzpig/pi-starline 0.5.0 → 0.7.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.
Files changed (53) hide show
  1. package/FORK-CHANGELOG.md +28 -0
  2. package/README.md +91 -181
  3. package/dist/public.d.ts +5 -0
  4. package/extensions/starline/config.ts +156 -326
  5. package/extensions/starline/context-live.ts +33 -0
  6. package/extensions/starline/editor-cursor.ts +3 -0
  7. package/extensions/starline/extension-status.ts +9 -5
  8. package/extensions/starline/footer.ts +331 -528
  9. package/extensions/starline/fork-editor-cursor.ts +14 -0
  10. package/extensions/starline/format.ts +151 -20
  11. package/extensions/starline/git-live.ts +20 -0
  12. package/extensions/starline/git-probe.ts +40 -0
  13. package/extensions/starline/git-segments.ts +175 -0
  14. package/extensions/starline/git.ts +71 -0
  15. package/extensions/starline/icons.ts +17 -0
  16. package/extensions/starline/index.ts +188 -91
  17. package/extensions/starline/live-session.ts +164 -0
  18. package/extensions/starline/live-tps.ts +165 -0
  19. package/extensions/starline/mouse/bubble-dismiss.ts +82 -0
  20. package/extensions/starline/mouse/cursor-marker.ts +69 -0
  21. package/extensions/starline/mouse/editor-hit-test.ts +6 -3
  22. package/extensions/starline/mouse/explicit-expand-target.ts +89 -0
  23. package/extensions/starline/mouse/external-layer.ts +42 -0
  24. package/extensions/starline/mouse/fork-native-clipboard.ts +57 -0
  25. package/extensions/starline/mouse/fork-pill-mouse.ts +19 -0
  26. package/extensions/starline/mouse/fork-pointer-coordination.ts +10 -0
  27. package/extensions/starline/mouse/fork-transcript-copy.ts +54 -0
  28. package/extensions/starline/mouse/fork-wheel-compat.ts +22 -0
  29. package/extensions/starline/mouse/hint.ts +4 -4
  30. package/extensions/starline/mouse/index.ts +54 -24
  31. package/extensions/starline/mouse/pointer-events.ts +73 -0
  32. package/extensions/starline/mouse/pointer-shape.ts +138 -0
  33. package/extensions/starline/mouse/tool-box.ts +4 -0
  34. package/extensions/starline/mouse/transcript-copy.ts +76 -33
  35. package/extensions/starline/paste-collapse.ts +52 -13
  36. package/extensions/starline/pill-actions.ts +590 -0
  37. package/extensions/starline/pill-anchors.ts +26 -0
  38. package/extensions/starline/pill-api.ts +80 -0
  39. package/extensions/starline/pill-bubble.ts +607 -0
  40. package/extensions/starline/pill-config.ts +67 -9
  41. package/extensions/starline/pill.ts +102 -20
  42. package/extensions/starline/project-probe.ts +79 -0
  43. package/extensions/starline/prototype-patch-registry.ts +6 -1
  44. package/extensions/starline/queue-rows.ts +43 -0
  45. package/extensions/starline/settings-command.ts +124 -177
  46. package/extensions/starline/subagent-fleet.ts +246 -0
  47. package/extensions/starline/turn-clock.ts +81 -0
  48. package/extensions/starline/ui.ts +297 -156
  49. package/extensions/starline/working-indicator.ts +189 -0
  50. package/package.json +18 -12
  51. package/types/pill-api.d.ts +115 -0
  52. package/extensions/starline/editor-metadata-format.ts +0 -259
  53. package/extensions/starline/footer-format.ts +0 -279
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Pi 1.2+ delays drawing its software cursor: the editor emits APC markers
3
+ * around the cursor cell and the TUI resolves them after our editor renders.
4
+ * Restyle the marked cell before that resolution, keeping CURSOR_MARKER intact
5
+ * for hardware cursor/IME positioning. Pi 1.1's SGR cursor stays upstream's
6
+ * responsibility in editor-cursor.ts.
7
+ */
8
+ const MARKED_CURSOR = /\x1b_pi:fc\x07([^\x1b]+)\x1b_pi:\/fc\x07/g;
9
+
10
+ export function applyMarkedCursorStyle(line: string, style: "underline" | "terminal"): string {
11
+ return line.replace(MARKED_CURSOR, (_match, cell: string) =>
12
+ style === "underline" ? `\x1b[4m${cell}\x1b[0m` : cell,
13
+ );
14
+ }
@@ -55,19 +55,16 @@ export function formatGitMetricsSegment(
55
55
  metrics: GitMetricsInfo | null | undefined,
56
56
  config: { onlyNonzero: boolean },
57
57
  colorSource: ColorSource,
58
- addedStyle: ColorSpec,
59
- deletedStyle: ColorSpec,
58
+ style: ColorSpec,
60
59
  ): string {
61
60
  if (!metrics) return "";
62
61
  const showAdded = !config.onlyNonzero || metrics.added > 0;
63
62
  const showDeleted = !config.onlyNonzero || metrics.deleted > 0;
64
63
  if (!showAdded && !showDeleted) return "";
65
64
  const parts: string[] = [];
66
- if (showAdded) {
67
- parts.push(renderStyleForSource(theme, colorSource, addedStyle, `+${metrics.added}`));
68
- }
65
+ if (showAdded) parts.push(renderStyleForSource(theme, colorSource, style, `+${metrics.added}`));
69
66
  if (showDeleted) {
70
- parts.push(renderStyleForSource(theme, colorSource, deletedStyle, `−${metrics.deleted}`));
67
+ parts.push(renderStyleForSource(theme, colorSource, style, `−${metrics.deleted}`));
71
68
  }
72
69
  return parts.join(" ");
73
70
  }
@@ -150,6 +147,37 @@ function buildUsageFingerprint(entries: readonly SessionEntry[]): string {
150
147
  return `${entries.length}\0${entryIdentity(first)}\0${entryIdentity(last)}`;
151
148
  }
152
149
 
150
+ /** The slice of an entry's usage this footer reads. */
151
+ type UsageShape = {
152
+ input?: number;
153
+ output?: number;
154
+ cacheRead?: number;
155
+ cacheWrite?: number;
156
+ cost?: { total?: number };
157
+ };
158
+
159
+ /**
160
+ * An entry as far as usage goes. Read structurally rather than through
161
+ * `SessionEntry`: Pi 1.4 added a `usage` entry type that the type surface this
162
+ * package builds against does not know about, and a footer that silently stopped
163
+ * counting it would be worse than one that reads the field defensively.
164
+ */
165
+ type UsageEntryShape = {
166
+ type?: string;
167
+ usage?: UsageShape;
168
+ message?: { role?: string; usage?: UsageShape };
169
+ };
170
+
171
+ /**
172
+ * Everything the session spent, following Pi's own footer.
173
+ *
174
+ * A usage entry, an assistant message, a tool result and the summaries that
175
+ * compaction and branching write are all LLM calls the user paid for, and Pi
176
+ * counts all of them (`dist/modes/interactive/components/footer.js:99-114`).
177
+ * Counting only assistant messages undercounted every session that compacted,
178
+ * branched or ran a tool that reports its own usage — and compaction is exactly
179
+ * the kind of call you do not want to hide from a budget.
180
+ */
153
181
  function computeUsageTotals(entries: readonly SessionEntry[]): UsageTotals {
154
182
  usageTotalsComputeCount += 1;
155
183
  let input = 0;
@@ -159,19 +187,42 @@ function computeUsageTotals(entries: readonly SessionEntry[]): UsageTotals {
159
187
  let latestCacheHitRate: number | undefined;
160
188
  let cost = 0;
161
189
 
190
+ const addUsage = (usage: UsageShape | undefined) => {
191
+ if (!usage) return;
192
+ input += usage.input ?? 0;
193
+ output += usage.output ?? 0;
194
+ cacheRead += usage.cacheRead ?? 0;
195
+ cacheWrite += usage.cacheWrite ?? 0;
196
+ cost += usage.cost?.total ?? 0;
197
+ };
198
+
162
199
  for (const entry of entries) {
163
- if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
164
- const usage = entry.message.usage;
165
- const entryInput = usage?.input ?? 0;
166
- const entryCacheRead = usage?.cacheRead ?? 0;
167
- const entryCacheWrite = usage?.cacheWrite ?? 0;
168
-
169
- input += entryInput;
170
- output += usage?.output ?? 0;
171
- cacheRead += entryCacheRead;
172
- cacheWrite += entryCacheWrite;
173
- cost += usage?.cost?.total ?? 0;
174
- latestCacheHitRate = calculateCacheHitRate(entryInput, entryCacheRead, entryCacheWrite);
200
+ const record = entry as unknown as UsageEntryShape;
201
+ switch (record.type) {
202
+ case "usage":
203
+ addUsage(record.usage);
204
+ break;
205
+ case "message":
206
+ if (record.message?.role === "assistant") {
207
+ addUsage(record.message.usage);
208
+ // Only a real assistant turn tells you how well the prompt cache
209
+ // did; a tool result's usage carries no prompt of its own.
210
+ latestCacheHitRate = calculateCacheHitRate(
211
+ record.message.usage?.input ?? 0,
212
+ record.message.usage?.cacheRead ?? 0,
213
+ record.message.usage?.cacheWrite ?? 0,
214
+ );
215
+ } else if (record.message?.role === "toolResult") {
216
+ addUsage(record.message.usage);
217
+ }
218
+ break;
219
+ case "compaction":
220
+ case "branch_summary":
221
+ addUsage(record.usage);
222
+ break;
223
+ default:
224
+ break;
225
+ }
175
226
  }
176
227
 
177
228
  return Object.freeze({ input, output, cacheRead, cacheWrite, latestCacheHitRate, cost });
@@ -248,8 +299,31 @@ export function buildCostLabel(totals: UsageTotals): string {
248
299
  return `$${totals.cost.toFixed(3)}`;
249
300
  }
250
301
 
251
- export function buildSessionDurationLabel(startEpoch: number): string {
252
- const totalSeconds = Math.max(0, Math.floor((Date.now() - startEpoch) / 1000));
302
+ /**
303
+ * Throughput label for the `tps` pill.
304
+ *
305
+ * One decimal under 10 keeps slow models from all reading `1 t/s`, and an
306
+ * integer above it because the second digit is noise at that point and the pill
307
+ * would jitter in width. Undefined or non-positive input draws nothing: the
308
+ * segment has no content until a message has actually been measured.
309
+ */
310
+ export function buildTpsLabel(tokensPerSecond: number | undefined): string {
311
+ if (tokensPerSecond === undefined || !Number.isFinite(tokensPerSecond)) return "";
312
+ if (tokensPerSecond <= 0) return "";
313
+ const rounded =
314
+ tokensPerSecond < 10 ? tokensPerSecond.toFixed(1) : Math.round(tokensPerSecond).toString();
315
+ return `${rounded} t/s`;
316
+ }
317
+
318
+ /**
319
+ * Elapsed time at pill width: `45s`, `2m 13s`, `1h 5m`.
320
+ *
321
+ * Two units at most. The pill is a glance, not a stopwatch, and dropping the
322
+ * fine unit once the coarser one carries the meaning keeps the bar from growing
323
+ * a column every second. Negative input clamps to `0s`.
324
+ */
325
+ export function buildElapsedLabel(elapsedMs: number): string {
326
+ const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
253
327
  const hours = Math.floor(totalSeconds / 3600);
254
328
  const minutes = Math.floor((totalSeconds % 3600) / 60);
255
329
  const seconds = totalSeconds % 60;
@@ -258,6 +332,49 @@ export function buildSessionDurationLabel(startEpoch: number): string {
258
332
  return `${seconds}s`;
259
333
  }
260
334
 
335
+ export function buildSessionDurationLabel(startEpoch: number): string {
336
+ return buildElapsedLabel(Date.now() - startEpoch);
337
+ }
338
+
339
+ /**
340
+ * When the session really started.
341
+ *
342
+ * A resumed session (`--continue`, `--resume`) keeps the header of the session it
343
+ * continues, so the elapsed-time segment has to read that instead of the moment
344
+ * this process attached to it — otherwise every resumed session reads `0s`.
345
+ */
346
+ /**
347
+ * What to tell the user when compaction fails, or undefined when there is nothing
348
+ * to say.
349
+ *
350
+ * Pi emits \`session_compact_failed\` but draws nothing for it, so a failure late in
351
+ * a long session looks like the agent simply forgetting the conversation. An
352
+ * abort is the user's own doing and stays silent.
353
+ */
354
+ export function compactionFailureNotice(event: {
355
+ aborted?: boolean;
356
+ errorMessage?: string;
357
+ reason?: string;
358
+ }): string | undefined {
359
+ if (event.aborted) return undefined;
360
+ const reason =
361
+ event.reason === "overflow"
362
+ ? " (context was full)"
363
+ : event.reason === "manual"
364
+ ? ""
365
+ : " (context threshold)";
366
+ const detail = event.errorMessage?.trim();
367
+ return detail ? `Compaction failed${reason}: ${detail}` : `Compaction failed${reason}`;
368
+ }
369
+
370
+ export function resolveSessionStartEpoch(
371
+ header: { timestamp?: string } | null | undefined,
372
+ now = Date.now(),
373
+ ): number {
374
+ const parsed = header?.timestamp ? Date.parse(header.timestamp) : Number.NaN;
375
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : now;
376
+ }
377
+
261
378
  export function contextColorTier(
262
379
  percent: number | null | undefined,
263
380
  thresholds: ContextThresholds = { warning: 70, error: 90 },
@@ -471,3 +588,17 @@ export function formatOsLabel(
471
588
  ): string {
472
589
  return resolveOsIcon(configuredIcon, mode, platform);
473
590
  }
591
+
592
+ /**
593
+ * The number a status is about, for statuses configured as `formats: value`:
594
+ * `Tavily: 41.1%` → `41.1%`, `1 proc` → `1`, `¥1,447.55 spent` → `1,447.55`.
595
+ *
596
+ * Text with no number is returned unchanged: a status that is a word ("ready",
597
+ * "offline") is still the whole of what it has to say, and blanking it would
598
+ * leave an icon that means nothing.
599
+ */
600
+ export function statusValueText(text: string): string {
601
+ const match = text.match(/-?\d[\d,.]*%?/);
602
+ if (!match) return text;
603
+ return match[0].replace(/\s+/g, "");
604
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The git state the last project refresh produced.
3
+ *
4
+ * The footer draws from it every render; the pill's bubble reads it on click, so
5
+ * a click costs no `git status` of its own. `index.ts` sets it once per session
6
+ * (the state object is mutated in place) and clears it on shutdown, so a bubble
7
+ * opened after the session ended shows nothing rather than a stale tree.
8
+ */
9
+
10
+ import type { GitStatusSummary } from "./git";
11
+
12
+ let current: GitStatusSummary | undefined;
13
+
14
+ export function setActiveGitStatus(status: GitStatusSummary | undefined): void {
15
+ current = status;
16
+ }
17
+
18
+ export function activeGitStatus(): GitStatusSummary | undefined {
19
+ return current;
20
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What the bar needs git to read.
3
+ *
4
+ * Every optional probe costs a process: `git tag --points-at` for an exact tag,
5
+ * `git diff HEAD --numstat` for line counts, `git remote -v` for a forge logo.
6
+ * This decides which of them a given segment list asks for, so what is on the
7
+ * bar and what gets read cannot drift apart — the bug that made a `gitTag` pill
8
+ * render empty unless the unrelated `footerSegments.gitCommit` toggle was on.
9
+ *
10
+ * It also answers whether git should be read at all. A bar with no git pill
11
+ * draws nothing from `git status`, so the refresh skip the two processes it
12
+ * would otherwise start every thirty seconds.
13
+ */
14
+
15
+ export type GitProbeOptions = {
16
+ /** Whether any pill on the bar shows something read from the repository. */
17
+ enabled: boolean;
18
+ /** The pills that show a commit hash want its tag too, when tags are on. */
19
+ readExactTag: boolean;
20
+ readMetrics: boolean;
21
+ readHost: boolean;
22
+ };
23
+
24
+ /** Pills whose content comes from a git read. */
25
+ const GIT_SEGMENTS = ["git", "gitCommit", "gitTag", "gitMetrics"] as const;
26
+
27
+ /** Read options for `readGitStatus` plus the host lookup, from one segment list. */
28
+ export function gitProbeOptions(
29
+ segments: readonly string[],
30
+ config: { showTag: boolean; hostIcon: boolean },
31
+ ): GitProbeOptions {
32
+ const wants = (name: string) => segments.includes(name);
33
+ const wantsCommit = wants("gitCommit");
34
+ return {
35
+ enabled: GIT_SEGMENTS.some(wants),
36
+ readExactTag: wants("gitTag") || (wantsCommit && config.showTag),
37
+ readMetrics: wants("gitMetrics"),
38
+ readHost: wants("git") && config.hostIcon,
39
+ };
40
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The git part of the footer, rendered once.
3
+ *
4
+ * The bar asks for seven strings: branch icon, branch name, dirty-state block,
5
+ * operation label, commit, tag and line metrics. They live here rather than in
6
+ * `footer.ts` so that everything which decides *what a git segment says* — the
7
+ * icon set, the colours, branch length, hash length, counts instead of glyphs —
8
+ * is in one file, and `footer.ts` stays layout.
9
+ */
10
+
11
+ import type { ColorSource, ColorSpec, PolishedTuiConfig } from "./config";
12
+ import { formatGitBranchText, formatGitCommitSegment, formatGitMetricsSegment } from "./format";
13
+ import type { GitHost, GitStatusSummary } from "./git";
14
+ import type { ResolvedIcons } from "./icons";
15
+ import { renderStyleForSource, type ThemeLike } from "./style";
16
+
17
+ /** The state fields the git segments read. */
18
+ export type GitSegmentState = Pick<
19
+ GitStatusSummary,
20
+ | "branch"
21
+ | "ahead"
22
+ | "behind"
23
+ | "conflicted"
24
+ | "untracked"
25
+ | "stashed"
26
+ | "modified"
27
+ | "staged"
28
+ | "renamed"
29
+ | "deleted"
30
+ | "typechanged"
31
+ | "gitStateLabel"
32
+ | "commit"
33
+ | "metrics"
34
+ > & { gitHost?: GitHost };
35
+
36
+ /** The config the git segments read. */
37
+ export type GitSegmentConfig = Pick<PolishedTuiConfig, "colors" | "icons" | "git">;
38
+
39
+ export type GitSegments = {
40
+ /** Branch icon in branch colour; "" when the repository has no branch. */
41
+ icon: string;
42
+ /** Shortened branch name in branch colour; undefined on a detached HEAD. */
43
+ branchText: string | undefined;
44
+ /** `icon branch` as the bar shows it; "" when there is no branch. */
45
+ branch: string;
46
+ /**
47
+ * The whole thing in one pill: `icon branch [status] OPERATION`. This is what
48
+ * the `git` segment draws, so a dirty tree reads next to the branch it is
49
+ * dirty on rather than in a second pill of its own.
50
+ */
51
+ label: string;
52
+ /** Dirty-worktree glyphs and ahead/behind in a `[...]` block; "" when clean. */
53
+ status: string;
54
+ /** `REBASING`-style operation label; "" when no operation is in progress. */
55
+ state: string;
56
+ /** Short hash (and tag, when `gitCommit.showTag`), or "" when hidden. */
57
+ commit: string;
58
+ /** The tag on its own, for a `gitTag` segment; "" when there is none. */
59
+ tag: string;
60
+ /** `+added −deleted` line counts; "" when there are none to show. */
61
+ metrics: string;
62
+ };
63
+
64
+ /** Forge logo for the branch segment. Empty in ascii mode, which has no glyphs. */
65
+ export function gitHostIconGlyph(icons: ResolvedIcons, host: GitHost): string {
66
+ switch (host) {
67
+ case "github":
68
+ return icons.gitHostGithub;
69
+ case "gitlab":
70
+ return icons.gitHostGitlab;
71
+ case "bitbucket":
72
+ return icons.gitHostBitbucket;
73
+ default:
74
+ return icons.gitHostGeneric;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Render every git segment.
80
+ *
81
+ * Everything the output depends on — colours, icons, branch length, hash length,
82
+ * counts instead of lone glyphs — comes from `config.git` and `config.colors`.
83
+ */
84
+ export function renderGitSegments(
85
+ state: GitSegmentState,
86
+ config: GitSegmentConfig,
87
+ theme: ThemeLike,
88
+ colorSource: ColorSource,
89
+ ): GitSegments {
90
+ const git = config.git;
91
+ const counts = git.counts;
92
+ const paint = (spec: ColorSpec, text: string) =>
93
+ renderStyleForSource(theme, colorSource, spec, text);
94
+ const icons = config.icons;
95
+
96
+ // With git.hostIcon on, the branch icon becomes the origin remote's forge
97
+ // logo. Repos with no origin, and ascii mode (whose host glyphs are all
98
+ // empty), fall back to the plain branch icon.
99
+ const hostGlyph = git.hostIcon && state.gitHost ? gitHostIconGlyph(icons, state.gitHost) : "";
100
+ const glyph = hostGlyph || icons.git;
101
+ const icon = glyph ? paint(config.colors.gitBranch, glyph) : "";
102
+
103
+ const branchText = state.branch
104
+ ? paint(config.colors.gitBranch, formatGitBranchText(state.branch, git.branch.maxLength))
105
+ : undefined;
106
+ // A detached HEAD has no branch name; `HEAD` still says where the tree is, and
107
+ // it is what the text footer used to print.
108
+ const headText =
109
+ branchText ??
110
+ (state.commit && !state.branch
111
+ ? paint(config.colors.gitBranch, state.commit.oid ? "HEAD" : "")
112
+ : undefined);
113
+
114
+ const stashLabel =
115
+ state.stashed > 0 ? (counts ? `${icons.stashed}${state.stashed}` : icons.stashed) : "";
116
+ const dirty = [
117
+ state.conflicted > 0 ? icons.conflicted : "",
118
+ stashLabel,
119
+ state.deleted > 0 ? icons.deleted : "",
120
+ state.renamed > 0 ? icons.renamed : "",
121
+ state.modified > 0 ? icons.modified : "",
122
+ state.typechanged > 0 ? icons.typechanged : "",
123
+ state.staged > 0 ? icons.staged : "",
124
+ state.untracked > 0 ? icons.untracked : "",
125
+ ].join("");
126
+ const aheadBehind = (() => {
127
+ if (state.ahead > 0 && state.behind > 0) {
128
+ return counts ? `${icons.ahead}${state.ahead}${icons.behind}${state.behind}` : icons.diverged;
129
+ }
130
+ if (state.ahead > 0) return counts ? `${icons.ahead}${state.ahead}` : icons.ahead;
131
+ if (state.behind > 0) return counts ? `${icons.behind}${state.behind}` : icons.behind;
132
+ return "";
133
+ })();
134
+ // Plain text: the pill paints its whole label in one contrast colour, so
135
+ // colouring pieces here would only be stripped again.
136
+ const status = dirty || aheadBehind ? `[${dirty}${aheadBehind}]` : "";
137
+
138
+ const operation = state.gitStateLabel ?? "";
139
+
140
+ const commit = formatGitCommitSegment(
141
+ theme,
142
+ state.commit,
143
+ git.commit,
144
+ colorSource,
145
+ config.colors.gitCommit,
146
+ );
147
+ const tag =
148
+ git.commit.showTag && state.commit?.tag ? paint(config.colors.gitCommit, state.commit.tag) : "";
149
+ const metrics = formatGitMetricsSegment(
150
+ theme,
151
+ state.metrics,
152
+ git.metrics,
153
+ colorSource,
154
+ config.colors.gitMetricsAdded,
155
+ );
156
+
157
+ const branch = branchText ? (icon ? `${icon} ${branchText}` : branchText) : "";
158
+ const labelParts = [
159
+ headText ? (icon ? `${icon} ${headText}` : headText) : "",
160
+ status,
161
+ operation,
162
+ ].filter(Boolean);
163
+
164
+ return {
165
+ icon,
166
+ branchText,
167
+ branch,
168
+ label: labelParts.join(" "),
169
+ status,
170
+ state: operation,
171
+ commit,
172
+ tag,
173
+ metrics,
174
+ };
175
+ }
@@ -6,6 +6,20 @@ import { promisify } from "node:util";
6
6
  const execFileAsync = promisify(execFile);
7
7
  const GIT_COMMAND_TIMEOUT_MS = 2_000;
8
8
 
9
+ /**
10
+ * Read the repository without writing to it.
11
+ *
12
+ * `status` and `diff` refresh the index's stat information as a side effect,
13
+ * which means taking `.git/index.lock` and rewriting `.git/index`. From a footer
14
+ * that polls every thirty seconds that is churn on the user's repository, and a
15
+ * lock that can collide with their own `git commit`. `GIT_OPTIONAL_LOCKS=0` tells
16
+ * git to skip every lock it does not need to answer the question (git 2.15+).
17
+ *
18
+ * An environment variable rather than `--no-optional-locks`: an older git ignores
19
+ * a variable it does not know, and fails on a flag it does not know.
20
+ */
21
+ const GIT_READ_ENV: NodeJS.ProcessEnv = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
22
+
9
23
  export type GitOperationState =
10
24
  | "REBASING"
11
25
  | "MERGING"
@@ -37,6 +51,17 @@ export type GitMetricsInfo = {
37
51
  deleted: number;
38
52
  };
39
53
 
54
+ /**
55
+ * One changed path, as `git status` reports it.
56
+ *
57
+ * `code` is a single letter for the pill's bubble: the index side when there is
58
+ * one (`M` staged), the worktree side otherwise. `?` untracked, `U` conflicted.
59
+ */
60
+ export type GitChange = {
61
+ code: string;
62
+ path: string;
63
+ };
64
+
40
65
  export type GitStatusSummary = {
41
66
  branch?: string;
42
67
  dirty: boolean;
@@ -54,6 +79,11 @@ export type GitStatusSummary = {
54
79
  gitStateLabel?: string;
55
80
  commit?: GitCommitInfo;
56
81
  metrics?: GitMetricsInfo | null;
82
+ /**
83
+ * Changed paths, in `git status` order, capped at `MAX_TRACKED_CHANGES`. Only
84
+ * the bubble reads them; the pill itself is drawn from the counts.
85
+ */
86
+ changes: GitChange[];
57
87
  };
58
88
 
59
89
  export type GitReadResult =
@@ -90,9 +120,36 @@ export function emptyGitStatus(): GitStatusSummary {
90
120
  gitStateLabel: undefined,
91
121
  commit: undefined,
92
122
  metrics: undefined,
123
+ changes: [],
93
124
  };
94
125
  }
95
126
 
127
+ /** Cap on remembered paths: the bubble shows a dozen, the rest is `+N more`. */
128
+ const MAX_TRACKED_CHANGES = 200;
129
+
130
+ /**
131
+ * The rest of a porcelain v2 line after its fixed fields.
132
+ *
133
+ * The path is whatever follows the last space-separated field, so paths with
134
+ * spaces survive: `1 .M N... 100644 100644 100644 abc def my file.txt` keeps
135
+ * `my file.txt` whole.
136
+ */
137
+ function porcelainPath(line: string, fields: number): string {
138
+ let index = 0;
139
+ for (let field = 0; field < fields; field += 1) {
140
+ index = line.indexOf(" ", index);
141
+ if (index < 0) return "";
142
+ index += 1;
143
+ }
144
+ return line.slice(index);
145
+ }
146
+
147
+ function addChange(status: GitStatusSummary, code: string, path: string): void {
148
+ const trimmed = path.trim();
149
+ if (!trimmed || status.changes.length >= MAX_TRACKED_CHANGES) return;
150
+ status.changes.push({ code: code === "." || code === " " ? "M" : code, path: trimmed });
151
+ }
152
+
96
153
  export function parseGitStatusPorcelain(stdoutText: string, stashCount: number): GitStatusSummary {
97
154
  const status = emptyGitStatus();
98
155
  status.stashed = stashCount;
@@ -133,10 +190,12 @@ export function parseGitStatusPorcelain(stdoutText: string, stashCount: number):
133
190
 
134
191
  if (line.startsWith("? ")) {
135
192
  status.untracked += 1;
193
+ addChange(status, "?", line.slice(2));
136
194
  continue;
137
195
  }
138
196
  if (line.startsWith("u ")) {
139
197
  status.conflicted += 1;
198
+ addChange(status, "U", porcelainPath(line, 10));
140
199
  continue;
141
200
  }
142
201
  if (!(line.startsWith("1 ") || line.startsWith("2 "))) continue;
@@ -153,6 +212,11 @@ export function parseGitStatusPorcelain(stdoutText: string, stashCount: number):
153
212
  if (y === "M") status.modified += 1;
154
213
  else if (y === "D") status.deleted += 1;
155
214
  else if (y === "T") status.typechanged += 1;
215
+
216
+ // `2` lines carry an extra score field before the path, and a rename adds
217
+ // `<tab>original` to it; the new name is what the bubble lists.
218
+ const path = porcelainPath(line, line.startsWith("2 ") ? 9 : 8).split("\t")[0] ?? "";
219
+ addChange(status, x !== "." ? x : y, path);
156
220
  }
157
221
 
158
222
  // Only populate commit info when we actually saw branch headers (inside
@@ -236,6 +300,7 @@ async function resolveGitPath(cwd: string, pathSpec: string): Promise<string | u
236
300
  ["--no-optional-locks", "rev-parse", "--git-path", pathSpec],
237
301
  {
238
302
  cwd,
303
+ env: GIT_READ_ENV,
239
304
  timeout: GIT_COMMAND_TIMEOUT_MS,
240
305
  },
241
306
  );
@@ -313,10 +378,12 @@ export async function readGitStatus(
313
378
  const [{ stdout: statusStdout }, stashResult, tagResult, metricsResult] = await Promise.all([
314
379
  execFileAsync("git", ["--no-optional-locks", "status", "--porcelain=2", "--branch"], {
315
380
  cwd,
381
+ env: GIT_READ_ENV,
316
382
  timeout: GIT_COMMAND_TIMEOUT_MS,
317
383
  }),
318
384
  execFileAsync("git", ["--no-optional-locks", "stash", "list"], {
319
385
  cwd,
386
+ env: GIT_READ_ENV,
320
387
  timeout: GIT_COMMAND_TIMEOUT_MS,
321
388
  }).catch(() => ({ stdout: "" })),
322
389
  readExactTag
@@ -325,6 +392,7 @@ export async function readGitStatus(
325
392
  ["--no-optional-locks", "describe", "--tags", "--exact-match", "HEAD"],
326
393
  {
327
394
  cwd,
395
+ env: GIT_READ_ENV,
328
396
  timeout: GIT_COMMAND_TIMEOUT_MS,
329
397
  },
330
398
  ).then(
@@ -335,6 +403,7 @@ export async function readGitStatus(
335
403
  readMetrics
336
404
  ? execFileAsync("git", numstatArgs, {
337
405
  cwd,
406
+ env: GIT_READ_ENV,
338
407
  timeout: GIT_COMMAND_TIMEOUT_MS,
339
408
  }).then(
340
409
  (r) => ({ stdout: typeof r.stdout === "string" ? r.stdout : String(r.stdout) }),
@@ -382,6 +451,7 @@ export async function readGitStatus(
382
451
  ["--no-optional-locks", "rev-parse", "--is-inside-work-tree"],
383
452
  {
384
453
  cwd,
454
+ env: GIT_READ_ENV,
385
455
  timeout: GIT_COMMAND_TIMEOUT_MS,
386
456
  },
387
457
  );
@@ -450,6 +520,7 @@ export async function readGitHost(cwd: string): Promise<GitHost | undefined> {
450
520
  ["--no-optional-locks", "remote", "get-url", "origin"],
451
521
  {
452
522
  cwd,
523
+ env: GIT_READ_ENV,
453
524
  timeout: GIT_COMMAND_TIMEOUT_MS,
454
525
  },
455
526
  );
@@ -38,6 +38,12 @@ export type IconGlyphs = {
38
38
  context: string;
39
39
  cost: string;
40
40
  tokens: string;
41
+ /** Decode throughput, shown only after a message has been measured. */
42
+ tps: string;
43
+ /** The queued-messages pill, shown in front of the count. */
44
+ queue: string;
45
+ /** The running-subagents pill. Same glyph pi-tidy uses for subagent tool rows. */
46
+ subagents: string;
41
47
  /** Forge logos for the branch segment, used only when gitHostIcon is on. */
42
48
  gitHostGithub: string;
43
49
  gitHostGitlab: string;
@@ -73,6 +79,9 @@ export const ICON_GLYPH_KEYS = [
73
79
  "context",
74
80
  "cost",
75
81
  "tokens",
82
+ "tps",
83
+ "queue",
84
+ "subagents",
76
85
  "gitHostGithub",
77
86
  "gitHostGitlab",
78
87
  "gitHostBitbucket",
@@ -114,6 +123,11 @@ export const NERD_DEFAULT_ICONS: IconGlyphs = {
114
123
  context: "",
115
124
  cost: "",
116
125
  tokens: "",
126
+ tps: "",
127
+ // fa-inbox: messages waiting their turn.
128
+ queue: "\uf01c",
129
+ // fa-users, the glyph pi-tidy draws for a subagent tool row.
130
+ subagents: "\uf0c0",
117
131
  gitHostGithub: "",
118
132
  gitHostGitlab: "",
119
133
  gitHostBitbucket: "",
@@ -146,6 +160,9 @@ export const ASCII_DEFAULT_ICONS: IconGlyphs = {
146
160
  context: "",
147
161
  cost: "",
148
162
  tokens: "",
163
+ tps: "",
164
+ queue: "",
165
+ subagents: "",
149
166
  // gitHostIcon is a Nerd Font feature; it disables itself in ascii mode.
150
167
  gitHostGithub: "",
151
168
  gitHostGitlab: "",