@lamemind/loom-deck 0.27.1 → 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/README.md CHANGED
@@ -308,6 +308,14 @@ senza flag resta `recap`, `none` apre la sessione bound **senza** alcun prompt.
308
308
  `LOOM_DECK_ENTER_PROMPT` (placeholder `{TASK}`) resta un override che vince sul
309
309
  kind — tranne su `none`, che è una richiesta esplicita di non averne.
310
310
 
311
+ La tab porta anche `PTYXIS_PROFILE` forzata al profilo bindato al progetto nel
312
+ registry (`bindings/claude`), letto da dconf: è la chiave con cui loom-compass
313
+ associa a un progetto lo stato annunciato dagli hook (running/ask/done). Una tab
314
+ `ptyxis --tab` nuda erediterebbe il profilo di default, e l'annuncio finirebbe
315
+ keyed su un UUID che nessun progetto dichiara — pallino fermo su idle, senza
316
+ alcun errore visibile. Override o disattivazione via `LOOM_DECK_STATE_PROFILE`
317
+ (settata a vuoto → nessun annuncio); progetto non registrato → nessun prefisso.
318
+
311
319
  ## Sviluppo (TUI Ink)
312
320
 
313
321
  ```bash
package/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowI
16
16
  import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch, } from './config.js';
17
17
  import { countArchivable, SCAN_INTERVAL_MS } from './archivable.js';
18
18
  import { assignListCapacity, isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
19
- import { caretWindow, cut, pad, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
19
+ import { caretWindow, cut, cutParts, pad, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
20
20
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
21
21
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
22
22
  import { loadView, saveView, viewFilePath } from './view-store.js';
@@ -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:
@@ -2017,15 +2022,43 @@ function AssignScreen({ sessionId, label, current, filter, rows, selected, match
2017
2022
  return (_jsxs(Text, { inverse: sel, 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));
2018
2023
  })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
2019
2024
  }
2020
- function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, allCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, archivable, }) {
2025
+ /**
2026
+ * Header del pane task, tagliato QUI e non da Ink — stesso motivo del gemello
2027
+ * `SessionsHeader`, con una differenza di rischio: qui i segmenti sono tutti
2028
+ * ASCII più le frecce `↑↓` (larghe 1), quindi `cli-truncate` oggi darebbe la
2029
+ * riga giusta per caso. Il taglio resta del deck perché la correttezza non deve
2030
+ * dipendere dall'alfabeto che capita nella riga: il primo glifo largo 2 che
2031
+ * entrasse in un segmento nuovo riaprirebbe il difetto in silenzio, e a
2032
+ * scoprirlo sarebbe il bordo del pane a schermo.
2033
+ *
2034
+ * `truncate-end` taglia dalla coda, e `cutParts` conserva l'ordine: l'ultimo
2035
+ * segmento resta il primo a cedere il posto (vedi T61 sotto, `archiviabili` in
2036
+ * coda ai contatori della vista corrente).
2037
+ */
2038
+ function TasksHeader({ filtered, total, hidden, above, below, archivable, focused, columns, }) {
2039
+ const segments = [
2040
+ { text: `Tasks (${hidden > 0 ? `${filtered}/${total}` : filtered})` },
2041
+ ...(hidden > 0 ? [{ text: ` · ${hidden} nascoste`, color: 'yellow' }] : []),
2042
+ ...(above > 0 ? [{ text: ` · ↑${above}`, dim: true }] : []),
2043
+ ...(below > 0 ? [{ text: ` · ↓${below}`, dim: true }] : []),
2044
+ ...(archivable > 0 ? [{ text: ` · ${archivable} archiviabili`, dim: true }] : []),
2045
+ ];
2046
+ const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns));
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) }));
2048
+ }
2049
+ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, allCount, childCount, focused, loadError, windowStart, above, below, columns, archivable, }) {
2021
2050
  const allSelected = selected === ROW_ALL;
2022
2051
  const spotSelected = selected === ROW_SPOT;
2023
- return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Tasks (", hidden > 0 ? `${filtered}/${total}` : filtered, ")", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 ", hidden, " nascoste"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null, archivable > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", archivable, " archiviabili"] }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort: ", describeSort(view.sort), view.hiddenPri.length + view.hiddenProg.length > 0 ? (_jsxs(Text, { children: [' ', "\u00B7 filtri:", ' ', [
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)}` +
2053
+ (view.hiddenPri.length + view.hiddenProg.length > 0
2054
+ ? ' · filtri: ' +
2055
+ [
2024
2056
  ...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
2025
2057
  ...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
2026
2058
  ]
2027
- .map((e) => `−${sanitize(e.glyph)}`)
2028
- .join(' ')] })) : null] }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni", allCount > 0 ? ` (${allCount})` : ''] }), _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) => {
2059
+ .map((e) => `−${e.glyph}`)
2060
+ .join(' ')
2061
+ : '')), paneTextWidth(columns)) }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni", allCount > 0 ? ` (${allCount})` : ''] }), _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) => {
2029
2062
  // windowStart riporta l'indice di finestra a quello della lista
2030
2063
  // completa, su cui è keyata la selezione. +META_ROWS: le prime due
2031
2064
  // righe sono le meta.
@@ -2041,10 +2074,34 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
2041
2074
  const tail = n > 0 ? ` (${n})` : '';
2042
2075
  const desc = cut(task.desc, Math.max(4, paneTextWidth(columns) - termWidth(head) - termWidth(tail)));
2043
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));
2044
- })), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
2077
+ }))] }));
2078
+ }
2079
+ /**
2080
+ * Header del pane sessioni, tagliato QUI e non da Ink.
2081
+ *
2082
+ * `📌{pinnedCount}` è un glifo largo 2 in mezzo alla riga: con
2083
+ * `wrap="truncate-end"` il taglio passava da `cli-truncate`, che indicizza per
2084
+ * code point con un budget in colonne e restituiva una riga larga 45 su un
2085
+ * budget di 44 — la colonna in più finiva sopra il bordo destro del pane, che
2086
+ * spariva dalla riga (invariante ③ di `width.ts`).
2087
+ *
2088
+ * I segmenti restano segmenti fino al render: il taglio è della RIGA (budget
2089
+ * condiviso, `cutParts`), la resa è del pezzo. Il giallo su `📌N` distingue le
2090
+ * pinnate dal resto dell'header e non è decorazione.
2091
+ */
2092
+ function SessionsHeader({ parentLabel, total, pinnedCount, hidden, above, below, focused, columns, }) {
2093
+ const segments = [
2094
+ { text: `Sessions · ${parentLabel} (${total})` },
2095
+ ...(pinnedCount > 0 ? [{ text: ` · 📌${pinnedCount}`, color: 'yellow' }] : []),
2096
+ ...(hidden > 0 ? [{ text: ` · +${hidden} più vecchie`, dim: true }] : []),
2097
+ ...(above > 0 ? [{ text: ` · ↑${above}`, dim: true }] : []),
2098
+ ...(below > 0 ? [{ text: ` · ↓${below}`, dim: true }] : []),
2099
+ ];
2100
+ const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns));
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) }));
2045
2102
  }
2046
- function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, sessionNotes, projectCore, }) {
2047
- 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, ")", pinnedCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 \uD83D\uDCCC", pinnedCount] }) : null, 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: isAll
2103
+ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, total, pinnedCount, hidden, selectedId, focused, above, below, columns, forkOf, sessionNotes, projectCore, }) {
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
2048
2105
  ? 'nessuna conversazione nel progetto'
2049
2106
  : isSpot
2050
2107
  ? 'nessuna sessione libera'
@@ -2130,21 +2187,23 @@ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows,
2130
2187
  (label.note && label.rest ? 1 : 0) +
2131
2188
  termWidth(label.rest);
2132
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));
2133
- })), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null, note: sessionNotes.get(detail.sessionId) ?? '' })) : null] }));
2190
+ }))] }));
2191
+ }
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 })) }));
2134
2194
  }
2135
- // T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
2136
- // task. Tutti i campi vengono dal parse già cached dell'adapter (mtime-keyed):
2137
- // il pannello non costa I/O al movimento di selezione. Mostra "da dove parte,
2138
- // dove è arrivata": il primo prompt utente ( `) e l'ultima risposta del
2139
- // modello (`« `). La preview del primo prompt compare SOLO con un titolo custom
2140
- // senza, il titolo È già il primo prompt e la riga lo duplicherebbe (D4
2141
- // preflight). Le righe rese non superano mai il riservato dal budget
2142
- // (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
2143
- function SessionDetailPane({ s, firstLines, lastLines, columns, origin, note, }) {
2144
- const width = detailTextWidth(columns);
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);
2145
2204
  const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
2146
2205
  const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
2147
- 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}`)))] }));
2148
2207
  }
2149
2208
  /**
2150
2209
  * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
@@ -2159,15 +2218,16 @@ function detailMetaOf(detail) {
2159
2218
  return { meta, commit, metaLines: 1 + (meta ? 1 : 0) + (commit ? 1 : 0) };
2160
2219
  }
2161
2220
  /**
2162
- * Larghezza utile del testo di descrizione, ricavata dalle colonne del
2163
- * terminale: box esterno (2 bordi + 2 padding) → pane al 50% box dettaglio
2164
- * (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.
2165
2225
  *
2166
2226
  * Volutamente prudente: sottostimare tronca qualche carattere in più,
2167
2227
  * sovrastimare farebbe andare a capo una riga e sforare il tetto d'altezza.
2168
2228
  */
2169
- function detailTextWidth(columns) {
2170
- return Math.max(10, Math.floor(((columns || 80) - 4) / 2) - 9);
2229
+ function previewTextWidth(columns) {
2230
+ return Math.max(10, (columns || 80) - 8);
2171
2231
  }
2172
2232
  /**
2173
2233
  * Larghezza del TESTO dentro un pane al 50%: box esterno (2 bordi + 2 padding)
@@ -2183,14 +2243,15 @@ function detailTextWidth(columns) {
2183
2243
  function paneTextWidth(columns) {
2184
2244
  return Math.max(20, Math.floor(((columns || 80) - 4) / 2) - 4);
2185
2245
  }
2186
- function DetailPane({ detail, maxLines, columns, }) {
2246
+ /** Corpo della preview task: titolo, meta, descrizione wrappata, commit. */
2247
+ function TaskPreview({ detail, maxLines, columns, }) {
2187
2248
  const { meta, commit } = detailMetaOf(detail);
2188
2249
  // Wrap calcolato qui, non delegato a `<Text wrap="wrap">`: il budget ha
2189
2250
  // riservato ESATTAMENTE `maxLines` righe, e un wrap deciso da Ink a runtime
2190
2251
  // ne produrrebbe un numero che il budget non conosce — cioè il frame torna a
2191
2252
  // sforare e il bug si riapre da questa singola casella di testo.
2192
- const lines = wrapLines(detail.description ?? '', detailTextWidth(columns), maxLines);
2193
- 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] }));
2194
2255
  }
2195
2256
  const cwd = process.cwd();
2196
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/dist/width.js CHANGED
@@ -202,6 +202,61 @@ export function cut(s, cols) {
202
202
  // informazione, tolgono una colonna di testo.
203
203
  return out.trimEnd().replace(/…$/, '') + '…';
204
204
  }
205
+ /**
206
+ * Taglio di una riga COMPOSTA da più pezzi, ognuno con la propria resa (colore,
207
+ * dim, bold). Restituisce un array della stessa lunghezza: i pezzi interi finché
208
+ * il budget regge, quello a cavallo del taglio accorciato, i successivi vuoti.
209
+ *
210
+ * Perché non basta `cut` chiamato pezzo per pezzo: il budget è della RIGA, non
211
+ * del singolo pezzo — un pezzo va tagliato in base a quanto hanno già consumato
212
+ * quelli prima di lui. E `cut` collassa il whitespace, quindi mangerebbe lo
213
+ * spazio che separa i segmenti (` · 📌3` diventerebbe `· 📌3`), disallineando la
214
+ * riga di una colonna per ogni giunzione.
215
+ *
216
+ * Perché non basta concatenare e tagliare una volta sola: il risultato sarebbe
217
+ * una stringa unica, e i pezzi tornerebbero renderizzabili solo tutti dello
218
+ * stesso colore. Un header che perde il giallo su `📌N` perde il segnale, non
219
+ * solo la vernice.
220
+ *
221
+ * Invariante ③ (§ in testa a questo file): serve a togliere l'ultima riga
222
+ * composita ancora tagliata da Ink. Gli header dei due pane sono l'ultimo posto
223
+ * dove `wrap="truncate-end"` era rimasto il meccanismo e non la rete: entrambi
224
+ * portano glifi larghi 2 (`📌` nelle sessioni, i glifi di priorità nei filtri
225
+ * delle task), cioè la condizione esatta in cui `cli-truncate` restituisce una
226
+ * riga più larga del budget e il bordo del pane sparisce.
227
+ */
228
+ export function cutParts(parts, cols) {
229
+ const empty = parts.map(() => '');
230
+ if (cols <= 0)
231
+ return empty;
232
+ if (parts.reduce((w, p) => w + termWidth(p), 0) <= cols)
233
+ return [...parts];
234
+ const out = [...empty];
235
+ const budget = cols - 1; // -1 = la colonna dell'ellissi, che qui c'è di sicuro
236
+ let w = 0;
237
+ let full = false;
238
+ for (let i = 0; i < parts.length && !full; i++) {
239
+ let acc = '';
240
+ for (const ch of parts[i]) {
241
+ const cw = termWidth(ch);
242
+ if (w + cw > budget) {
243
+ full = true;
244
+ break;
245
+ }
246
+ acc += ch;
247
+ w += cw;
248
+ }
249
+ out[i] = acc;
250
+ }
251
+ // L'ellissi va sull'ultimo pezzo che ha davvero del testo: appiccicarla a un
252
+ // pezzo vuoto la staccherebbe dal punto di taglio, e il lettore la leggerebbe
253
+ // come un segmento a sé.
254
+ let last = out.length - 1;
255
+ while (last > 0 && out[last] === '')
256
+ last--;
257
+ out[last] = out[last].trimEnd().replace(/…$/, '') + '…';
258
+ return out;
259
+ }
205
260
  /**
206
261
  * Cella di larghezza ESATTA `cols`: taglia se eccede, riempie di spazi se manca.
207
262
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.27.1",
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": {
package/scripts/deck-run CHANGED
@@ -40,6 +40,8 @@
40
40
  # LOOM_DECK_WORKDIR dir di lavoro della tab (default: $PWD)
41
41
  # LOOM_DECK_ENTER_PROMPT override del prompt iniziale, {TASK}=TaskID (vince sul kind, tranne `none`)
42
42
  # LOOM_DECK_INTAB_CMD override comando in-tab (default: la sessione CC; usato per i test)
43
+ # LOOM_DECK_STATE_PROFILE PTYXIS_PROFILE annunciata a compass (default: bindings/claude
44
+ # del progetto, letto da dconf; settata a vuoto = nessun annuncio)
43
45
  # LOOM_DECK_PERMISSION_MODE override del permissionMode del file config (default: campo file, poi 'manual')
44
46
  #
45
47
  set -euo pipefail
@@ -271,6 +273,48 @@ case "${PERM_MODE:-}" in
271
273
  esac
272
274
  MODE_FLAG="--permission-mode ${PERM_MODE} "
273
275
 
276
+ # ── Profilo di stato per compass ─────────────────────────────────────────────
277
+ # Lo stato di una sessione (running/ask/done) viaggia verso compass via D-Bus
278
+ # keyed su $PTYXIS_PROFILE (hook Claude → `compass <stato>`), e compass lo mappa
279
+ # a un progetto SOLO se quell'UUID compare in `bindings/<surface>/profile` del
280
+ # registry. Ma nel modo `inline` la tab nasce da `ptyxis --tab` nudo, che eredita
281
+ # il profilo DEFAULT di Ptyxis: l'annuncio arriva keyed su un UUID che nessun
282
+ # progetto dichiara → stato scartato, pallino fermo su idle per sempre. Il match
283
+ # della FINESTRA non se ne accorge (passa dal titolo, non dal profilo), quindi il
284
+ # progetto risulta presente ma senza stato — un guasto che non si manifesta come
285
+ # errore, solo come pallino che non cambia mai.
286
+ # Rimedio: forzare in-tab la PTYXIS_PROFILE del binding claude del progetto, così
287
+ # l'annuncio è keyed sull'UUID che compass cerca davvero. Il profilo bindato NON
288
+ # è usabile come profilo vero della tab (`--tab-with-profile`): ha
289
+ # use-custom-command=true, che fa scartare il comando inline — vedi il ramo
290
+ # `profile` sotto, che per questo deve riscrivere dconf.
291
+ #
292
+ # `${VAR+set}`, non `${VAR:-}`: env settata anche a VUOTO = "salta il lookup"
293
+ # (deterministico per i test, che altrimenti leggerebbero il dconf della macchina
294
+ # su cui gira la suite). Non settata = risolvi dal registry.
295
+ # Assente/non risolvibile → nessun prefisso: si degrada al comportamento di prima
296
+ # (stato orfano) invece di rompere lo spawn.
297
+ if [[ -n "${LOOM_DECK_STATE_PROFILE+set}" ]]; then
298
+ STATE_PROFILE="${LOOM_DECK_STATE_PROFILE}"
299
+ else
300
+ STATE_PROFILE=""
301
+ if [[ -n "$_cfg" ]] && command -v dconf >/dev/null 2>&1; then
302
+ _pid="$(jq -r '.id // empty' "$_cfg" 2>/dev/null || true)"
303
+ if [[ -n "$_pid" ]]; then
304
+ STATE_PROFILE="$(dconf read "/org/lamemind/loom/projects/${_pid}/bindings/claude/profile" 2>/dev/null | tr -d "'" || true)"
305
+ fi
306
+ fi
307
+ fi
308
+ # Whitelist come per la nota del titolo: il valore entra in `bash -lc "…"`, e un
309
+ # registry è pur sempre un file editabile a mano. Un UUID Ptyxis è esadecimale;
310
+ # accetto l'alfabeto id-safe e scarto il resto invece di quotare.
311
+ if [[ -n "$STATE_PROFILE" && ! "$STATE_PROFILE" =~ ^[A-Za-z0-9_-]+$ ]]; then
312
+ echo "profilo di stato ignorato (caratteri non ammessi): '${STATE_PROFILE}'" >&2
313
+ STATE_PROFILE=""
314
+ fi
315
+ PROFILE_ENV=""
316
+ [[ -n "$STATE_PROFILE" ]] && PROFILE_ENV="PTYXIS_PROFILE=${STATE_PROFILE} "
317
+
274
318
  # --session-id per la tab: pinna il sessionId della sessione CC (vuoto → CC ne
275
319
  # genera uno). Lo spazio finale tiene la concatenazione pulita quando è assente.
276
320
  SID_FLAG=""
@@ -344,10 +388,13 @@ PROMPT_ARG=""
344
388
  # agli altri elementi task-bound: nel fork di una sessione SPOT il sessionId
345
389
  # pinnato non serve a legare una task (non c'è) ma a rendere noto in anticipo
346
390
  # l'id del ramo, senza il quale il lineage non sarebbe registrabile.
391
+ # PROFILE_ENV in testa a entrambi i rami: lo stato è una proprietà della SESSIONE
392
+ # (esiste anche senza task), esattamente come la label del titolo — legarlo al
393
+ # ramo task-bound ripeterebbe l'errore di layer già evitato per la titolazione.
347
394
  if [[ $NO_TASK -eq 1 ]]; then
348
- _default_intab="claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}"
395
+ _default_intab="${PROFILE_ENV}claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}"
349
396
  else
350
- _default_intab="LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}${PROMPT_ARG}"
397
+ _default_intab="${PROFILE_ENV}LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}${PROMPT_ARG}"
351
398
  fi
352
399
  IN_TAB_CMD="${LOOM_DECK_INTAB_CMD:-$_default_intab}"
353
400