@lamemind/loom-deck 0.31.0 → 0.32.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
@@ -56,7 +56,8 @@ senza collisioni:
56
56
  | Tasto | Semantica | Note |
57
57
  |---|---|---|
58
58
  | `↑` `↓` | naviga nella lista | |
59
- | `←` `→` `tab` | cambia pane | |
59
+ | `tab` | cambia pane | l'unico tasto che sposta il focus |
60
+ | `←` `→` | cambia **vista** del pane a fuoco | ciclico; il catalogo è l'header stesso |
60
61
  | `⏎` | azione primaria del pane | Tasks → apre il **detail** della task selezionata · Sessions → riprende (`claude --resume`) la sessione selezionata |
61
62
  | **MAIUSCOLA** | **apre un modale** | cattura tutti i tasti; `esc` annulla, non esce |
62
63
  | minuscola | azione immediata, one-shot | |
@@ -166,9 +167,10 @@ Sono bottoni affiancati e non voci di un menu verticale perché è la forma che
166
167
  sopravvive all'arrivo del mouse: un rettangolo ha già coordinate e area
167
168
  cliccabile, una lista di voci andrebbe rifatta.
168
169
 
169
- Dentro il detail `←→` non cambia più pane il ramo `useInput` della modalità
170
- chiude prima di arrivare a quello di default. Ink non ha focus-trap: la cattura
171
- **è** l'ordine dei rami.
170
+ Dentro il detail `←→` cicla i bottoni invece di cambiare la vista del pane — il
171
+ ramo `useInput` della modalità chiude prima di arrivare a quello di default. Ink
172
+ non ha focus-trap: la cattura **è** l'ordine dei rami, e cambiare cosa fa un
173
+ tasto fuori dal detail non tocca cosa fa dentro.
172
174
 
173
175
  Il catalogo dei prompt vive in `scripts/deck-run`, non nella TUI: è il primitive
174
176
  UI-agnostico, il deck gli passa un **simbolo** (`--prompt-kind`) e non una
@@ -299,6 +301,40 @@ Con un filtro attivo l'header dichiara sempre quanto sta nascondendo
299
301
  `w` la scrive in `.claude/loom/deck-view.json` (macchina-locale, da gitignorare)
300
302
  e al riavvio viene ripristinata. File assente o corrotto → default puliti.
301
303
 
304
+ ## L'header è un selettore di vista
305
+
306
+ Ogni segmento dell'header nomina un sottoinsieme che il deck sa già calcolare.
307
+ `←`/`→` lo rendono **raggiungibile**: la voce attiva si evidenzia in video
308
+ inverso e la lista sotto mostra quel sottoinsieme.
309
+
310
+ ```
311
+ Tasks (20/78) · 58 nascoste · 23 archiviabili · ↑7 · ↓9
312
+ Sessions · tutte (317) · ●2 vive · 📌4 · +213 più vecchie · ↑7 · ↓9
313
+ ```
314
+
315
+ | Pane | Viste |
316
+ |---|---|
317
+ | Tasks | `Tasks (n/N)` (filtri applicati) · `N nascoste` (il complemento esatto di quei filtri) · `N archiviabili` (Done oltre `archivableDays`, cieca ai filtri) |
318
+ | Sessions | `{parent} (N)` · `●N vive` · `📌N` · `+N più vecchie` (le contestuali che il cap `maxContext` tronca) |
319
+
320
+ - **Il catalogo è fisso**, anche a contatore 0 (la voce si mostra dim). Un
321
+ catalogo che si accorcia sposta le voci sotto le dita, e la vista corrente può
322
+ svanire mentre la guardi — togliere un filtro mentre sei su `nascoste`.
323
+ - **`↑N` `↓N` non sono viste**: contano elementi fuori finestra per altezza del
324
+ terminale, cioè una posizione, non un insieme. Restano in coda, non selezionabili.
325
+ - **Due assi ortogonali**: il pane task sceglie il *parent* delle conversazioni
326
+ (`≡ tutte` / `○ spot` / una task), l'header sceglie *quale sottoinsieme* di quel
327
+ parent. Cambiare parent non azzera la vista, che si ricalcola dentro il nuovo.
328
+ - **`F` è inerte fuori dalla vista `Tasks`** e lo dice con una nota: su
329
+ `nascoste` riapplicare i filtri non ha senso, `archiviabili` è cieca per scelta.
330
+ - **La vista è volatile**: non entra in `deck-view.json`, il deck riapre sempre
331
+ sulla voce 1. Un filtro salvato lo si è scelto; una vista non-default riaperta
332
+ a freddo si leggerebbe come la lista intera.
333
+ - **Cambiare vista riporta la selezione in cima**, righe meta comprese: nessuna
334
+ regola di mantenimento, nessun tentativo di ritrovare l'elemento precedente.
335
+ - Su un terminale stretto **la voce attiva è servita per prima** dal budget di
336
+ larghezza: sparisce un contatore, mai la voce che dice dove sei.
337
+
302
338
  ## Installazione
303
339
 
304
340
  ```bash
@@ -106,17 +106,23 @@ async function gitOut(args, cwd) {
106
106
  }
107
107
  }
108
108
  /**
109
- * Quante fra le task Done passate sono oltre soglia. Le indeterminate (nessuno
109
+ * Quali fra le task Done passate sono oltre soglia. Le indeterminate (nessuno
110
110
  * dei tre gradini risolve) e le righe orfane (Done in tasks.md ma task file
111
- * assente) NON entrano nel conteggio: il deck non chiama "vecchia" una task di
112
- * cui non sa l'età.
111
+ * assente) NON entrano: il deck non chiama "vecchia" una task di cui non sa
112
+ * l'età.
113
113
  *
114
114
  * Riceve gli id già filtrati sul glifo Done invece della lista completa: lo
115
115
  * scan resta così proporzionale al vecchiume, non alla lunghezza della lista.
116
+ *
117
+ * T100 — ritorna gli ID e non più il solo conteggio: `archiviabili` è diventata
118
+ * una vista navigabile del pane task, e il numero da solo non basta più a
119
+ * disegnarne le righe. Il contatore dell'header è la lunghezza della lista, così
120
+ * ciò che si conta e ciò che si mostra restano lo stesso insieme per
121
+ * costruzione.
116
122
  */
117
- export async function countArchivable(doneIds, opts) {
123
+ export async function archivableIds(doneIds, opts) {
118
124
  const now = opts.now ?? Date.now();
119
- let n = 0;
125
+ const out = [];
120
126
  for (const id of doneIds) {
121
127
  const taskFile = findTaskFile(opts.tasksDir, id);
122
128
  if (!taskFile)
@@ -133,7 +139,7 @@ export async function countArchivable(doneIds, opts) {
133
139
  continue;
134
140
  const age = ageDays(iso, now);
135
141
  if (age !== null && age >= opts.days)
136
- n++;
142
+ out.push(id);
137
143
  }
138
- return n;
144
+ return out;
139
145
  }
package/dist/cli.js CHANGED
@@ -15,7 +15,8 @@ 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';
@@ -469,22 +470,26 @@ function useSessions(projectRoot) {
469
470
  // `doneSig` è una stringa, non l'array: `tasks` cambia identità a ogni re-read
470
471
  // di tasks.md, e usarlo come dipendenza rimetterebbe lo scan sul tick da 1,5s
471
472
  // per la via di dietro.
473
+ //
474
+ // T100 — tiene gli ID e non più il conteggio: `archiviabili` è una vista del
475
+ // pane, quindi servono le righe. Il contatore dell'header è `.size` dello stesso
476
+ // insieme che disegna la lista — un numero e una lista che non possono divergere.
472
477
  function useArchivable(doneSig, tasksDir, projectRoot, days) {
473
- const [count, setCount] = useState(0);
478
+ const [ids, setIds] = useState(() => new Set());
474
479
  useEffect(() => {
475
480
  let alive = true;
476
- const ids = doneSig ? doneSig.split(',') : [];
481
+ const done = doneSig ? doneSig.split(',') : [];
477
482
  const scan = () => {
478
- countArchivable(ids, { tasksDir, projectRoot, days })
479
- .then((n) => {
483
+ archivableIds(done, { tasksDir, projectRoot, days })
484
+ .then((found) => {
480
485
  if (alive)
481
- setCount(n);
486
+ setIds(new Set(found));
482
487
  })
483
- // Scan fallito (task file illeggibili, git muto) → 0, cioè segmento
484
- // omesso. Un contatore informativo non merita un errore a schermo.
488
+ // Scan fallito (task file illeggibili, git muto) → insieme vuoto, cioè
489
+ // voce a 0. Un contatore informativo non merita un errore a schermo.
485
490
  .catch(() => {
486
491
  if (alive)
487
- setCount(0);
492
+ setIds(new Set());
488
493
  });
489
494
  };
490
495
  scan();
@@ -494,7 +499,7 @@ function useArchivable(doneSig, tasksDir, projectRoot, days) {
494
499
  clearInterval(id);
495
500
  };
496
501
  }, [doneSig, tasksDir, projectRoot, days]);
497
- return count;
502
+ return ids;
498
503
  }
499
504
  // Legge il task file della task selezionata (Q1+B T20). On-id-change: navigare
500
505
  // con ↑↓ ricarica il dettaglio; leggere un singolo file 4-9KB è I/O triviale,
@@ -622,6 +627,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
622
627
  // modale: la lista si aggiorna dal vivo, quindi `esc` deve poter ripristinare.
623
628
  const [view, setView] = useState(() => loadView(cwd));
624
629
  const [viewBackup, setViewBackup] = useState(null);
630
+ // T100 — vista attiva di ciascun pane, navigata con ←/→. VOLATILE per
631
+ // decisione (D3 create): non entra in `deck-view.json`, il deck riapre sempre
632
+ // su `Tasks` e su `{parent}`. Il criterio è il rischio di leggere una lista
633
+ // parziale credendola completa — un filtro salvato lo si è scelto, una vista
634
+ // riaperta a freddo si legge come la lista intera.
635
+ const [taskViewId, setTaskViewId] = useState('tasks');
636
+ const [sessionViewId, setSessionViewId] = useState('context');
625
637
  const [filterCursor, setFilterCursor] = useState({ row: 0, col: 0 });
626
638
  // T41 — bozza dell'edit (null fuori dal modale) e riga attiva della griglia.
627
639
  const [edit, setEdit] = useState(null);
@@ -697,15 +709,31 @@ function Deck({ cwd, tasksPath, tasksDir }) {
697
709
  const doneSig = useMemo(() => tasks.filter((t) => isDone(t.prog)).map((t) => t.id).join(','), [tasks]);
698
710
  const archivableDays = useMemo(() => loadArchivableDays(cwd), [cwd]);
699
711
  const archivable = useArchivable(doneSig, tasksDir, cwd, archivableDays);
712
+ // T100 — le task effettivamente a schermo: la vista principale coincide con
713
+ // `viewTasks` (nessun ricalcolo sul cammino di default), le altre due passano
714
+ // dal predicato del catalogo. I CONTATORI restano misurati sulla vista di
715
+ // default, o navigare cambierebbe i numeri che si sta navigando.
716
+ const taskCounts = {
717
+ filtered: viewTasks.length,
718
+ total: tasks.length,
719
+ hidden: hiddenTasks,
720
+ archivable: archivable.size,
721
+ };
722
+ const paneTasks = useMemo(() => taskViewId === 'tasks'
723
+ ? viewTasks
724
+ : selectTasks(tasks, taskViewId, { view, archivable }), [taskViewId, viewTasks, tasks, view, archivable]);
700
725
  const isSpot = sel === SPOT;
701
726
  const isAll = sel === ALL;
702
727
  const projectName = cwd.split('/').pop() || cwd;
703
728
  // Unica fonte della selezione: si legge SEMPRE dalla vista, mai dall'array
704
729
  // grezzo — è l'invariante che tiene allineati dettaglio mostrato e spawn.
705
- const selTask = typeof sel === 'string' ? viewTasks.find((t) => t.id === sel) ?? null : null;
730
+ const selTask = typeof sel === 'string' ? paneTasks.find((t) => t.id === sel) ?? null : null;
706
731
  const selectedTaskId = selTask?.id ?? null;
707
- const selIndex = selTask ? viewTasks.indexOf(selTask) + META_ROWS : isAll ? ROW_ALL : ROW_SPOT;
732
+ const selIndex = selTask ? paneTasks.indexOf(selTask) + META_ROWS : isAll ? ROW_ALL : ROW_SPOT;
708
733
  const detail = useTaskDetail(tasksDir, selectedTaskId ?? undefined);
734
+ // Il parent delle conversazioni: l'asse che sceglie il pane task, ortogonale
735
+ // alla vista che sceglie l'header (D2 create).
736
+ const parentLabel = isAll ? 'tutte' : isSpot ? 'spot' : selectedTaskId ?? '—';
709
737
  // Conteggio figli per task + spot (badge nel Tasks pane).
710
738
  const childCount = new Map();
711
739
  let spotCount = 0;
@@ -731,12 +759,24 @@ function Deck({ cwd, tasksPath, tasksDir }) {
731
759
  // contestuali. Dedup, cap solo sulle contestuali, righe stale per le pinnate
732
760
  // orfane. Core PURO in session-list.ts (testabile senza Ink).
733
761
  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
762
  // T62 — contato sulla lista INTERA (stessa ragione delle larghezze di colonna
737
763
  // qui sotto): derivarlo dalla finestra visibile lo farebbe cambiare a ogni
738
764
  // 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]);
765
+ // T100 «intera» ora vuol dire la lista della vista di DEFAULT (`assembled`),
766
+ // non quella a schermo: il contatore di una voce del catalogo non può
767
+ // dipendere da quale voce è selezionata, o navigare muoverebbe i numeri.
768
+ const liveCount = useMemo(() => assembled.rows.filter((r) => r.kind !== 'separator' && live.has(r.sessionId)).length, [assembled, live]);
769
+ const sessionCounts = {
770
+ total: assembled.pinnedCount + assembled.contextTotal,
771
+ live: liveCount,
772
+ pinned: assembled.pinnedCount,
773
+ older: assembled.contextHidden,
774
+ };
775
+ // T100 — le righe a schermo sono quelle della vista attiva. Fuori dalla vista
776
+ // di default il separatore non c'è: segna il confine fra pinnate e
777
+ // contestuali, e in un sottoinsieme quel confine non esiste più.
778
+ const sessionRows = useMemo(() => selectSessionRows(sessionViewId, { assembled, isLive: (id) => live.has(id) }), [sessionViewId, assembled, live]);
779
+ const selSessionObj = selectedSession(sessionRows, selSessionId);
740
780
  // T60 — larghezze delle colonne fisse della lista sessioni, misurate sulla
741
781
  // lista INTERA e non sulla finestra visibile: derivarle dalle sole righe a
742
782
  // schermo le farebbe cambiare a ogni scroll, cioè l'opposto di una tabella.
@@ -849,10 +889,10 @@ function Deck({ cwd, tasksPath, tasksDir }) {
849
889
  // dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
850
890
  // sulla prima visibile — fallback deterministico, mai una posizione a caso.
851
891
  useEffect(() => {
852
- if (typeof sel === 'string' && !viewTasks.some((t) => t.id === sel)) {
853
- setSel(viewTasks[0]?.id ?? ALL);
892
+ if (typeof sel === 'string' && !paneTasks.some((t) => t.id === sel)) {
893
+ setSel(paneTasks[0]?.id ?? ALL);
854
894
  }
855
- }, [viewTasks, sel]);
895
+ }, [paneTasks, sel]);
856
896
  // T50 — la selezione (id) resta valida sotto la vista a due gruppi: se l'id
857
897
  // non è più una riga selezionabile (cambio parent, lista mutata, pin rimosso,
858
898
  // sessione sparita) cade sulla prima riga — fallback deterministico, mai una
@@ -935,7 +975,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
935
975
  // messaggi a mano. `verb` è l'unica cosa che cambia fra i due usi.
936
976
  function selectedTaskOr(keyLabel, verb) {
937
977
  if (focus !== 'tasks') {
938
- setNote(`${keyLabel} → ${verb}: seleziona una task (←→ per il pane)`);
978
+ setNote(`${keyLabel} → ${verb}: seleziona una task (tab per il pane)`);
939
979
  return null;
940
980
  }
941
981
  // T59 — la guardia è "non è una task", non "è spot": le righe meta sono due
@@ -1178,13 +1218,33 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1178
1218
  // 2..N+1 = task visibili) e la riconverte subito in sentinella o id: l'indice
1179
1219
  // non sopravvive a un cambio di filtro, l'id sì.
1180
1220
  function moveTaskSel(delta) {
1181
- const next = Math.max(0, Math.min(viewTasks.length + META_ROWS - 1, selIndex + delta));
1221
+ const next = Math.max(0, Math.min(paneTasks.length + META_ROWS - 1, selIndex + delta));
1182
1222
  if (next === ROW_ALL)
1183
1223
  setSel(ALL);
1184
1224
  else if (next === ROW_SPOT)
1185
1225
  setSel(SPOT);
1186
1226
  else
1187
- setSel(viewTasks[next - META_ROWS]?.id ?? SPOT);
1227
+ setSel(paneTasks[next - META_ROWS]?.id ?? SPOT);
1228
+ }
1229
+ // T100 — ←/→ navigano il catalogo viste del pane in focus. Il reset della
1230
+ // selezione è la regola letterale «prima riga in alto», senza eccezioni: sul
1231
+ // pane task è `ROW_ALL` (D2 preflight — le righe meta non si saltano, e il
1232
+ // parent delle sessioni che torna a `tutte` è un effetto accettato); sul pane
1233
+ // sessioni basta invalidare l'id, e l'effect di validità atterra sulla prima
1234
+ // riga selezionabile della vista nuova.
1235
+ function cycleView(delta) {
1236
+ if (focus === 'tasks') {
1237
+ const next = cycleTaskView(taskViewId, delta);
1238
+ setTaskViewId(next);
1239
+ setSel(ALL);
1240
+ setNote(`vista task: ${taskView(next).label(taskCounts)}`);
1241
+ }
1242
+ else {
1243
+ const next = cycleSessionView(sessionViewId, delta);
1244
+ setSessionViewId(next);
1245
+ setSelSessionId(null);
1246
+ setNote(`vista sessioni: ${sessionView(next).label(sessionCounts, parentLabel)}`);
1247
+ }
1188
1248
  }
1189
1249
  // T52 — `⏎` contestuale al TIPO di riga: la lista ne mescola due e l'azione
1190
1250
  // giusta dipende da quale è selezionata. Riga sessione → resume, identico al
@@ -1718,9 +1778,18 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1718
1778
  }
1719
1779
  return;
1720
1780
  }
1721
- if (key.leftArrow || key.rightArrow || key.tab) {
1781
+ if (key.tab) {
1782
+ // T100 — `tab` resta l'unico tasto che sposta il focus fra i due pane. Le
1783
+ // frecce orizzontali facevano lo stesso lavoro (due tasti per un'azione)
1784
+ // mentre la navigazione DENTRO un pane non ne aveva nessuno: ora sono il
1785
+ // selettore di vista dell'header. Il ramo `mode === 'detail'`, anteposto e
1786
+ // chiuso da `return`, non è più l'eccezione a «cambia pane» ma a «cambia
1787
+ // vista» — l'ordine dei rami non cambia, cambia cosa cattura.
1722
1788
  setFocus((f) => (f === 'tasks' ? 'sessions' : 'tasks'));
1723
1789
  }
1790
+ else if (key.leftArrow || key.rightArrow) {
1791
+ cycleView(key.leftArrow ? -1 : 1);
1792
+ }
1724
1793
  else if (key.upArrow) {
1725
1794
  if (focus === 'tasks')
1726
1795
  moveTaskSel(-1);
@@ -1775,9 +1844,20 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1775
1844
  setMode('sort');
1776
1845
  }
1777
1846
  else if (input === 'F') {
1778
- setViewBackup(view);
1779
- setNote('');
1780
- setMode('filter');
1847
+ // T100/D3 — i filtri valgono SOLO sulla vista principale: su `nascoste`
1848
+ // riapplicarli non ha senso (quella vista È il loro complemento), su
1849
+ // `archiviabili` non li si vuole (è cieca ai filtri per decisione). Stessa
1850
+ // forma dell'inerzia di ^K/^P/^R dentro il detail, ma keyed sulla vista
1851
+ // invece che sul modo — e come là, l'inerzia lo DICE invece di non fare
1852
+ // niente in silenzio.
1853
+ if (taskViewId !== 'tasks') {
1854
+ setNote(`F → filtri: solo sulla vista ${TASK_VIEWS[0].label(taskCounts)} (ora: ${taskView(taskViewId).label(taskCounts)})`);
1855
+ }
1856
+ else {
1857
+ setViewBackup(view);
1858
+ setNote('');
1859
+ setMode('filter');
1860
+ }
1781
1861
  }
1782
1862
  else if (input === 'f') {
1783
1863
  // T28 — fork della sessione selezionata. Minuscola come `t`/`c` (T39):
@@ -1785,7 +1865,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1785
1865
  // Vive solo sul pane sessioni: il fork ha per oggetto una conversazione,
1786
1866
  // e senza focus lì non ce n'è una selezionata su cui agire.
1787
1867
  if (focus !== 'sessions') {
1788
- setNote('f → fork: seleziona una sessione (←→ per il pane)');
1868
+ setNote('f → fork: seleziona una sessione (tab per il pane)');
1789
1869
  }
1790
1870
  else {
1791
1871
  const s = selSessionObj;
@@ -1817,7 +1897,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1817
1897
  // pinnata STALE (l'unico modo di spinnarla). Scrive il sidecar e ricarica
1818
1898
  // subito, senza attendere il tick del poll.
1819
1899
  if (focus !== 'sessions') {
1820
- setNote('p → pin: seleziona una sessione (←→ per il pane)');
1900
+ setNote('p → pin: seleziona una sessione (tab per il pane)');
1821
1901
  }
1822
1902
  else if (!selSessionId) {
1823
1903
  setNote('p → nessuna sessione da pinnare');
@@ -1845,7 +1925,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1845
1925
  // su una pinnata STALE, perché annotare «questa non c'è più, era X» è
1846
1926
  // proprio il caso in cui una nota serve.
1847
1927
  if (focus !== 'sessions') {
1848
- setNote('N → nota: seleziona una sessione (←→ per il pane)');
1928
+ setNote('N → nota: seleziona una sessione (tab per il pane)');
1849
1929
  }
1850
1930
  else if (!selSessionId) {
1851
1931
  setNote('N → nessuna sessione da annotare');
@@ -1863,7 +1943,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1863
1943
  // il binding è nostro, il transcript è di CC — riassegnare una
1864
1944
  // conversazione il cui transcript non c'è più resta legittimo.
1865
1945
  if (focus !== 'sessions') {
1866
- setNote('A → assegna: seleziona una sessione (←→ per il pane)');
1946
+ setNote('A → assegna: seleziona una sessione (tab per il pane)');
1867
1947
  }
1868
1948
  else if (!selSessionId) {
1869
1949
  setNote('A → nessuna sessione da assegnare');
@@ -1912,7 +1992,6 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1912
1992
  exit();
1913
1993
  }
1914
1994
  });
1915
- const parentLabel = isAll ? 'tutte' : isSpot ? 'spot' : selectedTaskId ?? '—';
1916
1995
  const canSpawn = focus === 'tasks' && selTask !== null;
1917
1996
  const canResume = focus === 'sessions' && selSessionObj !== null;
1918
1997
  // T50 — il pin agisce su qualunque riga selezionata (anche stale, per
@@ -2062,8 +2141,8 @@ function Deck({ cwd, tasksPath, tasksDir }) {
2062
2141
  // Finestre di rendering. Le liste "logiche" (viewTasks, sessionRows)
2063
2142
  // restano intere: navigazione, selezione e spawn continuano a ragionare su
2064
2143
  // 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);
2144
+ const taskWin = windowRange(paneTasks.length, selIndex - META_ROWS, budget.taskRows);
2145
+ const windowTasks = paneTasks.slice(taskWin.start, taskWin.end);
2067
2146
  const selRowIndex = rowIndexOf(sessionRows, selSessionId);
2068
2147
  const sessionWin = windowRange(sessionRows.length, selRowIndex, budget.sessionRows);
2069
2148
  const windowRows = sessionRows.slice(sessionWin.start, sessionWin.end);
@@ -2073,7 +2152,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
2073
2152
  if (budget.compact) {
2074
2153
  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
2154
  }
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] }));
2155
+ 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
2156
  }
2078
2157
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
2079
2158
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -2375,24 +2454,39 @@ function AssignScreen({ sessionId, label, current, filter, rows, selected, match
2375
2454
  * scoprirlo sarebbe il bordo del pane a schermo.
2376
2455
  *
2377
2456
  * `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).
2457
+ * segmento resta il primo a cedere il posto (`↑↓` in coda alle voci navigabili).
2458
+ *
2459
+ * T100 — la riga non è più informativa: le voci del catalogo sono SELEZIONABILI
2460
+ * con ←/→, e l'attiva si distingue in video inverso (D5 — costa 0 colonne e non
2461
+ * entra in gara con la semantica di colore già occupata). Le voci ci sono tutte
2462
+ * anche a 0 (D1): un catalogo che si accorcia sposta le voci sotto le dita.
2463
+ * L'ordine è vincolato — le navigabili PRIMA di `↑N`/`↓N`, che cadono per primi
2464
+ * su un terminale stretto — e la voce attiva ha la precedenza sul budget (D6).
2380
2465
  */
2381
- function TasksHeader({ filtered, total, hidden, above, below, archivable, focused, columns, }) {
2466
+ function TasksHeader({ counts, active, above, below, focused, columns, }) {
2467
+ const views = TASK_VIEWS.map((v, i) => {
2468
+ const n = v.count(counts);
2469
+ return {
2470
+ // Il separatore sta nel segmento, non fra i segmenti: `cutParts` misura la
2471
+ // riga pezzo per pezzo e uno spazio fuori dai pezzi non verrebbe contato.
2472
+ text: `${i > 0 ? ' · ' : ''}${v.label(counts)}`,
2473
+ color: v.color,
2474
+ dim: v.dim || n === 0,
2475
+ active: v.id === active,
2476
+ };
2477
+ });
2382
2478
  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 }] : []),
2479
+ ...views,
2480
+ { text: above > 0 ? ` · ↑${above}` : '', dim: true, active: false, color: undefined },
2481
+ { text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false, color: undefined },
2388
2482
  ];
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) }));
2483
+ const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
2484
+ 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
2485
  }
2392
- function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, allCount, childCount, focused, loadError, windowStart, above, below, columns, archivable, }) {
2486
+ function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, childCount, focused, loadError, windowStart, above, below, columns, }) {
2393
2487
  const allSelected = selected === ROW_ALL;
2394
2488
  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)}` +
2489
+ 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
2490
  (view.hiddenPri.length + view.hiddenProg.length > 0
2397
2491
  ? ' · filtri: ' +
2398
2492
  [
@@ -2401,7 +2495,12 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
2401
2495
  ]
2402
2496
  .map((e) => `−${e.glyph}`)
2403
2497
  .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) => {
2498
+ : '')), 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 ? (
2499
+ // T100/D1 — una voce a contatore 0 resta navigabile, e selezionarla dà
2500
+ // una lista vuota che DICE perché è vuota. Senza la nota il pane si
2501
+ // legge come rotto: le righe meta restano, le task no, e niente spiega
2502
+ // che è la vista scelta a non contenere nulla.
2503
+ _jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(taskView(activeView).empty, paneTextWidth(columns)) })) : (tasks.map((task, i) => {
2405
2504
  // windowStart riporta l'indice di finestra a quello della lista
2406
2505
  // completa, su cui è keyata la selezione. +META_ROWS: le prime due
2407
2506
  // righe sono le meta.
@@ -2432,24 +2531,39 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
2432
2531
  * condiviso, `cutParts`), la resa è del pezzo. Il giallo su `📌N` distingue le
2433
2532
  * pinnate dal resto dell'header e non è decorazione.
2434
2533
  */
2435
- function SessionsHeader({ parentLabel, total, pinnedCount, liveCount, hidden, above, below, focused, columns, }) {
2534
+ function SessionsHeader({ parentLabel, counts, active, above, below, focused, columns, }) {
2535
+ const views = SESSION_VIEWS.map((v) => {
2536
+ const n = v.count(counts);
2537
+ return {
2538
+ text: ` · ${v.label(counts, parentLabel)}`,
2539
+ color: v.color,
2540
+ dim: v.dim || n === 0,
2541
+ active: v.id === active,
2542
+ };
2543
+ });
2436
2544
  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 }] : []),
2545
+ // `Sessions` non è una voce del catalogo: nomina il pane, non un
2546
+ // sottoinsieme, quindi non è raggiungibile con le frecce.
2547
+ { text: 'Sessions', color: undefined, dim: false, active: false },
2548
+ ...views,
2549
+ { text: above > 0 ? ` · ↑${above}` : '', dim: true, active: false, color: undefined },
2550
+ { text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false, color: undefined },
2443
2551
  ];
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) }));
2552
+ const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
2553
+ 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
2554
  }
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) => {
2555
+ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, counts, activeView, paneCount, selectedId, focused, above, below, columns, forkOf, sessionNotes, projectCore, live, }) {
2556
+ 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 ? (
2557
+ // T100 la nota della vista di default resta quella storica, che nomina
2558
+ // il PARENT (task, spot o tutte); le altre tre viste portano la propria,
2559
+ // che nomina il sottoinsieme. Sono due vuoti diversi: «questo parent non
2560
+ // ha conversazioni» e «questo sottoinsieme del parent è vuoto».
2561
+ _jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(sessionView(activeView).empty ??
2562
+ (isAll
2563
+ ? 'nessuna conversazione nel progetto'
2564
+ : isSpot
2565
+ ? 'nessuna sessione libera'
2566
+ : 'nessuna sessione legata a questa task'), paneTextWidth(columns)) })) : (rows.map((row, i) => {
2453
2567
  // T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
2454
2568
  // box pesante (coerente con lo styling delle Done dimmate).
2455
2569
  if (row.kind === 'separator') {
@@ -0,0 +1,135 @@
1
+ // T100 — Catalogo delle viste dei due pane. L'header smette di essere una riga
2
+ // di contatori e diventa un SELETTORE: ogni segmento che nomina un sottoinsieme
3
+ // calcolabile (`nascoste`, `archiviabili`, `vive`, `📌`, `+più vecchie`) è una
4
+ // vista raggiungibile con ←/→.
5
+ //
6
+ // Modulo PURO: nessun import da ink/react, nessun I/O. Il calcolo resta dove
7
+ // stava — `view.ts` per i filtri task, `session-list.ts` per l'assemblaggio a
8
+ // due gruppi — qui c'è solo la mappa di quali sottoinsiemi esistono, come si
9
+ // chiamano e con che numero.
10
+ //
11
+ // Invarianti (decisioni congelate al create-task e al preflight):
12
+ // - D1 create — catalogo FISSO: le voci ci sono tutte anche a contatore 0. Un
13
+ // catalogo che si accorcia quando un numero va a zero sposta le voci sotto le
14
+ // dita e può far svanire la vista corrente mentre la guardi (togliere un
15
+ // filtro mentre sei su `nascoste`).
16
+ // - D1 preflight — navigazione CICLICA, come `DETAIL_ACTIONS` e le righe di
17
+ // scelta del modale edit.
18
+ // - Il numero di una voce si misura SEMPRE sulla vista di default, mai su
19
+ // quella attiva: altrimenti navigare cambierebbe i contatori che si sta
20
+ // navigando, e il catalogo tornerebbe dinamico per la via di dietro.
21
+ // - `↑N`/`↓N` non stanno qui: contano elementi fuori finestra per altezza del
22
+ // terminale, cioè una POSIZIONE, non un insieme.
23
+ import { isVisible, compareTasks } from './view.js';
24
+ export const TASK_VIEWS = [
25
+ {
26
+ id: 'tasks',
27
+ label: (c) => `Tasks (${c.hidden > 0 ? `${c.filtered}/${c.total}` : c.filtered})`,
28
+ count: (c) => c.filtered,
29
+ has: (t, ctx) => isVisible(t, ctx.view),
30
+ empty: 'nessuna task in vista: i filtri le escludono tutte',
31
+ },
32
+ {
33
+ // `yellow` come prima: avverte che la vista è distorta da un filtro.
34
+ id: 'hidden',
35
+ label: (c) => `${c.hidden} nascoste`,
36
+ count: (c) => c.hidden,
37
+ has: (t, ctx) => !isVisible(t, ctx.view),
38
+ color: 'yellow',
39
+ empty: 'nessuna task nascosta: nessun filtro ne esclude',
40
+ },
41
+ {
42
+ // T61 — dim: le Done oltre soglia non alterano ciò che stai guardando.
43
+ id: 'archivable',
44
+ label: (c) => `${c.archivable} archiviabili`,
45
+ count: (c) => c.archivable,
46
+ has: (t, ctx) => ctx.archivable.has(t.id),
47
+ dim: true,
48
+ empty: "nessuna Done oltre la soglia d'età",
49
+ },
50
+ ];
51
+ /**
52
+ * Le task della vista `id`, filtrate col predicato e ordinate con la chain
53
+ * corrente. L'ordinamento vale per ogni vista, non solo la principale: una lista
54
+ * di task ordinata in un modo e l'altra in un altro sarebbe un secondo asse che
55
+ * si muove da solo.
56
+ */
57
+ export function selectTasks(tasks, id, ctx) {
58
+ const entry = taskView(id);
59
+ const picked = tasks.filter((t) => entry.has(t, ctx));
60
+ picked.sort((a, b) => compareTasks(a, b, ctx.view.sort));
61
+ return picked;
62
+ }
63
+ /** Glifo della sessione viva, gemello di `LIVE_IDLE` del render. Duplicato qui
64
+ * perché il catalogo è puro e non può importare da `cli.tsx`; è un carattere
65
+ * concorde (largo 1 per entrambe le contabilità), quindi non passa da
66
+ * `sanitize`. */
67
+ const LIVE_GLYPH = '●';
68
+ /** Righe selezionabili della lista assemblata (il separatore non è una riga di
69
+ * contenuto: fuori dalla vista di default i due gruppi non ci sono più). */
70
+ function selectable(rows) {
71
+ return rows.filter((r) => r.kind !== 'separator');
72
+ }
73
+ export const SESSION_VIEWS = [
74
+ {
75
+ id: 'context',
76
+ label: (c, parent) => `${parent} (${c.total})`,
77
+ count: (c) => c.total,
78
+ rows: (ctx) => ctx.assembled.rows,
79
+ empty: null,
80
+ },
81
+ {
82
+ id: 'live',
83
+ label: (c) => `${LIVE_GLYPH}${c.live} vive`,
84
+ count: (c) => c.live,
85
+ rows: (ctx) => selectable(ctx.assembled.rows).filter((r) => ctx.isLive(r.sessionId)),
86
+ color: 'green',
87
+ empty: 'nessuna conversazione viva in questa lista',
88
+ },
89
+ {
90
+ id: 'pinned',
91
+ label: (c) => `📌${c.pinned}`,
92
+ count: (c) => c.pinned,
93
+ rows: (ctx) => ctx.assembled.rows.filter((r) => r.kind === 'pinned'),
94
+ color: 'yellow',
95
+ empty: 'nessuna conversazione pinnata',
96
+ },
97
+ {
98
+ // D4 preflight — la coda troncata, non la lista intera senza cap: il numero
99
+ // dell'header e il numero di righe devono coincidere.
100
+ id: 'older',
101
+ label: (c) => `+${c.older} più vecchie`,
102
+ count: (c) => c.older,
103
+ rows: (ctx) => ctx.assembled.contextOverflow.map((s) => ({
104
+ kind: 'context',
105
+ sessionId: s.sessionId,
106
+ session: s,
107
+ })),
108
+ dim: true,
109
+ empty: 'nessuna conversazione oltre il cap',
110
+ },
111
+ ];
112
+ export function selectSessionRows(id, ctx) {
113
+ return sessionView(id).rows(ctx);
114
+ }
115
+ // ── lookup e navigazione ───────────────────────────────────────────────────
116
+ export function taskView(id) {
117
+ return TASK_VIEWS.find((v) => v.id === id) ?? TASK_VIEWS[0];
118
+ }
119
+ export function sessionView(id) {
120
+ return SESSION_VIEWS.find((v) => v.id === id) ?? SESSION_VIEWS[0];
121
+ }
122
+ /** Scorrimento ciclico del catalogo: `→` sull'ultima voce torna alla prima. Id
123
+ * ignoto → prima voce, mai un indice negativo. */
124
+ function cycle(catalog, current, delta) {
125
+ const at = catalog.findIndex((v) => v.id === current);
126
+ if (at < 0)
127
+ return catalog[0];
128
+ return catalog[(at + delta + catalog.length) % catalog.length];
129
+ }
130
+ export function cycleTaskView(current, delta) {
131
+ return cycle(TASK_VIEWS, current, delta).id;
132
+ }
133
+ export function cycleSessionView(current, delta) {
134
+ return cycle(SESSION_VIEWS, current, delta).id;
135
+ }
@@ -134,7 +134,9 @@ export function assembleSessionList(childSessions, allSessions, pinned, maxConte
134
134
  });
135
135
  const pinnedSet = new Set(pinnedIds);
136
136
  const dedupedContext = childSessions.filter((s) => !pinnedSet.has(s.sessionId));
137
- const shownContext = dedupedContext.slice(0, Math.max(0, maxContext));
137
+ const cap = Math.max(0, maxContext);
138
+ const shownContext = dedupedContext.slice(0, cap);
139
+ const overflowContext = dedupedContext.slice(cap);
138
140
  const contextRows = shownContext.map((s) => ({
139
141
  kind: 'context',
140
142
  sessionId: s.sessionId,
@@ -150,7 +152,8 @@ export function assembleSessionList(childSessions, allSessions, pinned, maxConte
150
152
  rows,
151
153
  pinnedCount: pinnedRows.length,
152
154
  contextTotal: dedupedContext.length,
153
- contextHidden: dedupedContext.length - shownContext.length,
155
+ contextHidden: overflowContext.length,
156
+ contextOverflow: overflowContext,
154
157
  };
155
158
  }
156
159
  function isSelectable(r) {
package/dist/width.js CHANGED
@@ -224,8 +224,16 @@ export function cut(s, cols) {
224
224
  * portano glifi larghi 2 (`📌` nelle sessioni, i glifi di priorità nei filtri
225
225
  * delle task), cioè la condizione esatta in cui `cli-truncate` restituisce una
226
226
  * riga più larga del budget e il bordo del pane sparisce.
227
+ *
228
+ * T100/D6 — `priority` è l'indice del pezzo che il budget serve PER PRIMO,
229
+ * quando la riga porta un segmento su cui si sta agendo (la vista attiva di un
230
+ * header-selettore). Gli altri si mostrano solo se avanza spazio, e cadono dalla
231
+ * coda come prima. L'ORDINE DI RESA non cambia — cambia chi paga il taglio: su
232
+ * un terminale stretto sparisce un contatore, mai la voce che dice dove sei.
233
+ * Riservare non allarga la riga: resta un tetto, e con `priority` da solo più
234
+ * largo del budget è lui a essere tagliato.
227
235
  */
228
- export function cutParts(parts, cols) {
236
+ export function cutParts(parts, cols, priority = -1) {
229
237
  const empty = parts.map(() => '');
230
238
  if (cols <= 0)
231
239
  return empty;
@@ -234,10 +242,45 @@ export function cutParts(parts, cols) {
234
242
  const out = [...empty];
235
243
  const budget = cols - 1; // -1 = la colonna dell'ellissi, che qui c'è di sicuro
236
244
  let w = 0;
237
- let full = false;
238
- for (let i = 0; i < parts.length && !full; i++) {
245
+ const hasPriority = priority >= 0 && priority < parts.length;
246
+ if (hasPriority) {
247
+ // Il prioritario prende il suo spazio per primo, e se non ci sta intero è
248
+ // lui a essere tagliato: dev'esserci comunque, anche monco.
239
249
  let acc = '';
240
- for (const ch of parts[i]) {
250
+ for (const ch of parts[priority]) {
251
+ const cw = termWidth(ch);
252
+ if (w + cw > budget)
253
+ break;
254
+ acc += ch;
255
+ w += cw;
256
+ }
257
+ out[priority] = acc;
258
+ }
259
+ for (let i = 0; i < parts.length; i++) {
260
+ if (hasPriority && i === priority)
261
+ continue;
262
+ const part = parts[i];
263
+ const pw = termWidth(part);
264
+ if (hasPriority) {
265
+ // Col prioritario già servito il taglio a metà di un altro segmento
266
+ // lascerebbe a schermo un moncone seguito da una voce intera, che si legge
267
+ // come due cose slegate invece che come una riga troncata: qui un pezzo o
268
+ // entra tutto o cede il posto.
269
+ //
270
+ // E chi cede si porta dietro tutti quelli dopo di lui, invece di lasciare
271
+ // passare il primo più corto: l'ordine dell'array È l'ordine di
272
+ // importanza (le voci navigabili prima dei contatori di scroll), e un
273
+ // riempimento a buchi lo scavalcherebbe — l'header mostrerebbe `↓46`
274
+ // avendo buttato la voce che si può selezionare.
275
+ if (w + pw > budget)
276
+ break;
277
+ out[i] = part;
278
+ w += pw;
279
+ continue;
280
+ }
281
+ let acc = '';
282
+ let full = false;
283
+ for (const ch of part) {
241
284
  const cw = termWidth(ch);
242
285
  if (w + cw > budget) {
243
286
  full = true;
@@ -247,7 +290,15 @@ export function cutParts(parts, cols) {
247
290
  w += cw;
248
291
  }
249
292
  out[i] = acc;
293
+ if (full)
294
+ break;
250
295
  }
296
+ // Col prioritario servito per primo il primo pezzo a schermo può non essere il
297
+ // pezzo 0, e i segmenti portano il proprio separatore in testa: senza questa
298
+ // potatura la riga aprirebbe con un ` · ` appeso al nulla.
299
+ const first = out.findIndex((p) => p !== '');
300
+ if (first > 0)
301
+ out[first] = out[first].replace(/^[\s·]+/, '');
251
302
  // L'ellissi va sull'ultimo pezzo che ha davvero del testo: appiccicarla a un
252
303
  // pezzo vuoto la staccherebbe dal punto di taglio, e il lettore la leggerebbe
253
304
  // come un segmento a sé.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.31.0",
3
+ "version": "0.32.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": {