@xzzpig/pi-starline 0.5.1 → 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.
- package/FORK-CHANGELOG.md +19 -0
- package/README.md +68 -181
- package/extensions/starline/config.ts +156 -326
- package/extensions/starline/context-live.ts +33 -0
- package/extensions/starline/editor-cursor.ts +3 -0
- package/extensions/starline/extension-status.ts +9 -5
- package/extensions/starline/footer.ts +331 -528
- package/extensions/starline/fork-editor-cursor.ts +14 -0
- package/extensions/starline/format.ts +151 -20
- package/extensions/starline/git-live.ts +20 -0
- package/extensions/starline/git-probe.ts +40 -0
- package/extensions/starline/git-segments.ts +175 -0
- package/extensions/starline/git.ts +71 -0
- package/extensions/starline/icons.ts +17 -0
- package/extensions/starline/index.ts +188 -91
- package/extensions/starline/live-session.ts +164 -0
- package/extensions/starline/live-tps.ts +165 -0
- package/extensions/starline/mouse/bubble-dismiss.ts +82 -0
- package/extensions/starline/mouse/cursor-marker.ts +69 -0
- package/extensions/starline/mouse/editor-hit-test.ts +6 -3
- package/extensions/starline/mouse/external-layer.ts +42 -0
- package/extensions/starline/mouse/fork-native-clipboard.ts +57 -0
- package/extensions/starline/mouse/fork-pill-mouse.ts +19 -0
- package/extensions/starline/mouse/fork-pointer-coordination.ts +10 -0
- package/extensions/starline/mouse/fork-transcript-copy.ts +54 -0
- package/extensions/starline/mouse/fork-wheel-compat.ts +22 -0
- package/extensions/starline/mouse/hint.ts +4 -4
- package/extensions/starline/mouse/index.ts +54 -24
- package/extensions/starline/mouse/pointer-events.ts +73 -0
- package/extensions/starline/mouse/pointer-shape.ts +138 -0
- package/extensions/starline/mouse/transcript-copy.ts +76 -33
- package/extensions/starline/paste-collapse.ts +52 -13
- package/extensions/starline/pill-actions.ts +590 -0
- package/extensions/starline/pill-anchors.ts +26 -0
- package/extensions/starline/pill-api.ts +80 -0
- package/extensions/starline/pill-bubble.ts +607 -0
- package/extensions/starline/pill-config.ts +67 -9
- package/extensions/starline/pill.ts +102 -20
- package/extensions/starline/project-probe.ts +79 -0
- package/extensions/starline/prototype-patch-registry.ts +6 -1
- package/extensions/starline/queue-rows.ts +43 -0
- package/extensions/starline/settings-command.ts +124 -177
- package/extensions/starline/subagent-fleet.ts +246 -0
- package/extensions/starline/turn-clock.ts +81 -0
- package/extensions/starline/ui.ts +297 -156
- package/extensions/starline/working-indicator.ts +189 -0
- package/package.json +6 -5
- package/types/pill-api.d.ts +115 -0
- package/extensions/starline/editor-metadata-format.ts +0 -259
- 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
|
-
|
|
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,
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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: "",
|