@lamemind/loom-deck 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -1
- package/dist/cli.js +160 -37
- package/dist/session-list.js +80 -0
- package/dist/task-index.js +32 -18
- package/package.json +1 -1
- package/scripts/deck-run +44 -5
package/README.md
CHANGED
|
@@ -62,6 +62,7 @@ Assegnazioni correnti:
|
|
|
62
62
|
| modale | `E` | edit priorità/stato della task selezionata (salva + commit) |
|
|
63
63
|
| modale | `S` | sort chain |
|
|
64
64
|
| modale | `F` | filtri |
|
|
65
|
+
| immediata | `f` | **forka** la sessione selezionata (solo col focus sul pane Sessions) |
|
|
65
66
|
| immediata | `t` | terminale @project-root (surface standard launch) |
|
|
66
67
|
| immediata | `c` | sessione Claude **nuda**: nessuna task, nessun prompt iniziale |
|
|
67
68
|
| immediata | `w` | salva la vista corrente su disco |
|
|
@@ -77,7 +78,30 @@ sono configurate ma non raggiungibili (e la legenda lo dice).
|
|
|
77
78
|
|
|
78
79
|
`t` e `c` sono gemelle: entrambe aprono una surface del cappello nella stessa
|
|
79
80
|
finestra Ptyxis, senza passare da un modale. `c` (minuscola, azione) e `C`
|
|
80
|
-
(maiuscola, modale create-task) restano distinte per la regola sopra
|
|
81
|
+
(maiuscola, modale create-task) restano distinte per la regola sopra — così come
|
|
82
|
+
`f` (fork) e `F` (filtri).
|
|
83
|
+
|
|
84
|
+
### `f` — forkare una conversazione
|
|
85
|
+
|
|
86
|
+
Il fork rama la sessione selezionata: `claude --resume <origine> --fork-session`
|
|
87
|
+
apre un **sessionId nuovo** con il transcript copiato, lasciando l'origine
|
|
88
|
+
intatta. Serve quando vuoi ripartire da un certo stato senza perdere il ramo
|
|
89
|
+
originale — e siccome i due id sono distinti, non esistono mai due processi che
|
|
90
|
+
scrivono lo stesso file (il vincolo *single-writer* dello store di Claude Code).
|
|
91
|
+
|
|
92
|
+
Il nuovo id lo genera il deck e lo pinna con `--session-id`, per due ragioni:
|
|
93
|
+
|
|
94
|
+
- il ramo **eredita la task** dell'origine (senza id noto in anticipo il fork
|
|
95
|
+
di una sessione scoped comparirebbe come spot);
|
|
96
|
+
- il **lineage** finisce nel sidecar `.claude/loom/session-tasks.jsonl` come
|
|
97
|
+
campo `forkOf`. Serve perché il transcript del fork **non nomina** la sessione
|
|
98
|
+
d'origine da nessuna parte: è una copia verbatim (stessi uuid dei messaggi) e
|
|
99
|
+
`parentUuid` incatena i messaggi dentro un transcript, non le sessioni fra
|
|
100
|
+
loro. Senza quel record un ramo sarebbe una riga gemella dell'originale, di
|
|
101
|
+
cui eredita anche il titolo.
|
|
102
|
+
|
|
103
|
+
Un ramo si riconosce dal marker `⑂` nella lista e dalla riga `⑂ da <id>` nel
|
|
104
|
+
pannello di dettaglio; la sua tab Ptyxis titola `<label> · <task> · fork`.
|
|
81
105
|
|
|
82
106
|
> **Nota di migrazione (0.6.0)**: `c` → **`C`** per creare una task, e le voci
|
|
83
107
|
> `codium`/`idea` non hanno più una lettera dedicata (erano `C`/`I` hardcoded):
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { render, Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
4
|
-
import { useState, useEffect, useMemo } from 'react';
|
|
4
|
+
import { useState, useEffect, useMemo, useRef } from 'react';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { statSync } from 'node:fs';
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
@@ -9,7 +9,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
10
|
import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from './tasks.js';
|
|
11
11
|
import { discoverProjectSessions } from './sessions.js';
|
|
12
|
-
import { appendTaskBinding,
|
|
12
|
+
import { appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
13
|
+
import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, selectedSession, } from './session-list.js';
|
|
13
14
|
import { launchLegend, loadIdentity, loadLaunch } from './config.js';
|
|
14
15
|
import { layoutBudget, normalizeEmoji, windowRange, wrapLines, } from './viewport.js';
|
|
15
16
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
@@ -63,6 +64,27 @@ function spawnDeckResume(taskId, cwd, sessionId) {
|
|
|
63
64
|
child.unref();
|
|
64
65
|
return child;
|
|
65
66
|
}
|
|
67
|
+
// T28 — FORK: `deck-run <task|--no-task> --resume <origine> --fork --session-id
|
|
68
|
+
// <nuovo>`. Variante del resume, non una terza forma: cambia solo che CC apre un
|
|
69
|
+
// id nuovo (`--fork-session`) invece di riprendere a scrivere sull'origine —
|
|
70
|
+
// due writer sullo stesso JSONL non esistono mai, che è l'intero punto del fork.
|
|
71
|
+
// Il nuovo id lo genera il DECK e lo pinna, come in spawnDeck: è l'unico modo di
|
|
72
|
+
// conoscerlo prima che la sessione esista, e senza conoscerlo non si possono
|
|
73
|
+
// scrivere né il binding task né il record di lineage (il transcript del fork
|
|
74
|
+
// non nomina da nessuna parte la sessione d'origine).
|
|
75
|
+
function spawnDeckFork(taskId, cwd, originId, newId) {
|
|
76
|
+
const args = [
|
|
77
|
+
...(taskId ? [taskId] : ['--no-task']),
|
|
78
|
+
'--resume',
|
|
79
|
+
originId,
|
|
80
|
+
'--fork',
|
|
81
|
+
'--session-id',
|
|
82
|
+
newId,
|
|
83
|
+
];
|
|
84
|
+
const child = spawn(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
|
|
85
|
+
child.unref();
|
|
86
|
+
return child;
|
|
87
|
+
}
|
|
66
88
|
// T42 — sessione Claude NUDA: nessuna task, nessun prompt iniziale, nessun
|
|
67
89
|
// sessionId pinnato (quindi nessuna entry nel sidecar session-tasks.jsonl: senza
|
|
68
90
|
// task non c'è nulla da legare). Funzione separata e non un parametro opzionale
|
|
@@ -240,33 +262,48 @@ function useSessions(projectRoot) {
|
|
|
240
262
|
const [state, setState] = useState({
|
|
241
263
|
sessions: [],
|
|
242
264
|
bindings: new Map(),
|
|
265
|
+
forkOf: new Map(),
|
|
266
|
+
pinned: new Map(),
|
|
243
267
|
});
|
|
268
|
+
// T50 — pin/unpin scrive il sidecar e vuole feedback IMMEDIATO, non al
|
|
269
|
+
// prossimo tick del poll (1.5s): la reload è esposta via ref così il toggle la
|
|
270
|
+
// richiama senza risottoscrivere l'intervallo.
|
|
271
|
+
const reloadRef = useRef(() => { });
|
|
244
272
|
useEffect(() => {
|
|
245
273
|
let lastSig = '';
|
|
246
274
|
const reload = () => {
|
|
247
275
|
let sessions;
|
|
248
|
-
let
|
|
276
|
+
let index;
|
|
249
277
|
try {
|
|
250
278
|
sessions = discoverProjectSessions(projectRoot);
|
|
251
|
-
|
|
279
|
+
index = loadSessionIndex(projectRoot);
|
|
252
280
|
}
|
|
253
281
|
catch {
|
|
254
282
|
sessions = [];
|
|
255
|
-
|
|
283
|
+
index = { bindings: new Map(), forkOf: new Map(), pinned: new Map() };
|
|
256
284
|
}
|
|
285
|
+
const { bindings, forkOf, pinned } = index;
|
|
286
|
+
// La signature copre anche fork e pin: un record di lineage o un toggle di
|
|
287
|
+
// pin appena scritto cambia la lista renderizzata, quindi deve forzare il
|
|
288
|
+
// re-render come farebbe un binding nuovo.
|
|
257
289
|
const sig = sessions.map((s) => `${s.sessionId}:${s.ts}`).join('|') +
|
|
258
290
|
'#' +
|
|
259
|
-
[...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',')
|
|
291
|
+
[...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',') +
|
|
292
|
+
'#' +
|
|
293
|
+
[...forkOf.entries()].map(([k, v]) => `${k}<${v}`).sort().join(',') +
|
|
294
|
+
'#' +
|
|
295
|
+
[...pinned.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',');
|
|
260
296
|
if (sig === lastSig)
|
|
261
297
|
return;
|
|
262
298
|
lastSig = sig;
|
|
263
|
-
setState({ sessions, bindings });
|
|
299
|
+
setState({ sessions, bindings, forkOf, pinned });
|
|
264
300
|
};
|
|
301
|
+
reloadRef.current = reload;
|
|
265
302
|
reload();
|
|
266
303
|
const id = setInterval(reload, POLL_MS);
|
|
267
304
|
return () => clearInterval(id);
|
|
268
305
|
}, [projectRoot]);
|
|
269
|
-
return state;
|
|
306
|
+
return { ...state, reload: () => reloadRef.current() };
|
|
270
307
|
}
|
|
271
308
|
// Legge il task file della task selezionata (Q1+B T20). On-id-change: navigare
|
|
272
309
|
// con ↑↓ ricarica il dettaglio; leggere un singolo file 4-9KB è I/O triviale,
|
|
@@ -302,6 +339,13 @@ const forceEmojiWidth = normalizeEmoji;
|
|
|
302
339
|
// larghi 1 e restano intatti.
|
|
303
340
|
const CARET = normalizeEmoji('▶ ');
|
|
304
341
|
const CARET_OFF = ' ';
|
|
342
|
+
// T50 — glifo warning della riga pin-stale: BMP largo 2 senza VS16 → va timbrato
|
|
343
|
+
// (come CARET), altrimenti la riga slitta di una colonna.
|
|
344
|
+
const WARN = normalizeEmoji('⚠');
|
|
345
|
+
// T50 — separatore leggero fra blocco pinnate e contestuali. `─` (box-drawing) è
|
|
346
|
+
// largo 1 sia per string-width sia per il terminale → nessun timbro. Corto +
|
|
347
|
+
// wrap="truncate-end" così non va mai a capo nel pane al 50%.
|
|
348
|
+
const SESSION_SEP = '─'.repeat(16);
|
|
305
349
|
// Normalizza il marker Done per il display. `✔` (U+2714) è text-presentation-
|
|
306
350
|
// default: string-width — quindi Ink, sia per il layout sia per il troncamento —
|
|
307
351
|
// lo misura 2 (rispetta il VS16 di `✔️`), ma VTE/Ptyxis lo disegna largo 1
|
|
@@ -346,14 +390,17 @@ const META_KEYS = ['Priority', 'Size', 'Estimated Time', 'Progress'];
|
|
|
346
390
|
function Deck({ cwd, tasksPath, tasksDir }) {
|
|
347
391
|
const { exit } = useApp();
|
|
348
392
|
const { tasks, loadError } = useTasks(tasksPath);
|
|
349
|
-
const { sessions, bindings } = useSessions(cwd);
|
|
393
|
+
const { sessions, bindings, forkOf, pinned, reload: reloadSessions } = useSessions(cwd);
|
|
350
394
|
const [focus, setFocus] = useState('tasks');
|
|
351
395
|
// T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
|
|
352
396
|
// (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
|
|
353
397
|
// grezzo per posizione spawnerebbe la task sbagliata, in silenzio. `null` = la
|
|
354
398
|
// riga meta "spot", sempre in testa alla lista.
|
|
355
399
|
const [selId, setSelId] = useState(null);
|
|
356
|
-
|
|
400
|
+
// T50 — selezione del pane sessioni KEYED SU sessionId (non indice): la lista
|
|
401
|
+
// a due gruppi + separatore è una vista trasformata, un indice grezzo punterebbe
|
|
402
|
+
// alla riga sbagliata dopo un pin o un cambio di contesto (stesso trap T39).
|
|
403
|
+
const [selSessionId, setSelSessionId] = useState(null);
|
|
357
404
|
const [note, setNote] = useState('');
|
|
358
405
|
// Modali: catturano i tasti e corto-circuitano la navigazione normale.
|
|
359
406
|
// T30 create · T39 sort/filter.
|
|
@@ -396,12 +443,18 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
396
443
|
}
|
|
397
444
|
// Figli della selezione: sessioni bound alla task selezionata, oppure (spot)
|
|
398
445
|
// le sessioni senza binding. sessions è già ts desc → l'ordine si eredita.
|
|
399
|
-
|
|
446
|
+
// Memoizzato così `sessionRows` resta stabile fra render che non cambiano gli
|
|
447
|
+
// input: l'effect di validità della selezione non rigira a vuoto.
|
|
448
|
+
const childSessions = useMemo(() => sessions.filter((s) => {
|
|
400
449
|
const bound = bindings.get(s.sessionId);
|
|
401
450
|
return selectedTaskId ? bound === selectedTaskId : !bound;
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
|
|
451
|
+
}), [sessions, bindings, selectedTaskId]);
|
|
452
|
+
// T50 — lista a due gruppi: pinnate (sempre, in cima) + separatore +
|
|
453
|
+
// contestuali. Dedup, cap solo sulle contestuali, righe stale per le pinnate
|
|
454
|
+
// orfane. Core PURO in session-list.ts (testabile senza Ink).
|
|
455
|
+
const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, MAX_SESSIONS), [childSessions, sessions, pinned]);
|
|
456
|
+
const sessionRows = assembled.rows;
|
|
457
|
+
const selSessionObj = selectedSession(sessionRows, selSessionId);
|
|
405
458
|
// T39 — selezione stabile sotto trasformazione. Se la task selezionata esce
|
|
406
459
|
// dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
|
|
407
460
|
// sulla prima visibile — fallback deterministico, mai una posizione a caso.
|
|
@@ -410,13 +463,15 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
410
463
|
setSelId(viewTasks[0]?.id ?? null);
|
|
411
464
|
}
|
|
412
465
|
}, [viewTasks, selId]);
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
466
|
+
// T50 — la selezione (id) resta valida sotto la vista a due gruppi: se l'id
|
|
467
|
+
// non è più una riga selezionabile (cambio parent, lista mutata, pin rimosso,
|
|
468
|
+
// sessione sparita) cade sulla prima riga — fallback deterministico, mai una
|
|
469
|
+
// posizione a caso. Sostituisce il reset-a-0 e il clamp index-based.
|
|
417
470
|
useEffect(() => {
|
|
418
|
-
|
|
419
|
-
|
|
471
|
+
if (rowIndexOf(sessionRows, selSessionId) < 0) {
|
|
472
|
+
setSelSessionId(firstSelectableId(sessionRows));
|
|
473
|
+
}
|
|
474
|
+
}, [sessionRows, selSessionId]);
|
|
420
475
|
// T30: submit dell'input box. Il taskId nasce DOPO create-task (lo assegna la
|
|
421
476
|
// skill scrivendo tasks.md) → non è noto allo spawn. Il sessionId invece è
|
|
422
477
|
// pinnato qui: snapshot degli id PRIMA, poi al completamento re-leggo tasks.md
|
|
@@ -640,13 +695,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
640
695
|
if (focus === 'tasks')
|
|
641
696
|
moveTaskSel(-1);
|
|
642
697
|
else
|
|
643
|
-
|
|
698
|
+
setSelSessionId((id) => moveSelection(sessionRows, id, -1));
|
|
644
699
|
}
|
|
645
700
|
else if (key.downArrow) {
|
|
646
701
|
if (focus === 'tasks')
|
|
647
702
|
moveTaskSel(1);
|
|
648
703
|
else
|
|
649
|
-
|
|
704
|
+
setSelSessionId((id) => moveSelection(sessionRows, id, 1));
|
|
650
705
|
}
|
|
651
706
|
else if (key.return) {
|
|
652
707
|
if (focus === 'tasks') {
|
|
@@ -669,9 +724,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
669
724
|
else {
|
|
670
725
|
// T49 — ⏎ su una sessione = resume in nuova tab. Il binding si rilegge
|
|
671
726
|
// dal sidecar (non dal padre selezionato): vale anche per le spot.
|
|
672
|
-
const s =
|
|
727
|
+
const s = selSessionObj;
|
|
673
728
|
if (!s) {
|
|
674
|
-
setNote('nessuna sessione da riprendere');
|
|
729
|
+
setNote(selSessionId ? 'pin stale: transcript non più presente' : 'nessuna sessione da riprendere');
|
|
675
730
|
}
|
|
676
731
|
else {
|
|
677
732
|
const bound = bindings.get(s.sessionId) ?? null;
|
|
@@ -702,6 +757,56 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
702
757
|
setNote('');
|
|
703
758
|
setMode('filter');
|
|
704
759
|
}
|
|
760
|
+
else if (input === 'f') {
|
|
761
|
+
// T28 — fork della sessione selezionata. Minuscola come `t`/`c` (T39):
|
|
762
|
+
// azione immediata, nessun modale — la `F` maiuscola resta ai filtri.
|
|
763
|
+
// Vive solo sul pane sessioni: il fork ha per oggetto una conversazione,
|
|
764
|
+
// e senza focus lì non ce n'è una selezionata su cui agire.
|
|
765
|
+
if (focus !== 'sessions') {
|
|
766
|
+
setNote('f → fork: seleziona una sessione (←→ per il pane)');
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
const s = selSessionObj;
|
|
770
|
+
if (!s) {
|
|
771
|
+
setNote(selSessionId ? 'f → pin stale: niente da forkare' : 'f → nessuna sessione da forkare');
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
// L'id del ramo nasce qui, prima dello spawn: pinnandolo posso
|
|
775
|
+
// scrivere subito binding e lineage. Il binding task si eredita
|
|
776
|
+
// dall'origine (un ramo appartiene alla stessa task), il lineage
|
|
777
|
+
// registra la provenienza che il transcript non porta.
|
|
778
|
+
const newId = randomUUID();
|
|
779
|
+
const bound = bindings.get(s.sessionId) ?? null;
|
|
780
|
+
appendSessionRecord(cwd, {
|
|
781
|
+
sessionId: newId,
|
|
782
|
+
...(bound ? { taskId: bound } : {}),
|
|
783
|
+
forkOf: s.sessionId,
|
|
784
|
+
});
|
|
785
|
+
const child = spawnDeckFork(bound, cwd, s.sessionId, newId);
|
|
786
|
+
child.on('error', () => setNote(`⚠ fork fallito (${DECK_RUN})`));
|
|
787
|
+
setNote(`⑂ fork ${s.sessionId.slice(0, 8)} → ${newId.slice(0, 8)}${bound ? ` (${bound})` : ' (spot)'}`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
else if (input === 'p') {
|
|
792
|
+
// T50 — pin/unpin della conversazione selezionata. Minuscola = azione
|
|
793
|
+
// immediata (convenzione T39), gemella di `f`: opera sulla riga
|
|
794
|
+
// selezionata del pane sessioni, quindi vive solo lì. Vale anche su una
|
|
795
|
+
// pinnata STALE (l'unico modo di spinnarla). Scrive il sidecar e ricarica
|
|
796
|
+
// subito, senza attendere il tick del poll.
|
|
797
|
+
if (focus !== 'sessions') {
|
|
798
|
+
setNote('p → pin: seleziona una sessione (←→ per il pane)');
|
|
799
|
+
}
|
|
800
|
+
else if (!selSessionId) {
|
|
801
|
+
setNote('p → nessuna sessione da pinnare');
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
const isPinned = pinned.has(selSessionId);
|
|
805
|
+
appendPin(cwd, selSessionId, !isPinned);
|
|
806
|
+
reloadSessions();
|
|
807
|
+
setNote(`${isPinned ? 'unpin' : '📌 pin'} ${selSessionId.slice(0, 8)}`);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
705
810
|
else if (input === 't') {
|
|
706
811
|
const title = identity ? `🖥️ ${identity.owner} ${identity.name} [term]` : null;
|
|
707
812
|
const child = spawnTerminal(cwd, title);
|
|
@@ -742,11 +847,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
742
847
|
exit();
|
|
743
848
|
}
|
|
744
849
|
});
|
|
745
|
-
const selSessionObj = visibleSessions[selSession] ?? null;
|
|
746
|
-
const selSessionId = selSessionObj?.sessionId;
|
|
747
850
|
const parentLabel = isSpot ? 'spot' : selectedTaskId ?? '—';
|
|
748
851
|
const canSpawn = focus === 'tasks' && !isSpot;
|
|
749
852
|
const canResume = focus === 'sessions' && selSessionObj !== null;
|
|
853
|
+
// T50 — il pin agisce su qualunque riga selezionata (anche stale, per
|
|
854
|
+
// spinnarla); basta il focus sul pane e una selezione.
|
|
855
|
+
const canPin = focus === 'sessions' && selSessionId !== null;
|
|
750
856
|
// Larghezza dal medesimo hook che dà l'altezza: dopo un resize la legenda si
|
|
751
857
|
// ricalcola con lo stesso re-render che ridimensiona i pane.
|
|
752
858
|
const legend = launchLegend(launch, columns);
|
|
@@ -774,20 +880,21 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
774
880
|
sessionHasFirstPreview: canResume && Boolean(selSessionObj?.customTitle),
|
|
775
881
|
sessionHasLastPreview: canResume && Boolean(selSessionObj?.lastReply),
|
|
776
882
|
});
|
|
777
|
-
// Finestre di rendering. Le liste "logiche" (viewTasks,
|
|
883
|
+
// Finestre di rendering. Le liste "logiche" (viewTasks, sessionRows)
|
|
778
884
|
// restano intere: navigazione, selezione e spawn continuano a ragionare su
|
|
779
885
|
// quelle, la finestra è solo ciò che finisce a schermo.
|
|
780
886
|
const taskWin = windowRange(viewTasks.length, selIndex - 1, budget.taskRows);
|
|
781
887
|
const windowTasks = viewTasks.slice(taskWin.start, taskWin.end);
|
|
782
|
-
const
|
|
783
|
-
const
|
|
888
|
+
const selRowIndex = rowIndexOf(sessionRows, selSessionId);
|
|
889
|
+
const sessionWin = windowRange(sessionRows.length, selRowIndex, budget.sessionRows);
|
|
890
|
+
const windowRows = sessionRows.slice(sessionWin.start, sessionWin.end);
|
|
784
891
|
// Sotto la soglia minima il layout a box non entra a nessun costo: si scende
|
|
785
892
|
// a una riga sola. Perdere il deck per un terminale basso è meglio che
|
|
786
893
|
// sporcare la cronologia del terminale a ogni poll.
|
|
787
894
|
if (budget.compact) {
|
|
788
895
|
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? 'spot', " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
|
|
789
896
|
}
|
|
790
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : canResume ? 'resume' : '—', " \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 t term \u00B7 c claude \u00B7 q esci \u00B7 focus:", ' ', _jsx(Text, { color: "cyan", children: focus })] })), mode === 'normal' && launch.length > 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["launch ", legend.shown, legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot,
|
|
897
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : canResume ? 'resume' : '—', canResume ? ' · f fork' : '', canPin ? ' · p pin' : '', " \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 t term \u00B7 c claude \u00B7 q esci \u00B7 focus:", ' ', _jsx(Text, { color: "cyan", children: focus })] })), mode === 'normal' && launch.length > 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["launch ", legend.shown, legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
|
|
791
898
|
}
|
|
792
899
|
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
793
900
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
@@ -832,11 +939,27 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
|
|
|
832
939
|
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", forceEmojiWidth(task.pri), " ", displayProg(task.prog), " ", task.desc, n > 0 ? ` (${n})` : ''] }, task.id));
|
|
833
940
|
})), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
|
|
834
941
|
}
|
|
835
|
-
function SessionsPane({ parentLabel, isSpot,
|
|
836
|
-
return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Sessions \u00B7 ", parentLabel, " (", total, ")", hidden > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 +", hidden, " pi\u00F9 vecchie"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isSpot ? 'nessuna sessione libera' : 'nessuna sessione legata a questa task' })) : (
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
942
|
+
function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, }) {
|
|
943
|
+
return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Sessions \u00B7 ", parentLabel, " (", total, ")", pinnedCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 \uD83D\uDCCC", pinnedCount] }) : null, hidden > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 +", hidden, " pi\u00F9 vecchie"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isSpot ? 'nessuna sessione libera' : 'nessuna sessione legata a questa task' })) : (rows.map((row, i) => {
|
|
944
|
+
// T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
|
|
945
|
+
// box pesante (coerente con lo styling delle Done dimmate).
|
|
946
|
+
if (row.kind === 'separator') {
|
|
947
|
+
return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: SESSION_SEP }, `sep${i}`));
|
|
948
|
+
}
|
|
949
|
+
const sel = row.sessionId === selectedId;
|
|
950
|
+
// T50 — pin stale: transcript sparito, nessuna Session da mostrare.
|
|
951
|
+
// Riga navigabile e spinnabile (`p`), marcata, mai un crash.
|
|
952
|
+
if (row.kind === 'pinned' && row.stale) {
|
|
953
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, 8) })] }, row.sessionId));
|
|
954
|
+
}
|
|
955
|
+
const s = row.session; // non-stale → session presente
|
|
956
|
+
const isPinnedRow = row.kind === 'pinned';
|
|
957
|
+
// T28 — un ramo eredita il titolo dell'origine: senza marcatore le due
|
|
958
|
+
// righe sarebbero identiche a occhio. `⑂` sta PRIMA del titolo, dove
|
|
959
|
+
// la troncatura non arriva mai.
|
|
960
|
+
const forked = forkOf.has(s.sessionId);
|
|
961
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" })) : isSpot ? (_jsx(Text, { dimColor: true, children: "\u25CB" })) : (_jsx(Text, { color: "green", children: "\uD83D\uDD17" })), ' ', forked ? _jsx(Text, { color: "magenta", children: "\u2442 " }) : null, truncate(s.title, forked ? 42 : 44), ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
|
|
962
|
+
})), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null })) : null] }));
|
|
840
963
|
}
|
|
841
964
|
// T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
|
|
842
965
|
// task. Tutti i campi vengono dal parse già cached dell'adapter (mtime-keyed):
|
|
@@ -846,11 +969,11 @@ function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId
|
|
|
846
969
|
// — senza, il titolo È già il primo prompt e la riga lo duplicherebbe (D4
|
|
847
970
|
// preflight). Le righe rese non superano mai il riservato dal budget
|
|
848
971
|
// (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
|
|
849
|
-
function SessionDetailPane({ s, firstLines, lastLines, columns, }) {
|
|
972
|
+
function SessionDetailPane({ s, firstLines, lastLines, columns, origin, }) {
|
|
850
973
|
const width = detailTextWidth(columns);
|
|
851
974
|
const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
|
|
852
975
|
const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
|
|
853
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: s.title }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts)] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
|
|
976
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: s.title }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), origin ? ` · ⑂ da ${origin.slice(0, 8)}` : ''] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
|
|
854
977
|
}
|
|
855
978
|
/**
|
|
856
979
|
* Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// T50 — Assemblaggio della lista sessioni a DUE gruppi: pinnate (sempre in
|
|
2
|
+
// lista, in cima) + separatore + contestuali (figlie del parent selezionato).
|
|
3
|
+
// Modulo PURO: nessun import da ink/react, nessun I/O → testabile senza terminale
|
|
4
|
+
// (il pacchetto non ha infrastruttura TUI di test, solo unit sui core puri).
|
|
5
|
+
//
|
|
6
|
+
// Invarianti (decisioni congelate al preflight):
|
|
7
|
+
// - D2: dentro il blocco pinnate l'ordine è di PIN stabile (ultima pinnata in
|
|
8
|
+
// cima), dato dal rango del sidecar `session-tasks.jsonl` — non `ts desc`.
|
|
9
|
+
// - Dedup: una sessione sia pinnata sia contestuale compare SOLO fra le pinnate.
|
|
10
|
+
// - Cap: `maxContext` limita SOLO le contestuali; le pinnate sono esenti. Il
|
|
11
|
+
// conteggio delle troncate (`contextHidden`) resta esposto (non-silenzioso).
|
|
12
|
+
// - D3: una pinnata il cui transcript non esiste più è una riga STALE (session
|
|
13
|
+
// null), navigabile e spinnabile — non sparisce in silenzio.
|
|
14
|
+
// - Trap T39: la selezione è KEYED SU sessionId, non su indice posizionale. La
|
|
15
|
+
// lista è una vista trasformata (due gruppi + separatore): un indice grezzo
|
|
16
|
+
// identificherebbe la riga sbagliata dopo un pin/cambio-contesto.
|
|
17
|
+
export function assembleSessionList(childSessions, allSessions, pinned, maxContext) {
|
|
18
|
+
// Le pinnate si risolvono contro TUTTE le sessioni del progetto, non solo le
|
|
19
|
+
// figlie del parent: una pinnata può appartenere a un'altra task. Assente
|
|
20
|
+
// dallo store → stale.
|
|
21
|
+
const byId = new Map(allSessions.map((s) => [s.sessionId, s]));
|
|
22
|
+
const pinnedIds = [...pinned.entries()]
|
|
23
|
+
.sort((a, b) => b[1] - a[1]) // rango desc → ultima pinnata in cima (D2)
|
|
24
|
+
.map(([id]) => id);
|
|
25
|
+
const pinnedRows = pinnedIds.map((id) => {
|
|
26
|
+
const session = byId.get(id) ?? null;
|
|
27
|
+
return { kind: 'pinned', sessionId: id, session, stale: session === null };
|
|
28
|
+
});
|
|
29
|
+
const pinnedSet = new Set(pinnedIds);
|
|
30
|
+
const dedupedContext = childSessions.filter((s) => !pinnedSet.has(s.sessionId));
|
|
31
|
+
const shownContext = dedupedContext.slice(0, Math.max(0, maxContext));
|
|
32
|
+
const contextRows = shownContext.map((s) => ({
|
|
33
|
+
kind: 'context',
|
|
34
|
+
sessionId: s.sessionId,
|
|
35
|
+
session: s,
|
|
36
|
+
}));
|
|
37
|
+
const rows = [...pinnedRows];
|
|
38
|
+
// Separatore SOLO fra due gruppi entrambi non vuoti: senza pinnate o senza
|
|
39
|
+
// contestuali non c'è confine da segnare.
|
|
40
|
+
if (pinnedRows.length > 0 && contextRows.length > 0)
|
|
41
|
+
rows.push({ kind: 'separator' });
|
|
42
|
+
rows.push(...contextRows);
|
|
43
|
+
return {
|
|
44
|
+
rows,
|
|
45
|
+
pinnedCount: pinnedRows.length,
|
|
46
|
+
contextTotal: dedupedContext.length,
|
|
47
|
+
contextHidden: dedupedContext.length - shownContext.length,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function isSelectable(r) {
|
|
51
|
+
return r.kind !== 'separator';
|
|
52
|
+
}
|
|
53
|
+
/** Primo id selezionabile (salta il separatore); null = lista senza righe. */
|
|
54
|
+
export function firstSelectableId(rows) {
|
|
55
|
+
return rows.find(isSelectable)?.sessionId ?? null;
|
|
56
|
+
}
|
|
57
|
+
/** Indice della riga con quel sessionId nell'ARRAY COMPLETO (per il windowing);
|
|
58
|
+
* -1 se assente o id null. Il separatore non ha id → mai matchato. */
|
|
59
|
+
export function rowIndexOf(rows, sessionId) {
|
|
60
|
+
if (sessionId === null)
|
|
61
|
+
return -1;
|
|
62
|
+
return rows.findIndex((r) => isSelectable(r) && r.sessionId === sessionId);
|
|
63
|
+
}
|
|
64
|
+
/** Sposta la selezione di `delta` fra le sole righe selezionabili (attraversa il
|
|
65
|
+
* separatore senza fermarcisi). Id perso → prima riga; lista vuota → null. */
|
|
66
|
+
export function moveSelection(rows, currentId, delta) {
|
|
67
|
+
const selectable = rows.filter(isSelectable);
|
|
68
|
+
if (selectable.length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
const cur = selectable.findIndex((r) => r.sessionId === currentId);
|
|
71
|
+
if (cur < 0)
|
|
72
|
+
return selectable[0].sessionId;
|
|
73
|
+
const next = Math.max(0, Math.min(selectable.length - 1, cur + delta));
|
|
74
|
+
return selectable[next].sessionId;
|
|
75
|
+
}
|
|
76
|
+
/** Session della riga selezionata; null se stale o nessuna selezione. */
|
|
77
|
+
export function selectedSession(rows, sessionId) {
|
|
78
|
+
const r = rows.find((row) => isSelectable(row) && row.sessionId === sessionId);
|
|
79
|
+
return r && isSelectable(r) ? r.session : null;
|
|
80
|
+
}
|
package/dist/task-index.js
CHANGED
|
@@ -1,46 +1,60 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
-
// SIDECAR sessionId ↔ taskId (T27).
|
|
4
|
-
//
|
|
5
|
-
// Lo store JSONL di CC NON registra LOOM_TASK/taskId: la classificazione
|
|
6
|
-
// spot vs scoped non può derivare dal transcript. La verità è QUESTO indice,
|
|
7
|
-
// che il deck popola allo spawn — quando pinna `--session-id <uuid>` (D1
|
|
8
|
-
// preflight) il sessionId è già noto, quindi il binding è deterministico.
|
|
9
|
-
//
|
|
10
|
-
// Store (D3 preflight): project-local `<root>/.claude/loom/session-tasks.jsonl`,
|
|
11
|
-
// JSONL append-only. Append (non read-modify-write) = concurrency-safe fra
|
|
12
|
-
// spawn concorrenti; last-wins in lettura copre eventuali re-pin.
|
|
13
3
|
export function taskIndexPath(projectRoot) {
|
|
14
4
|
return join(projectRoot, '.claude', 'loom', 'session-tasks.jsonl');
|
|
15
5
|
}
|
|
16
|
-
export function
|
|
6
|
+
export function appendSessionRecord(projectRoot, rec) {
|
|
17
7
|
const path = taskIndexPath(projectRoot);
|
|
18
8
|
mkdirSync(dirname(path), { recursive: true });
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
appendFileSync(path, JSON.stringify({ ...rec, ts: new Date().toISOString() }) + '\n');
|
|
10
|
+
}
|
|
11
|
+
export function appendTaskBinding(projectRoot, sessionId, taskId) {
|
|
12
|
+
appendSessionRecord(projectRoot, { sessionId, taskId });
|
|
13
|
+
}
|
|
14
|
+
/** T50 — pin/unpin (toggle): append immediato, il reader risolve last-wins. */
|
|
15
|
+
export function appendPin(projectRoot, sessionId, pinned) {
|
|
16
|
+
appendSessionRecord(projectRoot, { sessionId, pinned });
|
|
21
17
|
}
|
|
22
|
-
//
|
|
23
|
-
|
|
18
|
+
// Una sola lettura del JSONL per entrambe le mappe: il deck poll-a l'indice a
|
|
19
|
+
// ogni tick, leggere il file due volte raddoppierebbe l'I/O per nulla.
|
|
20
|
+
// Last-wins per campo (un re-pin dello stesso sessionId sovrascrive), e i due
|
|
21
|
+
// campi sono indipendenti — un record di solo `forkOf` non cancella un binding
|
|
22
|
+
// task scritto prima per lo stesso sessionId.
|
|
23
|
+
export function loadSessionIndex(projectRoot) {
|
|
24
24
|
const bindings = new Map();
|
|
25
|
+
const forkOf = new Map();
|
|
26
|
+
const pinned = new Map();
|
|
25
27
|
let content;
|
|
26
28
|
try {
|
|
27
29
|
content = readFileSync(taskIndexPath(projectRoot), 'utf8');
|
|
28
30
|
}
|
|
29
31
|
catch {
|
|
30
|
-
return bindings;
|
|
32
|
+
return { bindings, forkOf, pinned };
|
|
31
33
|
}
|
|
34
|
+
let order = 0; // posizione crescente dei record pinned → rango di pin (D2)
|
|
32
35
|
for (const line of content.split('\n')) {
|
|
33
36
|
if (!line.trim())
|
|
34
37
|
continue;
|
|
35
38
|
try {
|
|
36
39
|
const d = JSON.parse(line);
|
|
37
|
-
if (typeof d.sessionId
|
|
40
|
+
if (typeof d.sessionId !== 'string')
|
|
41
|
+
continue;
|
|
42
|
+
if (typeof d.taskId === 'string')
|
|
38
43
|
bindings.set(d.sessionId, d.taskId);
|
|
44
|
+
if (typeof d.forkOf === 'string')
|
|
45
|
+
forkOf.set(d.sessionId, d.forkOf);
|
|
46
|
+
// last-wins per campo: un `pinned:false` finale rimuove il pin, un
|
|
47
|
+
// `pinned:true` (ri)assegna il rango con la posizione corrente nel file.
|
|
48
|
+
if (typeof d.pinned === 'boolean') {
|
|
49
|
+
if (d.pinned)
|
|
50
|
+
pinned.set(d.sessionId, order++);
|
|
51
|
+
else
|
|
52
|
+
pinned.delete(d.sessionId);
|
|
39
53
|
}
|
|
40
54
|
}
|
|
41
55
|
catch {
|
|
42
56
|
// riga corrotta → skip
|
|
43
57
|
}
|
|
44
58
|
}
|
|
45
|
-
return bindings;
|
|
59
|
+
return { bindings, forkOf, pinned };
|
|
46
60
|
}
|
package/package.json
CHANGED
package/scripts/deck-run
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
# Con --no-task la sessione è NUDA: niente LOOM_TASK, niente prompt iniziale,
|
|
11
11
|
# niente --session-id. Serve al lavoro spot che non appartiene a nessuna task.
|
|
12
12
|
#
|
|
13
|
+
# Con --resume <sid> riprende una conversazione esistente; aggiungendo --fork la
|
|
14
|
+
# riprende RAMANDOLA (`--fork-session`): id nuovo, transcript copiato, l'origine
|
|
15
|
+
# resta intatta e scrivibile.
|
|
16
|
+
#
|
|
13
17
|
# Modalità spawn (LOOM_DECK_SPAWN_MODE):
|
|
14
18
|
# inline → ptyxis --tab -- <cmd> (default) comando inline, LOOM_TASK diretta, zero dconf
|
|
15
19
|
# profile → ptyxis --tab-with-profile=<UUID> riusa il profilo, riscrive il custom-command via dconf
|
|
@@ -32,6 +36,7 @@ TASK=""
|
|
|
32
36
|
SESSION_ID=""
|
|
33
37
|
RESUME_ID=""
|
|
34
38
|
NO_TASK=0
|
|
39
|
+
FORK=0
|
|
35
40
|
# Positional <TaskID> + flag opzionale --session-id <uuid> (T27): il deck genera
|
|
36
41
|
# l'UUID e lo pinna così il binding sidecar sessionId↔taskId è deterministico.
|
|
37
42
|
# --no-task (T42): modalità NUDA, senza TaskID — nessuna LOOM_TASK, nessun prompt
|
|
@@ -43,6 +48,13 @@ NO_TASK=0
|
|
|
43
48
|
# <sid>` (spot: resume nudo). Su resume niente prompt iniziale (si continua la
|
|
44
49
|
# conversazione, non se ne inietta una nuova) e niente --session-id (l'id ce
|
|
45
50
|
# l'ha già la sessione ripresa).
|
|
51
|
+
# --fork (T28): MODIFICATORE di --resume, speculare al `--fork-session` del CLI
|
|
52
|
+
# (che è definito allo stesso modo: "when resuming, create a new session ID").
|
|
53
|
+
# Non è una terza forma di spawn ma una variante della ripresa → richiede
|
|
54
|
+
# --resume, e senza di esso è un errore d'uso, non un fork "dell'ultima".
|
|
55
|
+
# Sotto --fork il --session-id torna AMMESSO (anzi: è il punto) — la mutua
|
|
56
|
+
# esclusione con --resume esiste perché una ripresa nuda riscrive il transcript
|
|
57
|
+
# dell'id ripreso, mentre il fork ne apre uno NUOVO, che possiamo quindi pinnare.
|
|
46
58
|
while [[ $# -gt 0 ]]; do
|
|
47
59
|
case "$1" in
|
|
48
60
|
--session-id)
|
|
@@ -53,6 +65,8 @@ while [[ $# -gt 0 ]]; do
|
|
|
53
65
|
RESUME_ID="${2:-}"; shift 2 ;;
|
|
54
66
|
--resume=*)
|
|
55
67
|
RESUME_ID="${1#*=}"; shift ;;
|
|
68
|
+
--fork)
|
|
69
|
+
FORK=1; shift ;;
|
|
56
70
|
--no-task)
|
|
57
71
|
NO_TASK=1; shift ;;
|
|
58
72
|
*)
|
|
@@ -64,7 +78,9 @@ done
|
|
|
64
78
|
USAGE="uso: deck-run <TaskID> [--session-id <uuid>] (es. deck-run T18)
|
|
65
79
|
| deck-run --no-task (sessione nuda, senza task)
|
|
66
80
|
| deck-run <TaskID> --resume <uuid> (riprende sessione scoped)
|
|
67
|
-
| deck-run --no-task --resume <uuid> (riprende sessione spot)
|
|
81
|
+
| deck-run --no-task --resume <uuid> (riprende sessione spot)
|
|
82
|
+
| deck-run <TaskID> --resume <uuid> --fork [--session-id <nuovo-uuid>]
|
|
83
|
+
(forka: ramo con id NUOVO)"
|
|
68
84
|
|
|
69
85
|
if [[ $NO_TASK -eq 1 && -n "$TASK" ]]; then
|
|
70
86
|
echo "--no-task e <TaskID> sono mutuamente esclusivi" >&2
|
|
@@ -75,8 +91,14 @@ if [[ $NO_TASK -eq 0 && -z "$TASK" ]]; then
|
|
|
75
91
|
echo "$USAGE" >&2
|
|
76
92
|
exit 2
|
|
77
93
|
fi
|
|
78
|
-
if [[ -
|
|
94
|
+
if [[ $FORK -eq 1 && -z "$RESUME_ID" ]]; then
|
|
95
|
+
echo "--fork richiede --resume <uuid>: si forka una conversazione esistente, non il nulla" >&2
|
|
96
|
+
echo "$USAGE" >&2
|
|
97
|
+
exit 2
|
|
98
|
+
fi
|
|
99
|
+
if [[ -n "$RESUME_ID" && -n "$SESSION_ID" && $FORK -eq 0 ]]; then
|
|
79
100
|
echo "--resume e --session-id sono mutuamente esclusivi (la sessione ripresa ha già il suo id)" >&2
|
|
101
|
+
echo "usa --fork se vuoi un ramo con id nuovo" >&2
|
|
80
102
|
echo "$USAGE" >&2
|
|
81
103
|
exit 2
|
|
82
104
|
fi
|
|
@@ -125,6 +147,11 @@ if [[ -n "$_cfg" ]]; then
|
|
|
125
147
|
if [[ $NO_TASK -eq 1 ]]; then TITLE="${_label}"; else TITLE="${_label} · ${TASK}"; fi
|
|
126
148
|
fi
|
|
127
149
|
fi
|
|
150
|
+
# Suffisso fork (T28): un ramo eredita task e label dell'origine, quindi senza
|
|
151
|
+
# marcatore le due tab risulterebbero omonime nella stessa window. Suffisso e
|
|
152
|
+
# non prefisso perché il match compass è `.includes(label)` e la label deve
|
|
153
|
+
# restare intatta in testa — stesso schema di `· <task>` e `· deck`.
|
|
154
|
+
[[ $FORK -eq 1 ]] && TITLE="${TITLE} · fork"
|
|
128
155
|
|
|
129
156
|
# ── permissionMode (T45) ─────────────────────────────────────────────────────
|
|
130
157
|
# Precedenza: env LOOM_DECK_PERMISSION_MODE > campo del file config > 'manual'.
|
|
@@ -155,6 +182,13 @@ SID_FLAG=""
|
|
|
155
182
|
RES_FLAG=""
|
|
156
183
|
[[ -n "$RESUME_ID" ]] && RES_FLAG="--resume ${RESUME_ID} "
|
|
157
184
|
|
|
185
|
+
# --fork-session per la tab (T28): CC apre un id NUOVO invece di riscrivere
|
|
186
|
+
# quello ripreso → mai due writer sullo stesso JSONL. Terzo gemello del pattern
|
|
187
|
+
# flag-opzionale, per la stessa ragione degli altri due: la variante non deve
|
|
188
|
+
# duplicare IN_TAB_CMD.
|
|
189
|
+
FORK_FLAG=""
|
|
190
|
+
[[ $FORK -eq 1 ]] && FORK_FLAG="--fork-session "
|
|
191
|
+
|
|
158
192
|
# Prompt iniziale della sessione. Default: prompt DIRETTO (no skill) che chiede
|
|
159
193
|
# a Claude un recap dello stato della task — l'utente rivede/steera prima di
|
|
160
194
|
# lanciare (= più controllo), nessuna esecuzione automatica. Override completo via
|
|
@@ -177,13 +211,18 @@ fi
|
|
|
177
211
|
# iniezione LOOM_TASK, prompt iniziale, --session-id pinnato. Resta claude nudo,
|
|
178
212
|
# titolato e con il permission mode del progetto.
|
|
179
213
|
# Su resume (T49) il prompt iniziale salta anche nel ramo task: riprendere una
|
|
180
|
-
# conversazione significa continuarla, non iniettarle un nuovo messaggio.
|
|
214
|
+
# conversazione significa continuarla, non iniettarle un nuovo messaggio. Il
|
|
215
|
+
# fork (T28) ricade nello stesso ramo — è una ripresa, con id nuovo.
|
|
181
216
|
PROMPT_ARG="'${PROMPT}'"
|
|
182
217
|
[[ -n "$RESUME_ID" ]] && PROMPT_ARG=""
|
|
218
|
+
# Il SID_FLAG entra anche nel ramo --no-task, che altrimenti lo scarta insieme
|
|
219
|
+
# agli altri elementi task-bound: nel fork di una sessione SPOT il sessionId
|
|
220
|
+
# pinnato non serve a legare una task (non c'è) ma a rendere noto in anticipo
|
|
221
|
+
# l'id del ramo, senza il quale il lineage non sarebbe registrabile.
|
|
183
222
|
if [[ $NO_TASK -eq 1 ]]; then
|
|
184
|
-
_default_intab="claude --name '${TITLE}' ${MODE_FLAG}${RES_FLAG}"
|
|
223
|
+
_default_intab="claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}"
|
|
185
224
|
else
|
|
186
|
-
_default_intab="LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${PROMPT_ARG}"
|
|
225
|
+
_default_intab="LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}${PROMPT_ARG}"
|
|
187
226
|
fi
|
|
188
227
|
IN_TAB_CMD="${LOOM_DECK_INTAB_CMD:-$_default_intab}"
|
|
189
228
|
|