@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 +106 -5
- package/dist/cli.js +7 -3
- package/dist/deck-model.js +12 -1
- package/dist/focus-session.js +139 -0
- package/dist/frame.js +60 -10
- package/dist/hooks.js +70 -3
- package/dist/input.js +21 -2
- package/dist/live-marks.js +166 -0
- package/dist/live-sessions.js +1 -0
- package/dist/mouse.js +8 -0
- package/dist/task-index.js +24 -2
- package/package.json +1 -1
- package/dist/overlays/inbox.js +0 -90
- package/dist/ui/inbox-screen.js +0 -21
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
|
|
191
|
-
// il `⏎` della lista sessioni e quello
|
|
192
|
-
//
|
|
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
|
|
196
|
-
// scelta (D1 preflight). I
|
|
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
|
-
|
|
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.
|
|
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();
|
package/dist/deck-model.js
CHANGED
|
@@ -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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
454
|
+
/** Il budget della testata sinistra, per sottrazione dal blocco destro. */
|
|
405
455
|
export function headlineWidth(columns, headerRight) {
|
|
406
|
-
return Math.max(
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|
package/dist/live-sessions.js
CHANGED
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;
|
package/dist/task-index.js
CHANGED
|
@@ -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
package/dist/overlays/inbox.js
DELETED
|
@@ -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
|
-
}
|
package/dist/ui/inbox-screen.js
DELETED
|
@@ -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
|
-
}
|