@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 +233 -12
- package/dist/config.js +3 -3
- package/dist/session-list.js +25 -6
- package/dist/task-index.js +15 -2
- package/dist/viewport.js +23 -0
- package/package.json +1 -1
- package/scripts/deck-run +7 -7
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>`
|
|
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 —
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
|
|
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.
|
|
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> <
|
|
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 `<
|
|
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 {
|
|
106
|
-
if (typeof
|
|
105
|
+
const { name } = raw;
|
|
106
|
+
if (typeof name !== 'string' || !name)
|
|
107
107
|
return null;
|
|
108
|
-
return {
|
|
108
|
+
return { name };
|
|
109
109
|
}
|
|
110
110
|
/** File assente o malformato → nessuna identità. Mai un throw. */
|
|
111
111
|
export function loadIdentity(projectRoot) {
|
package/dist/session-list.js
CHANGED
|
@@ -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
|
|
21
|
-
* giunzione che resta appesa
|
|
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 (`🧵
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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);
|
package/dist/task-index.js
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
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
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
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
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 .
|
|
195
|
-
then "\(.emoji) \(.
|
|
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
|