@lamemind/loom-deck 0.27.3 → 0.29.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/README.md CHANGED
@@ -10,9 +10,10 @@ già bound alla task via `LOOM_TASK`.
10
10
  ↑↓ scegli la task → ⏎ → tab CC di fianco → LOOM_TASK bound
11
11
  ```
12
12
 
13
- Il tasto premuto sceglie **con quale prompt** si entra: `⏎` a mani nude, `^K`
14
- recap, `^P` preflight, `^R` esecuzione (vedi [I quattro modi di entrare in una
15
- task](#i-quattro-modi-di-entrare-in-una-task)).
13
+ `⏎` apre il **detail** della task task file scrollabile più una barra azioni —
14
+ e da si sceglie con quale prompt entrare; `^K`/`^P`/`^R` restano acceleratori
15
+ per chi lo sa già (vedi [I cinque modi di entrare in una
16
+ task](#i-cinque-modi-di-entrare-in-una-task)).
16
17
 
17
18
  Entrambi i prefissi del contratto loom entrano in lista: **`T`** (code task) e
18
19
  **`D`** (doc task). Il trattamento è identico ovunque tranne che su `^R`, dove il
@@ -37,7 +38,7 @@ Ptyxis. Chiudere il deck non uccide le sessioni. La tab nasce nella window *atti
37
38
 
38
39
  ## Stato
39
40
 
40
- Bootstrap + spike ① + **TUI ③** funzionante (legge `tasks.md`, `⏎` spawna). Roadmap:
41
+ Bootstrap + spike ① + **TUI ③** funzionante (legge `tasks.md`, `⏎` apre il detail). Roadmap:
41
42
 
42
43
  ```
43
44
  ① spike spawn-tab + LOOM_TASK ✅ scripts/deck-run
@@ -56,7 +57,7 @@ senza collisioni:
56
57
  |---|---|---|
57
58
  | `↑` `↓` | naviga nella lista | |
58
59
  | `←` `→` `tab` | cambia pane | |
59
- | `⏎` | azione primaria del pane | Tasks → spawna la task selezionata (senza prompt) · Sessions → riprende (`claude --resume`) la sessione selezionata |
60
+ | `⏎` | azione primaria del pane | Tasks → apre il **detail** della task selezionata · Sessions → riprende (`claude --resume`) la sessione selezionata |
60
61
  | **MAIUSCOLA** | **apre un modale** | cattura tutti i tasti; `esc` annulla, non esce |
61
62
  | minuscola | azione immediata, one-shot | |
62
63
  | `CTRL`+lettera | idiomi universali, toggle dentro i modali di testo, varianti di un'azione | `^F` = find, come ovunque · `^K`/`^P`/`^R` = spawn con un altro prompt |
@@ -79,6 +80,7 @@ Assegnazioni correnti:
79
80
  | modale | `S` | sort chain |
80
81
  | modale | `F` | filtri |
81
82
  | modale | `^F` | **ricerca full-text** nelle conversazioni del progetto |
83
+ | modale | `⏎` | **detail** della task selezionata: task file scrollabile + barra azioni |
82
84
  | immediata | `^K` | spawna la task selezionata col prompt di **recap** |
83
85
  | immediata | `^P` | spawna la task selezionata sul **preflight** |
84
86
  | immediata | `^R` | spawna la task selezionata in **esecuzione** |
@@ -91,7 +93,7 @@ Assegnazioni correnti:
91
93
  Il footer è **due righe con due nature diverse**:
92
94
 
93
95
  ```
94
- ⏎/^K/^P/^R spawn · ^F cerca · C nuova · E edit · S sort · F filtri · w salva
96
+ ⏎ detail · ^K/^P/^R spawn · ^F cerca · C nuova · E edit · S sort · F filtri · w salva
95
97
  t 💻 · c 🤖 · 1 📝 codium · 2 ☕ idea
96
98
  ```
97
99
 
@@ -123,28 +125,50 @@ finestra Ptyxis, senza passare da un modale. `c` (minuscola, azione) e `C`
123
125
  (maiuscola, modale create-task) restano distinte per la regola sopra — così come
124
126
  `f` (fork) e `F` (filtri).
125
127
 
126
- ### I quattro modi di entrare in una task
128
+ ### I cinque modi di entrare in una task
127
129
 
128
- Sulla task selezionata (focus sul pane Tasks) quattro tasti aprono una sessione
129
- **bound**: `LOOM_TASK` iniettata, `sessionId` pinnato, binding scritto nel
130
- sidecar. Fra loro cambia **solo il prompt iniziale**.
130
+ Sulla task selezionata (focus sul pane Tasks) si apre una sessione **bound**:
131
+ `LOOM_TASK` iniettata, `sessionId` pinnato, binding scritto nel sidecar. Fra i
132
+ modi cambia **solo il prompt iniziale**.
131
133
 
132
- | Tasto | Prompt |
133
- |---|---|
134
- | `⏎` | nessuno — il contesto lo carica l'hook `SessionStart`, il primo messaggio lo scrivi tu |
135
- | `^K` | `recap stato task <id>` — prompt diretto, nessuna skill |
136
- | `^P` | `/loom-works:preflight-task <id>` |
137
- | `^R` | `/loom-works:run-task <id>`, oppure `/loom-works:run-doc <id>` se l'id è una `D` |
134
+ | Azione (detail) | Tasto diretto | Prompt |
135
+ |---|---|---|
136
+ | `open` | — | nessuno — il contesto lo carica l'hook `SessionStart`, il primo messaggio lo scrivi tu |
137
+ | `status` | `^K` | `recap stato task <id>` — prompt diretto, nessuna skill |
138
+ | `preflight` | `^P` | `/loom-works:preflight-task <id>` |
139
+ | `run` | `^R` | `/loom-works:run-task <id>`, oppure `/loom-works:run-doc <id>` se l'id è una `D` |
140
+ | `checkpoint` | — | `/loom-works:checkpoint-task <id>` |
138
141
 
139
142
  Il prompt iniziale è la scelta di **chi apre**, non una proprietà della task: lo
140
143
  stesso task file si apre per leggerne lo stato, per congelarne le decisioni, per
141
- eseguirlo o per entrarci a mani nude — e il tasto premuto *è* quell'intento.
142
- Prima esisteva il solo `⏎` col recap, quindi ogni altro ingresso passava per una
143
- sessione da correggere a mano.
144
+ eseguirlo o per entrarci a mani nude — e l'azione scelta *è* quell'intento.
145
+
146
+ Le due superfici sono complementari, non alternative. `⏎` apre il **detail** e lì
147
+ si legge la Description *mentre* si decide: è il caso normale, perché la domanda
148
+ «quale azione?» quasi sempre si risponde leggendo la task. I tre `CTRL` saltano
149
+ il passaggio per chi lo sa già. Dentro il detail sono inerti — i bottoni sono lì.
150
+
151
+ Col focus sul pane Sessions né `⏎` né i `CTRL` spawnano: l'oggetto dell'azione è
152
+ la task selezionata, e senza quel pane a fuoco non ce n'è una (il deck lo dice
153
+ nella riga di nota, invece di aprire qualcosa a caso).
154
+
155
+ ### `⏎` — il detail della task
156
+
157
+ Overlay fullscreen a due zone: il **task file per intero**, scrollabile
158
+ (`↑↓` riga, `PgUp`/`PgDn` pagina, `g`/`G` estremi), e una riga di **bottoni**
159
+ navigabile con `←→`, con `open` a fuoco all'apertura. `⏎` esegue l'azione
160
+ selezionata e chiude; `esc` chiude lasciando la lista con la stessa selezione.
161
+
162
+ Le azioni non sono un catalogo nuovo: chiamano lo **stesso** percorso di spawn
163
+ dei tasti diretti. Una superficie in più, zero percorsi di spawn in più.
164
+
165
+ Sono bottoni affiancati e non voci di un menu verticale perché è la forma che
166
+ sopravvive all'arrivo del mouse: un rettangolo ha già coordinate e area
167
+ cliccabile, una lista di voci andrebbe rifatta.
144
168
 
145
- Col focus sul pane Sessions i tre `CTRL` non spawnano: l'oggetto dell'azione è la
146
- task selezionata, e senza quel pane a fuoco non ce n'è una (il deck lo dice nella
147
- riga di nota, invece di aprire qualcosa a caso).
169
+ Dentro il detail `←→` non cambia più pane il ramo `useInput` della modalità
170
+ chiude prima di arrivare a quello di default. Ink non ha focus-trap: la cattura
171
+ **è** l'ordine dei rami.
148
172
 
149
173
  Il catalogo dei prompt vive in `scripts/deck-run`, non nella TUI: è il primitive
150
174
  UI-agnostico, il deck gli passa un **simbolo** (`--prompt-kind`) e non una
@@ -298,7 +322,7 @@ npx @lamemind/loom-deck
298
322
  ```bash
299
323
  # dalla project dir con un tasks.md
300
324
  scripts/deck-run T18
301
- scripts/deck-run T18 --prompt-kind none|recap|preflight|run
325
+ scripts/deck-run T18 --prompt-kind none|recap|preflight|run|checkpoint
302
326
  ```
303
327
 
304
328
  Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude 'recap stato task T18'`.
@@ -320,7 +344,7 @@ alcun errore visibile. Override o disattivazione via `LOOM_DECK_STATE_PROFILE`
320
344
 
321
345
  ```bash
322
346
  npm install
323
- npm run dev # tsx src/cli.tsx — lista tasks.md reale, ↑↓ naviga · ⏎/^K/^P/^R spawn · q esci
347
+ npm run dev # tsx src/cli.tsx — lista tasks.md reale, ↑↓ naviga · ⏎ detail · ^K/^P/^R spawn · q esci
324
348
  npm run build # tsc → dist/
325
349
  npm test # node:test sul core vista (src/view.ts), senza Ink né terminale
326
350
  ```
package/dist/cli.js CHANGED
@@ -8,14 +8,15 @@ import { statSync } from 'node:fs';
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { dirname, join } from 'node:path';
11
- import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from './tasks.js';
11
+ import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, loadTaskFileText, } from './tasks.js';
12
12
  import { discoverProjectSessions } from './sessions.js';
13
+ import { discoverLiveSessions, liveSig } from './live-sessions.js';
13
14
  import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions, selectedRow, DEFAULT_OPTIONS, MIN_QUERY, } from './search.js';
14
15
  import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
15
16
  import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, rowLabel, selectedSession, sessionTitle, stripProjectCore, } from './session-list.js';
16
17
  import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch, } from './config.js';
17
18
  import { countArchivable, SCAN_INTERVAL_MS } from './archivable.js';
18
- import { assignListCapacity, isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
19
+ import { assignListCapacity, detailCapacity, isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
19
20
  import { caretWindow, cut, cutParts, pad, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
20
21
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
21
22
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
@@ -136,6 +137,21 @@ function spawnOut(cmd, args, opts) {
136
137
  fake.unref = () => fake;
137
138
  return fake;
138
139
  }
140
+ // T66 — le azioni del detail. Non sono un catalogo nuovo: ognuna è un
141
+ // `--prompt-kind` già esistente più `checkpoint`, e tutte passano dallo stesso
142
+ // `spawnForTask` dei CTRL della lista — una superficie in più, zero percorsi di
143
+ // spawn in più.
144
+ //
145
+ // L'etichetta è distinta dal kind dove il kind è il nome del MECCANISMO e
146
+ // l'etichetta quello dell'INTENZIONE: `none` è "aprire la task a mani nude",
147
+ // `recap` è "vedere a che punto sta".
148
+ const DETAIL_ACTIONS = [
149
+ { kind: 'none', label: 'open' },
150
+ { kind: 'preflight', label: 'preflight' },
151
+ { kind: 'run', label: 'run' },
152
+ { kind: 'recap', label: 'status' },
153
+ { kind: 'checkpoint', label: 'checkpoint' },
154
+ ];
139
155
  // Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
140
156
  // ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
141
157
  // sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
@@ -379,6 +395,7 @@ function useSessions(projectRoot) {
379
395
  forkOf: new Map(),
380
396
  pinned: new Map(),
381
397
  notes: new Map(),
398
+ live: new Map(),
382
399
  });
383
400
  // T50 — pin/unpin scrive il sidecar e vuole feedback IMMEDIATO, non al
384
401
  // prossimo tick del poll (1.5s): la reload è esposta via ref così il toggle la
@@ -397,6 +414,18 @@ function useSessions(projectRoot) {
397
414
  sessions = [];
398
415
  index = { bindings: new Map(), forkOf: new Map(), pinned: new Map(), notes: new Map() };
399
416
  }
417
+ // T62 — le vive stanno sullo STESSO tick delle altre fonti, non su una
418
+ // scala propria come `useArchivable`: `status` cambia a ogni turno, quindi
419
+ // un refresh più lento mostrerebbe `idle` su una sessione che lavora.
420
+ // Il try è separato perché il registry è una fonte indipendente: se manca
421
+ // (versione del CLI che non lo scrive) la lista deve restare, senza vive.
422
+ let live;
423
+ try {
424
+ live = discoverLiveSessions(projectRoot);
425
+ }
426
+ catch {
427
+ live = new Map();
428
+ }
400
429
  const { bindings, forkOf, pinned, notes } = index;
401
430
  // La signature copre anche fork, pin e note: un record di lineage, un
402
431
  // toggle di pin o una nota appena scritta cambiano la lista renderizzata,
@@ -409,11 +438,13 @@ function useSessions(projectRoot) {
409
438
  '#' +
410
439
  [...pinned.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',') +
411
440
  '#' +
412
- [...notes.entries()].map(([k, v]) => `${k}"${v}`).sort().join(',');
441
+ [...notes.entries()].map(([k, v]) => `${k}"${v}`).sort().join(',') +
442
+ '#' +
443
+ liveSig(live);
413
444
  if (sig === lastSig)
414
445
  return;
415
446
  lastSig = sig;
416
- setState({ sessions, bindings, forkOf, pinned, notes });
447
+ setState({ sessions, bindings, forkOf, pinned, notes, live });
417
448
  };
418
449
  reloadRef.current = reload;
419
450
  reload();
@@ -494,6 +525,18 @@ const SID_CHARS = 8;
494
525
  * vuota di soli spazi lascerebbe un buco che si legge come "colonna finita",
495
526
  * e la riga tornerebbe a sembrare disallineata pur non essendolo. */
496
527
  const TASK_EMPTY = '·';
528
+ // T62 — colonna liveness, larga 1, incollata al sessionId senza gutter proprio:
529
+ // il glifo qualifica QUELL'id, e uno spazio in mezzo lo farebbe leggere come una
530
+ // colonna a sé. Entrambi Ambiguous (EAW) → 1 colonna per il terminale e 1 cella
531
+ // per Ink, quindi concordi (invariante ① di width.ts): il pieno/vuoto del
532
+ // cerchio è l'unico asse che varia, e resta scandibile in verticale.
533
+ //
534
+ // Sulla riga CHIUSA c'è uno spazio e non un terzo glifo: le chiuse sono la
535
+ // maggioranza di ogni lista, e marcarle vorrebbe dire disegnare N volte «niente
536
+ // da dire» — la colonna diventerebbe rumore invece di un segnale.
537
+ const LIVE_IDLE = '●';
538
+ const LIVE_BUSY = '◍';
539
+ const LIVE_NONE = ' ';
497
540
  // Marker Done per il DISPLAY. `task.prog` resta il `✔️` letto da tasks.md —
498
541
  // `isDone()` e le lookup di `view.ts` ci confrontano sopra, e `task-edit` lo
499
542
  // riscrive sul file: è una chiave semantica, non testo. Qui `sanitize` lo
@@ -550,7 +593,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
550
593
  // già la riga di STATO in fondo al frame (il feedback di un'azione). Due
551
594
  // concetti diversi a una lettera di distanza sarebbero una trappola di
552
595
  // lettura — e di scrittura, visto che `setNote` compare in quasi ogni ramo.
553
- const { sessions, bindings, forkOf, pinned, notes: sessionNotes, reload: reloadSessions, } = useSessions(cwd);
596
+ const { sessions, bindings, forkOf, pinned, notes: sessionNotes, live, reload: reloadSessions, } = useSessions(cwd);
554
597
  const [focus, setFocus] = useState('tasks');
555
598
  // T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
556
599
  // (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
@@ -609,6 +652,19 @@ function Deck({ cwd, tasksPath, tasksDir }) {
609
652
  const [assignSid, setAssignSid] = useState(null);
610
653
  const [assignFilter, setAssignFilter] = useState('');
611
654
  const [assignSel, setAssignSel] = useState(null);
655
+ // T66 — la task aperta nel detail, FOTOGRAFATA all'apertura: id, titolo e
656
+ // testo integrale del task file (`text` null = file assente). Non si rilegge
657
+ // da `selTask` per la stessa ragione di `assignSid`: l'overlay copre la lista,
658
+ // quindi l'oggetto dell'azione deve restare quello che si è scelto anche se un
659
+ // tick del poll spostasse la selezione sotto.
660
+ //
661
+ // Si chiama `sheet` e non `detail` perché `detail` in questo componente è già
662
+ // il `TaskDetail` del blocco preview sotto i pane: due cose vicine con lo
663
+ // stesso nome sono una trappola di lettura (stesso motivo di
664
+ // `note`/`sessionNotes`).
665
+ const [sheet, setSheet] = useState(null);
666
+ const [sheetTop, setSheetTop] = useState(0);
667
+ const [sheetAction, setSheetAction] = useState(0);
612
668
  // Dimensioni vive del terminale: sono l'input del budget d'altezza sotto.
613
669
  const { rows, columns } = useTerminalSize();
614
670
  // Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
@@ -669,6 +725,10 @@ function Deck({ cwd, tasksPath, tasksDir }) {
669
725
  const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, isAll ? MAX_SESSIONS_ALL : MAX_SESSIONS), [childSessions, sessions, pinned, isAll]);
670
726
  const sessionRows = assembled.rows;
671
727
  const selSessionObj = selectedSession(sessionRows, selSessionId);
728
+ // T62 — contato sulla lista INTERA (stessa ragione delle larghezze di colonna
729
+ // qui sotto): derivarlo dalla finestra visibile lo farebbe cambiare a ogni
730
+ // scroll, cioè un contatore che conta lo schermo invece della lista.
731
+ const liveCount = useMemo(() => sessionRows.filter((r) => r.kind !== 'separator' && live.has(r.sessionId)).length, [sessionRows, live]);
672
732
  // T60 — larghezze delle colonne fisse della lista sessioni, misurate sulla
673
733
  // lista INTERA e non sulla finestra visibile: derivarle dalle sole righe a
674
734
  // schermo le farebbe cambiare a ogni scroll, cioè l'opposto di una tabella.
@@ -722,6 +782,19 @@ function Deck({ cwd, tasksPath, tasksDir }) {
722
782
  const readerLines = useMemo(() => (readerRow?.kind === 'hit' ? wrapWithOffsets(readerRow.hit.text, readerWidth) : []), [readerRow, readerWidth]);
723
783
  const readerCap = readerCapacity(rows);
724
784
  const readerMaxTop = Math.max(0, readerLines.length - readerCap);
785
+ // T66 — testo del task file wrappato per il detail. `wrapWithOffsets` e non
786
+ // `wrapLines`: quest'ultimo appiattisce gli a-capo in un flusso unico, che per
787
+ // una preview di 4 righe va bene e per un task file — titoli, bullet, tabelle,
788
+ // blocchi di codice — significa renderlo illeggibile. Memoizzato per (testo,
789
+ // larghezza), o ogni pressione di freccia rifarebbe l'a-capo di 9KB.
790
+ //
791
+ // Cornici da scalare: box esterno (2 bordi + 2 padding) + box testo (2 bordi +
792
+ // 2 padding) = 8. Sottostimare tronca un carattere, sovrastimare manda a capo
793
+ // una riga che il budget d'altezza non ha contato.
794
+ const sheetWidth = Math.max(20, (columns || 80) - 8);
795
+ const sheetLines = useMemo(() => (sheet?.text ? wrapWithOffsets(sheet.text, sheetWidth).map((l) => l.text) : []), [sheet, sheetWidth]);
796
+ const sheetCap = detailCapacity(rows);
797
+ const sheetMaxTop = Math.max(0, sheetLines.length - sheetCap);
725
798
  // T57 — righe del modale assegnazione: `null` (detach) in testa, poi le task
726
799
  // della VISTA corrente (D4 — filtri e sort inclusi, coerenza con ciò che si
727
800
  // stava leggendo a sinistra; le escluse restano contate nell'header).
@@ -825,29 +898,71 @@ function Deck({ cwd, tasksPath, tasksDir }) {
825
898
  // è la task SELEZIONATA: senza il pane task a fuoco non ce n'è una. Per `⏎` il
826
899
  // ramo è irraggiungibile (ci arriva già dentro `focus === 'tasks'`); per i tre
827
900
  // CTRL, che il ramo `key.ctrl` intercetta globalmente, è l'unico posto.
828
- function spawnTaskSession(kind, keyLabel) {
901
+ // T66 — la guardia, estratta perché ora ha due chiamanti: gli spawn dalla
902
+ // lista e l'apertura del detail. Le tre uscite sono le stesse (pane sbagliato,
903
+ // riga meta, task sparita), e duplicarle vorrebbe dire tenerne allineati i
904
+ // messaggi a mano. `verb` è l'unica cosa che cambia fra i due usi.
905
+ function selectedTaskOr(keyLabel, verb) {
829
906
  if (focus !== 'tasks') {
830
- setNote(`${keyLabel} → spawn: seleziona una task (←→ per il pane)`);
831
- return;
907
+ setNote(`${keyLabel} → ${verb}: seleziona una task (←→ per il pane)`);
908
+ return null;
832
909
  }
833
910
  // T59 — la guardia è "non è una task", non "è spot": le righe meta sono due
834
911
  // e nessuna delle due ha una task da aprire. Il messaggio dice quale delle
835
912
  // due, perché il motivo è diverso (vista di sola lettura vs sessioni libere).
836
913
  if (isAll || isSpot) {
837
914
  setNote(isAll
838
- ? 'tutte: vista di sola lettura, nessuna task da spawnare'
839
- : 'spot: sessioni libere, nessuna task da spawnare');
840
- return;
915
+ ? `tutte: vista di sola lettura, nessuna task da ${verb}`
916
+ : `spot: sessioni libere, nessuna task da ${verb}`);
917
+ return null;
841
918
  }
842
- if (!selTask)
843
- return;
844
- const task = selTask;
919
+ return selTask;
920
+ }
921
+ // Lo spawn vero, su una task GIÀ risolta. Separato dalla guardia perché il
922
+ // detail passa l'id fotografato all'apertura e non la selezione corrente: la
923
+ // lista lì sotto non è più a schermo, quindi non è più la fonte dell'oggetto.
924
+ function spawnForTask(id, kind, keyLabel) {
845
925
  const sid = randomUUID();
846
- appendTaskBinding(cwd, sid, task.id);
847
- const child = spawnDeck(task.id, cwd, sid, kind);
848
- child.on('error', () => setNote(`⚠ spawn ${task.id} fallito (${DECK_RUN})`));
926
+ appendTaskBinding(cwd, sid, id);
927
+ const child = spawnDeck(id, cwd, sid, kind);
928
+ child.on('error', () => setNote(`⚠ spawn ${id} fallito (${DECK_RUN})`));
849
929
  const what = kind === 'none' ? '' : ` · ${kind}`;
850
- setNote(`${keyLabel} spawn ${task.id}${what} → tab CC (sid ${sid.slice(0, 8)})`);
930
+ setNote(`${keyLabel} spawn ${id}${what} → tab CC (sid ${sid.slice(0, 8)})`);
931
+ }
932
+ function spawnTaskSession(kind, keyLabel) {
933
+ const task = selectedTaskOr(keyLabel, 'spawnare');
934
+ if (task)
935
+ spawnForTask(task.id, kind, keyLabel);
936
+ }
937
+ // T66 — `⏎` sul pane task apre il detail invece di spawnare. Il testo del task
938
+ // file si legge QUI e non a ogni cambio selezione: l'overlay è l'unico
939
+ // consumer, e leggerlo in anticipo pagherebbe un file a ogni pressione di
940
+ // freccia per una schermata che quasi sempre non si apre.
941
+ // File assente → `text` null: l'overlay si apre lo stesso, perché spawnare una
942
+ // sessione non richiede il file (lo risolve `deck-run` per id).
943
+ function openDetail() {
944
+ const task = selectedTaskOr('⏎', 'aprire');
945
+ if (!task)
946
+ return;
947
+ setSheet({
948
+ id: task.id,
949
+ // Il titolo del task file quando c'è (è l'H1, cioè la forma lunga), la
950
+ // riga di tasks.md altrimenti: il detail non deve restare senza intestazione
951
+ // solo perché il file manca.
952
+ title: detail?.title || task.desc,
953
+ text: loadTaskFileText(tasksDir, task.id),
954
+ });
955
+ setSheetTop(0);
956
+ setSheetAction(0);
957
+ setNote('');
958
+ setMode('detail');
959
+ }
960
+ function closeDetail() {
961
+ setMode('normal');
962
+ setSheet(null);
963
+ }
964
+ function scrollDetail(delta) {
965
+ setSheetTop((t) => Math.max(0, Math.min(sheetMaxTop, t + delta)));
851
966
  }
852
967
  // T53 — apertura del modale nota sulla conversazione selezionata. Come
853
968
  // `openEdit`, la bozza parte dal valore ATTUALE: annotare una seconda volta è
@@ -1428,6 +1543,58 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1428
1543
  }
1429
1544
  return;
1430
1545
  }
1546
+ // T66 — detail della task: due zone (testo scrollabile + barra azioni) e una
1547
+ // selezione per ognuna. Sta PRIMA del ramo di default per la stessa ragione
1548
+ // di reader e ricerca — Ink non ha focus-trap, quindi la cattura È l'ordine
1549
+ // dei rami — e il `return` in fondo è ciò che impedisce a `←→` di cambiare
1550
+ // pane mentre muove fra le azioni: qui quel binding non esiste più.
1551
+ // È anche prima del ramo CTRL, quindi `^K`/`^P`/`^R` restano acceleratori
1552
+ // della sola lista: chi è già nel detail ha i bottoni.
1553
+ if (mode === 'detail') {
1554
+ if (key.escape) {
1555
+ // Nessun reset di `sel`: la selezione della lista non è mai stata
1556
+ // toccata, quindi si ritrova esattamente dov'era.
1557
+ closeDetail();
1558
+ }
1559
+ else if (key.return) {
1560
+ // `⏎` esegue SEMPRE l'azione selezionata, mai "scrolla" o "chiudi": il
1561
+ // testo non è un campo attivo (si scorre, non si edita), quindi nessuna
1562
+ // competizione sul tasto.
1563
+ const action = DETAIL_ACTIONS[sheetAction];
1564
+ const id = sheet?.id;
1565
+ closeDetail();
1566
+ if (id)
1567
+ spawnForTask(id, action.kind, `⏎ ${action.label}`);
1568
+ }
1569
+ else if (key.leftArrow || key.rightArrow) {
1570
+ // Scorrimento CICLICO come le righe di scelta del modale edit: cinque
1571
+ // voci, arrivare in fondo e ripartire costa meno che invertire direzione.
1572
+ const d = key.leftArrow ? -1 : 1;
1573
+ setSheetAction((i) => (i + d + DETAIL_ACTIONS.length) % DETAIL_ACTIONS.length);
1574
+ }
1575
+ else if (key.upArrow) {
1576
+ scrollDetail(-1);
1577
+ }
1578
+ else if (key.downArrow) {
1579
+ scrollDetail(1);
1580
+ }
1581
+ else if (key.pageUp) {
1582
+ scrollDetail(-sheetCap);
1583
+ }
1584
+ else if (key.pageDown) {
1585
+ scrollDetail(sheetCap);
1586
+ }
1587
+ else if (input === 'g') {
1588
+ // Estremi su lettera per lo stesso motivo del reader: Ink riconosce
1589
+ // `Home`/`End` ma non le espone, e qui non c'è input di testo che
1590
+ // contenda le lettere.
1591
+ setSheetTop(0);
1592
+ }
1593
+ else if (input === 'G') {
1594
+ setSheetTop(sheetMaxTop);
1595
+ }
1596
+ return;
1597
+ }
1431
1598
  // T52/D1 — il ramo CTRL sta PRIMA di quelli su lettera nuda e li chiude
1432
1599
  // tutti. `CTRL+F` e `f` nudo arrivano con lo STESSO `input` ('f'),
1433
1600
  // distinguibili solo da `key.ctrl`: senza questa precedenza `CTRL+F`
@@ -1469,12 +1636,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1469
1636
  }
1470
1637
  else if (key.return) {
1471
1638
  if (focus === 'tasks') {
1472
- // T56 — ⏎ apre la sessione a MANI NUDE: bound alla task (LOOM_TASK,
1473
- // sessionId pinnato, binding) ma senza messaggio iniettato il contesto
1474
- // lo carica l'hook SessionStart, il primo messaggio lo scrive l'utente.
1475
- // Il recap che faceva prima è passato a ^K: resta a un tasto, ma smette
1476
- // di essere l'unico ingresso possibile nella task.
1477
- spawnTaskSession('none', '⏎');
1639
+ // T66 — ⏎ apre il DETAIL, non più una sessione. Secondo rimappaggio in
1640
+ // due task (T56 lo spostò da recap a sessione a mani nude), e la
1641
+ // direzione è una sola: da azione singola a punto d'ingresso. Il tasto
1642
+ // più battuto non è il posto dove inchiodare una scelta di prompt le
1643
+ // shortcut CTRL restano per chi sa già cosa vuole, `⏎` apre il ventaglio.
1644
+ // Lo spawn a mani nude di prima è `open`, cioè `⏎ ⏎` (è il focus iniziale).
1645
+ openDetail();
1478
1646
  }
1479
1647
  else {
1480
1648
  // T49 — ⏎ su una sessione = resume in nuova tab. Il binding si rilegge
@@ -1665,7 +1833,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1665
1833
  // l'indicatore `focus:` — il pane a fuoco si vede già dall'evidenziazione, e
1666
1834
  // ridirlo a parole costava colonne su una riga che tronca in silenzio.
1667
1835
  const keyLegend = sanitize([
1668
- ...(canSpawn ? ['⏎/^K/^P/^R spawn'] : canResume ? ['⏎ resume'] : []),
1836
+ ...(canSpawn ? ['⏎ detail', '^K/^P/^R spawn'] : canResume ? ['⏎ resume'] : []),
1669
1837
  ...(canResume ? ['f fork'] : []),
1670
1838
  ...(canPin ? ['p pin', 'N nota', 'A assegna'] : []),
1671
1839
  '^F cerca',
@@ -1695,6 +1863,20 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1695
1863
  (s ? conversationLabel(s, projectCore, assignSid ? bindings.get(assignSid) : undefined) : '');
1696
1864
  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 }));
1697
1865
  }
1866
+ // ── T66 · detail della task ─────────────────────────────────────────────
1867
+ // Quarta schermata sostitutiva, stessa ragione delle altre tre: un task file
1868
+ // non entra in un box sopra i due pane. Il budget dei pane non viene nemmeno
1869
+ // calcolato — il render esce di qui prima.
1870
+ if (mode === 'detail' && sheet) {
1871
+ if (isCompact(sheetCap)) {
1872
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", sheet.id, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7 esc chiude"] })] }));
1873
+ }
1874
+ // Niente `windowRange`: quella centra la finestra su una selezione, qui la
1875
+ // posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
1876
+ // può accorciare il testo sotto uno scroll già dato.
1877
+ const start = Math.min(sheetTop, sheetMaxTop);
1878
+ return (_jsx(DetailScreen, { id: sheet.id, title: sheet.title, missing: sheet.text === null, lines: sheetLines.slice(start, start + sheetCap), top: start, total: sheetLines.length, capacity: sheetCap, action: sheetAction, columns: columns }));
1879
+ }
1698
1880
  // ── T52 · schermate sostitutive ─────────────────────────────────────────
1699
1881
  // Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
1700
1882
  // pane: una lista di occorrenze non entra in un box da 4 righe. Prendono
@@ -1749,21 +1931,26 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1749
1931
  // non dipende più da quante voci `launch` il progetto dichiara.
1750
1932
  const launchLine = mode === 'normal';
1751
1933
  const detailParts = detail ? detailMetaOf(detail) : null;
1934
+ // T70 — un solo blocco preview, sotto i due pane, e il FOCUS decide cosa
1935
+ // contiene: a sinistra la task selezionata, a destra la conversazione. È il
1936
+ // focus e non la selezione perché il blocco è uno — averne due significava
1937
+ // pagare due cornici per mostrare contemporaneamente il dettaglio di una cosa
1938
+ // che si sta guardando e di una che non si sta guardando.
1939
+ // `none` quando non c'è contenuto (riga meta selezionata, task file assente,
1940
+ // pin stale): il blocco sparisce del tutto, non resta una cornice vuota.
1941
+ const previewKind = focus === 'tasks' ? (detail ? 'task' : 'none') : selSessionObj ? 'session' : 'none';
1752
1942
  const budget = layoutBudget({
1753
1943
  rows,
1754
1944
  mode,
1755
1945
  launchLine,
1756
1946
  noteLine: Boolean(note),
1757
- hasDetail: Boolean(detail),
1947
+ preview: previewKind,
1758
1948
  detailMetaLines: detailParts?.metaLines ?? 0,
1759
- // T49 il detail pane sessione esiste solo con il focus sul pane: è
1760
- // l'hover, non uno stato persistente; navigando le task non ruba righe.
1761
- hasSessionDetail: canResume,
1762
- // Riservo righe di preview solo per i blocchi che davvero renderizzano: il
1949
+ // Riservo righe di anteprima solo per i blocchi che davvero renderizzano: il
1763
1950
  // primo prompt aggiunge info solo con un titolo custom (senza, titolo ===
1764
1951
  // primo prompt); l'ultima risposta solo se il modello ha già risposto.
1765
- sessionHasFirstPreview: canResume && Boolean(selSessionObj?.customTitle),
1766
- sessionHasLastPreview: canResume && Boolean(selSessionObj?.lastReply),
1952
+ sessionHasFirstPreview: previewKind === 'session' && Boolean(selSessionObj?.customTitle),
1953
+ sessionHasLastPreview: previewKind === 'session' && Boolean(selSessionObj?.lastReply),
1767
1954
  });
1768
1955
  // Finestre di rendering. Le liste "logiche" (viewTasks, sessionRows)
1769
1956
  // restano intere: navigazione, selezione e spawn continuano a ragionare su
@@ -1779,7 +1966,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1779
1966
  if (budget.compact) {
1780
1967
  return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? parentLabel, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
1781
1968
  }
1782
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${legend.shown}` : '', legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, archivable: archivable, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1969
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${legend.shown}` : '', legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, archivable: archivable, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore, live: live, liveCount: liveCount })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '', live: live.get(selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1783
1970
  }
1784
1971
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
1785
1972
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -1971,6 +2158,45 @@ function ReaderLine({ line, from, to }) {
1971
2158
  }
1972
2159
  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)] }));
1973
2160
  }
2161
+ /**
2162
+ * Detail della task (T66): il task file scrollabile più la barra azioni.
2163
+ *
2164
+ * Unisce due gesti che erano due schermate — leggere la task e agire su di essa
2165
+ * — perché convergono sullo stesso oggetto: si legge la Description proprio per
2166
+ * decidere QUALE azione lanciare, e con due overlay separati quella decisione
2167
+ * costava uscire dal viewer e ricordarsi la combo.
2168
+ *
2169
+ * Le azioni sono BOTTONI AFFIANCATI e non voci di un menu verticale: un
2170
+ * rettangolo ha già coordinate e area cliccabile, quindi il layout sopravvive
2171
+ * all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione. La
2172
+ * navigazione da tastiera ci si sovrappone senza conflitti.
2173
+ */
2174
+ function DetailScreen({ id, title, missing, lines, top, total, capacity, action, columns, }) {
2175
+ const last = Math.min(total, top + capacity);
2176
+ // Il taglio lo fa il chiamante (invariante ③ di width.ts): la riga bottoni è
2177
+ // ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso — ma
2178
+ // la correttezza non deve dipendere dall'alfabeto che capita nella riga.
2179
+ const width = Math.max(20, (columns || 80) - 4);
2180
+ const segs = DETAIL_ACTIONS.map((a) => `[ ${a.label} ]`);
2181
+ const parts = [];
2182
+ segs.forEach((s, i) => {
2183
+ if (i > 0)
2184
+ parts.push(' ');
2185
+ parts.push(s);
2186
+ });
2187
+ const dropped = (v) => segs.filter((s, i) => v[i * 2] !== s).length;
2188
+ // Due passate: la seconda serve SOLO quando qualcosa cade, e riserva le
2189
+ // colonne del contatore. Riservarle sempre costerebbe 6 colonne su ogni
2190
+ // terminale largo per un avviso che lì non comparirà mai.
2191
+ let shown = cutParts(parts, width);
2192
+ if (dropped(shown) > 0)
2193
+ shown = cutParts(parts, Math.max(0, width - 6));
2194
+ const cutCount = dropped(shown);
2195
+ 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: [_jsx(Text, { color: "cyan", children: id }), " \u00B7 ", cut(title, Math.max(10, width - 34)), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " esegui \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: missing ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: [WARN, " task file non trovato \u00B7 le azioni restano attive (deck-run risolve la task per id)"] })) : (
2196
+ // Riga vuota → uno spazio: un `<Text>` senza contenuto Ink non lo
2197
+ // disegna, e il testo si compatterebbe perdendo la struttura del file.
2198
+ lines.map((l, i) => (_jsx(Text, { wrap: "truncate-end", children: l || ' ' }, top + i)))) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === action, color: i / 2 === action ? 'green' : 'gray', children: part }, i))), cutCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cutCount] }) : null] }) })] }));
2199
+ }
1974
2200
  /**
1975
2201
  * Larghezza del testo dentro la lista della schermata di assegnazione: box
1976
2202
  * esterno (2 bordi + 2 padding) + box lista (2 bordi + 2 padding).
@@ -2041,7 +2267,7 @@ function TasksHeader({ filtered, total, hidden, above, below, archivable, focuse
2041
2267
  const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns));
2042
2268
  return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, children: shown[i] }, i)) : null) }));
2043
2269
  }
2044
- function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, allCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, archivable, }) {
2270
+ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, allCount, childCount, focused, loadError, windowStart, above, below, columns, archivable, }) {
2045
2271
  const allSelected = selected === ROW_ALL;
2046
2272
  const spotSelected = selected === ROW_SPOT;
2047
2273
  return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(TasksHeader, { filtered: filtered, total: total, hidden: hidden, above: above, below: below, archivable: archivable, focused: focused, columns: columns }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut(sanitize(`sort: ${describeSort(view.sort)}` +
@@ -2069,7 +2295,7 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
2069
2295
  const tail = n > 0 ? ` (${n})` : '';
2070
2296
  const desc = cut(task.desc, Math.max(4, paneTextWidth(columns) - termWidth(head) - termWidth(tail)));
2071
2297
  return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, 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));
2072
- })), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
2298
+ }))] }));
2073
2299
  }
2074
2300
  /**
2075
2301
  * Header del pane sessioni, tagliato QUI e non da Ink.
@@ -2084,9 +2310,10 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
2084
2310
  * condiviso, `cutParts`), la resa è del pezzo. Il giallo su `📌N` distingue le
2085
2311
  * pinnate dal resto dell'header e non è decorazione.
2086
2312
  */
2087
- function SessionsHeader({ parentLabel, total, pinnedCount, hidden, above, below, focused, columns, }) {
2313
+ function SessionsHeader({ parentLabel, total, pinnedCount, liveCount, hidden, above, below, focused, columns, }) {
2088
2314
  const segments = [
2089
2315
  { text: `Sessions · ${parentLabel} (${total})` },
2316
+ ...(liveCount > 0 ? [{ text: ` · ${LIVE_IDLE}${liveCount} vive`, color: 'green' }] : []),
2090
2317
  ...(pinnedCount > 0 ? [{ text: ` · 📌${pinnedCount}`, color: 'yellow' }] : []),
2091
2318
  ...(hidden > 0 ? [{ text: ` · +${hidden} più vecchie`, dim: true }] : []),
2092
2319
  ...(above > 0 ? [{ text: ` · ↑${above}`, dim: true }] : []),
@@ -2095,8 +2322,8 @@ function SessionsHeader({ parentLabel, total, pinnedCount, hidden, above, below,
2095
2322
  const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns));
2096
2323
  return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, children: shown[i] }, i)) : null) }));
2097
2324
  }
2098
- function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, sessionNotes, projectCore, }) {
2099
- return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(SessionsHeader, { parentLabel: parentLabel, total: total, pinnedCount: pinnedCount, hidden: hidden, above: above, below: below, focused: focused, columns: columns }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isAll
2325
+ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, total, pinnedCount, hidden, selectedId, focused, above, below, columns, forkOf, sessionNotes, projectCore, live, liveCount, }) {
2326
+ return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(SessionsHeader, { parentLabel: parentLabel, total: total, pinnedCount: pinnedCount, liveCount: liveCount, hidden: hidden, above: above, below: below, focused: focused, columns: columns }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isAll
2100
2327
  ? 'nessuna conversazione nel progetto'
2101
2328
  : isSpot
2102
2329
  ? 'nessuna sessione libera'
@@ -2141,6 +2368,10 @@ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows,
2141
2368
  // task accanto, che esiste solo in questa vista.
2142
2369
  const bound = bindings.get(s.sessionId) ?? null;
2143
2370
  const linked = isAll ? Boolean(bound) : !isSpot;
2371
+ // T62 — liveness e binding sono ORTOGONALI: la cella marker dice a chi
2372
+ // appartiene la conversazione (pin/task/spot), questa dice se è aperta
2373
+ // adesso. Farle condividere una cella perderebbe una delle due.
2374
+ const liveEntry = live.get(s.sessionId);
2144
2375
  // Stesso motivo per cui la colonna esiste: una pinnata resta in lista
2145
2376
  // qualunque sia il parent selezionato, quindi l'header non ne dice
2146
2377
  // l'appartenenza e la cella va riempita anche fuori dalla vista
@@ -2163,6 +2394,7 @@ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows,
2163
2394
  (2 /* caret */ +
2164
2395
  2 /* marker */ +
2165
2396
  1 /* gutter */ +
2397
+ 1 /* T62 · colonna liveness */ +
2166
2398
  SID_CHARS +
2167
2399
  1 /* gutter */ +
2168
2400
  (taskW > 0 ? taskW + 1 : 0) +
@@ -2181,22 +2413,24 @@ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows,
2181
2413
  const used = (label.note ? termWidth(label.note) + 2 : 0) +
2182
2414
  (label.note && label.rest ? 1 : 0) +
2183
2415
  termWidth(label.rest);
2184
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', taskW > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: bound && taskCell ? 'green' : undefined, dimColor: !bound, children: pad(taskCell, taskW) }), ' '] })) : null, forkMark ? _jsx(Text, { color: "magenta", children: forkMark }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' '.repeat(Math.max(0, inner - used)), ' ', _jsx(Text, { dimColor: true, children: pad(age, ageW, 'right') })] }, s.sessionId));
2185
- })), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null, note: sessionNotes.get(detail.sessionId) ?? '' })) : null] }));
2416
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : undefined, children: liveEntry ? (liveEntry.status === 'busy' ? LIVE_BUSY : LIVE_IDLE) : LIVE_NONE }), _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : 'cyan', bold: Boolean(liveEntry), children: s.sessionId.slice(0, SID_CHARS) }), ' ', taskW > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: bound && taskCell ? 'green' : undefined, dimColor: !bound, children: pad(taskCell, taskW) }), ' '] })) : null, forkMark ? _jsx(Text, { color: "magenta", children: forkMark }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' '.repeat(Math.max(0, inner - used)), ' ', _jsx(Text, { dimColor: true, children: pad(age, ageW, 'right') })] }, s.sessionId));
2417
+ }))] }));
2418
+ }
2419
+ function PreviewPane(p) {
2420
+ return (_jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: p.kind === 'task' ? (_jsx(TaskPreview, { detail: p.detail, maxLines: p.maxLines, columns: p.columns })) : (_jsx(SessionPreview, { s: p.s, firstLines: p.firstLines, lastLines: p.lastLines, columns: p.columns, origin: p.origin, note: p.note, live: p.live })) }));
2186
2421
  }
2187
- // T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
2188
- // task. Tutti i campi vengono dal parse già cached dell'adapter (mtime-keyed):
2189
- // il pannello non costa I/O al movimento di selezione. Mostra "da dove parte,
2190
- // dove è arrivata": il primo prompt utente ( `) e l'ultima risposta del
2191
- // modello (`« `). La preview del primo prompt compare SOLO con un titolo custom
2192
- // senza, il titolo È già il primo prompt e la riga lo duplicherebbe (D4
2193
- // preflight). Le righe rese non superano mai il riservato dal budget
2194
- // (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
2195
- function SessionDetailPane({ s, firstLines, lastLines, columns, origin, note, }) {
2196
- const width = detailTextWidth(columns);
2422
+ // T49 — corpo della preview sessione. Tutti i campi vengono dal parse già
2423
+ // cached dell'adapter (mtime-keyed): non costa I/O al movimento di selezione.
2424
+ // Mostra "da dove parte, dove è arrivata": il primo prompt utente (`» `) e
2425
+ // l'ultima risposta del modello ( `). L'anteprima del primo prompt compare
2426
+ // SOLO con un titolo custom senza, il titolo È già il primo prompt e la riga
2427
+ // lo duplicherebbe (D4 preflight). Le righe rese non superano mai il riservato
2428
+ // dal budget (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
2429
+ function SessionPreview({ s, firstLines, lastLines, columns, origin, note, live, }) {
2430
+ const width = previewTextWidth(columns);
2197
2431
  const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
2198
2432
  const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
2199
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), " \u00B7 ", s.gitBranch || '-', origin ? ` · ⑂ da ${origin.slice(0, 8)}` : ''] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
2433
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), " \u00B7 ", s.gitBranch || '-', origin ? ` · ⑂ da ${origin.slice(0, 8)}` : '', live ? (_jsx(Text, { color: live.status === 'busy' ? 'yellow' : 'green', children: ` · ${live.status === 'busy' ? LIVE_BUSY : LIVE_IDLE} viva pid ${live.pid} (${live.status})` })) : null] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
2200
2434
  }
2201
2435
  /**
2202
2436
  * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
@@ -2211,15 +2445,16 @@ function detailMetaOf(detail) {
2211
2445
  return { meta, commit, metaLines: 1 + (meta ? 1 : 0) + (commit ? 1 : 0) };
2212
2446
  }
2213
2447
  /**
2214
- * Larghezza utile del testo di descrizione, ricavata dalle colonne del
2215
- * terminale: box esterno (2 bordi + 2 padding) → pane al 50% box dettaglio
2216
- * (2 bordi + 2 padding).
2448
+ * T70 — larghezza utile del testo dentro il blocco preview, che è a PIENA
2449
+ * larghezza: box esterno (2 bordi + 2 padding) → box preview (2 bordi + 2
2450
+ * padding). Niente più `/2`: il blocco non vive più dentro un pane al 50%,
2451
+ * quindi una riga di descrizione dispone del doppio delle colonne.
2217
2452
  *
2218
2453
  * Volutamente prudente: sottostimare tronca qualche carattere in più,
2219
2454
  * sovrastimare farebbe andare a capo una riga e sforare il tetto d'altezza.
2220
2455
  */
2221
- function detailTextWidth(columns) {
2222
- return Math.max(10, Math.floor(((columns || 80) - 4) / 2) - 9);
2456
+ function previewTextWidth(columns) {
2457
+ return Math.max(10, (columns || 80) - 8);
2223
2458
  }
2224
2459
  /**
2225
2460
  * Larghezza del TESTO dentro un pane al 50%: box esterno (2 bordi + 2 padding)
@@ -2235,14 +2470,15 @@ function detailTextWidth(columns) {
2235
2470
  function paneTextWidth(columns) {
2236
2471
  return Math.max(20, Math.floor(((columns || 80) - 4) / 2) - 4);
2237
2472
  }
2238
- function DetailPane({ detail, maxLines, columns, }) {
2473
+ /** Corpo della preview task: titolo, meta, descrizione wrappata, commit. */
2474
+ function TaskPreview({ detail, maxLines, columns, }) {
2239
2475
  const { meta, commit } = detailMetaOf(detail);
2240
2476
  // Wrap calcolato qui, non delegato a `<Text wrap="wrap">`: il budget ha
2241
2477
  // riservato ESATTAMENTE `maxLines` righe, e un wrap deciso da Ink a runtime
2242
2478
  // ne produrrebbe un numero che il budget non conosce — cioè il frame torna a
2243
2479
  // sforare e il bug si riapre da questa singola casella di testo.
2244
- const lines = wrapLines(detail.description ?? '', detailTextWidth(columns), maxLines);
2245
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: detail.title || detail.id }), meta ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: meta }) : null, lines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", children: line }, i))), commit ? _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", commit] }) : null] }));
2480
+ const lines = wrapLines(detail.description ?? '', previewTextWidth(columns), maxLines);
2481
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: detail.title || detail.id }), meta ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: meta }) : null, lines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", children: line }, i))), commit ? _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", commit] }) : null] }));
2246
2482
  }
2247
2483
  const cwd = process.cwd();
2248
2484
  render(_jsx(Deck, { cwd: cwd, tasksPath: resolveTasksPath(cwd), tasksDir: resolveTasksDir(cwd) }));
@@ -0,0 +1,135 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, join } from 'node:path';
4
+ function liveSessionsRoot() {
5
+ return join(homedir(), '.claude', 'sessions');
6
+ }
7
+ /**
8
+ * Campo 22 (`starttime`) di `/proc/<pid>/stat`, o null se la riga non è
9
+ * parsabile.
10
+ *
11
+ * Il taglio è sull'ULTIMA `)` e non su uno split ingenuo: il campo 2 (`comm`) è
12
+ * il nome dell'eseguibile fra parentesi e può contenere spazi e parentesi a sua
13
+ * volta, quindi tokenizzare da inizio riga sfasa tutti i campi successivi. Dopo
14
+ * l'ultima `)` il primo token è il campo 3 → `starttime` è l'indice 19.
15
+ */
16
+ export function procStartOf(statLine) {
17
+ const at = statLine.lastIndexOf(')');
18
+ if (at < 0)
19
+ return null;
20
+ const fields = statLine.slice(at + 1).trim().split(/\s+/);
21
+ return fields[19] ?? null;
22
+ }
23
+ /**
24
+ * Il processo `pid` è ANCORA quello che ha scritto il file di registry?
25
+ *
26
+ * Il registry è keyed su pid, e i pid si riciclano: senza questo confronto un
27
+ * pid riusato da un altro programma marcherebbe «aperta» una conversazione
28
+ * morta, e un'entry stale lasciata da un `kill -9` (il CLI non fa in tempo a
29
+ * ripulire) resterebbe viva per sempre. Il guard sta dentro il file stesso —
30
+ * `procStart` è esattamente il campo 22 di `/proc/<pid>/stat`, verificato — e
31
+ * quel campo è il tempo di avvio in tick dal boot: irripetibile per un pid
32
+ * riciclato, che parte per definizione dopo.
33
+ */
34
+ function isAlive(pid, procStart) {
35
+ let stat;
36
+ try {
37
+ stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
38
+ }
39
+ catch {
40
+ return false; // processo finito → nessun /proc/<pid>
41
+ }
42
+ return procStartOf(stat) === procStart;
43
+ }
44
+ /**
45
+ * Semantica di UNA entry del registry, separata dalla lettura del disco: qui
46
+ * c'è solo cosa rende un record utilizzabile, ed è l'unica parte che vale la
47
+ * pena testare (su una stringa, senza inventare un finto `~/.claude`).
48
+ *
49
+ * `pid` arriva dal NOME del file e non dal campo omonimo: è il nome che decide
50
+ * quale `/proc/<pid>/stat` interrogare, e far divergere le due cose renderebbe
51
+ * il guard verificabile su un processo diverso da quello controllato.
52
+ */
53
+ export function parseLiveEntry(content, pid) {
54
+ let d;
55
+ try {
56
+ d = JSON.parse(content);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ const sessionId = typeof d.sessionId === 'string' ? d.sessionId : '';
62
+ const procStart = typeof d.procStart === 'string' ? d.procStart : '';
63
+ const cwd = typeof d.cwd === 'string' ? d.cwd : '';
64
+ // Senza uno dei tre l'entry non è utilizzabile: `sessionId` è la chiave del
65
+ // join con la lista, `procStart` è il guard anti-pid-riciclato (assente →
66
+ // non si può stabilire la liveness, e assumerla sarebbe il falso positivo che
67
+ // il guard esiste per escludere), `cwd` è il filtro di progetto.
68
+ if (!sessionId || !procStart || !cwd)
69
+ return null;
70
+ return {
71
+ entry: {
72
+ sessionId,
73
+ pid,
74
+ status: d.status === 'busy' ? 'busy' : 'idle',
75
+ name: typeof d.name === 'string' ? d.name : '',
76
+ cwd,
77
+ },
78
+ procStart,
79
+ };
80
+ }
81
+ /**
82
+ * Le sessioni VIVE del solo progetto corrente, indicizzate per `sessionId` —
83
+ * la stessa chiave del sidecar `session-tasks.jsonl` e di `deck-run --resume`,
84
+ * quindi il join con la riga di lista è diretto.
85
+ *
86
+ * Il filtro per `cwd` è d'EFFICIENZA, non di correttezza: risparmia lo `stat`
87
+ * di `/proc` sui processi degli altri progetti (il registry è globale, non
88
+ * per-progetto). Il match resta sul `sessionId`.
89
+ *
90
+ * Nessuna cache mtime-keyed come in `sessions.ts`: le entry sono poche e di
91
+ * poche centinaia di byte, mentre `status` cambia a ogni turno — una cache qui
92
+ * mostrerebbe `idle` su una sessione che sta lavorando, cioè il difetto che la
93
+ * colonna esiste per non avere.
94
+ */
95
+ export function discoverLiveSessions(projectRoot) {
96
+ const out = new Map();
97
+ let files;
98
+ try {
99
+ files = readdirSync(liveSessionsRoot()).filter((f) => f.endsWith('.json'));
100
+ }
101
+ catch {
102
+ return out; // registry assente (CLI mai avviato, o versione che non lo scrive)
103
+ }
104
+ for (const f of files) {
105
+ const pid = Number(basename(f, '.json'));
106
+ if (!Number.isInteger(pid) || pid <= 0)
107
+ continue;
108
+ let content;
109
+ try {
110
+ content = readFileSync(join(liveSessionsRoot(), f), 'utf8');
111
+ }
112
+ catch {
113
+ continue; // il processo può finire fra readdir e read
114
+ }
115
+ const parsed = parseLiveEntry(content, pid);
116
+ if (!parsed)
117
+ continue;
118
+ const { entry, procStart } = parsed;
119
+ if (entry.cwd !== projectRoot && !entry.cwd.startsWith(projectRoot + '/'))
120
+ continue;
121
+ if (!isAlive(pid, procStart))
122
+ continue;
123
+ out.set(entry.sessionId, entry);
124
+ }
125
+ return out;
126
+ }
127
+ /** Signature del poll: cambia quando cambia l'insieme delle vive O lo stato di
128
+ * una di esse. Il `pid` ci sta dentro perché un `/clear` sposta lo stesso
129
+ * `sessionId` su un processo diverso senza toccare né insieme né stato. */
130
+ export function liveSig(live) {
131
+ return [...live.values()]
132
+ .map((l) => `${l.sessionId}:${l.pid}:${l.status}`)
133
+ .sort()
134
+ .join(',');
135
+ }
package/dist/tasks.js CHANGED
@@ -121,6 +121,29 @@ export function parseTaskDetail(id, content) {
121
121
  description: sanitize(descLines.join('\n').trim()),
122
122
  };
123
123
  }
124
+ /**
125
+ * Testo INTEGRALE del task file (T66 · detail).
126
+ *
127
+ * Distinto da `loadTaskDetail`, che estrae header e Description per il blocco
128
+ * preview: qui si legge la task per intero — Acceptance, Deliverables,
129
+ * Implementation Notes — e un parse che tenesse solo le sezioni note
130
+ * nasconderebbe proprio ciò per cui si apre l'overlay.
131
+ *
132
+ * `sanitize` al confine come per il detail parsato: il task file è testo libero
133
+ * e porta glifi che Ink e il terminale misurano diversamente. Non torna mai su
134
+ * disco da qui, quindi la sostituzione non ha il rischio di `pri`/`prog`.
135
+ */
136
+ export function loadTaskFileText(dir, id) {
137
+ const path = findTaskFile(dir, id);
138
+ if (!path)
139
+ return null;
140
+ try {
141
+ return sanitize(readFileSync(path, 'utf8'));
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
124
147
  export function loadTaskDetail(dir, id) {
125
148
  const path = findTaskFile(dir, id);
126
149
  if (!path)
package/dist/viewport.js CHANGED
@@ -17,25 +17,25 @@
17
17
  * basterebbe 1; ne teniamo 1 come margine per l'a-capo del cursore. */
18
18
  export const SLACK = 1;
19
19
  /** Sotto questa soglia la lista task non è più utilizzabile: meglio sacrificare
20
- * il pannello di dettaglio che ridurre la lista a due righe. */
20
+ * il blocco preview che ridurre la lista a due righe. */
21
21
  const MIN_TASK_ROWS = 3;
22
- /** Il dettaglio è secondario: non si prende mai più di così, anche con spazio. */
23
- const MAX_DETAIL_LINES = 4;
24
- /** Righe di "cornice" fisse dei tre contenitori a lunghezza variabile. */
22
+ /** La preview è secondaria: non si prende mai più di così, anche con spazio.
23
+ * A piena larghezza (T70) una riga contiene il doppio del testo di prima,
24
+ * quindi il tetto sale insieme allo spazio: il blocco è uno solo e lo paga
25
+ * una volta sola per entrambi i pane. */
26
+ const MAX_DETAIL_LINES = 6;
27
+ /** Righe di "cornice" fisse dei contenitori a lunghezza variabile. */
25
28
  // T59 — le righe meta sono DUE (`≡ tutte`, `○ spot`): ogni riga fissa aggiunta
26
29
  // a un pane va scalata qui, o il frame sfonda `rows` e Ink passa a
27
30
  // clearTerminal (frame-fantasma nello scrollback di VTE).
28
31
  const TASKS_PANE_CHROME = 6; // 2 bordi + header "Tasks (n)" + riga sort + 2 righe meta
29
32
  const SESSIONS_PANE_CHROME = 3; // 2 bordi + header "Sessions · …"
30
- const DETAIL_CHROME = 3; // marginTop + 2 bordi
31
- /** Detail pane sessione (T49): righe fisse = titolo + riga meta (size · turni ·
32
- * ultima attività). Le preview (primo prompt + ultima risposta) sono variabili,
33
- * ciascuna al più MAX_SESSION_PREVIEW righe. */
33
+ const PREVIEW_CHROME = 3; // marginTop + 2 bordi
34
+ /** Preview sessione: righe fisse = titolo + riga meta (size · turni · ultima
35
+ * attività · branch). Le due anteprime (primo prompt + ultima risposta) sono
36
+ * variabili, ciascuna al più MAX_SESSION_PREVIEW righe. */
34
37
  const SESSION_DETAIL_FIXED = 2;
35
- const MAX_SESSION_PREVIEW = 2;
36
- /** Gemello di MIN_TASK_ROWS: sotto questa soglia la lista sessioni non serve
37
- * più — meglio sacrificare il detail pane. */
38
- const MIN_SESSION_ROWS = 3;
38
+ const MAX_SESSION_PREVIEW = 3;
39
39
  /** Altezza di ciascuna modale, marginTop incluso. In flusso, non in overlay:
40
40
  * spingono giù i pane, quindi il loro costo va scalato dal budget. */
41
41
  export const MODAL_HEIGHT = {
@@ -57,16 +57,28 @@ export const MODAL_HEIGHT = {
57
57
  // pagherebbe due volte (il suo costo + quello che toglie ai pane sotto, che
58
58
  // in quel momento non si stanno nemmeno guardando).
59
59
  assign: 0,
60
+ // T66 — il detail della task: task file scrollabile + barra azioni. Quarta
61
+ // sostitutiva, stesso motivo delle altre tre — un task file da 200 righe non
62
+ // entra in un box sopra i pane. La sua altezza la distribuisce
63
+ // `detailCapacity`.
64
+ detail: 0,
60
65
  };
61
66
  /**
62
- * Distribuisce le righe disponibili fra lista task, lista sessioni e dettaglio.
67
+ * Distribuisce le righe disponibili fra lista task, lista sessioni e preview.
63
68
  *
64
69
  * I due pane stanno affiancati (flexDirection row) → l'altezza del blocco è il
65
70
  * MAX delle due colonne, non la somma: ognuna riceve lo stesso tetto.
66
71
  *
72
+ * T70 — il blocco preview è UNO e sta SOTTO i due pane, non dentro uno di essi:
73
+ * il suo costo esce quindi da `avail` PRIMA della distribuzione, e ciò che
74
+ * resta va a entrambe le colonne. Con i due pannelli separati il costo gravava
75
+ * su una colonna sola, e siccome l'altezza del blocco è il max delle due, il
76
+ * pannello del pane meno alto era di fatto gratis: contabilità che qui non
77
+ * esiste più.
78
+ *
67
79
  * Ordine di sacrificio quando lo spazio stringe:
68
- * 1. righe di descrizione del dettaglio (fino a sparire col pannello),
69
- * 2. righe della lista task, mai sotto MIN_TASK_ROWS finché il dettaglio c'è.
80
+ * 1. righe variabili della preview (fino a sparire col blocco),
81
+ * 2. righe delle liste, mai sotto MIN_TASK_ROWS finché la preview c'è.
70
82
  */
71
83
  export function layoutBudget(input) {
72
84
  const outerChrome = 2 + // bordi del box esterno
@@ -86,58 +98,63 @@ export function layoutBudget(input) {
86
98
  return {
87
99
  taskRows: 0,
88
100
  sessionRows: 0,
101
+ preview: false,
89
102
  detailLines: 0,
90
- sessionDetail: false,
91
103
  sessionFirstLines: 0,
92
104
  sessionLastLines: 0,
93
105
  compact: true,
94
106
  };
95
107
  }
96
- // Detail pane sessione (T49): stesso schema del dettaglio task prima la
97
- // lista minima, poi la cornice, le preview solo con lo spazio che avanza.
98
- // A differenza del dettaglio task il box regge anche senza righe variabili:
99
- // titolo + meta sono il valore, le preview (primo prompt + ultima risposta)
100
- // sono bonus. Priorità al primo prompt, poi l'ultima risposta prende ciò che
101
- // resta: su terminale stretto cade prima l'ultima risposta, non il primo.
102
- // Riservo righe solo per le preview che davvero renderizzeranno (i due
103
- // `has…Preview`), così non sottraggo righe alla lista per un blocco vuoto.
104
- let sessionDetail = false;
105
- let sessionDetailCost = 0;
108
+ // Pavimento sotto cui la preview non si concede: la lista minima del pane
109
+ // TASK, cioè il più caro in cornice dei due. Garantito lui, il pane sessioni
110
+ // (3 di cornice contro 6) sta comodo per costruzione un secondo controllo
111
+ // sul suo minimo sarebbe una condizione che non può mai scattare.
112
+ const floor = TASKS_PANE_CHROME + MIN_TASK_ROWS;
113
+ let preview = false;
114
+ let previewCost = 0;
115
+ let detailLines = 0;
106
116
  let sessionFirstLines = 0;
107
117
  let sessionLastLines = 0;
108
- if (input.hasSessionDetail) {
109
- const fixed = DETAIL_CHROME + SESSION_DETAIL_FIXED;
110
- const spare = avail - SESSIONS_PANE_CHROME - MIN_SESSION_ROWS - fixed;
118
+ if (input.preview === 'task') {
119
+ const fixed = PREVIEW_CHROME + input.detailMetaLines;
120
+ // Serve almeno 1 riga di descrizione per giustificare il blocco: titolo e
121
+ // meta sono già nella riga di lista, quindi una cornice senza descrizione
122
+ // ruberebbe righe alle liste per non aggiungere niente.
123
+ const spare = avail - floor - fixed;
124
+ if (spare >= 1) {
125
+ preview = true;
126
+ detailLines = Math.min(MAX_DETAIL_LINES, spare);
127
+ previewCost = fixed + detailLines;
128
+ }
129
+ }
130
+ else if (input.preview === 'session') {
131
+ const fixed = PREVIEW_CHROME + SESSION_DETAIL_FIXED;
132
+ const spare = avail - floor - fixed;
133
+ // A differenza della task il blocco regge anche a zero righe variabili:
134
+ // titolo e riga meta (size, turni, data, branch) non stanno da nessun'altra
135
+ // parte a schermo, quindi sono già il valore. Priorità al primo prompt, poi
136
+ // l'ultima risposta prende ciò che resta: quando lo spazio stringe cade
137
+ // prima l'ultima risposta, non il primo prompt. Le righe si riservano solo
138
+ // per le anteprime che davvero renderizzeranno (i due `has…Preview`), o si
139
+ // toglierebbero righe alle liste per un blocco vuoto.
111
140
  if (spare >= 0) {
112
- sessionDetail = true;
141
+ preview = true;
113
142
  if (input.sessionHasFirstPreview)
114
143
  sessionFirstLines = Math.min(MAX_SESSION_PREVIEW, spare);
115
144
  if (input.sessionHasLastPreview) {
116
145
  sessionLastLines = Math.min(MAX_SESSION_PREVIEW, spare - sessionFirstLines);
117
146
  }
118
- sessionDetailCost = fixed + sessionFirstLines + sessionLastLines;
119
- }
120
- }
121
- const sessionRows = Math.max(0, avail - SESSIONS_PANE_CHROME - sessionDetailCost);
122
- let detailLines = 0;
123
- let detailChrome = 0;
124
- if (input.hasDetail) {
125
- const fixed = DETAIL_CHROME + input.detailMetaLines;
126
- // Righe che avanzano dopo aver garantito la lista minima e la cornice del
127
- // dettaglio. Serve almeno 1 riga di descrizione per giustificare il box:
128
- // un pannello con la sola cornice ruberebbe 3+ righe per mostrare nulla.
129
- const spare = avail - TASKS_PANE_CHROME - MIN_TASK_ROWS - fixed;
130
- if (spare >= 1) {
131
- detailChrome = fixed;
132
- detailLines = Math.min(MAX_DETAIL_LINES, spare);
147
+ previewCost = fixed + sessionFirstLines + sessionLastLines;
133
148
  }
134
149
  }
135
- const taskRows = Math.max(0, avail - TASKS_PANE_CHROME - detailChrome - detailLines);
150
+ // Ciò che resta va a ENTRAMBE le colonne: la preview sta sotto il blocco dei
151
+ // pane, quindi il suo costo lo pagano insieme una volta sola.
152
+ const listAvail = avail - previewCost;
136
153
  return {
137
- taskRows,
138
- sessionRows,
154
+ taskRows: Math.max(0, listAvail - TASKS_PANE_CHROME),
155
+ sessionRows: Math.max(0, listAvail - SESSIONS_PANE_CHROME),
156
+ preview,
139
157
  detailLines,
140
- sessionDetail,
141
158
  sessionFirstLines,
142
159
  sessionLastLines,
143
160
  compact: false,
@@ -192,6 +209,25 @@ const READER_CHROME = 8;
192
209
  export function readerCapacity(rows) {
193
210
  return Math.max(0, (rows || 24) - SLACK - READER_CHROME);
194
211
  }
212
+ // T66 — cornice del detail della task.
213
+ // 2 bordi del box esterno
214
+ // 1 titolo "loom-deck"
215
+ // 1 riga meta (id · titolo · posizione nel testo)
216
+ // 1 riga hint
217
+ // 1 marginTop del box testo
218
+ // 2 bordi del box testo
219
+ // 1 marginTop della riga azioni
220
+ // 1 riga azioni
221
+ //
222
+ // Le ultime due sono il motivo per cui il detail non può riusare READER_CHROME:
223
+ // la barra bottoni è una riga FISSA in più dentro l'overlay, e ogni riga fissa
224
+ // aggiunta va scalata dalla capienza del contenuto o il frame sfonda `rows`
225
+ // (stessa invariante di TASKS_PANE_CHROME).
226
+ const DETAIL_CHROME = 10;
227
+ /** Righe di task file che entrano nel terminale. */
228
+ export function detailCapacity(rows) {
229
+ return Math.max(0, (rows || 24) - SLACK - DETAIL_CHROME);
230
+ }
195
231
  // T52 — cornice del pannello di anteprima sotto la lista occorrenze:
196
232
  // marginTop + 2 bordi + riga meta.
197
233
  const SEARCH_PREVIEW_CHROME = 4;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.27.3",
3
+ "version": "0.29.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
@@ -11,7 +11,7 @@
11
11
  # Tre assi ORTOGONALI, da non confondere fra loro:
12
12
  # binding task <TaskID> ..... --no-task
13
13
  # continuità nuova ........ --resume [--fork]
14
- # prompt --prompt-kind none|recap|preflight|run
14
+ # prompt --prompt-kind none|recap|preflight|run|checkpoint
15
15
  # Il terzo asse (T56) prima non esisteva: il prompt era una CONSEGUENZA degli
16
16
  # altri due (bound ⇒ recap, --no-task ⇒ niente, --resume ⇒ niente), quindi
17
17
  # "task-bound SENZA prompt" era inesprimibile e l'unico modo di non avere un
@@ -74,7 +74,7 @@ PROMPT_KIND=""
74
74
  # Sotto --fork il --session-id torna AMMESSO (anzi: è il punto) — la mutua
75
75
  # esclusione con --resume esiste perché una ripresa nuda riscrive il transcript
76
76
  # dell'id ripreso, mentre il fork ne apre uno NUOVO, che possiamo quindi pinnare.
77
- # --prompt-kind <none|recap|preflight|run> (T56): sceglie il prompt iniziale fra
77
+ # --prompt-kind <none|recap|preflight|run|checkpoint> (T56, T66): sceglie il prompt iniziale fra
78
78
  # quelli del catalogo qui sotto. Enum e non stringa libera: il prompt viaggia
79
79
  # dentro apici singoli in `bash -lc`, quindi il testo va tenuto in un posto solo
80
80
  # e verificato una volta, invece di spostare il rischio di quoting su ogni
@@ -125,6 +125,7 @@ USAGE="uso: deck-run <TaskID> [--prompt-kind <kind>] [--session-id <uuid>]
125
125
  recap recap stato task <TaskID>
126
126
  preflight /loom-works:preflight-task <TaskID>
127
127
  run /loom-works:run-task <TaskID> (run-doc sulle task D)
128
+ checkpoint /loom-works:checkpoint-task <TaskID>
128
129
 
129
130
  --title-note nota della conversazione, appesa al titolo tab come «nota»
130
131
  (ridotta a lettere/cifre/spazi/-/_, cap 60 char)"
@@ -154,8 +155,8 @@ fi
154
155
  # lì il valore arriva da un file editabile a mano, qui da un argomento nostro.
155
156
  if [[ -n "$PROMPT_KIND" ]]; then
156
157
  case "$PROMPT_KIND" in
157
- none|recap|preflight|run) ;;
158
- *) echo "--prompt-kind ignoto: '${PROMPT_KIND}' (usa none|recap|preflight|run)" >&2
158
+ none|recap|preflight|run|checkpoint) ;;
159
+ *) echo "--prompt-kind ignoto: '${PROMPT_KIND}' (usa none|recap|preflight|run|checkpoint)" >&2
159
160
  echo "$USAGE" >&2
160
161
  exit 2 ;;
161
162
  esac
@@ -336,11 +337,12 @@ FORK_FLAG=""
336
337
  # Il prompt iniziale è la scelta di CHI APRE la sessione, non una proprietà della
337
338
  # task: lo stesso task file si apre per leggerne lo stato, per un preflight, per
338
339
  # eseguirlo o a mani nude. Il kind è quell'intento.
339
- # none → nessun prompt (≠ stringa vuota: PROMPT_ARG sparisce del tutto,
340
- # come sul ramo --resume, altrimenti CC riceve un posizionale vuoto)
341
- # recap → prompt DIRETTO, no skill: l'utente rivede/steera prima di lanciare
342
- # preflight → skill di preflight sulla task
343
- # run → skill di esecuzione sulla task
340
+ # none → nessun prompt (≠ stringa vuota: PROMPT_ARG sparisce del tutto,
341
+ # come sul ramo --resume, altrimenti CC riceve un posizionale vuoto)
342
+ # recap → prompt DIRETTO, no skill: l'utente rivede/steera prima di lanciare
343
+ # preflight → skill di preflight sulla task
344
+ # run → skill di esecuzione sulla task
345
+ # checkpoint → skill di checkpoint sulla task
344
346
  # Vincolo di quoting: nessun template deve contenere apici singoli (viaggia
345
347
  # dentro '...' nel comando in-tab). `/loom-works:...` passa intero proprio perché
346
348
  # è single-quoted — lo slash e i due punti non sono metacaratteri lì dentro.
@@ -355,16 +357,19 @@ FORK_FLAG=""
355
357
  # del contratto loom (`T` code, `D` doc) e le due hanno skill di esecuzione
356
358
  # diverse — `run-task` è dichiarata code-only, `run-doc` è la sua complementare.
357
359
  # Su una D il prompt `run-task` invocherebbe la skill sbagliata. Gli altri kind
358
- # non discriminano: `recap` è un prompt diretto (nessuna skill) e
359
- # `preflight-task` risolve il task file per id, indifferente al prefisso.
360
+ # non discriminano, ma è una verifica da rifare per OGNI kind nuovo, non una
361
+ # proprietà del catalogo: `recap` è un prompt diretto (nessuna skill),
362
+ # `preflight-task` risolve il task file per id, e `checkpoint-task` estrae il
363
+ # taskId con `T\d+|D\d+` — copre quindi entrambi i prefissi da sé.
360
364
  case "${PROMPT_KIND:-recap}" in
361
- none) PROMPT="" ;;
362
- recap) PROMPT="recap stato task ${TASK}" ;;
363
- preflight) PROMPT="/loom-works:preflight-task ${TASK}" ;;
364
- run) case "$TASK" in
365
- D*) PROMPT="/loom-works:run-doc ${TASK}" ;;
366
- *) PROMPT="/loom-works:run-task ${TASK}" ;;
367
- esac ;;
365
+ none) PROMPT="" ;;
366
+ recap) PROMPT="recap stato task ${TASK}" ;;
367
+ preflight) PROMPT="/loom-works:preflight-task ${TASK}" ;;
368
+ checkpoint) PROMPT="/loom-works:checkpoint-task ${TASK}" ;;
369
+ run) case "$TASK" in
370
+ D*) PROMPT="/loom-works:run-doc ${TASK}" ;;
371
+ *) PROMPT="/loom-works:run-task ${TASK}" ;;
372
+ esac ;;
368
373
  esac
369
374
  if [[ "${PROMPT_KIND:-}" != "none" && -n "${LOOM_DECK_ENTER_PROMPT:-}" ]]; then
370
375
  PROMPT="${LOOM_DECK_ENTER_PROMPT//\{TASK\}/${TASK}}"