@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
@@ -25,13 +25,7 @@ import {
25
25
  type ResolvedIcons,
26
26
  resolveConfiguredIcons,
27
27
  } from "./icons";
28
- import {
29
- DEFAULT_PILL_CONFIG,
30
- type FooterStyle,
31
- normalizePillConfig,
32
- type PillConfig,
33
- parseFooterStyle,
34
- } from "./pill-config";
28
+ import { DEFAULT_PILL_CONFIG, normalizePillConfig, type PillConfig } from "./pill-config";
35
29
  import { isSupportedColorSpec } from "./style";
36
30
 
37
31
  export type ColorSpec = string;
@@ -43,12 +37,14 @@ export type ContextStyle = "text" | "gauge" | "text+gauge";
43
37
  export type ContextFormat = "full" | "percent";
44
38
  /** What the tokens segment shows for cache activity. */
45
39
  export type TokensCacheFormat = "percent" | "tokens" | "off";
40
+ /** What the subagents segment spells out: `2 agents`, or just the count. */
41
+ export type SubagentsFormat = "agents" | "count";
46
42
 
47
43
  export type SegmentOptionsConfig = {
48
44
  context: { format: ContextFormat };
49
45
  tokens: { cache: TokensCacheFormat };
46
+ subagents: { format: SubagentsFormat };
50
47
  };
51
- export type SeparatorStyle = "pipe" | "dot" | "chevron" | "none";
52
48
  export type ModelLabelSource = "id" | "name";
53
49
 
54
50
  export type ContextThresholds = {
@@ -82,26 +78,14 @@ export type UiFeaturesConfig = {
82
78
  copyFriendly: boolean;
83
79
  };
84
80
 
85
- export type FooterSegmentsConfig = {
86
- model: boolean;
87
- thinking: boolean;
88
- cwd: boolean;
89
- sessionName: boolean;
90
- gitBranch: boolean;
91
- gitStatus: boolean;
92
- gitCounts: boolean;
93
- gitCommit: boolean;
94
- gitMetrics: boolean;
95
- runtime: boolean;
96
- context: boolean;
97
- tokens: boolean;
98
- cacheHit: boolean;
99
- cost: boolean;
100
- sessionDuration: boolean;
101
- username: boolean;
102
- time: boolean;
103
- os: boolean;
104
- packageVersion: boolean;
81
+ export type GitConfig = {
82
+ /** Glyphs carry their number (`!3`) instead of standing alone (`!`). */
83
+ counts: boolean;
84
+ /** Use the origin remote's forge logo as the branch icon. */
85
+ hostIcon: boolean;
86
+ branch: GitBranchConfig;
87
+ commit: GitCommitConfig;
88
+ metrics: GitMetricsConfig;
105
89
  };
106
90
 
107
91
  export type MouseConfig = {
@@ -154,15 +138,16 @@ export function migrateFixedEditorKeys(raw: Record<string, unknown>): {
154
138
  }
155
139
 
156
140
  /**
157
- * Where a third-party status is drawn.
158
- *
159
- * `editor` is the right-hand side of the editor's metadata row, alongside the
160
- * vim-mode label and the copy/paste hints — not the footer. It needs Starline's
161
- * own footer installed to have data at all, because the status map only reaches
162
- * the extension through the footer factory.
141
+ * Where in the footer a third-party status is drawn, or `off`. Up to 0.8 there
142
+ * was also `editor`, the editor's metadata row; see `readExtensionStatusPlacement`.
163
143
  */
164
- export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right" | "editor";
144
+ export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right";
165
145
  export type ExtensionStatusColorMode = "themed" | "original";
146
+ /**
147
+ * How much of a status is drawn: its text as published, or just its number.
148
+ * "value" is for statuses whose words are redundant once the pill has an icon.
149
+ */
150
+ export type ExtensionStatusFormat = "full" | "value";
166
151
 
167
152
  /**
168
153
  * Starship `git_commit`-style options.
@@ -193,20 +178,20 @@ export type ExtensionStatusesConfig = {
193
178
  colors: Record<string, ColorSpec>;
194
179
  /** Per-status icon, keyed by status key. Prefixed to the status text. */
195
180
  icons: Record<string, string>;
181
+ /**
182
+ * Per-status text shape, keyed by status key. `"value"` keeps only the number
183
+ * (`Tavily: 41.1%` → `41.1%`), so an icon plus that number reads as one pill;
184
+ * a status with no number keeps its text. Defaults to the published text.
185
+ */
186
+ formats: Record<string, ExtensionStatusFormat>;
196
187
  };
197
188
 
198
189
  const DEFAULT_PROJECT_REFRESH_INTERVAL_MS = 30_000;
199
190
  const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
200
- export const DEFAULT_EDITOR_METADATA_FORMAT = "$model $provider( $thinking)";
201
191
 
202
192
  export type PolishedTuiConfig = {
203
193
  projectRefreshIntervalMs: number;
204
- /** "text" renders footerFormat as before; "pill" renders pill.segments instead. */
205
- footerStyle: FooterStyle;
206
194
  pill: PillConfig;
207
- footerFormat: string;
208
- editorMetadataFormat: string;
209
- separator: SeparatorStyle;
210
195
  contextStyle: ContextStyle;
211
196
  segmentOptions: SegmentOptionsConfig;
212
197
  editorModelLabel: ModelLabelSource;
@@ -215,157 +200,103 @@ export type PolishedTuiConfig = {
215
200
  editorClickCursor: boolean;
216
201
  /** Collapse pastes at this many lines. 11 (default) leaves Pi's threshold alone. */
217
202
  pasteCollapseLines: number;
218
- /** Blank rows inside the editor box, above the input and above the metadata row. */
203
+ /** Blank rows inside the editor box, above and below the input. */
219
204
  editorPaddingY: number;
220
- /** Blank rows inside the previous-message box, above and below the body. */
205
+ /** The input area's height floor: a shorter draft is followed by blank rows. */
206
+ editorMinRows: number;
207
+ /** Blank rows above and below a Starline user message. */
221
208
  userMessagePaddingY: number;
222
209
  contextThresholds: ContextThresholds;
223
210
  pathDisplay: PathDisplayConfig;
224
- gitBranch: GitBranchConfig;
225
- /** Replace the branch icon with the origin remote's forge logo. */
226
- gitHostIcon: boolean;
211
+ git: GitConfig;
227
212
  icons: ResolvedIcons;
228
213
  colors: {
229
214
  model: ColorSpec;
230
215
  /** Unset derives the colour from the level via the theme's thinking* keys. */
231
216
  thinking?: ColorSpec;
217
+ /** The queued-messages pill, which only exists while something is queued. */
218
+ queue: ColorSpec;
219
+ /** The running-subagents pill, which only exists while pi-subagents runs something. */
220
+ subagents: ColorSpec;
232
221
  cwd: ColorSpec;
233
222
  sessionName: ColorSpec;
234
223
  gitBranch: ColorSpec;
235
- gitStatus: ColorSpec;
236
224
  contextNormal: ColorSpec;
237
225
  contextWarning: ColorSpec;
238
226
  contextError: ColorSpec;
239
- tokens: ColorSpec;
240
- cacheHit: ColorSpec;
241
227
  cost: ColorSpec;
242
- separator: ColorSpec;
228
+ /** Decode throughput of the latest assistant message. */
229
+ tps: ColorSpec;
243
230
  runtimePrefix: ColorSpec;
244
231
  extensionStatus: ColorSpec;
245
232
  sessionDuration: ColorSpec;
246
233
  packageVersion: ColorSpec;
247
234
  gitCommit: ColorSpec;
248
235
  gitMetricsAdded: ColorSpec;
249
- gitMetricsDeleted: ColorSpec;
250
236
  username: ColorSpec;
251
237
  time: ColorSpec;
252
238
  os: ColorSpec;
253
239
  editorAccent?: ColorSpec;
254
240
  editorPrompt?: ColorSpec;
255
241
  editorBorder?: ColorSpec;
256
- /** Unset falls back to editorBorder, which is upstream's behaviour. */
257
242
  userMessageBorder?: ColorSpec;
258
- /** Unset follows the theme's userMessageText colour. */
259
243
  userMessageText?: ColorSpec;
260
- editorModel?: ColorSpec;
261
- editorProvider?: ColorSpec;
262
- editorThinking?: ColorSpec;
263
- editorThinkingMinimal?: ColorSpec;
264
- editorThinkingLow?: ColorSpec;
265
- editorThinkingMedium?: ColorSpec;
266
- editorThinkingHigh?: ColorSpec;
267
- editorThinkingXhigh?: ColorSpec;
268
244
  };
269
245
  colorSources: ColorSourcesConfig;
270
246
  features: UiFeaturesConfig;
271
- footerSegments: FooterSegmentsConfig;
272
- gitCommit: GitCommitConfig;
273
- gitMetrics: GitMetricsConfig;
274
247
  extensionStatuses: ExtensionStatusesConfig;
275
248
  mouse: MouseConfig;
276
249
  };
277
250
 
278
- /**
279
- * Canonical footer format variable names. In a `footerFormat` string these
280
- * are written as `$name` or `${name}`.
281
- */
282
- export const FOOTER_FORMAT_VARIABLES = [
283
- "model",
284
- "thinking",
285
- "cwd",
286
- "session_name",
287
- "git_branch",
288
- "git_status",
289
- "git_state",
290
- "runtime",
291
- "session_duration",
292
- "username",
293
- "os",
294
- "time",
295
- "context",
296
- "tokens",
297
- "cache_hit",
298
- "cost",
299
- "package",
300
- "package_version",
301
- "git_commit",
302
- "git_tag",
303
- "git_metrics",
304
- "git_added",
305
- "git_deleted",
306
- "sep",
307
- ] as const;
308
-
309
- /**
310
- * Alias → canonical variable name mapping for `footerFormat`.
311
- * `$fill` is special (not a variable) and handled by the parser.
312
- */
313
- export const FOOTER_FORMAT_ALIASES: Record<string, string> = {
314
- directory: "cwd",
315
- branch: "git_branch",
316
- status: "git_status",
317
- state: "git_state",
318
- commit: "git_commit",
319
- tag: "git_tag",
320
- duration: "session_duration",
321
- separator: "sep",
322
- };
323
-
324
251
  export const configPath = join(getAgentDir(), "starline.json");
325
252
 
326
253
  export const defaultConfig: PolishedTuiConfig = {
327
254
  projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
328
- footerStyle: "text",
329
255
  pill: DEFAULT_PILL_CONFIG,
330
- footerFormat: "",
331
- editorMetadataFormat: DEFAULT_EDITOR_METADATA_FORMAT,
332
- separator: "pipe",
333
256
  contextStyle: "text",
334
- segmentOptions: { context: { format: "full" }, tokens: { cache: "percent" } },
257
+ segmentOptions: {
258
+ context: { format: "full" },
259
+ tokens: { cache: "percent" },
260
+ subagents: { format: "agents" },
261
+ },
335
262
  editorModelLabel: "id",
336
263
  editorCursor: "block",
337
264
  editorClickCursor: true,
338
265
  pasteCollapseLines: 11,
339
266
  editorPaddingY: 1,
267
+ editorMinRows: 1,
340
268
  userMessagePaddingY: 1,
341
269
  contextThresholds: { warning: 70, error: 90 },
342
270
  pathDisplay: { mode: "basename", depth: 0 },
343
- gitBranch: { maxLength: "full" },
344
- gitHostIcon: false,
271
+ git: {
272
+ counts: false,
273
+ hostIcon: false,
274
+ branch: { maxLength: "full" },
275
+ commit: { hashLength: 7, onlyDetached: true, showTag: true },
276
+ metrics: { onlyNonzero: true, ignoreSubmodules: false },
277
+ },
345
278
  icons: {
346
279
  mode: "auto",
347
280
  ...NERD_DEFAULT_ICONS,
348
281
  },
349
282
  colors: {
350
283
  model: "bold blue",
284
+ queue: "bold yellow",
285
+ subagents: "bold green",
351
286
  cwd: "bold cyan",
352
287
  sessionName: "bold green",
353
288
  gitBranch: "bold purple",
354
- gitStatus: "bold red",
355
289
  contextNormal: "bright-black",
356
290
  contextWarning: "bold yellow",
357
291
  contextError: "bold red",
358
- tokens: "bright-black",
359
- cacheHit: "bright-black",
360
292
  cost: "bold green",
361
- separator: "bright-black",
293
+ tps: "bright-black",
362
294
  runtimePrefix: "",
363
295
  extensionStatus: "bright-black",
364
296
  sessionDuration: "yellow",
365
297
  packageVersion: "208",
366
298
  gitCommit: "bold green",
367
299
  gitMetricsAdded: "bold green",
368
- gitMetricsDeleted: "bold red",
369
300
  username: "bold yellow",
370
301
  time: "bold yellow",
371
302
  os: "bold white",
@@ -380,42 +311,13 @@ export const defaultConfig: PolishedTuiConfig = {
380
311
  statusLine: true,
381
312
  copyFriendly: false,
382
313
  },
383
- footerSegments: {
384
- model: false,
385
- thinking: false,
386
- cwd: true,
387
- sessionName: true,
388
- gitBranch: true,
389
- gitStatus: true,
390
- gitCounts: false,
391
- runtime: true,
392
- context: true,
393
- tokens: true,
394
- cacheHit: false,
395
- cost: true,
396
- sessionDuration: false,
397
- username: false,
398
- time: false,
399
- os: false,
400
- packageVersion: false,
401
- gitCommit: false,
402
- gitMetrics: false,
403
- },
404
- gitCommit: {
405
- hashLength: 7,
406
- onlyDetached: true,
407
- showTag: true,
408
- },
409
- gitMetrics: {
410
- onlyNonzero: true,
411
- ignoreSubmodules: false,
412
- },
413
314
  extensionStatuses: {
414
315
  defaultPlacement: "right",
415
316
  colors: {},
416
317
  icons: {},
417
318
  placements: {},
418
319
  colorModes: {},
320
+ formats: {},
419
321
  },
420
322
  mouse: {
421
323
  enabled: true,
@@ -457,14 +359,6 @@ function parseEditorModelLabel(value: unknown): ModelLabelSource {
457
359
  return defaultConfig.editorModelLabel;
458
360
  }
459
361
 
460
- export function isSeparatorStyle(value: unknown): value is SeparatorStyle {
461
- return value === "pipe" || value === "dot" || value === "chevron" || value === "none";
462
- }
463
-
464
- function parseSeparatorStyle(value: unknown): SeparatorStyle {
465
- return isSeparatorStyle(value) ? value : defaultConfig.separator;
466
- }
467
-
468
362
  function parseContextThresholds(value: unknown): ContextThresholds {
469
363
  const defaults = defaultConfig.contextThresholds;
470
364
  if (!isRecord(value)) return { ...defaults };
@@ -502,14 +396,21 @@ function parsePathDisplay(value: unknown): PathDisplayConfig {
502
396
  function normalizeGitBranchMaxLength(value: unknown): GitBranchMaxLength {
503
397
  if (value === "full") return value;
504
398
  if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
505
- return defaultConfig.gitBranch.maxLength;
399
+ return defaultConfig.git.branch.maxLength;
506
400
  }
507
401
 
508
- function parseGitBranchConfig(value: unknown): GitBranchConfig {
509
- const defaults = defaultConfig.gitBranch;
510
- if (!isRecord(value)) return { ...defaults };
402
+ function parseGitConfig(value: unknown): GitConfig {
403
+ const defaults = defaultConfig.git;
404
+ if (!isRecord(value)) return structuredClone(defaults);
405
+ const branch = isRecord(value.branch) ? value.branch : {};
406
+ const commit = isRecord(value.commit) ? value.commit : {};
407
+ const metrics = isRecord(value.metrics) ? value.metrics : {};
511
408
  return {
512
- maxLength: normalizeGitBranchMaxLength(value.maxLength),
409
+ counts: typeof value.counts === "boolean" ? value.counts : defaults.counts,
410
+ hostIcon: typeof value.hostIcon === "boolean" ? value.hostIcon : defaults.hostIcon,
411
+ branch: { maxLength: normalizeGitBranchMaxLength(branch.maxLength) },
412
+ commit: normalizeGitCommitConfig(commit),
413
+ metrics: normalizeGitMetricsConfig(metrics),
513
414
  };
514
415
  }
515
416
 
@@ -536,14 +437,6 @@ function booleanValue(record: Record<string, unknown>, key: keyof UiFeaturesConf
536
437
  return typeof value === "boolean" ? value : defaultConfig.features[key];
537
438
  }
538
439
 
539
- function footerSegmentValue(
540
- record: Record<string, unknown>,
541
- key: keyof FooterSegmentsConfig,
542
- ): boolean {
543
- const value = record[key];
544
- return typeof value === "boolean" ? value : defaultConfig.footerSegments[key];
545
- }
546
-
547
440
  function definedColors(
548
441
  colors: Partial<Record<keyof PolishedTuiConfig["colors"], string | undefined>>,
549
442
  ): Partial<PolishedTuiConfig["colors"]> {
@@ -567,9 +460,8 @@ function normalizeIconOverrides(record: Record<string, unknown>): Partial<IconGl
567
460
  * A named colour palette, so a whole scheme can be restated in one place
568
461
  * instead of repeating hex values across every segment.
569
462
  *
570
- * Expansion is scoped to colour records only — `footerFormat` and
571
- * `editorMetadataFormat` use `$name` for their own format variables and must
572
- * never be touched by it.
463
+ * Expansion is scoped to colour records only — keys and other strings are
464
+ * never touched by it.
573
465
  */
574
466
  export type Palette = Record<string, string>;
575
467
 
@@ -630,40 +522,30 @@ function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiCo
630
522
  return definedColors({
631
523
  model: colorValue(record, "model"),
632
524
  thinking: colorValue(record, "thinking"),
525
+ queue: colorValue(record, "queue"),
526
+ subagents: colorValue(record, "subagents"),
633
527
  cwd: colorValue(record, "cwd") ?? colorValue(record, "cwdText"),
634
528
  sessionName: colorValue(record, "sessionName"),
635
529
  gitBranch: colorValue(record, "gitBranch") ?? colorValue(record, "git"),
636
- gitStatus: colorValue(record, "gitStatus"),
637
530
  contextNormal: colorValue(record, "contextNormal"),
638
531
  contextWarning: colorValue(record, "contextWarning"),
639
532
  contextError: colorValue(record, "contextError"),
640
- tokens: colorValue(record, "tokens"),
641
- cacheHit: colorValue(record, "cacheHit"),
642
533
  cost: colorValue(record, "cost"),
643
- separator: colorValue(record, "separator"),
534
+ tps: colorValue(record, "tps"),
644
535
  runtimePrefix: colorValue(record, "runtimePrefix"),
645
536
  extensionStatus: colorValue(record, "extensionStatus"),
646
537
  sessionDuration: colorValue(record, "sessionDuration"),
647
538
  packageVersion: colorValue(record, "packageVersion"),
648
539
  gitCommit: colorValue(record, "gitCommit"),
649
540
  gitMetricsAdded: colorValue(record, "gitMetricsAdded"),
650
- gitMetricsDeleted: colorValue(record, "gitMetricsDeleted"),
651
541
  username: colorValue(record, "username"),
652
542
  time: colorValue(record, "time"),
653
543
  os: colorValue(record, "os"),
654
- userMessageBorder: colorValue(record, "userMessageBorder"),
655
- userMessageText: colorValue(record, "userMessageText"),
656
544
  editorAccent: colorValue(record, "editorAccent"),
657
545
  editorPrompt: colorValue(record, "editorPrompt"),
658
546
  editorBorder: colorValue(record, "editorBorder"),
659
- editorModel: colorValue(record, "editorModel"),
660
- editorProvider: colorValue(record, "editorProvider"),
661
- editorThinking: colorValue(record, "editorThinking"),
662
- editorThinkingMinimal: colorValue(record, "editorThinkingMinimal"),
663
- editorThinkingLow: colorValue(record, "editorThinkingLow"),
664
- editorThinkingMedium: colorValue(record, "editorThinkingMedium"),
665
- editorThinkingHigh: colorValue(record, "editorThinkingHigh"),
666
- editorThinkingXhigh: colorValue(record, "editorThinkingXhigh"),
547
+ userMessageBorder: colorValue(record, "userMessageBorder"),
548
+ userMessageText: colorValue(record, "userMessageText"),
667
549
  });
668
550
  }
669
551
 
@@ -697,47 +579,34 @@ function parseBoxPadding(value: unknown, fallback: number): number {
697
579
  return value === 0 || value === 1 ? value : fallback;
698
580
  }
699
581
 
582
+ const MAX_EDITOR_MIN_ROWS = 10;
583
+
584
+ function parseEditorMinRows(value: unknown): number {
585
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
586
+ return defaultConfig.editorMinRows;
587
+ }
588
+ return Math.min(MAX_EDITOR_MIN_ROWS, value);
589
+ }
590
+
700
591
  function normalizeSegmentOptions(value: unknown): SegmentOptionsConfig {
701
592
  const record = isRecord(value) ? value : {};
702
593
  const context = isRecord(record.context) ? record.context : {};
703
594
  const tokens = isRecord(record.tokens) ? record.tokens : {};
595
+ const subagents = isRecord(record.subagents) ? record.subagents : {};
704
596
  const cache = tokens.cache;
705
597
  return {
706
598
  context: { format: context.format === "percent" ? "percent" : "full" },
707
599
  tokens: {
708
600
  cache: cache === "tokens" || cache === "off" || cache === "percent" ? cache : "percent",
709
601
  },
710
- };
711
- }
712
-
713
- function normalizeFooterSegments(record: Record<string, unknown>): FooterSegmentsConfig {
714
- return {
715
- model: footerSegmentValue(record, "model"),
716
- thinking: footerSegmentValue(record, "thinking"),
717
- cwd: footerSegmentValue(record, "cwd"),
718
- sessionName: footerSegmentValue(record, "sessionName"),
719
- gitBranch: footerSegmentValue(record, "gitBranch"),
720
- gitStatus: footerSegmentValue(record, "gitStatus"),
721
- gitCounts: footerSegmentValue(record, "gitCounts"),
722
- runtime: footerSegmentValue(record, "runtime"),
723
- context: footerSegmentValue(record, "context"),
724
- tokens: footerSegmentValue(record, "tokens"),
725
- cacheHit: footerSegmentValue(record, "cacheHit"),
726
- cost: footerSegmentValue(record, "cost"),
727
- sessionDuration: footerSegmentValue(record, "sessionDuration"),
728
- username: footerSegmentValue(record, "username"),
729
- time: footerSegmentValue(record, "time"),
730
- os: footerSegmentValue(record, "os"),
731
- packageVersion: footerSegmentValue(record, "packageVersion"),
732
- gitCommit: footerSegmentValue(record, "gitCommit"),
733
- gitMetrics: footerSegmentValue(record, "gitMetrics"),
602
+ subagents: { format: subagents.format === "count" ? "count" : "agents" },
734
603
  };
735
604
  }
736
605
 
737
606
  /** Clamp hashLength to Git's valid abbreviation range [4, 40]. */
738
607
  function normalizeGitHashLength(value: unknown): number {
739
608
  const parsed = typeof value === "number" ? value : Number(value);
740
- if (!Number.isFinite(parsed)) return defaultConfig.gitCommit.hashLength;
609
+ if (!Number.isFinite(parsed)) return defaultConfig.git.commit.hashLength;
741
610
  const rounded = Math.round(parsed);
742
611
  return Math.min(40, Math.max(4, rounded));
743
612
  }
@@ -748,8 +617,9 @@ function normalizeGitCommitConfig(record: Record<string, unknown>): GitCommitCon
748
617
  onlyDetached:
749
618
  typeof record.onlyDetached === "boolean"
750
619
  ? record.onlyDetached
751
- : defaultConfig.gitCommit.onlyDetached,
752
- showTag: typeof record.showTag === "boolean" ? record.showTag : defaultConfig.gitCommit.showTag,
620
+ : defaultConfig.git.commit.onlyDetached,
621
+ showTag:
622
+ typeof record.showTag === "boolean" ? record.showTag : defaultConfig.git.commit.showTag,
753
623
  };
754
624
  }
755
625
 
@@ -758,22 +628,26 @@ function normalizeGitMetricsConfig(record: Record<string, unknown>): GitMetricsC
758
628
  onlyNonzero:
759
629
  typeof record.onlyNonzero === "boolean"
760
630
  ? record.onlyNonzero
761
- : defaultConfig.gitMetrics.onlyNonzero,
631
+ : defaultConfig.git.metrics.onlyNonzero,
762
632
  ignoreSubmodules:
763
633
  typeof record.ignoreSubmodules === "boolean"
764
634
  ? record.ignoreSubmodules
765
- : defaultConfig.gitMetrics.ignoreSubmodules,
635
+ : defaultConfig.git.metrics.ignoreSubmodules,
766
636
  };
767
637
  }
768
638
 
769
639
  export function isExtensionStatusPlacement(value: unknown): value is ExtensionStatusPlacement {
770
- return (
771
- value === "off" ||
772
- value === "left" ||
773
- value === "middle" ||
774
- value === "right" ||
775
- value === "editor"
776
- );
640
+ return value === "off" || value === "left" || value === "middle" || value === "right";
641
+ }
642
+
643
+ /**
644
+ * A stored placement, read leniently: `editor` named the editor's metadata row,
645
+ * which 0.9 removed, so those statuses move to the right of the footer rather
646
+ * than disappearing.
647
+ */
648
+ function readExtensionStatusPlacement(value: unknown): ExtensionStatusPlacement | undefined {
649
+ if (value === "editor") return "right";
650
+ return isExtensionStatusPlacement(value) ? value : undefined;
777
651
  }
778
652
 
779
653
  export function isExtensionStatusColorMode(value: unknown): value is ExtensionStatusColorMode {
@@ -784,15 +658,15 @@ function normalizeExtensionStatuses(
784
658
  record: Record<string, unknown>,
785
659
  palette: Palette = {},
786
660
  ): ExtensionStatusesConfig {
787
- const defaultPlacement = isExtensionStatusPlacement(record.defaultPlacement)
788
- ? record.defaultPlacement
789
- : defaultConfig.extensionStatuses.defaultPlacement;
661
+ const defaultPlacement =
662
+ readExtensionStatusPlacement(record.defaultPlacement) ??
663
+ defaultConfig.extensionStatuses.defaultPlacement;
790
664
  const placements = isRecord(record.placements)
791
665
  ? Object.fromEntries(
792
- Object.entries(record.placements).filter(
793
- (entry): entry is [string, ExtensionStatusPlacement] =>
794
- isExtensionStatusPlacement(entry[1]),
795
- ),
666
+ Object.entries(record.placements).flatMap(([key, value]) => {
667
+ const placement = readExtensionStatusPlacement(value);
668
+ return placement ? [[key, placement]] : [];
669
+ }),
796
670
  )
797
671
  : {};
798
672
  const colorModes = isRecord(record.colorModes)
@@ -823,12 +697,22 @@ function normalizeExtensionStatuses(
823
697
  )
824
698
  : {};
825
699
 
700
+ const formats = isRecord(record.formats)
701
+ ? Object.fromEntries(
702
+ Object.entries(record.formats).filter(
703
+ (entry): entry is [string, ExtensionStatusFormat] =>
704
+ entry[1] === "full" || entry[1] === "value",
705
+ ),
706
+ )
707
+ : {};
708
+
826
709
  return {
827
710
  defaultPlacement,
828
711
  placements,
829
712
  colorModes,
830
713
  colors,
831
714
  icons,
715
+ formats,
832
716
  };
833
717
  }
834
718
 
@@ -864,27 +748,6 @@ function isUiFeatureKey(value: string): value is keyof UiFeaturesConfig {
864
748
  return value === "editor" || value === "statusLine" || value === "copyFriendly";
865
749
  }
866
750
 
867
- function isFooterSegmentKey(value: string): value is keyof FooterSegmentsConfig {
868
- return (
869
- value === "cwd" ||
870
- value === "sessionName" ||
871
- value === "gitBranch" ||
872
- value === "gitStatus" ||
873
- value === "gitCounts" ||
874
- value === "runtime" ||
875
- value === "context" ||
876
- value === "tokens" ||
877
- value === "cost" ||
878
- value === "sessionDuration" ||
879
- value === "username" ||
880
- value === "time" ||
881
- value === "os" ||
882
- value === "packageVersion" ||
883
- value === "gitCommit" ||
884
- value === "gitMetrics"
885
- );
886
- }
887
-
888
751
  function validColorSourceEntries(record: Record<string, unknown>): Partial<ColorSourcesConfig> {
889
752
  return Object.fromEntries(
890
753
  Object.entries(record).filter((entry): entry is [keyof ColorSourcesConfig, ColorSource] => {
@@ -903,15 +766,6 @@ function validUiFeatureEntries(record: Record<string, unknown>): Partial<UiFeatu
903
766
  ) as Partial<UiFeaturesConfig>;
904
767
  }
905
768
 
906
- function validFooterSegmentEntries(record: Record<string, unknown>): Partial<FooterSegmentsConfig> {
907
- return Object.fromEntries(
908
- Object.entries(record).filter((entry): entry is [keyof FooterSegmentsConfig, boolean] => {
909
- const [key, value] = entry;
910
- return isFooterSegmentKey(key) && typeof value === "boolean";
911
- }),
912
- ) as Partial<FooterSegmentsConfig>;
913
- }
914
-
915
769
  type ConfigFileState =
916
770
  | { kind: "missing"; record: ConfigRecord; writePath: string }
917
771
  | { kind: "valid"; record: ConfigRecord; writePath: string; mode: number }
@@ -1016,33 +870,16 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
1016
870
  const features = isRecord(config.features)
1017
871
  ? normalizeUiFeatures(config.features as Record<string, unknown>)
1018
872
  : defaultConfig.features;
1019
- const footerSegments = isRecord(config.footerSegments)
1020
- ? normalizeFooterSegments(config.footerSegments as Record<string, unknown>)
1021
- : defaultConfig.footerSegments;
1022
873
  const extensionStatuses = isRecord(config.extensionStatuses)
1023
874
  ? normalizeExtensionStatuses(config.extensionStatuses as Record<string, unknown>, palette)
1024
875
  : defaultConfig.extensionStatuses;
1025
- const gitCommit = isRecord(config.gitCommit)
1026
- ? normalizeGitCommitConfig(config.gitCommit as Record<string, unknown>)
1027
- : defaultConfig.gitCommit;
1028
- const gitMetrics = isRecord(config.gitMetrics)
1029
- ? normalizeGitMetricsConfig(config.gitMetrics as Record<string, unknown>)
1030
- : defaultConfig.gitMetrics;
1031
- const gitBranch = parseGitBranchConfig(config.gitBranch);
876
+ const git = parseGitConfig(config.git);
1032
877
  const mouse = isRecord(config.mouse)
1033
878
  ? normalizeMouseConfig(config.mouse as Record<string, unknown>)
1034
879
  : defaultConfig.mouse;
1035
- const editorMetadataFormat = stringValue(config, "editorMetadataFormat");
1036
880
  return {
1037
881
  projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
1038
- footerStyle: parseFooterStyle(config.footerStyle),
1039
882
  pill: normalizePillConfig(config.pill),
1040
- footerFormat: stringValue(config, "footerFormat") ?? "",
1041
- // An explicit "" means "show nothing", which is how the model/thinking
1042
- // segments get moved down to the footer without leaving an empty row.
1043
- // Only a missing or non-string value falls back to the default.
1044
- editorMetadataFormat: editorMetadataFormat ?? DEFAULT_EDITOR_METADATA_FORMAT,
1045
- separator: parseSeparatorStyle(config.separator),
1046
883
  contextStyle: parseContextStyle(config.contextStyle),
1047
884
  segmentOptions: normalizeSegmentOptions(config.segmentOptions),
1048
885
  editorModelLabel: parseEditorModelLabel(config.editorModelLabel),
@@ -1050,14 +887,14 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
1050
887
  editorClickCursor: config.editorClickCursor !== false,
1051
888
  pasteCollapseLines: parsePasteCollapseLines(config.pasteCollapseLines),
1052
889
  editorPaddingY: parseBoxPadding(config.editorPaddingY, defaultConfig.editorPaddingY),
890
+ editorMinRows: parseEditorMinRows(config.editorMinRows),
1053
891
  userMessagePaddingY: parseBoxPadding(
1054
892
  config.userMessagePaddingY,
1055
893
  defaultConfig.userMessagePaddingY,
1056
894
  ),
1057
895
  contextThresholds: parseContextThresholds(config.contextThresholds),
1058
896
  pathDisplay: parsePathDisplay(config.pathDisplay),
1059
- gitBranch,
1060
- gitHostIcon: config.gitHostIcon === true,
897
+ git,
1061
898
  icons: resolveConfiguredIcons(iconMode, iconOverrides),
1062
899
  colors: {
1063
900
  ...defaultConfig.colors,
@@ -1065,15 +902,13 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
1065
902
  },
1066
903
  colorSources: { ...colorSources },
1067
904
  features: { ...features },
1068
- footerSegments: { ...footerSegments },
1069
- gitCommit,
1070
- gitMetrics,
1071
905
  extensionStatuses: {
1072
906
  defaultPlacement: extensionStatuses.defaultPlacement,
1073
907
  placements: { ...extensionStatuses.placements },
1074
908
  colorModes: { ...extensionStatuses.colorModes },
1075
909
  colors: { ...extensionStatuses.colors },
1076
910
  icons: { ...extensionStatuses.icons },
911
+ formats: { ...extensionStatuses.formats },
1077
912
  },
1078
913
  mouse,
1079
914
  };
@@ -1101,6 +936,14 @@ export function getExtensionStatusIcon(config: PolishedTuiConfig, key: string):
1101
936
  return config.extensionStatuses.icons[key] ?? "";
1102
937
  }
1103
938
 
939
+ /** Text shape for one status. Unset means the text the extension published. */
940
+ export function getExtensionStatusFormat(
941
+ config: PolishedTuiConfig,
942
+ key: string,
943
+ ): ExtensionStatusFormat {
944
+ return config.extensionStatuses.formats[key] ?? "full";
945
+ }
946
+
1104
947
  export function getExtensionStatusColorMode(
1105
948
  config: PolishedTuiConfig,
1106
949
  key: string,
@@ -1108,16 +951,16 @@ export function getExtensionStatusColorMode(
1108
951
  return config.extensionStatuses.colorModes[key] ?? DEFAULT_EXTENSION_STATUS_COLOR_MODE;
1109
952
  }
1110
953
 
1111
- export function loadConfig(): PolishedTuiConfig {
954
+ export function loadConfig(path = configPath): PolishedTuiConfig {
1112
955
  try {
1113
- if (!existsSync(configPath)) return mergeConfig({});
1114
- const parsed = JSON.parse(readFileSync(configPath, "utf8"));
956
+ if (!existsSync(path)) return mergeConfig({});
957
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
1115
958
  if (!isRecord(parsed)) return mergeConfig(parsed);
1116
959
 
1117
960
  const { config, migrated } = migrateFixedEditorKeys(parsed);
1118
961
  if (migrated) {
1119
962
  try {
1120
- writeConfigAtomically(configPath, config);
963
+ writeConfigAtomically(path, config);
1121
964
  } catch {
1122
965
  // Best effort: still use the migrated config for this run even if
1123
966
  // the write-back fails (e.g. read-only filesystem).
@@ -1127,7 +970,13 @@ export function loadConfig(): PolishedTuiConfig {
1127
970
  );
1128
971
  }
1129
972
  return mergeConfig(config);
1130
- } catch {
973
+ } catch (error) {
974
+ // Falling back to defaults is right — a broken config must not block a
975
+ // session — but silently forgetting every setting is not. `mutateConfig`
976
+ // refuses loudly on the same file, so say which file was given up on.
977
+ console.warn(
978
+ `[starline] Could not read ${path}; using defaults for this session. ${error instanceof Error ? error.message : String(error)}`,
979
+ );
1131
980
  return mergeConfig({});
1132
981
  }
1133
982
  }
@@ -1162,27 +1011,6 @@ export function saveUiFeaturesPatch(
1162
1011
  });
1163
1012
  }
1164
1013
 
1165
- export function saveFooterSegmentsPatch(
1166
- patch: Partial<FooterSegmentsConfig>,
1167
- path = configPath,
1168
- ): PolishedTuiConfig {
1169
- return mutateConfig(path, (record) => {
1170
- const existing = isRecord(record.footerSegments)
1171
- ? { ...(record.footerSegments as Record<string, unknown>) }
1172
- : {};
1173
- record.footerSegments = {
1174
- ...existing,
1175
- ...validFooterSegmentEntries(patch),
1176
- };
1177
- });
1178
- }
1179
-
1180
- export function saveFooterFormatPatch(value: string, path = configPath): PolishedTuiConfig {
1181
- return mutateConfig(path, (record) => {
1182
- record.footerFormat = typeof value === "string" ? value : "";
1183
- });
1184
- }
1185
-
1186
1014
  export function saveIconsModePatch(mode: IconMode, path = configPath): PolishedTuiConfig {
1187
1015
  return mutateConfig(path, (record) => {
1188
1016
  const existing = isRecord(record.icons) ? { ...(record.icons as Record<string, unknown>) } : {};
@@ -1199,15 +1027,6 @@ export function saveContextStylePatch(style: ContextStyle, path = configPath): P
1199
1027
  });
1200
1028
  }
1201
1029
 
1202
- export function saveSeparatorPatch(
1203
- separator: SeparatorStyle,
1204
- path = configPath,
1205
- ): PolishedTuiConfig {
1206
- return mutateConfig(path, (record) => {
1207
- record.separator = parseSeparatorStyle(separator);
1208
- });
1209
- }
1210
-
1211
1030
  export function saveContextThresholdsPatch(
1212
1031
  thresholds: Partial<ContextThresholds>,
1213
1032
  path = configPath,
@@ -1237,17 +1056,28 @@ export function savePathDisplayPatch(
1237
1056
  });
1238
1057
  }
1239
1058
 
1059
+ export function savePillSegmentsPatch(
1060
+ segments: readonly string[],
1061
+ path = configPath,
1062
+ ): PolishedTuiConfig {
1063
+ return mutateConfig(path, (record) => {
1064
+ const existing = isRecord(record.pill) ? { ...(record.pill as Record<string, unknown>) } : {};
1065
+ record.pill = { ...existing, segments: [...segments] };
1066
+ });
1067
+ }
1068
+
1240
1069
  export function saveGitBranchPatch(
1241
1070
  patch: Partial<GitBranchConfig>,
1242
1071
  path = configPath,
1243
1072
  ): PolishedTuiConfig {
1244
1073
  return mutateConfig(path, (record) => {
1245
- const existing = isRecord(record.gitBranch)
1246
- ? { ...(record.gitBranch as Record<string, unknown>) }
1074
+ const existingGit = isRecord(record.git) ? { ...(record.git as Record<string, unknown>) } : {};
1075
+ const existingBranch = isRecord(existingGit.branch)
1076
+ ? { ...(existingGit.branch as Record<string, unknown>) }
1247
1077
  : {};
1248
1078
  if (patch.maxLength !== undefined)
1249
- existing.maxLength = normalizeGitBranchMaxLength(patch.maxLength);
1250
- record.gitBranch = existing;
1079
+ existingBranch.maxLength = normalizeGitBranchMaxLength(patch.maxLength);
1080
+ record.git = { ...existingGit, branch: existingBranch };
1251
1081
  });
1252
1082
  }
1253
1083