@lamemind/loom-deck 0.29.0 → 0.30.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/cli.js CHANGED
@@ -18,6 +18,7 @@ import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch,
18
18
  import { countArchivable, SCAN_INTERVAL_MS } from './archivable.js';
19
19
  import { assignListCapacity, detailCapacity, isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
20
20
  import { caretWindow, cut, cutParts, pad, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
21
+ import { scanText, sliceLine, topForOffset, } from './text-search.js';
21
22
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
22
23
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
23
24
  import { loadView, saveView, viewFilePath } from './view-store.js';
@@ -665,6 +666,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
665
666
  const [sheet, setSheet] = useState(null);
666
667
  const [sheetTop, setSheetTop] = useState(0);
667
668
  const [sheetAction, setSheetAction] = useState(0);
669
+ // T91 — ricerca dentro il detail. `open` distingue i due modi in cui la si
670
+ // lascia: `esc` butta via ciò che il modale ha prodotto (`find` a null,
671
+ // evidenziazione via), `⏎` lo congela e restituisce il controllo allo strato
672
+ // sotto — campo chiuso, occorrenze ancora colorate, scroll dov'era. Senza il
673
+ // flag i due gesti collasserebbero su uno solo.
674
+ const [find, setFind] = useState(null);
675
+ const [occIdx, setOccIdx] = useState(0);
668
676
  // Dimensioni vive del terminale: sono l'input del budget d'altezza sotto.
669
677
  const { rows, columns } = useTerminalSize();
670
678
  // Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
@@ -792,9 +800,32 @@ function Deck({ cwd, tasksPath, tasksDir }) {
792
800
  // 2 padding) = 8. Sottostimare tronca un carattere, sovrastimare manda a capo
793
801
  // una riga che il budget d'altezza non ha contato.
794
802
  const sheetWidth = Math.max(20, (columns || 80) - 8);
795
- const sheetLines = useMemo(() => (sheet?.text ? wrapWithOffsets(sheet.text, sheetWidth).map((l) => l.text) : []), [sheet, sheetWidth]);
796
- const sheetCap = detailCapacity(rows);
803
+ // Le righe conservano i propri offset invece di essere appiattite a stringa
804
+ // (T66 le buttava con `.map((l) => l.text)`): è ciò che rende
805
+ // l'evidenziazione un'intersezione di intervalli invece di un caso speciale
806
+ // per il match spezzato dall'a-capo.
807
+ const sheetLines = useMemo(() => (sheet?.text ? wrapWithOffsets(sheet.text, sheetWidth) : []), [sheet, sheetWidth]);
808
+ const sheetCap = detailCapacity(rows, find?.open === true);
797
809
  const sheetMaxTop = Math.max(0, sheetLines.length - sheetCap);
810
+ // Lo scan gira sulla STESSA stringa che si renderizza (`sheet.text`, già
811
+ // passata da `sanitize` in `loadTaskFileText`): una rilettura del file darebbe
812
+ // offset che indicizzano un documento diverso da quello a schermo, cioè
813
+ // un'evidenziazione spostata di N caratteri e nessun errore.
814
+ const findRes = useMemo(() => (find && sheet?.text ? scanText(sheet.text, find.q) : { occ: [], error: '' }), [find?.q, sheet]);
815
+ // L'indice si clampa qui invece di essere corretto a ogni `setOccIdx`: la
816
+ // lista si accorcia da sola mentre si digita, e un indice fuori range vivrebbe
817
+ // per il tempo di un render.
818
+ const occCur = findRes.occ.length > 0 ? Math.min(occIdx, findRes.occ.length - 1) : -1;
819
+ // Salto all'occorrenza corrente, centrata. Non dipende da `sheetTop`, quindi
820
+ // non si auto-rilancia; dipende da `sheetLines` e `sheetCap`, quindi un resize
821
+ // ricalcola la posizione senza toccare le occorrenze — che sono offset del
822
+ // sorgente e il resize non le sposta.
823
+ useEffect(() => {
824
+ const o = occCur >= 0 ? findRes.occ[occCur] : undefined;
825
+ if (!o)
826
+ return;
827
+ setSheetTop(topForOffset(sheetLines, o.start, sheetCap));
828
+ }, [findRes, occCur, sheetLines, sheetCap]);
798
829
  // T57 — righe del modale assegnazione: `null` (detach) in testa, poi le task
799
830
  // della VISTA corrente (D4 — filtri e sort inclusi, coerenza con ciò che si
800
831
  // stava leggendo a sinistra; le escluse restano contate nell'header).
@@ -954,16 +985,32 @@ function Deck({ cwd, tasksPath, tasksDir }) {
954
985
  });
955
986
  setSheetTop(0);
956
987
  setSheetAction(0);
988
+ setFind(null);
989
+ setOccIdx(0);
957
990
  setNote('');
958
991
  setMode('detail');
959
992
  }
960
993
  function closeDetail() {
961
994
  setMode('normal');
962
995
  setSheet(null);
996
+ setFind(null);
963
997
  }
964
998
  function scrollDetail(delta) {
965
999
  setSheetTop((t) => Math.max(0, Math.min(sheetMaxTop, t + delta)));
966
1000
  }
1001
+ /** Modifica la query: l'insieme delle occorrenze cambia, quindi si riparte
1002
+ * dalla prima. Il movimento del caret NON passa di qui — sposta il cursore,
1003
+ * non i risultati. */
1004
+ function editFind(next) {
1005
+ setFind((f) => (f ? next(f) : f));
1006
+ setOccIdx(0);
1007
+ }
1008
+ function moveOcc(d) {
1009
+ const n = findRes.occ.length;
1010
+ if (n === 0)
1011
+ return;
1012
+ setOccIdx((i) => (Math.min(i, n - 1) + d + n) % n);
1013
+ }
967
1014
  // T53 — apertura del modale nota sulla conversazione selezionata. Come
968
1015
  // `openEdit`, la bozza parte dal valore ATTUALE: annotare una seconda volta è
969
1016
  // quasi sempre correggere, e ripartire da vuoto costringerebbe a ridigitare
@@ -1551,10 +1598,62 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1551
1598
  // È anche prima del ramo CTRL, quindi `^K`/`^P`/`^R` restano acceleratori
1552
1599
  // della sola lista: chi è già nel detail ha i bottoni.
1553
1600
  if (mode === 'detail') {
1601
+ // T91 — modale DENTRO il modale, e sta prima per la stessa ragione per cui
1602
+ // il detail sta prima del ramo CTRL: Ink non ha focus-trap, quindi la
1603
+ // cattura È l'ordine dei rami. Mentre il campo è aperto mangia ogni lettera
1604
+ // nuda, `g`/`G` compresi.
1605
+ if (find?.open) {
1606
+ if (key.escape) {
1607
+ // Annulla: butta via ciò che il modale ha prodotto. Lo scroll resta
1608
+ // dove la ricerca l'ha portato — riavvolgerlo sarebbe una terza
1609
+ // semantica che nessun tasto ha chiesto.
1610
+ setFind(null);
1611
+ }
1612
+ else if (key.return) {
1613
+ // Congela: campo chiuso, occorrenze ancora colorate, scroll intatto.
1614
+ setFind((f) => (f ? { ...f, open: false } : f));
1615
+ }
1616
+ else if (key.upArrow) {
1617
+ moveOcc(-1);
1618
+ }
1619
+ else if (key.downArrow) {
1620
+ moveOcc(1);
1621
+ }
1622
+ else if (key.leftArrow || key.rightArrow) {
1623
+ const d = key.leftArrow ? -1 : 1;
1624
+ setFind((f) => f ? { ...f, caret: Math.max(0, Math.min(cpLen(f.q), f.caret + d)) } : f);
1625
+ }
1626
+ else if (key.backspace || key.delete) {
1627
+ editFind((f) => f.caret > 0 ? { ...f, q: removeAt(f.q, f.caret - 1), caret: f.caret - 1 } : f);
1628
+ }
1629
+ else if (key.ctrl) {
1630
+ // `^U` svuota, come il filtro del modale assegnazione; ogni altra combo
1631
+ // è no-op — `^F` incluso, siamo già dentro.
1632
+ if (input === 'u')
1633
+ editFind((f) => ({ ...f, q: '', caret: 0 }));
1634
+ }
1635
+ else if (input && !key.meta) {
1636
+ const ins = sanitizeTyped(input);
1637
+ editFind((f) => ({ ...f, q: insertAt(f.q, f.caret, ins), caret: f.caret + cpLen(ins) }));
1638
+ }
1639
+ return;
1640
+ }
1641
+ if (key.ctrl && input === 'f') {
1642
+ // Fuori dal detail `^F` è la ricerca conversazioni; qui è la ricerca nel
1643
+ // testo. La query sopravvive a una chiusura con `⏎`, quindi riaprire
1644
+ // riprende da dov'era invece di ricominciare.
1645
+ setFind((f) => (f ? { ...f, open: true } : { q: '', caret: 0, open: true }));
1646
+ return;
1647
+ }
1554
1648
  if (key.escape) {
1649
+ // Uno strato alla volta: se resta un'evidenziazione congelata, `esc`
1650
+ // smonta quella; il detail lo chiude il secondo.
1555
1651
  // Nessun reset di `sel`: la selezione della lista non è mai stata
1556
1652
  // toccata, quindi si ritrova esattamente dov'era.
1557
- closeDetail();
1653
+ if (find)
1654
+ setFind(null);
1655
+ else
1656
+ closeDetail();
1558
1657
  }
1559
1658
  else if (key.return) {
1560
1659
  // `⏎` esegue SEMPRE l'azione selezionata, mai "scrolla" o "chiudi": il
@@ -1875,7 +1974,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1875
1974
  // posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
1876
1975
  // può accorciare il testo sotto uno scroll già dato.
1877
1976
  const start = Math.min(sheetTop, sheetMaxTop);
1878
- return (_jsx(DetailScreen, { id: sheet.id, title: sheet.title, missing: sheet.text === null, lines: sheetLines.slice(start, start + sheetCap), top: start, total: sheetLines.length, capacity: sheetCap, action: sheetAction, columns: columns }));
1977
+ return (_jsx(DetailScreen, { id: sheet.id, title: sheet.title, missing: sheet.text === null, lines: sheetLines.slice(start, start + sheetCap), top: start, total: sheetLines.length, capacity: sheetCap, action: sheetAction, columns: columns, find: find, occ: findRes.occ, occCur: occCur }));
1879
1978
  }
1880
1979
  // ── T52 · schermate sostitutive ─────────────────────────────────────────
1881
1980
  // Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
@@ -2133,7 +2232,8 @@ function SearchScreen({ preview, hash, query, field, opts, result, rows, selecte
2133
2232
  */
2134
2233
  function SearchPreviewPane({ p }) {
2135
2234
  const last = Math.min(p.total, p.from + p.lines.length);
2136
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["record ", p.hit.idx, " \u00B7 ", KIND_LABEL[p.hit.kind], p.ts ? ` · ${fmtDateTime(p.ts)}` : '', " \u00B7 righe ", p.from + 1, "-", last, " di ", p.total, " \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " apre il reader"] }), p.lines.map((l, i) => (_jsx(ReaderLine, { line: l, from: p.hit.matchStart, to: p.hit.matchEnd }, p.from + i)))] }));
2235
+ const occ = [{ start: p.hit.matchStart, end: p.hit.matchEnd }];
2236
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["record ", p.hit.idx, " \u00B7 ", KIND_LABEL[p.hit.kind], p.ts ? ` · ${fmtDateTime(p.ts)}` : '', " \u00B7 righe ", p.from + 1, "-", last, " di ", p.total, " \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " apre il reader"] }), p.lines.map((l, i) => (_jsx(ReaderLine, { line: l, occ: occ, current: 0 }, p.from + i)))] }));
2137
2237
  }
2138
2238
  /**
2139
2239
  * Reader fullscreen (T52 · D8).
@@ -2145,18 +2245,32 @@ function SearchPreviewPane({ p }) {
2145
2245
  */
2146
2246
  function ReaderScreen({ hit, lines, top, total, capacity, bound, }) {
2147
2247
  const last = Math.min(total, top + capacity);
2148
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["reader \u00B7 ", _jsx(Text, { color: "cyan", children: hit.sessionId.slice(0, 8) }), " \u00B7 record ", hit.idx, " \u00B7", ' ', KIND_LABEL[hit.kind], bound ? ` · ${bound}` : '', " \u00B7 righe ", total === 0 ? 0 : top + 1, "-", last, " di ", total] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g" }), " inizio \u00B7 ", _jsx(Text, { color: "yellow", children: "G" }), " fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " torna alla lista"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: lines.map((l, i) => (_jsx(ReaderLine, { line: l, from: hit.matchStart, to: hit.matchEnd }, top + i))) })] }));
2248
+ const occ = [{ start: hit.matchStart, end: hit.matchEnd }];
2249
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["reader \u00B7 ", _jsx(Text, { color: "cyan", children: hit.sessionId.slice(0, 8) }), " \u00B7 record ", hit.idx, " \u00B7", ' ', KIND_LABEL[hit.kind], bound ? ` · ${bound}` : '', " \u00B7 righe ", total === 0 ? 0 : top + 1, "-", last, " di ", total] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g" }), " inizio \u00B7 ", _jsx(Text, { color: "yellow", children: "G" }), " fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " torna alla lista"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: lines.map((l, i) => (_jsx(ReaderLine, { line: l, occ: occ, current: 0 }, top + i))) })] }));
2149
2250
  }
2150
- /** Una riga del reader, con la sola porzione di match evidenziata. Gli offset
2151
- * sono quelli del testo sorgente, quindi un match a cavallo dell'a-capo si
2152
- * colora su entrambe le righe senza casi speciali. */
2153
- function ReaderLine({ line, from, to }) {
2154
- const a = Math.max(0, Math.min(line.text.length, from - line.start));
2155
- const b = Math.max(0, Math.min(line.text.length, to - line.start));
2156
- if (b <= a) {
2251
+ /** Una riga con le porzioni di match evidenziate. Gli offset sono quelli del
2252
+ * testo sorgente, quindi un match a cavallo dell'a-capo si colora su entrambe
2253
+ * le righe senza casi speciali — entrambe intersecano il suo intervallo.
2254
+ *
2255
+ * Regge N occorrenze perché il detail (T91) ne mostra tutte quelle visibili; il
2256
+ * reader (T52) ne passa una sola, che è il caso degenere dello stesso taglio. */
2257
+ function ReaderLine({ line, occ, current, }) {
2258
+ const segs = sliceLine(line.text, line.start, occ, current);
2259
+ if (segs.length === 0)
2157
2260
  return _jsx(Text, { wrap: "truncate-end", children: line.text || ' ' });
2158
- }
2159
- return (_jsxs(Text, { wrap: "truncate-end", children: [line.text.slice(0, a), _jsx(Text, { backgroundColor: "yellow", color: "black", children: line.text.slice(a, b) }), line.text.slice(b)] }));
2261
+ return (_jsx(Text, { wrap: "truncate-end", children: segs.map((s, i) => s.hit ? (
2262
+ // La corrente si distingue dalle altre per COLORE di sfondo, non per
2263
+ // presenza: tutte restano visibili, o navigare fra occorrenze non
2264
+ // mostrerebbe più dove sono le altre.
2265
+ _jsx(Text, { backgroundColor: s.current ? 'cyan' : 'yellow', color: "black", children: s.text }, i)) : (_jsx(Text, { children: s.text }, i))) }));
2266
+ }
2267
+ /** Campo della ricerca nel detail: finestra ancorata al caret, cursore inverso
2268
+ * sulla cella reale. Gemello di `EditTextField` senza la label, che qui sta
2269
+ * fuori perché il campo vive in FLUSSO su una riga condivisa col contatore —
2270
+ * non su una riga propria. */
2271
+ function DetailFindField({ value, caret, cols }) {
2272
+ const win = caretWindow(value, caret, cols);
2273
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { children: sanitize(win.head) }), _jsx(Text, { inverse: true, children: sanitize(win.at) }), _jsx(Text, { children: sanitize(win.tail) })] }));
2160
2274
  }
2161
2275
  /**
2162
2276
  * Detail della task (T66): il task file scrollabile più la barra azioni.
@@ -2171,7 +2285,7 @@ function ReaderLine({ line, from, to }) {
2171
2285
  * all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione. La
2172
2286
  * navigazione da tastiera ci si sovrappone senza conflitti.
2173
2287
  */
2174
- function DetailScreen({ id, title, missing, lines, top, total, capacity, action, columns, }) {
2288
+ function DetailScreen({ id, title, missing, lines, top, total, capacity, action, columns, find, occ, occCur, }) {
2175
2289
  const last = Math.min(total, top + capacity);
2176
2290
  // Il taglio lo fa il chiamante (invariante ③ di width.ts): la riga bottoni è
2177
2291
  // ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso — ma
@@ -2192,10 +2306,10 @@ function DetailScreen({ id, title, missing, lines, top, total, capacity, action,
2192
2306
  if (dropped(shown) > 0)
2193
2307
  shown = cutParts(parts, Math.max(0, width - 6));
2194
2308
  const cutCount = dropped(shown);
2195
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: id }), " \u00B7 ", cut(title, Math.max(10, width - 34)), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " esegui \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: missing ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: [WARN, " task file non trovato \u00B7 le azioni restano attive (deck-run risolve la task per id)"] })) : (
2309
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: id }), " \u00B7 ", cut(title, Math.max(10, width - 34)), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), find?.open ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " occorrenza \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " caret \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " tieni \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^F" }), " cerca \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " esegui \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] })), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: missing ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: [WARN, " task file non trovato \u00B7 le azioni restano attive (deck-run risolve la task per id)"] })) : (
2196
2310
  // Riga vuota → uno spazio: un `<Text>` senza contenuto Ink non lo
2197
2311
  // disegna, e il testo si compatterebbe perdendo la struttura del file.
2198
- lines.map((l, i) => (_jsx(Text, { wrap: "truncate-end", children: l || ' ' }, top + i)))) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === action, color: i / 2 === action ? 'green' : 'gray', children: part }, i))), cutCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cutCount] }) : null] }) })] }));
2312
+ lines.map((l, i) => _jsx(ReaderLine, { line: l, occ: occ, current: occCur }, top + i))) }), find?.open ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "cerca " }), _jsx(DetailFindField, { value: find.q, caret: find.caret, cols: Math.max(10, width - 28) }), find.q.length === 0 ? (_jsx(Text, { dimColor: true, children: " \u00B7 digita per cercare" })) : occ.length === 0 ? (_jsx(Text, { color: "yellow", children: " \u00B7 nessuna occorrenza" })) : (_jsxs(Text, { color: "cyan", children: [' ', "\u00B7 ", occCur + 1, "/", occ.length] }))] }) })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === action, color: i / 2 === action ? 'green' : 'gray', children: part }, i))), cutCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cutCount] }) : null] }) })] }));
2199
2313
  }
2200
2314
  /**
2201
2315
  * Larghezza del testo dentro la lista della schermata di assegnazione: box
package/dist/search.js CHANGED
@@ -58,8 +58,8 @@ function escapeLiteral(s) {
58
58
  * `(`) è la NORMA durante la digitazione, non un caso limite. Deve degradare a
59
59
  * lista vuota con segnalazione inline, mai a un crash della TUI.
60
60
  */
61
- export function buildMatcher(query, opts) {
62
- if (query.length < MIN_QUERY)
61
+ export function buildMatcher(query, opts, min = MIN_QUERY) {
62
+ if (query.length < min)
63
63
  return { re: null, error: '' };
64
64
  let source = opts.regex ? query : escapeLiteral(query);
65
65
  // Il gruppo non-catturante è obbligatorio: senza, `\b` si legherebbe al solo
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Ricerca dentro UN documento: occorrenze come offset del testo sorgente.
3
+ *
4
+ * Gemello di `search.ts` per il motore, opposto per forma. Lì il corpus è N
5
+ * conversazioni, quindi il risultato raggruppa per sessione, cappa le occorrenze
6
+ * e produce estratti; qui il corpus è una stringa sola e la risposta utile è la
7
+ * posizione, non l'estratto — il testo è già a schermo.
8
+ *
9
+ * Gli offset sono quelli del SORGENTE, mai coordinate riga/colonna del testo
10
+ * wrappato. Due proprietà ne discendono, ed è il motivo per cui il modulo esiste:
11
+ *
12
+ * - **Immunità al re-wrap.** Un resize cambia larghezza e numero di righe ma non
13
+ * sposta un match, perché il sorgente non cambia. Coordinate «riga 42,
14
+ * colonna 7» andrebbero ricalcolate a ogni SIGWINCH.
15
+ * - **Match a cavallo dell'a-capo senza casi speciali.** Ogni riga di
16
+ * `wrapWithOffsets` è una fetta contigua del sorgente, quindi evidenziare è
17
+ * intersecare due intervalli — e un match spezzato su due righe si colora su
18
+ * entrambe perché entrambe intersecano.
19
+ *
20
+ * Puro: nessun Ink, nessun terminale, nessun filesystem.
21
+ */
22
+ import { buildMatcher } from './search.js';
23
+ export const LITERAL = { regex: false, caseSensitive: false, wholeWord: false };
24
+ /**
25
+ * Minimo di caratteri più basso di `MIN_QUERY`.
26
+ *
27
+ * Su N conversazioni una query di 2 caratteri produce migliaia di occorrenze
28
+ * inutili, e la soglia a 3 protegge dall'indicizzazione a vuoto. Su un task file
29
+ * `T7` o `⏎` sono query legittime e frequenti: la soglia lì non protegge da
30
+ * niente, impedisce soltanto.
31
+ */
32
+ export const MIN_DETAIL_QUERY = 1;
33
+ const EMPTY = { occ: [], error: '' };
34
+ /**
35
+ * Tutte le occorrenze di `query` in `text`, in ordine di posizione.
36
+ *
37
+ * Il testo deve essere **la stessa stringa che si renderizza**: passa da
38
+ * `sanitize` al confine di caricamento, e cercare su una rilettura del file
39
+ * produrrebbe offset che indicizzano un documento diverso da quello a schermo —
40
+ * evidenziazione spostata di N caratteri, senza nessun errore.
41
+ */
42
+ export function scanText(text, query, opts = LITERAL) {
43
+ const { re, error } = buildMatcher(query, opts, MIN_DETAIL_QUERY);
44
+ if (!re)
45
+ return error ? { occ: [], error } : EMPTY;
46
+ const occ = [];
47
+ re.lastIndex = 0;
48
+ let m;
49
+ while ((m = re.exec(text)) !== null) {
50
+ // Una regex può matchare la STRINGA VUOTA (`a*`, `^`, `\b`): `exec` non
51
+ // avanzerebbe `lastIndex` e il ciclo non terminerebbe mai. Un match di
52
+ // larghezza zero non è nemmeno evidenziabile, quindi si salta invece di
53
+ // registrarlo.
54
+ if (m[0].length === 0) {
55
+ re.lastIndex++;
56
+ continue;
57
+ }
58
+ occ.push({ start: m.index, end: m.index + m[0].length });
59
+ }
60
+ return { occ, error };
61
+ }
62
+ /**
63
+ * Taglia una riga wrappata nei pezzi da colorare, intersecando gli offset.
64
+ *
65
+ * `occ` è in ordine e senza sovrapposizioni (`exec` avanza monotono), quindi
66
+ * basta una passata. Il guard `a < pos` regge comunque il caso di intervalli
67
+ * sovrapposti passati da un chiamante futuro, senza produrre testo duplicato.
68
+ */
69
+ export function sliceLine(lineText, lineStart, occ, current) {
70
+ const out = [];
71
+ let pos = 0;
72
+ for (let i = 0; i < occ.length; i++) {
73
+ const a = Math.max(0, Math.min(lineText.length, occ[i].start - lineStart));
74
+ const b = Math.max(0, Math.min(lineText.length, occ[i].end - lineStart));
75
+ if (b <= a || a < pos)
76
+ continue;
77
+ if (a > pos)
78
+ out.push({ text: lineText.slice(pos, a), hit: false, current: false });
79
+ out.push({ text: lineText.slice(a, b), hit: true, current: i === current });
80
+ pos = b;
81
+ }
82
+ if (pos < lineText.length) {
83
+ out.push({ text: lineText.slice(pos), hit: false, current: false });
84
+ }
85
+ return out;
86
+ }
87
+ /**
88
+ * Scroll che porta l'offset al CENTRO della finestra visibile.
89
+ *
90
+ * Stessa ricetta di `submitSearchRow` (T52). Centrare conta più che sembri:
91
+ * un'occorrenza portata in cima alla finestra arriva senza il contesto che la
92
+ * precede, ed è il contesto che dice se è quella giusta.
93
+ */
94
+ export function topForOffset(lines, offset, capacity) {
95
+ const maxTop = Math.max(0, lines.length - capacity);
96
+ let i = lines.findIndex((l) => l.end > offset);
97
+ if (i < 0)
98
+ i = Math.max(0, lines.length - 1);
99
+ return Math.max(0, Math.min(maxTop, i - Math.floor(capacity / 2)));
100
+ }
package/dist/viewport.js CHANGED
@@ -224,9 +224,20 @@ export function readerCapacity(rows) {
224
224
  // aggiunta va scalata dalla capienza del contenuto o il frame sfonda `rows`
225
225
  // (stessa invariante di TASKS_PANE_CHROME).
226
226
  const DETAIL_CHROME = 10;
227
- /** Righe di task file che entrano nel terminale. */
228
- export function detailCapacity(rows) {
229
- return Math.max(0, (rows || 24) - SLACK - DETAIL_CHROME);
227
+ // T91 la ricerca dentro il detail: marginTop + riga del campo.
228
+ //
229
+ // `MODAL_HEIGHT` dice che il detail costa 0 ai due pane (li sostituisce), e da
230
+ // lì è facile leggere «dentro l'overlay lo spazio è gratis». Le due contabilità
231
+ // sono distinte: quella dice quanto l'overlay toglie ai PANE, questa quanto
232
+ // toglie al PROPRIO contenuto. Una riga fissa aggiunta e non scalata è ciò che
233
+ // fa sfondare `rows` e manda Ink nel ramo `clearTerminal`, che su VTE riversa un
234
+ // frame nello scrollback a ogni tick del poll.
235
+ const DETAIL_SEARCH_CHROME = 2;
236
+ /** Righe di task file che entrano nel terminale; con la ricerca aperta il campo
237
+ * si paga qui, non altrove. */
238
+ export function detailCapacity(rows, searching = false) {
239
+ const extra = searching ? DETAIL_SEARCH_CHROME : 0;
240
+ return Math.max(0, (rows || 24) - SLACK - DETAIL_CHROME - extra);
230
241
  }
231
242
  // T52 — cornice del pannello di anteprima sotto la lista occorrenze:
232
243
  // marginTop + 2 bordi + riga meta.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
5
5
  "type": "module",
6
6
  "bin": {