@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.
Files changed (50) hide show
  1. package/FORK-CHANGELOG.md +19 -0
  2. package/README.md +68 -181
  3. package/extensions/starline/config.ts +156 -326
  4. package/extensions/starline/context-live.ts +33 -0
  5. package/extensions/starline/editor-cursor.ts +3 -0
  6. package/extensions/starline/extension-status.ts +9 -5
  7. package/extensions/starline/footer.ts +331 -528
  8. package/extensions/starline/fork-editor-cursor.ts +14 -0
  9. package/extensions/starline/format.ts +151 -20
  10. package/extensions/starline/git-live.ts +20 -0
  11. package/extensions/starline/git-probe.ts +40 -0
  12. package/extensions/starline/git-segments.ts +175 -0
  13. package/extensions/starline/git.ts +71 -0
  14. package/extensions/starline/icons.ts +17 -0
  15. package/extensions/starline/index.ts +188 -91
  16. package/extensions/starline/live-session.ts +164 -0
  17. package/extensions/starline/live-tps.ts +165 -0
  18. package/extensions/starline/mouse/bubble-dismiss.ts +82 -0
  19. package/extensions/starline/mouse/cursor-marker.ts +69 -0
  20. package/extensions/starline/mouse/editor-hit-test.ts +6 -3
  21. package/extensions/starline/mouse/external-layer.ts +42 -0
  22. package/extensions/starline/mouse/fork-native-clipboard.ts +57 -0
  23. package/extensions/starline/mouse/fork-pill-mouse.ts +19 -0
  24. package/extensions/starline/mouse/fork-pointer-coordination.ts +10 -0
  25. package/extensions/starline/mouse/fork-transcript-copy.ts +54 -0
  26. package/extensions/starline/mouse/fork-wheel-compat.ts +22 -0
  27. package/extensions/starline/mouse/hint.ts +4 -4
  28. package/extensions/starline/mouse/index.ts +54 -24
  29. package/extensions/starline/mouse/pointer-events.ts +73 -0
  30. package/extensions/starline/mouse/pointer-shape.ts +138 -0
  31. package/extensions/starline/mouse/transcript-copy.ts +76 -33
  32. package/extensions/starline/paste-collapse.ts +52 -13
  33. package/extensions/starline/pill-actions.ts +590 -0
  34. package/extensions/starline/pill-anchors.ts +26 -0
  35. package/extensions/starline/pill-api.ts +80 -0
  36. package/extensions/starline/pill-bubble.ts +607 -0
  37. package/extensions/starline/pill-config.ts +67 -9
  38. package/extensions/starline/pill.ts +102 -20
  39. package/extensions/starline/project-probe.ts +79 -0
  40. package/extensions/starline/prototype-patch-registry.ts +6 -1
  41. package/extensions/starline/queue-rows.ts +43 -0
  42. package/extensions/starline/settings-command.ts +124 -177
  43. package/extensions/starline/subagent-fleet.ts +246 -0
  44. package/extensions/starline/turn-clock.ts +81 -0
  45. package/extensions/starline/ui.ts +297 -156
  46. package/extensions/starline/working-indicator.ts +189 -0
  47. package/package.json +6 -5
  48. package/types/pill-api.d.ts +115 -0
  49. package/extensions/starline/editor-metadata-format.ts +0 -259
  50. package/extensions/starline/footer-format.ts +0 -279
@@ -26,6 +26,8 @@ export type PillConfig = {
26
26
  separator: PillSeparator;
27
27
  bold: boolean;
28
28
  caps: PillCaps;
29
+ /** Unfold a pill's bubble upward from the pill instead of showing it at once. */
30
+ bubbleAnimation: boolean;
29
31
  };
30
32
 
31
33
  export const EXTENSION_STATUS_SEGMENT = "extensionStatus";
@@ -35,18 +37,17 @@ export const EXTENSION_STATUS_PREFIX = `${EXTENSION_STATUS_SEGMENT}:`;
35
37
  export const PILL_SEGMENT_VARIABLES: Record<string, string> = {
36
38
  model: "model",
37
39
  thinking: "thinking",
40
+ queue: "queue",
41
+ subagents: "subagents",
38
42
  cwd: "cwd",
39
43
  sessionName: "session_name",
40
- gitBranch: "git_branch",
41
- gitStatus: "git_status",
42
- gitState: "git_state",
44
+ git: "git",
43
45
  gitCommit: "git_commit",
44
46
  gitTag: "git_tag",
45
47
  gitMetrics: "git_metrics",
46
48
  runtime: "runtime",
47
49
  context: "context",
48
- tokens: "tokens",
49
- cacheHit: "cache_hit",
50
+ tps: "tps",
50
51
  cost: "cost",
51
52
  sessionDuration: "session_duration",
52
53
  username: "username",
@@ -59,16 +60,19 @@ export const DEFAULT_PILL_CONFIG: PillConfig = {
59
60
  segments: [
60
61
  "model",
61
62
  "thinking",
63
+ "queue",
64
+ "subagents",
62
65
  "cwd",
63
- "gitBranch",
64
- "gitStatus",
66
+ "git",
65
67
  "context",
68
+ "tps",
66
69
  "cost",
67
70
  EXTENSION_STATUS_SEGMENT,
68
71
  ],
69
72
  separator: "powerline",
70
73
  bold: true,
71
74
  caps: "round",
75
+ bubbleAnimation: true,
72
76
  };
73
77
 
74
78
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -98,15 +102,65 @@ export function isKnownPillSegment(name: string): boolean {
98
102
  return name in PILL_SEGMENT_VARIABLES;
99
103
  }
100
104
 
105
+ /**
106
+ * Every segment name `/starline` can put on the bar, in a fixed order so the
107
+ * rows do not move under the cursor when a position changes. Per-status
108
+ * `extensionStatus:<key>` entries are not listed here: they depend on what other
109
+ * extensions published this session, and the *Pills* tab appends a row for each
110
+ * one after these.
111
+ */
112
+ export const PILL_SEGMENT_NAMES: readonly string[] = [
113
+ ...Object.keys(PILL_SEGMENT_VARIABLES),
114
+ EXTENSION_STATUS_SEGMENT,
115
+ ];
116
+
117
+ /** 1-based position of a segment in the bar, or 0 when it is not drawn. */
118
+ export function pillSegmentPosition(segments: readonly string[], name: string): number {
119
+ return segments.indexOf(name) + 1;
120
+ }
121
+
122
+ /**
123
+ * Apply a position edit to `pill.segments`.
124
+ *
125
+ * `value` is `off` or a 1-based position. A position is where the segment ends
126
+ * up: everything already there shifts along, and a position past the end
127
+ * appends. Moving the segment that is already in the bar works the same way,
128
+ * because it is removed before the index is applied.
129
+ */
130
+ export function movePillSegment(
131
+ segments: readonly string[],
132
+ name: string,
133
+ value: string,
134
+ ): string[] {
135
+ const rest = segments.filter((segment) => segment !== name);
136
+ if (value === "off") return rest;
137
+ const position = Number(value);
138
+ if (!Number.isInteger(position) || position < 1) return [...segments];
139
+ const index = Math.min(position - 1, rest.length);
140
+ return [...rest.slice(0, index), name, ...rest.slice(index)];
141
+ }
142
+
143
+ /** Segment names that became part of another one. */
144
+ const LEGACY_SEGMENT_NAMES: Record<string, string> = {
145
+ gitBranch: "git",
146
+ gitStatus: "git",
147
+ gitState: "git",
148
+ tokens: "context",
149
+ cacheHit: "context",
150
+ };
151
+
101
152
  function parseSegments(value: unknown): string[] {
102
153
  if (!Array.isArray(value)) return DEFAULT_PILL_CONFIG.segments;
103
154
  const segments: string[] = [];
104
155
  for (const entry of value) {
105
156
  if (typeof entry !== "string") continue;
106
- const name = entry.trim();
157
+ // A bar that listed the branch and the dirty block separately now draws
158
+ // both in one pill, so the old names collapse into it instead of vanishing.
159
+ const renamed = LEGACY_SEGMENT_NAMES[entry.trim()] ?? entry.trim();
107
160
  // Unknown names are dropped rather than rendered as empty pills, so a
108
161
  // typo shortens the bar instead of punching a hole in it.
109
- if (name && isKnownPillSegment(name) && !segments.includes(name)) segments.push(name);
162
+ if (renamed && isKnownPillSegment(renamed) && !segments.includes(renamed))
163
+ segments.push(renamed);
110
164
  }
111
165
  return segments;
112
166
  }
@@ -118,5 +172,9 @@ export function normalizePillConfig(value: unknown): PillConfig {
118
172
  separator: parseSeparator(value.separator),
119
173
  bold: typeof value.bold === "boolean" ? value.bold : DEFAULT_PILL_CONFIG.bold,
120
174
  caps: parseCaps(value.caps),
175
+ bubbleAnimation:
176
+ typeof value.bubbleAnimation === "boolean"
177
+ ? value.bubbleAnimation
178
+ : DEFAULT_PILL_CONFIG.bubbleAnimation,
121
179
  };
122
180
  }
@@ -43,6 +43,42 @@ const CAP_GLYPHS = { left: "", right: "" };
43
43
  /** Divider used where a solid arrow would be invisible. */
44
44
  const THIN_SEPARATOR = SEPARATOR_GLYPHS["powerline-thin"];
45
45
 
46
+ /**
47
+ * Where one pill ended up in a rendered bar, in visible columns.
48
+ *
49
+ * `startColumn` is inclusive, `endColumn` exclusive. Columns count what the
50
+ * terminal draws, so a CJK pill spans two cells per character and a pill that
51
+ * `width` cut in half still reports the cells it kept.
52
+ *
53
+ * A span covers everything the pill visually owns: its own padding, the
54
+ * separator that points out of it, and — on the two ends of the bar — the cap.
55
+ * That leaves no dead cells between two pills for a click to fall into.
56
+ */
57
+ export type PillSpan = {
58
+ /** `PillInput.key` of the segment this span belongs to. */
59
+ key: string;
60
+ startColumn: number;
61
+ endColumn: number;
62
+ /**
63
+ * First column painted solid in the pill's colour. The hit area starts earlier
64
+ * on the first pill, whose round cap is the colour drawn on the terminal's own
65
+ * background; anything that has to line up with the pill's edge uses this.
66
+ */
67
+ bodyStartColumn: number;
68
+ /** First column after the pill's own colour: where its arrow or cap starts. */
69
+ bodyEndColumn: number;
70
+ /** Background SGR the pill was painted with, for anything that has to match it. */
71
+ bg: string;
72
+ /** Foreground SGR, or "" when the text carried its own styling. */
73
+ fg: string;
74
+ };
75
+
76
+ export type PillRenderOptions = {
77
+ ascii?: boolean;
78
+ /** Called once per pill that survived truncation, in left-to-right order. */
79
+ onSpan?: (span: PillSpan) => void;
80
+ };
81
+
46
82
  export type PillInput = {
47
83
  /** Segment name, for diagnostics. */
48
84
  key: string;
@@ -123,6 +159,7 @@ export function collectPillInputs(
123
159
  }
124
160
 
125
161
  type PreparedPill = {
162
+ key: string;
126
163
  text: string;
127
164
  bg: string;
128
165
  fg: string;
@@ -213,7 +250,7 @@ function prepare(
213
250
  if (!text || visibleWidth(text) === 0) continue;
214
251
 
215
252
  if (input.keepStyling) {
216
- pills.push({ text, bg: neutral, fg: "", bold: false });
253
+ pills.push({ key: input.key, text, bg: neutral, fg: "", bold: false });
217
254
  continue;
218
255
  }
219
256
 
@@ -221,6 +258,7 @@ function prepare(
221
258
  const bg = resolved.bg ?? resolveBackgroundSgr(theme, source, input.spec) ?? neutral;
222
259
  // An explicit fg: in the spec wins; otherwise pick for legibility.
223
260
  pills.push({
261
+ key: input.key,
224
262
  text,
225
263
  bg,
226
264
  fg: resolved.bg && resolved.fg ? resolved.fg : contrastTextSgr(bg),
@@ -234,6 +272,11 @@ function prepare(
234
272
  /**
235
273
  * Render the pill bar. Returns "" when nothing has content, so the caller can
236
274
  * fall back rather than emit a lone pair of caps.
275
+ *
276
+ * `options.onSpan` receives the hit area of every pill the terminal will
277
+ * actually show, measured in the bar's own visible columns. Recording the spans
278
+ * here rather than recomputing them later keeps them in step with the render:
279
+ * colour, caps, separators and truncation are all already accounted for.
237
280
  */
238
281
  export function renderPillBar(
239
282
  inputs: PillInput[],
@@ -241,7 +284,7 @@ export function renderPillBar(
241
284
  source: ColorSource,
242
285
  config: PillConfig,
243
286
  width: number,
244
- options: { ascii?: boolean } = {},
287
+ options: PillRenderOptions = {},
245
288
  ): string {
246
289
  const pills = prepare(inputs, theme, source, config);
247
290
  if (pills.length === 0) return "";
@@ -255,41 +298,80 @@ export function renderPillBar(
255
298
  const rightCap = wantsCaps ? CAP_GLYPHS.right : "";
256
299
 
257
300
  let out = "";
301
+ // Columns the terminal will draw so far. Getting this from `visibleWidth`
302
+ // rather than string length is what makes CJK segment text hit the right cells.
303
+ let column = 0;
304
+ const emit = (piece: string) => {
305
+ out += piece;
306
+ column += visibleWidth(piece);
307
+ };
258
308
 
309
+ const capStart = column;
259
310
  const firstBg = pills[0]?.bg;
260
311
  if (leftCap && firstBg) {
261
312
  const capFg = toForegroundSgr(firstBg);
262
- if (capFg) out += `${DEFAULT_BG}${capFg}${leftCap}${RESET}`;
313
+ if (capFg) emit(`${DEFAULT_BG}${capFg}${leftCap}${RESET}`);
263
314
  }
264
315
 
316
+ const spans: PillSpan[] = [];
317
+
265
318
  pills.forEach((pill, index) => {
266
- out += `${pill.bg}${pill.fg}${pill.bold ? BOLD : ""} ${pill.text} ${RESET}`;
319
+ // The left cap opens the bar in the first pill's colour, so it belongs to it.
320
+ const startColumn = index === 0 ? capStart : column;
321
+ const bodyStartColumn = column;
322
+ emit(`${pill.bg}${pill.fg}${pill.bold ? BOLD : ""} ${pill.text} ${RESET}`);
323
+ const bodyEndColumn = column;
267
324
 
268
325
  const next = pills[index + 1];
269
326
  if (next) {
270
- if (!separator) return;
271
-
272
327
  // A solid arrow works by being the left colour on the right colour, so
273
328
  // it vanishes when the two pills share a background — several segments
274
329
  // default to the same theme colour, which merged them into one blob.
275
330
  // Fall back to the thin divider in the text colour, which stays visible.
276
- if (pill.bg === next.bg) {
277
- out += `${pill.bg}${pill.fg || contrastTextSgr(pill.bg)}${THIN_SEPARATOR}${RESET}`;
278
- return;
331
+ if (separator && pill.bg === next.bg) {
332
+ emit(`${pill.bg}${pill.fg || contrastTextSgr(pill.bg)}${THIN_SEPARATOR}${RESET}`);
333
+ } else if (separator) {
334
+ const arrowFg = toForegroundSgr(pill.bg);
335
+ // Without a usable foreground the arrow would be invisible; letting the
336
+ // backgrounds abut is better than drawing a glyph in the wrong colour.
337
+ if (arrowFg) emit(`${next.bg}${arrowFg}${separator}${RESET}`);
279
338
  }
280
-
281
- const arrowFg = toForegroundSgr(pill.bg);
282
- // Without a usable foreground the arrow would be invisible; letting the
283
- // backgrounds abut is better than drawing a glyph in the wrong colour.
284
- out += arrowFg ? `${next.bg}${arrowFg}${separator}${RESET}` : "";
285
- return;
286
- }
287
-
288
- if (rightCap) {
339
+ } else if (rightCap) {
289
340
  const capFg = toForegroundSgr(pill.bg);
290
- if (capFg) out += `${DEFAULT_BG}${capFg}${rightCap}${RESET}`;
341
+ if (capFg) emit(`${DEFAULT_BG}${capFg}${rightCap}${RESET}`);
291
342
  }
343
+
344
+ // The arrow (or cap) is emitted before the span closes, so it belongs to
345
+ // the pill that points out of itself.
346
+ spans.push({
347
+ key: pill.key,
348
+ startColumn,
349
+ endColumn: column,
350
+ bodyStartColumn,
351
+ bodyEndColumn,
352
+ bg: pill.bg,
353
+ fg: pill.fg,
354
+ });
292
355
  });
293
356
 
294
- return width > 0 ? truncateToWidth(out, width, "") : out;
357
+ const rendered = width > 0 ? truncateToWidth(out, width, "") : out;
358
+ if (options.onSpan) {
359
+ // Truncation keeps a prefix, so surviving cells keep their columns; only
360
+ // the far end needs clamping, and a pill clipped away entirely vanishes.
361
+ const visible = visibleWidth(rendered);
362
+ for (const span of spans) {
363
+ const startColumn = Math.min(span.startColumn, visible);
364
+ const endColumn = Math.min(span.endColumn, visible);
365
+ if (endColumn > startColumn) {
366
+ const bodyStartColumn = Math.min(span.bodyStartColumn, endColumn - 1);
367
+ const bodyEndColumn = Math.max(
368
+ bodyStartColumn + 1,
369
+ Math.min(span.bodyEndColumn, endColumn),
370
+ );
371
+ options.onSpan({ ...span, startColumn, endColumn, bodyStartColumn, bodyEndColumn });
372
+ }
373
+ }
374
+ }
375
+
376
+ return rendered;
295
377
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * One project refresh's reads: git, the runtime in this directory, and the
3
+ * project's own version.
4
+ *
5
+ * Kept out of the extension entry so a test can hand it fake readers and see
6
+ * which of them a given bar actually asks for — the question "does a bar with
7
+ * no git pill still run `git status`?" is not answerable from a closure.
8
+ *
9
+ * The readers are injected rather than imported at the call site for exactly
10
+ * that reason; production passes the real ones.
11
+ */
12
+
13
+ import type { PolishedTuiConfig } from "./config";
14
+ import {
15
+ emptyGitStatus,
16
+ type GitHost,
17
+ type GitReadResult,
18
+ readGitHost,
19
+ readGitStatus,
20
+ } from "./git";
21
+ import { gitProbeOptions } from "./git-probe";
22
+ import { type PackageVersionReadResult, readPackageVersionResult } from "./package-version";
23
+ import { type RuntimeReadResult, readRuntimeInfo } from "./runtime";
24
+
25
+ export type ProjectProbe = {
26
+ git: GitReadResult;
27
+ /** Undefined when the bar does not ask for a forge icon: keep the last one. */
28
+ gitHost: GitHost | undefined;
29
+ runtime: RuntimeReadResult;
30
+ /** Undefined when the bar does not show the project version. */
31
+ packageVersion: PackageVersionReadResult | undefined;
32
+ };
33
+
34
+ export type ProjectProbeReaders = {
35
+ readGitStatus: typeof readGitStatus;
36
+ readGitHost: typeof readGitHost;
37
+ readRuntimeInfo: typeof readRuntimeInfo;
38
+ readPackageVersion: typeof readPackageVersionResult;
39
+ };
40
+
41
+ const REAL_READERS: ProjectProbeReaders = {
42
+ readGitStatus,
43
+ readGitHost,
44
+ readRuntimeInfo,
45
+ readPackageVersion: readPackageVersionResult,
46
+ };
47
+
48
+ /**
49
+ * Read what the bar is about to draw, in parallel.
50
+ *
51
+ * A bar with no git pill gets an empty status instead of a read, which also
52
+ * clears the branch and dirty markers the previous bar left in state — the
53
+ * object is reused across refreshes, so skipping the read must not mean keeping
54
+ * yesterday's branch.
55
+ */
56
+ export async function probeProject(
57
+ cwd: string,
58
+ config: PolishedTuiConfig,
59
+ readers: ProjectProbeReaders = REAL_READERS,
60
+ ): Promise<ProjectProbe> {
61
+ const probe = gitProbeOptions(config.pill.segments, {
62
+ showTag: config.git.commit.showTag,
63
+ hostIcon: config.git.hostIcon,
64
+ });
65
+ const wantPackage = config.pill.segments.includes("packageVersion");
66
+ const [git, runtime, packageVersion, gitHost] = await Promise.all([
67
+ probe.enabled
68
+ ? readers.readGitStatus(cwd, {
69
+ readExactTag: probe.readExactTag,
70
+ readMetrics: probe.readMetrics,
71
+ ignoreSubmodules: config.git.metrics.ignoreSubmodules,
72
+ })
73
+ : Promise.resolve<GitReadResult>({ kind: "ok", status: emptyGitStatus() }),
74
+ readers.readRuntimeInfo(cwd),
75
+ wantPackage ? readers.readPackageVersion(cwd) : Promise.resolve(undefined),
76
+ probe.readHost ? readers.readGitHost(cwd) : Promise.resolve(undefined),
77
+ ]);
78
+ return { git, gitHost, runtime, packageVersion };
79
+ }
@@ -21,7 +21,12 @@ const LEGACY_PROTOTYPE_PATCH_REGISTRY = Symbol.for("pi-zentui.prototype-patch-re
21
21
  type PrototypePatchAdapter =
22
22
  | "user-message-render"
23
23
  | "user-message-invalidate"
24
- | "selector-border-render";
24
+ | "selector-border-render"
25
+ | "selection-marker-dedupe"
26
+ | "agent-session-capture-prompt"
27
+ | "agent-session-capture-usage"
28
+ | "pending-messages-hide"
29
+ | "mouse-pointer-shape";
25
30
 
26
31
  type PrototypeMethodName = string;
27
32
 
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Hides Pi's own queued-message rows above the editor while the queue pill is
3
+ * showing the same thing.
4
+ *
5
+ * Pi draws `Steering: …` / `Follow-up: …` plus a dequeue hint in
6
+ * `pendingMessagesContainer`, rebuilt by `InteractiveMode.updatePendingMessagesDisplay`.
7
+ * There is no switch for it, so the method is wrapped: when the queue pill is on,
8
+ * the container is cleared instead of refilled, which leaves the layout row empty
9
+ * rather than collapsing the layout from outside.
10
+ *
11
+ * Deliberately tied to the pill rather than a config key: the rows only disappear
12
+ * when something else is already carrying that information. Remove `queue` from
13
+ * `pill.segments` (or turn the status line off) and the rows come back on the next
14
+ * queue update, with no restart.
15
+ */
16
+
17
+ import { InteractiveMode } from "@earendil-works/pi-coding-agent";
18
+ import { installPrototypePatch } from "./prototype-patch-registry";
19
+
20
+ type PendingMessagesHost = {
21
+ pendingMessagesContainer?: { clear?: () => void };
22
+ };
23
+
24
+ /**
25
+ * Wrap the rebuild so it can be skipped. `isEnabled` is read on every queue
26
+ * update, so a `/starline` change applies immediately.
27
+ */
28
+ export function installNativeQueueRowHiding(isEnabled: () => boolean): () => void {
29
+ try {
30
+ return installPrototypePatch(
31
+ InteractiveMode.prototype,
32
+ "updatePendingMessagesDisplay",
33
+ "pending-messages-hide",
34
+ ({ predecessor, receiver, args }) => {
35
+ if (!isEnabled()) return Function.prototype.apply.call(predecessor, receiver, args);
36
+ (receiver as PendingMessagesHost).pendingMessagesContainer?.clear?.();
37
+ return undefined;
38
+ },
39
+ );
40
+ } catch {
41
+ return () => {};
42
+ }
43
+ }