@lamemind/loom-deck 0.33.0 → 0.34.1

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.
@@ -0,0 +1,259 @@
1
+ // Overlay RICERCA e READER (T52): stato, derivati e input nello stesso file.
2
+ //
3
+ // I due lati stanno insieme perché sono la stessa cosa vista da due angoli — lo
4
+ // stato descrive cosa il modale mostra, l'handler cosa lo cambia — e separarli
5
+ // metterebbe ogni modifica a cavallo di due file.
6
+ //
7
+ // Reader e ricerca condividono un hook solo perché il primo è un modale DENTRO
8
+ // il secondo: l'occorrenza aperta nel reader è una riga della lista sottostante,
9
+ // e `esc` deve ritrovare quella lista esattamente com'era.
10
+ //
11
+ // Ciò che esce dal deck (il resume di una conversazione) NON sta qui: arriva
12
+ // come callback `onResume`. È il confine del criterio di partizione — gli
13
+ // effetti verso l'esterno stanno a monte, e un overlay li chiede invece di
14
+ // eseguirli.
15
+ import { useEffect, useMemo, useState } from 'react';
16
+ import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions, selectedRow, DEFAULT_OPTIONS, } from '../search.js';
17
+ import { readerCapacity, searchListCapacity } from '../viewport.js';
18
+ import { wrapWithOffsets } from '../width.js';
19
+ import { searchExcerptWidth } from '../layout.js';
20
+ import { sanitizeTyped } from '../glyphs.js';
21
+ import { SEARCH_KIND_KEYS, SEARCH_TOGGLE_KEYS } from '../model.js';
22
+ export function useSearchOverlay(deps) {
23
+ const { sessions, rows, columns, hasNote, setMode, setNote, onResume } = deps;
24
+ // T52 — stato del modale ricerca. VOLATILE per decisione (D4): vive in
25
+ // memoria, quindi riaprendo il modale ritrovi query e toggle come li avevi
26
+ // lasciati, ma un riavvio del deck riporta ai default. Niente schema nuovo su
27
+ // `deck-view.json` e nessuna scrittura implicita su disco — che
28
+ // contraddirebbe la regola T39 «il disco si tocca solo su `w`», qui non
29
+ // trasportabile perché dentro il modale `w` è un carattere digitabile.
30
+ const [hash, setHash] = useState('');
31
+ const [query, setQuery] = useState('');
32
+ const [field, setField] = useState('query');
33
+ const [opts, setOpts] = useState(DEFAULT_OPTIONS);
34
+ const [selKey, setSelKey] = useState(null);
35
+ // Occorrenza aperta nel reader; null = reader chiuso. Tenerla separata dalla
36
+ // selezione della lista è ciò che permette a `esc` di tornare indietro
37
+ // trovando la lista esattamente com'era.
38
+ const [readerRow, setReaderRow] = useState(null);
39
+ const [readerTop, setReaderTop] = useState(0);
40
+ // T52 — ricerca EAGER: rigira a ogni carattere digitato, non su ⏎. È
41
+ // sostenibile perché i corpi sono già in RAM dentro la cache mtime-keyed
42
+ // dell'adapter (D5): misurato su questo progetto, 0,8 ms sui soli corpi IA e
43
+ // ≤9 ms su tutti i tipi — sotto il tempo fra due battute. Il memo evita di
44
+ // rifarla sui re-render che non toccano né query né opzioni (il poll delle
45
+ // sessioni ogni 1,5s è già filtrato dalla signature in `useSessions`).
46
+ const result = useMemo(() => searchSessions(sessions, hash, query, opts, searchExcerptWidth(columns)), [sessions, hash, query, opts, columns]);
47
+ // Con l'hash valorizzato la conversazione è una sola e già nominata nel campo:
48
+ // la riga-sessione ripeterebbe un dato costante rubando una riga per gruppo.
49
+ const flat = hash.trim().length > 0;
50
+ const searchRows = useMemo(() => buildRows(result, flat), [result, flat]);
51
+ // T52 — riga selezionata e, se è un'occorrenza, il suo corpo wrappato per
52
+ // l'anteprima sotto la lista. Memoizzato per (testo, larghezza): navigando
53
+ // con le frecce si ri-wrappa solo quando cambia davvero l'occorrenza.
54
+ const selRow = useMemo(() => selectedRow(searchRows, selKey), [searchRows, selKey]);
55
+ const previewWidth = Math.max(20, (columns || 80) - 8);
56
+ const previewBody = useMemo(() => (selRow?.kind === 'hit' ? wrapWithOffsets(selRow.hit.text, previewWidth) : []), [selRow, previewWidth]);
57
+ // T52 — corpo del messaggio aperto nel reader, wrappato UNA volta per (testo,
58
+ // larghezza). Senza memo ogni pressione di freccia rifarebbe l'a-capo di un
59
+ // messaggio che nella coda lunga arriva a 150k char.
60
+ const readerWidth = Math.max(20, (columns || 80) - 6);
61
+ const readerLines = useMemo(() => (readerRow?.kind === 'hit' ? wrapWithOffsets(readerRow.hit.text, readerWidth) : []), [readerRow, readerWidth]);
62
+ const readerCap = readerCapacity(rows);
63
+ const readerMaxTop = Math.max(0, readerLines.length - readerCap);
64
+ const listCap = searchListCapacity(rows, hasNote);
65
+ // T52 — invariante di validità della selezione, più stretta che altrove: la
66
+ // lista si RICOSTRUISCE a ogni carattere digitato, quindi la chiave
67
+ // selezionata sparisce di continuo. Persa → prima riga, mai una posizione
68
+ // ereditata (che qui punterebbe a un'altra conversazione, non a un'altra riga).
69
+ useEffect(() => {
70
+ if (rowIndexOfKey(searchRows, selKey) < 0) {
71
+ setSelKey(firstRowKey(searchRows));
72
+ }
73
+ }, [searchRows, selKey]);
74
+ // T52 — `⏎` contestuale al TIPO di riga: la lista ne mescola due e l'azione
75
+ // giusta dipende da quale è selezionata. Riga sessione → resume, identico al
76
+ // `⏎` della lista sessioni (stessa funzione, T49). Riga occorrenza → reader.
77
+ function submitRow() {
78
+ const row = selectedRow(searchRows, selKey);
79
+ if (!row) {
80
+ setNote(result.error ? '⚠ regex non valida' : 'nessuna occorrenza selezionata');
81
+ return;
82
+ }
83
+ if (row.kind === 'session') {
84
+ onResume(row);
85
+ return;
86
+ }
87
+ // D8 — il reader si apre POSIZIONATO sull'occorrenza, non in cima. Il 94%
88
+ // dei messaggi entra in una schermata e la differenza non si vede; è sul 6%
89
+ // lungo (p99 ≈ 88 righe, max ≈ 1547) che aprire in cima costringerebbe a
90
+ // rifare a mano la ricerca appena fatta — cioè proprio il lavoro che questa
91
+ // feature esiste per evitare.
92
+ const lines = wrapWithOffsets(row.hit.text, readerWidth);
93
+ const cap = readerCapacity(rows);
94
+ const matchLine = lines.findIndex((l) => l.end > row.hit.matchStart);
95
+ const maxTop = Math.max(0, lines.length - cap);
96
+ setReaderRow(row);
97
+ setReaderTop(Math.max(0, Math.min(maxTop, Math.max(0, matchLine) - Math.floor(cap / 2))));
98
+ setNote('');
99
+ setMode('reader');
100
+ }
101
+ function scrollReader(delta) {
102
+ setReaderTop((t) => Math.max(0, Math.min(readerMaxTop, t + delta)));
103
+ }
104
+ // T52 — toggle di tipo messaggio. Spegnerli tutti è uno stato lecito (lista
105
+ // vuota, nessun errore): è l'utente che ha chiuso ogni canale, non un guasto.
106
+ function toggleKind(kind) {
107
+ setOpts((o) => ({ ...o, kinds: { ...o.kinds, [kind]: !o.kinds[kind] } }));
108
+ }
109
+ function editField(fn) {
110
+ // La nota racconta l'esito di un'AZIONE su una lista che, con l'eager, si
111
+ // ricostruisce a ogni carattere: appena la query cambia è già scaduta.
112
+ // Lasciarla lì la fa leggere come se descrivesse lo stato corrente — nello
113
+ // specifico «nessuna occorrenza selezionata» sopra una lista con una riga
114
+ // visibilmente selezionata, cioè una contraddizione a schermo.
115
+ setNote('');
116
+ if (field === 'hash')
117
+ setHash(fn);
118
+ else
119
+ setQuery(fn);
120
+ }
121
+ // `useInput` consegna il CHUNK letto da stdin, non un tasto: un incollaggio —
122
+ // o una raffica di tasti piu' veloce di una read — arriva come stringa unica,
123
+ // byte di controllo compresi. Due conseguenze, entrambe verificate su pty:
124
+ //
125
+ // 1. I byte di controllo finiscono DENTRO il campo se non li si filtra. Non
126
+ // si vedono, ma Ink li conta nella larghezza della riga e nessun match li
127
+ // soddisfa -> la ricerca smette di trovare senza dire perche'. Le newline
128
+ // diventano spazio invece di sparire: incollare due righe deve separare
129
+ // le parole, non fonderle.
130
+ //
131
+ // 2. Un TAB digitato subito dopo una lettera (~60 ms, cioe' battitura veloce
132
+ // normale) arriva incollato ad essa: `key.tab` resta falso e il ramo del
133
+ // cambio campo non scatta mai. Trattarlo come testo lo renderebbe uno
134
+ // spazio, per giunta nel campo sbagliato — e' cosi' che `70897aff` + Tab
135
+ // + `congelat` finiva tutto quanto nel campo hash.
136
+ //
137
+ // Si spezza quindi il chunk SUL TAB applicando il cambio campo in mezzo: il
138
+ // risultato e' identico alla battitura lenta. Stessa lezione del modale sort
139
+ // (T39), che cicla sui caratteri del chunk invece di leggerlo intero.
140
+ function typeIntoField(chunk) {
141
+ setNote('');
142
+ const parts = chunk.split('\t');
143
+ let cur = field;
144
+ const add = { hash: '', query: '' };
145
+ for (let k = 0; k < parts.length; k++) {
146
+ if (k > 0)
147
+ cur = cur === 'hash' ? 'query' : 'hash';
148
+ add[cur] += sanitizeTyped(parts[k]);
149
+ }
150
+ if (add.hash)
151
+ setHash((v) => v + add.hash);
152
+ if (add.query)
153
+ setQuery((v) => v + add.query);
154
+ setField(cur);
155
+ }
156
+ /** Apertura del modale dal modo normale: `^F`. */
157
+ function open() {
158
+ setNote('');
159
+ setMode('search');
160
+ }
161
+ // Handler del modo `reader`. Sta prima di quello della ricerca nel catalogo di
162
+ // `input-modes.ts`, ma la precedenza la porta ormai il modo: `mode` è un
163
+ // valore solo, quindi i due non possono essere attivi insieme.
164
+ function onReaderKey(input, key) {
165
+ if (key.escape) {
166
+ setMode('search');
167
+ setReaderRow(null);
168
+ }
169
+ else if (key.upArrow) {
170
+ scrollReader(-1);
171
+ }
172
+ else if (key.downArrow) {
173
+ scrollReader(1);
174
+ }
175
+ else if (key.pageUp) {
176
+ scrollReader(-readerCap);
177
+ }
178
+ else if (key.pageDown) {
179
+ scrollReader(readerCap);
180
+ }
181
+ else if (input === 'g') {
182
+ // Estremi su lettera e non su Home/End: Ink RICONOSCE quelle due (le
183
+ // mappa a 'home'/'end' nel parser) ma NON le espone — `nonAlphanumericKeys`
184
+ // azzera l'input e nessun flag le rappresenta, quindi arrivano
185
+ // indistinguibili da qualunque tasto ignoto. Verificato su pty reale.
186
+ // Nel reader non c'è input di testo, quindi le lettere sono libere.
187
+ setReaderTop(0);
188
+ }
189
+ else if (input === 'G') {
190
+ setReaderTop(readerMaxTop);
191
+ }
192
+ }
193
+ // T52 — modale ricerca. I due campi di testo mangiano ogni lettera nuda:
194
+ // ogni comando che deve restare vivo MENTRE si digita passa da CTRL, dai
195
+ // tasti freccia o da Tab. `esc` chiude il modale, non il deck.
196
+ function onSearchKey(input, key) {
197
+ if (key.escape) {
198
+ setMode('normal');
199
+ setNote('');
200
+ return;
201
+ }
202
+ if (key.ctrl) {
203
+ const flag = SEARCH_TOGGLE_KEYS[input];
204
+ if (flag) {
205
+ setOpts((o) => ({ ...o, [flag]: !o[flag] }));
206
+ return;
207
+ }
208
+ const kind = SEARCH_KIND_KEYS[input];
209
+ if (kind)
210
+ toggleKind(kind);
211
+ return; // ogni altra combo ctrl (^F incluso: siamo già dentro) = no-op
212
+ }
213
+ if (key.tab) {
214
+ setField((f) => (f === 'hash' ? 'query' : 'hash'));
215
+ }
216
+ else if (key.upArrow) {
217
+ setSelKey((k) => moveRowSelection(searchRows, k, -1));
218
+ }
219
+ else if (key.downArrow) {
220
+ setSelKey((k) => moveRowSelection(searchRows, k, 1));
221
+ }
222
+ else if (key.pageUp) {
223
+ setSelKey((k) => moveRowSelection(searchRows, k, -Math.max(1, listCap)));
224
+ }
225
+ else if (key.pageDown) {
226
+ setSelKey((k) => moveRowSelection(searchRows, k, Math.max(1, listCap)));
227
+ }
228
+ else if (key.return) {
229
+ submitRow();
230
+ }
231
+ else if (key.backspace || key.delete) {
232
+ editField((s) => s.slice(0, -1));
233
+ }
234
+ else if (input && !key.meta) {
235
+ typeIntoField(input);
236
+ }
237
+ }
238
+ return {
239
+ hash,
240
+ query,
241
+ field,
242
+ opts,
243
+ selKey,
244
+ setSelKey,
245
+ result,
246
+ rows: searchRows,
247
+ selRow,
248
+ previewBody,
249
+ readerRow,
250
+ readerTop,
251
+ readerLines,
252
+ readerCap,
253
+ readerMaxTop,
254
+ listCap,
255
+ open,
256
+ onReaderKey,
257
+ onSearchKey,
258
+ };
259
+ }
@@ -0,0 +1,222 @@
1
+ // Overlay DETAIL della task (T66) con la sua ricerca interna (T91).
2
+ //
3
+ // Lo `sheet` è la task FOTOGRAFATA all'apertura — id, titolo e testo integrale
4
+ // del task file — e non una vista sulla selezione corrente: l'overlay copre la
5
+ // lista, quindi l'oggetto dell'azione deve restare quello che si è scelto anche
6
+ // se un tick del poll spostasse la selezione sotto.
7
+ //
8
+ // Si chiama `sheet` e non `detail` perché in `Deck` `detail` è già il
9
+ // `TaskDetail` del blocco preview sotto i pane: due cose vicine con lo stesso
10
+ // nome sono una trappola di lettura.
11
+ //
12
+ // Lo spawn della sessione dalla barra azioni NON sta qui: arriva come callback
13
+ // `onAction`, per lo stesso confine di `useSearchOverlay`.
14
+ import { useEffect, useMemo, useState } from 'react';
15
+ import { detailCapacity } from '../viewport.js';
16
+ import { wrapWithOffsets } from '../width.js';
17
+ import { scanText, topForOffset } from '../text-search.js';
18
+ import { parseMarkdown } from '../markdown.js';
19
+ import { cpLen, insertAt, removeAt } from '../layout.js';
20
+ import { sanitizeTyped } from '../glyphs.js';
21
+ import { DETAIL_ACTIONS } from '../spawn.js';
22
+ export function useSheetOverlay(deps) {
23
+ const { rows, columns, setMode, setNote, onAction } = deps;
24
+ const [sheet, setSheet] = useState(null);
25
+ const [top, setTop] = useState(0);
26
+ const [action, setAction] = useState(0);
27
+ // T91 — ricerca dentro il detail. `open` distingue i due modi in cui la si
28
+ // lascia: `esc` butta via ciò che il modale ha prodotto (`find` a null,
29
+ // evidenziazione via), `⏎` lo congela e restituisce il controllo allo strato
30
+ // sotto — campo chiuso, occorrenze ancora colorate, scroll dov'era. Senza il
31
+ // flag i due gesti collasserebbero su uno solo.
32
+ const [find, setFind] = useState(null);
33
+ const [occIdx, setOccIdx] = useState(0);
34
+ // Cornici da scalare: box esterno (2 bordi + 2 padding) + box testo (2 bordi +
35
+ // 2 padding) = 8. Sottostimare tronca un carattere, sovrastimare manda a capo
36
+ // una riga che il budget d'altezza non ha contato.
37
+ const width = Math.max(20, (columns || 80) - 8);
38
+ // T75 — il markdown si rende PRIMA del wrap, e il resto della catena lavora
39
+ // sul testo reso: `**foo**` occupa 3 colonne rese e 7 grezze, quindi
40
+ // wrappare sui marker manderebbe a capo su un conteggio che il terminale non
41
+ // disegna. Memo separato dal wrap perché il parse dipende solo dal testo: un
42
+ // resize ri-wrappa 9KB, non li ri-parsa.
43
+ const doc = useMemo(() => (sheet?.text ? parseMarkdown(sheet.text) : null), [sheet]);
44
+ // Le righe conservano i propri offset invece di essere appiattite a stringa
45
+ // (T66 le buttava con `.map((l) => l.text)`): è ciò che rende
46
+ // l'evidenziazione un'intersezione di intervalli invece di un caso speciale
47
+ // per il match spezzato dall'a-capo. Dopo T75 gli offset indicizzano il testo
48
+ // RESO, ed è l'unica coordinata coerente che resti — il sorgente non è più
49
+ // ciò che sta a schermo.
50
+ const lines = useMemo(() => (doc ? wrapWithOffsets(doc.text, width) : []), [doc, width]);
51
+ const capacity = detailCapacity(rows, find?.open === true);
52
+ const maxTop = Math.max(0, lines.length - capacity);
53
+ // Lo scan gira sulla STESSA stringa che si renderizza: cercare su un testo
54
+ // diverso da quello a schermo darebbe offset che indicizzano un altro
55
+ // documento, cioè un'evidenziazione spostata di N caratteri e nessun errore.
56
+ //
57
+ // Dopo T75 quella stringa è il testo RESO, non più il sorgente: si cerca ciò
58
+ // che si vede. Ne discende che `**` non è più cercabile — è la conseguenza
59
+ // voluta, perché a schermo non c'è; e `Priority`, che prima era `**Priority**`
60
+ // e si trovava lo stesso, continua a trovarsi.
61
+ const findRes = useMemo(() => (find && doc ? scanText(doc.text, find.q) : { occ: [], error: '' }), [find?.q, doc]);
62
+ // L'indice si clampa qui invece di essere corretto a ogni `setOccIdx`: la
63
+ // lista si accorcia da sola mentre si digita, e un indice fuori range vivrebbe
64
+ // per il tempo di un render.
65
+ const occCur = findRes.occ.length > 0 ? Math.min(occIdx, findRes.occ.length - 1) : -1;
66
+ // Salto all'occorrenza corrente, centrata. Non dipende da `top`, quindi non si
67
+ // auto-rilancia; dipende da `lines` e `capacity`, quindi un resize ricalcola
68
+ // la posizione senza toccare le occorrenze — che sono offset del sorgente e il
69
+ // resize non le sposta.
70
+ useEffect(() => {
71
+ const o = occCur >= 0 ? findRes.occ[occCur] : undefined;
72
+ if (!o)
73
+ return;
74
+ setTop(topForOffset(lines, o.start, capacity));
75
+ }, [findRes, occCur, lines, capacity]);
76
+ /** Apre il detail su una task, azzerando scroll, azione e ricerca. */
77
+ function open(next) {
78
+ setSheet(next);
79
+ setTop(0);
80
+ setAction(0);
81
+ setFind(null);
82
+ setOccIdx(0);
83
+ setNote('');
84
+ setMode('detail');
85
+ }
86
+ function close() {
87
+ setMode('normal');
88
+ setSheet(null);
89
+ setFind(null);
90
+ }
91
+ function scroll(delta) {
92
+ setTop((t) => Math.max(0, Math.min(maxTop, t + delta)));
93
+ }
94
+ /** Modifica la query: l'insieme delle occorrenze cambia, quindi si riparte
95
+ * dalla prima. Il movimento del caret NON passa di qui — sposta il cursore,
96
+ * non i risultati. */
97
+ function editFind(next) {
98
+ setFind((f) => (f ? next(f) : f));
99
+ setOccIdx(0);
100
+ }
101
+ function moveOcc(d) {
102
+ const n = findRes.occ.length;
103
+ if (n === 0)
104
+ return;
105
+ setOccIdx((i) => (Math.min(i, n - 1) + d + n) % n);
106
+ }
107
+ // T91 — il campo di ricerca è un modale DENTRO il detail, e lo intercetta per
108
+ // intero: mentre è aperto mangia ogni lettera nuda, `g`/`G` compresi.
109
+ function onFindKey(input, key) {
110
+ if (key.escape) {
111
+ // Annulla: butta via ciò che il modale ha prodotto. Lo scroll resta dove
112
+ // la ricerca l'ha portato — riavvolgerlo sarebbe una terza semantica che
113
+ // nessun tasto ha chiesto.
114
+ setFind(null);
115
+ }
116
+ else if (key.return) {
117
+ // Congela: campo chiuso, occorrenze ancora colorate, scroll intatto.
118
+ setFind((f) => (f ? { ...f, open: false } : f));
119
+ }
120
+ else if (key.upArrow) {
121
+ moveOcc(-1);
122
+ }
123
+ else if (key.downArrow) {
124
+ moveOcc(1);
125
+ }
126
+ else if (key.leftArrow || key.rightArrow) {
127
+ const d = key.leftArrow ? -1 : 1;
128
+ setFind((f) => (f ? { ...f, caret: Math.max(0, Math.min(cpLen(f.q), f.caret + d)) } : f));
129
+ }
130
+ else if (key.backspace || key.delete) {
131
+ editFind((f) => f.caret > 0 ? { ...f, q: removeAt(f.q, f.caret - 1), caret: f.caret - 1 } : f);
132
+ }
133
+ else if (key.ctrl) {
134
+ // `^U` svuota, come il filtro del modale assegnazione; ogni altra combo
135
+ // è no-op — `^F` incluso, siamo già dentro.
136
+ if (input === 'u')
137
+ editFind((f) => ({ ...f, q: '', caret: 0 }));
138
+ }
139
+ else if (input && !key.meta) {
140
+ const ins = sanitizeTyped(input);
141
+ editFind((f) => ({ ...f, q: insertAt(f.q, f.caret, ins), caret: f.caret + cpLen(ins) }));
142
+ }
143
+ }
144
+ // T66 — detail della task: due zone (testo scrollabile + barra azioni) e una
145
+ // selezione per ognuna. Il modo cattura TUTTO, acceleratori `^K`/`^P`/`^R`
146
+ // compresi: chi è già nel detail ha i bottoni. L'unica deroga è `^F`, che qui
147
+ // apre la ricerca nel testo invece di quella sulle conversazioni.
148
+ function onKey(input, key) {
149
+ if (find?.open) {
150
+ onFindKey(input, key);
151
+ return;
152
+ }
153
+ if (key.ctrl && input === 'f') {
154
+ // Fuori dal detail `^F` è la ricerca conversazioni; qui è la ricerca nel
155
+ // testo. La query sopravvive a una chiusura con `⏎`, quindi riaprire
156
+ // riprende da dov'era invece di ricominciare.
157
+ setFind((f) => (f ? { ...f, open: true } : { q: '', caret: 0, open: true }));
158
+ return;
159
+ }
160
+ if (key.escape) {
161
+ // Uno strato alla volta: se resta un'evidenziazione congelata, `esc`
162
+ // smonta quella; il detail lo chiude il secondo.
163
+ // Nessun reset di `sel`: la selezione della lista non è mai stata
164
+ // toccata, quindi si ritrova esattamente dov'era.
165
+ if (find)
166
+ setFind(null);
167
+ else
168
+ close();
169
+ }
170
+ else if (key.return) {
171
+ // `⏎` esegue SEMPRE l'azione selezionata, mai "scrolla" o "chiudi": il
172
+ // testo non è un campo attivo (si scorre, non si edita), quindi nessuna
173
+ // competizione sul tasto.
174
+ const act = DETAIL_ACTIONS[action];
175
+ const id = sheet?.id;
176
+ close();
177
+ if (id)
178
+ onAction(id, act.kind, `⏎ ${act.label}`);
179
+ }
180
+ else if (key.leftArrow || key.rightArrow) {
181
+ // Scorrimento CICLICO come le righe di scelta del modale edit: cinque
182
+ // voci, arrivare in fondo e ripartire costa meno che invertire direzione.
183
+ const d = key.leftArrow ? -1 : 1;
184
+ setAction((i) => (i + d + DETAIL_ACTIONS.length) % DETAIL_ACTIONS.length);
185
+ }
186
+ else if (key.upArrow) {
187
+ scroll(-1);
188
+ }
189
+ else if (key.downArrow) {
190
+ scroll(1);
191
+ }
192
+ else if (key.pageUp) {
193
+ scroll(-capacity);
194
+ }
195
+ else if (key.pageDown) {
196
+ scroll(capacity);
197
+ }
198
+ else if (input === 'g') {
199
+ // Estremi su lettera per lo stesso motivo del reader: Ink riconosce
200
+ // `Home`/`End` ma non le espone, e qui non c'è input di testo che
201
+ // contenda le lettere.
202
+ setTop(0);
203
+ }
204
+ else if (input === 'G') {
205
+ setTop(maxTop);
206
+ }
207
+ }
208
+ return {
209
+ sheet,
210
+ doc,
211
+ top,
212
+ action,
213
+ find,
214
+ lines,
215
+ capacity,
216
+ maxTop,
217
+ findRes,
218
+ occCur,
219
+ open,
220
+ onKey,
221
+ };
222
+ }
@@ -1,7 +1,7 @@
1
1
  // T100 — Catalogo delle viste dei due pane. L'header smette di essere una riga
2
2
  // di contatori e diventa un SELETTORE: ogni segmento che nomina un sottoinsieme
3
3
  // calcolabile (`nascoste`, `archiviabili`, `vive`, `📌`, `+più vecchie`) è una
4
- // vista raggiungibile con ←/→.
4
+ // vista raggiungibile con `tab`.
5
5
  //
6
6
  // Modulo PURO: nessun import da ink/react, nessun I/O. Il calcolo resta dove
7
7
  // stava — `view.ts` per i filtri task, `session-list.ts` per l'assemblaggio a