@lamemind/loom-deck 0.34.0 → 0.34.1

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,250 @@
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
+ // T66 — le azioni del detail. Non sono un catalogo nuovo: ognuna è un
36
+ // `--prompt-kind` già esistente più `checkpoint`, e tutte passano dallo stesso
37
+ // `spawnForTask` dei CTRL della lista — una superficie in più, zero percorsi di
38
+ // spawn in più.
39
+ //
40
+ // L'etichetta è distinta dal kind dove il kind è il nome del MECCANISMO e
41
+ // l'etichetta quello dell'INTENZIONE: `none` è "aprire la task a mani nude",
42
+ // `recap` è "vedere a che punto sta".
43
+ export const DETAIL_ACTIONS = [
44
+ { kind: 'none', label: 'open' },
45
+ { kind: 'preflight', label: 'preflight' },
46
+ { kind: 'run', label: 'run' },
47
+ { kind: 'recap', label: 'status' },
48
+ { kind: 'checkpoint', label: 'checkpoint' },
49
+ ];
50
+ // Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
51
+ // ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
52
+ // sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
53
+ // Il kind è OBBLIGATORIO e non ha default qui: il default vive in deck-run (per
54
+ // le invocazioni a mano), mentre dal deck ogni tasto dichiara il proprio intento
55
+ // — un default silenzioso renderebbe indistinguibili `⏎` e `^K`.
56
+ export function deckArgs(id, sessionId, kind) {
57
+ return [id, '--session-id', sessionId, '--prompt-kind', kind];
58
+ }
59
+ export function spawnDeck(id, cwd, sessionId, kind) {
60
+ const child = spawnOut(DECK_RUN, deckArgs(id, sessionId, kind), {
61
+ cwd,
62
+ detached: true,
63
+ stdio: 'ignore',
64
+ });
65
+ child.unref();
66
+ return child;
67
+ }
68
+ // T49 — resume di una sessione esistente come nuova tab Ptyxis. Scoped (taskId
69
+ // presente) → `deck-run <task> --resume <sid>`: la ripresa eredita LOOM_TASK +
70
+ // titolo `· <task>` (D2 preflight, l'hook SessionStart ricarica il contesto
71
+ // task). Spot → `--no-task --resume`: resume nudo, solo label progetto. Nessun
72
+ // prompt iniziale in entrambi i casi: riprendere una conversazione significa
73
+ // continuarla, non iniettarle un messaggio (lo salta deck-run).
74
+ //
75
+ // T64 — la NOTA della conversazione (se c'è) viaggia nel titolo della tab. Più
76
+ // sessioni sulla stessa task hanno oggi titoli identici (`label · T81`): la nota
77
+ // è già ciò con cui l'utente le distingue in lista, quindi è anche ciò che
78
+ // distingue le tab. La passa il DECK e non la legge deck-run perché la nota vive
79
+ // nel sidecar `session-tasks.jsonl`, che deck-run non tocca (legge solo
80
+ // `.claude/loom-works.json`): tenerlo così evita di dare al primitive un secondo
81
+ // file da conoscere. Il titolo si congela qui — `claude --name` lo setta una
82
+ // volta sola, quindi una nota cambiata DOPO non ri-titola la tab già aperta.
83
+ export function resumeArgs(taskId, sessionId, note) {
84
+ const args = taskId ? [taskId, '--resume', sessionId] : ['--no-task', '--resume', sessionId];
85
+ if (note)
86
+ args.push('--title-note', note);
87
+ return args;
88
+ }
89
+ export function spawnDeckResume(taskId, cwd, sessionId, note) {
90
+ const child = spawnOut(DECK_RUN, resumeArgs(taskId, sessionId, note), {
91
+ cwd,
92
+ detached: true,
93
+ stdio: 'ignore',
94
+ });
95
+ child.unref();
96
+ return child;
97
+ }
98
+ // T28 — FORK: `deck-run <task|--no-task> --resume <origine> --fork --session-id
99
+ // <nuovo>`. Variante del resume, non una terza forma: cambia solo che CC apre un
100
+ // id nuovo (`--fork-session`) invece di riprendere a scrivere sull'origine —
101
+ // due writer sullo stesso JSONL non esistono mai, che è l'intero punto del fork.
102
+ // Il nuovo id lo genera il DECK e lo pinna, come in spawnDeck: è l'unico modo di
103
+ // conoscerlo prima che la sessione esista, e senza conoscerlo non si possono
104
+ // scrivere né il binding task né il record di lineage (il transcript del fork
105
+ // non nomina da nessuna parte la sessione d'origine).
106
+ // Nessun `--title-note` (T64): il ramo nasce con un sessionId proprio e SENZA
107
+ // nota nel sidecar — ereditare quella dell'origine metterebbe nel titolo una
108
+ // maniglia che nella lista non compare, cioè una promessa falsa. Il fork si
109
+ // distingue col suo marcatore, `· fork`.
110
+ export function forkArgs(taskId, originId, newId) {
111
+ return [
112
+ ...(taskId ? [taskId] : ['--no-task']),
113
+ '--resume',
114
+ originId,
115
+ '--fork',
116
+ '--session-id',
117
+ newId,
118
+ ];
119
+ }
120
+ export function spawnDeckFork(taskId, cwd, originId, newId) {
121
+ const child = spawnOut(DECK_RUN, forkArgs(taskId, originId, newId), {
122
+ cwd,
123
+ detached: true,
124
+ stdio: 'ignore',
125
+ });
126
+ child.unref();
127
+ return child;
128
+ }
129
+ // T42 — sessione Claude NUDA: nessuna task, nessun prompt iniziale, nessun
130
+ // sessionId pinnato (quindi nessuna entry nel sidecar session-tasks.jsonl: senza
131
+ // task non c'è nulla da legare). Funzione separata e non un parametro opzionale
132
+ // di spawnDeck: i tre argomenti mancano tutti insieme, un `if` per ciascuno
133
+ // sporcherebbe il percorso bound. Il titolo tab resta la label loom — lo mette
134
+ // deck-run, perché il match compass è window-level e non sa nulla di task.
135
+ export function spawnClaudeEmpty(cwd) {
136
+ const child = spawnOut(DECK_RUN, ['--no-task'], {
137
+ cwd,
138
+ detached: true,
139
+ stdio: 'ignore',
140
+ });
141
+ child.unref();
142
+ return child;
143
+ }
144
+ // T39/T32: voce `launch` custom del file config, eseguita con cwd = project root.
145
+ // Spawn detached come spawnDeck: il deck lancia ma non possiede il processo.
146
+ // Shell login+interattiva (bash -lic) perché i comandi tipici sono alias o
147
+ // funzioni di ~/.bashrc (`codium`=alias flatpak, `idea`=funzione) — con `bash -c`
148
+ // non risolverebbero. Il comando NON è input utente: viene dal file committato
149
+ // `.claude/loom-works.json`, fidato quanto un custom-command Ptyxis (contratto
150
+ // esplicito in project-config-architecture.md). La project root arriva via cwd,
151
+ // non interpolata nella stringa.
152
+ export function runLaunch(entry, cwd) {
153
+ const child = spawnOut('bash', ['-lic', entry.command], {
154
+ cwd,
155
+ detached: true,
156
+ stdio: 'ignore',
157
+ });
158
+ child.unref();
159
+ return child;
160
+ }
161
+ // T37 — surface STANDARD LAUNCH `terminal`: built-in e universale (nessuna
162
+ // dichiarazione in `launch[]`), ma di natura launch — fire-once, nessuno stato.
163
+ // Il deck gira già DENTRO una tab Ptyxis → `--tab` mette il terminale accanto a
164
+ // sé nella stessa finestra, invece di sparpagliare finestre.
165
+ // Nessun `-- CMD`: l'azione È aprire la shell (differenza dalle launch custom,
166
+ // che eseguono un comando dentro `bash -lic`).
167
+ // `-T <title>` con la chiave `🖥️ <name>` tiene la finestra matchabile da compass
168
+ // anche mentre la tab attiva è il terminale; senza identità nel file config si
169
+ // spawna senza titolo (la surface resta funzionante, il progetto risulta assente
170
+ // dal radar finché quella tab è in primo piano).
171
+ export function terminalArgs(cwd, title) {
172
+ return title ? ['--tab', '-T', title, '-d', cwd] : ['--tab', '-d', cwd];
173
+ }
174
+ export function spawnTerminal(cwd, title) {
175
+ const child = spawnOut('ptyxis', terminalArgs(cwd, title), {
176
+ cwd,
177
+ detached: true,
178
+ stdio: 'ignore',
179
+ });
180
+ child.unref();
181
+ return child;
182
+ }
183
+ // Comando claude (override per ambienti dove non è su PATH; loom-deck → NPM).
184
+ export const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
185
+ // T30: create-task inline. Spawna CC HEADLESS (`-p`) con `--session-id` pinnato
186
+ // che invoca la skill create-task. Differenze da spawnDeck:
187
+ // - headless (`-p`), non una tab Ptyxis interattiva → il deck osserva l'esito;
188
+ // - `yolo` FORZATO: create-task è interattiva di default (AskUserQuestion) e in
189
+ // `-p` non può ricevere risposte → si impianterebbe. yolo = zero domande.
190
+ // - `--output-format stream-json` (richiede `--verbose`): l'ultima riga è
191
+ // `{type:"result", is_error}`, segnale di completamento robusto (> exit code).
192
+ // - detached (own process-group) → il create sopravvive alla chiusura del deck e
193
+ // completa commit+push da sé; stdout in pipe SOLO per leggere il result event.
194
+ // Il prompt viaggia come singolo argv (no shell) → nessuna injection dal testo utente.
195
+ export function spawnCreateTask(text, cwd, sessionId, onResult) {
196
+ const child = spawnOut(CLAUDE_CMD, [
197
+ '-p',
198
+ '--output-format',
199
+ 'stream-json',
200
+ '--verbose',
201
+ '--session-id',
202
+ sessionId,
203
+ `/loom-works:create-task yolo ${text}`,
204
+ ], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
205
+ let buf = '';
206
+ let isError = null;
207
+ child.stdout?.on('data', (chunk) => {
208
+ buf += chunk.toString();
209
+ let nl;
210
+ while ((nl = buf.indexOf('\n')) >= 0) {
211
+ const line = buf.slice(0, nl).trim();
212
+ buf = buf.slice(nl + 1);
213
+ if (!line)
214
+ continue;
215
+ try {
216
+ const obj = JSON.parse(line);
217
+ if (obj.type === 'result')
218
+ isError = obj.is_error ?? false;
219
+ }
220
+ catch {
221
+ // riga parziale / non-json → skip
222
+ }
223
+ }
224
+ });
225
+ // Drena stderr per non riempire il buffer pipe (deadlock del figlio).
226
+ child.stderr?.on('data', () => { });
227
+ child.on('error', () => onResult(false));
228
+ child.on('close', (code) => {
229
+ onResult(isError === null ? code === 0 : !isError);
230
+ });
231
+ return child;
232
+ }
233
+ // T41 — Commit dell'edit. `git commit -- <paths>` committa lo stato working-tree
234
+ // SOLO di quei path, ignorando l'index: se l'utente ha altro in stage (o altri
235
+ // file sporchi) non finisce dentro per errore. NON detached: è un'operazione
236
+ // veloce e il suo esito va riportato nella nota. stderr raccolto per dire perché
237
+ // ha fallito (identità git assente, hook che rifiuta, …) invece di un generico ⚠.
238
+ export function commitTaskEdit(cwd, paths, message, onResult) {
239
+ const child = spawnOut('git', ['commit', '-m', message, '--', ...paths], {
240
+ cwd,
241
+ stdio: ['ignore', 'ignore', 'pipe'],
242
+ });
243
+ let err = '';
244
+ child.stderr?.on('data', (c) => {
245
+ err += c.toString();
246
+ });
247
+ child.on('error', () => onResult(false, 'git non lanciabile'));
248
+ child.on('close', (code) => onResult(code === 0, err.trim().split('\n')[0] ?? ''));
249
+ return child;
250
+ }
@@ -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,97 @@
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 } 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, 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) => segs.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) > 0)
94
+ shown = cutParts(parts, Math.max(0, width - 6));
95
+ const cutCount = dropped(shown);
96
+ 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: "^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, _jsx(Box, { marginTop: 1, children: _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] }) })] }));
97
+ }
@@ -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
+ }