@lamemind/loom-deck 0.31.0 → 0.33.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
@@ -15,10 +15,12 @@ import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions
15
15
  import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
16
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
- import { countArchivable, SCAN_INTERVAL_MS } from './archivable.js';
18
+ import { archivableIds, SCAN_INTERVAL_MS } from './archivable.js';
19
+ import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, SESSION_VIEWS, TASK_VIEWS, } from './pane-views.js';
19
20
  import { assignListCapacity, detailCapacity, isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
20
21
  import { caretWindow, cut, cutParts, pad, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
21
22
  import { scanText, sliceLine, topForOffset, } from './text-search.js';
23
+ import { parseMarkdown, sliceSpans, } from './markdown.js';
22
24
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
23
25
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
24
26
  import { loadView, saveView, viewFilePath } from './view-store.js';
@@ -469,22 +471,26 @@ function useSessions(projectRoot) {
469
471
  // `doneSig` è una stringa, non l'array: `tasks` cambia identità a ogni re-read
470
472
  // di tasks.md, e usarlo come dipendenza rimetterebbe lo scan sul tick da 1,5s
471
473
  // per la via di dietro.
474
+ //
475
+ // T100 — tiene gli ID e non più il conteggio: `archiviabili` è una vista del
476
+ // pane, quindi servono le righe. Il contatore dell'header è `.size` dello stesso
477
+ // insieme che disegna la lista — un numero e una lista che non possono divergere.
472
478
  function useArchivable(doneSig, tasksDir, projectRoot, days) {
473
- const [count, setCount] = useState(0);
479
+ const [ids, setIds] = useState(() => new Set());
474
480
  useEffect(() => {
475
481
  let alive = true;
476
- const ids = doneSig ? doneSig.split(',') : [];
482
+ const done = doneSig ? doneSig.split(',') : [];
477
483
  const scan = () => {
478
- countArchivable(ids, { tasksDir, projectRoot, days })
479
- .then((n) => {
484
+ archivableIds(done, { tasksDir, projectRoot, days })
485
+ .then((found) => {
480
486
  if (alive)
481
- setCount(n);
487
+ setIds(new Set(found));
482
488
  })
483
- // Scan fallito (task file illeggibili, git muto) → 0, cioè segmento
484
- // omesso. Un contatore informativo non merita un errore a schermo.
489
+ // Scan fallito (task file illeggibili, git muto) → insieme vuoto, cioè
490
+ // voce a 0. Un contatore informativo non merita un errore a schermo.
485
491
  .catch(() => {
486
492
  if (alive)
487
- setCount(0);
493
+ setIds(new Set());
488
494
  });
489
495
  };
490
496
  scan();
@@ -494,7 +500,7 @@ function useArchivable(doneSig, tasksDir, projectRoot, days) {
494
500
  clearInterval(id);
495
501
  };
496
502
  }, [doneSig, tasksDir, projectRoot, days]);
497
- return count;
503
+ return ids;
498
504
  }
499
505
  // Legge il task file della task selezionata (Q1+B T20). On-id-change: navigare
500
506
  // con ↑↓ ricarica il dettaglio; leggere un singolo file 4-9KB è I/O triviale,
@@ -622,6 +628,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
622
628
  // modale: la lista si aggiorna dal vivo, quindi `esc` deve poter ripristinare.
623
629
  const [view, setView] = useState(() => loadView(cwd));
624
630
  const [viewBackup, setViewBackup] = useState(null);
631
+ // T100 — vista attiva di ciascun pane, navigata con ←/→. VOLATILE per
632
+ // decisione (D3 create): non entra in `deck-view.json`, il deck riapre sempre
633
+ // su `Tasks` e su `{parent}`. Il criterio è il rischio di leggere una lista
634
+ // parziale credendola completa — un filtro salvato lo si è scelto, una vista
635
+ // riaperta a freddo si legge come la lista intera.
636
+ const [taskViewId, setTaskViewId] = useState('tasks');
637
+ const [sessionViewId, setSessionViewId] = useState('context');
625
638
  const [filterCursor, setFilterCursor] = useState({ row: 0, col: 0 });
626
639
  // T41 — bozza dell'edit (null fuori dal modale) e riga attiva della griglia.
627
640
  const [edit, setEdit] = useState(null);
@@ -697,15 +710,31 @@ function Deck({ cwd, tasksPath, tasksDir }) {
697
710
  const doneSig = useMemo(() => tasks.filter((t) => isDone(t.prog)).map((t) => t.id).join(','), [tasks]);
698
711
  const archivableDays = useMemo(() => loadArchivableDays(cwd), [cwd]);
699
712
  const archivable = useArchivable(doneSig, tasksDir, cwd, archivableDays);
713
+ // T100 — le task effettivamente a schermo: la vista principale coincide con
714
+ // `viewTasks` (nessun ricalcolo sul cammino di default), le altre due passano
715
+ // dal predicato del catalogo. I CONTATORI restano misurati sulla vista di
716
+ // default, o navigare cambierebbe i numeri che si sta navigando.
717
+ const taskCounts = {
718
+ filtered: viewTasks.length,
719
+ total: tasks.length,
720
+ hidden: hiddenTasks,
721
+ archivable: archivable.size,
722
+ };
723
+ const paneTasks = useMemo(() => taskViewId === 'tasks'
724
+ ? viewTasks
725
+ : selectTasks(tasks, taskViewId, { view, archivable }), [taskViewId, viewTasks, tasks, view, archivable]);
700
726
  const isSpot = sel === SPOT;
701
727
  const isAll = sel === ALL;
702
728
  const projectName = cwd.split('/').pop() || cwd;
703
729
  // Unica fonte della selezione: si legge SEMPRE dalla vista, mai dall'array
704
730
  // grezzo — è l'invariante che tiene allineati dettaglio mostrato e spawn.
705
- const selTask = typeof sel === 'string' ? viewTasks.find((t) => t.id === sel) ?? null : null;
731
+ const selTask = typeof sel === 'string' ? paneTasks.find((t) => t.id === sel) ?? null : null;
706
732
  const selectedTaskId = selTask?.id ?? null;
707
- const selIndex = selTask ? viewTasks.indexOf(selTask) + META_ROWS : isAll ? ROW_ALL : ROW_SPOT;
733
+ const selIndex = selTask ? paneTasks.indexOf(selTask) + META_ROWS : isAll ? ROW_ALL : ROW_SPOT;
708
734
  const detail = useTaskDetail(tasksDir, selectedTaskId ?? undefined);
735
+ // Il parent delle conversazioni: l'asse che sceglie il pane task, ortogonale
736
+ // alla vista che sceglie l'header (D2 create).
737
+ const parentLabel = isAll ? 'tutte' : isSpot ? 'spot' : selectedTaskId ?? '—';
709
738
  // Conteggio figli per task + spot (badge nel Tasks pane).
710
739
  const childCount = new Map();
711
740
  let spotCount = 0;
@@ -731,12 +760,24 @@ function Deck({ cwd, tasksPath, tasksDir }) {
731
760
  // contestuali. Dedup, cap solo sulle contestuali, righe stale per le pinnate
732
761
  // orfane. Core PURO in session-list.ts (testabile senza Ink).
733
762
  const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, isAll ? MAX_SESSIONS_ALL : MAX_SESSIONS), [childSessions, sessions, pinned, isAll]);
734
- const sessionRows = assembled.rows;
735
- const selSessionObj = selectedSession(sessionRows, selSessionId);
736
763
  // T62 — contato sulla lista INTERA (stessa ragione delle larghezze di colonna
737
764
  // qui sotto): derivarlo dalla finestra visibile lo farebbe cambiare a ogni
738
765
  // scroll, cioè un contatore che conta lo schermo invece della lista.
739
- const liveCount = useMemo(() => sessionRows.filter((r) => r.kind !== 'separator' && live.has(r.sessionId)).length, [sessionRows, live]);
766
+ // T100 «intera» ora vuol dire la lista della vista di DEFAULT (`assembled`),
767
+ // non quella a schermo: il contatore di una voce del catalogo non può
768
+ // dipendere da quale voce è selezionata, o navigare muoverebbe i numeri.
769
+ const liveCount = useMemo(() => assembled.rows.filter((r) => r.kind !== 'separator' && live.has(r.sessionId)).length, [assembled, live]);
770
+ const sessionCounts = {
771
+ total: assembled.pinnedCount + assembled.contextTotal,
772
+ live: liveCount,
773
+ pinned: assembled.pinnedCount,
774
+ older: assembled.contextHidden,
775
+ };
776
+ // T100 — le righe a schermo sono quelle della vista attiva. Fuori dalla vista
777
+ // di default il separatore non c'è: segna il confine fra pinnate e
778
+ // contestuali, e in un sottoinsieme quel confine non esiste più.
779
+ const sessionRows = useMemo(() => selectSessionRows(sessionViewId, { assembled, isLive: (id) => live.has(id) }), [sessionViewId, assembled, live]);
780
+ const selSessionObj = selectedSession(sessionRows, selSessionId);
740
781
  // T60 — larghezze delle colonne fisse della lista sessioni, misurate sulla
741
782
  // lista INTERA e non sulla finestra visibile: derivarle dalle sole righe a
742
783
  // schermo le farebbe cambiare a ogni scroll, cioè l'opposto di una tabella.
@@ -800,18 +841,32 @@ function Deck({ cwd, tasksPath, tasksDir }) {
800
841
  // 2 padding) = 8. Sottostimare tronca un carattere, sovrastimare manda a capo
801
842
  // una riga che il budget d'altezza non ha contato.
802
843
  const sheetWidth = Math.max(20, (columns || 80) - 8);
844
+ // T75 — il markdown si rende PRIMA del wrap, e il resto della catena lavora
845
+ // sul testo reso: `**foo**` occupa 3 colonne rese e 7 grezze, quindi
846
+ // wrappare sui marker manderebbe a capo su un conteggio che il terminale non
847
+ // disegna. Memo separato dal wrap perché il parse dipende solo dal testo: un
848
+ // resize ri-wrappa 9KB, non li ri-parsa.
849
+ const sheetDoc = useMemo(() => (sheet?.text ? parseMarkdown(sheet.text) : null), [sheet]);
803
850
  // Le righe conservano i propri offset invece di essere appiattite a stringa
804
851
  // (T66 le buttava con `.map((l) => l.text)`): è ciò che rende
805
852
  // 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]);
853
+ // per il match spezzato dall'a-capo. Dopo T75 gli offset indicizzano il testo
854
+ // RESO, ed è l'unica coordinata coerente che resti il sorgente non è più
855
+ // ciò che sta a schermo.
856
+ const sheetLines = useMemo(() => (sheetDoc ? wrapWithOffsets(sheetDoc.text, sheetWidth) : []), [sheetDoc, sheetWidth]);
808
857
  const sheetCap = detailCapacity(rows, find?.open === true);
809
858
  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]);
859
+ // Lo scan gira sulla STESSA stringa che si renderizza: cercare su un testo
860
+ // diverso da quello a schermo darebbe offset che indicizzano un altro
861
+ // documento, cioè un'evidenziazione spostata di N caratteri e nessun errore.
862
+ //
863
+ // Dopo T75 quella stringa è il testo RESO, non più il sorgente: si cerca ciò
864
+ // che si vede. Ne discende che `**` non è più cercabile — è la conseguenza
865
+ // voluta, perché a schermo non c'è; e `Priority`, che prima era `**Priority**`
866
+ // e si trovava lo stesso, continua a trovarsi.
867
+ const findRes = useMemo(() => find && sheetDoc
868
+ ? scanText(sheetDoc.text, find.q)
869
+ : { occ: [], error: '' }, [find?.q, sheetDoc]);
815
870
  // L'indice si clampa qui invece di essere corretto a ogni `setOccIdx`: la
816
871
  // lista si accorcia da sola mentre si digita, e un indice fuori range vivrebbe
817
872
  // per il tempo di un render.
@@ -849,10 +904,10 @@ function Deck({ cwd, tasksPath, tasksDir }) {
849
904
  // dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
850
905
  // sulla prima visibile — fallback deterministico, mai una posizione a caso.
851
906
  useEffect(() => {
852
- if (typeof sel === 'string' && !viewTasks.some((t) => t.id === sel)) {
853
- setSel(viewTasks[0]?.id ?? ALL);
907
+ if (typeof sel === 'string' && !paneTasks.some((t) => t.id === sel)) {
908
+ setSel(paneTasks[0]?.id ?? ALL);
854
909
  }
855
- }, [viewTasks, sel]);
910
+ }, [paneTasks, sel]);
856
911
  // T50 — la selezione (id) resta valida sotto la vista a due gruppi: se l'id
857
912
  // non è più una riga selezionabile (cambio parent, lista mutata, pin rimosso,
858
913
  // sessione sparita) cade sulla prima riga — fallback deterministico, mai una
@@ -935,7 +990,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
935
990
  // messaggi a mano. `verb` è l'unica cosa che cambia fra i due usi.
936
991
  function selectedTaskOr(keyLabel, verb) {
937
992
  if (focus !== 'tasks') {
938
- setNote(`${keyLabel} → ${verb}: seleziona una task (←→ per il pane)`);
993
+ setNote(`${keyLabel} → ${verb}: seleziona una task (tab per il pane)`);
939
994
  return null;
940
995
  }
941
996
  // T59 — la guardia è "non è una task", non "è spot": le righe meta sono due
@@ -1178,13 +1233,33 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1178
1233
  // 2..N+1 = task visibili) e la riconverte subito in sentinella o id: l'indice
1179
1234
  // non sopravvive a un cambio di filtro, l'id sì.
1180
1235
  function moveTaskSel(delta) {
1181
- const next = Math.max(0, Math.min(viewTasks.length + META_ROWS - 1, selIndex + delta));
1236
+ const next = Math.max(0, Math.min(paneTasks.length + META_ROWS - 1, selIndex + delta));
1182
1237
  if (next === ROW_ALL)
1183
1238
  setSel(ALL);
1184
1239
  else if (next === ROW_SPOT)
1185
1240
  setSel(SPOT);
1186
1241
  else
1187
- setSel(viewTasks[next - META_ROWS]?.id ?? SPOT);
1242
+ setSel(paneTasks[next - META_ROWS]?.id ?? SPOT);
1243
+ }
1244
+ // T100 — ←/→ navigano il catalogo viste del pane in focus. Il reset della
1245
+ // selezione è la regola letterale «prima riga in alto», senza eccezioni: sul
1246
+ // pane task è `ROW_ALL` (D2 preflight — le righe meta non si saltano, e il
1247
+ // parent delle sessioni che torna a `tutte` è un effetto accettato); sul pane
1248
+ // sessioni basta invalidare l'id, e l'effect di validità atterra sulla prima
1249
+ // riga selezionabile della vista nuova.
1250
+ function cycleView(delta) {
1251
+ if (focus === 'tasks') {
1252
+ const next = cycleTaskView(taskViewId, delta);
1253
+ setTaskViewId(next);
1254
+ setSel(ALL);
1255
+ setNote(`vista task: ${taskView(next).label(taskCounts)}`);
1256
+ }
1257
+ else {
1258
+ const next = cycleSessionView(sessionViewId, delta);
1259
+ setSessionViewId(next);
1260
+ setSelSessionId(null);
1261
+ setNote(`vista sessioni: ${sessionView(next).label(sessionCounts, parentLabel)}`);
1262
+ }
1188
1263
  }
1189
1264
  // T52 — `⏎` contestuale al TIPO di riga: la lista ne mescola due e l'azione
1190
1265
  // giusta dipende da quale è selezionata. Riga sessione → resume, identico al
@@ -1718,9 +1793,18 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1718
1793
  }
1719
1794
  return;
1720
1795
  }
1721
- if (key.leftArrow || key.rightArrow || key.tab) {
1796
+ if (key.tab) {
1797
+ // T100 — `tab` resta l'unico tasto che sposta il focus fra i due pane. Le
1798
+ // frecce orizzontali facevano lo stesso lavoro (due tasti per un'azione)
1799
+ // mentre la navigazione DENTRO un pane non ne aveva nessuno: ora sono il
1800
+ // selettore di vista dell'header. Il ramo `mode === 'detail'`, anteposto e
1801
+ // chiuso da `return`, non è più l'eccezione a «cambia pane» ma a «cambia
1802
+ // vista» — l'ordine dei rami non cambia, cambia cosa cattura.
1722
1803
  setFocus((f) => (f === 'tasks' ? 'sessions' : 'tasks'));
1723
1804
  }
1805
+ else if (key.leftArrow || key.rightArrow) {
1806
+ cycleView(key.leftArrow ? -1 : 1);
1807
+ }
1724
1808
  else if (key.upArrow) {
1725
1809
  if (focus === 'tasks')
1726
1810
  moveTaskSel(-1);
@@ -1775,9 +1859,20 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1775
1859
  setMode('sort');
1776
1860
  }
1777
1861
  else if (input === 'F') {
1778
- setViewBackup(view);
1779
- setNote('');
1780
- setMode('filter');
1862
+ // T100/D3 — i filtri valgono SOLO sulla vista principale: su `nascoste`
1863
+ // riapplicarli non ha senso (quella vista È il loro complemento), su
1864
+ // `archiviabili` non li si vuole (è cieca ai filtri per decisione). Stessa
1865
+ // forma dell'inerzia di ^K/^P/^R dentro il detail, ma keyed sulla vista
1866
+ // invece che sul modo — e come là, l'inerzia lo DICE invece di non fare
1867
+ // niente in silenzio.
1868
+ if (taskViewId !== 'tasks') {
1869
+ setNote(`F → filtri: solo sulla vista ${TASK_VIEWS[0].label(taskCounts)} (ora: ${taskView(taskViewId).label(taskCounts)})`);
1870
+ }
1871
+ else {
1872
+ setViewBackup(view);
1873
+ setNote('');
1874
+ setMode('filter');
1875
+ }
1781
1876
  }
1782
1877
  else if (input === 'f') {
1783
1878
  // T28 — fork della sessione selezionata. Minuscola come `t`/`c` (T39):
@@ -1785,7 +1880,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1785
1880
  // Vive solo sul pane sessioni: il fork ha per oggetto una conversazione,
1786
1881
  // e senza focus lì non ce n'è una selezionata su cui agire.
1787
1882
  if (focus !== 'sessions') {
1788
- setNote('f → fork: seleziona una sessione (←→ per il pane)');
1883
+ setNote('f → fork: seleziona una sessione (tab per il pane)');
1789
1884
  }
1790
1885
  else {
1791
1886
  const s = selSessionObj;
@@ -1817,7 +1912,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1817
1912
  // pinnata STALE (l'unico modo di spinnarla). Scrive il sidecar e ricarica
1818
1913
  // subito, senza attendere il tick del poll.
1819
1914
  if (focus !== 'sessions') {
1820
- setNote('p → pin: seleziona una sessione (←→ per il pane)');
1915
+ setNote('p → pin: seleziona una sessione (tab per il pane)');
1821
1916
  }
1822
1917
  else if (!selSessionId) {
1823
1918
  setNote('p → nessuna sessione da pinnare');
@@ -1845,7 +1940,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1845
1940
  // su una pinnata STALE, perché annotare «questa non c'è più, era X» è
1846
1941
  // proprio il caso in cui una nota serve.
1847
1942
  if (focus !== 'sessions') {
1848
- setNote('N → nota: seleziona una sessione (←→ per il pane)');
1943
+ setNote('N → nota: seleziona una sessione (tab per il pane)');
1849
1944
  }
1850
1945
  else if (!selSessionId) {
1851
1946
  setNote('N → nessuna sessione da annotare');
@@ -1863,7 +1958,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1863
1958
  // il binding è nostro, il transcript è di CC — riassegnare una
1864
1959
  // conversazione il cui transcript non c'è più resta legittimo.
1865
1960
  if (focus !== 'sessions') {
1866
- setNote('A → assegna: seleziona una sessione (←→ per il pane)');
1961
+ setNote('A → assegna: seleziona una sessione (tab per il pane)');
1867
1962
  }
1868
1963
  else if (!selSessionId) {
1869
1964
  setNote('A → nessuna sessione da assegnare');
@@ -1912,7 +2007,6 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1912
2007
  exit();
1913
2008
  }
1914
2009
  });
1915
- const parentLabel = isAll ? 'tutte' : isSpot ? 'spot' : selectedTaskId ?? '—';
1916
2010
  const canSpawn = focus === 'tasks' && selTask !== null;
1917
2011
  const canResume = focus === 'sessions' && selSessionObj !== null;
1918
2012
  // T50 — il pin agisce su qualunque riga selezionata (anche stale, per
@@ -1982,7 +2076,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1982
2076
  // posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
1983
2077
  // può accorciare il testo sotto uno scroll già dato.
1984
2078
  const start = Math.min(sheetTop, sheetMaxTop);
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 }));
2079
+ return (_jsx(DetailScreen, { id: sheet.id, title: sheet.title, missing: sheet.text === null, lines: sheetLines.slice(start, start + sheetCap), spans: sheetDoc?.spans ?? [], top: start, total: sheetLines.length, capacity: sheetCap, action: sheetAction, columns: columns, find: find, occ: findRes.occ, occCur: occCur }));
1986
2080
  }
1987
2081
  // ── T52 · schermate sostitutive ─────────────────────────────────────────
1988
2082
  // Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
@@ -2062,8 +2156,8 @@ function Deck({ cwd, tasksPath, tasksDir }) {
2062
2156
  // Finestre di rendering. Le liste "logiche" (viewTasks, sessionRows)
2063
2157
  // restano intere: navigazione, selezione e spawn continuano a ragionare su
2064
2158
  // quelle, la finestra è solo ciò che finisce a schermo.
2065
- const taskWin = windowRange(viewTasks.length, selIndex - META_ROWS, budget.taskRows);
2066
- const windowTasks = viewTasks.slice(taskWin.start, taskWin.end);
2159
+ const taskWin = windowRange(paneTasks.length, selIndex - META_ROWS, budget.taskRows);
2160
+ const windowTasks = paneTasks.slice(taskWin.start, taskWin.end);
2067
2161
  const selRowIndex = rowIndexOf(sessionRows, selSessionId);
2068
2162
  const sessionWin = windowRange(sessionRows.length, selRowIndex, budget.sessionRows);
2069
2163
  const windowRows = sessionRows.slice(sessionWin.start, sessionWin.end);
@@ -2073,7 +2167,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
2073
2167
  if (budget.compact) {
2074
2168
  return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? parentLabel, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
2075
2169
  }
2076
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${legend.shown}` : '', legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, archivable: archivable, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore, live: live, liveCount: liveCount })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '', live: live.get(selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
2170
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${legend.shown}` : '', legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, counts: taskCounts, activeView: taskViewId, paneCount: paneTasks.length, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: paneTasks.length - taskWin.end, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, counts: sessionCounts, activeView: sessionViewId, paneCount: sessionRows.length, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore, live: live })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '', live: live.get(selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
2077
2171
  }
2078
2172
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
2079
2173
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -2272,6 +2366,50 @@ function ReaderLine({ line, occ, current, }) {
2272
2366
  // mostrerebbe più dove sono le altre.
2273
2367
  _jsx(Text, { backgroundColor: s.current ? 'cyan' : 'yellow', color: "black", children: s.text }, i)) : (_jsx(Text, { children: s.text }, i))) }));
2274
2368
  }
2369
+ /** Resa di ogni costrutto markdown (T75 · D4): un solo livello di enfasi per
2370
+ * costrutto, senza un secondo alfabeto da imparare. Heading uguali a ogni
2371
+ * livello — la gerarchia la porta già il testo. `code` e `fence` condividono
2372
+ * il giallo perché sono lo stesso costrutto a due granularità: dargli due
2373
+ * colori direbbe che sono due cose. */
2374
+ const MD_STYLE = {
2375
+ heading: { bold: true, color: 'cyan' },
2376
+ bold: { bold: true },
2377
+ code: { color: 'yellow' },
2378
+ fence: { color: 'yellow' },
2379
+ };
2380
+ /**
2381
+ * Una riga del detail (T75): markdown reso, con sopra l'evidenziazione della
2382
+ * ricerca.
2383
+ *
2384
+ * Due segmentazioni sulla stessa riga, annidate e non fuse: prima si taglia sui
2385
+ * costrutti markdown, poi ogni pezzo si ritaglia sulle occorrenze. L'ordine non
2386
+ * è indifferente — così un match a cavallo di un `**grassetto**` resta
2387
+ * evidenziato per intero e insieme conserva il grassetto sulla metà che ce
2388
+ * l'ha, cosa che una segmentazione unica dovrebbe risolvere decidendo chi vince.
2389
+ *
2390
+ * Gli offset di `occ` e di `spans` indicizzano ENTRAMBI il testo reso: è ciò
2391
+ * che permette di comporli senza rimappature. Vedi `sheetDoc` per il perché la
2392
+ * ricerca del detail ha smesso di scandire il sorgente.
2393
+ */
2394
+ function DetailLine({ line, spans, occ, current, }) {
2395
+ const styled = sliceSpans(line, spans);
2396
+ // Riga vuota → uno spazio: un `<Text>` senza contenuto Ink non lo disegna, e
2397
+ // il testo si compatterebbe perdendo la struttura del file.
2398
+ if (styled.length === 0)
2399
+ return _jsx(Text, { wrap: "truncate-end", children: " " });
2400
+ let off = line.start;
2401
+ return (_jsx(Text, { wrap: "truncate-end", children: styled.map((seg, i) => {
2402
+ const st = seg.kind ? MD_STYLE[seg.kind] : undefined;
2403
+ const at = off;
2404
+ off += seg.text.length;
2405
+ // Senza ricerca aperta il secondo taglio non ha niente da tagliare, e
2406
+ // saltarlo evita di allocare tre array per ogni riga a ogni freccia.
2407
+ if (occ.length === 0) {
2408
+ return (_jsx(Text, { bold: st?.bold, color: st?.color, children: seg.text }, i));
2409
+ }
2410
+ return (_jsx(Text, { bold: st?.bold, color: st?.color, children: sliceLine(seg.text, at, occ, current).map((p, j) => p.hit ? (_jsx(Text, { backgroundColor: p.current ? 'cyan' : 'yellow', color: "black", children: p.text }, j)) : (_jsx(Text, { children: p.text }, j))) }, i));
2411
+ }) }));
2412
+ }
2275
2413
  /** Campo della ricerca nel detail: finestra ancorata al caret, cursore inverso
2276
2414
  * sulla cella reale. Gemello di `EditTextField` senza la label, che qui sta
2277
2415
  * fuori perché il campo vive in FLUSSO su una riga condivisa col contatore —
@@ -2293,7 +2431,7 @@ function DetailFindField({ value, caret, cols }) {
2293
2431
  * all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione. La
2294
2432
  * navigazione da tastiera ci si sovrappone senza conflitti.
2295
2433
  */
2296
- function DetailScreen({ id, title, missing, lines, top, total, capacity, action, columns, find, occ, occCur, }) {
2434
+ function DetailScreen({ id, title, missing, lines, spans, top, total, capacity, action, columns, find, occ, occCur, }) {
2297
2435
  const last = Math.min(total, top + capacity);
2298
2436
  // Il taglio lo fa il chiamante (invariante ③ di width.ts): la riga bottoni è
2299
2437
  // ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso — ma
@@ -2314,10 +2452,7 @@ function DetailScreen({ id, title, missing, lines, top, total, capacity, action,
2314
2452
  if (dropped(shown) > 0)
2315
2453
  shown = cutParts(parts, Math.max(0, width - 6));
2316
2454
  const cutCount = dropped(shown);
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)"] })) : (
2318
- // Riga vuota → uno spazio: un `<Text>` senza contenuto Ink non lo
2319
- // disegna, e il testo si compatterebbe perdendo la struttura del file.
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] }) })] }));
2455
+ 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)"] })) : (lines.map((l, i) => (_jsx(DetailLine, { line: l, spans: spans, 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] }) })] }));
2321
2456
  }
2322
2457
  /**
2323
2458
  * Larghezza del testo dentro la lista della schermata di assegnazione: box
@@ -2375,24 +2510,39 @@ function AssignScreen({ sessionId, label, current, filter, rows, selected, match
2375
2510
  * scoprirlo sarebbe il bordo del pane a schermo.
2376
2511
  *
2377
2512
  * `truncate-end` taglia dalla coda, e `cutParts` conserva l'ordine: l'ultimo
2378
- * segmento resta il primo a cedere il posto (vedi T61 sotto, `archiviabili` in
2379
- * coda ai contatori della vista corrente).
2513
+ * segmento resta il primo a cedere il posto (`↑↓` in coda alle voci navigabili).
2514
+ *
2515
+ * T100 — la riga non è più informativa: le voci del catalogo sono SELEZIONABILI
2516
+ * con ←/→, e l'attiva si distingue in video inverso (D5 — costa 0 colonne e non
2517
+ * entra in gara con la semantica di colore già occupata). Le voci ci sono tutte
2518
+ * anche a 0 (D1): un catalogo che si accorcia sposta le voci sotto le dita.
2519
+ * L'ordine è vincolato — le navigabili PRIMA di `↑N`/`↓N`, che cadono per primi
2520
+ * su un terminale stretto — e la voce attiva ha la precedenza sul budget (D6).
2380
2521
  */
2381
- function TasksHeader({ filtered, total, hidden, above, below, archivable, focused, columns, }) {
2522
+ function TasksHeader({ counts, active, above, below, focused, columns, }) {
2523
+ const views = TASK_VIEWS.map((v, i) => {
2524
+ const n = v.count(counts);
2525
+ return {
2526
+ // Il separatore sta nel segmento, non fra i segmenti: `cutParts` misura la
2527
+ // riga pezzo per pezzo e uno spazio fuori dai pezzi non verrebbe contato.
2528
+ text: `${i > 0 ? ' · ' : ''}${v.label(counts)}`,
2529
+ color: v.color,
2530
+ dim: v.dim || n === 0,
2531
+ active: v.id === active,
2532
+ };
2533
+ });
2382
2534
  const segments = [
2383
- { text: `Tasks (${hidden > 0 ? `${filtered}/${total}` : filtered})` },
2384
- ...(hidden > 0 ? [{ text: ` · ${hidden} nascoste`, color: 'yellow' }] : []),
2385
- ...(above > 0 ? [{ text: ` · ↑${above}`, dim: true }] : []),
2386
- ...(below > 0 ? [{ text: ` · ↓${below}`, dim: true }] : []),
2387
- ...(archivable > 0 ? [{ text: ` · ${archivable} archiviabili`, dim: true }] : []),
2535
+ ...views,
2536
+ { text: above > 0 ? ` · ↑${above}` : '', dim: true, active: false, color: undefined },
2537
+ { text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false, color: undefined },
2388
2538
  ];
2389
- const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns));
2390
- return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, children: shown[i] }, i)) : null) }));
2539
+ const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
2540
+ return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, inverse: seg.active, children: shown[i] }, i)) : null) }));
2391
2541
  }
2392
- function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, allCount, childCount, focused, loadError, windowStart, above, below, columns, archivable, }) {
2542
+ function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, childCount, focused, loadError, windowStart, above, below, columns, }) {
2393
2543
  const allSelected = selected === ROW_ALL;
2394
2544
  const spotSelected = selected === ROW_SPOT;
2395
- return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(TasksHeader, { filtered: filtered, total: total, hidden: hidden, above: above, below: below, archivable: archivable, focused: focused, columns: columns }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut(sanitize(`sort: ${describeSort(view.sort)}` +
2545
+ return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(TasksHeader, { counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut(sanitize(`sort: ${describeSort(view.sort)}` +
2396
2546
  (view.hiddenPri.length + view.hiddenProg.length > 0
2397
2547
  ? ' · filtri: ' +
2398
2548
  [
@@ -2401,7 +2551,12 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
2401
2551
  ]
2402
2552
  .map((e) => `−${e.glyph}`)
2403
2553
  .join(' ')
2404
- : '')), paneTextWidth(columns)) }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni", allCount > 0 ? ` (${allCount})` : ''] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : (tasks.map((task, i) => {
2554
+ : '')), paneTextWidth(columns)) }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni", allCount > 0 ? ` (${allCount})` : ''] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : paneCount === 0 ? (
2555
+ // T100/D1 — una voce a contatore 0 resta navigabile, e selezionarla dà
2556
+ // una lista vuota che DICE perché è vuota. Senza la nota il pane si
2557
+ // legge come rotto: le righe meta restano, le task no, e niente spiega
2558
+ // che è la vista scelta a non contenere nulla.
2559
+ _jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(taskView(activeView).empty, paneTextWidth(columns)) })) : (tasks.map((task, i) => {
2405
2560
  // windowStart riporta l'indice di finestra a quello della lista
2406
2561
  // completa, su cui è keyata la selezione. +META_ROWS: le prime due
2407
2562
  // righe sono le meta.
@@ -2432,24 +2587,39 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
2432
2587
  * condiviso, `cutParts`), la resa è del pezzo. Il giallo su `📌N` distingue le
2433
2588
  * pinnate dal resto dell'header e non è decorazione.
2434
2589
  */
2435
- function SessionsHeader({ parentLabel, total, pinnedCount, liveCount, hidden, above, below, focused, columns, }) {
2590
+ function SessionsHeader({ parentLabel, counts, active, above, below, focused, columns, }) {
2591
+ const views = SESSION_VIEWS.map((v) => {
2592
+ const n = v.count(counts);
2593
+ return {
2594
+ text: ` · ${v.label(counts, parentLabel)}`,
2595
+ color: v.color,
2596
+ dim: v.dim || n === 0,
2597
+ active: v.id === active,
2598
+ };
2599
+ });
2436
2600
  const segments = [
2437
- { text: `Sessions · ${parentLabel} (${total})` },
2438
- ...(liveCount > 0 ? [{ text: ` · ${LIVE_IDLE}${liveCount} vive`, color: 'green' }] : []),
2439
- ...(pinnedCount > 0 ? [{ text: ` · 📌${pinnedCount}`, color: 'yellow' }] : []),
2440
- ...(hidden > 0 ? [{ text: ` · +${hidden} più vecchie`, dim: true }] : []),
2441
- ...(above > 0 ? [{ text: ` · ↑${above}`, dim: true }] : []),
2442
- ...(below > 0 ? [{ text: ` · ↓${below}`, dim: true }] : []),
2601
+ // `Sessions` non è una voce del catalogo: nomina il pane, non un
2602
+ // sottoinsieme, quindi non è raggiungibile con le frecce.
2603
+ { text: 'Sessions', color: undefined, dim: false, active: false },
2604
+ ...views,
2605
+ { text: above > 0 ? ` · ↑${above}` : '', dim: true, active: false, color: undefined },
2606
+ { text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false, color: undefined },
2443
2607
  ];
2444
- const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns));
2445
- return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, children: shown[i] }, i)) : null) }));
2608
+ const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
2609
+ return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, inverse: seg.active, children: shown[i] }, i)) : null) }));
2446
2610
  }
2447
- function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, total, pinnedCount, hidden, selectedId, focused, above, below, columns, forkOf, sessionNotes, projectCore, live, liveCount, }) {
2448
- return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(SessionsHeader, { parentLabel: parentLabel, total: total, pinnedCount: pinnedCount, liveCount: liveCount, hidden: hidden, above: above, below: below, focused: focused, columns: columns }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isAll
2449
- ? 'nessuna conversazione nel progetto'
2450
- : isSpot
2451
- ? 'nessuna sessione libera'
2452
- : 'nessuna sessione legata a questa task' })) : (rows.map((row, i) => {
2611
+ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, counts, activeView, paneCount, selectedId, focused, above, below, columns, forkOf, sessionNotes, projectCore, live, }) {
2612
+ return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(SessionsHeader, { parentLabel: parentLabel, counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), paneCount === 0 ? (
2613
+ // T100 la nota della vista di default resta quella storica, che nomina
2614
+ // il PARENT (task, spot o tutte); le altre tre viste portano la propria,
2615
+ // che nomina il sottoinsieme. Sono due vuoti diversi: «questo parent non
2616
+ // ha conversazioni» e «questo sottoinsieme del parent è vuoto».
2617
+ _jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(sessionView(activeView).empty ??
2618
+ (isAll
2619
+ ? 'nessuna conversazione nel progetto'
2620
+ : isSpot
2621
+ ? 'nessuna sessione libera'
2622
+ : 'nessuna sessione legata a questa task'), paneTextWidth(columns)) })) : (rows.map((row, i) => {
2453
2623
  // T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
2454
2624
  // box pesante (coerente con lo styling delle Done dimmate).
2455
2625
  if (row.kind === 'separator') {