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