@vincemakes/kiso-tui 0.31.1 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/compositor.d.ts +17 -0
- package/dist/compositor.js +58 -6
- package/dist/editor.d.ts +34 -0
- package/dist/editor.js +359 -77
- package/dist/panel-input.d.ts +4 -3
- package/package.json +2 -2
package/dist/compositor.d.ts
CHANGED
|
@@ -284,8 +284,25 @@ export declare class Body {
|
|
|
284
284
|
* reaching into a card whose content is still arriving.
|
|
285
285
|
*/
|
|
286
286
|
toggleExpanded(): void;
|
|
287
|
+
/** §2.4 — the terminal holds someone else's drawing (an external
|
|
288
|
+
* editor had it) and the model is the only authority on what should
|
|
289
|
+
* be there. The same act as a resize; the same path. */
|
|
290
|
+
reprint(): void;
|
|
287
291
|
/** DC-50 — the switch itself, for the CLI's affordance text. */
|
|
288
292
|
expandedAll(): boolean;
|
|
293
|
+
/** §2.3 — ctrl+t: the committed thinking blocks fold, and fold back.
|
|
294
|
+
*
|
|
295
|
+
* DC-50's mechanism exactly: one boolean, then the session is printed
|
|
296
|
+
* again, so the blocks ALREADY on screen obey the switch rather than
|
|
297
|
+
* only the next ones. Nothing durable moves — the events are
|
|
298
|
+
* untouched and `/think` still reaches the last block whole.
|
|
299
|
+
*
|
|
300
|
+
* COMMITTED blocks only. The live `thinking…` placeholder belongs to
|
|
301
|
+
* the live region, not to a cell, and an OPEN thinking cell is text
|
|
302
|
+
* still arriving; a global "quiet down" has no business reaching into
|
|
303
|
+
* either, which is the same exemption `toggleExpanded` states for a
|
|
304
|
+
* card that is still growing. */
|
|
305
|
+
toggleThinking(): void;
|
|
289
306
|
/** W18: the status row's right-aligned hint is part of the status
|
|
290
307
|
* state — the compacting row passes "esc to cancel" (the affordance
|
|
291
308
|
* must survive repaints). */
|
package/dist/compositor.js
CHANGED
|
@@ -57,6 +57,7 @@ import { atPanelRows, bandHeader } from "./at-picker.js";
|
|
|
57
57
|
import { sessionPickerRows } from "./session-picker.js";
|
|
58
58
|
import { CAP_PREVIEW, thinkingRow, Container, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, gutterCut, cutLine, pendingQueueRows, statusLine, visibleWidth, breathFrame, } from "./components.js";
|
|
59
59
|
import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary } from "./lines.js";
|
|
60
|
+
import { oneRow } from "@vincemakes/kiso-tui-cells/render";
|
|
60
61
|
import { displayVerb, keysSheetRows } from "./strings.js";
|
|
61
62
|
/** DC-56: does a rendered row carry any visible text once its SGR is
|
|
62
63
|
* stripped? A washed pad row is spaces under a background colour — width
|
|
@@ -202,6 +203,9 @@ export class Body {
|
|
|
202
203
|
#lastTool = null;
|
|
203
204
|
#pendingCalls = new Map();
|
|
204
205
|
#pipeBuf = ""; // the passthrough's thinking buffer
|
|
206
|
+
// §2.3 — the ctrl+t switch. A block that completes AFTER the press
|
|
207
|
+
// inherits it, so the session stays one way up rather than mixing.
|
|
208
|
+
#thinkingFolded = false;
|
|
205
209
|
/** TUI2-MD ⑤ — the markdown scanner of the message currently
|
|
206
210
|
* streaming, and the cell index its first block landed at. Null
|
|
207
211
|
* between messages: the scanner's life is one assistant message. */
|
|
@@ -390,6 +394,9 @@ export class Body {
|
|
|
390
394
|
const last = this.#cells[this.#cells.length - 1];
|
|
391
395
|
if (last !== undefined && last.kind === "thinking" && !last.done) {
|
|
392
396
|
last.done = true;
|
|
397
|
+
// §2.3: a block that SETTLES after the switch was thrown is
|
|
398
|
+
// folded like the rest — the session stays one way up.
|
|
399
|
+
last.folded = this.#thinkingFolded;
|
|
393
400
|
this.#lastThinking = last.text;
|
|
394
401
|
if (!this.#isActive())
|
|
395
402
|
this.#write(foldThinking(last.text));
|
|
@@ -1303,11 +1310,12 @@ export class Body {
|
|
|
1303
1310
|
/**
|
|
1304
1311
|
* R14 — ERASE THE TERMINAL AND PRINT THE SESSION AGAIN.
|
|
1305
1312
|
*
|
|
1306
|
-
*
|
|
1307
|
-
*
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1313
|
+
* Four callers now: a settled resize, DC-50's ctrl+o, §2.3's ctrl+t,
|
|
1314
|
+
* and §2.4's return from an external editor. They are the same act —
|
|
1315
|
+
* the rendering the terminal holds is wrong (wrong geometry, wrong
|
|
1316
|
+
* expansion state, wrong fold, or another program drew over it) and
|
|
1317
|
+
* the model is the only authority on what it should be — so they
|
|
1318
|
+
* share the path rather than growing four.
|
|
1311
1319
|
*/
|
|
1312
1320
|
#reprint() {
|
|
1313
1321
|
const H = this.#opts.height();
|
|
@@ -1367,10 +1375,36 @@ export class Body {
|
|
|
1367
1375
|
}
|
|
1368
1376
|
this.#reprint();
|
|
1369
1377
|
}
|
|
1378
|
+
/** §2.4 — the terminal holds someone else's drawing (an external
|
|
1379
|
+
* editor had it) and the model is the only authority on what should
|
|
1380
|
+
* be there. The same act as a resize; the same path. */
|
|
1381
|
+
reprint() {
|
|
1382
|
+
this.#reprint();
|
|
1383
|
+
}
|
|
1370
1384
|
/** DC-50 — the switch itself, for the CLI's affordance text. */
|
|
1371
1385
|
expandedAll() {
|
|
1372
1386
|
return this.#expandedAll;
|
|
1373
1387
|
}
|
|
1388
|
+
/** §2.3 — ctrl+t: the committed thinking blocks fold, and fold back.
|
|
1389
|
+
*
|
|
1390
|
+
* DC-50's mechanism exactly: one boolean, then the session is printed
|
|
1391
|
+
* again, so the blocks ALREADY on screen obey the switch rather than
|
|
1392
|
+
* only the next ones. Nothing durable moves — the events are
|
|
1393
|
+
* untouched and `/think` still reaches the last block whole.
|
|
1394
|
+
*
|
|
1395
|
+
* COMMITTED blocks only. The live `thinking…` placeholder belongs to
|
|
1396
|
+
* the live region, not to a cell, and an OPEN thinking cell is text
|
|
1397
|
+
* still arriving; a global "quiet down" has no business reaching into
|
|
1398
|
+
* either, which is the same exemption `toggleExpanded` states for a
|
|
1399
|
+
* card that is still growing. */
|
|
1400
|
+
toggleThinking() {
|
|
1401
|
+
this.#thinkingFolded = !this.#thinkingFolded;
|
|
1402
|
+
for (const cell of this.#cells) {
|
|
1403
|
+
if (cell.kind === "thinking" && cell.done)
|
|
1404
|
+
cell.folded = this.#thinkingFolded;
|
|
1405
|
+
}
|
|
1406
|
+
this.#reprint();
|
|
1407
|
+
}
|
|
1374
1408
|
/** W18: the status row's right-aligned hint is part of the status
|
|
1375
1409
|
* state — the compacting row passes "esc to cancel" (the affordance
|
|
1376
1410
|
* must survive repaints). */
|
|
@@ -2605,6 +2639,8 @@ export class Body {
|
|
|
2605
2639
|
* CRASH with the diagnostic, never a silent truncate. */
|
|
2606
2640
|
/** DC-48 — the field cut is announced ONCE per session. */
|
|
2607
2641
|
#cutOnce = false;
|
|
2642
|
+
/** HF-1: the ①b notice, once per session (the same discipline as #cutOnce). */
|
|
2643
|
+
#breakOnce = false;
|
|
2608
2644
|
#checked(line, W) {
|
|
2609
2645
|
// Invariant ①b (R3f): a ROW IS ONE PHYSICAL ROW.
|
|
2610
2646
|
//
|
|
@@ -2630,7 +2666,23 @@ export class Body {
|
|
|
2630
2666
|
// (it moves the cursor to column 1).
|
|
2631
2667
|
const bad = /[\n\r]/.exec(line);
|
|
2632
2668
|
if (bad !== null) {
|
|
2633
|
-
|
|
2669
|
+
// HF-1 (0.32.1): the SAME ruling as the width half below. This
|
|
2670
|
+
// threw in the field on a heredoc shell command (the running
|
|
2671
|
+
// card's head carried the model's newlines) and the owner's
|
|
2672
|
+
// session died with it. Under test the crash keeps its teeth;
|
|
2673
|
+
// in the field the break is projected to ⏎ — the row goes out
|
|
2674
|
+
// as one row — and a notice says so once. The builders project
|
|
2675
|
+
// first (tui-cells `oneRow`), so this branch is the net under
|
|
2676
|
+
// the net: a builder nobody has found yet costs a mark, not a
|
|
2677
|
+
// session.
|
|
2678
|
+
const why = `kiso-tui invariant ①b violated: a row containing ${JSON.stringify(bad[0])} was about to be emitted — a row must be ONE physical row, and the width check cannot see this (charWidth counts a newline as one cell) — ${JSON.stringify(line.slice(0, 80))}`;
|
|
2679
|
+
if (process.env.KISO_INVARIANTS === "throw")
|
|
2680
|
+
throw new Error(why);
|
|
2681
|
+
if (!this.#breakOnce) {
|
|
2682
|
+
this.#breakOnce = true;
|
|
2683
|
+
queueMicrotask(() => this.notice("kiso-tui: a row carried a line break and was shown as one row (invariant ①b) · please report"));
|
|
2684
|
+
}
|
|
2685
|
+
line = oneRow(line);
|
|
2634
2686
|
}
|
|
2635
2687
|
const w = visibleWidth(line);
|
|
2636
2688
|
if (w > W) {
|
package/dist/editor.d.ts
CHANGED
|
@@ -86,6 +86,26 @@ export declare class Editor {
|
|
|
86
86
|
/** E1 §3 — the copy key (ctrl+x). */
|
|
87
87
|
onCopy(cb: () => void): void;
|
|
88
88
|
onExpand(cb: () => void): void;
|
|
89
|
+
/** §2.3: the thinking key (ctrl+t) — the chain-level action, wired
|
|
90
|
+
* exactly like ctrl+o because it is the same kind of thing: a switch
|
|
91
|
+
* the compositor throws, never an interpretation the editor makes. */
|
|
92
|
+
onThink(cb: () => void): void;
|
|
93
|
+
/** §2.4: the external-editor key (ctrl+g). */
|
|
94
|
+
onEditor(cb: () => void): void;
|
|
95
|
+
/** §2.4 — hand the terminal to an external program, and take it back.
|
|
96
|
+
*
|
|
97
|
+
* `run` receives the composer's text and returns what should replace
|
|
98
|
+
* it, or null to leave it alone (no editor configured, the editor
|
|
99
|
+
* failed, nothing changed). The SPAWN is the caller's: this package is
|
|
100
|
+
* pure terminal — input is data, output is bytes, zero runtime deps —
|
|
101
|
+
* so what lives here is the handover and nothing else.
|
|
102
|
+
*
|
|
103
|
+
* NOT `exit()` / `enter()`. Those are the session's boundary: exit()
|
|
104
|
+
* also resolves the closed promise, which tells the layer above that
|
|
105
|
+
* the session is over. Suspending is a different act with the same
|
|
106
|
+
* terminal moves, and conflating them would end the session every time
|
|
107
|
+
* someone opened their editor. */
|
|
108
|
+
externalEdit(run: (text: string) => string | null): void;
|
|
89
109
|
/** KC2 §2: the redirect chain — the gesture hands the buffer's text
|
|
90
110
|
* over while the run is told to stop. Mirrors onEscape (a list, so
|
|
91
111
|
* listeners can coexist); the line arrives already gone from the
|
|
@@ -168,6 +188,20 @@ export declare class Editor {
|
|
|
168
188
|
panelState(): PanelState | null;
|
|
169
189
|
enter(): void;
|
|
170
190
|
exit(): void;
|
|
191
|
+
/** OR-11 (a) — the lead the composer's row is DRAWN with, which is not
|
|
192
|
+
* always the brick. The CLI binds the compositor's input lead as `""`
|
|
193
|
+
* (a prompt character is a third thing saying "input lives here", and
|
|
194
|
+
* it cost the row a column), while the editor measured against PROMPT
|
|
195
|
+
* — so the editor believed the row two columns narrower than the
|
|
196
|
+
* compositor drew it, and W23's "the two width authorities can never
|
|
197
|
+
* disagree" was off by two.
|
|
198
|
+
*
|
|
199
|
+
* A PROVIDER, not a string, because both renderers are live in the
|
|
200
|
+
* same process: the dock draws the row when it is active and
|
|
201
|
+
* `selfRender` draws it when it is not, and they lead it differently.
|
|
202
|
+
* Whoever binds the row answers for whichever is drawing. The default
|
|
203
|
+
* is the brick, which is what `selfRender` has always drawn. */
|
|
204
|
+
setInputLead(lead: () => string): void;
|
|
171
205
|
/** The row's own render when the dock is inactive (a TTY without a
|
|
172
206
|
* real size): \r + clear + blue brick prompt + visible + cursor
|
|
173
207
|
* column. */
|
package/dist/editor.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* source funnels through the ONE normalizer in feed() (§3).
|
|
23
23
|
*/
|
|
24
24
|
var _a;
|
|
25
|
-
import { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
|
|
25
|
+
import { breakable, charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
|
|
26
26
|
// the width primitives moved to width.ts (W1, the single width
|
|
27
27
|
// authority) — re-exported so the editor's public surface is unchanged.
|
|
28
28
|
export { charWidth, displayWidth, widthOf };
|
|
@@ -146,8 +146,16 @@ const N_MAX = 6;
|
|
|
146
146
|
* payload for someone else, and holding it is how the editor goes
|
|
147
147
|
* deaf. */
|
|
148
148
|
const OSC_MAX = 1024;
|
|
149
|
-
/**
|
|
150
|
-
*
|
|
149
|
+
/** TMUX-F1 ①: a CSI parameter string longer than this with no final byte
|
|
150
|
+
* is not a sequence any terminal sends; it is dropped rather than held, so
|
|
151
|
+
* a runaway can never park the editor. */
|
|
152
|
+
const CSI_MAX = 64;
|
|
153
|
+
/** TMUX-F1 ②: identical arrow sequences in ONE read at or past this count
|
|
154
|
+
* are a wheel notch, not a hand — treated as one press. */
|
|
155
|
+
const ARROW_BURST = 3;
|
|
156
|
+
/** The dim "…" — the ONE truncation mark. OR-11 retired its other use
|
|
157
|
+
* (the horizontal scroll's prefix) with the scroll itself; what is left
|
|
158
|
+
* is the viewport's hidden-rows markers, above and below. */
|
|
151
159
|
/** R3: built per call from the palette — `dim` is an absolute grey once
|
|
152
160
|
* the ground is known, so a frozen SGR 2 here would be the one span
|
|
153
161
|
* that ignores it. */
|
|
@@ -198,11 +206,10 @@ const shown = (chars) => {
|
|
|
198
206
|
export class Editor {
|
|
199
207
|
#chars = [];
|
|
200
208
|
#cursor = 0;
|
|
201
|
-
#scroll = 0; // chars scrolled off the left of the CURSOR'S LINE (width-based reflow; KC1: line-local, so a single-line buffer is unchanged)
|
|
202
209
|
// KC1 §2 — the ONE new ephemeral field: the desired column for the
|
|
203
210
|
// ↑/↓ walk (a long line's column 20 → a short line clamps to 5 → the
|
|
204
211
|
// next long line RETURNS to 20). Set on the first vertical move,
|
|
205
|
-
// kept across consecutive ones, reset by any
|
|
212
|
+
// kept across consecutive ones, reset by any left/right move, insert
|
|
206
213
|
// or delete. Never stashed — it is a walk's state, not the buffer's.
|
|
207
214
|
#verticalGoalCol = null;
|
|
208
215
|
#questionCb = null;
|
|
@@ -322,6 +329,8 @@ export class Editor {
|
|
|
322
329
|
// expanded block). Mirrors the escape list: multiple listeners can
|
|
323
330
|
// coexist; the editor never interprets the key itself.
|
|
324
331
|
#expandCbs = [];
|
|
332
|
+
#thinkCbs = [];
|
|
333
|
+
#editorCbs = [];
|
|
325
334
|
/** E1 §3 — ctrl+x. Same shape as the expand key: the editor owns the
|
|
326
335
|
* KEY, the CLI owns what it means. */
|
|
327
336
|
#copyCbs = [];
|
|
@@ -455,6 +464,57 @@ export class Editor {
|
|
|
455
464
|
onExpand(cb) {
|
|
456
465
|
this.#expandCbs.push(cb);
|
|
457
466
|
}
|
|
467
|
+
/** §2.3: the thinking key (ctrl+t) — the chain-level action, wired
|
|
468
|
+
* exactly like ctrl+o because it is the same kind of thing: a switch
|
|
469
|
+
* the compositor throws, never an interpretation the editor makes. */
|
|
470
|
+
onThink(cb) {
|
|
471
|
+
this.#thinkCbs.push(cb);
|
|
472
|
+
}
|
|
473
|
+
/** §2.4: the external-editor key (ctrl+g). */
|
|
474
|
+
onEditor(cb) {
|
|
475
|
+
this.#editorCbs.push(cb);
|
|
476
|
+
}
|
|
477
|
+
/** §2.4 — hand the terminal to an external program, and take it back.
|
|
478
|
+
*
|
|
479
|
+
* `run` receives the composer's text and returns what should replace
|
|
480
|
+
* it, or null to leave it alone (no editor configured, the editor
|
|
481
|
+
* failed, nothing changed). The SPAWN is the caller's: this package is
|
|
482
|
+
* pure terminal — input is data, output is bytes, zero runtime deps —
|
|
483
|
+
* so what lives here is the handover and nothing else.
|
|
484
|
+
*
|
|
485
|
+
* NOT `exit()` / `enter()`. Those are the session's boundary: exit()
|
|
486
|
+
* also resolves the closed promise, which tells the layer above that
|
|
487
|
+
* the session is over. Suspending is a different act with the same
|
|
488
|
+
* terminal moves, and conflating them would end the session every time
|
|
489
|
+
* someone opened their editor. */
|
|
490
|
+
externalEdit(run) {
|
|
491
|
+
const before = this.line();
|
|
492
|
+
let next = null;
|
|
493
|
+
process.stdin.off("data", this.#onData);
|
|
494
|
+
process.stdout.write(MOUSE_OFF);
|
|
495
|
+
process.stdout.write("\x1b[?2004l"); // bracketed paste OFF — the child's, not ours
|
|
496
|
+
process.stdout.write("\x1b[?25h"); // and it needs a cursor to draw
|
|
497
|
+
process.stdin.setRawMode(false);
|
|
498
|
+
try {
|
|
499
|
+
next = run(before);
|
|
500
|
+
}
|
|
501
|
+
finally {
|
|
502
|
+
// the handover comes back whatever the child did, including
|
|
503
|
+
// throwing: a terminal left in the child's mode is unusable and
|
|
504
|
+
// the human has no way to ask for it back.
|
|
505
|
+
process.stdin.setRawMode(true);
|
|
506
|
+
process.stdout.write(MOUSE_OFF);
|
|
507
|
+
this.#mouseOn = false;
|
|
508
|
+
process.stdout.write("\x1b[?2004h");
|
|
509
|
+
process.stdin.on("data", this.#onData);
|
|
510
|
+
}
|
|
511
|
+
if (next !== null && next !== before) {
|
|
512
|
+
this.#chars = [...next].map((ch) => ch.codePointAt(0));
|
|
513
|
+
this.#cursor = this.#chars.length;
|
|
514
|
+
this.#reflow();
|
|
515
|
+
}
|
|
516
|
+
this.#onRender();
|
|
517
|
+
}
|
|
458
518
|
/** KC2 §2: the redirect chain — the gesture hands the buffer's text
|
|
459
519
|
* over while the run is told to stop. Mirrors onEscape (a list, so
|
|
460
520
|
* listeners can coexist); the line arrives already gone from the
|
|
@@ -509,7 +569,6 @@ export class Editor {
|
|
|
509
569
|
this.#redoStack.length = 0;
|
|
510
570
|
this.#chars = [];
|
|
511
571
|
this.#cursor = 0;
|
|
512
|
-
this.#scroll = 0;
|
|
513
572
|
this.#verticalGoalCol = null;
|
|
514
573
|
this.#onRender();
|
|
515
574
|
}
|
|
@@ -539,12 +598,129 @@ export class Editor {
|
|
|
539
598
|
}
|
|
540
599
|
return bounds.length - 1;
|
|
541
600
|
}
|
|
542
|
-
/** The cursor's OWN line — the unit of the
|
|
543
|
-
* the
|
|
601
|
+
/** The cursor's OWN LOGICAL line — the unit of the line-local A/E/U/K
|
|
602
|
+
* (A3) and of the `@` token scan. The visual rows a long line folds
|
|
603
|
+
* into are `#visualRows`; these two answer different questions and
|
|
604
|
+
* OR-11 kept them apart deliberately. */
|
|
544
605
|
#cursorBounds() {
|
|
545
606
|
const bounds = this.#lineBounds();
|
|
546
607
|
return bounds[this.#cursorLine(bounds)];
|
|
547
608
|
}
|
|
609
|
+
/** OR-11 — the row's width budget: the SAME number the compositor
|
|
610
|
+
* gives the row (W23's one formula), against the lead that is
|
|
611
|
+
* actually drawn. The fold and the cursor math both ask here. */
|
|
612
|
+
#budget() {
|
|
613
|
+
const W = (process.stdout.columns ?? 0) || 80;
|
|
614
|
+
const ps = this.#panelInput.state();
|
|
615
|
+
const lead = ps !== null ? panelLead(ps.view, ps.phase, ps.cursor, ps.ask) : this.#inputLead();
|
|
616
|
+
return Math.max(1, W - leadWidth(lead) - 1);
|
|
617
|
+
}
|
|
618
|
+
/** The `[Pasted text #N +M lines]` tokens inside [start, end).
|
|
619
|
+
* A capsule is ONE thing on screen and one thing to the cursor, so a
|
|
620
|
+
* fold never lands inside it: it is cut when drawn if it is wider
|
|
621
|
+
* than the row, and it is never split into two. */
|
|
622
|
+
#capsules(start, end) {
|
|
623
|
+
const text = String.fromCodePoint(...this.#chars.slice(start, end));
|
|
624
|
+
const units = [];
|
|
625
|
+
// the regex indexes UTF-16 units; the buffer is code points
|
|
626
|
+
let cp = start;
|
|
627
|
+
for (const ch of text) {
|
|
628
|
+
units.push(cp);
|
|
629
|
+
cp += 1;
|
|
630
|
+
if (ch.length === 2)
|
|
631
|
+
units.push(cp - 1); // a surrogate pair is one code point
|
|
632
|
+
}
|
|
633
|
+
units.push(end);
|
|
634
|
+
const out = [];
|
|
635
|
+
for (const m of text.matchAll(_a.#CAPSULE)) {
|
|
636
|
+
const i = m.index ?? 0;
|
|
637
|
+
out.push({ start: units[i] ?? start, end: units[i + m[0].length] ?? end });
|
|
638
|
+
}
|
|
639
|
+
return out;
|
|
640
|
+
}
|
|
641
|
+
/** OR-11 — one logical line's VISUAL rows, as [start, end) into
|
|
642
|
+
* #chars. The rows TILE the line exactly: no character is dropped and
|
|
643
|
+
* none is shown twice, which is what lets the cursor map back.
|
|
644
|
+
*
|
|
645
|
+
* The break, in order:
|
|
646
|
+
* 1. everything fits — one row;
|
|
647
|
+
* 2. the cut falls on a CJK boundary (either side breakable) — take
|
|
648
|
+
* it, because looking further back for a space would leave the
|
|
649
|
+
* row half empty for text that breaks anywhere;
|
|
650
|
+
* 3. otherwise the last whitespace RUN that fits: the whole run ends
|
|
651
|
+
* the row it is on, so a continuation row never begins with a
|
|
652
|
+
* space — unless the run itself straddles the budget, where ①
|
|
653
|
+
* wins and the leftover spaces open the next row (OR11-F1);
|
|
654
|
+
* 4. otherwise a hard break at the last code point that fits — never
|
|
655
|
+
* inside a wide character, because `#indexAtWidth` is that walk. */
|
|
656
|
+
#foldLine(start, end, budget) {
|
|
657
|
+
const rows = [];
|
|
658
|
+
const atoms = this.#capsules(start, end);
|
|
659
|
+
let from = start;
|
|
660
|
+
while (from < end) {
|
|
661
|
+
const fits = this.#fitsWithin(from, end, budget);
|
|
662
|
+
if (fits >= end)
|
|
663
|
+
break;
|
|
664
|
+
let cut = fits;
|
|
665
|
+
const atom = atoms.find((a) => a.start < cut && cut < a.end);
|
|
666
|
+
if (atom !== undefined) {
|
|
667
|
+
// the capsule keeps its own row: start one before it when
|
|
668
|
+
// there is text ahead of it, otherwise let it run whole.
|
|
669
|
+
cut = atom.start > from ? atom.start : atom.end;
|
|
670
|
+
}
|
|
671
|
+
else if (!breakable(this.#chars[fits] ?? 0) && !breakable(this.#chars[fits - 1] ?? 0)) {
|
|
672
|
+
let w = -1;
|
|
673
|
+
for (let i = cut - 1; i > from; i -= 1) {
|
|
674
|
+
if (this.#chars[i] === SPACE) {
|
|
675
|
+
w = i;
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (w >= 0) {
|
|
680
|
+
while (this.#chars[w + 1] === SPACE && w + 1 < end)
|
|
681
|
+
w += 1;
|
|
682
|
+
// OR11-F1: capped at `fits`. Ending the row with the WHOLE
|
|
683
|
+
// run is what keeps a continuation row from opening with a
|
|
684
|
+
// space — but that is a preference, and invariant ① is
|
|
685
|
+
// not: every row kiso produces measures ≤ W, because
|
|
686
|
+
// autowrap is off and the terminal will not save it. A run
|
|
687
|
+
// STRADDLING the boundary was carrying the row past the
|
|
688
|
+
// budget (34 letters + 12 spaces + 20 letters at width 40
|
|
689
|
+
// measured 46 against 39). When the run itself does not
|
|
690
|
+
// fit, the row stops at the budget and the leftover spaces
|
|
691
|
+
// open the next row.
|
|
692
|
+
cut = Math.min(w + 1, fits);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (cut <= from || cut >= end)
|
|
696
|
+
break; // no progress, or the rest fits
|
|
697
|
+
rows.push({ start: from, end: cut });
|
|
698
|
+
from = cut;
|
|
699
|
+
}
|
|
700
|
+
rows.push({ start: from, end });
|
|
701
|
+
return rows;
|
|
702
|
+
}
|
|
703
|
+
/** OR-11 — every VISUAL row of the buffer, in order. ONE fold, built
|
|
704
|
+
* once per read; `dockState`, the cursor mapping and the ↑/↓ walk all
|
|
705
|
+
* READ this table rather than folding again. */
|
|
706
|
+
#visualRows() {
|
|
707
|
+
const budget = this.#budget();
|
|
708
|
+
const out = [];
|
|
709
|
+
for (const b of this.#lineBounds())
|
|
710
|
+
out.push(...this.#foldLine(b.start, b.end, budget));
|
|
711
|
+
return out;
|
|
712
|
+
}
|
|
713
|
+
/** The visual row the cursor is on: the LAST row it can belong to. At
|
|
714
|
+
* a fold boundary that is the row the next character would go on; at
|
|
715
|
+
* a newline it is the row the newline closes, never the next line. */
|
|
716
|
+
#cursorVisualRow(rows) {
|
|
717
|
+
let hit = 0;
|
|
718
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
719
|
+
if (rows[i].start <= this.#cursor && this.#cursor <= rows[i].end)
|
|
720
|
+
hit = i;
|
|
721
|
+
}
|
|
722
|
+
return hit;
|
|
723
|
+
}
|
|
548
724
|
/** KC1 §5 — N_visible = min(lineCount, N_MAX, max(1, H − 3 − the
|
|
549
725
|
* menu/queue bands)). The height clamp guarantees legal geometry
|
|
550
726
|
* down to the compositor's enter gate; the compositor re-applies the
|
|
@@ -568,27 +744,27 @@ export class Editor {
|
|
|
568
744
|
* stash / restore / clear / submit path has new state to carry. A
|
|
569
745
|
* dim "…" marks whichever edge hides rows. */
|
|
570
746
|
dockState() {
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
747
|
+
// OR-11: VISUAL rows. One logical line may be several of them, and
|
|
748
|
+
// the window, the markers and the cursor all count them the same
|
|
749
|
+
// way — N_MAX has always been about how tall the box may get, and
|
|
750
|
+
// a folded line is as tall as a pasted one.
|
|
751
|
+
const rows = this.#visualRows();
|
|
752
|
+
const cursorLine = this.#cursorVisualRow(rows);
|
|
753
|
+
const n = this.#visibleRows(rows.length);
|
|
754
|
+
const first = Math.max(0, Math.min(cursorLine - n + 1, rows.length - n));
|
|
575
755
|
const lines = [];
|
|
576
756
|
for (let i = first; i < first + n; i += 1) {
|
|
577
|
-
const b =
|
|
578
|
-
// the cursor's own row carries the horizontal scroll (and its
|
|
579
|
-
// "…"); the other rows render whole and cap at the frame's wall
|
|
580
|
-
const from = i === cursorLine ? b.start + this.#scroll : b.start;
|
|
581
|
-
const scrolled = i === cursorLine && this.#scroll > 0 ? ellipsis() : "";
|
|
757
|
+
const b = rows[i];
|
|
582
758
|
const above = i === first && first > 0 ? ellipsis() : "";
|
|
583
|
-
const below = i === first + n - 1 && first + n <
|
|
584
|
-
lines.push(`${above}${
|
|
759
|
+
const below = i === first + n - 1 && first + n < rows.length ? ellipsis() : "";
|
|
760
|
+
lines.push(`${above}${shown(this.#chars.slice(b.start, b.end))}${below}`);
|
|
585
761
|
}
|
|
586
762
|
const cursorRow = cursorLine - first;
|
|
587
763
|
// the window trails the cursor, so the hidden-above marker can only
|
|
588
764
|
// share the cursor's row in the degenerate one-row window (a tiny
|
|
589
|
-
// terminal) — where it shifts the column
|
|
590
|
-
const marks =
|
|
591
|
-
const cursorCol = marks + widthOf(this.#chars.slice(
|
|
765
|
+
// terminal) — where it shifts the column by its one cell
|
|
766
|
+
const marks = cursorRow === 0 && first > 0 ? 1 : 0;
|
|
767
|
+
const cursorCol = marks + widthOf(this.#chars.slice(rows[cursorLine].start, this.#cursor));
|
|
592
768
|
return { line: lines[cursorRow], cursor: cursorCol, lines, cursorRow, cursorCol };
|
|
593
769
|
}
|
|
594
770
|
/** v3 §04: the menu's visible state for the dock — null when closed. */
|
|
@@ -852,6 +1028,24 @@ export class Editor {
|
|
|
852
1028
|
#syncMouse() {
|
|
853
1029
|
this.#setMouse(this.#panelInput.up() || this.#pickInput.up() || this.#atUp());
|
|
854
1030
|
}
|
|
1031
|
+
/** OR-11 (a) — the lead the composer's row is DRAWN with, which is not
|
|
1032
|
+
* always the brick. The CLI binds the compositor's input lead as `""`
|
|
1033
|
+
* (a prompt character is a third thing saying "input lives here", and
|
|
1034
|
+
* it cost the row a column), while the editor measured against PROMPT
|
|
1035
|
+
* — so the editor believed the row two columns narrower than the
|
|
1036
|
+
* compositor drew it, and W23's "the two width authorities can never
|
|
1037
|
+
* disagree" was off by two.
|
|
1038
|
+
*
|
|
1039
|
+
* A PROVIDER, not a string, because both renderers are live in the
|
|
1040
|
+
* same process: the dock draws the row when it is active and
|
|
1041
|
+
* `selfRender` draws it when it is not, and they lead it differently.
|
|
1042
|
+
* Whoever binds the row answers for whichever is drawing. The default
|
|
1043
|
+
* is the brick, which is what `selfRender` has always drawn. */
|
|
1044
|
+
setInputLead(lead) {
|
|
1045
|
+
this.#inputLead = lead;
|
|
1046
|
+
this.#reflow();
|
|
1047
|
+
}
|
|
1048
|
+
#inputLead = () => PROMPT;
|
|
855
1049
|
/** The row's own render when the dock is inactive (a TTY without a
|
|
856
1050
|
* real size): \r + clear + blue brick prompt + visible + cursor
|
|
857
1051
|
* column. */
|
|
@@ -862,7 +1056,7 @@ export class Editor {
|
|
|
862
1056
|
// W21: the panel's lead owns the row while up (the brick returns
|
|
863
1057
|
// when the panel closes).
|
|
864
1058
|
const panel = this.#panelInput.state();
|
|
865
|
-
const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.cursor, panel.ask) : `${p.bold}${
|
|
1059
|
+
const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.cursor, panel.ask) : `${p.bold}${this.#inputLead()}${p.reset}`;
|
|
866
1060
|
// W23: the ONE width authority — leadWidth(lead), the ANSI-stripped
|
|
867
1061
|
// visible width (the styled panel lead / the styled brick measure
|
|
868
1062
|
// the same as their plain text — a lead can never measure
|
|
@@ -952,13 +1146,47 @@ export class Editor {
|
|
|
952
1146
|
// never complete. The editor went deaf. It never happened
|
|
953
1147
|
// because nothing ever enabled reporting — which is exactly
|
|
954
1148
|
// the kind of latent break turning a feature on discovers.
|
|
955
|
-
|
|
1149
|
+
//
|
|
1150
|
+
// TMUX-F1 ①: the whole CSI grammar — parameters (the private
|
|
1151
|
+
// markers `?` `<` `>` `=` included), intermediates (0x20–0x2F),
|
|
1152
|
+
// a final (0x40–0x7E). The class above could not END a sequence
|
|
1153
|
+
// carrying an intermediate — tmux answers DECRQM with
|
|
1154
|
+
// `ESC[?69;0$y` — so it was parked as "incomplete" and every
|
|
1155
|
+
// later keystroke joined a sequence that never completed: the
|
|
1156
|
+
// editor went deaf for the session. A sequence with
|
|
1157
|
+
// intermediates is a reply kiso did not ask for; it is skipped
|
|
1158
|
+
// whole. A parameter string past CSI_MAX with no final is
|
|
1159
|
+
// dropped, not held.
|
|
1160
|
+
const m = rest.match(/^\[([0-9;?<>=]*)([ -/]*)([@-~])/);
|
|
956
1161
|
if (m === null) {
|
|
1162
|
+
const junk = /^\[[0-9;?<>=]*[ -/]*/.exec(rest)[0];
|
|
1163
|
+
if (junk.length > CSI_MAX) {
|
|
1164
|
+
i += 1 + junk.length; // the introducer and its runaway parameters go; parsing continues
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
957
1167
|
this.#pending = text.slice(i); // incomplete CSI — wait for more
|
|
958
1168
|
break;
|
|
959
1169
|
}
|
|
960
|
-
|
|
961
|
-
|
|
1170
|
+
const seqLen = m[0].length + 1;
|
|
1171
|
+
if (m[2] !== "") {
|
|
1172
|
+
i += seqLen; // intermediates: a report kiso did not ask for — skipped whole
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
// TMUX-F1 ②: a BURST of identical arrows in ONE read is a wheel,
|
|
1176
|
+
// not a hand. Apple Terminal turns wheel and trackpad scrolling
|
|
1177
|
+
// into arrow keys for an alternate-screen app (tmux's client is
|
|
1178
|
+
// one) — three or more per notch in a single write, which a
|
|
1179
|
+
// person never produces in one read — and each one walked the
|
|
1180
|
+
// history. The burst is one press (owner ruling 2026-09-10, c);
|
|
1181
|
+
// separate reads stay separate presses.
|
|
1182
|
+
const seq = `\x1b${m[0]}`;
|
|
1183
|
+
let run = 1;
|
|
1184
|
+
if (m[1] === "" && "ABCD".includes(m[3])) {
|
|
1185
|
+
while (text.startsWith(seq, i + run * seq.length))
|
|
1186
|
+
run += 1;
|
|
1187
|
+
}
|
|
1188
|
+
this.#csi(m[1], m[3], run);
|
|
1189
|
+
i += run * seqLen;
|
|
962
1190
|
}
|
|
963
1191
|
else if (rest.startsWith("]")) {
|
|
964
1192
|
// DC-7: an OSC is a message FROM the terminal — a background
|
|
@@ -1041,7 +1269,6 @@ export class Editor {
|
|
|
1041
1269
|
this.#checkpoint(); // UD-1
|
|
1042
1270
|
this.#chars = [];
|
|
1043
1271
|
this.#cursor = 0;
|
|
1044
|
-
this.#scroll = 0;
|
|
1045
1272
|
this.#verticalGoalCol = null;
|
|
1046
1273
|
this.#refreshMenu();
|
|
1047
1274
|
i += 1;
|
|
@@ -1192,7 +1419,16 @@ export class Editor {
|
|
|
1192
1419
|
}
|
|
1193
1420
|
i += 1;
|
|
1194
1421
|
}
|
|
1195
|
-
else if (c === "\x0f") {
|
|
1422
|
+
else if (c === "\x0f" && !this.#pasting) {
|
|
1423
|
+
// DC-58 (0.32.1): this branch and the two below take `!#pasting`,
|
|
1424
|
+
// the guard tab and CR already had — inside a paste a control byte
|
|
1425
|
+
// is content or nothing, never a gesture. Unguarded, a pasted BEL
|
|
1426
|
+
// opened $VISUAL mid-paste and the rest of the body went to that
|
|
1427
|
+
// child's stdin: DC-7's rule from the third side (a byte from a
|
|
1428
|
+
// paste is data the human handed over, as a reply's byte is the
|
|
1429
|
+
// terminal's). Unclaimed control bytes were discarded already;
|
|
1430
|
+
// now the claimed ones fall to the same discard while pasting.
|
|
1431
|
+
//
|
|
1196
1432
|
// W15: the expand key — rides the chain like a command, the
|
|
1197
1433
|
// editor just forwards it.
|
|
1198
1434
|
//
|
|
@@ -1209,6 +1445,33 @@ export class Editor {
|
|
|
1209
1445
|
cb();
|
|
1210
1446
|
i += 1;
|
|
1211
1447
|
}
|
|
1448
|
+
else if (c === "\x14" && !this.#pasting) {
|
|
1449
|
+
// §2.3 — ctrl+t folds the committed thinking blocks, and
|
|
1450
|
+
// folds them back. `\x14` was unbound across the tree
|
|
1451
|
+
// (checked before the round), and it is the key the
|
|
1452
|
+
// reference implementation uses for this same gesture, so a
|
|
1453
|
+
// reader arriving from it is not retrained.
|
|
1454
|
+
//
|
|
1455
|
+
// Forwarded, not interpreted: the switch is the
|
|
1456
|
+
// compositor's, exactly as ctrl+o's is.
|
|
1457
|
+
for (const cb of [...this.#thinkCbs])
|
|
1458
|
+
cb();
|
|
1459
|
+
i += 1;
|
|
1460
|
+
}
|
|
1461
|
+
else if (c === "\x07" && !this.#pasting) {
|
|
1462
|
+
// §2.4 — ctrl+g opens $VISUAL / $EDITOR on the composer.
|
|
1463
|
+
//
|
|
1464
|
+
// 0x07 is BEL, which is also the terminator a terminal puts
|
|
1465
|
+
// on an OSC reply (DC-7). This branch is only reached by a
|
|
1466
|
+
// BARE 0x07: the OSC arm above consumes its own terminator,
|
|
1467
|
+
// so the terminal's answer never arrives here. One byte, two
|
|
1468
|
+
// meanings, told apart by what precedes it — and there is a
|
|
1469
|
+
// gate for exactly that, because "should not reach here" is
|
|
1470
|
+
// not something to take on trust.
|
|
1471
|
+
for (const cb of [...this.#editorCbs])
|
|
1472
|
+
cb();
|
|
1473
|
+
i += 1;
|
|
1474
|
+
}
|
|
1212
1475
|
else if (c === "\x18" && this.#composerIdle()) {
|
|
1213
1476
|
// E1 §3 — ctrl+x copies the last answer. `\x18` was unbound
|
|
1214
1477
|
// across the whole tree (checked before the round started),
|
|
@@ -1323,7 +1586,22 @@ export class Editor {
|
|
|
1323
1586
|
bindPanelRows(fn) {
|
|
1324
1587
|
this.#panelRows = fn;
|
|
1325
1588
|
}
|
|
1326
|
-
|
|
1589
|
+
/** TMUX-F1 ②: `run` identical arrow sequences arrived in ONE read. Every
|
|
1590
|
+
* surface but one gets every press — a wheel over the transcript viewer,
|
|
1591
|
+
* a panel, a multi-row draft scrolls, as a wheel should. The HISTORY
|
|
1592
|
+
* walk is the one that replaces the draft's content, and there a burst
|
|
1593
|
+
* (ARROW_BURST or more in one read — a wheel notch, never a hand) is one
|
|
1594
|
+
* step. The first press decides which branch it was, so the gate is not
|
|
1595
|
+
* written twice. */
|
|
1596
|
+
#csi(params, final, run = 1) {
|
|
1597
|
+
const took = this.#csiOnce(params, final);
|
|
1598
|
+
if (took === "history" && run >= ARROW_BURST)
|
|
1599
|
+
return;
|
|
1600
|
+
for (let k = 1; k < run; k += 1)
|
|
1601
|
+
this.#csiOnce(params, final);
|
|
1602
|
+
}
|
|
1603
|
+
#csiOnce(params, final) {
|
|
1604
|
+
let took;
|
|
1327
1605
|
// TUI2-R3v2 ②: an SGR 1006 report — `\x1b[<b;col;rowM` (press) or
|
|
1328
1606
|
// `...m` (release). It is routed FIRST because a `<` parameter is
|
|
1329
1607
|
// never anything else, and because a mouse byte must never fall
|
|
@@ -1434,11 +1712,18 @@ export class Editor {
|
|
|
1434
1712
|
const view = this.#atView();
|
|
1435
1713
|
this.#atSel = final === "A" ? Math.max(0, view.selected - 1) : Math.min(view.matches.length - 1, view.selected + 1);
|
|
1436
1714
|
}
|
|
1437
|
-
else if (this.#
|
|
1438
|
-
// KC1 §4
|
|
1439
|
-
//
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1715
|
+
else if (this.#visualRows().length > 1) {
|
|
1716
|
+
// KC1 §4, restated by OR-11 over VISUAL rows: a buffer that
|
|
1717
|
+
// occupies more than one row has its ↑↓ walk them. It used
|
|
1718
|
+
// to read `#chars.includes(NEWLINE)`, which was the same
|
|
1719
|
+
// question while one logical line was always one row; a
|
|
1720
|
+
// folded line is several now, and the walk is about what the
|
|
1721
|
+
// eye sees.
|
|
1722
|
+
//
|
|
1723
|
+
// The history and the queue-pop below stay gated on an EMPTY
|
|
1724
|
+
// buffer, and an empty buffer is exactly one row — so the
|
|
1725
|
+
// precedence can only ever add, never take, which is what
|
|
1726
|
+
// T-E4 pins.
|
|
1442
1727
|
this.#verticalMove(final === "A" ? -1 : 1);
|
|
1443
1728
|
}
|
|
1444
1729
|
else if (final === "A" && this.#queuePop !== null && (this.#queuePopMode || this.line() === "") && this.#queueState().length > 0) {
|
|
@@ -1452,6 +1737,7 @@ export class Editor {
|
|
|
1452
1737
|
}
|
|
1453
1738
|
else if (this.#historyIdx !== null || this.line() === "") {
|
|
1454
1739
|
this.#historyMove(final === "A" ? -1 : 1);
|
|
1740
|
+
took = "history"; // the repaint below is still owed — an early return here left the recall unpainted (r3a red)
|
|
1455
1741
|
}
|
|
1456
1742
|
this.#onRender();
|
|
1457
1743
|
}
|
|
@@ -1468,22 +1754,31 @@ export class Editor {
|
|
|
1468
1754
|
this.#move(1);
|
|
1469
1755
|
}
|
|
1470
1756
|
else if (final === "H") {
|
|
1757
|
+
// OR-11 (b): and REPAINT. Ctrl+A and Ctrl+E render; their arrow
|
|
1758
|
+
// spellings reflowed and stopped there, so the caret stayed
|
|
1759
|
+
// where it had been until some later key happened to draw. A
|
|
1760
|
+
// gesture that moves the cursor is a gesture that shows it.
|
|
1471
1761
|
this.#cursor = this.#cursorBounds().start; // A3: Home follows Ctrl+A — line-local
|
|
1472
1762
|
this.#reflow();
|
|
1763
|
+
this.#onRender();
|
|
1473
1764
|
}
|
|
1474
1765
|
else if (final === "F") {
|
|
1475
1766
|
this.#cursor = this.#cursorBounds().end; // A3: End follows Ctrl+E — line-local
|
|
1476
1767
|
this.#reflow();
|
|
1768
|
+
this.#onRender();
|
|
1477
1769
|
}
|
|
1770
|
+
return took;
|
|
1478
1771
|
}
|
|
1479
1772
|
/** KC1 §4 — the ↑/↓ walk. The cursor keeps its DESIRED column across
|
|
1480
1773
|
* a short line: the goal is captured at the FIRST vertical move and
|
|
1481
|
-
* survives consecutive ones (#reflow clears it, so any
|
|
1774
|
+
* survives consecutive ones (#reflow clears it, so any left/right
|
|
1482
1775
|
* move / insert / delete ends the walk); a step past either end
|
|
1483
1776
|
* stays put. */
|
|
1484
1777
|
#verticalMove(delta) {
|
|
1485
|
-
|
|
1486
|
-
|
|
1778
|
+
// OR-11: VISUAL rows — one long logical line is several of them, and
|
|
1779
|
+
// ↑/↓ walk what the eye sees. The goal column is unchanged.
|
|
1780
|
+
const bounds = this.#visualRows();
|
|
1781
|
+
const cur = this.#cursorVisualRow(bounds);
|
|
1487
1782
|
const next = cur + delta;
|
|
1488
1783
|
if (next < 0 || next >= bounds.length)
|
|
1489
1784
|
return;
|
|
@@ -1508,16 +1803,14 @@ export class Editor {
|
|
|
1508
1803
|
clear: () => {
|
|
1509
1804
|
this.#chars = [];
|
|
1510
1805
|
this.#cursor = 0;
|
|
1511
|
-
this.#scroll = 0;
|
|
1512
1806
|
this.#verticalGoalCol = null;
|
|
1513
1807
|
},
|
|
1514
1808
|
insert: (cp) => this.#insert(cp),
|
|
1515
1809
|
newline: () => this.#insert(NEWLINE),
|
|
1516
|
-
stash: () => ({ chars: this.#chars, cursor: this.#cursor
|
|
1810
|
+
stash: () => ({ chars: this.#chars, cursor: this.#cursor }),
|
|
1517
1811
|
restore: (st) => {
|
|
1518
1812
|
this.#chars = [...st.chars];
|
|
1519
1813
|
this.#cursor = st.cursor;
|
|
1520
|
-
this.#scroll = st.scroll;
|
|
1521
1814
|
},
|
|
1522
1815
|
reflow: () => this.#reflow(),
|
|
1523
1816
|
render: () => this.#onRender(),
|
|
@@ -1615,7 +1908,6 @@ export class Editor {
|
|
|
1615
1908
|
#restoreSnap(s) {
|
|
1616
1909
|
this.#chars = [...s.chars];
|
|
1617
1910
|
this.#cursor = s.cursor;
|
|
1618
|
-
this.#scroll = 0;
|
|
1619
1911
|
this.#verticalGoalCol = null;
|
|
1620
1912
|
this.#reflow();
|
|
1621
1913
|
this.#refreshMenu();
|
|
@@ -1778,8 +2070,8 @@ export class Editor {
|
|
|
1778
2070
|
this.#cursor = this.#wordEdge(this.#cursor, dir);
|
|
1779
2071
|
this.#reflow();
|
|
1780
2072
|
}
|
|
1781
|
-
/** KC1/KC2 — the buffer LEAVES: the flat chars, the cursor, the
|
|
1782
|
-
*
|
|
2073
|
+
/** KC1/KC2 — the buffer LEAVES: the flat chars, the cursor, the ↑/↓
|
|
2074
|
+
* goal, the menu and the pop-walk all
|
|
1783
2075
|
* reset together (W22: a departing line ends the pop-walk, so the
|
|
1784
2076
|
* next esc at rest interrupts again). Shared by the submit and the
|
|
1785
2077
|
* redirect — the two doors a line can leave by. */
|
|
@@ -1892,7 +2184,6 @@ export class Editor {
|
|
|
1892
2184
|
this.#redoStack.length = 0;
|
|
1893
2185
|
this.#chars = [];
|
|
1894
2186
|
this.#cursor = 0;
|
|
1895
|
-
this.#scroll = 0;
|
|
1896
2187
|
this.#verticalGoalCol = null;
|
|
1897
2188
|
this.#menuOpen = false;
|
|
1898
2189
|
this.#menuSel = 0;
|
|
@@ -2061,50 +2352,41 @@ export class Editor {
|
|
|
2061
2352
|
this.#checkpoint(); // UD-1: a mid-walk edit is recoverable
|
|
2062
2353
|
this.#chars = [...line].map((ch) => ch.codePointAt(0));
|
|
2063
2354
|
this.#cursor = this.#chars.length;
|
|
2064
|
-
this.#scroll = 0;
|
|
2065
2355
|
this.#verticalGoalCol = null;
|
|
2066
2356
|
this.#onRender();
|
|
2067
2357
|
}
|
|
2068
|
-
// ---- width
|
|
2358
|
+
// ---- the width walks ----
|
|
2069
2359
|
#reflow() {
|
|
2070
2360
|
// KC1: any key that reaches the reflow ended a ↑/↓ walk (the walk
|
|
2071
2361
|
// itself re-arms the goal right after its own reflow call).
|
|
2072
2362
|
this.#verticalGoalCol = null;
|
|
2073
|
-
|
|
2074
|
-
//
|
|
2075
|
-
// line
|
|
2076
|
-
//
|
|
2077
|
-
//
|
|
2078
|
-
//
|
|
2079
|
-
// owns the keys, the brick otherwise): maxW = W − walls − lead.
|
|
2080
|
-
const ps = this.#panelInput.state();
|
|
2081
|
-
const lead = ps !== null ? panelLead(ps.view, ps.phase, ps.cursor, ps.ask) : PROMPT;
|
|
2082
|
-
const leadW = leadWidth(lead);
|
|
2083
|
-
// DC-17: ONE column, not four. W6's box took 2+2 and this kept
|
|
2084
|
-
// reserving them after law 1.1 retired it — so the horizontal
|
|
2085
|
-
// scroll fired three columns early, the cursor could never reach
|
|
2086
|
-
// the row's last three cells, and the two width authorities the
|
|
2087
|
-
// W23 contract says must never disagree disagreed by 3. The
|
|
2088
|
-
// compositor's walk caps at W−1 (the drawn cursor's own cell);
|
|
2089
|
-
// this is the same one column, on the same row.
|
|
2090
|
-
const maxW = Math.max(1, W - leadW - 1);
|
|
2091
|
-
// KC1: the scroll is the CURSOR LINE's own offset — a single-line
|
|
2092
|
-
// buffer's line starts at 0, so the math is today's exactly. The
|
|
2093
|
-
// clamp catches a walk onto a line SHORTER than the old offset.
|
|
2094
|
-
const { start, end } = this.#cursorBounds();
|
|
2095
|
-
this.#scroll = Math.min(this.#scroll, end - start);
|
|
2096
|
-
const curCol = widthOf(this.#chars.slice(start, this.#cursor));
|
|
2097
|
-
const scrolledW = widthOf(this.#chars.slice(start, start + this.#scroll));
|
|
2098
|
-
if (curCol < scrolledW) {
|
|
2099
|
-
this.#scroll = this.#indexAtWidth(start, end, curCol) - start;
|
|
2100
|
-
}
|
|
2101
|
-
else if (curCol >= scrolledW + maxW) {
|
|
2102
|
-
this.#scroll = this.#indexAtWidth(start, end, Math.max(0, curCol - maxW + 1)) - start;
|
|
2103
|
-
}
|
|
2363
|
+
// OR-11 — DECLARED SUPERSESSION of ADR-0039 Amendment 2's horizontal
|
|
2364
|
+
// scrolling. There is nothing to reflow horizontally any more: a
|
|
2365
|
+
// long line FOLDS (see #foldLine), so every character is on screen
|
|
2366
|
+
// and the row offset that used to be kept here is gone with it.
|
|
2367
|
+
// What survives is the goal-column reset above, which every key
|
|
2368
|
+
// that is not a ↑/↓ walk still owes.
|
|
2104
2369
|
}
|
|
2105
2370
|
/** The first index in [start, end] whose display width from `start`
|
|
2106
2371
|
* reaches `target` — the width-based column walk (a wide char never
|
|
2107
2372
|
* splits: the index lands BEFORE it). */
|
|
2373
|
+
/** OR-11 — the end of the WIDEST PREFIX of [start, end) that fits in
|
|
2374
|
+
* `budget` columns. Distinct from `#indexAtWidth`, which answers the
|
|
2375
|
+
* scroll's question ("the first index AT or past this width") and is
|
|
2376
|
+
* one character too generous for a fold: 20 wide characters are 40
|
|
2377
|
+
* columns and do not fit in 39. The walk stops BEFORE a character
|
|
2378
|
+
* that would overflow, so a wide character is never split — the same
|
|
2379
|
+
* `w + cw > limit` the compositor's own row cut uses. */
|
|
2380
|
+
#fitsWithin(start, end, budget) {
|
|
2381
|
+
let w = 0;
|
|
2382
|
+
for (let i = start; i < end; i += 1) {
|
|
2383
|
+
const cw = charWidth(this.#chars[i]);
|
|
2384
|
+
if (w + cw > budget)
|
|
2385
|
+
return i;
|
|
2386
|
+
w += cw;
|
|
2387
|
+
}
|
|
2388
|
+
return end;
|
|
2389
|
+
}
|
|
2108
2390
|
#indexAtWidth(start, end, target) {
|
|
2109
2391
|
let w = 0;
|
|
2110
2392
|
for (let i = start; i < end; i += 1) {
|
package/dist/panel-input.d.ts
CHANGED
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
* goes through the host.
|
|
18
18
|
*/
|
|
19
19
|
import { type PanelState, type PanelVerdict, type PanelView, type SaferAnswer } from "./approval-panel.js";
|
|
20
|
-
/** The composer's buffer, as a band puts it aside and gets it back.
|
|
20
|
+
/** The composer's buffer, as a band puts it aside and gets it back.
|
|
21
|
+
* OR-11 dropped `scroll`: a long line folds now, so there is no
|
|
22
|
+
* horizontal offset left to put aside. */
|
|
21
23
|
export interface BufferStash {
|
|
22
24
|
readonly chars: number[];
|
|
23
25
|
readonly cursor: number;
|
|
24
|
-
readonly scroll: number;
|
|
25
26
|
}
|
|
26
27
|
/** The composer, as a band controller is allowed to see it. Every
|
|
27
28
|
* entry is something a panel or the session picker does to the
|
|
@@ -31,7 +32,7 @@ export interface BandHost {
|
|
|
31
32
|
line(): string;
|
|
32
33
|
/** The buffer with its paste capsules expanded — the text that would leave the editor. */
|
|
33
34
|
expandPastes(line: string): string;
|
|
34
|
-
/** Empty the buffer: chars, cursor
|
|
35
|
+
/** Empty the buffer: chars, cursor and the ↑↓ goal column. */
|
|
35
36
|
clear(): void;
|
|
36
37
|
/** Type one code point at the cursor. */
|
|
37
38
|
insert(cp: number): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.1",
|
|
4
4
|
"description": "kiso tui — the pure terminal layer (cell renderer, dock, raw editor, diff, palette). Zero runtime dependencies: input is data, output is bytes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,6 +35,6 @@
|
|
|
35
35
|
},
|
|
36
36
|
"homepage": "https://github.com/vincemakes/kiso/tree/main/packages/tui#readme",
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@vincemakes/kiso-tui-cells": "0.
|
|
38
|
+
"@vincemakes/kiso-tui-cells": "0.32.1"
|
|
39
39
|
}
|
|
40
40
|
}
|