@lamemind/loom-deck 0.20.0 → 0.21.1

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
@@ -4,15 +4,19 @@ Deck TUI (Ink) **per-progetto** della famiglia [loom](https://github.com/lamemin
4
4
 
5
5
  Legge il `tasks.md` del progetto e, con un tasto (poi un click), **spawna** una tab
6
6
  [Ptyxis](https://gitlab.gnome.org/chergert/ptyxis) che avvia una sessione Claude Code
7
- già bound alla task via `LOOM_TASK`, con un prompt di recap sullo stato della task.
7
+ già bound alla task via `LOOM_TASK`.
8
8
 
9
9
  ```
10
- ↑↓ scegli la task → ⏎ → tab CC di fianco → LOOM_TASK bound + recap stato task
10
+ ↑↓ scegli la task → ⏎ → tab CC di fianco → LOOM_TASK bound
11
11
  ```
12
12
 
13
+ Il tasto premuto sceglie **con quale prompt** si entra: `⏎` a mani nude, `^K`
14
+ recap, `^P` preflight, `^R` esecuzione (vedi [I quattro modi di entrare in una
15
+ task](#i-quattro-modi-di-entrare-in-una-task)).
16
+
13
17
  Entrambi i prefissi del contratto loom entrano in lista: **`T`** (code task) e
14
- **`D`** (doc task). Nessuna differenza di trattamento il prompt iniziale è un
15
- recap, non l'invocazione di una skill, quindi vale identico sulle due famiglie.
18
+ **`D`** (doc task). Il trattamento è identico ovunque tranne che su `^R`, dove il
19
+ prompt dispatcha sulla skill di esecuzione della rispettiva famiglia.
16
20
 
17
21
  ## Ruolo nella famiglia loom
18
22
 
@@ -52,10 +56,10 @@ senza collisioni:
52
56
  |---|---|---|
53
57
  | `↑` `↓` | naviga nella lista | |
54
58
  | `←` `→` `tab` | cambia pane | |
55
- | `⏎` | azione primaria del pane | Tasks → spawna la task selezionata · Sessions → riprende (`claude --resume`) la sessione selezionata |
59
+ | `⏎` | azione primaria del pane | Tasks → spawna la task selezionata (senza prompt) · Sessions → riprende (`claude --resume`) la sessione selezionata |
56
60
  | **MAIUSCOLA** | **apre un modale** | cattura tutti i tasti; `esc` annulla, non esce |
57
61
  | minuscola | azione immediata, one-shot | |
58
- | `CTRL`+lettera | idiomi universali e toggle dentro i modali di testo | `^F` = find, come ovunque |
62
+ | `CTRL`+lettera | idiomi universali, toggle dentro i modali di testo, varianti di un'azione | `^F` = find, come ovunque · `^K`/`^P`/`^R` = spawn con un altro prompt |
59
63
  | `1`…`9` | voce `launch` n-esima del progetto | da `.claude/loom-works.json` |
60
64
  | `q` `esc` | esce dal deck | in un modale `esc` annulla soltanto |
61
65
 
@@ -75,25 +79,64 @@ Assegnazioni correnti:
75
79
  | modale | `S` | sort chain |
76
80
  | modale | `F` | filtri |
77
81
  | modale | `^F` | **ricerca full-text** nelle conversazioni del progetto |
82
+ | immediata | `^K` | spawna la task selezionata col prompt di **recap** |
83
+ | immediata | `^P` | spawna la task selezionata sul **preflight** |
84
+ | immediata | `^R` | spawna la task selezionata in **esecuzione** |
78
85
  | immediata | `f` | **forka** la sessione selezionata (solo col focus sul pane Sessions) |
79
- | immediata | `t` | terminale @project-root (surface standard launch) |
80
- | immediata | `c` | sessione Claude **nuda**: nessuna task, nessun prompt iniziale |
86
+ | immediata | `t` 💻 | terminale @project-root (surface standard launch) |
87
+ | immediata | `c` 🤖 | sessione Claude **nuda**: nessuna task, nessun prompt iniziale |
81
88
  | immediata | `w` | salva la vista corrente su disco |
82
89
  | launch | `1`…`9` | esegue il `command` della voce, con `cwd` = project root |
83
90
 
84
91
  Le voci `launch` sono elencate in una **riga di legenda** sotto il footer
85
- (`launch 1 📝 codium · 2 ☕ idea`): l'indice da solo è opaco, perché le voci sono
92
+ (`1 📝 codium · 2 ☕ idea`): l'indice da solo è opaco, perché le voci sono
86
93
  custom per-progetto e non hanno una lettera fissa per app. Se non entrano in
87
94
  larghezza, la legenda si ferma a voci intere e mostra il contatore di quelle
88
95
  fuori riga — mai un troncamento silenzioso. Il cap a `9` è imposto dai tasti-cifra,
89
96
  non dallo schema: un progetto può dichiarare più di 9 voci, quelle oltre la nona
90
97
  sono configurate ma non raggiungibili (e la legenda lo dice).
91
98
 
99
+ In legenda le due surface del cappello portano l'emoji del menu compass (`t 💻`,
100
+ `c 🤖`) invece della parola. Per il terminale compass usa 🖥️, che nel frame Ink
101
+ non sopravvive: `sanitize` lo sostituisce perché VTE lo disegna largo 1 mentre
102
+ `string-width` ne conta 2 (invariante ① di `src/width.ts`) — 💻 è il gemello
103
+ concorde. La legenda elenca solo i tasti attivi in quel momento: navigazione,
104
+ uscita e indicatore di focus non ci sono, e le voci contestuali spariscono
105
+ invece di mostrarsi inerti.
106
+
92
107
  `t` e `c` sono gemelle: entrambe aprono una surface del cappello nella stessa
93
108
  finestra Ptyxis, senza passare da un modale. `c` (minuscola, azione) e `C`
94
109
  (maiuscola, modale create-task) restano distinte per la regola sopra — così come
95
110
  `f` (fork) e `F` (filtri).
96
111
 
112
+ ### I quattro modi di entrare in una task
113
+
114
+ Sulla task selezionata (focus sul pane Tasks) quattro tasti aprono una sessione
115
+ **bound**: `LOOM_TASK` iniettata, `sessionId` pinnato, binding scritto nel
116
+ sidecar. Fra loro cambia **solo il prompt iniziale**.
117
+
118
+ | Tasto | Prompt |
119
+ |---|---|
120
+ | `⏎` | nessuno — il contesto lo carica l'hook `SessionStart`, il primo messaggio lo scrivi tu |
121
+ | `^K` | `recap stato task <id>` — prompt diretto, nessuna skill |
122
+ | `^P` | `/loom-works:preflight-task <id>` |
123
+ | `^R` | `/loom-works:run-task <id>`, oppure `/loom-works:run-doc <id>` se l'id è una `D` |
124
+
125
+ Il prompt iniziale è la scelta di **chi apre**, non una proprietà della task: lo
126
+ stesso task file si apre per leggerne lo stato, per congelarne le decisioni, per
127
+ eseguirlo o per entrarci a mani nude — e il tasto premuto *è* quell'intento.
128
+ Prima esisteva il solo `⏎` col recap, quindi ogni altro ingresso passava per una
129
+ sessione da correggere a mano.
130
+
131
+ Col focus sul pane Sessions i tre `CTRL` non spawnano: l'oggetto dell'azione è la
132
+ task selezionata, e senza quel pane a fuoco non ce n'è una (il deck lo dice nella
133
+ riga di nota, invece di aprire qualcosa a caso).
134
+
135
+ Il catalogo dei prompt vive in `scripts/deck-run`, non nella TUI: è il primitive
136
+ UI-agnostico, il deck gli passa un **simbolo** (`--prompt-kind`) e non una
137
+ stringa. Così il testo sta in un posto solo, e con lui il suo vincolo di quoting
138
+ — il prompt viaggia dentro apici singoli in `bash -lc`, quindi non può contenerne.
139
+
97
140
  ### `f` — forkare una conversazione
98
141
 
99
142
  Il fork rama la sessione selezionata: `claude --resume <origine> --fork-session`
@@ -241,16 +284,21 @@ npx @lamemind/loom-deck
241
284
  ```bash
242
285
  # dalla project dir con un tasks.md
243
286
  scripts/deck-run T18
287
+ scripts/deck-run T18 --prompt-kind none|recap|preflight|run
244
288
  ```
245
289
 
246
- Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude 'recap stato task T18'`
247
- (prompt override-abile via `LOOM_DECK_ENTER_PROMPT`, placeholder `{TASK}`).
290
+ Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude 'recap stato task T18'`.
291
+ Il prompt iniziale è il terzo asse dello spawn, ortogonale al binding task
292
+ (`<TaskID>` vs `--no-task`) e alla continuità (nuova vs `--resume`/`--fork`):
293
+ senza flag resta `recap`, `none` apre la sessione bound **senza** alcun prompt.
294
+ `LOOM_DECK_ENTER_PROMPT` (placeholder `{TASK}`) resta un override che vince sul
295
+ kind — tranne su `none`, che è una richiesta esplicita di non averne.
248
296
 
249
297
  ## Sviluppo (TUI Ink)
250
298
 
251
299
  ```bash
252
300
  npm install
253
- npm run dev # tsx src/cli.tsx — lista tasks.md reale, ↑↓ naviga · spawn · q esci
301
+ npm run dev # tsx src/cli.tsx — lista tasks.md reale, ↑↓ naviga · ⏎/^K/^P/^R spawn · q esci
254
302
  npm run build # tsc → dist/
255
303
  npm test # node:test sul core vista (src/view.ts), senza Ink né terminale
256
304
  ```
package/dist/cli.js CHANGED
@@ -120,8 +120,11 @@ function spawnOut(cmd, args, opts) {
120
120
  // Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
121
121
  // ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
122
122
  // sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
123
- function spawnDeck(id, cwd, sessionId) {
124
- const child = spawnOut(DECK_RUN, [id, '--session-id', sessionId], {
123
+ // Il kind è OBBLIGATORIO e non ha default qui: il default vive in deck-run (per
124
+ // le invocazioni a mano), mentre dal deck ogni tasto dichiara il proprio intento
125
+ // — un default silenzioso renderebbe indistinguibili `⏎` e `^K`.
126
+ function spawnDeck(id, cwd, sessionId, kind) {
127
+ const child = spawnOut(DECK_RUN, [id, '--session-id', sessionId, '--prompt-kind', kind], {
125
128
  cwd,
126
129
  detached: true,
127
130
  stdio: 'ignore',
@@ -643,6 +646,36 @@ function Deck({ cwd, tasksPath, tasksDir }) {
643
646
  });
644
647
  child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
645
648
  }
649
+ // T56 — apre una sessione bound alla task selezionata. Punto UNICO dei quattro
650
+ // tasti (⏎/^K/^P/^R): fra loro cambia solo il prompt iniziale, tutto il resto
651
+ // è identico — uuid pinnato, binding scritto PRIMA dello spawn (la sessione
652
+ // risulta figlia della task appena il JSONL compare), handler d'errore, nota.
653
+ // Quattro copie sarebbero quattro posti dove dimenticare il `child.on('error')`,
654
+ // e uno spawn fallito è async: senza handler diventa uncaughtException e
655
+ // ucciderebbe il deck, che invece deve restare vivo.
656
+ // La guardia di focus sta qui e non nei chiamanti perché l'oggetto dell'azione
657
+ // è la task SELEZIONATA: senza il pane task a fuoco non ce n'è una. Per `⏎` il
658
+ // ramo è irraggiungibile (ci arriva già dentro `focus === 'tasks'`); per i tre
659
+ // CTRL, che il ramo `key.ctrl` intercetta globalmente, è l'unico posto.
660
+ function spawnTaskSession(kind, keyLabel) {
661
+ if (focus !== 'tasks') {
662
+ setNote(`${keyLabel} → spawn: seleziona una task (←→ per il pane)`);
663
+ return;
664
+ }
665
+ if (isSpot) {
666
+ setNote('spot: sessioni libere, nessuna task da spawnare');
667
+ return;
668
+ }
669
+ if (!selTask)
670
+ return;
671
+ const task = selTask;
672
+ const sid = randomUUID();
673
+ appendTaskBinding(cwd, sid, task.id);
674
+ const child = spawnDeck(task.id, cwd, sid, kind);
675
+ child.on('error', () => setNote(`⚠ spawn ${task.id} fallito (${DECK_RUN})`));
676
+ const what = kind === 'none' ? '' : ` · ${kind}`;
677
+ setNote(`${keyLabel} spawn ${task.id}${what} → tab CC (sid ${sid.slice(0, 8)})`);
678
+ }
646
679
  // T53 — apertura del modale nota sulla conversazione selezionata. Come
647
680
  // `openEdit`, la bozza parte dal valore ATTUALE: annotare una seconda volta è
648
681
  // quasi sempre correggere, e ripartire da vuoto costringerebbe a ridigitare
@@ -1138,6 +1171,15 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1138
1171
  setNote('');
1139
1172
  setMode('search');
1140
1173
  }
1174
+ else if (input === 'k') {
1175
+ spawnTaskSession('recap', '^K');
1176
+ }
1177
+ else if (input === 'p') {
1178
+ spawnTaskSession('preflight', '^P');
1179
+ }
1180
+ else if (input === 'r') {
1181
+ spawnTaskSession('run', '^R');
1182
+ }
1141
1183
  return;
1142
1184
  }
1143
1185
  if (key.leftArrow || key.rightArrow || key.tab) {
@@ -1157,21 +1199,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1157
1199
  }
1158
1200
  else if (key.return) {
1159
1201
  if (focus === 'tasks') {
1160
- if (isSpot) {
1161
- setNote('spot: sessioni libere, nessuna task da spawnare');
1162
- }
1163
- else if (selTask) {
1164
- // sessionId pinnato lato deck binding sidecar scritto PRIMA dello
1165
- // spawn: la sessione risulta figlia della task appena il JSONL compare.
1166
- const sid = randomUUID();
1167
- appendTaskBinding(cwd, sid, selTask.id);
1168
- const child = spawnDeck(selTask.id, cwd, sid);
1169
- // Un errore di spawn è async: senza handler diventa uncaughtException
1170
- // e ucciderebbe il deck. Lo intercetto per preservare l'invariante
1171
- // "il deck resta vivo" e mostro la nota d'errore.
1172
- child.on('error', () => setNote(`⚠ spawn ${selTask.id} fallito (${DECK_RUN})`));
1173
- setNote(`⏎ spawn ${selTask.id} → tab CC (sid ${sid.slice(0, 8)})`);
1174
- }
1202
+ // T56 — ⏎ apre la sessione a MANI NUDE: bound alla task (LOOM_TASK,
1203
+ // sessionId pinnato, binding) ma senza messaggio iniettato — il contesto
1204
+ // lo carica l'hook SessionStart, il primo messaggio lo scrive l'utente.
1205
+ // Il recap che ⏎ faceva prima è passato a ^K: resta a un tasto, ma smette
1206
+ // di essere l'unico ingresso possibile nella task.
1207
+ spawnTaskSession('none', '⏎');
1175
1208
  }
1176
1209
  else {
1177
1210
  // T49 — ⏎ su una sessione = resume in nuova tab. Il binding si rilegge
@@ -1325,6 +1358,31 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1325
1358
  // Larghezza dal medesimo hook che dà l'altezza: dopo un resize la legenda si
1326
1359
  // ricalcola con lo stesso re-render che ridimensiona i pane.
1327
1360
  const legend = launchLegend(launch, columns);
1361
+ // Legenda della modalità normale. Elenca SOLO i tasti che fanno qualcosa qui e
1362
+ // ora: le voci contestuali compaiono quando il pane a fuoco le rende possibili
1363
+ // e altrimenti spariscono, invece di annunciarsi inerti con un `—`.
1364
+ // Fuori: navigazione (`↑↓` `←→`) e uscita (`q`), universali in qualunque TUI, e
1365
+ // l'indicatore `focus:` — il pane a fuoco si vede già dall'evidenziazione, e
1366
+ // ridirlo a parole costava colonne su una riga che tronca in silenzio.
1367
+ const keyLegend = sanitize([
1368
+ ...(canSpawn ? ['⏎/^K/^P/^R spawn'] : canResume ? ['⏎ resume'] : []),
1369
+ ...(canResume ? ['f fork'] : []),
1370
+ ...(canPin ? ['p pin', 'N nota'] : []),
1371
+ '^F cerca',
1372
+ 'C nuova',
1373
+ 'E edit',
1374
+ 'S sort',
1375
+ 'F filtri',
1376
+ 'w salva',
1377
+ // Emoji delle surface, le stesse del menu compass: 🤖 = nuova sessione
1378
+ // claude. Per il terminale compass usa 🖥️, che nel frame Ink NON passa —
1379
+ // `sanitize` lo sostituisce (VTE lo disegna largo 1, string-width dice 2:
1380
+ // discordante, invariante ① di width.ts) e resterebbe un `·` muto. 💻 è il
1381
+ // gemello concorde più vicino. Il `sanitize` attorno all'array è la rete
1382
+ // che rende quel vincolo automatico, non una cosa da ricordarsi.
1383
+ 't 💻',
1384
+ 'c 🤖',
1385
+ ].join(' · '));
1328
1386
  // ── T52 · schermate sostitutive ─────────────────────────────────────────
1329
1387
  // Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
1330
1388
  // pane: una lista di occorrenze non entra in un box da 4 righe. Prendono
@@ -1407,7 +1465,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1407
1465
  if (budget.compact) {
1408
1466
  return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? 'spot', " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
1409
1467
  }
1410
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : canResume ? 'resume' : '—', canResume ? ' · f fork' : '', canPin ? ' · p pin · N nota' : '', " \u00B7 ^F cerca \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 t term \u00B7 c claude \u00B7 q esci \u00B7 focus:", ' ', _jsx(Text, { color: "cyan", children: focus })] })), mode === 'normal' && launch.length > 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["launch ", legend.shown, legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1468
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' && launch.length > 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [legend.shown, legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1411
1469
  }
1412
1470
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
1413
1471
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
package/dist/config.js CHANGED
@@ -69,9 +69,11 @@ export function cellWidth(s) {
69
69
  export function launchLegend(entries, columns) {
70
70
  const reachable = entries.slice(0, LAUNCH_MAX);
71
71
  const unreachable = entries.length - reachable.length;
72
- // bordo + padding del box + prefisso "launch " 12 celle; pavimento a 24 per
73
- // non degenerare a legenda vuota su terminali strettissimi.
74
- const budget = Math.max(24, columns - 12);
72
+ // bordo + padding del box 4 celle, più 2 di margine; pavimento a 24 per
73
+ // non degenerare a legenda vuota su terminali strettissimi. Il prefisso
74
+ // "launch " non c'è più (la riga si riconosce dalla forma `indice emoji
75
+ // label`), quindi le 7 celle che occupava tornano alle voci.
76
+ const budget = Math.max(24, columns - 6);
75
77
  // I fallback di parseLaunch (`▸` per emoji, command per label) sono già
76
78
  // applicati a monte: qui non si re-implementano né si assumono campi popolati.
77
79
  const parts = reachable.map((e, i) => `${i + 1} ${e.emoji} ${e.label}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/deck-run CHANGED
@@ -4,8 +4,20 @@
4
4
  #
5
5
  # Apre una tab Ptyxis nella window ATTIVA (quella col focus = il deck) e vi
6
6
  # avvia una sessione Claude Code già bound alla task via LOOM_TASK, dritta sul
7
- # prompt iniziale (default: prompt diretto "recap stato task <TaskID>" — no skill,
8
- # solo recap più controllo). Riusabile identico da TUI (Ink) e da web.
7
+ # prompt iniziale scelto con --prompt-kind (default: `recap`, prompt diretto
8
+ # "recap stato task <TaskID>" no skill, solo recap più controllo).
9
+ # Riusabile identico da TUI (Ink) e da web.
10
+ #
11
+ # Tre assi ORTOGONALI, da non confondere fra loro:
12
+ # binding task <TaskID> ..... --no-task
13
+ # continuità nuova ........ --resume [--fork]
14
+ # prompt --prompt-kind none|recap|preflight|run
15
+ # Il terzo asse (T56) prima non esisteva: il prompt era una CONSEGUENZA degli
16
+ # altri due (bound ⇒ recap, --no-task ⇒ niente, --resume ⇒ niente), quindi
17
+ # "task-bound SENZA prompt" era inesprimibile e l'unico modo di non avere un
18
+ # prompt era perdere la task. Il catalogo dei prompt vive qui e non nel deck
19
+ # perché questo è il primitive UI-agnostico: il chiamante passa un SIMBOLO, non
20
+ # una stringa — il quoting resta verificato in un posto solo (vedi PROMPT sotto).
9
21
  #
10
22
  # Con --no-task la sessione è NUDA: niente LOOM_TASK, niente prompt iniziale,
11
23
  # niente --session-id. Serve al lavoro spot che non appartiene a nessuna task.
@@ -26,7 +38,7 @@
26
38
  # LOOM_DECK_SPAWN_MODE inline|profile (default: inline)
27
39
  # LOOM_DECK_PROFILE_UUID UUID profilo Ptyxis (default: cc-host)
28
40
  # LOOM_DECK_WORKDIR dir di lavoro della tab (default: $PWD)
29
- # LOOM_DECK_ENTER_PROMPT prompt iniziale sessione, {TASK}=TaskID (default: "recap stato task <TaskID>")
41
+ # LOOM_DECK_ENTER_PROMPT override del prompt iniziale, {TASK}=TaskID (vince sul kind, tranne `none`)
30
42
  # LOOM_DECK_INTAB_CMD override comando in-tab (default: la sessione CC; usato per i test)
31
43
  # LOOM_DECK_PERMISSION_MODE override del permissionMode del file config (default: campo file, poi 'manual')
32
44
  #
@@ -37,6 +49,10 @@ SESSION_ID=""
37
49
  RESUME_ID=""
38
50
  NO_TASK=0
39
51
  FORK=0
52
+ # Vuoto = flag non passato (≠ un default già risolto): serve a distinguere
53
+ # "kind implicito" da "kind chiesto", perché con --no-task il primo è legittimo
54
+ # (nessun prompt, come sempre) e il secondo è un errore d'uso.
55
+ PROMPT_KIND=""
40
56
  # Positional <TaskID> + flag opzionale --session-id <uuid> (T27): il deck genera
41
57
  # l'UUID e lo pinna così il binding sidecar sessionId↔taskId è deterministico.
42
58
  # --no-task (T42): modalità NUDA, senza TaskID — nessuna LOOM_TASK, nessun prompt
@@ -55,8 +71,17 @@ FORK=0
55
71
  # Sotto --fork il --session-id torna AMMESSO (anzi: è il punto) — la mutua
56
72
  # esclusione con --resume esiste perché una ripresa nuda riscrive il transcript
57
73
  # dell'id ripreso, mentre il fork ne apre uno NUOVO, che possiamo quindi pinnare.
74
+ # --prompt-kind <none|recap|preflight|run> (T56): sceglie il prompt iniziale fra
75
+ # quelli del catalogo qui sotto. Enum e non stringa libera: il prompt viaggia
76
+ # dentro apici singoli in `bash -lc`, quindi il testo va tenuto in un posto solo
77
+ # e verificato una volta, invece di spostare il rischio di quoting su ogni
78
+ # chiamante. Assente → `recap`, così ogni invocazione preesistente è invariata.
58
79
  while [[ $# -gt 0 ]]; do
59
80
  case "$1" in
81
+ --prompt-kind)
82
+ PROMPT_KIND="${2:-}"; shift 2 ;;
83
+ --prompt-kind=*)
84
+ PROMPT_KIND="${1#*=}"; shift ;;
60
85
  --session-id)
61
86
  SESSION_ID="${2:-}"; shift 2 ;;
62
87
  --session-id=*)
@@ -75,12 +100,19 @@ while [[ $# -gt 0 ]]; do
75
100
  esac
76
101
  done
77
102
 
78
- USAGE="uso: deck-run <TaskID> [--session-id <uuid>] (es. deck-run T18)
103
+ USAGE="uso: deck-run <TaskID> [--prompt-kind <kind>] [--session-id <uuid>]
104
+ (es. deck-run T18)
79
105
  | deck-run --no-task (sessione nuda, senza task)
80
106
  | deck-run <TaskID> --resume <uuid> (riprende sessione scoped)
81
107
  | deck-run --no-task --resume <uuid> (riprende sessione spot)
82
108
  | deck-run <TaskID> --resume <uuid> --fork [--session-id <nuovo-uuid>]
83
- (forka: ramo con id NUOVO)"
109
+ (forka: ramo con id NUOVO)
110
+
111
+ --prompt-kind prompt iniziale della sessione bound (default: recap)
112
+ none nessun prompt — sessione aperta sulla task, a mani nude
113
+ recap recap stato task <TaskID>
114
+ preflight /loom-works:preflight-task <TaskID>
115
+ run /loom-works:run-task <TaskID> (run-doc sulle task D)"
84
116
 
85
117
  if [[ $NO_TASK -eq 1 && -n "$TASK" ]]; then
86
118
  echo "--no-task e <TaskID> sono mutuamente esclusivi" >&2
@@ -102,6 +134,25 @@ if [[ -n "$RESUME_ID" && -n "$SESSION_ID" && $FORK -eq 0 ]]; then
102
134
  echo "$USAGE" >&2
103
135
  exit 2
104
136
  fi
137
+ # Un kind fuori catalogo è un errore d'uso del CHIAMANTE (simbolo interno, non
138
+ # configurazione utente) → exit, non fallback silenzioso come permissionMode:
139
+ # lì il valore arriva da un file editabile a mano, qui da un argomento nostro.
140
+ if [[ -n "$PROMPT_KIND" ]]; then
141
+ case "$PROMPT_KIND" in
142
+ none|recap|preflight|run) ;;
143
+ *) echo "--prompt-kind ignoto: '${PROMPT_KIND}' (usa none|recap|preflight|run)" >&2
144
+ echo "$USAGE" >&2
145
+ exit 2 ;;
146
+ esac
147
+ fi
148
+ # Ogni prompt del catalogo nomina il TaskID: senza task non c'è nulla da
149
+ # chiedere, e un `--no-task --prompt-kind run` è una richiesta contraddittoria,
150
+ # non una da degradare in silenzio.
151
+ if [[ $NO_TASK -eq 1 && -n "$PROMPT_KIND" ]]; then
152
+ echo "--prompt-kind richiede una task: una sessione --no-task non ha prompt iniziale" >&2
153
+ echo "$USAGE" >&2
154
+ exit 2
155
+ fi
105
156
 
106
157
  MODE="${LOOM_DECK_SPAWN_MODE:-inline}"
107
158
  PROFILE_UUID="${LOOM_DECK_PROFILE_UUID:-5a36ae48df1c4d4882f43060e3e59656}"
@@ -189,18 +240,42 @@ RES_FLAG=""
189
240
  FORK_FLAG=""
190
241
  [[ $FORK -eq 1 ]] && FORK_FLAG="--fork-session "
191
242
 
192
- # Prompt iniziale della sessione. Default: prompt DIRETTO (no skill) che chiede
193
- # a Claude un recap dello stato della task l'utente rivede/steera prima di
194
- # lanciare (= più controllo), nessuna esecuzione automatica. Override completo via
195
- # LOOM_DECK_ENTER_PROMPT, con placeholder {TASK} rimpiazzato dal TaskID. Vincolo: il
196
- # template NON deve contenere apici singoli (viaggia dentro '...' nel comando in-tab).
197
- # NB: il default NON può stare nel `${VAR:-...}` perché conterrebbe `{TASK}` e la sua
198
- # `}` chiuderebbe in anticipo l'espansione → if/else esplicito (default interpola
199
- # ${TASK} diretto; il placeholder {TASK} vale solo per il valore dell'override).
200
- if [[ -n "${LOOM_DECK_ENTER_PROMPT:-}" ]]; then
243
+ # ── Catalogo dei prompt iniziali (T56) ───────────────────────────────────────
244
+ # Il prompt iniziale è la scelta di CHI APRE la sessione, non una proprietà della
245
+ # task: lo stesso task file si apre per leggerne lo stato, per un preflight, per
246
+ # eseguirlo o a mani nude. Il kind è quell'intento.
247
+ # none → nessun prompt (≠ stringa vuota: PROMPT_ARG sparisce del tutto,
248
+ # come sul ramo --resume, altrimenti CC riceve un posizionale vuoto)
249
+ # recap → prompt DIRETTO, no skill: l'utente rivede/steera prima di lanciare
250
+ # preflight skill di preflight sulla task
251
+ # run → skill di esecuzione sulla task
252
+ # Vincolo di quoting: nessun template deve contenere apici singoli (viaggia
253
+ # dentro '...' nel comando in-tab). `/loom-works:...` passa intero proprio perché
254
+ # è single-quoted — lo slash e i due punti non sono metacaratteri lì dentro.
255
+ #
256
+ # Precedenza con l'env: LOOM_DECK_ENTER_PROMPT VINCE sul kind (retro-compat: chi
257
+ # lo usa oggi non si accorge del cambio), tranne su `none` — chiedere
258
+ # esplicitamente nessun prompt non è una richiesta che un default d'ambiente
259
+ # possa scavalcare. Il placeholder {TASK} vale solo per il valore dell'override;
260
+ # i template del catalogo interpolano ${TASK} diretto (nel `${VAR:-...}` la `}`
261
+ # di `{TASK}` chiuderebbe in anticipo l'espansione).
262
+ # `run` DISPATCHA sul prefisso: la lista del deck contiene entrambe le famiglie
263
+ # del contratto loom (`T` code, `D` doc) e le due hanno skill di esecuzione
264
+ # diverse — `run-task` è dichiarata code-only, `run-doc` è la sua complementare.
265
+ # Su una D il prompt `run-task` invocherebbe la skill sbagliata. Gli altri kind
266
+ # non discriminano: `recap` è un prompt diretto (nessuna skill) e
267
+ # `preflight-task` risolve il task file per id, indifferente al prefisso.
268
+ case "${PROMPT_KIND:-recap}" in
269
+ none) PROMPT="" ;;
270
+ recap) PROMPT="recap stato task ${TASK}" ;;
271
+ preflight) PROMPT="/loom-works:preflight-task ${TASK}" ;;
272
+ run) case "$TASK" in
273
+ D*) PROMPT="/loom-works:run-doc ${TASK}" ;;
274
+ *) PROMPT="/loom-works:run-task ${TASK}" ;;
275
+ esac ;;
276
+ esac
277
+ if [[ "${PROMPT_KIND:-}" != "none" && -n "${LOOM_DECK_ENTER_PROMPT:-}" ]]; then
201
278
  PROMPT="${LOOM_DECK_ENTER_PROMPT//\{TASK\}/${TASK}}"
202
- else
203
- PROMPT="recap stato task ${TASK}"
204
279
  fi
205
280
 
206
281
  # Comando eseguito dentro la tab: bind LOOM_TASK per la sessione, avvia CC sul PROMPT.
@@ -213,8 +288,10 @@ fi
213
288
  # Su resume (T49) il prompt iniziale salta anche nel ramo task: riprendere una
214
289
  # conversazione significa continuarla, non iniettarle un nuovo messaggio. Il
215
290
  # fork (T28) ricade nello stesso ramo — è una ripresa, con id nuovo.
216
- PROMPT_ARG="'${PROMPT}'"
217
- [[ -n "$RESUME_ID" ]] && PROMPT_ARG=""
291
+ # Il kind `none` (T56) atterra sulla stessa assenza per una ragione diversa: lì
292
+ # la sessione è nuova e bound, ma l'utente ha chiesto di entrarci a mani nude.
293
+ PROMPT_ARG=""
294
+ [[ -n "$PROMPT" && -z "$RESUME_ID" ]] && PROMPT_ARG="'${PROMPT}'"
218
295
  # Il SID_FLAG entra anche nel ramo --no-task, che altrimenti lo scarta insieme
219
296
  # agli altri elementi task-bound: nel fork di una sessione SPOT il sessionId
220
297
  # pinnato non serve a legare una task (non c'è) ma a rendere noto in anticipo