@lamemind/loom-deck 0.44.0 → 0.45.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,7 +18,20 @@ 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 { DETAIL_ACTIONS, MODELS, MODEL_DEFAULT } from '../spawn.js';
21
+ import { ACTION_HOTKEYS, DETAIL_ACTIONS, MODELS, MODEL_DEFAULT, } from '../spawn.js';
22
+ import { fieldsKey } from '../fields.js';
23
+ import { loadPromptCatalog, promptFor } from '../prompt-catalog.js';
24
+ // T117 — le quattro righe dell'area di compilazione del detail, nell'ordine in
25
+ // cui si leggono dall'alto. Gli indici sono nominati perché li usano insieme
26
+ // l'handler dei tasti e la resa, e un `2` nudo in due file diversi è la
27
+ // coordinata che scade appena una riga si sposta.
28
+ export const DROW = { action: 0, prompt: 1, model: 2, title: 3 };
29
+ export const DETAIL_FIELDS = [
30
+ { kind: 'choice', count: DETAIL_ACTIONS.length, hotkeys: ACTION_HOTKEYS },
31
+ { kind: 'text' },
32
+ { kind: 'choice', count: MODELS.length },
33
+ { kind: 'text' },
34
+ ];
22
35
  export function useSheetOverlay(deps) {
23
36
  const { rows, columns, setMode, setNote, onAction } = deps;
24
37
  const [sheet, setSheet] = useState(null);
@@ -28,13 +41,19 @@ export function useSheetOverlay(deps) {
28
41
  // deck: si azzera a ogni apertura come scroll e azione, quindi non esiste una
29
42
  // selezione invisibile che cambi il comportamento dei tasti della lista.
30
43
  const [model, setModel] = useState(MODEL_DEFAULT);
31
- // T111 — la nota con cui nascerà la conversazione. Campo SEMPRE ATTIVO: riceve
32
- // la scrittura appena il detail si apre, senza nessun tasto che lo apra, e per
33
- // questo si prende l'alfabeto nudo del modo (le cifre del selettore modello,
34
- // `g`/`G` degli estremi del testo). Si azzera a ogni apertura come scroll,
35
- // azione e modello: una nota armata che sopravvive alla chiusura sarebbe uno
36
- // stato invisibile che cambia il titolo dello spawn successivo.
44
+ // T111 — il titolo con cui nascerà la conversazione (nel sidecar e in
45
+ // `deck-run` il dato si chiama ancora `note`: il rename è di sola etichetta).
46
+ // Si azzera a ogni apertura come scroll, azione e modello: un titolo armato
47
+ // che sopravvive alla chiusura sarebbe uno stato invisibile che cambia il
48
+ // titolo dello spawn successivo.
37
49
  const [spawnNote, setSpawnNote] = useState('');
50
+ // T117 — il prompt iniziale, EDITABILE. Quello che si legge nel campo è quello
51
+ // che parte: non un'anteprima di qualcos'altro.
52
+ const [prompt, setPrompt] = useState('');
53
+ const [cursor, setCursor] = useState({ row: DROW.action, caret: 0 });
54
+ // Il catalogo si legge una volta per vita del deck: è un file di quattro righe
55
+ // accanto al codice, non un dato che cambia sotto i piedi.
56
+ const catalog = useMemo(() => loadPromptCatalog(), []);
38
57
  // T91 — ricerca dentro il detail. `open` distingue i due modi in cui la si
39
58
  // lascia: `esc` butta via ciò che il modale ha prodotto (`find` a null,
40
59
  // evidenziazione via), `⏎` lo congela e restituisce il controllo allo strato
@@ -84,18 +103,43 @@ export function useSheetOverlay(deps) {
84
103
  return;
85
104
  setTop(topForOffset(lines, o.start, capacity));
86
105
  }, [findRes, occCur, lines, capacity]);
87
- /** Apre il detail su una task, azzerando scroll, azione, modello, nota e ricerca. */
106
+ /** Apre il detail su una task, azzerando scroll, area di compilazione e ricerca. */
88
107
  function open(next) {
89
108
  setSheet(next);
90
109
  setTop(0);
91
110
  setAction(0);
92
111
  setModel(MODEL_DEFAULT);
93
112
  setSpawnNote('');
113
+ setPrompt(promptFor(catalog, DETAIL_ACTIONS[0].kind, next.id));
114
+ setCursor({ row: DROW.action, caret: 0 });
94
115
  setFind(null);
95
116
  setOccIdx(0);
96
117
  setNote('');
97
118
  setMode('detail');
98
119
  }
120
+ /** Cambia l'azione e RISCRIVE il prompt col default del nuovo kind (D2).
121
+ *
122
+ * Nessuna preservazione del testo modificato a mano, e non è una svista: la
123
+ * regola `initialDetail` del modale edit protegge da un cambio di sorgente
124
+ * ACCIDENTALE, e col fuoco per riga quel caso non esiste — `←→` cambiano
125
+ * azione solo dalla riga azione, mentre sul campo prompt muovono il caret.
126
+ * Senza il cambio accidentale la preservazione difenderebbe da nulla, e
127
+ * costerebbe un campo che non torna più al default. */
128
+ function selectAction(index) {
129
+ setAction(index);
130
+ const id = sheet?.id;
131
+ if (id)
132
+ setPrompt(promptFor(catalog, DETAIL_ACTIONS[index].kind, id));
133
+ }
134
+ // Il ponte fra le quattro righe e i quattro stati. Le righe restano
135
+ // TIPIZZATE dove vivono (`ModelKind`, indice dell'azione) invece di finire in
136
+ // un record generico: `fields.ts` governa la grammatica, non il modello dati.
137
+ const fieldsIO = {
138
+ text: (row) => (row === DROW.prompt ? prompt : spawnNote),
139
+ setText: (row, next) => (row === DROW.prompt ? setPrompt(next) : setSpawnNote(next)),
140
+ choice: (row) => (row === DROW.action ? action : Math.max(0, MODELS.indexOf(model))),
141
+ setChoice: (row, index) => row === DROW.action ? selectAction(index) : setModel(MODELS[index]),
142
+ };
99
143
  function close() {
100
144
  setMode('normal');
101
145
  setSheet(null);
@@ -154,36 +198,26 @@ export function useSheetOverlay(deps) {
154
198
  editFind((f) => ({ ...f, q: insertAt(f.q, f.caret, ins), caret: f.caret + cpLen(ins) }));
155
199
  }
156
200
  }
157
- // T66 — detail della task: due zone (testo scrollabile + barra azioni) e una
158
- // selezione per ognuna. Il modo cattura TUTTO, acceleratori `^K`/`^P`/`^R`
159
- // compresi: chi è già nel detail ha i bottoni. L'unica deroga è `^F`, che qui
160
- // apre la ricerca nel testo invece di quella sulle conversazioni.
201
+ // T66 — detail della task: due zone (testo scrollabile + area di compilazione)
202
+ // e una posizione per ognuna. Il modo cattura TUTTO, acceleratori
203
+ // `^K`/`^P`/`^R` compresi: chi è già nel detail ha i bottoni. L'unica deroga è
204
+ // `^F`, che qui apre la ricerca nel testo invece di quella sulle conversazioni.
161
205
  //
162
- // T111con il campo nota sempre attivo l'ultimo ramo è la SCRITTURA, quindi
163
- // ogni funzione che resta viva deve stare su un tasto che un campo di testo
164
- // non contende: `tab`, frecce, `PgUp`/`PgDn`, CTRL, `⏎`, `esc`.
206
+ // T117le quattro righe si scorrono con `↑↓`, che quindi NON scrollano più il
207
+ // testo: la lettura del task file resta su `PgUp`/`PgDn`, cioè una granularità
208
+ // sola invece di due. È un costo reale su un task file lungo, ed è il prezzo di
209
+ // avere `↑↓` nel loro significato di sempre (muovere il fuoco) su una schermata
210
+ // che ospita insieme un documento e dei campi.
165
211
  function onKey(input, key) {
166
212
  if (find?.open) {
167
213
  onFindKey(input, key);
168
214
  return;
169
215
  }
170
- if (key.ctrl) {
171
- // Ramo CTRL chiuso in testa, e non più il solo `if` su `^F`: sotto c'è la
172
- // scrittura nel campo nota, e senza questo ramo `^K` ci finirebbe come
173
- // lettera `k` la combo non arriva come tasto proprio, `key.ctrl` è
174
- // l'unico discriminante fra `^X` e `x`.
175
- //
176
- // `^F` è la deroga dichiarata (`CTRL_DEROGATIONS.detail`): fuori dal
177
- // detail è la ricerca conversazioni, qui è la ricerca nel testo. La query
178
- // sopravvive a una chiusura con `⏎`, quindi riaprire riprende da dov'era.
179
- // `^U` svuota la nota — non è una deroga ma una combo che il modo gestisce
180
- // da sé, come il filtro del modale assegnazione. Ogni altro CTRL è inerte.
181
- if (input === 'f') {
182
- setFind((f) => (f ? { ...f, open: true } : { q: '', caret: 0, open: true }));
183
- }
184
- else if (input === 'u') {
185
- setSpawnNote('');
186
- }
216
+ if (key.ctrl && input === 'f') {
217
+ // Deroga dichiarata (`CTRL_DEROGATIONS.detail`): fuori dal detail `^F` è
218
+ // la ricerca conversazioni, qui la ricerca nel testo. La query sopravvive
219
+ // a una chiusura con `⏎`, quindi riaprire riprende da dov'era.
220
+ setFind((f) => (f ? { ...f, open: true } : { q: '', caret: 0, open: true }));
187
221
  return;
188
222
  }
189
223
  if (key.escape) {
@@ -195,57 +229,33 @@ export function useSheetOverlay(deps) {
195
229
  setFind(null);
196
230
  else
197
231
  close();
232
+ return;
198
233
  }
199
- else if (key.return) {
200
- // `⏎` esegue SEMPRE l'azione selezionata, mai "scrolla" o "chiudi": il
201
- // testo non è un campo attivo (si scorre, non si edita), quindi nessuna
202
- // competizione sul tasto.
234
+ if (key.return) {
235
+ // `⏎` esegue SEMPRE l'azione selezionata, da qualunque riga: i campi sono
236
+ // di una riga sola, quindi nessuno di loro ha da farci un a-capo.
203
237
  const act = DETAIL_ACTIONS[action];
204
238
  const id = sheet?.id;
205
239
  const note = spawnNote.trim();
240
+ const text = prompt.trim();
206
241
  close();
207
242
  if (id)
208
- onAction(id, act.kind, model, note);
209
- }
210
- else if (key.tab) {
211
- // T108 — `tab` scorre il catalogo dei modelli, e da T111 è il SOLO canale:
212
- // le cifre `1`-`4` sono passate al campo nota. Libero solo QUI: in vista
213
- // normale cicla la vista del pane a fuoco. Il detail cattura l'input per
214
- // intero, quindi è dove un alfabeto già speso torna disponibile.
215
- setModel((m) => MODELS[(MODELS.indexOf(m) + 1) % MODELS.length]);
216
- }
217
- else if (key.leftArrow || key.rightArrow) {
218
- // Scorrimento CICLICO come le righe di scelta del modale edit: cinque
219
- // voci, arrivare in fondo e ripartire costa meno che invertire direzione.
220
- const d = key.leftArrow ? -1 : 1;
221
- setAction((i) => (i + d + DETAIL_ACTIONS.length) % DETAIL_ACTIONS.length);
222
- }
223
- else if (key.upArrow) {
224
- scroll(-1);
225
- }
226
- else if (key.downArrow) {
227
- scroll(1);
243
+ onAction(id, act.kind, model, note, text);
244
+ return;
228
245
  }
229
- else if (key.pageUp) {
246
+ if (key.pageUp) {
230
247
  scroll(-capacity);
248
+ return;
231
249
  }
232
- else if (key.pageDown) {
250
+ if (key.pageDown) {
233
251
  scroll(capacity);
252
+ return;
234
253
  }
235
- else if (key.backspace || key.delete) {
236
- // Nessun movimento di caret nel campo (D3): `←→` sono già delle azioni e
237
- // riprenderle costerebbe la barra. Si cancella quindi in coda e
238
- // `key.delete` è ANCHE Backspace in Ink, come nel campo di ricerca.
239
- setSpawnNote((s) => removeAt(s, cpLen(s) - 1));
240
- }
241
- else if (input && !key.meta) {
242
- // Ultimo ramo: tutto ciò che nessun tasto vivo ha reclamato è testo. Ci
243
- // cadono le cifre `1`-`4` (prima il selettore modello) e `g`/`G` (prima
244
- // gli estremi del testo, che nel detail si raggiungono con
245
- // `PgUp`/`PgDn`); nel reader fullscreen restano, perché lì nessun campo
246
- // contende le lettere.
247
- setSpawnNote((s) => s + sanitizeTyped(input));
248
- }
254
+ // Tutto il resto è dell'area di compilazione. Ciò che non consuma resta
255
+ // inerte dentro un modo capturing è la scelta giusta: gli acceleratori
256
+ // globali non devono riattivarsi, e su una riga a scelta una lettera che
257
+ // non è la sua non deve finire in nessun campo.
258
+ fieldsKey(input, key, DETAIL_FIELDS, cursor, setCursor, fieldsIO);
249
259
  }
250
260
  return {
251
261
  sheet,
@@ -254,6 +264,8 @@ export function useSheetOverlay(deps) {
254
264
  action,
255
265
  model,
256
266
  spawnNote,
267
+ prompt,
268
+ cursor,
257
269
  find,
258
270
  lines,
259
271
  capacity,
@@ -0,0 +1,55 @@
1
+ // T117 — il testo dei prompt iniziali, letto dal file dati che il deck CONDIVIDE
2
+ // con `deck-run`.
3
+ //
4
+ // Fino a T116 il catalogo era un `case` dentro `deck-run` e il deck non ne aveva
5
+ // mai visto una stringa: passava un simbolo (`--prompt-kind`) e il testo restava
6
+ // dall'altra parte. Un campo che MOSTRA il prompt prima dello spawn rompe quella
7
+ // divisione — per mostrarlo bisogna averlo — e le due strade erano ricopiare il
8
+ // catalogo qui (seconda scrittura delle stesse regole, divergente alla prima
9
+ // voce aggiunta da una parte sola) oppure spostarlo in un dato che entrambi
10
+ // leggono. È la seconda.
11
+ //
12
+ // Il file sta in `scripts/`, sibling di `deck-run`, e non in `src/`: la cartella
13
+ // è già dentro `files` di package.json, quindi viene pubblicata as-is su npm,
14
+ // mentre `tsc` non copia asset in `dist/` e un file dati sotto `src/` avrebbe
15
+ // richiesto un passo di build apposta.
16
+ import { readFileSync } from 'node:fs';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { dirname, join } from 'node:path';
19
+ /** Stessa risalita di `DECK_RUN`: src/ (dev) e dist/ (build) sono entrambi
20
+ * figli della package root, quindi il sibling è un livello sopra. */
21
+ export const PROMPT_CATALOG = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'prompt-catalog');
22
+ /**
23
+ * Il catalogo come mappa kind → template, col placeholder `{TASK}` ancora
24
+ * dentro.
25
+ *
26
+ * File illeggibile → mappa VUOTA, non un fallback cablato: un default scritto
27
+ * qui sarebbe esattamente la copia del catalogo che questo modulo esiste per non
28
+ * avere, e divergerebbe in silenzio. L'assenza si vede — il campo prompt resta
29
+ * vuoto — e uno spawn senza prompt è un esito benigno.
30
+ */
31
+ export function loadPromptCatalog(path = PROMPT_CATALOG) {
32
+ const out = new Map();
33
+ let raw;
34
+ try {
35
+ raw = readFileSync(path, 'utf8');
36
+ }
37
+ catch {
38
+ return out;
39
+ }
40
+ for (const line of raw.split('\n')) {
41
+ if (!line.trim() || line.startsWith('#'))
42
+ continue;
43
+ const tab = line.indexOf('\t');
44
+ if (tab < 0)
45
+ continue;
46
+ out.set(line.slice(0, tab).trim(), line.slice(tab + 1).trim());
47
+ }
48
+ return out;
49
+ }
50
+ /** Il prompt di un kind su una task. Kind fuori catalogo (`none`, o un file
51
+ * mutilo) → stringa vuota, che è la stessa cosa che `deck-run` fa con `none`. */
52
+ export function promptFor(catalog, kind, taskId) {
53
+ const template = catalog.get(kind);
54
+ return template ? template.replaceAll('{TASK}', taskId) : '';
55
+ }
package/dist/spawn.js CHANGED
@@ -142,6 +142,14 @@ export const DETAIL_ACTIONS = [
142
142
  { kind: 'recap', label: 'status' },
143
143
  { kind: 'checkpoint', label: 'checkpoint' },
144
144
  ];
145
+ // T117 — selezione DIRETTA di un'azione con la sua iniziale (`o p r s c`),
146
+ // accanto allo scorrimento `←→`. Derivate dalle label e non cablate: una voce
147
+ // aggiunta al catalogo porta con sé la propria lettera, invece di lasciare
148
+ // indietro una seconda lista.
149
+ // Il vincolo che la derivazione impone al catalogo: le iniziali devono restare
150
+ // DISTINTE fra loro. Due label con la stessa lettera renderebbero la seconda
151
+ // irraggiungibile, e in silenzio — chi aggiunge una voce lo controlla qui.
152
+ export const ACTION_HOTKEYS = Object.fromEntries(DETAIL_ACTIONS.map((a, i) => [a.label[0], i]));
145
153
  // Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
146
154
  // ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
147
155
  // sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
@@ -157,14 +165,28 @@ export const DETAIL_ACTIONS = [
157
165
  // separino, quindi il flag non appartiene alla ripresa — vale su ogni spawn.
158
166
  // Assente quando la nota è vuota, e non passato vuoto: `--title-note ''`
159
167
  // produrrebbe un `«»` a vuoto nel titolo.
160
- export function deckArgs(id, sessionId, kind, model = MODEL_DEFAULT, spawnNote) {
161
- const args = [id, '--session-id', sessionId, '--prompt-kind', kind, '--model', model];
168
+ // T117 `prompt` è il TESTO letterale, e quando c'è sostituisce il kind:
169
+ // `--prompt` e `--prompt-kind` sono mutuamente esclusivi in deck-run. Serve al
170
+ // detail, dove il prompt è un campo editabile e quello che l'utente legge è
171
+ // quello che parte — dopo una modifica nessun kind lo descrive più.
172
+ // `undefined` = questo percorso non ha un campo prompt (gli acceleratori della
173
+ // lista) e viaggia col simbolo, come prima. Stringa VUOTA ≠ undefined: è la
174
+ // richiesta esplicita di nessun prompt (azione `open`, o un campo svuotato a
175
+ // mano) e si esprime col kind `none`, perché senza flag deck-run cadrebbe sul
176
+ // proprio default `recap`.
177
+ export function deckArgs(id, sessionId, kind, model = MODEL_DEFAULT, spawnNote, prompt) {
178
+ const promptArgs = prompt === undefined
179
+ ? ['--prompt-kind', kind]
180
+ : prompt
181
+ ? ['--prompt', prompt]
182
+ : ['--prompt-kind', 'none'];
183
+ const args = [id, '--session-id', sessionId, ...promptArgs, '--model', model];
162
184
  if (spawnNote)
163
185
  args.push('--title-note', spawnNote);
164
186
  return args;
165
187
  }
166
- export function spawnDeck(id, cwd, sessionId, kind, model = MODEL_DEFAULT, spawnNote) {
167
- return launchDeckRun(deckArgs(id, sessionId, kind, model, spawnNote), cwd);
188
+ export function spawnDeck(id, cwd, sessionId, kind, model = MODEL_DEFAULT, spawnNote, prompt) {
189
+ return launchDeckRun(deckArgs(id, sessionId, kind, model, spawnNote, prompt), cwd);
168
190
  }
169
191
  // T49 — resume di una sessione esistente come nuova tab Ptyxis. Scoped (taskId
170
192
  // presente) → `deck-run <task> --resume <sid>`: la ripresa eredita LOOM_TASK +
@@ -1,13 +1,14 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Detail della task (T66): quarta schermata sostitutiva, col markdown reso in
3
3
  // span tipizzati (T75) e la ricerca interna (T91).
4
4
  import { Box, Text } from 'ink';
5
- import { caretWindow, cut, cutParts, sanitize } from '../width.js';
5
+ import { cut, cutParts } from '../width.js';
6
6
  import { sliceLine } from '../text-search.js';
7
7
  import { sliceSpans } from '../markdown.js';
8
- import { cpLen } from '../layout.js';
8
+ import { FieldText } from './fields.js';
9
9
  import { DETAIL_ACTIONS, MODELS } from '../spawn.js';
10
- import { WARN } from '../glyphs.js';
10
+ import { DROW } from '../overlays/sheet.js';
11
+ import { CARET, CARET_OFF, WARN } from '../glyphs.js';
11
12
  /** Resa di ogni costrutto markdown (T75 · D4): un solo livello di enfasi per
12
13
  * costrutto, senza un secondo alfabeto da imparare. Heading uguali a ogni
13
14
  * livello — la gerarchia la porta già il testo. `code` e `fence` condividono
@@ -52,90 +53,74 @@ export function DetailLine({ line, spans, occ, current, }) {
52
53
  return (_jsx(Text, { bold: st?.bold, color: st?.color, children: sliceLine(seg.text, at, occ, current).map((p, j) => p.hit ? (_jsx(Text, { backgroundColor: p.current ? 'cyan' : 'yellow', color: "black", children: p.text }, j)) : (_jsx(Text, { children: p.text }, j))) }, i));
53
54
  }) }));
54
55
  }
55
- /** Campo di testo del detail: finestra ancorata al caret, cursore inverso sulla
56
- * cella reale. Gemello di `EditTextField` senza la label, che qui sta fuori
57
- * perché il campo vive in FLUSSO su una riga condivisa con altro (il contatore
58
- * di occorrenze per la ricerca, il segnaposto per la nota) — non su una riga
59
- * propria. Lo usano i due campi del detail: la ricerca `^F`, dove il caret si
60
- * muove, e la nota (T111), dove il caret sta sempre in coda. */
61
- export function DetailTextField({ value, caret, cols }) {
62
- const win = caretWindow(value, caret, cols);
63
- return (_jsxs(_Fragment, { children: [_jsx(Text, { children: sanitize(win.head) }), _jsx(Text, { inverse: true, children: sanitize(win.at) }), _jsx(Text, { children: sanitize(win.tail) })] }));
64
- }
65
- /** Segnaposto della riga nota quando il campo è vuoto. Costante e non letterale
66
- * inline perché la sua larghezza entra nel budget del campo accanto. */
56
+ /** Segnaposti delle due righe di testo a campo vuoto. Costanti e non letterali
57
+ * inline perché la loro larghezza entra nel budget del campo accanto. */
67
58
  const NOTE_HINT = ' · nome della conversazione';
59
+ const PROMPT_HINT = ' · nessun prompt iniziale';
60
+ /** Le lettere di selezione diretta dell'azione, per la riga hint. Derivate dallo
61
+ * stesso catalogo di `ACTION_HOTKEYS`, così una voce aggiunta compare qui senza
62
+ * che nessuno ci ripassi — un'etichetta che nomina dei tasti a mano è accoppiata
63
+ * al binding, e resta indietro appena il binding cambia. */
64
+ const ACTION_KEYS = DETAIL_ACTIONS.map((a) => a.label[0]).join(' ');
65
+ /** Prefisso incolonnato delle quattro righe dell'area di compilazione: le
66
+ * etichette si leggono una sotto l'altra, quindi la larghezza è quella della
67
+ * più lunga. Entra nel budget di ogni campo, da cui la costante. */
68
+ const LABEL_W = 8;
69
+ /** Una riga a SCELTA dell'area di compilazione: bottoni affiancati, voce attiva
70
+ * in video inverso.
71
+ *
72
+ * Due passate di `cutParts`: la seconda serve SOLO quando qualcosa cade, e
73
+ * riserva le colonne del contatore. Riservarle sempre costerebbe 6 colonne su
74
+ * ogni terminale largo per un avviso che lì non comparirà mai.
75
+ *
76
+ * `priority` sulla voce SELEZIONATA e non sulla prima: qui il troncamento
77
+ * cancellerebbe l'unica informazione che la riga esiste per dare — quale valore
78
+ * sta per essere usato.
79
+ */
80
+ function ChoiceRow({ label, values, index, focused, width, }) {
81
+ const segs = values.map((v) => `[ ${v} ]`);
82
+ const parts = [];
83
+ segs.forEach((s, i) => {
84
+ if (i > 0)
85
+ parts.push(' ');
86
+ parts.push(s);
87
+ });
88
+ const avail = Math.max(0, width - LABEL_W - CARET_OFF.length);
89
+ let shown = cutParts(parts, avail, index * 2);
90
+ const dropped = (v) => segs.filter((s, i) => v[i * 2] !== s).length;
91
+ if (dropped(shown) > 0)
92
+ shown = cutParts(parts, Math.max(0, avail - 6), index * 2);
93
+ const cut = dropped(shown);
94
+ return (_jsxs(Text, { wrap: "truncate-end", children: [focused ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: label.padEnd(LABEL_W) }), shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === index, color: i / 2 === index ? 'green' : 'gray', children: part }, i))), cut > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cut] }) : null] }));
95
+ }
68
96
  /**
69
- * Detail della task (T66): il task file scrollabile più la barra azioni.
97
+ * Detail della task (T66): il task file scrollabile più l'area di compilazione.
70
98
  *
71
99
  * Unisce due gesti che erano due schermate — leggere la task e agire su di essa
72
100
  * — perché convergono sullo stesso oggetto: si legge la Description proprio per
73
101
  * decidere QUALE azione lanciare, e con due overlay separati quella decisione
74
102
  * costava uscire dal viewer e ricordarsi la combo.
75
103
  *
76
- * Le azioni sono BOTTONI AFFIANCATI e non voci di un menu verticale: un
104
+ * T117 i quattro parametri dello spawn sono quattro RIGHE con un fuoco solo,
105
+ * la stessa forma del modale edit: prima erano tre alfabeti diversi sulla stessa
106
+ * schermata (`←→` per l'azione, `tab` per il modello, i tasti nudi per la nota),
107
+ * e ogni parametro aggiunto ne chiedeva un quarto.
108
+ *
109
+ * Le righe a scelta restano BOTTONI AFFIANCATI e non menu verticali: un
77
110
  * rettangolo ha già coordinate e area cliccabile, quindi il layout sopravvive
78
- * all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione. La
79
- * navigazione da tastiera ci si sovrappone senza conflitti.
111
+ * all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione.
80
112
  */
81
- export function DetailScreen({ id, title, missing, lines, spans, top, total, capacity, action, model, spawnNote, columns, find, occ, occCur, }) {
113
+ export function DetailScreen({ id, title, missing, lines, spans, top, total, capacity, action, model, spawnNote, prompt, cursor, columns, find, occ, occCur, }) {
82
114
  const last = Math.min(total, top + capacity);
83
- // Il taglio lo fa il chiamante (invariante ③ di width.ts): la riga bottoni è
84
- // ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso — ma
85
- // la correttezza non deve dipendere dall'alfabeto che capita nella riga.
115
+ // Il taglio lo fa il chiamante (invariante ③ di width.ts): le righe bottoni
116
+ // sono ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso
117
+ // — ma la correttezza non deve dipendere dall'alfabeto che capita nella riga.
86
118
  const width = Math.max(20, (columns || 80) - 4);
87
- const segs = DETAIL_ACTIONS.map((a) => `[ ${a.label} ]`);
88
- const parts = [];
89
- segs.forEach((s, i) => {
90
- if (i > 0)
91
- parts.push(' ');
92
- parts.push(s);
93
- });
94
- const dropped = (v, of) => of.filter((s, i) => v[i * 2] !== s).length;
95
- // Due passate: la seconda serve SOLO quando qualcosa cade, e riserva le
96
- // colonne del contatore. Riservarle sempre costerebbe 6 colonne su ogni
97
- // terminale largo per un avviso che lì non comparirà mai.
98
- let shown = cutParts(parts, width);
99
- if (dropped(shown, segs) > 0)
100
- shown = cutParts(parts, Math.max(0, width - 6));
101
- const cutCount = dropped(shown, segs);
102
- // T108 — riga del selettore modello: bottoni affiancati IDENTICI a quelli
103
- // della barra azioni, quadre comprese, e voce attiva in video inverso. Due
104
- // enfasi diverse su due righe adiacenti (là `inverse`, qui un grassetto
105
- // colorato) obbligano a guardare da vicino per capire quale voce è scelta —
106
- // la stessa resa si legge di colpo su entrambe.
107
- // T111 — la cifra è USCITA dalla quadra: `1`-`4` sono passate al campo nota e
108
- // il modello si scorre col solo `tab`. Un'etichetta che nomina un tasto è
109
- // accoppiata al binding, e tenerla dopo che il binding è morto non è
110
- // un'informazione parziale — è una resa che dichiara un tasto che non fa più
111
- // quella cosa, cioè peggio di nessuna indicazione.
112
- // `priority` sulla voce SELEZIONATA e non sulla prima: qui il troncamento
113
- // cancellerebbe l'unica informazione che la riga esiste per dare — quale
114
- // modello sta per essere usato — mentre nella barra azioni la voce attiva è
115
- // comunque nota dal tasto appena premuto.
116
- const mSegs = MODELS.map((m) => `[ ${m} ]`);
117
- const mParts = [];
118
- mSegs.forEach((s, i) => {
119
- if (i > 0)
120
- mParts.push(' ');
121
- mParts.push(s);
122
- });
123
- const mIdx = Math.max(0, MODELS.indexOf(model));
124
- // `- 8` = le colonne del prefisso "modello ", che sta fuori dai parts perché
125
- // non è un bottone e non deve mai cadere.
126
- const mWidth = Math.max(0, width - 8);
127
- let mShown = cutParts(mParts, mWidth, mIdx * 2);
128
- if (dropped(mShown, mSegs) > 0)
129
- mShown = cutParts(mParts, Math.max(0, mWidth - 6), mIdx * 2);
130
- const mCut = dropped(mShown, mSegs);
131
- // T111 — riga della nota. FISSA (D1): il valore armato si vede sempre, e il
132
- // suo costo sta in `DETAIL_CHROME` invece che in un secondo `extra`
133
- // condizionale di `detailCapacity`. Prefisso largo 8 come `modello `, così i
134
- // due parametri dello spawn si leggono incolonnati.
135
- // Il campo non ha nessun tasto che lo apra: il cursore è l'unica cosa che lo
136
- // dichiara attivo, e il segnaposto dice cosa ci si scrive. Le colonne del
137
- // segnaposto si riservano SOLO quando c'è (cioè a nota vuota): riservarle
138
- // sempre toglierebbe testo visibile a una nota lunga per un avviso assente.
139
- const nWidth = Math.max(10, width - 8 - (spawnNote ? 0 : NOTE_HINT.length));
140
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: id }), " \u00B7 ", cut(title, Math.max(10, width - 34)), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), find?.open ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " occorrenza \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " caret \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " tieni \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193/PgUp/PgDn" }), " testo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "tab" }), " modello \u00B7", ' ', _jsx(Text, { color: "yellow", children: "scrivi" }), " nota \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^F" }), " cerca \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " esegui \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] })), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: missing ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: [WARN, " task file non trovato \u00B7 le azioni restano attive (deck-run risolve la task per id)"] })) : (lines.map((l, i) => (_jsx(DetailLine, { line: l, spans: spans, occ: occ, current: occCur }, top + i)))) }), find?.open ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "cerca " }), _jsx(DetailTextField, { value: find.q, caret: find.caret, cols: Math.max(10, width - 28) }), find.q.length === 0 ? (_jsx(Text, { dimColor: true, children: " \u00B7 digita per cercare" })) : occ.length === 0 ? (_jsx(Text, { color: "yellow", children: " \u00B7 nessuna occorrenza" })) : (_jsxs(Text, { color: "cyan", children: [' ', "\u00B7 ", occCur + 1, "/", occ.length] }))] }) })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "modello " }), mShown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === mIdx, color: i / 2 === mIdx ? 'green' : 'gray', children: part }, i))), mCut > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", mCut] }) : null] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "nota " }), _jsx(DetailTextField, { value: spawnNote, caret: cpLen(spawnNote), cols: nWidth }), spawnNote ? null : _jsx(Text, { dimColor: true, children: NOTE_HINT })] }), _jsxs(Text, { wrap: "truncate-end", children: [shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === action, color: i / 2 === action ? 'green' : 'gray', children: part }, i))), cutCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cutCount] }) : null] })] })] }));
119
+ const mark = (row) => (cursor.row === row ? CARET : CARET_OFF);
120
+ // Budget dei due campi di testo: la cornice, il marker di riga e l'etichetta
121
+ // incolonnata. Le colonne del segnaposto si riservano SOLO quando c'è (cioè a
122
+ // campo vuoto): riservarle sempre toglierebbe testo visibile a un titolo lungo
123
+ // per un avviso assente.
124
+ const textW = (extra) => Math.max(10, width - LABEL_W - CARET_OFF.length - extra);
125
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: id }), " \u00B7 ", cut(title, Math.max(10, width - 34)), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), find?.open ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " occorrenza \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " caret \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " tieni \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " testo \u00B7", ' ', _jsx(Text, { color: "yellow", children: ACTION_KEYS }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota campo \u00B7 ", _jsx(Text, { color: "yellow", children: "^F" }), " cerca"] })), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: missing ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: [WARN, " task file non trovato \u00B7 le azioni restano attive (deck-run risolve la task per id)"] })) : (lines.map((l, i) => (_jsx(DetailLine, { line: l, spans: spans, occ: occ, current: occCur }, top + i)))) }), find?.open ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "cerca " }), _jsx(FieldText, { value: find.q, caret: find.caret, focused: true, cols: Math.max(10, width - 28) }), find.q.length === 0 ? (_jsx(Text, { dimColor: true, children: " \u00B7 digita per cercare" })) : occ.length === 0 ? (_jsx(Text, { color: "yellow", children: " \u00B7 nessuna occorrenza" })) : (_jsxs(Text, { color: "cyan", children: [' ', "\u00B7 ", occCur + 1, "/", occ.length] }))] }) })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(ChoiceRow, { label: "azione", values: DETAIL_ACTIONS.map((a) => a.label), index: action, focused: cursor.row === DROW.action, width: width }), _jsxs(Text, { wrap: "truncate-end", children: [mark(DROW.prompt), _jsx(Text, { dimColor: true, children: 'prompt'.padEnd(LABEL_W) }), _jsx(FieldText, { value: prompt, caret: cursor.caret, focused: cursor.row === DROW.prompt, cols: textW(prompt ? 0 : PROMPT_HINT.length) }), prompt ? null : _jsx(Text, { dimColor: true, children: PROMPT_HINT })] }), _jsx(ChoiceRow, { label: "modello", values: MODELS, index: Math.max(0, MODELS.indexOf(model)), focused: cursor.row === DROW.model, width: width }), _jsxs(Text, { wrap: "truncate-end", children: [mark(DROW.title), _jsx(Text, { dimColor: true, children: 'titolo'.padEnd(LABEL_W) }), _jsx(FieldText, { value: spawnNote, caret: cursor.caret, focused: cursor.row === DROW.title, cols: textW(spawnNote ? 0 : NOTE_HINT.length) }), spawnNote ? null : _jsx(Text, { dimColor: true, children: NOTE_HINT })] })] })] }));
141
126
  }
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // T117 — resa di un campo di testo dentro un'area di compilazione: finestra
3
+ // ancorata al caret, cursore inverso sulla cella REALE.
4
+ //
5
+ // Uno solo per le due schermate. Esisteva in due copie — `EditTextField` nel
6
+ // modale edit e `DetailTextField` nel detail — e la seconda si dichiarava
7
+ // «gemello del primo senza la label»: la label infatti non è del campo, è del
8
+ // LAYOUT che lo ospita, e i due layout la mettono in posti diversi (l'edit dopo
9
+ // il marker di riga, il detail dentro un prefisso incolonnato). Tolta di mezzo,
10
+ // dei due gemelli resta una cosa sola.
11
+ import { Text } from 'ink';
12
+ import { caretWindow, sanitize } from '../width.js';
13
+ import { cpLen } from '../layout.js';
14
+ /**
15
+ * Fuori fuoco (`focused` falso) il caret non si disegna e la finestra si ancora
16
+ * in fondo, che è la vista utile per un campo che non si sta scrivendo: `at` lì
17
+ * è solo la cella virtuale di fine campo, e disegnarla aggiungerebbe al testo
18
+ * uno spazio che non gli appartiene.
19
+ */
20
+ export function FieldText({ value, caret, focused, cols, }) {
21
+ const win = caretWindow(value, focused ? caret : cpLen(value), cols);
22
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { children: sanitize(win.head) }), focused ? _jsx(Text, { inverse: true, children: sanitize(win.at) }) : null, _jsx(Text, { children: sanitize(win.tail) })] }));
23
+ }
package/dist/ui/modals.js CHANGED
@@ -3,8 +3,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  // schermate sostitutive costano righe al budget d'altezza, quindi ognuno ha un
4
4
  // costo dichiarato in `viewport.ts`.
5
5
  import { Box, Text } from 'ink';
6
- import { caretWindow, cut, cutParts, sanitize } from '../width.js';
7
- import { cpLen } from '../layout.js';
6
+ import { cut, cutParts, sanitize } from '../width.js';
7
+ import { FieldText } from './fields.js';
8
8
  import { CARET, CARET_OFF, WARN } from '../glyphs.js';
9
9
  import { EDIT_PRI, EDIT_PROG } from '../model.js';
10
10
  import { PRI_ENTRIES, PROG_ENTRIES } from '../view.js';
@@ -80,24 +80,17 @@ function IgnoredChoice({ survivors, mode, width, }) {
80
80
  const shown = cutParts(parts, width, mode === 'keep' ? 1 : 3);
81
81
  return (_jsx(Text, { wrap: "truncate-end", children: shown.map((s, i) => s ? (_jsx(Text, { color: "yellow", inverse: (i === 1 && mode === 'keep') || (i === 3 && mode === 'purge'), children: s }, i)) : null) }));
82
82
  }
83
- /**
84
- * Campo di testo del modale edit: finestra ancorata al caret + cursore inverso
85
- * nella posizione REALE.
86
- *
87
- * Il cursore non è più uno spazio inverso appiccicato in coda ma la cella `at`
88
- * della finestra — cioè il carattere su cui il caret sta davvero. Fuori fuoco
89
- * (`focused` falso) il caret non si disegna e la finestra si ancora in fondo,
90
- * che è la vista utile per un campo che non si sta scrivendo.
91
- */
92
- export function EditTextField({ label, value, caret, focused, cols, }) {
93
- const win = caretWindow(value, focused ? caret : cpLen(value), cols);
94
- 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) })] }));
83
+ /** Campo di testo con la sua etichetta, nel layout dell'edit: label, due spazi,
84
+ * campo. La label sta qui e non dentro `FieldText` perché è del LAYOUT, non del
85
+ * campo il detail la mette in un prefisso incolonnato di larghezza diversa. */
86
+ function LabelledField({ label, value, caret, focused, cols, }) {
87
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: label }), _jsx(Text, { children: ' ' }), _jsx(FieldText, { value: value, caret: caret, focused: focused, cols: cols })] }));
95
88
  }
96
89
  // T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
97
90
  // coprirli: la riga che stai modificando resta visibile sopra la lista).
98
91
  // La riga di anteprima mostra il testo ESATTO che finirà nel campo `Progress`
99
92
  // del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
100
- export function EditModal({ id, draft, row, columns, }) {
93
+ export function EditModal({ id, draft, row, caret, columns, }) {
101
94
  const mark = (r) => (row === r ? CARET : CARET_OFF);
102
95
  // Budget dei campi di testo, DERIVATO da `columns` (mai una costante): il box
103
96
  // del modale è ANNIDATO nella cornice del deck, quindi le cornici da scalare
@@ -106,5 +99,5 @@ export function EditModal({ id, draft, row, columns, }) {
106
99
  // Un titolo di tasks.md arriva a ~64 caratteri: senza taglio la riga va a capo
107
100
  // dentro il box, che si alza di una riga e sfonda il budget verticale (invariante ③).
108
101
  const fieldBudget = Math.max(8, columns - 19);
109
- 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))] })] }));
102
+ 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(LabelledField, { label: "prog ", value: draft.detail, caret: caret, focused: row === 2, cols: fieldBudget }), !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { children: [mark(3), _jsx(LabelledField, { label: "titolo", value: draft.title, caret: caret, focused: row === 3, cols: fieldBudget })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", sanitize(progressText(draft.prog, draft.detail))] })] }));
110
103
  }