@lamemind/loom-deck 0.73.1 → 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
@@ -24,11 +24,21 @@ import { cut, cutMiddle } from './width.js';
24
24
  import { dropSessionFiles, sessionArtifacts } from './session-drop.js';
25
25
  import { saveView, viewFilePath } from './view-store.js';
26
26
  import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, DECK_RUN, } from './spawn.js';
27
+ import { MODEL_DEFAULT } from './spawn-catalog.js';
28
+ import { modelAlias } from './glyphs.js';
27
29
  import { resolveSpawnId } from './spawn-config.js';
28
30
  import { useTaskOps } from './task-ops.js';
29
31
  import { inboxPrompt, inboxWords } from './inbox.js';
30
32
  import { docTargetKind, docWords } from './doc-tree.js';
31
33
  import { wrapWords } from './wrap-scan.js';
34
+ import { enterSession, focusSession } from './focus-session.js';
35
+ /**
36
+ * T181 — l'attesa massima di un passo del restore prima di passare al
37
+ * successivo. Un `deck-run` esce appena `ptyxis --tab` ha consegnato la tab
38
+ * all'istanza già aperta, cioè ben sotto questa soglia: il timeout esiste per il
39
+ * figlio che non esce mai, non per scandire la serie.
40
+ */
41
+ const RESTORE_STEP_TIMEOUT_MS = 3_000;
32
42
  export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overrides, catalog, setNote, }) {
33
43
  // La riga di stato di OGNI spawn di sessione Claude: il comando esatto, come
34
44
  // lo si scriverebbe in bash, invece di una parafrasi.
@@ -187,19 +197,108 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
187
197
  const d = spawnDefaults(kind, taskHoles(task.id));
188
198
  spawnForTask(task.id, d.model, d.title ?? '', d.prompt ?? '');
189
199
  }
190
- // T49 — resume di una conversazione in una nuova tab. Unico punto: lo chiamano
191
- // il `⏎` della lista sessioni e quello sulla riga-sessione della ricerca, che
192
- // 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.
193
211
  // T148 — il modello lo decide il CHIAMANTE, non questa funzione: la lista
194
212
  // principale passa il selettore della preview (`model.resumeModel`), la
195
- // ricerca passa il modello ereditato dalla sessione senza superficie di
196
- // 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é è
197
215
  // lo spawn a essere identico, non la sorgente del valore.
198
216
  function resumeSession(sessionId, modelKind) {
199
217
  const bound = model.bindings.get(sessionId) ?? null;
200
218
  const spawned = spawnDeckResume(bound, cwd, sessionId, modelKind, model.sessionNotes.get(sessionId));
201
219
  spawned.child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
202
220
  noteSpawn(spawned);
221
+ return spawned;
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
+ }
244
+ /**
245
+ * T181 — `^T` e il bottone in testata: riapre le conversazioni dell'ultimo
246
+ * insieme vivo, una tab per conversazione, ognuna col proprio binding di
247
+ * task, la propria nota e il modello con cui girava.
248
+ *
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).
254
+ *
255
+ * IN SERIE, e non tutte insieme (P7 preflight): la tab successiva parte
256
+ * quando il `deck-run` della precedente è uscito, così le tab si aprono
257
+ * nell'ordine dell'insieme e la finestra riaperta ricalca da sinistra a
258
+ * destra quella chiusa. Il timeout per passo tiene la serie in moto anche se
259
+ * un figlio non esce mai — uno spawn inerte dei test, un `ptyxis` che resta
260
+ * appeso: una tab fuori ordine è meglio di un restore che si ferma a metà.
261
+ *
262
+ * La tenuta (`holdLive`) va due volte: su tutte all'avvio, perché il bottone
263
+ * sparisca subito e un secondo `^T` non riapra tutto di nuovo; su ognuna alla
264
+ * partenza della sua tab, perché la scadenza si conti da lì. Nessuna
265
+ * conferma (P6): ricostruisce uno stato che l'utente aveva, ed è reversibile
266
+ * chiudendo le tab.
267
+ */
268
+ function restoreSessions() {
269
+ const list = model.restorable;
270
+ if (list.length === 0) {
271
+ setNote(model.restoring ? '^T → restore già in corso' : '^T → nessuna conversazione da riaprire');
272
+ return;
273
+ }
274
+ model.holdLive(list.map((s) => s.sessionId));
275
+ const step = (i) => {
276
+ if (i >= list.length) {
277
+ // L'ultimo annuncio in-tab arriva asincrono e scriverebbe sopra il
278
+ // riepilogo: avanzare la sequenza lo rende vecchio prima che arrivi.
279
+ spawnSeq.current++;
280
+ setNote(`↻ restore: ${list.length} ${list.length === 1 ? 'conversazione riaperta' : 'conversazioni riaperte'}`);
281
+ return;
282
+ }
283
+ const s = list[i];
284
+ model.holdLive([s.sessionId]);
285
+ const { child } = resumeSession(s.sessionId, modelAlias(s.model) ?? MODEL_DEFAULT);
286
+ let done = false;
287
+ const next = () => {
288
+ if (done)
289
+ return;
290
+ done = true;
291
+ clearTimeout(timer);
292
+ step(i + 1);
293
+ };
294
+ // `unref`: il timer non deve tenere vivo il processo se il deck esce a
295
+ // metà serie — lo schermo sarebbe già restituito al terminale.
296
+ const timer = setTimeout(next, RESTORE_STEP_TIMEOUT_MS);
297
+ timer.unref();
298
+ child.once('exit', next);
299
+ child.once('error', next);
300
+ };
301
+ step(0);
203
302
  }
204
303
  /** T148/T154(P2,P3) — `m` col bersaglio RESUME: `input.ts` instrada qui solo
205
304
  * quando il focus è sulle sessioni E una riga è selezionata (P2), quindi il
@@ -596,6 +695,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
596
695
  spawnForTask,
597
696
  spawnTaskSession,
598
697
  resumeSession,
698
+ openSession,
699
+ restoreSessions,
599
700
  cycleResumeModel,
600
701
  cycleBareModel,
601
702
  writeNote,
package/dist/cli.js CHANGED
@@ -23,7 +23,7 @@ import { MODEL_SHORT_LIST, modelAlias } from './glyphs.js';
23
23
  import { MODEL_DEFAULT, MODELS } from './spawn-catalog.js';
24
24
  import { loadPromptCatalog } from './prompt-catalog.js';
25
25
  import { resolveSpawnId } from './spawn-config.js';
26
- import { BARE_BUTTONS_WIDTH, BARE_LABEL, deckLegend, frameGeometry, headlineWidth, indicatorRow, launchRow, } from './frame.js';
26
+ import { BARE_BUTTONS_WIDTH, BARE_LABEL, HEADER_BUTTON_SEP, deckLegend, frameGeometry, headlineWidth, indicatorRow, launchRow, } from './frame.js';
27
27
  import { choiceButtons } from './ui/fields.js';
28
28
  import { useDeckModel } from './deck-model.js';
29
29
  import { useDeckActions } from './actions.js';
@@ -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
@@ -304,6 +306,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
304
306
  docCounts: model.docCounts,
305
307
  docViewId: model.docViewId,
306
308
  docHasDirs: model.docHasDirs,
309
+ restoreCount: model.restorable.length,
307
310
  });
308
311
  useDeckInput({
309
312
  cwd,
@@ -343,7 +346,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
343
346
  if (frame.budget.compact) {
344
347
  return (_jsx(CompactNotice, { dot: false, what: `v${VERSION} · ${model.viewTasks.length} task · sel ${model.selectedTaskId ?? model.parentLabel}`, rows: rows, columns: columns }));
345
348
  }
346
- 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({
349
+ 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.headerBlock) }), frame.headerButton ? (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "yellow", bold: true, children: frame.headerButton }), _jsxs(Text, { dimColor: true, children: [HEADER_BUTTON_SEP, frame.headerRight] })] })) : (_jsx(Text, { dimColor: true, children: frame.headerRight }))] }), _jsx(HintBar, { mode: mode, purge: purge.draft, keyLegend: deckLegend({
347
350
  focus: model.focus,
348
351
  hasTask: model.selTask !== null,
349
352
  hasSession: model.selSessionObj !== null,
@@ -353,6 +356,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
353
356
  hasInbox: model.selInbox !== null,
354
357
  hasDoc: model.selDoc !== null,
355
358
  gitlink: gitlink.enabled,
359
+ restore: frame.headerButton !== null,
356
360
  }), indicators: indicators, columns: columns }), 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, launch.filler, BARE_LABEL.text, ' ', bareButtons.nodes] })) : 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, mode === 'drop' && drop.draft ? _jsx(DropModal, { draft: drop.draft, columns: columns }) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [model.deckMode === 'doc' ? (_jsx(DocPane, { rows: frame.windowDoc, counts: model.docCounts, activeView: model.docViewId, paneCount: model.docRows.length, selectedPath: model.selDocPath, focused: model.focus === 'doctree', above: frame.docWin.start, below: model.docRows.length - frame.docWin.end, columns: columns, ok: model.docOk, scanned: model.docScanned, scanning: model.docScanning, hasDirs: model.docHasDirs })) : (_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, blockMark: model.blockMark })), model.deckMode === 'doc' ? (_jsx(InboxPane, { files: frame.windowInbox, counts: model.inboxCounts, activeView: model.inboxViewId, paneCount: model.inboxFiles.length, selectedPath: model.selInboxPath, focused: model.focus === 'inbox', above: frame.inboxWin.start, below: model.inboxFiles.length - frame.inboxWin.end, columns: columns, ok: model.inboxOk, scanned: model.inboxScanned })) : (_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, sessionPriority: model.sessionPriority, 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 === 'inbox' && model.selInbox ? (_jsx(PreviewPane, { kind: "inbox", file: model.selInbox, columns: columns })) : frame.budget.preview && previewKind === 'doc' && model.selDoc ? (_jsx(PreviewPane, { kind: "doc", row: model.selDoc, 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, resumeModel: model.resumeModel })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
357
361
  }
358
362
  const cwd = process.cwd();
@@ -16,6 +16,7 @@ import { useEffect, useMemo, useState } from 'react';
16
16
  import { loadArchivableDays, loadIdentity, loadLaunch } from './config.js';
17
17
  import { useArchivable, useCommitTimes, useDirtyFolders, useDocScan, useEpicHierarchy, useInboxScan, useSessions, useTaskDetail, useTasks, } from './hooks.js';
18
18
  import { DEFAULT_INBOX_STALE_HOURS, staleCount } from './inbox.js';
19
+ import { aliveIds, restoreSet } from './live-marks.js';
19
20
  import { cycleDocView, docCounts as deriveDocCounts, docView, selectDocRows, } from './doc-views.js';
20
21
  import { cycleInboxView, inboxCounts as deriveInboxCounts, inboxView, selectInboxRows, } from './inbox-views.js';
21
22
  import { docsRootName } from './tasks.js';
@@ -129,7 +130,7 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
129
130
  // di STATO in fondo al frame (il feedback di un'azione). Due concetti diversi
130
131
  // a una lettera di distanza sarebbero una trappola di lettura — e di
131
132
  // scrittura, visto che `setNote` compare in quasi ogni ramo.
132
- const { sessions, bindings, forkOf, pinned, notes: sessionNotes, priority: sessionPriority, live, reload: reloadSessions, } = useSessions(cwd, projectCore);
133
+ const { sessions, bindings, forkOf, pinned, notes: sessionNotes, priority: sessionPriority, live, liveMarks, restoring, holdLive, reload: reloadSessions, } = useSessions(cwd, projectCore);
133
134
  const [focus, setFocus] = useState('tasks');
134
135
  // T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
135
136
  // (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
@@ -317,6 +318,12 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
317
318
  // non quella a schermo: il contatore di una voce del catalogo non può
318
319
  // dipendere da quale voce è selezionata, o navigare muoverebbe i numeri.
319
320
  const liveCount = useMemo(() => assembled.rows.filter((r) => live.has(r.sessionId)).length, [assembled, live]);
321
+ // T181 — le conversazioni che il bottone di restore riapre, nell'ordine di
322
+ // riapertura. L'insieme vivo è lo stesso incrocio `live ∩ sessions` che la
323
+ // convergenza usa per scrivere la marca (`aliveIds`): due definizioni
324
+ // diverse farebbero comparire il bottone accanto a una conversazione che per
325
+ // la marca è viva, o il contrario.
326
+ const restorable = useMemo(() => restoreSet({ marked: liveMarks, sessions, alive: aliveIds(live, sessions), restoring }), [liveMarks, sessions, live, restoring]);
320
327
  const sessionCounts = {
321
328
  total: assembled.contextTotal,
322
329
  live: liveCount,
@@ -544,6 +551,10 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
544
551
  live,
545
552
  reloadSessions,
546
553
  detail,
554
+ // T181 — restore delle conversazioni dell'ultimo insieme vivo
555
+ restorable,
556
+ restoring,
557
+ holdLive,
547
558
  // progetto
548
559
  launch,
549
560
  identity,
@@ -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/frame.js CHANGED
@@ -16,7 +16,7 @@
16
16
  // budget anche quando il render esce presto su una schermata sostitutiva:
17
17
  // aritmetica su array già in memoria, e `layoutBudget` accetta ogni `Mode`.
18
18
  import { docHeaderParts, headerItems, inboxHeaderParts, sessionHeaderParts, taskHeaderParts, } from './pane-header.js';
19
- import { FRAME_TEXT_COL, HINT_ROW, inlineRegions, LAUNCH_ROW, PANE_TEXT_PAD, paneSpans, rowRegions, } from './mouse.js';
19
+ import { FRAME_TEXT_COL, HEADER_ROW, HINT_ROW, inlineRegions, LAUNCH_ROW, PANE_TEXT_PAD, paneSpans, rowRegions, } from './mouse.js';
20
20
  import { cellWidth, launchLegend, LAUNCH_SEP } from './config.js';
21
21
  import { layoutBudget, windowRange } from './viewport.js';
22
22
  import { sanitize, termWidth } from './width.js';
@@ -85,6 +85,10 @@ export function deckLegend(state) {
85
85
  // spinnarla); basta il focus sul pane e una selezione.
86
86
  const canPin = state.focus === 'sessions' && state.hasSessionId;
87
87
  return sanitize([
88
+ // T181/P5 — in TESTA alla legenda, perché la legenda tronca dalla coda e
89
+ // questa voce vale proprio nel momento in cui il deck si è appena
90
+ // riaperto: è il primo gesto che si vuole fare, non uno fra venti.
91
+ ...(state.restore ? ['^T restore'] : []),
88
92
  ...(canSpawn
89
93
  ? ['⏎ detail', '^K/^P/^R spawn']
90
94
  : canResume
@@ -316,6 +320,39 @@ export function indicatorRow(state, columns) {
316
320
  const start = FRAME_TEXT_COL + Math.max(0, (columns || 80) - 4) - width;
317
321
  return { segments, text, regions: rowRegions(segments, INDICATOR_SEP, start), width };
318
322
  }
323
+ /**
324
+ * T181 — il bottone della testata: `[ restore 3 sessions ]`, a sinistra di
325
+ * risoluzione e versione.
326
+ *
327
+ * `key` come gli indicatori della riga legenda: la COMBO col prefisso `^`, e il
328
+ * click rientra dalla porta della tastiera premendola (`HEADER_ROW` in
329
+ * `mouse.ts`). La combo non sta nel testo del bottone (P5 preflight): la dice la
330
+ * legenda, come per gli indicatori di T134.
331
+ *
332
+ * Tre forme, dalla più lunga, e vince la prima che lascia al project status a
333
+ * sinistra almeno il pavimento di `headlineWidth`: il bottone entra nel budget
334
+ * della testata, e una forma che non ci sta spingerebbe la riga oltre il bordo.
335
+ * Nessuna delle tre ci sta → nessun bottone; `^T` resta valido, perché il
336
+ * tasto non dipende da cosa entra a schermo.
337
+ */
338
+ /** Il pavimento del budget del project status: sotto, non resta niente di
339
+ * leggibile. Il bottone di restore lo rispetta (`restoreButton`). */
340
+ const HEADLINE_MIN = 4;
341
+ export const RESTORE_KEY = '^t';
342
+ export const HEADER_BUTTON_SEP = ' ';
343
+ export function restoreButtonForms(n) {
344
+ return [`[ restore ${n} ${n === 1 ? 'session' : 'sessions'} ]`, `[ restore ${n} ]`, `[ ${n} ]`];
345
+ }
346
+ export function restoreButton(n, columns, headerRight) {
347
+ if (n <= 0)
348
+ return null;
349
+ for (const form of restoreButtonForms(n)) {
350
+ const block = termWidth(form) + termWidth(HEADER_BUTTON_SEP) + termWidth(headerRight);
351
+ if ((columns || 80) - 4 - block - 1 >= HEADLINE_MIN)
352
+ return form;
353
+ }
354
+ return null;
355
+ }
319
356
  /**
320
357
  * Il budget d'altezza e le finestre di rendering.
321
358
  *
@@ -381,6 +418,21 @@ export function frameGeometry(input) {
381
418
  docRows: windowDoc.length,
382
419
  mode: input.deckMode,
383
420
  };
421
+ // Dimensione del terminale in CELLE (colonne×righe, mai pixel — un processo
422
+ // dentro un terminale vede solo la griglia di caratteri) e versione. La
423
+ // parte sinistra della testata riceve un budget derivato da questa stringa e
424
+ // non da una lunghezza fissa: la risoluzione cambia a ogni resize, quindi
425
+ // anche la larghezza che occupa.
426
+ const headerRight = `${input.columns}×${input.rows} · v${VERSION}`;
427
+ // T181 — il bottone sta a sinistra di `headerRight` nello stesso blocco
428
+ // ancorato a destra, quindi la sua prima colonna si ricava per sottrazione
429
+ // dal bordo, come per gli indicatori (`indicatorRow`).
430
+ const headerButton = restoreButton(input.restoreCount, input.columns, headerRight);
431
+ const headerBlock = headerButton ? headerButton + HEADER_BUTTON_SEP + headerRight : headerRight;
432
+ const blockStart = FRAME_TEXT_COL + Math.max(0, (input.columns || 80) - 4) - termWidth(headerBlock);
433
+ const headerRegions = headerButton
434
+ ? [{ key: RESTORE_KEY, start: blockStart, end: blockStart + termWidth(headerButton) - 1 }]
435
+ : [];
384
436
  return {
385
437
  budget,
386
438
  taskWin,
@@ -392,18 +444,16 @@ export function frameGeometry(input) {
392
444
  docWin,
393
445
  windowDoc,
394
446
  listGeometry,
395
- // Dimensione del terminale in CELLE (colonne×righe, mai pixel — un processo
396
- // dentro un terminale vede solo la griglia di caratteri) e versione. La
397
- // parte sinistra della testata riceve un budget derivato da questa stringa e
398
- // non da una lunghezza fissa: la risoluzione cambia a ogni resize, quindi
399
- // anche la larghezza che occupa.
400
- headerRight: `${input.columns}×${input.rows} · v${VERSION}`,
447
+ headerRight,
448
+ headerButton,
449
+ headerBlock,
450
+ headerRegions,
401
451
  launchLine,
402
452
  };
403
453
  }
404
- /** Il budget della testata sinistra, per sottrazione dal segmento destro. */
454
+ /** Il budget della testata sinistra, per sottrazione dal blocco destro. */
405
455
  export function headlineWidth(columns, headerRight) {
406
- return Math.max(4, columns - 4 - termWidth(headerRight) - 1);
456
+ return Math.max(HEADLINE_MIN, columns - 4 - termWidth(headerRight) - 1);
407
457
  }
408
458
  /**
409
459
  * T134 — il budget della legenda tasti, per sottrazione del blocco indicatori
@@ -418,4 +468,4 @@ export function headlineWidth(columns, headerRight) {
418
468
  export function legendWidth(columns, indicatorW) {
419
469
  return Math.max(4, (columns || 80) - 4 - indicatorW - termWidth(INDICATOR_SEP));
420
470
  }
421
- export { HINT_ROW, LAUNCH_ROW };
471
+ export { HEADER_ROW, HINT_ROW, LAUNCH_ROW };
package/dist/hooks.js CHANGED
@@ -10,6 +10,7 @@ import { discoverProjectSessions } from './sessions.js';
10
10
  import { discoverLiveSessions, liveSig } from './live-sessions.js';
11
11
  import { loadSessionIndex } from './task-index.js';
12
12
  import { fillSidecarGaps } from './session-meta.js';
13
+ import { aliveIds, liveMarkPlan, writeLiveMarks, RESTORE_HOLD_MS } from './live-marks.js';
13
14
  import { archivableIds, SCAN_INTERVAL_MS } from './archivable.js';
14
15
  import { commitTimes } from './commit-times.js';
15
16
  import { scanEpicHierarchy, EMPTY_EPIC_HIERARCHY } from './epic-hierarchy.js';
@@ -147,6 +148,9 @@ export function useEpicHierarchy(tasksDir) {
147
148
  // vive già nel modello (`projectCore`), e una seconda copia toglierebbe dal
148
149
  // titolo scritto nel sidecar una stringa diversa da quella che la lista toglie
149
150
  // dalle proprie righe.
151
+ //
152
+ // T181 — ed è il posto dove CONVERGE la marca `live` del sidecar, per la stessa
153
+ // ragione: l'insieme vivo e la marca su disco sono in mano nello stesso giro.
150
154
  export function useSessions(projectRoot, core) {
151
155
  const [state, setState] = useState({
152
156
  sessions: [],
@@ -156,13 +160,24 @@ export function useSessions(projectRoot, core) {
156
160
  notes: new Map(),
157
161
  priority: new Set(),
158
162
  live: new Map(),
163
+ liveMarks: new Map(),
164
+ restoring: false,
159
165
  });
160
166
  // T50 — pin/unpin scrive il sidecar e vuole feedback IMMEDIATO, non al
161
167
  // prossimo tick del poll (1.5s): la reload è esposta via ref così il toggle la
162
168
  // richiama senza risottoscrivere l'intervallo.
163
169
  const reloadRef = useRef(() => { });
170
+ // T181 — le conversazioni che il restore sta riaprendo, con la scadenza della
171
+ // tenuta. In un ref e non nell'effect perché chi le scrive è un gesto
172
+ // (`holdLive`, dal restore), non il giro del poll.
173
+ const holds = useRef(new Map());
164
174
  useEffect(() => {
165
175
  let lastSig = '';
176
+ // T181 — l'isteresi del ritiro (P3 preflight): le marcate viste mancare al
177
+ // giro precedente. È l'unico stato in memoria della convergenza, e parte
178
+ // vuoto a ogni avvio: un deck che riparte senza vive non scrive niente, e
179
+ // con delle vive rimisura da capo — un ritiro ritardato di un tick, mai perso.
180
+ let missingOnce = new Set();
166
181
  const reload = () => {
167
182
  let sessions;
168
183
  let index;
@@ -180,6 +195,7 @@ export function useSessions(projectRoot, core) {
180
195
  priority: new Set(),
181
196
  titles: new Map(),
182
197
  models: new Map(),
198
+ liveMarks: new Map(),
183
199
  };
184
200
  }
185
201
  // T62 — le vive stanno sullo STESSO tick delle altre fonti, non su una
@@ -222,6 +238,40 @@ export function useSessions(projectRoot, core) {
222
238
  models: index.models,
223
239
  core,
224
240
  });
241
+ // T181 — la marca `live`: il piano dei buchi, poi la scrittura. Fuori dal
242
+ // gate della signature come i campi derivati sopra, e per la stessa
243
+ // ragione: un append fallito si ritenta al giro dopo, e la signature
244
+ // invariata non lo farebbe mai ripartire.
245
+ //
246
+ // La tenuta del restore si pota qui, prima del piano: una conversazione
247
+ // tenuta che è già viva non ha più niente da cui essere protetta, e una
248
+ // tenuta scaduta torna sotto l'isteresi normale.
249
+ const alive = aliveIds(live, sessions);
250
+ const now = Date.now();
251
+ for (const [id, until] of holds.current) {
252
+ if (until <= now || alive.includes(id))
253
+ holds.current.delete(id);
254
+ }
255
+ const plan = liveMarkPlan({
256
+ alive,
257
+ marked: index.liveMarks,
258
+ missingOnce,
259
+ held: new Set(holds.current.keys()),
260
+ });
261
+ missingOnce = plan.missing;
262
+ let liveMarks = index.liveMarks;
263
+ // Rilettura solo quando ha scritto, cioè a ogni cambio dell'insieme vivo e
264
+ // mai a regime: la mappa a schermo è quella su disco, rango compreso, e
265
+ // non una seconda derivazione del rango in memoria che potrebbe divergere.
266
+ if (writeLiveMarks(projectRoot, plan) > 0) {
267
+ try {
268
+ liveMarks = loadSessionIndex(projectRoot).liveMarks;
269
+ }
270
+ catch {
271
+ // rilettura fallita: resta la mappa di prima, il giro dopo la rifà.
272
+ }
273
+ }
274
+ const restoring = holds.current.size > 0;
225
275
  // La signature copre anche fork, pin, note e marca di priorità: un record
226
276
  // di lineage, un toggle di pin, una nota appena scritta o un 🚨 acceso
227
277
  // cambiano la lista renderizzata, quindi devono forzare il re-render come
@@ -238,18 +288,35 @@ export function useSessions(projectRoot, core) {
238
288
  '#' +
239
289
  [...priority].sort().join(',') +
240
290
  '#' +
241
- liveSig(live);
291
+ liveSig(live) +
292
+ // T181 — la marca e il restore in corso decidono il bottone in testata.
293
+ '#' +
294
+ [...liveMarks.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',') +
295
+ (restoring ? '#restoring' : '');
242
296
  if (sig === lastSig)
243
297
  return;
244
298
  lastSig = sig;
245
- setState({ sessions, bindings, forkOf, pinned, notes, priority, live });
299
+ setState({ sessions, bindings, forkOf, pinned, notes, priority, live, liveMarks, restoring });
246
300
  };
247
301
  reloadRef.current = reload;
248
302
  reload();
249
303
  const id = setInterval(reload, POLL_MS);
250
304
  return () => clearInterval(id);
251
305
  }, [projectRoot, core]);
252
- return { ...state, reload: () => reloadRef.current() };
306
+ /**
307
+ * T181 — tiene fuori dal ritiro le conversazioni che il restore sta per
308
+ * riaprire, per `RESTORE_HOLD_MS` da adesso. Il restore la chiama due volte:
309
+ * su tutte all'avvio, così il bottone sparisce subito e un secondo `^T` non
310
+ * riapre tutto una seconda volta; e su ognuna quando parte la sua tab, così la
311
+ * scadenza si conta da lì e non dall'inizio di una serie che può durare.
312
+ */
313
+ const holdLive = useCallback((ids) => {
314
+ const until = Date.now() + RESTORE_HOLD_MS;
315
+ for (const id of ids)
316
+ holds.current.set(id, until);
317
+ reloadRef.current();
318
+ }, []);
319
+ return { ...state, reload: () => reloadRef.current(), holdLive };
253
320
  }
254
321
  // T61 — conteggio delle Done oltre soglia, su una scala di refresh TUTTA SUA.
255
322
  //
package/dist/input.js CHANGED
@@ -20,7 +20,7 @@
20
20
  import { useEffect, useRef } from 'react';
21
21
  import { useApp, useInput } from 'ink';
22
22
  import { hitRegion, isWheel, listHit, takeMouse, wheelDir, WHEEL_LINES, } from './mouse.js';
23
- import { HINT_ROW, LAUNCH_ROW } from './frame.js';
23
+ import { HEADER_ROW, HINT_ROW, LAUNCH_ROW } from './frame.js';
24
24
  import { moveSelection } from './session-list.js';
25
25
  import { captures, inertNote, scrolls, taskModeKey, } from './input-modes.js';
26
26
  import { LEFT_PANE, META_ROWS, QUIT_WINDOW_MS, RIGHT_PANE } from './model.js';
@@ -186,6 +186,14 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
186
186
  onKey(hit.slice(1), { ...NO_MODIFIERS, ctrl: true });
187
187
  return;
188
188
  }
189
+ if (ev.row === HEADER_ROW) {
190
+ // T181 — il bottone di restore, stessa porta degli indicatori: la sua
191
+ // chiave è la combo `^t`, e il click la preme invece di chiamare l'azione.
192
+ const hit = hitRegion(frame.headerRegions, ev.col);
193
+ if (hit)
194
+ onKey(hit.slice(1), { ...NO_MODIFIERS, ctrl: true });
195
+ return;
196
+ }
189
197
  onListClick(ev);
190
198
  }
191
199
  /**
@@ -386,6 +394,14 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
386
394
  // byte non arrivasse il test fallirebbe invece di lasciare il dubbio.
387
395
  overlays.spawn.openPage();
388
396
  }
397
+ else if (input === 't') {
398
+ // T181/P4 preflight — `^T` riapre le conversazioni dell'ultimo insieme
399
+ // vivo. La `t` nuda apre un terminale: stesso schema «una lettera, due
400
+ // bersagli» di `^F`/`f`, che il ramo `ctrl` anteposto rende sicuro. Non
401
+ // è un tasto del mondo task (`TASK_MODE_KEYS`): il bottone sta in
402
+ // testata, visibile in entrambi i modi del deck.
403
+ actions.restoreSessions();
404
+ }
389
405
  else if (input === 'b') {
390
406
  // T160 — `^B` scambia il MODO del deck, cioè entrambi gli slot insieme.
391
407
  // Erede del tasto di T134, che scambiava il solo pane destro: la
@@ -507,6 +523,9 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
507
523
  else {
508
524
  // T49 — ⏎ su una sessione = resume in nuova tab. Il binding si rilegge
509
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).
510
529
  const s = model.selSessionObj;
511
530
  if (!s) {
512
531
  setNote(model.selSessionId
@@ -514,7 +533,7 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
514
533
  : 'nessuna sessione da riprendere');
515
534
  }
516
535
  else {
517
- actions.resumeSession(s.sessionId, model.resumeModel);
536
+ actions.openSession(s.sessionId, model.resumeModel);
518
537
  }
519
538
  }
520
539
  }
@@ -0,0 +1,166 @@
1
+ // T181 — la MARCA `live` del sidecar: quali conversazioni stavano nell'ultimo
2
+ // insieme vivo del progetto, e quindi quali riapre il bottone di restore.
3
+ //
4
+ // Stesso schema di `session-meta.ts`: CONVERGENZA, non evento. A ogni tick del
5
+ // poll il deck confronta la marca su disco con le vive del registro e appende
6
+ // solo i buchi. Nessuno stato sul «giro precedente» a parte l'isteresi qui
7
+ // sotto: un deck che riparte converge dal sidecar com'è, e un tick perso si
8
+ // recupera al tick dopo.
9
+ //
10
+ // Il giudizio è separato dalla scrittura (`liveMarkPlan` decide,
11
+ // `writeLiveMarks` scrive) per la stessa ragione di `sidecarGaps`: la parte non
12
+ // ovvia è la convergenza, e un piano che non converge appenderebbe a ogni tick
13
+ // del poll, per sempre, senza nessun errore a dirlo.
14
+ //
15
+ // Nessun import da ink/react: il chiamante è un hook, questo modulo no.
16
+ import { NO_SPAWN } from './spawn.js';
17
+ import { appendLive } from './task-index.js';
18
+ /**
19
+ * L'insieme vivo della feature: le vive del registro che hanno anche una
20
+ * `Session` nel deck (P2 preflight), ordinate per avvio del processo.
21
+ *
22
+ * L'incrocio è lo stesso del contatore «vive» e del rollup (`rollupChildren` in
23
+ * `deck-model.ts`), ed è l'unico insieme che `resumeSession` sa riprendere: lo
24
+ * spawn parte con cwd = root del progetto e prende il modello dalla `Session`.
25
+ * Ne restano fuori due casi, entrambi di proposito:
26
+ *
27
+ * - una conversazione aperta in una SOTTOCARTELLA del progetto — il registro
28
+ * la include (filtro `cwd` per prefisso), ma il transcript sta sotto un altro
29
+ * hash di progetto e il deck non ha una `Session` per lei. Non si marca, e la
30
+ * sua presenza non azzera l'insieme da ripristinare;
31
+ * - una conversazione appena nata, o appena passata da un `/clear` — il
32
+ * registro la conosce prima del suo primo record di transcript. Entra al
33
+ * primo prompt.
34
+ *
35
+ * L'ordine per `startedAt` decide il rango di chi entra nello stesso tick: è
36
+ * l'ordine in cui le tab sono state aperte, quindi quello che il restore deve
37
+ * ricalcare. A parità (registro senza il campo) decide il `sessionId`, perché un
38
+ * piano deve essere deterministico per essere testabile.
39
+ */
40
+ export function aliveIds(live, sessions) {
41
+ const known = new Set(sessions.map((s) => s.sessionId));
42
+ return [...live.entries()]
43
+ .filter(([id]) => known.has(id))
44
+ .sort(([a, x], [b, y]) => x.startedAt - y.startedAt || (a < b ? -1 : a > b ? 1 : 0))
45
+ .map(([id]) => id);
46
+ }
47
+ /**
48
+ * Quanto a lungo una conversazione in riapertura resta tenuta, contato dalla
49
+ * partenza della SUA tab.
50
+ *
51
+ * Il conto che lo regge: il registro vivo scrive il file del processo
52
+ * nell'ordine del secondo dopo l'avvio di `claude` (`startedAt` contro
53
+ * `procStart` dello stesso file), più il tempo di `deck-run` e della tab. Dieci
54
+ * secondi coprono quel ritardo con un margine largo, e restano brevi abbastanza
55
+ * da ritirare in tempi
56
+ * umani la marca di una conversazione che non si riaccende — un `--resume` che
57
+ * fallisce, una tab chiusa subito. Scaduta la tenuta, vale l'isteresi normale.
58
+ */
59
+ export const RESTORE_HOLD_MS = 10_000;
60
+ /**
61
+ * Il piano di questo tick.
62
+ *
63
+ * **Insieme vivo VUOTO → nessun append, e l'isteresi riparte.** È la regola
64
+ * dell'utente, «la traccia si sovrascrive solo quando ci sono conversazioni
65
+ * vive»: è ciò che fa sopravvivere l'ultimo insieme alla chiusura del deck,
66
+ * della finestra o della macchina, e che il bottone di restore legge alla
67
+ * riapertura. Svuotare `missing` qui invece di portarlo avanti allunga il
68
+ * ritiro, mai lo accorcia: il prezzo di una scelta prudente è un tick di
69
+ * ritardo, il prezzo di quella opposta sarebbe ritirare al primo tick non vuoto
70
+ * una conversazione vista mancare una volta sola, magari molto prima.
71
+ *
72
+ * **Insieme vivo non vuoto:**
73
+ * - ogni viva non marcata ENTRA, al primo tick in cui la si vede;
74
+ * - ogni marcata assente ESCE solo al SECONDO tick consecutivo di assenza
75
+ * (P3 preflight). Il primo la mette in `missing` e basta. Serve perché
76
+ * chiudere una finestra Ptyxis con N tab fa uscire N processi con tempi
77
+ * propri: un tick caduto in quella finestra vede un insieme non vuoto ma
78
+ * dimagrito, e senza isteresi ritirerebbe la marca alle già morte proprio
79
+ * mentre l'insieme stava per diventare vuoto — cioè l'insieme da salvare;
80
+ * - una marcata TENUTA (`held`) non esce e non conta come assente: il restore
81
+ * accende le tab una alla volta, e la prima viva non deve far ritirare le
82
+ * altre che stanno ancora partendo.
83
+ *
84
+ * Una viva già marcata non produce niente: è la convergenza — un sidecar
85
+ * allineato dà un piano vuoto, e il giro dopo non trova niente da fare.
86
+ */
87
+ export function liveMarkPlan(input) {
88
+ const { alive, marked, missingOnce, held } = input;
89
+ if (alive.length === 0)
90
+ return { enter: [], leave: [], missing: new Set() };
91
+ const aliveSet = new Set(alive);
92
+ const enter = alive.filter((id) => !marked.has(id));
93
+ const leave = [];
94
+ const missing = new Set();
95
+ for (const id of marked.keys()) {
96
+ if (aliveSet.has(id) || held.has(id))
97
+ continue;
98
+ if (missingOnce.has(id))
99
+ leave.push(id);
100
+ else
101
+ missing.add(id);
102
+ }
103
+ return { enter, leave, missing };
104
+ }
105
+ /**
106
+ * Scrive il piano. Ritorna quanti record ha appeso — zero è l'esito normale a
107
+ * regime, non un fallimento.
108
+ *
109
+ * `NO_SPAWN` frena anche questa scrittura, come `fillSidecarGaps` e per la
110
+ * stessa ragione: il gate su pseudo-terminale avvia il deck vero con cwd la
111
+ * project root del cappello, e senza freno ogni run della suite appenderebbe la
112
+ * marca nel sidecar reale di chi la lancia.
113
+ *
114
+ * Gli ingressi prima delle uscite: un tick che li porta entrambi è il passaggio
115
+ * da un insieme all'altro (`/clear`, una tab chiusa mentre un'altra si apre), e
116
+ * nell'ordine inverso un lettore che cade fra i due append vedrebbe per un
117
+ * istante un insieme più piccolo di entrambi.
118
+ *
119
+ * Un sidecar non scrivibile non è un errore da mostrare: il giro dopo trova gli
120
+ * stessi buchi e riprova.
121
+ */
122
+ export function writeLiveMarks(projectRoot, plan) {
123
+ if (NO_SPAWN)
124
+ return 0;
125
+ let scritti = 0;
126
+ try {
127
+ for (const id of plan.enter) {
128
+ appendLive(projectRoot, id, true);
129
+ scritti++;
130
+ }
131
+ for (const id of plan.leave) {
132
+ appendLive(projectRoot, id, false);
133
+ scritti++;
134
+ }
135
+ }
136
+ catch {
137
+ // sidecar non scrivibile: si riprova al giro dopo, niente a schermo.
138
+ }
139
+ return scritti;
140
+ }
141
+ /**
142
+ * Le conversazioni che il bottone riapre, nell'ordine di riapertura (rango di
143
+ * entrata crescente, dalla più vecchia — P7 preflight). Vuoto = niente bottone.
144
+ *
145
+ * Vuoto in due casi oltre alla marca vuota: c'è almeno una viva (il bottone
146
+ * vale solo finché non ce n'è nessuna, e appena una è viva la marca si
147
+ * riallinea a lei), oppure un restore è già in corso — senza, un secondo
148
+ * `^T` nei secondi prima che la prima tab si accenda riaprirebbe tutto due
149
+ * volte.
150
+ *
151
+ * Solo le marcate che hanno una `Session` (P2): una marcata senza transcript —
152
+ * cancellato, o di un altro hash di progetto — non si conta e non si segnala.
153
+ * Il suo `live:false` arriva al primo tick con delle vive, come per ogni altra
154
+ * marcata che non lo è.
155
+ */
156
+ export function restoreSet(input) {
157
+ if (input.alive.length > 0 || input.restoring || input.marked.size === 0)
158
+ return [];
159
+ const byId = new Map(input.sessions.map((s) => [s.sessionId, s]));
160
+ return [...input.marked.entries()]
161
+ .sort((a, b) => a[1] - b[1])
162
+ .flatMap(([id]) => {
163
+ const s = byId.get(id);
164
+ return s ? [s] : [];
165
+ });
166
+ }
@@ -89,6 +89,7 @@ export function parseLiveEntry(content, pid) {
89
89
  status: d.status === 'busy' ? 'busy' : 'idle',
90
90
  name: typeof d.name === 'string' ? d.name : '',
91
91
  cwd,
92
+ startedAt: typeof d.startedAt === 'number' ? d.startedAt : 0,
92
93
  },
93
94
  procStart,
94
95
  };
package/dist/mouse.js CHANGED
@@ -207,6 +207,14 @@ export const LAUNCH_ROW = 4;
207
207
  * tastiera con la combo che l'indicatore rappresenta.
208
208
  */
209
209
  export const HINT_ROW = LAUNCH_ROW - 1;
210
+ /**
211
+ * T181 — riga della TESTATA (project status a sinistra, risoluzione e versione
212
+ * a destra), subito sotto il bordo superiore. Entra nell'hit-test col bottone di
213
+ * restore, che le sta a sinistra di risoluzione e versione; come le altre due
214
+ * righe fisse è incondizionata in modalità normale, e il click rientra dalla
215
+ * porta della tastiera con la combo che il bottone rappresenta.
216
+ */
217
+ export const HEADER_ROW = HINT_ROW - 1;
210
218
  /** Colonna (1-based) del primo carattere di testo dentro il box esterno:
211
219
  * bordo + `paddingX={1}`. */
212
220
  export const FRAME_TEXT_COL = 3;
@@ -62,6 +62,16 @@ export function appendSessionMeta(projectRoot, sessionId, fields) {
62
62
  return;
63
63
  appendSessionRecord(projectRoot, rec);
64
64
  }
65
+ /**
66
+ * T181 — entrata (`true`) o uscita (`false`) di una conversazione dall'insieme
67
+ * vivo: stessa forma di `appendPin`. Il nome del campo è un contratto di
68
+ * formato come `priority`: i lettori esterni (compass, `announce-state.sh`) lo
69
+ * ignorano perché leggono i campi per nome, e rinominarlo dopo vorrebbe una
70
+ * migrazione dei file già scritti.
71
+ */
72
+ export function appendLive(projectRoot, sessionId, live) {
73
+ appendSessionRecord(projectRoot, { sessionId, live });
74
+ }
65
75
  // Una sola lettura del JSONL per entrambe le mappe: il deck poll-a l'indice a
66
76
  // ogni tick, leggere il file due volte raddoppierebbe l'I/O per nulla.
67
77
  // Last-wins per campo (un re-pin dello stesso sessionId sovrascrive), e i due
@@ -75,14 +85,16 @@ export function loadSessionIndex(projectRoot) {
75
85
  const priority = new Set();
76
86
  const titles = new Map();
77
87
  const models = new Map();
88
+ const liveMarks = new Map();
78
89
  let content;
79
90
  try {
80
91
  content = readFileSync(taskIndexPath(projectRoot), 'utf8');
81
92
  }
82
93
  catch {
83
- return { bindings, forkOf, pinned, notes, priority, titles, models };
94
+ return { bindings, forkOf, pinned, notes, priority, titles, models, liveMarks };
84
95
  }
85
96
  let order = 0; // posizione crescente dei record pinned → rango di pin (D2)
97
+ let liveOrder = 0; // T181 — idem per i record live:true → rango di entrata
86
98
  for (const line of content.split('\n')) {
87
99
  if (!line.trim())
88
100
  continue;
@@ -157,10 +169,20 @@ export function loadSessionIndex(projectRoot) {
157
169
  else
158
170
  models.delete(d.sessionId);
159
171
  }
172
+ // T181 — stesso last-wins di `pinned`, rango compreso. Un `live:true` su
173
+ // una conversazione già marcata RIASSEGNA il rango: la convergenza non lo
174
+ // scrive mai (appende solo i buchi), quindi succede solo con due deck
175
+ // sullo stesso progetto, e l'effetto è spostarla in coda al restore.
176
+ if (typeof d.live === 'boolean') {
177
+ if (d.live)
178
+ liveMarks.set(d.sessionId, liveOrder++);
179
+ else
180
+ liveMarks.delete(d.sessionId);
181
+ }
160
182
  }
161
183
  catch {
162
184
  // riga corrotta → skip
163
185
  }
164
186
  }
165
- return { bindings, forkOf, pinned, notes, priority, titles, models };
187
+ return { bindings, forkOf, pinned, notes, priority, titles, models, liveMarks };
166
188
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.73.1",
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
- }