@lamemind/loom-deck 0.21.2 → 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';
@@ -203,7 +203,7 @@ function runLaunch(entry, cwd) {
203
203
  // sé nella stessa finestra, invece di sparpagliare finestre.
204
204
  // Nessun `-- CMD`: l'azione È aprire la shell (differenza dalle launch custom,
205
205
  // che eseguono un comando dentro `bash -lic`).
206
- // `-T <title>` col core `<owner> <name>` tiene la finestra matchabile da compass
206
+ // `-T <title>` con la chiave `🖥️ <name>` tiene la finestra matchabile da compass
207
207
  // anche mentre la tab attiva è il terminale; senza identità nel file config si
208
208
  // spawna senza titolo (la surface resta funzionante, il progetto risulta assente
209
209
  // dal radar finché quella tab è in primo piano).
@@ -511,17 +511,32 @@ 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.
517
528
  const launch = useMemo(() => loadLaunch(cwd), [cwd]);
518
529
  // Identità (T37): titolo delle tab terminale spawnate col tasto `t`.
519
530
  const identity = useMemo(() => loadIdentity(cwd), [cwd]);
520
- // T53 — `<owner> <name>`: il core che ogni titolo di tab porta, quindi la
521
- // colonna costante da togliere quando serve spazio. Hoistato qui perché ora
522
- // lo consumano DUE schermate (lista e ricerca): calcolarlo su ogni call site
523
- // è il modo in cui le due smettono di togliere la stessa cosa.
524
- const projectCore = identity ? `${identity.owner} ${identity.name}` : null;
531
+ // T53 — il core che ogni titolo di tab porta, quindi la colonna costante da
532
+ // togliere quando serve spazio. Hoistato qui perché ora lo consumano DUE
533
+ // schermate (lista e ricerca): calcolarlo su ogni call site è il modo in cui
534
+ // le due smettono di togliere la stessa cosa.
535
+ // T58 il core è il solo `name`, non `<emoji> <name>` come la chiave di match
536
+ // di compass: qui non serve entropia (è un taglio cosmetico, non un matcher) e
537
+ // il nome nudo ripulisce anche i titoli storici, scritti quando la formula
538
+ // includeva l'owner.
539
+ const projectCore = identity ? identity.name : null;
525
540
  // La vista è una trasformazione DERIVATA, applicata a valle del load: il
526
541
  // polling di tasks.md continua a funzionare senza saperne nulla.
527
542
  const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
@@ -583,6 +598,25 @@ function Deck({ cwd, tasksPath, tasksDir }) {
583
598
  const readerLines = useMemo(() => (readerRow?.kind === 'hit' ? wrapWithOffsets(readerRow.hit.text, readerWidth) : []), [readerRow, readerWidth]);
584
599
  const readerCap = readerCapacity(rows);
585
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));
586
620
  // T39 — selezione stabile sotto trasformazione. Se la task selezionata esce
587
621
  // dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
588
622
  // sulla prima visibile — fallback deterministico, mai una posizione a caso.
@@ -609,6 +643,16 @@ function Deck({ cwd, tasksPath, tasksDir }) {
609
643
  setSearchSelKey(firstRowKey(searchRows));
610
644
  }
611
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]);
612
656
  // T30: submit dell'input box. Il taskId nasce DOPO create-task (lo assegna la
613
657
  // skill scrivendo tasks.md) → non è noto allo spawn. Il sessionId invece è
614
658
  // pinnato qui: snapshot degli id PRIMA, poi al completamento re-leggo tasks.md
@@ -708,6 +752,58 @@ function Deck({ cwd, tasksPath, tasksDir }) {
708
752
  ? `✎ nota su ${sid.slice(0, 8)}: "${cut(text, 40)}"`
709
753
  : `✎ nota rimossa da ${sid.slice(0, 8)}`);
710
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
+ }
711
807
  // T41 — apertura dell'edit: la bozza parte dai valori ATTUALI della task, non
712
808
  // da default. La priorità arriva dal glifo di tasks.md (già in `selTask`), lo
713
809
  // stato dal suo glifo Prog; il progresso arbitrario dal campo `Progress` del
@@ -963,6 +1059,46 @@ function Deck({ cwd, tasksPath, tasksDir }) {
963
1059
  }
964
1060
  return;
965
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
+ }
966
1102
  // T30: in modalità create l'handler cattura il testo e corto-circuita la
967
1103
  // navigazione normale (incl. q/esc → quit: qui esc annulla, non esce).
968
1104
  if (mode === 'create') {
@@ -1309,8 +1445,26 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1309
1445
  openNote();
1310
1446
  }
1311
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
+ }
1312
1466
  else if (input === 't') {
1313
- const title = identity ? `🖥️ ${identity.owner} ${identity.name} [term]` : null;
1467
+ const title = identity ? `🖥️ ${identity.name} [term]` : null;
1314
1468
  const child = spawnTerminal(cwd, title);
1315
1469
  child.on('error', () => setNote('⚠ t → ptyxis non lanciabile'));
1316
1470
  setNote(`t → terminale su ${projectName}`);
@@ -1379,7 +1533,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1379
1533
  const keyLegend = sanitize([
1380
1534
  ...(canSpawn ? ['⏎/^K/^P/^R spawn'] : canResume ? ['⏎ resume'] : []),
1381
1535
  ...(canResume ? ['f fork'] : []),
1382
- ...(canPin ? ['p pin', 'N nota'] : []),
1536
+ ...(canPin ? ['p pin', 'N nota', 'A assegna'] : []),
1383
1537
  '^F cerca',
1384
1538
  'C nuova',
1385
1539
  'E edit',
@@ -1387,6 +1541,26 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1387
1541
  'F filtri',
1388
1542
  'w salva',
1389
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
+ }
1390
1564
  // ── T52 · schermate sostitutive ─────────────────────────────────────────
1391
1565
  // Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
1392
1566
  // pane: una lista di occorrenze non entra in un box da 4 righe. Prendono
@@ -1556,11 +1730,12 @@ function searchTitleWidth(columns) {
1556
1730
  * Cosa scrivere sulla riga-gruppo per distinguere una conversazione dall'altra.
1557
1731
  *
1558
1732
  * Non basta `session.title`: quel titolo è la label della tab Ptyxis, cioè
1559
- * `<emoji> <owner> <name>` più un eventuale suffisso. Su una lista tutta dello
1733
+ * `<emoji> <name>` più un eventuale suffisso. Su una lista tutta dello
1560
1734
  * stesso progetto (D3) è una COLONNA COSTANTE — tre righe su quattro
1561
1735
  * identiche, che è esattamente il difetto che la riga-gruppo doveva evitare.
1562
1736
  *
1563
- * Si toglie quindi il core `<owner> <name>` (noto dal file config), e se ciò
1737
+ * Si toglie quindi il core `<name>` con tutto ciò che lo precede (noto dal file
1738
+ * config), e se ciò
1564
1739
  * che resta è vuoto o duplica la task già mostrata nella sua colonna, si
1565
1740
  * ripiega sul primo prompt — l'unica cosa che davvero identifica quella
1566
1741
  * conversazione e non un'altra.
@@ -1662,6 +1837,52 @@ function ReaderLine({ line, from, to }) {
1662
1837
  }
1663
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)] }));
1664
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
+ }
1665
1886
  function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, }) {
1666
1887
  const spotSelected = selected === 0;
1667
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:", ' ', [
package/dist/config.js CHANGED
@@ -102,10 +102,10 @@ export function launchLegend(entries, columns, reserved = 0) {
102
102
  export function parseIdentity(raw) {
103
103
  if (!raw || typeof raw !== 'object')
104
104
  return null;
105
- const { owner, name } = raw;
106
- if (typeof owner !== 'string' || !owner || typeof name !== 'string' || !name)
105
+ const { name } = raw;
106
+ if (typeof name !== 'string' || !name)
107
107
  return null;
108
- return { owner, name };
108
+ return { name };
109
109
  }
110
110
  /** File assente o malformato → nessuna identità. Mai un throw. */
111
111
  export function loadIdentity(projectRoot) {
@@ -17,13 +17,14 @@
17
17
  import { cut, termWidth } from './width.js';
18
18
  // ── T53 · etichetta di riga ────────────────────────────────────────────────
19
19
  /**
20
- * Toglie dal titolo il core `<owner> <name>` del progetto e la punteggiatura di
21
- * giunzione che resta appesa davanti.
20
+ * Toglie dal titolo il core del progetto (il suo `name`) e tutto ciò che lo
21
+ * precede — emoji inclusa — più la punteggiatura di giunzione che resta appesa
22
+ * davanti.
22
23
  *
23
- * Il titolo di una sessione È la label della tab Ptyxis (`🧵 LOCAL loom-works ·
24
- * T52`): dentro un deck che mostra un progetto solo, il core è una COLONNA
25
- * COSTANTE ripetuta su ogni riga. Toglierlo libera le colonne per ciò che
26
- * distingue davvero una conversazione dall'altra.
24
+ * Il titolo di una sessione È la label della tab Ptyxis (`🧵 loom-works · T52`):
25
+ * dentro un deck che mostra un progetto solo, il core è una COLONNA COSTANTE
26
+ * ripetuta su ogni riga. Toglierlo libera le colonne per ciò che distingue
27
+ * davvero una conversazione dall'altra.
27
28
  *
28
29
  * `core` null (file config assente o senza identità) → titolo intatto: senza
29
30
  * sapere cosa togliere non si indovina, si lascia stare.
@@ -138,6 +139,24 @@ export function moveSelection(rows, currentId, delta) {
138
139
  const next = Math.max(0, Math.min(selectable.length - 1, cur + delta));
139
140
  return selectable[next].sessionId;
140
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
+ }
141
160
  /** Session della riga selezionata; null se stale o nessuna selezione. */
142
161
  export function selectedSession(rows, sessionId) {
143
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.21.2",
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": {
package/scripts/deck-run CHANGED
@@ -159,11 +159,11 @@ PROFILE_UUID="${LOOM_DECK_PROFILE_UUID:-5a36ae48df1c4d4882f43060e3e59656}"
159
159
  WORKDIR="${LOOM_DECK_WORKDIR:-$PWD}"
160
160
 
161
161
  # ── Titolo tab = label standard loom (matchabile da compass) ──────────────────
162
- # compass matcha le finestre con `window.title.includes(project.label)`, dove la
163
- # label è DERIVATA dal contratto committato .claude/loom-works.json:
164
- # `{emoji} {owner} {name}` → es. "🧵 LOCAL loom-works". Un titolo "cc <task>"
165
- # non contiene la label → finestra ORFANA (progetto appare spento anche mentre ci
166
- # lavori dentro). Deriviamo la label dal file e la usiamo come titolo, con
162
+ # compass matcha le finestre cercando nel titolo un prefisso `{emoji} {name}`,
163
+ # dove i due token sono DERIVATI dal contratto committato .claude/loom-works.json
164
+ # → es. "🧵 loom-works". Un titolo "cc <task>" non contiene la label → finestra
165
+ # ORFANA (progetto appare spento anche mentre ci lavori dentro). Deriviamo la
166
+ # label dal file e la usiamo come titolo, con
167
167
  # suffisso "· <task>" per restare distinguibili fra più tab claude (il suffisso
168
168
  # non rompe il match: è .includes, non equals — stesso pattern del "· deck").
169
169
  # Fallback su "cc <task>" se manca il file o jq (progetto non loom-registered).
@@ -191,8 +191,8 @@ _cfg=""
191
191
  # la sessione è bound, nessun suffisso quando è nuda.
192
192
  if [[ $NO_TASK -eq 1 ]]; then TITLE="cc"; else TITLE="cc ${TASK}"; fi
193
193
  if [[ -n "$_cfg" ]]; then
194
- _label="$(jq -r 'if (.emoji and .owner and .name)
195
- then "\(.emoji) \(.owner) \(.name)" else empty end' \
194
+ _label="$(jq -r 'if (.emoji and .name)
195
+ then "\(.emoji) \(.name)" else empty end' \
196
196
  "$_cfg" 2>/dev/null || true)"
197
197
  if [[ -n "${_label:-}" ]]; then
198
198
  if [[ $NO_TASK -eq 1 ]]; then TITLE="${_label}"; else TITLE="${_label} · ${TASK}"; fi