@lamemind/loom-deck 0.49.1 → 0.51.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, WHEEL_LINES, wheelDir, } 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';
@@ -32,7 +33,7 @@ import { useSearchOverlay } from './overlays/search.js';
32
33
  import { useSheetOverlay } from './overlays/sheet.js';
33
34
  import { useAssignOverlay } from './overlays/assign.js';
34
35
  import { useProjectStatus } from './overlays/status.js';
35
- import { captures } from './input-modes.js';
36
+ import { captures, scrolls } from './input-modes.js';
36
37
  import { VERSION } from './version.js';
37
38
  // T116 — l'avviso della prima pressione di `^C`. La durata è INTERPOLATA dalla
38
39
  // finestra, non ricopiata: un numero scritto a mano in un testo che promette un
@@ -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,73 @@ function Deck({ cwd, tasksPath, tasksDir }) {
956
994
  edit: onEditKey,
957
995
  purge: onPurgeKey,
958
996
  };
959
- useInput((input, key) => {
997
+ // T21 (mandata 2) la ROTELLA, per i soli modi che scorrono un contenuto
998
+ // lungo (`SCROLLING_MODES`). Stessa forma di `MODE_KEYS`: un `Record` sul
999
+ // catalogo, quindi un modo dichiarato scorrevole senza uno scroll da chiamare
1000
+ // non compila. Il delta è in righe, col segno del verso.
1001
+ const MODE_WHEEL = {
1002
+ detail: sheet.scroll,
1003
+ status: status.scroll,
1004
+ reader: search.scrollReader,
1005
+ };
1006
+ /**
1007
+ * T21 — il MOUSE precede tutto, in ogni modo del deck.
1008
+ *
1009
+ * Il filtro è incondizionato — anche dentro un campo di testo, anche in un
1010
+ * modo che non ha nessuna superficie cliccabile — e non è una precauzione:
1011
+ * `useInput` riceve le sequenze SGR come testo (`mouse.ts`, fatto ①), quindi
1012
+ * senza filtro un click battuto mentre è aperto il campo del titolo ci
1013
+ * scriverebbe dentro `[<0;5;3M`. Un listener raw su stdin non risolverebbe:
1014
+ * riceve gli stessi chunk in broadcast e non li sottrae a `useInput`.
1015
+ *
1016
+ * Il chunk MISTO — un tasto e un click nella stessa scrittura, che arriva
1017
+ * come una sola chiamata — si separa a mano: gli eventi vanno al mouse, il
1018
+ * testo residuo prosegue verso la tastiera. Solo un chunk di solo mouse
1019
+ * chiude qui.
1020
+ */
1021
+ useInput((raw, key) => {
1022
+ const { text, events } = takeMouse(raw);
1023
+ for (const ev of events)
1024
+ onMouse(ev);
1025
+ if (events.length > 0 && !text)
1026
+ return;
1027
+ onKey(text, key);
1028
+ });
1029
+ function onMouse(ev) {
1030
+ // Un click produce due eventi (pressione e rilascio): agire su entrambi
1031
+ // spawnerebbe due volte. La rotella arriva come sola pressione, quindi il
1032
+ // filtro su `press` la lascia passare e non la dedoppia.
1033
+ if (!ev.press)
1034
+ return;
1035
+ if (isWheel(ev.button)) {
1036
+ // La rotella NON rientra dalla porta della tastiera come il click: nel
1037
+ // detail `↑↓` muovono il fuoco fra i campi (T117), non il testo, e il
1038
+ // tasto che scorre — `PgUp`/`PgDn` — ha la granularità sbagliata per una
1039
+ // tacca. Va quindi allo scroll del modo, in righe. Fuori dai modi
1040
+ // scorrevoli è inerte: nelle liste la rotella non muove mai la
1041
+ // selezione (D5), e una tacca mentre il deck è sulla lista non deve
1042
+ // fare niente.
1043
+ if (scrolls(mode))
1044
+ MODE_WHEEL[mode](wheelDir(ev.button) * WHEEL_LINES);
1045
+ return;
1046
+ }
1047
+ // Il bottone si legge sui due bit bassi, perché gli alti portano i
1048
+ // modificatori (shift/meta/ctrl).
1049
+ if ((ev.button & 3) !== 0)
1050
+ return;
1051
+ // Le superfici esistono solo nella lista: un modo capturing prende il
1052
+ // frame intero e a quella riga c'è dell'altro.
1053
+ if (mode !== 'normal' || ev.row !== LAUNCH_ROW)
1054
+ return;
1055
+ const hit = hitRegion(launchRegions, ev.col);
1056
+ // Il click NON chiama l'azione: rientra dalla porta della tastiera col
1057
+ // tasto che la superficie annuncia. Un ramo nuovo su `t`/`c`/cifre nasce
1058
+ // così già cliccabile, e non esiste un secondo posto in cui il click possa
1059
+ // dire una cosa diversa dal tasto che gli sta scritto sopra.
1060
+ if (hit)
1061
+ onKey(hit, NO_MODIFIERS);
1062
+ }
1063
+ function onKey(input, key) {
960
1064
  // T116 — `^C` sta SOPRA il dispatch dei modi, unico tasto a scavalcarlo.
961
1065
  // Ogni altra combo `ctrl` è un acceleratore, e un acceleratore dentro un
962
1066
  // modo capturing dev'essere inerte (`input-modes.ts`); questo non è un
@@ -1251,27 +1355,27 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1251
1355
  // chiuderlo, perché quel ramo esce prima di qui. L'unica uscita da tastiera
1252
1356
  // è `^C` battuto due volte (T116, in testa a questo handler); resta poi la
1253
1357
  // chiusura della tab Ptyxis che ospita il processo.
1254
- });
1358
+ }
1255
1359
  const canSpawn = focus === 'tasks' && selTask !== null;
1256
1360
  const canResume = focus === 'sessions' && selSessionObj !== null;
1257
1361
  // T50 — il pin agisce su qualunque riga selezionata (anche stale, per
1258
1362
  // spinnarla); basta il focus sul pane e una selezione.
1259
1363
  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 🤖');
1364
+ const surfaceLegend = SURFACE_SEGMENTS.map((s) => s.text).join(LAUNCH_SEP);
1270
1365
  // Larghezza dal medesimo hook che dà l'altezza: dopo un resize la legenda si
1271
1366
  // ricalcola con lo stesso re-render che ridimensiona i pane. Le celle delle
1272
1367
  // surface (più il ` · ` che le separa dalle voci) sono già spese sulla riga →
1273
1368
  // vanno riservate, o le voci launch la sfonderebbero di quel tanto.
1274
- const legend = launchLegend(launch, columns, cellWidth(surfaceLegend) + 3);
1369
+ const legend = launchLegend(launch, columns, cellWidth(surfaceLegend) + cellWidth(LAUNCH_SEP));
1370
+ // T21 — la riga launch come DATO, non come stringa: gli stessi segmenti
1371
+ // compongono il testo renderizzato e le colonne dell'hit-test. Derivare le
1372
+ // seconde ri-splittando il primo sarebbe un conto parallelo, che diverge alla
1373
+ // prima label che contenga il separatore.
1374
+ const launchSegments = [
1375
+ ...SURFACE_SEGMENTS,
1376
+ ...legend.taken.map((text, i) => ({ key: String(i + 1), text })),
1377
+ ];
1378
+ const launchRegions = rowRegions(launchSegments, LAUNCH_SEP, FRAME_TEXT_COL);
1275
1379
  // Legenda della modalità normale. Elenca SOLO i tasti che fanno qualcosa qui e
1276
1380
  // ora: le voci contestuali compaiono quando il pane a fuoco le rende possibili
1277
1381
  // e altrimenti spariscono, invece di annunciarsi inerti con un `—`.
@@ -1446,7 +1550,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1446
1550
  if (budget.compact) {
1447
1551
  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
1552
  }
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] }));
1553
+ 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
1554
  }
1451
1555
  const cwd = process.cwd();
1452
1556
  // T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
@@ -1454,6 +1558,13 @@ const cwd = process.cwd();
1454
1558
  // osservabile. In raw mode `^C` non è un SIGINT — è il byte `\x03` nello
1455
1559
  // stream di input, e chi lo consuma per primo decide. Da qui in poi lo consuma
1456
1560
  // il deck, quindi l'uscita è tutta a carico del suo handler.
1561
+ // T21 — l'ordine dei tre passi è vincolante. `anchorFrame` va PRIMA del render:
1562
+ // pinna il frame a riga 1 e con lui l'origine dell'hit-test, che senza sarebbe
1563
+ // la posizione casuale del cursore all'avvio del processo. `enableMouse`
1564
+ // registra il ripristino del tracking sulle vie d'uscita, quindi prima che
1565
+ // esista qualcosa da cui uscire.
1566
+ anchorFrame();
1567
+ enableMouse();
1457
1568
  render(_jsx(Deck, { cwd: cwd, tasksPath: resolveTasksPath(cwd), tasksDir: resolveTasksDir(cwd) }), {
1458
1569
  exitOnCtrlC: false,
1459
1570
  });
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
  //
@@ -55,3 +55,23 @@ export function captures(mode) {
55
55
  export const CTRL_DEROGATIONS = {
56
56
  detail: ['f'],
57
57
  };
58
+ /**
59
+ * T21 (mandata 2) — i modi che SCORRONO un contenuto lungo, cioè gli unici in
60
+ * cui la rotella del mouse fa qualcosa.
61
+ *
62
+ * La rotella scorre il TESTO, mai la selezione di una lista (D5): nelle liste
63
+ * la selezione è un'intenzione — la riga su cui si preme `⏎` — e una rotella
64
+ * che la muovesse trasformerebbe ogni sfioramento in una scelta. Nei tre modi
65
+ * qui sotto lo scroll non sceglie niente, è solo posizione di lettura. Non
66
+ * coincide con «ha un documento a schermo»: `search` mostra un'anteprima, ma
67
+ * il fuoco lì è sulla lista dei risultati, che è una selezione.
68
+ *
69
+ * Il custode è `MODE_WHEEL` in `cli.tsx`, un `Record<ScrollingMode, …>` che non
70
+ * compila se un modo entra qui senza uno scroll da chiamare.
71
+ */
72
+ export const SCROLLING_MODES = ['detail', 'status', 'reader'];
73
+ const SCROLLING = new Set(SCROLLING_MODES);
74
+ /** `true` se la rotella scorre il contenuto del modo. */
75
+ export function scrolls(mode) {
76
+ return SCROLLING.has(mode);
77
+ }
package/dist/mouse.js ADDED
@@ -0,0 +1,201 @@
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
+ * Verso della rotella: `-1` su (codice 64), `+1` giù (65), `0` se non è una
37
+ * rotella. Il verso sta nel bit 0, i modificatori nei bit 2-4 (shift/meta/
38
+ * ctrl): `66`/`67` sono le rotelle orizzontali, che qui valgono `0` perché il
39
+ * deck non ha nulla da scorrere in orizzontale.
40
+ *
41
+ * Un terminale manda la rotella come SOLA pressione (`M`), mai seguita da un
42
+ * rilascio: un tacca = un evento, e il chiamante non deve dedoppiare come fa
43
+ * per il click.
44
+ */
45
+ export function wheelDir(button) {
46
+ if (!isWheel(button) || (button & 2) !== 0)
47
+ return 0;
48
+ return (button & 1) === 0 ? -1 : 1;
49
+ }
50
+ /**
51
+ * Righe scorse per tacca di rotella nei contenuti lunghi (detail, project
52
+ * status, reader). Tre è la convenzione dei terminali e dei browser: una tacca
53
+ * a riga singola costringe a girare la rotella quanto basta a stancare la mano,
54
+ * una a pagina salta il testo che si stava leggendo.
55
+ */
56
+ export const WHEEL_LINES = 3;
57
+ /**
58
+ * Separa le sequenze mouse dal testo di un chunk di `useInput`.
59
+ *
60
+ * Ritorna il testo RIPULITO (quello che il deck deve continuare a trattare come
61
+ * input di tastiera) e gli eventi trovati, nell'ordine di arrivo.
62
+ *
63
+ * Una sequenza spezzata su due chunk — succede sotto render pesante — arriva
64
+ * come due frammenti monchi: nessuno dei due matcha, quindi entrambi finiscono
65
+ * nel testo ripulito. Il click va perso. È voluto: bufferizzare significherebbe
66
+ * tenere uno stato fra due chiamate di `useInput` e decidere quando scade, e un
67
+ * click perso costa una ripetizione mentre un buffer che non scade mai
68
+ * inghiotte i tasti che seguono.
69
+ */
70
+ export function takeMouse(input) {
71
+ if (!input.includes('[<'))
72
+ return { text: input, events: [] };
73
+ const events = [];
74
+ const text = input.replace(SGR_MOUSE, (_all, b, x, y, kind) => {
75
+ events.push({
76
+ button: Number(b),
77
+ col: Number(x),
78
+ row: Number(y),
79
+ press: kind === 'M',
80
+ });
81
+ return '';
82
+ });
83
+ return { text, events };
84
+ }
85
+ // ── Tracking: accensione, spegnimento, ripristino ───────────────────────────
86
+ const ENABLE = '\x1b[?1000h\x1b[?1006h';
87
+ const DISABLE = '\x1b[?1006l\x1b[?1000l';
88
+ /**
89
+ * Ancoraggio del frame: pulisce lo schermo e riporta il cursore in alto a
90
+ * sinistra PRIMA del primo render di Ink.
91
+ *
92
+ * Senza, l'hit-test non ha origine. Ink non posiziona mai il cursore in modo
93
+ * assoluto (verificato sul flusso di byte: solo `\x1b[2K` e `\x1b[1A`, cioè
94
+ * `log-update` puro): il frame nasce dove si trovava il cursore quando il
95
+ * processo è partito — riga 1 in una tab appena aperta, più in basso dopo un
96
+ * prompt di shell, più in alto ancora se la prima scrittura ha fatto scorrere
97
+ * lo schermo. Una coordinata assoluta del mouse non sarebbe traducibile.
98
+ *
99
+ * Pinnato a riga 1 il frame ci RESTA, senza bisogno di ri-ancorarlo a ogni
100
+ * resize: `log-update` cancella all'insù tante righe quante ne aveva scritte e
101
+ * poi riscrive da lì, e il frame è alto al massimo `rows - 1` (lo garantisce lo
102
+ * SLACK di `viewport.ts`) — quindi la scrittura non fa mai scorrere lo schermo
103
+ * e la cancellazione riporta sempre il cursore a riga 1. È un punto fisso.
104
+ */
105
+ export function anchorFrame(out = process.stdout) {
106
+ out.write('\x1b[2J\x1b[H');
107
+ }
108
+ /**
109
+ * Accende il tracking e registra lo spegnimento su ogni via d'uscita.
110
+ *
111
+ * Un tracking lasciato acceso non è un difetto cosmetico: il terminale continua
112
+ * a mandare sequenze a qualunque programma prenda il posto del deck, che se le
113
+ * ritrova stampate come testo a ogni movimento.
114
+ *
115
+ * Due agganci coprono tutte le uscite, e la ragione per cui non ne basta uno è
116
+ * che le due classi non si attraversano. L'evento `exit` copre l'uscita normale
117
+ * (`exit()` di Ink, che è anche il secondo `^C` del deck) e il CRASH, perché
118
+ * un'eccezione non catturata termina il processo passando comunque di lì. I
119
+ * segnali no: `SIGINT`/`SIGTERM` terminano il processo senza emettere `exit`,
120
+ * quindi vogliono un handler proprio — che poi deve ri-emettere il segnale, o
121
+ * il processo resterebbe vivo (agganciare un handler ne disattiva la
122
+ * terminazione di default). `^C` battuto dentro il deck non è nessuno dei due:
123
+ * in raw mode è il byte `\x03` nello stream di input, che il deck consuma da sé
124
+ * e che sfocia nell'uscita normale.
125
+ *
126
+ * Ritorna la funzione di spegnimento per chi voglia chiamarla da sé.
127
+ */
128
+ export function enableMouse(out = process.stdout) {
129
+ let off = false;
130
+ const disable = () => {
131
+ if (off)
132
+ return;
133
+ off = true;
134
+ try {
135
+ out.write(DISABLE);
136
+ }
137
+ catch {
138
+ // stdout già chiuso: non c'è più niente da ripristinare.
139
+ }
140
+ };
141
+ out.write(ENABLE);
142
+ process.on('exit', disable);
143
+ for (const sig of ['SIGINT', 'SIGTERM']) {
144
+ process.on(sig, () => {
145
+ disable();
146
+ process.kill(process.pid, sig);
147
+ });
148
+ }
149
+ return disable;
150
+ }
151
+ /**
152
+ * Colonne occupate da ogni segmento di una riga, dato il separatore che li
153
+ * unisce e la colonna in cui la riga comincia.
154
+ *
155
+ * Deriva dall'ARITMETICA di layout, non da un registro popolato al render:
156
+ * `measureElement` di Ink espone width e height ma mai la posizione assoluta di
157
+ * un elemento, quindi la mappa coordinate→superficie va costruita comunque. Il
158
+ * vincolo che la tiene onesta è che i `Segment` che entrano qui sono gli stessi
159
+ * che compongono la stringa renderizzata — una sola fonte, non due conti
160
+ * paralleli che possono divergere.
161
+ *
162
+ * La larghezza è quella del TERMINALE (`termWidth`), non quella di `cellWidth`
163
+ * in `config.ts`: la seconda è una stima prudente che serve a decidere quante
164
+ * voci stanno in riga e ha licenza di sovrastimare, mentre qui una colonna di
165
+ * troppo sposta il bersaglio.
166
+ */
167
+ export function rowRegions(segments, sep, startCol) {
168
+ const sepW = termWidth(sep);
169
+ const out = [];
170
+ let col = startCol;
171
+ for (const seg of segments) {
172
+ const w = termWidth(seg.text);
173
+ if (w > 0)
174
+ out.push({ key: seg.key, start: col, end: col + w - 1 });
175
+ col += w + sepW;
176
+ }
177
+ return out;
178
+ }
179
+ /** Il tasto della superficie sotto la colonna, o `null` fuori da tutte. */
180
+ export function hitRegion(regions, col) {
181
+ for (const r of regions) {
182
+ if (col >= r.start && col <= r.end)
183
+ return r.key;
184
+ }
185
+ return null;
186
+ }
187
+ /**
188
+ * Riga del terminale (1-based) su cui vive la riga launch in modalità normale.
189
+ *
190
+ * Il frame è ancorato a riga 1 (`anchorFrame`) e le righe sopra la launch sono
191
+ * fisse e sempre presenti: bordo superiore, testata (status + versione),
192
+ * legenda tasti. Nessuna delle tre è condizionale in modalità normale — la
193
+ * legenda tasti è una catena di ternari che rende SEMPRE esattamente un `Text`,
194
+ * e la testata è una riga `space-between`. Tutto ciò che varia in altezza (i
195
+ * due pane, il blocco preview, la riga di nota) sta SOTTO, quindi il conto non
196
+ * dipende da quanto è popolato il progetto.
197
+ */
198
+ export const LAUNCH_ROW = 4;
199
+ /** Colonna (1-based) del primo carattere di testo dentro il box esterno:
200
+ * bordo + `paddingX={1}`. */
201
+ export const FRAME_TEXT_COL = 3;
@@ -255,5 +255,6 @@ export function useSearchOverlay(deps) {
255
255
  open,
256
256
  onReaderKey,
257
257
  onSearchKey,
258
+ scrollReader,
258
259
  };
259
260
  }
@@ -286,5 +286,6 @@ export function useSheetOverlay(deps) {
286
286
  occCur,
287
287
  open,
288
288
  onKey,
289
+ scroll,
289
290
  };
290
291
  }
@@ -155,5 +155,6 @@ export function useProjectStatus(deps) {
155
155
  generate,
156
156
  open,
157
157
  onKey,
158
+ scroll,
158
159
  };
159
160
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.49.1",
3
+ "version": "0.51.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": {