zames_pro 2.73.10 → 2.74.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 (52) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +13 -7
  3. package/dist/banner.js +71 -0
  4. package/dist/browser.js +84 -27
  5. package/dist/code-blocks.js +48 -0
  6. package/dist/commands.js +1 -0
  7. package/dist/config-live.js +53 -0
  8. package/dist/config.js +38 -0
  9. package/dist/goal.js +81 -0
  10. package/dist/i18n/common.js +31 -0
  11. package/dist/i18n/config.js +20 -0
  12. package/dist/i18n/deepseek.js +14 -0
  13. package/dist/i18n/help.js +22 -2
  14. package/dist/i18n/qwen.js +4 -0
  15. package/dist/i18n/self-review.js +8 -0
  16. package/dist/i18n/spinner.js +6 -0
  17. package/dist/index.js +478 -65
  18. package/dist/input/layout.js +19 -0
  19. package/dist/input/suggest.js +13 -0
  20. package/dist/input.js +14 -3
  21. package/dist/net-capture.js +18 -0
  22. package/dist/qwen-browser.js +43 -20
  23. package/dist/run-task.js +97 -4
  24. package/dist/theme.js +7 -1
  25. package/dist/tool-summary.js +83 -0
  26. package/dist/tui/ConfigMenu.js +304 -0
  27. package/dist/tui/ConfirmDialog.js +42 -0
  28. package/dist/tui/FullScreenApp.js +85 -0
  29. package/dist/tui/InputLine.js +285 -0
  30. package/dist/tui/LogView.js +48 -0
  31. package/dist/tui/LoginForm.js +83 -0
  32. package/dist/tui/SelectList.js +50 -0
  33. package/dist/tui/StatusBar.js +140 -0
  34. package/dist/tui/TextPrompt.js +41 -0
  35. package/dist/tui/TuiApp.js +28 -0
  36. package/dist/tui/TuiEditor.js +490 -0
  37. package/dist/tui/edit.js +297 -0
  38. package/dist/tui/keys.js +162 -0
  39. package/dist/tui/log-window.js +93 -0
  40. package/dist/tui/login-pure.js +20 -0
  41. package/dist/tui/run-config-menu.js +19 -0
  42. package/dist/tui/run-confirm.js +22 -0
  43. package/dist/tui/run-login.js +43 -0
  44. package/dist/tui/run-select.js +18 -0
  45. package/dist/tui/store.js +104 -0
  46. package/dist/tui/types.js +10 -0
  47. package/dist/tui/useMouseWheel.js +113 -0
  48. package/dist/tui/widgets.js +55 -0
  49. package/package.json +6 -4
  50. package/scripts/coverage-gate.mts +10 -0
  51. package/scripts/render-demo.mts +1 -1
  52. package/scripts/tui-probe.tsx +62 -0
@@ -0,0 +1,104 @@
1
+ // The TUI STATE STORE — the bridge between the imperative agent code and the
2
+ // React/Ink render tree.
3
+ //
4
+ // Why a store instead of React state:
5
+ // * the agent loop calls the UI from OUTSIDE React (`printAbove`, `toolCall`,
6
+ // `thinking`, ...), i.e. imperatively;
7
+ // * Ink re-renders from React state, which can only be set from inside a
8
+ // component or via a setter we own.
9
+ // So the imperative API writes into this store and the Ink tree subscribes
10
+ // via `useSyncExternalStore`. One direction (calls -> store -> render) means no
11
+ // conflict between the two worlds.
12
+ //
13
+ // The store keeps the WHOLE app state in one immutable snapshot object so
14
+ // `useSyncExternalStore` gets a stable reference between changes (a new object
15
+ // is produced only on an actual mutation).
16
+ import { initialEditState } from './edit.js';
17
+ function initialState() {
18
+ return {
19
+ log: [],
20
+ title: 'zames',
21
+ version: '',
22
+ workdir: '',
23
+ scroll: 0,
24
+ busy: false,
25
+ locked: false,
26
+ statusText: '',
27
+ sendState: '',
28
+ animBase: '',
29
+ animStart: 0,
30
+ pendingText: null,
31
+ edit: initialEditState(),
32
+ pastes: [],
33
+ attachments: [],
34
+ history: [],
35
+ prompt: '> ',
36
+ commands: [],
37
+ locale: 'ru',
38
+ contextText: null,
39
+ contextLevel: null,
40
+ queueLength: 0,
41
+ tasks: '',
42
+ provider: '',
43
+ mouseScroll: false,
44
+ thinkingEnabled: false,
45
+ searchEnabled: false,
46
+ statusLine: '',
47
+ codeBlocks: [],
48
+ codeFocus: -1,
49
+ columns: process.stdout.columns || 80,
50
+ };
51
+ }
52
+ /**
53
+ * A minimal external store for the Ink tree.
54
+ *
55
+ * Mutations go through `set(partial)` which shallow-merges into a NEW snapshot
56
+ * object, so `useSyncExternalStore` sees a changed reference and re-renders.
57
+ * A render that changes nothing produces no new object and no repaint.
58
+ */
59
+ export class TuiStore {
60
+ state = initialState();
61
+ listeners = new Set();
62
+ nextLogId = 1;
63
+ subscribe = (cb) => {
64
+ this.listeners.add(cb);
65
+ return () => this.listeners.delete(cb);
66
+ };
67
+ getSnapshot = () => this.state;
68
+ /** Shallow-merge a partial update and notify subscribers. */
69
+ set(partial) {
70
+ let changed = false;
71
+ for (const k of Object.keys(partial)) {
72
+ if (partial[k] !== this.state[k]) {
73
+ changed = true;
74
+ break;
75
+ }
76
+ }
77
+ if (!changed)
78
+ return;
79
+ this.state = { ...this.state, ...partial };
80
+ this.emit();
81
+ }
82
+ /** Functional update over the whole snapshot. */
83
+ update(fn) {
84
+ this.set(fn(this.state));
85
+ }
86
+ /** Append one permanent log entry (an Ink `<Static>` item). */
87
+ pushLog(kind, text, raw = false) {
88
+ const entry = { id: this.nextLogId++, kind, text, raw };
89
+ this.state = { ...this.state, log: [...this.state.log, entry] };
90
+ this.emit();
91
+ }
92
+ /** Append several entries in one notification (cheaper than N pushes). */
93
+ pushLogs(entries) {
94
+ if (!entries.length)
95
+ return;
96
+ const withIds = entries.map((e) => ({ ...e, id: this.nextLogId++ }));
97
+ this.state = { ...this.state, log: [...this.state.log, ...withIds] };
98
+ this.emit();
99
+ }
100
+ emit() {
101
+ for (const cb of this.listeners)
102
+ cb();
103
+ }
104
+ }
@@ -0,0 +1,10 @@
1
+ // The UI EDITOR interface — the shared surface of the legacy `LineEditor`
2
+ // (src/input.ts) and the new Ink-based `TuiEditor` (src/tui/TuiEditor.tsx).
3
+ //
4
+ // index.ts and run-task.ts talk to the UI ONLY through this interface, so the
5
+ // rendering engine (hand-rolled ANSI vs React/Ink) can be swapped without
6
+ // touching the agent code. Both classes implement every member below.
7
+ //
8
+ // Only the members the agent actually uses are listed; the concrete classes may
9
+ // expose more (internal render helpers, etc.).
10
+ export {};
@@ -0,0 +1,113 @@
1
+ // A hook that reads the terminal's MOUSE-WHEEL events and scrolls the log.
2
+ //
3
+ // Ink 8 does NOT parse mouse events (its `useInput` only handles keys), and it
4
+ // STRIPS unknown CSI sequences, so the wheel never reaches a component. To
5
+ // scroll the chat with the wheel we therefore:
6
+ // 1. enable SGR mouse reporting on the terminal (ESC[?1000h ESC[?1006h);
7
+ // 2. listen to the RAW stdin bytes ourselves (Ink's own listener ignores
8
+ // them, so there is no conflict).
9
+ //
10
+ // The SGR encoding is `ESC [ < Cb ; Cx ; Cy M` where Cb 64 = wheel up and
11
+ // 65 = wheel down (plus modifier bits). We only need Cb.
12
+ //
13
+ // ALTERNATE-SCROLL: while the app is in the ALTERNATE SCREEN (full-screen
14
+ // mode), terminals turn the mouse wheel into ARROW KEYS by default (DECSET
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
17
+ // scrolled the INPUT HISTORY instead of the log (the operator's bug).
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';
28
+ import { useStdin } from 'ink';
29
+ /** True when a chunk contains an SGR mouse wheel event; returns the delta. */
30
+ export function parseWheel(chunk) {
31
+ // Count wheel-ups (down) and wheel-downs (up) in the chunk.
32
+ let delta = 0;
33
+ const re = /\x1b\[<(\d+);\d+;\d+[Mm]/g;
34
+ let m;
35
+ while ((m = re.exec(chunk)) !== null) {
36
+ const cb = Number(m[1]);
37
+ // Button code 64 = wheel up (scroll back), 65 = wheel down (scroll fwd).
38
+ const button = cb & 0b11000011;
39
+ if (button === 64)
40
+ delta += 1;
41
+ else if (button === 65)
42
+ delta -= 1;
43
+ }
44
+ return delta;
45
+ }
46
+ /**
47
+ * The part of an SGR mouse sequence at the END of `chunk` that is still
48
+ * INCOMPLETE (no `M`/`m` terminator yet), or '' when the chunk ends cleanly.
49
+ * A terminal may split one sequence across two `data` events; scanning each
50
+ * chunk alone dropped the wheel notch (the hook keeps this tail and prepends
51
+ * it to the next chunk).
52
+ */
53
+ export function incompleteTail(chunk) {
54
+ const start = chunk.lastIndexOf('\u001b[<');
55
+ if (start === -1)
56
+ return '';
57
+ // Complete if an `M`/`m` follows the introducer before the chunk ends.
58
+ if (/[Mm]/.test(chunk.slice(start + 3)))
59
+ return '';
60
+ return chunk.slice(start);
61
+ }
62
+ const ESC = String.fromCharCode(27);
63
+ /**
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).
68
+ */
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 };
75
+ }
76
+ /**
77
+ * 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.
81
+ */
82
+ export function useMouseWheel(onWheel, enabled = true) {
83
+ const { stdin, isRawModeSupported } = useStdin();
84
+ useEffect(() => {
85
+ if (!isRawModeSupported)
86
+ return;
87
+ const seq = mouseModeSequences();
88
+ process.stdout.write(seq.on);
89
+ // A sequence may be split across two chunks; carry the incomplete tail.
90
+ let carry = '';
91
+ const onData = (data) => {
92
+ const s = typeof data === 'string' ? data : data.toString('utf-8');
93
+ const buf = carry + s;
94
+ carry = incompleteTail(buf);
95
+ const scan = carry ? buf.slice(0, buf.length - carry.length) : buf;
96
+ if (!scan.includes('\u001b[<'))
97
+ 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.
100
+ if (!enabled)
101
+ return;
102
+ const delta = parseWheel(scan);
103
+ if (delta !== 0)
104
+ onWheel(delta);
105
+ };
106
+ stdin.on('data', onData);
107
+ return () => {
108
+ stdin.removeListener('data', onData);
109
+ // Disable mouse reporting on unmount so normal selection works again.
110
+ process.stdout.write(seq.off);
111
+ };
112
+ }, [stdin, isRawModeSupported, onWheel, enabled]);
113
+ }
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ // ---- palette (kept in one place so every menu is consistent) ----
4
+ export const UI = {
5
+ accent: '#c8b06a',
6
+ accentDim: '#7a6b45',
7
+ text: '#cfc9b0',
8
+ dim: '#5b616e',
9
+ border: '#4a5058',
10
+ on: '#6fd0b0',
11
+ off: '#5b616e',
12
+ ok: '#a9c08c',
13
+ warn: '#c9a86a',
14
+ err: '#c98a80',
15
+ system: '#808896',
16
+ title: '#9fb8d8',
17
+ };
18
+ /** `[x]` / `[ ]` — a checkbox for a boolean value. */
19
+ export function Checkbox({ checked, focused, }) {
20
+ return (_jsx(Text, { color: checked ? UI.on : UI.off, bold: focused, children: checked ? '[x]' : '[ ]' }));
21
+ }
22
+ /** `‹ value ›` — an inline SELECT for an enum value. The arrows hint that
23
+ * ←/→ (or Space) cycle the option. */
24
+ export function Select({ value, focused, }) {
25
+ return (_jsxs(Text, { children: [_jsx(Text, { color: UI.off, children: '‹ ' }), _jsx(Text, { color: focused ? UI.accent : UI.text, bold: focused, children: value }), _jsx(Text, { color: UI.off, children: ' ›' })] }));
26
+ }
27
+ /** A framed panel (rounded border) with an optional title bar and footer. */
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] }));
30
+ }
31
+ /** A single hint chip: `↑/↓ select`. */
32
+ export function Hint({ keys, label, }) {
33
+ return (_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, children: keys }), _jsx(Text, { color: UI.dim, children: ' ' + label })] }));
34
+ }
35
+ /** A row of hint chips separated by ` · `. */
36
+ export function HintBar({ hints, }) {
37
+ return (_jsx(Text, { children: hints.map(([k, l], i) => (_jsxs(Text, { children: [i > 0 ? _jsx(Text, { color: UI.dim, children: ' · ' }) : null, _jsx(Text, { color: UI.accent, children: k }), _jsx(Text, { color: UI.dim, children: ' ' + l })] }, i))) }));
38
+ }
39
+ /**
40
+ * A CODE BLOCK with a syntax-highlighted body and a copy affordance.
41
+ *
42
+ * The agent's answers are printed as pre-rendered ANSI (markdown.ts already
43
+ * highlights with cli-highlight), so the live log does NOT use this component —
44
+ * it exists for menus/panels that show a snippet inline (and for a future
45
+ * structured log). Copying is a KEY, not a button: a terminal has no pointer.
46
+ * Pass `onCopy` and show `hint` so the operator learns the binding.
47
+ */
48
+ export function CodeBlock({ code, lang, highlighted, width, hint, focused, onCopy, }) {
49
+ // React cannot call `onCopy` from a keydown here (Ink has no per-element
50
+ // focus ring by default); the caller wires the key and passes `focused` to
51
+ // show which block it will copy. `onCopy` is kept so a future focus manager
52
+ // can invoke it without changing the call sites.
53
+ void onCopy;
54
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: focused ? UI.accent : UI.border, paddingX: 1, width: width, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: UI.dim, children: lang || 'code' }), hint ? _jsx(Text, { color: UI.accent, children: hint }) : null] }), (highlighted || code).split(String.fromCharCode(10)).map((line, i) => (_jsx(Text, { children: line || ' ' }, i)))] }));
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.73.10",
3
+ "version": "2.74.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": {
@@ -36,7 +36,6 @@
36
36
  "qwen-smoke": "tsx scripts/qwen-smoke.mts",
37
37
  "render-demo": "tsx scripts/render-demo.mts",
38
38
  "render-how": "tsx scripts/render-how-it-works.mts",
39
- "render-how": "tsx scripts/render-how-it-works.mts",
40
39
  "build:readme": "tsx scripts/build-readme.mts",
41
40
  "postinstall": "node scripts/postinstall.mjs",
42
41
  "prepare": "husky"
@@ -67,16 +66,18 @@
67
66
  ],
68
67
  "license": "MIT",
69
68
  "engines": {
70
- "node": ">=20"
69
+ "node": ">=22"
71
70
  },
72
71
  "packageManager": "npm@11.19.0",
73
72
  "dependencies": {
74
73
  "@modelcontextprotocol/sdk": "^1.30.1",
75
74
  "chalk": "^5.3.0",
76
75
  "cli-highlight": "^2.1.11",
76
+ "ink": "^8.0.0",
77
77
  "markdansi": "^0.3.4",
78
78
  "ora": "^8.0.1",
79
- "playwright": "^1.48.0"
79
+ "playwright": "^1.48.0",
80
+ "react": "^19.3.0"
80
81
  },
81
82
  "publishConfig": {
82
83
  "access": "public"
@@ -92,6 +93,7 @@
92
93
  "devDependencies": {
93
94
  "@eslint/js": "^9.39.5",
94
95
  "@types/node": "^26.6.2",
96
+ "@types/react": "^19.3.0",
95
97
  "eslint": "^9.39.5",
96
98
  "eslint-config-prettier": "^10.1.8",
97
99
  "globals": "^17.12.0",
@@ -39,6 +39,16 @@ const FLOORS = {
39
39
  // C3: the pure /config subcommand dispatcher — silent routing regressions
40
40
  // (a wrong subcommand branch) are invisible without tests.
41
41
  'config-command.ts': 85,
42
+ // The Ink TUI's PURE halves: the editor state transitions (tui/edit.ts) and
43
+ // the key dispatcher (tui/keys.ts). A silent regression there breaks the
44
+ // operator's editing without any UI test catching it. NOTE: the gate keys on
45
+ // the BASENAME (its regex has no slash), so these are 'edit.ts' / 'keys.ts' /
46
+ // 'store.ts' — unique in the tree today.
47
+ 'edit.ts': 85,
48
+ 'keys.ts': 85,
49
+ 'store.ts': 85,
50
+ 'banner.ts': 85,
51
+ 'tool-summary.ts': 85,
42
52
  // N12: web.ts holds the SSRF guard and the DuckDuckGo/HTML parsers — a
43
53
  // regression there is silent. Floors sit below the current values.
44
54
  'web.ts': 40,
@@ -21,7 +21,7 @@ const out = path.join(root, 'docs', 'demo.png')
21
21
 
22
22
  const C = {
23
23
  bg: '#23272e',
24
- user: '#b4b8d0',
24
+ user: '#8fd4f0',
25
25
  prompt: '#c8b06a',
26
26
  dir: '#7fc4f0',
27
27
  assistant: '#cfc9b0',
@@ -0,0 +1,62 @@
1
+ // A tiny Ink harness that mounts the REAL FullScreenApp with a scripted store,
2
+ // so a pty run can assert the on-screen geometry (input box height, cursor
3
+ // row) instead of grepping escape bytes. Run under `script -qec` and feed keys
4
+ // on stdin; the terminal output is replayed by the VT100 emulator in the test.
5
+ //
6
+ // NOT shipped: a dev probe only (lives in scripts/, not in `files`).
7
+
8
+ import React from 'react'
9
+ import fs from 'node:fs'
10
+ import { render } from 'ink'
11
+ import { TuiStore } from '../src/tui/store.js'
12
+ import { FullScreenApp } from '../src/tui/FullScreenApp.js'
13
+ import { TuiApp } from '../src/tui/TuiApp.js'
14
+ import { initialEditState } from '../src/tui/edit.js'
15
+
16
+ const store = new TuiStore()
17
+ store.set({
18
+ title: 'probe',
19
+ version: '0.0.0',
20
+ workdir: '/tmp',
21
+ prompt: '> ',
22
+ locale: 'en',
23
+ commands: [],
24
+ edit: { ...initialEditState(), buf: process.argv[2] ?? '' },
25
+ // A copyable code block, so a pty run can press Ctrl+Y and assert the copy
26
+ // callback fired (the raw block, not the highlighted render).
27
+ codeBlocks: [{ lang: 'ts', text: 'const a = 1', index: 1 }],
28
+ })
29
+
30
+ // A few log rows so the viewport is not empty.
31
+ store.pushLog('system', 'log line one')
32
+ store.pushLog('system', 'log line two')
33
+
34
+ const COPY_LOG = process.env.PROBE_COPY_LOG || ''
35
+ const INLINE = process.env.PROBE_INLINE === '1'
36
+
37
+ const inputProps = {
38
+ onSubmit: () => {},
39
+ onEscape: () => {},
40
+ onCtrlC: () => {},
41
+ onCopyCode: () => {
42
+ if (COPY_LOG) fs.appendFileSync(COPY_LOG, 'copy' + String.fromCharCode(10))
43
+ },
44
+ onAttach: () => null,
45
+ onClipboard: () => Promise.resolve(null),
46
+ onHistoryChange: () => {},
47
+ onClearScreen: () => {},
48
+ attachments: [],
49
+ onPastesChange: () => {},
50
+ }
51
+
52
+ render(
53
+ INLINE ? (
54
+ <TuiApp store={store} inputProps={inputProps} />
55
+ ) : (
56
+ <FullScreenApp store={store} inputProps={inputProps} />
57
+ ),
58
+ { exitOnCtrlC: false, patchConsole: false, alternateScreen: !INLINE },
59
+ )
60
+
61
+ // Keep the app alive until killed.
62
+ setInterval(() => {}, 1 << 30)