@lamemind/loom-deck 0.40.0 → 0.41.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/README.md CHANGED
@@ -26,6 +26,22 @@ Entrambi i prefissi del contratto loom entrano in lista: **`T`** (code task) e *
26
26
  Il deck è **UN processo Node**: spawna ma **non contiene** le sessioni CC — le possiede Ptyxis. Chiudere il deck non uccide le sessioni. La tab nasce nella window *attiva*
27
27
  (quella col focus = il deck) → desktop isolation "gratis".
28
28
 
29
+ ### La riga di stato dice il comando, non una parafrasi
30
+
31
+ Ogni spawn di una sessione Claude — task (`⏎`/`^K`/`^P`/`^R`), resume, fork, sessione nuda (`c`) — scrive nella riga di stato in fondo al frame il **comando esatto** che è stato eseguito, quotato come lo si scriverebbe in bash:
32
+
33
+ ```
34
+ $ /home/tizio/.local/lib/node_modules/@lamemind/loom-deck/scripts/deck-run T115 --session-id 9f3a… --prompt-kind run --model opus --title-note 'parser'
35
+ ```
36
+
37
+ Task, `sessionId`, prompt-kind, modello e nota del titolo sono già argomenti del comando: una nota che li elencasse a parole li direbbe una seconda volta, in una grafia che non si può ricopiare in un terminale. Quello che si perde è il **tasto premuto**, che è ciò che hai appena fatto tu e non ciò che il deck ha fatto per te.
38
+
39
+ Il deck non passa mai da una shell (`spawn` senza `shell:true` consegna l'argv a `execve`): il quoting esiste per la sola resa, e serve a rendere la riga ricopiabile senza cambiare di una virgola ciò che è stato eseguito.
40
+
41
+ Su un terminale stretto il taglio è **al mezzo** (`…`), non dalla coda: la testa è il path assoluto di `deck-run`, identico a ogni invocazione, mentre quel che distingue una sessione dall'altra sta tutto in fondo.
42
+
43
+ Restano fuori gli spawn che non aprono una sessione Claude interattiva: terminale (`t`), voci `launch` (`1`-`9`) e le skill headless (`C` create-task, `CANC` clean-tasks), dove la riga di stato serve a riportare l'**esito** di un'operazione asincrona.
44
+
29
45
  ## Stato
30
46
 
31
47
  Bootstrap + spike ① + **TUI ③** funzionante (legge `tasks.md`, `⏎` apre il detail). Roadmap:
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowI
10
10
  import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch, } from './config.js';
11
11
  import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, TASK_VIEWS, } from './pane-views.js';
12
12
  import { isCompact, layoutBudget, searchPreviewCapacity, windowRange, } from './viewport.js';
13
- import { cut, sanitize, termWidth } from './width.js';
13
+ import { cut, cutMiddle, sanitize, termWidth } from './width.js';
14
14
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
15
15
  import { initialDetail, writeTaskEdit, PRI_GLYPH, PRI_LABEL } from './task-edit.js';
16
16
  import { ALL, EDIT_PRI, EDIT_PROG, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, ROW_ALL, ROW_SPOT, SORT_TASTI, SPOT, } from './model.js';
@@ -273,6 +273,29 @@ function Deck({ cwd, tasksPath, tasksDir }) {
273
273
  });
274
274
  child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
275
275
  }
276
+ // La riga di stato di OGNI spawn di sessione Claude: il comando esatto, come
277
+ // lo si scriverebbe in bash, invece di una parafrasi.
278
+ //
279
+ // Non è un di più sulla nota descrittiva, la sostituisce: task, sessionId,
280
+ // prompt-kind, modello e nota del titolo sono già tutti argomenti del comando,
281
+ // quindi elencarli a parole li direbbe una seconda volta in una grafia che non
282
+ // si può ricopiare in un terminale. Ciò che va perso è il tasto premuto
283
+ // (`^K`, `⏎`, `f`), che è ciò che l'utente ha appena fatto e non ciò che il
284
+ // deck ha fatto per lui.
285
+ //
286
+ // Il taglio è al MEZZO (`cutMiddle`, non `cut`): la testa è il path assoluto
287
+ // di `deck-run`, identico a ogni spawn, e un taglio dalla coda mostrerebbe
288
+ // solo quello. Si taglia QUI, alla composizione, e non al render: la riga di
289
+ // stato porta anche messaggi normali, dove è la testa a contare. Ne discende
290
+ // che un resize successivo non ricalcola l'elisione — la nota è transitoria e
291
+ // `wrap="truncate-end"` resta come rete.
292
+ function noteCommand(cmd) {
293
+ // 4 = bordo + padding della cornice esterna, 2 = il prompt `$ `. Sbagliare
294
+ // il budget non produce un errore visibile: la `truncate-end` di Ink taglia
295
+ // il resto dalla CODA, e il comando esce col mezzo eliso E la fine persa —
296
+ // cioè con entrambi i pezzi che l'elisione al mezzo voleva salvare.
297
+ setNote(`$ ${cutMiddle(cmd, Math.max(8, columns - 6))}`);
298
+ }
276
299
  // T56 — apre una sessione bound alla task selezionata. Punto UNICO dei quattro
277
300
  // tasti (⏎/^K/^P/^R): fra loro cambia solo il prompt iniziale, tutto il resto
278
301
  // è identico — uuid pinnato, binding scritto PRIMA dello spawn (la sessione
@@ -315,25 +338,24 @@ function Deck({ cwd, tasksPath, tasksDir }) {
315
338
  // il tempo di un tick la riga in lista comparirebbe nuda. Due record separati
316
339
  // sullo stesso `sessionId` sono la forma normale di un file append-only
317
340
  // last-wins, non una scrittura da fondere.
318
- function spawnForTask(id, kind, model, keyLabel, spawnNote = '') {
341
+ function spawnForTask(id, kind, model, spawnNote = '') {
319
342
  const sid = randomUUID();
320
343
  appendTaskBinding(cwd, sid, id);
321
344
  if (spawnNote)
322
345
  appendNote(cwd, sid, spawnNote);
323
- const child = spawnDeck(id, cwd, sid, kind, model, spawnNote);
346
+ const { child, cmd } = spawnDeck(id, cwd, sid, kind, model, spawnNote);
324
347
  child.on('error', () => setNote(`⚠ spawn ${id} fallito (${DECK_RUN})`));
325
- const what = kind === 'none' ? '' : ` · ${kind}`;
326
- // Il modello è SEMPRE nominato, anche quando è il default: gli acceleratori
327
- // della lista non passano dal selettore del detail e usano il default fisso
328
- // (T108 · via a), quindi senza dirlo l'utente crederebbe di aver ereditato
329
- // la scelta fatta nell'ultimo detail aperto.
330
- const named = spawnNote ? ` · «${cut(spawnNote, 24)}»` : '';
331
- setNote(`${keyLabel} spawn ${id}${what} · ${model}${named} → tab CC (sid ${sid.slice(0, 8)})`);
348
+ // Il modello resta SEMPRE visibile anche quando è il default, perché è un
349
+ // argomento esplicito del comando (T108): gli acceleratori della lista non
350
+ // passano dal selettore del detail e usano il default fisso, quindi senza
351
+ // vederlo l'utente crederebbe di aver ereditato la scelta dell'ultimo
352
+ // detail aperto.
353
+ noteCommand(cmd);
332
354
  }
333
355
  function spawnTaskSession(kind, keyLabel) {
334
356
  const task = selectedTaskOr(keyLabel, 'spawnare');
335
357
  if (task)
336
- spawnForTask(task.id, kind, MODEL_DEFAULT, keyLabel);
358
+ spawnForTask(task.id, kind, MODEL_DEFAULT);
337
359
  }
338
360
  // T53 — apertura del modale nota sulla conversazione selezionata. Come
339
361
  // `openEdit`, la bozza parte dal valore ATTUALE: annotare una seconda volta è
@@ -615,9 +637,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
615
637
  // devono restare la stessa azione.
616
638
  function resumeSession(sessionId) {
617
639
  const bound = bindings.get(sessionId) ?? null;
618
- const child = spawnDeckResume(bound, cwd, sessionId, sessionNotes.get(sessionId));
640
+ const { child, cmd } = spawnDeckResume(bound, cwd, sessionId, sessionNotes.get(sessionId));
619
641
  child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
620
- setNote(`⏎ resume ${sessionId.slice(0, 8)} → tab CC${bound ? ` (${bound})` : ' (spot)'}`);
642
+ noteCommand(cmd);
621
643
  }
622
644
  // T57 — ⏎ nel modale: riscrive il binding nel sidecar e ricarica subito.
623
645
  //
@@ -655,7 +677,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
655
677
  columns,
656
678
  setMode,
657
679
  setNote,
658
- onAction: (id, kind, model, spawnNote, label) => spawnForTask(id, kind, model, label, spawnNote),
680
+ onAction: (id, kind, model, spawnNote) => spawnForTask(id, kind, model, spawnNote),
659
681
  });
660
682
  const search = useSearchOverlay({
661
683
  sessions,
@@ -1021,9 +1043,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1021
1043
  ...(bound ? { taskId: bound } : {}),
1022
1044
  forkOf: s.sessionId,
1023
1045
  });
1024
- const child = spawnDeckFork(bound, cwd, s.sessionId, newId);
1046
+ const { child, cmd } = spawnDeckFork(bound, cwd, s.sessionId, newId);
1025
1047
  child.on('error', () => setNote(`⚠ fork fallito (${DECK_RUN})`));
1026
- setNote(`⑂ fork ${s.sessionId.slice(0, 8)} → ${newId.slice(0, 8)}${bound ? ` (${bound})` : ' (spot)'}`);
1048
+ noteCommand(cmd);
1027
1049
  }
1028
1050
  }
1029
1051
  }
@@ -1100,9 +1122,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1100
1122
  // Minuscola = azione immediata (convenzione T39), gemella di `t`: entrambe
1101
1123
  // aprono una surface del cappello senza passare da un modale. `C` (create
1102
1124
  // task) resta distinta — stessa lettera, ma la maiuscola è per i modali.
1103
- const child = spawnClaudeEmpty(cwd);
1125
+ const { child, cmd } = spawnClaudeEmpty(cwd);
1104
1126
  child.on('error', () => setNote(`⚠ c → spawn claude fallito (${DECK_RUN})`));
1105
- setNote(`c → claude nuda su ${projectName} (nessuna task)`);
1127
+ noteCommand(cmd);
1106
1128
  }
1107
1129
  else if (input === 'w') {
1108
1130
  // Salvataggio ESPLICITO: comporre una vista non tocca il disco, così
@@ -205,7 +205,7 @@ export function useSheetOverlay(deps) {
205
205
  const note = spawnNote.trim();
206
206
  close();
207
207
  if (id)
208
- onAction(id, act.kind, model, note, `⏎ ${act.label}`);
208
+ onAction(id, act.kind, model, note);
209
209
  }
210
210
  else if (key.tab) {
211
211
  // T108 — `tab` scorre il catalogo dei modelli, e da T111 è il SOLO canale:
package/dist/spawn.js CHANGED
@@ -32,6 +32,40 @@ export function spawnOut(cmd, args, opts) {
32
32
  fake.unref = () => fake;
33
33
  return fake;
34
34
  }
35
+ // Caratteri che una shell POSIX passa attraverso senza interpretarli: tutto il
36
+ // resto (spazi, apici, `$`, glob, non-ASCII) va quotato o il comando ricopiato
37
+ // dalla riga di stato non farebbe la stessa cosa di quello eseguito.
38
+ const SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
39
+ /**
40
+ * Un argomento come lo si scriverebbe in bash.
41
+ *
42
+ * Apici SINGOLI, non doppi: dentro i singoli nessun carattere resta speciale,
43
+ * quindi non c'è da enumerare cosa sfuggire. L'unico caso da chiudere è l'apice
44
+ * stesso, che si esce dalla stringa (`'\''`) invece di essere escapato dentro.
45
+ *
46
+ * Il deck non passa MAI da una shell — `spawn` senza `shell:true` consegna
47
+ * l'argv a execve così com'è. Questo quoting esiste per la sola RESA: rende la
48
+ * riga ricopiabile in un terminale senza cambiare di una virgola ciò che il
49
+ * deck ha davvero eseguito.
50
+ */
51
+ export function shellQuote(arg) {
52
+ if (arg === '')
53
+ return "''";
54
+ if (SHELL_SAFE.test(arg))
55
+ return arg;
56
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
57
+ }
58
+ export function shellCommand(cmd, args) {
59
+ return [cmd, ...args].map(shellQuote).join(' ');
60
+ }
61
+ // I quattro percorsi di spawn di una sessione Claude interattiva differiscono
62
+ // SOLO per l'argv: stesso eseguibile, stesso detached, stesso `unref`. Il corpo
63
+ // sta qui una volta sola, così anche il comando mostrato nasce in un punto solo.
64
+ function launchDeckRun(args, cwd) {
65
+ const child = spawnOut(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
66
+ child.unref();
67
+ return { child, cmd: shellCommand(DECK_RUN, args) };
68
+ }
35
69
  // L'ordine È il giro di `tab` nel detail, non una preferenza di lettura:
36
70
  // cambiarlo sposta le voci sotto le dita di chi le ha imparate. Fino a T111 era
37
71
  // anche il binding delle cifre `1`-`4`, passate poi al campo nota.
@@ -79,13 +113,7 @@ export function deckArgs(id, sessionId, kind, model = MODEL_DEFAULT, spawnNote)
79
113
  return args;
80
114
  }
81
115
  export function spawnDeck(id, cwd, sessionId, kind, model = MODEL_DEFAULT, spawnNote) {
82
- const child = spawnOut(DECK_RUN, deckArgs(id, sessionId, kind, model, spawnNote), {
83
- cwd,
84
- detached: true,
85
- stdio: 'ignore',
86
- });
87
- child.unref();
88
- return child;
116
+ return launchDeckRun(deckArgs(id, sessionId, kind, model, spawnNote), cwd);
89
117
  }
90
118
  // T49 — resume di una sessione esistente come nuova tab Ptyxis. Scoped (taskId
91
119
  // presente) → `deck-run <task> --resume <sid>`: la ripresa eredita LOOM_TASK +
@@ -109,13 +137,7 @@ export function resumeArgs(taskId, sessionId, note) {
109
137
  return args;
110
138
  }
111
139
  export function spawnDeckResume(taskId, cwd, sessionId, note) {
112
- const child = spawnOut(DECK_RUN, resumeArgs(taskId, sessionId, note), {
113
- cwd,
114
- detached: true,
115
- stdio: 'ignore',
116
- });
117
- child.unref();
118
- return child;
140
+ return launchDeckRun(resumeArgs(taskId, sessionId, note), cwd);
119
141
  }
120
142
  // T28 — FORK: `deck-run <task|--no-task> --resume <origine> --fork --session-id
121
143
  // <nuovo>`. Variante del resume, non una terza forma: cambia solo che CC apre un
@@ -140,13 +162,7 @@ export function forkArgs(taskId, originId, newId) {
140
162
  ];
141
163
  }
142
164
  export function spawnDeckFork(taskId, cwd, originId, newId) {
143
- const child = spawnOut(DECK_RUN, forkArgs(taskId, originId, newId), {
144
- cwd,
145
- detached: true,
146
- stdio: 'ignore',
147
- });
148
- child.unref();
149
- return child;
165
+ return launchDeckRun(forkArgs(taskId, originId, newId), cwd);
150
166
  }
151
167
  // T42 — sessione Claude NUDA: nessuna task, nessun prompt iniziale, nessun
152
168
  // sessionId pinnato (quindi nessuna entry nel sidecar session-tasks.jsonl: senza
@@ -155,13 +171,7 @@ export function spawnDeckFork(taskId, cwd, originId, newId) {
155
171
  // sporcherebbe il percorso bound. Il titolo tab resta la label loom — lo mette
156
172
  // deck-run, perché il match compass è window-level e non sa nulla di task.
157
173
  export function spawnClaudeEmpty(cwd) {
158
- const child = spawnOut(DECK_RUN, ['--no-task'], {
159
- cwd,
160
- detached: true,
161
- stdio: 'ignore',
162
- });
163
- child.unref();
164
- return child;
174
+ return launchDeckRun(['--no-task'], cwd);
165
175
  }
166
176
  // T39/T32: voce `launch` custom del file config, eseguita con cwd = project root.
167
177
  // Spawn detached come spawnDeck: il deck lancia ma non possiede il processo.
package/dist/width.js CHANGED
@@ -328,6 +328,56 @@ export function pad(s, cols, align = 'left') {
328
328
  const fill = ' '.repeat(Math.max(0, cols - termWidth(t)));
329
329
  return align === 'right' ? fill + t : t + fill;
330
330
  }
331
+ /**
332
+ * Taglio a un budget di COLONNE che sacrifica il MEZZO invece della coda.
333
+ *
334
+ * Serve dove la testa e la coda portano informazione diversa e la TESTA è quella
335
+ * costante: il comando di uno spawn è sempre lo stesso path assoluto di
336
+ * `deck-run`, e quel che cambia da un'invocazione all'altra (task, sessionId,
337
+ * prompt-kind, modello, nota) sta tutto in fondo. Un `cut()` su quella riga
338
+ * mostrerebbe l'unica parte che non cambia mai.
339
+ *
340
+ * La coda prende due terzi del budget e la testa il resto: basta a riconoscere
341
+ * l'eseguibile senza mangiare gli argomenti. Testa e coda non possono
342
+ * sovrapporsi — insieme occupano al più `cols - 1` colonne, cioè meno della
343
+ * stringa intera (che qui è già più larga del budget).
344
+ *
345
+ * Niente collasso del whitespace (a differenza di `cut`): la stringa in ingresso
346
+ * è un comando, e due spazi dentro un argomento quotato sono suoi. Restano
347
+ * appiattiti i soli a-capo, che sfonderebbero la riga singola.
348
+ */
349
+ export function cutMiddle(s, cols) {
350
+ const flat = s.replace(/[\r\n\t]+/g, ' ');
351
+ if (cols <= 0)
352
+ return '';
353
+ if (termWidth(flat) <= cols)
354
+ return flat;
355
+ if (cols === 1)
356
+ return '…';
357
+ const budget = cols - 1; // -1 = la colonna dell'ellissi, che qui c'è di sicuro
358
+ const headCols = budget - Math.ceil((budget * 2) / 3);
359
+ let head = '';
360
+ let hw = 0;
361
+ for (const ch of flat) {
362
+ const cw = termWidth(ch);
363
+ if (hw + cw > headCols)
364
+ break;
365
+ head += ch;
366
+ hw += cw;
367
+ }
368
+ const chars = [...flat];
369
+ let tail = '';
370
+ let tw = 0;
371
+ for (let i = chars.length - 1; i >= 0; i--) {
372
+ const ch = chars[i];
373
+ const cw = termWidth(ch);
374
+ if (hw + tw + cw > budget)
375
+ break;
376
+ tail = ch + tail;
377
+ tw += cw;
378
+ }
379
+ return head + '…' + tail;
380
+ }
331
381
  /**
332
382
  * Larghezza in colonne di UN carattere **dopo** `sanitize`.
333
383
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
5
5
  "type": "module",
6
6
  "bin": {