@lamemind/loom-deck 0.19.0 → 0.20.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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { render, Box, Text, useApp, useInput, useStdout } from 'ink';
4
4
  import { useState, useEffect, useMemo, useRef } from 'react';
5
5
  import { spawn } from 'node:child_process';
@@ -15,7 +15,7 @@ import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSess
15
15
  import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, rowLabel, selectedSession, stripProjectCore, } from './session-list.js';
16
16
  import { launchLegend, loadIdentity, loadLaunch } from './config.js';
17
17
  import { isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
18
- import { cut, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
18
+ import { caretWindow, cut, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
19
19
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
20
20
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
21
21
  import { loadView, saveView, viewFilePath } from './view-store.js';
@@ -35,6 +35,31 @@ const EDIT_ROWS = 4;
35
35
  function isTextRow(r) {
36
36
  return r === 2 || r === 3;
37
37
  }
38
+ /** Chiave della bozza scritta dalla riga di testo `r`. */
39
+ function editField(r) {
40
+ return r === 2 ? 'detail' : 'title';
41
+ }
42
+ /**
43
+ * Lunghezza in CODE POINT. Il caret ci indicizza sopra: `.length` conterebbe le
44
+ * code unit UTF-16 e un'emoji nel titolo varrebbe 2 posizioni, cioè un cursore
45
+ * che si ferma a metà glifo e un `slice` che lo spezza in due surrogati.
46
+ */
47
+ function cpLen(s) {
48
+ return [...s].length;
49
+ }
50
+ /** Inserisce `ins` alla posizione `at` (code point). */
51
+ function insertAt(s, at, ins) {
52
+ const cp = [...s];
53
+ return cp.slice(0, at).join('') + ins + cp.slice(at).join('');
54
+ }
55
+ /** Toglie il code point in posizione `at`; fuori range = stringa invariata. */
56
+ function removeAt(s, at) {
57
+ const cp = [...s];
58
+ if (at < 0 || at >= cp.length)
59
+ return s;
60
+ cp.splice(at, 1);
61
+ return cp.join('');
62
+ }
38
63
  // Modale sort a grammatica libera: un tasto per chiave, pressioni successive
39
64
  // ciclano asc → desc → fuori dalla chain.
40
65
  const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
@@ -669,6 +694,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
669
694
  prog,
670
695
  detail: initialDetail(detail?.fields['Progress'] ?? '', prog),
671
696
  title: selTask.rawDesc,
697
+ // Si apre sulla riga 0 (priorità), che non è un campo di testo: il caret
698
+ // prende la sua posizione entrando in una riga di testo con ↑↓.
699
+ caret: 0,
672
700
  });
673
701
  setEditRow(0);
674
702
  setNote('');
@@ -1026,11 +1054,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1026
1054
  else if (key.return) {
1027
1055
  submitEdit();
1028
1056
  }
1029
- else if (key.upArrow) {
1030
- setEditRow((r) => ((r + EDIT_ROWS - 1) % EDIT_ROWS));
1031
- }
1032
- else if (key.downArrow) {
1033
- setEditRow((r) => ((r + 1) % EDIT_ROWS));
1057
+ else if (key.upArrow || key.downArrow) {
1058
+ const next = ((editRow + EDIT_ROWS + (key.upArrow ? -1 : 1)) % EDIT_ROWS);
1059
+ setEditRow(next);
1060
+ // Il caret segue la riga attiva e atterra in CODA al nuovo campo: è la
1061
+ // posizione da cui si continua a scrivere, ed è anche l'unica che non
1062
+ // dipende da dove stava il cursore nel campo precedente.
1063
+ setEdit((e) => (e && isTextRow(next) ? { ...e, caret: cpLen(e[editField(next)]) } : e));
1034
1064
  }
1035
1065
  else if ((key.leftArrow || key.rightArrow) && !isTextRow(editRow)) {
1036
1066
  const d = key.leftArrow ? -1 : 1;
@@ -1049,16 +1079,48 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1049
1079
  // Un solo ramo per i due campi di testo, la riga sceglie la chiave:
1050
1080
  // duplicarlo significherebbe tenere allineate a mano due copie della
1051
1081
  // stessa grammatica di input a ogni tasto aggiunto.
1052
- const field = editRow === 2 ? 'detail' : 'title';
1053
- if (key.backspace || key.delete) {
1054
- setEdit((e) => (e ? { ...e, [field]: e[field].slice(0, -1) } : e));
1082
+ const field = editField(editRow);
1083
+ if (key.ctrl) {
1084
+ // T54 ramo CTRL ANTEPOSTO a quelli su carattere, come in modalità
1085
+ // normale: `^A` e `a` arrivano con lo stesso `input`, quindi senza
1086
+ // questa precedenza il `^A` finirebbe dentro il testo.
1087
+ //
1088
+ // `^A`/`^E` (convenzione readline) perché `Home`/`End` NON sono
1089
+ // esposte da `useInput`: arrivano come input vuoto, indistinguibili
1090
+ // da qualunque altro tasto senza nome.
1091
+ //
1092
+ // `^D` è il delete-forward, e anche qui il motivo è un limite di Ink:
1093
+ // il tasto Backspace fisico manda `\x7f` e il tasto Canc manda
1094
+ // `\x1b[3~`, ma `parseKeypress` li battezza ENTRAMBI `delete` e
1095
+ // svuota `input` — a valle sono lo stesso evento. `key.delete` va
1096
+ // quindi al backspace (il tasto che si usa davvero) e la
1097
+ // cancellazione in avanti prende il suo tasto readline.
1098
+ if (input === 'a')
1099
+ setEdit((e) => (e ? { ...e, caret: 0 } : e));
1100
+ else if (input === 'e')
1101
+ setEdit((e) => (e ? { ...e, caret: cpLen(e[field]) } : e));
1102
+ else if (input === 'd')
1103
+ setEdit((e) => (e ? { ...e, [field]: removeAt(e[field], e.caret) } : e));
1104
+ }
1105
+ else if (key.leftArrow || key.rightArrow) {
1106
+ // CLAMP agli estremi, non wrap: a inizio campo `←` non deve saltare in
1107
+ // fondo. Le liste di valori (righe 0/1) ciclano perché sono 3-4 voci;
1108
+ // un testo no — il salto sarebbe indistinguibile da uno sfarfallio.
1109
+ const d = key.leftArrow ? -1 : 1;
1110
+ setEdit((e) => e ? { ...e, caret: Math.max(0, Math.min(cpLen(e[field]), e.caret + d)) } : e);
1055
1111
  }
1056
- else if (input && !key.ctrl && !key.meta) {
1112
+ else if (key.backspace || key.delete) {
1113
+ setEdit((e) => e ? { ...e, [field]: removeAt(e[field], e.caret - 1), caret: Math.max(0, e.caret - 1) } : e);
1114
+ }
1115
+ else if (input && !key.meta) {
1057
1116
  // `sanitizeTyped`: `useInput` consegna il CHUNK di stdin, quindi un
1058
1117
  // incollaggio porta dentro newline e byte di controllo — invisibili
1059
1118
  // nel campo ma contati da Ink nella larghezza della riga, e destinati
1060
- // a finire tali e quali dentro tasks.md.
1061
- setEdit((e) => (e ? { ...e, [field]: e[field] + sanitizeTyped(input) } : e));
1119
+ // a finire tali e quali dentro tasks.md. Ed è per lo stesso motivo che
1120
+ // il caret avanza della LUNGHEZZA del chunk, non di uno: un incollaggio
1121
+ // entra tutto insieme.
1122
+ const ins = sanitizeTyped(input);
1123
+ setEdit((e) => e ? { ...e, [field]: insertAt(e[field], e.caret, ins), caret: e.caret + cpLen(ins) } : e);
1062
1124
  }
1063
1125
  }
1064
1126
  return;
@@ -1345,7 +1407,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1345
1407
  if (budget.compact) {
1346
1408
  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"] })] }));
1347
1409
  }
1348
- 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: "testo" }), " su prog/titolo \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, columns: columns })) : 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] }));
1410
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_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, columns: columns })) : 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] }));
1349
1411
  }
1350
1412
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
1351
1413
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -1368,31 +1430,17 @@ function FilterModal({ view, cursor }) {
1368
1430
  })] }, row.label)))] }));
1369
1431
  }
1370
1432
  /**
1371
- * Taglio dalla TESTA: tiene la CODA della stringa dentro `cols`.
1433
+ * Campo di testo del modale edit: finestra ancorata al caret + cursore inverso
1434
+ * nella posizione REALE.
1372
1435
  *
1373
- * `cut()` fa l'opposto (tiene l'inizio) ed è giusto per un testo che si LEGGE;
1374
- * qui il testo si SCRIVE, il cursore sta in fondo (append-only come gli altri
1375
- * campi del deck) e ciò che serve vedere è l'ultima parola digitata, non la
1376
- * prima con `cut()` un titolo lungo sparirebbe sotto l'ellissi proprio mentre
1377
- * lo si sta scrivendo. Budget in COLONNE (`termWidth`), non in code unit: è
1378
- * quello che decide se la riga sfonda il bordo del box.
1436
+ * Il cursore non è più uno spazio inverso appiccicato in coda ma la cella `at`
1437
+ * della finestra — cioè il carattere su cui il caret sta davvero. Fuori fuoco
1438
+ * (`focused` falso) il caret non si disegna e la finestra si ancora in fondo,
1439
+ * che è la vista utile per un campo che non si sta scrivendo.
1379
1440
  */
1380
- function tailCut(s, cols) {
1381
- if (cols <= 0)
1382
- return '';
1383
- if (termWidth(s) <= cols)
1384
- return s;
1385
- const chars = [...s];
1386
- let out = '';
1387
- let w = 1; // la colonna dell'ellissi di testa
1388
- for (let i = chars.length - 1; i >= 0; i--) {
1389
- const cw = termWidth(chars[i]);
1390
- if (w + cw > cols)
1391
- break;
1392
- out = chars[i] + out;
1393
- w += cw;
1394
- }
1395
- return `…${out}`;
1441
+ function EditTextField({ label, value, caret, focused, cols, }) {
1442
+ const win = caretWindow(value, focused ? caret : cpLen(value), cols);
1443
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: label }), _jsxs(Text, { children: [' ', sanitize(win.head)] }), focused ? _jsx(Text, { inverse: true, children: sanitize(win.at) }) : null, _jsx(Text, { children: sanitize(win.tail) })] }));
1396
1444
  }
1397
1445
  // T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
1398
1446
  // coprirli: la riga che stai modificando resta visibile sopra la lista).
@@ -1400,14 +1448,14 @@ function tailCut(s, cols) {
1400
1448
  // del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
1401
1449
  function EditModal({ id, draft, row, columns, }) {
1402
1450
  const mark = (r) => (row === r ? CARET : CARET_OFF);
1403
- // Budget del campo titolo, DERIVATO da `columns` (mai una costante): il box
1451
+ // Budget dei campi di testo, DERIVATO da `columns` (mai una costante): il box
1404
1452
  // del modale è ANNIDATO nella cornice del deck, quindi le cornici da scalare
1405
1453
  // sono due — root (bordo 2 + paddingX 2) e modale (bordo 2 + paddingX 2) — più
1406
1454
  // caret 2, etichetta 6, gap 2 e cursore 1. Totale 19.
1407
1455
  // Un titolo di tasks.md arriva a ~64 caratteri: senza taglio la riga va a capo
1408
1456
  // dentro il box, che si alza di una riga e sfonda il budget verticale (invariante ③).
1409
- const titleBudget = Math.max(8, columns - 19);
1410
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 titolo, 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: [' ', sanitize(draft.detail)] }), row === 2 ? _jsx(Text, { inverse: true, children: " " }) : null, !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { children: [mark(3), _jsx(Text, { dimColor: true, children: "titolo" }), _jsxs(Text, { children: [' ', sanitize(tailCut(draft.title, titleBudget))] }), row === 3 ? _jsx(Text, { inverse: true, children: " " }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", sanitize(progressText(draft.prog, draft.detail))] })] }));
1457
+ const fieldBudget = Math.max(8, columns - 19);
1458
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 titolo, 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(EditTextField, { label: "prog ", value: draft.detail, caret: draft.caret, focused: row === 2, cols: fieldBudget }), !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { children: [mark(3), _jsx(EditTextField, { label: "titolo", value: draft.title, caret: draft.caret, focused: row === 3, cols: fieldBudget })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", sanitize(progressText(draft.prog, draft.detail))] })] }));
1411
1459
  }
1412
1460
  // T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
1413
1461
  // caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
package/dist/width.js CHANGED
@@ -202,6 +202,102 @@ export function cut(s, cols) {
202
202
  // informazione, tolgono una colonna di testo.
203
203
  return out.trimEnd().replace(/…$/, '') + '…';
204
204
  }
205
+ /**
206
+ * Larghezza in colonne di UN carattere **dopo** `sanitize`.
207
+ *
208
+ * Misurare il carattere grezzo significa misurare qualcosa che nel frame non
209
+ * entra mai: la sanificazione può cambiarne la larghezza (`✔`, largo 1, diventa
210
+ * `✅`, largo 2 — invariante ①). Chi taglia su un budget deve contare le colonne
211
+ * che verranno DISEGNATE, altrimenti sbaglia di una colonna per ogni glifo
212
+ * sostituito. La cache di `fixChar` rende la doppia chiamata gratuita.
213
+ */
214
+ export function cellWidth(ch) {
215
+ return termWidth(sanitize(ch));
216
+ }
217
+ /**
218
+ * Porzione visibile di un campo di testo con un caret MOBILE, larga al più
219
+ * `cols` colonne.
220
+ *
221
+ * Perché non basta un taglio dalla coda: mostrare sempre le ultime `cols`
222
+ * colonne è corretto solo finché si scrive in fondo. Con un caret che si muove,
223
+ * appena rientra a sinistra del bordo visibile si finirebbe per digitare in un
224
+ * punto che non si vede — il testo deve scorrere DIETRO il cursore, non
225
+ * viceversa.
226
+ *
227
+ * Il caret è un indice per CODE POINT (`[...s]`), non per code unit: un'emoji
228
+ * nel titolo sono due code unit e muoversi per unità la spezzerebbe a metà.
229
+ *
230
+ * La finestra si espande ALTERNANDO i due lati, così il cursore resta al centro
231
+ * finché il testo lo consente: muoversi di un carattere fa scorrere il testo di
232
+ * uno, invece di far saltare la vista da un bordo all'altro. Le ellissi entrano
233
+ * nel budget e ne escono da sole — quando un lato tocca il bordo del testo la
234
+ * sua ellissi non serve più e la colonna liberata va all'altro lato.
235
+ *
236
+ * Restituisce i tre pezzi già pronti al render (non la sola stringa visibile):
237
+ * il cursore si disegna invertendo `at`, e ricavarlo tagliando `visible` a
238
+ * `cursorCol` vorrebbe dire rifare qui fuori lo stesso conteggio in colonne.
239
+ */
240
+ export function caretWindow(text, caret, cols) {
241
+ const empty = { head: '', at: '', tail: '', cursorCol: 0 };
242
+ if (cols <= 0)
243
+ return empty;
244
+ const chars = [...text];
245
+ const c = Math.max(0, Math.min(caret, chars.length));
246
+ // Il caret a fine campo non ha un carattere sotto di sé: gliene si dà uno
247
+ // virtuale, così il cursore ha sempre una cella da invertire e la finestra ha
248
+ // sempre un centro. È lo spazio inverso che il modale disegnava in coda.
249
+ const cells = c === chars.length ? [...chars, ' '] : chars;
250
+ const len = cells.length;
251
+ const w = cells.map(cellWidth);
252
+ const cost = (s, e, tw) => tw + (s > 0 ? 1 : 0) + (e < len ? 1 : 0);
253
+ let start = c;
254
+ let end = c + 1;
255
+ let textW = w[c];
256
+ // Budget più stretto della sola cella del cursore: non c'è finestra da dare.
257
+ if (cost(start, end, textW) > cols)
258
+ return empty;
259
+ let goRight = true;
260
+ let stuckL = false;
261
+ let stuckR = false;
262
+ while (!stuckL || !stuckR) {
263
+ const canR = end < len && !stuckR;
264
+ const canL = start > 0 && !stuckL;
265
+ if (!canR && !canL)
266
+ break;
267
+ if (canR && (goRight || !canL)) {
268
+ const nw = textW + w[end];
269
+ if (cost(start, end + 1, nw) > cols)
270
+ stuckR = true;
271
+ else {
272
+ textW = nw;
273
+ end++;
274
+ // Arrivare in fondo toglie l'ellissi di coda: la colonna che si libera
275
+ // può rimettere in gioco il lato che si era fermato per un pelo.
276
+ if (end === len)
277
+ stuckL = false;
278
+ }
279
+ }
280
+ else {
281
+ const nw = textW + w[start - 1];
282
+ if (cost(start - 1, end, nw) > cols)
283
+ stuckL = true;
284
+ else {
285
+ textW = nw;
286
+ start--;
287
+ if (start === 0)
288
+ stuckR = false;
289
+ }
290
+ }
291
+ goRight = !goRight;
292
+ }
293
+ const head = (start > 0 ? '…' : '') + cells.slice(start, c).join('');
294
+ return {
295
+ head,
296
+ at: cells[c],
297
+ tail: cells.slice(c + 1, end).join('') + (end < len ? '…' : ''),
298
+ cursorCol: termWidth(sanitize(head)),
299
+ };
300
+ }
205
301
  /**
206
302
  * Indice sorgente raggiunto consumando al più `cols` colonne da `from`.
207
303
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.19.0",
3
+ "version": "0.20.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": {