zames_pro 2.75.6 → 2.77.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/index.js CHANGED
@@ -530,9 +530,18 @@ async function refreshDynamicCommands(workdir) {
530
530
  for (const s of skills) {
531
531
  if (!s.userInvokable)
532
532
  continue;
533
+ // A well-known skill (built-in or bundled methodology) has a localized
534
+ // description keyed by NAME — its SKILL.md frontmatter is agent-facing
535
+ // English, which must not leak into a Russian UI. `t` returns the key
536
+ // itself when it is missing, so an unknown (user-authored) skill falls
537
+ // back to its own description.
538
+ const descKey = 'skill.desc.' + s.name;
539
+ const localized = t(descKey);
533
540
  out.push({
534
541
  name: '/' + s.name,
535
- description: s.description || t('help.cmd.skill'),
542
+ description: localized !== descKey
543
+ ? localized
544
+ : s.description || t('help.cmd.skill'),
536
545
  });
537
546
  }
538
547
  const cmds = await loadCommands(workdir);
@@ -1099,7 +1108,7 @@ async function main() {
1099
1108
  if (editor)
1100
1109
  editor.pause();
1101
1110
  try {
1102
- return await askOperatorConfirm(null, t('perm.ask_sub', { tool: info.tool, reason: info.reason }));
1111
+ return await askOperatorConfirm(null, t('perm.ask_sub', { tool: info.tool, reason: info.reason }), t, currentLocale);
1103
1112
  }
1104
1113
  finally {
1105
1114
  if (editor)
@@ -1901,6 +1910,7 @@ async function main() {
1901
1910
  getDefault: (path) => getByPath(DEFAULTS, path),
1902
1911
  set: (field, raw) => configSetRaw(field, raw),
1903
1912
  reset: (field) => configResetField(field),
1913
+ locale: currentLocale,
1904
1914
  };
1905
1915
  try {
1906
1916
  // Prefer the Ink menu (nicer, resize-aware) when the TUI is enabled;
@@ -1981,6 +1991,7 @@ async function main() {
1981
1991
  const { selectListInk } = await import('./tui/run-select.js');
1982
1992
  picked = await selectListInk({
1983
1993
  title: t('chats.pick_title'),
1994
+ locale: currentLocale,
1984
1995
  items: chats.map((c, i) => ({
1985
1996
  value: c.id,
1986
1997
  label: String(i + 1).padStart(2, ' ') + '. ' + c.title,
@@ -2014,6 +2025,7 @@ async function main() {
2014
2025
  const { selectListInk } = await import('./tui/run-select.js');
2015
2026
  picked = await selectListInk({
2016
2027
  title: t('sessions.pick_title'),
2028
+ locale: currentLocale,
2017
2029
  items: sessions.map((s, i) => ({
2018
2030
  value: s.id,
2019
2031
  label: String(i + 1).padStart(2, ' ') +
@@ -2582,8 +2594,9 @@ async function main() {
2582
2594
  items: models.map((m) => ({
2583
2595
  value: m.id,
2584
2596
  label: m.name || m.id,
2585
- detail: m.id === config.qwen.model ? '← текущая' : m.id,
2597
+ detail: m.id === config.qwen.model ? t('tui.list.current') : m.id,
2586
2598
  })),
2599
+ locale: currentLocale,
2587
2600
  });
2588
2601
  }
2589
2602
  catch {
@@ -2775,6 +2788,13 @@ async function main() {
2775
2788
  const intervalStr = sp === -1 ? rest : rest.slice(0, sp);
2776
2789
  const taskStr = sp === -1 ? '' : rest.slice(sp + 1).trim();
2777
2790
  const ms = parseInterval(intervalStr);
2791
+ if (!ms && intervalStr) {
2792
+ // Say WHY the job was not added. A bare `10` is not a typo the agent
2793
+ // can spot: it IS a valid interval (10 SECONDS), while an operator who
2794
+ // meant ten minutes needs the `m` suffix.
2795
+ console.error(theme.error(t('sched.interval_typo', { v: intervalStr })));
2796
+ continue;
2797
+ }
2778
2798
  if (!ms || !taskStr) {
2779
2799
  console.error(theme.error(t('sched.loop_usage')));
2780
2800
  continue;
@@ -2795,6 +2815,12 @@ async function main() {
2795
2815
  when: formatInterval(ms),
2796
2816
  task: taskStr,
2797
2817
  })));
2818
+ // Tell the operator WHEN it will actually run. A scheduled task never
2819
+ // interrupts a running turn: it is queued and starts only when the
2820
+ // agent is idle (or right after the current task). The bare "added"
2821
+ // line made a first /loop look like a no-op, so the operator re-sent the
2822
+ // task by hand (the very report this line answers).
2823
+ console.log(theme.dim(t('sched.added_hint')));
2798
2824
  continue;
2799
2825
  }
2800
2826
  if (lower === '/cron' || lower.startsWith('/cron ')) {
@@ -2824,6 +2850,7 @@ async function main() {
2824
2850
  when: cronExpr + ' (in ~' + nextIn + ')',
2825
2851
  task: taskStr,
2826
2852
  })));
2853
+ console.log(theme.dim(t('sched.added_hint')));
2827
2854
  continue;
2828
2855
  }
2829
2856
  if (lower === '/jobs' || lower.startsWith('/jobs ')) {
@@ -3289,14 +3316,15 @@ async function main() {
3289
3316
  console.log(theme.system(mod.commands.formatDiff(out, { maxLines: 400 }, t)));
3290
3317
  continue;
3291
3318
  }
3292
- if (lower === '/diffstat') {
3319
+ if (lower === '/diffstat' || lower.startsWith('/diffstat ')) {
3320
+ const staged = lower.indexOf('--staged') !== -1;
3293
3321
  const { runGitArgs } = await import('./gitTools.js');
3294
3322
  const probe = await runGitArgs(['rev-parse', '--is-inside-work-tree'], currentWorkdir, 5000);
3295
3323
  if (probe.trim() !== 'true') {
3296
3324
  console.error(theme.error(t('diff.not_repo')));
3297
3325
  continue;
3298
3326
  }
3299
- const out = await runGitArgs(['diff', '--stat'], currentWorkdir, 20_000);
3327
+ const out = await runGitArgs(mod.commands.diffStatGitArgs(staged), currentWorkdir, 20_000);
3300
3328
  console.log(theme.system(mod.commands.formatDiffStat(out, { maxLines: 60 }, t)));
3301
3329
  continue;
3302
3330
  }
package/dist/input.js CHANGED
@@ -1601,7 +1601,13 @@ export class LineEditor {
1601
1601
  // A chunk may end in the MIDDLE of a PASTE marker (`ESC[2`, `ESC[20`, …).
1602
1602
  // Hold that tail back so the next chunk completes it; otherwise the
1603
1603
  // fragment is inserted as literal text and the paste is not bracketed.
1604
- const held = splitPasteTail(s);
1604
+ // EXCEPTION: once inside a bracketed paste the body is appended VERBATIM
1605
+ // to _pasteBuf (it is not parsed by this loop), so a held tail would be
1606
+ // re-attached to `s` and inserted as text BEFORE the body — the tail
1607
+ // (`1~`) leaked into the input line and was not part of the pasted text.
1608
+ const held = this._inPaste
1609
+ ? { head: s, tail: null }
1610
+ : splitPasteTail(s);
1605
1611
  if (held.tail) {
1606
1612
  this._ctrlTail = held.tail;
1607
1613
  s = held.head;
package/dist/mcp.js CHANGED
@@ -183,7 +183,15 @@ export function truncateMcpResult(s, limit = MCP_RESULT_LIMIT) {
183
183
  const str = String(s);
184
184
  if (str.length <= limit)
185
185
  return str;
186
- const head = str.slice(0, limit);
186
+ let head = str.slice(0, limit);
187
+ // Never leave a lone HIGH surrogate at the cut: an emoji / astral CJK char
188
+ // sits on two UTF-16 units, and cutting between them hands the model a
189
+ // broken code point (same rule as truncateToolResult / capReport).
190
+ if (head.length > 0) {
191
+ const last = head.charCodeAt(head.length - 1);
192
+ if (last >= 0xd800 && last <= 0xdbff)
193
+ head = head.slice(0, -1);
194
+ }
187
195
  // Prefer a line boundary so a cut does not split a line in half.
188
196
  const nl = head.lastIndexOf(String.fromCharCode(10));
189
197
  const cut = nl > limit * 0.5 ? head.slice(0, nl + 1) : head;
@@ -280,6 +280,43 @@ export function extractTokenUsage(body) {
280
280
  }
281
281
  }
282
282
  }
283
+ // An OpenAI-compatible body reports the turn size as usage.total_tokens.
284
+ const usage = o.usage;
285
+ if (usage && typeof usage === 'object') {
286
+ consider(usage.total_tokens);
287
+ }
288
+ }
289
+ if (found !== null)
290
+ return found;
291
+ // A PLAIN JSON body (not SSE): /api/v0/chat/history_messages answers this
292
+ // way, and every message carries the running counter. Without this a
293
+ // resumed chat showed no context size until the next completion. The
294
+ // payload is the whole history, so the LAST value wins.
295
+ try {
296
+ const root = JSON.parse(body);
297
+ const visit = (node) => {
298
+ if (node == null || typeof node !== 'object')
299
+ return;
300
+ if (Array.isArray(node)) {
301
+ for (const it of node)
302
+ visit(it);
303
+ return;
304
+ }
305
+ const o = node;
306
+ consider(o.accumulated_token_usage);
307
+ const usage = o.usage;
308
+ if (usage && typeof usage === 'object')
309
+ consider(usage.total_tokens);
310
+ for (const key of Object.keys(o)) {
311
+ const v = o[key];
312
+ if (v && typeof v === 'object')
313
+ visit(v);
314
+ }
315
+ };
316
+ visit(root);
317
+ }
318
+ catch {
319
+ // not plain JSON — nothing more to read
283
320
  }
284
321
  return found;
285
322
  }
@@ -1396,7 +1396,8 @@ export class QwenBrowser {
1396
1396
  attempt: rateLimitRetries,
1397
1397
  max: this.maxRateLimitRetries,
1398
1398
  }));
1399
- await this._sleepInterruptible(this.rateLimitWaitMs);
1399
+ if (await this._sleepInterruptible(this.rateLimitWaitMs))
1400
+ return '(прервано пользователем)';
1400
1401
  continue;
1401
1402
  }
1402
1403
  if (e instanceof QwenServerBusyError || /server busy/i.test(msg)) {
@@ -1408,7 +1409,8 @@ export class QwenBrowser {
1408
1409
  attempt: serverBusyRetries,
1409
1410
  max: this.maxServerBusyRetries,
1410
1411
  }));
1411
- await this._sleepInterruptible(this.serverBusyWaitMs);
1412
+ if (await this._sleepInterruptible(this.serverBusyWaitMs))
1413
+ return '(прервано пользователем)';
1412
1414
  continue;
1413
1415
  }
1414
1416
  if (e instanceof QwenIncompleteError) {
@@ -1419,7 +1421,8 @@ export class QwenBrowser {
1419
1421
  attempt: incompleteRetries,
1420
1422
  max: this.maxIncompleteRetries,
1421
1423
  })));
1422
- await this._sleepInterruptible(this.incompleteWaitMs);
1424
+ if (await this._sleepInterruptible(this.incompleteWaitMs))
1425
+ return '(прервано пользователем)';
1423
1426
  continue;
1424
1427
  }
1425
1428
  // A LOST page/context ("Target page, context or browser has been
@@ -186,10 +186,17 @@ export function parseQwenHistory(json) {
186
186
  }
187
187
  }
188
188
  // `content_list` (phase-split output) is used when `content` is empty.
189
+ // Each part carries its PHASE, and the reasoning phase must never leak
190
+ // into the restored dialogue as the answer (the live SSE parser skips
191
+ // `thinking_summary` for the same reason).
189
192
  if (!text && Array.isArray(m.content_list)) {
190
193
  for (const part of m.content_list) {
191
- if (part && typeof part.content === 'string')
192
- text += part.content;
194
+ if (!part || typeof part.content !== 'string')
195
+ continue;
196
+ const phase = String(part.phase ?? part.type ?? '');
197
+ if (/think|reason/i.test(phase))
198
+ continue;
199
+ text += part.content;
193
200
  }
194
201
  }
195
202
  // `reasoning_content` is the THINK phase and must never leak into the
package/dist/run-task.js CHANGED
@@ -2,12 +2,15 @@ import { theme } from './theme.js';
2
2
  import { withGoal, mergeMessages, isSlashCommand, formatDuration, } from './commands.js';
3
3
  import { buildEvaluatorPrompt, buildContinuationDirective, parseGoalVerdict, GOAL_MAX_TURNS, } from './goal.js';
4
4
  import { formatCompactTokens, pasteReplacement, expandPastes } from './input.js';
5
+ import { DEFAULT_LOCALE } from './i18n.js';
5
6
  import { providerAskDeadlineMs } from './config.js';
6
7
  // Ask the operator to approve a tool call flagged by an `ask` permission rule
7
8
  // (C1). While the LineEditor owns the terminal it must be paused first, or the
8
9
  // confirmation prompt and the input line fight for the same keys. A non-TTY run
9
10
  // has no way to answer, so it DENIES (never silently allows a guarded call).
10
- export async function askOperatorConfirm(editor, question, t) {
11
+ export async function askOperatorConfirm(editor, question, t,
12
+ /** Language of the Ink dialog's key-hint bar (the labels use `t`). */
13
+ locale = DEFAULT_LOCALE) {
11
14
  if (!(process.stdin.isTTY && process.stdout.isTTY))
12
15
  return false;
13
16
  // Prefer the Ink modal when the TUI owns the terminal: it keeps the layout
@@ -23,6 +26,9 @@ export async function askOperatorConfirm(editor, question, t) {
23
26
  message: question,
24
27
  yesLabel: tr('confirm.yes'),
25
28
  noLabel: tr('confirm.no'),
29
+ // The dialog's key-hint bar is operator text: it must follow the SAME
30
+ // language as the labels above, not a hardcoded one.
31
+ locale,
26
32
  });
27
33
  }
28
34
  finally {
@@ -386,7 +392,7 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
386
392
  // C1: a rule with action "ask". Show the reason and wait for the
387
393
  // operator. In non-TTY this denies.
388
394
  ui.warning(t('perm.ask', { tool: info.tool, reason: info.reason }));
389
- const ok = await askOperatorConfirm(editor || null, t('perm.confirm'), t);
395
+ const ok = await askOperatorConfirm(editor || null, t('perm.confirm'), t, locale);
390
396
  ui.warning(ok ? t('perm.allowed') : t('perm.denied'));
391
397
  transcript?.log('permission_ask', {
392
398
  tool: info.tool,
package/dist/scheduler.js CHANGED
@@ -16,8 +16,17 @@ export function parseInterval(text) {
16
16
  : unit.startsWith('m')
17
17
  ? 60_000
18
18
  : 1000;
19
- return n * mult;
19
+ const ms = n * mult;
20
+ // setInterval clamps its delay to a 32-bit signed int (~24.8 days) and Node
21
+ // warns on every tick, while the scheduler computes a fire time the timer
22
+ // can never reach — a huge value is a typo, not a schedule. Reject it here
23
+ // so the operator gets the usage/typo message instead of a silent dead job.
24
+ if (ms > MAX_TIMER_MS)
25
+ return null;
26
+ return ms;
20
27
  }
28
+ /** Largest delay setInterval accepts (32-bit signed milliseconds). */
29
+ export const MAX_TIMER_MS = 2_147_483_647;
21
30
  // Render an interval back to the shortest human form (used in listings).
22
31
  // Non-finite input (a cron job whose next fire could not be computed sets
23
32
  // nextAt to Infinity) renders as "-" instead of "Infinityh".
@@ -53,7 +62,10 @@ function parseCronField(field, min, max) {
53
62
  }
54
63
  else {
55
64
  lo = Number(range);
56
- hi = lo;
65
+ // `N/step` (a start with a step, no range) is valid cron: it means from
66
+ // N up to the field maximum. Treating it as the single value N made a
67
+ // normal `5/20 * * * *` expression invalid.
68
+ hi = stepStr === undefined ? lo : max;
57
69
  }
58
70
  if (!Number.isInteger(lo) || !Number.isInteger(hi))
59
71
  return null;
@@ -128,6 +140,13 @@ export function formatJobLine(job, now) {
128
140
  const when = job.kind === 'loop'
129
141
  ? 'every ' + formatInterval(job.intervalMs || 0)
130
142
  : job.cron || '';
143
+ // A cron job whose next fire could not be computed stores Infinity (see
144
+ // due()). "next ~-" (formatInterval maps a non-finite value to '-') read
145
+ // like a normal schedule, so the job looked healthy while it could never
146
+ // fire again.
147
+ if (!Number.isFinite(job.nextAt)) {
148
+ return ' #' + job.id + ' [' + when + ', NEVER fires again] ' + job.task;
149
+ }
131
150
  const nextIn = formatInterval(Math.max(0, job.nextAt - now));
132
151
  return ' #' + job.id + ' [' + when + ', next ~' + nextIn + '] ' + job.task;
133
152
  }
@@ -142,7 +161,13 @@ export class Scheduler {
142
161
  kind: 'loop',
143
162
  task,
144
163
  intervalMs,
145
- nextAt: now + intervalMs,
164
+ // The FIRST run is due at once: `/loop 30m <task>` means "start now,
165
+ // then repeat every 30m". Scheduling the first fire one full interval
166
+ // ahead left the operator staring at half an hour of silence right
167
+ // after the command — it read as "the loop did nothing". `due()`
168
+ // reschedules the job a full interval ahead AFTER it fires, so this
169
+ // does not become a busy loop.
170
+ nextAt: now,
146
171
  createdAt: now,
147
172
  };
148
173
  this.jobs.push(job);
package/dist/shell.js CHANGED
@@ -84,10 +84,18 @@ function formatExecResult(err, stdout, stderr, timeout, okExitCodes) {
84
84
  return parts.join('\n');
85
85
  }
86
86
  export function runShell(workdir, command, timeout = 30_000, signal) {
87
+ // The timeout arrives from the MODEL's tool arguments, and exec validates it
88
+ // SYNCHRONOUSLY: a negative, fractional or non-numeric value throws
89
+ // ERR_OUT_OF_RANGE out of runShell instead of becoming a tool error string.
90
+ // Normalize it to the unsigned-integer contract (falling back to the
91
+ // default) so a sloppy call cannot crash the Bash tool.
92
+ const safeTimeout = typeof timeout === 'number' && Number.isFinite(timeout) && timeout >= 0
93
+ ? Math.floor(timeout)
94
+ : 30_000;
87
95
  return new Promise((resolve) => {
88
96
  const options = {
89
97
  cwd: workdir,
90
- timeout,
98
+ timeout: safeTimeout,
91
99
  maxBuffer: 1024 * 1024 * 8,
92
100
  windowsHide: true,
93
101
  env: { ...process.env },
@@ -102,7 +110,7 @@ export function runShell(workdir, command, timeout = 30_000, signal) {
102
110
  options.shell = '/bin/sh';
103
111
  }
104
112
  exec(command, options, (err, stdout, stderr) => {
105
- resolve(formatExecResult(err, stdout, stderr, timeout, []));
113
+ resolve(formatExecResult(err, stdout, stderr, safeTimeout, []));
106
114
  });
107
115
  });
108
116
  }
package/dist/subagent.js CHANGED
@@ -23,9 +23,16 @@ export const SUBAGENT_REPORT_CAP = 20_000;
23
23
  export function capReport(text, cap = SUBAGENT_REPORT_CAP) {
24
24
  if (text.length <= cap)
25
25
  return text;
26
- return (text.slice(0, cap) +
26
+ // Never split a surrogate PAIR (an emoji / astral CJK char): the high half
27
+ // alone is a lone surrogate. The report becomes the parent chat's tool
28
+ // result, so the same rule as truncateToolResult() applies.
29
+ let cut = cap;
30
+ const c = text.charCodeAt(cut - 1);
31
+ if (c >= 0xd800 && c <= 0xdbff)
32
+ cut--;
33
+ return (text.slice(0, cut) +
27
34
  '\n… [report truncated: ' +
28
- (text.length - cap) +
35
+ (text.length - cut) +
29
36
  ' chars omitted]');
30
37
  }
31
38
  /**
@@ -171,8 +178,14 @@ export function createSubagentRunner(opts) {
171
178
  hookOut = await runLifecycleHooks(loadHooks(workdir), 'SubagentStop', workdir, { type: req.type, ok: failed ? 'false' : 'true' });
172
179
  }
173
180
  catch { }
174
- if (failed)
175
- return { ok: false, text: failed };
181
+ // A failed subagent must NOT lose the hook's stdout: a SubagentStop hook
182
+ // that reports the broken state is most useful exactly when the run died.
183
+ if (failed) {
184
+ return {
185
+ ok: false,
186
+ text: capReport(hookOut ? failed + '\n\n' + hookOut : failed),
187
+ };
188
+ }
176
189
  return {
177
190
  ok: true,
178
191
  text: capReport(hookOut ? report + '\n\n' + hookOut : report),
@@ -79,5 +79,18 @@ export function looksLikeToolError(result) {
79
79
  head.startsWith('missing required argument') ||
80
80
  head.startsWith('failed') ||
81
81
  head.includes('command failed') ||
82
- head.includes('exit code'));
82
+ head.includes('exit code') ||
83
+ // NOT every failure is a THROWN error: a PreToolUse hook, the permission
84
+ // policy and the operator's denial BLOCK the call and return their message
85
+ // as the result; an unknown tool and an aborted/timed-out run are reported
86
+ // in words too. Those calls never did their work, so a green check next to
87
+ // them lied to the operator.
88
+ head.startsWith('blocked by') ||
89
+ head.startsWith('denied by') ||
90
+ head.startsWith('unknown tool') ||
91
+ head.startsWith('subagents are not available') ||
92
+ head.startsWith('subagent failed') ||
93
+ head.startsWith('subagent error') ||
94
+ head.includes('process killed') ||
95
+ head.startsWith('(aborted by the operator)'));
83
96
  }
@@ -12,6 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  // callbacks (the SAME ones the legacy menu takes), so the logic is shared.
13
13
  import { useMemo, useState } from 'react';
14
14
  import { Box, Text, useInput, useApp, useWindowSize } from 'ink';
15
+ import { translate } from '../i18n.js';
15
16
  import { Checkbox, Select, UI, HintBar } from './widgets.js';
16
17
  /** Strip ANSI to measure a line's visible width. */
17
18
  function stripAnsi(s) {
@@ -37,8 +38,11 @@ function displayValue(field, value, t) {
37
38
  return String(value);
38
39
  }
39
40
  export function ConfigMenuApp(opts) {
40
- const { fields, t, get, set, reset, getDefault } = opts;
41
+ const { fields, t, get, set, reset, getDefault, locale = 'en' } = opts;
41
42
  const { exit } = useApp();
43
+ // The hint bar is operator text: it follows the SAME locale the labels use
44
+ // (a hardcoded Russian bar showed up in an English UI).
45
+ const hint = translate(locale);
42
46
  const { columns, rows } = useWindowSize();
43
47
  const [cursor, setCursor] = useState(0);
44
48
  const [filterQuery, setFilterQuery] = useState('');
@@ -327,11 +331,11 @@ export function ConfigMenuApp(opts) {
327
331
  items.push(_jsx(Text, { color: UI.dim, children: ' ' + t('cfg.menu.no_match') }, "none"));
328
332
  }
329
333
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: UI.title, children: t('cfg.menu.title') }), _jsx(Text, { color: UI.dim, children: (visible.length ? cursor + 1 : 0) + '/' + visible.length })] }), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(Math.max(10, (columns || 80) - 6)) }), items, _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(Math.max(10, (columns || 80) - 6)) }), editing ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.text, children: t('cfg.menu.edit_hint') }), _jsx(Text, { color: UI.accent, children: '❯ ' + editBuf })] })) : filterMode ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.text, children: t('cfg.menu.filter_hint') }), _jsx(Text, { color: UI.accent, children: '❯ ' + filterQuery })] })) : message ? (_jsx(Text, { color: UI.ok, children: message })) : filterQuery ? (_jsx(Text, { color: UI.dim, children: t('cfg.menu.filter_active', { q: filterQuery }) })) : null, _jsx(HintBar, { hints: [
330
- ['↑/↓', 'выбор'],
331
- ['←/→', 'изменить'],
332
- ['Enter', 'правка'],
333
- ['/', 'поиск'],
334
- ['d', 'сброс'],
335
- ['q', 'выход'],
334
+ ['↑/↓', hint('tui.hint.select')],
335
+ ['←/→', hint('tui.hint.change')],
336
+ ['Enter', hint('tui.hint.edit')],
337
+ ['/', hint('tui.hint.search')],
338
+ ['d', hint('tui.hint.reset')],
339
+ ['q', hint('tui.hint.quit')],
336
340
  ] })] }));
337
341
  }
@@ -5,14 +5,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  import { useState } from 'react';
6
6
  import { Box, Text, useInput, useApp } from 'ink';
7
7
  import { UI, HintBar } from './widgets.js';
8
+ import { translate } from '../i18n.js';
8
9
  function Button({ label, focused, danger, }) {
9
10
  const color = danger ? UI.err : UI.on;
10
11
  return (_jsxs(Text, { children: [focused ? _jsx(Text, { color: color, children: '▸ ' }) : _jsx(Text, { children: ' ' }), _jsx(Text, { color: focused ? color : UI.dim, bold: focused, children: label })] }));
11
12
  }
12
- export function ConfirmDialog({ title, message, yesLabel, noLabel, onAnswer, }) {
13
+ export function ConfirmDialog({ title, message, yesLabel, noLabel, onAnswer, locale = 'en', }) {
13
14
  // Focus starts on "no" — a stray Enter must not confirm a destructive action.
14
15
  const [yes, setYes] = useState(false);
15
16
  const { exit } = useApp();
17
+ const t = translate(locale);
16
18
  const answer = (ok) => {
17
19
  onAnswer(ok);
18
20
  exit();
@@ -35,8 +37,8 @@ export function ConfirmDialog({ title, message, yesLabel, noLabel, onAnswer, })
35
37
  }
36
38
  });
37
39
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.warn, paddingX: 2, paddingY: 0, children: [_jsx(Text, { color: UI.warn, bold: true, children: '⚠ ' + title }), message ? _jsx(Text, { color: UI.text, children: message }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Button, { label: yesLabel, focused: yes, danger: true }), _jsx(Text, { children: ' ' }), _jsx(Button, { label: noLabel, focused: !yes })] }), _jsx(HintBar, { hints: [
38
- ['←/→', 'выбор'],
39
- ['Enter', 'ok'],
40
+ ['←/→', t('tui.hint.select')],
41
+ ['Enter', t('tui.hint.ok')],
40
42
  ['y/n', ''],
41
43
  ] })] }));
42
44
  }
@@ -5,9 +5,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  import { useMemo, useState } from 'react';
6
6
  import { Box, Text, useInput, useApp, useWindowSize } from 'ink';
7
7
  import { UI, HintBar } from './widgets.js';
8
- export function SelectList({ title, items, initial, onSelect, }) {
8
+ import { translate } from '../i18n.js';
9
+ export function SelectList({ title, items, initial, locale = 'en', onSelect, }) {
9
10
  const { exit } = useApp();
10
11
  const { rows, columns } = useWindowSize();
12
+ const t = translate(locale);
11
13
  const start = Math.max(0, items.findIndex((it) => it.value === initial));
12
14
  const [cursor, setCursor] = useState(start);
13
15
  const maxRows = Math.max(3, (rows || 24) - 7);
@@ -47,8 +49,8 @@ export function SelectList({ title, items, initial, onSelect, }) {
47
49
  const sel = idx === cursor;
48
50
  return (_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, children: sel ? '❯ ' : ' ' }), _jsx(Text, { color: sel ? UI.accent : undefined, bold: sel, children: it.label }), it.detail ? _jsx(Text, { color: UI.dim, children: ' ' + it.detail }) : null] }, it.value));
49
51
  }), _jsx(HintBar, { hints: [
50
- ['↑/↓', 'выбор'],
51
- ['Enter', 'ok'],
52
- ['Esc', 'отмена'],
52
+ ['↑/↓', t('tui.hint.select')],
53
+ ['Enter', t('tui.hint.ok')],
54
+ ['Esc', t('tui.hint.cancel')],
53
55
  ] })] }));
54
56
  }
@@ -254,6 +254,10 @@ export class TuiEditor {
254
254
  this.printAbove(theme.userBar('▌ ') +
255
255
  theme.user(wrapToWidth(display, contentWidth() - 2)));
256
256
  this.store.set({ pastes: [] });
257
+ // Sending a message pins the log back to the newest content: the operator
258
+ // expects their message (and the answer) at the BOTTOM, so a viewport left
259
+ // scrolled-up (PgUp / wheel) must snap to scroll=0 on submit.
260
+ this.store.set({ scroll: 0 });
257
261
  this.onSubmit?.(text, atts);
258
262
  if (this.busy)
259
263
  this.thinking();
package/dist/tui/edit.js CHANGED
@@ -82,35 +82,26 @@ export function deleteWordLeft(s) {
82
82
  return { ...s, buf: chars.join(''), cursor: i };
83
83
  }
84
84
  /**
85
- * Delete the word on the RIGHT of the cursor (Ctrl+Delete / Alt+Delete).
86
- *
87
- * FALLBACK: at the END of the line there is no word to the right, so the key
88
- * did NOTHING and the operator (whose terminal sends the standard xterm
89
- * Ctrl+Delete) read it as "Ctrl+Delete does not delete the word before the
90
- * cursor". Deleting the word on the LEFT there matches what other terminal
91
- * editors do and what the operator expects, while keeping the standard
92
- * right-delete when the cursor is mid-line.
85
+ * Delete the word on the RIGHT of the cursor (Ctrl+Delete / Alt+Delete):
86
+ * skip the whitespace after the cursor, then delete up to the end of the next
87
+ * word — the mirror of deleteWordLeft(). At the end of the line there is
88
+ * nothing to the right, so the buffer is left UNCHANGED (never a surprise
89
+ * leftward delete), matching the legacy LineEditor's _deleteWordRight().
93
90
  */
94
91
  export function deleteWordRight(s) {
95
92
  const chars = charsOf(s);
96
- // Nothing to the right (end of buffer or only whitespace to the newline):
97
- // delete the word on the LEFT instead of doing nothing.
98
- let probe = s.cursor;
99
- while (probe < chars.length &&
100
- chars[probe] !== NL &&
101
- /\s/.test(chars[probe])) {
102
- probe++;
103
- }
104
- if (probe >= chars.length || chars[probe] === NL) {
105
- return deleteWordLeft(s);
106
- }
93
+ // Stop at a NEWLINE: it is whitespace for /\s/ but must never be swallowed
94
+ // by a word delete (the old code guarded this; without the guard the key
95
+ // ate the line break).
107
96
  let i = s.cursor;
108
- while (i < chars.length && /\s/.test(chars[i]))
97
+ while (i < chars.length && chars[i] !== NL && /\s/.test(chars[i]))
109
98
  i++;
110
- while (i < chars.length && !/\s/.test(chars[i]))
99
+ while (i < chars.length && chars[i] !== NL && !/\s/.test(chars[i]))
111
100
  i++;
101
+ if (i === s.cursor)
102
+ return s;
112
103
  chars.splice(s.cursor, i - s.cursor);
113
- return { ...s, buf: chars.join('') };
104
+ return { ...s, buf: chars.join(''), cursor: s.cursor };
114
105
  }
115
106
  /** Delete from the cursor to the end of the current LOGICAL line (Ctrl+K). */
116
107
  export function killToLineEnd(s) {
package/dist/tui/keys.js CHANGED
@@ -35,8 +35,8 @@ export function parseCsiU(raw) {
35
35
  if (cp === 13 && (ctrl || alt))
36
36
  return { type: 'newline' };
37
37
  if (cp === 3) {
38
- // Ctrl/Alt+Delete deletes the word on the LEFT (see dispatchKey).
39
- return ctrl || alt ? { type: 'deleteWordLeft' } : { type: 'delete' };
38
+ // Ctrl/Alt+Delete deletes the word on the RIGHT (standard editor binding).
39
+ return ctrl || alt ? { type: 'deleteWordRight' } : { type: 'delete' };
40
40
  }
41
41
  if (cp === 127) {
42
42
  return ctrl || alt ? { type: 'deleteWordLeft' } : { type: 'backspace' };
@@ -85,11 +85,11 @@ export function dispatchKey(input, key, ctx, isVisualEdge) {
85
85
  return { type: 'wordLeft' };
86
86
  if (key.rightArrow)
87
87
  return { type: 'wordRight' };
88
- // Ctrl+Delete deletes the word BEFORE the cursor (the operator's binding:
89
- // "Ctrl+Del must delete the word before the cursor"). Ctrl+Backspace does
90
- // the same. Plain Delete still deletes one char to the RIGHT.
88
+ // Ctrl+Delete deletes the word AFTER the cursor (the standard editor
89
+ // binding, same as the legacy LineEditor). Ctrl+Backspace deletes the word
90
+ // BEFORE it. Plain Delete still deletes one char to the right.
91
91
  if (key.delete)
92
- return { type: 'deleteWordLeft' };
92
+ return { type: 'deleteWordRight' };
93
93
  if (key.backspace)
94
94
  return { type: 'deleteWordLeft' };
95
95
  if (key.home)
@@ -143,16 +143,16 @@ export function dispatchKey(input, key, ctx, isVisualEdge) {
143
143
  if (input === 'f')
144
144
  return { type: 'wordRight' };
145
145
  // VSCode sends Ctrl+Delete as `ESC d` (which Ink reports as Alt+d).
146
- // The operator expects it to delete the word BEFORE the cursor.
146
+ // Standard editor binding: delete the word AFTER the cursor.
147
147
  if (input === 'd')
148
- return { type: 'deleteWordLeft' };
148
+ return { type: 'deleteWordRight' };
149
149
  if (key.backspace)
150
150
  return { type: 'deleteWordLeft' };
151
- // Alt/Ctrl+Delete also deletes the word BEFORE the cursor (the operator's
152
- // binding, consistent with ESC d): some terminals encode Ctrl+Delete as
153
- // meta+Delete rather than ESC d.
151
+ // Alt/Ctrl+Delete also deletes the word AFTER the cursor (consistent with
152
+ // ESC d): some terminals encode Ctrl+Delete as meta+Delete rather than
153
+ // ESC d.
154
154
  if (key.delete)
155
- return { type: 'deleteWordLeft' };
155
+ return { type: 'deleteWordRight' };
156
156
  return null;
157
157
  }
158
158
  // ---- LF as a bare newline (Ctrl+J) ----