zames_pro 2.74.0 → 2.75.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +24 -0
  3. package/dist/attachments.js +6 -1
  4. package/dist/config.js +7 -7
  5. package/dist/i18n/common.js +4 -0
  6. package/dist/i18n/help.js +8 -0
  7. package/dist/index.js +9 -0
  8. package/dist/input/suggest.js +19 -4
  9. package/dist/theme.js +9 -0
  10. package/dist/tui/ConfigMenu.js +44 -11
  11. package/dist/tui/FullScreenApp.js +37 -9
  12. package/dist/tui/InputLine.js +52 -19
  13. package/dist/tui/SelectList.js +6 -2
  14. package/dist/tui/StatusBar.js +66 -36
  15. package/dist/tui/TuiApp.js +3 -2
  16. package/dist/tui/edit.js +21 -1
  17. package/dist/tui/keys.js +33 -0
  18. package/dist/tui/store.js +1 -1
  19. package/dist/tui/useMouseWheel.js +80 -29
  20. package/dist/tui/widgets.js +6 -1
  21. package/package.json +2 -1
  22. package/scripts/inline-status-driver.mts +31 -0
  23. package/scripts/inline-status.tsx +33 -0
  24. package/scripts/mouse-churn.mts +34 -0
  25. package/scripts/qwen-models-smoke.mts +117 -0
  26. package/scripts/tui-comp-shot.mts +63 -0
  27. package/scripts/tui-components.tsx +62 -0
  28. package/scripts/tui-config-driver.mts +82 -0
  29. package/scripts/tui-config-probe.tsx +74 -0
  30. package/scripts/tui-edit-driver.mts +100 -0
  31. package/scripts/tui-edit-probe.tsx +38 -0
  32. package/scripts/tui-editor-probe.tsx +24 -0
  33. package/scripts/tui-input-driver.mts +54 -0
  34. package/scripts/tui-input-shot.tsx +59 -0
  35. package/scripts/tui-log-driver.mts +35 -0
  36. package/scripts/tui-log-shot.tsx +48 -0
  37. package/scripts/tui-longpaste-driver.mts +56 -0
  38. package/scripts/tui-longpaste.tsx +35 -0
  39. package/scripts/tui-manyselect-shot.mts +34 -0
  40. package/scripts/tui-manyselect.tsx +17 -0
  41. package/scripts/tui-mouse-churn-probe.tsx +28 -0
  42. package/scripts/tui-paste-driver.mts +88 -0
  43. package/scripts/tui-paste-kinds.tsx +76 -0
  44. package/scripts/tui-pastetest.mts +38 -0
  45. package/scripts/tui-repro.tsx +64 -0
  46. package/scripts/tui-resize.mts +70 -0
  47. package/scripts/tui-scroll-driver.mts +53 -0
  48. package/scripts/tui-scroll-probe.tsx +46 -0
  49. package/scripts/tui-shot.mts +106 -0
  50. package/scripts/tui-status-driver.mts +24 -0
  51. package/scripts/tui-status-shot.tsx +27 -0
  52. package/scripts/tui-submit.tsx +57 -0
  53. package/scripts/tui-wheel-driver.mts +61 -0
  54. package/scripts/tui-wheel-probe.tsx +48 -0
  55. package/scripts/tui-widgets-driver.mts +20 -0
  56. package/scripts/tui-widgets.tsx +44 -0
@@ -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
  }
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,39 @@
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
+ return ctrl || alt ? { type: 'deleteWordRight' } : { type: 'delete' };
39
+ }
40
+ if (cp === 127) {
41
+ return ctrl || alt ? { type: 'deleteWordLeft' } : { type: 'backspace' };
42
+ }
43
+ return null;
44
+ }
12
45
  /**
13
46
  * Map a normalized Ink keypress to an editor action.
14
47
  *
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: '',
@@ -13,18 +13,32 @@
13
13
  // ALTERNATE-SCROLL: while the app is in the ALTERNATE SCREEN (full-screen
14
14
  // mode), terminals turn the mouse wheel into ARROW KEYS by default (DECSET
15
15
  // 1007, "alternate scroll mode"). Ink parses those arrows as normal Up/Down,
16
- // and the input editor at a buffer edge maps them to HISTORY — so the wheel
16
+ // and the input editor at a buffer edge maps them to HISTORY -- so the wheel
17
17
  // scrolled the INPUT HISTORY instead of the log (the operator's bug).
18
18
  //
19
- // ORDER MATTERS and reporting is ALWAYS ON: some terminals only apply `1007l`
20
- // while mouse reporting is enabled, so sending `1007l` alone (the previous
21
- // behavior when ui.mouseScroll was off) was a no-op there and the wheel kept
22
- // arriving as arrows. We now enable 1000/1006 unconditionally and disable 1007
23
- // right after. Consequence: drag-select needs SHIFT (the standard TUI
24
- // convention — Shift+drag bypasses the app), which is the trade-off for a
25
- // working wheel. `enabled` only decides whether the wheel SCROLLS the log;
26
- // the events are consumed either way so they never reach the editor.
27
- import { useEffect } from 'react';
19
+ // Two clear, MUTUALLY EXCLUSIVE modes (a terminal cannot do both at once):
20
+ // - SELECT (ui.mouseScroll=false): mouse reporting stays OFF, so the
21
+ // terminal owns the mouse -- drag-select works natively and the wheel is
22
+ // inert (no arrows, no history jump).
23
+ // - SCROLL (ui.mouseScroll=true): mouse reporting ON, so the wheel arrives
24
+ // as SGR events and scrolls the log; drag-select then needs SHIFT.
25
+ //
26
+ // THE CRITICAL DETAIL (this bug kept coming back): the mode sequences must be
27
+ // written ONCE per mode change, NEVER per render. An earlier version listed
28
+ // `onWheel` in the effect deps, but callers pass an inline arrow, so the
29
+ // effect re-ran on EVERY render (the status spinner re-renders constantly) --
30
+ // each cleanup wrote `1007h` (alternate-scroll ON) before the next setup wrote
31
+ // `1007l`. A wheel notch that landed in that window became an ARROW and
32
+ // scrolled the input history: the intermittent "either scroll or selection"
33
+ // behaviour. The effect therefore depends ONLY on the mode + stdin, and the
34
+ // callback is read from a ref (set in its own effect) so a new closure never tears
35
+ // the terminal mode down.
36
+ //
37
+ // ORDER MATTERS: some terminals only apply `1007l` while mouse reporting is
38
+ // enabled, so we enable 1000/1006 FIRST, then disable 1007. In SELECT mode we
39
+ // actually want reporting off, so we send the on/off pair ONCE (the 1007l then
40
+ // "sticks") and then leave reporting off.
41
+ import { useEffect, useRef } from 'react';
28
42
  import { useStdin } from 'ink';
29
43
  /** True when a chunk contains an SGR mouse wheel event; returns the delta. */
30
44
  export function parseWheel(chunk) {
@@ -61,30 +75,64 @@ export function incompleteTail(chunk) {
61
75
  }
62
76
  const ESC = String.fromCharCode(27);
63
77
  /**
64
- * The terminal-mode sequences the hook writes. Exported PURE so a test can pin
65
- * the exact bytes AND their order (reporting FIRST, then 1007 off — some
66
- * terminals ignore `1007l` when mouse reporting is off, which is what made the
67
- * wheel keep scrolling the input history).
78
+ * The terminal-mode sequences the hook writes for a given mode. Exported PURE
79
+ * so a test can pin the exact bytes AND their order:
80
+ * - SELECT (enabled=false): NO `1000h/1006h` in the STEADY state, `on`
81
+ * only applies `1007l` (the one-time prime), and `off` guarantees the
82
+ * reporting bits are clear on unmount;
83
+ * - SCROLL (enabled=true): `1000h 1006h 1007l` in the STEADY state.
68
84
  */
69
- export function mouseModeSequences() {
70
- const on =
71
- // 1000 = button-event tracking, 1006 = SGR extended coords, 1007 = off.
72
- ESC + '[?1000h' + ESC + '[?1006h' + ESC + '[?1007l';
73
- const off = ESC + '[?1006l' + ESC + '[?1000l' + ESC + '[?1007h';
74
- return { on, off };
85
+ export function mouseModeSequences(enabled) {
86
+ // 1000 = button-event tracking, 1006 = SGR extended coords, 1007 off always
87
+ // (being in alt-screen otherwise turns the wheel into arrows => history).
88
+ const reportingOn = ESC + '[?1000h' + ESC + '[?1006h';
89
+ const reportingOff = ESC + '[?1006l' + ESC + '[?1000l';
90
+ const alternateScrollOff = ESC + '[?1007l';
91
+ const alternateScrollOn = ESC + '[?1007h';
92
+ if (enabled) {
93
+ return {
94
+ on: reportingOn + alternateScrollOff,
95
+ off: reportingOff + alternateScrollOn,
96
+ };
97
+ }
98
+ // SELECT: the on/off pair is applied ONCE at mount so the 1007l sticks even
99
+ // though reporting is then off (some terminals ignore 1007l while reporting
100
+ // is off). After the prime the steady state has reporting OFF, and the
101
+ // listener is not bound, so nothing consumes the native selection.
102
+ return {
103
+ on: reportingOn + alternateScrollOff + reportingOff,
104
+ off: reportingOff + alternateScrollOn,
105
+ };
75
106
  }
76
107
  /**
77
108
  * Subscribe to wheel events on the given terminal and call `onWheel(delta)`
78
- * where delta > 0 means "scroll up / back". Enables SGR mouse reporting while
79
- * mounted and disables alternate-scroll so the wheel is never turned into
80
- * arrow keys.
109
+ * where delta > 0 means "scroll up / back".
110
+ *
111
+ * Two mutually exclusive modes:
112
+ * - `enabled=false` (SELECT): mouse reporting stays OFF, native drag-select
113
+ * works, the wheel is inert. The listener is still bound so an accidental
114
+ * SGR byte from a previous mode never leaks into the editor as an arrow.
115
+ * - `enabled=true` (SCROLL): reporting is on, the wheel scrolls the log
116
+ * (via `onWheel`), drag-select needs Shift.
117
+ *
118
+ * The terminal mode is written ONCE per `enabled` change. The wheel listener
119
+ * is bound once and reads the latest `onWheel` from a ref, so an inline
120
+ * callback (a new identity every render) does NOT re-run the effect -- a
121
+ * re-run would momentarily re-enable alternate-scroll and let a wheel notch
122
+ * scroll the input history.
81
123
  */
82
- export function useMouseWheel(onWheel, enabled = true) {
124
+ export function useMouseWheel(onWheel, enabled = false) {
83
125
  const { stdin, isRawModeSupported } = useStdin();
126
+ // The latest callback, without making the mode effect depend on its
127
+ // identity (callers pass an inline arrow).
128
+ const onWheelRef = useRef(onWheel);
129
+ useEffect(() => {
130
+ onWheelRef.current = onWheel;
131
+ }, [onWheel]);
84
132
  useEffect(() => {
85
133
  if (!isRawModeSupported)
86
134
  return;
87
- const seq = mouseModeSequences();
135
+ const seq = mouseModeSequences(enabled);
88
136
  process.stdout.write(seq.on);
89
137
  // A sequence may be split across two chunks; carry the incomplete tail.
90
138
  let carry = '';
@@ -95,13 +143,14 @@ export function useMouseWheel(onWheel, enabled = true) {
95
143
  const scan = carry ? buf.slice(0, buf.length - carry.length) : buf;
96
144
  if (!scan.includes('\u001b[<'))
97
145
  return;
98
- // `enabled` decides whether the wheel SCROLLS; the event is consumed
99
- // either way, so it never falls through to the editor as an arrow.
146
+ // SELECT mode ignores the wheel entirely (the event never reaches the
147
+ // editor either -- there is no reporting to generate it in the first
148
+ // place, but a stale SGR byte from a previous mode is swallowed here).
100
149
  if (!enabled)
101
150
  return;
102
151
  const delta = parseWheel(scan);
103
152
  if (delta !== 0)
104
- onWheel(delta);
153
+ onWheelRef.current(delta);
105
154
  };
106
155
  stdin.on('data', onData);
107
156
  return () => {
@@ -109,5 +158,7 @@ export function useMouseWheel(onWheel, enabled = true) {
109
158
  // Disable mouse reporting on unmount so normal selection works again.
110
159
  process.stdout.write(seq.off);
111
160
  };
112
- }, [stdin, isRawModeSupported, onWheel, enabled]);
161
+ // `onWheel` is deliberately NOT a dependency: it is read via the ref so an
162
+ // inline callback does not tear the terminal mode down on every render.
163
+ }, [stdin, isRawModeSupported, enabled]);
113
164
  }
@@ -26,7 +26,12 @@ export function Select({ value, focused, }) {
26
26
  }
27
27
  /** A framed panel (rounded border) with an optional title bar and footer. */
28
28
  export function Panel({ title, subtitle, footer, width, children, }) {
29
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, width: width, children: [title ? (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: UI.title, bold: true, children: title }), subtitle ? _jsx(Text, { color: UI.dim, children: subtitle }) : null] })) : null, title ? _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(40) }) : null, children, footer ? footer : null] }));
29
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, width: width, children: [title ? (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: UI.title, bold: true, children: title }), subtitle ? _jsx(Text, { color: UI.dim, children: subtitle }) : null] })) : null, title ? (_jsx(Text, { color: "#3a3f4b", children: '─'.repeat(panelInnerCols(width)) })) : null, children, footer ? footer : null] }));
30
+ }
31
+ /** Inner width (minus border + paddingX) the panel divider should span. */
32
+ function panelInnerCols(width) {
33
+ const cols = width || process.stdout.columns || 80;
34
+ return Math.max(1, cols - 6);
30
35
  }
31
36
  /** A single hint chip: `↑/↓ select`. */
32
37
  export function Hint({ keys, label, }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.74.0",
3
+ "version": "2.75.0",
4
4
  "description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen and other web LLMs — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,6 +34,7 @@
34
34
  "test:watch": "tsx --test --watch test/*.test.ts",
35
35
  "self-smoke": "tsx scripts/self-smoke.mts",
36
36
  "qwen-smoke": "tsx scripts/qwen-smoke.mts",
37
+ "qwen-models-smoke": "tsx scripts/qwen-models-smoke.mts",
37
38
  "render-demo": "tsx scripts/render-demo.mts",
38
39
  "render-how": "tsx scripts/render-how-it-works.mts",
39
40
  "build:readme": "tsx scripts/build-readme.mts",
@@ -0,0 +1,31 @@
1
+ import { spawn } from 'node:child_process'
2
+ import path from 'node:path'
3
+ import { Term } from '../test/vt100.mts'
4
+ const NL = String.fromCharCode(10)
5
+ const term = new Term(12, 60)
6
+ const child = spawn(
7
+ 'script',
8
+ ['-qec', 'npx tsx scripts/inline-status.tsx', '/dev/null'],
9
+ {
10
+ cwd: path.resolve('.'),
11
+ env: { ...process.env, COLUMNS: '60', LINES: '12' },
12
+ },
13
+ )
14
+ child.stdout.on('data', (d) => term.write(d.toString('utf-8')))
15
+ child.stderr.on('data', (d) => term.write(d.toString('utf-8')))
16
+ setTimeout(() => {
17
+ const g = term.grid
18
+ if (g[term.r]) g[term.r][term.c] = String.fromCharCode(9673)
19
+ process.stdout.write(
20
+ g
21
+ .map((r) => r.join('').replace(/\s+$/, ''))
22
+ .map((l, n) => String(n).padStart(2) + '| ' + l)
23
+ .join(NL) +
24
+ NL +
25
+ 'CURSOR row=' +
26
+ term.r +
27
+ NL,
28
+ )
29
+ child.kill('SIGTERM')
30
+ }, 5000)
31
+ child.on('close', () => process.exit(0))
@@ -0,0 +1,33 @@
1
+ import React from 'react'
2
+ import { render } from 'ink'
3
+ import { TuiStore } from '../src/tui/store.js'
4
+ import { TuiApp } from '../src/tui/TuiApp.js'
5
+ import { initialEditState } from '../src/tui/edit.js'
6
+ const store = new TuiStore()
7
+ store.set({
8
+ title: 'z',
9
+ version: '1',
10
+ workdir: '/tmp',
11
+ prompt: '> ',
12
+ locale: 'en',
13
+ commands: [],
14
+ edit: { ...initialEditState(), buf: 'abc' },
15
+ statusText: 'STATUS HERE',
16
+ })
17
+ const p = {
18
+ onSubmit: () => {},
19
+ onEscape: () => {},
20
+ onCtrlC: () => {},
21
+ onCopyCode: () => {},
22
+ onAttach: () => null,
23
+ onClipboard: () => Promise.resolve(null),
24
+ onHistoryChange: () => {},
25
+ onClearScreen: () => {},
26
+ attachments: [],
27
+ onPastesChange: () => {},
28
+ }
29
+ render(React.createElement(TuiApp, { store, inputProps: p }), {
30
+ exitOnCtrlC: false,
31
+ patchConsole: false,
32
+ })
33
+ setInterval(() => {}, 1 << 30)
@@ -0,0 +1,34 @@
1
+ // Prove the mouse-mode churn is ZERO: mount the REAL TuiEditor, force several
2
+ // store updates (like the 1s badge refresh does), and COUNT how many times the
3
+ // raw stdout receives the mouse-mode sequences. With the effect depending on
4
+ // an inline onWheel, the count grew per render (the wheel-vs-history bug);
5
+ // with the ref fix, each mode byte is written ONCE.
6
+ //
7
+ // Exit code 0 = OK, 1 = churn detected. Prints a one-line verdict so the pty
8
+ // test can assert on it.
9
+
10
+ import { spawn } from 'node:child_process'
11
+ import path from 'node:path'
12
+
13
+ const child = spawn(
14
+ 'script',
15
+ ['-qec', 'npx tsx scripts/tui-mouse-churn-probe.tsx', '/dev/null'],
16
+ {
17
+ cwd: path.resolve('.'),
18
+ env: { ...process.env, COLUMNS: '80', LIMES: '24' },
19
+ },
20
+ )
21
+ let out = ''
22
+ child.stdout.on('data', (d) => (out += d.toString('utf-8')))
23
+ child.stderr.on('data', (d) => (out += d.toString('utf-8')))
24
+ setTimeout(() => child.kill('SIGTERM'), 8000)
25
+ child.on('close', () => {
26
+ const count = (seq: string): number => out.split(seq).length - 1
27
+ const on = count('\u001b[?1000h')
28
+ const altOff = count('\u001b[?1007l')
29
+ const altOn = count('\u001b[?1007h')
30
+ console.log('MOUSE_CHURN 1000h=' + on + ' 1007l=' + altOff + ' 1007h=' + altOn)
31
+ const ok = on === 1 && altOff === 1 && altOn === 0
32
+ console.log(ok ? 'MOUSE_CHURN_OK' : 'MOUSE_CHURN_FAIL')
33
+ process.exit(ok ? 0 : 1)
34
+ })