@lamemind/loom-deck 0.22.0 → 0.23.1

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';
@@ -410,6 +410,12 @@ const WARN = sanitize('⚠');
410
410
  // largo 1 sia per string-width sia per il terminale. Corto +
411
411
  // wrap="truncate-end" così non va mai a capo nel pane al 50%.
412
412
  const SESSION_SEP = '─'.repeat(16);
413
+ // Prefisso del sessionId mostrato in lista: stesso dato e stessa lunghezza del
414
+ // widget `⛓ <8 char>` della statusline, così le due superfici si confrontano a
415
+ // occhio. `SID_W` include lo spazio che segue → è quello che il budget della
416
+ // label deve scalare.
417
+ const SID_CHARS = 8;
418
+ const SID_W = SID_CHARS + 1;
413
419
  // Marker Done per il DISPLAY. `task.prog` resta il `✔️` letto da tasks.md —
414
420
  // `isDone()` e le lookup di `view.ts` ci confrontano sopra, e `task-edit` lo
415
421
  // riscrive sul file: è una chiave semantica, non testo. Qui `sanitize` lo
@@ -511,6 +517,17 @@ function Deck({ cwd, tasksPath, tasksDir }) {
511
517
  // trovando la lista esattamente com'era.
512
518
  const [readerRow, setReaderRow] = useState(null);
513
519
  const [readerTop, setReaderTop] = useState(0);
520
+ // T57 — stato del modale di assegnazione sessione → task.
521
+ //
522
+ // `assignSid` è la conversazione in assegnazione, FOTOGRAFATA all'apertura e
523
+ // non riletta da `selSessionId`: il modale è fullscreen (D3), quindi il pane
524
+ // sessioni non è più a schermo e l'oggetto dell'azione deve restare quello
525
+ // che si è scelto — anche se un tick del poll rimescolasse la lista sotto.
526
+ // `assignSel` è la task di destinazione: `null` è la riga `detach` (D2), che
527
+ // sta sempre in testa e scrive un binding vuoto.
528
+ const [assignSid, setAssignSid] = useState(null);
529
+ const [assignFilter, setAssignFilter] = useState('');
530
+ const [assignSel, setAssignSel] = useState(null);
514
531
  // Dimensioni vive del terminale: sono l'input del budget d'altezza sotto.
515
532
  const { rows, columns } = useTerminalSize();
516
533
  // Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
@@ -587,6 +604,25 @@ function Deck({ cwd, tasksPath, tasksDir }) {
587
604
  const readerLines = useMemo(() => (readerRow?.kind === 'hit' ? wrapWithOffsets(readerRow.hit.text, readerWidth) : []), [readerRow, readerWidth]);
588
605
  const readerCap = readerCapacity(rows);
589
606
  const readerMaxTop = Math.max(0, readerLines.length - readerCap);
607
+ // T57 — righe del modale assegnazione: `null` (detach) in testa, poi le task
608
+ // della VISTA corrente (D4 — filtri e sort inclusi, coerenza con ciò che si
609
+ // stava leggendo a sinistra; le escluse restano contate nell'header).
610
+ //
611
+ // Il filtro è un substring case-insensitive su id + titolo grezzo (D5): un
612
+ // restringimento veloce, non una ricerca — quella è `^F` e ha il suo motore.
613
+ // Il solo id non basterebbe, perché il caso d'uso nasce proprio dal «ho
614
+ // capito ora che questa conversazione è la task del titolo X».
615
+ //
616
+ // `detach` NON si filtra: è l'azione di svuotamento, non una task, e sparire
617
+ // digitando la renderebbe raggiungibile solo a campo vuoto.
618
+ const assignRows = useMemo(() => {
619
+ const q = assignFilter.trim().toLowerCase();
620
+ const matched = q
621
+ ? viewTasks.filter((t) => t.id.toLowerCase().includes(q) || t.rawDesc.toLowerCase().includes(q))
622
+ : viewTasks;
623
+ return [null, ...matched];
624
+ }, [viewTasks, assignFilter]);
625
+ const assignCap = assignListCapacity(rows, Boolean(note));
590
626
  // T39 — selezione stabile sotto trasformazione. Se la task selezionata esce
591
627
  // dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
592
628
  // sulla prima visibile — fallback deterministico, mai una posizione a caso.
@@ -613,6 +649,16 @@ function Deck({ cwd, tasksPath, tasksDir }) {
613
649
  setSearchSelKey(firstRowKey(searchRows));
614
650
  }
615
651
  }, [searchRows, searchSelKey]);
652
+ // T57 — la lista del modale si restringe a ogni carattere digitato: quando la
653
+ // task selezionata esce dal filtro si cade sulla PRIMA TASK, non su `detach`.
654
+ // Cadere su detach significherebbe che un `⏎` battuto di slancio dopo aver
655
+ // digitato staccherebbe la sessione invece di assegnarla — l'esatto opposto
656
+ // dell'intenzione. Senza match la selezione resta detach: è l'unica riga viva.
657
+ useEffect(() => {
658
+ if (assignSel !== null && !assignRows.some((t) => t?.id === assignSel)) {
659
+ setAssignSel(assignRows[1]?.id ?? null);
660
+ }
661
+ }, [assignRows, assignSel]);
616
662
  // T30: submit dell'input box. Il taskId nasce DOPO create-task (lo assegna la
617
663
  // skill scrivendo tasks.md) → non è noto allo spawn. Il sessionId invece è
618
664
  // pinnato qui: snapshot degli id PRIMA, poi al completamento re-leggo tasks.md
@@ -712,6 +758,58 @@ function Deck({ cwd, tasksPath, tasksDir }) {
712
758
  ? `✎ nota su ${sid.slice(0, 8)}: "${cut(text, 40)}"`
713
759
  : `✎ nota rimossa da ${sid.slice(0, 8)}`);
714
760
  }
761
+ // T57 — apertura del modale di assegnazione. La preselezione è la task a cui
762
+ // la sessione è GIÀ legata (riassegnare è quasi sempre correggere, e vedere da
763
+ // dove si parte è metà dell'informazione), altrimenti la prima della vista.
764
+ // Mai `detach`: è l'azione distruttiva della lista, e preselezionarla
765
+ // metterebbe un `⏎` battuto di slancio a un tasto dal cancellare un binding.
766
+ function openAssign() {
767
+ const sid = selSessionId;
768
+ if (!sid)
769
+ return;
770
+ const bound = bindings.get(sid);
771
+ setAssignSid(sid);
772
+ setAssignFilter('');
773
+ setAssignSel(bound && viewTasks.some((t) => t.id === bound) ? bound : viewTasks[0]?.id ?? null);
774
+ setNote('');
775
+ setMode('assign');
776
+ }
777
+ // Sposta la selezione del modale sulle righe (0 = detach, 1..N = task filtrate).
778
+ function moveAssign(delta) {
779
+ const at = assignRows.findIndex((t) => (t?.id ?? null) === assignSel);
780
+ const next = Math.max(0, Math.min(assignRows.length - 1, (at < 0 ? 0 : at) + delta));
781
+ setAssignSel(assignRows[next]?.id ?? null);
782
+ }
783
+ // T57 — ⏎ nel modale: riscrive il binding nel sidecar e ricarica subito.
784
+ //
785
+ // Il binding retroattivo governa il FUTURO della conversazione, non il suo
786
+ // passato: il titolo della tab è stato deciso allo spawn da `claude --name` e
787
+ // vive nel transcript, la `LOOM_TASK` di un processo già partito non si
788
+ // reinietta. Cambia cosa fa il prossimo `⏎ resume`, che rilegge il binding dal
789
+ // sidecar. La nota lo dice: senza, la promessa implicita è «ho spostato la
790
+ // conversazione» e il titolo che non cambia sembra un bug.
791
+ //
792
+ // Dove atterra la selezione (D6): il pane task non si muove, quindi la
793
+ // sessione appena assegnata esce dal gruppo contestuale → si scende alla riga
794
+ // SUCCESSIVA, catturata PRIMA della riscrittura (dopo, la riga non c'è più).
795
+ // Due eccezioni in cui invece resta dov'è, perché non sparisce affatto: una
796
+ // pinnata (esente dal contesto) e un'assegnazione al parent già selezionato.
797
+ function submitAssign() {
798
+ const sid = assignSid;
799
+ const target = assignSel;
800
+ setMode('normal');
801
+ setAssignFilter('');
802
+ if (!sid)
803
+ return;
804
+ const stays = pinned.has(sid) || target === selectedTaskId;
805
+ const next = stays ? sid : neighborId(sessionRows, sid);
806
+ appendTaskBinding(cwd, sid, target ?? '');
807
+ reloadSessions();
808
+ setSelSessionId(next);
809
+ setNote(target
810
+ ? `A ${sid.slice(0, 8)} → ${target} · vale dal prossimo ⏎ resume (titolo tab invariato)`
811
+ : `A ${sid.slice(0, 8)} → spot · binding rimosso`);
812
+ }
715
813
  // T41 — apertura dell'edit: la bozza parte dai valori ATTUALI della task, non
716
814
  // da default. La priorità arriva dal glifo di tasks.md (già in `selTask`), lo
717
815
  // stato dal suo glifo Prog; il progresso arbitrario dal campo `Progress` del
@@ -967,6 +1065,46 @@ function Deck({ cwd, tasksPath, tasksDir }) {
967
1065
  }
968
1066
  return;
969
1067
  }
1068
+ // T57 — modale di assegnazione: un campo di filtro più una lista. Come
1069
+ // ricerca e nota, il campo mangia ogni lettera nuda → `^U` (kill-line delle
1070
+ // shell) per svuotarlo, e la navigazione passa dalle frecce.
1071
+ if (mode === 'assign') {
1072
+ if (key.escape) {
1073
+ setMode('normal');
1074
+ setAssignFilter('');
1075
+ setNote('A → assegnazione annullata');
1076
+ }
1077
+ else if (key.return) {
1078
+ submitAssign();
1079
+ }
1080
+ else if (key.upArrow) {
1081
+ moveAssign(-1);
1082
+ }
1083
+ else if (key.downArrow) {
1084
+ moveAssign(1);
1085
+ }
1086
+ else if (key.pageUp) {
1087
+ moveAssign(-Math.max(1, assignCap));
1088
+ }
1089
+ else if (key.pageDown) {
1090
+ moveAssign(Math.max(1, assignCap));
1091
+ }
1092
+ else if (key.ctrl) {
1093
+ // `^U` svuota; ogni altra combo è no-op. Il backspace tenuto premuto
1094
+ // cancella un carattere per CHUNK letto da stdin, non per pressione
1095
+ // (T53): senza `^U` ripulire un filtro digitato di getto sarebbe lento
1096
+ // quanto riaprire il modale.
1097
+ if (input === 'u')
1098
+ setAssignFilter('');
1099
+ }
1100
+ else if (key.backspace || key.delete) {
1101
+ setAssignFilter((f) => f.slice(0, -1));
1102
+ }
1103
+ else if (input && !key.meta) {
1104
+ setAssignFilter((f) => f + sanitizeTyped(input));
1105
+ }
1106
+ return;
1107
+ }
970
1108
  // T30: in modalità create l'handler cattura il testo e corto-circuita la
971
1109
  // navigazione normale (incl. q/esc → quit: qui esc annulla, non esce).
972
1110
  if (mode === 'create') {
@@ -1313,6 +1451,24 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1313
1451
  openNote();
1314
1452
  }
1315
1453
  }
1454
+ else if (input === 'A') {
1455
+ // T57 — assegna la conversazione selezionata a una task. MAIUSCOLA perché
1456
+ // apre un modale (convenzione T39), e il modale è obbligatorio: il pane
1457
+ // task non può fare da picker, perché spostare la selezione lì cambia il
1458
+ // parent e la sessione da assegnare sparisce dalla lista sotto le mani.
1459
+ // Vincolo di focus identico a `p`/`N`, e vale anche su una pinnata STALE:
1460
+ // il binding è nostro, il transcript è di CC — riassegnare una
1461
+ // conversazione il cui transcript non c'è più resta legittimo.
1462
+ if (focus !== 'sessions') {
1463
+ setNote('A → assegna: seleziona una sessione (←→ per il pane)');
1464
+ }
1465
+ else if (!selSessionId) {
1466
+ setNote('A → nessuna sessione da assegnare');
1467
+ }
1468
+ else {
1469
+ openAssign();
1470
+ }
1471
+ }
1316
1472
  else if (input === 't') {
1317
1473
  const title = identity ? `🖥️ ${identity.name} [term]` : null;
1318
1474
  const child = spawnTerminal(cwd, title);
@@ -1383,7 +1539,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1383
1539
  const keyLegend = sanitize([
1384
1540
  ...(canSpawn ? ['⏎/^K/^P/^R spawn'] : canResume ? ['⏎ resume'] : []),
1385
1541
  ...(canResume ? ['f fork'] : []),
1386
- ...(canPin ? ['p pin', 'N nota'] : []),
1542
+ ...(canPin ? ['p pin', 'N nota', 'A assegna'] : []),
1387
1543
  '^F cerca',
1388
1544
  'C nuova',
1389
1545
  'E edit',
@@ -1391,6 +1547,26 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1391
1547
  'F filtri',
1392
1548
  'w salva',
1393
1549
  ].join(' · '));
1550
+ // ── T57 · schermata di assegnazione ─────────────────────────────────────
1551
+ // Sostitutiva come ricerca e reader (D3): la lista task non entra in un box
1552
+ // sopra i due pane, e prendendo l'intero frame non costa nulla al loro budget.
1553
+ // Conseguenza obbligata della scelta: la sessione in assegnazione non è più
1554
+ // visibile, quindi va RIPETUTA nel titolo — senza, non si sa più su cosa si
1555
+ // sta agendo.
1556
+ if (mode === 'assign') {
1557
+ if (isCompact(assignCap)) {
1558
+ 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"] })] }));
1559
+ }
1560
+ const at = assignRows.findIndex((t) => (t?.id ?? null) === assignSel);
1561
+ const win = windowRange(assignRows.length, at, assignCap);
1562
+ const s = assignSid ? sessions.find((x) => x.sessionId === assignSid) ?? null : null;
1563
+ // Etichetta della conversazione: la nota umana se c'è (è il nome con cui la
1564
+ // riconosci), altrimenti la stessa derivazione della ricerca. Su una pinnata
1565
+ // stale non resta nulla: il titolo si accontenta dell'hash.
1566
+ const label = (assignSid ? sessionNotes.get(assignSid) : '') ||
1567
+ (s ? conversationLabel(s, projectCore, assignSid ? bindings.get(assignSid) : undefined) : '');
1568
+ 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 }));
1569
+ }
1394
1570
  // ── T52 · schermate sostitutive ─────────────────────────────────────────
1395
1571
  // Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
1396
1572
  // pane: una lista di occorrenze non entra in un box da 4 righe. Prendono
@@ -1667,6 +1843,52 @@ function ReaderLine({ line, from, to }) {
1667
1843
  }
1668
1844
  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
1845
  }
1846
+ /**
1847
+ * Larghezza del testo dentro la lista della schermata di assegnazione: box
1848
+ * esterno (2 bordi + 2 padding) + box lista (2 bordi + 2 padding).
1849
+ *
1850
+ * Invariante ③ (`width.ts`): il taglio lo fa il chiamante. Delegarlo a
1851
+ * `wrap="truncate-end"` passerebbe da `cli-truncate`, che restituisce una riga
1852
+ * più larga di quella chiesta di una colonna per emoji — e quelle colonne
1853
+ * finiscono sopra il bordo, che sparisce dalla riga.
1854
+ */
1855
+ function assignTextWidth(columns) {
1856
+ return Math.max(20, (columns || 80) - 8);
1857
+ }
1858
+ /**
1859
+ * Schermata di assegnazione di una conversazione a una task (T57).
1860
+ *
1861
+ * Fullscreen sostitutiva (D3) come la ricerca: la lista task non entra in un
1862
+ * box sopra i due pane. Da lì discende che l'oggetto dell'azione — la sessione
1863
+ * selezionata, che non è più a schermo — va ripetuto nel titolo.
1864
+ *
1865
+ * L'header della lista conta le task escluse dai filtri della vista (D4): la
1866
+ * scelta di mostrare `viewTasks` e non tutte le task ha come prezzo noto che un
1867
+ * filtro può nascondere proprio il bersaglio, e quel prezzo non deve essere
1868
+ * silenzioso — stessa convenzione del `+N più vecchie` del pane sessioni.
1869
+ */
1870
+ function AssignScreen({ sessionId, label, current, filter, rows, selected, matched, hidden, above, below, childCount, columns, note, }) {
1871
+ const width = assignTextWidth(columns);
1872
+ 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) => {
1873
+ // T57/D2 — `detach` è una VOCE della lista, non un tasto a parte: un
1874
+ // solo gesto (`A`), un solo modale. Speculare alla riga meta `spot`
1875
+ // del pane task, e nominata con l'AZIONE («detach») invece che con lo
1876
+ // stato d'arrivo — è ciò che si sta per fare.
1877
+ if (!task) {
1878
+ const sel = selected === null;
1879
+ // Le colonne fisse (caret + `○ detach` + i due spazi) sono 12: solo
1880
+ // la glossa si taglia, così su un terminale stretto resta comunque
1881
+ // il nome dell'azione invece di un moncone di frase.
1882
+ return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, "\u25CB detach ", cut('la sessione torna spot', width - 12)] }, "detach"));
1883
+ }
1884
+ const sel = selected === task.id;
1885
+ const n = childCount.get(task.id) ?? 0;
1886
+ const head = `${CARET_OFF}${task.id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
1887
+ const tail = n > 0 ? ` (${n})` : '';
1888
+ const desc = cut(task.desc, Math.max(4, width - termWidth(head) - termWidth(tail)));
1889
+ 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
+ })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1891
+ }
1670
1892
  function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, }) {
1671
1893
  const spotSelected = selected === 0;
1672
1894
  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:", ' ', [
@@ -1719,11 +1941,15 @@ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, s
1719
1941
  // di una colonna per emoji e si mangia il bordo destro.
1720
1942
  const meta = ` · ${s.gitBranch || '-'} · ${relTime(s.ts)}`;
1721
1943
  const labelBudget = Math.max(12, paneTextWidth(columns) -
1722
- (2 /* caret */ + 2 /* icona */ + 1 /* spazio */ + (forked ? 2 : 0)) -
1944
+ (2 /* caret */ +
1945
+ 2 /* icona */ +
1946
+ 1 /* spazio */ +
1947
+ SID_W /* hash + spazio */ +
1948
+ (forked ? 2 : 0)) -
1723
1949
  termWidth(meta) -
1724
1950
  1 /* spazio prima del meta */);
1725
1951
  const label = rowLabel(s.title, sessionNotes.get(s.sessionId), projectCore, labelBudget);
1726
- 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" })), ' ', 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));
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));
1727
1953
  })), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null, note: sessionNotes.get(detail.sessionId) ?? '' })) : null] }));
1728
1954
  }
1729
1955
  // T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
@@ -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.1",
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": {