zames_pro 2.74.0 → 2.75.1

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 (66) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +24 -0
  3. package/dist/attachments.js +6 -1
  4. package/dist/commands.js +0 -1
  5. package/dist/config.js +7 -7
  6. package/dist/i18n/common.js +0 -8
  7. package/dist/i18n/config.js +0 -1
  8. package/dist/i18n/help.js +8 -4
  9. package/dist/i18n/qwen.js +0 -4
  10. package/dist/i18n/self-review.js +2 -18
  11. package/dist/i18n/spinner.js +5 -0
  12. package/dist/index.js +30 -43
  13. package/dist/input/suggest.js +19 -4
  14. package/dist/theme.js +9 -0
  15. package/dist/tui/ConfigMenu.js +44 -11
  16. package/dist/tui/FullScreenApp.js +44 -11
  17. package/dist/tui/InputLine.js +52 -19
  18. package/dist/tui/SelectList.js +6 -2
  19. package/dist/tui/StatusBar.js +66 -36
  20. package/dist/tui/TuiApp.js +3 -2
  21. package/dist/tui/TuiEditor.js +44 -4
  22. package/dist/tui/edit.js +21 -1
  23. package/dist/tui/keys.js +46 -2
  24. package/dist/tui/store.js +1 -1
  25. package/dist/tui/useMouseWheel.js +80 -29
  26. package/dist/tui/widgets.js +6 -1
  27. package/package.json +2 -1
  28. package/scripts/ctd-live-driver.mts +51 -0
  29. package/scripts/ctd-live.tsx +33 -0
  30. package/scripts/escd-live-driver.mts +37 -0
  31. package/scripts/inline-status-driver.mts +31 -0
  32. package/scripts/inline-status.tsx +33 -0
  33. package/scripts/mouse-churn.mts +34 -0
  34. package/scripts/qwen-models-smoke.mts +117 -0
  35. package/scripts/ru-shot.mts +20 -0
  36. package/scripts/tui-comp-shot.mts +63 -0
  37. package/scripts/tui-components.tsx +62 -0
  38. package/scripts/tui-config-driver.mts +82 -0
  39. package/scripts/tui-config-probe.tsx +74 -0
  40. package/scripts/tui-edit-driver.mts +100 -0
  41. package/scripts/tui-edit-probe.tsx +38 -0
  42. package/scripts/tui-editor-probe.tsx +24 -0
  43. package/scripts/tui-input-driver.mts +54 -0
  44. package/scripts/tui-input-shot.tsx +59 -0
  45. package/scripts/tui-log-driver.mts +35 -0
  46. package/scripts/tui-log-shot.tsx +48 -0
  47. package/scripts/tui-longpaste-driver.mts +56 -0
  48. package/scripts/tui-longpaste.tsx +35 -0
  49. package/scripts/tui-manyselect-shot.mts +34 -0
  50. package/scripts/tui-manyselect.tsx +17 -0
  51. package/scripts/tui-mouse-churn-probe.tsx +28 -0
  52. package/scripts/tui-paste-driver.mts +88 -0
  53. package/scripts/tui-paste-kinds.tsx +76 -0
  54. package/scripts/tui-pastetest.mts +38 -0
  55. package/scripts/tui-repro.tsx +64 -0
  56. package/scripts/tui-resize.mts +70 -0
  57. package/scripts/tui-scroll-driver.mts +53 -0
  58. package/scripts/tui-scroll-probe.tsx +46 -0
  59. package/scripts/tui-shot.mts +106 -0
  60. package/scripts/tui-status-driver.mts +24 -0
  61. package/scripts/tui-status-shot.tsx +27 -0
  62. package/scripts/tui-submit.tsx +57 -0
  63. package/scripts/tui-wheel-driver.mts +61 -0
  64. package/scripts/tui-wheel-probe.tsx +48 -0
  65. package/scripts/tui-widgets-driver.mts +20 -0
  66. package/scripts/tui-widgets.tsx +44 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.75.1] - 2026-10-15
11
+
12
+ ### Added
13
+
14
+ - remove /resume; Ctrl+Delete always deletes the word BEFORE the cursor
15
+ - **tui:** drop the working directory from the prompt
16
+ - **tui:** persistent key hint under the input box
17
+
18
+ ### Fixed
19
+
20
+ - stop advertising the removed /resume and make the i18n guard real
21
+ - **tui:** Ctrl+Delete works in VSCode (it sends ESC d)
22
+ - **tui:** Ctrl+Delete deletes the word BEFORE the cursor
23
+
24
+ ## [2.75.0] - 2026-10-14
25
+
26
+ ### Added
27
+
28
+ - **tui:** show a one-time hint about mouse selection
29
+ - **tui:** Home/End jump to the top/bottom of the dialog
30
+ - **tui:** mouse wheel scrolls the dialog by default
31
+
32
+ ### Fixed
33
+
34
+ - **tui:** the status row keeps its height (inline caret on the input row)
35
+ - **tui:** the status bar never wraps to a second line
36
+ - **tui:** the caret lands correctly in a CJK buffer
37
+ - **tui:** responsive dividers and wrapping-aware menu window
38
+ - **tui:** log scroll reads the LIVE offset (no stale closure)
39
+ - **tui:** Ctrl+Delete works (kitty CSI-u) and deletes left at end of line
40
+ - **tui:** the mouse wheel no longer scrolls the input history (for good)
41
+ - **attachments:** keep the items array identity across reset()
42
+ - **tui:** the config menu now redraws on change and fits the terminal
43
+ - **tui:** reserve the real number of rows for a wrapping suggest popup
44
+ - **tui:** exact badge width (T0.7) and honor NO_COLOR (T0.11)
45
+ - **tui:** deliver the FULL pasted text to the agent, not the [Pasted lines#N] marker
46
+ - **tui:** separate SELECT/SCROLL mouse modes and stop the header wrapping the panel
47
+
10
48
  ## [2.74.0] - 2026-10-13
11
49
 
12
50
  ### Added
package/README.md CHANGED
@@ -255,6 +255,18 @@ The prompt is a small line editor with persistent history:
255
255
  Claude Code's bash mode). The same sandbox guard as the `Bash` tool applies,
256
256
  so a direct command cannot leave the project either.
257
257
 
258
+ Full-screen mode adds dialog navigation, so a long answer can be read and
259
+ selected without leaving the terminal:
260
+
261
+ - mouse wheel — scroll the dialog (never the input history; history is arrow
262
+ keys only). Selecting text needs `Shift`+drag, the standard TUI convention.
263
+ - `PgUp` / `PgDn` — scroll the dialog half a screen.
264
+ - `Home` / `End` — jump to the very top / bottom of the dialog (only while the
265
+ input line is empty; with text typed they move the caret as usual).
266
+ - To copy a LARGE block, use `/export` (writes the whole session as Markdown)
267
+ or the transcript file (`/transcript` shows the path) — the terminal's own
268
+ drag-select cannot reach past one screen.
269
+
258
270
  Set `NO_COLOR=1` to disable colors (a calm default palette is used otherwise).
259
271
 
260
272
  ### Images and files
@@ -823,6 +835,18 @@ zames
823
835
  Claude Code). Действует та же песочница, что и для инструмента `Bash`, так
824
836
  что прямая команда тоже не может выйти за пределы проекта.
825
837
 
838
+ В полноэкранном режиме добавляется навигация по диалогу, чтобы длинный ответ
839
+ можно было прочитать и выделить не покидая терминал:
840
+
841
+ - колесо мыши — скролл диалога (историю ввода колесо НЕ листает; история —
842
+ только стрелки). Выделение текста — `Shift`+протяжка, стандарт TUI.
843
+ - `PgUp` / `PgDn` — листать диалог на пол-экрана.
844
+ - `Home` / `End` — прыжок в самый верх / низ диалога (только когда строка ввода
845
+ пуста; при набранном тексте они двигают курсор, как обычно).
846
+ - Чтобы скопировать БОЛЬШОЙ кусок, используйте `/export` (пишет всю сессию в
847
+ Markdown) или файл транскрипта (`/transcript` покажет путь) — собственным
848
+ выделением терминала за пределы одного экрана не выделить.
849
+
826
850
  Установите `NO_COLOR=1`, чтобы отключить цвета (иначе используется спокойная
827
851
  палитра по умолчанию).
828
852
 
@@ -141,7 +141,12 @@ export class AttachmentStore {
141
141
  imageCount = 0;
142
142
  fileCount = 0;
143
143
  reset() {
144
- this.items = [];
144
+ // Empty IN PLACE: the Ink tree captures `this.items` by reference at mount
145
+ // (InputLine's `attachments` prop). Reassigning to a new array left the
146
+ // tree holding the OLD one, so after the first submit every new attachment
147
+ // was appended to an array the UI no longer read — `onSubmit` got `[]` and
148
+ // the pasted image/file was silently dropped.
149
+ this.items.length = 0;
145
150
  this.imageCount = 0;
146
151
  this.fileCount = 0;
147
152
  }
package/dist/commands.js CHANGED
@@ -981,7 +981,6 @@ export const SLASH_COMMANDS = [
981
981
  { name: '/clear', key: 'help.cmd.new' },
982
982
  { name: '/sessions', key: 'help.cmd.sessions' },
983
983
  { name: '/chats', key: 'help.cmd.chats' },
984
- { name: '/resume', key: 'help.cmd.resume' },
985
984
  { name: '/resume-id', key: 'help.cmd.resume_id' },
986
985
  { name: '/last', key: 'help.cmd.last' },
987
986
  { name: '/retry', key: 'help.cmd.retry' },
package/dist/config.js CHANGED
@@ -101,13 +101,13 @@ export const DEFAULTS = {
101
101
  // layout, which keeps the scrollback but is subject to reflow artifacts.
102
102
  alternateScreen: true,
103
103
  // In-app MOUSE-WHEEL scrolling of the log. ON by default: the operator
104
- // wants the wheel to scroll the DIALOG. That needs terminal mouse
105
- // REPORTING (1000/1006), which in turn means drag-select needs Shift (a
106
- // terminal convention: Shift+drag bypasses the app). With this OFF the
107
- // app does not enable reporting, and in the alternate screen most
108
- // terminals then turn the wheel into Up/Down ARROW keys (DECSET 1007),
109
- // which the editor maps to HISTORY — the exact bug reported. So ON is the
110
- // only setting where the wheel behaves.
104
+ // asked that the wheel scroll the DIALOG (and it must NEVER scroll the
105
+ // input history — history is arrow keys only). That needs terminal mouse
106
+ // REPORTING (1000/1006) plus alternate-scroll OFF (1007l), so the wheel
107
+ // arrives as SGR events we consume and never as Up/Down arrows. The
108
+ // trade-off is that the terminal no longer does its own drag-select, so
109
+ // selecting text needs SHIFT+DRAG (the standard TUI convention). Set to
110
+ // false to give the terminal the mouse back (native select, wheel inert).
111
111
  mouseScroll: true,
112
112
  },
113
113
  // Off by default: a full tsc/eslint run after every edit is slow; opt in.
@@ -254,14 +254,6 @@ export const commonMessages = {
254
254
  ru: 'Нет блока кода #{n}.',
255
255
  en: 'No code block #{n}.',
256
256
  },
257
- 'copy.code_hint': {
258
- ru: 'блок кода {n}/{m} · Ctrl+Y — копировать',
259
- en: 'code block {n}/{m} · Ctrl+Y to copy',
260
- },
261
- 'copy_code.usage': {
262
- ru: 'Использование: /copy-code [номер] — копировать блок кода последнего ответа (по умолчанию последний). /copy-code list — список.',
263
- en: 'Usage: /copy-code [n] — copy a code block from the last answer (default: the last). /copy-code list — list them.',
264
- },
265
257
  'copy_code.list': {
266
258
  ru: 'Блоки кода последнего ответа:',
267
259
  en: 'Code blocks in the last answer:',
@@ -6,7 +6,6 @@ export const configMessages = {
6
6
  'cfg.group.checkpoint': { ru: 'Чекпойнты', en: 'Checkpoints' },
7
7
  'cfg.group.transcript': { ru: 'Транскрипт', en: 'Transcript' },
8
8
  'cfg.group.browser': { ru: 'Браузер / DeepSeek', en: 'Browser / DeepSeek' },
9
- 'cfg.group.qwen': { ru: 'Qwen', en: 'Qwen' },
10
9
  'cfg.f.provider': { ru: 'Провайдер чата', en: 'Chat provider' },
11
10
  'cfg.f.qwen_model': {
12
11
  ru: 'Модель Qwen (id)',
package/dist/i18n/help.js CHANGED
@@ -97,6 +97,14 @@ export const helpMessages = {
97
97
  ru: 'Esc, Ctrl+C прервать текущую генерацию',
98
98
  en: 'Esc, Ctrl+C abort current generation',
99
99
  },
100
+ 'help.key.scroll': {
101
+ ru: 'PgUp / PgDn листать диалог (Home/End — в начало/конец) [full-screen]',
102
+ en: 'PgUp / PgDn scroll the dialog (Home/End — top/bottom) [full-screen]',
103
+ },
104
+ 'help.key.wheel': {
105
+ ru: 'колесо мыши скролл диалога; выделение — Shift+протяжка',
106
+ en: 'mouse wheel scroll the dialog; select text with Shift+drag',
107
+ },
100
108
  'help.key.bang': {
101
109
  ru: '!команда выполнить shell-команду напрямую (мимо модели)',
102
110
  en: '!command run a shell command directly (bypass the model)',
@@ -125,10 +133,6 @@ export const helpMessages = {
125
133
  ru: '/chats [фильтр] список последних чатов DeepSeek',
126
134
  en: '/chats [filter] recent DeepSeek chats',
127
135
  },
128
- 'help.cmd.resume': {
129
- ru: '/resume <n> открыть чат №n из /chats',
130
- en: '/resume <n> open chat #n from /chats',
131
- },
132
136
  'help.cmd.last': {
133
137
  ru: '/last открыть последний чат этого каталога',
134
138
  en: '/last open the last chat of this directory',
package/dist/i18n/qwen.js CHANGED
@@ -14,10 +14,6 @@ export const qwenMessages = {
14
14
  ru: 'Код из письма Qwen: ',
15
15
  en: 'Qwen email code: ',
16
16
  },
17
- 'qwen.login_pass_prompt': {
18
- ru: 'Пароль Qwen: ',
19
- en: 'Qwen password: ',
20
- },
21
17
  'qwen.login_ok': {
22
18
  ru: 'Qwen: вход выполнен.',
23
19
  en: 'Qwen: signed in.',
@@ -113,8 +113,8 @@ export const selfReviewMessages = {
113
113
  en: 'Pick a chat (↑/↓, Enter to open, Esc to cancel)',
114
114
  },
115
115
  'chats.use_resume': {
116
- ru: '\nИспользуй /resume <n> для продолжения.\n',
117
- en: '\nUse /resume <n> to continue.\n',
116
+ ru: '\nДля продолжения используй /resume-id <id> (полный id в скобках).\n',
117
+ en: '\nTo continue use /resume-id <id> (the full id in brackets).\n',
118
118
  },
119
119
  'chats.none_hint': {
120
120
  ru: 'Чатов не найдено. Возможно, сайдбар свёрнут или селекторы устарели.',
@@ -128,22 +128,6 @@ export const selfReviewMessages = {
128
128
  ru: ' Системный промпт НЕ пересылается (он уже в начале чата). Включить: /config set browser.resendPromptOnResume true.\n',
129
129
  en: ' The system prompt is NOT resent (it is already at the start of the chat). Enable: /config set browser.resendPromptOnResume true.\n',
130
130
  },
131
- 'chats.resume_usage': {
132
- ru: 'Использование: /resume <n> (или /chats для списка)',
133
- en: 'Usage: /resume <n> (or /chats for the list)',
134
- },
135
- 'chats.need_number': {
136
- ru: 'Нужен номер из /chats.',
137
- en: 'A number from /chats is required.',
138
- },
139
- 'chats.run_chats_first': {
140
- ru: 'Сначала выполни /chats.',
141
- en: 'Run /chats first.',
142
- },
143
- 'chats.no_n': {
144
- ru: 'Нет чата №{n}. Всего: {total}.',
145
- en: 'No chat #{n}. Total: {total}.',
146
- },
147
131
  'chats.opening': { ru: 'Открываю: {v}', en: 'Opening: {v}' },
148
132
  'chats.prompt_will_resend': {
149
133
  ru: ' Системный промпт будет переслан на следующей задаче.\n',
@@ -30,6 +30,11 @@ export const spinnerMessages = {
30
30
  ru: 'Напишите задачу и нажмите Enter. /help — команды, Ctrl+J — новая строка.',
31
31
  en: 'Type a task and press Enter. /help for commands, Ctrl+J for a new line.',
32
32
  },
33
+ // One-line key hint UNDER the input box (full-screen mode).
34
+ 'editor.keys_hint': {
35
+ ru: 'выделение — Shift+протяжка · колесо — скролл · PgUp/PgDn листают · Home/End — в начало/конец',
36
+ en: 'select — Shift+drag · wheel — scroll · PgUp/PgDn scroll · Home/End top/bottom',
37
+ },
33
38
  // Reverse search (Ctrl+R) status line. The query may be empty.
34
39
  'editor.search_prompt': {
35
40
  ru: '(обратный поиск) `{q}` — Ctrl+R дальше, Enter принять, Esc отмена',
package/dist/index.js CHANGED
@@ -155,7 +155,7 @@ if (jsonMode) {
155
155
  // ---------- session persistence ----------
156
156
  // We store sessions (DeepSeek chats) in ~/.zames/.sessions so they survive
157
157
  // a restart and are available for explicit restore (--resume-last,
158
- // --chat <id>, /resume <n>). They are NOT brought up automatically on launch.
158
+ // --chat <id>, /resume-id <id>). They are NOT brought up automatically on launch.
159
159
  // Previously there was a single last-chat.json file that got lost when the
160
160
  // project changed and gave no list of sessions to restore.
161
161
  function saveLastChat(id, workdir = '', title = '') {
@@ -163,7 +163,7 @@ function saveLastChat(id, workdir = '', title = '') {
163
163
  return;
164
164
  // Tag the session with the ACTIVE provider: a chat id is only meaningful to
165
165
  // the backend that created it, so a later resume must open it in the same
166
- // one (or refuse). Without this a /provider switch made /resume try to open a
166
+ // one (or refuse). Without this a /provider switch made a resume try to open a
167
167
  // qwen id in DeepSeek (and vice versa).
168
168
  saveSession({ id, workdir, title, provider: config.provider });
169
169
  }
@@ -437,6 +437,8 @@ ${theme.bold(t('help.while_working'))}
437
437
  ${t('help.key.attach')}
438
438
  ${t('help.key.esc')}
439
439
  ${t('help.key.bang')}
440
+ ${t('help.key.scroll')}
441
+ ${t('help.key.wheel')}
440
442
 
441
443
  ${theme.bold(t('help.commands'))}
442
444
 
@@ -444,7 +446,6 @@ ${theme.bold(t('help.sec.session'))}
444
446
  ${t('help.cmd.new')}
445
447
  ${t('help.cmd.sessions')}
446
448
  ${t('help.cmd.chats')}
447
- ${t('help.cmd.resume')}
448
449
  ${t('help.cmd.resume_id')}
449
450
  ${t('help.cmd.last')}
450
451
  ${t('help.cmd.retry')}
@@ -1005,7 +1006,10 @@ async function main() {
1005
1006
  // `--resend-prompt` or `browser.resendPromptOnResume` forces it (e.g. when
1006
1007
  // the prompt/tools changed).
1007
1008
  const promptOnResume = () => resendPrompt || config.browser.resendPromptOnResume === true;
1008
- let lastChats = [];
1009
+ // Assigned by the /chats handler before it is read; no initial value needed
1010
+ // (the empty-array default was flagged as a useless assignment once the
1011
+ // /resume <n> handler that read it was removed).
1012
+ let lastChats;
1009
1013
  let currentChatId = null;
1010
1014
  // The last task the operator sent (used by /retry). Plain text only.
1011
1015
  let lastTaskText = '';
@@ -1079,7 +1083,7 @@ async function main() {
1079
1083
  total: String(items.length),
1080
1084
  });
1081
1085
  };
1082
- // Re-inject a persisted checklist after /resume or the startup restore.
1086
+ // Re-inject a persisted checklist after /resume-id or the startup restore.
1083
1087
  // The list is REPLACED (not merged): a resumed chat with no saved checklist
1084
1088
  // must clear the previous chat's tasks, otherwise the status line keeps
1085
1089
  // showing them for a chat that never had them.
@@ -1232,17 +1236,18 @@ async function main() {
1232
1236
  });
1233
1237
  };
1234
1238
  const buildPrompt = () => {
1235
- let tail;
1236
1239
  if (reviewMode) {
1237
- tail =
1240
+ // Review mode is rare and MEANINGFUL, so it stays in the prompt.
1241
+ return (theme.prompt('❯ ') +
1242
+ theme.dim('(') +
1238
1243
  theme.warn(t('prompt.review')) +
1239
- theme.dim(':') +
1240
- theme.dir(reviewMode.snapName);
1244
+ theme.dim(':') +
1245
+ theme.dir(reviewMode.snapName) +
1246
+ theme.dim(') '));
1241
1247
  }
1242
- else {
1243
- tail = theme.dir(dirLabel(currentWorkdir));
1244
- }
1245
- return theme.prompt('❯ ') + tail + theme.dim(' › ');
1248
+ // No working-directory tail: it ate input width and is already in the
1249
+ // header / /pwd. A bare prompt keeps room for the task text.
1250
+ return theme.prompt('❯ ') + theme.dim('› ');
1246
1251
  };
1247
1252
  await refreshDynamicCommands(currentWorkdir);
1248
1253
  if (process.stdin.isTTY && process.stdout.isTTY) {
@@ -1596,6 +1601,8 @@ async function main() {
1596
1601
  };
1597
1602
  try {
1598
1603
  ed.start();
1604
+ // The key/selection hint is now a PERSISTENT row under the input box
1605
+ // (FullScreenApp), so the old one-time printAbove is redundant.
1599
1606
  }
1600
1607
  catch (e) {
1601
1608
  // A TUI mount failure must not kill the agent. We surface it and let the
@@ -1891,7 +1898,7 @@ async function main() {
1891
1898
  });
1892
1899
  }
1893
1900
  // Open a chat by its ChatInfo and print the restored dialogue. Shared by
1894
- // /resume and the interactive /chats list, so both paths behave identically
1901
+ // /resume-id and the interactive /chats list, so both paths behave identically
1895
1902
  // (todos reloaded, prompt-resend decision, transcript event).
1896
1903
  const openResumedChat = async (pick) => {
1897
1904
  if (editor)
@@ -2007,16 +2014,19 @@ async function main() {
2007
2014
  }
2008
2015
  else {
2009
2016
  let tail;
2017
+ // The non-TTY fallback prompt mirrors buildPrompt(): bare, no dir.
2010
2018
  if (reviewMode) {
2011
2019
  tail =
2012
- theme.warn(t('prompt.review')) +
2020
+ theme.dim('(') +
2021
+ theme.warn(t('prompt.review')) +
2013
2022
  theme.dim(':') +
2014
- theme.dir(reviewMode.snapName);
2023
+ theme.dir(reviewMode.snapName) +
2024
+ theme.dim(') ');
2015
2025
  }
2016
2026
  else {
2017
- tail = theme.dir(dirLabel(currentWorkdir));
2027
+ tail = theme.dim('› ');
2018
2028
  }
2019
- input = await promptOnce(theme.prompt('❯ ') + tail + theme.dim(' › '));
2029
+ input = await promptOnce(theme.prompt('❯ ') + tail);
2020
2030
  }
2021
2031
  }
2022
2032
  catch {
@@ -2318,8 +2328,8 @@ async function main() {
2318
2328
  continue;
2319
2329
  }
2320
2330
  console.log(theme.system(t('chats.recent')));
2321
- // The number is the index in the FULL list, so `/resume <n>` keeps
2322
- // pointing at the same chat even when a filter narrows the view.
2331
+ // The number is the index in the FULL list (a filter only narrows
2332
+ // the VIEW), and the full id in brackets is what /resume-id takes.
2323
2333
  for (const c of shown) {
2324
2334
  const idx = lastChats.indexOf(c);
2325
2335
  const n = String(idx + 1).padStart(2, ' ');
@@ -2338,29 +2348,6 @@ async function main() {
2338
2348
  }
2339
2349
  continue;
2340
2350
  }
2341
- if (lower === '/resume' || lower.startsWith('/resume ')) {
2342
- const arg = trimmed.slice(7).trim();
2343
- if (!arg) {
2344
- console.error(theme.error(t('chats.resume_usage')));
2345
- continue;
2346
- }
2347
- const n = Number(arg);
2348
- if (!Number.isFinite(n) || n < 1) {
2349
- console.error(theme.error(t('chats.need_number')));
2350
- continue;
2351
- }
2352
- if (!lastChats.length) {
2353
- console.error(theme.error(t('chats.run_chats_first')));
2354
- continue;
2355
- }
2356
- const pick = lastChats[n - 1];
2357
- if (!pick) {
2358
- console.error(theme.error(t('chats.no_n', { n, total: lastChats.length })));
2359
- continue;
2360
- }
2361
- await openResumedChat(pick);
2362
- continue;
2363
- }
2364
2351
  if (lower === '/provider' || lower.startsWith('/provider ')) {
2365
2352
  const arg = trimmed.slice('/provider'.length).trim().toLowerCase();
2366
2353
  if (!arg || arg === 'status') {
@@ -3,7 +3,7 @@
3
3
  // editor state: given the buffer text and the command list these decide which
4
4
  // commands to show, the visible window, and the Tab completion. Split out
5
5
  // (C3) so the fuzzy match can be unit-tested without driving the editor.
6
- import { SUGGEST_PAGE, isSubsequence } from './layout.js';
6
+ import { SUGGEST_PAGE, isSubsequence, visLen, } from './layout.js';
7
7
  /**
8
8
  * The commands matching the current buffer. Shown only when the line starts
9
9
  * with «/» and is a single short word. The match is fuzzy: an exact prefix
@@ -62,13 +62,28 @@ export function suggestWindow(sugg, offset) {
62
62
  * (0 when no list). Full-screen mode uses this to RESERVE space above the
63
63
  * input panel so opening the list does not shift the layout (the operator saw
64
64
  * the whole screen jump when `/chats` or `/resume` hints appeared).
65
+ *
66
+ * With `cols > 0` a row whose label+description is WIDER than the terminal is
67
+ * counted as the multiple rows it actually wraps to; counting one row per
68
+ * command under-reserved the panel and the caret drifted on a narrow terminal
69
+ * (the command list carries descriptions up to ~60 chars).
65
70
  */
66
- export function suggestRowCount(buf, commands) {
71
+ export function suggestRowCount(buf, commands, cols = 0) {
67
72
  const sugg = matchSlashCommands(buf, commands);
68
73
  if (!sugg.length)
69
74
  return 0;
70
- const { moreCount } = suggestWindow(sugg, 0);
71
- return Math.min(sugg.length, SUGGEST_PAGE) + (moreCount > 0 ? 1 : 0);
75
+ const { shown, moreCount } = suggestWindow(sugg, 0);
76
+ const width = Math.max(1, cols);
77
+ let rows = 0;
78
+ for (const c of shown) {
79
+ const label = c.hint ? c.name + ' ' + c.hint : c.name;
80
+ // 3 = ' ▸ ' prefix, 2 = the gap before the description.
81
+ const textWidth = 3 + visLen(label) + 2 + visLen(c.description);
82
+ rows += cols > 0 ? Math.max(1, Math.ceil(textWidth / width)) : 1;
83
+ }
84
+ if (moreCount > 0)
85
+ rows += 1;
86
+ return rows;
72
87
  }
73
88
  /**
74
89
  * Tab completion. With an explicit selection, insert that command. With a
package/dist/theme.js CHANGED
@@ -1,5 +1,14 @@
1
1
  import chalk from 'chalk';
2
2
  import { contentWidth } from './width.js';
3
+ // Respect NO_COLOR (https://no-color.org): when the operator sets it (any
4
+ // non-empty value) drop ALL SGR codes so the output can be piped into a file
5
+ // or a log viewer without escape noise. chalk 5 only auto-detects TTY/CI, it
6
+ // does NOT read NO_COLOR, so the level is set explicitly here — the one place
7
+ // the palette is built, so every consumer (theme, spinner, Ink Text colors
8
+ // that go through chalk) is covered. chalk.level is the documented override.
9
+ if (process.env.NO_COLOR) {
10
+ chalk.level = 0;
11
+ }
3
12
  // A calm palette for a dark terminal.
4
13
  // Muted, slightly "faded" tones without high saturation
5
14
  // (in the spirit of Tokyo Night / Nord). The goal is comfortable reading of
@@ -47,6 +47,12 @@ export function ConfigMenuApp(opts) {
47
47
  const [editBuf, setEditBuf] = useState('');
48
48
  const [pendingReset, setPendingReset] = useState(null);
49
49
  const [message, setMessage] = useState(null);
50
+ // `get`/`set` mutate the LIVE config object in place, so React has no state
51
+ // change to react to and the row kept the OLD value on screen after a
52
+ // toggle/cycle (the operator saw «ru» while the config already held «en»).
53
+ // Bump this counter after every mutation to force a re-render.
54
+ const [version, setVersion] = useState(0);
55
+ const bump = () => setVersion((v) => v + 1);
50
56
  const visible = useMemo(() => {
51
57
  const q = filterQuery.trim().toLowerCase();
52
58
  if (!q)
@@ -78,6 +84,7 @@ export function ConfigMenuApp(opts) {
78
84
  }
79
85
  try {
80
86
  set(f, raw);
87
+ bump();
81
88
  setMessage(t('cfg.menu.saved', {
82
89
  v: f.path,
83
90
  value: displayValue(f, get(f.path), t),
@@ -93,6 +100,7 @@ export function ConfigMenuApp(opts) {
93
100
  return;
94
101
  if (f.type === 'boolean') {
95
102
  set(f, get(f.path) === true ? 'false' : 'true');
103
+ bump();
96
104
  setMessage(null);
97
105
  return;
98
106
  }
@@ -112,6 +120,7 @@ export function ConfigMenuApp(opts) {
112
120
  const base = idx < 0 ? 0 : idx;
113
121
  const next = f.values[(base + dir + f.values.length) % f.values.length];
114
122
  set(f, next);
123
+ bump();
115
124
  setMessage(null);
116
125
  };
117
126
  /** ←/→ acts on the focused row: cycle an enum, toggle a boolean. */
@@ -123,6 +132,7 @@ export function ConfigMenuApp(opts) {
123
132
  cycleEnum(f, dir);
124
133
  else if (f.type === 'boolean') {
125
134
  set(f, get(f.path) === true ? 'false' : 'true');
135
+ bump();
126
136
  setMessage(null);
127
137
  }
128
138
  };
@@ -229,6 +239,7 @@ export function ConfigMenuApp(opts) {
229
239
  return;
230
240
  if (pendingReset === f.path) {
231
241
  reset(f);
242
+ bump();
232
243
  setMessage(t('cfg.reset', { v: f.path }));
233
244
  setPendingReset(null);
234
245
  }
@@ -239,29 +250,51 @@ export function ConfigMenuApp(opts) {
239
250
  return;
240
251
  }
241
252
  });
242
- // Windowed list: show a slice around the cursor, accounting for group headers.
253
+ // Windowed list: show a slice around the cursor. The window is measured in
254
+ // RENDERED ROWS, not items: each new group adds a header row, AND a label
255
+ // long enough to wrap takes more than one row. Counting one row per item
256
+ // let the block grow taller than the terminal and the top border/title
257
+ // scrolled off ("cut off at the top") on a narrow terminal.
243
258
  const termRows = rows || 24;
244
- const maxItemRows = Math.max(3, termRows - 6);
259
+ const maxRows = Math.max(3, termRows - 8);
260
+ const innerCols = Math.max(1, (columns || 80) - 4);
245
261
  const shown = useMemo(() => {
246
- // Walk back from the cursor until the window would overflow.
247
- let start = Math.min(cursor, Math.max(0, visible.length - 1));
248
262
  const groupStart = (i) => i === 0 || visible[i].groupKey !== visible[i - 1].groupKey;
249
- const rowsFor = (from) => {
263
+ // Rendered height of one item: 1 base row + wrapped extra rows. The label
264
+ // plus the value cell plus the path hint is approximated by the raw label
265
+ // length; over-estimating is safe (it only shortens the window).
266
+ const itemRows = (i) => {
267
+ const f = visible[i];
268
+ const label = f.labelKey ? t(f.labelKey) : f.path;
269
+ return Math.max(1, Math.ceil((label.length + 12) / innerCols));
270
+ };
271
+ const rowsIn = (a, b) => {
250
272
  let r = 0;
251
- for (let i = from; i <= cursor; i++) {
252
- if (i === from || groupStart(i))
273
+ for (let i = a; i < b; i++) {
274
+ if (groupStart(i))
253
275
  r++;
254
- r++;
276
+ r += itemRows(i);
255
277
  }
256
278
  return r;
257
279
  };
258
- while (start > 0 && rowsFor(start - 1) <= maxItemRows)
280
+ if (!visible.length)
281
+ return { start: 0, end: 0 };
282
+ let start = cursor;
283
+ let end = cursor + 1;
284
+ // Fill downward first, then upward, so the cursor stays visible and the
285
+ // window uses the whole budget.
286
+ while (end < visible.length && rowsIn(start, end + 1) <= maxRows)
287
+ end++;
288
+ while (start > 0 && rowsIn(start - 1, end) <= maxRows)
259
289
  start--;
260
- return { start, end: Math.min(visible.length, start + maxItemRows) };
261
- }, [cursor, visible, maxItemRows]);
290
+ return { start, end };
291
+ }, [cursor, visible, maxRows, innerCols, t]);
262
292
  const usable = Math.max(1, (columns || 80) - 1);
263
293
  void visCols;
264
294
  void usable;
295
+ // Referenced so the value-cell recompute below depends on the mutation
296
+ // counter (a pure `get()` call has no reactive dependency of its own).
297
+ void version;
265
298
  const items = [];
266
299
  let lastGroup = '';
267
300
  for (let i = shown.start; i < shown.end; i++) {