@vincemakes/kiso-tui 0.2.0 → 0.6.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/compositor.d.ts +18 -8
- package/dist/compositor.js +175 -93
- package/dist/editor.d.ts +26 -4
- package/dist/editor.js +298 -42
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/status.d.ts +38 -0
- package/dist/status.js +52 -0
- package/package.json +2 -2
package/dist/compositor.d.ts
CHANGED
|
@@ -49,6 +49,22 @@ import { type ResumeMeta } from "./render.js";
|
|
|
49
49
|
* embeds at the edit position; the compositor strips it and moves
|
|
50
50
|
* relatively (it never reaches the terminal). */
|
|
51
51
|
export declare const CURSOR_MARKER = "\u001B_[kiso-cur]\u001B\\";
|
|
52
|
+
/** KC1 §5 — the input row's bound state. The legacy pair stays
|
|
53
|
+
* REQUIRED and keeps its exact meaning (the cursor line's visible
|
|
54
|
+
* slice and its display column), so a one-row provider — the old
|
|
55
|
+
* `{line, cursor}` shape — keeps working unchanged; the composer's
|
|
56
|
+
* rows ride as OPTIONAL fields, which is what makes the CLI's binding
|
|
57
|
+
* signature-neutral (zero cli source growth). */
|
|
58
|
+
export interface InputState {
|
|
59
|
+
readonly line: string;
|
|
60
|
+
readonly cursor: number;
|
|
61
|
+
/** the visible rows (≤ N_visible), dim "…" markers included */
|
|
62
|
+
readonly lines?: readonly string[];
|
|
63
|
+
/** the cursor's row within `lines` */
|
|
64
|
+
readonly cursorRow?: number;
|
|
65
|
+
/** the cursor's display column within that row */
|
|
66
|
+
readonly cursorCol?: number;
|
|
67
|
+
}
|
|
52
68
|
export interface BodyOptions {
|
|
53
69
|
/** Is the cell renderer live? A color TTY with a real size — checked
|
|
54
70
|
* per mutation (the TIOCSWINSZ can land after main constructs us). */
|
|
@@ -182,10 +198,7 @@ export declare class Body {
|
|
|
182
198
|
* up; the old ApprovalPrompt's question slot retires with it). */
|
|
183
199
|
bindApproval(state: () => PanelState | null): void;
|
|
184
200
|
/** Bind the CURRENT input line's state — the focus component reads it. */
|
|
185
|
-
bindInput(state: () =>
|
|
186
|
-
line: string;
|
|
187
|
-
cursor: number;
|
|
188
|
-
}, prompt: string): void;
|
|
201
|
+
bindInput(state: () => InputState, prompt: string): void;
|
|
189
202
|
/** Bind the editor's slash-command menu state — the MenuSelect slot
|
|
190
203
|
* occupant (the menu replaces the editor's view while open). */
|
|
191
204
|
bindMenu(state: () => {
|
|
@@ -228,10 +241,7 @@ export declare class Dock {
|
|
|
228
241
|
* occupant (the panel replaces the live region + the input lead
|
|
229
242
|
* while up; the old ApprovalPrompt's question slot retires). */
|
|
230
243
|
bindApproval(state: () => PanelState | null): void;
|
|
231
|
-
bindInput(state: () =>
|
|
232
|
-
line: string;
|
|
233
|
-
cursor: number;
|
|
234
|
-
}, prompt: string): void;
|
|
244
|
+
bindInput(state: () => InputState, prompt: string): void;
|
|
235
245
|
bindMenu(state: () => {
|
|
236
246
|
items: readonly MenuItem[];
|
|
237
247
|
selected: number;
|
package/dist/compositor.js
CHANGED
|
@@ -77,6 +77,12 @@ export class Body {
|
|
|
77
77
|
#lastLiveRows = 0; // the recorded live row count (incl. the chrome)
|
|
78
78
|
#lastSkip = 0; // the last frame's window top in model rows — the A8b scroll's leaving-count base
|
|
79
79
|
#lastH = 0;
|
|
80
|
+
// KC1 §6: the composer's recorded extent — the row count the last
|
|
81
|
+
// frame drew (exit's clear walks it) and the row its CHA parked the
|
|
82
|
+
// cursor on (the steady frame's relative anchor). N = 1 reproduces
|
|
83
|
+
// the retired constants exactly (one input row, the anchor at H−2).
|
|
84
|
+
#lastInputRows = 1;
|
|
85
|
+
#lastAnchorRow = 0;
|
|
80
86
|
#frameTimer = null;
|
|
81
87
|
#spinnerTimer = null;
|
|
82
88
|
#spinnerI = 0;
|
|
@@ -599,8 +605,10 @@ export class Body {
|
|
|
599
605
|
const H = this.#lastH > 0 ? this.#lastH : process.stdout.rows ?? 24;
|
|
600
606
|
const out = [];
|
|
601
607
|
out.push("\x1b[r");
|
|
602
|
-
|
|
603
|
-
|
|
608
|
+
// V6-3 + KC1: the chrome rows — the RECORDED composer extent rides
|
|
609
|
+
// the same mechanism the resize clear already uses (N = 1 ⇒ H−3..H)
|
|
610
|
+
for (let row = H - 2 - this.#lastInputRows; row <= H; row += 1) {
|
|
611
|
+
out.push(`\x1b[${row};1H\x1b[0K`); // clear the chrome rows
|
|
604
612
|
}
|
|
605
613
|
out.push(`\x1b[${Math.max(1, H - 1)};1H`);
|
|
606
614
|
this.#write(out.join(""));
|
|
@@ -751,11 +759,17 @@ export class Body {
|
|
|
751
759
|
liveCount() {
|
|
752
760
|
const panel = this.#panelState?.() ?? null;
|
|
753
761
|
const queueRows = this.#queueRows(this.#opts.width(), this.#opts.height());
|
|
762
|
+
// KC1 §6: the composer's extra rows are chrome too — the scalar
|
|
763
|
+
// counts them exactly like the menu/queue bands (N = 1 ⇒ +0)
|
|
764
|
+
const inputExtra = this.#inputRows(this.#opts.width(), this.#opts.height(), this.#menuRows(this.#opts.width()).length, queueRows.length).rows.length - 1;
|
|
754
765
|
if (panel !== null) {
|
|
755
766
|
// W21: the panel's own rows (the cap is exact — the scalar
|
|
756
767
|
// reflects the screen). W22: the queue chips occupy their
|
|
757
768
|
// own band — the panel's cap shrinks by their rows.
|
|
758
|
-
return panelBlockRows(panel.view, panel.phase, panel.sel, this.#opts.width(), Math.max(1, this.#opts.height() - 4 - queueRows.length)).length +
|
|
769
|
+
return (panelBlockRows(panel.view, panel.phase, panel.sel, this.#opts.width(), Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length)).length +
|
|
770
|
+
CHROME_ROWS +
|
|
771
|
+
inputExtra +
|
|
772
|
+
queueRows.length);
|
|
759
773
|
}
|
|
760
774
|
const live = this.#cells.slice(this.#committed);
|
|
761
775
|
const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
|
|
@@ -767,7 +781,7 @@ export class Body {
|
|
|
767
781
|
lines += bodySpacing(prev, rows).length;
|
|
768
782
|
prev = rows;
|
|
769
783
|
}
|
|
770
|
-
return lines + CHROME_ROWS + this.#menuRows(W).length + queueRows.length;
|
|
784
|
+
return lines + CHROME_ROWS + inputExtra + this.#menuRows(W).length + queueRows.length;
|
|
771
785
|
}
|
|
772
786
|
/** The lines committed THIS frame — the writes land in the frame's
|
|
773
787
|
* committed section (the rows just above the live region). */
|
|
@@ -825,7 +839,12 @@ export class Body {
|
|
|
825
839
|
// (the band above the box top) — the chrome rows and the live
|
|
826
840
|
// caps account for both.
|
|
827
841
|
const queueRows = this.#queueRows(W, H);
|
|
828
|
-
|
|
842
|
+
// KC1 §6: the input is N rows now (N = 1 ⇒ today's chrome exactly)
|
|
843
|
+
// — chromeRows = 3 + N + menu + queue, and the content cap loses
|
|
844
|
+
// the composer's EXTRA rows the same way it loses the bands.
|
|
845
|
+
const editor = this.#inputRows(W, H, menuRows.length, queueRows.length);
|
|
846
|
+
const inputExtra = editor.rows.length - 1;
|
|
847
|
+
const chromeRows = CHROME_ROWS + inputExtra + menuRows.length + queueRows.length;
|
|
829
848
|
let liveLines = [];
|
|
830
849
|
const panel = this.#panelState?.() ?? null;
|
|
831
850
|
if (panel !== null) {
|
|
@@ -834,7 +853,7 @@ export class Body {
|
|
|
834
853
|
// the W11 blank would separate it from the frozen content).
|
|
835
854
|
// The cap is exact, so the force-commit loop never fires. W22:
|
|
836
855
|
// the queue band sits below the panel — the cap shrinks by it.
|
|
837
|
-
liveLines = panelBlockRows(panel.view, panel.phase, panel.sel, W, Math.max(1, H - 4 - queueRows.length));
|
|
856
|
+
liveLines = panelBlockRows(panel.view, panel.phase, panel.sel, W, Math.max(1, H - 4 - inputExtra - queueRows.length));
|
|
838
857
|
}
|
|
839
858
|
else {
|
|
840
859
|
let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
|
|
@@ -848,7 +867,7 @@ export class Body {
|
|
|
848
867
|
// commits the oldest live cell UNCONDITIONALLY (the one sharp
|
|
849
868
|
// edge — the cap scalar is asserted by the gates). W22: the
|
|
850
869
|
// queue band shrinks the cap by its rows (empty queue → H−4).
|
|
851
|
-
while (liveLines.length > H - 4 - queueRows.length && this.#committed < this.#cells.length) { // V6-3: the content cap H−4
|
|
870
|
+
while (liveLines.length > H - 4 - inputExtra - queueRows.length && this.#committed < this.#cells.length) { // V6-3: the content cap H−4 (KC1: −N's extra rows)
|
|
852
871
|
this.#commitCell(this.#committed, W, ctx);
|
|
853
872
|
liveLines = [];
|
|
854
873
|
{
|
|
@@ -882,16 +901,21 @@ export class Body {
|
|
|
882
901
|
// path: the window re-paints at the model's positions, every row
|
|
883
902
|
// covered (the V6-1 every-row rule).
|
|
884
903
|
if (this.#fullRedraw || liveTop > this.#lastLiveTop + this.#committedLinesThisFrame.length || liveRowsTotal < this.#lastLiveRows) {
|
|
885
|
-
this.#drawFull(out, W, H, liveTop, liveLines, queueRows, menuRows,
|
|
904
|
+
this.#drawFull(out, W, H, liveTop, liveLines, queueRows, menuRows, editor);
|
|
886
905
|
this.#fullRedraw = false;
|
|
887
906
|
}
|
|
888
907
|
else {
|
|
889
|
-
this.#drawSteady(out, W, H, liveTop, liveLines, queueRows, menuRows,
|
|
908
|
+
this.#drawSteady(out, W, H, liveTop, liveLines, queueRows, menuRows, editor);
|
|
890
909
|
}
|
|
891
910
|
out.push("\x1b[?2026l");
|
|
892
911
|
this.#write(out.join(""));
|
|
893
912
|
this.#lastLiveTop = liveTop;
|
|
894
913
|
this.#lastLiveRows = liveRowsTotal;
|
|
914
|
+
this.#lastInputRows = editor.rows.length;
|
|
915
|
+
// KC1 §6: the next steady frame's relative moves start where THIS
|
|
916
|
+
// frame's CHA parked the cursor — the marker's row inside the
|
|
917
|
+
// composer (N = 1 ⇒ H−2, the retired hard-coded anchor).
|
|
918
|
+
this.#lastAnchorRow = H - 1 - editor.rows.length + editor.markerRow;
|
|
895
919
|
}
|
|
896
920
|
/** Commit the cell at index i: render + cache its lines (immutable —
|
|
897
921
|
* the force-committed form freezes at the current render), advance
|
|
@@ -1044,9 +1068,20 @@ export class Body {
|
|
|
1044
1068
|
* occupant (the queue is dense, like the menu; each line is its
|
|
1045
1069
|
* own chip with the □ gutter). */
|
|
1046
1070
|
#queueRows(W, H) {
|
|
1047
|
-
const
|
|
1048
|
-
if (
|
|
1071
|
+
const queued = this.#queueState?.() ?? [];
|
|
1072
|
+
if (queued.length === 0)
|
|
1049
1073
|
return [];
|
|
1074
|
+
// KC1 (adjudication A4): a MULTI-LINE queued message's chip shows
|
|
1075
|
+
// its FIRST line + a ⏎×k suffix — k = the additional lines, counted
|
|
1076
|
+
// after the SAME §3 normalization the editor applies (a CRLF pair
|
|
1077
|
+
// is ONE break), so the chip stays one row per queued turn. The
|
|
1078
|
+
// suffix rides INSIDE the chip as plain text: the chip renderer
|
|
1079
|
+
// escapes control bytes, so an SGR span would be stripped there —
|
|
1080
|
+
// and the cells package stays untouched this round.
|
|
1081
|
+
const lines = queued.map((line) => {
|
|
1082
|
+
const parts = line.replace(/\r\n?/g, "\n").split("\n");
|
|
1083
|
+
return parts.length > 1 ? `${parts[0]} ⏎×${parts.length - 1}` : line;
|
|
1084
|
+
});
|
|
1050
1085
|
// A8b: the band CAPS so the content keeps its rows — an unbounded
|
|
1051
1086
|
// band (the batch flood pastes the whole queue at once) overflowed
|
|
1052
1087
|
// the screen: the content cap H−4−queue went negative, the march
|
|
@@ -1078,14 +1113,14 @@ export class Body {
|
|
|
1078
1113
|
}
|
|
1079
1114
|
return rows;
|
|
1080
1115
|
}
|
|
1081
|
-
/**
|
|
1082
|
-
* cursor's display column
|
|
1083
|
-
*
|
|
1084
|
-
* strips the marker and returns the frame-derived
|
|
1085
|
-
*
|
|
1086
|
-
*
|
|
1087
|
-
*
|
|
1088
|
-
*
|
|
1116
|
+
/** ONE input row's bytes — the marker embedded at `embedAt` (the
|
|
1117
|
+
* cursor's display column within the row) when this row owns the
|
|
1118
|
+
* cursor, `null` on the composer's other rows. The compositor
|
|
1119
|
+
* strips the marker and returns the frame-derived CELL — the cursor
|
|
1120
|
+
* move lands AT the marker (a CHA — the column is absolute, so the
|
|
1121
|
+
* move's base is irrelevant; the retired afterW CUB's base was the
|
|
1122
|
+
* LAST write's end column, which the steady frame's gap/stale ELs
|
|
1123
|
+
* leave at col 1 — the A3 finding).
|
|
1089
1124
|
*
|
|
1090
1125
|
* W6: the row lives INSIDE the box — the walls are a prefix/suffix
|
|
1091
1126
|
* width only, composed AFTER the marker embed (the marker math is
|
|
@@ -1094,67 +1129,101 @@ export class Body {
|
|
|
1094
1129
|
* pad completes the row to EXACTLY W — invariant ① throws on
|
|
1095
1130
|
* overflow, so the box row is built full-width, never truncated.
|
|
1096
1131
|
* W23: the lead width is the ONE authority — leadWidth (width.ts),
|
|
1097
|
-
* shared with the editor's selfRender/#reflow and editCol.
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
// the lead — the panel's phase lead when the panel owns the row
|
|
1102
|
-
// (1-3> / the rule input's "2 Yes, don't ask again for " / the
|
|
1103
|
-
// amend "feedback (deny): "), the bound prompt otherwise
|
|
1104
|
-
const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.sel) : this.#inputPrompt;
|
|
1105
|
-
const leadW = leadWidth(lead);
|
|
1106
|
-
const row = `${lead}${st.line}`;
|
|
1107
|
-
// embed the marker at the cursor's display column
|
|
1132
|
+
* shared with the editor's selfRender/#reflow and editCol.
|
|
1133
|
+
* KC1 §6: the walk is UNCHANGED — a one-row composer emits exactly
|
|
1134
|
+
* today's bytes (the T-C1 identity anchor). */
|
|
1135
|
+
#inputRowBytes(row, W, embedAt) {
|
|
1108
1136
|
let markerLine = "";
|
|
1109
1137
|
let markerCell = 0; // the marker's 0-based cell — the walk's w at the embed
|
|
1110
1138
|
let w = 0;
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
continue;
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
if (!inserted && w >= leadW + st.cursor) {
|
|
1124
|
-
markerLine += CURSOR_MARKER;
|
|
1125
|
-
markerCell = w;
|
|
1126
|
-
inserted = true;
|
|
1139
|
+
let inserted = embedAt === null; // a row without the cursor never embeds
|
|
1140
|
+
let i = 0;
|
|
1141
|
+
while (i < row.length) {
|
|
1142
|
+
if (row[i] === "\x1b") {
|
|
1143
|
+
const m = /^\x1b\[[0-9;]*m/.exec(row.slice(i));
|
|
1144
|
+
if (m !== null) {
|
|
1145
|
+
markerLine += m[0];
|
|
1146
|
+
i += m[0].length;
|
|
1147
|
+
continue;
|
|
1127
1148
|
}
|
|
1128
|
-
const cw = displayWidth(row[i]);
|
|
1129
|
-
if (w + cw > W - 4)
|
|
1130
|
-
break; // the cap — the two walls' columns
|
|
1131
|
-
markerLine += row[i];
|
|
1132
|
-
w += cw;
|
|
1133
|
-
i += 1;
|
|
1134
1149
|
}
|
|
1135
|
-
if (!inserted) {
|
|
1136
|
-
// the walk ended before the cursor cell (the box edge) —
|
|
1137
|
-
// the marker rests at the row's end; the move still lands
|
|
1138
|
-
// AT it (the min() of the contract)
|
|
1150
|
+
if (!inserted && w >= embedAt) {
|
|
1139
1151
|
markerLine += CURSOR_MARKER;
|
|
1140
1152
|
markerCell = w;
|
|
1153
|
+
inserted = true;
|
|
1141
1154
|
}
|
|
1155
|
+
const cw = displayWidth(row[i]);
|
|
1156
|
+
if (w + cw > W - 4)
|
|
1157
|
+
break; // the cap — the two walls' columns
|
|
1158
|
+
markerLine += row[i];
|
|
1159
|
+
w += cw;
|
|
1160
|
+
i += 1;
|
|
1161
|
+
}
|
|
1162
|
+
if (!inserted) {
|
|
1163
|
+
// the walk ended before the cursor cell (the box edge) — the
|
|
1164
|
+
// marker rests at the row's end; the move still lands AT it
|
|
1165
|
+
// (the min() of the contract)
|
|
1166
|
+
markerLine += CURSOR_MARKER;
|
|
1167
|
+
markerCell = w;
|
|
1142
1168
|
}
|
|
1143
1169
|
const stripped0 = markerLine.replace(CURSOR_MARKER, "");
|
|
1144
1170
|
if (W < 4) {
|
|
1145
1171
|
// the degenerate screen: the box cannot hold its walls — the
|
|
1146
1172
|
// bare row (the pre-W6 bytes; the fold probe's pass-through
|
|
1147
1173
|
// line still crashes invariant ① downstream, as before)
|
|
1148
|
-
return { stripped: stripped0,
|
|
1174
|
+
return { stripped: stripped0, markerCell };
|
|
1149
1175
|
}
|
|
1150
1176
|
// the pad completes the row to W — the content stopped at W−4,
|
|
1151
1177
|
// so the pad is ≥ 1
|
|
1152
1178
|
const padW = W - 3 - w;
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1179
|
+
return { stripped: `\x1b[2m│ \x1b[0m${stripped0}\x1b[2m${" ".repeat(padW)}│\x1b[0m`, markerCell };
|
|
1180
|
+
}
|
|
1181
|
+
/** KC1 §6 — the focus component's input ROWS (N = 1 today's single
|
|
1182
|
+
* row, byte for byte). The lead rides the FIRST row and the
|
|
1183
|
+
* continuations indent by its width, so the cursor's column formula
|
|
1184
|
+
* is the same on every row; the CURSOR'S row carries the marker and
|
|
1185
|
+
* the frame derives the cursor from it — row AND column — with no
|
|
1186
|
+
* editPos side channel.
|
|
1187
|
+
*
|
|
1188
|
+
* The rows arrive already windowed by the editor's §5 estimate; the
|
|
1189
|
+
* frame re-applies the SAME clamp against the REAL folded menu and
|
|
1190
|
+
* queue bands (N_visible's height term), keeping the cursor's row
|
|
1191
|
+
* in view — so the geometry is legal at every terminal size. */
|
|
1192
|
+
#inputRows(W, H, menuRows, queueRows) {
|
|
1193
|
+
const st = this.#inputState();
|
|
1194
|
+
const panel = this.#panelState?.() ?? null;
|
|
1195
|
+
// the lead — the panel's phase lead when the panel owns the row
|
|
1196
|
+
// (1-3> / the rule input's "2 Yes, don't ask again for " / the
|
|
1197
|
+
// amend "feedback (deny): "), the bound prompt otherwise
|
|
1198
|
+
const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.sel) : this.#inputPrompt;
|
|
1199
|
+
const leadW = leadWidth(lead);
|
|
1200
|
+
// a LEGACY one-row provider (the old {line, cursor} shape) keeps
|
|
1201
|
+
// working: its single line is the composer's single row
|
|
1202
|
+
let rows = st.lines !== undefined && st.lines.length > 0 ? [...st.lines] : [st.line];
|
|
1203
|
+
let cursorRow = Math.min(st.cursorRow ?? 0, rows.length - 1);
|
|
1204
|
+
const cursorCol = st.cursorCol ?? st.cursor;
|
|
1205
|
+
// KC1 §5's N_visible, re-applied against the frame's REAL bands:
|
|
1206
|
+
// the editor could only estimate the menu/queue heights (they
|
|
1207
|
+
// fold at width), so the frame is the authority. The window keeps
|
|
1208
|
+
// the CURSOR'S row, so a clamp never hides it.
|
|
1209
|
+
const n = Math.max(1, Math.min(rows.length, H - 3 - menuRows - queueRows));
|
|
1210
|
+
if (rows.length > n) {
|
|
1211
|
+
const first = Math.max(0, Math.min(cursorRow - n + 1, rows.length - n));
|
|
1212
|
+
rows = rows.slice(first, first + n);
|
|
1213
|
+
cursorRow -= first;
|
|
1214
|
+
}
|
|
1215
|
+
const out = [];
|
|
1216
|
+
let markerCol = 3;
|
|
1217
|
+
for (let r = 0; r < rows.length; r += 1) {
|
|
1218
|
+
const text = `${r === 0 ? lead : " ".repeat(leadW)}${rows[r]}`;
|
|
1219
|
+
const bytes = this.#inputRowBytes(text, W, r === cursorRow ? leadW + cursorCol : null);
|
|
1220
|
+
out.push(bytes.stripped);
|
|
1221
|
+
// W23: the frame-derived column — wallL (2) + the marker's
|
|
1222
|
+
// cell + 1 — the CHA lands the cursor AT the marker from ANY base
|
|
1223
|
+
if (r === cursorRow)
|
|
1224
|
+
markerCol = 3 + bytes.markerCell;
|
|
1225
|
+
}
|
|
1226
|
+
return { rows: out, markerRow: cursorRow, markerCol };
|
|
1158
1227
|
}
|
|
1159
1228
|
/** The full-redraw path (the first frame, the resize repaint) — CUP
|
|
1160
1229
|
* allowed here; zero LF; zero \x1b[3J; zero replay. The committed
|
|
@@ -1167,7 +1236,8 @@ export class Body {
|
|
|
1167
1236
|
* survive anywhere the draw does not touch; a draw that covers
|
|
1168
1237
|
* EVERY row is idempotent: N consecutive resizes end with the same
|
|
1169
1238
|
* screen as a single jump to the same size. */
|
|
1170
|
-
#drawFull(out, W, H, liveTop, liveLines, queueRows, menuRows,
|
|
1239
|
+
#drawFull(out, W, H, liveTop, liveLines, queueRows, menuRows, editor) {
|
|
1240
|
+
const inputExtra = editor.rows.length - 1; // KC1: the composer's rows above the retired single input row
|
|
1171
1241
|
const committed = this.#committedLinesThisFrame;
|
|
1172
1242
|
// 0. the FROZEN rows — the re-folded committed content (re-flowed
|
|
1173
1243
|
// at the new width by the terminal): re-painted at [1..frozen],
|
|
@@ -1194,7 +1264,7 @@ export class Body {
|
|
|
1194
1264
|
// window (the committed share + the live + the chrome), r
|
|
1195
1265
|
// monotone, every row 1..H re-painted (the V6-1 every-row rule).
|
|
1196
1266
|
const all = [...frozen, ...committed, ...liveLines];
|
|
1197
|
-
const skip = Math.max(0, all.length + CHROME_ROWS + queueRows.length + menuRows.length - H);
|
|
1267
|
+
const skip = Math.max(0, all.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
|
|
1198
1268
|
// A8b (the shrink-trigger's completion): the rows that LEAVE the
|
|
1199
1269
|
// window scroll into the terminal's scrollback — the LF mechanism
|
|
1200
1270
|
// (the steady path's own). Only the rows the paint re-covers (the
|
|
@@ -1237,41 +1307,44 @@ export class Body {
|
|
|
1237
1307
|
r += 1;
|
|
1238
1308
|
}
|
|
1239
1309
|
// 3. the GAP rows (between the live content and the chrome) — EL.
|
|
1240
|
-
for (let rr = r; rr <= H - 4; rr += 1) {
|
|
1310
|
+
for (let rr = r; rr <= H - 4 - inputExtra; rr += 1) {
|
|
1241
1311
|
out.push(`\x1b[${rr};1H\x1b[0K`);
|
|
1242
1312
|
}
|
|
1243
1313
|
// W22: the queue chips sit directly above the box top (the
|
|
1244
1314
|
// "pre-render ABOVE the input row"), the menu above the queue.
|
|
1245
|
-
const queueTop = H - 3 - queueRows.length;
|
|
1246
|
-
const menuTop =
|
|
1315
|
+
const queueTop = H - 3 - inputExtra - queueRows.length;
|
|
1316
|
+
const menuTop = queueTop - menuRows.length;
|
|
1247
1317
|
for (let i = 0; i < queueRows.length; i += 1) {
|
|
1248
1318
|
out.push(`\x1b[${queueTop + i};1H\x1b[0K${this.#checked(queueRows[i], W)}`);
|
|
1249
1319
|
}
|
|
1250
1320
|
for (let i = 0; i < menuRows.length; i += 1) {
|
|
1251
1321
|
out.push(`\x1b[${menuTop + i};1H\x1b[0K${this.#checked(menuRows[i], W)}`);
|
|
1252
1322
|
}
|
|
1253
|
-
// V6-3 + W6: the design §03 chrome — box top (H−
|
|
1254
|
-
// (H−2), box bottom (H−1),
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1323
|
+
// V6-3 + W6 + KC1 §6: the design §03 chrome — box top (H−2−N),
|
|
1324
|
+
// the composer's N input rows (H−1−N .. H−2), box bottom (H−1),
|
|
1325
|
+
// status (H). N = 1 is the retired four-row chrome exactly.
|
|
1326
|
+
out.push(`\x1b[${H - 3 - inputExtra};1H\x1b[0K${boxTop(W)}`);
|
|
1327
|
+
for (let i = 0; i < editor.rows.length; i += 1) {
|
|
1328
|
+
out.push(`\x1b[${H - 2 - inputExtra + i};1H\x1b[0K${this.#checked(editor.rows[i], W)}`);
|
|
1329
|
+
}
|
|
1258
1330
|
out.push(`\x1b[${H - 1};1H\x1b[0K${boxBottom(W)}`);
|
|
1259
1331
|
const statusRow = this.#statusSource();
|
|
1260
1332
|
out.push(`\x1b[${H};1H\x1b[0K${this.#checked(statusLine(statusRow.status, this.#tail, W, statusRow.hint), W)}`);
|
|
1261
|
-
// the cursor: up
|
|
1262
|
-
//
|
|
1263
|
-
//
|
|
1264
|
-
//
|
|
1265
|
-
//
|
|
1266
|
-
|
|
1333
|
+
// the cursor: up from the status row to the MARKER'S row inside
|
|
1334
|
+
// the composer (N = 1, markerRow 0 ⇒ the retired \x1b[2A) + the
|
|
1335
|
+
// CHA to the marker's frame-derived column — W23: the afterW CUB
|
|
1336
|
+
// retired (the CHA is absolute — the base is irrelevant; the
|
|
1337
|
+
// CUB's base was the LAST write's end column, which the steady
|
|
1338
|
+
// frame's ELs leave at col 1 — the A3 finding)
|
|
1339
|
+
out.push(`\x1b[${1 + editor.rows.length - editor.markerRow}A`);
|
|
1267
1340
|
out.push(`\x1b[${editor.markerCol}G`);
|
|
1268
1341
|
}
|
|
1269
1342
|
/** The steady-state frame — RELATIVE moves only (invariant ②); the
|
|
1270
1343
|
* commits scroll via the CUP-free real LF at the last row, and the
|
|
1271
1344
|
* committed lines write in the march's top section (rows
|
|
1272
1345
|
* [liveTop−N .. liveTop−1] — the frozen area's bottom). */
|
|
1273
|
-
#drawSteady(out, W, H, liveTop, liveLines, queueRows, menuRows,
|
|
1274
|
-
const
|
|
1346
|
+
#drawSteady(out, W, H, liveTop, liveLines, queueRows, menuRows, editor) {
|
|
1347
|
+
const inputExtra = editor.rows.length - 1; // KC1: the composer's rows above the retired single input row
|
|
1275
1348
|
const committed = this.#committedLinesThisFrame;
|
|
1276
1349
|
// A8b: the steady path's window geometry — the same skip as the full
|
|
1277
1350
|
// path's (#lastSkip's formula): the model rows above the window
|
|
@@ -1280,7 +1353,7 @@ export class Body {
|
|
|
1280
1353
|
// frozenCount, so the lines at [frozenCount..skip−1] are the fresh
|
|
1281
1354
|
// leaving share (their old-screen copies are stale).
|
|
1282
1355
|
const frozenCount = this.#committedLines - committed.length;
|
|
1283
|
-
const skip = Math.max(0, this.#committedLines + liveLines.length + CHROME_ROWS + queueRows.length + menuRows.length - H);
|
|
1356
|
+
const skip = Math.max(0, this.#committedLines + liveLines.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
|
|
1284
1357
|
const leaving = Math.max(0, skip - this.#lastSkip);
|
|
1285
1358
|
// the jump to the bottom row H, then N real LFs scroll the screen
|
|
1286
1359
|
// exactly N rows — ONE per committed line (the bookkeeping; the
|
|
@@ -1329,18 +1402,25 @@ export class Body {
|
|
|
1329
1402
|
out.push(`\x1b[${H};1H`); // CUP to the bottom — the absolute scroll base (the ELs moved the cursor)
|
|
1330
1403
|
}
|
|
1331
1404
|
else {
|
|
1332
|
-
|
|
1405
|
+
// KC1 §6: the anchor is where the LAST frame's CHA parked the
|
|
1406
|
+
// cursor — the marker's row inside the composer (N = 1 ⇒ H−2,
|
|
1407
|
+
// the retired \x1b[2B)
|
|
1408
|
+
const anchorRow = this.#lastAnchorRow > 0 ? this.#lastAnchorRow : H - 2;
|
|
1409
|
+
if (H > anchorRow)
|
|
1410
|
+
out.push(`\x1b[${H - anchorRow}B`);
|
|
1333
1411
|
}
|
|
1334
1412
|
for (let i = 0; i < committed.length; i += 1)
|
|
1335
1413
|
out.push("\n");
|
|
1336
|
-
// the bottom-up repaint, from the last row up — V6-3 + W6:
|
|
1337
|
-
// design §03 chrome: status (H), box bottom (H−1),
|
|
1338
|
-
// box top (H−
|
|
1414
|
+
// the bottom-up repaint, from the last row up — V6-3 + W6 + KC1:
|
|
1415
|
+
// the design §03 chrome: status (H), box bottom (H−1), the
|
|
1416
|
+
// composer's N input rows (H−2 up to H−1−N), box top (H−2−N)
|
|
1339
1417
|
const statusRow = this.#statusSource();
|
|
1340
1418
|
out.push(`\x1b[1G\x1b[0K${this.#checked(statusLine(statusRow.status, this.#tail, W, statusRow.hint), W)}`); // H — the status
|
|
1341
1419
|
out.push(`\x1b[1A\x1b[1G\x1b[0K${boxBottom(W)}`); // H−1 — the box bottom
|
|
1342
|
-
|
|
1343
|
-
|
|
1420
|
+
for (let i = editor.rows.length - 1; i >= 0; i -= 1) {
|
|
1421
|
+
out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(editor.rows[i], W)}`); // the input rows, bottom-up
|
|
1422
|
+
}
|
|
1423
|
+
out.push(`\x1b[1A\x1b[1G\x1b[0K${boxTop(W)}`); // H−2−N — the box top
|
|
1344
1424
|
// W22: the queue chips sit directly above the box top (the
|
|
1345
1425
|
// "pre-render ABOVE the input row"), the menu above the queue —
|
|
1346
1426
|
// the bottom-up order mirrors the row order.
|
|
@@ -1370,7 +1450,7 @@ export class Body {
|
|
|
1370
1450
|
// the queue + menu bands (their rows at [H−3−queue−menu..H−4]
|
|
1371
1451
|
// are marched and must survive — the unclamped geometry erased
|
|
1372
1452
|
// them).
|
|
1373
|
-
for (let r = liveTop + liveLines.length; r <= H - 4 - queueRows.length - menuRows.length; r += 1) {
|
|
1453
|
+
for (let r = liveTop + liveLines.length; r <= H - 4 - inputExtra - queueRows.length - menuRows.length; r += 1) {
|
|
1374
1454
|
out.push(`\x1b[${r};1H\x1b[0K`);
|
|
1375
1455
|
}
|
|
1376
1456
|
// 2. the STALE rows above the committed section — the scrolled old
|
|
@@ -1402,14 +1482,16 @@ export class Body {
|
|
|
1402
1482
|
? Math.max(1, liveTop - 1)
|
|
1403
1483
|
: staleFrom < liveTop
|
|
1404
1484
|
? liveTop - 1
|
|
1405
|
-
: liveTop + liveLines.length <= H - 4 - queueRows.length - menuRows.length
|
|
1406
|
-
? H - 4 - queueRows.length - menuRows.length
|
|
1485
|
+
: liveTop + liveLines.length <= H - 4 - inputExtra - queueRows.length - menuRows.length
|
|
1486
|
+
? H - 4 - inputExtra - queueRows.length - menuRows.length
|
|
1407
1487
|
: liveLines.length > 0
|
|
1408
1488
|
? liveTop + liveLines.length - 1
|
|
1409
1489
|
: menuRows.length > 0
|
|
1410
|
-
? H - 3 - menuRows.length - queueRows.length
|
|
1411
|
-
: H - 3;
|
|
1412
|
-
|
|
1490
|
+
? H - 3 - inputExtra - menuRows.length - queueRows.length
|
|
1491
|
+
: H - 3 - inputExtra;
|
|
1492
|
+
// the anchor: the MARKER'S row inside the composer (N = 1,
|
|
1493
|
+
// markerRow 0 ⇒ the retired H−2)
|
|
1494
|
+
const down = H - 2 - inputExtra + editor.markerRow - lastRow;
|
|
1413
1495
|
if (down > 0)
|
|
1414
1496
|
out.push(`\x1b[${down}B`);
|
|
1415
1497
|
// W23: the CHA to the frame-derived column — the cursor rests AT
|
package/dist/editor.d.ts
CHANGED
|
@@ -13,8 +13,13 @@
|
|
|
13
13
|
* clusters (family emoji etc.) are not guaranteed perfect — each code
|
|
14
14
|
* point counts as its width.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* KC1 (the multi-line composer): the buffer is FLAT — 0x0A is a stored
|
|
17
|
+
* code point in #chars, and the lines, the cursor's row/column and the
|
|
18
|
+
* visible window are all DERIVED per read (never a second mutable
|
|
19
|
+
* model, so every existing op — insert, kills, history stash, queue-pop
|
|
20
|
+
* replace, panel stash/restore — works unchanged). Bracketed paste
|
|
21
|
+
* (?2004h) unwraps and inserts its newlines LITERALLY; every newline
|
|
22
|
+
* source funnels through the ONE normalizer in feed() (§3).
|
|
18
23
|
*/
|
|
19
24
|
import { charWidth, displayWidth, widthOf } from "./width.js";
|
|
20
25
|
export { charWidth, displayWidth, widthOf };
|
|
@@ -43,6 +48,11 @@ export declare class Editor {
|
|
|
43
48
|
onEot(cb: () => void): void;
|
|
44
49
|
onEscape(cb: () => void): void;
|
|
45
50
|
onExpand(cb: () => void): void;
|
|
51
|
+
/** KC2 §2: the redirect chain — the gesture hands the buffer's text
|
|
52
|
+
* over while the run is told to stop. Mirrors onEscape (a list, so
|
|
53
|
+
* listeners can coexist); the line arrives already gone from the
|
|
54
|
+
* composer, exactly as a submit's does. */
|
|
55
|
+
onRedirect(cb: (line: string) => void): void;
|
|
46
56
|
/** W22: bind the pending-turn queue — the CLI's live slots. The ↑
|
|
47
57
|
* pop walks them (each pop leaves the queue, cancelling the turn);
|
|
48
58
|
* esc ends the walk after one more pop. */
|
|
@@ -50,11 +60,23 @@ export declare class Editor {
|
|
|
50
60
|
/** The whole buffer as text (the CLI's line()/clearLine()). */
|
|
51
61
|
line(): string;
|
|
52
62
|
clearLine(): void;
|
|
53
|
-
/** The
|
|
54
|
-
*
|
|
63
|
+
/** The dock's input-row state — ADDITIVE (§5): `line` + `cursor` keep
|
|
64
|
+
* their legacy meaning (the CURSOR LINE's visible slice and the
|
|
65
|
+
* cursor's display column in it — a single-line buffer yields
|
|
66
|
+
* today's exact values, and a legacy one-row consumer keeps
|
|
67
|
+
* working), and the composer's own view rides beside them.
|
|
68
|
+
*
|
|
69
|
+
* The window is DERIVED per read — no persistent #vscroll:
|
|
70
|
+
* visibleStart = clamp(cursorLine − N_visible + 1, 0, lineCount −
|
|
71
|
+
* N_visible), so it trails the cursor, can never hide it, and no
|
|
72
|
+
* stash / restore / clear / submit path has new state to carry. A
|
|
73
|
+
* dim "…" marks whichever edge hides rows. */
|
|
55
74
|
dockState(): {
|
|
56
75
|
line: string;
|
|
57
76
|
cursor: number;
|
|
77
|
+
lines: string[];
|
|
78
|
+
cursorRow: number;
|
|
79
|
+
cursorCol: number;
|
|
58
80
|
};
|
|
59
81
|
/** v3 §04: the menu's visible state for the dock — null when closed. */
|
|
60
82
|
menuState(): {
|
package/dist/editor.js
CHANGED
|
@@ -13,8 +13,13 @@
|
|
|
13
13
|
* clusters (family emoji etc.) are not guaranteed perfect — each code
|
|
14
14
|
* point counts as its width.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* KC1 (the multi-line composer): the buffer is FLAT — 0x0A is a stored
|
|
17
|
+
* code point in #chars, and the lines, the cursor's row/column and the
|
|
18
|
+
* visible window are all DERIVED per read (never a second mutable
|
|
19
|
+
* model, so every existing op — insert, kills, history stash, queue-pop
|
|
20
|
+
* replace, panel stash/restore — works unchanged). Bracketed paste
|
|
21
|
+
* (?2004h) unwraps and inserts its newlines LITERALLY; every newline
|
|
22
|
+
* source funnels through the ONE normalizer in feed() (§3).
|
|
18
23
|
*/
|
|
19
24
|
import { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
|
|
20
25
|
// the width primitives moved to width.ts (W1, the single width
|
|
@@ -36,6 +41,17 @@ export const MENU_ITEMS = [
|
|
|
36
41
|
{ name: "/status", desc: "show session id, event count, and context estimate" },
|
|
37
42
|
{ name: "/help", desc: "print this list of commands" },
|
|
38
43
|
];
|
|
44
|
+
/** KC1 §3 — the newline code point. Every source (paste, Ctrl+J, the
|
|
45
|
+
* Shift+Enter encodings, a CRLF pair) normalizes to exactly ONE. */
|
|
46
|
+
const NEWLINE = 0x0a;
|
|
47
|
+
/** KC1 §5 — the composer's CEILING (adjudication A1): at most 6 visible
|
|
48
|
+
* rows. A ceiling only — N_visible clamps by the terminal's height so
|
|
49
|
+
* the geometry stays legal down to the compositor's enter gate (H = 4
|
|
50
|
+
* ⇒ one row, exactly today's minimum). */
|
|
51
|
+
const N_MAX = 6;
|
|
52
|
+
/** The dim "…" — the ONE truncation mark: the horizontal scroll's
|
|
53
|
+
* prefix (unchanged) and the viewport's hidden-rows markers. */
|
|
54
|
+
const ELLIPSIS = "\x1b[2m…\x1b[0m";
|
|
39
55
|
/**
|
|
40
56
|
* The editor. Raw mode + bracketed paste (?2004h) on enter, restored on
|
|
41
57
|
* exit. The input row is rendered by `onRender` (the CLI wires it to the
|
|
@@ -46,7 +62,13 @@ export const MENU_ITEMS = [
|
|
|
46
62
|
export class Editor {
|
|
47
63
|
#chars = [];
|
|
48
64
|
#cursor = 0;
|
|
49
|
-
#scroll = 0; // chars scrolled off the left (width-based reflow)
|
|
65
|
+
#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)
|
|
66
|
+
// KC1 §2 — the ONE new ephemeral field: the desired column for the
|
|
67
|
+
// ↑/↓ walk (a long line's column 20 → a short line clamps to 5 → the
|
|
68
|
+
// next long line RETURNS to 20). Set on the first vertical move,
|
|
69
|
+
// kept across consecutive ones, reset by any horizontal move, insert
|
|
70
|
+
// or delete. Never stashed — it is a walk's state, not the buffer's.
|
|
71
|
+
#verticalGoalCol = null;
|
|
50
72
|
#questionCb = null;
|
|
51
73
|
// W21: the panel state machine — the approval/trust panel owns the
|
|
52
74
|
// interaction while up: the digit/y/n/esc/tab routing, the rule
|
|
@@ -65,6 +87,12 @@ export class Editor {
|
|
|
65
87
|
// (dispatch) coexist; a listener removes itself via an unarmed guard
|
|
66
88
|
// (the compact's handler no-ops after its abort has fired).
|
|
67
89
|
#escapeCbs = [];
|
|
90
|
+
// KC2 §2: the redirect LIST — mirrors #escapeCbs. The editor FORWARDS
|
|
91
|
+
// the gesture with the buffer's text; it never interprets it. What a
|
|
92
|
+
// redirect MEANS (abort the run, then run THIS ahead of the queue) is
|
|
93
|
+
// the CLI's — here it is only "these two keys, pressed together, hand
|
|
94
|
+
// the line over by a different door than Enter's".
|
|
95
|
+
#redirectCbs = [];
|
|
68
96
|
// W15: the expand-key list (ctrl+r) — the CLI's dispatch decides the
|
|
69
97
|
// target (a live cell toggles in place; a committed cell appends the
|
|
70
98
|
// expanded block). Mirrors the escape list: multiple listeners can
|
|
@@ -122,6 +150,13 @@ export class Editor {
|
|
|
122
150
|
onExpand(cb) {
|
|
123
151
|
this.#expandCbs.push(cb);
|
|
124
152
|
}
|
|
153
|
+
/** KC2 §2: the redirect chain — the gesture hands the buffer's text
|
|
154
|
+
* over while the run is told to stop. Mirrors onEscape (a list, so
|
|
155
|
+
* listeners can coexist); the line arrives already gone from the
|
|
156
|
+
* composer, exactly as a submit's does. */
|
|
157
|
+
onRedirect(cb) {
|
|
158
|
+
this.#redirectCbs.push(cb);
|
|
159
|
+
}
|
|
125
160
|
/** W22: bind the pending-turn queue — the CLI's live slots. The ↑
|
|
126
161
|
* pop walks them (each pop leaves the queue, cancelling the turn);
|
|
127
162
|
* esc ends the walk after one more pop. */
|
|
@@ -137,15 +172,86 @@ export class Editor {
|
|
|
137
172
|
this.#chars = [];
|
|
138
173
|
this.#cursor = 0;
|
|
139
174
|
this.#scroll = 0;
|
|
175
|
+
this.#verticalGoalCol = null;
|
|
140
176
|
this.#onRender();
|
|
141
177
|
}
|
|
142
|
-
|
|
143
|
-
|
|
178
|
+
// ---- KC1 §5: the DERIVED line model (the buffer stays FLAT) ----
|
|
179
|
+
/** The lines as [start, end) index pairs — the 0x0A itself EXCLUDED.
|
|
180
|
+
* A buffer without a newline is exactly ONE line spanning the whole
|
|
181
|
+
* buffer: today's shape, derived. */
|
|
182
|
+
#lineBounds() {
|
|
183
|
+
const out = [];
|
|
184
|
+
let start = 0;
|
|
185
|
+
for (let i = 0; i < this.#chars.length; i += 1) {
|
|
186
|
+
if (this.#chars[i] === NEWLINE) {
|
|
187
|
+
out.push({ start, end: i });
|
|
188
|
+
start = i + 1;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
out.push({ start, end: this.#chars.length });
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
/** The cursor's line index — the first line whose end it has not
|
|
195
|
+
* passed (a cursor resting ON a newline belongs to the line that
|
|
196
|
+
* newline closes, never to the next one). */
|
|
197
|
+
#cursorLine(bounds) {
|
|
198
|
+
for (let i = 0; i < bounds.length; i += 1) {
|
|
199
|
+
if (this.#cursor <= bounds[i].end)
|
|
200
|
+
return i;
|
|
201
|
+
}
|
|
202
|
+
return bounds.length - 1;
|
|
203
|
+
}
|
|
204
|
+
/** The cursor's OWN line — the unit of the horizontal scroll and of
|
|
205
|
+
* the line-local A/E/U/K (A3). */
|
|
206
|
+
#cursorBounds() {
|
|
207
|
+
const bounds = this.#lineBounds();
|
|
208
|
+
return bounds[this.#cursorLine(bounds)];
|
|
209
|
+
}
|
|
210
|
+
/** KC1 §5 — N_visible = min(lineCount, N_MAX, max(1, H − 3 − the
|
|
211
|
+
* menu/queue bands)). The height clamp guarantees legal geometry
|
|
212
|
+
* down to the compositor's enter gate; the compositor re-applies the
|
|
213
|
+
* SAME formula against the frame's real bands (it alone knows their
|
|
214
|
+
* folded row counts), so this is the editor's honest estimate and
|
|
215
|
+
* the frame's clamp is the authority. */
|
|
216
|
+
#visibleRows(lineCount) {
|
|
217
|
+
const H = process.stdout.rows ?? 24;
|
|
218
|
+
const bands = (this.#menuOpen ? this.#menuFiltered().length : 0) + this.#queueState().length;
|
|
219
|
+
return Math.max(1, Math.min(lineCount, N_MAX, Math.max(1, H - 3 - bands)));
|
|
220
|
+
}
|
|
221
|
+
/** The dock's input-row state — ADDITIVE (§5): `line` + `cursor` keep
|
|
222
|
+
* their legacy meaning (the CURSOR LINE's visible slice and the
|
|
223
|
+
* cursor's display column in it — a single-line buffer yields
|
|
224
|
+
* today's exact values, and a legacy one-row consumer keeps
|
|
225
|
+
* working), and the composer's own view rides beside them.
|
|
226
|
+
*
|
|
227
|
+
* The window is DERIVED per read — no persistent #vscroll:
|
|
228
|
+
* visibleStart = clamp(cursorLine − N_visible + 1, 0, lineCount −
|
|
229
|
+
* N_visible), so it trails the cursor, can never hide it, and no
|
|
230
|
+
* stash / restore / clear / submit path has new state to carry. A
|
|
231
|
+
* dim "…" marks whichever edge hides rows. */
|
|
144
232
|
dockState() {
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
const
|
|
148
|
-
|
|
233
|
+
const bounds = this.#lineBounds();
|
|
234
|
+
const cursorLine = this.#cursorLine(bounds);
|
|
235
|
+
const n = this.#visibleRows(bounds.length);
|
|
236
|
+
const first = Math.max(0, Math.min(cursorLine - n + 1, bounds.length - n));
|
|
237
|
+
const lines = [];
|
|
238
|
+
for (let i = first; i < first + n; i += 1) {
|
|
239
|
+
const b = bounds[i];
|
|
240
|
+
// the cursor's own row carries the horizontal scroll (and its
|
|
241
|
+
// "…"); the other rows render whole and cap at the frame's wall
|
|
242
|
+
const from = i === cursorLine ? b.start + this.#scroll : b.start;
|
|
243
|
+
const scrolled = i === cursorLine && this.#scroll > 0 ? ELLIPSIS : "";
|
|
244
|
+
const above = i === first && first > 0 ? ELLIPSIS : "";
|
|
245
|
+
const below = i === first + n - 1 && first + n < bounds.length ? ELLIPSIS : "";
|
|
246
|
+
lines.push(`${above}${scrolled}${String.fromCodePoint(...this.#chars.slice(from, b.end))}${below}`);
|
|
247
|
+
}
|
|
248
|
+
const cursorRow = cursorLine - first;
|
|
249
|
+
// the window trails the cursor, so the hidden-above marker can only
|
|
250
|
+
// share the cursor's row in the degenerate one-row window (a tiny
|
|
251
|
+
// terminal) — where it shifts the column like the scroll's does
|
|
252
|
+
const marks = (cursorRow === 0 && first > 0 ? 1 : 0) + (this.#scroll > 0 ? 1 : 0);
|
|
253
|
+
const cursorCol = marks + widthOf(this.#chars.slice(bounds[cursorLine].start + this.#scroll, this.#cursor));
|
|
254
|
+
return { line: lines[cursorRow], cursor: cursorCol, lines, cursorRow, cursorCol };
|
|
149
255
|
}
|
|
150
256
|
/** v3 §04: the menu's visible state for the dock — null when closed. */
|
|
151
257
|
menuState() {
|
|
@@ -194,6 +300,7 @@ export class Editor {
|
|
|
194
300
|
this.#chars = [];
|
|
195
301
|
this.#cursor = 0;
|
|
196
302
|
this.#scroll = 0;
|
|
303
|
+
this.#verticalGoalCol = null;
|
|
197
304
|
this.#menuOpen = false;
|
|
198
305
|
this.#menuSel = 0;
|
|
199
306
|
this.#queuePopMode = false; // W22: the panel owns the keys while up
|
|
@@ -314,6 +421,17 @@ export class Editor {
|
|
|
314
421
|
else if (rest.startsWith("O")) {
|
|
315
422
|
i += 3; // SS3 (function keys) — ignored
|
|
316
423
|
}
|
|
424
|
+
else if (rest.startsWith("\x0d") && this.#composerIdle()) {
|
|
425
|
+
// KC2 §2 — Alt+Enter. A terminal sends Alt+X as ESC and X in
|
|
426
|
+
// ONE write, so SAME-CHUNK is the whole test: no timer, no
|
|
427
|
+
// hold, nothing parked. The identical two bytes arriving in
|
|
428
|
+
// SEPARATE chunks are NOT combined — they fall to the branch
|
|
429
|
+
// below, where the bare Esc fires at once (its immediacy is
|
|
430
|
+
// exactly what a hold would spend) and the next chunk's CR
|
|
431
|
+
// submits: today's two gestures, untouched.
|
|
432
|
+
this.#redirect();
|
|
433
|
+
i += 2; // both bytes belong to the one gesture
|
|
434
|
+
}
|
|
317
435
|
else if (this.#menuOpen) {
|
|
318
436
|
// v3 §04: Esc closes the menu and clears the buffer.
|
|
319
437
|
// CA-4: the closing esc consumes its burst (the `i += 1`
|
|
@@ -321,6 +439,7 @@ export class Editor {
|
|
|
321
439
|
this.#chars = [];
|
|
322
440
|
this.#cursor = 0;
|
|
323
441
|
this.#scroll = 0;
|
|
442
|
+
this.#verticalGoalCol = null;
|
|
324
443
|
this.#refreshMenu();
|
|
325
444
|
i += 1;
|
|
326
445
|
}
|
|
@@ -350,13 +469,27 @@ export class Editor {
|
|
|
350
469
|
}
|
|
351
470
|
}
|
|
352
471
|
else if (c === "\x0d" || c === "\x0a") {
|
|
353
|
-
|
|
354
|
-
|
|
472
|
+
// KC1 §3 — the ONE newline normalizer. Inside a paste every
|
|
473
|
+
// boundary (LF, CR, CRLF) becomes EXACTLY one 0x0A; a paste's
|
|
474
|
+
// trailing CR at a CHUNK boundary parks in #pending (the
|
|
475
|
+
// existing CSI-resume mechanism) and resolves against the next
|
|
476
|
+
// chunk's leading LF, so a CR|LF pair split by the stdin read
|
|
477
|
+
// is still ONE newline. Outside a paste: Ctrl+J (LF) inserts,
|
|
478
|
+
// Enter (CR) submits — and a typed CRLF pair submits ONCE (the
|
|
479
|
+
// LF is consumed with it, never landing in the fresh buffer).
|
|
480
|
+
// A lone interactive CR never parks: the submit is immediate.
|
|
481
|
+
if (c === "\x0d" && i + 1 === text.length && this.#pasting) {
|
|
482
|
+
this.#pending = text.slice(i);
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
const consumed = c === "\x0d" && text[i + 1] === "\x0a" ? 2 : 1;
|
|
486
|
+
if (this.#pasting || c === "\x0a") {
|
|
487
|
+
this.#insert(NEWLINE);
|
|
355
488
|
}
|
|
356
489
|
else {
|
|
357
490
|
this.#submit();
|
|
358
491
|
}
|
|
359
|
-
i +=
|
|
492
|
+
i += consumed;
|
|
360
493
|
}
|
|
361
494
|
else if (c === "\x7f" || c === "\x08") {
|
|
362
495
|
this.#backspace();
|
|
@@ -383,13 +516,13 @@ export class Editor {
|
|
|
383
516
|
i += 1;
|
|
384
517
|
}
|
|
385
518
|
else if (c === "\x01") {
|
|
386
|
-
this.#cursor = 0
|
|
519
|
+
this.#cursor = this.#cursorBounds().start; // A3: line-local (a single line starts at 0 — unchanged)
|
|
387
520
|
this.#reflow();
|
|
388
521
|
this.#onRender();
|
|
389
522
|
i += 1;
|
|
390
523
|
}
|
|
391
524
|
else if (c === "\x05") {
|
|
392
|
-
this.#cursor = this.#
|
|
525
|
+
this.#cursor = this.#cursorBounds().end; // A3: line-local (a single line ends at the buffer's end)
|
|
393
526
|
this.#reflow();
|
|
394
527
|
this.#onRender();
|
|
395
528
|
i += 1;
|
|
@@ -423,6 +556,27 @@ export class Editor {
|
|
|
423
556
|
}
|
|
424
557
|
}
|
|
425
558
|
#csi(params, final) {
|
|
559
|
+
// KC1 §4 — Shift+Enter WHERE THE TERMINAL ENCODES IT: kitty's
|
|
560
|
+
// CSI-u (ESC [ 13;2 u) and xterm's modifyOtherKeys (ESC [ 27;2;13 ~).
|
|
561
|
+
// Never claimed universal — Ctrl+J is the everywhere baseline; a
|
|
562
|
+
// terminal that sends neither simply never reaches this row. The
|
|
563
|
+
// chunk-split safety is the existing #pending CSI resume.
|
|
564
|
+
if ((final === "u" && params === "13;2") || (final === "~" && params === "27;2;13")) {
|
|
565
|
+
this.#insert(NEWLINE);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
// KC2 §2 — Ctrl+Enter, the SAME two encodings with modifier 5
|
|
569
|
+
// (1 + ctrl): kitty's CSI-u and xterm's modifyOtherKeys. Never
|
|
570
|
+
// claimed universal — a terminal that encodes neither sends a plain
|
|
571
|
+
// CR, which is an ordinary submit/queue (the safe degrade). The
|
|
572
|
+
// chunk-split safety is the existing #pending CSI resume, shared
|
|
573
|
+
// with Shift+Enter above. Outside the normal composer state the
|
|
574
|
+
// sequence is simply unknown, exactly like any other stray CSI.
|
|
575
|
+
if ((final === "u" && params === "13;5") || (final === "~" && params === "27;5;13")) {
|
|
576
|
+
if (this.#composerIdle())
|
|
577
|
+
this.#redirect();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
426
580
|
if (final === "~") {
|
|
427
581
|
const n = Number(params);
|
|
428
582
|
if (n === 3)
|
|
@@ -450,6 +604,13 @@ export class Editor {
|
|
|
450
604
|
else
|
|
451
605
|
this.#menuSel = Math.min(this.#menuFiltered().length - 1, this.#menuSel + 1);
|
|
452
606
|
}
|
|
607
|
+
else if (this.#chars.includes(NEWLINE)) {
|
|
608
|
+
// KC1 §4: a MULTI-LINE buffer's ↑↓ walk its lines. The
|
|
609
|
+
// history and the queue-pop below stay gated on an EMPTY
|
|
610
|
+
// buffer — a multi-line buffer is never empty, so the
|
|
611
|
+
// precedence can only ever add, never take.
|
|
612
|
+
this.#verticalMove(final === "A" ? -1 : 1);
|
|
613
|
+
}
|
|
453
614
|
else if (final === "A" && this.#queuePop !== null && (this.#queuePopMode || this.line() === "") && this.#queueState().length > 0) {
|
|
454
615
|
// W22: ↑ pops the LAST queued message into the buffer — the
|
|
455
616
|
// walk: repeated presses pop older ones (each replaces the
|
|
@@ -471,14 +632,32 @@ export class Editor {
|
|
|
471
632
|
this.#move(1);
|
|
472
633
|
}
|
|
473
634
|
else if (final === "H") {
|
|
474
|
-
this.#cursor =
|
|
635
|
+
this.#cursor = this.#cursorBounds().start; // A3: Home follows Ctrl+A — line-local
|
|
475
636
|
this.#reflow();
|
|
476
637
|
}
|
|
477
638
|
else if (final === "F") {
|
|
478
|
-
this.#cursor = this.#
|
|
639
|
+
this.#cursor = this.#cursorBounds().end; // A3: End follows Ctrl+E — line-local
|
|
479
640
|
this.#reflow();
|
|
480
641
|
}
|
|
481
642
|
}
|
|
643
|
+
/** KC1 §4 — the ↑/↓ walk. The cursor keeps its DESIRED column across
|
|
644
|
+
* a short line: the goal is captured at the FIRST vertical move and
|
|
645
|
+
* survives consecutive ones (#reflow clears it, so any horizontal
|
|
646
|
+
* move / insert / delete ends the walk); a step past either end
|
|
647
|
+
* stays put. */
|
|
648
|
+
#verticalMove(delta) {
|
|
649
|
+
const bounds = this.#lineBounds();
|
|
650
|
+
const cur = this.#cursorLine(bounds);
|
|
651
|
+
const next = cur + delta;
|
|
652
|
+
if (next < 0 || next >= bounds.length)
|
|
653
|
+
return;
|
|
654
|
+
const from = bounds[cur];
|
|
655
|
+
const goal = this.#verticalGoalCol ?? widthOf(this.#chars.slice(from.start, this.#cursor));
|
|
656
|
+
const to = bounds[next];
|
|
657
|
+
this.#cursor = this.#indexAtWidth(to.start, to.end, goal);
|
|
658
|
+
this.#reflow();
|
|
659
|
+
this.#verticalGoalCol = goal; // the walk re-arms it (the reflow's reset is for every OTHER key)
|
|
660
|
+
}
|
|
482
661
|
// ---- W21: the panel state machine ----
|
|
483
662
|
#panelSelect(sel) {
|
|
484
663
|
const panel = this.#panel;
|
|
@@ -498,6 +677,7 @@ export class Editor {
|
|
|
498
677
|
this.#chars = [...panel.view.name].map((ch) => ch.codePointAt(0));
|
|
499
678
|
this.#cursor = this.#chars.length;
|
|
500
679
|
this.#scroll = 0;
|
|
680
|
+
this.#verticalGoalCol = null;
|
|
501
681
|
this.#onRender();
|
|
502
682
|
}
|
|
503
683
|
/** tab — the amend phase on the selected option (yes/deny); the
|
|
@@ -511,6 +691,7 @@ export class Editor {
|
|
|
511
691
|
this.#chars = [];
|
|
512
692
|
this.#cursor = 0;
|
|
513
693
|
this.#scroll = 0;
|
|
694
|
+
this.#verticalGoalCol = null;
|
|
514
695
|
this.#onRender();
|
|
515
696
|
}
|
|
516
697
|
/** esc — back out of the rule/amend to the options (the buffer
|
|
@@ -525,6 +706,7 @@ export class Editor {
|
|
|
525
706
|
this.#chars = [];
|
|
526
707
|
this.#cursor = 0;
|
|
527
708
|
this.#scroll = 0;
|
|
709
|
+
this.#verticalGoalCol = null;
|
|
528
710
|
this.#onRender();
|
|
529
711
|
return;
|
|
530
712
|
}
|
|
@@ -610,18 +792,23 @@ export class Editor {
|
|
|
610
792
|
this.#onRender();
|
|
611
793
|
}
|
|
612
794
|
#killToStart() {
|
|
613
|
-
this.#
|
|
614
|
-
this.#cursor
|
|
795
|
+
const { start } = this.#cursorBounds(); // A3: line-local (0 on a single line — unchanged)
|
|
796
|
+
this.#chars.splice(start, this.#cursor - start);
|
|
797
|
+
this.#cursor = start;
|
|
615
798
|
this.#reflow();
|
|
616
799
|
if (!this.#pasting)
|
|
617
800
|
this.#onRender();
|
|
618
801
|
}
|
|
619
802
|
#killToEnd() {
|
|
620
|
-
|
|
803
|
+
const { end } = this.#cursorBounds(); // A3: line-local (the buffer's end on a single line — unchanged)
|
|
804
|
+
this.#chars.splice(this.#cursor, end - this.#cursor);
|
|
621
805
|
this.#reflow();
|
|
622
806
|
if (!this.#pasting)
|
|
623
807
|
this.#onRender();
|
|
624
808
|
}
|
|
809
|
+
/** Ctrl+W — the word kill. The newline rides as a non-space code
|
|
810
|
+
* point (a kill at a line's start joins it to the one above, the
|
|
811
|
+
* readline behavior); A3 scopes A/E/U/K, not W. */
|
|
625
812
|
#killWord() {
|
|
626
813
|
let i = this.#cursor;
|
|
627
814
|
while (i > 0 && this.#chars[i - 1] === 0x20)
|
|
@@ -632,8 +819,76 @@ export class Editor {
|
|
|
632
819
|
this.#cursor = i;
|
|
633
820
|
this.#reflow();
|
|
634
821
|
}
|
|
822
|
+
/** KC1/KC2 — the buffer LEAVES: the flat chars, the cursor, the
|
|
823
|
+
* horizontal scroll, the ↑/↓ goal, the menu and the pop-walk all
|
|
824
|
+
* reset together (W22: a departing line ends the pop-walk, so the
|
|
825
|
+
* next esc at rest interrupts again). Shared by the submit and the
|
|
826
|
+
* redirect — the two doors a line can leave by. */
|
|
827
|
+
#takeLine() {
|
|
828
|
+
const line = String.fromCodePoint(...this.#chars);
|
|
829
|
+
this.#chars = [];
|
|
830
|
+
this.#cursor = 0;
|
|
831
|
+
this.#scroll = 0;
|
|
832
|
+
this.#verticalGoalCol = null;
|
|
833
|
+
this.#menuOpen = false;
|
|
834
|
+
this.#menuSel = 0;
|
|
835
|
+
this.#queuePopMode = false;
|
|
836
|
+
return line;
|
|
837
|
+
}
|
|
838
|
+
/** A2: the history remembers submitted TURN lines — never question
|
|
839
|
+
* answers, never empties; adjacent duplicates collapse, the tail
|
|
840
|
+
* caps at 100. A redirect is a turn, so it is remembered too. */
|
|
841
|
+
#remember(line) {
|
|
842
|
+
if (this.#history[this.#history.length - 1] !== line)
|
|
843
|
+
this.#history.push(line);
|
|
844
|
+
if (this.#history.length > 100)
|
|
845
|
+
this.#history.shift();
|
|
846
|
+
}
|
|
847
|
+
/** KC2 §2 — the NORMAL composer state: the redirect gesture is live
|
|
848
|
+
* ONLY here. The approval panel, the slash menu, the history browse
|
|
849
|
+
* and the queue-pop walk each OWN their keys first (the W21 "the
|
|
850
|
+
* panel owns the keys" design, restated as a gate); a pending
|
|
851
|
+
* question is the panel's dock-less twin (askPanel routes to
|
|
852
|
+
* question() when the dock cannot render, so the ask owns the keys
|
|
853
|
+
* there too); and a bracketed paste is literal TEXT, where an ESC CR
|
|
854
|
+
* is the pasted content's own bytes and never a keypress. In every
|
|
855
|
+
* one of those states the two bytes fall through to today's
|
|
856
|
+
* handling — two gestures, unchanged. */
|
|
857
|
+
#composerIdle() {
|
|
858
|
+
return (this.#panel === null &&
|
|
859
|
+
!this.#menuOpen &&
|
|
860
|
+
this.#historyIdx === null &&
|
|
861
|
+
!this.#queuePopMode &&
|
|
862
|
+
!this.#pasting &&
|
|
863
|
+
this.#questionCb === null);
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* KC2 §2 — the gesture's meaning, kept as small as it can honestly be.
|
|
867
|
+
*
|
|
868
|
+
* An EMPTY buffer carries no correction, so the gesture degenerates to
|
|
869
|
+
* the bare Esc: the abort alone, nothing submitted. With text, the
|
|
870
|
+
* line leaves exactly as a submit's does and the listeners decide (the
|
|
871
|
+
* CLI aborts a live run and front-jumps the correction; idle, it is
|
|
872
|
+
* simply an Enter). UNWIRED — the recovery flow never binds it — the
|
|
873
|
+
* gesture IS a submit: a line is never lost to a missing binding.
|
|
874
|
+
*/
|
|
875
|
+
#redirect() {
|
|
876
|
+
if (this.#chars.length === 0) {
|
|
877
|
+
for (const cb of [...this.#escapeCbs])
|
|
878
|
+
cb();
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (this.#redirectCbs.length === 0) {
|
|
882
|
+
this.#submit();
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
const line = this.#takeLine();
|
|
886
|
+
this.#remember(line);
|
|
887
|
+
for (const cb of [...this.#redirectCbs])
|
|
888
|
+
cb(line);
|
|
889
|
+
this.#onRender();
|
|
890
|
+
}
|
|
635
891
|
#submit() {
|
|
636
|
-
let line = String.fromCodePoint(...this.#chars);
|
|
637
892
|
if (this.#menuOpen) {
|
|
638
893
|
// A1 (the feel): Enter submits the EXACT selection directly; a
|
|
639
894
|
// PARTIAL selection COMPLETES the buffer (the Tab semantics)
|
|
@@ -641,7 +896,7 @@ export class Editor {
|
|
|
641
896
|
// again. The old behavior executed the completed command on
|
|
642
897
|
// the first Enter, before the user had seen the completion.
|
|
643
898
|
const m = this.#menuFiltered()[this.#menuSel];
|
|
644
|
-
if (m !== undefined && m.name !== line) {
|
|
899
|
+
if (m !== undefined && m.name !== this.line()) {
|
|
645
900
|
this.#chars = [...m.name].map((ch) => ch.codePointAt(0));
|
|
646
901
|
this.#cursor = this.#chars.length;
|
|
647
902
|
this.#reflow();
|
|
@@ -650,12 +905,7 @@ export class Editor {
|
|
|
650
905
|
return; // completed, not executed
|
|
651
906
|
}
|
|
652
907
|
}
|
|
653
|
-
|
|
654
|
-
this.#cursor = 0;
|
|
655
|
-
this.#scroll = 0;
|
|
656
|
-
this.#menuOpen = false;
|
|
657
|
-
this.#menuSel = 0;
|
|
658
|
-
this.#queuePopMode = false; // W22: a submit ends the pop-walk — the next esc at rest interrupts again
|
|
908
|
+
const line = this.#takeLine();
|
|
659
909
|
const cb = this.#questionCb;
|
|
660
910
|
this.#questionCb = null;
|
|
661
911
|
if (cb !== null) {
|
|
@@ -667,14 +917,8 @@ export class Editor {
|
|
|
667
917
|
else {
|
|
668
918
|
this.#pendingLines.push(line); // nobody wired yet — hold it
|
|
669
919
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
if (cb === null && line !== "") {
|
|
673
|
-
if (this.#history[this.#history.length - 1] !== line)
|
|
674
|
-
this.#history.push(line);
|
|
675
|
-
if (this.#history.length > 100)
|
|
676
|
-
this.#history.shift();
|
|
677
|
-
}
|
|
920
|
+
if (cb === null && line !== "")
|
|
921
|
+
this.#remember(line);
|
|
678
922
|
this.#onRender();
|
|
679
923
|
}
|
|
680
924
|
/** A2: step the history browse; a delta past the newest exits back to
|
|
@@ -716,10 +960,14 @@ export class Editor {
|
|
|
716
960
|
this.#chars = [...line].map((ch) => ch.codePointAt(0));
|
|
717
961
|
this.#cursor = this.#chars.length;
|
|
718
962
|
this.#scroll = 0;
|
|
963
|
+
this.#verticalGoalCol = null;
|
|
719
964
|
this.#onRender();
|
|
720
965
|
}
|
|
721
966
|
// ---- width-based horizontal scroll ----
|
|
722
967
|
#reflow() {
|
|
968
|
+
// KC1: any key that reaches the reflow ended a ↑/↓ walk (the walk
|
|
969
|
+
// itself re-arms the goal right after its own reflow call).
|
|
970
|
+
this.#verticalGoalCol = null;
|
|
723
971
|
const W = (process.stdout.columns ?? 0) || 80; // degenerate 0 falls back to 80
|
|
724
972
|
// W21: the panel's phase lead owns the input row while up — the
|
|
725
973
|
// line's max width follows the lead (the rule/amend leads are
|
|
@@ -730,22 +978,30 @@ export class Editor {
|
|
|
730
978
|
const lead = this.#panel !== null ? panelLead(this.#panel.view, this.#panel.phase, this.#panel.sel) : PROMPT;
|
|
731
979
|
const leadW = leadWidth(lead);
|
|
732
980
|
const maxW = Math.max(1, W - leadW - 4); // W6: the box's walls (2+2) — the visible line fits the box's inner width; the "…" rides inside
|
|
733
|
-
|
|
734
|
-
|
|
981
|
+
// KC1: the scroll is the CURSOR LINE's own offset — a single-line
|
|
982
|
+
// buffer's line starts at 0, so the math is today's exactly. The
|
|
983
|
+
// clamp catches a walk onto a line SHORTER than the old offset.
|
|
984
|
+
const { start, end } = this.#cursorBounds();
|
|
985
|
+
this.#scroll = Math.min(this.#scroll, end - start);
|
|
986
|
+
const curCol = widthOf(this.#chars.slice(start, this.#cursor));
|
|
987
|
+
const scrolledW = widthOf(this.#chars.slice(start, start + this.#scroll));
|
|
735
988
|
if (curCol < scrolledW) {
|
|
736
|
-
this.#scroll = this.#indexAtWidth(curCol);
|
|
989
|
+
this.#scroll = this.#indexAtWidth(start, end, curCol) - start;
|
|
737
990
|
}
|
|
738
991
|
else if (curCol >= scrolledW + maxW) {
|
|
739
|
-
this.#scroll = this.#indexAtWidth(Math.max(0, curCol - maxW + 1));
|
|
992
|
+
this.#scroll = this.#indexAtWidth(start, end, Math.max(0, curCol - maxW + 1)) - start;
|
|
740
993
|
}
|
|
741
994
|
}
|
|
742
|
-
|
|
995
|
+
/** The first index in [start, end] whose display width from `start`
|
|
996
|
+
* reaches `target` — the width-based column walk (a wide char never
|
|
997
|
+
* splits: the index lands BEFORE it). */
|
|
998
|
+
#indexAtWidth(start, end, target) {
|
|
743
999
|
let w = 0;
|
|
744
|
-
for (let i =
|
|
1000
|
+
for (let i = start; i < end; i += 1) {
|
|
745
1001
|
if (w >= target)
|
|
746
1002
|
return i;
|
|
747
1003
|
w += charWidth(this.#chars[i]);
|
|
748
1004
|
}
|
|
749
|
-
return
|
|
1005
|
+
return end;
|
|
750
1006
|
}
|
|
751
1007
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,3 +11,4 @@ export { Container, foldLine, visibleWidth, SPINNER, type Component, type FrameC
|
|
|
11
11
|
export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, type MenuItem, } from "./editor.js";
|
|
12
12
|
export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type PathResolver, type RecapStats, type ResumeMeta, type RenderInput, type RenderResult, type RunUsage, } from "./render.js";
|
|
13
13
|
export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
|
|
14
|
+
export { STATUS_GLYPHS, idleStatus, runningStatus } from "./status.js";
|
package/dist/index.js
CHANGED
|
@@ -14,3 +14,6 @@ export { Container, foldLine, visibleWidth, SPINNER } from "./components.js";
|
|
|
14
14
|
export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, } from "./editor.js";
|
|
15
15
|
export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
|
|
16
16
|
export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";
|
|
17
|
+
// KC2 §5: the status rows' formatters — the CLI keeps the state and the
|
|
18
|
+
// repaint, the terminal layer owns what the row says.
|
|
19
|
+
export { STATUS_GLYPHS, idleStatus, runningStatus } from "./status.js";
|
package/dist/status.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KC2 §5 — the status line's FORMATTERS, extracted from the CLI (the
|
|
3
|
+
* ADR-0041 escape hatch: extraction, never a fifth raise). The split is
|
|
4
|
+
* the one the ADR names: the CLI keeps the STATE (the rotating glyph,
|
|
5
|
+
* the run's start instant, the live usage, whether the dock is up) and
|
|
6
|
+
* the REPAINT; what a status row SAYS is presentation, and presentation
|
|
7
|
+
* belongs to the terminal layer.
|
|
8
|
+
*
|
|
9
|
+
* Two callers built these rows independently before the move — chat's
|
|
10
|
+
* REPL and the recovery flow — with the running row duplicated verbatim
|
|
11
|
+
* in both. One definition now serves both, and the tier stays a
|
|
12
|
+
* PARAMETER precisely because the two callers disagree on it (chat
|
|
13
|
+
* spells plan's read-only posture out per W19, the recovery flow prints
|
|
14
|
+
* the bare mode): the extraction must not silently unify a difference it
|
|
15
|
+
* was not asked to settle.
|
|
16
|
+
*
|
|
17
|
+
* The rows are byte-for-byte what the CLI built before the move — the
|
|
18
|
+
* v2b/v3 §03 shapes the e2e transcripts pin by substring — with ONE
|
|
19
|
+
* deliberate exception: the running row's interrupt hint, which KC2 §2
|
|
20
|
+
* widens to name the new gesture.
|
|
21
|
+
*/
|
|
22
|
+
/** v3 §03/§05 — the working glyph family; the CLI's 200ms spinner walks
|
|
23
|
+
* it and hands each glyph back to `runningStatus`. */
|
|
24
|
+
export declare const STATUS_GLYPHS: readonly ["▖", "▘", "▝", "▗"];
|
|
25
|
+
/**
|
|
26
|
+
* The RUNNING row: the rotating glyph, the wall seconds since `since`
|
|
27
|
+
* (never below 1 — a run that just started still reads "1s", so the row
|
|
28
|
+
* never claims a turn took no time), the streamed output tokens once the
|
|
29
|
+
* count is known, the interrupt hints, and the live ctx estimate.
|
|
30
|
+
*
|
|
31
|
+
* KC2 §2: the hint names BOTH gestures. Esc still stops; alt+⏎ redirects
|
|
32
|
+
* — stop, and do THIS instead. The row is where the gesture is taught,
|
|
33
|
+
* because it is on screen exactly when the gesture is useful.
|
|
34
|
+
*/
|
|
35
|
+
export declare function runningStatus(glyph: string, since: number, outTokens: number | null, ctxRatio: number): string;
|
|
36
|
+
/** The IDLE row: the approval tier as the CALLER names it, the /mode
|
|
37
|
+
* hint, the model driving the session, and the ctx estimate. */
|
|
38
|
+
export declare function idleStatus(tier: string, model: string, ctxRatio: number): string;
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KC2 §5 — the status line's FORMATTERS, extracted from the CLI (the
|
|
3
|
+
* ADR-0041 escape hatch: extraction, never a fifth raise). The split is
|
|
4
|
+
* the one the ADR names: the CLI keeps the STATE (the rotating glyph,
|
|
5
|
+
* the run's start instant, the live usage, whether the dock is up) and
|
|
6
|
+
* the REPAINT; what a status row SAYS is presentation, and presentation
|
|
7
|
+
* belongs to the terminal layer.
|
|
8
|
+
*
|
|
9
|
+
* Two callers built these rows independently before the move — chat's
|
|
10
|
+
* REPL and the recovery flow — with the running row duplicated verbatim
|
|
11
|
+
* in both. One definition now serves both, and the tier stays a
|
|
12
|
+
* PARAMETER precisely because the two callers disagree on it (chat
|
|
13
|
+
* spells plan's read-only posture out per W19, the recovery flow prints
|
|
14
|
+
* the bare mode): the extraction must not silently unify a difference it
|
|
15
|
+
* was not asked to settle.
|
|
16
|
+
*
|
|
17
|
+
* The rows are byte-for-byte what the CLI built before the move — the
|
|
18
|
+
* v2b/v3 §03 shapes the e2e transcripts pin by substring — with ONE
|
|
19
|
+
* deliberate exception: the running row's interrupt hint, which KC2 §2
|
|
20
|
+
* widens to name the new gesture.
|
|
21
|
+
*/
|
|
22
|
+
import { kUnit } from "./render.js";
|
|
23
|
+
/** v3 §03/§05 — the working glyph family; the CLI's 200ms spinner walks
|
|
24
|
+
* it and hands each glyph back to `runningStatus`. */
|
|
25
|
+
export const STATUS_GLYPHS = ["▖", "▘", "▝", "▗"];
|
|
26
|
+
/** The ~ctx estimate as the whole-percent LEFT. A non-finite ratio (no
|
|
27
|
+
* window, no estimate) yields null and the row prints "~null%" — the
|
|
28
|
+
* long-standing shape, kept on purpose: an honest null beats an
|
|
29
|
+
* invented percentage. */
|
|
30
|
+
function ctxLeft(ratio) {
|
|
31
|
+
return Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The RUNNING row: the rotating glyph, the wall seconds since `since`
|
|
35
|
+
* (never below 1 — a run that just started still reads "1s", so the row
|
|
36
|
+
* never claims a turn took no time), the streamed output tokens once the
|
|
37
|
+
* count is known, the interrupt hints, and the live ctx estimate.
|
|
38
|
+
*
|
|
39
|
+
* KC2 §2: the hint names BOTH gestures. Esc still stops; alt+⏎ redirects
|
|
40
|
+
* — stop, and do THIS instead. The row is where the gesture is taught,
|
|
41
|
+
* because it is on screen exactly when the gesture is useful.
|
|
42
|
+
*/
|
|
43
|
+
export function runningStatus(glyph, since, outTokens, ctxRatio) {
|
|
44
|
+
const out = outTokens !== null ? ` ↓ ${kUnit(outTokens)} tokens` : "";
|
|
45
|
+
const seconds = Math.max(1, Math.round((Date.now() - since) / 1000));
|
|
46
|
+
return `${glyph} working ${seconds}s${out} · esc stop · alt+⏎ redirect · ctx left ~${ctxLeft(ctxRatio)}%`;
|
|
47
|
+
}
|
|
48
|
+
/** The IDLE row: the approval tier as the CALLER names it, the /mode
|
|
49
|
+
* hint, the model driving the session, and the ctx estimate. */
|
|
50
|
+
export function idleStatus(tier, model, ctxRatio) {
|
|
51
|
+
return `▸ ${tier} · /mode to switch · ${model} · ctx left ~${ctxLeft(ctxRatio)}%`;
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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.6.0"
|
|
39
39
|
}
|
|
40
40
|
}
|