@lamemind/loom-deck 0.55.0 → 0.56.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.
@@ -0,0 +1,348 @@
1
+ // Il MODELLO del deck: ciò che si sta guardando e come lo si naviga.
2
+ //
3
+ // Tiene insieme le fonti (task, conversazioni, archiviabili, dettaglio), lo
4
+ // stato di navigazione (focus, selezione dei due pane, vista attiva di ognuno) e
5
+ // le derivazioni che la schermata consuma. Non spawna, non scrive su disco, non
6
+ // legge la tastiera: chi agisce è `actions.ts`, chi dispaccia è `input.ts`.
7
+ //
8
+ // T131 — la superficie di ritorno è larga (~35 campi), e va dichiarato invece
9
+ // che nascosto. La ragione per cui va bene qui e non altrove è che questo È il
10
+ // modello: l'insieme di ciò che la vista consuma, e un modello con trenta campi
11
+ // resta tale che sia scritto in un `return` o sparso nel corpo di un
12
+ // componente. Quello che non va fatto è passarlo PEZZO PER PEZZO ai
13
+ // consumatori — gli attuatori e il dispatch ne ricevono l'oggetto intero, ed è
14
+ // la ragione per cui i loro conti di parametri restano leggibili.
15
+ import { useEffect, useMemo, useState } from 'react';
16
+ import { loadArchivableDays, loadIdentity, loadLaunch } from './config.js';
17
+ import { useArchivable, useDirtyFolders, useSessions, useTaskDetail, useTasks, } from './hooks.js';
18
+ import { isDone } from './layout.js';
19
+ import { TASK_EMPTY, relTime } from './glyphs.js';
20
+ import { assembleSessionList, firstSelectableId, rowIndexOf, selectedSession, } from './session-list.js';
21
+ import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, } from './pane-views.js';
22
+ import { ALL, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, ROW_ALL, ROW_SPOT, SPOT, } from './model.js';
23
+ import { applyView, taskColumns } from './view.js';
24
+ import { loadView } from './view-store.js';
25
+ import { termWidth } from './width.js';
26
+ /**
27
+ * Conteggio dei figli per task, più il rollup delle VIVE e le conversazioni
28
+ * senza binding.
29
+ *
30
+ * T124 — il rollup delle vive si deriva NELLO STESSO CICLO del conteggio, non
31
+ * da un secondo passaggio sul registry dei processi: quel registry conosce una
32
+ * conversazione PRIMA che abbia scritto il suo primo record di transcript,
33
+ * quindi due derivazioni indipendenti renderebbero producibile `1/0` — una viva
34
+ * senza totale — che non è un caso limite teorico ma la finestra normale fra lo
35
+ * spawn e il primo turno. Intersecando qui, **`vive ≤ totali` è garantito per
36
+ * costruzione**: la conversazione appena nata resta invisibile a entrambi i
37
+ * numeri finché non scrive, e il segnale arriva in ritardo di un turno invece
38
+ * che sbagliato.
39
+ */
40
+ export function rollupChildren(sessions, bindings, live) {
41
+ const childCount = new Map();
42
+ const taskLive = new Map();
43
+ let spotCount = 0;
44
+ for (const s of sessions) {
45
+ const bound = bindings.get(s.sessionId);
46
+ if (!bound) {
47
+ spotCount++;
48
+ continue;
49
+ }
50
+ childCount.set(bound, (childCount.get(bound) ?? 0) + 1);
51
+ const entry = live.get(s.sessionId);
52
+ if (!entry)
53
+ continue;
54
+ const prev = taskLive.get(bound);
55
+ // Rollup a stato misto: vince `busy`. Fra N vive, quella che sta lavorando è
56
+ // la ragione per cui si guarda la riga.
57
+ taskLive.set(bound, {
58
+ count: (prev?.count ?? 0) + 1,
59
+ status: prev?.status === 'busy' || entry.status === 'busy' ? 'busy' : 'idle',
60
+ });
61
+ }
62
+ return { childCount, taskLive, spotCount };
63
+ }
64
+ /**
65
+ * T60 — larghezze delle colonne fisse della lista sessioni, misurate sulla
66
+ * lista INTERA e non sulla finestra visibile: derivarle dalle sole righe a
67
+ * schermo le farebbe cambiare a ogni scroll, cioè l'opposto di una tabella.
68
+ *
69
+ * La colonna task esiste solo nella vista "tutte" (altrove il binding è lo
70
+ * stesso su ogni riga e sta già nell'header del pane), e `0` la spegne.
71
+ */
72
+ export function sessionColumns(rows, bindings, isAll) {
73
+ let task = 0;
74
+ let age = 2;
75
+ for (const r of rows) {
76
+ // T133 D10 — la colonna serve dove l'appartenenza NON è già scritta
77
+ // altrove sullo schermo, cioè nella sola vista "tutte": ogni altra lista
78
+ // contiene le figlie del parent selezionato, che l'header nomina.
79
+ if (isAll) {
80
+ const b = bindings.get(r.sessionId);
81
+ if (b)
82
+ task = Math.max(task, termWidth(b));
83
+ }
84
+ if (r.kind === 'session')
85
+ age = Math.max(age, termWidth(relTime(r.session.ts)));
86
+ }
87
+ // La cella vuota deve poter entrare nella colonna, o le righe spot
88
+ // perderebbero il segnaposto e con lui l'allineamento.
89
+ return { task: task > 0 ? Math.max(task, termWidth(TASK_EMPTY)) : 0, age };
90
+ }
91
+ /**
92
+ * Le conversazioni figlie della selezione: tutte quelle del progetto (`≡
93
+ * tutte`), quelle bound alla task selezionata, oppure (spot) quelle senza
94
+ * binding. `sessions` è già ts desc → l'ordine si eredita in tutti e tre i rami.
95
+ */
96
+ export function childSessionsOf(sessions, bindings, selectedTaskId, isAll) {
97
+ if (isAll)
98
+ return sessions;
99
+ return sessions.filter((s) => {
100
+ const bound = bindings.get(s.sessionId);
101
+ return selectedTaskId ? bound === selectedTaskId : !bound;
102
+ });
103
+ }
104
+ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
105
+ const { tasks, loadError } = useTasks(tasksPath);
106
+ // `notes` esce dall'indice come `sessionNotes`: nel deck `note` è già la riga
107
+ // di STATO in fondo al frame (il feedback di un'azione). Due concetti diversi
108
+ // a una lettera di distanza sarebbero una trappola di lettura — e di
109
+ // scrittura, visto che `setNote` compare in quasi ogni ramo.
110
+ const { sessions, bindings, forkOf, pinned, notes: sessionNotes, live, reload: reloadSessions, } = useSessions(cwd);
111
+ const [focus, setFocus] = useState('tasks');
112
+ // T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
113
+ // (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
114
+ // grezzo per posizione spawnerebbe la task sbagliata, in silenzio.
115
+ // T59 — e le righe meta sono sentinelle, non `null`: gli stati sono tre.
116
+ // D4 — si apre su `≡ tutte`: la vista più ampia in cima, poi si scende verso
117
+ // i sottoinsiemi. La selezione non è persistita (a differenza di `view`, T39),
118
+ // quindi questo atterraggio vale a ogni avvio.
119
+ const [sel, setSel] = useState(ALL);
120
+ // T50 — selezione del pane sessioni KEYED SU sessionId (non indice): la lista
121
+ // a due gruppi + separatore è una vista trasformata, un indice grezzo punterebbe
122
+ // alla riga sbagliata dopo un pin o un cambio di contesto (stesso trap T39).
123
+ const [selSessionId, setSelSessionId] = useState(null);
124
+ // T39 — vista corrente (filtri + sort). Vive nel modello e non nell'hook dei
125
+ // modali che la editano: è ciò che `applyView` consuma per produrre la lista,
126
+ // è persistita su disco e la rilegge il tasto `w`. È stato del modello che due
127
+ // overlay scrivono, non stato di un overlay.
128
+ const [view, setView] = useState(() => loadView(cwd));
129
+ // T100 — vista attiva di ciascun pane, navigata con `tab`. VOLATILE per
130
+ // decisione (D3 create): non entra in `deck-view.json`, il deck riapre sempre
131
+ // su `Tasks` e su `{parent}`. Il criterio è il rischio di leggere una lista
132
+ // parziale credendola completa — un filtro salvato lo si è scelto, una vista
133
+ // riaperta a freddo si legge come la lista intera.
134
+ const [taskViewId, setTaskViewId] = useState('tasks');
135
+ const [sessionViewId, setSessionViewId] = useState('context');
136
+ // Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
137
+ const launch = useMemo(() => loadLaunch(cwd), [cwd]);
138
+ // Identità (T37): titolo delle tab terminale spawnate col tasto `t`.
139
+ const identity = useMemo(() => loadIdentity(cwd), [cwd]);
140
+ // T53 — il core che ogni titolo di tab porta, quindi la colonna costante da
141
+ // togliere quando serve spazio. Hoistato perché lo consumano DUE schermate
142
+ // (lista e ricerca): calcolarlo su ogni call site è il modo in cui le due
143
+ // smettono di togliere la stessa cosa.
144
+ // T58 — il core è il solo `name`, non `<emoji> <name>` come la chiave di match
145
+ // di compass: qui non serve entropia (è un taglio cosmetico, non un matcher) e
146
+ // il nome nudo ripulisce anche i titoli storici, scritti quando la formula
147
+ // includeva l'owner.
148
+ const projectCore = identity ? identity.name : null;
149
+ const projectName = cwd.split('/').pop() || cwd;
150
+ // La vista è una trasformazione DERIVATA, applicata a valle del load: il
151
+ // polling di tasks.md continua a funzionare senza saperne nulla.
152
+ const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
153
+ // T61 — il conteggio guarda la lista GREZZA, non `viewTasks`: le Done fuori
154
+ // dai filtri della vista restano archiviabili, e un contatore che cambiasse
155
+ // filtrando direbbe qualcosa sulla vista invece che sulla task list.
156
+ const doneSig = useMemo(() => tasks.filter((t) => isDone(t.prog)).map((t) => t.id).join(','), [tasks]);
157
+ const archivableDays = useMemo(() => loadArchivableDays(cwd), [cwd]);
158
+ const archivable = useArchivable(doneSig, tasksDir, cwd, archivableDays);
159
+ // T112 — secondo asse di stato della vista `archiviabili`: quali fra le
160
+ // eliminabili hanno una folder che `git rm` non svuoterebbe. Si misura solo
161
+ // su quelle, non su tutta la lista: fuori da quella vista `CANC` agisce su
162
+ // una task sola e il dato lo ricalcola al momento.
163
+ const archivableSig = useMemo(() => [...archivable].sort().join(','), [archivable]);
164
+ const dirtyFolders = useDirtyFolders(archivableSig, tasksDir, cwd);
165
+ // T100 — le task effettivamente a schermo: la vista principale coincide con
166
+ // `viewTasks` (nessun ricalcolo sul cammino di default), le altre due passano
167
+ // dal predicato del catalogo. I CONTATORI restano misurati sulla vista di
168
+ // default, o navigare cambierebbe i numeri che si sta navigando.
169
+ const taskCounts = {
170
+ filtered: viewTasks.length,
171
+ total: tasks.length,
172
+ hidden: hiddenTasks,
173
+ archivable: archivable.size,
174
+ };
175
+ const paneTasks = useMemo(() => taskViewId === 'tasks' ? viewTasks : selectTasks(tasks, taskViewId, { view, archivable }), [taskViewId, viewTasks, tasks, view, archivable]);
176
+ const isSpot = sel === SPOT;
177
+ const isAll = sel === ALL;
178
+ // T112 — quando `CANC` pota in BLOCCO invece della sola task selezionata.
179
+ //
180
+ // La SELEZIONE batte la vista, non il contrario: con una riga task sotto il
181
+ // caret il tasto tocca quella task e basta, in ogni vista. Il bulk vive solo
182
+ // sulle righe meta della vista `archiviabili`, cioè richiede di essere usciti
183
+ // da ogni riga task — così l'azione di massa non è mai a un tasto di distanza
184
+ // da quella singola, e non si preme guardando una task evidenziata credendo
185
+ // che il bersaglio sia lei.
186
+ //
187
+ // Derivato una volta e letto sia dal ramo di apertura sia dalla legenda: due
188
+ // condizioni scritte due volte direbbero due cose diverse sullo stesso tasto
189
+ // alla prima modifica.
190
+ const purgeBulk = taskViewId === 'archivable' && (isAll || isSpot);
191
+ // Unica fonte della selezione: si legge SEMPRE dalla vista, mai dall'array
192
+ // grezzo — è l'invariante che tiene allineati dettaglio mostrato e spawn.
193
+ const selTask = typeof sel === 'string' ? paneTasks.find((t) => t.id === sel) ?? null : null;
194
+ const selectedTaskId = selTask?.id ?? null;
195
+ const selIndex = selTask ? paneTasks.indexOf(selTask) + META_ROWS : isAll ? ROW_ALL : ROW_SPOT;
196
+ const detail = useTaskDetail(tasksDir, selectedTaskId ?? undefined);
197
+ // Il parent delle conversazioni: l'asse che sceglie il pane task, ortogonale
198
+ // alla vista che sceglie l'header (D2 create).
199
+ const parentLabel = isAll ? 'tutte' : isSpot ? 'spot' : selectedTaskId ?? '—';
200
+ const { childCount, taskLive, spotCount } = rollupChildren(sessions, bindings, live);
201
+ const taskRowData = { childCount, live: taskLive, dirty: dirtyFolders };
202
+ // T118 — colonne fisse della lista task, misurate su `paneTasks` (la vista
203
+ // attiva INTERA) e non sulla finestra visibile: gemelle di `sessionCols` e
204
+ // per la stessa ragione, che una larghezza derivata dallo schermo si muove a
205
+ // ogni scroll.
206
+ const taskCols = taskColumns(paneTasks, taskRowData);
207
+ // Memoizzato così `sessionRows` resta stabile fra render che non cambiano gli
208
+ // input: l'effect di validità della selezione non rigira a vuoto.
209
+ const childSessions = useMemo(() => childSessionsOf(sessions, bindings, selectedTaskId, isAll), [sessions, bindings, selectedTaskId, isAll]);
210
+ // T133 — lista unica: le figlie del parent, `ts desc`, cap su tutte. Le
211
+ // pinnate del progetto escono a parte (`pinnedRows`), per la sola vista `📌`.
212
+ // Core PURO in session-list.ts (testabile senza Ink).
213
+ const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, isAll ? MAX_SESSIONS_ALL : MAX_SESSIONS), [childSessions, sessions, pinned, isAll]);
214
+ // T62 — contato sulla lista INTERA (stessa ragione delle larghezze di colonna
215
+ // qui sotto): derivarlo dalla finestra visibile lo farebbe cambiare a ogni
216
+ // scroll, cioè un contatore che conta lo schermo invece della lista.
217
+ // T100 — «intera» ora vuol dire la lista della vista di DEFAULT (`assembled`),
218
+ // non quella a schermo: il contatore di una voce del catalogo non può
219
+ // dipendere da quale voce è selezionata, o navigare muoverebbe i numeri.
220
+ const liveCount = useMemo(() => assembled.rows.filter((r) => live.has(r.sessionId)).length, [assembled, live]);
221
+ const sessionCounts = {
222
+ total: assembled.contextTotal,
223
+ live: liveCount,
224
+ pinned: assembled.pinnedRows.length,
225
+ older: assembled.overflowRows.length,
226
+ };
227
+ // T100 — le righe a schermo sono quelle della vista attiva.
228
+ const sessionRows = useMemo(() => selectSessionRows(sessionViewId, { assembled, isLive: (id) => live.has(id) }), [sessionViewId, assembled, live]);
229
+ const selSessionObj = selectedSession(sessionRows, selSessionId);
230
+ const sessionCols = useMemo(() => sessionColumns(sessionRows, bindings, isAll), [sessionRows, bindings, isAll]);
231
+ // T39 — selezione stabile sotto trasformazione. Se la task selezionata esce
232
+ // dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
233
+ // sulla prima visibile — fallback deterministico, mai una posizione a caso.
234
+ useEffect(() => {
235
+ if (typeof sel === 'string' && !paneTasks.some((t) => t.id === sel)) {
236
+ setSel(paneTasks[0]?.id ?? ALL);
237
+ }
238
+ }, [paneTasks, sel]);
239
+ // T50 — la selezione (id) resta valida sotto la vista a due gruppi: se l'id
240
+ // non è più una riga selezionabile (cambio parent, lista mutata, pin rimosso,
241
+ // sessione sparita) cade sulla prima riga — fallback deterministico, mai una
242
+ // posizione a caso. Sostituisce il reset-a-0 e il clamp index-based.
243
+ useEffect(() => {
244
+ if (rowIndexOf(sessionRows, selSessionId) < 0) {
245
+ setSelSessionId(firstSelectableId(sessionRows));
246
+ }
247
+ }, [sessionRows, selSessionId]);
248
+ /** Selezione per INDICE nella vista, riconvertita subito in sentinella o id.
249
+ * T21 — la chiama anche il click su una riga del pane task. */
250
+ function selectTaskRow(index) {
251
+ if (index === ROW_ALL)
252
+ setSel(ALL);
253
+ else if (index === ROW_SPOT)
254
+ setSel(SPOT);
255
+ else
256
+ setSel(paneTasks[index - META_ROWS]?.id ?? SPOT);
257
+ }
258
+ // Sposta la selezione di `delta` righe nella VISTA (0 = tutte, 1 = spot,
259
+ // 2..N+1 = task visibili) e la riconverte subito in sentinella o id: l'indice
260
+ // non sopravvive a un cambio di filtro, l'id sì.
261
+ function moveTaskSel(delta) {
262
+ selectTaskRow(Math.max(0, Math.min(paneTasks.length + META_ROWS - 1, selIndex + delta)));
263
+ }
264
+ // T21 — per VISTA e non per passo: il click sull'header nomina una voce, e
265
+ // `tab` la raggiunge ciclando. Sulla vista già attiva non si fa niente — un
266
+ // click che azzerasse la selezione alla riga in alto sarebbe un effetto che
267
+ // la tastiera non ha mai avuto, perché `tab` non può restare ferma.
268
+ function selectTaskView(next) {
269
+ if (next === taskViewId)
270
+ return;
271
+ setTaskViewId(next);
272
+ setSel(ALL);
273
+ setNote(`vista task: ${taskView(next).label(taskCounts)}`);
274
+ }
275
+ function selectSessionView(next) {
276
+ if (next === sessionViewId)
277
+ return;
278
+ setSessionViewId(next);
279
+ setSelSessionId(null);
280
+ setNote(`vista sessioni: ${sessionView(next).label(sessionCounts, parentLabel)}`);
281
+ }
282
+ // T100 — `tab` naviga il catalogo viste del pane in focus. Il reset della
283
+ // selezione è la regola letterale «prima riga in alto», senza eccezioni: sul
284
+ // pane task è `ROW_ALL` (D2 preflight — le righe meta non si saltano, e il
285
+ // parent delle sessioni che torna a `tutte` è un effetto accettato); sul pane
286
+ // sessioni basta invalidare l'id, e l'effect di validità atterra sulla prima
287
+ // riga selezionabile della vista nuova.
288
+ function cycleView(delta) {
289
+ if (focus === 'tasks')
290
+ selectTaskView(cycleTaskView(taskViewId, delta));
291
+ else
292
+ selectSessionView(cycleSessionView(sessionViewId, delta));
293
+ }
294
+ return {
295
+ // fonti
296
+ tasks,
297
+ loadError,
298
+ sessions,
299
+ bindings,
300
+ forkOf,
301
+ pinned,
302
+ sessionNotes,
303
+ live,
304
+ reloadSessions,
305
+ detail,
306
+ // progetto
307
+ launch,
308
+ identity,
309
+ projectCore,
310
+ projectName,
311
+ // stato di navigazione
312
+ focus,
313
+ setFocus,
314
+ sel,
315
+ selSessionId,
316
+ setSelSessionId,
317
+ view,
318
+ setView,
319
+ taskViewId,
320
+ sessionViewId,
321
+ // derivazioni del pane task
322
+ viewTasks,
323
+ hiddenTasks,
324
+ taskCounts,
325
+ paneTasks,
326
+ isSpot,
327
+ isAll,
328
+ purgeBulk,
329
+ selTask,
330
+ selectedTaskId,
331
+ selIndex,
332
+ parentLabel,
333
+ taskRowData,
334
+ taskCols,
335
+ spotCount,
336
+ // derivazioni del pane sessioni
337
+ sessionRows,
338
+ sessionCounts,
339
+ selSessionObj,
340
+ sessionCols,
341
+ // mutatori di navigazione
342
+ selectTaskRow,
343
+ moveTaskSel,
344
+ selectTaskView,
345
+ selectSessionView,
346
+ cycleView,
347
+ };
348
+ }
package/dist/frame.js ADDED
@@ -0,0 +1,178 @@
1
+ // La GEOMETRIA di un frame del deck: cosa entra a schermo, dove, e quali righe
2
+ // sono cliccabili.
3
+ //
4
+ // Modulo PURO — nessun React, nessuno stato, nessun effetto. Prende le misure
5
+ // del terminale e le liste intere, restituisce il budget d'altezza, le finestre
6
+ // di rendering e la mappa dell'hit-test. Ne discende che è testabile a unità,
7
+ // che è la ragione principale per cui è uscito da `cli.tsx`: qui vivono due
8
+ // invarianti che prima erano affidate a un commento — la legenda che non deve
9
+ // mentire sul bersaglio di `CANC`, e le righe cliccabili che devono essere
10
+ // esattamente quelle disegnate.
11
+ //
12
+ // T131 — l'ordine di calcolo è vincolante rispetto al cablaggio dell'input.
13
+ // `frameGeometry` va invocata PRIMA di `useDeckInput`, perché l'hook del mouse
14
+ // riceve `listGeometry` come valore e non più attraverso una closure che la
15
+ // leggeva dichiarata seicento righe più in basso. Il costo è calcolare il
16
+ // budget anche quando il render esce presto su una schermata sostitutiva:
17
+ // aritmetica su array già in memoria, e `layoutBudget` accetta ogni `Mode`.
18
+ import { headerItems, sessionHeaderParts, taskHeaderParts } from './pane-header.js';
19
+ import { FRAME_TEXT_COL, inlineRegions, LAUNCH_ROW, PANE_TEXT_PAD, paneSpans, rowRegions, } from './mouse.js';
20
+ import { cellWidth, launchLegend, LAUNCH_SEP } from './config.js';
21
+ import { layoutBudget, windowRange } from './viewport.js';
22
+ import { sanitize, termWidth } from './width.js';
23
+ import { META_ROWS } from './model.js';
24
+ import { rowIndexOf } from './session-list.js';
25
+ import { VERSION } from './version.js';
26
+ // Le due surface built-in del cappello, in testa alla riga launch. Non stanno
27
+ // fra i tasti perché hanno la stessa natura delle voci `launch` — fire-once,
28
+ // cwd = project root, nessuno stato — e la differenza è solo che sono
29
+ // universali (nessun progetto le dichiara) invece che custom. Emoji del menu
30
+ // compass: 🤖 = nuova sessione claude. Per il terminale compass usa 🖥️, che nel
31
+ // frame Ink NON passa — `sanitize` lo sostituisce (VTE lo disegna largo 1,
32
+ // string-width dice 2: discordante, invariante ① di width.ts) e resterebbe un
33
+ // `·` muto. 💻 è il gemello concorde; il `sanitize` qui rende il vincolo
34
+ // automatico invece che da ricordare.
35
+ //
36
+ // T21 — `key` è il tasto che la superficie rappresenta: un click su di essa
37
+ // entra nell'handler di tastiera con quel tasto, invece di chiamare l'azione
38
+ // per conto proprio. È ciò che tiene click e tasto per costruzione allineati.
39
+ export const SURFACE_SEGMENTS = [
40
+ { key: 't', text: sanitize('t 💻') },
41
+ { key: 'c', text: sanitize('c 🤖') },
42
+ ];
43
+ /**
44
+ * La legenda della modalità normale. Elenca SOLO i tasti che fanno qualcosa qui
45
+ * e ora: le voci contestuali compaiono quando il pane a fuoco le rende possibili
46
+ * e altrimenti spariscono, invece di annunciarsi inerti con un `—`.
47
+ *
48
+ * Fuori: la navigazione (`↑↓` `←→`), universale in qualunque TUI, e
49
+ * l'indicatore `focus:` — il pane a fuoco si vede già dall'evidenziazione, e
50
+ * ridirlo a parole costava colonne su una riga che tronca in silenzio.
51
+ * Nessuna voce di uscita: non esiste più un tasto che chiuda il deck.
52
+ */
53
+ export function deckLegend(state) {
54
+ const canSpawn = state.focus === 'tasks' && state.hasTask;
55
+ const canResume = state.focus === 'sessions' && state.hasSession;
56
+ // T50 — il pin agisce su qualunque riga selezionata (anche stale, per
57
+ // spinnarla); basta il focus sul pane e una selezione.
58
+ const canPin = state.focus === 'sessions' && state.hasSessionId;
59
+ return sanitize([
60
+ ...(canSpawn ? ['⏎ detail', '^K/^P/^R spawn'] : canResume ? ['⏎ resume'] : []),
61
+ // T112 — la voce nomina il BERSAGLIO, che cambia di taglia senza che
62
+ // cambi il tasto. Legge `purgeBulk`, la stessa condizione del ramo di
63
+ // apertura: una legenda che annunciasse «tutte» dove il tasto ne pota una
64
+ // sola sarebbe peggio di nessuna legenda.
65
+ ...(state.focus === 'tasks' ? [state.purgeBulk ? 'CANC elimina tutte' : 'CANC elimina'] : []),
66
+ ...(canResume ? ['f fork'] : []),
67
+ // T117 · D3 — «titolo» anche qui: è lo stesso valore che il detail chiede
68
+ // allo spawn, e due nomi per una cosa sola li paga chi legge le due
69
+ // schermate. Rename di sola ETICHETTA: il dato resta `note` nel sidecar e
70
+ // `--title-note` in deck-run, dove rinominarlo sarebbe un breaking.
71
+ ...(canPin ? ['p pin', 'N titolo', 'A assegna'] : []),
72
+ // T121 — le due voci nominano il GESTO e non l'oggetto: «status» da solo
73
+ // sarebbe indistinguibile dal recap della task su `^K`, che è un'altra
74
+ // cosa e sta a due voci di distanza.
75
+ '^G genera status',
76
+ '^O apri status',
77
+ '^F cerca',
78
+ 'C nuova',
79
+ 'E edit',
80
+ 'S sort',
81
+ 'F filtri',
82
+ 'w salva',
83
+ ].join(' · '));
84
+ }
85
+ /**
86
+ * T21 — la riga launch come DATO, non come stringa: gli stessi segmenti
87
+ * compongono il testo renderizzato e le colonne dell'hit-test. Derivare le
88
+ * seconde ri-splittando il primo sarebbe un conto parallelo, che diverge alla
89
+ * prima label che contenga il separatore.
90
+ *
91
+ * Le celle delle surface (più il ` · ` che le separa dalle voci) sono già spese
92
+ * sulla riga → vanno riservate, o le voci launch la sfonderebbero di quel tanto.
93
+ */
94
+ export function launchRow(launch, columns) {
95
+ const surfaceLegend = SURFACE_SEGMENTS.map((s) => s.text).join(LAUNCH_SEP);
96
+ const legend = launchLegend(launch, columns, cellWidth(surfaceLegend) + cellWidth(LAUNCH_SEP));
97
+ const segments = [
98
+ ...SURFACE_SEGMENTS,
99
+ ...legend.taken.map((text, i) => ({ key: String(i + 1), text })),
100
+ ];
101
+ return {
102
+ segments,
103
+ regions: rowRegions(segments, LAUNCH_SEP, FRAME_TEXT_COL),
104
+ overflow: legend.overflow,
105
+ unreachable: legend.unreachable,
106
+ };
107
+ }
108
+ /**
109
+ * Il budget d'altezza e le finestre di rendering.
110
+ *
111
+ * Il frame deve restare sotto `rows`, sempre: oltre quella soglia Ink smette di
112
+ * aggiornare per differenza e pulisce lo schermo a ogni redraw, che su Ptyxis
113
+ * significa un frame intero versato nello scrollback per ogni tick del poll.
114
+ * Tutto ciò che varia in altezza (le due liste e la descrizione del dettaglio)
115
+ * riceve qui la propria capienza.
116
+ *
117
+ * Le liste "logiche" restano intere: navigazione, selezione e spawn continuano
118
+ * a ragionare su quelle, la finestra è solo ciò che finisce a schermo.
119
+ */
120
+ export function frameGeometry(input) {
121
+ // Le surface built-in `t`/`c` la rendono sempre presente in modalità normale:
122
+ // non dipende da quante voci `launch` il progetto dichiara.
123
+ const launchLine = input.mode === 'normal';
124
+ const budget = layoutBudget({
125
+ rows: input.rows,
126
+ mode: input.mode,
127
+ launchLine,
128
+ noteLine: input.hasNote,
129
+ preview: input.previewKind,
130
+ detailMetaLines: input.detailMetaLines,
131
+ // Riservo righe di anteprima solo per i blocchi che davvero renderizzano: il
132
+ // primo prompt aggiunge info solo con un titolo custom (senza, titolo ===
133
+ // primo prompt); l'ultima risposta solo se il modello ha già risposto.
134
+ sessionHasFirstPreview: input.sessionHasFirstPreview,
135
+ sessionHasLastPreview: input.sessionHasLastPreview,
136
+ });
137
+ const taskWin = windowRange(input.paneTasks.length, input.selIndex - META_ROWS, budget.taskRows);
138
+ const windowTasks = input.paneTasks.slice(taskWin.start, taskWin.end);
139
+ const selRowIndex = rowIndexOf(input.sessionRows, input.selSessionId);
140
+ const sessionWin = windowRange(input.sessionRows.length, selRowIndex, budget.sessionRows);
141
+ const windowRows = input.sessionRows.slice(sessionWin.start, sessionWin.end);
142
+ // T21 — geometria delle liste per l'hit-test del click, dalla STESSA
143
+ // aritmetica che disegna i pane: le parti degli header escono dal modulo che
144
+ // `ui/panes.tsx` consuma per renderle, le righe cliccabili sono le finestre
145
+ // appena calcolate.
146
+ const spans = paneSpans(input.columns);
147
+ const listGeometry = budget.compact
148
+ ? null
149
+ : {
150
+ columns: input.columns,
151
+ taskHeader: inlineRegions(headerItems(taskHeaderParts(input.taskCounts, input.taskViewId, taskWin.start, input.paneTasks.length - taskWin.end, input.columns)), spans.tasks.start + PANE_TEXT_PAD),
152
+ sessionHeader: inlineRegions(headerItems(sessionHeaderParts(input.parentLabel, input.sessionCounts, input.sessionViewId, sessionWin.start, input.sessionRows.length - sessionWin.end, input.columns)), spans.sessions.start + PANE_TEXT_PAD),
153
+ // Con un errore di caricamento al posto delle task c'è la riga rossa:
154
+ // restano cliccabili le sole righe meta.
155
+ taskRows: META_ROWS + (input.hasLoadError ? 0 : windowTasks.length),
156
+ sessionRows: windowRows.length,
157
+ };
158
+ return {
159
+ budget,
160
+ taskWin,
161
+ windowTasks,
162
+ sessionWin,
163
+ windowRows,
164
+ listGeometry,
165
+ // Dimensione del terminale in CELLE (colonne×righe, mai pixel — un processo
166
+ // dentro un terminale vede solo la griglia di caratteri) e versione. La
167
+ // parte sinistra della testata riceve un budget derivato da questa stringa e
168
+ // non da una lunghezza fissa: la risoluzione cambia a ogni resize, quindi
169
+ // anche la larghezza che occupa.
170
+ headerRight: `${input.columns}×${input.rows} · v${VERSION}`,
171
+ launchLine,
172
+ };
173
+ }
174
+ /** Il budget della testata sinistra, per sottrazione dal segmento destro. */
175
+ export function headlineWidth(columns, headerRight) {
176
+ return Math.max(4, columns - 4 - termWidth(headerRight) - 1);
177
+ }
178
+ export { LAUNCH_ROW };
package/dist/glyphs.js CHANGED
@@ -89,7 +89,7 @@ export function displayProg(prog) {
89
89
  * 3 spazi vuoti su ogni riga di un pane largo la metà del terminale.
90
90
  *
91
91
  * Vive qui e non nel sito di render perché la STESSA stringa serve due volte —
92
- * a misurare la larghezza della colonna (sulla vista completa, in `cli.tsx`) e
92
+ * a misurare la larghezza della colonna (sulla vista completa, in `deck-model.ts`) e
93
93
  * a disegnarla (sulla finestra, in `panes.tsx`). Due formattazioni gemelle
94
94
  * divergerebbero alla prima modifica, e la colonna risulterebbe larga quanto
95
95
  * una stringa che nessuno scrive.
package/dist/inbox.js ADDED
@@ -0,0 +1,135 @@
1
+ // T134 — la coda inbox come dato del deck.
2
+ //
3
+ // La misura non la fa il deck: la fa `doc-metrics.sh --inbox`, che per ogni file
4
+ // invoca `inbox.sh parse` e ne riporta natura e marcatori. È il contrario della
5
+ // scelta fatta per le task archiviabili (`archivable.ts`), dove il criterio —
6
+ // una data meno un'altra — era abbastanza piccolo da replicarlo: qui si
7
+ // replicherebbe la grammatica intera del marker inbox, e una divergenza fra le
8
+ // due copie non si vedrebbe finché non conta i file sbagliati.
9
+ //
10
+ // Lettore puro + spawn: nessun React, nessuna resa. È l'unico modo di provarlo
11
+ // senza pseudo-terminale.
12
+ import { execFile } from 'node:child_process';
13
+ import { promisify } from 'node:util';
14
+ import { basename } from 'node:path';
15
+ import { pluginScript } from './plugin-cache.js';
16
+ const execFileAsync = promisify(execFile);
17
+ export const INBOX_METRICS_SCRIPT = 'scripts/docs/doc-metrics.sh';
18
+ /** Le tre nature che una skill sa consumare, più il file che non si parsa. */
19
+ export const NATURE = ['nozioni', 'derivazione', 'sweep'];
20
+ /**
21
+ * `si`/`no` è la grafia dello script. Qualunque altra cosa (colonna vuota su un
22
+ * file malformato) è `false`: un marcatore che non si è potuto leggere non vale
23
+ * come presente.
24
+ */
25
+ function flag(cell) {
26
+ return cell === 'si';
27
+ }
28
+ function num(cell) {
29
+ const n = Number(cell);
30
+ return Number.isFinite(n) ? n : 0;
31
+ }
32
+ function natura(cell) {
33
+ return NATURE.includes(cell) ? cell : 'malformato';
34
+ }
35
+ /**
36
+ * Il TSV di `doc-metrics.sh --inbox --format tsv`, riga di intestazione
37
+ * compresa. Colonne, nell'ordine emesso dallo script:
38
+ *
39
+ * PATH · NATURA · INDEXED · DRAINABLE · BRANCH · NOZIONI · APERTE · CHAR ·
40
+ * CREATED · AGE_DAYS · CAPPELLO
41
+ *
42
+ * `AGE_DAYS` si scarta di proposito: è troncato al giorno, e la soglia della
43
+ * sirena è in ORE — con i giorni interi 47 e 49 ore sarebbero lo stesso numero.
44
+ * L'età la ricalcola `ageHours` da `CREATED`.
45
+ */
46
+ export function parseInboxTsv(stdout) {
47
+ const out = [];
48
+ for (const line of stdout.split('\n')) {
49
+ if (!line.trim())
50
+ continue;
51
+ const c = line.split('\t');
52
+ if (c[0] === 'PATH' || c.length < 11)
53
+ continue;
54
+ out.push({
55
+ path: c[0],
56
+ basename: basename(c[0]),
57
+ natura: natura(c[1]),
58
+ indexed: flag(c[2]),
59
+ drainable: flag(c[3]),
60
+ branch: c[4] ?? '',
61
+ nozioni: num(c[5]),
62
+ aperte: num(c[6]),
63
+ chars: num(c[7]),
64
+ created: num(c[8]),
65
+ cappello: c[10] ?? '',
66
+ });
67
+ }
68
+ // Dal più vecchio in cima (D8): i più vecchi sono i più urgenti. Lo script
69
+ // emette già in quest'ordine — riordinare qui costa nulla e toglie di mezzo
70
+ // la dipendenza da una garanzia scritta altrove.
71
+ return out.sort((a, b) => a.created - b.created);
72
+ }
73
+ /** Ore intere trascorse da `created` (epoch in secondi). */
74
+ export function ageHours(created, now) {
75
+ return Math.floor((now - created * 1000) / 3_600_000);
76
+ }
77
+ /**
78
+ * Il file promette lavoro che una skill può prendere DA SOLA, senza che nessuno
79
+ * lo nomini: `drainable` e nessun branch che lo congeli.
80
+ *
81
+ * Non è un criterio di ammissibilità dell'azione, ed è la distinzione che tiene
82
+ * in piedi D5: le tre skill dichiarano tutte che un file NOMINATO si esegue
83
+ * anche senza `drainable` — nominarlo è già la decisione che il token
84
+ * dichiarerebbe. Replicare qui quel criterio come guardia impedirebbe ciò che
85
+ * l'utente ha appena chiesto. Serve solo a decidere cosa entra nei contatori.
86
+ */
87
+ export function isQueued(f) {
88
+ return f.drainable && !f.branch && f.natura !== 'malformato';
89
+ }
90
+ /**
91
+ * Un contatore per natura, sui soli file in coda. Un `malformato` non ha natura
92
+ * e non entra in nessuno dei tre — ma resta in lista, perché nasconderlo
93
+ * produrrebbe un contatore che mente al ribasso proprio quando qualcosa si è
94
+ * rotto.
95
+ */
96
+ export function countByNatura(files) {
97
+ const counts = { nozioni: 0, derivazione: 0, sweep: 0 };
98
+ for (const f of files)
99
+ if (isQueued(f))
100
+ counts[f.natura]++;
101
+ return counts;
102
+ }
103
+ export function totalQueued(counts) {
104
+ return counts.nozioni + counts.derivazione + counts.sweep;
105
+ }
106
+ /**
107
+ * D10 — 48 ore. Un campionamento a 6 ore sbaglia al massimo di un ottavo, cioè
108
+ * entro il rumore di una soglia scelta a mano.
109
+ */
110
+ export const DEFAULT_INBOX_STALE_HOURS = 48;
111
+ /** Quanti file in coda hanno passato la soglia: è il numero accanto alla sirena. */
112
+ export function staleCount(files, hours, now) {
113
+ return files.filter((f) => isQueued(f) && ageHours(f.created, now) >= hours).length;
114
+ }
115
+ /**
116
+ * Invoca la misura e ne parsa l'output.
117
+ *
118
+ * I modi di non avere il dato — plugin non installato su questa macchina,
119
+ * script che esce male — collassano in un solo `ok: false` (D2): chi vede
120
+ * l'allerta chiede a Claude di indagare, e distinguerli a schermo non
121
+ * cambierebbe la prima mossa. Nessuno dei due è un throw: un contatore
122
+ * informativo non può rompere il deck.
123
+ */
124
+ export async function scanInbox(projectRoot, docsRoot) {
125
+ const script = pluginScript(INBOX_METRICS_SCRIPT);
126
+ if (!script)
127
+ return { files: [], ok: false };
128
+ try {
129
+ const { stdout } = await execFileAsync(script, ['--docs-root', docsRoot, '--inbox', '--format', 'tsv'], { cwd: projectRoot, maxBuffer: 4 * 1024 * 1024 });
130
+ return { files: parseInboxTsv(stdout), ok: true };
131
+ }
132
+ catch {
133
+ return { files: [], ok: false };
134
+ }
135
+ }