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.
@@ -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
- // `_eraseBlock` erases from the top of the block DOWN (CR + ESC[J),
482
- // so everything above it is preserved; then _writeBlock redraws the
483
- // block at the new width. A slight leftover is possible at extreme
484
- // width changes, but losing the history is far worse.
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 && statusBlock === this._lastStatusBlock;
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