@pellux/goodvibes-tui 0.19.53 → 0.19.55

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 (48) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +10 -13
  3. package/docs/foundation-artifacts/knowledge-store.sql +27 -0
  4. package/docs/foundation-artifacts/operator-contract.json +15736 -7265
  5. package/package.json +2 -2
  6. package/src/audio/spoken-turn-controller.ts +4 -1
  7. package/src/input/command-args-hint.ts +36 -0
  8. package/src/input/command-registry.ts +3 -1
  9. package/src/input/commands/config.ts +7 -521
  10. package/src/input/commands/knowledge.ts +111 -1
  11. package/src/input/commands/local-runtime.ts +0 -80
  12. package/src/input/commands/operator-runtime.ts +3 -3
  13. package/src/input/commands/planning-runtime.ts +83 -34
  14. package/src/input/commands/shell-core.ts +2 -34
  15. package/src/input/commands/tts-runtime.ts +1 -389
  16. package/src/input/commands.ts +0 -2
  17. package/src/input/handler-modal-routes.ts +61 -7
  18. package/src/input/handler-modal-token-routes.ts +1 -0
  19. package/src/input/handler-picker-routes.ts +50 -4
  20. package/src/input/model-picker-provider-filter.ts +28 -0
  21. package/src/input/model-picker-types.ts +12 -0
  22. package/src/input/model-picker.ts +65 -23
  23. package/src/input/selection-modal.ts +1 -1
  24. package/src/input/settings-modal-behavior.ts +2 -0
  25. package/src/input/settings-modal-subscriptions.ts +95 -0
  26. package/src/input/settings-modal-types.ts +50 -3
  27. package/src/input/settings-modal.ts +106 -134
  28. package/src/input/tts-settings-actions.ts +100 -0
  29. package/src/main.ts +50 -45
  30. package/src/panels/builtin/agent.ts +15 -0
  31. package/src/panels/builtin/shared.ts +17 -0
  32. package/src/panels/project-planning-panel.ts +370 -0
  33. package/src/planning/project-planning-coordinator.ts +249 -0
  34. package/src/renderer/compositor.ts +2 -1
  35. package/src/renderer/conversation-overlays.ts +4 -5
  36. package/src/renderer/model-workspace.ts +488 -0
  37. package/src/renderer/settings-modal-helpers.ts +16 -1
  38. package/src/renderer/settings-modal.ts +616 -716
  39. package/src/runtime/bootstrap-command-context.ts +6 -0
  40. package/src/runtime/bootstrap-command-parts.ts +5 -0
  41. package/src/runtime/bootstrap-shell.ts +2 -0
  42. package/src/runtime/services.ts +33 -2
  43. package/src/runtime/terminal-output-guard.ts +228 -0
  44. package/src/runtime/ui-services.ts +4 -0
  45. package/src/shell/ui-openers.ts +59 -3
  46. package/src/utils/clipboard.ts +2 -1
  47. package/src/version.ts +1 -1
  48. package/src/input/commands/permissions-runtime.ts +0 -104
@@ -1,757 +1,657 @@
1
1
  /**
2
- * renderSettingsModal renders the /settings config browser modal as Line[]
3
- * using ModalFactory.
2
+ * Fullscreen configuration workspace.
4
3
  *
5
- * Layout:
6
- * - Title bar: ┌─ Settings ───────────────────────────────────────┐
7
- * - Category tabs row
8
- * - Separator
9
- * - Settings list (current category)
10
- * - Footer hints: [Tab] Category [↑↓] Navigate [Enter] Edit/Toggle [Esc] Close
4
+ * This intentionally does not use ModalFactory. Configuration needs a stable,
5
+ * roomy workspace with contextual documentation, not a cramped modal list.
11
6
  */
12
7
 
13
8
  import type { Line } from '../types/grid.ts';
14
- import { ModalFactory } from './modal-factory.ts';
15
- import type { SettingsModal, SettingEntry, FlagEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal.ts';
9
+ import { createEmptyLine, createStyledCell } from '../types/grid.ts';
10
+ import type { SettingsModal, SettingEntry, FlagEntry, McpEntry, SubscriptionEntry, SettingsCategory } from '../input/settings-modal.ts';
16
11
  import { SETTINGS_CATEGORIES } from '../input/settings-modal.ts';
17
- import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
18
- import { getOverlaySurfaceMetrics, getStableOverlayContentRows } from './overlay-viewport.ts';
19
- import { getVisibleWindow } from './surface-layout.ts';
20
- import {
21
- formatValue,
22
- valueColor,
23
- flagStateColor,
24
- mcpTrustColor,
25
- subscriptionStateColor,
26
- inferSubscriptionRouteReason,
27
- CATEGORY_LABELS,
28
- getSettingLabel,
29
- describeUiRouting,
30
- } from './settings-modal-helpers.ts';
31
-
32
- // ---------------------------------------------------------------------------
33
- // Renderer
34
- // ---------------------------------------------------------------------------
12
+ import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
13
+ import { CATEGORY_LABELS, describeUiRouting, formatValue, getSettingLabel, inferSubscriptionRouteReason, valueColor } from './settings-modal-helpers.ts';
14
+ import { isSecretConfigKey } from '../config/secret-config.ts';
15
+ import { GLYPHS, UI_TONES } from './ui-primitives.ts';
16
+
17
+ const PALETTE = {
18
+ border: '#64748b',
19
+ title: '#67e8f9',
20
+ subtitle: '#93c5fd',
21
+ text: '#e2e8f0',
22
+ muted: '#94a3b8',
23
+ dim: '#64748b',
24
+ selectedBg: '#223049',
25
+ categoryBg: '#141b25',
26
+ contextBg: '#121923',
27
+ controlsBg: '#0f141d',
28
+ footerBg: '#111827',
29
+ good: UI_TONES.state.good,
30
+ warn: UI_TONES.state.warn,
31
+ bad: UI_TONES.state.bad,
32
+ info: UI_TONES.state.info,
33
+ };
34
+
35
+ const CATEGORY_INFO: Record<SettingsCategory, string> = {
36
+ display: 'Presentation settings for the terminal transcript: streaming, line numbers, thinking visibility, reasoning summaries, token speed, and tool previews.',
37
+ ui: 'Controls where operational messages render and whether voice surfaces are enabled. These settings change visibility, not provider behavior.',
38
+ provider: 'Default model routing for normal chat turns, embeddings, reasoning effort, and persistent system prompt file.',
39
+ subscriptions: 'Provider subscription login state and routing posture. Active sessions can be reviewed or signed out here; API keys remain managed through secrets.',
40
+ behavior: 'Day-to-day shell behavior: approval posture, compaction, history, guidance, notifications, stale-context warnings, return context, and Human-in-the-Loop mode.',
41
+ storage: 'Local storage posture, including secret storage policy and maximum artifact size for knowledge/home graph/document ingestion.',
42
+ permissions: 'Permission mode and tool-class policy. These settings decide whether the shell prompts before read/write/exec/network/agent actions.',
43
+ orchestration: 'Agent orchestration limits and recursion controls.',
44
+ wrfc: 'Work-review-fix-cycle thresholds, retry limits, and automatic commit behavior.',
45
+ helper: 'Helper model defaults used by helper subsystems when they do not use the main chat route.',
46
+ tts: 'Text-to-speech provider, voice, and optional spoken-turn LLM overrides.',
47
+ service: 'Background service posture: enabled state, autostart, restart behavior, service name, platform, and logs.',
48
+ controlPlane: 'Daemon control-plane settings for local admin/API access.',
49
+ httpListener: 'HTTP listener settings for webhook and integration ingress.',
50
+ web: 'Browser surface settings for the local or network web UI.',
51
+ batch: 'Batch execution settings, including local vs Cloudflare queue behavior.',
52
+ automation: 'Scheduled and automated run settings, concurrency, timeout, catch-up, cooldown, and retention behavior.',
53
+ watchers: 'File/process watcher heartbeat, polling, and recovery-window behavior.',
54
+ runtime: 'Runtime guardrails such as companion chat limiter and event bus listener caps.',
55
+ telemetry: 'Telemetry payload policy.',
56
+ cache: 'Provider and model cache behavior, TTL, and hit-rate monitoring.',
57
+ mcp: 'MCP server trust and scope review. Trust changes can expose local files, tools, databases, browsers, or remote automation depending on the server.',
58
+ sandbox: 'Isolation strategy for REPL, MCP, Windows, and QEMU-backed execution. Use these settings to separate risky tools from the host shell.',
59
+ surfaces: 'External app surfaces such as Slack, Discord, ntfy, Home Assistant, Telegram, webhooks, chat bridges, and messaging providers.',
60
+ cloudflare: 'Optional Cloudflare control plane, batch queue, Worker, Tunnel, Access, DNS, KV, Durable Objects, Secrets Store, and R2 settings.',
61
+ release: 'Release-channel preference.',
62
+ danger: 'High-impact switches for daemon and HTTP listener behavior. These are operational overrides, not normal preferences.',
63
+ tools: 'Tool LLM and helper model routing. Empty provider/model values inherit the active chat route unless a specific helper/tool route is set.',
64
+ flags: 'Feature flags are SDK runtime gates. They are separate from normal config keys because they enable or disable staged runtime behavior.',
65
+ network: 'Combined network view for daemon control-plane, HTTP listener, browser web surface, and general outbound network settings.',
66
+ };
67
+
68
+ const ENUM_VALUE_DESCRIPTIONS: Record<string, Record<string, string>> = {
69
+ 'behavior.hitlMode': {
70
+ quiet: 'Minimize operational interruptions and surface fewer Human-in-the-Loop prompts.',
71
+ balanced: 'Show important Human-in-the-Loop prompts without turning routine work into noise.',
72
+ operator: 'Surface more operational detail for users actively supervising agents, tools, services, and automation.',
73
+ },
74
+ 'behavior.guidanceMode': {
75
+ off: 'Do not add extra guidance beyond direct command output.',
76
+ minimal: 'Show concise guidance only when it helps avoid mistakes.',
77
+ guided: 'Provide more explanation and next-step context during configuration and operations.',
78
+ },
79
+ 'permissions.mode': {
80
+ prompt: 'Ask before powerful or risky actions according to tool policy.',
81
+ 'allow-all': 'Allow actions without prompting. This is fast but removes an important safety gate.',
82
+ custom: 'Use per-tool-class permission settings from the rows below.',
83
+ },
84
+ 'storage.secretPolicy': {
85
+ preferred_secure: 'Use secure secret storage when available, with supported fallback behavior.',
86
+ require_secure: 'Require secure secret storage and reject plaintext fallback.',
87
+ plaintext_allowed: 'Allow plaintext fallback when secure storage is unavailable.',
88
+ },
89
+ 'batch.mode': {
90
+ off: 'Keep daemon work on the immediate local path.',
91
+ explicit: 'Use batch only when callers explicitly request batch execution.',
92
+ 'eligible-by-default': 'Allow eligible daemon work to use the batch path unless callers opt out.',
93
+ },
94
+ 'controlPlane.hostMode': {
95
+ localhost: 'Bind only to this computer.',
96
+ network: 'Bind for LAN access using the default network host.',
97
+ custom: 'Use the explicit host value in the related host setting.',
98
+ },
99
+ 'httpListener.hostMode': {
100
+ localhost: 'Bind only to this computer.',
101
+ network: 'Bind for LAN/webhook access using the default network host.',
102
+ custom: 'Use the explicit host value in the related host setting.',
103
+ },
104
+ 'web.hostMode': {
105
+ localhost: 'Serve the browser UI only on this computer.',
106
+ network: 'Serve the browser UI on the LAN.',
107
+ custom: 'Use the explicit host value in the related host setting.',
108
+ },
109
+ 'ui.systemMessages': {
110
+ panel: 'Show system messages in panels only.',
111
+ conversation: 'Show system messages inline in the transcript.',
112
+ both: 'Show system messages in both panels and the transcript.',
113
+ },
114
+ 'ui.operationalMessages': {
115
+ panel: 'Show operational messages in panels only.',
116
+ conversation: 'Show operational messages inline in the transcript.',
117
+ both: 'Show operational messages in both panels and the transcript.',
118
+ },
119
+ 'ui.wrfcMessages': {
120
+ panel: 'Show WRFC messages in panels only.',
121
+ conversation: 'Show WRFC messages inline in the transcript.',
122
+ both: 'Show WRFC messages in both panels and the transcript.',
123
+ },
124
+ 'surfaces.telegram.mode': {
125
+ webhook: 'Receive Telegram updates through webhook delivery.',
126
+ polling: 'Poll Telegram for updates from the service.',
127
+ },
128
+ 'surfaces.whatsapp.provider': {
129
+ 'meta-cloud': 'Use Meta Cloud API credentials and identifiers.',
130
+ bridge: 'Use a bridge service URL/token flow instead of direct Meta Cloud API delivery.',
131
+ },
132
+ };
133
+
134
+ function clamp(value: number, min: number, max: number): number {
135
+ return Math.max(min, Math.min(max, value));
136
+ }
35
137
 
36
- /**
37
- * Render the settings modal as Line[] for overlay in the viewport.
38
- *
39
- * @param modal SettingsModal state object.
40
- * @param width Terminal width.
41
- */
42
- export function renderSettingsModal(
43
- modal: SettingsModal,
44
- width: number,
45
- viewportHeight = 24,
46
- ): Line[] {
47
- const metrics = getOverlaySurfaceMetrics(width, viewportHeight, {
48
- chromeRows: 8,
49
- minContentRows: 5,
50
- maxContentRows: 8,
51
- });
52
- const boxMargin = metrics.margin;
53
- const boxW = metrics.boxWidth;
54
- const contentW = metrics.contentWidth;
55
- const maxVisibleRows = metrics.contentRows;
56
- const targetContentRows = getStableOverlayContentRows(maxVisibleRows, 8);
57
-
58
- const sections: import('./modal-factory.ts').ModalSection[] = [];
59
-
60
- const isDangerTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'danger';
61
- const isMcpTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'mcp';
62
- const isSubscriptionsTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'subscriptions';
63
- const isFlagsTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'flags';
64
- const isUiTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'ui';
65
- const isToolsTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'tools';
66
- const isNetworkTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'network';
67
- const isSurfacesTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'surfaces';
68
- let persistentHelpers: import('./modal-factory.ts').ModalHelperRow[] | undefined;
69
- sections.push({
70
- type: 'text',
71
- content: isDangerTab
72
- ? 'High-impact configuration. Treat changes here as operational overrides, not everyday preferences.'
73
- : isMcpTab
74
- ? 'Review MCP role, trust, and scope. High-risk escalation is intentionally more explicit here.'
75
- : isSubscriptionsTab
76
- ? 'Manage provider login state and subscription-backed routing without dropping into raw config files.'
77
- : isUiTab
78
- ? 'Control shell presentation, including where operational and WRFC updates render across conversation and panels.'
79
- : isFlagsTab
80
- ? 'Feature flags control staged or experimental behavior. Some changes may require restart.'
81
- : isToolsTab
82
- ? 'Configure tool LLM routing and helper model. Provider and model fields are optional — empty means use the active provider.'
83
- : isSurfacesTab
84
- ? 'Configure external app surfaces. Toggle each Enabled setting to auto-start that surface when the service starts.'
85
- : isNetworkTab
86
- ? 'Configure control-plane and HTTP-listener binding. hostMode local/network use preset hosts; custom enables the host field. Changes trigger auto-restart.'
87
- : 'Browse and adjust operator-facing runtime settings by category.',
88
- style: { fg: '246', dim: true },
89
- });
90
-
91
- sections.push({ type: 'separator' });
92
-
93
- // ── Network tab restart notice ─────────────────────────────────
94
- if (isNetworkTab && modal.lastSaveTriggeredRestart !== null) {
95
- const restartTarget = modal.lastSaveTriggeredRestart === 'control-plane'
96
- ? 'control-plane server'
97
- : modal.lastSaveTriggeredRestart === 'http-listener'
98
- ? 'HTTP listener'
99
- : 'web server';
100
- sections.push({
101
- type: 'text',
102
- content: truncateDisplay(`Restarting ${restartTarget}… server will reconnect momentarily.`, contentW),
103
- style: { fg: '#38bdf8' },
138
+ function fillRange(line: Line, startX: number, endX: number, bg: string): void {
139
+ for (let x = Math.max(0, startX); x <= Math.min(line.length - 1, endX); x += 1) {
140
+ const cell = line[x] ?? createStyledCell(' ');
141
+ line[x] = createStyledCell(cell.char, {
142
+ fg: cell.fg,
143
+ bg,
144
+ bold: cell.bold,
145
+ dim: cell.dim,
146
+ underline: cell.underline,
147
+ italic: cell.italic,
148
+ strikethrough: cell.strikethrough,
149
+ link: cell.link,
104
150
  });
105
151
  }
152
+ }
153
+
154
+ function writeText(line: Line, startX: number, maxWidth: number, text: string, style: Partial<Omit<Line[number], 'char'>> = {}): void {
155
+ let x = startX;
156
+ let used = 0;
157
+ for (const ch of text) {
158
+ const width = getDisplayWidth(ch);
159
+ if (width <= 0) continue;
160
+ if (used + width > maxWidth || x >= line.length) break;
161
+ line[x] = createStyledCell(ch, style);
162
+ if (width > 1 && x + 1 < line.length) {
163
+ line[x + 1] = createStyledCell(' ', style);
164
+ }
165
+ x += width;
166
+ used += width;
167
+ }
168
+ }
106
169
 
107
- // ── Flags tab ──────────────────────────────────────────────────
108
- if (isFlagsTab) {
109
- const flagEntries: FlagEntry[] = modal.flagEntries;
170
+ function makeLine(width: number, bg = ''): Line {
171
+ const line = createEmptyLine(width);
172
+ if (bg) fillRange(line, 0, width - 1, bg);
173
+ return line;
174
+ }
110
175
 
111
- if (flagEntries.length === 0) {
112
- sections.push({
113
- type: 'text',
114
- content: '(no feature flags registered)',
115
- style: { fg: '240', dim: true },
116
- });
117
- } else {
118
- // Column widths for flags table
119
- const nameW = Math.floor(contentW * 0.30);
120
- const tierW = 5;
121
- const stateW = 10;
122
- const notesW = Math.max(0, contentW - nameW - tierW - stateW - 6);
123
-
124
- // Column header
125
- const nameHdr = 'Name'.padEnd(nameW);
126
- const tierHdr = 'Tier'.padEnd(tierW);
127
- const stateHdr = 'State'.padEnd(stateW);
128
- const notesHdr = 'Notes';
129
- sections.push({
130
- type: 'text',
131
- content: `${nameHdr} ${tierHdr} ${stateHdr} ${notesHdr}`,
132
- style: { fg: '240', dim: true },
133
- });
134
- sections.push({ type: 'separator' });
135
-
136
- const window = getVisibleWindow(flagEntries.length, modal.selectedIndex, maxVisibleRows);
137
- const visibleFlags = flagEntries.slice(window.start, window.end);
138
- const listItems: import('./modal-factory.ts').ModalListItem[] = visibleFlags.map((entry, idx) => {
139
- const isSelected = window.start + idx === modal.selectedIndex;
140
- const isKilled = entry.state === 'killed';
141
-
142
- const nameStr = entry.flag.name.length > nameW
143
- ? entry.flag.name.slice(0, nameW - 1) + '\u2026'
144
- : entry.flag.name.padEnd(nameW);
145
- const tierStr = String(entry.flag.tier).padEnd(tierW);
146
-
147
- let stateStr: string;
148
- if (isKilled) {
149
- stateStr = 'KILLED'.padEnd(stateW);
150
- } else {
151
- stateStr = entry.state.padEnd(stateW);
152
- }
153
-
154
- const notes = !entry.flag.runtimeToggleable && !isKilled ? '(restart required)' : '';
155
- const notesStr = notes.length > notesW ? notes.slice(0, notesW - 1) + '\u2026' : notes;
156
-
157
- const label = `${nameStr} ${tierStr} ${stateStr} ${notesStr}`;
158
-
159
- return {
160
- label,
161
- selected: isSelected,
162
- style: isSelected ? undefined : { fg: flagStateColor(entry.state, isKilled) },
163
- };
164
- });
176
+ function borderLine(width: number, left: string, fill: string, right: string): Line {
177
+ const line = makeLine(width);
178
+ if (width <= 0) return line;
179
+ line[0] = createStyledCell(left, { fg: PALETTE.border });
180
+ for (let x = 1; x < width - 1; x += 1) {
181
+ line[x] = createStyledCell(fill, { fg: PALETTE.border });
182
+ }
183
+ if (width > 1) line[width - 1] = createStyledCell(right, { fg: PALETTE.border });
184
+ return line;
185
+ }
165
186
 
166
- sections.push({ type: 'list', items: listItems });
167
- if (flagEntries.length > maxVisibleRows) {
168
- sections.push({
169
- type: 'text',
170
- content: `[${window.start + 1}-${window.end} of ${flagEntries.length}]`,
171
- style: { fg: '244', dim: true },
172
- });
173
- }
174
-
175
- // Description of selected flag
176
- const selected = modal.getSelectedFlag();
177
- if (selected) {
178
- sections.push({ type: 'separator' });
179
- const desc = selected.flag.description;
180
- const truncated = desc.length > contentW
181
- ? desc.slice(0, contentW - 1) + '\u2026'
182
- : desc;
183
- sections.push({
184
- type: 'text',
185
- content: truncated,
186
- style: { fg: '246', dim: true },
187
- });
188
- if (selected.state === 'killed' && selected.flag.killReason) {
189
- const killStr = `Kill reason: ${selected.flag.killReason}`;
190
- const killTrunc = killStr.length > contentW ? killStr.slice(0, contentW - 1) + '\u2026' : killStr;
191
- sections.push({
192
- type: 'text',
193
- content: killTrunc,
194
- style: { fg: '#ef4444', dim: true },
195
- });
196
- }
197
- }
198
- }
187
+ function contentLine(width: number, bg: string): Line {
188
+ const line = makeLine(width, bg);
189
+ if (width > 0) line[0] = createStyledCell(GLYPHS.frame.vertical, { fg: PALETTE.border });
190
+ if (width > 1) line[width - 1] = createStyledCell(GLYPHS.frame.vertical, { fg: PALETTE.border });
191
+ return line;
192
+ }
199
193
 
200
- const hints = ['[Tab] Category', '[\u2191\u2193] Navigate', '[←/→] Adjust', '[Enter] Toggle', '[Esc] Close'];
201
- return ModalFactory.createModal(
202
- {
203
- title: 'Settings',
204
- width: boxW,
205
- margin: boxMargin,
206
- targetContentRows,
207
- tabs: SETTINGS_CATEGORIES.map((category, index) => ({
208
- label: CATEGORY_LABELS[category],
209
- active: index === modal.categoryIndex,
210
- })),
211
- sections,
212
- hints,
213
- helpers: persistentHelpers,
214
- },
215
- width,
216
- );
194
+ function drawVertical(line: Line, x: number, bg = ''): void {
195
+ if (x <= 0 || x >= line.length - 1) return;
196
+ line[x] = createStyledCell(GLYPHS.frame.vertical, { fg: PALETTE.border, bg });
197
+ }
198
+
199
+ function drawHorizontalRange(line: Line, startX: number, endX: number, bg = ''): void {
200
+ for (let x = Math.max(1, startX); x <= Math.min(line.length - 2, endX); x += 1) {
201
+ line[x] = createStyledCell(GLYPHS.frame.horizontal, { fg: PALETTE.border, bg });
217
202
  }
203
+ }
218
204
 
219
- if (isMcpTab) {
220
- const mcpEntries = modal.mcpEntries;
205
+ function paddedWrapped(text: string, width: number, prefix = ''): string[] {
206
+ const safeWidth = Math.max(1, width - getDisplayWidth(prefix));
207
+ const wrapped = wrapText(text, safeWidth);
208
+ if (prefix.length === 0) return wrapped;
209
+ return wrapped.map((line, index) => `${index === 0 ? prefix : ' '.repeat(getDisplayWidth(prefix))}${line}`);
210
+ }
221
211
 
222
- if (mcpEntries.length === 0) {
223
- sections.push({
224
- type: 'text',
225
- content: '(no MCP servers registered)',
226
- style: { fg: '240', dim: true },
227
- });
228
- } else {
229
- const visibleRows = Math.max(1, maxVisibleRows - 4);
230
- const nameW = Math.floor(contentW * 0.28);
231
- const roleW = 12;
232
- const trustW = 13;
233
- const scopeW = Math.max(0, contentW - nameW - roleW - trustW - 6);
234
-
235
- sections.push({
236
- type: 'text',
237
- content: `${fitDisplay('Server', nameW)} ${fitDisplay('Role', roleW)} ${fitDisplay('Trust', trustW)} Scope`,
238
- style: { fg: '240', dim: true },
239
- });
240
- sections.push({ type: 'separator' });
241
-
242
- const window = getVisibleWindow(mcpEntries.length, modal.selectedIndex, visibleRows);
243
- const visibleMcpEntries = mcpEntries.slice(window.start, window.end);
244
- const listItems: import('./modal-factory.ts').ModalListItem[] = visibleMcpEntries.map((entry, idx) => {
245
- const isSelected = window.start + idx === modal.selectedIndex;
246
- const isEditing = isSelected && modal.editingMode;
247
- const trustValue = isEditing ? `${modal.editBuffer}\u2588` : entry.trustMode;
248
- const scopeValue = entry.allowedPaths.length > 0
249
- ? `paths:${entry.allowedPaths.length}`
250
- : entry.allowedHosts.length > 0
251
- ? `hosts:${entry.allowedHosts.length}`
252
- : 'unbounded';
253
- const label = `${fitDisplay(entry.name, nameW)} ${fitDisplay(entry.role, roleW)} ${fitDisplay(trustValue, trustW)} ${fitDisplay(scopeValue, scopeW)}`;
254
- return {
255
- label,
256
- selected: isSelected,
257
- style: isSelected ? undefined : { fg: mcpTrustColor(entry.trustMode) },
258
- };
259
- });
212
+ function clipDisplay(text: string, width: number): string {
213
+ if (width <= 0) return '';
214
+ let used = 0;
215
+ let output = '';
216
+ for (const ch of text) {
217
+ const chWidth = getDisplayWidth(ch);
218
+ if (chWidth <= 0) continue;
219
+ if (used + chWidth > width) break;
220
+ output += ch;
221
+ used += chWidth;
222
+ }
223
+ return output;
224
+ }
225
+
226
+ function padDisplay(text: string, width: number): string {
227
+ const clipped = clipDisplay(text, width);
228
+ return `${clipped}${' '.repeat(Math.max(0, width - getDisplayWidth(clipped)))}`;
229
+ }
230
+
231
+ function stableWindow(total: number, selectedIndex: number, visibleCount: number): { start: number; end: number } {
232
+ if (total <= 0 || visibleCount <= 0) return { start: 0, end: 0 };
233
+ if (total <= visibleCount) return { start: 0, end: total };
234
+ const selected = clamp(selectedIndex, 0, total - 1);
235
+ const half = Math.floor(visibleCount / 2);
236
+ const start = clamp(selected - half, 0, total - visibleCount);
237
+ return { start, end: start + visibleCount };
238
+ }
239
+
240
+ function formatDefaultValue(value: unknown): string {
241
+ if (value === '') return '(empty)';
242
+ if (value === null || value === undefined) return '(unset)';
243
+ return String(value);
244
+ }
260
245
 
261
- sections.push({ type: 'list', items: listItems });
262
- if (mcpEntries.length > visibleRows) {
263
- sections.push({
264
- type: 'text',
265
- content: `[${window.start + 1}-${window.end} of ${mcpEntries.length}]`,
266
- style: { fg: '244', dim: true },
267
- });
268
- }
269
-
270
- const selected = modal.getSelectedMcp();
271
- if (selected) {
272
- sections.push({ type: 'separator' });
273
- sections.push({
274
- type: 'text',
275
- content: `Trust ${selected.trustMode} for ${selected.name} (${selected.connected ? 'connected' : 'disconnected'}, role ${selected.role}).`,
276
- style: { fg: '246', dim: true },
277
- });
278
- const scope = selected.allowedPaths.length > 0
279
- ? `Path scope: ${selected.allowedPaths.join(', ')}`
280
- : selected.allowedHosts.length > 0
281
- ? `Host scope: ${selected.allowedHosts.join(', ')}`
282
- : 'No explicit path or host scope is configured.';
283
- sections.push({
284
- type: 'text',
285
- content: truncateDisplay(scope, contentW),
286
- style: { fg: '240', dim: true },
287
- });
288
- const guidance = modal.mcpAllowAllConfirmationTarget
289
- ? `Type ALLOW ALL ${modal.mcpAllowAllConfirmationTarget} to confirm unrestricted trust for this server.`
290
- : 'Press Enter to edit trust mode. Type constrained, ask-on-risk, allow-all, or blocked, then press Enter.';
291
- sections.push({
292
- type: 'text',
293
- content: truncateDisplay(guidance, contentW),
294
- style: { fg: modal.mcpAllowAllConfirmationTarget ? '#ef4444' : '#38bdf8', dim: true },
295
- });
296
- if (modal.mcpAllowAllConfirmationTarget) {
297
- persistentHelpers = [{ content: truncateDisplay(guidance, contentW), accent: true }];
298
- }
299
- }
246
+ function currentSettingValue(modal: SettingsModal, entry: SettingEntry, selected: boolean): string {
247
+ if (selected && modal.editingMode) return `${modal.editBuffer}${GLYPHS.surface.cursor}`;
248
+ return formatValue(entry);
249
+ }
250
+
251
+ function buildSettingContext(modal: SettingsModal, entry: SettingEntry): string[] {
252
+ const lines: string[] = [
253
+ getSettingLabel(entry),
254
+ `Key: ${entry.setting.key}`,
255
+ `Current: ${currentSettingValue(modal, entry, true)}`,
256
+ `Default: ${formatDefaultValue(entry.setting.default)}`,
257
+ `Type: ${entry.setting.type}${entry.setting.enumValues ? ` with ${entry.setting.enumValues.length} possible value(s)` : ''}`,
258
+ `Source: ${entry.effectiveSource ?? 'default'}${entry.sourceLabel ? ` from ${entry.sourceLabel}` : ''}`,
259
+ ];
260
+
261
+ if (entry.locked) lines.push(`Locked: ${entry.lockReason ?? 'This setting is locked by a higher-priority layer.'}`);
262
+ if (entry.conflict) lines.push(`Conflict: resolve with /settingssync resolve ${entry.setting.key} local|synced.`);
263
+
264
+ lines.push('', entry.setting.description);
265
+
266
+ if (
267
+ entry.setting.key === 'ui.systemMessages'
268
+ || entry.setting.key === 'ui.operationalMessages'
269
+ || entry.setting.key === 'ui.wrfcMessages'
270
+ ) {
271
+ lines.push(`Routing meaning: ${describeUiRouting(String(entry.currentValue))}.`);
272
+ }
273
+
274
+ if (entry.setting.type === 'boolean') {
275
+ lines.push('');
276
+ lines.push('Possible values:');
277
+ lines.push('true: enabled or allowed for this setting.');
278
+ lines.push('false: disabled or not allowed for this setting.');
279
+ }
280
+
281
+ if (entry.setting.type === 'enum' && entry.setting.enumValues) {
282
+ lines.push('');
283
+ lines.push('Possible values:');
284
+ const descriptions = ENUM_VALUE_DESCRIPTIONS[entry.setting.key] ?? {};
285
+ for (const value of entry.setting.enumValues) {
286
+ lines.push(`${value}: ${descriptions[value] ?? `Use ${value} for this setting.`}`);
300
287
  }
288
+ }
301
289
 
302
- const hints = modal.editingMode
303
- ? ['[Enter] Confirm', '[Esc] Cancel']
304
- : ['[Tab] Category', '[Up/Down] Navigate', '[←/→] Cycle Trust', '[Enter] Edit Trust', '[Esc] Close'];
305
-
306
- return ModalFactory.createModal(
307
- {
308
- title: 'Settings',
309
- width: boxW,
310
- margin: boxMargin,
311
- targetContentRows,
312
- tabs: SETTINGS_CATEGORIES.map((category, index) => ({
313
- label: CATEGORY_LABELS[category],
314
- active: index === modal.categoryIndex,
315
- })),
316
- sections,
317
- helpers: persistentHelpers,
318
- hints,
319
- },
320
- width,
321
- );
290
+ if (isSecretConfigKey(entry.setting.key)) {
291
+ lines.push('');
292
+ lines.push('Secret handling: raw values entered here are stored through the secret manager and the config receives a goodvibes:// secret reference. Empty input clears the config value.');
322
293
  }
323
294
 
324
- if (isSubscriptionsTab) {
325
- const subscriptionEntries = modal.subscriptionEntries;
295
+ if (entry.setting.type === 'number') {
296
+ lines.push('');
297
+ lines.push('Editing: Enter opens inline edit, then type the value and press Enter to save. Arrow keys only navigate.');
298
+ }
326
299
 
327
- if (subscriptionEntries.length === 0) {
328
- sections.push({
329
- type: 'text',
330
- content: '(no provider subscriptions available or configured)',
331
- style: { fg: '240', dim: true },
332
- });
333
- } else {
334
- const visibleRows = Math.max(1, maxVisibleRows - 4);
335
- const providerW = Math.floor(contentW * 0.28);
336
- const stateW = 12;
337
- const routeW = 14;
338
- const scopeW = Math.max(0, contentW - providerW - stateW - routeW - 6);
339
-
340
- sections.push({
341
- type: 'text',
342
- content: `${fitDisplay('Provider', providerW)} ${fitDisplay('State', stateW)} ${fitDisplay('Route', routeW)} Notes`,
343
- style: { fg: '240', dim: true },
344
- });
345
- sections.push({ type: 'separator' });
346
-
347
- const window = getVisibleWindow(subscriptionEntries.length, modal.selectedIndex, visibleRows);
348
- const visibleSubscriptions = subscriptionEntries.slice(window.start, window.end);
349
- const listItems: import('./modal-factory.ts').ModalListItem[] = visibleSubscriptions.map((entry, idx) => {
350
- const isSelected = window.start + idx === modal.selectedIndex;
351
- const routeReason = inferSubscriptionRouteReason(entry);
352
- const note = routeReason?.toLowerCase().includes('ambient key override')
353
- ? 'ambient key ov'
354
- : entry.state === 'active'
355
- ? entry.authFreshness === 'expiring'
356
- ? 'session nearing expiry'
357
- : entry.authFreshness === 'expired'
358
- ? 'stored session expired'
359
- : 'session active'
360
- : entry.state === 'pending'
361
- ? 'awaiting code exchange'
362
- : entry.oauthConfigured
363
- ? 'ready for login'
364
- : 'config required';
365
- const label = `${fitDisplay(entry.provider, providerW)} ${fitDisplay(entry.state, stateW)} ${fitDisplay(entry.activeRoute ?? 'n/a', routeW)} ${fitDisplay(note, scopeW)}`;
366
- return {
367
- label,
368
- selected: isSelected,
369
- style: isSelected ? undefined : { fg: subscriptionStateColor(entry.state) },
370
- };
371
- });
300
+ if (entry.setting.type === 'string' && !isSecretConfigKey(entry.setting.key)) {
301
+ lines.push('');
302
+ lines.push('Editing: Enter opens inline edit. Delete the current text to save an empty value when that is valid for the setting.');
303
+ }
372
304
 
373
- sections.push({ type: 'list', items: listItems });
374
- if (subscriptionEntries.length > visibleRows) {
375
- sections.push({
376
- type: 'text',
377
- content: `[${window.start + 1}-${window.end} of ${subscriptionEntries.length}]`,
378
- style: { fg: '244', dim: true },
379
- });
380
- }
381
-
382
- const selected = modal.getSelectedSubscription();
383
- if (selected) {
384
- sections.push({ type: 'separator' });
385
- const expires = selected.expiresAt ? new Date(selected.expiresAt).toISOString() : 'n/a';
386
- const routeReason = inferSubscriptionRouteReason(selected);
387
- sections.push({
388
- type: 'text',
389
- content: truncateDisplay(
390
- `${routeReason?.toLowerCase().includes('ambient key override') ? 'ambient key ov. ' : ''}Subscription ${selected.provider} is ${selected.state}. Active route is ${selected.activeRoute ?? 'n/a'} and preferred route is ${selected.preferredRoute ?? 'n/a'}. OAuth config is ${selected.oauthConfigured ? 'present' : 'missing'}.`.trim(),
391
- contentW,
392
- ),
393
- style: { fg: '246', dim: true },
394
- });
395
- sections.push({
396
- type: 'text',
397
- content: truncateDisplay(`Expires: ${expires} Freshness: ${selected.authFreshness ?? 'n/a'}`, contentW),
398
- style: { fg: '240', dim: true },
399
- });
400
- if (routeReason) {
401
- sections.push({
402
- type: 'text',
403
- content: truncateDisplay(routeReason, contentW),
404
- style: { fg: '240', dim: true },
405
- });
406
- }
407
- for (const issue of selected.issues ?? []) {
408
- sections.push({
409
- type: 'text',
410
- content: truncateDisplay(`Issue: ${issue}`, contentW),
411
- style: { fg: '#ef4444', dim: true },
412
- });
413
- }
414
- const guidance = selected.state === 'active' || selected.state === 'pending'
415
- ? modal.subscriptionLogoutConfirmationTarget === selected.provider
416
- ? `Press Enter again to sign out ${selected.provider}. Move selection or close settings to cancel.`
417
- : 'Press Enter to review sign-out for this provider session.'
418
- : 'Use /subscription login <provider> start to begin OAuth sign-in for this provider.';
419
- sections.push({
420
- type: 'text',
421
- content: truncateDisplay(guidance, contentW),
422
- style: { fg: selected.state === 'active' || selected.state === 'pending' ? '#f59e0b' : '#38bdf8', dim: true },
423
- });
424
- if ((selected.nextActions?.length ?? 0) > 0) {
425
- sections.push({
426
- type: 'text',
427
- content: truncateDisplay(`Next: ${selected.nextActions![0]}`, contentW),
428
- style: { fg: '#38bdf8', dim: true },
429
- });
430
- }
431
- if (modal.subscriptionLogoutConfirmationTarget === selected.provider) {
432
- persistentHelpers = [{ content: truncateDisplay(guidance, contentW), accent: true }];
433
- }
434
- }
435
- }
305
+ return lines;
306
+ }
307
+
308
+ function buildFlagContext(entry: FlagEntry | null): string[] {
309
+ if (!entry) return ['Feature flags', 'No feature flag is selected.'];
310
+ return [
311
+ entry.flag.name,
312
+ `ID: ${entry.flag.id}`,
313
+ `State: ${entry.state}`,
314
+ `Default: ${entry.flag.defaultState}`,
315
+ `Tier: ${entry.flag.tier}`,
316
+ `Runtime toggleable: ${entry.flag.runtimeToggleable ? 'yes' : 'no'}`,
317
+ '',
318
+ entry.flag.description,
319
+ ...(entry.state === 'killed' && entry.flag.killReason ? ['', `Kill reason: ${entry.flag.killReason}`] : []),
320
+ '',
321
+ entry.flag.runtimeToggleable
322
+ ? 'Impact: changes apply immediately and are also persisted as an override when they differ from the default.'
323
+ : 'Impact: this flag is persisted as an override and requires restart before startup-only code sees the new state.',
324
+ ];
325
+ }
326
+
327
+ function buildMcpContext(modal: SettingsModal, entry: McpEntry | null): string[] {
328
+ if (!entry) return ['MCP trust', 'No MCP server is selected.'];
329
+ const scope = entry.allowedPaths.length > 0
330
+ ? `Allowed paths: ${entry.allowedPaths.join(', ')}`
331
+ : entry.allowedHosts.length > 0
332
+ ? `Allowed hosts: ${entry.allowedHosts.join(', ')}`
333
+ : 'No explicit path or host scope is configured.';
334
+ const confirmation = modal.mcpAllowAllConfirmationTarget === entry.name
335
+ ? `Confirmation required: type ALLOW ALL ${entry.name} to grant unrestricted trust.`
336
+ : 'Enter edits the trust mode. Valid values are constrained, ask-on-risk, allow-all, and blocked.';
337
+ return [
338
+ entry.name,
339
+ `Connection: ${entry.connected ? 'connected' : 'disconnected'}`,
340
+ `Role: ${entry.role}`,
341
+ `Trust mode: ${entry.trustMode}`,
342
+ confirmation,
343
+ '',
344
+ scope,
345
+ '',
346
+ 'Trust meanings:',
347
+ 'constrained: keep MCP activity inside declared paths/hosts and prompt on risk.',
348
+ 'ask-on-risk: allow routine MCP operations but ask before risky behavior.',
349
+ 'allow-all: allow unrestricted MCP operations for this server after explicit confirmation.',
350
+ 'blocked: prevent this MCP server from being used.',
351
+ ];
352
+ }
436
353
 
437
- const hints = ['[Tab] Category', '[Up/Down] Navigate', '[Enter] Review / Confirm', '[Esc] Close'];
438
- return ModalFactory.createModal(
439
- {
440
- title: 'Settings',
441
- width: boxW,
442
- margin: boxMargin,
443
- targetContentRows,
444
- tabs: SETTINGS_CATEGORIES.map((category, index) => ({
445
- label: CATEGORY_LABELS[category],
446
- active: index === modal.categoryIndex,
447
- })),
448
- sections,
449
- hints,
450
- helpers: persistentHelpers,
451
- },
452
- width,
453
- );
354
+ function buildSubscriptionContext(modal: SettingsModal, entry: SubscriptionEntry | null): string[] {
355
+ if (!entry) return ['Subscriptions', 'No subscription provider is selected.'];
356
+ const expires = entry.expiresAt ? new Date(entry.expiresAt).toISOString() : 'not reported';
357
+ const routeReason = inferSubscriptionRouteReason(entry);
358
+ const logout = entry.state === 'active' || entry.state === 'pending'
359
+ ? modal.subscriptionLogoutConfirmationTarget === entry.provider
360
+ ? `Press Enter again to sign out ${entry.provider}. Move selection or close config to cancel.`
361
+ : 'Press Enter to review sign-out for this provider session.'
362
+ : `Use /subscription login ${entry.provider} start to begin OAuth sign-in for this provider.`;
363
+ return [
364
+ entry.provider,
365
+ `State: ${entry.state}`,
366
+ ...(routeReason ? [routeReason] : []),
367
+ logout,
368
+ `Active route: ${entry.activeRoute ?? 'n/a'}`,
369
+ `Preferred route: ${entry.preferredRoute ?? 'n/a'}`,
370
+ `OAuth configured: ${entry.oauthConfigured ? 'yes' : 'no'}`,
371
+ `Freshness: ${entry.authFreshness ?? 'n/a'}`,
372
+ `Expires: ${expires}`,
373
+ ...((entry.issues ?? []).length > 0 ? ['', 'Issues:', ...(entry.issues ?? [])] : []),
374
+ ...((entry.nextActions ?? []).length > 0 ? ['', 'Next actions:', ...(entry.nextActions ?? [])] : []),
375
+ ];
376
+ }
377
+
378
+ function buildContextLines(modal: SettingsModal, width: number): string[] {
379
+ const category = modal.currentCategory;
380
+ const lines: string[] = [
381
+ `${CATEGORY_LABELS[category]} configuration`,
382
+ ];
383
+
384
+ if (category === 'flags') {
385
+ lines.push(...buildFlagContext(modal.getSelectedFlag()));
386
+ } else if (category === 'mcp') {
387
+ lines.push(...buildMcpContext(modal, modal.getSelectedMcp()));
388
+ } else if (category === 'subscriptions') {
389
+ lines.push(...buildSubscriptionContext(modal, modal.getSelectedSubscription()));
390
+ } else {
391
+ const selected = modal.getSelected();
392
+ if (selected) lines.push(...buildSettingContext(modal, selected));
393
+ else lines.push('No setting is selected in this category.');
454
394
  }
455
395
 
456
- // ── Tools tab ─────────────────────────────────────────────────
457
- if (isToolsTab) {
458
- const toolsItems = modal.currentItems; // includes helper.* entries routed into tools group
396
+ lines.push('', `Category purpose: ${CATEGORY_INFO[category]}`);
459
397
 
460
- if (toolsItems.length === 0) {
461
- sections.push({
462
- type: 'text',
463
- content: '(no tool or helper settings available)',
464
- style: { fg: '240', dim: true },
465
- });
466
- } else {
467
- const labelW = Math.floor(contentW * 0.38);
468
- const valW = Math.floor(contentW * 0.30);
469
- const srcW = Math.max(0, contentW - labelW - valW - 4);
470
-
471
- sections.push({
472
- type: 'text',
473
- content: `${fitDisplay('Setting', labelW)} ${fitDisplay('Value', valW)} Source`,
474
- style: { fg: '240', dim: true },
475
- });
476
- sections.push({ type: 'separator' });
477
-
478
- const window = getVisibleWindow(toolsItems.length, modal.selectedIndex, maxVisibleRows);
479
- const visibleItems = toolsItems.slice(window.start, window.end);
480
-
481
- // Render each entry as an individual text row so section headers can interleave.
482
- // Section headers are emitted when the key prefix changes (tools.* vs helper.*).
483
- let lastGroupPrefix = '';
484
- for (let i = 0; i < visibleItems.length; i++) {
485
- const entry = visibleItems[i]!;
486
- const isSelected = window.start + i === modal.selectedIndex;
487
- const isEditing = isSelected && modal.editingMode;
488
- const prefix = entry.setting.key.split('.')[0]!;
489
-
490
- // Emit a section header when the group prefix changes
491
- if (prefix !== lastGroupPrefix) {
492
- lastGroupPrefix = prefix;
493
- const sectionLabel = prefix === 'helper' ? '── Helper Model ──' : '── Tool LLM ──';
494
- sections.push({
495
- type: 'text',
496
- content: fitDisplay(sectionLabel, contentW),
497
- style: { fg: '243', dim: true },
498
- });
499
- }
500
-
501
- const label = getSettingLabel(entry);
502
- const labelStr = fitDisplay(label, labelW);
503
-
504
- let valueStr: string;
505
- if (entry.setting.type === 'boolean') {
506
- const boolVal = Boolean(entry.currentValue);
507
- valueStr = isEditing ? `${modal.editBuffer}\u2588` : (boolVal ? '[on]' : '[off]');
508
- } else if (isEditing) {
509
- valueStr = `${modal.editBuffer}\u2588`;
510
- } else {
511
- valueStr = formatValue(entry);
512
- }
513
-
514
- const valStr = fitDisplay(valueStr, valW);
515
- const sourceText = entry.effectiveSource ?? 'default';
516
- const srcStr = fitDisplay(sourceText, srcW);
517
- const rowLabel = `${labelStr} ${valStr} ${srcStr}`;
518
-
519
- // Render as a single-item list so the ModalFactory applies selection highlight
520
- sections.push({
521
- type: 'list',
522
- items: [{
523
- label: rowLabel,
524
- selected: isSelected,
525
- style: isSelected ? undefined : { fg: valueColor(entry) },
526
- }],
527
- });
528
- }
529
-
530
- if (toolsItems.length > maxVisibleRows) {
531
- sections.push({
532
- type: 'text',
533
- content: `[${window.start + 1}-${window.end} of ${toolsItems.length}]`,
534
- style: { fg: '244', dim: true },
535
- });
536
- }
537
-
538
- // Description of selected entry
539
- const selected = modal.getSelected();
540
- if (selected) {
541
- sections.push({ type: 'separator' });
542
- sections.push({
543
- type: 'text',
544
- content: truncateDisplay(selected.setting.description, contentW),
545
- style: { fg: '246', dim: true },
546
- });
547
- if (selected.setting.type === 'boolean') {
548
- sections.push({
549
- type: 'text',
550
- content: truncateDisplay(`Currently ${Boolean(selected.currentValue) ? 'enabled' : 'disabled'}. Press Enter or Space to toggle.`, contentW),
551
- style: { fg: '#38bdf8', dim: true },
552
- });
553
- } else {
554
- const emptyNote = selected.currentValue === '' || selected.currentValue === null || selected.currentValue === undefined
555
- ? ' (empty = use active provider default)'
556
- : '';
557
- sections.push({
558
- type: 'text',
559
- content: truncateDisplay(`Current: ${formatValue(selected)}${emptyNote}`, contentW),
560
- style: { fg: '#38bdf8', dim: true },
561
- });
562
- }
563
- }
398
+ const wrapped: string[] = [];
399
+ for (const line of lines) {
400
+ if (line === '') {
401
+ wrapped.push('');
402
+ continue;
564
403
  }
404
+ wrapped.push(...paddedWrapped(line, width));
405
+ }
406
+ return wrapped;
407
+ }
565
408
 
566
- const hints = modal.editingMode
567
- ? ['[Enter] Confirm', '[Esc] Cancel']
568
- : ['[Tab] Category', '[\u2191\u2193] Navigate', '[Enter] Toggle / Edit', '[Esc] Close'];
569
-
570
- return ModalFactory.createModal(
571
- {
572
- title: 'Settings',
573
- width: boxW,
574
- margin: boxMargin,
575
- targetContentRows,
576
- tabs: SETTINGS_CATEGORIES.map((category, index) => ({
577
- label: CATEGORY_LABELS[category],
578
- active: index === modal.categoryIndex,
579
- })),
580
- sections,
581
- hints,
582
- helpers: persistentHelpers,
583
- },
584
- width,
585
- );
409
+ function categoryItemCount(modal: SettingsModal, category: SettingsCategory): number {
410
+ if (category === 'flags') return modal.flagEntries.length;
411
+ if (category === 'mcp') return modal.mcpEntries.length;
412
+ if (category === 'subscriptions') return modal.subscriptionEntries.length;
413
+ return modal.groups.get(category)?.length ?? 0;
414
+ }
415
+
416
+ function renderCategories(modal: SettingsModal, width: number, height: number): string[] {
417
+ const rows: string[] = [];
418
+ const window = stableWindow(SETTINGS_CATEGORIES.length, modal.categoryIndex, height);
419
+ if (window.start > 0) rows.push(`${GLYPHS.navigation.moreAbove} ${window.start} more categor${window.start === 1 ? 'y' : 'ies'} above`);
420
+ for (let index = window.start; index < window.end; index += 1) {
421
+ const category = SETTINGS_CATEGORIES[index]!;
422
+ const active = index === modal.categoryIndex;
423
+ const count = categoryItemCount(modal, category);
424
+ const cursor = active ? (modal.focusPane === 'categories' ? GLYPHS.navigation.selected : '•') : ' ';
425
+ rows.push(`${cursor} ${CATEGORY_LABELS[category]} (${count})`);
586
426
  }
427
+ if (window.end < SETTINGS_CATEGORIES.length) rows.push(`${GLYPHS.navigation.moreBelow} ${SETTINGS_CATEGORIES.length - window.end} more categor${SETTINGS_CATEGORIES.length - window.end === 1 ? 'y' : 'ies'} below`);
428
+ while (rows.length < height) rows.push('');
429
+ return rows.slice(0, height);
430
+ }
587
431
 
588
- // ── Settings list ────────────────────────────────────────────
432
+ function renderSettingRows(modal: SettingsModal, width: number, height: number): string[] {
433
+ const rows: string[] = [];
589
434
  const items = modal.currentItems;
435
+ if (items.length === 0) return ['No settings in this category.'];
436
+ const selectedIndex = clamp(modal.selectedIndex, 0, items.length - 1);
437
+ const typeWidth = 9;
438
+ const sourceWidth = 12;
439
+ const defaultWidth = 12;
440
+ const available = Math.max(24, width - typeWidth - sourceWidth - defaultWidth - 13);
441
+ const keyWidth = clamp(Math.floor(available * 0.56), 18, 52);
442
+ const valueWidth = Math.max(10, available - keyWidth);
443
+ rows.push(` ${padDisplay('Setting', keyWidth)} ${padDisplay('Value', valueWidth)} ${padDisplay('Type', typeWidth)} ${padDisplay('Source', sourceWidth)} ${padDisplay('Default', defaultWidth)}`);
444
+ const visibleCount = Math.max(1, height - 2);
445
+ const window = stableWindow(items.length, selectedIndex, visibleCount);
446
+ if (window.start > 0) rows.push(`${GLYPHS.navigation.moreAbove} ${window.start} more setting(s) above`);
447
+
448
+ for (let index = window.start; index < window.end; index += 1) {
449
+ const entry = items[index]!;
450
+ const selected = index === selectedIndex;
451
+ const marker = selected ? (modal.focusPane === 'settings' ? GLYPHS.navigation.selected : '•') : entry.isDefault ? ' ' : '◇';
452
+ const value = currentSettingValue(modal, entry, selected);
453
+ const source = `${entry.effectiveSource ?? 'default'}${entry.locked ? ' locked' : ''}${entry.conflict ? ' conflict' : ''}`;
454
+ const label = getSettingLabel(entry);
455
+ rows.push(`${marker} ${padDisplay(label, keyWidth)} ${padDisplay(value, valueWidth)} ${padDisplay(entry.setting.type, typeWidth)} ${padDisplay(source, sourceWidth)} ${padDisplay(formatDefaultValue(entry.setting.default), defaultWidth)}`);
456
+ }
590
457
 
591
- if (items.length === 0) {
592
- sections.push({
593
- type: 'text',
594
- content: '(no settings in this category)',
595
- style: { fg: '240', dim: true },
596
- });
597
- } else {
598
- const keyW = Math.floor(contentW * 0.45);
599
- const valW = Math.floor(contentW * 0.18);
600
- const srcW = Math.floor(contentW * 0.14);
601
-
602
- // Column header
603
- const keyHdr = fitDisplay('Setting', keyW);
604
- const valHdr = fitDisplay('Value', valW);
605
- const srcHdr = fitDisplay('Source', srcW);
606
- const defHdr = 'Default';
607
- sections.push({
608
- type: 'text',
609
- content: `${keyHdr} ${valHdr} ${srcHdr} ${defHdr}`,
610
- style: { fg: '240', dim: true },
611
- });
612
- sections.push({ type: 'separator' });
613
-
614
- const isDangerCategory = modal.currentCategory === 'danger';
615
- const window = getVisibleWindow(items.length, modal.selectedIndex, maxVisibleRows);
616
- const visibleSettings = items.slice(window.start, window.end);
617
- const listItems: import('./modal-factory.ts').ModalListItem[] = visibleSettings.map((entry, idx) => {
618
- const isSelected = window.start + idx === modal.selectedIndex;
619
-
620
- // If this is selected and editing, show edit buffer
621
- const isEditing = isSelected && modal.editingMode;
622
- const valueStr = isEditing
623
- ? modal.editBuffer + '\u2588'
624
- : formatValue(entry);
625
-
626
- const keyStr = fitDisplay(getSettingLabel(entry), keyW);
627
- const valStr = fitDisplay(valueStr, valW);
628
- const sourceText = `${entry.effectiveSource ?? 'default'}${entry.locked ? '!' : entry.conflict ? '?' : ''}`;
629
- const srcStr = fitDisplay(sourceText, srcW);
630
- const defStr = String(entry.setting.default);
631
-
632
- const label = `${keyStr} ${valStr} ${srcStr} ${defStr}`;
633
-
634
- return {
635
- label,
636
- selected: isSelected,
637
- style: isSelected ? undefined : { fg: isDangerCategory ? '#ef4444' : valueColor(entry) },
638
- };
639
- });
458
+ if (window.end < items.length) rows.push(`${GLYPHS.navigation.moreBelow} ${items.length - window.end} more setting(s) below`);
459
+ return rows.slice(0, height);
460
+ }
640
461
 
641
- sections.push({ type: 'list', items: listItems });
642
- if (items.length > maxVisibleRows) {
643
- sections.push({
644
- type: 'text',
645
- content: `[${window.start + 1}-${window.end} of ${items.length}]`,
646
- style: { fg: '244', dim: true },
647
- });
648
- }
462
+ function renderFlagRows(modal: SettingsModal, width: number, height: number): string[] {
463
+ const rows: string[] = [];
464
+ const items = modal.flagEntries;
465
+ if (items.length === 0) return ['No feature flags registered.'];
466
+ const selectedIndex = clamp(modal.selectedIndex, 0, items.length - 1);
467
+ const nameWidth = clamp(Math.floor(width * 0.40), 24, 58);
468
+ const stateWidth = 10;
469
+ const tierWidth = 6;
470
+ const runtimeWidth = 9;
471
+ const defaultWidth = 9;
472
+ const idWidth = Math.max(12, width - nameWidth - stateWidth - tierWidth - runtimeWidth - defaultWidth - 14);
473
+ rows.push(` ${padDisplay('Feature Flag', nameWidth)} ${padDisplay('State', stateWidth)} ${padDisplay('Tier', tierWidth)} ${padDisplay('Runtime', runtimeWidth)} ${padDisplay('Default', defaultWidth)} ${padDisplay('ID', idWidth)}`);
474
+ const visibleCount = Math.max(1, height - 2);
475
+ const window = stableWindow(items.length, selectedIndex, visibleCount);
476
+ if (window.start > 0) rows.push(`${GLYPHS.navigation.moreAbove} ${window.start} more flag(s) above`);
477
+ for (let index = window.start; index < window.end; index += 1) {
478
+ const entry = items[index]!;
479
+ const selected = index === selectedIndex;
480
+ const marker = selected ? (modal.focusPane === 'settings' ? GLYPHS.navigation.selected : '•') : ' ';
481
+ rows.push(`${marker} ${padDisplay(entry.flag.name, nameWidth)} ${padDisplay(entry.state, stateWidth)} ${padDisplay(String(entry.flag.tier), tierWidth)} ${padDisplay(entry.flag.runtimeToggleable ? 'yes' : 'restart', runtimeWidth)} ${padDisplay(entry.flag.defaultState, defaultWidth)} ${padDisplay(entry.flag.id, idWidth)}`);
482
+ }
483
+ if (window.end < items.length) rows.push(`${GLYPHS.navigation.moreBelow} ${items.length - window.end} more flag(s) below`);
484
+ return rows.slice(0, height);
485
+ }
649
486
 
650
- // Description of selected item
651
- const selected = modal.getSelected();
652
- if (selected) {
653
- sections.push({ type: 'separator' });
654
- const desc = selected.setting.description;
655
- const truncated = truncateDisplay(desc, contentW);
656
- sections.push({
657
- type: 'text',
658
- content: truncated,
659
- style: { fg: '246', dim: true },
487
+ function renderMcpRows(modal: SettingsModal, width: number, height: number): string[] {
488
+ const rows: string[] = [];
489
+ const items = modal.mcpEntries;
490
+ if (items.length === 0) return ['No MCP servers registered.'];
491
+ const selectedIndex = clamp(modal.selectedIndex, 0, items.length - 1);
492
+ const nameWidth = clamp(Math.floor(width * 0.32), 18, 44);
493
+ const trustWidth = 14;
494
+ const roleWidth = 12;
495
+ const statusWidth = 12;
496
+ const scopeWidth = Math.max(12, width - nameWidth - trustWidth - roleWidth - statusWidth - 10);
497
+ rows.push(` ${padDisplay('Server', nameWidth)} ${padDisplay('Trust', trustWidth)} ${padDisplay('Role', roleWidth)} ${padDisplay('Status', statusWidth)} ${padDisplay('Scope', scopeWidth)}`);
498
+ const window = stableWindow(items.length, selectedIndex, Math.max(1, height - 2));
499
+ if (window.start > 0) rows.push(`${GLYPHS.navigation.moreAbove} ${window.start} more MCP server(s) above`);
500
+ for (let index = window.start; index < window.end; index += 1) {
501
+ const entry = items[index]!;
502
+ const selected = index === selectedIndex;
503
+ const trust = selected && modal.editingMode ? `${modal.editBuffer}${GLYPHS.surface.cursor}` : entry.trustMode;
504
+ const scope = entry.allowedPaths.length > 0 ? entry.allowedPaths.join(', ') : entry.allowedHosts.length > 0 ? entry.allowedHosts.join(', ') : 'none';
505
+ const marker = selected ? (modal.focusPane === 'settings' ? GLYPHS.navigation.selected : '•') : ' ';
506
+ rows.push(`${marker} ${padDisplay(entry.name, nameWidth)} ${padDisplay(trust, trustWidth)} ${padDisplay(entry.role, roleWidth)} ${padDisplay(entry.connected ? 'connected' : 'offline', statusWidth)} ${padDisplay(scope, scopeWidth)}`);
507
+ }
508
+ if (window.end < items.length) rows.push(`${GLYPHS.navigation.moreBelow} ${items.length - window.end} more MCP server(s) below`);
509
+ return rows.slice(0, height);
510
+ }
511
+
512
+ function renderSubscriptionRows(modal: SettingsModal, width: number, height: number): string[] {
513
+ const rows: string[] = [];
514
+ const items = modal.subscriptionEntries;
515
+ if (items.length === 0) return ['No provider subscriptions available or configured.'];
516
+ const selectedIndex = clamp(modal.selectedIndex, 0, items.length - 1);
517
+ const providerWidth = clamp(Math.floor(width * 0.28), 14, 36);
518
+ const stateWidth = 10;
519
+ const routeWidth = 16;
520
+ const freshnessWidth = 14;
521
+ const oauthWidth = 8;
522
+ const noteWidth = Math.max(12, width - providerWidth - stateWidth - routeWidth - freshnessWidth - oauthWidth - 12);
523
+ rows.push(` ${padDisplay('Provider', providerWidth)} ${padDisplay('State', stateWidth)} ${padDisplay('Route', routeWidth)} ${padDisplay('Freshness', freshnessWidth)} ${padDisplay('OAuth', oauthWidth)} ${padDisplay('Note', noteWidth)}`);
524
+ const window = stableWindow(items.length, selectedIndex, Math.max(1, height - 2));
525
+ if (window.start > 0) rows.push(`${GLYPHS.navigation.moreAbove} ${window.start} more subscription provider(s) above`);
526
+ for (let index = window.start; index < window.end; index += 1) {
527
+ const entry = items[index]!;
528
+ const selected = index === selectedIndex;
529
+ const marker = selected ? (modal.focusPane === 'settings' ? GLYPHS.navigation.selected : '•') : ' ';
530
+ rows.push(`${marker} ${padDisplay(entry.provider, providerWidth)} ${padDisplay(entry.state, stateWidth)} ${padDisplay(entry.activeRoute ?? 'n/a', routeWidth)} ${padDisplay(entry.authFreshness ?? 'n/a', freshnessWidth)} ${padDisplay(entry.oauthConfigured ? 'yes' : 'no', oauthWidth)} ${padDisplay(inferSubscriptionRouteReason(entry) ?? '', noteWidth)}`);
531
+ }
532
+ if (window.end < items.length) rows.push(`${GLYPHS.navigation.moreBelow} ${items.length - window.end} more subscription provider(s) below`);
533
+ return rows.slice(0, height);
534
+ }
535
+
536
+ function renderControlRows(modal: SettingsModal, width: number, height: number): string[] {
537
+ if (modal.currentCategory === 'flags') return renderFlagRows(modal, width, height);
538
+ if (modal.currentCategory === 'mcp') return renderMcpRows(modal, width, height);
539
+ if (modal.currentCategory === 'subscriptions') return renderSubscriptionRows(modal, width, height);
540
+ return renderSettingRows(modal, width, height);
541
+ }
542
+
543
+ function rowColorForSetting(modal: SettingsModal, rowText: string): string {
544
+ if (modal.currentCategory === 'danger') return PALETTE.bad;
545
+ if (rowText.startsWith(GLYPHS.navigation.selected)) return PALETTE.text;
546
+ const selected = modal.getSelected();
547
+ if (!selected) return PALETTE.text;
548
+ return valueColor(selected);
549
+ }
550
+
551
+ function footerText(modal: SettingsModal): string {
552
+ if (modal.editingMode) return 'Enter Confirm edit · Esc Cancel edit · text keys edit the selected field';
553
+ if (modal.focusPane === 'categories') return 'Focus categories · Up/Down choose · Right/Enter settings · Tab pane · Esc close';
554
+ if (modal.currentCategory === 'subscriptions') return 'Focus settings · Up/Down provider · Left categories · Tab pane · Enter review/sign out · Esc close';
555
+ if (modal.currentCategory === 'mcp') return 'Focus settings · Up/Down server · Left categories · Tab pane · Enter edit trust · Esc close';
556
+ if (modal.currentCategory === 'flags') return 'Focus feature flags · Up/Down flag · Left categories · Tab pane · Enter/Space toggle · Esc close';
557
+ return 'Focus settings · Up/Down setting · Left categories · Tab pane · Enter/Space edit/toggle · R reset · Esc close';
558
+ }
559
+
560
+ export function renderSettingsModal(
561
+ modal: SettingsModal,
562
+ width: number,
563
+ viewportHeight = 24,
564
+ ): Line[] {
565
+ const safeWidth = Math.max(1, width);
566
+ const safeHeight = Math.max(12, viewportHeight);
567
+ const lines: Line[] = [];
568
+ const leftWidth = safeWidth < 80
569
+ ? clamp(Math.round(safeWidth * 0.32), 14, Math.max(14, safeWidth - 24))
570
+ : clamp(Math.round(safeWidth * 0.22), 24, 34);
571
+ const centerWidth = Math.max(20, safeWidth - leftWidth - 3);
572
+ const leftStart = 1;
573
+ const dividerX = leftWidth + 1;
574
+ const centerStart = dividerX + 1;
575
+ const centerEnd = safeWidth - 2;
576
+ const bodyTop = 3;
577
+ const footerY = safeHeight - 2;
578
+ const bodyRows = Math.max(4, footerY - bodyTop);
579
+ const contextWidth = Math.max(10, centerWidth - 2);
580
+ const contextLines = buildContextLines(modal, contextWidth);
581
+ const maxContextRows = Math.max(3, bodyRows - 4);
582
+ const contextRows = clamp(Math.round(bodyRows * 0.4), Math.min(10, maxContextRows), maxContextRows);
583
+ const controlsRows = Math.max(3, bodyRows - contextRows - 1);
584
+ const separatorY = bodyTop + contextRows;
585
+
586
+ const top = borderLine(safeWidth, GLYPHS.frame.topLeft, GLYPHS.frame.horizontal, GLYPHS.frame.topRight);
587
+ writeText(top, 2, safeWidth - 4, ` Configuration Workspace / Settings `, { fg: PALETTE.title, bold: true });
588
+ lines.push(top);
589
+
590
+ const header = contentLine(safeWidth, PALETTE.footerBg);
591
+ drawVertical(header, dividerX, PALETTE.footerBg);
592
+ writeText(header, leftStart + 1, leftWidth - 2, 'Categories', { fg: PALETTE.subtitle, bold: true, bg: PALETTE.footerBg });
593
+ const headerText = `${CATEGORY_LABELS[modal.currentCategory]} (${categoryItemCount(modal, modal.currentCategory)})${modal.lastSaveTriggeredRestart ? ` · Restarting ${modal.lastSaveTriggeredRestart}` : ''}`;
594
+ writeText(header, centerStart + 1, centerWidth - 2, headerText, { fg: PALETTE.subtitle, bold: true, bg: PALETTE.footerBg });
595
+ lines.push(header);
596
+
597
+ const headerSep = contentLine(safeWidth, '');
598
+ drawVertical(headerSep, dividerX);
599
+ drawHorizontalRange(headerSep, 1, safeWidth - 2);
600
+ lines.push(headerSep);
601
+
602
+ const categoryRows = renderCategories(modal, leftWidth - 2, bodyRows);
603
+ const controlRows = renderControlRows(modal, contextWidth, controlsRows);
604
+
605
+ for (let row = 0; row < bodyRows; row += 1) {
606
+ const y = bodyTop + row;
607
+ const inContext = y < separatorY;
608
+ const inSeparator = y === separatorY;
609
+ const bg = inSeparator ? '' : inContext ? PALETTE.contextBg : PALETTE.controlsBg;
610
+ const line = contentLine(safeWidth, bg);
611
+ fillRange(line, 1, dividerX - 1, PALETTE.categoryBg);
612
+ drawVertical(line, dividerX, bg);
613
+
614
+ const categoryText = categoryRows[row] ?? '';
615
+ const categoryActive = categoryText.startsWith(GLYPHS.navigation.selected) || categoryText.startsWith('•');
616
+ if (categoryText.startsWith(GLYPHS.navigation.selected)) fillRange(line, leftStart, dividerX - 1, PALETTE.selectedBg);
617
+ writeText(line, leftStart + 1, leftWidth - 3, categoryText, {
618
+ fg: categoryActive ? PALETTE.text : PALETTE.muted,
619
+ bg: categoryText.startsWith(GLYPHS.navigation.selected) ? PALETTE.selectedBg : PALETTE.categoryBg,
620
+ bold: categoryActive,
621
+ });
622
+
623
+ if (inSeparator) {
624
+ drawHorizontalRange(line, centerStart, centerEnd);
625
+ } else if (inContext) {
626
+ const contextText = contextLines[row] ?? '';
627
+ const selectedSetting = modal.getSelected();
628
+ const isTitle = row === 0 || (selectedSetting !== null && contextText === getSettingLabel(selectedSetting));
629
+ writeText(line, centerStart + 1, contextWidth, contextText, {
630
+ fg: row === 0 ? PALETTE.title : contextText.endsWith(':') ? PALETTE.subtitle : PALETTE.text,
631
+ bg,
632
+ bold: isTitle,
633
+ dim: contextText.length === 0,
660
634
  });
661
- if (
662
- selected.setting.key === 'ui.systemMessages'
663
- || selected.setting.key === 'ui.operationalMessages'
664
- || selected.setting.key === 'ui.wrfcMessages'
665
- ) {
666
- sections.push({
667
- type: 'text',
668
- content: truncateDisplay(`Current route: ${describeUiRouting(String(selected.currentValue))}.`, contentW),
669
- style: { fg: '#38bdf8', dim: true },
670
- });
671
- }
672
- const provenanceParts = [
673
- `Source: ${selected.effectiveSource ?? 'default'}`,
674
- selected.locked ? 'locked' : null,
675
- selected.conflict ? 'conflict' : null,
676
- ].filter(Boolean);
677
- sections.push({
678
- type: 'text',
679
- content: truncateDisplay(provenanceParts.join(' · '), contentW),
680
- style: { fg: selected.locked ? '#eab308' : selected.conflict ? '#ef4444' : '244', dim: true },
635
+ } else {
636
+ const controlText = controlRows[row - contextRows - 1] ?? '';
637
+ const selected = controlText.startsWith(GLYPHS.navigation.selected);
638
+ if (selected) fillRange(line, centerStart, centerEnd, PALETTE.selectedBg);
639
+ writeText(line, centerStart + 1, contextWidth, controlText, {
640
+ fg: selected ? PALETTE.text : controlText.startsWith('value:') || controlText.trimStart().startsWith('value:') ? PALETTE.info : rowColorForSetting(modal, controlText),
641
+ bg: selected ? PALETTE.selectedBg : bg,
642
+ bold: selected,
643
+ dim: controlText.length === 0,
681
644
  });
682
- if (selected.sourceLabel) {
683
- sections.push({
684
- type: 'text',
685
- content: truncateDisplay(`Layer: ${selected.sourceLabel}`, contentW),
686
- style: { fg: '244', dim: true },
687
- });
688
- }
689
- if (selected.lockReason) {
690
- sections.push({
691
- type: 'text',
692
- content: truncateDisplay(`Lock: ${selected.lockReason}`, contentW),
693
- style: { fg: '#eab308', dim: true },
694
- });
695
- }
696
- if (selected.conflict) {
697
- const helper = `Repair: /settingssync resolve ${selected.setting.key} local|synced`;
698
- sections.push({
699
- type: 'text',
700
- content: truncateDisplay(helper, contentW),
701
- style: { fg: '#ef4444', dim: true },
702
- });
703
- persistentHelpers = [{ content: truncateDisplay(helper, contentW), accent: true }];
704
- } else if (selected.effectiveSource === 'managed') {
705
- const helper = 'Review: /managed staged Apply or rollback managed changes from the control plane.';
706
- sections.push({
707
- type: 'text',
708
- content: truncateDisplay(helper, contentW),
709
- style: { fg: '#eab308', dim: true },
710
- });
711
- persistentHelpers = [{ content: truncateDisplay(helper, contentW), accent: true }];
712
- } else if (selected.effectiveSource === 'synced') {
713
- const helper = `Review: /settingssync show ${selected.setting.key} Inspect synced provenance and fallback state.`;
714
- sections.push({
715
- type: 'text',
716
- content: truncateDisplay(helper, contentW),
717
- style: { fg: '#38bdf8', dim: true },
718
- });
719
- persistentHelpers = [{ content: truncateDisplay(helper, contentW), accent: true }];
720
- }
721
- // Show enum options if applicable
722
- if (selected.setting.type === 'enum' && selected.setting.enumValues) {
723
- const opts = selected.setting.enumValues.join(' | ');
724
- const optStr = selected.setting.key === 'ui.systemMessages'
725
- || selected.setting.key === 'ui.operationalMessages'
726
- || selected.setting.key === 'ui.wrfcMessages'
727
- ? 'Options: panel | conversation | both'
728
- : `Options: ${opts}`;
729
- sections.push({
730
- type: 'text',
731
- content: truncateDisplay(optStr, contentW),
732
- style: { fg: '240', dim: true },
733
- });
734
- }
735
645
  }
646
+ lines.push(line);
736
647
  }
737
648
 
738
- const hints = modal.editingMode
739
- ? ['[Enter] Confirm', '[Esc] Cancel']
740
- : ['[Tab] Category', '[\u2191\u2193] Navigate', '[←/→] Adjust', '[Enter] Toggle / Edit', '[Esc] Close'];
741
-
742
- return ModalFactory.createModal(
743
- {
744
- title: 'Settings',
745
- width: boxW,
746
- margin: boxMargin,
747
- targetContentRows,
748
- tabs: SETTINGS_CATEGORIES.map((category, index) => ({
749
- label: CATEGORY_LABELS[category],
750
- active: index === modal.categoryIndex,
751
- })),
752
- sections,
753
- hints,
754
- },
755
- width,
756
- );
649
+ const footer = contentLine(safeWidth, PALETTE.footerBg);
650
+ writeText(footer, 2, safeWidth - 4, footerText(modal), { fg: PALETTE.muted, bg: PALETTE.footerBg });
651
+ lines.push(footer);
652
+ const bottom = borderLine(safeWidth, GLYPHS.frame.bottomLeft, GLYPHS.frame.horizontal, GLYPHS.frame.bottomRight);
653
+ lines.push(bottom);
654
+
655
+ while (lines.length < safeHeight) lines.unshift(makeLine(safeWidth));
656
+ return lines.slice(-safeHeight);
757
657
  }