@pellux/goodvibes-tui 1.7.0 → 1.9.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 (140) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +10 -9
  3. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  4. package/package.json +2 -2
  5. package/src/audio/spoken-turn-controller.ts +12 -2
  6. package/src/audio/spoken-turn-wiring.ts +2 -1
  7. package/src/cli/help.ts +8 -1
  8. package/src/cli/service-posture.ts +2 -1
  9. package/src/cli/status.ts +2 -1
  10. package/src/cli/surface-command.ts +2 -2
  11. package/src/config/credential-availability.ts +122 -0
  12. package/src/config/index.ts +15 -0
  13. package/src/core/composer-state.ts +11 -3
  14. package/src/core/conversation-line-cache.ts +27 -3
  15. package/src/core/conversation-rendering.ts +71 -14
  16. package/src/core/stream-event-wiring.ts +20 -1
  17. package/src/core/system-message-noise.ts +87 -0
  18. package/src/core/system-message-router.ts +68 -1
  19. package/src/core/turn-cancellation.ts +7 -2
  20. package/src/core/turn-event-wiring.ts +10 -2
  21. package/src/daemon/cli.ts +29 -2
  22. package/src/daemon/handlers/register.ts +8 -1
  23. package/src/daemon/service-commands.ts +329 -0
  24. package/src/input/autocomplete.ts +27 -1
  25. package/src/input/command-registry.ts +46 -4
  26. package/src/input/commands/codebase-runtime.ts +46 -6
  27. package/src/input/commands/config.ts +43 -3
  28. package/src/input/commands/health-runtime.ts +9 -1
  29. package/src/input/commands/memory.ts +68 -35
  30. package/src/input/commands/operator-panel-runtime.ts +31 -7
  31. package/src/input/commands/planning-runtime.ts +95 -6
  32. package/src/input/commands/qrcode-runtime.ts +25 -5
  33. package/src/input/commands/remote-runtime-setup.ts +5 -3
  34. package/src/input/commands/session-content.ts +20 -9
  35. package/src/input/commands/settings-sync-runtime.ts +15 -3
  36. package/src/input/commands/shell-core.ts +10 -1
  37. package/src/input/commands/workstream-runtime.ts +168 -18
  38. package/src/input/config-modal-types.ts +15 -1
  39. package/src/input/config-modal.ts +227 -12
  40. package/src/input/feed-context-factory.ts +3 -0
  41. package/src/input/handler-command-route.ts +10 -3
  42. package/src/input/handler-content-actions.ts +17 -2
  43. package/src/input/handler-feed-routes.ts +43 -121
  44. package/src/input/handler-feed.ts +32 -4
  45. package/src/input/handler-modal-routes.ts +78 -13
  46. package/src/input/handler-modal-stack.ts +11 -2
  47. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  48. package/src/input/handler-onboarding.ts +71 -59
  49. package/src/input/handler-picker-routes.ts +15 -8
  50. package/src/input/handler-shortcuts.ts +59 -9
  51. package/src/input/handler.ts +6 -1
  52. package/src/input/keybindings.ts +6 -5
  53. package/src/input/model-picker.ts +19 -2
  54. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  55. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  56. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  57. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  58. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  59. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  60. package/src/input/panel-mouse-geometry.ts +97 -0
  61. package/src/input/panel-paste-flood-guard.ts +86 -0
  62. package/src/input/selection-modal.ts +11 -0
  63. package/src/input/session-picker-modal.ts +44 -4
  64. package/src/input/settings-modal-data.ts +51 -2
  65. package/src/input/settings-modal-types.ts +4 -2
  66. package/src/main.ts +28 -27
  67. package/src/panels/builtin/shared.ts +9 -3
  68. package/src/panels/fleet-deep-link.ts +31 -0
  69. package/src/panels/fleet-panel-format.ts +62 -0
  70. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  71. package/src/panels/fleet-panel.ts +89 -131
  72. package/src/panels/fleet-read-model.ts +43 -0
  73. package/src/panels/fleet-steer.ts +35 -2
  74. package/src/panels/fleet-stop.ts +29 -1
  75. package/src/panels/modals/keybindings-modal.ts +16 -1
  76. package/src/panels/modals/modal-theme.ts +35 -29
  77. package/src/panels/modals/pairing-modal.ts +25 -6
  78. package/src/panels/modals/planning-modal.ts +43 -21
  79. package/src/panels/modals/work-plan-modal.ts +28 -0
  80. package/src/panels/panel-manager.ts +15 -4
  81. package/src/panels/polish-core.ts +38 -25
  82. package/src/panels/project-planning-answer-actions.ts +8 -13
  83. package/src/panels/types.ts +24 -0
  84. package/src/permissions/prompt.ts +160 -13
  85. package/src/renderer/autocomplete-overlay.ts +28 -2
  86. package/src/renderer/compositor.ts +2 -3
  87. package/src/renderer/config-modal.ts +19 -5
  88. package/src/renderer/footer-tips.ts +5 -1
  89. package/src/renderer/fullscreen-primitives.ts +32 -22
  90. package/src/renderer/git-status.ts +3 -1
  91. package/src/renderer/layout.ts +0 -4
  92. package/src/renderer/markdown.ts +7 -3
  93. package/src/renderer/modal-factory.ts +25 -20
  94. package/src/renderer/model-workspace.ts +18 -3
  95. package/src/renderer/overlay-box.ts +21 -17
  96. package/src/renderer/process-indicator.ts +14 -3
  97. package/src/renderer/selection-modal-overlay.ts +6 -1
  98. package/src/renderer/session-picker-modal.ts +196 -3
  99. package/src/renderer/settings-modal-helpers.ts +2 -0
  100. package/src/renderer/settings-modal.ts +7 -0
  101. package/src/renderer/shell-surface.ts +8 -1
  102. package/src/renderer/status-glyphs.ts +14 -15
  103. package/src/renderer/system-message.ts +15 -3
  104. package/src/renderer/terminal-bg-probe.ts +339 -0
  105. package/src/renderer/terminal-escapes.ts +20 -0
  106. package/src/renderer/theme-mode-config.ts +67 -0
  107. package/src/renderer/theme.ts +91 -1
  108. package/src/renderer/thinking.ts +11 -3
  109. package/src/renderer/tool-call.ts +15 -9
  110. package/src/renderer/tool-result-summary.ts +148 -0
  111. package/src/renderer/turn-injection.ts +22 -3
  112. package/src/renderer/ui-factory.ts +154 -85
  113. package/src/renderer/ui-primitives.ts +30 -129
  114. package/src/runtime/bootstrap-command-context.ts +6 -0
  115. package/src/runtime/bootstrap-command-parts.ts +8 -4
  116. package/src/runtime/bootstrap-core.ts +46 -24
  117. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  118. package/src/runtime/bootstrap-shell.ts +19 -1
  119. package/src/runtime/bootstrap.ts +118 -5
  120. package/src/runtime/code-index-services.ts +25 -2
  121. package/src/runtime/legacy-daemon-migration.ts +516 -0
  122. package/src/runtime/memory-fold.ts +55 -0
  123. package/src/runtime/onboarding/derivation.ts +7 -2
  124. package/src/runtime/onboarding/snapshot.ts +27 -1
  125. package/src/runtime/onboarding/types.ts +31 -1
  126. package/src/runtime/operator-token-cleanup.ts +82 -1
  127. package/src/runtime/orchestrator-core-services.ts +10 -0
  128. package/src/runtime/resume-notice.ts +209 -0
  129. package/src/runtime/services.ts +12 -8
  130. package/src/runtime/session-inbound-inputs.ts +252 -0
  131. package/src/runtime/session-spine-transport.ts +64 -0
  132. package/src/runtime/terminal-output-guard.ts +15 -8
  133. package/src/runtime/ui-services.ts +19 -3
  134. package/src/runtime/workstream-services.ts +160 -28
  135. package/src/runtime/wrfc-persistence.ts +124 -17
  136. package/src/shell/blocking-input.ts +46 -3
  137. package/src/shell/recovery-input-helpers.ts +170 -1
  138. package/src/shell/ui-openers.ts +42 -9
  139. package/src/utils/terminal-width.ts +52 -0
  140. package/src/version.ts +1 -1
@@ -0,0 +1,86 @@
1
+ // ---------------------------------------------------------------------------
2
+ // panel-paste-flood-guard.ts — DEBT-5 item 5.
3
+ //
4
+ // A terminal WITHOUT bracketed paste delivers a pasted block as a burst of
5
+ // discrete 1-char 'text' tokens (isPasteToken stays false for every one of
6
+ // them, since that flag only fires for a single token whose value.length > 1
7
+ // — see handler-feed-routes.ts's own Invariant B doc). Before this guard,
8
+ // each such character became a real panel hotkey on a focused non-capturing
9
+ // panel (K arms kill, etc.) once it reached handlePanelFocusToken's per-char
10
+ // dispatch loop.
11
+ //
12
+ // This is a RATE guard: more than PANEL_PASTE_FLOOD_THRESHOLD qualifying
13
+ // tokens within the trailing PANEL_PASTE_FLOOD_WINDOW_MS, evaluated with a
14
+ // real sliding window (old timestamps age out of `timestamps` every call).
15
+ // It is deliberately NOT the retired per-feed char-SUM burst heuristic this
16
+ // same input layer already tore out once (see panel-focus-burst.test.ts's
17
+ // header doc): that old guard summed one feed()'s character count with no
18
+ // timing signal at all, so two ordinary nav keystrokes landing in a single
19
+ // feed() — a real, common case, not an edge case — were misread as a burst
20
+ // and focus was silently flipped to the composer. This guard:
21
+ // - never touches focus (panelFocused is untouched by trackPanelPasteFloodGuard
22
+ // and by its caller);
23
+ // - is keyed on WALL-CLOCK TIMING, not a per-feed token count, so it
24
+ // doesn't care how many tokens land in one feed() call, only how fast
25
+ // they arrive relative to each other;
26
+ // - is sticky once tripped (only a quiet gap — no qualifying token for a
27
+ // full window — clears it) so it doesn't flap dispatch on/off as the
28
+ // count oscillates near the threshold mid-flood.
29
+ //
30
+ // ~8 keys/120ms is far beyond sustained human typing (a fast typist peaks
31
+ // well under that inter-key rate over any real span) but is exactly the
32
+ // shape an unbracketed paste replay takes. It also keeps the pre-existing
33
+ // Invariant B regression tests (2 keys in one feed(), no timing control at
34
+ // all) far under threshold while catching a real flood within its first ~9
35
+ // characters.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export const PANEL_PASTE_FLOOD_WINDOW_MS = 120;
39
+ export const PANEL_PASTE_FLOOD_THRESHOLD = 8;
40
+
41
+ /**
42
+ * Guard state — a single persistent instance lives on the caller's
43
+ * long-lived context (handler-feed.ts's InputFeedContext, mirroring how that
44
+ * object already owns `nextPasteId`/`mouseDownRow`/etc.) and is MUTATED IN
45
+ * PLACE by trackPanelPasteFloodGuard below, never replaced — so callers never
46
+ * need to thread a return value back into their own state (unlike
47
+ * `panelFocused`, which handlePanelFocusToken returns because it is NOT a
48
+ * mutable outparam).
49
+ */
50
+ export interface PanelBurstGuardState {
51
+ timestamps: readonly number[];
52
+ suspended: boolean;
53
+ hintShown: boolean;
54
+ }
55
+
56
+ export interface PanelBurstGuardResult {
57
+ /** False while suspended — the caller must drop this token, not dispatch it. */
58
+ readonly dispatch: boolean;
59
+ /** True exactly once per burst: the call that just tripped suspension. */
60
+ readonly showHintNow: boolean;
61
+ }
62
+
63
+ /** Advance `guard` (mutated in place) by one qualifying token at time `now` (ms). */
64
+ export function trackPanelPasteFloodGuard(guard: PanelBurstGuardState, now: number): PanelBurstGuardResult {
65
+ const lastAt = guard.timestamps.length > 0 ? guard.timestamps[guard.timestamps.length - 1]! : -Infinity;
66
+ const isQuietGap = now - lastAt > PANEL_PASTE_FLOOD_WINDOW_MS;
67
+ if (isQuietGap && guard.suspended) {
68
+ // A silence at least as long as the window means whatever burst was
69
+ // happening has ended — un-suspend so a LATER burst gets its own fresh
70
+ // count and its own one-shot hint.
71
+ guard.suspended = false;
72
+ guard.hintShown = false;
73
+ }
74
+ guard.timestamps = isQuietGap
75
+ ? [now]
76
+ : [...guard.timestamps.filter((t) => t > now - PANEL_PASTE_FLOOD_WINDOW_MS), now];
77
+ if (guard.timestamps.length > PANEL_PASTE_FLOOD_THRESHOLD) {
78
+ guard.suspended = true;
79
+ }
80
+ let showHintNow = false;
81
+ if (guard.suspended && !guard.hintShown) {
82
+ guard.hintShown = true;
83
+ showHintNow = true;
84
+ }
85
+ return { dispatch: !guard.suspended, showHintNow };
86
+ }
@@ -36,6 +36,14 @@ export class SelectionModal {
36
36
  public selectedIndex = 0;
37
37
  public allowSearch = true;
38
38
  public customActions: Map<string, SelectionAction> = new Map();
39
+ /**
40
+ * UX-C vocab unification: overrides the default per-primaryAction Enter
41
+ * verb ("Select"/"Toggle"/"Edit"/"Delete") shown in the footer hint. Used by
42
+ * callers whose items are really commands (e.g. /help) so the hint reads
43
+ * "[Enter] Run", matching the slash-command palette's own verb, instead of
44
+ * the generic "Select".
45
+ */
46
+ public primaryVerbLabel: string | undefined = undefined;
39
47
 
40
48
  /** Open the modal with items and title */
41
49
  open(
@@ -45,6 +53,7 @@ export class SelectionModal {
45
53
  preSelectId?: string;
46
54
  allowSearch?: boolean;
47
55
  customActions?: Map<string, SelectionAction>;
56
+ primaryVerbLabel?: string;
48
57
  }
49
58
  ): void {
50
59
  this.title = title;
@@ -53,6 +62,7 @@ export class SelectionModal {
53
62
  this.allowSearch = opts?.allowSearch ?? true;
54
63
  this.searchFocused = false;
55
64
  this.customActions = opts?.customActions ?? new Map();
65
+ this.primaryVerbLabel = opts?.primaryVerbLabel;
56
66
  this.active = true;
57
67
  this.filterItems();
58
68
 
@@ -74,6 +84,7 @@ export class SelectionModal {
74
84
  this.filteredItems = [];
75
85
  this.selectedIndex = 0;
76
86
  this.customActions = new Map();
87
+ this.primaryVerbLabel = undefined;
77
88
  }
78
89
 
79
90
  moveUp(): void {
@@ -1,12 +1,28 @@
1
1
  /**
2
2
  * SessionPickerModal — state management for the /sessions picker modal.
3
3
  *
4
- * Lists sessions from SessionManager.list(), tracks selected index,
5
- * and handles load/delete actions.
4
+ * Lists LOCAL saved transcript files from SessionManager.list() (load/delete
5
+ * operate on these — a saved JSONL session is a different thing from a live
6
+ * control-plane session, see below), tracks selected index, and handles
7
+ * load/delete actions.
8
+ *
9
+ * W3-T2 (union-sessions surface): additionally surfaces the cross-surface
10
+ * session union from `sessionBroker` (a SessionReadFacade — normally
11
+ * `uiServices.sessions.sessionBroker`, the SessionUnionCache) so a user can
12
+ * SEE what sessions are live/closed across every surface sharing this
13
+ * daemon (TUI, webui, companion, automation), not just this process's own
14
+ * saved files. This is deliberately READ-ONLY: a SharedSessionRecord has no
15
+ * on-disk transcript this process can load()/delete() the way a local
16
+ * SessionInfo does, so cross-surface rows are visible + badged, not
17
+ * selectable for load/delete. `sessionBroker` is optional so every existing
18
+ * caller/test that only cares about local saved-session management keeps
19
+ * working unchanged.
6
20
  */
7
21
 
8
22
  import { unlinkSync } from 'node:fs';
9
23
  import type { SessionInfo, SessionManager } from '@pellux/goodvibes-sdk/platform/sessions';
24
+ import type { SharedSessionRecord } from '@pellux/goodvibes-sdk/platform/control-plane';
25
+ import type { CrossSurfaceView, SessionReadFacade } from '@pellux/goodvibes-sdk/platform/runtime/session-spine';
10
26
  import type { ConversationManager } from '../core/conversation';
11
27
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
12
28
 
@@ -14,9 +30,22 @@ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
14
30
  // SessionPickerModal
15
31
  // ---------------------------------------------------------------------------
16
32
 
33
+ /** Honest default posture when no `sessionBroker` was wired: no cross-surface claim. */
34
+ const DORMANT_CROSS_SURFACE_VIEW: CrossSurfaceView = {
35
+ mode: 'local',
36
+ online: false,
37
+ stale: false,
38
+ lastSyncAt: null,
39
+ offlineNote: null,
40
+ };
41
+
17
42
  export class SessionPickerModal {
18
43
  public active = false;
19
44
  public sessions: SessionInfo[] = [];
45
+ /** Cross-surface session union (view-only) — see class doc. Empty until open(). */
46
+ public crossSurfaceSessions: readonly SharedSessionRecord[] = [];
47
+ /** Honest posture for crossSurfaceSessions: mode/online/stale/offlineNote. */
48
+ public crossSurfaceView: CrossSurfaceView = DORMANT_CROSS_SURFACE_VIEW;
20
49
  public selectedIndex = 0;
21
50
  public scrollOffset = 0;
22
51
  public visibleRows = 8;
@@ -25,13 +54,24 @@ export class SessionPickerModal {
25
54
  /** Last status message to show in the modal (e.g. error or success). */
26
55
  public statusMessage = '';
27
56
 
28
- public constructor(private readonly sessionManager: SessionManager) {}
57
+ public constructor(
58
+ private readonly sessionManager: SessionManager,
59
+ private readonly sessionBroker?: SessionReadFacade,
60
+ ) {}
29
61
 
30
62
  /**
31
- * Open the modal, loading sessions from SessionManager.
63
+ * Open the modal, loading local saved sessions from SessionManager and (when
64
+ * a sessionBroker was wired) the cross-surface session union, honestly.
32
65
  */
33
66
  open(): void {
34
67
  this.sessions = this.sessionManager.list();
68
+ if (this.sessionBroker) {
69
+ this.crossSurfaceSessions = this.sessionBroker.listSessions();
70
+ this.crossSurfaceView = this.sessionBroker.crossSurfaceView;
71
+ } else {
72
+ this.crossSurfaceSessions = [];
73
+ this.crossSurfaceView = DORMANT_CROSS_SURFACE_VIEW;
74
+ }
35
75
  this.selectedIndex = 0;
36
76
  this.scrollOffset = 0;
37
77
  this.statusMessage = '';
@@ -15,6 +15,13 @@ import { buildSubscriptionEntries } from './settings-modal-subscriptions.ts';
15
15
  import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config';
16
16
  import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
17
17
  import { CODE_INDEX_ENABLED_CONFIG_KEY } from '../runtime/code-index-services.ts';
18
+ import {
19
+ THEME_MODE_CONFIG_KEY,
20
+ THEME_MODE_VALUES,
21
+ THEME_MODE_DEFAULT,
22
+ THEME_MODE_DESCRIPTION,
23
+ coerceThemeModeSetting,
24
+ } from '../renderer/theme-mode-config.ts';
18
25
  import {
19
26
  SETTINGS_CATEGORIES,
20
27
  type FlagEntry,
@@ -94,6 +101,14 @@ export function buildSettingGroups(
94
101
  }
95
102
  }
96
103
 
104
+ // DEBT-2: inject the synthetic display.themeMode enum (auto|dark|light). TUI-local
105
+ // key stored under the existing `display` section (see theme-mode-config.ts for why
106
+ // not `appearance`), same rationale as the other synthetic settings below.
107
+ const displayEntries = groups.get('display');
108
+ if (displayEntries && !displayEntries.some((e) => e.setting.key === (THEME_MODE_CONFIG_KEY as ConfigKey))) {
109
+ displayEntries.push(buildThemeModeSyntheticEntry(configManager));
110
+ }
111
+
97
112
  const uiEntries = groups.get('ui');
98
113
  if (uiEntries) {
99
114
  const uiPriority: Record<string, number> = {
@@ -199,6 +214,34 @@ export function buildTtsSpeedSyntheticEntry(configManager: Pick<ConfigManager, '
199
214
  };
200
215
  }
201
216
 
217
+ // ---------------------------------------------------------------------------
218
+ // display.themeMode synthetic setting (DEBT-2 light theme)
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /**
222
+ * The synthetic ConfigSetting descriptor for display.themeMode. TUI-local (not
223
+ * in the SDK ConfigKey union); the key is cast to ConfigKey as the other
224
+ * synthetic settings do. Stored under the existing `display` section so
225
+ * ConfigManager.setDynamic/get round-trip it with no SDK change.
226
+ */
227
+ export const THEME_MODE_SYNTHETIC_SETTING: ConfigSetting = {
228
+ key: THEME_MODE_CONFIG_KEY as ConfigKey,
229
+ type: 'enum',
230
+ default: THEME_MODE_DEFAULT,
231
+ enumValues: [...THEME_MODE_VALUES],
232
+ description: THEME_MODE_DESCRIPTION,
233
+ };
234
+
235
+ /** Build the synthetic SettingEntry for display.themeMode. */
236
+ export function buildThemeModeSyntheticEntry(configManager: Pick<ConfigManager, 'get'>): SettingEntry {
237
+ const currentValue = coerceThemeModeSetting(configManager.get(THEME_MODE_CONFIG_KEY as ConfigKey));
238
+ return {
239
+ setting: THEME_MODE_SYNTHETIC_SETTING,
240
+ currentValue,
241
+ isDefault: currentValue === THEME_MODE_DEFAULT,
242
+ };
243
+ }
244
+
202
245
  // ---------------------------------------------------------------------------
203
246
  // behavior.notifyAfterSeconds synthetic setting
204
247
  // ---------------------------------------------------------------------------
@@ -401,6 +444,8 @@ export function refreshEntryValues(
401
444
  // construction time so isDefault stays accurate.
402
445
  if (entry.setting.key === ('tts.speed' as ConfigKey)) {
403
446
  entry.currentValue = normalizeTtsSpeedValue(raw);
447
+ } else if (entry.setting.key === (THEME_MODE_CONFIG_KEY as ConfigKey)) {
448
+ entry.currentValue = coerceThemeModeSetting(raw);
404
449
  } else {
405
450
  entry.currentValue = raw;
406
451
  }
@@ -422,8 +467,12 @@ export function updateEntryForKey(
422
467
  const entry = entries.find((candidate) => candidate.setting.key === key);
423
468
  if (entry) {
424
469
  const raw = configManager.get(key);
425
- // Synthetic tts.speed entry: normalize using the same fallback logic.
426
- entry.currentValue = key === ('tts.speed' as ConfigKey) ? normalizeTtsSpeedValue(raw) : raw;
470
+ // Synthetic entries: normalize using the same fallback logic as construction.
471
+ entry.currentValue = key === ('tts.speed' as ConfigKey)
472
+ ? normalizeTtsSpeedValue(raw)
473
+ : key === (THEME_MODE_CONFIG_KEY as ConfigKey)
474
+ ? coerceThemeModeSetting(raw)
475
+ : raw;
427
476
  entry.isDefault = deepEqual(entry.currentValue, entry.setting.default);
428
477
  }
429
478
  }
@@ -11,11 +11,13 @@ export type SettingsCategory =
11
11
  | 'storage'
12
12
  | 'permissions'
13
13
  | 'orchestration'
14
+ | 'planner'
14
15
  | 'wrfc'
15
16
  | 'tools'
16
17
  | 'helper'
17
18
  | 'tts'
18
19
  | 'service'
20
+ | 'daemon'
19
21
  | 'controlPlane'
20
22
  | 'httpListener'
21
23
  | 'web'
@@ -42,9 +44,9 @@ export const SETTINGS_CATEGORY_GROUPS: ReadonlyArray<{
42
44
  }> = [
43
45
  { label: 'Interface', categories: ['display', 'ui', 'behavior', 'permissions'] },
44
46
  { label: 'AI Routing', categories: ['provider', 'subscriptions', 'helper', 'tools', 'tts'] },
45
- { label: 'Service & Network', categories: ['service', 'network', 'controlPlane', 'httpListener', 'web'] },
47
+ { label: 'Service & Network', categories: ['service', 'daemon', 'network', 'controlPlane', 'httpListener', 'web'] },
46
48
  { label: 'Surfaces & Cloud', categories: ['surfaces', 'mcp', 'cloudflare'] },
47
- { label: 'Automation', categories: ['batch', 'automation', 'watchers', 'orchestration', 'wrfc'] },
49
+ { label: 'Automation', categories: ['batch', 'automation', 'watchers', 'orchestration', 'planner', 'wrfc'] },
48
50
  { label: 'Runtime & Data', categories: ['storage', 'sandbox', 'runtime', 'cache', 'telemetry'] },
49
51
  { label: 'Advanced', categories: ['flags', 'release', 'danger'] },
50
52
  ];
package/src/main.ts CHANGED
@@ -34,15 +34,9 @@ import { bootstrapRuntime } from './runtime/bootstrap.ts';
34
34
  import type { BootstrapContext } from './runtime/bootstrap.ts';
35
35
  import { buildSharedOrchestratorCoreServices } from './runtime/orchestrator-core-services.ts';
36
36
  import type { HITLMode } from '@pellux/goodvibes-sdk/platform/state';
37
- import {
38
- checkRecoveryFile,
39
- deleteRecoveryFile,
40
- loadRecoveryConversation,
41
- readLastSessionPointer,
42
- writeRecoveryFile,
43
- } from '@/runtime/index.ts';
37
+ import { readLastSessionPointer, writeRecoveryFile } from '@/runtime/index.ts';
44
38
  import { handleBlockingShellInput, type PendingPermissionState } from './shell/blocking-input.ts';
45
- import { createPersistRecoverySnapshot, createReopenRecoveryPanels, handleErrorAffordanceKey } from './shell/recovery-input-helpers.ts';
39
+ import { createPersistRecoverySnapshot, createRecoveryFileOps, createReopenRecoveryPanels, handleErrorAffordanceKey, resolveStartupRecoveryInfo } from './shell/recovery-input-helpers.ts';
46
40
  import { wireShellUiOpeners } from './shell/ui-openers.ts';
47
41
  import { deriveComposerState } from './core/composer-state.ts';
48
42
  import { buildPersistedSessionContext, formatReturnContextForDisplay, getReturnContextMode, maybeAssistReturnContextSummary } from '@/runtime/index.ts';
@@ -58,6 +52,7 @@ import { installProcessLifecycle } from './runtime/process-lifecycle.ts';
58
52
  import { createRenderScheduler } from './runtime/render-scheduler.ts';
59
53
  import { buildCommandArgsHint } from './input/command-args-hint.ts';
60
54
  import { summarizeRunningAgents } from './renderer/process-summary.ts';
55
+ import { footerFleetCost } from './panels/fleet-read-model.ts';
61
56
  import { formatUserFacingErrorLine } from './core/format-user-error.ts';
62
57
  import { wireStreamEventMetrics, type StreamMetrics, type WireStreamEventMetricsResult } from './core/stream-event-wiring.ts';
63
58
  import { wireTurnEventHandlers } from './core/turn-event-wiring.ts';
@@ -67,12 +62,8 @@ import { createCancelGeneration } from './core/turn-cancellation.ts';
67
62
  import { wrapRequestPermissionWithAlert } from './core/approval-alert.ts';
68
63
  import { setPanelFrameRequester } from './panels/base-panel.ts';
69
64
 
70
- const ALT_SCREEN_ENTER = '\x1b[?1049h'; const ALT_SCREEN_EXIT = '\x1b[?1049l';
71
- const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1002h\x1b[?1006h'; const MOUSE_DISABLE = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
72
- const CURSOR_HIDE = '\x1b[?25l'; const CURSOR_SHOW = '\x1b[?25h'; const CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H';
73
- const KEYBOARD_EXT_ENABLE = '\x1b[>4;2m' + '\x1b[?1u'; const KEYBOARD_EXT_DISABLE = '\x1b[>4;0m' + '\x1b[?1l';
74
- const PASTE_ENABLE = '\x1b[?2004h'; const PASTE_DISABLE = '\x1b[?2004l';
75
- const FOCUS_ENABLE = '\x1b[?1004h'; const FOCUS_DISABLE = '\x1b[?1004l';
65
+ import { ALT_SCREEN_ENTER, ALT_SCREEN_EXIT, MOUSE_ENABLE, MOUSE_DISABLE, CURSOR_HIDE, CURSOR_SHOW, CLEAR_SCREEN, KEYBOARD_EXT_ENABLE, KEYBOARD_EXT_DISABLE, PASTE_ENABLE, PASTE_DISABLE, FOCUS_ENABLE, FOCUS_DISABLE } from './renderer/terminal-escapes.ts';
66
+ import { installBackgroundThemeProbe } from './renderer/terminal-bg-probe.ts';
76
67
 
77
68
  async function main() {
78
69
  const stdout = process.stdout;
@@ -213,6 +204,8 @@ async function main() {
213
204
  let recoveryInterval: ReturnType<typeof setInterval> | null = null;
214
205
  let stopSpokenOutputForExit: (() => void) | null = null;
215
206
  let recoveryPending = false;
207
+ // Which file the current recovery prompt should load/delete from (see recovery-input-helpers.ts).
208
+ let recoverySource: 'live' | 'preserved' = 'live';
216
209
 
217
210
  const lifecycle = installProcessLifecycle({
218
211
  stdin,
@@ -392,7 +385,7 @@ async function main() {
392
385
  },
393
386
  sessions: {
394
387
  sessionManager: ctx.services.sessionManager,
395
- sessionBroker: ctx.services.sessionBroker,
388
+ sessionBroker: uiServices.sessions.sessionBroker,
396
389
  sessionOrchestration: ctx.services.sessionOrchestration,
397
390
  sessionMemoryStore: ctx.services.sessionMemoryStore,
398
391
  },
@@ -477,7 +470,7 @@ async function main() {
477
470
  .slice(0, promptInfo.visibleCursorLine)
478
471
  .reduce((sum: number, line: string) => sum + line.length + 1, 0) + promptInfo.visibleCursorCol
479
472
  : undefined,
480
- usage: { up: orchestrator.usage.input, down: orchestrator.usage.output },
473
+ usage: { up: orchestrator.usage.input, down: orchestrator.usage.output, fleetCostUsd: footerFleetCost(() => ctx.services.processRegistry.query().nodes, runningAgentCount > 0) },
481
474
  showExitNotice: input.showExitNotice,
482
475
  lastCopyTime: input.lastCopyTime,
483
476
  model: runtime.model,
@@ -504,8 +497,8 @@ async function main() {
504
497
  lastInputTokens: orchestrator.lastInputTokens,
505
498
  commandArgsHint,
506
499
  hitlMode: modeManager.getHITLMode(),
507
- runningAgentCount,
508
- runningProcessCount,
500
+ // S3d: cross-surface spine posture segment (adopted-daemon mode only).
501
+ sessionSpineStatus: (() => { const s = uiServices.platform.externalServices?.inspect(); return s?.sessionSpineActive && s.sessionSpineStatus && s.sessionSpineStatus !== 'unknown' ? s.sessionSpineStatus : undefined; })(), runningAgentCount, runningProcessCount,
509
502
  // Composer must not read as focused while the panel/process indicator owns keyboard focus.
510
503
  promptFocused: !input.panelFocused && !input.indicatorFocused,
511
504
  indicatorFocused: input.indicatorFocused,
@@ -552,7 +545,7 @@ async function main() {
552
545
  // Calculate how many rows are consumed by overlays (thinking, permissions, queue, file picker)
553
546
  let overlayRows = 0;
554
547
  if (orchestrator.isThinking) overlayRows += 2; // spinner + blank
555
- if (pendingPermission) overlayRows += PermissionPromptUI.getPromptHeight(pendingPermission, pendingPermission.hunkState);
548
+ if (pendingPermission) overlayRows += PermissionPromptUI.getPromptHeight(pendingPermission, pendingPermission.hunkState, pendingPermission.detailsExpanded);
556
549
  overlayRows += orchestrator.messageQueue.length * 3; // queued messages
557
550
  // File picker and model picker overlay rows computed from actual rendered line count below
558
551
  // Selection modal overlay rows are computed from actual rendered line count below
@@ -591,6 +584,7 @@ async function main() {
591
584
  turnElapsedMs,
592
585
  streamMetrics.ttftMs,
593
586
  stallInfo,
587
+ pendingPermission !== null,
594
588
  );
595
589
  viewport.push(...thinking);
596
590
  // Live tool timer: render the currently executing tool row with ticking elapsed.
@@ -601,7 +595,7 @@ async function main() {
601
595
  }
602
596
 
603
597
  if (pendingPermission) {
604
- viewport.push(...PermissionPromptUI.createPromptLines(conversationWidth, pendingPermission, pendingPermission.hunkState));
598
+ viewport.push(...PermissionPromptUI.createPromptLines(conversationWidth, pendingPermission, pendingPermission.hunkState, pendingPermission.detailsExpanded));
605
599
  }
606
600
 
607
601
  orchestrator.messageQueue.forEach(msg => {
@@ -648,8 +642,8 @@ async function main() {
648
642
  });
649
643
  };
650
644
  const renderScheduler = createRenderScheduler(renderNow); // WO-208 same-tick coalescer
651
- const render = (): void => renderScheduler.schedule();
652
- const terminalOutputGuard = installTuiTerminalOutputGuard({ stdout, stderr: process.stderr, notify: (message) => { systemMessageRouter.low(message); render(); } });
645
+ const render = (): void => renderScheduler.schedule(); // captured direct writes → activity log + quiet /debug counter, not repeated transcript lines (UX-B 1a)
646
+ const terminalOutputGuard = installTuiTerminalOutputGuard({ stdout, stderr: process.stderr, onCapture: (total) => { commandContext.session.runtime.terminalWritesIntercepted = total; render(); } });
653
647
 
654
648
  setRenderRequest(renderNow); // bootstrap's 16ms coalescer calls the composite directly
655
649
  setPanelFrameRequester(render); // live panels repaint when idle (Wave-6 replay: fleet sat stale until keypress)
@@ -711,6 +705,7 @@ async function main() {
711
705
  events: uiServices.events, orchestrator, providerRegistry,
712
706
  systemMessageRouter, render, metrics: streamMetrics,
713
707
  providerOptimizer: ctx.services.providerOptimizer, costLookup: providerRegistry, retryTurn,
708
+ isApprovalPending: () => pendingPermission !== null,
714
709
  });
715
710
  unsubs.push(...streamResult.unsubs);
716
711
  // Activate one-key retry affordance when a user-visible error surfaces.
@@ -734,15 +729,18 @@ async function main() {
734
729
  stdin.resume();
735
730
  stdin.setEncoding('utf8');
736
731
  allowTerminalWrite(() => stdout.write((cli.flags.noAltScreen ? '' : ALT_SCREEN_ENTER) + CLEAR_SCREEN + CURSOR_HIDE + MOUSE_ENABLE + KEYBOARD_EXT_ENABLE + PASTE_ENABLE + FOCUS_ENABLE));
732
+ // DEBT-2: forced dark/light applies before first paint; auto (TTY only) fires the
733
+ // OSC 11 probe and repaints once if light wins. filterInput strips the reply from stdin.
734
+ const themeProbe = installBackgroundThemeProbe({ configManager, isTTY: Boolean(stdout.isTTY), env: process.env, writeQuery: (b) => allowTerminalWrite(() => stdout.write(b)), requestRepaint: () => { compositor.resetDiff(); render(); } });
737
735
 
738
736
  applyInitialTuiCliState({ cli, input, commandRegistry, commandContext, shellPaths: ctx.services.shellPaths, render });
739
737
 
740
- stdin.on('data', (data: string) => {
738
+ stdin.on('data', (raw: string) => {
739
+ const data = themeProbe.filterInput(raw); if (data.length === 0) return;
741
740
  const blocking = handleBlockingShellInput({
742
741
  data, pendingPermission, recoveryPending, conversation, systemMessageRouter, render,
743
742
  abortTurn: () => orchestrator.abort(),
744
- loadRecoveryConversation: () => loadRecoveryConversation({ homeDirectory }),
745
- deleteRecoveryFile: () => deleteRecoveryFile({ homeDirectory }),
743
+ ...createRecoveryFileOps(() => recoverySource, { homeDirectory }),
746
744
  homeDirectory, sessionId: runtime.sessionId,
747
745
  persistSnapshot: createPersistRecoverySnapshot({ sessionManager: ctx.services.sessionManager, runtime, conversation }),
748
746
  reopenPanels: createReopenRecoveryPanels({ panelManager, render }),
@@ -769,9 +767,10 @@ async function main() {
769
767
  conversation.rebuildHistory();
770
768
  render();
771
769
 
772
- // --- Crash recovery check ---
773
- const recoveryInfo = checkRecoveryFile({ workingDirectory: workingDir, homeDirectory });
770
+ // --- Crash recovery check (also checks the preserve-on-dismiss sibling; see recovery-input-helpers.ts) ---
771
+ const recoveryInfo = resolveStartupRecoveryInfo({ workingDirectory: workingDir, homeDirectory });
774
772
  if (recoveryInfo) {
773
+ recoverySource = recoveryInfo.source;
775
774
  systemMessageRouter.high(`[Recovery] Found unsaved session from ${new Date(recoveryInfo.timestamp).toLocaleString()}. Title: "${recoveryInfo.title}". Press Ctrl+R to restore, Esc to discard, or start typing to ignore it.`);
776
775
  for (const line of formatReturnContextForDisplay(recoveryInfo.returnContext)) {
777
776
  systemMessageRouter.low(`[Recovery] ${line}`);
@@ -782,6 +781,8 @@ async function main() {
782
781
 
783
782
  // --- Auto-save to recovery file every 60s ---
784
783
  recoveryInterval = setInterval(() => {
784
+ // Skip while an earlier recovery prompt is unresolved (avoids racing preserve-on-dismiss).
785
+ if (recoveryPending) return;
785
786
  const snapshot = conversation.toJSON() as { messages: Array<import('./core/conversation.ts').ConversationMessageSnapshot> };
786
787
  const persisted = buildPersistedSessionContext(snapshot.messages, conversation.getTitleSource(), buildSessionContinuityHints());
787
788
  writeRecoveryFile(
@@ -4,7 +4,8 @@ import type { ToolRegistry } from '@pellux/goodvibes-sdk/platform/tools';
4
4
  import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
5
5
  import type { Orchestrator } from '../../core/orchestrator';
6
6
  import type { MemoryRegistry } from '@pellux/goodvibes-sdk/platform/state';
7
- import type { ApprovalBroker, SharedSessionBroker } from '@pellux/goodvibes-sdk/platform/control-plane';
7
+ import type { ApprovalBroker } from '@pellux/goodvibes-sdk/platform/control-plane';
8
+ import type { SessionReadFacade } from '@pellux/goodvibes-sdk/platform/runtime/session-spine';
8
9
  import type { AutomationManager } from '@pellux/goodvibes-sdk/platform/automation';
9
10
  import type { ControlPlaneRecentEvent } from '@pellux/goodvibes-sdk/platform/control-plane';
10
11
  import type { UiRuntimeServices } from '../../runtime/ui-services.ts';
@@ -84,8 +85,13 @@ export interface BuiltinPanelDeps {
84
85
  policyRuntimeState?: import('@/runtime/index.ts').PolicyRuntimeState;
85
86
  /** Approval broker for control-plane/operator panels. */
86
87
  approvalBroker?: ApprovalBroker;
87
- /** Shared session broker for control-plane/operator panels. */
88
- sessionBroker?: SharedSessionBroker;
88
+ /**
89
+ * S3d: cross-surface session READ facade for control-plane/operator panels.
90
+ * Sync listSessions()/getSession() shape preserved; in adopted-daemon mode it
91
+ * serves the daemon-hosted union (with an honest offline note when the wire is
92
+ * down) instead of only this process's local broker. See session-union-cache.ts.
93
+ */
94
+ sessionBroker?: SessionReadFacade;
89
95
  /** Automation manager for schedule/operator panels. */
90
96
  automationManager?: AutomationManager;
91
97
  /** Recent control-plane events provider for control-plane/operator panels. */
@@ -0,0 +1,31 @@
1
+ // ---------------------------------------------------------------------------
2
+ // fleet-deep-link.ts — DEBT-5 item 4.
3
+ //
4
+ // PanelManager.open('fleet', pane, target) hands FleetPanel a
5
+ // PanelDeepLinkTarget (a process id + optional ProcessKind) so a cross-panel
6
+ // jump (the work-plan modal's 'i'/'w' agent/WRFC-chain jumps, the docs
7
+ // modal's tools-tab Enter) can select + reveal a SPECIFIC node instead of
8
+ // just opening Fleet generically. Extracted from fleet-panel.ts (already at
9
+ // the 800-line architecture cap) so the panel stays a thin caller.
10
+ //
11
+ // `kind`, when given, must also match: ProcessNode ids are unique per the
12
+ // SDK's own contract, so this is a cheap extra honesty check, not the
13
+ // primary lookup key. Reveal is "free" once selectedIndex is set —
14
+ // ScrollableListPanel's render-time scroll-window resolution keeps the
15
+ // selected row visible, so FleetPanel.receiveDeepLink does not need its own
16
+ // scroll math.
17
+ //
18
+ // A target that isn't found (pruned, killed, never existed, or a domain —
19
+ // like the docs Tools tab's static tool definitions — that has no
20
+ // corresponding fleet node at all) resolves to -1; the caller degrades
21
+ // honestly (FleetPanel.setError('node no longer present.')) rather than
22
+ // silently landing on an unrelated row.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ import type { PanelDeepLinkTarget } from './types.ts';
26
+ import type { FleetTreeRow } from './fleet-read-model.ts';
27
+
28
+ /** The row index matching `target`'s id (+ kind, if given), or -1. Pure — no I/O, no mutation. */
29
+ export function resolveFleetDeepLinkIndex(rows: readonly FleetTreeRow[], target: PanelDeepLinkTarget): number {
30
+ return rows.findIndex((row) => row.node.id === target.id && (!target.kind || row.node.kind === target.kind));
31
+ }
@@ -0,0 +1,62 @@
1
+ // ---------------------------------------------------------------------------
2
+ // fleet-panel-format.ts
3
+ //
4
+ // Pure column-layout + cell-formatting helpers for FleetPanel's tree rows.
5
+ // Extracted from fleet-panel.ts (S3b hygiene) to hold that file under the
6
+ // 800-line architecture cap; these are stateless functions the panel's
7
+ // renderItem/renderDetail call, with no `this` dependency.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ import type { ProcessCostState, ProcessUsage } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
11
+ import { formatAgentCost } from './agent-inspector-shared.ts';
12
+ import { DEFAULT_PANEL_PALETTE, type ColumnSpec, type PanelPalette } from './polish.ts';
13
+ import { fleetUsageTokens, hasFleetCost, type FleetStateTone } from './fleet-read-model.ts';
14
+
15
+ // Column widths for the tree row layout. `label` absorbs whatever width is
16
+ // left over after the fixed columns + gaps; on hostile (narrow) widths the
17
+ // trailing columns simply clip (buildAlignedRow/buildSelectablePanelLine stop
18
+ // writing once a cell would overflow the row) rather than throwing or
19
+ // wrapping — the tree stays readable, just denser.
20
+ const KIND_W = 8;
21
+ const ELAPSED_W = 7;
22
+ const TOKENS_W = 7;
23
+ const COST_W = 8;
24
+ const ACTIVITY_W = 20;
25
+ const GAP = 1;
26
+ const FIXED_W = 1 /* glyph */ + KIND_W + ELAPSED_W + TOKENS_W + COST_W + ACTIVITY_W + GAP * 6;
27
+
28
+ export function planColumns(width: number): ColumnSpec[] {
29
+ const labelWidth = Math.max(10, width - FIXED_W);
30
+ return [
31
+ { width: 1 },
32
+ { width: KIND_W },
33
+ { width: labelWidth },
34
+ { width: ELAPSED_W, align: 'right' },
35
+ { width: TOKENS_W, align: 'right' },
36
+ { width: COST_W, align: 'right' },
37
+ { width: ACTIVITY_W },
38
+ ];
39
+ }
40
+
41
+ export function toneColor(tone: FleetStateTone, palette: PanelPalette): string {
42
+ switch (tone) {
43
+ case 'active': return palette.info ?? DEFAULT_PANEL_PALETTE.info;
44
+ case 'success': return palette.good ?? DEFAULT_PANEL_PALETTE.good;
45
+ case 'failure': return palette.bad ?? DEFAULT_PANEL_PALETTE.bad;
46
+ case 'warn': return palette.warn ?? DEFAULT_PANEL_PALETTE.warn;
47
+ case 'muted': return palette.dim;
48
+ }
49
+ }
50
+
51
+ export function formatFleetTokens(usage: ProcessUsage | undefined): string {
52
+ const total = fleetUsageTokens(usage);
53
+ if (total === null) return 'n/a';
54
+ return total >= 1000 ? `${(total / 1000).toFixed(1)}k` : String(total);
55
+ }
56
+
57
+ /** Honest cost display: never a fabricated $0.00 — 'unpriced' when costState says so. */
58
+ export function formatFleetCost(costUsd: number | null | undefined, costState: ProcessCostState): string {
59
+ if (!hasFleetCost(costUsd, costState)) return 'unpriced';
60
+ const formatted = formatAgentCost(costUsd as number);
61
+ return costState === 'estimated' ? `~${formatted}` : formatted;
62
+ }
@@ -0,0 +1,48 @@
1
+ // ---------------------------------------------------------------------------
2
+ // fleet-panel-worktree-detail.ts
3
+ //
4
+ // NET-NEW helper (not moved code — it was authored here because fleet-panel.ts
5
+ // sits at the architecture check's 800-line cap, scripts/check-architecture.ts):
6
+ // a single pure helper for the Fleet panel's per-work-item detail view, so a
7
+ // new panel feature doesn't have to fight that cap inline.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ import type { WorkItem } from '@pellux/goodvibes-sdk/platform/orchestration';
11
+
12
+ /**
13
+ * Per-item worktree-isolation detail text for the Fleet panel's expanded
14
+ * node detail (fleet-panel.ts renderDetail), or null for a shared-isolation
15
+ * item (or any node that isn't a work item). Inferred from the item's OWN
16
+ * fields (worktreePath/worktreeBranch/mergeState) rather than a
17
+ * workstream-level flag — adaptWorkItem's ProcessNode.raw carries
18
+ * `{ item, workstreamId }`, not the full Workstream, and these fields are
19
+ * set ONLY when the owning workstream's isolation is 'worktree'
20
+ * (engine.ts/worktree-isolation.ts, SDK), so they are a reliable
21
+ * self-contained signal with no fleet-adapter change needed.
22
+ */
23
+ export function formatWorkItemIsolationDetail(item: WorkItem): string | null {
24
+ const isolated = item.worktreePath !== undefined || item.worktreeBranch !== undefined || item.mergeState !== undefined;
25
+ if (!isolated) return null;
26
+ switch (item.mergeState) {
27
+ case 'merged':
28
+ // Papercut sweep item 3: worktree-isolation items whose integration had
29
+ // no changes to merge (mergeState 'merged', mergeHash undefined — a
30
+ // documented cosmetic case, not an error) used to read as a bare/awkward
31
+ // "merged (nothing to merge)". "merged (no changes)" says the same thing
32
+ // in the same shape as the other merge-state phrases below.
33
+ return item.mergeHash ? `worktree — merged ${item.mergeHash.slice(0, 12)}` : 'worktree — merged (no changes)';
34
+ case 'conflict':
35
+ return 'worktree — merge-conflict (kept for inspection)';
36
+ case 'pending':
37
+ return 'worktree — merge pending';
38
+ case 'n-a':
39
+ default:
40
+ return item.worktreeKept ? 'worktree — kept' : 'worktree — not yet integrated';
41
+ }
42
+ }
43
+
44
+ /** `node.raw` for a 'work-item' ProcessNode is `{ item, workstreamId }` (unknown to the panel's own types) — this narrows without fleet-panel.ts needing its own WorkItem import at the call site. Non-work-item nodes (raw is undefined/some other shape) fall through to null. */
45
+ export function formatWorkItemIsolationDetailFromRaw(raw: unknown): string | null {
46
+ const item = (raw as { item?: WorkItem } | undefined)?.item;
47
+ return item ? formatWorkItemIsolationDetail(item) : null;
48
+ }