@lamemind/loom-deck 0.18.0 → 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 +117 -23
- package/dist/sessions.js +9 -2
- package/dist/task-edit.js +50 -12
- package/dist/task-index.js +8 -1
- package/dist/tasks.js +1 -1
- package/dist/viewport.js +1 -1
- package/package.json +1 -1
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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({
|
|
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
|
|
965
|
-
// (←→ scorre),
|
|
966
|
-
//
|
|
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 +
|
|
1030
|
+
setEditRow((r) => ((r + EDIT_ROWS - 1) % EDIT_ROWS));
|
|
979
1031
|
}
|
|
980
1032
|
else if (key.downArrow) {
|
|
981
|
-
setEditRow((r) => ((r + 1) %
|
|
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
|
|
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,
|
|
1054
|
+
setEdit((e) => (e ? { ...e, [field]: e[field].slice(0, -1) } : e));
|
|
999
1055
|
}
|
|
1000
1056
|
else if (input && !key.ctrl && !key.meta) {
|
|
1001
|
-
|
|
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
|
|
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
|
-
|
|
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/sessions.js
CHANGED
|
@@ -231,8 +231,15 @@ export function parseTranscript(content, path, mtime, sizeBytes) {
|
|
|
231
231
|
sizeBytes,
|
|
232
232
|
turns,
|
|
233
233
|
customTitle,
|
|
234
|
-
|
|
235
|
-
|
|
234
|
+
// Sanificati come il titolo, e per lo stesso motivo: le due preview del
|
|
235
|
+
// detail pane sono testo di transcript messo NEL FRAME. Erano l'ultimo
|
|
236
|
+
// varco rimasto — un `✅` (BMP largo 2) nell'estratto prende una cella
|
|
237
|
+
// sola nella griglia di Ink e due colonne sul terminale, quindi la riga
|
|
238
|
+
// scivola a destra e si mangia il bordo del pane. Qui il confine è lo
|
|
239
|
+
// stesso di `title`, non il render: `wrapLines` conta le colonne su questo
|
|
240
|
+
// testo, sanificare a valle sposterebbe l'a-capo già calcolato.
|
|
241
|
+
firstPrompt: sanitize(firstUserText),
|
|
242
|
+
lastReply: sanitize(lastAssistantText),
|
|
236
243
|
bodies,
|
|
237
244
|
};
|
|
238
245
|
}
|
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)
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* restano i token grezzi originali: nessun
|
|
82
|
-
* di una riga. `ok:false` = id assente →
|
|
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
|
-
*
|
|
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/task-index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
+
import { sanitize } from './width.js';
|
|
3
4
|
export function taskIndexPath(projectRoot) {
|
|
4
5
|
return join(projectRoot, '.claude', 'loom', 'session-tasks.jsonl');
|
|
5
6
|
}
|
|
@@ -60,8 +61,14 @@ export function loadSessionIndex(projectRoot) {
|
|
|
60
61
|
// nota vuota da mostrare. Il `typeof` esclude i record senza il campo, che
|
|
61
62
|
// non devono toccare una nota scritta da un record precedente.
|
|
62
63
|
if (typeof d.note === 'string') {
|
|
64
|
+
// Sanificata in lettura, non solo alla digitazione: `sanitizeTyped`
|
|
65
|
+
// toglie i byte di controllo ma non ripara la larghezza, e il file è
|
|
66
|
+
// editabile a mano — una nota con un `✅` finirebbe nel frame larga il
|
|
67
|
+
// doppio di quanto Ink ha contato. Il round-trip (edit di una nota già
|
|
68
|
+
// sanificata → riscrittura del sostituto) è il prezzo accettato: a
|
|
69
|
+
// schermo il glifo originale non era comunque disegnabile.
|
|
63
70
|
if (d.note)
|
|
64
|
-
notes.set(d.sessionId, d.note);
|
|
71
|
+
notes.set(d.sessionId, sanitize(d.note));
|
|
65
72
|
else
|
|
66
73
|
notes.delete(d.sessionId);
|
|
67
74
|
}
|
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:
|
|
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