zames_pro 2.74.0 → 2.75.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +24 -0
- package/dist/attachments.js +6 -1
- package/dist/commands.js +0 -1
- package/dist/config.js +7 -7
- package/dist/i18n/common.js +0 -8
- package/dist/i18n/config.js +0 -1
- package/dist/i18n/help.js +8 -4
- package/dist/i18n/qwen.js +0 -4
- package/dist/i18n/self-review.js +2 -18
- package/dist/i18n/spinner.js +5 -0
- package/dist/index.js +30 -43
- package/dist/input/suggest.js +19 -4
- package/dist/theme.js +9 -0
- package/dist/tui/ConfigMenu.js +44 -11
- package/dist/tui/FullScreenApp.js +44 -11
- package/dist/tui/InputLine.js +52 -19
- package/dist/tui/SelectList.js +6 -2
- package/dist/tui/StatusBar.js +66 -36
- package/dist/tui/TuiApp.js +3 -2
- package/dist/tui/TuiEditor.js +44 -4
- package/dist/tui/edit.js +21 -1
- package/dist/tui/keys.js +46 -2
- package/dist/tui/store.js +1 -1
- package/dist/tui/useMouseWheel.js +80 -29
- package/dist/tui/widgets.js +6 -1
- package/package.json +2 -1
- package/scripts/ctd-live-driver.mts +51 -0
- package/scripts/ctd-live.tsx +33 -0
- package/scripts/escd-live-driver.mts +37 -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/ru-shot.mts +20 -0
- 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-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
|
@@ -18,7 +18,7 @@ import { InputLine } from './InputLine.js';
|
|
|
18
18
|
import { contentWidth } from '../width.js';
|
|
19
19
|
import { translate } from '../i18n.js';
|
|
20
20
|
import { suggestRowCount } from '../input/suggest.js';
|
|
21
|
-
import { inputBoxRows } from '../input/layout.js';
|
|
21
|
+
import { inputBoxRows, truncateToWidth, visLen } from '../input/layout.js';
|
|
22
22
|
import { useMouseWheel } from './useMouseWheel.js';
|
|
23
23
|
/** The header bar: title, version, working directory. */
|
|
24
24
|
function Header({ store }) {
|
|
@@ -28,9 +28,22 @@ function Header({ store }) {
|
|
|
28
28
|
.filter(Boolean)
|
|
29
29
|
.join(' ');
|
|
30
30
|
const left = '❯ ' + state.title + (state.version ? ' v' + state.version : '');
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
const rawDir = state.workdir ? ' ' + state.workdir : '';
|
|
32
|
+
// The header MUST stay exactly ONE text row: FullScreenApp reserves
|
|
33
|
+
// HEADER_ROWS = 2 (title + divider) and the whole panel height is computed
|
|
34
|
+
// from it. A long working directory used to WRAP the title row at a narrow
|
|
35
|
+
// terminal (or a deep path), adding a third row the layout did not know
|
|
36
|
+
// about — Ink then overflowed the fixed `height={termRows}` column and the
|
|
37
|
+
// TOP input row was clipped (the operator saw the second line overprint the
|
|
38
|
+
// first). Truncate the directory (then the title) so the row always fits.
|
|
39
|
+
const gap = Math.max(1, cols - visLen(left) - visLen(rawDir) - visLen(right) - 2);
|
|
40
|
+
let dir = rawDir;
|
|
41
|
+
const budget = cols - visLen(left) - visLen(right) - 2;
|
|
42
|
+
if (visLen(dir) > budget)
|
|
43
|
+
dir = budget > 0 ? truncateToWidth(dir, budget) : '';
|
|
44
|
+
// If even title + provider exceed the width, drop the directory entirely.
|
|
45
|
+
const finalDir = gap >= 1 ? dir : '';
|
|
46
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "#c8b06a", bold: true, children: left }), _jsx(Text, { color: "#7fc4f0", children: finalDir }), _jsx(Text, { children: ' '.repeat(Math.max(1, cols - visLen(left) - visLen(finalDir) - visLen(right) - 2)) }), right ? _jsx(Text, { color: "#6fd0b0", children: right }) : null] }), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(cols) })] }));
|
|
34
47
|
}
|
|
35
48
|
export function FullScreenApp({ store, inputProps, }) {
|
|
36
49
|
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
@@ -49,8 +62,13 @@ export function FullScreenApp({ store, inputProps, }) {
|
|
|
49
62
|
const INPUT_BOX_ROWS = inputBoxRows(state.prompt, state.edit.buf, state.edit.cursor, cols);
|
|
50
63
|
// The suggestion popup renders ABOVE the input box; reserve its rows so
|
|
51
64
|
// opening the list does not push the panel / shift the whole layout.
|
|
52
|
-
const suggestRows = suggestRowCount(state.edit.buf, state.commands);
|
|
53
|
-
|
|
65
|
+
const suggestRows = suggestRowCount(state.edit.buf, state.commands, cols);
|
|
66
|
+
// A one-line hint UNDER the input box (selection / scroll keys). It is part
|
|
67
|
+
// of the fixed panel height, so the log viewport shrinks by one row instead
|
|
68
|
+
// of the panel overflowing; `cursorTop` (measured from the top) is NOT
|
|
69
|
+
// affected, so the caret cannot move.
|
|
70
|
+
const HINT_ROWS = 1;
|
|
71
|
+
const panelRows = HEADER_ROWS + STATUS_ROWS + INPUT_BOX_ROWS + suggestRows + HINT_ROWS;
|
|
54
72
|
const logHeight = Math.max(1, termRows - panelRows);
|
|
55
73
|
// Window the log BY RENDERED ROW: a long wrapped answer can be taller than
|
|
56
74
|
// one entry, and an entry-count window either overflowed the panel or left
|
|
@@ -61,25 +79,40 @@ export function FullScreenApp({ store, inputProps, }) {
|
|
|
61
79
|
const maxScroll = Math.max(0, total - logHeight);
|
|
62
80
|
const scroll = Math.max(0, Math.min(state.scroll, maxScroll));
|
|
63
81
|
const visible = windowByRows(all, logHeight, scroll, cols);
|
|
64
|
-
// Scroll the log with PgUp/PgDn.
|
|
82
|
+
// Scroll the log with PgUp/PgDn; Home/End jump to the very top/bottom. The
|
|
83
|
+
// input line OWNS Home/End while its buffer is non-empty (they move the
|
|
84
|
+
// caret), so the jump only applies to an EMPTY buffer — which is exactly
|
|
85
|
+
// when the operator is navigating the log to select a large block.
|
|
86
|
+
//
|
|
87
|
+
// `useInput` binds the callback ONCE, so a `scroll` captured in the closure
|
|
88
|
+
// goes STALE after the first scroll (PgUp/PgDn/Home/End then compute from an
|
|
89
|
+
// old offset and jump wrong). Read the live value from the store instead.
|
|
65
90
|
useInput((_input, key) => {
|
|
66
91
|
const step = Math.max(1, Math.floor(logHeight / 2));
|
|
92
|
+
const cur = store.getSnapshot().scroll;
|
|
67
93
|
if (key.pageUp) {
|
|
68
|
-
store.set({ scroll: Math.min(maxScroll,
|
|
94
|
+
store.set({ scroll: Math.min(maxScroll, cur + step) });
|
|
69
95
|
}
|
|
70
96
|
else if (key.pageDown) {
|
|
71
|
-
store.set({ scroll: Math.max(0,
|
|
97
|
+
store.set({ scroll: Math.max(0, cur - step) });
|
|
98
|
+
}
|
|
99
|
+
else if (state.edit.buf.length === 0 && (key.home || key.end)) {
|
|
100
|
+
// Both the bare and the Ctrl-modified forms land here (Ink sets
|
|
101
|
+
// key.end / key.home for either), so Ctrl+End / Ctrl+Home are a
|
|
102
|
+
// reliable fallback on terminals that swallow the bare keys.
|
|
103
|
+
store.set({ scroll: key.home ? maxScroll : 0 });
|
|
72
104
|
}
|
|
73
105
|
});
|
|
74
106
|
// Scroll the log with the MOUSE WHEEL. Ink does not parse mouse events, so
|
|
75
107
|
// useMouseWheel reads the raw SGR sequences (see the hook). One wheel notch
|
|
76
108
|
// scrolls ~3 rows, like most terminals.
|
|
77
109
|
useMouseWheel((delta) => {
|
|
78
|
-
const
|
|
110
|
+
const cur = store.getSnapshot().scroll;
|
|
111
|
+
const next = cur + delta * 3;
|
|
79
112
|
store.set({ scroll: Math.max(0, Math.min(maxScroll, next)) });
|
|
80
113
|
}, state.mouseScroll);
|
|
81
114
|
return (_jsxs(Box, { flexDirection: "column", height: termRows, children: [_jsx(Header, { store: store }), _jsx(Box, { flexDirection: "column", height: logHeight, overflow: "hidden", justifyContent: "flex-end", children: all.length === 0 ? (_jsx(Text, { color: "#5b616e", children: translate(state.locale)('editor.empty_hint') })) : (_jsx(LogList, { entries: visible })) }), _jsx(StatusBar, { state: state, divider: false }), _jsx(InputLine, { ...inputProps, store: store,
|
|
82
115
|
// Cursor rows above the input box: header + log viewport + status.
|
|
83
|
-
cursorTop: HEADER_ROWS + logHeight + suggestRows + STATUS_ROWS + 1, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked })] }));
|
|
116
|
+
cursorTop: HEADER_ROWS + logHeight + suggestRows + STATUS_ROWS + 1, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked }), _jsx(Text, { color: "#5b616e", wrap: "truncate", children: ' ' + translate(state.locale)('editor.keys_hint') })] }));
|
|
84
117
|
}
|
|
85
118
|
void contentWidth;
|
package/dist/tui/InputLine.js
CHANGED
|
@@ -8,12 +8,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
// reading key events from Ink's useInput, bracketed paste from usePaste,
|
|
9
9
|
// calling the operator callbacks (submit / abort / attach), and painting the
|
|
10
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';
|
|
11
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
12
|
+
import { Box, Text, useInput, usePaste, useCursor, useWindowSize, useStdin, } from 'ink';
|
|
13
13
|
import { translate } from '../i18n.js';
|
|
14
|
-
import { layoutInput,
|
|
14
|
+
import { layoutInput, SUGGEST_PAGE, } from '../input/layout.js';
|
|
15
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';
|
|
16
|
+
import { dispatchKey, parseCsiU } from './keys.js';
|
|
17
17
|
import { useSyncExternalStore } from 'react';
|
|
18
18
|
import { matchSlashCommands, suggestWindow, completeSlashCommand, } from '../input/suggest.js';
|
|
19
19
|
/** Render the visual rows of the buffer with the prompt on the first row. */
|
|
@@ -69,9 +69,13 @@ export function InputLine(props) {
|
|
|
69
69
|
const withHist = recordHistory(s, s.buf);
|
|
70
70
|
const next = afterSubmit(withHist);
|
|
71
71
|
setSt(next);
|
|
72
|
-
props.onPastesChange(next.pastes);
|
|
73
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.
|
|
74
77
|
props.onSubmit(s.buf, props.attachments.slice());
|
|
78
|
+
props.onPastesChange(next.pastes);
|
|
75
79
|
}, [props]);
|
|
76
80
|
const handlePaste = useCallback(async (text) => {
|
|
77
81
|
const trimmed = String(text).trim();
|
|
@@ -224,6 +228,11 @@ export function InputLine(props) {
|
|
|
224
228
|
doSubmit,
|
|
225
229
|
tryClipboard,
|
|
226
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;
|
|
227
236
|
useInput((input, key) => {
|
|
228
237
|
const s = stRef.current;
|
|
229
238
|
if (props.locked) {
|
|
@@ -248,6 +257,37 @@ export function InputLine(props) {
|
|
|
248
257
|
usePaste((text) => {
|
|
249
258
|
void handlePaste(text);
|
|
250
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]);
|
|
251
291
|
// Position the real terminal cursor on the input cell. `y` is the row within
|
|
252
292
|
// the live Ink output BELOW the log. Panel layout (top to bottom):
|
|
253
293
|
// row 0 = divider
|
|
@@ -255,13 +295,15 @@ export function InputLine(props) {
|
|
|
255
295
|
// row 2 = input box TOP border
|
|
256
296
|
// rows 3.. = input rows
|
|
257
297
|
const cursorRow = lay.cursorRow;
|
|
258
|
-
const rowText = lay.rows[cursorRow]?.text ?? '';
|
|
259
298
|
const cursorX =
|
|
260
299
|
// 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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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;
|
|
265
307
|
setCursorPosition({ x: cursorX, y: (props.cursorTop ?? 3) + cursorRow });
|
|
266
308
|
const { shown, moreCount } = suggestWindow(suggestions, suggestOffset);
|
|
267
309
|
const t = translate(props.locale);
|
|
@@ -274,12 +316,3 @@ export function InputLine(props) {
|
|
|
274
316
|
' ' +
|
|
275
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 }) })] }));
|
|
276
318
|
}
|
|
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
|
-
}
|
package/dist/tui/SelectList.js
CHANGED
|
@@ -7,10 +7,14 @@ import { Box, Text, useInput, useApp, useWindowSize } from 'ink';
|
|
|
7
7
|
import { UI, HintBar } from './widgets.js';
|
|
8
8
|
export function SelectList({ title, items, initial, onSelect, }) {
|
|
9
9
|
const { exit } = useApp();
|
|
10
|
-
const { rows } = useWindowSize();
|
|
10
|
+
const { rows, columns } = useWindowSize();
|
|
11
11
|
const start = Math.max(0, items.findIndex((it) => it.value === initial));
|
|
12
12
|
const [cursor, setCursor] = useState(start);
|
|
13
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);
|
|
14
18
|
const win = useMemo(() => {
|
|
15
19
|
let top = Math.max(0, cursor - maxRows + 1);
|
|
16
20
|
if (cursor < top)
|
|
@@ -38,7 +42,7 @@ export function SelectList({ title, items, initial, onSelect, }) {
|
|
|
38
42
|
finish(items[cursor]?.value ?? null);
|
|
39
43
|
}
|
|
40
44
|
});
|
|
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(
|
|
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) => {
|
|
42
46
|
const idx = win.top + i;
|
|
43
47
|
const sel = idx === cursor;
|
|
44
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));
|
package/dist/tui/StatusBar.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text, useAnimation, useWindowSize } from 'ink';
|
|
3
3
|
import { translate } from '../i18n.js';
|
|
4
4
|
import { DOTS, stripEllipsis } from '../spinner.js';
|
|
5
|
-
import { truncateToWidth } from '../input/layout.js';
|
|
5
|
+
import { truncateToWidth, visLen } from '../input/layout.js';
|
|
6
6
|
/** Elapsed seconds of the current animated phase ("0s", "45s", "2m 05s"). */
|
|
7
7
|
function elapsedLabel(startedAt, animating) {
|
|
8
8
|
if (!animating || !startedAt)
|
|
@@ -24,50 +24,69 @@ function stateLabel(state, locale) {
|
|
|
24
24
|
return '';
|
|
25
25
|
}
|
|
26
26
|
/** Visible width of the badge row (used to reserve space for the left
|
|
27
|
-
* status).
|
|
28
|
-
*
|
|
29
|
-
|
|
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) {
|
|
30
33
|
let w = 0;
|
|
31
34
|
if (state.statusLine)
|
|
32
|
-
w += state.statusLine
|
|
35
|
+
w += visLen(state.statusLine) + 1;
|
|
33
36
|
if (state.provider)
|
|
34
|
-
w += state.provider
|
|
37
|
+
w += visLen('[' + state.provider + '] ');
|
|
35
38
|
if (state.queueLength > 0)
|
|
36
|
-
w +=
|
|
37
|
-
if (state.codeBlocks.length > 0)
|
|
38
|
-
|
|
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
|
+
}
|
|
39
44
|
if (state.tasks)
|
|
40
|
-
w += state.tasks
|
|
45
|
+
w += visLen(state.tasks) + 1;
|
|
46
|
+
const toggles = [];
|
|
41
47
|
if (state.thinkingEnabled)
|
|
42
|
-
|
|
48
|
+
toggles.push('🧠\uFE0E');
|
|
43
49
|
if (state.searchEnabled)
|
|
44
|
-
|
|
50
|
+
toggles.push('🌐\uFE0E');
|
|
51
|
+
if (toggles.length)
|
|
52
|
+
w += visLen(toggles.join(' ') + ' ');
|
|
45
53
|
if (state.contextText) {
|
|
46
|
-
const marker = state.contextLevel === 'high'
|
|
47
|
-
|
|
54
|
+
const marker = state.contextLevel === 'high'
|
|
55
|
+
? '⛔ '
|
|
56
|
+
: state.contextLevel === 'warn'
|
|
57
|
+
? '▲ '
|
|
58
|
+
: '';
|
|
59
|
+
w += visLen(marker + state.contextText);
|
|
48
60
|
}
|
|
49
61
|
return w;
|
|
50
62
|
}
|
|
51
|
-
/** The badge row: statusLine, provider, queue, tasks, toggles, context.
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
};
|
|
54
74
|
if (state.statusLine) {
|
|
55
|
-
|
|
75
|
+
add('sl', visLen(state.statusLine) + 1, _jsxs(Text, { color: "#808896", children: [state.statusLine, ' '] }, "sl"));
|
|
56
76
|
}
|
|
57
77
|
if (state.provider) {
|
|
58
|
-
|
|
78
|
+
add('prov', visLen('[' + state.provider + '] '), _jsx(Text, { color: "#808896", children: '[' + state.provider + '] ' }, "prov"));
|
|
59
79
|
}
|
|
60
80
|
if (state.queueLength > 0) {
|
|
61
|
-
|
|
81
|
+
add('q', visLen('⧗' + state.queueLength + ' '), _jsxs(Text, { color: "#c9a86a", children: ['⧗' + state.queueLength, ' '] }, "q"));
|
|
62
82
|
}
|
|
63
83
|
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
84
|
const idx = state.codeFocus >= 0 ? state.codeFocus + 1 : state.codeBlocks.length;
|
|
67
|
-
|
|
85
|
+
const label = '⌘ ' + idx + '/' + state.codeBlocks.length + ' ';
|
|
86
|
+
add('code', visLen(label), _jsx(Text, { color: "#7fc4f0", children: label }, "code"));
|
|
68
87
|
}
|
|
69
88
|
if (state.tasks) {
|
|
70
|
-
|
|
89
|
+
add('t', visLen(state.tasks) + 1, _jsxs(Text, { color: "#808896", children: [state.tasks, ' '] }, "t"));
|
|
71
90
|
}
|
|
72
91
|
const toggles = [];
|
|
73
92
|
if (state.thinkingEnabled)
|
|
@@ -75,7 +94,8 @@ function Badges({ state }) {
|
|
|
75
94
|
if (state.searchEnabled)
|
|
76
95
|
toggles.push('🌐\uFE0E');
|
|
77
96
|
if (toggles.length) {
|
|
78
|
-
|
|
97
|
+
const label = toggles.join(' ') + ' ';
|
|
98
|
+
add('tg', visLen(label), _jsx(Text, { color: "#6fd0b0", children: label }, "tg"));
|
|
79
99
|
}
|
|
80
100
|
if (state.contextText) {
|
|
81
101
|
const color = state.contextLevel === 'high'
|
|
@@ -88,11 +108,20 @@ function Badges({ state }) {
|
|
|
88
108
|
: state.contextLevel === 'warn'
|
|
89
109
|
? '▲ '
|
|
90
110
|
: '';
|
|
91
|
-
|
|
111
|
+
const label = marker + state.contextText;
|
|
112
|
+
add('ctx', visLen(label), _jsx(Text, { color: color, children: label }, "ctx"));
|
|
92
113
|
}
|
|
93
|
-
if (!
|
|
114
|
+
if (!all.length)
|
|
94
115
|
return null;
|
|
95
|
-
|
|
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) });
|
|
96
125
|
}
|
|
97
126
|
export function StatusBar({ state, divider = true, }) {
|
|
98
127
|
// Drive the spinner from Ink's shared animation timer (one timer for the
|
|
@@ -129,12 +158,13 @@ export function StatusBar({ state, divider = true, }) {
|
|
|
129
158
|
const scrollNote = state.scroll > 0 ? `↑ прокручено на ${state.scroll} стр. · PgDn/End` : '';
|
|
130
159
|
const leftText = scrollNote || prefix + left;
|
|
131
160
|
const showLeft = !!leftText;
|
|
132
|
-
// Keep the
|
|
133
|
-
//
|
|
134
|
-
//
|
|
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.
|
|
135
166
|
const cols = useWindowSize().columns || 80;
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
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 })] })] }));
|
|
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)) })] })] }));
|
|
140
170
|
}
|
package/dist/tui/TuiApp.js
CHANGED
|
@@ -9,7 +9,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
9
|
// (never repainted), the bottom section re-renders as state changes, so the
|
|
10
10
|
// input stays pinned at the bottom while answers scroll up into scrollback.
|
|
11
11
|
import React, { useSyncExternalStore } from 'react';
|
|
12
|
-
import { Box } from 'ink';
|
|
12
|
+
import { Box, useWindowSize } from 'ink';
|
|
13
13
|
import { LogView } from './LogView.js';
|
|
14
14
|
import { StatusBar } from './StatusBar.js';
|
|
15
15
|
import { InputLine } from './InputLine.js';
|
|
@@ -23,6 +23,7 @@ export function TuiApp({ store, inputProps, initialPrintedCount = 0, }) {
|
|
|
23
23
|
// + the suggestion popup (which renders above the box and would otherwise
|
|
24
24
|
// shift the caret down by its row count). Without the suggestRows term the
|
|
25
25
|
// caret drifted whenever the «/» list opened.
|
|
26
|
-
const
|
|
26
|
+
const cols = useWindowSize().columns || 80;
|
|
27
|
+
const suggestRows = suggestRowCount(state.edit.buf, state.commands, cols);
|
|
27
28
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(LogView, { entries: entries }), _jsx(StatusBar, { state: state }), _jsx(InputLine, { ...inputProps, store: store, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked, cursorTop: 3 + suggestRows })] }));
|
|
28
29
|
}
|
package/dist/tui/TuiEditor.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// TuiEditor — a drop-in replacement for the hand-rolled LineEditor backed by an
|
|
3
|
+
// Ink/React render tree.
|
|
4
|
+
//
|
|
5
|
+
// WHY an adapter and not a rewrite of index.ts:
|
|
6
|
+
// * index.ts (4200 lines) and run-task.ts call the UI through a large but
|
|
7
|
+
// STABLE imperative surface (`printAbove`, `lock`, `toolCall`, `busy`, ...);
|
|
8
|
+
// * Ink renders from React state, which can only be driven from inside the
|
|
9
|
+
// tree. The adapter is the seam: it implements the SAME surface and forwards
|
|
10
|
+
// every call into the TuiStore, which the tree subscribes to.
|
|
11
|
+
// So the rest of the agent is unchanged; only the rendering engine differs.
|
|
12
|
+
//
|
|
13
|
+
// Lifecycle: `start()` mounts the Ink app; `dispose()` unmounts it. A `pause()`
|
|
14
|
+
// /`resume()` pair suspends the tree for a foreign full-screen UI (the config
|
|
15
|
+
// menu) — implemented as unmount/remount so the foreign program owns the
|
|
16
|
+
// terminal cleanly.
|
|
17
|
+
import fs from 'node:fs';
|
|
2
18
|
import { render } from 'ink';
|
|
3
19
|
import { TuiStore } from './store.js';
|
|
4
20
|
import { TuiApp } from './TuiApp.js';
|
|
@@ -126,6 +142,26 @@ export class TuiEditor {
|
|
|
126
142
|
alternateScreen: this.alternateScreen,
|
|
127
143
|
});
|
|
128
144
|
this.rendered = true;
|
|
145
|
+
// DEV DIAGNOSTIC: dump every raw stdin chunk to a file so a terminal's
|
|
146
|
+
// key encoding (e.g. Ctrl+Delete = ESC d in VSCode) can be captured from
|
|
147
|
+
// the LIVE agent, not guessed. Off unless ZAMES_TUI_KEY_DEBUG names a
|
|
148
|
+
// file.
|
|
149
|
+
const keyDbg = process.env.ZAMES_TUI_KEY_DEBUG;
|
|
150
|
+
if (keyDbg) {
|
|
151
|
+
process.stdin.on('data', (d) => {
|
|
152
|
+
try {
|
|
153
|
+
fs.appendFileSync(keyDbg, new Date().toISOString() +
|
|
154
|
+
' ' +
|
|
155
|
+
d.toString('hex') +
|
|
156
|
+
' ' +
|
|
157
|
+
JSON.stringify(d.toString('utf-8')) +
|
|
158
|
+
String.fromCharCode(10));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* ignore */
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
129
165
|
this.installResizeHandler();
|
|
130
166
|
// Entries shown so far are now on screen; a later remount must not reprint
|
|
131
167
|
// them.
|
|
@@ -453,9 +489,9 @@ export class TuiEditor {
|
|
|
453
489
|
return false;
|
|
454
490
|
}
|
|
455
491
|
const pick = typeof index === 'number'
|
|
456
|
-
? blocks.find((b) => b.index === index) ?? null
|
|
492
|
+
? (blocks.find((b) => b.index === index) ?? null)
|
|
457
493
|
: snap.codeFocus >= 0
|
|
458
|
-
? blocks[snap.codeFocus] ?? null
|
|
494
|
+
? (blocks[snap.codeFocus] ?? null)
|
|
459
495
|
: blocks[blocks.length - 1];
|
|
460
496
|
if (!pick) {
|
|
461
497
|
this.printAbove(theme.warn(translate(this.locale)('copy.bad_index', { n: String(index) })));
|
|
@@ -464,10 +500,14 @@ export class TuiEditor {
|
|
|
464
500
|
const res = this.onCopyText?.(pick.text) ?? { ok: false, via: 'none' };
|
|
465
501
|
const label = 'code #' + pick.index + (pick.lang ? ' (' + pick.lang + ')' : '');
|
|
466
502
|
if (res.ok) {
|
|
467
|
-
this.printAbove(theme.assistant(translate(this.locale)('copy.done', { tool: res.via }) +
|
|
503
|
+
this.printAbove(theme.assistant(translate(this.locale)('copy.done', { tool: res.via }) +
|
|
504
|
+
' — ' +
|
|
505
|
+
label));
|
|
468
506
|
}
|
|
469
507
|
else {
|
|
470
|
-
this.printAbove(theme.warn(translate(this.locale)('copy.failed', { tool: res.via }) +
|
|
508
|
+
this.printAbove(theme.warn(translate(this.locale)('copy.failed', { tool: res.via }) +
|
|
509
|
+
' — ' +
|
|
510
|
+
label));
|
|
471
511
|
}
|
|
472
512
|
return res.ok;
|
|
473
513
|
}
|
package/dist/tui/edit.js
CHANGED
|
@@ -81,9 +81,29 @@ export function deleteWordLeft(s) {
|
|
|
81
81
|
chars.splice(i, s.cursor - i);
|
|
82
82
|
return { ...s, buf: chars.join(''), cursor: i };
|
|
83
83
|
}
|
|
84
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Delete the word on the RIGHT of the cursor (Ctrl+Delete / Alt+Delete).
|
|
86
|
+
*
|
|
87
|
+
* FALLBACK: at the END of the line there is no word to the right, so the key
|
|
88
|
+
* did NOTHING and the operator (whose terminal sends the standard xterm
|
|
89
|
+
* Ctrl+Delete) read it as "Ctrl+Delete does not delete the word before the
|
|
90
|
+
* cursor". Deleting the word on the LEFT there matches what other terminal
|
|
91
|
+
* editors do and what the operator expects, while keeping the standard
|
|
92
|
+
* right-delete when the cursor is mid-line.
|
|
93
|
+
*/
|
|
85
94
|
export function deleteWordRight(s) {
|
|
86
95
|
const chars = charsOf(s);
|
|
96
|
+
// Nothing to the right (end of buffer or only whitespace to the newline):
|
|
97
|
+
// delete the word on the LEFT instead of doing nothing.
|
|
98
|
+
let probe = s.cursor;
|
|
99
|
+
while (probe < chars.length &&
|
|
100
|
+
chars[probe] !== NL &&
|
|
101
|
+
/\s/.test(chars[probe])) {
|
|
102
|
+
probe++;
|
|
103
|
+
}
|
|
104
|
+
if (probe >= chars.length || chars[probe] === NL) {
|
|
105
|
+
return deleteWordLeft(s);
|
|
106
|
+
}
|
|
87
107
|
let i = s.cursor;
|
|
88
108
|
while (i < chars.length && /\s/.test(chars[i]))
|
|
89
109
|
i++;
|
package/dist/tui/keys.js
CHANGED
|
@@ -9,6 +9,40 @@
|
|
|
9
9
|
// Kept free of React/Ink runtime imports beyond the Key TYPE, so it is
|
|
10
10
|
// unit-testable.
|
|
11
11
|
const LF = String.fromCharCode(10);
|
|
12
|
+
/**
|
|
13
|
+
* Ctrl/Alt + named keys under the KITTY keyboard protocol (CSI-u). Ink's
|
|
14
|
+
* `parseKeypress` recognizes the MODIFIED ARROW forms but returns `name=''`
|
|
15
|
+
* for the modified Delete/Backspace codepoints (CSI 3;5u, 127;5u, ...), so
|
|
16
|
+
* `useInput` yields an empty input/name and the binding never fires — the
|
|
17
|
+
* operator's "Ctrl+Delete does nothing". The legacy LineEditor parsed these
|
|
18
|
+
* bytes itself; the TUI reads them from raw stdin (Ink hides `sequence`) and
|
|
19
|
+
* maps the known ones to the SAME actions so both editors behave alike.
|
|
20
|
+
*
|
|
21
|
+
* Returns null when the string is not one of these sequences. Pure + exported
|
|
22
|
+
* for tests.
|
|
23
|
+
*/
|
|
24
|
+
export function parseCsiU(raw) {
|
|
25
|
+
// CSI codepoint ; modifier [: eventType] u — e.g. ESC[3;5u (Ctrl+Delete),
|
|
26
|
+
// ESC[127;5u (Ctrl+Backspace), ESC[3u (plain Delete),
|
|
27
|
+
// ESC[127u (plain Backspace), ESC[13;5u (Ctrl+Enter).
|
|
28
|
+
const m = /^\u001b\[(\d+)(?:;(\d+))?(?::\d+)?u$/.exec(raw);
|
|
29
|
+
if (!m)
|
|
30
|
+
return null;
|
|
31
|
+
const cp = Number(m[1]);
|
|
32
|
+
const mod = m[2] ? Number(m[2]) - 1 : 0;
|
|
33
|
+
const ctrl = (mod & 4) !== 0;
|
|
34
|
+
const alt = (mod & 2) !== 0;
|
|
35
|
+
if (cp === 13 && (ctrl || alt))
|
|
36
|
+
return { type: 'newline' };
|
|
37
|
+
if (cp === 3) {
|
|
38
|
+
// Ctrl/Alt+Delete deletes the word on the LEFT (see dispatchKey).
|
|
39
|
+
return ctrl || alt ? { type: 'deleteWordLeft' } : { type: 'delete' };
|
|
40
|
+
}
|
|
41
|
+
if (cp === 127) {
|
|
42
|
+
return ctrl || alt ? { type: 'deleteWordLeft' } : { type: 'backspace' };
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
12
46
|
/**
|
|
13
47
|
* Map a normalized Ink keypress to an editor action.
|
|
14
48
|
*
|
|
@@ -51,8 +85,11 @@ export function dispatchKey(input, key, ctx, isVisualEdge) {
|
|
|
51
85
|
return { type: 'wordLeft' };
|
|
52
86
|
if (key.rightArrow)
|
|
53
87
|
return { type: 'wordRight' };
|
|
88
|
+
// Ctrl+Delete deletes the word BEFORE the cursor (the operator's binding:
|
|
89
|
+
// "Ctrl+Del must delete the word before the cursor"). Ctrl+Backspace does
|
|
90
|
+
// the same. Plain Delete still deletes one char to the RIGHT.
|
|
54
91
|
if (key.delete)
|
|
55
|
-
return { type: '
|
|
92
|
+
return { type: 'deleteWordLeft' };
|
|
56
93
|
if (key.backspace)
|
|
57
94
|
return { type: 'deleteWordLeft' };
|
|
58
95
|
if (key.home)
|
|
@@ -107,10 +144,17 @@ export function dispatchKey(input, key, ctx, isVisualEdge) {
|
|
|
107
144
|
return { type: 'wordLeft' };
|
|
108
145
|
if (input === 'f')
|
|
109
146
|
return { type: 'wordRight' };
|
|
147
|
+
// VSCode sends Ctrl+Delete as `ESC d` (which Ink reports as Alt+d).
|
|
148
|
+
// The operator expects it to delete the word BEFORE the cursor.
|
|
149
|
+
if (input === 'd')
|
|
150
|
+
return { type: 'deleteWordLeft' };
|
|
110
151
|
if (key.backspace)
|
|
111
152
|
return { type: 'deleteWordLeft' };
|
|
153
|
+
// Alt/Ctrl+Delete also deletes the word BEFORE the cursor (the operator's
|
|
154
|
+
// binding, consistent with ESC d): some terminals encode Ctrl+Delete as
|
|
155
|
+
// meta+Delete rather than ESC d.
|
|
112
156
|
if (key.delete)
|
|
113
|
-
return { type: '
|
|
157
|
+
return { type: 'deleteWordLeft' };
|
|
114
158
|
return null;
|
|
115
159
|
}
|
|
116
160
|
// ---- LF as a bare newline (Ctrl+J) ----
|