@lamemind/loom-deck 0.55.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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/inbox.js +135 -0
- package/dist/input.js +469 -0
- package/dist/overlays/modals.js +231 -0
- package/dist/overlays/purge.js +44 -0
- package/dist/plugin-cache.js +106 -0
- package/dist/task-ops.js +235 -0
- package/dist/ui/screens.js +201 -0
- package/dist/wrap-scan.js +122 -0
- package/package.json +1 -1
package/dist/actions.js
ADDED
|
@@ -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
|
+
}
|