@pellux/goodvibes-tui 1.7.0 → 1.9.0

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 (137) hide show
  1. package/README.md +10 -9
  2. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  3. package/package.json +2 -2
  4. package/src/audio/spoken-turn-controller.ts +12 -2
  5. package/src/audio/spoken-turn-wiring.ts +2 -1
  6. package/src/cli/help.ts +8 -1
  7. package/src/cli/service-posture.ts +2 -1
  8. package/src/cli/status.ts +2 -1
  9. package/src/cli/surface-command.ts +2 -2
  10. package/src/core/composer-state.ts +11 -3
  11. package/src/core/conversation-line-cache.ts +27 -3
  12. package/src/core/conversation-rendering.ts +71 -14
  13. package/src/core/stream-event-wiring.ts +20 -1
  14. package/src/core/system-message-noise.ts +87 -0
  15. package/src/core/system-message-router.ts +68 -1
  16. package/src/core/turn-cancellation.ts +7 -2
  17. package/src/core/turn-event-wiring.ts +10 -2
  18. package/src/daemon/cli.ts +29 -2
  19. package/src/daemon/handlers/register.ts +8 -1
  20. package/src/daemon/service-commands.ts +329 -0
  21. package/src/input/autocomplete.ts +27 -1
  22. package/src/input/command-registry.ts +46 -4
  23. package/src/input/commands/codebase-runtime.ts +46 -6
  24. package/src/input/commands/config.ts +43 -3
  25. package/src/input/commands/health-runtime.ts +9 -1
  26. package/src/input/commands/memory.ts +68 -35
  27. package/src/input/commands/operator-panel-runtime.ts +31 -7
  28. package/src/input/commands/planning-runtime.ts +95 -6
  29. package/src/input/commands/qrcode-runtime.ts +25 -5
  30. package/src/input/commands/remote-runtime-setup.ts +5 -3
  31. package/src/input/commands/session-content.ts +20 -9
  32. package/src/input/commands/settings-sync-runtime.ts +15 -3
  33. package/src/input/commands/shell-core.ts +10 -1
  34. package/src/input/commands/workstream-runtime.ts +168 -18
  35. package/src/input/config-modal-types.ts +15 -1
  36. package/src/input/config-modal.ts +227 -12
  37. package/src/input/feed-context-factory.ts +3 -0
  38. package/src/input/handler-command-route.ts +10 -3
  39. package/src/input/handler-content-actions.ts +17 -2
  40. package/src/input/handler-feed-routes.ts +43 -121
  41. package/src/input/handler-feed.ts +32 -4
  42. package/src/input/handler-modal-routes.ts +78 -13
  43. package/src/input/handler-modal-stack.ts +11 -2
  44. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  45. package/src/input/handler-onboarding.ts +71 -59
  46. package/src/input/handler-picker-routes.ts +15 -8
  47. package/src/input/handler-shortcuts.ts +59 -9
  48. package/src/input/handler.ts +6 -1
  49. package/src/input/keybindings.ts +6 -5
  50. package/src/input/model-picker.ts +19 -2
  51. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  52. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  53. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  54. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  55. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  56. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  57. package/src/input/panel-mouse-geometry.ts +97 -0
  58. package/src/input/panel-paste-flood-guard.ts +86 -0
  59. package/src/input/selection-modal.ts +11 -0
  60. package/src/input/session-picker-modal.ts +44 -4
  61. package/src/input/settings-modal-data.ts +51 -2
  62. package/src/input/settings-modal-types.ts +4 -2
  63. package/src/main.ts +28 -27
  64. package/src/panels/builtin/shared.ts +9 -3
  65. package/src/panels/fleet-deep-link.ts +31 -0
  66. package/src/panels/fleet-panel-format.ts +62 -0
  67. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  68. package/src/panels/fleet-panel.ts +89 -131
  69. package/src/panels/fleet-read-model.ts +43 -0
  70. package/src/panels/fleet-steer.ts +35 -2
  71. package/src/panels/fleet-stop.ts +29 -1
  72. package/src/panels/modals/keybindings-modal.ts +16 -1
  73. package/src/panels/modals/modal-theme.ts +35 -29
  74. package/src/panels/modals/pairing-modal.ts +25 -6
  75. package/src/panels/modals/planning-modal.ts +43 -21
  76. package/src/panels/modals/work-plan-modal.ts +28 -0
  77. package/src/panels/panel-manager.ts +15 -4
  78. package/src/panels/polish-core.ts +38 -25
  79. package/src/panels/project-planning-answer-actions.ts +8 -13
  80. package/src/panels/types.ts +24 -0
  81. package/src/permissions/prompt.ts +160 -13
  82. package/src/renderer/autocomplete-overlay.ts +28 -2
  83. package/src/renderer/compositor.ts +2 -3
  84. package/src/renderer/config-modal.ts +19 -5
  85. package/src/renderer/footer-tips.ts +5 -1
  86. package/src/renderer/fullscreen-primitives.ts +32 -22
  87. package/src/renderer/git-status.ts +3 -1
  88. package/src/renderer/layout.ts +0 -4
  89. package/src/renderer/markdown.ts +7 -3
  90. package/src/renderer/modal-factory.ts +25 -20
  91. package/src/renderer/model-workspace.ts +18 -3
  92. package/src/renderer/overlay-box.ts +21 -17
  93. package/src/renderer/process-indicator.ts +14 -3
  94. package/src/renderer/selection-modal-overlay.ts +6 -1
  95. package/src/renderer/session-picker-modal.ts +196 -3
  96. package/src/renderer/settings-modal-helpers.ts +2 -0
  97. package/src/renderer/settings-modal.ts +7 -0
  98. package/src/renderer/shell-surface.ts +8 -1
  99. package/src/renderer/status-glyphs.ts +14 -15
  100. package/src/renderer/system-message.ts +15 -3
  101. package/src/renderer/terminal-bg-probe.ts +339 -0
  102. package/src/renderer/terminal-escapes.ts +20 -0
  103. package/src/renderer/theme-mode-config.ts +67 -0
  104. package/src/renderer/theme.ts +91 -1
  105. package/src/renderer/thinking.ts +11 -3
  106. package/src/renderer/tool-call.ts +15 -9
  107. package/src/renderer/tool-result-summary.ts +148 -0
  108. package/src/renderer/turn-injection.ts +22 -3
  109. package/src/renderer/ui-factory.ts +154 -85
  110. package/src/renderer/ui-primitives.ts +30 -129
  111. package/src/runtime/bootstrap-command-context.ts +6 -0
  112. package/src/runtime/bootstrap-command-parts.ts +8 -4
  113. package/src/runtime/bootstrap-core.ts +33 -11
  114. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  115. package/src/runtime/bootstrap-shell.ts +19 -1
  116. package/src/runtime/bootstrap.ts +118 -5
  117. package/src/runtime/code-index-services.ts +25 -2
  118. package/src/runtime/legacy-daemon-migration.ts +516 -0
  119. package/src/runtime/memory-fold.ts +26 -0
  120. package/src/runtime/onboarding/derivation.ts +7 -2
  121. package/src/runtime/onboarding/snapshot.ts +27 -1
  122. package/src/runtime/onboarding/types.ts +31 -1
  123. package/src/runtime/operator-token-cleanup.ts +82 -1
  124. package/src/runtime/orchestrator-core-services.ts +10 -0
  125. package/src/runtime/resume-notice.ts +209 -0
  126. package/src/runtime/services.ts +12 -8
  127. package/src/runtime/session-inbound-inputs.ts +252 -0
  128. package/src/runtime/session-spine-transport.ts +64 -0
  129. package/src/runtime/terminal-output-guard.ts +15 -8
  130. package/src/runtime/ui-services.ts +19 -3
  131. package/src/runtime/workstream-services.ts +160 -28
  132. package/src/runtime/wrfc-persistence.ts +124 -17
  133. package/src/shell/blocking-input.ts +46 -3
  134. package/src/shell/recovery-input-helpers.ts +170 -1
  135. package/src/shell/ui-openers.ts +42 -9
  136. package/src/utils/terminal-width.ts +52 -0
  137. package/src/version.ts +1 -1
@@ -5,8 +5,15 @@
5
5
  * architecture line ceiling; main() wires these with its live services.
6
6
  */
7
7
 
8
+ import { copyFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs';
8
9
  import type { ConversationMessageSnapshot } from '../core/conversation.ts';
9
- import type { SessionSnapshot } from '@/runtime/index.ts';
10
+ import type { SessionReturnContextSummary, SessionSnapshot } from '@/runtime/index.ts';
11
+ import {
12
+ checkRecoveryFile,
13
+ deleteRecoveryFile as deleteLiveRecoveryFile,
14
+ getRecoveryFilePath,
15
+ loadRecoveryConversation as loadLiveRecoveryConversation,
16
+ } from '@/runtime/index.ts';
10
17
 
11
18
  export interface PersistRecoveryDeps {
12
19
  readonly sessionManager: {
@@ -41,6 +48,168 @@ export function createReopenRecoveryPanels(deps: ReopenPanelsDeps): (snapshot: S
41
48
  };
42
49
  }
43
50
 
51
+ // ---------------------------------------------------------------------------
52
+ // W3 Finding 3: preserve the dismissed recovery snapshot.
53
+ //
54
+ // main.ts's 60s autosave (writeRecoveryFile) overwrites the single shared
55
+ // recovery.jsonl with the CURRENT session's live state within a minute — so
56
+ // the dismiss banner's promise ("still on disk; you will be asked again next
57
+ // time") went false silently well before the next launch. Fix: the moment
58
+ // the user dismisses (blocking-input.ts), copy recovery.jsonl aside to a
59
+ // preserved sibling file (recovery.jsonl.preserved) so it survives this
60
+ // session's later autosaves. Startup then checks BOTH the live file and the
61
+ // preserved sibling and offers whichever is newer (pickNewestRecoveryInfo),
62
+ // bounded to exactly one preserved file — a later dismiss replaces it, and
63
+ // that replacement is reported honestly (PreserveRecoveryResult.replacedPrevious)
64
+ // rather than silently dropping the older snapshot.
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export interface RecoveryFileDeps {
68
+ readonly homeDirectory: string;
69
+ readonly surfaceRoot?: string | undefined;
70
+ }
71
+
72
+ export interface PreservedRecoveryInfo {
73
+ readonly title: string;
74
+ readonly timestamp: number;
75
+ readonly sessionId: string;
76
+ readonly returnContext?: SessionReturnContextSummary | undefined;
77
+ }
78
+
79
+ export interface PreserveRecoveryResult {
80
+ /** True if a live recovery.jsonl existed and was copied to the preserved sibling. */
81
+ readonly preserved: boolean;
82
+ /** True if an existing preserved sibling (from an earlier dismiss) was overwritten. */
83
+ readonly replacedPrevious: boolean;
84
+ }
85
+
86
+ function preservedRecoveryPath(deps: RecoveryFileDeps): string {
87
+ return `${getRecoveryFilePath(deps.homeDirectory, deps.surfaceRoot)}.preserved`;
88
+ }
89
+
90
+ function readRecoveryMetaLine(path: string): { title?: string; timestamp?: number; sessionId?: string; returnContext?: SessionReturnContextSummary } | null {
91
+ try {
92
+ if (!existsSync(path)) return null;
93
+ const firstLine = readFileSync(path, 'utf-8').split('\n')[0];
94
+ if (!firstLine) return null;
95
+ return JSON.parse(firstLine) as { title?: string; timestamp?: number; sessionId?: string; returnContext?: SessionReturnContextSummary };
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /** Copy the live recovery.jsonl aside to the preserved sibling. Called once, at dismiss. */
102
+ export function createPreserveRecoveryFile(deps: RecoveryFileDeps): () => PreserveRecoveryResult {
103
+ return () => {
104
+ const live = getRecoveryFilePath(deps.homeDirectory, deps.surfaceRoot);
105
+ const preserved = preservedRecoveryPath(deps);
106
+ if (!existsSync(live)) return { preserved: false, replacedPrevious: false };
107
+ const replacedPrevious = existsSync(preserved);
108
+ try {
109
+ copyFileSync(live, preserved);
110
+ return { preserved: true, replacedPrevious };
111
+ } catch {
112
+ return { preserved: false, replacedPrevious: false };
113
+ }
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Startup check for the preserved sibling — mirrors the SDK's
119
+ * checkRecoveryFile meta read, but for the fixed preserved path. No
120
+ * mtime-vs-last-clean-save gate: the preserved file is inert (never
121
+ * autosaved-over) until explicitly replaced or cleared, so its mere
122
+ * existence is actionable.
123
+ */
124
+ export function checkPreservedRecoveryFile(deps: RecoveryFileDeps): PreservedRecoveryInfo | null {
125
+ const meta = readRecoveryMetaLine(preservedRecoveryPath(deps));
126
+ if (!meta) return null;
127
+ return { title: meta.title ?? '', timestamp: meta.timestamp ?? 0, sessionId: meta.sessionId ?? '', returnContext: meta.returnContext };
128
+ }
129
+
130
+ /** Load the preserved sibling's full snapshot — same JSONL shape as recovery.jsonl since it's a byte-identical copy. */
131
+ export function loadPreservedRecoveryConversation(deps: RecoveryFileDeps): SessionSnapshot | null {
132
+ const path = preservedRecoveryPath(deps);
133
+ try {
134
+ if (!existsSync(path)) return null;
135
+ const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean);
136
+ if (lines.length < 2) return { messages: [] };
137
+ const metaLine = JSON.parse(lines[0]!) as { title?: string; titleSource?: SessionSnapshot['titleSource']; returnContext?: SessionSnapshot['returnContext'] };
138
+ return {
139
+ title: metaLine.title,
140
+ titleSource: metaLine.titleSource,
141
+ returnContext: metaLine.returnContext,
142
+ messages: lines.slice(1).map((line) => {
143
+ const { type: _type, ...rest } = JSON.parse(line) as { type: string } & Record<string, unknown>;
144
+ return rest;
145
+ }),
146
+ };
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /** Remove the preserved sibling — called after it has been restored or discarded. */
153
+ export function deletePreservedRecoveryFile(deps: RecoveryFileDeps): void {
154
+ try {
155
+ unlinkSync(preservedRecoveryPath(deps));
156
+ } catch {
157
+ // missing file is fine
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Pick the newer of the live vs. preserved recovery info for the startup
163
+ * prompt, tagging which source it came from so the caller knows which
164
+ * load/delete pair to use. Null when neither exists.
165
+ */
166
+ export function pickNewestRecoveryInfo<T extends { readonly timestamp: number }>(
167
+ live: T | null,
168
+ preserved: T | null,
169
+ ): (T & { readonly source: 'live' | 'preserved' }) | null {
170
+ if (!live && !preserved) return null;
171
+ if (!live) return { ...(preserved as T), source: 'preserved' };
172
+ if (!preserved) return { ...live, source: 'live' };
173
+ return preserved.timestamp > live.timestamp
174
+ ? { ...(preserved as T), source: 'preserved' }
175
+ : { ...live, source: 'live' };
176
+ }
177
+
178
+ /**
179
+ * Startup entry point: resolve which recovery prompt (if any) main.ts should
180
+ * show — the live recovery.jsonl, or an earlier dismiss's preserved sibling,
181
+ * whichever is newer. Bundles checkRecoveryFile + checkPreservedRecoveryFile
182
+ * + pickNewestRecoveryInfo so main.ts's entrypoint stays a one-liner.
183
+ */
184
+ export function resolveStartupRecoveryInfo(
185
+ deps: RecoveryFileDeps & { readonly workingDirectory: string },
186
+ ): (PreservedRecoveryInfo & { readonly source: 'live' | 'preserved' }) | null {
187
+ const live = checkRecoveryFile({ workingDirectory: deps.workingDirectory, homeDirectory: deps.homeDirectory });
188
+ const preserved = checkPreservedRecoveryFile(deps);
189
+ return pickNewestRecoveryInfo(live, preserved);
190
+ }
191
+
192
+ export interface RecoveryFileOps {
193
+ readonly loadRecoveryConversation: () => SessionSnapshot | null;
194
+ readonly deleteRecoveryFile: () => void;
195
+ readonly preserveRecoveryFile?: () => PreserveRecoveryResult;
196
+ }
197
+
198
+ /**
199
+ * Build the three source-aware recovery callbacks main.ts's stdin handler
200
+ * wires into handleBlockingShellInput. `getSource` is read fresh by
201
+ * load/delete on every call (main.ts's recoverySource can change between
202
+ * events); preserveRecoveryFile is only ever wired when the CURRENT prompt
203
+ * came from the live file (see createPreserveRecoveryFile's doc comment).
204
+ */
205
+ export function createRecoveryFileOps(getSource: () => 'live' | 'preserved', deps: RecoveryFileDeps): RecoveryFileOps {
206
+ return {
207
+ loadRecoveryConversation: () => (getSource() === 'preserved' ? loadPreservedRecoveryConversation(deps) : loadLiveRecoveryConversation(deps)),
208
+ deleteRecoveryFile: () => (getSource() === 'preserved' ? deletePreservedRecoveryFile(deps) : deleteLiveRecoveryFile(deps)),
209
+ preserveRecoveryFile: getSource() === 'live' ? createPreserveRecoveryFile(deps) : undefined,
210
+ };
211
+ }
212
+
44
213
  export interface ErrorAffordanceDeps {
45
214
  /** True when the failover retry context is armed (a retry is actually possible). */
46
215
  readonly retryArmed: boolean;
@@ -15,6 +15,8 @@ import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
15
15
  import type { EmbeddingProviderPickerEntry, ModelPickerTargetInfo } from '../input/model-picker.ts';
16
16
  import type { SelectionItem } from '../input/selection-modal.ts';
17
17
  import { syncServiceSettingToPlatform } from './service-settings-sync.ts';
18
+ import { setActiveThemeMode } from '../renderer/theme.ts';
19
+ import { THEME_MODE_CONFIG_KEY, coerceThemeModeSetting } from '../renderer/theme-mode-config.ts';
18
20
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
19
21
 
20
22
  type WireShellUiOpenersOptions = {
@@ -229,7 +231,13 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
229
231
 
230
232
  commandContext.openModelPicker = () => {
231
233
  void (async () => {
232
- const models = providerRegistry.getSelectableModels();
234
+ // D5: getSelectableModels() is catalog-driven and can list models whose
235
+ // `provider` id (e.g. 'google', sourced from the pricing catalog) was never
236
+ // handed to providerRegistry.register()/registerRuntimeProvider(). Selecting
237
+ // such a model fails hard at turn time with ProviderNotFoundError
238
+ // ("Provider 'X' is not registered."). Filter to runtime-registered
239
+ // providers only, so the picker never offers a model that cannot work.
240
+ const models = providerRegistry.getSelectableModels().filter((m) => providerRegistry.has(m.provider));
233
241
  const configuredIds = new Set(getConfiguredProviderIds());
234
242
  input.modelPicker.configuredProviders = configuredIds;
235
243
  const providerIds = [...new Set(models.map((m) => m.provider))];
@@ -256,7 +264,13 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
256
264
 
257
265
  commandContext.openProviderPicker = () => {
258
266
  void (async () => {
259
- const providers = [...new Set(providerRegistry.listModels().map((model) => model.provider))];
267
+ // D5: listModels() surfaces every catalog provider id, not just the ones
268
+ // actually registered on this runtime (see openModelPicker above for the
269
+ // same class of bug). Intersect against providerRegistry.has() so the
270
+ // provider picker never offers a provider that will fail with
271
+ // "Provider not registered" once a model under it is selected.
272
+ const providers = [...new Set(providerRegistry.listModels().map((model) => model.provider))]
273
+ .filter((providerId) => providerRegistry.has(providerId));
260
274
  const configuredIds = new Set(getConfiguredProviderIds());
261
275
  input.modelPicker.configuredProviders = configuredIds;
262
276
  const secretProviderIds = await resolveSecretProviderIds();
@@ -325,10 +339,23 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
325
339
  commandContext.openSettingsModal = (target?: string) => {
326
340
  input.modalOpened('settings');
327
341
  input.settingsModal.open(configManager, featureFlags, subscriptionManager, serviceRegistry, mcpRegistry, secretsManager, {
328
- onSettingApplied: (change) => syncServiceSettingToPlatform(
329
- { configManager, workingDirectory, homeDirectory },
330
- change,
331
- ),
342
+ onSettingApplied: (change) => {
343
+ // DEBT-2: forced dark/light applies immediately (rebuild palettes + full
344
+ // repaint); auto only re-probes at startup, so it takes effect next launch.
345
+ if (String(change.key) === THEME_MODE_CONFIG_KEY) {
346
+ const next = coerceThemeModeSetting(change.value);
347
+ if (next === 'dark' || next === 'light') {
348
+ setActiveThemeMode(next);
349
+ commandContext.requestFullRepaint?.();
350
+ return { message: `Theme mode: ${next} (applied now)` };
351
+ }
352
+ return { message: 'Theme mode: auto (probes terminal on next startup)' };
353
+ }
354
+ return syncServiceSettingToPlatform(
355
+ { configManager, workingDirectory, homeDirectory },
356
+ change,
357
+ );
358
+ },
332
359
  });
333
360
  input.settingsModal.selectTarget(target);
334
361
  render();
@@ -441,10 +468,16 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
441
468
  render();
442
469
  };
443
470
 
444
- commandContext.showPanel = (panelId, pane) => {
445
- panelManager.open(panelId, pane);
471
+ commandContext.showPanel = (panelId, pane, target, opts) => {
472
+ // DEBT-5: forward the deep-link target so a jumped-to panel lands on the
473
+ // right row (fleet --target); undefined for ordinary opens.
474
+ panelManager.open(panelId, pane, target);
446
475
  panelManager.show();
447
- panelManager.focusPanels();
476
+ // UX-C focus rule 1a: every registered caller of showPanel is a slash
477
+ // command (/panel open, /routes, /approval, /tasks, /ops-control, ...) —
478
+ // the command path leaves focus in the composer ("the user is
479
+ // mid-command-flow") unless the caller explicitly asks to grab it.
480
+ if (opts?.focus) panelManager.focusPanels();
448
481
  conversation.setSplashSuppressed(true);
449
482
  conversation.rebuildHistory();
450
483
  render();
@@ -86,6 +86,13 @@ export function getDisplayWidth(text: string): number {
86
86
  code === 0x2717 ||
87
87
  code === 0x2714 ||
88
88
  code === 0x2718 ||
89
+ // ✕ (0x2715) and ✖ (0x2716) — the multiplication-X cross family used for
90
+ // the error-line prefix. Terminals draw them one cell wide, but they sit
91
+ // inside the 0x2600–0x27bf emoji block below and would otherwise be counted
92
+ // as width 2, desyncing the styled cell grid from the physical glyph and
93
+ // corrupting the following text (the "✕t" glitch on the steer error line).
94
+ code === 0x2715 ||
95
+ code === 0x2716 ||
89
96
  code === 0x2022 ||
90
97
  code === 0x258d ||
91
98
  (code >= 0x2500 && code <= 0x257f)
@@ -159,6 +166,51 @@ export function truncateDisplay(text: string, width: number, ellipsis = '…'):
159
166
  return result + ellipsis;
160
167
  }
161
168
 
169
+ /** A single footer/status-line segment plus its survival priority. */
170
+ export interface PrioritizedSegment {
171
+ readonly text: string;
172
+ /** Lower number = higher priority = dropped LAST under width pressure. */
173
+ readonly priority: number;
174
+ }
175
+
176
+ /**
177
+ * Join segments left-to-right with `separator`, but when the joined line
178
+ * would exceed `width`, drop whole low-priority segments (highest `priority`
179
+ * number first) one at a time until it fits — rather than character-truncating
180
+ * the joined string, which can mangle a high-value segment mid-word (e.g. a
181
+ * `spine:online` daemon-liveness marker clipped to `spi…`).
182
+ *
183
+ * Only falls back to character truncation (via truncateDisplay) if the
184
+ * remaining highest-priority segments still don't fit at width — a rare,
185
+ * very-narrow-terminal case.
186
+ */
187
+ export function joinPrioritizedSegments(
188
+ segments: readonly PrioritizedSegment[],
189
+ separator: string,
190
+ width: number,
191
+ ): string {
192
+ const join = (list: readonly PrioritizedSegment[]) => list.map(s => s.text).join(separator);
193
+ let kept = segments;
194
+ // Keep dropping whole segments while more than one remains; once a single
195
+ // segment is left, stop — an empty result would be a worse outcome than
196
+ // falling through to character truncation on that last segment below.
197
+ while (kept.length > 1 && getDisplayWidth(join(kept)) > width) {
198
+ // Drop the single lowest-priority (highest `priority` number) segment;
199
+ // ties broken toward the leftmost (earlier-declared) segment surviving —
200
+ // `>=` (not `>`) so that on equal priority the LATER index keeps winning
201
+ // as the drop candidate, leaving the earliest-declared segment of that
202
+ // priority tier intact (e.g. cwd survives over model when both are
203
+ // priority 0, since cwd is declared first).
204
+ let dropIdx = 0;
205
+ for (let i = 1; i < kept.length; i++) {
206
+ if (kept[i].priority >= kept[dropIdx].priority) dropIdx = i;
207
+ }
208
+ kept = kept.slice(0, dropIdx).concat(kept.slice(dropIdx + 1));
209
+ }
210
+ const joined = join(kept);
211
+ return getDisplayWidth(joined) > width ? truncateDisplay(joined, width) : joined;
212
+ }
213
+
162
214
  export function padDisplayEnd(text: string, width: number): string {
163
215
  const currentWidth = getDisplayWidth(text);
164
216
  if (currentWidth >= width) return text;
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '1.7.0';
9
+ let _version = '1.9.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;