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
@@ -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.1",
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,51 @@
1
+ // Drive the live-like probe: type a seed, then send Ctrl+Delete in EVERY
2
+ // encoding a terminal might use, and print the raw bytes + the buffer.
3
+ import { spawn } from 'node:child_process'
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+
7
+ const ESC = String.fromCharCode(27)
8
+ const LOG = '/tmp/ctd-live.log'
9
+ try {
10
+ fs.unlinkSync(LOG)
11
+ } catch {
12
+ /* ignore */
13
+ }
14
+
15
+ const child = spawn(
16
+ 'script',
17
+ ['-qec', 'npx tsx scripts/ctd-live.tsx', '/dev/null'],
18
+ {
19
+ cwd: path.resolve('.'),
20
+ env: { ...process.env, COLUMNS: '80', LINES: '24', PROBE_CTD_LOG: LOG },
21
+ },
22
+ )
23
+ child.stdout.on('data', () => {})
24
+ child.stderr.on('data', () => {})
25
+
26
+ const seq = process.argv[2] || 'xterm'
27
+ const enc: Record<string, string> = {
28
+ xterm: ESC + '[3;5~',
29
+ kitty: ESC + '[3;5u',
30
+ vt: ESC + '[3;5:1~',
31
+ }
32
+ const bytes = enc[seq] ?? seq
33
+
34
+ setTimeout(() => {
35
+ child.stdin.write('foo bar baz') // seed
36
+ setTimeout(() => {
37
+ child.stdin.write(bytes) // the Ctrl+Delete encoding
38
+ setTimeout(() => {
39
+ const log = fs.readFileSync(LOG, 'utf-8')
40
+ process.stdout.write('=== ' + seq + ' ===' + String.fromCharCode(10))
41
+ process.stdout.write(
42
+ log
43
+ .split(String.fromCharCode(10))
44
+ .filter((l) => l.startsWith('RAW') || l.startsWith('BUF'))
45
+ .join(String.fromCharCode(10)) + String.fromCharCode(10),
46
+ )
47
+ child.kill('SIGTERM')
48
+ }, 500)
49
+ }, 400)
50
+ }, 5000)
51
+ child.on('close', () => process.exit(0))
@@ -0,0 +1,33 @@
1
+ // Minimal LIVE-like probe: the REAL TuiEditor exactly as index.ts builds it
2
+ // (default options), plus a raw-stdin TAP that logs every byte the terminal
3
+ // sends, so we can see EXACTLY what Ctrl+Delete produces on this terminal.
4
+ import fs from 'node:fs'
5
+ import { TuiEditor } from '../src/tui/TuiEditor.js'
6
+
7
+ const LOG = process.env.PROBE_CTD_LOG || '/tmp/ctd-live.log'
8
+ fs.writeFileSync(LOG, '')
9
+ const w = (s: string): void => {
10
+ fs.appendFileSync(LOG, s + String.fromCharCode(10))
11
+ }
12
+
13
+ const ed = new TuiEditor({
14
+ prompt: '❯ › ',
15
+ commands: [],
16
+ locale: 'ru',
17
+ alternateScreen: true,
18
+ })
19
+ ed.onSubmit = () => {}
20
+ ed.onAttach = () => null
21
+ ed.onClipboard = () => Promise.resolve(null)
22
+ ed.start()
23
+
24
+ // Tap raw stdin: hex-dump every chunk so we see the exact bytes.
25
+ process.stdin.on('data', (d: Buffer) => {
26
+ w('RAW ' + d.toString('hex') + ' ' + JSON.stringify(d.toString('utf-8')))
27
+ })
28
+ // Log the buffer after every change.
29
+ ed.store.subscribe(() => {
30
+ const s = ed.store.getSnapshot().edit
31
+ w('BUF ' + JSON.stringify(s.buf) + ' cur=' + s.cursor)
32
+ })
33
+ setInterval(() => {}, 1 << 30)
@@ -0,0 +1,37 @@
1
+ // Send `ESC d` (what VSCode sends for Ctrl+Delete) to the REAL TuiEditor and
2
+ // print the resulting buffer.
3
+ import { spawn } from 'node:child_process'
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ const ESC = String.fromCharCode(27)
7
+ const LOG = '/tmp/ctd-live.log'
8
+ try {
9
+ fs.unlinkSync(LOG)
10
+ } catch {}
11
+ const child = spawn(
12
+ 'script',
13
+ ['-qec', 'npx tsx scripts/ctd-live.tsx', '/dev/null'],
14
+ {
15
+ cwd: path.resolve('.'),
16
+ env: { ...process.env, COLUMNS: '80', LINES: '24', PROBE_CTD_LOG: LOG },
17
+ },
18
+ )
19
+ child.stdout.on('data', () => {})
20
+ child.stderr.on('data', () => {})
21
+ setTimeout(() => {
22
+ child.stdin.write('foo bar baz')
23
+ setTimeout(() => {
24
+ child.stdin.write(ESC + 'd')
25
+ setTimeout(() => {
26
+ const log = fs.readFileSync(LOG, 'utf-8')
27
+ process.stdout.write(
28
+ log
29
+ .split(String.fromCharCode(10))
30
+ .filter((l) => l.startsWith('RAW') || l.startsWith('BUF'))
31
+ .join(String.fromCharCode(10)) + String.fromCharCode(10),
32
+ )
33
+ child.kill('SIGTERM')
34
+ }, 500)
35
+ }, 400)
36
+ }, 5000)
37
+ child.on('close', () => process.exit(0))
@@ -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
+ })
@@ -0,0 +1,117 @@
1
+ // Qwen model-switching self-smoke: launch a REAL isolated QwenBrowser, list
2
+ // the model catalogue, then SWITCH to several models and ask a question with
3
+ // each — the operator explicitly asked that model switching be exercised.
4
+ //
5
+ // Isolation mirrors qwen-smoke.mts: a throwaway HOME + a read-only COPY of the
6
+ // real qwen-profile (the live profile is never opened).
7
+ //
8
+ // Usage: npx tsx scripts/qwen-models-smoke.mts [--headed] [--all]
9
+
10
+ import fs from 'fs'
11
+ import path from 'path'
12
+ import os from 'os'
13
+
14
+ const HEADED = process.argv.includes('--headed')
15
+ const ALL = process.argv.includes('--all')
16
+ const REAL_HOME = os.homedir()
17
+ const PROFILE_SRC = path.join(REAL_HOME, '.zames', 'qwen-profile')
18
+
19
+ if (!process.env.ZAMES_QWEN_MODELS_HOME) {
20
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'zames-qwen-models-'))
21
+ process.env.HOME = tmp
22
+ process.env.ZAMES_QWEN_MODELS_HOME = tmp
23
+ fs.mkdirSync(path.join(tmp, '.zames'), { recursive: true })
24
+ fs.mkdirSync(path.join(tmp, '.cache'), { recursive: true })
25
+ const realCfg = path.join(REAL_HOME, '.zames', 'config.json')
26
+ if (fs.existsSync(realCfg)) {
27
+ fs.copyFileSync(realCfg, path.join(tmp, '.zames', 'config.json'))
28
+ }
29
+ const realPw = path.join(REAL_HOME, '.cache', 'ms-playwright')
30
+ if (fs.existsSync(realPw)) {
31
+ fs.symlinkSync(realPw, path.join(tmp, '.cache', 'ms-playwright'))
32
+ }
33
+ if (fs.existsSync(PROFILE_SRC)) {
34
+ fs.cpSync(PROFILE_SRC, path.join(tmp, '.zames', 'qwen-profile'), {
35
+ recursive: true,
36
+ filter: (src) => !/^Singleton/.test(path.basename(src)),
37
+ })
38
+ }
39
+ }
40
+
41
+ const { QwenBrowser } = await import('../src/qwen-browser.ts')
42
+ const { loadConfig } = await import('../src/config.ts')
43
+ const cfg = loadConfig()
44
+
45
+ const results: Array<{ name: string; ok: boolean; note?: string }> = []
46
+ const record = (name: string, ok: boolean, note?: string): void => {
47
+ results.push({ name, ok, note })
48
+ console.log((ok ? 'PASS ' : 'FAIL ') + name + (note ? ' — ' + note : ''))
49
+ }
50
+
51
+ const browser = new QwenBrowser({
52
+ headless: !HEADED,
53
+ debug: false,
54
+ channel: cfg.browserChannel,
55
+ auth: cfg.browser.auth,
56
+ locale: 'ru',
57
+ minSendIntervalMs: 0,
58
+ answerTimeoutMs: 180000,
59
+ webSearch: false,
60
+ deepThinking: false,
61
+ })
62
+ browser.onNotice = (t: string) => console.log(' [notice] ' + t)
63
+
64
+ async function main(): Promise<void> {
65
+ await browser.launch()
66
+ const timedOut = await Promise.race([
67
+ browser.waitForLogin().then(() => false),
68
+ new Promise<boolean>((r) => setTimeout(() => r(true), 60000)),
69
+ ])
70
+ if (timedOut) {
71
+ record('login', false, 'timed out')
72
+ await browser.close().catch(() => {})
73
+ process.exit(2)
74
+ }
75
+ record('login', true)
76
+
77
+ const models = await browser.listModels()
78
+ record(
79
+ 'listModels',
80
+ models.length > 0,
81
+ 'n=' + models.length + ' ' + models.map((m) => m.id).join(','),
82
+ )
83
+
84
+ // Switch through a few models and ask with each. Prefer distinct vendors so
85
+ // the dropdown selection really changes the backend.
86
+ const wanted = ALL ? models : models.slice(0, 3)
87
+ for (const m of wanted) {
88
+ try {
89
+ await browser.newChat()
90
+ browser.model = m.id
91
+ const ans = await browser.ask('Reply with exactly: OK', { agent: true })
92
+ const ok = /OK/i.test(ans)
93
+ record('model:' + m.id, ok, ok ? '' : 'got: ' + ans.slice(0, 60))
94
+ } catch (e) {
95
+ record('model:' + m.id, false, (e as Error).message)
96
+ }
97
+ }
98
+
99
+ await browser.close().catch(() => {})
100
+ const failed = results.filter((r) => !r.ok)
101
+ console.log(
102
+ '\nqwen-models-smoke: ' +
103
+ (results.length - failed.length) +
104
+ '/' +
105
+ results.length +
106
+ ' passed',
107
+ )
108
+ if (!process.env.ZAMES_QWEN_MODELS_KEEP) {
109
+ fs.rmSync(process.env.ZAMES_QWEN_MODELS_HOME as string, {
110
+ recursive: true,
111
+ force: true,
112
+ })
113
+ }
114
+ process.exit(failed.length ? 1 : 0)
115
+ }
116
+
117
+ void main()
@@ -0,0 +1,20 @@
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(20, 80)
6
+ const child = spawn(
7
+ 'script',
8
+ ['-qec', 'npx tsx scripts/tui-repro.tsx', '/dev/null'],
9
+ {
10
+ cwd: path.resolve('.'),
11
+ env: { ...process.env, COLUMNS: '80', LINES: '20', PROBE_LOCALE: 'ru' },
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
+ process.stdout.write(term.render() + NL)
18
+ child.kill('SIGTERM')
19
+ }, 5000)
20
+ child.on('close', () => process.exit(0))
@@ -0,0 +1,63 @@
1
+ // Screenshot a standalone TUI component under a pty, optionally driving keys.
2
+ // Usage: npx tsx scripts/tui-comp-shot.mts --component=select --keys=j,j --cols=80 --rows=24
3
+ import { spawn } from 'node:child_process'
4
+ import path from 'node:path'
5
+ import { Term } from '../test/vt100.mts'
6
+
7
+ const ESC = String.fromCharCode(27)
8
+ const NL = String.fromCharCode(10)
9
+ function arg(name: string, def = ''): string {
10
+ const hit = process.argv.find((a) => a.startsWith('--' + name + '='))
11
+ return hit ? hit.slice(name.length + 3) : def
12
+ }
13
+ const component = arg('component', 'select')
14
+ const cols = Number(arg('cols', '80'))
15
+ const rows = Number(arg('rows', '24'))
16
+ const keys = arg('keys', '')
17
+ .split(',')
18
+ .filter((s) => s.length > 0)
19
+ .map((s) =>
20
+ s === 'UP'
21
+ ? ESC + '[A'
22
+ : s === 'DOWN'
23
+ ? ESC + '[B'
24
+ : s === 'TAB'
25
+ ? String.fromCharCode(9)
26
+ : s === 'ENTER'
27
+ ? String.fromCharCode(13)
28
+ : s === 'ESC'
29
+ ? ESC
30
+ : s,
31
+ )
32
+
33
+ const term = new Term(rows, cols)
34
+ const child = spawn(
35
+ 'script',
36
+ ['-qec', 'npx tsx scripts/tui-components.tsx', '/dev/null'],
37
+ {
38
+ cwd: path.resolve('.'),
39
+ env: {
40
+ ...process.env,
41
+ COLUMNS: String(cols),
42
+ LINES: String(rows),
43
+ PROBE_COMPONENT: component,
44
+ },
45
+ },
46
+ )
47
+ child.stdout.on('data', (d) => term.write(d.toString('utf-8')))
48
+ child.stderr.on('data', (d) => term.write(d.toString('utf-8')))
49
+ let i = 0
50
+ const tick = (): void => {
51
+ if (i < keys.length) {
52
+ child.stdin.write(keys[i])
53
+ i++
54
+ setTimeout(tick, 250)
55
+ } else {
56
+ setTimeout(() => {
57
+ process.stdout.write(term.render() + NL)
58
+ child.kill('SIGTERM')
59
+ }, 400)
60
+ }
61
+ }
62
+ setTimeout(tick, 4500)
63
+ child.on('close', () => process.exit(0))