@lamemind/loom-deck 0.77.0 → 0.79.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
@@ -403,12 +403,13 @@ Le tab usano il profilo di Windows Terminal **`loom-deck`**, uno solo per tutti
403
403
  "name": "loom-deck",
404
404
  "guid": "{<un GUID nuovo>}",
405
405
  "hidden": true,
406
+ "colorScheme": "<nome dello schema, es. One Half Light>",
406
407
  "commandline": "%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoLogo",
407
408
  "closeOnExit": "graceful"
408
409
  }
409
410
  ```
410
411
 
411
- `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`).
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`).
412
413
 
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.
414
415
 
@@ -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/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/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
  }
@@ -2,8 +2,10 @@ import { readFileSync, readdirSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, join } from 'node:path';
4
4
  import { isWithin } from './project-path.js';
5
- function liveSessionsRoot() {
6
- return join(homedir(), '.claude', 'sessions');
5
+ /** La cartella del registry. `LOOM_DECK_LIVE_SESSIONS_ROOT` la sposta, per i
6
+ * test che costruiscono un registro finto invece di leggere quello vero. */
7
+ export function liveSessionsRoot(env = process.env) {
8
+ return env.LOOM_DECK_LIVE_SESSIONS_ROOT || join(homedir(), '.claude', 'sessions');
7
9
  }
8
10
  /**
9
11
  * Campo 22 (`starttime`) di `/proc/<pid>/stat`, o null se la riga non è
@@ -96,24 +98,16 @@ export function parseLiveEntry(content, pid) {
96
98
  };
97
99
  }
98
100
  /**
99
- * Le sessioni VIVE del solo progetto corrente, indicizzate per `sessionId` —
100
- * la stessa chiave del sidecar `session-tasks.jsonl` e di `deck-run --resume`,
101
- * quindi il join con la riga di lista è diretto.
102
- *
103
- * Il filtro per `cwd` è d'EFFICIENZA, non di correttezza: risparmia lo `stat`
104
- * di `/proc` sui processi degli altri progetti (il registry è globale, non
105
- * per-progetto). Il match resta sul `sessionId`.
101
+ * Le entry VIVE del registry, nell'ordine di `readdir`.
106
102
  *
107
- * Nessuna cache mtime-keyed come in `sessions.ts`: le entry sono poche e di
108
- * poche centinaia di byte, mentre `status` cambia a ogni turno — una cache qui
109
- * mostrerebbe `idle` su una sessione che sta lavorando, cioè il difetto che la
110
- * colonna esiste per non avere.
103
+ * `keep` scarta un'entry PRIMA del controllo di liveness: chi filtra per
104
+ * progetto risparmia così lo `stat` di `/proc` sui processi degli altri.
111
105
  */
112
- export function discoverLiveSessions(projectRoot) {
113
- const out = new Map();
106
+ function liveEntries(root, keep = () => true) {
107
+ const out = [];
114
108
  let files;
115
109
  try {
116
- files = readdirSync(liveSessionsRoot()).filter((f) => f.endsWith('.json'));
110
+ files = readdirSync(root).filter((f) => f.endsWith('.json'));
117
111
  }
118
112
  catch {
119
113
  return out; // registry assente (CLI mai avviato, o versione che non lo scrive)
@@ -124,7 +118,7 @@ export function discoverLiveSessions(projectRoot) {
124
118
  continue;
125
119
  let content;
126
120
  try {
127
- content = readFileSync(join(liveSessionsRoot(), f), 'utf8');
121
+ content = readFileSync(join(root, f), 'utf8');
128
122
  }
129
123
  catch {
130
124
  continue; // il processo può finire fra readdir e read
@@ -133,16 +127,101 @@ export function discoverLiveSessions(projectRoot) {
133
127
  if (!parsed)
134
128
  continue;
135
129
  const { entry, procStart } = parsed;
136
- // T175 — `isWithin` regge le grafie di Windows: il registry scrive `C:\…`,
137
- // e la root del deck può arrivare con la lettera del drive in un altro case.
138
- if (!isWithin(entry.cwd, projectRoot))
130
+ if (!keep(entry))
139
131
  continue;
140
132
  if (!isAlive(pid, procStart))
141
133
  continue;
134
+ out.push(entry);
135
+ }
136
+ return out;
137
+ }
138
+ /**
139
+ * Le sessioni VIVE del solo progetto corrente, indicizzate per `sessionId` —
140
+ * la stessa chiave del sidecar `session-tasks.jsonl` e di `deck-run --resume`,
141
+ * quindi il join con la riga di lista è diretto.
142
+ *
143
+ * Il filtro per `cwd` è d'EFFICIENZA, non di correttezza: risparmia lo `stat`
144
+ * di `/proc` sui processi degli altri progetti (il registry è globale, non
145
+ * per-progetto). Il match resta sul `sessionId`.
146
+ *
147
+ * Nessuna cache mtime-keyed come in `sessions.ts`: le entry sono poche e di
148
+ * poche centinaia di byte, mentre `status` cambia a ogni turno — una cache qui
149
+ * mostrerebbe `idle` su una sessione che sta lavorando, cioè il difetto che la
150
+ * colonna esiste per non avere.
151
+ */
152
+ export function discoverLiveSessions(projectRoot) {
153
+ const out = new Map();
154
+ // T175 — `isWithin` regge le grafie di Windows: il registry scrive `C:\…`,
155
+ // e la root del deck può arrivare con la lettera del drive in un altro case.
156
+ for (const entry of liveEntries(liveSessionsRoot(), (e) => isWithin(e.cwd, projectRoot))) {
142
157
  out.set(entry.sessionId, entry);
143
158
  }
144
159
  return out;
145
160
  }
161
+ /**
162
+ * Il nome CHIESTO in un argv di `claude` — il valore di `--name` o del suo alias
163
+ * `-n`, staccato o con `=` — oppure `null` se l'argv non ne chiede uno.
164
+ *
165
+ * Non è il campo `name` del registry, ed è la ragione per cui serve: il registry
166
+ * porta il nome EFFETTIVO, e quando il nome chiesto è già preso il CLI rinomina
167
+ * la sessione (`🧵 loom-works` → `🧵 loom-works-cryptic-hopper`, con
168
+ * `nameSource: "collision"`), mentre il titolo della tab resta il nome chiesto.
169
+ *
170
+ * GEMELLA di `procRequestedName` in `loom-compass/compass@lamemind/model.js`:
171
+ * le due letture devono riconoscere le stesse quattro forme.
172
+ */
173
+ export function requestedNameIn(argv) {
174
+ for (let i = 0; i < argv.length; i++) {
175
+ const a = argv[i];
176
+ if (a === '--name' || a === '-n')
177
+ return argv[i + 1] ?? null;
178
+ if (a.startsWith('--name='))
179
+ return a.slice('--name='.length);
180
+ if (a.startsWith('-n='))
181
+ return a.slice('-n='.length);
182
+ }
183
+ return null;
184
+ }
185
+ /** Gli argomenti del processo: `/proc/<pid>/cmdline` li separa con NUL e ne
186
+ * mette uno anche in coda. Ogni argomento arriva intero, spazi compresi. */
187
+ function procArgv(pid) {
188
+ let raw;
189
+ try {
190
+ raw = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
191
+ }
192
+ catch {
193
+ return null; // processo finito fra la liveness e la lettura
194
+ }
195
+ const argv = raw.split('\0');
196
+ if (argv.length && argv[argv.length - 1] === '')
197
+ argv.pop();
198
+ return argv;
199
+ }
200
+ /**
201
+ * T182 — i nomi chiesti con `--name` dalle conversazioni vive di TUTTI i
202
+ * progetti: l'ingresso di `twinTitle` (`src/twin-title.ts`).
203
+ *
204
+ * Nessun filtro per `cwd`, al contrario di `discoverLiveSessions`: chi cerca la
205
+ * tab per titolo la cerca in tutte le finestre Ptyxis, quindi una conversazione
206
+ * dello stesso progetto aperta da un worktree di lane (altro `cwd`, stesso
207
+ * titolo) è un gemello a tutti gli effetti. Il titolo porta già la label del
208
+ * progetto, e il confronto per uguaglianza intera non scambia un progetto con
209
+ * un altro.
210
+ *
211
+ * Una viva senza `--name` non entra: il suo titolo di tab non è un nome
212
+ * chiesto. Dove `/proc` non esiste nessuna entry risulta viva, e la lista
213
+ * esce vuota: il lancio usa il nome base.
214
+ */
215
+ export function liveRequestedNames(root = liveSessionsRoot()) {
216
+ const names = [];
217
+ for (const entry of liveEntries(root)) {
218
+ const argv = procArgv(entry.pid);
219
+ const name = argv ? requestedNameIn(argv) : null;
220
+ if (name !== null)
221
+ names.push(name);
222
+ }
223
+ return names;
224
+ }
146
225
  /** Signature del poll: cambia quando cambia l'insieme delle vive O lo stato di
147
226
  * una di esse. Il `pid` ci sta dentro perché un `/clear` sposta lo stesso
148
227
  * `sessionId` su un processo diverso senza toccare né insieme né stato. */
@@ -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,
@@ -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')
@@ -72,12 +75,16 @@ export function matchingDirNames(entries, name, platform = process.platform) {
72
75
  /**
73
76
  * T176 — la cartella in cui nasce la tab che riprende o forka una conversazione.
74
77
  *
75
- * Su Linux la root del progetto, come prima. Su win32 la cwd che la
76
- * conversazione ha registrato, in forma Windows (P7 preflight): `claude
77
- * --resume` cerca il transcript nella cartella che ricava dalla PROPRIA cwd, e
78
- * Claude Code non normalizza la lettera del drive (T174 verdetto 1) — una
79
- * conversazione nata da VS Code (`c:\…`) sta in `c--…`, e un `claude` partito in
80
- * `C:\…` la cercherebbe in `C--…`, senza trovarla.
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.
81
88
  *
82
89
  * Una cwd in forma MSYS (`/c/…`) non dice la grafia del drive: la dice il nome
83
90
  * della cartella del transcript, che Claude Code ha derivato dalla cwd vera
@@ -15,6 +15,7 @@
15
15
  import { saneNote } from './sane-note.js';
16
16
  import { MODEL_DEFAULT, MODELS } from './spawn-catalog.js';
17
17
  import { shellQuote } from './spawn.js';
18
+ import { twinTitle } from './twin-title.js';
18
19
  export const PERMISSION_MODES = ['acceptEdits', 'auto', 'bypassPermissions', 'manual', 'dontAsk', 'plan'];
19
20
  /** L'alfabeto della chiave di stato: il valore entra in `bash -lc "…"` e il
20
21
  * file di progetto è editabile a mano, quindi si scarta invece di quotare. */
@@ -173,6 +174,12 @@ export function posixQuote(text) {
173
174
  /**
174
175
  * La composizione intera.
175
176
  *
177
+ * Il titolo è `tabTitle` più, solo se una conversazione viva ha già chiesto lo
178
+ * stesso nome, il carattere del gemello (`twinTitle`, T182). Il carattere entra
179
+ * nel titolo intero — label, task, nota e fork compresi — perché due
180
+ * conversazioni della stessa task con note diverse hanno già titoli distinti e
181
+ * non sono gemelle. Lo stesso `title` alimenta `-T` di Ptyxis e `--name`.
182
+ *
176
183
  * Ordine degli argomenti di `claude`: nome, permission mode e modello prima dei
177
184
  * flag di continuità, il prompt sempre ultimo. Il modello sta accanto al
178
185
  * permission mode perché sono la stessa cosa — la scelta di chi apre la
@@ -186,7 +193,7 @@ export function posixQuote(text) {
186
193
  */
187
194
  export function composeTab(req, ctx) {
188
195
  const warnings = [];
189
- const title = tabTitle(req, projectLabel(ctx.config));
196
+ const title = twinTitle(tabTitle(req, projectLabel(ctx.config)), ctx.liveRequestedNames ?? []);
190
197
  const mode = permissionMode(ctx, warnings);
191
198
  const model = modelOf(req, ctx, warnings);
192
199
  const key = stateKey(ctx, warnings);
@@ -21,6 +21,7 @@ import { isUsageError, parseTabArgs } from './tab-args.js';
21
21
  import { composeTab, ptyxisArgv } from './tab-compose.js';
22
22
  import { loadPromptCatalog, PROMPT_CATALOG } from './prompt-catalog.js';
23
23
  import { configFilePath } from './config.js';
24
+ import { liveRequestedNames, liveSessionsRoot } from './live-sessions.js';
24
25
  import { resolveGitBash } from './git-bash.js';
25
26
  import { semicolonArg, TAB_ARGV_ENV, windowsTabEnv, WT_CMD, wtNewTabArgs, wtProfile, } from './windows-terminal.js';
26
27
  /** Il wrapper che gira dentro una tab di Windows Terminal, nel build: lo esegue
@@ -96,6 +97,9 @@ export async function runDeckRun(argv, io) {
96
97
  catalog: loadPromptCatalog(catalogPath),
97
98
  catalogPath,
98
99
  platform: io.platform,
100
+ // Su Windows niente `/proc`, quindi nessuna liveness (T177): il titolo
101
+ // resta quello base, cioè il comportamento di prima del gemello.
102
+ liveRequestedNames: io.platform === 'win32' ? [] : liveRequestedNames(liveSessionsRoot(io.env)),
99
103
  });
100
104
  for (const w of plan.warnings)
101
105
  io.stderr(`${w}\n`);
@@ -0,0 +1,63 @@
1
+ // T182 — il titolo della conversazione GEMELLA: un carattere in coda al
2
+ // `--name` quando una conversazione viva ha già chiesto lo stesso nome.
3
+ //
4
+ // Chi porta davanti la tab di una conversazione la cerca per titolo (coda via
5
+ // AT-SPI, `sessionsByName` di compass), e il titolo della tab è il `--name`
6
+ // passato a `claude`. Due conversazioni vive con lo stesso nome — tipico: il
7
+ // preflight e il run della stessa task — hanno due tab con lo stesso titolo, e
8
+ // chi cerca non può scegliere. Il lanciatore rende unico il secondo titolo; il
9
+ // primo lancio resta senza carattere, quindi nel caso normale il titolo è quello
10
+ // di sempre.
11
+ //
12
+ // Modulo PURO: i nomi chiesti dalle conversazioni vive arrivano già letti
13
+ // (`liveRequestedNames` in `live-sessions.ts`).
14
+ /**
15
+ * I caratteri del gemello, nell'ordine in cui si assegnano: il primo gemello
16
+ * prende `#`.
17
+ *
18
+ * GEMELLA di `TWIN_MARKS` in `loom-compass/compass@lamemind/model.js`, il
19
+ * secondo lanciatore di conversazioni: nessun codice confronta le due copie, e
20
+ * cambiarne una sola fa scegliere caratteri diversi ai due lanciatori senza
21
+ * errore. Si toccano insieme.
22
+ *
23
+ * Ogni carattere sta fuori da cinque classi, e ognuna rompe un consumer in
24
+ * silenzio:
25
+ * - fuori dall'ASCII a una cella: un simbolo di larghezza ambigua (`§`, `†`)
26
+ * occupa una o due celle a seconda della locale e sposta il taglio del
27
+ * titolo nella tab;
28
+ * - dentro l'alfabeto di `saneNote`: un carattere ammesso lì può arrivare da
29
+ * una nota digitata, e il gemello non si distingue più dal testo;
30
+ * - dentro `[A-Za-z0-9_-]`: il matcher di compass `titleKeyRe` riconosce una
31
+ * finestra con `^<emoji> <name>(?![\w-])`;
32
+ * - l'apice singolo: il titolo sta dentro `claude --name '…'` nella riga che
33
+ * `bash -lc` analizza;
34
+ * - i metacaratteri di shell (`; & $ \` | < >`): inerti fra apici, ma il test
35
+ * di `saneNote` asserisce che nessun `[;&`$]` sopravviva in un titolo, e un
36
+ * gemello della stessa classe renderebbe cieco quel test. Fuori anche la
37
+ * punteggiatura che si legge come testo (`. , : /`) e le parentesi.
38
+ */
39
+ export const TWIN_MARKS = ['#', '+', '=', '~', '^', '%', '@', '*', '?', '!'];
40
+ /**
41
+ * Il titolo da usare: `base` se nessuna conversazione viva l'ha chiesto,
42
+ * altrimenti `base` + spazio + il primo carattere di `TWIN_MARKS` che nessuna
43
+ * conversazione viva porta già su quel nome.
44
+ *
45
+ * Lo spazio separa il carattere come ogni altro suffisso del titolo (` · T182`,
46
+ * ` <nota>`, ` · fork`): incollato all'ultima parola si leggerebbe come un
47
+ * refuso della nota. Il confronto è per uguaglianza intera, la stessa che fanno
48
+ * coda e `sessionsByName`.
49
+ *
50
+ * Insieme esaurito → `base`: il lancio parte comunque, e chi cerca la tab per
51
+ * titolo torna all'esito ambiguo che ha anche senza lanciatore.
52
+ */
53
+ export function twinTitle(base, liveRequestedNames) {
54
+ const taken = new Set(liveRequestedNames);
55
+ if (!taken.has(base))
56
+ return base;
57
+ for (const mark of TWIN_MARKS) {
58
+ const candidate = `${base} ${mark}`;
59
+ if (!taken.has(candidate))
60
+ return candidate;
61
+ }
62
+ return base;
63
+ }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // Detail della task (T66): quarta schermata sostitutiva, col markdown reso in
3
3
  // span tipizzati (T75) e la ricerca interna (T91).
4
4
  import { Box, Text } from 'ink';
@@ -9,6 +9,7 @@ import { ChoiceRow, FieldText, LABEL_W } from './fields.js';
9
9
  import { DETAIL_ACTIONS, MODELS } from '../spawn-catalog.js';
10
10
  import { DROW, PRIORITY_CHOICES } from '../overlays/sheet.js';
11
11
  import { CARET, CARET_OFF, WARN } from '../glyphs.js';
12
+ import { DlvArea } from './dlv-area.js';
12
13
  /** Resa di ogni costrutto markdown (T75 · D4): un solo livello di enfasi per
13
14
  * costrutto, senza un secondo alfabeto da imparare. Heading uguali a ogni
14
15
  * livello — la gerarchia la porta già il testo. `code` e `fence` condividono
@@ -79,7 +80,7 @@ const ACTION_KEYS = DETAIL_ACTIONS.map((a) => a.label[0]).join(' ');
79
80
  * rettangolo ha già coordinate e area cliccabile, quindi il layout sopravvive
80
81
  * all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione.
81
82
  */
82
- export function DetailScreen({ id, title, missing, lines, spans, top, total, capacity, action, model, spawnNote, prompt, priority, cursor, columns, find, occ, occCur, }) {
83
+ export function DetailScreen({ id, title, missing, lines, spans, top, total, capacity, action, model, spawnNote, prompt, priority, cursor, columns, find, occ, occCur, dlv, dlvRows, }) {
83
84
  const last = Math.min(total, top + capacity);
84
85
  // Il taglio lo fa il chiamante (invariante ③ di width.ts): le righe bottoni
85
86
  // sono ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso
@@ -91,5 +92,9 @@ export function DetailScreen({ id, title, missing, lines, spans, top, total, cap
91
92
  // campo vuoto): riservarle sempre toglierebbe testo visibile a un titolo lungo
92
93
  // per un avviso assente.
93
94
  const textW = (extra) => Math.max(10, width - LABEL_W - CARET_OFF.length - extra);
94
- 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}`] }), find?.open ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " occorrenza \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " caret \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " tieni \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " testo \u00B7", ' ', _jsx(Text, { color: "yellow", children: ACTION_KEYS }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota campo \u00B7 ", _jsx(Text, { color: "yellow", children: "^F" }), " cerca"] })), _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)"] })) : (lines.map((l, i) => (_jsx(DetailLine, { line: l, spans: spans, occ: occ, current: occCur }, top + i)))) }), find?.open ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "cerca " }), _jsx(FieldText, { value: find.q, caret: find.caret, focused: true, cols: Math.max(10, width - 28) }), find.q.length === 0 ? (_jsx(Text, { dimColor: true, children: " \u00B7 digita per cercare" })) : occ.length === 0 ? (_jsx(Text, { color: "yellow", children: " \u00B7 nessuna occorrenza" })) : (_jsxs(Text, { color: "cyan", children: [' ', "\u00B7 ", occCur + 1, "/", occ.length] }))] }) })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(ChoiceRow, { label: "azione", values: DETAIL_ACTIONS.map((a) => a.label), index: action, focused: cursor.row === DROW.action, width: width }), _jsxs(Text, { wrap: "truncate-end", children: [mark(DROW.prompt), _jsx(Text, { dimColor: true, children: 'prompt'.padEnd(LABEL_W) }), _jsx(FieldText, { value: prompt, caret: cursor.caret, focused: cursor.row === DROW.prompt, cols: textW(prompt ? 0 : PROMPT_HINT.length) }), prompt ? null : _jsx(Text, { dimColor: true, children: PROMPT_HINT })] }), _jsx(ChoiceRow, { label: "modello", values: MODELS, index: Math.max(0, MODELS.indexOf(model)), focused: cursor.row === DROW.model, width: width }), _jsxs(Text, { wrap: "truncate-end", children: [mark(DROW.title), _jsx(Text, { dimColor: true, children: 'titolo'.padEnd(LABEL_W) }), _jsx(FieldText, { value: spawnNote, caret: cursor.caret, focused: cursor.row === DROW.title, cols: textW(spawnNote ? 0 : NOTE_HINT.length) }), spawnNote ? null : _jsx(Text, { dimColor: true, children: NOTE_HINT })] }), _jsx(ChoiceRow, { label: "priorit\u00E0", values: PRIORITY_CHOICES, index: priority ? 1 : 0, focused: cursor.row === DROW.priority, width: width })] })] }));
95
+ 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}`] }), find?.open ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " occorrenza \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " caret \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " tieni \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : dlv.active ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [dlv.gate.kind === 'ready' ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " DLV aperti \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "seleziona \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " run \u00B7", ' '] })) : null, _jsx(Text, { color: "yellow", children: "tab" }), " campi \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " testo \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^F" }), " cerca"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " testo \u00B7", ' ', _jsx(Text, { color: "yellow", children: ACTION_KEYS }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota campo \u00B7 ", _jsx(Text, { color: "yellow", children: "^F" }), " cerca \u00B7", ' ', _jsx(Text, { color: "yellow", children: "tab" }), " DLV"] })), _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)"] })) : (lines.map((l, i) => (_jsx(DetailLine, { line: l, spans: spans, occ: occ, current: occCur }, top + i)))) }), find?.open ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "cerca " }), _jsx(FieldText, { value: find.q, caret: find.caret, focused: true, cols: Math.max(10, width - 28) }), find.q.length === 0 ? (_jsx(Text, { dimColor: true, children: " \u00B7 digita per cercare" })) : occ.length === 0 ? (_jsx(Text, { color: "yellow", children: " \u00B7 nessuna occorrenza" })) : (_jsxs(Text, { color: "cyan", children: [' ', "\u00B7 ", occCur + 1, "/", occ.length] }))] }) })) : null, dlv.active ? (_jsx(DlvArea, { dlvs: dlv.dlvs, cursor: dlv.cursor, selected: dlv.selected, gate: dlv.gate, prompt: dlv.prompt, model: dlv.model, rows: dlvRows, width: width })) : (
96
+ /* Le quattro righe dell'area di compilazione, nell'ordine in cui si
97
+ decide: cosa lanciare, con quale prompt, con quale modello, sotto quale
98
+ titolo. */
99
+ _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(ChoiceRow, { label: "azione", values: DETAIL_ACTIONS.map((a) => a.label), index: action, focused: cursor.row === DROW.action, width: width }), _jsxs(Text, { wrap: "truncate-end", children: [mark(DROW.prompt), _jsx(Text, { dimColor: true, children: 'prompt'.padEnd(LABEL_W) }), _jsx(FieldText, { value: prompt, caret: cursor.caret, focused: cursor.row === DROW.prompt, cols: textW(prompt ? 0 : PROMPT_HINT.length) }), prompt ? null : _jsx(Text, { dimColor: true, children: PROMPT_HINT })] }), _jsx(ChoiceRow, { label: "modello", values: MODELS, index: Math.max(0, MODELS.indexOf(model)), focused: cursor.row === DROW.model, width: width }), _jsxs(Text, { wrap: "truncate-end", children: [mark(DROW.title), _jsx(Text, { dimColor: true, children: 'titolo'.padEnd(LABEL_W) }), _jsx(FieldText, { value: spawnNote, caret: cursor.caret, focused: cursor.row === DROW.title, cols: textW(spawnNote ? 0 : NOTE_HINT.length) }), spawnNote ? null : _jsx(Text, { dimColor: true, children: NOTE_HINT })] }), _jsx(ChoiceRow, { label: "priorit\u00E0", values: PRIORITY_CHOICES, index: priority ? 1 : 0, focused: cursor.row === DROW.priority, width: width })] }))] }));
95
100
  }
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // T189 — la lista dei DLV nel detail della task, al posto dell'area di
3
+ // compilazione quando il modo DLV è attivo.
4
+ import { Box, Text } from 'ink';
5
+ import { cut, cutParts, pad } from '../width.js';
6
+ import { parseMarkdown } from '../markdown.js';
7
+ import { windowRange } from '../viewport.js';
8
+ import { CARET, CARET_OFF, DLV_MARK, DLV_MARK_W, WARN } from '../glyphs.js';
9
+ /** Il testo di una voce come lo rende il detail sopra: senza `**` e backtick.
10
+ * Il markdown resta grezzo nel task file, e una lista che mostrasse i marker
11
+ * direbbe la stessa voce in due forme sulla stessa schermata. */
12
+ function plain(text) {
13
+ return parseMarkdown(text).text;
14
+ }
15
+ /** La riga di stato: cosa parte su `⏎`, o perché non parte niente, più quante
16
+ * righe della lista stanno fuori dalla finestra. Quattro pezzi, uno per
17
+ * colore; il budget serve per primo il secondo, perché è quello che si legge
18
+ * prima di premere. */
19
+ function statusParts(gate, prompt, model, above, below) {
20
+ const overflow = above + below > 0 ? ` · ↑${above} ↓${below}` : '';
21
+ if (gate.kind === 'ready') {
22
+ return { parts: ['⏎ ', prompt, model ? ` · ${model}` : '', overflow], tone: 'ready' };
23
+ }
24
+ const lead = gate.kind === 'blocked' ? `${WARN} ` : '';
25
+ return { parts: [lead, gate.reason, ' · ⏎ inerte', overflow], tone: gate.kind };
26
+ }
27
+ /**
28
+ * La lista dei DLV numerata come la numera `run-task`, coi fatti in grigio e
29
+ * spuntati, più una riga di stato.
30
+ *
31
+ * Il cursore c'è solo quando il gate è `ready`: fuori da lì nessuna voce è
32
+ * selezionabile, e un caret su una riga inerte prometterebbe un gesto che non
33
+ * esiste. Sopra `rows` la lista si finestra sul cursore; senza cursore parte
34
+ * dall'inizio, e la riga di stato dice quante voci restano fuori.
35
+ */
36
+ export function DlvArea({ dlvs, cursor, selected, gate, prompt, model, rows, width, }) {
37
+ const ready = gate.kind === 'ready';
38
+ const at = ready ? dlvs.findIndex((d) => d.n === cursor) : -1;
39
+ const { start, end } = windowRange(dlvs.length, at, rows);
40
+ const numW = String(dlvs.length).length;
41
+ // caret + marca + spazio + numero + spazio: tutto ciò che precede il testo.
42
+ const textW = Math.max(1, width - CARET_OFF.length - DLV_MARK_W - 1 - numW - 1);
43
+ const status = statusParts(gate, prompt, model, start, dlvs.length - end);
44
+ const [lead, body, tail, overflow] = cutParts(status.parts, width, 1);
45
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [dlvs.slice(start, end).map((d) => {
46
+ const focused = ready && d.n === cursor;
47
+ const picked = selected.has(d.n);
48
+ const mark = d.done ? DLV_MARK.done : picked ? DLV_MARK.picked : DLV_MARK.open;
49
+ return (_jsxs(Text, { wrap: "truncate-end", children: [focused ? CARET : CARET_OFF, _jsx(Text, { color: picked ? 'cyan' : undefined, dimColor: d.done, children: pad(mark, DLV_MARK_W) }), ' ', _jsx(Text, { dimColor: true, children: pad(String(d.n), numW, 'right') }), ' ', _jsx(Text, { dimColor: d.done, color: picked ? 'cyan' : undefined, bold: focused, children: cut(plain(d.text), textW) })] }, d.n));
50
+ }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: status.tone === 'off' ? undefined : 'yellow', dimColor: status.tone === 'off', children: lead }), _jsx(Text, { color: status.tone === 'ready' ? 'cyan' : status.tone === 'blocked' ? 'yellow' : undefined, dimColor: status.tone === 'off', children: body }), _jsx(Text, { dimColor: true, children: tail }), _jsx(Text, { dimColor: true, children: overflow })] })] }));
51
+ }
@@ -153,7 +153,7 @@ export function screenFor(input) {
153
153
  // posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
154
154
  // può accorciare il testo sotto uno scroll già dato.
155
155
  const start = Math.min(sheet.top, sheet.maxTop);
156
- return (_jsx(DetailScreen, { id: sheet.sheet.id, title: sheet.sheet.title, missing: sheet.sheet.text === null, lines: sheet.lines.slice(start, start + sheet.capacity), spans: sheet.doc?.spans ?? [], top: start, total: sheet.lines.length, capacity: sheet.capacity, action: sheet.action, model: sheet.model, spawnNote: sheet.spawnNote, prompt: sheet.prompt, priority: sheet.priority, cursor: sheet.cursor, columns: columns, find: sheet.find, occ: sheet.findRes.occ, occCur: sheet.occCur }));
156
+ return (_jsx(DetailScreen, { id: sheet.sheet.id, title: sheet.sheet.title, missing: sheet.sheet.text === null, lines: sheet.lines.slice(start, start + sheet.capacity), spans: sheet.doc?.spans ?? [], top: start, total: sheet.lines.length, capacity: sheet.capacity, action: sheet.action, model: sheet.model, spawnNote: sheet.spawnNote, prompt: sheet.prompt, priority: sheet.priority, cursor: sheet.cursor, columns: columns, find: sheet.find, occ: sheet.findRes.occ, occCur: sheet.occCur, dlv: sheet.dlv, dlvRows: sheet.dlvRows }));
157
157
  }
158
158
  // ── T121 · viewer del project status ────────────────────────────────────
159
159
  // Quinta schermata sostitutiva, stessa ragione delle altre quattro: un recap
package/dist/viewport.js CHANGED
@@ -74,7 +74,7 @@ export const MODAL_HEIGHT = {
74
74
  // Le quattro righe sono FISSE, non condizionali: la quarta ospita tanto gli
75
75
  // scarti del bulk quanto la scelta keep/purge della singola task sporca, e due
76
76
  // condizionali mutuamente esclusivi obbligherebbero il budget a sapere QUALE
77
- // dei due è a schermo (stessa ragione della riga nota in DETAIL_CHROME, T111).
77
+ // dei due è a schermo (stessa ragione della riga nota in DETAIL_FIELD_ROWS, T111).
78
78
  purge: 7,
79
79
  // Conferma di eliminazione di una conversazione: marginTop + 2 bordi + 3
80
80
  // righe (domanda con l'id, titolo, effetto sul disco + tasti). Nessuna riga
@@ -278,30 +278,38 @@ const READER_CHROME = 8;
278
278
  export function readerCapacity(rows) {
279
279
  return Math.max(0, (rows || 24) - SLACK - READER_CHROME);
280
280
  }
281
- // T66 — cornice del detail della task.
281
+ // T66 — cornice del detail della task, comune ai due modi (T189).
282
282
  // 2 bordi del box esterno
283
283
  // 1 titolo "loom-deck"
284
284
  // 1 riga meta (id · titolo · posizione nel testo)
285
285
  // 1 riga hint
286
286
  // 1 marginTop del box testo
287
287
  // 2 bordi del box testo
288
- // 1 marginTop dell'area di compilazione
288
+ // 1 marginTop dell'area sotto il testo
289
+ //
290
+ // Il detail non può riusare READER_CHROME perché sotto il testo porta un'AREA
291
+ // in più dentro l'overlay, e ogni riga di quell'area va scalata dalla capienza
292
+ // del contenuto o il frame sfonda `rows` (stessa invariante di
293
+ // TASKS_PANE_CHROME).
294
+ const DETAIL_BASE_CHROME = 9;
295
+ // L'area di compilazione: righe FISSE, una per campo.
289
296
  // 1 riga azione (T66)
290
297
  // 1 riga prompt (T117)
291
298
  // 1 riga modello (T108)
292
299
  // 1 riga titolo (T111)
293
300
  // 1 riga priorità (T158)
294
301
  //
295
- // Le ultime sei sono il motivo per cui il detail non può riusare READER_CHROME:
296
- // le cinque righe dell'area di compilazione sono righe FISSE in più dentro
297
- // l'overlay, e ogni riga fissa aggiunta va scalata dalla capienza del contenuto o
298
- // il frame sfonda `rows` (stessa invariante di TASKS_PANE_CHROME).
299
- //
300
- // Sono fisse e non `extra` condizionali di `detailCapacity` (T111 · D1): due
302
+ // Fisse e non `extra` condizionali dei singoli campi (T111 · D1): due
301
303
  // condizionali mutuamente esclusivi obbligherebbero il parametro a dire QUALE è
302
304
  // a schermo — un solo booleano per entrambi sottostima quando quello aperto è il
303
305
  // più alto, e sommarli toglie righe al testo per un campo che non c'è.
304
- const DETAIL_CHROME = 14;
306
+ export const DETAIL_FIELD_ROWS = 5;
307
+ // T189 — il modo DLV mette al posto dei campi la lista dei deliverable più una
308
+ // riga di stato (cosa parte su `⏎`, o perché non parte niente). È l'altezza che
309
+ // `detailCapacity` riceve come `area` quando il modo è attivo: il chiamante
310
+ // dice cosa c'è a schermo con un numero di righe, non con un flag, per la
311
+ // stessa ragione scritta sopra sui condizionali.
312
+ export const DLV_STATUS_ROWS = 1;
305
313
  // T91 — la ricerca dentro il detail: marginTop + riga del campo.
306
314
  //
307
315
  // `MODAL_HEIGHT` dice che il detail costa 0 ai due pane (li sostituisce), e da
@@ -312,10 +320,25 @@ const DETAIL_CHROME = 14;
312
320
  // frame nello scrollback a ogni tick del poll.
313
321
  const DETAIL_SEARCH_CHROME = 2;
314
322
  /** Righe di task file che entrano nel terminale; con la ricerca aperta il campo
315
- * si paga qui, non altrove. */
316
- export function detailCapacity(rows, searching = false) {
323
+ * si paga qui, non altrove. `area` = righe dell'area sotto il testo: i campi
324
+ * per default, `dlvListRows(…) + DLV_STATUS_ROWS` nel modo DLV. */
325
+ export function detailCapacity(rows, searching = false, area = DETAIL_FIELD_ROWS) {
326
+ const extra = searching ? DETAIL_SEARCH_CHROME : 0;
327
+ return Math.max(0, (rows || 24) - SLACK - DETAIL_BASE_CHROME - area - extra);
328
+ }
329
+ /**
330
+ * T189 — righe della lista DLV: tutte quelle che ci sono, ma mai più di METÀ
331
+ * dello spazio sotto la cornice. Il resto resta al testo della task, che è ciò
332
+ * per cui il detail si apre; una lista più lunga si finestra sul cursore
333
+ * (`windowRange`) e la riga di stato dice quante righe stanno fuori.
334
+ *
335
+ * Su un terminale basso può valere 0 anche con dei DLV: resta la sola riga di
336
+ * stato, e il frame non sfonda.
337
+ */
338
+ export function dlvListRows(rows, searching, total) {
317
339
  const extra = searching ? DETAIL_SEARCH_CHROME : 0;
318
- return Math.max(0, (rows || 24) - SLACK - DETAIL_CHROME - extra);
340
+ const free = (rows || 24) - SLACK - DETAIL_BASE_CHROME - DLV_STATUS_ROWS - extra;
341
+ return Math.max(0, Math.min(total, Math.floor(free / 2)));
319
342
  }
320
343
  // T121 — cornice del viewer del project status. Gemella di READER_CHROME: il
321
344
  // viewer scorre testo e basta, senza l'area di compilazione che rende caro il
@@ -383,7 +406,7 @@ const SPAWN_CHROME = 8;
383
406
  // si modificano. Un'altezza che dipendesse dalla riga selezionata farebbe
384
407
  // saltare la tabella di due righe a ogni movimento del caret, e il budget
385
408
  // dovrebbe sapere quale riga è aperta per non sottostimare — lo stesso motivo
386
- // per cui le cinque righe del detail sono dentro `DETAIL_CHROME` invece che
409
+ // per cui le cinque righe del detail sono fisse in `DETAIL_FIELD_ROWS` invece che
387
410
  // condizionali (T111 · D1).
388
411
  const SPAWN_FIELDS_CHROME = 5;
389
412
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.77.0",
3
+ "version": "0.79.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": {