codex-configurator 0.2.1 → 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,6 +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.
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`).
50
55
 
51
56
  ## TOML-aware navigation
52
57
 
@@ -96,12 +101,21 @@ When the log exceeds the size limit, it is rotated to `<log-path>.1` before writ
96
101
 
97
102
  ## Version checks
98
103
 
99
- Codex version checks are disabled by default to avoid executing unknown binaries from `PATH`.
100
- To enable checks, all of the following must be set:
104
+ Codex version checks are always enabled.
105
+ By default, the app runs global `codex` and `npm` from `PATH`.
106
+ You can override either command with:
101
107
 
102
- - `CODEX_CONFIGURATOR_ENABLE_VERSION_CHECK=1`
103
- - `CODEX_CONFIGURATOR_CODEX_BIN=/absolute/path/to/codex`
104
- - `CODEX_CONFIGURATOR_NPM_BIN=/absolute/path/to/npm`
108
+ - `CODEX_CONFIGURATOR_CODEX_BIN=<command-or-path>`
109
+ - `CODEX_CONFIGURATOR_NPM_BIN=<command-or-path>`
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).
105
119
 
106
120
  ## Upstream reference
107
121
 
package/index.js CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  import React, { useState, useEffect } from 'react';
4
4
  import { execFile } from 'node:child_process';
5
- import path from 'node:path';
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,10 @@ 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';
34
+
35
+ const require = createRequire(import.meta.url);
36
+ const { version: PACKAGE_VERSION = 'unknown' } = require('./package.json');
33
37
 
34
38
  const computeListViewportHeight = (rows, terminalRows) =>
35
39
  Math.max(4, Math.min(rows.length, Math.min(20, Math.max(4, terminalRows - 14))));
@@ -58,57 +62,69 @@ const isCustomIdTableRow = (pathSegments, row) =>
58
62
 
59
63
  const isInlineTextMode = (mode) => mode === 'text' || mode === 'add-id';
60
64
  const VERSION_COMMAND_TIMEOUT_MS = 3000;
61
- const VERSION_DISABLED_LABEL = 'version check disabled';
62
- const VERSION_CHECK_ENABLED_ENV_VAR = 'CODEX_CONFIGURATOR_ENABLE_VERSION_CHECK';
65
+ const UPDATE_COMMAND_TIMEOUT_MS = 180000;
66
+ const COMMAND_MAX_BUFFER_BYTES = 1024 * 1024;
67
+ const UPDATE_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
63
68
  const CODEX_BIN_ENV_VAR = 'CODEX_CONFIGURATOR_CODEX_BIN';
64
69
  const NPM_BIN_ENV_VAR = 'CODEX_CONFIGURATOR_NPM_BIN';
70
+ const CONFIGURATOR_PACKAGE_NAME = 'codex-configurator';
65
71
 
66
- const runCommand = (command, args = []) =>
72
+ const runCommandWithResult = (command, args = [], options = {}) =>
67
73
  new Promise((resolve) => {
68
74
  execFile(
69
75
  command,
70
76
  args,
71
77
  {
72
78
  encoding: 'utf8',
73
- timeout: VERSION_COMMAND_TIMEOUT_MS,
74
- maxBuffer: 1024 * 1024,
79
+ timeout: options.timeout || VERSION_COMMAND_TIMEOUT_MS,
80
+ maxBuffer: options.maxBuffer || COMMAND_MAX_BUFFER_BYTES,
75
81
  windowsHide: true,
76
82
  },
77
- (error, stdout) => {
78
- if (error) {
79
- resolve('');
80
- return;
81
- }
82
-
83
- resolve(String(stdout || '').trim());
83
+ (error, stdout, stderr) => {
84
+ resolve({
85
+ ok: !error,
86
+ stdout: String(stdout || '').trim(),
87
+ stderr: String(stderr || '').trim(),
88
+ });
84
89
  }
85
90
  );
86
91
  });
87
92
 
88
- const getAbsoluteCommandPath = (environmentVariableName) => {
89
- const configuredPath = String(process.env[environmentVariableName] || '').trim();
90
- if (!configuredPath || !path.isAbsolute(configuredPath)) {
93
+ const runCommand = async (command, args = [], options = {}) => {
94
+ const result = await runCommandWithResult(command, args, options);
95
+ if (!result.ok) {
91
96
  return '';
92
97
  }
93
98
 
94
- return configuredPath;
99
+ return result.stdout;
95
100
  };
96
101
 
97
- const getVersionCommands = () => {
98
- if (process.env[VERSION_CHECK_ENABLED_ENV_VAR] !== '1') {
99
- return null;
100
- }
102
+ const getConfiguredCommand = (environmentVariableName, fallbackCommand) => {
103
+ const configuredCommand = String(process.env[environmentVariableName] || '').trim();
104
+ return configuredCommand || fallbackCommand;
105
+ };
101
106
 
102
- const codexCommand = getAbsoluteCommandPath(CODEX_BIN_ENV_VAR);
103
- const npmCommand = getAbsoluteCommandPath(NPM_BIN_ENV_VAR);
104
- if (!codexCommand || !npmCommand) {
105
- return null;
106
- }
107
+ const getVersionCommands = () => ({
108
+ codexCommand: getConfiguredCommand(CODEX_BIN_ENV_VAR, 'codex'),
109
+ npmCommand: getConfiguredCommand(NPM_BIN_ENV_VAR, 'npm'),
110
+ });
107
111
 
108
- return {
109
- codexCommand,
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(
110
119
  npmCommand,
111
- };
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;
112
128
  };
113
129
 
114
130
  const getCodexVersion = async (codexCommand) => {
@@ -157,14 +173,6 @@ const compareVersions = (left, right) => {
157
173
 
158
174
  const getCodexUpdateStatus = async () => {
159
175
  const commands = getVersionCommands();
160
- if (!commands) {
161
- return {
162
- installed: VERSION_DISABLED_LABEL,
163
- latest: 'unknown',
164
- status: '',
165
- };
166
- }
167
-
168
176
  const installedLabel = await getCodexVersion(commands.codexCommand);
169
177
  const installed = normalizeVersion(installedLabel);
170
178
 
@@ -208,6 +216,24 @@ const getCodexUpdateStatus = async () => {
208
216
  };
209
217
  };
210
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
+
211
237
  const App = () => {
212
238
  const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
213
239
  const { stdout } = useStdout();
@@ -221,6 +247,8 @@ const App = () => {
221
247
  const [scrollOffset, setScrollOffset] = useState(0);
222
248
  const [editMode, setEditMode] = useState(null);
223
249
  const [editError, setEditError] = useState('');
250
+ const [filterQuery, setFilterQuery] = useState('');
251
+ const [isFilterEditing, setIsFilterEditing] = useState(false);
224
252
  const [codexVersion, setCodexVersion] = useState('version loading...');
225
253
  const [codexVersionStatus, setCodexVersionStatus] = useState('');
226
254
  const { exit } = useApp();
@@ -239,7 +267,13 @@ const App = () => {
239
267
  setCodexVersionStatus(check.status);
240
268
  };
241
269
 
270
+ const ensureLatestConfigurator = async () => {
271
+ const commands = getVersionCommands();
272
+ await ensureLatestConfiguratorVersion(commands.npmCommand);
273
+ };
274
+
242
275
  loadVersionStatus();
276
+ ensureLatestConfigurator();
243
277
 
244
278
  return () => {
245
279
  isCancelled = true;
@@ -247,7 +281,8 @@ const App = () => {
247
281
  }, []);
248
282
 
249
283
  const currentNode = getNodeAtPath(snapshot.ok ? snapshot.data : {}, pathSegments);
250
- const rows = buildRows(currentNode, pathSegments);
284
+ const allRows = buildRows(currentNode, pathSegments);
285
+ const rows = filterRowsByQuery(allRows, filterQuery);
251
286
  const safeSelected = rows.length === 0 ? 0 : Math.min(selectedIndex, rows.length - 1);
252
287
  const listViewportHeight = computeListViewportHeight(rows, terminalHeight);
253
288
  const currentPathKey = pathToKey(pathSegments);
@@ -464,11 +499,51 @@ const App = () => {
464
499
  useInput((input, key) => {
465
500
  const isTextEditing = isInlineTextMode(editMode?.mode);
466
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
+
467
537
  if (input === 'q' && !isTextEditing) {
468
538
  exit();
469
539
  return;
470
540
  }
471
541
 
542
+ if (!editMode && input === '/') {
543
+ setIsFilterEditing(true);
544
+ return;
545
+ }
546
+
472
547
  if (editMode) {
473
548
  if (isInlineTextMode(editMode.mode)) {
474
549
  if (key.return) {
@@ -785,7 +860,11 @@ const App = () => {
785
860
  return React.createElement(
786
861
  Box,
787
862
  { flexDirection: 'column', padding: 1 },
788
- React.createElement(Header, { codexVersion, codexVersionStatus }),
863
+ React.createElement(Header, {
864
+ codexVersion,
865
+ codexVersionStatus,
866
+ packageVersion: PACKAGE_VERSION,
867
+ }),
789
868
  React.createElement(ConfigNavigator, {
790
869
  snapshot,
791
870
  pathSegments,
@@ -795,6 +874,8 @@ const App = () => {
795
874
  scrollOffset: 0,
796
875
  editMode: null,
797
876
  editError: editError,
877
+ filterQuery,
878
+ isFilterEditing,
798
879
  }),
799
880
  React.createElement(Text, { color: 'yellow' }, 'Non-interactive mode: input is disabled.')
800
881
  );
@@ -803,7 +884,11 @@ const App = () => {
803
884
  return React.createElement(
804
885
  Box,
805
886
  { flexDirection: 'column', padding: 1 },
806
- React.createElement(Header, { codexVersion, codexVersionStatus }),
887
+ React.createElement(Header, {
888
+ codexVersion,
889
+ codexVersionStatus,
890
+ packageVersion: PACKAGE_VERSION,
891
+ }),
807
892
  React.createElement(ConfigNavigator, {
808
893
  snapshot,
809
894
  pathSegments,
@@ -813,8 +898,14 @@ const App = () => {
813
898
  scrollOffset,
814
899
  editMode,
815
900
  editError,
901
+ filterQuery,
902
+ isFilterEditing,
816
903
  }),
817
- 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
+ )
818
909
  );
819
910
  };
820
911
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-configurator",
3
- "version": "0.2.1",
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
  };
@@ -10,7 +10,7 @@ const WORDMARK = [
10
10
  ' ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝',
11
11
  ];
12
12
 
13
- export const Header = ({ codexVersion, codexVersionStatus }) =>
13
+ export const Header = ({ codexVersion, codexVersionStatus, packageVersion }) =>
14
14
  React.createElement(
15
15
  Box,
16
16
  {
@@ -20,14 +20,34 @@ export const Header = ({ codexVersion, codexVersionStatus }) =>
20
20
  },
21
21
  React.createElement(
22
22
  Box,
23
- { flexDirection: 'column', marginBottom: 1, gap: 0 },
24
- ...WORDMARK.map((line, index) =>
25
- React.createElement(Text, { color: 'magentaBright', bold: true, key: `word-${index}` }, line)
26
- )
27
- ),
23
+ { flexDirection: 'row', marginBottom: 1, gap: 0, alignItems: 'flex-end' },
24
+ React.createElement(
25
+ Box,
26
+ { flexDirection: 'column' },
27
+ ...WORDMARK.map((line, index) =>
28
+ React.createElement(Text, { color: 'magentaBright', bold: true, key: `word-${index}` }, line)
29
+ )
30
+ ),
31
+ React.createElement(
32
+ Box,
33
+ { marginLeft: 1 },
34
+ React.createElement(Text, { color: 'gray', bold: true }, `v${packageVersion || 'unknown'}`)
35
+ )
36
+ ),
28
37
  React.createElement(
29
- Text,
30
- { color: 'gray' },
31
- codexVersionStatus ? `Codex ${codexVersion} (${codexVersionStatus})` : `Codex ${codexVersion}`
38
+ Box,
39
+ { flexDirection: 'row' },
40
+ React.createElement(Text, { color: 'gray' }, `Codex ${codexVersion}`),
41
+ codexVersionStatus
42
+ ? codexVersionStatus === 'up to date'
43
+ ? React.createElement(
44
+ React.Fragment,
45
+ null,
46
+ React.createElement(Text, { color: 'gray' }, ' ('),
47
+ React.createElement(Text, { color: 'green' }, '✓'),
48
+ React.createElement(Text, { color: 'gray' }, ` ${codexVersionStatus})`)
49
+ )
50
+ : React.createElement(Text, { color: 'gray' }, ` (${codexVersionStatus})`)
51
+ : null
32
52
  )
33
53
  );
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
+ };