@lamemind/loom-deck 0.27.3 → 0.28.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/dist/cli.js CHANGED
@@ -1749,21 +1749,26 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1749
1749
  // non dipende più da quante voci `launch` il progetto dichiara.
1750
1750
  const launchLine = mode === 'normal';
1751
1751
  const detailParts = detail ? detailMetaOf(detail) : null;
1752
+ // T70 — un solo blocco preview, sotto i due pane, e il FOCUS decide cosa
1753
+ // contiene: a sinistra la task selezionata, a destra la conversazione. È il
1754
+ // focus e non la selezione perché il blocco è uno — averne due significava
1755
+ // pagare due cornici per mostrare contemporaneamente il dettaglio di una cosa
1756
+ // che si sta guardando e di una che non si sta guardando.
1757
+ // `none` quando non c'è contenuto (riga meta selezionata, task file assente,
1758
+ // pin stale): il blocco sparisce del tutto, non resta una cornice vuota.
1759
+ const previewKind = focus === 'tasks' ? (detail ? 'task' : 'none') : selSessionObj ? 'session' : 'none';
1752
1760
  const budget = layoutBudget({
1753
1761
  rows,
1754
1762
  mode,
1755
1763
  launchLine,
1756
1764
  noteLine: Boolean(note),
1757
- hasDetail: Boolean(detail),
1765
+ preview: previewKind,
1758
1766
  detailMetaLines: detailParts?.metaLines ?? 0,
1759
- // T49 il detail pane sessione esiste solo con il focus sul pane: è
1760
- // l'hover, non uno stato persistente; navigando le task non ruba righe.
1761
- hasSessionDetail: canResume,
1762
- // Riservo righe di preview solo per i blocchi che davvero renderizzano: il
1767
+ // Riservo righe di anteprima solo per i blocchi che davvero renderizzano: il
1763
1768
  // primo prompt aggiunge info solo con un titolo custom (senza, titolo ===
1764
1769
  // primo prompt); l'ultima risposta solo se il modello ha già risposto.
1765
- sessionHasFirstPreview: canResume && Boolean(selSessionObj?.customTitle),
1766
- sessionHasLastPreview: canResume && Boolean(selSessionObj?.lastReply),
1770
+ sessionHasFirstPreview: previewKind === 'session' && Boolean(selSessionObj?.customTitle),
1771
+ sessionHasLastPreview: previewKind === 'session' && Boolean(selSessionObj?.lastReply),
1767
1772
  });
1768
1773
  // Finestre di rendering. Le liste "logiche" (viewTasks, sessionRows)
1769
1774
  // restano intere: navigazione, selezione e spawn continuano a ragionare su
@@ -1779,7 +1784,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1779
1784
  if (budget.compact) {
1780
1785
  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 ?? parentLabel, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
1781
1786
  }
1782
- 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 === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \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, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${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 === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _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, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, archivable: archivable, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, 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, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1787
+ 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 === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \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, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${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 === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _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, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, archivable: archivable, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '' })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1783
1788
  }
1784
1789
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
1785
1790
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -2041,7 +2046,7 @@ function TasksHeader({ filtered, total, hidden, above, below, archivable, focuse
2041
2046
  const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns));
2042
2047
  return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, children: shown[i] }, i)) : null) }));
2043
2048
  }
2044
- function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, allCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, archivable, }) {
2049
+ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, allCount, childCount, focused, loadError, windowStart, above, below, columns, archivable, }) {
2045
2050
  const allSelected = selected === ROW_ALL;
2046
2051
  const spotSelected = selected === ROW_SPOT;
2047
2052
  return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(TasksHeader, { filtered: filtered, total: total, hidden: hidden, above: above, below: below, archivable: archivable, focused: focused, columns: columns }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut(sanitize(`sort: ${describeSort(view.sort)}` +
@@ -2069,7 +2074,7 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
2069
2074
  const tail = n > 0 ? ` (${n})` : '';
2070
2075
  const desc = cut(task.desc, Math.max(4, paneTextWidth(columns) - termWidth(head) - termWidth(tail)));
2071
2076
  return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
2072
- })), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
2077
+ }))] }));
2073
2078
  }
2074
2079
  /**
2075
2080
  * Header del pane sessioni, tagliato QUI e non da Ink.
@@ -2095,7 +2100,7 @@ function SessionsHeader({ parentLabel, total, pinnedCount, hidden, above, below,
2095
2100
  const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns));
2096
2101
  return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, children: shown[i] }, i)) : null) }));
2097
2102
  }
2098
- function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, sessionNotes, projectCore, }) {
2103
+ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, total, pinnedCount, hidden, selectedId, focused, above, below, columns, forkOf, sessionNotes, projectCore, }) {
2099
2104
  return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(SessionsHeader, { parentLabel: parentLabel, total: total, pinnedCount: pinnedCount, hidden: hidden, above: above, below: below, focused: focused, columns: columns }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isAll
2100
2105
  ? 'nessuna conversazione nel progetto'
2101
2106
  : isSpot
@@ -2182,21 +2187,23 @@ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows,
2182
2187
  (label.note && label.rest ? 1 : 0) +
2183
2188
  termWidth(label.rest);
2184
2189
  return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', taskW > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: bound && taskCell ? 'green' : undefined, dimColor: !bound, children: pad(taskCell, taskW) }), ' '] })) : null, forkMark ? _jsx(Text, { color: "magenta", children: forkMark }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' '.repeat(Math.max(0, inner - used)), ' ', _jsx(Text, { dimColor: true, children: pad(age, ageW, 'right') })] }, s.sessionId));
2185
- })), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null, note: sessionNotes.get(detail.sessionId) ?? '' })) : null] }));
2190
+ }))] }));
2186
2191
  }
2187
- // T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
2188
- // task. Tutti i campi vengono dal parse già cached dell'adapter (mtime-keyed):
2189
- // il pannello non costa I/O al movimento di selezione. Mostra "da dove parte,
2190
- // dove è arrivata": il primo prompt utente (`» `) e l'ultima risposta del
2191
- // modello (`« `). La preview del primo prompt compare SOLO con un titolo custom
2192
- // senza, il titolo È già il primo prompt e la riga lo duplicherebbe (D4
2193
- // preflight). Le righe rese non superano mai il riservato dal budget
2194
- // (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
2195
- function SessionDetailPane({ s, firstLines, lastLines, columns, origin, note, }) {
2196
- const width = detailTextWidth(columns);
2192
+ function PreviewPane(p) {
2193
+ return (_jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: p.kind === 'task' ? (_jsx(TaskPreview, { detail: p.detail, maxLines: p.maxLines, columns: p.columns })) : (_jsx(SessionPreview, { s: p.s, firstLines: p.firstLines, lastLines: p.lastLines, columns: p.columns, origin: p.origin, note: p.note })) }));
2194
+ }
2195
+ // T49 corpo della preview sessione. Tutti i campi vengono dal parse già
2196
+ // cached dell'adapter (mtime-keyed): non costa I/O al movimento di selezione.
2197
+ // Mostra "da dove parte, dove è arrivata": il primo prompt utente (`» `) e
2198
+ // l'ultima risposta del modello (`« `). L'anteprima del primo prompt compare
2199
+ // SOLO con un titolo custom senza, il titolo È già il primo prompt e la riga
2200
+ // lo duplicherebbe (D4 preflight). Le righe rese non superano mai il riservato
2201
+ // dal budget (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
2202
+ function SessionPreview({ s, firstLines, lastLines, columns, origin, note, }) {
2203
+ const width = previewTextWidth(columns);
2197
2204
  const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
2198
2205
  const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
2199
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), " \u00B7 ", s.gitBranch || '-', origin ? ` · ⑂ da ${origin.slice(0, 8)}` : ''] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
2206
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), " \u00B7 ", s.gitBranch || '-', origin ? ` · ⑂ da ${origin.slice(0, 8)}` : ''] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
2200
2207
  }
2201
2208
  /**
2202
2209
  * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
@@ -2211,15 +2218,16 @@ function detailMetaOf(detail) {
2211
2218
  return { meta, commit, metaLines: 1 + (meta ? 1 : 0) + (commit ? 1 : 0) };
2212
2219
  }
2213
2220
  /**
2214
- * Larghezza utile del testo di descrizione, ricavata dalle colonne del
2215
- * terminale: box esterno (2 bordi + 2 padding) → pane al 50% box dettaglio
2216
- * (2 bordi + 2 padding).
2221
+ * T70 — larghezza utile del testo dentro il blocco preview, che è a PIENA
2222
+ * larghezza: box esterno (2 bordi + 2 padding) → box preview (2 bordi + 2
2223
+ * padding). Niente più `/2`: il blocco non vive più dentro un pane al 50%,
2224
+ * quindi una riga di descrizione dispone del doppio delle colonne.
2217
2225
  *
2218
2226
  * Volutamente prudente: sottostimare tronca qualche carattere in più,
2219
2227
  * sovrastimare farebbe andare a capo una riga e sforare il tetto d'altezza.
2220
2228
  */
2221
- function detailTextWidth(columns) {
2222
- return Math.max(10, Math.floor(((columns || 80) - 4) / 2) - 9);
2229
+ function previewTextWidth(columns) {
2230
+ return Math.max(10, (columns || 80) - 8);
2223
2231
  }
2224
2232
  /**
2225
2233
  * Larghezza del TESTO dentro un pane al 50%: box esterno (2 bordi + 2 padding)
@@ -2235,14 +2243,15 @@ function detailTextWidth(columns) {
2235
2243
  function paneTextWidth(columns) {
2236
2244
  return Math.max(20, Math.floor(((columns || 80) - 4) / 2) - 4);
2237
2245
  }
2238
- function DetailPane({ detail, maxLines, columns, }) {
2246
+ /** Corpo della preview task: titolo, meta, descrizione wrappata, commit. */
2247
+ function TaskPreview({ detail, maxLines, columns, }) {
2239
2248
  const { meta, commit } = detailMetaOf(detail);
2240
2249
  // Wrap calcolato qui, non delegato a `<Text wrap="wrap">`: il budget ha
2241
2250
  // riservato ESATTAMENTE `maxLines` righe, e un wrap deciso da Ink a runtime
2242
2251
  // ne produrrebbe un numero che il budget non conosce — cioè il frame torna a
2243
2252
  // sforare e il bug si riapre da questa singola casella di testo.
2244
- const lines = wrapLines(detail.description ?? '', detailTextWidth(columns), maxLines);
2245
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: detail.title || detail.id }), meta ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: meta }) : null, lines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", children: line }, i))), commit ? _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", commit] }) : null] }));
2253
+ const lines = wrapLines(detail.description ?? '', previewTextWidth(columns), maxLines);
2254
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: detail.title || detail.id }), meta ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: meta }) : null, lines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", children: line }, i))), commit ? _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", commit] }) : null] }));
2246
2255
  }
2247
2256
  const cwd = process.cwd();
2248
2257
  render(_jsx(Deck, { cwd: cwd, tasksPath: resolveTasksPath(cwd), tasksDir: resolveTasksDir(cwd) }));
package/dist/viewport.js CHANGED
@@ -17,25 +17,25 @@
17
17
  * basterebbe 1; ne teniamo 1 come margine per l'a-capo del cursore. */
18
18
  export const SLACK = 1;
19
19
  /** Sotto questa soglia la lista task non è più utilizzabile: meglio sacrificare
20
- * il pannello di dettaglio che ridurre la lista a due righe. */
20
+ * il blocco preview che ridurre la lista a due righe. */
21
21
  const MIN_TASK_ROWS = 3;
22
- /** Il dettaglio è secondario: non si prende mai più di così, anche con spazio. */
23
- const MAX_DETAIL_LINES = 4;
24
- /** Righe di "cornice" fisse dei tre contenitori a lunghezza variabile. */
22
+ /** La preview è secondaria: non si prende mai più di così, anche con spazio.
23
+ * A piena larghezza (T70) una riga contiene il doppio del testo di prima,
24
+ * quindi il tetto sale insieme allo spazio: il blocco è uno solo e lo paga
25
+ * una volta sola per entrambi i pane. */
26
+ const MAX_DETAIL_LINES = 6;
27
+ /** Righe di "cornice" fisse dei contenitori a lunghezza variabile. */
25
28
  // T59 — le righe meta sono DUE (`≡ tutte`, `○ spot`): ogni riga fissa aggiunta
26
29
  // a un pane va scalata qui, o il frame sfonda `rows` e Ink passa a
27
30
  // clearTerminal (frame-fantasma nello scrollback di VTE).
28
31
  const TASKS_PANE_CHROME = 6; // 2 bordi + header "Tasks (n)" + riga sort + 2 righe meta
29
32
  const SESSIONS_PANE_CHROME = 3; // 2 bordi + header "Sessions · …"
30
- const DETAIL_CHROME = 3; // marginTop + 2 bordi
31
- /** Detail pane sessione (T49): righe fisse = titolo + riga meta (size · turni ·
32
- * ultima attività). Le preview (primo prompt + ultima risposta) sono variabili,
33
- * ciascuna al più MAX_SESSION_PREVIEW righe. */
33
+ const PREVIEW_CHROME = 3; // marginTop + 2 bordi
34
+ /** Preview sessione: righe fisse = titolo + riga meta (size · turni · ultima
35
+ * attività · branch). Le due anteprime (primo prompt + ultima risposta) sono
36
+ * variabili, ciascuna al più MAX_SESSION_PREVIEW righe. */
34
37
  const SESSION_DETAIL_FIXED = 2;
35
- const MAX_SESSION_PREVIEW = 2;
36
- /** Gemello di MIN_TASK_ROWS: sotto questa soglia la lista sessioni non serve
37
- * più — meglio sacrificare il detail pane. */
38
- const MIN_SESSION_ROWS = 3;
38
+ const MAX_SESSION_PREVIEW = 3;
39
39
  /** Altezza di ciascuna modale, marginTop incluso. In flusso, non in overlay:
40
40
  * spingono giù i pane, quindi il loro costo va scalato dal budget. */
41
41
  export const MODAL_HEIGHT = {
@@ -59,14 +59,21 @@ export const MODAL_HEIGHT = {
59
59
  assign: 0,
60
60
  };
61
61
  /**
62
- * Distribuisce le righe disponibili fra lista task, lista sessioni e dettaglio.
62
+ * Distribuisce le righe disponibili fra lista task, lista sessioni e preview.
63
63
  *
64
64
  * I due pane stanno affiancati (flexDirection row) → l'altezza del blocco è il
65
65
  * MAX delle due colonne, non la somma: ognuna riceve lo stesso tetto.
66
66
  *
67
+ * T70 — il blocco preview è UNO e sta SOTTO i due pane, non dentro uno di essi:
68
+ * il suo costo esce quindi da `avail` PRIMA della distribuzione, e ciò che
69
+ * resta va a entrambe le colonne. Con i due pannelli separati il costo gravava
70
+ * su una colonna sola, e siccome l'altezza del blocco è il max delle due, il
71
+ * pannello del pane meno alto era di fatto gratis: contabilità che qui non
72
+ * esiste più.
73
+ *
67
74
  * Ordine di sacrificio quando lo spazio stringe:
68
- * 1. righe di descrizione del dettaglio (fino a sparire col pannello),
69
- * 2. righe della lista task, mai sotto MIN_TASK_ROWS finché il dettaglio c'è.
75
+ * 1. righe variabili della preview (fino a sparire col blocco),
76
+ * 2. righe delle liste, mai sotto MIN_TASK_ROWS finché la preview c'è.
70
77
  */
71
78
  export function layoutBudget(input) {
72
79
  const outerChrome = 2 + // bordi del box esterno
@@ -86,58 +93,63 @@ export function layoutBudget(input) {
86
93
  return {
87
94
  taskRows: 0,
88
95
  sessionRows: 0,
96
+ preview: false,
89
97
  detailLines: 0,
90
- sessionDetail: false,
91
98
  sessionFirstLines: 0,
92
99
  sessionLastLines: 0,
93
100
  compact: true,
94
101
  };
95
102
  }
96
- // Detail pane sessione (T49): stesso schema del dettaglio task prima la
97
- // lista minima, poi la cornice, le preview solo con lo spazio che avanza.
98
- // A differenza del dettaglio task il box regge anche senza righe variabili:
99
- // titolo + meta sono il valore, le preview (primo prompt + ultima risposta)
100
- // sono bonus. Priorità al primo prompt, poi l'ultima risposta prende ciò che
101
- // resta: su terminale stretto cade prima l'ultima risposta, non il primo.
102
- // Riservo righe solo per le preview che davvero renderizzeranno (i due
103
- // `has…Preview`), così non sottraggo righe alla lista per un blocco vuoto.
104
- let sessionDetail = false;
105
- let sessionDetailCost = 0;
103
+ // Pavimento sotto cui la preview non si concede: la lista minima del pane
104
+ // TASK, cioè il più caro in cornice dei due. Garantito lui, il pane sessioni
105
+ // (3 di cornice contro 6) sta comodo per costruzione un secondo controllo
106
+ // sul suo minimo sarebbe una condizione che non può mai scattare.
107
+ const floor = TASKS_PANE_CHROME + MIN_TASK_ROWS;
108
+ let preview = false;
109
+ let previewCost = 0;
110
+ let detailLines = 0;
106
111
  let sessionFirstLines = 0;
107
112
  let sessionLastLines = 0;
108
- if (input.hasSessionDetail) {
109
- const fixed = DETAIL_CHROME + SESSION_DETAIL_FIXED;
110
- const spare = avail - SESSIONS_PANE_CHROME - MIN_SESSION_ROWS - fixed;
113
+ if (input.preview === 'task') {
114
+ const fixed = PREVIEW_CHROME + input.detailMetaLines;
115
+ // Serve almeno 1 riga di descrizione per giustificare il blocco: titolo e
116
+ // meta sono già nella riga di lista, quindi una cornice senza descrizione
117
+ // ruberebbe righe alle liste per non aggiungere niente.
118
+ const spare = avail - floor - fixed;
119
+ if (spare >= 1) {
120
+ preview = true;
121
+ detailLines = Math.min(MAX_DETAIL_LINES, spare);
122
+ previewCost = fixed + detailLines;
123
+ }
124
+ }
125
+ else if (input.preview === 'session') {
126
+ const fixed = PREVIEW_CHROME + SESSION_DETAIL_FIXED;
127
+ const spare = avail - floor - fixed;
128
+ // A differenza della task il blocco regge anche a zero righe variabili:
129
+ // titolo e riga meta (size, turni, data, branch) non stanno da nessun'altra
130
+ // parte a schermo, quindi sono già il valore. Priorità al primo prompt, poi
131
+ // l'ultima risposta prende ciò che resta: quando lo spazio stringe cade
132
+ // prima l'ultima risposta, non il primo prompt. Le righe si riservano solo
133
+ // per le anteprime che davvero renderizzeranno (i due `has…Preview`), o si
134
+ // toglierebbero righe alle liste per un blocco vuoto.
111
135
  if (spare >= 0) {
112
- sessionDetail = true;
136
+ preview = true;
113
137
  if (input.sessionHasFirstPreview)
114
138
  sessionFirstLines = Math.min(MAX_SESSION_PREVIEW, spare);
115
139
  if (input.sessionHasLastPreview) {
116
140
  sessionLastLines = Math.min(MAX_SESSION_PREVIEW, spare - sessionFirstLines);
117
141
  }
118
- sessionDetailCost = fixed + sessionFirstLines + sessionLastLines;
119
- }
120
- }
121
- const sessionRows = Math.max(0, avail - SESSIONS_PANE_CHROME - sessionDetailCost);
122
- let detailLines = 0;
123
- let detailChrome = 0;
124
- if (input.hasDetail) {
125
- const fixed = DETAIL_CHROME + input.detailMetaLines;
126
- // Righe che avanzano dopo aver garantito la lista minima e la cornice del
127
- // dettaglio. Serve almeno 1 riga di descrizione per giustificare il box:
128
- // un pannello con la sola cornice ruberebbe 3+ righe per mostrare nulla.
129
- const spare = avail - TASKS_PANE_CHROME - MIN_TASK_ROWS - fixed;
130
- if (spare >= 1) {
131
- detailChrome = fixed;
132
- detailLines = Math.min(MAX_DETAIL_LINES, spare);
142
+ previewCost = fixed + sessionFirstLines + sessionLastLines;
133
143
  }
134
144
  }
135
- const taskRows = Math.max(0, avail - TASKS_PANE_CHROME - detailChrome - detailLines);
145
+ // Ciò che resta va a ENTRAMBE le colonne: la preview sta sotto il blocco dei
146
+ // pane, quindi il suo costo lo pagano insieme una volta sola.
147
+ const listAvail = avail - previewCost;
136
148
  return {
137
- taskRows,
138
- sessionRows,
149
+ taskRows: Math.max(0, listAvail - TASKS_PANE_CHROME),
150
+ sessionRows: Math.max(0, listAvail - SESSIONS_PANE_CHROME),
151
+ preview,
139
152
  detailLines,
140
- sessionDetail,
141
153
  sessionFirstLines,
142
154
  sessionLastLines,
143
155
  compact: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.27.3",
3
+ "version": "0.28.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": {