@lamemind/loom-deck 0.5.0 → 0.6.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 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,9 @@ 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, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
15
+ import { loadView, saveView, viewFilePath } from './view-store.js';
13
16
  // scripts/deck-run è un sibling della dir del bundle: src/ (dev, tsx) e dist/
14
17
  // (build, node) stanno entrambi sotto la package root → risalita di un livello.
15
18
  const DECK_RUN = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'deck-run');
@@ -21,6 +24,9 @@ const MAX_SESSIONS = 30;
21
24
  // meta "spot" (sentinella) che raccoglie le sessioni NON legate ad alcuna task.
22
25
  // La selezione nel Tasks pane è il "padre"; il Sessions pane mostra i suoi figli.
23
26
  const SPOT = Symbol('spot');
27
+ // Modale sort a grammatica libera: un tasto per chiave, pressioni successive
28
+ // ciclano asc → desc → fuori dalla chain.
29
+ const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
24
30
  function isDone(prog) {
25
31
  return prog.includes('✔');
26
32
  }
@@ -36,18 +42,16 @@ function spawnDeck(id, cwd, sessionId) {
36
42
  child.unref();
37
43
  return child;
38
44
  }
39
- // Editor esterni: aprono l'IDE sulla project root. Il comando risolve attraverso
40
- // la shell login+interattiva dell'utente (bash -lic) alias/funzioni in ~/.bashrc
41
- // (es. `codium`=alias flatpak, `idea`=funzione) risolvono come nel terminale.
42
- // Override via env per ambienti senza quegli alias (loom-deck è destinato a NPM).
43
- const EDITOR_CMD = {
44
- codium: process.env.LOOM_DECK_EDITOR_CODIUM ?? 'codium',
45
- idea: process.env.LOOM_DECK_EDITOR_IDEA ?? 'idea',
46
- };
47
- // Spawn detached come spawnDeck: il deck lancia ma non possiede l'IDE. La project
48
- // root arriva via $PWD (spawn cwd) → niente interpolazione di path, zero injection.
49
- function openEditor(cmd, cwd) {
50
- const child = spawn('bash', ['-lic', `${cmd} "$PWD"`], {
45
+ // T39/T32: voce `launch` custom del file config, eseguita con cwd = project root.
46
+ // Spawn detached come spawnDeck: il deck lancia ma non possiede il processo.
47
+ // Shell login+interattiva (bash -lic) perché i comandi tipici sono alias o
48
+ // funzioni di ~/.bashrc (`codium`=alias flatpak, `idea`=funzione) con `bash -c`
49
+ // non risolverebbero. Il comando NON è input utente: viene dal file committato
50
+ // `.claude/loom-works.json`, fidato quanto un custom-command Ptyxis (contratto
51
+ // esplicito in project-config-architecture.md). La project root arriva via cwd,
52
+ // non interpolata nella stringa.
53
+ function runLaunch(entry, cwd) {
54
+ const child = spawn('bash', ['-lic', entry.command], {
51
55
  cwd,
52
56
  detached: true,
53
57
  stdio: 'ignore',
@@ -204,6 +208,18 @@ function forceEmojiWidth(s) {
204
208
  return s + VS16;
205
209
  return s;
206
210
  }
211
+ // Normalizza il marker Done per il display. `✔` (U+2714) è text-presentation-
212
+ // default: string-width — quindi Ink, sia per il layout sia per il troncamento —
213
+ // lo misura 2 (rispetta il VS16 di `✔️`), ma VTE/Ptyxis lo disegna largo 1
214
+ // (ignora il VS16). Risultato: le righe Done finivano 1 colonna più strette del
215
+ // riservato → la coda della riga (bordo destro del pane incluso) slittava a
216
+ // sinistra, sfasando il layout solo su quelle righe. `✅` (U+2705) è invece
217
+ // emoji-presentation-default → largo 2 sia per string-width sia per il terminale,
218
+ // come 🔵/🟡: colonna Prog allineata. Cambia SOLO il display: `task.prog` resta
219
+ // `✔️` così `isDone()` continua a matchare.
220
+ function displayProg(prog) {
221
+ return forceEmojiWidth(prog.replace(/✔️?/g, '✅'));
222
+ }
207
223
  // Età relativa compatta (ms epoch → "2m"/"3h"/"5d") per il preview sessioni.
208
224
  function relTime(ts) {
209
225
  const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
@@ -223,18 +239,34 @@ function Deck({ cwd, tasksPath, tasksDir }) {
223
239
  const { tasks, loadError } = useTasks(tasksPath);
224
240
  const { sessions, bindings } = useSessions(cwd);
225
241
  const [focus, setFocus] = useState('tasks');
226
- // selParent [0, tasks.length]: l'indice 0 è la riga meta "spot" (prima voce),
227
- // gli indici 1..tasks.length sono le task reali (task = selParent-1).
228
- const [selParent, setSelParent] = useState(0);
242
+ // T39 selezione KEYED SU ID, non su indice. Con una vista trasformata
243
+ // (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
244
+ // grezzo per posizione spawnerebbe la task sbagliata, in silenzio. `null` = la
245
+ // riga meta "spot", sempre in testa alla lista.
246
+ const [selId, setSelId] = useState(null);
229
247
  const [selSession, setSelSession] = useState(0);
230
248
  const [note, setNote] = useState('');
231
- // T30: modale di creazione task inline. mode='create' useInput cattura il
232
- // testo (gating della navigazione normale) e mostra l'input box.
249
+ // Modali: catturano i tasti e corto-circuitano la navigazione normale.
250
+ // T30 create · T39 sort/filter.
233
251
  const [mode, setMode] = useState('normal');
234
252
  const [draft, setDraft] = useState('');
235
- const isSpot = selParent === 0;
253
+ // T39 vista corrente (filtri + sort) e sua fotografia all'apertura di un
254
+ // modale: la lista si aggiorna dal vivo, quindi `esc` deve poter ripristinare.
255
+ const [view, setView] = useState(() => loadView(cwd));
256
+ const [viewBackup, setViewBackup] = useState(null);
257
+ const [filterCursor, setFilterCursor] = useState({ row: 0, col: 0 });
258
+ // Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
259
+ const launch = useMemo(() => loadLaunch(cwd), [cwd]);
260
+ // La vista è una trasformazione DERIVATA, applicata a valle del load: il
261
+ // polling di tasks.md continua a funzionare senza saperne nulla.
262
+ const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
263
+ const isSpot = selId === null;
236
264
  const projectName = cwd.split('/').pop() || cwd;
237
- const selectedTaskId = isSpot ? null : tasks[selParent - 1]?.id ?? null;
265
+ // Unica fonte della selezione: si legge SEMPRE dalla vista, mai dall'array
266
+ // grezzo — è l'invariante che tiene allineati dettaglio mostrato e spawn.
267
+ const selTask = selId === null ? null : viewTasks.find((t) => t.id === selId) ?? null;
268
+ const selectedTaskId = selTask?.id ?? null;
269
+ const selIndex = selTask ? viewTasks.indexOf(selTask) + 1 : 0;
238
270
  const detail = useTaskDetail(tasksDir, selectedTaskId ?? undefined);
239
271
  // Conteggio figli per task + spot (badge nel Tasks pane).
240
272
  const childCount = new Map();
@@ -254,15 +286,18 @@ function Deck({ cwd, tasksPath, tasksDir }) {
254
286
  });
255
287
  const visibleSessions = childSessions.slice(0, MAX_SESSIONS);
256
288
  const hiddenSessions = childSessions.length - visibleSessions.length;
257
- // Dopo un refresh le liste possono accorciarsi: tieni le selezioni in range
258
- // (max index = tasks.length; spot è sempre l'indice 0).
289
+ // T39 selezione stabile sotto trasformazione. Se la task selezionata esce
290
+ // dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
291
+ // sulla prima visibile — fallback deterministico, mai una posizione a caso.
259
292
  useEffect(() => {
260
- setSelParent((s) => Math.min(s, tasks.length));
261
- }, [tasks.length]);
293
+ if (selId !== null && !viewTasks.some((t) => t.id === selId)) {
294
+ setSelId(viewTasks[0]?.id ?? null);
295
+ }
296
+ }, [viewTasks, selId]);
262
297
  // Cambio padre → riparti dalla prima sessione figlia.
263
298
  useEffect(() => {
264
299
  setSelSession(0);
265
- }, [selParent]);
300
+ }, [selId]);
266
301
  useEffect(() => {
267
302
  setSelSession((s) => Math.min(s, Math.max(0, visibleSessions.length - 1)));
268
303
  }, [visibleSessions.length]);
@@ -275,7 +310,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
275
310
  setMode('normal');
276
311
  setDraft('');
277
312
  if (!text) {
278
- setNote('c → create annullato (vuoto)');
313
+ setNote('C → create annullato (vuoto)');
279
314
  return;
280
315
  }
281
316
  const sid = randomUUID();
@@ -303,6 +338,21 @@ function Deck({ cwd, tasksPath, tasksDir }) {
303
338
  });
304
339
  child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
305
340
  }
341
+ // Chiusura di un modale di vista: `restore` rimette la fotografia scattata
342
+ // all'apertura (esc = annulla), altrimenti tiene ciò che si è composto (⏎).
343
+ function closeViewModal(restore) {
344
+ if (restore && viewBackup)
345
+ setView(viewBackup);
346
+ setViewBackup(null);
347
+ setMode('normal');
348
+ }
349
+ // Sposta la selezione di `delta` righe nella VISTA (0 = spot, 1..N = task
350
+ // visibili) e la riconverte subito in id: l'indice non sopravvive a un
351
+ // cambio di filtro, l'id sì.
352
+ function moveTaskSel(delta) {
353
+ const next = Math.max(0, Math.min(viewTasks.length, selIndex + delta));
354
+ setSelId(next === 0 ? null : viewTasks[next - 1]?.id ?? null);
355
+ }
306
356
  useInput((input, key) => {
307
357
  // T30: in modalità create l'handler cattura il testo e corto-circuita la
308
358
  // navigazione normale (incl. q/esc → quit: qui esc annulla, non esce).
@@ -310,7 +360,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
310
360
  if (key.escape) {
311
361
  setMode('normal');
312
362
  setDraft('');
313
- setNote('c → create annullato');
363
+ setNote('C → create annullato');
314
364
  }
315
365
  else if (key.return) {
316
366
  submitCreate();
@@ -323,18 +373,73 @@ function Deck({ cwd, tasksPath, tasksDir }) {
323
373
  }
324
374
  return;
325
375
  }
376
+ // T39 — modale sort a grammatica libera: la SEQUENZA di tasti È la chain.
377
+ // Digitare `ppi` = p(asc) p(desc) i(asc) → [pri desc, id asc]. La lista si
378
+ // riordina dal vivo a ogni pressione.
379
+ if (mode === 'sort') {
380
+ if (key.escape) {
381
+ closeViewModal(true);
382
+ setNote('S → sort annullato');
383
+ }
384
+ else if (key.return) {
385
+ closeViewModal(false);
386
+ setNote(`S → sort: ${describeSort(view.sort)}`);
387
+ }
388
+ else if (input) {
389
+ // useInput consegna il CHUNK letto da stdin, non un tasto: digitando
390
+ // veloce (o incollando) `ppi` arriva come stringa unica. Si cicla su
391
+ // ogni carattere, così la chain esce identica a battitura lenta.
392
+ const keys = [...input].map((ch) => SORT_TASTI[ch]).filter(Boolean);
393
+ if (keys.length > 0) {
394
+ setView((v) => ({ ...v, sort: keys.reduce(cycleSort, v.sort) }));
395
+ }
396
+ }
397
+ return;
398
+ }
399
+ // T39 — modale filtri: griglia 2 righe (priorità / stato), un toggle per
400
+ // valore. Anche qui l'effetto è immediato sulla lista.
401
+ if (mode === 'filter') {
402
+ const rowLen = (r) => (r === 0 ? PRI_ENTRIES.length : PROG_ENTRIES.length);
403
+ if (key.escape) {
404
+ closeViewModal(true);
405
+ setNote('F → filtri annullati');
406
+ }
407
+ else if (key.return) {
408
+ closeViewModal(false);
409
+ setNote(hiddenTasks > 0 ? `F → ${hiddenTasks} task nascoste` : 'F → nessun filtro attivo');
410
+ }
411
+ else if (key.upArrow || key.downArrow) {
412
+ setFilterCursor((c) => {
413
+ const row = c.row === 0 ? 1 : 0;
414
+ return { row, col: Math.min(c.col, rowLen(row) - 1) };
415
+ });
416
+ }
417
+ else if (key.leftArrow) {
418
+ setFilterCursor((c) => ({ ...c, col: Math.max(0, c.col - 1) }));
419
+ }
420
+ else if (key.rightArrow) {
421
+ setFilterCursor((c) => ({ ...c, col: Math.min(rowLen(c.row) - 1, c.col + 1) }));
422
+ }
423
+ else if (input === ' ') {
424
+ const { row, col } = filterCursor;
425
+ setView((v) => row === 0
426
+ ? { ...v, hiddenPri: toggleHidden(v.hiddenPri, PRI_ENTRIES[col].name) }
427
+ : { ...v, hiddenProg: toggleHidden(v.hiddenProg, PROG_ENTRIES[col].name) });
428
+ }
429
+ return;
430
+ }
326
431
  if (key.leftArrow || key.rightArrow || key.tab) {
327
432
  setFocus((f) => (f === 'tasks' ? 'sessions' : 'tasks'));
328
433
  }
329
434
  else if (key.upArrow) {
330
435
  if (focus === 'tasks')
331
- setSelParent((i) => Math.max(0, i - 1));
436
+ moveTaskSel(-1);
332
437
  else
333
438
  setSelSession((i) => Math.max(0, i - 1));
334
439
  }
335
440
  else if (key.downArrow) {
336
441
  if (focus === 'tasks')
337
- setSelParent((i) => Math.min(tasks.length, i + 1));
442
+ moveTaskSel(1);
338
443
  else
339
444
  setSelSession((i) => Math.min(visibleSessions.length - 1, i + 1));
340
445
  }
@@ -343,39 +448,58 @@ function Deck({ cwd, tasksPath, tasksDir }) {
343
448
  if (isSpot) {
344
449
  setNote('spot: sessioni libere, nessuna task da spawnare');
345
450
  }
346
- else {
347
- const task = tasks[selParent - 1];
348
- if (task) {
349
- // sessionId pinnato lato deck → binding sidecar scritto PRIMA dello
350
- // spawn: la sessione risulta figlia della task appena il JSONL compare.
351
- const sid = randomUUID();
352
- appendTaskBinding(cwd, sid, task.id);
353
- const child = spawnDeck(task.id, cwd, sid);
354
- // Un errore di spawn è async: senza handler diventa uncaughtException
355
- // e ucciderebbe il deck. Lo intercetto per preservare l'invariante
356
- // "il deck resta vivo" e mostro la nota d'errore.
357
- child.on('error', () => setNote(`⚠ spawn ${task.id} fallito (${DECK_RUN})`));
358
- setNote(`⏎ spawn ${task.id} → tab CC (sid ${sid.slice(0, 8)})`);
359
- }
451
+ else if (selTask) {
452
+ // sessionId pinnato lato deck → binding sidecar scritto PRIMA dello
453
+ // spawn: la sessione risulta figlia della task appena il JSONL compare.
454
+ const sid = randomUUID();
455
+ appendTaskBinding(cwd, sid, selTask.id);
456
+ const child = spawnDeck(selTask.id, cwd, sid);
457
+ // Un errore di spawn è async: senza handler diventa uncaughtException
458
+ // e ucciderebbe il deck. Lo intercetto per preservare l'invariante
459
+ // "il deck resta vivo" e mostro la nota d'errore.
460
+ child.on('error', () => setNote(`⚠ spawn ${selTask.id} fallito (${DECK_RUN})`));
461
+ setNote(`⏎ spawn ${selTask.id} tab CC (sid ${sid.slice(0, 8)})`);
360
462
  }
361
463
  }
362
464
  else {
363
465
  setNote('sessioni read-only in T27 · fork/resume → T28');
364
466
  }
365
467
  }
366
- else if (input === 'c') {
468
+ else if (input === 'C') {
367
469
  setNote('');
368
470
  setMode('create');
369
471
  }
370
- else if (input === 'C') {
371
- const child = openEditor(EDITOR_CMD.codium, cwd);
372
- child.on('error', () => setNote(`⚠ codium: '${EDITOR_CMD.codium}' non lanciabile`));
373
- setNote(`C → codium su ${projectName}`);
472
+ else if (input === 'S') {
473
+ setViewBackup(view);
474
+ setNote('');
475
+ setMode('sort');
374
476
  }
375
- else if (input === 'I') {
376
- const child = openEditor(EDITOR_CMD.idea, cwd);
377
- child.on('error', () => setNote(`⚠ idea: '${EDITOR_CMD.idea}' non lanciabile`));
378
- setNote(`I → idea su ${projectName}`);
477
+ else if (input === 'F') {
478
+ setViewBackup(view);
479
+ setNote('');
480
+ setMode('filter');
481
+ }
482
+ else if (input === 'w') {
483
+ // Salvataggio ESPLICITO: comporre una vista non tocca il disco, così
484
+ // sperimentare non sporca lo stato persistito.
485
+ try {
486
+ saveView(cwd, view);
487
+ setNote(`w → vista salvata (${viewFilePath(cwd)})`);
488
+ }
489
+ catch {
490
+ setNote('⚠ salvataggio vista fallito');
491
+ }
492
+ }
493
+ else if (input && /^[1-9]$/.test(input)) {
494
+ const entry = launch[Number(input) - 1];
495
+ if (!entry) {
496
+ setNote(`${input} → nessuna voce launch (${launch.length} configurate)`);
497
+ }
498
+ else {
499
+ const child = runLaunch(entry, cwd);
500
+ child.on('error', () => setNote(`⚠ ${entry.label}: '${entry.command}' non lanciabile`));
501
+ setNote(`${input} → ${entry.label} su ${projectName}`);
502
+ }
379
503
  }
380
504
  else if (input === 'q' || key.escape) {
381
505
  exit();
@@ -384,14 +508,40 @@ function Deck({ cwd, tasksPath, tasksDir }) {
384
508
  const selSessionId = visibleSessions[selSession]?.sessionId;
385
509
  const parentLabel = isSpot ? 'spot' : selectedTaskId ?? '—';
386
510
  const canSpawn = focus === 'tasks' && !isSpot;
387
- 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"] })) : (_jsxs(Text, { dimColor: true, children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : '—', " \u00B7 c nuova \u00B7 C codium \u00B7 I idea \u00B7 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, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: tasks, selected: selParent, 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] }));
511
+ 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", ' ', 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, _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
+ }
514
+ const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
515
+ // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
516
+ // spingono giù i pane invece di coprirli, così la lista che stai filtrando
517
+ // resta sempre visibile mentre la componi.
518
+ function SortModal({ sort }) {
519
+ 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
520
+ .map((e, i) => `${i + 1}. ${SORT_UI[e.key]} ${e.dir === 'asc' ? '↑' : '↓'}`)
521
+ .join(' ') }))] }));
522
+ }
523
+ function FilterModal({ view, cursor }) {
524
+ const rows = [
525
+ { label: 'pri ', entries: PRI_ENTRIES, hidden: new Set(view.hiddenPri) },
526
+ { label: 'stato', entries: PROG_ENTRIES, hidden: new Set(view.hiddenProg) },
527
+ ];
528
+ 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) => {
529
+ const on = !row.hidden.has(e.name);
530
+ const here = cursor.row === r && cursor.col === c;
531
+ return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ", forceEmojiWidth(e.glyph)] }, e.name));
532
+ })] }, row.label)))] }));
388
533
  }
389
- function TasksPane({ tasks, selected, spotCount, childCount, focused, loadError, detail, }) {
534
+ function TasksPane({ tasks, total, hidden, view, selected, spotCount, childCount, focused, loadError, detail, }) {
390
535
  const spotSelected = selected === 0;
391
- 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, ")"] }), _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) => {
536
+ 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:", ' ', [
537
+ ...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
538
+ ...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
539
+ ]
540
+ .map((e) => `−${e.glyph}`)
541
+ .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) => {
392
542
  const sel = i + 1 === selected; // +1: lo 0 è spot
393
543
  const n = childCount.get(task.id) ?? 0;
394
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? '▶ ' : ' ', task.id, " ", forceEmojiWidth(task.pri), " ", forceEmojiWidth(task.prog), " ", task.desc, n > 0 ? ` (${n})` : ''] }, task.id));
544
+ 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));
395
545
  })), detail ? _jsx(DetailPane, { detail: detail }) : null] }));
396
546
  }
397
547
  function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId, focused, }) {
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,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,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.5.0",
3
+ "version": "0.6.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": {
@@ -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
  },