@lamemind/loom-deck 0.10.0 → 0.12.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 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
@@ -9,7 +9,7 @@ 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, loadTaskBindings } from './task-index.js';
12
+ import { appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
13
13
  import { launchLegend, loadIdentity, loadLaunch } from './config.js';
14
14
  import { layoutBudget, normalizeEmoji, windowRange, wrapLines, } from './viewport.js';
15
15
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
@@ -63,6 +63,27 @@ function spawnDeckResume(taskId, cwd, sessionId) {
63
63
  child.unref();
64
64
  return child;
65
65
  }
66
+ // T28 — FORK: `deck-run <task|--no-task> --resume <origine> --fork --session-id
67
+ // <nuovo>`. Variante del resume, non una terza forma: cambia solo che CC apre un
68
+ // id nuovo (`--fork-session`) invece di riprendere a scrivere sull'origine —
69
+ // due writer sullo stesso JSONL non esistono mai, che è l'intero punto del fork.
70
+ // Il nuovo id lo genera il DECK e lo pinna, come in spawnDeck: è l'unico modo di
71
+ // conoscerlo prima che la sessione esista, e senza conoscerlo non si possono
72
+ // scrivere né il binding task né il record di lineage (il transcript del fork
73
+ // non nomina da nessuna parte la sessione d'origine).
74
+ function spawnDeckFork(taskId, cwd, originId, newId) {
75
+ const args = [
76
+ ...(taskId ? [taskId] : ['--no-task']),
77
+ '--resume',
78
+ originId,
79
+ '--fork',
80
+ '--session-id',
81
+ newId,
82
+ ];
83
+ const child = spawn(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
84
+ child.unref();
85
+ return child;
86
+ }
66
87
  // T42 — sessione Claude NUDA: nessuna task, nessun prompt iniziale, nessun
67
88
  // sessionId pinnato (quindi nessuna entry nel sidecar session-tasks.jsonl: senza
68
89
  // task non c'è nulla da legare). Funzione separata e non un parametro opzionale
@@ -240,27 +261,34 @@ function useSessions(projectRoot) {
240
261
  const [state, setState] = useState({
241
262
  sessions: [],
242
263
  bindings: new Map(),
264
+ forkOf: new Map(),
243
265
  });
244
266
  useEffect(() => {
245
267
  let lastSig = '';
246
268
  const reload = () => {
247
269
  let sessions;
248
- let bindings;
270
+ let index;
249
271
  try {
250
272
  sessions = discoverProjectSessions(projectRoot);
251
- bindings = loadTaskBindings(projectRoot);
273
+ index = loadSessionIndex(projectRoot);
252
274
  }
253
275
  catch {
254
276
  sessions = [];
255
- bindings = new Map();
277
+ index = { bindings: new Map(), forkOf: new Map() };
256
278
  }
279
+ const { bindings, forkOf } = index;
280
+ // La signature copre anche i fork: un record di lineage appena scritto
281
+ // cambia il marker della riga, quindi deve forzare il re-render come
282
+ // farebbe un binding nuovo.
257
283
  const sig = sessions.map((s) => `${s.sessionId}:${s.ts}`).join('|') +
258
284
  '#' +
259
- [...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',');
285
+ [...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',') +
286
+ '#' +
287
+ [...forkOf.entries()].map(([k, v]) => `${k}<${v}`).sort().join(',');
260
288
  if (sig === lastSig)
261
289
  return;
262
290
  lastSig = sig;
263
- setState({ sessions, bindings });
291
+ setState({ sessions, bindings, forkOf });
264
292
  };
265
293
  reload();
266
294
  const id = setInterval(reload, POLL_MS);
@@ -346,7 +374,7 @@ const META_KEYS = ['Priority', 'Size', 'Estimated Time', 'Progress'];
346
374
  function Deck({ cwd, tasksPath, tasksDir }) {
347
375
  const { exit } = useApp();
348
376
  const { tasks, loadError } = useTasks(tasksPath);
349
- const { sessions, bindings } = useSessions(cwd);
377
+ const { sessions, bindings, forkOf } = useSessions(cwd);
350
378
  const [focus, setFocus] = useState('tasks');
351
379
  // T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
352
380
  // (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
@@ -702,6 +730,37 @@ function Deck({ cwd, tasksPath, tasksDir }) {
702
730
  setNote('');
703
731
  setMode('filter');
704
732
  }
733
+ else if (input === 'f') {
734
+ // T28 — fork della sessione selezionata. Minuscola come `t`/`c` (T39):
735
+ // azione immediata, nessun modale — la `F` maiuscola resta ai filtri.
736
+ // Vive solo sul pane sessioni: il fork ha per oggetto una conversazione,
737
+ // e senza focus lì non ce n'è una selezionata su cui agire.
738
+ if (focus !== 'sessions') {
739
+ setNote('f → fork: seleziona una sessione (←→ per il pane)');
740
+ }
741
+ else {
742
+ const s = visibleSessions[selSession];
743
+ if (!s) {
744
+ setNote('f → nessuna sessione da forkare');
745
+ }
746
+ else {
747
+ // L'id del ramo nasce qui, prima dello spawn: pinnandolo posso
748
+ // scrivere subito binding e lineage. Il binding task si eredita
749
+ // dall'origine (un ramo appartiene alla stessa task), il lineage
750
+ // registra la provenienza che il transcript non porta.
751
+ const newId = randomUUID();
752
+ const bound = bindings.get(s.sessionId) ?? null;
753
+ appendSessionRecord(cwd, {
754
+ sessionId: newId,
755
+ ...(bound ? { taskId: bound } : {}),
756
+ forkOf: s.sessionId,
757
+ });
758
+ const child = spawnDeckFork(bound, cwd, s.sessionId, newId);
759
+ child.on('error', () => setNote(`⚠ fork fallito (${DECK_RUN})`));
760
+ setNote(`⑂ fork ${s.sessionId.slice(0, 8)} → ${newId.slice(0, 8)}${bound ? ` (${bound})` : ' (spot)'}`);
761
+ }
762
+ }
763
+ }
705
764
  else if (input === 't') {
706
765
  const title = identity ? `🖥️ ${identity.owner} ${identity.name} [term]` : null;
707
766
  const child = spawnTerminal(cwd, title);
@@ -768,6 +827,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
768
827
  // T49 — il detail pane sessione esiste solo con il focus sul pane: è
769
828
  // l'hover, non uno stato persistente; navigando le task non ruba righe.
770
829
  hasSessionDetail: canResume,
830
+ // Riservo righe di preview solo per i blocchi che davvero renderizzano: il
831
+ // primo prompt aggiunge info solo con un titolo custom (senza, titolo ===
832
+ // primo prompt); l'ultima risposta solo se il modello ha già risposto.
833
+ sessionHasFirstPreview: canResume && Boolean(selSessionObj?.customTitle),
834
+ sessionHasLastPreview: canResume && Boolean(selSessionObj?.lastReply),
771
835
  });
772
836
  // Finestre di rendering. Le liste "logiche" (viewTasks, visibleSessions)
773
837
  // restano intere: navigazione, selezione e spawn continuano a ragionare su
@@ -782,7 +846,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
782
846
  if (budget.compact) {
783
847
  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"] })] }));
784
848
  }
785
- 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, sessions: windowSessions, total: childSessions.length, hidden: hiddenSessions, selectedId: selSessionId, focused: focus === 'sessions', above: sessionWin.start, below: visibleSessions.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, previewLines: budget.sessionPreviewLines, columns: columns })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
849
+ 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' : '', " \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, sessions: windowSessions, total: childSessions.length, hidden: hiddenSessions, selectedId: selSessionId, focused: focus === 'sessions', above: sessionWin.start, below: visibleSessions.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] }));
786
850
  }
787
851
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
788
852
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -827,23 +891,29 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
827
891
  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));
828
892
  })), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
829
893
  }
830
- function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId, focused, above, below, detail, previewLines, columns, }) {
894
+ function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, }) {
831
895
  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' })) : (sessions.map((s) => {
832
896
  const sel = s.sessionId === selectedId;
833
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isSpot ? _jsx(Text, { dimColor: true, children: "\u25CB" }) : _jsx(Text, { color: "green", children: "\uD83D\uDD17" }), ' ', truncate(s.title, 44), ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
834
- })), detail ? (_jsx(SessionDetailPane, { s: detail, previewLines: previewLines, columns: columns })) : null] }));
897
+ // T28 un ramo eredita il titolo dell'origine: senza marcatore le due
898
+ // righe sarebbero identiche a occhio. `⑂` sta PRIMA del titolo, dove
899
+ // la troncatura non arriva mai.
900
+ const forked = forkOf.has(s.sessionId);
901
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, 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));
902
+ })), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null })) : null] }));
835
903
  }
836
904
  // T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
837
905
  // task. Tutti i campi vengono dal parse già cached dell'adapter (mtime-keyed):
838
- // il pannello non costa I/O al movimento di selezione. La preview del primo
839
- // prompt compare SOLO con un titolo custom senza, il titolo È già il primo
840
- // prompt e la riga lo duplicherebbe (D4 preflight). Renderizzare meno righe del
841
- // riservato è sicuro: il frame esce più corto, mai più alto del budget.
842
- function SessionDetailPane({ s, previewLines, columns, }) {
843
- const lines = s.customTitle
844
- ? wrapLines(s.firstPrompt, detailTextWidth(columns), previewLines)
845
- : [];
846
- 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)] }), lines.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, i)))] }));
906
+ // il pannello non costa I/O al movimento di selezione. Mostra "da dove parte,
907
+ // dove è arrivata": il primo prompt utente (`» `) e l'ultima risposta del
908
+ // modello (`« `). La preview del primo prompt compare SOLO con un titolo custom
909
+ // senza, il titolo È già il primo prompt e la riga lo duplicherebbe (D4
910
+ // preflight). Le righe rese non superano mai il riservato dal budget
911
+ // (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
912
+ function SessionDetailPane({ s, firstLines, lastLines, columns, origin, }) {
913
+ const width = detailTextWidth(columns);
914
+ const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
915
+ const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
916
+ 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}`)))] }));
847
917
  }
848
918
  /**
849
919
  * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
package/dist/sessions.js CHANGED
@@ -20,7 +20,12 @@ function cleanPreview(s) {
20
20
  .replace(/\s+/g, ' ')
21
21
  .trim();
22
22
  }
23
- function extractUserText(message) {
23
+ // Estrae il primo blocco di testo dal `message` di un record. Funziona sia sui
24
+ // record `type:user` sia sugli `type:assistant`: entrambi portano `content` come
25
+ // stringa o array di blocchi, e un blocco di testo è `{type:'text', text}`. Il
26
+ // nome è generico apposta — l'ultima risposta del modello (T49 first+last) esce
27
+ // dallo stesso estrattore del primo prompt utente.
28
+ function extractText(message) {
24
29
  if (!message || typeof message !== 'object')
25
30
  return '';
26
31
  const content = message.content;
@@ -58,6 +63,7 @@ function parseSessionFile(path, mtime, sizeBytes) {
58
63
  let parentUuid = null;
59
64
  let customTitle = '';
60
65
  let firstUserText = '';
66
+ let lastAssistantText = '';
61
67
  let turns = 0;
62
68
  for (const line of content.split('\n')) {
63
69
  if (!line.trim())
@@ -82,14 +88,22 @@ function parseSessionFile(path, mtime, sizeBytes) {
82
88
  customTitle = d.customTitle; // last-wins
83
89
  if (d.type === 'user') {
84
90
  // T49: turno = prompt umano. I tool_result viaggiano anch'essi come
85
- // type:user ma senza blocchi text → extractUserText '' li esclude.
86
- const t = extractUserText(d.message);
91
+ // type:user ma senza blocchi text → extractText '' li esclude.
92
+ const t = extractText(d.message);
87
93
  if (t) {
88
94
  turns++;
89
95
  if (!firstUserText)
90
96
  firstUserText = t;
91
97
  }
92
98
  }
99
+ else if (d.type === 'assistant') {
100
+ // Ultima risposta del modello: last-wins (come customTitle), niente
101
+ // early-stop — l'ultima riga assistant con testo è quella buona. I
102
+ // record assistant di solo tool_use danno '' e non sovrascrivono.
103
+ const t = extractText(d.message);
104
+ if (t)
105
+ lastAssistantText = t;
106
+ }
93
107
  }
94
108
  if (!cwd)
95
109
  return null; // nessuna riga con cwd → non è una sessione di progetto valida
@@ -105,6 +119,7 @@ function parseSessionFile(path, mtime, sizeBytes) {
105
119
  turns,
106
120
  customTitle,
107
121
  firstPrompt: firstUserText,
122
+ lastReply: lastAssistantText,
108
123
  };
109
124
  }
110
125
  // Discovery read-only delle sessioni del SOLO progetto corrente (D2 preflight
@@ -1,46 +1,46 @@
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 appendTaskBinding(projectRoot, sessionId, taskId) {
6
+ export function appendSessionRecord(projectRoot, rec) {
17
7
  const path = taskIndexPath(projectRoot);
18
8
  mkdirSync(dirname(path), { recursive: true });
19
- const record = { sessionId, taskId, ts: new Date().toISOString() };
20
- appendFileSync(path, JSON.stringify(record) + '\n');
9
+ appendFileSync(path, JSON.stringify({ ...rec, ts: new Date().toISOString() }) + '\n');
10
+ }
11
+ export function appendTaskBinding(projectRoot, sessionId, taskId) {
12
+ appendSessionRecord(projectRoot, { sessionId, taskId });
21
13
  }
22
- // sessionId taskId, last-wins (un re-pin dello stesso sessionId sovrascrive).
23
- export function loadTaskBindings(projectRoot) {
14
+ // Una sola lettura del JSONL per entrambe le mappe: il deck poll-a l'indice a
15
+ // ogni tick, leggere il file due volte raddoppierebbe l'I/O per nulla.
16
+ // Last-wins per campo (un re-pin dello stesso sessionId sovrascrive), e i due
17
+ // campi sono indipendenti — un record di solo `forkOf` non cancella un binding
18
+ // task scritto prima per lo stesso sessionId.
19
+ export function loadSessionIndex(projectRoot) {
24
20
  const bindings = new Map();
21
+ const forkOf = new Map();
25
22
  let content;
26
23
  try {
27
24
  content = readFileSync(taskIndexPath(projectRoot), 'utf8');
28
25
  }
29
26
  catch {
30
- return bindings;
27
+ return { bindings, forkOf };
31
28
  }
32
29
  for (const line of content.split('\n')) {
33
30
  if (!line.trim())
34
31
  continue;
35
32
  try {
36
33
  const d = JSON.parse(line);
37
- if (typeof d.sessionId === 'string' && typeof d.taskId === 'string') {
34
+ if (typeof d.sessionId !== 'string')
35
+ continue;
36
+ if (typeof d.taskId === 'string')
38
37
  bindings.set(d.sessionId, d.taskId);
39
- }
38
+ if (typeof d.forkOf === 'string')
39
+ forkOf.set(d.sessionId, d.forkOf);
40
40
  }
41
41
  catch {
42
42
  // riga corrotta → skip
43
43
  }
44
44
  }
45
- return bindings;
45
+ return { bindings, forkOf };
46
46
  }
package/dist/viewport.js CHANGED
@@ -63,7 +63,8 @@ const TASKS_PANE_CHROME = 5; // 2 bordi + header "Tasks (n)" + riga sort + riga
63
63
  const SESSIONS_PANE_CHROME = 3; // 2 bordi + header "Sessions · …"
64
64
  const DETAIL_CHROME = 3; // marginTop + 2 bordi
65
65
  /** Detail pane sessione (T49): righe fisse = titolo + riga meta (size · turni ·
66
- * ultima attività). La preview del primo prompt è la parte variabile. */
66
+ * ultima attività). Le preview (primo prompt + ultima risposta) sono variabili,
67
+ * ciascuna al più MAX_SESSION_PREVIEW righe. */
67
68
  const SESSION_DETAIL_FIXED = 2;
68
69
  const MAX_SESSION_PREVIEW = 2;
69
70
  /** Gemello di MIN_TASK_ROWS: sotto questa soglia la lista sessioni non serve
@@ -108,24 +109,34 @@ export function layoutBudget(input) {
108
109
  sessionRows: 0,
109
110
  detailLines: 0,
110
111
  sessionDetail: false,
111
- sessionPreviewLines: 0,
112
+ sessionFirstLines: 0,
113
+ sessionLastLines: 0,
112
114
  compact: true,
113
115
  };
114
116
  }
115
117
  // Detail pane sessione (T49): stesso schema del dettaglio task — prima la
116
- // lista minima, poi la cornice, la preview solo con lo spazio che avanza.
118
+ // lista minima, poi la cornice, le preview solo con lo spazio che avanza.
117
119
  // A differenza del dettaglio task il box regge anche senza righe variabili:
118
- // titolo + meta sono il valore, la preview è bonus.
120
+ // titolo + meta sono il valore, le preview (primo prompt + ultima risposta)
121
+ // sono bonus. Priorità al primo prompt, poi l'ultima risposta prende ciò che
122
+ // resta: su terminale stretto cade prima l'ultima risposta, non il primo.
123
+ // Riservo righe solo per le preview che davvero renderizzeranno (i due
124
+ // `has…Preview`), così non sottraggo righe alla lista per un blocco vuoto.
119
125
  let sessionDetail = false;
120
126
  let sessionDetailCost = 0;
121
- let sessionPreviewLines = 0;
127
+ let sessionFirstLines = 0;
128
+ let sessionLastLines = 0;
122
129
  if (input.hasSessionDetail) {
123
130
  const fixed = DETAIL_CHROME + SESSION_DETAIL_FIXED;
124
131
  const spare = avail - SESSIONS_PANE_CHROME - MIN_SESSION_ROWS - fixed;
125
132
  if (spare >= 0) {
126
133
  sessionDetail = true;
127
- sessionPreviewLines = Math.min(MAX_SESSION_PREVIEW, spare);
128
- sessionDetailCost = fixed + sessionPreviewLines;
134
+ if (input.sessionHasFirstPreview)
135
+ sessionFirstLines = Math.min(MAX_SESSION_PREVIEW, spare);
136
+ if (input.sessionHasLastPreview) {
137
+ sessionLastLines = Math.min(MAX_SESSION_PREVIEW, spare - sessionFirstLines);
138
+ }
139
+ sessionDetailCost = fixed + sessionFirstLines + sessionLastLines;
129
140
  }
130
141
  }
131
142
  const sessionRows = Math.max(0, avail - SESSIONS_PANE_CHROME - sessionDetailCost);
@@ -143,7 +154,15 @@ export function layoutBudget(input) {
143
154
  }
144
155
  }
145
156
  const taskRows = Math.max(0, avail - TASKS_PANE_CHROME - detailChrome - detailLines);
146
- return { taskRows, sessionRows, detailLines, sessionDetail, sessionPreviewLines, compact: false };
157
+ return {
158
+ taskRows,
159
+ sessionRows,
160
+ detailLines,
161
+ sessionDetail,
162
+ sessionFirstLines,
163
+ sessionLastLines,
164
+ compact: false,
165
+ };
147
166
  }
148
167
  /**
149
168
  * Finestra scorrevole su una lista più lunga della capienza.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
5
5
  "type": "module",
6
6
  "bin": {
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 [[ -n "$RESUME_ID" && -n "$SESSION_ID" ]]; then
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