@lamemind/loom-deck 0.23.1 → 0.24.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
@@ -26,10 +26,28 @@ const POLL_MS = 1500;
26
26
  // Cap del pane sessioni: le più recenti (ts desc), le altre restano nell'indice
27
27
  // ma fuori vista. Non-silenzioso → l'header mostra quante sono nascoste.
28
28
  const MAX_SESSIONS = 30;
29
- // Modello task-centrico: il Tasks pane ha, oltre alle task reali, una riga
30
- // meta "spot" (sentinella) che raccoglie le sessioni NON legate ad alcuna task.
31
- // La selezione nel Tasks pane è il "padre"; il Sessions pane mostra i suoi figli.
29
+ // T59 D3 cap dedicato alla vista "tutte": a 30 su un progetto con ~170
30
+ // conversazioni la lista sarebbe esaustiva solo sull'ultimo 17%, cioè
31
+ // contraddirebbe lo scopo della riga. 100 copre la finestra temporale utile e
32
+ // tiene comunque un tetto alle righe da attraversare con ↑↓.
33
+ const MAX_SESSIONS_ALL = 100;
34
+ // Modello task-centrico: il Tasks pane ha, oltre alle task reali, DUE righe
35
+ // meta in testa — `≡ tutte` (ogni conversazione del progetto) e `○ spot` (le
36
+ // sole NON legate ad alcuna task). La selezione nel Tasks pane è il "padre"; il
37
+ // Sessions pane mostra i suoi figli.
38
+ //
39
+ // T59 D1 — le sentinelle sono Symbol, non `null` né stringhe riservate: la
40
+ // selezione è un ENUM a tre casi (task / spot / tutte), non un flag. Un Symbol
41
+ // non può collidere con un task id e si confronta secco (`sel === ALL`); una
42
+ // stringa sentinella farebbe invece circolare un id-fantasma dentro un tipo che
43
+ // altrove significa "task id".
32
44
  const SPOT = Symbol('spot');
45
+ const ALL = Symbol('all');
46
+ // Le righe meta occupano le prime posizioni della lista: l'indice di una task
47
+ // nella VISTA è quindi il suo indice in `viewTasks` + META_ROWS.
48
+ const ROW_ALL = 0;
49
+ const ROW_SPOT = 1;
50
+ const META_ROWS = 2;
33
51
  const EDIT_ROWS = 4;
34
52
  /** Le righe del modale edit che sono campi di TESTO (il resto è scelta ←→). */
35
53
  function isTextRow(r) {
@@ -476,9 +494,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
476
494
  const [focus, setFocus] = useState('tasks');
477
495
  // T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
478
496
  // (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
479
- // grezzo per posizione spawnerebbe la task sbagliata, in silenzio. `null` = la
480
- // riga meta "spot", sempre in testa alla lista.
481
- const [selId, setSelId] = useState(null);
497
+ // grezzo per posizione spawnerebbe la task sbagliata, in silenzio.
498
+ // T59 — e le righe meta sono sentinelle, non `null`: gli stati sono tre.
499
+ // D4 — si apre su `≡ tutte`: la vista più ampia in cima, poi si scende verso
500
+ // i sottoinsiemi. La selezione non è persistita (a differenza di `view`, T39),
501
+ // quindi questo atterraggio vale a ogni avvio.
502
+ const [sel, setSel] = useState(ALL);
482
503
  // T50 — selezione del pane sessioni KEYED SU sessionId (non indice): la lista
483
504
  // a due gruppi + separatore è una vista trasformata, un indice grezzo punterebbe
484
505
  // alla riga sbagliata dopo un pin o un cambio di contesto (stesso trap T39).
@@ -546,13 +567,14 @@ function Deck({ cwd, tasksPath, tasksDir }) {
546
567
  // La vista è una trasformazione DERIVATA, applicata a valle del load: il
547
568
  // polling di tasks.md continua a funzionare senza saperne nulla.
548
569
  const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
549
- const isSpot = selId === null;
570
+ const isSpot = sel === SPOT;
571
+ const isAll = sel === ALL;
550
572
  const projectName = cwd.split('/').pop() || cwd;
551
573
  // Unica fonte della selezione: si legge SEMPRE dalla vista, mai dall'array
552
574
  // grezzo — è l'invariante che tiene allineati dettaglio mostrato e spawn.
553
- const selTask = selId === null ? null : viewTasks.find((t) => t.id === selId) ?? null;
575
+ const selTask = typeof sel === 'string' ? viewTasks.find((t) => t.id === sel) ?? null : null;
554
576
  const selectedTaskId = selTask?.id ?? null;
555
- const selIndex = selTask ? viewTasks.indexOf(selTask) + 1 : 0;
577
+ const selIndex = selTask ? viewTasks.indexOf(selTask) + META_ROWS : isAll ? ROW_ALL : ROW_SPOT;
556
578
  const detail = useTaskDetail(tasksDir, selectedTaskId ?? undefined);
557
579
  // Conteggio figli per task + spot (badge nel Tasks pane).
558
580
  const childCount = new Map();
@@ -564,18 +586,21 @@ function Deck({ cwd, tasksPath, tasksDir }) {
564
586
  else
565
587
  spotCount++;
566
588
  }
567
- // Figli della selezione: sessioni bound alla task selezionata, oppure (spot)
568
- // le sessioni senza binding. sessions è già ts desc l'ordine si eredita.
589
+ // Figli della selezione: tutte le conversazioni del progetto (`≡ tutte`), le
590
+ // sessioni bound alla task selezionata, oppure (spot) quelle senza binding.
591
+ // sessions è già ts desc → l'ordine si eredita in tutti e tre i rami.
569
592
  // Memoizzato così `sessionRows` resta stabile fra render che non cambiano gli
570
593
  // input: l'effect di validità della selezione non rigira a vuoto.
571
- const childSessions = useMemo(() => sessions.filter((s) => {
572
- const bound = bindings.get(s.sessionId);
573
- return selectedTaskId ? bound === selectedTaskId : !bound;
574
- }), [sessions, bindings, selectedTaskId]);
594
+ const childSessions = useMemo(() => isAll
595
+ ? sessions
596
+ : sessions.filter((s) => {
597
+ const bound = bindings.get(s.sessionId);
598
+ return selectedTaskId ? bound === selectedTaskId : !bound;
599
+ }), [sessions, bindings, selectedTaskId, isAll]);
575
600
  // T50 — lista a due gruppi: pinnate (sempre, in cima) + separatore +
576
601
  // contestuali. Dedup, cap solo sulle contestuali, righe stale per le pinnate
577
602
  // orfane. Core PURO in session-list.ts (testabile senza Ink).
578
- const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, MAX_SESSIONS), [childSessions, sessions, pinned]);
603
+ const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, isAll ? MAX_SESSIONS_ALL : MAX_SESSIONS), [childSessions, sessions, pinned, isAll]);
579
604
  const sessionRows = assembled.rows;
580
605
  const selSessionObj = selectedSession(sessionRows, selSessionId);
581
606
  // T52 — ricerca EAGER: rigira a ogni carattere digitato, non su ⏎. È
@@ -627,10 +652,10 @@ function Deck({ cwd, tasksPath, tasksDir }) {
627
652
  // dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
628
653
  // sulla prima visibile — fallback deterministico, mai una posizione a caso.
629
654
  useEffect(() => {
630
- if (selId !== null && !viewTasks.some((t) => t.id === selId)) {
631
- setSelId(viewTasks[0]?.id ?? null);
655
+ if (typeof sel === 'string' && !viewTasks.some((t) => t.id === sel)) {
656
+ setSel(viewTasks[0]?.id ?? ALL);
632
657
  }
633
- }, [viewTasks, selId]);
658
+ }, [viewTasks, sel]);
634
659
  // T50 — la selezione (id) resta valida sotto la vista a due gruppi: se l'id
635
660
  // non è più una riga selezionabile (cambio parent, lista mutata, pin rimosso,
636
661
  // sessione sparita) cade sulla prima riga — fallback deterministico, mai una
@@ -712,8 +737,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
712
737
  setNote(`${keyLabel} → spawn: seleziona una task (←→ per il pane)`);
713
738
  return;
714
739
  }
715
- if (isSpot) {
716
- setNote('spot: sessioni libere, nessuna task da spawnare');
740
+ // T59 — la guardia è "non è una task", non "è spot": le righe meta sono due
741
+ // e nessuna delle due ha una task da aprire. Il messaggio dice quale delle
742
+ // due, perché il motivo è diverso (vista di sola lettura vs sessioni libere).
743
+ if (isAll || isSpot) {
744
+ setNote(isAll
745
+ ? 'tutte: vista di sola lettura, nessuna task da spawnare'
746
+ : 'spot: sessioni libere, nessuna task da spawnare');
717
747
  return;
718
748
  }
719
749
  if (!selTask)
@@ -889,12 +919,17 @@ function Deck({ cwd, tasksPath, tasksDir }) {
889
919
  setViewBackup(null);
890
920
  setMode('normal');
891
921
  }
892
- // Sposta la selezione di `delta` righe nella VISTA (0 = spot, 1..N = task
893
- // visibili) e la riconverte subito in id: l'indice non sopravvive a un
894
- // cambio di filtro, l'id sì.
922
+ // Sposta la selezione di `delta` righe nella VISTA (0 = tutte, 1 = spot,
923
+ // 2..N+1 = task visibili) e la riconverte subito in sentinella o id: l'indice
924
+ // non sopravvive a un cambio di filtro, l'id sì.
895
925
  function moveTaskSel(delta) {
896
- const next = Math.max(0, Math.min(viewTasks.length, selIndex + delta));
897
- setSelId(next === 0 ? null : viewTasks[next - 1]?.id ?? null);
926
+ const next = Math.max(0, Math.min(viewTasks.length + META_ROWS - 1, selIndex + delta));
927
+ if (next === ROW_ALL)
928
+ setSel(ALL);
929
+ else if (next === ROW_SPOT)
930
+ setSel(SPOT);
931
+ else
932
+ setSel(viewTasks[next - META_ROWS]?.id ?? SPOT);
898
933
  }
899
934
  // T52 — `⏎` contestuale al TIPO di riga: la lista ne mescola due e l'azione
900
935
  // giusta dipende da quale è selezionata. Riga sessione → resume, identico al
@@ -1368,8 +1403,8 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1368
1403
  setMode('create');
1369
1404
  }
1370
1405
  else if (input === 'E') {
1371
- // L'edit ha senso solo su una task reale: la riga meta "spot" non ne è una.
1372
- if (isSpot || !selTask)
1406
+ // L'edit ha senso solo su una task reale: le righe meta non ne sono.
1407
+ if (!selTask)
1373
1408
  setNote('E → nessuna task selezionata');
1374
1409
  else
1375
1410
  openEdit();
@@ -1509,8 +1544,8 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1509
1544
  exit();
1510
1545
  }
1511
1546
  });
1512
- const parentLabel = isSpot ? 'spot' : selectedTaskId ?? '—';
1513
- const canSpawn = focus === 'tasks' && !isSpot;
1547
+ const parentLabel = isAll ? 'tutte' : isSpot ? 'spot' : selectedTaskId ?? '—';
1548
+ const canSpawn = focus === 'tasks' && selTask !== null;
1514
1549
  const canResume = focus === 'sessions' && selSessionObj !== null;
1515
1550
  // T50 — il pin agisce su qualunque riga selezionata (anche stale, per
1516
1551
  // spinnarla); basta il focus sul pane e una selezione.
@@ -1640,7 +1675,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1640
1675
  // Finestre di rendering. Le liste "logiche" (viewTasks, sessionRows)
1641
1676
  // restano intere: navigazione, selezione e spawn continuano a ragionare su
1642
1677
  // quelle, la finestra è solo ciò che finisce a schermo.
1643
- const taskWin = windowRange(viewTasks.length, selIndex - 1, budget.taskRows);
1678
+ const taskWin = windowRange(viewTasks.length, selIndex - META_ROWS, budget.taskRows);
1644
1679
  const windowTasks = viewTasks.slice(taskWin.start, taskWin.end);
1645
1680
  const selRowIndex = rowIndexOf(sessionRows, selSessionId);
1646
1681
  const sessionWin = windowRange(sessionRows.length, selRowIndex, budget.sessionRows);
@@ -1649,9 +1684,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1649
1684
  // a una riga sola. Perdere il deck per un terminale basso è meglio che
1650
1685
  // sporcare la cronologia del terminale a ogni poll.
1651
1686
  if (budget.compact) {
1652
- 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 ?? 'spot', " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
1687
+ 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"] })] }));
1653
1688
  }
1654
- 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, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, 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, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1689
+ 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, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, 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, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1655
1690
  }
1656
1691
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
1657
1692
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -1889,17 +1924,19 @@ function AssignScreen({ sessionId, label, current, filter, rows, selected, match
1889
1924
  return (_jsxs(Text, { inverse: sel, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
1890
1925
  })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1891
1926
  }
1892
- function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, }) {
1893
- const spotSelected = selected === 0;
1927
+ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, allCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, }) {
1928
+ const allSelected = selected === ROW_ALL;
1929
+ const spotSelected = selected === ROW_SPOT;
1894
1930
  return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Tasks (", hidden > 0 ? `${filtered}/${total}` : filtered, ")", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 ", hidden, " nascoste"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort: ", describeSort(view.sort), view.hiddenPri.length + view.hiddenProg.length > 0 ? (_jsxs(Text, { children: [' ', "\u00B7 filtri:", ' ', [
1895
1931
  ...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
1896
1932
  ...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
1897
1933
  ]
1898
1934
  .map((e) => `−${sanitize(e.glyph)}`)
1899
- .join(' ')] })) : null] }), _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) => {
1935
+ .join(' ')] })) : null] }), _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) => {
1900
1936
  // windowStart riporta l'indice di finestra a quello della lista
1901
- // completa, su cui è keyata la selezione. +1: lo 0 è spot.
1902
- const sel = windowStart + i + 1 === selected;
1937
+ // completa, su cui è keyata la selezione. +META_ROWS: le prime due
1938
+ // righe sono le meta.
1939
+ const sel = windowStart + i + META_ROWS === selected;
1903
1940
  const n = childCount.get(task.id) ?? 0;
1904
1941
  // Invariante ③: la descrizione è l'unico pezzo a lunghezza libera, e
1905
1942
  // si taglia QUI sul budget che resta dopo le colonne fisse. Lasciarlo
@@ -1913,8 +1950,12 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
1913
1950
  return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
1914
1951
  })), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
1915
1952
  }
1916
- function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, sessionNotes, projectCore, }) {
1917
- return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Sessions \u00B7 ", parentLabel, " (", total, ")", pinnedCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 \uD83D\uDCCC", pinnedCount] }) : null, hidden > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 +", hidden, " pi\u00F9 vecchie"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isSpot ? 'nessuna sessione libera' : 'nessuna sessione legata a questa task' })) : (rows.map((row, i) => {
1953
+ function SessionsPane({ parentLabel, isSpot, isAll, bindings, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, sessionNotes, projectCore, }) {
1954
+ return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Sessions \u00B7 ", parentLabel, " (", total, ")", pinnedCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 \uD83D\uDCCC", pinnedCount] }) : null, hidden > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 +", hidden, " pi\u00F9 vecchie"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isAll
1955
+ ? 'nessuna conversazione nel progetto'
1956
+ : isSpot
1957
+ ? 'nessuna sessione libera'
1958
+ : 'nessuna sessione legata a questa task' })) : (rows.map((row, i) => {
1918
1959
  // T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
1919
1960
  // box pesante (coerente con lo styling delle Done dimmate).
1920
1961
  if (row.kind === 'separator') {
@@ -1939,17 +1980,34 @@ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, s
1939
1980
  // Con un valore fisso (erano 44) su un terminale stretto la riga
1940
1981
  // superava il pane, e a troncarla finiva `cli-truncate` — che sfora
1941
1982
  // di una colonna per emoji e si mangia il bordo destro.
1983
+ // T59 D2 — nella vista "tutte" il marker è PER-SESSIONE (binding letto
1984
+ // dal sidecar) e non deciso dal parent: la lista mescola scoped e spot,
1985
+ // quindi un marker uniforme mentirebbe su metà delle righe. E il solo
1986
+ // glifo direbbe *che* la conversazione è legata senza dire *a cosa* —
1987
+ // informazione monca proprio qui, l'unica vista dove l'appartenenza
1988
+ // non è scritta da nessun'altra parte dello schermo. Da qui il task id
1989
+ // inline, che costa colonne al titolo solo in questa vista.
1990
+ const bound = bindings.get(s.sessionId) ?? null;
1991
+ const idTag = isAll && bound ? `${bound} ` : '';
1992
+ const linked = isAll ? Boolean(bound) : !isSpot;
1942
1993
  const meta = ` · ${s.gitBranch || '-'} · ${relTime(s.ts)}`;
1943
- const labelBudget = Math.max(12, paneTextWidth(columns) -
1994
+ // Il pavimento è `0`, non un minimo di cortesia: questo numero è un
1995
+ // TETTO (le colonne che restano), non una preferenza. Un `Math.max(12,
1996
+ // …)` lo alza sopra lo spazio reale appena il resto della riga cresce
1997
+ // — e la riga esce dal pane mangiandosi il bordo, cioè il difetto che
1998
+ // l'invariante ③ esiste per impedire. Con poco spazio è il titolo a
1999
+ // sparire: hash, task id, branch e data restano, e il frame regge.
2000
+ const labelBudget = Math.max(0, paneTextWidth(columns) -
1944
2001
  (2 /* caret */ +
1945
2002
  2 /* icona */ +
1946
2003
  1 /* spazio */ +
1947
2004
  SID_W /* hash + spazio */ +
2005
+ termWidth(idTag) +
1948
2006
  (forked ? 2 : 0)) -
1949
2007
  termWidth(meta) -
1950
2008
  1 /* spazio prima del meta */);
1951
2009
  const label = rowLabel(s.title, sessionNotes.get(s.sessionId), projectCore, labelBudget);
1952
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" })) : isSpot ? (_jsx(Text, { dimColor: true, children: "\u25CB" })) : (_jsx(Text, { color: "green", children: "\uD83D\uDD17" })), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', forked ? _jsx(Text, { color: "magenta", children: "\u2442 " }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
2010
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" })) : linked ? (_jsx(Text, { color: "green", children: "\uD83D\uDD17" })) : (_jsx(Text, { dimColor: true, children: "\u25CB" })), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', idTag ? _jsx(Text, { color: "green", children: idTag }) : null, forked ? _jsx(Text, { color: "magenta", children: "\u2442 " }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
1953
2011
  })), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null, note: sessionNotes.get(detail.sessionId) ?? '' })) : null] }));
1954
2012
  }
1955
2013
  // T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
package/dist/viewport.js CHANGED
@@ -22,7 +22,10 @@ const MIN_TASK_ROWS = 3;
22
22
  /** Il dettaglio è secondario: non si prende mai più di così, anche con spazio. */
23
23
  const MAX_DETAIL_LINES = 4;
24
24
  /** Righe di "cornice" fisse dei tre contenitori a lunghezza variabile. */
25
- const TASKS_PANE_CHROME = 5; // 2 bordi + header "Tasks (n)" + riga sort + riga spot
25
+ // T59 le righe meta sono DUE (`≡ tutte`, `○ spot`): ogni riga fissa aggiunta
26
+ // a un pane va scalata qui, o il frame sfonda `rows` e Ink passa a
27
+ // clearTerminal (frame-fantasma nello scrollback di VTE).
28
+ const TASKS_PANE_CHROME = 6; // 2 bordi + header "Tasks (n)" + riga sort + 2 righe meta
26
29
  const SESSIONS_PANE_CHROME = 3; // 2 bordi + header "Sessions · …"
27
30
  const DETAIL_CHROME = 3; // marginTop + 2 bordi
28
31
  /** Detail pane sessione (T49): righe fisse = titolo + riga meta (size · turni ·
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.23.1",
3
+ "version": "0.24.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": {