@lamemind/loom-deck 0.42.0 → 0.44.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
@@ -76,10 +76,13 @@ Regola unica, senza eccezioni — pensata per reggere l'aggiunta di nuove azioni
76
76
  | minuscola | azione immediata, one-shot | |
77
77
  | `CTRL`+lettera | idiomi universali, toggle dentro i modali di testo, varianti di un'azione | `^F` = find, come ovunque · `^K`/`^P`/`^R` = spawn con un altro prompt |
78
78
  | `1`…`9` | voce `launch` n-esima del progetto | da `.claude/loom-works.json` |
79
- | `esc` | chiude l'overlay aperto | in modalità normale è inerte: **nessun tasto chiude il deck** |
79
+ | `esc` | chiude l'overlay aperto | in modalità normale è inerte: **nessuna lettera chiude il deck** |
80
+ | `^C` `^C` | chiude il deck | la prima pressione avverte in riga di stato, la seconda entro **5 secondi** esce; passata la finestra si riparte dall'avviso. Da dentro un modale il primo `^C` riporta alla lista — l'avviso vive nella riga di stato, che le schermate a pieno frame non disegnano |
80
81
 
81
82
  `CTRL` è il terzo livello, aggiunto quando è arrivata la ricerca. Serve perché un modale con **campi di testo** mangia ogni lettera nuda: là dentro nessun comando può essere una lettera semplice. `CTRL+X` e `x` nudo condividono lo stesso `input` e si distinguono solo per `key.ctrl`, quindi in modalità normale il ramo `CTRL` è valutato **per primo** e chiude l'intera classe — senza, `CTRL+F` cadrebbe nel ramo `f` e forkerebbe una sessione invece di cercare.
82
83
 
84
+ `^C` è l'unica combo fuori da questo schema: non sta nel ramo `CTRL` di `normal` ma **sopra** il dispatch dei modi, perché non è un acceleratore — è la via d'uscita dal processo, e una via d'uscita che funziona in una schermata su undici non è una via d'uscita.
85
+
83
86
  Assegnazioni correnti:
84
87
 
85
88
  | | Tasto | Cosa fa |
@@ -109,7 +112,7 @@ t 💻 · c 🤖 · 1 📝 codium · 2 ☕ idea
109
112
 
110
113
  - **tasti** — cosa puoi fare *qui e ora*. Solo le voci attive: quelle contestuali compaiono quando il pane a fuoco le rende possibili e altrimenti spariscono,
111
114
  invece di mostrarsi inerti. Fuori navigazione (`↑↓` `←→`) e indicatore `focus:` — la prima è universale in qualunque TUI, il pane a fuoco si vede già dall'evidenziazione.
112
- Nessuna voce di uscita, perché non esiste un tasto che chiuda il deck.
115
+ Nessuna voce di uscita: `^C` non ha bisogno di essere annunciato — è il tasto che si prova per primo, e la prima pressione risponde da sé con l'avviso.
113
116
  - **surface** — *dove* puoi aprire qualcosa: prima le due built-in (`t`/`c`), poi le voci `launch` del progetto. Stanno insieme perché hanno la stessa natura — fire-once, `cwd` = project root, nessuno stato — e differiscono solo per essere universali invece che custom.
114
117
 
115
118
  L'indice da solo è opaco (le `launch` sono custom per-progetto, senza una lettera fissa per app), quindi la riga espone la mappa e non il conteggio. Se non entrano in larghezza, si ferma a voci intere e mostra il contatore di quelle fuori riga — mai un troncamento silenzioso; le celle delle due built-in sono riservate a monte, o le voci sfonderebbero il box di quel tanto. Il cap a `9` è imposto dai tasti-cifra, non dallo schema: un progetto può dichiarare più di 9 voci, quelle oltre la nona sono configurate ma non raggiungibili (e la riga lo dice).
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { render, Box, Text, useInput } from 'ink';
3
+ import { render, Box, Text, useApp, useInput } from 'ink';
4
4
  import { useState, useEffect, useMemo, useRef } from 'react';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskFileText, } from './tasks.js';
@@ -11,12 +11,12 @@ import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch,
11
11
  import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, TASK_VIEWS, } from './pane-views.js';
12
12
  import { isCompact, layoutBudget, searchPreviewCapacity, windowRange, } from './viewport.js';
13
13
  import { cut, cutMiddle, sanitize, termWidth } from './width.js';
14
- import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
14
+ import { applyView, cycleSort, describeSort, idColumnWidth, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
15
15
  import { initialDetail, writeTaskEdit, PRI_GLYPH, PRI_LABEL } from './task-edit.js';
16
- import { ALL, EDIT_PRI, EDIT_PROG, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, ROW_ALL, ROW_SPOT, SORT_TASTI, SPOT, } from './model.js';
16
+ import { ALL, EDIT_PRI, EDIT_PROG, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, QUIT_WINDOW_MS, ROW_ALL, ROW_SPOT, SORT_TASTI, SPOT, } from './model.js';
17
17
  import { purgeTargets, splitTargets } from './purge.js';
18
18
  import { conversationLabel, cpLen, editField, insertAt, isDone, isTextRow, removeAt, EDIT_ROWS, } from './layout.js';
19
- import { TASK_EMPTY, relTime, sanitizeTyped } from './glyphs.js';
19
+ import { TASK_EMPTY, relTime, sanitizeTyped, taskTail } from './glyphs.js';
20
20
  import { commitTaskEdit, runLaunch, spawnClaudeEmpty, spawnCleanTasks, spawnCreateTask, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, onInTabCommand, CLAUDE_CMD, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
21
21
  import { EditModal, FilterModal, PurgeModal, SortModal, idList } from './ui/modals.js';
22
22
  import { ReaderScreen, SearchScreen } from './ui/search-screen.js';
@@ -31,6 +31,12 @@ import { useSheetOverlay } from './overlays/sheet.js';
31
31
  import { useAssignOverlay } from './overlays/assign.js';
32
32
  import { captures } from './input-modes.js';
33
33
  import { VERSION } from './version.js';
34
+ // T116 — l'avviso della prima pressione di `^C`. La durata è INTERPOLATA dalla
35
+ // finestra, non ricopiata: un numero scritto a mano in un testo che promette un
36
+ // comportamento diventa falso il giorno che la costante cambia, e nessuno strumento
37
+ // lo segnala. Vive a livello di modulo perché ha due lettori — il ramo che la
38
+ // scrive e il timer che la ritira, e quest'ultimo deve poterla riconoscere.
39
+ const QUIT_NOTE = `⚠ ^C di nuovo entro ${QUIT_WINDOW_MS / 1000}s per chiudere il deck`;
34
40
  function Deck({ cwd, tasksPath, tasksDir }) {
35
41
  const { tasks, loadError } = useTasks(tasksPath);
36
42
  // `notes` esce dall'indice come `sessionNotes`: in questo componente `note` è
@@ -78,6 +84,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
78
84
  const [editRow, setEditRow] = useState(0);
79
85
  // T112 — bozza della conferma di eliminazione (null fuori dal modale).
80
86
  const [purge, setPurge] = useState(null);
87
+ // T116 — uscita a doppio `^C`. Il timer È lo stato dell'armamento: finché il
88
+ // handle esiste la finestra è aperta, e non serve un secondo stato da tenere
89
+ // in fase con lui. In un `useRef` e non in `useState` perché il ramo di uscita
90
+ // lo legge NELLO STESSO tasto in cui potrebbe averlo scritto — un valore di
91
+ // stato React arriverebbe al render dopo, cioè troppo tardi per decidere.
92
+ const { exit } = useApp();
93
+ const quitTimer = useRef(null);
81
94
  // Dimensioni vive del terminale: sono l'input del budget d'altezza sotto.
82
95
  const { rows, columns } = useTerminalSize();
83
96
  // Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
@@ -156,6 +169,19 @@ function Deck({ cwd, tasksPath, tasksDir }) {
156
169
  else
157
170
  spotCount++;
158
171
  }
172
+ // T118 — colonne fisse della lista task, misurate su `paneTasks` (la vista
173
+ // attiva INTERA) e non sulla finestra visibile: gemelle di `sessionCols` e
174
+ // per la stessa ragione, che una larghezza derivata dallo schermo si muove a
175
+ // ogni scroll. La coda porta dentro il proprio gutter, così l'allineamento a
176
+ // destra lo produce `pad` da sé; nessuna riga con qualcosa da scrivere →
177
+ // colonna spenta a `0`, e le descrizioni si riprendono lo spazio.
178
+ const taskCols = (() => {
179
+ let tail = 0;
180
+ for (const t of paneTasks) {
181
+ tail = Math.max(tail, termWidth(taskTail(childCount.get(t.id) ?? 0, dirtyFolders.has(t.id))));
182
+ }
183
+ return { id: idColumnWidth(paneTasks), tail: tail > 0 ? tail + 1 : 0 };
184
+ })();
159
185
  // Figli della selezione: tutte le conversazioni del progetto (`≡ tutte`), le
160
186
  // sessioni bound alla task selezionata, oppure (spot) quelle senza binding.
161
187
  // sessions è già ts desc → l'ordine si eredita in tutti e tre i rami.
@@ -888,6 +914,49 @@ function Deck({ cwd, tasksPath, tasksDir }) {
888
914
  }
889
915
  }
890
916
  }
917
+ /**
918
+ * T116 — `^C`: la prima pressione ARMA, la seconda entro la finestra chiude.
919
+ *
920
+ * Prima di questa task `^C` usciva subito, e a chiuderlo era Ink stesso
921
+ * (`exitOnCtrlC`, di default vero) PRIMA che il tasto raggiungesse
922
+ * `useInput`. Spegnere quell'opzione è la condizione perché il ramo esista —
923
+ * ma spegnerla e basta lascerebbe il deck senza NESSUNA uscita da tastiera:
924
+ * il ramo qui sotto è ciò che restituisce quella via, e i due cambi non si
925
+ * possono separare.
926
+ *
927
+ * La prima pressione RIPORTA IN `normal` quando si è dentro un modo
928
+ * capturing. L'avviso vive nella riga di stato, e le tre schermate
929
+ * sostitutive (detail, ricerca, reader) prendono l'intero frame senza
930
+ * renderizzarla: armare restando lì dentro darebbe un deck che sembra
931
+ * ignorare il tasto e che alla pressione successiva sparisce senza aver mai
932
+ * avvertito. Chiudere lo strato è anche coerente con `esc` — `^C` dice
933
+ * «voglio uscire», e il primo passo fuori è la lista.
934
+ */
935
+ function onQuitKey() {
936
+ if (quitTimer.current) {
937
+ clearTimeout(quitTimer.current);
938
+ quitTimer.current = null;
939
+ exit();
940
+ return;
941
+ }
942
+ if (captures(mode))
943
+ setMode('normal');
944
+ setNote(QUIT_NOTE);
945
+ quitTimer.current = setTimeout(() => {
946
+ quitTimer.current = null;
947
+ // Solo se l'avviso è ANCORA il proprio: nei 5 secondi una qualunque altra
948
+ // azione può aver scritto in riga di stato, e cancellare quella nota
949
+ // significherebbe far sparire il feedback di un'azione che non c'entra.
950
+ setNote((n) => (n === QUIT_NOTE ? '' : n));
951
+ }, QUIT_WINDOW_MS);
952
+ }
953
+ // Il timer pendente è un handle vivo del loop di Node: lasciarlo appeso allo
954
+ // smontaggio terrebbe il processo in piedi fino allo scadere della finestra,
955
+ // con lo schermo già restituito al terminale.
956
+ useEffect(() => () => {
957
+ if (quitTimer.current)
958
+ clearTimeout(quitTimer.current);
959
+ }, []);
891
960
  // Il dispatch consulta il CATALOGO (`input-modes.ts`), non l'ordine di una
892
961
  // catena di `if`. `MODE_KEYS` è il custode: essendo un `Record` su
893
962
  // `CapturingMode`, un modo nuovo senza handler non compila.
@@ -904,6 +973,17 @@ function Deck({ cwd, tasksPath, tasksDir }) {
904
973
  purge: onPurgeKey,
905
974
  };
906
975
  useInput((input, key) => {
976
+ // T116 — `^C` sta SOPRA il dispatch dei modi, unico tasto a scavalcarlo.
977
+ // Ogni altra combo `ctrl` è un acceleratore, e un acceleratore dentro un
978
+ // modo capturing dev'essere inerte (`input-modes.ts`); questo non è un
979
+ // acceleratore ma la via d'uscita dal processo, e una via d'uscita che
980
+ // funziona solo in una schermata su undici non è una via d'uscita. Non è
981
+ // quindi una deroga di `CTRL_DEROGATIONS` — quelle vivono DENTRO un modo e
982
+ // le gestisce il modo, questa li precede tutti.
983
+ if (key.ctrl && input === 'c') {
984
+ onQuitKey();
985
+ return;
986
+ }
907
987
  // Un modo capturing consuma TUTTO — acceleratori globali compresi — e le
908
988
  // deroghe se le gestisce da sé (`CTRL_DEROGATIONS`). Da qui in giù si è
909
989
  // quindi in `normal`, l'unico modo che cede ai `key.ctrl`.
@@ -1171,11 +1251,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1171
1251
  setNote(`${input} → ${entry.label} su ${projectName}`);
1172
1252
  }
1173
1253
  }
1174
- // Nessun tasto di uscita: il deck non si chiude da dentro. `q` resta libera
1175
- // per un binding futuro, `esc` in modalità normale è inerte — dentro un
1176
- // overlay continua a chiuderlo, perché quel ramo esce prima di qui.
1177
- // Restano le vie di sistema: `^C` (Ink lo intercetta da sé, `exitOnCtrlC`
1178
- // di default) e la chiusura della tab Ptyxis che ospita il processo.
1254
+ // Nessun tasto NUDO di uscita: `q` resta libera per un binding futuro, e
1255
+ // `esc` in modalità normale è inerte — dentro un overlay continua a
1256
+ // chiuderlo, perché quel ramo esce prima di qui. L'unica uscita da tastiera
1257
+ // è `^C` battuto due volte (T116, in testa a questo handler); resta poi la
1258
+ // chiusura della tab Ptyxis che ospita il processo.
1179
1259
  });
1180
1260
  const canSpawn = focus === 'tasks' && selTask !== null;
1181
1261
  const canResume = focus === 'sessions' && selSessionObj !== null;
@@ -1345,7 +1425,14 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1345
1425
  if (budget.compact) {
1346
1426
  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"] })] }));
1347
1427
  }
1348
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _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: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === '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: editRow, 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, childCount: childCount, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: paneTasks.length - taskWin.end, columns: columns, dirty: dirtyFolders }), _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] }));
1428
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _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: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === '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: editRow, 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, childCount: childCount, idW: taskCols.id, tailW: taskCols.tail, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: paneTasks.length - taskWin.end, columns: columns, dirty: dirtyFolders }), _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] }));
1349
1429
  }
1350
1430
  const cwd = process.cwd();
1351
- render(_jsx(Deck, { cwd: cwd, tasksPath: resolveTasksPath(cwd), tasksDir: resolveTasksDir(cwd) }));
1431
+ // T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
1432
+ // il tasto non arriverebbe mai a `useInput` e il doppio colpo non sarebbe
1433
+ // osservabile. In raw mode `^C` non è un SIGINT — è il byte `\x03` nello
1434
+ // stream di input, e chi lo consuma per primo decide. Da qui in poi lo consuma
1435
+ // il deck, quindi l'uscita è tutta a carico del suo handler.
1436
+ render(_jsx(Deck, { cwd: cwd, tasksPath: resolveTasksPath(cwd), tasksDir: resolveTasksDir(cwd) }), {
1437
+ exitOnCtrlC: false,
1438
+ });
package/dist/glyphs.js CHANGED
@@ -74,6 +74,28 @@ export const LIVE_NONE = ' ';
74
74
  export function displayProg(prog) {
75
75
  return sanitize(prog);
76
76
  }
77
+ /**
78
+ * T118 — coda della riga task: marker dirty e contatore di conversazioni, nella
79
+ * colonna ancorata al bordo destro del pane.
80
+ *
81
+ * Il contatore va per ULTIMO perché è lui ad allinearsi al bordo — la colonna è
82
+ * resa `pad(…, 'right')`, quindi ciò che sta in fondo alla stringa cade sempre
83
+ * nella stessa colonna. Il marker gli sta a sinistra, dentro la stessa cella
84
+ * invece che in una colonna propria: è raro, e una colonna dedicata costerebbe
85
+ * 3 spazi vuoti su ogni riga di un pane largo la metà del terminale.
86
+ *
87
+ * Vive qui e non nel sito di render perché la STESSA stringa serve due volte —
88
+ * a misurare la larghezza della colonna (sulla vista completa, in `cli.tsx`) e
89
+ * a disegnarla (sulla finestra, in `panes.tsx`). Due formattazioni gemelle
90
+ * divergerebbero alla prima modifica, e la colonna risulterebbe larga quanto
91
+ * una stringa che nessuno scrive.
92
+ */
93
+ export function taskTail(childCount, dirty) {
94
+ const count = childCount > 0 ? `(${childCount})` : '';
95
+ if (!dirty)
96
+ return count;
97
+ return count ? `${WARN} ${count}` : WARN;
98
+ }
77
99
  // T49 — size umana compatta per il detail pane sessione.
78
100
  export function fmtSize(bytes) {
79
101
  if (bytes < 1024)
package/dist/model.js CHANGED
@@ -1,4 +1,11 @@
1
1
  export const POLL_MS = 1500;
2
+ // Finestra entro cui la SECONDA pressione di `^C` chiude il deck. Scaduta, la
3
+ // prima pressione è dimenticata e la successiva riparte dall'avviso: un `^C`
4
+ // battuto per abitudine mezzo minuto prima non deve poter uscire a insaputa di
5
+ // chi preme. Il valore è anche il tempo per cui l'avviso resta in riga di stato
6
+ // — armamento e messaggio scadono insieme, o la riga prometterebbe un'uscita
7
+ // che il tasto non fa più.
8
+ export const QUIT_WINDOW_MS = 5000;
2
9
  // Cap del pane sessioni: le più recenti (ts desc), le altre restano nell'indice
3
10
  // ma fuori vista. Non-silenzioso → l'header mostra quante sono nascoste.
4
11
  export const MAX_SESSIONS = 30;
package/dist/ui/panes.js CHANGED
@@ -5,11 +5,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  import { Box, Text } from 'ink';
6
6
  import { cut, cutParts, pad, sanitize, termWidth } from '../width.js';
7
7
  import { isDone, paneTextWidth } from '../layout.js';
8
- import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, MODEL_W, SESSION_SEP, SID_CHARS, TASK_EMPTY, WARN, displayProg, modelShort, relTime, } from '../glyphs.js';
8
+ import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, MODEL_W, SESSION_SEP, SID_CHARS, TASK_EMPTY, WARN, displayProg, modelShort, relTime, taskTail, } from '../glyphs.js';
9
9
  import { META_ROWS, ROW_ALL, ROW_SPOT } from '../model.js';
10
10
  import { rowLabel, sessionTitle } from '../session-list.js';
11
11
  import { sessionView, taskView, SESSION_VIEWS, TASK_VIEWS, } from '../pane-views.js';
12
- import { describeSort, PRI_ENTRIES, PROG_ENTRIES } from '../view.js';
12
+ import { describeSort, padId, PRI_ENTRIES, PROG_ENTRIES } from '../view.js';
13
13
  /**
14
14
  * Header del pane task, tagliato QUI e non da Ink — stesso motivo del gemello
15
15
  * `SessionsHeader`, con una differenza di rischio: qui i segmenti sono tutti
@@ -49,7 +49,7 @@ export function TasksHeader({ counts, active, above, below, focused, columns, })
49
49
  const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
50
50
  return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, inverse: seg.active, children: shown[i] }, i)) : null) }));
51
51
  }
52
- export function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, childCount, focused, loadError, windowStart, above, below, columns, dirty, }) {
52
+ export function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, childCount, idW, tailW, focused, loadError, windowStart, above, below, columns, dirty, }) {
53
53
  const allSelected = selected === ROW_ALL;
54
54
  const spotSelected = selected === ROW_SPOT;
55
55
  return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(TasksHeader, { counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut(sanitize(`sort: ${describeSort(view.sort)}` +
@@ -71,17 +71,31 @@ export function TasksPane({ tasks, counts, activeView, paneCount, view, selected
71
71
  // completa, su cui è keyata la selezione. +META_ROWS: le prime due
72
72
  // righe sono le meta.
73
73
  const sel = windowStart + i + META_ROWS === selected;
74
- const n = childCount.get(task.id) ?? 0;
74
+ const tail = taskTail(childCount.get(task.id) ?? 0, dirty.has(task.id));
75
+ // T118 — l'id è paddato a `idW`, quindi `head` ha la stessa larghezza
76
+ // su ogni riga e Pri/Prog cadono sempre nella stessa colonna.
77
+ const id = padId(task.id, idW);
75
78
  // Invariante ③: la descrizione è l'unico pezzo a lunghezza libera, e
76
79
  // si taglia QUI sul budget che resta dopo le colonne fisse. Lasciarlo
77
80
  // fare a `truncate-end` significa passare da `cli-truncate`, che
78
81
  // restituisce una riga più larga del pane (una colonna per emoji) e
79
82
  // quindi scrive sopra il bordo. Le parti fisse si misurano con
80
- // `termWidth`: `task.id` è `T9` o `T52`, i due glifi valgono 2 ciascuno.
81
- const head = `${CARET_OFF}${task.id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
82
- const tail = `${n > 0 ? ` (${n})` : ''}${dirty.has(task.id) ? ` ${WARN}` : ''}`;
83
- const desc = cut(task.desc, Math.max(4, paneTextWidth(columns) - termWidth(head) - termWidth(tail)));
84
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
83
+ // `termWidth`: i due glifi Pri/Prog valgono 2 ciascuno.
84
+ const head = `${CARET_OFF}${id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
85
+ // T118 la colonna della coda si riserva PRIMA di tagliare la
86
+ // descrizione, e per la stessa larghezza su ogni riga: appesa dopo,
87
+ // entrava nel budget solo dove c'era qualcosa da scrivere, e la
88
+ // descrizione si tagliava a una colonna diversa riga per riga.
89
+ //
90
+ // Pavimento `0` su tutte e tre le misure e non un minimo di cortesia:
91
+ // il budget è un TETTO. Un pavimento sopra lo spazio reale fa uscire
92
+ // la riga dal pane e le mangia il bordo. `reserve` si clampa su ciò
93
+ // che avanza, così `head + desc + coda` sta sempre dentro il pane.
94
+ const avail = Math.max(0, paneTextWidth(columns) - termWidth(head));
95
+ const reserve = Math.min(tailW, avail);
96
+ const descW = avail - reserve;
97
+ const desc = cut(task.desc, descW);
98
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, ' '.repeat(Math.max(0, descW - termWidth(desc))), pad(tail, reserve, 'right')] }, task.id));
85
99
  }))] }));
86
100
  }
87
101
  /**
package/dist/view.js CHANGED
@@ -59,6 +59,37 @@ export function idNum(id) {
59
59
  const m = /^T(\d+)$/.exec(id);
60
60
  return m ? Number(m[1]) : Number.MAX_SAFE_INTEGER;
61
61
  }
62
+ /**
63
+ * T118 — larghezza della colonna id, DERIVATA dalla popolazione.
64
+ *
65
+ * La popolazione è la vista attiva COMPLETA, non la finestra visibile: una
66
+ * larghezza misurata sulle sole righe renderizzate sposta le colonne mentre si
67
+ * scorre, cioè una tabella corretta a ogni frame e illeggibile nel movimento.
68
+ * Il chiamante deve quindi passare `paneTasks`, non l'array che finisce nel
69
+ * pane (`tasks` in `TaskPane` è già la finestra).
70
+ *
71
+ * Gli id sono `T` + cifre (`TASK_ID_RE`), cioè ASCII: `.length` conta colonne.
72
+ */
73
+ export function idColumnWidth(tasks) {
74
+ let w = 0;
75
+ for (const t of tasks)
76
+ w = Math.max(w, t.id.length);
77
+ return w;
78
+ }
79
+ /**
80
+ * T118 — id allineato a destra DENTRO il prefisso: `T102` in lista rende `T90`
81
+ * come `T 90`, non come ` T90`.
82
+ *
83
+ * Il prefisso di tipo è l'ancora con cui l'occhio riconosce la colonna:
84
+ * spostarlo riga per riga toglie proprio ciò che l'allineamento dava. Sono le
85
+ * cifre ad allinearsi fra loro, la `T` resta ferma.
86
+ */
87
+ export function padId(id, cols) {
88
+ const m = /^([A-Za-z]+)(\d+)$/.exec(id);
89
+ if (!m)
90
+ return id.padEnd(cols);
91
+ return m[1] + ' '.repeat(Math.max(0, cols - id.length)) + m[2];
92
+ }
62
93
  function rankOf(task, key) {
63
94
  if (key === 'pri')
64
95
  return priRank(task.pri);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.42.0",
3
+ "version": "0.44.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": {