zames_pro 2.88.2 → 2.90.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +32 -585
  3. package/dist/agent-loop.js +47 -4
  4. package/dist/ask-retry.js +49 -0
  5. package/dist/ask-state.js +80 -0
  6. package/dist/browser-base.js +69 -0
  7. package/dist/browser.js +170 -41
  8. package/dist/command-registry.js +58 -0
  9. package/dist/commands.js +25 -2
  10. package/dist/completion-check.js +77 -0
  11. package/dist/config-commands.js +12 -4
  12. package/dist/config-menu.js +179 -81
  13. package/dist/config-schema.js +562 -0
  14. package/dist/config.js +19 -405
  15. package/dist/dev-commands.js +27 -0
  16. package/dist/dev-watch.js +96 -0
  17. package/dist/first-run.js +80 -0
  18. package/dist/glm-browser.js +133 -32
  19. package/dist/i18n/common.js +6 -3
  20. package/dist/i18n/config.js +78 -3
  21. package/dist/i18n/deepseek.js +1 -0
  22. package/dist/i18n/wizard.js +25 -0
  23. package/dist/i18n.js +6 -1
  24. package/dist/index.js +1299 -924
  25. package/dist/info-commands.js +61 -0
  26. package/dist/input/layout.js +38 -1
  27. package/dist/input.js +2 -2
  28. package/dist/password-store.js +48 -0
  29. package/dist/prompt-budget.js +23 -0
  30. package/dist/provider-errors.js +53 -0
  31. package/dist/provider-fallback.js +46 -0
  32. package/dist/qwen-browser.js +155 -39
  33. package/dist/run-task.js +2 -1
  34. package/dist/scheduler-commands.js +41 -0
  35. package/dist/send-slot.js +36 -0
  36. package/dist/session-commands.js +42 -0
  37. package/dist/spinner.js +3 -3
  38. package/dist/swallow.js +45 -0
  39. package/dist/system-prompt.js +2 -2
  40. package/dist/tree-commands.js +36 -0
  41. package/dist/tui/ConfigMenu.js +149 -64
  42. package/dist/tui/FullScreenApp.js +13 -4
  43. package/dist/tui/InputLine.js +20 -3
  44. package/dist/tui/LogView.js +11 -5
  45. package/dist/tui/StatusBar.js +4 -1
  46. package/dist/tui/TuiApp.js +11 -2
  47. package/dist/tui/layout.js +14 -1
  48. package/dist/tui/store.js +2 -1
  49. package/dist/tui/ui-colors.js +2 -0
  50. package/dist/width.js +10 -5
  51. package/llms-full.txt +16 -292
  52. package/package.json +2 -2
@@ -0,0 +1,45 @@
1
+ // N7: ACCOUNTING for swallowed exceptions instead of silent `catch {}`.
2
+ //
3
+ // The browser classes have ~180 empty catches (a selector probe, a best-effort
4
+ // cleanup, an optional DOM read). When a site redesign breaks a selector, such
5
+ // a catch turns the failure into silence: the UI health probe is clean, yet the
6
+ // answer never appears. This helper records the failure by TAG (always) and
7
+ // prints it only in debug mode, so the counters can be surfaced by /doctor and
8
+ // the send telemetry without flooding the terminal.
9
+ //
10
+ // A genuinely expected empty catch (best-effort cleanup, an absent optional
11
+ // element) keeps `swallowSilently()` with a WHY comment — the point is that
12
+ // silence becomes an EXPLICIT choice, not the default.
13
+ const counts = new Map();
14
+ /** Record a swallowed failure. `detail` is only used in debug mode. */
15
+ export function noteSwallowed(tag, err, debug = false) {
16
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
17
+ if (debug) {
18
+ const msg = err instanceof Error ? err.message : String(err ?? '');
19
+ console.error('[swallowed] ' + tag + (msg ? ': ' + msg : ''));
20
+ }
21
+ }
22
+ /**
23
+ * Run `fn`, swallowing (and counting) any error under `tag`.
24
+ * Returns `fallback` when it failed, so the caller keeps its happy path.
25
+ */
26
+ export async function swallow(tag, fn, fallback, debug = false) {
27
+ try {
28
+ return await fn();
29
+ }
30
+ catch (e) {
31
+ noteSwallowed(tag, e, debug);
32
+ return fallback;
33
+ }
34
+ }
35
+ /** The swallowed-failure counters, sorted by count (for /doctor). */
36
+ export function swallowedReport(limit = 5) {
37
+ return [...counts.entries()]
38
+ .map(([tag, count]) => ({ tag, count }))
39
+ .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
40
+ .slice(0, limit);
41
+ }
42
+ /** Reset the counters (tests and a fresh session). */
43
+ export function resetSwallowed() {
44
+ counts.clear();
45
+ }
@@ -1,5 +1,5 @@
1
1
  import path from 'path';
2
- import { translate } from './i18n.js';
2
+ import { translate, DEFAULT_LOCALE } from './i18n.js';
3
3
  // The dev-mode BACKLOG instruction. English (agent-facing) like the rest of the
4
4
  // prompt. Deliberately narrow: one short note at most, and only when the idea
5
5
  // is OUTSIDE the current task — otherwise the model documents trivia instead
@@ -204,7 +204,7 @@ function renderToolSection(tools) {
204
204
  .map((t2) => `### ${t2.name}\n${t2.description}\nParameters: ${JSON.stringify(t2.parameters)}`)
205
205
  .join('\n\n');
206
206
  }
207
- export function buildSystemPrompt({ workdir, tools, gitContext = null, locale = 'ru', attachments = [], context = null, selfImprovement = false, provider, }) {
207
+ export function buildSystemPrompt({ workdir, tools, gitContext = null, locale = DEFAULT_LOCALE, attachments = [], context = null, selfImprovement = false, provider, }) {
208
208
  const t = translate(locale);
209
209
  const toolDescriptions = renderToolSection(tools);
210
210
  // Emit the subagent guidance ONLY when the Task tool is actually in the set
@@ -0,0 +1,36 @@
1
+ // N2 group (c): the git / undo / rewind commands — /diff, /diffstat, /undo,
2
+ // /undo-list (/history), /rewind-list (/checkpoints), /rewind. All of them
3
+ // drive live stores (git, the undo store, the checkpoint store) and the editor
4
+ // lock, so the work is INJECTED; the registry owns the names, aliases and the
5
+ // argument forwarding.
6
+ /** True when the typed line carries the `--staged` flag (any position). */
7
+ export function hasStagedFlag(line) {
8
+ return /(^|\s)--staged(\s|$)/.test(line);
9
+ }
10
+ export function createTreeCommands(deps) {
11
+ return [
12
+ {
13
+ name: 'diff',
14
+ handler: (ctx) => deps.diff(hasStagedFlag(ctx.input)),
15
+ },
16
+ {
17
+ name: 'diffstat',
18
+ handler: (ctx) => deps.diffstat(hasStagedFlag(ctx.input)),
19
+ },
20
+ { name: 'undo', handler: () => deps.undo() },
21
+ {
22
+ name: 'undo-list',
23
+ aliases: ['history'],
24
+ handler: () => deps.undoList(),
25
+ },
26
+ {
27
+ name: 'rewind-list',
28
+ aliases: ['checkpoints'],
29
+ handler: () => deps.rewindList(),
30
+ },
31
+ {
32
+ name: 'rewind',
33
+ handler: (ctx) => deps.rewind(ctx.rest.split(/\s+/)[0] || '1', ctx.input),
34
+ },
35
+ ];
36
+ }
@@ -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
  }
@@ -17,14 +17,17 @@ import { StatusBar } from './StatusBar.js';
17
17
  import { InputLine } from './InputLine.js';
18
18
  import { translate } from '../i18n.js';
19
19
  import { suggestRowCount } from '../input/suggest.js';
20
- import { truncateToWidth, visLen } from '../input/layout.js';
20
+ import { truncateToWidth, visLen, screenGutter, screenInnerCols, } from '../input/layout.js';
21
21
  import { fullScreenLayout } from './layout.js';
22
22
  import { useMouseWheel } from './useMouseWheel.js';
23
23
  import { UI, inputBorderCols } from './ui-colors.js';
24
24
  /** The header bar: title, version, working directory. */
25
25
  function Header({ store }) {
26
26
  const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
27
- const cols = Math.max(1, useWindowSize().columns || 80);
27
+ // The header lives INSIDE the gutter, so it must lay out to the pane width:
28
+ // filling the terminal width would wrap the title row and add a row the
29
+ // layout does not reserve.
30
+ const cols = screenInnerCols(useWindowSize().columns || 80);
28
31
  const right = [state.provider && '[' + state.provider + ']']
29
32
  .filter(Boolean)
30
33
  .join(' ');
@@ -57,7 +60,13 @@ export function FullScreenApp({ store, inputProps, }) {
57
60
  // caret row can never drift apart (the component used to compute them
58
61
  // separately and the caret landed below the input box when the panel filled
59
62
  // the terminal).
60
- const cols = Math.max(1, useWindowSize().columns || 80);
63
+ // Gutter on both sides: the operator asked for breathing room instead of a
64
+ // UI pinned to the terminal edges (most visible on a wide screen, where the
65
+ // theme's background made the flush layout look broken). Everything below
66
+ // (header, log wrap, status, input box, hint) uses the SAME pane width.
67
+ const termCols = Math.max(1, useWindowSize().columns || 80);
68
+ const gutter = screenGutter(termCols);
69
+ const cols = screenInnerCols(termCols);
61
70
  const fsLayout = fullScreenLayout({
62
71
  termRows,
63
72
  cols,
@@ -112,7 +121,7 @@ export function FullScreenApp({ store, inputProps, }) {
112
121
  const next = cur + delta * 3;
113
122
  store.set({ scroll: Math.max(0, Math.min(maxScroll, next)) });
114
123
  }, state.mouseScroll);
115
- return (_jsxs(Box, { flexDirection: "column", height: termRows, children: [_jsx(Header, { store: store }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 1, overflow: "hidden", justifyContent: "flex-end", children: all.length === 0 ? (_jsx(Text, { color: UI.dim, children: translate(state.locale)('editor.empty_hint') })) : (_jsx(LogList, { entries: visible })) }), _jsx(StatusBar, { state: state, divider: false }), _jsx(InputLine, { ...inputProps, store: store,
124
+ return (_jsxs(Box, { flexDirection: "column", height: termRows, paddingLeft: gutter, paddingRight: gutter, children: [_jsx(Header, { store: store }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 1, overflow: "hidden", justifyContent: "flex-end", children: all.length === 0 ? (_jsx(Text, { color: UI.dim, children: translate(state.locale)('editor.empty_hint') })) : (_jsx(LogList, { entries: visible })) }), _jsx(StatusBar, { state: state, divider: false }), _jsx(InputLine, { ...inputProps, store: store,
116
125
  // Cursor rows above the input box: header + log viewport + status.
117
126
  // The log pane is FLEXIBLE now, so the panel below it starts right
118
127
  // after everything above: header + as many log rows as fit + the
@@ -12,10 +12,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
12
12
  import { Box, Text, useInput, usePaste, useCursor, useWindowSize, useStdin, } from 'ink';
13
13
  import { translate } from '../i18n.js';
14
14
  import { expandPastes, inputInnerCols, layoutInput, SUGGEST_PAGE, } from '../input/layout.js';
15
+ import { screenInnerCols, screenGutter } from '../input/layout.js';
15
16
  import { insertText, insertPaste, insertNewline, backspace, del, moveLeft, moveRight, moveHome, moveEnd, wordLeft, wordRight, deleteWordLeft, deleteWordRight, killToLineEnd, moveVisualRows, pushUndo, undoEdit, historyUp, historyDown, searchStart, searchNext, searchAccept, searchCancel, searchInput, searchBackspace, recordHistory, afterSubmit, rememberDraft, } from './edit.js';
16
17
  import { dispatchKey, parseCsiU } from './keys.js';
17
18
  import { useSyncExternalStore } from 'react';
18
19
  import { matchSlashCommands, suggestWindow, completeSlashCommand, } from '../input/suggest.js';
20
+ import { suggestRowCount } from '../input/suggest.js';
19
21
  import { UI, inputBorderStyle } from './ui-colors.js';
20
22
  /** Render the visual rows of the buffer with the prompt on the first row. */
21
23
  function InputRows({ prompt, buf, cols, }) {
@@ -23,8 +25,10 @@ function InputRows({ prompt, buf, cols, }) {
23
25
  return (_jsx(Box, { flexDirection: "column", children: lay.rows.map((r, i) => (_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, bold: true, children: r.prefix }), _jsx(Text, { color: UI.text, children: r.text })] }, i))) }));
24
26
  }
25
27
  export function InputLine(props) {
28
+ // Pane width (gutter excluded): the box must not be wider than the padded
29
+ // root, or it overflows the row and the caret math drifts.
26
30
  const { columns } = useWindowSize();
27
- const cols = columns || 80;
31
+ const cols = screenInnerCols(columns || 80);
28
32
  const { setCursorPosition } = useCursor();
29
33
  const [suggestSelected, setSuggestSelected] = useState(-1);
30
34
  // The visible page of the suggestion list follows the SELECTION: with more
@@ -317,8 +321,21 @@ export function InputLine(props) {
317
321
  // code re-measured `rowText` up to `cursorCol` code points, which
318
322
  // DOUBLE-counted wide chars: a CJK buffer put the caret at the line end
319
323
  // instead of under the cursor.
320
- 2 + lay.cursorCol;
321
- setCursorPosition({ x: cursorX, y: (props.cursorTop ?? 3) + cursorRow });
324
+ // 2 (box border + inner padding) + the ROOT gutter (paddingLeft of the
325
+ // full-screen/inline root) + the column inside the box. The gutter was
326
+ // forgotten: at 170 columns the root pads 2, so the caret sat exactly 2
327
+ // cells LEFT of the text the operator typed.
328
+ screenGutter(columns || 80) + 2 + lay.cursorCol;
329
+ // The suggestion popup is drawn ABOVE the box in THIS component, so it pushes
330
+ // the input rows DOWN by its own height. `cursorTop` (from the app) counts
331
+ // the rows above the INPUT BOX, not above the popup, so the popup rows are
332
+ // added HERE — exactly where they are rendered. (A caller that folded them
333
+ // in as well double-counted them and the caret landed inside the popup.)
334
+ const popupRows = suggestions.length > 0 ? suggestRowCount(st.buf, props.commands, cols) : 0;
335
+ setCursorPosition({
336
+ x: cursorX,
337
+ y: (props.cursorTop ?? 3) + popupRows + cursorRow,
338
+ });
322
339
  const { shown, moreCount } = suggestWindow(suggestions, suggestOffset);
323
340
  const t = translate(props.locale);
324
341
  return (_jsxs(Box, { flexDirection: "column", children: [suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [shown.map((c, i) => {
@@ -1,6 +1,7 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Static, Box, Text } from 'ink';
3
3
  import { theme } from '../theme.js';
4
+ import { UI } from './ui-colors.js';
4
5
  /** One log entry. `raw` items already carry ANSI colors and are printed
5
6
  * verbatim; structured items get a themed prefix. */
6
7
  function LogRow({ entry }) {
@@ -10,10 +11,15 @@ function LogRow({ entry }) {
10
11
  return _jsx(Text, { children: entry.text });
11
12
  }
12
13
  if (entry.bar) {
13
- // An operator turn: `▌` on EVERY visual row. Applying the bar to the whole
14
- // (already wrapped) block keeps the indent equal on continuation lines —
15
- // prefixing only the first line made a multi-line message look ragged.
16
- return _jsx(Text, { children: theme.userBar('▌ ') + theme.user(entry.text) });
14
+ // An operator turn: `▌` on EVERY visual row.
15
+ //
16
+ // A SINGLE <Text> with a colored prefix child is NOT enough: Ink renders a
17
+ // multi-line string as-is, so the prefix appears on the FIRST line only
18
+ // (the old comment claimed the opposite and the bug survived for that
19
+ // reason). The text is already hard-wrapped by the caller
20
+ // (wrapToWidth), so split it back into rows and draw the bar on each one.
21
+ const rows = String(entry.text ?? '').split(String.fromCharCode(10));
22
+ return (_jsx(Box, { flexDirection: "column", children: rows.map((row, i) => (_jsxs(Text, { children: [_jsx(Text, { color: UI.userBar, children: "\u258C " }), _jsx(Text, { color: UI.user, children: row })] }, i))) }));
17
23
  }
18
24
  switch (entry.kind) {
19
25
  case 'user':
@@ -1,6 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useAnimation, useWindowSize } from 'ink';
3
3
  import { translate } from '../i18n.js';
4
+ import { screenInnerCols } from '../input/layout.js';
4
5
  import { DOTS, stripEllipsis } from '../spinner.js';
5
6
  import { UI } from './ui-colors.js';
6
7
  import { truncateToWidth, visLen } from '../input/layout.js';
@@ -175,7 +176,9 @@ export function StatusBar({ state, divider = true, }) {
175
176
  // row sat empty; measuring the badges (they are DROPPED whole when the row
176
177
  // is tight, the context counter survives longest) is both accurate and
177
178
  // keeps the layout stable.
178
- const cols = useWindowSize().columns || 80;
179
+ // Pane width: the full-screen/inline root keeps a gutter on both sides, so
180
+ // the divider must not run to the terminal edge.
181
+ const cols = screenInnerCols(useWindowSize().columns || 80);
179
182
  const badgeW = badgeTextWidth(state);
180
183
  const gap = badgeW > 0 ? 2 : 0;
181
184
  const leftBudget = Math.max(0, cols - 2 - badgeW - gap);
@@ -14,6 +14,7 @@ import { LogView } from './LogView.js';
14
14
  import { StatusBar } from './StatusBar.js';
15
15
  import { InputLine } from './InputLine.js';
16
16
  import { suggestRowCount } from '../input/suggest.js';
17
+ import { screenGutter, screenInnerCols } from '../input/layout.js';
17
18
  export function TuiApp({ store, inputProps, initialPrintedCount = 0, }) {
18
19
  const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
19
20
  // The watermark is fixed for the lifetime of this mount.
@@ -23,7 +24,15 @@ export function TuiApp({ store, inputProps, initialPrintedCount = 0, }) {
23
24
  // + the suggestion popup (which renders above the box and would otherwise
24
25
  // shift the caret down by its row count). Without the suggestRows term the
25
26
  // caret drifted whenever the «/» list opened.
26
- const cols = useWindowSize().columns || 80;
27
+ // The INLINE layout needs the same gutter the full-screen one has: the
28
+ // operator's complaint was that switching a theme made the flush edges
29
+ // obvious (the themed background only covers what we paint). The width fed
30
+ // to the suggest-row math must match the padded pane, or the caret drifts.
31
+ const gutter = screenGutter(useWindowSize().columns || 80);
32
+ const cols = screenInnerCols(useWindowSize().columns || 80);
27
33
  const suggestRows = suggestRowCount(state.edit.buf, state.commands, cols);
28
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(LogView, { entries: entries }), _jsx(StatusBar, { state: state }), _jsx(InputLine, { ...inputProps, store: store, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked, cursorTop: 3 + suggestRows })] }));
34
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: gutter, paddingRight: gutter, children: [_jsx(LogView, { entries: entries }), _jsx(StatusBar, { state: state }), _jsx(InputLine, { ...inputProps, store: store, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked,
35
+ // 3 = divider + status + the box TOP border. The popup sits ABOVE
36
+ // the box and does not move it, so it must not shift the caret.
37
+ cursorTop: 3 })] }));
29
38
  }
@@ -12,13 +12,26 @@ const STATUS_ROWS = 1;
12
12
  export const HINT_ROWS = 1;
13
13
  export function fullScreenLayout(input) {
14
14
  const termRows = Math.max(1, Math.floor(input.termRows) || 24);
15
+ // `cols` is the WIDTH OF A PANE, not of the terminal: the full-screen app
16
+ // puts a gutter on both sides (screenInnerCols), and the input box wraps to
17
+ // exactly that inner width. Using the terminal width here reserved one row
18
+ // too few the moment a long input filled the pane (the box then wrapped an
19
+ // extra line and the caret drifted), so the caller MUST pass the inner
20
+ // width — and the pure helper stays consistent with what it renders.
15
21
  const cols = Math.max(1, Math.floor(input.cols) || 80);
16
22
  const box = inputBoxRows(input.prompt, input.buf, input.cursor, cols, input.borderCols ?? 2);
17
23
  const suggestRows = Math.max(0, Math.floor(input.suggestRows) || 0);
18
24
  const panelRows = HEADER_ROWS + STATUS_ROWS + box + suggestRows + HINT_ROWS;
19
25
  const logHeight = Math.max(1, termRows - panelRows);
20
26
  // The caret counts the SAME viewport the reservation uses.
21
- const cursorTop = HEADER_ROWS + logHeight + suggestRows + STATUS_ROWS + 1;
27
+ // The suggestion popup is drawn ABOVE the box and the box does NOT move, so
28
+ // the caret row must NOT include `suggestRows`: adding it put the terminal
29
+ // caret on the popup line (the operator saw the block cursor between
30
+ // `/chats` and the box). The popup only SHRINKS the log viewport — which
31
+ // `logHeight` above already accounts for.
32
+ // `cursorTop` is the row of the input BOX top border (the popup and the
33
+ // buffer rows are added by InputLine, where they are actually rendered).
34
+ const cursorTop = HEADER_ROWS + logHeight + STATUS_ROWS + 1;
22
35
  return {
23
36
  headerRows: HEADER_ROWS,
24
37
  statusRows: STATUS_ROWS,