@lamemind/loom-deck 0.67.0 → 0.69.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/dist/hooks.js CHANGED
@@ -8,13 +8,15 @@ import { statSync } from 'node:fs';
8
8
  import { loadTasks, loadTaskDetail } from './tasks.js';
9
9
  import { discoverProjectSessions } from './sessions.js';
10
10
  import { discoverLiveSessions, liveSig } from './live-sessions.js';
11
- import { loadSessionIndex } from './task-index.js';
11
+ import { appendSessionMeta, loadSessionIndex } from './task-index.js';
12
+ import { sidecarGaps } from './session-meta.js';
12
13
  import { archivableIds, SCAN_INTERVAL_MS } from './archivable.js';
13
14
  import { commitTimes } from './commit-times.js';
14
15
  import { scanEpicHierarchy, EMPTY_EPIC_HIERARCHY } from './epic-hierarchy.js';
15
16
  import { scanInbox, INBOX_SCAN_INTERVAL_MS } from './inbox.js';
16
17
  import { mixedCount, readWrapCache, runWrapScan, wrapCacheFile, wrapCount, } from './wrap-scan.js';
17
18
  import { purgeTargets } from './purge.js';
19
+ import { loadSpawnOverrides, saveSpawnOverrides, } from './spawn-config.js';
18
20
  import { POLL_MS } from './model.js';
19
21
  // Dimensioni del terminale, live sul resize.
20
22
  //
@@ -136,7 +138,15 @@ export function useEpicHierarchy(tasksDir) {
136
138
  // ha cache mtime-keyed interna → il poll è economico; qui si evita comunque il
137
139
  // re-render inutile con una signature (sessionId:ts + binding entries): setState
138
140
  // solo quando cambia davvero qualcosa.
139
- export function useSessions(projectRoot) {
141
+ //
142
+ // T162 — è anche il posto dove il deck RIEMPIE I BUCHI dei campi derivati del
143
+ // sidecar (`title`, `model`): qui le due fonti sono già in mano nello stesso
144
+ // giro, la corrente (le `Session`) e quella su disco (l'indice). Il `core` del
145
+ // progetto arriva per argomento e non si rilegge qui: la stessa derivazione
146
+ // vive già nel modello (`projectCore`), e una seconda copia toglierebbe dal
147
+ // titolo scritto nel sidecar una stringa diversa da quella che la lista toglie
148
+ // dalle proprie righe.
149
+ export function useSessions(projectRoot, core) {
140
150
  const [state, setState] = useState({
141
151
  sessions: [],
142
152
  bindings: new Map(),
@@ -167,6 +177,8 @@ export function useSessions(projectRoot) {
167
177
  pinned: new Map(),
168
178
  notes: new Map(),
169
179
  priority: new Set(),
180
+ titles: new Map(),
181
+ models: new Map(),
170
182
  };
171
183
  }
172
184
  // T62 — le vive stanno sullo STESSO tick delle altre fonti, non su una
@@ -182,6 +194,42 @@ export function useSessions(projectRoot) {
182
194
  live = new Map();
183
195
  }
184
196
  const { bindings, forkOf, pinned, notes, priority } = index;
197
+ // T162 — i campi derivati delle PINNATE, allineati prima della signature.
198
+ //
199
+ // Gira a ogni giro e non solo al cambio di stato, perché il buco può
200
+ // nascere senza che nulla nel deck si muova: a pinnare può essere compass,
201
+ // dalla modale sulla conversazione in focus, e compass il titolo non lo
202
+ // conosce. Il deck è l'unico che ce l'ha, quindi è l'unico che può
203
+ // chiudere quel buco — e lo fa passando, non al momento del gesto.
204
+ //
205
+ // Costa solo quando c'è da scrivere: senza pinnate `sidecarGaps` esce
206
+ // subito, e con un sidecar già allineato ritorna una lista vuota (è
207
+ // l'invariante di convergenza del modulo — vedi `session-meta.ts`).
208
+ //
209
+ // Fuori dal gate della signature di proposito: un sidecar temporaneamente
210
+ // non scrivibile deve poter essere ritentato al giro dopo, e con lo stato
211
+ // invariato la signature non cambierebbe mai più.
212
+ //
213
+ // Gli append NON entrano nella signature e non producono un re-render: il
214
+ // deck il titolo lo ha dalla `Session`, che è la fonte. Questi campi
215
+ // esistono per un lettore che non può aprire un transcript, cioè compass.
216
+ try {
217
+ for (const gap of sidecarGaps({
218
+ pinned,
219
+ sessions,
220
+ bindings,
221
+ titles: index.titles,
222
+ models: index.models,
223
+ core,
224
+ })) {
225
+ appendSessionMeta(projectRoot, gap.sessionId, gap);
226
+ }
227
+ }
228
+ catch {
229
+ // sidecar non scrivibile: il poll continua, il buco si riprova al giro
230
+ // dopo. Niente a schermo — è manutenzione di un campo di servizio, non
231
+ // un'azione che l'utente ha chiesto.
232
+ }
185
233
  // La signature copre anche fork, pin, note e marca di priorità: un record
186
234
  // di lineage, un toggle di pin, una nota appena scritta o un 🚨 acceso
187
235
  // cambiano la lista renderizzata, quindi devono forzare il re-render come
@@ -208,7 +256,7 @@ export function useSessions(projectRoot) {
208
256
  reload();
209
257
  const id = setInterval(reload, POLL_MS);
210
258
  return () => clearInterval(id);
211
- }, [projectRoot]);
259
+ }, [projectRoot, core]);
212
260
  return { ...state, reload: () => reloadRef.current() };
213
261
  }
214
262
  // T61 — conteggio delle Done oltre soglia, su una scala di refresh TUTTA SUA.
@@ -398,3 +446,38 @@ export function useTaskDetail(tasksDir, id) {
398
446
  }, [tasksDir, id]);
399
447
  return detail;
400
448
  }
449
+ /**
450
+ * T161 — gli OVERRIDE di spawn del progetto, tenuti vivi per la durata del deck.
451
+ *
452
+ * Lettura una volta al mount, e da lì in poi lo stato in memoria è la fonte: la
453
+ * pagina delle azioni è l'unico scrittore, e rileggere il file a ogni tick del
454
+ * poll significherebbe pagare una `readFileSync` ogni 1,5 secondi per un dato
455
+ * che cambia quando lo si cambia. Chi lo modifica fuori dal deck (a mano, o un
456
+ * altro deck sullo stesso progetto) si riallinea alla riapertura, che è lo stesso
457
+ * regime di `launch` e `archivableDays`.
458
+ *
459
+ * Gli AVVISI del parse si mostrano una volta, all'avvio, nella riga di stato: una
460
+ * voce scartata in silenzio si presenta come un override che non fa niente, cioè
461
+ * come un guasto del deck. La riga di stato è l'unica superficie sempre presente
462
+ * — la pagina potrebbe non venire mai aperta.
463
+ *
464
+ * Il salvataggio NON si fida: ricarica dal disco quello che ha appena scritto,
465
+ * così quello che la pagina mostra dopo `w` è quello che il file dice davvero,
466
+ * riduzione del titolo e scarti compresi.
467
+ */
468
+ export function useSpawnConfig({ cwd, setNote, }) {
469
+ const [parsed, setParsed] = useState(() => loadSpawnOverrides(cwd));
470
+ const warned = useRef(false);
471
+ useEffect(() => {
472
+ if (warned.current || parsed.warnings.length === 0)
473
+ return;
474
+ warned.current = true;
475
+ const [first, ...rest] = parsed.warnings;
476
+ setNote(`⚠ ${first}${rest.length > 0 ? ` · +${rest.length}` : ''}`);
477
+ }, [parsed, setNote]);
478
+ const save = useCallback((entries) => {
479
+ saveSpawnOverrides(cwd, entries);
480
+ setParsed(loadSpawnOverrides(cwd));
481
+ }, [cwd]);
482
+ return { overrides: parsed.entries, warnings: parsed.warnings, save };
483
+ }
package/dist/inbox.js CHANGED
@@ -192,16 +192,11 @@ export function inboxPrompt(f) {
192
192
  }
193
193
  return `/loom-works:${DRAIN_SKILL[f.natura]} ${f.basename}`;
194
194
  }
195
- /** Prefisso del titolo di una sessione aperta su un file inbox. Astrale come le
196
- * emoji delle azioni su task (`ACTION_EMOJI` in `src/spawn.ts`) e per la stessa
197
- * ragione: `sanitize` di `src/width.ts` sostituisce con `·` ogni glifo su cui
198
- * le due contabilità di larghezza divergono, e le emoji del BMP divergono
199
- * sempre. Sta anche nella whitelist di `_sane_note` in `deck-run`, o arriverebbe
200
- * nella tab ridotta a niente mentre la lista del deck la mostra. */
201
- const INBOX_EMOJI = '🧹';
202
195
  /**
203
- * Il titolo della sessione che il deck apre su un file inbox: `🧹 <basename>`,
204
- * senza l'estensione.
196
+ * T161 — il buco `{file}` del titolo di una sessione di drain: il basename senza
197
+ * estensione e senza punti. L'emoji del prefisso non sta più qui — è dentro il
198
+ * template della riga `drain` del catalogo delle azioni, dove il progetto può
199
+ * riscriverla.
205
200
  *
206
201
  * Il `.md` si toglie QUI e non lo si lascia cadere a valle: l'alfabeto di
207
202
  * `_sane_note` non ha il punto, quindi il titolo della tab uscirebbe con le due
@@ -213,9 +208,8 @@ const INBOX_EMOJI = '🧹';
213
208
  * invece di drenare, ma l'oggetto su cui lavora è lo stesso file, ed è il file
214
209
  * che il titolo nomina. L'azione la dice il prompt.
215
210
  */
216
- export function inboxTitle(f) {
217
- const name = f.basename.replace(/\.md$/i, '').replace(/\./g, '-');
218
- return `${INBOX_EMOJI} ${name}`;
211
+ export function inboxWords(f) {
212
+ return f.basename.replace(/\.md$/i, '').replace(/\./g, '-');
219
213
  }
220
214
  /**
221
215
  * Il testo del file inbox, per il detail fullscreen. `null` = illeggibile —
@@ -26,6 +26,7 @@ export const CAPTURING_MODES = [
26
26
  'status',
27
27
  'inbox',
28
28
  'wrap',
29
+ 'spawn',
29
30
  'reader',
30
31
  'search',
31
32
  'assign',
package/dist/input.js CHANGED
@@ -79,6 +79,7 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
79
79
  status: overlays.status.onKey,
80
80
  inbox: overlays.inbox.onKey,
81
81
  wrap: overlays.wrap.onKey,
82
+ spawn: overlays.spawn.onKey,
82
83
  reader: overlays.search.onReaderKey,
83
84
  search: overlays.search.onSearchKey,
84
85
  assign: overlays.assign.onKey,
@@ -335,6 +336,21 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
335
336
  // stesso doppio significato per contesto già in esercizio su `^F`.
336
337
  overlays.gitlink.bump();
337
338
  }
339
+ else if (input === 's') {
340
+ // T161/P13 preflight — `^S` apre la pagina delle azioni di spawn. Le
341
+ // schermate sostitutive si aprono tutte con `CTRL` più lettera, e `s`
342
+ // sta per «spawn»; `^A`, che sarebbe l'altra mnemonica, è già
343
+ // inizio-riga dentro ogni campo di testo del deck.
344
+ //
345
+ // Sul flow-control XON/XOFF il repo porta due commenti in
346
+ // contraddizione — `model.ts` dice che il raw mode di Ink lo disattiva e
347
+ // il tasto passa pulito, `cli.tsx` che il terminale lo intercetta. La
348
+ // contraddizione si chiude qui con una MISURA, non con un'opinione: il
349
+ // gate pty di `test/modes-smoke.test.ts` manda `^S` al deck vero dentro
350
+ // un pseudo-terminale e pretende che la pagina si apra, quindi se il
351
+ // byte non arrivasse il test fallirebbe invece di lasciare il dubbio.
352
+ overlays.spawn.openPage();
353
+ }
338
354
  else if (input === 'b') {
339
355
  // T134/D8 preflight — `^B` (box/bacheca) scambia i due pane dello slot
340
356
  // destro. Non è un modale né una schermata: il pane resta uno dei due
@@ -18,10 +18,9 @@ import { scanText, topForOffset } from '../text-search.js';
18
18
  import { parseMarkdown } from '../markdown.js';
19
19
  import { cpLen, insertAt, removeAt } from '../layout.js';
20
20
  import { sanitizeTyped } from '../glyphs.js';
21
- import { ACTION_HOTKEYS, DETAIL_ACTIONS, MODELS, MODEL_DEFAULT, specializeRecap, } from '../spawn.js';
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 { loadPromptCatalog, modelFor, promptFor } from '../prompt-catalog.js';
25
24
  // T117 — le righe dell'area di compilazione del detail, nell'ordine in cui si
26
25
  // leggono dall'alto. Gli indici sono nominati perché li usano insieme l'handler
27
26
  // dei tasti e la resa, e un `2` nudo in due file diversi è la coordinata che
@@ -41,7 +40,7 @@ export const DETAIL_FIELDS = [
41
40
  { kind: 'choice', count: PRIORITY_CHOICES.length },
42
41
  ];
43
42
  export function useSheetOverlay(deps) {
44
- const { rows, columns, setMode, setNote, onAction } = deps;
43
+ const { rows, columns, setMode, setNote, defaults, onAction } = deps;
45
44
  const [sheet, setSheet] = useState(null);
46
45
  const [top, setTop] = useState(0);
47
46
  const [action, setAction] = useState(0);
@@ -68,9 +67,6 @@ export function useSheetOverlay(deps) {
68
67
  * riparserebbe l'intero task file per un campo dell'header. */
69
68
  const [epic, setEpic] = useState(false);
70
69
  const [cursor, setCursor] = useState({ row: DROW.action, caret: 0 });
71
- // Il catalogo si legge una volta per vita del deck: è un file di quattro righe
72
- // accanto al codice, non un dato che cambia sotto i piedi.
73
- const catalog = useMemo(() => loadPromptCatalog(), []);
74
70
  // T91 — ricerca dentro il detail. `open` distingue i due modi in cui la si
75
71
  // lascia: `esc` butta via ciò che il modale ha prodotto (`find` a null,
76
72
  // evidenziazione via), `⏎` lo congela e restituisce il controllo allo strato
@@ -130,13 +126,13 @@ export function useSheetOverlay(deps) {
130
126
  setTop(0);
131
127
  setAction(0);
132
128
  setEpic(isEpic);
133
- // T152 — il modello segue l'azione iniziale, come già il prompt: su una
134
- // task Epic la prima azione specializza a `recap-epic`, che nel catalogo
135
- // può portare un modello diverso da `recap`. Nessun kind fuori catalogo →
136
- // MODEL_DEFAULT, lo stesso fallback che vale ovunque nel deck.
137
- setModel(modelFor(catalog, kind) ?? MODEL_DEFAULT);
138
- setSpawnNote('');
139
- setPrompt(promptFor(catalog, kind, next.id));
129
+ // T152/T161 — i tre campi partono dalla terna dell'azione, risolta sul
130
+ // catalogo e sugli override di progetto: su una task Epic la prima azione
131
+ // specializza a `recap-epic`, che può portare valori diversi da `recap`.
132
+ const d = defaults(kind, next.id);
133
+ setModel(d.model);
134
+ setSpawnNote(d.title ?? '');
135
+ setPrompt(d.prompt ?? '');
140
136
  setPriority(false);
141
137
  setCursor({ row: DROW.action, caret: 0 });
142
138
  setFind(null);
@@ -144,21 +140,27 @@ export function useSheetOverlay(deps) {
144
140
  setNote('');
145
141
  setMode('detail');
146
142
  }
147
- /** Cambia l'azione e RISCRIVE prompt e modello col default del nuovo kind (D2).
143
+ /** Cambia l'azione e RISCRIVE i tre campi col default del nuovo kind (D2).
148
144
  *
149
- * Nessuna preservazione della scelta manuale di modello, per la stessa
150
- * ragione già scritta sul prompt: col fuoco per riga un cambio di azione
151
- * ACCIDENTALE non esiste — `←→` cambiano azione solo dalla riga azione —
152
- * quindi la preservazione difenderebbe da nulla e costerebbe un campo che
153
- * non torna più al default della nuova azione (T152). */
145
+ * Nessuna preservazione delle scelte fatte a mano, per la stessa ragione già
146
+ * scritta sul prompt: col fuoco per riga un cambio di azione ACCIDENTALE non
147
+ * esiste — `←→` cambiano azione solo dalla riga azione — quindi la
148
+ * preservazione difenderebbe da nulla e costerebbe campi che non tornano più
149
+ * al default della nuova azione (T152).
150
+ *
151
+ * T161 — da qui ci passa anche il TITOLO, che prima restava vuoto e veniva
152
+ * riempito allo spawn da `fallbackTitle`: ora il campo mostra quello che
153
+ * partirà, come già il prompt e il modello. */
154
154
  function selectAction(index) {
155
155
  setAction(index);
156
156
  const id = sheet?.id;
157
157
  if (!id)
158
158
  return;
159
159
  const kind = specializeRecap(DETAIL_ACTIONS[index].kind, epic);
160
- setPrompt(promptFor(catalog, kind, id));
161
- setModel(modelFor(catalog, kind) ?? MODEL_DEFAULT);
160
+ const d = defaults(kind, id);
161
+ setPrompt(d.prompt ?? '');
162
+ setModel(d.model);
163
+ setSpawnNote(d.title ?? '');
162
164
  }
163
165
  // Il ponte fra le quattro righe e i quattro stati. Le righe restano
164
166
  // TIPIZZATE dove vivono (`ModelKind`, indice dell'azione) invece di finire in
@@ -279,13 +281,12 @@ export function useSheetOverlay(deps) {
279
281
  // Il kind viaggia specializzato quanto il prompt che lo accompagna: se il
280
282
  // campo è stato svuotato a mano il testo non parte e resta lui a dire cosa
281
283
  // ricevera' la sessione, quindi i due non possono divergere.
282
- const act = DETAIL_ACTIONS[action];
283
284
  const id = sheet?.id;
284
285
  const note = spawnNote.trim();
285
286
  const text = prompt.trim();
286
287
  close();
287
288
  if (id)
288
- onAction(id, specializeRecap(act.kind, epic), model, note, text, priority);
289
+ onAction(id, model, note, text, priority);
289
290
  return;
290
291
  }
291
292
  if (key.pageUp) {
@@ -0,0 +1,240 @@
1
+ // T161 — la PAGINA DELLE AZIONI DI SPAWN: ottava schermata sostitutiva.
2
+ //
3
+ // Una tabella di undici righe — una per azione che il deck sa aprire — con le
4
+ // quattro colonne funzione, titolo, modello, prompt. Le ultime tre si modificano
5
+ // e il salvataggio scrive il blocco `spawn` di `.claude/loom-works.json`.
6
+ //
7
+ // Due LIVELLI, non un edit dentro la cella (P14 preflight). Al primo si scorre
8
+ // la tabella; `⏎` apre sotto di essa l'area di compilazione della riga
9
+ // selezionata, che è la stessa forma già in esercizio nel detail della task —
10
+ // righe con un fuoco solo, `↑↓` fra le righe, `←→` sul valore o sul caret
11
+ // secondo il tipo (`fields.ts`). Un editor dentro la cella avrebbe voluto un
12
+ // caret che scorre dentro una colonna larga venti colonne, cioè una terza
13
+ // grammatica di input su una schermata che ne ha già due.
14
+ //
15
+ // La bozza vive IN MEMORIA fino a `w`. Comporre una configurazione non tocca il
16
+ // disco — stesso regime del salvataggio esplicito della vista (`w` sul deck) —
17
+ // e il file di progetto è committato: scriverci a ogni freccia produrrebbe un
18
+ // working tree sporco per una prova.
19
+ import { useState } from 'react';
20
+ import { pageStep, spawnListCapacity } from '../viewport.js';
21
+ import { fieldsKey } from '../fields.js';
22
+ import { cpLen } from '../layout.js';
23
+ import { saneTemplate } from '../sane-note.js';
24
+ import { configFilePath } from '../config.js';
25
+ import { cut } from '../width.js';
26
+ import { EXAMPLE_HOLES, MODELS, SPAWN_ACTIONS, editableFields, } from '../spawn-catalog.js';
27
+ import { rawSpawn, } from '../spawn-config.js';
28
+ /** Le tre righe dell'area di compilazione, nell'ordine in cui si leggono. Sono
29
+ * posizioni di SCHERMO e non indici del cursore: le righe non editabili si
30
+ * disegnano comunque (dicono perché non lo sono) ma il fuoco non ci passa. */
31
+ export const SROW = { title: 0, model: 1, prompt: 2 };
32
+ export function useSpawnPage(deps) {
33
+ const { cwd, rows, hasNote, overrides, save, catalog, setMode, setNote } = deps;
34
+ /** La bozza, `null` a pagina chiusa. Copia degli override all'apertura: una
35
+ * bozza che sopravvive alla chiusura sarebbe uno stato invisibile che `w`
36
+ * scriverebbe la volta dopo. */
37
+ const [draft, setDraft] = useState(null);
38
+ const [sel, setSel] = useState(0);
39
+ const [top, setTop] = useState(0);
40
+ const [dirty, setDirty] = useState(false);
41
+ /** L'area di compilazione è aperta sulla riga selezionata. */
42
+ const [editing, setEditing] = useState(false);
43
+ const [fieldTitle, setFieldTitle] = useState('');
44
+ const [fieldModel, setFieldModel] = useState(MODELS[0]);
45
+ const [fieldPrompt, setFieldPrompt] = useState('');
46
+ const [cursor, setCursor] = useState({ row: 0, caret: 0 });
47
+ const capacity = spawnListCapacity(rows, editing, hasNote);
48
+ const maxTop = Math.max(0, SPAWN_ACTIONS.length - capacity);
49
+ const open = draft !== null;
50
+ const action = SPAWN_ACTIONS[sel];
51
+ const editable = editableFields(action);
52
+ /** Le specifiche che `fields.ts` consuma: SOLO le celle editabili di questa
53
+ * riga, nell'ordine di schermo. Il cursore indicizza qui, non le tre righe
54
+ * disegnate — su `bare`, che ha il solo modello, `↑↓` non hanno dove andare e
55
+ * non devono finire su una riga che non si modifica. */
56
+ const specs = editable.map((f) => f === 'model' ? { kind: 'choice', count: MODELS.length } : { kind: 'text' });
57
+ const table = SPAWN_ACTIONS.map((a) => ({
58
+ action: a,
59
+ raw: rawSpawn(a, draft ?? overrides, catalog),
60
+ touched: (draft ?? overrides).has(a.id),
61
+ }));
62
+ function openPage() {
63
+ setDraft(new Map(overrides));
64
+ setSel(0);
65
+ setTop(0);
66
+ setDirty(false);
67
+ setEditing(false);
68
+ setNote('');
69
+ setMode('spawn');
70
+ }
71
+ function close() {
72
+ setMode('normal');
73
+ setDraft(null);
74
+ setEditing(false);
75
+ }
76
+ function scroll(delta) {
77
+ const next = Math.max(0, Math.min(SPAWN_ACTIONS.length - 1, sel + delta));
78
+ setSel(next);
79
+ // La finestra INSEGUE la selezione invece di centrarla: la tabella è di
80
+ // undici righe e su un terminale normale ci sta intera, quindi centrare
81
+ // farebbe saltare la lista sotto il caret per niente.
82
+ setTop(Math.max(0, Math.min(next, Math.max(Math.min(top, maxTop), next - capacity + 1))));
83
+ }
84
+ /** Apre l'area sulla riga selezionata, coi valori GREZZI correnti. */
85
+ function openFields() {
86
+ const raw = rawSpawn(action, draft ?? overrides, catalog);
87
+ const title = raw.title ?? '';
88
+ const prompt = raw.prompt ?? '';
89
+ setFieldTitle(title);
90
+ setFieldModel(raw.model);
91
+ setFieldPrompt(prompt);
92
+ // Caret in coda al primo campo editabile, come `caretAtEnd`: è la posizione
93
+ // da cui si continua a scrivere, e l'unica che non dipende da dove stava il
94
+ // cursore l'ultima volta che l'area era aperta.
95
+ const first = editable[0];
96
+ setCursor({ row: 0, caret: first === 'model' ? 0 : cpLen(first === 'title' ? title : prompt) });
97
+ setEditing(true);
98
+ }
99
+ /**
100
+ * Congela l'area nella bozza, tenendo solo ciò che DIFFERISCE dal default.
101
+ *
102
+ * È ciò che rende reversibile una modifica senza un gesto apposta: un valore
103
+ * riportato a mano a quello di partenza smette di essere un override, invece
104
+ * di restare nel file come una riga che non cambia niente (AC7). Il gesto
105
+ * apposta — `CANC` — resta per togliere tutte e tre le celle in un colpo.
106
+ */
107
+ function commitFields() {
108
+ const base = rawSpawn(action, new Map(), catalog);
109
+ const next = {};
110
+ for (const f of editable) {
111
+ if (f === 'model') {
112
+ if (fieldModel !== base.model)
113
+ next.model = fieldModel;
114
+ }
115
+ else if (f === 'title') {
116
+ // P10 — la riduzione al confine: quello che entra nella bozza è quello
117
+ // che comparirà nella tab, coi buchi preservati. Farla solo al
118
+ // salvataggio mostrerebbe per tutta la sessione un titolo che la tab non
119
+ // porterà mai.
120
+ const title = saneTemplate(fieldTitle);
121
+ if (title !== (base.title ?? ''))
122
+ next.title = title;
123
+ }
124
+ else if (fieldPrompt !== (base.prompt ?? '')) {
125
+ next.prompt = fieldPrompt;
126
+ }
127
+ }
128
+ setDraft((d) => {
129
+ const m = new Map(d ?? overrides);
130
+ if (Object.keys(next).length > 0)
131
+ m.set(action.id, next);
132
+ else
133
+ m.delete(action.id);
134
+ return m;
135
+ });
136
+ setDirty(true);
137
+ setEditing(false);
138
+ }
139
+ /** `CANC` sulla riga: via l'override, la riga torna al default. */
140
+ function clearRow() {
141
+ if (!(draft ?? overrides).has(action.id)) {
142
+ setNote(`CANC → ${action.id}: nessun override da togliere`);
143
+ return;
144
+ }
145
+ setDraft((d) => {
146
+ const m = new Map(d ?? overrides);
147
+ m.delete(action.id);
148
+ return m;
149
+ });
150
+ setDirty(true);
151
+ setNote(`CANC → ${action.id}: override rimosso, torna al default`);
152
+ }
153
+ function write() {
154
+ const entries = draft ?? overrides;
155
+ try {
156
+ save(entries);
157
+ }
158
+ catch (e) {
159
+ // Il file non è del deck: se non si parsa il saver rifiuta invece di
160
+ // rigenerarlo, e qui si dice perché. La bozza resta viva — chi ripara il
161
+ // file a mano può ripremere `w` senza rifare le modifiche.
162
+ setNote(`⚠ ${cut(e.message, 70)}`);
163
+ return;
164
+ }
165
+ setDirty(false);
166
+ const n = entries.size;
167
+ setNote(`w → ${n === 0 ? 'nessun override' : `${n} azione${n > 1 ? 'i' : ''} configurate`} · ${configFilePath(cwd)}`);
168
+ }
169
+ const fieldsIO = {
170
+ text: (row) => (editable[row] === 'title' ? fieldTitle : fieldPrompt),
171
+ setText: (row, next) => editable[row] === 'title' ? setFieldTitle(next) : setFieldPrompt(next),
172
+ choice: () => Math.max(0, MODELS.indexOf(fieldModel)),
173
+ setChoice: (_row, index) => setFieldModel(MODELS[index]),
174
+ };
175
+ function onFieldsKey(input, key) {
176
+ if (key.escape) {
177
+ setEditing(false);
178
+ return;
179
+ }
180
+ if (key.return) {
181
+ commitFields();
182
+ return;
183
+ }
184
+ fieldsKey(input, key, specs, cursor, setCursor, fieldsIO);
185
+ }
186
+ function onKey(input, key) {
187
+ if (!open)
188
+ return;
189
+ if (editing) {
190
+ onFieldsKey(input, key);
191
+ return;
192
+ }
193
+ if (key.escape) {
194
+ // Una bozza non salvata si perde: dirlo è l'unico modo di non perderla per
195
+ // sbaglio, e un modale di conferma su una pagina di configurazione
196
+ // costerebbe una domanda a ogni uscita.
197
+ if (dirty)
198
+ setNote('modifiche non salvate perse · w salva prima di uscire');
199
+ close();
200
+ return;
201
+ }
202
+ if (key.return) {
203
+ openFields();
204
+ return;
205
+ }
206
+ if (key.delete) {
207
+ clearRow();
208
+ return;
209
+ }
210
+ if (key.upArrow)
211
+ scroll(-1);
212
+ else if (key.downArrow)
213
+ scroll(1);
214
+ else if (key.pageUp)
215
+ scroll(-pageStep(capacity));
216
+ else if (key.pageDown)
217
+ scroll(pageStep(capacity));
218
+ else if (input === 'w' && !key.ctrl)
219
+ write();
220
+ }
221
+ return {
222
+ open,
223
+ table,
224
+ sel,
225
+ top,
226
+ capacity,
227
+ maxTop,
228
+ dirty,
229
+ editing,
230
+ editable,
231
+ fieldTitle,
232
+ fieldModel,
233
+ fieldPrompt,
234
+ cursor,
235
+ /** L'anteprima resa della riga aperta: i buchi riempiti coi valori finti. */
236
+ example: EXAMPLE_HOLES,
237
+ openPage,
238
+ onKey,
239
+ };
240
+ }
@@ -17,7 +17,7 @@ import { parseMarkdown } from '../markdown.js';
17
17
  import { CLAUDE_CMD, notifyDone, spawnProjectStatus } from '../spawn.js';
18
18
  import { readStatusCache, statusFilePath, statusLabel, writeStatusCache, } from '../project-status.js';
19
19
  export function useProjectStatus(deps) {
20
- const { cwd, name, rows, columns, setMode, setNote } = deps;
20
+ const { cwd, name, rows, columns, model, prompt, setMode, setNote } = deps;
21
21
  const path = useMemo(() => statusFilePath(cwd), [cwd]);
22
22
  // Letta al mount e basta: è ciò che fa ripartire un deck riaperto dall'ora di
23
23
  // prima invece che da `missing`.
@@ -78,7 +78,7 @@ export function useProjectStatus(deps) {
78
78
  setFailed(false);
79
79
  setStartedAt(Date.now());
80
80
  setNote(`⏳ project status di ${name}… (sid ${sid.slice(0, 8)})`);
81
- const child = spawnProjectStatus(cwd, sid, finish);
81
+ const child = spawnProjectStatus(cwd, sid, finish, model, prompt);
82
82
  child.on('error', () => {
83
83
  setStartedAt(null);
84
84
  setFailed(true);
@@ -14,7 +14,7 @@ import { useWrapScan } from '../hooks.js';
14
14
  import { pageStep, wrapListCapacity } from '../viewport.js';
15
15
  import { cpLen, insertAt, removeAt } from '../layout.js';
16
16
  import { sanitizeTyped } from '../glyphs.js';
17
- import { WRAP_DEFAULT_PATH, wrapPrompt } from '../wrap-scan.js';
17
+ import { WRAP_DEFAULT_PATH } from '../wrap-scan.js';
18
18
  export function useWrapOverlay(deps) {
19
19
  const { cwd, rows, setMode, setNote, onApply } = deps;
20
20
  // Lo stato dello scan vive QUI e non nel modello, sul precedente di
@@ -69,7 +69,7 @@ export function useWrapOverlay(deps) {
69
69
  if (key.return) {
70
70
  const target = path.trim() || WRAP_DEFAULT_PATH;
71
71
  close();
72
- onApply(target, wrapPrompt(target));
72
+ onApply(target);
73
73
  return;
74
74
  }
75
75
  if (key.upArrow) {
@@ -16,7 +16,7 @@
16
16
  import { readFileSync } from 'node:fs';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { dirname, join } from 'node:path';
19
- import { MODELS } from './spawn.js';
19
+ import { MODELS } from './spawn-catalog.js';
20
20
  /** Stessa risalita di `DECK_RUN`: src/ (dev) e dist/ (build) sono entrambi
21
21
  * figli della package root, quindi il sibling è un livello sopra. */
22
22
  export const PROMPT_CATALOG = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'prompt-catalog');