@lamemind/loom-deck 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +135 -2
- package/dist/task-edit.js +160 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -11,7 +11,8 @@ import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from '.
|
|
|
11
11
|
import { discoverProjectSessions } from './sessions.js';
|
|
12
12
|
import { appendTaskBinding, loadTaskBindings } from './task-index.js';
|
|
13
13
|
import { loadLaunch } from './config.js';
|
|
14
|
-
import { applyView, cycleSort, describeSort, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
14
|
+
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
15
|
+
import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
|
|
15
16
|
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
16
17
|
// scripts/deck-run è un sibling della dir del bundle: src/ (dev, tsx) e dist/
|
|
17
18
|
// (build, node) stanno entrambi sotto la package root → risalita di un livello.
|
|
@@ -27,6 +28,13 @@ const SPOT = Symbol('spot');
|
|
|
27
28
|
// Modale sort a grammatica libera: un tasto per chiave, pressioni successive
|
|
28
29
|
// ciclano asc → desc → fuori dalla chain.
|
|
29
30
|
const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
|
|
31
|
+
// T41 — ordine dei valori nel modale edit. Deliberatamente DIVERSO da
|
|
32
|
+
// PRI_ENTRIES/PROG_ENTRIES (che seguono il rango di sort): qui si sceglie un
|
|
33
|
+
// valore, non si ordina, quindi vince l'ordine del CICLO DI VITA — da fare →
|
|
34
|
+
// in corso → chiusa → bloccata. La priorità resta alta→bassa, che è già
|
|
35
|
+
// l'ordine naturale di lettura.
|
|
36
|
+
const EDIT_PRI = ['high', 'med', 'low'];
|
|
37
|
+
const EDIT_PROG = ['todo', 'wip', 'done', 'locked'];
|
|
30
38
|
function isDone(prog) {
|
|
31
39
|
return prog.includes('✔');
|
|
32
40
|
}
|
|
@@ -109,6 +117,24 @@ function spawnCreateTask(text, cwd, sessionId, onResult) {
|
|
|
109
117
|
});
|
|
110
118
|
return child;
|
|
111
119
|
}
|
|
120
|
+
// T41 — Commit dell'edit. `git commit -- <paths>` committa lo stato working-tree
|
|
121
|
+
// SOLO di quei path, ignorando l'index: se l'utente ha altro in stage (o altri
|
|
122
|
+
// file sporchi) non finisce dentro per errore. NON detached: è un'operazione
|
|
123
|
+
// veloce e il suo esito va riportato nella nota. stderr raccolto per dire perché
|
|
124
|
+
// ha fallito (identità git assente, hook che rifiuta, …) invece di un generico ⚠.
|
|
125
|
+
function commitTaskEdit(cwd, paths, message, onResult) {
|
|
126
|
+
const child = spawn('git', ['commit', '-m', message, '--', ...paths], {
|
|
127
|
+
cwd,
|
|
128
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
129
|
+
});
|
|
130
|
+
let err = '';
|
|
131
|
+
child.stderr?.on('data', (c) => {
|
|
132
|
+
err += c.toString();
|
|
133
|
+
});
|
|
134
|
+
child.on('error', () => onResult(false, 'git non lanciabile'));
|
|
135
|
+
child.on('close', (code) => onResult(code === 0, err.trim().split('\n')[0] ?? ''));
|
|
136
|
+
return child;
|
|
137
|
+
}
|
|
112
138
|
// Carica tasks.md e lo ri-legge quando cambia sotto (poll su mtime). Poll
|
|
113
139
|
// (non fs.watch) perché i writer di tasks.md — checkpoint-task/create-task —
|
|
114
140
|
// riscrivono il file (probabile replace atomico), che rompe il watch sull'inode
|
|
@@ -255,6 +281,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
255
281
|
const [view, setView] = useState(() => loadView(cwd));
|
|
256
282
|
const [viewBackup, setViewBackup] = useState(null);
|
|
257
283
|
const [filterCursor, setFilterCursor] = useState({ row: 0, col: 0 });
|
|
284
|
+
// T41 — bozza dell'edit (null fuori dal modale) e riga attiva della griglia.
|
|
285
|
+
const [edit, setEdit] = useState(null);
|
|
286
|
+
const [editRow, setEditRow] = useState(0);
|
|
258
287
|
// Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
|
|
259
288
|
const launch = useMemo(() => loadLaunch(cwd), [cwd]);
|
|
260
289
|
// La vista è una trasformazione DERIVATA, applicata a valle del load: il
|
|
@@ -338,6 +367,53 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
338
367
|
});
|
|
339
368
|
child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
|
|
340
369
|
}
|
|
370
|
+
// T41 — apertura dell'edit: la bozza parte dai valori ATTUALI della task, non
|
|
371
|
+
// da default. La priorità arriva dal glifo di tasks.md (già in `selTask`), lo
|
|
372
|
+
// stato dal suo glifo Prog; il progresso arbitrario dal campo `Progress` del
|
|
373
|
+
// task file — ma solo se è davvero custom (vedi `initialDetail`).
|
|
374
|
+
function openEdit() {
|
|
375
|
+
if (!selTask)
|
|
376
|
+
return;
|
|
377
|
+
const prog = progName(selTask.prog) ?? 'todo';
|
|
378
|
+
setEdit({
|
|
379
|
+
pri: priName(selTask.pri) ?? 'med',
|
|
380
|
+
prog,
|
|
381
|
+
detail: initialDetail(detail?.fields['Progress'] ?? '', prog),
|
|
382
|
+
});
|
|
383
|
+
setEditRow(0);
|
|
384
|
+
setNote('');
|
|
385
|
+
setMode('edit');
|
|
386
|
+
}
|
|
387
|
+
// T41 — ⏎ nell'edit: scrive tasks.md + task file, poi committa. Il commit è
|
|
388
|
+
// immediato e non confermato (scelta esplicita: l'edit è una micro-modifica,
|
|
389
|
+
// la storia granulare vale più di un batch). Se nessuno dei due lati è stato
|
|
390
|
+
// scritto non si committa nulla — `paths` vuoto renderebbe `git commit --`
|
|
391
|
+
// un commit di TUTTO il working tree, che è l'opposto di ciò che vogliamo.
|
|
392
|
+
function submitEdit() {
|
|
393
|
+
const task = selTask;
|
|
394
|
+
const draft = edit;
|
|
395
|
+
setMode('normal');
|
|
396
|
+
setEdit(null);
|
|
397
|
+
if (!task || !draft)
|
|
398
|
+
return;
|
|
399
|
+
let res;
|
|
400
|
+
try {
|
|
401
|
+
res = writeTaskEdit({ tasksPath, tasksDir, id: task.id, ...draft });
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
setNote(`⚠ ${task.id}: scrittura fallita (${e.message})`);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (res.paths.length === 0) {
|
|
408
|
+
setNote(`⚠ ${task.id}: nessun campo aggiornabile (riga o task file assenti)`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const summary = `${PRI_GLYPH[draft.pri]} ${PRI_LABEL[draft.pri]} · ${res.progress}`;
|
|
412
|
+
setNote(`⏳ ${task.id} → ${summary} · commit…`);
|
|
413
|
+
commitTaskEdit(cwd, res.paths, `chore(${task.id}): pri ${PRI_LABEL[draft.pri]} · stato ${res.progress}`, (ok, err) => {
|
|
414
|
+
setNote(ok ? `✔ ${task.id} → ${summary} · committato` : `⚠ ${task.id} salvato, commit fallito: ${err}`);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
341
417
|
// Chiusura di un modale di vista: `restore` rimette la fotografia scattata
|
|
342
418
|
// all'apertura (esc = annulla), altrimenti tiene ciò che si è composto (⏎).
|
|
343
419
|
function closeViewModal(restore) {
|
|
@@ -428,6 +504,48 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
428
504
|
}
|
|
429
505
|
return;
|
|
430
506
|
}
|
|
507
|
+
// T41 — modale edit: griglia a 3 righe. Righe 0/1 = scelta a valore singolo
|
|
508
|
+
// (←→ scorre), riga 2 = testo libero (ogni carattere stampabile entra nel
|
|
509
|
+
// progresso). Come gli altri modali cattura tutto: `esc` annulla senza
|
|
510
|
+
// scrivere né uscire dal deck.
|
|
511
|
+
if (mode === 'edit') {
|
|
512
|
+
if (key.escape) {
|
|
513
|
+
setMode('normal');
|
|
514
|
+
setEdit(null);
|
|
515
|
+
setNote('E → edit annullato');
|
|
516
|
+
}
|
|
517
|
+
else if (key.return) {
|
|
518
|
+
submitEdit();
|
|
519
|
+
}
|
|
520
|
+
else if (key.upArrow) {
|
|
521
|
+
setEditRow((r) => ((r + 2) % 3));
|
|
522
|
+
}
|
|
523
|
+
else if (key.downArrow) {
|
|
524
|
+
setEditRow((r) => ((r + 1) % 3));
|
|
525
|
+
}
|
|
526
|
+
else if (key.leftArrow || key.rightArrow) {
|
|
527
|
+
const d = key.leftArrow ? -1 : 1;
|
|
528
|
+
// Scorrimento CICLICO (wrap) e non clampato: le liste sono di 3-4 voci,
|
|
529
|
+
// arrivare in fondo e ripartire costa meno di invertire direzione.
|
|
530
|
+
if (editRow === 0) {
|
|
531
|
+
setEdit((e) => e ? { ...e, pri: EDIT_PRI[(EDIT_PRI.indexOf(e.pri) + d + EDIT_PRI.length) % EDIT_PRI.length] } : e);
|
|
532
|
+
}
|
|
533
|
+
else if (editRow === 1) {
|
|
534
|
+
setEdit((e) => e
|
|
535
|
+
? { ...e, prog: EDIT_PROG[(EDIT_PROG.indexOf(e.prog) + d + EDIT_PROG.length) % EDIT_PROG.length] }
|
|
536
|
+
: e);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
else if (editRow === 2) {
|
|
540
|
+
if (key.backspace || key.delete) {
|
|
541
|
+
setEdit((e) => (e ? { ...e, detail: e.detail.slice(0, -1) } : e));
|
|
542
|
+
}
|
|
543
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
544
|
+
setEdit((e) => (e ? { ...e, detail: e.detail + input } : e));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
431
549
|
if (key.leftArrow || key.rightArrow || key.tab) {
|
|
432
550
|
setFocus((f) => (f === 'tasks' ? 'sessions' : 'tasks'));
|
|
433
551
|
}
|
|
@@ -469,6 +587,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
469
587
|
setNote('');
|
|
470
588
|
setMode('create');
|
|
471
589
|
}
|
|
590
|
+
else if (input === 'E') {
|
|
591
|
+
// L'edit ha senso solo su una task reale: la riga meta "spot" non ne è una.
|
|
592
|
+
if (isSpot || !selTask)
|
|
593
|
+
setNote('E → nessuna task selezionata');
|
|
594
|
+
else
|
|
595
|
+
openEdit();
|
|
596
|
+
}
|
|
472
597
|
else if (input === 'S') {
|
|
473
598
|
setViewBackup(view);
|
|
474
599
|
setNote('');
|
|
@@ -509,7 +634,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
509
634
|
const parentLabel = isSpot ? 'spot' : selectedTaskId ?? '—';
|
|
510
635
|
const canSpawn = focus === 'tasks' && !isSpot;
|
|
511
636
|
const launchHint = launch.length > 0 ? `1-${Math.min(9, launch.length)} launch · ` : '';
|
|
512
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : '—', " \u00B7 C nuova \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7",
|
|
637
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : '—', " \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 ", launchHint, "q esci \u00B7 focus: ", _jsx(Text, { color: "cyan", children: focus })] })), mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: viewTasks, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, sessions: visibleSessions, total: childSessions.length, hidden: hiddenSessions, selectedId: selSessionId, focused: focus === 'sessions' })] }), note ? _jsx(Text, { color: "green", children: note }) : null] }));
|
|
513
638
|
}
|
|
514
639
|
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
515
640
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
@@ -531,6 +656,14 @@ function FilterModal({ view, cursor }) {
|
|
|
531
656
|
return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ", forceEmojiWidth(e.glyph)] }, e.name));
|
|
532
657
|
})] }, row.label)))] }));
|
|
533
658
|
}
|
|
659
|
+
// T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
|
|
660
|
+
// coprirli: la riga che stai modificando resta visibile sopra la lista).
|
|
661
|
+
// La riga di anteprima mostra il testo ESATTO che finirà nel campo `Progress`
|
|
662
|
+
// del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
|
|
663
|
+
function EditModal({ id, draft, row }) {
|
|
664
|
+
const mark = (r) => (row === r ? '▶ ' : ' ');
|
|
665
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 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: [' ', forceEmojiWidth(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: [' ', forceEmojiWidth(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(Text, { dimColor: true, children: "prog " }), _jsxs(Text, { children: [' ', draft.detail] }), row === 2 ? _jsx(Text, { inverse: true, children: " " }) : null, !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { dimColor: true, children: ["\u21B3 ", progressText(draft.prog, draft.detail)] })] }));
|
|
666
|
+
}
|
|
534
667
|
function TasksPane({ tasks, total, hidden, view, selected, spotCount, childCount, focused, loadError, detail, }) {
|
|
535
668
|
const spotSelected = selected === 0;
|
|
536
669
|
return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, children: ["Tasks (", hidden > 0 ? `${tasks.length}/${total}` : tasks.length, ")", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 ", hidden, " nascoste"] }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort: ", describeSort(view.sort), view.hiddenPri.length + view.hiddenProg.length > 0 ? (_jsxs(Text, { children: [' ', "\u00B7 filtri:", ' ', [
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// T41 — Edit inline di priorità/stato dal deck.
|
|
2
|
+
// Le funzioni di rewrite sono PURE (stringa → stringa): la scrittura su disco è
|
|
3
|
+
// isolata in `writeTaskEdit`, così la grammatica delle sostituzioni è testabile
|
|
4
|
+
// senza toccare il filesystem.
|
|
5
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { findTaskFile } from './tasks.js';
|
|
7
|
+
// Il task file scrive la priorità per NOME (`- **Priority**: Med`), tasks.md la
|
|
8
|
+
// scrive come GLIFO (colonna Pri). Due rappresentazioni dello stesso valore →
|
|
9
|
+
// due mappe, entrambe keyed sul nome canonico di view.ts (unica fonte del rango).
|
|
10
|
+
export const PRI_LABEL = { high: 'High', med: 'Med', low: 'Low' };
|
|
11
|
+
export const PRI_GLYPH = { high: '🔥', med: '⚡', low: '🔹' };
|
|
12
|
+
// `✔️` CON VS16: è la forma usata nelle righe Done già presenti in tasks.md e nei
|
|
13
|
+
// task file. view.ts normalizza via VS16 in lettura, quindi scrivere la forma
|
|
14
|
+
// lunga resta riconosciuto da priName/progName/isDone.
|
|
15
|
+
export const PROG_GLYPH = {
|
|
16
|
+
todo: '🔵',
|
|
17
|
+
wip: '🟡',
|
|
18
|
+
done: '✔️',
|
|
19
|
+
locked: '🔒',
|
|
20
|
+
};
|
|
21
|
+
// Testo di default per stato, usato quando l'utente non digita un progresso
|
|
22
|
+
// arbitrario. Il campo `Progress` del task file è testo libero (`🟡 85%`,
|
|
23
|
+
// `🔵 Todo`, `✔️ Done at 2026-07-20`) → il glifo è il prefisso, il resto è prosa.
|
|
24
|
+
const PROG_DEFAULT = {
|
|
25
|
+
todo: 'Todo',
|
|
26
|
+
wip: 'In Progress',
|
|
27
|
+
done: 'Done',
|
|
28
|
+
locked: 'Locked',
|
|
29
|
+
};
|
|
30
|
+
/** Data locale `YYYY-MM-DD` (non UTC: `toISOString` sposterebbe il giorno la sera). */
|
|
31
|
+
export function today(d = new Date()) {
|
|
32
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
33
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Riga `Progress` del task file. `detail` è il progresso ARBITRARIO digitato
|
|
37
|
+
* dall'utente: se c'è vince sempre (anche su done/locked — nessuna forma è
|
|
38
|
+
* preclusa), se manca si cade sul default dello stato. Solo `done` senza detail
|
|
39
|
+
* data-stampa, coerente con le righe già presenti (`✔️ Done at YYYY-MM-DD`).
|
|
40
|
+
*/
|
|
41
|
+
export function progressText(prog, detail, date = today()) {
|
|
42
|
+
const glyph = PROG_GLYPH[prog];
|
|
43
|
+
const d = detail.trim();
|
|
44
|
+
if (d)
|
|
45
|
+
return `${glyph} ${d}`;
|
|
46
|
+
if (prog === 'done')
|
|
47
|
+
return `${glyph} Done at ${date}`;
|
|
48
|
+
return `${glyph} ${PROG_DEFAULT[prog]}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Toglie il glifo di testa da un valore `Progress` (`🟡 85%` → `85%`).
|
|
52
|
+
* Il glifo lo ri-deriva `progressText` dallo stato selezionato.
|
|
53
|
+
*/
|
|
54
|
+
export function stripProgGlyph(s) {
|
|
55
|
+
return s.replace(/^[\p{Extended_Pictographic}✔️\s]+/u, '').trim();
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Valore iniziale del campo "progresso arbitrario" all'apertura del modale.
|
|
59
|
+
*
|
|
60
|
+
* NON è semplicemente il testo corrente spogliato del glifo: se quel testo è
|
|
61
|
+
* esattamente ciò che il default già produrrebbe (`🔵 Todo`, `🟡 In Progress`)
|
|
62
|
+
* non c'è nulla di custom da preservare, e pre-riempirlo lo renderebbe
|
|
63
|
+
* APPICCICOSO — cambiando stato la scritta vecchia resterebbe attaccata al
|
|
64
|
+
* glifo nuovo (`🔵 Todo` → wip → `🟡 Todo`). Si parte quindi da vuoto e il
|
|
65
|
+
* default segue lo stato scelto.
|
|
66
|
+
*
|
|
67
|
+
* Un testo che il default NON sa riprodurre è invece informazione dell'utente e
|
|
68
|
+
* va tenuta: `🟡 85%` → `85%`, e `✔️ Done at 2026-07-14` → `Done at 2026-07-14`
|
|
69
|
+
* (la data storica sopravvive, non viene ri-stampata a oggi).
|
|
70
|
+
*/
|
|
71
|
+
export function initialDetail(current, prog, date = today()) {
|
|
72
|
+
const s = current.trim();
|
|
73
|
+
if (!s || s === progressText(prog, '', date))
|
|
74
|
+
return '';
|
|
75
|
+
return stripProgGlyph(s);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Riscrive le celle Pri (col 2) e Prog (col 4) della riga `| Tnn | … |` in
|
|
79
|
+
* tasks.md. Solo la PRIMA riga con quell'id — l'overview è unica, un secondo
|
|
80
|
+
* match sarebbe un duplicato da non propagare. Le altre celle (K, descrizione)
|
|
81
|
+
* restano i token grezzi originali: nessun re-flow della tabella, il diff resta
|
|
82
|
+
* di una riga. `ok:false` = id assente → il chiamante non scrive nulla.
|
|
83
|
+
*
|
|
84
|
+
* L'id deve rispettare `^T\d+$`, stesso gate di `parseTasks`: senza, un id
|
|
85
|
+
* arbitrario matcherebbe la riga di HEADER (`| ID | Pri | K | Prog |`) o quella
|
|
86
|
+
* di separatore, riscrivendone le celle e sfondando la tabella.
|
|
87
|
+
*/
|
|
88
|
+
const TASK_ID_RE = /^T\d+$/;
|
|
89
|
+
export function updateTasksMdRow(content, id, priGlyph, progGlyph) {
|
|
90
|
+
if (!TASK_ID_RE.test(id))
|
|
91
|
+
return { content, ok: false };
|
|
92
|
+
let ok = false;
|
|
93
|
+
const lines = content.split('\n').map((line) => {
|
|
94
|
+
if (ok)
|
|
95
|
+
return line;
|
|
96
|
+
if (!line.trim().startsWith('|'))
|
|
97
|
+
return line;
|
|
98
|
+
const cells = line.split('|');
|
|
99
|
+
if ((cells[1] ?? '').trim() !== id)
|
|
100
|
+
return line;
|
|
101
|
+
if (cells.length < 6)
|
|
102
|
+
return line; // riga malformata → non toccarla
|
|
103
|
+
ok = true;
|
|
104
|
+
cells[2] = ` ${priGlyph} `;
|
|
105
|
+
cells[4] = ` ${progGlyph} `;
|
|
106
|
+
return cells.join('|');
|
|
107
|
+
});
|
|
108
|
+
return { content: ok ? lines.join('\n') : content, ok };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Riscrive i bullet header `- **Priority**:` e `- **Progress**:` del task file.
|
|
112
|
+
* First-match-wins per chiave, stessa regola di `parseTaskDetail`: se un campo
|
|
113
|
+
* ricompare nel body (residuo template) vince quello dell'header — così ciò che
|
|
114
|
+
* il deck mostra e ciò che scrive restano la stessa riga.
|
|
115
|
+
*/
|
|
116
|
+
export function updateTaskFileFields(content, priLabel, progress) {
|
|
117
|
+
let priDone = false;
|
|
118
|
+
let progDone = false;
|
|
119
|
+
const lines = content.split('\n').map((line) => {
|
|
120
|
+
if (!priDone && /^-\s*\*\*Priority\*\*:/.test(line)) {
|
|
121
|
+
priDone = true;
|
|
122
|
+
return `- **Priority**: ${priLabel}`;
|
|
123
|
+
}
|
|
124
|
+
if (!progDone && /^-\s*\*\*Progress\*\*:/.test(line)) {
|
|
125
|
+
progDone = true;
|
|
126
|
+
return `- **Progress**: ${progress}`;
|
|
127
|
+
}
|
|
128
|
+
return line;
|
|
129
|
+
});
|
|
130
|
+
const ok = priDone || progDone;
|
|
131
|
+
return { content: ok ? lines.join('\n') : content, ok };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Scrive i DUE lati della task: la riga di tasks.md (vista d'insieme) e i campi
|
|
135
|
+
* del task file (dettaglio). Sono due fonti che devono restare allineate — il
|
|
136
|
+
* deck legge la prima e mostra la seconda — quindi si toccano insieme.
|
|
137
|
+
* Un lato mancante (id fuori overview, o nessun task file) non blocca l'altro:
|
|
138
|
+
* si scrive ciò che esiste e il risultato dice cosa è stato toccato.
|
|
139
|
+
*/
|
|
140
|
+
export function writeTaskEdit(input) {
|
|
141
|
+
const { tasksPath, tasksDir, id, pri, prog, detail } = input;
|
|
142
|
+
const progress = progressText(prog, detail);
|
|
143
|
+
const paths = [];
|
|
144
|
+
const row = updateTasksMdRow(readFileSync(tasksPath, 'utf8'), id, PRI_GLYPH[pri], PROG_GLYPH[prog]);
|
|
145
|
+
if (row.ok) {
|
|
146
|
+
writeFileSync(tasksPath, row.content, 'utf8');
|
|
147
|
+
paths.push(tasksPath);
|
|
148
|
+
}
|
|
149
|
+
let fileUpdated = false;
|
|
150
|
+
const taskFile = findTaskFile(tasksDir, id);
|
|
151
|
+
if (taskFile) {
|
|
152
|
+
const upd = updateTaskFileFields(readFileSync(taskFile, 'utf8'), PRI_LABEL[pri], progress);
|
|
153
|
+
if (upd.ok) {
|
|
154
|
+
writeFileSync(taskFile, upd.content, 'utf8');
|
|
155
|
+
paths.push(taskFile);
|
|
156
|
+
fileUpdated = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { paths, rowUpdated: row.ok, fileUpdated, progress };
|
|
160
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lamemind/loom-deck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"loom-deck": "
|
|
7
|
+
"loom-deck": "dist/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|