@lamemind/loom-deck 0.9.2 → 0.10.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,9 @@ 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,
728
771
  });
729
772
  // Finestre di rendering. Le liste "logiche" (viewTasks, visibleSessions)
730
773
  // restano intere: navigazione, selezione e spawn continuano a ragionare su
@@ -739,7 +782,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
739
782
  if (budget.compact) {
740
783
  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
784
  }
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] }));
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] }));
743
786
  }
744
787
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
745
788
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -784,11 +827,23 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
784
827
  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
828
  })), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
786
829
  }
787
- function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId, focused, above, below, }) {
830
+ function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId, focused, above, below, detail, previewLines, columns, }) {
788
831
  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
832
  const sel = s.sessionId === selectedId;
790
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));
791
- }))] }));
834
+ })), detail ? (_jsx(SessionDetailPane, { s: detail, previewLines: previewLines, columns: columns })) : null] }));
835
+ }
836
+ // T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
837
+ // 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)))] }));
792
847
  }
793
848
  /**
794
849
  * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
package/dist/sessions.js CHANGED
@@ -44,7 +44,7 @@ function extractUserText(message) {
44
44
  // poll-a resta economico anche con decine di sessioni multi-MB. Lo stato vive
45
45
  // dentro l'adapter così l'invariante "unico modulo che tocca lo store" regge.
46
46
  const cache = new Map();
47
- function parseSessionFile(path, mtime) {
47
+ function parseSessionFile(path, mtime, sizeBytes) {
48
48
  let content;
49
49
  try {
50
50
  content = readFileSync(path, 'utf8');
@@ -58,6 +58,7 @@ function parseSessionFile(path, mtime) {
58
58
  let parentUuid = null;
59
59
  let customTitle = '';
60
60
  let firstUserText = '';
61
+ let turns = 0;
61
62
  for (const line of content.split('\n')) {
62
63
  if (!line.trim())
63
64
  continue;
@@ -79,10 +80,15 @@ function parseSessionFile(path, mtime) {
79
80
  }
80
81
  if (typeof d.customTitle === 'string' && d.customTitle)
81
82
  customTitle = d.customTitle; // last-wins
82
- if (!firstUserText && d.type === 'user') {
83
+ if (d.type === 'user') {
84
+ // T49: turno = prompt umano. I tool_result viaggiano anch'essi come
85
+ // type:user ma senza blocchi text → extractUserText '' li esclude.
83
86
  const t = extractUserText(d.message);
84
- if (t)
85
- firstUserText = t;
87
+ if (t) {
88
+ turns++;
89
+ if (!firstUserText)
90
+ firstUserText = t;
91
+ }
86
92
  }
87
93
  }
88
94
  if (!cwd)
@@ -95,6 +101,10 @@ function parseSessionFile(path, mtime) {
95
101
  title: normalizeEmoji(customTitle || firstUserText || '(senza titolo)'),
96
102
  ts: mtime,
97
103
  path,
104
+ sizeBytes,
105
+ turns,
106
+ customTitle,
107
+ firstPrompt: firstUserText,
98
108
  };
99
109
  }
100
110
  // Discovery read-only delle sessioni del SOLO progetto corrente (D2 preflight
@@ -115,8 +125,11 @@ export function discoverProjectSessions(projectRoot) {
115
125
  const path = join(dir, f);
116
126
  seen.add(path);
117
127
  let mtime;
128
+ let sizeBytes;
118
129
  try {
119
- mtime = statSync(path).mtimeMs;
130
+ const st = statSync(path);
131
+ mtime = st.mtimeMs;
132
+ sizeBytes = st.size;
120
133
  }
121
134
  catch {
122
135
  continue;
@@ -127,7 +140,7 @@ export function discoverProjectSessions(projectRoot) {
127
140
  session = cached.session;
128
141
  }
129
142
  else {
130
- session = parseSessionFile(path, mtime);
143
+ session = parseSessionFile(path, mtime, sizeBytes);
131
144
  cache.set(path, { mtime, session });
132
145
  }
133
146
  if (session && (session.cwd === projectRoot || session.cwd.startsWith(projectRoot + '/'))) {
package/dist/viewport.js CHANGED
@@ -62,6 +62,13 @@ 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à). La preview del primo prompt è la parte variabile. */
67
+ const SESSION_DETAIL_FIXED = 2;
68
+ const MAX_SESSION_PREVIEW = 2;
69
+ /** Gemello di MIN_TASK_ROWS: sotto questa soglia la lista sessioni non serve
70
+ * più — meglio sacrificare il detail pane. */
71
+ const MIN_SESSION_ROWS = 3;
65
72
  /** Altezza di ciascuna modale, marginTop incluso. In flusso, non in overlay:
66
73
  * spingono giù i pane, quindi il loro costo va scalato dal budget. */
67
74
  export const MODAL_HEIGHT = {
@@ -96,9 +103,32 @@ export function layoutBudget(input) {
96
103
  // regolamentare con zero task dentro — occupa 5 righe per non mostrare nulla,
97
104
  // mentre la riga compatta dice le stesse cose in una.
98
105
  if (avail < TASKS_PANE_CHROME + 1) {
99
- return { taskRows: 0, sessionRows: 0, detailLines: 0, compact: true };
106
+ return {
107
+ taskRows: 0,
108
+ sessionRows: 0,
109
+ detailLines: 0,
110
+ sessionDetail: false,
111
+ sessionPreviewLines: 0,
112
+ compact: true,
113
+ };
100
114
  }
101
- const sessionRows = Math.max(0, avail - SESSIONS_PANE_CHROME);
115
+ // 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.
117
+ // A differenza del dettaglio task il box regge anche senza righe variabili:
118
+ // titolo + meta sono il valore, la preview è bonus.
119
+ let sessionDetail = false;
120
+ let sessionDetailCost = 0;
121
+ let sessionPreviewLines = 0;
122
+ if (input.hasSessionDetail) {
123
+ const fixed = DETAIL_CHROME + SESSION_DETAIL_FIXED;
124
+ const spare = avail - SESSIONS_PANE_CHROME - MIN_SESSION_ROWS - fixed;
125
+ if (spare >= 0) {
126
+ sessionDetail = true;
127
+ sessionPreviewLines = Math.min(MAX_SESSION_PREVIEW, spare);
128
+ sessionDetailCost = fixed + sessionPreviewLines;
129
+ }
130
+ }
131
+ const sessionRows = Math.max(0, avail - SESSIONS_PANE_CHROME - sessionDetailCost);
102
132
  let detailLines = 0;
103
133
  let detailChrome = 0;
104
134
  if (input.hasDetail) {
@@ -113,7 +143,7 @@ export function layoutBudget(input) {
113
143
  }
114
144
  }
115
145
  const taskRows = Math.max(0, avail - TASKS_PANE_CHROME - detailChrome - detailLines);
116
- return { taskRows, sessionRows, detailLines, compact: false };
146
+ return { taskRows, sessionRows, detailLines, sessionDetail, sessionPreviewLines, compact: false };
117
147
  }
118
148
  /**
119
149
  * 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.10.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