@lamemind/loom-deck 0.14.0 → 0.15.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/README.md CHANGED
@@ -148,7 +148,19 @@ che non può mai produrre un risultato.
148
148
 
149
149
  Con l'hash vuoto la lista è **raggruppata per conversazione**; `⏎` è contestuale
150
150
  alla riga selezionata — su una riga-conversazione **riprende** la sessione
151
- (come dal pane Sessions), su una riga-occorrenza apre il **reader**.
151
+ (come dal pane Sessions), su una riga-occorrenza apre il **reader**. La selezione
152
+ parte dalla prima *occorrenza*, non dalla riga di gruppo: quella è un segnaposto
153
+ di navigazione, raggiungibile con le frecce ma non una destinazione.
154
+
155
+ L'estratto attorno al match **si allarga col terminale**: a 190 colonne sono
156
+ ~170 caratteri di contesto, non i 50 di un valore fisso — ed è il contesto la
157
+ ragione per cui si legge la riga invece di aprire il reader.
158
+
159
+ Sotto la lista, un **pannello di anteprima** mostra il contesto attorno
160
+ all'occorrenza selezionata e si aggiorna navigando con le frecce. Prende solo le
161
+ righe che la lista non usa: con pochi risultati riempie il terminale, con molti
162
+ sparisce e la lista se le riprende — quando c'è tanto da scorrere la priorità è
163
+ vedere più occorrenze, il contesto è il premio per una ricerca già stretta.
152
164
 
153
165
  Il reader mostra il messaggio intero, aperto già **posizionato sull'occorrenza**
154
166
  col match evidenziato: `↑↓` riga, `PgUp`/`PgDn` pagina, `g`/`G` estremi, `esc`
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions
13
13
  import { appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
14
14
  import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, selectedSession, } from './session-list.js';
15
15
  import { launchLegend, loadIdentity, loadLaunch } from './config.js';
16
- import { isCompact, layoutBudget, normalizeEmoji, readerCapacity, searchListCapacity, windowRange, wrapLines, wrapWithOffsets, } from './viewport.js';
16
+ import { isCompact, layoutBudget, normalizeEmoji, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, wrapLines, wrapWithOffsets, } from './viewport.js';
17
17
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
18
18
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
19
19
  import { loadView, saveView, viewFilePath } from './view-store.js';
@@ -497,11 +497,19 @@ function Deck({ cwd, tasksPath, tasksDir }) {
497
497
  // ≤9 ms su tutti i tipi — sotto il tempo fra due battute. Il memo evita di
498
498
  // rifarla sui re-render che non toccano né query né opzioni (il poll delle
499
499
  // sessioni ogni 1,5s è già filtrato dalla signature in `useSessions`).
500
- const searchResult = useMemo(() => searchSessions(sessions, searchHash, searchQuery, searchOpts), [sessions, searchHash, searchQuery, searchOpts]);
500
+ const searchResult = useMemo(() => searchSessions(sessions, searchHash, searchQuery, searchOpts, searchExcerptWidth(columns)), [sessions, searchHash, searchQuery, searchOpts, columns]);
501
501
  // Con l'hash valorizzato la conversazione è una sola e già nominata nel campo:
502
502
  // la riga-sessione ripeterebbe un dato costante rubando una riga per gruppo.
503
503
  const searchFlat = searchHash.trim().length > 0;
504
504
  const searchRows = useMemo(() => buildRows(searchResult, searchFlat), [searchResult, searchFlat]);
505
+ // T52 — riga selezionata e, se è un'occorrenza, il suo corpo wrappato per
506
+ // l'anteprima sotto la lista. Memoizzato per (testo, larghezza): navigando
507
+ // con le frecce si ri-wrappa solo quando cambia davvero l'occorrenza.
508
+ const searchSelRow = useMemo(() => selectedRow(searchRows, searchSelKey), [searchRows, searchSelKey]);
509
+ const searchPreviewWidth = Math.max(20, (columns || 80) - 8);
510
+ const searchPreviewBody = useMemo(() => searchSelRow?.kind === 'hit'
511
+ ? wrapWithOffsets(searchSelRow.hit.text, searchPreviewWidth)
512
+ : [], [searchSelRow, searchPreviewWidth]);
505
513
  // T52 — corpo del messaggio aperto nel reader, wrappato UNA volta per (testo,
506
514
  // larghezza). Senza memo ogni pressione di freccia rifarebbe l'a-capo di un
507
515
  // messaggio che nella coda lunga arriva a 150k char.
@@ -673,22 +681,52 @@ function Deck({ cwd, tasksPath, tasksDir }) {
673
681
  setSearchOpts((o) => ({ ...o, kinds: { ...o.kinds, [kind]: !o.kinds[kind] } }));
674
682
  }
675
683
  function editSearchField(fn) {
684
+ // La nota racconta l'esito di un'AZIONE su una lista che, con l'eager, si
685
+ // ricostruisce a ogni carattere: appena la query cambia è già scaduta.
686
+ // Lasciarla lì la fa leggere come se descrivesse lo stato corrente — nello
687
+ // specifico «nessuna occorrenza selezionata» sopra una lista con una riga
688
+ // visibilmente selezionata, cioè una contraddizione a schermo.
689
+ setNote('');
676
690
  if (searchField === 'hash')
677
691
  setSearchHash(fn);
678
692
  else
679
693
  setSearchQuery(fn);
680
694
  }
681
695
  // `useInput` consegna il CHUNK letto da stdin, non un tasto: un incollaggio —
682
- // o una raffica di tasti più veloce di una read — arriva come stringa unica,
683
- // byte di controllo compresi. Senza filtro finiscono DENTRO la query: non si
684
- // vedono, ma Ink li conta nella larghezza della riga e nessun match li
685
- // soddisfa, quindi la ricerca smette di trovare senza dire perché.
686
- // Le newline diventano spazio invece di sparire: incollare due righe deve
687
- // separare le parole, non fonderle.
696
+ // o una raffica di tasti piu' veloce di una read — arriva come stringa unica,
697
+ // byte di controllo compresi. Due conseguenze, entrambe verificate su pty:
698
+ //
699
+ // 1. I byte di controllo finiscono DENTRO il campo se non li si filtra. Non
700
+ // si vedono, ma Ink li conta nella larghezza della riga e nessun match li
701
+ // soddisfa -> la ricerca smette di trovare senza dire perche'. Le newline
702
+ // diventano spazio invece di sparire: incollare due righe deve separare
703
+ // le parole, non fonderle.
704
+ //
705
+ // 2. Un TAB digitato subito dopo una lettera (~60 ms, cioe' battitura veloce
706
+ // normale) arriva incollato ad essa: `key.tab` resta falso e il ramo del
707
+ // cambio campo non scatta mai. Trattarlo come testo lo renderebbe uno
708
+ // spazio, per giunta nel campo sbagliato — e' cosi' che `70897aff` + Tab
709
+ // + `congelat` finiva tutto quanto nel campo hash.
710
+ //
711
+ // Si spezza quindi il chunk SUL TAB applicando il cambio campo in mezzo: il
712
+ // risultato e' identico alla battitura lenta. Stessa lezione del modale sort
713
+ // (T39), che cicla sui caratteri del chunk invece di leggerlo intero.
688
714
  function typeIntoField(chunk) {
689
- const clean = chunk.replace(/[\r\n\t]/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '');
690
- if (clean)
691
- editSearchField((s) => s + clean);
715
+ setNote('');
716
+ const clean = (s) => s.replace(/[\r\n]/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '');
717
+ const parts = chunk.split('\t');
718
+ let field = searchField;
719
+ const add = { hash: '', query: '' };
720
+ for (let k = 0; k < parts.length; k++) {
721
+ if (k > 0)
722
+ field = field === 'hash' ? 'query' : 'hash';
723
+ add[field] += clean(parts[k]);
724
+ }
725
+ if (add.hash)
726
+ setSearchHash((v) => v + add.hash);
727
+ if (add.query)
728
+ setSearchQuery((v) => v + add.query);
729
+ setSearchField(field);
692
730
  }
693
731
  const searchCap = searchListCapacity(rows, Boolean(note));
694
732
  useInput((input, key) => {
@@ -1095,7 +1133,26 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1095
1133
  }
1096
1134
  const selIdx = rowIndexOfKey(searchRows, searchSelKey);
1097
1135
  const win = windowRange(searchRows.length, selIdx, searchCap);
1098
- return (_jsx(SearchScreen, { hash: searchHash, query: searchQuery, field: searchField, opts: searchOpts, result: searchResult, rows: searchRows.slice(win.start, win.end), selectedKey: searchSelKey, selectedKind: selectedRow(searchRows, searchSelKey)?.kind ?? null, above: win.start, below: searchRows.length - win.end, capacity: searchCap, bindings: bindings, pinned: pinned, projectCore: identity ? `${identity.owner} ${identity.name}` : null, note: note }));
1136
+ // Anteprima dell'occorrenza selezionata: prende le righe che la lista non
1137
+ // usa. Con molti risultati `spare` è 0 e il pannello non esiste — la lista
1138
+ // se le riprende tutte, che è la priorità giusta quando c'è molto da
1139
+ // scorrere. La finestra si CENTRA sul match (`windowRange`), così il
1140
+ // contesto arriva da entrambi i lati.
1141
+ const spare = searchPreviewCapacity(searchCap, win.end - win.start);
1142
+ let preview = null;
1143
+ if (spare >= 1 && searchSelRow?.kind === 'hit') {
1144
+ const h = searchSelRow.hit;
1145
+ const mline = Math.max(0, searchPreviewBody.findIndex((l) => l.end > h.matchStart));
1146
+ const pw = windowRange(searchPreviewBody.length, mline, spare);
1147
+ preview = {
1148
+ hit: h,
1149
+ lines: searchPreviewBody.slice(pw.start, pw.end),
1150
+ from: pw.start,
1151
+ total: searchPreviewBody.length,
1152
+ ts: sessions.find((s) => s.sessionId === h.sessionId)?.ts ?? 0,
1153
+ };
1154
+ }
1155
+ return (_jsx(SearchScreen, { preview: preview, hash: searchHash, query: searchQuery, field: searchField, opts: searchOpts, result: searchResult, rows: searchRows.slice(win.start, win.end), selectedKey: searchSelKey, selectedKind: selectedRow(searchRows, searchSelKey)?.kind ?? null, above: win.start, below: searchRows.length - win.end, capacity: searchCap, bindings: bindings, pinned: pinned, projectCore: identity ? `${identity.owner} ${identity.name}` : null, columns: columns, note: note }));
1099
1156
  }
1100
1157
  // ── Budget d'altezza ────────────────────────────────────────────────────
1101
1158
  // Il frame deve restare sotto `rows`, sempre: oltre quella soglia Ink smette
@@ -1171,6 +1228,31 @@ function EditModal({ id, draft, row }) {
1171
1228
  // terminale misurano diversamente (vedi normalizeEmoji).
1172
1229
  const KIND_TAG = { ai: 'ai', tool: 'tl', human: 'hu' };
1173
1230
  const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
1231
+ /**
1232
+ * Larghezza dell'estratto, DERIVATA dalle colonne del terminale.
1233
+ *
1234
+ * Una costante qui è spazio buttato a ogni riga: su un terminale a 190 colonne
1235
+ * un estratto fisso a 50 lascia il match con ~20 caratteri di contesto per lato
1236
+ * quando potrebbe averne 80 — e il contesto attorno al match è l'unica ragione
1237
+ * per cui si legge la riga invece di aprire il reader.
1238
+ *
1239
+ * Scomposizione delle colonne consumate dalla cornice e dal prefisso di riga:
1240
+ * 4 box esterno (2 bordi + 2 padding)
1241
+ * 4 box lista (2 bordi + 2 padding)
1242
+ * 2 caret
1243
+ * 4 indice del record
1244
+ * 3 spazio + tag del kind + spazio
1245
+ * Prudente per costruzione: sottostimare tronca un carattere in più,
1246
+ * sovrastimare manderebbe la riga a capo e sfonderebbe il budget d'altezza.
1247
+ */
1248
+ function searchExcerptWidth(columns) {
1249
+ return Math.max(30, (columns || 80) - 17);
1250
+ }
1251
+ /** Titolo della conversazione sulla riga-gruppo: prende ciò che avanza dopo le
1252
+ * colonne a larghezza fissa (caret, pin, hash, task, conteggio, data). */
1253
+ function searchTitleWidth(columns) {
1254
+ return Math.max(24, (columns || 80) - 56);
1255
+ }
1174
1256
  /**
1175
1257
  * Cosa scrivere sulla riga-gruppo per distinguere una conversazione dall'altra.
1176
1258
  *
@@ -1227,27 +1309,34 @@ function SearchListHeader({ result, query, above, below, }) {
1227
1309
  }
1228
1310
  return (_jsxs(Text, { bold: true, wrap: "truncate-end", children: [result.shown, " occorrenze in ", result.sessionCount, " conversazioni", result.hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", result.hidden, " oltre il cap"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }));
1229
1311
  }
1230
- /**
1231
- * Schermata di ricerca full-text (T52).
1232
- *
1233
- * Due campi + toggle + lista di occorrenze. Con l'hash vuoto la lista è
1234
- * raggruppata per conversazione: la riga-gruppo NON ripete il nome del progetto
1235
- * (D3: sono tutte dello stesso progetto, sarebbe una colonna costante) e usa lo
1236
- * spazio per ciò che distingue davvero una conversazione — hash, task legata,
1237
- * titolo, data.
1238
- */
1239
- function SearchScreen({ hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, projectCore, note, }) {
1312
+ function SearchScreen({ preview, hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, projectCore, columns, note, }) {
1240
1313
  const enter = selectedKind === 'session' ? 'resume' : selectedKind === 'hit' ? 'leggi' : '—';
1241
1314
  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: ["ricerca \u00B7 ", _jsx(Text, { color: "yellow", children: "tab" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " naviga \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " ", enter, " \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "hash " }), _jsx(Text, { color: field === 'hash' ? 'yellow' : undefined, children: hash }), field === 'hash' ? _jsx(Text, { inverse: true, children: " " }) : null, !hash ? _jsx(Text, { dimColor: true, children: " (vuoto = tutte le conversazioni)" }) : null] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "chiave " }), _jsx(Text, { color: field === 'query' ? 'yellow' : undefined, children: query }), field === 'query' ? _jsx(Text, { inverse: true, children: " " }) : null] }), _jsx(ToggleHint, { opts: opts })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsx(SearchListHeader, { result: result, query: query, above: above, below: below }), rows.slice(0, Math.max(0, capacity)).map((row) => {
1242
1315
  const sel = row.key === selectedKey;
1243
1316
  if (row.kind === 'session') {
1244
1317
  const s = row.session;
1245
1318
  const bound = bindings.get(s.sessionId);
1246
- return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), truncate(conversationLabel(s, projectCore, bound), 44), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
1319
+ return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), truncate(conversationLabel(s, projectCore, bound), searchTitleWidth(columns)), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
1247
1320
  }
1248
1321
  const h = row.hit;
1249
1322
  return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: String(h.idx).padStart(4) }), ' ', _jsx(Text, { color: KIND_COLOR[h.kind], children: KIND_TAG[h.kind] }), " ", h.excerpt] }, row.key));
1250
- })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
1323
+ })] }), preview ? _jsx(SearchPreviewPane, { p: preview }) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
1324
+ }
1325
+ /**
1326
+ * Anteprima dell'occorrenza selezionata, sotto la lista.
1327
+ *
1328
+ * Riempie le righe che la lista non usa: con pochi risultati il terminale
1329
+ * resterebbe vuoto per tre quarti, e il contesto attorno al match è proprio
1330
+ * ciò che serve per decidere se è l'occorrenza giusta. Nel caso comune evita
1331
+ * del tutto di aprire il reader.
1332
+ *
1333
+ * Si aggiorna navigando con le frecce, e la finestra è centrata sul match:
1334
+ * stessa `windowRange` della lista, stessa evidenziazione del reader
1335
+ * (`ReaderLine`) — nessuna primitiva nuova.
1336
+ */
1337
+ function SearchPreviewPane({ p }) {
1338
+ const last = Math.min(p.total, p.from + p.lines.length);
1339
+ 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)))] }));
1251
1340
  }
1252
1341
  /**
1253
1342
  * Reader fullscreen (T52 · D8).
package/dist/search.js CHANGED
@@ -19,7 +19,10 @@
19
19
  /** Sotto questa soglia non si cerca: 1-2 char matchano ovunque e la lista
20
20
  * sarebbe rumore. Stato lecito, non un errore. */
21
21
  export const MIN_QUERY = 3;
22
- /** Larghezza nominale dell'estratto attorno al match. */
22
+ /** Larghezza di ripiego dell'estratto, usata solo quando il chiamante non
23
+ * conosce le colonne del terminale (test, uso non-TUI). In UI la larghezza
24
+ * arriva SEMPRE da `columns`: inchiodarla qui sprecherebbe tutto lo spazio di
25
+ * un terminale largo — su 190 colonne significa ~140 buttate per riga. */
23
26
  export const EXCERPT_WIDTH = 50;
24
27
  /** Cap per singola conversazione. Serve a tenere leggibile un gruppo, non a
25
28
  * proteggere la memoria: una query corta può matchare centinaia di volte nello
@@ -121,7 +124,7 @@ export function excerptAround(text, start, end, width = EXCERPT_WIDTH) {
121
124
  *
122
125
  * `re` deve avere il flag `g` (lo garantisce buildMatcher).
123
126
  */
124
- function scanBody(sessionId, body, re, need) {
127
+ function scanBody(sessionId, body, re, need, excerptWidth) {
125
128
  const hits = [];
126
129
  let count = 0;
127
130
  re.lastIndex = 0;
@@ -135,7 +138,7 @@ function scanBody(sessionId, body, re, need) {
135
138
  sessionId,
136
139
  idx: body.idx,
137
140
  kind: body.kind,
138
- excerpt: excerptAround(body.text, start, end),
141
+ excerpt: excerptAround(body.text, start, end, excerptWidth),
139
142
  matchStart: start,
140
143
  matchEnd: end,
141
144
  text: body.text,
@@ -155,7 +158,7 @@ function scanBody(sessionId, body, re, need) {
155
158
  * uguaglianza: gli 8 char che la statusline mostra bastano a individuarla, e
156
159
  * nessuno copia un uuid intero a mano.
157
160
  */
158
- export function searchSessions(sessions, hashPrefix, query, opts) {
161
+ export function searchSessions(sessions, hashPrefix, query, opts, excerptWidth = EXCERPT_WIDTH) {
159
162
  const prefix = hashPrefix.trim().toLowerCase();
160
163
  const scoped = prefix
161
164
  ? sessions.filter((s) => s.sessionId.toLowerCase().startsWith(prefix))
@@ -178,7 +181,7 @@ export function searchSessions(sessions, hashPrefix, query, opts) {
178
181
  // spazio nella lista. A zero la scansione prosegue lo stesso — serve il
179
182
  // conteggio, che è ciò che alimenta «+N occorrenze».
180
183
  const need = Math.max(0, Math.min(MAX_HITS_PER_SESSION - hits.length, MAX_TOTAL_HITS - shown - hits.length));
181
- const r = scanBody(session.sessionId, body, re, need);
184
+ const r = scanBody(session.sessionId, body, re, need, excerptWidth);
182
185
  found += r.count;
183
186
  hits.push(...r.hits);
184
187
  }
@@ -221,10 +224,19 @@ export function buildRows(result, flat) {
221
224
  }
222
225
  return rows;
223
226
  }
224
- /** Prima riga selezionabile; null = lista vuota. Ogni riga è selezionabile —
225
- * non esiste un separatore, a differenza di `session-list.ts`. */
227
+ /**
228
+ * Riga su cui atterrare quando la selezione va ricostruita.
229
+ *
230
+ * Preferisce la prima **occorrenza**, non la prima riga in assoluto. In modalità
231
+ * raggruppata la lista si apre sempre con una riga-sessione, che è un
232
+ * segnaposto di navigazione e non una destinazione: atterrarci lascerebbe
233
+ * l'anteprima vuota all'apertura di ogni ricerca, e `⏎` prometterebbe un resume
234
+ * quando l'utente ha appena cercato delle occorrenze.
235
+ *
236
+ * Le righe-sessione restano raggiungibili con le frecce. null = lista vuota.
237
+ */
226
238
  export function firstRowKey(rows) {
227
- return rows[0]?.key ?? null;
239
+ return (rows.find((r) => r.kind === 'hit') ?? rows[0])?.key ?? null;
228
240
  }
229
241
  /** Indice della riga con quella chiave; -1 se assente. */
230
242
  export function rowIndexOfKey(rows, key) {
package/dist/viewport.js CHANGED
@@ -202,6 +202,24 @@ const READER_CHROME = 8;
202
202
  export function readerCapacity(rows) {
203
203
  return Math.max(0, (rows || 24) - SLACK - READER_CHROME);
204
204
  }
205
+ // T52 — cornice del pannello di anteprima sotto la lista occorrenze:
206
+ // marginTop + 2 bordi + riga meta.
207
+ const SEARCH_PREVIEW_CHROME = 4;
208
+ /**
209
+ * Righe di contesto concesse all'anteprima dell'occorrenza selezionata.
210
+ *
211
+ * Prende SOLO ciò che avanza: la lista si dimensiona sul contenuto e, quando i
212
+ * risultati sono pochi, il resto del terminale resterebbe vuoto. Con molti
213
+ * risultati lo spazio torna alla lista e il pannello sparisce da sé — la
214
+ * priorità è vedere quante più occorrenze possibile, il contesto è il premio
215
+ * per una ricerca già stretta.
216
+ *
217
+ * Restituisce 0 quando non c'è spazio nemmeno per una riga utile: un pannello
218
+ * di sola cornice ruberebbe 4 righe per non mostrare nulla.
219
+ */
220
+ export function searchPreviewCapacity(capacity, listRows) {
221
+ return Math.max(0, capacity - listRows - SEARCH_PREVIEW_CHROME);
222
+ }
205
223
  /**
206
224
  * Il terminale non ospita nemmeno la cornice della schermata: va sostituita da
207
225
  * una riga singola, come fa `Budget.compact` per il deck.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.14.0",
3
+ "version": "0.15.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": {