@pellux/goodvibes-tui 0.28.0 → 1.0.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 (144) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-line-cache.ts +432 -0
  5. package/src/core/conversation-rendering.ts +11 -3
  6. package/src/core/conversation.ts +50 -4
  7. package/src/input/commands/cost-runtime.ts +49 -0
  8. package/src/input/commands/operator-runtime.ts +5 -1
  9. package/src/input/commands.ts +2 -0
  10. package/src/input/handler-content-actions.ts +8 -3
  11. package/src/input/handler-feed-routes.ts +12 -35
  12. package/src/input/handler-feed.ts +4 -8
  13. package/src/input/handler-shortcuts.ts +51 -0
  14. package/src/input/handler.ts +43 -6
  15. package/src/input/input-history.ts +17 -4
  16. package/src/input/keybindings.ts +48 -8
  17. package/src/input/panel-integration-actions.ts +107 -1
  18. package/src/main.ts +19 -11
  19. package/src/panels/agent-inspector-panel.ts +125 -36
  20. package/src/panels/agent-inspector-shared.ts +144 -0
  21. package/src/panels/approval-panel.ts +67 -16
  22. package/src/panels/automation-control-panel.ts +368 -124
  23. package/src/panels/base-panel.ts +1 -1
  24. package/src/panels/builtin/agent.ts +28 -37
  25. package/src/panels/builtin/development.ts +40 -32
  26. package/src/panels/builtin/knowledge.ts +15 -6
  27. package/src/panels/builtin/operations.ts +178 -117
  28. package/src/panels/builtin/session.ts +35 -9
  29. package/src/panels/builtin/shared.ts +98 -6
  30. package/src/panels/cockpit-panel.ts +232 -73
  31. package/src/panels/communication-panel.ts +58 -20
  32. package/src/panels/confirm-state.ts +8 -1
  33. package/src/panels/control-plane-panel.ts +351 -118
  34. package/src/panels/cost-tracker-panel.ts +165 -7
  35. package/src/panels/debug-panel.ts +312 -159
  36. package/src/panels/diff-panel.ts +209 -57
  37. package/src/panels/docs-panel.ts +152 -66
  38. package/src/panels/eval-panel.ts +220 -42
  39. package/src/panels/file-explorer-panel.ts +202 -122
  40. package/src/panels/file-preview-panel.ts +132 -57
  41. package/src/panels/git-panel.ts +310 -129
  42. package/src/panels/hooks-panel.ts +150 -22
  43. package/src/panels/incident-review-panel.ts +223 -35
  44. package/src/panels/index.ts +0 -4
  45. package/src/panels/intelligence-panel.ts +212 -86
  46. package/src/panels/knowledge-graph-panel.ts +461 -101
  47. package/src/panels/local-auth-panel.ts +240 -28
  48. package/src/panels/marketplace-panel.ts +193 -26
  49. package/src/panels/memory-panel.ts +78 -77
  50. package/src/panels/ops-control-panel.ts +146 -29
  51. package/src/panels/ops-strategy-panel.ts +72 -4
  52. package/src/panels/orchestration-panel.ts +231 -69
  53. package/src/panels/panel-list-panel.ts +154 -131
  54. package/src/panels/panel-manager.ts +97 -9
  55. package/src/panels/plan-dashboard-panel.ts +333 -17
  56. package/src/panels/plugins-panel.ts +184 -29
  57. package/src/panels/policy-panel.ts +210 -38
  58. package/src/panels/polish-core.ts +7 -2
  59. package/src/panels/polish.ts +23 -4
  60. package/src/panels/project-planning-answer-actions.ts +134 -0
  61. package/src/panels/project-planning-panel.ts +62 -113
  62. package/src/panels/provider-health-panel.ts +434 -518
  63. package/src/panels/provider-health-routes.ts +203 -0
  64. package/src/panels/provider-health-tracker.ts +194 -6
  65. package/src/panels/provider-health-views.ts +560 -0
  66. package/src/panels/qr-panel.ts +116 -30
  67. package/src/panels/remote-panel.ts +114 -36
  68. package/src/panels/routes-panel.ts +63 -22
  69. package/src/panels/sandbox-panel.ts +174 -42
  70. package/src/panels/scrollable-list-panel.ts +19 -135
  71. package/src/panels/security-panel.ts +133 -33
  72. package/src/panels/services-panel.ts +116 -64
  73. package/src/panels/session-browser-panel.ts +73 -2
  74. package/src/panels/session-maintenance.ts +4 -122
  75. package/src/panels/settings-sync-panel.ts +335 -72
  76. package/src/panels/skills-panel.ts +157 -89
  77. package/src/panels/subscription-panel.ts +86 -33
  78. package/src/panels/symbol-outline-panel.ts +248 -108
  79. package/src/panels/system-messages-panel.ts +114 -13
  80. package/src/panels/tasks-panel.ts +326 -139
  81. package/src/panels/thinking-panel.ts +43 -10
  82. package/src/panels/token-budget-panel.ts +194 -18
  83. package/src/panels/tool-inspector-panel.ts +144 -34
  84. package/src/panels/types.ts +40 -4
  85. package/src/panels/work-plan-panel.ts +280 -17
  86. package/src/panels/worktree-panel.ts +175 -42
  87. package/src/panels/wrfc-panel.ts +116 -24
  88. package/src/renderer/agent-detail-modal.ts +37 -8
  89. package/src/renderer/code-block.ts +58 -28
  90. package/src/renderer/context-inspector.ts +3 -6
  91. package/src/renderer/conversation-overlays.ts +25 -11
  92. package/src/renderer/conversation-surface.ts +1 -21
  93. package/src/renderer/diff-view.ts +6 -4
  94. package/src/renderer/footer-tips.ts +41 -0
  95. package/src/renderer/fullscreen-primitives.ts +23 -17
  96. package/src/renderer/fullscreen-workspace.ts +2 -7
  97. package/src/renderer/help-overlay.ts +91 -14
  98. package/src/renderer/hint-grammar.ts +52 -0
  99. package/src/renderer/history-search-overlay.ts +10 -16
  100. package/src/renderer/layout.ts +7 -7
  101. package/src/renderer/markdown.ts +9 -1
  102. package/src/renderer/modal-factory.ts +23 -15
  103. package/src/renderer/model-picker-overlay.ts +8 -485
  104. package/src/renderer/model-workspace.ts +9 -24
  105. package/src/renderer/overlay-box.ts +23 -19
  106. package/src/renderer/process-indicator.ts +15 -25
  107. package/src/renderer/profile-picker-modal.ts +13 -14
  108. package/src/renderer/prompt-content-width.ts +16 -0
  109. package/src/renderer/search-overlay.ts +27 -6
  110. package/src/renderer/selection-modal-overlay.ts +3 -1
  111. package/src/renderer/semantic-diff.ts +1 -1
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal-helpers.ts +10 -34
  114. package/src/renderer/settings-modal.ts +70 -10
  115. package/src/renderer/shell-surface.ts +54 -15
  116. package/src/renderer/status-glyphs.ts +11 -9
  117. package/src/renderer/surface-layout.ts +0 -12
  118. package/src/renderer/term-caps.ts +1 -1
  119. package/src/renderer/theme.ts +60 -3
  120. package/src/renderer/tool-call.ts +6 -20
  121. package/src/renderer/ui-factory.ts +48 -37
  122. package/src/renderer/ui-primitives.ts +41 -3
  123. package/src/runtime/bootstrap-shell.ts +35 -18
  124. package/src/runtime/diagnostics/panels/index.ts +0 -3
  125. package/src/runtime/render-scheduler.ts +80 -0
  126. package/src/shell/ui-openers.ts +14 -22
  127. package/src/utils/format-duration.ts +2 -2
  128. package/src/utils/splash-lines.ts +54 -5
  129. package/src/version.ts +1 -1
  130. package/src/work-plans/work-plan-store.ts +13 -0
  131. package/src/panels/agent-logs-panel.ts +0 -635
  132. package/src/panels/agent-logs-shared.ts +0 -129
  133. package/src/panels/context-visualizer-panel.ts +0 -238
  134. package/src/panels/forensics-panel.ts +0 -378
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -255
  137. package/src/panels/provider-stats-panel.ts +0 -391
  138. package/src/panels/schedule-panel.ts +0 -365
  139. package/src/panels/watchers-panel.ts +0 -213
  140. package/src/renderer/file-tree.ts +0 -153
  141. package/src/renderer/progress.ts +0 -99
  142. package/src/renderer/status-token.ts +0 -67
  143. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  144. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -1,28 +1,16 @@
1
1
  import { type Line } from '../types/grid.ts';
2
2
  import { UIFactory } from './ui-factory.ts';
3
- import { getDisplayWidth } from '../utils/terminal-width.ts';
4
- import { GLYPHS } from './ui-primitives.ts';
5
-
6
- /** Truncate a string to fit within maxWidth display columns. */
7
- function truncateToWidth(text: string, maxWidth: number): string {
8
- let width = 0;
9
- let i = 0;
10
- for (const char of text) {
11
- const cw = getDisplayWidth(char);
12
- if (width + cw > maxWidth) break;
13
- width += cw;
14
- i += char.length;
15
- }
16
- return text.slice(0, i);
17
- }
3
+ import { truncateDisplay } from '../utils/terminal-width.ts';
4
+ import { GLYPHS, UI_TONES } from './ui-primitives.ts';
5
+ import { formatHints } from './hint-grammar.ts';
18
6
 
19
7
  /**
20
8
  * renderProcessIndicator — shows a one-line summary of active background
21
9
  * processes below the input area.
22
10
  *
23
11
  * Dimmed when no processes are active, highlighted (cyan) when agents or
24
- * background exec processes are running. Includes an `Enter to view` hint
25
- * when active.
12
+ * background exec processes are running. Includes an `[Enter] View` hint
13
+ * (hint-grammar bracket form) when active.
26
14
  */
27
15
  export function renderProcessIndicator(
28
16
  width: number,
@@ -38,10 +26,10 @@ export function renderProcessIndicator(
38
26
  const renderFocusedStatus = (text: string): Line[] => {
39
27
  const bg = '#31506f';
40
28
  const fg = '#eefaff';
41
- const markerFg = '#7dd3fc';
29
+ const markerFg = UI_TONES.accent.browser;
42
30
  const line = UIFactory.stringToLine(' '.repeat(width), width, { fg: '238' });
43
31
  const prefix = `${GLYPHS.navigation.selected} `;
44
- const body = truncateToWidth(text, Math.max(0, width - 8));
32
+ const body = truncateDisplay(text, Math.max(0, width - 8), '');
45
33
  const highlighted = ` ${prefix}${body} `;
46
34
  const startX = 2;
47
35
  for (let i = 0; i < highlighted.length && startX + i < width - 2; i++) {
@@ -62,8 +50,8 @@ export function renderProcessIndicator(
62
50
  if (agentCount > 0) parts.push(`${agentCount} agent${agentCount !== 1 ? 's' : ''}`);
63
51
  if (toolCount > 0) parts.push(`${toolCount} tool${toolCount !== 1 ? 's' : ''} running`);
64
52
  const label = total === 0
65
- ? `No background processes ${GLYPHS.status.pending} back to input`
66
- : `${parts.join(` ${GLYPHS.navigation.pipeSeparator} `)} ${GLYPHS.status.pending} Enter to open ${GLYPHS.status.pending} back to input`;
53
+ ? `No background processes ${formatHints([{ key: 'Esc', verb: 'Back to input' }])}`
54
+ : `${parts.join(` ${GLYPHS.navigation.pipeSeparator} `)} ${formatHints([{ key: 'Enter', verb: 'Open' }, { key: 'Esc', verb: 'Back to input' }])}`;
67
55
  return renderFocusedStatus(label);
68
56
  }
69
57
 
@@ -83,14 +71,16 @@ export function renderProcessIndicator(
83
71
  /**
84
72
  * Number of columns reserved for the agent count label and hint text.
85
73
  * Breakdown: "bg: N agents" prefix (~15 chars) + " | " separator (~3)
86
- * + " Enter to view " hint (~17) + padding (~8) ≈ 43 chars.
74
+ * + " [Enter] View " hint (~16) + padding (~9) ≈ 43 chars.
87
75
  */
88
76
  const PROGRESS_RESERVED_CHARS = 43;
89
77
  const progressMaxLen = Math.max(0, width - PROGRESS_RESERVED_CHARS); // reserve space for count + hint
78
+ // Truncate by display width (not JS string length) so wide/CJK glyphs in the
79
+ // agent progress text don't overflow the reserved budget.
90
80
  const progressSuffix = agentProgress && progressMaxLen > 10
91
- ? ` | ${agentProgress.length > progressMaxLen ? agentProgress.slice(0, Math.max(0, progressMaxLen - 3)) + '...' : agentProgress}`
81
+ ? ` | ${truncateDisplay(agentProgress, progressMaxLen, '...')}`
92
82
  : '';
93
83
  const label = `${parts.join(` ${GLYPHS.navigation.pipeSeparator} `)}${progressSuffix}`;
94
- const hint = ` ${GLYPHS.status.pending} Enter to view`;
95
- return renderPlainStatus(`${label}${hint}`, { fg: '#00ffff', bold: true });
84
+ const hint = ` ${formatHints([{ key: 'Enter', verb: 'View' }])}`;
85
+ return renderPlainStatus(`${label}${hint}`, { fg: UI_TONES.accent.brand, bold: true });
96
86
  }
@@ -3,7 +3,7 @@
3
3
  * using ModalFactory.
4
4
  *
5
5
  * Shows a list of saved profiles with:
6
- * - name, timestamp (formatted), settings preview
6
+ * - name, timestamp (formatted)
7
7
  * Footer hints: [Up/Down] Navigate [Enter] Load [d] Arm/Delete [s] Save current [Esc] Close
8
8
  */
9
9
 
@@ -55,18 +55,21 @@ export function renderProfilePickerModal(
55
55
  style: { fg: '240', dim: true },
56
56
  });
57
57
  } else {
58
- // Column widths: name(24) | timestamp(16) | preview(remaining)
59
- const nameW = 24;
60
- const tsW = 16;
61
- const previewW = Math.max(4, contentW - nameW - tsW - 4);
58
+ // Proportional column widths that adapt to the modal's content width:
59
+ // timestamp ~22% (clamped 10..16); the name column absorbs the remainder
60
+ // (ported from session-picker-modal.ts). Reserves 4 cols — 2 for the
61
+ // name/timestamp separator and 2 for the list row's selection indicator
62
+ // ("▸ ") that ModalFactory prepends outside the wrapped label — so the
63
+ // row never spills the timestamp's embedded space onto a wrapped line.
64
+ const tsW = Math.min(16, Math.max(10, Math.floor(contentW * 0.22)));
65
+ const nameW = Math.max(8, contentW - tsW - 4);
62
66
 
63
67
  // Column header
64
- const nameHdr = fitDisplay('Name', nameW);
65
- const tsHdr = fitDisplay('Saved', tsW);
66
- const previewHdr = fitDisplay('Settings', previewW);
68
+ const nameHdr = fitDisplay('Name', nameW);
69
+ const tsHdr = fitDisplay('Saved', tsW);
67
70
  sections.push({
68
71
  type: 'text',
69
- content: `${nameHdr} ${tsHdr} ${previewHdr}`,
72
+ content: `${nameHdr} ${tsHdr}`,
70
73
  style: { fg: '240', dim: true },
71
74
  });
72
75
  sections.push({ type: 'separator' });
@@ -79,11 +82,7 @@ export function renderProfilePickerModal(
79
82
 
80
83
  const tsStr = fitDisplay(formatTimestamp(prof.timestamp), tsW);
81
84
 
82
- // Read the profile file to get a preview of settings
83
- // (We only have name/timestamp in ProfileInfo, so show a placeholder)
84
- const preview = fitDisplay('(display/provider/behavior)', previewW);
85
-
86
- const label = `${nameStr} ${tsStr} ${preview}`;
85
+ const label = `${nameStr} ${tsStr}`;
87
86
  return { label, selected: isSelected };
88
87
  });
89
88
 
@@ -0,0 +1,16 @@
1
+ /**
2
+ * computePromptContentWidth — content width available for prompt text inside
3
+ * the footer input box, after the box margin, inner padding, and the ' > '
4
+ * prefix are subtracted.
5
+ *
6
+ * Floors at 1: on a hostile-narrow terminal the raw subtraction
7
+ * (boxWidth - 4 - 3) can go zero or negative, and a non-positive content
8
+ * width breaks downstream text-wrapping and cursor-position math (which
9
+ * assume at least one column to place a character in).
10
+ */
11
+ export function computePromptContentWidth(terminalColumns: number): number {
12
+ const w = terminalColumns || 80;
13
+ const boxMargin = 2;
14
+ const boxWidth = w - (boxMargin * 2);
15
+ return Math.max(1, boxWidth - 4 - 3); // minus padding (4) minus prefix width (3: ' > ')
16
+ }
@@ -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,
@@ -196,10 +196,12 @@ export function renderSelectionModalOverlay(
196
196
  : selectedItem?.primaryAction === 'delete'
197
197
  ? '[Enter] Delete'
198
198
  : '[Enter] Select';
199
- let hints = `[Up/Down] Navigate ${primaryVerb} [Esc] Close`;
199
+ let hints = `[Up/Down] Navigate ${primaryVerb}`;
200
200
  if (modal.allowSearch) hints += ' [/] Search';
201
201
  if (selectedItem?.primaryAction === 'toggle' && !selectedItem.actions) hints += ' [Space] Toggle';
202
202
  if (selectedItem?.actions) hints += ` ${selectedItem.actions}`;
203
+ // hint-grammar: Esc is the conventional "way out" and always sorts last.
204
+ hints += ' [Esc] Close';
203
205
  putText(
204
206
  footerLine,
205
207
  layout.margin + 2,
@@ -328,7 +328,7 @@ const CHANGE_GLYPH: Record<ChangeKind, string> = {
328
328
  * + import ./utils { A, B }
329
329
  * ~ import ./types (+NewType, -OldType)
330
330
  */
331
- export function formatSemanticDiff(diff: SemanticDiff): string[] {
331
+ function formatSemanticDiff(diff: SemanticDiff): string[] {
332
332
  const lines: string[] = [];
333
333
 
334
334
  // Symbol changes first
@@ -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);
@@ -7,6 +7,13 @@
7
7
  import type { SettingEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal-types.ts';
8
8
  import { SETTINGS_CATEGORIES } from '../input/settings-modal-types.ts';
9
9
  import { isSecretConfigKey, isSecretReferenceValue } from '../config/secret-config.ts';
10
+ import { UI_TONES } from './ui-primitives.ts';
11
+
12
+ /**
13
+ * "Modified / active" accent for settings rows — cyan-green, no UI_TONES
14
+ * role matches it (WO-207b: preserved byte-exact as a named constant).
15
+ */
16
+ const SETTINGS_ACCENT = '#00ffcc';
10
17
 
11
18
  function maskSecretValue(value: string): string {
12
19
  if (value.length === 0) return '(empty)';
@@ -25,43 +32,12 @@ export function formatValue(entry: SettingEntry): string {
25
32
  }
26
33
 
27
34
  export function valueColor(entry: SettingEntry): string {
28
- if (!entry.isDefault) return '#00ffcc'; // cyan-green = modified
29
- return '244'; // dim = default
35
+ if (!entry.isDefault) return SETTINGS_ACCENT; // cyan-green = modified
36
+ return '244'; // dim = default
30
37
  }
31
38
 
32
- export function flagStateColor(state: string, killed: boolean): string {
33
- if (killed) return '#ef4444'; // red
34
- if (state === 'enabled') return '#00ffcc'; // cyan-green
35
- return '244'; // dim
36
- }
37
39
 
38
- export function mcpTrustColor(mode: McpEntry['trustMode']): string {
39
- switch (mode) {
40
- case 'allow-all':
41
- return '#ef4444';
42
- case 'ask-on-risk':
43
- return '#eab308';
44
- case 'constrained':
45
- return '#00ffcc';
46
- case 'blocked':
47
- return '244';
48
- default:
49
- return '244';
50
- }
51
- }
52
40
 
53
- export function subscriptionStateColor(state: SubscriptionEntry['state']): string {
54
- switch (state) {
55
- case 'active':
56
- return '#00ffcc';
57
- case 'pending':
58
- return '#eab308';
59
- case 'available':
60
- return '#38bdf8';
61
- default:
62
- return '244';
63
- }
64
- }
65
41
 
66
42
  export function inferSubscriptionRouteReason(entry: SubscriptionEntry): string | undefined {
67
43
  if (entry.routeReason?.trim()) return entry.routeReason;
@@ -107,7 +83,7 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
107
83
  network: 'Network',
108
84
  };
109
85
 
110
- export const SETTING_LABELS: Partial<Record<string, string>> = {
86
+ const SETTING_LABELS: Partial<Record<string, string>> = {
111
87
  'ui.systemMessages': 'System Message Target',
112
88
  'ui.operationalMessages': 'Operational Message Target',
113
89
  'ui.wrfcMessages': 'WRFC Message Target',
@@ -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,15 +47,42 @@ 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;
57
+ // Compact posture drops the context-info line (and never shows the context
58
+ // bar or process indicator), leaving just: prompt-box top border, bottom
59
+ // border, token-usage line, and the help/exit line.
60
+ const COMPACT_FOOTER_BASE_ROWS = 4;
61
+
62
+ /**
63
+ * Real height of the most recently rendered footer, tagged with the compact
64
+ * posture it was rendered under. estimateShellFooterHeight prefers this so
65
+ * the pre-prompt viewport math accounts for the composer posture block and
66
+ * context hint that the static formula cannot see. Reset to null before any
67
+ * footer has rendered (cold start), where the formula is exact for the
68
+ * common posture-free, hint-free case. The compact flag is part of the cache
69
+ * key: a cached non-compact height must never answer a compact query (and
70
+ * vice versa), since the two postures differ by several rows.
71
+ */
72
+ let lastRenderedFooterHeight: { compact: boolean; height: number } | null = null;
47
73
 
48
74
  export function estimateShellFooterHeight(
49
75
  promptLineCount: number,
50
76
  contextWindow?: number,
77
+ compact = false,
51
78
  ): number {
79
+ if (lastRenderedFooterHeight !== null && lastRenderedFooterHeight.compact === compact) {
80
+ return lastRenderedFooterHeight.height;
81
+ }
52
82
  const safePromptLines = Math.max(1, promptLineCount);
83
+ if (compact) {
84
+ return COMPACT_FOOTER_BASE_ROWS + safePromptLines;
85
+ }
53
86
  const progressRows = contextWindow && contextWindow > 0 ? CONTEXT_PROGRESS_ROWS : 0;
54
87
  return FOOTER_BASE_ROWS + safePromptLines + progressRows + PROCESS_INDICATOR_ROWS;
55
88
  }
@@ -79,20 +112,26 @@ export function buildShellFooter(
79
112
  options.composerStatus,
80
113
  options.composerFlags,
81
114
  options.composerPendingRisk,
115
+ options.compact ?? false,
82
116
  );
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);
117
+ // Compact posture drops the process indicator and context hint entirely so
118
+ // the footer fits within ~5 rows on short terminals.
119
+ if (!options.compact) {
120
+ const processIndicator = renderProcessIndicator(
121
+ options.width,
122
+ options.runningAgentCount,
123
+ options.runningProcessCount,
124
+ options.indicatorFocused,
125
+ options.runningAgentProgress,
126
+ );
127
+ const inputBoxRows = Math.max(1, options.promptLineCount) + 2;
128
+ lines.splice(inputBoxRows, 0, ...processIndicator);
129
+ // Passive context status hint — rendered as a dim informational line before the prompt.
130
+ if (options.contextStatusHint) {
131
+ const hintLine = UIFactory.stringToLine(options.contextStatusHint, options.width, { fg: '#64748b' });
132
+ lines.unshift(hintLine);
133
+ }
96
134
  }
135
+ lastRenderedFooterHeight = { compact: options.compact ?? false, height: lines.length };
97
136
  return { lines, height: lines.length };
98
137
  }
@@ -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
  };
@@ -87,15 +87,3 @@ export function getTrackedVisibleWindow(
87
87
  }
88
88
  return { start, end, count: end - start, total };
89
89
  }
90
-
91
- export function sliceVisibleWindow<T>(
92
- items: readonly T[],
93
- selectedIndex: number,
94
- visibleCount: number,
95
- ): { readonly items: readonly T[]; readonly window: VisibleWindow } {
96
- const window = getVisibleWindow(items.length, selectedIndex, visibleCount);
97
- return {
98
- items: items.slice(window.start, window.end),
99
- window,
100
- };
101
- }
@@ -223,7 +223,7 @@ export function nearestAnsi16Fg(r: number, g: number, b: number): number {
223
223
  * Convert an ANSI16 fg code to the corresponding bg code.
224
224
  * fg 30-37 → bg 40-47; fg 90-97 → bg 100-107.
225
225
  */
226
- export function ansi16FgToBg(fgCode: number): number {
226
+ function ansi16FgToBg(fgCode: number): number {
227
227
  // Both ranges (30-37 and 90-97) shift by +10 to reach their bg equivalents
228
228
  // (30-37 → 40-47, 90-97 → 100-107).
229
229
  return fgCode + 10;
@@ -14,8 +14,19 @@
14
14
  * IMPORTANT: inline code has NO background token. The bg:#1a1a1a hardcode
15
15
  * that previously existed caused a near-black box on light terminals.
16
16
  * Differentiate inline code via inlineCodeFg + bold only; bg inherits terminal.
17
+ *
18
+ * resolveUiTones(mode) is the sibling read path for CHROME tokens (UI_TONES
19
+ * in ui-primitives.ts — panel/modal/overlay/fullscreen backgrounds, borders,
20
+ * status colours). It composes the same ThemeMode dimension so that
21
+ * DEFAULT_PANEL_PALETTE, DEFAULT_STYLE, FULLSCREEN_PALETTE and
22
+ * DEFAULT_OVERLAY_PALETTE all read through ONE mode-resolved path instead of
23
+ * importing the static UI_TONES constant directly. Mode stays 'dark'
24
+ * everywhere until the terminal-bg-probe lands (see WO-001) — the light
25
+ * entry is type-complete, not a shipped light theme.
17
26
  */
18
27
 
28
+ import { UI_TONES } from './ui-primitives.ts';
29
+
19
30
  /** Background mode — dark is the safe default until terminal-bg-probe lands. */
20
31
  export type ThemeMode = 'dark' | 'light';
21
32
 
@@ -78,9 +89,9 @@ const DARK: ThemeTokens = {
78
89
  searchCurrentFg: '#000000',
79
90
  strikethrough: '244',
80
91
  blockquote: '244',
81
- assistantHeader: '#22d3ee',
82
- reasoningAccent: '#a855f7',
83
- toolAccent: '#38bdf8',
92
+ assistantHeader: UI_TONES.accent.control,
93
+ reasoningAccent: UI_TONES.state.reasoning,
94
+ toolAccent: UI_TONES.state.info,
84
95
  collapsedBodyBg: '#1a1a1a',
85
96
  checkboxChecked: '#22c55e',
86
97
  errorBarBg: '#3a1a1a',
@@ -156,3 +167,49 @@ Object.freeze(LIGHT);
156
167
  * Frozen — do not mutate.
157
168
  */
158
169
  export const DARK_THEME: Readonly<ThemeTokens> = DARK;
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Chrome tokens (UI_TONES) — mode-resolved sibling to resolveTheme().
173
+ //
174
+ // UI_TONES (ui-primitives.ts) is the dark entry. The light entry mirrors
175
+ // dark for every role that has no light-appropriate equivalent yet — the
176
+ // WO-001 deliverable is the mode dimension and single read path, not a
177
+ // shipped light chrome theme (see module doc comment above).
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /** Recursively widen the `as const` literal leaves of UI_TONES to `string`
181
+ * so mode variants (e.g. UI_TONES_LIGHT) can assign different colour
182
+ * values without fighting TypeScript's literal-type inference. */
183
+ type DeepWidenToString<T> = T extends string ? string : { [K in keyof T]: DeepWidenToString<T[K]> };
184
+
185
+ export type UiToneTokens = DeepWidenToString<typeof UI_TONES>;
186
+
187
+ const UI_TONES_LIGHT: UiToneTokens = {
188
+ ...UI_TONES,
189
+ state: {
190
+ ...UI_TONES.state,
191
+ info: LIGHT.toolAccent,
192
+ reasoning: LIGHT.reasoningAccent,
193
+ },
194
+ accent: {
195
+ ...UI_TONES.accent,
196
+ brand: LIGHT.heading1,
197
+ gradientStart: LIGHT.heading1,
198
+ gradientEnd: LIGHT.reasoningAccent,
199
+ },
200
+ };
201
+
202
+ Object.freeze(UI_TONES_LIGHT.state);
203
+ Object.freeze(UI_TONES_LIGHT.accent);
204
+ Object.freeze(UI_TONES_LIGHT);
205
+
206
+ /**
207
+ * resolveUiTones — Return the chrome (panel/modal/overlay/fullscreen) token
208
+ * set for the given background mode. Single read path for UI_TONES; the
209
+ * 'dark' resolution is byte-identical to the UI_TONES constant.
210
+ *
211
+ * Call with 'dark' (the safe default) until terminal-bg-probe lands.
212
+ */
213
+ export function resolveUiTones(mode: ThemeMode): Readonly<UiToneTokens> {
214
+ return mode === 'light' ? UI_TONES_LIGHT : UI_TONES;
215
+ }