@xzzpig/pi-starline 0.5.1 → 0.7.2

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 (51) hide show
  1. package/FORK-CHANGELOG.md +26 -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/fork-pill-legibility.ts +52 -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/external-layer.ts +42 -0
  23. package/extensions/starline/mouse/fork-native-clipboard.ts +57 -0
  24. package/extensions/starline/mouse/fork-pill-mouse.ts +19 -0
  25. package/extensions/starline/mouse/fork-pointer-coordination.ts +10 -0
  26. package/extensions/starline/mouse/fork-transcript-copy.ts +54 -0
  27. package/extensions/starline/mouse/fork-wheel-compat.ts +22 -0
  28. package/extensions/starline/mouse/hint.ts +4 -4
  29. package/extensions/starline/mouse/index.ts +54 -24
  30. package/extensions/starline/mouse/pointer-events.ts +73 -0
  31. package/extensions/starline/mouse/pointer-shape.ts +138 -0
  32. package/extensions/starline/mouse/transcript-copy.ts +76 -33
  33. package/extensions/starline/paste-collapse.ts +52 -13
  34. package/extensions/starline/pill-actions.ts +590 -0
  35. package/extensions/starline/pill-anchors.ts +26 -0
  36. package/extensions/starline/pill-api.ts +80 -0
  37. package/extensions/starline/pill-bubble.ts +611 -0
  38. package/extensions/starline/pill-config.ts +67 -9
  39. package/extensions/starline/pill.ts +121 -25
  40. package/extensions/starline/project-probe.ts +79 -0
  41. package/extensions/starline/prototype-patch-registry.ts +6 -1
  42. package/extensions/starline/queue-rows.ts +43 -0
  43. package/extensions/starline/settings-command.ts +124 -177
  44. package/extensions/starline/subagent-fleet.ts +246 -0
  45. package/extensions/starline/turn-clock.ts +81 -0
  46. package/extensions/starline/ui.ts +297 -156
  47. package/extensions/starline/working-indicator.ts +189 -0
  48. package/package.json +7 -6
  49. package/types/pill-api.d.ts +115 -0
  50. package/extensions/starline/editor-metadata-format.ts +0 -259
  51. 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
  }
@@ -13,6 +13,7 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
13
 
14
14
  import type { ColorSource, ColorSpec } from "./config";
15
15
  import type { ExtensionStatusSegment } from "./extension-status";
16
+ import { hasExplicitForeground, reverseVideoSgr } from "./fork-pill-legibility";
16
17
  import {
17
18
  EXTENSION_STATUS_PREFIX,
18
19
  EXTENSION_STATUS_SEGMENT,
@@ -25,9 +26,13 @@ const RESET = "\x1b[0m";
25
26
  const BOLD = "\x1b[1m";
26
27
  const DEFAULT_BG = "\x1b[49m";
27
28
 
28
- /** Text colours are the basic named codes, which every terminal renders correctly. */
29
- const TEXT_DARK = "\x1b[30m";
30
- const TEXT_LIGHT = "\x1b[97m";
29
+ /**
30
+ * Text colours are the 256-colour cube's black and white (16/231), whose values
31
+ * the standard fixes. The basic named codes are not fixed: a terminal theme may
32
+ * map ANSI black to a mid grey, which leaves mid-tone pills hard to read.
33
+ */
34
+ const TEXT_DARK = "\x1b[38;5;16m";
35
+ const TEXT_LIGHT = "\x1b[38;5;231m";
31
36
 
32
37
  /** Catppuccin surface1 — the neutral for segments whose spec names no colour. */
33
38
  const NEUTRAL_BG = "\x1b[48;2;69;71;90m";
@@ -43,6 +48,42 @@ const CAP_GLYPHS = { left: "", right: "" };
43
48
  /** Divider used where a solid arrow would be invisible. */
44
49
  const THIN_SEPARATOR = SEPARATOR_GLYPHS["powerline-thin"];
45
50
 
51
+ /**
52
+ * Where one pill ended up in a rendered bar, in visible columns.
53
+ *
54
+ * `startColumn` is inclusive, `endColumn` exclusive. Columns count what the
55
+ * terminal draws, so a CJK pill spans two cells per character and a pill that
56
+ * `width` cut in half still reports the cells it kept.
57
+ *
58
+ * A span covers everything the pill visually owns: its own padding, the
59
+ * separator that points out of it, and — on the two ends of the bar — the cap.
60
+ * That leaves no dead cells between two pills for a click to fall into.
61
+ */
62
+ export type PillSpan = {
63
+ /** `PillInput.key` of the segment this span belongs to. */
64
+ key: string;
65
+ startColumn: number;
66
+ endColumn: number;
67
+ /**
68
+ * First column painted solid in the pill's colour. The hit area starts earlier
69
+ * on the first pill, whose round cap is the colour drawn on the terminal's own
70
+ * background; anything that has to line up with the pill's edge uses this.
71
+ */
72
+ bodyStartColumn: number;
73
+ /** First column after the pill's own colour: where its arrow or cap starts. */
74
+ bodyEndColumn: number;
75
+ /** Background SGR the pill was painted with, for anything that has to match it. */
76
+ bg: string;
77
+ /** Foreground SGR, or "" when the text carried its own styling. */
78
+ fg: string;
79
+ };
80
+
81
+ export type PillRenderOptions = {
82
+ ascii?: boolean;
83
+ /** Called once per pill that survived truncation, in left-to-right order. */
84
+ onSpan?: (span: PillSpan) => void;
85
+ };
86
+
46
87
  export type PillInput = {
47
88
  /** Segment name, for diagnostics. */
48
89
  key: string;
@@ -123,10 +164,13 @@ export function collectPillInputs(
123
164
  }
124
165
 
125
166
  type PreparedPill = {
167
+ key: string;
126
168
  text: string;
127
169
  bg: string;
128
170
  fg: string;
129
171
  bold: boolean;
172
+ /** Body SGR for a terminal-owned background, which paints text in reverse. */
173
+ reverse: string;
130
174
  };
131
175
 
132
176
  const XTERM_CUBE_LEVELS = [0, 95, 135, 175, 215, 255];
@@ -213,18 +257,25 @@ function prepare(
213
257
  if (!text || visibleWidth(text) === 0) continue;
214
258
 
215
259
  if (input.keepStyling) {
216
- pills.push({ text, bg: neutral, fg: "", bold: false });
260
+ pills.push({ key: input.key, text, bg: neutral, fg: "", bold: false, reverse: "" });
217
261
  continue;
218
262
  }
219
263
 
220
264
  const resolved = resolveColorSpec(theme, source, input.spec);
221
265
  const bg = resolved.bg ?? resolveBackgroundSgr(theme, source, input.spec) ?? neutral;
222
- // An explicit fg: in the spec wins; otherwise pick for legibility.
266
+ // An explicit fg: in the spec wins. A background the terminal owns has no
267
+ // knowable RGB, so reverse video hands both sides to the palette instead of
268
+ // guessing from the standard table.
269
+ const explicitFg = hasExplicitForeground(input.spec);
270
+ const reverse = explicitFg ? "" : (reverseVideoSgr(bg) ?? "");
271
+ const specFg = explicitFg || (resolved.bg && resolved.fg) ? resolved.fg : undefined;
223
272
  pills.push({
273
+ key: input.key,
224
274
  text,
225
275
  bg,
226
- fg: resolved.bg && resolved.fg ? resolved.fg : contrastTextSgr(bg),
276
+ fg: reverse ? "" : (specFg ?? contrastTextSgr(bg)),
227
277
  bold: config.bold || resolved.bold,
278
+ reverse,
228
279
  });
229
280
  }
230
281
 
@@ -234,6 +285,11 @@ function prepare(
234
285
  /**
235
286
  * Render the pill bar. Returns "" when nothing has content, so the caller can
236
287
  * fall back rather than emit a lone pair of caps.
288
+ *
289
+ * `options.onSpan` receives the hit area of every pill the terminal will
290
+ * actually show, measured in the bar's own visible columns. Recording the spans
291
+ * here rather than recomputing them later keeps them in step with the render:
292
+ * colour, caps, separators and truncation are all already accounted for.
237
293
  */
238
294
  export function renderPillBar(
239
295
  inputs: PillInput[],
@@ -241,7 +297,7 @@ export function renderPillBar(
241
297
  source: ColorSource,
242
298
  config: PillConfig,
243
299
  width: number,
244
- options: { ascii?: boolean } = {},
300
+ options: PillRenderOptions = {},
245
301
  ): string {
246
302
  const pills = prepare(inputs, theme, source, config);
247
303
  if (pills.length === 0) return "";
@@ -255,41 +311,81 @@ export function renderPillBar(
255
311
  const rightCap = wantsCaps ? CAP_GLYPHS.right : "";
256
312
 
257
313
  let out = "";
314
+ // Columns the terminal will draw so far. Getting this from `visibleWidth`
315
+ // rather than string length is what makes CJK segment text hit the right cells.
316
+ let column = 0;
317
+ const emit = (piece: string) => {
318
+ out += piece;
319
+ column += visibleWidth(piece);
320
+ };
258
321
 
322
+ const capStart = column;
259
323
  const firstBg = pills[0]?.bg;
260
324
  if (leftCap && firstBg) {
261
325
  const capFg = toForegroundSgr(firstBg);
262
- if (capFg) out += `${DEFAULT_BG}${capFg}${leftCap}${RESET}`;
326
+ if (capFg) emit(`${DEFAULT_BG}${capFg}${leftCap}${RESET}`);
263
327
  }
264
328
 
329
+ const spans: PillSpan[] = [];
330
+
265
331
  pills.forEach((pill, index) => {
266
- out += `${pill.bg}${pill.fg}${pill.bold ? BOLD : ""} ${pill.text} ${RESET}`;
332
+ // The left cap opens the bar in the first pill's colour, so it belongs to it.
333
+ const startColumn = index === 0 ? capStart : column;
334
+ const bodyStartColumn = column;
335
+ emit(`${pill.reverse || pill.bg}${pill.fg}${pill.bold ? BOLD : ""} ${pill.text} ${RESET}`);
336
+ const bodyEndColumn = column;
267
337
 
268
338
  const next = pills[index + 1];
269
339
  if (next) {
270
- if (!separator) return;
271
-
272
340
  // A solid arrow works by being the left colour on the right colour, so
273
341
  // it vanishes when the two pills share a background — several segments
274
342
  // default to the same theme colour, which merged them into one blob.
275
343
  // 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;
344
+ if (separator && pill.bg === next.bg) {
345
+ const dividerFg = pill.reverse ? "" : pill.fg || contrastTextSgr(pill.bg);
346
+ emit(`${pill.reverse || pill.bg}${dividerFg}${THIN_SEPARATOR}${RESET}`);
347
+ } else if (separator) {
348
+ const arrowFg = toForegroundSgr(pill.bg);
349
+ // Without a usable foreground the arrow would be invisible; letting the
350
+ // backgrounds abut is better than drawing a glyph in the wrong colour.
351
+ if (arrowFg) emit(`${next.bg}${arrowFg}${separator}${RESET}`);
279
352
  }
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) {
353
+ } else if (rightCap) {
289
354
  const capFg = toForegroundSgr(pill.bg);
290
- if (capFg) out += `${DEFAULT_BG}${capFg}${rightCap}${RESET}`;
355
+ if (capFg) emit(`${DEFAULT_BG}${capFg}${rightCap}${RESET}`);
291
356
  }
357
+
358
+ // The arrow (or cap) is emitted before the span closes, so it belongs to
359
+ // the pill that points out of itself.
360
+ spans.push({
361
+ key: pill.key,
362
+ startColumn,
363
+ endColumn: column,
364
+ bodyStartColumn,
365
+ bodyEndColumn,
366
+ bg: pill.bg,
367
+ fg: pill.fg,
368
+ });
292
369
  });
293
370
 
294
- return width > 0 ? truncateToWidth(out, width, "") : out;
371
+ const rendered = width > 0 ? truncateToWidth(out, width, "") : out;
372
+ if (options.onSpan) {
373
+ // Truncation keeps a prefix, so surviving cells keep their columns; only
374
+ // the far end needs clamping, and a pill clipped away entirely vanishes.
375
+ const visible = visibleWidth(rendered);
376
+ for (const span of spans) {
377
+ const startColumn = Math.min(span.startColumn, visible);
378
+ const endColumn = Math.min(span.endColumn, visible);
379
+ if (endColumn > startColumn) {
380
+ const bodyStartColumn = Math.min(span.bodyStartColumn, endColumn - 1);
381
+ const bodyEndColumn = Math.max(
382
+ bodyStartColumn + 1,
383
+ Math.min(span.bodyEndColumn, endColumn),
384
+ );
385
+ options.onSpan({ ...span, startColumn, endColumn, bodyStartColumn, bodyEndColumn });
386
+ }
387
+ }
388
+ }
389
+
390
+ return rendered;
295
391
  }
@@ -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
+ }