zames_pro 2.88.2 → 2.89.0

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.
@@ -2,17 +2,20 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // The Ink-based CONFIG MENU — a drop-in alternative to the hand-rolled
3
3
  // `runConfigMenu` (src/config-menu.ts), used when `ui.tui` is enabled.
4
4
  //
5
- // It offers the same operations with a cleaner layout: a scrolling list of
6
- // settings grouped by section, ↑/↓ (or j/k) to move, g to jump to the next
7
- // group, Enter/Space to edit (boolean/enum toggle in place, number/string
8
- // prompt), d twice to reset, / to filter, q/Esc to quit.
5
+ // TWO LEVELS. The flat 45-entry list this replaced was unusable: every timing
6
+ // knob of every backend sat in one scroll. `/config` now opens a short list of
7
+ // GROUPS (Agent, Browser & chat, Providers, Interface, Files & history,
8
+ // Diagnostics); Enter opens a group and shows ONLY its settings, Esc goes back.
9
9
  //
10
- // All strings come from i18n (cfg.*), so the menu is fully localized. The
11
- // component never touches config.ts directly: the caller passes get/set/reset
12
- // callbacks (the SAME ones the legacy menu takes), so the logic is shared.
10
+ // Inside a group: ↑/↓ (or j/k) to move, ←/→ or Enter/Space to edit (boolean
11
+ // toggle, enum cycle, number/string prompt), d twice to reset, / to filter,
12
+ // q to quit. All strings come from i18n (cfg.*), so the menu is fully localized.
13
+ // The component never touches config.ts directly: the caller passes get/set/
14
+ // reset callbacks (the SAME ones the legacy menu takes), so the logic is shared.
13
15
  import { useMemo, useState } from 'react';
14
16
  import { Box, Text, useInput, useApp, useWindowSize } from 'ink';
15
17
  import { translate } from '../i18n.js';
18
+ import { CONFIG_TOP_GROUPS } from '../config.js';
16
19
  import { Checkbox, Select, UI, HintBar } from './widgets.js';
17
20
  import { ThemePreview } from './theme-preview.js';
18
21
  /** Strip ANSI to measure a line's visible width. */
@@ -50,6 +53,9 @@ export function ConfigMenuApp(opts) {
50
53
  // (a hardcoded Russian bar showed up in an English UI).
51
54
  const hint = translate(locale);
52
55
  const { columns, rows } = useWindowSize();
56
+ // null = the GROUP list (level 1); a topKey = that group's settings.
57
+ const [openGroup, setOpenGroup] = useState(null);
58
+ const [groupCursor, setGroupCursor] = useState(0);
53
59
  const [cursor, setCursor] = useState(0);
54
60
  const [filterQuery, setFilterQuery] = useState('');
55
61
  const [filterMode, setFilterMode] = useState(false);
@@ -65,14 +71,20 @@ export function ConfigMenuApp(opts) {
65
71
  // Bump this counter after every mutation to force a re-render.
66
72
  const [version, setVersion] = useState(0);
67
73
  const bump = () => setVersion((v) => v + 1);
74
+ const allGroups = opts.topGroups || [...CONFIG_TOP_GROUPS];
75
+ // A group with no visible field is not offered at all.
76
+ const groups = useMemo(() => allGroups.filter((g) => fields.some((f) => f.topKey === g && !f.advanced)), [allGroups, fields]);
68
77
  const visible = useMemo(() => {
78
+ if (!openGroup)
79
+ return [];
80
+ const inGroup = fields.filter((f) => f.topKey === openGroup && !f.advanced);
69
81
  const q = filterQuery.trim().toLowerCase();
70
82
  if (!q)
71
- return fields;
72
- return fields.filter((f) => (f.path + ' ' + t(f.labelKey || f.path) + ' ' + t(f.groupKey))
83
+ return inGroup;
84
+ return inGroup.filter((f) => (f.path + ' ' + t(f.labelKey || f.path) + ' ' + t(f.groupKey))
73
85
  .toLowerCase()
74
86
  .includes(q));
75
- }, [fields, filterQuery, t]);
87
+ }, [fields, openGroup, filterQuery, t]);
76
88
  const isModified = (f) => {
77
89
  if (!getDefault)
78
90
  return false;
@@ -85,6 +97,12 @@ export function ConfigMenuApp(opts) {
85
97
  return String(cur) !== String(def);
86
98
  }
87
99
  };
100
+ /** How many fields of a top group differ from their defaults (badge). */
101
+ const groupChanged = (topKey) => {
102
+ if (!getDefault)
103
+ return 0;
104
+ return fields.filter((f) => f.topKey === topKey && isModified(f)).length;
105
+ };
88
106
  const commitEdit = () => {
89
107
  const f = visible[cursor];
90
108
  const raw = editBuf.trim();
@@ -152,6 +170,30 @@ export function ConfigMenuApp(opts) {
152
170
  setMessage(null);
153
171
  }
154
172
  };
173
+ /** Enter a group: reset the per-group UI state so nothing leaks across. */
174
+ const enterGroup = (topKey) => {
175
+ setOpenGroup(topKey);
176
+ setCursor(0);
177
+ setFilterQuery('');
178
+ setFilterMode(false);
179
+ setPendingReset(null);
180
+ setMessage(null);
181
+ setPreviewTheme(false);
182
+ };
183
+ const leaveGroup = () => {
184
+ // Keep the group list cursor on the group that was just left, so Esc does
185
+ // not send the operator back to the top of the list.
186
+ const idx = groups.indexOf(openGroup || '');
187
+ setOpenGroup(null);
188
+ setGroupCursor(idx >= 0 ? idx : 0);
189
+ setFilterQuery('');
190
+ setFilterMode(false);
191
+ setPendingReset(null);
192
+ setMessage(null);
193
+ setEditing(false);
194
+ setEditBuf('');
195
+ setPreviewTheme(false);
196
+ };
155
197
  const quit = () => {
156
198
  opts.onExit();
157
199
  exit();
@@ -199,13 +241,42 @@ export function ConfigMenuApp(opts) {
199
241
  }
200
242
  return;
201
243
  }
202
- if (key.escape ||
203
- input === 'q' ||
204
- input === 'Q' ||
205
- (key.ctrl && input === 'c')) {
244
+ // ---------- LEVEL 1: the group list ----------
245
+ if (!openGroup) {
246
+ if (key.escape ||
247
+ input === 'q' ||
248
+ input === 'Q' ||
249
+ (key.ctrl && input === 'c')) {
250
+ quit();
251
+ return;
252
+ }
253
+ if (key.upArrow || input === 'k') {
254
+ setGroupCursor((c) => groups.length ? (c - 1 + groups.length) % groups.length : 0);
255
+ return;
256
+ }
257
+ if (key.downArrow || input === 'j') {
258
+ setGroupCursor((c) => (groups.length ? (c + 1) % groups.length : 0));
259
+ return;
260
+ }
261
+ if (key.return || input === ' ' || key.rightArrow) {
262
+ const g = groups[groupCursor];
263
+ if (g)
264
+ enterGroup(g);
265
+ return;
266
+ }
267
+ return;
268
+ }
269
+ // ---------- LEVEL 2: the settings of one group ----------
270
+ if (input === 'q' || input === 'Q' || (key.ctrl && input === 'c')) {
206
271
  quit();
207
272
  return;
208
273
  }
274
+ // Esc = BACK to the group list (not out of /config): inside a group the
275
+ // operator is one level deep, and quitting from there was a surprise.
276
+ if (key.escape) {
277
+ leaveGroup();
278
+ return;
279
+ }
209
280
  if (input === '/') {
210
281
  setFilterMode(true);
211
282
  setMessage(null);
@@ -234,7 +305,7 @@ export function ConfigMenuApp(opts) {
234
305
  return;
235
306
  }
236
307
  if (input === 'g') {
237
- // Jump to the first field of the NEXT group (and back to the top).
308
+ // Jump to the first field of the NEXT subsection (and back to the top).
238
309
  const curGroup = visible[cursor]?.groupKey;
239
310
  let target = -1;
240
311
  for (let k = cursor + 1; k < visible.length; k++) {
@@ -259,18 +330,6 @@ export function ConfigMenuApp(opts) {
259
330
  return;
260
331
  }
261
332
  }
262
- if (input === 'p' || input === 'P') {
263
- // Inline palette PREVIEW on the theme row: /config is where most
264
- // operators meet ui.theme, and cycling the enum only shows the id. 'p'
265
- // toggles a sample session painted with the CURRENT palette, so the
266
- // choice can be judged without leaving the menu. Other rows: no-op.
267
- const f = visible[cursor];
268
- if (f && f.path === 'ui.theme') {
269
- setPreviewTheme((v) => !v);
270
- setMessage(null);
271
- return;
272
- }
273
- }
274
333
  if (key.return || input === ' ') {
275
334
  activate();
276
335
  return;
@@ -302,11 +361,13 @@ export function ConfigMenuApp(opts) {
302
361
  const maxRows = Math.max(3, termRows - 8);
303
362
  const innerCols = Math.max(1, (columns || 80) - 4);
304
363
  const shown = useMemo(() => {
305
- const groupStart = (i) => i === 0 || visible[i].groupKey !== visible[i - 1].groupKey;
306
- // Rendered height of one item: 1 base row + wrapped extra rows. The label
307
- // plus the value cell plus the path hint is approximated by the raw label
308
- // length; over-estimating is safe (it only shortens the window).
364
+ const list = openGroup ? visible : groups;
365
+ const groupStart = (i) => !!openGroup &&
366
+ (i === 0 || visible[i].groupKey !== visible[i - 1].groupKey);
367
+ // Rendered height of one item: 1 base row + wrapped extra rows.
309
368
  const itemRows = (i) => {
369
+ if (!openGroup)
370
+ return 1;
310
371
  const f = visible[i];
311
372
  const label = f.labelKey ? t(f.labelKey) : f.path;
312
373
  return Math.max(1, Math.ceil((label.length + 12) / innerCols));
@@ -320,18 +381,19 @@ export function ConfigMenuApp(opts) {
320
381
  }
321
382
  return r;
322
383
  };
323
- if (!visible.length)
384
+ const cur = openGroup ? cursor : groupCursor;
385
+ if (!list.length)
324
386
  return { start: 0, end: 0 };
325
- let start = cursor;
326
- let end = cursor + 1;
387
+ let start = cur;
388
+ let end = cur + 1;
327
389
  // Fill downward first, then upward, so the cursor stays visible and the
328
390
  // window uses the whole budget.
329
- while (end < visible.length && rowsIn(start, end + 1) <= maxRows)
391
+ while (end < list.length && rowsIn(start, end + 1) <= maxRows)
330
392
  end++;
331
393
  while (start > 0 && rowsIn(start - 1, end) <= maxRows)
332
394
  start--;
333
395
  return { start, end };
334
- }, [cursor, visible, maxRows, innerCols, t]);
396
+ }, [cursor, groupCursor, openGroup, visible, groups, maxRows, innerCols, t]);
335
397
  const usable = Math.max(1, (columns || 80) - 1);
336
398
  void visCols;
337
399
  void usable;
@@ -339,37 +401,59 @@ export function ConfigMenuApp(opts) {
339
401
  // counter (a pure `get()` call has no reactive dependency of its own).
340
402
  void version;
341
403
  const items = [];
342
- let lastGroup = '';
343
- for (let i = shown.start; i < shown.end; i++) {
344
- const f = visible[i];
345
- const g = f.groupKey;
346
- if (i === shown.start || g !== lastGroup) {
347
- items.push(_jsx(Text, { color: UI.title, children: ' ' + t(f.groupKey).toUpperCase() }, 'g' + i));
348
- lastGroup = g;
404
+ if (!openGroup) {
405
+ // ---- level 1: one row per GROUP ----
406
+ for (let i = shown.start; i < shown.end; i++) {
407
+ const g = groups[i];
408
+ const selected = i === groupCursor;
409
+ const count = fields.filter((f) => f.topKey === g && !f.advanced).length;
410
+ const changed = groupChanged(g);
411
+ items.push(_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, children: selected ? '❯ ' : ' ' }), _jsx(Text, { color: selected ? UI.accent : UI.text, bold: selected, children: t(g) }), _jsx(Text, { color: UI.dim, children: ' ' + t('cfg.menu.group_count', { n: count }) }), changed > 0 ? (_jsx(Text, { color: UI.warn, children: ' ' + t('cfg.menu.group_modified', { n: changed }) })) : null] }, 'grp' + i));
349
412
  }
350
- const selected = i === cursor;
351
- const label = f.labelKey ? t(f.labelKey) : f.path;
352
- const raw = get(f.path);
353
- const mod = isModified(f) ? '*' : '';
354
- // The VALUE cell depends on the field type: a checkbox for booleans, an
355
- // inline select for enums, plain text otherwise. The selected row shows
356
- // the path on the right as a hint.
357
- let valueCell;
358
- if (f.type === 'boolean') {
359
- valueCell = _jsx(Checkbox, { checked: raw === true, focused: selected });
360
- }
361
- else if (f.type === 'enum' && f.values && f.values.length) {
362
- valueCell = (_jsx(Select, { value: String(raw ?? f.values[0]), focused: selected }));
413
+ }
414
+ else {
415
+ // ---- level 2: the settings of the open group ----
416
+ let lastGroup = '';
417
+ for (let i = shown.start; i < shown.end; i++) {
418
+ const f = visible[i];
419
+ const g = f.groupKey;
420
+ if (i === shown.start || g !== lastGroup) {
421
+ items.push(_jsx(Text, { color: UI.title, children: ' ' + t(f.groupKey).toUpperCase() }, 'g' + i));
422
+ lastGroup = g;
423
+ }
424
+ const selected = i === cursor;
425
+ const label = f.labelKey ? t(f.labelKey) : f.path;
426
+ const raw = get(f.path);
427
+ const mod = isModified(f) ? '*' : '';
428
+ // The VALUE cell depends on the field type: a checkbox for booleans, an
429
+ // inline select for enums, plain text otherwise. The selected row shows
430
+ // the path on the right as a hint.
431
+ let valueCell;
432
+ if (f.type === 'boolean') {
433
+ valueCell = _jsx(Checkbox, { checked: raw === true, focused: selected });
434
+ }
435
+ else if (f.type === 'enum' && f.values && f.values.length) {
436
+ valueCell = (_jsx(Select, { value: String(raw ?? f.values[0]), focused: selected }));
437
+ }
438
+ else {
439
+ valueCell = (_jsx(Text, { color: selected ? UI.text : UI.system, children: displayValue(f, raw, t) }));
440
+ }
441
+ items.push(_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, children: selected ? '❯ ' : ' ' }), _jsx(Text, { color: selected ? UI.accent : UI.text, bold: selected, children: label }), _jsx(Text, { color: UI.dim, children: ' ' }), valueCell, mod ? _jsx(Text, { color: UI.warn, children: ' ' + mod }) : null, selected ? _jsx(Text, { color: UI.dim, children: ' ' + f.path }) : null] }, 'f' + i));
363
442
  }
364
- else {
365
- valueCell = (_jsx(Text, { color: selected ? UI.text : UI.system, children: displayValue(f, raw, t) }));
443
+ if (!visible.length) {
444
+ items.push(_jsx(Text, { color: UI.dim, children: ' ' + t('cfg.menu.no_match') }, "none"));
366
445
  }
367
- items.push(_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, children: selected ? '❯ ' : ' ' }), _jsx(Text, { color: selected ? UI.accent : UI.text, bold: selected, children: label }), _jsx(Text, { color: UI.dim, children: ' ' }), valueCell, mod ? _jsx(Text, { color: UI.warn, children: ' ' + mod }) : null, selected ? _jsx(Text, { color: UI.dim, children: ' ' + f.path }) : null] }, 'f' + i));
368
- }
369
- if (!visible.length) {
370
- items.push(_jsx(Text, { color: UI.dim, children: ' ' + t('cfg.menu.no_match') }, "none"));
371
446
  }
372
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: UI.title, children: t('cfg.menu.title') }), _jsx(Text, { color: UI.dim, children: (visible.length ? cursor + 1 : 0) + '/' + visible.length })] }), _jsx(Text, { color: UI.border, children: '─'.repeat(Math.max(10, (columns || 80) - 6)) }), items, _jsx(Text, { color: UI.border, children: '─'.repeat(Math.max(10, (columns || 80) - 6)) }), previewTheme && visible[cursor]?.path === 'ui.theme' ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.dim, children: t('cfg.menu.theme_preview') }), _jsx(ThemePreview, { name: String(get('ui.theme') ?? 'zames'), compact: true })] })) : null, editing ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.text, children: t('cfg.menu.edit_hint') }), _jsx(Text, { color: UI.accent, children: '❯ ' + editBuf })] })) : filterMode ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.text, children: t('cfg.menu.filter_hint') }), _jsx(Text, { color: UI.accent, children: '❯ ' + filterQuery })] })) : message ? (_jsx(Text, { color: UI.ok, children: message })) : filterQuery ? (_jsx(Text, { color: UI.dim, children: t('cfg.menu.filter_active', { q: filterQuery }) })) : null, _jsx(HintBar, { hints: [
447
+ const count = openGroup ? visible.length : groups.length;
448
+ const pos = openGroup ? cursor : groupCursor;
449
+ const title = openGroup
450
+ ? t(openGroup) + ' › ' + t('cfg.menu.title')
451
+ : t('cfg.menu.groups_title');
452
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: UI.title, children: title }), _jsx(Text, { color: UI.dim, children: (count ? pos + 1 : 0) + '/' + count })] }), _jsx(Text, { color: UI.border, children: '─'.repeat(Math.max(10, (columns || 80) - 6)) }), items, _jsx(Text, { color: UI.border, children: '─'.repeat(Math.max(10, (columns || 80) - 6)) }), previewTheme && visible[cursor]?.path === 'ui.theme' ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.dim, children: t('cfg.menu.theme_preview') }), _jsx(ThemePreview, { name: String(get('ui.theme') ?? 'zames'), compact: true })] })) : null, editing ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.text, children: t('cfg.menu.edit_hint') }), _jsx(Text, { color: UI.accent, children: '❯ ' + editBuf })] })) : filterMode ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.text, children: t('cfg.menu.filter_hint') }), _jsx(Text, { color: UI.accent, children: '❯ ' + filterQuery })] })) : message ? (_jsx(Text, { color: UI.ok, children: message })) : filterQuery ? (_jsx(Text, { color: UI.dim, children: t('cfg.menu.filter_active', { q: filterQuery }) })) : null, !openGroup ? (_jsx(HintBar, { hints: [
453
+ ['↑/↓', hint('tui.hint.select')],
454
+ ['Enter', hint('tui.hint.ok')],
455
+ ['q', hint('tui.hint.quit')],
456
+ ] })) : (_jsx(HintBar, { hints: [
373
457
  ['↑/↓', hint('tui.hint.select')],
374
458
  ['←/→', hint('tui.hint.change')],
375
459
  ['Enter', hint('tui.hint.edit')],
@@ -378,6 +462,7 @@ export function ConfigMenuApp(opts) {
378
462
  : []),
379
463
  ['/', hint('tui.hint.search')],
380
464
  ['d', hint('tui.hint.reset')],
465
+ ['Esc', hint('tui.hint.back')],
381
466
  ['q', hint('tui.hint.quit')],
382
- ] })] }));
467
+ ] }))] }));
383
468
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.88.2",
3
+ "version": "2.89.0",
4
4
  "description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen or GLM — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
5
5
  "type": "module",
6
6
  "bin": {