@lamemind/loom-deck 0.54.1 → 0.56.0

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