zames_pro 2.64.1 → 2.65.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.
@@ -0,0 +1,314 @@
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
+ // Truncate a PLAIN (no-ANSI) string to at most `maxCols` visible columns,
154
+ // appending an ellipsis when it was cut. Used for one-line previews (tool call
155
+ // args, tool results) so a long argument cannot print a line wider than the
156
+ // terminal — which made some terminals show a horizontal scrollbar.
157
+ export function truncateToWidth(s, maxCols) {
158
+ const width = Math.max(1, Math.floor(maxCols));
159
+ const text = String(s ?? '');
160
+ if (visLen(text) <= width)
161
+ return text;
162
+ // Reserve one column for the ellipsis.
163
+ const budget = Math.max(0, width - 1);
164
+ let used = 0;
165
+ let out = '';
166
+ for (const ch of text) {
167
+ const w = charWidth(ch.codePointAt(0));
168
+ if (used + w > budget)
169
+ break;
170
+ out += ch;
171
+ used += w;
172
+ }
173
+ return out + '…';
174
+ }
175
+ export function layoutInput(promptStr, buf, cursor, cols) {
176
+ // Reserve the LAST column. If a printed row reaches exactly `cols` columns,
177
+ // terminals with autowrap (Tabby, iTerm, Windows Terminal, xterm's default
178
+ // deferred wrap) either wrap the cursor to the next line or leave it in the
179
+ // last cell with a "pending wrap". In both cases the editor's row count
180
+ // disagrees with the real screen by one, so the cursor/erase arithmetic in
181
+ // `_renderInputOnly`/`_writeBlock` walks one row too far and the input line
182
+ // climbs. This was most visible in an EMPTY chat with a single long,
183
+ // space-less line (which fills the row exactly) and when moving the cursor
184
+ // with the arrow keys.
185
+ const width = Math.max(2, (Number(cols) || 80) - 1);
186
+ const promptW = visLen(promptStr);
187
+ const pad = ' '.repeat(promptW);
188
+ const chars = Array.from(String(buf));
189
+ const cur = Math.max(0, Math.min(Number(cursor) || 0, chars.length));
190
+ const rows = [];
191
+ let i = 0;
192
+ while (true) {
193
+ const first = rows.length === 0;
194
+ const prefix = first ? promptStr : pad;
195
+ const avail = Math.max(1, width - promptW);
196
+ const start = i;
197
+ // Build the next visual line. First we take as many
198
+ // characters as fit (columns < avail). If we hit the width
199
+ // and the next character is not the end of the line, we roll back to the
200
+ // last space so we don't split a word in the middle (word-wrap).
201
+ // Wrapping and the cursor column are measured in COLUMNS (charWidth),
202
+ // not code points — otherwise a CJK/emoji input would overflow the row
203
+ // and put the cursor on the wrong cell.
204
+ let text = '';
205
+ let widthUsed = 0;
206
+ let charIdx = 0;
207
+ let lastSpace = -1; // char index of the space within text
208
+ while (i < chars.length && chars[i] !== NL) {
209
+ const w = charWidth(chars[i].codePointAt(0));
210
+ if (widthUsed + w > avail)
211
+ break;
212
+ if (chars[i] === ' ')
213
+ lastSpace = charIdx;
214
+ text += chars[i];
215
+ i++;
216
+ charIdx++;
217
+ widthUsed += w;
218
+ }
219
+ const atLineEnd = i >= chars.length || chars[i] === NL;
220
+ if (!atLineEnd && widthUsed >= avail && lastSpace > 0) {
221
+ // Move the "tail" of the line to the next visual line.
222
+ const textChars = Array.from(text);
223
+ const tailLen = textChars.length - lastSpace;
224
+ i -= tailLen;
225
+ text = textChars.slice(0, lastSpace).join('');
226
+ }
227
+ if (i < chars.length && chars[i] === NL) {
228
+ i++;
229
+ rows.push({ prefix, text, start });
230
+ continue;
231
+ }
232
+ rows.push({ prefix, text, start });
233
+ if (i >= chars.length)
234
+ break;
235
+ }
236
+ let cursorRow = 0;
237
+ let cursorCol = promptW;
238
+ for (let r = 0; r < rows.length; r++) {
239
+ const row = rows[r];
240
+ const rowChars = Array.from(row.text);
241
+ const len = rowChars.length;
242
+ const from = row.start;
243
+ const to = row.start + len;
244
+ if (cur >= from && cur <= to) {
245
+ const next = rows[r + 1];
246
+ if (cur === to && next && next.start === to)
247
+ continue;
248
+ cursorRow = r;
249
+ // Cursor column in COLUMNS (wide chars count as 2).
250
+ let w = 0;
251
+ for (let k = 0; k < cur - from; k++) {
252
+ w += charWidth(rowChars[k].codePointAt(0));
253
+ }
254
+ cursorCol = promptW + w;
255
+ break;
256
+ }
257
+ }
258
+ return { rows, cursorRow, cursorCol };
259
+ }
260
+ // Format a token count for the status line: compact (10k, 125k) plus the
261
+ // percentage of CONTEXT_LIMIT. Exported so it is unit-tested without a live
262
+ // editor. A null/undefined/NaN count renders an empty string (no status).
263
+ export const CONTEXT_LIMIT = 1_000_000;
264
+ // Thresholds for coloring the context counter. Below YELLOW the value is
265
+ // green (plenty of room), between YELLOW and RED it is yellow (getting full),
266
+ // above RED it is red (nearly exhausted). Percent of CONTEXT_LIMIT.
267
+ export const CONTEXT_YELLOW_PCT = 50;
268
+ export const CONTEXT_RED_PCT = 80;
269
+ // The color role for a context fill level, as a stable string so it can be
270
+ // unit-tested without a terminal: 'ok' | 'warn' | 'high'. Null tokens -> null.
271
+ export function tokenStatusLevel(tokens, limit = CONTEXT_LIMIT) {
272
+ if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens < 0) {
273
+ return null;
274
+ }
275
+ const pct = (tokens / limit) * 100;
276
+ if (pct >= CONTEXT_RED_PCT)
277
+ return 'high';
278
+ if (pct >= CONTEXT_YELLOW_PCT)
279
+ return 'warn';
280
+ return 'ok';
281
+ }
282
+ export function formatTokenStatus(tokens, limit = CONTEXT_LIMIT) {
283
+ if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens < 0) {
284
+ return '';
285
+ }
286
+ const n = Math.round(tokens);
287
+ const compact = formatCompactTokens(n);
288
+ const pct = Math.max(0, (n / limit) * 100);
289
+ const pctStr = pct >= 10 ? String(Math.round(pct)) : pct.toFixed(1);
290
+ // "ctx:" marks this as the USED context size, so the operator does not have
291
+ // to guess what "302k · 30%" means.
292
+ return 'ctx: ' + compact + ' · ' + pctStr + '%';
293
+ }
294
+ // Compact token count (10k, 125k, 1.5M). Exported so the per-task summary
295
+ // reuses the EXACT same "k/M" formatting as the context counter. Invalid
296
+ // input (null/NaN/negative) renders an empty string.
297
+ export function formatCompactTokens(tokens) {
298
+ if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens < 0) {
299
+ return '';
300
+ }
301
+ const n = Math.round(tokens);
302
+ if (n >= 1_000_000) {
303
+ const m = n / 1_000_000;
304
+ return (Number.isInteger(m) ? String(m) : m.toFixed(1)) + 'M';
305
+ }
306
+ if (n >= 1_000) {
307
+ const k = n / 1_000;
308
+ return ((k >= 100 ? String(Math.round(k)) : k.toFixed(1).replace(/\.0$/, '')) +
309
+ 'k');
310
+ }
311
+ return String(n);
312
+ }
313
+ // Re-exported for the editor's layout selftest (node src/input.js --selftest).
314
+ export { NL, CR, ESC, PASTE_START, PASTE_END, safeJson };