@lamemind/loom-deck 0.55.0 → 0.56.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.
@@ -0,0 +1,231 @@
1
+ // I modali IN FLUSSO: quelli che si disegnano in un box dentro la cornice
2
+ // invece di sostituirla. Sono due famiglie e stanno nello stesso file perché
3
+ // hanno lo stesso mestiere — tenere una bozza mentre si digita — e perché il
4
+ // loro gemello di vista esiste già e si chiama `ui/modals.tsx`. La simmetria
5
+ // `ui/modals.tsx` (vista) ↔ `overlays/modals.ts` (stato e input) è essa stessa
6
+ // un'informazione: chi cerca l'uno trova l'altro.
7
+ //
8
+ // `useViewModals` governa sort e filtri, che compongono la VISTA. Lo stato
9
+ // `view` non è loro e vive nel modello (`deck-model.ts`): è ciò che `applyView`
10
+ // consuma, è persistito su disco e lo rilegge il tasto `w`. Qui resta ciò che è
11
+ // davvero del modale — la fotografia per l'annullamento e la posizione nella
12
+ // griglia dei filtri.
13
+ //
14
+ // `useTextModals` governa create, nota ed edit, cioè i tre campi di testo. Le
15
+ // scritture che ne conseguono escono come callback (deroga T104: un hook di
16
+ // overlay non esce dal deck).
17
+ import { useState } from 'react';
18
+ import { caretAtEnd, fieldsKey } from '../fields.js';
19
+ import { sanitizeTyped } from '../glyphs.js';
20
+ import { EDIT_FIELDS, EDIT_PRI, EDIT_PROG, editTextField, SORT_TASTI, } from '../model.js';
21
+ import { cycleSort, describeSort, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from '../view.js';
22
+ export function useViewModals({ view, setView, hiddenTasks, setMode, setNote, }) {
23
+ // T39 — fotografia della vista all'apertura di un modale: la lista si
24
+ // aggiorna dal vivo, quindi `esc` deve poter ripristinare.
25
+ const [backup, setBackup] = useState(null);
26
+ const [filterCursor, setFilterCursor] = useState({ row: 0, col: 0 });
27
+ /** Chiusura: `restore` rimette la fotografia (esc = annulla), altrimenti tiene ciò che si è composto (⏎). */
28
+ function close(restore) {
29
+ if (restore && backup) {
30
+ const snapshot = backup;
31
+ setView(() => snapshot);
32
+ }
33
+ setBackup(null);
34
+ setMode('normal');
35
+ }
36
+ function openSort() {
37
+ setBackup(view);
38
+ setNote('');
39
+ setMode('sort');
40
+ }
41
+ function openFilter() {
42
+ setBackup(view);
43
+ setNote('');
44
+ setMode('filter');
45
+ }
46
+ function onSortKey(input, key) {
47
+ if (key.escape) {
48
+ close(true);
49
+ setNote('S → sort annullato');
50
+ }
51
+ else if (key.return) {
52
+ close(false);
53
+ setNote(`S → sort: ${describeSort(view.sort)}`);
54
+ }
55
+ else if (input) {
56
+ // useInput consegna il CHUNK letto da stdin, non un tasto: digitando
57
+ // veloce (o incollando) `ppi` arriva come stringa unica. Si cicla su
58
+ // ogni carattere, così la chain esce identica a battitura lenta.
59
+ const keys = [...input].map((ch) => SORT_TASTI[ch]).filter(Boolean);
60
+ if (keys.length > 0) {
61
+ setView((v) => ({ ...v, sort: keys.reduce(cycleSort, v.sort) }));
62
+ }
63
+ }
64
+ }
65
+ function onFilterKey(input, key) {
66
+ const rowLen = (r) => (r === 0 ? PRI_ENTRIES.length : PROG_ENTRIES.length);
67
+ if (key.escape) {
68
+ close(true);
69
+ setNote('F → filtri annullati');
70
+ }
71
+ else if (key.return) {
72
+ close(false);
73
+ setNote(hiddenTasks > 0 ? `F → ${hiddenTasks} task nascoste` : 'F → nessun filtro attivo');
74
+ }
75
+ else if (key.upArrow || key.downArrow) {
76
+ setFilterCursor((c) => {
77
+ const row = c.row === 0 ? 1 : 0;
78
+ return { row, col: Math.min(c.col, rowLen(row) - 1) };
79
+ });
80
+ }
81
+ else if (key.leftArrow) {
82
+ setFilterCursor((c) => ({ ...c, col: Math.max(0, c.col - 1) }));
83
+ }
84
+ else if (key.rightArrow) {
85
+ setFilterCursor((c) => ({ ...c, col: Math.min(rowLen(c.row) - 1, c.col + 1) }));
86
+ }
87
+ else if (input === ' ') {
88
+ const { row, col } = filterCursor;
89
+ setView((v) => row === 0
90
+ ? { ...v, hiddenPri: toggleHidden(v.hiddenPri, PRI_ENTRIES[col].name) }
91
+ : { ...v, hiddenProg: toggleHidden(v.hiddenProg, PROG_ENTRIES[col].name) });
92
+ }
93
+ }
94
+ return { filterCursor, openSort, openFilter, onSortKey, onFilterKey };
95
+ }
96
+ export function useTextModals({ setMode, setNote, onCreate, onNote, onEdit, editDraftFor, currentNote, }) {
97
+ const [draft, setDraft] = useState('');
98
+ // T53 — bozza della nota sulla conversazione selezionata. Si apre PRECARICATA
99
+ // con la nota esistente: annotare due volte è quasi sempre correggere, non
100
+ // riscrivere da zero, e un campo vuoto costringerebbe a ridigitare tutto per
101
+ // cambiare una parola. Confermare il campo vuoto cancella la nota.
102
+ const [noteDraft, setNoteDraft] = useState('');
103
+ const [noteSid, setNoteSid] = useState(null);
104
+ // T41 — bozza dell'edit (null fuori dal modale) e posizione nell'area di
105
+ // compilazione: riga in fuoco + caret dentro la riga di testo attiva.
106
+ const [edit, setEdit] = useState(null);
107
+ const [editTask, setEditTask] = useState(null);
108
+ const [editCursor, setEditCursor] = useState({ row: 0, caret: 0 });
109
+ function openCreate() {
110
+ setNote('');
111
+ setMode('create');
112
+ }
113
+ function openNote(sid) {
114
+ setNoteSid(sid);
115
+ setNoteDraft(currentNote(sid));
116
+ setNote('');
117
+ setMode('note');
118
+ }
119
+ function openEdit(task) {
120
+ setEditTask(task);
121
+ setEdit(editDraftFor(task));
122
+ // Si apre sulla riga 0 (priorità), che non è un campo di testo: il caret
123
+ // prende la sua posizione entrando in una riga di testo con ↑↓.
124
+ setEditCursor({ row: 0, caret: 0 });
125
+ setNote('');
126
+ setMode('edit');
127
+ }
128
+ function onCreateKey(input, key) {
129
+ if (key.escape) {
130
+ setMode('normal');
131
+ setDraft('');
132
+ setNote('C → create annullato');
133
+ }
134
+ else if (key.return) {
135
+ const text = draft.trim();
136
+ setMode('normal');
137
+ setDraft('');
138
+ onCreate(text);
139
+ }
140
+ else if (key.backspace || key.delete) {
141
+ setDraft((d) => d.slice(0, -1));
142
+ }
143
+ else if (input && !key.ctrl && !key.meta) {
144
+ setDraft((d) => d + input);
145
+ }
146
+ }
147
+ function onNoteKey(input, key) {
148
+ if (key.escape) {
149
+ setMode('normal');
150
+ setNoteDraft('');
151
+ setNote('N → titolo annullato');
152
+ }
153
+ else if (key.return) {
154
+ const sid = noteSid;
155
+ const text = noteDraft.trim();
156
+ setMode('normal');
157
+ setNoteDraft('');
158
+ if (sid)
159
+ onNote(sid, text);
160
+ }
161
+ else if (key.ctrl && input === 'u') {
162
+ // Svuota il campo in un colpo. NON è una scorciatoia di comodo: il
163
+ // backspace tenuto premuto cancella UN carattere per CHUNK letto da
164
+ // stdin, non per pressione (`useInput` consegna il chunk, e per una
165
+ // raffica di DEL Ink alza `key.backspace` una volta sola) — misurato,
166
+ // 30 pressioni → 2 caratteri. Siccome «campo vuoto» qui è l'unico modo
167
+ // di CANCELLARE una nota, dipendere dal backspace renderebbe
168
+ // l'operazione praticamente non eseguibile. `^U` è il kill-line delle
169
+ // shell, quindi il gesto è già nelle dita di chi usa un terminale.
170
+ setNoteDraft('');
171
+ }
172
+ else if (key.backspace || key.delete) {
173
+ setNoteDraft((d) => d.slice(0, -1));
174
+ }
175
+ else if (input && !key.ctrl && !key.meta) {
176
+ // Sanificazione dei byte di controllo, come `typeIntoField` della
177
+ // ricerca: `useInput` consegna il CHUNK letto da stdin, quindi un
178
+ // incollaggio porta dentro newline e control char. Invisibili a schermo
179
+ // ma contati da Ink nella larghezza della riga — e qui finirebbero
180
+ // scritti su disco, dove resterebbero a sporcare la riga per sempre.
181
+ // Le newline diventano spazio: incollare due righe deve separare le
182
+ // parole, non fonderle.
183
+ setNoteDraft((d) => d + sanitizeTyped(input));
184
+ }
185
+ }
186
+ // Il ponte fra le quattro righe del modale e la bozza. I valori restano
187
+ // tipizzati (`PriName`, `ProgName`) invece di finire in un record generico:
188
+ // `fields.ts` governa la grammatica dei tasti, non il modello dati.
189
+ const editIO = {
190
+ text: (row) => edit?.[editTextField(row)] ?? '',
191
+ setText: (row, next) => setEdit((e) => (e ? { ...e, [editTextField(row)]: next } : e)),
192
+ choice: (row) => row === 0
193
+ ? Math.max(0, EDIT_PRI.indexOf(edit?.pri ?? 'med'))
194
+ : Math.max(0, EDIT_PROG.indexOf(edit?.prog ?? 'todo')),
195
+ setChoice: (row, index) => setEdit((e) => e ? (row === 0 ? { ...e, pri: EDIT_PRI[index] } : { ...e, prog: EDIT_PROG[index] }) : e),
196
+ };
197
+ function onEditKey(input, key) {
198
+ if (key.escape) {
199
+ setMode('normal');
200
+ setEdit(null);
201
+ setNote('E → edit annullato');
202
+ return;
203
+ }
204
+ if (key.return) {
205
+ const task = editTask;
206
+ const current = edit;
207
+ setMode('normal');
208
+ setEdit(null);
209
+ if (task && current)
210
+ onEdit(task, current);
211
+ return;
212
+ }
213
+ // Tutto il resto è dell'area di compilazione, che è la STESSA del detail
214
+ // (T117): una grammatica sola, non due copie da tenere allineate a mano.
215
+ fieldsKey(input, key, EDIT_FIELDS, editCursor, setEditCursor, editIO);
216
+ }
217
+ return {
218
+ draft,
219
+ noteDraft,
220
+ edit,
221
+ editTask,
222
+ editCursor,
223
+ openCreate,
224
+ openNote,
225
+ openEdit,
226
+ onCreateKey,
227
+ onNoteKey,
228
+ onEditKey,
229
+ };
230
+ }
231
+ export { caretAtEnd };
@@ -0,0 +1,44 @@
1
+ // T112 — l'overlay di conferma dell'eliminazione (`CANC`).
2
+ //
3
+ // T131 — è nato dentro `cli.tsx` e ci è rimasto per centosessantacinque righe,
4
+ // pur avendo da subito la forma esatta degli altri overlay: un modo dichiarato
5
+ // in `CAPTURING_MODES`, uno stato proprio, un handler proprio, un'apertura con
6
+ // precondizioni. È la prova che `MODE_KEYS` non basta come custode — quel
7
+ // `Record` obbliga un modo nuovo ad avere un handler, non a tenerlo fuori dal
8
+ // componente. Il gate che lo pretende è `test/input-wiring.test.ts`.
9
+ //
10
+ // La deroga di T104 vale anche qui: l'hook non esce dal deck. Costruire la
11
+ // bozza e ordinarne l'esecuzione sono due cose diverse — la prima arriva come
12
+ // `draftFor`, la seconda parte come `onSubmit`, ed entrambe vivono in
13
+ // `actions.ts`. Quello che resta qui è lo stato del modale e i suoi tasti.
14
+ import { useState } from 'react';
15
+ export function usePurgeOverlay({ setMode, setNote, draftFor, onSubmit, }) {
16
+ // La bozza della conferma (null fuori dal modale).
17
+ const [draft, setDraft] = useState(null);
18
+ function open() {
19
+ const next = draftFor();
20
+ if (!next)
21
+ return;
22
+ setDraft(next);
23
+ setNote('');
24
+ setMode('purge');
25
+ }
26
+ function onKey(_input, key) {
27
+ if (key.escape) {
28
+ setMode('normal');
29
+ setDraft(null);
30
+ setNote('CANC → eliminazione annullata');
31
+ }
32
+ else if (key.return) {
33
+ const current = draft;
34
+ setMode('normal');
35
+ setDraft(null);
36
+ if (current)
37
+ onSubmit(current);
38
+ }
39
+ else if ((key.leftArrow || key.rightArrow) && draft?.ignored) {
40
+ setDraft((p) => (p ? { ...p, ignored: p.ignored === 'keep' ? 'purge' : 'keep' } : p));
41
+ }
42
+ }
43
+ return { draft, open, onKey };
44
+ }
@@ -1,8 +1,8 @@
1
1
  // T21 (mandata 3) — le PARTI degli header dei due pane come DATO, non come JSX.
2
2
  //
3
- // Stesso principio della riga launch (`launchSegments` in `cli.tsx`): gli
3
+ // Stesso principio della riga launch (`launchRow` in `frame.ts`): gli
4
4
  // stessi oggetti compongono la riga disegnata (`ui/panes.tsx`) e le colonne
5
- // dell'hit-test (`cli.tsx`). Chi li deriva dall'altro — ri-splittando la
5
+ // dell'hit-test (`frame.ts`). Chi li deriva dall'altro — ri-splittando la
6
6
  // stringa renderizzata, o ricontando le voci del catalogo — tiene due conti
7
7
  // paralleli che divergono alla prima voce che cambia testo o ordine.
8
8
  //
@@ -61,7 +61,7 @@ export function selectTasks(tasks, id, ctx) {
61
61
  return picked;
62
62
  }
63
63
  /** Glifo della sessione viva, gemello di `LIVE_IDLE` del render. Duplicato qui
64
- * perché il catalogo è puro e non può importare da `cli.tsx`; è un carattere
64
+ * perché il catalogo è puro e non può importare da `ui/`; è un carattere
65
65
  * concorde (largo 1 per entrambe le contabilità), quindi non passa da
66
66
  * `sanitize`. */
67
67
  const LIVE_GLYPH = '●';
@@ -0,0 +1,106 @@
1
+ // T134 — dove sta il plugin loom-works su questa macchina, e quale versione.
2
+ //
3
+ // Terza volta che il deck incontra l'assenza di `CLAUDE_PLUGIN_ROOT`: è una env
4
+ // che esiste solo dentro un processo Claude Code, e il deck è un binario npm
5
+ // spawnato da Ptyxis. Le prime due volte (età delle task archiviabili in
6
+ // `archivable.ts`, gate `--ignored-files` in `purge.ts`) hanno RIPLICATO un
7
+ // criterio di poche righe. Qui si risolve invece di duplicare, perché la
8
+ // superficie da replicare sarebbe troppo grande — la grammatica del marker
9
+ // inbox da un lato, le tre euristiche tarate dello scanner di wrap dall'altro —
10
+ // e una divergenza fra le due copie non si vedrebbe finché non conta i file
11
+ // sbagliati.
12
+ //
13
+ // L'allow `Read(~/.claude/plugins/cache/…/**)` che `init` scrive in
14
+ // `.claude/settings.json` non c'entra: è un permesso per gli agent di Claude
15
+ // Code. Il deck non passa dal motore dei permessi e non ha niente da farsi
16
+ // autorizzare — gli manca soltanto di sapere quale versione eseguire.
17
+ import { readdirSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ const MARKETPLACE_OWNER = 'lamemind';
21
+ const PLUGIN_NAME = 'loom-works';
22
+ /** Radice delle cache version-pinned del plugin. */
23
+ export function pluginCacheRoot() {
24
+ const env = process.env.LOOM_DECK_PLUGIN_CACHE_ROOT;
25
+ if (env)
26
+ return env;
27
+ return join(homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_OWNER, PLUGIN_NAME);
28
+ }
29
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
30
+ /**
31
+ * Confronto semver NUMERICO per componente: `-1` se `a` precede `b`, `1` se lo
32
+ * segue, `0` se pari.
33
+ *
34
+ * È il punto di tutto il modulo. In ordine lessicografico `10.1.3` precede
35
+ * `7.9.1` — `'1' < '7'` al primo carattere — quindi un `sort()` ingenuo su
36
+ * questa cartella sceglierebbe `9.0.0` fra le quaranta presenti, cioè una cache
37
+ * di due major fa, e lo farebbe senza dare alcun segno di sbagliare: gli script
38
+ * ci sono, girano, e rispondono col contratto di allora.
39
+ */
40
+ export function compareSemver(a, b) {
41
+ const pa = SEMVER_RE.exec(a);
42
+ const pb = SEMVER_RE.exec(b);
43
+ if (!pa || !pb)
44
+ return 0;
45
+ for (let i = 1; i <= 3; i++) {
46
+ const diff = Number(pa[i]) - Number(pb[i]);
47
+ if (diff !== 0)
48
+ return diff < 0 ? -1 : 1;
49
+ }
50
+ return 0;
51
+ }
52
+ /** La più alta fra le versioni passate; `null` se nessuna è un semver X.Y.Z. */
53
+ export function pickLatestVersion(names) {
54
+ let best = null;
55
+ for (const name of names) {
56
+ if (!SEMVER_RE.test(name))
57
+ continue;
58
+ if (best === null || compareSemver(name, best) > 0)
59
+ best = name;
60
+ }
61
+ return best;
62
+ }
63
+ /**
64
+ * Cache installata più recente, o `null` se il plugin non è installato su questa
65
+ * macchina (radice assente, o presente ma senza una sola cartella semver).
66
+ *
67
+ * `null` non è un errore da riportare: un deck su una macchina senza il plugin
68
+ * deve restare muto sugli indicatori che ne dipendono, non rompersi.
69
+ */
70
+ export function resolvePluginCache() {
71
+ const root = pluginCacheRoot();
72
+ let names;
73
+ try {
74
+ names = readdirSync(root, { withFileTypes: true })
75
+ .filter((e) => e.isDirectory())
76
+ .map((e) => e.name);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ const version = pickLatestVersion(names);
82
+ if (version === null)
83
+ return null;
84
+ return { root: join(root, version), version };
85
+ }
86
+ /**
87
+ * La versione si risolve UNA VOLTA e resta quella per tutta la vita del
88
+ * processo. La cache cambia solo a un `plugin update`, quindi un deck aperto
89
+ * durante l'update continua a leggere la versione di prima fino al riavvio.
90
+ * È il limite accettato, e sta scritto qui invece di essere scoperto dopo.
91
+ */
92
+ let resolved;
93
+ export function pluginCache() {
94
+ if (resolved === undefined)
95
+ resolved = resolvePluginCache();
96
+ return resolved;
97
+ }
98
+ /** Path assoluto di uno script del plugin, `null` se il plugin non c'è. */
99
+ export function pluginScript(relative) {
100
+ const cache = pluginCache();
101
+ return cache ? join(cache.root, relative) : null;
102
+ }
103
+ /** Solo per i test: dimentica la versione risolta al primo accesso. */
104
+ export function resetPluginCache() {
105
+ resolved = undefined;
106
+ }
@@ -0,0 +1,235 @@
1
+ // Le operazioni su una TASK: crearla, editarla, potarla.
2
+ //
3
+ // T131 — nate insieme agli spawn dentro `cli.tsx` e separate da quelli qui,
4
+ // sulla linea di frattura che l'analisi dello split aveva indicato: ciò che
5
+ // apre una conversazione da una parte, ciò che ordina un lavoro sulla task list
6
+ // dall'altra. Il discriminante operativo è l'OGGETTO — una sessione contro una
7
+ // riga di `tasks.md` — e si vede dagli effetti: qui si scrive il task file, si
8
+ // committa, si delega a una skill headless.
9
+ //
10
+ // Il deck non rimuove e non riscrive mai la task list da sé: `createTask` e
11
+ // `purgeTasks` ordinano a `loom-works:create-task` e `loom-works:clean-tasks`,
12
+ // che quelle sequenze le implementano già una volta. Averne due significherebbe
13
+ // due creazioni e due rimozioni capaci di divergere.
14
+ //
15
+ // Le stringhe di `setNote` di questo file sono ASSERITE dal gate
16
+ // `test/modes-smoke.test.ts` (`eliminare N task?`, `nessun push`, `scartate`,
17
+ // `tasks.md`): si copiano verbatim, non si migliorano di passaggio.
18
+ import { randomUUID } from 'node:crypto';
19
+ import { loadTasks } from './tasks.js';
20
+ import { appendTaskBinding } from './task-index.js';
21
+ import { cut, sanitize } from './width.js';
22
+ import { idList } from './ui/modals.js';
23
+ import { purgeTargets, splitTargets } from './purge.js';
24
+ import { initialDetail, writeTaskEdit, PRI_GLYPH, PRI_LABEL } from './task-edit.js';
25
+ import { priName, progName } from './view.js';
26
+ import { commitTaskEdit, spawnCleanTasks, spawnCreateTask, CLAUDE_CMD, } from './spawn.js';
27
+ export function useTaskOps({ cwd, tasksPath, tasksDir, model, setNote, selectedTaskOr, }) {
28
+ // T30 — il taskId nasce DOPO create-task (lo assegna la skill scrivendo
29
+ // tasks.md) → non è noto allo spawn. Il sessionId invece è pinnato qui:
30
+ // snapshot degli id PRIMA, poi al completamento re-leggo tasks.md e il diff dà
31
+ // il nuovo id → appendTaskBinding lega la sessione (scoped).
32
+ function createTask(text) {
33
+ if (!text) {
34
+ setNote('C → create annullato (vuoto)');
35
+ return;
36
+ }
37
+ const sid = randomUUID();
38
+ const beforeIds = new Set(model.tasks.map((t) => t.id));
39
+ setNote(`⏳ creando task… "${cut(text, 40)}" (sid ${sid.slice(0, 8)})`);
40
+ const child = spawnCreateTask(text, cwd, sid, (ok) => {
41
+ if (!ok) {
42
+ setNote(`⚠ create-task fallito (${CLAUDE_CMD} -p)`);
43
+ return;
44
+ }
45
+ let newId;
46
+ try {
47
+ newId = loadTasks(tasksPath).find((t) => !beforeIds.has(t.id))?.id;
48
+ }
49
+ catch {
50
+ // tasks.md illeggibile → id non rilevato, sotto
51
+ }
52
+ if (newId) {
53
+ appendTaskBinding(cwd, sid, newId);
54
+ setNote(`✔ ${newId} creata · sessione scoped (sid ${sid.slice(0, 8)})`);
55
+ }
56
+ else {
57
+ setNote(`✔ task creata (id non rilevato) · sid ${sid.slice(0, 8)}`);
58
+ }
59
+ });
60
+ child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
61
+ }
62
+ // T41 — scrive tasks.md + task file, poi committa. Il commit è immediato e non
63
+ // confermato (scelta esplicita: l'edit è una micro-modifica, la storia
64
+ // granulare vale più di un batch). Se nessuno dei due lati è stato scritto non
65
+ // si committa nulla — `paths` vuoto renderebbe `git commit --` un commit di
66
+ // TUTTO il working tree, che è l'opposto di ciò che vogliamo.
67
+ function writeEdit(task, draft) {
68
+ // Il titolo si scrive solo se è CAMBIATO davvero: rimandarlo identico
69
+ // riscriverebbe comunque la cella (collassando spazi ed escape) e sporcherebbe
70
+ // il diff di una riga per un edit di sola priorità. Vuoto → scartato: una
71
+ // task senza descrizione in overview non è più riconoscibile.
72
+ const title = draft.title.trim();
73
+ const titleChanged = title.length > 0 && title !== task.rawDesc.trim();
74
+ let res;
75
+ try {
76
+ res = writeTaskEdit({
77
+ tasksPath,
78
+ tasksDir,
79
+ id: task.id,
80
+ pri: draft.pri,
81
+ prog: draft.prog,
82
+ detail: draft.detail,
83
+ title: titleChanged ? title : undefined,
84
+ });
85
+ }
86
+ catch (e) {
87
+ setNote(`⚠ ${task.id}: scrittura fallita (${e.message})`);
88
+ return;
89
+ }
90
+ if (res.paths.length === 0) {
91
+ setNote(`⚠ ${task.id}: nessun campo aggiornabile (riga o task file assenti)`);
92
+ return;
93
+ }
94
+ const summary = `${PRI_GLYPH[draft.pri]} ${PRI_LABEL[draft.pri]} · ${res.progress}${titleChanged ? ` · "${cut(sanitize(title), 32)}"` : ''}`;
95
+ setNote(`⏳ ${task.id} → ${summary} · commit…`);
96
+ commitTaskEdit(cwd, res.paths, `chore(${task.id}): pri ${PRI_LABEL[draft.pri]} · stato ${res.progress}${titleChanged ? ' · titolo' : ''}`, (ok, err) => {
97
+ setNote(ok
98
+ ? `✔ ${task.id} → ${summary} · committato`
99
+ : `⚠ ${task.id} salvato, commit fallito: ${err}`);
100
+ });
101
+ }
102
+ /**
103
+ * La bozza di conferma per `CANC`, o `null` se non c'è niente da confermare
104
+ * (in tal caso la nota dice già perché).
105
+ *
106
+ * Il tasto è uno e il bersaglio ha due taglie: la task selezionata, o
107
+ * l'insieme intero della vista `archiviabili`. A discriminare è `purgeBulk` —
108
+ * la selezione prima della vista.
109
+ *
110
+ * Il bersaglio del bulk si legge da `paneTasks`, cioè dalla stessa fonte che
111
+ * disegna le righe e alimenta il contatore in header (D6): mai il `Set` grezzo
112
+ * di `archivable.ts`, mai un secondo filtro sullo stato. T100 ha fissato che
113
+ * ciò che si conta e ciò che si mostra siano lo stesso insieme per
114
+ * costruzione; qui l'invariante si estende a ciò che si pota.
115
+ */
116
+ function purgeDraftFor() {
117
+ // La guardia di focus sta QUI e non solo dentro `selectedTaskOr`: il ramo
118
+ // bulk non passa da quella, perché il suo oggetto è la vista e non la
119
+ // selezione. Senza, `CANC` col focus sulle sessioni potrebbe potare in
120
+ // blocco senza che nessuna task fosse selezionata.
121
+ if (model.focus !== 'tasks') {
122
+ setNote('CANC → eliminare: seleziona una task (← per il pane)');
123
+ return null;
124
+ }
125
+ const bulk = model.purgeBulk;
126
+ const ids = bulk ? model.paneTasks.map((t) => t.id) : [];
127
+ if (!bulk) {
128
+ const task = selectedTaskOr('CANC', 'eliminare');
129
+ if (!task)
130
+ return null;
131
+ ids.push(task.id);
132
+ }
133
+ if (ids.length === 0) {
134
+ setNote('CANC → nessuna task in vista da eliminare');
135
+ return null;
136
+ }
137
+ // Ricalcolo al momento dell'AZIONE, non lettura del campionamento in lista:
138
+ // una folder sporcata dopo l'ultimo scan si presenterebbe come eliminabile.
139
+ const { clean, dirty } = splitTargets(purgeTargets(ids, tasksDir, cwd));
140
+ if (bulk) {
141
+ // Il gate del plugin esce 2 PRIMA di toccare qualsiasi cosa, quindi una
142
+ // sola folder sporca annullerebbe il purge di tutte le altre: si scartano
143
+ // a monte e il modale nomina sia le potate sia le scartate.
144
+ if (clean.length === 0) {
145
+ setNote(`CANC → ${dirty.length} task con file non tracciati in folder: eliminale una per una`);
146
+ return null;
147
+ }
148
+ return {
149
+ ids: clean.map((t) => t.id),
150
+ skipped: dirty.map((t) => t.id),
151
+ bulk: true,
152
+ ignored: null,
153
+ survivors: 0,
154
+ };
155
+ }
156
+ // Singola con superstiti → conferma a TRE uscite (D3): la scelta keep/purge
157
+ // è rara e distruttiva in modo diverso dal purge normale, e qui è visibile
158
+ // invece di stare davanti a ogni potatura.
159
+ const one = clean[0] ?? dirty[0];
160
+ return {
161
+ ids: [one.id],
162
+ skipped: [],
163
+ bulk: false,
164
+ ignored: one.survivors > 0 ? 'keep' : null,
165
+ survivors: one.survivors,
166
+ };
167
+ }
168
+ // ⏎ nella conferma: ordina la potatura a `loom-works:clean-tasks` e la
169
+ // osserva. Il deck non rimuove niente da sé — nessun `git rm`, nessuna
170
+ // riscrittura di tasks.md, nessuna `unlink`: quella sequenza è già
171
+ // implementata una volta, e averne due significherebbe due rimozioni capaci
172
+ // di divergere.
173
+ //
174
+ // La lista si riallinea al primo tick del poll e il set delle archiviabili
175
+ // allo scan che `doneSig` fa scattare quando la popolazione Done cambia; la
176
+ // selezione, keyed su id, cade sulla prima riga della vista (effect di
177
+ // validità) invece che su una posizione residua.
178
+ function purgeTasks(draft) {
179
+ const sid = randomUUID();
180
+ setNote(`⏳ eliminando ${draft.ids.length} task… ${idList(draft.ids, 6)}`);
181
+ const child = spawnCleanTasks(draft.ids, cwd, sid, draft.ignored, (ok, detail) => {
182
+ // L'esito si misura su `tasks.md`, NON sul solo `is_error`. Misurato: col
183
+ // gate `--ignored-files` che blocca, la skill spiega il blocco e chiude
184
+ // comunque `is_error: false` — un successo dichiarato su zero rimozioni.
185
+ // Il segnale robusto è quali degli ID bersaglio non hanno più una riga:
186
+ // deterministico, e indipendente da come la skill racconta sé stessa.
187
+ let survived = draft.ids;
188
+ try {
189
+ const now = new Set(loadTasks(tasksPath).map((t) => t.id));
190
+ survived = draft.ids.filter((id) => now.has(id));
191
+ }
192
+ catch {
193
+ // tasks.md illeggibile → nessuna verifica possibile, e allora si dice
194
+ // che non è stata fatta invece di dedurre un esito.
195
+ setNote(`⚠ ${draft.ids.length} task: esito non verificabile (tasks.md illeggibile)`);
196
+ return;
197
+ }
198
+ const removed = draft.ids.length - survived.length;
199
+ if (removed === draft.ids.length) {
200
+ // Il push non c'è, per scelta della skill: finché nessuno pusha, un
201
+ // altro worktree continua a vedere le task potate in tasks.md. Non
202
+ // dirlo lascerebbe leggere l'assenza di push come «fatto».
203
+ setNote(`✔ ${removed} task eliminate · commit locali, nessun push`);
204
+ }
205
+ else {
206
+ // Il gate `--ignored-files` (exit 2) può scattare lo stesso: la
207
+ // dirtiness è un dato campionato e una folder può sporcarsi fra il
208
+ // ricalcolo e l'apply. Il testo del result event è l'unica cosa che dice
209
+ // PERCHÉ, quindi entra nella riga invece di un ⚠ muto.
210
+ setNote(`⚠ ${removed}/${draft.ids.length} eliminate · restano ${idList(survived, 4)} · ${cut(detail || (ok ? '' : `${CLAUDE_CMD} -p`), 56)}`);
211
+ }
212
+ });
213
+ child?.on('error', () => setNote(`⚠ clean-tasks: '${CLAUDE_CMD}' non lanciabile`));
214
+ }
215
+ /** T41 — la bozza dell'edit seminata dai valori ATTUALI della task. */
216
+ function editDraftFor(task) {
217
+ // La priorità arriva dal glifo di tasks.md (già in `selTask`), lo stato dal
218
+ // suo glifo Prog; il progresso arbitrario dal campo `Progress` del task file
219
+ // — ma solo se è davvero custom (vedi `initialDetail`).
220
+ //
221
+ // Il titolo si semina dalla riga di tasks.md e non dall'H1 del task file per
222
+ // due ragioni: è la fonte che esiste SEMPRE (un task file può mancare), ed è
223
+ // il testo che l'utente sta guardando in lista quando preme `E`. Grezzo
224
+ // (`rawDesc`), non sanificato: rimandare a disco la forma sanificata
225
+ // riscriverebbe i glifi anche senza toccare il campo.
226
+ const prog = progName(task.prog) ?? 'todo';
227
+ return {
228
+ pri: priName(task.pri) ?? 'med',
229
+ prog,
230
+ detail: initialDetail(model.detail?.fields['Progress'] ?? '', prog),
231
+ title: task.rawDesc,
232
+ };
233
+ }
234
+ return { createTask, editDraftFor, writeEdit, purgeDraftFor, purgeTasks };
235
+ }
package/dist/ui/panes.js CHANGED
@@ -33,7 +33,7 @@ import { describeSort, PRI_ENTRIES, PROG_ENTRIES, } from '../view.js';
33
33
  */
34
34
  export function TasksHeader({ counts, active, above, below, focused, columns, }) {
35
35
  // T21 — le parti e il loro taglio vengono da `pane-header.ts`, lo stesso
36
- // modulo da cui `cli.tsx` ricava le colonne cliccabili: una fonte sola.
36
+ // modulo da cui `frame.ts` ricava le colonne cliccabili: una fonte sola.
37
37
  const { parts, shown } = taskHeaderParts(counts, active, above, below, columns);
38
38
  return _jsx(HeaderLine, { parts: parts, shown: shown, focused: focused });
39
39
  }