@lamemind/loom-deck 0.49.1 → 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 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
- useInput((input, key) => {
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
- // Le due surface built-in del cappello stanno in testa alla riga launch, non
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) + 3);
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: [surfaceLegend, legend.shown ? ` · ${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: 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] }));
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 ? 3 : 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 { shown: taken.join(' · '), overflow: parts.length - taken.length, unreachable };
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.49.1",
3
+ "version": "0.50.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": {