@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,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'>>;
@@ -101,7 +107,7 @@ export function drawHorizontalRule(line: Line, startX: number, endX: number, fg:
101
107
  }
102
108
  }
103
109
 
104
- export function clipDisplay(text: string, width: number): string {
110
+ function clipDisplay(text: string, width: number): string {
105
111
  if (width <= 0) return '';
106
112
  let used = 0;
107
113
  let output = '';
@@ -13,14 +13,9 @@ import {
13
13
  } from './fullscreen-primitives.ts';
14
14
 
15
15
  export {
16
- borderLine,
17
16
  clamp,
18
- contentLine,
19
- fillRange,
20
- makeLine,
21
17
  padDisplay,
22
18
  stableWindow,
23
- writeText,
24
19
  } from './fullscreen-primitives.ts';
25
20
  export { FULLSCREEN_PALETTE as WORKSPACE_PALETTE } from './fullscreen-primitives.ts';
26
21
 
@@ -63,12 +58,12 @@ export interface FullscreenWorkspaceMetrics {
63
58
  readonly controlRows: number;
64
59
  }
65
60
 
66
- export function drawVertical(line: Line, x: number, bg = ''): void {
61
+ function drawVertical(line: Line, x: number, bg = ''): void {
67
62
  if (x <= 0 || x >= line.length - 1) return;
68
63
  drawVerticalRule(line, x, WORKSPACE_PALETTE.border, bg);
69
64
  }
70
65
 
71
- export function drawHorizontalRange(line: Line, startX: number, endX: number, bg = ''): void {
66
+ function drawHorizontalRange(line: Line, startX: number, endX: number, bg = ''): void {
72
67
  drawHorizontalRule(line, Math.max(1, startX), Math.min(line.length - 2, endX), WORKSPACE_PALETTE.border, bg);
73
68
  }
74
69
 
@@ -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
+ 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,27 +1,15 @@
1
1
  import type { Line } from '../types/grid.ts';
2
- import { getDisplayWidth } from '../utils/terminal-width.ts';
2
+ import { fitDisplay, getDisplayWidth } from '../utils/terminal-width.ts';
3
3
  import type { HistorySearch } from '../input/input-history.ts';
4
4
  import { createBottomBarLine, writeBottomBarText } from './bottom-bar.ts';
5
+ import { formatHints } from './hint-grammar.ts';
5
6
 
6
7
  /**
7
8
  * Truncate `text` to at most `maxWidth` display columns, then pad with spaces
8
9
  * to exactly `maxWidth` columns. CJK/emoji wide characters count as 2 columns.
9
10
  */
10
11
  function truncateToWidth(text: string, maxWidth: number): string {
11
- let usedWidth = 0;
12
- let result = '';
13
- let i = 0;
14
- while (i < text.length) {
15
- const code = text.codePointAt(i)!;
16
- const charLen = code > 0xFFFF ? 2 : 1;
17
- const charWidth = getDisplayWidth(text.slice(i, i + charLen));
18
- if (usedWidth + charWidth > maxWidth) break;
19
- result += text.slice(i, i + charLen);
20
- usedWidth += charWidth;
21
- i += charLen;
22
- }
23
- // Pad to exactly maxWidth columns with spaces
24
- return result + ' '.repeat(maxWidth - usedWidth);
12
+ return fitDisplay(text, maxWidth, '');
25
13
  }
26
14
 
27
15
  /**
@@ -49,7 +37,13 @@ export function renderHistorySearchOverlay(
49
37
 
50
38
  // Build the display string
51
39
  const label = prefix + queryPart;
52
- const full = truncateToWidth(label + matchText, width);
40
+ const hints = formatHints([
41
+ { key: 'Ctrl+R/↑', verb: 'Older' },
42
+ { key: 'Ctrl+S/↓', verb: 'Newer' },
43
+ { key: 'Enter', verb: 'Accept' },
44
+ { key: 'Esc', verb: 'Cancel' },
45
+ ]);
46
+ const full = truncateToWidth(`${label}${matchText} ${hints}`, width);
53
47
 
54
48
  const line = createBottomBarLine(width, { fg: '#000000', bg: '#00ffcc' });
55
49
  writeBottomBarText(line, 0, width, full, { fg: '#000000', bg: '#00ffcc' });
@@ -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;
@@ -310,7 +310,15 @@ function renderTable(rows: string[], width: number, indent: number): Line[] {
310
310
  for (const ch of text) {
311
311
  const cw = getDisplayWidth(ch);
312
312
  if (w + cw > maxW) {
313
- // Truncate with ellipsis
313
+ // Truncate with ellipsis. If the last cell pushed is a wide-glyph
314
+ // placeholder (the empty second column of a 2-wide character),
315
+ // overwriting it in place would leave the wide glyph's first
316
+ // column dangling next to the ellipsis with no placeholder cell \u2014
317
+ // step back one more cell so the ellipsis replaces the actual
318
+ // glyph, not its placeholder.
319
+ if (cells.length > 0 && cells[cells.length - 1].char === '' && cells.length > 1) {
320
+ cells.pop();
321
+ }
314
322
  if (cells.length > 0) cells[cells.length - 1] = createStyledCell('\u2026', cells[cells.length - 1]);
315
323
  return cells;
316
324
  }
@@ -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