@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
@@ -8,7 +8,9 @@ import {
8
8
  putOverlayText,
9
9
  } from './overlay-box.ts';
10
10
  import { getOverlayMaxWidth } from './overlay-viewport.ts';
11
- import { GLYPHS, UI_TONES } from './ui-primitives.ts';
11
+ import { GLYPHS } from './ui-primitives.ts';
12
+ import { resolveUiTones } from './theme.ts';
13
+ import { HINT_SEPARATOR } from './hint-grammar.ts';
12
14
 
13
15
  // ── Helpers ──────────────────────────────────────────────────────────────────
14
16
 
@@ -90,19 +92,24 @@ export interface ModalConfig {
90
92
 
91
93
  // ── Defaults ─────────────────────────────────────────────────────────────────
92
94
 
95
+ // DEFAULT_STYLE is built from the mode-resolved chrome tones (resolveUiTones)
96
+ // rather than the static UI_TONES constant — WO-001 single read path. Mode is
97
+ // fixed to 'dark' until the terminal-bg-probe lands.
98
+ const TONES = resolveUiTones('dark');
99
+
93
100
  const DEFAULT_STYLE: Required<ModalStyle> = {
94
- titleFg: UI_TONES.fg.primary,
95
- borderFg: UI_TONES.fg.dim,
96
- hintFg: UI_TONES.fg.muted,
97
- selectedFg: UI_TONES.fg.primary,
98
- selectedBg: UI_TONES.bg.selected,
99
- textFg: UI_TONES.fg.primary,
100
- accentFg: UI_TONES.state.info,
101
- titleRowFg: UI_TONES.fg.secondary,
102
- titleBg: UI_TONES.bg.title,
103
- sectionBg: UI_TONES.bg.section,
104
- inputBg: UI_TONES.bg.input,
105
- surfaceBg: UI_TONES.bg.surface,
101
+ titleFg: TONES.fg.primary,
102
+ borderFg: TONES.fg.dim,
103
+ hintFg: TONES.fg.muted,
104
+ selectedFg: TONES.fg.primary,
105
+ selectedBg: TONES.bg.selected,
106
+ textFg: TONES.fg.primary,
107
+ accentFg: TONES.state.info,
108
+ titleRowFg: TONES.fg.secondary,
109
+ titleBg: TONES.bg.title,
110
+ sectionBg: TONES.bg.section,
111
+ inputBg: TONES.bg.input,
112
+ surfaceBg: TONES.bg.surface,
106
113
  };
107
114
 
108
115
  // ── ModalFactory ─────────────────────────────────────────────────────────────
@@ -182,9 +189,10 @@ export class ModalFactory {
182
189
  }
183
190
  }
184
191
 
185
- // Footer / hint bar
192
+ // Footer / hint bar. Hint segments join with the shared middle-dot grammar
193
+ // separator (WO-151) so every modal footer reads as one dialect.
186
194
  const hintStr = config.hints
187
- ? config.hints.join(' ')
195
+ ? config.hints.join(HINT_SEPARATOR)
188
196
  : (config.footer ?? '');
189
197
  lines.push(ModalFactory.renderHints(boxW, margin, hintStr, terminalWidth, style));
190
198
 
@@ -1,5 +1,6 @@
1
1
  import { type Line } from '../types/grid.ts';
2
2
  import { fitDisplay, getDisplayWidth, truncateDisplay } from '../utils/terminal-width.ts';
3
+ import { abbreviateCount } from '../utils/format-number.ts';
3
4
  import type { ModelPickerModal } from '../input/model-picker.ts';
4
5
  import { EFFORT_DESCRIPTIONS } from '@pellux/goodvibes-sdk/platform/providers';
5
6
  import { getQualityTier, getQualityTierFromScore } from '@pellux/goodvibes-sdk/platform/providers';
@@ -12,12 +13,11 @@ import {
12
13
  putOverlayText,
13
14
  } from './overlay-box.ts';
14
15
  import { getOverlaySurfaceMetrics } from './overlay-viewport.ts';
16
+ import { formatHints, type HintSpec } from './hint-grammar.ts';
15
17
 
16
18
  /** Format a context window number into a short human-readable string. */
17
19
  function fmtContext(n: number): string {
18
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
19
- if (n >= 1000) return `${Math.round(n / 1000)}k`;
20
- return String(n);
20
+ return abbreviateCount(n, { rounding: 'round', decimals: 0 });
21
21
  }
22
22
 
23
23
  /** Title text per picker mode. */
@@ -360,6 +360,12 @@ export function renderModelPickerOverlay(
360
360
  putRowText(promptRow, layout.margin + 2, contentW, fitDisplay(`${promptLabel} ${inputDisplay}`, contentW), '#ffffff');
361
361
  lines.push(promptRow);
362
362
 
363
+ if (picker.contextCapError) {
364
+ const errRow = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
365
+ putRowText(errRow, layout.margin + 2, contentW, fitDisplay(picker.contextCapError, contentW), '#ff6666');
366
+ lines.push(errRow);
367
+ }
368
+
363
369
  lines.push(createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg));
364
370
 
365
371
  const hintText = `Leave blank to use default (current: ${currentCtx}, source: ${provenance})`;
@@ -408,13 +414,26 @@ export function renderModelPickerOverlay(
408
414
  const groupByLabel = picker.groupBy ?? 'provider';
409
415
  const selectedModel = picker.mode === 'model' ? picker.getSelected() : null;
410
416
  const showContextCapHint = selectedModel != null && selectedModel.contextWindowProvenance !== undefined;
411
- const hints = picker.mode === 'model'
412
- ? showContextCapHint
413
- ? `[Up/Down] [Enter] [/] Search [Space] Ctx [Esc] [Tab] Filter: ${filterLabelFooter} [G] Group: ${groupByLabel}`
414
- : `[Up/Down] [Enter] [/] Search [Esc] [Tab] Filter: ${filterLabelFooter} [G] Group: ${groupByLabel}`
415
- : picker.mode === 'contextCap'
416
- ? '[Enter] Confirm [Esc] Cancel'
417
- : '[Up/Down] Nav [Enter] Select [Esc] Cancel';
417
+ let hints: string;
418
+ if (picker.mode === 'model') {
419
+ // The model-picker box caps at 72 cols, so the footer stays compact: the
420
+ // list nav (Up/Down/Enter) is the obvious interaction and lives in the
421
+ // /shortcuts overlay; the footer prioritizes the stateful controls. The
422
+ // live filter/group value is folded into each key's verb so it stays
423
+ // adjacent to its control and reads inside the width.
424
+ const modelHints: HintSpec[] = [{ key: '/', verb: 'Search' }];
425
+ if (showContextCapHint) modelHints.push({ key: 'Space', verb: 'Ctx' });
426
+ modelHints.push(
427
+ { key: 'Tab', verb: `Filter: ${filterLabelFooter}` },
428
+ { key: 'G', verb: `Group: ${groupByLabel}` },
429
+ { key: 'Esc', verb: 'Close' },
430
+ );
431
+ hints = formatHints(modelHints);
432
+ } else if (picker.mode === 'contextCap') {
433
+ hints = formatHints([{ key: 'Enter', verb: 'Confirm' }, { key: 'Esc', verb: 'Cancel' }]);
434
+ } else {
435
+ hints = formatHints([{ key: 'Up/Down', verb: 'Move' }, { key: 'Enter', verb: 'Select' }, { key: 'Esc', verb: 'Cancel' }]);
436
+ }
418
437
  const footerLine = createOverlayFilledBorderLine(width, layout, OVERLAY_GLYPHS.bottomLeft, OVERLAY_GLYPHS.horizontal, OVERLAY_GLYPHS.bottomRight, borderFg, DEFAULT_OVERLAY_PALETTE.sectionBg);
419
438
  putRowText(footerLine, layout.margin + 2, contentW, fitDisplay(truncateDisplay(hints, contentW), contentW), mutedFg, '', false, true);
420
439
  lines.push(footerLine);
@@ -458,7 +477,7 @@ function getRenderCacheKey(
458
477
  } else if (picker.mode === 'effort') {
459
478
  base.push(objectId(picker.effortLevels), picker.effortLevels.join('\u001f'), picker.pendingModel?.registryKey ?? picker.pendingModel?.id ?? '');
460
479
  } else if (picker.mode === 'contextCap') {
461
- base.push(picker.contextCapQuery, picker.contextCapPendingModel?.registryKey ?? picker.contextCapPendingModel?.id ?? '');
480
+ base.push(picker.contextCapQuery, picker.contextCapPendingModel?.registryKey ?? picker.contextCapPendingModel?.id ?? '', picker.contextCapError ?? '');
462
481
  }
463
482
 
464
483
  return base.join('\u001e');
@@ -11,6 +11,7 @@ import type { ModelPickerTargetInfo } from '../input/model-picker.ts';
11
11
  import type { Line } from '../types/grid.ts';
12
12
  import { createEmptyLine, createStyledCell } from '../types/grid.ts';
13
13
  import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
14
+ import { abbreviateCount } from '../utils/format-number.ts';
14
15
  import { GLYPHS, UI_TONES } from './ui-primitives.ts';
15
16
 
16
17
  const PALETTE = {
@@ -129,9 +130,7 @@ function stableWindow(total: number, selected: number, visible: number): { start
129
130
 
130
131
  function formatContext(value: number | undefined): string {
131
132
  if (!value) return '-';
132
- if (value >= 1_000_000) return `${Math.round(value / 1_000_000)}M`;
133
- if (value >= 1000) return `${Math.round(value / 1000)}k`;
134
- return String(value);
133
+ return abbreviateCount(value, { rounding: 'round', decimals: 0 });
135
134
  }
136
135
 
137
136
  function modelKey(model: ModelDefinition): string {
@@ -1,8 +1,14 @@
1
1
  import { createStyledCell, type Line } from '../types/grid.ts';
2
2
  import { getOverlayMaxWidth } from './overlay-viewport.ts';
3
- import { GLYPHS, UI_TONES } from './ui-primitives.ts';
3
+ import { GLYPHS } from './ui-primitives.ts';
4
+ import { resolveUiTones } from './theme.ts';
4
5
  import { fillWidth, makeLine, writeText } from './fullscreen-primitives.ts';
5
6
 
7
+ // DEFAULT_OVERLAY_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');
11
+
6
12
  export interface OverlayBoxPalette {
7
13
  readonly borderFg: string;
8
14
  readonly titleFg: string;
@@ -16,15 +22,15 @@ export interface OverlayBoxPalette {
16
22
  }
17
23
 
18
24
  export const DEFAULT_OVERLAY_PALETTE: Readonly<OverlayBoxPalette> = {
19
- borderFg: UI_TONES.fg.secondary,
20
- titleFg: UI_TONES.fg.primary,
21
- bodyFg: UI_TONES.fg.primary,
22
- mutedFg: UI_TONES.fg.dim,
23
- selectedBg: UI_TONES.bg.selected,
24
- titleBg: UI_TONES.bg.title,
25
- sectionBg: UI_TONES.bg.section,
26
- inputBg: UI_TONES.bg.input,
27
- bodyBg: UI_TONES.bg.surface,
25
+ borderFg: TONES.fg.secondary,
26
+ titleFg: TONES.fg.primary,
27
+ bodyFg: TONES.fg.primary,
28
+ mutedFg: TONES.fg.dim,
29
+ selectedBg: TONES.bg.selected,
30
+ titleBg: TONES.bg.title,
31
+ sectionBg: TONES.bg.section,
32
+ inputBg: TONES.bg.input,
33
+ bodyBg: TONES.bg.surface,
28
34
  } as const;
29
35
 
30
36
  export interface OverlayBoxLayout {
@@ -4,7 +4,6 @@ import type { InputHandler } from '../input/handler.ts';
4
4
  import type { PanelManager } from '../panels/panel-manager.ts';
5
5
  import type { PanelCompositeData } from './compositor.ts';
6
6
  import { createSplitPaneLayout } from './layout-engine.ts';
7
- import { renderPanelTabBar } from './panel-tab-bar.ts';
8
7
  import { renderPanelWorkspaceBar } from './panel-workspace-bar.ts';
9
8
 
10
9
  /**
@@ -108,33 +107,22 @@ export function buildPanelCompositeData(
108
107
  const workspaceBar = renderPanelWorkspaceBar(workspaceTabs, panelWidth, input.panelFocused);
109
108
 
110
109
  let topContent: Line[];
111
- let topTabBar: Line | undefined;
112
- let bottomTabBar: Line | undefined;
113
110
  let bottomContent: Line[] | undefined;
114
111
 
115
112
  const topActivePanel = topPane.panels[topPane.activeIndex] ?? null;
116
113
 
117
114
  if (hasBottom) {
118
- topTabBar = renderPanelTabBar(
119
- topPane.panels,
120
- topPane.activeIndex,
121
- panelWidth,
122
- input.panelFocused && focusedPane === 'top',
123
- 'top',
124
- );
125
- const paneLayout = createSplitPaneLayout(Math.max(0, panelHeight - 1), verticalSplitRatio);
115
+ // Single consolidated workspace bar (1 row) + h-separator (1 row); the rest
116
+ // splits between the two panes' content.
117
+ const paneLayout = createSplitPaneLayout(Math.max(0, panelHeight - 1), verticalSplitRatio, {
118
+ topTabRows: 0,
119
+ bottomTabRows: 0,
120
+ });
126
121
  const topH = paneLayout.topContentRows;
127
122
  const bottomH = paneLayout.bottomContentRows;
128
123
  topContent = topActivePanel ? renderPanel(topActivePanel, panelWidth, topH) : [];
129
124
 
130
125
  const bottomActivePanel = bottomPane.panels[bottomPane.activeIndex] ?? null;
131
- bottomTabBar = renderPanelTabBar(
132
- bottomPane.panels,
133
- bottomPane.activeIndex,
134
- panelWidth,
135
- input.panelFocused && focusedPane === 'bottom',
136
- 'bottom',
137
- );
138
126
  bottomContent = bottomActivePanel ? renderPanel(bottomActivePanel, panelWidth, bottomH) : [];
139
127
  } else {
140
128
  const topH = Math.max(0, panelHeight - 1);
@@ -144,10 +132,9 @@ export function buildPanelCompositeData(
144
132
  return {
145
133
  panelData: {
146
134
  workspaceBar,
147
- topTabBar,
148
135
  topContent,
149
136
  topFocused: input.panelFocused && focusedPane === 'top',
150
- bottomTabBar,
137
+ hasBottomPane: hasBottom,
151
138
  bottomContent,
152
139
  bottomFocused: input.panelFocused && focusedPane === 'bottom',
153
140
  separator: true,
@@ -1,21 +1,26 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import type { WorkspaceTab } from '../panels/panel-manager.ts';
3
- import { renderTabStrip } from './tab-strip.ts';
3
+ import { renderTabStrip, type TabHitRegion } from './tab-strip.ts';
4
+ import { UI_TONES } from './ui-primitives.ts';
4
5
 
5
- const ACTIVE_FG = '#e2e8f0';
6
- const ACTIVE_BG = '#0f172a';
7
- const INACTIVE_FG = '#94a3b8';
8
- const SEPARATOR_FG = '238';
9
- const LABEL_FG = '#cbd5e1';
10
- const LABEL_BG = '#1e293b';
6
+ // Theme tokens (no raw hex) — keeps the bar in sync with the shared palette.
7
+ const ACTIVE_FG = UI_TONES.fg.primary; // selected tab text
8
+ const ACTIVE_BG = UI_TONES.bg.selected; // selected tab fill (stronger contrast)
9
+ const INACTIVE_FG = UI_TONES.fg.muted; // unselected tabs
10
+ const SEPARATOR_FG = UI_TONES.fg.dim; // │ separators
11
+ const LABEL_FG = UI_TONES.fg.secondary; // PANELS prefix label
12
+ const LABEL_BG = UI_TONES.bg.title; // prefix label background when focused
13
+ const FOCUS_ACCENT_FG = UI_TONES.state.active; // the one focused tab
11
14
 
12
15
  export function renderPanelWorkspaceBar(
13
16
  tabs: readonly WorkspaceTab[],
14
17
  width: number,
15
18
  focused: boolean,
19
+ onLayout?: (regions: readonly TabHitRegion[]) => void,
16
20
  ): Line {
17
21
  return renderTabStrip({
18
22
  width,
23
+ onLayout,
19
24
  tabs: tabs.map((tab) => ({
20
25
  // tab.active = selected in its own pane (drives highlighted background).
21
26
  // tab.focused = has keyboard focus (drives brighter text / focus indicator).
@@ -32,7 +37,8 @@ export function renderPanelWorkspaceBar(
32
37
  activeBold: focused,
33
38
  inactiveFg: INACTIVE_FG,
34
39
  separatorFg: SEPARATOR_FG,
35
- labelFg: LABEL_FG,
40
+ // Accent the PANELS chip when focused so it ties to the focus border.
41
+ labelFg: focused ? FOCUS_ACCENT_FG : LABEL_FG,
36
42
  labelBg: focused ? LABEL_BG : '',
37
43
  labelBold: focused,
38
44
  overflowFg: SEPARATOR_FG,
@@ -87,8 +87,10 @@ export function renderProcessIndicator(
87
87
  */
88
88
  const PROGRESS_RESERVED_CHARS = 43;
89
89
  const progressMaxLen = Math.max(0, width - PROGRESS_RESERVED_CHARS); // reserve space for count + hint
90
+ // Truncate by display width (not JS string length) so wide/CJK glyphs in the
91
+ // agent progress text don't overflow the reserved budget.
90
92
  const progressSuffix = agentProgress && progressMaxLen > 10
91
- ? ` | ${agentProgress.length > progressMaxLen ? agentProgress.slice(0, Math.max(0, progressMaxLen - 3)) + '...' : agentProgress}`
93
+ ? ` | ${getDisplayWidth(agentProgress) > progressMaxLen ? truncateToWidth(agentProgress, Math.max(0, progressMaxLen - 3)) + '...' : agentProgress}`
92
94
  : '';
93
95
  const label = `${parts.join(` ${GLYPHS.navigation.pipeSeparator} `)}${progressSuffix}`;
94
96
  const hint = ` ${GLYPHS.status.pending} Enter to view`;
@@ -1,11 +1,13 @@
1
1
  import { type Line } from '../types/grid.ts';
2
2
  import { UIFactory } from './ui-factory.ts';
3
3
  import { getDisplayWidth, padDisplayEnd } from '../utils/terminal-width.ts';
4
+ import { abbreviateCount } from '../utils/format-number.ts';
5
+ import { SPINNER_FRAMES } from './ui-primitives.ts';
4
6
 
5
- // Rich spinner frames (used by progress indicators)
6
- export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
- // Braille thinking spinner frames (used by the orchestrator thinking animation)
8
- export const THINKING_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
+ // Rich spinner frames (used by progress indicators) — single source in ui-primitives.ts.
8
+ export { SPINNER_FRAMES };
9
+ // Braille thinking spinner frames (used by the orchestrator thinking animation) — same frame set.
10
+ export const THINKING_SPINNER_FRAMES = SPINNER_FRAMES;
9
11
 
10
12
  /**
11
13
  * renderSpinner - Render a spinner with label as a single Line.
@@ -51,8 +53,8 @@ export function renderTokenBar(
51
53
  const lines: Line[] = [];
52
54
 
53
55
  // Stats row
54
- const usedK = used >= 1000 ? `${(used / 1000).toFixed(1)}k` : String(used);
55
- const maxK = max >= 1000 ? `${(max / 1000).toFixed(1)}k` : String(max);
56
+ const usedK = abbreviateCount(used, { noM: true });
57
+ const maxK = abbreviateCount(max, { noM: true });
56
58
  const pct = max > 0 ? Math.min(100, Math.round((used / max) * 100)) : 0;
57
59
  const toolStr = toolCount > 0 ? ` tools:${toolCount}` : '';
58
60
  const statsText = ` ${model} in:${usedK}/${maxK} (${pct}%)${toolStr}`;
@@ -25,18 +25,39 @@ export function renderSearchOverlay(
25
25
  const locked = manager.locked;
26
26
  const cursor = locked ? '' : '█';
27
27
  const queryDisplay = manager.query + cursor;
28
- const hints = locked
28
+ const label = ' Find: ';
29
+ const fullHints = locked
29
30
  ? ' [n/N] next/prev [jk] navigate [Bksp] edit [Esc] close'
30
31
  : ' [Enter/Tab] lock [Esc] close';
31
- const label = ' Find: ';
32
- const matchStr = matchCount ? ` ${matchCount}` : '';
32
+ const shortHints = locked ? ' [n/N] [Esc]' : ' [Esc]';
33
33
 
34
34
  // Build left portion: label + query (no match count — that gets separate styling)
35
35
  const leftPart = label + queryDisplay;
36
- const hintsW = getDisplayWidth(hints);
36
+
37
+ // Tiered degradation: as the terminal narrows, first shorten the hints, then
38
+ // drop them, then drop the match-count segment — so the query area (leftWidth)
39
+ // never collapses to zero or negative and the overlay never renders garbage.
40
+ const minLeft = getDisplayWidth(label) + 4; // label + a few query cells
41
+ let hints = fullHints;
42
+ let matchStr = matchCount ? ` ${matchCount}` : '';
43
+ const leftFor = (h: string, m: string) => width - getDisplayWidth(h) - getDisplayWidth(m) - 2;
44
+ let leftWidth = leftFor(hints, matchStr);
45
+ if (leftWidth < minLeft) {
46
+ hints = shortHints;
47
+ leftWidth = leftFor(hints, matchStr);
48
+ }
49
+ if (leftWidth < minLeft) {
50
+ hints = '';
51
+ leftWidth = leftFor(hints, matchStr);
52
+ }
53
+ if (leftWidth < minLeft && matchStr.length > 0) {
54
+ matchStr = '';
55
+ leftWidth = leftFor(hints, matchStr);
56
+ }
57
+ // Final clamp: at least one cell, never wider than the bar.
58
+ leftWidth = Math.max(1, Math.min(width, leftWidth));
59
+
37
60
  const matchStrW = getDisplayWidth(matchStr);
38
- // Available width for left content (query area)
39
- const leftWidth = width - hintsW - matchStrW - 2;
40
61
  const truncatedLeft = fitDisplay(
41
62
  getDisplayWidth(leftPart) > leftWidth ? truncateDisplay(leftPart, leftWidth) : leftPart,
42
63
  leftWidth,
@@ -55,10 +55,12 @@ export function renderSessionPickerModal(
55
55
  style: { fg: '240', dim: true },
56
56
  });
57
57
  } else {
58
- // Column widths: name(24) | timestamp(16) | messages(remaining)
59
- const nameW = 24;
60
- const tsW = 16;
61
- const msgW = Math.max(4, contentW - nameW - tsW - 4); // 4 = separators/spaces
58
+ // Proportional column widths that adapt to the modal's content width:
59
+ // timestamp ~22% (clamped 10..16), messages ~12% (clamped 4..8), and the
60
+ // name column absorbs the remainder so the row always fills contentW.
61
+ const tsW = Math.min(16, Math.max(10, Math.floor(contentW * 0.22)));
62
+ const msgW = Math.min(8, Math.max(4, Math.floor(contentW * 0.12)));
63
+ const nameW = Math.max(8, contentW - tsW - msgW - 4); // 4 = separators/spaces
62
64
 
63
65
  // Column header
64
66
  const nameHdr = fitDisplay('Name', nameW);
@@ -12,6 +12,7 @@ import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
12
12
  import { CATEGORY_LABELS, describeUiRouting, formatValue, getSettingLabel, inferSubscriptionRouteReason, valueColor } from './settings-modal-helpers.ts';
13
13
  import { isSecretConfigKey } from '../config/secret-config.ts';
14
14
  import { GLYPHS } from './ui-primitives.ts';
15
+ import { formatHints, joinHints } from './hint-grammar.ts';
15
16
  import {
16
17
  clamp,
17
18
  getFullscreenWorkspaceMetrics,
@@ -540,24 +541,83 @@ function rowColorForSetting(modal: SettingsModal, rowText: string): string {
540
541
  }
541
542
 
542
543
  function footerText(modal: SettingsModal, width: number): string {
544
+ // Every branch speaks the shared hint grammar: bracketed [Key] Verb segments
545
+ // joined by the middle dot, with Esc sorted last. Leading words like
546
+ // "Focus settings" are state labels appended verbatim, not key hints.
543
547
  // Armed reset gate takes priority over all other footer states.
544
548
  if (modal.resetCategoryConfirm !== null || modal.resetAllConfirm !== null)
545
- return 'Reset armed · Enter/y confirm · Esc/n cancel';
546
- if (modal.searchFocused) return 'Search · type to filter · Up/Down navigate results · Enter select · Esc exit search';
547
- if (modal.editingMode) return 'Enter Confirm edit · Esc Cancel edit · text keys edit the selected field';
548
- if (modal.focusPane === 'categories') return 'Focus categories · Up/Down choose · Right/Enter settings · Tab pane · / search · Esc close';
549
- if (modal.currentCategory === 'subscriptions') return 'Focus settings · Up/Down provider · Left categories · Tab pane · / search · Enter review/sign out · Esc close';
550
- if (modal.currentCategory === 'mcp') return 'Focus settings · Up/Down server · Left categories · Tab pane · / search · Enter edit trust · Esc close';
551
- if (modal.currentCategory === 'flags') return 'Focus feature flags · Up/Down flag · Left categories · Tab pane · / search · Enter/Space toggle · Esc close';
549
+ return joinHints('Reset armed', formatHints([
550
+ { key: 'Enter/y', verb: 'confirm' },
551
+ { key: 'Esc/n', verb: 'cancel' },
552
+ ]));
553
+ if (modal.searchFocused)
554
+ return joinHints('Search', 'type to filter', formatHints([
555
+ { key: 'Up/Down', verb: 'Navigate' },
556
+ { key: 'Enter', verb: 'Select' },
557
+ { key: 'Esc', verb: 'Exit search' },
558
+ ]));
559
+ if (modal.editingMode)
560
+ return formatHints([
561
+ { key: 'Enter', verb: 'Confirm edit' },
562
+ { key: 'Esc', verb: 'Cancel edit' },
563
+ ]);
564
+ if (modal.focusPane === 'categories')
565
+ return joinHints('Focus categories', formatHints([
566
+ { key: 'Up/Down', verb: 'Choose' },
567
+ { key: 'Right/Enter', verb: 'Settings' },
568
+ { key: 'Tab', verb: 'Pane' },
569
+ { key: '/', verb: 'Search' },
570
+ { key: 'Esc', verb: 'Close' },
571
+ ]));
572
+ if (modal.currentCategory === 'subscriptions')
573
+ return joinHints('Focus settings', formatHints([
574
+ { key: 'Up/Down', verb: 'Provider' },
575
+ { key: 'Left', verb: 'Categories' },
576
+ { key: 'Tab', verb: 'Pane' },
577
+ { key: '/', verb: 'Search' },
578
+ { key: 'Enter', verb: 'Review/sign out' },
579
+ { key: 'Esc', verb: 'Close' },
580
+ ]));
581
+ if (modal.currentCategory === 'mcp')
582
+ return joinHints('Focus settings', formatHints([
583
+ { key: 'Up/Down', verb: 'Server' },
584
+ { key: 'Left', verb: 'Categories' },
585
+ { key: 'Tab', verb: 'Pane' },
586
+ { key: '/', verb: 'Search' },
587
+ { key: 'Enter', verb: 'Edit trust' },
588
+ { key: 'Esc', verb: 'Close' },
589
+ ]));
590
+ if (modal.currentCategory === 'flags')
591
+ return joinHints('Focus feature flags', formatHints([
592
+ { key: 'Up/Down', verb: 'Flag' },
593
+ { key: 'Left', verb: 'Categories' },
594
+ { key: 'Tab', verb: 'Pane' },
595
+ { key: '/', verb: 'Search' },
596
+ { key: 'Enter/Space', verb: 'Toggle' },
597
+ { key: 'Esc', verb: 'Close' },
598
+ ]));
552
599
  // Default settings pane: tier the reset affordances by available width.
553
600
  // W<80: minimal — only the most critical action survives.
554
601
  // W<160: compact but still shows both reset affordances.
555
602
  // W≥160: standard with all navigation tokens.
556
603
  if (width < 80)
557
- return 'R reset · Esc';
604
+ return formatHints([{ key: 'R', verb: 'reset' }, { key: 'Esc', verb: 'Close' }]);
558
605
  if (width < 160)
559
- return 'Up/Down · Enter/Space edit · ⇧R reset cat · ^⇧R reset all · Esc';
560
- return 'Focus settings · Up/Down setting · Left · Enter/Space edit/toggle · ⇧R reset cat · ^⇧R reset all · Esc close';
606
+ return formatHints([
607
+ { key: 'Up/Down', verb: 'Move' },
608
+ { key: 'Enter/Space', verb: 'Edit' },
609
+ { key: '⇧R', verb: 'reset cat' },
610
+ { key: '^⇧R', verb: 'reset all' },
611
+ { key: 'Esc', verb: 'Close' },
612
+ ]);
613
+ return joinHints('Focus settings', formatHints([
614
+ { key: 'Up/Down', verb: 'Setting' },
615
+ { key: 'Left', verb: 'Categories' },
616
+ { key: 'Enter/Space', verb: 'Edit/toggle' },
617
+ { key: '⇧R', verb: 'reset cat' },
618
+ { key: '^⇧R', verb: 'reset all' },
619
+ { key: 'Esc', verb: 'Close' },
620
+ ]));
561
621
  }
562
622
 
563
623
  export function renderSettingsModal(
@@ -34,6 +34,12 @@ export interface ShellFooterBuildOptions {
34
34
  * Rendered as a dim informational line above the prompt when non-null.
35
35
  */
36
36
  readonly contextStatusHint?: string | null;
37
+ /**
38
+ * Compact footer posture for short terminals (~<30 rows). Collapses the
39
+ * footer to its essentials (prompt box + token/cost line + help) and drops
40
+ * the process indicator, context bar, context-info line and posture block.
41
+ */
42
+ readonly compact?: boolean;
37
43
  }
38
44
 
39
45
  export interface ShellFooterBuildResult {
@@ -41,14 +47,28 @@ export interface ShellFooterBuildResult {
41
47
  readonly height: number;
42
48
  }
43
49
 
44
- const FOOTER_BASE_ROWS = 9;
45
- const CONTEXT_PROGRESS_ROWS = 2;
50
+ // Fixed rows that createFooter always emits (non-compact): prompt-box top
51
+ // border, prompt-box bottom border, token-usage line, context-info line, and
52
+ // the help/exit line. The composer posture block and context bar are counted
53
+ // separately (they are conditional).
54
+ const FOOTER_BASE_ROWS = 5;
55
+ const CONTEXT_PROGRESS_ROWS = 1;
46
56
  const PROCESS_INDICATOR_ROWS = 1;
47
57
 
58
+ /**
59
+ * Real height of the most recently rendered footer. estimateShellFooterHeight
60
+ * prefers this so the pre-prompt viewport math accounts for the composer
61
+ * posture block and context hint that the static formula cannot see. Reset to
62
+ * null before any footer has rendered (cold start), where the formula is exact
63
+ * for the common posture-free, hint-free case.
64
+ */
65
+ let lastRenderedFooterHeight: number | null = null;
66
+
48
67
  export function estimateShellFooterHeight(
49
68
  promptLineCount: number,
50
69
  contextWindow?: number,
51
70
  ): number {
71
+ if (lastRenderedFooterHeight !== null) return lastRenderedFooterHeight;
52
72
  const safePromptLines = Math.max(1, promptLineCount);
53
73
  const progressRows = contextWindow && contextWindow > 0 ? CONTEXT_PROGRESS_ROWS : 0;
54
74
  return FOOTER_BASE_ROWS + safePromptLines + progressRows + PROCESS_INDICATOR_ROWS;
@@ -79,20 +99,26 @@ export function buildShellFooter(
79
99
  options.composerStatus,
80
100
  options.composerFlags,
81
101
  options.composerPendingRisk,
102
+ options.compact ?? false,
82
103
  );
83
- const processIndicator = renderProcessIndicator(
84
- options.width,
85
- options.runningAgentCount,
86
- options.runningProcessCount,
87
- options.indicatorFocused,
88
- options.runningAgentProgress,
89
- );
90
- const inputBoxRows = Math.max(1, options.promptLineCount) + 2;
91
- lines.splice(inputBoxRows, 0, ...processIndicator);
92
- // Passive context status hint — rendered as a dim informational line before the prompt.
93
- if (options.contextStatusHint) {
94
- const hintLine = UIFactory.stringToLine(options.contextStatusHint, options.width, { fg: '#64748b' });
95
- lines.unshift(hintLine);
104
+ // Compact posture drops the process indicator and context hint entirely so
105
+ // the footer fits within ~5 rows on short terminals.
106
+ if (!options.compact) {
107
+ const processIndicator = renderProcessIndicator(
108
+ options.width,
109
+ options.runningAgentCount,
110
+ options.runningProcessCount,
111
+ options.indicatorFocused,
112
+ options.runningAgentProgress,
113
+ );
114
+ const inputBoxRows = Math.max(1, options.promptLineCount) + 2;
115
+ lines.splice(inputBoxRows, 0, ...processIndicator);
116
+ // Passive context status hint — rendered as a dim informational line before the prompt.
117
+ if (options.contextStatusHint) {
118
+ const hintLine = UIFactory.stringToLine(options.contextStatusHint, options.width, { fg: '#64748b' });
119
+ lines.unshift(hintLine);
120
+ }
96
121
  }
122
+ lastRenderedFooterHeight = lines.length;
97
123
  return { lines, height: lines.length };
98
124
  }
@@ -4,18 +4,20 @@
4
4
  // Extracted as a neutral module so both status-token.ts and polish.ts can
5
5
  // import from here without creating a circular ESM dependency.
6
6
  //
7
- // Glyphs:
8
- // good ✓ (CHECK MARK U+2713)
9
- // warn ⚠ (WARNING SIGN U+26A0)
10
- // bad ✕ (MULTIPLICATION X U+2715)
11
- // info ○ (WHITE CIRCLE U+25CB)
7
+ // STATE_GLYPHS is an alias into GLYPHS.status (ui-primitives.ts) — the single
8
+ // glyph registry. Previously GLYPHS.status.info ('•') collided with
9
+ // GLYPHS.status.pending, and STATE_GLYPHS.info ('○') collided with
10
+ // GLYPHS.status.idle. GLYPHS.status.info is now '○' (this module's historical
11
+ // value) and idle uses a distinct glyph ('◌').
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
+ import { GLYPHS } from './ui-primitives.ts';
15
+
14
16
  export type StatusState = 'good' | 'warn' | 'bad' | 'info';
15
17
 
16
18
  export const STATE_GLYPHS: Record<StatusState, string> = {
17
- good: '\u2713', // ✓
18
- warn: '\u26a0', // ⚠
19
- bad: '\u2715', // ✕
20
- info: '\u25cb', // ○
19
+ good: GLYPHS.status.success, // ✓
20
+ warn: GLYPHS.status.warn, // ⚠
21
+ bad: GLYPHS.status.failure, // ✕
22
+ info: GLYPHS.status.info, // ○
21
23
  };