@lamemind/loom-deck 0.67.0 → 0.68.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.
@@ -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');
@@ -0,0 +1,115 @@
1
+ // T161 — il gemello TypeScript di `_sane_note`, la riduzione che `deck-run`
2
+ // applica a ogni nota del titolo.
3
+ //
4
+ // Perché serve un gemello. La nota del titolo è l'UNICO ingresso di testo libero
5
+ // nel titolo di una tab, quindi `deck-run` non la quota: la RIDUCE a una
6
+ // whitelist chiusa di code point, e tutto ciò che non ci rientra sparisce senza
7
+ // errore. Finché la nota nasceva da un campo scritto al volo, la divergenza fra
8
+ // quello che si legge nel deck e quello che compare nella tab durava il tempo di
9
+ // uno spawn. Da quando un TEMPLATE del titolo si scrive in una pagina e si salva
10
+ // su disco, quella divergenza diventa permanente: il file direbbe una cosa e la
11
+ // tab ne mostrerebbe un'altra, per sempre e senza un errore da nessuna parte.
12
+ //
13
+ // La riduzione si applica quindi QUI, al confine — al salvataggio e alla
14
+ // lettura del blocco `spawn` — così quello che sta nel file è quello che la tab
15
+ // mostrerà.
16
+ //
17
+ // L'ALLINEAMENTO fra i due lati non è assunto: `test/sane-note.test.ts` passa la
18
+ // stessa batteria di input a questa funzione e a `deck-run` vero (via lo shim
19
+ // `ptyxis` di `deck-run.test.ts`) e pretende l'uguaglianza. La whitelist non si
20
+ // allarga — e se un giorno lo facesse, sono tre le sedi da toccare insieme
21
+ // (`deck-run`, questo file, le emoji dei titoli in `spawn-catalog.ts`), col test
22
+ // che fallisce se una resta indietro.
23
+ /**
24
+ * L'alfabeto ammesso, negato: lettere, cifre, spazio, `_`, `-`, le accentate
25
+ * italiane e sei emoji.
26
+ *
27
+ * Whitelist e non blacklist, come in bash: il titolo finisce dentro apici
28
+ * singoli in `bash -lc "claude --name '…'"`, ed enumerare i caratteri pericolosi
29
+ * significa sbagliarne uno.
30
+ *
31
+ * Le sei emoji sono nominate una per una e NON come intervallo di code point,
32
+ * per due ragioni che valgono su entrambi i lati: un'emoji arriva nella tab solo
33
+ * se il deck la mostra identica in lista, cioè se `sanitize` (`src/width.ts`) la
34
+ * lascia passare invece di renderla `·`, e quel verdetto si verifica un glifo
35
+ * alla volta; e l'ordine di un intervallo dentro un bracket expression segue la
36
+ * collazione della locale, non i code point.
37
+ *
38
+ * Il flag `u` non è cosmetico: senza, le quattro emoji astrali entrerebbero
39
+ * nella classe come coppie di surrogati sciolti, e la riduzione lascerebbe
40
+ * passare mezzo carattere di qualunque altra emoji che ne condivida il primo.
41
+ */
42
+ const OUT_OF_ALPHABET = /[^A-Za-z0-9 _àèéìòùÀÈÉÌÒÙ📐🚀📊🏁🧹📏-]/gu;
43
+ /** Cap in CARATTERI (code point), non in byte: a byte un taglio a metà di una
44
+ * `à` lascerebbe UTF-8 rotto nel titolo. Largo di proposito — una tab che sfora
45
+ * tronca a destra da sola, e il limite serve solo a non spingere fuori vista la
46
+ * parte del titolo che compass matcha. */
47
+ export const SANE_CAP = 60;
48
+ /** Un buco di template: `{TASK}`, `{slug}`, `{file}`, `{path}`. La cattura nel
49
+ * gruppo è ciò che fa comparire i buchi nei dispari di `split`. */
50
+ const HOLE = /(\{[A-Za-z]+\})/;
51
+ /** Spazi collassati, estremi potati, cap: i tre passi che in bash sono `tr -s`,
52
+ * i due `${s#/%}` e `${s:0:60}`. */
53
+ function tidy(s) {
54
+ const squeezed = s.replace(/ +/g, ' ');
55
+ const trimmed = squeezed.replace(/^ /, '').replace(/ $/, '');
56
+ return [...trimmed].slice(0, SANE_CAP).join('');
57
+ }
58
+ /**
59
+ * La nota ridotta, esattamente come la ridurrebbe `deck-run`.
60
+ *
61
+ * Si applica al titolo GIÀ interpolato, cioè al testo che finisce nel sidecar e
62
+ * in `--title-note`: le due superfici che mostrano quel nome — la riga di lista
63
+ * del deck, che legge la nota grezza dal sidecar, e la tab Ptyxis — devono
64
+ * vedere la stessa stringa.
65
+ */
66
+ export function saneNote(raw) {
67
+ return tidy(raw.replace(OUT_OF_ALPHABET, ''));
68
+ }
69
+ /**
70
+ * La stessa riduzione, coi BUCHI preservati.
71
+ *
72
+ * Le graffe non sono nell'alfabeto: passare un template da `saneNote` lo
73
+ * ridurrebbe a `📐 slug`, cioè a un titolo che nomina la parola «slug» invece di
74
+ * interpolare il nome della task. Il template si riduce comunque — è testo
75
+ * scritto a mano, e il resto della riga deve sopravvivere alla tab — ma i buchi
76
+ * attraversano intatti e vengono riempiti dopo.
77
+ *
78
+ * Fuori dai buchi il comportamento è identico a `saneNote`, compresi collasso
79
+ * degli spazi, potatura e cap: un template senza buchi passa dalle stesse regole
80
+ * di una nota qualunque.
81
+ */
82
+ export function saneTemplate(raw) {
83
+ return tidy(raw
84
+ .split(HOLE)
85
+ .map((part, i) => (i % 2 === 1 ? part : part.replace(OUT_OF_ALPHABET, '')))
86
+ .join(''));
87
+ }
88
+ /**
89
+ * Riempie i buchi di un template. `null` quando un buco nominato non ha un
90
+ * valore da mettere.
91
+ *
92
+ * Il `null` non è difensivo, è il comportamento: un titolo `📐 {slug}` su una
93
+ * task il cui file non si trova diventerebbe `📐` — un prefisso solo, uguale per
94
+ * ogni task, che è peggio di nessun titolo. Chi chiama riceve `null` e apre la
95
+ * conversazione senza nota, che è ciò che faceva il vecchio `fallbackTitle`
96
+ * quando il task file mancava.
97
+ *
98
+ * I buchi non dichiarati dall'azione restano LETTERALI e non fanno fallire
99
+ * l'interpolazione: non sono un valore mancante, sono testo che l'utente ha
100
+ * scritto — e il giro di riduzione dopo l'interpolazione toglierà le graffe.
101
+ */
102
+ export function interpolate(template, values) {
103
+ let missing = false;
104
+ const filled = template.replace(/\{([A-Za-z]+)\}/g, (whole, name) => {
105
+ if (!(name in values))
106
+ return whole;
107
+ const v = values[name];
108
+ if (!v) {
109
+ missing = true;
110
+ return '';
111
+ }
112
+ return v;
113
+ });
114
+ return missing ? null : filled;
115
+ }