@xzzpig/pi-starline 0.3.0 → 0.4.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/README.md CHANGED
@@ -30,7 +30,8 @@ pieces of one product, not a theme:
30
30
  - Built-in footer segments can be shown or hidden individually from `/starline`
31
31
  - Fully custom Starship-style layout via a `footerFormat` template string — see [Footer Format Template](https://github.com/Andy8647/pi-starline/blob/main/docs/configuration.md#footer-format-template)
32
32
  - Third-party Pi extension statuses from `ctx.ui.setStatus()` can be shown on the left,
33
- middle, or right side, or hidden per status key from `/starline`
33
+ middle, or right side of the footer, on the editor's metadata row, or hidden per
34
+ status key from `/starline`
34
35
 
35
36
  ### Editor (Opencode-inspired)
36
37
 
@@ -150,7 +150,15 @@ export function migrateFixedEditorKeys(raw: Record<string, unknown>): {
150
150
  return { config: { ...rest, mouse }, migrated: true };
151
151
  }
152
152
 
153
- export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right";
153
+ /**
154
+ * Where a third-party status is drawn.
155
+ *
156
+ * `editor` is the right-hand side of the editor's metadata row, alongside the
157
+ * vim-mode label and the copy/paste hints — not the footer. It needs Starline's
158
+ * own footer installed to have data at all, because the status map only reaches
159
+ * the extension through the footer factory.
160
+ */
161
+ export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right" | "editor";
154
162
  export type ExtensionStatusColorMode = "themed" | "original";
155
163
 
156
164
  /**
@@ -756,7 +764,13 @@ function normalizeGitMetricsConfig(record: Record<string, unknown>): GitMetricsC
756
764
  }
757
765
 
758
766
  export function isExtensionStatusPlacement(value: unknown): value is ExtensionStatusPlacement {
759
- return value === "off" || value === "left" || value === "middle" || value === "right";
767
+ return (
768
+ value === "off" ||
769
+ value === "left" ||
770
+ value === "middle" ||
771
+ value === "right" ||
772
+ value === "editor"
773
+ );
760
774
  }
761
775
 
762
776
  export function isExtensionStatusColorMode(value: unknown): value is ExtensionStatusColorMode {
@@ -940,7 +954,10 @@ function writeConfigAtomically(path: string, record: ConfigRecord, mode?: number
940
954
  if (file !== undefined) {
941
955
  try {
942
956
  closeSync(file);
943
- } catch {}
957
+ } catch {
958
+ // Best-effort close during error cleanup; the outer catch
959
+ // already reports the primary failure.
960
+ }
944
961
  }
945
962
  try {
946
963
  unlinkSync(tempPath);
@@ -17,6 +17,7 @@ export type ExtensionStatusSegmentsByPlacement = {
17
17
  left: ExtensionStatusSegment[];
18
18
  middle: ExtensionStatusSegment[];
19
19
  right: ExtensionStatusSegment[];
20
+ editor: ExtensionStatusSegment[];
20
21
  };
21
22
 
22
23
  const safeSgrPattern = /\x1b\[[0-9;:]*m/g;
@@ -65,6 +66,7 @@ export function collectExtensionStatusSegments(
65
66
  left: [],
66
67
  middle: [],
67
68
  right: [],
69
+ editor: [],
68
70
  };
69
71
 
70
72
  for (const [key, value] of statuses.entries()) {
@@ -84,5 +86,6 @@ export function collectExtensionStatusSegments(
84
86
  segments.left.sort(compareKeys);
85
87
  segments.middle.sort(compareKeys);
86
88
  segments.right.sort(compareKeys);
89
+ segments.editor.sort(compareKeys);
87
90
  return segments;
88
91
  }
@@ -5,7 +5,6 @@ import type {
5
5
  Theme,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
8
- import type { MouseEventsApi } from "@xzzpig/pi-mouse-events/api";
9
8
  import {
10
9
  type ColorSourcesConfig,
11
10
  type ContextStyle,
@@ -324,6 +323,7 @@ export default function (pi: ExtensionAPI) {
324
323
  sessionName: ctx.sessionManager.getSessionName() ?? "",
325
324
  }),
326
325
  getThinkingLevel,
326
+ () => getActiveExtensionStatuses(),
327
327
  );
328
328
  applyPasteCollapse(editor);
329
329
  // Nothing on Pi's renderer points at the live editor, and the
@@ -357,6 +357,7 @@ export default function (pi: ExtensionAPI) {
357
357
  sessionName: ctx.sessionManager.getSessionName() ?? "",
358
358
  }),
359
359
  getThinkingLevel,
360
+ () => getActiveExtensionStatuses(),
360
361
  );
361
362
  // Two references, because these are two different objects here: the
362
363
  // wrapper is what gets mounted and hit-tested, while the draft's
@@ -25,7 +25,14 @@ import type { MouseEventsApi } from "@xzzpig/pi-mouse-events/api";
25
25
  const MOUSE_EVENTS_API_KEY = Symbol.for("pi-mouse-events.api.v1");
26
26
 
27
27
  export function getMouseEventsApi(): MouseEventsApi | undefined {
28
- return (globalThis as Record<symbol, unknown>)[MOUSE_EVENTS_API_KEY] as
28
+ const candidate = (globalThis as Record<symbol, unknown>)[MOUSE_EVENTS_API_KEY] as
29
29
  | MouseEventsApi
30
30
  | undefined;
31
+ // The symbol key pins the contract name, not its shape: reject an object
32
+ // from a different contract major so callers never dereference a
33
+ // mismatched surface (the key is only bumped together with the version).
34
+ if (!candidate || candidate.version !== 1) {
35
+ return undefined;
36
+ }
37
+ return candidate;
31
38
  }
@@ -686,8 +686,8 @@ function expandClickTarget(
686
686
  * a non-empty `pressedUrl` means Pi is about to open a link — the box stays
687
687
  * as it is and the release goes through unconsumed.
688
688
  */
689
- function pressedUrlAt(receiver: MouseCapableReceiver): unknown {
690
- return (receiver as { pressedUrl?: unknown }).pressedUrl;
689
+ function pressedUrlAt(receiver: MouseCapableReceiver): string | undefined {
690
+ return (receiver as { pressedUrl?: unknown }).pressedUrl as string | undefined;
691
691
  }
692
692
 
693
693
  /**
@@ -42,6 +42,7 @@ const extensionStatusPlacementValues: ExtensionStatusPlacement[] = [
42
42
  "left",
43
43
  "middle",
44
44
  "right",
45
+ "editor",
45
46
  ];
46
47
  const extensionStatusColorModeValues: ExtensionStatusColorMode[] = ["themed", "original"];
47
48
  const contextStyleValues: ContextStyle[] = ["text", "gauge", "text+gauge"];
@@ -11,6 +11,7 @@ import {
11
11
  import type { PolishedTuiConfig } from "./config";
12
12
  import { applyEditorCursorStyleToLines } from "./editor-cursor";
13
13
  import { renderEditorMetadataFormat } from "./editor-metadata-format";
14
+ import { collectExtensionStatusSegments } from "./extension-status";
14
15
  import { activeSelectionHintText, externalEditorHintText } from "./mouse";
15
16
  import { composeHints } from "./mouse/hint";
16
17
  import { pasteExpandHintText } from "./paste-collapse";
@@ -85,6 +86,8 @@ type PolishedFrameOptions = {
85
86
  modelMeta: EditorMeta;
86
87
  thinkingLevel: string | undefined;
87
88
  rightStatus?: string;
89
+ /** Third-party statuses placed on the editor row (`placement: "editor"`). */
90
+ editorStatus?: string;
88
91
  splitBaseFrame?: (lines: string[]) => PolishedFrameSplit | undefined;
89
92
  };
90
93
 
@@ -132,7 +135,8 @@ function getEditorChromeWidths(config: PolishedTuiConfig, uiTheme: Theme, reset:
132
135
  }
133
136
 
134
137
  /**
135
- * The right side of the metadata row: vim mode, the paste-expand hint, or both.
138
+ * The right side of the metadata row: vim mode, third-party statuses placed
139
+ * there, and the paste/selection hints.
136
140
  *
137
141
  * The hint used to live on the editor's bottom border, drawn there by the fixed
138
142
  * editor's compositor. Pi 0.84 supersedes the fixed editor, so the hint needs a
@@ -140,8 +144,15 @@ function getEditorChromeWidths(config: PolishedTuiConfig, uiTheme: Theme, reset:
140
144
  * `isHorizontalBorder` requires an unbroken rule to find the frame again. The
141
145
  * metadata row is always rendered, even when `editorMetadataFormat` is blank,
142
146
  * so the hint shows up whatever the user has done to that template.
147
+ *
148
+ * Hints come last: `composeMetadataLine` truncates this string from the right
149
+ * when it cannot fit, and the hints are the transient, actionable part.
143
150
  */
144
- function composeRightStatus(vimStatus: string | undefined, uiTheme: Theme): string | undefined {
151
+ function composeRightStatus(
152
+ vimStatus: string | undefined,
153
+ editorStatus: string | undefined,
154
+ uiTheme: Theme,
155
+ ): string | undefined {
145
156
  // A live selection hint already spells the external editor out ("N
146
157
  // characters selected, ctrl+x to copy ⋅ ctrl+g to edit in $EDITOR"), so it
147
158
  // takes precedence; the always-on hint only speaks when nothing else does.
@@ -149,9 +160,31 @@ function composeRightStatus(vimStatus: string | undefined, uiTheme: Theme): stri
149
160
  pasteExpandHintText(),
150
161
  activeSelectionHintText() ?? externalEditorHintText(),
151
162
  );
152
- if (!hint) return vimStatus;
153
- const styled = safeThemeFg(uiTheme, "muted", hint);
154
- return vimStatus ? `${vimStatus} ⋅ ${styled}` : styled;
163
+ const styled = hint ? safeThemeFg(uiTheme, "muted", hint) : undefined;
164
+ const parts = [vimStatus, editorStatus, styled].filter((part): part is string => Boolean(part));
165
+ return parts.length > 0 ? parts.join(" ⋅ ") : undefined;
166
+ }
167
+
168
+ /**
169
+ * Statuses configured with `placement: "editor"`, joined for the metadata row.
170
+ *
171
+ * The row is one muted line like the hints beside it, so the per-status colours
172
+ * and icons (footer features) are not applied here; `colorMode: "original"`
173
+ * text keeps whatever the extension already styled it with.
174
+ */
175
+ function editorStatusText(
176
+ config: PolishedTuiConfig,
177
+ uiTheme: Theme,
178
+ statuses: ReadonlyMap<string, string> | undefined,
179
+ ): string | undefined {
180
+ if (!statuses || statuses.size === 0) return undefined;
181
+ const segments = collectExtensionStatusSegments(statuses, config).editor;
182
+ if (segments.length === 0) return undefined;
183
+ return segments
184
+ .map((segment) =>
185
+ segment.colorMode === "original" ? segment.text : safeThemeFg(uiTheme, "muted", segment.text),
186
+ )
187
+ .join(" ⋅ ");
155
188
  }
156
189
 
157
190
  function composeMetadataLine(left: string, right: string | undefined, width: number): string {
@@ -280,6 +313,7 @@ function renderPolishedFrame({
280
313
  modelMeta,
281
314
  thinkingLevel,
282
315
  rightStatus,
316
+ editorStatus,
283
317
  splitBaseFrame,
284
318
  }: PolishedFrameOptions): string[] {
285
319
  if (width <= 2) return clampRenderedLines(baseRendered, width);
@@ -330,7 +364,7 @@ function renderPolishedFrame({
330
364
  uiTheme,
331
365
  config,
332
366
  );
333
- const status = composeRightStatus(rightStatus, uiTheme);
367
+ const status = composeRightStatus(rightStatus, editorStatus, uiTheme);
334
368
  const copyFriendlyMeta = composeMetadataLine(meta, status, Math.max(0, width - 1));
335
369
  const railedMeta = composeMetadataLine(meta, status, innerWidth);
336
370
 
@@ -381,6 +415,7 @@ export class PolishedEditor extends CustomEditor {
381
415
  private readonly getThinkingLevel: () => string | undefined;
382
416
  private readonly getConfig: () => PolishedTuiConfig;
383
417
  private readonly uiTheme: Theme;
418
+ private readonly getExtensionStatuses: (() => ReadonlyMap<string, string>) | undefined;
384
419
 
385
420
  constructor(
386
421
  tui: TUI,
@@ -390,6 +425,7 @@ export class PolishedEditor extends CustomEditor {
390
425
  getConfig: () => PolishedTuiConfig,
391
426
  getModelMeta: () => EditorMeta,
392
427
  getThinkingLevel: () => string | undefined,
428
+ getExtensionStatuses?: () => ReadonlyMap<string, string>,
393
429
  ) {
394
430
  super(tui, theme, keybindings, { paddingX: 0 });
395
431
  this.borderColor = (text: string) => safeThemeFg(uiTheme, "border", text);
@@ -397,6 +433,7 @@ export class PolishedEditor extends CustomEditor {
397
433
  this.getConfig = getConfig;
398
434
  this.getModelMeta = getModelMeta;
399
435
  this.getThinkingLevel = getThinkingLevel;
436
+ this.getExtensionStatuses = getExtensionStatuses;
400
437
  }
401
438
 
402
439
  render(width: number): string[] {
@@ -412,11 +449,14 @@ export class PolishedEditor extends CustomEditor {
412
449
  const result = renderPolishedFrame({
413
450
  width,
414
451
  baseRendered: rendered,
452
+ // SAFETY: this PolishedEditor is constructed with the internals the
453
+ // renderer needs; the widening crosses an interface boundary only.
415
454
  autocompleteSource: this as unknown as AutocompleteEditorInternals,
416
455
  uiTheme: this.uiTheme,
417
456
  config,
418
457
  modelMeta,
419
458
  thinkingLevel: this.getThinkingLevel(),
459
+ editorStatus: editorStatusText(config, this.uiTheme, this.getExtensionStatuses?.()),
420
460
  });
421
461
  return result;
422
462
  }
@@ -427,6 +467,8 @@ export class PolishedEditor extends CustomEditor {
427
467
  }
428
468
  // Also resolvable under the pre-rename key, so a pi-zentui wrapper reading a
429
469
  // pi-starline-built base still finds the splitter.
470
+ // SAFETY: LEGACY_SPLIT_POLISHED_FRAME is our own Symbol key; the cast only
471
+ // widens the receiver to assign a symbol-keyed method on the prototype.
430
472
  (PolishedEditor.prototype as unknown as Record<PropertyKey, unknown>)[LEGACY_SPLIT_POLISHED_FRAME] =
431
473
  PolishedEditor.prototype[SPLIT_POLISHED_FRAME];
432
474
 
@@ -437,6 +479,7 @@ export class WrappedPolishedEditor implements EditorComponent {
437
479
  private readonly getConfig: () => PolishedTuiConfig,
438
480
  private readonly getModelMeta: () => EditorMeta,
439
481
  private readonly getThinkingLevel: () => string | undefined,
482
+ private readonly getExtensionStatuses?: () => ReadonlyMap<string, string>,
440
483
  ) {}
441
484
 
442
485
  get focused(): boolean {
@@ -534,6 +577,7 @@ export class WrappedPolishedEditor implements EditorComponent {
534
577
  modelMeta,
535
578
  thinkingLevel: this.getThinkingLevel(),
536
579
  rightStatus: vimStatus,
580
+ editorStatus: editorStatusText(config, this.uiTheme, this.getExtensionStatuses?.()),
537
581
  splitBaseFrame: (
538
582
  this.base[SPLIT_POLISHED_FRAME] ?? this.base[LEGACY_SPLIT_POLISHED_FRAME]
539
583
  )?.bind(this.base),
@@ -588,10 +632,17 @@ export class WrappedPolishedEditor implements EditorComponent {
588
632
  return this.base.getLines?.() ?? this.base.getText().split("\n");
589
633
  }
590
634
 
635
+ // Deliberate pass-through of the base editor's duck-typed contract: Pi
636
+ // returns a {line, col} cursor object, so narrowing here would assert a
637
+ // type this wrapper cannot guarantee.
638
+ // pi-lens-ignore: no-unknown-returns
591
639
  getCursor(): unknown {
592
640
  return this.base.getCursor?.();
593
641
  }
594
642
 
643
+ // Deliberate pass-through of the base editor's duck-typed contract; callers
644
+ // narrow the value with their own runtime checks.
645
+ // pi-lens-ignore: no-unknown-returns
595
646
  getMode(): unknown {
596
647
  return this.base.getMode?.();
597
648
  }
@@ -606,6 +657,8 @@ export class WrappedPolishedEditor implements EditorComponent {
606
657
  }
607
658
  // Also resolvable under the pre-rename key, so a pi-zentui wrapper reading a
608
659
  // pi-starline-built base still finds the splitter.
660
+ // SAFETY: LEGACY_SPLIT_POLISHED_FRAME is our own Symbol key; the cast only
661
+ // widens the receiver to assign a symbol-keyed method on the prototype.
609
662
  (WrappedPolishedEditor.prototype as unknown as Record<PropertyKey, unknown>)[
610
663
  LEGACY_SPLIT_POLISHED_FRAME
611
664
  ] = WrappedPolishedEditor.prototype[SPLIT_POLISHED_FRAME];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xzzpig/pi-starline",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,11 +28,6 @@
28
28
  "@earendil-works/pi-tui": ">=0.84.4",
29
29
  "@xzzpig/pi-mouse-events": ">=0.1.0 <2"
30
30
  },
31
- "peerDependenciesMeta": {
32
- "@xzzpig/pi-mouse-events": {
33
- "optional": true
34
- }
35
- },
36
31
  "pi": {
37
32
  "extensions": [
38
33
  "./extensions"
@@ -50,7 +45,12 @@
50
45
  "@types/node": "^26.0.0",
51
46
  "typescript": "^7.0.2",
52
47
  "vitest": "^4.1.8",
53
- "@xzzpig/pi-mouse-events": "0.1.0"
48
+ "@xzzpig/pi-mouse-events": "0.1.2"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "@xzzpig/pi-mouse-events": {
52
+ "optional": true
53
+ }
54
54
  },
55
55
  "scripts": {
56
56
  "lint": "biome check .",