@lamemind/loom-deck 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { render, Box, Text, useApp, useInput, useStdout } from 'ink';
4
4
  import { useState, useEffect, useMemo, useRef } from 'react';
5
5
  import { spawn } from 'node:child_process';
6
+ import { EventEmitter } from 'node:events';
6
7
  import { statSync } from 'node:fs';
7
8
  import { randomUUID } from 'node:crypto';
8
9
  import { fileURLToPath } from 'node:url';
@@ -29,6 +30,11 @@ const MAX_SESSIONS = 30;
29
30
  // meta "spot" (sentinella) che raccoglie le sessioni NON legate ad alcuna task.
30
31
  // La selezione nel Tasks pane è il "padre"; il Sessions pane mostra i suoi figli.
31
32
  const SPOT = Symbol('spot');
33
+ const EDIT_ROWS = 4;
34
+ /** Le righe del modale edit che sono campi di TESTO (il resto è scelta ←→). */
35
+ function isTextRow(r) {
36
+ return r === 2 || r === 3;
37
+ }
32
38
  // Modale sort a grammatica libera: un tasto per chiave, pressioni successive
33
39
  // ciclano asc → desc → fuori dalla chain.
34
40
  const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
@@ -61,11 +67,36 @@ const EDIT_PROG = ['todo', 'wip', 'done', 'locked'];
61
67
  function isDone(prog) {
62
68
  return prog.includes('✔');
63
69
  }
70
+ /**
71
+ * Freno agli effetti VERSO L'ESTERNO (tab Ptyxis, sessioni Claude, git commit).
72
+ *
73
+ * Il gate di larghezza avvia il deck vero in uno pseudo-terminale e gli manda
74
+ * tasti — e in questa TUI un tasto è un'azione: `⏎` su una riga sessione apre
75
+ * una tab Ptyxis, `t` un terminale, `⏎` nel modale edit committa. Ogni run dei
76
+ * test apriva quindi finestre reali sulla macchina di chi li lanciava, in
77
+ * qualunque progetto avesse in focus.
78
+ *
79
+ * Il gate va tenuto sul deck VERO (è tutto il suo valore: misura il frame che
80
+ * VTE disegna davvero), quindi il freno sta qui: `LOOM_DECK_NO_SPAWN=1` fa
81
+ * restituire un figlio finto e inerte invece di lanciare il processo. Non è un
82
+ * mock del comportamento — l'azione semplicemente non avviene, e il frame che il
83
+ * test misura resta identico.
84
+ */
85
+ const NO_SPAWN = process.env.LOOM_DECK_NO_SPAWN === '1';
86
+ function spawnOut(cmd, args, opts) {
87
+ if (!NO_SPAWN)
88
+ return spawn(cmd, args, opts);
89
+ // Figlio inerte: emette nulla, quindi i `.on('error'|'close')` dei chiamanti
90
+ // restano appesi senza mai scattare — che è esattamente "non è successo niente".
91
+ const fake = new EventEmitter();
92
+ fake.unref = () => fake;
93
+ return fake;
94
+ }
64
95
  // Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
65
96
  // ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
66
97
  // sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
67
98
  function spawnDeck(id, cwd, sessionId) {
68
- const child = spawn(DECK_RUN, [id, '--session-id', sessionId], {
99
+ const child = spawnOut(DECK_RUN, [id, '--session-id', sessionId], {
69
100
  cwd,
70
101
  detached: true,
71
102
  stdio: 'ignore',
@@ -81,7 +112,7 @@ function spawnDeck(id, cwd, sessionId) {
81
112
  // continuarla, non iniettarle un messaggio (lo salta deck-run).
82
113
  function spawnDeckResume(taskId, cwd, sessionId) {
83
114
  const args = taskId ? [taskId, '--resume', sessionId] : ['--no-task', '--resume', sessionId];
84
- const child = spawn(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
115
+ const child = spawnOut(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
85
116
  child.unref();
86
117
  return child;
87
118
  }
@@ -102,7 +133,7 @@ function spawnDeckFork(taskId, cwd, originId, newId) {
102
133
  '--session-id',
103
134
  newId,
104
135
  ];
105
- const child = spawn(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
136
+ const child = spawnOut(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
106
137
  child.unref();
107
138
  return child;
108
139
  }
@@ -113,7 +144,7 @@ function spawnDeckFork(taskId, cwd, originId, newId) {
113
144
  // sporcherebbe il percorso bound. Il titolo tab resta la label loom — lo mette
114
145
  // deck-run, perché il match compass è window-level e non sa nulla di task.
115
146
  function spawnClaudeEmpty(cwd) {
116
- const child = spawn(DECK_RUN, ['--no-task'], {
147
+ const child = spawnOut(DECK_RUN, ['--no-task'], {
117
148
  cwd,
118
149
  detached: true,
119
150
  stdio: 'ignore',
@@ -130,7 +161,7 @@ function spawnClaudeEmpty(cwd) {
130
161
  // esplicito in project-config-architecture.md). La project root arriva via cwd,
131
162
  // non interpolata nella stringa.
132
163
  function runLaunch(entry, cwd) {
133
- const child = spawn('bash', ['-lic', entry.command], {
164
+ const child = spawnOut('bash', ['-lic', entry.command], {
134
165
  cwd,
135
166
  detached: true,
136
167
  stdio: 'ignore',
@@ -150,7 +181,7 @@ function runLaunch(entry, cwd) {
150
181
  // dal radar finché quella tab è in primo piano).
151
182
  function spawnTerminal(cwd, title) {
152
183
  const args = title ? ['--tab', '-T', title, '-d', cwd] : ['--tab', '-d', cwd];
153
- const child = spawn('ptyxis', args, { cwd, detached: true, stdio: 'ignore' });
184
+ const child = spawnOut('ptyxis', args, { cwd, detached: true, stdio: 'ignore' });
154
185
  child.unref();
155
186
  return child;
156
187
  }
@@ -167,7 +198,7 @@ const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
167
198
  // completa commit+push da sé; stdout in pipe SOLO per leggere il result event.
168
199
  // Il prompt viaggia come singolo argv (no shell) → nessuna injection dal testo utente.
169
200
  function spawnCreateTask(text, cwd, sessionId, onResult) {
170
- const child = spawn(CLAUDE_CMD, [
201
+ const child = spawnOut(CLAUDE_CMD, [
171
202
  '-p',
172
203
  '--output-format',
173
204
  'stream-json',
@@ -210,7 +241,7 @@ function spawnCreateTask(text, cwd, sessionId, onResult) {
210
241
  // veloce e il suo esito va riportato nella nota. stderr raccolto per dire perché
211
242
  // ha fallito (identità git assente, hook che rifiuta, …) invece di un generico ⚠.
212
243
  function commitTaskEdit(cwd, paths, message, onResult) {
213
- const child = spawn('git', ['commit', '-m', message, '--', ...paths], {
244
+ const child = spawnOut('git', ['commit', '-m', message, '--', ...paths], {
214
245
  cwd,
215
246
  stdio: ['ignore', 'ignore', 'pipe'],
216
247
  });
@@ -623,6 +654,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
623
654
  // da default. La priorità arriva dal glifo di tasks.md (già in `selTask`), lo
624
655
  // stato dal suo glifo Prog; il progresso arbitrario dal campo `Progress` del
625
656
  // task file — ma solo se è davvero custom (vedi `initialDetail`).
657
+ //
658
+ // Il titolo si semina dalla riga di tasks.md e non dall'H1 del task file per
659
+ // due ragioni: è la fonte che esiste SEMPRE (un task file può mancare), ed è
660
+ // il testo che l'utente sta guardando in lista quando preme `E`. Grezzo
661
+ // (`rawDesc`), non sanificato: rimandare a disco la forma sanificata
662
+ // riscriverebbe i glifi anche senza toccare il campo.
626
663
  function openEdit() {
627
664
  if (!selTask)
628
665
  return;
@@ -631,6 +668,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
631
668
  pri: priName(selTask.pri) ?? 'med',
632
669
  prog,
633
670
  detail: initialDetail(detail?.fields['Progress'] ?? '', prog),
671
+ title: selTask.rawDesc,
634
672
  });
635
673
  setEditRow(0);
636
674
  setNote('');
@@ -648,9 +686,23 @@ function Deck({ cwd, tasksPath, tasksDir }) {
648
686
  setEdit(null);
649
687
  if (!task || !draft)
650
688
  return;
689
+ // Il titolo si scrive solo se è CAMBIATO davvero: rimandarlo identico
690
+ // riscriverebbe comunque la cella (collassando spazi ed escape) e sporcherebbe
691
+ // il diff di una riga per un edit di sola priorità. Vuoto → scartato: una
692
+ // task senza descrizione in overview non è più riconoscibile.
693
+ const title = draft.title.trim();
694
+ const titleChanged = title.length > 0 && title !== task.rawDesc.trim();
651
695
  let res;
652
696
  try {
653
- res = writeTaskEdit({ tasksPath, tasksDir, id: task.id, ...draft });
697
+ res = writeTaskEdit({
698
+ tasksPath,
699
+ tasksDir,
700
+ id: task.id,
701
+ pri: draft.pri,
702
+ prog: draft.prog,
703
+ detail: draft.detail,
704
+ title: titleChanged ? title : undefined,
705
+ });
654
706
  }
655
707
  catch (e) {
656
708
  setNote(`⚠ ${task.id}: scrittura fallita (${e.message})`);
@@ -660,9 +712,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
660
712
  setNote(`⚠ ${task.id}: nessun campo aggiornabile (riga o task file assenti)`);
661
713
  return;
662
714
  }
663
- const summary = `${PRI_GLYPH[draft.pri]} ${PRI_LABEL[draft.pri]} · ${res.progress}`;
715
+ const summary = `${PRI_GLYPH[draft.pri]} ${PRI_LABEL[draft.pri]} · ${res.progress}${titleChanged ? ` · "${cut(sanitize(title), 32)}"` : ''}`;
664
716
  setNote(`⏳ ${task.id} → ${summary} · commit…`);
665
- commitTaskEdit(cwd, res.paths, `chore(${task.id}): pri ${PRI_LABEL[draft.pri]} · stato ${res.progress}`, (ok, err) => {
717
+ commitTaskEdit(cwd, res.paths, `chore(${task.id}): pri ${PRI_LABEL[draft.pri]} · stato ${res.progress}${titleChanged ? ' · titolo' : ''}`, (ok, err) => {
666
718
  setNote(ok ? `✔ ${task.id} → ${summary} · committato` : `⚠ ${task.id} salvato, commit fallito: ${err}`);
667
719
  });
668
720
  }
@@ -961,9 +1013,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
961
1013
  }
962
1014
  return;
963
1015
  }
964
- // T41 — modale edit: griglia a 3 righe. Righe 0/1 = scelta a valore singolo
965
- // (←→ scorre), riga 2 = testo libero (ogni carattere stampabile entra nel
966
- // progresso). Come gli altri modali cattura tutto: `esc` annulla senza
1016
+ // T41 — modale edit: griglia a 4 righe. Righe 0/1 = scelta a valore singolo
1017
+ // (←→ scorre), righe 2/3 = testo libero (ogni carattere stampabile entra nel
1018
+ // campo). Come gli altri modali cattura tutto: `esc` annulla senza
967
1019
  // scrivere né uscire dal deck.
968
1020
  if (mode === 'edit') {
969
1021
  if (key.escape) {
@@ -975,12 +1027,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
975
1027
  submitEdit();
976
1028
  }
977
1029
  else if (key.upArrow) {
978
- setEditRow((r) => ((r + 2) % 3));
1030
+ setEditRow((r) => ((r + EDIT_ROWS - 1) % EDIT_ROWS));
979
1031
  }
980
1032
  else if (key.downArrow) {
981
- setEditRow((r) => ((r + 1) % 3));
1033
+ setEditRow((r) => ((r + 1) % EDIT_ROWS));
982
1034
  }
983
- else if (key.leftArrow || key.rightArrow) {
1035
+ else if ((key.leftArrow || key.rightArrow) && !isTextRow(editRow)) {
984
1036
  const d = key.leftArrow ? -1 : 1;
985
1037
  // Scorrimento CICLICO (wrap) e non clampato: le liste sono di 3-4 voci,
986
1038
  // arrivare in fondo e ripartire costa meno di invertire direzione.
@@ -993,12 +1045,20 @@ function Deck({ cwd, tasksPath, tasksDir }) {
993
1045
  : e);
994
1046
  }
995
1047
  }
996
- else if (editRow === 2) {
1048
+ else if (isTextRow(editRow)) {
1049
+ // Un solo ramo per i due campi di testo, la riga sceglie la chiave:
1050
+ // duplicarlo significherebbe tenere allineate a mano due copie della
1051
+ // stessa grammatica di input a ogni tasto aggiunto.
1052
+ const field = editRow === 2 ? 'detail' : 'title';
997
1053
  if (key.backspace || key.delete) {
998
- setEdit((e) => (e ? { ...e, detail: e.detail.slice(0, -1) } : e));
1054
+ setEdit((e) => (e ? { ...e, [field]: e[field].slice(0, -1) } : e));
999
1055
  }
1000
1056
  else if (input && !key.ctrl && !key.meta) {
1001
- setEdit((e) => (e ? { ...e, detail: e.detail + input } : e));
1057
+ // `sanitizeTyped`: `useInput` consegna il CHUNK di stdin, quindi un
1058
+ // incollaggio porta dentro newline e byte di controllo — invisibili
1059
+ // nel campo ma contati da Ink nella larghezza della riga, e destinati
1060
+ // a finire tali e quali dentro tasks.md.
1061
+ setEdit((e) => (e ? { ...e, [field]: e[field] + sanitizeTyped(input) } : e));
1002
1062
  }
1003
1063
  }
1004
1064
  return;
@@ -1285,7 +1345,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1285
1345
  if (budget.compact) {
1286
1346
  return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? 'spot', " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
1287
1347
  }
1288
- 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, wrap: "truncate-end", 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, wrap: "truncate-end", 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, wrap: "truncate-end", 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 === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", 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, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : canResume ? 'resume' : '—', canResume ? ' · f fork' : '', canPin ? ' · p pin · N nota' : '', " \u00B7 ^F cerca \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 t term \u00B7 c claude \u00B7 q esci \u00B7 focus:", ' ', _jsx(Text, { color: "cyan", children: focus })] })), mode === 'normal' && launch.length > 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["launch ", legend.shown, legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, 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 === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _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: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1348
+ 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, wrap: "truncate-end", 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, wrap: "truncate-end", 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, wrap: "truncate-end", 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 === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", 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: "testo" }), " su prog/titolo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : canResume ? 'resume' : '—', canResume ? ' · f fork' : '', canPin ? ' · p pin · N nota' : '', " \u00B7 ^F cerca \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 t term \u00B7 c claude \u00B7 q esci \u00B7 focus:", ' ', _jsx(Text, { color: "cyan", children: focus })] })), mode === 'normal' && launch.length > 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["launch ", legend.shown, legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, 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 === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _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, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1289
1349
  }
1290
1350
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
1291
1351
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -1307,13 +1367,47 @@ function FilterModal({ view, cursor }) {
1307
1367
  return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ", sanitize(e.glyph)] }, e.name));
1308
1368
  })] }, row.label)))] }));
1309
1369
  }
1370
+ /**
1371
+ * Taglio dalla TESTA: tiene la CODA della stringa dentro `cols`.
1372
+ *
1373
+ * `cut()` fa l'opposto (tiene l'inizio) ed è giusto per un testo che si LEGGE;
1374
+ * qui il testo si SCRIVE, il cursore sta in fondo (append-only come gli altri
1375
+ * campi del deck) e ciò che serve vedere è l'ultima parola digitata, non la
1376
+ * prima — con `cut()` un titolo lungo sparirebbe sotto l'ellissi proprio mentre
1377
+ * lo si sta scrivendo. Budget in COLONNE (`termWidth`), non in code unit: è
1378
+ * quello che decide se la riga sfonda il bordo del box.
1379
+ */
1380
+ function tailCut(s, cols) {
1381
+ if (cols <= 0)
1382
+ return '';
1383
+ if (termWidth(s) <= cols)
1384
+ return s;
1385
+ const chars = [...s];
1386
+ let out = '';
1387
+ let w = 1; // la colonna dell'ellissi di testa
1388
+ for (let i = chars.length - 1; i >= 0; i--) {
1389
+ const cw = termWidth(chars[i]);
1390
+ if (w + cw > cols)
1391
+ break;
1392
+ out = chars[i] + out;
1393
+ w += cw;
1394
+ }
1395
+ return `…${out}`;
1396
+ }
1310
1397
  // T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
1311
1398
  // coprirli: la riga che stai modificando resta visibile sopra la lista).
1312
1399
  // La riga di anteprima mostra il testo ESATTO che finirà nel campo `Progress`
1313
1400
  // del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
1314
- function EditModal({ id, draft, row }) {
1401
+ function EditModal({ id, draft, row, columns, }) {
1315
1402
  const mark = (r) => (row === r ? CARET : CARET_OFF);
1316
- 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: [' ', sanitize(PRI_GLYPH[p]), " ", PRI_LABEL[p]] }, p)))] }), _jsxs(Text, { children: [mark(1), _jsx(Text, { dimColor: true, children: "stato" }), EDIT_PROG.map((p) => (_jsxs(Text, { inverse: draft.prog === p, color: draft.prog === p ? 'green' : 'gray', children: [' ', sanitize(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(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, wrap: "truncate-end", children: ["\u21B3 ", sanitize(progressText(draft.prog, draft.detail))] })] }));
1403
+ // Budget del campo titolo, DERIVATO da `columns` (mai una costante): il box
1404
+ // del modale è ANNIDATO nella cornice del deck, quindi le cornici da scalare
1405
+ // sono due — root (bordo 2 + paddingX 2) e modale (bordo 2 + paddingX 2) — più
1406
+ // caret 2, etichetta 6, gap 2 e cursore 1. Totale 19.
1407
+ // Un titolo di tasks.md arriva a ~64 caratteri: senza taglio la riga va a capo
1408
+ // dentro il box, che si alza di una riga e sfonda il budget verticale (invariante ③).
1409
+ const titleBudget = Math.max(8, columns - 19);
1410
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 titolo, priorit\u00E0 e stato"] }), _jsxs(Text, { children: [mark(0), _jsx(Text, { dimColor: true, children: "pri " }), EDIT_PRI.map((p) => (_jsxs(Text, { inverse: draft.pri === p, color: draft.pri === p ? 'green' : 'gray', children: [' ', sanitize(PRI_GLYPH[p]), " ", PRI_LABEL[p]] }, p)))] }), _jsxs(Text, { children: [mark(1), _jsx(Text, { dimColor: true, children: "stato " }), EDIT_PROG.map((p) => (_jsxs(Text, { inverse: draft.prog === p, color: draft.prog === p ? 'green' : 'gray', children: [' ', sanitize(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(Text, { dimColor: true, children: "prog " }), _jsxs(Text, { children: [' ', sanitize(draft.detail)] }), row === 2 ? _jsx(Text, { inverse: true, children: " " }) : null, !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { children: [mark(3), _jsx(Text, { dimColor: true, children: "titolo" }), _jsxs(Text, { children: [' ', sanitize(tailCut(draft.title, titleBudget))] }), row === 3 ? _jsx(Text, { inverse: true, children: " " }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", sanitize(progressText(draft.prog, draft.detail))] })] }));
1317
1411
  }
1318
1412
  // T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
1319
1413
  // caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
package/dist/task-edit.js CHANGED
@@ -75,18 +75,27 @@ export function initialDetail(current, prog, date = today()) {
75
75
  return stripProgGlyph(s);
76
76
  }
77
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.
78
+ * Riscrive le celle Pri (col 2), Prog (col 4) e se `desc` è passata la
79
+ * descrizione (col 5) della riga `| Tnn | |` in tasks.md. Solo la PRIMA riga
80
+ * con quell'id l'overview è unica, un secondo match sarebbe un duplicato da
81
+ * non propagare. Le celle non toccate restano i token grezzi originali: nessun
82
+ * re-flow della tabella, il diff resta di una riga. `ok:false` = id assente →
83
+ * il chiamante non scrive nulla.
84
+ *
85
+ * `desc` undefined ≠ `desc` vuota: la prima non tocca la cella (chiamata
86
+ * legacy, solo pri/prog), la seconda la svuota davvero.
87
+ *
88
+ * La descrizione è l'ULTIMA colonna e può contenere `|` — che in una tabella
89
+ * markdown spezza la cella. Riscriverla significa quindi collassare tutte le
90
+ * celle da 5 fino alla penultima in una sola (`splice`), altrimenti i pezzi
91
+ * della vecchia descrizione resterebbero appesi in coda alla nuova.
83
92
  *
84
93
  * L'id deve rispettare `TASK_ID_RE` (importato da tasks.ts — stesso gate di
85
94
  * `parseTasks`, non una copia): senza, un id arbitrario matcherebbe la riga di
86
95
  * HEADER (`| ID | Pri | K | Prog |`) o quella di separatore, riscrivendone le
87
96
  * celle e sfondando la tabella.
88
97
  */
89
- export function updateTasksMdRow(content, id, priGlyph, progGlyph) {
98
+ export function updateTasksMdRow(content, id, priGlyph, progGlyph, desc) {
90
99
  if (!TASK_ID_RE.test(id))
91
100
  return { content, ok: false };
92
101
  let ok = false;
@@ -103,20 +112,49 @@ export function updateTasksMdRow(content, id, priGlyph, progGlyph) {
103
112
  ok = true;
104
113
  cells[2] = ` ${priGlyph} `;
105
114
  cells[4] = ` ${progGlyph} `;
115
+ if (desc !== undefined) {
116
+ // Da cells[5] fino alla penultima: l'ultima è la coda dopo il `|` finale
117
+ // (stringa vuota su una riga ben formata) e va conservata, o la riga
118
+ // perderebbe il proprio bordo destro.
119
+ cells.splice(5, cells.length - 6, ` ${sanitizeCell(desc)} `);
120
+ }
106
121
  return cells.join('|');
107
122
  });
108
123
  return { content: ok ? lines.join('\n') : content, ok };
109
124
  }
110
125
  /**
111
- * Riscrive i bullet header `- **Priority**:` e `- **Progress**:` del task file.
126
+ * Rende un testo scrivibile dentro una CELLA di tabella markdown. Due caratteri
127
+ * la romperebbero e non sono rappresentabili altrimenti su una riga sola:
128
+ * il `|` (chiude la cella) e l'a-capo (chiude la riga). Il primo va escapato,
129
+ * il secondo collassato a spazio. È l'unico posto dove il titolo viene toccato:
130
+ * nel task file, che è markdown libero, va scritto verbatim.
131
+ */
132
+ function sanitizeCell(s) {
133
+ return s.replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim();
134
+ }
135
+ /**
136
+ * Riscrive i bullet header `- **Priority**:` e `- **Progress**:` del task file,
137
+ * più — se `title` è passato — l'H1 di testa.
112
138
  * First-match-wins per chiave, stessa regola di `parseTaskDetail`: se un campo
113
139
  * ricompare nel body (residuo template) vince quello dell'header — così ciò che
114
140
  * il deck mostra e ciò che scrive restano la stessa riga.
141
+ *
142
+ * L'H1 conserva il proprio CAPPELLO (`# Task: …` code, `# Doc Task: …` doc):
143
+ * `parseTaskDetail` lo strippa in lettura, quindi il titolo che arriva dal
144
+ * modale non ce l'ha e riscrivere la riga nuda perderebbe la categoria. Il
145
+ * prefisso si rilegge dalla riga esistente e si ri-antepone tale e quale — così
146
+ * una T resta `# Task:` e una D resta `# Doc Task:` senza doverlo sapere qui.
115
147
  */
116
- export function updateTaskFileFields(content, priLabel, progress) {
148
+ export function updateTaskFileFields(content, priLabel, progress, title) {
117
149
  let priDone = false;
118
150
  let progDone = false;
151
+ let titleDone = false;
119
152
  const lines = content.split('\n').map((line) => {
153
+ if (title !== undefined && !titleDone && /^#\s+/.test(line)) {
154
+ titleDone = true;
155
+ const prefix = line.match(/^#\s+((?:Doc\s+)?Task:\s*)/)?.[1] ?? '';
156
+ return `# ${prefix}${title.replace(/\s+/g, ' ').trim()}`;
157
+ }
120
158
  if (!priDone && /^-\s*\*\*Priority\*\*:/.test(line)) {
121
159
  priDone = true;
122
160
  return `- **Priority**: ${priLabel}`;
@@ -127,7 +165,7 @@ export function updateTaskFileFields(content, priLabel, progress) {
127
165
  }
128
166
  return line;
129
167
  });
130
- const ok = priDone || progDone;
168
+ const ok = priDone || progDone || titleDone;
131
169
  return { content: ok ? lines.join('\n') : content, ok };
132
170
  }
133
171
  /**
@@ -138,10 +176,10 @@ export function updateTaskFileFields(content, priLabel, progress) {
138
176
  * si scrive ciò che esiste e il risultato dice cosa è stato toccato.
139
177
  */
140
178
  export function writeTaskEdit(input) {
141
- const { tasksPath, tasksDir, id, pri, prog, detail } = input;
179
+ const { tasksPath, tasksDir, id, pri, prog, detail, title } = input;
142
180
  const progress = progressText(prog, detail);
143
181
  const paths = [];
144
- const row = updateTasksMdRow(readFileSync(tasksPath, 'utf8'), id, PRI_GLYPH[pri], PROG_GLYPH[prog]);
182
+ const row = updateTasksMdRow(readFileSync(tasksPath, 'utf8'), id, PRI_GLYPH[pri], PROG_GLYPH[prog], title);
145
183
  if (row.ok) {
146
184
  writeFileSync(tasksPath, row.content, 'utf8');
147
185
  paths.push(tasksPath);
@@ -149,7 +187,7 @@ export function writeTaskEdit(input) {
149
187
  let fileUpdated = false;
150
188
  const taskFile = findTaskFile(tasksDir, id);
151
189
  if (taskFile) {
152
- const upd = updateTaskFileFields(readFileSync(taskFile, 'utf8'), PRI_LABEL[pri], progress);
190
+ const upd = updateTaskFileFields(readFileSync(taskFile, 'utf8'), PRI_LABEL[pri], progress, title);
153
191
  if (upd.ok) {
154
192
  writeFileSync(taskFile, upd.content, 'utf8');
155
193
  paths.push(taskFile);
package/dist/tasks.js CHANGED
@@ -52,7 +52,7 @@ export function parseTasks(content) {
52
52
  // il glifo, e `task-edit` lo riscrive in tasks.md. Sanificarli qui
53
53
  // renderebbe `✔` un `✅` anche su disco, cambiando il formato di famiglia.
54
54
  // La sanificazione di quei due avviene al display (`displayProg`).
55
- tasks.push({ id, pri, prog, desc: sanitize(desc) });
55
+ tasks.push({ id, pri, prog, desc: sanitize(desc), rawDesc: desc });
56
56
  }
57
57
  return tasks;
58
58
  }
package/dist/viewport.js CHANGED
@@ -41,7 +41,7 @@ export const MODAL_HEIGHT = {
41
41
  note: 4, // T53 — gemello di create: marginTop + 2 bordi + 1 riga input
42
42
  sort: 5, // marginTop + 2 bordi + titolo + 1 riga catena
43
43
  filter: 6, // marginTop + 2 bordi + titolo + 2 righe (pri, stato)
44
- edit: 8, // marginTop + 2 bordi + titolo + 3 campi + riga anteprima
44
+ edit: 9, // marginTop + 2 bordi + titolo + 4 campi (pri, stato, prog, titolo) + riga anteprima
45
45
  // T52 — search e reader sono gli unici modali NON in flusso: sostituiscono i
46
46
  // due pane invece di spingerli giù (una lista di occorrenze non entra in un
47
47
  // box sopra il deck). Costo 0 nel budget dei pane perché quel budget non
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.18.1",
3
+ "version": "0.19.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": {