@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.
@@ -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 };
@@ -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 visible slice (dim "…" prefix when scrolled) + the cursor's
54
- * display column within it the dock's input-row state. */
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
- * 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
@@ -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
- /** The visible slice (dim "…" prefix when scrolled) + the cursor's
143
- * display column within it — the dock's input-row state. */
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 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 };
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
- if (this.#pasting) {
354
- this.#insert(0x20); // single-line editor: newlines become spaces
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 += 1;
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.#chars.length;
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 = 0;
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.#chars.length;
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.#chars.splice(0, this.#cursor);
614
- this.#cursor = 0;
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
- this.#chars.length = this.#cursor;
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
- this.#chars = [];
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
- // A2: the history remembers submitted TURN lines — never question
671
- // answers, never empties; adjacent duplicates collapse.
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
- const curCol = widthOf(this.#chars.slice(0, this.#cursor));
734
- const scrolledW = widthOf(this.#chars.slice(0, this.#scroll));
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
- #indexAtWidth(target) {
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 = 0; i < this.#chars.length; i += 1) {
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 this.#chars.length;
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";
@@ -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.2.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.2.0"
38
+ "@vincemakes/kiso-tui-cells": "0.6.0"
39
39
  }
40
40
  }