zames_pro 2.51.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/README.md CHANGED
@@ -76,6 +76,23 @@ While the agent is working you can keep typing: press Enter to queue a message
76
76
  abort the current generation. This mirrors typing during generation on the
77
77
  DeepSeek website.
78
78
 
79
+ ### Input line
80
+
81
+ The prompt is a small line editor with persistent history:
82
+
83
+ - `↑` / `↓` — walk the message history (saved in `~/.zames/history.json`, so it
84
+ survives a restart); inside a multiline message the arrows move between lines.
85
+ - `Ctrl+R` — incremental reverse search over the history (bash-style): type to
86
+ filter, `Ctrl+R` for older matches, `Enter` to accept, `Esc` to cancel.
87
+ - `Ctrl+_` — undo the last edit in the input line (a fat-fingered `Ctrl+U` /
88
+ `Ctrl+K` is recoverable).
89
+ - `Ctrl+U` — clear the line, `Ctrl+K` — delete to end of line, `Ctrl+W` —
90
+ delete the word before the cursor, `Ctrl+←`/`Ctrl+→` — move by words.
91
+ - `\` + `Enter`, `Ctrl+J`, `Ctrl+Enter` or `Shift+Enter` — insert a newline.
92
+ - `/` + `Tab` — slash-command hints and completion.
93
+
94
+ Set `NO_COLOR=1` to disable colors (a calm default palette is used otherwise).
95
+
79
96
  ### Images and files
80
97
 
81
98
  You can paste an image or a file into the input line (Ctrl+Shift+V / Shift+Insert
@@ -149,7 +166,11 @@ Codex CLI:
149
166
  startup; relaunch with --dir to write there).
150
167
  - /resume <n> (after /chats) and /resume-id <id> — open a chat and PRINT its
151
168
  dialogue into the terminal, so the restored context is visible. Only the
152
- last 20 messages are shown (`RESTORED_HISTORY_LIMIT`).
169
+ last 20 messages are shown (`RESTORED_HISTORY_LIMIT`). /last reopens the last
170
+ chat of the current directory with no lookup step; /sessions shows the saved
171
+ sessions with their relative age.
172
+ - /help <command> — the full description of a single command (e.g. `/help diff`)
173
+ instead of the whole list.
153
174
  - /review [focus] [--staged] — ask the agent to review uncommitted changes
154
175
  and report findings (no code changes).
155
176
  - /compact — ask DeepSeek to compress the current chat into a handover
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 {};
package/dist/commands.js CHANGED
@@ -86,6 +86,27 @@ export function formatDuration(ms, units = EN_DURATION_UNITS) {
86
86
  return m + units.m + ' ' + pad(s) + units.s;
87
87
  return s + units.s;
88
88
  }
89
+ // Short relative age ("just now", "5m ago", "3h ago", "2d ago") for the
90
+ // /sessions and /chats lists. The operator cares about RECENCY, not a raw
91
+ // ISO timestamp, when picking a session to restore.
92
+ export function formatRelativeTime(iso, t = translate('en'), now = Date.now()) {
93
+ if (!iso)
94
+ return '';
95
+ const then = Date.parse(iso);
96
+ if (!Number.isFinite(then))
97
+ return '';
98
+ const diff = Math.max(0, now - then);
99
+ const min = Math.floor(diff / 60_000);
100
+ if (min < 1)
101
+ return t('time.now');
102
+ if (min < 60)
103
+ return t('time.min_ago', { n: String(min) });
104
+ const hours = Math.floor(min / 60);
105
+ if (hours < 24)
106
+ return t('time.hour_ago', { n: String(hours) });
107
+ const days = Math.floor(hours / 24);
108
+ return t('time.day_ago', { n: String(days) });
109
+ }
89
110
  export function renderCost(stats, transcriptFile, tokenUsage = null, t = translate('en')) {
90
111
  const lines = [];
91
112
  lines.push(t('cost.title'));
@@ -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: 'Пока агент работает:',
@@ -103,13 +107,17 @@ const CATALOG = {
103
107
  ru: 'Ctrl+R поиск по истории (обратный)',
104
108
  en: 'Ctrl+R reverse-search the history',
105
109
  },
110
+ 'help.key.undo': {
111
+ ru: 'Ctrl+_ отменить правку в строке ввода',
112
+ en: 'Ctrl+_ undo the last edit in the input line',
113
+ },
106
114
  'help.key.words': {
107
115
  ru: 'Ctrl+← / Ctrl+→ перемещение по словам',
108
116
  en: 'Ctrl+← / Ctrl+→ move by words',
109
117
  },
110
118
  'help.key.slash': {
111
- ru: '/ + Tab подсказка и автодополнение slash-команд',
112
- 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)',
113
121
  },
114
122
  'help.key.newline': {
115
123
  ru: 'Ctrl+J / Ctrl+Enter новая строка (Shift+Enter в терминалах с поддержкой)',
@@ -133,16 +141,16 @@ const CATALOG = {
133
141
  en: '/new, /clear new chat (reset context)',
134
142
  },
135
143
  'help.cmd.sessions': {
136
- ru: '/sessions список сохранённых сессий',
137
- en: '/sessions list saved sessions',
144
+ ru: '/sessions [фильтр] список сохранённых сессий (можно фильтровать)',
145
+ en: '/sessions [filter] list saved sessions (optionally filtered)',
138
146
  },
139
147
  'help.cmd.resume_id': {
140
148
  ru: '/resume-id <id> восстановить сессию по полному id',
141
149
  en: '/resume-id <id> resume a session by full id',
142
150
  },
143
151
  'help.cmd.chats': {
144
- ru: '/chats список последних чатов DeepSeek',
145
- en: '/chats recent DeepSeek chats',
152
+ ru: '/chats [фильтр] список последних чатов DeepSeek',
153
+ en: '/chats [filter] recent DeepSeek chats',
146
154
  },
147
155
  'help.cmd.resume': {
148
156
  ru: '/resume <n> открыть чат №n из /chats',
@@ -363,6 +371,10 @@ const CATALOG = {
363
371
  ru: '/debug-dom сохранить HTML страницы (для отладки)',
364
372
  en: '/debug-dom dump page HTML (debug)',
365
373
  },
374
+ 'help.no_topic': {
375
+ ru: 'Нет команды по запросу «{v}». Попробуй /help без аргумента.',
376
+ en: 'No command matches "{v}". Try /help without arguments.',
377
+ },
366
378
  'help.cmd.help': {
367
379
  ru: '/help, help справка',
368
380
  en: '/help, help this help',
@@ -455,7 +467,15 @@ const CATALOG = {
455
467
  'help.files.profile': { ru: 'Профиль:', en: 'Profile:' },
456
468
  'help.files.snapshots': { ru: 'Снапшоты:', en: 'Snapshots:' },
457
469
  'help.files.tmp': { ru: 'Временные:', en: 'Temp:' },
470
+ 'help.files.tmp_note': {
471
+ ru: '(в .gitignore, очищается при старте)',
472
+ en: '(.gitignore, cleaned on start)',
473
+ },
458
474
  'help.files.config': { ru: 'Конфиг:', en: 'Config:' },
475
+ 'help.toggles_legend': {
476
+ ru: 'Индикаторы в статус-строке: 🧠 — глубокое размышление, 🌐 — поиск в интернете (видны, когда включены).',
477
+ en: 'Status-line icons: 🧠 — Deep thinking, 🌐 — web search (shown when enabled).',
478
+ },
459
479
  // ---------- spinner ----------
460
480
  'spinner.phrases': {
461
481
  ru: 'Анализирую запрос…|Изучаю контекст…|Планирую решение…|Обрабатываю данные…|Формулирую ответ…|Проверяю детали…|Собираю информацию…|Обдумываю варианты…|Структурирую мысли…|Готовлю решение…|Анализирую код…|Уточняю детали…|Выполняю вычисления…|Систематизирую данные…|Прорабатываю задачу…|Проверяю логику…|Оптимизирую подход…|Формирую выводы…',
@@ -474,7 +494,12 @@ const CATALOG = {
474
494
  // animated thinking phrase, it says explicitly that a generation is in
475
495
  // flight. 'paused'/'settled' have no label (see LineEditor._stateLabel).
476
496
  'state.generating': { ru: '▶ генерация', en: '▶ generating' },
497
+ 'state.settled': { ru: '✓ готово', en: '✓ done' },
477
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
+ },
478
503
  'editor.answer': { ru: '● Ответ', en: '● Answer' },
479
504
  // Reverse search (Ctrl+R) status line. The query may be empty.
480
505
  'editor.search_prompt': {
@@ -565,6 +590,10 @@ const CATALOG = {
565
590
  ru: 'Интерактивный режим. Введите задачу. Команды — /help. Выход — /exit.',
566
591
  en: 'Interactive mode. Enter a task. Commands — /help. Exit — /exit.',
567
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
+ },
568
597
  'msg.first_hint': {
569
598
  ru: 'Подсказка: «/» — список команд, Ctrl+R — поиск по истории, картинку можно вставить через Ctrl+Shift+V.',
570
599
  en: 'Hint: "/" lists commands, Ctrl+R searches history, paste an image with Ctrl+Shift+V.',
@@ -1080,6 +1109,10 @@ const CATALOG = {
1080
1109
  },
1081
1110
  'chats.history_you': { ru: 'Вы', en: 'You' },
1082
1111
  'chats.history_agent': { ru: 'Агент', en: 'Agent' },
1112
+ 'time.now': { ru: 'только что', en: 'just now' },
1113
+ 'time.min_ago': { ru: '{n} мин назад', en: '{n}m ago' },
1114
+ 'time.hour_ago': { ru: '{n} ч назад', en: '{n}h ago' },
1115
+ 'time.day_ago': { ru: '{n} дн назад', en: '{n}d ago' },
1083
1116
  'sessions.dir': { ru: 'Папка сессий: {v}', en: 'Sessions dir: {v}' },
1084
1117
  'sessions.none': {
1085
1118
  ru: 'Сохранённых сессий нет. Они появятся после первой задачи/чата.',
@@ -1192,6 +1225,10 @@ const CATALOG = {
1192
1225
  ru: 'Язык интерфейса и ответов агента',
1193
1226
  en: 'Interface and agent reply language',
1194
1227
  },
1228
+ 'cfg.f.ui_answerWidth': {
1229
+ ru: 'Максимальная ширина ответа в колонках (0 — авто, до 100)',
1230
+ en: 'Max answer width in columns (0 = auto, capped at 100)',
1231
+ },
1195
1232
  'cfg.f.maxIterations': {
1196
1233
  ru: 'Лимит итераций на задачу',
1197
1234
  en: 'Max agent loop iterations per task',
@@ -1380,8 +1417,8 @@ const CATALOG = {
1380
1417
  en: '⚙ Settings — choose a parameter',
1381
1418
  },
1382
1419
  'cfg.menu.hint': {
1383
- ru: '↑/↓ — выбор, Enter — изменить, q/Esc — выйти',
1384
- 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',
1385
1422
  },
1386
1423
  'cfg.menu.edit_hint': {
1387
1424
  ru: 'Введи значение и Enter (пусто — отмена)',
@@ -1430,6 +1467,10 @@ const CATALOG = {
1430
1467
  ru: 'Неверное значение для {v}: ожидалось {type}',
1431
1468
  en: 'Invalid value for {v}: expected {type}',
1432
1469
  },
1470
+ 'cfg.range_hint': {
1471
+ ru: 'число от {min} до {max}',
1472
+ en: 'a number from {min} to {max}',
1473
+ },
1433
1474
  'cfg.write_error': {
1434
1475
  ru: 'Не удалось записать конфиг: {v}',
1435
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';
@@ -17,12 +18,12 @@ import { translate, normalizeLocale, localeDisplayName, isLocale, } from './i18n
17
18
  import { Transcript } from './transcript.js';
18
19
  import { UndoStore } from './undo.js';
19
20
  import { selfReview, selfDiff, selfApply, selfList } from './self-review.js';
20
- import { formatDiff, diffGitArgs, parseTranscript, summarizeTranscript, renderCost, formatExport, defaultExportPath, renderDoctor, renderPermissions, resolveExtraDir, buildReviewPrompt, formatDuration, trimRestoredMessages, RESTORED_HISTORY_LIMIT, mergeMessages, hasQueuedJob, isSlashCommand, parseQueueCommand, parseLiveToggle, parseGoalCommand, isLiveConfigCommand, withGoal, formatQueueList, ctrlCEscalation, } from './commands.js';
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
- import { saveSession, loadLastSession, readSession, listSessions, sessionsDir, } from './sessions.js';
26
+ import { saveSession, loadLastSession, readSession, listSessions, sessionsDir, loadHistory, saveHistory, } from './sessions.js';
26
27
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
28
  const NL = String.fromCharCode(10);
28
29
  // Floor for a /loop interval. A loop faster than the send throttle (15s)
@@ -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
 
@@ -299,6 +303,7 @@ ${theme.bold(t('help.while_working'))}
299
303
  ${t('help.key.history')}
300
304
  ${t('help.key.search')}
301
305
  ${t('help.key.words')}
306
+ ${t('help.key.undo')}
302
307
  ${t('help.key.slash')}
303
308
  ${t('help.key.newline')}
304
309
  ${t('help.key.backslash')}
@@ -357,9 +362,11 @@ ${theme.bold(t('help.files'))}
357
362
  ${t('help.files.sessions')} ${sessionsDir()}
358
363
  ${t('help.files.profile')} ~/.zames/profile
359
364
  ${t('help.files.snapshots')} ~/.zames/snapshots
360
- ${t('help.files.tmp')} <project>/tmp (.gitignore, cleaned on start)
365
+ ${t('help.files.tmp')} <project>/tmp ${t('help.files.tmp_note')}
361
366
  ${t('help.files.config')} ${CONFIG_PATHS.HOME_CONFIG}
362
367
  ${CONFIG_PATHS.PROJECT_CONFIG}
368
+
369
+ ${theme.dim(t('help.toggles_legend'))}
363
370
  `);
364
371
  }
365
372
  function dirLabel(p) {
@@ -1339,6 +1346,12 @@ async function main() {
1339
1346
  console.log(theme.system(t('msg.interactive')));
1340
1347
  console.log(theme.system(t('msg.queue_hint')));
1341
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
+ }
1342
1355
  let freshChatNext = true;
1343
1356
  let sendSystemPromptNext = true;
1344
1357
  // Resuming an existing chat already has the system-prompt at its start, so
@@ -1544,6 +1557,11 @@ async function main() {
1544
1557
  });
1545
1558
  editor = ed;
1546
1559
  ed.setTmpDir(TMP_DIR);
1560
+ // Restore the input history from the previous runs and persist every new
1561
+ // entry, so Ctrl+R / arrows can recall older messages after a restart.
1562
+ ed.history = loadHistory();
1563
+ ed._histIndex = ed.history.length;
1564
+ ed.onHistoryChange = (h) => saveHistory(h);
1547
1565
  // The token context right-aligned on the status line (above the input).
1548
1566
  // The editor pulls the number on every render, so it follows the live
1549
1567
  // DeepSeek counter (accumulated_token_usage) without a polling timer.
@@ -1807,6 +1825,11 @@ async function main() {
1807
1825
  editor.setPrompt(buildPrompt());
1808
1826
  }
1809
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
+ }
1810
1833
  // Reflect browser toggles on the LIVE browser object. The browser is
1811
1834
  // created once at startup, so without this a /config set browser.*
1812
1835
  // change would only apply after a restart. We also update the cached
@@ -1905,6 +1928,21 @@ async function main() {
1905
1928
  writeConfigValue('home', field.path, raw);
1906
1929
  setConfigRuntime(field.path, value);
1907
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
+ }
1908
1946
  function configResetField(field) {
1909
1947
  resetConfigValue('home', field.path);
1910
1948
  // Reset the runtime value to the default.
@@ -2050,7 +2088,7 @@ async function main() {
2050
2088
  catch {
2051
2089
  console.error(theme.error(t('cfg.bad_value', {
2052
2090
  v: key,
2053
- type: field.values ? field.values.join('|') : field.type,
2091
+ type: configValueHint(field),
2054
2092
  })));
2055
2093
  return;
2056
2094
  }
@@ -2115,8 +2153,29 @@ async function main() {
2115
2153
  const lower = trimmed.toLowerCase();
2116
2154
  if (['/exit', '/quit', 'exit', 'quit'].includes(lower))
2117
2155
  break;
2118
- if (lower === '/help' || lower === 'help') {
2119
- printHelp();
2156
+ if (lower === '/help' ||
2157
+ lower === 'help' ||
2158
+ lower.startsWith('/help ') ||
2159
+ lower.startsWith('help ')) {
2160
+ const topic = trimmed.replace(/^\/?help\s*/i, '').trim();
2161
+ if (!topic) {
2162
+ printHelp();
2163
+ }
2164
+ else {
2165
+ // /help <command> — print the full description of ONE command instead
2166
+ // of making the operator scroll the whole list.
2167
+ const q = '/' + topic.replace(/^\//, '').toLowerCase();
2168
+ const match = buildSlashCommands().filter((c) => c.name.toLowerCase() === q ||
2169
+ c.name.toLowerCase().startsWith(q + ' '));
2170
+ if (!match.length) {
2171
+ console.error(theme.warn(t('help.no_topic', { v: topic })));
2172
+ }
2173
+ else {
2174
+ for (const c of match) {
2175
+ console.log(theme.assistant(c.name) + theme.dim(' ' + c.description));
2176
+ }
2177
+ }
2178
+ }
2120
2179
  continue;
2121
2180
  }
2122
2181
  if (['/new', '/clear', 'new'].includes(lower)) {
@@ -2312,7 +2371,8 @@ async function main() {
2312
2371
  continue;
2313
2372
  }
2314
2373
  // ---------- Regular commands ----------
2315
- if (lower === '/chats') {
2374
+ if (lower === '/chats' || lower.startsWith('/chats ')) {
2375
+ const filter = trimmed.slice('/chats'.length).trim().toLowerCase();
2316
2376
  const spin = editor || mod.createSpinner(currentLocale);
2317
2377
  if (editor)
2318
2378
  editor.lock(t('msg.input_locked'));
@@ -2320,15 +2380,23 @@ async function main() {
2320
2380
  try {
2321
2381
  lastChats = await browser.listChats(30);
2322
2382
  spin.stop();
2323
- 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) {
2324
2389
  console.log(theme.system(t('chats.none_hint')));
2325
2390
  }
2326
2391
  else {
2327
2392
  console.log(theme.system(t('chats.recent')));
2328
- lastChats.forEach((c, i) => {
2329
- 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, ' ');
2330
2398
  console.log(` ${theme.user(n)}. ${c.title} ${theme.system('(' + c.id.slice(0, 8) + '…)')}`);
2331
- });
2399
+ }
2332
2400
  console.log(theme.system(t('chats.use_resume')));
2333
2401
  }
2334
2402
  }
@@ -2402,8 +2470,19 @@ async function main() {
2402
2470
  }
2403
2471
  continue;
2404
2472
  }
2405
- if (lower === '/sessions' || lower === '/session') {
2406
- 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));
2407
2486
  console.log(theme.system(t('sessions.dir', { v: sessionsDir() })));
2408
2487
  if (!all.length) {
2409
2488
  console.log(theme.system(t('sessions.none')));
@@ -2414,7 +2493,9 @@ async function main() {
2414
2493
  const mark = s.id === currentChatId ? theme.user(' *') : '';
2415
2494
  const title = s.title ? ` ${s.title}` : '';
2416
2495
  const wd = s.workdir ? theme.dim(` [${dirLabel(s.workdir)}]`) : '';
2417
- console.log(` ${theme.user(n)}. ${s.id.slice(0, 8)}…${title}${wd}${mark}`);
2496
+ const age = formatRelativeTime(s.updatedAt, t);
2497
+ const ageText = age ? theme.dim(' ' + age) : '';
2498
+ console.log(` ${theme.user(n)}. ${s.id.slice(0, 8)}…${title}${wd}${ageText}${mark}`);
2418
2499
  });
2419
2500
  console.log(theme.system(t('sessions.restore_hint')));
2420
2501
  }
@@ -3133,6 +3214,12 @@ async function main() {
3133
3214
  const taskText = expandedTask !== null ? expandedTask : trimmed;
3134
3215
  // Dev mode: pick up fresh logic modules before the task.
3135
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());
3136
3223
  transcript.log('user_task', { task: taskText, workdir: currentWorkdir });
3137
3224
  const tools = mod.createTools(currentWorkdir, { undo, todos: todoStore });
3138
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)
@@ -356,9 +370,27 @@ export class LineEditor {
356
370
  _searchQuery;
357
371
  _searchIndex;
358
372
  _searchSavedBuf;
373
+ // Called after every submit with the new full history, so the caller can
374
+ // persist it (the history otherwise dies with the process).
375
+ onHistoryChange;
376
+ // Snapshots of {buf, cursor} taken before each edit, so Ctrl+_ can undo a
377
+ // slip (a fat-fingered Ctrl+U / Ctrl+K used to wipe the whole line with no
378
+ // way back). Capped so a long session cannot grow it without bound.
379
+ _undoStack;
380
+ // True while a run of plain typing is in progress, so one undo snapshot
381
+ // covers the whole run instead of every character.
382
+ _typingRun;
359
383
  // Slash-command hints (shown when you type «/»).
360
384
  slashCommands;
361
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;
362
394
  // Pasted blocks collapsed into markers (expanded back on submit).
363
395
  pastes;
364
396
  _pasteBuf;
@@ -462,8 +494,14 @@ export class LineEditor {
462
494
  this._searchQuery = '';
463
495
  this._searchIndex = -1;
464
496
  this._searchSavedBuf = '';
497
+ this.onHistoryChange = null;
498
+ this._undoStack = [];
499
+ this._typingRun = false;
465
500
  this.slashCommands = commands;
466
501
  this._suggestCount = 0;
502
+ this._suggestOffset = 0;
503
+ this._suggestSelected = -1;
504
+ this._suggestQuery = '';
467
505
  this.pastes = [];
468
506
  this._pasteBuf = '';
469
507
  this.attachments = new AttachmentStore();
@@ -596,15 +634,101 @@ export class LineEditor {
596
634
  }
597
635
  // List of hints for the current input. We show them only when the line
598
636
  // starts with «/» and is a single word (no spaces/newlines) — like
599
- // 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.
600
640
  _suggestions() {
601
641
  const b = this.buf;
602
642
  if (!b.startsWith('/'))
603
643
  return [];
604
644
  if (b.includes(NL) || b.includes(' '))
605
645
  return [];
646
+ if (b.length > 40)
647
+ return [];
606
648
  const q = b.toLowerCase();
607
- 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();
608
732
  }
609
733
  // Tab: complete the command up to the common prefix; if there is a single
610
734
  // match — insert it whole and add a space.
@@ -612,6 +736,11 @@ export class LineEditor {
612
736
  const sugg = this._suggestions();
613
737
  if (!sugg.length)
614
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
+ }
615
744
  if (sugg.length === 1) {
616
745
  this.buf = sugg[0].name + ' ';
617
746
  this.cursor = Array.from(this.buf).length;
@@ -880,29 +1009,15 @@ export class LineEditor {
880
1009
  if (statusUnchanged) {
881
1010
  // Count the real input rows via layout (wrapping aware).
882
1011
  const layOnly = layoutInput(this.promptStr, this.buf, this.cursor, cols);
883
- const suggOnly = this._suggestions();
884
- const shownOnly = suggOnly.slice(0, 8);
885
- const linesBelowOnly = shownOnly.length
886
- ? shownOnly.length + (suggOnly.length > shownOnly.length ? 1 : 0)
887
- : 0;
1012
+ const suggOnly = this._suggestionLines(this._suggestOffset);
1013
+ const linesBelowOnly = suggOnly.rows;
888
1014
  // Move up to the INPUT top using the OLD cursor row (the position that is
889
1015
  // on screen right now), never the input's height.
890
1016
  this._eraseInputOnly(this.cursorRowFromTop - this._statusTop);
891
1017
  let outOnly = '';
892
1018
  outOnly += layOnly.rows.map((r) => r.prefix + r.text).join(NL);
893
- if (shownOnly.length) {
894
- const maxName = Math.max(...shownOnly.map((c) => c.name.length));
895
- const lines = shownOnly.map((c) => {
896
- const name = theme.prompt(c.name.padEnd(maxName));
897
- const desc = theme.dim(' ' + c.description);
898
- return ' ' + name + desc;
899
- });
900
- outOnly += NL + lines.join(NL);
901
- const hidden = suggOnly.length - shownOnly.length;
902
- if (hidden > 0)
903
- outOnly +=
904
- NL +
905
- theme.dim(' ' + translate(this.locale)('editor.more', { n: hidden }));
1019
+ if (suggOnly.lines.length) {
1020
+ outOnly += NL + suggOnly.lines.join(NL);
906
1021
  }
907
1022
  // The input may have UNWRAPPED (fewer rows than before) — push the block
908
1023
  // back to the bottom BEFORE the new content is printed (the padding is
@@ -927,26 +1042,10 @@ export class LineEditor {
927
1042
  out += lay.rows.map((r) => r.prefix + r.text).join(NL);
928
1043
  // We draw the slash-command hints BELOW the input line. We then move the
929
1044
  // cursor back up to the input line, so cursorRowFromTop doesn't change.
930
- const sugg = this._suggestions();
931
- const shown = sugg.slice(0, 8);
932
- this._suggestCount = shown.length;
933
- if (shown.length) {
934
- const maxName = Math.max(...shown.map((c) => c.name.length));
935
- const lines = shown.map((c) => {
936
- const name = theme.prompt(c.name.padEnd(maxName));
937
- const desc = theme.dim(' ' + c.description);
938
- return ' ' + name + desc;
939
- });
940
- out += NL + lines.join(NL);
941
- const hidden = sugg.length - shown.length;
942
- if (hidden > 0)
943
- out +=
944
- NL +
945
- theme.dim(' ' + translate(this.locale)('editor.more', { n: hidden }));
946
- }
947
- const linesBelow = shown.length
948
- ? shown.length + (sugg.length > shown.length ? 1 : 0)
949
- : 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;
950
1049
  // If the block SHRANK (the pause status went away, the input unwrapped),
951
1050
  // the erase left the cursor at the old block top; printing a shorter block
952
1051
  // would end above the screen bottom and the footer would float with blank
@@ -1088,13 +1187,15 @@ export class LineEditor {
1088
1187
  return theme.dim(' · ' + translate(this.locale)('spinner.hint'));
1089
1188
  }
1090
1189
  // The lifecycle phase prefix for the status line, colored by meaning:
1091
- // generating (brown). There is NO prefix for 'settled' (the answer block
1092
- // already marks completion) and none for 'paused' (the base status itself
1093
- // reads "пауза Ns"). Returns '' when no phase is active, so an idle status
1094
- // 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.
1095
1194
  _stateLabel() {
1096
1195
  if (this._sendState === 'generating')
1097
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(':'));
1098
1199
  return '';
1099
1200
  }
1100
1201
  // Called by the browser layer at real lifecycle points: right before a
@@ -1280,6 +1381,9 @@ export class LineEditor {
1280
1381
  // Add to history only non-empty messages that don't duplicate the previous one.
1281
1382
  if (text.trim() && this.history[this.history.length - 1] !== text) {
1282
1383
  this.history.push(text);
1384
+ // Persist the new entry: the in-memory history dies with the process.
1385
+ if (this.onHistoryChange)
1386
+ this.onHistoryChange(this.history.slice());
1283
1387
  }
1284
1388
  this._histIndex = this.history.length;
1285
1389
  this._histDraft = '';
@@ -1304,6 +1408,26 @@ export class LineEditor {
1304
1408
  if (this.busy)
1305
1409
  this._startThinking();
1306
1410
  }
1411
+ // Record the current buffer for Ctrl+_ undo. `coalesce` keeps one snapshot
1412
+ // for a whole run of plain typing (so undo removes the run, not one char) —
1413
+ // the run ends as soon as a non-typing key is handled.
1414
+ _pushUndo(coalesce = false) {
1415
+ if (coalesce && this._typingRun)
1416
+ return;
1417
+ this._undoStack.push({ buf: this.buf, cursor: this.cursor });
1418
+ if (this._undoStack.length > 200)
1419
+ this._undoStack.shift();
1420
+ this._typingRun = coalesce;
1421
+ }
1422
+ // Ctrl+_ — restore the buffer to before the last edit.
1423
+ _undoEdit() {
1424
+ const prev = this._undoStack.pop();
1425
+ if (!prev)
1426
+ return;
1427
+ this.buf = prev.buf;
1428
+ this.cursor = Math.min(prev.cursor, Array.from(prev.buf).length);
1429
+ this._typingRun = false;
1430
+ }
1307
1431
  _insert(text) {
1308
1432
  const chars = Array.from(this.buf);
1309
1433
  const ins = Array.from(String(text));
@@ -1774,12 +1898,19 @@ export class LineEditor {
1774
1898
  continue;
1775
1899
  }
1776
1900
  if (code === 21) {
1901
+ this._pushUndo();
1777
1902
  this.buf = '';
1778
1903
  this.cursor = 0;
1779
1904
  this.pastes = [];
1780
1905
  this._renderInputOnly();
1781
1906
  continue;
1782
1907
  }
1908
+ if (code === 31) {
1909
+ // Ctrl+_ — undo the last edit in the input line.
1910
+ this._undoEdit();
1911
+ this._renderInputOnly();
1912
+ continue;
1913
+ }
1783
1914
  if (code === 22) {
1784
1915
  // Ctrl+V: terminals rarely deliver an image as text here, so we try the
1785
1916
  // OS clipboard first; if there is no image we fall back to reading the
@@ -1792,13 +1923,25 @@ export class LineEditor {
1792
1923
  this._searchStart();
1793
1924
  continue;
1794
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
+ }
1795
1936
  if (code === 23) {
1937
+ this._pushUndo();
1796
1938
  this._deleteWordLeft();
1797
1939
  this._renderInputOnly();
1798
1940
  continue;
1799
1941
  }
1800
1942
  if (code === 11) {
1801
1943
  // Ctrl+K — delete from the cursor to the end of the line.
1944
+ this._pushUndo();
1802
1945
  const arr = Array.from(this.buf);
1803
1946
  let e = this.cursor;
1804
1947
  while (e < arr.length && arr[e] !== NL)
@@ -1809,6 +1952,7 @@ export class LineEditor {
1809
1952
  continue;
1810
1953
  }
1811
1954
  if (code === 127 || code === 8) {
1955
+ this._pushUndo(true);
1812
1956
  this._backspace();
1813
1957
  this._renderInputOnly();
1814
1958
  continue;
@@ -1872,6 +2016,7 @@ export class LineEditor {
1872
2016
  continue;
1873
2017
  }
1874
2018
  if (s.startsWith('[3~')) {
2019
+ this._pushUndo(true);
1875
2020
  this._delete();
1876
2021
  s = s.slice(3);
1877
2022
  this._renderInputOnly();
@@ -1898,6 +2043,8 @@ export class LineEditor {
1898
2043
  }
1899
2044
  if (code < 32)
1900
2045
  continue;
2046
+ // Coalesce a run of typing into ONE undo snapshot.
2047
+ this._pushUndo(true);
1901
2048
  this._insert(ch);
1902
2049
  this._renderInputOnly();
1903
2050
  }
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/dist/sessions.js CHANGED
@@ -132,3 +132,29 @@ export function listSessions() {
132
132
  export function sessionsDir() {
133
133
  return SESSIONS_DIR;
134
134
  }
135
+ // ---------- input history ----------
136
+ // The LineEditor history otherwise dies with the process (it was in-memory
137
+ // only), so the arrow keys / Ctrl+R could not recall anything from a previous
138
+ // run. We persist it in ~/.zames/history.json, capped so the file cannot grow
139
+ // without bound.
140
+ const HISTORY_FILE = path.join(os.homedir(), '.zames', 'history.json');
141
+ const HISTORY_LIMIT = 500;
142
+ export function loadHistory() {
143
+ try {
144
+ const raw = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'));
145
+ if (!Array.isArray(raw))
146
+ return [];
147
+ return raw.filter((x) => typeof x === 'string');
148
+ }
149
+ catch {
150
+ return [];
151
+ }
152
+ }
153
+ export function saveHistory(history) {
154
+ try {
155
+ ensureDir();
156
+ const trimmed = history.slice(-HISTORY_LIMIT);
157
+ atomicWriteJson(HISTORY_FILE, trimmed);
158
+ }
159
+ catch { }
160
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.51.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": {