@lamemind/loom-deck 0.18.1 → 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,8 +1,9 @@
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';
6
+ import { EventEmitter } from 'node:events';
6
7
  import { statSync } from 'node:fs';
7
8
  import { randomUUID } from 'node:crypto';
8
9
  import { fileURLToPath } from 'node:url';
@@ -14,7 +15,7 @@ import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSess
14
15
  import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, rowLabel, selectedSession, stripProjectCore, } from './session-list.js';
15
16
  import { launchLegend, loadIdentity, loadLaunch } from './config.js';
16
17
  import { isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
17
- import { cut, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
18
+ import { caretWindow, cut, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
18
19
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
19
20
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
20
21
  import { loadView, saveView, viewFilePath } from './view-store.js';
@@ -29,6 +30,36 @@ const MAX_SESSIONS = 30;
29
30
  // meta "spot" (sentinella) che raccoglie le sessioni NON legate ad alcuna task.
30
31
  // La selezione nel Tasks pane è il "padre"; il Sessions pane mostra i suoi figli.
31
32
  const SPOT = Symbol('spot');
33
+ const EDIT_ROWS = 4;
34
+ /** Le righe del modale edit che sono campi di TESTO (il resto è scelta ←→). */
35
+ function isTextRow(r) {
36
+ return r === 2 || r === 3;
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
+ }
32
63
  // Modale sort a grammatica libera: un tasto per chiave, pressioni successive
33
64
  // ciclano asc → desc → fuori dalla chain.
34
65
  const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
@@ -61,11 +92,36 @@ const EDIT_PROG = ['todo', 'wip', 'done', 'locked'];
61
92
  function isDone(prog) {
62
93
  return prog.includes('✔');
63
94
  }
95
+ /**
96
+ * Freno agli effetti VERSO L'ESTERNO (tab Ptyxis, sessioni Claude, git commit).
97
+ *
98
+ * Il gate di larghezza avvia il deck vero in uno pseudo-terminale e gli manda
99
+ * tasti — e in questa TUI un tasto è un'azione: `⏎` su una riga sessione apre
100
+ * una tab Ptyxis, `t` un terminale, `⏎` nel modale edit committa. Ogni run dei
101
+ * test apriva quindi finestre reali sulla macchina di chi li lanciava, in
102
+ * qualunque progetto avesse in focus.
103
+ *
104
+ * Il gate va tenuto sul deck VERO (è tutto il suo valore: misura il frame che
105
+ * VTE disegna davvero), quindi il freno sta qui: `LOOM_DECK_NO_SPAWN=1` fa
106
+ * restituire un figlio finto e inerte invece di lanciare il processo. Non è un
107
+ * mock del comportamento — l'azione semplicemente non avviene, e il frame che il
108
+ * test misura resta identico.
109
+ */
110
+ const NO_SPAWN = process.env.LOOM_DECK_NO_SPAWN === '1';
111
+ function spawnOut(cmd, args, opts) {
112
+ if (!NO_SPAWN)
113
+ return spawn(cmd, args, opts);
114
+ // Figlio inerte: emette nulla, quindi i `.on('error'|'close')` dei chiamanti
115
+ // restano appesi senza mai scattare — che è esattamente "non è successo niente".
116
+ const fake = new EventEmitter();
117
+ fake.unref = () => fake;
118
+ return fake;
119
+ }
64
120
  // Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
65
121
  // ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
66
122
  // sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
67
123
  function spawnDeck(id, cwd, sessionId) {
68
- const child = spawn(DECK_RUN, [id, '--session-id', sessionId], {
124
+ const child = spawnOut(DECK_RUN, [id, '--session-id', sessionId], {
69
125
  cwd,
70
126
  detached: true,
71
127
  stdio: 'ignore',
@@ -81,7 +137,7 @@ function spawnDeck(id, cwd, sessionId) {
81
137
  // continuarla, non iniettarle un messaggio (lo salta deck-run).
82
138
  function spawnDeckResume(taskId, cwd, sessionId) {
83
139
  const args = taskId ? [taskId, '--resume', sessionId] : ['--no-task', '--resume', sessionId];
84
- const child = spawn(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
140
+ const child = spawnOut(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
85
141
  child.unref();
86
142
  return child;
87
143
  }
@@ -102,7 +158,7 @@ function spawnDeckFork(taskId, cwd, originId, newId) {
102
158
  '--session-id',
103
159
  newId,
104
160
  ];
105
- const child = spawn(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
161
+ const child = spawnOut(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
106
162
  child.unref();
107
163
  return child;
108
164
  }
@@ -113,7 +169,7 @@ function spawnDeckFork(taskId, cwd, originId, newId) {
113
169
  // sporcherebbe il percorso bound. Il titolo tab resta la label loom — lo mette
114
170
  // deck-run, perché il match compass è window-level e non sa nulla di task.
115
171
  function spawnClaudeEmpty(cwd) {
116
- const child = spawn(DECK_RUN, ['--no-task'], {
172
+ const child = spawnOut(DECK_RUN, ['--no-task'], {
117
173
  cwd,
118
174
  detached: true,
119
175
  stdio: 'ignore',
@@ -130,7 +186,7 @@ function spawnClaudeEmpty(cwd) {
130
186
  // esplicito in project-config-architecture.md). La project root arriva via cwd,
131
187
  // non interpolata nella stringa.
132
188
  function runLaunch(entry, cwd) {
133
- const child = spawn('bash', ['-lic', entry.command], {
189
+ const child = spawnOut('bash', ['-lic', entry.command], {
134
190
  cwd,
135
191
  detached: true,
136
192
  stdio: 'ignore',
@@ -150,7 +206,7 @@ function runLaunch(entry, cwd) {
150
206
  // dal radar finché quella tab è in primo piano).
151
207
  function spawnTerminal(cwd, title) {
152
208
  const args = title ? ['--tab', '-T', title, '-d', cwd] : ['--tab', '-d', cwd];
153
- const child = spawn('ptyxis', args, { cwd, detached: true, stdio: 'ignore' });
209
+ const child = spawnOut('ptyxis', args, { cwd, detached: true, stdio: 'ignore' });
154
210
  child.unref();
155
211
  return child;
156
212
  }
@@ -167,7 +223,7 @@ const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
167
223
  // completa commit+push da sé; stdout in pipe SOLO per leggere il result event.
168
224
  // Il prompt viaggia come singolo argv (no shell) → nessuna injection dal testo utente.
169
225
  function spawnCreateTask(text, cwd, sessionId, onResult) {
170
- const child = spawn(CLAUDE_CMD, [
226
+ const child = spawnOut(CLAUDE_CMD, [
171
227
  '-p',
172
228
  '--output-format',
173
229
  'stream-json',
@@ -210,7 +266,7 @@ function spawnCreateTask(text, cwd, sessionId, onResult) {
210
266
  // veloce e il suo esito va riportato nella nota. stderr raccolto per dire perché
211
267
  // ha fallito (identità git assente, hook che rifiuta, …) invece di un generico ⚠.
212
268
  function commitTaskEdit(cwd, paths, message, onResult) {
213
- const child = spawn('git', ['commit', '-m', message, '--', ...paths], {
269
+ const child = spawnOut('git', ['commit', '-m', message, '--', ...paths], {
214
270
  cwd,
215
271
  stdio: ['ignore', 'ignore', 'pipe'],
216
272
  });
@@ -623,6 +679,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
623
679
  // da default. La priorità arriva dal glifo di tasks.md (già in `selTask`), lo
624
680
  // stato dal suo glifo Prog; il progresso arbitrario dal campo `Progress` del
625
681
  // task file — ma solo se è davvero custom (vedi `initialDetail`).
682
+ //
683
+ // Il titolo si semina dalla riga di tasks.md e non dall'H1 del task file per
684
+ // due ragioni: è la fonte che esiste SEMPRE (un task file può mancare), ed è
685
+ // il testo che l'utente sta guardando in lista quando preme `E`. Grezzo
686
+ // (`rawDesc`), non sanificato: rimandare a disco la forma sanificata
687
+ // riscriverebbe i glifi anche senza toccare il campo.
626
688
  function openEdit() {
627
689
  if (!selTask)
628
690
  return;
@@ -631,6 +693,10 @@ function Deck({ cwd, tasksPath, tasksDir }) {
631
693
  pri: priName(selTask.pri) ?? 'med',
632
694
  prog,
633
695
  detail: initialDetail(detail?.fields['Progress'] ?? '', prog),
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,
634
700
  });
635
701
  setEditRow(0);
636
702
  setNote('');
@@ -648,9 +714,23 @@ function Deck({ cwd, tasksPath, tasksDir }) {
648
714
  setEdit(null);
649
715
  if (!task || !draft)
650
716
  return;
717
+ // Il titolo si scrive solo se è CAMBIATO davvero: rimandarlo identico
718
+ // riscriverebbe comunque la cella (collassando spazi ed escape) e sporcherebbe
719
+ // il diff di una riga per un edit di sola priorità. Vuoto → scartato: una
720
+ // task senza descrizione in overview non è più riconoscibile.
721
+ const title = draft.title.trim();
722
+ const titleChanged = title.length > 0 && title !== task.rawDesc.trim();
651
723
  let res;
652
724
  try {
653
- res = writeTaskEdit({ tasksPath, tasksDir, id: task.id, ...draft });
725
+ res = writeTaskEdit({
726
+ tasksPath,
727
+ tasksDir,
728
+ id: task.id,
729
+ pri: draft.pri,
730
+ prog: draft.prog,
731
+ detail: draft.detail,
732
+ title: titleChanged ? title : undefined,
733
+ });
654
734
  }
655
735
  catch (e) {
656
736
  setNote(`⚠ ${task.id}: scrittura fallita (${e.message})`);
@@ -660,9 +740,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
660
740
  setNote(`⚠ ${task.id}: nessun campo aggiornabile (riga o task file assenti)`);
661
741
  return;
662
742
  }
663
- const summary = `${PRI_GLYPH[draft.pri]} ${PRI_LABEL[draft.pri]} · ${res.progress}`;
743
+ const summary = `${PRI_GLYPH[draft.pri]} ${PRI_LABEL[draft.pri]} · ${res.progress}${titleChanged ? ` · "${cut(sanitize(title), 32)}"` : ''}`;
664
744
  setNote(`⏳ ${task.id} → ${summary} · commit…`);
665
- commitTaskEdit(cwd, res.paths, `chore(${task.id}): pri ${PRI_LABEL[draft.pri]} · stato ${res.progress}`, (ok, err) => {
745
+ commitTaskEdit(cwd, res.paths, `chore(${task.id}): pri ${PRI_LABEL[draft.pri]} · stato ${res.progress}${titleChanged ? ' · titolo' : ''}`, (ok, err) => {
666
746
  setNote(ok ? `✔ ${task.id} → ${summary} · committato` : `⚠ ${task.id} salvato, commit fallito: ${err}`);
667
747
  });
668
748
  }
@@ -961,9 +1041,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
961
1041
  }
962
1042
  return;
963
1043
  }
964
- // T41 — modale edit: griglia a 3 righe. Righe 0/1 = scelta a valore singolo
965
- // (←→ scorre), riga 2 = testo libero (ogni carattere stampabile entra nel
966
- // progresso). Come gli altri modali cattura tutto: `esc` annulla senza
1044
+ // T41 — modale edit: griglia a 4 righe. Righe 0/1 = scelta a valore singolo
1045
+ // (←→ scorre), righe 2/3 = testo libero (ogni carattere stampabile entra nel
1046
+ // campo). Come gli altri modali cattura tutto: `esc` annulla senza
967
1047
  // scrivere né uscire dal deck.
968
1048
  if (mode === 'edit') {
969
1049
  if (key.escape) {
@@ -974,13 +1054,15 @@ function Deck({ cwd, tasksPath, tasksDir }) {
974
1054
  else if (key.return) {
975
1055
  submitEdit();
976
1056
  }
977
- else if (key.upArrow) {
978
- setEditRow((r) => ((r + 2) % 3));
979
- }
980
- else if (key.downArrow) {
981
- setEditRow((r) => ((r + 1) % 3));
982
- }
983
- else if (key.leftArrow || key.rightArrow) {
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));
1064
+ }
1065
+ else if ((key.leftArrow || key.rightArrow) && !isTextRow(editRow)) {
984
1066
  const d = key.leftArrow ? -1 : 1;
985
1067
  // Scorrimento CICLICO (wrap) e non clampato: le liste sono di 3-4 voci,
986
1068
  // arrivare in fondo e ripartire costa meno di invertire direzione.
@@ -993,12 +1075,52 @@ function Deck({ cwd, tasksPath, tasksDir }) {
993
1075
  : e);
994
1076
  }
995
1077
  }
996
- else if (editRow === 2) {
997
- if (key.backspace || key.delete) {
998
- setEdit((e) => (e ? { ...e, detail: e.detail.slice(0, -1) } : e));
1078
+ else if (isTextRow(editRow)) {
1079
+ // Un solo ramo per i due campi di testo, la riga sceglie la chiave:
1080
+ // duplicarlo significherebbe tenere allineate a mano due copie della
1081
+ // stessa grammatica di input a ogni tasto aggiunto.
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));
999
1104
  }
1000
- else if (input && !key.ctrl && !key.meta) {
1001
- setEdit((e) => (e ? { ...e, detail: e.detail + input } : e));
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);
1111
+ }
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) {
1116
+ // `sanitizeTyped`: `useInput` consegna il CHUNK di stdin, quindi un
1117
+ // incollaggio porta dentro newline e byte di controllo — invisibili
1118
+ // nel campo ma contati da Ink nella larghezza della riga, e destinati
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);
1002
1124
  }
1003
1125
  }
1004
1126
  return;
@@ -1285,7 +1407,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1285
1407
  if (budget.compact) {
1286
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"] })] }));
1287
1409
  }
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] }));
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] }));
1289
1411
  }
1290
1412
  const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
1291
1413
  // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
@@ -1307,13 +1429,33 @@ function FilterModal({ view, cursor }) {
1307
1429
  return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ", sanitize(e.glyph)] }, e.name));
1308
1430
  })] }, row.label)))] }));
1309
1431
  }
1432
+ /**
1433
+ * Campo di testo del modale edit: finestra ancorata al caret + cursore inverso
1434
+ * nella posizione REALE.
1435
+ *
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.
1440
+ */
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) })] }));
1444
+ }
1310
1445
  // T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
1311
1446
  // coprirli: la riga che stai modificando resta visibile sopra la lista).
1312
1447
  // La riga di anteprima mostra il testo ESATTO che finirà nel campo `Progress`
1313
1448
  // del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
1314
- function EditModal({ id, draft, row }) {
1449
+ function EditModal({ id, draft, row, columns, }) {
1315
1450
  const mark = (r) => (row === r ? CARET : CARET_OFF);
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))] })] }));
1451
+ // Budget dei campi di testo, DERIVATO da `columns` (mai una costante): il box
1452
+ // del modale è ANNIDATO nella cornice del deck, quindi le cornici da scalare
1453
+ // sono due — root (bordo 2 + paddingX 2) e modale (bordo 2 + paddingX 2) — più
1454
+ // caret 2, etichetta 6, gap 2 e cursore 1. Totale 19.
1455
+ // Un titolo di tasks.md arriva a ~64 caratteri: senza taglio la riga va a capo
1456
+ // dentro il box, che si alza di una riga e sfonda il budget verticale (invariante ③).
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))] })] }));
1317
1459
  }
1318
1460
  // T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
1319
1461
  // caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
package/dist/task-edit.js CHANGED
@@ -75,18 +75,27 @@ export function initialDetail(current, prog, date = today()) {
75
75
  return stripProgGlyph(s);
76
76
  }
77
77
  /**
78
- * Riscrive le celle Pri (col 2) e Prog (col 4) della riga `| Tnn | |` in
79
- * tasks.md. Solo la PRIMA riga con quell'id l'overview è unica, un secondo
80
- * match sarebbe un duplicato da non propagare. Le altre celle (K, descrizione)
81
- * restano i token grezzi originali: nessun re-flow della tabella, il diff resta
82
- * di una riga. `ok:false` = id assente → il chiamante non scrive nulla.
78
+ * Riscrive le celle Pri (col 2), Prog (col 4) e se `desc` è passata la
79
+ * descrizione (col 5) della riga `| Tnn | |` in tasks.md. Solo la PRIMA riga
80
+ * con quell'id l'overview è unica, un secondo match sarebbe un duplicato da
81
+ * non propagare. Le celle non toccate restano i token grezzi originali: nessun
82
+ * re-flow della tabella, il diff resta di una riga. `ok:false` = id assente →
83
+ * il chiamante non scrive nulla.
84
+ *
85
+ * `desc` undefined ≠ `desc` vuota: la prima non tocca la cella (chiamata
86
+ * legacy, solo pri/prog), la seconda la svuota davvero.
87
+ *
88
+ * La descrizione è l'ULTIMA colonna e può contenere `|` — che in una tabella
89
+ * markdown spezza la cella. Riscriverla significa quindi collassare tutte le
90
+ * celle da 5 fino alla penultima in una sola (`splice`), altrimenti i pezzi
91
+ * della vecchia descrizione resterebbero appesi in coda alla nuova.
83
92
  *
84
93
  * L'id deve rispettare `TASK_ID_RE` (importato da tasks.ts — stesso gate di
85
94
  * `parseTasks`, non una copia): senza, un id arbitrario matcherebbe la riga di
86
95
  * HEADER (`| ID | Pri | K | Prog |`) o quella di separatore, riscrivendone le
87
96
  * celle e sfondando la tabella.
88
97
  */
89
- export function updateTasksMdRow(content, id, priGlyph, progGlyph) {
98
+ export function updateTasksMdRow(content, id, priGlyph, progGlyph, desc) {
90
99
  if (!TASK_ID_RE.test(id))
91
100
  return { content, ok: false };
92
101
  let ok = false;
@@ -103,20 +112,49 @@ export function updateTasksMdRow(content, id, priGlyph, progGlyph) {
103
112
  ok = true;
104
113
  cells[2] = ` ${priGlyph} `;
105
114
  cells[4] = ` ${progGlyph} `;
115
+ if (desc !== undefined) {
116
+ // Da cells[5] fino alla penultima: l'ultima è la coda dopo il `|` finale
117
+ // (stringa vuota su una riga ben formata) e va conservata, o la riga
118
+ // perderebbe il proprio bordo destro.
119
+ cells.splice(5, cells.length - 6, ` ${sanitizeCell(desc)} `);
120
+ }
106
121
  return cells.join('|');
107
122
  });
108
123
  return { content: ok ? lines.join('\n') : content, ok };
109
124
  }
110
125
  /**
111
- * Riscrive i bullet header `- **Priority**:` e `- **Progress**:` del task file.
126
+ * Rende un testo scrivibile dentro una CELLA di tabella markdown. Due caratteri
127
+ * la romperebbero e non sono rappresentabili altrimenti su una riga sola:
128
+ * il `|` (chiude la cella) e l'a-capo (chiude la riga). Il primo va escapato,
129
+ * il secondo collassato a spazio. È l'unico posto dove il titolo viene toccato:
130
+ * nel task file, che è markdown libero, va scritto verbatim.
131
+ */
132
+ function sanitizeCell(s) {
133
+ return s.replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim();
134
+ }
135
+ /**
136
+ * Riscrive i bullet header `- **Priority**:` e `- **Progress**:` del task file,
137
+ * più — se `title` è passato — l'H1 di testa.
112
138
  * First-match-wins per chiave, stessa regola di `parseTaskDetail`: se un campo
113
139
  * ricompare nel body (residuo template) vince quello dell'header — così ciò che
114
140
  * il deck mostra e ciò che scrive restano la stessa riga.
141
+ *
142
+ * L'H1 conserva il proprio CAPPELLO (`# Task: …` code, `# Doc Task: …` doc):
143
+ * `parseTaskDetail` lo strippa in lettura, quindi il titolo che arriva dal
144
+ * modale non ce l'ha e riscrivere la riga nuda perderebbe la categoria. Il
145
+ * prefisso si rilegge dalla riga esistente e si ri-antepone tale e quale — così
146
+ * una T resta `# Task:` e una D resta `# Doc Task:` senza doverlo sapere qui.
115
147
  */
116
- export function updateTaskFileFields(content, priLabel, progress) {
148
+ export function updateTaskFileFields(content, priLabel, progress, title) {
117
149
  let priDone = false;
118
150
  let progDone = false;
151
+ let titleDone = false;
119
152
  const lines = content.split('\n').map((line) => {
153
+ if (title !== undefined && !titleDone && /^#\s+/.test(line)) {
154
+ titleDone = true;
155
+ const prefix = line.match(/^#\s+((?:Doc\s+)?Task:\s*)/)?.[1] ?? '';
156
+ return `# ${prefix}${title.replace(/\s+/g, ' ').trim()}`;
157
+ }
120
158
  if (!priDone && /^-\s*\*\*Priority\*\*:/.test(line)) {
121
159
  priDone = true;
122
160
  return `- **Priority**: ${priLabel}`;
@@ -127,7 +165,7 @@ export function updateTaskFileFields(content, priLabel, progress) {
127
165
  }
128
166
  return line;
129
167
  });
130
- const ok = priDone || progDone;
168
+ const ok = priDone || progDone || titleDone;
131
169
  return { content: ok ? lines.join('\n') : content, ok };
132
170
  }
133
171
  /**
@@ -138,10 +176,10 @@ export function updateTaskFileFields(content, priLabel, progress) {
138
176
  * si scrive ciò che esiste e il risultato dice cosa è stato toccato.
139
177
  */
140
178
  export function writeTaskEdit(input) {
141
- const { tasksPath, tasksDir, id, pri, prog, detail } = input;
179
+ const { tasksPath, tasksDir, id, pri, prog, detail, title } = input;
142
180
  const progress = progressText(prog, detail);
143
181
  const paths = [];
144
- const row = updateTasksMdRow(readFileSync(tasksPath, 'utf8'), id, PRI_GLYPH[pri], PROG_GLYPH[prog]);
182
+ const row = updateTasksMdRow(readFileSync(tasksPath, 'utf8'), id, PRI_GLYPH[pri], PROG_GLYPH[prog], title);
145
183
  if (row.ok) {
146
184
  writeFileSync(tasksPath, row.content, 'utf8');
147
185
  paths.push(tasksPath);
@@ -149,7 +187,7 @@ export function writeTaskEdit(input) {
149
187
  let fileUpdated = false;
150
188
  const taskFile = findTaskFile(tasksDir, id);
151
189
  if (taskFile) {
152
- const upd = updateTaskFileFields(readFileSync(taskFile, 'utf8'), PRI_LABEL[pri], progress);
190
+ const upd = updateTaskFileFields(readFileSync(taskFile, 'utf8'), PRI_LABEL[pri], progress, title);
153
191
  if (upd.ok) {
154
192
  writeFileSync(taskFile, upd.content, 'utf8');
155
193
  paths.push(taskFile);
package/dist/tasks.js CHANGED
@@ -52,7 +52,7 @@ export function parseTasks(content) {
52
52
  // il glifo, e `task-edit` lo riscrive in tasks.md. Sanificarli qui
53
53
  // renderebbe `✔` un `✅` anche su disco, cambiando il formato di famiglia.
54
54
  // La sanificazione di quei due avviene al display (`displayProg`).
55
- tasks.push({ id, pri, prog, desc: sanitize(desc) });
55
+ tasks.push({ id, pri, prog, desc: sanitize(desc), rawDesc: desc });
56
56
  }
57
57
  return tasks;
58
58
  }
package/dist/viewport.js CHANGED
@@ -41,7 +41,7 @@ export const MODAL_HEIGHT = {
41
41
  note: 4, // T53 — gemello di create: marginTop + 2 bordi + 1 riga input
42
42
  sort: 5, // marginTop + 2 bordi + titolo + 1 riga catena
43
43
  filter: 6, // marginTop + 2 bordi + titolo + 2 righe (pri, stato)
44
- edit: 8, // marginTop + 2 bordi + titolo + 3 campi + riga anteprima
44
+ edit: 9, // marginTop + 2 bordi + titolo + 4 campi (pri, stato, prog, titolo) + riga anteprima
45
45
  // T52 — search e reader sono gli unici modali NON in flusso: sostituiscono i
46
46
  // due pane invece di spingerli giù (una lista di occorrenze non entra in un
47
47
  // box sopra il deck). Costo 0 nel budget dei pane perché quel budget non
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.18.1",
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": {