@vincemakes/kiso-code 0.1.13 → 0.1.15

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/dist/dock.js CHANGED
@@ -1,18 +1,23 @@
1
1
  /**
2
- * v2b — the bottom-anchored UI (TTY + color only). Borrows pi-tui's IDEA —
3
- * a DECSTBM scroll region with a reserved bottom — without its
2
+ * v2b/v2c — the bottom-anchored UI (TTY + color only). Borrows pi-tui's
3
+ * IDEA — a DECSTBM scroll region with a reserved bottom — without its
4
4
  * implementation: zero dependencies, line-level ANSI, no differential
5
5
  * renderer.
6
6
  *
7
7
  * Layout (H = terminal height): rows 1..H-3 = the scroll region (the body
8
8
  * streams and scrolls here, never touching the bottom), row H-2 = the dim
9
- * separator, row H-1 = the live status bar (or a takeover question), row
10
- * H = the input line (blue you> + readline). Bottom redraws are wrapped in
11
- * CSI 2026 (synchronized output) to avoid flicker the pi trick.
9
+ * dotted separator (╌), row H-1 = the live status bar (or a takeover
10
+ * question), row H = the input line (the blue brick ▌you> + the v2c
11
+ * editor's row readline is gone from the TTY path). Bottom redraws are
12
+ * wrapped in CSI 2026 (synchronized output) to avoid flicker — the pi
13
+ * trick. The visual identity is the kiso brick motif — ▌ half-block,
14
+ * dotted separator — deliberately NOT the CC rounded frame nor the pi
15
+ * editor (ADR-0039 Amendment 2).
12
16
  *
13
17
  * Pipes / NO_COLOR: the dock never activates; the v2a line mode stays
14
18
  * byte-for-byte (the existing e2e assertions guard it).
15
19
  */
20
+ import { displayWidth } from "./editor.js";
16
21
  import { palette } from "./render.js";
17
22
  export class Dock {
18
23
  #active = false;
@@ -83,9 +88,16 @@ export class Dock {
83
88
  }
84
89
  /** Body output: position the cursor inside the scroll region at the
85
90
  * body's tracked position, write, and hand the cursor back to the
86
- * input line. The row/col tracking is approximate for width-wrapped
87
- * and wide-char lines (documented) — the region clamp keeps the
88
- * bottom rows safe regardless. */
91
+ * input line's EDIT position. The row/col tracking is approximate for
92
+ * width-wrapped and wide-char lines (documented) — the region clamp
93
+ * keeps the bottom rows safe regardless.
94
+ *
95
+ * The edit-position return is a correctness requirement, not
96
+ * cosmetics: readline tracks its cursor internally and NEVER
97
+ * re-syncs after an external move — a body write that left the
98
+ * cursor at column 1 made the next keystroke overwrite the prompt
99
+ * (probe-confirmed; the dock's redraw self-repaired ~200ms later,
100
+ * which read the user as cursor drift). */
89
101
  writeBody(text) {
90
102
  if (!this.#active) {
91
103
  process.stdout.write(text);
@@ -104,7 +116,21 @@ export class Dock {
104
116
  this.#bodyCol += 1;
105
117
  }
106
118
  }
107
- process.stdout.write(`\x1b[${this.#height};1H`); // back to the input line
119
+ process.stdout.write(`\x1b[${this.#height};${this.#inputCol()}H`); // back to the edit position
120
+ }
121
+ /** The input line's edit column — prompt width + cursor + 1. The
122
+ * dock's redraw and the body's cursor return both end here, so the
123
+ * ACTUAL cursor always equals what the editor tracks. The width is
124
+ * DISPLAY width (the editor's cursor column is already width-based —
125
+ * the CJK drift root cause, editor.ts). v2d: public — the Body's
126
+ * render loop ends at this column. */
127
+ editCol() {
128
+ return this.#inputCol();
129
+ }
130
+ #inputCol() {
131
+ const inp = this.#inputState();
132
+ const promptWidth = displayWidth(this.#inputPrompt.replace(/\x1b\[[0-9;]*m/g, ""));
133
+ return promptWidth + inp.cursor + 1;
108
134
  }
109
135
  /** The status bar's base text (usage, ctx, session, …). */
110
136
  setStatus(text) {
@@ -128,25 +154,30 @@ export class Dock {
128
154
  }
129
155
  /** The bottom three rows, wrapped in CSI 2026 (synchronized output —
130
156
  * the pi trick against flicker). The cursor ends at the input line's
131
- * edit position. */
157
+ * edit position. v2c: the separator is the dim dotted ╌ (a weaker
158
+ * presence than the solid ─), the status line is dim (blue accents
159
+ * inside come from the CLI's composition), the input row is the blue
160
+ * brick ▌you> + the editor's visible slice. */
132
161
  redraw() {
133
162
  if (!this.#active)
134
163
  return;
135
164
  const p = palette();
136
165
  const H = this.#height;
137
166
  const W = this.#width;
138
- const sep = `${p.dim}${"".repeat(W)}${p.reset}`;
167
+ const sep = `${p.dim}${"".repeat(W)}${p.reset}`;
139
168
  const status = `${this.#status}${this.#tail === "" ? "" : ` · ${this.#tail}`}`;
140
- const statusLine = this.#question ?? status;
169
+ const statusLine = this.#question ?? `${p.dim}${status}${p.reset}`;
141
170
  const inp = this.#inputState();
142
- const promptWidth = this.#inputPrompt.replace(/\x1b\[[0-9;]*m/g, "").length;
143
171
  const out = [];
144
- out.push("\x1b[2026h"); // synchronized output ON
172
+ // P3 (审查): the DEC private-mode SET/RESET needs the "?" prefix —
173
+ // \x1b[?2026h/l, the pi source's exact form. Without it terminals
174
+ // silently ignore the mode and the anti-flicker never engages.
175
+ out.push("\x1b[?2026h"); // synchronized output ON (DEC 2026)
145
176
  out.push(`\x1b[${H - 2};1H\x1b[0K${sep}`);
146
177
  out.push(`\x1b[${H - 1};1H\x1b[0K${statusLine}`);
147
178
  out.push(`\x1b[${H};1H\x1b[0K${this.#inputPrompt}${inp.line}`);
148
- out.push(`\x1b[${H};${promptWidth + inp.cursor + 1}H`); // back to the edit position
149
- out.push("\x1b[2026l"); // synchronized output OFF
179
+ out.push(`\x1b[${H};${this.#inputCol()}H`); // back to the edit position
180
+ out.push("\x1b[?2026l"); // synchronized output OFF
150
181
  process.stdout.write(out.join(""));
151
182
  }
152
183
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * v2c — the raw-mode single-line editor that REPLACES readline on the TTY
3
+ * path. Root cause of the v2b drift (plan 2026-08-05-tui-v2b §11): readline
4
+ * re-renders its line by CHARACTER count and assumes it owns the row
5
+ * exclusively — a CJK wide character (2 cells) shifts every following
6
+ * column, and the dock's redraws make the mismatch permanent. Patching
7
+ * readline is a dead end; the TTY path draws its own input row instead.
8
+ * Non-TTY paths keep readline untouched (pipe bytes unchanged).
9
+ *
10
+ * Zero dependencies. The eastAsianWidth table is a ~40-line subset (CJK
11
+ * ideographs/kana/hangul/fullwidth/common wide symbols = 2, everything
12
+ * else = 1). Known limitation, documented in the README: emoji ZWJ
13
+ * clusters (family emoji etc.) are not guaranteed perfect — each code
14
+ * point counts as its width.
15
+ *
16
+ * The editor is a SINGLE line: bracketed paste (?2004h) unwraps and
17
+ * inserts, internal newlines become spaces.
18
+ */
19
+ /** A code point's display width: 2 for the wide ranges, 1 otherwise. */
20
+ export declare function charWidth(cp: number): number;
21
+ /** Display width of a code-point array (cursor math, scrolling). */
22
+ export declare function widthOf(chars: readonly number[]): number;
23
+ /** Display width of a string. */
24
+ export declare function displayWidth(text: string): number;
25
+ export declare const PROMPT = "\u258Cyou> ";
26
+ export declare const PROMPT_WIDTH: number;
27
+ /**
28
+ * The editor. Raw mode + bracketed paste (?2004h) on enter, restored on
29
+ * exit. The input row is rendered by `onRender` (the CLI wires it to the
30
+ * dock's redraw when docked, the editor's own self-render otherwise) —
31
+ * the editor itself never writes while docked. The event handlers are
32
+ * settable — the chat/resume contexts wire them after construction.
33
+ */
34
+ export declare class Editor {
35
+ #private;
36
+ readonly closed: Promise<void>;
37
+ constructor(onRender: () => void);
38
+ onLine(cb: (line: string) => void): void;
39
+ onSigint(cb: () => void): void;
40
+ onEot(cb: () => void): void;
41
+ onEscape(cb: () => void): void;
42
+ /** The whole buffer as text (the CLI's line()/clearLine()). */
43
+ line(): string;
44
+ clearLine(): void;
45
+ /** The visible slice (dim "…" prefix when scrolled) + the cursor's
46
+ * display column within it — the dock's input-row state. */
47
+ dockState(): {
48
+ line: string;
49
+ cursor: number;
50
+ };
51
+ /** One-shot question mode: the NEXT submit answers, not a turn. */
52
+ question(_query: string, cb: (answer: string) => void): void;
53
+ /** Cancel a pending question — the buffer stays (its text becomes the
54
+ * next turn on Enter, the readline re-emit equivalent). */
55
+ cancelQuestion(): void;
56
+ enter(): void;
57
+ exit(): void;
58
+ /** The row's own render when the dock is inactive (a TTY without a
59
+ * real size): \r + clear + blue brick prompt + visible + cursor
60
+ * column. */
61
+ selfRender(): void;
62
+ /** Feed raw stdin bytes — the parser. Public for unit tests. */
63
+ feed(raw: Uint8Array): void;
64
+ }
package/dist/editor.js ADDED
@@ -0,0 +1,378 @@
1
+ /**
2
+ * v2c — the raw-mode single-line editor that REPLACES readline on the TTY
3
+ * path. Root cause of the v2b drift (plan 2026-08-05-tui-v2b §11): readline
4
+ * re-renders its line by CHARACTER count and assumes it owns the row
5
+ * exclusively — a CJK wide character (2 cells) shifts every following
6
+ * column, and the dock's redraws make the mismatch permanent. Patching
7
+ * readline is a dead end; the TTY path draws its own input row instead.
8
+ * Non-TTY paths keep readline untouched (pipe bytes unchanged).
9
+ *
10
+ * Zero dependencies. The eastAsianWidth table is a ~40-line subset (CJK
11
+ * ideographs/kana/hangul/fullwidth/common wide symbols = 2, everything
12
+ * else = 1). Known limitation, documented in the README: emoji ZWJ
13
+ * clusters (family emoji etc.) are not guaranteed perfect — each code
14
+ * point counts as its width.
15
+ *
16
+ * The editor is a SINGLE line: bracketed paste (?2004h) unwraps and
17
+ * inserts, internal newlines become spaces.
18
+ */
19
+ /** A code point's display width: 2 for the wide ranges, 1 otherwise. */
20
+ export function charWidth(cp) {
21
+ if (cp >= 0x1100 && cp <= 0x115f)
22
+ return 2; // hangul jamo
23
+ if (cp >= 0x2e80 && cp <= 0x303e)
24
+ return 2; // radicals .. CJK punctuation
25
+ if (cp >= 0x3041 && cp <= 0x33ff)
26
+ return 2; // kana, CJK compat
27
+ if (cp >= 0x3400 && cp <= 0x4dbf)
28
+ return 2; // CJK ext A
29
+ if (cp >= 0x4e00 && cp <= 0x9fff)
30
+ return 2; // CJK unified
31
+ if (cp >= 0xa000 && cp <= 0xa4cf)
32
+ return 2; // yi
33
+ if (cp >= 0xa960 && cp <= 0xa97f)
34
+ return 2; // hangul jamo ext
35
+ if (cp >= 0xac00 && cp <= 0xd7a3)
36
+ return 2; // hangul syllables
37
+ if (cp >= 0xf900 && cp <= 0xfaff)
38
+ return 2; // CJK compat ideographs
39
+ if (cp >= 0xfe10 && cp <= 0xfe19)
40
+ return 2; // vertical forms
41
+ if (cp >= 0xfe30 && cp <= 0xfe6f)
42
+ return 2; // CJK compat forms
43
+ if (cp >= 0xff00 && cp <= 0xff60)
44
+ return 2; // fullwidth forms
45
+ if (cp >= 0xffe0 && cp <= 0xffe6)
46
+ return 2; // fullwidth signs
47
+ if (cp >= 0x1f300 && cp <= 0x1f64f)
48
+ return 2; // emoji (misc + emoticons)
49
+ if (cp >= 0x1f900 && cp <= 0x1f9ff)
50
+ return 2; // supplemental emoji
51
+ if (cp >= 0x20000 && cp <= 0x3fffd)
52
+ return 2; // CJK ext B..G
53
+ return 1;
54
+ }
55
+ /** Display width of a code-point array (cursor math, scrolling). */
56
+ export function widthOf(chars) {
57
+ let w = 0;
58
+ for (const cp of chars)
59
+ w += charWidth(cp);
60
+ return w;
61
+ }
62
+ /** Display width of a string. */
63
+ export function displayWidth(text) {
64
+ let w = 0;
65
+ for (const ch of text)
66
+ w += charWidth(ch.codePointAt(0));
67
+ return w;
68
+ }
69
+ import { palette } from "./render.js";
70
+ export const PROMPT = "▌you> "; // the kiso brick motif: one blue half-block, then you>
71
+ export const PROMPT_WIDTH = displayWidth(PROMPT);
72
+ /**
73
+ * The editor. Raw mode + bracketed paste (?2004h) on enter, restored on
74
+ * exit. The input row is rendered by `onRender` (the CLI wires it to the
75
+ * dock's redraw when docked, the editor's own self-render otherwise) —
76
+ * the editor itself never writes while docked. The event handlers are
77
+ * settable — the chat/resume contexts wire them after construction.
78
+ */
79
+ export class Editor {
80
+ #chars = [];
81
+ #cursor = 0;
82
+ #scroll = 0; // chars scrolled off the left (width-based reflow)
83
+ #questionCb = null;
84
+ #pasting = false;
85
+ #lineCb = null;
86
+ #pendingLines = []; // submits before onLine is wired (startup) — never dropped
87
+ #sigintCb = null;
88
+ #eotCb = null;
89
+ #escapeCb = null;
90
+ #onRender;
91
+ #pending = ""; // an incomplete ESC/CSI prefix across chunks
92
+ #decoder = new TextDecoder();
93
+ #entered = false;
94
+ #onData;
95
+ #closedResolve;
96
+ closed;
97
+ constructor(onRender) {
98
+ this.#onRender = onRender;
99
+ this.#onData = (raw) => this.feed(raw);
100
+ this.closed = new Promise((resolve) => {
101
+ this.#closedResolve = resolve;
102
+ });
103
+ }
104
+ onLine(cb) {
105
+ this.#lineCb = cb;
106
+ // Flush submits that arrived before the handler was wired (typed
107
+ // during startup) — readline buffered these; the editor must too.
108
+ for (const line of this.#pendingLines)
109
+ cb(line);
110
+ this.#pendingLines.length = 0;
111
+ }
112
+ onSigint(cb) {
113
+ this.#sigintCb = cb;
114
+ }
115
+ onEot(cb) {
116
+ this.#eotCb = cb;
117
+ }
118
+ onEscape(cb) {
119
+ this.#escapeCb = cb;
120
+ }
121
+ /** The whole buffer as text (the CLI's line()/clearLine()). */
122
+ line() {
123
+ return String.fromCodePoint(...this.#chars);
124
+ }
125
+ clearLine() {
126
+ this.#chars = [];
127
+ this.#cursor = 0;
128
+ this.#scroll = 0;
129
+ this.#onRender();
130
+ }
131
+ /** The visible slice (dim "…" prefix when scrolled) + the cursor's
132
+ * display column within it — the dock's input-row state. */
133
+ dockState() {
134
+ const visible = String.fromCodePoint(...this.#chars.slice(this.#scroll));
135
+ const prefix = this.#scroll > 0 ? "\x1b[2m…\x1b[0m" : "";
136
+ const col = (this.#scroll > 0 ? 1 : 0) + widthOf(this.#chars.slice(this.#scroll, this.#cursor));
137
+ return { line: `${prefix}${visible}`, cursor: col };
138
+ }
139
+ /** One-shot question mode: the NEXT submit answers, not a turn. */
140
+ question(_query, cb) {
141
+ this.#questionCb = cb;
142
+ }
143
+ /** Cancel a pending question — the buffer stays (its text becomes the
144
+ * next turn on Enter, the readline re-emit equivalent). */
145
+ cancelQuestion() {
146
+ this.#questionCb = null;
147
+ }
148
+ enter() {
149
+ if (this.#entered)
150
+ return;
151
+ this.#entered = true;
152
+ process.stdin.setRawMode(true);
153
+ process.stdout.write("\x1b[?2004h"); // bracketed paste ON
154
+ process.stdin.on("data", this.#onData);
155
+ this.#onRender();
156
+ }
157
+ exit() {
158
+ if (!this.#entered)
159
+ return;
160
+ this.#entered = false;
161
+ process.stdin.off("data", this.#onData);
162
+ process.stdout.write("\x1b[?2004l"); // bracketed paste OFF
163
+ process.stdin.setRawMode(false);
164
+ this.#closedResolve();
165
+ }
166
+ /** The row's own render when the dock is inactive (a TTY without a
167
+ * real size): \r + clear + blue brick prompt + visible + cursor
168
+ * column. */
169
+ selfRender() {
170
+ const p = palette();
171
+ const st = this.dockState();
172
+ const W = (process.stdout.columns ?? 0) || 80; // a degenerate 0 size (no TIOCSWINSZ) falls back
173
+ const cursorCol = Math.min(1 + PROMPT_WIDTH + st.cursor, W);
174
+ process.stdout.write(`\r\x1b[0K${p.blue}${PROMPT}${p.reset}${st.line}\x1b[${cursorCol}G`);
175
+ }
176
+ // ---- input ----
177
+ /** Feed raw stdin bytes — the parser. Public for unit tests. */
178
+ feed(raw) {
179
+ const text = this.#pending + this.#decoder.decode(raw, { stream: true });
180
+ this.#pending = "";
181
+ let i = 0;
182
+ while (i < text.length) {
183
+ const c = text[i];
184
+ if (c === "\x1b") {
185
+ const rest = text.slice(i + 1);
186
+ if (rest.startsWith("[")) {
187
+ const m = rest.match(/^\[([0-9;?]*)([A-Za-z~])/);
188
+ if (m === null) {
189
+ this.#pending = text.slice(i); // incomplete CSI — wait for more
190
+ break;
191
+ }
192
+ this.#csi(m[1], m[2]);
193
+ i += m[0].length + 1;
194
+ }
195
+ else if (rest.startsWith("O")) {
196
+ i += 3; // SS3 (function keys) — ignored
197
+ }
198
+ else {
199
+ this.#escapeCb?.();
200
+ i += 1;
201
+ }
202
+ }
203
+ else if (c === "\x0d" || c === "\x0a") {
204
+ if (this.#pasting) {
205
+ this.#insert(0x20); // single-line editor: newlines become spaces
206
+ }
207
+ else {
208
+ this.#submit();
209
+ }
210
+ i += 1;
211
+ }
212
+ else if (c === "\x7f" || c === "\x08") {
213
+ this.#backspace();
214
+ i += 1;
215
+ }
216
+ else if (c === "\x03") {
217
+ this.#sigintCb?.();
218
+ i += 1;
219
+ }
220
+ else if (c === "\x04") {
221
+ this.#eotCb?.();
222
+ i += 1;
223
+ }
224
+ else if (c === "\x15") {
225
+ this.#killToStart();
226
+ i += 1;
227
+ }
228
+ else if (c === "\x0b") {
229
+ this.#killToEnd();
230
+ i += 1;
231
+ }
232
+ else if (c === "\x17") {
233
+ this.#killWord();
234
+ i += 1;
235
+ }
236
+ else if (c === "\x01") {
237
+ this.#cursor = 0;
238
+ this.#reflow();
239
+ this.#onRender();
240
+ i += 1;
241
+ }
242
+ else if (c === "\x05") {
243
+ this.#cursor = this.#chars.length;
244
+ this.#reflow();
245
+ this.#onRender();
246
+ i += 1;
247
+ }
248
+ else if (c !== undefined && c < " ") {
249
+ i += 1; // other control — ignored
250
+ }
251
+ else {
252
+ this.#insert(text.codePointAt(i));
253
+ i += c.length;
254
+ }
255
+ }
256
+ }
257
+ #csi(params, final) {
258
+ if (final === "~") {
259
+ const n = Number(params);
260
+ if (n === 3)
261
+ this.#delete();
262
+ else if (n === 200)
263
+ this.#pasting = true;
264
+ else if (n === 201) {
265
+ this.#pasting = false;
266
+ this.#onRender();
267
+ }
268
+ }
269
+ else if (final === "D") {
270
+ this.#move(-1);
271
+ }
272
+ else if (final === "C") {
273
+ this.#move(1);
274
+ }
275
+ else if (final === "H") {
276
+ this.#cursor = 0;
277
+ this.#reflow();
278
+ }
279
+ else if (final === "F") {
280
+ this.#cursor = this.#chars.length;
281
+ this.#reflow();
282
+ }
283
+ }
284
+ // ---- editing ----
285
+ #insert(cp) {
286
+ this.#chars.splice(this.#cursor, 0, cp);
287
+ this.#cursor += 1;
288
+ this.#reflow();
289
+ if (!this.#pasting)
290
+ this.#onRender();
291
+ }
292
+ #backspace() {
293
+ if (this.#cursor === 0)
294
+ return;
295
+ this.#chars.splice(this.#cursor - 1, 1);
296
+ this.#cursor -= 1;
297
+ this.#reflow();
298
+ if (!this.#pasting)
299
+ this.#onRender();
300
+ }
301
+ #delete() {
302
+ if (this.#cursor >= this.#chars.length)
303
+ return;
304
+ this.#chars.splice(this.#cursor, 1);
305
+ this.#reflow();
306
+ if (!this.#pasting)
307
+ this.#onRender();
308
+ }
309
+ #move(delta) {
310
+ this.#cursor = Math.max(0, Math.min(this.#chars.length, this.#cursor + delta));
311
+ this.#reflow();
312
+ if (!this.#pasting)
313
+ this.#onRender();
314
+ }
315
+ #killToStart() {
316
+ this.#chars.splice(0, this.#cursor);
317
+ this.#cursor = 0;
318
+ this.#reflow();
319
+ if (!this.#pasting)
320
+ this.#onRender();
321
+ }
322
+ #killToEnd() {
323
+ this.#chars.length = this.#cursor;
324
+ this.#reflow();
325
+ if (!this.#pasting)
326
+ this.#onRender();
327
+ }
328
+ #killWord() {
329
+ let i = this.#cursor;
330
+ while (i > 0 && this.#chars[i - 1] === 0x20)
331
+ i -= 1; // trailing spaces
332
+ while (i > 0 && this.#chars[i - 1] !== 0x20)
333
+ i -= 1; // the word
334
+ this.#chars.splice(i, this.#cursor - i);
335
+ this.#cursor = i;
336
+ this.#reflow();
337
+ }
338
+ #submit() {
339
+ const line = String.fromCodePoint(...this.#chars);
340
+ this.#chars = [];
341
+ this.#cursor = 0;
342
+ this.#scroll = 0;
343
+ const cb = this.#questionCb;
344
+ this.#questionCb = null;
345
+ if (cb !== null) {
346
+ cb(line);
347
+ }
348
+ else if (this.#lineCb !== null) {
349
+ this.#lineCb(line);
350
+ }
351
+ else {
352
+ this.#pendingLines.push(line); // nobody wired yet — hold it
353
+ }
354
+ this.#onRender();
355
+ }
356
+ // ---- width-based horizontal scroll ----
357
+ #reflow() {
358
+ const W = (process.stdout.columns ?? 0) || 80; // degenerate 0 falls back to 80
359
+ const maxW = Math.max(1, W - PROMPT_WIDTH - 1); // 1 col for the "…"
360
+ const curCol = widthOf(this.#chars.slice(0, this.#cursor));
361
+ const scrolledW = widthOf(this.#chars.slice(0, this.#scroll));
362
+ if (curCol < scrolledW) {
363
+ this.#scroll = this.#indexAtWidth(curCol);
364
+ }
365
+ else if (curCol >= scrolledW + maxW) {
366
+ this.#scroll = this.#indexAtWidth(Math.max(0, curCol - maxW + 1));
367
+ }
368
+ }
369
+ #indexAtWidth(target) {
370
+ let w = 0;
371
+ for (let i = 0; i < this.#chars.length; i += 1) {
372
+ if (w >= target)
373
+ return i;
374
+ w += charWidth(this.#chars[i]);
375
+ }
376
+ return this.#chars.length;
377
+ }
378
+ }