@lamemind/loom-deck 0.34.0 → 0.34.2

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/spawn.js ADDED
@@ -0,0 +1,262 @@
1
+ // Effetti VERSO L'ESTERNO del deck: processi spawnati, comandi git. Nessun React
2
+ // qui — è la fase a monte del ciclo, quindi non importa nulla della vista.
3
+ import { spawn } from 'node:child_process';
4
+ import { EventEmitter } from 'node:events';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dirname, join } from 'node:path';
7
+ // scripts/deck-run è un sibling della dir del bundle: src/ (dev, tsx) e dist/
8
+ // (build, node) stanno entrambi sotto la package root → risalita di un livello.
9
+ export const DECK_RUN = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'deck-run');
10
+ /**
11
+ * Freno agli effetti VERSO L'ESTERNO (tab Ptyxis, sessioni Claude, git commit).
12
+ *
13
+ * Il gate di larghezza avvia il deck vero in uno pseudo-terminale e gli manda
14
+ * tasti — e in questa TUI un tasto è un'azione: `⏎` su una riga sessione apre
15
+ * una tab Ptyxis, `t` un terminale, `⏎` nel modale edit committa. Ogni run dei
16
+ * test apriva quindi finestre reali sulla macchina di chi li lanciava, in
17
+ * qualunque progetto avesse in focus.
18
+ *
19
+ * Il gate va tenuto sul deck VERO (è tutto il suo valore: misura il frame che
20
+ * VTE disegna davvero), quindi il freno sta qui: `LOOM_DECK_NO_SPAWN=1` fa
21
+ * restituire un figlio finto e inerte invece di lanciare il processo. Non è un
22
+ * mock del comportamento — l'azione semplicemente non avviene, e il frame che il
23
+ * test misura resta identico.
24
+ */
25
+ export const NO_SPAWN = process.env.LOOM_DECK_NO_SPAWN === '1';
26
+ export function spawnOut(cmd, args, opts) {
27
+ if (!NO_SPAWN)
28
+ return spawn(cmd, args, opts);
29
+ // Figlio inerte: emette nulla, quindi i `.on('error'|'close')` dei chiamanti
30
+ // restano appesi senza mai scattare — che è esattamente "non è successo niente".
31
+ const fake = new EventEmitter();
32
+ fake.unref = () => fake;
33
+ return fake;
34
+ }
35
+ // L'ordine È il binding dei tasti `1`-`4` nel detail, non una preferenza di
36
+ // lettura: cambiarlo sposta i tasti sotto le dita di chi li ha imparati.
37
+ export const MODELS = ['fable', 'opus', 'sonnet', 'haiku'];
38
+ // Default del selettore e di ogni percorso di spawn che non passa da lui
39
+ // (`^K`/`^P`/`^R` dalla lista, resume, fork). Duplicato del default di deck-run
40
+ // e non letto da lì: il deck deve poter MOSTRARE la selezione iniziale prima di
41
+ // spawnare alcunché, e un valore che si conosce solo a spawn avvenuto non è
42
+ // mostrabile.
43
+ export const MODEL_DEFAULT = 'opus';
44
+ // T66 — le azioni del detail. Non sono un catalogo nuovo: ognuna è un
45
+ // `--prompt-kind` già esistente più `checkpoint`, e tutte passano dallo stesso
46
+ // `spawnForTask` dei CTRL della lista — una superficie in più, zero percorsi di
47
+ // spawn in più.
48
+ //
49
+ // L'etichetta è distinta dal kind dove il kind è il nome del MECCANISMO e
50
+ // l'etichetta quello dell'INTENZIONE: `none` è "aprire la task a mani nude",
51
+ // `recap` è "vedere a che punto sta".
52
+ export const DETAIL_ACTIONS = [
53
+ { kind: 'none', label: 'open' },
54
+ { kind: 'preflight', label: 'preflight' },
55
+ { kind: 'run', label: 'run' },
56
+ { kind: 'recap', label: 'status' },
57
+ { kind: 'checkpoint', label: 'checkpoint' },
58
+ ];
59
+ // Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
60
+ // ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
61
+ // sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
62
+ // Il kind è OBBLIGATORIO e non ha default qui: il default vive in deck-run (per
63
+ // le invocazioni a mano), mentre dal deck ogni tasto dichiara il proprio intento
64
+ // — un default silenzioso renderebbe indistinguibili `⏎` e `^K`.
65
+ // Il modello, al contrario del kind, ha un default (T108): i percorsi che non
66
+ // passano dal selettore del detail non devono nominarlo per forza, e l'unico
67
+ // valore sensato per loro è quello che il selettore stesso mostra all'apertura.
68
+ export function deckArgs(id, sessionId, kind, model = MODEL_DEFAULT) {
69
+ return [id, '--session-id', sessionId, '--prompt-kind', kind, '--model', model];
70
+ }
71
+ export function spawnDeck(id, cwd, sessionId, kind, model = MODEL_DEFAULT) {
72
+ const child = spawnOut(DECK_RUN, deckArgs(id, sessionId, kind, model), {
73
+ cwd,
74
+ detached: true,
75
+ stdio: 'ignore',
76
+ });
77
+ child.unref();
78
+ return child;
79
+ }
80
+ // T49 — resume di una sessione esistente come nuova tab Ptyxis. Scoped (taskId
81
+ // presente) → `deck-run <task> --resume <sid>`: la ripresa eredita LOOM_TASK +
82
+ // titolo `· <task>` (D2 preflight, l'hook SessionStart ricarica il contesto
83
+ // task). Spot → `--no-task --resume`: resume nudo, solo label progetto. Nessun
84
+ // prompt iniziale in entrambi i casi: riprendere una conversazione significa
85
+ // continuarla, non iniettarle un messaggio (lo salta deck-run).
86
+ //
87
+ // T64 — la NOTA della conversazione (se c'è) viaggia nel titolo della tab. Più
88
+ // sessioni sulla stessa task hanno oggi titoli identici (`label · T81`): la nota
89
+ // è già ciò con cui l'utente le distingue in lista, quindi è anche ciò che
90
+ // distingue le tab. La passa il DECK e non la legge deck-run perché la nota vive
91
+ // nel sidecar `session-tasks.jsonl`, che deck-run non tocca (legge solo
92
+ // `.claude/loom-works.json`): tenerlo così evita di dare al primitive un secondo
93
+ // file da conoscere. Il titolo si congela qui — `claude --name` lo setta una
94
+ // volta sola, quindi una nota cambiata DOPO non ri-titola la tab già aperta.
95
+ export function resumeArgs(taskId, sessionId, note) {
96
+ const args = taskId ? [taskId, '--resume', sessionId] : ['--no-task', '--resume', sessionId];
97
+ if (note)
98
+ args.push('--title-note', note);
99
+ return args;
100
+ }
101
+ export function spawnDeckResume(taskId, cwd, sessionId, note) {
102
+ const child = spawnOut(DECK_RUN, resumeArgs(taskId, sessionId, note), {
103
+ cwd,
104
+ detached: true,
105
+ stdio: 'ignore',
106
+ });
107
+ child.unref();
108
+ return child;
109
+ }
110
+ // T28 — FORK: `deck-run <task|--no-task> --resume <origine> --fork --session-id
111
+ // <nuovo>`. Variante del resume, non una terza forma: cambia solo che CC apre un
112
+ // id nuovo (`--fork-session`) invece di riprendere a scrivere sull'origine —
113
+ // due writer sullo stesso JSONL non esistono mai, che è l'intero punto del fork.
114
+ // Il nuovo id lo genera il DECK e lo pinna, come in spawnDeck: è l'unico modo di
115
+ // conoscerlo prima che la sessione esista, e senza conoscerlo non si possono
116
+ // scrivere né il binding task né il record di lineage (il transcript del fork
117
+ // non nomina da nessuna parte la sessione d'origine).
118
+ // Nessun `--title-note` (T64): il ramo nasce con un sessionId proprio e SENZA
119
+ // nota nel sidecar — ereditare quella dell'origine metterebbe nel titolo una
120
+ // maniglia che nella lista non compare, cioè una promessa falsa. Il fork si
121
+ // distingue col suo marcatore, `· fork`.
122
+ export function forkArgs(taskId, originId, newId) {
123
+ return [
124
+ ...(taskId ? [taskId] : ['--no-task']),
125
+ '--resume',
126
+ originId,
127
+ '--fork',
128
+ '--session-id',
129
+ newId,
130
+ ];
131
+ }
132
+ export function spawnDeckFork(taskId, cwd, originId, newId) {
133
+ const child = spawnOut(DECK_RUN, forkArgs(taskId, originId, newId), {
134
+ cwd,
135
+ detached: true,
136
+ stdio: 'ignore',
137
+ });
138
+ child.unref();
139
+ return child;
140
+ }
141
+ // T42 — sessione Claude NUDA: nessuna task, nessun prompt iniziale, nessun
142
+ // sessionId pinnato (quindi nessuna entry nel sidecar session-tasks.jsonl: senza
143
+ // task non c'è nulla da legare). Funzione separata e non un parametro opzionale
144
+ // di spawnDeck: i tre argomenti mancano tutti insieme, un `if` per ciascuno
145
+ // sporcherebbe il percorso bound. Il titolo tab resta la label loom — lo mette
146
+ // deck-run, perché il match compass è window-level e non sa nulla di task.
147
+ export function spawnClaudeEmpty(cwd) {
148
+ const child = spawnOut(DECK_RUN, ['--no-task'], {
149
+ cwd,
150
+ detached: true,
151
+ stdio: 'ignore',
152
+ });
153
+ child.unref();
154
+ return child;
155
+ }
156
+ // T39/T32: voce `launch` custom del file config, eseguita con cwd = project root.
157
+ // Spawn detached come spawnDeck: il deck lancia ma non possiede il processo.
158
+ // Shell login+interattiva (bash -lic) perché i comandi tipici sono alias o
159
+ // funzioni di ~/.bashrc (`codium`=alias flatpak, `idea`=funzione) — con `bash -c`
160
+ // non risolverebbero. Il comando NON è input utente: viene dal file committato
161
+ // `.claude/loom-works.json`, fidato quanto un custom-command Ptyxis (contratto
162
+ // esplicito in project-config-architecture.md). La project root arriva via cwd,
163
+ // non interpolata nella stringa.
164
+ export function runLaunch(entry, cwd) {
165
+ const child = spawnOut('bash', ['-lic', entry.command], {
166
+ cwd,
167
+ detached: true,
168
+ stdio: 'ignore',
169
+ });
170
+ child.unref();
171
+ return child;
172
+ }
173
+ // T37 — surface STANDARD LAUNCH `terminal`: built-in e universale (nessuna
174
+ // dichiarazione in `launch[]`), ma di natura launch — fire-once, nessuno stato.
175
+ // Il deck gira già DENTRO una tab Ptyxis → `--tab` mette il terminale accanto a
176
+ // sé nella stessa finestra, invece di sparpagliare finestre.
177
+ // Nessun `-- CMD`: l'azione È aprire la shell (differenza dalle launch custom,
178
+ // che eseguono un comando dentro `bash -lic`).
179
+ // `-T <title>` con la chiave `🖥️ <name>` tiene la finestra matchabile da compass
180
+ // anche mentre la tab attiva è il terminale; senza identità nel file config si
181
+ // spawna senza titolo (la surface resta funzionante, il progetto risulta assente
182
+ // dal radar finché quella tab è in primo piano).
183
+ export function terminalArgs(cwd, title) {
184
+ return title ? ['--tab', '-T', title, '-d', cwd] : ['--tab', '-d', cwd];
185
+ }
186
+ export function spawnTerminal(cwd, title) {
187
+ const child = spawnOut('ptyxis', terminalArgs(cwd, title), {
188
+ cwd,
189
+ detached: true,
190
+ stdio: 'ignore',
191
+ });
192
+ child.unref();
193
+ return child;
194
+ }
195
+ // Comando claude (override per ambienti dove non è su PATH; loom-deck → NPM).
196
+ export const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
197
+ // T30: create-task inline. Spawna CC HEADLESS (`-p`) con `--session-id` pinnato
198
+ // che invoca la skill create-task. Differenze da spawnDeck:
199
+ // - headless (`-p`), non una tab Ptyxis interattiva → il deck osserva l'esito;
200
+ // - `yolo` FORZATO: create-task è interattiva di default (AskUserQuestion) e in
201
+ // `-p` non può ricevere risposte → si impianterebbe. yolo = zero domande.
202
+ // - `--output-format stream-json` (richiede `--verbose`): l'ultima riga è
203
+ // `{type:"result", is_error}`, segnale di completamento robusto (> exit code).
204
+ // - detached (own process-group) → il create sopravvive alla chiusura del deck e
205
+ // completa commit+push da sé; stdout in pipe SOLO per leggere il result event.
206
+ // Il prompt viaggia come singolo argv (no shell) → nessuna injection dal testo utente.
207
+ export function spawnCreateTask(text, cwd, sessionId, onResult) {
208
+ const child = spawnOut(CLAUDE_CMD, [
209
+ '-p',
210
+ '--output-format',
211
+ 'stream-json',
212
+ '--verbose',
213
+ '--session-id',
214
+ sessionId,
215
+ `/loom-works:create-task yolo ${text}`,
216
+ ], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
217
+ let buf = '';
218
+ let isError = null;
219
+ child.stdout?.on('data', (chunk) => {
220
+ buf += chunk.toString();
221
+ let nl;
222
+ while ((nl = buf.indexOf('\n')) >= 0) {
223
+ const line = buf.slice(0, nl).trim();
224
+ buf = buf.slice(nl + 1);
225
+ if (!line)
226
+ continue;
227
+ try {
228
+ const obj = JSON.parse(line);
229
+ if (obj.type === 'result')
230
+ isError = obj.is_error ?? false;
231
+ }
232
+ catch {
233
+ // riga parziale / non-json → skip
234
+ }
235
+ }
236
+ });
237
+ // Drena stderr per non riempire il buffer pipe (deadlock del figlio).
238
+ child.stderr?.on('data', () => { });
239
+ child.on('error', () => onResult(false));
240
+ child.on('close', (code) => {
241
+ onResult(isError === null ? code === 0 : !isError);
242
+ });
243
+ return child;
244
+ }
245
+ // T41 — Commit dell'edit. `git commit -- <paths>` committa lo stato working-tree
246
+ // SOLO di quei path, ignorando l'index: se l'utente ha altro in stage (o altri
247
+ // file sporchi) non finisce dentro per errore. NON detached: è un'operazione
248
+ // veloce e il suo esito va riportato nella nota. stderr raccolto per dire perché
249
+ // ha fallito (identità git assente, hook che rifiuta, …) invece di un generico ⚠.
250
+ export function commitTaskEdit(cwd, paths, message, onResult) {
251
+ const child = spawnOut('git', ['commit', '-m', message, '--', ...paths], {
252
+ cwd,
253
+ stdio: ['ignore', 'ignore', 'pipe'],
254
+ });
255
+ let err = '';
256
+ child.stderr?.on('data', (c) => {
257
+ err += c.toString();
258
+ });
259
+ child.on('error', () => onResult(false, 'git non lanciabile'));
260
+ child.on('close', (code) => onResult(code === 0, err.trim().split('\n')[0] ?? ''));
261
+ return child;
262
+ }
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Schermata di assegnazione conversazione → task (T57).
3
+ import { Box, Text } from 'ink';
4
+ import { cut, sanitize, termWidth } from '../width.js';
5
+ import { assignTextWidth, isDone } from '../layout.js';
6
+ import { CARET, CARET_OFF, displayProg } from '../glyphs.js';
7
+ /**
8
+ * Schermata di assegnazione di una conversazione a una task (T57).
9
+ *
10
+ * Fullscreen sostitutiva (D3) come la ricerca: la lista task non entra in un
11
+ * box sopra i due pane. Da lì discende che l'oggetto dell'azione — la sessione
12
+ * selezionata, che non è più a schermo — va ripetuto nel titolo.
13
+ *
14
+ * L'header della lista conta le task escluse dai filtri della vista (D4): la
15
+ * scelta di mostrare `viewTasks` e non tutte le task ha come prezzo noto che un
16
+ * filtro può nascondere proprio il bersaglio, e quel prezzo non deve essere
17
+ * silenzioso — stessa convenzione del `+N più vecchie` del pane sessioni.
18
+ */
19
+ export function AssignScreen({ sessionId, label, current, filter, rows, selected, matched, hidden, above, below, childCount, columns, note, }) {
20
+ const width = assignTextWidth(columns);
21
+ 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: ["assegna ", _jsx(Text, { color: "cyan", children: sessionId.slice(0, 8) }), label ? ` «${cut(sanitize(label), Math.max(10, Math.floor(width / 4)))}»` : '', " \u00B7 ora", ' ', current ? _jsx(Text, { color: "green", children: current }) : 'spot', " \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), ' ', "assegna \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " pulisci \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }), _jsx(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "filtro " }), _jsx(Text, { color: "yellow", children: cut(filter, Math.max(8, width - 24)) }), _jsx(Text, { inverse: true, children: " " }), !filter ? _jsx(Text, { dimColor: true, children: " (id o titolo)" }) : null] }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [matched, " task", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", hidden, " fuori dai filtri"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), rows.map((task) => {
22
+ // T57/D2 — `detach` è una VOCE della lista, non un tasto a parte: un
23
+ // solo gesto (`A`), un solo modale. Speculare alla riga meta `spot`
24
+ // del pane task, e nominata con l'AZIONE («detach») invece che con lo
25
+ // stato d'arrivo — è ciò che si sta per fare.
26
+ if (!task) {
27
+ const sel = selected === null;
28
+ // Le colonne fisse (caret + `○ detach` + i due spazi) sono 12: solo
29
+ // la glossa si taglia, così su un terminale stretto resta comunque
30
+ // il nome dell'azione invece di un moncone di frase.
31
+ return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, "\u25CB detach ", cut('la sessione torna spot', width - 12)] }, "detach"));
32
+ }
33
+ const sel = selected === task.id;
34
+ const n = childCount.get(task.id) ?? 0;
35
+ const head = `${CARET_OFF}${task.id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
36
+ const tail = n > 0 ? ` (${n})` : '';
37
+ const desc = cut(task.desc, Math.max(4, width - termWidth(head) - termWidth(tail)));
38
+ return (_jsxs(Text, { inverse: sel, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
39
+ })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
40
+ }
@@ -0,0 +1,117 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Detail della task (T66): quarta schermata sostitutiva, col markdown reso in
3
+ // span tipizzati (T75) e la ricerca interna (T91).
4
+ import { Box, Text } from 'ink';
5
+ import { caretWindow, cut, cutParts, sanitize } from '../width.js';
6
+ import { sliceLine } from '../text-search.js';
7
+ import { sliceSpans } from '../markdown.js';
8
+ import { DETAIL_ACTIONS, MODELS } from '../spawn.js';
9
+ import { WARN } from '../glyphs.js';
10
+ /** Resa di ogni costrutto markdown (T75 · D4): un solo livello di enfasi per
11
+ * costrutto, senza un secondo alfabeto da imparare. Heading uguali a ogni
12
+ * livello — la gerarchia la porta già il testo. `code` e `fence` condividono
13
+ * il giallo perché sono lo stesso costrutto a due granularità: dargli due
14
+ * colori direbbe che sono due cose. */
15
+ export const MD_STYLE = {
16
+ heading: { bold: true, color: 'cyan' },
17
+ bold: { bold: true },
18
+ code: { color: 'yellow' },
19
+ fence: { color: 'yellow' },
20
+ };
21
+ /**
22
+ * Una riga del detail (T75): markdown reso, con sopra l'evidenziazione della
23
+ * ricerca.
24
+ *
25
+ * Due segmentazioni sulla stessa riga, annidate e non fuse: prima si taglia sui
26
+ * costrutti markdown, poi ogni pezzo si ritaglia sulle occorrenze. L'ordine non
27
+ * è indifferente — così un match a cavallo di un `**grassetto**` resta
28
+ * evidenziato per intero e insieme conserva il grassetto sulla metà che ce
29
+ * l'ha, cosa che una segmentazione unica dovrebbe risolvere decidendo chi vince.
30
+ *
31
+ * Gli offset di `occ` e di `spans` indicizzano ENTRAMBI il testo reso: è ciò
32
+ * che permette di comporli senza rimappature. Vedi `sheetDoc` per il perché la
33
+ * ricerca del detail ha smesso di scandire il sorgente.
34
+ */
35
+ export function DetailLine({ line, spans, occ, current, }) {
36
+ const styled = sliceSpans(line, spans);
37
+ // Riga vuota → uno spazio: un `<Text>` senza contenuto Ink non lo disegna, e
38
+ // il testo si compatterebbe perdendo la struttura del file.
39
+ if (styled.length === 0)
40
+ return _jsx(Text, { wrap: "truncate-end", children: " " });
41
+ let off = line.start;
42
+ return (_jsx(Text, { wrap: "truncate-end", children: styled.map((seg, i) => {
43
+ const st = seg.kind ? MD_STYLE[seg.kind] : undefined;
44
+ const at = off;
45
+ off += seg.text.length;
46
+ // Senza ricerca aperta il secondo taglio non ha niente da tagliare, e
47
+ // saltarlo evita di allocare tre array per ogni riga a ogni freccia.
48
+ if (occ.length === 0) {
49
+ return (_jsx(Text, { bold: st?.bold, color: st?.color, children: seg.text }, i));
50
+ }
51
+ 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));
52
+ }) }));
53
+ }
54
+ /** Campo della ricerca nel detail: finestra ancorata al caret, cursore inverso
55
+ * sulla cella reale. Gemello di `EditTextField` senza la label, che qui sta
56
+ * fuori perché il campo vive in FLUSSO su una riga condivisa col contatore —
57
+ * non su una riga propria. */
58
+ export function DetailFindField({ value, caret, cols }) {
59
+ const win = caretWindow(value, caret, cols);
60
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { children: sanitize(win.head) }), _jsx(Text, { inverse: true, children: sanitize(win.at) }), _jsx(Text, { children: sanitize(win.tail) })] }));
61
+ }
62
+ /**
63
+ * Detail della task (T66): il task file scrollabile più la barra azioni.
64
+ *
65
+ * Unisce due gesti che erano due schermate — leggere la task e agire su di essa
66
+ * — perché convergono sullo stesso oggetto: si legge la Description proprio per
67
+ * decidere QUALE azione lanciare, e con due overlay separati quella decisione
68
+ * costava uscire dal viewer e ricordarsi la combo.
69
+ *
70
+ * Le azioni sono BOTTONI AFFIANCATI e non voci di un menu verticale: un
71
+ * rettangolo ha già coordinate e area cliccabile, quindi il layout sopravvive
72
+ * all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione. La
73
+ * navigazione da tastiera ci si sovrappone senza conflitti.
74
+ */
75
+ export function DetailScreen({ id, title, missing, lines, spans, top, total, capacity, action, model, columns, find, occ, occCur, }) {
76
+ const last = Math.min(total, top + capacity);
77
+ // Il taglio lo fa il chiamante (invariante ③ di width.ts): la riga bottoni è
78
+ // ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso — ma
79
+ // la correttezza non deve dipendere dall'alfabeto che capita nella riga.
80
+ const width = Math.max(20, (columns || 80) - 4);
81
+ const segs = DETAIL_ACTIONS.map((a) => `[ ${a.label} ]`);
82
+ const parts = [];
83
+ segs.forEach((s, i) => {
84
+ if (i > 0)
85
+ parts.push(' ');
86
+ parts.push(s);
87
+ });
88
+ const dropped = (v, of) => of.filter((s, i) => v[i * 2] !== s).length;
89
+ // Due passate: la seconda serve SOLO quando qualcosa cade, e riserva le
90
+ // colonne del contatore. Riservarle sempre costerebbe 6 colonne su ogni
91
+ // terminale largo per un avviso che lì non comparirà mai.
92
+ let shown = cutParts(parts, width);
93
+ if (dropped(shown, segs) > 0)
94
+ shown = cutParts(parts, Math.max(0, width - 6));
95
+ const cutCount = dropped(shown, segs);
96
+ // T108 — riga del selettore modello, stesso schema di bottoni affiancati della
97
+ // barra azioni. `priority` sulla voce SELEZIONATA e non sulla prima: qui il
98
+ // troncamento cancellerebbe l'unica informazione che la riga esiste per dare
99
+ // — quale modello sta per essere usato — mentre nella barra azioni la voce
100
+ // attiva è comunque nota dal tasto appena premuto.
101
+ const mSegs = MODELS.map((m, i) => `${i + 1} ${m}`);
102
+ const mParts = [];
103
+ mSegs.forEach((s, i) => {
104
+ if (i > 0)
105
+ mParts.push(' ');
106
+ mParts.push(s);
107
+ });
108
+ const mIdx = Math.max(0, MODELS.indexOf(model));
109
+ // `- 8` = le colonne del prefisso "modello ", che sta fuori dai parts perché
110
+ // non è un bottone e non deve mai cadere.
111
+ const mWidth = Math.max(0, width - 8);
112
+ let mShown = cutParts(mParts, mWidth, mIdx * 2);
113
+ if (dropped(mShown, mSegs) > 0)
114
+ mShown = cutParts(mParts, Math.max(0, mWidth - 6), mIdx * 2);
115
+ const mCut = dropped(mShown, mSegs);
116
+ 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" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "1-4/tab" }), " modello \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(DetailFindField, { 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, { bold: 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: [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] })] })] }));
117
+ }
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Modali in FLUSSO sopra i due pane (sort, filtri, edit): a differenza delle
3
+ // schermate sostitutive costano righe al budget d'altezza, quindi ognuno ha un
4
+ // costo dichiarato in `viewport.ts`.
5
+ import { Box, Text } from 'ink';
6
+ import { caretWindow, sanitize } from '../width.js';
7
+ import { cpLen } from '../layout.js';
8
+ import { CARET, CARET_OFF } from '../glyphs.js';
9
+ import { EDIT_PRI, EDIT_PROG } from '../model.js';
10
+ import { PRI_ENTRIES, PROG_ENTRIES } from '../view.js';
11
+ import { progressText, PRI_GLYPH, PRI_LABEL, PROG_GLYPH } from '../task-edit.js';
12
+ export const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
13
+ // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
14
+ // spingono giù i pane invece di coprirli, così la lista che stai filtrando
15
+ // resta sempre visibile mentre la componi.
16
+ export function SortModal({ sort }) {
17
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "S \u203A sort chain" }), sort.length === 0 ? (_jsx(Text, { dimColor: true, children: "nessuna chiave \u00B7 resta l'ordine per id \u2191" })) : (_jsx(Text, { children: sort
18
+ .map((e, i) => `${i + 1}. ${SORT_UI[e.key]} ${e.dir === 'asc' ? '↑' : '↓'}`)
19
+ .join(' ') }))] }));
20
+ }
21
+ export function FilterModal({ view, cursor }) {
22
+ const rows = [
23
+ { label: 'pri ', entries: PRI_ENTRIES, hidden: new Set(view.hiddenPri) },
24
+ { label: 'stato', entries: PROG_ENTRIES, hidden: new Set(view.hiddenProg) },
25
+ ];
26
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "F \u203A filtri" }), rows.map((row, r) => (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: row.label }), row.entries.map((e, c) => {
27
+ const on = !row.hidden.has(e.name);
28
+ const here = cursor.row === r && cursor.col === c;
29
+ return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ", sanitize(e.glyph)] }, e.name));
30
+ })] }, row.label)))] }));
31
+ }
32
+ /**
33
+ * Campo di testo del modale edit: finestra ancorata al caret + cursore inverso
34
+ * nella posizione REALE.
35
+ *
36
+ * Il cursore non è più uno spazio inverso appiccicato in coda ma la cella `at`
37
+ * della finestra — cioè il carattere su cui il caret sta davvero. Fuori fuoco
38
+ * (`focused` falso) il caret non si disegna e la finestra si ancora in fondo,
39
+ * che è la vista utile per un campo che non si sta scrivendo.
40
+ */
41
+ export function EditTextField({ label, value, caret, focused, cols, }) {
42
+ const win = caretWindow(value, focused ? caret : cpLen(value), cols);
43
+ 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) })] }));
44
+ }
45
+ // T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
46
+ // coprirli: la riga che stai modificando resta visibile sopra la lista).
47
+ // La riga di anteprima mostra il testo ESATTO che finirà nel campo `Progress`
48
+ // del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
49
+ export function EditModal({ id, draft, row, columns, }) {
50
+ const mark = (r) => (row === r ? CARET : CARET_OFF);
51
+ // Budget dei campi di testo, DERIVATO da `columns` (mai una costante): il box
52
+ // del modale è ANNIDATO nella cornice del deck, quindi le cornici da scalare
53
+ // sono due — root (bordo 2 + paddingX 2) e modale (bordo 2 + paddingX 2) — più
54
+ // caret 2, etichetta 6, gap 2 e cursore 1. Totale 19.
55
+ // Un titolo di tasks.md arriva a ~64 caratteri: senza taglio la riga va a capo
56
+ // dentro il box, che si alza di una riga e sfonda il budget verticale (invariante ③).
57
+ const fieldBudget = Math.max(8, columns - 19);
58
+ 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))] })] }));
59
+ }