zames_pro 2.64.1 → 2.65.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 +36 -0
- package/README.md +12 -11
- package/dist/backlog.js +20 -0
- package/dist/browser.js +11 -0
- package/dist/commands.js +81 -0
- package/dist/config-menu.js +71 -33
- package/dist/i18n/common.js +362 -0
- package/dist/i18n/config.js +199 -0
- package/dist/i18n/deepseek.js +194 -0
- package/dist/i18n/doctor.js +127 -0
- package/dist/i18n/help.js +552 -0
- package/dist/i18n/mcp.js +41 -0
- package/dist/i18n/prompt.js +20 -0
- package/dist/i18n/self-review.js +198 -0
- package/dist/i18n/spinner.js +36 -0
- package/dist/i18n.js +21 -1724
- package/dist/index.js +39 -503
- package/dist/input/layout.js +292 -0
- package/dist/input.js +37 -290
- package/dist/run-task.js +451 -0
- package/package.json +2 -2
- package/scripts/render-demo.mjs +153 -0
- package/logo.jpg +0 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Terminal INPUT-LINE layout + formatting helpers — the PURE half of the
|
|
2
|
+
// LineEditor (src/input.ts). No terminal access, no state: given a prompt, a
|
|
3
|
+
// buffer, a cursor and a width these compute the visual rows, the cursor
|
|
4
|
+
// position, the visible width (wide chars count 2) and the status formatting.
|
|
5
|
+
// Split out (C3 step 3) so the geometry can be unit-tested without driving the
|
|
6
|
+
// real editor, and so the editor class keeps only render/key handling.
|
|
7
|
+
// All control characters are assembled from codes so the file has no
|
|
8
|
+
// "raw" ESC/CR/LF in string literals (see AGENTS.md).
|
|
9
|
+
const NL = String.fromCharCode(10);
|
|
10
|
+
const CR = String.fromCharCode(13);
|
|
11
|
+
const ESC = String.fromCharCode(27);
|
|
12
|
+
const PASTE_START = ESC + '[200~';
|
|
13
|
+
const PASTE_END = ESC + '[201~';
|
|
14
|
+
// A large paste (3+ lines) is collapsed in the input line into a short
|
|
15
|
+
// marker "[Pasted lines#N]" so the line stays readable; the original text is
|
|
16
|
+
// kept aside and expanded back on submit. Pastes of 1–2 lines are inserted
|
|
17
|
+
// as-is (small pastes are usually short and don't clutter the line).
|
|
18
|
+
export const PASTE_MIN_LINES = 3;
|
|
19
|
+
// How many slash-command hints are shown at once (the rest are paged with
|
|
20
|
+
// Ctrl+N/P). The list used to be hard-capped at 8 with a "...more" note and no
|
|
21
|
+
// way to reach the rest.
|
|
22
|
+
export const SUGGEST_PAGE = 8;
|
|
23
|
+
// True when `q` is a subsequence of `s` (e.g. "hst" in "/history"). Used for
|
|
24
|
+
// the fuzzy fallback in _suggestions().
|
|
25
|
+
export function isSubsequence(q, s) {
|
|
26
|
+
let i = 0;
|
|
27
|
+
for (let j = 0; j < s.length && i < q.length; j++) {
|
|
28
|
+
if (s[j] === q[i])
|
|
29
|
+
i++;
|
|
30
|
+
}
|
|
31
|
+
return i === q.length;
|
|
32
|
+
}
|
|
33
|
+
// Number of lines in a pasted block (the trailing newline doesn't add a line).
|
|
34
|
+
export function countPasteLines(raw) {
|
|
35
|
+
const normalized = String(raw)
|
|
36
|
+
.split(CR + NL)
|
|
37
|
+
.join(NL)
|
|
38
|
+
.split(CR)
|
|
39
|
+
.join(NL);
|
|
40
|
+
const trimmed = normalized.replace(/\n+$/, '');
|
|
41
|
+
if (trimmed === '')
|
|
42
|
+
return 1;
|
|
43
|
+
return trimmed.split(NL).length;
|
|
44
|
+
}
|
|
45
|
+
export function formatPasteMarker(lines) {
|
|
46
|
+
return '[Pasted lines#' + lines + ']';
|
|
47
|
+
}
|
|
48
|
+
// Decide how to represent a pasted block: null — insert as-is (small paste),
|
|
49
|
+
// otherwise { marker, text }: the marker goes into the input line, and the
|
|
50
|
+
// original text is expanded back on submit.
|
|
51
|
+
export function pasteReplacement(raw) {
|
|
52
|
+
const text = String(raw)
|
|
53
|
+
.split(CR + NL)
|
|
54
|
+
.join(NL)
|
|
55
|
+
.split(CR)
|
|
56
|
+
.join(NL);
|
|
57
|
+
const lines = countPasteLines(text);
|
|
58
|
+
if (lines < PASTE_MIN_LINES)
|
|
59
|
+
return null;
|
|
60
|
+
return { marker: formatPasteMarker(lines), text };
|
|
61
|
+
}
|
|
62
|
+
// Replace "[Pasted lines#N]" markers with the original pasted text (the first
|
|
63
|
+
// occurrence in order — matches the order in which the pastes were inserted).
|
|
64
|
+
export function expandPastes(pastes, text) {
|
|
65
|
+
let out = text;
|
|
66
|
+
for (const p of pastes) {
|
|
67
|
+
const idx = out.indexOf(p.marker);
|
|
68
|
+
if (idx !== -1) {
|
|
69
|
+
out = out.slice(0, idx) + p.text + out.slice(idx + p.marker.length);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function safeJson(v) {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.stringify(v);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return String(v);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// How many visual lines the text occupies at the given terminal width
|
|
83
|
+
// (accounting for wrapping). Needed so the cursor/block erase don't drift
|
|
84
|
+
// when the status is longer than the terminal width and wraps onto 2+ lines.
|
|
85
|
+
export function visRows(s, cols) {
|
|
86
|
+
const width = Math.max(1, Number(cols) || 80);
|
|
87
|
+
const len = visLen(s);
|
|
88
|
+
return Math.max(1, Math.ceil(len / width));
|
|
89
|
+
}
|
|
90
|
+
// Visible length of a line without ANSI sequences. WIDE characters (emoji,
|
|
91
|
+
// CJK) count as 2 columns — a plain code-point count made the status-line
|
|
92
|
+
// arithmetic (right-align, wrapping, block erase) disagree with the real
|
|
93
|
+
// screen, so a status containing an emoji/CJK could wrap past the edge and
|
|
94
|
+
// make the block erase miss (status "stacking"). Combining marks and the
|
|
95
|
+
// zero-width joiner/space/variation selectors count as 0.
|
|
96
|
+
export function visLen(s) {
|
|
97
|
+
s = String(s);
|
|
98
|
+
let n = 0;
|
|
99
|
+
let i = 0;
|
|
100
|
+
while (i < s.length) {
|
|
101
|
+
if (s.charCodeAt(i) === 27) {
|
|
102
|
+
i++;
|
|
103
|
+
if (s[i] === '[') {
|
|
104
|
+
i++;
|
|
105
|
+
while (i < s.length && !/[A-Za-z]/.test(s[i]))
|
|
106
|
+
i++;
|
|
107
|
+
i++;
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const cp = s.codePointAt(i);
|
|
112
|
+
n += charWidth(cp);
|
|
113
|
+
i += cp > 0xffff ? 2 : 1;
|
|
114
|
+
}
|
|
115
|
+
return n;
|
|
116
|
+
}
|
|
117
|
+
// Display width of one code point in columns (0, 1 or 2). Based on the
|
|
118
|
+
// conventional wcwidth ranges — good enough for terminal layout, and it makes
|
|
119
|
+
// the emoji used in the status line (🧠🌐) count as 2, while math symbols like
|
|
120
|
+
// ⧗ (U+29D7) stay 1, matching a real terminal.
|
|
121
|
+
export function charWidth(cp) {
|
|
122
|
+
// Zero width: combining marks, ZWJ/ZWNJ, variation selectors.
|
|
123
|
+
if (cp === 0x200b ||
|
|
124
|
+
cp === 0x200c ||
|
|
125
|
+
cp === 0x200d ||
|
|
126
|
+
(cp >= 0x0300 && cp <= 0x036f) ||
|
|
127
|
+
(cp >= 0x1ab0 && cp <= 0x1aff) ||
|
|
128
|
+
(cp >= 0x1dc0 && cp <= 0x1dff) ||
|
|
129
|
+
(cp >= 0x20d0 && cp <= 0x20ff) ||
|
|
130
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) ||
|
|
131
|
+
(cp >= 0xfe20 && cp <= 0xfe2f)) {
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
// Wide: CJK, Hangul, fullwidth forms, emoji.
|
|
135
|
+
if ((cp >= 0x1100 && cp <= 0x115f) ||
|
|
136
|
+
(cp >= 0x2e80 && cp <= 0x303e) ||
|
|
137
|
+
(cp >= 0x3041 && cp <= 0x33ff) ||
|
|
138
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
139
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
140
|
+
(cp >= 0xa000 && cp <= 0xa4cf) ||
|
|
141
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
142
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
143
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
144
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
145
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
146
|
+
(cp >= 0x1f000 && cp <= 0x1f02f) ||
|
|
147
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
148
|
+
(cp >= 0x20000 && cp <= 0x3fffd)) {
|
|
149
|
+
return 2;
|
|
150
|
+
}
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
export function layoutInput(promptStr, buf, cursor, cols) {
|
|
154
|
+
// Reserve the LAST column. If a printed row reaches exactly `cols` columns,
|
|
155
|
+
// terminals with autowrap (Tabby, iTerm, Windows Terminal, xterm's default
|
|
156
|
+
// deferred wrap) either wrap the cursor to the next line or leave it in the
|
|
157
|
+
// last cell with a "pending wrap". In both cases the editor's row count
|
|
158
|
+
// disagrees with the real screen by one, so the cursor/erase arithmetic in
|
|
159
|
+
// `_renderInputOnly`/`_writeBlock` walks one row too far and the input line
|
|
160
|
+
// climbs. This was most visible in an EMPTY chat with a single long,
|
|
161
|
+
// space-less line (which fills the row exactly) and when moving the cursor
|
|
162
|
+
// with the arrow keys.
|
|
163
|
+
const width = Math.max(2, (Number(cols) || 80) - 1);
|
|
164
|
+
const promptW = visLen(promptStr);
|
|
165
|
+
const pad = ' '.repeat(promptW);
|
|
166
|
+
const chars = Array.from(String(buf));
|
|
167
|
+
const cur = Math.max(0, Math.min(Number(cursor) || 0, chars.length));
|
|
168
|
+
const rows = [];
|
|
169
|
+
let i = 0;
|
|
170
|
+
while (true) {
|
|
171
|
+
const first = rows.length === 0;
|
|
172
|
+
const prefix = first ? promptStr : pad;
|
|
173
|
+
const avail = Math.max(1, width - promptW);
|
|
174
|
+
const start = i;
|
|
175
|
+
// Build the next visual line. First we take as many
|
|
176
|
+
// characters as fit (columns < avail). If we hit the width
|
|
177
|
+
// and the next character is not the end of the line, we roll back to the
|
|
178
|
+
// last space so we don't split a word in the middle (word-wrap).
|
|
179
|
+
// Wrapping and the cursor column are measured in COLUMNS (charWidth),
|
|
180
|
+
// not code points — otherwise a CJK/emoji input would overflow the row
|
|
181
|
+
// and put the cursor on the wrong cell.
|
|
182
|
+
let text = '';
|
|
183
|
+
let widthUsed = 0;
|
|
184
|
+
let charIdx = 0;
|
|
185
|
+
let lastSpace = -1; // char index of the space within text
|
|
186
|
+
while (i < chars.length && chars[i] !== NL) {
|
|
187
|
+
const w = charWidth(chars[i].codePointAt(0));
|
|
188
|
+
if (widthUsed + w > avail)
|
|
189
|
+
break;
|
|
190
|
+
if (chars[i] === ' ')
|
|
191
|
+
lastSpace = charIdx;
|
|
192
|
+
text += chars[i];
|
|
193
|
+
i++;
|
|
194
|
+
charIdx++;
|
|
195
|
+
widthUsed += w;
|
|
196
|
+
}
|
|
197
|
+
const atLineEnd = i >= chars.length || chars[i] === NL;
|
|
198
|
+
if (!atLineEnd && widthUsed >= avail && lastSpace > 0) {
|
|
199
|
+
// Move the "tail" of the line to the next visual line.
|
|
200
|
+
const textChars = Array.from(text);
|
|
201
|
+
const tailLen = textChars.length - lastSpace;
|
|
202
|
+
i -= tailLen;
|
|
203
|
+
text = textChars.slice(0, lastSpace).join('');
|
|
204
|
+
}
|
|
205
|
+
if (i < chars.length && chars[i] === NL) {
|
|
206
|
+
i++;
|
|
207
|
+
rows.push({ prefix, text, start });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
rows.push({ prefix, text, start });
|
|
211
|
+
if (i >= chars.length)
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
let cursorRow = 0;
|
|
215
|
+
let cursorCol = promptW;
|
|
216
|
+
for (let r = 0; r < rows.length; r++) {
|
|
217
|
+
const row = rows[r];
|
|
218
|
+
const rowChars = Array.from(row.text);
|
|
219
|
+
const len = rowChars.length;
|
|
220
|
+
const from = row.start;
|
|
221
|
+
const to = row.start + len;
|
|
222
|
+
if (cur >= from && cur <= to) {
|
|
223
|
+
const next = rows[r + 1];
|
|
224
|
+
if (cur === to && next && next.start === to)
|
|
225
|
+
continue;
|
|
226
|
+
cursorRow = r;
|
|
227
|
+
// Cursor column in COLUMNS (wide chars count as 2).
|
|
228
|
+
let w = 0;
|
|
229
|
+
for (let k = 0; k < cur - from; k++) {
|
|
230
|
+
w += charWidth(rowChars[k].codePointAt(0));
|
|
231
|
+
}
|
|
232
|
+
cursorCol = promptW + w;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { rows, cursorRow, cursorCol };
|
|
237
|
+
}
|
|
238
|
+
// Format a token count for the status line: compact (10k, 125k) plus the
|
|
239
|
+
// percentage of CONTEXT_LIMIT. Exported so it is unit-tested without a live
|
|
240
|
+
// editor. A null/undefined/NaN count renders an empty string (no status).
|
|
241
|
+
export const CONTEXT_LIMIT = 1_000_000;
|
|
242
|
+
// Thresholds for coloring the context counter. Below YELLOW the value is
|
|
243
|
+
// green (plenty of room), between YELLOW and RED it is yellow (getting full),
|
|
244
|
+
// above RED it is red (nearly exhausted). Percent of CONTEXT_LIMIT.
|
|
245
|
+
export const CONTEXT_YELLOW_PCT = 50;
|
|
246
|
+
export const CONTEXT_RED_PCT = 80;
|
|
247
|
+
// The color role for a context fill level, as a stable string so it can be
|
|
248
|
+
// unit-tested without a terminal: 'ok' | 'warn' | 'high'. Null tokens -> null.
|
|
249
|
+
export function tokenStatusLevel(tokens, limit = CONTEXT_LIMIT) {
|
|
250
|
+
if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens < 0) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
const pct = (tokens / limit) * 100;
|
|
254
|
+
if (pct >= CONTEXT_RED_PCT)
|
|
255
|
+
return 'high';
|
|
256
|
+
if (pct >= CONTEXT_YELLOW_PCT)
|
|
257
|
+
return 'warn';
|
|
258
|
+
return 'ok';
|
|
259
|
+
}
|
|
260
|
+
export function formatTokenStatus(tokens, limit = CONTEXT_LIMIT) {
|
|
261
|
+
if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens < 0) {
|
|
262
|
+
return '';
|
|
263
|
+
}
|
|
264
|
+
const n = Math.round(tokens);
|
|
265
|
+
const compact = formatCompactTokens(n);
|
|
266
|
+
const pct = Math.max(0, (n / limit) * 100);
|
|
267
|
+
const pctStr = pct >= 10 ? String(Math.round(pct)) : pct.toFixed(1);
|
|
268
|
+
// "ctx:" marks this as the USED context size, so the operator does not have
|
|
269
|
+
// to guess what "302k · 30%" means.
|
|
270
|
+
return 'ctx: ' + compact + ' · ' + pctStr + '%';
|
|
271
|
+
}
|
|
272
|
+
// Compact token count (10k, 125k, 1.5M). Exported so the per-task summary
|
|
273
|
+
// reuses the EXACT same "k/M" formatting as the context counter. Invalid
|
|
274
|
+
// input (null/NaN/negative) renders an empty string.
|
|
275
|
+
export function formatCompactTokens(tokens) {
|
|
276
|
+
if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens < 0) {
|
|
277
|
+
return '';
|
|
278
|
+
}
|
|
279
|
+
const n = Math.round(tokens);
|
|
280
|
+
if (n >= 1_000_000) {
|
|
281
|
+
const m = n / 1_000_000;
|
|
282
|
+
return (Number.isInteger(m) ? String(m) : m.toFixed(1)) + 'M';
|
|
283
|
+
}
|
|
284
|
+
if (n >= 1_000) {
|
|
285
|
+
const k = n / 1_000;
|
|
286
|
+
return ((k >= 100 ? String(Math.round(k)) : k.toFixed(1).replace(/\.0$/, '')) +
|
|
287
|
+
'k');
|
|
288
|
+
}
|
|
289
|
+
return String(n);
|
|
290
|
+
}
|
|
291
|
+
// Re-exported for the editor's layout selftest (node src/input.js --selftest).
|
|
292
|
+
export { NL, CR, ESC, PASTE_START, PASTE_END, safeJson };
|
package/dist/input.js
CHANGED
|
@@ -3,6 +3,12 @@ import { renderMarkdown } from './markdown.js';
|
|
|
3
3
|
import { randomThinkingPhrase, stripEllipsis, DOTS, renderDots, } from './spinner.js';
|
|
4
4
|
import { translate } from './i18n.js';
|
|
5
5
|
import { AttachmentStore, parseImagePaste, looksLikeFilePath, } from './attachments.js';
|
|
6
|
+
import { NL, CR, ESC, PASTE_START, PASTE_END, SUGGEST_PAGE, isSubsequence, pasteReplacement, expandPastes, safeJson, visRows, visLen, layoutInput, formatTokenStatus, tokenStatusLevel, CONTEXT_LIMIT, } from './input/layout.js';
|
|
7
|
+
// The pure layout/formatting helpers (visual rows, cursor position, visible
|
|
8
|
+
// width, paste markers, token formatting) live in ./input/layout.js. They are
|
|
9
|
+
// re-exported here so every existing import from './input.js' keeps working,
|
|
10
|
+
// and so tests can import them from either module (C3 step 3).
|
|
11
|
+
export { PASTE_MIN_LINES, SUGGEST_PAGE, isSubsequence, countPasteLines, formatPasteMarker, pasteReplacement, expandPastes, visRows, visLen, charWidth, layoutInput, tokenStatusLevel, formatTokenStatus, formatCompactTokens, CONTEXT_LIMIT, CONTEXT_YELLOW_PCT, CONTEXT_RED_PCT, } from './input/layout.js';
|
|
6
12
|
// A permanent input line at the bottom of the terminal + a status/output area above it.
|
|
7
13
|
//
|
|
8
14
|
// Why a custom editor:
|
|
@@ -24,290 +30,6 @@ import { AttachmentStore, parseImagePaste, looksLikeFilePath, } from './attachme
|
|
|
24
30
|
//
|
|
25
31
|
// All control characters are assembled from codes so the file has no
|
|
26
32
|
// "raw" ESC/CR/LF in string literals (see AGENTS.md).
|
|
27
|
-
const NL = String.fromCharCode(10);
|
|
28
|
-
const CR = String.fromCharCode(13);
|
|
29
|
-
const ESC = String.fromCharCode(27);
|
|
30
|
-
const PASTE_START = ESC + '[200~';
|
|
31
|
-
const PASTE_END = ESC + '[201~';
|
|
32
|
-
// A large paste (3+ lines) is collapsed in the input line into a short
|
|
33
|
-
// marker "[Pasted lines#N]" so the line stays readable; the original text is
|
|
34
|
-
// kept aside and expanded back on submit. Pastes of 1–2 lines are inserted
|
|
35
|
-
// as-is (small pastes are usually short and don't clutter the line).
|
|
36
|
-
export const PASTE_MIN_LINES = 3;
|
|
37
|
-
// How many slash-command hints are shown at once (the rest are paged with
|
|
38
|
-
// Ctrl+N/P). The list used to be hard-capped at 8 with a "...more" note and no
|
|
39
|
-
// way to reach the rest.
|
|
40
|
-
export const SUGGEST_PAGE = 8;
|
|
41
|
-
// True when `q` is a subsequence of `s` (e.g. "hst" in "/history"). Used for
|
|
42
|
-
// the fuzzy fallback in _suggestions().
|
|
43
|
-
export function isSubsequence(q, s) {
|
|
44
|
-
let i = 0;
|
|
45
|
-
for (let j = 0; j < s.length && i < q.length; j++) {
|
|
46
|
-
if (s[j] === q[i])
|
|
47
|
-
i++;
|
|
48
|
-
}
|
|
49
|
-
return i === q.length;
|
|
50
|
-
}
|
|
51
|
-
// Number of lines in a pasted block (the trailing newline doesn't add a line).
|
|
52
|
-
export function countPasteLines(raw) {
|
|
53
|
-
const normalized = String(raw)
|
|
54
|
-
.split(CR + NL)
|
|
55
|
-
.join(NL)
|
|
56
|
-
.split(CR)
|
|
57
|
-
.join(NL);
|
|
58
|
-
const trimmed = normalized.replace(/\n+$/, '');
|
|
59
|
-
if (trimmed === '')
|
|
60
|
-
return 1;
|
|
61
|
-
return trimmed.split(NL).length;
|
|
62
|
-
}
|
|
63
|
-
export function formatPasteMarker(lines) {
|
|
64
|
-
return '[Pasted lines#' + lines + ']';
|
|
65
|
-
}
|
|
66
|
-
// Decide how to represent a pasted block: null — insert as-is (small paste),
|
|
67
|
-
// otherwise { marker, text }: the marker goes into the input line, and the
|
|
68
|
-
// original text is expanded back on submit.
|
|
69
|
-
export function pasteReplacement(raw) {
|
|
70
|
-
const text = String(raw)
|
|
71
|
-
.split(CR + NL)
|
|
72
|
-
.join(NL)
|
|
73
|
-
.split(CR)
|
|
74
|
-
.join(NL);
|
|
75
|
-
const lines = countPasteLines(text);
|
|
76
|
-
if (lines < PASTE_MIN_LINES)
|
|
77
|
-
return null;
|
|
78
|
-
return { marker: formatPasteMarker(lines), text };
|
|
79
|
-
}
|
|
80
|
-
// Replace "[Pasted lines#N]" markers with the original pasted text (the first
|
|
81
|
-
// occurrence in order — matches the order in which the pastes were inserted).
|
|
82
|
-
export function expandPastes(pastes, text) {
|
|
83
|
-
let out = text;
|
|
84
|
-
for (const p of pastes) {
|
|
85
|
-
const idx = out.indexOf(p.marker);
|
|
86
|
-
if (idx !== -1) {
|
|
87
|
-
out = out.slice(0, idx) + p.text + out.slice(idx + p.marker.length);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return out;
|
|
91
|
-
}
|
|
92
|
-
// Dot animation is shared with the ora spinner (see src/spinner.ts): DOTS and
|
|
93
|
-
// renderDots live there so both status lines animate identically.
|
|
94
|
-
function safeJson(v) {
|
|
95
|
-
try {
|
|
96
|
-
return JSON.stringify(v);
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
return String(v);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
// How many visual lines the text occupies at the given terminal width
|
|
103
|
-
// (accounting for wrapping). Needed so the cursor/block erase don't drift
|
|
104
|
-
// when the status is longer than the terminal width and wraps onto 2+ lines.
|
|
105
|
-
export function visRows(s, cols) {
|
|
106
|
-
const width = Math.max(1, Number(cols) || 80);
|
|
107
|
-
const len = visLen(s);
|
|
108
|
-
return Math.max(1, Math.ceil(len / width));
|
|
109
|
-
}
|
|
110
|
-
// Visible length of a line without ANSI sequences. WIDE characters (emoji,
|
|
111
|
-
// CJK) count as 2 columns — a plain code-point count made the status-line
|
|
112
|
-
// arithmetic (right-align, wrapping, block erase) disagree with the real
|
|
113
|
-
// screen, so a status containing an emoji/CJK could wrap past the edge and
|
|
114
|
-
// make the block erase miss (status "stacking"). Combining marks and the
|
|
115
|
-
// zero-width joiner/space/variation selectors count as 0.
|
|
116
|
-
export function visLen(s) {
|
|
117
|
-
s = String(s);
|
|
118
|
-
let n = 0;
|
|
119
|
-
let i = 0;
|
|
120
|
-
while (i < s.length) {
|
|
121
|
-
if (s.charCodeAt(i) === 27) {
|
|
122
|
-
i++;
|
|
123
|
-
if (s[i] === '[') {
|
|
124
|
-
i++;
|
|
125
|
-
while (i < s.length && !/[A-Za-z]/.test(s[i]))
|
|
126
|
-
i++;
|
|
127
|
-
i++;
|
|
128
|
-
}
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
const cp = s.codePointAt(i);
|
|
132
|
-
n += charWidth(cp);
|
|
133
|
-
i += cp > 0xffff ? 2 : 1;
|
|
134
|
-
}
|
|
135
|
-
return n;
|
|
136
|
-
}
|
|
137
|
-
// Display width of one code point in columns (0, 1 or 2). Based on the
|
|
138
|
-
// conventional wcwidth ranges — good enough for terminal layout, and it makes
|
|
139
|
-
// the emoji used in the status line (🧠🌐) count as 2, while math symbols like
|
|
140
|
-
// ⧗ (U+29D7) stay 1, matching a real terminal.
|
|
141
|
-
export function charWidth(cp) {
|
|
142
|
-
// Zero width: combining marks, ZWJ/ZWNJ, variation selectors.
|
|
143
|
-
if (cp === 0x200b ||
|
|
144
|
-
cp === 0x200c ||
|
|
145
|
-
cp === 0x200d ||
|
|
146
|
-
(cp >= 0x0300 && cp <= 0x036f) ||
|
|
147
|
-
(cp >= 0x1ab0 && cp <= 0x1aff) ||
|
|
148
|
-
(cp >= 0x1dc0 && cp <= 0x1dff) ||
|
|
149
|
-
(cp >= 0x20d0 && cp <= 0x20ff) ||
|
|
150
|
-
(cp >= 0xfe00 && cp <= 0xfe0f) ||
|
|
151
|
-
(cp >= 0xfe20 && cp <= 0xfe2f)) {
|
|
152
|
-
return 0;
|
|
153
|
-
}
|
|
154
|
-
// Wide: CJK, Hangul, fullwidth forms, emoji.
|
|
155
|
-
if ((cp >= 0x1100 && cp <= 0x115f) ||
|
|
156
|
-
(cp >= 0x2e80 && cp <= 0x303e) ||
|
|
157
|
-
(cp >= 0x3041 && cp <= 0x33ff) ||
|
|
158
|
-
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
159
|
-
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
160
|
-
(cp >= 0xa000 && cp <= 0xa4cf) ||
|
|
161
|
-
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
162
|
-
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
163
|
-
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
164
|
-
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
165
|
-
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
166
|
-
(cp >= 0x1f000 && cp <= 0x1f02f) ||
|
|
167
|
-
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
168
|
-
(cp >= 0x20000 && cp <= 0x3fffd)) {
|
|
169
|
-
return 2;
|
|
170
|
-
}
|
|
171
|
-
return 1;
|
|
172
|
-
}
|
|
173
|
-
export function layoutInput(promptStr, buf, cursor, cols) {
|
|
174
|
-
// Reserve the LAST column. If a printed row reaches exactly `cols` columns,
|
|
175
|
-
// terminals with autowrap (Tabby, iTerm, Windows Terminal, xterm's default
|
|
176
|
-
// deferred wrap) either wrap the cursor to the next line or leave it in the
|
|
177
|
-
// last cell with a "pending wrap". In both cases the editor's row count
|
|
178
|
-
// disagrees with the real screen by one, so the cursor/erase arithmetic in
|
|
179
|
-
// `_renderInputOnly`/`_writeBlock` walks one row too far and the input line
|
|
180
|
-
// climbs. This was most visible in an EMPTY chat with a single long,
|
|
181
|
-
// space-less line (which fills the row exactly) and when moving the cursor
|
|
182
|
-
// with the arrow keys.
|
|
183
|
-
const width = Math.max(2, (Number(cols) || 80) - 1);
|
|
184
|
-
const promptW = visLen(promptStr);
|
|
185
|
-
const pad = ' '.repeat(promptW);
|
|
186
|
-
const chars = Array.from(String(buf));
|
|
187
|
-
const cur = Math.max(0, Math.min(Number(cursor) || 0, chars.length));
|
|
188
|
-
const rows = [];
|
|
189
|
-
let i = 0;
|
|
190
|
-
while (true) {
|
|
191
|
-
const first = rows.length === 0;
|
|
192
|
-
const prefix = first ? promptStr : pad;
|
|
193
|
-
const avail = Math.max(1, width - promptW);
|
|
194
|
-
const start = i;
|
|
195
|
-
// Build the next visual line. First we take as many
|
|
196
|
-
// characters as fit (columns < avail). If we hit the width
|
|
197
|
-
// and the next character is not the end of the line, we roll back to the
|
|
198
|
-
// last space so we don't split a word in the middle (word-wrap).
|
|
199
|
-
// Wrapping and the cursor column are measured in COLUMNS (charWidth),
|
|
200
|
-
// not code points — otherwise a CJK/emoji input would overflow the row
|
|
201
|
-
// and put the cursor on the wrong cell.
|
|
202
|
-
let text = '';
|
|
203
|
-
let widthUsed = 0;
|
|
204
|
-
let charIdx = 0;
|
|
205
|
-
let lastSpace = -1; // char index of the space within text
|
|
206
|
-
while (i < chars.length && chars[i] !== NL) {
|
|
207
|
-
const w = charWidth(chars[i].codePointAt(0));
|
|
208
|
-
if (widthUsed + w > avail)
|
|
209
|
-
break;
|
|
210
|
-
if (chars[i] === ' ')
|
|
211
|
-
lastSpace = charIdx;
|
|
212
|
-
text += chars[i];
|
|
213
|
-
i++;
|
|
214
|
-
charIdx++;
|
|
215
|
-
widthUsed += w;
|
|
216
|
-
}
|
|
217
|
-
const atLineEnd = i >= chars.length || chars[i] === NL;
|
|
218
|
-
if (!atLineEnd && widthUsed >= avail && lastSpace > 0) {
|
|
219
|
-
// Move the "tail" of the line to the next visual line.
|
|
220
|
-
const textChars = Array.from(text);
|
|
221
|
-
const tailLen = textChars.length - lastSpace;
|
|
222
|
-
i -= tailLen;
|
|
223
|
-
text = textChars.slice(0, lastSpace).join('');
|
|
224
|
-
}
|
|
225
|
-
if (i < chars.length && chars[i] === NL) {
|
|
226
|
-
i++;
|
|
227
|
-
rows.push({ prefix, text, start });
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
rows.push({ prefix, text, start });
|
|
231
|
-
if (i >= chars.length)
|
|
232
|
-
break;
|
|
233
|
-
}
|
|
234
|
-
let cursorRow = 0;
|
|
235
|
-
let cursorCol = promptW;
|
|
236
|
-
for (let r = 0; r < rows.length; r++) {
|
|
237
|
-
const row = rows[r];
|
|
238
|
-
const rowChars = Array.from(row.text);
|
|
239
|
-
const len = rowChars.length;
|
|
240
|
-
const from = row.start;
|
|
241
|
-
const to = row.start + len;
|
|
242
|
-
if (cur >= from && cur <= to) {
|
|
243
|
-
const next = rows[r + 1];
|
|
244
|
-
if (cur === to && next && next.start === to)
|
|
245
|
-
continue;
|
|
246
|
-
cursorRow = r;
|
|
247
|
-
// Cursor column in COLUMNS (wide chars count as 2).
|
|
248
|
-
let w = 0;
|
|
249
|
-
for (let k = 0; k < cur - from; k++) {
|
|
250
|
-
w += charWidth(rowChars[k].codePointAt(0));
|
|
251
|
-
}
|
|
252
|
-
cursorCol = promptW + w;
|
|
253
|
-
break;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
return { rows, cursorRow, cursorCol };
|
|
257
|
-
}
|
|
258
|
-
// Format a token count for the status line: compact (10k, 125k) plus the
|
|
259
|
-
// percentage of CONTEXT_LIMIT. Exported so it is unit-tested without a live
|
|
260
|
-
// editor. A null/undefined/NaN count renders an empty string (no status).
|
|
261
|
-
export const CONTEXT_LIMIT = 1_000_000;
|
|
262
|
-
// Thresholds for coloring the context counter. Below YELLOW the value is
|
|
263
|
-
// green (plenty of room), between YELLOW and RED it is yellow (getting full),
|
|
264
|
-
// above RED it is red (nearly exhausted). Percent of CONTEXT_LIMIT.
|
|
265
|
-
export const CONTEXT_YELLOW_PCT = 50;
|
|
266
|
-
export const CONTEXT_RED_PCT = 80;
|
|
267
|
-
// The color role for a context fill level, as a stable string so it can be
|
|
268
|
-
// unit-tested without a terminal: 'ok' | 'warn' | 'high'. Null tokens -> null.
|
|
269
|
-
export function tokenStatusLevel(tokens, limit = CONTEXT_LIMIT) {
|
|
270
|
-
if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens < 0) {
|
|
271
|
-
return null;
|
|
272
|
-
}
|
|
273
|
-
const pct = (tokens / limit) * 100;
|
|
274
|
-
if (pct >= CONTEXT_RED_PCT)
|
|
275
|
-
return 'high';
|
|
276
|
-
if (pct >= CONTEXT_YELLOW_PCT)
|
|
277
|
-
return 'warn';
|
|
278
|
-
return 'ok';
|
|
279
|
-
}
|
|
280
|
-
export function formatTokenStatus(tokens, limit = CONTEXT_LIMIT) {
|
|
281
|
-
if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens < 0) {
|
|
282
|
-
return '';
|
|
283
|
-
}
|
|
284
|
-
const n = Math.round(tokens);
|
|
285
|
-
const compact = formatCompactTokens(n);
|
|
286
|
-
const pct = Math.max(0, (n / limit) * 100);
|
|
287
|
-
const pctStr = pct >= 10 ? String(Math.round(pct)) : pct.toFixed(1);
|
|
288
|
-
// "ctx:" marks this as the USED context size, so the operator does not have
|
|
289
|
-
// to guess what "302k · 30%" means.
|
|
290
|
-
return 'ctx: ' + compact + ' · ' + pctStr + '%';
|
|
291
|
-
}
|
|
292
|
-
// Compact token count (10k, 125k, 1.5M). Exported so the per-task summary
|
|
293
|
-
// reuses the EXACT same "k/M" formatting as the context counter. Invalid
|
|
294
|
-
// input (null/NaN/negative) renders an empty string.
|
|
295
|
-
export function formatCompactTokens(tokens) {
|
|
296
|
-
if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens < 0) {
|
|
297
|
-
return '';
|
|
298
|
-
}
|
|
299
|
-
const n = Math.round(tokens);
|
|
300
|
-
if (n >= 1_000_000) {
|
|
301
|
-
const m = n / 1_000_000;
|
|
302
|
-
return (Number.isInteger(m) ? String(m) : m.toFixed(1)) + 'M';
|
|
303
|
-
}
|
|
304
|
-
if (n >= 1_000) {
|
|
305
|
-
const k = n / 1_000;
|
|
306
|
-
return ((k >= 100 ? String(Math.round(k)) : k.toFixed(1).replace(/\.0$/, '')) +
|
|
307
|
-
'k');
|
|
308
|
-
}
|
|
309
|
-
return String(n);
|
|
310
|
-
}
|
|
311
33
|
export class LineEditor {
|
|
312
34
|
promptStr;
|
|
313
35
|
buf;
|
|
@@ -356,6 +78,10 @@ export class LineEditor {
|
|
|
356
78
|
_blockRows;
|
|
357
79
|
_wasRaw;
|
|
358
80
|
_onData;
|
|
81
|
+
// True after a resize: the next render must RE-PIN the block
|
|
82
|
+
// (home + pad to bottom) instead of trusting the relative `ESC[n A` erase,
|
|
83
|
+
// whose old block row is stale after the terminal re-flowed.
|
|
84
|
+
_resizeRepin;
|
|
359
85
|
// On SIGWINCH we redraw the whole block so the layout follows the new
|
|
360
86
|
// terminal width. Debounced: a drag fires many resize events. `_onResize`
|
|
361
87
|
// is stored so it can be removed in dispose().
|
|
@@ -465,6 +191,7 @@ export class LineEditor {
|
|
|
465
191
|
this._sendState = '';
|
|
466
192
|
this._lastStatusBlock = '';
|
|
467
193
|
this._blockRows = 0;
|
|
194
|
+
this._resizeRepin = false;
|
|
468
195
|
this._wasRaw = false;
|
|
469
196
|
this._onData = (b) => this._handle(b);
|
|
470
197
|
this._resizeTimer = null;
|
|
@@ -478,10 +205,14 @@ export class LineEditor {
|
|
|
478
205
|
// Repaint ONLY the status + input block. We deliberately do NOT clear
|
|
479
206
|
// the whole viewport here: the operator's scrollback (the tool-call
|
|
480
207
|
// lines and answers printed above the input) must stay on screen.
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
208
|
+
// The relative `ESC[n A` erase cannot be trusted after a resize: the
|
|
209
|
+
// terminal re-flowed the longer wrapped lines and the old block row
|
|
210
|
+
// is stale, so the block would be redrawn at the wrong row (artifacts
|
|
211
|
+
// / duplicated status). The next block write re-pins from scratch
|
|
212
|
+
// (home + pad to bottom) instead of relying on the relative move.
|
|
213
|
+
// The scrollback above is still preserved — we only move the cursor
|
|
214
|
+
// and pad, and never emit ESC[2J ("losing the history is far worse").
|
|
215
|
+
this._resizeRepin = true;
|
|
485
216
|
this._render();
|
|
486
217
|
}, 120);
|
|
487
218
|
if (this._resizeTimer.unref)
|
|
@@ -928,6 +659,20 @@ export class LineEditor {
|
|
|
928
659
|
}
|
|
929
660
|
_writeBlock() {
|
|
930
661
|
const cols = process.stdout.columns || 80;
|
|
662
|
+
// After a resize the relative erase is unreliable (the terminal
|
|
663
|
+
// re-flowed everything and the cursor row changed). Re-DRAW the block
|
|
664
|
+
// from a known position instead: erase what we can, go HOME, fill the
|
|
665
|
+
// viewport with blank lines (pushing the old content into the
|
|
666
|
+
// scrollback — nothing is lost), then draw at the bottom. We never
|
|
667
|
+
// emit ESC[2J here, so the operator's history is preserved.
|
|
668
|
+
if (this._resizeRepin) {
|
|
669
|
+
this._resizeRepin = false;
|
|
670
|
+
this._eraseBlock();
|
|
671
|
+
this._lastStatusBlock = '';
|
|
672
|
+
this._blockRows = 0;
|
|
673
|
+
process.stdout.write(ESC + '[H');
|
|
674
|
+
this._padToBottom();
|
|
675
|
+
}
|
|
931
676
|
// Status line above the input: the spinner/answer text on the left and the
|
|
932
677
|
// token context right-aligned on the SAME row (10k · 12%). The context is
|
|
933
678
|
// refreshed from onContextQuery() on every render, so it follows the live
|
|
@@ -1006,7 +751,9 @@ export class LineEditor {
|
|
|
1006
751
|
// cursor math stays correct.
|
|
1007
752
|
this._statusTop = top;
|
|
1008
753
|
const statusBlock = statusOut;
|
|
1009
|
-
const statusUnchanged = this.rendered &&
|
|
754
|
+
const statusUnchanged = this.rendered &&
|
|
755
|
+
!this._resizeRepin &&
|
|
756
|
+
statusBlock === this._lastStatusBlock;
|
|
1010
757
|
if (statusUnchanged) {
|
|
1011
758
|
// Count the real input rows via layout (wrapping aware).
|
|
1012
759
|
const layOnly = layoutInput(this.promptStr, this.buf, this.cursor, cols);
|
|
@@ -1216,7 +963,7 @@ export class LineEditor {
|
|
|
1216
963
|
this.setStatus(theme.prompt('✎ ') + this.pendingText + this._hint());
|
|
1217
964
|
return;
|
|
1218
965
|
}
|
|
1219
|
-
this._startAnimated(randomThinkingPhrase());
|
|
966
|
+
this._startAnimated(randomThinkingPhrase(this.locale));
|
|
1220
967
|
}
|
|
1221
968
|
// Animated status: a brown base text plus a growing "running" dot sequence.
|
|
1222
969
|
// Shared by the thinking spinner and the send-pause indicator, so the pause
|