@lamemind/loom-deck 0.73.1 → 0.74.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 +68 -0
- package/dist/cli.js +4 -2
- package/dist/deck-model.js +12 -1
- package/dist/frame.js +60 -10
- package/dist/hooks.js +70 -3
- package/dist/input.js +17 -1
- 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/actions.js
CHANGED
|
@@ -24,11 +24,20 @@ 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
|
+
/**
|
|
35
|
+
* T181 — l'attesa massima di un passo del restore prima di passare al
|
|
36
|
+
* successivo. Un `deck-run` esce appena `ptyxis --tab` ha consegnato la tab
|
|
37
|
+
* all'istanza già aperta, cioè ben sotto questa soglia: il timeout esiste per il
|
|
38
|
+
* figlio che non esce mai, non per scandire la serie.
|
|
39
|
+
*/
|
|
40
|
+
const RESTORE_STEP_TIMEOUT_MS = 3_000;
|
|
32
41
|
export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overrides, catalog, setNote, }) {
|
|
33
42
|
// La riga di stato di OGNI spawn di sessione Claude: il comando esatto, come
|
|
34
43
|
// lo si scriverebbe in bash, invece di una parafrasi.
|
|
@@ -200,6 +209,64 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
200
209
|
const spawned = spawnDeckResume(bound, cwd, sessionId, modelKind, model.sessionNotes.get(sessionId));
|
|
201
210
|
spawned.child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
|
|
202
211
|
noteSpawn(spawned);
|
|
212
|
+
return spawned;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* T181 — `^T` e il bottone in testata: riapre le conversazioni dell'ultimo
|
|
216
|
+
* insieme vivo, una tab per conversazione, ognuna col proprio binding di
|
|
217
|
+
* task, la propria nota e il modello con cui girava.
|
|
218
|
+
*
|
|
219
|
+
* Passa da `resumeSession`, terzo chiamante dopo la lista e la ricerca, e
|
|
220
|
+
* come la ricerca non ha una superficie di scelta del modello: lo eredita
|
|
221
|
+
* dalla conversazione (`modelAlias(session.model) ?? MODEL_DEFAULT`, T148/D1).
|
|
222
|
+
*
|
|
223
|
+
* IN SERIE, e non tutte insieme (P7 preflight): la tab successiva parte
|
|
224
|
+
* quando il `deck-run` della precedente è uscito, così le tab si aprono
|
|
225
|
+
* nell'ordine dell'insieme e la finestra riaperta ricalca da sinistra a
|
|
226
|
+
* destra quella chiusa. Il timeout per passo tiene la serie in moto anche se
|
|
227
|
+
* un figlio non esce mai — uno spawn inerte dei test, un `ptyxis` che resta
|
|
228
|
+
* appeso: una tab fuori ordine è meglio di un restore che si ferma a metà.
|
|
229
|
+
*
|
|
230
|
+
* La tenuta (`holdLive`) va due volte: su tutte all'avvio, perché il bottone
|
|
231
|
+
* sparisca subito e un secondo `^T` non riapra tutto di nuovo; su ognuna alla
|
|
232
|
+
* partenza della sua tab, perché la scadenza si conti da lì. Nessuna
|
|
233
|
+
* conferma (P6): ricostruisce uno stato che l'utente aveva, ed è reversibile
|
|
234
|
+
* chiudendo le tab.
|
|
235
|
+
*/
|
|
236
|
+
function restoreSessions() {
|
|
237
|
+
const list = model.restorable;
|
|
238
|
+
if (list.length === 0) {
|
|
239
|
+
setNote(model.restoring ? '^T → restore già in corso' : '^T → nessuna conversazione da riaprire');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
model.holdLive(list.map((s) => s.sessionId));
|
|
243
|
+
const step = (i) => {
|
|
244
|
+
if (i >= list.length) {
|
|
245
|
+
// L'ultimo annuncio in-tab arriva asincrono e scriverebbe sopra il
|
|
246
|
+
// riepilogo: avanzare la sequenza lo rende vecchio prima che arrivi.
|
|
247
|
+
spawnSeq.current++;
|
|
248
|
+
setNote(`↻ restore: ${list.length} ${list.length === 1 ? 'conversazione riaperta' : 'conversazioni riaperte'}`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const s = list[i];
|
|
252
|
+
model.holdLive([s.sessionId]);
|
|
253
|
+
const { child } = resumeSession(s.sessionId, modelAlias(s.model) ?? MODEL_DEFAULT);
|
|
254
|
+
let done = false;
|
|
255
|
+
const next = () => {
|
|
256
|
+
if (done)
|
|
257
|
+
return;
|
|
258
|
+
done = true;
|
|
259
|
+
clearTimeout(timer);
|
|
260
|
+
step(i + 1);
|
|
261
|
+
};
|
|
262
|
+
// `unref`: il timer non deve tenere vivo il processo se il deck esce a
|
|
263
|
+
// metà serie — lo schermo sarebbe già restituito al terminale.
|
|
264
|
+
const timer = setTimeout(next, RESTORE_STEP_TIMEOUT_MS);
|
|
265
|
+
timer.unref();
|
|
266
|
+
child.once('exit', next);
|
|
267
|
+
child.once('error', next);
|
|
268
|
+
};
|
|
269
|
+
step(0);
|
|
203
270
|
}
|
|
204
271
|
/** T148/T154(P2,P3) — `m` col bersaglio RESUME: `input.ts` instrada qui solo
|
|
205
272
|
* quando il focus è sulle sessioni E una riga è selezionata (P2), quindi il
|
|
@@ -596,6 +663,7 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
|
|
|
596
663
|
spawnForTask,
|
|
597
664
|
spawnTaskSession,
|
|
598
665
|
resumeSession,
|
|
666
|
+
restoreSessions,
|
|
599
667
|
cycleResumeModel,
|
|
600
668
|
cycleBareModel,
|
|
601
669
|
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';
|
|
@@ -304,6 +304,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
304
304
|
docCounts: model.docCounts,
|
|
305
305
|
docViewId: model.docViewId,
|
|
306
306
|
docHasDirs: model.docHasDirs,
|
|
307
|
+
restoreCount: model.restorable.length,
|
|
307
308
|
});
|
|
308
309
|
useDeckInput({
|
|
309
310
|
cwd,
|
|
@@ -343,7 +344,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
343
344
|
if (frame.budget.compact) {
|
|
344
345
|
return (_jsx(CompactNotice, { dot: false, what: `v${VERSION} · ${model.viewTasks.length} task · sel ${model.selectedTaskId ?? model.parentLabel}`, rows: rows, columns: columns }));
|
|
345
346
|
}
|
|
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.
|
|
347
|
+
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
348
|
focus: model.focus,
|
|
348
349
|
hasTask: model.selTask !== null,
|
|
349
350
|
hasSession: model.selSessionObj !== null,
|
|
@@ -353,6 +354,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
353
354
|
hasInbox: model.selInbox !== null,
|
|
354
355
|
hasDoc: model.selDoc !== null,
|
|
355
356
|
gitlink: gitlink.enabled,
|
|
357
|
+
restore: frame.headerButton !== null,
|
|
356
358
|
}), 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
359
|
}
|
|
358
360
|
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,
|
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
|
|
@@ -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