zames_pro 2.50.2 → 2.51.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/commands.js CHANGED
@@ -1,29 +1,26 @@
1
1
  import path from 'path';
2
2
  import { isImageName } from './attachments.js';
3
+ import { translate } from './i18n.js';
3
4
  // Helpers for the extra slash commands (/diff, /cost, /export, /doctor,
4
5
  // /permissions, /review, /add-dir). Pure functions, unit-tested without a
5
6
  // live agent/browser. index.ts only renders their output.
6
7
  const NL = String.fromCharCode(10);
7
8
  const CR = String.fromCharCode(13);
8
9
  // ---------- /diff ----------
9
- export function formatDiff(diffText, opts = {}) {
10
+ export function formatDiff(diffText, opts = {}, t = translate('en')) {
10
11
  const maxLines = opts.maxLines ?? 200;
11
12
  const trimmed = String(diffText ?? '')
12
13
  .split(CR + NL)
13
14
  .join(NL)
14
15
  .trimEnd();
15
16
  if (!trimmed)
16
- return '(no changes)';
17
+ return t('diff.no_changes');
17
18
  const lines = trimmed.split(NL);
18
19
  if (lines.length <= maxLines)
19
20
  return trimmed;
20
21
  const head = lines.slice(0, maxLines).join(NL);
21
22
  const rest = lines.length - maxLines;
22
- return (head +
23
- NL +
24
- '... [' +
25
- rest +
26
- ' more lines, use Bash git diff for the full output]');
23
+ return head + NL + t('diff.more_lines', { n: String(rest) });
27
24
  }
28
25
  export function diffGitArgs(staged = false) {
29
26
  return staged ? 'git diff --staged' : 'git diff';
@@ -89,30 +86,30 @@ export function formatDuration(ms, units = EN_DURATION_UNITS) {
89
86
  return m + units.m + ' ' + pad(s) + units.s;
90
87
  return s + units.s;
91
88
  }
92
- export function renderCost(stats, transcriptFile, tokenUsage = null) {
89
+ export function renderCost(stats, transcriptFile, tokenUsage = null, t = translate('en')) {
93
90
  const lines = [];
94
- lines.push('Session stats:');
91
+ lines.push(t('cost.title'));
95
92
  if (typeof tokenUsage === 'number') {
96
- lines.push(' context: ~' + tokenUsage + ' tokens (DeepSeek accumulated_token_usage)');
93
+ lines.push(t('cost.context_known', { n: String(tokenUsage) }));
97
94
  }
98
95
  else {
99
- lines.push(' context: unknown (DeepSeek reports it after the first answer in a chat)');
96
+ lines.push(t('cost.context_unknown'));
100
97
  }
101
- lines.push(' tasks: ' + stats.turns);
102
- lines.push(' tool calls: ' + stats.toolCalls);
98
+ lines.push(t('cost.tasks', { n: String(stats.turns) }));
99
+ lines.push(t('cost.tool_calls', { n: String(stats.toolCalls) }));
103
100
  if (stats.autoCompacts > 0) {
104
- lines.push(' auto-compacts: ' + stats.autoCompacts);
101
+ lines.push(t('cost.auto_compacts', { n: String(stats.autoCompacts) }));
105
102
  }
106
103
  const top = Object.entries(stats.toolCounts).sort((a, b) => b[1] - a[1]);
107
104
  if (top.length) {
108
- lines.push(' by tool:');
105
+ lines.push(t('cost.by_tool'));
109
106
  for (const [name, n] of top)
110
107
  lines.push(' ' + name + ': ' + n);
111
108
  }
112
- lines.push(' duration: ' + formatDuration(stats.durationMs));
109
+ lines.push(t('cost.duration', { dur: formatDuration(stats.durationMs) }));
113
110
  if (stats.startedAt)
114
- lines.push(' started: ' + stats.startedAt);
115
- lines.push(' transcript: ' + (transcriptFile || '(off)'));
111
+ lines.push(t('cost.started', { v: stats.startedAt }));
112
+ lines.push(t('cost.transcript', { v: transcriptFile || t('cost.off') }));
116
113
  return lines.join(NL);
117
114
  }
118
115
  // ---------- /export ----------
@@ -172,64 +169,74 @@ export function defaultExportPath(workdir, now = new Date()) {
172
169
  .replace('Z', '');
173
170
  return path.join(workdir, 'zames-export-' + stamp + '.md');
174
171
  }
175
- export function renderDoctor(d) {
172
+ export function renderDoctor(d, t = translate('en')) {
176
173
  const rows = [];
177
174
  const row = (ok, label, value) => {
178
175
  rows.push(' ' + (ok ? '[OK] ' : '[WARN]') + ' ' + label.padEnd(16) + ' ' + value);
179
176
  };
177
+ const onoff = (b) => (b ? t('common.on') : t('common.off'));
180
178
  row(true, 'node', d.nodeVersion);
181
179
  row(true, 'platform', d.platform);
182
180
  row(true, 'workdir', d.workdir);
183
- row(d.gitOk, 'git', d.gitOk ? 'repo (' + (d.gitBranch || 'detached') + ')' : 'not a repository');
184
- row(d.configOk, 'config', d.configOk ? 'loaded' : 'error: ' + (d.configError || 'unknown'));
185
- row(true, 'browser', d.browserChannel ? d.browserChannel : 'bundled chromium');
186
- row(!!d.authSaved, 'auth', d.authSaved ? 'session saved (auto re-login ready)' : 'no saved session');
187
- row(!!d.clipboardTool, 'clipboard', d.clipboardTool || 'no tool found (image paste disabled)');
188
- row(true, 'mcp', d.mcpServers + ' server(s), ' + d.mcpTools + ' tool(s)');
189
- row(d.transcriptOk, 'transcript', d.transcriptOk ? 'on' : 'off');
181
+ row(d.gitOk, 'git', d.gitOk
182
+ ? t('doctor.repo', { v: d.gitBranch || t('doctor.detached') })
183
+ : t('doctor.not_repo'));
184
+ row(d.configOk, 'config', d.configOk
185
+ ? t('doctor.loaded')
186
+ : t('doctor.error', { v: d.configError || t('doctor.unknown') }));
187
+ row(true, 'browser', d.browserChannel ? d.browserChannel : t('doctor.bundled'));
188
+ row(!!d.authSaved, 'auth', d.authSaved ? t('doctor.auth_saved') : t('doctor.auth_none'));
189
+ row(!!d.clipboardTool, 'clipboard', d.clipboardTool || t('doctor.clipboard_none'));
190
+ row(true, 'mcp', t('doctor.mcp', {
191
+ servers: String(d.mcpServers),
192
+ tools: String(d.mcpTools),
193
+ }));
194
+ row(d.transcriptOk, 'transcript', onoff(d.transcriptOk));
190
195
  if (typeof d.contextLimit === 'number' && d.contextLimit > 0) {
191
- row(true, 'context', d.contextLimit.toLocaleString('en-US') + ' tokens');
196
+ row(true, 'context', t('doctor.tokens', {
197
+ v: d.contextLimit.toLocaleString('en-US'),
198
+ }));
192
199
  }
193
200
  if (d.gitOk && typeof d.hasOrigin === 'boolean') {
194
- row(true, 'git remote', d.hasOrigin ? 'origin configured' : 'no origin');
201
+ row(true, 'git remote', d.hasOrigin ? t('doctor.origin_ok') : t('doctor.origin_none'));
195
202
  }
196
203
  if (typeof d.minSendIntervalMs === 'number') {
197
- row(d.minSendIntervalMs > 0, 'send pause', Math.round(d.minSendIntervalMs / 1000) + 's between agent sends');
204
+ row(d.minSendIntervalMs > 0, 'send pause', t('doctor.send_pause', {
205
+ n: String(Math.round(d.minSendIntervalMs / 1000)),
206
+ }));
198
207
  }
199
208
  if (typeof d.sshRemote === 'boolean') {
200
- row(true, 'session', d.sshRemote ? 'SSH remote' : 'local terminal');
209
+ row(true, 'session', d.sshRemote ? t('doctor.ssh') : t('doctor.local'));
201
210
  }
202
211
  if (d.headless) {
203
- row(!!d.headlessUaCached, 'headless', d.headlessUaCached
204
- ? 'UA cached (no relaunch)'
205
- : 'no cached UA yet (first start relaunches once)');
212
+ row(!!d.headlessUaCached, 'headless', d.headlessUaCached ? t('doctor.ua_cached') : t('doctor.ua_not_cached'));
206
213
  }
207
- return 'Doctor:' + NL + rows.join(NL);
214
+ return t('doctor.title') + NL + rows.join(NL);
208
215
  }
209
- export function renderPermissions(p) {
210
- const onoff = (b) => (b ? 'ask' : 'allow');
216
+ export function renderPermissions(p, t = translate('en')) {
217
+ const onoff = (b) => (b ? t('perm.ask') : t('perm.allow'));
211
218
  const lines = [];
212
- lines.push('Tool permissions (confirmation settings):');
213
- lines.push(' Write: ' + onoff(p.write));
214
- lines.push(' Edit: ' + onoff(p.edit));
215
- lines.push(' Bash: ' + onoff(p.bash));
219
+ lines.push(t('perm.title'));
220
+ lines.push(t('perm.write', { v: onoff(p.write) }));
221
+ lines.push(t('perm.edit', { v: onoff(p.edit) }));
222
+ lines.push(t('perm.bash', { v: onoff(p.bash) }));
216
223
  if (p.alwaysConfirm.length) {
217
- lines.push(' Always confirm (regex):');
224
+ lines.push(t('perm.always'));
218
225
  for (const re of p.alwaysConfirm)
219
226
  lines.push(' ' + re);
220
227
  }
221
228
  lines.push('');
222
- lines.push('Change with: /config set confirmation.write false (and .edit / .bash)');
229
+ lines.push(t('perm.change_hint'));
223
230
  return lines.join(NL);
224
231
  }
225
232
  // ---------- /add-dir ----------
226
- export function resolveExtraDir(input, workdir) {
233
+ export function resolveExtraDir(input, workdir, t = translate('en')) {
227
234
  const raw = String(input ?? '').trim();
228
235
  if (!raw)
229
- return { error: 'Usage: /add-dir <path>' };
236
+ return { error: t('adddir.usage') };
230
237
  const abs = path.resolve(workdir, raw);
231
238
  if (abs === path.resolve(workdir)) {
232
- return { error: 'This is already the working directory.' };
239
+ return { error: t('adddir.same') };
233
240
  }
234
241
  return { path: abs };
235
242
  }
@@ -11,9 +11,9 @@
11
11
  import { theme } from './theme.js';
12
12
  import { visLen } from './input.js';
13
13
  const ESC = String.fromCharCode(27);
14
- // Strip ANSI sequences to compute the visible length of a line.
14
+ // Strip ANSH sequences to compute the visible length of a line.
15
15
  function stripAnsi(s) {
16
- return s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
16
+ return s.replace(/\x1b\`[0-9;?]*[A-Za-z]/g, '');
17
17
  }
18
18
  // Visible COLUMNS of a line (ANSI removed, wide chars counted as 2). Using the
19
19
  // code-point count made the block height disagree with the real screen on lines
@@ -35,7 +35,7 @@ function displayValue(field, value, t) {
35
35
  return String(value);
36
36
  }
37
37
  function groupLabel(field, t) {
38
- return t(field.groupKey);
38
+ return theme.system(' ' + t(field.groupKey));
39
39
  }
40
40
  /**
41
41
  * Show the interactive menu. Returns a promise that resolves when the
@@ -45,13 +45,20 @@ function groupLabel(field, t) {
45
45
  export function runConfigMenu(opts) {
46
46
  const input = opts.input || process.stdin;
47
47
  const output = opts.output || process.stdout;
48
- const { fields, t, get, set, reset } = opts;
48
+ const { fields, t, get, set, reset, getDefault } = opts;
49
49
  if (!input.isTTY || !input.setRawMode) {
50
50
  return Promise.reject(new Error(t('cfg.menu.notty')));
51
51
  }
52
+ // cursor indexes the VISIBLE (filtered) list, not the full `fields`.
52
53
  let cursor = 0;
53
54
  let editing = false;
54
55
  let editBuf = '';
56
+ // Reverse/forward filter over the list (/ to enter, typing narrows it).
57
+ let filterMode = false;
58
+ let filterQuery = '';
59
+ // Second `d` on the same row confirms the reset, so a stray keypress
60
+ // cannot wipe a setting by accident.
61
+ let pendingReset = null;
55
62
  let message = null;
56
63
  let exited = false;
57
64
  let resolveDone = null;
@@ -59,18 +66,53 @@ export function runConfigMenu(opts) {
59
66
  input.setRawMode(true);
60
67
  input.resume();
61
68
  output.write(ESC + '[?25l'); // hide the cursor while rendering
69
+ // Fields that match the current filter (path, label or group contains the
70
+ // query, case-insensitive). Empty query -> everything.
71
+ const visible = () => {
72
+ const q = filterQuery.trim().toLowerCase();
73
+ if (!q)
74
+ return fields;
75
+ return fields.filter((f) => (f.path + ' ' + t(f.labelKey || f.path) + ' ' + t(f.groupKey))
76
+ .toLowerCase()
77
+ .includes(q));
78
+ };
79
+ const currentField = () => {
80
+ const vis = visible();
81
+ if (!vis.length)
82
+ return null;
83
+ if (cursor >= vis.length)
84
+ cursor = vis.length - 1;
85
+ return vis[cursor];
86
+ };
87
+ // True when the value differs from the default (the field is overridden
88
+ // in a config file), so the menu can mark it. Skipped with no getDefault.
89
+ const isModified = (f) => {
90
+ if (!getDefault)
91
+ return false;
92
+ const cur = get(f.path);
93
+ const def = getDefault(f.path);
94
+ try {
95
+ return JSON.stringify(cur) !== JSON.stringify(def);
96
+ }
97
+ catch {
98
+ return String(cur) !== String(def);
99
+ }
100
+ };
62
101
  // Menu rendering: lines go top to bottom, and we return the cursor to the
63
102
  // start of the block via relative moves so we don't spawn blank lines.
64
103
  let lastRows = 0;
65
104
  const render = () => {
66
105
  const lines = [];
67
106
  lines.push(theme.bold(t('cfg.menu.title')));
107
+ const vis = visible();
108
+ if (cursor >= vis.length)
109
+ cursor = Math.max(0, vis.length - 1);
68
110
  let lastGroup = '';
69
- for (let i = 0; i < fields.length; i++) {
70
- const f = fields[i];
111
+ for (let i = 0; i < vis.length; i++) {
112
+ const f = vis[i];
71
113
  const g = groupLabel(f, t);
72
114
  if (g !== lastGroup) {
73
- lines.push(theme.system(' ' + g));
115
+ lines.push(g);
74
116
  lastGroup = g;
75
117
  }
76
118
  const selected = i === cursor;
@@ -80,21 +122,38 @@ export function runConfigMenu(opts) {
80
122
  : t(f.labelKey || f.path);
81
123
  const val = displayValue(f, get(f.path), t);
82
124
  const valText = selected ? theme.assistant(val) : theme.dim(val);
125
+ const mod = isModified(f) ? theme.warn('*') : '';
83
126
  const pathText = selected ? theme.dim(' ' + f.path) : '';
84
- lines.push(marker + label + theme.dim(' = ') + valText + pathText);
127
+ lines.push(marker + label + theme.dim(' = ') + valText + mod + pathText);
128
+ }
129
+ if (!vis.length) {
130
+ lines.push(theme.dim(' ' + t('cfg.menu.no_match')));
85
131
  }
86
132
  if (editing) {
87
133
  lines.push('');
88
134
  lines.push(theme.user(t('cfg.menu.edit_hint')));
89
135
  lines.push(theme.prompt('\u276f ') + editBuf);
90
136
  }
137
+ else if (filterMode) {
138
+ lines.push('');
139
+ lines.push(theme.user(t('cfg.menu.filter_hint')));
140
+ lines.push(theme.prompt('\u276f ') + filterQuery);
141
+ }
91
142
  else if (message) {
92
143
  lines.push('');
93
144
  lines.push(theme.assistant(message));
94
145
  }
95
146
  else {
96
147
  lines.push('');
97
- lines.push(theme.dim(t('cfg.menu.hint')));
148
+ if (filterQuery) {
149
+ lines.push(theme.dim(t('cfg.menu.filter_active', { q: filterQuery }) +
150
+ ' ' +
151
+ t('cfg.menu.hint')));
152
+ }
153
+ else {
154
+ lines.push(' ' + theme.dim(t('cfg.menu.hint')));
155
+ lines.push(' ' + theme.dim(t('cfg.menu.filter_hint')));
156
+ }
98
157
  }
99
158
  // Erase the previous block and print the new one. We count VISUAL lines
100
159
  // (accounting for wrapping by terminal width), otherwise on narrow
@@ -134,12 +193,12 @@ export function runConfigMenu(opts) {
134
193
  lastRows = 0;
135
194
  }
136
195
  };
137
- const cleanup = (clearOnExit = false) => {
196
+ const cleanup = (cleanOnExit = false) => {
138
197
  if (exited)
139
198
  return;
140
199
  exited = true;
141
200
  input.removeListener('data', onData);
142
- if (clearOnExit)
201
+ if (cleanOnExit)
143
202
  clearBlock();
144
203
  if (input.setRawMode)
145
204
  input.setRawMode(wasRaw || false);
@@ -148,10 +207,12 @@ export function runConfigMenu(opts) {
148
207
  resolveDone();
149
208
  };
150
209
  const commitEdit = () => {
151
- const f = fields[cursor];
210
+ const f = currentField();
152
211
  const raw = editBuf.trim();
153
212
  editing = false;
154
213
  editBuf = '';
214
+ if (!f)
215
+ return;
155
216
  if (!raw) {
156
217
  message = null;
157
218
  return;
@@ -168,7 +229,9 @@ export function runConfigMenu(opts) {
168
229
  }
169
230
  };
170
231
  const toggle = () => {
171
- const f = fields[cursor];
232
+ const f = currentField();
233
+ if (!f)
234
+ return;
172
235
  if (f.type === 'boolean') {
173
236
  const cur = get(f.path);
174
237
  set(f, cur === true ? 'false' : 'true');
@@ -218,20 +281,49 @@ export function runConfigMenu(opts) {
218
281
  render();
219
282
  return;
220
283
  }
284
+ // Filter mode (/): printable keys build the query, Esc/Enter leave it
285
+ // (keeping the query active), Ctrl+C exits the whole menu.
286
+ if (filterMode) {
287
+ for (const ch of s) {
288
+ if (ch === '\r' || ch === '\n' || ch === ESC) {
289
+ filterMode = false;
290
+ break;
291
+ }
292
+ if (ch === '\x03') {
293
+ cleanup(true);
294
+ return;
295
+ }
296
+ if (ch === '\x7f' || ch === '\b') {
297
+ filterQuery = filterQuery.slice(0, -1);
298
+ }
299
+ else if (ch >= ' ') {
300
+ filterQuery += ch;
301
+ }
302
+ }
303
+ cursor = 0;
304
+ render();
305
+ return;
306
+ }
221
307
  // The terminal may send several keys in one packet (fast typing,
222
308
  // auto-repeat, automation). We parse the buffer into tokens: first
223
309
  // escape sequences (arrows), then single characters.
224
310
  let i = 0;
225
311
  while (i < s.length) {
226
312
  if (s.startsWith('\x1b[A', i)) {
227
- cursor = (cursor - 1 + fields.length) % fields.length;
313
+ const n = visible().length;
314
+ if (n > 0)
315
+ cursor = (cursor - 1 + n) % n;
228
316
  message = null;
317
+ pendingReset = null;
229
318
  i += 3;
230
319
  continue;
231
320
  }
232
321
  if (s.startsWith('\x1b[B', i)) {
233
- cursor = (cursor + 1) % fields.length;
322
+ const n = visible().length;
323
+ if (n > 0)
324
+ cursor = (cursor + 1) % n;
234
325
  message = null;
326
+ pendingReset = null;
235
327
  i += 3;
236
328
  continue;
237
329
  }
@@ -242,21 +334,51 @@ export function runConfigMenu(opts) {
242
334
  cleanup(true);
243
335
  return;
244
336
  }
245
- if (ch === 'k') {
246
- cursor = (cursor - 1 + fields.length) % fields.length;
337
+ if (ch === '/') {
338
+ filterMode = true;
339
+ message = null;
340
+ // The rest of THIS packet belongs to the filter query (typing " /debug"
341
+ // arrives as one chunk, and without this the trailing characters fell
342
+ // through to the command keys — 'd' armed a reset instead of filtering).
343
+ for (const rest of s.slice(i + 1)) {
344
+ if (rest === '\x7f' || rest === '\b') {
345
+ filterQuery = filterQuery.slice(0, -1);
346
+ }
347
+ else if (rest >= ' ') {
348
+ filterQuery += rest;
349
+ }
350
+ }
351
+ cursor = 0;
352
+ i = s.length;
353
+ }
354
+ else if (ch === 'k') {
355
+ const n = visible().length;
356
+ if (n > 0)
357
+ cursor = (cursor - 1 + n) % n;
247
358
  message = null;
248
359
  }
249
360
  else if (ch === 'j') {
250
- cursor = (cursor + 1) % fields.length;
361
+ const n = visible().length;
362
+ if (n > 0)
363
+ cursor = (cursor + 1) % n;
251
364
  message = null;
252
365
  }
253
366
  else if (ch === '\r' || ch === '\n' || ch === ' ') {
254
367
  toggle();
255
368
  }
256
369
  else if (ch === 'd' || ch === 'D') {
257
- const f = fields[cursor];
258
- reset(f);
259
- message = t('cfg.reset', { v: f.path });
370
+ const f = currentField();
371
+ if (f) {
372
+ if (pendingReset === f.path) {
373
+ reset(f);
374
+ message = t('cfg.reset', { v: f.path });
375
+ pendingReset = null;
376
+ }
377
+ else {
378
+ pendingReset = f.path;
379
+ message = t('cfg.menu.reset_confirm', { v: f.path });
380
+ }
381
+ }
260
382
  }
261
383
  i++;
262
384
  }
package/dist/i18n.js CHANGED
@@ -39,6 +39,10 @@ const CATALOG = {
39
39
  'common.unknown': { ru: 'неизвестно', en: 'unknown' },
40
40
  // ---------- help ----------
41
41
  'help.options': { ru: 'Опции CLI:', en: 'CLI options:' },
42
+ 'help.color_note': {
43
+ ru: 'Цвет отключается переменной окружения NO_COLOR=1.',
44
+ en: 'Disable color with the NO_COLOR=1 environment variable.',
45
+ },
42
46
  'help.opt.dir': {
43
47
  ru: 'рабочая директория агента',
44
48
  en: 'agent working directory',
@@ -95,6 +99,10 @@ const CATALOG = {
95
99
  ru: '↑ / ↓ история введённых сообщений',
96
100
  en: '↑ / ↓ input history',
97
101
  },
102
+ 'help.key.search': {
103
+ ru: 'Ctrl+R поиск по истории (обратный)',
104
+ en: 'Ctrl+R reverse-search the history',
105
+ },
98
106
  'help.key.words': {
99
107
  ru: 'Ctrl+← / Ctrl+→ перемещение по словам',
100
108
  en: 'Ctrl+← / Ctrl+→ move by words',
@@ -140,6 +148,10 @@ const CATALOG = {
140
148
  ru: '/resume <n> открыть чат №n из /chats',
141
149
  en: '/resume <n> open chat #n from /chats',
142
150
  },
151
+ 'help.cmd.last': {
152
+ ru: '/last открыть последний чат этого каталога',
153
+ en: '/last open the last chat of this directory',
154
+ },
143
155
  'help.cmd.chat': {
144
156
  ru: '/chat показать текущий chat id',
145
157
  en: '/chat show current chat id',
@@ -464,6 +476,15 @@ const CATALOG = {
464
476
  'state.generating': { ru: '▶ генерация', en: '▶ generating' },
465
477
  'editor.more': { ru: '…ещё {n}', en: '…{n} more' },
466
478
  'editor.answer': { ru: '● Ответ', en: '● Answer' },
479
+ // Reverse search (Ctrl+R) status line. The query may be empty.
480
+ 'editor.search_prompt': {
481
+ ru: '(обратный поиск) `{q}` — Ctrl+R дальше, Enter принять, Esc отмена',
482
+ en: '(reverse-i-search) `{q}` — Ctrl+R next, Enter accept, Esc cancel',
483
+ },
484
+ 'editor.search_fail': {
485
+ ru: '(обратный поиск) `{q}` — не найдено, Esc отмена',
486
+ en: '(reverse-i-search) `{q}` — not found, Esc cancel',
487
+ },
467
488
  // ---------- status ----------
468
489
  'status.workdir': {
469
490
  ru: 'Рабочая директория: {v}',
@@ -539,10 +560,15 @@ const CATALOG = {
539
560
  en: 'Opening chat {id}...',
540
561
  },
541
562
  'msg.chat_opened': { ru: 'Чат открыт.', en: 'Chat opened.' },
563
+ 'msg.version': { ru: 'Версия: {v}', en: 'Version: {v}' },
542
564
  'msg.interactive': {
543
565
  ru: 'Интерактивный режим. Введите задачу. Команды — /help. Выход — /exit.',
544
566
  en: 'Interactive mode. Enter a task. Commands — /help. Exit — /exit.',
545
567
  },
568
+ 'msg.first_hint': {
569
+ ru: 'Подсказка: «/» — список команд, Ctrl+R — поиск по истории, картинку можно вставить через Ctrl+Shift+V.',
570
+ en: 'Hint: "/" lists commands, Ctrl+R searches history, paste an image with Ctrl+Shift+V.',
571
+ },
546
572
  'msg.queue_hint': {
547
573
  ru: 'Пока агент работает, можно печатать следующее сообщение — оно уйдёт в очередь (Enter — отправить, Esc — прервать).',
548
574
  en: 'While the agent works you can type the next message — it goes to the queue (Enter — send, Esc — abort).',
@@ -601,6 +627,99 @@ const CATALOG = {
601
627
  'dur.h': { ru: 'ч', en: 'h' },
602
628
  'dur.m': { ru: 'м', en: 'm' },
603
629
  'dur.s': { ru: 'с', en: 's' },
630
+ // ---------- /diff ----------
631
+ 'diff.no_changes': { ru: '(нет изменений)', en: '(no changes)' },
632
+ 'diff.more_lines': {
633
+ ru: '... [{n} строк ещё, полный вывод — Bash git diff]',
634
+ en: '... [{n} more lines, use Bash git diff for the full output]',
635
+ },
636
+ // ---------- /cost ----------
637
+ 'cost.title': { ru: 'Статистика сессии:', en: 'Session stats:' },
638
+ 'cost.context_known': {
639
+ ru: ' контекст: ~{n} токенов (DeepSeek accumulated_token_usage)',
640
+ en: ' context: ~{n} tokens (DeepSeek accumulated_token_usage)',
641
+ },
642
+ 'cost.context_unknown': {
643
+ ru: ' контекст: неизвестно (DeepSeek сообщит после первого ответа в чате)',
644
+ en: ' context: unknown (DeepSeek reports it after the first answer in a chat)',
645
+ },
646
+ 'cost.tasks': { ru: ' задач: {n}', en: ' tasks: {n}' },
647
+ 'cost.tool_calls': {
648
+ ru: ' вызовов инструментов: {n}',
649
+ en: ' tool calls: {n}',
650
+ },
651
+ 'cost.auto_compacts': { ru: ' авто-сжатий: {n}', en: ' auto-compacts: {n}' },
652
+ 'cost.by_tool': { ru: ' по инструментам:', en: ' by tool:' },
653
+ 'cost.duration': { ru: ' длительность: {dur}', en: ' duration: {dur}' },
654
+ 'cost.started': { ru: ' начало: {v}', en: ' started: {v}' },
655
+ 'cost.transcript': { ru: ' транскрипт: {v}', en: ' transcript: {v}' },
656
+ 'cost.off': { ru: '(выкл)', en: '(off)' },
657
+ // ---------- /doctor ----------
658
+ 'doctor.title': { ru: 'Диагностика:', en: 'Doctor:' },
659
+ 'doctor.repo': { ru: 'репозиторий ({v})', en: 'repo ({v})' },
660
+ 'doctor.detached': { ru: 'detached', en: 'detached' },
661
+ 'doctor.not_repo': { ru: 'не репозиторий', en: 'not a repository' },
662
+ 'doctor.loaded': { ru: 'загружен', en: 'loaded' },
663
+ 'doctor.error': { ru: 'ошибка: {v}', en: 'error: {v}' },
664
+ 'doctor.unknown': { ru: 'неизвестно', en: 'unknown' },
665
+ 'doctor.bundled': { ru: 'встроенный chromium', en: 'bundled chromium' },
666
+ 'doctor.auth_saved': {
667
+ ru: 'сессия сохранена (авто-вход готов)',
668
+ en: 'session saved (auto re-login ready)',
669
+ },
670
+ 'doctor.auth_none': { ru: 'нет сохранённой сессии', en: 'no saved session' },
671
+ 'doctor.clipboard_none': {
672
+ ru: 'инструмент не найден (вставка картинок отключена)',
673
+ en: 'no tool found (image paste disabled)',
674
+ },
675
+ 'doctor.mcp': {
676
+ ru: '{servers} сервер(ов), {tools} инструмент(ов)',
677
+ en: '{servers} server(s), {tools} tool(s)',
678
+ },
679
+ 'doctor.tokens': { ru: '{v} токенов', en: '{v} tokens' },
680
+ 'doctor.origin_ok': { ru: 'origin настроен', en: 'origin configured' },
681
+ 'doctor.origin_none': { ru: 'нет origin', en: 'no origin' },
682
+ 'doctor.send_pause': {
683
+ ru: '{n}с между отправками агента',
684
+ en: '{n}s between agent sends',
685
+ },
686
+ 'doctor.ssh': { ru: 'SSH-подключение', en: 'SSH remote' },
687
+ 'doctor.local': { ru: 'локальный терминал', en: 'local terminal' },
688
+ 'doctor.ua_cached': {
689
+ ru: 'UA закэширован (без перезапуска)',
690
+ en: 'UA cached (no relaunch)',
691
+ },
692
+ 'doctor.ua_not_cached': {
693
+ ru: 'UA ещё не закэширован (первый запуск перезапустится один раз)',
694
+ en: 'no cached UA yet (first start relaunches once)',
695
+ },
696
+ // ---------- /permissions ----------
697
+ 'perm.title': {
698
+ ru: 'Права инструментов (настройки подтверждений):',
699
+ en: 'Tool permissions (confirmation settings):',
700
+ },
701
+ 'perm.write': { ru: ' Write: {v}', en: ' Write: {v}' },
702
+ 'perm.edit': { ru: ' Edit: {v}', en: ' Edit: {v}' },
703
+ 'perm.bash': { ru: ' Bash: {v}', en: ' Bash: {v}' },
704
+ 'perm.ask': { ru: 'спрашивать', en: 'ask' },
705
+ 'perm.allow': { ru: 'разрешить', en: 'allow' },
706
+ 'perm.always': {
707
+ ru: ' Всегда подтверждать (regex):',
708
+ en: ' Always confirm (regex):',
709
+ },
710
+ 'perm.change_hint': {
711
+ ru: 'Изменить: /config set confirmation.write false (и .edit / .bash)',
712
+ en: 'Change with: /config set confirmation.write false (and .edit / .bash)',
713
+ },
714
+ // ---------- /add-dir ----------
715
+ 'adddir.usage': {
716
+ ru: 'Использование: /add-dir <path>',
717
+ en: 'Usage: /add-dir <path>',
718
+ },
719
+ 'adddir.same': {
720
+ ru: 'Это уже рабочая директория.',
721
+ en: 'This is already the working directory.',
722
+ },
604
723
  'msg.from_queue': { ru: '▶ Из очереди: ', en: '▶ From queue: ' },
605
724
  'msg.from_queue_batch': {
606
725
  ru: '▶ Из очереди ({n} сообщ.): ',
@@ -1273,6 +1392,19 @@ const CATALOG = {
1273
1392
  en: '✔ Saved: {v} = {value}',
1274
1393
  },
1275
1394
  'cfg.menu.default': { ru: 'по умолчанию', en: 'default' },
1395
+ 'cfg.menu.filter_hint': {
1396
+ ru: '/ — поиск по настройкам, d — сбросить (дважды), * — изменено от дефолта',
1397
+ en: '/ search settings, d reset (press twice), * changed from default',
1398
+ },
1399
+ 'cfg.menu.filter_active': {
1400
+ ru: 'Фильтр: «{q}» (Esc/Enter — очистить)',
1401
+ en: 'Filter: "{q}" (Esc/Enter to clear)',
1402
+ },
1403
+ 'cfg.menu.no_match': { ru: '(нет совпадений)', en: '(no matches)' },
1404
+ 'cfg.menu.reset_confirm': {
1405
+ ru: 'Нажми d ещё раз, чтобы сбросить {v}',
1406
+ en: 'Press d again to reset {v}',
1407
+ },
1276
1408
  'cfg.menu.notty': {
1277
1409
  ru: 'Меню доступно только в интерактивном терминале. Используй /config list|get|set.',
1278
1410
  en: 'The menu needs an interactive terminal. Use /config list|get|set.',
package/dist/index.js CHANGED
@@ -292,9 +292,12 @@ ${theme.bold(t('help.options'))}
292
292
  --version, -v ${t('help.opt.version')}
293
293
  --help, -h ${t('help.opt.help')}
294
294
 
295
+ ${t('help.color_note')}
296
+
295
297
  ${theme.bold(t('help.while_working'))}
296
298
  ${t('help.key.queue')}
297
299
  ${t('help.key.history')}
300
+ ${t('help.key.search')}
298
301
  ${t('help.key.words')}
299
302
  ${t('help.key.slash')}
300
303
  ${t('help.key.newline')}
@@ -308,6 +311,7 @@ ${theme.bold(t('help.commands'))}
308
311
  ${t('help.cmd.resume_id')}
309
312
  ${t('help.cmd.chats')}
310
313
  ${t('help.cmd.resume')}
314
+ ${t('help.cmd.last')}
311
315
  ${t('help.cmd.chat')}
312
316
  ${t('help.cmd.cd')}
313
317
  ${t('help.cmd.pwd')}
@@ -316,20 +320,20 @@ ${theme.bold(t('help.commands'))}
316
320
  ${t('help.cmd.undo')}
317
321
  ${t('help.cmd.undo_list')}
318
322
  ${t('help.cmd.transcript')}
319
- ${t('help.cmd.diff')}
320
- ${t('help.cmd.cost')}
321
- ${t('help.cmd.export')}
322
- ${t('help.cmd.doctor')}
323
- ${t('help.cmd.permissions')}
324
- ${t('help.cmd.add_dir')}
325
- ${t('help.cmd.review')}
326
- ${t('help.cmd.goal')}
327
- ${t('help.cmd.loop')}
328
- ${t('help.cmd.cron')}
329
- ${t('help.cmd.jobs')}
330
- ${t('help.cmd.thinking')}
331
- ${t('help.cmd.web')}
332
- ${t('help.cmd.compact')}
323
+ ${t('help.cmd.diff')}
324
+ ${t('help.cmd.cost')}
325
+ ${t('help.cmd.export')}
326
+ ${t('help.cmd.doctor')}
327
+ ${t('help.cmd.permissions')}
328
+ ${t('help.cmd.add_dir')}
329
+ ${t('help.cmd.review')}
330
+ ${t('help.cmd.goal')}
331
+ ${t('help.cmd.loop')}
332
+ ${t('help.cmd.cron')}
333
+ ${t('help.cmd.jobs')}
334
+ ${t('help.cmd.thinking')}
335
+ ${t('help.cmd.web')}
336
+ ${t('help.cmd.compact')}
333
337
  ${t('help.cmd.queue')}
334
338
  ${t('help.cmd.tasks')}
335
339
  ${t('help.cmd.config')}
@@ -372,6 +376,7 @@ const SLASH_COMMANDS = [
372
376
  { name: '/chats', key: 'help.cmd.chats' },
373
377
  { name: '/resume', key: 'help.cmd.resume' },
374
378
  { name: '/resume-id', key: 'help.cmd.resume_id' },
379
+ { name: '/last', key: 'help.cmd.last' },
375
380
  { name: '/chat', key: 'help.cmd.chat' },
376
381
  { name: '/cd', key: 'help.cmd.cd' },
377
382
  { name: '/pwd', key: 'help.cmd.pwd' },
@@ -1190,9 +1195,14 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
1190
1195
  }
1191
1196
  // ---------- main ----------
1192
1197
  async function main() {
1198
+ // Read the package version once: --version prints it and the interactive
1199
+ // banner shows it (a version in the first lines is what a bug report needs).
1200
+ const pkgVersion = await fs
1201
+ .readFile(path.join(__dirname, '..', 'package.json'), 'utf-8')
1202
+ .then((raw) => JSON.parse(raw).version)
1203
+ .catch(() => '');
1193
1204
  if (hasFlag('--version') || hasFlag('-v')) {
1194
- const pkg = JSON.parse(await fs.readFile(path.join(__dirname, '..', 'package.json'), 'utf-8'));
1195
- console.log(pkg.version);
1205
+ console.log(pkgVersion || 'unknown');
1196
1206
  return;
1197
1207
  }
1198
1208
  if (hasFlag('--help') || hasFlag('-h')) {
@@ -1215,6 +1225,7 @@ async function main() {
1215
1225
  process.exit(1);
1216
1226
  }
1217
1227
  console.log(theme.system(t('msg.working_dir', { v: currentWorkdir })));
1228
+ console.log(theme.system(t('msg.version', { v: pkgVersion || '?' })));
1218
1229
  const transcript = new Transcript({
1219
1230
  dir: config.transcript.dir,
1220
1231
  enabled: config.transcript.enabled,
@@ -1327,6 +1338,7 @@ async function main() {
1327
1338
  }
1328
1339
  console.log(theme.system(t('msg.interactive')));
1329
1340
  console.log(theme.system(t('msg.queue_hint')));
1341
+ console.log(theme.dim(t('msg.first_hint')));
1330
1342
  let freshChatNext = true;
1331
1343
  let sendSystemPromptNext = true;
1332
1344
  // Resuming an existing chat already has the system-prompt at its start, so
@@ -1946,6 +1958,7 @@ async function main() {
1946
1958
  fields: CONFIG_SCHEMA,
1947
1959
  t,
1948
1960
  get: (path) => getByPath(config, path),
1961
+ getDefault: (path) => getByPath(DEFAULTS, path),
1949
1962
  set: (field, raw) => configSetRaw(field, raw),
1950
1963
  reset: (field) => configResetField(field),
1951
1964
  });
@@ -2407,6 +2420,39 @@ async function main() {
2407
2420
  }
2408
2421
  continue;
2409
2422
  }
2423
+ if (lower === '/last') {
2424
+ // Quick resume of the last chat for THIS working directory — otherwise
2425
+ // finding it meant /chats (a network fetch) or copying the id.
2426
+ const last = loadLastSession(currentWorkdir);
2427
+ if (!last || !last.id) {
2428
+ console.error(theme.error(t('sessions.none')));
2429
+ continue;
2430
+ }
2431
+ if (editor)
2432
+ editor.lock(t('msg.input_locked'));
2433
+ try {
2434
+ if (editor)
2435
+ editor.clearScreen();
2436
+ console.log(theme.system(t('msg.opening_chat', { id: last.id })));
2437
+ await browser.openChat(last.id);
2438
+ currentChatId = last.id;
2439
+ freshChatNext = false;
2440
+ sendSystemPromptNext = promptOnResume();
2441
+ saveLastChat(last.id, currentWorkdir, last.title);
2442
+ transcript.log('resume_chat', { id: last.id, title: last.title });
2443
+ console.log(theme.assistant(t('msg.chat_opened')));
2444
+ loadSessionTodos(last.id);
2445
+ await printRestoredHistory(browser, editor, last.id);
2446
+ }
2447
+ catch (e) {
2448
+ console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
2449
+ }
2450
+ finally {
2451
+ if (editor)
2452
+ editor.unlock();
2453
+ }
2454
+ continue;
2455
+ }
2410
2456
  if (lower.startsWith('/resume-id ')) {
2411
2457
  const id = trimmed.slice('/resume-id'.length).trim();
2412
2458
  if (!id) {
@@ -2818,7 +2864,7 @@ async function main() {
2818
2864
  continue;
2819
2865
  }
2820
2866
  const out = await runGit(diffGitArgs(staged), currentWorkdir, 20_000);
2821
- console.log(theme.system(formatDiff(out, { maxLines: 400 })));
2867
+ console.log(theme.system(formatDiff(out, { maxLines: 400 }, t)));
2822
2868
  continue;
2823
2869
  }
2824
2870
  if (lower === '/cost' || lower === '/usage') {
@@ -2832,7 +2878,7 @@ async function main() {
2832
2878
  // best-effort
2833
2879
  }
2834
2880
  }
2835
- console.log(theme.system(renderCost(stats, transcript.file, browser.getLastTokenUsage())));
2881
+ console.log(theme.system(renderCost(stats, transcript.file, browser.getLastTokenUsage(), t)));
2836
2882
  continue;
2837
2883
  }
2838
2884
  if (lower === '/export' || lower.startsWith('/export ')) {
@@ -2907,7 +2953,7 @@ async function main() {
2907
2953
  sshRemote: !!(process.env.SSH_CONNECTION || process.env.SSH_TTY),
2908
2954
  headless: !!headless,
2909
2955
  headlessUaCached: await headlessUACacheReady().catch(() => false),
2910
- })));
2956
+ }, t)));
2911
2957
  continue;
2912
2958
  }
2913
2959
  if (lower === '/permissions' || lower === '/allowed-tools') {
@@ -2916,12 +2962,12 @@ async function main() {
2916
2962
  edit: config.confirmation.edit,
2917
2963
  bash: config.confirmation.bash,
2918
2964
  alwaysConfirm: config.confirmation.alwaysConfirm,
2919
- })));
2965
+ }, t)));
2920
2966
  continue;
2921
2967
  }
2922
2968
  if (lower === '/add-dir' || lower.startsWith('/add-dir ')) {
2923
2969
  const arg = trimmed.slice('/add-dir'.length).trim();
2924
- const res = resolveExtraDir(arg, currentWorkdir);
2970
+ const res = resolveExtraDir(arg, currentWorkdir, t);
2925
2971
  if ('error' in res) {
2926
2972
  console.error(theme.warn(res.error));
2927
2973
  continue;
package/dist/input.js CHANGED
@@ -316,6 +316,9 @@ export class LineEditor {
316
316
  // only the base text (remaining seconds) without restarting the timer.
317
317
  _animating;
318
318
  _dotPhase;
319
+ // When the current animated phase started, so the status can show its
320
+ // elapsed time. 0 when nothing is animating.
321
+ _animStart;
319
322
  _thinkBase;
320
323
  // Explicit lifecycle state of the send ('generating' | 'paused' |
321
324
  // 'settled' | ''). The thinking spinner already shows ACTIVITY, but it does
@@ -348,6 +351,11 @@ export class LineEditor {
348
351
  history;
349
352
  _histIndex;
350
353
  _histDraft;
354
+ // Incremental reverse-search over the history (Ctrl+R), bash-style.
355
+ _searchMode;
356
+ _searchQuery;
357
+ _searchIndex;
358
+ _searchSavedBuf;
351
359
  // Slash-command hints (shown when you type «/»).
352
360
  slashCommands;
353
361
  _suggestCount;
@@ -420,6 +428,7 @@ export class LineEditor {
420
428
  this._dotTimer = null;
421
429
  this._animating = false;
422
430
  this._dotPhase = 0;
431
+ this._animStart = 0;
423
432
  this._thinkBase = '';
424
433
  this._sendState = '';
425
434
  this._lastStatusBlock = '';
@@ -449,6 +458,10 @@ export class LineEditor {
449
458
  this.history = [];
450
459
  this._histIndex = 0;
451
460
  this._histDraft = '';
461
+ this._searchMode = false;
462
+ this._searchQuery = '';
463
+ this._searchIndex = -1;
464
+ this._searchSavedBuf = '';
452
465
  this.slashCommands = commands;
453
466
  this._suggestCount = 0;
454
467
  this.pastes = [];
@@ -504,16 +517,17 @@ export class LineEditor {
504
517
  return this.contextStatus;
505
518
  }
506
519
  // The context text colored by fill level: green (ok), yellow (warn),
507
- // red (high).
520
+ // red (high). A leading marker repeats the level as TEXT, so the signal
521
+ // survives on a terminal without color and for a color-blind operator.
508
522
  _contextText() {
509
523
  const ctx = this._contextForRender();
510
524
  if (!ctx)
511
525
  return '';
512
526
  const level = tokenStatusLevel(this.contextTokens, this.contextLimit);
513
527
  if (level === 'high')
514
- return theme.error(ctx);
528
+ return theme.error('⛔ ' + ctx);
515
529
  if (level === 'warn')
516
- return theme.warn(ctx);
530
+ return theme.warn('▲ ' + ctx);
517
531
  return theme.success(ctx);
518
532
  }
519
533
  // The queue badge ("⧗N") shown before the context counter when messages are
@@ -1109,16 +1123,34 @@ export class LineEditor {
1109
1123
  _startAnimated(baseText) {
1110
1124
  this._thinkBase = theme.brown(stripEllipsis(baseText));
1111
1125
  this._dotPhase = 0;
1112
- this.setStatus(this._thinkBase + this._dots(0) + this._hint());
1126
+ // Remember when the animation began so the status can show how long the
1127
+ // current phase has been running ("12s"): a long reasoning turn otherwise
1128
+ // looks identical to a hung one.
1129
+ this._animStart = Date.now();
1130
+ this.setStatus(this._thinkBase + this._dots(0) + this._elapsedLabel() + this._hint());
1113
1131
  this._stopDots();
1114
1132
  this._dotTimer = setInterval(() => {
1115
1133
  this._dotPhase = (this._dotPhase + 1) % DOTS.length;
1116
- this.setStatus(this._thinkBase + this._dots(this._dotPhase) + this._hint());
1134
+ this.setStatus(this._thinkBase +
1135
+ this._dots(this._dotPhase) +
1136
+ this._elapsedLabel() +
1137
+ this._hint());
1117
1138
  }, 400);
1118
1139
  this._animating = true;
1119
1140
  if (this._dotTimer.unref)
1120
1141
  this._dotTimer.unref();
1121
1142
  }
1143
+ // Elapsed seconds of the current animated phase, formatted for the status
1144
+ // line ("0s", "45s", "2m 05s"). Empty while nothing is animating.
1145
+ _elapsedLabel() {
1146
+ if (!this._animating || !this._animStart)
1147
+ return '';
1148
+ const sec = Math.floor((Date.now() - this._animStart) / 1000);
1149
+ const m = Math.floor(sec / 60);
1150
+ const s = sec % 60;
1151
+ const text = m > 0 ? m + 'm ' + String(s).padStart(2, '0') + 's' : s + 's';
1152
+ return theme.dim(' · ' + text);
1153
+ }
1122
1154
  thinking() {
1123
1155
  this._startThinking();
1124
1156
  }
@@ -1483,6 +1515,93 @@ export class LineEditor {
1483
1515
  }
1484
1516
  this.cursor = Array.from(this.buf).length;
1485
1517
  }
1518
+ // ---------- reverse-search (Ctrl+R) ----------
1519
+ // Start an incremental reverse search, keeping the current buffer aside so
1520
+ // Esc restores it (bash behavior). An empty history leaves the editor
1521
+ // untouched.
1522
+ _searchStart() {
1523
+ if (!this.history.length)
1524
+ return;
1525
+ this._searchMode = true;
1526
+ this._searchQuery = '';
1527
+ this._searchIndex = this.history.length - 1;
1528
+ this._searchSavedBuf = this.buf;
1529
+ this._searchRefresh(true);
1530
+ this._searchStatus();
1531
+ }
1532
+ // Recompute the match for the current query. `resetToNewest` moves the
1533
+ // cursor back to the newest entry (used when the query changes; a repeated
1534
+ // Ctrl+R keeps scanning older entries from the current index).
1535
+ _searchRefresh(resetToNewest = false) {
1536
+ const q = this._searchQuery;
1537
+ if (resetToNewest)
1538
+ this._searchIndex = this.history.length - 1;
1539
+ for (let i = this._searchIndex; i >= 0; i--) {
1540
+ if (q === '' || this.history[i].toLowerCase().includes(q.toLowerCase())) {
1541
+ this._searchIndex = i;
1542
+ return;
1543
+ }
1544
+ }
1545
+ // No match from here: signal it and leave the buffer as-is.
1546
+ this._searchIndex = -1;
1547
+ }
1548
+ // Show the search state in the status line. The matched entry is previewed
1549
+ // in the input line live, so Enter can accept it at once.
1550
+ _searchStatus() {
1551
+ const t = translate(this.locale);
1552
+ const q = this._searchQuery;
1553
+ if (this._searchIndex >= 0) {
1554
+ // Set the matched buffer BEFORE setStatus(): setStatus renders the
1555
+ // block, so the preview must already be in the buffer.
1556
+ this.buf = this.history[this._searchIndex];
1557
+ this.cursor = Array.from(this.buf).length;
1558
+ this.setStatus(t('editor.search_prompt', { q }));
1559
+ }
1560
+ else {
1561
+ this.setStatus(t('editor.search_fail', { q }));
1562
+ }
1563
+ }
1564
+ // One more Ctrl+R: move to the PREVIOUS (older) match.
1565
+ _searchNext() {
1566
+ if (this._searchIndex > 0) {
1567
+ const prev = this._searchIndex;
1568
+ this._searchIndex = prev - 1;
1569
+ this._searchRefresh(false);
1570
+ // If nothing matched above `prev`, keep the previous match.
1571
+ if (this._searchIndex < 0)
1572
+ this._searchIndex = prev;
1573
+ }
1574
+ this._searchStatus();
1575
+ }
1576
+ // Enter: keep the matched buffer and leave search mode.
1577
+ _searchAccept() {
1578
+ this._searchMode = false;
1579
+ this._searchQuery = '';
1580
+ this.setStatus('');
1581
+ this._render();
1582
+ }
1583
+ // Esc / Ctrl+C: leave search mode and restore the buffer typed before it.
1584
+ _searchCancel() {
1585
+ this._searchMode = false;
1586
+ this._searchQuery = '';
1587
+ this.buf = this._searchSavedBuf;
1588
+ this.cursor = Array.from(this.buf).length;
1589
+ this.setStatus('');
1590
+ this._render();
1591
+ }
1592
+ // A character typed during search extends the query; Backspace trims it.
1593
+ _searchInput(ch) {
1594
+ this._searchQuery += ch;
1595
+ this._searchRefresh(true);
1596
+ this._searchStatus();
1597
+ }
1598
+ _searchBackspace() {
1599
+ if (!this._searchQuery.length)
1600
+ return;
1601
+ this._searchQuery = this._searchQuery.slice(0, -1);
1602
+ this._searchRefresh(true);
1603
+ this._searchStatus();
1604
+ }
1486
1605
  _handle(data) {
1487
1606
  let s = data.toString('utf-8');
1488
1607
  // While locked (a long operation is running), swallow all input except
@@ -1504,11 +1623,40 @@ export class LineEditor {
1504
1623
  }
1505
1624
  return;
1506
1625
  }
1507
- if (!this._inPaste && s === ESC) {
1626
+ if (!this._inPaste && s === ESC && !this._searchMode) {
1508
1627
  if (this.onEscape)
1509
1628
  this.onEscape();
1510
1629
  return;
1511
1630
  }
1631
+ // While the reverse search (Ctrl+R) is active, the editor is in a modal
1632
+ // state: every printable key extends the query, Ctrl+R scans older
1633
+ // entries, Enter accepts and Esc/Ctrl+C cancels. Handling this BEFORE the
1634
+ // normal key dispatch keeps the search self-contained.
1635
+ if (this._searchMode) {
1636
+ let i = 0;
1637
+ while (i < s.length) {
1638
+ const code = s.charCodeAt(i);
1639
+ if (code === 18) {
1640
+ this._searchNext();
1641
+ }
1642
+ else if (code === 13 || code === 10) {
1643
+ this._searchAccept();
1644
+ return;
1645
+ }
1646
+ else if (code === 27 || code === 3) {
1647
+ this._searchCancel();
1648
+ return;
1649
+ }
1650
+ else if (code === 127 || code === 8) {
1651
+ this._searchBackspace();
1652
+ }
1653
+ else if (code >= 32) {
1654
+ this._searchInput(s[i]);
1655
+ }
1656
+ i++;
1657
+ }
1658
+ return;
1659
+ }
1512
1660
  while (s.length) {
1513
1661
  if (this._inPaste) {
1514
1662
  const end = s.indexOf(PASTE_END);
@@ -1639,6 +1787,11 @@ export class LineEditor {
1639
1787
  void this._tryClipboard();
1640
1788
  continue;
1641
1789
  }
1790
+ if (code === 18) {
1791
+ // Ctrl+R — incremental reverse search over the input history.
1792
+ this._searchStart();
1793
+ continue;
1794
+ }
1642
1795
  if (code === 23) {
1643
1796
  this._deleteWordLeft();
1644
1797
  this._renderInputOnly();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.50.2",
3
+ "version": "2.51.0",
4
4
  "description": "Terminal coding agent over chat.deepseek.com via Playwright",
5
5
  "type": "module",
6
6
  "bin": {