@lamemind/loom-deck 0.42.0 → 0.43.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';
@@ -13,7 +13,7 @@ import { isCompact, layoutBudget, searchPreviewCapacity, windowRange, } from './
13
13
  import { cut, cutMiddle, sanitize, termWidth } from './width.js';
14
14
  import { applyView, cycleSort, describeSort, 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
19
  import { TASK_EMPTY, relTime, sanitizeTyped } from './glyphs.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.
@@ -888,6 +901,49 @@ function Deck({ cwd, tasksPath, tasksDir }) {
888
901
  }
889
902
  }
890
903
  }
904
+ /**
905
+ * T116 — `^C`: la prima pressione ARMA, la seconda entro la finestra chiude.
906
+ *
907
+ * Prima di questa task `^C` usciva subito, e a chiuderlo era Ink stesso
908
+ * (`exitOnCtrlC`, di default vero) PRIMA che il tasto raggiungesse
909
+ * `useInput`. Spegnere quell'opzione è la condizione perché il ramo esista —
910
+ * ma spegnerla e basta lascerebbe il deck senza NESSUNA uscita da tastiera:
911
+ * il ramo qui sotto è ciò che restituisce quella via, e i due cambi non si
912
+ * possono separare.
913
+ *
914
+ * La prima pressione RIPORTA IN `normal` quando si è dentro un modo
915
+ * capturing. L'avviso vive nella riga di stato, e le tre schermate
916
+ * sostitutive (detail, ricerca, reader) prendono l'intero frame senza
917
+ * renderizzarla: armare restando lì dentro darebbe un deck che sembra
918
+ * ignorare il tasto e che alla pressione successiva sparisce senza aver mai
919
+ * avvertito. Chiudere lo strato è anche coerente con `esc` — `^C` dice
920
+ * «voglio uscire», e il primo passo fuori è la lista.
921
+ */
922
+ function onQuitKey() {
923
+ if (quitTimer.current) {
924
+ clearTimeout(quitTimer.current);
925
+ quitTimer.current = null;
926
+ exit();
927
+ return;
928
+ }
929
+ if (captures(mode))
930
+ setMode('normal');
931
+ setNote(QUIT_NOTE);
932
+ quitTimer.current = setTimeout(() => {
933
+ quitTimer.current = null;
934
+ // Solo se l'avviso è ANCORA il proprio: nei 5 secondi una qualunque altra
935
+ // azione può aver scritto in riga di stato, e cancellare quella nota
936
+ // significherebbe far sparire il feedback di un'azione che non c'entra.
937
+ setNote((n) => (n === QUIT_NOTE ? '' : n));
938
+ }, QUIT_WINDOW_MS);
939
+ }
940
+ // Il timer pendente è un handle vivo del loop di Node: lasciarlo appeso allo
941
+ // smontaggio terrebbe il processo in piedi fino allo scadere della finestra,
942
+ // con lo schermo già restituito al terminale.
943
+ useEffect(() => () => {
944
+ if (quitTimer.current)
945
+ clearTimeout(quitTimer.current);
946
+ }, []);
891
947
  // Il dispatch consulta il CATALOGO (`input-modes.ts`), non l'ordine di una
892
948
  // catena di `if`. `MODE_KEYS` è il custode: essendo un `Record` su
893
949
  // `CapturingMode`, un modo nuovo senza handler non compila.
@@ -904,6 +960,17 @@ function Deck({ cwd, tasksPath, tasksDir }) {
904
960
  purge: onPurgeKey,
905
961
  };
906
962
  useInput((input, key) => {
963
+ // T116 — `^C` sta SOPRA il dispatch dei modi, unico tasto a scavalcarlo.
964
+ // Ogni altra combo `ctrl` è un acceleratore, e un acceleratore dentro un
965
+ // modo capturing dev'essere inerte (`input-modes.ts`); questo non è un
966
+ // acceleratore ma la via d'uscita dal processo, e una via d'uscita che
967
+ // funziona solo in una schermata su undici non è una via d'uscita. Non è
968
+ // quindi una deroga di `CTRL_DEROGATIONS` — quelle vivono DENTRO un modo e
969
+ // le gestisce il modo, questa li precede tutti.
970
+ if (key.ctrl && input === 'c') {
971
+ onQuitKey();
972
+ return;
973
+ }
907
974
  // Un modo capturing consuma TUTTO — acceleratori globali compresi — e le
908
975
  // deroghe se le gestisce da sé (`CTRL_DEROGATIONS`). Da qui in giù si è
909
976
  // quindi in `normal`, l'unico modo che cede ai `key.ctrl`.
@@ -1171,11 +1238,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1171
1238
  setNote(`${input} → ${entry.label} su ${projectName}`);
1172
1239
  }
1173
1240
  }
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.
1241
+ // Nessun tasto NUDO di uscita: `q` resta libera per un binding futuro, e
1242
+ // `esc` in modalità normale è inerte — dentro un overlay continua a
1243
+ // chiuderlo, perché quel ramo esce prima di qui. L'unica uscita da tastiera
1244
+ // è `^C` battuto due volte (T116, in testa a questo handler); resta poi la
1245
+ // chiusura della tab Ptyxis che ospita il processo.
1179
1246
  });
1180
1247
  const canSpawn = focus === 'tasks' && selTask !== null;
1181
1248
  const canResume = focus === 'sessions' && selSessionObj !== null;
@@ -1348,4 +1415,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1348
1415
  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] }));
1349
1416
  }
1350
1417
  const cwd = process.cwd();
1351
- render(_jsx(Deck, { cwd: cwd, tasksPath: resolveTasksPath(cwd), tasksDir: resolveTasksDir(cwd) }));
1418
+ // T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
1419
+ // il tasto non arriverebbe mai a `useInput` e il doppio colpo non sarebbe
1420
+ // osservabile. In raw mode `^C` non è un SIGINT — è il byte `\x03` nello
1421
+ // stream di input, e chi lo consuma per primo decide. Da qui in poi lo consuma
1422
+ // il deck, quindi l'uscita è tutta a carico del suo handler.
1423
+ render(_jsx(Deck, { cwd: cwd, tasksPath: resolveTasksPath(cwd), tasksDir: resolveTasksDir(cwd) }), {
1424
+ exitOnCtrlC: false,
1425
+ });
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.42.0",
3
+ "version": "0.43.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": {