@vincemakes/kiso-tui 0.15.4 → 0.15.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/editor.js CHANGED
@@ -134,6 +134,25 @@ export class Editor {
134
134
  #pasteSeq = 0;
135
135
  /** The buffer index where the in-flight paste began; null outside one. */
136
136
  #pasteAt = null;
137
+ /**
138
+ * REL-0152-D9 — the in-flight paste's characters, held OUT of the
139
+ * buffer until the paste ends.
140
+ *
141
+ * Every character used to go through #insert, which splices one code
142
+ * point and then reflows — and a reflow scans the line to find the
143
+ * cursor's bounds and measures its width. That is linear work per
144
+ * character, so a paste cost time in the SQUARE of its size: measured
145
+ * on the shipped build, 10k characters took 33ms and 30k took 278ms,
146
+ * with a 100k paste heading for three seconds of a frozen composer.
147
+ * The owner's report: the capsule appears, but only after a long wait.
148
+ *
149
+ * Held here, the whole run splices in ONCE and reflows ONCE, so the
150
+ * cost is linear and the arithmetic is done on a finished string
151
+ * rather than re-done at every character of it. It survives across
152
+ * chunks by construction — a terminal delivers a large paste in many
153
+ * reads, and this is a field, not a local.
154
+ */
155
+ #pasteRun = null;
137
156
  /** TUI2-R3v2 ①: one-shot — a panel that just closed swallows the
138
157
  * habitual trailing enter rather than submitting the restored draft. */
139
158
  #swallowEnter = false;
@@ -784,6 +803,19 @@ export class Editor {
784
803
  continue;
785
804
  }
786
805
  if (c === "\x0d" || c === "\x0a") {
806
+ // REL-0152-D10: a newline inside a PASTE is content, never a
807
+ // commit. Bracketed paste marks its own boundaries, so a
808
+ // \n between them is a line of the pasted text and nothing
809
+ // else. Without this, pasting a stack trace into a typed
810
+ // panel phase submitted the first line and dropped the
811
+ // rest into the composer behind the closed panel — the
812
+ // owner asked whether the type-your-own box takes a paste,
813
+ // and the answer was no, in the worst way.
814
+ if (this.#pasting) {
815
+ this.#insert(NEWLINE);
816
+ i += c === "\x0d" && text[i + 1] === "\x0a" ? 2 : 1;
817
+ continue;
818
+ }
787
819
  this.#pickPanelEnter();
788
820
  i += 1;
789
821
  continue;
@@ -815,6 +847,19 @@ export class Editor {
815
847
  continue;
816
848
  }
817
849
  if (c === "\x0d" || c === "\x0a") {
850
+ // REL-0152-D10: a newline inside a PASTE is content, never a
851
+ // commit. Bracketed paste marks its own boundaries, so a
852
+ // \n between them is a line of the pasted text and nothing
853
+ // else. Without this, pasting a stack trace into a typed
854
+ // panel phase submitted the first line and dropped the
855
+ // rest into the composer behind the closed panel — the
856
+ // owner asked whether the type-your-own box takes a paste,
857
+ // and the answer was no, in the worst way.
858
+ if (this.#pasting) {
859
+ this.#insert(NEWLINE);
860
+ i += c === "\x0d" && text[i + 1] === "\x0a" ? 2 : 1;
861
+ continue;
862
+ }
818
863
  this.#askStep(typing ? "commit" : "enter");
819
864
  i += 1;
820
865
  continue;
@@ -867,6 +912,19 @@ export class Editor {
867
912
  continue;
868
913
  }
869
914
  if (c === "\x0d" || c === "\x0a") {
915
+ // REL-0152-D10: a newline inside a PASTE is content, never a
916
+ // commit. Bracketed paste marks its own boundaries, so a
917
+ // \n between them is a line of the pasted text and nothing
918
+ // else. Without this, pasting a stack trace into a typed
919
+ // panel phase submitted the first line and dropped the
920
+ // rest into the composer behind the closed panel — the
921
+ // owner asked whether the type-your-own box takes a paste,
922
+ // and the answer was no, in the worst way.
923
+ if (this.#pasting) {
924
+ this.#insert(NEWLINE);
925
+ i += c === "\x0d" && text[i + 1] === "\x0a" ? 2 : 1;
926
+ continue;
927
+ }
870
928
  this.#panelEnter();
871
929
  i += 1;
872
930
  continue;
@@ -1229,10 +1287,11 @@ export class Editor {
1229
1287
  else if (n === 200) {
1230
1288
  this.#pasting = true;
1231
1289
  this.#pasteAt = this.#cursor; // REL-0152-D8: where the capsule will go
1290
+ this.#pasteRun = []; // REL-0152-D9: collect, do not insert
1232
1291
  }
1233
1292
  else if (n === 201) {
1234
1293
  this.#pasting = false;
1235
- this.#encapsulate();
1294
+ this.#commitPaste();
1236
1295
  this.#onRender();
1237
1296
  }
1238
1297
  }
@@ -1710,6 +1769,13 @@ export class Editor {
1710
1769
  }
1711
1770
  // ---- editing ----
1712
1771
  #insert(cp) {
1772
+ // REL-0152-D9: inside a paste the character is COLLECTED, not
1773
+ // inserted — see #pasteRun. Every other caller (a typed key, a
1774
+ // ctrl+J newline) is outside a paste and lands below unchanged.
1775
+ if (this.#pasteRun !== null) {
1776
+ this.#pasteRun.push(cp);
1777
+ return;
1778
+ }
1713
1779
  if (this.#historyIdx !== null)
1714
1780
  this.#historyIdx = null; // editing leaves the browse
1715
1781
  this.#queuePopMode = false; // W22: editing leaves the pop-walk too
@@ -1813,21 +1879,54 @@ export class Editor {
1813
1879
  * every editing operation in this file works on it without knowing
1814
1880
  * it exists.
1815
1881
  */
1816
- #encapsulate() {
1817
- const start = this.#pasteAt;
1882
+ /** REL-0152-D9: code points to a string WITHOUT spreading the whole
1883
+ * array into one call. `String.fromCodePoint(...run)` throws
1884
+ * RangeError on a large paste — the argument list is the stack — and
1885
+ * a composer that crashes on a big paste is worse than one that is
1886
+ * slow. Chunked, it is linear and bounded. */
1887
+ static #textOf(run) {
1888
+ const CHUNK = 4096;
1889
+ let out = "";
1890
+ for (let i = 0; i < run.length; i += CHUNK)
1891
+ out += String.fromCodePoint(...run.slice(i, i + CHUNK));
1892
+ return out;
1893
+ }
1894
+ /**
1895
+ * Close an in-flight paste: the collected run goes into the buffer in
1896
+ * ONE splice — as itself when it is small, as its capsule when it is
1897
+ * not (REL-0152-D8).
1898
+ *
1899
+ * The splice is at the recorded start, so a paste in the MIDDLE of a
1900
+ * line leaves the prose on both sides untouched — what lands is a
1901
+ * character run like any other from here on, and every editing
1902
+ * operation in this file works on it without knowing it exists.
1903
+ */
1904
+ #commitPaste() {
1905
+ const run = this.#pasteRun ?? [];
1906
+ const start = this.#pasteAt ?? this.#cursor;
1907
+ this.#pasteRun = null;
1818
1908
  this.#pasteAt = null;
1819
- if (start === null || this.#cursor <= start)
1909
+ if (run.length === 0)
1820
1910
  return;
1821
- const pasted = String.fromCodePoint(...this.#chars.slice(start, this.#cursor));
1911
+ if (this.#historyIdx !== null)
1912
+ this.#historyIdx = null;
1913
+ this.#queuePopMode = false;
1914
+ const pasted = _a.#textOf(run);
1822
1915
  const lines = pasted.split("\n").length;
1823
- if (lines < _a.#PASTE_LINES && pasted.length < _a.#PASTE_CHARS)
1824
- return;
1825
- this.#pasteSeq += 1;
1826
- this.#pastes.set(this.#pasteSeq, pasted);
1827
- const capsule = [..._a.#capsuleText(this.#pasteSeq, lines)].map((ch) => ch.codePointAt(0));
1828
- this.#chars.splice(start, this.#cursor - start, ...capsule);
1829
- this.#cursor = start + capsule.length;
1916
+ const small = lines < _a.#PASTE_LINES && run.length < _a.#PASTE_CHARS;
1917
+ let placed;
1918
+ if (small) {
1919
+ placed = run;
1920
+ }
1921
+ else {
1922
+ this.#pasteSeq += 1;
1923
+ this.#pastes.set(this.#pasteSeq, pasted);
1924
+ placed = [..._a.#capsuleText(this.#pasteSeq, lines)].map((ch) => ch.codePointAt(0));
1925
+ }
1926
+ this.#chars.splice(start, 0, ...placed);
1927
+ this.#cursor = start + placed.length;
1830
1928
  this.#reflow();
1929
+ this.#refreshMenu();
1831
1930
  }
1832
1931
  /**
1833
1932
  * The way out: every capsule token becomes its text again.
@@ -28,6 +28,10 @@
28
28
  * imports nothing from the runtime, by rule). */
29
29
  export interface SessionCardView {
30
30
  readonly id: string;
31
+ /** REL-0152-D6b: the session's first substantive prompt. Optional so
32
+ * a caller that has not got one still renders — the row simply
33
+ * carries no title, which is where this picker started. */
34
+ readonly title?: string;
31
35
  readonly badge: "uncertain" | "ask" | "interrupted" | "completed" | "failed";
32
36
  readonly turns: number;
33
37
  readonly updatedAt: number;
@@ -150,6 +150,11 @@ export function sessionFilter(cards, query) {
150
150
  * CRASHES the compositor, so the arithmetic has to be provably right at
151
151
  * every width rather than right at eighty.
152
152
  */
153
+ /** REL-0152-D6b: what the title may take, and what it must leave. The
154
+ * reserve is the widest note this picker writes ("N uncertain
155
+ * executions"), so an actionable row keeps saying so. */
156
+ const TITLE_MAX = 44;
157
+ const NOTE_RESERVE = 22;
153
158
  function rowSpans(card, budget, now, idCol) {
154
159
  const p = palette();
155
160
  let text = "";
@@ -175,6 +180,20 @@ function rowSpans(card, budget, now, idCol) {
175
180
  put(" ".repeat(pad), " ".repeat(pad));
176
181
  const meta = ` ${sessionAge(card.updatedAt, now)} · ${card.turns} turn${card.turns === 1 ? "" : "s"}`;
177
182
  put(meta, `${p.dim}${meta}${p.reset}`);
183
+ // REL-0152-D6b: the TITLE — the only span on this row that answers
184
+ // "which conversation is this?". It goes after the meta and before
185
+ // the note, and it is bounded so the note (which can be the one that
186
+ // demands an action) still has room at ordinary widths; on a narrow
187
+ // terminal `put` drops whichever no longer fits, in that order.
188
+ const title = card.title ?? "";
189
+ if (title !== "") {
190
+ const room = Math.max(0, Math.min(budget - w - 3 - NOTE_RESERVE, TITLE_MAX));
191
+ const cut = widthCut(escapeTerminal(title), room);
192
+ if (cut !== "") {
193
+ put(" ", " ");
194
+ put(cut, `${p.bold}${cut}${p.reset}`);
195
+ }
196
+ }
178
197
  const note = widthCut(sessionNote(card), Math.max(0, budget - w - 3));
179
198
  if (note !== "") {
180
199
  // the ? note carries the warn tint — the row's own words are what
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.15.4",
3
+ "version": "0.15.5",
4
4
  "description": "kiso tui \u2014 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.15.4"
38
+ "@vincemakes/kiso-tui-cells": "0.15.5"
39
39
  }
40
40
  }