@lamemind/loom-deck 0.29.0 → 0.30.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.
package/dist/cli.js CHANGED
@@ -13,11 +13,12 @@ import { discoverProjectSessions } from './sessions.js';
13
13
  import { discoverLiveSessions, liveSig } from './live-sessions.js';
14
14
  import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions, selectedRow, DEFAULT_OPTIONS, MIN_QUERY, } from './search.js';
15
15
  import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
16
- import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, rowLabel, selectedSession, sessionTitle, stripProjectCore, } from './session-list.js';
16
+ import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, rowLabel, selectedSession, sessionTitle, stripProjectCore, unpinLandingId, } from './session-list.js';
17
17
  import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch, } from './config.js';
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
@@ -1725,8 +1824,16 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1725
1824
  }
1726
1825
  else {
1727
1826
  const isPinned = pinned.has(selSessionId);
1827
+ // Dove atterra la selezione: sul pin resta sulla sessione (sale in cima
1828
+ // con lei, il caret la segue perché è l'oggetto dell'azione); sull'unpin
1829
+ // resta IN PLACE nel gruppo pinnate — successiva, precedente, o prima
1830
+ // contestuale se il gruppo si svuota. Calcolato PRIMA della riscrittura
1831
+ // del sidecar, quando la riga pinnata esiste ancora.
1832
+ const landing = isPinned ? unpinLandingId(sessionRows, selSessionId) : null;
1728
1833
  appendPin(cwd, selSessionId, !isPinned);
1729
1834
  reloadSessions();
1835
+ if (landing)
1836
+ setSelSessionId(landing);
1730
1837
  setNote(`${isPinned ? 'unpin' : '📌 pin'} ${selSessionId.slice(0, 8)}`);
1731
1838
  }
1732
1839
  }
@@ -1875,7 +1982,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1875
1982
  // posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
1876
1983
  // può accorciare il testo sotto uno scroll già dato.
1877
1984
  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 }));
1985
+ 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
1986
  }
1880
1987
  // ── T52 · schermate sostitutive ─────────────────────────────────────────
1881
1988
  // Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
@@ -2133,7 +2240,8 @@ function SearchScreen({ preview, hash, query, field, opts, result, rows, selecte
2133
2240
  */
2134
2241
  function SearchPreviewPane({ p }) {
2135
2242
  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)))] }));
2243
+ const occ = [{ start: p.hit.matchStart, end: p.hit.matchEnd }];
2244
+ 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
2245
  }
2138
2246
  /**
2139
2247
  * Reader fullscreen (T52 · D8).
@@ -2145,18 +2253,32 @@ function SearchPreviewPane({ p }) {
2145
2253
  */
2146
2254
  function ReaderScreen({ hit, lines, top, total, capacity, bound, }) {
2147
2255
  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))) })] }));
2256
+ const occ = [{ start: hit.matchStart, end: hit.matchEnd }];
2257
+ 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
2258
  }
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) {
2259
+ /** Una riga con le porzioni di match evidenziate. Gli offset sono quelli del
2260
+ * testo sorgente, quindi un match a cavallo dell'a-capo si colora su entrambe
2261
+ * le righe senza casi speciali — entrambe intersecano il suo intervallo.
2262
+ *
2263
+ * Regge N occorrenze perché il detail (T91) ne mostra tutte quelle visibili; il
2264
+ * reader (T52) ne passa una sola, che è il caso degenere dello stesso taglio. */
2265
+ function ReaderLine({ line, occ, current, }) {
2266
+ const segs = sliceLine(line.text, line.start, occ, current);
2267
+ if (segs.length === 0)
2157
2268
  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)] }));
2269
+ return (_jsx(Text, { wrap: "truncate-end", children: segs.map((s, i) => s.hit ? (
2270
+ // La corrente si distingue dalle altre per COLORE di sfondo, non per
2271
+ // presenza: tutte restano visibili, o navigare fra occorrenze non
2272
+ // mostrerebbe più dove sono le altre.
2273
+ _jsx(Text, { backgroundColor: s.current ? 'cyan' : 'yellow', color: "black", children: s.text }, i)) : (_jsx(Text, { children: s.text }, i))) }));
2274
+ }
2275
+ /** Campo della ricerca nel detail: finestra ancorata al caret, cursore inverso
2276
+ * sulla cella reale. Gemello di `EditTextField` senza la label, che qui sta
2277
+ * fuori perché il campo vive in FLUSSO su una riga condivisa col contatore —
2278
+ * non su una riga propria. */
2279
+ function DetailFindField({ value, caret, cols }) {
2280
+ const win = caretWindow(value, caret, cols);
2281
+ 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
2282
  }
2161
2283
  /**
2162
2284
  * Detail della task (T66): il task file scrollabile più la barra azioni.
@@ -2171,7 +2293,7 @@ function ReaderLine({ line, from, to }) {
2171
2293
  * all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione. La
2172
2294
  * navigazione da tastiera ci si sovrappone senza conflitti.
2173
2295
  */
2174
- function DetailScreen({ id, title, missing, lines, top, total, capacity, action, columns, }) {
2296
+ function DetailScreen({ id, title, missing, lines, top, total, capacity, action, columns, find, occ, occCur, }) {
2175
2297
  const last = Math.min(total, top + capacity);
2176
2298
  // Il taglio lo fa il chiamante (invariante ③ di width.ts): la riga bottoni è
2177
2299
  // ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso — ma
@@ -2192,10 +2314,10 @@ function DetailScreen({ id, title, missing, lines, top, total, capacity, action,
2192
2314
  if (dropped(shown) > 0)
2193
2315
  shown = cutParts(parts, Math.max(0, width - 6));
2194
2316
  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)"] })) : (
2317
+ 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
2318
  // Riga vuota → uno spazio: un `<Text>` senza contenuto Ink non lo
2197
2319
  // 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] }) })] }));
2320
+ 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
2321
  }
2200
2322
  /**
2201
2323
  * 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
@@ -197,6 +197,34 @@ export function neighborId(rows, sessionId) {
197
197
  return null;
198
198
  return selectable[at + 1]?.sessionId ?? selectable[at - 1]?.sessionId ?? null;
199
199
  }
200
+ /**
201
+ * Id su cui atterrare quando la riga pinnata `sessionId` viene SPINNATA: la
202
+ * pinnata successiva, la precedente se era l'ultima, la prima contestuale se il
203
+ * gruppo pinnate si svuota. `null` se `sessionId` non è pinnata o non resta
204
+ * nessuna riga.
205
+ *
206
+ * Non è `neighborId` ristretto al gruppo: quello attraversa il separatore,
207
+ * quindi sull'ULTIMA pinnata atterrerebbe sulla prima contestuale — il caret
208
+ * scavalcherebbe l'intero gruppo invece di risalire di una riga. Qui il confine
209
+ * fra i due gruppi è la regola, non un ostacolo.
210
+ *
211
+ * L'id va calcolato PRIMA di riscrivere il sidecar: dopo, la riga pinnata non
212
+ * c'è più e il vicino nel gruppo non è calcolabile. Serve perché una spinnata
213
+ * non sparisce necessariamente dalla lista — se appartiene al parent
214
+ * selezionato ricompare fra le contestuali, e la selezione keyed sull'id la
215
+ * seguirebbe fin laggiù, trascinando il caret.
216
+ */
217
+ export function unpinLandingId(rows, sessionId) {
218
+ const pinnedRows = rows.filter((r) => r.kind === 'pinned');
219
+ const at = pinnedRows.findIndex((r) => r.sessionId === sessionId);
220
+ if (at < 0)
221
+ return null;
222
+ const inGroup = pinnedRows[at + 1]?.sessionId ?? pinnedRows[at - 1]?.sessionId;
223
+ if (inGroup)
224
+ return inGroup;
225
+ const firstContext = rows.find((r) => r.kind === 'context');
226
+ return firstContext && isSelectable(firstContext) ? firstContext.sessionId : null;
227
+ }
200
228
  /** Session della riga selezionata; null se stale o nessuna selezione. */
201
229
  export function selectedSession(rows, sessionId) {
202
230
  const r = rows.find((row) => isSelectable(row) && row.sessionId === sessionId);
@@ -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.1",
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": {