zames_pro 2.74.0 → 2.75.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +24 -0
  3. package/dist/attachments.js +6 -1
  4. package/dist/commands.js +0 -1
  5. package/dist/config.js +7 -7
  6. package/dist/i18n/common.js +0 -8
  7. package/dist/i18n/config.js +0 -1
  8. package/dist/i18n/help.js +8 -4
  9. package/dist/i18n/qwen.js +0 -4
  10. package/dist/i18n/self-review.js +2 -18
  11. package/dist/i18n/spinner.js +5 -0
  12. package/dist/index.js +30 -43
  13. package/dist/input/suggest.js +19 -4
  14. package/dist/theme.js +9 -0
  15. package/dist/tui/ConfigMenu.js +44 -11
  16. package/dist/tui/FullScreenApp.js +44 -11
  17. package/dist/tui/InputLine.js +52 -19
  18. package/dist/tui/SelectList.js +6 -2
  19. package/dist/tui/StatusBar.js +66 -36
  20. package/dist/tui/TuiApp.js +3 -2
  21. package/dist/tui/TuiEditor.js +44 -4
  22. package/dist/tui/edit.js +21 -1
  23. package/dist/tui/keys.js +46 -2
  24. package/dist/tui/store.js +1 -1
  25. package/dist/tui/useMouseWheel.js +80 -29
  26. package/dist/tui/widgets.js +6 -1
  27. package/package.json +2 -1
  28. package/scripts/ctd-live-driver.mts +51 -0
  29. package/scripts/ctd-live.tsx +33 -0
  30. package/scripts/escd-live-driver.mts +37 -0
  31. package/scripts/inline-status-driver.mts +31 -0
  32. package/scripts/inline-status.tsx +33 -0
  33. package/scripts/mouse-churn.mts +34 -0
  34. package/scripts/qwen-models-smoke.mts +117 -0
  35. package/scripts/ru-shot.mts +20 -0
  36. package/scripts/tui-comp-shot.mts +63 -0
  37. package/scripts/tui-components.tsx +62 -0
  38. package/scripts/tui-config-driver.mts +82 -0
  39. package/scripts/tui-config-probe.tsx +74 -0
  40. package/scripts/tui-edit-driver.mts +100 -0
  41. package/scripts/tui-edit-probe.tsx +38 -0
  42. package/scripts/tui-editor-probe.tsx +24 -0
  43. package/scripts/tui-input-driver.mts +54 -0
  44. package/scripts/tui-input-shot.tsx +59 -0
  45. package/scripts/tui-log-driver.mts +35 -0
  46. package/scripts/tui-log-shot.tsx +48 -0
  47. package/scripts/tui-longpaste-driver.mts +56 -0
  48. package/scripts/tui-longpaste.tsx +35 -0
  49. package/scripts/tui-manyselect-shot.mts +34 -0
  50. package/scripts/tui-manyselect.tsx +17 -0
  51. package/scripts/tui-mouse-churn-probe.tsx +28 -0
  52. package/scripts/tui-paste-driver.mts +88 -0
  53. package/scripts/tui-paste-kinds.tsx +76 -0
  54. package/scripts/tui-pastetest.mts +38 -0
  55. package/scripts/tui-repro.tsx +64 -0
  56. package/scripts/tui-resize.mts +70 -0
  57. package/scripts/tui-scroll-driver.mts +53 -0
  58. package/scripts/tui-scroll-probe.tsx +46 -0
  59. package/scripts/tui-shot.mts +106 -0
  60. package/scripts/tui-status-driver.mts +24 -0
  61. package/scripts/tui-status-shot.tsx +27 -0
  62. package/scripts/tui-submit.tsx +57 -0
  63. package/scripts/tui-wheel-driver.mts +61 -0
  64. package/scripts/tui-wheel-probe.tsx +48 -0
  65. package/scripts/tui-widgets-driver.mts +20 -0
  66. package/scripts/tui-widgets.tsx +44 -0
@@ -18,7 +18,7 @@ import { InputLine } from './InputLine.js';
18
18
  import { contentWidth } from '../width.js';
19
19
  import { translate } from '../i18n.js';
20
20
  import { suggestRowCount } from '../input/suggest.js';
21
- import { inputBoxRows } from '../input/layout.js';
21
+ import { inputBoxRows, truncateToWidth, visLen } from '../input/layout.js';
22
22
  import { useMouseWheel } from './useMouseWheel.js';
23
23
  /** The header bar: title, version, working directory. */
24
24
  function Header({ store }) {
@@ -28,9 +28,22 @@ function Header({ store }) {
28
28
  .filter(Boolean)
29
29
  .join(' ');
30
30
  const left = '❯ ' + state.title + (state.version ? ' v' + state.version : '');
31
- const dir = state.workdir ? ' ' + state.workdir : '';
32
- const gap = Math.max(1, cols - left.length - dir.length - right.length - 2);
33
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "#c8b06a", bold: true, children: left }), _jsx(Text, { color: "#7fc4f0", children: dir }), _jsx(Text, { children: ' '.repeat(gap) }), right ? _jsx(Text, { color: "#6fd0b0", children: right }) : null] }), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(cols) })] }));
31
+ const rawDir = state.workdir ? ' ' + state.workdir : '';
32
+ // The header MUST stay exactly ONE text row: FullScreenApp reserves
33
+ // HEADER_ROWS = 2 (title + divider) and the whole panel height is computed
34
+ // from it. A long working directory used to WRAP the title row at a narrow
35
+ // terminal (or a deep path), adding a third row the layout did not know
36
+ // about — Ink then overflowed the fixed `height={termRows}` column and the
37
+ // TOP input row was clipped (the operator saw the second line overprint the
38
+ // first). Truncate the directory (then the title) so the row always fits.
39
+ const gap = Math.max(1, cols - visLen(left) - visLen(rawDir) - visLen(right) - 2);
40
+ let dir = rawDir;
41
+ const budget = cols - visLen(left) - visLen(right) - 2;
42
+ if (visLen(dir) > budget)
43
+ dir = budget > 0 ? truncateToWidth(dir, budget) : '';
44
+ // If even title + provider exceed the width, drop the directory entirely.
45
+ const finalDir = gap >= 1 ? dir : '';
46
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "#c8b06a", bold: true, children: left }), _jsx(Text, { color: "#7fc4f0", children: finalDir }), _jsx(Text, { children: ' '.repeat(Math.max(1, cols - visLen(left) - visLen(finalDir) - visLen(right) - 2)) }), right ? _jsx(Text, { color: "#6fd0b0", children: right }) : null] }), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(cols) })] }));
34
47
  }
35
48
  export function FullScreenApp({ store, inputProps, }) {
36
49
  const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
@@ -49,8 +62,13 @@ export function FullScreenApp({ store, inputProps, }) {
49
62
  const INPUT_BOX_ROWS = inputBoxRows(state.prompt, state.edit.buf, state.edit.cursor, cols);
50
63
  // The suggestion popup renders ABOVE the input box; reserve its rows so
51
64
  // opening the list does not push the panel / shift the whole layout.
52
- const suggestRows = suggestRowCount(state.edit.buf, state.commands);
53
- const panelRows = HEADER_ROWS + STATUS_ROWS + INPUT_BOX_ROWS + suggestRows;
65
+ const suggestRows = suggestRowCount(state.edit.buf, state.commands, cols);
66
+ // A one-line hint UNDER the input box (selection / scroll keys). It is part
67
+ // of the fixed panel height, so the log viewport shrinks by one row instead
68
+ // of the panel overflowing; `cursorTop` (measured from the top) is NOT
69
+ // affected, so the caret cannot move.
70
+ const HINT_ROWS = 1;
71
+ const panelRows = HEADER_ROWS + STATUS_ROWS + INPUT_BOX_ROWS + suggestRows + HINT_ROWS;
54
72
  const logHeight = Math.max(1, termRows - panelRows);
55
73
  // Window the log BY RENDERED ROW: a long wrapped answer can be taller than
56
74
  // one entry, and an entry-count window either overflowed the panel or left
@@ -61,25 +79,40 @@ export function FullScreenApp({ store, inputProps, }) {
61
79
  const maxScroll = Math.max(0, total - logHeight);
62
80
  const scroll = Math.max(0, Math.min(state.scroll, maxScroll));
63
81
  const visible = windowByRows(all, logHeight, scroll, cols);
64
- // Scroll the log with PgUp/PgDn.
82
+ // Scroll the log with PgUp/PgDn; Home/End jump to the very top/bottom. The
83
+ // input line OWNS Home/End while its buffer is non-empty (they move the
84
+ // caret), so the jump only applies to an EMPTY buffer — which is exactly
85
+ // when the operator is navigating the log to select a large block.
86
+ //
87
+ // `useInput` binds the callback ONCE, so a `scroll` captured in the closure
88
+ // goes STALE after the first scroll (PgUp/PgDn/Home/End then compute from an
89
+ // old offset and jump wrong). Read the live value from the store instead.
65
90
  useInput((_input, key) => {
66
91
  const step = Math.max(1, Math.floor(logHeight / 2));
92
+ const cur = store.getSnapshot().scroll;
67
93
  if (key.pageUp) {
68
- store.set({ scroll: Math.min(maxScroll, scroll + step) });
94
+ store.set({ scroll: Math.min(maxScroll, cur + step) });
69
95
  }
70
96
  else if (key.pageDown) {
71
- store.set({ scroll: Math.max(0, scroll - step) });
97
+ store.set({ scroll: Math.max(0, cur - step) });
98
+ }
99
+ else if (state.edit.buf.length === 0 && (key.home || key.end)) {
100
+ // Both the bare and the Ctrl-modified forms land here (Ink sets
101
+ // key.end / key.home for either), so Ctrl+End / Ctrl+Home are a
102
+ // reliable fallback on terminals that swallow the bare keys.
103
+ store.set({ scroll: key.home ? maxScroll : 0 });
72
104
  }
73
105
  });
74
106
  // Scroll the log with the MOUSE WHEEL. Ink does not parse mouse events, so
75
107
  // useMouseWheel reads the raw SGR sequences (see the hook). One wheel notch
76
108
  // scrolls ~3 rows, like most terminals.
77
109
  useMouseWheel((delta) => {
78
- const next = scroll + delta * 3;
110
+ const cur = store.getSnapshot().scroll;
111
+ const next = cur + delta * 3;
79
112
  store.set({ scroll: Math.max(0, Math.min(maxScroll, next)) });
80
113
  }, state.mouseScroll);
81
114
  return (_jsxs(Box, { flexDirection: "column", height: termRows, children: [_jsx(Header, { store: store }), _jsx(Box, { flexDirection: "column", height: logHeight, overflow: "hidden", justifyContent: "flex-end", children: all.length === 0 ? (_jsx(Text, { color: "#5b616e", children: translate(state.locale)('editor.empty_hint') })) : (_jsx(LogList, { entries: visible })) }), _jsx(StatusBar, { state: state, divider: false }), _jsx(InputLine, { ...inputProps, store: store,
82
115
  // Cursor rows above the input box: header + log viewport + status.
83
- cursorTop: HEADER_ROWS + logHeight + suggestRows + STATUS_ROWS + 1, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked })] }));
116
+ cursorTop: HEADER_ROWS + logHeight + suggestRows + STATUS_ROWS + 1, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked }), _jsx(Text, { color: "#5b616e", wrap: "truncate", children: ' ' + translate(state.locale)('editor.keys_hint') })] }));
84
117
  }
85
118
  void contentWidth;
@@ -8,12 +8,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  // reading key events from Ink's useInput, bracketed paste from usePaste,
9
9
  // calling the operator callbacks (submit / abort / attach), and painting the
10
10
  // visual rows via the shared layoutInput.
11
- import { useCallback, useMemo, useRef, useState } from 'react';
12
- import { Box, Text, useInput, usePaste, useCursor, useWindowSize } from 'ink';
11
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
12
+ import { Box, Text, useInput, usePaste, useCursor, useWindowSize, useStdin, } from 'ink';
13
13
  import { translate } from '../i18n.js';
14
- import { layoutInput, visLen, charWidth, SUGGEST_PAGE, } from '../input/layout.js';
14
+ import { layoutInput, SUGGEST_PAGE, } from '../input/layout.js';
15
15
  import { insertText, insertPaste, insertNewline, backspace, del, moveLeft, moveRight, moveHome, moveEnd, wordLeft, wordRight, deleteWordLeft, deleteWordRight, killToLineEnd, moveVisualRows, pushUndo, undoEdit, historyUp, historyDown, searchStart, searchNext, searchAccept, searchCancel, searchInput, searchBackspace, recordHistory, afterSubmit, } from './edit.js';
16
- import { dispatchKey } from './keys.js';
16
+ import { dispatchKey, parseCsiU } from './keys.js';
17
17
  import { useSyncExternalStore } from 'react';
18
18
  import { matchSlashCommands, suggestWindow, completeSlashCommand, } from '../input/suggest.js';
19
19
  /** Render the visual rows of the buffer with the prompt on the first row. */
@@ -69,9 +69,13 @@ export function InputLine(props) {
69
69
  const withHist = recordHistory(s, s.buf);
70
70
  const next = afterSubmit(withHist);
71
71
  setSt(next);
72
- props.onPastesChange(next.pastes);
73
72
  props.onHistoryChange(withHist.history);
73
+ // onSubmit MUST run BEFORE the paste list is cleared: the Ink adapter
74
+ // (TuiEditor.handleSubmit) expands `[Pasted lines#N]` markers by reading
75
+ // the store's paste list, so clearing it first sent the literal marker to
76
+ // the agent instead of the pasted text.
74
77
  props.onSubmit(s.buf, props.attachments.slice());
78
+ props.onPastesChange(next.pastes);
75
79
  }, [props]);
76
80
  const handlePaste = useCallback(async (text) => {
77
81
  const trimmed = String(text).trim();
@@ -224,6 +228,11 @@ export function InputLine(props) {
224
228
  doSubmit,
225
229
  tryClipboard,
226
230
  ]);
231
+ // The raw-stdin CSI-u handler is bound ONCE (its effect depends only on
232
+ // stdin), so it reads the latest `runAction` through a ref — otherwise the
233
+ // effect would re-bind on every render and could miss keystrokes.
234
+ const runActionRef = useRef(runAction);
235
+ runActionRef.current = runAction;
227
236
  useInput((input, key) => {
228
237
  const s = stRef.current;
229
238
  if (props.locked) {
@@ -248,6 +257,37 @@ export function InputLine(props) {
248
257
  usePaste((text) => {
249
258
  void handlePaste(text);
250
259
  });
260
+ // RAW STDIN fallback for the KITTY CSI-u forms Ink DROPS. Ink hides the raw
261
+ // `sequence` for these (it yields `input=''`), so the modified
262
+ // Delete/Backspace bindings never fired (the operator's "Ctrl+Delete does
263
+ // nothing"). The legacy LineEditor parsed the bytes itself; we read the same
264
+ // raw stream here. Sequences split across reads are carried over.
265
+ const { stdin: rawStdin, isRawModeSupported } = useStdin();
266
+ useEffect(() => {
267
+ if (!isRawModeSupported)
268
+ return;
269
+ let carry = '';
270
+ const onRaw = (data) => {
271
+ const s = typeof data === 'string' ? data : data.toString('utf-8');
272
+ const buf = carry + s;
273
+ // Hold a trailing INCOMPLETE CSI-u sequence for the next read.
274
+ const m = /\u001b\[[0-9;:]*$/.exec(buf);
275
+ carry = m ? m[0] : '';
276
+ const scan = carry ? buf.slice(0, buf.length - carry.length) : buf;
277
+ // Find every complete CSI-u sequence and dispatch it.
278
+ const re = /\u001b\[[0-9;:]*u/g;
279
+ let hit;
280
+ while ((hit = re.exec(scan)) !== null) {
281
+ const action = parseCsiU(hit[0]);
282
+ if (action)
283
+ runActionRef.current(action);
284
+ }
285
+ };
286
+ rawStdin.on('data', onRaw);
287
+ return () => {
288
+ rawStdin.removeListener('data', onRaw);
289
+ };
290
+ }, [rawStdin, isRawModeSupported]);
251
291
  // Position the real terminal cursor on the input cell. `y` is the row within
252
292
  // the live Ink output BELOW the log. Panel layout (top to bottom):
253
293
  // row 0 = divider
@@ -255,13 +295,15 @@ export function InputLine(props) {
255
295
  // row 2 = input box TOP border
256
296
  // rows 3.. = input rows
257
297
  const cursorRow = lay.cursorRow;
258
- const rowText = lay.rows[cursorRow]?.text ?? '';
259
298
  const cursorX =
260
299
  // The input rows sit inside the rounded box, indented by 1 column for the
261
- // left border and 1 for the box's inner padding.
262
- 2 +
263
- visLen(lay.rows[cursorRow]?.prefix ?? '') +
264
- colWidth(rowText, lay.cursorCol - visLen(lay.rows[cursorRow]?.prefix ?? ''));
300
+ // left border and 1 for the box's inner padding. `layoutInput().cursorCol`
301
+ // is ALREADY a column count that includes the prompt prefix (wide chars
302
+ // counted as 2), so the terminal column is just 2 + cursorCol. The old
303
+ // code re-measured `rowText` up to `cursorCol` code points, which
304
+ // DOUBLE-counted wide chars: a CJK buffer put the caret at the line end
305
+ // instead of under the cursor.
306
+ 2 + lay.cursorCol;
265
307
  setCursorPosition({ x: cursorX, y: (props.cursorTop ?? 3) + cursorRow });
266
308
  const { shown, moreCount } = suggestWindow(suggestions, suggestOffset);
267
309
  const t = translate(props.locale);
@@ -274,12 +316,3 @@ export function InputLine(props) {
274
316
  ' ' +
275
317
  t('editor.page_hint') }))] })), _jsx(Box, { borderStyle: "round", borderColor: props.locked ? '#5b616e' : '#c8b06a', paddingLeft: 1, paddingRight: 1, flexDirection: "column", children: _jsx(InputRows, { prompt: props.prompt, buf: st.buf, cols: innerCols }) })] }));
276
318
  }
277
- /** Visible width of the first `col` code points of `text` (wide chars = 2). */
278
- function colWidth(text, col) {
279
- const chars = Array.from(text);
280
- let w = 0;
281
- for (let i = 0; i < col && i < chars.length; i++) {
282
- w += charWidth(chars[i].codePointAt(0));
283
- }
284
- return Math.max(0, w);
285
- }
@@ -7,10 +7,14 @@ import { Box, Text, useInput, useApp, useWindowSize } from 'ink';
7
7
  import { UI, HintBar } from './widgets.js';
8
8
  export function SelectList({ title, items, initial, onSelect, }) {
9
9
  const { exit } = useApp();
10
- const { rows } = useWindowSize();
10
+ const { rows, columns } = useWindowSize();
11
11
  const start = Math.max(0, items.findIndex((it) => it.value === initial));
12
12
  const [cursor, setCursor] = useState(start);
13
13
  const maxRows = Math.max(3, (rows || 24) - 7);
14
+ // The divider must fit the box: the border (2) + paddingX (2) leave
15
+ // `cols - 6` usable columns. A hardcoded 40-char rule WRAPPED to a second
16
+ // row on a narrow terminal and left a ragged edge on a wide one.
17
+ const innerCols = Math.max(1, (columns || 80) - 6);
14
18
  const win = useMemo(() => {
15
19
  let top = Math.max(0, cursor - maxRows + 1);
16
20
  if (cursor < top)
@@ -38,7 +42,7 @@ export function SelectList({ title, items, initial, onSelect, }) {
38
42
  finish(items[cursor]?.value ?? null);
39
43
  }
40
44
  });
41
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: UI.title, bold: true, children: '◆ ' + title }), _jsx(Text, { color: UI.dim, children: items.length })] }), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(40) }), items.slice(win.top, win.end).map((it, i) => {
45
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: UI.title, bold: true, children: '◆ ' + title }), _jsx(Text, { color: UI.dim, children: items.length })] }), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(innerCols) }), items.slice(win.top, win.end).map((it, i) => {
42
46
  const idx = win.top + i;
43
47
  const sel = idx === cursor;
44
48
  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));
@@ -1,8 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useAnimation, useWindowSize } from 'ink';
3
3
  import { translate } from '../i18n.js';
4
4
  import { DOTS, stripEllipsis } from '../spinner.js';
5
- import { truncateToWidth } from '../input/layout.js';
5
+ import { truncateToWidth, visLen } from '../input/layout.js';
6
6
  /** Elapsed seconds of the current animated phase ("0s", "45s", "2m 05s"). */
7
7
  function elapsedLabel(startedAt, animating) {
8
8
  if (!animating || !startedAt)
@@ -24,50 +24,69 @@ function stateLabel(state, locale) {
24
24
  return '';
25
25
  }
26
26
  /** Visible width of the badge row (used to reserve space for the left
27
- * status). An emoji is counted as 2 columns; the rest as 1. Approximation is
28
- * fine — the goal is only to avoid clipping the badges. */
29
- function badgeTextWidth(state) {
27
+ * status). Measured with the shared `visLen` so wide chars (CJK/emoji) count
28
+ * as 2 and the emoji's text-presentation selector (\uFE0E) as 0 — a plain
29
+ * `.length` counted an emoji as 1 and a CJK badge was clipped on a narrow
30
+ * terminal (BACKLOG T0.7). Exported so the width arithmetic is unit-tested
31
+ * without rendering. */
32
+ export function badgeTextWidth(state) {
30
33
  let w = 0;
31
34
  if (state.statusLine)
32
- w += state.statusLine.length + 1;
35
+ w += visLen(state.statusLine) + 1;
33
36
  if (state.provider)
34
- w += state.provider.length + 3;
37
+ w += visLen('[' + state.provider + '] ');
35
38
  if (state.queueLength > 0)
36
- w += 2 + String(state.queueLength).length;
37
- if (state.codeBlocks.length > 0)
38
- w += 8;
39
+ w += visLen('⧗' + state.queueLength + ' ');
40
+ if (state.codeBlocks.length > 0) {
41
+ const idx = state.codeFocus >= 0 ? state.codeFocus + 1 : state.codeBlocks.length;
42
+ w += visLen('⌘ ' + idx + '/' + state.codeBlocks.length + ' ');
43
+ }
39
44
  if (state.tasks)
40
- w += state.tasks.length + 1;
45
+ w += visLen(state.tasks) + 1;
46
+ const toggles = [];
41
47
  if (state.thinkingEnabled)
42
- w += 3;
48
+ toggles.push('🧠\uFE0E');
43
49
  if (state.searchEnabled)
44
- w += 3;
50
+ toggles.push('🌐\uFE0E');
51
+ if (toggles.length)
52
+ w += visLen(toggles.join(' ') + ' ');
45
53
  if (state.contextText) {
46
- const marker = state.contextLevel === 'high' ? 2 : state.contextLevel === 'warn' ? 2 : 0;
47
- w += state.contextText.length + marker;
54
+ const marker = state.contextLevel === 'high'
55
+ ? '⛔ '
56
+ : state.contextLevel === 'warn'
57
+ ? '▲ '
58
+ : '';
59
+ w += visLen(marker + state.contextText);
48
60
  }
49
61
  return w;
50
62
  }
51
- /** The badge row: statusLine, provider, queue, tasks, toggles, context. */
52
- function Badges({ state }) {
53
- const parts = [];
63
+ /** The badge row: statusLine, provider, queue, tasks, toggles, context.
64
+ * `maxWidth` caps the row so it never wraps to a second line; when the badges
65
+ * do not fit, the lowest-priority ones (statusLine, then provider) are
66
+ * dropped first so the context counter — the most useful — survives. */
67
+ function Badges({ state, budget = Infinity, }) {
68
+ // Each badge is (node, visible width); lowest-priority first so the context
69
+ // counter (last) survives when the row is tight.
70
+ const all = [];
71
+ const add = (key, w, node) => {
72
+ all.push({ key, w, node });
73
+ };
54
74
  if (state.statusLine) {
55
- parts.push(_jsxs(Text, { color: "#808896", children: [state.statusLine, ' '] }, "sl"));
75
+ add('sl', visLen(state.statusLine) + 1, _jsxs(Text, { color: "#808896", children: [state.statusLine, ' '] }, "sl"));
56
76
  }
57
77
  if (state.provider) {
58
- parts.push(_jsx(Text, { color: "#808896", children: '[' + state.provider + '] ' }, "prov"));
78
+ add('prov', visLen('[' + state.provider + '] '), _jsx(Text, { color: "#808896", children: '[' + state.provider + '] ' }, "prov"));
59
79
  }
60
80
  if (state.queueLength > 0) {
61
- parts.push(_jsxs(Text, { color: "#c9a86a", children: ['⧗' + state.queueLength, ' '] }, "q"));
81
+ add('q', visLen('⧗' + state.queueLength + ' '), _jsxs(Text, { color: "#c9a86a", children: ['⧗' + state.queueLength, ' '] }, "q"));
62
82
  }
63
83
  if (state.codeBlocks.length > 0) {
64
- // A hint that the last answer has copyable code: Ctrl+Y copies the last
65
- // block (or the focused one), like a copy button in a GUI chat.
66
84
  const idx = state.codeFocus >= 0 ? state.codeFocus + 1 : state.codeBlocks.length;
67
- parts.push(_jsx(Text, { color: "#7fc4f0", children: '⌘ ' + idx + '/' + state.codeBlocks.length + ' ' }, "code"));
85
+ const label = '⌘ ' + idx + '/' + state.codeBlocks.length + ' ';
86
+ add('code', visLen(label), _jsx(Text, { color: "#7fc4f0", children: label }, "code"));
68
87
  }
69
88
  if (state.tasks) {
70
- parts.push(_jsxs(Text, { color: "#808896", children: [state.tasks, ' '] }, "t"));
89
+ add('t', visLen(state.tasks) + 1, _jsxs(Text, { color: "#808896", children: [state.tasks, ' '] }, "t"));
71
90
  }
72
91
  const toggles = [];
73
92
  if (state.thinkingEnabled)
@@ -75,7 +94,8 @@ function Badges({ state }) {
75
94
  if (state.searchEnabled)
76
95
  toggles.push('🌐\uFE0E');
77
96
  if (toggles.length) {
78
- parts.push(_jsx(Text, { color: "#6fd0b0", children: toggles.join(' ') + ' ' }, "tg"));
97
+ const label = toggles.join(' ') + ' ';
98
+ add('tg', visLen(label), _jsx(Text, { color: "#6fd0b0", children: label }, "tg"));
79
99
  }
80
100
  if (state.contextText) {
81
101
  const color = state.contextLevel === 'high'
@@ -88,11 +108,20 @@ function Badges({ state }) {
88
108
  : state.contextLevel === 'warn'
89
109
  ? '▲ '
90
110
  : '';
91
- parts.push(_jsx(Text, { color: color, children: marker + state.contextText }, "ctx"));
111
+ const label = marker + state.contextText;
112
+ add('ctx', visLen(label), _jsx(Text, { color: color, children: label }, "ctx"));
92
113
  }
93
- if (!parts.length)
114
+ if (!all.length)
94
115
  return null;
95
- return _jsx(Box, { children: parts });
116
+ // Drop badges from the FRONT (statusLine, provider, ... least important)
117
+ // until the row fits the budget. Keep at least the last (context).
118
+ let total = all.reduce((n, b) => n + b.w, 0);
119
+ let start = 0;
120
+ while (total > budget && start < all.length - 1) {
121
+ total -= all[start].w;
122
+ start++;
123
+ }
124
+ return _jsx(_Fragment, { children: all.slice(start).map((b) => b.node) });
96
125
  }
97
126
  export function StatusBar({ state, divider = true, }) {
98
127
  // Drive the spinner from Ink's shared animation timer (one timer for the
@@ -129,12 +158,13 @@ export function StatusBar({ state, divider = true, }) {
129
158
  const scrollNote = state.scroll > 0 ? `↑ прокручено на ${state.scroll} стр. · PgDn/End` : '';
130
159
  const leftText = scrollNote || prefix + left;
131
160
  const showLeft = !!leftText;
132
- // Keep the badge row visible: the left status truncates to whatever space is
133
- // left after the badges, instead of pushing them off the right edge (they
134
- // used to be clipped: "[DeepSee⧗1 задачи: 3/...").
161
+ // Keep the row on ONE line. The left status gets at most a third of the
162
+ // width (it is the least important); the badges take the rest and DROP whole
163
+ // badges until they fit (the context counter survives longest). Estimating
164
+ // the badge width for the left budget was circular and let the row overshoot
165
+ // to a second line on a narrow terminal.
135
166
  const cols = useWindowSize().columns || 80;
136
- const badgeWidth = badgeTextWidth(state);
137
- const maxLeft = Math.max(0, cols - badgeWidth - 2);
138
- const leftOut = showLeft ? truncateToWidth(leftText, maxLeft) : '';
139
- return (_jsxs(Box, { flexDirection: "column", children: [divider ? _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(cols) }) : null, _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { children: leftOut ? _jsx(Text, { color: "#a1723f", children: leftOut }) : _jsx(Text, { children: " " }) }), _jsx(Badges, { state: state })] })] }));
167
+ const leftBudget = Math.max(0, Math.min(Math.floor(cols / 3), cols - 14));
168
+ const leftOut = showLeft ? truncateToWidth(leftText, leftBudget) : '';
169
+ return (_jsxs(Box, { flexDirection: "column", children: [divider ? _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(cols) }) : null, _jsxs(Box, { flexDirection: "row", width: Math.max(1, cols - 2), height: 1, children: [_jsx(Text, { color: "#a1723f", children: leftOut || ' ' }), _jsx(Box, { flexGrow: 1 }), _jsx(Badges, { state: state, budget: Math.max(0, cols - 2 - visLen(leftOut)) })] })] }));
140
170
  }
@@ -9,7 +9,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  // (never repainted), the bottom section re-renders as state changes, so the
10
10
  // input stays pinned at the bottom while answers scroll up into scrollback.
11
11
  import React, { useSyncExternalStore } from 'react';
12
- import { Box } from 'ink';
12
+ import { Box, useWindowSize } from 'ink';
13
13
  import { LogView } from './LogView.js';
14
14
  import { StatusBar } from './StatusBar.js';
15
15
  import { InputLine } from './InputLine.js';
@@ -23,6 +23,7 @@ export function TuiApp({ store, inputProps, initialPrintedCount = 0, }) {
23
23
  // + the suggestion popup (which renders above the box and would otherwise
24
24
  // shift the caret down by its row count). Without the suggestRows term the
25
25
  // caret drifted whenever the «/» list opened.
26
- const suggestRows = suggestRowCount(state.edit.buf, state.commands);
26
+ const cols = useWindowSize().columns || 80;
27
+ const suggestRows = suggestRowCount(state.edit.buf, state.commands, cols);
27
28
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(LogView, { entries: entries }), _jsx(StatusBar, { state: state }), _jsx(InputLine, { ...inputProps, store: store, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked, cursorTop: 3 + suggestRows })] }));
28
29
  }
@@ -1,4 +1,20 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ // TuiEditor — a drop-in replacement for the hand-rolled LineEditor backed by an
3
+ // Ink/React render tree.
4
+ //
5
+ // WHY an adapter and not a rewrite of index.ts:
6
+ // * index.ts (4200 lines) and run-task.ts call the UI through a large but
7
+ // STABLE imperative surface (`printAbove`, `lock`, `toolCall`, `busy`, ...);
8
+ // * Ink renders from React state, which can only be driven from inside the
9
+ // tree. The adapter is the seam: it implements the SAME surface and forwards
10
+ // every call into the TuiStore, which the tree subscribes to.
11
+ // So the rest of the agent is unchanged; only the rendering engine differs.
12
+ //
13
+ // Lifecycle: `start()` mounts the Ink app; `dispose()` unmounts it. A `pause()`
14
+ // /`resume()` pair suspends the tree for a foreign full-screen UI (the config
15
+ // menu) — implemented as unmount/remount so the foreign program owns the
16
+ // terminal cleanly.
17
+ import fs from 'node:fs';
2
18
  import { render } from 'ink';
3
19
  import { TuiStore } from './store.js';
4
20
  import { TuiApp } from './TuiApp.js';
@@ -126,6 +142,26 @@ export class TuiEditor {
126
142
  alternateScreen: this.alternateScreen,
127
143
  });
128
144
  this.rendered = true;
145
+ // DEV DIAGNOSTIC: dump every raw stdin chunk to a file so a terminal's
146
+ // key encoding (e.g. Ctrl+Delete = ESC d in VSCode) can be captured from
147
+ // the LIVE agent, not guessed. Off unless ZAMES_TUI_KEY_DEBUG names a
148
+ // file.
149
+ const keyDbg = process.env.ZAMES_TUI_KEY_DEBUG;
150
+ if (keyDbg) {
151
+ process.stdin.on('data', (d) => {
152
+ try {
153
+ fs.appendFileSync(keyDbg, new Date().toISOString() +
154
+ ' ' +
155
+ d.toString('hex') +
156
+ ' ' +
157
+ JSON.stringify(d.toString('utf-8')) +
158
+ String.fromCharCode(10));
159
+ }
160
+ catch {
161
+ /* ignore */
162
+ }
163
+ });
164
+ }
129
165
  this.installResizeHandler();
130
166
  // Entries shown so far are now on screen; a later remount must not reprint
131
167
  // them.
@@ -453,9 +489,9 @@ export class TuiEditor {
453
489
  return false;
454
490
  }
455
491
  const pick = typeof index === 'number'
456
- ? blocks.find((b) => b.index === index) ?? null
492
+ ? (blocks.find((b) => b.index === index) ?? null)
457
493
  : snap.codeFocus >= 0
458
- ? blocks[snap.codeFocus] ?? null
494
+ ? (blocks[snap.codeFocus] ?? null)
459
495
  : blocks[blocks.length - 1];
460
496
  if (!pick) {
461
497
  this.printAbove(theme.warn(translate(this.locale)('copy.bad_index', { n: String(index) })));
@@ -464,10 +500,14 @@ export class TuiEditor {
464
500
  const res = this.onCopyText?.(pick.text) ?? { ok: false, via: 'none' };
465
501
  const label = 'code #' + pick.index + (pick.lang ? ' (' + pick.lang + ')' : '');
466
502
  if (res.ok) {
467
- this.printAbove(theme.assistant(translate(this.locale)('copy.done', { tool: res.via }) + ' — ' + label));
503
+ this.printAbove(theme.assistant(translate(this.locale)('copy.done', { tool: res.via }) +
504
+ ' — ' +
505
+ label));
468
506
  }
469
507
  else {
470
- this.printAbove(theme.warn(translate(this.locale)('copy.failed', { tool: res.via }) + ' — ' + label));
508
+ this.printAbove(theme.warn(translate(this.locale)('copy.failed', { tool: res.via }) +
509
+ ' — ' +
510
+ label));
471
511
  }
472
512
  return res.ok;
473
513
  }
package/dist/tui/edit.js CHANGED
@@ -81,9 +81,29 @@ export function deleteWordLeft(s) {
81
81
  chars.splice(i, s.cursor - i);
82
82
  return { ...s, buf: chars.join(''), cursor: i };
83
83
  }
84
- /** Delete the word on the RIGHT of the cursor (Ctrl+Delete / Alt+Delete). */
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.
93
+ */
85
94
  export function deleteWordRight(s) {
86
95
  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
+ }
87
107
  let i = s.cursor;
88
108
  while (i < chars.length && /\s/.test(chars[i]))
89
109
  i++;
package/dist/tui/keys.js CHANGED
@@ -9,6 +9,40 @@
9
9
  // Kept free of React/Ink runtime imports beyond the Key TYPE, so it is
10
10
  // unit-testable.
11
11
  const LF = String.fromCharCode(10);
12
+ /**
13
+ * Ctrl/Alt + named keys under the KITTY keyboard protocol (CSI-u). Ink's
14
+ * `parseKeypress` recognizes the MODIFIED ARROW forms but returns `name=''`
15
+ * for the modified Delete/Backspace codepoints (CSI 3;5u, 127;5u, ...), so
16
+ * `useInput` yields an empty input/name and the binding never fires — the
17
+ * operator's "Ctrl+Delete does nothing". The legacy LineEditor parsed these
18
+ * bytes itself; the TUI reads them from raw stdin (Ink hides `sequence`) and
19
+ * maps the known ones to the SAME actions so both editors behave alike.
20
+ *
21
+ * Returns null when the string is not one of these sequences. Pure + exported
22
+ * for tests.
23
+ */
24
+ export function parseCsiU(raw) {
25
+ // CSI codepoint ; modifier [: eventType] u — e.g. ESC[3;5u (Ctrl+Delete),
26
+ // ESC[127;5u (Ctrl+Backspace), ESC[3u (plain Delete),
27
+ // ESC[127u (plain Backspace), ESC[13;5u (Ctrl+Enter).
28
+ const m = /^\u001b\[(\d+)(?:;(\d+))?(?::\d+)?u$/.exec(raw);
29
+ if (!m)
30
+ return null;
31
+ const cp = Number(m[1]);
32
+ const mod = m[2] ? Number(m[2]) - 1 : 0;
33
+ const ctrl = (mod & 4) !== 0;
34
+ const alt = (mod & 2) !== 0;
35
+ if (cp === 13 && (ctrl || alt))
36
+ return { type: 'newline' };
37
+ if (cp === 3) {
38
+ // Ctrl/Alt+Delete deletes the word on the LEFT (see dispatchKey).
39
+ return ctrl || alt ? { type: 'deleteWordLeft' } : { type: 'delete' };
40
+ }
41
+ if (cp === 127) {
42
+ return ctrl || alt ? { type: 'deleteWordLeft' } : { type: 'backspace' };
43
+ }
44
+ return null;
45
+ }
12
46
  /**
13
47
  * Map a normalized Ink keypress to an editor action.
14
48
  *
@@ -51,8 +85,11 @@ export function dispatchKey(input, key, ctx, isVisualEdge) {
51
85
  return { type: 'wordLeft' };
52
86
  if (key.rightArrow)
53
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.
54
91
  if (key.delete)
55
- return { type: 'deleteWordRight' };
92
+ return { type: 'deleteWordLeft' };
56
93
  if (key.backspace)
57
94
  return { type: 'deleteWordLeft' };
58
95
  if (key.home)
@@ -107,10 +144,17 @@ export function dispatchKey(input, key, ctx, isVisualEdge) {
107
144
  return { type: 'wordLeft' };
108
145
  if (input === 'f')
109
146
  return { type: 'wordRight' };
147
+ // VSCode sends Ctrl+Delete as `ESC d` (which Ink reports as Alt+d).
148
+ // The operator expects it to delete the word BEFORE the cursor.
149
+ if (input === 'd')
150
+ return { type: 'deleteWordLeft' };
110
151
  if (key.backspace)
111
152
  return { type: 'deleteWordLeft' };
153
+ // Alt/Ctrl+Delete also deletes the word BEFORE the cursor (the operator's
154
+ // binding, consistent with ESC d): some terminals encode Ctrl+Delete as
155
+ // meta+Delete rather than ESC d.
112
156
  if (key.delete)
113
- return { type: 'deleteWordRight' };
157
+ return { type: 'deleteWordLeft' };
114
158
  return null;
115
159
  }
116
160
  // ---- LF as a bare newline (Ctrl+J) ----
package/dist/tui/store.js CHANGED
@@ -40,7 +40,7 @@ function initialState() {
40
40
  queueLength: 0,
41
41
  tasks: '',
42
42
  provider: '',
43
- mouseScroll: false,
43
+ mouseScroll: true,
44
44
  thinkingEnabled: false,
45
45
  searchEnabled: false,
46
46
  statusLine: '',