@lamemind/loom-deck 0.17.0 → 0.18.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
@@ -13,7 +13,8 @@ import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions
13
13
  import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
14
14
  import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, rowLabel, selectedSession, stripProjectCore, } from './session-list.js';
15
15
  import { launchLegend, loadIdentity, loadLaunch } from './config.js';
16
- import { isCompact, layoutBudget, normalizeEmoji, readerCapacity, searchListCapacity, searchPreviewCapacity, truncate, windowRange, wrapLines, wrapWithOffsets, } from './viewport.js';
16
+ import { isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
17
+ import { cut, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
17
18
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
18
19
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
19
20
  import { loadView, saveView, viewFilePath } from './view-store.js';
@@ -339,41 +340,23 @@ function useTaskDetail(tasksDir, id) {
339
340
  }, [tasksDir, id]);
340
341
  return detail;
341
342
  }
342
- // Normalizzazione larghezza glifi `normalizeEmoji` in viewport.ts.
343
- //
344
- // Sostituisce il precedente `forceEmojiWidth`, che aveva l'intuizione giusta
345
- // (timbrare il VS16) ma due limiti che lasciavano passare il difetto:
346
- // - agiva solo su stringhe di UN codepoint, quindi non toccava mai un testo
347
- // composto — la riga della legenda launch, una descrizione task con emoji
348
- // dentro, il titolo di una sessione;
349
- // - decideva per intervallo di codepoint invece che per larghezza misurata,
350
- // quindi avrebbe timbrato anche `↓` `↑` `−` (larghi 1) se gli fossero
351
- // arrivati da soli, accorciando la riga invece di allargarla.
352
- const forceEmojiWidth = normalizeEmoji;
353
- // Glifi LETTERALI del JSX che ricadono nella classe mal misurata (BMP largo 2
354
- // senza VS16). I dati passano dai loader, questi no: normalizzati una volta
355
- // qui, così nessun sito di render li scrive nudi. `↳ ○ ▸ ⏎ · − ↑ ↓` sono
356
- // larghi 1 e restano intatti.
357
- const CARET = normalizeEmoji('▶ ');
343
+ // Glifi LETTERALI del JSX. I dati passano dai loader, che sanificano al
344
+ // confine; questi no — quindi passano da `sanitize` una volta qui, così nessun
345
+ // sito di render scrive un glifo nudo. `↳ ○ ▸ ⏎ · − ↑ ↓` sono già concordi e
346
+ // restano intatti; `▶` e `⚠` sono discordi e vengono sostituiti (`width.ts`).
347
+ const CARET = sanitize('▶ ');
358
348
  const CARET_OFF = ' ';
359
- // T50 glifo warning della riga pin-stale: BMP largo 2 senza VS16 → va timbrato
360
- // (come CARET), altrimenti la riga slitta di una colonna.
361
- const WARN = normalizeEmoji('⚠');
349
+ const WARN = sanitize('⚠');
362
350
  // T50 — separatore leggero fra blocco pinnate e contestuali. `─` (box-drawing) è
363
- // largo 1 sia per string-width sia per il terminale → nessun timbro. Corto +
351
+ // largo 1 sia per string-width sia per il terminale. Corto +
364
352
  // wrap="truncate-end" così non va mai a capo nel pane al 50%.
365
353
  const SESSION_SEP = '─'.repeat(16);
366
- // Normalizza il marker Done per il display. `✔` (U+2714) è text-presentation-
367
- // default: string-width quindi Ink, sia per il layout sia per il troncamento —
368
- // lo misura 2 (rispetta il VS16 di `✔️`), ma VTE/Ptyxis lo disegna largo 1
369
- // (ignora il VS16). Risultato: le righe Done finivano 1 colonna più strette del
370
- // riservato → la coda della riga (bordo destro del pane incluso) slittava a
371
- // sinistra, sfasando il layout solo su quelle righe. `✅` (U+2705) è invece
372
- // emoji-presentation-default → largo 2 sia per string-width sia per il terminale,
373
- // come 🔵/🟡: colonna Prog allineata. Cambia SOLO il display: `task.prog` resta
374
- // `✔️` così `isDone()` continua a matchare.
354
+ // Marker Done per il DISPLAY. `task.prog` resta il `✔️` letto da tasks.md —
355
+ // `isDone()` e le lookup di `view.ts` ci confrontano sopra, e `task-edit` lo
356
+ // riscrive sul file: è una chiave semantica, non testo. Qui `sanitize` lo
357
+ // traduce nel suo gemello concorde (`✅`) solo per finire nel frame.
375
358
  function displayProg(prog) {
376
- return forceEmojiWidth(prog.replace(/✔️?/g, '✅'));
359
+ return sanitize(prog);
377
360
  }
378
361
  // T49 — size umana compatta per il detail pane sessione.
379
362
  function fmtSize(bytes) {
@@ -581,7 +564,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
581
564
  }
582
565
  const sid = randomUUID();
583
566
  const beforeIds = new Set(tasks.map((t) => t.id));
584
- setNote(`⏳ creando task… "${truncate(text, 40)}" (sid ${sid.slice(0, 8)})`);
567
+ setNote(`⏳ creando task… "${cut(text, 40)}" (sid ${sid.slice(0, 8)})`);
585
568
  const child = spawnCreateTask(text, cwd, sid, (ok) => {
586
569
  if (!ok) {
587
570
  setNote(`⚠ create-task fallito (${CLAUDE_CMD} -p)`);
@@ -633,7 +616,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
633
616
  appendNote(cwd, sid, text);
634
617
  reloadSessions();
635
618
  setNote(text
636
- ? `✎ nota su ${sid.slice(0, 8)}: "${truncate(text, 40)}"`
619
+ ? `✎ nota su ${sid.slice(0, 8)}: "${cut(text, 40)}"`
637
620
  : `✎ nota rimossa da ${sid.slice(0, 8)}`);
638
621
  }
639
622
  // T41 — apertura dell'edit: la bozza parte dai valori ATTUALI della task, non
@@ -1302,7 +1285,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1302
1285
  if (budget.compact) {
1303
1286
  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"] })] }));
1304
1287
  }
1305
- 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 \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : canResume ? 'resume' : '—', canResume ? ' · f fork' : '', canPin ? ' · p pin · N nota' : '', " \u00B7 ^F cerca \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 === '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 })) : 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, 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: normalizeEmoji(note) }) : null] }));
1288
+ 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 \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : canResume ? 'resume' : '—', canResume ? ' · f fork' : '', canPin ? ' · p pin · N nota' : '', " \u00B7 ^F cerca \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 === '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 })) : 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, 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] }));
1306
1289
  }
1307
1290
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
1308
1291
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -1321,7 +1304,7 @@ function FilterModal({ view, cursor }) {
1321
1304
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "F \u203A filtri" }), rows.map((row, r) => (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: row.label }), row.entries.map((e, c) => {
1322
1305
  const on = !row.hidden.has(e.name);
1323
1306
  const here = cursor.row === r && cursor.col === c;
1324
- return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ", forceEmojiWidth(e.glyph)] }, e.name));
1307
+ return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ", sanitize(e.glyph)] }, e.name));
1325
1308
  })] }, row.label)))] }));
1326
1309
  }
1327
1310
  // T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
@@ -1330,12 +1313,12 @@ function FilterModal({ view, cursor }) {
1330
1313
  // del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
1331
1314
  function EditModal({ id, draft, row }) {
1332
1315
  const mark = (r) => (row === r ? CARET : CARET_OFF);
1333
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 priorit\u00E0 e stato"] }), _jsxs(Text, { children: [mark(0), _jsx(Text, { dimColor: true, children: "pri " }), EDIT_PRI.map((p) => (_jsxs(Text, { inverse: draft.pri === p, color: draft.pri === p ? 'green' : 'gray', children: [' ', forceEmojiWidth(PRI_GLYPH[p]), " ", PRI_LABEL[p]] }, p)))] }), _jsxs(Text, { children: [mark(1), _jsx(Text, { dimColor: true, children: "stato" }), EDIT_PROG.map((p) => (_jsxs(Text, { inverse: draft.prog === p, color: draft.prog === p ? 'green' : 'gray', children: [' ', forceEmojiWidth(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(Text, { dimColor: true, children: "prog " }), _jsxs(Text, { children: [' ', draft.detail] }), row === 2 ? _jsx(Text, { inverse: true, children: " " }) : null, !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", normalizeEmoji(progressText(draft.prog, draft.detail))] })] }));
1316
+ 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: [' ', sanitize(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: [' ', sanitize(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(Text, { dimColor: true, children: "prog " }), _jsxs(Text, { children: [' ', draft.detail] }), row === 2 ? _jsx(Text, { inverse: true, children: " " }) : null, !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", sanitize(progressText(draft.prog, draft.detail))] })] }));
1334
1317
  }
1335
1318
  // T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
1336
1319
  // caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
1337
1320
  // allinearsi, e i glifi BMP larghi 2 sono proprio la classe che Ink e il
1338
- // terminale misurano diversamente (vedi normalizeEmoji).
1321
+ // terminale misurano diversamente (vedi width.ts).
1339
1322
  const KIND_TAG = { ai: 'ai', tool: 'tl', human: 'hu' };
1340
1323
  const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
1341
1324
  /**
@@ -1351,12 +1334,12 @@ const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
1351
1334
  * 4 box lista (2 bordi + 2 padding)
1352
1335
  * 2 caret
1353
1336
  * 4 indice del record
1354
- * 3 spazio + tag del kind + spazio
1337
+ * 4 spazio + tag del kind (2 caratteri) + spazio
1355
1338
  * Prudente per costruzione: sottostimare tronca un carattere in più,
1356
1339
  * sovrastimare manderebbe la riga a capo e sfonderebbe il budget d'altezza.
1357
1340
  */
1358
1341
  function searchExcerptWidth(columns) {
1359
- return Math.max(30, (columns || 80) - 17);
1342
+ return Math.max(30, (columns || 80) - 18);
1360
1343
  }
1361
1344
  /** Titolo della conversazione sulla riga-gruppo: prende ciò che avanza dopo le
1362
1345
  * colonne a larghezza fissa (caret, pin, hash, task, conteggio, data). */
@@ -1421,17 +1404,18 @@ function SearchScreen({ preview, hash, query, field, opts, result, rows, selecte
1421
1404
  const s = row.session;
1422
1405
  const bound = bindings.get(s.sessionId);
1423
1406
  const rowNote = sessionNotes.get(s.sessionId);
1424
- const noteShown = rowNote ? truncate(rowNote, 24) : '';
1407
+ const noteShown = rowNote ? cut(rowNote, 24) : '';
1425
1408
  // `+3` = i due caporali e lo spazio che li separa dall'etichetta.
1426
1409
  // Il pavimento non è cosmetico: senza, un terminale stretto manda
1427
- // l'argomento di `truncate` sotto zero e `slice` conta dalla FINE
1428
- // della stringa mostrerebbe la coda del titolo, in silenzio.
1429
- const restWidth = Math.max(8, searchTitleWidth(columns) - (noteShown ? noteShown.length + 3 : 0));
1430
- return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), noteShown ? _jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", noteShown, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(noteShown), children: truncate(conversationLabel(s, projectCore, bound), restWidth) }), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
1410
+ // l'argomento di `cut` sotto zero, cioè un budget negativo.
1411
+ // La nota si misura con `termWidth`, non con `.length`: contiene
1412
+ // testo umano, emoji compresi.
1413
+ const restWidth = Math.max(8, searchTitleWidth(columns) - (noteShown ? termWidth(noteShown) + 3 : 0));
1414
+ return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), noteShown ? _jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", noteShown, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(noteShown), children: cut(conversationLabel(s, projectCore, bound), restWidth) }), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
1431
1415
  }
1432
1416
  const h = row.hit;
1433
- return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: String(h.idx).padStart(4) }), ' ', _jsx(Text, { color: KIND_COLOR[h.kind], children: KIND_TAG[h.kind] }), " ", h.excerpt] }, row.key));
1434
- })] }), preview ? _jsx(SearchPreviewPane, { p: preview }) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
1417
+ return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: String(h.idx).padStart(4) }), ' ', _jsx(Text, { color: KIND_COLOR[h.kind], children: KIND_TAG[h.kind] }), ' ', cut(h.excerpt, searchExcerptWidth(columns))] }, row.key));
1418
+ })] }), preview ? _jsx(SearchPreviewPane, { p: preview }) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1435
1419
  }
1436
1420
  /**
1437
1421
  * Anteprima dell'occorrenza selezionata, sotto la lista.
@@ -1478,13 +1462,22 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
1478
1462
  ...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
1479
1463
  ...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
1480
1464
  ]
1481
- .map((e) => `−${normalizeEmoji(e.glyph)}`)
1465
+ .map((e) => `−${sanitize(e.glyph)}`)
1482
1466
  .join(' ')] })) : null] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : (tasks.map((task, i) => {
1483
1467
  // windowStart riporta l'indice di finestra a quello della lista
1484
1468
  // completa, su cui è keyata la selezione. +1: lo 0 è spot.
1485
1469
  const sel = windowStart + i + 1 === selected;
1486
1470
  const n = childCount.get(task.id) ?? 0;
1487
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", forceEmojiWidth(task.pri), " ", displayProg(task.prog), " ", task.desc, n > 0 ? ` (${n})` : ''] }, task.id));
1471
+ // Invariante ③: la descrizione è l'unico pezzo a lunghezza libera, e
1472
+ // si taglia QUI sul budget che resta dopo le colonne fisse. Lasciarlo
1473
+ // fare a `truncate-end` significa passare da `cli-truncate`, che
1474
+ // restituisce una riga più larga del pane (una colonna per emoji) e
1475
+ // quindi scrive sopra il bordo. Le parti fisse si misurano con
1476
+ // `termWidth`: `task.id` è `T9` o `T52`, i due glifi valgono 2 ciascuno.
1477
+ const head = `${CARET_OFF}${task.id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
1478
+ const tail = n > 0 ? ` (${n})` : '';
1479
+ const desc = cut(task.desc, Math.max(4, paneTextWidth(columns) - termWidth(head) - termWidth(tail)));
1480
+ 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));
1488
1481
  })), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
1489
1482
  }
1490
1483
  function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, sessionNotes, projectCore, }) {
@@ -1498,7 +1491,7 @@ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, s
1498
1491
  // T50 — pin stale: transcript sparito, nessuna Session da mostrare.
1499
1492
  // Riga navigabile e spinnabile (`p`), marcata, mai un crash.
1500
1493
  if (row.kind === 'pinned' && row.stale) {
1501
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, 8) }), sessionNotes.get(row.sessionId) ? (_jsxs(Text, { color: "yellow", children: [" \u00AB", truncate(sessionNotes.get(row.sessionId), 30), "\u00BB"] })) : null] }, row.sessionId));
1494
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, 8) }), sessionNotes.get(row.sessionId) ? (_jsxs(Text, { color: "yellow", children: [" \u00AB", cut(sessionNotes.get(row.sessionId), 30), "\u00BB"] })) : null] }, row.sessionId));
1502
1495
  }
1503
1496
  const s = row.session; // non-stale → session presente
1504
1497
  const isPinnedRow = row.kind === 'pinned';
@@ -1508,7 +1501,17 @@ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, s
1508
1501
  const forked = forkOf.has(s.sessionId);
1509
1502
  // T53 — con una nota il prefisso di progetto sparisce e le sue colonne
1510
1503
  // passano alla nota; senza, il titolo resta com'è (vedi `rowLabel`).
1511
- const label = rowLabel(s.title, sessionNotes.get(s.sessionId), projectCore, forked ? 42 : 44);
1504
+ //
1505
+ // Invariante ③: il budget è DERIVATO da `columns`, non inchiodato.
1506
+ // Con un valore fisso (erano 44) su un terminale stretto la riga
1507
+ // superava il pane, e a troncarla finiva `cli-truncate` — che sfora
1508
+ // di una colonna per emoji e si mangia il bordo destro.
1509
+ const meta = ` · ${s.gitBranch || '-'} · ${relTime(s.ts)}`;
1510
+ const labelBudget = Math.max(12, paneTextWidth(columns) -
1511
+ (2 /* caret */ + 2 /* icona */ + 1 /* spazio */ + (forked ? 2 : 0)) -
1512
+ termWidth(meta) -
1513
+ 1 /* spazio prima del meta */);
1514
+ const label = rowLabel(s.title, sessionNotes.get(s.sessionId), projectCore, labelBudget);
1512
1515
  return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" })) : isSpot ? (_jsx(Text, { dimColor: true, children: "\u25CB" })) : (_jsx(Text, { color: "green", children: "\uD83D\uDD17" })), ' ', forked ? _jsx(Text, { color: "magenta", children: "\u2442 " }) : 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, ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
1513
1516
  })), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null, note: sessionNotes.get(detail.sessionId) ?? '' })) : null] }));
1514
1517
  }
@@ -1549,6 +1552,20 @@ function detailMetaOf(detail) {
1549
1552
  function detailTextWidth(columns) {
1550
1553
  return Math.max(10, Math.floor(((columns || 80) - 4) / 2) - 9);
1551
1554
  }
1555
+ /**
1556
+ * Larghezza del TESTO dentro un pane al 50%: box esterno (2 bordi + 2 padding)
1557
+ * → metà → bordo + padding del pane.
1558
+ *
1559
+ * Invariante ③ (`width.ts`): chi renderizza una riga a lunghezza libera la
1560
+ * taglia PRIMA con questa larghezza. Delegarlo a `wrap="truncate-end"`
1561
+ * significa passare da `cli-truncate`, che indicizza per code point e restituisce
1562
+ * una riga più larga di quella chiesta — una colonna per ogni emoji astrale a
1563
+ * sinistra del taglio. Quelle colonne finiscono sopra il bordo del pane, che
1564
+ * sparisce dalla riga: è la sminchiatura visibile a schermo.
1565
+ */
1566
+ function paneTextWidth(columns) {
1567
+ return Math.max(20, Math.floor(((columns || 80) - 4) / 2) - 4);
1568
+ }
1552
1569
  function DetailPane({ detail, maxLines, columns, }) {
1553
1570
  const { meta, commit } = detailMetaOf(detail);
1554
1571
  // Wrap calcolato qui, non delegato a `<Text wrap="wrap">`: il budget ha
package/dist/config.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // indice 1..9. Il `command` gira con cwd = project root.
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
- import { normalizeEmoji } from './viewport.js';
8
+ import { sanitize } from './width.js';
9
9
  export function configFilePath(projectRoot) {
10
10
  return join(projectRoot, '.claude', 'loom-works.json');
11
11
  }
@@ -25,10 +25,10 @@ export function parseLaunch(raw) {
25
25
  out.push({
26
26
  // L'emoji arriva dal file config: testo arbitrario, quindi va normalizzato
27
27
  // come ogni altro dato esterno — `☕` senza VS16 allargherebbe di una cella
28
- // la riga della legenda, mandandola a capo (vedi normalizeEmoji).
29
- emoji: normalizeEmoji(typeof emoji === 'string' ? emoji : '▸'),
28
+ // la riga della legenda, mandandola a capo (vedi width.ts).
29
+ emoji: sanitize(typeof emoji === 'string' ? emoji : '▸'),
30
30
  // `label` è opzionale per contratto → fallback sul comando stesso.
31
- label: normalizeEmoji(typeof label === 'string' && label ? label : command),
31
+ label: sanitize(typeof label === 'string' && label ? label : command),
32
32
  command,
33
33
  });
34
34
  }
@@ -14,7 +14,7 @@
14
14
  // - Trap T39: la selezione è KEYED SU sessionId, non su indice posizionale. La
15
15
  // lista è una vista trasformata (due gruppi + separatore): un indice grezzo
16
16
  // identificherebbe la riga sbagliata dopo un pin/cambio-contesto.
17
- import { truncate } from './viewport.js';
17
+ import { cut, termWidth } from './width.js';
18
18
  // ── T53 · etichetta di riga ────────────────────────────────────────────────
19
19
  /**
20
20
  * Toglie dal titolo il core `<owner> <name>` del progetto e la punteggiatura di
@@ -62,19 +62,22 @@ const MIN_NOTE = 14;
62
62
  */
63
63
  export function rowLabel(title, note, core, budget) {
64
64
  if (!note)
65
- return { note: '', rest: truncate(title, Math.max(0, budget)) };
65
+ return { note: '', rest: cut(title, Math.max(0, budget)) };
66
66
  // 2 colonne per i caporali « », 1 per lo spazio prima del residuo.
67
67
  const rest = stripProjectCore(title, core);
68
68
  const noteBudget = Math.max(0, budget - 2);
69
69
  if (!rest)
70
- return { note: truncate(note, noteBudget), rest: '' };
70
+ return { note: cut(note, noteBudget), rest: '' };
71
71
  // Il `min` col budget totale non è ridondante: su un pane strettissimo
72
72
  // `MIN_NOTE` supererebbe le colonne disponibili e la riga andrebbe a capo —
73
73
  // che è precisamente il difetto che ogni larghezza qui dentro esiste per
74
74
  // evitare (una riga wrappata sfonda il budget d'ALTEZZA, non solo l'estetica).
75
- const shownNote = truncate(note, Math.min(noteBudget, Math.max(MIN_NOTE, noteBudget - MIN_REST - 1)));
76
- const restBudget = noteBudget - shownNote.length - 1;
77
- return { note: shownNote, rest: restBudget >= MIN_REST ? truncate(rest, restBudget) : '' };
75
+ const shownNote = cut(note, Math.min(noteBudget, Math.max(MIN_NOTE, noteBudget - MIN_REST - 1)));
76
+ // `termWidth`, non `.length`: quanto RESTA si misura in colonne come tutto il
77
+ // resto: una nota con un emoji conta 2 sullo schermo e 1 (o 2) code unit, e
78
+ // sottrarre le une dalle altre rimetterebbe la riga fuori dal pane.
79
+ const restBudget = noteBudget - termWidth(shownNote) - 1;
80
+ return { note: shownNote, rest: restBudget >= MIN_REST ? cut(rest, restBudget) : '' };
78
81
  }
79
82
  export function assembleSessionList(childSessions, allSessions, pinned, maxContext) {
80
83
  // Le pinnate si risolvono contro TUTTE le sessioni del progetto, non solo le
package/dist/sessions.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, readdirSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, join } from 'node:path';
4
- import { normalizeEmoji } from './viewport.js';
4
+ import { sanitize } from './width.js';
5
5
  // CC codifica la project dir sostituendo ogni char non-alfanumerico con '-'
6
6
  // (verificato: `/home/lamemind/cc-host` → `-home-lamemind-cc-host`). È LOSSY
7
7
  // (un '-' nel path e un '/' collassano nello stesso char) → il forward è
@@ -122,7 +122,12 @@ function collectBodies(message, idx, isAssistant, out) {
122
122
  }
123
123
  }
124
124
  for (const kind of ['ai', 'tool', 'human']) {
125
- const text = parts[kind].join('\n');
125
+ // Sanificato QUI, prima che la ricerca ci giri sopra: gli offset del match
126
+ // (`matchStart`/`matchEnd`) indicizzano questo testo, e sanificare a valle —
127
+ // al render — li sposterebbe, evidenziando la parte sbagliata della riga.
128
+ // Un corpo di transcript è la sorgente più ostile che il deck legge: emoji
129
+ // qualsiasi, output di tool con byte di controllo, CJK.
130
+ const text = sanitize(parts[kind].join('\n'));
126
131
  if (!text)
127
132
  continue;
128
133
  if (kind === 'human' && isInterrupt(text))
@@ -220,14 +225,21 @@ export function parseTranscript(content, path, mtime, sizeBytes) {
220
225
  cwd,
221
226
  gitBranch,
222
227
  parentUuid,
223
- title: normalizeEmoji(customTitle || firstUserText || '(senza titolo)'),
228
+ title: sanitize(customTitle || firstUserText || '(senza titolo)'),
224
229
  ts: mtime,
225
230
  path,
226
231
  sizeBytes,
227
232
  turns,
228
233
  customTitle,
229
- firstPrompt: firstUserText,
230
- lastReply: lastAssistantText,
234
+ // Sanificati come il titolo, e per lo stesso motivo: le due preview del
235
+ // detail pane sono testo di transcript messo NEL FRAME. Erano l'ultimo
236
+ // varco rimasto — un `✅` (BMP largo 2) nell'estratto prende una cella
237
+ // sola nella griglia di Ink e due colonne sul terminale, quindi la riga
238
+ // scivola a destra e si mangia il bordo del pane. Qui il confine è lo
239
+ // stesso di `title`, non il render: `wrapLines` conta le colonne su questo
240
+ // testo, sanificare a valle sposterebbe l'a-capo già calcolato.
241
+ firstPrompt: sanitize(firstUserText),
242
+ lastReply: sanitize(lastAssistantText),
231
243
  bodies,
232
244
  };
233
245
  }
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
+ import { sanitize } from './width.js';
3
4
  export function taskIndexPath(projectRoot) {
4
5
  return join(projectRoot, '.claude', 'loom', 'session-tasks.jsonl');
5
6
  }
@@ -60,8 +61,14 @@ export function loadSessionIndex(projectRoot) {
60
61
  // nota vuota da mostrare. Il `typeof` esclude i record senza il campo, che
61
62
  // non devono toccare una nota scritta da un record precedente.
62
63
  if (typeof d.note === 'string') {
64
+ // Sanificata in lettura, non solo alla digitazione: `sanitizeTyped`
65
+ // toglie i byte di controllo ma non ripara la larghezza, e il file è
66
+ // editabile a mano — una nota con un `✅` finirebbe nel frame larga il
67
+ // doppio di quanto Ink ha contato. Il round-trip (edit di una nota già
68
+ // sanificata → riscrittura del sostituto) è il prezzo accettato: a
69
+ // schermo il glifo originale non era comunque disegnabile.
63
70
  if (d.note)
64
- notes.set(d.sessionId, d.note);
71
+ notes.set(d.sessionId, sanitize(d.note));
65
72
  else
66
73
  notes.delete(d.sessionId);
67
74
  }
package/dist/tasks.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, readdirSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
- import { normalizeEmoji } from './viewport.js';
3
+ import { sanitize } from './width.js';
4
4
  /**
5
5
  * Forma di un ID task: `T` (code) o `D` (doc) + numero. FONTE UNICA del gate —
6
6
  * `parseTasks` lo usa per scartare header/separatore della tabella, `task-edit`
@@ -42,16 +42,17 @@ export function parseTasks(content) {
42
42
  const prog = cells[4] ?? '';
43
43
  // desc = colonna finale; join per resistere a eventuali `|` nella descrizione.
44
44
  const desc = cells.slice(5, -1).join('|').trim();
45
- // Normalizzazione larghezza glifi AL CONFINE: tutto ciò che entra da
46
- // tasks.md è testo arbitrario e può contenere emoji BMP che Ink misura una
47
- // cella in meno del terminale (vedi normalizeEmoji). Farlo qui invece che a
48
- // ogni sito di render è ciò che impedisce di dimenticarne uno.
49
- tasks.push({
50
- id,
51
- pri: normalizeEmoji(pri),
52
- prog: normalizeEmoji(prog),
53
- desc: normalizeEmoji(desc),
54
- });
45
+ // Sanificazione AL CONFINE: tutto ciò che entra da tasks.md è testo
46
+ // arbitrario e può contenere glifi che Ink e il terminale misurano in modo
47
+ // diverso (vedi `width.ts`). Farlo qui invece che a ogni sito di render è
48
+ // ciò che impedisce di dimenticarne uno.
49
+ //
50
+ // `pri` e `prog` sono l'eccezione: NON si sanificano qui perché sono chiavi
51
+ // SEMANTICHE, non testo — `isDone()` e le lookup di `view.ts` confrontano
52
+ // il glifo, e `task-edit` lo riscrive in tasks.md. Sanificarli qui
53
+ // renderebbe `✔` un `✅` anche su disco, cambiando il formato di famiglia.
54
+ // La sanificazione di quei due avviene al display (`displayProg`).
55
+ tasks.push({ id, pri, prog, desc: sanitize(desc) });
55
56
  }
56
57
  return tasks;
57
58
  }
@@ -112,12 +113,12 @@ export function parseTaskDetail(id, content) {
112
113
  // allarga la riga oltre il bordo del pane.
113
114
  const normFields = {};
114
115
  for (const [k, v] of Object.entries(fields))
115
- normFields[k] = normalizeEmoji(v);
116
+ normFields[k] = sanitize(v);
116
117
  return {
117
118
  id,
118
- title: normalizeEmoji(title),
119
+ title: sanitize(title),
119
120
  fields: normFields,
120
- description: normalizeEmoji(descLines.join('\n').trim()),
121
+ description: sanitize(descLines.join('\n').trim()),
121
122
  };
122
123
  }
123
124
  export function loadTaskDetail(dir, id) {
package/dist/viewport.js CHANGED
@@ -13,62 +13,6 @@
13
13
  // dettaglio) passa da qui e riceve una capienza in righe.
14
14
  //
15
15
  // Logica pura, zero React/Ink: testabile senza pseudo-terminale.
16
- import stringWidth from 'string-width';
17
- /** Variation Selector-16: forza la presentazione emoji del carattere che precede. */
18
- const VS16 = '️';
19
- /**
20
- * Riallinea la larghezza dei glifi fra Ink e il terminale.
21
- *
22
- * Secondo meccanismo di sporcamento dello scrollback, indipendente dal budget
23
- * d'altezza. Ink assembla ogni riga su una griglia di CARATTERI: per un emoji
24
- * BMP senza VS16 (`☕` U+2615, `✔` U+2714, `⚡` U+26A1, `✅` U+2705, `▶` U+25B6)
25
- * riserva una sola posizione, mentre il terminale ne disegna due. La riga esce
26
- * larga `columns + 1`, il terminale la manda a capo, e l'altezza reale supera
27
- * quella che Ink crede — senza mai passare dal ramo `clearTerminal`. Con più
28
- * glifi sulla stessa riga l'eccesso si somma.
29
- *
30
- * Il VS16 esplicito fa contare 2 anche a Ink, riallineando le due contabilità.
31
- *
32
- * Il predicato è la LARGHEZZA MISURATA, non l'intervallo di codepoint: nello
33
- * stesso blocco U+2190–U+2BFF vivono anche `↓` `↑` `−`, larghi 1, e timbrarli
34
- * col VS16 li porterebbe a 2 accorciando la riga — l'errore opposto, con lo
35
- * stesso effetto di layout rotto. Gli astrali (U+1F000+) Ink li conta già bene.
36
- *
37
- * Idempotente: un glifo che ha già il VS16 non viene ri-timbrato.
38
- */
39
- export function normalizeEmoji(s) {
40
- if (!s)
41
- return s;
42
- const cps = [...s];
43
- let out = '';
44
- for (let i = 0; i < cps.length; i++) {
45
- const ch = cps[i];
46
- out += ch;
47
- const cp = ch.codePointAt(0);
48
- if (cp < 0x10000 && stringWidth(ch) === 2 && cps[i + 1] !== VS16)
49
- out += VS16;
50
- }
51
- return out;
52
- }
53
- /**
54
- * Collassa gli spazi (description multi-paragrafo → blocco unico) e tronca a
55
- * `n` caratteri, con ellissi quando taglia.
56
- *
57
- * Vive qui e non nel modulo di render perché è misura di larghezza come il
58
- * resto del file, e perché la cascata dell'etichetta di riga (`session-list.ts`)
59
- * ne ha bisogno restando pura — importarla da `cli.tsx` tirerebbe dentro Ink e
60
- * React in un modulo che si testa senza terminale.
61
- */
62
- export function truncate(s, n) {
63
- // Budget non positivo → stringa vuota, e va detto ESPLICITAMENTE: senza questa
64
- // guardia `n = 0` produce `slice(0, -1)`, e un indice negativo in JS conta
65
- // dalla FINE — restituirebbe quasi tutta la stringa invece di niente, cioè
66
- // l'opposto del troncamento, in silenzio e proprio quando lo spazio manca.
67
- if (n <= 0)
68
- return '';
69
- const flat = s.replace(/\s+/g, ' ').trim();
70
- return flat.length > n ? flat.slice(0, n - 1).trimEnd() + '…' : flat;
71
- }
72
16
  /** Righe lasciate libere sotto il frame. La condizione di Ink è `>=`, quindi
73
17
  * basterebbe 1; ne teniamo 1 come margine per l'a-capo del cursore. */
74
18
  export const SLACK = 1;
@@ -252,53 +196,6 @@ export function searchPreviewCapacity(capacity, listRows) {
252
196
  export function isCompact(capacity) {
253
197
  return capacity < 1;
254
198
  }
255
- /**
256
- * A-capo che PRESERVA la struttura del testo e traccia gli offset.
257
- *
258
- * Distinto da `wrapLines`, che collassa tutto in un flusso unico: lì serviva una
259
- * preview compatta di 2-4 righe, qui si legge un messaggio intero — appiattire
260
- * le newline renderebbe illeggibile qualunque blocco di codice o elenco.
261
- *
262
- * Ogni riga prodotta è una FETTA CONTIGUA del sorgente: è ciò che permette di
263
- * intersecarla con l'intervallo del match e colorare solo la parte giusta,
264
- * anche quando il match cade a cavallo di due righe.
265
- *
266
- * Taglia sull'ultimo spazio disponibile; una parola più larga della riga viene
267
- * spezzata a forza, altrimenti l'a-capo lo farebbe il terminale — fuori dal
268
- * conteggio delle righe, cioè di nuovo un frame più alto di `rows`.
269
- */
270
- export function wrapWithOffsets(text, width) {
271
- const out = [];
272
- if (width <= 0)
273
- return out;
274
- let base = 0;
275
- for (const raw of text.split('\n')) {
276
- // Tab e CR diventano UN singolo spazio, non quattro: la sostituzione deve
277
- // conservare la lunghezza, o gli offset delle righe smettono di indicizzare
278
- // il sorgente e il match verrebbe evidenziato spostato. Un tab lasciato
279
- // passare sarebbe l'errore opposto — lo conteremmo 1 e il terminale 8.
280
- const line = raw.replace(/[\t\r]/g, ' ');
281
- if (line.length === 0) {
282
- out.push({ text: '', start: base, end: base });
283
- }
284
- let pos = 0;
285
- while (pos < line.length) {
286
- if (line.length - pos <= width) {
287
- out.push({ text: line.slice(pos), start: base + pos, end: base + line.length });
288
- break;
289
- }
290
- let brk = line.lastIndexOf(' ', pos + width);
291
- if (brk <= pos)
292
- brk = pos + width; // parola più lunga della riga
293
- out.push({ text: line.slice(pos, brk), start: base + pos, end: base + brk });
294
- pos = brk;
295
- while (line[pos] === ' ')
296
- pos++; // lo spazio del taglio non apre la riga dopo
297
- }
298
- base += raw.length + 1; // +1 = il '\n' consumato dallo split
299
- }
300
- return out;
301
- }
302
199
  /**
303
200
  * Finestra scorrevole su una lista più lunga della capienza.
304
201
  *
@@ -317,58 +214,3 @@ export function windowRange(total, selected, capacity) {
317
214
  const start = Math.max(0, Math.min(sel - Math.floor(capacity / 2), total - capacity));
318
215
  return { start, end: start + capacity };
319
216
  }
320
- /**
321
- * Hard-wrap a larghezza fissa, con tetto di righe.
322
- *
323
- * Serve un conteggio righe DETERMINISTICO: `<Text wrap="wrap">` di Ink wrappa a
324
- * runtime su una larghezza che il budget non conosce, quindi il pannello
325
- * dettaglio potrebbe sforare il tetto e riaprire il bug. Qui il testo viene
326
- * spezzato prima, e ogni riga è renderizzata con `wrap="truncate-end"`.
327
- *
328
- * Sottostimare `width` è sicuro (tronca prima), sovrastimarlo no (la riga
329
- * andrebbe a capo aggiungendo altezza non contabilizzata).
330
- */
331
- export function wrapLines(text, width, maxLines) {
332
- if (maxLines <= 0 || width <= 0)
333
- return [];
334
- const flat = text.replace(/\s+/g, ' ').trim();
335
- if (!flat)
336
- return [];
337
- const lines = [];
338
- let line = '';
339
- for (const word of flat.split(' ')) {
340
- if (!line) {
341
- line = word;
342
- }
343
- else if (line.length + 1 + word.length <= width) {
344
- line += ' ' + word;
345
- }
346
- else {
347
- lines.push(line);
348
- line = word;
349
- if (lines.length === maxLines)
350
- break;
351
- }
352
- // Parola più lunga della riga: spezzala a forza, altrimenti l'a-capo lo
353
- // farebbe il terminale — fuori dal nostro conteggio.
354
- while (line.length > width) {
355
- lines.push(line.slice(0, width));
356
- line = line.slice(width);
357
- if (lines.length === maxLines)
358
- break;
359
- }
360
- if (lines.length === maxLines)
361
- break;
362
- }
363
- if (line && lines.length < maxLines)
364
- lines.push(line);
365
- const kept = lines.slice(0, maxLines);
366
- // Troncamento MAI silenzioso, come le liste: l'ellissi segnala che il testo
367
- // continua oltre il pannello.
368
- const consumed = kept.join(' ').length;
369
- if (consumed < flat.length && kept.length > 0) {
370
- const last = kept[kept.length - 1];
371
- kept[kept.length - 1] = last.length >= width ? last.slice(0, Math.max(0, width - 1)) + '…' : last + '…';
372
- }
373
- return kept;
374
- }
package/dist/width.js ADDED
@@ -0,0 +1,333 @@
1
+ // Contabilità UNICA della larghezza — fonte di verità per «quanto è larga
2
+ // questa stringa sullo schermo».
3
+ //
4
+ // PERCHÉ ESISTE. Nel frame convivevano tre unità di misura diverse, e nessuna
5
+ // era la colonna del terminale:
6
+ //
7
+ // 1. `string-width` — quella con cui Ink misura per il layout (Yoga) e per il
8
+ // troncamento. Conta 2 ogni emoji, VS16 o no.
9
+ // 2. `.length` / `.slice` — code unit UTF-16, usate dai vecchi `truncate` e
10
+ // `wrapLines`. Un emoji astrale (`🔥`) sono 2 unità per 2 colonne (caso
11
+ // fortunato), uno BMP (`⚡`) 1 unità per 2 colonne (sbagliato).
12
+ // 3. `slice-ansi`, dentro `cli-truncate`, dentro `wrap="truncate-end"` di Ink
13
+ // (`ink/build/wrap-text.js`): indicizza per CODE POINT ma riceve un budget
14
+ // di COLONNE. Ogni glifo largo 2 che sta a sinistra del taglio fa uscire
15
+ // la riga 1 colonna più larga del richiesto.
16
+ // 4. VTE/Ptyxis, che disegna: largo 2 solo se East-Asian-Width ∈ {W, F}, con
17
+ // gli Ambiguous a 1 e il VS16 IGNORATO.
18
+ //
19
+ // Una riga più larga del riservato sovrascrive il bordo del pane nella griglia
20
+ // di caratteri di Ink (bordo mangiato = layout sminchiato) e, quando il taglio
21
+ // cade su un glifo largo, fa crescere la riga composta oltre `columns`: il
22
+ // terminale la manda a capo, compare una riga vuota e tutto ciò che sta sotto
23
+ // slitta di una riga. Una riga più STRETTA del riservato sposta a sinistra
24
+ // bordo e pane vicino, sempre e solo sulle righe che contengono il glifo.
25
+ //
26
+ // LE TRE INVARIANTI. Tutto il resto del deck si appoggia a queste:
27
+ //
28
+ // ① Alfabeto concorde — nel frame entrano solo caratteri per cui
29
+ // `stringWidth(ch) === termWidth(ch)`. Il predicato è CALCOLATO, non una
30
+ // lista: qualunque glifo nuovo (in una descrizione, in un transcript) è
31
+ // giudicato dalla stessa regola. I discordi sono i ~120 emoji a
32
+ // presentazione-testo del BMP (`▶ ✔ ⚠ ❤ ✂ ➡ …`): string-width li dà 2, il
33
+ // terminale ne disegna 1. Non sono riparabili con un selettore di
34
+ // variazione (né VS16 né VS15 cambiano il verdetto di string-width) →
35
+ // vengono SOSTITUITI con un gemello concorde.
36
+ //
37
+ // ② VS16 sui glifi larghi 2 del BMP, e SOLO su quelli. Ink compone il frame
38
+ // su una griglia di celle assegnando 2 celle a un carattere se
39
+ // `isFullwidthCodePoint(cp) || value.length > 1`
40
+ // (`@alcalzone/ansi-tokenize`, un token per CODE POINT). `isFullwidthCodePoint`
41
+ // copre il CJK ma NON gli emoji, quindi:
42
+ // · astrale (`🔥`, 2 code unit) → `value.length > 1` → 2 celle, giusto;
43
+ // · BMP largo 2 (`⚡`, 1 code unit) → 1 sola cella, una in meno del dovuto;
44
+ // · BMP + VS16 → 1 + 1 = 2 celle, di nuovo giusto.
45
+ // Timbrare un ASTRALE è quindi l'errore opposto: gli darebbe 3 celle per 2
46
+ // colonne e la riga uscirebbe una colonna più CORTA del padding calcolato.
47
+ //
48
+ // ③ Il taglio lo fa il deck, non Ink. Restava scoperta la terza contabilità
49
+ // (`slice-ansi`, per code point): con un budget di colonne in ingresso,
50
+ // ogni glifo largo 2 a sinistra del taglio allarga il risultato di una
51
+ // colonna, e nessun timbro può ripararlo senza rompere ② o ①. L'unica via
52
+ // è non farlo mai intervenire: chi renderizza una riga a lunghezza libera
53
+ // la taglia PRIMA con `cut()`, su un budget derivato da `columns`.
54
+ // `wrap="truncate-end"` resta come rete, non come meccanismo.
55
+ //
56
+ // L'assunzione sul terminale (EAW, ambiguous = 1, VS16 ignorato) vive TUTTA in
57
+ // `termWidth`. Se un giorno cambia il terminale, cambia questa funzione e basta.
58
+ import stringWidth from 'string-width';
59
+ import { eastAsianWidth } from 'get-east-asian-width';
60
+ /** Variation Selector-16: forza la presentazione emoji del carattere che precede. */
61
+ const VS16 = '️';
62
+ /** Zero-width per il terminale: combining marks e format chars (VS16 incluso). */
63
+ const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}]$/u;
64
+ /**
65
+ * Larghezza in colonne secondo il TERMINALE (VTE/Ptyxis).
66
+ *
67
+ * `eastAsianWidth` restituisce già 1 per gli Ambiguous — che è il default di
68
+ * VTE (`utf8-ambiguous-width`) e il motivo per cui `▶` (EAW = Ambiguous) e `✔`
69
+ * (EAW = Neutral) vengono disegnati stretti anche col VS16 appiccicato.
70
+ */
71
+ export function termWidth(s) {
72
+ let w = 0;
73
+ for (const ch of s) {
74
+ if (ZERO_WIDTH.test(ch))
75
+ continue;
76
+ w += eastAsianWidth(ch.codePointAt(0));
77
+ }
78
+ return w;
79
+ }
80
+ /** Il carattere è misurato allo stesso modo da Ink e dal terminale? */
81
+ export function agrees(ch) {
82
+ return stringWidth(ch) === termWidth(ch);
83
+ }
84
+ /**
85
+ * Sostituzioni esplicite per i discordi che il deck usa come SEGNALE: qui la
86
+ * semantica del glifo va preservata, non degradata a segnaposto.
87
+ *
88
+ * I sostituti sono concordi per costruzione (verificati dal test): `▸` è
89
+ * Neutral e non-emoji → 1 ovunque; `✅` `❗` sono astrali a presentazione-emoji
90
+ * → 2 ovunque.
91
+ */
92
+ const GLYPH_MAP = new Map([
93
+ ['▶', '▸'], // caret di selezione
94
+ ['◀', '◂'],
95
+ ['✔', '✅'], // marker Done
96
+ ['☑', '✅'],
97
+ ['✖', '❌'],
98
+ ['⚠', '❗'], // warning (riga pin stale)
99
+ ['❗', '❗'],
100
+ ]);
101
+ /**
102
+ * Segnaposto per i discordi che arrivano da testo NON nostro (descrizioni,
103
+ * titoli, corpi dei transcript): non c'è una traduzione sensata glifo-per-glifo
104
+ * di `❤ ✂ ☀ ➡ …`, e lasciarli passare romperebbe la riga che li ospita.
105
+ * Largo 1 e concorde.
106
+ */
107
+ const FILLER = '·';
108
+ /**
109
+ * Fast-path: sotto U+1100 non esiste né un wide né un discorde, quindi non
110
+ * c’è niente da sanificare. `\n` e `\t` stanno fuori dal sospetto pur essendo
111
+ * control char — li normalizzano i chiamanti (`wrapLines` collassa il
112
+ * whitespace, `wrapWithOffsets` li mappa preservando gli offset) e tenerli
113
+ * dentro costerebbe il fast-path su OGNI corpo di transcript, visto che
114
+ * `sanitize` gira anche sui ~10 MB di testo cercabile del progetto.
115
+ */
116
+ const SUSPECT = /[^\n\t\u0020-\u10FF]/;
117
+ /**
118
+ * Byte di controllo che il terminale INTERPRETA — ESC in testa. Passati nudi
119
+ * da un transcript muoverebbero il cursore o cambierebbero i colori dal mezzo
120
+ * di una preview. Diventano uno spazio: largo 1, concorde, inerte.
121
+ */
122
+ const CONTROL = /[\u0000-\u001F\u007F-\u009F]/;
123
+ /**
124
+ * Sostituzione di UN carattere secondo le invarianti ① e ②. Memoizzata: i
125
+ * corpi cercabili sono ~10 MB e i caratteri distinti che li attraversano sono
126
+ * poche decine — misurare la stessa emoji un milione di volte è il costo che
127
+ * questa cache toglie.
128
+ */
129
+ const FIXED = new Map();
130
+ function fixChar(ch) {
131
+ const cached = FIXED.get(ch);
132
+ if (cached !== undefined)
133
+ return cached;
134
+ let out;
135
+ if (CONTROL.test(ch)) {
136
+ out = ' ';
137
+ }
138
+ else if (ZERO_WIDTH.test(ch)) {
139
+ // Il VS16 in ingresso si scarta SEMPRE: se serve lo ri-mette il ramo ② sul
140
+ // carattere che lo precede. Passare da «scarta e ri-timbra» invece che da
141
+ // «tieni se c'è» è ciò che rende `sanitize` idempotente. Le altre combining
142
+ // mark e i format char restano dove sono (0 colonne per entrambe le conte).
143
+ out = ch === VS16 ? '' : ch;
144
+ }
145
+ else {
146
+ const fixedGlyph = agrees(ch) ? ch : (GLYPH_MAP.get(ch) ?? FILLER); // ①
147
+ // ② una cella per colonna nella griglia di Ink — SOLO sui BMP.
148
+ const bmpWide = fixedGlyph.codePointAt(0) < 0x10000 && termWidth(fixedGlyph) === 2;
149
+ out = bmpWide ? fixedGlyph + VS16 : fixedGlyph;
150
+ }
151
+ FIXED.set(ch, out);
152
+ return out;
153
+ }
154
+ /**
155
+ * Caratteri che possono violare un'invariante: control char, tutto ciò che sta
156
+ * da U+1100 in su (primo wide dell'insieme) e gli astrali. `\n` e `\t` restano
157
+ * fuori — li normalizzano i chiamanti, e includerli qui costerebbe una
158
+ * sostituzione su ogni riga di ogni transcript.
159
+ */
160
+ const RISKY = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]|[\u1100-\uFFFF]|[\u{10000}-\u{10FFFF}]/gu;
161
+ /**
162
+ * Rende una stringa sicura da mettere nel frame: applica le invarianti ① e ②.
163
+ *
164
+ * Idempotente, e a costo quasi zero sul testo ASCII (fast-path) — cioè sulla
165
+ * stragrande maggioranza dei corpi di transcript, che è il volume vero: la
166
+ * versione carattere-per-carattere costava secondi all'avvio del deck.
167
+ */
168
+ export function sanitize(s) {
169
+ if (!s || !SUSPECT.test(s))
170
+ return s;
171
+ return s.replace(RISKY, fixChar);
172
+ }
173
+ /**
174
+ * Taglio a un budget di COLONNE, ellissi inclusa nel budget.
175
+ *
176
+ * Rimpiazza il vecchio `truncate`, che tagliava per code unit: su una riga con
177
+ * due emoji il risultato usciva 2 colonne oltre il budget, cioè esattamente
178
+ * dentro il bordo del pane.
179
+ *
180
+ * Collassa il whitespace come faceva il predecessore: i chiamanti passano
181
+ * descrizioni multi-riga e si aspettano un blocco unico.
182
+ */
183
+ export function cut(s, cols) {
184
+ const flat = s.replace(/\s+/g, ' ').trim();
185
+ if (cols <= 0)
186
+ return '';
187
+ if (termWidth(flat) <= cols)
188
+ return flat;
189
+ if (cols === 1)
190
+ return '…';
191
+ let out = '';
192
+ let w = 0;
193
+ for (const ch of flat) {
194
+ const cw = termWidth(ch);
195
+ if (w + cw > cols - 1)
196
+ break; // -1 = la colonna dell'ellissi
197
+ out += ch;
198
+ w += cw;
199
+ }
200
+ // Un estratto può arrivare qui con la SUA ellissi già in coda (la ricerca
201
+ // ritaglia il contesto attorno al match): due ellissi di fila non aggiungono
202
+ // informazione, tolgono una colonna di testo.
203
+ return out.trimEnd().replace(/…$/, '') + '…';
204
+ }
205
+ /**
206
+ * Indice sorgente raggiunto consumando al più `cols` colonne da `from`.
207
+ *
208
+ * Il conteggio è per colonne, l'indice restituito è in code unit: è ciò che
209
+ * tiene gli offset del match allineati al sorgente mentre l'a-capo ragiona in
210
+ * colonne.
211
+ */
212
+ function advance(line, from, cols) {
213
+ let i = from;
214
+ let w = 0;
215
+ while (i < line.length) {
216
+ const cp = line.codePointAt(i);
217
+ const ch = String.fromCodePoint(cp);
218
+ const cw = termWidth(ch);
219
+ if (w + cw > cols)
220
+ break;
221
+ w += cw;
222
+ i += ch.length;
223
+ }
224
+ return i;
225
+ }
226
+ /**
227
+ * A-capo che PRESERVA la struttura del testo e traccia gli offset.
228
+ *
229
+ * Distinto da `wrapLines`, che collassa tutto in un flusso unico: lì serve una
230
+ * preview compatta di 2-4 righe, qui si legge un messaggio intero — appiattire
231
+ * le newline renderebbe illeggibile qualunque blocco di codice o elenco.
232
+ *
233
+ * Ogni riga prodotta è una FETTA CONTIGUA del sorgente: è ciò che permette di
234
+ * intersecarla con l'intervallo del match e colorare solo la parte giusta,
235
+ * anche quando il match cade a cavallo di due righe.
236
+ *
237
+ * Taglia sull'ultimo spazio disponibile; una parola più larga della riga viene
238
+ * spezzata a forza, altrimenti l'a-capo lo farebbe il terminale — fuori dal
239
+ * conteggio delle righe, cioè di nuovo un frame più alto di `rows`.
240
+ */
241
+ export function wrapWithOffsets(text, width) {
242
+ const out = [];
243
+ if (width <= 0)
244
+ return out;
245
+ let base = 0;
246
+ for (const raw of text.split('\n')) {
247
+ // Tab e CR diventano UN singolo spazio, non quattro: la sostituzione deve
248
+ // conservare la lunghezza, o gli offset delle righe smettono di indicizzare
249
+ // il sorgente e il match verrebbe evidenziato spostato. Un tab lasciato
250
+ // passare sarebbe l'errore opposto — lo conteremmo 1 e il terminale 8.
251
+ const line = raw.replace(/[\t\r]/g, ' ');
252
+ if (line.length === 0) {
253
+ out.push({ text: '', start: base, end: base });
254
+ }
255
+ let pos = 0;
256
+ while (pos < line.length) {
257
+ const stop = advance(line, pos, width);
258
+ if (stop >= line.length) {
259
+ out.push({ text: line.slice(pos), start: base + pos, end: base + line.length });
260
+ break;
261
+ }
262
+ let brk = line.lastIndexOf(' ', stop);
263
+ if (brk <= pos)
264
+ brk = stop; // parola più lunga della riga
265
+ out.push({ text: line.slice(pos, brk), start: base + pos, end: base + brk });
266
+ pos = brk;
267
+ while (line[pos] === ' ')
268
+ pos++; // lo spazio del taglio non apre la riga dopo
269
+ }
270
+ base += raw.length + 1; // +1 = il '\n' consumato dallo split
271
+ }
272
+ return out;
273
+ }
274
+ /**
275
+ * Hard-wrap a larghezza fissa, con tetto di righe.
276
+ *
277
+ * Serve un conteggio righe DETERMINISTICO: `<Text wrap="wrap">` di Ink wrappa a
278
+ * runtime su una larghezza che il budget non conosce, quindi il pannello
279
+ * dettaglio potrebbe sforare il tetto e riaprire il bug dell'altezza. Qui il
280
+ * testo viene spezzato prima, e ogni riga è renderizzata con `truncate-end`.
281
+ *
282
+ * Sottostimare `width` è sicuro (tronca prima), sovrastimarlo no (la riga
283
+ * andrebbe a capo aggiungendo altezza non contabilizzata).
284
+ */
285
+ export function wrapLines(text, width, maxLines) {
286
+ if (maxLines <= 0 || width <= 0)
287
+ return [];
288
+ const flat = text.replace(/\s+/g, ' ').trim();
289
+ if (!flat)
290
+ return [];
291
+ const lines = [];
292
+ let line = '';
293
+ const push = () => {
294
+ lines.push(line);
295
+ line = '';
296
+ };
297
+ for (const word of flat.split(' ')) {
298
+ if (!line) {
299
+ line = word;
300
+ }
301
+ else if (termWidth(line) + 1 + termWidth(word) <= width) {
302
+ line += ' ' + word;
303
+ }
304
+ else {
305
+ push();
306
+ line = word;
307
+ if (lines.length === maxLines)
308
+ break;
309
+ }
310
+ // Parola più lunga della riga: spezzala a forza, altrimenti l'a-capo lo
311
+ // farebbe il terminale — fuori dal nostro conteggio.
312
+ while (termWidth(line) > width) {
313
+ const stop = advance(line, 0, width);
314
+ lines.push(line.slice(0, stop));
315
+ line = line.slice(stop);
316
+ if (lines.length === maxLines)
317
+ break;
318
+ }
319
+ if (lines.length === maxLines)
320
+ break;
321
+ }
322
+ if (line && lines.length < maxLines)
323
+ lines.push(line);
324
+ const kept = lines.slice(0, maxLines);
325
+ // Troncamento MAI silenzioso, come le liste: l'ellissi segnala che il testo
326
+ // continua oltre il pannello.
327
+ const consumed = kept.join(' ').length;
328
+ if (consumed < flat.length && kept.length > 0) {
329
+ const last = kept[kept.length - 1];
330
+ kept[kept.length - 1] = termWidth(last) >= width ? cut(last, width) : last + '…';
331
+ }
332
+ return kept;
333
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.17.0",
3
+ "version": "0.18.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": {