@lamemind/loom-deck 0.5.1 → 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/README.md +67 -0
- package/dist/cli.js +326 -55
- package/dist/config.js +41 -0
- package/dist/task-edit.js +160 -0
- package/dist/view-store.js +66 -0
- package/dist/view.js +133 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -39,6 +39,68 @@ Bootstrap + spike ① + **TUI ③** funzionante (legge `tasks.md`, `⏎` spawna)
|
|
|
39
39
|
⑤ azioni extra (start/preflight/checkpoint/merge dal deck)
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
## Standard shortcut
|
|
43
|
+
|
|
44
|
+
Regola unica, senza eccezioni — pensata per reggere l'aggiunta di nuove azioni
|
|
45
|
+
senza collisioni:
|
|
46
|
+
|
|
47
|
+
| Tasto | Semantica | Note |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `↑` `↓` | naviga nella lista | |
|
|
50
|
+
| `←` `→` `tab` | cambia pane | |
|
|
51
|
+
| `⏎` | azione primaria del pane | Tasks → spawna la task selezionata |
|
|
52
|
+
| **MAIUSCOLA** | **apre un modale** | cattura tutti i tasti; `esc` annulla, non esce |
|
|
53
|
+
| minuscola | azione immediata, one-shot | |
|
|
54
|
+
| `1`…`9` | voce `launch` n-esima del progetto | da `.claude/loom-works.json` |
|
|
55
|
+
| `q` `esc` | esce dal deck | in un modale `esc` annulla soltanto |
|
|
56
|
+
|
|
57
|
+
Assegnazioni correnti:
|
|
58
|
+
|
|
59
|
+
| | Tasto | Cosa fa |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| modale | `C` | nuova task (create-task inline) |
|
|
62
|
+
| modale | `S` | sort chain |
|
|
63
|
+
| modale | `F` | filtri |
|
|
64
|
+
| immediata | `w` | salva la vista corrente su disco |
|
|
65
|
+
| launch | `1`…`9` | esegue il `command` della voce, con `cwd` = project root |
|
|
66
|
+
|
|
67
|
+
Le minuscole sono deliberatamente quasi tutte libere: le consumeranno le azioni
|
|
68
|
+
in arrivo (start/preflight/checkpoint, fork/resume, terminale @project-root).
|
|
69
|
+
|
|
70
|
+
> **Nota di migrazione (0.6.0)**: `c` → **`C`** per creare una task, e le voci
|
|
71
|
+
> `codium`/`idea` non hanno più una lettera dedicata (erano `C`/`I` hardcoded):
|
|
72
|
+
> ora sono voci `launch` del file config, raggiunte per indice `1`…`9`.
|
|
73
|
+
|
|
74
|
+
## Vista: filtri e ordinamenti
|
|
75
|
+
|
|
76
|
+
La lista è una **vista** sulla `tasks.md`: si filtra e si ordina senza toccare il file.
|
|
77
|
+
|
|
78
|
+
**`S` — sort chain.** Grammatica libera: la *sequenza* di tasti **è** la catena di
|
|
79
|
+
ordinamento. Ogni tasto cicla `asc → desc → fuori dalla catena`; una chiave
|
|
80
|
+
rimossa e ripremuta si riaccoda in fondo.
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
p priorità s stato i id
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Partendo da catena vuota, digitare `ppi` produce `[pri ↓, id ↑]`. Il ciclo parte
|
|
87
|
+
sempre **dallo stato corrente**, che il modale mostra dal vivo mentre digiti.
|
|
88
|
+
A parità su tutte le chiavi decide sempre l'`id` (confronto **numerico**: `T9`
|
|
89
|
+
prima di `T10`) → l'ordine è deterministico, mai instabile fra un refresh e l'altro.
|
|
90
|
+
|
|
91
|
+
**`F` — filtri.** Un toggle per ogni priorità e per ogni stato, componibili in AND.
|
|
92
|
+
`↑↓` cambia riga, `←→` scorre i valori, `spazio` mostra/nasconde.
|
|
93
|
+
|
|
94
|
+
In entrambi i modali la lista si aggiorna **dal vivo**; `⏎` conferma, `esc`
|
|
95
|
+
ripristina la vista com'era all'apertura.
|
|
96
|
+
|
|
97
|
+
Con un filtro attivo l'header dichiara sempre quanto sta nascondendo
|
|
98
|
+
(`Tasks (9/25) · 16 nascoste`): il deck non finge mai una lista completa.
|
|
99
|
+
|
|
100
|
+
**Persistenza.** La vista non si salva da sola — sperimentare non sporca nulla.
|
|
101
|
+
`w` la scrive in `.claude/loom/deck-view.json` (macchina-locale, da gitignorare)
|
|
102
|
+
e al riavvio viene ripristinata. File assente o corrotto → default puliti.
|
|
103
|
+
|
|
42
104
|
## Installazione
|
|
43
105
|
|
|
44
106
|
```bash
|
|
@@ -72,8 +134,13 @@ Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude '/loom-works:r
|
|
|
72
134
|
npm install
|
|
73
135
|
npm run dev # tsx src/cli.tsx — lista tasks.md reale, ↑↓ naviga · ⏎ spawn · q esci
|
|
74
136
|
npm run build # tsc → dist/
|
|
137
|
+
npm test # node:test sul core vista (src/view.ts), senza Ink né terminale
|
|
75
138
|
```
|
|
76
139
|
|
|
140
|
+
Il core di filtri e ordinamenti (`src/view.ts`) è **puro**: nessun import da Ink o
|
|
141
|
+
React, nessun I/O. È il motivo per cui è testabile con `node:test` su array
|
|
142
|
+
fixture, mentre la TUI resta un guscio sottile che lo consuma.
|
|
143
|
+
|
|
77
144
|
Il deck cerca `tasks.md` in `$PWD/${LOOM_DECK_DOCS_ROOT:-docs}/tasks.md`. Progetti
|
|
78
145
|
con docs-root non-standard esportano la variabile, es. `LOOM_DECK_DOCS_ROOT=runtime`.
|
|
79
146
|
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { render, Box, Text, useApp, useInput } from 'ink';
|
|
4
|
-
import { useState, useEffect } from 'react';
|
|
4
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { statSync } from 'node:fs';
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
@@ -10,6 +10,10 @@ import { dirname, join } from 'node:path';
|
|
|
10
10
|
import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from './tasks.js';
|
|
11
11
|
import { discoverProjectSessions } from './sessions.js';
|
|
12
12
|
import { appendTaskBinding, loadTaskBindings } from './task-index.js';
|
|
13
|
+
import { loadLaunch } from './config.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';
|
|
16
|
+
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
13
17
|
// scripts/deck-run è un sibling della dir del bundle: src/ (dev, tsx) e dist/
|
|
14
18
|
// (build, node) stanno entrambi sotto la package root → risalita di un livello.
|
|
15
19
|
const DECK_RUN = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'deck-run');
|
|
@@ -21,6 +25,16 @@ const MAX_SESSIONS = 30;
|
|
|
21
25
|
// meta "spot" (sentinella) che raccoglie le sessioni NON legate ad alcuna task.
|
|
22
26
|
// La selezione nel Tasks pane è il "padre"; il Sessions pane mostra i suoi figli.
|
|
23
27
|
const SPOT = Symbol('spot');
|
|
28
|
+
// Modale sort a grammatica libera: un tasto per chiave, pressioni successive
|
|
29
|
+
// ciclano asc → desc → fuori dalla chain.
|
|
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'];
|
|
24
38
|
function isDone(prog) {
|
|
25
39
|
return prog.includes('✔');
|
|
26
40
|
}
|
|
@@ -36,18 +50,16 @@ function spawnDeck(id, cwd, sessionId) {
|
|
|
36
50
|
child.unref();
|
|
37
51
|
return child;
|
|
38
52
|
}
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
function openEditor(cmd, cwd) {
|
|
50
|
-
const child = spawn('bash', ['-lic', `${cmd} "$PWD"`], {
|
|
53
|
+
// T39/T32: voce `launch` custom del file config, eseguita con cwd = project root.
|
|
54
|
+
// Spawn detached come spawnDeck: il deck lancia ma non possiede il processo.
|
|
55
|
+
// Shell login+interattiva (bash -lic) perché i comandi tipici sono alias o
|
|
56
|
+
// funzioni di ~/.bashrc (`codium`=alias flatpak, `idea`=funzione) — con `bash -c`
|
|
57
|
+
// non risolverebbero. Il comando NON è input utente: viene dal file committato
|
|
58
|
+
// `.claude/loom-works.json`, fidato quanto un custom-command Ptyxis (contratto
|
|
59
|
+
// esplicito in project-config-architecture.md). La project root arriva via cwd,
|
|
60
|
+
// non interpolata nella stringa.
|
|
61
|
+
function runLaunch(entry, cwd) {
|
|
62
|
+
const child = spawn('bash', ['-lic', entry.command], {
|
|
51
63
|
cwd,
|
|
52
64
|
detached: true,
|
|
53
65
|
stdio: 'ignore',
|
|
@@ -105,6 +117,24 @@ function spawnCreateTask(text, cwd, sessionId, onResult) {
|
|
|
105
117
|
});
|
|
106
118
|
return child;
|
|
107
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
|
+
}
|
|
108
138
|
// Carica tasks.md e lo ri-legge quando cambia sotto (poll su mtime). Poll
|
|
109
139
|
// (non fs.watch) perché i writer di tasks.md — checkpoint-task/create-task —
|
|
110
140
|
// riscrivono il file (probabile replace atomico), che rompe il watch sull'inode
|
|
@@ -235,18 +265,37 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
235
265
|
const { tasks, loadError } = useTasks(tasksPath);
|
|
236
266
|
const { sessions, bindings } = useSessions(cwd);
|
|
237
267
|
const [focus, setFocus] = useState('tasks');
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
268
|
+
// T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
|
|
269
|
+
// (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
|
|
270
|
+
// grezzo per posizione spawnerebbe la task sbagliata, in silenzio. `null` = la
|
|
271
|
+
// riga meta "spot", sempre in testa alla lista.
|
|
272
|
+
const [selId, setSelId] = useState(null);
|
|
241
273
|
const [selSession, setSelSession] = useState(0);
|
|
242
274
|
const [note, setNote] = useState('');
|
|
243
|
-
//
|
|
244
|
-
//
|
|
275
|
+
// Modali: catturano i tasti e corto-circuitano la navigazione normale.
|
|
276
|
+
// T30 create · T39 sort/filter.
|
|
245
277
|
const [mode, setMode] = useState('normal');
|
|
246
278
|
const [draft, setDraft] = useState('');
|
|
247
|
-
|
|
279
|
+
// T39 — vista corrente (filtri + sort) e sua fotografia all'apertura di un
|
|
280
|
+
// modale: la lista si aggiorna dal vivo, quindi `esc` deve poter ripristinare.
|
|
281
|
+
const [view, setView] = useState(() => loadView(cwd));
|
|
282
|
+
const [viewBackup, setViewBackup] = useState(null);
|
|
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);
|
|
287
|
+
// Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
|
|
288
|
+
const launch = useMemo(() => loadLaunch(cwd), [cwd]);
|
|
289
|
+
// La vista è una trasformazione DERIVATA, applicata a valle del load: il
|
|
290
|
+
// polling di tasks.md continua a funzionare senza saperne nulla.
|
|
291
|
+
const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
|
|
292
|
+
const isSpot = selId === null;
|
|
248
293
|
const projectName = cwd.split('/').pop() || cwd;
|
|
249
|
-
|
|
294
|
+
// Unica fonte della selezione: si legge SEMPRE dalla vista, mai dall'array
|
|
295
|
+
// grezzo — è l'invariante che tiene allineati dettaglio mostrato e spawn.
|
|
296
|
+
const selTask = selId === null ? null : viewTasks.find((t) => t.id === selId) ?? null;
|
|
297
|
+
const selectedTaskId = selTask?.id ?? null;
|
|
298
|
+
const selIndex = selTask ? viewTasks.indexOf(selTask) + 1 : 0;
|
|
250
299
|
const detail = useTaskDetail(tasksDir, selectedTaskId ?? undefined);
|
|
251
300
|
// Conteggio figli per task + spot (badge nel Tasks pane).
|
|
252
301
|
const childCount = new Map();
|
|
@@ -266,15 +315,18 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
266
315
|
});
|
|
267
316
|
const visibleSessions = childSessions.slice(0, MAX_SESSIONS);
|
|
268
317
|
const hiddenSessions = childSessions.length - visibleSessions.length;
|
|
269
|
-
//
|
|
270
|
-
// (
|
|
318
|
+
// T39 — selezione stabile sotto trasformazione. Se la task selezionata esce
|
|
319
|
+
// dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
|
|
320
|
+
// sulla prima visibile — fallback deterministico, mai una posizione a caso.
|
|
271
321
|
useEffect(() => {
|
|
272
|
-
|
|
273
|
-
|
|
322
|
+
if (selId !== null && !viewTasks.some((t) => t.id === selId)) {
|
|
323
|
+
setSelId(viewTasks[0]?.id ?? null);
|
|
324
|
+
}
|
|
325
|
+
}, [viewTasks, selId]);
|
|
274
326
|
// Cambio padre → riparti dalla prima sessione figlia.
|
|
275
327
|
useEffect(() => {
|
|
276
328
|
setSelSession(0);
|
|
277
|
-
}, [
|
|
329
|
+
}, [selId]);
|
|
278
330
|
useEffect(() => {
|
|
279
331
|
setSelSession((s) => Math.min(s, Math.max(0, visibleSessions.length - 1)));
|
|
280
332
|
}, [visibleSessions.length]);
|
|
@@ -287,7 +339,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
287
339
|
setMode('normal');
|
|
288
340
|
setDraft('');
|
|
289
341
|
if (!text) {
|
|
290
|
-
setNote('
|
|
342
|
+
setNote('C → create annullato (vuoto)');
|
|
291
343
|
return;
|
|
292
344
|
}
|
|
293
345
|
const sid = randomUUID();
|
|
@@ -315,6 +367,68 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
315
367
|
});
|
|
316
368
|
child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
|
|
317
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
|
+
}
|
|
417
|
+
// Chiusura di un modale di vista: `restore` rimette la fotografia scattata
|
|
418
|
+
// all'apertura (esc = annulla), altrimenti tiene ciò che si è composto (⏎).
|
|
419
|
+
function closeViewModal(restore) {
|
|
420
|
+
if (restore && viewBackup)
|
|
421
|
+
setView(viewBackup);
|
|
422
|
+
setViewBackup(null);
|
|
423
|
+
setMode('normal');
|
|
424
|
+
}
|
|
425
|
+
// Sposta la selezione di `delta` righe nella VISTA (0 = spot, 1..N = task
|
|
426
|
+
// visibili) e la riconverte subito in id: l'indice non sopravvive a un
|
|
427
|
+
// cambio di filtro, l'id sì.
|
|
428
|
+
function moveTaskSel(delta) {
|
|
429
|
+
const next = Math.max(0, Math.min(viewTasks.length, selIndex + delta));
|
|
430
|
+
setSelId(next === 0 ? null : viewTasks[next - 1]?.id ?? null);
|
|
431
|
+
}
|
|
318
432
|
useInput((input, key) => {
|
|
319
433
|
// T30: in modalità create l'handler cattura il testo e corto-circuita la
|
|
320
434
|
// navigazione normale (incl. q/esc → quit: qui esc annulla, non esce).
|
|
@@ -322,7 +436,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
322
436
|
if (key.escape) {
|
|
323
437
|
setMode('normal');
|
|
324
438
|
setDraft('');
|
|
325
|
-
setNote('
|
|
439
|
+
setNote('C → create annullato');
|
|
326
440
|
}
|
|
327
441
|
else if (key.return) {
|
|
328
442
|
submitCreate();
|
|
@@ -335,18 +449,115 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
335
449
|
}
|
|
336
450
|
return;
|
|
337
451
|
}
|
|
452
|
+
// T39 — modale sort a grammatica libera: la SEQUENZA di tasti È la chain.
|
|
453
|
+
// Digitare `ppi` = p(asc) p(desc) i(asc) → [pri desc, id asc]. La lista si
|
|
454
|
+
// riordina dal vivo a ogni pressione.
|
|
455
|
+
if (mode === 'sort') {
|
|
456
|
+
if (key.escape) {
|
|
457
|
+
closeViewModal(true);
|
|
458
|
+
setNote('S → sort annullato');
|
|
459
|
+
}
|
|
460
|
+
else if (key.return) {
|
|
461
|
+
closeViewModal(false);
|
|
462
|
+
setNote(`S → sort: ${describeSort(view.sort)}`);
|
|
463
|
+
}
|
|
464
|
+
else if (input) {
|
|
465
|
+
// useInput consegna il CHUNK letto da stdin, non un tasto: digitando
|
|
466
|
+
// veloce (o incollando) `ppi` arriva come stringa unica. Si cicla su
|
|
467
|
+
// ogni carattere, così la chain esce identica a battitura lenta.
|
|
468
|
+
const keys = [...input].map((ch) => SORT_TASTI[ch]).filter(Boolean);
|
|
469
|
+
if (keys.length > 0) {
|
|
470
|
+
setView((v) => ({ ...v, sort: keys.reduce(cycleSort, v.sort) }));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
// T39 — modale filtri: griglia 2 righe (priorità / stato), un toggle per
|
|
476
|
+
// valore. Anche qui l'effetto è immediato sulla lista.
|
|
477
|
+
if (mode === 'filter') {
|
|
478
|
+
const rowLen = (r) => (r === 0 ? PRI_ENTRIES.length : PROG_ENTRIES.length);
|
|
479
|
+
if (key.escape) {
|
|
480
|
+
closeViewModal(true);
|
|
481
|
+
setNote('F → filtri annullati');
|
|
482
|
+
}
|
|
483
|
+
else if (key.return) {
|
|
484
|
+
closeViewModal(false);
|
|
485
|
+
setNote(hiddenTasks > 0 ? `F → ${hiddenTasks} task nascoste` : 'F → nessun filtro attivo');
|
|
486
|
+
}
|
|
487
|
+
else if (key.upArrow || key.downArrow) {
|
|
488
|
+
setFilterCursor((c) => {
|
|
489
|
+
const row = c.row === 0 ? 1 : 0;
|
|
490
|
+
return { row, col: Math.min(c.col, rowLen(row) - 1) };
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
else if (key.leftArrow) {
|
|
494
|
+
setFilterCursor((c) => ({ ...c, col: Math.max(0, c.col - 1) }));
|
|
495
|
+
}
|
|
496
|
+
else if (key.rightArrow) {
|
|
497
|
+
setFilterCursor((c) => ({ ...c, col: Math.min(rowLen(c.row) - 1, c.col + 1) }));
|
|
498
|
+
}
|
|
499
|
+
else if (input === ' ') {
|
|
500
|
+
const { row, col } = filterCursor;
|
|
501
|
+
setView((v) => row === 0
|
|
502
|
+
? { ...v, hiddenPri: toggleHidden(v.hiddenPri, PRI_ENTRIES[col].name) }
|
|
503
|
+
: { ...v, hiddenProg: toggleHidden(v.hiddenProg, PROG_ENTRIES[col].name) });
|
|
504
|
+
}
|
|
505
|
+
return;
|
|
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
|
+
}
|
|
338
549
|
if (key.leftArrow || key.rightArrow || key.tab) {
|
|
339
550
|
setFocus((f) => (f === 'tasks' ? 'sessions' : 'tasks'));
|
|
340
551
|
}
|
|
341
552
|
else if (key.upArrow) {
|
|
342
553
|
if (focus === 'tasks')
|
|
343
|
-
|
|
554
|
+
moveTaskSel(-1);
|
|
344
555
|
else
|
|
345
556
|
setSelSession((i) => Math.max(0, i - 1));
|
|
346
557
|
}
|
|
347
558
|
else if (key.downArrow) {
|
|
348
559
|
if (focus === 'tasks')
|
|
349
|
-
|
|
560
|
+
moveTaskSel(1);
|
|
350
561
|
else
|
|
351
562
|
setSelSession((i) => Math.min(visibleSessions.length - 1, i + 1));
|
|
352
563
|
}
|
|
@@ -355,39 +566,65 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
355
566
|
if (isSpot) {
|
|
356
567
|
setNote('spot: sessioni libere, nessuna task da spawnare');
|
|
357
568
|
}
|
|
358
|
-
else {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
child.on('error', () => setNote(`⚠ spawn ${task.id} fallito (${DECK_RUN})`));
|
|
370
|
-
setNote(`⏎ spawn ${task.id} → tab CC (sid ${sid.slice(0, 8)})`);
|
|
371
|
-
}
|
|
569
|
+
else if (selTask) {
|
|
570
|
+
// sessionId pinnato lato deck → binding sidecar scritto PRIMA dello
|
|
571
|
+
// spawn: la sessione risulta figlia della task appena il JSONL compare.
|
|
572
|
+
const sid = randomUUID();
|
|
573
|
+
appendTaskBinding(cwd, sid, selTask.id);
|
|
574
|
+
const child = spawnDeck(selTask.id, cwd, sid);
|
|
575
|
+
// Un errore di spawn è async: senza handler diventa uncaughtException
|
|
576
|
+
// e ucciderebbe il deck. Lo intercetto per preservare l'invariante
|
|
577
|
+
// "il deck resta vivo" e mostro la nota d'errore.
|
|
578
|
+
child.on('error', () => setNote(`⚠ spawn ${selTask.id} fallito (${DECK_RUN})`));
|
|
579
|
+
setNote(`⏎ spawn ${selTask.id} → tab CC (sid ${sid.slice(0, 8)})`);
|
|
372
580
|
}
|
|
373
581
|
}
|
|
374
582
|
else {
|
|
375
583
|
setNote('sessioni read-only in T27 · fork/resume → T28');
|
|
376
584
|
}
|
|
377
585
|
}
|
|
378
|
-
else if (input === '
|
|
586
|
+
else if (input === 'C') {
|
|
379
587
|
setNote('');
|
|
380
588
|
setMode('create');
|
|
381
589
|
}
|
|
382
|
-
else if (input === '
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
+
}
|
|
597
|
+
else if (input === 'S') {
|
|
598
|
+
setViewBackup(view);
|
|
599
|
+
setNote('');
|
|
600
|
+
setMode('sort');
|
|
601
|
+
}
|
|
602
|
+
else if (input === 'F') {
|
|
603
|
+
setViewBackup(view);
|
|
604
|
+
setNote('');
|
|
605
|
+
setMode('filter');
|
|
606
|
+
}
|
|
607
|
+
else if (input === 'w') {
|
|
608
|
+
// Salvataggio ESPLICITO: comporre una vista non tocca il disco, così
|
|
609
|
+
// sperimentare non sporca lo stato persistito.
|
|
610
|
+
try {
|
|
611
|
+
saveView(cwd, view);
|
|
612
|
+
setNote(`w → vista salvata (${viewFilePath(cwd)})`);
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
setNote('⚠ salvataggio vista fallito');
|
|
616
|
+
}
|
|
386
617
|
}
|
|
387
|
-
else if (input
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
618
|
+
else if (input && /^[1-9]$/.test(input)) {
|
|
619
|
+
const entry = launch[Number(input) - 1];
|
|
620
|
+
if (!entry) {
|
|
621
|
+
setNote(`${input} → nessuna voce launch (${launch.length} configurate)`);
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
const child = runLaunch(entry, cwd);
|
|
625
|
+
child.on('error', () => setNote(`⚠ ${entry.label}: '${entry.command}' non lanciabile`));
|
|
626
|
+
setNote(`${input} → ${entry.label} su ${projectName}`);
|
|
627
|
+
}
|
|
391
628
|
}
|
|
392
629
|
else if (input === 'q' || key.escape) {
|
|
393
630
|
exit();
|
|
@@ -396,11 +633,45 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
396
633
|
const selSessionId = visibleSessions[selSession]?.sessionId;
|
|
397
634
|
const parentLabel = isSpot ? 'spot' : selectedTaskId ?? '—';
|
|
398
635
|
const canSpawn = focus === 'tasks' && !isSpot;
|
|
399
|
-
|
|
636
|
+
const launchHint = launch.length > 0 ? `1-${Math.min(9, launch.length)} launch · ` : '';
|
|
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] }));
|
|
638
|
+
}
|
|
639
|
+
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
640
|
+
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
641
|
+
// spingono giù i pane invece di coprirli, così la lista che stai filtrando
|
|
642
|
+
// resta sempre visibile mentre la componi.
|
|
643
|
+
function SortModal({ sort }) {
|
|
644
|
+
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
|
|
645
|
+
.map((e, i) => `${i + 1}. ${SORT_UI[e.key]} ${e.dir === 'asc' ? '↑' : '↓'}`)
|
|
646
|
+
.join(' ') }))] }));
|
|
647
|
+
}
|
|
648
|
+
function FilterModal({ view, cursor }) {
|
|
649
|
+
const rows = [
|
|
650
|
+
{ label: 'pri ', entries: PRI_ENTRIES, hidden: new Set(view.hiddenPri) },
|
|
651
|
+
{ label: 'stato', entries: PROG_ENTRIES, hidden: new Set(view.hiddenProg) },
|
|
652
|
+
];
|
|
653
|
+
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) => {
|
|
654
|
+
const on = !row.hidden.has(e.name);
|
|
655
|
+
const here = cursor.row === r && cursor.col === c;
|
|
656
|
+
return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ", forceEmojiWidth(e.glyph)] }, e.name));
|
|
657
|
+
})] }, row.label)))] }));
|
|
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)] })] }));
|
|
400
666
|
}
|
|
401
|
-
function TasksPane({ tasks, selected, spotCount, childCount, focused, loadError, detail, }) {
|
|
667
|
+
function TasksPane({ tasks, total, hidden, view, selected, spotCount, childCount, focused, loadError, detail, }) {
|
|
402
668
|
const spotSelected = selected === 0;
|
|
403
|
-
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 (", tasks.length, ")"
|
|
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:", ' ', [
|
|
670
|
+
...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
|
|
671
|
+
...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
|
|
672
|
+
]
|
|
673
|
+
.map((e) => `−${e.glyph}`)
|
|
674
|
+
.join(' ')] })) : null] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? '▶ ' : ' ', "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : (tasks.map((task, i) => {
|
|
404
675
|
const sel = i + 1 === selected; // +1: lo 0 è spot
|
|
405
676
|
const n = childCount.get(task.id) ?? 0;
|
|
406
677
|
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? '▶ ' : ' ', task.id, " ", forceEmojiWidth(task.pri), " ", displayProg(task.prog), " ", task.desc, n > 0 ? ` (${n})` : ''] }, task.id));
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// T39 — Lettura delle voci `launch` da `.claude/loom-works.json`.
|
|
2
|
+
// Allineamento T32: le launch sono voci CUSTOM per-progetto {emoji,label,command},
|
|
3
|
+
// di numero e nome arbitrari. Il deck non può quindi avere una lettera fissa per
|
|
4
|
+
// app (com'era `C`→codium / `I`→idea, hardcoded): le voci si raggiungono per
|
|
5
|
+
// indice 1..9. Il `command` gira con cwd = project root.
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
export function configFilePath(projectRoot) {
|
|
9
|
+
return join(projectRoot, '.claude', 'loom-works.json');
|
|
10
|
+
}
|
|
11
|
+
export function parseLaunch(raw) {
|
|
12
|
+
if (!raw || typeof raw !== 'object')
|
|
13
|
+
return [];
|
|
14
|
+
const list = raw.launch;
|
|
15
|
+
if (!Array.isArray(list))
|
|
16
|
+
return [];
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const item of list) {
|
|
19
|
+
if (!item || typeof item !== 'object')
|
|
20
|
+
continue;
|
|
21
|
+
const { emoji, label, command } = item;
|
|
22
|
+
if (typeof command !== 'string' || !command.trim())
|
|
23
|
+
continue;
|
|
24
|
+
out.push({
|
|
25
|
+
emoji: typeof emoji === 'string' ? emoji : '▸',
|
|
26
|
+
// `label` è opzionale per contratto → fallback sul comando stesso.
|
|
27
|
+
label: typeof label === 'string' && label ? label : command,
|
|
28
|
+
command,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** File assente o malformato → nessuna voce launch. Mai un throw. */
|
|
34
|
+
export function loadLaunch(projectRoot) {
|
|
35
|
+
try {
|
|
36
|
+
return parseLaunch(JSON.parse(readFileSync(configFilePath(projectRoot), 'utf8')));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// T39 — Persistenza della vista (filtri + sort).
|
|
2
|
+
// D3 (preflight): sidecar `.claude/loom/deck-view.json`, GITIGNORED. La vista è
|
|
3
|
+
// preferenza personale macchina-locale, non config di progetto portabile: sta
|
|
4
|
+
// fuori da `.claude/loom-works.json` (committato), come session-tasks.jsonl.
|
|
5
|
+
// Formato JSON singolo documento read-modify-write — non JSONL append-only:
|
|
6
|
+
// lì l'append serviva la concorrenza fra spawn, qui il writer è uno solo.
|
|
7
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { DEFAULT_VIEW, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
10
|
+
export function viewFilePath(projectRoot) {
|
|
11
|
+
return join(projectRoot, '.claude', 'loom', 'deck-view.json');
|
|
12
|
+
}
|
|
13
|
+
const SORT_KEYS = ['pri', 'prog', 'id'];
|
|
14
|
+
const PRI_NAMES = PRI_ENTRIES.map((e) => e.name);
|
|
15
|
+
const PROG_NAMES = PROG_ENTRIES.map((e) => e.name);
|
|
16
|
+
// Il file è editabile a mano e sopravvive ai cambi di schema: si tiene solo ciò
|
|
17
|
+
// che è riconoscibile e si scarta il resto, invece di fidarsi del JSON o di
|
|
18
|
+
// rifiutare l'intero documento per una chiave sporca.
|
|
19
|
+
function sanitizeSort(raw) {
|
|
20
|
+
if (!Array.isArray(raw))
|
|
21
|
+
return [...DEFAULT_VIEW.sort];
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const item of raw) {
|
|
24
|
+
if (!item || typeof item !== 'object')
|
|
25
|
+
continue;
|
|
26
|
+
const { key, dir } = item;
|
|
27
|
+
if (!SORT_KEYS.includes(key))
|
|
28
|
+
continue;
|
|
29
|
+
if (dir !== 'asc' && dir !== 'desc')
|
|
30
|
+
continue;
|
|
31
|
+
if (out.some((e) => e.key === key))
|
|
32
|
+
continue; // una chiave compare una volta sola
|
|
33
|
+
out.push({ key: key, dir });
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function sanitizeNames(raw, allowed) {
|
|
38
|
+
if (!Array.isArray(raw))
|
|
39
|
+
return [];
|
|
40
|
+
return allowed.filter((name) => raw.includes(name));
|
|
41
|
+
}
|
|
42
|
+
export function parseView(raw) {
|
|
43
|
+
if (!raw || typeof raw !== 'object')
|
|
44
|
+
return { ...DEFAULT_VIEW };
|
|
45
|
+
const o = raw;
|
|
46
|
+
return {
|
|
47
|
+
sort: sanitizeSort(o.sort),
|
|
48
|
+
hiddenPri: sanitizeNames(o.hiddenPri, PRI_NAMES),
|
|
49
|
+
hiddenProg: sanitizeNames(o.hiddenProg, PROG_NAMES),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** File assente, illeggibile o corrotto → default puliti. Mai un throw. */
|
|
53
|
+
export function loadView(projectRoot) {
|
|
54
|
+
try {
|
|
55
|
+
return parseView(JSON.parse(readFileSync(viewFilePath(projectRoot), 'utf8')));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return { ...DEFAULT_VIEW };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Scrittura esplicita (tasto `w`), mai automatica: sperimentare non persiste. */
|
|
62
|
+
export function saveView(projectRoot, view) {
|
|
63
|
+
const path = viewFilePath(projectRoot);
|
|
64
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
65
|
+
writeFileSync(path, JSON.stringify(view, null, 2) + '\n', 'utf8');
|
|
66
|
+
}
|
package/dist/view.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// T39 — Core della vista: ordinali, sort chain multi-chiave, filtri.
|
|
2
|
+
// Modulo PURO: nessun import da ink/react, nessun I/O → testabile senza terminale.
|
|
3
|
+
// D2 (preflight T39): sort opinato, filtri off. La lista parte ordinata ma
|
|
4
|
+
// completa — nessuna task sparisce senza che l'utente abbia toccato una leva.
|
|
5
|
+
export const DEFAULT_VIEW = {
|
|
6
|
+
sort: [
|
|
7
|
+
{ key: 'pri', dir: 'desc' },
|
|
8
|
+
{ key: 'id', dir: 'asc' },
|
|
9
|
+
],
|
|
10
|
+
hiddenPri: [],
|
|
11
|
+
hiddenProg: [],
|
|
12
|
+
};
|
|
13
|
+
// Le celle Pri/Prog di tasks.md sono glifi grezzi (tasks.ts:47-50), non ranghi.
|
|
14
|
+
// Il rango è un'IMPORTANZA: valore alto = più urgente/attivo, così `desc` legge
|
|
15
|
+
// naturalmente come "prima i più importanti" (ed è il default della chain).
|
|
16
|
+
const PRI_TABLE = [
|
|
17
|
+
{ name: 'high', glyph: '🔥', rank: 3 },
|
|
18
|
+
{ name: 'med', glyph: '⚡', rank: 2 },
|
|
19
|
+
{ name: 'low', glyph: '🔹', rank: 1 },
|
|
20
|
+
];
|
|
21
|
+
// Ordine per "attivabilità" (desc = prima ciò su cui puoi agire): in corso →
|
|
22
|
+
// da fare → bloccata → chiusa. Non è l'ordine del ciclo di vita: il deck serve
|
|
23
|
+
// a scegliere su cosa lavorare, quindi le Done stanno in fondo sotto `desc`.
|
|
24
|
+
const PROG_TABLE = [
|
|
25
|
+
{ name: 'wip', glyph: '🟡', rank: 4 },
|
|
26
|
+
{ name: 'todo', glyph: '🔵', rank: 3 },
|
|
27
|
+
{ name: 'locked', glyph: '🔒', rank: 2 },
|
|
28
|
+
{ name: 'done', glyph: '✔', rank: 1 },
|
|
29
|
+
];
|
|
30
|
+
/** Rango dei glifi non riconosciuti: sotto tutti i noti → coda sotto `desc`. */
|
|
31
|
+
const UNKNOWN_RANK = 0;
|
|
32
|
+
// `✔️` (con VS16) e `✔` (senza) coesistono in tasks.md; una lookup per
|
|
33
|
+
// uguaglianza mancherebbe una delle due forme. Stessa insidia già aggirata ad
|
|
34
|
+
// hoc da isDone() con un includes(). Qui si normalizza una volta sola.
|
|
35
|
+
const VS16_RE = /️/g;
|
|
36
|
+
function normGlyph(s) {
|
|
37
|
+
return s.replace(VS16_RE, '').trim();
|
|
38
|
+
}
|
|
39
|
+
export function priName(glyph) {
|
|
40
|
+
const g = normGlyph(glyph);
|
|
41
|
+
return PRI_TABLE.find((e) => e.glyph === g)?.name ?? null;
|
|
42
|
+
}
|
|
43
|
+
export function progName(glyph) {
|
|
44
|
+
const g = normGlyph(glyph);
|
|
45
|
+
return PROG_TABLE.find((e) => e.glyph === g)?.name ?? null;
|
|
46
|
+
}
|
|
47
|
+
export function priRank(glyph) {
|
|
48
|
+
const g = normGlyph(glyph);
|
|
49
|
+
return PRI_TABLE.find((e) => e.glyph === g)?.rank ?? UNKNOWN_RANK;
|
|
50
|
+
}
|
|
51
|
+
export function progRank(glyph) {
|
|
52
|
+
const g = normGlyph(glyph);
|
|
53
|
+
return PROG_TABLE.find((e) => e.glyph === g)?.rank ?? UNKNOWN_RANK;
|
|
54
|
+
}
|
|
55
|
+
// `T10`.localeCompare(`T9`) mette T10 prima: l'ID va confrontato NUMERICO.
|
|
56
|
+
// parseTasks ammette solo /^T\d+$/, ma un id fuori forma non deve far esplodere
|
|
57
|
+
// il comparator → finisce in coda con Number.MAX_SAFE_INTEGER.
|
|
58
|
+
export function idNum(id) {
|
|
59
|
+
const m = /^T(\d+)$/.exec(id);
|
|
60
|
+
return m ? Number(m[1]) : Number.MAX_SAFE_INTEGER;
|
|
61
|
+
}
|
|
62
|
+
function rankOf(task, key) {
|
|
63
|
+
if (key === 'pri')
|
|
64
|
+
return priRank(task.pri);
|
|
65
|
+
if (key === 'prog')
|
|
66
|
+
return progRank(task.prog);
|
|
67
|
+
return idNum(task.id);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Comparator della chain: chiavi valutate in cascata, prima differenza vince.
|
|
71
|
+
* A parità piena decide `id` ascendente — fallback implicito che rende l'ordine
|
|
72
|
+
* SEMPRE deterministico (mai instabile fra re-render). Se `id` è già una chiave
|
|
73
|
+
* esplicita della chain il fallback non serve: l'id è unico, la parità è totale.
|
|
74
|
+
*/
|
|
75
|
+
export function compareTasks(a, b, sort) {
|
|
76
|
+
for (const entry of sort) {
|
|
77
|
+
const diff = rankOf(a, entry.key) - rankOf(b, entry.key);
|
|
78
|
+
if (diff !== 0)
|
|
79
|
+
return entry.dir === 'asc' ? diff : -diff;
|
|
80
|
+
}
|
|
81
|
+
if (sort.some((e) => e.key === 'id'))
|
|
82
|
+
return 0;
|
|
83
|
+
return idNum(a.id) - idNum(b.id);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Ciclo di una chiave nella chain: assente → asc → desc → assente.
|
|
87
|
+
* La POSIZIONE nella chain nasce dall'ordine di prima pressione (digitare
|
|
88
|
+
* `ppi` produce [pri desc, id asc]); ri-aggiungere una chiave rimossa la
|
|
89
|
+
* riaccoda in fondo, non la rimette al posto vecchio.
|
|
90
|
+
*/
|
|
91
|
+
export function cycleSort(sort, key) {
|
|
92
|
+
const i = sort.findIndex((e) => e.key === key);
|
|
93
|
+
if (i < 0)
|
|
94
|
+
return [...sort, { key, dir: 'asc' }];
|
|
95
|
+
if (sort[i].dir === 'asc') {
|
|
96
|
+
const next = [...sort];
|
|
97
|
+
next[i] = { key, dir: 'desc' };
|
|
98
|
+
return next;
|
|
99
|
+
}
|
|
100
|
+
return sort.filter((e) => e.key !== key);
|
|
101
|
+
}
|
|
102
|
+
export function toggleHidden(hidden, name) {
|
|
103
|
+
return hidden.includes(name) ? hidden.filter((h) => h !== name) : [...hidden, name];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Un filtro nasconde solo valori RICONOSCIUTI: un glifo ignoto non è
|
|
107
|
+
* classificabile, quindi resta visibile. Regola voluta — un filtro non deve
|
|
108
|
+
* far sparire in silenzio task che non sa leggere.
|
|
109
|
+
*/
|
|
110
|
+
export function isVisible(task, view) {
|
|
111
|
+
const p = priName(task.pri);
|
|
112
|
+
if (p && view.hiddenPri.includes(p))
|
|
113
|
+
return false;
|
|
114
|
+
const s = progName(task.prog);
|
|
115
|
+
if (s && view.hiddenProg.includes(s))
|
|
116
|
+
return false;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
/** Filtra poi ordina. Non muta l'input: il polling di tasks.md resta ignaro. */
|
|
120
|
+
export function applyView(tasks, view) {
|
|
121
|
+
const visible = tasks.filter((t) => isVisible(t, view));
|
|
122
|
+
visible.sort((a, b) => compareTasks(a, b, view.sort));
|
|
123
|
+
return { visible, hidden: tasks.length - visible.length };
|
|
124
|
+
}
|
|
125
|
+
export const PRI_ENTRIES = PRI_TABLE.map((e) => ({ name: e.name, glyph: e.glyph }));
|
|
126
|
+
export const PROG_ENTRIES = PROG_TABLE.map((e) => ({ name: e.name, glyph: e.glyph }));
|
|
127
|
+
const SORT_LABEL = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
128
|
+
/** Riassunto della chain per l'header ("pri↓ id↑"); vuota → "—". */
|
|
129
|
+
export function describeSort(sort) {
|
|
130
|
+
if (sort.length === 0)
|
|
131
|
+
return '—';
|
|
132
|
+
return sort.map((e) => `${SORT_LABEL[e.key]}${e.dir === 'asc' ? '↑' : '↓'}`).join(' ');
|
|
133
|
+
}
|
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",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"scripts": {
|
|
14
14
|
"dev": "tsx src/cli.tsx",
|
|
15
15
|
"build": "tsc",
|
|
16
|
+
"test": "tsx --test test/*.test.ts",
|
|
16
17
|
"start": "node dist/cli.js",
|
|
17
18
|
"prepare": "tsc"
|
|
18
19
|
},
|