@lamemind/loom-deck 0.9.0 → 0.9.1

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
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { render, Box, Text, useApp, useInput } from 'ink';
3
+ import { render, Box, Text, useApp, useInput, useStdout } from 'ink';
4
4
  import { useState, useEffect, useMemo } from 'react';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { statSync } from 'node:fs';
@@ -11,6 +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
15
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
15
16
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
16
17
  import { loadView, saveView, viewFilePath } from './view-store.js';
@@ -166,6 +167,29 @@ function commitTaskEdit(cwd, paths, message, onResult) {
166
167
  child.on('close', (code) => onResult(code === 0, err.trim().split('\n')[0] ?? ''));
167
168
  return child;
168
169
  }
170
+ // Dimensioni del terminale, live sul resize.
171
+ //
172
+ // Non è una comodità di layout: senza `rows` il frame non ha tetto, e un frame
173
+ // più alto del terminale fa cadere Ink nel ramo `clearTerminal` (ink.js:121)
174
+ // che su VTE/Ptyxis riversa ogni redraw nello scrollback.
175
+ //
176
+ // Il valore iniziale conta quanto il resize: una tab Ptyxis appena aperta parte
177
+ // spesso a 24 righe e riceve il SIGWINCH subito dopo. Nella finestra fra i due
178
+ // il deck disegnava già a piena altezza — motivo per cui lo scrollback risultava
179
+ // sporco fin dall'avvio, prima ancora di toccare un tasto.
180
+ function useTerminalSize() {
181
+ const { stdout } = useStdout();
182
+ const [size, setSize] = useState({ rows: stdout.rows || 24, columns: stdout.columns || 80 });
183
+ useEffect(() => {
184
+ const onResize = () => setSize({ rows: stdout.rows || 24, columns: stdout.columns || 80 });
185
+ stdout.on('resize', onResize);
186
+ onResize(); // allinea se il resize è arrivato prima del mount
187
+ return () => {
188
+ stdout.off('resize', onResize);
189
+ };
190
+ }, [stdout]);
191
+ return size;
192
+ }
169
193
  // Carica tasks.md e lo ri-legge quando cambia sotto (poll su mtime). Poll
170
194
  // (non fs.watch) perché i writer di tasks.md — checkpoint-task/create-task —
171
195
  // riscrivono il file (probabile replace atomico), che rompe il watch sull'inode
@@ -315,6 +339,8 @@ function Deck({ cwd, tasksPath, tasksDir }) {
315
339
  // T41 — bozza dell'edit (null fuori dal modale) e riga attiva della griglia.
316
340
  const [edit, setEdit] = useState(null);
317
341
  const [editRow, setEditRow] = useState(0);
342
+ // Dimensioni vive del terminale: sono l'input del budget d'altezza sotto.
343
+ const { rows, columns } = useTerminalSize();
318
344
  // Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
319
345
  const launch = useMemo(() => loadLaunch(cwd), [cwd]);
320
346
  // Identità (T37): titolo delle tab terminale spawnate col tasto `t`.
@@ -680,10 +706,39 @@ function Deck({ cwd, tasksPath, tasksDir }) {
680
706
  const selSessionId = visibleSessions[selSession]?.sessionId;
681
707
  const parentLabel = isSpot ? 'spot' : selectedTaskId ?? '—';
682
708
  const canSpawn = focus === 'tasks' && !isSpot;
683
- // Larghezza letta a ogni render (non memoizzata): dopo un resize il primo
684
- // re-render utile ricalcola la legenda senza bisogno di un listener dedicato.
685
- const legend = launchLegend(launch, process.stdout.columns || 80);
686
- 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, 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, 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, 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, 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, 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, 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: viewTasks, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, sessions: visibleSessions, total: childSessions.length, hidden: hiddenSessions, selectedId: selSessionId, focused: focus === 'sessions' })] }), note ? _jsx(Text, { color: "green", children: note }) : null] }));
709
+ // Larghezza dal medesimo hook che l'altezza: dopo un resize la legenda si
710
+ // ricalcola con lo stesso re-render che ridimensiona i pane.
711
+ const legend = launchLegend(launch, columns);
712
+ // ── Budget d'altezza ────────────────────────────────────────────────────
713
+ // Il frame deve restare sotto `rows`, sempre: oltre quella soglia Ink smette
714
+ // di aggiornare per differenza e pulisce lo schermo a ogni redraw, che su
715
+ // Ptyxis significa un frame intero versato nello scrollback per ogni tick del
716
+ // poll. Tutto ciò che varia in altezza (le due liste e la descrizione del
717
+ // dettaglio) riceve qui la propria capienza.
718
+ const launchLine = mode === 'normal' && launch.length > 0;
719
+ const detailParts = detail ? detailMetaOf(detail) : null;
720
+ const budget = layoutBudget({
721
+ rows,
722
+ mode,
723
+ launchLine,
724
+ noteLine: Boolean(note),
725
+ hasDetail: Boolean(detail),
726
+ detailMetaLines: detailParts?.metaLines ?? 0,
727
+ });
728
+ // Finestre di rendering. Le liste "logiche" (viewTasks, visibleSessions)
729
+ // restano intere: navigazione, selezione e spawn continuano a ragionare su
730
+ // quelle, la finestra è solo ciò che finisce a schermo.
731
+ const taskWin = windowRange(viewTasks.length, selIndex - 1, budget.taskRows);
732
+ const windowTasks = viewTasks.slice(taskWin.start, taskWin.end);
733
+ const sessionWin = windowRange(visibleSessions.length, selSession, budget.sessionRows);
734
+ const windowSessions = visibleSessions.slice(sessionWin.start, sessionWin.end);
735
+ // Sotto la soglia minima il layout a box non entra a nessun costo: si scende
736
+ // a una riga sola. Perdere il deck per un terminale basso è meglio che
737
+ // sporcare la cronologia del terminale a ogni poll.
738
+ if (budget.compact) {
739
+ 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
+ }
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] }));
687
742
  }
688
743
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
689
744
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -713,31 +768,58 @@ function EditModal({ id, draft, row }) {
713
768
  const mark = (r) => (row === r ? '▶ ' : ' ');
714
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)] })] }));
715
770
  }
716
- function TasksPane({ tasks, total, hidden, view, selected, spotCount, childCount, focused, loadError, detail, }) {
771
+ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, }) {
717
772
  const spotSelected = selected === 0;
718
- 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, children: ["Tasks (", hidden > 0 ? `${tasks.length}/${total}` : tasks.length, ")", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 ", hidden, " nascoste"] }) : 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:", ' ', [
773
+ 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] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort: ", describeSort(view.sort), view.hiddenPri.length + view.hiddenProg.length > 0 ? (_jsxs(Text, { children: [' ', "\u00B7 filtri:", ' ', [
719
774
  ...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
720
775
  ...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
721
776
  ]
722
777
  .map((e) => `−${e.glyph}`)
723
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) => {
724
- const sel = i + 1 === selected; // +1: lo 0 è spot
779
+ // windowStart riporta l'indice di finestra a quello della lista
780
+ // completa, su cui è keyata la selezione. +1: lo 0 è spot.
781
+ const sel = windowStart + i + 1 === selected;
725
782
  const n = childCount.get(task.id) ?? 0;
726
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));
727
- })), detail ? _jsx(DetailPane, { detail: detail }) : null] }));
784
+ })), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
728
785
  }
729
- function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId, focused, }) {
730
- return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, children: ["Sessions \u00B7 ", parentLabel, " (", total, ")", hidden > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 +", hidden, " pi\u00F9 vecchie"] }) : null] }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isSpot ? 'nessuna sessione libera' : 'nessuna sessione legata a questa task' })) : (sessions.map((s) => {
786
+ function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId, focused, above, below, }) {
787
+ 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) => {
731
788
  const sel = s.sessionId === selectedId;
732
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));
733
790
  }))] }));
734
791
  }
735
- function DetailPane({ detail }) {
792
+ /**
793
+ * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
794
+ * Estratto dal componente perché il budget deve saperlo PRIMA di renderizzare:
795
+ * sono righe fisse che tolgono spazio alla descrizione.
796
+ */
797
+ function detailMetaOf(detail) {
736
798
  const meta = META_KEYS.map((k) => detail.fields[k])
737
799
  .filter(Boolean)
738
800
  .join(' · ');
739
- const commit = detail.fields['Last tracked commit'];
740
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: detail.title || detail.id }), meta ? _jsx(Text, { dimColor: true, children: meta }) : null, detail.description ? _jsx(Text, { wrap: "wrap", children: truncate(detail.description, 300) }) : null, commit ? _jsxs(Text, { dimColor: true, children: ["\u21B3 ", commit] }) : null] }));
801
+ const commit = detail.fields['Last tracked commit'] ?? '';
802
+ return { meta, commit, metaLines: 1 + (meta ? 1 : 0) + (commit ? 1 : 0) };
803
+ }
804
+ /**
805
+ * Larghezza utile del testo di descrizione, ricavata dalle colonne del
806
+ * terminale: box esterno (2 bordi + 2 padding) → pane al 50% → box dettaglio
807
+ * (2 bordi + 2 padding).
808
+ *
809
+ * Volutamente prudente: sottostimare tronca qualche carattere in più,
810
+ * sovrastimare farebbe andare a capo una riga e sforare il tetto d'altezza.
811
+ */
812
+ function detailTextWidth(columns) {
813
+ return Math.max(10, Math.floor(((columns || 80) - 4) / 2) - 9);
814
+ }
815
+ function DetailPane({ detail, maxLines, columns, }) {
816
+ const { meta, commit } = detailMetaOf(detail);
817
+ // Wrap calcolato qui, non delegato a `<Text wrap="wrap">`: il budget ha
818
+ // riservato ESATTAMENTE `maxLines` righe, e un wrap deciso da Ink a runtime
819
+ // ne produrrebbe un numero che il budget non conosce — cioè il frame torna a
820
+ // sforare e il bug si riapre da questa singola casella di testo.
821
+ const lines = wrapLines(detail.description ?? '', detailTextWidth(columns), maxLines);
822
+ 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] }));
741
823
  }
742
824
  const cwd = process.cwd();
743
825
  render(_jsx(Deck, { cwd: cwd, tasksPath: resolveTasksPath(cwd), tasksDir: resolveTasksDir(cwd) }));
@@ -0,0 +1,153 @@
1
+ // Budget d'altezza del frame — il deck non deve MAI renderizzare più righe di
2
+ // quante ne ha il terminale.
3
+ //
4
+ // Perché: quando `outputHeight >= stdout.rows`, Ink (ink/build/ink.js:121)
5
+ // abbandona il diff incrementale e passa a `clearTerminal + write` a ogni
6
+ // frame. Su VTE/Ptyxis `clearTerminal` non distrugge il contenuto: lo spinge
7
+ // nello scrollback. Col poll del deck che ridisegna ogni POLL_MS, ogni tick
8
+ // deposita un frame intero nella cronologia — da lì le righe-fantasma di
9
+ // bordi vuoti sopra l'header.
10
+ //
11
+ // Il fix non è cosmetico: è tenere il frame sotto `rows`, sempre. Tutto ciò
12
+ // che è a lunghezza variabile (lista task, lista sessioni, descrizione del
13
+ // dettaglio) passa da qui e riceve una capienza in righe.
14
+ //
15
+ // Logica pura, zero React/Ink: testabile senza pseudo-terminale.
16
+ /** Righe lasciate libere sotto il frame. La condizione di Ink è `>=`, quindi
17
+ * basterebbe 1; ne teniamo 1 come margine per l'a-capo del cursore. */
18
+ export const SLACK = 1;
19
+ /** Sotto questa soglia la lista task non è più utilizzabile: meglio sacrificare
20
+ * il pannello di dettaglio che ridurre la lista a due righe. */
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. */
25
+ const TASKS_PANE_CHROME = 5; // 2 bordi + header "Tasks (n)" + riga sort + riga spot
26
+ const SESSIONS_PANE_CHROME = 3; // 2 bordi + header "Sessions · …"
27
+ const DETAIL_CHROME = 3; // marginTop + 2 bordi
28
+ /** Altezza di ciascuna modale, marginTop incluso. In flusso, non in overlay:
29
+ * spingono giù i pane, quindi il loro costo va scalato dal budget. */
30
+ export const MODAL_HEIGHT = {
31
+ normal: 0,
32
+ create: 4, // marginTop + 2 bordi + 1 riga input
33
+ sort: 5, // marginTop + 2 bordi + titolo + 1 riga catena
34
+ filter: 6, // marginTop + 2 bordi + titolo + 2 righe (pri, stato)
35
+ edit: 8, // marginTop + 2 bordi + titolo + 3 campi + riga anteprima
36
+ };
37
+ /**
38
+ * Distribuisce le righe disponibili fra lista task, lista sessioni e dettaglio.
39
+ *
40
+ * I due pane stanno affiancati (flexDirection row) → l'altezza del blocco è il
41
+ * MAX delle due colonne, non la somma: ognuna riceve lo stesso tetto.
42
+ *
43
+ * Ordine di sacrificio quando lo spazio stringe:
44
+ * 1. righe di descrizione del dettaglio (fino a sparire col pannello),
45
+ * 2. righe della lista task, mai sotto MIN_TASK_ROWS finché il dettaglio c'è.
46
+ */
47
+ export function layoutBudget(input) {
48
+ const outerChrome = 2 + // bordi del box esterno
49
+ 1 + // titolo "loom-deck"
50
+ 1 + // riga navigazione
51
+ (input.launchLine ? 1 : 0) +
52
+ MODAL_HEIGHT[input.mode] +
53
+ 1 + // marginTop del blocco pane
54
+ (input.noteLine ? 1 : 0);
55
+ // Tetto per colonna: righe che restano ai due pane affiancati.
56
+ const avail = (input.rows || 24) - SLACK - outerChrome;
57
+ // La cornice del pane task (bordi + 3 header) più ALMENO una riga di lista.
58
+ // Il `+1` non è cosmetico: senza, un terminale bassissimo produce un pane
59
+ // regolamentare con zero task dentro — occupa 5 righe per non mostrare nulla,
60
+ // mentre la riga compatta dice le stesse cose in una.
61
+ if (avail < TASKS_PANE_CHROME + 1) {
62
+ return { taskRows: 0, sessionRows: 0, detailLines: 0, compact: true };
63
+ }
64
+ const sessionRows = Math.max(0, avail - SESSIONS_PANE_CHROME);
65
+ let detailLines = 0;
66
+ let detailChrome = 0;
67
+ if (input.hasDetail) {
68
+ const fixed = DETAIL_CHROME + input.detailMetaLines;
69
+ // Righe che avanzano dopo aver garantito la lista minima e la cornice del
70
+ // dettaglio. Serve almeno 1 riga di descrizione per giustificare il box:
71
+ // un pannello con la sola cornice ruberebbe 3+ righe per mostrare nulla.
72
+ const spare = avail - TASKS_PANE_CHROME - MIN_TASK_ROWS - fixed;
73
+ if (spare >= 1) {
74
+ detailChrome = fixed;
75
+ detailLines = Math.min(MAX_DETAIL_LINES, spare);
76
+ }
77
+ }
78
+ const taskRows = Math.max(0, avail - TASKS_PANE_CHROME - detailChrome - detailLines);
79
+ return { taskRows, sessionRows, detailLines, compact: false };
80
+ }
81
+ /**
82
+ * Finestra scorrevole su una lista più lunga della capienza.
83
+ *
84
+ * Centra la selezione, poi clampa ai bordi — così in cima e in fondo alla lista
85
+ * la finestra non spreca righe fuori dai dati.
86
+ *
87
+ * `selected` è l'indice nella lista completa; -1 (o fuori range) = nessuna
88
+ * selezione, la finestra parte da capo.
89
+ */
90
+ export function windowRange(total, selected, capacity) {
91
+ if (capacity <= 0 || total <= 0)
92
+ return { start: 0, end: 0 };
93
+ if (total <= capacity)
94
+ return { start: 0, end: total };
95
+ const sel = selected >= 0 && selected < total ? selected : 0;
96
+ const start = Math.max(0, Math.min(sel - Math.floor(capacity / 2), total - capacity));
97
+ return { start, end: start + capacity };
98
+ }
99
+ /**
100
+ * Hard-wrap a larghezza fissa, con tetto di righe.
101
+ *
102
+ * Serve un conteggio righe DETERMINISTICO: `<Text wrap="wrap">` di Ink wrappa a
103
+ * runtime su una larghezza che il budget non conosce, quindi il pannello
104
+ * dettaglio potrebbe sforare il tetto e riaprire il bug. Qui il testo viene
105
+ * spezzato prima, e ogni riga è renderizzata con `wrap="truncate-end"`.
106
+ *
107
+ * Sottostimare `width` è sicuro (tronca prima), sovrastimarlo no (la riga
108
+ * andrebbe a capo aggiungendo altezza non contabilizzata).
109
+ */
110
+ export function wrapLines(text, width, maxLines) {
111
+ if (maxLines <= 0 || width <= 0)
112
+ return [];
113
+ const flat = text.replace(/\s+/g, ' ').trim();
114
+ if (!flat)
115
+ return [];
116
+ const lines = [];
117
+ let line = '';
118
+ for (const word of flat.split(' ')) {
119
+ if (!line) {
120
+ line = word;
121
+ }
122
+ else if (line.length + 1 + word.length <= width) {
123
+ line += ' ' + word;
124
+ }
125
+ else {
126
+ lines.push(line);
127
+ line = word;
128
+ if (lines.length === maxLines)
129
+ break;
130
+ }
131
+ // Parola più lunga della riga: spezzala a forza, altrimenti l'a-capo lo
132
+ // farebbe il terminale — fuori dal nostro conteggio.
133
+ while (line.length > width) {
134
+ lines.push(line.slice(0, width));
135
+ line = line.slice(width);
136
+ if (lines.length === maxLines)
137
+ break;
138
+ }
139
+ if (lines.length === maxLines)
140
+ break;
141
+ }
142
+ if (line && lines.length < maxLines)
143
+ lines.push(line);
144
+ const kept = lines.slice(0, maxLines);
145
+ // Troncamento MAI silenzioso, come le liste: l'ellissi segnala che il testo
146
+ // continua oltre il pannello.
147
+ const consumed = kept.join(' ').length;
148
+ if (consumed < flat.length && kept.length > 0) {
149
+ const last = kept[kept.length - 1];
150
+ kept[kept.length - 1] = last.length >= width ? last.slice(0, Math.max(0, width - 1)) + '…' : last + '…';
151
+ }
152
+ return kept;
153
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
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": {