codex-configurator 0.2.2 → 0.2.3

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/README.md CHANGED
@@ -38,6 +38,7 @@ npm start
38
38
  - `PgUp` `PgDn`: move one page up/down
39
39
  - `Home` `End`: jump to first/last item
40
40
  - `Enter`: open selected table; for boolean settings, toggle directly; for string settings, open inline input; for other preset values, open picker
41
+ - `/`: start fuzzy filter mode for the current list
41
42
  - `Del`: unset selected value or remove selected custom `<id>` entry from `config.toml`
42
43
  - `←` / `Backspace`: move up one level (to parent table)
43
44
  - `r`: reload the active config file
@@ -47,7 +48,10 @@ The right-hand pane shows what each setting means, plus a picker when a value ha
47
48
  Deprecated settings are marked with a `[!]` warning marker; only that marker is highlighted.
48
49
  Model picker entries are curated presets maintained by this project.
49
50
  In select lists, `[default]` marks the default option.
50
- The header banner shows the configurator package version (for example `v0.2.1`) next to the wordmark.
51
+ The header banner shows the configurator package version (for example `v0.2.2`) next to the wordmark.
52
+ Section help includes purpose-driven guidance (what a section is for), not generic placeholder copy.
53
+ Fuzzy filter mode matches section/value rows by label and key as you type.
54
+ The list panel shows a breadcrumb title on its top border with your current path (for example `projects > /home/me/repo`).
51
55
 
52
56
  ## TOML-aware navigation
53
57
 
@@ -104,6 +108,15 @@ You can override either command with:
104
108
  - `CODEX_CONFIGURATOR_CODEX_BIN=<command-or-path>`
105
109
  - `CODEX_CONFIGURATOR_NPM_BIN=<command-or-path>`
106
110
 
111
+ ## Self-update behavior
112
+
113
+ On startup, the app checks the npm registry for the latest `codex-configurator` version.
114
+ If the running version is older, it automatically runs:
115
+
116
+ - `npm install -g codex-configurator@latest`
117
+
118
+ This uses the `npm` command from `PATH` (or `CODEX_CONFIGURATOR_NPM_BIN` if set).
119
+
107
120
  ## Upstream reference
108
121
 
109
122
  - Codex configuration reference: https://developers.openai.com/codex/config-reference/
package/index.js CHANGED
@@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { createRequire } from 'node:module';
6
6
  import { render, useInput, useApp, useStdout, Text, Box } from 'ink';
7
- import { CONTROL_HINT, EDIT_CONTROL_HINT } from './src/constants.js';
7
+ import { CONTROL_HINT, EDIT_CONTROL_HINT, FILTER_CONTROL_HINT } from './src/constants.js';
8
8
  import {
9
9
  readConfig,
10
10
  getNodeAtPath,
@@ -30,6 +30,7 @@ import {
30
30
  } from './src/interaction.js';
31
31
  import { Header } from './src/components/Header.js';
32
32
  import { ConfigNavigator } from './src/components/ConfigNavigator.js';
33
+ import { filterRowsByQuery } from './src/fuzzySearch.js';
33
34
 
34
35
  const require = createRequire(import.meta.url);
35
36
  const { version: PACKAGE_VERSION = 'unknown' } = require('./package.json');
@@ -61,31 +62,43 @@ const isCustomIdTableRow = (pathSegments, row) =>
61
62
 
62
63
  const isInlineTextMode = (mode) => mode === 'text' || mode === 'add-id';
63
64
  const VERSION_COMMAND_TIMEOUT_MS = 3000;
65
+ const UPDATE_COMMAND_TIMEOUT_MS = 180000;
66
+ const COMMAND_MAX_BUFFER_BYTES = 1024 * 1024;
67
+ const UPDATE_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
64
68
  const CODEX_BIN_ENV_VAR = 'CODEX_CONFIGURATOR_CODEX_BIN';
65
69
  const NPM_BIN_ENV_VAR = 'CODEX_CONFIGURATOR_NPM_BIN';
70
+ const CONFIGURATOR_PACKAGE_NAME = 'codex-configurator';
66
71
 
67
- const runCommand = (command, args = []) =>
72
+ const runCommandWithResult = (command, args = [], options = {}) =>
68
73
  new Promise((resolve) => {
69
74
  execFile(
70
75
  command,
71
76
  args,
72
77
  {
73
78
  encoding: 'utf8',
74
- timeout: VERSION_COMMAND_TIMEOUT_MS,
75
- maxBuffer: 1024 * 1024,
79
+ timeout: options.timeout || VERSION_COMMAND_TIMEOUT_MS,
80
+ maxBuffer: options.maxBuffer || COMMAND_MAX_BUFFER_BYTES,
76
81
  windowsHide: true,
77
82
  },
78
- (error, stdout) => {
79
- if (error) {
80
- resolve('');
81
- return;
82
- }
83
-
84
- resolve(String(stdout || '').trim());
83
+ (error, stdout, stderr) => {
84
+ resolve({
85
+ ok: !error,
86
+ stdout: String(stdout || '').trim(),
87
+ stderr: String(stderr || '').trim(),
88
+ });
85
89
  }
86
90
  );
87
91
  });
88
92
 
93
+ const runCommand = async (command, args = [], options = {}) => {
94
+ const result = await runCommandWithResult(command, args, options);
95
+ if (!result.ok) {
96
+ return '';
97
+ }
98
+
99
+ return result.stdout;
100
+ };
101
+
89
102
  const getConfiguredCommand = (environmentVariableName, fallbackCommand) => {
90
103
  const configuredCommand = String(process.env[environmentVariableName] || '').trim();
91
104
  return configuredCommand || fallbackCommand;
@@ -96,6 +109,24 @@ const getVersionCommands = () => ({
96
109
  npmCommand: getConfiguredCommand(NPM_BIN_ENV_VAR, 'npm'),
97
110
  });
98
111
 
112
+ const getLatestPackageVersion = async (npmCommand, packageName) => {
113
+ const latestOutput = await runCommand(npmCommand, ['view', packageName, 'version', '--json']);
114
+ return normalizeVersion(latestOutput) || latestOutput.trim();
115
+ };
116
+
117
+ 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;
128
+ };
129
+
99
130
  const getCodexVersion = async (codexCommand) => {
100
131
  const output = await runCommand(codexCommand, ['--version']);
101
132
  const firstLine = output.split('\n')[0]?.trim();
@@ -185,6 +216,24 @@ const getCodexUpdateStatus = async () => {
185
216
  };
186
217
  };
187
218
 
219
+ 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);
235
+ };
236
+
188
237
  const App = () => {
189
238
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
190
239
  const { stdout } = useStdout();
@@ -198,6 +247,8 @@ const App = () => {
198
247
  const [scrollOffset, setScrollOffset] = useState(0);
199
248
  const [editMode, setEditMode] = useState(null);
200
249
  const [editError, setEditError] = useState('');
250
+ const [filterQuery, setFilterQuery] = useState('');
251
+ const [isFilterEditing, setIsFilterEditing] = useState(false);
201
252
  const [codexVersion, setCodexVersion] = useState('version loading...');
202
253
  const [codexVersionStatus, setCodexVersionStatus] = useState('');
203
254
  const { exit } = useApp();
@@ -216,7 +267,13 @@ const App = () => {
216
267
  setCodexVersionStatus(check.status);
217
268
  };
218
269
 
270
+ const ensureLatestConfigurator = async () => {
271
+ const commands = getVersionCommands();
272
+ await ensureLatestConfiguratorVersion(commands.npmCommand);
273
+ };
274
+
219
275
  loadVersionStatus();
276
+ ensureLatestConfigurator();
220
277
 
221
278
  return () => {
222
279
  isCancelled = true;
@@ -224,7 +281,8 @@ const App = () => {
224
281
  }, []);
225
282
 
226
283
  const currentNode = getNodeAtPath(snapshot.ok ? snapshot.data : {}, pathSegments);
227
- const rows = buildRows(currentNode, pathSegments);
284
+ const allRows = buildRows(currentNode, pathSegments);
285
+ const rows = filterRowsByQuery(allRows, filterQuery);
228
286
  const safeSelected = rows.length === 0 ? 0 : Math.min(selectedIndex, rows.length - 1);
229
287
  const listViewportHeight = computeListViewportHeight(rows, terminalHeight);
230
288
  const currentPathKey = pathToKey(pathSegments);
@@ -441,11 +499,51 @@ const App = () => {
441
499
  useInput((input, key) => {
442
500
  const isTextEditing = isInlineTextMode(editMode?.mode);
443
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
+
444
537
  if (input === 'q' && !isTextEditing) {
445
538
  exit();
446
539
  return;
447
540
  }
448
541
 
542
+ if (!editMode && input === '/') {
543
+ setIsFilterEditing(true);
544
+ return;
545
+ }
546
+
449
547
  if (editMode) {
450
548
  if (isInlineTextMode(editMode.mode)) {
451
549
  if (key.return) {
@@ -776,6 +874,8 @@ const App = () => {
776
874
  scrollOffset: 0,
777
875
  editMode: null,
778
876
  editError: editError,
877
+ filterQuery,
878
+ isFilterEditing,
779
879
  }),
780
880
  React.createElement(Text, { color: 'yellow' }, 'Non-interactive mode: input is disabled.')
781
881
  );
@@ -798,8 +898,14 @@ const App = () => {
798
898
  scrollOffset,
799
899
  editMode,
800
900
  editError,
901
+ filterQuery,
902
+ isFilterEditing,
801
903
  }),
802
- React.createElement(Text, { color: 'gray' }, editMode ? EDIT_CONTROL_HINT : CONTROL_HINT)
904
+ React.createElement(
905
+ Text,
906
+ { color: 'gray' },
907
+ isFilterEditing ? FILTER_CONTROL_HINT : editMode ? EDIT_CONTROL_HINT : CONTROL_HINT
908
+ )
803
909
  );
804
910
  };
805
911
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-configurator",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "TOML-aware Ink TUI for Codex Configurator",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -8,6 +8,7 @@ import {
8
8
  } from '../configHelp.js';
9
9
  import { computePaneWidths, clamp } from '../layout.js';
10
10
  import { getNodeAtPath, buildRows } from '../configParser.js';
11
+ import { filterRowsByQuery } from '../fuzzySearch.js';
11
12
 
12
13
  const MenuItem = ({ isSelected, isDimmed, isDeprecated, label }) =>
13
14
  React.createElement(
@@ -98,6 +99,45 @@ const renderIdEditor = (placeholder, draftValue) =>
98
99
  React.createElement(Text, { color: 'gray' }, 'Type id • Enter: create • Esc: cancel')
99
100
  );
100
101
 
102
+ const formatBreadcrumbSegment = (segment) => {
103
+ const value = String(segment);
104
+ return /^\d+$/.test(value) ? `[${value}]` : value;
105
+ };
106
+
107
+ const formatBreadcrumbs = (pathSegments) => {
108
+ if (!Array.isArray(pathSegments) || pathSegments.length === 0) {
109
+ return '';
110
+ }
111
+
112
+ return pathSegments.map(formatBreadcrumbSegment).join(' > ');
113
+ };
114
+
115
+ const formatSectionSummary = (row) => {
116
+ if (row?.kind === 'table') {
117
+ const entryCount =
118
+ Object.prototype.toString.call(row?.value) === '[object Object]'
119
+ ? Object.keys(row.value).length
120
+ : 0;
121
+
122
+ if (entryCount === 0) {
123
+ return 'Empty section.';
124
+ }
125
+
126
+ return `Section with ${entryCount} configured ${entryCount === 1 ? 'entry' : 'entries'}.`;
127
+ }
128
+
129
+ if (row?.kind === 'tableArray') {
130
+ const entryCount = Array.isArray(row?.value) ? row.value.length : 0;
131
+ if (entryCount === 0) {
132
+ return 'Section with no entries.';
133
+ }
134
+
135
+ return `Section list with ${entryCount} ${entryCount === 1 ? 'entry' : 'entries'}.`;
136
+ }
137
+
138
+ return 'This section groups related settings.';
139
+ };
140
+
101
141
  const renderEditableOptions = (
102
142
  options,
103
143
  selectedOptionIndex,
@@ -181,14 +221,15 @@ const formatConfigHelp = (pathSegments, row) => {
181
221
  }
182
222
 
183
223
  const info = getConfigHelp(pathSegments, row.key);
224
+ const isSectionRow = row.kind === 'table' || row.kind === 'tableArray';
184
225
  const defaultCollectionText =
185
- row.kind === 'table' || row.kind === 'tableArray'
186
- ? 'This section groups related settings.'
226
+ isSectionRow
227
+ ? formatSectionSummary(row)
187
228
  : row.kind === 'array'
188
229
  ? `This is a list with ${row.value.length} entries.`
189
230
  : 'This setting affects Codex behavior.';
190
231
  const short = info?.short || defaultCollectionText;
191
- const usage = info?.usage;
232
+ const usage = isSectionRow ? null : info?.usage;
192
233
  const isWarning = Boolean(info?.deprecation);
193
234
  const lines = [{ text: short, color: 'white', bold: false, showWarningIcon: false }];
194
235
 
@@ -240,6 +281,8 @@ export const ConfigNavigator = ({
240
281
  terminalHeight,
241
282
  editMode,
242
283
  editError,
284
+ filterQuery = '',
285
+ isFilterEditing = false,
243
286
  }) => {
244
287
  if (!snapshot.ok) {
245
288
  return React.createElement(
@@ -252,9 +295,13 @@ export const ConfigNavigator = ({
252
295
  }
253
296
 
254
297
  const currentNode = getNodeAtPath(snapshot.data, pathSegments);
255
- const rows = buildRows(currentNode, pathSegments);
298
+ const allRows = buildRows(currentNode, pathSegments);
299
+ const rows = filterRowsByQuery(allRows, filterQuery);
300
+ const breadcrumbs = formatBreadcrumbs(pathSegments);
256
301
  const selected = rows.length === 0 ? 0 : Math.min(selectedIndex, rows.length - 1);
257
- const { leftWidth, rightWidth } = computePaneWidths(terminalWidth, rows);
302
+ const paneSizingRows = breadcrumbs ? [...rows, { label: breadcrumbs }] : rows;
303
+ const { leftWidth, rightWidth } = computePaneWidths(terminalWidth, paneSizingRows);
304
+ const breadcrumbLabel = breadcrumbs ? truncate(breadcrumbs, Math.max(8, leftWidth - 6)) : '';
258
305
  const terminalListHeight = Math.max(4, (terminalHeight || 24) - 14);
259
306
  const viewportHeight = Math.max(4, Math.min(rows.length, Math.min(20, terminalListHeight)));
260
307
  const viewportStart = clamp(scrollOffset, 0, Math.max(0, rows.length - viewportHeight));
@@ -292,96 +339,138 @@ export const ConfigNavigator = ({
292
339
  return React.createElement(
293
340
  Box,
294
341
  { flexDirection: 'row', gap: 2 },
295
- React.createElement(
296
- Box,
297
- { flexDirection: 'column', width: leftWidth },
298
342
  React.createElement(
299
343
  Box,
300
- {
301
- flexDirection: 'column',
302
- borderStyle: 'single',
303
- borderColor: 'gray',
304
- padding: 1,
305
- },
306
- rows.length === 0
307
- ? React.createElement(Text, { color: 'gray' }, '[no entries in this table]')
308
- : visibleRows.map((row, viewIndex) => {
309
- const index = viewportStart + viewIndex;
310
- const showTopCue = canScrollUp && viewIndex === 0;
311
- const showBottomCue = canScrollDown && viewIndex === visibleRows.length - 1;
312
- const isSelected = index === selected;
313
- const label = `${showTopCue ? '' : showBottomCue ? '↓ ' : ' '}${isSelected ? '▶' : ' '} ${row.label}`;
314
-
315
- return React.createElement(
316
- MenuItem,
317
- {
318
- label,
319
- isSelected,
320
- isDimmed: !isSelected && row.isConfigured === false,
321
- isDeprecated: row.isDeprecated,
322
- key: `left-${index}`,
323
- }
324
- );
325
- })
326
- )
327
- ),
328
- React.createElement(
329
- Box,
330
- { flexDirection: 'column', width: rightWidth, marginTop: 2 },
331
- rows.length === 0
332
- ? React.createElement(Text, { color: 'gray' }, 'No selection available.')
333
- : React.createElement(
334
- React.Fragment,
335
- null,
336
- ...formatConfigHelp(pathSegments, rows[selected]).map((line, lineIndex) =>
337
- React.createElement(
338
- Text,
344
+ { flexDirection: 'column', width: leftWidth },
345
+ React.createElement(
346
+ Box,
347
+ {
348
+ flexDirection: 'column',
349
+ borderStyle: 'single',
350
+ borderColor: 'gray',
351
+ padding: 1,
352
+ },
353
+ rows.length === 0
354
+ ? React.createElement(
355
+ Text,
356
+ { color: 'gray' },
357
+ String(filterQuery || '').trim().length > 0
358
+ ? '[no entries match current filter]'
359
+ : '[no entries in this table]'
360
+ )
361
+ : visibleRows.map((row, viewIndex) => {
362
+ const index = viewportStart + viewIndex;
363
+ const showTopCue = canScrollUp && viewIndex === 0;
364
+ const showBottomCue = canScrollDown && viewIndex === visibleRows.length - 1;
365
+ const isSelected = index === selected;
366
+ const label = `${showTopCue ? '↑ ' : showBottomCue ? '↓ ' : ' '}${isSelected ? '▶' : ' '} ${row.label}`;
367
+
368
+ return React.createElement(
369
+ MenuItem,
339
370
  {
340
- key: `help-${selected}-${lineIndex}`,
341
- color: line.color,
342
- bold: line.bold,
343
- },
344
- line.showWarningIcon
345
- ? React.createElement(Text, { color: 'yellow' }, '[!] ')
346
- : null,
347
- line.text
348
- )
349
- ),
350
- editError ? React.createElement(Text, { color: 'red' }, editError) : null,
351
- editMode ? React.createElement(Text, { color: 'gray' }, ' ') : null,
352
- editMode
353
- ? editMode.mode === 'text'
354
- ? renderTextEditor(editMode.draftValue)
355
- : editMode.mode === 'add-id'
356
- ? renderIdEditor(editMode.placeholder, editMode.draftValue)
357
- : renderEditableOptions(
358
- editMode.options,
359
- editMode.selectedOptionIndex,
360
- editDefaultOptionIndex,
361
- editMode.path.slice(0, -1),
362
- rows[selected].key,
363
- editMode.savedOptionIndex,
364
- true
371
+ label,
372
+ isSelected,
373
+ isDimmed: !isSelected && row.isConfigured === false,
374
+ isDeprecated: row.isDeprecated,
375
+ key: `left-${index}`,
376
+ }
377
+ );
378
+ })
379
+ ),
380
+ React.createElement(
381
+ Box,
382
+ { position: 'absolute', top: 0, left: 0 },
383
+ breadcrumbLabel
384
+ ? React.createElement(
385
+ Text,
386
+ null,
387
+ React.createElement(Text, { color: 'gray' }, '┌── '),
388
+ React.createElement(Text, { color: 'cyan' }, `${breadcrumbLabel} `)
389
+ )
390
+ : null
391
+ )
392
+ ),
393
+ React.createElement(
394
+ Box,
395
+ { flexDirection: 'column', width: rightWidth, marginTop: 1 },
396
+ rows.length === 0
397
+ ? React.createElement(
398
+ React.Fragment,
399
+ null,
400
+ String(filterQuery || '').trim().length > 0
401
+ ? React.createElement(
402
+ Text,
403
+ { color: 'gray' },
404
+ `Filter: ${filterQuery} (${rows.length}/${allRows.length})`
365
405
  )
366
- : shouldShowReadOnlyOptions
406
+ : null,
407
+ React.createElement(
408
+ Text,
409
+ { color: 'gray' },
410
+ String(filterQuery || '').trim().length > 0
411
+ ? 'No selection available. Adjust or clear the filter.'
412
+ : 'No selection available.'
413
+ )
414
+ )
415
+ : React.createElement(
416
+ React.Fragment,
417
+ null,
418
+ String(filterQuery || '').trim().length > 0 || isFilterEditing
367
419
  ? React.createElement(
368
- React.Fragment,
369
- null,
370
- React.createElement(Text, { color: 'gray' }, ' '),
371
- renderEditableOptions(
372
- readOnlyOptions,
373
- readOnlyOptionIndex,
374
- readOnlyDefaultOptionIndex,
375
- selectedPath,
376
- selectedRow?.key,
420
+ Text,
421
+ { color: 'cyan' },
422
+ `Filter: ${filterQuery || ''} (${rows.length}/${allRows.length})${isFilterEditing ? ' [editing]' : ''}`
423
+ )
424
+ : null,
425
+ ...formatConfigHelp(pathSegments, rows[selected]).map((line, lineIndex) =>
426
+ React.createElement(
427
+ Text,
428
+ {
429
+ key: `help-${selected}-${lineIndex}`,
430
+ color: line.color,
431
+ bold: line.bold,
432
+ },
433
+ line.showWarningIcon
434
+ ? React.createElement(Text, { color: 'yellow' }, '[!] ')
435
+ : null,
436
+ line.text
437
+ )
438
+ ),
439
+ editError ? React.createElement(Text, { color: 'red' }, editError) : null,
440
+ editMode ? React.createElement(Text, { color: 'gray' }, ' ') : null,
441
+ editMode
442
+ ? editMode.mode === 'text'
443
+ ? renderTextEditor(editMode.draftValue)
444
+ : editMode.mode === 'add-id'
445
+ ? renderIdEditor(editMode.placeholder, editMode.draftValue)
446
+ : renderEditableOptions(
447
+ editMode.options,
448
+ editMode.selectedOptionIndex,
449
+ editDefaultOptionIndex,
450
+ editMode.path.slice(0, -1),
451
+ rows[selected].key,
452
+ editMode.savedOptionIndex,
453
+ true
454
+ )
455
+ : shouldShowReadOnlyOptions
456
+ ? React.createElement(
457
+ React.Fragment,
377
458
  null,
378
- false
459
+ React.createElement(Text, { color: 'gray' }, ' '),
460
+ renderEditableOptions(
461
+ readOnlyOptions,
462
+ readOnlyOptionIndex,
463
+ readOnlyDefaultOptionIndex,
464
+ selectedPath,
465
+ selectedRow?.key,
466
+ null,
467
+ false
468
+ )
379
469
  )
380
- )
381
- : rows[selected].kind === 'array'
382
- ? renderArrayDetails(rows[selected].value)
383
- : null
384
- )
385
- )
470
+ : rows[selected].kind === 'array'
471
+ ? renderArrayDetails(rows[selected].value)
472
+ : null
473
+ )
474
+ )
386
475
  );
387
476
  };
package/src/configHelp.js CHANGED
@@ -2,7 +2,12 @@ import {
2
2
  getConfigFeatureDefinition,
3
3
  getConfigFeatureDefinitionOrFallback,
4
4
  } from './configFeatures.js';
5
- import { getReferenceOptionForPath } from './configReference.js';
5
+ import {
6
+ getReferenceOptionForPath,
7
+ getReferenceCustomIdPlaceholder,
8
+ getReferenceDescendantOptions,
9
+ getReferenceTableDefinitions,
10
+ } from './configReference.js';
6
11
 
7
12
  const CONFIG_VALUE_OPTIONS = {
8
13
  model: [
@@ -54,6 +59,24 @@ const CONFIG_OPTION_EXPLANATIONS = {
54
59
  untrusted: 'Limits risky actions and prompts more often.',
55
60
  },
56
61
  };
62
+ const SECTION_PURPOSE_OVERRIDES = {
63
+ agents: 'Named agent definitions and per-agent configuration file references.',
64
+ apps: 'Per-app enablement rules and disable reasons.',
65
+ features: 'Feature flags for optional and experimental Codex behavior.',
66
+ feedback: 'Feedback submission settings for Codex surfaces.',
67
+ history: 'Session history retention policy and on-disk size limits.',
68
+ mcp_servers: 'MCP server definitions, transport settings, and authentication configuration.',
69
+ model_providers: 'Model provider definitions, API endpoints, and credential settings.',
70
+ notice: 'Visibility toggles for startup and migration notices.',
71
+ otel: 'OpenTelemetry exporter configuration for telemetry and traces.',
72
+ profiles: 'Named profile overrides you can select per session.',
73
+ projects: 'Project/worktree trust settings scoped by filesystem path.',
74
+ sandbox_workspace_write: 'Workspace-write sandbox behavior, writable roots, and network access rules.',
75
+ shell_environment_policy: 'Shell environment inheritance and variable override policy.',
76
+ skills: 'Skill discovery and loading controls.',
77
+ tools: 'Tool-related configuration, including legacy compatibility flags.',
78
+ tui: 'Terminal UI behavior, notifications, and presentation settings.',
79
+ };
57
80
 
58
81
  const makePathSegments = (segments, key) => {
59
82
  const normalizedSegments = Array.isArray(segments)
@@ -78,6 +101,73 @@ const getContextEntry = (segments, key, candidates) => {
78
101
 
79
102
  const getReferenceEntry = (segments, key) => getReferenceOptionForPath(makePathSegments(segments, key));
80
103
 
104
+ const formatPlaceholderLabel = (placeholder) => {
105
+ const cleaned = String(placeholder || '')
106
+ .replace(/^</, '')
107
+ .replace(/>$/, '');
108
+
109
+ return cleaned || 'id';
110
+ };
111
+
112
+ const toFirstSentence = (text) => {
113
+ const normalized = String(text || '').replace(/\s+/g, ' ').trim();
114
+ if (!normalized) {
115
+ return '';
116
+ }
117
+
118
+ const periodIndex = normalized.indexOf('. ');
119
+ if (periodIndex < 0) {
120
+ return normalized;
121
+ }
122
+
123
+ return normalized.slice(0, periodIndex + 1);
124
+ };
125
+
126
+ const getSectionPurposeDescription = (descendantOptions) => {
127
+ for (const option of descendantOptions) {
128
+ const firstSentence = toFirstSentence(option?.description);
129
+ if (firstSentence) {
130
+ return firstSentence;
131
+ }
132
+ }
133
+
134
+ return '';
135
+ };
136
+
137
+ const buildInferredSectionHelp = (segments, key) => {
138
+ const sectionPath = makePathSegments(segments, key);
139
+ const childDefinitions = getReferenceTableDefinitions(sectionPath);
140
+ const descendantOptions = getReferenceDescendantOptions(sectionPath);
141
+ const customPlaceholder = getReferenceCustomIdPlaceholder(sectionPath);
142
+ const parentCustomPlaceholder = getReferenceCustomIdPlaceholder(sectionPath.slice(0, -1));
143
+
144
+ if (childDefinitions.length === 0 && descendantOptions.length === 0 && !customPlaceholder) {
145
+ return null;
146
+ }
147
+
148
+ const rootOverride = sectionPath.length === 1
149
+ ? SECTION_PURPOSE_OVERRIDES[sectionPath[0]]
150
+ : null;
151
+ const customLabel = customPlaceholder ? formatPlaceholderLabel(customPlaceholder) : '';
152
+ const bestPurposeDescription = getSectionPurposeDescription(descendantOptions);
153
+ const dynamicEntryLabel = parentCustomPlaceholder
154
+ ? formatPlaceholderLabel(parentCustomPlaceholder)
155
+ : '';
156
+ const short = rootOverride
157
+ || (dynamicEntryLabel
158
+ ? `Configuration for this ${dynamicEntryLabel} entry.`
159
+ : '')
160
+ || bestPurposeDescription
161
+ || (customLabel
162
+ ? `Section for custom ${customLabel} entries.`
163
+ : 'Section with related settings.');
164
+
165
+ return {
166
+ short,
167
+ usage: null,
168
+ };
169
+ };
170
+
81
171
  const getReferenceUsage = (entry) => {
82
172
  if (entry.deprecated) {
83
173
  return 'This option is deprecated in the official configuration reference.';
@@ -92,7 +182,7 @@ const getReferenceUsage = (entry) => {
92
182
  }
93
183
 
94
184
  if (entry.type === 'table') {
95
- return 'This section groups related settings.';
185
+ return 'Press Enter to open this section and edit nested settings.';
96
186
  }
97
187
 
98
188
  if (entry.type.startsWith('array<')) {
@@ -144,6 +234,11 @@ export const getConfigHelp = (segments, key) => {
144
234
  return referenceHelp;
145
235
  }
146
236
 
237
+ const inferredSectionHelp = buildInferredSectionHelp(segments, key);
238
+ if (inferredSectionHelp) {
239
+ return inferredSectionHelp;
240
+ }
241
+
147
242
  return null;
148
243
  };
149
244
 
@@ -218,3 +218,15 @@ export const getReferenceCustomIdPlaceholder = (pathSegments = []) => {
218
218
  const [firstMatch] = [...placeholders];
219
219
  return firstMatch || null;
220
220
  };
221
+
222
+ export const getReferenceDescendantOptions = (pathSegments = []) => {
223
+ const normalizedPath = normalizeSegments(pathSegments);
224
+
225
+ return referenceOptions
226
+ .filter(
227
+ (option) =>
228
+ pathPrefixMatches(option.keyPath, normalizedPath) &&
229
+ option.keyPath.length > normalizedPath.length
230
+ )
231
+ .sort((left, right) => left.keyPath.length - right.keyPath.length || left.key.localeCompare(right.key));
232
+ };
package/src/constants.js CHANGED
@@ -1,4 +1,5 @@
1
- export const CONTROL_HINT = '↑/↓ move • PgUp/PgDn page • Home/End jump • Enter: open section or edit • Del: unset value • ←/Backspace: back • r: reload • q: quit';
1
+ export const CONTROL_HINT = '↑/↓ move • PgUp/PgDn page • Home/End jump • Enter: open section or edit • /: filter • Del: unset value • ←/Backspace: back • r: reload • q: quit';
2
2
  export const EDIT_CONTROL_HINT = '↑/↓ choose • PgUp/PgDn page • Home/End jump • Enter: save • Esc/Backspace/←: cancel • Del: delete char (text input) • r: reload • q: quit';
3
+ export const FILTER_CONTROL_HINT = 'Type filter • Enter/Esc: done • Del/Backspace: delete • Ctrl+U: clear';
3
4
  export const BRAND = 'CODEX CONFIGURATOR';
4
5
  export const CONFIG_TAGS = ['Node.js', 'React', 'Ink', 'TOML'];
@@ -0,0 +1,39 @@
1
+ const normalizeText = (value) => String(value || '').toLowerCase();
2
+
3
+ export const isFuzzyMatch = (query, text) => {
4
+ const normalizedQuery = normalizeText(query).trim();
5
+ if (!normalizedQuery) {
6
+ return true;
7
+ }
8
+
9
+ const normalizedText = normalizeText(text);
10
+ let queryIndex = 0;
11
+
12
+ for (let textIndex = 0; textIndex < normalizedText.length; textIndex += 1) {
13
+ if (normalizedText[textIndex] === normalizedQuery[queryIndex]) {
14
+ queryIndex += 1;
15
+ if (queryIndex >= normalizedQuery.length) {
16
+ return true;
17
+ }
18
+ }
19
+ }
20
+
21
+ return false;
22
+ };
23
+
24
+ const rowSearchableTexts = (row) => [
25
+ row?.label,
26
+ row?.key,
27
+ row?.preview,
28
+ ];
29
+
30
+ export const filterRowsByQuery = (rows, query) => {
31
+ const normalizedQuery = String(query || '').trim();
32
+ if (!normalizedQuery) {
33
+ return rows;
34
+ }
35
+
36
+ return rows.filter((row) =>
37
+ rowSearchableTexts(row).some((value) => isFuzzyMatch(normalizedQuery, value))
38
+ );
39
+ };