@lamemind/loom-deck 0.19.0 → 0.21.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
@@ -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,6 +79,9 @@ 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
86
  | immediata | `t` | terminale @project-root (surface standard launch) |
80
87
  | immediata | `c` | sessione Claude **nuda**: nessuna task, nessun prompt iniziale |
@@ -94,6 +101,34 @@ finestra Ptyxis, senza passare da un modale. `c` (minuscola, azione) e `C`
94
101
  (maiuscola, modale create-task) restano distinte per la regola sopra — così come
95
102
  `f` (fork) e `F` (filtri).
96
103
 
104
+ ### I quattro modi di entrare in una task
105
+
106
+ Sulla task selezionata (focus sul pane Tasks) quattro tasti aprono una sessione
107
+ **bound**: `LOOM_TASK` iniettata, `sessionId` pinnato, binding scritto nel
108
+ sidecar. Fra loro cambia **solo il prompt iniziale**.
109
+
110
+ | Tasto | Prompt |
111
+ |---|---|
112
+ | `⏎` | nessuno — il contesto lo carica l'hook `SessionStart`, il primo messaggio lo scrivi tu |
113
+ | `^K` | `recap stato task <id>` — prompt diretto, nessuna skill |
114
+ | `^P` | `/loom-works:preflight-task <id>` |
115
+ | `^R` | `/loom-works:run-task <id>`, oppure `/loom-works:run-doc <id>` se l'id è una `D` |
116
+
117
+ Il prompt iniziale è la scelta di **chi apre**, non una proprietà della task: lo
118
+ stesso task file si apre per leggerne lo stato, per congelarne le decisioni, per
119
+ eseguirlo o per entrarci a mani nude — e il tasto premuto *è* quell'intento.
120
+ Prima esisteva il solo `⏎` col recap, quindi ogni altro ingresso passava per una
121
+ sessione da correggere a mano.
122
+
123
+ Col focus sul pane Sessions i tre `CTRL` non spawnano: l'oggetto dell'azione è la
124
+ task selezionata, e senza quel pane a fuoco non ce n'è una (il deck lo dice nella
125
+ riga di nota, invece di aprire qualcosa a caso).
126
+
127
+ Il catalogo dei prompt vive in `scripts/deck-run`, non nella TUI: è il primitive
128
+ UI-agnostico, il deck gli passa un **simbolo** (`--prompt-kind`) e non una
129
+ stringa. Così il testo sta in un posto solo, e con lui il suo vincolo di quoting
130
+ — il prompt viaggia dentro apici singoli in `bash -lc`, quindi non può contenerne.
131
+
97
132
  ### `f` — forkare una conversazione
98
133
 
99
134
  Il fork rama la sessione selezionata: `claude --resume <origine> --fork-session`
@@ -241,16 +276,21 @@ npx @lamemind/loom-deck
241
276
  ```bash
242
277
  # dalla project dir con un tasks.md
243
278
  scripts/deck-run T18
279
+ scripts/deck-run T18 --prompt-kind none|recap|preflight|run
244
280
  ```
245
281
 
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}`).
282
+ Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude 'recap stato task T18'`.
283
+ Il prompt iniziale è il terzo asse dello spawn, ortogonale al binding task
284
+ (`<TaskID>` vs `--no-task`) e alla continuità (nuova vs `--resume`/`--fork`):
285
+ senza flag resta `recap`, `none` apre la sessione bound **senza** alcun prompt.
286
+ `LOOM_DECK_ENTER_PROMPT` (placeholder `{TASK}`) resta un override che vince sul
287
+ kind — tranne su `none`, che è una richiesta esplicita di non averne.
248
288
 
249
289
  ## Sviluppo (TUI Ink)
250
290
 
251
291
  ```bash
252
292
  npm install
253
- npm run dev # tsx src/cli.tsx — lista tasks.md reale, ↑↓ naviga · spawn · q esci
293
+ npm run dev # tsx src/cli.tsx — lista tasks.md reale, ↑↓ naviga · ⏎/^K/^P/^R spawn · q esci
254
294
  npm run build # tsc → dist/
255
295
  npm test # node:test sul core vista (src/view.ts), senza Ink né terminale
256
296
  ```
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { render, Box, Text, useApp, useInput, useStdout } from 'ink';
4
4
  import { useState, useEffect, useMemo, useRef } from 'react';
5
5
  import { spawn } from 'node:child_process';
@@ -15,7 +15,7 @@ import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSess
15
15
  import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, rowLabel, selectedSession, stripProjectCore, } from './session-list.js';
16
16
  import { launchLegend, loadIdentity, loadLaunch } from './config.js';
17
17
  import { isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
18
- import { cut, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
18
+ import { caretWindow, cut, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
19
19
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
20
20
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
21
21
  import { loadView, saveView, viewFilePath } from './view-store.js';
@@ -35,6 +35,31 @@ const EDIT_ROWS = 4;
35
35
  function isTextRow(r) {
36
36
  return r === 2 || r === 3;
37
37
  }
38
+ /** Chiave della bozza scritta dalla riga di testo `r`. */
39
+ function editField(r) {
40
+ return r === 2 ? 'detail' : 'title';
41
+ }
42
+ /**
43
+ * Lunghezza in CODE POINT. Il caret ci indicizza sopra: `.length` conterebbe le
44
+ * code unit UTF-16 e un'emoji nel titolo varrebbe 2 posizioni, cioè un cursore
45
+ * che si ferma a metà glifo e un `slice` che lo spezza in due surrogati.
46
+ */
47
+ function cpLen(s) {
48
+ return [...s].length;
49
+ }
50
+ /** Inserisce `ins` alla posizione `at` (code point). */
51
+ function insertAt(s, at, ins) {
52
+ const cp = [...s];
53
+ return cp.slice(0, at).join('') + ins + cp.slice(at).join('');
54
+ }
55
+ /** Toglie il code point in posizione `at`; fuori range = stringa invariata. */
56
+ function removeAt(s, at) {
57
+ const cp = [...s];
58
+ if (at < 0 || at >= cp.length)
59
+ return s;
60
+ cp.splice(at, 1);
61
+ return cp.join('');
62
+ }
38
63
  // Modale sort a grammatica libera: un tasto per chiave, pressioni successive
39
64
  // ciclano asc → desc → fuori dalla chain.
40
65
  const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
@@ -95,8 +120,11 @@ function spawnOut(cmd, args, opts) {
95
120
  // Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
96
121
  // ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
97
122
  // sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
98
- function spawnDeck(id, cwd, sessionId) {
99
- 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], {
100
128
  cwd,
101
129
  detached: true,
102
130
  stdio: 'ignore',
@@ -618,6 +646,36 @@ function Deck({ cwd, tasksPath, tasksDir }) {
618
646
  });
619
647
  child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
620
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
+ }
621
679
  // T53 — apertura del modale nota sulla conversazione selezionata. Come
622
680
  // `openEdit`, la bozza parte dal valore ATTUALE: annotare una seconda volta è
623
681
  // quasi sempre correggere, e ripartire da vuoto costringerebbe a ridigitare
@@ -669,6 +727,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
669
727
  prog,
670
728
  detail: initialDetail(detail?.fields['Progress'] ?? '', prog),
671
729
  title: selTask.rawDesc,
730
+ // Si apre sulla riga 0 (priorità), che non è un campo di testo: il caret
731
+ // prende la sua posizione entrando in una riga di testo con ↑↓.
732
+ caret: 0,
672
733
  });
673
734
  setEditRow(0);
674
735
  setNote('');
@@ -1026,11 +1087,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1026
1087
  else if (key.return) {
1027
1088
  submitEdit();
1028
1089
  }
1029
- else if (key.upArrow) {
1030
- setEditRow((r) => ((r + EDIT_ROWS - 1) % EDIT_ROWS));
1031
- }
1032
- else if (key.downArrow) {
1033
- setEditRow((r) => ((r + 1) % EDIT_ROWS));
1090
+ else if (key.upArrow || key.downArrow) {
1091
+ const next = ((editRow + EDIT_ROWS + (key.upArrow ? -1 : 1)) % EDIT_ROWS);
1092
+ setEditRow(next);
1093
+ // Il caret segue la riga attiva e atterra in CODA al nuovo campo: è la
1094
+ // posizione da cui si continua a scrivere, ed è anche l'unica che non
1095
+ // dipende da dove stava il cursore nel campo precedente.
1096
+ setEdit((e) => (e && isTextRow(next) ? { ...e, caret: cpLen(e[editField(next)]) } : e));
1034
1097
  }
1035
1098
  else if ((key.leftArrow || key.rightArrow) && !isTextRow(editRow)) {
1036
1099
  const d = key.leftArrow ? -1 : 1;
@@ -1049,16 +1112,48 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1049
1112
  // Un solo ramo per i due campi di testo, la riga sceglie la chiave:
1050
1113
  // duplicarlo significherebbe tenere allineate a mano due copie della
1051
1114
  // stessa grammatica di input a ogni tasto aggiunto.
1052
- const field = editRow === 2 ? 'detail' : 'title';
1053
- if (key.backspace || key.delete) {
1054
- setEdit((e) => (e ? { ...e, [field]: e[field].slice(0, -1) } : e));
1115
+ const field = editField(editRow);
1116
+ if (key.ctrl) {
1117
+ // T54 ramo CTRL ANTEPOSTO a quelli su carattere, come in modalità
1118
+ // normale: `^A` e `a` arrivano con lo stesso `input`, quindi senza
1119
+ // questa precedenza il `^A` finirebbe dentro il testo.
1120
+ //
1121
+ // `^A`/`^E` (convenzione readline) perché `Home`/`End` NON sono
1122
+ // esposte da `useInput`: arrivano come input vuoto, indistinguibili
1123
+ // da qualunque altro tasto senza nome.
1124
+ //
1125
+ // `^D` è il delete-forward, e anche qui il motivo è un limite di Ink:
1126
+ // il tasto Backspace fisico manda `\x7f` e il tasto Canc manda
1127
+ // `\x1b[3~`, ma `parseKeypress` li battezza ENTRAMBI `delete` e
1128
+ // svuota `input` — a valle sono lo stesso evento. `key.delete` va
1129
+ // quindi al backspace (il tasto che si usa davvero) e la
1130
+ // cancellazione in avanti prende il suo tasto readline.
1131
+ if (input === 'a')
1132
+ setEdit((e) => (e ? { ...e, caret: 0 } : e));
1133
+ else if (input === 'e')
1134
+ setEdit((e) => (e ? { ...e, caret: cpLen(e[field]) } : e));
1135
+ else if (input === 'd')
1136
+ setEdit((e) => (e ? { ...e, [field]: removeAt(e[field], e.caret) } : e));
1137
+ }
1138
+ else if (key.leftArrow || key.rightArrow) {
1139
+ // CLAMP agli estremi, non wrap: a inizio campo `←` non deve saltare in
1140
+ // fondo. Le liste di valori (righe 0/1) ciclano perché sono 3-4 voci;
1141
+ // un testo no — il salto sarebbe indistinguibile da uno sfarfallio.
1142
+ const d = key.leftArrow ? -1 : 1;
1143
+ setEdit((e) => e ? { ...e, caret: Math.max(0, Math.min(cpLen(e[field]), e.caret + d)) } : e);
1144
+ }
1145
+ else if (key.backspace || key.delete) {
1146
+ setEdit((e) => e ? { ...e, [field]: removeAt(e[field], e.caret - 1), caret: Math.max(0, e.caret - 1) } : e);
1055
1147
  }
1056
- else if (input && !key.ctrl && !key.meta) {
1148
+ else if (input && !key.meta) {
1057
1149
  // `sanitizeTyped`: `useInput` consegna il CHUNK di stdin, quindi un
1058
1150
  // incollaggio porta dentro newline e byte di controllo — invisibili
1059
1151
  // nel campo ma contati da Ink nella larghezza della riga, e destinati
1060
- // a finire tali e quali dentro tasks.md.
1061
- setEdit((e) => (e ? { ...e, [field]: e[field] + sanitizeTyped(input) } : e));
1152
+ // a finire tali e quali dentro tasks.md. Ed è per lo stesso motivo che
1153
+ // il caret avanza della LUNGHEZZA del chunk, non di uno: un incollaggio
1154
+ // entra tutto insieme.
1155
+ const ins = sanitizeTyped(input);
1156
+ setEdit((e) => e ? { ...e, [field]: insertAt(e[field], e.caret, ins), caret: e.caret + cpLen(ins) } : e);
1062
1157
  }
1063
1158
  }
1064
1159
  return;
@@ -1076,6 +1171,15 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1076
1171
  setNote('');
1077
1172
  setMode('search');
1078
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
+ }
1079
1183
  return;
1080
1184
  }
1081
1185
  if (key.leftArrow || key.rightArrow || key.tab) {
@@ -1095,21 +1199,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1095
1199
  }
1096
1200
  else if (key.return) {
1097
1201
  if (focus === 'tasks') {
1098
- if (isSpot) {
1099
- setNote('spot: sessioni libere, nessuna task da spawnare');
1100
- }
1101
- else if (selTask) {
1102
- // sessionId pinnato lato deck binding sidecar scritto PRIMA dello
1103
- // spawn: la sessione risulta figlia della task appena il JSONL compare.
1104
- const sid = randomUUID();
1105
- appendTaskBinding(cwd, sid, selTask.id);
1106
- const child = spawnDeck(selTask.id, cwd, sid);
1107
- // Un errore di spawn è async: senza handler diventa uncaughtException
1108
- // e ucciderebbe il deck. Lo intercetto per preservare l'invariante
1109
- // "il deck resta vivo" e mostro la nota d'errore.
1110
- child.on('error', () => setNote(`⚠ spawn ${selTask.id} fallito (${DECK_RUN})`));
1111
- setNote(`⏎ spawn ${selTask.id} → tab CC (sid ${sid.slice(0, 8)})`);
1112
- }
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', '⏎');
1113
1208
  }
1114
1209
  else {
1115
1210
  // T49 — ⏎ su una sessione = resume in nuova tab. Il binding si rilegge
@@ -1345,7 +1440,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1345
1440
  if (budget.compact) {
1346
1441
  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"] })] }));
1347
1442
  }
1348
- 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 \u00B7", ' ', _jsx(Text, { color: "yellow", children: "testo" }), " su prog/titolo \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] }));
1443
+ 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", ' ', canSpawn ? '⏎/^K/^P/^R 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] }));
1349
1444
  }
1350
1445
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
1351
1446
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -1368,31 +1463,17 @@ function FilterModal({ view, cursor }) {
1368
1463
  })] }, row.label)))] }));
1369
1464
  }
1370
1465
  /**
1371
- * Taglio dalla TESTA: tiene la CODA della stringa dentro `cols`.
1466
+ * Campo di testo del modale edit: finestra ancorata al caret + cursore inverso
1467
+ * nella posizione REALE.
1372
1468
  *
1373
- * `cut()` fa l'opposto (tiene l'inizio) ed è giusto per un testo che si LEGGE;
1374
- * qui il testo si SCRIVE, il cursore sta in fondo (append-only come gli altri
1375
- * campi del deck) e ciò che serve vedere è l'ultima parola digitata, non la
1376
- * prima con `cut()` un titolo lungo sparirebbe sotto l'ellissi proprio mentre
1377
- * lo si sta scrivendo. Budget in COLONNE (`termWidth`), non in code unit: è
1378
- * quello che decide se la riga sfonda il bordo del box.
1469
+ * Il cursore non è più uno spazio inverso appiccicato in coda ma la cella `at`
1470
+ * della finestra — cioè il carattere su cui il caret sta davvero. Fuori fuoco
1471
+ * (`focused` falso) il caret non si disegna e la finestra si ancora in fondo,
1472
+ * che è la vista utile per un campo che non si sta scrivendo.
1379
1473
  */
1380
- function tailCut(s, cols) {
1381
- if (cols <= 0)
1382
- return '';
1383
- if (termWidth(s) <= cols)
1384
- return s;
1385
- const chars = [...s];
1386
- let out = '';
1387
- let w = 1; // la colonna dell'ellissi di testa
1388
- for (let i = chars.length - 1; i >= 0; i--) {
1389
- const cw = termWidth(chars[i]);
1390
- if (w + cw > cols)
1391
- break;
1392
- out = chars[i] + out;
1393
- w += cw;
1394
- }
1395
- return `…${out}`;
1474
+ function EditTextField({ label, value, caret, focused, cols, }) {
1475
+ const win = caretWindow(value, focused ? caret : cpLen(value), cols);
1476
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: label }), _jsxs(Text, { children: [' ', sanitize(win.head)] }), focused ? _jsx(Text, { inverse: true, children: sanitize(win.at) }) : null, _jsx(Text, { children: sanitize(win.tail) })] }));
1396
1477
  }
1397
1478
  // T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
1398
1479
  // coprirli: la riga che stai modificando resta visibile sopra la lista).
@@ -1400,14 +1481,14 @@ function tailCut(s, cols) {
1400
1481
  // del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
1401
1482
  function EditModal({ id, draft, row, columns, }) {
1402
1483
  const mark = (r) => (row === r ? CARET : CARET_OFF);
1403
- // Budget del campo titolo, DERIVATO da `columns` (mai una costante): il box
1484
+ // Budget dei campi di testo, DERIVATO da `columns` (mai una costante): il box
1404
1485
  // del modale è ANNIDATO nella cornice del deck, quindi le cornici da scalare
1405
1486
  // sono due — root (bordo 2 + paddingX 2) e modale (bordo 2 + paddingX 2) — più
1406
1487
  // caret 2, etichetta 6, gap 2 e cursore 1. Totale 19.
1407
1488
  // Un titolo di tasks.md arriva a ~64 caratteri: senza taglio la riga va a capo
1408
1489
  // dentro il box, che si alza di una riga e sfonda il budget verticale (invariante ③).
1409
- const titleBudget = Math.max(8, columns - 19);
1410
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 titolo, priorit\u00E0 e stato"] }), _jsxs(Text, { children: [mark(0), _jsx(Text, { dimColor: true, children: "pri " }), EDIT_PRI.map((p) => (_jsxs(Text, { inverse: draft.pri === p, color: draft.pri === p ? 'green' : 'gray', children: [' ', sanitize(PRI_GLYPH[p]), " ", PRI_LABEL[p]] }, p)))] }), _jsxs(Text, { children: [mark(1), _jsx(Text, { dimColor: true, children: "stato " }), EDIT_PROG.map((p) => (_jsxs(Text, { inverse: draft.prog === p, color: draft.prog === p ? 'green' : 'gray', children: [' ', sanitize(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(Text, { dimColor: true, children: "prog " }), _jsxs(Text, { children: [' ', sanitize(draft.detail)] }), row === 2 ? _jsx(Text, { inverse: true, children: " " }) : null, !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { children: [mark(3), _jsx(Text, { dimColor: true, children: "titolo" }), _jsxs(Text, { children: [' ', sanitize(tailCut(draft.title, titleBudget))] }), row === 3 ? _jsx(Text, { inverse: true, children: " " }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", sanitize(progressText(draft.prog, draft.detail))] })] }));
1490
+ const fieldBudget = Math.max(8, columns - 19);
1491
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 titolo, priorit\u00E0 e stato"] }), _jsxs(Text, { children: [mark(0), _jsx(Text, { dimColor: true, children: "pri " }), EDIT_PRI.map((p) => (_jsxs(Text, { inverse: draft.pri === p, color: draft.pri === p ? 'green' : 'gray', children: [' ', sanitize(PRI_GLYPH[p]), " ", PRI_LABEL[p]] }, p)))] }), _jsxs(Text, { children: [mark(1), _jsx(Text, { dimColor: true, children: "stato " }), EDIT_PROG.map((p) => (_jsxs(Text, { inverse: draft.prog === p, color: draft.prog === p ? 'green' : 'gray', children: [' ', sanitize(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(EditTextField, { label: "prog ", value: draft.detail, caret: draft.caret, focused: row === 2, cols: fieldBudget }), !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { children: [mark(3), _jsx(EditTextField, { label: "titolo", value: draft.title, caret: draft.caret, focused: row === 3, cols: fieldBudget })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", sanitize(progressText(draft.prog, draft.detail))] })] }));
1411
1492
  }
1412
1493
  // T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
1413
1494
  // caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
package/dist/width.js CHANGED
@@ -202,6 +202,102 @@ export function cut(s, cols) {
202
202
  // informazione, tolgono una colonna di testo.
203
203
  return out.trimEnd().replace(/…$/, '') + '…';
204
204
  }
205
+ /**
206
+ * Larghezza in colonne di UN carattere **dopo** `sanitize`.
207
+ *
208
+ * Misurare il carattere grezzo significa misurare qualcosa che nel frame non
209
+ * entra mai: la sanificazione può cambiarne la larghezza (`✔`, largo 1, diventa
210
+ * `✅`, largo 2 — invariante ①). Chi taglia su un budget deve contare le colonne
211
+ * che verranno DISEGNATE, altrimenti sbaglia di una colonna per ogni glifo
212
+ * sostituito. La cache di `fixChar` rende la doppia chiamata gratuita.
213
+ */
214
+ export function cellWidth(ch) {
215
+ return termWidth(sanitize(ch));
216
+ }
217
+ /**
218
+ * Porzione visibile di un campo di testo con un caret MOBILE, larga al più
219
+ * `cols` colonne.
220
+ *
221
+ * Perché non basta un taglio dalla coda: mostrare sempre le ultime `cols`
222
+ * colonne è corretto solo finché si scrive in fondo. Con un caret che si muove,
223
+ * appena rientra a sinistra del bordo visibile si finirebbe per digitare in un
224
+ * punto che non si vede — il testo deve scorrere DIETRO il cursore, non
225
+ * viceversa.
226
+ *
227
+ * Il caret è un indice per CODE POINT (`[...s]`), non per code unit: un'emoji
228
+ * nel titolo sono due code unit e muoversi per unità la spezzerebbe a metà.
229
+ *
230
+ * La finestra si espande ALTERNANDO i due lati, così il cursore resta al centro
231
+ * finché il testo lo consente: muoversi di un carattere fa scorrere il testo di
232
+ * uno, invece di far saltare la vista da un bordo all'altro. Le ellissi entrano
233
+ * nel budget e ne escono da sole — quando un lato tocca il bordo del testo la
234
+ * sua ellissi non serve più e la colonna liberata va all'altro lato.
235
+ *
236
+ * Restituisce i tre pezzi già pronti al render (non la sola stringa visibile):
237
+ * il cursore si disegna invertendo `at`, e ricavarlo tagliando `visible` a
238
+ * `cursorCol` vorrebbe dire rifare qui fuori lo stesso conteggio in colonne.
239
+ */
240
+ export function caretWindow(text, caret, cols) {
241
+ const empty = { head: '', at: '', tail: '', cursorCol: 0 };
242
+ if (cols <= 0)
243
+ return empty;
244
+ const chars = [...text];
245
+ const c = Math.max(0, Math.min(caret, chars.length));
246
+ // Il caret a fine campo non ha un carattere sotto di sé: gliene si dà uno
247
+ // virtuale, così il cursore ha sempre una cella da invertire e la finestra ha
248
+ // sempre un centro. È lo spazio inverso che il modale disegnava in coda.
249
+ const cells = c === chars.length ? [...chars, ' '] : chars;
250
+ const len = cells.length;
251
+ const w = cells.map(cellWidth);
252
+ const cost = (s, e, tw) => tw + (s > 0 ? 1 : 0) + (e < len ? 1 : 0);
253
+ let start = c;
254
+ let end = c + 1;
255
+ let textW = w[c];
256
+ // Budget più stretto della sola cella del cursore: non c'è finestra da dare.
257
+ if (cost(start, end, textW) > cols)
258
+ return empty;
259
+ let goRight = true;
260
+ let stuckL = false;
261
+ let stuckR = false;
262
+ while (!stuckL || !stuckR) {
263
+ const canR = end < len && !stuckR;
264
+ const canL = start > 0 && !stuckL;
265
+ if (!canR && !canL)
266
+ break;
267
+ if (canR && (goRight || !canL)) {
268
+ const nw = textW + w[end];
269
+ if (cost(start, end + 1, nw) > cols)
270
+ stuckR = true;
271
+ else {
272
+ textW = nw;
273
+ end++;
274
+ // Arrivare in fondo toglie l'ellissi di coda: la colonna che si libera
275
+ // può rimettere in gioco il lato che si era fermato per un pelo.
276
+ if (end === len)
277
+ stuckL = false;
278
+ }
279
+ }
280
+ else {
281
+ const nw = textW + w[start - 1];
282
+ if (cost(start - 1, end, nw) > cols)
283
+ stuckL = true;
284
+ else {
285
+ textW = nw;
286
+ start--;
287
+ if (start === 0)
288
+ stuckR = false;
289
+ }
290
+ }
291
+ goRight = !goRight;
292
+ }
293
+ const head = (start > 0 ? '…' : '') + cells.slice(start, c).join('');
294
+ return {
295
+ head,
296
+ at: cells[c],
297
+ tail: cells.slice(c + 1, end).join('') + (end < len ? '…' : ''),
298
+ cursorCol: termWidth(sanitize(head)),
299
+ };
300
+ }
205
301
  /**
206
302
  * Indice sorgente raggiunto consumando al più `cols` colonne da `from`.
207
303
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/deck-run CHANGED
@@ -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