@pellux/goodvibes-tui 1.1.0 → 1.7.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 (194) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +20 -11
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/core/alert-gating.ts +67 -0
  6. package/src/core/approval-alert.ts +72 -0
  7. package/src/core/budget-breach-notifier.ts +106 -0
  8. package/src/core/conversation-rendering.ts +19 -0
  9. package/src/core/conversation.ts +18 -0
  10. package/src/core/focus-tracker.ts +41 -0
  11. package/src/core/long-task-notifier.ts +33 -6
  12. package/src/core/system-message-router.ts +37 -51
  13. package/src/core/turn-cancellation.ts +20 -0
  14. package/src/core/turn-event-wiring.ts +64 -3
  15. package/src/export/cost-utils.ts +36 -0
  16. package/src/input/command-registry.ts +38 -1
  17. package/src/input/commands/checkpoint-runtime.ts +280 -0
  18. package/src/input/commands/codebase-runtime.ts +192 -0
  19. package/src/input/commands/eval.ts +1 -1
  20. package/src/input/commands/health-runtime.ts +1 -1
  21. package/src/input/commands/image-runtime.ts +112 -0
  22. package/src/input/commands/intelligence-runtime.ts +11 -1
  23. package/src/input/commands/local-auth-runtime.ts +4 -1
  24. package/src/input/commands/local-runtime.ts +9 -3
  25. package/src/input/commands/marketplace-runtime.ts +2 -2
  26. package/src/input/commands/memory.ts +6 -1
  27. package/src/input/commands/operator-panel-runtime.ts +40 -24
  28. package/src/input/commands/planning-runtime.ts +26 -3
  29. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  30. package/src/input/commands/plugin-runtime.ts +2 -2
  31. package/src/input/commands/policy-dispatch.ts +21 -0
  32. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  33. package/src/input/commands/qrcode-runtime.ts +3 -3
  34. package/src/input/commands/recall-review.ts +35 -0
  35. package/src/input/commands/remote-runtime.ts +3 -3
  36. package/src/input/commands/runtime-services.ts +54 -13
  37. package/src/input/commands/services-runtime.ts +1 -1
  38. package/src/input/commands/session-workflow.ts +16 -1
  39. package/src/input/commands/settings-sync-runtime.ts +1 -1
  40. package/src/input/commands/shell-core.ts +15 -7
  41. package/src/input/commands/skills-runtime.ts +2 -8
  42. package/src/input/commands/subscription-runtime.ts +2 -2
  43. package/src/input/commands/test-runtime.ts +277 -0
  44. package/src/input/commands/websearch-runtime.ts +101 -0
  45. package/src/input/commands/work-plan-runtime.ts +36 -10
  46. package/src/input/commands/workstream-runtime.ts +338 -0
  47. package/src/input/commands.ts +12 -0
  48. package/src/input/config-modal-types.ts +161 -0
  49. package/src/input/config-modal.ts +377 -0
  50. package/src/input/feed-context-factory.ts +7 -8
  51. package/src/input/handler-command-route.ts +27 -17
  52. package/src/input/handler-feed-routes.ts +61 -23
  53. package/src/input/handler-feed.ts +47 -23
  54. package/src/input/handler-interactions.ts +1 -3
  55. package/src/input/handler-modal-routes.ts +65 -0
  56. package/src/input/handler-modal-stack.ts +3 -5
  57. package/src/input/handler-modal-token-routes.ts +16 -49
  58. package/src/input/handler-picker-routes.ts +11 -94
  59. package/src/input/handler-shortcuts.ts +24 -14
  60. package/src/input/handler-types.ts +2 -6
  61. package/src/input/handler-ui-state.ts +10 -22
  62. package/src/input/handler.ts +6 -28
  63. package/src/input/keybindings.ts +22 -8
  64. package/src/input/model-picker-types.ts +24 -6
  65. package/src/input/model-picker.ts +58 -0
  66. package/src/input/panel-integration-actions.ts +9 -170
  67. package/src/input/settings-modal-data.ts +95 -0
  68. package/src/input/settings-modal-mutations.ts +6 -4
  69. package/src/main.ts +24 -27
  70. package/src/panels/agent-inspector-shared.ts +2 -1
  71. package/src/panels/base-panel.ts +22 -1
  72. package/src/panels/builtin/agent.ts +21 -107
  73. package/src/panels/builtin/development.ts +15 -61
  74. package/src/panels/builtin/knowledge.ts +8 -32
  75. package/src/panels/builtin/operations.ts +84 -428
  76. package/src/panels/builtin/session.ts +21 -112
  77. package/src/panels/builtin/shared.ts +9 -43
  78. package/src/panels/builtin-modals.ts +218 -0
  79. package/src/panels/builtin-panels.ts +5 -0
  80. package/src/panels/cost-tracker-panel.ts +36 -10
  81. package/src/panels/diff-panel.ts +12 -43
  82. package/src/panels/eval-registry.ts +60 -0
  83. package/src/panels/fleet-panel.ts +800 -0
  84. package/src/panels/fleet-read-model.ts +477 -0
  85. package/src/panels/fleet-steer.ts +92 -0
  86. package/src/panels/fleet-stop.ts +125 -0
  87. package/src/panels/fleet-tabs.ts +230 -0
  88. package/src/panels/fleet-transcript.ts +356 -0
  89. package/src/panels/index.ts +7 -31
  90. package/src/panels/modals/hooks-modal.ts +187 -0
  91. package/src/panels/modals/keybindings-modal.ts +151 -0
  92. package/src/panels/modals/knowledge-modal.ts +158 -0
  93. package/src/panels/modals/local-auth-modal.ts +132 -0
  94. package/src/panels/modals/marketplace-modal.ts +218 -0
  95. package/src/panels/modals/memory-modal.ts +180 -0
  96. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  97. package/src/panels/modals/modal-theme.ts +36 -0
  98. package/src/panels/modals/pairing-modal.ts +144 -0
  99. package/src/panels/modals/planning-modal.ts +282 -0
  100. package/src/panels/modals/plugins-modal.ts +174 -0
  101. package/src/panels/modals/policy-modal.ts +256 -0
  102. package/src/panels/modals/provider-health-modal.ts +136 -0
  103. package/src/panels/modals/remote-modal.ts +115 -0
  104. package/src/panels/modals/sandbox-modal.ts +150 -0
  105. package/src/panels/modals/security-modal.ts +217 -0
  106. package/src/panels/modals/services-modal.ts +179 -0
  107. package/src/panels/modals/settings-sync-modal.ts +142 -0
  108. package/src/panels/modals/skills-modal.ts +189 -0
  109. package/src/panels/modals/subscription-modal.ts +172 -0
  110. package/src/panels/modals/work-plan-modal.ts +149 -0
  111. package/src/panels/panel-confirm-overlay.ts +72 -0
  112. package/src/panels/panel-manager.ts +108 -5
  113. package/src/panels/project-planning-answer-actions.ts +4 -2
  114. package/src/panels/skills-panel.ts +7 -51
  115. package/src/panels/types.ts +13 -0
  116. package/src/permissions/hunk-selection.ts +179 -0
  117. package/src/permissions/prompt.ts +60 -4
  118. package/src/renderer/compaction-history-modal.ts +19 -3
  119. package/src/renderer/compaction-preview.ts +13 -2
  120. package/src/renderer/compaction-quality.ts +100 -0
  121. package/src/renderer/config-modal.ts +81 -0
  122. package/src/renderer/conversation-overlays.ts +10 -17
  123. package/src/renderer/fleet-tab-strip.ts +56 -0
  124. package/src/renderer/footer-tips.ts +10 -2
  125. package/src/renderer/git-status.ts +40 -0
  126. package/src/renderer/help-overlay.ts +2 -2
  127. package/src/renderer/model-workspace.ts +44 -1
  128. package/src/renderer/panel-workspace-bar.ts +8 -2
  129. package/src/renderer/turn-injection.ts +114 -0
  130. package/src/runtime/bootstrap-command-context.ts +21 -1
  131. package/src/runtime/bootstrap-command-parts.ts +34 -7
  132. package/src/runtime/bootstrap-core.ts +14 -4
  133. package/src/runtime/bootstrap-shell.ts +29 -16
  134. package/src/runtime/bootstrap.ts +11 -17
  135. package/src/runtime/code-index-services.ts +112 -0
  136. package/src/runtime/orchestrator-core-services.ts +49 -0
  137. package/src/runtime/process-lifecycle.ts +3 -1
  138. package/src/runtime/services.ts +91 -43
  139. package/src/runtime/ui-services.ts +8 -0
  140. package/src/runtime/workstream-services.ts +195 -0
  141. package/src/shell/blocking-input.ts +22 -1
  142. package/src/shell/ui-openers.ts +129 -17
  143. package/src/utils/format-duration.ts +8 -18
  144. package/src/utils/splash-lines.ts +1 -1
  145. package/src/version.ts +1 -1
  146. package/src/panels/agent-inspector-panel.ts +0 -786
  147. package/src/panels/approval-panel.ts +0 -252
  148. package/src/panels/automation-control-panel.ts +0 -479
  149. package/src/panels/cockpit-panel.ts +0 -481
  150. package/src/panels/cockpit-read-model.ts +0 -238
  151. package/src/panels/communication-panel.ts +0 -256
  152. package/src/panels/control-plane-panel.ts +0 -470
  153. package/src/panels/debug-panel.ts +0 -615
  154. package/src/panels/docs-panel.ts +0 -384
  155. package/src/panels/eval-panel.ts +0 -627
  156. package/src/panels/file-explorer-panel.ts +0 -673
  157. package/src/panels/file-preview-panel.ts +0 -517
  158. package/src/panels/hooks-panel.ts +0 -401
  159. package/src/panels/incident-review-panel.ts +0 -406
  160. package/src/panels/intelligence-panel.ts +0 -383
  161. package/src/panels/knowledge-graph-panel.ts +0 -515
  162. package/src/panels/marketplace-panel.ts +0 -399
  163. package/src/panels/memory-panel.ts +0 -558
  164. package/src/panels/ops-control-panel.ts +0 -329
  165. package/src/panels/ops-strategy-panel.ts +0 -321
  166. package/src/panels/orchestration-panel.ts +0 -465
  167. package/src/panels/panel-list-panel.ts +0 -566
  168. package/src/panels/plan-dashboard-panel.ts +0 -707
  169. package/src/panels/policy-panel.ts +0 -517
  170. package/src/panels/project-planning-panel.ts +0 -731
  171. package/src/panels/provider-health-panel.ts +0 -678
  172. package/src/panels/provider-health-tracker.ts +0 -310
  173. package/src/panels/provider-health-views.ts +0 -567
  174. package/src/panels/qr-panel.ts +0 -280
  175. package/src/panels/remote-panel.ts +0 -534
  176. package/src/panels/routes-panel.ts +0 -241
  177. package/src/panels/sandbox-panel.ts +0 -456
  178. package/src/panels/security-panel.ts +0 -447
  179. package/src/panels/services-panel.ts +0 -329
  180. package/src/panels/session-browser-panel.ts +0 -496
  181. package/src/panels/settings-sync-panel.ts +0 -398
  182. package/src/panels/subscription-panel.ts +0 -342
  183. package/src/panels/symbol-outline-panel.ts +0 -619
  184. package/src/panels/system-messages-panel.ts +0 -364
  185. package/src/panels/tasks-panel.ts +0 -608
  186. package/src/panels/thinking-panel.ts +0 -333
  187. package/src/panels/tool-inspector-panel.ts +0 -537
  188. package/src/panels/work-plan-panel.ts +0 -540
  189. package/src/panels/worktree-panel.ts +0 -360
  190. package/src/panels/wrfc-panel.ts +0 -790
  191. package/src/renderer/agent-detail-modal.ts +0 -466
  192. package/src/renderer/live-tail-modal.ts +0 -156
  193. package/src/renderer/process-modal.ts +0 -671
  194. package/src/renderer/qr-renderer.ts +0 -120
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Compaction quality-score bridging (Wave-5 W5.4 / B28).
3
+ *
4
+ * Reality check (wo803): the SDK's `computeQualityScore()`
5
+ * (platform/runtime/compaction/quality-score.ts) is only ever called from a
6
+ * SEPARATE pipeline, `CompactionManager` (platform/runtime/compaction/manager.ts).
7
+ * The TUI's actual `/compact` path never instantiates `CompactionManager` — it
8
+ * goes through `platform/core/context-compaction.ts` via
9
+ * `ConversationManager.compact()` (see `compactConversation()` in
10
+ * runtime-services.ts), which has no scoring of its own. Adopting the whole
11
+ * `CompactionManager` pipeline just to get the score would be a far bigger
12
+ * change than this item calls for.
13
+ *
14
+ * Instead, this module calls `computeQualityScore()` directly and out-of-band,
15
+ * fed by the before/after message sets and token estimates
16
+ * `compactConversation()` already holds. That's enough to close the gap:
17
+ * quality scoring reaches `/compact` and `/compact-history` without adopting
18
+ * `CompactionManager`.
19
+ *
20
+ * Honesty note: the `strategy` value passed to `computeQualityScore()` is a
21
+ * BORROWED rubric label describing what `compactConversation()`'s structured
22
+ * multi-section extraction actually does (collapse the whole conversation
23
+ * into one handoff message) — it is NOT a real `CompactionStrategy`
24
+ * escalation result, and no strategy switch is triggered from it. Every
25
+ * rendering of the score says so explicitly (see `formatQualityScoreLine`).
26
+ *
27
+ * Storage: scores are kept in a TUI-local, timestamp-keyed store, never added
28
+ * to the SDK's public `CompactionEvent` type (that would force an
29
+ * api-extractor regen for a value the SDK's own compaction log never
30
+ * populates). Keyed by `CompactionEvent.timestamp` rather than array index,
31
+ * because `compactSmallWindow`'s null-event path (small-context-window
32
+ * models — see runtime-services.ts) can create index gaps.
33
+ */
34
+
35
+ import { computeQualityScore, describeScore } from '@/runtime/index.ts';
36
+ import type { CompactionQualityScore, StrategyInput, StrategyOutput } from '@/runtime/index.ts';
37
+ import type { ProviderMessage } from '@pellux/goodvibes-sdk/platform/providers';
38
+
39
+ export type { CompactionQualityScore } from '@/runtime/index.ts';
40
+
41
+ /**
42
+ * Bound the TUI-local score store the same way the SDK bounds its own
43
+ * compaction event log (context-compaction.ts caps at 50 and shifts the
44
+ * oldest entry) — a long-running session should not accumulate this forever.
45
+ */
46
+ const MAX_TRACKED_SCORES = 50;
47
+
48
+ const scoresByTimestamp = new Map<number, CompactionQualityScore>();
49
+
50
+ /** Record a computed quality score, keyed by the CompactionEvent's timestamp. */
51
+ export function recordCompactionQualityScore(timestamp: number, score: CompactionQualityScore): void {
52
+ scoresByTimestamp.set(timestamp, score);
53
+ if (scoresByTimestamp.size > MAX_TRACKED_SCORES) {
54
+ const oldestKey = scoresByTimestamp.keys().next().value;
55
+ if (oldestKey !== undefined) scoresByTimestamp.delete(oldestKey);
56
+ }
57
+ }
58
+
59
+ /** Look up a previously-recorded quality score for a CompactionEvent, by timestamp. */
60
+ export function getCompactionQualityScore(timestamp: number): CompactionQualityScore | undefined {
61
+ return scoresByTimestamp.get(timestamp);
62
+ }
63
+
64
+ export interface ScoreCompactionRunInput {
65
+ readonly sessionId: string;
66
+ readonly contextWindow: number;
67
+ readonly messagesBefore: readonly ProviderMessage[];
68
+ readonly messagesAfter: readonly ProviderMessage[];
69
+ readonly tokensBefore: number;
70
+ readonly tokensAfter: number;
71
+ }
72
+
73
+ /**
74
+ * Compute a CompactionQualityScore for one compaction run, out-of-band from
75
+ * the SDK's own (unused, in this TUI) CompactionManager pipeline.
76
+ */
77
+ export function scoreCompactionRun(input: ScoreCompactionRunInput): CompactionQualityScore {
78
+ const strategyInput: StrategyInput = {
79
+ sessionId: input.sessionId,
80
+ messages: input.messagesBefore,
81
+ tokensBefore: input.tokensBefore,
82
+ contextWindow: input.contextWindow,
83
+ // Borrowed rubric label, not a real strategy escalation — see module doc.
84
+ strategy: 'collapse',
85
+ };
86
+ const strategyOutput: StrategyOutput = {
87
+ messages: [...input.messagesAfter],
88
+ tokensAfter: input.tokensAfter,
89
+ summary: '',
90
+ strategy: 'collapse',
91
+ durationMs: 0,
92
+ warnings: [],
93
+ };
94
+ return computeQualityScore(strategyInput, strategyOutput);
95
+ }
96
+
97
+ /** One-line grade summary for the /compact after-notice and /compact-history entries. */
98
+ export function formatQualityScoreLine(score: CompactionQualityScore): string {
99
+ return `Quality: ${describeScore(score)} (rubric applied out-of-band; no strategy escalation ran)`;
100
+ }
@@ -0,0 +1,81 @@
1
+ import type { Line } from '../types/grid.ts';
2
+ import { ModalFactory, type ModalSection, type ModalTab } from './modal-factory.ts';
3
+ import { resolveUiTones } from './theme.ts';
4
+ import type { ConfigModal, ConfigModalRenderModel } from '../input/config-modal.ts';
5
+
6
+ const TONES = resolveUiTones('dark');
7
+
8
+ /**
9
+ * renderConfigModal — the single render path for every ConfigModalSurface.
10
+ * Reads the host's frozen-structure-plus-live-values render model and maps it
11
+ * onto ModalFactory.createModal (the same renderer settings-modal uses), so the
12
+ * chrome, tab strip, list, and footer are one dialect across all migrated
13
+ * surfaces. Pure: takes the modal's render model, returns Line[].
14
+ */
15
+ export function renderConfigModal(modal: ConfigModal, width: number, height: number): Line[] {
16
+ // Size the visible row window to the available height so the list scrolls
17
+ // rather than overflowing (posture header + separator + chrome ≈ 8 rows).
18
+ const visible = Math.max(3, Math.min(16, height - 8));
19
+ modal.setViewportRows(visible);
20
+ const model = modal.getRenderModel();
21
+ return renderConfigModalModel(model, width);
22
+ }
23
+
24
+ /** Pure model→Line[] mapping — the shared inner step of renderConfigModal. */
25
+ function renderConfigModalModel(model: ConfigModalRenderModel, width: number): Line[] {
26
+ const sections: ModalSection[] = [];
27
+
28
+ if (model.degraded) {
29
+ sections.push({ type: 'text', content: `⚠ ${model.degraded}`, style: { fg: TONES.state.warn, bold: true } });
30
+ sections.push({ type: 'separator' });
31
+ }
32
+
33
+ for (const line of model.header) {
34
+ sections.push({ type: 'text', content: line, style: { fg: TONES.fg.secondary } });
35
+ }
36
+ if (model.header.length > 0) sections.push({ type: 'separator' });
37
+
38
+ if (model.rows.length === 0) {
39
+ sections.push({ type: 'text', content: model.emptyText ?? 'Nothing to show.', style: { fg: TONES.fg.muted, dim: true } });
40
+ } else {
41
+ sections.push({
42
+ type: 'list',
43
+ items: model.rows.map((row) => ({
44
+ label: row.label,
45
+ selected: row.selected,
46
+ style: row.stale
47
+ ? { fg: TONES.fg.muted, dim: true }
48
+ : row.style,
49
+ })),
50
+ });
51
+ }
52
+
53
+ const tabs: ModalTab[] | undefined = model.tabs.length > 1
54
+ ? model.tabs.map((t) => ({ label: t.label, active: t.active }))
55
+ : undefined;
56
+
57
+ // Keep the content region a stable height so live-value ticks never change
58
+ // the box size: frozen header + separator + the fixed visible-row window.
59
+ const targetContentRows =
60
+ (model.degraded ? 2 : 0) +
61
+ model.header.length +
62
+ (model.header.length > 0 ? 1 : 0) +
63
+ model.scroll.visible;
64
+
65
+ const helpers = model.status
66
+ ? [{ content: model.status, accent: true }]
67
+ : undefined;
68
+
69
+ return ModalFactory.createModal(
70
+ {
71
+ title: model.title,
72
+ width: 76,
73
+ tabs,
74
+ sections,
75
+ targetContentRows,
76
+ helpers,
77
+ hints: [...model.hints],
78
+ },
79
+ width,
80
+ );
81
+ }
@@ -8,11 +8,9 @@ import { renderModelWorkspace } from './model-workspace.ts';
8
8
  import { renderSelectionModalOverlay } from './selection-modal-overlay.ts';
9
9
  import { renderSearchOverlay } from './search-overlay.ts';
10
10
  import { renderHistorySearchOverlay } from './history-search-overlay.ts';
11
- import { renderProcessModal } from './process-modal.ts';
12
- import { renderAgentDetailModal } from './agent-detail-modal.ts';
13
- import { renderLiveTailModal } from './live-tail-modal.ts';
14
11
  import { renderContextInspector } from './context-inspector.ts';
15
12
  import { renderSettingsModal } from './settings-modal.ts';
13
+ import { renderConfigModal } from './config-modal.ts';
16
14
  import { renderMcpWorkspace } from './mcp-workspace.ts';
17
15
  import { renderSessionPickerModal } from './session-picker-modal.ts';
18
16
  import { renderProfilePickerModal } from './profile-picker-modal.ts';
@@ -78,20 +76,9 @@ export function applyConversationOverlays(
78
76
  next.push(...renderHistorySearchOverlay(input.historySearch, conversationWidth));
79
77
  }
80
78
 
81
- if (!fullscreenClaimed && input.processModal.active) {
82
- const lines = renderProcessModal(input.processModal, conversationWidth, viewportHeight);
83
- next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
84
- }
85
-
86
- if (!fullscreenClaimed && input.agentDetailModal.active) {
87
- const lines = renderAgentDetailModal(input.agentDetailModal, conversationWidth, viewportHeight);
88
- next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
89
- }
90
-
91
- if (!fullscreenClaimed && input.liveTailModal.active) {
92
- const lines = renderLiveTailModal(input.liveTailModal, conversationWidth, viewportHeight);
93
- next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
94
- }
79
+ // W6.1 retirement: the process, agent-detail, and live-tail modal overlays
80
+ // were removed with those modals — F2 now opens the Fleet panel, which
81
+ // subsumes the live process tree.
95
82
 
96
83
  if (!fullscreenClaimed && input.contextInspectorModal.active) {
97
84
  const lines = renderContextInspector(conversation, conversationWidth, viewportHeight, contextWindow);
@@ -104,6 +91,12 @@ export function applyConversationOverlays(
104
91
  fullscreenClaimed = true;
105
92
  }
106
93
 
94
+ if (!fullscreenClaimed && input.configModal.active) {
95
+ const lines = renderConfigModal(input.configModal, conversationWidth, viewportHeight);
96
+ next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
97
+ fullscreenClaimed = true;
98
+ }
99
+
107
100
  if (input.mcpWorkspace.active) {
108
101
  const lines = renderMcpWorkspace(input.mcpWorkspace, conversationWidth, viewportHeight);
109
102
  next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
@@ -0,0 +1,56 @@
1
+ // ---------------------------------------------------------------------------
2
+ // fleet-tab-strip.ts
3
+ //
4
+ // Wave-3 (W3.1 Part C3) — renders FleetPanel's session-tab strip by reusing
5
+ // renderTabStrip exactly as panel-workspace-bar.ts does for the workspace
6
+ // tab bar. Deliberately a SEPARATE, visually distinct strip from the
7
+ // workspace bar: workspace tabs switch PANELS, fleet session tabs switch
8
+ // ATTACHED PROCESSES within one panel — conflating their styling would blur
9
+ // that distinction for the operator.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import type { Line } from '../types/grid.ts';
13
+ import type { FleetTabsState } from '../panels/fleet-tabs.ts';
14
+ import { renderTabStrip, type TabHitRegion } from './tab-strip.ts';
15
+ import { UI_TONES } from './ui-primitives.ts';
16
+
17
+ const ACTIVE_FG = UI_TONES.fg.primary;
18
+ const ACTIVE_BG = UI_TONES.bg.selected;
19
+ const INACTIVE_FG = UI_TONES.fg.muted;
20
+ const SEPARATOR_FG = UI_TONES.fg.dim;
21
+ const LABEL_FG = UI_TONES.fg.secondary;
22
+
23
+ /**
24
+ * Render the fleet session-tab strip, or `null` when there are no attached
25
+ * tabs — the panel omits the strip entirely in that case (root-tab-only),
26
+ * which is what keeps the pre-Wave-3 fleet-panel goldens byte-identical.
27
+ */
28
+ export function renderFleetTabStrip(
29
+ state: FleetTabsState,
30
+ width: number,
31
+ onLayout?: (regions: readonly TabHitRegion[]) => void,
32
+ ): Line | null {
33
+ if (state.tabs.length === 0) return null;
34
+ return renderTabStrip({
35
+ width,
36
+ onLayout,
37
+ tabs: [
38
+ { label: 'Tree', active: state.activeTabIndex === 0 },
39
+ ...state.tabs.map((tab, index) => ({
40
+ label: tab.label,
41
+ active: state.activeTabIndex === index + 1,
42
+ })),
43
+ ],
44
+ prefixLabel: ' SESSIONS ',
45
+ style: {
46
+ activeFg: ACTIVE_FG,
47
+ activeBg: ACTIVE_BG,
48
+ activeBold: true,
49
+ inactiveFg: INACTIVE_FG,
50
+ separatorFg: SEPARATOR_FG,
51
+ labelFg: LABEL_FG,
52
+ overflowFg: SEPARATOR_FG,
53
+ trailingFg: INACTIVE_FG,
54
+ },
55
+ });
56
+ }
@@ -25,9 +25,17 @@ export function isAgentActive(composerStatus: string | undefined): boolean {
25
25
  }
26
26
 
27
27
  const TIP_PANELS = 'Ctrl+P panels';
28
- const TIP_PROCESSES = 'F2 processes';
28
+ // W6.2 e: F2 now opens the Fleet panel (the process modal was retired), so the
29
+ // tip names 'fleet', not 'processes'.
30
+ const TIP_PROCESSES = 'F2 fleet';
29
31
  const TIP_HELP = '? help';
30
- const TIP_QUIT = 'Ctrl+C quit';
32
+ // W6.2 f: an empty-composer Ctrl+C does NOT quit on the first press — it arms a
33
+ // ~1s "press again to exit" confirm, and only a SECOND Ctrl+C within that window
34
+ // exits (an intentional accidental-exit guard). The old 'Ctrl+C quit' tip
35
+ // implied a single press quits, so two presses seconds apart appeared to do
36
+ // nothing. 'Ctrl+C x2 quit' matches the real double-press contract (and the
37
+ // help overlay's "Ctrl+C x2 Exit" row).
38
+ const TIP_QUIT = 'Ctrl+C x2 quit';
31
39
 
32
40
  /**
33
41
  * Build the footer discoverability tip. Segments join with the shared middle
@@ -24,6 +24,7 @@ export class GitStatusProvider {
24
24
  private lastFetch = 0;
25
25
  private readonly ttlMs = 2000;
26
26
  private fetching = false;
27
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
27
28
 
28
29
  constructor(private readonly workingDirectory: string) {}
29
30
 
@@ -53,6 +54,45 @@ export class GitStatusProvider {
53
54
  return this.cache;
54
55
  }
55
56
 
57
+ /**
58
+ * Start a lightweight live-repo-state poll. Mirrors DiffPanel's principle of
59
+ * never trusting a cached "is this a repo" flag (see diff-panel.ts
60
+ * showGitDiff/showFileDiffs/showStagedDiff, all gated on a fresh
61
+ * GitService.isGitRepo() check) but amortizes the cost for the header: the
62
+ * cheap synchronous isGitRepo() spawn runs every tick; the heavier async
63
+ * status()+branch() fetch (this.refresh()) only runs when that boolean
64
+ * actually flips — e.g. an external `git init`, or `.git` removed.
65
+ */
66
+ startPolling(intervalMs: number, onChange: (info: GitHeaderInfo) => void): void {
67
+ if (this.pollTimer !== null) return;
68
+ const checkIsRepo = (): boolean | null => {
69
+ try {
70
+ return GitService.isGitRepo(this.workingDirectory);
71
+ } catch (err) {
72
+ logger.debug('GitStatusProvider: isGitRepo poll check failed', { error: summarizeError(err) });
73
+ return null;
74
+ }
75
+ };
76
+ let lastKnownIsRepo = checkIsRepo();
77
+ this.pollTimer = setInterval(() => {
78
+ const isRepoNow = checkIsRepo();
79
+ if (isRepoNow !== null && isRepoNow !== lastKnownIsRepo) {
80
+ lastKnownIsRepo = isRepoNow;
81
+ this.refresh().then(onChange).catch((err) => {
82
+ logger.debug('GitStatusProvider: poll-triggered refresh failed', { error: summarizeError(err) });
83
+ });
84
+ }
85
+ }, intervalMs);
86
+ }
87
+
88
+ /** Stop the live-repo-state poll started by startPolling(). No-op if not polling. */
89
+ stopPolling(): void {
90
+ if (this.pollTimer !== null) {
91
+ clearInterval(this.pollTimer);
92
+ this.pollTimer = null;
93
+ }
94
+ }
95
+
56
96
  private async _fetch(): Promise<void> {
57
97
  if (this.fetching) return;
58
98
  this.fetching = true;
@@ -289,7 +289,7 @@ export function renderShortcutsOverlay(
289
289
  row(kb('block-copy'), 'Copy block to clipboard'),
290
290
  row(kb('block-save'), 'Save block to file'),
291
291
  row(kb('copy-selection'), 'Copy selection'),
292
- row('F2', 'Process monitor'),
292
+ row('F2', 'Open the Fleet panel'),
293
293
  row('?', 'Help overlay'),
294
294
  row(`${kb('clear-cancel')} x2`, 'Exit'),
295
295
  '',
@@ -303,7 +303,7 @@ export function renderShortcutsOverlay(
303
303
  row(kb('panel-focus-toggle'), 'Swap focus between top / bottom pane'),
304
304
  row(kb('panel-close'), 'Close active panel'),
305
305
  row(kb('panel-close-all'), 'Close all panels'),
306
- row(kb('panel-ops'), 'Open the Ops Control panel'),
306
+ row(kb('panel-ops'), 'Open and focus the Fleet panel'),
307
307
  '',
308
308
  ' In-Panel Controls',
309
309
  ' ' + '\u2500'.repeat(40),
@@ -124,6 +124,10 @@ function modelKey(model: ModelDefinition): string {
124
124
 
125
125
  function targetSummary(info: ModelPickerTargetInfo): string {
126
126
  if (!info.enabled) return 'disabled';
127
+ // 'embeddings' has no model concept — configuredNote carries the honest
128
+ // provider id + dimensions + configured-state summary instead of a
129
+ // computed provider:model route (which would print a phantom "model:").
130
+ if (info.configuredNote) return info.configuredNote;
127
131
  const route = formatRoute(info.provider, info.model);
128
132
  if (info.inherited) return `inherits ${route}`;
129
133
  return route;
@@ -145,6 +149,7 @@ function targetLabelFor(target: string): string {
145
149
  if (target === 'helper') return 'Helper Model';
146
150
  if (target === 'tool') return 'Tool LLM';
147
151
  if (target === 'tts') return 'TTS LLM';
152
+ if (target === 'embeddings') return 'Embeddings';
148
153
  return 'Main Chat';
149
154
  }
150
155
 
@@ -174,6 +179,13 @@ function detailLines(picker: ModelPickerModal, width: number): string[] {
174
179
  }
175
180
  } else if (picker.mode === 'effort') {
176
181
  lines.push(`Reasoning effort applies to the main chat model. Select the default effort for this model.`);
182
+ } else if (picker.mode === 'embeddingProvider') {
183
+ lines.push('Embedding provider selection: choose the provider memory search and the code index use to embed content.');
184
+ const selectedEmbedding = picker.embeddingProviders[picker.selectedIndex];
185
+ if (selectedEmbedding) {
186
+ const status = selectedEmbedding.configured ? 'configured' : 'unconfigured';
187
+ lines.push(`Selected: ${selectedEmbedding.id} | ${selectedEmbedding.dimensions} dimensions | ${status}${selectedEmbedding.detail ? ` | ${selectedEmbedding.detail}` : ''}`);
188
+ }
177
189
  } else {
178
190
  lines.push(`Context cap overrides the detected local-model context window for this selection.`);
179
191
  }
@@ -288,6 +300,28 @@ function renderContextCapRows(picker: ModelPickerModal, lines: Line[], rows: num
288
300
  }
289
301
  }
290
302
 
303
+ function renderEmbeddingProviderRows(picker: ModelPickerModal, lines: Line[], rows: number, startX: number, width: number): void {
304
+ const providers = picker.embeddingProviders;
305
+ const { start, end } = stableWindow(providers.length, picker.selectedIndex, rows);
306
+ for (let visibleRow = 0; visibleRow < rows; visibleRow += 1) {
307
+ const absolute = start + visibleRow;
308
+ const provider = providers[absolute];
309
+ const line = lines[visibleRow]!;
310
+ if (!provider) continue;
311
+ const selected = absolute === picker.selectedIndex;
312
+ const bg = selected ? PALETTE.selectedBg : PALETTE.bodyBg;
313
+ fillRange(line, startX, startX + width - 1, bg);
314
+ const marker = selected ? (picker.focusPane === 'items' ? GLYPHS.navigation.selected : '•') : ' ';
315
+ const status = provider.configured ? 'configured' : 'unconfigured';
316
+ writeText(line, startX + 1, width, padDisplay(marker, 2), { fg: PALETTE.text, bg, bold: selected });
317
+ writeText(line, startX + 3, Math.max(0, width - 30), padDisplay(provider.label, Math.max(0, width - 30)), { fg: selected ? PALETTE.text : PALETTE.muted, bg, bold: selected });
318
+ writeText(line, startX + Math.max(4, width - 25), 12, padDisplay(`${provider.dimensions}d`, 12), { fg: PALETTE.dim, bg });
319
+ writeText(line, startX + Math.max(4, width - 13), 12, padDisplay(status, 12), { fg: provider.configured ? PALETTE.good : PALETTE.warn, bg });
320
+ }
321
+ if (start > 0 && lines[0]) writeText(lines[0], startX + 1, width - 2, `${GLYPHS.navigation.moreAbove} ${start} more provider(s) above`, { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
322
+ if (end < providers.length && lines[rows - 1]) writeText(lines[rows - 1]!, startX + 1, width - 2, `${GLYPHS.navigation.moreBelow} ${providers.length - end} more provider(s) below`, { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
323
+ }
324
+
291
325
  function writeTableHeader(line: Line, picker: ModelPickerModal, startX: number, width: number): void {
292
326
  fillRange(line, startX, startX + width - 1, PALETTE.footerBg);
293
327
  const style = { fg: PALETTE.muted, bg: PALETTE.footerBg, bold: true };
@@ -303,6 +337,10 @@ function writeTableHeader(line: Line, picker: ModelPickerModal, startX: number,
303
337
  writeText(line, startX + 1, width - 2, 'Context cap input', style);
304
338
  return;
305
339
  }
340
+ if (picker.mode === 'embeddingProvider') {
341
+ writeText(line, startX + 1, width - 2, 'Embedding provider Dimensions Status', style);
342
+ return;
343
+ }
306
344
  const providerW = clamp(Math.floor(width * 0.14), 10, 18);
307
345
  const ctxW = 8;
308
346
  const tierW = 8;
@@ -337,7 +375,7 @@ export function renderModelWorkspace(picker: ModelPickerModal, width: number, vi
337
375
  const header = contentLine(safeWidth, PALETTE.footerBg);
338
376
  writeText(header, 2, targetW - 2, 'Targets', { fg: PALETTE.subtitle, bold: true, bg: PALETTE.footerBg });
339
377
  drawVertical(header, targetW, PALETTE.footerBg);
340
- const modeLabel = picker.mode === 'provider' ? 'Provider list' : picker.mode === 'model' ? 'Model list' : picker.mode === 'effort' ? 'Reasoning effort' : 'Context cap';
378
+ const modeLabel = picker.mode === 'provider' ? 'Provider list' : picker.mode === 'model' ? 'Model list' : picker.mode === 'effort' ? 'Reasoning effort' : picker.mode === 'embeddingProvider' ? 'Embedding providers' : 'Context cap';
341
379
  writeText(header, contentX + 1, contentW - 2, `${modeLabel} • ${picker.getItemCount()} item(s)`, { fg: PALETTE.subtitle, bold: true, bg: PALETTE.footerBg });
342
380
  lines.push(header);
343
381
 
@@ -380,6 +418,8 @@ export function renderModelWorkspace(picker: ModelPickerModal, width: number, vi
380
418
  renderModelRows(picker, listLines, listLines.length, contentX, contentW - 1);
381
419
  } else if (picker.mode === 'contextCap') {
382
420
  renderContextCapRows(picker, listLines, listLines.length, contentX, contentW - 1);
421
+ } else if (picker.mode === 'embeddingProvider') {
422
+ renderEmbeddingProviderRows(picker, listLines, listLines.length, contentX, contentW - 1);
383
423
  } else {
384
424
  renderEffortRows(picker, listLines, listLines.length, contentX, contentW - 1);
385
425
  }
@@ -432,6 +472,8 @@ function getRenderCacheKey(picker: ModelPickerModal, width: number, viewportHeig
432
472
  base.push(objectId(picker.effortLevels), picker.effortLevels.join('\u001f'), picker.pendingModel?.registryKey ?? picker.pendingModel?.id ?? '');
433
473
  } else if (picker.mode === 'contextCap') {
434
474
  base.push(picker.contextCapQuery, picker.contextCapPendingModel?.registryKey ?? picker.contextCapPendingModel?.id ?? '');
475
+ } else if (picker.mode === 'embeddingProvider') {
476
+ base.push(objectId(picker.embeddingProviders), picker.embeddingProviders.length);
435
477
  }
436
478
 
437
479
  return base.join('\u001e');
@@ -467,6 +509,7 @@ function keyForTargets(values: readonly ModelPickerTargetInfo[]): string {
467
509
  entry.model,
468
510
  entry.enabled ? 1 : 0,
469
511
  entry.inherited ? 1 : 0,
512
+ entry.configuredNote ?? '',
470
513
  ].join('\u001d'))
471
514
  .join('\u001f');
472
515
  }
@@ -21,12 +21,18 @@ export function renderPanelWorkspaceBar(
21
21
  return renderTabStrip({
22
22
  width,
23
23
  onLayout,
24
- tabs: tabs.map((tab) => ({
24
+ tabs: tabs.map((tab, i) => ({
25
25
  // tab.active = selected in its own pane (drives highlighted background).
26
26
  // tab.focused = has keyboard focus (drives brighter text / focus indicator).
27
27
  // A tab can be active-but-not-focused (selected in the unfocused pane) or
28
28
  // active-and-focused (selected in the focused pane).
29
- label: `${tab.pane === 'bottom' ? 'v' : '^'} ${tab.icon} ${tab.name}${tab.focused ? ' ▸' : ''}`,
29
+ //
30
+ // A14: the pane marker uses ▲/▼ (top/bottom pane). The old '^'/'v'
31
+ // prefixes read as a Ctrl caret (^) implying a non-existent Ctrl+<tab>
32
+ // hotkey. The REAL per-tab jump key is Alt+N (panel-tab-1..9), rendered
33
+ // here as ⌥N for the first nine tabs so the shown affordance matches the
34
+ // binding (tabs past nine have no Alt+N chord, so no index is shown).
35
+ label: `${tab.pane === 'bottom' ? '▼' : '▲'}${i < 9 ? ` ⌥${i + 1}` : ''} ${tab.icon} ${tab.name}${tab.focused ? ' ▸' : ''}`,
30
36
  active: tab.active,
31
37
  })),
32
38
  prefixLabel: ' PANELS ',
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Per-turn knowledge injection record rendering (Wave-5 W5.2, wo803).
3
+ *
4
+ * The SDK's passive per-turn retrieval engine (wo801, W5.1 —
5
+ * packages/sdk/src/platform/agents/turn-knowledge-injection.ts) stores one
6
+ * `TurnInjectionRecord` per turn on `AgentRecord.turnInjections` (a bounded
7
+ * ring, default 20 entries) and appends the same record to the agent's
8
+ * session transcript as `{type:'knowledge_injection', turn, ...record}`.
9
+ *
10
+ * `TurnInjectionRecord` itself is not re-exported from the SDK's
11
+ * `platform/agents` barrel (only `AgentRecord.turnInjections` carries its
12
+ * structural shape through the already-public `platform/tools` barrel), so
13
+ * the entry type here is DERIVED from `AgentRecord` rather than imported by
14
+ * name — this needs no SDK export change.
15
+ *
16
+ * Reality check (updated wo805): as of Wave-5 wo805 the TUI's main interactive
17
+ * session DOES route through this engine. The SDK `Orchestrator` runs per-turn
18
+ * passive injection on the evolving primary conversation and records each turn
19
+ * on its own bounded ring, exposed via `Orchestrator.getTurnInjections()` — the
20
+ * main-session counterpart to `AgentRecord.turnInjections` (there is no
21
+ * AgentRecord for the primary conversation). `buildMainSessionTurnInjectionsText`
22
+ * renders that ring for `/recall injections` with no agent id. Spawned agents
23
+ * (Task-tool runs, automation-triggered runs, session continuations) still
24
+ * record onto their own `AgentRecord.turnInjections`, rendered per-agent by
25
+ * `buildTurnInjectionsText` when an explicit agent id is given.
26
+ */
27
+ import type { AgentRecord } from '@pellux/goodvibes-sdk/platform/tools';
28
+
29
+ /** One TurnInjectionRecord entry, derived from AgentRecord (see module doc). */
30
+ export type TurnInjectionEntry = NonNullable<AgentRecord['turnInjections']>[number];
31
+
32
+ function fmtN(n: number): string {
33
+ return n.toLocaleString();
34
+ }
35
+
36
+ /** Render a single TurnInjectionRecord as one readable line. */
37
+ export function formatTurnInjectionEntry(entry: TurnInjectionEntry): string {
38
+ const backendTag = entry.embeddingBackend === 'fallback-lexical' ? ' [lexical fallback]' : '';
39
+ if (entry.injectedIds.length === 0) {
40
+ // Honest empty state: the engine ran this turn but injected nothing —
41
+ // distinct from "the engine never ran" (see buildTurnInjectionsText).
42
+ const reasonText = entry.reason === 'no records cleared relevance floor'
43
+ ? 'nothing injected this turn — nothing cleared the relevance floor'
44
+ : `nothing injected this turn — ${entry.reason ?? 'unknown reason'}`;
45
+ return ` turn ${entry.turn}: ${reasonText}${backendTag} (considered ${entry.candidatesConsidered}, floor ${entry.relevanceFloor})`;
46
+ }
47
+ const droppedStr = entry.droppedForBudget.length > 0
48
+ ? `, dropped for budget: ${entry.droppedForBudget.join(', ')}`
49
+ : '';
50
+ // The retrieval query is part of the record's honesty contract — without it
51
+ // an injected line can't be traced back to WHY those ids were retrieved
52
+ // (Wave-5 replay flagged the omission). Truncated to keep the line scannable.
53
+ const queryStr = entry.query ? ` for ${JSON.stringify(truncateQuery(entry.query))}` : '';
54
+ return (
55
+ ` turn ${entry.turn}: injected ${entry.injectedIds.join(', ')}${queryStr} ` +
56
+ `(~${fmtN(entry.tokenCost)}/${fmtN(entry.budgetTokens)} tok, floor ${entry.relevanceFloor})${droppedStr}${backendTag}`
57
+ );
58
+ }
59
+
60
+ function truncateQuery(query: string): string {
61
+ const flat = query.replace(/\s+/g, ' ').trim();
62
+ return flat.length > 48 ? `${flat.slice(0, 47)}…` : flat;
63
+ }
64
+
65
+ /**
66
+ * Render the full per-turn injection history for one agent, most-recent-turn
67
+ * first, with an honest empty state when nothing has been recorded yet.
68
+ *
69
+ * An empty `entries` array is deliberately ambiguous about WHY it's empty
70
+ * (flag disabled, no turn with new input has run yet, or every turn's token
71
+ * budget had no headroom) — the SDK does not distinguish these cases in the
72
+ * ring itself, so this renders all three possibilities rather than guessing.
73
+ */
74
+ export function buildTurnInjectionsText(agentId: string, entries: readonly TurnInjectionEntry[]): string {
75
+ if (entries.length === 0) {
76
+ return (
77
+ `[recall] No per-turn injection records for agent ${agentId} yet. This means one of: ` +
78
+ 'passive knowledge injection is disabled, no turn with new input has run yet, or the ' +
79
+ 'token budget had no headroom on every turn so far — there is no record either way.'
80
+ );
81
+ }
82
+ const lines = [`[recall] Per-turn knowledge injections for agent ${agentId} (${entries.length}, most recent first):`];
83
+ for (const entry of [...entries].reverse()) {
84
+ lines.push(formatTurnInjectionEntry(entry));
85
+ }
86
+ return lines.join('\n');
87
+ }
88
+
89
+ /**
90
+ * Render the MAIN interactive session's per-turn injection ring
91
+ * (`Orchestrator.getTurnInjections()`, wo805), most-recent-turn first, with the
92
+ * same honest empty state as the per-agent path. The main-session counterpart
93
+ * to {@link buildTurnInjectionsText}: no agent id, main-session-appropriate
94
+ * wording, but the identical per-entry rendering (`formatTurnInjectionEntry`).
95
+ *
96
+ * As with the agent path, an empty `entries` array is deliberately ambiguous
97
+ * about WHY it's empty (flag disabled, no turn with new input has run yet, or
98
+ * every turn's token budget had no headroom) — the ring does not distinguish
99
+ * these, so this renders all three possibilities rather than guessing.
100
+ */
101
+ export function buildMainSessionTurnInjectionsText(entries: readonly TurnInjectionEntry[]): string {
102
+ if (entries.length === 0) {
103
+ return (
104
+ '[recall] No per-turn injection records for the main session yet. This means one of: ' +
105
+ 'passive knowledge injection is disabled, no turn with new input has run yet, or the ' +
106
+ 'token budget had no headroom on every turn so far — there is no record either way.'
107
+ );
108
+ }
109
+ const lines = [`[recall] Per-turn knowledge injections for the main session (${entries.length}, most recent first):`];
110
+ for (const entry of [...entries].reverse()) {
111
+ lines.push(formatTurnInjectionEntry(entry));
112
+ }
113
+ return lines.join('\n');
114
+ }