@lamemind/loom-deck 0.9.2 → 0.11.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
@@ -48,7 +48,7 @@ senza collisioni:
48
48
  |---|---|---|
49
49
  | `↑` `↓` | naviga nella lista | |
50
50
  | `←` `→` `tab` | cambia pane | |
51
- | `⏎` | azione primaria del pane | Tasks → spawna la task selezionata |
51
+ | `⏎` | azione primaria del pane | Tasks → spawna la task selezionata · Sessions → riprende (`claude --resume`) la sessione selezionata |
52
52
  | **MAIUSCOLA** | **apre un modale** | cattura tutti i tasti; `esc` annulla, non esce |
53
53
  | minuscola | azione immediata, one-shot | |
54
54
  | `1`…`9` | voce `launch` n-esima del progetto | da `.claude/loom-works.json` |
package/dist/cli.js CHANGED
@@ -51,6 +51,18 @@ function spawnDeck(id, cwd, sessionId) {
51
51
  child.unref();
52
52
  return child;
53
53
  }
54
+ // T49 — resume di una sessione esistente come nuova tab Ptyxis. Scoped (taskId
55
+ // presente) → `deck-run <task> --resume <sid>`: la ripresa eredita LOOM_TASK +
56
+ // titolo `· <task>` (D2 preflight, l'hook SessionStart ricarica il contesto
57
+ // task). Spot → `--no-task --resume`: resume nudo, solo label progetto. Nessun
58
+ // prompt iniziale in entrambi i casi: riprendere una conversazione significa
59
+ // continuarla, non iniettarle un messaggio (lo salta deck-run).
60
+ function spawnDeckResume(taskId, cwd, sessionId) {
61
+ const args = taskId ? [taskId, '--resume', sessionId] : ['--no-task', '--resume', sessionId];
62
+ const child = spawn(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
63
+ child.unref();
64
+ return child;
65
+ }
54
66
  // T42 — sessione Claude NUDA: nessuna task, nessun prompt iniziale, nessun
55
67
  // sessionId pinnato (quindi nessuna entry nel sidecar session-tasks.jsonl: senza
56
68
  // task non c'è nulla da legare). Funzione separata e non un parametro opzionale
@@ -302,6 +314,21 @@ const CARET_OFF = ' ';
302
314
  function displayProg(prog) {
303
315
  return forceEmojiWidth(prog.replace(/✔️?/g, '✅'));
304
316
  }
317
+ // T49 — size umana compatta per il detail pane sessione.
318
+ function fmtSize(bytes) {
319
+ if (bytes < 1024)
320
+ return `${bytes} B`;
321
+ if (bytes < 1024 * 1024)
322
+ return `${(bytes / 1024).toFixed(1)} KB`;
323
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
324
+ }
325
+ // T49 — ultima attività ESTESA (giorno/mese ora:minuti) per il detail pane;
326
+ // nella riga di lista resta il relTime compatto.
327
+ function fmtDateTime(ts) {
328
+ const d = new Date(ts);
329
+ const p = (n) => String(n).padStart(2, '0');
330
+ return `${p(d.getDate())}/${p(d.getMonth() + 1)} ${p(d.getHours())}:${p(d.getMinutes())}`;
331
+ }
305
332
  // Età relativa compatta (ms epoch → "2m"/"3h"/"5d") per il preview sessioni.
306
333
  function relTime(ts) {
307
334
  const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
@@ -640,7 +667,18 @@ function Deck({ cwd, tasksPath, tasksDir }) {
640
667
  }
641
668
  }
642
669
  else {
643
- setNote('sessioni read-only in T27 · fork/resume T28');
670
+ // T49 su una sessione = resume in nuova tab. Il binding si rilegge
671
+ // dal sidecar (non dal padre selezionato): vale anche per le spot.
672
+ const s = visibleSessions[selSession];
673
+ if (!s) {
674
+ setNote('nessuna sessione da riprendere');
675
+ }
676
+ else {
677
+ const bound = bindings.get(s.sessionId) ?? null;
678
+ const child = spawnDeckResume(bound, cwd, s.sessionId);
679
+ child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
680
+ setNote(`⏎ resume ${s.sessionId.slice(0, 8)} → tab CC${bound ? ` (${bound})` : ' (spot)'}`);
681
+ }
644
682
  }
645
683
  }
646
684
  else if (input === 'C') {
@@ -704,9 +742,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
704
742
  exit();
705
743
  }
706
744
  });
707
- const selSessionId = visibleSessions[selSession]?.sessionId;
745
+ const selSessionObj = visibleSessions[selSession] ?? null;
746
+ const selSessionId = selSessionObj?.sessionId;
708
747
  const parentLabel = isSpot ? 'spot' : selectedTaskId ?? '—';
709
748
  const canSpawn = focus === 'tasks' && !isSpot;
749
+ const canResume = focus === 'sessions' && selSessionObj !== null;
710
750
  // Larghezza dal medesimo hook che dà l'altezza: dopo un resize la legenda si
711
751
  // ricalcola con lo stesso re-render che ridimensiona i pane.
712
752
  const legend = launchLegend(launch, columns);
@@ -725,6 +765,14 @@ function Deck({ cwd, tasksPath, tasksDir }) {
725
765
  noteLine: Boolean(note),
726
766
  hasDetail: Boolean(detail),
727
767
  detailMetaLines: detailParts?.metaLines ?? 0,
768
+ // T49 — il detail pane sessione esiste solo con il focus sul pane: è
769
+ // l'hover, non uno stato persistente; navigando le task non ruba righe.
770
+ hasSessionDetail: canResume,
771
+ // Riservo righe di preview solo per i blocchi che davvero renderizzano: il
772
+ // primo prompt aggiunge info solo con un titolo custom (senza, titolo ===
773
+ // primo prompt); l'ultima risposta solo se il modello ha già risposto.
774
+ sessionHasFirstPreview: canResume && Boolean(selSessionObj?.customTitle),
775
+ sessionHasLastPreview: canResume && Boolean(selSessionObj?.lastReply),
728
776
  });
729
777
  // Finestre di rendering. Le liste "logiche" (viewTasks, visibleSessions)
730
778
  // restano intere: navigazione, selezione e spawn continuano a ragionare su
@@ -739,7 +787,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
739
787
  if (budget.compact) {
740
788
  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"] })] }));
741
789
  }
742
- 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' : '—', " \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 })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
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, 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 })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
743
791
  }
744
792
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
745
793
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -784,11 +832,25 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
784
832
  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));
785
833
  })), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
786
834
  }
787
- function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId, focused, above, below, }) {
835
+ function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, }) {
788
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' })) : (sessions.map((s) => {
789
837
  const sel = s.sessionId === selectedId;
790
838
  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));
791
- }))] }));
839
+ })), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns })) : null] }));
840
+ }
841
+ // T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
842
+ // task. Tutti i campi vengono dal parse già cached dell'adapter (mtime-keyed):
843
+ // il pannello non costa I/O al movimento di selezione. Mostra "da dove parte,
844
+ // dove è arrivata": il primo prompt utente (`» `) e l'ultima risposta del
845
+ // modello (`« `). La preview del primo prompt compare SOLO con un titolo custom
846
+ // — senza, il titolo È già il primo prompt e la riga lo duplicherebbe (D4
847
+ // preflight). Le righe rese non superano mai il riservato dal budget
848
+ // (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
849
+ function SessionDetailPane({ s, firstLines, lastLines, columns, }) {
850
+ const width = detailTextWidth(columns);
851
+ const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
852
+ 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}`)))] }));
792
854
  }
793
855
  /**
794
856
  * 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;
@@ -44,7 +49,7 @@ function extractUserText(message) {
44
49
  // poll-a resta economico anche con decine di sessioni multi-MB. Lo stato vive
45
50
  // dentro l'adapter così l'invariante "unico modulo che tocca lo store" regge.
46
51
  const cache = new Map();
47
- function parseSessionFile(path, mtime) {
52
+ function parseSessionFile(path, mtime, sizeBytes) {
48
53
  let content;
49
54
  try {
50
55
  content = readFileSync(path, 'utf8');
@@ -58,6 +63,8 @@ function parseSessionFile(path, mtime) {
58
63
  let parentUuid = null;
59
64
  let customTitle = '';
60
65
  let firstUserText = '';
66
+ let lastAssistantText = '';
67
+ let turns = 0;
61
68
  for (const line of content.split('\n')) {
62
69
  if (!line.trim())
63
70
  continue;
@@ -79,10 +86,23 @@ function parseSessionFile(path, mtime) {
79
86
  }
80
87
  if (typeof d.customTitle === 'string' && d.customTitle)
81
88
  customTitle = d.customTitle; // last-wins
82
- if (!firstUserText && d.type === 'user') {
83
- const t = extractUserText(d.message);
89
+ if (d.type === 'user') {
90
+ // T49: turno = prompt umano. I tool_result viaggiano anch'essi come
91
+ // type:user ma senza blocchi text → extractText '' li esclude.
92
+ const t = extractText(d.message);
93
+ if (t) {
94
+ turns++;
95
+ if (!firstUserText)
96
+ firstUserText = t;
97
+ }
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);
84
104
  if (t)
85
- firstUserText = t;
105
+ lastAssistantText = t;
86
106
  }
87
107
  }
88
108
  if (!cwd)
@@ -95,6 +115,11 @@ function parseSessionFile(path, mtime) {
95
115
  title: normalizeEmoji(customTitle || firstUserText || '(senza titolo)'),
96
116
  ts: mtime,
97
117
  path,
118
+ sizeBytes,
119
+ turns,
120
+ customTitle,
121
+ firstPrompt: firstUserText,
122
+ lastReply: lastAssistantText,
98
123
  };
99
124
  }
100
125
  // Discovery read-only delle sessioni del SOLO progetto corrente (D2 preflight
@@ -115,8 +140,11 @@ export function discoverProjectSessions(projectRoot) {
115
140
  const path = join(dir, f);
116
141
  seen.add(path);
117
142
  let mtime;
143
+ let sizeBytes;
118
144
  try {
119
- mtime = statSync(path).mtimeMs;
145
+ const st = statSync(path);
146
+ mtime = st.mtimeMs;
147
+ sizeBytes = st.size;
120
148
  }
121
149
  catch {
122
150
  continue;
@@ -127,7 +155,7 @@ export function discoverProjectSessions(projectRoot) {
127
155
  session = cached.session;
128
156
  }
129
157
  else {
130
- session = parseSessionFile(path, mtime);
158
+ session = parseSessionFile(path, mtime, sizeBytes);
131
159
  cache.set(path, { mtime, session });
132
160
  }
133
161
  if (session && (session.cwd === projectRoot || session.cwd.startsWith(projectRoot + '/'))) {
package/dist/viewport.js CHANGED
@@ -62,6 +62,14 @@ const MAX_DETAIL_LINES = 4;
62
62
  const TASKS_PANE_CHROME = 5; // 2 bordi + header "Tasks (n)" + riga sort + riga spot
63
63
  const SESSIONS_PANE_CHROME = 3; // 2 bordi + header "Sessions · …"
64
64
  const DETAIL_CHROME = 3; // marginTop + 2 bordi
65
+ /** Detail pane sessione (T49): righe fisse = titolo + riga meta (size · turni ·
66
+ * ultima attività). Le preview (primo prompt + ultima risposta) sono variabili,
67
+ * ciascuna al più MAX_SESSION_PREVIEW righe. */
68
+ const SESSION_DETAIL_FIXED = 2;
69
+ const MAX_SESSION_PREVIEW = 2;
70
+ /** Gemello di MIN_TASK_ROWS: sotto questa soglia la lista sessioni non serve
71
+ * più — meglio sacrificare il detail pane. */
72
+ const MIN_SESSION_ROWS = 3;
65
73
  /** Altezza di ciascuna modale, marginTop incluso. In flusso, non in overlay:
66
74
  * spingono giù i pane, quindi il loro costo va scalato dal budget. */
67
75
  export const MODAL_HEIGHT = {
@@ -96,9 +104,42 @@ export function layoutBudget(input) {
96
104
  // regolamentare con zero task dentro — occupa 5 righe per non mostrare nulla,
97
105
  // mentre la riga compatta dice le stesse cose in una.
98
106
  if (avail < TASKS_PANE_CHROME + 1) {
99
- return { taskRows: 0, sessionRows: 0, detailLines: 0, compact: true };
107
+ return {
108
+ taskRows: 0,
109
+ sessionRows: 0,
110
+ detailLines: 0,
111
+ sessionDetail: false,
112
+ sessionFirstLines: 0,
113
+ sessionLastLines: 0,
114
+ compact: true,
115
+ };
100
116
  }
101
- const sessionRows = Math.max(0, avail - SESSIONS_PANE_CHROME);
117
+ // Detail pane sessione (T49): stesso schema del dettaglio task — prima la
118
+ // lista minima, poi la cornice, le preview solo con lo spazio che avanza.
119
+ // A differenza del dettaglio task il box regge anche senza righe variabili:
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.
125
+ let sessionDetail = false;
126
+ let sessionDetailCost = 0;
127
+ let sessionFirstLines = 0;
128
+ let sessionLastLines = 0;
129
+ if (input.hasSessionDetail) {
130
+ const fixed = DETAIL_CHROME + SESSION_DETAIL_FIXED;
131
+ const spare = avail - SESSIONS_PANE_CHROME - MIN_SESSION_ROWS - fixed;
132
+ if (spare >= 0) {
133
+ sessionDetail = true;
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;
140
+ }
141
+ }
142
+ const sessionRows = Math.max(0, avail - SESSIONS_PANE_CHROME - sessionDetailCost);
102
143
  let detailLines = 0;
103
144
  let detailChrome = 0;
104
145
  if (input.hasDetail) {
@@ -113,7 +154,15 @@ export function layoutBudget(input) {
113
154
  }
114
155
  }
115
156
  const taskRows = Math.max(0, avail - TASKS_PANE_CHROME - detailChrome - detailLines);
116
- return { taskRows, sessionRows, detailLines, compact: false };
157
+ return {
158
+ taskRows,
159
+ sessionRows,
160
+ detailLines,
161
+ sessionDetail,
162
+ sessionFirstLines,
163
+ sessionLastLines,
164
+ compact: false,
165
+ };
117
166
  }
118
167
  /**
119
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.9.2",
3
+ "version": "0.11.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
@@ -30,18 +30,29 @@ set -euo pipefail
30
30
 
31
31
  TASK=""
32
32
  SESSION_ID=""
33
+ RESUME_ID=""
33
34
  NO_TASK=0
34
35
  # Positional <TaskID> + flag opzionale --session-id <uuid> (T27): il deck genera
35
36
  # l'UUID e lo pinna così il binding sidecar sessionId↔taskId è deterministico.
36
37
  # --no-task (T42): modalità NUDA, senza TaskID — nessuna LOOM_TASK, nessun prompt
37
38
  # iniziale, nessun sessionId pinnato. Flag esplicito e non "positional opzionale"
38
39
  # perché un `deck-run` a mani vuote resta un errore d'uso, non una sessione spot.
40
+ # --resume <sid> (T49): riapre una sessione esistente con `claude --resume`.
41
+ # Componibile con entrambe le forme: `deck-run <TaskID> --resume <sid>` (scoped:
42
+ # la ripresa eredita LOOM_TASK + titolo · task) e `deck-run --no-task --resume
43
+ # <sid>` (spot: resume nudo). Su resume niente prompt iniziale (si continua la
44
+ # conversazione, non se ne inietta una nuova) e niente --session-id (l'id ce
45
+ # l'ha già la sessione ripresa).
39
46
  while [[ $# -gt 0 ]]; do
40
47
  case "$1" in
41
48
  --session-id)
42
49
  SESSION_ID="${2:-}"; shift 2 ;;
43
50
  --session-id=*)
44
51
  SESSION_ID="${1#*=}"; shift ;;
52
+ --resume)
53
+ RESUME_ID="${2:-}"; shift 2 ;;
54
+ --resume=*)
55
+ RESUME_ID="${1#*=}"; shift ;;
45
56
  --no-task)
46
57
  NO_TASK=1; shift ;;
47
58
  *)
@@ -51,7 +62,9 @@ while [[ $# -gt 0 ]]; do
51
62
  done
52
63
 
53
64
  USAGE="uso: deck-run <TaskID> [--session-id <uuid>] (es. deck-run T18)
54
- | deck-run --no-task (sessione nuda, senza task)"
65
+ | deck-run --no-task (sessione nuda, senza task)
66
+ | deck-run <TaskID> --resume <uuid> (riprende sessione scoped)
67
+ | deck-run --no-task --resume <uuid> (riprende sessione spot)"
55
68
 
56
69
  if [[ $NO_TASK -eq 1 && -n "$TASK" ]]; then
57
70
  echo "--no-task e <TaskID> sono mutuamente esclusivi" >&2
@@ -62,6 +75,11 @@ if [[ $NO_TASK -eq 0 && -z "$TASK" ]]; then
62
75
  echo "$USAGE" >&2
63
76
  exit 2
64
77
  fi
78
+ if [[ -n "$RESUME_ID" && -n "$SESSION_ID" ]]; then
79
+ echo "--resume e --session-id sono mutuamente esclusivi (la sessione ripresa ha già il suo id)" >&2
80
+ echo "$USAGE" >&2
81
+ exit 2
82
+ fi
65
83
 
66
84
  MODE="${LOOM_DECK_SPAWN_MODE:-inline}"
67
85
  PROFILE_UUID="${LOOM_DECK_PROFILE_UUID:-5a36ae48df1c4d4882f43060e3e59656}"
@@ -132,6 +150,11 @@ MODE_FLAG="--permission-mode ${PERM_MODE} "
132
150
  SID_FLAG=""
133
151
  [[ -n "$SESSION_ID" ]] && SID_FLAG="--session-id ${SESSION_ID} "
134
152
 
153
+ # --resume per la tab (T49): riprende la sessione esistente. Stesso pattern
154
+ # flag-opzionale di SID_FLAG — la variante resta dentro l'unico IN_TAB_CMD.
155
+ RES_FLAG=""
156
+ [[ -n "$RESUME_ID" ]] && RES_FLAG="--resume ${RESUME_ID} "
157
+
135
158
  # Prompt iniziale della sessione. Default: prompt DIRETTO (no skill) che chiede
136
159
  # a Claude un recap dello stato della task — l'utente rivede/steera prima di
137
160
  # lanciare (= più controllo), nessuna esecuzione automatica. Override completo via
@@ -153,10 +176,14 @@ fi
153
176
  # Ramo --no-task (T42): saltano i TRE elementi che legano la sessione alla task —
154
177
  # iniezione LOOM_TASK, prompt iniziale, --session-id pinnato. Resta claude nudo,
155
178
  # titolato e con il permission mode del progetto.
179
+ # Su resume (T49) il prompt iniziale salta anche nel ramo task: riprendere una
180
+ # conversazione significa continuarla, non iniettarle un nuovo messaggio.
181
+ PROMPT_ARG="'${PROMPT}'"
182
+ [[ -n "$RESUME_ID" ]] && PROMPT_ARG=""
156
183
  if [[ $NO_TASK -eq 1 ]]; then
157
- _default_intab="claude --name '${TITLE}' ${MODE_FLAG}"
184
+ _default_intab="claude --name '${TITLE}' ${MODE_FLAG}${RES_FLAG}"
158
185
  else
159
- _default_intab="LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}'${PROMPT}'"
186
+ _default_intab="LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${PROMPT_ARG}"
160
187
  fi
161
188
  IN_TAB_CMD="${LOOM_DECK_INTAB_CMD:-$_default_intab}"
162
189