@lamemind/loom-deck 0.48.0 → 0.49.1

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
@@ -74,7 +74,7 @@ Regola unica, senza eccezioni — pensata per reggere l'aggiunta di nuove azioni
74
74
  | `⏎` | azione primaria del pane | Tasks → apre il **detail** della task selezionata · Sessions → riprende (`claude --resume`) la sessione selezionata |
75
75
  | **MAIUSCOLA** | **apre un modale** | cattura tutti i tasti; `esc` annulla, non esce |
76
76
  | minuscola | azione immediata, one-shot | |
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 |
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 · `^G`/`^O` = genera/apri il project status |
78
78
  | `1`…`9` | voce `launch` n-esima del progetto | da `.claude/loom-works.json` |
79
79
  | `esc` | chiude l'overlay aperto | in modalità normale è inerte: **nessuna lettera chiude il deck** |
80
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 |
@@ -94,6 +94,8 @@ Assegnazioni correnti:
94
94
  | modale | `^F` | **ricerca full-text** nelle conversazioni del progetto |
95
95
  | modale | `⏎` | **detail** della task selezionata: task file scrollabile + area di compilazione a quattro righe |
96
96
  | modale | `CANC` | **elimina** — la task selezionata; su una riga meta della vista `archiviabili`, l'intero insieme |
97
+ | modale | `^O` | apre il **project status** in cache (viewer fullscreen) |
98
+ | immediata | `^G` | **genera** il project status (skill headless, dura minuti) |
97
99
  | immediata | `^K` | spawna la task selezionata col prompt di **recap** |
98
100
  | immediata | `^P` | spawna la task selezionata sul **preflight** |
99
101
  | immediata | `^R` | spawna la task selezionata in **esecuzione** |
@@ -106,7 +108,7 @@ Assegnazioni correnti:
106
108
  Il footer è **due righe con due nature diverse**:
107
109
 
108
110
  ```
109
- ⏎ detail · ^K/^P/^R spawn · ^F cerca · C nuova · E edit · S sort · F filtri · w salva
111
+ ⏎ detail · ^K/^P/^R spawn · ^G genera status · ^O apri status · ^F cerca · C nuova · E edit · S sort · F filtri · w salva
110
112
  t 💻 · c 🤖 · 1 📝 codium · 2 ☕ idea
111
113
  ```
112
114
 
@@ -233,6 +235,32 @@ I toggle e la query sono **volatili**: sopravvivono alla chiusura del modale, no
233
235
 
234
236
  **Come fa a essere istantanea.** I corpi dei messaggi restano in RAM dentro la cache mtime-keyed che il deck usa già per la lista sessioni: quei file venivano comunque deserializzati per turni e titoli, e i corpi buttati. Trattenerli non aggiunge I/O, aggiunge memoria — e ne aggiunge poca, perché un JSONL è per l'85% overhead (misurato su un progetto reale: 57 MB su disco = 9,8 MB di testo cercabile). Cercarci dentro costa 1-9 ms; un prefiltro `grep` sugli stessi file ne costerebbe 26, perché rileggerebbe il volume pieno a ogni battuta.
235
237
 
238
+ ### `^G` / `^O` — il project status
239
+
240
+ Il recap di progetto (`/loom-works:recap-status-project`) non è più solo una conversazione da aprire e perdere: il deck lo tiene come **artefatto**. La prima riga della cornice lo dice sempre.
241
+
242
+ ```
243
+ loom-works :: PROJECT STATUS [ 09:42 ] v0.49.0
244
+ ```
245
+
246
+ Il campo fra parentesi ha tre stati e basta:
247
+
248
+ | Stato | Significato |
249
+ |---|---|
250
+ | `missing` | nessun recap in cache: mai generato, o cache svuotata |
251
+ | `◍ building 47...` | generazione in corso, coi secondi trascorsi dallo spawn |
252
+ | `09:42` | ora dell'ultima generazione riuscita |
253
+
254
+ Il glifo `◍` è lo stesso della colonna liveness delle sessioni: una generazione in corso **è** una conversazione che sta lavorando, non un simbolo nuovo da imparare. Dopo un tentativo fallito il campo porta un marker in coda (`[ 09:42 ⚠ ]`) e se lo tiene finché una generazione riuscita non lo azzera — la riga di stato sparisce al primo tasto premuto, e un fallimento non deve poter passare inosservato per una pressione di freccia. Il fallimento **non** cancella l'ora: la cache non è stata riscritta, quindi il recap di prima è ancora vero e ancora apribile.
255
+
256
+ **Due tasti, non uno**: `^G` genera, `^O` apre. Aprire un recap vecchio costa una lettura di file, generarlo costa minuti di sessione Claude — legarli allo stesso tasto renderebbe il gesto economico un effetto collaterale di quello caro. `^G` con una generazione già in corso non ne fa partire una seconda; `^O` su cache assente lo dice nella riga di stato invece di aprire un viewer vuoto.
257
+
258
+ Il recap vive in un file markdown sotto `/tmp/loom-deck-status-<uid>/`, col nome derivato dal path del progetto (non-alnum → `-`, la stessa trasformazione del transcript store di Claude Code). Da lì tre conseguenze volute: **sopravvive alla chiusura del deck** (riaperto, la testata mostra l'ora di prima), muore al reboot (un recap di ieri è comunque stale), e la coordinata resta una funzione pura del path invece di un registro da tenere allineato. L'ora mostrata è l'**mtime del file**, mai un campo scritto dentro il testo: un fatto scritto due volte diverge alla prima riscrittura parziale.
259
+
260
+ `^O` apre un **viewer fullscreen** con lo stesso markdown reso del detail della task: `↑↓` riga, `PgUp`/`PgDn` pagina, `g`/`G` estremi, `esc` chiude. Aperto mentre una generazione gira, mostra la versione precedente e lo **dichiara** nella riga meta — il viewer è una fotografia e non si aggiorna da sé quando la generazione finisce.
261
+
262
+ A generazione finita arriva una notifica desktop (`notify-send`): la generazione dura minuti e nel frattempo si guarda altrove, e la notifica di GNOME persiste nel cassetto — cosa che un suono non farebbe. Senza `notify-send` installato non succede **niente**: nessuna riga, nessun messaggio. L'assenza di un binario è una proprietà stabile della macchina, non un evento, e segnalarla a ogni generazione sarebbe rumore ripetuto su una funzione accessoria mentre il deliverable vero è arrivato comunque.
263
+
236
264
  ## Vista: filtri e ordinamenti
237
265
 
238
266
  La lista è una **vista** sulla `tasks.md`: si filtra e si ordina senza toccare il file.
package/dist/cli.js CHANGED
@@ -22,6 +22,7 @@ import { commitTaskEdit, runLaunch, spawnClaudeEmpty, spawnCleanTasks, spawnCrea
22
22
  import { EditModal, FilterModal, PurgeModal, SortModal, idList } from './ui/modals.js';
23
23
  import { ReaderScreen, SearchScreen } from './ui/search-screen.js';
24
24
  import { DetailScreen } from './ui/detail-screen.js';
25
+ import { StatusHeadline, StatusScreen } from './ui/status-screen.js';
25
26
  import { AssignScreen } from './ui/assign-screen.js';
26
27
  import { SessionsPane, TasksPane } from './ui/panes.js';
27
28
  import { PreviewPane, detailMetaOf } from './ui/preview.js';
@@ -30,6 +31,7 @@ import { useArchivable, useDirtyFolders, useSessions, useTaskDetail, useTasks, u
30
31
  import { useSearchOverlay } from './overlays/search.js';
31
32
  import { useSheetOverlay } from './overlays/sheet.js';
32
33
  import { useAssignOverlay } from './overlays/assign.js';
34
+ import { useProjectStatus } from './overlays/status.js';
33
35
  import { captures } from './input-modes.js';
34
36
  import { VERSION } from './version.js';
35
37
  // T116 — l'avviso della prima pressione di `^C`. La durata è INTERPOLATA dalla
@@ -757,6 +759,20 @@ function Deck({ cwd, tasksPath, tasksDir }) {
757
759
  setNote,
758
760
  onResume: (row) => resumeSession(row.session.sessionId),
759
761
  });
762
+ // T121 — il project status. Non è solo un overlay come i tre sopra: il suo
763
+ // stato si legge in TESTATA a schermata chiusa, e il viewer è una delle sue
764
+ // superfici invece che il suo contenuto.
765
+ // Il nome è quello del file config quando c'è, la cartella altrimenti: la
766
+ // testata deve dire di quale progetto parla anche su un checkout non ancora
767
+ // registrato.
768
+ const status = useProjectStatus({
769
+ cwd,
770
+ name: projectCore ?? projectName,
771
+ rows,
772
+ columns,
773
+ setMode,
774
+ setNote,
775
+ });
760
776
  function onCreateKey(input, key) {
761
777
  if (key.escape) {
762
778
  setMode('normal');
@@ -929,6 +945,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
929
945
  // `CapturingMode`, un modo nuovo senza handler non compila.
930
946
  const MODE_KEYS = {
931
947
  detail: sheet.onKey,
948
+ status: status.onKey,
932
949
  reader: search.onReaderKey,
933
950
  search: search.onSearchKey,
934
951
  assign: assign.onKey,
@@ -980,6 +997,17 @@ function Deck({ cwd, tasksPath, tasksDir }) {
980
997
  else if (input === 'r') {
981
998
  spawnTaskSession('run', '^R');
982
999
  }
1000
+ else if (input === 'g') {
1001
+ // T121 — GENERA e APRI su due tasti distinti (D2): aprire un recap
1002
+ // vecchio deve costare zero, e generare non deve essere un effetto
1003
+ // collaterale del guardare. Nessuno dei due è preso né bruciato da Ink,
1004
+ // e `^S`/`^Q` sono evitati perché il terminale li intercetta come flow
1005
+ // control.
1006
+ status.generate();
1007
+ }
1008
+ else if (input === 'o') {
1009
+ status.open();
1010
+ }
983
1011
  return;
984
1012
  }
985
1013
  if (key.tab) {
@@ -1266,6 +1294,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1266
1294
  // schermate. Rename di sola ETICHETTA: il dato resta `note` nel sidecar e
1267
1295
  // `--title-note` in deck-run, dove rinominarlo sarebbe un breaking.
1268
1296
  ...(canPin ? ['p pin', 'N titolo', 'A assegna'] : []),
1297
+ // T121 — le due voci nominano il GESTO e non l'oggetto: «status» da solo
1298
+ // sarebbe indistinguibile dal recap della task su `^K`, che è un'altra
1299
+ // cosa e sta a due voci di distanza.
1300
+ '^G genera status',
1301
+ '^O apri status',
1269
1302
  '^F cerca',
1270
1303
  'C nuova',
1271
1304
  'E edit',
@@ -1312,6 +1345,18 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1312
1345
  const start = Math.min(sheet.top, sheet.maxTop);
1313
1346
  return (_jsx(DetailScreen, { id: sheet.sheet.id, title: sheet.sheet.title, missing: sheet.sheet.text === null, lines: sheet.lines.slice(start, start + sheet.capacity), spans: sheet.doc?.spans ?? [], top: start, total: sheet.lines.length, capacity: sheet.capacity, action: sheet.action, model: sheet.model, spawnNote: sheet.spawnNote, prompt: sheet.prompt, cursor: sheet.cursor, columns: columns, find: sheet.find, occ: sheet.findRes.occ, occCur: sheet.occCur }));
1314
1347
  }
1348
+ // ── T121 · viewer del project status ────────────────────────────────────
1349
+ // Quinta schermata sostitutiva, stessa ragione delle altre quattro: un recap
1350
+ // di progetto è lungo quanto un task file e non entra in un box sopra i pane.
1351
+ if (mode === 'status' && status.view) {
1352
+ if (isCompact(status.capacity)) {
1353
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 project status \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7 esc chiude"] })] }));
1354
+ }
1355
+ // Il clamp serve anche qui: un resize può accorciare il testo sotto uno
1356
+ // scroll già dato.
1357
+ const start = Math.min(status.top, status.maxTop);
1358
+ return (_jsx(StatusScreen, { name: projectCore ?? projectName, label: status.label, building: status.building, failed: status.failed, view: status.view, lines: status.lines.slice(start, start + status.capacity), spans: status.doc?.spans ?? [], top: start, total: status.lines.length, capacity: status.capacity, columns: columns }));
1359
+ }
1315
1360
  // ── T52 · schermate sostitutive ─────────────────────────────────────────
1316
1361
  // Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
1317
1362
  // pane: una lista di occorrenze non entra in un box da 4 righe. Prendono
@@ -1401,7 +1446,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1401
1446
  if (budget.compact) {
1402
1447
  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"] })] }));
1403
1448
  }
1404
- 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: ["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] }));
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] }));
1405
1450
  }
1406
1451
  const cwd = process.cwd();
1407
1452
  // T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
package/dist/glyphs.js CHANGED
@@ -139,6 +139,14 @@ export function fmtDateTime(ts) {
139
139
  const p = (n) => String(n).padStart(2, '0');
140
140
  return `${p(d.getDate())}/${p(d.getMonth() + 1)} ${p(d.getHours())}:${p(d.getMinutes())}`;
141
141
  }
142
+ // T121 — sola ora del giorno, per l'indicatore di project status in testata. Il
143
+ // giorno non ci sta e non servirebbe: un recap del giorno prima è comunque
144
+ // stale, e la cache muore col reboot.
145
+ export function fmtTime(ts) {
146
+ const d = new Date(ts);
147
+ const p = (n) => String(n).padStart(2, '0');
148
+ return `${p(d.getHours())}:${p(d.getMinutes())}`;
149
+ }
142
150
  // Età relativa compatta (ms epoch → "2m"/"3h"/"5d") per il preview sessioni.
143
151
  export function relTime(ts) {
144
152
  const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
@@ -21,6 +21,7 @@
21
21
  */
22
22
  export const CAPTURING_MODES = [
23
23
  'detail',
24
+ 'status',
24
25
  'reader',
25
26
  'search',
26
27
  'assign',
@@ -0,0 +1,159 @@
1
+ // T121 — il PROJECT STATUS del deck: generazione headless, cache su disco,
2
+ // indicatore in testata e viewer fullscreen.
3
+ //
4
+ // Non è solo un overlay come `sheet` o `search`: lo stato che tiene (cosa c'è
5
+ // in cache, se una generazione è in corso, com'è andato l'ultimo tentativo) è
6
+ // visibile in testata a schermata chiusa. Il viewer è una delle sue superfici,
7
+ // non il suo contenuto.
8
+ //
9
+ // Le due attivazioni sono DISTINTE per costruzione (D2): generare costa minuti
10
+ // di sessione Claude, aprire costa una lettura di file — legarle allo stesso
11
+ // tasto renderebbe il gesto economico un effetto collaterale di quello caro.
12
+ import { useEffect, useMemo, useState } from 'react';
13
+ import { randomUUID } from 'node:crypto';
14
+ import { statusCapacity } from '../viewport.js';
15
+ import { cut, wrapWithOffsets } from '../width.js';
16
+ import { parseMarkdown } from '../markdown.js';
17
+ import { CLAUDE_CMD, notifyDone, spawnProjectStatus } from '../spawn.js';
18
+ import { readStatusCache, statusFilePath, statusLabel, writeStatusCache, } from '../project-status.js';
19
+ export function useProjectStatus(deps) {
20
+ const { cwd, name, rows, columns, setMode, setNote } = deps;
21
+ const path = useMemo(() => statusFilePath(cwd), [cwd]);
22
+ // Letta al mount e basta: è ciò che fa ripartire un deck riaperto dall'ora di
23
+ // prima invece che da `missing`.
24
+ const [cache, setCache] = useState(() => readStatusCache(path));
25
+ const [startedAt, setStartedAt] = useState(null);
26
+ const [failed, setFailed] = useState(false);
27
+ const [now, setNow] = useState(() => Date.now());
28
+ const [view, setView] = useState(null);
29
+ const [top, setTop] = useState(0);
30
+ // Il contatore dei secondi ha un tick PROPRIO, acceso solo mentre serve. Il
31
+ // poll del deck è a 1500ms: appeso a quello l'elapsed avanzerebbe a scatti
32
+ // irregolari. Un intervallo permanente, all'opposto, ridisegnerebbe il deck
33
+ // ogni secondo per sempre — costo invisibile in sviluppo, non su una macchina
34
+ // carica.
35
+ useEffect(() => {
36
+ if (startedAt === null)
37
+ return;
38
+ setNow(Date.now());
39
+ const id = setInterval(() => setNow(Date.now()), 1000);
40
+ return () => clearInterval(id);
41
+ }, [startedAt]);
42
+ const label = statusLabel({ mtime: cache?.mtime ?? null, startedAt, now, failed });
43
+ function finish(ok, detail) {
44
+ setStartedAt(null);
45
+ const text = detail.trim();
46
+ if (!ok || !text) {
47
+ // Nessuna riscrittura della cache: il recap precedente resta leggibile e
48
+ // `^O` continua ad aprirlo. Il marker in testata è l'unica traccia che
49
+ // sopravvive alla riga di stato, che sparisce al primo tasto premuto.
50
+ setFailed(true);
51
+ setNote(`⚠ project status fallito · ${cut(detail || `${CLAUDE_CMD} -p`, 56)}`);
52
+ notifyDone('loom-deck · project status', `${name}: generazione fallita`);
53
+ return;
54
+ }
55
+ try {
56
+ writeStatusCache(path, text);
57
+ }
58
+ catch (e) {
59
+ setFailed(true);
60
+ setNote(`⚠ project status: cache non scrivibile (${e.message})`);
61
+ notifyDone('loom-deck · project status', `${name}: cache non scrivibile`);
62
+ return;
63
+ }
64
+ // Ri-letta da disco invece di comporre `{text, mtime: Date.now()}`: l'ora
65
+ // che l'indicatore mostra deve essere quella del file, o testata e viewer
66
+ // finirebbero a dire due orari diversi dello stesso recap.
67
+ setCache(readStatusCache(path));
68
+ setFailed(false);
69
+ setNote('✔ project status aggiornato · ^O per aprirlo');
70
+ notifyDone('loom-deck · project status', `${name}: recap pronto`);
71
+ }
72
+ function generate() {
73
+ if (startedAt !== null) {
74
+ setNote(`^G → generazione già in corso (${label})`);
75
+ return;
76
+ }
77
+ const sid = randomUUID();
78
+ setFailed(false);
79
+ setStartedAt(Date.now());
80
+ setNote(`⏳ project status di ${name}… (sid ${sid.slice(0, 8)})`);
81
+ const child = spawnProjectStatus(cwd, sid, finish);
82
+ child.on('error', () => {
83
+ setStartedAt(null);
84
+ setFailed(true);
85
+ setNote(`⚠ project status: '${CLAUDE_CMD}' non lanciabile`);
86
+ });
87
+ }
88
+ function open() {
89
+ if (!cache) {
90
+ // Un viewer vuoto direbbe «il recap è questo, ed è nulla»: la cache
91
+ // assente è uno stato del progetto, non un documento da mostrare.
92
+ setNote(startedAt !== null
93
+ ? '^O → nessun recap in cache · la prima generazione è in corso'
94
+ : '^O → nessun recap in cache · ^G per generarlo');
95
+ return;
96
+ }
97
+ setView({ text: cache.text, mtime: cache.mtime, stale: startedAt !== null });
98
+ setTop(0);
99
+ setNote('');
100
+ setMode('status');
101
+ }
102
+ function close() {
103
+ setMode('normal');
104
+ setView(null);
105
+ }
106
+ // Cornici da scalare: box esterno (2 bordi + 2 padding) + box testo (2 bordi
107
+ // + 2 padding) = 8, come il detail.
108
+ const width = Math.max(20, (columns || 80) - 8);
109
+ // Il markdown si rende PRIMA del wrap: `**foo**` occupa 3 colonne rese e 7
110
+ // grezze, quindi wrappare sui marker manderebbe a capo su un conteggio che il
111
+ // terminale non disegna.
112
+ const doc = useMemo(() => (view ? parseMarkdown(view.text) : null), [view]);
113
+ const lines = useMemo(() => (doc ? wrapWithOffsets(doc.text, width) : []), [doc, width]);
114
+ const capacity = statusCapacity(rows);
115
+ const maxTop = Math.max(0, lines.length - capacity);
116
+ function scroll(delta) {
117
+ setTop((t) => Math.max(0, Math.min(maxTop, t + delta)));
118
+ }
119
+ function onKey(input, key) {
120
+ if (key.escape) {
121
+ close();
122
+ }
123
+ else if (key.upArrow) {
124
+ scroll(-1);
125
+ }
126
+ else if (key.downArrow) {
127
+ scroll(1);
128
+ }
129
+ else if (key.pageUp) {
130
+ scroll(-capacity);
131
+ }
132
+ else if (key.pageDown) {
133
+ scroll(capacity);
134
+ }
135
+ else if (input === 'g') {
136
+ // `g`/`G` sugli estremi, come nel reader e nel detail: anche questa
137
+ // schermata scorre testo grezzo, quindi eredita lo stesso alfabeto invece
138
+ // di inventarne un secondo.
139
+ setTop(0);
140
+ }
141
+ else if (input === 'G') {
142
+ setTop(maxTop);
143
+ }
144
+ }
145
+ return {
146
+ label,
147
+ building: startedAt !== null,
148
+ failed,
149
+ view,
150
+ doc,
151
+ lines,
152
+ top,
153
+ capacity,
154
+ maxTop,
155
+ generate,
156
+ open,
157
+ onKey,
158
+ };
159
+ }
@@ -0,0 +1,87 @@
1
+ // T121 — il recap di progetto come ARTEFATTO invece che come conversazione.
2
+ //
3
+ // La skill `loom-works:recap-status-project` produce un testo che oggi vive
4
+ // solo dentro la sessione che l'ha chiesta: il deck la invoca in headless e ne
5
+ // scrive il risultato su disco, così un deck riaperto lo ritrova invece di
6
+ // ripartire da zero.
7
+ //
8
+ // Logica pura + accesso al file, zero React: gli stati dell'indicatore si
9
+ // collaudano senza pseudo-terminale.
10
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { tmpdir } from 'node:os';
13
+ import { fmtTime, WARN } from './glyphs.js';
14
+ /**
15
+ * Cartella della cache, una per UTENTE.
16
+ *
17
+ * `/tmp` è condivisa e world-writable col solo sticky bit: un file a nome
18
+ * prevedibile lì dentro può essere preceduto da un symlink piazzato da un altro
19
+ * utente, e la scrittura lo seguirebbe. La cartella con `mode 0700` chiude il
20
+ * caso senza rinunciare a `/tmp` (D7), e l'uid è stabile quanto il path del
21
+ * progetto — la coordinata resta una funzione, non un registro.
22
+ */
23
+ export function statusDir() {
24
+ return join(tmpdir(), `loom-deck-status-${process.getuid?.() ?? 0}`);
25
+ }
26
+ /**
27
+ * Coordinata del file di cache: derivata dal path del progetto con la stessa
28
+ * trasformazione del transcript store di Claude Code (non-alnum → `-`).
29
+ *
30
+ * L'env override esiste per i test e per la verifica a mano: senza, provare il
31
+ * viewer richiederebbe di spendere una generazione vera da minuti.
32
+ */
33
+ export function statusFilePath(projectRoot) {
34
+ const env = process.env.LOOM_DECK_STATUS_FILE;
35
+ if (env)
36
+ return env;
37
+ return join(statusDir(), `${projectRoot.replace(/[^a-zA-Z0-9]/g, '-')}.md`);
38
+ }
39
+ /** File assente o illeggibile → nessuna cache. Mai un throw: `missing` non è
40
+ * un errore, è lo stato di partenza di ogni progetto. */
41
+ export function readStatusCache(path) {
42
+ try {
43
+ const text = readFileSync(path, 'utf8');
44
+ return { text, mtime: statSync(path).mtimeMs };
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * Riporta a markdown gli heading scritti per la chat di Claude Code.
52
+ *
53
+ * L'output style del progetto impone lì un `#` in PIÙ (`# ## Sezione` per un
54
+ * H2), perché quel terminale stila solo l'H1 e senza il trucco la gerarchia si
55
+ * appiattirebbe. Il deck però rende markdown vero: lasciato com'è, ogni titolo
56
+ * del recap comparirebbe come un H1 col testo letterale `## Sezione`.
57
+ *
58
+ * Il deck è il consumatore, quindi la traduzione la paga lui: la skill produce
59
+ * per la chat, che resta il suo canale principale. La sostituzione è sicura
60
+ * anche se la convenzione sparisse — richiede altri `#` dopo il primo, quindi
61
+ * un heading markdown normale non la incontra mai.
62
+ */
63
+ export function normalizeChatHeadings(text) {
64
+ return text.replace(/^# (#+ )/gm, '$1');
65
+ }
66
+ export function writeStatusCache(path, text) {
67
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
68
+ writeFileSync(path, normalizeChatHeadings(text), { mode: 0o600 });
69
+ }
70
+ export const STATUS_MISSING = 'missing';
71
+ /**
72
+ * Il campo fra parentesi della testata, nei suoi tre stati.
73
+ *
74
+ * Il marker di fallimento è un'AGGIUNTA e non un quarto stato (D5): un tentativo
75
+ * fallito non riscrive la cache, quindi il dato precedente resta vero e ancora
76
+ * apribile — sostituirlo con `failed` butterebbe via un'informazione che c'è.
77
+ * I due fatti (cosa c'è in cache, com'è andato l'ultimo tentativo) sono sempre
78
+ * entrambi veri nello stesso istante.
79
+ */
80
+ export function statusLabel(s) {
81
+ const base = s.startedAt !== null
82
+ ? `building ${Math.max(0, Math.floor((s.now - s.startedAt) / 1000))}...`
83
+ : s.mtime !== null
84
+ ? fmtTime(s.mtime)
85
+ : STATUS_MISSING;
86
+ return s.failed ? `${base} ${WARN}` : base;
87
+ }
package/dist/spawn.js CHANGED
@@ -323,8 +323,17 @@ export const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
323
323
  // un fallimento arriva come booleano nudo e la riga di stato può solo dire "non
324
324
  // ha funzionato" — che su un'operazione distruttiva non basta a sapere se
325
325
  // qualcosa è stato rimosso.
326
- function spawnSkill(prompt, cwd, sessionId, onResult) {
327
- const child = spawnOut(CLAUDE_CMD, ['-p', '--output-format', 'stream-json', '--verbose', '--session-id', sessionId, prompt], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
326
+ function spawnSkill(prompt, cwd, sessionId, onResult, extraArgs = []) {
327
+ const child = spawnOut(CLAUDE_CMD, [
328
+ '-p',
329
+ '--output-format',
330
+ 'stream-json',
331
+ '--verbose',
332
+ '--session-id',
333
+ sessionId,
334
+ ...extraArgs,
335
+ prompt,
336
+ ], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
328
337
  let buf = '';
329
338
  let isError = null;
330
339
  let detail = '';
@@ -399,6 +408,48 @@ export function spawnCleanTasks(ids, cwd, sessionId, ignored, onResult) {
399
408
  return null;
400
409
  return spawnSkill(cleanTasksPrompt(ids, ignored), cwd, sessionId, onResult);
401
410
  }
411
+ // T121 — recap di progetto in headless. Qui il `detail` del result event NON è
412
+ // un messaggio d'errore da troncare come per `create-task`/`clean-tasks`: è il
413
+ // DELIVERABLE, cioè il testo intero della skill, e chi chiama lo scrive su
414
+ // disco. Una skill read-only invocata in `-p` è una funzione che ritorna testo.
415
+ //
416
+ // `--permission-mode auto` e nessun token `yolo` (D4): le due cautele coprono
417
+ // cause di stallo diverse e non sono intercambiabili. `yolo` toglie le domande
418
+ // che la skill pone da sé, e `recap-status-project` non ne ha; il modo
419
+ // permissivo toglie le richieste di permesso che il CLI pone su un tool, e
420
+ // quelle in `-p` non hanno nessuno che risponda — il processo resterebbe appeso
421
+ // senza emettere niente, cioè con l'indicatore che conta all'infinito.
422
+ export function spawnProjectStatus(cwd, sessionId, onResult) {
423
+ return spawnSkill('/loom-works:recap-status-project', cwd, sessionId, onResult, [
424
+ '--permission-mode',
425
+ 'auto',
426
+ ]);
427
+ }
428
+ // Comando della notifica desktop. L'override esiste per il collaudo del ramo
429
+ // «binario assente»: su una macchina dove `notify-send` c'è, quel ramo non è
430
+ // altrimenti raggiungibile — e non provarlo significherebbe scoprire su una
431
+ // macchina altrui che un ENOENT non agganciato porta via la TUI.
432
+ export const NOTIFY_CMD = process.env.LOOM_DECK_NOTIFY_CMD ?? 'notify-send';
433
+ // T121 — notifica desktop di fine generazione. La generazione dura minuti e chi
434
+ // l'ha chiesta nel frattempo guarda altrove; la notifica di GNOME persiste nel
435
+ // cassetto, quindi raggiunge anche chi torna dieci minuti dopo — cosa che un
436
+ // suono non fa.
437
+ //
438
+ // Il listener su `error` è OBBLIGATORIO e non difensivo: un `error` non
439
+ // agganciato su un `ChildProcess` diventa un'eccezione non catturata e abbatte
440
+ // il deck, quindi senza di lui l'assenza di `notify-send` ucciderebbe la TUI.
441
+ // Agganciato, la degradazione è MUTA (D6): un binario assente è una proprietà
442
+ // stabile della macchina, e dirlo a ogni generazione sarebbe lo stesso messaggio
443
+ // a ogni giro su una funzione che non è il deliverable.
444
+ export function notifyDone(title, body) {
445
+ const child = spawnOut(NOTIFY_CMD, ['--app-name=loom-deck', title, body], {
446
+ detached: true,
447
+ stdio: 'ignore',
448
+ });
449
+ child.on('error', () => { });
450
+ child.unref?.();
451
+ return child;
452
+ }
402
453
  // T41 — Commit dell'edit. `git commit -- <paths>` committa lo stato working-tree
403
454
  // SOLO di quei path, ignorando l'index: se l'utente ha altro in stage (o altri
404
455
  // file sporchi) non finisce dentro per errore. NON detached: è un'operazione
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // T121 — project status: l'indicatore di testata e il viewer fullscreen.
3
+ //
4
+ // I due stanno nello stesso file perché sono due rese dello STESSO dato — cosa
5
+ // c'è in cache e come sta andando l'ultima generazione — e la testata compare
6
+ // anche dentro il viewer.
7
+ import { Box, Text } from 'ink';
8
+ import { cutParts } from '../width.js';
9
+ import { sanitize } from '../width.js';
10
+ import { LIVE_BUSY } from '../glyphs.js';
11
+ import { fmtTime } from '../glyphs.js';
12
+ import { DetailLine } from './detail-screen.js';
13
+ /** Nessuna occorrenza da evidenziare: il viewer non ha ricerca interna. Costanti
14
+ * di modulo e non letterali inline — un array nuovo a ogni riga renderizzata
15
+ * farebbe lavoro per niente su un recap di duecento righe. */
16
+ const NO_OCC = [];
17
+ /**
18
+ * L'indicatore: `<progetto> :: PROJECT STATUS [ <stato> ]`.
19
+ *
20
+ * Occupa la posizione SINISTRA della testata, quella che era di `loom-deck`
21
+ * (D1): il posto più visibile della cornice va a un dato che cambia, non a
22
+ * un'etichetta costante che chi guarda il deck sa già.
23
+ *
24
+ * Il taglio lo fa `cutParts` e non `wrap` di Ink (invariante ③ di `width.ts`),
25
+ * con `priority` sul blocco fra parentesi: su un terminale stretto a cadere è
26
+ * il nome del progetto — che chi guarda quel deck conosce — mai lo stato, che è
27
+ * l'unica cosa che la riga esiste per dire.
28
+ */
29
+ export function StatusHeadline({ name, label, building, failed, cols, }) {
30
+ // `name` arriva dal file config, cioè da testo arbitrario: un glifo discorde
31
+ // allargherebbe la riga di una cella e manderebbe a capo la testata. Si
32
+ // sanifica QUI e non in `loadIdentity`, perché lo stesso valore serve intatto
33
+ // ai titoli delle tab, dove è la chiave di match di compass.
34
+ const shown = sanitize(name);
35
+ // Il glifo di conversazione viva sta DENTRO le parentesi, attaccato allo
36
+ // stato: qualifica quel campo, e il vocabolario è lo stesso della colonna
37
+ // liveness delle sessioni — una generazione in corso è una conversazione che
38
+ // sta lavorando, non un quinto simbolo da imparare.
39
+ const parts = [shown, ' :: PROJECT STATUS ', `[ ${building ? `${LIVE_BUSY} ` : ''}${label} ]`];
40
+ const shownParts = cutParts(parts, Math.max(4, cols), 2);
41
+ const color = building ? 'yellow' : failed ? 'yellow' : label === 'missing' ? 'gray' : 'green';
42
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: shownParts[0] }), _jsx(Text, { dimColor: true, children: shownParts[1] }), _jsx(Text, { bold: building, color: color, children: shownParts[2] })] }));
43
+ }
44
+ /**
45
+ * Viewer fullscreen del recap: quinta schermata sostitutiva.
46
+ *
47
+ * Stessa forma del detail della task — markdown reso in span tipizzati, righe
48
+ * wrappate col proprio offset — meno l'area di compilazione: qui non c'è niente
49
+ * da lanciare, si legge e basta. Da cui `↑↓` liberi di scorrere il testo, che
50
+ * nel detail sono spesi sul fuoco fra i campi.
51
+ */
52
+ export function StatusScreen({ name, label, building, failed, view, lines, spans, top, total, capacity, columns, }) {
53
+ const last = Math.min(total, top + capacity);
54
+ const width = Math.max(20, (columns || 80) - 4);
55
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(StatusHeadline, { name: name, label: label, building: building, failed: failed, cols: width }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["generato alle ", _jsx(Text, { color: "cyan", children: fmtTime(view.mtime) }), " \u00B7 righe", ' ', total === 0 ? 0 : top + 1, "-", last, " di ", total, view.stale ? (_jsx(Text, { color: "yellow", children: " \u00B7 versione precedente, generazione in corso" })) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiude"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: lines.map((l, i) => (_jsx(DetailLine, { line: l, spans: spans, occ: NO_OCC, current: -1 }, top + i))) })] }));
56
+ }
package/dist/viewport.js CHANGED
@@ -69,6 +69,10 @@ export const MODAL_HEIGHT = {
69
69
  // entra in un box sopra i pane. La sua altezza la distribuisce
70
70
  // `detailCapacity`.
71
71
  detail: 0,
72
+ // T121 — il viewer del project status: quinta sostitutiva. Un recap di
73
+ // progetto è un documento lungo quanto un task file, quindi vale la stessa
74
+ // ragione delle altre quattro. La sua altezza la distribuisce `statusCapacity`.
75
+ status: 0,
72
76
  };
73
77
  /**
74
78
  * Distribuisce le righe disponibili fra lista task, lista sessioni e preview.
@@ -254,6 +258,20 @@ export function detailCapacity(rows, searching = false) {
254
258
  const extra = searching ? DETAIL_SEARCH_CHROME : 0;
255
259
  return Math.max(0, (rows || 24) - SLACK - DETAIL_CHROME - extra);
256
260
  }
261
+ // T121 — cornice del viewer del project status. Gemella di READER_CHROME: il
262
+ // viewer scorre testo e basta, senza l'area di compilazione che rende caro il
263
+ // detail.
264
+ // 2 bordi del box esterno
265
+ // 1 riga di testata (progetto :: PROJECT STATUS)
266
+ // 1 riga meta (ora della generazione · posizione nel testo)
267
+ // 1 riga hint
268
+ // 1 marginTop del box corpo
269
+ // 2 bordi del box corpo
270
+ const STATUS_CHROME = 8;
271
+ /** Righe di recap che entrano nel terminale. */
272
+ export function statusCapacity(rows) {
273
+ return Math.max(0, (rows || 24) - SLACK - STATUS_CHROME);
274
+ }
257
275
  // T52 — cornice del pannello di anteprima sotto la lista occorrenze:
258
276
  // marginTop + 2 bordi + riga meta.
259
277
  const SEARCH_PREVIEW_CHROME = 4;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.48.0",
3
+ "version": "0.49.1",
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": {