@lamemind/loom-deck 0.9.1 → 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
@@ -11,7 +11,7 @@ import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from '.
11
11
  import { discoverProjectSessions } from './sessions.js';
12
12
  import { appendTaskBinding, loadTaskBindings } from './task-index.js';
13
13
  import { launchLegend, loadIdentity, loadLaunch } from './config.js';
14
- import { layoutBudget, windowRange, wrapLines, } from './viewport.js';
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';
16
16
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
17
17
  import { loadView, saveView, viewFilePath } from './view-store.js';
@@ -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
@@ -273,22 +285,23 @@ function truncate(s, n) {
273
285
  const flat = s.replace(/\s+/g, ' ').trim();
274
286
  return flat.length > n ? flat.slice(0, n - 1).trimEnd() + '…' : flat;
275
287
  }
276
- // Forza la presentazione emoji (larghezza 2 non-ambigua) su un glifo BMP
277
- // text-default come ⚡ (U+26A1, range simboli U+2190–U+2BFF). Senza il variation
278
- // selector VS16 questi glifi sono larghi-ambigui: Ink li misura 1, ma string-width
279
- // e il terminale li disegnano 2 la riga esce 1 colonna troppo larga, sfonda il
280
- // pane e va a capo (righe vuote spurie). Gli emoji astrali (🔥🔵🟡…, U+1F000+) sono
281
- // già width-2 non-ambigui e le sequenze con VS16 (✔️) sono >1 code point → intatti.
282
- const VS16 = '️';
283
- function forceEmojiWidth(s) {
284
- const cps = [...s];
285
- if (cps.length !== 1)
286
- return s;
287
- const cp = cps[0].codePointAt(0);
288
- if (cp >= 0x2190 && cp <= 0x2bff)
289
- return s + VS16;
290
- return s;
291
- }
288
+ // Normalizzazione larghezza glifi `normalizeEmoji` in viewport.ts.
289
+ //
290
+ // Sostituisce il precedente `forceEmojiWidth`, che aveva l'intuizione giusta
291
+ // (timbrare il VS16) ma due limiti che lasciavano passare il difetto:
292
+ // - agiva solo su stringhe di UN codepoint, quindi non toccava mai un testo
293
+ // composto la riga della legenda launch, una descrizione task con emoji
294
+ // dentro, il titolo di una sessione;
295
+ // - decideva per intervallo di codepoint invece che per larghezza misurata,
296
+ // quindi avrebbe timbrato anche `↓` `↑` `−` (larghi 1) se gli fossero
297
+ // arrivati da soli, accorciando la riga invece di allargarla.
298
+ const forceEmojiWidth = normalizeEmoji;
299
+ // Glifi LETTERALI del JSX che ricadono nella classe mal misurata (BMP largo 2
300
+ // senza VS16). I dati passano dai loader, questi no: normalizzati una volta
301
+ // qui, così nessun sito di render li scrive nudi. `↳ ○ ▸ ⏎ · − ↑ ↓` sono
302
+ // larghi 1 e restano intatti.
303
+ const CARET = normalizeEmoji('▶ ');
304
+ const CARET_OFF = ' ';
292
305
  // Normalizza il marker Done per il display. `✔` (U+2714) è text-presentation-
293
306
  // default: string-width — quindi Ink, sia per il layout sia per il troncamento —
294
307
  // lo misura 2 (rispetta il VS16 di `✔️`), ma VTE/Ptyxis lo disegna largo 1
@@ -301,6 +314,21 @@ function forceEmojiWidth(s) {
301
314
  function displayProg(prog) {
302
315
  return forceEmojiWidth(prog.replace(/✔️?/g, '✅'));
303
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
+ }
304
332
  // Età relativa compatta (ms epoch → "2m"/"3h"/"5d") per il preview sessioni.
305
333
  function relTime(ts) {
306
334
  const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
@@ -639,7 +667,18 @@ function Deck({ cwd, tasksPath, tasksDir }) {
639
667
  }
640
668
  }
641
669
  else {
642
- 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
+ }
643
682
  }
644
683
  }
645
684
  else if (input === 'C') {
@@ -703,9 +742,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
703
742
  exit();
704
743
  }
705
744
  });
706
- const selSessionId = visibleSessions[selSession]?.sessionId;
745
+ const selSessionObj = visibleSessions[selSession] ?? null;
746
+ const selSessionId = selSessionObj?.sessionId;
707
747
  const parentLabel = isSpot ? 'spot' : selectedTaskId ?? '—';
708
748
  const canSpawn = focus === 'tasks' && !isSpot;
749
+ const canResume = focus === 'sessions' && selSessionObj !== null;
709
750
  // Larghezza dal medesimo hook che dà l'altezza: dopo un resize la legenda si
710
751
  // ricalcola con lo stesso re-render che ridimensiona i pane.
711
752
  const legend = launchLegend(launch, columns);
@@ -724,6 +765,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
724
765
  noteLine: Boolean(note),
725
766
  hasDetail: Boolean(detail),
726
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,
727
771
  });
728
772
  // Finestre di rendering. Le liste "logiche" (viewTasks, visibleSessions)
729
773
  // restano intere: navigazione, selezione e spawn continuano a ragionare su
@@ -738,7 +782,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
738
782
  if (budget.compact) {
739
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"] })] }));
740
784
  }
741
- 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: 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] }));
742
786
  }
743
787
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
744
788
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -765,8 +809,8 @@ function FilterModal({ view, cursor }) {
765
809
  // La riga di anteprima mostra il testo ESATTO che finirà nel campo `Progress`
766
810
  // del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
767
811
  function EditModal({ id, draft, row }) {
768
- const mark = (r) => (row === r ? '▶ ' : ' ');
769
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 priorit\u00E0 e stato"] }), _jsxs(Text, { children: [mark(0), _jsx(Text, { dimColor: true, children: "pri " }), EDIT_PRI.map((p) => (_jsxs(Text, { inverse: draft.pri === p, color: draft.pri === p ? 'green' : 'gray', children: [' ', forceEmojiWidth(PRI_GLYPH[p]), " ", PRI_LABEL[p]] }, p)))] }), _jsxs(Text, { children: [mark(1), _jsx(Text, { dimColor: true, children: "stato" }), EDIT_PROG.map((p) => (_jsxs(Text, { inverse: draft.prog === p, color: draft.prog === p ? 'green' : 'gray', children: [' ', forceEmojiWidth(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(Text, { dimColor: true, children: "prog " }), _jsxs(Text, { children: [' ', draft.detail] }), row === 2 ? _jsx(Text, { inverse: true, children: " " }) : null, !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { dimColor: true, children: ["\u21B3 ", progressText(draft.prog, draft.detail)] })] }));
812
+ const mark = (r) => (row === r ? CARET : CARET_OFF);
813
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 priorit\u00E0 e stato"] }), _jsxs(Text, { children: [mark(0), _jsx(Text, { dimColor: true, children: "pri " }), EDIT_PRI.map((p) => (_jsxs(Text, { inverse: draft.pri === p, color: draft.pri === p ? 'green' : 'gray', children: [' ', forceEmojiWidth(PRI_GLYPH[p]), " ", PRI_LABEL[p]] }, p)))] }), _jsxs(Text, { children: [mark(1), _jsx(Text, { dimColor: true, children: "stato" }), EDIT_PROG.map((p) => (_jsxs(Text, { inverse: draft.prog === p, color: draft.prog === p ? 'green' : 'gray', children: [' ', forceEmojiWidth(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(Text, { dimColor: true, children: "prog " }), _jsxs(Text, { children: [' ', draft.detail] }), row === 2 ? _jsx(Text, { inverse: true, children: " " }) : null, !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", normalizeEmoji(progressText(draft.prog, draft.detail))] })] }));
770
814
  }
771
815
  function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, }) {
772
816
  const spotSelected = selected === 0;
@@ -774,20 +818,32 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
774
818
  ...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
775
819
  ...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
776
820
  ]
777
- .map((e) => `−${e.glyph}`)
778
- .join(' ')] })) : null] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? '▶ ' : ' ', "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : (tasks.map((task, i) => {
821
+ .map((e) => `−${normalizeEmoji(e.glyph)}`)
822
+ .join(' ')] })) : null] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : (tasks.map((task, i) => {
779
823
  // windowStart riporta l'indice di finestra a quello della lista
780
824
  // completa, su cui è keyata la selezione. +1: lo 0 è spot.
781
825
  const sel = windowStart + i + 1 === selected;
782
826
  const n = childCount.get(task.id) ?? 0;
783
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? '▶ ' : ' ', task.id, " ", forceEmojiWidth(task.pri), " ", displayProg(task.prog), " ", task.desc, n > 0 ? ` (${n})` : ''] }, task.id));
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));
784
828
  })), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
785
829
  }
786
- 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, }) {
787
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) => {
788
832
  const sel = s.sessionId === selectedId;
789
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? '▶ ' : ' ', 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));
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));
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)))] }));
791
847
  }
792
848
  /**
793
849
  * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // indice 1..9. Il `command` gira con cwd = project root.
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
+ import { normalizeEmoji } from './viewport.js';
8
9
  export function configFilePath(projectRoot) {
9
10
  return join(projectRoot, '.claude', 'loom-works.json');
10
11
  }
@@ -22,9 +23,12 @@ export function parseLaunch(raw) {
22
23
  if (typeof command !== 'string' || !command.trim())
23
24
  continue;
24
25
  out.push({
25
- emoji: typeof emoji === 'string' ? emoji : '▸',
26
+ // L'emoji arriva dal file config: testo arbitrario, quindi va normalizzato
27
+ // come ogni altro dato esterno — `☕` senza VS16 allargherebbe di una cella
28
+ // la riga della legenda, mandandola a capo (vedi normalizeEmoji).
29
+ emoji: normalizeEmoji(typeof emoji === 'string' ? emoji : '▸'),
26
30
  // `label` è opzionale per contratto → fallback sul comando stesso.
27
- label: typeof label === 'string' && label ? label : command,
31
+ label: normalizeEmoji(typeof label === 'string' && label ? label : command),
28
32
  command,
29
33
  });
30
34
  }
package/dist/sessions.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, readdirSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, join } from 'node:path';
4
+ import { normalizeEmoji } from './viewport.js';
4
5
  // CC codifica la project dir sostituendo ogni char non-alfanumerico con '-'
5
6
  // (verificato: `/home/lamemind/cc-host` → `-home-lamemind-cc-host`). È LOSSY
6
7
  // (un '-' nel path e un '/' collassano nello stesso char) → il forward è
@@ -43,7 +44,7 @@ function extractUserText(message) {
43
44
  // poll-a resta economico anche con decine di sessioni multi-MB. Lo stato vive
44
45
  // dentro l'adapter così l'invariante "unico modulo che tocca lo store" regge.
45
46
  const cache = new Map();
46
- function parseSessionFile(path, mtime) {
47
+ function parseSessionFile(path, mtime, sizeBytes) {
47
48
  let content;
48
49
  try {
49
50
  content = readFileSync(path, 'utf8');
@@ -57,6 +58,7 @@ function parseSessionFile(path, mtime) {
57
58
  let parentUuid = null;
58
59
  let customTitle = '';
59
60
  let firstUserText = '';
61
+ let turns = 0;
60
62
  for (const line of content.split('\n')) {
61
63
  if (!line.trim())
62
64
  continue;
@@ -78,10 +80,15 @@ function parseSessionFile(path, mtime) {
78
80
  }
79
81
  if (typeof d.customTitle === 'string' && d.customTitle)
80
82
  customTitle = d.customTitle; // last-wins
81
- 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.
82
86
  const t = extractUserText(d.message);
83
- if (t)
84
- firstUserText = t;
87
+ if (t) {
88
+ turns++;
89
+ if (!firstUserText)
90
+ firstUserText = t;
91
+ }
85
92
  }
86
93
  }
87
94
  if (!cwd)
@@ -91,9 +98,13 @@ function parseSessionFile(path, mtime) {
91
98
  cwd,
92
99
  gitBranch,
93
100
  parentUuid,
94
- title: customTitle || firstUserText || '(senza titolo)',
101
+ title: normalizeEmoji(customTitle || firstUserText || '(senza titolo)'),
95
102
  ts: mtime,
96
103
  path,
104
+ sizeBytes,
105
+ turns,
106
+ customTitle,
107
+ firstPrompt: firstUserText,
97
108
  };
98
109
  }
99
110
  // Discovery read-only delle sessioni del SOLO progetto corrente (D2 preflight
@@ -114,8 +125,11 @@ export function discoverProjectSessions(projectRoot) {
114
125
  const path = join(dir, f);
115
126
  seen.add(path);
116
127
  let mtime;
128
+ let sizeBytes;
117
129
  try {
118
- mtime = statSync(path).mtimeMs;
130
+ const st = statSync(path);
131
+ mtime = st.mtimeMs;
132
+ sizeBytes = st.size;
119
133
  }
120
134
  catch {
121
135
  continue;
@@ -126,7 +140,7 @@ export function discoverProjectSessions(projectRoot) {
126
140
  session = cached.session;
127
141
  }
128
142
  else {
129
- session = parseSessionFile(path, mtime);
143
+ session = parseSessionFile(path, mtime, sizeBytes);
130
144
  cache.set(path, { mtime, session });
131
145
  }
132
146
  if (session && (session.cwd === projectRoot || session.cwd.startsWith(projectRoot + '/'))) {
package/dist/tasks.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, readdirSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
+ import { normalizeEmoji } from './viewport.js';
3
4
  // D1 (preflight T20): default docs/tasks.md, override della docs-root via env
4
5
  // LOOM_DECK_DOCS_ROOT (es. questo progetto usa `runtime`). No auto-detect.
5
6
  export function resolveTasksPath(cwd = process.cwd()) {
@@ -32,7 +33,16 @@ export function parseTasks(content) {
32
33
  const prog = cells[4] ?? '';
33
34
  // desc = colonna finale; join per resistere a eventuali `|` nella descrizione.
34
35
  const desc = cells.slice(5, -1).join('|').trim();
35
- tasks.push({ id, pri, prog, desc });
36
+ // Normalizzazione larghezza glifi AL CONFINE: tutto ciò che entra da
37
+ // tasks.md è testo arbitrario e può contenere emoji BMP che Ink misura una
38
+ // cella in meno del terminale (vedi normalizeEmoji). Farlo qui invece che a
39
+ // ogni sito di render è ciò che impedisce di dimenticarne uno.
40
+ tasks.push({
41
+ id,
42
+ pri: normalizeEmoji(pri),
43
+ prog: normalizeEmoji(prog),
44
+ desc: normalizeEmoji(desc),
45
+ });
36
46
  }
37
47
  return tasks;
38
48
  }
@@ -85,7 +95,18 @@ export function parseTaskDetail(id, content) {
85
95
  fields[key] = f[2].trim();
86
96
  }
87
97
  }
88
- return { id, title, fields, description: descLines.join('\n').trim() };
98
+ // Stessa normalizzazione dell'overview: il task file è testo libero, e la
99
+ // descrizione finisce nel pannello dettaglio dove un glifo mal misurato
100
+ // allarga la riga oltre il bordo del pane.
101
+ const normFields = {};
102
+ for (const [k, v] of Object.entries(fields))
103
+ normFields[k] = normalizeEmoji(v);
104
+ return {
105
+ id,
106
+ title: normalizeEmoji(title),
107
+ fields: normFields,
108
+ description: normalizeEmoji(descLines.join('\n').trim()),
109
+ };
89
110
  }
90
111
  export function loadTaskDetail(dir, id) {
91
112
  const path = findTaskFile(dir, id);
package/dist/viewport.js CHANGED
@@ -13,6 +13,43 @@
13
13
  // dettaglio) passa da qui e riceve una capienza in righe.
14
14
  //
15
15
  // Logica pura, zero React/Ink: testabile senza pseudo-terminale.
16
+ import stringWidth from 'string-width';
17
+ /** Variation Selector-16: forza la presentazione emoji del carattere che precede. */
18
+ const VS16 = '️';
19
+ /**
20
+ * Riallinea la larghezza dei glifi fra Ink e il terminale.
21
+ *
22
+ * Secondo meccanismo di sporcamento dello scrollback, indipendente dal budget
23
+ * d'altezza. Ink assembla ogni riga su una griglia di CARATTERI: per un emoji
24
+ * BMP senza VS16 (`☕` U+2615, `✔` U+2714, `⚡` U+26A1, `✅` U+2705, `▶` U+25B6)
25
+ * riserva una sola posizione, mentre il terminale ne disegna due. La riga esce
26
+ * larga `columns + 1`, il terminale la manda a capo, e l'altezza reale supera
27
+ * quella che Ink crede — senza mai passare dal ramo `clearTerminal`. Con più
28
+ * glifi sulla stessa riga l'eccesso si somma.
29
+ *
30
+ * Il VS16 esplicito fa contare 2 anche a Ink, riallineando le due contabilità.
31
+ *
32
+ * Il predicato è la LARGHEZZA MISURATA, non l'intervallo di codepoint: nello
33
+ * stesso blocco U+2190–U+2BFF vivono anche `↓` `↑` `−`, larghi 1, e timbrarli
34
+ * col VS16 li porterebbe a 2 accorciando la riga — l'errore opposto, con lo
35
+ * stesso effetto di layout rotto. Gli astrali (U+1F000+) Ink li conta già bene.
36
+ *
37
+ * Idempotente: un glifo che ha già il VS16 non viene ri-timbrato.
38
+ */
39
+ export function normalizeEmoji(s) {
40
+ if (!s)
41
+ return s;
42
+ const cps = [...s];
43
+ let out = '';
44
+ for (let i = 0; i < cps.length; i++) {
45
+ const ch = cps[i];
46
+ out += ch;
47
+ const cp = ch.codePointAt(0);
48
+ if (cp < 0x10000 && stringWidth(ch) === 2 && cps[i + 1] !== VS16)
49
+ out += VS16;
50
+ }
51
+ return out;
52
+ }
16
53
  /** Righe lasciate libere sotto il frame. La condizione di Ink è `>=`, quindi
17
54
  * basterebbe 1; ne teniamo 1 come margine per l'a-capo del cursore. */
18
55
  export const SLACK = 1;
@@ -25,6 +62,13 @@ const MAX_DETAIL_LINES = 4;
25
62
  const TASKS_PANE_CHROME = 5; // 2 bordi + header "Tasks (n)" + riga sort + riga spot
26
63
  const SESSIONS_PANE_CHROME = 3; // 2 bordi + header "Sessions · …"
27
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;
28
72
  /** Altezza di ciascuna modale, marginTop incluso. In flusso, non in overlay:
29
73
  * spingono giù i pane, quindi il loro costo va scalato dal budget. */
30
74
  export const MODAL_HEIGHT = {
@@ -59,9 +103,32 @@ export function layoutBudget(input) {
59
103
  // regolamentare con zero task dentro — occupa 5 righe per non mostrare nulla,
60
104
  // mentre la riga compatta dice le stesse cose in una.
61
105
  if (avail < TASKS_PANE_CHROME + 1) {
62
- 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
+ };
114
+ }
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
+ }
63
130
  }
64
- const sessionRows = Math.max(0, avail - SESSIONS_PANE_CHROME);
131
+ const sessionRows = Math.max(0, avail - SESSIONS_PANE_CHROME - sessionDetailCost);
65
132
  let detailLines = 0;
66
133
  let detailChrome = 0;
67
134
  if (input.hasDetail) {
@@ -76,7 +143,7 @@ export function layoutBudget(input) {
76
143
  }
77
144
  }
78
145
  const taskRows = Math.max(0, avail - TASKS_PANE_CHROME - detailChrome - detailLines);
79
- return { taskRows, sessionRows, detailLines, compact: false };
146
+ return { taskRows, sessionRows, detailLines, sessionDetail, sessionPreviewLines, compact: false };
80
147
  }
81
148
  /**
82
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.1",
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": {
@@ -42,7 +42,8 @@
42
42
  "license": "MIT",
43
43
  "dependencies": {
44
44
  "ink": "^5.1.0",
45
- "react": "^18.3.1"
45
+ "react": "^18.3.1",
46
+ "string-width": "^7.2.0"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@types/node": "^22.20.0",
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