@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.
- package/CHANGELOG.md +35 -0
- package/README.md +10 -13
- package/docs/foundation-artifacts/knowledge-store.sql +27 -0
- package/docs/foundation-artifacts/operator-contract.json +15736 -7265
- package/package.json +2 -2
- package/src/audio/spoken-turn-controller.ts +4 -1
- package/src/input/command-args-hint.ts +36 -0
- package/src/input/command-registry.ts +3 -1
- package/src/input/commands/config.ts +7 -521
- package/src/input/commands/knowledge.ts +111 -1
- package/src/input/commands/local-runtime.ts +0 -80
- package/src/input/commands/operator-runtime.ts +3 -3
- package/src/input/commands/planning-runtime.ts +83 -34
- package/src/input/commands/shell-core.ts +2 -34
- package/src/input/commands/tts-runtime.ts +1 -389
- package/src/input/commands.ts +0 -2
- package/src/input/handler-modal-routes.ts +61 -7
- package/src/input/handler-modal-token-routes.ts +1 -0
- package/src/input/handler-picker-routes.ts +50 -4
- package/src/input/model-picker-provider-filter.ts +28 -0
- package/src/input/model-picker-types.ts +12 -0
- package/src/input/model-picker.ts +65 -23
- package/src/input/selection-modal.ts +1 -1
- package/src/input/settings-modal-behavior.ts +2 -0
- package/src/input/settings-modal-subscriptions.ts +95 -0
- package/src/input/settings-modal-types.ts +50 -3
- package/src/input/settings-modal.ts +106 -134
- package/src/input/tts-settings-actions.ts +100 -0
- package/src/main.ts +50 -45
- package/src/panels/builtin/agent.ts +15 -0
- package/src/panels/builtin/shared.ts +17 -0
- package/src/panels/project-planning-panel.ts +370 -0
- package/src/planning/project-planning-coordinator.ts +249 -0
- package/src/renderer/compositor.ts +2 -1
- package/src/renderer/conversation-overlays.ts +4 -5
- package/src/renderer/model-workspace.ts +488 -0
- package/src/renderer/settings-modal-helpers.ts +16 -1
- package/src/renderer/settings-modal.ts +616 -716
- package/src/runtime/bootstrap-command-context.ts +6 -0
- package/src/runtime/bootstrap-command-parts.ts +5 -0
- package/src/runtime/bootstrap-shell.ts +2 -0
- package/src/runtime/services.ts +33 -2
- package/src/runtime/terminal-output-guard.ts +228 -0
- package/src/runtime/ui-services.ts +4 -0
- package/src/shell/ui-openers.ts +59 -3
- package/src/utils/clipboard.ts +2 -1
- package/src/version.ts +1 -1
- package/src/input/commands/permissions-runtime.ts +0 -104
|
@@ -1,757 +1,657 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* using ModalFactory.
|
|
2
|
+
* Fullscreen configuration workspace.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
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 {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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 (
|
|
325
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
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
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
}
|