codex-configurator 0.2.4 → 0.2.6

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/index.js CHANGED
@@ -1,66 +1,75 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import React, { useState, useEffect } from 'react';
3
+ import React, { useEffect, useReducer, useRef, useState } from 'react';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { createRequire } from 'node:module';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
6
8
  import { render, useInput, useApp, useStdout, Text, Box } from 'ink';
7
- import { CONTROL_HINT, EDIT_CONTROL_HINT, FILTER_CONTROL_HINT } from './src/constants.js';
8
9
  import {
9
- readConfig,
10
- getNodeAtPath,
11
- buildRows,
12
- deleteValueAtPath,
13
- setValueAtPath,
14
- writeConfig,
10
+ buildConfigFileCatalog,
11
+ MAIN_CONFIG_FILE_ID,
12
+ } from './src/fileContext.js';
13
+ import {
14
+ ensureConfigFileExists,
15
+ readConfig,
16
+ getNodeAtPath,
17
+ buildRows,
18
+ deleteValueAtPathPruningEmptyObjects,
19
+ setValueAtPath,
20
+ writeConfig,
15
21
  } from './src/configParser.js';
16
- import { getConfigOptions } from './src/configHelp.js';
22
+ import { getConfigOptions, getConfigVariantMeta } from './src/configHelp.js';
17
23
  import {
18
- getReferenceOptionForPath,
19
- getReferenceCustomIdPlaceholder,
24
+ getReferenceOptionForPath,
25
+ getReferenceCustomIdPlaceholder,
20
26
  } from './src/configReference.js';
21
27
  import { normalizeCustomPathId } from './src/customPathId.js';
22
- import { pathToKey, clamp } from './src/layout.js';
23
28
  import {
24
- isBackspaceKey,
25
- isDeleteKey,
26
- isPageUpKey,
27
- isPageDownKey,
28
- isHomeKey,
29
- isEndKey,
30
- } from './src/interaction.js';
29
+ applyVariantSelection,
30
+ buildVariantSelectorOptions,
31
+ isObjectValue,
32
+ objectMatchesVariant,
33
+ resolveMixedVariantBackNavigationPath,
34
+ resolveObjectVariantNavigationPath,
35
+ } from './src/variantPresets.js';
36
+ import {
37
+ APP_MODES,
38
+ APP_STATE_ACTION,
39
+ REFERENCE_SCHEMA_CHANGED_ACTION,
40
+ appStateReducer,
41
+ buildInitialAppState,
42
+ } from './src/appState.js';
43
+ import { pathToKey, clamp, computeListViewportRows } from './src/layout.js';
31
44
  import { Header } from './src/components/Header.js';
32
45
  import { ConfigNavigator } from './src/components/ConfigNavigator.js';
33
46
  import { filterRowsByQuery } from './src/fuzzySearch.js';
47
+ import { syncReferenceSchemaAtStartup } from './src/schemaRuntimeSync.js';
48
+ import { executeInputCommand, getModeHint } from './src/ui/commands.js';
49
+ import { CommandBar } from './src/ui/panes/CommandBar.js';
50
+ import { HelpBubble } from './src/ui/panes/HelpBubble.js';
51
+ import { LayoutShell } from './src/ui/panes/LayoutShell.js';
52
+ import { StatusLine } from './src/ui/panes/StatusLine.js';
34
53
 
35
54
  const require = createRequire(import.meta.url);
36
55
  const { version: PACKAGE_VERSION = 'unknown' } = require('./package.json');
37
56
 
38
- const computeListViewportHeight = (rows, terminalRows) =>
39
- Math.max(4, Math.min(rows.length, Math.min(20, Math.max(4, terminalRows - 14))));
40
-
41
- const isBooleanOnlyOptions = (options) =>
42
- Array.isArray(options) &&
43
- options.length === 2 &&
44
- options.every((option) => typeof option === 'boolean') &&
45
- options.includes(false) &&
46
- options.includes(true);
47
-
48
- const isStringReferenceType = (type) => /^string(?:\s|$)/.test(String(type || '').trim());
57
+ const isStringReferenceType = (type) =>
58
+ /^string(?:\s|$)/.test(String(type || '').trim());
49
59
 
50
60
  const isStringField = (pathSegments, value) => {
51
- if (typeof value === 'string') {
52
- return true;
53
- }
61
+ if (typeof value === 'string') {
62
+ return true;
63
+ }
54
64
 
55
- return isStringReferenceType(getReferenceOptionForPath(pathSegments)?.type);
65
+ return isStringReferenceType(getReferenceOptionForPath(pathSegments)?.type);
56
66
  };
57
67
 
58
68
  const isCustomIdTableRow = (pathSegments, row) =>
59
- row?.kind === 'table' &&
60
- typeof row?.pathSegment === 'string' &&
61
- Boolean(getReferenceCustomIdPlaceholder(pathSegments));
69
+ row?.kind === 'table' &&
70
+ typeof row?.pathSegment === 'string' &&
71
+ Boolean(getReferenceCustomIdPlaceholder(pathSegments));
62
72
 
63
- const isInlineTextMode = (mode) => mode === 'text' || mode === 'add-id';
64
73
  const VERSION_COMMAND_TIMEOUT_MS = 3000;
65
74
  const UPDATE_COMMAND_TIMEOUT_MS = 180000;
66
75
  const COMMAND_MAX_BUFFER_BYTES = 1024 * 1024;
@@ -68,845 +77,1464 @@ const UPDATE_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
68
77
  const CODEX_BIN_ENV_VAR = 'CODEX_CONFIGURATOR_CODEX_BIN';
69
78
  const NPM_BIN_ENV_VAR = 'CODEX_CONFIGURATOR_NPM_BIN';
70
79
  const CONFIGURATOR_PACKAGE_NAME = 'codex-configurator';
80
+ const SCHEMA_STATUS_MIN_VISIBLE_MS = 1200;
81
+ const FILE_SWITCH_MAX_VISIBLE_ENTRIES = 6;
82
+ const FILE_SWITCH_PANEL_BASE_ROWS = 3;
83
+ const FILE_SWITCH_LAYOUT_EXTRA_GAP_ROWS = 1;
84
+ const ENABLE_ALT_SCREEN = '\u001b[?1049h';
85
+ const DISABLE_ALT_SCREEN = '\u001b[?1049l';
86
+ const HIDE_CURSOR = '\u001b[?25l';
87
+ const SHOW_CURSOR = '\u001b[?25h';
88
+ const CLEAR_SCREEN = '\u001b[2J';
89
+ const CURSOR_HOME = '\u001b[H';
90
+
91
+ const supportsFullScreenTerminal = () =>
92
+ Boolean(process.stdout?.isTTY && process.stderr?.isTTY);
93
+
94
+ const setFullScreenState = (isActive) => {
95
+ if (!supportsFullScreenTerminal()) {
96
+ return;
97
+ }
98
+
99
+ if (isActive) {
100
+ process.stdout.write(
101
+ `${ENABLE_ALT_SCREEN}${CLEAR_SCREEN}${CURSOR_HOME}${HIDE_CURSOR}`,
102
+ );
103
+ return;
104
+ }
105
+
106
+ process.stdout.write(`${SHOW_CURSOR}${DISABLE_ALT_SCREEN}`);
107
+ };
108
+
109
+ const activateTerminalMode = () => {
110
+ if (!supportsFullScreenTerminal()) {
111
+ return false;
112
+ }
113
+
114
+ let isActive = true;
115
+ setFullScreenState(true);
116
+
117
+ const restore = () => {
118
+ if (!isActive) {
119
+ return;
120
+ }
121
+
122
+ isActive = false;
123
+ setFullScreenState(false);
124
+ };
125
+
126
+ process.once('exit', restore);
127
+ process.once('SIGINT', () => {
128
+ restore();
129
+ process.exit(130);
130
+ });
131
+ process.once('SIGTERM', () => {
132
+ restore();
133
+ process.exit(143);
134
+ });
135
+
136
+ return true;
137
+ };
138
+
139
+ const computeFileSwitchPanelRows = (entryCount) => {
140
+ const totalEntries = Math.max(0, entryCount);
141
+ const visibleEntries = Math.min(
142
+ FILE_SWITCH_MAX_VISIBLE_ENTRIES,
143
+ totalEntries,
144
+ );
145
+ const hasOverflow = totalEntries > visibleEntries;
146
+
147
+ return FILE_SWITCH_PANEL_BASE_ROWS + visibleEntries + (hasOverflow ? 1 : 0);
148
+ };
71
149
 
72
150
  const runCommandWithResult = (command, args = [], options = {}) =>
73
- new Promise((resolve) => {
74
- execFile(
75
- command,
76
- args,
77
- {
78
- encoding: 'utf8',
79
- timeout: options.timeout || VERSION_COMMAND_TIMEOUT_MS,
80
- maxBuffer: options.maxBuffer || COMMAND_MAX_BUFFER_BYTES,
81
- windowsHide: true,
82
- },
83
- (error, stdout, stderr) => {
84
- resolve({
85
- ok: !error,
86
- stdout: String(stdout || '').trim(),
87
- stderr: String(stderr || '').trim(),
88
- });
89
- }
90
- );
91
- });
151
+ new Promise((resolve) => {
152
+ execFile(
153
+ command,
154
+ args,
155
+ {
156
+ encoding: 'utf8',
157
+ timeout: options.timeout || VERSION_COMMAND_TIMEOUT_MS,
158
+ maxBuffer: options.maxBuffer || COMMAND_MAX_BUFFER_BYTES,
159
+ windowsHide: true,
160
+ },
161
+ (error, stdout, stderr) => {
162
+ resolve({
163
+ ok: !error,
164
+ stdout: String(stdout || '').trim(),
165
+ stderr: String(stderr || '').trim(),
166
+ });
167
+ },
168
+ );
169
+ });
92
170
 
93
171
  const runCommand = async (command, args = [], options = {}) => {
94
- const result = await runCommandWithResult(command, args, options);
95
- if (!result.ok) {
96
- return '';
97
- }
172
+ const result = await runCommandWithResult(command, args, options);
173
+ if (!result.ok) {
174
+ return '';
175
+ }
98
176
 
99
- return result.stdout;
177
+ return result.stdout;
100
178
  };
101
179
 
180
+ const waitForMilliseconds = (milliseconds) =>
181
+ new Promise((resolve) => {
182
+ setTimeout(resolve, Math.max(0, Number(milliseconds) || 0));
183
+ });
184
+
102
185
  const getConfiguredCommand = (environmentVariableName, fallbackCommand) => {
103
- const configuredCommand = String(process.env[environmentVariableName] || '').trim();
104
- return configuredCommand || fallbackCommand;
186
+ const configuredCommand = String(
187
+ process.env[environmentVariableName] || '',
188
+ ).trim();
189
+ return configuredCommand || fallbackCommand;
190
+ };
191
+
192
+ const expandTildePath = (value, homeDir = os.homedir()) => {
193
+ const normalized = String(value || '').trim();
194
+ if (!normalized) {
195
+ return '';
196
+ }
197
+
198
+ if (normalized === '~') {
199
+ return homeDir;
200
+ }
201
+
202
+ if (normalized.startsWith('~/') || normalized.startsWith('~\\')) {
203
+ return path.join(homeDir, normalized.slice(2));
204
+ }
205
+
206
+ return normalized;
207
+ };
208
+
209
+ const resolveAgentConfigFilePath = (mainConfigPath, configFileValue) => {
210
+ const normalizedValue = expandTildePath(configFileValue);
211
+ if (!normalizedValue) {
212
+ return '';
213
+ }
214
+
215
+ const mainPath =
216
+ String(mainConfigPath || '').trim() ||
217
+ path.resolve(process.cwd(), '.codex', 'config.toml');
218
+ const mainDirectory = path.dirname(mainPath);
219
+
220
+ return path.resolve(mainDirectory, normalizedValue);
105
221
  };
106
222
 
107
223
  const getVersionCommands = () => ({
108
- codexCommand: getConfiguredCommand(CODEX_BIN_ENV_VAR, 'codex'),
109
- npmCommand: getConfiguredCommand(NPM_BIN_ENV_VAR, 'npm'),
224
+ codexCommand: getConfiguredCommand(CODEX_BIN_ENV_VAR, 'codex'),
225
+ npmCommand: getConfiguredCommand(NPM_BIN_ENV_VAR, 'npm'),
110
226
  });
111
227
 
112
228
  const getLatestPackageVersion = async (npmCommand, packageName) => {
113
- const latestOutput = await runCommand(npmCommand, ['view', packageName, 'version', '--json']);
114
- return normalizeVersion(latestOutput) || latestOutput.trim();
229
+ const latestOutput = await runCommand(npmCommand, [
230
+ 'view',
231
+ packageName,
232
+ 'version',
233
+ '--json',
234
+ ]);
235
+ return normalizeVersion(latestOutput) || latestOutput.trim();
115
236
  };
116
237
 
117
238
  const updateGlobalPackageToLatest = async (npmCommand, packageName) => {
118
- const result = await runCommandWithResult(
119
- npmCommand,
120
- ['install', '-g', `${packageName}@latest`],
121
- {
122
- timeout: UPDATE_COMMAND_TIMEOUT_MS,
123
- maxBuffer: UPDATE_MAX_BUFFER_BYTES,
124
- }
125
- );
126
-
127
- return result.ok;
239
+ const result = await runCommandWithResult(
240
+ npmCommand,
241
+ ['install', '-g', `${packageName}@latest`],
242
+ {
243
+ timeout: UPDATE_COMMAND_TIMEOUT_MS,
244
+ maxBuffer: UPDATE_MAX_BUFFER_BYTES,
245
+ },
246
+ );
247
+
248
+ return result.ok;
128
249
  };
129
250
 
130
251
  const getCodexVersion = async (codexCommand) => {
131
- const output = await runCommand(codexCommand, ['--version']);
132
- const firstLine = output.split('\n')[0]?.trim();
252
+ const output = await runCommand(codexCommand, ['--version']);
253
+ const firstLine = output.split('\n')[0]?.trim();
133
254
 
134
- if (!firstLine) {
135
- return 'version unavailable';
136
- }
255
+ if (!firstLine) {
256
+ return 'version unavailable';
257
+ }
137
258
 
138
- return firstLine.startsWith('codex') ? firstLine : `version ${firstLine}`;
259
+ return firstLine.startsWith('codex') ? firstLine : `version ${firstLine}`;
139
260
  };
140
261
 
141
262
  const normalizeVersion = (value) => {
142
- const match = String(value || '').match(/(\d+\.\d+\.\d+(?:[-+._][0-9A-Za-z.-]+)*)/);
143
- return match ? match[1] : '';
263
+ const match = String(value || '').match(
264
+ /(\d+\.\d+\.\d+(?:[-+._][0-9A-Za-z.-]+)*)/,
265
+ );
266
+ return match ? match[1] : '';
144
267
  };
145
268
 
146
- const toVersionParts = (value) =>
147
- normalizeVersion(value)
148
- .split(/[-+._]/)[0]
149
- .split('.')
150
- .map((part) => Number.parseInt(part, 10))
151
- .filter(Number.isFinite);
152
-
153
- const compareVersions = (left, right) => {
154
- const leftParts = toVersionParts(left);
155
- const rightParts = toVersionParts(right);
156
- const maxLength = Math.max(leftParts.length, rightParts.length);
157
-
158
- for (let index = 0; index < maxLength; index += 1) {
159
- const a = leftParts[index] || 0;
160
- const b = rightParts[index] || 0;
161
-
162
- if (a > b) {
163
- return 1;
164
- }
269
+ const parseVersion = (value) => {
270
+ const normalized = normalizeVersion(value);
271
+ const [core, ...suffixParts] = String(normalized || '').split(/[-+]/);
272
+ const suffix = suffixParts.join('-');
273
+ const coreParts = core
274
+ .split('.')
275
+ .map((part) => Number.parseInt(part, 10))
276
+ .filter(Number.isFinite);
277
+
278
+ return {
279
+ hasSuffix: Boolean(suffix),
280
+ suffix,
281
+ parts: coreParts,
282
+ };
283
+ };
165
284
 
166
- if (a < b) {
167
- return -1;
168
- }
169
- }
285
+ const comparePreRelease = (leftSuffix, rightSuffix) => {
286
+ const leftParts = String(leftSuffix || '')
287
+ .split('.')
288
+ .filter(Boolean);
289
+ const rightParts = String(rightSuffix || '')
290
+ .split('.')
291
+ .filter(Boolean);
292
+ const maxLength = Math.max(leftParts.length, rightParts.length);
293
+
294
+ for (let index = 0; index < maxLength; index += 1) {
295
+ const leftPart = leftParts[index];
296
+ const rightPart = rightParts[index];
297
+ const leftNumber = Number.parseInt(leftPart, 10);
298
+ const rightNumber = Number.parseInt(rightPart, 10);
299
+
300
+ const leftIsNumber = Number.isFinite(leftNumber);
301
+ const rightIsNumber = Number.isFinite(rightNumber);
302
+
303
+ if (leftIsNumber && rightIsNumber) {
304
+ if (leftNumber > rightNumber) {
305
+ return 1;
306
+ }
307
+
308
+ if (leftNumber < rightNumber) {
309
+ return -1;
310
+ }
311
+
312
+ continue;
313
+ }
314
+
315
+ if (leftIsNumber && !rightIsNumber) {
316
+ return -1;
317
+ }
318
+
319
+ if (!leftIsNumber && rightIsNumber) {
320
+ return 1;
321
+ }
322
+
323
+ if ((leftPart || '') > (rightPart || '')) {
324
+ return 1;
325
+ }
326
+
327
+ if ((leftPart || '') < (rightPart || '')) {
328
+ return -1;
329
+ }
330
+ }
331
+
332
+ return 0;
333
+ };
170
334
 
171
- return 0;
335
+ const compareVersions = (left, right) => {
336
+ const leftVersion = parseVersion(left);
337
+ const rightVersion = parseVersion(right);
338
+ const leftParts = leftVersion.parts;
339
+ const rightParts = rightVersion.parts;
340
+ const maxLength = Math.max(leftParts.length, rightParts.length);
341
+
342
+ for (let index = 0; index < maxLength; index += 1) {
343
+ const a = leftParts[index] || 0;
344
+ const b = rightParts[index] || 0;
345
+
346
+ if (a > b) {
347
+ return 1;
348
+ }
349
+
350
+ if (a < b) {
351
+ return -1;
352
+ }
353
+ }
354
+
355
+ if (leftVersion.hasSuffix !== rightVersion.hasSuffix) {
356
+ return leftVersion.hasSuffix ? -1 : 1;
357
+ }
358
+
359
+ if (leftVersion.hasSuffix) {
360
+ return comparePreRelease(leftVersion.suffix, rightVersion.suffix);
361
+ }
362
+
363
+ return 0;
172
364
  };
173
365
 
174
366
  const getCodexUpdateStatus = async () => {
175
- const commands = getVersionCommands();
176
- const installedLabel = await getCodexVersion(commands.codexCommand);
177
- const installed = normalizeVersion(installedLabel);
178
-
179
- if (!installed) {
180
- return {
181
- installed: installedLabel,
182
- latest: 'unknown',
183
- status: 'version check unavailable',
184
- };
185
- }
186
-
187
- const latestOutput = await runCommand(commands.npmCommand, [
188
- 'view',
189
- '@openai/codex',
190
- 'version',
191
- '--json',
192
- ]);
193
- const latest = normalizeVersion(latestOutput) || latestOutput.trim();
194
-
195
- if (!latest) {
196
- return {
197
- installed,
198
- latest: 'unknown',
199
- status: 'version check unavailable',
200
- };
201
- }
202
-
203
- const comparison = compareVersions(installed, latest);
204
- if (comparison < 0) {
205
- return {
206
- installed,
207
- latest,
208
- status: 'update available',
209
- };
210
- }
211
-
212
- return {
213
- installed,
214
- latest,
215
- status: 'up to date',
216
- };
367
+ const commands = getVersionCommands();
368
+ const installedLabel = await getCodexVersion(commands.codexCommand);
369
+ const installed = normalizeVersion(installedLabel);
370
+
371
+ if (!installed) {
372
+ return {
373
+ installed: installedLabel,
374
+ latest: 'unknown',
375
+ status: 'Version check unavailable',
376
+ };
377
+ }
378
+
379
+ const latestOutput = await runCommand(commands.npmCommand, [
380
+ 'view',
381
+ '@openai/codex',
382
+ 'version',
383
+ '--json',
384
+ ]);
385
+ const latest = normalizeVersion(latestOutput) || latestOutput.trim();
386
+
387
+ if (!latest) {
388
+ return {
389
+ installed,
390
+ latest: 'unknown',
391
+ status: 'Version check unavailable',
392
+ };
393
+ }
394
+
395
+ const comparison = compareVersions(installed, latest);
396
+ if (comparison < 0) {
397
+ return {
398
+ installed,
399
+ latest,
400
+ status: 'Update available',
401
+ };
402
+ }
403
+
404
+ return {
405
+ installed,
406
+ latest,
407
+ status: 'Up to date',
408
+ };
217
409
  };
218
410
 
219
411
  const ensureLatestConfiguratorVersion = async (npmCommand) => {
220
- const installed = normalizeVersion(PACKAGE_VERSION);
221
- if (!installed) {
222
- return;
223
- }
224
-
225
- const latest = await getLatestPackageVersion(npmCommand, CONFIGURATOR_PACKAGE_NAME);
226
- if (!latest) {
227
- return;
228
- }
229
-
230
- if (compareVersions(installed, latest) >= 0) {
231
- return;
232
- }
233
-
234
- await updateGlobalPackageToLatest(npmCommand, CONFIGURATOR_PACKAGE_NAME);
412
+ const installed = normalizeVersion(PACKAGE_VERSION);
413
+ if (!installed) {
414
+ return;
415
+ }
416
+
417
+ const latest = await getLatestPackageVersion(
418
+ npmCommand,
419
+ CONFIGURATOR_PACKAGE_NAME,
420
+ );
421
+ if (!latest) {
422
+ return;
423
+ }
424
+
425
+ if (compareVersions(installed, latest) >= 0) {
426
+ return;
427
+ }
428
+
429
+ await updateGlobalPackageToLatest(npmCommand, CONFIGURATOR_PACKAGE_NAME);
235
430
  };
236
431
 
237
432
  const App = () => {
238
- const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
239
- const { stdout } = useStdout();
240
- const terminalWidth = stdout?.columns || 100;
241
- const terminalHeight = stdout?.rows || 24;
242
-
243
- const [snapshot, setSnapshot] = useState(readConfig);
244
- const [pathSegments, setPathSegments] = useState([]);
245
- const [selectedIndex, setSelectedIndex] = useState(0);
246
- const [selectionByPath, setSelectionByPath] = useState({});
247
- const [scrollOffset, setScrollOffset] = useState(0);
248
- const [editMode, setEditMode] = useState(null);
249
- const [editError, setEditError] = useState('');
250
- const [filterQuery, setFilterQuery] = useState('');
251
- const [isFilterEditing, setIsFilterEditing] = useState(false);
252
- const [codexVersion, setCodexVersion] = useState('version loading...');
253
- const [codexVersionStatus, setCodexVersionStatus] = useState('');
254
- const { exit } = useApp();
255
-
256
- useEffect(() => {
257
- let isCancelled = false;
258
-
259
- const loadVersionStatus = async () => {
260
- const check = await getCodexUpdateStatus();
261
-
262
- if (isCancelled) {
263
- return;
264
- }
265
-
266
- setCodexVersion(check.installed);
267
- setCodexVersionStatus(check.status);
268
- };
269
-
270
- const ensureLatestConfigurator = async () => {
271
- const commands = getVersionCommands();
272
- await ensureLatestConfiguratorVersion(commands.npmCommand);
273
- };
274
-
275
- loadVersionStatus();
276
- ensureLatestConfigurator();
277
-
278
- return () => {
279
- isCancelled = true;
280
- };
281
- }, []);
282
-
283
- const currentNode = getNodeAtPath(snapshot.ok ? snapshot.data : {}, pathSegments);
284
- const allRows = buildRows(currentNode, pathSegments);
285
- const rows = filterRowsByQuery(allRows, filterQuery);
286
- const safeSelected = rows.length === 0 ? 0 : Math.min(selectedIndex, rows.length - 1);
287
- const listViewportHeight = computeListViewportHeight(rows, terminalHeight);
288
- const currentPathKey = pathToKey(pathSegments);
289
-
290
- const getSavedIndex = (segments, fallback = 0) => {
291
- const key = pathToKey(segments);
292
- const maybe = selectionByPath[key];
293
-
294
- if (Number.isInteger(maybe)) {
295
- return maybe;
296
- }
297
-
298
- return fallback;
299
- };
300
-
301
- const adjustScrollForSelection = (nextSelection, nextViewportHeight, totalRows) => {
302
- const maxOffset = Math.max(0, totalRows - nextViewportHeight);
303
- const minOffset = 0;
304
-
305
- setScrollOffset((previous) => {
306
- if (nextSelection < previous) {
307
- return clamp(nextSelection, minOffset, maxOffset);
308
- }
309
-
310
- if (nextSelection > previous + nextViewportHeight - 1) {
311
- return clamp(nextSelection - nextViewportHeight + 1, minOffset, maxOffset);
312
- }
313
-
314
- return clamp(previous, minOffset, maxOffset);
315
- });
316
- };
317
-
318
- const beginEditing = (target, targetPath) => {
319
- const options = getConfigOptions(targetPath, target.key, target.value, target.kind) || [];
320
- if (options.length === 0) {
321
- return;
322
- }
323
-
324
- setEditError('');
325
- setEditMode({
326
- mode: 'select',
327
- path: targetPath,
328
- options,
329
- selectedOptionIndex: clamp(options.findIndex((option) => Object.is(option, target.value)), 0, options.length - 1),
330
- savedOptionIndex: null,
331
- });
332
- };
333
-
334
- const beginTextEditing = (target, targetPath) => {
335
- setEditError('');
336
- setEditMode({
337
- mode: 'text',
338
- path: targetPath,
339
- draftValue: typeof target.value === 'string' ? target.value : '',
340
- savedValue: null,
341
- });
342
- };
343
-
344
- const beginAddIdEditing = (targetPath, placeholder = 'id') => {
345
- setEditError('');
346
- setEditMode({
347
- mode: 'add-id',
348
- path: targetPath,
349
- placeholder,
350
- draftValue: '',
351
- savedValue: null,
352
- });
353
- };
354
-
355
- const applyEdit = () => {
356
- if (!editMode || editMode.mode !== 'select') {
357
- return;
358
- }
359
-
360
- const nextIndex = editMode.selectedOptionIndex;
361
- const nextValue = editMode.options[nextIndex];
362
- const nextData = setValueAtPath(snapshot.ok ? snapshot.data : {}, editMode.path, nextValue);
363
- const writeResult = writeConfig(nextData, snapshot.path);
364
-
365
- if (!writeResult.ok) {
366
- setEditError(writeResult.error);
367
- return;
368
- }
369
-
370
- setSnapshot({
371
- ok: true,
372
- path: snapshot.path,
373
- data: nextData,
374
- });
375
- setEditMode(null);
376
- setEditError('');
377
- };
378
-
379
- const applyTextEdit = () => {
380
- if (!editMode || editMode.mode !== 'text') {
381
- return;
382
- }
383
-
384
- const nextData = setValueAtPath(snapshot.ok ? snapshot.data : {}, editMode.path, editMode.draftValue);
385
- const writeResult = writeConfig(nextData, snapshot.path);
386
-
387
- if (!writeResult.ok) {
388
- setEditError(writeResult.error);
389
- return;
390
- }
391
-
392
- setSnapshot({
393
- ok: true,
394
- path: snapshot.path,
395
- data: nextData,
396
- });
397
- setEditMode(null);
398
- setEditError('');
399
- };
400
-
401
- const applyAddId = () => {
402
- if (!editMode || editMode.mode !== 'add-id') {
403
- return;
404
- }
405
-
406
- const nextIdInput = String(editMode.draftValue || '').trim();
407
- const placeholder = getReferenceCustomIdPlaceholder(editMode.path);
408
- let nextId = nextIdInput;
409
-
410
- if (placeholder === '<path>') {
411
- const normalizedPath = normalizeCustomPathId(nextIdInput);
412
- if (!normalizedPath.ok) {
413
- setEditError(normalizedPath.error);
414
- return;
415
- }
416
-
417
- nextId = normalizedPath.value;
418
- }
419
-
420
- if (!nextId) {
421
- setEditError('ID cannot be empty.');
422
- return;
423
- }
424
-
425
- const nextPath = [...editMode.path, nextId];
426
- const data = snapshot.ok ? snapshot.data : {};
427
- const existingValue = getNodeAtPath(data, nextPath);
428
-
429
- if (typeof existingValue !== 'undefined') {
430
- setEditError(`ID "${nextId}" already exists.`);
431
- return;
432
- }
433
-
434
- const nextData = setValueAtPath(data, nextPath, {});
435
- const writeResult = writeConfig(nextData, snapshot.path);
436
-
437
- if (!writeResult.ok) {
438
- setEditError(writeResult.error);
439
- return;
440
- }
441
-
442
- setSnapshot({
443
- ok: true,
444
- path: snapshot.path,
445
- data: nextData,
446
- });
447
- setPathSegments(nextPath);
448
- setSelectedIndex(0);
449
- setScrollOffset(0);
450
- setEditMode(null);
451
- setEditError('');
452
- };
453
-
454
- const applyBooleanToggle = (target, targetPath) => {
455
- const nextValue = !target.value;
456
- const data = snapshot.ok ? snapshot.data : {};
457
- const nextData = setValueAtPath(data, targetPath, nextValue);
458
-
459
- const writeResult = writeConfig(nextData, snapshot.path);
460
-
461
- if (!writeResult.ok) {
462
- setEditError(writeResult.error);
463
- return;
464
- }
465
-
466
- setSnapshot({
467
- ok: true,
468
- path: snapshot.path,
469
- data: nextData,
470
- });
471
- setEditError('');
472
- };
473
-
474
- const unsetValueAtPath = (targetPath) => {
475
- const data = snapshot.ok ? snapshot.data : {};
476
- const hasConfiguredValue = typeof getNodeAtPath(data, targetPath) !== 'undefined';
477
-
478
- if (!hasConfiguredValue) {
479
- setEditError('');
480
- return;
481
- }
482
-
483
- const nextData = deleteValueAtPath(data, targetPath);
484
- const writeResult = writeConfig(nextData, snapshot.path);
485
-
486
- if (!writeResult.ok) {
487
- setEditError(writeResult.error);
488
- return;
489
- }
490
-
491
- setSnapshot({
492
- ok: true,
493
- path: snapshot.path,
494
- data: nextData,
495
- });
496
- setEditError('');
497
- };
498
-
499
- useInput((input, key) => {
500
- const isTextEditing = isInlineTextMode(editMode?.mode);
501
-
502
- if (isFilterEditing) {
503
- if (key.return || key.escape) {
504
- setIsFilterEditing(false);
505
- return;
506
- }
507
-
508
- if ((key.ctrl && key.name === 'u') || input === '\u0015') {
509
- setFilterQuery('');
510
- return;
511
- }
512
-
513
- if (isDeleteKey(input, key) || isBackspaceKey(input, key)) {
514
- setFilterQuery((previous) => previous.slice(0, -1));
515
- return;
516
- }
517
-
518
- if (
519
- key.rightArrow ||
520
- key.leftArrow ||
521
- key.upArrow ||
522
- key.downArrow ||
523
- isPageUpKey(input, key) ||
524
- isPageDownKey(input, key) ||
525
- isHomeKey(input, key) ||
526
- isEndKey(input, key)
527
- ) {
528
- return;
529
- }
530
-
531
- if (!key.ctrl && !key.meta && input.length > 0) {
532
- setFilterQuery((previous) => `${previous}${input}`);
533
- }
534
- return;
535
- }
536
-
537
- if (input === 'q' && !isTextEditing) {
538
- exit();
539
- return;
540
- }
541
-
542
- if (!editMode && input === '/') {
543
- setIsFilterEditing(true);
544
- return;
545
- }
546
-
547
- if (editMode) {
548
- if (isInlineTextMode(editMode.mode)) {
549
- if (key.return) {
550
- if (editMode.mode === 'text') {
551
- applyTextEdit();
552
- } else {
553
- applyAddId();
554
- }
555
- return;
556
- }
557
-
558
- if (key.escape) {
559
- setEditMode(null);
560
- setEditError('');
561
- return;
562
- }
563
-
564
- if (key.leftArrow || isBackspaceKey(input, key)) {
565
- setEditMode(null);
566
- setEditError('');
567
- return;
568
- }
569
-
570
- if (isDeleteKey(input, key)) {
571
- setEditMode((previous) => ({
572
- ...previous,
573
- draftValue: previous.draftValue.slice(0, -1),
574
- }));
575
- return;
576
- }
577
-
578
- if (
579
- key.rightArrow ||
580
- key.upArrow ||
581
- key.downArrow ||
582
- isPageUpKey(input, key) ||
583
- isPageDownKey(input, key) ||
584
- isHomeKey(input, key) ||
585
- isEndKey(input, key)
586
- ) {
587
- return;
588
- }
589
-
590
- if (!key.ctrl && !key.meta && input.length > 0) {
591
- setEditMode((previous) => ({
592
- ...previous,
593
- draftValue: `${previous.draftValue}${input}`,
594
- }));
595
- }
596
-
597
- return;
598
- }
599
-
600
- if (key.upArrow) {
601
- setEditMode((previous) => ({
602
- ...previous,
603
- selectedOptionIndex: clamp(previous.selectedOptionIndex - 1, 0, previous.options.length - 1),
604
- }));
605
- return;
606
- }
607
-
608
- if (key.downArrow) {
609
- setEditMode((previous) => ({
610
- ...previous,
611
- selectedOptionIndex: clamp(previous.selectedOptionIndex + 1, 0, previous.options.length - 1),
612
- }));
613
- return;
614
- }
615
-
616
- if (isPageUpKey(input, key)) {
617
- setEditMode((previous) => ({
618
- ...previous,
619
- selectedOptionIndex: clamp(
620
- previous.selectedOptionIndex - listViewportHeight,
621
- 0,
622
- previous.options.length - 1
623
- ),
624
- }));
625
- return;
626
- }
627
-
628
- if (isPageDownKey(input, key)) {
629
- setEditMode((previous) => ({
630
- ...previous,
631
- selectedOptionIndex: clamp(
632
- previous.selectedOptionIndex + listViewportHeight,
633
- 0,
634
- previous.options.length - 1
635
- ),
636
- }));
637
- return;
638
- }
639
-
640
- if (isHomeKey(input, key)) {
641
- setEditMode((previous) => ({
642
- ...previous,
643
- selectedOptionIndex: 0,
644
- }));
645
- return;
646
- }
647
-
648
- if (isEndKey(input, key)) {
649
- setEditMode((previous) => ({
650
- ...previous,
651
- selectedOptionIndex: Math.max(0, previous.options.length - 1),
652
- }));
653
- return;
654
- }
655
-
656
- if (key.return) {
657
- applyEdit();
658
- return;
659
- }
660
-
661
- if (key.leftArrow || isBackspaceKey(input, key)) {
662
- setEditMode(null);
663
- setEditError('');
664
- return;
665
- }
666
-
667
- if (isDeleteKey(input, key)) {
668
- return;
669
- }
670
-
671
- if (key.escape) {
672
- setEditMode(null);
673
- setEditError('');
674
- return;
675
- }
676
- }
677
-
678
- if (key.upArrow) {
679
- if (rows.length === 0) {
680
- return;
681
- }
682
-
683
- setSelectedIndex((previous) => {
684
- const next = Math.max(previous - 1, 0);
685
- adjustScrollForSelection(next, listViewportHeight, rows.length);
686
- return next;
687
- });
688
- return;
689
- }
690
-
691
- if (key.downArrow) {
692
- if (rows.length === 0) {
693
- return;
694
- }
695
-
696
- setSelectedIndex((previous) => {
697
- const next = Math.min(previous + 1, rows.length - 1);
698
- adjustScrollForSelection(next, listViewportHeight, rows.length);
699
- return next;
700
- });
701
- return;
702
- }
703
-
704
- if (isPageUpKey(input, key)) {
705
- if (rows.length === 0) {
706
- return;
707
- }
708
-
709
- setSelectedIndex((previous) => {
710
- const next = Math.max(previous - listViewportHeight, 0);
711
- adjustScrollForSelection(next, listViewportHeight, rows.length);
712
- return next;
713
- });
714
- return;
715
- }
716
-
717
- if (isPageDownKey(input, key)) {
718
- if (rows.length === 0) {
719
- return;
720
- }
721
-
722
- setSelectedIndex((previous) => {
723
- const next = Math.min(previous + listViewportHeight, rows.length - 1);
724
- adjustScrollForSelection(next, listViewportHeight, rows.length);
725
- return next;
726
- });
727
- return;
728
- }
729
-
730
- if (isHomeKey(input, key)) {
731
- if (rows.length === 0) {
732
- return;
733
- }
734
-
735
- setSelectedIndex(0);
736
- adjustScrollForSelection(0, listViewportHeight, rows.length);
737
- return;
738
- }
739
-
740
- if (isEndKey(input, key)) {
741
- if (rows.length === 0) {
742
- return;
743
- }
744
-
745
- const next = rows.length - 1;
746
- setSelectedIndex(next);
747
- adjustScrollForSelection(next, listViewportHeight, rows.length);
748
- return;
749
- }
750
-
751
- if (key.return && rows[safeSelected]) {
752
- const target = rows[safeSelected];
753
-
754
- if (target.kind === 'action' && target.action === 'add-custom-id') {
755
- beginAddIdEditing(pathSegments, target.placeholder || 'id');
756
- return;
757
- }
758
-
759
- if (target.kind === 'table' || target.kind === 'tableArray') {
760
- const nextPath = [...pathSegments, target.pathSegment];
761
-
762
- setPathSegments((previous) => [...previous, target.pathSegment]);
763
- setSelectionByPath((previous) => ({
764
- ...previous,
765
- [currentPathKey]: safeSelected,
766
- }));
767
-
768
- const nextNode = getNodeAtPath(snapshot.ok ? snapshot.data : {}, nextPath);
769
- const nextRows = buildRows(nextNode, nextPath);
770
- const nextViewportHeight = computeListViewportHeight(nextRows, terminalHeight);
771
- const nextSavedIndex = getSavedIndex(nextPath, 0);
772
- const nextSelected = nextRows.length === 0 ? 0 : clamp(nextSavedIndex, 0, nextRows.length - 1);
773
-
774
- setSelectedIndex(nextSelected);
775
- setScrollOffset(clamp(nextSelected, 0, Math.max(0, nextRows.length - nextViewportHeight)));
776
- return;
777
- }
778
-
779
- const targetPath = [...pathSegments, target.pathSegment];
780
- const options = getConfigOptions(targetPath, target.key, target.value, target.kind) || [];
781
- if (typeof target.value === 'boolean' || isBooleanOnlyOptions(options)) {
782
- applyBooleanToggle(
783
- typeof target.value === 'boolean'
784
- ? target
785
- : { ...target, value: false },
786
- targetPath
787
- );
788
- return;
789
- }
790
-
791
- if (options.length > 0) {
792
- beginEditing(target, targetPath);
793
- return;
794
- }
795
-
796
- if (isStringField(targetPath, target.value)) {
797
- beginTextEditing(target, targetPath);
798
- }
799
- return;
800
- }
801
-
802
- if (isDeleteKey(input, key) && rows[safeSelected]) {
803
- const target = rows[safeSelected];
804
- const isValueRow = target.kind === 'value';
805
- const isCustomIdRow = isCustomIdTableRow(pathSegments, target);
806
- const isInsideArray = Array.isArray(currentNode);
807
-
808
- if ((!isValueRow && !isCustomIdRow) || isInsideArray) {
809
- return;
810
- }
811
-
812
- const targetPath = [...pathSegments, target.pathSegment];
813
- unsetValueAtPath(targetPath);
814
- return;
815
- }
816
-
817
- if (input === 'r') {
818
- setSnapshot(readConfig());
819
- setPathSegments([]);
820
- setSelectedIndex(0);
821
- setSelectionByPath({});
822
- setScrollOffset(0);
823
- setEditMode(null);
824
- setEditError('');
825
- return;
826
- }
827
-
828
- if (key.leftArrow || isBackspaceKey(input, key) || key.escape) {
829
- if (pathSegments.length === 0) {
830
- return;
831
- }
832
-
833
- const parentPath = pathSegments.slice(0, -1);
834
- const parentNode = getNodeAtPath(snapshot.ok ? snapshot.data : {}, parentPath);
835
- const parentRows = buildRows(parentNode, parentPath);
836
- const savedIndex = getSavedIndex(parentPath, 0);
837
-
838
- setPathSegments(parentPath);
839
- setSelectionByPath((previous) => ({
840
- ...previous,
841
- [currentPathKey]: safeSelected,
842
- }));
843
-
844
- const parentViewportHeight = computeListViewportHeight(parentRows, terminalHeight);
845
- const parentSelected = parentRows.length === 0 ? 0 : clamp(savedIndex, 0, parentRows.length - 1);
846
- setSelectedIndex(parentSelected);
847
- setScrollOffset(clamp(parentSelected, 0, Math.max(0, parentRows.length - parentViewportHeight)));
848
- setEditMode(null);
849
- setEditError('');
850
- return;
851
- }
852
- }, { isActive: isInteractive });
853
-
854
- useEffect(() => {
855
- const maxOffset = Math.max(0, rows.length - listViewportHeight);
856
- setScrollOffset((previous) => clamp(previous, 0, maxOffset));
857
- }, [rows.length, listViewportHeight]);
858
-
859
- if (!isInteractive) {
860
- return React.createElement(
861
- Box,
862
- { flexDirection: 'column', padding: 1 },
863
- React.createElement(Header, {
864
- codexVersion,
865
- codexVersionStatus,
866
- packageVersion: PACKAGE_VERSION,
867
- }),
868
- React.createElement(ConfigNavigator, {
869
- snapshot,
870
- pathSegments,
871
- selectedIndex: 0,
872
- terminalWidth,
873
- terminalHeight,
874
- scrollOffset: 0,
875
- editMode: null,
876
- editError: editError,
877
- filterQuery,
878
- isFilterEditing,
879
- }),
880
- React.createElement(Text, { color: 'yellow' }, 'Non-interactive mode: input is disabled.')
881
- );
882
- }
883
-
884
- return React.createElement(
885
- Box,
886
- { flexDirection: 'column', padding: 1 },
887
- React.createElement(Header, {
888
- codexVersion,
889
- codexVersionStatus,
890
- packageVersion: PACKAGE_VERSION,
891
- }),
892
- React.createElement(ConfigNavigator, {
893
- snapshot,
894
- pathSegments,
895
- selectedIndex: safeSelected,
896
- terminalWidth,
897
- terminalHeight,
898
- scrollOffset,
899
- editMode,
900
- editError,
901
- filterQuery,
902
- isFilterEditing,
903
- }),
904
- React.createElement(
905
- Text,
906
- { color: 'gray' },
907
- isFilterEditing ? FILTER_CONTROL_HINT : editMode ? EDIT_CONTROL_HINT : CONTROL_HINT
908
- )
909
- );
433
+ const initialMainSnapshot = readConfig();
434
+ const initialCatalog = buildConfigFileCatalog(initialMainSnapshot);
435
+ const initialActiveFileId = initialCatalog[0]?.id || MAIN_CONFIG_FILE_ID;
436
+
437
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
438
+ const { stdout } = useStdout();
439
+ const defaultTerminalWidth = 100;
440
+ const defaultTerminalHeight = 24;
441
+ const [terminalSize, setTerminalSize] = useState({
442
+ width: stdout?.columns || defaultTerminalWidth,
443
+ height: stdout?.rows || defaultTerminalHeight,
444
+ });
445
+ const terminalWidth = terminalSize.width || defaultTerminalWidth;
446
+ const terminalHeight = terminalSize.height || defaultTerminalHeight;
447
+
448
+ useEffect(() => {
449
+ const handleResize = () => {
450
+ const nextWidth = process.stdout?.columns || defaultTerminalWidth;
451
+ const nextHeight = process.stdout?.rows || defaultTerminalHeight;
452
+ setTerminalSize({
453
+ width: nextWidth,
454
+ height: nextHeight,
455
+ });
456
+ };
457
+
458
+ process.stdout?.on('resize', handleResize);
459
+ return () => {
460
+ process.stdout?.off('resize', handleResize);
461
+ };
462
+ }, []);
463
+
464
+ const [state, dispatch] = useReducer(
465
+ appStateReducer,
466
+ buildInitialAppState(
467
+ initialMainSnapshot,
468
+ initialCatalog,
469
+ initialActiveFileId,
470
+ ),
471
+ );
472
+ const commandModeLockRef = useRef(false);
473
+ const commandInputRef = useRef('');
474
+ const setAppState = (key, valueOrUpdater) =>
475
+ dispatch({
476
+ type: APP_STATE_ACTION,
477
+ payload: { key, valueOrUpdater },
478
+ });
479
+ const setStateBatch = (updates) =>
480
+ dispatch({ type: APP_STATE_ACTION, payload: { updates } });
481
+ const resetSchemaDerivedEditState = () =>
482
+ dispatch({ type: REFERENCE_SCHEMA_CHANGED_ACTION });
483
+
484
+ const setSnapshot = (valueOrUpdater) =>
485
+ setAppState('snapshot', valueOrUpdater);
486
+ const setSnapshotByFileId = (valueOrUpdater) =>
487
+ setAppState('snapshotByFileId', valueOrUpdater);
488
+ const setConfigFileCatalog = (valueOrUpdater) =>
489
+ setAppState('configFileCatalog', valueOrUpdater);
490
+ const setSelectedIndex = (valueOrUpdater) =>
491
+ setAppState('selectedIndex', valueOrUpdater);
492
+ const setScrollOffset = (valueOrUpdater) =>
493
+ setAppState('scrollOffset', valueOrUpdater);
494
+ const setEditMode = (valueOrUpdater) =>
495
+ setAppState('editMode', valueOrUpdater);
496
+ const setFileSwitchIndex = (valueOrUpdater) =>
497
+ setAppState('fileSwitchIndex', valueOrUpdater);
498
+ const setEditError = (valueOrUpdater) =>
499
+ setAppState('editError', valueOrUpdater);
500
+ const setCommandMode = (valueOrUpdater) => {
501
+ const nextValue =
502
+ typeof valueOrUpdater === 'function'
503
+ ? Boolean(valueOrUpdater(isCommandMode))
504
+ : Boolean(valueOrUpdater);
505
+ commandModeLockRef.current = nextValue;
506
+ setAppState('isCommandMode', nextValue);
507
+ };
508
+ const setCommandInput = (valueOrUpdater) => {
509
+ const previous = String(commandInputRef.current || '');
510
+ const resolved =
511
+ typeof valueOrUpdater === 'function'
512
+ ? valueOrUpdater(previous)
513
+ : valueOrUpdater;
514
+ const next = String(resolved ?? '');
515
+ commandInputRef.current = next;
516
+ setAppState('commandInput', next);
517
+ };
518
+ const setCommandMessage = (valueOrUpdater) =>
519
+ setAppState('commandMessage', valueOrUpdater);
520
+ const setShowHelp = (valueOrUpdater) =>
521
+ setAppState('showHelp', valueOrUpdater);
522
+ const setFilterQuery = (valueOrUpdater) =>
523
+ setAppState('filterQuery', valueOrUpdater);
524
+ const setIsFilterEditing = (valueOrUpdater) =>
525
+ setAppState('isFilterEditing', valueOrUpdater);
526
+ const setCodexVersion = (valueOrUpdater) =>
527
+ setAppState('codexVersion', valueOrUpdater);
528
+ const setCodexVersionStatus = (valueOrUpdater) =>
529
+ setAppState('codexVersionStatus', valueOrUpdater);
530
+ const setIsSchemaCheckInProgress = (valueOrUpdater) =>
531
+ setAppState('isSchemaCheckInProgress', valueOrUpdater);
532
+ const setSchemaCheckStatusText = (valueOrUpdater) =>
533
+ setAppState('schemaCheckStatusText', valueOrUpdater);
534
+ const {
535
+ snapshot,
536
+ snapshotByFileId,
537
+ configFileCatalog,
538
+ activeConfigFileId,
539
+ pathSegments,
540
+ selectedIndex,
541
+ selectionByPath,
542
+ scrollOffset,
543
+ editMode,
544
+ isFileSwitchMode,
545
+ fileSwitchIndex,
546
+ editError,
547
+ filterQuery,
548
+ isFilterEditing,
549
+ isCommandMode,
550
+ commandInput,
551
+ commandMessage,
552
+ showHelp,
553
+ codexVersion,
554
+ codexVersionStatus,
555
+ isSchemaCheckInProgress,
556
+ schemaCheckStatusText,
557
+ } = state;
558
+ commandInputRef.current = String(commandInput || '');
559
+ const { exit } = useApp();
560
+ const appMode = isFilterEditing
561
+ ? APP_MODES.FILTER
562
+ : isCommandMode
563
+ ? APP_MODES.COMMAND
564
+ : isFileSwitchMode
565
+ ? APP_MODES.FILE_SWITCH
566
+ : editMode
567
+ ? APP_MODES.EDIT
568
+ : APP_MODES.BROWSE;
569
+
570
+ useEffect(() => {
571
+ if (isSchemaCheckInProgress) {
572
+ return undefined;
573
+ }
574
+
575
+ let isCancelled = false;
576
+
577
+ const loadVersionStatus = async () => {
578
+ const check = await getCodexUpdateStatus();
579
+
580
+ if (isCancelled) {
581
+ return;
582
+ }
583
+
584
+ setCodexVersion(check.installed);
585
+ setCodexVersionStatus(check.status);
586
+ };
587
+
588
+ const ensureLatestConfigurator = async () => {
589
+ const commands = getVersionCommands();
590
+ await ensureLatestConfiguratorVersion(commands.npmCommand);
591
+ };
592
+
593
+ setCodexVersionStatus('Checking Codex version');
594
+ loadVersionStatus();
595
+ ensureLatestConfigurator();
596
+
597
+ return () => {
598
+ isCancelled = true;
599
+ };
600
+ }, [isSchemaCheckInProgress]);
601
+
602
+ useEffect(() => {
603
+ let isCancelled = false;
604
+
605
+ const syncSchemaReference = async () => {
606
+ const checkStartedAt = Date.now();
607
+ const updateSchemaStatus = (status) => {
608
+ if (isCancelled) {
609
+ return;
610
+ }
611
+
612
+ setSchemaCheckStatusText(status);
613
+ };
614
+
615
+ await syncReferenceSchemaAtStartup({
616
+ mainConfigPath: initialMainSnapshot.path,
617
+ onSchemaChange: () => {
618
+ if (isCancelled) {
619
+ return;
620
+ }
621
+
622
+ resetSchemaDerivedEditState();
623
+ },
624
+ onStatus: updateSchemaStatus,
625
+ });
626
+
627
+ const elapsed = Date.now() - checkStartedAt;
628
+ const remainingVisibleMs = SCHEMA_STATUS_MIN_VISIBLE_MS - elapsed;
629
+ if (remainingVisibleMs > 0) {
630
+ await waitForMilliseconds(remainingVisibleMs);
631
+ }
632
+
633
+ if (isCancelled) {
634
+ return;
635
+ }
636
+
637
+ setSchemaCheckStatusText('');
638
+ setIsSchemaCheckInProgress(false);
639
+ };
640
+
641
+ syncSchemaReference();
642
+
643
+ return () => {
644
+ isCancelled = true;
645
+ };
646
+ }, []);
647
+
648
+ useEffect(() => {
649
+ const hasActiveFile = configFileCatalog.some(
650
+ (file) => file.id === activeConfigFileId,
651
+ );
652
+ if (hasActiveFile) {
653
+ return;
654
+ }
655
+
656
+ const fallbackFile = configFileCatalog[0];
657
+ if (!fallbackFile) {
658
+ return;
659
+ }
660
+
661
+ const fallbackSnapshot =
662
+ snapshotByFileId[fallbackFile.id] ||
663
+ (fallbackFile.kind === 'agent'
664
+ ? ensureConfigFileExists(fallbackFile.path)
665
+ : readConfig(fallbackFile.path));
666
+ setStateBatch({
667
+ activeConfigFileId: fallbackFile.id,
668
+ snapshotByFileId: {
669
+ ...snapshotByFileId,
670
+ ...(snapshotByFileId[fallbackFile.id]
671
+ ? {}
672
+ : {
673
+ [fallbackFile.id]: fallbackSnapshot,
674
+ }),
675
+ },
676
+ snapshot: fallbackSnapshot,
677
+ pathSegments: [],
678
+ selectedIndex: 0,
679
+ selectionByPath: {},
680
+ scrollOffset: 0,
681
+ });
682
+ }, [configFileCatalog, activeConfigFileId, snapshotByFileId]);
683
+
684
+ useEffect(() => {
685
+ if (!isFileSwitchMode) {
686
+ return;
687
+ }
688
+
689
+ setFileSwitchIndex((previous) => {
690
+ const maxIndex = Math.max(0, configFileCatalog.length - 1);
691
+ return clamp(previous, 0, maxIndex);
692
+ });
693
+ }, [isFileSwitchMode, configFileCatalog]);
694
+
695
+ const activeConfigFile =
696
+ configFileCatalog.find((file) => file.id === activeConfigFileId) ||
697
+ configFileCatalog[0];
698
+ const activeConfigFilePath = activeConfigFile?.path || snapshot.path;
699
+ const readActiveConfigSnapshot = () => {
700
+ const activeEntry = resolveActiveFileEntry();
701
+ const targetPath = activeEntry?.path || activeConfigFilePath;
702
+
703
+ if (!activeEntry || activeEntry.kind !== 'agent') {
704
+ return readConfig(targetPath);
705
+ }
706
+
707
+ return ensureConfigFileExists(targetPath);
708
+ };
709
+
710
+ const currentNode = getNodeAtPath(
711
+ snapshot.ok ? snapshot.data : {},
712
+ pathSegments,
713
+ );
714
+ const allRows = buildRows(currentNode, pathSegments);
715
+ const rows = filterRowsByQuery(allRows, filterQuery);
716
+ const safeSelected =
717
+ rows.length === 0 ? 0 : Math.min(selectedIndex, rows.length - 1);
718
+ const fileSwitchPanelExtraRows =
719
+ isInteractive && isFileSwitchMode
720
+ ? computeFileSwitchPanelRows(configFileCatalog.length) +
721
+ FILE_SWITCH_LAYOUT_EXTRA_GAP_ROWS
722
+ : 0;
723
+ const listViewportHeight = computeListViewportRows({
724
+ terminalHeight,
725
+ terminalWidth,
726
+ activeConfigFile,
727
+ packageVersion: PACKAGE_VERSION,
728
+ codexVersion,
729
+ codexVersionStatus,
730
+ isInteractive,
731
+ isCommandMode,
732
+ extraChromeRows: fileSwitchPanelExtraRows,
733
+ });
734
+ const currentPathKey = `${activeConfigFileId}::${pathToKey(pathSegments)}`;
735
+
736
+ const getSavedIndex = (segments, fallback = 0) => {
737
+ const key = `${activeConfigFileId}::${pathToKey(segments)}`;
738
+ const maybe = selectionByPath[key];
739
+
740
+ if (Number.isInteger(maybe)) {
741
+ return maybe;
742
+ }
743
+
744
+ return fallback;
745
+ };
746
+
747
+ const adjustScrollForSelection = (
748
+ nextSelection,
749
+ nextViewportHeight,
750
+ totalRows,
751
+ ) => {
752
+ const maxOffset = Math.max(0, totalRows - nextViewportHeight);
753
+ const minOffset = 0;
754
+
755
+ setScrollOffset((previous) => {
756
+ if (nextSelection < previous) {
757
+ return clamp(nextSelection, minOffset, maxOffset);
758
+ }
759
+
760
+ if (nextSelection > previous + nextViewportHeight - 1) {
761
+ return clamp(
762
+ nextSelection - nextViewportHeight + 1,
763
+ minOffset,
764
+ maxOffset,
765
+ );
766
+ }
767
+
768
+ return clamp(previous, minOffset, maxOffset);
769
+ });
770
+ };
771
+
772
+ const updateActiveSnapshot = (nextSnapshot) => {
773
+ setSnapshot(nextSnapshot);
774
+ setSnapshotByFileId((previous) => ({
775
+ ...previous,
776
+ [activeConfigFileId]: nextSnapshot,
777
+ }));
778
+ };
779
+
780
+ const ensureAgentConfigFile = (nextData, editedPath) => {
781
+ if (activeConfigFileId !== MAIN_CONFIG_FILE_ID) {
782
+ return true;
783
+ }
784
+
785
+ const isAgentConfigFilePath =
786
+ Array.isArray(editedPath) &&
787
+ editedPath.length === 3 &&
788
+ editedPath[0] === 'agents' &&
789
+ editedPath[2] === 'config_file';
790
+ if (!isAgentConfigFilePath) {
791
+ return true;
792
+ }
793
+
794
+ const configFileValue = getNodeAtPath(nextData, editedPath);
795
+ if (typeof configFileValue !== 'string' || !configFileValue.trim()) {
796
+ return true;
797
+ }
798
+
799
+ const normalizedTarget = resolveAgentConfigFilePath(
800
+ activeConfigFile?.path || snapshot.path,
801
+ configFileValue,
802
+ );
803
+ if (!normalizedTarget) {
804
+ return true;
805
+ }
806
+
807
+ const ensureResult = ensureConfigFileExists(normalizedTarget);
808
+ if (!ensureResult.ok) {
809
+ setEditError(ensureResult.error);
810
+ return false;
811
+ }
812
+
813
+ setSnapshotByFileId((previous) => ({
814
+ ...previous,
815
+ [`agent:${normalizedTarget}`]:
816
+ previous[`agent:${normalizedTarget}`] || ensureResult,
817
+ }));
818
+ return true;
819
+ };
820
+
821
+ const resolveActiveFileEntry = () =>
822
+ configFileCatalog.find((file) => file.id === activeConfigFileId);
823
+
824
+ const refreshConfigFileCatalog = (mainSnapshot) => {
825
+ const nextCatalog = buildConfigFileCatalog(mainSnapshot);
826
+ setConfigFileCatalog(nextCatalog);
827
+ return nextCatalog;
828
+ };
829
+
830
+ const switchConfigFile = (nextFileId) => {
831
+ const nextFile = configFileCatalog.find(
832
+ (file) => file.id === nextFileId,
833
+ );
834
+ if (!nextFile) {
835
+ return;
836
+ }
837
+
838
+ const nextSnapshot =
839
+ snapshotByFileId[nextFileId] ||
840
+ (nextFile.kind === 'agent'
841
+ ? ensureConfigFileExists(nextFile.path)
842
+ : readConfig(nextFile.path));
843
+ if (!snapshotByFileId[nextFileId]) {
844
+ setSnapshotByFileId((previous) => ({
845
+ ...previous,
846
+ [nextFileId]: nextSnapshot,
847
+ }));
848
+ }
849
+
850
+ if (nextFileId === activeConfigFileId) {
851
+ return;
852
+ }
853
+
854
+ setStateBatch({
855
+ activeConfigFileId: nextFileId,
856
+ snapshot: nextSnapshot,
857
+ pathSegments: [],
858
+ selectedIndex: 0,
859
+ scrollOffset: 0,
860
+ editMode: null,
861
+ isFileSwitchMode: false,
862
+ editError: '',
863
+ });
864
+ };
865
+
866
+ const beginEditing = (target, targetPath) => {
867
+ const options =
868
+ getConfigOptions(
869
+ targetPath,
870
+ target.key,
871
+ target.value,
872
+ target.kind,
873
+ ) || [];
874
+ if (options.length === 0) {
875
+ return;
876
+ }
877
+
878
+ setEditError('');
879
+ setEditMode({
880
+ mode: 'select',
881
+ path: targetPath,
882
+ options,
883
+ selectedOptionIndex: clamp(
884
+ options.findIndex((option) => Object.is(option, target.value)),
885
+ 0,
886
+ options.length - 1,
887
+ ),
888
+ savedOptionIndex: null,
889
+ });
890
+ };
891
+
892
+ const beginTextEditing = (target, targetPath) => {
893
+ setEditError('');
894
+ setEditMode({
895
+ mode: 'text',
896
+ path: targetPath,
897
+ draftValue: typeof target.value === 'string' ? target.value : '',
898
+ savedValue: null,
899
+ });
900
+ };
901
+
902
+ const beginAddIdEditing = (targetPath, placeholder = 'id') => {
903
+ setEditError('');
904
+ setEditMode({
905
+ mode: 'add-id',
906
+ path: targetPath,
907
+ placeholder,
908
+ draftValue: '',
909
+ savedValue: null,
910
+ });
911
+ };
912
+
913
+ const beginVariantEditing = (target, targetPath, variantMeta) => {
914
+ if (variantMeta?.kind !== 'scalar_object') {
915
+ return;
916
+ }
917
+
918
+ const variantOptions = buildVariantSelectorOptions(variantMeta);
919
+ if (variantOptions.length === 0) {
920
+ return;
921
+ }
922
+
923
+ const currentVariantIndex = isObjectValue(target.value)
924
+ ? variantOptions.findIndex(
925
+ (option) =>
926
+ option.kind === 'object' &&
927
+ objectMatchesVariant(target.value, option),
928
+ )
929
+ : variantOptions.findIndex(
930
+ (option) =>
931
+ option.kind === 'scalar' &&
932
+ Object.is(option.value, String(target.value)),
933
+ );
934
+ const selectedOptionIndex =
935
+ currentVariantIndex >= 0 ? currentVariantIndex : 0;
936
+
937
+ setEditError('');
938
+ setEditMode({
939
+ mode: 'variant-select',
940
+ key: target.key,
941
+ path: targetPath,
942
+ options: variantOptions.map((option) => option.label),
943
+ variantOptions,
944
+ selectedOptionIndex: clamp(
945
+ selectedOptionIndex,
946
+ 0,
947
+ variantOptions.length - 1,
948
+ ),
949
+ savedOptionIndex: null,
950
+ });
951
+ };
952
+
953
+ const openPathView = (nextPath, nextData) => {
954
+ const data =
955
+ typeof nextData === 'undefined'
956
+ ? snapshot.ok
957
+ ? snapshot.data
958
+ : {}
959
+ : nextData;
960
+ const nextNode = getNodeAtPath(data, nextPath);
961
+ const nextRows = buildRows(nextNode, nextPath);
962
+ const nextViewportHeight = computeListViewportRows({
963
+ terminalHeight,
964
+ terminalWidth,
965
+ activeConfigFile,
966
+ packageVersion: PACKAGE_VERSION,
967
+ codexVersion,
968
+ codexVersionStatus,
969
+ isInteractive,
970
+ isCommandMode,
971
+ extraChromeRows: fileSwitchPanelExtraRows,
972
+ });
973
+ const nextSavedIndex = getSavedIndex(nextPath, 0);
974
+ const nextSelected =
975
+ nextRows.length === 0
976
+ ? 0
977
+ : clamp(nextSavedIndex, 0, nextRows.length - 1);
978
+
979
+ setStateBatch({
980
+ selectionByPath: {
981
+ ...selectionByPath,
982
+ [currentPathKey]: safeSelected,
983
+ },
984
+ pathSegments: nextPath,
985
+ selectedIndex: nextSelected,
986
+ scrollOffset: clamp(
987
+ nextSelected,
988
+ 0,
989
+ Math.max(0, nextRows.length - nextViewportHeight),
990
+ ),
991
+ });
992
+ };
993
+
994
+ const applyEdit = () => {
995
+ if (!editMode || editMode.mode !== 'select') {
996
+ return;
997
+ }
998
+
999
+ const nextIndex = editMode.selectedOptionIndex;
1000
+ const nextValue = editMode.options[nextIndex];
1001
+ const nextData = setValueAtPath(
1002
+ snapshot.ok ? snapshot.data : {},
1003
+ editMode.path,
1004
+ nextValue,
1005
+ );
1006
+ if (!ensureAgentConfigFile(nextData, editMode.path)) {
1007
+ return;
1008
+ }
1009
+
1010
+ const writeResult = writeConfig(nextData, snapshot.path);
1011
+
1012
+ if (!writeResult.ok) {
1013
+ setEditError(writeResult.error);
1014
+ return;
1015
+ }
1016
+
1017
+ const nextSnapshot = {
1018
+ ok: true,
1019
+ path: snapshot.path,
1020
+ data: nextData,
1021
+ };
1022
+ updateActiveSnapshot(nextSnapshot);
1023
+
1024
+ if (activeConfigFileId === MAIN_CONFIG_FILE_ID) {
1025
+ refreshConfigFileCatalog(nextSnapshot);
1026
+ }
1027
+ setEditMode(null);
1028
+ setEditError('');
1029
+ };
1030
+
1031
+ const applyTextEdit = () => {
1032
+ if (!editMode || editMode.mode !== 'text') {
1033
+ return;
1034
+ }
1035
+
1036
+ const nextData = setValueAtPath(
1037
+ snapshot.ok ? snapshot.data : {},
1038
+ editMode.path,
1039
+ editMode.draftValue,
1040
+ );
1041
+ if (!ensureAgentConfigFile(nextData, editMode.path)) {
1042
+ return;
1043
+ }
1044
+
1045
+ const writeResult = writeConfig(nextData, snapshot.path);
1046
+
1047
+ if (!writeResult.ok) {
1048
+ setEditError(writeResult.error);
1049
+ return;
1050
+ }
1051
+
1052
+ const nextSnapshot = {
1053
+ ok: true,
1054
+ path: snapshot.path,
1055
+ data: nextData,
1056
+ };
1057
+ updateActiveSnapshot(nextSnapshot);
1058
+
1059
+ if (activeConfigFileId === MAIN_CONFIG_FILE_ID) {
1060
+ refreshConfigFileCatalog(nextSnapshot);
1061
+ }
1062
+ setEditMode(null);
1063
+ setEditError('');
1064
+ };
1065
+
1066
+ const applyAddId = () => {
1067
+ if (!editMode || editMode.mode !== 'add-id') {
1068
+ return;
1069
+ }
1070
+
1071
+ const nextIdInput = String(editMode.draftValue || '').trim();
1072
+ const placeholder = getReferenceCustomIdPlaceholder(editMode.path);
1073
+ let nextId = nextIdInput;
1074
+
1075
+ if (placeholder === '<path>') {
1076
+ const normalizedPath = normalizeCustomPathId(nextIdInput);
1077
+ if (!normalizedPath.ok) {
1078
+ setEditError(normalizedPath.error);
1079
+ return;
1080
+ }
1081
+
1082
+ nextId = normalizedPath.value;
1083
+ }
1084
+
1085
+ if (!nextId) {
1086
+ setEditError('ID cannot be empty.');
1087
+ return;
1088
+ }
1089
+
1090
+ const nextPath = [...editMode.path, nextId];
1091
+ const data = snapshot.ok ? snapshot.data : {};
1092
+ const existingValue = getNodeAtPath(data, nextPath);
1093
+
1094
+ if (typeof existingValue !== 'undefined') {
1095
+ setEditError(`ID "${nextId}" already exists.`);
1096
+ return;
1097
+ }
1098
+
1099
+ openPathView(nextPath, data);
1100
+ setEditMode(null);
1101
+ setEditError('');
1102
+ };
1103
+
1104
+ const applyVariantEdit = () => {
1105
+ if (!editMode || editMode.mode !== 'variant-select') {
1106
+ return;
1107
+ }
1108
+
1109
+ const selectedVariant = Array.isArray(editMode.variantOptions)
1110
+ ? editMode.variantOptions[editMode.selectedOptionIndex]
1111
+ : null;
1112
+ if (!selectedVariant) {
1113
+ setEditMode(null);
1114
+ setEditError('');
1115
+ return;
1116
+ }
1117
+
1118
+ const data = snapshot.ok ? snapshot.data : {};
1119
+ const currentValue = getNodeAtPath(data, editMode.path);
1120
+ const selectionResult = applyVariantSelection({
1121
+ currentValue,
1122
+ selectedVariant,
1123
+ resolveDefaultValue: (requiredKey) => {
1124
+ const requiredPath = [...editMode.path, requiredKey];
1125
+ const requiredOptions =
1126
+ getConfigOptions(
1127
+ requiredPath,
1128
+ requiredKey,
1129
+ undefined,
1130
+ 'value',
1131
+ ) || [];
1132
+ if (requiredOptions.length > 0) {
1133
+ return requiredOptions[0];
1134
+ }
1135
+
1136
+ if (
1137
+ isStringReferenceType(
1138
+ getReferenceOptionForPath(requiredPath)?.type,
1139
+ )
1140
+ ) {
1141
+ return '';
1142
+ }
1143
+
1144
+ return {};
1145
+ },
1146
+ });
1147
+
1148
+ const shouldPersistSelection =
1149
+ selectionResult.changed &&
1150
+ (!selectionResult.isObjectSelection ||
1151
+ selectionResult.isObjectVariantSwitch);
1152
+ let nextData = data;
1153
+ if (shouldPersistSelection) {
1154
+ nextData = setValueAtPath(
1155
+ data,
1156
+ editMode.path,
1157
+ selectionResult.nextValue,
1158
+ );
1159
+ const writeResult = writeConfig(nextData, snapshot.path);
1160
+
1161
+ if (!writeResult.ok) {
1162
+ setEditError(writeResult.error);
1163
+ return;
1164
+ }
1165
+
1166
+ const nextSnapshot = {
1167
+ ok: true,
1168
+ path: snapshot.path,
1169
+ data: nextData,
1170
+ };
1171
+ updateActiveSnapshot(nextSnapshot);
1172
+
1173
+ if (activeConfigFileId === MAIN_CONFIG_FILE_ID) {
1174
+ refreshConfigFileCatalog(nextSnapshot);
1175
+ }
1176
+ }
1177
+
1178
+ if (selectionResult.navigateToObject) {
1179
+ const nextPath = resolveObjectVariantNavigationPath({
1180
+ basePath: editMode.path,
1181
+ nextValue: selectionResult.nextValue,
1182
+ preferredKey:
1183
+ selectedVariant.kind === 'object' &&
1184
+ selectedVariant.requiredKeys.length === 1
1185
+ ? selectedVariant.requiredKeys[0]
1186
+ : null,
1187
+ });
1188
+ openPathView(nextPath, nextData);
1189
+ }
1190
+
1191
+ setEditError('');
1192
+ setEditMode(null);
1193
+ };
1194
+
1195
+ const reloadActiveConfig = () => {
1196
+ const nextSnapshot = readActiveConfigSnapshot();
1197
+ updateActiveSnapshot(nextSnapshot);
1198
+ setStateBatch({
1199
+ pathSegments: [],
1200
+ selectedIndex: 0,
1201
+ selectionByPath: {},
1202
+ scrollOffset: 0,
1203
+ editMode: null,
1204
+ editError: '',
1205
+ });
1206
+
1207
+ if (activeConfigFileId === MAIN_CONFIG_FILE_ID) {
1208
+ refreshConfigFileCatalog(nextSnapshot);
1209
+ }
1210
+ };
1211
+
1212
+ const beginFileSwitchMode = () => {
1213
+ if (
1214
+ !Array.isArray(configFileCatalog) ||
1215
+ configFileCatalog.length === 0
1216
+ ) {
1217
+ return;
1218
+ }
1219
+
1220
+ if (configFileCatalog.length === 1) {
1221
+ return;
1222
+ }
1223
+
1224
+ setStateBatch({
1225
+ editError: '',
1226
+ isFileSwitchMode: true,
1227
+ fileSwitchIndex: Math.max(
1228
+ 0,
1229
+ configFileCatalog.findIndex(
1230
+ (file) => file.id === activeConfigFileId,
1231
+ ),
1232
+ ),
1233
+ });
1234
+ };
1235
+
1236
+ const applyFileSwitch = () => {
1237
+ if (!isFileSwitchMode) {
1238
+ return;
1239
+ }
1240
+
1241
+ const nextFile = configFileCatalog[fileSwitchIndex];
1242
+ if (!nextFile) {
1243
+ setStateBatch({ isFileSwitchMode: false, editError: '' });
1244
+ return;
1245
+ }
1246
+
1247
+ switchConfigFile(nextFile.id);
1248
+ setStateBatch({ isFileSwitchMode: false, editError: '' });
1249
+ };
1250
+
1251
+ const applyBooleanToggle = (target, targetPath) => {
1252
+ const nextValue = !target.value;
1253
+ const data = snapshot.ok ? snapshot.data : {};
1254
+ const nextData = setValueAtPath(data, targetPath, nextValue);
1255
+
1256
+ const writeResult = writeConfig(nextData, snapshot.path);
1257
+
1258
+ if (!writeResult.ok) {
1259
+ setEditError(writeResult.error);
1260
+ return;
1261
+ }
1262
+
1263
+ const nextSnapshot = {
1264
+ ok: true,
1265
+ path: snapshot.path,
1266
+ data: nextData,
1267
+ };
1268
+ updateActiveSnapshot(nextSnapshot);
1269
+
1270
+ if (activeConfigFileId === MAIN_CONFIG_FILE_ID) {
1271
+ refreshConfigFileCatalog(nextSnapshot);
1272
+ }
1273
+ setEditError('');
1274
+ };
1275
+
1276
+ const unsetValueAtPath = (targetPath) => {
1277
+ const data = snapshot.ok ? snapshot.data : {};
1278
+ const hasConfiguredValue =
1279
+ typeof getNodeAtPath(data, targetPath) !== 'undefined';
1280
+
1281
+ if (!hasConfiguredValue) {
1282
+ setEditError('');
1283
+ return;
1284
+ }
1285
+
1286
+ const nextData = deleteValueAtPathPruningEmptyObjects(data, targetPath);
1287
+ const writeResult = writeConfig(nextData, snapshot.path);
1288
+
1289
+ if (!writeResult.ok) {
1290
+ setEditError(writeResult.error);
1291
+ return;
1292
+ }
1293
+
1294
+ const nextSnapshot = {
1295
+ ok: true,
1296
+ path: snapshot.path,
1297
+ data: nextData,
1298
+ };
1299
+ updateActiveSnapshot(nextSnapshot);
1300
+
1301
+ if (activeConfigFileId === MAIN_CONFIG_FILE_ID) {
1302
+ refreshConfigFileCatalog(nextSnapshot);
1303
+ }
1304
+ setEditError('');
1305
+ };
1306
+
1307
+ useInput(
1308
+ (input, key) => {
1309
+ const commandHandled = executeInputCommand({
1310
+ input,
1311
+ key,
1312
+ context: {
1313
+ appMode,
1314
+ isFilterEditing,
1315
+ isFileSwitchMode,
1316
+ isCommandMode,
1317
+ isCommandModeLocked: commandModeLockRef.current,
1318
+ activeConfigFileId,
1319
+ rows,
1320
+ safeSelected,
1321
+ editMode,
1322
+ listViewportHeight,
1323
+ pathSegments,
1324
+ snapshot,
1325
+ currentNode,
1326
+ terminalHeight,
1327
+ selectionByPath,
1328
+ configFileCatalog,
1329
+ fileSwitchIndex,
1330
+ currentPathKey,
1331
+ clamp,
1332
+ setEditMode,
1333
+ setFileSwitchIndex,
1334
+ setIsFilterEditing,
1335
+ setFilterQuery,
1336
+ setShowHelp,
1337
+ setStateBatch,
1338
+ setSelectedIndex,
1339
+ setCommandMode,
1340
+ setCommandInput,
1341
+ getCommandInput: () => commandInputRef.current,
1342
+ setCommandMessage,
1343
+ setEditError,
1344
+ beginAddIdEditing,
1345
+ beginTextEditing,
1346
+ beginVariantEditing,
1347
+ beginEditing,
1348
+ beginFileSwitchMode,
1349
+ applyFileSwitch,
1350
+ applyTextEdit,
1351
+ applyAddId,
1352
+ applyVariantEdit,
1353
+ applyEdit,
1354
+ applyBooleanToggle,
1355
+ unsetValueAtPath,
1356
+ openPathView,
1357
+ reloadActiveConfig,
1358
+ getConfigOptions: getConfigOptions,
1359
+ getConfigVariantMeta,
1360
+ getNodeAtPath,
1361
+ buildRows,
1362
+ isStringField,
1363
+ isCustomIdTableRow,
1364
+ resolveMixedVariantBackNavigationPath,
1365
+ adjustScrollForSelection,
1366
+ getSavedIndex,
1367
+ readActiveConfigSnapshot,
1368
+ refreshConfigFileCatalog,
1369
+ exit,
1370
+ },
1371
+ });
1372
+
1373
+ if (commandHandled) {
1374
+ return;
1375
+ }
1376
+ },
1377
+ { isActive: isInteractive },
1378
+ );
1379
+
1380
+ useEffect(() => {
1381
+ const maxOffset = Math.max(0, rows.length - listViewportHeight);
1382
+ setScrollOffset((previous) => clamp(previous, 0, maxOffset));
1383
+ }, [rows.length, listViewportHeight]);
1384
+
1385
+ const renderFileSwitchPanel = () => {
1386
+ if (!isFileSwitchMode || configFileCatalog.length === 0) {
1387
+ return null;
1388
+ }
1389
+
1390
+ const totalEntries = configFileCatalog.length;
1391
+ const visibleEntries = Math.min(
1392
+ FILE_SWITCH_MAX_VISIBLE_ENTRIES,
1393
+ totalEntries,
1394
+ );
1395
+ const maxStartIndex = Math.max(0, totalEntries - visibleEntries);
1396
+ const startIndex = clamp(
1397
+ fileSwitchIndex - Math.floor(visibleEntries / 2),
1398
+ 0,
1399
+ maxStartIndex,
1400
+ );
1401
+ const endIndex = Math.min(totalEntries, startIndex + visibleEntries);
1402
+ const visibleFiles = configFileCatalog.slice(startIndex, endIndex);
1403
+ const hasOverflow = totalEntries > visibleEntries;
1404
+
1405
+ return React.createElement(
1406
+ Box,
1407
+ {
1408
+ borderStyle: 'round',
1409
+ borderColor: 'cyan',
1410
+ paddingX: 1,
1411
+ flexDirection: 'column',
1412
+ },
1413
+ React.createElement(
1414
+ Text,
1415
+ { bold: true, color: 'cyan' },
1416
+ 'File Switch',
1417
+ ),
1418
+ ...visibleFiles.map((file, offsetIndex) => {
1419
+ const index = startIndex + offsetIndex;
1420
+ const isSelected = index === fileSwitchIndex;
1421
+ const isActiveFile = file.id === activeConfigFileId;
1422
+ const fileLabel = `${file.label} (${file.kind === 'main' ? 'main' : 'agent'})`;
1423
+ return React.createElement(
1424
+ Text,
1425
+ {
1426
+ key: file.id,
1427
+ color: isSelected
1428
+ ? 'yellow'
1429
+ : isActiveFile
1430
+ ? 'green'
1431
+ : 'gray',
1432
+ bold: isSelected,
1433
+ wrap: 'truncate-end',
1434
+ },
1435
+ `${isSelected ? '› ' : ' '}${fileLabel}${isActiveFile ? ' [active]' : ''}`,
1436
+ );
1437
+ }),
1438
+ hasOverflow
1439
+ ? React.createElement(
1440
+ Text,
1441
+ {
1442
+ color: 'gray',
1443
+ wrap: 'truncate-end',
1444
+ key: 'file-switch-window',
1445
+ },
1446
+ `Showing ${startIndex + 1}-${endIndex} of ${totalEntries}`,
1447
+ )
1448
+ : null,
1449
+ );
1450
+ };
1451
+
1452
+ const commandModeHint = getModeHint({
1453
+ appMode,
1454
+ isCommandMode,
1455
+ });
1456
+
1457
+ if (!isInteractive) {
1458
+ return React.createElement(
1459
+ Box,
1460
+ { flexDirection: 'column', padding: 1 },
1461
+ React.createElement(Header, {
1462
+ packageVersion: PACKAGE_VERSION,
1463
+ terminalWidth,
1464
+ }),
1465
+ React.createElement(ConfigNavigator, {
1466
+ snapshot,
1467
+ pathSegments,
1468
+ selectedIndex: 0,
1469
+ terminalWidth,
1470
+ listViewportHeight,
1471
+ scrollOffset: 0,
1472
+ editMode: null,
1473
+ editError: editError,
1474
+ filterQuery,
1475
+ isFilterEditing,
1476
+ activeConfigFile: activeConfigFile,
1477
+ }),
1478
+ React.createElement(
1479
+ Text,
1480
+ { color: 'yellow' },
1481
+ 'Non-interactive mode: input is disabled.',
1482
+ ),
1483
+ );
1484
+ }
1485
+
1486
+ return React.createElement(
1487
+ LayoutShell,
1488
+ null,
1489
+ React.createElement(Header, {
1490
+ packageVersion: PACKAGE_VERSION,
1491
+ terminalWidth,
1492
+ }),
1493
+ React.createElement(ConfigNavigator, {
1494
+ snapshot,
1495
+ pathSegments,
1496
+ selectedIndex: safeSelected,
1497
+ terminalWidth,
1498
+ listViewportHeight,
1499
+ scrollOffset,
1500
+ editMode,
1501
+ editError,
1502
+ filterQuery,
1503
+ isFilterEditing,
1504
+ activeConfigFile: activeConfigFile,
1505
+ }),
1506
+ renderFileSwitchPanel(),
1507
+ React.createElement(CommandBar, {
1508
+ appMode,
1509
+ isCommandMode,
1510
+ commandInput,
1511
+ commandMessage,
1512
+ modeHint: commandModeHint,
1513
+ }),
1514
+ React.createElement(StatusLine, {
1515
+ codexVersion,
1516
+ codexVersionStatus,
1517
+ isSchemaCheckInProgress,
1518
+ schemaCheckStatusText,
1519
+ activeConfigFile,
1520
+ appMode,
1521
+ }),
1522
+ showHelp
1523
+ ? React.createElement(
1524
+ Box,
1525
+ {
1526
+ position: 'absolute',
1527
+ bottom: 3,
1528
+ left: 0,
1529
+ right: 0,
1530
+ marginTop: 0,
1531
+ zIndex: 1,
1532
+ },
1533
+ React.createElement(HelpBubble, { appMode }),
1534
+ )
1535
+ : null,
1536
+ );
910
1537
  };
911
1538
 
1539
+ activateTerminalMode();
912
1540
  render(React.createElement(App));