@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.
package/dist/cli.js CHANGED
@@ -1,811 +1,74 @@
1
1
  #!/usr/bin/env node
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { render, Box, Text, useApp, useInput } from 'ink';
4
- import { useState, useEffect, useMemo, useRef } from 'react';
5
- import { randomUUID } from 'node:crypto';
6
- import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskFileText, } from './tasks.js';
7
- import { rowIndexOfKey, selectedRow, } from './search.js';
8
- import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, } from './task-index.js';
9
- import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, selectedSession, } from './session-list.js';
10
- import { cellWidth, launchLegend, LAUNCH_SEP, loadArchivableDays, loadIdentity, loadLaunch, } from './config.js';
11
- import { anchorFrame, enableMouse, FRAME_TEXT_COL, hitRegion, inlineRegions, isWheel, LAUNCH_ROW, listHit, PANE_TEXT_PAD, paneSpans, rowRegions, takeMouse, WHEEL_LINES, wheelDir, } from './mouse.js';
12
- import { headerItems, sessionHeaderParts, taskHeaderParts } from './pane-header.js';
13
- import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, TASK_VIEWS, } from './pane-views.js';
14
- import { isCompact, layoutBudget, searchPreviewCapacity, windowRange, } from './viewport.js';
15
- import { cut, cutMiddle, sanitize, termWidth } from './width.js';
16
- import { applyView, cycleSort, describeSort, priName, progName, taskColumns, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
17
- import { initialDetail, writeTaskEdit, PRI_GLYPH, PRI_LABEL } from './task-edit.js';
18
- import { ALL, EDIT_FIELDS, EDIT_PRI, EDIT_PROG, editTextField, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, QUIT_WINDOW_MS, ROW_ALL, ROW_SPOT, SORT_TASTI, SPOT, } from './model.js';
19
- import { purgeTargets, splitTargets } from './purge.js';
20
- import { conversationLabel, isDone } from './layout.js';
21
- import { fieldsKey } from './fields.js';
22
- import { TASK_EMPTY, relTime, sanitizeTyped } from './glyphs.js';
23
- import { commitTaskEdit, runLaunch, spawnClaudeEmpty, spawnCleanTasks, spawnCreateTask, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, onInTabCommand, CLAUDE_CMD, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
24
- import { EditModal, FilterModal, PurgeModal, SortModal, idList } from './ui/modals.js';
25
- import { ReaderScreen, SearchScreen } from './ui/search-screen.js';
26
- import { DetailScreen } from './ui/detail-screen.js';
27
- import { StatusHeadline, StatusScreen } from './ui/status-screen.js';
28
- import { AssignScreen } from './ui/assign-screen.js';
29
- import { SessionsPane, TasksPane } from './ui/panes.js';
30
- import { PreviewPane, detailMetaOf } from './ui/preview.js';
31
- import { loadView, saveView, viewFilePath } from './view-store.js';
32
- import { useArchivable, useDirtyFolders, useSessions, useTaskDetail, useTasks, useTerminalSize, } from './hooks.js';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // Il deck: composizione e resa della schermata normale.
4
+ //
5
+ // Questo file NON contiene logica. È il cablaggio — chi parla con chi — più il
6
+ // `return` della lista, che è la schermata del deck e non avrebbe senso come
7
+ // componente con venti props e un chiamante solo.
8
+ //
9
+ // L'ordine delle chiamate è imposto dalle dipendenze, non dalla leggibilità:
10
+ //
11
+ // modello → attuatore → overlay → geometria → input
12
+ //
13
+ // La geometria sta PRIMA dell'input (T131): il dispatch riceve `listGeometry`
14
+ // come valore, e prima di questo split la leggeva da una closure risolta solo
15
+ // all'arrivo dell'evento, cioè dopo la fine del render.
16
+ import { render, Box, Text } from 'ink';
17
+ import { useState } from 'react';
18
+ import { resolveTasksPath, resolveTasksDir } from './tasks.js';
19
+ import { LAUNCH_SEP } from './config.js';
20
+ import { anchorFrame, enableMouse } from './mouse.js';
21
+ import { sanitize } from './width.js';
22
+ import { deckLegend, frameGeometry, headlineWidth, launchRow } from './frame.js';
23
+ import { useDeckModel } from './deck-model.js';
24
+ import { useDeckActions } from './actions.js';
25
+ import { useDeckInput } from './input.js';
33
26
  import { useSearchOverlay } from './overlays/search.js';
34
27
  import { useSheetOverlay } from './overlays/sheet.js';
35
28
  import { useAssignOverlay } from './overlays/assign.js';
36
29
  import { useProjectStatus } from './overlays/status.js';
37
- import { captures, scrolls } from './input-modes.js';
30
+ import { usePurgeOverlay } from './overlays/purge.js';
31
+ import { useTextModals, useViewModals } from './overlays/modals.js';
32
+ import { useTerminalSize } from './hooks.js';
33
+ import { EditModal, FilterModal, PurgeModal, SortModal } from './ui/modals.js';
34
+ import { StatusHeadline } from './ui/status-screen.js';
35
+ import { SessionsPane, TasksPane } from './ui/panes.js';
36
+ import { PreviewPane, detailMetaOf } from './ui/preview.js';
37
+ import { CompactNotice, HintBar, TextBox, screenFor } from './ui/screens.js';
38
38
  import { VERSION } from './version.js';
39
- // T116 — l'avviso della prima pressione di `^C`. La durata è INTERPOLATA dalla
40
- // finestra, non ricopiata: un numero scritto a mano in un testo che promette un
41
- // comportamento diventa falso il giorno che la costante cambia, e nessuno strumento
42
- // lo segnala. Vive a livello di modulo perché ha due lettori — il ramo che la
43
- // scrive e il timer che la ritira, e quest'ultimo deve poterla riconoscere.
44
- const QUIT_NOTE = `⚠ ^C di nuovo entro ${QUIT_WINDOW_MS / 1000}s per chiudere il deck`;
45
- // Le due surface built-in del cappello, in testa alla riga launch. Non stanno
46
- // fra i tasti perché hanno la stessa natura delle voci `launch` — fire-once,
47
- // cwd = project root, nessuno stato — e la differenza è solo che sono
48
- // universali (nessun progetto le dichiara) invece che custom. Emoji del menu
49
- // compass: 🤖 = nuova sessione claude. Per il terminale compass usa 🖥️, che nel
50
- // frame Ink NON passa — `sanitize` lo sostituisce (VTE lo disegna largo 1,
51
- // string-width dice 2: discordante, invariante ① di width.ts) e resterebbe un
52
- // `·` muto. 💻 è il gemello concorde; il `sanitize` qui rende il vincolo
53
- // automatico invece che da ricordare.
54
- //
55
- // T21 — `key` è il tasto che la superficie rappresenta: un click su di essa
56
- // entra nell'handler di tastiera con quel tasto, invece di chiamare l'azione
57
- // per conto proprio. È ciò che tiene click e tasto per costruzione allineati.
58
- const SURFACE_SEGMENTS = [
59
- { key: 't', text: sanitize('t 💻') },
60
- { key: 'c', text: sanitize('c 🤖') },
61
- ];
62
- // T21 — il `Key` che accompagna un tasto sintetizzato da un click: nessun
63
- // modificatore, nessun tasto speciale. Deve elencare ogni campo, o i rami che
64
- // leggono `key.ctrl` o `key.tab` riceverebbero `undefined` invece di `false` —
65
- // equivalente nel test di verità, ma non nel tipo.
66
- const NO_MODIFIERS = {
67
- upArrow: false,
68
- downArrow: false,
69
- leftArrow: false,
70
- rightArrow: false,
71
- pageDown: false,
72
- pageUp: false,
73
- return: false,
74
- escape: false,
75
- ctrl: false,
76
- shift: false,
77
- tab: false,
78
- backspace: false,
79
- delete: false,
80
- meta: false,
81
- };
82
39
  function Deck({ cwd, tasksPath, tasksDir }) {
83
- const { tasks, loadError } = useTasks(tasksPath);
84
- // `notes` esce dall'indice come `sessionNotes`: in questo componente `note` è
85
- // già la riga di STATO in fondo al frame (il feedback di un'azione). Due
86
- // concetti diversi a una lettera di distanza sarebbero una trappola di
87
- // lettura — e di scrittura, visto che `setNote` compare in quasi ogni ramo.
88
- const { sessions, bindings, forkOf, pinned, notes: sessionNotes, live, reload: reloadSessions, } = useSessions(cwd);
89
- const [focus, setFocus] = useState('tasks');
90
- // T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
91
- // (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
92
- // grezzo per posizione spawnerebbe la task sbagliata, in silenzio.
93
- // T59 — e le righe meta sono sentinelle, non `null`: gli stati sono tre.
94
- // D4 — si apre su `≡ tutte`: la vista più ampia in cima, poi si scende verso
95
- // i sottoinsiemi. La selezione non è persistita (a differenza di `view`, T39),
96
- // quindi questo atterraggio vale a ogni avvio.
97
- const [sel, setSel] = useState(ALL);
98
- // T50 — selezione del pane sessioni KEYED SU sessionId (non indice): la lista
99
- // a due gruppi + separatore è una vista trasformata, un indice grezzo punterebbe
100
- // alla riga sbagliata dopo un pin o un cambio di contesto (stesso trap T39).
101
- const [selSessionId, setSelSessionId] = useState(null);
40
+ // La riga di STATO in fondo al frame: il feedback dell'ultima azione. Non è
41
+ // la nota di una conversazione, che nel modello si chiama `sessionNotes`.
102
42
  const [note, setNote] = useState('');
103
43
  // Modali: catturano i tasti e corto-circuitano la navigazione normale.
104
- // T30 create · T39 sort/filter.
105
44
  const [mode, setMode] = useState('normal');
106
- const [draft, setDraft] = useState('');
107
- // T53 — bozza della nota sulla conversazione selezionata. Si apre PRECARICATA
108
- // con la nota esistente: annotare due volte è quasi sempre correggere, non
109
- // riscrivere da zero, e un campo vuoto costringerebbe a ridigitare tutto per
110
- // cambiare una parola. Confermare il campo vuoto cancella la nota.
111
- const [noteDraft, setNoteDraft] = useState('');
112
- // T39 — vista corrente (filtri + sort) e sua fotografia all'apertura di un
113
- // modale: la lista si aggiorna dal vivo, quindi `esc` deve poter ripristinare.
114
- const [view, setView] = useState(() => loadView(cwd));
115
- const [viewBackup, setViewBackup] = useState(null);
116
- // T100 — vista attiva di ciascun pane, navigata con `tab`. VOLATILE per
117
- // decisione (D3 create): non entra in `deck-view.json`, il deck riapre sempre
118
- // su `Tasks` e su `{parent}`. Il criterio è il rischio di leggere una lista
119
- // parziale credendola completa — un filtro salvato lo si è scelto, una vista
120
- // riaperta a freddo si legge come la lista intera.
121
- const [taskViewId, setTaskViewId] = useState('tasks');
122
- const [sessionViewId, setSessionViewId] = useState('context');
123
- const [filterCursor, setFilterCursor] = useState({ row: 0, col: 0 });
124
- // T41 — bozza dell'edit (null fuori dal modale) e posizione nell'area di
125
- // compilazione: riga in fuoco + caret dentro la riga di testo attiva.
126
- const [edit, setEdit] = useState(null);
127
- const [editCursor, setEditCursor] = useState({ row: 0, caret: 0 });
128
- // T112 — bozza della conferma di eliminazione (null fuori dal modale).
129
- const [purge, setPurge] = useState(null);
130
- // T116 — uscita a doppio `^C`. Il timer È lo stato dell'armamento: finché il
131
- // handle esiste la finestra è aperta, e non serve un secondo stato da tenere
132
- // in fase con lui. In un `useRef` e non in `useState` perché il ramo di uscita
133
- // lo legge NELLO STESSO tasto in cui potrebbe averlo scritto — un valore di
134
- // stato React arriverebbe al render dopo, cioè troppo tardi per decidere.
135
- const { exit } = useApp();
136
- const quitTimer = useRef(null);
137
- // Dimensioni vive del terminale: sono l'input del budget d'altezza sotto.
45
+ // Dimensioni vive del terminale: sono l'input del budget d'altezza.
138
46
  const { rows, columns } = useTerminalSize();
139
- // Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
140
- const launch = useMemo(() => loadLaunch(cwd), [cwd]);
141
- // Identità (T37): titolo delle tab terminale spawnate col tasto `t`.
142
- const identity = useMemo(() => loadIdentity(cwd), [cwd]);
143
- // T53 — il core che ogni titolo di tab porta, quindi la colonna costante da
144
- // togliere quando serve spazio. Hoistato qui perché ora lo consumano DUE
145
- // schermate (lista e ricerca): calcolarlo su ogni call site è il modo in cui
146
- // le due smettono di togliere la stessa cosa.
147
- // T58 — il core è il solo `name`, non `<emoji> <name>` come la chiave di match
148
- // di compass: qui non serve entropia (è un taglio cosmetico, non un matcher) e
149
- // il nome nudo ripulisce anche i titoli storici, scritti quando la formula
150
- // includeva l'owner.
151
- const projectCore = identity ? identity.name : null;
152
- // La vista è una trasformazione DERIVATA, applicata a valle del load: il
153
- // polling di tasks.md continua a funzionare senza saperne nulla.
154
- const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
155
- // T61 — il conteggio guarda la lista GREZZA, non `viewTasks`: le Done fuori
156
- // dai filtri della vista restano archiviabili, e un contatore che cambiasse
157
- // filtrando direbbe qualcosa sulla vista invece che sulla task list.
158
- const doneSig = useMemo(() => tasks.filter((t) => isDone(t.prog)).map((t) => t.id).join(','), [tasks]);
159
- const archivableDays = useMemo(() => loadArchivableDays(cwd), [cwd]);
160
- const archivable = useArchivable(doneSig, tasksDir, cwd, archivableDays);
161
- // T112 — secondo asse di stato della vista `archiviabili`: quali fra le
162
- // eliminabili hanno una folder che `git rm` non svuoterebbe. Si misura solo
163
- // su quelle, non su tutta la lista: fuori da quella vista `CANC` agisce su
164
- // una task sola e il dato lo ricalcola al momento.
165
- const archivableSig = useMemo(() => [...archivable].sort().join(','), [archivable]);
166
- const dirtyFolders = useDirtyFolders(archivableSig, tasksDir, cwd);
167
- // T100 — le task effettivamente a schermo: la vista principale coincide con
168
- // `viewTasks` (nessun ricalcolo sul cammino di default), le altre due passano
169
- // dal predicato del catalogo. I CONTATORI restano misurati sulla vista di
170
- // default, o navigare cambierebbe i numeri che si sta navigando.
171
- const taskCounts = {
172
- filtered: viewTasks.length,
173
- total: tasks.length,
174
- hidden: hiddenTasks,
175
- archivable: archivable.size,
176
- };
177
- const paneTasks = useMemo(() => taskViewId === 'tasks'
178
- ? viewTasks
179
- : selectTasks(tasks, taskViewId, { view, archivable }), [taskViewId, viewTasks, tasks, view, archivable]);
180
- const isSpot = sel === SPOT;
181
- const isAll = sel === ALL;
182
- // T112 — quando `CANC` pota in BLOCCO invece della sola task selezionata.
183
- //
184
- // La SELEZIONE batte la vista, non il contrario: con una riga task sotto il
185
- // caret il tasto tocca quella task e basta, in ogni vista. Il bulk vive solo
186
- // sulle righe meta della vista `archiviabili`, cioè richiede di essere usciti
187
- // da ogni riga task — così l'azione di massa non è mai a un tasto di distanza
188
- // da quella singola, e non si preme guardando una task evidenziata credendo
189
- // che il bersaglio sia lei.
190
- //
191
- // Derivato una volta e letto sia dal ramo di apertura sia dalla legenda: due
192
- // condizioni scritte due volte direbbero due cose diverse sullo stesso tasto
193
- // alla prima modifica.
194
- const purgeBulk = taskViewId === 'archivable' && (isAll || isSpot);
195
- const projectName = cwd.split('/').pop() || cwd;
196
- // Unica fonte della selezione: si legge SEMPRE dalla vista, mai dall'array
197
- // grezzo — è l'invariante che tiene allineati dettaglio mostrato e spawn.
198
- const selTask = typeof sel === 'string' ? paneTasks.find((t) => t.id === sel) ?? null : null;
199
- const selectedTaskId = selTask?.id ?? null;
200
- const selIndex = selTask ? paneTasks.indexOf(selTask) + META_ROWS : isAll ? ROW_ALL : ROW_SPOT;
201
- const detail = useTaskDetail(tasksDir, selectedTaskId ?? undefined);
202
- // Il parent delle conversazioni: l'asse che sceglie il pane task, ortogonale
203
- // alla vista che sceglie l'header (D2 create).
204
- const parentLabel = isAll ? 'tutte' : isSpot ? 'spot' : selectedTaskId ?? '—';
205
- // Conteggio figli per task + spot (badge nel Tasks pane).
206
- //
207
- // T124 — il rollup delle VIVE si deriva nello stesso ciclo, non da un secondo
208
- // passaggio sul registry dei processi: quel registry conosce una conversazione
209
- // PRIMA che abbia scritto il suo primo record di transcript, quindi due
210
- // derivazioni indipendenti renderebbero producibile `1/0` — una viva senza
211
- // totale — che non è un caso limite teorico ma la finestra normale fra lo
212
- // spawn e il primo turno. Intersecando qui, `vive ≤ totali` è garantito per
213
- // costruzione: la conversazione appena nata resta invisibile a entrambi i
214
- // numeri finché non scrive, e il segnale arriva in ritardo di un turno invece
215
- // che sbagliato.
216
- const childCount = new Map();
217
- const taskLive = new Map();
218
- let spotCount = 0;
219
- for (const s of sessions) {
220
- const bound = bindings.get(s.sessionId);
221
- if (!bound) {
222
- spotCount++;
223
- continue;
224
- }
225
- childCount.set(bound, (childCount.get(bound) ?? 0) + 1);
226
- const entry = live.get(s.sessionId);
227
- if (!entry)
228
- continue;
229
- const prev = taskLive.get(bound);
230
- // Rollup a stato misto: vince `busy`. Fra N vive, quella che sta lavorando è
231
- // la ragione per cui si guarda la riga.
232
- taskLive.set(bound, {
233
- count: (prev?.count ?? 0) + 1,
234
- status: prev?.status === 'busy' || entry.status === 'busy' ? 'busy' : 'idle',
235
- });
236
- }
237
- const taskRowData = { childCount, live: taskLive, dirty: dirtyFolders };
238
- // T118 — colonne fisse della lista task, misurate su `paneTasks` (la vista
239
- // attiva INTERA) e non sulla finestra visibile: gemelle di `sessionCols` e
240
- // per la stessa ragione, che una larghezza derivata dallo schermo si muove a
241
- // ogni scroll.
242
- const taskCols = taskColumns(paneTasks, taskRowData);
243
- // Figli della selezione: tutte le conversazioni del progetto (`≡ tutte`), le
244
- // sessioni bound alla task selezionata, oppure (spot) quelle senza binding.
245
- // sessions è già ts desc → l'ordine si eredita in tutti e tre i rami.
246
- // Memoizzato così `sessionRows` resta stabile fra render che non cambiano gli
247
- // input: l'effect di validità della selezione non rigira a vuoto.
248
- const childSessions = useMemo(() => isAll
249
- ? sessions
250
- : sessions.filter((s) => {
251
- const bound = bindings.get(s.sessionId);
252
- return selectedTaskId ? bound === selectedTaskId : !bound;
253
- }), [sessions, bindings, selectedTaskId, isAll]);
254
- // T133 — lista unica: le figlie del parent, `ts desc`, cap su tutte. Le
255
- // pinnate del progetto escono a parte (`pinnedRows`), per la sola vista `📌`.
256
- // Core PURO in session-list.ts (testabile senza Ink).
257
- const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, isAll ? MAX_SESSIONS_ALL : MAX_SESSIONS), [childSessions, sessions, pinned, isAll]);
258
- // T62 — contato sulla lista INTERA (stessa ragione delle larghezze di colonna
259
- // qui sotto): derivarlo dalla finestra visibile lo farebbe cambiare a ogni
260
- // scroll, cioè un contatore che conta lo schermo invece della lista.
261
- // T100 — «intera» ora vuol dire la lista della vista di DEFAULT (`assembled`),
262
- // non quella a schermo: il contatore di una voce del catalogo non può
263
- // dipendere da quale voce è selezionata, o navigare muoverebbe i numeri.
264
- const liveCount = useMemo(() => assembled.rows.filter((r) => live.has(r.sessionId)).length, [assembled, live]);
265
- const sessionCounts = {
266
- total: assembled.contextTotal,
267
- live: liveCount,
268
- pinned: assembled.pinnedRows.length,
269
- older: assembled.overflowRows.length,
270
- };
271
- // T100 — le righe a schermo sono quelle della vista attiva.
272
- const sessionRows = useMemo(() => selectSessionRows(sessionViewId, { assembled, isLive: (id) => live.has(id) }), [sessionViewId, assembled, live]);
273
- const selSessionObj = selectedSession(sessionRows, selSessionId);
274
- // T60 — larghezze delle colonne fisse della lista sessioni, misurate sulla
275
- // lista INTERA e non sulla finestra visibile: derivarle dalle sole righe a
276
- // schermo le farebbe cambiare a ogni scroll, cioè l'opposto di una tabella.
277
- // La colonna task esiste solo nella vista "tutte" (altrove il binding è lo
278
- // stesso su ogni riga e sta già nell'header del pane), e `0` la spegne.
279
- const sessionCols = useMemo(() => {
280
- let task = 0;
281
- let age = 2;
282
- for (const r of sessionRows) {
283
- // T133 D10 — la colonna serve dove l'appartenenza NON è già scritta
284
- // altrove sullo schermo, cioè nella sola vista "tutte": ogni altra lista
285
- // contiene le figlie del parent selezionato, che l'header nomina.
286
- if (isAll) {
287
- const b = bindings.get(r.sessionId);
288
- if (b)
289
- task = Math.max(task, termWidth(b));
290
- }
291
- if (r.kind === 'session')
292
- age = Math.max(age, termWidth(relTime(r.session.ts)));
293
- }
294
- // La cella vuota deve poter entrare nella colonna, o le righe spot
295
- // perderebbero il segnaposto e con lui l'allineamento.
296
- return { task: task > 0 ? Math.max(task, termWidth(TASK_EMPTY)) : 0, age };
297
- }, [sessionRows, bindings, isAll]);
298
- // T66 — testo del task file wrappato per il detail. `wrapWithOffsets` e non
299
- // `wrapLines`: quest'ultimo appiattisce gli a-capo in un flusso unico, che per
300
- // una preview di 4 righe va bene e per un task file — titoli, bullet, tabelle,
301
- // T39 — selezione stabile sotto trasformazione. Se la task selezionata esce
302
- // dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
303
- // sulla prima visibile — fallback deterministico, mai una posizione a caso.
304
- useEffect(() => {
305
- if (typeof sel === 'string' && !paneTasks.some((t) => t.id === sel)) {
306
- setSel(paneTasks[0]?.id ?? ALL);
307
- }
308
- }, [paneTasks, sel]);
309
- // T50 — la selezione (id) resta valida sotto la vista a due gruppi: se l'id
310
- // non è più una riga selezionabile (cambio parent, lista mutata, pin rimosso,
311
- // sessione sparita) cade sulla prima riga — fallback deterministico, mai una
312
- // posizione a caso. Sostituisce il reset-a-0 e il clamp index-based.
313
- useEffect(() => {
314
- if (rowIndexOf(sessionRows, selSessionId) < 0) {
315
- setSelSessionId(firstSelectableId(sessionRows));
316
- }
317
- }, [sessionRows, selSessionId]);
318
- // T30: submit dell'input box. Il taskId nasce DOPO create-task (lo assegna la
319
- // skill scrivendo tasks.md) → non è noto allo spawn. Il sessionId invece è
320
- // pinnato qui: snapshot degli id PRIMA, poi al completamento re-leggo tasks.md
321
- // e il diff dà il nuovo id → appendTaskBinding lega la sessione (scoped).
322
- function submitCreate() {
323
- const text = draft.trim();
324
- setMode('normal');
325
- setDraft('');
326
- if (!text) {
327
- setNote('C → create annullato (vuoto)');
328
- return;
329
- }
330
- const sid = randomUUID();
331
- const beforeIds = new Set(tasks.map((t) => t.id));
332
- setNote(`⏳ creando task… "${cut(text, 40)}" (sid ${sid.slice(0, 8)})`);
333
- const child = spawnCreateTask(text, cwd, sid, (ok) => {
334
- if (!ok) {
335
- setNote(`⚠ create-task fallito (${CLAUDE_CMD} -p)`);
336
- return;
337
- }
338
- let newId;
339
- try {
340
- newId = loadTasks(tasksPath).find((t) => !beforeIds.has(t.id))?.id;
341
- }
342
- catch {
343
- // tasks.md illeggibile → id non rilevato, sotto
344
- }
345
- if (newId) {
346
- appendTaskBinding(cwd, sid, newId);
347
- setNote(`✔ ${newId} creata · sessione scoped (sid ${sid.slice(0, 8)})`);
348
- }
349
- else {
350
- setNote(`✔ task creata (id non rilevato) · sid ${sid.slice(0, 8)}`);
351
- }
352
- });
353
- child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
354
- }
355
- // La riga di stato di OGNI spawn di sessione Claude: il comando esatto, come
356
- // lo si scriverebbe in bash, invece di una parafrasi.
357
- //
358
- // Non è un di più sulla nota descrittiva, la sostituisce: task, sessionId,
359
- // prompt-kind, modello e nota del titolo sono già tutti argomenti del comando,
360
- // quindi elencarli a parole li direbbe una seconda volta in una grafia che non
361
- // si può ricopiare in un terminale. Ciò che va perso è il tasto premuto
362
- // (`^K`, `⏎`, `f`), che è ciò che l'utente ha appena fatto e non ciò che il
363
- // deck ha fatto per lui.
364
- //
365
- // Il taglio è al MEZZO (`cutMiddle`, non `cut`): in un comando di spawn è la
366
- // coda a distinguere un'invocazione dall'altra, e un taglio dalla coda la
367
- // butterebbe via per intero. Si taglia QUI, alla composizione, e non al
368
- // render: la riga di stato porta anche messaggi normali, dove è la testa a
369
- // contare. Ne discende che un resize successivo non ricalcola l'elisione — la
370
- // nota è transitoria e `wrap="truncate-end"` resta come rete.
371
- function noteCommand(cmd) {
372
- // 4 = bordo + padding della cornice esterna, 2 = il prompt `$ `. Sbagliare
373
- // il budget non produce un errore visibile: la `truncate-end` di Ink taglia
374
- // il resto dalla CODA, e il comando esce col mezzo eliso E la fine persa —
375
- // cioè con entrambi i pezzi che l'elisione al mezzo voleva salvare.
376
- setNote(`$ ${cutMiddle(cmd, Math.max(8, columns - 6))}`);
377
- }
378
- // Le due note di uno spawn, in quest'ordine: il comando di `deck-run` subito,
379
- // e appena arriva l'annuncio quello della sessione `claude` che gira DENTRO
380
- // la tab — che è ciò che si vuole vedere davvero (`deck-run` è l'involucro,
381
- // l'invocazione vera la compone lui).
382
- //
383
- // La prima non è un ripiego di stile: è l'unica che esiste quando l'annuncio
384
- // non arriva (spawn inerte nei test, argomenti rifiutati, `deck-run` morto
385
- // prima dell'exec), ed è anche il comando da ripetere a mano per vedere
386
- // l'errore. Il salto fra le due dura i millisecondi che `deck-run` impiega a
387
- // comporre.
388
- const spawnSeq = useRef(0);
389
- function noteSpawn(spawned) {
390
- noteCommand(spawned.cmd);
391
- const mine = ++spawnSeq.current;
392
- onInTabCommand(spawned.child, (inTab) => {
393
- // Due spawn ravvicinati: gli annunci sono asincroni e possono tornare
394
- // fuori ordine, e senza guardia il più vecchio scriverebbe sopra il più
395
- // recente — la riga di stato mostrerebbe un comando che non è l'ultimo
396
- // partito.
397
- if (spawnSeq.current === mine)
398
- noteCommand(inTab);
399
- });
400
- }
401
- // T56 — apre una sessione bound alla task selezionata. Punto UNICO dei quattro
402
- // tasti (⏎/^K/^P/^R): fra loro cambia solo il prompt iniziale, tutto il resto
403
- // è identico — uuid pinnato, binding scritto PRIMA dello spawn (la sessione
404
- // risulta figlia della task appena il JSONL compare), handler d'errore, nota.
405
- // Quattro copie sarebbero quattro posti dove dimenticare il `child.on('error')`,
406
- // e uno spawn fallito è async: senza handler diventa uncaughtException e
407
- // ucciderebbe il deck, che invece deve restare vivo.
408
- // La guardia di focus sta qui e non nei chiamanti perché l'oggetto dell'azione
409
- // è la task SELEZIONATA: senza il pane task a fuoco non ce n'è una. Per `⏎` il
410
- // ramo è irraggiungibile (ci arriva già dentro `focus === 'tasks'`); per i tre
411
- // CTRL, che il ramo `key.ctrl` intercetta globalmente, è l'unico posto.
412
- // T66 — la guardia, estratta perché ora ha due chiamanti: gli spawn dalla
413
- // lista e l'apertura del detail. Le tre uscite sono le stesse (pane sbagliato,
414
- // riga meta, task sparita), e duplicarle vorrebbe dire tenerne allineati i
415
- // messaggi a mano. `verb` è l'unica cosa che cambia fra i due usi.
416
- function selectedTaskOr(keyLabel, verb) {
417
- if (focus !== 'tasks') {
418
- setNote(`${keyLabel} → ${verb}: seleziona una task (← per il pane)`);
419
- return null;
420
- }
421
- // T59 — la guardia è "non è una task", non "è spot": le righe meta sono due
422
- // e nessuna delle due ha una task da aprire. Il messaggio dice quale delle
423
- // due, perché il motivo è diverso (vista di sola lettura vs sessioni libere).
424
- if (isAll || isSpot) {
425
- setNote(isAll
426
- ? `tutte: vista di sola lettura, nessuna task da ${verb}`
427
- : `spot: sessioni libere, nessuna task da ${verb}`);
428
- return null;
429
- }
430
- return selTask;
431
- }
432
- // Lo spawn vero, su una task GIÀ risolta. Separato dalla guardia perché il
433
- // detail passa l'id fotografato all'apertura e non la selezione corrente: la
434
- // lista lì sotto non è più a schermo, quindi non è più la fonte dell'oggetto.
435
- // T111 — `spawnNote` arriva dal campo sempre attivo del detail ed è vuota per
436
- // ogni altro percorso (`^K`/`^P`/`^R` non passano di lì, quindi non hanno da
437
- // dove prenderla). Si scrive nel sidecar PRIMA dello spawn, accanto al
438
- // binding e per la stessa ragione: la conversazione deve risultare figlia
439
- // della task e portare la propria maniglia appena il suo JSONL compare, o per
440
- // il tempo di un tick la riga in lista comparirebbe nuda. Due record separati
441
- // sullo stesso `sessionId` sono la forma normale di un file append-only
442
- // last-wins, non una scrittura da fondere.
443
- // T117 — `prompt` è il testo LETTERALE quando lo spawn arriva dal detail, dove
444
- // il campo è editabile: quello che l'utente legge è quello che parte. Assente
445
- // per gli acceleratori della lista, che non hanno un campo da cui prenderlo e
446
- // viaggiano col simbolo.
447
- function spawnForTask(id, kind, model, spawnNote = '', prompt) {
448
- const sid = randomUUID();
449
- appendTaskBinding(cwd, sid, id);
450
- if (spawnNote)
451
- appendNote(cwd, sid, spawnNote);
452
- const spawned = spawnDeck(id, cwd, sid, kind, model, spawnNote, prompt);
453
- spawned.child.on('error', () => setNote(`⚠ spawn ${id} fallito (${DECK_RUN})`));
454
- // Il modello resta SEMPRE visibile anche quando è il default, perché è un
455
- // argomento esplicito del comando (T108): gli acceleratori della lista non
456
- // passano dal selettore del detail e usano il default fisso, quindi senza
457
- // vederlo l'utente crederebbe di aver ereditato la scelta dell'ultimo
458
- // detail aperto.
459
- noteSpawn(spawned);
460
- }
461
- function spawnTaskSession(kind, keyLabel) {
462
- const task = selectedTaskOr(keyLabel, 'spawnare');
463
- if (task)
464
- spawnForTask(task.id, kind, MODEL_DEFAULT);
465
- }
466
- // T53 — apertura del modale nota sulla conversazione selezionata. Come
467
- // `openEdit`, la bozza parte dal valore ATTUALE: annotare una seconda volta è
468
- // quasi sempre correggere, e ripartire da vuoto costringerebbe a ridigitare
469
- // tutto per cambiare una parola.
470
- function openNote() {
471
- if (!selSessionId)
472
- return;
473
- setNoteDraft(sessionNotes.get(selSessionId) ?? '');
474
- setNote('');
475
- setMode('note');
476
- }
477
- // T53 — ⏎ nel modale nota: scrive il sidecar e ricarica subito, senza
478
- // attendere il tick del poll (stesso feedback immediato del pin).
479
- //
480
- // Il campo VUOTO non è un annullamento: è la CANCELLAZIONE della nota. Sono
481
- // due intenzioni diverse e hanno due tasti diversi — `esc` lascia tutto com'è,
482
- // `⏎` su campo svuotato toglie la nota. Trattare il vuoto come un no-op (come
483
- // fa `submitCreate`, dove però una task senza titolo non esiste) renderebbe
484
- // impossibile disannotare una conversazione se non con un editor sul JSONL.
485
- function submitNote() {
486
- const sid = selSessionId;
487
- const text = noteDraft.trim();
488
- setMode('normal');
489
- setNoteDraft('');
490
- if (!sid)
491
- return;
492
- appendNote(cwd, sid, text);
493
- reloadSessions();
494
- setNote(text
495
- ? `✎ titolo su ${sid.slice(0, 8)}: "${cut(text, 40)}"`
496
- : `✎ titolo rimosso da ${sid.slice(0, 8)}`);
497
- }
498
- // T41 — apertura dell'edit: la bozza parte dai valori ATTUALI della task, non
499
- // da default. La priorità arriva dal glifo di tasks.md (già in `selTask`), lo
500
- // stato dal suo glifo Prog; il progresso arbitrario dal campo `Progress` del
501
- // task file — ma solo se è davvero custom (vedi `initialDetail`).
502
- //
503
- // Il titolo si semina dalla riga di tasks.md e non dall'H1 del task file per
504
- // due ragioni: è la fonte che esiste SEMPRE (un task file può mancare), ed è
505
- // il testo che l'utente sta guardando in lista quando preme `E`. Grezzo
506
- // (`rawDesc`), non sanificato: rimandare a disco la forma sanificata
507
- // riscriverebbe i glifi anche senza toccare il campo.
508
- function openEdit() {
509
- if (!selTask)
510
- return;
511
- const prog = progName(selTask.prog) ?? 'todo';
512
- setEdit({
513
- pri: priName(selTask.pri) ?? 'med',
514
- prog,
515
- detail: initialDetail(detail?.fields['Progress'] ?? '', prog),
516
- title: selTask.rawDesc,
517
- });
518
- // Si apre sulla riga 0 (priorità), che non è un campo di testo: il caret
519
- // prende la sua posizione entrando in una riga di testo con ↑↓.
520
- setEditCursor({ row: 0, caret: 0 });
521
- setNote('');
522
- setMode('edit');
523
- }
524
- // T41 — ⏎ nell'edit: scrive tasks.md + task file, poi committa. Il commit è
525
- // immediato e non confermato (scelta esplicita: l'edit è una micro-modifica,
526
- // la storia granulare vale più di un batch). Se nessuno dei due lati è stato
527
- // scritto non si committa nulla — `paths` vuoto renderebbe `git commit --`
528
- // un commit di TUTTO il working tree, che è l'opposto di ciò che vogliamo.
529
- function submitEdit() {
530
- const task = selTask;
531
- const draft = edit;
532
- setMode('normal');
533
- setEdit(null);
534
- if (!task || !draft)
535
- return;
536
- // Il titolo si scrive solo se è CAMBIATO davvero: rimandarlo identico
537
- // riscriverebbe comunque la cella (collassando spazi ed escape) e sporcherebbe
538
- // il diff di una riga per un edit di sola priorità. Vuoto → scartato: una
539
- // task senza descrizione in overview non è più riconoscibile.
540
- const title = draft.title.trim();
541
- const titleChanged = title.length > 0 && title !== task.rawDesc.trim();
542
- let res;
543
- try {
544
- res = writeTaskEdit({
545
- tasksPath,
546
- tasksDir,
547
- id: task.id,
548
- pri: draft.pri,
549
- prog: draft.prog,
550
- detail: draft.detail,
551
- title: titleChanged ? title : undefined,
552
- });
553
- }
554
- catch (e) {
555
- setNote(`⚠ ${task.id}: scrittura fallita (${e.message})`);
556
- return;
557
- }
558
- if (res.paths.length === 0) {
559
- setNote(`⚠ ${task.id}: nessun campo aggiornabile (riga o task file assenti)`);
560
- return;
561
- }
562
- const summary = `${PRI_GLYPH[draft.pri]} ${PRI_LABEL[draft.pri]} · ${res.progress}${titleChanged ? ` · "${cut(sanitize(title), 32)}"` : ''}`;
563
- setNote(`⏳ ${task.id} → ${summary} · commit…`);
564
- commitTaskEdit(cwd, res.paths, `chore(${task.id}): pri ${PRI_LABEL[draft.pri]} · stato ${res.progress}${titleChanged ? ' · titolo' : ''}`, (ok, err) => {
565
- setNote(ok ? `✔ ${task.id} → ${summary} · committato` : `⚠ ${task.id} salvato, commit fallito: ${err}`);
566
- });
567
- }
568
- // T112 — apertura della conferma di eliminazione. Il tasto è uno e il
569
- // bersaglio ha due taglie: la task selezionata, o l'insieme intero della vista
570
- // `archiviabili`. A discriminare è `purgeBulk` — la selezione prima della
571
- // vista. Il modale nomina comunque il bersaglio (quante e quali) e non
572
- // l'azione: è l'unico punto in cui la differenza fra i due gesti è leggibile.
573
- //
574
- // Il bersaglio del bulk si legge da `paneTasks`, cioè dalla stessa fonte che
575
- // disegna le righe e alimenta il contatore in header (D6): mai il `Set` grezzo
576
- // di `archivable.ts`, mai un secondo filtro sullo stato. T100 ha fissato che
577
- // ciò che si conta e ciò che si mostra siano lo stesso insieme per
578
- // costruzione; qui l'invariante si estende a ciò che si pota.
579
- function openPurge() {
580
- // La guardia di focus sta QUI e non solo dentro `selectedTaskOr`: il ramo
581
- // bulk non passa da quella, perché il suo oggetto è la vista e non la
582
- // selezione. Senza, `CANC` col focus sulle sessioni potrebbe potare in
583
- // blocco senza che nessuna task fosse selezionata.
584
- if (focus !== 'tasks') {
585
- setNote('CANC → eliminare: seleziona una task (← per il pane)');
586
- return;
587
- }
588
- const bulk = purgeBulk;
589
- const ids = bulk ? paneTasks.map((t) => t.id) : [];
590
- if (!bulk) {
591
- const task = selectedTaskOr('CANC', 'eliminare');
592
- if (!task)
593
- return;
594
- ids.push(task.id);
595
- }
596
- if (ids.length === 0) {
597
- setNote('CANC → nessuna task in vista da eliminare');
598
- return;
599
- }
600
- // Ricalcolo al momento dell'AZIONE, non lettura del campionamento in lista:
601
- // una folder sporcata dopo l'ultimo scan si presenterebbe come eliminabile.
602
- const { clean, dirty } = splitTargets(purgeTargets(ids, tasksDir, cwd));
603
- if (bulk) {
604
- // Il gate del plugin esce 2 PRIMA di toccare qualsiasi cosa, quindi una
605
- // sola folder sporca annullerebbe il purge di tutte le altre: si scartano
606
- // a monte e il modale nomina sia le potate sia le scartate.
607
- if (clean.length === 0) {
608
- setNote(`CANC → ${dirty.length} task con file non tracciati in folder: eliminale una per una`);
609
- return;
610
- }
611
- setPurge({
612
- ids: clean.map((t) => t.id),
613
- skipped: dirty.map((t) => t.id),
614
- bulk: true,
615
- ignored: null,
616
- survivors: 0,
617
- });
618
- }
619
- else {
620
- // Singola con superstiti → conferma a TRE uscite (D3): la scelta
621
- // keep/purge è rara e distruttiva in modo diverso dal purge normale, e
622
- // qui è visibile invece di stare davanti a ogni potatura.
623
- const one = clean[0] ?? dirty[0];
624
- setPurge({
625
- ids: [one.id],
626
- skipped: [],
627
- bulk: false,
628
- ignored: one.survivors > 0 ? 'keep' : null,
629
- survivors: one.survivors,
630
- });
631
- }
632
- setNote('');
633
- setMode('purge');
634
- }
635
- function onPurgeKey(_input, key) {
636
- if (key.escape) {
637
- setMode('normal');
638
- setPurge(null);
639
- setNote('CANC → eliminazione annullata');
640
- }
641
- else if (key.return) {
642
- submitPurge();
643
- }
644
- else if ((key.leftArrow || key.rightArrow) && purge?.ignored) {
645
- setPurge((p) => (p ? { ...p, ignored: p.ignored === 'keep' ? 'purge' : 'keep' } : p));
646
- }
647
- }
648
- // ⏎ nella conferma: ordina la potatura a `loom-works:clean-tasks` e la
649
- // osserva. Il deck non rimuove niente da sé — nessun `git rm`, nessuna
650
- // riscrittura di tasks.md, nessuna `unlink`: quella sequenza è già
651
- // implementata una volta, e averne due significherebbe due rimozioni capaci
652
- // di divergere.
653
- //
654
- // La lista si riallinea al primo tick del poll e il set delle archiviabili
655
- // allo scan che `doneSig` fa scattare quando la popolazione Done cambia; la
656
- // selezione, keyed su id, cade sulla prima riga della vista (effect di
657
- // validità) invece che su una posizione residua.
658
- function submitPurge() {
659
- const draft = purge;
660
- setMode('normal');
661
- setPurge(null);
662
- if (!draft)
663
- return;
664
- const sid = randomUUID();
665
- setNote(`⏳ eliminando ${draft.ids.length} task… ${idList(draft.ids, 6)}`);
666
- const child = spawnCleanTasks(draft.ids, cwd, sid, draft.ignored, (ok, detail) => {
667
- // L'esito si misura su `tasks.md`, NON sul solo `is_error`. Misurato: col
668
- // gate `--ignored-files` che blocca, la skill spiega il blocco e chiude
669
- // comunque `is_error: false` — un successo dichiarato su zero rimozioni.
670
- // Il segnale robusto è quali degli ID bersaglio non hanno più una riga:
671
- // deterministico, e indipendente da come la skill racconta sé stessa.
672
- let survived = draft.ids;
673
- try {
674
- const now = new Set(loadTasks(tasksPath).map((t) => t.id));
675
- survived = draft.ids.filter((id) => now.has(id));
676
- }
677
- catch {
678
- // tasks.md illeggibile → nessuna verifica possibile, e allora si dice
679
- // che non è stata fatta invece di dedurre un esito.
680
- setNote(`⚠ ${draft.ids.length} task: esito non verificabile (tasks.md illeggibile)`);
681
- return;
682
- }
683
- const removed = draft.ids.length - survived.length;
684
- if (removed === draft.ids.length) {
685
- // Il push non c'è, per scelta della skill: finché nessuno pusha, un
686
- // altro worktree continua a vedere le task potate in tasks.md. Non
687
- // dirlo lascerebbe leggere l'assenza di push come «fatto».
688
- setNote(`✔ ${removed} task eliminate · commit locali, nessun push`);
689
- }
690
- else {
691
- // Il gate `--ignored-files` (exit 2) può scattare lo stesso: la
692
- // dirtiness è un dato campionato e una folder può sporcarsi fra il
693
- // ricalcolo e l'apply. Il testo del result event è l'unica cosa che dice
694
- // PERCHÉ, quindi entra nella riga invece di un ⚠ muto.
695
- setNote(`⚠ ${removed}/${draft.ids.length} eliminate · restano ${idList(survived, 4)} · ${cut(detail || (ok ? '' : `${CLAUDE_CMD} -p`), 56)}`);
696
- }
697
- });
698
- child?.on('error', () => setNote(`⚠ clean-tasks: '${CLAUDE_CMD}' non lanciabile`));
699
- }
700
- // Chiusura di un modale di vista: `restore` rimette la fotografia scattata
701
- // all'apertura (esc = annulla), altrimenti tiene ciò che si è composto (⏎).
702
- function closeViewModal(restore) {
703
- if (restore && viewBackup)
704
- setView(viewBackup);
705
- setViewBackup(null);
706
- setMode('normal');
707
- }
708
- // Sposta la selezione di `delta` righe nella VISTA (0 = tutte, 1 = spot,
709
- // 2..N+1 = task visibili) e la riconverte subito in sentinella o id: l'indice
710
- // non sopravvive a un cambio di filtro, l'id sì.
711
- function moveTaskSel(delta) {
712
- selectTaskRow(Math.max(0, Math.min(paneTasks.length + META_ROWS - 1, selIndex + delta)));
713
- }
714
- /** Selezione per INDICE nella vista, riconvertita subito in sentinella o id.
715
- * T21 — la chiama anche il click su una riga del pane task. */
716
- function selectTaskRow(index) {
717
- if (index === ROW_ALL)
718
- setSel(ALL);
719
- else if (index === ROW_SPOT)
720
- setSel(SPOT);
721
- else
722
- setSel(paneTasks[index - META_ROWS]?.id ?? SPOT);
723
- }
724
- // T100 — `tab` naviga il catalogo viste del pane in focus. Il reset della
725
- // selezione è la regola letterale «prima riga in alto», senza eccezioni: sul
726
- // pane task è `ROW_ALL` (D2 preflight — le righe meta non si saltano, e il
727
- // parent delle sessioni che torna a `tutte` è un effetto accettato); sul pane
728
- // sessioni basta invalidare l'id, e l'effect di validità atterra sulla prima
729
- // riga selezionabile della vista nuova.
730
- function cycleView(delta) {
731
- if (focus === 'tasks')
732
- selectTaskView(cycleTaskView(taskViewId, delta));
733
- else
734
- selectSessionView(cycleSessionView(sessionViewId, delta));
735
- }
736
- // T21 — per VISTA e non per passo: il click sull'header nomina una voce, e
737
- // `tab` la raggiunge ciclando. Sulla vista già attiva non si fa niente — un
738
- // click che azzerasse la selezione alla riga in alto sarebbe un effetto che
739
- // la tastiera non ha mai avuto, perché `tab` non può restare ferma.
740
- function selectTaskView(next) {
741
- if (next === taskViewId)
742
- return;
743
- setTaskViewId(next);
744
- setSel(ALL);
745
- setNote(`vista task: ${taskView(next).label(taskCounts)}`);
746
- }
747
- function selectSessionView(next) {
748
- if (next === sessionViewId)
749
- return;
750
- setSessionViewId(next);
751
- setSelSessionId(null);
752
- setNote(`vista sessioni: ${sessionView(next).label(sessionCounts, parentLabel)}`);
753
- }
754
- // T49 — resume di una conversazione in una nuova tab. Unico punto: lo chiamano
755
- // il `⏎` della lista sessioni e quello sulla riga-sessione della ricerca, che
756
- // devono restare la stessa azione.
757
- function resumeSession(sessionId) {
758
- const bound = bindings.get(sessionId) ?? null;
759
- const spawned = spawnDeckResume(bound, cwd, sessionId, sessionNotes.get(sessionId));
760
- spawned.child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
761
- noteSpawn(spawned);
762
- }
763
- // T57 — ⏎ nel modale: riscrive il binding nel sidecar e ricarica subito.
764
- //
765
- // Il binding retroattivo governa il FUTURO della conversazione, non il suo
766
- // passato: il titolo della tab è stato deciso allo spawn da `claude --name` e
767
- // vive nel transcript, la `LOOM_TASK` di un processo già partito non si
768
- // reinietta. Cambia cosa fa il prossimo `⏎ resume`, che rilegge il binding dal
769
- // sidecar. La nota lo dice: senza, la promessa implicita è «ho spostato la
770
- // conversazione» e il titolo che non cambia sembra un bug.
771
- //
772
- // Dove atterra la selezione (D6): il pane task non si muove, quindi la
773
- // sessione appena assegnata esce dal gruppo contestuale → si scende alla riga
774
- // SUCCESSIVA, catturata PRIMA della riscrittura (dopo, la riga non c'è più).
775
- // Due eccezioni in cui invece resta dov'è, perché non sparisce affatto: una
776
- // pinnata (esente dal contesto) e un'assegnazione al parent già selezionato.
47
+ const model = useDeckModel({ cwd, tasksPath, tasksDir, setNote });
48
+ const actions = useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNote });
777
49
  const assign = useAssignOverlay({
778
- viewTasks,
50
+ viewTasks: model.viewTasks,
779
51
  rows,
780
52
  hasNote: Boolean(note),
781
53
  setMode,
782
54
  setNote,
783
- onSubmit: (sid, target) => {
784
- const stays = pinned.has(sid) || target === selectedTaskId;
785
- const next = stays ? sid : neighborId(sessionRows, sid);
786
- appendTaskBinding(cwd, sid, target ?? '');
787
- reloadSessions();
788
- setSelSessionId(next);
789
- setNote(target
790
- ? `A ${sid.slice(0, 8)} → ${target} · vale dal prossimo ⏎ resume (titolo tab invariato)`
791
- : `A ${sid.slice(0, 8)} → spot · binding rimosso`);
792
- },
55
+ onSubmit: actions.assignSession,
793
56
  });
794
57
  const sheet = useSheetOverlay({
795
58
  rows,
796
59
  columns,
797
60
  setMode,
798
61
  setNote,
799
- onAction: (id, kind, model, spawnNote, prompt) => spawnForTask(id, kind, model, spawnNote, prompt),
62
+ onAction: actions.spawnForTask,
800
63
  });
801
64
  const search = useSearchOverlay({
802
- sessions,
65
+ sessions: model.sessions,
803
66
  rows,
804
67
  columns,
805
68
  hasNote: Boolean(note),
806
69
  setMode,
807
70
  setNote,
808
- onResume: (row) => resumeSession(row.session.sessionId),
71
+ onResume: (row) => actions.resumeSession(row.session.sessionId),
809
72
  });
810
73
  // T121 — il project status. Non è solo un overlay come i tre sopra: il suo
811
74
  // stato si legge in TESTATA a schermata chiusa, e il viewer è una delle sue
@@ -815,773 +78,35 @@ function Deck({ cwd, tasksPath, tasksDir }) {
815
78
  // registrato.
816
79
  const status = useProjectStatus({
817
80
  cwd,
818
- name: projectCore ?? projectName,
81
+ name: model.projectCore ?? model.projectName,
819
82
  rows,
820
83
  columns,
821
84
  setMode,
822
85
  setNote,
823
86
  });
824
- function onCreateKey(input, key) {
825
- if (key.escape) {
826
- setMode('normal');
827
- setDraft('');
828
- setNote('C → create annullato');
829
- }
830
- else if (key.return) {
831
- submitCreate();
832
- }
833
- else if (key.backspace || key.delete) {
834
- setDraft((d) => d.slice(0, -1));
835
- }
836
- else if (input && !key.ctrl && !key.meta) {
837
- setDraft((d) => d + input);
838
- }
839
- }
840
- function onNoteKey(input, key) {
841
- if (key.escape) {
842
- setMode('normal');
843
- setNoteDraft('');
844
- setNote('N → titolo annullato');
845
- }
846
- else if (key.return) {
847
- submitNote();
848
- }
849
- else if (key.ctrl && input === 'u') {
850
- // Svuota il campo in un colpo. NON è una scorciatoia di comodo: il
851
- // backspace tenuto premuto cancella UN carattere per CHUNK letto da
852
- // stdin, non per pressione (`useInput` consegna il chunk, e per una
853
- // raffica di DEL Ink alza `key.backspace` una volta sola) — misurato,
854
- // 30 pressioni → 2 caratteri. Siccome «campo vuoto» qui è l'unico modo
855
- // di CANCELLARE una nota, dipendere dal backspace renderebbe
856
- // l'operazione praticamente non eseguibile. `^U` è il kill-line delle
857
- // shell, quindi il gesto è già nelle dita di chi usa un terminale.
858
- setNoteDraft('');
859
- }
860
- else if (key.backspace || key.delete) {
861
- setNoteDraft((d) => d.slice(0, -1));
862
- }
863
- else if (input && !key.ctrl && !key.meta) {
864
- // Sanificazione dei byte di controllo, come `typeIntoField` della
865
- // ricerca: `useInput` consegna il CHUNK letto da stdin, quindi un
866
- // incollaggio porta dentro newline e control char. Invisibili a schermo
867
- // ma contati da Ink nella larghezza della riga — e qui finirebbero
868
- // scritti su disco, dove resterebbero a sporcare la riga per sempre.
869
- // Le newline diventano spazio: incollare due righe deve separare le
870
- // parole, non fonderle.
871
- setNoteDraft((d) => d + sanitizeTyped(input));
872
- }
873
- }
874
- function onSortKey(input, key) {
875
- if (key.escape) {
876
- closeViewModal(true);
877
- setNote('S → sort annullato');
878
- }
879
- else if (key.return) {
880
- closeViewModal(false);
881
- setNote(`S → sort: ${describeSort(view.sort)}`);
882
- }
883
- else if (input) {
884
- // useInput consegna il CHUNK letto da stdin, non un tasto: digitando
885
- // veloce (o incollando) `ppi` arriva come stringa unica. Si cicla su
886
- // ogni carattere, così la chain esce identica a battitura lenta.
887
- const keys = [...input].map((ch) => SORT_TASTI[ch]).filter(Boolean);
888
- if (keys.length > 0) {
889
- setView((v) => ({ ...v, sort: keys.reduce(cycleSort, v.sort) }));
890
- }
891
- }
892
- }
893
- function onFilterKey(input, key) {
894
- const rowLen = (r) => (r === 0 ? PRI_ENTRIES.length : PROG_ENTRIES.length);
895
- if (key.escape) {
896
- closeViewModal(true);
897
- setNote('F → filtri annullati');
898
- }
899
- else if (key.return) {
900
- closeViewModal(false);
901
- setNote(hiddenTasks > 0 ? `F → ${hiddenTasks} task nascoste` : 'F → nessun filtro attivo');
902
- }
903
- else if (key.upArrow || key.downArrow) {
904
- setFilterCursor((c) => {
905
- const row = c.row === 0 ? 1 : 0;
906
- return { row, col: Math.min(c.col, rowLen(row) - 1) };
907
- });
908
- }
909
- else if (key.leftArrow) {
910
- setFilterCursor((c) => ({ ...c, col: Math.max(0, c.col - 1) }));
911
- }
912
- else if (key.rightArrow) {
913
- setFilterCursor((c) => ({ ...c, col: Math.min(rowLen(c.row) - 1, c.col + 1) }));
914
- }
915
- else if (input === ' ') {
916
- const { row, col } = filterCursor;
917
- setView((v) => row === 0
918
- ? { ...v, hiddenPri: toggleHidden(v.hiddenPri, PRI_ENTRIES[col].name) }
919
- : { ...v, hiddenProg: toggleHidden(v.hiddenProg, PROG_ENTRIES[col].name) });
920
- }
921
- }
922
- // Il ponte fra le quattro righe del modale e la bozza. I valori restano
923
- // tipizzati (`PriName`, `ProgName`) invece di finire in un record generico:
924
- // `fields.ts` governa la grammatica dei tasti, non il modello dati.
925
- const editIO = {
926
- text: (row) => edit?.[editTextField(row)] ?? '',
927
- setText: (row, next) => setEdit((e) => (e ? { ...e, [editTextField(row)]: next } : e)),
928
- choice: (row) => row === 0
929
- ? Math.max(0, EDIT_PRI.indexOf(edit?.pri ?? 'med'))
930
- : Math.max(0, EDIT_PROG.indexOf(edit?.prog ?? 'todo')),
931
- setChoice: (row, index) => setEdit((e) => e ? (row === 0 ? { ...e, pri: EDIT_PRI[index] } : { ...e, prog: EDIT_PROG[index] }) : e),
932
- };
933
- function onEditKey(input, key) {
934
- if (key.escape) {
935
- setMode('normal');
936
- setEdit(null);
937
- setNote('E → edit annullato');
938
- return;
939
- }
940
- if (key.return) {
941
- submitEdit();
942
- return;
943
- }
944
- // Tutto il resto è dell'area di compilazione, che è la STESSA del detail
945
- // (T117): una grammatica sola, non due copie da tenere allineate a mano.
946
- fieldsKey(input, key, EDIT_FIELDS, editCursor, setEditCursor, editIO);
947
- }
948
- /**
949
- * T116 — `^C`: la prima pressione ARMA, la seconda entro la finestra chiude.
950
- *
951
- * Prima di questa task `^C` usciva subito, e a chiuderlo era Ink stesso
952
- * (`exitOnCtrlC`, di default vero) PRIMA che il tasto raggiungesse
953
- * `useInput`. Spegnere quell'opzione è la condizione perché il ramo esista —
954
- * ma spegnerla e basta lascerebbe il deck senza NESSUNA uscita da tastiera:
955
- * il ramo qui sotto è ciò che restituisce quella via, e i due cambi non si
956
- * possono separare.
957
- *
958
- * La prima pressione RIPORTA IN `normal` quando si è dentro un modo
959
- * capturing. L'avviso vive nella riga di stato, e le tre schermate
960
- * sostitutive (detail, ricerca, reader) prendono l'intero frame senza
961
- * renderizzarla: armare restando lì dentro darebbe un deck che sembra
962
- * ignorare il tasto e che alla pressione successiva sparisce senza aver mai
963
- * avvertito. Chiudere lo strato è anche coerente con `esc` — `^C` dice
964
- * «voglio uscire», e il primo passo fuori è la lista.
965
- */
966
- function onQuitKey() {
967
- if (quitTimer.current) {
968
- clearTimeout(quitTimer.current);
969
- quitTimer.current = null;
970
- exit();
971
- return;
972
- }
973
- if (captures(mode))
974
- setMode('normal');
975
- setNote(QUIT_NOTE);
976
- quitTimer.current = setTimeout(() => {
977
- quitTimer.current = null;
978
- // Solo se l'avviso è ANCORA il proprio: nei 5 secondi una qualunque altra
979
- // azione può aver scritto in riga di stato, e cancellare quella nota
980
- // significherebbe far sparire il feedback di un'azione che non c'entra.
981
- setNote((n) => (n === QUIT_NOTE ? '' : n));
982
- }, QUIT_WINDOW_MS);
983
- }
984
- // Il timer pendente è un handle vivo del loop di Node: lasciarlo appeso allo
985
- // smontaggio terrebbe il processo in piedi fino allo scadere della finestra,
986
- // con lo schermo già restituito al terminale.
987
- useEffect(() => () => {
988
- if (quitTimer.current)
989
- clearTimeout(quitTimer.current);
990
- }, []);
991
- // Il dispatch consulta il CATALOGO (`input-modes.ts`), non l'ordine di una
992
- // catena di `if`. `MODE_KEYS` è il custode: essendo un `Record` su
993
- // `CapturingMode`, un modo nuovo senza handler non compila.
994
- const MODE_KEYS = {
995
- detail: sheet.onKey,
996
- status: status.onKey,
997
- reader: search.onReaderKey,
998
- search: search.onSearchKey,
999
- assign: assign.onKey,
1000
- create: onCreateKey,
1001
- note: onNoteKey,
1002
- sort: onSortKey,
1003
- filter: onFilterKey,
1004
- edit: onEditKey,
1005
- purge: onPurgeKey,
1006
- };
1007
- // T21 (mandata 2) — la ROTELLA, per i soli modi che scorrono un contenuto
1008
- // lungo (`SCROLLING_MODES`). Stessa forma di `MODE_KEYS`: un `Record` sul
1009
- // catalogo, quindi un modo dichiarato scorrevole senza uno scroll da chiamare
1010
- // non compila. Il delta è in righe, col segno del verso.
1011
- const MODE_WHEEL = {
1012
- detail: sheet.scroll,
1013
- status: status.scroll,
1014
- reader: search.scrollReader,
1015
- };
1016
- /**
1017
- * T21 — il MOUSE precede tutto, in ogni modo del deck.
1018
- *
1019
- * Il filtro è incondizionato — anche dentro un campo di testo, anche in un
1020
- * modo che non ha nessuna superficie cliccabile — e non è una precauzione:
1021
- * `useInput` riceve le sequenze SGR come testo (`mouse.ts`, fatto ①), quindi
1022
- * senza filtro un click battuto mentre è aperto il campo del titolo ci
1023
- * scriverebbe dentro `[<0;5;3M`. Un listener raw su stdin non risolverebbe:
1024
- * riceve gli stessi chunk in broadcast e non li sottrae a `useInput`.
1025
- *
1026
- * Il chunk MISTO — un tasto e un click nella stessa scrittura, che arriva
1027
- * come una sola chiamata — si separa a mano: gli eventi vanno al mouse, il
1028
- * testo residuo prosegue verso la tastiera. Solo un chunk di solo mouse
1029
- * chiude qui.
1030
- */
1031
- useInput((raw, key) => {
1032
- const { text, events } = takeMouse(raw);
1033
- for (const ev of events)
1034
- onMouse(ev);
1035
- if (events.length > 0 && !text)
1036
- return;
1037
- onKey(text, key);
87
+ const purge = usePurgeOverlay({
88
+ setMode,
89
+ setNote,
90
+ draftFor: actions.purgeDraftFor,
91
+ onSubmit: actions.purgeTasks,
1038
92
  });
1039
- function onMouse(ev) {
1040
- // Un click produce due eventi (pressione e rilascio): agire su entrambi
1041
- // spawnerebbe due volte. La rotella arriva come sola pressione, quindi il
1042
- // filtro su `press` la lascia passare e non la dedoppia.
1043
- if (!ev.press)
1044
- return;
1045
- if (isWheel(ev.button)) {
1046
- // La rotella NON rientra dalla porta della tastiera come il click: nel
1047
- // detail `↑↓` muovono il fuoco fra i campi (T117), non il testo, e il
1048
- // tasto che scorre — `PgUp`/`PgDn` — ha la granularità sbagliata per una
1049
- // tacca. Va quindi allo scroll del modo, in righe. Fuori dai modi
1050
- // scorrevoli è inerte: nelle liste la rotella non muove mai la
1051
- // selezione (D5), e una tacca mentre il deck è sulla lista non deve
1052
- // fare niente.
1053
- if (scrolls(mode))
1054
- MODE_WHEEL[mode](wheelDir(ev.button) * WHEEL_LINES);
1055
- return;
1056
- }
1057
- // Il bottone si legge sui due bit bassi, perché gli alti portano i
1058
- // modificatori (shift/meta/ctrl).
1059
- if ((ev.button & 3) !== 0)
1060
- return;
1061
- // Le superfici esistono solo nella lista: un modo capturing prende il
1062
- // frame intero e a quella riga c'è dell'altro.
1063
- if (mode !== 'normal')
1064
- return;
1065
- if (ev.row === LAUNCH_ROW) {
1066
- const hit = hitRegion(launchRegions, ev.col);
1067
- // Il click NON chiama l'azione: rientra dalla porta della tastiera col
1068
- // tasto che la superficie annuncia. Un ramo nuovo su `t`/`c`/cifre nasce
1069
- // così già cliccabile, e non esiste un secondo posto in cui il click possa
1070
- // dire una cosa diversa dal tasto che gli sta scritto sopra.
1071
- if (hit)
1072
- onKey(hit, NO_MODIFIERS);
1073
- return;
1074
- }
1075
- onListClick(ev);
1076
- }
1077
- /**
1078
- * T21 (mandata 3) — click su un elemento di lista = SOLO FUOCO (D2): il pane
1079
- * prende il focus e la selezione va sull'elemento, nessuna azione parte. Qui
1080
- * il click non sintetizza un tasto, perché nessun tasto nomina una riga o una
1081
- * vista — `↑↓` e `tab` sono relativi — e chiama i setter che la tastiera
1082
- * usa a sua volta. Un click sulla vista già attiva si limita al focus.
1083
- *
1084
- * ECCEZIONE: la riga già selezionata col pane già a fuoco. Lì un tasto che
1085
- * la nomina ESISTE — `⏎`, che agisce proprio su «la riga selezionata» — e il
1086
- * click lo sintetizza, come le superfici della riga launch: due click sulla
1087
- * stessa riga aprono il detail della task o fanno il resume della
1088
- * conversazione, e ogni caso limite (pin stale, nessuna sessione) resta per
1089
- * costruzione quello del tasto. Le righe meta ne sono fuori: `⏎` su di loro
1090
- * non fa nulla, e un click che lo sintetizzasse produrrebbe solo la nota di
1091
- * scarto di `selectedTaskOr`.
1092
- */
1093
- function onListClick(ev) {
1094
- if (!listGeometry)
1095
- return;
1096
- const hit = listHit(ev, listGeometry);
1097
- if (!hit)
1098
- return;
1099
- if (hit.target === 'view') {
1100
- setFocus(hit.pane);
1101
- if (hit.pane === 'tasks')
1102
- selectTaskView(hit.key);
1103
- else
1104
- selectSessionView(hit.key);
1105
- return;
1106
- }
1107
- if (hit.pane === 'tasks') {
1108
- // Le due righe meta hanno indice fisso; le task riportano l'indice di
1109
- // finestra a quello della lista completa, su cui è keyata la selezione.
1110
- const index = hit.index < META_ROWS ? hit.index : taskWin.start + hit.index;
1111
- if (focus === 'tasks' && index === selIndex && index >= META_ROWS) {
1112
- onKey('', { ...NO_MODIFIERS, return: true });
1113
- return;
1114
- }
1115
- setFocus('tasks');
1116
- selectTaskRow(index);
1117
- }
1118
- else {
1119
- const row = windowRows[hit.index];
1120
- if (!row)
1121
- return;
1122
- if (focus === 'sessions' && row.sessionId === selSessionId) {
1123
- onKey('', { ...NO_MODIFIERS, return: true });
1124
- return;
1125
- }
1126
- setFocus('sessions');
1127
- setSelSessionId(row.sessionId);
1128
- }
1129
- }
1130
- function onKey(input, key) {
1131
- // T116 — `^C` sta SOPRA il dispatch dei modi, unico tasto a scavalcarlo.
1132
- // Ogni altra combo `ctrl` è un acceleratore, e un acceleratore dentro un
1133
- // modo capturing dev'essere inerte (`input-modes.ts`); questo non è un
1134
- // acceleratore ma la via d'uscita dal processo, e una via d'uscita che
1135
- // funziona solo in una schermata su undici non è una via d'uscita. Non è
1136
- // quindi una deroga di `CTRL_DEROGATIONS` — quelle vivono DENTRO un modo e
1137
- // le gestisce il modo, questa li precede tutti.
1138
- if (key.ctrl && input === 'c') {
1139
- onQuitKey();
1140
- return;
1141
- }
1142
- // Un modo capturing consuma TUTTO — acceleratori globali compresi — e le
1143
- // deroghe se le gestisce da sé (`CTRL_DEROGATIONS`). Da qui in giù si è
1144
- // quindi in `normal`, l'unico modo che cede ai `key.ctrl`.
1145
- if (captures(mode)) {
1146
- MODE_KEYS[mode](input, key);
1147
- return;
1148
- }
1149
- // T52/D1 — il ramo CTRL sta PRIMA di quelli su lettera nuda e li chiude
1150
- // tutti. `CTRL+F` e `f` nudo arrivano con lo STESSO `input` ('f'),
1151
- // distinguibili solo da `key.ctrl`: senza questa precedenza `CTRL+F`
1152
- // cadrebbe nel ramo fork e spawnerebbe una sessione invece di aprire la
1153
- // ricerca. Non è un caso isolato della `f` — `^Q` finirebbe nel quit, `^T`
1154
- // aprirebbe un terminale, `^C` … ogni lettera legata a un'azione ha la sua
1155
- // combo omonima. Chiudere qui l'intera classe è più solido che ricordarsi
1156
- // un `!key.ctrl` su ognuno dei rami, oggi e a ogni tasto aggiunto domani.
1157
- if (key.ctrl) {
1158
- if (input === 'f') {
1159
- setNote('');
1160
- setMode('search');
1161
- }
1162
- else if (input === 'k') {
1163
- spawnTaskSession('recap', '^K');
1164
- }
1165
- else if (input === 'p') {
1166
- spawnTaskSession('preflight', '^P');
1167
- }
1168
- else if (input === 'r') {
1169
- spawnTaskSession('run', '^R');
1170
- }
1171
- else if (input === 'g') {
1172
- // T121 — GENERA e APRI su due tasti distinti (D2): aprire un recap
1173
- // vecchio deve costare zero, e generare non deve essere un effetto
1174
- // collaterale del guardare. Nessuno dei due è preso né bruciato da Ink,
1175
- // e `^S`/`^Q` sono evitati perché il terminale li intercetta come flow
1176
- // control.
1177
- status.generate();
1178
- }
1179
- else if (input === 'o') {
1180
- status.open();
1181
- }
1182
- return;
1183
- }
1184
- if (key.tab) {
1185
- // `tab` cicla la VISTA del pane a fuoco, `←→` spostano il focus fra i due
1186
- // pane: il criterio dell'assegnazione è spaziale. Una freccia orizzontale
1187
- // porta con sé una direzione e i pane sono affiancati (task a sinistra,
1188
- // sessioni a destra), quindi il tasto NOMINA il pane invece di limitarsi
1189
- // a scambiarlo; un catalogo ciclico non ha un verso da rispettare, e un
1190
- // tasto solo gli basta. `shift+tab` (backtab, `[Z`) scorre a rovescio — il
1191
- // verso che un tasto singolo non esprime sta nel modificatore, e su cinque
1192
- // voci risparmia quattro pressioni.
1193
- cycleView(key.shift ? -1 : 1);
1194
- }
1195
- else if (key.leftArrow || key.rightArrow) {
1196
- // Binding ASSOLUTO, non toggle: `←` porta sempre sui task e `→` sempre
1197
- // sulle sessioni, quindi ripremere lo stesso tasto non riporta indietro.
1198
- // È ciò che lo rende spaziale — la direzione indica una destinazione, e
1199
- // con due soli pane un toggle sarebbe indistinguibile solo per caso.
1200
- setFocus(key.leftArrow ? 'tasks' : 'sessions');
1201
- }
1202
- else if (key.upArrow) {
1203
- if (focus === 'tasks')
1204
- moveTaskSel(-1);
1205
- else
1206
- setSelSessionId((id) => moveSelection(sessionRows, id, -1));
1207
- }
1208
- else if (key.downArrow) {
1209
- if (focus === 'tasks')
1210
- moveTaskSel(1);
1211
- else
1212
- setSelSessionId((id) => moveSelection(sessionRows, id, 1));
1213
- }
1214
- else if (key.return) {
1215
- if (focus === 'tasks') {
1216
- // T66 — ⏎ apre il DETAIL, non più una sessione. Secondo rimappaggio in
1217
- // due task (T56 lo spostò da recap a sessione a mani nude), e la
1218
- // direzione è una sola: da azione singola a punto d'ingresso. Il tasto
1219
- // più battuto non è il posto dove inchiodare una scelta di prompt — le
1220
- // shortcut CTRL restano per chi sa già cosa vuole, `⏎` apre il ventaglio.
1221
- // Lo spawn a mani nude di prima è `open`, cioè `⏎ ⏎` (è il focus iniziale).
1222
- {
1223
- const task = selectedTaskOr('⏎', 'aprire');
1224
- if (task) {
1225
- sheet.open({
1226
- id: task.id,
1227
- // Il titolo del task file quando c'è (è l'H1, cioè la forma
1228
- // lunga), la riga di tasks.md altrimenti: il detail non deve
1229
- // restare senza intestazione solo perché il file manca.
1230
- title: detail?.title || task.desc,
1231
- text: loadTaskFileText(tasksDir, task.id),
1232
- });
1233
- }
1234
- }
1235
- }
1236
- else {
1237
- // T49 — ⏎ su una sessione = resume in nuova tab. Il binding si rilegge
1238
- // dal sidecar (non dal padre selezionato): vale anche per le spot.
1239
- const s = selSessionObj;
1240
- if (!s) {
1241
- setNote(selSessionId ? 'pin stale: transcript non più presente' : 'nessuna sessione da riprendere');
1242
- }
1243
- else {
1244
- resumeSession(s.sessionId);
1245
- }
1246
- }
1247
- }
1248
- else if (key.delete) {
1249
- // T112/D1 — `CANC` non esiste come tasto distinto in Ink: `\x7f`
1250
- // (Backspace) e `\x1b[3~` (Canc) collassano entrambi su `key.delete` con
1251
- // `input` azzerato, quindi a valle non resta niente da cui distinguerli.
1252
- // Il binding vale per entrambi e si accetta come tale: in `normal` non
1253
- // c'è nessun campo di testo da cui Backspace possa rubare un significato,
1254
- // e ogni modo che ne ha uno è capturing e non vede questo ramo. Il modale
1255
- // di conferma è la rete contro chi usa Backspace come «indietro».
1256
- openPurge();
1257
- }
1258
- else if (input === 'C') {
1259
- setNote('');
1260
- setMode('create');
1261
- }
1262
- else if (input === 'E') {
1263
- // L'edit ha senso solo su una task reale: le righe meta non ne sono.
1264
- if (!selTask)
1265
- setNote('E → nessuna task selezionata');
1266
- else
1267
- openEdit();
1268
- }
1269
- else if (input === 'S') {
1270
- setViewBackup(view);
1271
- setNote('');
1272
- setMode('sort');
1273
- }
1274
- else if (input === 'F') {
1275
- // T100/D3 — i filtri valgono SOLO sulla vista principale: su `nascoste`
1276
- // riapplicarli non ha senso (quella vista È il loro complemento), su
1277
- // `archiviabili` non li si vuole (è cieca ai filtri per decisione). Stessa
1278
- // forma dell'inerzia di ^K/^P/^R dentro il detail, ma keyed sulla vista
1279
- // invece che sul modo — e come là, l'inerzia lo DICE invece di non fare
1280
- // niente in silenzio.
1281
- if (taskViewId !== 'tasks') {
1282
- setNote(`F → filtri: solo sulla vista ${TASK_VIEWS[0].label(taskCounts)} (ora: ${taskView(taskViewId).label(taskCounts)})`);
1283
- }
1284
- else {
1285
- setViewBackup(view);
1286
- setNote('');
1287
- setMode('filter');
1288
- }
1289
- }
1290
- else if (input === 'f') {
1291
- // T28 — fork della sessione selezionata. Minuscola come `t`/`c` (T39):
1292
- // azione immediata, nessun modale — la `F` maiuscola resta ai filtri.
1293
- // Vive solo sul pane sessioni: il fork ha per oggetto una conversazione,
1294
- // e senza focus lì non ce n'è una selezionata su cui agire.
1295
- if (focus !== 'sessions') {
1296
- setNote('f → fork: seleziona una sessione (→ per il pane)');
1297
- }
1298
- else {
1299
- const s = selSessionObj;
1300
- if (!s) {
1301
- setNote(selSessionId ? 'f → pin stale: niente da forkare' : 'f → nessuna sessione da forkare');
1302
- }
1303
- else {
1304
- // L'id del ramo nasce qui, prima dello spawn: pinnandolo posso
1305
- // scrivere subito binding e lineage. Il binding task si eredita
1306
- // dall'origine (un ramo appartiene alla stessa task), il lineage
1307
- // registra la provenienza che il transcript non porta.
1308
- const newId = randomUUID();
1309
- const bound = bindings.get(s.sessionId) ?? null;
1310
- appendSessionRecord(cwd, {
1311
- sessionId: newId,
1312
- ...(bound ? { taskId: bound } : {}),
1313
- forkOf: s.sessionId,
1314
- });
1315
- const spawned = spawnDeckFork(bound, cwd, s.sessionId, newId);
1316
- spawned.child.on('error', () => setNote(`⚠ fork fallito (${DECK_RUN})`));
1317
- noteSpawn(spawned);
1318
- }
1319
- }
1320
- }
1321
- else if (input === 'p') {
1322
- // T50 — pin/unpin della conversazione selezionata. Minuscola = azione
1323
- // immediata (convenzione T39), gemella di `f`: opera sulla riga
1324
- // selezionata del pane sessioni, quindi vive solo lì. Vale anche su una
1325
- // pinnata STALE (l'unico modo di spinnarla). Scrive il sidecar e ricarica
1326
- // subito, senza attendere il tick del poll.
1327
- if (focus !== 'sessions') {
1328
- setNote('p → pin: seleziona una sessione (→ per il pane)');
1329
- }
1330
- else if (!selSessionId) {
1331
- setNote('p → nessuna sessione da pinnare');
1332
- }
1333
- else {
1334
- const isPinned = pinned.has(selSessionId);
1335
- // T133 D12 — il caret si sposta solo quando la riga ESCE dalla lista, e
1336
- // succede nella sola vista `📌` (spinnare toglie la riga dall'insieme che
1337
- // la vista mostra). Altrove pin e unpin non riordinano niente: la riga è
1338
- // in lista perché è figlia del parent, e ci resta al suo posto. Il vicino
1339
- // va calcolato PRIMA di riscrivere il sidecar, quando la riga c'è ancora.
1340
- const landing = isPinned && sessionViewId === 'pinned' ? neighborId(sessionRows, selSessionId) : null;
1341
- appendPin(cwd, selSessionId, !isPinned);
1342
- reloadSessions();
1343
- if (landing)
1344
- setSelSessionId(landing);
1345
- setNote(`${isPinned ? 'unpin' : '📌 pin'} ${selSessionId.slice(0, 8)}`);
1346
- }
1347
- }
1348
- else if (input === 'N') {
1349
- // T53 — nota sulla conversazione selezionata. MAIUSCOLA perché apre un
1350
- // modale: nel deck le minuscole sono azioni immediate (`f` fork, `p` pin,
1351
- // `t` term, `c` claude) e le maiuscole aprono un box (`C` create, `E`
1352
- // edit, `S` sort, `F` filtri). Vincolo di focus identico a `p`: vale anche
1353
- // su una pinnata STALE, perché annotare «questa non c'è più, era X» è
1354
- // proprio il caso in cui una nota serve.
1355
- if (focus !== 'sessions') {
1356
- setNote('N → titolo: seleziona una sessione (→ per il pane)');
1357
- }
1358
- else if (!selSessionId) {
1359
- setNote('N → nessuna sessione da annotare');
1360
- }
1361
- else {
1362
- openNote();
1363
- }
1364
- }
1365
- else if (input === 'A') {
1366
- // T57 — assegna la conversazione selezionata a una task. MAIUSCOLA perché
1367
- // apre un modale (convenzione T39), e il modale è obbligatorio: il pane
1368
- // task non può fare da picker, perché spostare la selezione lì cambia il
1369
- // parent e la sessione da assegnare sparisce dalla lista sotto le mani.
1370
- // Vincolo di focus identico a `p`/`N`, e vale anche su una pinnata STALE:
1371
- // il binding è nostro, il transcript è di CC — riassegnare una
1372
- // conversazione il cui transcript non c'è più resta legittimo.
1373
- if (focus !== 'sessions') {
1374
- setNote('A → assegna: seleziona una sessione (→ per il pane)');
1375
- }
1376
- else if (!selSessionId) {
1377
- setNote('A → nessuna sessione da assegnare');
1378
- }
1379
- else {
1380
- if (selSessionId)
1381
- assign.open(selSessionId, bindings.get(selSessionId));
1382
- }
1383
- }
1384
- else if (input === 't') {
1385
- const title = identity ? `🖥️ ${identity.name} [term]` : null;
1386
- const child = spawnTerminal(cwd, title);
1387
- child.on('error', () => setNote('⚠ t → ptyxis non lanciabile'));
1388
- setNote(`t → terminale su ${projectName}`);
1389
- }
1390
- else if (input === 'c') {
1391
- // Minuscola = azione immediata (convenzione T39), gemella di `t`: entrambe
1392
- // aprono una surface del cappello senza passare da un modale. `C` (create
1393
- // task) resta distinta — stessa lettera, ma la maiuscola è per i modali.
1394
- const spawned = spawnClaudeEmpty(cwd);
1395
- spawned.child.on('error', () => setNote(`⚠ c → spawn claude fallito (${DECK_RUN})`));
1396
- noteSpawn(spawned);
1397
- }
1398
- else if (input === 'w') {
1399
- // Salvataggio ESPLICITO: comporre una vista non tocca il disco, così
1400
- // sperimentare non sporca lo stato persistito.
1401
- try {
1402
- saveView(cwd, view);
1403
- setNote(`w → vista salvata (${viewFilePath(cwd)})`);
1404
- }
1405
- catch {
1406
- setNote('⚠ salvataggio vista fallito');
1407
- }
1408
- }
1409
- else if (input && /^[1-9]$/.test(input)) {
1410
- const entry = launch[Number(input) - 1];
1411
- if (!entry) {
1412
- setNote(`${input} → nessuna voce launch (${launch.length} configurate)`);
1413
- }
1414
- else {
1415
- const child = runLaunch(entry, cwd);
1416
- child.on('error', () => setNote(`⚠ ${entry.label}: '${entry.command}' non lanciabile`));
1417
- setNote(`${input} → ${entry.label} su ${projectName}`);
1418
- }
1419
- }
1420
- // Nessun tasto NUDO di uscita: `q` resta libera per un binding futuro, e
1421
- // `esc` in modalità normale è inerte — dentro un overlay continua a
1422
- // chiuderlo, perché quel ramo esce prima di qui. L'unica uscita da tastiera
1423
- // è `^C` battuto due volte (T116, in testa a questo handler); resta poi la
1424
- // chiusura della tab Ptyxis che ospita il processo.
1425
- }
1426
- const canSpawn = focus === 'tasks' && selTask !== null;
1427
- const canResume = focus === 'sessions' && selSessionObj !== null;
1428
- // T50 — il pin agisce su qualunque riga selezionata (anche stale, per
1429
- // spinnarla); basta il focus sul pane e una selezione.
1430
- const canPin = focus === 'sessions' && selSessionId !== null;
1431
- const surfaceLegend = SURFACE_SEGMENTS.map((s) => s.text).join(LAUNCH_SEP);
1432
- // Larghezza dal medesimo hook che dà l'altezza: dopo un resize la legenda si
1433
- // ricalcola con lo stesso re-render che ridimensiona i pane. Le celle delle
1434
- // surface (più il ` · ` che le separa dalle voci) sono già spese sulla riga →
1435
- // vanno riservate, o le voci launch la sfonderebbero di quel tanto.
1436
- const legend = launchLegend(launch, columns, cellWidth(surfaceLegend) + cellWidth(LAUNCH_SEP));
1437
- // T21 — la riga launch come DATO, non come stringa: gli stessi segmenti
1438
- // compongono il testo renderizzato e le colonne dell'hit-test. Derivare le
1439
- // seconde ri-splittando il primo sarebbe un conto parallelo, che diverge alla
1440
- // prima label che contenga il separatore.
1441
- const launchSegments = [
1442
- ...SURFACE_SEGMENTS,
1443
- ...legend.taken.map((text, i) => ({ key: String(i + 1), text })),
1444
- ];
1445
- const launchRegions = rowRegions(launchSegments, LAUNCH_SEP, FRAME_TEXT_COL);
1446
- // Legenda della modalità normale. Elenca SOLO i tasti che fanno qualcosa qui e
1447
- // ora: le voci contestuali compaiono quando il pane a fuoco le rende possibili
1448
- // e altrimenti spariscono, invece di annunciarsi inerti con un `—`.
1449
- // Fuori: la navigazione (`↑↓` `←→`), universale in qualunque TUI, e
1450
- // l'indicatore `focus:` — il pane a fuoco si vede già dall'evidenziazione, e
1451
- // ridirlo a parole costava colonne su una riga che tronca in silenzio.
1452
- // Nessuna voce di uscita: non esiste più un tasto che chiuda il deck.
1453
- const keyLegend = sanitize([
1454
- ...(canSpawn ? ['⏎ detail', '^K/^P/^R spawn'] : canResume ? ['⏎ resume'] : []),
1455
- // T112 — la voce nomina il BERSAGLIO, che cambia di taglia senza che
1456
- // cambi il tasto. Legge `purgeBulk`, la stessa condizione del ramo di
1457
- // apertura: una legenda che annunciasse «tutte» dove il tasto ne pota una
1458
- // sola sarebbe peggio di nessuna legenda.
1459
- ...(focus === 'tasks'
1460
- ? [purgeBulk ? 'CANC elimina tutte' : 'CANC elimina']
1461
- : []),
1462
- ...(canResume ? ['f fork'] : []),
1463
- // T117 · D3 — «titolo» anche qui: è lo stesso valore che il detail chiede
1464
- // allo spawn, e due nomi per una cosa sola li paga chi legge le due
1465
- // schermate. Rename di sola ETICHETTA: il dato resta `note` nel sidecar e
1466
- // `--title-note` in deck-run, dove rinominarlo sarebbe un breaking.
1467
- ...(canPin ? ['p pin', 'N titolo', 'A assegna'] : []),
1468
- // T121 — le due voci nominano il GESTO e non l'oggetto: «status» da solo
1469
- // sarebbe indistinguibile dal recap della task su `^K`, che è un'altra
1470
- // cosa e sta a due voci di distanza.
1471
- '^G genera status',
1472
- '^O apri status',
1473
- '^F cerca',
1474
- 'C nuova',
1475
- 'E edit',
1476
- 'S sort',
1477
- 'F filtri',
1478
- 'w salva',
1479
- ].join(' · '));
1480
- // ── T57 · schermata di assegnazione ─────────────────────────────────────
1481
- // Sostitutiva come ricerca e reader (D3): la lista task non entra in un box
1482
- // sopra i due pane, e prendendo l'intero frame non costa nulla al loro budget.
1483
- // Conseguenza obbligata della scelta: la sessione in assegnazione non è più
1484
- // visibile, quindi va RIPETUTA nel titolo — senza, non si sa più su cosa si
1485
- // sta agendo.
1486
- if (mode === 'assign') {
1487
- if (isCompact(assign.capacity)) {
1488
- return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 assegna \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7 esc annulla"] })] }));
1489
- }
1490
- const at = assign.list.findIndex((t) => (t?.id ?? null) === assign.sel);
1491
- const win = windowRange(assign.list.length, at, assign.capacity);
1492
- const s = assign.sid ? sessions.find((x) => x.sessionId === assign.sid) ?? null : null;
1493
- // Etichetta della conversazione: la nota umana se c'è (è il nome con cui la
1494
- // riconosci), altrimenti la stessa derivazione della ricerca. Su una pinnata
1495
- // stale non resta nulla: il titolo si accontenta dell'hash.
1496
- const label = (assign.sid ? sessionNotes.get(assign.sid) : '') ||
1497
- (s ? conversationLabel(s, projectCore, assign.sid ? bindings.get(assign.sid) : undefined) : '');
1498
- // T124 — le colonne si misurano sulla lista FILTRATA di questa schermata,
1499
- // non su `paneTasks`: la popolazione è un'altra, e riusare le larghezze del
1500
- // pane darebbe una colonna dimensionata su righe che qui non ci sono.
1501
- // `assign.list` porta in testa la riga `detach` (`null`), che non è una task.
1502
- const assignCols = taskColumns(assign.list.filter((t) => t !== null), taskRowData);
1503
- return (_jsx(AssignScreen, { sessionId: assign.sid ?? '', label: label, current: assign.sid ? bindings.get(assign.sid) ?? null : null, filter: assign.filter, rows: assign.list.slice(win.start, win.end), selected: assign.sel, matched: assign.list.length - 1, hidden: hiddenTasks, above: win.start, below: assign.list.length - win.end, idW: assignCols.id, tailW: assignCols.tail, data: taskRowData, columns: columns, note: note }));
1504
- }
1505
- // ── T66 · detail della task ─────────────────────────────────────────────
1506
- // Quarta schermata sostitutiva, stessa ragione delle altre tre: un task file
1507
- // non entra in un box sopra i due pane. Il budget dei pane non viene nemmeno
1508
- // calcolato — il render esce di qui prima.
1509
- if (mode === 'detail' && sheet.sheet) {
1510
- if (isCompact(sheet.capacity)) {
1511
- return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", sheet.sheet.id, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7 esc chiude"] })] }));
1512
- }
1513
- // Niente `windowRange`: quella centra la finestra su una selezione, qui la
1514
- // posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
1515
- // può accorciare il testo sotto uno scroll già dato.
1516
- const start = Math.min(sheet.top, sheet.maxTop);
1517
- 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 }));
1518
- }
1519
- // ── T121 · viewer del project status ────────────────────────────────────
1520
- // Quinta schermata sostitutiva, stessa ragione delle altre quattro: un recap
1521
- // di progetto è lungo quanto un task file e non entra in un box sopra i pane.
1522
- if (mode === 'status' && status.view) {
1523
- if (isCompact(status.capacity)) {
1524
- 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"] })] }));
1525
- }
1526
- // Il clamp serve anche qui: un resize può accorciare il testo sotto uno
1527
- // scroll già dato.
1528
- const start = Math.min(status.top, status.maxTop);
1529
- 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 }));
1530
- }
1531
- // ── T52 · schermate sostitutive ─────────────────────────────────────────
1532
- // Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
1533
- // pane: una lista di occorrenze non entra in un box da 4 righe. Prendono
1534
- // l'intero frame, quindi escono di qui — il budget dei due pane sotto non
1535
- // serve nemmeno calcolarlo, e la loro altezza la distribuiscono
1536
- // `searchListCapacity` / `readerCapacity`.
1537
- if (mode === 'search' || mode === 'reader') {
1538
- const hit = mode === 'reader' && search.readerRow?.kind === 'hit' ? search.readerRow.hit : null;
1539
- // Terminale sotto la cornice: riga singola invece del box, per lo stesso
1540
- // motivo del `budget.compact` del deck — un frame più alto di `rows` fa
1541
- // pulire lo schermo a Ink a ogni redraw, e il poll lo versa nello scrollback.
1542
- if (isCompact(hit ? search.readerCap : search.listCap)) {
1543
- return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", hit ? 'reader' : 'ricerca', " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7", ' ', "esc ", hit ? 'torna' : 'chiude'] })] }));
1544
- }
1545
- if (hit) {
1546
- // Niente `windowRange`: quella centra la finestra su una SELEZIONE, qui
1547
- // la posizione è lo scroll che l'utente muove a mano. Il clamp serve
1548
- // comunque — un resize può accorciare il testo sotto uno scroll già dato.
1549
- const start = Math.min(search.readerTop, search.readerMaxTop);
1550
- return (_jsx(ReaderScreen, { hit: hit, lines: search.readerLines.slice(start, start + search.readerCap), top: start, total: search.readerLines.length, capacity: search.readerCap, bound: bindings.get(hit.sessionId) ?? null }));
1551
- }
1552
- const selIdx = rowIndexOfKey(search.rows, search.selKey);
1553
- const win = windowRange(search.rows.length, selIdx, search.listCap);
1554
- // Anteprima dell'occorrenza selezionata: prende le righe che la lista non
1555
- // usa. Con molti risultati `spare` è 0 e il pannello non esiste — la lista
1556
- // se le riprende tutte, che è la priorità giusta quando c'è molto da
1557
- // scorrere. La finestra si CENTRA sul match (`windowRange`), così il
1558
- // contesto arriva da entrambi i lati.
1559
- const spare = searchPreviewCapacity(search.listCap, win.end - win.start);
1560
- let preview = null;
1561
- if (spare >= 1 && search.selRow?.kind === 'hit') {
1562
- const h = search.selRow.hit;
1563
- const mline = Math.max(0, search.previewBody.findIndex((l) => l.end > h.matchStart));
1564
- const pw = windowRange(search.previewBody.length, mline, spare);
1565
- preview = {
1566
- hit: h,
1567
- lines: search.previewBody.slice(pw.start, pw.end),
1568
- from: pw.start,
1569
- total: search.previewBody.length,
1570
- ts: sessions.find((s) => s.sessionId === h.sessionId)?.ts ?? 0,
1571
- };
1572
- }
1573
- return (_jsx(SearchScreen, { preview: preview, hash: search.hash, query: search.query, field: search.field, opts: search.opts, result: search.result, rows: search.rows.slice(win.start, win.end), selectedKey: search.selKey, selectedKind: selectedRow(search.rows, search.selKey)?.kind ?? null, above: win.start, below: search.rows.length - win.end, capacity: search.listCap, bindings: bindings, pinned: pinned, sessionNotes: sessionNotes, projectCore: projectCore, columns: columns, note: note }));
1574
- }
1575
- // ── Budget d'altezza ────────────────────────────────────────────────────
1576
- // Il frame deve restare sotto `rows`, sempre: oltre quella soglia Ink smette
1577
- // di aggiornare per differenza e pulisce lo schermo a ogni redraw, che su
1578
- // Ptyxis significa un frame intero versato nello scrollback per ogni tick del
1579
- // poll. Tutto ciò che varia in altezza (le due liste e la descrizione del
1580
- // dettaglio) riceve qui la propria capienza.
1581
- // Le surface built-in `t`/`c` la rendono sempre presente in modalità normale:
1582
- // non dipende più da quante voci `launch` il progetto dichiara.
1583
- const launchLine = mode === 'normal';
1584
- const detailParts = detail ? detailMetaOf(detail) : null;
93
+ const viewModals = useViewModals({
94
+ view: model.view,
95
+ setView: model.setView,
96
+ hiddenTasks: model.hiddenTasks,
97
+ setMode,
98
+ setNote,
99
+ });
100
+ const textModals = useTextModals({
101
+ setMode,
102
+ setNote,
103
+ onCreate: actions.createTask,
104
+ onNote: actions.writeNote,
105
+ onEdit: actions.writeEdit,
106
+ editDraftFor: actions.editDraftFor,
107
+ currentNote: (sid) => model.sessionNotes.get(sid) ?? '',
108
+ });
109
+ const overlays = { assign, sheet, search, status, purge, view: viewModals, text: textModals };
1585
110
  // T70 — un solo blocco preview, sotto i due pane, e il FOCUS decide cosa
1586
111
  // contiene: a sinistra la task selezionata, a destra la conversazione. È il
1587
112
  // focus e non la selezione perché il blocco è uno — averne due significava
@@ -1589,57 +114,78 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1589
114
  // che si sta guardando e di una che non si sta guardando.
1590
115
  // `none` quando non c'è contenuto (riga meta selezionata, task file assente,
1591
116
  // pin stale): il blocco sparisce del tutto, non resta una cornice vuota.
1592
- const previewKind = focus === 'tasks' ? (detail ? 'task' : 'none') : selSessionObj ? 'session' : 'none';
1593
- const budget = layoutBudget({
117
+ const previewKind = model.focus === 'tasks'
118
+ ? model.detail
119
+ ? 'task'
120
+ : 'none'
121
+ : model.selSessionObj
122
+ ? 'session'
123
+ : 'none';
124
+ const detailParts = model.detail ? detailMetaOf(model.detail) : null;
125
+ const launch = launchRow(model.launch, columns);
126
+ const frame = frameGeometry({
1594
127
  rows,
128
+ columns,
1595
129
  mode,
1596
- launchLine,
1597
- noteLine: Boolean(note),
1598
- preview: previewKind,
130
+ hasNote: Boolean(note),
131
+ previewKind,
1599
132
  detailMetaLines: detailParts?.metaLines ?? 0,
1600
- // Riservo righe di anteprima solo per i blocchi che davvero renderizzano: il
1601
- // primo prompt aggiunge info solo con un titolo custom (senza, titolo ===
1602
- // primo prompt); l'ultima risposta solo se il modello ha già risposto.
1603
- sessionHasFirstPreview: previewKind === 'session' && Boolean(selSessionObj?.customTitle),
1604
- sessionHasLastPreview: previewKind === 'session' && Boolean(selSessionObj?.lastReply),
133
+ sessionHasFirstPreview: previewKind === 'session' && Boolean(model.selSessionObj?.customTitle),
134
+ sessionHasLastPreview: previewKind === 'session' && Boolean(model.selSessionObj?.lastReply),
135
+ paneTasks: model.paneTasks,
136
+ selIndex: model.selIndex,
137
+ sessionRows: model.sessionRows,
138
+ selSessionId: model.selSessionId,
139
+ taskCounts: model.taskCounts,
140
+ taskViewId: model.taskViewId,
141
+ sessionCounts: model.sessionCounts,
142
+ sessionViewId: model.sessionViewId,
143
+ parentLabel: model.parentLabel,
144
+ hasLoadError: Boolean(model.loadError),
1605
145
  });
1606
- // Finestre di rendering. Le liste "logiche" (viewTasks, sessionRows)
1607
- // restano intere: navigazione, selezione e spawn continuano a ragionare su
1608
- // quelle, la finestra è solo ciò che finisce a schermo.
1609
- const taskWin = windowRange(paneTasks.length, selIndex - META_ROWS, budget.taskRows);
1610
- const windowTasks = paneTasks.slice(taskWin.start, taskWin.end);
1611
- const selRowIndex = rowIndexOf(sessionRows, selSessionId);
1612
- const sessionWin = windowRange(sessionRows.length, selRowIndex, budget.sessionRows);
1613
- const windowRows = sessionRows.slice(sessionWin.start, sessionWin.end);
1614
- // T21 — geometria delle liste per l'hit-test del click, dalla STESSA
1615
- // aritmetica che disegna i pane: le parti degli header escono dal modulo che
1616
- // `ui/panes.tsx` consuma per renderle, le righe cliccabili sono le finestre
1617
- // appena calcolate. `null` in compatto: i pane non esistono.
1618
- const spans = paneSpans(columns);
1619
- const listGeometry = budget.compact
1620
- ? null
1621
- : {
1622
- columns,
1623
- taskHeader: inlineRegions(headerItems(taskHeaderParts(taskCounts, taskViewId, taskWin.start, paneTasks.length - taskWin.end, columns)), spans.tasks.start + PANE_TEXT_PAD),
1624
- sessionHeader: inlineRegions(headerItems(sessionHeaderParts(parentLabel, sessionCounts, sessionViewId, sessionWin.start, sessionRows.length - sessionWin.end, columns)), spans.sessions.start + PANE_TEXT_PAD),
1625
- // Con un errore di caricamento al posto delle task c'è la riga rossa:
1626
- // restano cliccabili le sole righe meta.
1627
- taskRows: META_ROWS + (loadError ? 0 : windowTasks.length),
1628
- sessionRows: windowRows.length,
1629
- };
146
+ useDeckInput({
147
+ tasksDir,
148
+ mode,
149
+ setMode,
150
+ setNote,
151
+ model,
152
+ actions,
153
+ overlays,
154
+ frame,
155
+ launchRegions: launch.regions,
156
+ });
157
+ // Le cinque schermate sostitutive prendono il frame intero: se una è attiva
158
+ // il render finisce qui, e il budget dei pane resta calcolato ma inutilizzato.
159
+ const screen = screenFor({
160
+ mode,
161
+ rows,
162
+ columns,
163
+ note,
164
+ sessions: model.sessions,
165
+ bindings: model.bindings,
166
+ pinned: model.pinned,
167
+ sessionNotes: model.sessionNotes,
168
+ projectCore: model.projectCore,
169
+ projectName: model.projectName,
170
+ taskRowData: model.taskRowData,
171
+ hiddenTasks: model.hiddenTasks,
172
+ overlays: { assign, sheet, search, status },
173
+ });
174
+ if (screen)
175
+ return screen;
1630
176
  // Sotto la soglia minima il layout a box non entra a nessun costo: si scende
1631
177
  // a una riga sola. Perdere il deck per un terminale basso è meglio che
1632
178
  // sporcare la cronologia del terminale a ogni poll.
1633
- if (budget.compact) {
1634
- 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"] })] }));
1635
- }
1636
- // Segmento destro della testata: dimensione del terminale in CELLE
1637
- // (colonne×righe, mai pixel — un processo dentro un terminale vede solo la
1638
- // griglia di caratteri) e versione. La parte sinistra riceve un budget
1639
- // derivato da questa stringa e non da una lunghezza fissa: la risoluzione
1640
- // cambia a ogni resize, quindi anche la larghezza che occupa.
1641
- const headerRight = `${columns}×${rows} · v${VERSION}`;
1642
- 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 - termWidth(headerRight) - 1) }), _jsx(Text, { dimColor: true, children: headerRight })] }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["titolo conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'purge' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["elimina task \u00B7", ' ', purge?.ignored ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " keep/purge dei file non tracciati \u00B7", ' '] })) : null, _jsx(Text, { color: "yellow", children: "\u23CE" }), " conferma \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [launchSegments.map((s) => s.text).join(LAUNCH_SEP), legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editCursor.row, caret: editCursor.caret, columns: columns })) : null, mode === 'purge' && purge ? _jsx(PurgeModal, { draft: purge, columns: columns }) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, counts: taskCounts, activeView: taskViewId, paneCount: paneTasks.length, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, idW: taskCols.id, tailW: taskCols.tail, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: paneTasks.length - taskWin.end, columns: columns, data: taskRowData }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, counts: sessionCounts, activeView: sessionViewId, paneCount: sessionRows.length, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore, live: live })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '', live: live.get(selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
179
+ if (frame.budget.compact) {
180
+ return (_jsx(CompactNotice, { dot: false, what: `v${VERSION} · ${model.viewTasks.length} task · sel ${model.selectedTaskId ?? model.parentLabel}`, rows: rows, columns: columns }));
181
+ }
182
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(StatusHeadline, { name: model.projectCore ?? model.projectName, label: status.label, building: status.building, failed: status.failed, cols: headlineWidth(columns, frame.headerRight) }), _jsx(Text, { dimColor: true, children: frame.headerRight })] }), _jsx(HintBar, { mode: mode, purge: purge.draft, keyLegend: deckLegend({
183
+ focus: model.focus,
184
+ hasTask: model.selTask !== null,
185
+ hasSession: model.selSessionObj !== null,
186
+ hasSessionId: model.selSessionId !== null,
187
+ purgeBulk: model.purgeBulk,
188
+ }) }), frame.launchLine ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [launch.segments.map((s) => s.text).join(LAUNCH_SEP), launch.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", launch.overflow, " fuori riga"] })) : null, launch.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", launch.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? _jsx(TextBox, { glyph: "C", value: textModals.draft }) : null, mode === 'note' ? _jsx(TextBox, { glyph: "\u270E", value: textModals.noteDraft }) : null, mode === 'sort' ? _jsx(SortModal, { sort: model.view.sort }) : null, mode === 'filter' ? (_jsx(FilterModal, { view: model.view, cursor: viewModals.filterCursor })) : null, mode === 'edit' && textModals.edit && textModals.editTask ? (_jsx(EditModal, { id: textModals.editTask.id, draft: textModals.edit, row: textModals.editCursor.row, caret: textModals.editCursor.caret, columns: columns })) : null, mode === 'purge' && purge.draft ? (_jsx(PurgeModal, { draft: purge.draft, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: frame.windowTasks, counts: model.taskCounts, activeView: model.taskViewId, paneCount: model.paneTasks.length, view: model.view, selected: model.selIndex, spotCount: model.spotCount, allCount: model.sessions.length, idW: model.taskCols.id, tailW: model.taskCols.tail, focused: model.focus === 'tasks', loadError: model.loadError, windowStart: frame.taskWin.start, above: frame.taskWin.start, below: model.paneTasks.length - frame.taskWin.end, columns: columns, data: model.taskRowData }), _jsx(SessionsPane, { parentLabel: model.parentLabel, isSpot: model.isSpot, isAll: model.isAll, bindings: model.bindings, taskW: model.sessionCols.task, ageW: model.sessionCols.age, rows: frame.windowRows, counts: model.sessionCounts, activeView: model.sessionViewId, paneCount: model.sessionRows.length, selectedId: model.selSessionId ?? undefined, focused: model.focus === 'sessions', above: frame.sessionWin.start, below: model.sessionRows.length - frame.sessionWin.end, columns: columns, forkOf: model.forkOf, sessionNotes: model.sessionNotes, projectCore: model.projectCore, live: model.live })] }), frame.budget.preview && previewKind === 'task' && model.detail ? (_jsx(PreviewPane, { kind: "task", detail: model.detail, maxLines: frame.budget.detailLines, columns: columns })) : frame.budget.preview && previewKind === 'session' && model.selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: model.selSessionObj, firstLines: frame.budget.sessionFirstLines, lastLines: frame.budget.sessionLastLines, columns: columns, origin: model.forkOf.get(model.selSessionObj.sessionId) ?? null, note: model.sessionNotes.get(model.selSessionObj.sessionId) ?? '', live: model.live.get(model.selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1643
189
  }
1644
190
  const cwd = process.cwd();
1645
191
  // T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,