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.
- package/CHANGELOG.md +24 -0
- package/README.md +24 -0
- package/dist/attachments.js +6 -1
- package/dist/config.js +7 -7
- package/dist/i18n/common.js +4 -0
- package/dist/i18n/help.js +8 -0
- package/dist/index.js +9 -0
- package/dist/input/suggest.js +19 -4
- package/dist/theme.js +9 -0
- package/dist/tui/ConfigMenu.js +44 -11
- package/dist/tui/FullScreenApp.js +37 -9
- package/dist/tui/InputLine.js +52 -19
- package/dist/tui/SelectList.js +6 -2
- package/dist/tui/StatusBar.js +66 -36
- package/dist/tui/TuiApp.js +3 -2
- package/dist/tui/edit.js +21 -1
- package/dist/tui/keys.js +33 -0
- package/dist/tui/store.js +1 -1
- package/dist/tui/useMouseWheel.js +80 -29
- package/dist/tui/widgets.js +6 -1
- package/package.json +2 -1
- package/scripts/inline-status-driver.mts +31 -0
- package/scripts/inline-status.tsx +33 -0
- package/scripts/mouse-churn.mts +34 -0
- package/scripts/qwen-models-smoke.mts +117 -0
- package/scripts/tui-comp-shot.mts +63 -0
- package/scripts/tui-components.tsx +62 -0
- package/scripts/tui-config-driver.mts +82 -0
- package/scripts/tui-config-probe.tsx +74 -0
- package/scripts/tui-edit-driver.mts +100 -0
- package/scripts/tui-edit-probe.tsx +38 -0
- package/scripts/tui-editor-probe.tsx +24 -0
- package/scripts/tui-input-driver.mts +54 -0
- package/scripts/tui-input-shot.tsx +59 -0
- package/scripts/tui-log-driver.mts +35 -0
- package/scripts/tui-log-shot.tsx +48 -0
- package/scripts/tui-longpaste-driver.mts +56 -0
- package/scripts/tui-longpaste.tsx +35 -0
- package/scripts/tui-manyselect-shot.mts +34 -0
- package/scripts/tui-manyselect.tsx +17 -0
- package/scripts/tui-mouse-churn-probe.tsx +28 -0
- package/scripts/tui-paste-driver.mts +88 -0
- package/scripts/tui-paste-kinds.tsx +76 -0
- package/scripts/tui-pastetest.mts +38 -0
- package/scripts/tui-repro.tsx +64 -0
- package/scripts/tui-resize.mts +70 -0
- package/scripts/tui-scroll-driver.mts +53 -0
- package/scripts/tui-scroll-probe.tsx +46 -0
- package/scripts/tui-shot.mts +106 -0
- package/scripts/tui-status-driver.mts +24 -0
- package/scripts/tui-status-shot.tsx +27 -0
- package/scripts/tui-submit.tsx +57 -0
- package/scripts/tui-wheel-driver.mts +61 -0
- package/scripts/tui-wheel-probe.tsx +48 -0
- package/scripts/tui-widgets-driver.mts +20 -0
- package/scripts/tui-widgets.tsx +44 -0
package/dist/tui/StatusBar.js
CHANGED
|
@@ -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).
|
|
28
|
-
*
|
|
29
|
-
|
|
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
|
|
35
|
+
w += visLen(state.statusLine) + 1;
|
|
33
36
|
if (state.provider)
|
|
34
|
-
w += state.provider
|
|
37
|
+
w += visLen('[' + state.provider + '] ');
|
|
35
38
|
if (state.queueLength > 0)
|
|
36
|
-
w +=
|
|
37
|
-
if (state.codeBlocks.length > 0)
|
|
38
|
-
|
|
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
|
|
45
|
+
w += visLen(state.tasks) + 1;
|
|
46
|
+
const toggles = [];
|
|
41
47
|
if (state.thinkingEnabled)
|
|
42
|
-
|
|
48
|
+
toggles.push('🧠\uFE0E');
|
|
43
49
|
if (state.searchEnabled)
|
|
44
|
-
|
|
50
|
+
toggles.push('🌐\uFE0E');
|
|
51
|
+
if (toggles.length)
|
|
52
|
+
w += visLen(toggles.join(' ') + ' ');
|
|
45
53
|
if (state.contextText) {
|
|
46
|
-
const marker = state.contextLevel === 'high'
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
75
|
+
add('sl', visLen(state.statusLine) + 1, _jsxs(Text, { color: "#808896", children: [state.statusLine, ' '] }, "sl"));
|
|
56
76
|
}
|
|
57
77
|
if (state.provider) {
|
|
58
|
-
|
|
78
|
+
add('prov', visLen('[' + state.provider + '] '), _jsx(Text, { color: "#808896", children: '[' + state.provider + '] ' }, "prov"));
|
|
59
79
|
}
|
|
60
80
|
if (state.queueLength > 0) {
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
+
const label = marker + state.contextText;
|
|
112
|
+
add('ctx', visLen(label), _jsx(Text, { color: color, children: label }, "ctx"));
|
|
92
113
|
}
|
|
93
|
-
if (!
|
|
114
|
+
if (!all.length)
|
|
94
115
|
return null;
|
|
95
|
-
|
|
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
|
|
133
|
-
//
|
|
134
|
-
//
|
|
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
|
|
137
|
-
const
|
|
138
|
-
|
|
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
|
}
|
package/dist/tui/TuiApp.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
@@ -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
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
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
|
|
65
|
-
* the exact bytes AND their order
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
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".
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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 =
|
|
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
|
-
//
|
|
99
|
-
// either
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/tui/widgets.js
CHANGED
|
@@ -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(
|
|
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.
|
|
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
|
+
})
|