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.
- package/CHANGELOG.md +48 -0
- package/README.md +37 -7
- package/dist/attachments.js +6 -1
- 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 +35 -0
- package/dist/i18n/config.js +20 -0
- package/dist/i18n/deepseek.js +14 -0
- package/dist/i18n/help.js +30 -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 +487 -65
- package/dist/input/layout.js +19 -0
- package/dist/input/suggest.js +29 -1
- 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 +16 -1
- package/dist/tool-summary.js +83 -0
- package/dist/tui/ConfigMenu.js +337 -0
- package/dist/tui/ConfirmDialog.js +42 -0
- package/dist/tui/FullScreenApp.js +113 -0
- package/dist/tui/InputLine.js +318 -0
- package/dist/tui/LogView.js +48 -0
- package/dist/tui/LoginForm.js +83 -0
- package/dist/tui/SelectList.js +54 -0
- package/dist/tui/StatusBar.js +170 -0
- package/dist/tui/TextPrompt.js +41 -0
- package/dist/tui/TuiApp.js +29 -0
- package/dist/tui/TuiEditor.js +490 -0
- package/dist/tui/edit.js +317 -0
- package/dist/tui/keys.js +195 -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 +164 -0
- package/dist/tui/widgets.js +60 -0
- package/package.json +7 -3
- package/scripts/coverage-gate.mts +10 -0
- package/scripts/inline-status-driver.mts +31 -0
- package/scripts/inline-status.tsx +33 -0
- package/scripts/mouse-churn.mts +34 -0
- package/scripts/qwen-models-smoke.mts +117 -0
- package/scripts/render-demo.mts +1 -1
- package/scripts/tui-comp-shot.mts +63 -0
- package/scripts/tui-components.tsx +62 -0
- package/scripts/tui-config-driver.mts +82 -0
- package/scripts/tui-config-probe.tsx +74 -0
- package/scripts/tui-edit-driver.mts +100 -0
- package/scripts/tui-edit-probe.tsx +38 -0
- package/scripts/tui-editor-probe.tsx +24 -0
- package/scripts/tui-input-driver.mts +54 -0
- package/scripts/tui-input-shot.tsx +59 -0
- package/scripts/tui-log-driver.mts +35 -0
- package/scripts/tui-log-shot.tsx +48 -0
- package/scripts/tui-longpaste-driver.mts +56 -0
- package/scripts/tui-longpaste.tsx +35 -0
- package/scripts/tui-manyselect-shot.mts +34 -0
- package/scripts/tui-manyselect.tsx +17 -0
- package/scripts/tui-mouse-churn-probe.tsx +28 -0
- package/scripts/tui-paste-driver.mts +88 -0
- package/scripts/tui-paste-kinds.tsx +76 -0
- package/scripts/tui-pastetest.mts +38 -0
- package/scripts/tui-probe.tsx +62 -0
- package/scripts/tui-repro.tsx +64 -0
- package/scripts/tui-resize.mts +70 -0
- package/scripts/tui-scroll-driver.mts +53 -0
- package/scripts/tui-scroll-probe.tsx +46 -0
- package/scripts/tui-shot.mts +106 -0
- package/scripts/tui-status-driver.mts +24 -0
- package/scripts/tui-status-shot.tsx +27 -0
- package/scripts/tui-submit.tsx +57 -0
- package/scripts/tui-wheel-driver.mts +61 -0
- package/scripts/tui-wheel-probe.tsx +48 -0
- package/scripts/tui-widgets-driver.mts +20 -0
- package/scripts/tui-widgets.tsx +44 -0
|
@@ -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
|
+
}
|