@lamemind/loom-deck 0.33.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/README.md +4 -4
- package/dist/cli.js +321 -1981
- package/dist/glyphs.js +83 -0
- package/dist/hooks.js +192 -0
- package/dist/input-modes.js +52 -0
- package/dist/layout.js +121 -0
- package/dist/model.js +55 -0
- package/dist/overlays/assign.js +112 -0
- package/dist/overlays/search.js +259 -0
- package/dist/overlays/sheet.js +222 -0
- package/dist/pane-views.js +1 -1
- package/dist/spawn.js +250 -0
- package/dist/ui/assign-screen.js +40 -0
- package/dist/ui/detail-screen.js +97 -0
- package/dist/ui/modals.js +59 -0
- package/dist/ui/panes.js +220 -0
- package/dist/ui/preview.js +45 -0
- package/dist/ui/search-screen.js +115 -0
- package/package.json +1 -1
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
|
+
}
|