@lamemind/loom-deck 0.54.1 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,291 @@
1
+ // L'ATTUATORE del deck: ciò che parte da una selezione e finisce fuori dal
2
+ // processo — una tab spawnata, un record nel sidecar, una riga riscritta su
3
+ // disco, un commit.
4
+ //
5
+ // T131 — è la casella che l'asse di T104 non aveva. `spawn.ts` è la fase
6
+ // «effetti esterni» ed è PURA: compone argv, lancia, restituisce il figlio, e
7
+ // il suo test non conosce React. Portarci dentro `setNote` e la selezione
8
+ // metterebbe React nella fase più a monte e ucciderebbe quel test. Gli overlay
9
+ // non possono ospitarli per la deroga di T104: un hook di overlay non esce dal
10
+ // deck, chiama un callback. Resta lo scalino in mezzo, ed è questo file: sa
11
+ // cosa è selezionato e cosa deve partire, non sa come si compone un argv né
12
+ // cosa c'è a schermo.
13
+ //
14
+ // Le stringhe di `setNote` di questo file sono ASSERITE dal gate
15
+ // `test/modes-smoke.test.ts` (`seleziona una task`, `terminale su`, `deck-run`,
16
+ // `nessun push`, `eliminare N task?`, `scartate`): si copiano verbatim, non si
17
+ // migliorano di passaggio.
18
+ import { useRef } from 'react';
19
+ import { randomUUID } from 'node:crypto';
20
+ import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, } from './task-index.js';
21
+ import { neighborId } from './session-list.js';
22
+ import { cut, cutMiddle } from './width.js';
23
+ import { saveView, viewFilePath } from './view-store.js';
24
+ import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
25
+ import { useTaskOps } from './task-ops.js';
26
+ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNote, }) {
27
+ // La riga di stato di OGNI spawn di sessione Claude: il comando esatto, come
28
+ // lo si scriverebbe in bash, invece di una parafrasi.
29
+ //
30
+ // Non è un di più sulla nota descrittiva, la sostituisce: task, sessionId,
31
+ // prompt-kind, modello e nota del titolo sono già tutti argomenti del comando,
32
+ // quindi elencarli a parole li direbbe una seconda volta in una grafia che non
33
+ // si può ricopiare in un terminale. Ciò che va perso è il tasto premuto
34
+ // (`^K`, `⏎`, `f`), che è ciò che l'utente ha appena fatto e non ciò che il
35
+ // deck ha fatto per lui.
36
+ //
37
+ // Il taglio è al MEZZO (`cutMiddle`, non `cut`): in un comando di spawn è la
38
+ // coda a distinguere un'invocazione dall'altra, e un taglio dalla coda la
39
+ // butterebbe via per intero. Si taglia QUI, alla composizione, e non al
40
+ // render: la riga di stato porta anche messaggi normali, dove è la testa a
41
+ // contare. Ne discende che un resize successivo non ricalcola l'elisione — la
42
+ // nota è transitoria e `wrap="truncate-end"` resta come rete.
43
+ function noteCommand(cmd) {
44
+ // 4 = bordo + padding della cornice esterna, 2 = il prompt `$ `. Sbagliare
45
+ // il budget non produce un errore visibile: la `truncate-end` di Ink taglia
46
+ // il resto dalla CODA, e il comando esce col mezzo eliso E la fine persa —
47
+ // cioè con entrambi i pezzi che l'elisione al mezzo voleva salvare.
48
+ setNote(`$ ${cutMiddle(cmd, Math.max(8, columns - 6))}`);
49
+ }
50
+ // Le due note di uno spawn, in quest'ordine: il comando di `deck-run` subito,
51
+ // e appena arriva l'annuncio quello della sessione `claude` che gira DENTRO
52
+ // la tab — che è ciò che si vuole vedere davvero (`deck-run` è l'involucro,
53
+ // l'invocazione vera la compone lui).
54
+ //
55
+ // La prima non è un ripiego di stile: è l'unica che esiste quando l'annuncio
56
+ // non arriva (spawn inerte nei test, argomenti rifiutati, `deck-run` morto
57
+ // prima dell'exec), ed è anche il comando da ripetere a mano per vedere
58
+ // l'errore. Il salto fra le due dura i millisecondi che `deck-run` impiega a
59
+ // comporre.
60
+ const spawnSeq = useRef(0);
61
+ function noteSpawn(spawned) {
62
+ noteCommand(spawned.cmd);
63
+ const mine = ++spawnSeq.current;
64
+ onInTabCommand(spawned.child, (inTab) => {
65
+ // Due spawn ravvicinati: gli annunci sono asincroni e possono tornare
66
+ // fuori ordine, e senza guardia il più vecchio scriverebbe sopra il più
67
+ // recente — la riga di stato mostrerebbe un comando che non è l'ultimo
68
+ // partito.
69
+ if (spawnSeq.current === mine)
70
+ noteCommand(inTab);
71
+ });
72
+ }
73
+ // T66 — la guardia dello spawn bound, con due chiamanti: gli acceleratori
74
+ // della lista e l'apertura del detail. Le tre uscite sono le stesse (pane
75
+ // sbagliato, riga meta, task sparita), e duplicarle vorrebbe dire tenerne
76
+ // allineati i messaggi a mano. `verb` è l'unica cosa che cambia fra i due usi.
77
+ function selectedTaskOr(keyLabel, verb) {
78
+ if (model.focus !== 'tasks') {
79
+ setNote(`${keyLabel} → ${verb}: seleziona una task (← per il pane)`);
80
+ return null;
81
+ }
82
+ // T59 — la guardia è "non è una task", non "è spot": le righe meta sono due
83
+ // e nessuna delle due ha una task da aprire. Il messaggio dice quale delle
84
+ // due, perché il motivo è diverso (vista di sola lettura vs sessioni libere).
85
+ if (model.isAll || model.isSpot) {
86
+ setNote(model.isAll
87
+ ? `tutte: vista di sola lettura, nessuna task da ${verb}`
88
+ : `spot: sessioni libere, nessuna task da ${verb}`);
89
+ return null;
90
+ }
91
+ return model.selTask;
92
+ }
93
+ // Lo spawn vero, su una task GIÀ risolta. Separato dalla guardia perché il
94
+ // detail passa l'id fotografato all'apertura e non la selezione corrente: la
95
+ // lista lì sotto non è più a schermo, quindi non è più la fonte dell'oggetto.
96
+ // T111 — `spawnNote` arriva dal campo sempre attivo del detail ed è vuota per
97
+ // ogni altro percorso. Si scrive nel sidecar PRIMA dello spawn, accanto al
98
+ // binding e per la stessa ragione: la conversazione deve risultare figlia
99
+ // della task e portare la propria maniglia appena il suo JSONL compare, o per
100
+ // il tempo di un tick la riga in lista comparirebbe nuda. Due record separati
101
+ // sullo stesso `sessionId` sono la forma normale di un file append-only
102
+ // last-wins, non una scrittura da fondere.
103
+ // T117 — `prompt` è il testo LETTERALE quando lo spawn arriva dal detail, dove
104
+ // il campo è editabile: quello che l'utente legge è quello che parte. Assente
105
+ // per gli acceleratori della lista, che non hanno un campo da cui prenderlo e
106
+ // viaggiano col simbolo.
107
+ function spawnForTask(id, kind, modelKind, spawnNote = '', prompt) {
108
+ const sid = randomUUID();
109
+ appendTaskBinding(cwd, sid, id);
110
+ if (spawnNote)
111
+ appendNote(cwd, sid, spawnNote);
112
+ const spawned = spawnDeck(id, cwd, sid, kind, modelKind, spawnNote, prompt);
113
+ spawned.child.on('error', () => setNote(`⚠ spawn ${id} fallito (${DECK_RUN})`));
114
+ // Il modello resta SEMPRE visibile anche quando è il default, perché è un
115
+ // argomento esplicito del comando (T108): gli acceleratori della lista non
116
+ // passano dal selettore del detail e usano il default fisso, quindi senza
117
+ // vederlo l'utente crederebbe di aver ereditato la scelta dell'ultimo
118
+ // detail aperto.
119
+ noteSpawn(spawned);
120
+ }
121
+ // T56 — i quattro tasti (⏎/^K/^P/^R) passano tutti di qui: fra loro cambia
122
+ // solo il prompt iniziale, tutto il resto è identico. Quattro copie sarebbero
123
+ // quattro posti dove dimenticare il `child.on('error')`, e uno spawn fallito è
124
+ // async: senza handler diventa uncaughtException e ucciderebbe il deck, che
125
+ // invece deve restare vivo.
126
+ function spawnTaskSession(kind, keyLabel) {
127
+ const task = selectedTaskOr(keyLabel, 'spawnare');
128
+ if (task)
129
+ spawnForTask(task.id, kind, MODEL_DEFAULT);
130
+ }
131
+ // T49 — resume di una conversazione in una nuova tab. Unico punto: lo chiamano
132
+ // il `⏎` della lista sessioni e quello sulla riga-sessione della ricerca, che
133
+ // devono restare la stessa azione.
134
+ function resumeSession(sessionId) {
135
+ const bound = model.bindings.get(sessionId) ?? null;
136
+ const spawned = spawnDeckResume(bound, cwd, sessionId, model.sessionNotes.get(sessionId));
137
+ spawned.child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
138
+ noteSpawn(spawned);
139
+ }
140
+ // T53 — scrive il sidecar e ricarica subito, senza attendere il tick del poll
141
+ // (stesso feedback immediato del pin).
142
+ //
143
+ // Il campo VUOTO non è un annullamento: è la CANCELLAZIONE della nota. Sono
144
+ // due intenzioni diverse e hanno due tasti diversi — `esc` lascia tutto com'è,
145
+ // `⏎` su campo svuotato toglie la nota. Trattare il vuoto come un no-op (come
146
+ // fa `createTask`, dove però una task senza titolo non esiste) renderebbe
147
+ // impossibile disannotare una conversazione se non con un editor sul JSONL.
148
+ function writeNote(sid, text) {
149
+ appendNote(cwd, sid, text);
150
+ model.reloadSessions();
151
+ setNote(text
152
+ ? `✎ titolo su ${sid.slice(0, 8)}: "${cut(text, 40)}"`
153
+ : `✎ titolo rimosso da ${sid.slice(0, 8)}`);
154
+ }
155
+ // T57 — riscrive il binding nel sidecar e ricarica subito.
156
+ //
157
+ // Il binding retroattivo governa il FUTURO della conversazione, non il suo
158
+ // passato: il titolo della tab è stato deciso allo spawn da `claude --name` e
159
+ // vive nel transcript, la `LOOM_TASK` di un processo già partito non si
160
+ // reinietta. Cambia cosa fa il prossimo `⏎ resume`, che rilegge il binding dal
161
+ // sidecar. La nota lo dice: senza, la promessa implicita è «ho spostato la
162
+ // conversazione» e il titolo che non cambia sembra un bug.
163
+ //
164
+ // Dove atterra la selezione (D6): il pane task non si muove, quindi la
165
+ // sessione appena assegnata esce dal gruppo contestuale → si scende alla riga
166
+ // SUCCESSIVA, catturata PRIMA della riscrittura (dopo, la riga non c'è più).
167
+ // Due eccezioni in cui invece resta dov'è, perché non sparisce affatto: una
168
+ // pinnata (esente dal contesto) e un'assegnazione al parent già selezionato.
169
+ function assignSession(sid, target) {
170
+ const stays = model.pinned.has(sid) || target === model.selectedTaskId;
171
+ const next = stays ? sid : neighborId(model.sessionRows, sid);
172
+ appendTaskBinding(cwd, sid, target ?? '');
173
+ model.reloadSessions();
174
+ model.setSelSessionId(next);
175
+ setNote(target
176
+ ? `A ${sid.slice(0, 8)} → ${target} · vale dal prossimo ⏎ resume (titolo tab invariato)`
177
+ : `A ${sid.slice(0, 8)} → spot · binding rimosso`);
178
+ }
179
+ // T28 — fork della sessione selezionata. Vive solo sul pane sessioni: il fork
180
+ // ha per oggetto una conversazione, e senza focus lì non ce n'è una
181
+ // selezionata su cui agire.
182
+ function forkSession() {
183
+ if (model.focus !== 'sessions') {
184
+ setNote('f → fork: seleziona una sessione (→ per il pane)');
185
+ return;
186
+ }
187
+ const s = model.selSessionObj;
188
+ if (!s) {
189
+ setNote(model.selSessionId
190
+ ? 'f → pin stale: niente da forkare'
191
+ : 'f → nessuna sessione da forkare');
192
+ return;
193
+ }
194
+ // L'id del ramo nasce qui, prima dello spawn: pinnandolo posso scrivere
195
+ // subito binding e lineage. Il binding task si eredita dall'origine (un ramo
196
+ // appartiene alla stessa task), il lineage registra la provenienza che il
197
+ // transcript non porta.
198
+ const newId = randomUUID();
199
+ const bound = model.bindings.get(s.sessionId) ?? null;
200
+ appendSessionRecord(cwd, {
201
+ sessionId: newId,
202
+ ...(bound ? { taskId: bound } : {}),
203
+ forkOf: s.sessionId,
204
+ });
205
+ const spawned = spawnDeckFork(bound, cwd, s.sessionId, newId);
206
+ spawned.child.on('error', () => setNote(`⚠ fork fallito (${DECK_RUN})`));
207
+ noteSpawn(spawned);
208
+ }
209
+ // T50 — pin/unpin della conversazione selezionata, gemella di `f`. Vale anche
210
+ // su una pinnata STALE (l'unico modo di spinnarla). Scrive il sidecar e
211
+ // ricarica subito, senza attendere il tick del poll.
212
+ function togglePin() {
213
+ if (model.focus !== 'sessions') {
214
+ setNote('p → pin: seleziona una sessione (→ per il pane)');
215
+ return;
216
+ }
217
+ const sid = model.selSessionId;
218
+ if (!sid) {
219
+ setNote('p → nessuna sessione da pinnare');
220
+ return;
221
+ }
222
+ const isPinned = model.pinned.has(sid);
223
+ // T133 D12 — il caret si sposta solo quando la riga ESCE dalla lista, e
224
+ // succede nella sola vista `📌` (spinnare toglie la riga dall'insieme che
225
+ // la vista mostra). Altrove pin e unpin non riordinano niente: la riga è
226
+ // in lista perché è figlia del parent, e ci resta al suo posto. Il vicino
227
+ // va calcolato PRIMA di riscrivere il sidecar, quando la riga c'è ancora.
228
+ const landing = isPinned && model.sessionViewId === 'pinned' ? neighborId(model.sessionRows, sid) : null;
229
+ appendPin(cwd, sid, !isPinned);
230
+ model.reloadSessions();
231
+ if (landing)
232
+ model.setSelSessionId(landing);
233
+ setNote(`${isPinned ? 'unpin' : '📌 pin'} ${sid.slice(0, 8)}`);
234
+ }
235
+ /** `t` — terminale a project root, con un titolo che il matcher di compass riconosce. */
236
+ function openTerminal() {
237
+ const title = model.identity ? `🖥️ ${model.identity.name} [term]` : null;
238
+ const child = spawnTerminal(cwd, title);
239
+ child.on('error', () => setNote('⚠ t → ptyxis non lanciabile'));
240
+ setNote(`t → terminale su ${model.projectName}`);
241
+ }
242
+ /** `c` — sessione claude a mani nude, senza task e senza prompt. */
243
+ function openClaude() {
244
+ const spawned = spawnClaudeEmpty(cwd);
245
+ spawned.child.on('error', () => setNote(`⚠ c → spawn claude fallito (${DECK_RUN})`));
246
+ noteSpawn(spawned);
247
+ }
248
+ // Salvataggio ESPLICITO: comporre una vista non tocca il disco, così
249
+ // sperimentare non sporca lo stato persistito.
250
+ function saveCurrentView() {
251
+ try {
252
+ saveView(cwd, model.view);
253
+ setNote(`w → vista salvata (${viewFilePath(cwd)})`);
254
+ }
255
+ catch {
256
+ setNote('⚠ salvataggio vista fallito');
257
+ }
258
+ }
259
+ /** `1`-`9` — le voci launch del progetto, per indice base-1. */
260
+ function runLaunchAt(input) {
261
+ const entry = model.launch[Number(input) - 1];
262
+ if (!entry) {
263
+ setNote(`${input} → nessuna voce launch (${model.launch.length} configurate)`);
264
+ return;
265
+ }
266
+ const child = runLaunch(entry, cwd);
267
+ child.on('error', () => setNote(`⚠ ${entry.label}: '${entry.command}' non lanciabile`));
268
+ setNote(`${input} → ${entry.label} su ${model.projectName}`);
269
+ }
270
+ // Le operazioni sulla task list vivono in `task-ops.ts` e si ricompongono
271
+ // qui, non nel chiamante: chi consuma gli attuatori ne vuole uno solo, e la
272
+ // divisione fra spawn e task è di questo strato, non della schermata.
273
+ // `selectedTaskOr` scende come argomento perché la guardia ha due chiamanti
274
+ // su due lati della frattura — la dipendenza resta in un verso solo.
275
+ const taskOps = useTaskOps({ cwd, tasksPath, tasksDir, model, setNote, selectedTaskOr });
276
+ return {
277
+ ...taskOps,
278
+ selectedTaskOr,
279
+ spawnForTask,
280
+ spawnTaskSession,
281
+ resumeSession,
282
+ writeNote,
283
+ assignSession,
284
+ forkSession,
285
+ togglePin,
286
+ openTerminal,
287
+ openClaude,
288
+ saveCurrentView,
289
+ runLaunchAt,
290
+ };
291
+ }