@pellux/goodvibes-tui 0.27.0 → 0.29.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 (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -6,9 +6,15 @@ import type { SearchManager } from '../input/search.ts';
6
6
  import { allowTerminalWrite } from '../runtime/terminal-output-guard.ts';
7
7
  import { probeTermCaps, type TermColorCaps } from './term-caps.ts';
8
8
  import { DARK_THEME } from './theme.ts';
9
+ import { UI_TONES } from './ui-primitives.ts';
9
10
 
10
11
  const T = DARK_THEME;
11
12
 
13
+ // Accent / dim colors for the panel focus border. The focused pane's left
14
+ // border column is drawn in the accent tone; the unfocused pane stays dim.
15
+ const PANEL_FOCUS_ACCENT = UI_TONES.state.active; // bright blue
16
+ const PANEL_BORDER_DIM = '238';
17
+
12
18
  export interface SelectionInfo {
13
19
  isCellSelected: (col: number, absoluteRow: number) => boolean;
14
20
  scrollTop: number;
@@ -22,16 +28,18 @@ export interface SearchInfo {
22
28
  }
23
29
 
24
30
  export interface PanelCompositeData {
25
- /** Workspace-level tab bar spanning all open panels. */
31
+ /**
32
+ * The single consolidated workspace tab bar spanning all open panels across
33
+ * both panes. There are no per-pane tab bars — pane focus is shown by the
34
+ * accent border (see `topFocused`/`bottomFocused`).
35
+ */
26
36
  workspaceBar: Line;
27
- /** Top pane: tab bar */
28
- topTabBar?: Line;
29
37
  /** Top pane: panel content lines */
30
38
  topContent: Line[];
31
- /** Whether the top pane is focused (affects separator color) */
39
+ /** Whether the top pane is focused (drives the accent border) */
32
40
  topFocused: boolean;
33
- /** Bottom pane tab bar. Undefined = no bottom pane. */
34
- bottomTabBar?: Line;
41
+ /** Whether a bottom pane is present (splits the panel area). */
42
+ hasBottomPane: boolean;
35
43
  /** Bottom pane content lines. Undefined = no bottom pane. */
36
44
  bottomContent?: Line[];
37
45
  /** Whether the bottom pane is focused */
@@ -113,29 +121,31 @@ export class Compositor {
113
121
  const offset = Math.max(0, vHeight - lineCount);
114
122
 
115
123
  // --- Pre-compute panel row layout when split pane is active ---
116
- // When both top and bottom panes are visible, the panel area is split:
124
+ // A single consolidated workspace bar heads the panel area; there are no
125
+ // per-pane tab bars. When both panes are visible the layout is:
117
126
  // row 0: workspace tab bar
118
- // row 1: top tab bar
119
- // rows 2..topH+1: top content
120
- // row topH+2: horizontal separator (───)
121
- // row topH+3: bottom tab bar
122
- // rows topH+4..end: bottom content
123
- const hasBottomPane = hasPanel && panel!.bottomTabBar !== undefined;
127
+ // rows 1..topH: top content
128
+ // row topH+1: horizontal separator (───)
129
+ // rows topH+2..end: bottom content
130
+ const hasBottomPane = hasPanel && panel!.hasBottomPane;
124
131
  let topPaneHeight = 0; // number of content rows in top pane
125
- let bottomPaneHeight = 0;
126
132
  let hSepRow = -1; // viewport row of the horizontal separator
127
133
  if (hasPanel && hasBottomPane) {
128
- const panelAreaRows = Math.max(0, vHeight - 1); // subtract workspace tab bar
129
- // top: 1 (tabbar) + topContent rows; bottom: 1 (sep) + 1 (tabbar) + bottomContent
130
- const contentRows = Math.max(0, panelAreaRows - 3); // subtract top-tabbar + h-sep + bottom-tabbar
134
+ const panelAreaRows = Math.max(0, vHeight - 1); // subtract workspace bar
135
+ const contentRows = Math.max(0, panelAreaRows - 1); // subtract h-separator
131
136
  topPaneHeight = Math.max(1, Math.floor(contentRows * panel!.verticalSplitRatio));
132
- bottomPaneHeight = Math.max(1, contentRows - topPaneHeight);
133
- hSepRow = 2 + topPaneHeight; // workspace bar + top tab bar + top content rows
137
+ hSepRow = 1 + topPaneHeight; // workspace bar + top content rows
134
138
  }
135
139
 
136
- const sepFg = hasPanel && panel!.separator
137
- ? (panel!.topFocused || panel!.bottomFocused ? '244' : '238')
138
- : '238';
140
+ const panelFocused = hasPanel && (panel!.topFocused || panel!.bottomFocused);
141
+ // Per-row left-border color: the focused pane's rows get the accent tone.
142
+ const borderFgForRow = (i: number): string => {
143
+ if (!hasPanel || !panel!.separator || !panelFocused) return PANEL_BORDER_DIM;
144
+ if (!hasBottomPane) return panel!.topFocused ? PANEL_FOCUS_ACCENT : PANEL_BORDER_DIM;
145
+ if (i === 0) return PANEL_FOCUS_ACCENT; // workspace bar — panel is focused
146
+ if (i <= topPaneHeight) return panel!.topFocused ? PANEL_FOCUS_ACCENT : PANEL_BORDER_DIM;
147
+ return panel!.bottomFocused ? PANEL_FOCUS_ACCENT : PANEL_BORDER_DIM;
148
+ };
139
149
 
140
150
  viewport.forEach((line, i) => {
141
151
  const screenY = viewportStartY + i;
@@ -163,9 +173,10 @@ export class Compositor {
163
173
 
164
174
  const p = panel!;
165
175
 
166
- // Separator column (vertical bar between left and panel area)
176
+ // Separator column (vertical bar between left and panel area).
177
+ // Colored per-row so the focused pane shows a bright accent border.
167
178
  if (p.separator) {
168
- newBuffer.setCell(sepX, screenY, createStyledCell('│', { fg: sepFg }));
179
+ newBuffer.setCell(sepX, screenY, createStyledCell('│', { fg: borderFgForRow(i) }));
169
180
  }
170
181
 
171
182
  const panelStartX = sepX + 1;
@@ -195,39 +206,31 @@ export class Compositor {
195
206
  const panelLine = i === 0 ? p.workspaceBar : p.topContent[i - 1];
196
207
  drawPanelLine(panelLine);
197
208
  } else {
198
- // --- Two pane mode ---
209
+ // --- Two pane mode (single consolidated workspace bar) ---
199
210
  // Row layout (by viewport row i):
200
211
  // i = 0: workspace tab bar
201
- // i = 1: top tab bar
202
- // 2 <= i <= topPaneHeight+1: top content[i-2]
212
+ // 1 <= i <= topPaneHeight: top content[i-1]
203
213
  // i = hSepRow: horizontal separator
204
- // i = hSepRow+1: bottom tab bar
205
- // i >= hSepRow+2: bottom content[i - (hSepRow+2)]
214
+ // i >= hSepRow+1: bottom content[i - (hSepRow+1)]
206
215
  let panelLine: Line | undefined;
207
216
 
208
217
  if (i === 0) {
209
218
  panelLine = p.workspaceBar;
210
- } else if (i === 1) {
211
- panelLine = p.topTabBar;
212
- } else if (i <= topPaneHeight + 1) {
213
- panelLine = p.topContent[i - 2];
219
+ } else if (i <= topPaneHeight) {
220
+ panelLine = p.topContent[i - 1];
214
221
  } else if (i === hSepRow) {
215
- // Horizontal separator between the two panes
216
- // Render chars across the panel width
217
- const focusFg = p.bottomFocused ? '36' : '238'; // cyan if bottom pane focused
222
+ // Horizontal separator between the two panes. Accent when the bottom
223
+ // pane has focus so the divider reinforces the focus border.
224
+ const focusFg = p.bottomFocused ? PANEL_FOCUS_ACCENT : PANEL_BORDER_DIM;
218
225
  for (let x = 0; x < panelWidth; x++) {
219
226
  newBuffer.setCell(panelStartX + x, screenY, createStyledCell('─', { fg: focusFg }));
220
227
  }
221
- // Also update the separator column char to T-junction (├):
222
- // ├ connects the vertical left-separator with the horizontal pane divider,
223
- // forming a clean T-shaped joint at the split point.
228
+ // T-junction (├) joins the vertical left-border with the pane divider.
224
229
  if (p.separator) {
225
230
  newBuffer.setCell(sepX, screenY, createStyledCell('├', { fg: focusFg }));
226
231
  }
227
- } else if (i === hSepRow + 1) {
228
- panelLine = p.bottomTabBar;
229
232
  } else {
230
- panelLine = p.bottomContent?.[i - (hSepRow + 2)];
233
+ panelLine = p.bottomContent?.[i - (hSepRow + 1)];
231
234
  }
232
235
 
233
236
  if (i !== hSepRow) {
@@ -269,7 +272,7 @@ export class Compositor {
269
272
  for (let i = viewport.length; i < vHeight; i++) {
270
273
  const screenY = viewportStartY + i;
271
274
  if (screenY >= height) break;
272
- newBuffer.setCell(sepX, screenY, createStyledCell('│', { fg: sepFg }));
275
+ newBuffer.setCell(sepX, screenY, createStyledCell('│', { fg: borderFgForRow(i) }));
273
276
  }
274
277
  }
275
278
 
@@ -40,12 +40,21 @@ export function applyConversationOverlays(
40
40
  let next = viewport;
41
41
  const bottomDockInset = 1 + (input.searchManager.active || input.historySearch.active ? 1 : 0);
42
42
 
43
+ // Overlay posture rule: workspaces (onboarding, model, settings, mcp, help,
44
+ // shortcuts) claim the full viewport and are mutually exclusive with docked
45
+ // pickers/modals. Once a fullscreen workspace claims the screen, no docked
46
+ // overlay is drawn on top of it — enforcing a single visible overlay even if
47
+ // stray state survives the handler's clearModalStack. The input layer keeps
48
+ // the modal stack to one entry; this is the renderer-side backstop.
49
+ let fullscreenClaimed = false;
50
+
43
51
  if (input.onboardingWizard.active) {
44
52
  const lines = renderOnboardingWizard(input.onboardingWizard, conversationWidth, viewportHeight);
45
53
  next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
54
+ fullscreenClaimed = true;
46
55
  }
47
56
 
48
- if (input.filePicker.active) {
57
+ if (!fullscreenClaimed && input.filePicker.active) {
49
58
  const lines = renderFilePickerOverlay(input.filePicker, conversationWidth, viewportHeight);
50
59
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
51
60
  }
@@ -53,9 +62,10 @@ export function applyConversationOverlays(
53
62
  if (input.modelPicker.active) {
54
63
  const lines = renderModelWorkspace(input.modelPicker, conversationWidth, viewportHeight);
55
64
  next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
65
+ fullscreenClaimed = true;
56
66
  }
57
67
 
58
- if (input.selectionModal.active) {
68
+ if (!fullscreenClaimed && input.selectionModal.active) {
59
69
  const lines = renderSelectionModalOverlay(input.selectionModal, conversationWidth, viewportHeight);
60
70
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
61
71
  }
@@ -68,22 +78,22 @@ export function applyConversationOverlays(
68
78
  next.push(...renderHistorySearchOverlay(input.historySearch, conversationWidth));
69
79
  }
70
80
 
71
- if (input.processModal.active) {
81
+ if (!fullscreenClaimed && input.processModal.active) {
72
82
  const lines = renderProcessModal(input.processModal, conversationWidth, viewportHeight);
73
83
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
74
84
  }
75
85
 
76
- if (input.agentDetailModal.active) {
77
- const lines = renderAgentDetailModal(input.agentDetailModal, conversationWidth);
86
+ if (!fullscreenClaimed && input.agentDetailModal.active) {
87
+ const lines = renderAgentDetailModal(input.agentDetailModal, conversationWidth, viewportHeight);
78
88
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
79
89
  }
80
90
 
81
- if (input.liveTailModal.active) {
91
+ if (!fullscreenClaimed && input.liveTailModal.active) {
82
92
  const lines = renderLiveTailModal(input.liveTailModal, conversationWidth, viewportHeight);
83
93
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
84
94
  }
85
95
 
86
- if (input.contextInspectorModal.active) {
96
+ if (!fullscreenClaimed && input.contextInspectorModal.active) {
87
97
  const lines = renderContextInspector(conversation, conversationWidth, viewportHeight, contextWindow);
88
98
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
89
99
  }
@@ -91,24 +101,26 @@ export function applyConversationOverlays(
91
101
  if (input.settingsModal.active) {
92
102
  const lines = renderSettingsModal(input.settingsModal, conversationWidth, viewportHeight);
93
103
  next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
104
+ fullscreenClaimed = true;
94
105
  }
95
106
 
96
107
  if (input.mcpWorkspace.active) {
97
108
  const lines = renderMcpWorkspace(input.mcpWorkspace, conversationWidth, viewportHeight);
98
109
  next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
110
+ fullscreenClaimed = true;
99
111
  }
100
112
 
101
- if (input.sessionPickerModal.active) {
113
+ if (!fullscreenClaimed && input.sessionPickerModal.active) {
102
114
  const lines = renderSessionPickerModal(input.sessionPickerModal, conversationWidth, viewportHeight);
103
115
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
104
116
  }
105
117
 
106
- if (input.profilePickerModal.active) {
118
+ if (!fullscreenClaimed && input.profilePickerModal.active) {
107
119
  const lines = renderProfilePickerModal(input.profilePickerModal, conversationWidth, viewportHeight);
108
120
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
109
121
  }
110
122
 
111
- if (input.bookmarkModal.active) {
123
+ if (!fullscreenClaimed && input.bookmarkModal.active) {
112
124
  const lines = renderBookmarkModal(input.bookmarkModal, conversationWidth, viewportHeight);
113
125
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
114
126
  }
@@ -116,14 +128,16 @@ export function applyConversationOverlays(
116
128
  if (input.helpOverlayActive) {
117
129
  const lines = renderHelpOverlay(conversationWidth, keybindingsManager, commandRegistry.getAll(), input.helpScrollOffset, viewportHeight);
118
130
  next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
131
+ fullscreenClaimed = true;
119
132
  }
120
133
 
121
134
  if (input.shortcutsOverlayActive) {
122
135
  const lines = renderShortcutsOverlay(conversationWidth, keybindingsManager, input.shortcutsScrollOffset, viewportHeight);
123
136
  next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
137
+ fullscreenClaimed = true;
124
138
  }
125
139
 
126
- if (input.commandMode && input.autocomplete?.isActive) {
140
+ if (!fullscreenClaimed && input.commandMode && input.autocomplete?.isActive) {
127
141
  const lines = renderAutocompleteOverlay(input.autocomplete, conversationWidth, viewportHeight);
128
142
  if (lines.length > 0) {
129
143
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * footer-tips.ts — the persistent discoverability hint shown in the shell
3
+ * footer's tip slot.
4
+ *
5
+ * WO-151: instead of a single frozen "/help for commands" line, the footer
6
+ * surfaces a rotating set of the highest-value affordances (panels, process
7
+ * monitor, help, quit). Rotation is *contextual, not timed*: when an agent turn
8
+ * is actively running, the process-monitor tip is promoted to the front so the
9
+ * operator can jump to F2 while work is in flight. Selection is a pure function
10
+ * of context so the footer renders deterministically (golden frames stay
11
+ * stable) — no wall-clock input.
12
+ */
13
+
14
+ export interface FooterTipContext {
15
+ /** True while an agent turn is actively running (streaming / tools / hooks). */
16
+ readonly agentActive: boolean;
17
+ }
18
+
19
+ /** Composer status labels that mean an agent turn is actively in flight. */
20
+ const ACTIVE_TURN_STATUSES = new Set(['preflight', 'streaming', 'tools', 'post-hooks']);
21
+
22
+ /** Derive agent-active state from the composer status label passed to the footer. */
23
+ export function isAgentActive(composerStatus: string | undefined): boolean {
24
+ return composerStatus !== undefined && ACTIVE_TURN_STATUSES.has(composerStatus);
25
+ }
26
+
27
+ const TIP_PANELS = 'Ctrl+P panels';
28
+ const TIP_PROCESSES = 'F2 processes';
29
+ const TIP_HELP = '? help';
30
+ const TIP_QUIT = 'Ctrl+C quit';
31
+
32
+ /**
33
+ * Build the footer discoverability tip. Segments join with the shared middle
34
+ * dot. Agent-active state promotes the process-monitor tip to the front.
35
+ */
36
+ export function buildFooterTip(ctx: FooterTipContext): string {
37
+ const lead = ctx.agentActive
38
+ ? [TIP_PROCESSES, TIP_PANELS, TIP_HELP]
39
+ : [TIP_PANELS, TIP_PROCESSES, TIP_HELP];
40
+ return [...lead, TIP_QUIT].join(' · ');
41
+ }
@@ -1,24 +1,30 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine, createStyledCell } from '../types/grid.ts';
3
3
  import { getDisplayWidth } from '../utils/terminal-width.ts';
4
- import { GLYPHS, UI_TONES } from './ui-primitives.ts';
4
+ import { GLYPHS } from './ui-primitives.ts';
5
+ import { resolveUiTones } from './theme.ts';
6
+
7
+ // FULLSCREEN_PALETTE is built from the mode-resolved chrome tones
8
+ // (resolveUiTones) rather than the static UI_TONES constant — WO-001 single
9
+ // read path. Mode is fixed to 'dark' until the terminal-bg-probe lands.
10
+ const TONES = resolveUiTones('dark');
5
11
 
6
12
  export const FULLSCREEN_PALETTE = {
7
- border: '#64748b',
8
- title: '#67e8f9',
9
- subtitle: '#93c5fd',
10
- text: '#e2e8f0',
11
- muted: '#94a3b8',
12
- dim: '#64748b',
13
- selectedBg: '#223049',
14
- categoryBg: '#141b25',
15
- contextBg: '#121923',
16
- controlsBg: '#0f141d',
17
- footerBg: '#111827',
18
- good: UI_TONES.state.good,
19
- warn: UI_TONES.state.warn,
20
- bad: UI_TONES.state.bad,
21
- info: UI_TONES.state.info,
13
+ border: TONES.border,
14
+ title: TONES.accent.browser,
15
+ subtitle: TONES.accent.conversation,
16
+ text: TONES.fg.primary,
17
+ muted: TONES.fg.muted,
18
+ dim: TONES.border,
19
+ selectedBg: TONES.bg.selected,
20
+ categoryBg: TONES.bg.section,
21
+ contextBg: TONES.bg.surface,
22
+ controlsBg: TONES.bg.base,
23
+ footerBg: TONES.bg.footer,
24
+ good: TONES.state.good,
25
+ warn: TONES.state.warn,
26
+ bad: TONES.state.bad,
27
+ info: TONES.state.info,
22
28
  } as const;
23
29
 
24
30
  export type FullscreenTextStyle = Partial<Omit<Line[number], 'char'>>;
@@ -10,6 +10,7 @@ import type { SlashCommand } from '../input/command-registry.ts';
10
10
  import type { KeybindingsManager } from '../input/keybindings.ts';
11
11
  import { getOverlaySurfaceMetrics } from './overlay-viewport.ts';
12
12
  import { getVisibleWindow } from './surface-layout.ts';
13
+ import { formatHints } from './hint-grammar.ts';
13
14
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
14
15
 
15
16
  function toModalSections(rows: readonly string[]): import('./modal-factory.ts').ModalSection[] {
@@ -40,7 +41,28 @@ export function renderHelpOverlay(
40
41
  ): Line[] {
41
42
  const kb = (action: Parameters<typeof keybindingsManager.getComboLabel>[0]) => keybindingsManager.getComboLabel(action);
42
43
 
43
- const hasCommand = (name: string): boolean => Boolean(commands?.some((command) => command.name === name || (command.aliases ?? []).includes(name)));
44
+ const hasCommand = (name: string): boolean => {
45
+ if (!commands) return false;
46
+ for (const command of commands) {
47
+ // A broken plugin may expose a throwing `aliases` getter; skip it rather
48
+ // than crash the overlay (mirrors the Quick Start traversal guard below).
49
+ try {
50
+ if (command.name === name || (command.aliases ?? []).includes(name)) return true;
51
+ } catch { /* skip this command */ }
52
+ }
53
+ return false;
54
+ };
55
+
56
+ const shortcutLine = (label: string, desc: string): string => ` ${label.padEnd(20)} ${desc}`;
57
+
58
+ // Enumerate EVERY workspace/panel binding straight from the live keybindings
59
+ // table so each rebindable action (including user overrides) is discoverable
60
+ // here and stays in lockstep with /keybindings. Any action id prefixed
61
+ // `panel-` is a workspace affordance.
62
+ const panelBindingRows: string[] = keybindingsManager
63
+ .getAll()
64
+ .filter((entry) => entry.action.startsWith('panel-'))
65
+ .map((entry) => shortcutLine(keybindingsManager.getComboLabel(entry.action), entry.description));
44
66
 
45
67
  // Keyboard shortcut sections
46
68
  const shortcutRows: string[] = [
@@ -59,9 +81,20 @@ export function renderHelpOverlay(
59
81
  '',
60
82
  ' Overlays And Panels',
61
83
  ' ' + '\u2500'.repeat(40),
62
- ` ${'?'.padEnd(20)} Toggle help`,
63
- ` ${'/shortcuts'.padEnd(20)} Full keyboard shortcuts`,
64
- ` ${kb('panel-picker').padEnd(20)} Open or focus the panel workspace`,
84
+ shortcutLine('?', 'Toggle help'),
85
+ shortcutLine('/shortcuts', 'Full keyboard shortcuts'),
86
+ shortcutLine('Tab', 'Swap focus between input and workspace'),
87
+ ...panelBindingRows,
88
+ '',
89
+ // The shared in-panel contract every workspace panel honors. These are not
90
+ // rebindable global actions \u2014 they are the common controls a focused panel
91
+ // interprets \u2014 so they are documented here rather than pulled from getAll().
92
+ ' In-Panel Controls',
93
+ ' ' + '\u2500'.repeat(40),
94
+ shortcutLine('j / k', 'Move selection down / up'),
95
+ shortcutLine('g / G', 'Jump to top / bottom'),
96
+ shortcutLine('Enter / Esc', 'Activate / dismiss or leave panel'),
97
+ shortcutLine('/', 'Filter the list'),
65
98
  '',
66
99
  ];
67
100
 
@@ -109,7 +142,33 @@ export function renderHelpOverlay(
109
142
  }
110
143
  }
111
144
 
145
+ // Essentials \u2014 the handful of commands worth memorizing, listed first and
146
+ // filtered to what the live registry actually exposes.
147
+ const ESSENTIAL_COMMANDS: Array<[name: string, desc: string]> = [
148
+ ['model', 'Select provider or model'],
149
+ ['sessions', 'Browse and resume saved sessions'],
150
+ ['save', 'Save the current session'],
151
+ ['compact', 'Compact the conversation history'],
152
+ ['clear', 'Clear the conversation'],
153
+ ['keybindings', 'List and customize key bindings'],
154
+ ['panel', 'Open, focus, or manage panels'],
155
+ ];
156
+
112
157
  const commandRows: string[] = [];
158
+ // Track command names already surfaced in a curated group so the exhaustive
159
+ // list below does not repeat them.
160
+ const seen = new Set<string>();
161
+
162
+ const essentialRows: string[] = [];
163
+ for (const [name, desc] of ESSENTIAL_COMMANDS) {
164
+ if (!hasCommand(name)) continue;
165
+ seen.add(name);
166
+ essentialRows.push(` ${`/${name}`.padEnd(18)} ${desc}`);
167
+ }
168
+ if (essentialRows.length > 0) {
169
+ commandRows.push(' Essentials', ' ' + '\u2500'.repeat(40), ...essentialRows, '');
170
+ }
171
+
113
172
  if (quickStartRows.length > 0) {
114
173
  commandRows.push(' Quick Start', ' ' + '\u2500'.repeat(40), ...quickStartRows, '');
115
174
  }
@@ -117,18 +176,18 @@ export function renderHelpOverlay(
117
176
  if (commands && commands.length > 0) {
118
177
  commandRows.push('', ' Available Slash Commands', ' ' + '\u2500'.repeat(40));
119
178
  const preferred = ['setup', 'cockpit', 'settings', 'provider', 'subscription', 'marketplace', 'remote', 'sandbox', 'security', 'policy', 'incident', 'knowledge', 'hooks', 'orchestration', 'communication', 'tasks'];
120
- const seen = new Set<string>();
121
179
  for (const name of preferred) {
122
180
  const cmd = commands.find((entry) => entry.name === name);
123
- if (!cmd) continue;
181
+ if (!cmd || seen.has(cmd.name)) continue;
124
182
  seen.add(cmd.name);
125
183
  const nameCol = `/${cmd.name}`.padEnd(18);
126
184
  commandRows.push(` ${nameCol} ${cmd.description}`);
127
185
  }
186
+ // No command cap: the overlay windows and scrolls (getVisibleWindow below),
187
+ // so the full remaining registry is listed rather than truncated at 24.
128
188
  const remainder = [...commands]
129
189
  .filter((cmd) => !seen.has(cmd.name))
130
- .sort((a, b) => a.name.localeCompare(b.name))
131
- .slice(0, 24);
190
+ .sort((a, b) => a.name.localeCompare(b.name));
132
191
  if (remainder.length > 0) {
133
192
  commandRows.push('', ' More Commands', ' ' + '\u2500'.repeat(40));
134
193
  for (const cmd of remainder) {
@@ -163,15 +222,19 @@ export function renderHelpOverlay(
163
222
  width: metrics.boxWidth,
164
223
  margin: metrics.margin,
165
224
  targetContentRows: metrics.contentRows,
166
- tabs: [
167
- { label: 'Overview', active: true },
168
- { label: 'Commands' },
169
- ],
225
+ // Single tab: the old second 'Commands' tab had no switch handler (Left/
226
+ // Right did nothing), so it was a dead affordance. The overlay is one
227
+ // scrolling surface, so it advertises exactly one tab.
228
+ tabs: [{ label: 'Overview', active: true }],
170
229
  sections: toModalSections(visibleRows),
171
230
  helpers: allRows.length > maxVisible
172
231
  ? [{ content: `[${window.start + 1}-${Math.min(allRows.length, clampedOffset + visibleRows.length)} of ${allRows.length}]` }]
173
232
  : undefined,
174
- hints: ['? or Esc Close', 'Up/Down Scroll'],
233
+ hints: [formatHints([
234
+ { key: 'Up/Down', verb: 'Scroll' },
235
+ { key: '?', verb: 'Help' },
236
+ { key: 'Esc', verb: 'Close' },
237
+ ])],
175
238
  },
176
239
  width,
177
240
  );
@@ -236,6 +299,17 @@ export function renderShortcutsOverlay(
236
299
  row(kb('panel-picker'), 'Open / focus / hide panel workspace'),
237
300
  row(kb('panel-tab-next'), 'Next workspace panel tab'),
238
301
  row(kb('panel-tab-prev'), 'Previous workspace panel tab'),
302
+ row(`${kb('panel-tab-1')}\u2026${kb('panel-tab-9')}`, 'Jump to workspace tab 1-9'),
303
+ row(kb('panel-focus-toggle'), 'Swap focus between top / bottom pane'),
304
+ row(kb('panel-close'), 'Close active panel'),
305
+ row(kb('panel-close-all'), 'Close all panels'),
306
+ row(kb('panel-ops'), 'Open the Ops Control panel'),
307
+ '',
308
+ ' In-Panel Controls',
309
+ ' ' + '\u2500'.repeat(40),
310
+ row('j / k', 'Move selection down / up'),
311
+ row('g / G', 'Jump to top / bottom'),
312
+ row('/', 'Filter the list'),
239
313
  '',
240
314
  ` Config: /keybindings to list and customize`,
241
315
  ];
@@ -261,7 +335,10 @@ export function renderShortcutsOverlay(
261
335
  helpers: allRows.length > maxVisible
262
336
  ? [{ content: `[${window.start + 1}-${Math.min(allRows.length, clampedOffset + visibleRows.length)} of ${allRows.length}]` }]
263
337
  : undefined,
264
- hints: ['Esc Close', 'Up/Down Scroll'],
338
+ hints: [formatHints([
339
+ { key: 'Up/Down', verb: 'Scroll' },
340
+ { key: 'Esc', verb: 'Close' },
341
+ ])],
265
342
  },
266
343
  width,
267
344
  );
@@ -0,0 +1,52 @@
1
+ /**
2
+ * hint-grammar.ts — the single hint-bar grammar for every overlay footer.
3
+ *
4
+ * WO-151: one grammar across the model picker, settings modal, help/shortcuts
5
+ * overlays, and ModalFactory variants so footers stop drifting into three
6
+ * different dialects (verbless brackets, prose sentences, ad-hoc separators).
7
+ *
8
+ * The grammar is:
9
+ * - each hint renders as `[Key] Verb` (a bracketed key followed by its verb),
10
+ * - hints are joined by a middle-dot separator (` · `),
11
+ * - any Escape hint is always sorted last (the conventional "way out").
12
+ *
13
+ * State segments that are not key hints (e.g. `Filter: All`) can be appended
14
+ * verbatim via {@link joinHints}; they are never reordered.
15
+ */
16
+
17
+ /** A single key hint. `verb` may be omitted for a bare key affordance. */
18
+ export interface HintSpec {
19
+ key: string;
20
+ verb?: string;
21
+ }
22
+
23
+ /** The canonical separator between hint segments. */
24
+ export const HINT_SEPARATOR = ' · ';
25
+
26
+ function isEscapeHint(spec: HintSpec): boolean {
27
+ return spec.key.trim().toLowerCase() === 'esc';
28
+ }
29
+
30
+ /** Render one hint as `[Key] Verb` (or `[Key]` when it has no verb). */
31
+ export function formatHint(spec: HintSpec): string {
32
+ return spec.verb ? `[${spec.key}] ${spec.verb}` : `[${spec.key}]`;
33
+ }
34
+
35
+ /**
36
+ * Format a list of key hints into a single footer string using the shared
37
+ * grammar. Escape hints are moved to the end (stable for everything else).
38
+ */
39
+ export function formatHints(specs: readonly HintSpec[]): string {
40
+ // Stable partition: non-escape hints keep their order, escape hints trail.
41
+ const ordered = [...specs].sort((a, b) => Number(isEscapeHint(a)) - Number(isEscapeHint(b)));
42
+ return ordered.map(formatHint).join(HINT_SEPARATOR);
43
+ }
44
+
45
+ /**
46
+ * Join already-rendered segments (hint bars, state tags) with the shared
47
+ * separator, dropping empties. Order is preserved — use this to append state
48
+ * indicators after a {@link formatHints} bar.
49
+ */
50
+ export function joinHints(...segments: Array<string | null | undefined>): string {
51
+ return segments.filter((s): s is string => !!s && s.length > 0).join(HINT_SEPARATOR);
52
+ }
@@ -1,4 +1,4 @@
1
- import { GLYPHS } from './ui-primitives.ts';
1
+ import { GLYPHS, SPINNER_FRAMES, UI_TONES } from './ui-primitives.ts';
2
2
 
3
3
  /**
4
4
  * Layout constants — single source of truth for margins and content width.
@@ -14,19 +14,19 @@ export const LAYOUT = {
14
14
 
15
15
  export const TOOL_STATUS = {
16
16
  SUCCESS_ICON: GLYPHS.status.success,
17
- SPINNER_FRAMES: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
17
+ SPINNER_FRAMES,
18
18
  FAIL_ICON: GLYPHS.status.failure,
19
19
  PENDING_ICON: GLYPHS.status.pending,
20
20
  TOOL_NAME_PAD: 8,
21
21
  } as const;
22
22
 
23
23
  export const COLORS = {
24
- DIM_TEXT: '244',
24
+ DIM_TEXT: UI_TONES.fg.dim,
25
25
  } as const;
26
26
 
27
27
  export const BORDERS = {
28
- THINKING: { char: '▌', color: '#9945FF' },
29
- ERROR: { char: '▌', color: '#ef4444' },
30
- WARNING: { char: '▌', color: '#eab308' },
31
- INFO: { char: '▌', color: '#22d3ee' },
28
+ THINKING: { char: '▌', color: UI_TONES.state.reasoning },
29
+ ERROR: { char: '▌', color: UI_TONES.state.bad },
30
+ WARNING: { char: '▌', color: UI_TONES.state.warn },
31
+ INFO: { char: '▌', color: UI_TONES.state.info },
32
32
  } as const;