@lamemind/loom-deck 0.44.0 → 0.45.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/README.md CHANGED
@@ -92,7 +92,7 @@ Assegnazioni correnti:
92
92
  | modale | `S` | sort chain |
93
93
  | modale | `F` | filtri |
94
94
  | modale | `^F` | **ricerca full-text** nelle conversazioni del progetto |
95
- | modale | `⏎` | **detail** della task selezionata: task file scrollabile + barra azioni |
95
+ | modale | `⏎` | **detail** della task selezionata: task file scrollabile + area di compilazione a quattro righe |
96
96
  | modale | `CANC` | **elimina** — la task selezionata; su una riga meta della vista `archiviabili`, l'intero insieme |
97
97
  | immediata | `^K` | spawna la task selezionata col prompt di **recap** |
98
98
  | immediata | `^P` | spawna la task selezionata sul **preflight** |
@@ -161,19 +161,26 @@ Col focus sul pane Sessions né `⏎` né i `CTRL` spawnano: l'oggetto dell'azio
161
161
 
162
162
  ### `⏎` — il detail della task
163
163
 
164
- Overlay fullscreen a due zone: il **task file per intero**, scrollabile
165
- (`↑↓` riga, `PgUp`/`PgDn` pagina, `g`/`G` estremi), e una riga di **bottoni**
166
- navigabile con `←→`, con `open` a fuoco all'apertura. `⏎` esegue l'azione
167
- selezionata e chiude; `esc` chiude lasciando la lista con la stessa selezione.
164
+ Overlay fullscreen a due zone: il **task file per intero**, scrollabile con `PgUp`/`PgDn`, e sotto un'**area di compilazione** a quattro righe — i quattro parametri con cui la sessione sta per nascere.
168
165
 
169
- Le azioni non sono un catalogo nuovo: chiamano lo **stesso** percorso di spawn dei tasti diretti. Una superficie in più, zero percorsi di spawn in più.
166
+ | Riga | Cosa | Come si cambia |
167
+ |---|---|---|
168
+ | `azione` | `open` `preflight` `run` `status` `checkpoint` | `←→`, oppure l'iniziale: `o` `p` `r` `s` `c` |
169
+ | `prompt` | il testo che riceverà la sessione | si scrive; parte pre-riempito dall'azione scelta |
170
+ | `modello` | `fable` `opus` `sonnet` `haiku` | `←→` |
171
+ | `titolo` | il nome della conversazione, in lista e nella tab | si scrive |
172
+
173
+ `↑↓` spostano il fuoco fra le righe, `⏎` spawna coi quattro valori correnti, `esc` chiude lasciando la lista con la stessa selezione. Le righe di testo ricevono i caratteri **solo quando sono in fuoco**: su `azione` una lettera che non è un'iniziale resta inerte invece di finire in un campo.
174
+
175
+ `↑↓` sono quindi una risorsa contesa, e vanno al fuoco: la lettura del task file resta su `PgUp`/`PgDn`, cioè una granularità sola invece di due. È il prezzo di avere `↑↓` nel loro significato di sempre su una schermata che ospita insieme un documento e dei campi.
170
176
 
171
- Sono bottoni affiancati e non voci di un menu verticale perché è la forma che sopravvive all'arrivo del mouse: un rettangolo ha già coordinate e area cliccabile, una lista di voci andrebbe rifatta.
177
+ **Il campo `prompt` è quello che parte davvero**, non un'anteprima: modificarlo cambia il messaggio che la sessione riceve. Cambiare azione lo riscrive col default della nuova col fuoco per riga il cambio accidentale non esiste (`←→` toccano l'azione solo dalla sua riga), quindi non c'è nessuna modifica da proteggere.
172
178
 
173
- Dentro il detail `←→` cicla i bottoni invece di cambiare pane, e `tab` non cambia la vistail
174
- ramo `useInput` della modalità chiude prima di arrivare a quello di default. Ink non ha focus-trap: la cattura **è** l'ordine dei rami, e cambiare cosa fa un tasto fuori dal detail non tocca cosa fa dentro.
179
+ La grammatica delle righe è la **stessa** del modale edit (`SHIFT+E`) e sta in `src/fields.ts`: descrittore delle righe, fuoco, caret, `^A`/`^E`/`^D`/`^U`. Finché era scritta due volte andava tenuta allineata a mano a ogni tasto aggiunto. `Home`/`End` non ci sono e non possono esserci: `useInput` non le espone verificato su otto sequenze diverse, arrivano tutte come input vuoto senza flag da cui `^A`/`^E`.
175
180
 
176
- Il catalogo dei prompt vive in `scripts/deck-run`, non nella TUI: è il primitive UI-agnostico, il deck gli passa un **simbolo** (`--prompt-kind`) e non una stringa. Così il testo sta in un posto solo, e con lui il suo vincolo di quoting il prompt viaggia dentro apici singoli in `bash -lc`, quindi non può contenerne.
181
+ Le azioni non sono un catalogo nuovo: chiamano lo **stesso** percorso di spawn dei tasti diretti. Una superficie in più, zero percorsi di spawn in più. Sono bottoni affiancati e non voci di un menu verticale perché è la forma che sopravvive all'arrivo del mouse: un rettangolo ha già coordinate e area cliccabile, una lista di voci andrebbe rifatta.
182
+
183
+ Il **testo** dei prompt vive in `scripts/prompt-catalog`, un file dati sibling di `deck-run` che entrambi leggono a runtime: il deck per pre-riempire il campo, `deck-run` per comporre il comando in-tab. Come codice posseduto da un lato solo il deck avrebbe dovuto riscriverlo per mostrarlo, e le due copie divergerebbero alla prima voce aggiunta da una parte sola. Un prompt modificato a mano viaggia poi letterale (`--prompt`), perché a quel punto nessun `--prompt-kind` lo descrive più.
177
184
 
178
185
  ### `f` — forkare una conversazione
179
186
 
@@ -301,10 +308,13 @@ npx @lamemind/loom-deck
301
308
  # dalla project dir con un tasks.md
302
309
  scripts/deck-run T18
303
310
  scripts/deck-run T18 --prompt-kind none|recap|preflight|run|checkpoint
311
+ scripts/deck-run T18 --prompt 'testo letterale del prompt'
304
312
  ```
305
313
 
306
314
  Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude 'recap stato task T18'`. Il prompt iniziale è il terzo asse dello spawn, ortogonale al binding task (`<TaskID>` vs `--no-task`) e alla continuità (nuova vs `--resume`/`--fork`): senza flag resta `recap`, `none` apre la sessione bound **senza** alcun prompt. `LOOM_DECK_ENTER_PROMPT` (placeholder `{TASK}`) resta un override che vince sul kind — tranne su `none`, che è una richiesta esplicita di non averne.
307
315
 
316
+ I testi del catalogo stanno in `scripts/prompt-catalog` (formato `kind<TAB>template`, `{TASK}` interpolato), non dentro lo script: li legge anche il deck, per mostrare nel detail il prompt prima dello spawn. `--prompt` passa invece un testo **letterale** ed è esclusivo con `--prompt-kind` — sono due modi di dire la stessa cosa, e accettarli insieme costringerebbe a stabilire quale vince. Il testo viene quotato per la shell in-tab, apici singoli compresi: è l'unico ingresso di testo libero in una riga che una shell parsa.
317
+
308
318
  La tab porta anche `PTYXIS_PROFILE` forzata al profilo bindato al progetto nel registry (`bindings/claude`), letto da dconf: è la chiave con cui loom-compass associa a un progetto lo stato annunciato dagli hook (running/ask/done). Una tab `ptyxis --tab` nuda erediterebbe il profilo di default, e l'annuncio finirebbe keyed su un UUID che nessun progetto dichiara — pallino fermo su idle, senza alcun errore visibile. Override o disattivazione via `LOOM_DECK_STATE_PROFILE`
309
319
  (settata a vuoto → nessun annuncio); progetto non registrato → nessun prefisso.
310
320
 
package/dist/cli.js CHANGED
@@ -13,9 +13,10 @@ import { isCompact, layoutBudget, searchPreviewCapacity, windowRange, } from './
13
13
  import { cut, cutMiddle, sanitize, termWidth } from './width.js';
14
14
  import { applyView, cycleSort, describeSort, idColumnWidth, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
15
15
  import { initialDetail, writeTaskEdit, PRI_GLYPH, PRI_LABEL } from './task-edit.js';
16
- import { ALL, EDIT_PRI, EDIT_PROG, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, QUIT_WINDOW_MS, ROW_ALL, ROW_SPOT, SORT_TASTI, SPOT, } from './model.js';
16
+ import { ALL, EDIT_FIELDS, EDIT_PRI, EDIT_PROG, editTextField, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, QUIT_WINDOW_MS, ROW_ALL, ROW_SPOT, SORT_TASTI, SPOT, } from './model.js';
17
17
  import { purgeTargets, splitTargets } from './purge.js';
18
- import { conversationLabel, cpLen, editField, insertAt, isDone, isTextRow, removeAt, EDIT_ROWS, } from './layout.js';
18
+ import { conversationLabel, isDone } from './layout.js';
19
+ import { fieldsKey } from './fields.js';
19
20
  import { TASK_EMPTY, relTime, sanitizeTyped, taskTail } from './glyphs.js';
20
21
  import { commitTaskEdit, runLaunch, spawnClaudeEmpty, spawnCleanTasks, spawnCreateTask, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, onInTabCommand, CLAUDE_CMD, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
21
22
  import { EditModal, FilterModal, PurgeModal, SortModal, idList } from './ui/modals.js';
@@ -79,9 +80,10 @@ function Deck({ cwd, tasksPath, tasksDir }) {
79
80
  const [taskViewId, setTaskViewId] = useState('tasks');
80
81
  const [sessionViewId, setSessionViewId] = useState('context');
81
82
  const [filterCursor, setFilterCursor] = useState({ row: 0, col: 0 });
82
- // T41 — bozza dell'edit (null fuori dal modale) e riga attiva della griglia.
83
+ // T41 — bozza dell'edit (null fuori dal modale) e posizione nell'area di
84
+ // compilazione: riga in fuoco + caret dentro la riga di testo attiva.
83
85
  const [edit, setEdit] = useState(null);
84
- const [editRow, setEditRow] = useState(0);
86
+ const [editCursor, setEditCursor] = useState({ row: 0, caret: 0 });
85
87
  // T112 — bozza della conferma di eliminazione (null fuori dal modale).
86
88
  const [purge, setPurge] = useState(null);
87
89
  // T116 — uscita a doppio `^C`. Il timer È lo stato dell'armamento: finché il
@@ -387,12 +389,16 @@ function Deck({ cwd, tasksPath, tasksDir }) {
387
389
  // il tempo di un tick la riga in lista comparirebbe nuda. Due record separati
388
390
  // sullo stesso `sessionId` sono la forma normale di un file append-only
389
391
  // last-wins, non una scrittura da fondere.
390
- function spawnForTask(id, kind, model, spawnNote = '') {
392
+ // T117 `prompt` è il testo LETTERALE quando lo spawn arriva dal detail, dove
393
+ // il campo è editabile: quello che l'utente legge è quello che parte. Assente
394
+ // per gli acceleratori della lista, che non hanno un campo da cui prenderlo e
395
+ // viaggiano col simbolo.
396
+ function spawnForTask(id, kind, model, spawnNote = '', prompt) {
391
397
  const sid = randomUUID();
392
398
  appendTaskBinding(cwd, sid, id);
393
399
  if (spawnNote)
394
400
  appendNote(cwd, sid, spawnNote);
395
- const spawned = spawnDeck(id, cwd, sid, kind, model, spawnNote);
401
+ const spawned = spawnDeck(id, cwd, sid, kind, model, spawnNote, prompt);
396
402
  spawned.child.on('error', () => setNote(`⚠ spawn ${id} fallito (${DECK_RUN})`));
397
403
  // Il modello resta SEMPRE visibile anche quando è il default, perché è un
398
404
  // argomento esplicito del comando (T108): gli acceleratori della lista non
@@ -435,8 +441,8 @@ function Deck({ cwd, tasksPath, tasksDir }) {
435
441
  appendNote(cwd, sid, text);
436
442
  reloadSessions();
437
443
  setNote(text
438
- ? `✎ nota su ${sid.slice(0, 8)}: "${cut(text, 40)}"`
439
- : `✎ nota rimossa da ${sid.slice(0, 8)}`);
444
+ ? `✎ titolo su ${sid.slice(0, 8)}: "${cut(text, 40)}"`
445
+ : `✎ titolo rimosso da ${sid.slice(0, 8)}`);
440
446
  }
441
447
  // T41 — apertura dell'edit: la bozza parte dai valori ATTUALI della task, non
442
448
  // da default. La priorità arriva dal glifo di tasks.md (già in `selTask`), lo
@@ -457,11 +463,10 @@ function Deck({ cwd, tasksPath, tasksDir }) {
457
463
  prog,
458
464
  detail: initialDetail(detail?.fields['Progress'] ?? '', prog),
459
465
  title: selTask.rawDesc,
460
- // Si apre sulla riga 0 (priorità), che non è un campo di testo: il caret
461
- // prende la sua posizione entrando in una riga di testo con ↑↓.
462
- caret: 0,
463
466
  });
464
- setEditRow(0);
467
+ // Si apre sulla riga 0 (priorità), che non è un campo di testo: il caret
468
+ // prende la sua posizione entrando in una riga di testo con ↑↓.
469
+ setEditCursor({ row: 0, caret: 0 });
465
470
  setNote('');
466
471
  setMode('edit');
467
472
  }
@@ -726,7 +731,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
726
731
  columns,
727
732
  setMode,
728
733
  setNote,
729
- onAction: (id, kind, model, spawnNote) => spawnForTask(id, kind, model, spawnNote),
734
+ onAction: (id, kind, model, spawnNote, prompt) => spawnForTask(id, kind, model, spawnNote, prompt),
730
735
  });
731
736
  const search = useSearchOverlay({
732
737
  sessions,
@@ -757,7 +762,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
757
762
  if (key.escape) {
758
763
  setMode('normal');
759
764
  setNoteDraft('');
760
- setNote('N → nota annullata');
765
+ setNote('N → titolo annullato');
761
766
  }
762
767
  else if (key.return) {
763
768
  submitNote();
@@ -835,84 +840,31 @@ function Deck({ cwd, tasksPath, tasksDir }) {
835
840
  : { ...v, hiddenProg: toggleHidden(v.hiddenProg, PROG_ENTRIES[col].name) });
836
841
  }
837
842
  }
843
+ // Il ponte fra le quattro righe del modale e la bozza. I valori restano
844
+ // tipizzati (`PriName`, `ProgName`) invece di finire in un record generico:
845
+ // `fields.ts` governa la grammatica dei tasti, non il modello dati.
846
+ const editIO = {
847
+ text: (row) => edit?.[editTextField(row)] ?? '',
848
+ setText: (row, next) => setEdit((e) => (e ? { ...e, [editTextField(row)]: next } : e)),
849
+ choice: (row) => row === 0
850
+ ? Math.max(0, EDIT_PRI.indexOf(edit?.pri ?? 'med'))
851
+ : Math.max(0, EDIT_PROG.indexOf(edit?.prog ?? 'todo')),
852
+ setChoice: (row, index) => setEdit((e) => e ? (row === 0 ? { ...e, pri: EDIT_PRI[index] } : { ...e, prog: EDIT_PROG[index] }) : e),
853
+ };
838
854
  function onEditKey(input, key) {
839
855
  if (key.escape) {
840
856
  setMode('normal');
841
857
  setEdit(null);
842
858
  setNote('E → edit annullato');
859
+ return;
843
860
  }
844
- else if (key.return) {
861
+ if (key.return) {
845
862
  submitEdit();
863
+ return;
846
864
  }
847
- else if (key.upArrow || key.downArrow) {
848
- const next = ((editRow + EDIT_ROWS + (key.upArrow ? -1 : 1)) % EDIT_ROWS);
849
- setEditRow(next);
850
- // Il caret segue la riga attiva e atterra in CODA al nuovo campo: è la
851
- // posizione da cui si continua a scrivere, ed è anche l'unica che non
852
- // dipende da dove stava il cursore nel campo precedente.
853
- setEdit((e) => (e && isTextRow(next) ? { ...e, caret: cpLen(e[editField(next)]) } : e));
854
- }
855
- else if ((key.leftArrow || key.rightArrow) && !isTextRow(editRow)) {
856
- const d = key.leftArrow ? -1 : 1;
857
- // Scorrimento CICLICO (wrap) e non clampato: le liste sono di 3-4 voci,
858
- // arrivare in fondo e ripartire costa meno di invertire direzione.
859
- if (editRow === 0) {
860
- setEdit((e) => e ? { ...e, pri: EDIT_PRI[(EDIT_PRI.indexOf(e.pri) + d + EDIT_PRI.length) % EDIT_PRI.length] } : e);
861
- }
862
- else if (editRow === 1) {
863
- setEdit((e) => e
864
- ? { ...e, prog: EDIT_PROG[(EDIT_PROG.indexOf(e.prog) + d + EDIT_PROG.length) % EDIT_PROG.length] }
865
- : e);
866
- }
867
- }
868
- else if (isTextRow(editRow)) {
869
- // Un solo ramo per i due campi di testo, la riga sceglie la chiave:
870
- // duplicarlo significherebbe tenere allineate a mano due copie della
871
- // stessa grammatica di input a ogni tasto aggiunto.
872
- const field = editField(editRow);
873
- if (key.ctrl) {
874
- // T54 — ramo CTRL ANTEPOSTO a quelli su carattere, come in modalità
875
- // normale: `^A` e `a` arrivano con lo stesso `input`, quindi senza
876
- // questa precedenza il `^A` finirebbe dentro il testo.
877
- //
878
- // `^A`/`^E` (convenzione readline) perché `Home`/`End` NON sono
879
- // esposte da `useInput`: arrivano come input vuoto, indistinguibili
880
- // da qualunque altro tasto senza nome.
881
- //
882
- // `^D` è il delete-forward, e anche qui il motivo è un limite di Ink:
883
- // il tasto Backspace fisico manda `\x7f` e il tasto Canc manda
884
- // `\x1b[3~`, ma `parseKeypress` li battezza ENTRAMBI `delete` e
885
- // svuota `input` — a valle sono lo stesso evento. `key.delete` va
886
- // quindi al backspace (il tasto che si usa davvero) e la
887
- // cancellazione in avanti prende il suo tasto readline.
888
- if (input === 'a')
889
- setEdit((e) => (e ? { ...e, caret: 0 } : e));
890
- else if (input === 'e')
891
- setEdit((e) => (e ? { ...e, caret: cpLen(e[field]) } : e));
892
- else if (input === 'd')
893
- setEdit((e) => (e ? { ...e, [field]: removeAt(e[field], e.caret) } : e));
894
- }
895
- else if (key.leftArrow || key.rightArrow) {
896
- // CLAMP agli estremi, non wrap: a inizio campo `←` non deve saltare in
897
- // fondo. Le liste di valori (righe 0/1) ciclano perché sono 3-4 voci;
898
- // un testo no — il salto sarebbe indistinguibile da uno sfarfallio.
899
- const d = key.leftArrow ? -1 : 1;
900
- setEdit((e) => e ? { ...e, caret: Math.max(0, Math.min(cpLen(e[field]), e.caret + d)) } : e);
901
- }
902
- else if (key.backspace || key.delete) {
903
- setEdit((e) => e ? { ...e, [field]: removeAt(e[field], e.caret - 1), caret: Math.max(0, e.caret - 1) } : e);
904
- }
905
- else if (input && !key.meta) {
906
- // `sanitizeTyped`: `useInput` consegna il CHUNK di stdin, quindi un
907
- // incollaggio porta dentro newline e byte di controllo — invisibili
908
- // nel campo ma contati da Ink nella larghezza della riga, e destinati
909
- // a finire tali e quali dentro tasks.md. Ed è per lo stesso motivo che
910
- // il caret avanza della LUNGHEZZA del chunk, non di uno: un incollaggio
911
- // entra tutto insieme.
912
- const ins = sanitizeTyped(input);
913
- setEdit((e) => e ? { ...e, [field]: insertAt(e[field], e.caret, ins), caret: e.caret + cpLen(ins) } : e);
914
- }
915
- }
865
+ // Tutto il resto è dell'area di compilazione, che è la STESSA del detail
866
+ // (T117): una grammatica sola, non due copie da tenere allineate a mano.
867
+ fieldsKey(input, key, EDIT_FIELDS, editCursor, setEditCursor, editIO);
916
868
  }
917
869
  /**
918
870
  * T116 — `^C`: la prima pressione ARMA, la seconda entro la finestra chiude.
@@ -1187,7 +1139,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1187
1139
  // su una pinnata STALE, perché annotare «questa non c'è più, era X» è
1188
1140
  // proprio il caso in cui una nota serve.
1189
1141
  if (focus !== 'sessions') {
1190
- setNote('N → nota: seleziona una sessione (→ per il pane)');
1142
+ setNote('N → titolo: seleziona una sessione (→ per il pane)');
1191
1143
  }
1192
1144
  else if (!selSessionId) {
1193
1145
  setNote('N → nessuna sessione da annotare');
@@ -1294,7 +1246,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1294
1246
  ? [purgeBulk ? 'CANC elimina tutte' : 'CANC elimina']
1295
1247
  : []),
1296
1248
  ...(canResume ? ['f fork'] : []),
1297
- ...(canPin ? ['p pin', 'N nota', 'A assegna'] : []),
1249
+ // T117 · D3 «titolo» anche qui: è lo stesso valore che il detail chiede
1250
+ // allo spawn, e due nomi per una cosa sola li paga chi legge le due
1251
+ // schermate. Rename di sola ETICHETTA: il dato resta `note` nel sidecar e
1252
+ // `--title-note` in deck-run, dove rinominarlo sarebbe un breaking.
1253
+ ...(canPin ? ['p pin', 'N titolo', 'A assegna'] : []),
1298
1254
  '^F cerca',
1299
1255
  'C nuova',
1300
1256
  'E edit',
@@ -1334,7 +1290,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1334
1290
  // posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
1335
1291
  // può accorciare il testo sotto uno scroll già dato.
1336
1292
  const start = Math.min(sheet.top, sheet.maxTop);
1337
- return (_jsx(DetailScreen, { id: sheet.sheet.id, title: sheet.sheet.title, missing: sheet.sheet.text === null, lines: sheet.lines.slice(start, start + sheet.capacity), spans: sheet.doc?.spans ?? [], top: start, total: sheet.lines.length, capacity: sheet.capacity, action: sheet.action, model: sheet.model, spawnNote: sheet.spawnNote, columns: columns, find: sheet.find, occ: sheet.findRes.occ, occCur: sheet.occCur }));
1293
+ return (_jsx(DetailScreen, { id: sheet.sheet.id, title: sheet.sheet.title, missing: sheet.sheet.text === null, lines: sheet.lines.slice(start, start + sheet.capacity), spans: sheet.doc?.spans ?? [], top: start, total: sheet.lines.length, capacity: sheet.capacity, action: sheet.action, model: sheet.model, spawnNote: sheet.spawnNote, prompt: sheet.prompt, cursor: sheet.cursor, columns: columns, find: sheet.find, occ: sheet.findRes.occ, occCur: sheet.occCur }));
1338
1294
  }
1339
1295
  // ── T52 · schermate sostitutive ─────────────────────────────────────────
1340
1296
  // Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
@@ -1425,7 +1381,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1425
1381
  if (budget.compact) {
1426
1382
  return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "v", VERSION, " \u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? parentLabel, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
1427
1383
  }
1428
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: ["v", VERSION] })] }), 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 === 'purge' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["elimina task \u00B7", ' ', purge?.ignored ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " keep/purge dei file non tracciati \u00B7", ' '] })) : null, _jsx(Text, { color: "yellow", children: "\u23CE" }), " conferma \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"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${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, mode === 'purge' && purge ? _jsx(PurgeModal, { draft: purge, columns: columns }) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, counts: taskCounts, activeView: taskViewId, paneCount: paneTasks.length, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, idW: taskCols.id, tailW: taskCols.tail, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: paneTasks.length - taskWin.end, columns: columns, dirty: dirtyFolders }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, counts: sessionCounts, activeView: sessionViewId, paneCount: sessionRows.length, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore, live: live })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '', live: live.get(selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1384
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: ["v", VERSION] })] }), 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: ["titolo 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 === 'purge' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["elimina task \u00B7", ' ', purge?.ignored ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " keep/purge dei file non tracciati \u00B7", ' '] })) : null, _jsx(Text, { color: "yellow", children: "\u23CE" }), " conferma \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"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${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: editCursor.row, caret: editCursor.caret, columns: columns })) : null, mode === 'purge' && purge ? _jsx(PurgeModal, { draft: purge, columns: columns }) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, counts: taskCounts, activeView: taskViewId, paneCount: paneTasks.length, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, idW: taskCols.id, tailW: taskCols.tail, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: paneTasks.length - taskWin.end, columns: columns, dirty: dirtyFolders }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, counts: sessionCounts, activeView: sessionViewId, paneCount: sessionRows.length, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore, live: live })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '', live: live.get(selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1429
1385
  }
1430
1386
  const cwd = process.cwd();
1431
1387
  // T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
package/dist/fields.js ADDED
@@ -0,0 +1,108 @@
1
+ import { cpLen, insertAt, removeAt } from './layout.js';
2
+ import { sanitizeTyped } from './glyphs.js';
3
+ /** La riga `row` è un campo di testo (e quindi mangia le lettere). */
4
+ export function isTextField(specs, row) {
5
+ return specs[row]?.kind === 'text';
6
+ }
7
+ /** Caret in CODA al campo della riga `row`: la posizione da cui si continua a
8
+ * scrivere, e l'unica che non dipende da dove stava il cursore prima. Su una
9
+ * riga a scelta il caret non ha significato e vale 0. */
10
+ export function caretAtEnd(specs, row, io) {
11
+ return isTextField(specs, row) ? cpLen(io.text(row)) : 0;
12
+ }
13
+ /**
14
+ * Un tasto dell'area di compilazione. Ritorna `true` se lo ha consumato.
15
+ *
16
+ * Il chiamante gestisce PRIMA i tasti che sono suoi e non dell'area (`esc`,
17
+ * `⏎`, `^F`, `PgUp`/`PgDn`), poi delega qui; ciò che torna `false` è inerte, e
18
+ * lasciarlo cadere è la scelta giusta dentro un modo capturing.
19
+ *
20
+ * Il ramo CTRL è ANTEPOSTO a quelli su carattere, come in modalità normale: `^A`
21
+ * e `a` arrivano con lo stesso `input`, e `key.ctrl` è l'unico discriminante —
22
+ * senza la precedenza il `^A` finirebbe dentro il testo.
23
+ */
24
+ export function fieldsKey(input, key, specs, cursor, setCursor, io) {
25
+ const spec = specs[cursor.row];
26
+ if (!spec)
27
+ return false;
28
+ if (key.upArrow || key.downArrow) {
29
+ // Ciclico: le aree sono di 4 righe, arrivare in fondo e ripartire costa meno
30
+ // che invertire direzione.
31
+ const d = key.upArrow ? -1 : 1;
32
+ const row = (cursor.row + specs.length + d) % specs.length;
33
+ setCursor({ row, caret: caretAtEnd(specs, row, io) });
34
+ return true;
35
+ }
36
+ if (spec.kind === 'choice') {
37
+ if (key.leftArrow || key.rightArrow) {
38
+ const d = key.leftArrow ? -1 : 1;
39
+ io.setChoice(cursor.row, (io.choice(cursor.row) + spec.count + d) % spec.count);
40
+ return true;
41
+ }
42
+ // Le lettere di selezione diretta arrivano nude: un `key.ctrl` con lo stesso
43
+ // carattere è un acceleratore, non una scelta.
44
+ if (input && !key.ctrl && !key.meta && spec.hotkeys) {
45
+ const at = spec.hotkeys[input];
46
+ if (at !== undefined && at < spec.count) {
47
+ io.setChoice(cursor.row, at);
48
+ return true;
49
+ }
50
+ }
51
+ return false;
52
+ }
53
+ const value = io.text(cursor.row);
54
+ if (key.ctrl) {
55
+ // `^A`/`^E` (convenzione readline) perché `Home`/`End` NON sono esposte da
56
+ // `useInput`: misurate su otto sequenze diverse (`\x1b[H`, `\x1bOH`,
57
+ // `\x1b[1~`, `\x1b[7~` e i gemelli End), arrivano tutte come `input` vuoto
58
+ // senza nessun flag alzato — indistinguibili fra loro e da qualunque altro
59
+ // tasto senza nome.
60
+ //
61
+ // `^D` è il delete-forward, e anche lì il motivo è un limite di Ink: il
62
+ // Backspace fisico manda `\x7f` e il Canc manda `\x1b[3~`, ma
63
+ // `parseKeypress` li battezza ENTRAMBI `delete` — a valle sono lo stesso
64
+ // evento. `key.delete` va quindi al backspace (il tasto che si usa davvero)
65
+ // e la cancellazione in avanti prende il suo tasto readline.
66
+ //
67
+ // `^U` svuota il campo in fuoco. Non è comodità: il backspace tenuto premuto
68
+ // cancella UN carattere per CHUNK letto da stdin, non per pressione, quindi
69
+ // svuotare un campo lungo a colpi di backspace è di fatto non eseguibile.
70
+ if (input === 'a')
71
+ setCursor({ ...cursor, caret: 0 });
72
+ else if (input === 'e')
73
+ setCursor({ ...cursor, caret: cpLen(value) });
74
+ else if (input === 'd')
75
+ io.setText(cursor.row, removeAt(value, cursor.caret));
76
+ else if (input === 'u') {
77
+ io.setText(cursor.row, '');
78
+ setCursor({ ...cursor, caret: 0 });
79
+ }
80
+ else
81
+ return false;
82
+ return true;
83
+ }
84
+ if (key.leftArrow || key.rightArrow) {
85
+ // CLAMP agli estremi, non wrap: a inizio campo `←` non deve saltare in
86
+ // fondo. Le righe a scelta ciclano perché sono 3-5 voci; un testo no — il
87
+ // salto sarebbe indistinguibile da uno sfarfallio.
88
+ const d = key.leftArrow ? -1 : 1;
89
+ setCursor({ ...cursor, caret: Math.max(0, Math.min(cpLen(value), cursor.caret + d)) });
90
+ return true;
91
+ }
92
+ if (key.backspace || key.delete) {
93
+ io.setText(cursor.row, removeAt(value, cursor.caret - 1));
94
+ setCursor({ ...cursor, caret: Math.max(0, cursor.caret - 1) });
95
+ return true;
96
+ }
97
+ if (input && !key.meta) {
98
+ // `sanitizeTyped`: `useInput` consegna il CHUNK di stdin, quindi un
99
+ // incollaggio porta dentro newline e byte di controllo — invisibili nel
100
+ // campo ma contati da Ink nella larghezza della riga. Ed è per lo stesso
101
+ // motivo che il caret avanza della LUNGHEZZA del chunk, non di uno.
102
+ const ins = sanitizeTyped(input);
103
+ io.setText(cursor.row, insertAt(value, cursor.caret, ins));
104
+ setCursor({ ...cursor, caret: cursor.caret + cpLen(ins) });
105
+ return true;
106
+ }
107
+ return false;
108
+ }
package/dist/layout.js CHANGED
@@ -3,15 +3,6 @@
3
3
  // da scalare va tolta in un posto solo, o due chiamanti tolgono cose diverse
4
4
  // dalla stessa larghezza e il bordo viene mangiato.
5
5
  import { stripProjectCore } from './session-list.js';
6
- export const EDIT_ROWS = 4;
7
- /** Le righe del modale edit che sono campi di TESTO (il resto è scelta ←→). */
8
- export function isTextRow(r) {
9
- return r === 2 || r === 3;
10
- }
11
- /** Chiave della bozza scritta dalla riga di testo `r`. */
12
- export function editField(r) {
13
- return r === 2 ? 'detail' : 'title';
14
- }
15
6
  /**
16
7
  * Lunghezza in CODE POINT. Il caret ci indicizza sopra: `.length` conterebbe le
17
8
  * code unit UTF-16 e un'emoji nel titolo varrebbe 2 posizioni, cioè un cursore
package/dist/model.js CHANGED
@@ -60,3 +60,17 @@ export const KIND_LABEL = { ai: 'IA', tool: 'tools', human: 'human' };
60
60
  // l'ordine naturale di lettura.
61
61
  export const EDIT_PRI = ['high', 'med', 'low'];
62
62
  export const EDIT_PROG = ['todo', 'wip', 'done', 'locked'];
63
+ // T117 — le righe del modale edit come DATO, nella forma che `fields.ts`
64
+ // consuma: 0 priorità · 1 stato · 2 progresso libero · 3 titolo. Il modale non
65
+ // dà lettere di selezione alle due righe a scelta, dove i valori sono glifi e
66
+ // non hanno un'iniziale da premere.
67
+ export const EDIT_FIELDS = [
68
+ { kind: 'choice', count: EDIT_PRI.length },
69
+ { kind: 'choice', count: EDIT_PROG.length },
70
+ { kind: 'text' },
71
+ { kind: 'text' },
72
+ ];
73
+ /** Chiave della bozza scritta dalla riga di testo `r` (2 = progresso, 3 = titolo). */
74
+ export function editTextField(r) {
75
+ return r === 2 ? 'detail' : 'title';
76
+ }