@vincemakes/kiso-tui 0.15.8 → 0.15.10

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.
@@ -212,6 +212,32 @@ export declare class Body {
212
212
  * live region's first row) — NEVER at the formula's committed-count
213
213
  * top: external writes (the CLI's console.error CRLF) can shift the
214
214
  * committed content down, and a formula-top ED0 would clear it. */
215
+ /**
216
+ * REL-0152-D18 — a window DRAG is one resize, not forty.
217
+ *
218
+ * SIGWINCH fires continuously while a drag is in progress — every few
219
+ * pixels is another signal — and this used to answer each one at once
220
+ * with an erase and a full repaint. A real terminal REFLOWS on a width
221
+ * change and pushes the rows it displaces into its scrollback, so
222
+ * every one of those repaints deposited another copy of the screen
223
+ * into the history. The owner's two-second drag left six identical
224
+ * copies of the same tool block in the transcript.
225
+ *
226
+ * Nothing clever can undo that afterwards: the scrollback is not ours
227
+ * to rewrite. The only fix is to not paint into the middle of a drag.
228
+ *
229
+ * The geometry is adopted IMMEDIATELY — every cap and bound is read
230
+ * from `#opts` at frame time, so the model is already at the new size
231
+ * and nothing is computed against a stale width. What waits is the
232
+ * PAINT. When the signals stop, one erase and one repaint.
233
+ *
234
+ * The competitor does not have this problem, for a structural reason
235
+ * worth naming rather than envying: it runs on the alternate screen,
236
+ * which has no scrollback to accumulate into. kiso is on the primary
237
+ * screen deliberately — the transcript IS the terminal's own
238
+ * scrollback, which is the product's whole claim — so it pays for
239
+ * that choice here, and has to pay carefully.
240
+ */
215
241
  onResize(): void;
216
242
  /** W18: the status row's right-aligned hint is part of the status
217
243
  * state — the compacting row passes "esc to cancel" (the affordance
@@ -64,6 +64,10 @@ const SPINNER_MS = 200; // the spinner cadence — a ONE-SHOT re-armed on demand
64
64
  /** REL-0152-R1: a held row that has never been painted, so the first
65
65
  * frame writes every row rather than trusting an empty string. */
66
66
  const NOT_PAINTED = "\u0000never";
67
+ /** REL-0152-D18 — how long a drag has to be quiet before it is over.
68
+ * Long enough that a continuous drag never crosses it, short enough
69
+ * that a single resize still feels immediate. */
70
+ const RESIZE_SETTLE_MS = 80;
67
71
  const CHROME_ROWS = 4; // box top + input + box bottom + status — the design §03 chrome (V6-3; the box is W6)
68
72
  /** W20 — the whole-table-replace comparison: the live task block only
69
73
  * redraws when the items actually changed (the task extension's
@@ -141,6 +145,11 @@ export class Body {
141
145
  #scrolledOff = 0;
142
146
  /** REL-0152-R1: this frame is the repaint after a SIGWINCH. */
143
147
  #resizeFrame = false;
148
+ /** REL-0152-D18: a drag's signals are still arriving. */
149
+ #resizePending = false;
150
+ /** REL-0152-D19: the inherited terminal has not been released yet. */
151
+ #needsReset = false;
152
+ #resizeTimer = null;
144
153
  #lastH = 0;
145
154
  // KC1 §6: the composer's recorded extent — the row count the last
146
155
  // frame drew (exit's clear walks it) and the row its CHA parked the
@@ -867,7 +876,43 @@ export class Body {
867
876
  if (process.stdout.isTTY !== true || palette().bold === "" || rows < 4)
868
877
  return;
869
878
  this.#docked = true;
870
- this.#guardStdout();
879
+ this.#guardOutput();
880
+ // REL-0152-D19: RELEASE the terminal we were handed, before the
881
+ // first frame.
882
+ //
883
+ // The dock resets on the way OUT and reset nothing on the way IN,
884
+ // so every session began in whatever state the previous occupant
885
+ // left. For THIS product that is not an edge case: kiso's claim is
886
+ // that it survives kill -9, which makes "the last instance died
887
+ // without running its teardown" a supported and advertised way to
888
+ // arrive here.
889
+ //
890
+ // Both of these confine writes to a SUB-REGION of the screen,
891
+ // which is the only way content can survive at a column the dock
892
+ // paints — every chrome row is exactly W wide and written from
893
+ // column 1, so an erase plus a W-wide write covers the row end to
894
+ // end unless the write cannot reach the ends.
895
+ //
896
+ // ESC[r the scroll region back to the whole screen
897
+ // ESC[?69l left/right margin mode OFF (DECLRMM) — ESC[r does
898
+ // NOT release margins, which is why resetting only the
899
+ // region on exit was never enough
900
+ //
901
+ // And two that a KILLED kiso leaves set, which REL-0152-D14
902
+ // introduced and this is the first thing to defend against it: a
903
+ // frame turns autowrap off and the cursor invisible and restores
904
+ // both at its end, so a process that dies BETWEEN those two points
905
+ // hands the shell — and the next kiso — a terminal with wrapping
906
+ // off and no cursor. `kill -9` cannot be caught, so the entry is
907
+ // the only place this can be repaired, and a product whose claim
908
+ // is that it survives kill -9 has to repair it there.
909
+ //
910
+ // ESC[?7h autowrap back to its default
911
+ // ESC[?25h the cursor visible again
912
+ //
913
+ // Idempotent, sixteen bytes, once per session, and correct whether
914
+ // or not anything was actually left set.
915
+ this.#needsReset = true;
871
916
  this.#attachResize();
872
917
  this.#fullRedraw = true;
873
918
  this.#dirty = true;
@@ -877,6 +922,13 @@ export class Body {
877
922
  * chrome rows cleared, the cursor home at the input line. */
878
923
  exit() {
879
924
  this.#unguard?.();
925
+ // REL-0152-D18: a drag in flight must not repaint into a torn-down
926
+ // dock — the timer outlives the compositor otherwise.
927
+ if (this.#resizeTimer !== null) {
928
+ clearTimeout(this.#resizeTimer);
929
+ this.#resizeTimer = null;
930
+ }
931
+ this.#resizePending = false;
880
932
  // TUI2-R3v2 ②: the mouse disable rides the SAME teardown as CSI r,
881
933
  // and rides it BEFORE the docked guard — an un-docked compositor is
882
934
  // exactly the state a superseded or half-torn-down one is in, and
@@ -936,26 +988,56 @@ export class Body {
936
988
  * change, and the cursor parks on the wrong row. Three PTY gates
937
989
  * caught it.
938
990
  *
939
- * The fix is not a list of writers to remember. stdout is wrapped
940
- * while the dock is up: anything written that is not this frame's own
941
- * bytes forgets the screen, and the next frame repaints. New callers
942
- * cannot get it wrong because they are not asked to get it right.
991
+ * REL-0152-D17: BOTH descriptors, and the second one is the one that
992
+ * mattered. The first version of this guard wrapped stdout alone,
993
+ * while kiso's degradation notices go to stderr through
994
+ * `console.error` and several of them BEGIN with `[` and contain
995
+ * `]`:
996
+ *
997
+ * [extensions] … [project .kiso] …
998
+ * [KISO_FAUX_SCRIPT] … [run failed] …
999
+ *
1000
+ * Those land on the tty wherever the cursor is, and a renderer that
1001
+ * does not know they were printed skips exactly the rows that would
1002
+ * repair them. The residue survives on the rows whose desired content
1003
+ * NEVER changes — the composer box's own edges — which is a stray `[`
1004
+ * at the left and `]` at the right for the rest of the session,
1005
+ * clearing only on a resize and returning on the next launch.
1006
+ *
1007
+ * The reasoning that missed it was mine, and it is worth keeping: the
1008
+ * renderer emits no OSC and no bare `]`, so a `]` on screen CANNOT
1009
+ * have come from its stream. I had that fact and concluded "therefore
1010
+ * the terminal invents it" when the only sound conclusion is
1011
+ * "therefore something else wrote it".
1012
+ *
1013
+ * The fix is not a list of writers to remember, and not silencing the
1014
+ * loggers. A compositor cannot hold a belief about a terminal it does
1015
+ * not own: any write it did not make forgets the screen, whichever
1016
+ * descriptor carried it. New callers cannot get it wrong because they
1017
+ * are not asked to get it right.
943
1018
  */
944
- #guardStdout() {
1019
+ #guardOutput() {
945
1020
  if (this.#unguard !== null || this.#opts.write !== undefined)
946
1021
  return;
947
- const real = process.stdout.write.bind(process.stdout);
948
- const patched = ((chunk, ...rest) => {
949
- if (!this.#inFrame)
950
- this.#screen = [];
951
- return real(chunk, ...rest);
952
- });
953
- process.stdout.write = patched;
1022
+ const restores = [];
1023
+ for (const stream of [process.stdout, process.stderr]) {
1024
+ const real = stream.write.bind(stream);
1025
+ const patched = ((chunk, ...rest) => {
1026
+ if (!this.#inFrame)
1027
+ this.#screen = [];
1028
+ return real(chunk, ...rest);
1029
+ });
1030
+ stream.write = patched;
1031
+ restores.push(() => {
1032
+ // only restore what we installed — another wrapper may have
1033
+ // been layered on top since (the byte trace does exactly that)
1034
+ if (stream.write === patched)
1035
+ stream.write = real;
1036
+ });
1037
+ }
954
1038
  this.#unguard = () => {
955
- // only restore what we installed — another wrapper may have
956
- // been layered on top since (the byte trace does exactly that)
957
- if (process.stdout.write === patched)
958
- process.stdout.write = real;
1039
+ for (const r of restores)
1040
+ r();
959
1041
  this.#unguard = null;
960
1042
  };
961
1043
  }
@@ -971,9 +1053,50 @@ export class Body {
971
1053
  * live region's first row) — NEVER at the formula's committed-count
972
1054
  * top: external writes (the CLI's console.error CRLF) can shift the
973
1055
  * committed content down, and a formula-top ED0 would clear it. */
1056
+ /**
1057
+ * REL-0152-D18 — a window DRAG is one resize, not forty.
1058
+ *
1059
+ * SIGWINCH fires continuously while a drag is in progress — every few
1060
+ * pixels is another signal — and this used to answer each one at once
1061
+ * with an erase and a full repaint. A real terminal REFLOWS on a width
1062
+ * change and pushes the rows it displaces into its scrollback, so
1063
+ * every one of those repaints deposited another copy of the screen
1064
+ * into the history. The owner's two-second drag left six identical
1065
+ * copies of the same tool block in the transcript.
1066
+ *
1067
+ * Nothing clever can undo that afterwards: the scrollback is not ours
1068
+ * to rewrite. The only fix is to not paint into the middle of a drag.
1069
+ *
1070
+ * The geometry is adopted IMMEDIATELY — every cap and bound is read
1071
+ * from `#opts` at frame time, so the model is already at the new size
1072
+ * and nothing is computed against a stale width. What waits is the
1073
+ * PAINT. When the signals stop, one erase and one repaint.
1074
+ *
1075
+ * The competitor does not have this problem, for a structural reason
1076
+ * worth naming rather than envying: it runs on the alternate screen,
1077
+ * which has no scrollback to accumulate into. kiso is on the primary
1078
+ * screen deliberately — the transcript IS the terminal's own
1079
+ * scrollback, which is the product's whole claim — so it pays for
1080
+ * that choice here, and has to pay carefully.
1081
+ */
974
1082
  onResize() {
975
1083
  if (!this.#isActive())
976
1084
  return;
1085
+ this.#resizePending = true;
1086
+ if (this.#resizeTimer !== null)
1087
+ clearTimeout(this.#resizeTimer);
1088
+ this.#resizeTimer = setTimeout(() => {
1089
+ this.#resizeTimer = null;
1090
+ this.#settleResize();
1091
+ }, RESIZE_SETTLE_MS);
1092
+ if (this.#resizeTimer.unref !== undefined)
1093
+ this.#resizeTimer.unref();
1094
+ }
1095
+ /** The one repaint a drag earns, once its signals have stopped. */
1096
+ #settleResize() {
1097
+ if (!this.#resizePending || !this.#isActive())
1098
+ return;
1099
+ this.#resizePending = false;
977
1100
  const H = this.#opts.height();
978
1101
  const liveRows = this.#lastLiveRows > 0 ? this.#lastLiveRows : 3;
979
1102
  const from = Math.max(1, (this.#lastH > 0 ? this.#lastH : H) - liveRows + 1);
@@ -981,7 +1104,7 @@ export class Body {
981
1104
  this.#fullRedraw = true;
982
1105
  this.#resizeFrame = true;
983
1106
  this.#dirty = true;
984
- this.render(); // the immediate redraw at the NEW geometry
1107
+ this.render();
985
1108
  }
986
1109
  /** W18: the status row's right-aligned hint is part of the status
987
1110
  * state — the compacting row passes "esc to cancel" (the affordance
@@ -1343,6 +1466,15 @@ export class Body {
1343
1466
  // Restored at the end of every frame: prose written OUTSIDE a
1344
1467
  // frame (bodyLog, an error) is ordinary output and must still
1345
1468
  // wrap, and the shell inherits the terminal when kiso exits.
1469
+ if (this.#needsReset) {
1470
+ // REL-0152-D19: released as the FIRST bytes of the FIRST frame
1471
+ // rather than as a write of its own. A separate write would be
1472
+ // one more thing between the dock coming up and its first
1473
+ // frame, and the PTY gates that wait on the boot stream feel
1474
+ // it; inside the frame it is four sequences and no new event.
1475
+ this.#needsReset = false;
1476
+ out.push("\x1b[r\x1b[?69l\x1b[?7h\x1b[?25h");
1477
+ }
1346
1478
  out.push("\x1b[?7l");
1347
1479
  out.push(this.#conservative ? "\x1b[?25l" : "\x1b[?2026h"); // D1: sync ON, or cursor-hide where 2026 is dead bytes
1348
1480
  // A8: the bottom-anchored window (the model's last H rows) shifts
package/dist/editor.d.ts CHANGED
@@ -72,6 +72,10 @@ export declare class Editor {
72
72
  * human asks for them — ctrl+V, or an empty paste. The CLI owns the
73
73
  * platform, the editor owns the moment. */
74
74
  onClipboardPaste(cb: () => string | null): void;
75
+ /** REL-0152-D16: the files this line's `[Image #N]` capsules stand
76
+ * for, by their number. The CLI resolves them when it builds the
77
+ * turn; a capsule the human deleted is simply never looked up. */
78
+ attachments(): Map<number, string>;
75
79
  onRedirect(cb: (line: string) => void): void;
76
80
  /** W22: bind the pending-turn queue — the CLI's live slots. The ↑
77
81
  * pop walks them (each pop leaves the queue, cancelling the turn);
package/dist/editor.js CHANGED
@@ -131,6 +131,24 @@ export class Editor {
131
131
  * every entry is text they chose to paste and may still submit.
132
132
  */
133
133
  #pastes = new Map();
134
+ /**
135
+ * REL-0152-D16 — which file each `[Image #N]` capsule stands for.
136
+ *
137
+ * D15 made ctrl+V fetch the clipboard and it worked; then it inserted
138
+ * the PATH, the path began with `/`, and the composer handed it to
139
+ * the slash-command dispatcher. A feature that reaches the last step
140
+ * and gives the result to the wrong parser has not shipped.
141
+ *
142
+ * So the buffer carries a token and this carries the file. The token
143
+ * is what the LINE is — the dispatcher sees `[Image #1]`, which is
144
+ * not a command and never could be — and the CLI reads this map when
145
+ * it builds the turn. Unlike the text capsule, the image is NOT
146
+ * expanded into the line on the way out: a path is not something the
147
+ * model should be sent, and a transcript full of temp-file names is
148
+ * not something the human should have to read.
149
+ */
150
+ #attachments = new Map();
151
+ #attachSeq = 0;
134
152
  #pasteSeq = 0;
135
153
  /** The buffer index where the in-flight paste began; null outside one. */
136
154
  #pasteAt = null;
@@ -311,6 +329,12 @@ export class Editor {
311
329
  onClipboardPaste(cb) {
312
330
  this.#onClipboardPaste = cb;
313
331
  }
332
+ /** REL-0152-D16: the files this line's `[Image #N]` capsules stand
333
+ * for, by their number. The CLI resolves them when it builds the
334
+ * turn; a capsule the human deleted is simply never looked up. */
335
+ attachments() {
336
+ return new Map(this.#attachments);
337
+ }
314
338
  onRedirect(cb) {
315
339
  this.#redirectCbs.push(cb);
316
340
  }
@@ -1205,9 +1229,13 @@ export class Editor {
1205
1229
  // REL-0152-D15: ctrl+V asks for the clipboard. Inert with no
1206
1230
  // hook wired, and 0x16 must NEVER reach the buffer — an
1207
1231
  // unhandled control byte in a prompt is a corrupt prompt.
1208
- const text = this.#onClipboardPaste?.() ?? null;
1209
- if (text !== null && text !== "") {
1210
- for (const ch of text)
1232
+ const file = this.#onClipboardPaste?.() ?? null;
1233
+ if (file !== null && file !== "") {
1234
+ // REL-0152-D16: the capsule goes in the buffer, the file
1235
+ // goes beside it. See #attachments.
1236
+ this.#attachSeq += 1;
1237
+ this.#attachments.set(this.#attachSeq, file);
1238
+ for (const ch of `[Image #${this.#attachSeq}]`)
1211
1239
  this.#insert(ch.codePointAt(0));
1212
1240
  this.#onRender();
1213
1241
  }
@@ -1951,10 +1979,12 @@ export class Editor {
1951
1979
  // REL-0152-D11: an empty paste is the image case — see
1952
1980
  // #onEmptyPaste. Anything it returns is ordinary text from here
1953
1981
  // on and takes the same route as if it had been typed.
1954
- const substitute = this.#onClipboardPaste?.() ?? null;
1955
- if (substitute === null || substitute === "")
1982
+ const file = this.#onClipboardPaste?.() ?? null;
1983
+ if (file === null || file === "")
1956
1984
  return;
1957
- run = [...substitute].map((ch) => ch.codePointAt(0));
1985
+ this.#attachSeq += 1;
1986
+ this.#attachments.set(this.#attachSeq, file);
1987
+ run = [...`[Image #${this.#attachSeq}]`].map((ch) => ch.codePointAt(0));
1958
1988
  }
1959
1989
  if (this.#historyIdx !== null)
1960
1990
  this.#historyIdx = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.15.8",
3
+ "version": "0.15.10",
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.8"
38
+ "@vincemakes/kiso-tui-cells": "0.15.10"
39
39
  }
40
40
  }