zames_pro 2.52.0 → 2.53.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.
package/dist/color.js ADDED
@@ -0,0 +1,8 @@
1
+ // CLI color opt-out. This module MUST be imported before anything that pulls
2
+ // in chalk (see the import order in src/index.ts): chalk reads NO_COLOR at
3
+ // module-load time, so the env var has to be set first. A `--no-color` flag
4
+ // is the explicit equivalent of NO_COLOR=1 (which chalk already honors).
5
+ if (process.argv.includes('--no-color')) {
6
+ process.env.NO_COLOR = '1';
7
+ }
8
+ export {};
@@ -103,10 +103,13 @@ export function runConfigMenu(opts) {
103
103
  let lastRows = 0;
104
104
  const render = () => {
105
105
  const lines = [];
106
- lines.push(theme.bold(t('cfg.menu.title')));
107
106
  const vis = visible();
108
107
  if (cursor >= vis.length)
109
108
  cursor = Math.max(0, vis.length - 1);
109
+ // Title with a position counter ("Settings — 12/49") so the operator knows
110
+ // how far down a long list they are; a filter narrows both numbers.
111
+ const pos = vis.length ? ' ' + (cursor + 1) + '/' + vis.length : '';
112
+ lines.push(theme.bold(t('cfg.menu.title')) + theme.dim(pos));
110
113
  let lastGroup = '';
111
114
  for (let i = 0; i < vis.length; i++) {
112
115
  const f = vis[i];
@@ -356,12 +359,31 @@ export function runConfigMenu(opts) {
356
359
  if (n > 0)
357
360
  cursor = (cursor - 1 + n) % n;
358
361
  message = null;
362
+ pendingReset = null;
359
363
  }
360
364
  else if (ch === 'j') {
361
365
  const n = visible().length;
362
366
  if (n > 0)
363
367
  cursor = (cursor + 1) % n;
364
368
  message = null;
369
+ pendingReset = null;
370
+ }
371
+ else if (ch === 'g') {
372
+ // Jump to the first field of the NEXT group (and back to the top after
373
+ // the last), so a long grouped list can be navigated quickly.
374
+ const vis = visible();
375
+ if (vis.length) {
376
+ let target = -1;
377
+ const curGroup = vis[cursor] ? vis[cursor].groupKey : '';
378
+ for (let k = cursor + 1; k < vis.length; k++) {
379
+ if (vis[k].groupKey !== curGroup) {
380
+ target = k;
381
+ break;
382
+ }
383
+ }
384
+ cursor = target >= 0 ? target : 0;
385
+ }
386
+ message = null;
365
387
  }
366
388
  else if (ch === '\r' || ch === '\n' || ch === ' ') {
367
389
  toggle();
package/dist/config.js CHANGED
@@ -69,6 +69,8 @@ export const DEFAULTS = {
69
69
  ui: {
70
70
  locale: DEFAULT_LOCALE,
71
71
  contextLimit: 1_000_000,
72
+ // 0 = auto: terminal width, capped at 100 columns (the historical width).
73
+ answerWidth: 0,
72
74
  },
73
75
  };
74
76
  export function loadConfig() {
@@ -116,6 +118,14 @@ export const CONFIG_SCHEMA = [
116
118
  labelKey: 'cfg.f.maxIterations',
117
119
  groupKey: 'cfg.group.agent',
118
120
  },
121
+ {
122
+ path: 'ui.answerWidth',
123
+ type: 'number',
124
+ min: 0,
125
+ max: 1000,
126
+ labelKey: 'cfg.f.ui_answerWidth',
127
+ groupKey: 'cfg.group.ui',
128
+ },
119
129
  {
120
130
  path: 'headless',
121
131
  type: 'boolean',
package/dist/i18n.js CHANGED
@@ -86,6 +86,10 @@ const CATALOG = {
86
86
  en: 'dev mode: auto-reload modules',
87
87
  },
88
88
  'help.opt.version': { ru: 'показать версию', en: 'show version' },
89
+ 'help.opt.no_color': {
90
+ ru: 'отключить цвет (то же, что NO_COLOR=1)',
91
+ en: 'disable color (same as NO_COLOR=1)',
92
+ },
89
93
  'help.opt.help': { ru: 'эта справка', en: 'this help' },
90
94
  'help.while_working': {
91
95
  ru: 'Пока агент работает:',
@@ -112,8 +116,8 @@ const CATALOG = {
112
116
  en: 'Ctrl+← / Ctrl+→ move by words',
113
117
  },
114
118
  'help.key.slash': {
115
- ru: '/ + Tab подсказка и автодополнение slash-команд',
116
- en: '/ + Tab slash-command hints and completion',
119
+ ru: '/ + Tab подсказка и автодополнение slash-команд (Ctrl+N/P — листать)',
120
+ en: '/ + Tab slash-command hints and completion (Ctrl+N/P to page)',
117
121
  },
118
122
  'help.key.newline': {
119
123
  ru: 'Ctrl+J / Ctrl+Enter новая строка (Shift+Enter в терминалах с поддержкой)',
@@ -137,16 +141,16 @@ const CATALOG = {
137
141
  en: '/new, /clear new chat (reset context)',
138
142
  },
139
143
  'help.cmd.sessions': {
140
- ru: '/sessions список сохранённых сессий',
141
- en: '/sessions list saved sessions',
144
+ ru: '/sessions [фильтр] список сохранённых сессий (можно фильтровать)',
145
+ en: '/sessions [filter] list saved sessions (optionally filtered)',
142
146
  },
143
147
  'help.cmd.resume_id': {
144
148
  ru: '/resume-id <id> восстановить сессию по полному id',
145
149
  en: '/resume-id <id> resume a session by full id',
146
150
  },
147
151
  'help.cmd.chats': {
148
- ru: '/chats список последних чатов DeepSeek',
149
- en: '/chats recent DeepSeek chats',
152
+ ru: '/chats [фильтр] список последних чатов DeepSeek',
153
+ en: '/chats [filter] recent DeepSeek chats',
150
154
  },
151
155
  'help.cmd.resume': {
152
156
  ru: '/resume <n> открыть чат №n из /chats',
@@ -490,7 +494,12 @@ const CATALOG = {
490
494
  // animated thinking phrase, it says explicitly that a generation is in
491
495
  // flight. 'paused'/'settled' have no label (see LineEditor._stateLabel).
492
496
  'state.generating': { ru: '▶ генерация', en: '▶ generating' },
497
+ 'state.settled': { ru: '✓ готово', en: '✓ done' },
493
498
  'editor.more': { ru: '…ещё {n}', en: '…{n} more' },
499
+ 'editor.page_hint': {
500
+ ru: '(Ctrl+N/Ctrl+P — листать, Tab — вставить)',
501
+ en: '(Ctrl+N/Ctrl+P to page, Tab to insert)',
502
+ },
494
503
  'editor.answer': { ru: '● Ответ', en: '● Answer' },
495
504
  // Reverse search (Ctrl+R) status line. The query may be empty.
496
505
  'editor.search_prompt': {
@@ -581,6 +590,10 @@ const CATALOG = {
581
590
  ru: 'Интерактивный режим. Введите задачу. Команды — /help. Выход — /exit.',
582
591
  en: 'Interactive mode. Enter a task. Commands — /help. Exit — /exit.',
583
592
  },
593
+ 'msg.first_login_hint': {
594
+ ru: 'Сохранённой сессии DeepSeek нет — при первом запросе потребуется вход (логин/пароль спросят в терминале или открой --headed).',
595
+ en: 'No saved DeepSeek session — the first task will need a sign-in (login/password are asked in the terminal, or use --headed).',
596
+ },
584
597
  'msg.first_hint': {
585
598
  ru: 'Подсказка: «/» — список команд, Ctrl+R — поиск по истории, картинку можно вставить через Ctrl+Shift+V.',
586
599
  en: 'Hint: "/" lists commands, Ctrl+R searches history, paste an image with Ctrl+Shift+V.',
@@ -1212,6 +1225,10 @@ const CATALOG = {
1212
1225
  ru: 'Язык интерфейса и ответов агента',
1213
1226
  en: 'Interface and agent reply language',
1214
1227
  },
1228
+ 'cfg.f.ui_answerWidth': {
1229
+ ru: 'Максимальная ширина ответа в колонках (0 — авто, до 100)',
1230
+ en: 'Max answer width in columns (0 = auto, capped at 100)',
1231
+ },
1215
1232
  'cfg.f.maxIterations': {
1216
1233
  ru: 'Лимит итераций на задачу',
1217
1234
  en: 'Max agent loop iterations per task',
@@ -1400,8 +1417,8 @@ const CATALOG = {
1400
1417
  en: '⚙ Settings — choose a parameter',
1401
1418
  },
1402
1419
  'cfg.menu.hint': {
1403
- ru: '↑/↓ — выбор, Enter — изменить, q/Esc — выйти',
1404
- en: '↑/↓ select, Enter edit, q/Esc quit',
1420
+ ru: '↑/↓ — выбор, Enter — изменить, g — след. группа, / — поиск, q/Esc — выйти',
1421
+ en: '↑/↓ select, Enter edit, g next group, / search, q/Esc quit',
1405
1422
  },
1406
1423
  'cfg.menu.edit_hint': {
1407
1424
  ru: 'Введи значение и Enter (пусто — отмена)',
@@ -1450,6 +1467,10 @@ const CATALOG = {
1450
1467
  ru: 'Неверное значение для {v}: ожидалось {type}',
1451
1468
  en: 'Invalid value for {v}: expected {type}',
1452
1469
  },
1470
+ 'cfg.range_hint': {
1471
+ ru: 'число от {min} до {max}',
1472
+ en: 'a number from {min} to {max}',
1473
+ },
1453
1474
  'cfg.write_error': {
1454
1475
  ru: 'Не удалось записать конфиг: {v}',
1455
1476
  en: 'Failed to write config: {v}',
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import './color.js';
2
3
  import path from 'path';
3
4
  import fs from 'fs/promises';
4
5
  import { existsSync, statSync } from 'fs';
@@ -20,7 +21,7 @@ import { selfReview, selfDiff, selfApply, selfList } from './self-review.js';
20
21
  import { formatDiff, diffGitArgs, parseTranscript, summarizeTranscript, renderCost, formatExport, defaultExportPath, renderDoctor, renderPermissions, resolveExtraDir, buildReviewPrompt, formatDuration, formatRelativeTime, trimRestoredMessages, RESTORED_HISTORY_LIMIT, mergeMessages, hasQueuedJob, isSlashCommand, parseQueueCommand, parseLiveToggle, parseGoalCommand, isLiveConfigCommand, withGoal, formatQueueList, ctrlCEscalation, } from './commands.js';
21
22
  import { performCompact } from './compact.js';
22
23
  import { Scheduler, parseInterval, formatInterval, formatJobLine, parseCron, } from './scheduler.js';
23
- import { renderMarkdown } from './markdown.js';
24
+ import { renderMarkdown, setAnswerWidth } from './markdown.js';
24
25
  import { closeWeb } from './web.js';
25
26
  import { saveSession, loadLastSession, readSession, listSessions, sessionsDir, loadHistory, saveHistory, } from './sessions.js';
26
27
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -71,6 +72,8 @@ function getPositional() {
71
72
  // compaction).
72
73
  const COMPACT_FALLBACK_LIMIT = 40;
73
74
  const config = loadConfig();
75
+ // Apply the configured answer width to the markdown renderer (0 = auto).
76
+ setAnswerWidth(config.ui?.answerWidth ?? 0);
74
77
  // Current interface/agent language. Changed by the /config lang <ru|en> command.
75
78
  let currentLocale = isLocale(config.ui?.locale)
76
79
  ? config.ui.locale
@@ -289,6 +292,7 @@ ${theme.bold(t('help.options'))}
289
292
  --debug ${t('help.opt.debug')}
290
293
  --calibrate ${t('help.opt.calibrate')}
291
294
  --dev ${t('help.opt.dev')}
295
+ --no-color ${t('help.opt.no_color')}
292
296
  --version, -v ${t('help.opt.version')}
293
297
  --help, -h ${t('help.opt.help')}
294
298
 
@@ -1342,6 +1346,12 @@ async function main() {
1342
1346
  console.log(theme.system(t('msg.interactive')));
1343
1347
  console.log(theme.system(t('msg.queue_hint')));
1344
1348
  console.log(theme.dim(t('msg.first_hint')));
1349
+ // No stored session yet (auth.json absent): DeepSeek will ask for a sign-in.
1350
+ // The auto-login handles it, but a heads-up in the banner saves the operator
1351
+ // from wondering why the first task pauses on a login form.
1352
+ if (!authMarkerExists()) {
1353
+ console.log(theme.warn(t('msg.first_login_hint')));
1354
+ }
1345
1355
  let freshChatNext = true;
1346
1356
  let sendSystemPromptNext = true;
1347
1357
  // Resuming an existing chat already has the system-prompt at its start, so
@@ -1815,6 +1825,11 @@ async function main() {
1815
1825
  editor.setPrompt(buildPrompt());
1816
1826
  }
1817
1827
  }
1828
+ // Apply a live change to the answer width immediately, so the next
1829
+ // rendered answer uses the new width without a restart.
1830
+ if (path === 'ui.answerWidth') {
1831
+ setAnswerWidth(typeof value === 'number' ? value : Number(value) || 0);
1832
+ }
1818
1833
  // Reflect browser toggles on the LIVE browser object. The browser is
1819
1834
  // created once at startup, so without this a /config set browser.*
1820
1835
  // change would only apply after a restart. We also update the cached
@@ -1913,6 +1928,21 @@ async function main() {
1913
1928
  writeConfigValue('home', field.path, raw);
1914
1929
  setConfigRuntime(field.path, value);
1915
1930
  }
1931
+ // Human-readable hint of the accepted values for a field, used in the error
1932
+ // message when /config set gets a bad value. Enum -> the allowed list,
1933
+ // number -> a range, boolean -> "true|false".
1934
+ function configValueHint(field) {
1935
+ if (field.values && field.values.length)
1936
+ return field.values.join('|');
1937
+ if (field.type === 'boolean')
1938
+ return 'true|false';
1939
+ if (field.type === 'number') {
1940
+ const lo = field.min !== undefined ? String(field.min) : '-';
1941
+ const hi = field.max !== undefined ? String(field.max) : '-';
1942
+ return t('cfg.range_hint', { min: lo, max: hi });
1943
+ }
1944
+ return field.type;
1945
+ }
1916
1946
  function configResetField(field) {
1917
1947
  resetConfigValue('home', field.path);
1918
1948
  // Reset the runtime value to the default.
@@ -2058,7 +2088,7 @@ async function main() {
2058
2088
  catch {
2059
2089
  console.error(theme.error(t('cfg.bad_value', {
2060
2090
  v: key,
2061
- type: field.values ? field.values.join('|') : field.type,
2091
+ type: configValueHint(field),
2062
2092
  })));
2063
2093
  return;
2064
2094
  }
@@ -2341,7 +2371,8 @@ async function main() {
2341
2371
  continue;
2342
2372
  }
2343
2373
  // ---------- Regular commands ----------
2344
- if (lower === '/chats') {
2374
+ if (lower === '/chats' || lower.startsWith('/chats ')) {
2375
+ const filter = trimmed.slice('/chats'.length).trim().toLowerCase();
2345
2376
  const spin = editor || mod.createSpinner(currentLocale);
2346
2377
  if (editor)
2347
2378
  editor.lock(t('msg.input_locked'));
@@ -2349,15 +2380,23 @@ async function main() {
2349
2380
  try {
2350
2381
  lastChats = await browser.listChats(30);
2351
2382
  spin.stop();
2352
- if (!lastChats.length) {
2383
+ // An optional query filters by title (substring, case-insensitive),
2384
+ // so a long chat list does not have to be scanned by eye.
2385
+ const shown = filter
2386
+ ? lastChats.filter((c) => c.title.toLowerCase().includes(filter))
2387
+ : lastChats;
2388
+ if (!shown.length) {
2353
2389
  console.log(theme.system(t('chats.none_hint')));
2354
2390
  }
2355
2391
  else {
2356
2392
  console.log(theme.system(t('chats.recent')));
2357
- lastChats.forEach((c, i) => {
2358
- const n = String(i + 1).padStart(2, ' ');
2393
+ // The number is the index in the FULL list, so `/resume <n>` keeps
2394
+ // pointing at the same chat even when a filter narrows the view.
2395
+ for (const c of shown) {
2396
+ const idx = lastChats.indexOf(c);
2397
+ const n = String(idx + 1).padStart(2, ' ');
2359
2398
  console.log(` ${theme.user(n)}. ${c.title} ${theme.system('(' + c.id.slice(0, 8) + '…)')}`);
2360
- });
2399
+ }
2361
2400
  console.log(theme.system(t('chats.use_resume')));
2362
2401
  }
2363
2402
  }
@@ -2431,8 +2470,19 @@ async function main() {
2431
2470
  }
2432
2471
  continue;
2433
2472
  }
2434
- if (lower === '/sessions' || lower === '/session') {
2435
- const all = listSessions();
2473
+ if (lower === '/sessions' ||
2474
+ lower === '/session' ||
2475
+ lower.startsWith('/sessions ') ||
2476
+ lower.startsWith('/session ')) {
2477
+ const filter = trimmed
2478
+ .replace(/^\/sessions?/i, '')
2479
+ .trim()
2480
+ .toLowerCase();
2481
+ // /sessions <query> filters by title or workdir (substring). Sorted by
2482
+ // recency already (listSessions).
2483
+ const all = listSessions().filter((s) => !filter ||
2484
+ (s.title || '').toLowerCase().includes(filter) ||
2485
+ (s.workdir || '').toLowerCase().includes(filter));
2436
2486
  console.log(theme.system(t('sessions.dir', { v: sessionsDir() })));
2437
2487
  if (!all.length) {
2438
2488
  console.log(theme.system(t('sessions.none')));
@@ -3164,6 +3214,12 @@ async function main() {
3164
3214
  const taskText = expandedTask !== null ? expandedTask : trimmed;
3165
3215
  // Dev mode: pick up fresh logic modules before the task.
3166
3216
  await autoReload();
3217
+ // Re-scan skills/custom commands before every task: a SKILL.md or a
3218
+ // command .md created during this session would otherwise not appear in
3219
+ // the «/» hints until a /reload.
3220
+ await refreshDynamicCommands(currentWorkdir);
3221
+ if (editor)
3222
+ editor.setCommands(buildSlashCommands());
3167
3223
  transcript.log('user_task', { task: taskText, workdir: currentWorkdir });
3168
3224
  const tools = mod.createTools(currentWorkdir, { undo, todos: todoStore });
3169
3225
  if (mcpPool)
package/dist/input.js CHANGED
@@ -34,6 +34,20 @@ const PASTE_END = ESC + '[201~';
34
34
  // kept aside and expanded back on submit. Pastes of 1–2 lines are inserted
35
35
  // as-is (small pastes are usually short and don't clutter the line).
36
36
  export const PASTE_MIN_LINES = 3;
37
+ // How many slash-command hints are shown at once (the rest are paged with
38
+ // Ctrl+N/P). The list used to be hard-capped at 8 with a "...more" note and no
39
+ // way to reach the rest.
40
+ export const SUGGEST_PAGE = 8;
41
+ // True when `q` is a subsequence of `s` (e.g. "hst" in "/history"). Used for
42
+ // the fuzzy fallback in _suggestions().
43
+ export function isSubsequence(q, s) {
44
+ let i = 0;
45
+ for (let j = 0; j < s.length && i < q.length; j++) {
46
+ if (s[j] === q[i])
47
+ i++;
48
+ }
49
+ return i === q.length;
50
+ }
37
51
  // Number of lines in a pasted block (the trailing newline doesn't add a line).
38
52
  export function countPasteLines(raw) {
39
53
  const normalized = String(raw)
@@ -369,6 +383,14 @@ export class LineEditor {
369
383
  // Slash-command hints (shown when you type «/»).
370
384
  slashCommands;
371
385
  _suggestCount;
386
+ // Scroll window of the suggestion list and the currently highlighted entry
387
+ // (Ctrl+N/P move the highlight; Enter inserts it). Without these the list
388
+ // was capped at 8 and the rest were unreachable.
389
+ _suggestOffset;
390
+ _suggestSelected;
391
+ // The buffer the suggestion highlight/scroll was computed for; when the
392
+ // buffer changes the selection resets to the top.
393
+ _suggestQuery;
372
394
  // Pasted blocks collapsed into markers (expanded back on submit).
373
395
  pastes;
374
396
  _pasteBuf;
@@ -477,6 +499,9 @@ export class LineEditor {
477
499
  this._typingRun = false;
478
500
  this.slashCommands = commands;
479
501
  this._suggestCount = 0;
502
+ this._suggestOffset = 0;
503
+ this._suggestSelected = -1;
504
+ this._suggestQuery = '';
480
505
  this.pastes = [];
481
506
  this._pasteBuf = '';
482
507
  this.attachments = new AttachmentStore();
@@ -609,15 +634,101 @@ export class LineEditor {
609
634
  }
610
635
  // List of hints for the current input. We show them only when the line
611
636
  // starts with «/» and is a single word (no spaces/newlines) — like
612
- // command completion in Claude Code.
637
+ // command completion in Claude Code. The match is fuzzy: an exact prefix
638
+ // first, then a substring, then a subsequence (e.g. "hst" -> "/history"),
639
+ // so a half-remembered command name still surfaces. Order is preserved.
613
640
  _suggestions() {
614
641
  const b = this.buf;
615
642
  if (!b.startsWith('/'))
616
643
  return [];
617
644
  if (b.includes(NL) || b.includes(' '))
618
645
  return [];
646
+ if (b.length > 40)
647
+ return [];
619
648
  const q = b.toLowerCase();
620
- return this.slashCommands.filter((c) => c.name.toLowerCase().startsWith(q));
649
+ if (q === '/')
650
+ return this.slashCommands;
651
+ // Match on the part AFTER the leading slash: comparing the raw query
652
+ // (which itself starts with '/') against the full name made the fuzzy
653
+ // subsequence match fire on the slash alone (e.g. "/x" matched
654
+ // "/self-fix"), so a stray letter pulled in unrelated commands.
655
+ const body = q.slice(1);
656
+ const prefix = [];
657
+ const substring = [];
658
+ const fuzzy = [];
659
+ // A single character only matches by PREFIX: a one-letter substring /
660
+ // subsequence match is far too loose ("x" would pull in "/self-fix").
661
+ const loose = body.length >= 2;
662
+ for (const c of this.slashCommands) {
663
+ const name = c.name.toLowerCase().slice(1);
664
+ if (name.startsWith(body))
665
+ prefix.push(c);
666
+ else if (loose && name.includes(body))
667
+ substring.push(c);
668
+ else if (loose && isSubsequence(body, name))
669
+ fuzzy.push(c);
670
+ }
671
+ return prefix.concat(substring, fuzzy);
672
+ }
673
+ // Render the suggestion block for the current query. `offset` scrolls the
674
+ // window so Ctrl+N/P can page through more than the visible size (the list
675
+ // used to be silently capped at 8). Returns the lines to print BELOW the
676
+ // input and how many rows they occupy.
677
+ _suggestionLines(offset) {
678
+ const sugg = this._suggestions();
679
+ // The buffer changed since the last highlight -> drop the selection and
680
+ // reset the window to the top, so a stale highlight never sticks to an
681
+ // unrelated entry.
682
+ if (this.buf !== this._suggestQuery) {
683
+ this._suggestQuery = this.buf;
684
+ this._suggestSelected = 0;
685
+ this._suggestOffset = 0;
686
+ }
687
+ if (!sugg.length)
688
+ return { lines: [], rows: 0 };
689
+ const pageSize = SUGGEST_PAGE;
690
+ const maxOffset = Math.max(0, sugg.length - pageSize);
691
+ const off = Math.max(0, Math.min(offset, maxOffset));
692
+ const shown = sugg.slice(off, off + pageSize);
693
+ this._suggestOffset = off;
694
+ this._suggestCount = sugg.length;
695
+ const maxName = Math.max(...shown.map((c) => c.name.length));
696
+ const lines = shown.map((c, i) => {
697
+ const selected = off + i === this._suggestSelected && this._suggestSelected > 0;
698
+ const name = theme.prompt(c.name.padEnd(maxName));
699
+ const desc = theme.dim(' ' + c.description);
700
+ return (selected ? theme.prompt(' ▸ ') : ' ') + name + desc;
701
+ });
702
+ if (sugg.length > pageSize) {
703
+ const t = translate(this.locale);
704
+ lines.push(theme.dim(' ' +
705
+ t('editor.more', {
706
+ n: String(sugg.length - shown.length),
707
+ }) +
708
+ ' ' +
709
+ t('editor.page_hint')));
710
+ }
711
+ return { lines, rows: lines.length };
712
+ }
713
+ // Move the highlight in the suggestion list (Ctrl+N/Ctrl+P). The window
714
+ // scrolls so the highlighted entry stays visible. -1 means "no explicit
715
+ // selection" (Tab then falls back to the common-prefix completion).
716
+ _suggestMove(delta) {
717
+ const sugg = this._suggestions();
718
+ if (!sugg.length)
719
+ return;
720
+ const next = this._suggestSelected + delta;
721
+ this._suggestSelected = Math.max(-1, Math.min(sugg.length - 1, next));
722
+ if (this._suggestSelected < 0) {
723
+ this._suggestOffset = 0;
724
+ }
725
+ else if (this._suggestSelected < this._suggestOffset) {
726
+ this._suggestOffset = this._suggestSelected;
727
+ }
728
+ else if (this._suggestSelected >= this._suggestOffset + SUGGEST_PAGE) {
729
+ this._suggestOffset = this._suggestSelected - SUGGEST_PAGE + 1;
730
+ }
731
+ this._renderInputOnly();
621
732
  }
622
733
  // Tab: complete the command up to the common prefix; if there is a single
623
734
  // match — insert it whole and add a space.
@@ -625,6 +736,11 @@ export class LineEditor {
625
736
  const sugg = this._suggestions();
626
737
  if (!sugg.length)
627
738
  return;
739
+ if (this._suggestSelected >= 0 && this._suggestSelected < sugg.length) {
740
+ this.buf = sugg[this._suggestSelected].name + ' ';
741
+ this.cursor = Array.from(this.buf).length;
742
+ return;
743
+ }
628
744
  if (sugg.length === 1) {
629
745
  this.buf = sugg[0].name + ' ';
630
746
  this.cursor = Array.from(this.buf).length;
@@ -893,29 +1009,15 @@ export class LineEditor {
893
1009
  if (statusUnchanged) {
894
1010
  // Count the real input rows via layout (wrapping aware).
895
1011
  const layOnly = layoutInput(this.promptStr, this.buf, this.cursor, cols);
896
- const suggOnly = this._suggestions();
897
- const shownOnly = suggOnly.slice(0, 8);
898
- const linesBelowOnly = shownOnly.length
899
- ? shownOnly.length + (suggOnly.length > shownOnly.length ? 1 : 0)
900
- : 0;
1012
+ const suggOnly = this._suggestionLines(this._suggestOffset);
1013
+ const linesBelowOnly = suggOnly.rows;
901
1014
  // Move up to the INPUT top using the OLD cursor row (the position that is
902
1015
  // on screen right now), never the input's height.
903
1016
  this._eraseInputOnly(this.cursorRowFromTop - this._statusTop);
904
1017
  let outOnly = '';
905
1018
  outOnly += layOnly.rows.map((r) => r.prefix + r.text).join(NL);
906
- if (shownOnly.length) {
907
- const maxName = Math.max(...shownOnly.map((c) => c.name.length));
908
- const lines = shownOnly.map((c) => {
909
- const name = theme.prompt(c.name.padEnd(maxName));
910
- const desc = theme.dim(' ' + c.description);
911
- return ' ' + name + desc;
912
- });
913
- outOnly += NL + lines.join(NL);
914
- const hidden = suggOnly.length - shownOnly.length;
915
- if (hidden > 0)
916
- outOnly +=
917
- NL +
918
- theme.dim(' ' + translate(this.locale)('editor.more', { n: hidden }));
1019
+ if (suggOnly.lines.length) {
1020
+ outOnly += NL + suggOnly.lines.join(NL);
919
1021
  }
920
1022
  // The input may have UNWRAPPED (fewer rows than before) — push the block
921
1023
  // back to the bottom BEFORE the new content is printed (the padding is
@@ -940,26 +1042,10 @@ export class LineEditor {
940
1042
  out += lay.rows.map((r) => r.prefix + r.text).join(NL);
941
1043
  // We draw the slash-command hints BELOW the input line. We then move the
942
1044
  // cursor back up to the input line, so cursorRowFromTop doesn't change.
943
- const sugg = this._suggestions();
944
- const shown = sugg.slice(0, 8);
945
- this._suggestCount = shown.length;
946
- if (shown.length) {
947
- const maxName = Math.max(...shown.map((c) => c.name.length));
948
- const lines = shown.map((c) => {
949
- const name = theme.prompt(c.name.padEnd(maxName));
950
- const desc = theme.dim(' ' + c.description);
951
- return ' ' + name + desc;
952
- });
953
- out += NL + lines.join(NL);
954
- const hidden = sugg.length - shown.length;
955
- if (hidden > 0)
956
- out +=
957
- NL +
958
- theme.dim(' ' + translate(this.locale)('editor.more', { n: hidden }));
959
- }
960
- const linesBelow = shown.length
961
- ? shown.length + (sugg.length > shown.length ? 1 : 0)
962
- : 0;
1045
+ const sugg = this._suggestionLines(this._suggestOffset);
1046
+ if (sugg.lines.length)
1047
+ out += NL + sugg.lines.join(NL);
1048
+ const linesBelow = sugg.rows;
963
1049
  // If the block SHRANK (the pause status went away, the input unwrapped),
964
1050
  // the erase left the cursor at the old block top; printing a shorter block
965
1051
  // would end above the screen bottom and the footer would float with blank
@@ -1101,13 +1187,15 @@ export class LineEditor {
1101
1187
  return theme.dim(' · ' + translate(this.locale)('spinner.hint'));
1102
1188
  }
1103
1189
  // The lifecycle phase prefix for the status line, colored by meaning:
1104
- // generating (brown). There is NO prefix for 'settled' (the answer block
1105
- // already marks completion) and none for 'paused' (the base status itself
1106
- // reads "пауза Ns"). Returns '' when no phase is active, so an idle status
1107
- // is not prefixed.
1190
+ // generating (brown) and settled (green check) both get a prefix, so the
1191
+ // transition from "still generating" to "answer done" is visible; 'paused'
1192
+ // has none (the base status itself reads "пауза Ns"). Returns '' when no
1193
+ // phase is active, so an idle status is not prefixed.
1108
1194
  _stateLabel() {
1109
1195
  if (this._sendState === 'generating')
1110
1196
  return (theme.brown(translate(this.locale)('state.generating')) + theme.dim(':'));
1197
+ if (this._sendState === 'settled')
1198
+ return (theme.success(translate(this.locale)('state.settled')) + theme.dim(':'));
1111
1199
  return '';
1112
1200
  }
1113
1201
  // Called by the browser layer at real lifecycle points: right before a
@@ -1835,6 +1923,16 @@ export class LineEditor {
1835
1923
  this._searchStart();
1836
1924
  continue;
1837
1925
  }
1926
+ if (code === 14) {
1927
+ // Ctrl+N — move the suggestion highlight down (like a menu).
1928
+ this._suggestMove(1);
1929
+ continue;
1930
+ }
1931
+ if (code === 16) {
1932
+ // Ctrl+P — move the suggestion highlight up.
1933
+ this._suggestMove(-1);
1934
+ continue;
1935
+ }
1838
1936
  if (code === 23) {
1839
1937
  this._pushUndo();
1840
1938
  this._deleteWordLeft();
package/dist/markdown.js CHANGED
@@ -26,12 +26,23 @@ function highlighter(code, lang) {
26
26
  return code;
27
27
  }
28
28
  }
29
+ // Configured maximum answer width (columns). 0 = auto: terminal width, capped
30
+ // at 100. Set once from config (ui.answerWidth) so every render path honors it
31
+ // without threading the option through every call site.
32
+ let configuredWidth = 0;
33
+ export function setAnswerWidth(width) {
34
+ configuredWidth = typeof width === 'number' && width > 0 ? width : 0;
35
+ }
29
36
  export function renderMarkdown(text) {
30
37
  if (!text)
31
38
  return '';
32
39
  try {
40
+ const cols = process.stdout.columns || 80;
41
+ const width = configuredWidth > 0
42
+ ? Math.min(configuredWidth, cols)
43
+ : Math.min(cols, 100);
33
44
  return render(String(text), {
34
- width: Math.min(process.stdout.columns || 80, 100),
45
+ width,
35
46
  theme: theme,
36
47
  highlighter,
37
48
  codeBox: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.52.0",
3
+ "version": "2.53.0",
4
4
  "description": "Terminal coding agent over chat.deepseek.com via Playwright",
5
5
  "type": "module",
6
6
  "bin": {