zames_pro 2.73.11 → 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 (88) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +37 -7
  3. package/dist/attachments.js +6 -1
  4. package/dist/banner.js +71 -0
  5. package/dist/browser.js +84 -27
  6. package/dist/code-blocks.js +48 -0
  7. package/dist/commands.js +1 -0
  8. package/dist/config-live.js +53 -0
  9. package/dist/config.js +38 -0
  10. package/dist/goal.js +81 -0
  11. package/dist/i18n/common.js +35 -0
  12. package/dist/i18n/config.js +20 -0
  13. package/dist/i18n/deepseek.js +14 -0
  14. package/dist/i18n/help.js +30 -2
  15. package/dist/i18n/qwen.js +4 -0
  16. package/dist/i18n/self-review.js +8 -0
  17. package/dist/i18n/spinner.js +6 -0
  18. package/dist/index.js +487 -65
  19. package/dist/input/layout.js +19 -0
  20. package/dist/input/suggest.js +29 -1
  21. package/dist/input.js +14 -3
  22. package/dist/net-capture.js +18 -0
  23. package/dist/qwen-browser.js +43 -20
  24. package/dist/run-task.js +97 -4
  25. package/dist/theme.js +16 -1
  26. package/dist/tool-summary.js +83 -0
  27. package/dist/tui/ConfigMenu.js +337 -0
  28. package/dist/tui/ConfirmDialog.js +42 -0
  29. package/dist/tui/FullScreenApp.js +113 -0
  30. package/dist/tui/InputLine.js +318 -0
  31. package/dist/tui/LogView.js +48 -0
  32. package/dist/tui/LoginForm.js +83 -0
  33. package/dist/tui/SelectList.js +54 -0
  34. package/dist/tui/StatusBar.js +170 -0
  35. package/dist/tui/TextPrompt.js +41 -0
  36. package/dist/tui/TuiApp.js +29 -0
  37. package/dist/tui/TuiEditor.js +490 -0
  38. package/dist/tui/edit.js +317 -0
  39. package/dist/tui/keys.js +195 -0
  40. package/dist/tui/log-window.js +93 -0
  41. package/dist/tui/login-pure.js +20 -0
  42. package/dist/tui/run-config-menu.js +19 -0
  43. package/dist/tui/run-confirm.js +22 -0
  44. package/dist/tui/run-login.js +43 -0
  45. package/dist/tui/run-select.js +18 -0
  46. package/dist/tui/store.js +104 -0
  47. package/dist/tui/types.js +10 -0
  48. package/dist/tui/useMouseWheel.js +164 -0
  49. package/dist/tui/widgets.js +60 -0
  50. package/package.json +7 -3
  51. package/scripts/coverage-gate.mts +10 -0
  52. package/scripts/inline-status-driver.mts +31 -0
  53. package/scripts/inline-status.tsx +33 -0
  54. package/scripts/mouse-churn.mts +34 -0
  55. package/scripts/qwen-models-smoke.mts +117 -0
  56. package/scripts/render-demo.mts +1 -1
  57. package/scripts/tui-comp-shot.mts +63 -0
  58. package/scripts/tui-components.tsx +62 -0
  59. package/scripts/tui-config-driver.mts +82 -0
  60. package/scripts/tui-config-probe.tsx +74 -0
  61. package/scripts/tui-edit-driver.mts +100 -0
  62. package/scripts/tui-edit-probe.tsx +38 -0
  63. package/scripts/tui-editor-probe.tsx +24 -0
  64. package/scripts/tui-input-driver.mts +54 -0
  65. package/scripts/tui-input-shot.tsx +59 -0
  66. package/scripts/tui-log-driver.mts +35 -0
  67. package/scripts/tui-log-shot.tsx +48 -0
  68. package/scripts/tui-longpaste-driver.mts +56 -0
  69. package/scripts/tui-longpaste.tsx +35 -0
  70. package/scripts/tui-manyselect-shot.mts +34 -0
  71. package/scripts/tui-manyselect.tsx +17 -0
  72. package/scripts/tui-mouse-churn-probe.tsx +28 -0
  73. package/scripts/tui-paste-driver.mts +88 -0
  74. package/scripts/tui-paste-kinds.tsx +76 -0
  75. package/scripts/tui-pastetest.mts +38 -0
  76. package/scripts/tui-probe.tsx +62 -0
  77. package/scripts/tui-repro.tsx +64 -0
  78. package/scripts/tui-resize.mts +70 -0
  79. package/scripts/tui-scroll-driver.mts +53 -0
  80. package/scripts/tui-scroll-probe.tsx +46 -0
  81. package/scripts/tui-shot.mts +106 -0
  82. package/scripts/tui-status-driver.mts +24 -0
  83. package/scripts/tui-status-shot.tsx +27 -0
  84. package/scripts/tui-submit.tsx +57 -0
  85. package/scripts/tui-wheel-driver.mts +61 -0
  86. package/scripts/tui-wheel-probe.tsx +48 -0
  87. package/scripts/tui-widgets-driver.mts +20 -0
  88. package/scripts/tui-widgets.tsx +44 -0
@@ -0,0 +1,318 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The live INPUT LINE — a wrapping, multi-line editor rendered by Ink with a
3
+ // real terminal cursor (useCursor), so IME composition and the caret land on
4
+ // the right cell.
5
+ //
6
+ // The editor state transitions are PURE (src/tui/edit.ts) and the key mapping
7
+ // is PURE (src/tui/keys.ts). This component owns only the imperative parts:
8
+ // reading key events from Ink's useInput, bracketed paste from usePaste,
9
+ // calling the operator callbacks (submit / abort / attach), and painting the
10
+ // visual rows via the shared layoutInput.
11
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
12
+ import { Box, Text, useInput, usePaste, useCursor, useWindowSize, useStdin, } from 'ink';
13
+ import { translate } from '../i18n.js';
14
+ import { layoutInput, SUGGEST_PAGE, } from '../input/layout.js';
15
+ import { insertText, insertPaste, insertNewline, backspace, del, moveLeft, moveRight, moveHome, moveEnd, wordLeft, wordRight, deleteWordLeft, deleteWordRight, killToLineEnd, moveVisualRows, pushUndo, undoEdit, historyUp, historyDown, searchStart, searchNext, searchAccept, searchCancel, searchInput, searchBackspace, recordHistory, afterSubmit, } from './edit.js';
16
+ import { dispatchKey, parseCsiU } from './keys.js';
17
+ import { useSyncExternalStore } from 'react';
18
+ import { matchSlashCommands, suggestWindow, completeSlashCommand, } from '../input/suggest.js';
19
+ /** Render the visual rows of the buffer with the prompt on the first row. */
20
+ function InputRows({ prompt, buf, cols, }) {
21
+ const lay = layoutInput(prompt, buf, 0, cols);
22
+ return (_jsx(Box, { flexDirection: "column", children: lay.rows.map((r, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "#c8b06a", bold: true, children: r.prefix }), r.text] }, i))) }));
23
+ }
24
+ export function InputLine(props) {
25
+ const { columns } = useWindowSize();
26
+ const cols = columns || 80;
27
+ const { setCursorPosition } = useCursor();
28
+ const [suggestSelected, setSuggestSelected] = useState(-1);
29
+ // The visible page of the suggestion list follows the SELECTION: with more
30
+ // matches than one page, Ctrl+N could move the selection past the page and
31
+ // the highlight vanished (the operator "lost" the selected command). Derive
32
+ // the offset from the selection instead of keeping a separate state.
33
+ const suggestOffset = suggestSelected < 0 ? 0 : suggestSelected - (suggestSelected % SUGGEST_PAGE);
34
+ // The editor state lives in the STORE (props.store), read via
35
+ // useSyncExternalStore. This is what makes the typed line survive an Ink
36
+ // remount (a resize / pause): a local useState would be reset to empty on
37
+ // every remount, wiping what the operator had typed.
38
+ const st = useSyncExternalStore(props.store.subscribe, () => props.store.getSnapshot().edit);
39
+ const setSt = useCallback((next) => {
40
+ props.store.update((snap) => ({
41
+ edit: typeof next === 'function' ? next(snap.edit) : next,
42
+ }));
43
+ }, [props.store]);
44
+ // The input lives inside a rounded box: 2 columns of border (left/right) plus
45
+ // 1 column of inner padding on each side. The layout must wrap to the INNER
46
+ // width, or a long line would overflow the frame.
47
+ const innerCols = Math.max(4, cols - 4);
48
+ // Mirror the state into a ref so the useInput callback (a stable closure Ink
49
+ // calls for every key) always sees the latest values.
50
+ const stRef = useRef(st);
51
+ stRef.current = st;
52
+ // Layout for the CURRENT state — cursor positioning + visual-edge check.
53
+ const lay = useMemo(() => layoutInput(props.prompt, st.buf, st.cursor, innerCols), [props.prompt, st.buf, st.cursor, innerCols]);
54
+ const suggestions = useMemo(() => matchSlashCommands(st.buf, props.commands), [st.buf, props.commands]);
55
+ const apply = useCallback((fn) => {
56
+ setSt((prev) => {
57
+ const next = fn(prev);
58
+ if (next.pastes !== prev.pastes)
59
+ props.onPastesChange(next.pastes);
60
+ return next;
61
+ });
62
+ }, [props]);
63
+ const doSubmit = useCallback(() => {
64
+ const s = stRef.current;
65
+ if (props.locked)
66
+ return;
67
+ if (!s.buf.trim())
68
+ return;
69
+ const withHist = recordHistory(s, s.buf);
70
+ const next = afterSubmit(withHist);
71
+ setSt(next);
72
+ props.onHistoryChange(withHist.history);
73
+ // onSubmit MUST run BEFORE the paste list is cleared: the Ink adapter
74
+ // (TuiEditor.handleSubmit) expands `[Pasted lines#N]` markers by reading
75
+ // the store's paste list, so clearing it first sent the literal marker to
76
+ // the agent instead of the pasted text.
77
+ props.onSubmit(s.buf, props.attachments.slice());
78
+ props.onPastesChange(next.pastes);
79
+ }, [props]);
80
+ const handlePaste = useCallback(async (text) => {
81
+ const trimmed = String(text).trim();
82
+ const looksPath = !/\s/.test(trimmed) && /[./\\]/.test(trimmed) && trimmed.length < 500;
83
+ if (looksPath || trimmed.startsWith('data:')) {
84
+ try {
85
+ const att = await props.onAttach(text);
86
+ if (att) {
87
+ apply((s) => insertText(s, att.marker));
88
+ return;
89
+ }
90
+ }
91
+ catch {
92
+ // fall through to plain insert
93
+ }
94
+ }
95
+ apply((s) => insertPaste(s, text));
96
+ }, [apply, props]);
97
+ const tryClipboard = useCallback(async () => {
98
+ try {
99
+ const att = await props.onClipboard();
100
+ if (att)
101
+ apply((s) => insertText(s, att.marker));
102
+ }
103
+ catch {
104
+ // ignore
105
+ }
106
+ }, [apply, props]);
107
+ const runAction = useCallback((action) => {
108
+ if (!action)
109
+ return;
110
+ switch (action.type) {
111
+ case 'submit':
112
+ doSubmit();
113
+ return;
114
+ case 'newline':
115
+ apply(insertNewline);
116
+ return;
117
+ case 'insert':
118
+ apply((s) => pushUndo(insertText(s, action.text), true));
119
+ return;
120
+ case 'backspace':
121
+ apply((s) => backspace(pushUndo(s, true)));
122
+ return;
123
+ case 'delete':
124
+ apply((s) => del(pushUndo(s, true)));
125
+ return;
126
+ case 'left':
127
+ apply(moveLeft);
128
+ return;
129
+ case 'right':
130
+ apply(moveRight);
131
+ return;
132
+ case 'up':
133
+ apply((s) => moveVisualRows(s, -1, props.prompt, innerCols));
134
+ return;
135
+ case 'down':
136
+ apply((s) => moveVisualRows(s, 1, props.prompt, innerCols));
137
+ return;
138
+ case 'home':
139
+ apply(moveHome);
140
+ return;
141
+ case 'end':
142
+ apply(moveEnd);
143
+ return;
144
+ case 'wordLeft':
145
+ apply(wordLeft);
146
+ return;
147
+ case 'wordRight':
148
+ apply(wordRight);
149
+ return;
150
+ case 'deleteWordLeft':
151
+ apply((s) => deleteWordLeft(pushUndo(s)));
152
+ return;
153
+ case 'deleteWordRight':
154
+ apply((s) => deleteWordRight(pushUndo(s)));
155
+ return;
156
+ case 'killToEnd':
157
+ apply((s) => killToLineEnd(pushUndo(s)));
158
+ return;
159
+ case 'clearLine':
160
+ apply((s) => ({ ...pushUndo(s), buf: '', cursor: 0, pastes: [] }));
161
+ return;
162
+ case 'undoEdit':
163
+ apply(undoEdit);
164
+ return;
165
+ case 'complete':
166
+ apply((s) => {
167
+ const next = completeSlashCommand(s.buf, matchSlashCommands(s.buf, props.commands), suggestSelected);
168
+ if (next === null)
169
+ return s;
170
+ return { ...s, buf: next, cursor: Array.from(next).length };
171
+ });
172
+ return;
173
+ case 'suggestNext':
174
+ setSuggestSelected((n) => Math.min(suggestions.length - 1, (n < 0 ? -1 : n) + 1));
175
+ return;
176
+ case 'suggestPrev':
177
+ setSuggestSelected((n) => Math.max(-1, n - 1));
178
+ return;
179
+ case 'historyPrev':
180
+ apply(historyUp);
181
+ return;
182
+ case 'historyNext':
183
+ apply(historyDown);
184
+ return;
185
+ case 'searchStart':
186
+ apply(searchStart);
187
+ return;
188
+ case 'searchNext':
189
+ apply(searchNext);
190
+ return;
191
+ case 'searchAccept':
192
+ apply(searchAccept);
193
+ return;
194
+ case 'searchCancel':
195
+ apply(searchCancel);
196
+ return;
197
+ case 'searchInput':
198
+ apply((s) => searchInput(s, action.text));
199
+ return;
200
+ case 'searchBackspace':
201
+ apply(searchBackspace);
202
+ return;
203
+ case 'escape':
204
+ props.onEscape();
205
+ return;
206
+ case 'ctrlC':
207
+ case 'ctrlD':
208
+ props.onCtrlC();
209
+ return;
210
+ case 'clearScreen':
211
+ props.onClearScreen();
212
+ return;
213
+ case 'clipboard':
214
+ void tryClipboard();
215
+ return;
216
+ case 'copyCode':
217
+ props.onCopyCode();
218
+ return;
219
+ default:
220
+ return;
221
+ }
222
+ }, [
223
+ apply,
224
+ props,
225
+ suggestions,
226
+ suggestSelected,
227
+ innerCols,
228
+ doSubmit,
229
+ tryClipboard,
230
+ ]);
231
+ // The raw-stdin CSI-u handler is bound ONCE (its effect depends only on
232
+ // stdin), so it reads the latest `runAction` through a ref — otherwise the
233
+ // effect would re-bind on every render and could miss keystrokes.
234
+ const runActionRef = useRef(runAction);
235
+ runActionRef.current = runAction;
236
+ useInput((input, key) => {
237
+ const s = stRef.current;
238
+ if (props.locked) {
239
+ if (key.ctrl && input === 'c')
240
+ props.onCtrlC();
241
+ return;
242
+ }
243
+ const curLay = layoutInput(props.prompt, s.buf, s.cursor, innerCols);
244
+ const atTop = curLay.cursorRow === 0;
245
+ const atBottom = curLay.cursorRow === curLay.rows.length - 1;
246
+ const action = dispatchKey(input, key, {
247
+ searchMode: s.searchMode,
248
+ bufferEmpty: s.buf.length === 0,
249
+ // A trailing backslash before the cursor = line continuation: Enter
250
+ // then inserts a newline (and drops the backslash).
251
+ endsWithBackslash: Array.from(s.buf)[s.cursor - 1] === '\\',
252
+ }, { atTop, atBottom });
253
+ runAction(action);
254
+ }, { isActive: true });
255
+ // Bracketed paste arrives on a SEPARATE channel — Ink enables the paste mode
256
+ // automatically. Deliver the whole string to the same handler.
257
+ usePaste((text) => {
258
+ void handlePaste(text);
259
+ });
260
+ // RAW STDIN fallback for the KITTY CSI-u forms Ink DROPS. Ink hides the raw
261
+ // `sequence` for these (it yields `input=''`), so the modified
262
+ // Delete/Backspace bindings never fired (the operator's "Ctrl+Delete does
263
+ // nothing"). The legacy LineEditor parsed the bytes itself; we read the same
264
+ // raw stream here. Sequences split across reads are carried over.
265
+ const { stdin: rawStdin, isRawModeSupported } = useStdin();
266
+ useEffect(() => {
267
+ if (!isRawModeSupported)
268
+ return;
269
+ let carry = '';
270
+ const onRaw = (data) => {
271
+ const s = typeof data === 'string' ? data : data.toString('utf-8');
272
+ const buf = carry + s;
273
+ // Hold a trailing INCOMPLETE CSI-u sequence for the next read.
274
+ const m = /\u001b\[[0-9;:]*$/.exec(buf);
275
+ carry = m ? m[0] : '';
276
+ const scan = carry ? buf.slice(0, buf.length - carry.length) : buf;
277
+ // Find every complete CSI-u sequence and dispatch it.
278
+ const re = /\u001b\[[0-9;:]*u/g;
279
+ let hit;
280
+ while ((hit = re.exec(scan)) !== null) {
281
+ const action = parseCsiU(hit[0]);
282
+ if (action)
283
+ runActionRef.current(action);
284
+ }
285
+ };
286
+ rawStdin.on('data', onRaw);
287
+ return () => {
288
+ rawStdin.removeListener('data', onRaw);
289
+ };
290
+ }, [rawStdin, isRawModeSupported]);
291
+ // Position the real terminal cursor on the input cell. `y` is the row within
292
+ // the live Ink output BELOW the log. Panel layout (top to bottom):
293
+ // row 0 = divider
294
+ // row 1 = status bar
295
+ // row 2 = input box TOP border
296
+ // rows 3.. = input rows
297
+ const cursorRow = lay.cursorRow;
298
+ const cursorX =
299
+ // The input rows sit inside the rounded box, indented by 1 column for the
300
+ // left border and 1 for the box's inner padding. `layoutInput().cursorCol`
301
+ // is ALREADY a column count that includes the prompt prefix (wide chars
302
+ // counted as 2), so the terminal column is just 2 + cursorCol. The old
303
+ // code re-measured `rowText` up to `cursorCol` code points, which
304
+ // DOUBLE-counted wide chars: a CJK buffer put the caret at the line end
305
+ // instead of under the cursor.
306
+ 2 + lay.cursorCol;
307
+ setCursorPosition({ x: cursorX, y: (props.cursorTop ?? 3) + cursorRow });
308
+ const { shown, moreCount } = suggestWindow(suggestions, suggestOffset);
309
+ const t = translate(props.locale);
310
+ return (_jsxs(Box, { flexDirection: "column", children: [suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [shown.map((c, i) => {
311
+ const label = c.hint ? c.name + ' ' + c.hint : c.name;
312
+ const selected = suggestOffset + i === suggestSelected;
313
+ return (_jsxs(Text, { children: [_jsx(Text, { color: "#c8b06a", children: selected ? ' ▸ ' : ' ' }), _jsx(Text, { color: "#c8b06a", children: label }), _jsx(Text, { color: "#5b616e", children: ' ' + c.description })] }, c.name));
314
+ }), moreCount > 0 && (_jsx(Text, { color: "#5b616e", children: ' ' +
315
+ t('editor.more', { n: String(moreCount) }) +
316
+ ' ' +
317
+ t('editor.page_hint') }))] })), _jsx(Box, { borderStyle: "round", borderColor: props.locked ? '#5b616e' : '#c8b06a', paddingLeft: 1, paddingRight: 1, flexDirection: "column", children: _jsx(InputRows, { prompt: props.prompt, buf: st.buf, cols: innerCols }) })] }));
318
+ }
@@ -0,0 +1,48 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Static, Box, Text } from 'ink';
3
+ import { theme } from '../theme.js';
4
+ /** One log entry. `raw` items already carry ANSI colors and are printed
5
+ * verbatim; structured items get a themed prefix. */
6
+ export function LogRow({ entry }) {
7
+ if (entry.raw) {
8
+ // Pre-rendered (markdown answer, tool preview). Print as-is; Ink passes
9
+ // embedded ANSI escape sequences through to the terminal.
10
+ return _jsx(Text, { children: entry.text });
11
+ }
12
+ switch (entry.kind) {
13
+ case 'user':
14
+ return _jsx(Text, { children: theme.user('❯ ') + entry.text });
15
+ case 'assistant':
16
+ return _jsx(Text, { children: entry.text });
17
+ case 'tool':
18
+ return _jsx(Text, { children: entry.text });
19
+ case 'toolResult':
20
+ return _jsx(Text, { children: entry.text });
21
+ case 'warning':
22
+ return _jsx(Text, { children: theme.warn(entry.text) });
23
+ case 'summary':
24
+ return _jsx(Text, { children: entry.text });
25
+ case 'system':
26
+ default:
27
+ return _jsx(Text, { children: theme.system(entry.text) });
28
+ }
29
+ }
30
+ /**
31
+ * A NON-Static log list — a plain column of rows. Used in FULL-SCREEN mode,
32
+ * where <Static> does NOT work: Static is absolutely positioned and is ignored
33
+ * by a fixed-height flex layout (the rows would not appear inside our
34
+ * viewport). The caller windows the entries, so only the visible slice is
35
+ * rendered each frame.
36
+ */
37
+ export function LogList({ entries, }) {
38
+ return (_jsx(Box, { flexDirection: "column", children: entries.map((entry) => (_jsx(LogRow, { entry: entry }, entry.id))) }));
39
+ }
40
+ /**
41
+ * The INLINE log — rendered through Ink's <Static>, which prints each item
42
+ * ONCE above the live area and never repaints it. This is the right tool for
43
+ * the inline layout (the terminal keeps the scrollback), but it must NOT be
44
+ * used inside a fixed-height flex box (see LogList).
45
+ */
46
+ export function LogView({ entries, }) {
47
+ return (_jsx(Static, { items: entries, children: (entry) => (_jsx(Box, { flexDirection: "column", children: _jsx(LogRow, { entry: entry }) }, entry.id)) }));
48
+ }
@@ -0,0 +1,83 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A TUI LOGIN form (username + masked password) rendered by Ink.
3
+ //
4
+ // The browser's own interactive login uses readline / askPassword, which
5
+ // PAUSES the whole TUI (Ink loses the terminal to readline and both fight for
6
+ // stdin). This form keeps the login INSIDE the TUI: the browser calls an
7
+ // injected `onLoginPrompt` callback and never touches stdin itself.
8
+ //
9
+ // It is deliberately simple (two fields, Tab/↑/↓ to switch, Enter to submit,
10
+ // Esc to cancel) — a login is a rare, focused step, not a place for menus.
11
+ import { useState } from 'react';
12
+ import { Box, Text, useInput, useApp } from 'ink';
13
+ import { UI, HintBar } from './widgets.js';
14
+ import { maskSecret, validateLogin } from './login-pure.js';
15
+ /** Mask every character of the password with `*`. */
16
+ function mask(s) {
17
+ return maskSecret(s);
18
+ }
19
+ export function LoginForm({ t, initialUsername = '', initialPassword = '', note, onResult, }) {
20
+ const { exit } = useApp();
21
+ const [field, setField] = useState(initialUsername ? 'pass' : 'user');
22
+ const [user, setUser] = useState(initialUsername);
23
+ const [pass, setPass] = useState(initialPassword);
24
+ const [error, setError] = useState('');
25
+ const finish = (res) => {
26
+ onResult(res);
27
+ exit();
28
+ };
29
+ const submit = () => {
30
+ const v = validateLogin(user, pass);
31
+ if (!v.ok) {
32
+ setError(t(v.reason === 'user' ? 'auth.form.need_user' : 'auth.form.need_pass'));
33
+ setField(v.field);
34
+ return;
35
+ }
36
+ finish({ username: user.trim(), password: pass });
37
+ };
38
+ const append = (ch) => {
39
+ setError('');
40
+ if (field === 'user')
41
+ setUser((v) => v + ch);
42
+ else
43
+ setPass((v) => v + ch);
44
+ };
45
+ const backspace = () => {
46
+ setError('');
47
+ if (field === 'user')
48
+ setUser((v) => Array.from(v).slice(0, -1).join(''));
49
+ else
50
+ setPass((v) => Array.from(v).slice(0, -1).join(''));
51
+ };
52
+ useInput((input, key) => {
53
+ if (key.escape) {
54
+ finish(null);
55
+ return;
56
+ }
57
+ if (key.return) {
58
+ // Enter on the username moves to the password first (so a two-step
59
+ // prompt feels natural); Enter on the password submits.
60
+ if (field === 'user') {
61
+ setField('pass');
62
+ return;
63
+ }
64
+ submit();
65
+ return;
66
+ }
67
+ if (key.tab || key.upArrow || key.downArrow) {
68
+ setField((f) => (f === 'user' ? 'pass' : 'user'));
69
+ return;
70
+ }
71
+ if (key.backspace || key.delete) {
72
+ backspace();
73
+ return;
74
+ }
75
+ if (input && input >= ' ')
76
+ append(input);
77
+ });
78
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.accent, paddingX: 2, children: [_jsx(Text, { color: UI.title, bold: true, children: '🔐 ' + t('auth.form.title') }), note ? _jsx(Text, { color: UI.dim, children: note }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: field === 'user' ? UI.accent : UI.dim, children: (field === 'user' ? '❯ ' : ' ') + t('auth.form.login') + ' ' }), _jsx(Text, { color: UI.text, children: user }), field === 'user' ? _jsx(Text, { color: UI.accent, children: "\u258F" }) : null] }), _jsxs(Box, { children: [_jsx(Text, { color: field === 'pass' ? UI.accent : UI.dim, children: (field === 'pass' ? '❯ ' : ' ') + t('auth.form.password') + ' ' }), _jsx(Text, { color: UI.text, children: mask(pass) }), field === 'pass' ? _jsx(Text, { color: UI.accent, children: "\u258F" }) : null] }), error ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: UI.err, children: '⚠ ' + error }) })) : null, _jsx(Box, { marginTop: 1, children: _jsx(HintBar, { hints: [
79
+ ['Tab/↑↓', t('auth.form.hint_field')],
80
+ ['Enter', t('auth.form.hint_submit')],
81
+ ['Esc', t('auth.form.hint_cancel')],
82
+ ] }) })] }));
83
+ }
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A reusable Ink SELECT LIST — a scrollable, keyboard-navigable list of items
3
+ // with a title and an optional detail/description per row. Used by the model
4
+ // picker and any future list menu (chats, sessions, ...).
5
+ import { useMemo, useState } from 'react';
6
+ import { Box, Text, useInput, useApp, useWindowSize } from 'ink';
7
+ import { UI, HintBar } from './widgets.js';
8
+ export function SelectList({ title, items, initial, onSelect, }) {
9
+ const { exit } = useApp();
10
+ const { rows, columns } = useWindowSize();
11
+ const start = Math.max(0, items.findIndex((it) => it.value === initial));
12
+ const [cursor, setCursor] = useState(start);
13
+ const maxRows = Math.max(3, (rows || 24) - 7);
14
+ // The divider must fit the box: the border (2) + paddingX (2) leave
15
+ // `cols - 6` usable columns. A hardcoded 40-char rule WRAPPED to a second
16
+ // row on a narrow terminal and left a ragged edge on a wide one.
17
+ const innerCols = Math.max(1, (columns || 80) - 6);
18
+ const win = useMemo(() => {
19
+ let top = Math.max(0, cursor - maxRows + 1);
20
+ if (cursor < top)
21
+ top = cursor;
22
+ return { top, end: Math.min(items.length, top + maxRows) };
23
+ }, [cursor, items.length, maxRows]);
24
+ const finish = (value) => {
25
+ onSelect(value);
26
+ exit();
27
+ };
28
+ useInput((input, key) => {
29
+ if (key.escape || (key.ctrl && input === 'c') || input === 'q') {
30
+ finish(null);
31
+ return;
32
+ }
33
+ if (key.upArrow || input === 'k') {
34
+ setCursor((c) => items.length ? (c - 1 + items.length) % items.length : 0);
35
+ return;
36
+ }
37
+ if (key.downArrow || input === 'j') {
38
+ setCursor((c) => (items.length ? (c + 1) % items.length : 0));
39
+ return;
40
+ }
41
+ if (key.return) {
42
+ finish(items[cursor]?.value ?? null);
43
+ }
44
+ });
45
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: UI.title, bold: true, children: '◆ ' + title }), _jsx(Text, { color: UI.dim, children: items.length })] }), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(innerCols) }), items.slice(win.top, win.end).map((it, i) => {
46
+ const idx = win.top + i;
47
+ const sel = idx === cursor;
48
+ return (_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, children: sel ? '❯ ' : ' ' }), _jsx(Text, { color: sel ? UI.accent : undefined, bold: sel, children: it.label }), it.detail ? _jsx(Text, { color: UI.dim, children: ' ' + it.detail }) : null] }, it.value));
49
+ }), _jsx(HintBar, { hints: [
50
+ ['↑/↓', 'выбор'],
51
+ ['Enter', 'ok'],
52
+ ['Esc', 'отмена'],
53
+ ] })] }));
54
+ }
@@ -0,0 +1,170 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useAnimation, useWindowSize } from 'ink';
3
+ import { translate } from '../i18n.js';
4
+ import { DOTS, stripEllipsis } from '../spinner.js';
5
+ import { truncateToWidth, visLen } from '../input/layout.js';
6
+ /** Elapsed seconds of the current animated phase ("0s", "45s", "2m 05s"). */
7
+ function elapsedLabel(startedAt, animating) {
8
+ if (!animating || !startedAt)
9
+ return '';
10
+ const sec = Math.floor((Date.now() - startedAt) / 1000);
11
+ const m = Math.floor(sec / 60);
12
+ const s = sec % 60;
13
+ const text = m > 0 ? m + 'm ' + String(s).padStart(2, '0') + 's' : s + 's';
14
+ return ' · ' + text;
15
+ }
16
+ /** Lifecycle prefix, colored by meaning (generating / settled). */
17
+ function stateLabel(state, locale) {
18
+ if (state.sendState === 'generating') {
19
+ return translate(locale)('state.generating') + ':';
20
+ }
21
+ if (state.sendState === 'settled') {
22
+ return translate(locale)('state.settled') + ':';
23
+ }
24
+ return '';
25
+ }
26
+ /** Visible width of the badge row (used to reserve space for the left
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) {
33
+ let w = 0;
34
+ if (state.statusLine)
35
+ w += visLen(state.statusLine) + 1;
36
+ if (state.provider)
37
+ w += visLen('[' + state.provider + '] ');
38
+ if (state.queueLength > 0)
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
+ }
44
+ if (state.tasks)
45
+ w += visLen(state.tasks) + 1;
46
+ const toggles = [];
47
+ if (state.thinkingEnabled)
48
+ toggles.push('🧠\uFE0E');
49
+ if (state.searchEnabled)
50
+ toggles.push('🌐\uFE0E');
51
+ if (toggles.length)
52
+ w += visLen(toggles.join(' ') + ' ');
53
+ if (state.contextText) {
54
+ const marker = state.contextLevel === 'high'
55
+ ? '⛔ '
56
+ : state.contextLevel === 'warn'
57
+ ? '▲ '
58
+ : '';
59
+ w += visLen(marker + state.contextText);
60
+ }
61
+ return w;
62
+ }
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
+ };
74
+ if (state.statusLine) {
75
+ add('sl', visLen(state.statusLine) + 1, _jsxs(Text, { color: "#808896", children: [state.statusLine, ' '] }, "sl"));
76
+ }
77
+ if (state.provider) {
78
+ add('prov', visLen('[' + state.provider + '] '), _jsx(Text, { color: "#808896", children: '[' + state.provider + '] ' }, "prov"));
79
+ }
80
+ if (state.queueLength > 0) {
81
+ add('q', visLen('⧗' + state.queueLength + ' '), _jsxs(Text, { color: "#c9a86a", children: ['⧗' + state.queueLength, ' '] }, "q"));
82
+ }
83
+ if (state.codeBlocks.length > 0) {
84
+ const idx = state.codeFocus >= 0 ? state.codeFocus + 1 : state.codeBlocks.length;
85
+ const label = '⌘ ' + idx + '/' + state.codeBlocks.length + ' ';
86
+ add('code', visLen(label), _jsx(Text, { color: "#7fc4f0", children: label }, "code"));
87
+ }
88
+ if (state.tasks) {
89
+ add('t', visLen(state.tasks) + 1, _jsxs(Text, { color: "#808896", children: [state.tasks, ' '] }, "t"));
90
+ }
91
+ const toggles = [];
92
+ if (state.thinkingEnabled)
93
+ toggles.push('🧠\uFE0E');
94
+ if (state.searchEnabled)
95
+ toggles.push('🌐\uFE0E');
96
+ if (toggles.length) {
97
+ const label = toggles.join(' ') + ' ';
98
+ add('tg', visLen(label), _jsx(Text, { color: "#6fd0b0", children: label }, "tg"));
99
+ }
100
+ if (state.contextText) {
101
+ const color = state.contextLevel === 'high'
102
+ ? '#c98a80'
103
+ : state.contextLevel === 'warn'
104
+ ? '#c9a86a'
105
+ : '#a9c08c';
106
+ const marker = state.contextLevel === 'high'
107
+ ? '⛔ '
108
+ : state.contextLevel === 'warn'
109
+ ? '▲ '
110
+ : '';
111
+ const label = marker + state.contextText;
112
+ add('ctx', visLen(label), _jsx(Text, { color: color, children: label }, "ctx"));
113
+ }
114
+ if (!all.length)
115
+ return null;
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) });
125
+ }
126
+ export function StatusBar({ state, divider = true, }) {
127
+ // Drive the spinner from Ink's shared animation timer (one timer for the
128
+ // whole tree). The interval matches the old 400ms dot cadence.
129
+ const animating = state.busy || !!state.animBase;
130
+ const { frame } = useAnimation({ interval: 400, isActive: animating });
131
+ const t = translate(state.locale);
132
+ const stateLbl = stateLabel(state, state.locale);
133
+ let left;
134
+ if (state.pendingText) {
135
+ left = '✎ ' + state.pendingText + ' · ' + t('spinner.hint');
136
+ }
137
+ else if (state.animBase) {
138
+ const dots = DOTS[frame % DOTS.length] ?? '';
139
+ const pad = ' '.slice(dots.length);
140
+ left =
141
+ stripEllipsis(state.animBase) +
142
+ dots +
143
+ pad +
144
+ elapsedLabel(state.animStart, animating) +
145
+ ' · ' +
146
+ t('spinner.hint');
147
+ }
148
+ else if (state.statusText) {
149
+ left = state.statusText;
150
+ }
151
+ else {
152
+ left = '';
153
+ }
154
+ const prefix = stateLbl ? stateLbl + ' ' : '';
155
+ // When the log is scrolled up, show a marker in the status line (NOT as a
156
+ // separate row below the input — that pushed the panel up and made the input
157
+ // jump). It replaces the spinner text while scrolled, so it never adds a row.
158
+ const scrollNote = state.scroll > 0 ? `↑ прокручено на ${state.scroll} стр. · PgDn/End` : '';
159
+ const leftText = scrollNote || prefix + left;
160
+ const showLeft = !!leftText;
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.
166
+ const cols = useWindowSize().columns || 80;
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)) })] })] }));
170
+ }