@vincemakes/kiso-tui 0.15.3 → 0.15.4

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.
@@ -53,6 +53,11 @@ export interface AskStep {
53
53
  readonly state: AskRuntime;
54
54
  readonly result?: AskResult;
55
55
  }
56
+ /** REL-0152-D4: is the cursor on the type-your-own row? The editor asks
57
+ * before it decides what a printable key means — on this row a key is
58
+ * the first character of an answer, everywhere else it is a shortcut.
59
+ * Exported so that rule lives on ONE definition of the row. */
60
+ export declare function askOnCustomRow(spec: AskSpec, state: AskRuntime): boolean;
56
61
  export declare function askKey(spec: AskSpec, state: AskRuntime, key: string): AskStep;
57
62
  /** The typed answer commits: it becomes THE answer for this question
58
63
  * (clearing its picks) and the walk advances. An empty line is a
package/dist/ask-panel.js CHANGED
@@ -107,6 +107,14 @@ function toggle(state, option, multi) {
107
107
  * option, which is where `askBlockRows` draws it. Options and this row
108
108
  * are one list to the eye, so they are one list to the cursor. */
109
109
  const customRow = (q) => q.options.length;
110
+ /** REL-0152-D4: is the cursor on the type-your-own row? The editor asks
111
+ * before it decides what a printable key means — on this row a key is
112
+ * the first character of an answer, everywhere else it is a shortcut.
113
+ * Exported so that rule lives on ONE definition of the row. */
114
+ export function askOnCustomRow(spec, state) {
115
+ const q = spec.questions[state.qIndex];
116
+ return q !== undefined && state.phase === "options" && state.cursor === customRow(q);
117
+ }
110
118
  export function askKey(spec, state, key) {
111
119
  const q = spec.questions[state.qIndex];
112
120
  const multi = q.multiSelect === true;
@@ -119,7 +127,11 @@ export function askKey(spec, state, key) {
119
127
  }
120
128
  if (key === "esc")
121
129
  return { state, result: askDeclineAll(spec) };
122
- if (key === "t")
130
+ // `t` is the shortcut from anywhere in the list; `type` is the
131
+ // REL-0152-D4 gesture — the editor sends it when the cursor is
132
+ // already on the custom row and a printable key arrives, so the
133
+ // keystroke that opened the phase is also its first character.
134
+ if (key === "t" || key === "type")
123
135
  return { state: { ...state, phase: "custom" } };
124
136
  if (key === "left")
125
137
  return { state: state.qIndex === 0 ? state : { ...state, qIndex: state.qIndex - 1, cursor: 0 } };
@@ -202,9 +214,17 @@ export function askBlockRows(view, state, W, maxRows) {
202
214
  // look like a footnote.
203
215
  const typed = state.custom[state.qIndex];
204
216
  const onCustom = state.cursor === customRow(q);
205
- body.push(`${gutter}${cutLine(typed === null || typed === undefined
206
- ? `${onCustom ? p.bold : p.dim} t type your own answer${p.reset}`
207
- : `${onCustom ? p.bold : ""} t ${escapeTerminal(typed)}${p.reset}`, Math.max(1, W - 2))}`);
217
+ // REL-0152-D4: while the phase is OPEN the row becomes the answer's
218
+ // box a faint placeholder standing where the text will land, so an
219
+ // empty typing phase looks like somewhere to type instead of looking
220
+ // like nothing happened. The placeholder is dim and the answer is
221
+ // not: the two can never be mistaken for each other.
222
+ const typingHere = state.phase === "custom";
223
+ body.push(`${gutter}${cutLine(typed !== null && typed !== undefined
224
+ ? `${onCustom || typingHere ? p.bold : ""} t ◉ ${escapeTerminal(typed)}${p.reset}`
225
+ : typingHere
226
+ ? `${p.bold} t ▸${p.reset} ${p.dim}type your answer — enter sends, esc backs out${p.reset}`
227
+ : `${onCustom ? p.bold : p.dim} t type your own answer${p.reset}`, Math.max(1, W - 2))}`);
208
228
  // the bounded block: the options fold nothing and cut individually,
209
229
  // so the cap drops whole rows with the W21 notice row.
210
230
  const budget = Math.max(1, maxRows - 5);
package/dist/editor.js CHANGED
@@ -21,6 +21,7 @@
21
21
  * (?2004h) unwraps and inserts its newlines LITERALLY; every newline
22
22
  * source funnels through the ONE normalizer in feed() (§3).
23
23
  */
24
+ var _a;
24
25
  import { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
25
26
  // the width primitives moved to width.ts (W1, the single width
26
27
  // authority) — re-exported so the editor's public surface is unchanged.
@@ -29,7 +30,7 @@ import { palette } from "./render.js";
29
30
  import { PICK_MAX, panelOptions, saferDegradedNote, } from "./approval-panel.js";
30
31
  // KC3.5: the panel-slot dispatchers — the ask branch folded into the
31
32
  // W21 lead/rows, so this file keeps ONE panel and one key owner.
32
- import { askCommitCustom, askKey, askStart, panelLead } from "./ask-panel.js";
33
+ import { askCommitCustom, askKey, askOnCustomRow, askStart, panelLead } from "./ask-panel.js";
33
34
  import { AT_VISIBLE, atFilter } from "./at-picker.js";
34
35
  // TUI2-R2 ②: the session picker — the band's THIRD occupant. Its filter
35
36
  // is the @ picker's rank aimed at the session id; the editor owns the
@@ -110,6 +111,29 @@ export class Editor {
110
111
  // user's next turn.
111
112
  #panel = null;
112
113
  #pasting = false;
114
+ /**
115
+ * REL-0152-D8 — the paste capsule.
116
+ *
117
+ * A paste large enough to break the composer's layout is held HERE
118
+ * and shown in the buffer as `[Pasted text #N +M lines]`. The buffer
119
+ * is the display; this map is the content; the line that LEAVES the
120
+ * editor is the content again. That ordering is the whole design —
121
+ * the capsule can never truncate what gets sent, because expansion
122
+ * happens on the way out and reads from a map the display cannot
123
+ * edit.
124
+ *
125
+ * A capsule the human deletes is a paste that never happened: the
126
+ * token is gone, the expansion finds nothing to replace, and the
127
+ * entry is simply never read. That is how you take a paste back.
128
+ *
129
+ * The map is per-editor and grows by one entry per large paste in a
130
+ * session — bounded by how many times a human can press cmd-V, and
131
+ * every entry is text they chose to paste and may still submit.
132
+ */
133
+ #pastes = new Map();
134
+ #pasteSeq = 0;
135
+ /** The buffer index where the in-flight paste began; null outside one. */
136
+ #pasteAt = null;
113
137
  /** TUI2-R3v2 ①: one-shot — a panel that just closed swallows the
114
138
  * habitual trailing enter rather than submitting the restored draft. */
115
139
  #swallowEnter = false;
@@ -795,6 +819,22 @@ export class Editor {
795
819
  i += 1;
796
820
  continue;
797
821
  }
822
+ // REL-0152-D4 — on the custom row a printable key is TEXT.
823
+ // The row names typing as its purpose and then swallowed
824
+ // the first thing you typed; only enter or `t` opened the
825
+ // phase. Now the keystroke opens it AND lands in the
826
+ // buffer, so the character you meant is the character you
827
+ // get. This is checked BEFORE the shortcut branch below on
828
+ // purpose: on this row "3" and "t" are the start of an
829
+ // answer, not a pick and not a mode key. Everywhere else
830
+ // in the list they keep their fast-path meaning exactly.
831
+ if (askOnCustomRow(panel.view.ask, panel.ask) && c !== undefined && c >= " " && c !== "\x7f") {
832
+ this.#askStep("type");
833
+ this.#insert(c.codePointAt(0));
834
+ this.#onRender();
835
+ i += 1;
836
+ continue;
837
+ }
798
838
  if (!typing && (c === " " || (c !== undefined && c >= "1" && c <= "4") || c === "t" || c === "T")) {
799
839
  this.#askStep(c === " " ? "space" : c === "T" ? "t" : c);
800
840
  i += 1;
@@ -1186,10 +1226,13 @@ export class Editor {
1186
1226
  const n = Number(params);
1187
1227
  if (n === 3)
1188
1228
  this.#delete();
1189
- else if (n === 200)
1229
+ else if (n === 200) {
1190
1230
  this.#pasting = true;
1231
+ this.#pasteAt = this.#cursor; // REL-0152-D8: where the capsule will go
1232
+ }
1191
1233
  else if (n === 201) {
1192
1234
  this.#pasting = false;
1235
+ this.#encapsulate();
1193
1236
  this.#onRender();
1194
1237
  }
1195
1238
  }
@@ -1568,7 +1611,10 @@ export class Editor {
1568
1611
  return;
1569
1612
  const spec = panel.view.ask;
1570
1613
  const before = panel.ask.phase;
1571
- const step = key === "commit" ? askCommitCustom(spec, panel.ask, this.line()) : askKey(spec, panel.ask, key);
1614
+ // REL-0152-D8: a typed ask answer is a line leaving the editor too —
1615
+ // pasting a stack trace into "type your own answer" must send the
1616
+ // stack trace, not the capsule that stands for it.
1617
+ const step = key === "commit" ? askCommitCustom(spec, panel.ask, this.#expandPastes(this.line())) : askKey(spec, panel.ask, key);
1572
1618
  panel.ask = step.state;
1573
1619
  if (step.state.phase !== before) {
1574
1620
  this.#chars = [];
@@ -1737,6 +1783,67 @@ export class Editor {
1737
1783
  * reset together (W22: a departing line ends the pop-walk, so the
1738
1784
  * next esc at rest interrupts again). Shared by the submit and the
1739
1785
  * redirect — the two doors a line can leave by. */
1786
+ /**
1787
+ * REL-0152-D8 — how big a paste has to be before it is a capsule.
1788
+ *
1789
+ * LINES first, because lines are what actually break the layout: the
1790
+ * composer grows a row per line and walks up the terminal. The
1791
+ * character bound catches the pathological one-liner, which wraps to
1792
+ * the same screenful by another route.
1793
+ *
1794
+ * Below both, the paste is left exactly as it arrived. A four-line
1795
+ * snippet is something you want to SEE in the composer, and a capsule
1796
+ * there would be pure obstruction.
1797
+ */
1798
+ static #PASTE_LINES = 8;
1799
+ static #PASTE_CHARS = 900;
1800
+ /** The token a capsule shows as. Parsed back by the same regexp on
1801
+ * the way out — one definition, so the two can never drift. */
1802
+ static #capsuleText(id, lines) {
1803
+ return `[Pasted text #${id} +${lines} line${lines === 1 ? "" : "s"}]`;
1804
+ }
1805
+ static #CAPSULE = /\[Pasted text #(\d+) \+\d+ lines?\]/g;
1806
+ /**
1807
+ * Close an in-flight paste: if it was large, swap the pasted run out
1808
+ * of the buffer for its capsule and keep the text.
1809
+ *
1810
+ * The swap is a splice at the recorded start, so a paste in the
1811
+ * MIDDLE of a line leaves the prose on both sides of it untouched —
1812
+ * the capsule is a character run like any other from here on, and
1813
+ * every editing operation in this file works on it without knowing
1814
+ * it exists.
1815
+ */
1816
+ #encapsulate() {
1817
+ const start = this.#pasteAt;
1818
+ this.#pasteAt = null;
1819
+ if (start === null || this.#cursor <= start)
1820
+ return;
1821
+ const pasted = String.fromCodePoint(...this.#chars.slice(start, this.#cursor));
1822
+ 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;
1830
+ this.#reflow();
1831
+ }
1832
+ /**
1833
+ * The way out: every capsule token becomes its text again.
1834
+ *
1835
+ * Applied to the line the editor HANDS OVER, never to the buffer —
1836
+ * so what the human sees stays short and what the model receives is
1837
+ * what the human pasted. A token whose entry is missing (a stale id
1838
+ * recalled from history after the map moved on) is left standing as
1839
+ * literal text rather than silently becoming an empty string: a
1840
+ * visible oddity beats a silent deletion of someone's paste.
1841
+ */
1842
+ #expandPastes(line) {
1843
+ if (this.#pastes.size === 0)
1844
+ return line;
1845
+ return line.replace(_a.#CAPSULE, (whole, id) => this.#pastes.get(Number(id)) ?? whole);
1846
+ }
1740
1847
  #takeLine() {
1741
1848
  const line = String.fromCodePoint(...this.#chars);
1742
1849
  this.#chars = [];
@@ -1850,16 +1957,21 @@ export class Editor {
1850
1957
  }
1851
1958
  }
1852
1959
  const line = this.#takeLine();
1960
+ // REL-0152-D8: the capsule expands ON THE WAY OUT. The consumer
1961
+ // gets what was pasted; the HISTORY keeps the short form, so ↑
1962
+ // recalls a readable line that still expands when it is sent
1963
+ // again (the map outlives the buffer, by design).
1964
+ const sent = this.#expandPastes(line);
1853
1965
  const cb = this.#questionCb;
1854
1966
  this.#questionCb = null;
1855
1967
  if (cb !== null) {
1856
- cb(line);
1968
+ cb(sent);
1857
1969
  }
1858
1970
  else if (this.#lineCb !== null) {
1859
- this.#lineCb(line);
1971
+ this.#lineCb(sent);
1860
1972
  }
1861
1973
  else {
1862
- this.#pendingLines.push(line); // nobody wired yet — hold it
1974
+ this.#pendingLines.push(sent); // nobody wired yet — hold it
1863
1975
  }
1864
1976
  if (cb === null && line !== "")
1865
1977
  this.#remember(line);
@@ -1949,3 +2061,4 @@ export class Editor {
1949
2061
  return end;
1950
2062
  }
1951
2063
  }
2064
+ _a = Editor;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.15.3",
3
+ "version": "0.15.4",
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.3"
38
+ "@vincemakes/kiso-tui-cells": "0.15.4"
39
39
  }
40
40
  }