@lamemind/loom-deck 0.22.0 → 0.23.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
@@ -12,9 +12,9 @@ import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from '.
12
12
  import { discoverProjectSessions } from './sessions.js';
13
13
  import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions, selectedRow, DEFAULT_OPTIONS, MIN_QUERY, } from './search.js';
14
14
  import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
15
- import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, rowLabel, selectedSession, stripProjectCore, } from './session-list.js';
15
+ import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, rowLabel, selectedSession, stripProjectCore, } from './session-list.js';
16
16
  import { cellWidth, launchLegend, loadIdentity, loadLaunch } from './config.js';
17
- import { isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
17
+ import { assignListCapacity, isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
18
18
  import { caretWindow, cut, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
19
19
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
20
20
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
@@ -511,6 +511,17 @@ function Deck({ cwd, tasksPath, tasksDir }) {
511
511
  // trovando la lista esattamente com'era.
512
512
  const [readerRow, setReaderRow] = useState(null);
513
513
  const [readerTop, setReaderTop] = useState(0);
514
+ // T57 — stato del modale di assegnazione sessione → task.
515
+ //
516
+ // `assignSid` è la conversazione in assegnazione, FOTOGRAFATA all'apertura e
517
+ // non riletta da `selSessionId`: il modale è fullscreen (D3), quindi il pane
518
+ // sessioni non è più a schermo e l'oggetto dell'azione deve restare quello
519
+ // che si è scelto — anche se un tick del poll rimescolasse la lista sotto.
520
+ // `assignSel` è la task di destinazione: `null` è la riga `detach` (D2), che
521
+ // sta sempre in testa e scrive un binding vuoto.
522
+ const [assignSid, setAssignSid] = useState(null);
523
+ const [assignFilter, setAssignFilter] = useState('');
524
+ const [assignSel, setAssignSel] = useState(null);
514
525
  // Dimensioni vive del terminale: sono l'input del budget d'altezza sotto.
515
526
  const { rows, columns } = useTerminalSize();
516
527
  // Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
@@ -587,6 +598,25 @@ function Deck({ cwd, tasksPath, tasksDir }) {
587
598
  const readerLines = useMemo(() => (readerRow?.kind === 'hit' ? wrapWithOffsets(readerRow.hit.text, readerWidth) : []), [readerRow, readerWidth]);
588
599
  const readerCap = readerCapacity(rows);
589
600
  const readerMaxTop = Math.max(0, readerLines.length - readerCap);
601
+ // T57 — righe del modale assegnazione: `null` (detach) in testa, poi le task
602
+ // della VISTA corrente (D4 — filtri e sort inclusi, coerenza con ciò che si
603
+ // stava leggendo a sinistra; le escluse restano contate nell'header).
604
+ //
605
+ // Il filtro è un substring case-insensitive su id + titolo grezzo (D5): un
606
+ // restringimento veloce, non una ricerca — quella è `^F` e ha il suo motore.
607
+ // Il solo id non basterebbe, perché il caso d'uso nasce proprio dal «ho
608
+ // capito ora che questa conversazione è la task del titolo X».
609
+ //
610
+ // `detach` NON si filtra: è l'azione di svuotamento, non una task, e sparire
611
+ // digitando la renderebbe raggiungibile solo a campo vuoto.
612
+ const assignRows = useMemo(() => {
613
+ const q = assignFilter.trim().toLowerCase();
614
+ const matched = q
615
+ ? viewTasks.filter((t) => t.id.toLowerCase().includes(q) || t.rawDesc.toLowerCase().includes(q))
616
+ : viewTasks;
617
+ return [null, ...matched];
618
+ }, [viewTasks, assignFilter]);
619
+ const assignCap = assignListCapacity(rows, Boolean(note));
590
620
  // T39 — selezione stabile sotto trasformazione. Se la task selezionata esce
591
621
  // dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
592
622
  // sulla prima visibile — fallback deterministico, mai una posizione a caso.
@@ -613,6 +643,16 @@ function Deck({ cwd, tasksPath, tasksDir }) {
613
643
  setSearchSelKey(firstRowKey(searchRows));
614
644
  }
615
645
  }, [searchRows, searchSelKey]);
646
+ // T57 — la lista del modale si restringe a ogni carattere digitato: quando la
647
+ // task selezionata esce dal filtro si cade sulla PRIMA TASK, non su `detach`.
648
+ // Cadere su detach significherebbe che un `⏎` battuto di slancio dopo aver
649
+ // digitato staccherebbe la sessione invece di assegnarla — l'esatto opposto
650
+ // dell'intenzione. Senza match la selezione resta detach: è l'unica riga viva.
651
+ useEffect(() => {
652
+ if (assignSel !== null && !assignRows.some((t) => t?.id === assignSel)) {
653
+ setAssignSel(assignRows[1]?.id ?? null);
654
+ }
655
+ }, [assignRows, assignSel]);
616
656
  // T30: submit dell'input box. Il taskId nasce DOPO create-task (lo assegna la
617
657
  // skill scrivendo tasks.md) → non è noto allo spawn. Il sessionId invece è
618
658
  // pinnato qui: snapshot degli id PRIMA, poi al completamento re-leggo tasks.md
@@ -712,6 +752,58 @@ function Deck({ cwd, tasksPath, tasksDir }) {
712
752
  ? `✎ nota su ${sid.slice(0, 8)}: "${cut(text, 40)}"`
713
753
  : `✎ nota rimossa da ${sid.slice(0, 8)}`);
714
754
  }
755
+ // T57 — apertura del modale di assegnazione. La preselezione è la task a cui
756
+ // la sessione è GIÀ legata (riassegnare è quasi sempre correggere, e vedere da
757
+ // dove si parte è metà dell'informazione), altrimenti la prima della vista.
758
+ // Mai `detach`: è l'azione distruttiva della lista, e preselezionarla
759
+ // metterebbe un `⏎` battuto di slancio a un tasto dal cancellare un binding.
760
+ function openAssign() {
761
+ const sid = selSessionId;
762
+ if (!sid)
763
+ return;
764
+ const bound = bindings.get(sid);
765
+ setAssignSid(sid);
766
+ setAssignFilter('');
767
+ setAssignSel(bound && viewTasks.some((t) => t.id === bound) ? bound : viewTasks[0]?.id ?? null);
768
+ setNote('');
769
+ setMode('assign');
770
+ }
771
+ // Sposta la selezione del modale sulle righe (0 = detach, 1..N = task filtrate).
772
+ function moveAssign(delta) {
773
+ const at = assignRows.findIndex((t) => (t?.id ?? null) === assignSel);
774
+ const next = Math.max(0, Math.min(assignRows.length - 1, (at < 0 ? 0 : at) + delta));
775
+ setAssignSel(assignRows[next]?.id ?? null);
776
+ }
777
+ // T57 — ⏎ nel modale: riscrive il binding nel sidecar e ricarica subito.
778
+ //
779
+ // Il binding retroattivo governa il FUTURO della conversazione, non il suo
780
+ // passato: il titolo della tab è stato deciso allo spawn da `claude --name` e
781
+ // vive nel transcript, la `LOOM_TASK` di un processo già partito non si
782
+ // reinietta. Cambia cosa fa il prossimo `⏎ resume`, che rilegge il binding dal
783
+ // sidecar. La nota lo dice: senza, la promessa implicita è «ho spostato la
784
+ // conversazione» e il titolo che non cambia sembra un bug.
785
+ //
786
+ // Dove atterra la selezione (D6): il pane task non si muove, quindi la
787
+ // sessione appena assegnata esce dal gruppo contestuale → si scende alla riga
788
+ // SUCCESSIVA, catturata PRIMA della riscrittura (dopo, la riga non c'è più).
789
+ // Due eccezioni in cui invece resta dov'è, perché non sparisce affatto: una
790
+ // pinnata (esente dal contesto) e un'assegnazione al parent già selezionato.
791
+ function submitAssign() {
792
+ const sid = assignSid;
793
+ const target = assignSel;
794
+ setMode('normal');
795
+ setAssignFilter('');
796
+ if (!sid)
797
+ return;
798
+ const stays = pinned.has(sid) || target === selectedTaskId;
799
+ const next = stays ? sid : neighborId(sessionRows, sid);
800
+ appendTaskBinding(cwd, sid, target ?? '');
801
+ reloadSessions();
802
+ setSelSessionId(next);
803
+ setNote(target
804
+ ? `A ${sid.slice(0, 8)} → ${target} · vale dal prossimo ⏎ resume (titolo tab invariato)`
805
+ : `A ${sid.slice(0, 8)} → spot · binding rimosso`);
806
+ }
715
807
  // T41 — apertura dell'edit: la bozza parte dai valori ATTUALI della task, non
716
808
  // da default. La priorità arriva dal glifo di tasks.md (già in `selTask`), lo
717
809
  // stato dal suo glifo Prog; il progresso arbitrario dal campo `Progress` del
@@ -967,6 +1059,46 @@ function Deck({ cwd, tasksPath, tasksDir }) {
967
1059
  }
968
1060
  return;
969
1061
  }
1062
+ // T57 — modale di assegnazione: un campo di filtro più una lista. Come
1063
+ // ricerca e nota, il campo mangia ogni lettera nuda → `^U` (kill-line delle
1064
+ // shell) per svuotarlo, e la navigazione passa dalle frecce.
1065
+ if (mode === 'assign') {
1066
+ if (key.escape) {
1067
+ setMode('normal');
1068
+ setAssignFilter('');
1069
+ setNote('A → assegnazione annullata');
1070
+ }
1071
+ else if (key.return) {
1072
+ submitAssign();
1073
+ }
1074
+ else if (key.upArrow) {
1075
+ moveAssign(-1);
1076
+ }
1077
+ else if (key.downArrow) {
1078
+ moveAssign(1);
1079
+ }
1080
+ else if (key.pageUp) {
1081
+ moveAssign(-Math.max(1, assignCap));
1082
+ }
1083
+ else if (key.pageDown) {
1084
+ moveAssign(Math.max(1, assignCap));
1085
+ }
1086
+ else if (key.ctrl) {
1087
+ // `^U` svuota; ogni altra combo è no-op. Il backspace tenuto premuto
1088
+ // cancella un carattere per CHUNK letto da stdin, non per pressione
1089
+ // (T53): senza `^U` ripulire un filtro digitato di getto sarebbe lento
1090
+ // quanto riaprire il modale.
1091
+ if (input === 'u')
1092
+ setAssignFilter('');
1093
+ }
1094
+ else if (key.backspace || key.delete) {
1095
+ setAssignFilter((f) => f.slice(0, -1));
1096
+ }
1097
+ else if (input && !key.meta) {
1098
+ setAssignFilter((f) => f + sanitizeTyped(input));
1099
+ }
1100
+ return;
1101
+ }
970
1102
  // T30: in modalità create l'handler cattura il testo e corto-circuita la
971
1103
  // navigazione normale (incl. q/esc → quit: qui esc annulla, non esce).
972
1104
  if (mode === 'create') {
@@ -1313,6 +1445,24 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1313
1445
  openNote();
1314
1446
  }
1315
1447
  }
1448
+ else if (input === 'A') {
1449
+ // T57 — assegna la conversazione selezionata a una task. MAIUSCOLA perché
1450
+ // apre un modale (convenzione T39), e il modale è obbligatorio: il pane
1451
+ // task non può fare da picker, perché spostare la selezione lì cambia il
1452
+ // parent e la sessione da assegnare sparisce dalla lista sotto le mani.
1453
+ // Vincolo di focus identico a `p`/`N`, e vale anche su una pinnata STALE:
1454
+ // il binding è nostro, il transcript è di CC — riassegnare una
1455
+ // conversazione il cui transcript non c'è più resta legittimo.
1456
+ if (focus !== 'sessions') {
1457
+ setNote('A → assegna: seleziona una sessione (←→ per il pane)');
1458
+ }
1459
+ else if (!selSessionId) {
1460
+ setNote('A → nessuna sessione da assegnare');
1461
+ }
1462
+ else {
1463
+ openAssign();
1464
+ }
1465
+ }
1316
1466
  else if (input === 't') {
1317
1467
  const title = identity ? `🖥️ ${identity.name} [term]` : null;
1318
1468
  const child = spawnTerminal(cwd, title);
@@ -1383,7 +1533,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1383
1533
  const keyLegend = sanitize([
1384
1534
  ...(canSpawn ? ['⏎/^K/^P/^R spawn'] : canResume ? ['⏎ resume'] : []),
1385
1535
  ...(canResume ? ['f fork'] : []),
1386
- ...(canPin ? ['p pin', 'N nota'] : []),
1536
+ ...(canPin ? ['p pin', 'N nota', 'A assegna'] : []),
1387
1537
  '^F cerca',
1388
1538
  'C nuova',
1389
1539
  'E edit',
@@ -1391,6 +1541,26 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1391
1541
  'F filtri',
1392
1542
  'w salva',
1393
1543
  ].join(' · '));
1544
+ // ── T57 · schermata di assegnazione ─────────────────────────────────────
1545
+ // Sostitutiva come ricerca e reader (D3): la lista task non entra in un box
1546
+ // sopra i due pane, e prendendo l'intero frame non costa nulla al loro budget.
1547
+ // Conseguenza obbligata della scelta: la sessione in assegnazione non è più
1548
+ // visibile, quindi va RIPETUTA nel titolo — senza, non si sa più su cosa si
1549
+ // sta agendo.
1550
+ if (mode === 'assign') {
1551
+ if (isCompact(assignCap)) {
1552
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 assegna \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7 esc annulla"] })] }));
1553
+ }
1554
+ const at = assignRows.findIndex((t) => (t?.id ?? null) === assignSel);
1555
+ const win = windowRange(assignRows.length, at, assignCap);
1556
+ const s = assignSid ? sessions.find((x) => x.sessionId === assignSid) ?? null : null;
1557
+ // Etichetta della conversazione: la nota umana se c'è (è il nome con cui la
1558
+ // riconosci), altrimenti la stessa derivazione della ricerca. Su una pinnata
1559
+ // stale non resta nulla: il titolo si accontenta dell'hash.
1560
+ const label = (assignSid ? sessionNotes.get(assignSid) : '') ||
1561
+ (s ? conversationLabel(s, projectCore, assignSid ? bindings.get(assignSid) : undefined) : '');
1562
+ return (_jsx(AssignScreen, { sessionId: assignSid ?? '', label: label, current: assignSid ? bindings.get(assignSid) ?? null : null, filter: assignFilter, rows: assignRows.slice(win.start, win.end), selected: assignSel, matched: assignRows.length - 1, hidden: hiddenTasks, above: win.start, below: assignRows.length - win.end, childCount: childCount, columns: columns, note: note }));
1563
+ }
1394
1564
  // ── T52 · schermate sostitutive ─────────────────────────────────────────
1395
1565
  // Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
1396
1566
  // pane: una lista di occorrenze non entra in un box da 4 righe. Prendono
@@ -1667,6 +1837,52 @@ function ReaderLine({ line, from, to }) {
1667
1837
  }
1668
1838
  return (_jsxs(Text, { wrap: "truncate-end", children: [line.text.slice(0, a), _jsx(Text, { backgroundColor: "yellow", color: "black", children: line.text.slice(a, b) }), line.text.slice(b)] }));
1669
1839
  }
1840
+ /**
1841
+ * Larghezza del testo dentro la lista della schermata di assegnazione: box
1842
+ * esterno (2 bordi + 2 padding) + box lista (2 bordi + 2 padding).
1843
+ *
1844
+ * Invariante ③ (`width.ts`): il taglio lo fa il chiamante. Delegarlo a
1845
+ * `wrap="truncate-end"` passerebbe da `cli-truncate`, che restituisce una riga
1846
+ * più larga di quella chiesta di una colonna per emoji — e quelle colonne
1847
+ * finiscono sopra il bordo, che sparisce dalla riga.
1848
+ */
1849
+ function assignTextWidth(columns) {
1850
+ return Math.max(20, (columns || 80) - 8);
1851
+ }
1852
+ /**
1853
+ * Schermata di assegnazione di una conversazione a una task (T57).
1854
+ *
1855
+ * Fullscreen sostitutiva (D3) come la ricerca: la lista task non entra in un
1856
+ * box sopra i due pane. Da lì discende che l'oggetto dell'azione — la sessione
1857
+ * selezionata, che non è più a schermo — va ripetuto nel titolo.
1858
+ *
1859
+ * L'header della lista conta le task escluse dai filtri della vista (D4): la
1860
+ * scelta di mostrare `viewTasks` e non tutte le task ha come prezzo noto che un
1861
+ * filtro può nascondere proprio il bersaglio, e quel prezzo non deve essere
1862
+ * silenzioso — stessa convenzione del `+N più vecchie` del pane sessioni.
1863
+ */
1864
+ function AssignScreen({ sessionId, label, current, filter, rows, selected, matched, hidden, above, below, childCount, columns, note, }) {
1865
+ const width = assignTextWidth(columns);
1866
+ 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: ["assegna ", _jsx(Text, { color: "cyan", children: sessionId.slice(0, 8) }), label ? ` «${cut(sanitize(label), Math.max(10, Math.floor(width / 4)))}»` : '', " \u00B7 ora", ' ', current ? _jsx(Text, { color: "green", children: current }) : 'spot', " \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), ' ', "assegna \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " pulisci \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }), _jsx(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "filtro " }), _jsx(Text, { color: "yellow", children: cut(filter, Math.max(8, width - 24)) }), _jsx(Text, { inverse: true, children: " " }), !filter ? _jsx(Text, { dimColor: true, children: " (id o titolo)" }) : null] }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [matched, " task", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", hidden, " fuori dai filtri"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), rows.map((task) => {
1867
+ // T57/D2 — `detach` è una VOCE della lista, non un tasto a parte: un
1868
+ // solo gesto (`A`), un solo modale. Speculare alla riga meta `spot`
1869
+ // del pane task, e nominata con l'AZIONE («detach») invece che con lo
1870
+ // stato d'arrivo — è ciò che si sta per fare.
1871
+ if (!task) {
1872
+ const sel = selected === null;
1873
+ // Le colonne fisse (caret + `○ detach` + i due spazi) sono 12: solo
1874
+ // la glossa si taglia, così su un terminale stretto resta comunque
1875
+ // il nome dell'azione invece di un moncone di frase.
1876
+ return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, "\u25CB detach ", cut('la sessione torna spot', width - 12)] }, "detach"));
1877
+ }
1878
+ const sel = selected === task.id;
1879
+ const n = childCount.get(task.id) ?? 0;
1880
+ const head = `${CARET_OFF}${task.id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
1881
+ const tail = n > 0 ? ` (${n})` : '';
1882
+ const desc = cut(task.desc, Math.max(4, width - termWidth(head) - termWidth(tail)));
1883
+ 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));
1884
+ })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1885
+ }
1670
1886
  function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, }) {
1671
1887
  const spotSelected = selected === 0;
1672
1888
  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:", ' ', [
@@ -139,6 +139,24 @@ export function moveSelection(rows, currentId, delta) {
139
139
  const next = Math.max(0, Math.min(selectable.length - 1, cur + delta));
140
140
  return selectable[next].sessionId;
141
141
  }
142
+ /**
143
+ * T57 — id su cui atterrare quando la riga `sessionId` sta per USCIRE dalla
144
+ * lista (riassegnata a un'altra task): la SUCCESSIVA, o la precedente se era
145
+ * l'ultima, null se era l'unica.
146
+ *
147
+ * Serve perché il fallback di `moveSelection` (id perso → prima riga) qui
148
+ * sarebbe sbagliato: smistare una spot dopo l'altra è il caso d'uso, e ogni
149
+ * assegnazione rimanderebbe la selezione in cima. L'id va catturato PRIMA di
150
+ * riscrivere il sidecar — dopo, la riga non c'è più e il vicino non è
151
+ * calcolabile.
152
+ */
153
+ export function neighborId(rows, sessionId) {
154
+ const selectable = rows.filter(isSelectable);
155
+ const at = selectable.findIndex((r) => r.sessionId === sessionId);
156
+ if (at < 0)
157
+ return null;
158
+ return selectable[at + 1]?.sessionId ?? selectable[at - 1]?.sessionId ?? null;
159
+ }
142
160
  /** Session della riga selezionata; null se stale o nessuna selezione. */
143
161
  export function selectedSession(rows, sessionId) {
144
162
  const r = rows.find((row) => isSelectable(row) && row.sessionId === sessionId);
@@ -9,6 +9,11 @@ export function appendSessionRecord(projectRoot, rec) {
9
9
  mkdirSync(dirname(path), { recursive: true });
10
10
  appendFileSync(path, JSON.stringify({ ...rec, ts: new Date().toISOString() }) + '\n');
11
11
  }
12
+ /**
13
+ * Lega una sessione a una task. T57 — vale sia alla nascita (spawn) sia dopo:
14
+ * un secondo record con un `taskId` diverso RIASSEGNA la conversazione, e
15
+ * `taskId` VUOTO la stacca (torna spot), come `note:''` cancella la nota.
16
+ */
12
17
  export function appendTaskBinding(projectRoot, sessionId, taskId) {
13
18
  appendSessionRecord(projectRoot, { sessionId, taskId });
14
19
  }
@@ -45,8 +50,16 @@ export function loadSessionIndex(projectRoot) {
45
50
  const d = JSON.parse(line);
46
51
  if (typeof d.sessionId !== 'string')
47
52
  continue;
48
- if (typeof d.taskId === 'string')
49
- bindings.set(d.sessionId, d.taskId);
53
+ // T57 last-wins con la stringa vuota come CANCELLAZIONE: `taskId:''`
54
+ // toglie la chiave, così chi legge non deve distinguere «mai legata» da
55
+ // «staccata» (sono la stessa cosa: spot). Il `typeof` esclude i record
56
+ // senza il campo, che non devono toccare un binding scritto prima.
57
+ if (typeof d.taskId === 'string') {
58
+ if (d.taskId)
59
+ bindings.set(d.sessionId, d.taskId);
60
+ else
61
+ bindings.delete(d.sessionId);
62
+ }
50
63
  if (typeof d.forkOf === 'string')
51
64
  forkOf.set(d.sessionId, d.forkOf);
52
65
  // last-wins per campo: un `pinned:false` finale rimuove il pin, un
package/dist/viewport.js CHANGED
@@ -49,6 +49,11 @@ export const MODAL_HEIGHT = {
49
49
  // distribuiscono `searchListCapacity`/`readerCapacity`.
50
50
  search: 0,
51
51
  reader: 0,
52
+ // T57 — assign è la terza schermata sostitutiva, per la stessa ragione della
53
+ // ricerca: la lista task non entra in un box sopra i due pane, e in flusso
54
+ // pagherebbe due volte (il suo costo + quello che toglie ai pane sotto, che
55
+ // in quel momento non si stanno nemmeno guardando).
56
+ assign: 0,
52
57
  };
53
58
  /**
54
59
  * Distribuisce le righe disponibili fra lista task, lista sessioni e dettaglio.
@@ -154,6 +159,24 @@ const SEARCH_CHROME = 14;
154
159
  export function searchListCapacity(rows, noteLine) {
155
160
  return Math.max(0, (rows || 24) - SLACK - SEARCH_CHROME - (noteLine ? 1 : 0));
156
161
  }
162
+ // T57 — cornice della schermata di assegnazione. Gemella di SEARCH_CHROME, con
163
+ // un box filtro di UNA riga invece di tre.
164
+ //
165
+ // 2 bordi del box esterno
166
+ // 1 titolo "loom-deck"
167
+ // 1 riga hint/legenda tasti
168
+ // 1 marginTop del box filtro
169
+ // 2 bordi del box filtro
170
+ // 1 riga del filtro
171
+ // 1 marginTop del box lista
172
+ // 2 bordi del box lista
173
+ // 1 header della lista (conteggi + escluse dai filtri)
174
+ const ASSIGN_CHROME = 12;
175
+ /** Righe di lista task che entrano nel terminale. Zero = nemmeno la cornice ci
176
+ * sta → schermata sostituita dalla riga compatta (`isCompact`). */
177
+ export function assignListCapacity(rows, noteLine) {
178
+ return Math.max(0, (rows || 24) - SLACK - ASSIGN_CHROME - (noteLine ? 1 : 0));
179
+ }
157
180
  // T52 — cornice del reader fullscreen.
158
181
  // 2 bordi del box esterno
159
182
  // 1 titolo "loom-deck"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.22.0",
3
+ "version": "0.23.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": {