@lamemind/loom-deck 0.76.0 → 0.78.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
@@ -387,11 +387,34 @@ npx @lamemind/loom-deck
387
387
  ## Requisiti runtime
388
388
 
389
389
  - **Node.js ≥ 18** (dichiarato in `engines`).
390
- - **[Ptyxis](https://gitlab.gnome.org/chergert/ptyxis)** (terminale GNOME) — **dipendenza di runtime, non risolvibile da npm**. Lo spawn di una tab (`scripts/deck-run`) invoca il binario `ptyxis`: l'**install riesce anche senza**, ma al momento dello spawn il comando
390
+ - **[Ptyxis](https://gitlab.gnome.org/chergert/ptyxis)** (terminale GNOME), su Linux — **dipendenza di runtime, non risolvibile da npm**. Lo spawn di una tab (`scripts/deck-run`) invoca il binario `ptyxis`: l'**install riesce anche senza**, ma al momento dello spawn il comando
391
391
  fallisce (gestito: handler `error` → la TUI resta viva, mostra la nota). Senza una GUI
392
392
  GNOME con Ptyxis installato, il deck naviga i task ma **non apre sessioni**.
393
393
  - **[Claude Code](https://claude.com/claude-code)** nel `PATH` — la tab spawnata avvia `claude`.
394
394
 
395
+ ### Su Windows
396
+
397
+ Le tab si aprono in **Windows Terminal** (`wt.exe`, l'alias d'esecuzione che l'installazione dello Store mette nel `PATH`), nella finestra usata per ultima: `wt.exe -w 0 new-tab -p loom-deck -d <root> --title <titolo> --suppressApplicationTitle -- <node> <dist/tab-wrapper.js>`. L'argv di `claude` — nome, flag, prompt — non sta su quella riga: viaggia come JSON nella variabile `LOOM_DECK_TAB_ARGV`, che la tab eredita dal processo che lancia `wt.exe`, e il wrapper lo passa a `claude` senza shell. Il prompt non passa quindi né dal parser di `wt`, che legge `;` come separatore di sottocomandi in qualunque argomento, né da cmd o PowerShell.
398
+
399
+ Le tab usano il profilo di Windows Terminal **`loom-deck`**, uno solo per tutti i progetti. Il deck non lo crea e non scrive `settings.json`: lo si aggiunge a mano in `%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json`, sotto `profiles.list`, nascosto dal menu. Porta solo ciò che i flag di `wt` non sanno dire — titolo, cartella e comando li passa il deck, e battono quelli del profilo:
400
+
401
+ ```jsonc
402
+ {
403
+ "name": "loom-deck",
404
+ "guid": "{<un GUID nuovo>}",
405
+ "hidden": true,
406
+ "colorScheme": "<nome dello schema, es. One Half Light>",
407
+ "commandline": "%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoLogo",
408
+ "closeOnExit": "graceful"
409
+ }
410
+ ```
411
+
412
+ `colorScheme` fissa i colori di tutte le tab del deck: è il nome di uno schema predefinito di Windows Terminal o di uno dichiarato sotto `schemes` nello stesso `settings.json`. `commandline` è la shell del tasto `t`; nelle conversazioni il comando del deck prende il suo posto. `closeOnExit` decide cosa resta della tab quando `claude` esce (il wrapper esce col suo codice). Senza il profilo la tab si apre comunque, col profilo di default. Il nome si cambia con `LOOM_DECK_WT_PROFILE` (vuota = nessun `-p`).
413
+
414
+ La tab eredita tutto l'env del deck, tranne i marker che una sessione Claude Code inietta nei figli (`CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, …): con quelli una conversazione aperta da un deck lanciato dentro Claude Code non salverebbe il transcript. Se manca, il deck aggiunge `CLAUDE_CODE_GIT_BASH_PATH`: col `PATH` di Git Bash `claude` eseguirebbe gli hook del plugin in PowerShell, e la task non arriverebbe in contesto.
415
+
416
+ Su Windows il resume di una conversazione e il restore (`^T`) restano spenti con un avviso finché il deck non sa quali conversazioni sono vive: senza quella informazione un resume può riaprire una conversazione già aperta in un'altra tab. Il fork resta acceso, perché apre un transcript nuovo.
417
+
395
418
  ## Uso (spike ①)
396
419
 
397
420
  ```bash
package/dist/actions.js CHANGED
@@ -17,13 +17,15 @@
17
17
  // migliorano di passaggio.
18
18
  import { useRef } from 'react';
19
19
  import { randomUUID } from 'node:crypto';
20
+ import { existsSync } from 'node:fs';
21
+ import { conversationCwd } from './project-path.js';
20
22
  import { taskSlug } from './tasks.js';
21
23
  import { appendNote, appendPin, appendPriority, appendSessionRecord, appendTaskBinding, } from './task-index.js';
22
24
  import { neighborId } from './session-list.js';
23
25
  import { cut, cutMiddle } from './width.js';
24
26
  import { dropSessionFiles, sessionArtifacts } from './session-drop.js';
25
27
  import { saveView, viewFilePath } from './view-store.js';
26
- import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, DECK_RUN, } from './spawn.js';
28
+ import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, terminalName, DECK_RUN, } from './spawn.js';
27
29
  import { MODEL_DEFAULT } from './spawn-catalog.js';
28
30
  import { modelAlias } from './glyphs.js';
29
31
  import { resolveSpawnId } from './spawn-config.js';
@@ -103,7 +105,7 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
103
105
  }
104
106
  /** I buchi di un'azione su task. Lo slug viene dal NOME del task file, non
105
107
  * dalla descrizione di `tasks.md`: il nome è già dentro l'alfabeto di
106
- * `_sane_note` per costruzione — minuscolo, separato da trattini, senza
108
+ * `saneNote` per costruzione — minuscolo, separato da trattini, senza
107
109
  * punteggiatura — mentre la descrizione porta apostrofi e `/` che la riduzione
108
110
  * toglie senza sostituto, saldando le parole. Task file assente → slug vuoto,
109
111
  * e il titolo si spegne invece di ridursi al solo prefisso. */
@@ -220,9 +222,22 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
220
222
  // ricerca e il restore il modello ereditato dalla sessione senza superficie
221
223
  // di scelta (D1 preflight). I chiamanti restano la stessa azione perché è
222
224
  // lo spawn a essere identico, non la sorgente del valore.
225
+ /**
226
+ * T176 — la cartella della tab di una ripresa o di un fork: la root su Linux,
227
+ * la cwd registrata dalla conversazione su Windows (`conversationCwd`, P7
228
+ * preflight). Una cartella che non esiste più ricade sulla root: lo spawn
229
+ * fallirebbe prima ancora di aprire la tab.
230
+ */
231
+ function tabCwdOf(sessionId) {
232
+ const s = model.sessions.find((x) => x.sessionId === sessionId);
233
+ if (!s)
234
+ return cwd;
235
+ const dir = conversationCwd(s.cwd, s.path, cwd);
236
+ return dir === cwd || existsSync(dir) ? dir : cwd;
237
+ }
223
238
  function resumeSession(sessionId, modelKind) {
224
239
  const bound = model.bindings.get(sessionId) ?? null;
225
- const spawned = spawnDeckResume(bound, cwd, sessionId, modelKind, model.sessionNotes.get(sessionId));
240
+ const spawned = spawnDeckResume(bound, tabCwdOf(sessionId), sessionId, modelKind, model.sessionNotes.get(sessionId));
226
241
  spawned.child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
227
242
  noteSpawn(spawned);
228
243
  return spawned;
@@ -242,6 +257,12 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
242
257
  function openSession(sessionId, modelKind) {
243
258
  if (refuse('spawn', '⏎', setNote, 'resume'))
244
259
  return;
260
+ // T176 — il resume vuole sapere se la conversazione è viva, non solo poter
261
+ // aprire una tab (D4 preflight): senza liveness un `⏎` su una conversazione
262
+ // aperta in un'altra tab avvierebbe un secondo `claude` sullo stesso
263
+ // transcript. Spento finché la piattaforma non ha le sessioni vive.
264
+ if (refuse('liveSessions', '⏎', setNote, 'resume'))
265
+ return;
245
266
  enterSession(sessionId, {
246
267
  isLive: (id) => model.live.has(id),
247
268
  nameOf: (id) => model.live.get(id)?.name || id.slice(0, 8),
@@ -275,6 +296,12 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
275
296
  * chiudendo le tab.
276
297
  */
277
298
  function restoreSessions() {
299
+ // T176 — la guardia delle sessioni vive PRIMA di quella dello spawn (P8
300
+ // preflight): l'insieme da riaprire è l'ultimo insieme vivo, e senza
301
+ // liveness sarebbe vuoto — «nessuna conversazione da riaprire» direbbe uno
302
+ // stato dove manca una funzione.
303
+ if (refuse('liveSessions', '^T', setNote, 'restore delle conversazioni'))
304
+ return;
278
305
  if (refuse('spawn', '^T', setNote, 'restore delle conversazioni'))
279
306
  return;
280
307
  const list = model.restorable;
@@ -406,7 +433,7 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
406
433
  // T148 — il fork è l'altra continuità che condivide la stessa riga
407
434
  // selezionata: porta via lo stesso `model.resumeModel` del resume, non un
408
435
  // default fisso (P2 preflight).
409
- const spawned = spawnDeckFork(bound, cwd, s.sessionId, newId, model.resumeModel);
436
+ const spawned = spawnDeckFork(bound, tabCwdOf(s.sessionId), s.sessionId, newId, model.resumeModel);
410
437
  spawned.child.on('error', () => setNote(`⚠ fork fallito (${DECK_RUN})`));
411
438
  noteSpawn(spawned);
412
439
  }
@@ -533,7 +560,7 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
533
560
  return;
534
561
  const title = model.identity ? `🖥️ ${model.identity.name} [term]` : null;
535
562
  const child = spawnTerminal(cwd, title);
536
- child.on('error', () => setNote('⚠ t → ptyxis non lanciabile'));
563
+ child.on('error', () => setNote(`⚠ t → ${terminalName()} non lanciabile`));
537
564
  setNote(`t → terminale su ${model.projectName}`);
538
565
  }
539
566
  /**
package/dist/config.js CHANGED
@@ -115,7 +115,7 @@ export function launchLegend(entries, columns, reserved = 0) {
115
115
  // `identity` sopra sono strutture. La catena da percorrere si ferma qui: nessun
116
116
  // passaggio da `lib-config.sh`/`reg_pull`/dconf, perché il solo consumer è il
117
117
  // deck e il file ce l'ha sotto mano. Il precedente esatto è `permissionMode`,
118
- // che vive nel file, lo legge `deck-run` via jq e non è propagato al registry.
118
+ // che vive nel file, lo legge anche `deck-run` e non è propagato al registry.
119
119
  /** Interi positivi soltanto: uno 0 spegnerebbe la soglia (tutte le Done
120
120
  * archiviabili), un negativo o un decimale sono un typo. Valore fuori dominio
121
121
  * → default, mai passaggio cieco di un numero senza senso al conteggio. */
@@ -0,0 +1,7 @@
1
+ // T176 — il punto d'ingresso di `deck-run`: `scripts/deck-run` (lo shim bash
2
+ // che compass e i test lanciano) e il deck su Windows eseguono questo file col
3
+ // proprio node. La logica sta in `tab-launch.ts`, che si testa senza aprire tab.
4
+ import { realIo, runDeckRun } from './tab-launch.js';
5
+ runDeckRun(process.argv.slice(2), realIo()).then((code) => {
6
+ process.exitCode = code;
7
+ });
@@ -0,0 +1,139 @@
1
+ // T189 — i DELIVERABLE di un task file, e le due regole del plugin che il deck
2
+ // replica per offrirli nel detail.
3
+ //
4
+ // Il deck non esegue niente qui: legge i DLV per mostrarli e per comporre il
5
+ // perimetro di `run-task`. Ma il perimetro è un INDIRIZZO — `run-task T189 2,4`
6
+ // esegue il secondo e il quarto DLV come li conta il plugin, non come li conta
7
+ // chi ha composto il prompt. Se i due conteggi divergono l'umano seleziona DLV3
8
+ // e `run-task` esegue un altro DLV, senza errore. Per questo le due regole sono
9
+ // copie esatte, e la prima ha un test di parità contro lo script vero.
10
+ //
11
+ // ① NUMERAZIONE — `loom-works-plugin/scripts/task/task-deliverables.sh`:
12
+ // posizionale 1-based sulle sole checkbox a COLONNA ZERO dentro
13
+ // `## Deliverables Checklist`; le righe indentate sono prosa della voce.
14
+ // ② GATE DI ESEGUIBILITÀ — `run-task` SKILL.md §3: Size L vuole un
15
+ // `### Preflight` in `## Decisions`, S e M no, Size assente = M; un ultimo
16
+ // `### Preflight` con `— premessa decaduta` blocca a ogni Size. Questa regola
17
+ // vive nella prosa di una skill e non ha un presidio eseguibile: se cambia
18
+ // lì, va cambiata qui a mano.
19
+ //
20
+ // D6 (preflight T189) — parser TypeScript e non lo script del plugin lanciato
21
+ // dal deck: il deck gira anche su Windows, dove un `.sh` del plugin oggi non
22
+ // parte (T178), e il detail si apre a ogni `⏎` — uno spawn di bash lì dentro
23
+ // sarebbe un costo per tasto. La parità la garantisce il test, non la fonte
24
+ // comune.
25
+ //
26
+ // Logica pura, zero React/Ink.
27
+ import { parseTaskDetail } from './tasks.js';
28
+ /** Un heading di livello 2 apre una sezione nuova; `###` e oltre no. */
29
+ const SECTION_RE = /^## /;
30
+ const DLV_SECTION_RE = /^## Deliverables Checklist/;
31
+ /** Checkbox a colonna zero: `- [ ]`, `- [x]`, `- [X]`, e nient'altro. */
32
+ const CHECKBOX_RE = /^- \[[ xX]\]/;
33
+ /**
34
+ * I DLV del task file, nell'ordine del file.
35
+ *
36
+ * Copia riga per riga dell'`awk` di `task-deliverables.sh`, comprese le sue
37
+ * tolleranze: la sezione si riconosce per prefisso (`## Deliverables Checklist
38
+ * (…)` vale), un fence non sospende niente, e il testo è ciò che segue i sei
39
+ * caratteri della checkbox, spazi in testa tolti. Una «correzione» qui — saltare
40
+ * le checkbox dentro un fence, per dire — darebbe numeri diversi da quelli dello
41
+ * script sul primo task file che ne ha una.
42
+ *
43
+ * Testo assente (task file non trovato) → nessun DLV.
44
+ */
45
+ export function parseDeliverables(text) {
46
+ if (!text)
47
+ return [];
48
+ const out = [];
49
+ let inSection = false;
50
+ for (const line of text.split('\n')) {
51
+ if (SECTION_RE.test(line)) {
52
+ inSection = DLV_SECTION_RE.test(line);
53
+ continue;
54
+ }
55
+ if (!inSection || !CHECKBOX_RE.test(line))
56
+ continue;
57
+ out.push({
58
+ n: out.length + 1,
59
+ done: line[3] !== ' ',
60
+ text: line.slice(6).replace(/^[ \t]+/, ''),
61
+ });
62
+ }
63
+ return out;
64
+ }
65
+ const DECISIONS_RE = /^## Decisions\b/;
66
+ const PREFLIGHT_RE = /^### Preflight\b/;
67
+ /** La dicitura che `preflight-task` mette nel heading quando si ferma davanti a
68
+ * una premessa smentita (§0b della skill). */
69
+ const DECAYED_RE = /premessa decaduta/i;
70
+ /**
71
+ * L'ULTIMO blocco `### Preflight` di `## Decisions` decide: `preflight-task`
72
+ * appende in fondo alla sezione, quindi l'ultimo nel file è il più recente.
73
+ * Una premessa decaduta seguita da un preflight riuscito non blocca più; il
74
+ * contrario sì.
75
+ */
76
+ export function preflightState(text) {
77
+ if (!text)
78
+ return 'none';
79
+ let inDecisions = false;
80
+ let last = 'none';
81
+ for (const line of text.split('\n')) {
82
+ if (SECTION_RE.test(line)) {
83
+ inDecisions = DECISIONS_RE.test(line);
84
+ continue;
85
+ }
86
+ if (inDecisions && PREFLIGHT_RE.test(line))
87
+ last = DECAYED_RE.test(line) ? 'decayed' : 'done';
88
+ }
89
+ return last;
90
+ }
91
+ /**
92
+ * La Size della task, ridotta alla sua prima parola maiuscola (`L`, `M`, `S`,
93
+ * `EPIC`). Assente o vuota → `M`, come in `run-task` §4: la mancanza del campo
94
+ * non deve far scattare il ramo più severo.
95
+ */
96
+ export function taskSize(id, text) {
97
+ if (!text)
98
+ return 'M';
99
+ const raw = parseTaskDetail(id, text).fields['Size'] ?? '';
100
+ return /^\s*([A-Za-z]+)/.exec(raw)?.[1]?.toUpperCase() ?? 'M';
101
+ }
102
+ export const DLV_REASON = {
103
+ closed: 'task chiusa',
104
+ empty: 'nessun deliverable in ## Deliverables Checklist',
105
+ allDone: 'tutti i deliverable sono chiusi',
106
+ decayed: 'premessa decaduta — ricostruisci le premesse, poi preflight-task',
107
+ sizeL: 'preflight obbligatorio — Size L senza ### Preflight in ## Decisions',
108
+ };
109
+ /**
110
+ * Il gate di `run-task` §3 visto dal deck, più i due casi in cui non c'è niente
111
+ * da lanciare.
112
+ *
113
+ * L'ordine dei controlli è quello della domanda «cosa partirebbe?»: prima se c'è
114
+ * qualcosa da eseguire, poi se la skill lo eseguirebbe. Su una task chiusa con
115
+ * una premessa decaduta il motivo utile è «chiusa», non il preflight.
116
+ *
117
+ * `done` viene da `tasks.md` (la Prog ✔️ della lista, `isDone`) e non dal campo
118
+ * `Progress` del task file: è la stessa fonte che usa il resto del deck, e il
119
+ * testo che il detail ha in mano è già sanificato — il glifo di Done lì non è
120
+ * più quello scritto su disco.
121
+ *
122
+ * `Size: Epic` non esclude (D2): un'epica con DLV propri resta lanciabile, e se
123
+ * `run-task` si rifiuta il rifiuto è della skill, non del deck.
124
+ */
125
+ export function dlvGate(id, text, dlvs, done) {
126
+ if (done)
127
+ return { kind: 'off', reason: DLV_REASON.closed };
128
+ if (dlvs.length === 0)
129
+ return { kind: 'off', reason: DLV_REASON.empty };
130
+ if (!dlvs.some((d) => !d.done))
131
+ return { kind: 'off', reason: DLV_REASON.allDone };
132
+ const preflight = preflightState(text);
133
+ if (preflight === 'decayed')
134
+ return { kind: 'blocked', reason: DLV_REASON.decayed };
135
+ if (taskSize(id, text) === 'L' && preflight === 'none') {
136
+ return { kind: 'blocked', reason: DLV_REASON.sizeL };
137
+ }
138
+ return { kind: 'ready' };
139
+ }
package/dist/doc-tree.js CHANGED
@@ -376,7 +376,7 @@ export function docTargetKind(row, docsRoot) {
376
376
  * T160 — il buco `{words}` del titolo di una sessione di rebalance: il path reso
377
377
  * a parole.
378
378
  *
379
- * Gemello di `wrapWords`, e per la stessa ragione: l'alfabeto di `_sane_note`
379
+ * Gemello di `wrapWords`, e per la stessa ragione: l'alfabeto di `saneNote`
380
380
  * non ha né `/` né `.`, quindi un path lasciato cadere lì dentro esce con le
381
381
  * parole saldate (`runtimereferencedoc-systemmd`) mentre la lista del deck, che
382
382
  * legge la nota grezza dal sidecar, mostrerebbe il path intero — la stessa
package/dist/glyphs.js CHANGED
@@ -123,6 +123,22 @@ export const INBOX_MARK = {
123
123
  * sessioni: la cella si riempie di spazi anche quando è vuota, o le righe
124
124
  * `held` sposterebbero a sinistra tutto ciò che segue. */
125
125
  export const INBOX_MARK_W = 2;
126
+ /**
127
+ * T189 — colonna di stato di un DLV nella lista del detail, larga 3 in tutte e
128
+ * tre le forme: il numero e il testo che seguono restano incolonnati.
129
+ *
130
+ * La selezione è `[•]` e non `[x]`: nel task file `[x]` vuol dire FATTO, e sulla
131
+ * stessa schermata il testo reso sopra la lista lo mostra proprio con quel
132
+ * significato. Un `[x]` che sotto vuol dire «scelto» sarebbe un falso amico. Il
133
+ * fatto porta la spunta del Done della lista task, lo stesso glifo per la
134
+ * stessa idea.
135
+ */
136
+ export const DLV_MARK = {
137
+ done: sanitize('✔'),
138
+ open: '[ ]',
139
+ picked: '[•]',
140
+ };
141
+ export const DLV_MARK_W = 3;
126
142
  // Marker Done per il DISPLAY. `task.prog` resta il `✔️` letto da tasks.md —
127
143
  // `isDone()` e le lookup di `view.ts` ci confrontano sopra, e `task-edit` lo
128
144
  // riscrive sul file: è una chiave semantica, non testo. Qui `sanitize` lo
package/dist/inbox.js CHANGED
@@ -200,7 +200,7 @@ export function inboxPrompt(f) {
200
200
  * riscriverla.
201
201
  *
202
202
  * Il `.md` si toglie QUI e non lo si lascia cadere a valle: l'alfabeto di
203
- * `_sane_note` non ha il punto, quindi il titolo della tab uscirebbe con le due
203
+ * `saneNote` non ha il punto, quindi il titolo della tab uscirebbe con le due
204
204
  * lettere saldate al nome (`…-prioritariamd`) mentre la lista del deck, che
205
205
  * legge la nota grezza dal sidecar, mostrerebbe il nome intero — la stessa
206
206
  * conversazione con due nomi, senza errore.
package/dist/input.js CHANGED
@@ -26,6 +26,7 @@ import { captures, inertNote, scrolls, taskModeKey, } from './input-modes.js';
26
26
  import { LEFT_PANE, META_ROWS, QUIT_WINDOW_MS, RIGHT_PANE } from './model.js';
27
27
  import { TASK_VIEWS, taskView } from './pane-views.js';
28
28
  import { loadTaskFileText } from './tasks.js';
29
+ import { isDone } from './layout.js';
29
30
  import { inboxPrompt, loadInboxText } from './inbox.js';
30
31
  import { dirListing, loadDocText } from './doc-tree.js';
31
32
  import { refuse } from './platform.js';
@@ -524,6 +525,9 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
524
525
  // restare senza intestazione solo perché il file manca.
525
526
  title: model.detail?.title || task.desc,
526
527
  text: loadTaskFileText(tasksDir, task.id),
528
+ // T189 — la Prog della lista, non il campo del task file: è la
529
+ // fonte che il resto del deck legge come «chiusa».
530
+ done: isDone(task.prog),
527
531
  });
528
532
  }
529
533
  }
@@ -0,0 +1,123 @@
1
+ // T189 — il MODO DLV del detail della task: la lista dei deliverable,
2
+ // selezionabile, al posto dell'area di compilazione.
3
+ //
4
+ // È una variante dell'azione `run` del detail, non un'azione nuova (D3): stesso
5
+ // spawn, stesso modello, stesso titolo, override di progetto compreso. Cambia
6
+ // solo il prompt — il perimetro scelto in coda (`/loom-works:run-task T189 2,4`),
7
+ // oppure `cosa fare ora [R0]` senza selezione (D4).
8
+ //
9
+ // Vive fuori da `sheet.ts` (D5) e non lo importa: il detail possiede il modo,
10
+ // questo modulo ne possiede lo stato. Le regole del plugin che decide di
11
+ // rispettare — numerazione e gate di `run-task` — stanno in `deliverables.ts`.
12
+ import { useState } from 'react';
13
+ import { dlvGate, parseDeliverables } from '../deliverables.js';
14
+ /** D4 — il prompt di `⏎` senza nessun DLV selezionato. */
15
+ export const DLV_IDLE_PROMPT = 'cosa fare ora [R0]';
16
+ /**
17
+ * D3/D4 — il prompt che parte da `⏎` nel modo DLV.
18
+ *
19
+ * Con una selezione: il prompt dell'azione `run` GIÀ RISOLTO (default del deck ←
20
+ * override di progetto, buchi riempiti) con il perimetro in coda, in ordine
21
+ * crescente — `run-task` normalizza da sé, ma il prompt è anche ciò che l'umano
22
+ * legge nella riga di stato prima di premere. Lista semplice e non range: la
23
+ * grammatica `1,3-5` di `run-task` la accetta, e compattare non toglie ambiguità.
24
+ *
25
+ * Senza selezione: `cosa fare ora [R0]`, nella stessa sessione bound di `run`.
26
+ */
27
+ export function dlvPrompt(runPrompt, selected) {
28
+ const scope = [...selected].sort((a, b) => a - b);
29
+ if (scope.length === 0)
30
+ return DLV_IDLE_PROMPT;
31
+ return `${(runPrompt ?? '').trim()} ${scope.join(',')}`.trim();
32
+ }
33
+ /**
34
+ * Il DLV aperto dopo (o prima di) `from`, in giro come le righe dell'area di
35
+ * compilazione: `↑↓` si fermano solo sui DLV da fare. `from` è un NUMERO di DLV
36
+ * e non un indice — resta valido comunque la lista venga finestrata a schermo.
37
+ * `from` fuori dagli aperti → il primo aperto; nessun DLV aperto → 0.
38
+ */
39
+ export function stepOpen(dlvs, from, delta) {
40
+ const open = dlvs.filter((d) => !d.done).map((d) => d.n);
41
+ if (open.length === 0)
42
+ return 0;
43
+ const at = open.indexOf(from);
44
+ if (at < 0)
45
+ return open[0];
46
+ return open[(((at + delta) % open.length) + open.length) % open.length];
47
+ }
48
+ /** `spazio` sul DLV `n`: lo mette nella selezione o lo toglie. */
49
+ export function toggleIn(selected, n) {
50
+ const next = new Set(selected);
51
+ if (next.has(n))
52
+ next.delete(n);
53
+ else
54
+ next.add(n);
55
+ return next;
56
+ }
57
+ const NONE = new Set();
58
+ const CLOSED = { kind: 'off', reason: '' };
59
+ export function useDlvMode() {
60
+ /** Il detail mostra la lista DLV (`true`) o l'area di compilazione. */
61
+ const [active, setActive] = useState(false);
62
+ const [dlvs, setDlvs] = useState([]);
63
+ const [gate, setGate] = useState(CLOSED);
64
+ const [cursor, setCursor] = useState(0);
65
+ const [selected, setSelected] = useState(NONE);
66
+ const [run, setRun] = useState(null);
67
+ /**
68
+ * Azzera tutto a ogni apertura del detail, come gli altri campi: una selezione
69
+ * che sopravvive alla chiusura farebbe partire un perimetro che a schermo non
70
+ * si vede più.
71
+ *
72
+ * Il modo di apertura è il gate: si apre sui DLV solo se `⏎` lì lancerebbe
73
+ * davvero qualcosa — su una task bloccata o chiusa l'area di compilazione resta
74
+ * quella di sempre, e la lista si raggiunge con `tab`.
75
+ */
76
+ function open(input) {
77
+ const list = parseDeliverables(input.text);
78
+ const g = dlvGate(input.id, input.text, list, input.done);
79
+ setDlvs(list);
80
+ setGate(g);
81
+ setRun(input.run);
82
+ setCursor(g.kind === 'ready' ? stepOpen(list, 0, 1) : 0);
83
+ setSelected(NONE);
84
+ setActive(g.kind === 'ready');
85
+ }
86
+ function toggle() {
87
+ setActive((a) => !a);
88
+ }
89
+ const prompt = dlvPrompt(run?.prompt ?? null, selected);
90
+ /** Lo spawn di `⏎`, o `null` se il gate non lo permette: la ragione è già a
91
+ * schermo nella riga di stato della lista, quindi il tasto resta inerte. */
92
+ function launch() {
93
+ if (gate.kind !== 'ready' || !run)
94
+ return null;
95
+ return { model: run.model, title: (run.title ?? '').trim(), prompt };
96
+ }
97
+ /** I tasti della lista. Il chiamante gestisce prima i suoi (`tab`, `⏎`, `esc`,
98
+ * `^F`, `PgUp`/`PgDn`); qui resta ciò che muove cursore e selezione. Fuori
99
+ * da `ready` nessun DLV è selezionabile, quindi ogni tasto è inerte. */
100
+ function onKey(input, key) {
101
+ if (gate.kind !== 'ready')
102
+ return;
103
+ if (key.upArrow || key.downArrow) {
104
+ setCursor((c) => stepOpen(dlvs, c, key.upArrow ? -1 : 1));
105
+ }
106
+ else if (input === ' ' && !key.ctrl && !key.meta && cursor > 0) {
107
+ setSelected((s) => toggleIn(s, cursor));
108
+ }
109
+ }
110
+ return {
111
+ active,
112
+ dlvs,
113
+ gate,
114
+ cursor,
115
+ selected,
116
+ prompt,
117
+ model: run?.model ?? null,
118
+ open,
119
+ toggle,
120
+ launch,
121
+ onKey,
122
+ };
123
+ }
@@ -12,7 +12,7 @@
12
12
  // Lo spawn della sessione dalla barra azioni NON sta qui: arriva come callback
13
13
  // `onAction`, per lo stesso confine di `useSearchOverlay`.
14
14
  import { useEffect, useMemo, useState } from 'react';
15
- import { detailCapacity, pageStep } from '../viewport.js';
15
+ import { DETAIL_FIELD_ROWS, DLV_STATUS_ROWS, detailCapacity, dlvListRows, pageStep, } from '../viewport.js';
16
16
  import { wrapWithOffsets } from '../width.js';
17
17
  import { scanText, topForOffset } from '../text-search.js';
18
18
  import { parseMarkdown } from '../markdown.js';
@@ -21,6 +21,7 @@ import { sanitizeTyped } from '../glyphs.js';
21
21
  import { ACTION_HOTKEYS, DETAIL_ACTIONS, MODELS, MODEL_DEFAULT, specializeRecap, } from '../spawn-catalog.js';
22
22
  import { taskIsEpic } from '../tasks.js';
23
23
  import { fieldsKey } from '../fields.js';
24
+ import { useDlvMode } from './dlv-mode.js';
24
25
  // T117 — le righe dell'area di compilazione del detail, nell'ordine in cui si
25
26
  // leggono dall'alto. Gli indici sono nominati perché li usano insieme l'handler
26
27
  // dei tasti e la resa, e un `2` nudo in due file diversi è la coordinata che
@@ -67,6 +68,9 @@ export function useSheetOverlay(deps) {
67
68
  * riparserebbe l'intero task file per un campo dell'header. */
68
69
  const [epic, setEpic] = useState(false);
69
70
  const [cursor, setCursor] = useState({ row: DROW.action, caret: 0 });
71
+ // T189 — il secondo modo del detail: la lista dei DLV al posto dell'area di
72
+ // compilazione, alternata da `tab`.
73
+ const dlv = useDlvMode();
70
74
  // T91 — ricerca dentro il detail. `open` distingue i due modi in cui la si
71
75
  // lascia: `esc` butta via ciò che il modale ha prodotto (`find` a null,
72
76
  // evidenziazione via), `⏎` lo congela e restituisce il controllo allo strato
@@ -91,7 +95,11 @@ export function useSheetOverlay(deps) {
91
95
  // RESO, ed è l'unica coordinata coerente che resti — il sorgente non è più
92
96
  // ciò che sta a schermo.
93
97
  const lines = useMemo(() => (doc ? wrapWithOffsets(doc.text, width) : []), [doc, width]);
94
- const capacity = detailCapacity(rows, find?.open === true);
98
+ // T189 — l'area sotto il testo cambia altezza col modo: cinque righe di campi,
99
+ // oppure la lista DLV più la sua riga di stato. Il testo prende il resto.
100
+ const searching = find?.open === true;
101
+ const dlvRows = dlvListRows(rows, searching, dlv.dlvs.length);
102
+ const capacity = detailCapacity(rows, searching, dlv.active ? dlvRows + DLV_STATUS_ROWS : DETAIL_FIELD_ROWS);
95
103
  const maxTop = Math.max(0, lines.length - capacity);
96
104
  // Lo scan gira sulla STESSA stringa che si renderizza: cercare su un testo
97
105
  // diverso da quello a schermo darebbe offset che indicizzano un altro
@@ -137,6 +145,9 @@ export function useSheetOverlay(deps) {
137
145
  setCursor({ row: DROW.action, caret: 0 });
138
146
  setFind(null);
139
147
  setOccIdx(0);
148
+ // T189/D3 — il modo DLV è una variante di `run`: la terna gli arriva già
149
+ // risolta, override di progetto compreso, come all'azione che replica.
150
+ dlv.open({ id: next.id, text: next.text, done: next.done === true, run: defaults('run', next.id) });
140
151
  setNote('');
141
152
  setMode('detail');
142
153
  }
@@ -264,6 +275,13 @@ export function useSheetOverlay(deps) {
264
275
  setFind((f) => (f ? { ...f, open: true } : { q: '', caret: 0, open: true }));
265
276
  return;
266
277
  }
278
+ if (key.tab) {
279
+ // T189 — `tab` alterna area di compilazione e lista DLV. Nel detail era
280
+ // inerte (`fieldsKey` non lo consuma), e il `tab` che cicla le viste vive
281
+ // solo nella lista: nessun significato tolto.
282
+ dlv.toggle();
283
+ return;
284
+ }
267
285
  if (key.escape) {
268
286
  // Uno strato alla volta: se resta un'evidenziazione congelata, `esc`
269
287
  // smonta quella; il detail lo chiude il secondo.
@@ -275,6 +293,19 @@ export function useSheetOverlay(deps) {
275
293
  close();
276
294
  return;
277
295
  }
296
+ if (key.return && dlv.active) {
297
+ // T189 — nel modo DLV `⏎` lancia `run` col perimetro scelto. Gate non
298
+ // pronto → inerte: il motivo è già scritto sotto la lista, e chiudere il
299
+ // detail lo porterebbe via.
300
+ const launch = dlv.launch();
301
+ if (!launch)
302
+ return;
303
+ const id = sheet?.id;
304
+ close();
305
+ if (id)
306
+ onAction(id, launch.model, launch.title, launch.prompt, false);
307
+ return;
308
+ }
278
309
  if (key.return) {
279
310
  // `⏎` esegue SEMPRE l'azione selezionata, da qualunque riga: i campi sono
280
311
  // di una riga sola, quindi nessuno di loro ha da farci un a-capo.
@@ -297,6 +328,10 @@ export function useSheetOverlay(deps) {
297
328
  scroll(pageStep(capacity));
298
329
  return;
299
330
  }
331
+ if (dlv.active) {
332
+ dlv.onKey(input, key);
333
+ return;
334
+ }
300
335
  // Tutto il resto è dell'area di compilazione. Ciò che non consuma resta
301
336
  // inerte — dentro un modo capturing è la scelta giusta: gli acceleratori
302
337
  // globali non devono riattivarsi, e su una riga a scelta una lettera che
@@ -319,6 +354,8 @@ export function useSheetOverlay(deps) {
319
354
  maxTop,
320
355
  findRes,
321
356
  occCur,
357
+ dlv,
358
+ dlvRows,
322
359
  open,
323
360
  onKey,
324
361
  scroll,
package/dist/platform.js CHANGED
@@ -17,8 +17,7 @@
17
17
  */
18
18
  export const UNAVAILABLE = {
19
19
  win32: [
20
- 'spawn', // T176 — tab di Windows Terminal al posto di ptyxis
21
- 'liveSessions', // T177 — liveness senza /proc
20
+ 'liveSessions', // T177 — liveness senza /proc; spegne anche resume e restore
22
21
  'pluginScripts', // T178 — .sh del plugin via Git Bash
23
22
  'hardWrap', // T178 — python del wrap-scan
24
23
  'launch', // T179 — comando per piattaforma nelle voci launch[]
@@ -7,8 +7,9 @@
7
7
  // stringa È il path; su Windows no, per tre ragioni misurate (verdetto 1 di T174):
8
8
  //
9
9
  // - NTFS non distingue le maiuscole, e Claude Code non normalizza la grafia
10
- // della cwd: VS Code passa `c:\…`, Windows Terminal `C:\…`, e lo stesso
11
- // progetto ha due cartelle di transcript (`c--…` e `C--…`);
10
+ // della cwd: VS Code passa `c:\…`, Windows Terminal `C:\…`. La cartella di
11
+ // transcript del progetto è una sola, ma il suo nome porta la grafia di chi
12
+ // l'ha creata (`c--…` o `C--…`);
12
13
  // - `\` e `/` sono entrambi separatori;
13
14
  // - nei transcript `cwd` compare anche in forma MSYS (`/c/…`), quella di Git
14
15
  // Bash.
@@ -59,9 +60,11 @@ export function projectBaseName(cwd, platform = process.platform) {
59
60
  * Le cartelle di transcript del progetto, fra i nomi di `~/.claude/projects/`.
60
61
  *
61
62
  * Su Linux una sola, il nome esatto. Su win32 ogni nome che coincide ignorando
62
- * le maiuscole: la stessa root avviata da VS Code (`c:\…`) e da Windows
63
- * Terminal (`C:\…`) scrive in `c--…` e in `C--…`, e leggerne una sola perde le
64
- * conversazioni dell'altra.
63
+ * le maiuscole: per NTFS `c--…` e `C--…` sono la stessa cartella, ma `readdir`
64
+ * ne restituisce il nome con la grafia di chi l'ha creata — `c--…` se la prima
65
+ * conversazione è nata da VS Code (`c:\…`) — mentre il nome cercato il deck lo
66
+ * ricava dalla propria root, che può avere l'altra grafia. Un confronto esatto
67
+ * non troverebbe la cartella.
65
68
  */
66
69
  export function matchingDirNames(entries, name, platform = process.platform) {
67
70
  if (platform !== 'win32')
@@ -69,3 +72,38 @@ export function matchingDirNames(entries, name, platform = process.platform) {
69
72
  const want = name.toLowerCase();
70
73
  return entries.filter((e) => e.toLowerCase() === want);
71
74
  }
75
+ /**
76
+ * T176 — la cartella in cui nasce la tab che riprende o forka una conversazione.
77
+ *
78
+ * Su Linux la root del progetto, come prima. Su win32 la cwd di avvio che la
79
+ * conversazione ha registrato, in forma Windows e con la grafia del drive
80
+ * d'origine (P7 preflight). La ragione non è trovare il transcript: per NTFS
81
+ * `c--…` e `C--…` sono la stessa cartella, e `claude --resume` lo trova da tutte
82
+ * e due le grafie (misurato in T176 con Claude Code 2.1.310, anche con
83
+ * `--fork-session`). La ragione è la fiducia della cartella: Claude Code la
84
+ * tiene in `~/.claude.json` per grafia esatta del path (`c:/…` e `C:/…` sono due
85
+ * voci), e una sessione interattiva in una grafia mai approvata si ferma sulla
86
+ * domanda di fiducia prima di caricare la conversazione. Una conversazione nata
87
+ * interattiva ha già la fiducia nella grafia in cui è nata.
88
+ *
89
+ * Una cwd in forma MSYS (`/c/…`) non dice la grafia del drive: la dice il nome
90
+ * della cartella del transcript, che Claude Code ha derivato dalla cwd vera
91
+ * (`c--…` o `C--…`). Una cwd che non è un path assoluto Windows ricade sulla
92
+ * root.
93
+ */
94
+ export function conversationCwd(sessionCwd, transcriptPath, root, platform = process.platform) {
95
+ if (platform !== 'win32')
96
+ return root;
97
+ if (/^[a-zA-Z]:[\\/]/.test(sessionCwd))
98
+ return sessionCwd.replace(/\//g, '\\');
99
+ if (sessionCwd.startsWith('\\\\'))
100
+ return sessionCwd;
101
+ const msys = /^\/([a-zA-Z])(?:\/(.*))?$/.exec(sessionCwd);
102
+ if (!msys)
103
+ return root;
104
+ const folder = win32.basename(win32.dirname(transcriptPath));
105
+ const letter = folder.length > 2 && folder.slice(1, 3) === '--' && folder[0].toLowerCase() === msys[1].toLowerCase()
106
+ ? folder[0]
107
+ : msys[1].toUpperCase();
108
+ return `${letter}:\\${(msys[2] ?? '').replace(/\//g, '\\')}`;
109
+ }