@lamemind/loom-deck 0.74.0 → 0.75.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 CHANGED
@@ -31,6 +31,7 @@ import { useTaskOps } from './task-ops.js';
31
31
  import { inboxPrompt, inboxWords } from './inbox.js';
32
32
  import { docTargetKind, docWords } from './doc-tree.js';
33
33
  import { wrapWords } from './wrap-scan.js';
34
+ import { enterSession, focusSession } from './focus-session.js';
34
35
  /**
35
36
  * T181 — l'attesa massima di un passo del restore prima di passare al
36
37
  * successivo. Un `deck-run` esce appena `ptyxis --tab` ha consegnato la tab
@@ -196,13 +197,21 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
196
197
  const d = spawnDefaults(kind, taskHoles(task.id));
197
198
  spawnForTask(task.id, d.model, d.title ?? '', d.prompt ?? '');
198
199
  }
199
- // T49 — resume di una conversazione in una nuova tab. Unico punto: lo chiamano
200
- // il `⏎` della lista sessioni e quello sulla riga-sessione della ricerca, che
201
- // devono restare la stessa azione.
200
+ // T49 — resume di una conversazione in una nuova tab. Unico punto dello spawn,
201
+ // con due chiamanti: `openSession` (il `⏎` della lista sessioni e quello
202
+ // sulla riga-sessione della ricerca) quando la conversazione NON è viva, e il
203
+ // restore di T181 (`restoreSessions`).
204
+ // T184 — riprende SEMPRE, e non chiede se la conversazione è viva: quella
205
+ // domanda è del chiamante, e i due chiamanti la pongono in modo diverso. Il
206
+ // `⏎` la pone a ogni pressione (`openSession`); il restore l'ha già posta
207
+ // per l'insieme intero, che `restoreSet` svuota appena una conversazione è
208
+ // viva. Il ramo di focus resta fuori da qui anche per il contratto di
209
+ // ritorno: il restore scandisce la serie sull'uscita del `deck-run` che
210
+ // questa funzione restituisce, e un focus non ha nessun figlio da aspettare.
202
211
  // T148 — il modello lo decide il CHIAMANTE, non questa funzione: la lista
203
212
  // principale passa il selettore della preview (`model.resumeModel`), la
204
- // ricerca passa il modello ereditato dalla sessione senza superficie di
205
- // scelta (D1 preflight). I due chiamanti restano la stessa azione perché è
213
+ // ricerca e il restore il modello ereditato dalla sessione senza superficie
214
+ // di scelta (D1 preflight). I chiamanti restano la stessa azione perché è
206
215
  // lo spawn a essere identico, non la sorgente del valore.
207
216
  function resumeSession(sessionId, modelKind) {
208
217
  const bound = model.bindings.get(sessionId) ?? null;
@@ -211,14 +220,37 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
211
220
  noteSpawn(spawned);
212
221
  return spawned;
213
222
  }
223
+ /**
224
+ * T184 — `⏎` su una conversazione, dalla lista sessioni e dalla riga-sessione
225
+ * della ricerca: la stessa azione dai due posti, come chiede T49.
226
+ *
227
+ * Viva → compass porta davanti la sua tab, e nessun `deck-run` parte: un
228
+ * secondo resume aprirebbe un secondo processo `claude` sullo stesso
229
+ * transcript. Non viva → `resumeSession`, come prima di T184. La scelta e il
230
+ * canale vivono in `focus-session.ts`; qui si cablano coi valori del deck.
231
+ *
232
+ * Il nome della nota viene dal registro (`LiveSession.name`, il titolo della
233
+ * tab); senza nome, il prefisso del `sessionId` come nelle altre note.
234
+ */
235
+ function openSession(sessionId, modelKind) {
236
+ enterSession(sessionId, {
237
+ isLive: (id) => model.live.has(id),
238
+ nameOf: (id) => model.live.get(id)?.name || id.slice(0, 8),
239
+ focus: focusSession,
240
+ resume: (id) => resumeSession(id, modelKind),
241
+ note: setNote,
242
+ });
243
+ }
214
244
  /**
215
245
  * T181 — `^T` e il bottone in testata: riapre le conversazioni dell'ultimo
216
246
  * insieme vivo, una tab per conversazione, ognuna col proprio binding di
217
247
  * task, la propria nota e il modello con cui girava.
218
248
  *
219
- * Passa da `resumeSession`, terzo chiamante dopo la lista e la ricerca, e
220
- * come la ricerca non ha una superficie di scelta del modello: lo eredita
221
- * dalla conversazione (`modelAlias(session.model) ?? MODEL_DEFAULT`, T148/D1).
249
+ * Passa da `resumeSession` diretto, non da `openSession` come il `⏎` (T184):
250
+ * l'insieme è vuoto appena una conversazione è viva, quindi qui non c'è
251
+ * nessuna viva da portare davanti. Come la ricerca non ha una superficie di
252
+ * scelta del modello: lo eredita dalla conversazione
253
+ * (`modelAlias(session.model) ?? MODEL_DEFAULT`, T148/D1).
222
254
  *
223
255
  * IN SERIE, e non tutte insieme (P7 preflight): la tab successiva parte
224
256
  * quando il `deck-run` della precedente è uscito, così le tab si aprono
@@ -663,6 +695,7 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
663
695
  spawnForTask,
664
696
  spawnTaskSession,
665
697
  resumeSession,
698
+ openSession,
666
699
  restoreSessions,
667
700
  cycleResumeModel,
668
701
  cycleBareModel,
package/dist/cli.js CHANGED
@@ -111,7 +111,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
111
111
  setNote,
112
112
  // T148/D1 — nessun selettore nella ricerca: il modello è quello ereditato
113
113
  // dalla conversazione, senza superficie di scelta.
114
- onResume: (row) => actions.resumeSession(row.session.sessionId, modelAlias(row.session.model) ?? MODEL_DEFAULT),
114
+ // T184 — `openSession` come il `⏎` della lista: una viva va davanti, solo
115
+ // una non viva si riprende.
116
+ onResume: (row) => actions.openSession(row.session.sessionId, modelAlias(row.session.model) ?? MODEL_DEFAULT),
115
117
  });
116
118
  // T121 — il project status. Non è solo un overlay come i tre sopra: il suo
117
119
  // stato si legge in TESTATA a schermata chiusa, e il viewer è una delle sue
@@ -0,0 +1,139 @@
1
+ import { spawnOut } from './spawn.js';
2
+ // Il binario del canale. L'override esiste per il collaudo del ramo «canale
3
+ // assente», stesso schema di `LOOM_DECK_NOTIFY_CMD` (`spawn.ts`): su una
4
+ // macchina dove `gdbus` c'è e compass è sul bus, quel ramo non è altrimenti
5
+ // raggiungibile.
6
+ export const FOCUS_CMD = process.env.LOOM_DECK_FOCUS_CMD ?? 'gdbus';
7
+ /**
8
+ * Il timeout che `gdbus` applica alla risposta, in secondi.
9
+ *
10
+ * compass risponde DOPO il salto: l'helper AT-SPI e la ricerca della finestra
11
+ * costano qualche decimo di secondo ciascuno, quindi la soglia sta un ordine di
12
+ * grandezza sopra il caso normale e scatta solo su un compass appeso. Un compass
13
+ * assente non la aspetta: il bus risponde subito `ServiceUnknown`.
14
+ */
15
+ export const FOCUS_TIMEOUT_S = 5;
16
+ /**
17
+ * La rete sotto il timeout di `gdbus`: Node uccide il figlio se nemmeno quello
18
+ * esce, per esempio con un bus di sessione che non risponde alla connessione.
19
+ * Senza, un `⏎` resterebbe senza nota per sempre.
20
+ */
21
+ const FOCUS_KILL_MS = (FOCUS_TIMEOUT_S + 1) * 1000;
22
+ /** Gli esiti che portano alla conversazione: la tab era già davanti
23
+ * (`esatta`) o il salto l'ha portata davanti (`tab`). Ogni altra parola del
24
+ * vocabolario di compass dice perché non ci si è arrivati. */
25
+ const REACHED = new Set(['esatta', 'tab']);
26
+ /** L'argv di `gdbus` per chiedere a compass il focus di `sessionId`. Il
27
+ * `sessionId` va nudo: `gdbus` legge il tipo `s` dall'introspezione del metodo
28
+ * e un UUID non è testo GVariant valido, quindi lo prende come stringa. */
29
+ export function focusSessionArgs(sessionId) {
30
+ return [
31
+ 'call',
32
+ '--session',
33
+ '--dest',
34
+ 'org.lamemind.Compass',
35
+ '--object-path',
36
+ '/org/lamemind/Compass',
37
+ '--method',
38
+ 'org.lamemind.Compass.FocusSession',
39
+ '--timeout',
40
+ String(FOCUS_TIMEOUT_S),
41
+ sessionId,
42
+ ];
43
+ }
44
+ /**
45
+ * L'esito dentro la risposta testuale di `gdbus`, o `null` se la risposta non
46
+ * ha la forma attesa.
47
+ *
48
+ * Un metodo con firma `(s)` esce come tupla GVariant di un elemento:
49
+ * `('tab',)`. La parola non si confronta col vocabolario di compass: un esito
50
+ * che una versione futura aggiungesse deve arrivare nella nota così com'è,
51
+ * invece di confondersi con un canale rotto.
52
+ */
53
+ export function parseFocusReply(stdout) {
54
+ const m = /^\('([^']*)',\)$/.exec(stdout.trim());
55
+ return m ? m[1] : null;
56
+ }
57
+ /**
58
+ * Chiede a compass il focus di `sessionId` e consegna l'esito a `onResult`,
59
+ * una volta sola.
60
+ *
61
+ * Il listener su `error` è OBBLIGATORIO e non difensivo: senza `gdbus` (su
62
+ * Windows, o su un Linux senza glib) lo spawn fallisce con un `error`
63
+ * asincrono, e un `error` non agganciato su un `ChildProcess` abbatte il deck.
64
+ * Agganciato, è il ramo «canale assente», ed è anche il solo gate di
65
+ * piattaforma che serve: nessun `process.platform` qui.
66
+ *
67
+ * Il codice d'uscita decide, lo stderr no: `gdbus` lo scrive nella lingua del
68
+ * sistema (`Errore: GDBus.Error:…ServiceUnknown`), e ne resta solo l'ultima
69
+ * riga come motivo, per chi legge il risultato in un test.
70
+ */
71
+ export function focusSession(sessionId, onResult) {
72
+ const child = spawnOut(FOCUS_CMD, focusSessionArgs(sessionId), {
73
+ stdio: ['ignore', 'pipe', 'pipe'],
74
+ timeout: FOCUS_KILL_MS,
75
+ });
76
+ let out = '';
77
+ let err = '';
78
+ child.stdout?.on('data', (c) => {
79
+ out += c.toString();
80
+ });
81
+ child.stderr?.on('data', (c) => {
82
+ err += c.toString();
83
+ });
84
+ // `error` e `close` possono arrivare entrambi per lo stesso spawn fallito:
85
+ // la nota è una sola.
86
+ let done = false;
87
+ const finish = (r) => {
88
+ if (done)
89
+ return;
90
+ done = true;
91
+ onResult(r);
92
+ };
93
+ child.on('error', (e) => finish({ unreachable: e.code ?? e.message }));
94
+ child.on('close', (code, signal) => {
95
+ if (code !== 0) {
96
+ const last = err.trim().split('\n').pop() ?? '';
97
+ finish({ unreachable: signal ? `timeout (${signal})` : last || `exit ${code}` });
98
+ return;
99
+ }
100
+ const outcome = parseFocusReply(out);
101
+ finish(outcome === null
102
+ ? { unreachable: `risposta inattesa: ${out.trim()}` }
103
+ : { reached: REACHED.has(outcome), outcome });
104
+ });
105
+ return child;
106
+ }
107
+ /**
108
+ * La nota di un `⏎` su una viva, a ogni esito, successo compreso.
109
+ *
110
+ * Sul successo la tab del deck va dietro, quindi la nota non la legge nessuno
111
+ * subito: si legge al ritorno, ed è l'unica traccia di cosa ha fatto il tasto —
112
+ * ogni spawn del deck lascia già la sua riga di stato, e un `⏎` muto sarebbe
113
+ * l'unica azione senza.
114
+ *
115
+ * `sconosciuta` (compass non trova il `sessionId` fra i vivi) cade nella forma
116
+ * di ogni esito fuori dalla tab, senza un resume: vuol dire che la
117
+ * conversazione è finita dopo l'ultimo tick del deck, e il `⏎` successivo, a
118
+ * tick avvenuto, la vede non viva e la riprende.
119
+ */
120
+ export function focusNote(r, name) {
121
+ if ('unreachable' in r)
122
+ return `⚠ compass non raggiungibile — ${name}`;
123
+ return r.reached ? `→ ${name}` : `⚠ focus ${r.outcome} — ${name}`;
124
+ }
125
+ /**
126
+ * La scelta del `⏎`. Una viva non si riprende MAI, qualunque esito torni:
127
+ * né su `assente` (viva, ma senza una tab col suo nome — lanciata fuori da
128
+ * Ptyxis) né con compass irraggiungibile. In entrambi i casi il processo
129
+ * `claude` c'è, e un resume sarebbe il secondo scrittore.
130
+ */
131
+ export function enterSession(sessionId, deps) {
132
+ if (!deps.isLive(sessionId)) {
133
+ deps.resume(sessionId);
134
+ return 'resume';
135
+ }
136
+ const name = deps.nameOf(sessionId);
137
+ deps.focus(sessionId, (r) => deps.note(focusNote(r, name)));
138
+ return 'focus';
139
+ }
package/dist/input.js CHANGED
@@ -523,6 +523,9 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
523
523
  else {
524
524
  // T49 — ⏎ su una sessione = resume in nuova tab. Il binding si rilegge
525
525
  // dal sidecar (non dal padre selezionato): vale anche per le spot.
526
+ // T184 — solo se NON è viva: su una viva compass porta davanti la sua
527
+ // tab, e nessun `deck-run` parte (`openSession`, la stessa azione del
528
+ // `⏎` nella ricerca).
526
529
  const s = model.selSessionObj;
527
530
  if (!s) {
528
531
  setNote(model.selSessionId
@@ -530,7 +533,7 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
530
533
  : 'nessuna sessione da riprendere');
531
534
  }
532
535
  else {
533
- actions.resumeSession(s.sessionId, model.resumeModel);
536
+ actions.openSession(s.sessionId, model.resumeModel);
534
537
  }
535
538
  }
536
539
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.74.0",
3
+ "version": "0.75.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": {
@@ -1,90 +0,0 @@
1
- // T134 — il DETAIL di un file inbox: sesta schermata sostitutiva.
2
- //
3
- // Stessa forma dello `sheet` della task e per la stessa ragione: il file è
4
- // FOTOGRAFATO all'apertura — nome, natura, testo — e non è una vista sulla
5
- // selezione corrente. L'overlay copre la lista, quindi l'oggetto dell'azione
6
- // deve restare quello che si è scelto anche se un tick dello scan spostasse la
7
- // selezione sotto.
8
- //
9
- // Molto più magro dello sheet: nessuna area di compilazione. Lo sheet ha
10
- // quattro righe perché quattro parametri dello spawn sono una scelta (azione,
11
- // prompt, modello, titolo); qui non ce n'è nessuna — la skill la decide la
12
- // natura (D8), il modello è `MODEL_DEFAULT` per tutte e tre (D11 preflight), la sessione
13
- // nasce nuda (D10 preflight). Restano la lettura e un tasto.
14
- //
15
- // Lo spawn NON sta qui: arriva come callback `onDrain`, per lo stesso confine
16
- // di `useSheetOverlay` e `useSearchOverlay` — un hook di overlay non esce dal
17
- // deck.
18
- import { useMemo, useState } from 'react';
19
- import { inboxDetailCapacity, pageStep } from '../viewport.js';
20
- import { wrapWithOffsets } from '../width.js';
21
- import { parseMarkdown } from '../markdown.js';
22
- import { inboxPrompt } from '../inbox.js';
23
- export function useInboxOverlay(deps) {
24
- const { rows, columns, setMode, setNote, onDrain } = deps;
25
- const [sheet, setSheet] = useState(null);
26
- const [top, setTop] = useState(0);
27
- // Cornici da scalare: box esterno (2 bordi + 2 padding) + box testo (2 bordi
28
- // + 2 padding) = 8, come il detail della task.
29
- const width = Math.max(20, (columns || 80) - 8);
30
- // Il markdown si rende PRIMA del wrap: `**foo**` occupa 3 colonne rese e 7
31
- // grezze, quindi wrappare sui marker manderebbe a capo su un conteggio che il
32
- // terminale non disegna. Memo separato dal wrap perché il parse dipende solo
33
- // dal testo: un resize ri-wrappa, non ri-parsa.
34
- const doc = useMemo(() => (sheet?.text ? parseMarkdown(sheet.text) : null), [sheet]);
35
- const lines = useMemo(() => (doc ? wrapWithOffsets(doc.text, width) : []), [doc, width]);
36
- const capacity = inboxDetailCapacity(rows);
37
- const maxTop = Math.max(0, lines.length - capacity);
38
- /** Il prompt che partirà: derivato una volta e mostrato in fondo alla
39
- * schermata, così `⏎` non è mai un salto nel buio. */
40
- const prompt = sheet ? inboxPrompt(sheet.file) : '';
41
- function open(next) {
42
- setSheet(next);
43
- setTop(0);
44
- setNote('');
45
- setMode('inbox');
46
- }
47
- function close() {
48
- setMode('normal');
49
- setSheet(null);
50
- }
51
- function scroll(delta) {
52
- setTop((t) => Math.max(0, Math.min(maxTop, t + delta)));
53
- }
54
- function onKey(input, key) {
55
- if (key.escape) {
56
- close();
57
- }
58
- else if (key.return) {
59
- // Si chiude PRIMA di spawnare, come lo sheet della task: la schermata ha
60
- // finito il suo lavoro, e lasciarla aperta sopra una tab appena nata
61
- // farebbe credere che ci sia dell'altro da decidere.
62
- const s = sheet;
63
- close();
64
- if (s)
65
- onDrain(s.file, inboxPrompt(s.file));
66
- }
67
- else if (key.upArrow) {
68
- scroll(-1);
69
- }
70
- else if (key.downArrow) {
71
- scroll(1);
72
- }
73
- else if (key.pageUp) {
74
- scroll(-pageStep(capacity));
75
- }
76
- else if (key.pageDown) {
77
- scroll(pageStep(capacity));
78
- }
79
- else if (input === 'g') {
80
- // `g`/`G` sugli estremi, come reader, detail e project status: anche
81
- // questa schermata scorre testo, quindi eredita lo stesso alfabeto invece
82
- // di inventarne un secondo.
83
- setTop(0);
84
- }
85
- else if (input === 'G') {
86
- setTop(maxTop);
87
- }
88
- }
89
- return { sheet, doc, lines, top, capacity, maxTop, prompt, open, onKey, scroll };
90
- }
@@ -1,21 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // T134 — il detail di un file inbox: testo scorrevole più la riga che dichiara
3
- // cosa partirà su `⏎`.
4
- //
5
- // Riusa `DetailLine` del detail della task invece di ri-renderizzare il
6
- // markdown per conto proprio: sono lo stesso costrutto — un documento reso in
7
- // span tipizzati dentro un box — e due rese dello stesso markdown
8
- // divergerebbero al primo stile aggiunto. L'evidenziazione della ricerca non
9
- // serve qui (nessuna ricerca dentro l'inbox), quindi le occorrenze arrivano
10
- // vuote e il secondo taglio di `DetailLine` non gira nemmeno.
11
- import { Box, Text } from 'ink';
12
- import { cut } from '../width.js';
13
- import { DetailLine } from './detail-screen.js';
14
- import { INBOX_MARK, WARN, fmtDateTime, fmtSize } from '../glyphs.js';
15
- import { NATURA_SHORT, inboxMark } from '../inbox.js';
16
- export function InboxScreen({ file, missing, lines, spans, top, total, capacity, prompt, columns, }) {
17
- const last = Math.min(total, top + capacity);
18
- const width = Math.max(20, (columns || 80) - 4);
19
- const mark = inboxMark(file);
20
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: NATURA_SHORT[file.natura] }), ' ', cut(file.basename, Math.max(10, width - 8))] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [INBOX_MARK[mark] ? `${INBOX_MARK[mark]} ` : '', mark, file.branch ? ` ⟨${file.branch}⟩` : '', file.cappello ? ` · ${file.cappello}` : '', " \u00B7 ", file.nozioni, " nozioni \u00B7 ", file.aperte, ' ', "aperte \u00B7 ", fmtSize(file.chars), " \u00B7 ", fmtDateTime(file.created * 1000), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193/PgUp/PgDn" }), " testo \u00B7 ", _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " apre la sessione \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiude"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: missing ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: [WARN, " file non leggibile \u00B7 l'azione resta attiva (la skill risolve il file per nome)"] })) : (lines.map((l, i) => (_jsx(DetailLine, { line: l, spans: spans, occ: [], current: -1 }, top + i)))) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "\u23CE \u203A " }), _jsx(Text, { color: "green", children: cut(prompt, Math.max(10, width - 4)) })] }) })] }));
21
- }