@lamemind/loom-deck 0.55.0 → 0.56.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.
package/dist/input.js ADDED
@@ -0,0 +1,469 @@
1
+ // Il DISPATCH dell'input: tastiera e mouse, un ramo per tasto, nessun corpo di
2
+ // azione.
3
+ //
4
+ // Esce per ultimo dallo split (T131) e la sua firma è la prova del perché:
5
+ // riceve sei oggetti. Estratto per primo ne avrebbe richiesti venticinque —
6
+ // `onKey` tocca il focus, la selezione, la vista, le voci launch, l'identità,
7
+ // le conversazioni, i pin, i binding, i quattro overlay, la geometria e una
8
+ // dozzina di funzioni. Dopo che modello, attuatore e overlay hanno un nome,
9
+ // ognuna di quelle cose vive dentro uno di sei oggetti. È la lezione di T104
10
+ // ripetuta: l'input non si estrae per primo, si estrae quando ciò che dispaccia
11
+ // ha smesso di essere anonimo.
12
+ //
13
+ // `frame` arriva come VALORE già calcolato, non come closure. Prima di T131 la
14
+ // geometria delle liste nasceva seicento righe più in basso di `onListClick` e
15
+ // funzionava solo perché la closure di `useInput` legge il binding quando
16
+ // l'evento arriva, cioè dopo la fine del render. Con il dispatch in un hook
17
+ // chiamato in testa al componente quel binding non esisterebbe ancora, quindi
18
+ // il calcolo della geometria è stato hoistato sopra la chiamata. Il
19
+ // comportamento non cambia: il mouse esce comunque su `mode !== 'normal'`.
20
+ import { useEffect, useRef } from 'react';
21
+ import { useApp, useInput } from 'ink';
22
+ import { hitRegion, isWheel, listHit, takeMouse, wheelDir, WHEEL_LINES, } from './mouse.js';
23
+ import { LAUNCH_ROW } from './frame.js';
24
+ import { moveSelection } from './session-list.js';
25
+ import { captures, scrolls } from './input-modes.js';
26
+ import { META_ROWS, QUIT_WINDOW_MS } from './model.js';
27
+ import { TASK_VIEWS, taskView } from './pane-views.js';
28
+ import { loadTaskFileText } from './tasks.js';
29
+ // T116 — l'avviso della prima pressione di `^C`. La durata è INTERPOLATA dalla
30
+ // finestra, non ricopiata: un numero scritto a mano in un testo che promette un
31
+ // comportamento diventa falso il giorno che la costante cambia, e nessuno
32
+ // strumento lo segnala. Vive a livello di modulo perché ha due lettori — il ramo
33
+ // che la scrive e il timer che la ritira, e quest'ultimo deve riconoscerla.
34
+ export const QUIT_NOTE = `⚠ ^C di nuovo entro ${QUIT_WINDOW_MS / 1000}s per chiudere il deck`;
35
+ // T21 — il `Key` che accompagna un tasto sintetizzato da un click: nessun
36
+ // modificatore, nessun tasto speciale. Deve elencare ogni campo, o i rami che
37
+ // leggono `key.ctrl` o `key.tab` riceverebbero `undefined` invece di `false` —
38
+ // equivalente nel test di verità, ma non nel tipo.
39
+ export const NO_MODIFIERS = {
40
+ upArrow: false,
41
+ downArrow: false,
42
+ leftArrow: false,
43
+ rightArrow: false,
44
+ pageDown: false,
45
+ pageUp: false,
46
+ return: false,
47
+ escape: false,
48
+ ctrl: false,
49
+ shift: false,
50
+ tab: false,
51
+ backspace: false,
52
+ delete: false,
53
+ meta: false,
54
+ };
55
+ export function useDeckInput({ tasksDir, mode, setMode, setNote, model, actions, overlays, frame, launchRegions, }) {
56
+ // T116 — uscita a doppio `^C`. Il timer È lo stato dell'armamento: finché il
57
+ // handle esiste la finestra è aperta, e non serve un secondo stato da tenere
58
+ // in fase con lui. In un `useRef` e non in `useState` perché il ramo di uscita
59
+ // lo legge NELLO STESSO tasto in cui potrebbe averlo scritto — un valore di
60
+ // stato React arriverebbe al render dopo, cioè troppo tardi per decidere.
61
+ const { exit } = useApp();
62
+ const quitTimer = useRef(null);
63
+ // Il timer pendente è un handle vivo del loop di Node: lasciarlo appeso allo
64
+ // smontaggio terrebbe il processo in piedi fino allo scadere della finestra,
65
+ // con lo schermo già restituito al terminale.
66
+ useEffect(() => () => {
67
+ if (quitTimer.current)
68
+ clearTimeout(quitTimer.current);
69
+ }, []);
70
+ // Il dispatch consulta il CATALOGO (`input-modes.ts`), non l'ordine di una
71
+ // catena di `if`. `MODE_KEYS` è il custode di compilazione: essendo un
72
+ // `Record` su `CapturingMode`, un modo nuovo senza handler non compila.
73
+ // T131 — e ogni voce viene da un overlay, mai da una funzione dichiarata qui:
74
+ // è ciò che `test/input-wiring.test.ts` verifica, dopo che T112 aveva
75
+ // dimostrato che il solo `Record` non lo impedisce.
76
+ const MODE_KEYS = {
77
+ detail: overlays.sheet.onKey,
78
+ status: overlays.status.onKey,
79
+ reader: overlays.search.onReaderKey,
80
+ search: overlays.search.onSearchKey,
81
+ assign: overlays.assign.onKey,
82
+ create: overlays.text.onCreateKey,
83
+ note: overlays.text.onNoteKey,
84
+ sort: overlays.view.onSortKey,
85
+ filter: overlays.view.onFilterKey,
86
+ edit: overlays.text.onEditKey,
87
+ purge: overlays.purge.onKey,
88
+ };
89
+ // T21 (mandata 2) — la ROTELLA, per i soli modi che scorrono un contenuto
90
+ // lungo (`SCROLLING_MODES`). Stessa forma di `MODE_KEYS`: un `Record` sul
91
+ // catalogo, quindi un modo dichiarato scorrevole senza uno scroll da chiamare
92
+ // non compila. Il delta è in righe, col segno del verso.
93
+ const MODE_WHEEL = {
94
+ detail: overlays.sheet.scroll,
95
+ status: overlays.status.scroll,
96
+ reader: overlays.search.scrollReader,
97
+ };
98
+ /**
99
+ * T116 — `^C`: la prima pressione ARMA, la seconda entro la finestra chiude.
100
+ *
101
+ * Prima di questa task `^C` usciva subito, e a chiuderlo era Ink stesso
102
+ * (`exitOnCtrlC`, di default vero) PRIMA che il tasto raggiungesse
103
+ * `useInput`. Spegnere quell'opzione è la condizione perché il ramo esista —
104
+ * ma spegnerla e basta lascerebbe il deck senza NESSUNA uscita da tastiera:
105
+ * il ramo qui sotto è ciò che restituisce quella via, e i due cambi non si
106
+ * possono separare.
107
+ *
108
+ * La prima pressione RIPORTA IN `normal` quando si è dentro un modo
109
+ * capturing. L'avviso vive nella riga di stato, e le tre schermate
110
+ * sostitutive (detail, ricerca, reader) prendono l'intero frame senza
111
+ * renderizzarla: armare restando lì dentro darebbe un deck che sembra
112
+ * ignorare il tasto e che alla pressione successiva sparisce senza aver mai
113
+ * avvertito. Chiudere lo strato è anche coerente con `esc` — `^C` dice
114
+ * «voglio uscire», e il primo passo fuori è la lista.
115
+ */
116
+ function onQuitKey() {
117
+ if (quitTimer.current) {
118
+ clearTimeout(quitTimer.current);
119
+ quitTimer.current = null;
120
+ exit();
121
+ return;
122
+ }
123
+ if (captures(mode))
124
+ setMode('normal');
125
+ setNote(QUIT_NOTE);
126
+ quitTimer.current = setTimeout(() => {
127
+ quitTimer.current = null;
128
+ // Solo se l'avviso è ANCORA il proprio: nei 5 secondi una qualunque altra
129
+ // azione può aver scritto in riga di stato, e cancellare quella nota
130
+ // significherebbe far sparire il feedback di un'azione che non c'entra.
131
+ setNote((n) => (n === QUIT_NOTE ? '' : n));
132
+ }, QUIT_WINDOW_MS);
133
+ }
134
+ function onMouse(ev) {
135
+ // Un click produce due eventi (pressione e rilascio): agire su entrambi
136
+ // spawnerebbe due volte. La rotella arriva come sola pressione, quindi il
137
+ // filtro su `press` la lascia passare e non la dedoppia.
138
+ if (!ev.press)
139
+ return;
140
+ if (isWheel(ev.button)) {
141
+ // La rotella NON rientra dalla porta della tastiera come il click: nel
142
+ // detail `↑↓` muovono il fuoco fra i campi (T117), non il testo, e il
143
+ // tasto che scorre — `PgUp`/`PgDn` — ha la granularità sbagliata per una
144
+ // tacca. Va quindi allo scroll del modo, in righe. Fuori dai modi
145
+ // scorrevoli è inerte: nelle liste la rotella non muove mai la
146
+ // selezione (D5), e una tacca mentre il deck è sulla lista non deve
147
+ // fare niente.
148
+ if (scrolls(mode))
149
+ MODE_WHEEL[mode](wheelDir(ev.button) * WHEEL_LINES);
150
+ return;
151
+ }
152
+ // Il bottone si legge sui due bit bassi, perché gli alti portano i
153
+ // modificatori (shift/meta/ctrl).
154
+ if ((ev.button & 3) !== 0)
155
+ return;
156
+ // Le superfici esistono solo nella lista: un modo capturing prende il
157
+ // frame intero e a quella riga c'è dell'altro.
158
+ if (mode !== 'normal')
159
+ return;
160
+ if (ev.row === LAUNCH_ROW) {
161
+ const hit = hitRegion(launchRegions, ev.col);
162
+ // Il click NON chiama l'azione: rientra dalla porta della tastiera col
163
+ // tasto che la superficie annuncia. Un ramo nuovo su `t`/`c`/cifre nasce
164
+ // così già cliccabile, e non esiste un secondo posto in cui il click possa
165
+ // dire una cosa diversa dal tasto che gli sta scritto sopra.
166
+ if (hit)
167
+ onKey(hit, NO_MODIFIERS);
168
+ return;
169
+ }
170
+ onListClick(ev);
171
+ }
172
+ /**
173
+ * T21 (mandata 3) — click su un elemento di lista = SOLO FUOCO (D2): il pane
174
+ * prende il focus e la selezione va sull'elemento, nessuna azione parte. Qui
175
+ * il click non sintetizza un tasto, perché nessun tasto nomina una riga o una
176
+ * vista — `↑↓` e `tab` sono relativi — e chiama i setter che la tastiera
177
+ * usa a sua volta. Un click sulla vista già attiva si limita al focus.
178
+ *
179
+ * ECCEZIONE: la riga già selezionata col pane già a fuoco. Lì un tasto che
180
+ * la nomina ESISTE — `⏎`, che agisce proprio su «la riga selezionata» — e il
181
+ * click lo sintetizza, come le superfici della riga launch: due click sulla
182
+ * stessa riga aprono il detail della task o fanno il resume della
183
+ * conversazione, e ogni caso limite (pin stale, nessuna sessione) resta per
184
+ * costruzione quello del tasto. Le righe meta ne sono fuori: `⏎` su di loro
185
+ * non fa nulla, e un click che lo sintetizzasse produrrebbe solo la nota di
186
+ * scarto di `selectedTaskOr`.
187
+ */
188
+ function onListClick(ev) {
189
+ if (!frame.listGeometry)
190
+ return;
191
+ const hit = listHit(ev, frame.listGeometry);
192
+ if (!hit)
193
+ return;
194
+ if (hit.target === 'view') {
195
+ model.setFocus(hit.pane);
196
+ if (hit.pane === 'tasks')
197
+ model.selectTaskView(hit.key);
198
+ else
199
+ model.selectSessionView(hit.key);
200
+ return;
201
+ }
202
+ if (hit.pane === 'tasks') {
203
+ // Le due righe meta hanno indice fisso; le task riportano l'indice di
204
+ // finestra a quello della lista completa, su cui è keyata la selezione.
205
+ const index = hit.index < META_ROWS ? hit.index : frame.taskWin.start + hit.index;
206
+ if (model.focus === 'tasks' && index === model.selIndex && index >= META_ROWS) {
207
+ onKey('', { ...NO_MODIFIERS, return: true });
208
+ return;
209
+ }
210
+ model.setFocus('tasks');
211
+ model.selectTaskRow(index);
212
+ }
213
+ else {
214
+ const row = frame.windowRows[hit.index];
215
+ if (!row)
216
+ return;
217
+ if (model.focus === 'sessions' && row.sessionId === model.selSessionId) {
218
+ onKey('', { ...NO_MODIFIERS, return: true });
219
+ return;
220
+ }
221
+ model.setFocus('sessions');
222
+ model.setSelSessionId(row.sessionId);
223
+ }
224
+ }
225
+ function onKey(input, key) {
226
+ // T116 — `^C` sta SOPRA il dispatch dei modi, unico tasto a scavalcarlo.
227
+ // Ogni altra combo `ctrl` è un acceleratore, e un acceleratore dentro un
228
+ // modo capturing dev'essere inerte (`input-modes.ts`); questo non è un
229
+ // acceleratore ma la via d'uscita dal processo, e una via d'uscita che
230
+ // funziona solo in una schermata su undici non è una via d'uscita. Non è
231
+ // quindi una deroga di `CTRL_DEROGATIONS` — quelle vivono DENTRO un modo e
232
+ // le gestisce il modo, questa li precede tutti.
233
+ if (key.ctrl && input === 'c') {
234
+ onQuitKey();
235
+ return;
236
+ }
237
+ // Un modo capturing consuma TUTTO — acceleratori globali compresi — e le
238
+ // deroghe se le gestisce da sé (`CTRL_DEROGATIONS`). Da qui in giù si è
239
+ // quindi in `normal`, l'unico modo che cede ai `key.ctrl`.
240
+ if (captures(mode)) {
241
+ MODE_KEYS[mode](input, key);
242
+ return;
243
+ }
244
+ // T52/D1 — il ramo CTRL sta PRIMA di quelli su lettera nuda e li chiude
245
+ // tutti. `CTRL+F` e `f` nudo arrivano con lo STESSO `input` ('f'),
246
+ // distinguibili solo da `key.ctrl`: senza questa precedenza `CTRL+F`
247
+ // cadrebbe nel ramo fork e spawnerebbe una sessione invece di aprire la
248
+ // ricerca. Non è un caso isolato della `f` — `^Q` finirebbe nel quit, `^T`
249
+ // aprirebbe un terminale, `^C` … ogni lettera legata a un'azione ha la sua
250
+ // combo omonima. Chiudere qui l'intera classe è più solido che ricordarsi
251
+ // un `!key.ctrl` su ognuno dei rami, oggi e a ogni tasto aggiunto domani.
252
+ if (key.ctrl) {
253
+ if (input === 'f') {
254
+ setNote('');
255
+ setMode('search');
256
+ }
257
+ else if (input === 'k') {
258
+ actions.spawnTaskSession('recap', '^K');
259
+ }
260
+ else if (input === 'p') {
261
+ actions.spawnTaskSession('preflight', '^P');
262
+ }
263
+ else if (input === 'r') {
264
+ actions.spawnTaskSession('run', '^R');
265
+ }
266
+ else if (input === 'g') {
267
+ // T121 — GENERA e APRI su due tasti distinti (D2): aprire un recap
268
+ // vecchio deve costare zero, e generare non deve essere un effetto
269
+ // collaterale del guardare. Nessuno dei due è preso né bruciato da Ink,
270
+ // e `^S`/`^Q` sono evitati perché il terminale li intercetta come flow
271
+ // control.
272
+ overlays.status.generate();
273
+ }
274
+ else if (input === 'o') {
275
+ overlays.status.open();
276
+ }
277
+ return;
278
+ }
279
+ if (key.tab) {
280
+ // `tab` cicla la VISTA del pane a fuoco, `←→` spostano il focus fra i due
281
+ // pane: il criterio dell'assegnazione è spaziale. Una freccia orizzontale
282
+ // porta con sé una direzione e i pane sono affiancati (task a sinistra,
283
+ // sessioni a destra), quindi il tasto NOMINA il pane invece di limitarsi
284
+ // a scambiarlo; un catalogo ciclico non ha un verso da rispettare, e un
285
+ // tasto solo gli basta. `shift+tab` (backtab, `[Z`) scorre a rovescio — il
286
+ // verso che un tasto singolo non esprime sta nel modificatore, e su cinque
287
+ // voci risparmia quattro pressioni.
288
+ model.cycleView(key.shift ? -1 : 1);
289
+ }
290
+ else if (key.leftArrow || key.rightArrow) {
291
+ // Binding ASSOLUTO, non toggle: `←` porta sempre sui task e `→` sempre
292
+ // sulle sessioni, quindi ripremere lo stesso tasto non riporta indietro.
293
+ // È ciò che lo rende spaziale — la direzione indica una destinazione, e
294
+ // con due soli pane un toggle sarebbe indistinguibile solo per caso.
295
+ model.setFocus(key.leftArrow ? 'tasks' : 'sessions');
296
+ }
297
+ else if (key.upArrow) {
298
+ if (model.focus === 'tasks')
299
+ model.moveTaskSel(-1);
300
+ else
301
+ model.setSelSessionId((id) => moveSelection(model.sessionRows, id, -1));
302
+ }
303
+ else if (key.downArrow) {
304
+ if (model.focus === 'tasks')
305
+ model.moveTaskSel(1);
306
+ else
307
+ model.setSelSessionId((id) => moveSelection(model.sessionRows, id, 1));
308
+ }
309
+ else if (key.return) {
310
+ if (model.focus === 'tasks') {
311
+ // T66 — ⏎ apre il DETAIL, non più una sessione. Secondo rimappaggio in
312
+ // due task (T56 lo spostò da recap a sessione a mani nude), e la
313
+ // direzione è una sola: da azione singola a punto d'ingresso. Il tasto
314
+ // più battuto non è il posto dove inchiodare una scelta di prompt — le
315
+ // shortcut CTRL restano per chi sa già cosa vuole, `⏎` apre il ventaglio.
316
+ // Lo spawn a mani nude di prima è `open`, cioè `⏎ ⏎` (è il focus iniziale).
317
+ const task = actions.selectedTaskOr('⏎', 'aprire');
318
+ if (task) {
319
+ overlays.sheet.open({
320
+ id: task.id,
321
+ // Il titolo del task file quando c'è (è l'H1, cioè la forma
322
+ // lunga), la riga di tasks.md altrimenti: il detail non deve
323
+ // restare senza intestazione solo perché il file manca.
324
+ title: model.detail?.title || task.desc,
325
+ text: loadTaskFileText(tasksDir, task.id),
326
+ });
327
+ }
328
+ }
329
+ else {
330
+ // T49 — ⏎ su una sessione = resume in nuova tab. Il binding si rilegge
331
+ // dal sidecar (non dal padre selezionato): vale anche per le spot.
332
+ const s = model.selSessionObj;
333
+ if (!s) {
334
+ setNote(model.selSessionId
335
+ ? 'pin stale: transcript non più presente'
336
+ : 'nessuna sessione da riprendere');
337
+ }
338
+ else {
339
+ actions.resumeSession(s.sessionId);
340
+ }
341
+ }
342
+ }
343
+ else if (key.delete) {
344
+ // T112/D1 — `CANC` non esiste come tasto distinto in Ink: `\x7f`
345
+ // (Backspace) e `\x1b[3~` (Canc) collassano entrambi su `key.delete` con
346
+ // `input` azzerato, quindi a valle non resta niente da cui distinguerli.
347
+ // Il binding vale per entrambi e si accetta come tale: in `normal` non
348
+ // c'è nessun campo di testo da cui Backspace possa rubare un significato,
349
+ // e ogni modo che ne ha uno è capturing e non vede questo ramo. Il modale
350
+ // di conferma è la rete contro chi usa Backspace come «indietro».
351
+ overlays.purge.open();
352
+ }
353
+ else if (input === 'C') {
354
+ overlays.text.openCreate();
355
+ }
356
+ else if (input === 'E') {
357
+ // L'edit ha senso solo su una task reale: le righe meta non ne sono.
358
+ if (!model.selTask)
359
+ setNote('E → nessuna task selezionata');
360
+ else
361
+ overlays.text.openEdit(model.selTask);
362
+ }
363
+ else if (input === 'S') {
364
+ overlays.view.openSort();
365
+ }
366
+ else if (input === 'F') {
367
+ // T100/D3 — i filtri valgono SOLO sulla vista principale: su `nascoste`
368
+ // riapplicarli non ha senso (quella vista È il loro complemento), su
369
+ // `archiviabili` non li si vuole (è cieca ai filtri per decisione). Stessa
370
+ // forma dell'inerzia di ^K/^P/^R dentro il detail, ma keyed sulla vista
371
+ // invece che sul modo — e come là, l'inerzia lo DICE invece di non fare
372
+ // niente in silenzio.
373
+ if (model.taskViewId !== 'tasks') {
374
+ setNote(`F → filtri: solo sulla vista ${TASK_VIEWS[0].label(model.taskCounts)} (ora: ${taskView(model.taskViewId).label(model.taskCounts)})`);
375
+ }
376
+ else {
377
+ overlays.view.openFilter();
378
+ }
379
+ }
380
+ else if (input === 'f') {
381
+ // T28 — fork della sessione selezionata. Minuscola come `t`/`c` (T39):
382
+ // azione immediata, nessun modale — la `F` maiuscola resta ai filtri.
383
+ actions.forkSession();
384
+ }
385
+ else if (input === 'p') {
386
+ // T50 — pin/unpin, gemella di `f`: azione immediata sulla riga
387
+ // selezionata del pane sessioni.
388
+ actions.togglePin();
389
+ }
390
+ else if (input === 'N') {
391
+ // T53 — nota sulla conversazione selezionata. MAIUSCOLA perché apre un
392
+ // modale: nel deck le minuscole sono azioni immediate (`f` fork, `p` pin,
393
+ // `t` term, `c` claude) e le maiuscole aprono un box (`C` create, `E`
394
+ // edit, `S` sort, `F` filtri). Vincolo di focus identico a `p`: vale anche
395
+ // su una pinnata STALE, perché annotare «questa non c'è più, era X» è
396
+ // proprio il caso in cui una nota serve.
397
+ if (model.focus !== 'sessions') {
398
+ setNote('N → titolo: seleziona una sessione (→ per il pane)');
399
+ }
400
+ else if (!model.selSessionId) {
401
+ setNote('N → nessuna sessione da annotare');
402
+ }
403
+ else {
404
+ overlays.text.openNote(model.selSessionId);
405
+ }
406
+ }
407
+ else if (input === 'A') {
408
+ // T57 — assegna la conversazione selezionata a una task. MAIUSCOLA perché
409
+ // apre un modale (convenzione T39), e il modale è obbligatorio: il pane
410
+ // task non può fare da picker, perché spostare la selezione lì cambia il
411
+ // parent e la sessione da assegnare sparisce dalla lista sotto le mani.
412
+ // Vincolo di focus identico a `p`/`N`, e vale anche su una pinnata STALE:
413
+ // il binding è nostro, il transcript è di CC — riassegnare una
414
+ // conversazione il cui transcript non c'è più resta legittimo.
415
+ if (model.focus !== 'sessions') {
416
+ setNote('A → assegna: seleziona una sessione (→ per il pane)');
417
+ }
418
+ else if (!model.selSessionId) {
419
+ setNote('A → nessuna sessione da assegnare');
420
+ }
421
+ else {
422
+ overlays.assign.open(model.selSessionId, model.bindings.get(model.selSessionId));
423
+ }
424
+ }
425
+ else if (input === 't') {
426
+ actions.openTerminal();
427
+ }
428
+ else if (input === 'c') {
429
+ // Minuscola = azione immediata (convenzione T39), gemella di `t`: entrambe
430
+ // aprono una surface del cappello senza passare da un modale. `C` (create
431
+ // task) resta distinta — stessa lettera, ma la maiuscola è per i modali.
432
+ actions.openClaude();
433
+ }
434
+ else if (input === 'w') {
435
+ actions.saveCurrentView();
436
+ }
437
+ else if (input && /^[1-9]$/.test(input)) {
438
+ actions.runLaunchAt(input);
439
+ }
440
+ // Nessun tasto NUDO di uscita: `q` resta libera per un binding futuro, e
441
+ // `esc` in modalità normale è inerte — dentro un overlay continua a
442
+ // chiuderlo, perché quel ramo esce prima di qui. L'unica uscita da tastiera
443
+ // è `^C` battuto due volte (T116, in testa a questo handler); resta poi la
444
+ // chiusura della tab Ptyxis che ospita il processo.
445
+ }
446
+ /**
447
+ * T21 — il MOUSE precede tutto, in ogni modo del deck.
448
+ *
449
+ * Il filtro è incondizionato — anche dentro un campo di testo, anche in un
450
+ * modo che non ha nessuna superficie cliccabile — e non è una precauzione:
451
+ * `useInput` riceve le sequenze SGR come testo (`mouse.ts`, fatto ①), quindi
452
+ * senza filtro un click battuto mentre è aperto il campo del titolo ci
453
+ * scriverebbe dentro `[<0;5;3M`. Un listener raw su stdin non risolverebbe:
454
+ * riceve gli stessi chunk in broadcast e non li sottrae a `useInput`.
455
+ *
456
+ * Il chunk MISTO — un tasto e un click nella stessa scrittura, che arriva
457
+ * come una sola chiamata — si separa a mano: gli eventi vanno al mouse, il
458
+ * testo residuo prosegue verso la tastiera. Solo un chunk di solo mouse
459
+ * chiude qui.
460
+ */
461
+ useInput((raw, key) => {
462
+ const { text, events } = takeMouse(raw);
463
+ for (const ev of events)
464
+ onMouse(ev);
465
+ if (events.length > 0 && !text)
466
+ return;
467
+ onKey(text, key);
468
+ });
469
+ }