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