@vincemakes/kiso-tui 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ask-panel.js +1 -1
- package/dist/at-picker.js +1 -1
- package/dist/components.d.ts +1 -1
- package/dist/components.js +1 -1
- package/dist/compositor.d.ts +22 -42
- package/dist/compositor.js +265 -924
- package/dist/context-ledger.js +1 -1
- package/dist/editor.d.ts +6 -7
- package/dist/editor.js +376 -786
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/{render.js → lines.js} +3 -3
- package/dist/panel-input.d.ts +105 -0
- package/dist/panel-input.js +468 -0
- package/dist/pick-input.d.ts +34 -0
- package/dist/pick-input.js +100 -0
- package/dist/session-picker.js +1 -1
- package/dist/status.js +1 -1
- package/dist/transcript.js +4 -27
- package/package.json +2 -2
- /package/dist/{render.d.ts → lines.d.ts} +0 -0
package/dist/editor.js
CHANGED
|
@@ -26,16 +26,22 @@ import { 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 };
|
|
29
|
-
import { palette } from "./
|
|
30
|
-
import {
|
|
29
|
+
import { palette } from "./lines.js";
|
|
30
|
+
import {} from "./approval-panel.js";
|
|
31
31
|
// KC3.5: the panel-slot dispatchers — the ask branch folded into the
|
|
32
32
|
// W21 lead/rows, so this file keeps ONE panel and one key owner.
|
|
33
|
-
import {
|
|
33
|
+
import { panelLead } from "./ask-panel.js";
|
|
34
34
|
import { AT_VISIBLE, atFilter } from "./at-picker.js";
|
|
35
35
|
// TUI2-R2 ②: the session picker — the band's THIRD occupant. Its filter
|
|
36
36
|
// is the @ picker's rank aimed at the session id; the editor owns the
|
|
37
37
|
// keys, the compositor draws the rows.
|
|
38
|
-
import {
|
|
38
|
+
import {} from "./session-picker.js";
|
|
39
|
+
// S5 (C10): the two bands with state machines of their own — the
|
|
40
|
+
// approval/ask/pick panel and the session picker — are controllers.
|
|
41
|
+
// The editor parses bytes and lends them the composer through
|
|
42
|
+
// #bandHost; they answer keys.
|
|
43
|
+
import { PanelInput } from "./panel-input.js";
|
|
44
|
+
import { PickInput } from "./pick-input.js";
|
|
39
45
|
// TUI v4 #16d: the input row is the blue brick + the edit area — the
|
|
40
46
|
// "you>" text is gone (the brick IS the prompt; the pipe path's readline
|
|
41
47
|
// prompt keeps its own "you> " — v2a line mode, byte-for-byte).
|
|
@@ -89,6 +95,16 @@ export function viewerCommand(text) {
|
|
|
89
95
|
return null;
|
|
90
96
|
}
|
|
91
97
|
}
|
|
98
|
+
/** E1 §1 — the two-byte alt spellings of word motion and word deletion.
|
|
99
|
+
* A table because one gesture has several encodings, and a table is
|
|
100
|
+
* what keeps them from drifting into several features. */
|
|
101
|
+
const ALT_WORD = new Map([
|
|
102
|
+
["b", "left"],
|
|
103
|
+
["f", "right"],
|
|
104
|
+
["d", "killFwd"],
|
|
105
|
+
["\x7f", "killBack"],
|
|
106
|
+
["\x08", "killBack"],
|
|
107
|
+
]);
|
|
92
108
|
export const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
|
|
93
109
|
export const MOUSE_OFF = "\x1b[?1000l\x1b[?1006l";
|
|
94
110
|
export const PROMPT = "▌ ";
|
|
@@ -139,6 +155,39 @@ const ellipsis = () => {
|
|
|
139
155
|
const p = palette();
|
|
140
156
|
return `${p.dim}\u2026${p.reset}`;
|
|
141
157
|
};
|
|
158
|
+
/**
|
|
159
|
+
* DC-55 — the composer's DISPLAY form of the buffer.
|
|
160
|
+
*
|
|
161
|
+
* A tab is kept as U+0009 in `#chars`, because the submitted line and
|
|
162
|
+
* the durable event must carry the indentation the human pasted. It
|
|
163
|
+
* cannot be PAINTED as itself: the terminal expands it to the next tab
|
|
164
|
+
* stop while `charWidth(0x09)` returns 1, so the row on screen becomes
|
|
165
|
+
* wider than the row kiso measured — invariant ① in its literal form —
|
|
166
|
+
* and every cursor column after it is wrong by the same amount.
|
|
167
|
+
*
|
|
168
|
+
* So the projection shows `→`, ONE code point for one code point: every
|
|
169
|
+
* index and every cursor column the compositor computes from this string
|
|
170
|
+
* stays true. `line()` and `#chars` never see it.
|
|
171
|
+
*
|
|
172
|
+
* ONE CELL, not an expansion to a tab stop. A tab's width is a property
|
|
173
|
+
* of its POSITION, and `charWidth(cp)` takes a code point with no
|
|
174
|
+
* context to answer that from. CJK's two cells work because two is a
|
|
175
|
+
* property of the character; these are different problems, and reusing
|
|
176
|
+
* that path for this one would be a mis-fit that surfaces later as
|
|
177
|
+
* drift. The cost is stated in design §8: a pasted block's alignment in
|
|
178
|
+
* the composer is approximate, while the block itself is exact.
|
|
179
|
+
*
|
|
180
|
+
* The dim is safe for the reason the ellipsis above is: `cursorCol`
|
|
181
|
+
* COUNTS markers and sums the buffer's own widths — it never measures
|
|
182
|
+
* this string — so an SGR span in it has never been part of the
|
|
183
|
+
* arithmetic.
|
|
184
|
+
*/
|
|
185
|
+
const shown = (chars) => {
|
|
186
|
+
if (!chars.includes(0x09))
|
|
187
|
+
return String.fromCodePoint(...chars);
|
|
188
|
+
const p = palette();
|
|
189
|
+
return chars.map((cp) => (cp === 0x09 ? `${p.dim}\u2192${p.reset}` : String.fromCodePoint(cp))).join("");
|
|
190
|
+
};
|
|
142
191
|
/**
|
|
143
192
|
* The editor. Raw mode + bracketed paste (?2004h) on enter, restored on
|
|
144
193
|
* exit. The input row is rendered by `onRender` (the CLI wires it to the
|
|
@@ -157,14 +206,10 @@ export class Editor {
|
|
|
157
206
|
// or delete. Never stashed — it is a walk's state, not the buffer's.
|
|
158
207
|
#verticalGoalCol = null;
|
|
159
208
|
#questionCb = null;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// buffer is stashed at open and restored at close (commit AND
|
|
165
|
-
// cancel) — the panel's rule/feedback text never leaks into the
|
|
166
|
-
// user's next turn.
|
|
167
|
-
#panel = null;
|
|
209
|
+
/** The approval / ask / pick panel — its state machine and its keys
|
|
210
|
+
* live in PanelInput (S5); the editor lends it the composer through
|
|
211
|
+
* #bandHost. */
|
|
212
|
+
#panelInput = new PanelInput(this.#bandHost());
|
|
168
213
|
#pasting = false;
|
|
169
214
|
/**
|
|
170
215
|
* REL-0152-D8 — the paste capsule.
|
|
@@ -254,9 +299,6 @@ export class Editor {
|
|
|
254
299
|
#swallowEnter = false;
|
|
255
300
|
/** TUI2-R3v2 ②: whether SGR 1006 reporting is currently enabled. */
|
|
256
301
|
#mouseOn = false;
|
|
257
|
-
/** TUI2-R3v2 ③: the safer ask's generation. A panel the human escaped
|
|
258
|
-
* must not be resurrected by a promise nobody is waiting for. */
|
|
259
|
-
#saferToken = 0;
|
|
260
302
|
/** TUI2-R3v2 ②: where the compositor put the panel's option rows this
|
|
261
303
|
* frame (absolute 1-based screen rows). The editor owns no geometry —
|
|
262
304
|
* it asks the surface that placed them. */
|
|
@@ -280,6 +322,9 @@ export class Editor {
|
|
|
280
322
|
// expanded block). Mirrors the escape list: multiple listeners can
|
|
281
323
|
// coexist; the editor never interprets the key itself.
|
|
282
324
|
#expandCbs = [];
|
|
325
|
+
/** E1 §3 — ctrl+x. Same shape as the expand key: the editor owns the
|
|
326
|
+
* KEY, the CLI owns what it means. */
|
|
327
|
+
#copyCbs = [];
|
|
283
328
|
#onRender;
|
|
284
329
|
/** TUI2-R1 (D): the keys sheet — a static one-screen overlay opened by
|
|
285
330
|
* `?` on an empty composer and closed by the next key, whatever it
|
|
@@ -315,10 +360,8 @@ export class Editor {
|
|
|
315
360
|
// a session exists, owns the whole composer, and the only ways out
|
|
316
361
|
// are a pick and an esc. That is why the commit callback lives here
|
|
317
362
|
// rather than on the line channel — the caller is waiting for an id,
|
|
318
|
-
// not for a turn.
|
|
319
|
-
#
|
|
320
|
-
#pickCommit = null;
|
|
321
|
-
#pickSel = 0;
|
|
363
|
+
// not for a turn. S5: the state and the keys live in PickInput.
|
|
364
|
+
#pickInput = new PickInput(this.#bandHost());
|
|
322
365
|
// A2 (the feel): the session-scoped input history — every submitted TURN
|
|
323
366
|
// line (never a question answer), capped at 100, never persisted. ↑↓
|
|
324
367
|
// navigate it ONLY from an empty input or while already browsing.
|
|
@@ -405,6 +448,10 @@ export class Editor {
|
|
|
405
448
|
onModeCycle(cb) {
|
|
406
449
|
this.#onModeCycle = cb;
|
|
407
450
|
}
|
|
451
|
+
/** E1 §3 — the copy key (ctrl+x). */
|
|
452
|
+
onCopy(cb) {
|
|
453
|
+
this.#copyCbs.push(cb);
|
|
454
|
+
}
|
|
408
455
|
onExpand(cb) {
|
|
409
456
|
this.#expandCbs.push(cb);
|
|
410
457
|
}
|
|
@@ -506,7 +553,7 @@ export class Editor {
|
|
|
506
553
|
* the frame's clamp is the authority. */
|
|
507
554
|
#visibleRows(lineCount) {
|
|
508
555
|
const H = process.stdout.rows ?? 24;
|
|
509
|
-
const bands = (this.#menuOpen ? this.#menuFiltered().length : 0) + this.#atRows() + this.#
|
|
556
|
+
const bands = (this.#menuOpen ? this.#menuFiltered().length : 0) + this.#atRows() + this.#pickInput.rows() + this.#queueState().length;
|
|
510
557
|
return Math.max(1, Math.min(lineCount, N_MAX, Math.max(1, H - 3 - bands)));
|
|
511
558
|
}
|
|
512
559
|
/** The dock's input-row state — ADDITIVE (§5): `line` + `cursor` keep
|
|
@@ -534,7 +581,7 @@ export class Editor {
|
|
|
534
581
|
const scrolled = i === cursorLine && this.#scroll > 0 ? ellipsis() : "";
|
|
535
582
|
const above = i === first && first > 0 ? ellipsis() : "";
|
|
536
583
|
const below = i === first + n - 1 && first + n < bounds.length ? ellipsis() : "";
|
|
537
|
-
lines.push(`${above}${scrolled}${
|
|
584
|
+
lines.push(`${above}${scrolled}${shown(this.#chars.slice(from, b.end))}${below}`);
|
|
538
585
|
}
|
|
539
586
|
const cursorRow = cursorLine - first;
|
|
540
587
|
// the window trails the cursor, so the hidden-above marker can only
|
|
@@ -568,7 +615,7 @@ export class Editor {
|
|
|
568
615
|
return MENU_ITEMS.filter((m) => m.name.startsWith(line));
|
|
569
616
|
}
|
|
570
617
|
#refreshMenu() {
|
|
571
|
-
if (this.#
|
|
618
|
+
if (this.#panelInput.up())
|
|
572
619
|
return; // W21: the menu never opens while the panel owns the keys
|
|
573
620
|
const f = this.#menuFiltered();
|
|
574
621
|
this.#menuOpen = f.length > 0;
|
|
@@ -620,7 +667,7 @@ export class Editor {
|
|
|
620
667
|
#atView() {
|
|
621
668
|
if (!this.#atOpen || this.#atList === null)
|
|
622
669
|
return null;
|
|
623
|
-
if (this.#
|
|
670
|
+
if (this.#panelInput.up() || this.#menuOpen)
|
|
624
671
|
return null;
|
|
625
672
|
const token = this.#atToken();
|
|
626
673
|
if (token === null)
|
|
@@ -656,9 +703,9 @@ export class Editor {
|
|
|
656
703
|
// TUI2-R2 ②: not inside a session filter. An `@` typed into the
|
|
657
704
|
// picker's query is a character in a session id, and a file picker
|
|
658
705
|
// opening over a session picker would put two bands in one slot.
|
|
659
|
-
if (this.#
|
|
706
|
+
if (this.#pickInput.up())
|
|
660
707
|
return;
|
|
661
|
-
if (this.#
|
|
708
|
+
if (this.#panelInput.up() || this.#menuOpen || this.#questionCb !== null)
|
|
662
709
|
return;
|
|
663
710
|
if (this.#atToken() === null)
|
|
664
711
|
return; // not at a word boundary
|
|
@@ -708,71 +755,12 @@ export class Editor {
|
|
|
708
755
|
return view === null ? 0 : Math.min(view.matches.length, AT_VISIBLE) + 1;
|
|
709
756
|
}
|
|
710
757
|
// ── TUI2-R2 ② — the session picker ───────────────────────────────
|
|
711
|
-
/** Open the picker on a bound card source
|
|
712
|
-
* (the buffer becomes the filter query) and `onPick` receives the
|
|
713
|
-
* chosen id — or null when the human leaves without picking, which
|
|
714
|
-
* is a first-class outcome and not an error. */
|
|
758
|
+
/** Open the picker on a bound card source (PickInput, S5). */
|
|
715
759
|
beginPick(cards, onPick) {
|
|
716
|
-
this.#
|
|
717
|
-
this.#pickCommit = onPick;
|
|
718
|
-
this.#pickSel = 0;
|
|
719
|
-
this.#syncMouse();
|
|
720
|
-
this.#chars = [];
|
|
721
|
-
this.#cursor = 0;
|
|
722
|
-
this.#reflow();
|
|
723
|
-
this.#onRender();
|
|
724
|
-
}
|
|
725
|
-
/** The picker's state, derived: the full card list (the id column
|
|
726
|
-
* measures over ALL of them, so the columns never jump), the
|
|
727
|
-
* filtered matches, and the selection CLAMPED at read time — the
|
|
728
|
-
* same correction discipline the @ picker uses, for the same
|
|
729
|
-
* reason: narrowing can only ever shrink the list. */
|
|
730
|
-
#pickView() {
|
|
731
|
-
if (this.#pickCards === null)
|
|
732
|
-
return null;
|
|
733
|
-
const cards = this.#pickCards();
|
|
734
|
-
const matches = sessionFilter(cards, this.line());
|
|
735
|
-
return { cards, matches, selected: Math.max(0, Math.min(this.#pickSel, matches.length - 1)) };
|
|
760
|
+
this.#pickInput.begin(cards, onPick);
|
|
736
761
|
}
|
|
737
762
|
pickState() {
|
|
738
|
-
return this.#
|
|
739
|
-
}
|
|
740
|
-
#pickUp() {
|
|
741
|
-
return this.#pickCards !== null;
|
|
742
|
-
}
|
|
743
|
-
/** The band's height estimate: the header + the windowed rows (or
|
|
744
|
-
* the one "no match" row) + the counter. */
|
|
745
|
-
#pickRows() {
|
|
746
|
-
const view = this.#pickView();
|
|
747
|
-
return view === null ? 0 : Math.min(Math.max(view.matches.length, 1), AT_VISIBLE) + 2;
|
|
748
|
-
}
|
|
749
|
-
/** Close and hand the verdict back. The callback fires AFTER the
|
|
750
|
-
* state is cleared, so a caller that re-enters (a second picker, a
|
|
751
|
-
* session that starts) never sees the closing picker's rows. */
|
|
752
|
-
#pickClose(id) {
|
|
753
|
-
const cb = this.#pickCommit;
|
|
754
|
-
this.#pickCards = null;
|
|
755
|
-
this.#pickCommit = null;
|
|
756
|
-
this.#pickSel = 0;
|
|
757
|
-
this.#syncMouse();
|
|
758
|
-
this.#chars = [];
|
|
759
|
-
this.#cursor = 0;
|
|
760
|
-
this.#reflow();
|
|
761
|
-
cb?.(id);
|
|
762
|
-
this.#onRender();
|
|
763
|
-
}
|
|
764
|
-
/** Enter takes the SELECTED session. An empty match set takes
|
|
765
|
-
* nothing and leaves the picker up: a picker that invented a pick
|
|
766
|
-
* when the query matched nothing would resume the wrong session,
|
|
767
|
-
* which is the one failure this surface must never have. */
|
|
768
|
-
#pickAccept() {
|
|
769
|
-
const view = this.#pickView();
|
|
770
|
-
if (view === null)
|
|
771
|
-
return;
|
|
772
|
-
const card = view.matches[view.selected];
|
|
773
|
-
if (card === undefined)
|
|
774
|
-
return;
|
|
775
|
-
this.#pickClose(card.id);
|
|
763
|
+
return this.#pickInput.state();
|
|
776
764
|
}
|
|
777
765
|
/** One-shot question mode: the NEXT submit answers, not a turn. */
|
|
778
766
|
question(_query, cb) {
|
|
@@ -783,54 +771,20 @@ export class Editor {
|
|
|
783
771
|
cancelQuestion() {
|
|
784
772
|
this.#questionCb = null;
|
|
785
773
|
}
|
|
786
|
-
/** W21: open the approval panel
|
|
787
|
-
*
|
|
788
|
-
*
|
|
774
|
+
/** W21: open the approval panel (PanelInput, S5): the buffer is
|
|
775
|
+
* stashed and restored at close, the panel takes the keys and the
|
|
776
|
+
* input row's lead, the composer's own bands close. */
|
|
789
777
|
beginPanel(view, onCommit, opts) {
|
|
790
|
-
this.#
|
|
791
|
-
view,
|
|
792
|
-
phase: "options",
|
|
793
|
-
cursor: 0,
|
|
794
|
-
note: null,
|
|
795
|
-
safer: opts?.safer,
|
|
796
|
-
saferRun: null,
|
|
797
|
-
ask: view.ask === undefined ? null : askStart(view.ask),
|
|
798
|
-
// TUI2-R2 ④: the pick's walk — present exactly when the view is
|
|
799
|
-
// a pick, the same contract the ask's runtime has.
|
|
800
|
-
pick: view.pick === undefined ? null : { cursor: 0, phase: "options" },
|
|
801
|
-
onCommit,
|
|
802
|
-
stash: { chars: this.#chars, cursor: this.#cursor, scroll: this.#scroll },
|
|
803
|
-
};
|
|
804
|
-
this.#chars = [];
|
|
805
|
-
this.#cursor = 0;
|
|
806
|
-
this.#scroll = 0;
|
|
807
|
-
this.#verticalGoalCol = null;
|
|
808
|
-
this.#menuOpen = false;
|
|
809
|
-
this.#menuSel = 0;
|
|
810
|
-
this.#queuePopMode = false; // W22: the panel owns the keys while up
|
|
811
|
-
this.#atClose(); // KC3 §3: and the picker closes with everything else
|
|
812
|
-
this.#syncMouse();
|
|
813
|
-
this.#onRender();
|
|
778
|
+
this.#panelInput.begin(view, onCommit, opts);
|
|
814
779
|
}
|
|
815
780
|
/** W21: cancel the panel — the SIGINT path's pair to beginPanel. */
|
|
816
781
|
cancelPanel() {
|
|
817
|
-
this.#
|
|
782
|
+
this.#panelInput.cancel();
|
|
818
783
|
}
|
|
819
784
|
/** W21: the compositor's bound view — the phase/selection while the
|
|
820
785
|
* panel is up, null otherwise. */
|
|
821
786
|
panelState() {
|
|
822
|
-
|
|
823
|
-
if (panel === null)
|
|
824
|
-
return null;
|
|
825
|
-
return {
|
|
826
|
-
view: panel.view,
|
|
827
|
-
phase: panel.phase,
|
|
828
|
-
cursor: panel.cursor,
|
|
829
|
-
...(panel.note === null ? {} : { note: panel.note }),
|
|
830
|
-
...(panel.saferRun === null ? {} : { safer: panel.saferRun }),
|
|
831
|
-
...(panel.ask === null ? {} : { ask: panel.ask }),
|
|
832
|
-
...(panel.pick === null ? {} : { pick: panel.pick }),
|
|
833
|
-
};
|
|
787
|
+
return this.#panelInput.state();
|
|
834
788
|
}
|
|
835
789
|
enter() {
|
|
836
790
|
if (this.#entered)
|
|
@@ -896,7 +850,7 @@ export class Editor {
|
|
|
896
850
|
/** The surfaces that own a selection — the approval/ask/pick panel, the
|
|
897
851
|
* session picker and the @ picker. Any one of them up = reporting on. */
|
|
898
852
|
#syncMouse() {
|
|
899
|
-
this.#setMouse(this.#
|
|
853
|
+
this.#setMouse(this.#panelInput.up() || this.#pickInput.up() || this.#atUp());
|
|
900
854
|
}
|
|
901
855
|
/** The row's own render when the dock is inactive (a TTY without a
|
|
902
856
|
* real size): \r + clear + blue brick prompt + visible + cursor
|
|
@@ -907,8 +861,8 @@ export class Editor {
|
|
|
907
861
|
const W = (process.stdout.columns ?? 0) || 80; // a degenerate 0 size (no TIOCSWINSZ) falls back
|
|
908
862
|
// W21: the panel's lead owns the row while up (the brick returns
|
|
909
863
|
// when the panel closes).
|
|
910
|
-
const panel = this.#
|
|
911
|
-
const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.cursor, panel.ask
|
|
864
|
+
const panel = this.#panelInput.state();
|
|
865
|
+
const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.cursor, panel.ask) : `${p.bold}${PROMPT}${p.reset}`;
|
|
912
866
|
// W23: the ONE width authority — leadWidth(lead), the ANSI-stripped
|
|
913
867
|
// visible width (the styled panel lead / the styled brick measure
|
|
914
868
|
// the same as their plain text — a lead can never measure
|
|
@@ -954,246 +908,36 @@ export class Editor {
|
|
|
954
908
|
// close, and it disarms on anything else in the same breath.
|
|
955
909
|
if (this.#swallowEnter) {
|
|
956
910
|
this.#swallowEnter = false;
|
|
957
|
-
if (this.#
|
|
911
|
+
if (!this.#panelInput.up() && (c === "\x0d" || c === "\x0a")) {
|
|
958
912
|
i += 1;
|
|
959
913
|
continue;
|
|
960
914
|
}
|
|
961
915
|
}
|
|
962
|
-
if (this.#
|
|
963
|
-
//
|
|
964
|
-
//
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
// the
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
const
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
if (panel.pick !== null) {
|
|
986
|
-
const typing = panel.pick.phase === "custom";
|
|
987
|
-
if (c === "\x1b" && !text.slice(i + 1).startsWith("[") && !text.slice(i + 1).startsWith("O")) {
|
|
988
|
-
this.#pickPanelEsc();
|
|
989
|
-
i += 1;
|
|
990
|
-
continue;
|
|
991
|
-
}
|
|
992
|
-
if (c === "\x0d" || c === "\x0a") {
|
|
993
|
-
// REL-0152-D10: a newline inside a PASTE is content, never a
|
|
994
|
-
// commit. Bracketed paste marks its own boundaries, so a
|
|
995
|
-
// \n between them is a line of the pasted text and nothing
|
|
996
|
-
// else. Without this, pasting a stack trace into a typed
|
|
997
|
-
// panel phase submitted the first line and dropped the
|
|
998
|
-
// rest into the composer behind the closed panel — the
|
|
999
|
-
// owner asked whether the type-your-own box takes a paste,
|
|
1000
|
-
// and the answer was no, in the worst way.
|
|
1001
|
-
if (this.#pasting) {
|
|
1002
|
-
this.#insert(NEWLINE);
|
|
1003
|
-
i += c === "\x0d" && text[i + 1] === "\x0a" ? 2 : 1;
|
|
1004
|
-
continue;
|
|
1005
|
-
}
|
|
1006
|
-
this.#pickPanelEnter();
|
|
1007
|
-
i += 1;
|
|
1008
|
-
continue;
|
|
1009
|
-
}
|
|
1010
|
-
if (!typing && c !== undefined && c >= "1" && c <= "9") {
|
|
1011
|
-
this.#pickPanelDigit(Number(c) - 1);
|
|
1012
|
-
i += 1;
|
|
1013
|
-
continue;
|
|
1014
|
-
}
|
|
1015
|
-
// DC-36: no `t` row means no custom phase to enter — the
|
|
1016
|
-
// option list IS the world (a closed set), and a key
|
|
1017
|
-
// that leads to a surface the panel does not draw is
|
|
1018
|
-
// worse than an absent key.
|
|
1019
|
-
if (!typing && (c === "t" || c === "T") && this.#panel?.view.pick?.typeHint !== undefined) {
|
|
1020
|
-
panel.pick = { cursor: panel.pick.cursor, phase: "custom" };
|
|
1021
|
-
this.#chars = [];
|
|
1022
|
-
this.#cursor = 0;
|
|
1023
|
-
this.#scroll = 0;
|
|
1024
|
-
this.#onRender();
|
|
1025
|
-
i += 1;
|
|
1026
|
-
continue;
|
|
1027
|
-
}
|
|
1028
|
-
if (!typing && c !== undefined && c >= " " && c !== "\x7f") {
|
|
1029
|
-
i += 1;
|
|
1030
|
-
continue;
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
if (panel.ask !== null) {
|
|
1034
|
-
const typing = panel.ask.phase === "custom";
|
|
1035
|
-
if (c === "\x1b" && !text.slice(i + 1).startsWith("[") && !text.slice(i + 1).startsWith("O")) {
|
|
1036
|
-
this.#askStep("esc");
|
|
1037
|
-
i += 1;
|
|
1038
|
-
continue;
|
|
1039
|
-
}
|
|
1040
|
-
if (c === "\x0d" || c === "\x0a") {
|
|
1041
|
-
// REL-0152-D10: a newline inside a PASTE is content, never a
|
|
1042
|
-
// commit. Bracketed paste marks its own boundaries, so a
|
|
1043
|
-
// \n between them is a line of the pasted text and nothing
|
|
1044
|
-
// else. Without this, pasting a stack trace into a typed
|
|
1045
|
-
// panel phase submitted the first line and dropped the
|
|
1046
|
-
// rest into the composer behind the closed panel — the
|
|
1047
|
-
// owner asked whether the type-your-own box takes a paste,
|
|
1048
|
-
// and the answer was no, in the worst way.
|
|
1049
|
-
if (this.#pasting) {
|
|
1050
|
-
this.#insert(NEWLINE);
|
|
1051
|
-
i += c === "\x0d" && text[i + 1] === "\x0a" ? 2 : 1;
|
|
1052
|
-
continue;
|
|
1053
|
-
}
|
|
1054
|
-
this.#askStep(typing ? "commit" : "enter");
|
|
1055
|
-
i += 1;
|
|
1056
|
-
continue;
|
|
1057
|
-
}
|
|
1058
|
-
// REL-0152-D4 — on the custom row a printable key is TEXT.
|
|
1059
|
-
// The row names typing as its purpose and then swallowed
|
|
1060
|
-
// the first thing you typed; only enter or `t` opened the
|
|
1061
|
-
// phase. Now the keystroke opens it AND lands in the
|
|
1062
|
-
// buffer, so the character you meant is the character you
|
|
1063
|
-
// get. This is checked BEFORE the shortcut branch below on
|
|
1064
|
-
// purpose: on this row "3" and "t" are the start of an
|
|
1065
|
-
// answer, not a pick and not a mode key. Everywhere else
|
|
1066
|
-
// in the list they keep their fast-path meaning exactly.
|
|
1067
|
-
if (askOnCustomRow(panel.view.ask, panel.ask) && c !== undefined && c >= " " && c !== "\x7f") {
|
|
1068
|
-
this.#askStep("type");
|
|
1069
|
-
this.#insert(c.codePointAt(0));
|
|
1070
|
-
this.#onRender();
|
|
1071
|
-
i += 1;
|
|
1072
|
-
continue;
|
|
1073
|
-
}
|
|
1074
|
-
if (!typing && (c === " " || (c !== undefined && c >= "1" && c <= "4") || c === "t" || c === "T")) {
|
|
1075
|
-
this.#askStep(c === " " ? "space" : c === "T" ? "t" : c);
|
|
1076
|
-
i += 1;
|
|
1077
|
-
continue;
|
|
1078
|
-
}
|
|
1079
|
-
// an ask at rest swallows stray PRINTABLE keys — the panel
|
|
1080
|
-
// owns them, and a typed "/" or "@" must not arm the menu
|
|
1081
|
-
// or the picker underneath. Two things are never
|
|
1082
|
-
// swallowed: the CSI/SS3 introducer, because ←/↑/↓ are
|
|
1083
|
-
// the ask's own keys and the parser below routes them
|
|
1084
|
-
// (the T-Q1 red), and the CONTROL characters, because
|
|
1085
|
-
// ctrl-c must still reach the SIGINT handler that
|
|
1086
|
-
// cancels the panel — W21's own rule, and what the T-Q6
|
|
1087
|
-
// race red caught: an abort with the panel up did
|
|
1088
|
-
// nothing at all.
|
|
1089
|
-
if (!typing && c !== undefined && c >= " " && c !== "\x7f") {
|
|
1090
|
-
i += 1;
|
|
1091
|
-
continue;
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
if (c === "\x1b" && !text.slice(i + 1).startsWith("[") && !text.slice(i + 1).startsWith("O")) {
|
|
1095
|
-
this.#panelEsc();
|
|
1096
|
-
i += 1;
|
|
1097
|
-
continue;
|
|
1098
|
-
}
|
|
1099
|
-
if (c === "\t") {
|
|
1100
|
-
if (panel.phase === "options")
|
|
1101
|
-
this.#panelTab();
|
|
1102
|
-
i += 1;
|
|
1103
|
-
continue;
|
|
1104
|
-
}
|
|
1105
|
-
if (c === "\x0d" || c === "\x0a") {
|
|
1106
|
-
// REL-0152-D10: a newline inside a PASTE is content, never a
|
|
1107
|
-
// commit. Bracketed paste marks its own boundaries, so a
|
|
1108
|
-
// \n between them is a line of the pasted text and nothing
|
|
1109
|
-
// else. Without this, pasting a stack trace into a typed
|
|
1110
|
-
// panel phase submitted the first line and dropped the
|
|
1111
|
-
// rest into the composer behind the closed panel — the
|
|
1112
|
-
// owner asked whether the type-your-own box takes a paste,
|
|
1113
|
-
// and the answer was no, in the worst way.
|
|
1114
|
-
if (this.#pasting) {
|
|
1115
|
-
this.#insert(NEWLINE);
|
|
1116
|
-
i += c === "\x0d" && text[i + 1] === "\x0a" ? 2 : 1;
|
|
1117
|
-
continue;
|
|
1118
|
-
}
|
|
1119
|
-
this.#panelEnter();
|
|
1120
|
-
i += 1;
|
|
1121
|
-
continue;
|
|
1122
|
-
}
|
|
1123
|
-
// TUI2-R3v2 ③: the safer list answers the SAME keys the approval
|
|
1124
|
-
// list does — one interaction model means the new surface is not
|
|
1125
|
-
// an exception to it. A digit takes its row (the way back
|
|
1126
|
-
// included, as the last one).
|
|
1127
|
-
if (panel.phase === "safer" && c !== undefined && c >= "1" && c <= "9") {
|
|
1128
|
-
this.#saferConfirm(Number(c) - 1);
|
|
1129
|
-
i += 1;
|
|
916
|
+
if (this.#panelInput.up()) {
|
|
917
|
+
// S5: the panel answers PARSED keys — a bare Esc, an Enter, a
|
|
918
|
+
// Tab, a character — and says how many bytes it took, or null
|
|
919
|
+
// when the key falls through to the composer (the amend
|
|
920
|
+
// reason, a custom answer and a custom pick are typed through
|
|
921
|
+
// the ordinary path below; an unclaimed control byte keeps its
|
|
922
|
+
// meaning). An ESC that opens a sequence is not a key yet: it
|
|
923
|
+
// goes to the sequence parser like any other.
|
|
924
|
+
const ahead = text.slice(i + 1);
|
|
925
|
+
const key = c === undefined
|
|
926
|
+
? null
|
|
927
|
+
: c === "\x1b"
|
|
928
|
+
? ahead.startsWith("[") || ahead.startsWith("O")
|
|
929
|
+
? null
|
|
930
|
+
: { kind: "esc" }
|
|
931
|
+
: c === "\x0d" || c === "\x0a"
|
|
932
|
+
? { kind: "enter", crlf: c === "\x0d" && text[i + 1] === "\x0a" }
|
|
933
|
+
: c === "\t"
|
|
934
|
+
? { kind: "tab" }
|
|
935
|
+
: { kind: "char", ch: c };
|
|
936
|
+
const took = key === null ? null : this.#panelInput.feed(key, this.#pasting);
|
|
937
|
+
if (took !== null) {
|
|
938
|
+
i += took;
|
|
1130
939
|
continue;
|
|
1131
940
|
}
|
|
1132
|
-
// while the ask is in flight the panel owns every printable key
|
|
1133
|
-
// and answers to none of them — esc (above) is the only gesture
|
|
1134
|
-
// with a meaning, and a stray letter must not reach the composer.
|
|
1135
|
-
if (panel.phase === "asking" && c !== undefined && c >= " " && c !== "\x7f") {
|
|
1136
|
-
i += 1;
|
|
1137
|
-
continue;
|
|
1138
|
-
}
|
|
1139
|
-
// TUI2-R3v2 ① — the digit CONFIRMS, and it confirms on the
|
|
1140
|
-
// keypress.
|
|
1141
|
-
//
|
|
1142
|
-
// The retired model made a digit a selection and Enter the
|
|
1143
|
-
// commit, which meant the fastest path through an approval was
|
|
1144
|
-
// two keys and the hint line had to teach both. The list makes
|
|
1145
|
-
// the digit redundant as a selector — the bar is already showing
|
|
1146
|
-
// what is selected — so the digit becomes what a human pressing
|
|
1147
|
-
// a number on a numbered list means by it: THAT one.
|
|
1148
|
-
//
|
|
1149
|
-
// A digit past the list is INERT (the R2 pick panel's rule,
|
|
1150
|
-
// inherited whole): an option nobody has is never taken, and a
|
|
1151
|
-
// mistyped 7 must not fall through to the composer underneath.
|
|
1152
|
-
//
|
|
1153
|
-
// The guard is the options phase and nothing else. The typed
|
|
1154
|
-
// phase is prose — that is the R2 slice-⑧ finding, and it is
|
|
1155
|
-
// why this branch sits below the enter/esc/tab handlers and
|
|
1156
|
-
// above nothing at all: "yes, run 13 of them" keeps its digits.
|
|
1157
|
-
if (panel.phase === "options" && panel.ask === null && panel.pick === null && c !== undefined && c >= "1" && c <= "9") {
|
|
1158
|
-
this.#panelConfirm(Number(c) - 1);
|
|
1159
|
-
i += 1;
|
|
1160
|
-
continue;
|
|
1161
|
-
}
|
|
1162
|
-
// TUI2-R2 ⑧, carried forward — the shortcut keys belong to the
|
|
1163
|
-
// OPTIONS phase and to it alone.
|
|
1164
|
-
//
|
|
1165
|
-
// `y`/`n` used to be applied in every phase of every flavour:
|
|
1166
|
-
// the `i += 1; continue;` sat OUTSIDE the phase check, so a
|
|
1167
|
-
// phase where the key meant nothing swallowed it anyway — and a
|
|
1168
|
-
// phase where a letter means nothing is exactly a phase where a
|
|
1169
|
-
// human is typing prose. Every y and n vanished from the line,
|
|
1170
|
-
// silently. "yes, run it now" committed as "es, ru it ow".
|
|
1171
|
-
//
|
|
1172
|
-
// The guard survives the migration unchanged in spirit and
|
|
1173
|
-
// simpler in fact: there is now ONE typed phase instead of
|
|
1174
|
-
// three, and the letters reach only the list.
|
|
1175
|
-
//
|
|
1176
|
-
// The letters stay because they are the two answers this panel
|
|
1177
|
-
// has always taken and a decade of muscle memory types them.
|
|
1178
|
-
// They are ALIASES for rows, not a second model: `y` is the
|
|
1179
|
-
// first option, `n` is the last, and on an approval the last
|
|
1180
|
-
// option opens the composer — so the old "n then enter" still
|
|
1181
|
-
// lands the same bare denial it always did.
|
|
1182
|
-
const optionsPhase = panel.pick === null && // a pick has no yes and no no
|
|
1183
|
-
panel.phase === "options" && // the amend line is prose
|
|
1184
|
-
(panel.ask === null || panel.ask.phase === "options"); // and so is a typed ask answer
|
|
1185
|
-
if (optionsPhase && panel.ask === null) {
|
|
1186
|
-
if (c === "y" || c === "Y") {
|
|
1187
|
-
this.#panelConfirm(0);
|
|
1188
|
-
i += 1;
|
|
1189
|
-
continue;
|
|
1190
|
-
}
|
|
1191
|
-
if (c === "n" || c === "N") {
|
|
1192
|
-
this.#panelConfirm(panelOptions(panel.view).length - 1);
|
|
1193
|
-
i += 1;
|
|
1194
|
-
continue;
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
941
|
}
|
|
1198
942
|
if (c === "\x1b") {
|
|
1199
943
|
const rest = text.slice(i + 1);
|
|
@@ -1245,6 +989,39 @@ export class Editor {
|
|
|
1245
989
|
else if (rest.startsWith("O")) {
|
|
1246
990
|
i += 3; // SS3 (function keys) — ignored
|
|
1247
991
|
}
|
|
992
|
+
else if (rest !== "" && ALT_WORD.has(rest[0]) && !this.#pasting && this.#composerIdle()) {
|
|
993
|
+
// E1 §1 — the two-byte alt spellings, SAME-CHUNK ONLY.
|
|
994
|
+
//
|
|
995
|
+
// This follows Alt+Enter below, which ruled the identical
|
|
996
|
+
// question for the identical byte shape: "A terminal sends
|
|
997
|
+
// Alt+X as ESC and X in ONE write, so SAME-CHUNK is the
|
|
998
|
+
// whole test: no timer, no hold, nothing parked. The
|
|
999
|
+
// identical two bytes arriving in SEPARATE chunks are NOT
|
|
1000
|
+
// combined — the bare Esc fires at once (its immediacy is
|
|
1001
|
+
// exactly what a hold would spend)."
|
|
1002
|
+
//
|
|
1003
|
+
// The work order asked for the split pair to be JOINED
|
|
1004
|
+
// through #pending. Built that way first, and it broke six
|
|
1005
|
+
// gates across four files — `dc7-osc-swallow`'s case is
|
|
1006
|
+
// titled "a chunk boundary between ESC and ] is known to
|
|
1007
|
+
// leak — Esc stays immediate", which is the same ruling
|
|
1008
|
+
// stated from the other side. Parking a lone ESC is what
|
|
1009
|
+
// joining requires, and esc immediacy is what parking
|
|
1010
|
+
// spends: esc interrupts a run. The conflict is reported
|
|
1011
|
+
// rather than resolved here.
|
|
1012
|
+
//
|
|
1013
|
+
// Inside a paste these bytes are CONTENT — `\x1bb` in
|
|
1014
|
+
// someone's text is an escape and a letter, not a motion.
|
|
1015
|
+
const op = ALT_WORD.get(rest[0]);
|
|
1016
|
+
if (op === "left" || op === "right")
|
|
1017
|
+
this.#moveWord(op === "left" ? -1 : 1);
|
|
1018
|
+
else if (op === "killBack")
|
|
1019
|
+
this.#killWord();
|
|
1020
|
+
else
|
|
1021
|
+
this.#killWordForward();
|
|
1022
|
+
this.#onRender();
|
|
1023
|
+
i += 2;
|
|
1024
|
+
}
|
|
1248
1025
|
else if (rest.startsWith("\x0d") && this.#composerIdle()) {
|
|
1249
1026
|
// KC2 §2 — Alt+Enter. A terminal sends Alt+X as ESC and X in
|
|
1250
1027
|
// ONE write, so SAME-CHUNK is the whole test: no timer, no
|
|
@@ -1269,13 +1046,13 @@ export class Editor {
|
|
|
1269
1046
|
this.#refreshMenu();
|
|
1270
1047
|
i += 1;
|
|
1271
1048
|
}
|
|
1272
|
-
else if (this.#
|
|
1049
|
+
else if (this.#pickInput.up()) {
|
|
1273
1050
|
// TUI2-R2 ②: esc leaves the picker with nothing picked.
|
|
1274
1051
|
// The caller reads null and exits 0 — declining to resume
|
|
1275
1052
|
// is a normal thing to do, not a failure, so it must not
|
|
1276
1053
|
// fall through to the escapeCbs (which mean "abort the
|
|
1277
1054
|
// run" and there is no run yet).
|
|
1278
|
-
this.#
|
|
1055
|
+
this.#pickInput.close(null);
|
|
1279
1056
|
i += 1;
|
|
1280
1057
|
}
|
|
1281
1058
|
else if (this.#atUp()) {
|
|
@@ -1432,6 +1209,21 @@ export class Editor {
|
|
|
1432
1209
|
cb();
|
|
1433
1210
|
i += 1;
|
|
1434
1211
|
}
|
|
1212
|
+
else if (c === "\x18" && this.#composerIdle()) {
|
|
1213
|
+
// E1 §3 — ctrl+x copies the last answer. `\x18` was unbound
|
|
1214
|
+
// across the whole tree (checked before the round started),
|
|
1215
|
+
// so nothing is displaced.
|
|
1216
|
+
//
|
|
1217
|
+
// Composer-idle only, like `?` and ctrl+r: a panel, picker,
|
|
1218
|
+
// menu or question owns its keys first. The buffer is NOT
|
|
1219
|
+
// required to be empty — ctrl+x copies the ANSWER, not the
|
|
1220
|
+
// composer, so a half-written follow-up is no reason to
|
|
1221
|
+
// refuse. (ctrl+r requires an empty buffer because it opens
|
|
1222
|
+
// a surface OVER the composer; this prints one status row.)
|
|
1223
|
+
for (const cb of [...this.#copyCbs])
|
|
1224
|
+
cb();
|
|
1225
|
+
i += 1;
|
|
1226
|
+
}
|
|
1435
1227
|
else if (c === "\x12" && this.#composerIdle() && this.#chars.length === 0) {
|
|
1436
1228
|
// R5 — the transcript viewer, on ctrl+r since DC-41. The
|
|
1437
1229
|
// reference binds ctrl+r to renaming a session, which kiso
|
|
@@ -1454,12 +1246,48 @@ export class Editor {
|
|
|
1454
1246
|
this.#onRender();
|
|
1455
1247
|
i += 1;
|
|
1456
1248
|
}
|
|
1249
|
+
else if (c === "\t" && this.#pasting) {
|
|
1250
|
+
// DC-55 — a TAB is content, and the branch below would have
|
|
1251
|
+
// eaten it.
|
|
1252
|
+
//
|
|
1253
|
+
// `c < " "` discards every unclaimed control byte, and a
|
|
1254
|
+
// paste has no other way into the buffer, so pasting
|
|
1255
|
+
// indented code silently lost its indentation: `alpha\tbeta`
|
|
1256
|
+
// arrived as `alphabeta`.
|
|
1257
|
+
//
|
|
1258
|
+
// `#pasting` IS TESTED, and the first build left it out on the
|
|
1259
|
+
// reasoning that a typed Tab is claimed further up anyway.
|
|
1260
|
+
// It is claimed CONDITIONALLY: `\t && #menuOpen` completes a
|
|
1261
|
+
// command and `\t && #atUp()` completes a path, so with
|
|
1262
|
+
// neither surface open a typed Tab falls through to exactly
|
|
1263
|
+
// here. Without the guard it started inserting a tab — the
|
|
1264
|
+
// completion key silently became an insert key on an idle
|
|
1265
|
+
// composer, which the typed-Tab gate caught.
|
|
1266
|
+
//
|
|
1267
|
+
// A paste is the one context where a tab is CONTENT.
|
|
1268
|
+
this.#insert(0x09);
|
|
1269
|
+
i += 1;
|
|
1270
|
+
}
|
|
1457
1271
|
else if (c !== undefined && c < " ") {
|
|
1458
1272
|
i += 1; // other control — ignored
|
|
1459
1273
|
}
|
|
1460
1274
|
else {
|
|
1461
|
-
|
|
1462
|
-
|
|
1275
|
+
// E1 §1 (found by the word-op gate, PRE-EXISTING): advance by
|
|
1276
|
+
// the CODE POINT, not by `text[i]`.
|
|
1277
|
+
//
|
|
1278
|
+
// `c` is one UTF-16 unit, so `c.length` is always 1, while
|
|
1279
|
+
// `codePointAt` returns the whole astral code point. Typing
|
|
1280
|
+
// one emoji therefore inserted the code point AND then the
|
|
1281
|
+
// lone low surrogate left under the cursor — `"😀"` came back
|
|
1282
|
+
// as `"😀\ude00"`, three UTF-16 units for one glyph.
|
|
1283
|
+
//
|
|
1284
|
+
// Verified pre-existing by stashing this round's changes and
|
|
1285
|
+
// re-running: the baseline is identically wrong. It surfaces
|
|
1286
|
+
// now because word motion is the first feature that has to
|
|
1287
|
+
// STEP over a grapheme rather than only append to it.
|
|
1288
|
+
const cp = text.codePointAt(i);
|
|
1289
|
+
this.#insert(cp);
|
|
1290
|
+
i += cp > 0xffff ? 2 : 1;
|
|
1463
1291
|
}
|
|
1464
1292
|
}
|
|
1465
1293
|
}
|
|
@@ -1485,18 +1313,7 @@ export class Editor {
|
|
|
1485
1313
|
// TUI2-R3v2 ③: a click works on BOTH lists — one interaction model
|
|
1486
1314
|
// means the safer alternatives are clickable for the same reason the
|
|
1487
1315
|
// original choices are.
|
|
1488
|
-
|
|
1489
|
-
return;
|
|
1490
|
-
const span = this.#panelRows?.();
|
|
1491
|
-
if (span == null || row === undefined || !Number.isFinite(row))
|
|
1492
|
-
return;
|
|
1493
|
-
const offset = row - span.top;
|
|
1494
|
-
if (offset < 0 || offset >= span.count)
|
|
1495
|
-
return; // outside the list — inert
|
|
1496
|
-
if (this.#panel.phase === "safer")
|
|
1497
|
-
this.#saferConfirm(offset);
|
|
1498
|
-
else
|
|
1499
|
-
this.#panelConfirm((span.first ?? 0) + offset);
|
|
1316
|
+
this.#panelInput.click(this.#panelRows?.(), row);
|
|
1500
1317
|
}
|
|
1501
1318
|
/** TUI2-R3v2 ②: the compositor reports where it PUT the option rows.
|
|
1502
1319
|
* The editor does no row arithmetic of its own — the surface that
|
|
@@ -1531,6 +1348,21 @@ export class Editor {
|
|
|
1531
1348
|
return;
|
|
1532
1349
|
}
|
|
1533
1350
|
}
|
|
1351
|
+
// E1 §1 — alt+←/→ and ctrl+←/→, the CSI spellings of word motion.
|
|
1352
|
+
//
|
|
1353
|
+
// `1;3` is alt (meta), `1;5` is ctrl. Terminal.app sends the CSI
|
|
1354
|
+
// form for alt+← unless "Use Option as Meta Key" is on, in which
|
|
1355
|
+
// case it sends `\x1bb` — handled in the escape branch. Both are
|
|
1356
|
+
// the same gesture and both route here, because a gesture with
|
|
1357
|
+
// three spellings is a table, not three features.
|
|
1358
|
+
//
|
|
1359
|
+
// Composer-idle only, for the back-tab's reason below: a panel,
|
|
1360
|
+
// picker, menu or question owns its keys first.
|
|
1361
|
+
if ((final === "D" || final === "C") && (params === "1;3" || params === "1;5") && this.#composerIdle()) {
|
|
1362
|
+
this.#moveWord(final === "D" ? -1 : 1);
|
|
1363
|
+
this.#onRender();
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1534
1366
|
// R3a — Shift+Tab (CSI Z, the universal back-tab encoding) cycles
|
|
1535
1367
|
// the approval tier. Composer-idle ONLY: a panel, picker, menu,
|
|
1536
1368
|
// history browse or question owns its keys first (the W21 gate),
|
|
@@ -1582,37 +1414,11 @@ export class Editor {
|
|
|
1582
1414
|
// the cursor semantics are unchanged (↑↓ do nothing). W21: the
|
|
1583
1415
|
// panel owns the keys while up (↑↓ do nothing — the panel has no
|
|
1584
1416
|
// ↑↓ role).
|
|
1585
|
-
if (this.#
|
|
1586
|
-
//
|
|
1587
|
-
// the option cursor (the approval panel still has no ↑↓ role).
|
|
1588
|
-
if (this.#panel.pick !== null && this.#panel.pick.phase === "options") {
|
|
1589
|
-
// TUI2-R2 ④: ↑↓ walk the pick's cursor — the same list the
|
|
1590
|
-
// digits address, the other muscle.
|
|
1591
|
-
const n = Math.min(this.#panel.view.pick.options.length, PICK_MAX);
|
|
1592
|
-
const cur = this.#panel.pick.cursor;
|
|
1593
|
-
this.#panel.pick = { cursor: final === "A" ? Math.max(0, cur - 1) : Math.min(Math.max(0, n - 1), cur + 1), phase: "options" };
|
|
1594
|
-
}
|
|
1595
|
-
else if (this.#panel.ask !== null && this.#panel.ask.phase === "options")
|
|
1596
|
-
this.#askStep(final === "A" ? "up" : "down");
|
|
1597
|
-
// TUI2-R3v2 ①: the approval/simple panel joins them. It was the
|
|
1598
|
-
// one panel flavour with no ↑↓ role, because it had no cursor to
|
|
1599
|
-
// move; it has one now, and the gesture is the same one the
|
|
1600
|
-
// pick, the ask, the session picker and the @ picker already
|
|
1601
|
-
// answer to. ONE interaction model is the round's acceptance
|
|
1602
|
-
// criterion, and this branch is where it stops being four.
|
|
1603
|
-
else if (this.#panel.phase === "safer")
|
|
1604
|
-
this.#saferMove(final === "A" ? -1 : 1);
|
|
1605
|
-
else if (this.#panel.phase !== "asking")
|
|
1606
|
-
this.#panelMove(final === "A" ? -1 : 1);
|
|
1417
|
+
if (this.#panelInput.arrow(final === "A" ? "up" : "down")) {
|
|
1418
|
+
// the panel walked its own list (a phase without one swallows the key)
|
|
1607
1419
|
}
|
|
1608
|
-
else if (this.#
|
|
1609
|
-
//
|
|
1610
|
-
// SELECTION, never the composer's line walk and never the
|
|
1611
|
-
// history browse. It sits above both because the picker is
|
|
1612
|
-
// modal: there is no turn to recall and no second line to
|
|
1613
|
-
// walk to while it is open.
|
|
1614
|
-
const view = this.#pickView();
|
|
1615
|
-
this.#pickSel = final === "A" ? Math.max(0, view.selected - 1) : Math.min(Math.max(0, view.matches.length - 1), view.selected + 1);
|
|
1420
|
+
else if (this.#pickInput.arrow(final === "A" ? "up" : "down")) {
|
|
1421
|
+
// the session picker moved its selection
|
|
1616
1422
|
}
|
|
1617
1423
|
else if (this.#menuOpen) {
|
|
1618
1424
|
if (final === "A")
|
|
@@ -1652,9 +1458,7 @@ export class Editor {
|
|
|
1652
1458
|
else if (final === "D") {
|
|
1653
1459
|
// KC3.5: ← walks the ask BACK a question (the ‹ n/m › walk); at
|
|
1654
1460
|
// question one it stays put — esc is the decline, never ←.
|
|
1655
|
-
if (this.#
|
|
1656
|
-
this.#askStep("left");
|
|
1657
|
-
else
|
|
1461
|
+
if (!this.#panelInput.left())
|
|
1658
1462
|
this.#move(-1);
|
|
1659
1463
|
}
|
|
1660
1464
|
else if (final === "C") {
|
|
@@ -1687,365 +1491,44 @@ export class Editor {
|
|
|
1687
1491
|
this.#reflow();
|
|
1688
1492
|
this.#verticalGoalCol = goal; // the walk re-arms it (the reflow's reset is for every OTHER key)
|
|
1689
1493
|
}
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
return;
|
|
1728
|
-
case "rule":
|
|
1729
|
-
this.#panelClose({ action: "allow-rule", rule: panel.view.name });
|
|
1730
|
-
return;
|
|
1731
|
-
case "safer":
|
|
1732
|
-
this.#panelSafer();
|
|
1733
|
-
return;
|
|
1734
|
-
case "deny":
|
|
1735
|
-
// the approval flavor's denial is "let me tell it what to do
|
|
1736
|
-
// instead", so it opens the composer; the simple flavors have
|
|
1737
|
-
// nothing to tell anyone and resolve on the spot.
|
|
1738
|
-
if (panel.view.flavor === "approval")
|
|
1739
|
-
this.#panelAmend();
|
|
1740
|
-
else
|
|
1741
|
-
this.#panelClose({ action: "deny", reason: "" });
|
|
1742
|
-
return;
|
|
1743
|
-
}
|
|
1744
|
-
}
|
|
1745
|
-
/**
|
|
1746
|
-
* Option 3 — "show me safer ways to do this".
|
|
1747
|
-
*
|
|
1748
|
-
* The round's ONE new model request, and every branch here exists to
|
|
1749
|
-
* keep it honest.
|
|
1750
|
-
*
|
|
1751
|
-
* It fires ONLY from this method, which only this option reaches —
|
|
1752
|
-
* that is the entire mechanism behind the zero-ambient-rent claim,
|
|
1753
|
-
* and it is why the claim is checkable rather than asserted: a
|
|
1754
|
-
* session that never presses 3 never enters this branch, and the
|
|
1755
|
-
* trace shows no side-query line.
|
|
1756
|
-
*
|
|
1757
|
-
* The in-flight phase is VISIBLE because this is a network call: a
|
|
1758
|
-
* button that goes quiet for two seconds reads as broken, and the
|
|
1759
|
-
* human is standing in front of a paused run.
|
|
1760
|
-
*
|
|
1761
|
-
* Every failure — a throw, a null, an empty list, no provider bound
|
|
1762
|
-
* at all — lands on the SAME honest branch and puts back every
|
|
1763
|
-
* original choice. There is deliberately no retry and no partial
|
|
1764
|
-
* state: the alternative to "I could not get them" is either a lie or
|
|
1765
|
-
* a spinner that never ends, and both are worse than the sentence.
|
|
1766
|
-
*
|
|
1767
|
-
* R3v2-F1: one of those failures can now name its cause, and the
|
|
1768
|
-
* sentence says it. That is a widening of the COPY, not of the
|
|
1769
|
-
* branch — there is still exactly one failure path, it still restores
|
|
1770
|
-
* every original choice, and a provider that has nothing to add still
|
|
1771
|
-
* resolves `null` and still gets the line it always got. A cause is
|
|
1772
|
-
* only ever spoken when the caller could prove it; a diagnosis the
|
|
1773
|
-
* product cannot prove would be worse than the unqualified line it
|
|
1774
|
-
* replaced.
|
|
1775
|
-
*
|
|
1776
|
-
* The generation token is the guard against a late answer: a panel
|
|
1777
|
-
* the human escaped (or that a SIGINT cancelled) must not be
|
|
1778
|
-
* resurrected two seconds later by a promise nobody is waiting for.
|
|
1779
|
-
*/
|
|
1780
|
-
#panelSafer() {
|
|
1781
|
-
const panel = this.#panel;
|
|
1782
|
-
if (panel === null)
|
|
1783
|
-
return;
|
|
1784
|
-
const ask = panel.safer;
|
|
1785
|
-
panel.phase = "asking";
|
|
1786
|
-
panel.note = null;
|
|
1787
|
-
this.#onRender();
|
|
1788
|
-
const token = ++this.#saferToken;
|
|
1789
|
-
const settle = (answer) => {
|
|
1790
|
-
// the panel that asked must still be the panel on screen
|
|
1791
|
-
if (this.#panel !== panel || token !== this.#saferToken)
|
|
1792
|
-
return;
|
|
1793
|
-
// R3v2-F1: a non-list answer is a failure, and it may name its
|
|
1794
|
-
// cause. Which sentence that earns is decided where the
|
|
1795
|
-
// sentences live; here we only route to the same one branch
|
|
1796
|
-
// every failure has always taken.
|
|
1797
|
-
const options = Array.isArray(answer) ? answer : null;
|
|
1798
|
-
if (options === null || options.length === 0) {
|
|
1799
|
-
panel.phase = "options";
|
|
1800
|
-
panel.note = saferDegradedNote(answer);
|
|
1801
|
-
panel.cursor = 0;
|
|
1802
|
-
this.#onRender();
|
|
1803
|
-
return;
|
|
1804
|
-
}
|
|
1805
|
-
panel.phase = "safer";
|
|
1806
|
-
panel.saferRun = { options, cursor: 0 };
|
|
1807
|
-
this.#onRender();
|
|
1494
|
+
/* DECLARED MOVE (S5, 2026-09-06): the panel state machine — the
|
|
1495
|
+
fourteen handlers from #panelMove to #panelClose — lives in
|
|
1496
|
+
panel-input.ts (PanelInput); the session picker's in pick-input.ts
|
|
1497
|
+
(PickInput). The editor parses bytes and lends them the composer
|
|
1498
|
+
through #bandHost; nothing else of either band remains here. */
|
|
1499
|
+
/** S5 — the composer as the band controllers see it: the whole
|
|
1500
|
+
* surface a panel or the session picker may touch, in one place. */
|
|
1501
|
+
#bandHost() {
|
|
1502
|
+
return {
|
|
1503
|
+
line: () => this.line(),
|
|
1504
|
+
expandPastes: (line) => this.#expandPastes(line),
|
|
1505
|
+
clear: () => {
|
|
1506
|
+
this.#chars = [];
|
|
1507
|
+
this.#cursor = 0;
|
|
1508
|
+
this.#scroll = 0;
|
|
1509
|
+
this.#verticalGoalCol = null;
|
|
1510
|
+
},
|
|
1511
|
+
insert: (cp) => this.#insert(cp),
|
|
1512
|
+
newline: () => this.#insert(NEWLINE),
|
|
1513
|
+
stash: () => ({ chars: this.#chars, cursor: this.#cursor, scroll: this.#scroll }),
|
|
1514
|
+
restore: (st) => {
|
|
1515
|
+
this.#chars = [...st.chars];
|
|
1516
|
+
this.#cursor = st.cursor;
|
|
1517
|
+
this.#scroll = st.scroll;
|
|
1518
|
+
},
|
|
1519
|
+
reflow: () => this.#reflow(),
|
|
1520
|
+
render: () => this.#onRender(),
|
|
1521
|
+
syncMouse: () => this.#syncMouse(),
|
|
1522
|
+
closeBands: () => {
|
|
1523
|
+
this.#menuOpen = false;
|
|
1524
|
+
this.#menuSel = 0;
|
|
1525
|
+
this.#queuePopMode = false; // W22: the panel owns the keys while up
|
|
1526
|
+
this.#atClose(); // KC3 §3: and the picker closes with everything else
|
|
1527
|
+
},
|
|
1528
|
+
swallowNextEnter: () => {
|
|
1529
|
+
this.#swallowEnter = true;
|
|
1530
|
+
},
|
|
1808
1531
|
};
|
|
1809
|
-
if (ask === undefined) {
|
|
1810
|
-
settle(null); // no provider bound — the button says so rather than lying
|
|
1811
|
-
return;
|
|
1812
|
-
}
|
|
1813
|
-
void Promise.resolve()
|
|
1814
|
-
.then(ask)
|
|
1815
|
-
.then(settle)
|
|
1816
|
-
.catch(() => settle(null));
|
|
1817
|
-
}
|
|
1818
|
-
/** Take a row of the SAFER list. The alternatives route through the
|
|
1819
|
-
* EXISTING amend channel — choosing a safer command is a denial with
|
|
1820
|
-
* instructions, which is a verdict the product already has; the last
|
|
1821
|
-
* row is the way back and decides nothing. */
|
|
1822
|
-
#saferConfirm(index) {
|
|
1823
|
-
const panel = this.#panel;
|
|
1824
|
-
if (panel === null || panel.saferRun === null)
|
|
1825
|
-
return;
|
|
1826
|
-
const { options } = panel.saferRun;
|
|
1827
|
-
if (index === options.length) {
|
|
1828
|
-
// "back to the original choices"
|
|
1829
|
-
panel.phase = "options";
|
|
1830
|
-
panel.saferRun = null;
|
|
1831
|
-
panel.cursor = 0;
|
|
1832
|
-
this.#onRender();
|
|
1833
|
-
return;
|
|
1834
|
-
}
|
|
1835
|
-
const chosen = options[index];
|
|
1836
|
-
if (chosen === undefined)
|
|
1837
|
-
return; // past the list — inert
|
|
1838
|
-
this.#panelClose({ action: "deny", reason: `run this instead: ${chosen.command}` });
|
|
1839
|
-
}
|
|
1840
|
-
/** ↑↓ inside the safer list — the way back is its last row, so the
|
|
1841
|
-
* bar reaches it like any other. */
|
|
1842
|
-
#saferMove(delta) {
|
|
1843
|
-
const panel = this.#panel;
|
|
1844
|
-
if (panel === null || panel.saferRun === null)
|
|
1845
|
-
return;
|
|
1846
|
-
const last = panel.saferRun.options.length; // + the way-back row
|
|
1847
|
-
panel.saferRun = { options: panel.saferRun.options, cursor: Math.max(0, Math.min(last, panel.saferRun.cursor + delta)) };
|
|
1848
|
-
this.#onRender();
|
|
1849
|
-
}
|
|
1850
|
-
/** The typed phase — the one place the panel takes prose. The buffer
|
|
1851
|
-
* starts empty and the cursor stays where the human left it, so esc
|
|
1852
|
-
* can put the bar back exactly where it was. */
|
|
1853
|
-
#panelAmend() {
|
|
1854
|
-
const panel = this.#panel;
|
|
1855
|
-
if (panel === null)
|
|
1856
|
-
return;
|
|
1857
|
-
panel.phase = "amend";
|
|
1858
|
-
this.#chars = [];
|
|
1859
|
-
this.#cursor = 0;
|
|
1860
|
-
this.#scroll = 0;
|
|
1861
|
-
this.#verticalGoalCol = null;
|
|
1862
|
-
this.#onRender();
|
|
1863
|
-
}
|
|
1864
|
-
/** tab — the amend alias, unchanged as a GESTURE: it opens the same
|
|
1865
|
-
* typed phase the last option does, from anywhere in the list. The
|
|
1866
|
-
* simple flavors never had it and still do not. */
|
|
1867
|
-
#panelTab() {
|
|
1868
|
-
const panel = this.#panel;
|
|
1869
|
-
if (panel === null || panel.view.flavor !== "approval")
|
|
1870
|
-
return;
|
|
1871
|
-
panel.cursor = panelOptions(panel.view).length - 1;
|
|
1872
|
-
this.#panelAmend();
|
|
1873
|
-
}
|
|
1874
|
-
/** esc — back out of the typed phase to the list (the buffer clears,
|
|
1875
|
-
* the bar stays on the option that opened it), or cancel the panel.
|
|
1876
|
-
* The old model had a third step, the deselect, because a selection
|
|
1877
|
-
* could be "none"; a list always has a selection, so esc from the
|
|
1878
|
-
* list means what it means everywhere else in the product. */
|
|
1879
|
-
#panelEsc() {
|
|
1880
|
-
const panel = this.#panel;
|
|
1881
|
-
if (panel === null)
|
|
1882
|
-
return;
|
|
1883
|
-
// TUI2-R3v2 ③: esc out of the safer list — or out of the ask while
|
|
1884
|
-
// it is still in flight — returns to the original choices, exactly
|
|
1885
|
-
// as the way-back row does. The in-flight answer is orphaned by the
|
|
1886
|
-
// generation token; nothing it does can reopen this list.
|
|
1887
|
-
if (panel.phase === "safer" || panel.phase === "asking") {
|
|
1888
|
-
this.#saferToken += 1;
|
|
1889
|
-
panel.phase = "options";
|
|
1890
|
-
panel.saferRun = null;
|
|
1891
|
-
panel.cursor = 0;
|
|
1892
|
-
this.#onRender();
|
|
1893
|
-
return;
|
|
1894
|
-
}
|
|
1895
|
-
if (panel.phase !== "options") {
|
|
1896
|
-
panel.phase = "options";
|
|
1897
|
-
this.#chars = [];
|
|
1898
|
-
this.#cursor = 0;
|
|
1899
|
-
this.#scroll = 0;
|
|
1900
|
-
this.#verticalGoalCol = null;
|
|
1901
|
-
this.#onRender();
|
|
1902
|
-
return;
|
|
1903
|
-
}
|
|
1904
|
-
this.#panelClose({ action: "cancel" });
|
|
1905
|
-
}
|
|
1906
|
-
/**
|
|
1907
|
-
* enter — send the typed note, or TAKE THE HIGHLIGHTED OPTION.
|
|
1908
|
-
*
|
|
1909
|
-
* The second half is the round. The retired model's enter-at-rest did
|
|
1910
|
-
* nothing at all, on the theory that an accidental return must never
|
|
1911
|
-
* approve; what it actually produced was a panel that ignored the key
|
|
1912
|
-
* every human presses first. The safeguard is real but it belongs on
|
|
1913
|
-
* WHERE THE BAR STARTS, not on whether the key works: the bar opens on
|
|
1914
|
-
* the option whose blast radius is one tool call the human is looking
|
|
1915
|
-
* at, and every irreversible-er choice is a deliberate ↑↓ away.
|
|
1916
|
-
*
|
|
1917
|
-
* An empty note in the typed phase is the bare denial — the W21
|
|
1918
|
-
* mapping, untouched: no words means the run aborts, words mean the
|
|
1919
|
-
* model gets them and proposes a new call.
|
|
1920
|
-
*/
|
|
1921
|
-
#panelEnter() {
|
|
1922
|
-
const panel = this.#panel;
|
|
1923
|
-
if (panel === null)
|
|
1924
|
-
return;
|
|
1925
|
-
if (panel.phase === "amend") {
|
|
1926
|
-
this.#panelClose({ action: "deny", reason: this.line() });
|
|
1927
|
-
return;
|
|
1928
|
-
}
|
|
1929
|
-
// TUI2-R3v2 ③: in the safer list, enter takes the highlighted
|
|
1930
|
-
// alternative — the same gesture, one surface over.
|
|
1931
|
-
if (panel.phase === "safer" && panel.saferRun !== null) {
|
|
1932
|
-
this.#saferConfirm(panel.saferRun.cursor);
|
|
1933
|
-
return;
|
|
1934
|
-
}
|
|
1935
|
-
if (panel.phase === "asking")
|
|
1936
|
-
return; // nothing to confirm yet
|
|
1937
|
-
this.#panelConfirm(panel.cursor);
|
|
1938
|
-
}
|
|
1939
|
-
/**
|
|
1940
|
-
* KC3.5 — one ask key: the pure reducer decides, this method applies.
|
|
1941
|
-
* The buffer is cleared on every phase change so the type-your-own
|
|
1942
|
-
* line starts empty and its text never leaks back into the options
|
|
1943
|
-
* (the rule-input phase's own discipline). A step that produced a
|
|
1944
|
-
* RESULT closes the panel with it — the stash/restore is the W21
|
|
1945
|
-
* path, identical for an answer and for a decline.
|
|
1946
|
-
*/
|
|
1947
|
-
#askStep(key) {
|
|
1948
|
-
const panel = this.#panel;
|
|
1949
|
-
if (panel === null || panel.ask === null)
|
|
1950
|
-
return;
|
|
1951
|
-
const spec = panel.view.ask;
|
|
1952
|
-
const before = panel.ask.phase;
|
|
1953
|
-
// REL-0152-D8: a typed ask answer is a line leaving the editor too —
|
|
1954
|
-
// pasting a stack trace into "type your own answer" must send the
|
|
1955
|
-
// stack trace, not the capsule that stands for it.
|
|
1956
|
-
const step = key === "commit" ? askCommitCustom(spec, panel.ask, this.#expandPastes(this.line())) : askKey(spec, panel.ask, key);
|
|
1957
|
-
panel.ask = step.state;
|
|
1958
|
-
if (step.state.phase !== before) {
|
|
1959
|
-
this.#chars = [];
|
|
1960
|
-
this.#cursor = 0;
|
|
1961
|
-
this.#scroll = 0;
|
|
1962
|
-
}
|
|
1963
|
-
if (step.result !== undefined) {
|
|
1964
|
-
this.#panelClose({ action: "answers", result: step.result });
|
|
1965
|
-
return;
|
|
1966
|
-
}
|
|
1967
|
-
this.#onRender();
|
|
1968
|
-
}
|
|
1969
|
-
/**
|
|
1970
|
-
* TUI2-R2 ④ — the pick panel's three keys.
|
|
1971
|
-
*
|
|
1972
|
-
* A digit MOVES the cursor rather than committing. The list is short
|
|
1973
|
-
* and the digits are adjacent on the keyboard; a picker that acted on
|
|
1974
|
-
* the keypress would make a mistyped 3 a model switch, and the whole
|
|
1975
|
-
* point of a confirm step is that the choice is visible before it is
|
|
1976
|
-
* taken.
|
|
1977
|
-
*/
|
|
1978
|
-
#pickPanelDigit(index) {
|
|
1979
|
-
const panel = this.#panel;
|
|
1980
|
-
if (panel === null || panel.pick === null)
|
|
1981
|
-
return;
|
|
1982
|
-
// a digit past the list is INERT — an option nobody has is never
|
|
1983
|
-
// selected, and the cursor stays where the human left it
|
|
1984
|
-
if (index < 0 || index >= Math.min(panel.view.pick.options.length, PICK_MAX))
|
|
1985
|
-
return;
|
|
1986
|
-
panel.pick = { cursor: index, phase: "options" };
|
|
1987
|
-
this.#onRender();
|
|
1988
|
-
}
|
|
1989
|
-
/** enter — the typed line when there is one (an EMPTY line is not a
|
|
1990
|
-
* choice and commits nothing), else the option under the cursor. */
|
|
1991
|
-
#pickPanelEnter() {
|
|
1992
|
-
const panel = this.#panel;
|
|
1993
|
-
if (panel === null || panel.pick === null)
|
|
1994
|
-
return;
|
|
1995
|
-
if (panel.pick.phase === "custom") {
|
|
1996
|
-
const line = this.line().trim();
|
|
1997
|
-
if (line === "")
|
|
1998
|
-
return;
|
|
1999
|
-
this.#panelClose({ action: "picked", result: { custom: line } });
|
|
2000
|
-
return;
|
|
2001
|
-
}
|
|
2002
|
-
if (panel.view.pick.options.length === 0)
|
|
2003
|
-
return; // nothing to take
|
|
2004
|
-
this.#panelClose({ action: "picked", result: { index: panel.pick.cursor } });
|
|
2005
|
-
}
|
|
2006
|
-
/** esc — back out of the type-it line first, then cancel the panel.
|
|
2007
|
-
* Two escapes, two meanings, exactly as the approval panel's
|
|
2008
|
-
* rule/amend phases already work. */
|
|
2009
|
-
#pickPanelEsc() {
|
|
2010
|
-
const panel = this.#panel;
|
|
2011
|
-
if (panel === null || panel.pick === null)
|
|
2012
|
-
return;
|
|
2013
|
-
if (panel.pick.phase === "custom") {
|
|
2014
|
-
panel.pick = { cursor: panel.pick.cursor, phase: "options" };
|
|
2015
|
-
this.#chars = [];
|
|
2016
|
-
this.#cursor = 0;
|
|
2017
|
-
this.#scroll = 0;
|
|
2018
|
-
this.#onRender();
|
|
2019
|
-
return;
|
|
2020
|
-
}
|
|
2021
|
-
this.#panelClose({ action: "cancel" });
|
|
2022
|
-
}
|
|
2023
|
-
#panelClose(verdict) {
|
|
2024
|
-
const panel = this.#panel;
|
|
2025
|
-
if (panel === null)
|
|
2026
|
-
return;
|
|
2027
|
-
this.#panel = null;
|
|
2028
|
-
this.#syncMouse();
|
|
2029
|
-
// TUI2-R3v2 ①: swallow ONE bare enter after the panel goes away.
|
|
2030
|
-
//
|
|
2031
|
-
// This is the hazard the instant confirm creates and it is not
|
|
2032
|
-
// hypothetical: "y⏎" and "1⏎" are what a decade of y/n prompts
|
|
2033
|
-
// taught everyone's fingers, and the panel used to need both bytes.
|
|
2034
|
-
// It needs one now — so the second one lands in a composer that has
|
|
2035
|
-
// just had the user's PRE-PANEL DRAFT restored into it, and submits
|
|
2036
|
-
// it. Answering an approval would send a half-written message.
|
|
2037
|
-
//
|
|
2038
|
-
// The guard is one-shot and expires on any other key, so it can
|
|
2039
|
-
// never eat an enter the user meant: by the time they have typed
|
|
2040
|
-
// anything at all, it is gone.
|
|
2041
|
-
this.#swallowEnter = true;
|
|
2042
|
-
// the pre-panel buffer returns — the panel's amend text never leaks
|
|
2043
|
-
// into the user's next turn (commit AND cancel).
|
|
2044
|
-
this.#chars = [...panel.stash.chars];
|
|
2045
|
-
this.#cursor = panel.stash.cursor;
|
|
2046
|
-
this.#scroll = panel.stash.scroll;
|
|
2047
|
-
this.#onRender();
|
|
2048
|
-
panel.onCommit(verdict);
|
|
2049
1532
|
}
|
|
2050
1533
|
// ---- editing ----
|
|
2051
1534
|
#insert(cp) {
|
|
@@ -2171,21 +1654,127 @@ export class Editor {
|
|
|
2171
1654
|
if (!this.#pasting)
|
|
2172
1655
|
this.#onRender();
|
|
2173
1656
|
}
|
|
2174
|
-
/**
|
|
2175
|
-
*
|
|
2176
|
-
*
|
|
1657
|
+
/**
|
|
1658
|
+
* E1 §1 — WHERE A WORD ENDS. One function, five operations.
|
|
1659
|
+
*
|
|
1660
|
+
* `#killWord` used to answer this inline, and only for `0x20`: no
|
|
1661
|
+
* tab, no punctuation, and a whole Chinese sentence was one word from
|
|
1662
|
+
* its first character to its last. Five operations asking the
|
|
1663
|
+
* question separately is five chances for a motion and a deletion to
|
|
1664
|
+
* disagree about the same text, so they ask here.
|
|
1665
|
+
*
|
|
1666
|
+
* Three classes over the CODE POINT:
|
|
1667
|
+
*
|
|
1668
|
+
* - SEPARATOR — whitespace, and the newline the flat buffer carries
|
|
1669
|
+
* as an ordinary code point;
|
|
1670
|
+
* - CJK — one character IS one word. A sentence is not a unit
|
|
1671
|
+
* anyone wants to move or delete by, and the owner types Chinese;
|
|
1672
|
+
* - otherwise WORD vs PUNCT, which split from each other: `foo.bar`
|
|
1673
|
+
* is three words, the readline behaviour and every editor's.
|
|
1674
|
+
*
|
|
1675
|
+
* Combining marks, ZWJ joins and variation selectors are NOT their
|
|
1676
|
+
* own class — they belong to whatever precedes them (`#glyphStart`),
|
|
1677
|
+
* so a family emoji deletes whole instead of shedding a member and
|
|
1678
|
+
* stranding a joiner.
|
|
1679
|
+
*/
|
|
1680
|
+
#classOf(cp) {
|
|
1681
|
+
// U+3000 IS A SPACE, and it sits inside the CJK range below, so it
|
|
1682
|
+
// has to be named before the range test rather than after it. It
|
|
1683
|
+
// classed as "cjk" in the first build — an ideographic space
|
|
1684
|
+
// deleted as though it were a character, which is exactly what the
|
|
1685
|
+
// CJK-per-character rule is NOT about.
|
|
1686
|
+
if (cp === 0x20 || cp === 0x09 || cp === 0x0a || cp === 0x0d || cp === 0x3000)
|
|
1687
|
+
return "sep";
|
|
1688
|
+
// CJK ideographs, kana, Hangul, the fullwidth forms — one per word.
|
|
1689
|
+
if ((cp >= 0x1100 && cp <= 0x11ff) ||
|
|
1690
|
+
(cp >= 0x2e80 && cp <= 0x9fff) ||
|
|
1691
|
+
(cp >= 0xa960 && cp <= 0xa97f) ||
|
|
1692
|
+
(cp >= 0xac00 && cp <= 0xd7ff) ||
|
|
1693
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
1694
|
+
(cp >= 0xff00 && cp <= 0xffef) ||
|
|
1695
|
+
(cp >= 0x20000 && cp <= 0x3ffff)) {
|
|
1696
|
+
return "cjk";
|
|
1697
|
+
}
|
|
1698
|
+
const ch = String.fromCodePoint(cp);
|
|
1699
|
+
if (/[\p{L}\p{N}_]/u.test(ch))
|
|
1700
|
+
return "word";
|
|
1701
|
+
return "punct";
|
|
1702
|
+
}
|
|
1703
|
+
/** True when `i` is a continuation of the glyph before it — a
|
|
1704
|
+
* combining mark, a ZWJ, or a variation selector. Never a boundary. */
|
|
1705
|
+
#joins(cp) {
|
|
1706
|
+
return ((cp >= 0x0300 && cp <= 0x036f) || // combining diacriticals
|
|
1707
|
+
(cp >= 0x1ab0 && cp <= 0x1aff) ||
|
|
1708
|
+
(cp >= 0x20d0 && cp <= 0x20ff) ||
|
|
1709
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
|
|
1710
|
+
cp === 0x200d || // ZWJ
|
|
1711
|
+
(cp >= 0xe0100 && cp <= 0xe01ef));
|
|
1712
|
+
}
|
|
1713
|
+
/** The index one WORD away from `from`, in `dir`. Skips a run of
|
|
1714
|
+
* separators, then crosses one run of a single class. Joiners never
|
|
1715
|
+
* end a run, so a grapheme is never split. */
|
|
1716
|
+
#wordEdge(from, dir) {
|
|
1717
|
+
const at = (i) => this.#chars[dir < 0 ? i - 1 : i] ?? -1;
|
|
1718
|
+
let i = from;
|
|
1719
|
+
const end = dir < 0 ? 0 : this.#chars.length;
|
|
1720
|
+
const more = () => (dir < 0 ? i > end : i < end);
|
|
1721
|
+
while (more() && this.#classOf(at(i)) === "sep")
|
|
1722
|
+
i += dir;
|
|
1723
|
+
if (!more())
|
|
1724
|
+
return i;
|
|
1725
|
+
// A JOINER NEVER DECIDES THE RUN'S CLASS. Walking backwards, the
|
|
1726
|
+
// first thing seen at the end of `café` is the combining acute —
|
|
1727
|
+
// which is not a letter, so classifying from it made the run
|
|
1728
|
+
// "punct" and the kill stopped after one mark, leaving `e`.
|
|
1729
|
+
// Joiners belong to the character before them; step over them
|
|
1730
|
+
// first and classify from the base.
|
|
1731
|
+
while (more() && this.#joins(at(i)))
|
|
1732
|
+
i += dir;
|
|
1733
|
+
if (!more())
|
|
1734
|
+
return i;
|
|
1735
|
+
// CJK: exactly one character (plus anything joined to it).
|
|
1736
|
+
const cls = this.#classOf(at(i));
|
|
1737
|
+
if (cls === "cjk") {
|
|
1738
|
+
i += dir;
|
|
1739
|
+
while (more() && this.#joins(at(i)))
|
|
1740
|
+
i += dir;
|
|
1741
|
+
return i;
|
|
1742
|
+
}
|
|
1743
|
+
while (more()) {
|
|
1744
|
+
const c = at(i);
|
|
1745
|
+
if (!this.#joins(c) && this.#classOf(c) !== cls)
|
|
1746
|
+
break;
|
|
1747
|
+
i += dir;
|
|
1748
|
+
}
|
|
1749
|
+
return i;
|
|
1750
|
+
}
|
|
1751
|
+
/** Ctrl+W and alt+backspace — the word kill. A3 scopes A/E/U/K, not
|
|
1752
|
+
* W. UD-1: an archive point only when something is actually removed;
|
|
1753
|
+
* a no-op kill at the buffer's start must not eat the next ctrl+z. */
|
|
2177
1754
|
#killWord() {
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
i -= 1; // the word
|
|
2183
|
-
if (i < this.#cursor)
|
|
2184
|
-
this.#checkpoint(); // UD-1
|
|
1755
|
+
const i = this.#wordEdge(this.#cursor, -1);
|
|
1756
|
+
if (i >= this.#cursor)
|
|
1757
|
+
return;
|
|
1758
|
+
this.#checkpoint(); // UD-1
|
|
2185
1759
|
this.#chars.splice(i, this.#cursor - i);
|
|
2186
1760
|
this.#cursor = i;
|
|
2187
1761
|
this.#reflow();
|
|
2188
1762
|
}
|
|
1763
|
+
/** alt+d — the word kill FORWARD. Same boundary, same archive rule. */
|
|
1764
|
+
#killWordForward() {
|
|
1765
|
+
const j = this.#wordEdge(this.#cursor, 1);
|
|
1766
|
+
if (j <= this.#cursor)
|
|
1767
|
+
return;
|
|
1768
|
+
this.#checkpoint(); // UD-1
|
|
1769
|
+
this.#chars.splice(this.#cursor, j - this.#cursor);
|
|
1770
|
+
this.#reflow();
|
|
1771
|
+
}
|
|
1772
|
+
/** alt+←/→ — the cursor one word over. No archive point: nothing is
|
|
1773
|
+
* destroyed. */
|
|
1774
|
+
#moveWord(dir) {
|
|
1775
|
+
this.#cursor = this.#wordEdge(this.#cursor, dir);
|
|
1776
|
+
this.#reflow();
|
|
1777
|
+
}
|
|
2189
1778
|
/** KC1/KC2 — the buffer LEAVES: the flat chars, the cursor, the
|
|
2190
1779
|
* horizontal scroll, the ↑/↓ goal, the menu and the pop-walk all
|
|
2191
1780
|
* reset together (W22: a departing line ends the pop-walk, so the
|
|
@@ -2342,10 +1931,10 @@ export class Editor {
|
|
|
2342
1931
|
* one of those states the two bytes fall through to today's
|
|
2343
1932
|
* handling — two gestures, unchanged. */
|
|
2344
1933
|
#composerIdle() {
|
|
2345
|
-
return (this.#
|
|
1934
|
+
return (!this.#panelInput.up() &&
|
|
2346
1935
|
!this.#menuOpen &&
|
|
2347
1936
|
!this.#atUp() && // KC3 §3: the @ picker owns the keys while up, exactly like the menu
|
|
2348
|
-
!this.#
|
|
1937
|
+
!this.#pickInput.up() && // TUI2-R2 ②: and so does the session picker — `?` is a query character there
|
|
2349
1938
|
this.#historyIdx === null &&
|
|
2350
1939
|
!this.#queuePopMode &&
|
|
2351
1940
|
!this.#pasting &&
|
|
@@ -2380,8 +1969,8 @@ export class Editor {
|
|
|
2380
1969
|
#submit() {
|
|
2381
1970
|
// TUI2-R2 ②: the session picker takes Enter before anything else —
|
|
2382
1971
|
// while it is up there is no turn to submit and no line to send.
|
|
2383
|
-
if (this.#
|
|
2384
|
-
this.#
|
|
1972
|
+
if (this.#pickInput.up()) {
|
|
1973
|
+
this.#pickInput.accept();
|
|
2385
1974
|
return;
|
|
2386
1975
|
}
|
|
2387
1976
|
// KC3 §3: Enter ACCEPTS while the picker is up — the same rule the
|
|
@@ -2485,7 +2074,8 @@ export class Editor {
|
|
|
2485
2074
|
// W23: the ONE width authority — leadWidth(lead) — the cap follows
|
|
2486
2075
|
// the lead the editor itself renders (the panel lead when the panel
|
|
2487
2076
|
// owns the keys, the brick otherwise): maxW = W − walls − lead.
|
|
2488
|
-
const
|
|
2077
|
+
const ps = this.#panelInput.state();
|
|
2078
|
+
const lead = ps !== null ? panelLead(ps.view, ps.phase, ps.cursor, ps.ask) : PROMPT;
|
|
2489
2079
|
const leadW = leadWidth(lead);
|
|
2490
2080
|
// DC-17: ONE column, not four. W6's box took 2+2 and this kept
|
|
2491
2081
|
// reserving them after law 1.1 retired it — so the horizontal
|