zames_pro 2.73.11 → 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.
- package/CHANGELOG.md +24 -0
- package/README.md +13 -7
- package/dist/banner.js +71 -0
- package/dist/browser.js +84 -27
- package/dist/code-blocks.js +48 -0
- package/dist/commands.js +1 -0
- package/dist/config-live.js +53 -0
- package/dist/config.js +38 -0
- package/dist/goal.js +81 -0
- package/dist/i18n/common.js +31 -0
- package/dist/i18n/config.js +20 -0
- package/dist/i18n/deepseek.js +14 -0
- package/dist/i18n/help.js +22 -2
- package/dist/i18n/qwen.js +4 -0
- package/dist/i18n/self-review.js +8 -0
- package/dist/i18n/spinner.js +6 -0
- package/dist/index.js +478 -65
- package/dist/input/layout.js +19 -0
- package/dist/input/suggest.js +13 -0
- package/dist/input.js +14 -3
- package/dist/net-capture.js +18 -0
- package/dist/qwen-browser.js +43 -20
- package/dist/run-task.js +97 -4
- package/dist/theme.js +7 -1
- package/dist/tool-summary.js +83 -0
- package/dist/tui/ConfigMenu.js +304 -0
- package/dist/tui/ConfirmDialog.js +42 -0
- package/dist/tui/FullScreenApp.js +85 -0
- package/dist/tui/InputLine.js +285 -0
- package/dist/tui/LogView.js +48 -0
- package/dist/tui/LoginForm.js +83 -0
- package/dist/tui/SelectList.js +50 -0
- package/dist/tui/StatusBar.js +140 -0
- package/dist/tui/TextPrompt.js +41 -0
- package/dist/tui/TuiApp.js +28 -0
- package/dist/tui/TuiEditor.js +490 -0
- package/dist/tui/edit.js +297 -0
- package/dist/tui/keys.js +162 -0
- package/dist/tui/log-window.js +93 -0
- package/dist/tui/login-pure.js +20 -0
- package/dist/tui/run-config-menu.js +19 -0
- package/dist/tui/run-confirm.js +22 -0
- package/dist/tui/run-login.js +43 -0
- package/dist/tui/run-select.js +18 -0
- package/dist/tui/store.js +104 -0
- package/dist/tui/types.js +10 -0
- package/dist/tui/useMouseWheel.js +113 -0
- package/dist/tui/widgets.js +55 -0
- package/package.json +6 -3
- package/scripts/coverage-gate.mts +10 -0
- package/scripts/render-demo.mts +1 -1
- package/scripts/tui-probe.tsx +62 -0
|
@@ -0,0 +1,285 @@
|
|
|
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, useMemo, useRef, useState } from 'react';
|
|
12
|
+
import { Box, Text, useInput, usePaste, useCursor, useWindowSize } from 'ink';
|
|
13
|
+
import { translate } from '../i18n.js';
|
|
14
|
+
import { layoutInput, visLen, charWidth, 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 } 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.onPastesChange(next.pastes);
|
|
73
|
+
props.onHistoryChange(withHist.history);
|
|
74
|
+
props.onSubmit(s.buf, props.attachments.slice());
|
|
75
|
+
}, [props]);
|
|
76
|
+
const handlePaste = useCallback(async (text) => {
|
|
77
|
+
const trimmed = String(text).trim();
|
|
78
|
+
const looksPath = !/\s/.test(trimmed) && /[./\\]/.test(trimmed) && trimmed.length < 500;
|
|
79
|
+
if (looksPath || trimmed.startsWith('data:')) {
|
|
80
|
+
try {
|
|
81
|
+
const att = await props.onAttach(text);
|
|
82
|
+
if (att) {
|
|
83
|
+
apply((s) => insertText(s, att.marker));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// fall through to plain insert
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
apply((s) => insertPaste(s, text));
|
|
92
|
+
}, [apply, props]);
|
|
93
|
+
const tryClipboard = useCallback(async () => {
|
|
94
|
+
try {
|
|
95
|
+
const att = await props.onClipboard();
|
|
96
|
+
if (att)
|
|
97
|
+
apply((s) => insertText(s, att.marker));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// ignore
|
|
101
|
+
}
|
|
102
|
+
}, [apply, props]);
|
|
103
|
+
const runAction = useCallback((action) => {
|
|
104
|
+
if (!action)
|
|
105
|
+
return;
|
|
106
|
+
switch (action.type) {
|
|
107
|
+
case 'submit':
|
|
108
|
+
doSubmit();
|
|
109
|
+
return;
|
|
110
|
+
case 'newline':
|
|
111
|
+
apply(insertNewline);
|
|
112
|
+
return;
|
|
113
|
+
case 'insert':
|
|
114
|
+
apply((s) => pushUndo(insertText(s, action.text), true));
|
|
115
|
+
return;
|
|
116
|
+
case 'backspace':
|
|
117
|
+
apply((s) => backspace(pushUndo(s, true)));
|
|
118
|
+
return;
|
|
119
|
+
case 'delete':
|
|
120
|
+
apply((s) => del(pushUndo(s, true)));
|
|
121
|
+
return;
|
|
122
|
+
case 'left':
|
|
123
|
+
apply(moveLeft);
|
|
124
|
+
return;
|
|
125
|
+
case 'right':
|
|
126
|
+
apply(moveRight);
|
|
127
|
+
return;
|
|
128
|
+
case 'up':
|
|
129
|
+
apply((s) => moveVisualRows(s, -1, props.prompt, innerCols));
|
|
130
|
+
return;
|
|
131
|
+
case 'down':
|
|
132
|
+
apply((s) => moveVisualRows(s, 1, props.prompt, innerCols));
|
|
133
|
+
return;
|
|
134
|
+
case 'home':
|
|
135
|
+
apply(moveHome);
|
|
136
|
+
return;
|
|
137
|
+
case 'end':
|
|
138
|
+
apply(moveEnd);
|
|
139
|
+
return;
|
|
140
|
+
case 'wordLeft':
|
|
141
|
+
apply(wordLeft);
|
|
142
|
+
return;
|
|
143
|
+
case 'wordRight':
|
|
144
|
+
apply(wordRight);
|
|
145
|
+
return;
|
|
146
|
+
case 'deleteWordLeft':
|
|
147
|
+
apply((s) => deleteWordLeft(pushUndo(s)));
|
|
148
|
+
return;
|
|
149
|
+
case 'deleteWordRight':
|
|
150
|
+
apply((s) => deleteWordRight(pushUndo(s)));
|
|
151
|
+
return;
|
|
152
|
+
case 'killToEnd':
|
|
153
|
+
apply((s) => killToLineEnd(pushUndo(s)));
|
|
154
|
+
return;
|
|
155
|
+
case 'clearLine':
|
|
156
|
+
apply((s) => ({ ...pushUndo(s), buf: '', cursor: 0, pastes: [] }));
|
|
157
|
+
return;
|
|
158
|
+
case 'undoEdit':
|
|
159
|
+
apply(undoEdit);
|
|
160
|
+
return;
|
|
161
|
+
case 'complete':
|
|
162
|
+
apply((s) => {
|
|
163
|
+
const next = completeSlashCommand(s.buf, matchSlashCommands(s.buf, props.commands), suggestSelected);
|
|
164
|
+
if (next === null)
|
|
165
|
+
return s;
|
|
166
|
+
return { ...s, buf: next, cursor: Array.from(next).length };
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
case 'suggestNext':
|
|
170
|
+
setSuggestSelected((n) => Math.min(suggestions.length - 1, (n < 0 ? -1 : n) + 1));
|
|
171
|
+
return;
|
|
172
|
+
case 'suggestPrev':
|
|
173
|
+
setSuggestSelected((n) => Math.max(-1, n - 1));
|
|
174
|
+
return;
|
|
175
|
+
case 'historyPrev':
|
|
176
|
+
apply(historyUp);
|
|
177
|
+
return;
|
|
178
|
+
case 'historyNext':
|
|
179
|
+
apply(historyDown);
|
|
180
|
+
return;
|
|
181
|
+
case 'searchStart':
|
|
182
|
+
apply(searchStart);
|
|
183
|
+
return;
|
|
184
|
+
case 'searchNext':
|
|
185
|
+
apply(searchNext);
|
|
186
|
+
return;
|
|
187
|
+
case 'searchAccept':
|
|
188
|
+
apply(searchAccept);
|
|
189
|
+
return;
|
|
190
|
+
case 'searchCancel':
|
|
191
|
+
apply(searchCancel);
|
|
192
|
+
return;
|
|
193
|
+
case 'searchInput':
|
|
194
|
+
apply((s) => searchInput(s, action.text));
|
|
195
|
+
return;
|
|
196
|
+
case 'searchBackspace':
|
|
197
|
+
apply(searchBackspace);
|
|
198
|
+
return;
|
|
199
|
+
case 'escape':
|
|
200
|
+
props.onEscape();
|
|
201
|
+
return;
|
|
202
|
+
case 'ctrlC':
|
|
203
|
+
case 'ctrlD':
|
|
204
|
+
props.onCtrlC();
|
|
205
|
+
return;
|
|
206
|
+
case 'clearScreen':
|
|
207
|
+
props.onClearScreen();
|
|
208
|
+
return;
|
|
209
|
+
case 'clipboard':
|
|
210
|
+
void tryClipboard();
|
|
211
|
+
return;
|
|
212
|
+
case 'copyCode':
|
|
213
|
+
props.onCopyCode();
|
|
214
|
+
return;
|
|
215
|
+
default:
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}, [
|
|
219
|
+
apply,
|
|
220
|
+
props,
|
|
221
|
+
suggestions,
|
|
222
|
+
suggestSelected,
|
|
223
|
+
innerCols,
|
|
224
|
+
doSubmit,
|
|
225
|
+
tryClipboard,
|
|
226
|
+
]);
|
|
227
|
+
useInput((input, key) => {
|
|
228
|
+
const s = stRef.current;
|
|
229
|
+
if (props.locked) {
|
|
230
|
+
if (key.ctrl && input === 'c')
|
|
231
|
+
props.onCtrlC();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const curLay = layoutInput(props.prompt, s.buf, s.cursor, innerCols);
|
|
235
|
+
const atTop = curLay.cursorRow === 0;
|
|
236
|
+
const atBottom = curLay.cursorRow === curLay.rows.length - 1;
|
|
237
|
+
const action = dispatchKey(input, key, {
|
|
238
|
+
searchMode: s.searchMode,
|
|
239
|
+
bufferEmpty: s.buf.length === 0,
|
|
240
|
+
// A trailing backslash before the cursor = line continuation: Enter
|
|
241
|
+
// then inserts a newline (and drops the backslash).
|
|
242
|
+
endsWithBackslash: Array.from(s.buf)[s.cursor - 1] === '\\',
|
|
243
|
+
}, { atTop, atBottom });
|
|
244
|
+
runAction(action);
|
|
245
|
+
}, { isActive: true });
|
|
246
|
+
// Bracketed paste arrives on a SEPARATE channel — Ink enables the paste mode
|
|
247
|
+
// automatically. Deliver the whole string to the same handler.
|
|
248
|
+
usePaste((text) => {
|
|
249
|
+
void handlePaste(text);
|
|
250
|
+
});
|
|
251
|
+
// Position the real terminal cursor on the input cell. `y` is the row within
|
|
252
|
+
// the live Ink output BELOW the log. Panel layout (top to bottom):
|
|
253
|
+
// row 0 = divider
|
|
254
|
+
// row 1 = status bar
|
|
255
|
+
// row 2 = input box TOP border
|
|
256
|
+
// rows 3.. = input rows
|
|
257
|
+
const cursorRow = lay.cursorRow;
|
|
258
|
+
const rowText = lay.rows[cursorRow]?.text ?? '';
|
|
259
|
+
const cursorX =
|
|
260
|
+
// The input rows sit inside the rounded box, indented by 1 column for the
|
|
261
|
+
// left border and 1 for the box's inner padding.
|
|
262
|
+
2 +
|
|
263
|
+
visLen(lay.rows[cursorRow]?.prefix ?? '') +
|
|
264
|
+
colWidth(rowText, lay.cursorCol - visLen(lay.rows[cursorRow]?.prefix ?? ''));
|
|
265
|
+
setCursorPosition({ x: cursorX, y: (props.cursorTop ?? 3) + cursorRow });
|
|
266
|
+
const { shown, moreCount } = suggestWindow(suggestions, suggestOffset);
|
|
267
|
+
const t = translate(props.locale);
|
|
268
|
+
return (_jsxs(Box, { flexDirection: "column", children: [suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [shown.map((c, i) => {
|
|
269
|
+
const label = c.hint ? c.name + ' ' + c.hint : c.name;
|
|
270
|
+
const selected = suggestOffset + i === suggestSelected;
|
|
271
|
+
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));
|
|
272
|
+
}), moreCount > 0 && (_jsx(Text, { color: "#5b616e", children: ' ' +
|
|
273
|
+
t('editor.more', { n: String(moreCount) }) +
|
|
274
|
+
' ' +
|
|
275
|
+
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 }) })] }));
|
|
276
|
+
}
|
|
277
|
+
/** Visible width of the first `col` code points of `text` (wide chars = 2). */
|
|
278
|
+
function colWidth(text, col) {
|
|
279
|
+
const chars = Array.from(text);
|
|
280
|
+
let w = 0;
|
|
281
|
+
for (let i = 0; i < col && i < chars.length; i++) {
|
|
282
|
+
w += charWidth(chars[i].codePointAt(0));
|
|
283
|
+
}
|
|
284
|
+
return Math.max(0, w);
|
|
285
|
+
}
|
|
@@ -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,50 @@
|
|
|
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 } = 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
|
+
const win = useMemo(() => {
|
|
15
|
+
let top = Math.max(0, cursor - maxRows + 1);
|
|
16
|
+
if (cursor < top)
|
|
17
|
+
top = cursor;
|
|
18
|
+
return { top, end: Math.min(items.length, top + maxRows) };
|
|
19
|
+
}, [cursor, items.length, maxRows]);
|
|
20
|
+
const finish = (value) => {
|
|
21
|
+
onSelect(value);
|
|
22
|
+
exit();
|
|
23
|
+
};
|
|
24
|
+
useInput((input, key) => {
|
|
25
|
+
if (key.escape || (key.ctrl && input === 'c') || input === 'q') {
|
|
26
|
+
finish(null);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (key.upArrow || input === 'k') {
|
|
30
|
+
setCursor((c) => items.length ? (c - 1 + items.length) % items.length : 0);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (key.downArrow || input === 'j') {
|
|
34
|
+
setCursor((c) => (items.length ? (c + 1) % items.length : 0));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (key.return) {
|
|
38
|
+
finish(items[cursor]?.value ?? null);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
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(40) }), items.slice(win.top, win.end).map((it, i) => {
|
|
42
|
+
const idx = win.top + i;
|
|
43
|
+
const sel = idx === cursor;
|
|
44
|
+
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));
|
|
45
|
+
}), _jsx(HintBar, { hints: [
|
|
46
|
+
['↑/↓', 'выбор'],
|
|
47
|
+
['Enter', 'ok'],
|
|
48
|
+
['Esc', 'отмена'],
|
|
49
|
+
] })] }));
|
|
50
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { 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 } 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). An emoji is counted as 2 columns; the rest as 1. Approximation is
|
|
28
|
+
* fine — the goal is only to avoid clipping the badges. */
|
|
29
|
+
function badgeTextWidth(state) {
|
|
30
|
+
let w = 0;
|
|
31
|
+
if (state.statusLine)
|
|
32
|
+
w += state.statusLine.length + 1;
|
|
33
|
+
if (state.provider)
|
|
34
|
+
w += state.provider.length + 3;
|
|
35
|
+
if (state.queueLength > 0)
|
|
36
|
+
w += 2 + String(state.queueLength).length;
|
|
37
|
+
if (state.codeBlocks.length > 0)
|
|
38
|
+
w += 8;
|
|
39
|
+
if (state.tasks)
|
|
40
|
+
w += state.tasks.length + 1;
|
|
41
|
+
if (state.thinkingEnabled)
|
|
42
|
+
w += 3;
|
|
43
|
+
if (state.searchEnabled)
|
|
44
|
+
w += 3;
|
|
45
|
+
if (state.contextText) {
|
|
46
|
+
const marker = state.contextLevel === 'high' ? 2 : state.contextLevel === 'warn' ? 2 : 0;
|
|
47
|
+
w += state.contextText.length + marker;
|
|
48
|
+
}
|
|
49
|
+
return w;
|
|
50
|
+
}
|
|
51
|
+
/** The badge row: statusLine, provider, queue, tasks, toggles, context. */
|
|
52
|
+
function Badges({ state }) {
|
|
53
|
+
const parts = [];
|
|
54
|
+
if (state.statusLine) {
|
|
55
|
+
parts.push(_jsxs(Text, { color: "#808896", children: [state.statusLine, ' '] }, "sl"));
|
|
56
|
+
}
|
|
57
|
+
if (state.provider) {
|
|
58
|
+
parts.push(_jsx(Text, { color: "#808896", children: '[' + state.provider + '] ' }, "prov"));
|
|
59
|
+
}
|
|
60
|
+
if (state.queueLength > 0) {
|
|
61
|
+
parts.push(_jsxs(Text, { color: "#c9a86a", children: ['⧗' + state.queueLength, ' '] }, "q"));
|
|
62
|
+
}
|
|
63
|
+
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
|
+
const idx = state.codeFocus >= 0 ? state.codeFocus + 1 : state.codeBlocks.length;
|
|
67
|
+
parts.push(_jsx(Text, { color: "#7fc4f0", children: '⌘ ' + idx + '/' + state.codeBlocks.length + ' ' }, "code"));
|
|
68
|
+
}
|
|
69
|
+
if (state.tasks) {
|
|
70
|
+
parts.push(_jsxs(Text, { color: "#808896", children: [state.tasks, ' '] }, "t"));
|
|
71
|
+
}
|
|
72
|
+
const toggles = [];
|
|
73
|
+
if (state.thinkingEnabled)
|
|
74
|
+
toggles.push('🧠\uFE0E');
|
|
75
|
+
if (state.searchEnabled)
|
|
76
|
+
toggles.push('🌐\uFE0E');
|
|
77
|
+
if (toggles.length) {
|
|
78
|
+
parts.push(_jsx(Text, { color: "#6fd0b0", children: toggles.join(' ') + ' ' }, "tg"));
|
|
79
|
+
}
|
|
80
|
+
if (state.contextText) {
|
|
81
|
+
const color = state.contextLevel === 'high'
|
|
82
|
+
? '#c98a80'
|
|
83
|
+
: state.contextLevel === 'warn'
|
|
84
|
+
? '#c9a86a'
|
|
85
|
+
: '#a9c08c';
|
|
86
|
+
const marker = state.contextLevel === 'high'
|
|
87
|
+
? '⛔ '
|
|
88
|
+
: state.contextLevel === 'warn'
|
|
89
|
+
? '▲ '
|
|
90
|
+
: '';
|
|
91
|
+
parts.push(_jsx(Text, { color: color, children: marker + state.contextText }, "ctx"));
|
|
92
|
+
}
|
|
93
|
+
if (!parts.length)
|
|
94
|
+
return null;
|
|
95
|
+
return _jsx(Box, { children: parts });
|
|
96
|
+
}
|
|
97
|
+
export function StatusBar({ state, divider = true, }) {
|
|
98
|
+
// Drive the spinner from Ink's shared animation timer (one timer for the
|
|
99
|
+
// whole tree). The interval matches the old 400ms dot cadence.
|
|
100
|
+
const animating = state.busy || !!state.animBase;
|
|
101
|
+
const { frame } = useAnimation({ interval: 400, isActive: animating });
|
|
102
|
+
const t = translate(state.locale);
|
|
103
|
+
const stateLbl = stateLabel(state, state.locale);
|
|
104
|
+
let left;
|
|
105
|
+
if (state.pendingText) {
|
|
106
|
+
left = '✎ ' + state.pendingText + ' · ' + t('spinner.hint');
|
|
107
|
+
}
|
|
108
|
+
else if (state.animBase) {
|
|
109
|
+
const dots = DOTS[frame % DOTS.length] ?? '';
|
|
110
|
+
const pad = ' '.slice(dots.length);
|
|
111
|
+
left =
|
|
112
|
+
stripEllipsis(state.animBase) +
|
|
113
|
+
dots +
|
|
114
|
+
pad +
|
|
115
|
+
elapsedLabel(state.animStart, animating) +
|
|
116
|
+
' · ' +
|
|
117
|
+
t('spinner.hint');
|
|
118
|
+
}
|
|
119
|
+
else if (state.statusText) {
|
|
120
|
+
left = state.statusText;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
left = '';
|
|
124
|
+
}
|
|
125
|
+
const prefix = stateLbl ? stateLbl + ' ' : '';
|
|
126
|
+
// When the log is scrolled up, show a marker in the status line (NOT as a
|
|
127
|
+
// separate row below the input — that pushed the panel up and made the input
|
|
128
|
+
// jump). It replaces the spinner text while scrolled, so it never adds a row.
|
|
129
|
+
const scrollNote = state.scroll > 0 ? `↑ прокручено на ${state.scroll} стр. · PgDn/End` : '';
|
|
130
|
+
const leftText = scrollNote || prefix + left;
|
|
131
|
+
const showLeft = !!leftText;
|
|
132
|
+
// Keep the badge row visible: the left status truncates to whatever space is
|
|
133
|
+
// left after the badges, instead of pushing them off the right edge (they
|
|
134
|
+
// used to be clipped: "[DeepSee⧗1 задачи: 3/...").
|
|
135
|
+
const cols = useWindowSize().columns || 80;
|
|
136
|
+
const badgeWidth = badgeTextWidth(state);
|
|
137
|
+
const maxLeft = Math.max(0, cols - badgeWidth - 2);
|
|
138
|
+
const leftOut = showLeft ? truncateToWidth(leftText, maxLeft) : '';
|
|
139
|
+
return (_jsxs(Box, { flexDirection: "column", children: [divider ? _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(cols) }) : null, _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { children: leftOut ? _jsx(Text, { color: "#a1723f", children: leftOut }) : _jsx(Text, { children: " " }) }), _jsx(Badges, { state: state })] })] }));
|
|
140
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A one-line Ink TEXT PROMPT (label + single input + Enter/Esc).
|
|
3
|
+
//
|
|
4
|
+
// Used for steps the browser asks for interactively (currently the DeepSeek
|
|
5
|
+
// email one-time code) so they stay INSIDE the TUI instead of falling back to
|
|
6
|
+
// promptOnce/readline, which pauses Ink and fights it for stdin.
|
|
7
|
+
import { useState } from 'react';
|
|
8
|
+
import { Box, Text, useInput, useApp } from 'ink';
|
|
9
|
+
import { UI, HintBar } from './widgets.js';
|
|
10
|
+
import { maskSecret } from './login-pure.js';
|
|
11
|
+
function mask(s) {
|
|
12
|
+
return maskSecret(s);
|
|
13
|
+
}
|
|
14
|
+
export function TextPrompt({ label, secret, t, onResult, }) {
|
|
15
|
+
const { exit } = useApp();
|
|
16
|
+
const [value, setValue] = useState('');
|
|
17
|
+
const finish = (v) => {
|
|
18
|
+
onResult(v);
|
|
19
|
+
exit();
|
|
20
|
+
};
|
|
21
|
+
useInput((input, key) => {
|
|
22
|
+
if (key.escape) {
|
|
23
|
+
finish(null);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (key.return) {
|
|
27
|
+
finish(value.trim());
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (key.backspace || key.delete) {
|
|
31
|
+
setValue((v) => Array.from(v).slice(0, -1).join(''));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (input && input >= ' ')
|
|
35
|
+
setValue((v) => v + input);
|
|
36
|
+
});
|
|
37
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.accent, paddingX: 2, children: [_jsx(Text, { color: UI.text, children: label }), _jsxs(Text, { color: UI.accent, children: ['❯ ', secret ? mask(value) : value, '▏'] }), _jsx(HintBar, { hints: [
|
|
38
|
+
['Enter', t('auth.form.hint_submit')],
|
|
39
|
+
['Esc', t('auth.form.hint_cancel')],
|
|
40
|
+
] })] }));
|
|
41
|
+
}
|