@vincemakes/kiso-tui 0.2.0 → 0.5.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.
@@ -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;
@@ -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
- for (let row = H - 3; row <= H; row += 1) { // V6-3: the four chrome rows
603
- out.push(`\x1b[${row};1H\x1b[0K`); // clear the three chrome rows
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 + CHROME_ROWS + queueRows.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
- const chromeRows = CHROME_ROWS + menuRows.length + queueRows.length;
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, ctx);
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, ctx);
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 lines = this.#queueState?.() ?? [];
1048
- if (lines.length === 0)
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
- /** The focus component's input row — the marker embedded at the
1082
- * cursor's display column WITHIN THE ROW (the brick/question lead
1083
- * included), the question/editor/menu variants. The compositor
1084
- * strips the marker and returns the frame-derived COLUMN — the
1085
- * cursor move lands AT the marker (a CHA — the column is absolute,
1086
- * so the move's base is irrelevant; the retired afterW CUB's base
1087
- * was the LAST write's end column, which the steady frame's
1088
- * gap/stale ELs leave at col 1 — the A3 finding).
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
- #inputRow(W, _ctx) {
1099
- const st = this.#inputState();
1100
- const panel = this.#panelState?.() ?? null;
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
- let inserted = false;
1113
- let i = 0;
1114
- while (i < row.length) {
1115
- if (row[i] === "\x1b") {
1116
- const m = /^\x1b\[[0-9;]*m/.exec(row.slice(i));
1117
- if (m !== null) {
1118
- markerLine += m[0];
1119
- i += m[0].length;
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, markerCol: 3 + markerCell };
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
- const stripped = `\x1b[2m│ \x1b[0m${stripped0}\x1b[2m${" ".repeat(padW)}│\x1b[0m`;
1154
- // W23: the frame-derived column — wallL (2) + the marker's cell
1155
- // + 1 — the CHA lands the cursor AT the marker from ANY base
1156
- const markerCol = 3 + markerCell;
1157
- return { stripped, markerCol };
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, ctx) {
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 = H - 3 - queueRows.length - menuRows.length;
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−3), input
1254
- // (H−2), box bottom (H−1), status (H) — the box's four rows.
1255
- out.push(`\x1b[${H - 3};1H\x1b[0K${boxTop(W)}`);
1256
- const editor = this.#inputRow(W, ctx);
1257
- out.push(`\x1b[${H - 2};1H\x1b[0K${this.#checked(editor.stripped, W)}`);
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 two (the input row at H−2) + the CHA to the
1262
- // marker's frame-derived column W23: the afterW CUB retired
1263
- // (the CHA is absolute the base is irrelevant; the CUB's base
1264
- // was the LAST write's end column, which the steady frame's ELs
1265
- // leave at col 1 the A3 finding)
1266
- out.push("\x1b[2A");
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, ctx) {
1274
- const editor = this.#inputRow(W, ctx); // derived from the frame the marker
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
- out.push("\x1b[2B"); // the anchor (H−2) to the bottom no scroll
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: the
1337
- // design §03 chrome: status (H), box bottom (H−1), input (H−2),
1338
- // box top (H−3)
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
- out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(editor.stripped, W)}`); // H−2 the input
1343
- out.push(`\x1b[1A\x1b[1G\x1b[0K${boxTop(W)}`); // H−3 the box top
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
- const down = H - 2 - lastRow; // the anchor: the input row (H−2)
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
- * The editor is a SINGLE line: bracketed paste (?2004h) unwraps and
17
- * inserts, internal newlines become spaces.
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 };
@@ -50,11 +55,23 @@ export declare class Editor {
50
55
  /** The whole buffer as text (the CLI's line()/clearLine()). */
51
56
  line(): string;
52
57
  clearLine(): void;
53
- /** The visible slice (dim "…" prefix when scrolled) + the cursor's
54
- * display column within it the dock's input-row state. */
58
+ /** The dock's input-row state ADDITIVE (§5): `line` + `cursor` keep
59
+ * their legacy meaning (the CURSOR LINE's visible slice and the
60
+ * cursor's display column in it — a single-line buffer yields
61
+ * today's exact values, and a legacy one-row consumer keeps
62
+ * working), and the composer's own view rides beside them.
63
+ *
64
+ * The window is DERIVED per read — no persistent #vscroll:
65
+ * visibleStart = clamp(cursorLine − N_visible + 1, 0, lineCount −
66
+ * N_visible), so it trails the cursor, can never hide it, and no
67
+ * stash / restore / clear / submit path has new state to carry. A
68
+ * dim "…" marks whichever edge hides rows. */
55
69
  dockState(): {
56
70
  line: string;
57
71
  cursor: number;
72
+ lines: string[];
73
+ cursorRow: number;
74
+ cursorCol: number;
58
75
  };
59
76
  /** v3 §04: the menu's visible state for the dock — null when closed. */
60
77
  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
- * The editor is a SINGLE line: bracketed paste (?2004h) unwraps and
17
- * inserts, internal newlines become spaces.
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
@@ -137,15 +159,86 @@ export class Editor {
137
159
  this.#chars = [];
138
160
  this.#cursor = 0;
139
161
  this.#scroll = 0;
162
+ this.#verticalGoalCol = null;
140
163
  this.#onRender();
141
164
  }
142
- /** The visible slice (dim "…" prefix when scrolled) + the cursor's
143
- * display column within it — the dock's input-row state. */
165
+ // ---- KC1 §5: the DERIVED line model (the buffer stays FLAT) ----
166
+ /** The lines as [start, end) index pairs — the 0x0A itself EXCLUDED.
167
+ * A buffer without a newline is exactly ONE line spanning the whole
168
+ * buffer: today's shape, derived. */
169
+ #lineBounds() {
170
+ const out = [];
171
+ let start = 0;
172
+ for (let i = 0; i < this.#chars.length; i += 1) {
173
+ if (this.#chars[i] === NEWLINE) {
174
+ out.push({ start, end: i });
175
+ start = i + 1;
176
+ }
177
+ }
178
+ out.push({ start, end: this.#chars.length });
179
+ return out;
180
+ }
181
+ /** The cursor's line index — the first line whose end it has not
182
+ * passed (a cursor resting ON a newline belongs to the line that
183
+ * newline closes, never to the next one). */
184
+ #cursorLine(bounds) {
185
+ for (let i = 0; i < bounds.length; i += 1) {
186
+ if (this.#cursor <= bounds[i].end)
187
+ return i;
188
+ }
189
+ return bounds.length - 1;
190
+ }
191
+ /** The cursor's OWN line — the unit of the horizontal scroll and of
192
+ * the line-local A/E/U/K (A3). */
193
+ #cursorBounds() {
194
+ const bounds = this.#lineBounds();
195
+ return bounds[this.#cursorLine(bounds)];
196
+ }
197
+ /** KC1 §5 — N_visible = min(lineCount, N_MAX, max(1, H − 3 − the
198
+ * menu/queue bands)). The height clamp guarantees legal geometry
199
+ * down to the compositor's enter gate; the compositor re-applies the
200
+ * SAME formula against the frame's real bands (it alone knows their
201
+ * folded row counts), so this is the editor's honest estimate and
202
+ * the frame's clamp is the authority. */
203
+ #visibleRows(lineCount) {
204
+ const H = process.stdout.rows ?? 24;
205
+ const bands = (this.#menuOpen ? this.#menuFiltered().length : 0) + this.#queueState().length;
206
+ return Math.max(1, Math.min(lineCount, N_MAX, Math.max(1, H - 3 - bands)));
207
+ }
208
+ /** The dock's input-row state — ADDITIVE (§5): `line` + `cursor` keep
209
+ * their legacy meaning (the CURSOR LINE's visible slice and the
210
+ * cursor's display column in it — a single-line buffer yields
211
+ * today's exact values, and a legacy one-row consumer keeps
212
+ * working), and the composer's own view rides beside them.
213
+ *
214
+ * The window is DERIVED per read — no persistent #vscroll:
215
+ * visibleStart = clamp(cursorLine − N_visible + 1, 0, lineCount −
216
+ * N_visible), so it trails the cursor, can never hide it, and no
217
+ * stash / restore / clear / submit path has new state to carry. A
218
+ * dim "…" marks whichever edge hides rows. */
144
219
  dockState() {
145
- const visible = String.fromCodePoint(...this.#chars.slice(this.#scroll));
146
- const prefix = this.#scroll > 0 ? "\x1b[2m…\x1b[0m" : "";
147
- const col = (this.#scroll > 0 ? 1 : 0) + widthOf(this.#chars.slice(this.#scroll, this.#cursor));
148
- return { line: `${prefix}${visible}`, cursor: col };
220
+ const bounds = this.#lineBounds();
221
+ const cursorLine = this.#cursorLine(bounds);
222
+ const n = this.#visibleRows(bounds.length);
223
+ const first = Math.max(0, Math.min(cursorLine - n + 1, bounds.length - n));
224
+ const lines = [];
225
+ for (let i = first; i < first + n; i += 1) {
226
+ const b = bounds[i];
227
+ // the cursor's own row carries the horizontal scroll (and its
228
+ // "…"); the other rows render whole and cap at the frame's wall
229
+ const from = i === cursorLine ? b.start + this.#scroll : b.start;
230
+ const scrolled = i === cursorLine && this.#scroll > 0 ? ELLIPSIS : "";
231
+ const above = i === first && first > 0 ? ELLIPSIS : "";
232
+ const below = i === first + n - 1 && first + n < bounds.length ? ELLIPSIS : "";
233
+ lines.push(`${above}${scrolled}${String.fromCodePoint(...this.#chars.slice(from, b.end))}${below}`);
234
+ }
235
+ const cursorRow = cursorLine - first;
236
+ // the window trails the cursor, so the hidden-above marker can only
237
+ // share the cursor's row in the degenerate one-row window (a tiny
238
+ // terminal) — where it shifts the column like the scroll's does
239
+ const marks = (cursorRow === 0 && first > 0 ? 1 : 0) + (this.#scroll > 0 ? 1 : 0);
240
+ const cursorCol = marks + widthOf(this.#chars.slice(bounds[cursorLine].start + this.#scroll, this.#cursor));
241
+ return { line: lines[cursorRow], cursor: cursorCol, lines, cursorRow, cursorCol };
149
242
  }
150
243
  /** v3 §04: the menu's visible state for the dock — null when closed. */
151
244
  menuState() {
@@ -194,6 +287,7 @@ export class Editor {
194
287
  this.#chars = [];
195
288
  this.#cursor = 0;
196
289
  this.#scroll = 0;
290
+ this.#verticalGoalCol = null;
197
291
  this.#menuOpen = false;
198
292
  this.#menuSel = 0;
199
293
  this.#queuePopMode = false; // W22: the panel owns the keys while up
@@ -321,6 +415,7 @@ export class Editor {
321
415
  this.#chars = [];
322
416
  this.#cursor = 0;
323
417
  this.#scroll = 0;
418
+ this.#verticalGoalCol = null;
324
419
  this.#refreshMenu();
325
420
  i += 1;
326
421
  }
@@ -350,13 +445,27 @@ export class Editor {
350
445
  }
351
446
  }
352
447
  else if (c === "\x0d" || c === "\x0a") {
353
- if (this.#pasting) {
354
- this.#insert(0x20); // single-line editor: newlines become spaces
448
+ // KC1 §3 — the ONE newline normalizer. Inside a paste every
449
+ // boundary (LF, CR, CRLF) becomes EXACTLY one 0x0A; a paste's
450
+ // trailing CR at a CHUNK boundary parks in #pending (the
451
+ // existing CSI-resume mechanism) and resolves against the next
452
+ // chunk's leading LF, so a CR|LF pair split by the stdin read
453
+ // is still ONE newline. Outside a paste: Ctrl+J (LF) inserts,
454
+ // Enter (CR) submits — and a typed CRLF pair submits ONCE (the
455
+ // LF is consumed with it, never landing in the fresh buffer).
456
+ // A lone interactive CR never parks: the submit is immediate.
457
+ if (c === "\x0d" && i + 1 === text.length && this.#pasting) {
458
+ this.#pending = text.slice(i);
459
+ break;
460
+ }
461
+ const consumed = c === "\x0d" && text[i + 1] === "\x0a" ? 2 : 1;
462
+ if (this.#pasting || c === "\x0a") {
463
+ this.#insert(NEWLINE);
355
464
  }
356
465
  else {
357
466
  this.#submit();
358
467
  }
359
- i += 1;
468
+ i += consumed;
360
469
  }
361
470
  else if (c === "\x7f" || c === "\x08") {
362
471
  this.#backspace();
@@ -383,13 +492,13 @@ export class Editor {
383
492
  i += 1;
384
493
  }
385
494
  else if (c === "\x01") {
386
- this.#cursor = 0;
495
+ this.#cursor = this.#cursorBounds().start; // A3: line-local (a single line starts at 0 — unchanged)
387
496
  this.#reflow();
388
497
  this.#onRender();
389
498
  i += 1;
390
499
  }
391
500
  else if (c === "\x05") {
392
- this.#cursor = this.#chars.length;
501
+ this.#cursor = this.#cursorBounds().end; // A3: line-local (a single line ends at the buffer's end)
393
502
  this.#reflow();
394
503
  this.#onRender();
395
504
  i += 1;
@@ -423,6 +532,15 @@ export class Editor {
423
532
  }
424
533
  }
425
534
  #csi(params, final) {
535
+ // KC1 §4 — Shift+Enter WHERE THE TERMINAL ENCODES IT: kitty's
536
+ // CSI-u (ESC [ 13;2 u) and xterm's modifyOtherKeys (ESC [ 27;2;13 ~).
537
+ // Never claimed universal — Ctrl+J is the everywhere baseline; a
538
+ // terminal that sends neither simply never reaches this row. The
539
+ // chunk-split safety is the existing #pending CSI resume.
540
+ if ((final === "u" && params === "13;2") || (final === "~" && params === "27;2;13")) {
541
+ this.#insert(NEWLINE);
542
+ return;
543
+ }
426
544
  if (final === "~") {
427
545
  const n = Number(params);
428
546
  if (n === 3)
@@ -450,6 +568,13 @@ export class Editor {
450
568
  else
451
569
  this.#menuSel = Math.min(this.#menuFiltered().length - 1, this.#menuSel + 1);
452
570
  }
571
+ else if (this.#chars.includes(NEWLINE)) {
572
+ // KC1 §4: a MULTI-LINE buffer's ↑↓ walk its lines. The
573
+ // history and the queue-pop below stay gated on an EMPTY
574
+ // buffer — a multi-line buffer is never empty, so the
575
+ // precedence can only ever add, never take.
576
+ this.#verticalMove(final === "A" ? -1 : 1);
577
+ }
453
578
  else if (final === "A" && this.#queuePop !== null && (this.#queuePopMode || this.line() === "") && this.#queueState().length > 0) {
454
579
  // W22: ↑ pops the LAST queued message into the buffer — the
455
580
  // walk: repeated presses pop older ones (each replaces the
@@ -471,14 +596,32 @@ export class Editor {
471
596
  this.#move(1);
472
597
  }
473
598
  else if (final === "H") {
474
- this.#cursor = 0;
599
+ this.#cursor = this.#cursorBounds().start; // A3: Home follows Ctrl+A — line-local
475
600
  this.#reflow();
476
601
  }
477
602
  else if (final === "F") {
478
- this.#cursor = this.#chars.length;
603
+ this.#cursor = this.#cursorBounds().end; // A3: End follows Ctrl+E — line-local
479
604
  this.#reflow();
480
605
  }
481
606
  }
607
+ /** KC1 §4 — the ↑/↓ walk. The cursor keeps its DESIRED column across
608
+ * a short line: the goal is captured at the FIRST vertical move and
609
+ * survives consecutive ones (#reflow clears it, so any horizontal
610
+ * move / insert / delete ends the walk); a step past either end
611
+ * stays put. */
612
+ #verticalMove(delta) {
613
+ const bounds = this.#lineBounds();
614
+ const cur = this.#cursorLine(bounds);
615
+ const next = cur + delta;
616
+ if (next < 0 || next >= bounds.length)
617
+ return;
618
+ const from = bounds[cur];
619
+ const goal = this.#verticalGoalCol ?? widthOf(this.#chars.slice(from.start, this.#cursor));
620
+ const to = bounds[next];
621
+ this.#cursor = this.#indexAtWidth(to.start, to.end, goal);
622
+ this.#reflow();
623
+ this.#verticalGoalCol = goal; // the walk re-arms it (the reflow's reset is for every OTHER key)
624
+ }
482
625
  // ---- W21: the panel state machine ----
483
626
  #panelSelect(sel) {
484
627
  const panel = this.#panel;
@@ -498,6 +641,7 @@ export class Editor {
498
641
  this.#chars = [...panel.view.name].map((ch) => ch.codePointAt(0));
499
642
  this.#cursor = this.#chars.length;
500
643
  this.#scroll = 0;
644
+ this.#verticalGoalCol = null;
501
645
  this.#onRender();
502
646
  }
503
647
  /** tab — the amend phase on the selected option (yes/deny); the
@@ -511,6 +655,7 @@ export class Editor {
511
655
  this.#chars = [];
512
656
  this.#cursor = 0;
513
657
  this.#scroll = 0;
658
+ this.#verticalGoalCol = null;
514
659
  this.#onRender();
515
660
  }
516
661
  /** esc — back out of the rule/amend to the options (the buffer
@@ -525,6 +670,7 @@ export class Editor {
525
670
  this.#chars = [];
526
671
  this.#cursor = 0;
527
672
  this.#scroll = 0;
673
+ this.#verticalGoalCol = null;
528
674
  this.#onRender();
529
675
  return;
530
676
  }
@@ -610,18 +756,23 @@ export class Editor {
610
756
  this.#onRender();
611
757
  }
612
758
  #killToStart() {
613
- this.#chars.splice(0, this.#cursor);
614
- this.#cursor = 0;
759
+ const { start } = this.#cursorBounds(); // A3: line-local (0 on a single line — unchanged)
760
+ this.#chars.splice(start, this.#cursor - start);
761
+ this.#cursor = start;
615
762
  this.#reflow();
616
763
  if (!this.#pasting)
617
764
  this.#onRender();
618
765
  }
619
766
  #killToEnd() {
620
- this.#chars.length = this.#cursor;
767
+ const { end } = this.#cursorBounds(); // A3: line-local (the buffer's end on a single line — unchanged)
768
+ this.#chars.splice(this.#cursor, end - this.#cursor);
621
769
  this.#reflow();
622
770
  if (!this.#pasting)
623
771
  this.#onRender();
624
772
  }
773
+ /** Ctrl+W — the word kill. The newline rides as a non-space code
774
+ * point (a kill at a line's start joins it to the one above, the
775
+ * readline behavior); A3 scopes A/E/U/K, not W. */
625
776
  #killWord() {
626
777
  let i = this.#cursor;
627
778
  while (i > 0 && this.#chars[i - 1] === 0x20)
@@ -653,6 +804,7 @@ export class Editor {
653
804
  this.#chars = [];
654
805
  this.#cursor = 0;
655
806
  this.#scroll = 0;
807
+ this.#verticalGoalCol = null;
656
808
  this.#menuOpen = false;
657
809
  this.#menuSel = 0;
658
810
  this.#queuePopMode = false; // W22: a submit ends the pop-walk — the next esc at rest interrupts again
@@ -716,10 +868,14 @@ export class Editor {
716
868
  this.#chars = [...line].map((ch) => ch.codePointAt(0));
717
869
  this.#cursor = this.#chars.length;
718
870
  this.#scroll = 0;
871
+ this.#verticalGoalCol = null;
719
872
  this.#onRender();
720
873
  }
721
874
  // ---- width-based horizontal scroll ----
722
875
  #reflow() {
876
+ // KC1: any key that reaches the reflow ended a ↑/↓ walk (the walk
877
+ // itself re-arms the goal right after its own reflow call).
878
+ this.#verticalGoalCol = null;
723
879
  const W = (process.stdout.columns ?? 0) || 80; // degenerate 0 falls back to 80
724
880
  // W21: the panel's phase lead owns the input row while up — the
725
881
  // line's max width follows the lead (the rule/amend leads are
@@ -730,22 +886,30 @@ export class Editor {
730
886
  const lead = this.#panel !== null ? panelLead(this.#panel.view, this.#panel.phase, this.#panel.sel) : PROMPT;
731
887
  const leadW = leadWidth(lead);
732
888
  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
- const curCol = widthOf(this.#chars.slice(0, this.#cursor));
734
- const scrolledW = widthOf(this.#chars.slice(0, this.#scroll));
889
+ // KC1: the scroll is the CURSOR LINE's own offset — a single-line
890
+ // buffer's line starts at 0, so the math is today's exactly. The
891
+ // clamp catches a walk onto a line SHORTER than the old offset.
892
+ const { start, end } = this.#cursorBounds();
893
+ this.#scroll = Math.min(this.#scroll, end - start);
894
+ const curCol = widthOf(this.#chars.slice(start, this.#cursor));
895
+ const scrolledW = widthOf(this.#chars.slice(start, start + this.#scroll));
735
896
  if (curCol < scrolledW) {
736
- this.#scroll = this.#indexAtWidth(curCol);
897
+ this.#scroll = this.#indexAtWidth(start, end, curCol) - start;
737
898
  }
738
899
  else if (curCol >= scrolledW + maxW) {
739
- this.#scroll = this.#indexAtWidth(Math.max(0, curCol - maxW + 1));
900
+ this.#scroll = this.#indexAtWidth(start, end, Math.max(0, curCol - maxW + 1)) - start;
740
901
  }
741
902
  }
742
- #indexAtWidth(target) {
903
+ /** The first index in [start, end] whose display width from `start`
904
+ * reaches `target` — the width-based column walk (a wide char never
905
+ * splits: the index lands BEFORE it). */
906
+ #indexAtWidth(start, end, target) {
743
907
  let w = 0;
744
- for (let i = 0; i < this.#chars.length; i += 1) {
908
+ for (let i = start; i < end; i += 1) {
745
909
  if (w >= target)
746
910
  return i;
747
911
  w += charWidth(this.#chars[i]);
748
912
  }
749
- return this.#chars.length;
913
+ return end;
750
914
  }
751
915
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.2.0",
3
+ "version": "0.5.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.2.0"
38
+ "@vincemakes/kiso-tui-cells": "0.5.0"
39
39
  }
40
40
  }