@lamemind/loom-deck 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -10,10 +10,11 @@ import { dirname, join } from 'node:path';
10
10
  import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from './tasks.js';
11
11
  import { discoverProjectSessions } from './sessions.js';
12
12
  import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions, selectedRow, DEFAULT_OPTIONS, MIN_QUERY, } from './search.js';
13
- import { appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
14
- import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, selectedSession, } from './session-list.js';
13
+ import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
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, 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';
@@ -284,6 +285,7 @@ function useSessions(projectRoot) {
284
285
  bindings: new Map(),
285
286
  forkOf: new Map(),
286
287
  pinned: new Map(),
288
+ notes: new Map(),
287
289
  });
288
290
  // T50 — pin/unpin scrive il sidecar e vuole feedback IMMEDIATO, non al
289
291
  // prossimo tick del poll (1.5s): la reload è esposta via ref così il toggle la
@@ -300,23 +302,25 @@ function useSessions(projectRoot) {
300
302
  }
301
303
  catch {
302
304
  sessions = [];
303
- index = { bindings: new Map(), forkOf: new Map(), pinned: new Map() };
305
+ index = { bindings: new Map(), forkOf: new Map(), pinned: new Map(), notes: new Map() };
304
306
  }
305
- const { bindings, forkOf, pinned } = index;
306
- // La signature copre anche fork e pin: un record di lineage o un toggle di
307
- // pin appena scritto cambia la lista renderizzata, quindi deve forzare il
308
- // re-render come farebbe un binding nuovo.
307
+ const { bindings, forkOf, pinned, notes } = index;
308
+ // La signature copre anche fork, pin e note: un record di lineage, un
309
+ // toggle di pin o una nota appena scritta cambiano la lista renderizzata,
310
+ // quindi devono forzare il re-render come farebbe un binding nuovo.
309
311
  const sig = sessions.map((s) => `${s.sessionId}:${s.ts}`).join('|') +
310
312
  '#' +
311
313
  [...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',') +
312
314
  '#' +
313
315
  [...forkOf.entries()].map(([k, v]) => `${k}<${v}`).sort().join(',') +
314
316
  '#' +
315
- [...pinned.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',');
317
+ [...pinned.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',') +
318
+ '#' +
319
+ [...notes.entries()].map(([k, v]) => `${k}"${v}`).sort().join(',');
316
320
  if (sig === lastSig)
317
321
  return;
318
322
  lastSig = sig;
319
- setState({ sessions, bindings, forkOf, pinned });
323
+ setState({ sessions, bindings, forkOf, pinned, notes });
320
324
  };
321
325
  reloadRef.current = reload;
322
326
  reload();
@@ -336,47 +340,23 @@ function useTaskDetail(tasksDir, id) {
336
340
  }, [tasksDir, id]);
337
341
  return detail;
338
342
  }
339
- // Collassa gli spazi (description multi-paragrafo blocco unico wrappabile) e
340
- // tronca: il pane resta compatto e d'altezza prevedibile sotto la lista.
341
- function truncate(s, n) {
342
- const flat = s.replace(/\s+/g, ' ').trim();
343
- return flat.length > n ? flat.slice(0, n - 1).trimEnd() + '…' : flat;
344
- }
345
- // Normalizzazione larghezza glifi → `normalizeEmoji` in viewport.ts.
346
- //
347
- // Sostituisce il precedente `forceEmojiWidth`, che aveva l'intuizione giusta
348
- // (timbrare il VS16) ma due limiti che lasciavano passare il difetto:
349
- // - agiva solo su stringhe di UN codepoint, quindi non toccava mai un testo
350
- // composto — la riga della legenda launch, una descrizione task con emoji
351
- // dentro, il titolo di una sessione;
352
- // - decideva per intervallo di codepoint invece che per larghezza misurata,
353
- // quindi avrebbe timbrato anche `↓` `↑` `−` (larghi 1) se gli fossero
354
- // arrivati da soli, accorciando la riga invece di allargarla.
355
- const forceEmojiWidth = normalizeEmoji;
356
- // Glifi LETTERALI del JSX che ricadono nella classe mal misurata (BMP largo 2
357
- // senza VS16). I dati passano dai loader, questi no: normalizzati una volta
358
- // qui, così nessun sito di render li scrive nudi. `↳ ○ ▸ ⏎ · − ↑ ↓` sono
359
- // larghi 1 e restano intatti.
360
- 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('▶ ');
361
348
  const CARET_OFF = ' ';
362
- // T50 glifo warning della riga pin-stale: BMP largo 2 senza VS16 → va timbrato
363
- // (come CARET), altrimenti la riga slitta di una colonna.
364
- const WARN = normalizeEmoji('⚠');
349
+ const WARN = sanitize('⚠');
365
350
  // T50 — separatore leggero fra blocco pinnate e contestuali. `─` (box-drawing) è
366
- // 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 +
367
352
  // wrap="truncate-end" così non va mai a capo nel pane al 50%.
368
353
  const SESSION_SEP = '─'.repeat(16);
369
- // Normalizza il marker Done per il display. `✔` (U+2714) è text-presentation-
370
- // default: string-width quindi Ink, sia per il layout sia per il troncamento —
371
- // lo misura 2 (rispetta il VS16 di `✔️`), ma VTE/Ptyxis lo disegna largo 1
372
- // (ignora il VS16). Risultato: le righe Done finivano 1 colonna più strette del
373
- // riservato → la coda della riga (bordo destro del pane incluso) slittava a
374
- // sinistra, sfasando il layout solo su quelle righe. `✅` (U+2705) è invece
375
- // emoji-presentation-default → largo 2 sia per string-width sia per il terminale,
376
- // come 🔵/🟡: colonna Prog allineata. Cambia SOLO il display: `task.prog` resta
377
- // `✔️` 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.
378
358
  function displayProg(prog) {
379
- return forceEmojiWidth(prog.replace(/✔️?/g, '✅'));
359
+ return sanitize(prog);
380
360
  }
381
361
  // T49 — size umana compatta per il detail pane sessione.
382
362
  function fmtSize(bytes) {
@@ -406,11 +386,28 @@ function relTime(ts) {
406
386
  return `${hr}h`;
407
387
  return `${Math.floor(hr / 24)}d`;
408
388
  }
389
+ /**
390
+ * Ripulisce un chunk di stdin prima di scriverlo in un campo di testo.
391
+ *
392
+ * `useInput` consegna il CHUNK letto da stdin, non un tasto: un incollaggio — o
393
+ * una raffica piu' veloce di una read — arriva come stringa unica, byte di
394
+ * controllo compresi. Non si vedono a schermo, ma Ink li conta nella larghezza
395
+ * della riga, e in un campo che finisce su disco (la nota, T53) resterebbero
396
+ * li' per sempre. Le newline diventano SPAZIO invece di sparire: incollare due
397
+ * righe deve separare le parole, non fonderle.
398
+ */
399
+ function sanitizeTyped(s) {
400
+ return s.replace(/[\r\n]/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '');
401
+ }
409
402
  const META_KEYS = ['Priority', 'Size', 'Estimated Time', 'Progress'];
410
403
  function Deck({ cwd, tasksPath, tasksDir }) {
411
404
  const { exit } = useApp();
412
405
  const { tasks, loadError } = useTasks(tasksPath);
413
- const { sessions, bindings, forkOf, pinned, reload: reloadSessions } = useSessions(cwd);
406
+ // `notes` esce dall'indice come `sessionNotes`: in questo componente `note` è
407
+ // già la riga di STATO in fondo al frame (il feedback di un'azione). Due
408
+ // concetti diversi a una lettera di distanza sarebbero una trappola di
409
+ // lettura — e di scrittura, visto che `setNote` compare in quasi ogni ramo.
410
+ const { sessions, bindings, forkOf, pinned, notes: sessionNotes, reload: reloadSessions, } = useSessions(cwd);
414
411
  const [focus, setFocus] = useState('tasks');
415
412
  // T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
416
413
  // (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
@@ -426,6 +423,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
426
423
  // T30 create · T39 sort/filter.
427
424
  const [mode, setMode] = useState('normal');
428
425
  const [draft, setDraft] = useState('');
426
+ // T53 — bozza della nota sulla conversazione selezionata. Si apre PRECARICATA
427
+ // con la nota esistente: annotare due volte è quasi sempre correggere, non
428
+ // riscrivere da zero, e un campo vuoto costringerebbe a ridigitare tutto per
429
+ // cambiare una parola. Confermare il campo vuoto cancella la nota.
430
+ const [noteDraft, setNoteDraft] = useState('');
429
431
  // T39 — vista corrente (filtri + sort) e sua fotografia all'apertura di un
430
432
  // modale: la lista si aggiorna dal vivo, quindi `esc` deve poter ripristinare.
431
433
  const [view, setView] = useState(() => loadView(cwd));
@@ -456,6 +458,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
456
458
  const launch = useMemo(() => loadLaunch(cwd), [cwd]);
457
459
  // Identità (T37): titolo delle tab terminale spawnate col tasto `t`.
458
460
  const identity = useMemo(() => loadIdentity(cwd), [cwd]);
461
+ // T53 — `<owner> <name>`: il core che ogni titolo di tab porta, quindi la
462
+ // colonna costante da togliere quando serve spazio. Hoistato qui perché ora
463
+ // lo consumano DUE schermate (lista e ricerca): calcolarlo su ogni call site
464
+ // è il modo in cui le due smettono di togliere la stessa cosa.
465
+ const projectCore = identity ? `${identity.owner} ${identity.name}` : null;
459
466
  // La vista è una trasformazione DERIVATA, applicata a valle del load: il
460
467
  // polling di tasks.md continua a funzionare senza saperne nulla.
461
468
  const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
@@ -557,7 +564,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
557
564
  }
558
565
  const sid = randomUUID();
559
566
  const beforeIds = new Set(tasks.map((t) => t.id));
560
- setNote(`⏳ creando task… "${truncate(text, 40)}" (sid ${sid.slice(0, 8)})`);
567
+ setNote(`⏳ creando task… "${cut(text, 40)}" (sid ${sid.slice(0, 8)})`);
561
568
  const child = spawnCreateTask(text, cwd, sid, (ok) => {
562
569
  if (!ok) {
563
570
  setNote(`⚠ create-task fallito (${CLAUDE_CMD} -p)`);
@@ -580,6 +587,38 @@ function Deck({ cwd, tasksPath, tasksDir }) {
580
587
  });
581
588
  child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
582
589
  }
590
+ // T53 — apertura del modale nota sulla conversazione selezionata. Come
591
+ // `openEdit`, la bozza parte dal valore ATTUALE: annotare una seconda volta è
592
+ // quasi sempre correggere, e ripartire da vuoto costringerebbe a ridigitare
593
+ // tutto per cambiare una parola.
594
+ function openNote() {
595
+ if (!selSessionId)
596
+ return;
597
+ setNoteDraft(sessionNotes.get(selSessionId) ?? '');
598
+ setNote('');
599
+ setMode('note');
600
+ }
601
+ // T53 — ⏎ nel modale nota: scrive il sidecar e ricarica subito, senza
602
+ // attendere il tick del poll (stesso feedback immediato del pin).
603
+ //
604
+ // Il campo VUOTO non è un annullamento: è la CANCELLAZIONE della nota. Sono
605
+ // due intenzioni diverse e hanno due tasti diversi — `esc` lascia tutto com'è,
606
+ // `⏎` su campo svuotato toglie la nota. Trattare il vuoto come un no-op (come
607
+ // fa `submitCreate`, dove però una task senza titolo non esiste) renderebbe
608
+ // impossibile disannotare una conversazione se non con un editor sul JSONL.
609
+ function submitNote() {
610
+ const sid = selSessionId;
611
+ const text = noteDraft.trim();
612
+ setMode('normal');
613
+ setNoteDraft('');
614
+ if (!sid)
615
+ return;
616
+ appendNote(cwd, sid, text);
617
+ reloadSessions();
618
+ setNote(text
619
+ ? `✎ nota su ${sid.slice(0, 8)}: "${cut(text, 40)}"`
620
+ : `✎ nota rimossa da ${sid.slice(0, 8)}`);
621
+ }
583
622
  // T41 — apertura dell'edit: la bozza parte dai valori ATTUALI della task, non
584
623
  // da default. La priorità arriva dal glifo di tasks.md (già in `selTask`), lo
585
624
  // stato dal suo glifo Prog; il progresso arbitrario dal campo `Progress` del
@@ -713,7 +752,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
713
752
  // (T39), che cicla sui caratteri del chunk invece di leggerlo intero.
714
753
  function typeIntoField(chunk) {
715
754
  setNote('');
716
- const clean = (s) => s.replace(/[\r\n]/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '');
755
+ const clean = sanitizeTyped;
717
756
  const parts = chunk.split('\t');
718
757
  let field = searchField;
719
758
  const add = { hash: '', query: '' };
@@ -830,6 +869,43 @@ function Deck({ cwd, tasksPath, tasksDir }) {
830
869
  }
831
870
  return;
832
871
  }
872
+ // T53 — modale nota. Come create, cattura il testo e corto-circuita la
873
+ // navigazione (qui `q` è una lettera da scrivere, non il quit).
874
+ if (mode === 'note') {
875
+ if (key.escape) {
876
+ setMode('normal');
877
+ setNoteDraft('');
878
+ setNote('N → nota annullata');
879
+ }
880
+ else if (key.return) {
881
+ submitNote();
882
+ }
883
+ else if (key.ctrl && input === 'u') {
884
+ // Svuota il campo in un colpo. NON è una scorciatoia di comodo: il
885
+ // backspace tenuto premuto cancella UN carattere per CHUNK letto da
886
+ // stdin, non per pressione (`useInput` consegna il chunk, e per una
887
+ // raffica di DEL Ink alza `key.backspace` una volta sola) — misurato,
888
+ // 30 pressioni → 2 caratteri. Siccome «campo vuoto» qui è l'unico modo
889
+ // di CANCELLARE una nota, dipendere dal backspace renderebbe
890
+ // l'operazione praticamente non eseguibile. `^U` è il kill-line delle
891
+ // shell, quindi il gesto è già nelle dita di chi usa un terminale.
892
+ setNoteDraft('');
893
+ }
894
+ else if (key.backspace || key.delete) {
895
+ setNoteDraft((d) => d.slice(0, -1));
896
+ }
897
+ else if (input && !key.ctrl && !key.meta) {
898
+ // Sanificazione dei byte di controllo, come `typeIntoField` della
899
+ // ricerca: `useInput` consegna il CHUNK letto da stdin, quindi un
900
+ // incollaggio porta dentro newline e control char. Invisibili a schermo
901
+ // ma contati da Ink nella larghezza della riga — e qui finirebbero
902
+ // scritti su disco, dove resterebbero a sporcare la riga per sempre.
903
+ // Le newline diventano spazio: incollare due righe deve separare le
904
+ // parole, non fonderle.
905
+ setNoteDraft((d) => d + sanitizeTyped(input));
906
+ }
907
+ return;
908
+ }
833
909
  // T39 — modale sort a grammatica libera: la SEQUENZA di tasti È la chain.
834
910
  // Digitare `ppi` = p(asc) p(desc) i(asc) → [pri desc, id asc]. La lista si
835
911
  // riordina dal vivo a ogni pressione.
@@ -1061,6 +1137,23 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1061
1137
  setNote(`${isPinned ? 'unpin' : '📌 pin'} ${selSessionId.slice(0, 8)}`);
1062
1138
  }
1063
1139
  }
1140
+ else if (input === 'N') {
1141
+ // T53 — nota sulla conversazione selezionata. MAIUSCOLA perché apre un
1142
+ // modale: nel deck le minuscole sono azioni immediate (`f` fork, `p` pin,
1143
+ // `t` term, `c` claude) e le maiuscole aprono un box (`C` create, `E`
1144
+ // edit, `S` sort, `F` filtri). Vincolo di focus identico a `p`: vale anche
1145
+ // su una pinnata STALE, perché annotare «questa non c'è più, era X» è
1146
+ // proprio il caso in cui una nota serve.
1147
+ if (focus !== 'sessions') {
1148
+ setNote('N → nota: seleziona una sessione (←→ per il pane)');
1149
+ }
1150
+ else if (!selSessionId) {
1151
+ setNote('N → nessuna sessione da annotare');
1152
+ }
1153
+ else {
1154
+ openNote();
1155
+ }
1156
+ }
1064
1157
  else if (input === 't') {
1065
1158
  const title = identity ? `🖥️ ${identity.owner} ${identity.name} [term]` : null;
1066
1159
  const child = spawnTerminal(cwd, title);
@@ -1152,7 +1245,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1152
1245
  ts: sessions.find((s) => s.sessionId === h.sessionId)?.ts ?? 0,
1153
1246
  };
1154
1247
  }
1155
- return (_jsx(SearchScreen, { preview: preview, hash: searchHash, query: searchQuery, field: searchField, opts: searchOpts, result: searchResult, rows: searchRows.slice(win.start, win.end), selectedKey: searchSelKey, selectedKind: selectedRow(searchRows, searchSelKey)?.kind ?? null, above: win.start, below: searchRows.length - win.end, capacity: searchCap, bindings: bindings, pinned: pinned, projectCore: identity ? `${identity.owner} ${identity.name}` : null, columns: columns, note: note }));
1248
+ return (_jsx(SearchScreen, { preview: preview, hash: searchHash, query: searchQuery, field: searchField, opts: searchOpts, result: searchResult, rows: searchRows.slice(win.start, win.end), selectedKey: searchSelKey, selectedKind: selectedRow(searchRows, searchSelKey)?.kind ?? null, above: win.start, below: searchRows.length - win.end, capacity: searchCap, bindings: bindings, pinned: pinned, sessionNotes: sessionNotes, projectCore: projectCore, columns: columns, note: note }));
1156
1249
  }
1157
1250
  // ── Budget d'altezza ────────────────────────────────────────────────────
1158
1251
  // Il frame deve restare sotto `rows`, sempre: oltre quella soglia Ink smette
@@ -1192,7 +1285,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1192
1285
  if (budget.compact) {
1193
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"] })] }));
1194
1287
  }
1195
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : canResume ? 'resume' : '—', canResume ? ' · f fork' : '', canPin ? ' · p pin' : '', " \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 === '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 })] }), 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] }));
1196
1289
  }
1197
1290
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
1198
1291
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -1211,7 +1304,7 @@ function FilterModal({ view, cursor }) {
1211
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) => {
1212
1305
  const on = !row.hidden.has(e.name);
1213
1306
  const here = cursor.row === r && cursor.col === c;
1214
- 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));
1215
1308
  })] }, row.label)))] }));
1216
1309
  }
1217
1310
  // T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
@@ -1220,12 +1313,12 @@ function FilterModal({ view, cursor }) {
1220
1313
  // del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
1221
1314
  function EditModal({ id, draft, row }) {
1222
1315
  const mark = (r) => (row === r ? CARET : CARET_OFF);
1223
- 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))] })] }));
1224
1317
  }
1225
1318
  // T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
1226
1319
  // caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
1227
1320
  // allinearsi, e i glifi BMP larghi 2 sono proprio la classe che Ink e il
1228
- // terminale misurano diversamente (vedi normalizeEmoji).
1321
+ // terminale misurano diversamente (vedi width.ts).
1229
1322
  const KIND_TAG = { ai: 'ai', tool: 'tl', human: 'hu' };
1230
1323
  const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
1231
1324
  /**
@@ -1241,12 +1334,12 @@ const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
1241
1334
  * 4 box lista (2 bordi + 2 padding)
1242
1335
  * 2 caret
1243
1336
  * 4 indice del record
1244
- * 3 spazio + tag del kind + spazio
1337
+ * 4 spazio + tag del kind (2 caratteri) + spazio
1245
1338
  * Prudente per costruzione: sottostimare tronca un carattere in più,
1246
1339
  * sovrastimare manderebbe la riga a capo e sfonderebbe il budget d'altezza.
1247
1340
  */
1248
1341
  function searchExcerptWidth(columns) {
1249
- return Math.max(30, (columns || 80) - 17);
1342
+ return Math.max(30, (columns || 80) - 18);
1250
1343
  }
1251
1344
  /** Titolo della conversazione sulla riga-gruppo: prende ciò che avanza dopo le
1252
1345
  * colonne a larghezza fissa (caret, pin, hash, task, conteggio, data). */
@@ -1267,13 +1360,7 @@ function searchTitleWidth(columns) {
1267
1360
  * conversazione e non un'altra.
1268
1361
  */
1269
1362
  function conversationLabel(s, core, bound) {
1270
- let t = s.title;
1271
- if (core) {
1272
- const at = t.indexOf(core);
1273
- if (at >= 0)
1274
- t = t.slice(at + core.length);
1275
- }
1276
- t = t.replace(/^[\s·]+/, '').trim();
1363
+ let t = stripProjectCore(s.title, core);
1277
1364
  if (bound && t === bound)
1278
1365
  t = '';
1279
1366
  return t || s.firstPrompt || '(senza titolo)';
@@ -1309,18 +1396,26 @@ function SearchListHeader({ result, query, above, below, }) {
1309
1396
  }
1310
1397
  return (_jsxs(Text, { bold: true, wrap: "truncate-end", children: [result.shown, " occorrenze in ", result.sessionCount, " conversazioni", result.hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", result.hidden, " oltre il cap"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }));
1311
1398
  }
1312
- function SearchScreen({ preview, hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, projectCore, columns, note, }) {
1399
+ function SearchScreen({ preview, hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, sessionNotes, projectCore, columns, note, }) {
1313
1400
  const enter = selectedKind === 'session' ? 'resume' : selectedKind === 'hit' ? 'leggi' : '—';
1314
1401
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["ricerca \u00B7 ", _jsx(Text, { color: "yellow", children: "tab" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " naviga \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " ", enter, " \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "hash " }), _jsx(Text, { color: field === 'hash' ? 'yellow' : undefined, children: hash }), field === 'hash' ? _jsx(Text, { inverse: true, children: " " }) : null, !hash ? _jsx(Text, { dimColor: true, children: " (vuoto = tutte le conversazioni)" }) : null] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "chiave " }), _jsx(Text, { color: field === 'query' ? 'yellow' : undefined, children: query }), field === 'query' ? _jsx(Text, { inverse: true, children: " " }) : null] }), _jsx(ToggleHint, { opts: opts })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsx(SearchListHeader, { result: result, query: query, above: above, below: below }), rows.slice(0, Math.max(0, capacity)).map((row) => {
1315
1402
  const sel = row.key === selectedKey;
1316
1403
  if (row.kind === 'session') {
1317
1404
  const s = row.session;
1318
1405
  const bound = bindings.get(s.sessionId);
1319
- 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 " }), truncate(conversationLabel(s, projectCore, bound), searchTitleWidth(columns)), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
1406
+ const rowNote = sessionNotes.get(s.sessionId);
1407
+ const noteShown = rowNote ? cut(rowNote, 24) : '';
1408
+ // `+3` = i due caporali e lo spazio che li separa dall'etichetta.
1409
+ // Il pavimento non è cosmetico: senza, un terminale stretto manda
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));
1320
1415
  }
1321
1416
  const h = row.hit;
1322
- 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));
1323
- })] }), 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] }));
1324
1419
  }
1325
1420
  /**
1326
1421
  * Anteprima dell'occorrenza selezionata, sotto la lista.
@@ -1367,16 +1462,25 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
1367
1462
  ...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
1368
1463
  ...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
1369
1464
  ]
1370
- .map((e) => `−${normalizeEmoji(e.glyph)}`)
1465
+ .map((e) => `−${sanitize(e.glyph)}`)
1371
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) => {
1372
1467
  // windowStart riporta l'indice di finestra a quello della lista
1373
1468
  // completa, su cui è keyata la selezione. +1: lo 0 è spot.
1374
1469
  const sel = windowStart + i + 1 === selected;
1375
1470
  const n = childCount.get(task.id) ?? 0;
1376
- 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));
1377
1481
  })), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
1378
1482
  }
1379
- function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, }) {
1483
+ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, sessionNotes, projectCore, }) {
1380
1484
  return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Sessions \u00B7 ", parentLabel, " (", total, ")", pinnedCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 \uD83D\uDCCC", pinnedCount] }) : null, hidden > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 +", hidden, " pi\u00F9 vecchie"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isSpot ? 'nessuna sessione libera' : 'nessuna sessione legata a questa task' })) : (rows.map((row, i) => {
1381
1485
  // T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
1382
1486
  // box pesante (coerente con lo styling delle Done dimmate).
@@ -1387,7 +1491,7 @@ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, s
1387
1491
  // T50 — pin stale: transcript sparito, nessuna Session da mostrare.
1388
1492
  // Riga navigabile e spinnabile (`p`), marcata, mai un crash.
1389
1493
  if (row.kind === 'pinned' && row.stale) {
1390
- 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) })] }, 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));
1391
1495
  }
1392
1496
  const s = row.session; // non-stale → session presente
1393
1497
  const isPinnedRow = row.kind === 'pinned';
@@ -1395,8 +1499,21 @@ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, s
1395
1499
  // righe sarebbero identiche a occhio. `⑂` sta PRIMA del titolo, dove
1396
1500
  // la troncatura non arriva mai.
1397
1501
  const forked = forkOf.has(s.sessionId);
1398
- 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, truncate(s.title, forked ? 42 : 44), ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
1399
- })), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null })) : null] }));
1502
+ // T53 con una nota il prefisso di progetto sparisce e le sue colonne
1503
+ // passano alla nota; senza, il titolo resta com'è (vedi `rowLabel`).
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);
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));
1516
+ })), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null, note: sessionNotes.get(detail.sessionId) ?? '' })) : null] }));
1400
1517
  }
1401
1518
  // T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
1402
1519
  // task. Tutti i campi vengono dal parse già cached dell'adapter (mtime-keyed):
@@ -1406,11 +1523,11 @@ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, s
1406
1523
  // — senza, il titolo È già il primo prompt e la riga lo duplicherebbe (D4
1407
1524
  // preflight). Le righe rese non superano mai il riservato dal budget
1408
1525
  // (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
1409
- function SessionDetailPane({ s, firstLines, lastLines, columns, origin, }) {
1526
+ function SessionDetailPane({ s, firstLines, lastLines, columns, origin, note, }) {
1410
1527
  const width = detailTextWidth(columns);
1411
1528
  const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
1412
1529
  const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
1413
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: s.title }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), origin ? ` · ⑂ da ${origin.slice(0, 8)}` : ''] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
1530
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), origin ? ` · ⑂ da ${origin.slice(0, 8)}` : ''] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
1414
1531
  }
1415
1532
  /**
1416
1533
  * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
@@ -1435,6 +1552,20 @@ function detailMetaOf(detail) {
1435
1552
  function detailTextWidth(columns) {
1436
1553
  return Math.max(10, Math.floor(((columns || 80) - 4) / 2) - 9);
1437
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
+ }
1438
1569
  function DetailPane({ detail, maxLines, columns, }) {
1439
1570
  const { meta, commit } = detailMetaOf(detail);
1440
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,6 +14,71 @@
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 { cut, termWidth } from './width.js';
18
+ // ── T53 · etichetta di riga ────────────────────────────────────────────────
19
+ /**
20
+ * Toglie dal titolo il core `<owner> <name>` del progetto e la punteggiatura di
21
+ * giunzione che resta appesa davanti.
22
+ *
23
+ * Il titolo di una sessione È la label della tab Ptyxis (`🧵 LOCAL loom-works ·
24
+ * T52`): dentro un deck che mostra un progetto solo, il core è una COLONNA
25
+ * COSTANTE ripetuta su ogni riga. Toglierlo libera le colonne per ciò che
26
+ * distingue davvero una conversazione dall'altra.
27
+ *
28
+ * `core` null (file config assente o senza identità) → titolo intatto: senza
29
+ * sapere cosa togliere non si indovina, si lascia stare.
30
+ */
31
+ export function stripProjectCore(title, core) {
32
+ let t = title;
33
+ if (core) {
34
+ const at = t.indexOf(core);
35
+ if (at >= 0)
36
+ t = t.slice(at + core.length);
37
+ }
38
+ return t.replace(/^[\s·]+/, '').trim();
39
+ }
40
+ /** Colonne minime perché un residuo dopo la nota valga la riga: sotto questa
41
+ * soglia si vedrebbero due lettere e un'ellissi, cioè rumore. */
42
+ const MIN_REST = 6;
43
+ /** Colonne garantite alla nota quando c'è anche un residuo da mostrare: la nota
44
+ * è la parte scelta da un umano, non è lei a cedere il posto per prima. */
45
+ const MIN_NOTE = 14;
46
+ /**
47
+ * Cosa scrivere sulla riga di una conversazione, dentro `budget` colonne.
48
+ *
49
+ * Due regimi, ed è una decisione deliberata che NON siano lo stesso:
50
+ *
51
+ * - **senza nota** → il titolo resta INTATTO, prefisso di progetto incluso.
52
+ * Togliere il core qui lascerebbe `T52` o il nulla (i titoli di tab non
53
+ * contengono altro), cioè meno informazione di prima, non più spazio.
54
+ * - **con nota** → il core se ne va: la nota dice già quale conversazione è,
55
+ * quindi le colonne del prefisso passano a lei. Il residuo (tipicamente la
56
+ * task, `T52`) segue dimmato se ci sta; se non resta nulla va benissimo.
57
+ *
58
+ * Il budget si divide dando la precedenza alla nota, ma senza affamare il
59
+ * residuo: con entrambi presenti la nota cede fino a `MIN_NOTE` per lasciare
60
+ * spazio al residuo, e il residuo sparisce del tutto sotto `MIN_REST` invece di
61
+ * ridursi a un moncone.
62
+ */
63
+ export function rowLabel(title, note, core, budget) {
64
+ if (!note)
65
+ return { note: '', rest: cut(title, Math.max(0, budget)) };
66
+ // 2 colonne per i caporali « », 1 per lo spazio prima del residuo.
67
+ const rest = stripProjectCore(title, core);
68
+ const noteBudget = Math.max(0, budget - 2);
69
+ if (!rest)
70
+ return { note: cut(note, noteBudget), rest: '' };
71
+ // Il `min` col budget totale non è ridondante: su un pane strettissimo
72
+ // `MIN_NOTE` supererebbe le colonne disponibili e la riga andrebbe a capo —
73
+ // che è precisamente il difetto che ogni larghezza qui dentro esiste per
74
+ // evitare (una riga wrappata sfonda il budget d'ALTEZZA, non solo l'estetica).
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) : '' };
81
+ }
17
82
  export function assembleSessionList(childSessions, allSessions, pinned, maxContext) {
18
83
  // Le pinnate si risolvono contro TUTTE le sessioni del progetto, non solo le
19
84
  // figlie del parent: una pinnata può appartenere a un'altra task. Assente
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,7 +225,7 @@ 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,
@@ -15,6 +15,10 @@ export function appendTaskBinding(projectRoot, sessionId, taskId) {
15
15
  export function appendPin(projectRoot, sessionId, pinned) {
16
16
  appendSessionRecord(projectRoot, { sessionId, pinned });
17
17
  }
18
+ /** T53 — scrive (o cancella, con `note` vuota) la nota di una conversazione. */
19
+ export function appendNote(projectRoot, sessionId, note) {
20
+ appendSessionRecord(projectRoot, { sessionId, note });
21
+ }
18
22
  // Una sola lettura del JSONL per entrambe le mappe: il deck poll-a l'indice a
19
23
  // ogni tick, leggere il file due volte raddoppierebbe l'I/O per nulla.
20
24
  // Last-wins per campo (un re-pin dello stesso sessionId sovrascrive), e i due
@@ -24,12 +28,13 @@ export function loadSessionIndex(projectRoot) {
24
28
  const bindings = new Map();
25
29
  const forkOf = new Map();
26
30
  const pinned = new Map();
31
+ const notes = new Map();
27
32
  let content;
28
33
  try {
29
34
  content = readFileSync(taskIndexPath(projectRoot), 'utf8');
30
35
  }
31
36
  catch {
32
- return { bindings, forkOf, pinned };
37
+ return { bindings, forkOf, pinned, notes };
33
38
  }
34
39
  let order = 0; // posizione crescente dei record pinned → rango di pin (D2)
35
40
  for (const line of content.split('\n')) {
@@ -51,10 +56,19 @@ export function loadSessionIndex(projectRoot) {
51
56
  else
52
57
  pinned.delete(d.sessionId);
53
58
  }
59
+ // T53 — stesso last-wins: la stringa vuota è la CANCELLAZIONE, non una
60
+ // nota vuota da mostrare. Il `typeof` esclude i record senza il campo, che
61
+ // non devono toccare una nota scritta da un record precedente.
62
+ if (typeof d.note === 'string') {
63
+ if (d.note)
64
+ notes.set(d.sessionId, d.note);
65
+ else
66
+ notes.delete(d.sessionId);
67
+ }
54
68
  }
55
69
  catch {
56
70
  // riga corrotta → skip
57
71
  }
58
72
  }
59
- return { bindings, forkOf, pinned };
73
+ return { bindings, forkOf, pinned, notes };
60
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,43 +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
16
  /** Righe lasciate libere sotto il frame. La condizione di Ink è `>=`, quindi
54
17
  * basterebbe 1; ne teniamo 1 come margine per l'a-capo del cursore. */
55
18
  export const SLACK = 1;
@@ -75,6 +38,7 @@ const MIN_SESSION_ROWS = 3;
75
38
  export const MODAL_HEIGHT = {
76
39
  normal: 0,
77
40
  create: 4, // marginTop + 2 bordi + 1 riga input
41
+ note: 4, // T53 — gemello di create: marginTop + 2 bordi + 1 riga input
78
42
  sort: 5, // marginTop + 2 bordi + titolo + 1 riga catena
79
43
  filter: 6, // marginTop + 2 bordi + titolo + 2 righe (pri, stato)
80
44
  edit: 8, // marginTop + 2 bordi + titolo + 3 campi + riga anteprima
@@ -232,53 +196,6 @@ export function searchPreviewCapacity(capacity, listRows) {
232
196
  export function isCompact(capacity) {
233
197
  return capacity < 1;
234
198
  }
235
- /**
236
- * A-capo che PRESERVA la struttura del testo e traccia gli offset.
237
- *
238
- * Distinto da `wrapLines`, che collassa tutto in un flusso unico: lì serviva una
239
- * preview compatta di 2-4 righe, qui si legge un messaggio intero — appiattire
240
- * le newline renderebbe illeggibile qualunque blocco di codice o elenco.
241
- *
242
- * Ogni riga prodotta è una FETTA CONTIGUA del sorgente: è ciò che permette di
243
- * intersecarla con l'intervallo del match e colorare solo la parte giusta,
244
- * anche quando il match cade a cavallo di due righe.
245
- *
246
- * Taglia sull'ultimo spazio disponibile; una parola più larga della riga viene
247
- * spezzata a forza, altrimenti l'a-capo lo farebbe il terminale — fuori dal
248
- * conteggio delle righe, cioè di nuovo un frame più alto di `rows`.
249
- */
250
- export function wrapWithOffsets(text, width) {
251
- const out = [];
252
- if (width <= 0)
253
- return out;
254
- let base = 0;
255
- for (const raw of text.split('\n')) {
256
- // Tab e CR diventano UN singolo spazio, non quattro: la sostituzione deve
257
- // conservare la lunghezza, o gli offset delle righe smettono di indicizzare
258
- // il sorgente e il match verrebbe evidenziato spostato. Un tab lasciato
259
- // passare sarebbe l'errore opposto — lo conteremmo 1 e il terminale 8.
260
- const line = raw.replace(/[\t\r]/g, ' ');
261
- if (line.length === 0) {
262
- out.push({ text: '', start: base, end: base });
263
- }
264
- let pos = 0;
265
- while (pos < line.length) {
266
- if (line.length - pos <= width) {
267
- out.push({ text: line.slice(pos), start: base + pos, end: base + line.length });
268
- break;
269
- }
270
- let brk = line.lastIndexOf(' ', pos + width);
271
- if (brk <= pos)
272
- brk = pos + width; // parola più lunga della riga
273
- out.push({ text: line.slice(pos, brk), start: base + pos, end: base + brk });
274
- pos = brk;
275
- while (line[pos] === ' ')
276
- pos++; // lo spazio del taglio non apre la riga dopo
277
- }
278
- base += raw.length + 1; // +1 = il '\n' consumato dallo split
279
- }
280
- return out;
281
- }
282
199
  /**
283
200
  * Finestra scorrevole su una lista più lunga della capienza.
284
201
  *
@@ -297,58 +214,3 @@ export function windowRange(total, selected, capacity) {
297
214
  const start = Math.max(0, Math.min(sel - Math.floor(capacity / 2), total - capacity));
298
215
  return { start, end: start + capacity };
299
216
  }
300
- /**
301
- * Hard-wrap a larghezza fissa, con tetto di righe.
302
- *
303
- * Serve un conteggio righe DETERMINISTICO: `<Text wrap="wrap">` di Ink wrappa a
304
- * runtime su una larghezza che il budget non conosce, quindi il pannello
305
- * dettaglio potrebbe sforare il tetto e riaprire il bug. Qui il testo viene
306
- * spezzato prima, e ogni riga è renderizzata con `wrap="truncate-end"`.
307
- *
308
- * Sottostimare `width` è sicuro (tronca prima), sovrastimarlo no (la riga
309
- * andrebbe a capo aggiungendo altezza non contabilizzata).
310
- */
311
- export function wrapLines(text, width, maxLines) {
312
- if (maxLines <= 0 || width <= 0)
313
- return [];
314
- const flat = text.replace(/\s+/g, ' ').trim();
315
- if (!flat)
316
- return [];
317
- const lines = [];
318
- let line = '';
319
- for (const word of flat.split(' ')) {
320
- if (!line) {
321
- line = word;
322
- }
323
- else if (line.length + 1 + word.length <= width) {
324
- line += ' ' + word;
325
- }
326
- else {
327
- lines.push(line);
328
- line = word;
329
- if (lines.length === maxLines)
330
- break;
331
- }
332
- // Parola più lunga della riga: spezzala a forza, altrimenti l'a-capo lo
333
- // farebbe il terminale — fuori dal nostro conteggio.
334
- while (line.length > width) {
335
- lines.push(line.slice(0, width));
336
- line = line.slice(width);
337
- if (lines.length === maxLines)
338
- break;
339
- }
340
- if (lines.length === maxLines)
341
- break;
342
- }
343
- if (line && lines.length < maxLines)
344
- lines.push(line);
345
- const kept = lines.slice(0, maxLines);
346
- // Troncamento MAI silenzioso, come le liste: l'ellissi segnala che il testo
347
- // continua oltre il pannello.
348
- const consumed = kept.join(' ').length;
349
- if (consumed < flat.length && kept.length > 0) {
350
- const last = kept[kept.length - 1];
351
- kept[kept.length - 1] = last.length >= width ? last.slice(0, Math.max(0, width - 1)) + '…' : last + '…';
352
- }
353
- return kept;
354
- }
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.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
5
5
  "type": "module",
6
6
  "bin": {