@lamemind/loom-deck 0.49.0 → 0.50.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 +13 -0
- package/dist/cli.js +102 -15
- package/dist/config.js +11 -2
- package/dist/mouse.js +179 -0
- package/dist/project-status.js +17 -1
- package/dist/spawn.js +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -123,6 +123,19 @@ Le emoji sono quelle del menu compass. Per il terminale compass usa 🖥️, che
|
|
|
123
123
|
|
|
124
124
|
`t` e `c` sono gemelle: entrambe aprono una surface del cappello nella stessa finestra Ptyxis, senza passare da un modale. `c` (minuscola, azione) e `C` (maiuscola, modale create-task) restano distinte per la regola sopra — così come `f` (fork) e `F` (filtri).
|
|
125
125
|
|
|
126
|
+
### Il mouse
|
|
127
|
+
|
|
128
|
+
Le voci della riga surface sono **cliccabili**: un click su `t 💻`, su `c 🤖` o su una voce `launch` fa la stessa cosa del tasto che le sta scritto sopra. Non c'è una semantica nuova da imparare, e non c'è doppio-click — il protocollo non lo prevede, andrebbe ricostruito con un timer. Le liste (task, conversazioni, viste dell'header) **non** rispondono ancora al mouse, né per selezionare né per attivare.
|
|
129
|
+
|
|
130
|
+
Il click è l'unico gesto tracciato: modi `1000` + `1006`, niente movimento, niente drag, niente hover. I modi che li porterebbero (`1002`, `1003`) generano una valanga di eventi che sotto render pesante si frammentano su `stdin` e leakano dentro i campi di testo — è una classe di difetto osservata in più TUI. Con `1000`+`1006` gli eventi sono due per click, pressione e rilascio, e il deck agisce solo sulla pressione.
|
|
131
|
+
|
|
132
|
+
Il tracking si spegne su ogni via d'uscita: uscita normale, `^C`, segnale, crash. Un tracking lasciato acceso non è un difetto cosmetico — il terminale continua a mandare sequenze a qualunque programma prenda il posto del deck, che se le ritrova stampate come testo.
|
|
133
|
+
|
|
134
|
+
Due conseguenze visibili di come è fatto:
|
|
135
|
+
|
|
136
|
+
- **Il deck pulisce lo schermo all'avvio.** Non è cosmesi: Ink non posiziona mai il cursore in modo assoluto, quindi il frame nasce dove capitava il cursore all'avvio del processo e una coordinata del mouse non sarebbe traducibile in una riga del frame. Pulire e tornare in alto a sinistra pinna il frame a riga 1, e da lì non si sposta più — il frame è alto al massimo `rows - 1`, quindi non fa mai scorrere lo schermo.
|
|
137
|
+
- **Incollare un testo che contenga `[<0;5;3M` viene letto come un click.** Ink consegna le sequenze mouse a `useInput` come testo normale, con l'`ESC` iniziale già tolto: non resta niente che distingua la sequenza vera da quella scritta a mano.
|
|
138
|
+
|
|
126
139
|
### `CANC` — eliminare task
|
|
127
140
|
|
|
128
141
|
Il deck **ordina** la potatura, non la esegue: la fa `loom-works:clean-tasks`, invocato da un processo Claude headless. Nessun `git rm` e nessuna riscrittura di `tasks.md` vivono qui — quella sequenza (task file + folder dot-prefixed + riga in `tasks.md`, un commit atomico per task, symlink `current-task.md` rimosso, righe orfane riconciliate) è implementata una volta sola, e averne una seconda darebbe due rimozioni capaci di divergere.
|
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,8 @@ import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskFileText, } from
|
|
|
7
7
|
import { rowIndexOfKey, selectedRow, } from './search.js';
|
|
8
8
|
import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, } from './task-index.js';
|
|
9
9
|
import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, selectedSession, unpinLandingId, } from './session-list.js';
|
|
10
|
-
import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch, } from './config.js';
|
|
10
|
+
import { cellWidth, launchLegend, LAUNCH_SEP, loadArchivableDays, loadIdentity, loadLaunch, } from './config.js';
|
|
11
|
+
import { anchorFrame, enableMouse, FRAME_TEXT_COL, hitRegion, isWheel, LAUNCH_ROW, rowRegions, takeMouse, } from './mouse.js';
|
|
11
12
|
import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, TASK_VIEWS, } from './pane-views.js';
|
|
12
13
|
import { isCompact, layoutBudget, searchPreviewCapacity, windowRange, } from './viewport.js';
|
|
13
14
|
import { cut, cutMiddle, sanitize, termWidth } from './width.js';
|
|
@@ -40,6 +41,43 @@ import { VERSION } from './version.js';
|
|
|
40
41
|
// lo segnala. Vive a livello di modulo perché ha due lettori — il ramo che la
|
|
41
42
|
// scrive e il timer che la ritira, e quest'ultimo deve poterla riconoscere.
|
|
42
43
|
const QUIT_NOTE = `⚠ ^C di nuovo entro ${QUIT_WINDOW_MS / 1000}s per chiudere il deck`;
|
|
44
|
+
// Le due surface built-in del cappello, in testa alla riga launch. Non stanno
|
|
45
|
+
// fra i tasti perché hanno la stessa natura delle voci `launch` — fire-once,
|
|
46
|
+
// cwd = project root, nessuno stato — e la differenza è solo che sono
|
|
47
|
+
// universali (nessun progetto le dichiara) invece che custom. Emoji del menu
|
|
48
|
+
// compass: 🤖 = nuova sessione claude. Per il terminale compass usa 🖥️, che nel
|
|
49
|
+
// frame Ink NON passa — `sanitize` lo sostituisce (VTE lo disegna largo 1,
|
|
50
|
+
// string-width dice 2: discordante, invariante ① di width.ts) e resterebbe un
|
|
51
|
+
// `·` muto. 💻 è il gemello concorde; il `sanitize` qui rende il vincolo
|
|
52
|
+
// automatico invece che da ricordare.
|
|
53
|
+
//
|
|
54
|
+
// T21 — `key` è il tasto che la superficie rappresenta: un click su di essa
|
|
55
|
+
// entra nell'handler di tastiera con quel tasto, invece di chiamare l'azione
|
|
56
|
+
// per conto proprio. È ciò che tiene click e tasto per costruzione allineati.
|
|
57
|
+
const SURFACE_SEGMENTS = [
|
|
58
|
+
{ key: 't', text: sanitize('t 💻') },
|
|
59
|
+
{ key: 'c', text: sanitize('c 🤖') },
|
|
60
|
+
];
|
|
61
|
+
// T21 — il `Key` che accompagna un tasto sintetizzato da un click: nessun
|
|
62
|
+
// modificatore, nessun tasto speciale. Deve elencare ogni campo, o i rami che
|
|
63
|
+
// leggono `key.ctrl` o `key.tab` riceverebbero `undefined` invece di `false` —
|
|
64
|
+
// equivalente nel test di verità, ma non nel tipo.
|
|
65
|
+
const NO_MODIFIERS = {
|
|
66
|
+
upArrow: false,
|
|
67
|
+
downArrow: false,
|
|
68
|
+
leftArrow: false,
|
|
69
|
+
rightArrow: false,
|
|
70
|
+
pageDown: false,
|
|
71
|
+
pageUp: false,
|
|
72
|
+
return: false,
|
|
73
|
+
escape: false,
|
|
74
|
+
ctrl: false,
|
|
75
|
+
shift: false,
|
|
76
|
+
tab: false,
|
|
77
|
+
backspace: false,
|
|
78
|
+
delete: false,
|
|
79
|
+
meta: false,
|
|
80
|
+
};
|
|
43
81
|
function Deck({ cwd, tasksPath, tasksDir }) {
|
|
44
82
|
const { tasks, loadError } = useTasks(tasksPath);
|
|
45
83
|
// `notes` esce dall'indice come `sessionNotes`: in questo componente `note` è
|
|
@@ -956,7 +994,49 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
956
994
|
edit: onEditKey,
|
|
957
995
|
purge: onPurgeKey,
|
|
958
996
|
};
|
|
959
|
-
|
|
997
|
+
/**
|
|
998
|
+
* T21 — il MOUSE precede tutto, in ogni modo del deck.
|
|
999
|
+
*
|
|
1000
|
+
* Il filtro è incondizionato — anche dentro un campo di testo, anche in un
|
|
1001
|
+
* modo che non ha nessuna superficie cliccabile — e non è una precauzione:
|
|
1002
|
+
* `useInput` riceve le sequenze SGR come testo (`mouse.ts`, fatto ①), quindi
|
|
1003
|
+
* senza filtro un click battuto mentre è aperto il campo del titolo ci
|
|
1004
|
+
* scriverebbe dentro `[<0;5;3M`. Un listener raw su stdin non risolverebbe:
|
|
1005
|
+
* riceve gli stessi chunk in broadcast e non li sottrae a `useInput`.
|
|
1006
|
+
*
|
|
1007
|
+
* Il chunk MISTO — un tasto e un click nella stessa scrittura, che arriva
|
|
1008
|
+
* come una sola chiamata — si separa a mano: gli eventi vanno al mouse, il
|
|
1009
|
+
* testo residuo prosegue verso la tastiera. Solo un chunk di solo mouse
|
|
1010
|
+
* chiude qui.
|
|
1011
|
+
*/
|
|
1012
|
+
useInput((raw, key) => {
|
|
1013
|
+
const { text, events } = takeMouse(raw);
|
|
1014
|
+
for (const ev of events)
|
|
1015
|
+
onMouse(ev);
|
|
1016
|
+
if (events.length > 0 && !text)
|
|
1017
|
+
return;
|
|
1018
|
+
onKey(text, key);
|
|
1019
|
+
});
|
|
1020
|
+
function onMouse(ev) {
|
|
1021
|
+
// Un click produce due eventi (pressione e rilascio): agire su entrambi
|
|
1022
|
+
// spawnerebbe due volte. La rotella è mandata 2 — qui scartata, non
|
|
1023
|
+
// silenziosamente equiparata a un click. Il bottone si legge sui due bit
|
|
1024
|
+
// bassi, perché gli alti portano i modificatori (shift/meta/ctrl).
|
|
1025
|
+
if (!ev.press || isWheel(ev.button) || (ev.button & 3) !== 0)
|
|
1026
|
+
return;
|
|
1027
|
+
// Le superfici esistono solo nella lista: un modo capturing prende il
|
|
1028
|
+
// frame intero e a quella riga c'è dell'altro.
|
|
1029
|
+
if (mode !== 'normal' || ev.row !== LAUNCH_ROW)
|
|
1030
|
+
return;
|
|
1031
|
+
const hit = hitRegion(launchRegions, ev.col);
|
|
1032
|
+
// Il click NON chiama l'azione: rientra dalla porta della tastiera col
|
|
1033
|
+
// tasto che la superficie annuncia. Un ramo nuovo su `t`/`c`/cifre nasce
|
|
1034
|
+
// così già cliccabile, e non esiste un secondo posto in cui il click possa
|
|
1035
|
+
// dire una cosa diversa dal tasto che gli sta scritto sopra.
|
|
1036
|
+
if (hit)
|
|
1037
|
+
onKey(hit, NO_MODIFIERS);
|
|
1038
|
+
}
|
|
1039
|
+
function onKey(input, key) {
|
|
960
1040
|
// T116 — `^C` sta SOPRA il dispatch dei modi, unico tasto a scavalcarlo.
|
|
961
1041
|
// Ogni altra combo `ctrl` è un acceleratore, e un acceleratore dentro un
|
|
962
1042
|
// modo capturing dev'essere inerte (`input-modes.ts`); questo non è un
|
|
@@ -1251,27 +1331,27 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1251
1331
|
// chiuderlo, perché quel ramo esce prima di qui. L'unica uscita da tastiera
|
|
1252
1332
|
// è `^C` battuto due volte (T116, in testa a questo handler); resta poi la
|
|
1253
1333
|
// chiusura della tab Ptyxis che ospita il processo.
|
|
1254
|
-
}
|
|
1334
|
+
}
|
|
1255
1335
|
const canSpawn = focus === 'tasks' && selTask !== null;
|
|
1256
1336
|
const canResume = focus === 'sessions' && selSessionObj !== null;
|
|
1257
1337
|
// T50 — il pin agisce su qualunque riga selezionata (anche stale, per
|
|
1258
1338
|
// spinnarla); basta il focus sul pane e una selezione.
|
|
1259
1339
|
const canPin = focus === 'sessions' && selSessionId !== null;
|
|
1260
|
-
|
|
1261
|
-
// fra i tasti: hanno la stessa natura delle voci `launch` — fire-once, cwd =
|
|
1262
|
-
// project root, nessuno stato — e la differenza è solo che sono universali
|
|
1263
|
-
// (nessun progetto le dichiara) invece che custom. Emoji del menu compass:
|
|
1264
|
-
// 🤖 = nuova sessione claude. Per il terminale compass usa 🖥️, che nel frame
|
|
1265
|
-
// Ink NON passa — `sanitize` lo sostituisce (VTE lo disegna largo 1,
|
|
1266
|
-
// string-width dice 2: discordante, invariante ① di width.ts) e resterebbe un
|
|
1267
|
-
// `·` muto. 💻 è il gemello concorde; il `sanitize` qui rende il vincolo
|
|
1268
|
-
// automatico invece che da ricordare.
|
|
1269
|
-
const surfaceLegend = sanitize('t 💻 · c 🤖');
|
|
1340
|
+
const surfaceLegend = SURFACE_SEGMENTS.map((s) => s.text).join(LAUNCH_SEP);
|
|
1270
1341
|
// Larghezza dal medesimo hook che dà l'altezza: dopo un resize la legenda si
|
|
1271
1342
|
// ricalcola con lo stesso re-render che ridimensiona i pane. Le celle delle
|
|
1272
1343
|
// surface (più il ` · ` che le separa dalle voci) sono già spese sulla riga →
|
|
1273
1344
|
// vanno riservate, o le voci launch la sfonderebbero di quel tanto.
|
|
1274
|
-
const legend = launchLegend(launch, columns, cellWidth(surfaceLegend) +
|
|
1345
|
+
const legend = launchLegend(launch, columns, cellWidth(surfaceLegend) + cellWidth(LAUNCH_SEP));
|
|
1346
|
+
// T21 — la riga launch come DATO, non come stringa: gli stessi segmenti
|
|
1347
|
+
// compongono il testo renderizzato e le colonne dell'hit-test. Derivare le
|
|
1348
|
+
// seconde ri-splittando il primo sarebbe un conto parallelo, che diverge alla
|
|
1349
|
+
// prima label che contenga il separatore.
|
|
1350
|
+
const launchSegments = [
|
|
1351
|
+
...SURFACE_SEGMENTS,
|
|
1352
|
+
...legend.taken.map((text, i) => ({ key: String(i + 1), text })),
|
|
1353
|
+
];
|
|
1354
|
+
const launchRegions = rowRegions(launchSegments, LAUNCH_SEP, FRAME_TEXT_COL);
|
|
1275
1355
|
// Legenda della modalità normale. Elenca SOLO i tasti che fanno qualcosa qui e
|
|
1276
1356
|
// ora: le voci contestuali compaiono quando il pane a fuoco le rende possibili
|
|
1277
1357
|
// e altrimenti spariscono, invece di annunciarsi inerti con un `—`.
|
|
@@ -1446,7 +1526,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1446
1526
|
if (budget.compact) {
|
|
1447
1527
|
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "v", VERSION, " \u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? parentLabel, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
|
|
1448
1528
|
}
|
|
1449
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(StatusHeadline, { name: projectCore ?? projectName, label: status.label, building: status.building, failed: status.failed, cols: Math.max(4, columns - 4 - `v${VERSION}`.length - 1) }), _jsxs(Text, { dimColor: true, children: ["v", VERSION] })] }), 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: ["titolo 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 === 'purge' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["elimina task \u00B7", ' ', purge?.ignored ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " keep/purge dei file non tracciati \u00B7", ' '] })) : null, _jsx(Text, { color: "yellow", children: "\u23CE" }), " conferma \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, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [
|
|
1529
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(StatusHeadline, { name: projectCore ?? projectName, label: status.label, building: status.building, failed: status.failed, cols: Math.max(4, columns - 4 - `v${VERSION}`.length - 1) }), _jsxs(Text, { dimColor: true, children: ["v", VERSION] })] }), 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: ["titolo 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 === 'purge' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["elimina task \u00B7", ' ', purge?.ignored ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " keep/purge dei file non tracciati \u00B7", ' '] })) : null, _jsx(Text, { color: "yellow", children: "\u23CE" }), " conferma \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, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [launchSegments.map((s) => s.text).join(LAUNCH_SEP), 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: editCursor.row, caret: editCursor.caret, columns: columns })) : null, mode === 'purge' && purge ? _jsx(PurgeModal, { draft: purge, columns: columns }) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, counts: taskCounts, activeView: taskViewId, paneCount: paneTasks.length, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, idW: taskCols.id, tailW: taskCols.tail, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: paneTasks.length - taskWin.end, columns: columns, data: taskRowData }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, counts: sessionCounts, activeView: sessionViewId, paneCount: sessionRows.length, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore, live: live })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '', live: live.get(selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
1450
1530
|
}
|
|
1451
1531
|
const cwd = process.cwd();
|
|
1452
1532
|
// T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
|
|
@@ -1454,6 +1534,13 @@ const cwd = process.cwd();
|
|
|
1454
1534
|
// osservabile. In raw mode `^C` non è un SIGINT — è il byte `\x03` nello
|
|
1455
1535
|
// stream di input, e chi lo consuma per primo decide. Da qui in poi lo consuma
|
|
1456
1536
|
// il deck, quindi l'uscita è tutta a carico del suo handler.
|
|
1537
|
+
// T21 — l'ordine dei tre passi è vincolante. `anchorFrame` va PRIMA del render:
|
|
1538
|
+
// pinna il frame a riga 1 e con lui l'origine dell'hit-test, che senza sarebbe
|
|
1539
|
+
// la posizione casuale del cursore all'avvio del processo. `enableMouse`
|
|
1540
|
+
// registra il ripristino del tracking sulle vie d'uscita, quindi prima che
|
|
1541
|
+
// esista qualcosa da cui uscire.
|
|
1542
|
+
anchorFrame();
|
|
1543
|
+
enableMouse();
|
|
1457
1544
|
render(_jsx(Deck, { cwd: cwd, tasksPath: resolveTasksPath(cwd), tasksDir: resolveTasksDir(cwd) }), {
|
|
1458
1545
|
exitOnCtrlC: false,
|
|
1459
1546
|
});
|
package/dist/config.js
CHANGED
|
@@ -48,6 +48,10 @@ export function loadLaunch(projectRoot) {
|
|
|
48
48
|
// dallo schema config (che ne ammette quante se ne vogliono) ma dai tasti
|
|
49
49
|
// disponibili. Le voci oltre la nona restano configurate e non raggiungibili.
|
|
50
50
|
export const LAUNCH_MAX = 9;
|
|
51
|
+
/** Separatore fra le voci della riga launch. Costante e non letterale sparso:
|
|
52
|
+
* lo usano il calcolo di quante voci entrano in riga, la resa, e l'hit-test
|
|
53
|
+
* del mouse — tre conti che devono misurare lo stesso spazio. */
|
|
54
|
+
export const LAUNCH_SEP = ' · ';
|
|
51
55
|
// Larghezza in celle terminale, approssimata: emoji astrali (U+1F000+) e simboli
|
|
52
56
|
// BMP portati a presentazione emoji occupano 2 colonne, il VS16 è un modificatore
|
|
53
57
|
// a larghezza 0, tutto il resto 1. Serve solo a decidere quante voci stanno in
|
|
@@ -85,7 +89,7 @@ export function launchLegend(entries, columns, reserved = 0) {
|
|
|
85
89
|
const taken = [];
|
|
86
90
|
let used = 0;
|
|
87
91
|
for (const p of parts) {
|
|
88
|
-
const cost = cellWidth(p) + (taken.length > 0 ?
|
|
92
|
+
const cost = cellWidth(p) + (taken.length > 0 ? cellWidth(LAUNCH_SEP) : 0);
|
|
89
93
|
if (used + cost > budget - reserve)
|
|
90
94
|
break;
|
|
91
95
|
taken.push(p);
|
|
@@ -98,7 +102,12 @@ export function launchLegend(entries, columns, reserved = 0) {
|
|
|
98
102
|
let taken = fit(0);
|
|
99
103
|
if (taken.length < parts.length)
|
|
100
104
|
taken = fit(10);
|
|
101
|
-
return {
|
|
105
|
+
return {
|
|
106
|
+
shown: taken.join(LAUNCH_SEP),
|
|
107
|
+
taken,
|
|
108
|
+
overflow: parts.length - taken.length,
|
|
109
|
+
unreachable,
|
|
110
|
+
};
|
|
102
111
|
}
|
|
103
112
|
// T61 — soglia d'età del contatore archiviabili, campo `archivableDays`.
|
|
104
113
|
//
|
package/dist/mouse.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// T21 — MOUSE del deck: abilitazione del tracking SGR, parse delle sequenze,
|
|
2
|
+
// hit-test sulle superfici cliccabili.
|
|
3
|
+
//
|
|
4
|
+
// Tre fatti misurati sotto pty reggono tutto il modulo, e ognuno spiega una
|
|
5
|
+
// scelta che altrimenti sembrerebbe arbitraria.
|
|
6
|
+
//
|
|
7
|
+
// ① `useInput` di Ink riceve le sequenze mouse come TESTO, con l'ESC iniziale
|
|
8
|
+
// strippato e nessun flag di `key` attivo: un click arriva come `[<0;5;3M`.
|
|
9
|
+
// Un listener `data` raw agganciato a stdin accanto a Ink riceve gli stessi
|
|
10
|
+
// identici chunk (i listener `data` sono broadcast) e NON impedisce che le
|
|
11
|
+
// sequenze raggiungano comunque `useInput`. Il filtro dev'essere quindi
|
|
12
|
+
// dentro `useInput` in ogni caso — da cui: un punto solo, non due.
|
|
13
|
+
// ② L'ESC è strippato solo se la sequenza APRE il chunk. In un chunk misto
|
|
14
|
+
// tasto+mouse (`a\x1b[<0;7;2M`, una sola chiamata) l'ESC interno resta.
|
|
15
|
+
// Il pattern lo rende quindi opzionale e lo consuma quando c'è, o
|
|
16
|
+
// resterebbe un ESC orfano nel testo restituito al deck.
|
|
17
|
+
// ③ Modi di tracking `1000` (click) + `1006` (SGR) e basta. `1002` (drag) e
|
|
18
|
+
// `1003` (ogni movimento) sono la sorgente documentata delle valanghe di
|
|
19
|
+
// eventi che sotto render pesante si frammentano su stdin e leakano dentro
|
|
20
|
+
// i campi di testo. Con soli 1000+1006 gli eventi sono due per click.
|
|
21
|
+
import { termWidth } from './width.js';
|
|
22
|
+
/**
|
|
23
|
+
* Sequenza SGR: `ESC [ < b ; x ; y M|m`, con l'ESC opzionale (fatto ①).
|
|
24
|
+
*
|
|
25
|
+
* Il prezzo dell'ESC opzionale è che un INCOLLAGGIO della stringa letterale
|
|
26
|
+
* `[<0;5;3M` dentro un campo di testo verrebbe letto come un click. È accettato
|
|
27
|
+
* e non compensato: distinguerlo richiederebbe di fidarsi dell'ESC, che nel
|
|
28
|
+
* caso più comune — la sequenza che apre il chunk — non c'è.
|
|
29
|
+
*/
|
|
30
|
+
const SGR_MOUSE = /\x1b?\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
31
|
+
/** `true` se il codice è una pressione di rotella (bit 6 del bottone). */
|
|
32
|
+
export function isWheel(button) {
|
|
33
|
+
return (button & 64) !== 0;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Separa le sequenze mouse dal testo di un chunk di `useInput`.
|
|
37
|
+
*
|
|
38
|
+
* Ritorna il testo RIPULITO (quello che il deck deve continuare a trattare come
|
|
39
|
+
* input di tastiera) e gli eventi trovati, nell'ordine di arrivo.
|
|
40
|
+
*
|
|
41
|
+
* Una sequenza spezzata su due chunk — succede sotto render pesante — arriva
|
|
42
|
+
* come due frammenti monchi: nessuno dei due matcha, quindi entrambi finiscono
|
|
43
|
+
* nel testo ripulito. Il click va perso. È voluto: bufferizzare significherebbe
|
|
44
|
+
* tenere uno stato fra due chiamate di `useInput` e decidere quando scade, e un
|
|
45
|
+
* click perso costa una ripetizione mentre un buffer che non scade mai
|
|
46
|
+
* inghiotte i tasti che seguono.
|
|
47
|
+
*/
|
|
48
|
+
export function takeMouse(input) {
|
|
49
|
+
if (!input.includes('[<'))
|
|
50
|
+
return { text: input, events: [] };
|
|
51
|
+
const events = [];
|
|
52
|
+
const text = input.replace(SGR_MOUSE, (_all, b, x, y, kind) => {
|
|
53
|
+
events.push({
|
|
54
|
+
button: Number(b),
|
|
55
|
+
col: Number(x),
|
|
56
|
+
row: Number(y),
|
|
57
|
+
press: kind === 'M',
|
|
58
|
+
});
|
|
59
|
+
return '';
|
|
60
|
+
});
|
|
61
|
+
return { text, events };
|
|
62
|
+
}
|
|
63
|
+
// ── Tracking: accensione, spegnimento, ripristino ───────────────────────────
|
|
64
|
+
const ENABLE = '\x1b[?1000h\x1b[?1006h';
|
|
65
|
+
const DISABLE = '\x1b[?1006l\x1b[?1000l';
|
|
66
|
+
/**
|
|
67
|
+
* Ancoraggio del frame: pulisce lo schermo e riporta il cursore in alto a
|
|
68
|
+
* sinistra PRIMA del primo render di Ink.
|
|
69
|
+
*
|
|
70
|
+
* Senza, l'hit-test non ha origine. Ink non posiziona mai il cursore in modo
|
|
71
|
+
* assoluto (verificato sul flusso di byte: solo `\x1b[2K` e `\x1b[1A`, cioè
|
|
72
|
+
* `log-update` puro): il frame nasce dove si trovava il cursore quando il
|
|
73
|
+
* processo è partito — riga 1 in una tab appena aperta, più in basso dopo un
|
|
74
|
+
* prompt di shell, più in alto ancora se la prima scrittura ha fatto scorrere
|
|
75
|
+
* lo schermo. Una coordinata assoluta del mouse non sarebbe traducibile.
|
|
76
|
+
*
|
|
77
|
+
* Pinnato a riga 1 il frame ci RESTA, senza bisogno di ri-ancorarlo a ogni
|
|
78
|
+
* resize: `log-update` cancella all'insù tante righe quante ne aveva scritte e
|
|
79
|
+
* poi riscrive da lì, e il frame è alto al massimo `rows - 1` (lo garantisce lo
|
|
80
|
+
* SLACK di `viewport.ts`) — quindi la scrittura non fa mai scorrere lo schermo
|
|
81
|
+
* e la cancellazione riporta sempre il cursore a riga 1. È un punto fisso.
|
|
82
|
+
*/
|
|
83
|
+
export function anchorFrame(out = process.stdout) {
|
|
84
|
+
out.write('\x1b[2J\x1b[H');
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Accende il tracking e registra lo spegnimento su ogni via d'uscita.
|
|
88
|
+
*
|
|
89
|
+
* Un tracking lasciato acceso non è un difetto cosmetico: il terminale continua
|
|
90
|
+
* a mandare sequenze a qualunque programma prenda il posto del deck, che se le
|
|
91
|
+
* ritrova stampate come testo a ogni movimento.
|
|
92
|
+
*
|
|
93
|
+
* Due agganci coprono tutte le uscite, e la ragione per cui non ne basta uno è
|
|
94
|
+
* che le due classi non si attraversano. L'evento `exit` copre l'uscita normale
|
|
95
|
+
* (`exit()` di Ink, che è anche il secondo `^C` del deck) e il CRASH, perché
|
|
96
|
+
* un'eccezione non catturata termina il processo passando comunque di lì. I
|
|
97
|
+
* segnali no: `SIGINT`/`SIGTERM` terminano il processo senza emettere `exit`,
|
|
98
|
+
* quindi vogliono un handler proprio — che poi deve ri-emettere il segnale, o
|
|
99
|
+
* il processo resterebbe vivo (agganciare un handler ne disattiva la
|
|
100
|
+
* terminazione di default). `^C` battuto dentro il deck non è nessuno dei due:
|
|
101
|
+
* in raw mode è il byte `\x03` nello stream di input, che il deck consuma da sé
|
|
102
|
+
* e che sfocia nell'uscita normale.
|
|
103
|
+
*
|
|
104
|
+
* Ritorna la funzione di spegnimento per chi voglia chiamarla da sé.
|
|
105
|
+
*/
|
|
106
|
+
export function enableMouse(out = process.stdout) {
|
|
107
|
+
let off = false;
|
|
108
|
+
const disable = () => {
|
|
109
|
+
if (off)
|
|
110
|
+
return;
|
|
111
|
+
off = true;
|
|
112
|
+
try {
|
|
113
|
+
out.write(DISABLE);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// stdout già chiuso: non c'è più niente da ripristinare.
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
out.write(ENABLE);
|
|
120
|
+
process.on('exit', disable);
|
|
121
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
122
|
+
process.on(sig, () => {
|
|
123
|
+
disable();
|
|
124
|
+
process.kill(process.pid, sig);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return disable;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Colonne occupate da ogni segmento di una riga, dato il separatore che li
|
|
131
|
+
* unisce e la colonna in cui la riga comincia.
|
|
132
|
+
*
|
|
133
|
+
* Deriva dall'ARITMETICA di layout, non da un registro popolato al render:
|
|
134
|
+
* `measureElement` di Ink espone width e height ma mai la posizione assoluta di
|
|
135
|
+
* un elemento, quindi la mappa coordinate→superficie va costruita comunque. Il
|
|
136
|
+
* vincolo che la tiene onesta è che i `Segment` che entrano qui sono gli stessi
|
|
137
|
+
* che compongono la stringa renderizzata — una sola fonte, non due conti
|
|
138
|
+
* paralleli che possono divergere.
|
|
139
|
+
*
|
|
140
|
+
* La larghezza è quella del TERMINALE (`termWidth`), non quella di `cellWidth`
|
|
141
|
+
* in `config.ts`: la seconda è una stima prudente che serve a decidere quante
|
|
142
|
+
* voci stanno in riga e ha licenza di sovrastimare, mentre qui una colonna di
|
|
143
|
+
* troppo sposta il bersaglio.
|
|
144
|
+
*/
|
|
145
|
+
export function rowRegions(segments, sep, startCol) {
|
|
146
|
+
const sepW = termWidth(sep);
|
|
147
|
+
const out = [];
|
|
148
|
+
let col = startCol;
|
|
149
|
+
for (const seg of segments) {
|
|
150
|
+
const w = termWidth(seg.text);
|
|
151
|
+
if (w > 0)
|
|
152
|
+
out.push({ key: seg.key, start: col, end: col + w - 1 });
|
|
153
|
+
col += w + sepW;
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
/** Il tasto della superficie sotto la colonna, o `null` fuori da tutte. */
|
|
158
|
+
export function hitRegion(regions, col) {
|
|
159
|
+
for (const r of regions) {
|
|
160
|
+
if (col >= r.start && col <= r.end)
|
|
161
|
+
return r.key;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Riga del terminale (1-based) su cui vive la riga launch in modalità normale.
|
|
167
|
+
*
|
|
168
|
+
* Il frame è ancorato a riga 1 (`anchorFrame`) e le righe sopra la launch sono
|
|
169
|
+
* fisse e sempre presenti: bordo superiore, testata (status + versione),
|
|
170
|
+
* legenda tasti. Nessuna delle tre è condizionale in modalità normale — la
|
|
171
|
+
* legenda tasti è una catena di ternari che rende SEMPRE esattamente un `Text`,
|
|
172
|
+
* e la testata è una riga `space-between`. Tutto ciò che varia in altezza (i
|
|
173
|
+
* due pane, il blocco preview, la riga di nota) sta SOTTO, quindi il conto non
|
|
174
|
+
* dipende da quanto è popolato il progetto.
|
|
175
|
+
*/
|
|
176
|
+
export const LAUNCH_ROW = 4;
|
|
177
|
+
/** Colonna (1-based) del primo carattere di testo dentro il box esterno:
|
|
178
|
+
* bordo + `paddingX={1}`. */
|
|
179
|
+
export const FRAME_TEXT_COL = 3;
|
package/dist/project-status.js
CHANGED
|
@@ -47,9 +47,25 @@ export function readStatusCache(path) {
|
|
|
47
47
|
return null;
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Riporta a markdown gli heading scritti per la chat di Claude Code.
|
|
52
|
+
*
|
|
53
|
+
* L'output style del progetto impone lì un `#` in PIÙ (`# ## Sezione` per un
|
|
54
|
+
* H2), perché quel terminale stila solo l'H1 e senza il trucco la gerarchia si
|
|
55
|
+
* appiattirebbe. Il deck però rende markdown vero: lasciato com'è, ogni titolo
|
|
56
|
+
* del recap comparirebbe come un H1 col testo letterale `## Sezione`.
|
|
57
|
+
*
|
|
58
|
+
* Il deck è il consumatore, quindi la traduzione la paga lui: la skill produce
|
|
59
|
+
* per la chat, che resta il suo canale principale. La sostituzione è sicura
|
|
60
|
+
* anche se la convenzione sparisse — richiede altri `#` dopo il primo, quindi
|
|
61
|
+
* un heading markdown normale non la incontra mai.
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeChatHeadings(text) {
|
|
64
|
+
return text.replace(/^# (#+ )/gm, '$1');
|
|
65
|
+
}
|
|
50
66
|
export function writeStatusCache(path, text) {
|
|
51
67
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
52
|
-
writeFileSync(path, text, { mode: 0o600 });
|
|
68
|
+
writeFileSync(path, normalizeChatHeadings(text), { mode: 0o600 });
|
|
53
69
|
}
|
|
54
70
|
export const STATUS_MISSING = 'missing';
|
|
55
71
|
/**
|
package/dist/spawn.js
CHANGED
|
@@ -425,6 +425,11 @@ export function spawnProjectStatus(cwd, sessionId, onResult) {
|
|
|
425
425
|
'auto',
|
|
426
426
|
]);
|
|
427
427
|
}
|
|
428
|
+
// Comando della notifica desktop. L'override esiste per il collaudo del ramo
|
|
429
|
+
// «binario assente»: su una macchina dove `notify-send` c'è, quel ramo non è
|
|
430
|
+
// altrimenti raggiungibile — e non provarlo significherebbe scoprire su una
|
|
431
|
+
// macchina altrui che un ENOENT non agganciato porta via la TUI.
|
|
432
|
+
export const NOTIFY_CMD = process.env.LOOM_DECK_NOTIFY_CMD ?? 'notify-send';
|
|
428
433
|
// T121 — notifica desktop di fine generazione. La generazione dura minuti e chi
|
|
429
434
|
// l'ha chiesta nel frattempo guarda altrove; la notifica di GNOME persiste nel
|
|
430
435
|
// cassetto, quindi raggiunge anche chi torna dieci minuti dopo — cosa che un
|
|
@@ -437,7 +442,7 @@ export function spawnProjectStatus(cwd, sessionId, onResult) {
|
|
|
437
442
|
// stabile della macchina, e dirlo a ogni generazione sarebbe lo stesso messaggio
|
|
438
443
|
// a ogni giro su una funzione che non è il deliverable.
|
|
439
444
|
export function notifyDone(title, body) {
|
|
440
|
-
const child = spawnOut(
|
|
445
|
+
const child = spawnOut(NOTIFY_CMD, ['--app-name=loom-deck', title, body], {
|
|
441
446
|
detached: true,
|
|
442
447
|
stdio: 'ignore',
|
|
443
448
|
});
|
package/package.json
CHANGED