@lamemind/loom-deck 0.34.0 → 0.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,598 +1,34 @@
1
1
  #!/usr/bin/env node
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { render, Box, Text, useApp, useInput, useStdout } from 'ink';
4
- import { useState, useEffect, useMemo, useRef } from 'react';
5
- import { spawn } from 'node:child_process';
6
- import { EventEmitter } from 'node:events';
7
- import { statSync } from 'node:fs';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { render, Box, Text, useApp, useInput } from 'ink';
4
+ import { useState, useEffect, useMemo } from 'react';
8
5
  import { randomUUID } from 'node:crypto';
9
- import { fileURLToPath } from 'node:url';
10
- import { dirname, join } from 'node:path';
11
- import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, loadTaskFileText, } from './tasks.js';
12
- import { discoverProjectSessions } from './sessions.js';
13
- import { discoverLiveSessions, liveSig } from './live-sessions.js';
14
- import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions, selectedRow, DEFAULT_OPTIONS, MIN_QUERY, } from './search.js';
15
- import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
16
- import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, rowLabel, selectedSession, sessionTitle, stripProjectCore, unpinLandingId, } from './session-list.js';
6
+ import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskFileText, } from './tasks.js';
7
+ import { rowIndexOfKey, selectedRow, } from './search.js';
8
+ import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, } from './task-index.js';
9
+ import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, selectedSession, unpinLandingId, } from './session-list.js';
17
10
  import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch, } from './config.js';
18
- import { archivableIds, SCAN_INTERVAL_MS } from './archivable.js';
19
- import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, SESSION_VIEWS, TASK_VIEWS, } from './pane-views.js';
20
- import { assignListCapacity, detailCapacity, isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
21
- import { caretWindow, cut, cutParts, pad, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
22
- import { scanText, sliceLine, topForOffset, } from './text-search.js';
23
- import { parseMarkdown, sliceSpans, } from './markdown.js';
11
+ import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, TASK_VIEWS, } from './pane-views.js';
12
+ import { isCompact, layoutBudget, searchPreviewCapacity, windowRange, } from './viewport.js';
13
+ import { cut, sanitize, termWidth } from './width.js';
24
14
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
25
- import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
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, ROW_ALL, ROW_SPOT, SORT_TASTI, SPOT, } from './model.js';
17
+ import { conversationLabel, cpLen, editField, insertAt, isDone, isTextRow, removeAt, EDIT_ROWS, } from './layout.js';
18
+ import { TASK_EMPTY, relTime, sanitizeTyped } from './glyphs.js';
19
+ import { commitTaskEdit, runLaunch, spawnClaudeEmpty, spawnCreateTask, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, CLAUDE_CMD, DECK_RUN, } from './spawn.js';
20
+ import { EditModal, FilterModal, SortModal } from './ui/modals.js';
21
+ import { ReaderScreen, SearchScreen } from './ui/search-screen.js';
22
+ import { DetailScreen } from './ui/detail-screen.js';
23
+ import { AssignScreen } from './ui/assign-screen.js';
24
+ import { SessionsPane, TasksPane } from './ui/panes.js';
25
+ import { PreviewPane, detailMetaOf } from './ui/preview.js';
26
26
  import { loadView, saveView, viewFilePath } from './view-store.js';
27
- // scripts/deck-run è un sibling della dir del bundle: src/ (dev, tsx) e dist/
28
- // (build, node) stanno entrambi sotto la package root → risalita di un livello.
29
- const DECK_RUN = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'deck-run');
30
- const POLL_MS = 1500;
31
- // Cap del pane sessioni: le più recenti (ts desc), le altre restano nell'indice
32
- // ma fuori vista. Non-silenzioso → l'header mostra quante sono nascoste.
33
- const MAX_SESSIONS = 30;
34
- // T59 D3 — cap dedicato alla vista "tutte": a 30 su un progetto con ~170
35
- // conversazioni la lista sarebbe esaustiva solo sull'ultimo 17%, cioè
36
- // contraddirebbe lo scopo della riga. 100 copre la finestra temporale utile e
37
- // tiene comunque un tetto alle righe da attraversare con ↑↓.
38
- const MAX_SESSIONS_ALL = 100;
39
- // Modello task-centrico: il Tasks pane ha, oltre alle task reali, DUE righe
40
- // meta in testa — `≡ tutte` (ogni conversazione del progetto) e `○ spot` (le
41
- // sole NON legate ad alcuna task). La selezione nel Tasks pane è il "padre"; il
42
- // Sessions pane mostra i suoi figli.
43
- //
44
- // T59 D1 — le sentinelle sono Symbol, non `null` né stringhe riservate: la
45
- // selezione è un ENUM a tre casi (task / spot / tutte), non un flag. Un Symbol
46
- // non può collidere con un task id e si confronta secco (`sel === ALL`); una
47
- // stringa sentinella farebbe invece circolare un id-fantasma dentro un tipo che
48
- // altrove significa "task id".
49
- const SPOT = Symbol('spot');
50
- const ALL = Symbol('all');
51
- // Le righe meta occupano le prime posizioni della lista: l'indice di una task
52
- // nella VISTA è quindi il suo indice in `viewTasks` + META_ROWS.
53
- const ROW_ALL = 0;
54
- const ROW_SPOT = 1;
55
- const META_ROWS = 2;
56
- const EDIT_ROWS = 4;
57
- /** Le righe del modale edit che sono campi di TESTO (il resto è scelta ←→). */
58
- function isTextRow(r) {
59
- return r === 2 || r === 3;
60
- }
61
- /** Chiave della bozza scritta dalla riga di testo `r`. */
62
- function editField(r) {
63
- return r === 2 ? 'detail' : 'title';
64
- }
65
- /**
66
- * Lunghezza in CODE POINT. Il caret ci indicizza sopra: `.length` conterebbe le
67
- * code unit UTF-16 e un'emoji nel titolo varrebbe 2 posizioni, cioè un cursore
68
- * che si ferma a metà glifo e un `slice` che lo spezza in due surrogati.
69
- */
70
- function cpLen(s) {
71
- return [...s].length;
72
- }
73
- /** Inserisce `ins` alla posizione `at` (code point). */
74
- function insertAt(s, at, ins) {
75
- const cp = [...s];
76
- return cp.slice(0, at).join('') + ins + cp.slice(at).join('');
77
- }
78
- /** Toglie il code point in posizione `at`; fuori range = stringa invariata. */
79
- function removeAt(s, at) {
80
- const cp = [...s];
81
- if (at < 0 || at >= cp.length)
82
- return s;
83
- cp.splice(at, 1);
84
- return cp.join('');
85
- }
86
- // Modale sort a grammatica libera: un tasto per chiave, pressioni successive
87
- // ciclano asc → desc → fuori dalla chain.
88
- const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
89
- // T52 — toggle del modale ricerca, tutti su CTRL (D2).
90
- //
91
- // I due campi di testo mangiano ogni lettera nuda, quindi un toggle non può
92
- // essere una lettera semplice: resterebbero i caratteri della query. CTRL è
93
- // l'unico livello che convive con la digitazione senza modi né navigazione.
94
- //
95
- // Le mnemoniche ovvie sono precluse dall'ASCII, non da una scelta di design:
96
- // `^I` È il Tab (0x09) e `^H` È il Backspace (0x08) — stesso byte, nessuna
97
- // distinzione possibile a valle. Quindi niente I=IA e niente H=human. Bruciati
98
- // per lo stesso motivo `^M` (Enter), `^J` (LF), `^[` (Esc). Tutto il resto passa
99
- // pulito, `^S`/`^Q` inclusi: il raw mode di Ink disattiva il flow-control XON/XOFF
100
- // che altrimenti se li mangerebbe il terminale.
101
- const SEARCH_TOGGLE_KEYS = {
102
- r: 'regex',
103
- a: 'caseSensitive',
104
- w: 'wholeWord',
105
- };
106
- const SEARCH_KIND_KEYS = { b: 'ai', t: 'tool', u: 'human' };
107
- const KIND_LABEL = { ai: 'IA', tool: 'tools', human: 'human' };
108
- // T41 — ordine dei valori nel modale edit. Deliberatamente DIVERSO da
109
- // PRI_ENTRIES/PROG_ENTRIES (che seguono il rango di sort): qui si sceglie un
110
- // valore, non si ordina, quindi vince l'ordine del CICLO DI VITA — da fare →
111
- // in corso → chiusa → bloccata. La priorità resta alta→bassa, che è già
112
- // l'ordine naturale di lettura.
113
- const EDIT_PRI = ['high', 'med', 'low'];
114
- const EDIT_PROG = ['todo', 'wip', 'done', 'locked'];
115
- function isDone(prog) {
116
- return prog.includes('✔');
117
- }
118
- /**
119
- * Freno agli effetti VERSO L'ESTERNO (tab Ptyxis, sessioni Claude, git commit).
120
- *
121
- * Il gate di larghezza avvia il deck vero in uno pseudo-terminale e gli manda
122
- * tasti — e in questa TUI un tasto è un'azione: `⏎` su una riga sessione apre
123
- * una tab Ptyxis, `t` un terminale, `⏎` nel modale edit committa. Ogni run dei
124
- * test apriva quindi finestre reali sulla macchina di chi li lanciava, in
125
- * qualunque progetto avesse in focus.
126
- *
127
- * Il gate va tenuto sul deck VERO (è tutto il suo valore: misura il frame che
128
- * VTE disegna davvero), quindi il freno sta qui: `LOOM_DECK_NO_SPAWN=1` fa
129
- * restituire un figlio finto e inerte invece di lanciare il processo. Non è un
130
- * mock del comportamento — l'azione semplicemente non avviene, e il frame che il
131
- * test misura resta identico.
132
- */
133
- const NO_SPAWN = process.env.LOOM_DECK_NO_SPAWN === '1';
134
- function spawnOut(cmd, args, opts) {
135
- if (!NO_SPAWN)
136
- return spawn(cmd, args, opts);
137
- // Figlio inerte: emette nulla, quindi i `.on('error'|'close')` dei chiamanti
138
- // restano appesi senza mai scattare — che è esattamente "non è successo niente".
139
- const fake = new EventEmitter();
140
- fake.unref = () => fake;
141
- return fake;
142
- }
143
- // T66 — le azioni del detail. Non sono un catalogo nuovo: ognuna è un
144
- // `--prompt-kind` già esistente più `checkpoint`, e tutte passano dallo stesso
145
- // `spawnForTask` dei CTRL della lista — una superficie in più, zero percorsi di
146
- // spawn in più.
147
- //
148
- // L'etichetta è distinta dal kind dove il kind è il nome del MECCANISMO e
149
- // l'etichetta quello dell'INTENZIONE: `none` è "aprire la task a mani nude",
150
- // `recap` è "vedere a che punto sta".
151
- const DETAIL_ACTIONS = [
152
- { kind: 'none', label: 'open' },
153
- { kind: 'preflight', label: 'preflight' },
154
- { kind: 'run', label: 'run' },
155
- { kind: 'recap', label: 'status' },
156
- { kind: 'checkpoint', label: 'checkpoint' },
157
- ];
158
- // Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
159
- // ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
160
- // sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
161
- // Il kind è OBBLIGATORIO e non ha default qui: il default vive in deck-run (per
162
- // le invocazioni a mano), mentre dal deck ogni tasto dichiara il proprio intento
163
- // — un default silenzioso renderebbe indistinguibili `⏎` e `^K`.
164
- function spawnDeck(id, cwd, sessionId, kind) {
165
- const child = spawnOut(DECK_RUN, [id, '--session-id', sessionId, '--prompt-kind', kind], {
166
- cwd,
167
- detached: true,
168
- stdio: 'ignore',
169
- });
170
- child.unref();
171
- return child;
172
- }
173
- // T49 — resume di una sessione esistente come nuova tab Ptyxis. Scoped (taskId
174
- // presente) → `deck-run <task> --resume <sid>`: la ripresa eredita LOOM_TASK +
175
- // titolo `· <task>` (D2 preflight, l'hook SessionStart ricarica il contesto
176
- // task). Spot → `--no-task --resume`: resume nudo, solo label progetto. Nessun
177
- // prompt iniziale in entrambi i casi: riprendere una conversazione significa
178
- // continuarla, non iniettarle un messaggio (lo salta deck-run).
179
- //
180
- // T64 — la NOTA della conversazione (se c'è) viaggia nel titolo della tab. Più
181
- // sessioni sulla stessa task hanno oggi titoli identici (`label · T81`): la nota
182
- // è già ciò con cui l'utente le distingue in lista, quindi è anche ciò che
183
- // distingue le tab. La passa il DECK e non la legge deck-run perché la nota vive
184
- // nel sidecar `session-tasks.jsonl`, che deck-run non tocca (legge solo
185
- // `.claude/loom-works.json`): tenerlo così evita di dare al primitive un secondo
186
- // file da conoscere. Il titolo si congela qui — `claude --name` lo setta una
187
- // volta sola, quindi una nota cambiata DOPO non ri-titola la tab già aperta.
188
- function spawnDeckResume(taskId, cwd, sessionId, note) {
189
- const args = taskId ? [taskId, '--resume', sessionId] : ['--no-task', '--resume', sessionId];
190
- if (note)
191
- args.push('--title-note', note);
192
- const child = spawnOut(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
193
- child.unref();
194
- return child;
195
- }
196
- // T28 — FORK: `deck-run <task|--no-task> --resume <origine> --fork --session-id
197
- // <nuovo>`. Variante del resume, non una terza forma: cambia solo che CC apre un
198
- // id nuovo (`--fork-session`) invece di riprendere a scrivere sull'origine —
199
- // due writer sullo stesso JSONL non esistono mai, che è l'intero punto del fork.
200
- // Il nuovo id lo genera il DECK e lo pinna, come in spawnDeck: è l'unico modo di
201
- // conoscerlo prima che la sessione esista, e senza conoscerlo non si possono
202
- // scrivere né il binding task né il record di lineage (il transcript del fork
203
- // non nomina da nessuna parte la sessione d'origine).
204
- // Nessun `--title-note` (T64): il ramo nasce con un sessionId proprio e SENZA
205
- // nota nel sidecar — ereditare quella dell'origine metterebbe nel titolo una
206
- // maniglia che nella lista non compare, cioè una promessa falsa. Il fork si
207
- // distingue col suo marcatore, `· fork`.
208
- function spawnDeckFork(taskId, cwd, originId, newId) {
209
- const args = [
210
- ...(taskId ? [taskId] : ['--no-task']),
211
- '--resume',
212
- originId,
213
- '--fork',
214
- '--session-id',
215
- newId,
216
- ];
217
- const child = spawnOut(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
218
- child.unref();
219
- return child;
220
- }
221
- // T42 — sessione Claude NUDA: nessuna task, nessun prompt iniziale, nessun
222
- // sessionId pinnato (quindi nessuna entry nel sidecar session-tasks.jsonl: senza
223
- // task non c'è nulla da legare). Funzione separata e non un parametro opzionale
224
- // di spawnDeck: i tre argomenti mancano tutti insieme, un `if` per ciascuno
225
- // sporcherebbe il percorso bound. Il titolo tab resta la label loom — lo mette
226
- // deck-run, perché il match compass è window-level e non sa nulla di task.
227
- function spawnClaudeEmpty(cwd) {
228
- const child = spawnOut(DECK_RUN, ['--no-task'], {
229
- cwd,
230
- detached: true,
231
- stdio: 'ignore',
232
- });
233
- child.unref();
234
- return child;
235
- }
236
- // T39/T32: voce `launch` custom del file config, eseguita con cwd = project root.
237
- // Spawn detached come spawnDeck: il deck lancia ma non possiede il processo.
238
- // Shell login+interattiva (bash -lic) perché i comandi tipici sono alias o
239
- // funzioni di ~/.bashrc (`codium`=alias flatpak, `idea`=funzione) — con `bash -c`
240
- // non risolverebbero. Il comando NON è input utente: viene dal file committato
241
- // `.claude/loom-works.json`, fidato quanto un custom-command Ptyxis (contratto
242
- // esplicito in project-config-architecture.md). La project root arriva via cwd,
243
- // non interpolata nella stringa.
244
- function runLaunch(entry, cwd) {
245
- const child = spawnOut('bash', ['-lic', entry.command], {
246
- cwd,
247
- detached: true,
248
- stdio: 'ignore',
249
- });
250
- child.unref();
251
- return child;
252
- }
253
- // T37 — surface STANDARD LAUNCH `terminal`: built-in e universale (nessuna
254
- // dichiarazione in `launch[]`), ma di natura launch — fire-once, nessuno stato.
255
- // Il deck gira già DENTRO una tab Ptyxis → `--tab` mette il terminale accanto a
256
- // sé nella stessa finestra, invece di sparpagliare finestre.
257
- // Nessun `-- CMD`: l'azione È aprire la shell (differenza dalle launch custom,
258
- // che eseguono un comando dentro `bash -lic`).
259
- // `-T <title>` con la chiave `🖥️ <name>` tiene la finestra matchabile da compass
260
- // anche mentre la tab attiva è il terminale; senza identità nel file config si
261
- // spawna senza titolo (la surface resta funzionante, il progetto risulta assente
262
- // dal radar finché quella tab è in primo piano).
263
- function spawnTerminal(cwd, title) {
264
- const args = title ? ['--tab', '-T', title, '-d', cwd] : ['--tab', '-d', cwd];
265
- const child = spawnOut('ptyxis', args, { cwd, detached: true, stdio: 'ignore' });
266
- child.unref();
267
- return child;
268
- }
269
- // Comando claude (override per ambienti dove non è su PATH; loom-deck → NPM).
270
- const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
271
- // T30: create-task inline. Spawna CC HEADLESS (`-p`) con `--session-id` pinnato
272
- // che invoca la skill create-task. Differenze da spawnDeck:
273
- // - headless (`-p`), non una tab Ptyxis interattiva → il deck osserva l'esito;
274
- // - `yolo` FORZATO: create-task è interattiva di default (AskUserQuestion) e in
275
- // `-p` non può ricevere risposte → si impianterebbe. yolo = zero domande.
276
- // - `--output-format stream-json` (richiede `--verbose`): l'ultima riga è
277
- // `{type:"result", is_error}`, segnale di completamento robusto (> exit code).
278
- // - detached (own process-group) → il create sopravvive alla chiusura del deck e
279
- // completa commit+push da sé; stdout in pipe SOLO per leggere il result event.
280
- // Il prompt viaggia come singolo argv (no shell) → nessuna injection dal testo utente.
281
- function spawnCreateTask(text, cwd, sessionId, onResult) {
282
- const child = spawnOut(CLAUDE_CMD, [
283
- '-p',
284
- '--output-format',
285
- 'stream-json',
286
- '--verbose',
287
- '--session-id',
288
- sessionId,
289
- `/loom-works:create-task yolo ${text}`,
290
- ], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
291
- let buf = '';
292
- let isError = null;
293
- child.stdout?.on('data', (chunk) => {
294
- buf += chunk.toString();
295
- let nl;
296
- while ((nl = buf.indexOf('\n')) >= 0) {
297
- const line = buf.slice(0, nl).trim();
298
- buf = buf.slice(nl + 1);
299
- if (!line)
300
- continue;
301
- try {
302
- const obj = JSON.parse(line);
303
- if (obj.type === 'result')
304
- isError = obj.is_error ?? false;
305
- }
306
- catch {
307
- // riga parziale / non-json → skip
308
- }
309
- }
310
- });
311
- // Drena stderr per non riempire il buffer pipe (deadlock del figlio).
312
- child.stderr?.on('data', () => { });
313
- child.on('error', () => onResult(false));
314
- child.on('close', (code) => {
315
- onResult(isError === null ? code === 0 : !isError);
316
- });
317
- return child;
318
- }
319
- // T41 — Commit dell'edit. `git commit -- <paths>` committa lo stato working-tree
320
- // SOLO di quei path, ignorando l'index: se l'utente ha altro in stage (o altri
321
- // file sporchi) non finisce dentro per errore. NON detached: è un'operazione
322
- // veloce e il suo esito va riportato nella nota. stderr raccolto per dire perché
323
- // ha fallito (identità git assente, hook che rifiuta, …) invece di un generico ⚠.
324
- function commitTaskEdit(cwd, paths, message, onResult) {
325
- const child = spawnOut('git', ['commit', '-m', message, '--', ...paths], {
326
- cwd,
327
- stdio: ['ignore', 'ignore', 'pipe'],
328
- });
329
- let err = '';
330
- child.stderr?.on('data', (c) => {
331
- err += c.toString();
332
- });
333
- child.on('error', () => onResult(false, 'git non lanciabile'));
334
- child.on('close', (code) => onResult(code === 0, err.trim().split('\n')[0] ?? ''));
335
- return child;
336
- }
337
- // Dimensioni del terminale, live sul resize.
338
- //
339
- // Non è una comodità di layout: senza `rows` il frame non ha tetto, e un frame
340
- // più alto del terminale fa cadere Ink nel ramo `clearTerminal` (ink.js:121)
341
- // che su VTE/Ptyxis riversa ogni redraw nello scrollback.
342
- //
343
- // Il valore iniziale conta quanto il resize: una tab Ptyxis appena aperta parte
344
- // spesso a 24 righe e riceve il SIGWINCH subito dopo. Nella finestra fra i due
345
- // il deck disegnava già a piena altezza — motivo per cui lo scrollback risultava
346
- // sporco fin dall'avvio, prima ancora di toccare un tasto.
347
- function useTerminalSize() {
348
- const { stdout } = useStdout();
349
- const [size, setSize] = useState({ rows: stdout.rows || 24, columns: stdout.columns || 80 });
350
- useEffect(() => {
351
- const onResize = () => setSize({ rows: stdout.rows || 24, columns: stdout.columns || 80 });
352
- stdout.on('resize', onResize);
353
- onResize(); // allinea se il resize è arrivato prima del mount
354
- return () => {
355
- stdout.off('resize', onResize);
356
- };
357
- }, [stdout]);
358
- return size;
359
- }
360
- // Carica tasks.md e lo ri-legge quando cambia sotto (poll su mtime). Poll
361
- // (non fs.watch) perché i writer di tasks.md — checkpoint-task/create-task —
362
- // riscrivono il file (probabile replace atomico), che rompe il watch sull'inode
363
- // originale; statSync(path) segue sempre il file corrente al path.
364
- function useTasks(tasksPath) {
365
- const [tasks, setTasks] = useState([]);
366
- const [loadError, setLoadError] = useState('');
367
- useEffect(() => {
368
- let lastMtime = -1;
369
- const reload = () => {
370
- try {
371
- const mtime = statSync(tasksPath).mtimeMs;
372
- if (mtime === lastMtime)
373
- return; // invariato → niente re-read
374
- lastMtime = mtime;
375
- setTasks(loadTasks(tasksPath));
376
- setLoadError('');
377
- }
378
- catch {
379
- lastMtime = -1; // così quando il file riappare viene ri-letto
380
- setTasks([]);
381
- setLoadError(`tasks.md non leggibile: ${tasksPath}`);
382
- }
383
- };
384
- reload();
385
- const id = setInterval(reload, POLL_MS);
386
- return () => clearInterval(id);
387
- }, [tasksPath]);
388
- return { tasks, loadError };
389
- }
390
- // Poll delle sessioni del progetto + binding sidecar. discoverProjectSessions
391
- // ha cache mtime-keyed interna → il poll è economico; qui si evita comunque il
392
- // re-render inutile con una signature (sessionId:ts + binding entries): setState
393
- // solo quando cambia davvero qualcosa.
394
- function useSessions(projectRoot) {
395
- const [state, setState] = useState({
396
- sessions: [],
397
- bindings: new Map(),
398
- forkOf: new Map(),
399
- pinned: new Map(),
400
- notes: new Map(),
401
- live: new Map(),
402
- });
403
- // T50 — pin/unpin scrive il sidecar e vuole feedback IMMEDIATO, non al
404
- // prossimo tick del poll (1.5s): la reload è esposta via ref così il toggle la
405
- // richiama senza risottoscrivere l'intervallo.
406
- const reloadRef = useRef(() => { });
407
- useEffect(() => {
408
- let lastSig = '';
409
- const reload = () => {
410
- let sessions;
411
- let index;
412
- try {
413
- sessions = discoverProjectSessions(projectRoot);
414
- index = loadSessionIndex(projectRoot);
415
- }
416
- catch {
417
- sessions = [];
418
- index = { bindings: new Map(), forkOf: new Map(), pinned: new Map(), notes: new Map() };
419
- }
420
- // T62 — le vive stanno sullo STESSO tick delle altre fonti, non su una
421
- // scala propria come `useArchivable`: `status` cambia a ogni turno, quindi
422
- // un refresh più lento mostrerebbe `idle` su una sessione che lavora.
423
- // Il try è separato perché il registry è una fonte indipendente: se manca
424
- // (versione del CLI che non lo scrive) la lista deve restare, senza vive.
425
- let live;
426
- try {
427
- live = discoverLiveSessions(projectRoot);
428
- }
429
- catch {
430
- live = new Map();
431
- }
432
- const { bindings, forkOf, pinned, notes } = index;
433
- // La signature copre anche fork, pin e note: un record di lineage, un
434
- // toggle di pin o una nota appena scritta cambiano la lista renderizzata,
435
- // quindi devono forzare il re-render come farebbe un binding nuovo.
436
- const sig = sessions.map((s) => `${s.sessionId}:${s.ts}`).join('|') +
437
- '#' +
438
- [...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',') +
439
- '#' +
440
- [...forkOf.entries()].map(([k, v]) => `${k}<${v}`).sort().join(',') +
441
- '#' +
442
- [...pinned.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',') +
443
- '#' +
444
- [...notes.entries()].map(([k, v]) => `${k}"${v}`).sort().join(',') +
445
- '#' +
446
- liveSig(live);
447
- if (sig === lastSig)
448
- return;
449
- lastSig = sig;
450
- setState({ sessions, bindings, forkOf, pinned, notes, live });
451
- };
452
- reloadRef.current = reload;
453
- reload();
454
- const id = setInterval(reload, POLL_MS);
455
- return () => clearInterval(id);
456
- }, [projectRoot]);
457
- return { ...state, reload: () => reloadRef.current() };
458
- }
459
- // T61 — conteggio delle Done oltre soglia, su una scala di refresh TUTTA SUA.
460
- //
461
- // Non è appeso a POLL_MS (1,5s) come tasks.md e le sessioni: l'età di una task
462
- // cambia una volta al giorno, e ogni giro costa la lettura di N task file più
463
- // qualche spawn di git. Due trigger:
464
- //
465
- // · quando cambia l'INSIEME delle task Done (`doneSig`) — copre l'avvio, dove
466
- // il primo render ha `tasks` ancora vuoto, e la chiusura di una task, dove
467
- // il numero deve muoversi senza aspettare ore;
468
- // · ogni SCAN_INTERVAL_MS — copre il caso opposto, in cui non cambia nulla
469
- // sul disco ed è il calendario a far scattare una task oltre soglia.
470
- //
471
- // `doneSig` è una stringa, non l'array: `tasks` cambia identità a ogni re-read
472
- // di tasks.md, e usarlo come dipendenza rimetterebbe lo scan sul tick da 1,5s
473
- // per la via di dietro.
474
- //
475
- // T100 — tiene gli ID e non più il conteggio: `archiviabili` è una vista del
476
- // pane, quindi servono le righe. Il contatore dell'header è `.size` dello stesso
477
- // insieme che disegna la lista — un numero e una lista che non possono divergere.
478
- function useArchivable(doneSig, tasksDir, projectRoot, days) {
479
- const [ids, setIds] = useState(() => new Set());
480
- useEffect(() => {
481
- let alive = true;
482
- const done = doneSig ? doneSig.split(',') : [];
483
- const scan = () => {
484
- archivableIds(done, { tasksDir, projectRoot, days })
485
- .then((found) => {
486
- if (alive)
487
- setIds(new Set(found));
488
- })
489
- // Scan fallito (task file illeggibili, git muto) → insieme vuoto, cioè
490
- // voce a 0. Un contatore informativo non merita un errore a schermo.
491
- .catch(() => {
492
- if (alive)
493
- setIds(new Set());
494
- });
495
- };
496
- scan();
497
- const id = setInterval(scan, SCAN_INTERVAL_MS);
498
- return () => {
499
- alive = false;
500
- clearInterval(id);
501
- };
502
- }, [doneSig, tasksDir, projectRoot, days]);
503
- return ids;
504
- }
505
- // Legge il task file della task selezionata (Q1+B T20). On-id-change: navigare
506
- // con ↑↓ ricarica il dettaglio; leggere un singolo file 4-9KB è I/O triviale,
507
- // niente debounce serve per la tastiera. Il refresh del contenuto a file fermo
508
- // (es. checkpoint aggiorna Progress) è demandato al prossimo cambio selezione.
509
- function useTaskDetail(tasksDir, id) {
510
- const [detail, setDetail] = useState(null);
511
- useEffect(() => {
512
- setDetail(id ? loadTaskDetail(tasksDir, id) : null);
513
- }, [tasksDir, id]);
514
- return detail;
515
- }
516
- // Glifi LETTERALI del JSX. I dati passano dai loader, che sanificano al
517
- // confine; questi no — quindi passano da `sanitize` una volta qui, così nessun
518
- // sito di render scrive un glifo nudo. `↳ ○ ▸ ⏎ · − ↑ ↓` sono già concordi e
519
- // restano intatti; `▶` e `⚠` sono discordi e vengono sostituiti (`width.ts`).
520
- const CARET = sanitize('▶ ');
521
- const CARET_OFF = ' ';
522
- const WARN = sanitize('⚠');
523
- // T50 — separatore leggero fra blocco pinnate e contestuali. `─` (box-drawing) è
524
- // largo 1 sia per string-width sia per il terminale. Corto +
525
- // wrap="truncate-end" così non va mai a capo nel pane al 50%.
526
- const SESSION_SEP = '─'.repeat(16);
527
- // Prefisso del sessionId mostrato in lista: stesso dato e stessa lunghezza del
528
- // widget `⛓ <8 char>` della statusline, così le due superfici si confrontano a
529
- // occhio.
530
- const SID_CHARS = 8;
531
- /** T60 — segnaposto della colonna task su una riga senza binding. Una cella
532
- * vuota di soli spazi lascerebbe un buco che si legge come "colonna finita",
533
- * e la riga tornerebbe a sembrare disallineata pur non essendolo. */
534
- const TASK_EMPTY = '·';
535
- // T62 — colonna liveness, larga 1, incollata al sessionId senza gutter proprio:
536
- // il glifo qualifica QUELL'id, e uno spazio in mezzo lo farebbe leggere come una
537
- // colonna a sé. Entrambi Ambiguous (EAW) → 1 colonna per il terminale e 1 cella
538
- // per Ink, quindi concordi (invariante ① di width.ts): il pieno/vuoto del
539
- // cerchio è l'unico asse che varia, e resta scandibile in verticale.
540
- //
541
- // Sulla riga CHIUSA c'è uno spazio e non un terzo glifo: le chiuse sono la
542
- // maggioranza di ogni lista, e marcarle vorrebbe dire disegnare N volte «niente
543
- // da dire» — la colonna diventerebbe rumore invece di un segnale.
544
- const LIVE_IDLE = '●';
545
- const LIVE_BUSY = '◍';
546
- const LIVE_NONE = ' ';
547
- // Marker Done per il DISPLAY. `task.prog` resta il `✔️` letto da tasks.md —
548
- // `isDone()` e le lookup di `view.ts` ci confrontano sopra, e `task-edit` lo
549
- // riscrive sul file: è una chiave semantica, non testo. Qui `sanitize` lo
550
- // traduce nel suo gemello concorde (`✅`) solo per finire nel frame.
551
- function displayProg(prog) {
552
- return sanitize(prog);
553
- }
554
- // T49 — size umana compatta per il detail pane sessione.
555
- function fmtSize(bytes) {
556
- if (bytes < 1024)
557
- return `${bytes} B`;
558
- if (bytes < 1024 * 1024)
559
- return `${(bytes / 1024).toFixed(1)} KB`;
560
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
561
- }
562
- // T49 — ultima attività ESTESA (giorno/mese ora:minuti) per il detail pane;
563
- // nella riga di lista resta il relTime compatto.
564
- function fmtDateTime(ts) {
565
- const d = new Date(ts);
566
- const p = (n) => String(n).padStart(2, '0');
567
- return `${p(d.getDate())}/${p(d.getMonth() + 1)} ${p(d.getHours())}:${p(d.getMinutes())}`;
568
- }
569
- // Età relativa compatta (ms epoch → "2m"/"3h"/"5d") per il preview sessioni.
570
- function relTime(ts) {
571
- const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
572
- if (sec < 60)
573
- return `${sec}s`;
574
- const min = Math.floor(sec / 60);
575
- if (min < 60)
576
- return `${min}m`;
577
- const hr = Math.floor(min / 60);
578
- if (hr < 24)
579
- return `${hr}h`;
580
- return `${Math.floor(hr / 24)}d`;
581
- }
582
- /**
583
- * Ripulisce un chunk di stdin prima di scriverlo in un campo di testo.
584
- *
585
- * `useInput` consegna il CHUNK letto da stdin, non un tasto: un incollaggio — o
586
- * una raffica piu' veloce di una read — arriva come stringa unica, byte di
587
- * controllo compresi. Non si vedono a schermo, ma Ink li conta nella larghezza
588
- * della riga, e in un campo che finisce su disco (la nota, T53) resterebbero
589
- * li' per sempre. Le newline diventano SPAZIO invece di sparire: incollare due
590
- * righe deve separare le parole, non fonderle.
591
- */
592
- function sanitizeTyped(s) {
593
- return s.replace(/[\r\n]/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '');
594
- }
595
- const META_KEYS = ['Priority', 'Size', 'Estimated Time', 'Progress'];
27
+ import { useArchivable, useSessions, useTaskDetail, useTasks, useTerminalSize } from './hooks.js';
28
+ import { useSearchOverlay } from './overlays/search.js';
29
+ import { useSheetOverlay } from './overlays/sheet.js';
30
+ import { useAssignOverlay } from './overlays/assign.js';
31
+ import { captures } from './input-modes.js';
596
32
  function Deck({ cwd, tasksPath, tasksDir }) {
597
33
  const { exit } = useApp();
598
34
  const { tasks, loadError } = useTasks(tasksPath);
@@ -639,53 +75,6 @@ function Deck({ cwd, tasksPath, tasksDir }) {
639
75
  // T41 — bozza dell'edit (null fuori dal modale) e riga attiva della griglia.
640
76
  const [edit, setEdit] = useState(null);
641
77
  const [editRow, setEditRow] = useState(0);
642
- // T52 — stato del modale ricerca. VOLATILE per decisione (D4): vive in
643
- // memoria, quindi riaprendo il modale ritrovi query e toggle come li avevi
644
- // lasciati, ma un riavvio del deck riporta ai default. Niente schema nuovo su
645
- // `deck-view.json` e nessuna scrittura implicita su disco — che
646
- // contraddirebbe la regola T39 «il disco si tocca solo su `w`», qui non
647
- // trasportabile perché dentro il modale `w` è un carattere digitabile.
648
- const [searchHash, setSearchHash] = useState('');
649
- const [searchQuery, setSearchQuery] = useState('');
650
- const [searchField, setSearchField] = useState('query');
651
- const [searchOpts, setSearchOpts] = useState(DEFAULT_OPTIONS);
652
- const [searchSelKey, setSearchSelKey] = useState(null);
653
- // Occorrenza aperta nel reader; null = reader chiuso. Tenerla separata dalla
654
- // selezione della lista è ciò che permette a `esc` di tornare indietro
655
- // trovando la lista esattamente com'era.
656
- const [readerRow, setReaderRow] = useState(null);
657
- const [readerTop, setReaderTop] = useState(0);
658
- // T57 — stato del modale di assegnazione sessione → task.
659
- //
660
- // `assignSid` è la conversazione in assegnazione, FOTOGRAFATA all'apertura e
661
- // non riletta da `selSessionId`: il modale è fullscreen (D3), quindi il pane
662
- // sessioni non è più a schermo e l'oggetto dell'azione deve restare quello
663
- // che si è scelto — anche se un tick del poll rimescolasse la lista sotto.
664
- // `assignSel` è la task di destinazione: `null` è la riga `detach` (D2), che
665
- // sta sempre in testa e scrive un binding vuoto.
666
- const [assignSid, setAssignSid] = useState(null);
667
- const [assignFilter, setAssignFilter] = useState('');
668
- const [assignSel, setAssignSel] = useState(null);
669
- // T66 — la task aperta nel detail, FOTOGRAFATA all'apertura: id, titolo e
670
- // testo integrale del task file (`text` null = file assente). Non si rilegge
671
- // da `selTask` per la stessa ragione di `assignSid`: l'overlay copre la lista,
672
- // quindi l'oggetto dell'azione deve restare quello che si è scelto anche se un
673
- // tick del poll spostasse la selezione sotto.
674
- //
675
- // Si chiama `sheet` e non `detail` perché `detail` in questo componente è già
676
- // il `TaskDetail` del blocco preview sotto i pane: due cose vicine con lo
677
- // stesso nome sono una trappola di lettura (stesso motivo di
678
- // `note`/`sessionNotes`).
679
- const [sheet, setSheet] = useState(null);
680
- const [sheetTop, setSheetTop] = useState(0);
681
- const [sheetAction, setSheetAction] = useState(0);
682
- // T91 — ricerca dentro il detail. `open` distingue i due modi in cui la si
683
- // lascia: `esc` butta via ciò che il modale ha prodotto (`find` a null,
684
- // evidenziazione via), `⏎` lo congela e restituisce il controllo allo strato
685
- // sotto — campo chiuso, occorrenze ancora colorate, scroll dov'era. Senza il
686
- // flag i due gesti collasserebbero su uno solo.
687
- const [find, setFind] = useState(null);
688
- const [occIdx, setOccIdx] = useState(0);
689
78
  // Dimensioni vive del terminale: sono l'input del budget d'altezza sotto.
690
79
  const { rows, columns } = useTerminalSize();
691
80
  // Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
@@ -805,101 +194,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
805
194
  // perderebbero il segnaposto e con lui l'allineamento.
806
195
  return { task: task > 0 ? Math.max(task, termWidth(TASK_EMPTY)) : 0, age };
807
196
  }, [sessionRows, bindings, isAll]);
808
- // T52 — ricerca EAGER: rigira a ogni carattere digitato, non su ⏎. È
809
- // sostenibile perché i corpi sono già in RAM dentro la cache mtime-keyed
810
- // dell'adapter (D5): misurato su questo progetto, 0,8 ms sui soli corpi IA e
811
- // ≤9 ms su tutti i tipi — sotto il tempo fra due battute. Il memo evita di
812
- // rifarla sui re-render che non toccano né query né opzioni (il poll delle
813
- // sessioni ogni 1,5s è già filtrato dalla signature in `useSessions`).
814
- const searchResult = useMemo(() => searchSessions(sessions, searchHash, searchQuery, searchOpts, searchExcerptWidth(columns)), [sessions, searchHash, searchQuery, searchOpts, columns]);
815
- // Con l'hash valorizzato la conversazione è una sola e già nominata nel campo:
816
- // la riga-sessione ripeterebbe un dato costante rubando una riga per gruppo.
817
- const searchFlat = searchHash.trim().length > 0;
818
- const searchRows = useMemo(() => buildRows(searchResult, searchFlat), [searchResult, searchFlat]);
819
- // T52 — riga selezionata e, se è un'occorrenza, il suo corpo wrappato per
820
- // l'anteprima sotto la lista. Memoizzato per (testo, larghezza): navigando
821
- // con le frecce si ri-wrappa solo quando cambia davvero l'occorrenza.
822
- const searchSelRow = useMemo(() => selectedRow(searchRows, searchSelKey), [searchRows, searchSelKey]);
823
- const searchPreviewWidth = Math.max(20, (columns || 80) - 8);
824
- const searchPreviewBody = useMemo(() => searchSelRow?.kind === 'hit'
825
- ? wrapWithOffsets(searchSelRow.hit.text, searchPreviewWidth)
826
- : [], [searchSelRow, searchPreviewWidth]);
827
- // T52 — corpo del messaggio aperto nel reader, wrappato UNA volta per (testo,
828
- // larghezza). Senza memo ogni pressione di freccia rifarebbe l'a-capo di un
829
- // messaggio che nella coda lunga arriva a 150k char.
830
- const readerWidth = Math.max(20, (columns || 80) - 6);
831
- const readerLines = useMemo(() => (readerRow?.kind === 'hit' ? wrapWithOffsets(readerRow.hit.text, readerWidth) : []), [readerRow, readerWidth]);
832
- const readerCap = readerCapacity(rows);
833
- const readerMaxTop = Math.max(0, readerLines.length - readerCap);
834
197
  // T66 — testo del task file wrappato per il detail. `wrapWithOffsets` e non
835
198
  // `wrapLines`: quest'ultimo appiattisce gli a-capo in un flusso unico, che per
836
199
  // una preview di 4 righe va bene e per un task file — titoli, bullet, tabelle,
837
- // blocchi di codice — significa renderlo illeggibile. Memoizzato per (testo,
838
- // larghezza), o ogni pressione di freccia rifarebbe l'a-capo di 9KB.
839
- //
840
- // Cornici da scalare: box esterno (2 bordi + 2 padding) + box testo (2 bordi +
841
- // 2 padding) = 8. Sottostimare tronca un carattere, sovrastimare manda a capo
842
- // una riga che il budget d'altezza non ha contato.
843
- const sheetWidth = Math.max(20, (columns || 80) - 8);
844
- // T75 — il markdown si rende PRIMA del wrap, e il resto della catena lavora
845
- // sul testo reso: `**foo**` occupa 3 colonne rese e 7 grezze, quindi
846
- // wrappare sui marker manderebbe a capo su un conteggio che il terminale non
847
- // disegna. Memo separato dal wrap perché il parse dipende solo dal testo: un
848
- // resize ri-wrappa 9KB, non li ri-parsa.
849
- const sheetDoc = useMemo(() => (sheet?.text ? parseMarkdown(sheet.text) : null), [sheet]);
850
- // Le righe conservano i propri offset invece di essere appiattite a stringa
851
- // (T66 le buttava con `.map((l) => l.text)`): è ciò che rende
852
- // l'evidenziazione un'intersezione di intervalli invece di un caso speciale
853
- // per il match spezzato dall'a-capo. Dopo T75 gli offset indicizzano il testo
854
- // RESO, ed è l'unica coordinata coerente che resti — il sorgente non è più
855
- // ciò che sta a schermo.
856
- const sheetLines = useMemo(() => (sheetDoc ? wrapWithOffsets(sheetDoc.text, sheetWidth) : []), [sheetDoc, sheetWidth]);
857
- const sheetCap = detailCapacity(rows, find?.open === true);
858
- const sheetMaxTop = Math.max(0, sheetLines.length - sheetCap);
859
- // Lo scan gira sulla STESSA stringa che si renderizza: cercare su un testo
860
- // diverso da quello a schermo darebbe offset che indicizzano un altro
861
- // documento, cioè un'evidenziazione spostata di N caratteri e nessun errore.
862
- //
863
- // Dopo T75 quella stringa è il testo RESO, non più il sorgente: si cerca ciò
864
- // che si vede. Ne discende che `**` non è più cercabile — è la conseguenza
865
- // voluta, perché a schermo non c'è; e `Priority`, che prima era `**Priority**`
866
- // e si trovava lo stesso, continua a trovarsi.
867
- const findRes = useMemo(() => find && sheetDoc
868
- ? scanText(sheetDoc.text, find.q)
869
- : { occ: [], error: '' }, [find?.q, sheetDoc]);
870
- // L'indice si clampa qui invece di essere corretto a ogni `setOccIdx`: la
871
- // lista si accorcia da sola mentre si digita, e un indice fuori range vivrebbe
872
- // per il tempo di un render.
873
- const occCur = findRes.occ.length > 0 ? Math.min(occIdx, findRes.occ.length - 1) : -1;
874
- // Salto all'occorrenza corrente, centrata. Non dipende da `sheetTop`, quindi
875
- // non si auto-rilancia; dipende da `sheetLines` e `sheetCap`, quindi un resize
876
- // ricalcola la posizione senza toccare le occorrenze — che sono offset del
877
- // sorgente e il resize non le sposta.
878
- useEffect(() => {
879
- const o = occCur >= 0 ? findRes.occ[occCur] : undefined;
880
- if (!o)
881
- return;
882
- setSheetTop(topForOffset(sheetLines, o.start, sheetCap));
883
- }, [findRes, occCur, sheetLines, sheetCap]);
884
- // T57 — righe del modale assegnazione: `null` (detach) in testa, poi le task
885
- // della VISTA corrente (D4 — filtri e sort inclusi, coerenza con ciò che si
886
- // stava leggendo a sinistra; le escluse restano contate nell'header).
887
- //
888
- // Il filtro è un substring case-insensitive su id + titolo grezzo (D5): un
889
- // restringimento veloce, non una ricerca — quella è `^F` e ha il suo motore.
890
- // Il solo id non basterebbe, perché il caso d'uso nasce proprio dal «ho
891
- // capito ora che questa conversazione è la task del titolo X».
892
- //
893
- // `detach` NON si filtra: è l'azione di svuotamento, non una task, e sparire
894
- // digitando la renderebbe raggiungibile solo a campo vuoto.
895
- const assignRows = useMemo(() => {
896
- const q = assignFilter.trim().toLowerCase();
897
- const matched = q
898
- ? viewTasks.filter((t) => t.id.toLowerCase().includes(q) || t.rawDesc.toLowerCase().includes(q))
899
- : viewTasks;
900
- return [null, ...matched];
901
- }, [viewTasks, assignFilter]);
902
- const assignCap = assignListCapacity(rows, Boolean(note));
903
200
  // T39 — selezione stabile sotto trasformazione. Se la task selezionata esce
904
201
  // dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
905
202
  // sulla prima visibile — fallback deterministico, mai una posizione a caso.
@@ -917,25 +214,6 @@ function Deck({ cwd, tasksPath, tasksDir }) {
917
214
  setSelSessionId(firstSelectableId(sessionRows));
918
215
  }
919
216
  }, [sessionRows, selSessionId]);
920
- // T52 — stessa invariante sulla lista occorrenze, dove è ancora più stretta:
921
- // la lista si RICOSTRUISCE a ogni carattere digitato, quindi la chiave
922
- // selezionata sparisce continuamente. Persa → prima riga, mai una posizione
923
- // ereditata (che qui punterebbe a un'altra conversazione, non a un'altra riga).
924
- useEffect(() => {
925
- if (rowIndexOfKey(searchRows, searchSelKey) < 0) {
926
- setSearchSelKey(firstRowKey(searchRows));
927
- }
928
- }, [searchRows, searchSelKey]);
929
- // T57 — la lista del modale si restringe a ogni carattere digitato: quando la
930
- // task selezionata esce dal filtro si cade sulla PRIMA TASK, non su `detach`.
931
- // Cadere su detach significherebbe che un `⏎` battuto di slancio dopo aver
932
- // digitato staccherebbe la sessione invece di assegnarla — l'esatto opposto
933
- // dell'intenzione. Senza match la selezione resta detach: è l'unica riga viva.
934
- useEffect(() => {
935
- if (assignSel !== null && !assignRows.some((t) => t?.id === assignSel)) {
936
- setAssignSel(assignRows[1]?.id ?? null);
937
- }
938
- }, [assignRows, assignSel]);
939
217
  // T30: submit dell'input box. Il taskId nasce DOPO create-task (lo assegna la
940
218
  // skill scrivendo tasks.md) → non è noto allo spawn. Il sessionId invece è
941
219
  // pinnato qui: snapshot degli id PRIMA, poi al completamento re-leggo tasks.md
@@ -1020,52 +298,6 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1020
298
  if (task)
1021
299
  spawnForTask(task.id, kind, keyLabel);
1022
300
  }
1023
- // T66 — `⏎` sul pane task apre il detail invece di spawnare. Il testo del task
1024
- // file si legge QUI e non a ogni cambio selezione: l'overlay è l'unico
1025
- // consumer, e leggerlo in anticipo pagherebbe un file a ogni pressione di
1026
- // freccia per una schermata che quasi sempre non si apre.
1027
- // File assente → `text` null: l'overlay si apre lo stesso, perché spawnare una
1028
- // sessione non richiede il file (lo risolve `deck-run` per id).
1029
- function openDetail() {
1030
- const task = selectedTaskOr('⏎', 'aprire');
1031
- if (!task)
1032
- return;
1033
- setSheet({
1034
- id: task.id,
1035
- // Il titolo del task file quando c'è (è l'H1, cioè la forma lunga), la
1036
- // riga di tasks.md altrimenti: il detail non deve restare senza intestazione
1037
- // solo perché il file manca.
1038
- title: detail?.title || task.desc,
1039
- text: loadTaskFileText(tasksDir, task.id),
1040
- });
1041
- setSheetTop(0);
1042
- setSheetAction(0);
1043
- setFind(null);
1044
- setOccIdx(0);
1045
- setNote('');
1046
- setMode('detail');
1047
- }
1048
- function closeDetail() {
1049
- setMode('normal');
1050
- setSheet(null);
1051
- setFind(null);
1052
- }
1053
- function scrollDetail(delta) {
1054
- setSheetTop((t) => Math.max(0, Math.min(sheetMaxTop, t + delta)));
1055
- }
1056
- /** Modifica la query: l'insieme delle occorrenze cambia, quindi si riparte
1057
- * dalla prima. Il movimento del caret NON passa di qui — sposta il cursore,
1058
- * non i risultati. */
1059
- function editFind(next) {
1060
- setFind((f) => (f ? next(f) : f));
1061
- setOccIdx(0);
1062
- }
1063
- function moveOcc(d) {
1064
- const n = findRes.occ.length;
1065
- if (n === 0)
1066
- return;
1067
- setOccIdx((i) => (Math.min(i, n - 1) + d + n) % n);
1068
- }
1069
301
  // T53 — apertura del modale nota sulla conversazione selezionata. Come
1070
302
  // `openEdit`, la bozza parte dal valore ATTUALE: annotare una seconda volta è
1071
303
  // quasi sempre correggere, e ripartire da vuoto costringerebbe a ridigitare
@@ -1098,58 +330,6 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1098
330
  ? `✎ nota su ${sid.slice(0, 8)}: "${cut(text, 40)}"`
1099
331
  : `✎ nota rimossa da ${sid.slice(0, 8)}`);
1100
332
  }
1101
- // T57 — apertura del modale di assegnazione. La preselezione è la task a cui
1102
- // la sessione è GIÀ legata (riassegnare è quasi sempre correggere, e vedere da
1103
- // dove si parte è metà dell'informazione), altrimenti la prima della vista.
1104
- // Mai `detach`: è l'azione distruttiva della lista, e preselezionarla
1105
- // metterebbe un `⏎` battuto di slancio a un tasto dal cancellare un binding.
1106
- function openAssign() {
1107
- const sid = selSessionId;
1108
- if (!sid)
1109
- return;
1110
- const bound = bindings.get(sid);
1111
- setAssignSid(sid);
1112
- setAssignFilter('');
1113
- setAssignSel(bound && viewTasks.some((t) => t.id === bound) ? bound : viewTasks[0]?.id ?? null);
1114
- setNote('');
1115
- setMode('assign');
1116
- }
1117
- // Sposta la selezione del modale sulle righe (0 = detach, 1..N = task filtrate).
1118
- function moveAssign(delta) {
1119
- const at = assignRows.findIndex((t) => (t?.id ?? null) === assignSel);
1120
- const next = Math.max(0, Math.min(assignRows.length - 1, (at < 0 ? 0 : at) + delta));
1121
- setAssignSel(assignRows[next]?.id ?? null);
1122
- }
1123
- // T57 — ⏎ nel modale: riscrive il binding nel sidecar e ricarica subito.
1124
- //
1125
- // Il binding retroattivo governa il FUTURO della conversazione, non il suo
1126
- // passato: il titolo della tab è stato deciso allo spawn da `claude --name` e
1127
- // vive nel transcript, la `LOOM_TASK` di un processo già partito non si
1128
- // reinietta. Cambia cosa fa il prossimo `⏎ resume`, che rilegge il binding dal
1129
- // sidecar. La nota lo dice: senza, la promessa implicita è «ho spostato la
1130
- // conversazione» e il titolo che non cambia sembra un bug.
1131
- //
1132
- // Dove atterra la selezione (D6): il pane task non si muove, quindi la
1133
- // sessione appena assegnata esce dal gruppo contestuale → si scende alla riga
1134
- // SUCCESSIVA, catturata PRIMA della riscrittura (dopo, la riga non c'è più).
1135
- // Due eccezioni in cui invece resta dov'è, perché non sparisce affatto: una
1136
- // pinnata (esente dal contesto) e un'assegnazione al parent già selezionato.
1137
- function submitAssign() {
1138
- const sid = assignSid;
1139
- const target = assignSel;
1140
- setMode('normal');
1141
- setAssignFilter('');
1142
- if (!sid)
1143
- return;
1144
- const stays = pinned.has(sid) || target === selectedTaskId;
1145
- const next = stays ? sid : neighborId(sessionRows, sid);
1146
- appendTaskBinding(cwd, sid, target ?? '');
1147
- reloadSessions();
1148
- setSelSessionId(next);
1149
- setNote(target
1150
- ? `A ${sid.slice(0, 8)} → ${target} · vale dal prossimo ⏎ resume (titolo tab invariato)`
1151
- : `A ${sid.slice(0, 8)} → spot · binding rimosso`);
1152
- }
1153
333
  // T41 — apertura dell'edit: la bozza parte dai valori ATTUALI della task, non
1154
334
  // da default. La priorità arriva dal glifo di tasks.md (già in `selTask`), lo
1155
335
  // stato dal suo glifo Prog; il progresso arbitrario dal campo `Progress` del
@@ -1261,515 +441,259 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1261
441
  setNote(`vista sessioni: ${sessionView(next).label(sessionCounts, parentLabel)}`);
1262
442
  }
1263
443
  }
1264
- // T52`⏎` contestuale al TIPO di riga: la lista ne mescola due e l'azione
1265
- // giusta dipende da quale è selezionata. Riga sessione resume, identico al
1266
- // `⏎` della lista sessioni (stessa funzione, T49). Riga occorrenza → reader.
1267
- function submitSearchRow() {
1268
- const row = selectedRow(searchRows, searchSelKey);
1269
- if (!row) {
1270
- setNote(searchResult.error ? ' regex non valida' : 'nessuna occorrenza selezionata');
1271
- return;
1272
- }
1273
- if (row.kind === 'session') {
1274
- const bound = bindings.get(row.session.sessionId) ?? null;
1275
- const child = spawnDeckResume(bound, cwd, row.session.sessionId, sessionNotes.get(row.session.sessionId));
1276
- child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
1277
- setNote(`⏎ resume ${row.session.sessionId.slice(0, 8)} → tab CC${bound ? ` (${bound})` : ' (spot)'}`);
1278
- return;
1279
- }
1280
- // D8 — il reader si apre POSIZIONATO sull'occorrenza, non in cima. Il 94%
1281
- // dei messaggi entra in una schermata e la differenza non si vede; è sul 6%
1282
- // lungo (p99 ≈ 88 righe, max ≈ 1547) che aprire in cima costringerebbe a
1283
- // rifare a mano la ricerca appena fatta — cioè proprio il lavoro che questa
1284
- // feature esiste per evitare.
1285
- const lines = wrapWithOffsets(row.hit.text, readerWidth);
1286
- const cap = readerCapacity(rows);
1287
- const matchLine = lines.findIndex((l) => l.end > row.hit.matchStart);
1288
- const maxTop = Math.max(0, lines.length - cap);
1289
- setReaderRow(row);
1290
- setReaderTop(Math.max(0, Math.min(maxTop, Math.max(0, matchLine) - Math.floor(cap / 2))));
1291
- setNote('');
1292
- setMode('reader');
1293
- }
1294
- function scrollReader(delta) {
1295
- setReaderTop((t) => Math.max(0, Math.min(readerMaxTop, t + delta)));
444
+ // T49resume di una conversazione in una nuova tab. Unico punto: lo chiamano
445
+ // il `⏎` della lista sessioni e quello sulla riga-sessione della ricerca, che
446
+ // devono restare la stessa azione.
447
+ function resumeSession(sessionId) {
448
+ const bound = bindings.get(sessionId) ?? null;
449
+ const child = spawnDeckResume(bound, cwd, sessionId, sessionNotes.get(sessionId));
450
+ child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
451
+ setNote(`⏎ resume ${sessionId.slice(0, 8)} → tab CC${bound ? ` (${bound})` : ' (spot)'}`);
1296
452
  }
1297
- // T52toggle di tipo messaggio. Spegnerli tutti è uno stato lecito (lista
1298
- // vuota, nessun errore): è l'utente che ha chiuso ogni canale, non un guasto.
1299
- function toggleKind(kind) {
1300
- setSearchOpts((o) => ({ ...o, kinds: { ...o.kinds, [kind]: !o.kinds[kind] } }));
1301
- }
1302
- function editSearchField(fn) {
1303
- // La nota racconta l'esito di un'AZIONE su una lista che, con l'eager, si
1304
- // ricostruisce a ogni carattere: appena la query cambia è già scaduta.
1305
- // Lasciarla lì la fa leggere come se descrivesse lo stato corrente — nello
1306
- // specifico «nessuna occorrenza selezionata» sopra una lista con una riga
1307
- // visibilmente selezionata, cioè una contraddizione a schermo.
1308
- setNote('');
1309
- if (searchField === 'hash')
1310
- setSearchHash(fn);
1311
- else
1312
- setSearchQuery(fn);
1313
- }
1314
- // `useInput` consegna il CHUNK letto da stdin, non un tasto: un incollaggio —
1315
- // o una raffica di tasti piu' veloce di una read — arriva come stringa unica,
1316
- // byte di controllo compresi. Due conseguenze, entrambe verificate su pty:
1317
- //
1318
- // 1. I byte di controllo finiscono DENTRO il campo se non li si filtra. Non
1319
- // si vedono, ma Ink li conta nella larghezza della riga e nessun match li
1320
- // soddisfa -> la ricerca smette di trovare senza dire perche'. Le newline
1321
- // diventano spazio invece di sparire: incollare due righe deve separare
1322
- // le parole, non fonderle.
453
+ // T57 nel modale: riscrive il binding nel sidecar e ricarica subito.
1323
454
  //
1324
- // 2. Un TAB digitato subito dopo una lettera (~60 ms, cioe' battitura veloce
1325
- // normale) arriva incollato ad essa: `key.tab` resta falso e il ramo del
1326
- // cambio campo non scatta mai. Trattarlo come testo lo renderebbe uno
1327
- // spazio, per giunta nel campo sbagliato e' cosi' che `70897aff` + Tab
1328
- // + `congelat` finiva tutto quanto nel campo hash.
455
+ // Il binding retroattivo governa il FUTURO della conversazione, non il suo
456
+ // passato: il titolo della tab è stato deciso allo spawn da `claude --name` e
457
+ // vive nel transcript, la `LOOM_TASK` di un processo già partito non si
458
+ // reinietta. Cambia cosa fa il prossimo `⏎ resume`, che rilegge il binding dal
459
+ // sidecar. La nota lo dice: senza, la promessa implicita è «ho spostato la
460
+ // conversazione» e il titolo che non cambia sembra un bug.
1329
461
  //
1330
- // Si spezza quindi il chunk SUL TAB applicando il cambio campo in mezzo: il
1331
- // risultato e' identico alla battitura lenta. Stessa lezione del modale sort
1332
- // (T39), che cicla sui caratteri del chunk invece di leggerlo intero.
1333
- function typeIntoField(chunk) {
1334
- setNote('');
1335
- const clean = sanitizeTyped;
1336
- const parts = chunk.split('\t');
1337
- let field = searchField;
1338
- const add = { hash: '', query: '' };
1339
- for (let k = 0; k < parts.length; k++) {
1340
- if (k > 0)
1341
- field = field === 'hash' ? 'query' : 'hash';
1342
- add[field] += clean(parts[k]);
462
+ // Dove atterra la selezione (D6): il pane task non si muove, quindi la
463
+ // sessione appena assegnata esce dal gruppo contestuale si scende alla riga
464
+ // SUCCESSIVA, catturata PRIMA della riscrittura (dopo, la riga non c'è più).
465
+ // Due eccezioni in cui invece resta dov'è, perché non sparisce affatto: una
466
+ // pinnata (esente dal contesto) e un'assegnazione al parent già selezionato.
467
+ const assign = useAssignOverlay({
468
+ viewTasks,
469
+ rows,
470
+ hasNote: Boolean(note),
471
+ setMode,
472
+ setNote,
473
+ onSubmit: (sid, target) => {
474
+ const stays = pinned.has(sid) || target === selectedTaskId;
475
+ const next = stays ? sid : neighborId(sessionRows, sid);
476
+ appendTaskBinding(cwd, sid, target ?? '');
477
+ reloadSessions();
478
+ setSelSessionId(next);
479
+ setNote(target
480
+ ? `A ${sid.slice(0, 8)} → ${target} · vale dal prossimo ⏎ resume (titolo tab invariato)`
481
+ : `A ${sid.slice(0, 8)} → spot · binding rimosso`);
482
+ },
483
+ });
484
+ const sheet = useSheetOverlay({
485
+ rows,
486
+ columns,
487
+ setMode,
488
+ setNote,
489
+ onAction: (id, kind, label) => spawnForTask(id, kind, label),
490
+ });
491
+ const search = useSearchOverlay({
492
+ sessions,
493
+ rows,
494
+ columns,
495
+ hasNote: Boolean(note),
496
+ setMode,
497
+ setNote,
498
+ onResume: (row) => resumeSession(row.session.sessionId),
499
+ });
500
+ function onCreateKey(input, key) {
501
+ if (key.escape) {
502
+ setMode('normal');
503
+ setDraft('');
504
+ setNote('C → create annullato');
505
+ }
506
+ else if (key.return) {
507
+ submitCreate();
508
+ }
509
+ else if (key.backspace || key.delete) {
510
+ setDraft((d) => d.slice(0, -1));
511
+ }
512
+ else if (input && !key.ctrl && !key.meta) {
513
+ setDraft((d) => d + input);
1343
514
  }
1344
- if (add.hash)
1345
- setSearchHash((v) => v + add.hash);
1346
- if (add.query)
1347
- setSearchQuery((v) => v + add.query);
1348
- setSearchField(field);
1349
515
  }
1350
- const searchCap = searchListCapacity(rows, Boolean(note));
1351
- useInput((input, key) => {
1352
- // T52 — MODALE DENTRO IL MODALE. `useInput` in Ink è globale e non ha
1353
- // focus-trap: non esiste un meccanismo che confini l'input a un componente.
1354
- // La cattura È l'ordine dei rami — quindi il reader va PRIMA della ricerca,
1355
- // e mentre è aperto il modale ricerca resta montato sotto con la sua
1356
- // selezione intatta, pronto a riprendere il controllo su `esc`.
1357
- if (mode === 'reader') {
1358
- if (key.escape) {
1359
- setMode('search');
1360
- setReaderRow(null);
1361
- }
1362
- else if (key.upArrow) {
1363
- scrollReader(-1);
1364
- }
1365
- else if (key.downArrow) {
1366
- scrollReader(1);
1367
- }
1368
- else if (key.pageUp) {
1369
- scrollReader(-readerCap);
1370
- }
1371
- else if (key.pageDown) {
1372
- scrollReader(readerCap);
1373
- }
1374
- else if (input === 'g') {
1375
- // Estremi su lettera e non su Home/End: Ink RICONOSCE quelle due (le
1376
- // mappa a 'home'/'end' nel parser) ma NON le espone — `nonAlphanumericKeys`
1377
- // azzera l'input e nessun flag le rappresenta, quindi arrivano
1378
- // indistinguibili da qualunque tasto ignoto. Verificato su pty reale.
1379
- // Nel reader non c'è input di testo, quindi le lettere sono libere.
1380
- setReaderTop(0);
1381
- }
1382
- else if (input === 'G') {
1383
- setReaderTop(readerMaxTop);
1384
- }
1385
- return;
516
+ function onNoteKey(input, key) {
517
+ if (key.escape) {
518
+ setMode('normal');
519
+ setNoteDraft('');
520
+ setNote('N nota annullata');
1386
521
  }
1387
- // T52 — modale ricerca. I due campi di testo mangiano ogni lettera nuda:
1388
- // ogni comando che deve restare vivo MENTRE si digita passa da CTRL, dai
1389
- // tasti freccia o da Tab. `esc` chiude il modale, non il deck.
1390
- if (mode === 'search') {
1391
- if (key.escape) {
1392
- setMode('normal');
1393
- setNote('');
1394
- return;
1395
- }
1396
- if (key.ctrl) {
1397
- const flag = SEARCH_TOGGLE_KEYS[input];
1398
- if (flag) {
1399
- setSearchOpts((o) => ({ ...o, [flag]: !o[flag] }));
1400
- return;
1401
- }
1402
- const kind = SEARCH_KIND_KEYS[input];
1403
- if (kind)
1404
- toggleKind(kind);
1405
- return; // ogni altra combo ctrl (^F incluso: siamo già dentro) = no-op
1406
- }
1407
- if (key.tab) {
1408
- setSearchField((f) => (f === 'hash' ? 'query' : 'hash'));
1409
- }
1410
- else if (key.upArrow) {
1411
- setSearchSelKey((k) => moveRowSelection(searchRows, k, -1));
1412
- }
1413
- else if (key.downArrow) {
1414
- setSearchSelKey((k) => moveRowSelection(searchRows, k, 1));
1415
- }
1416
- else if (key.pageUp) {
1417
- setSearchSelKey((k) => moveRowSelection(searchRows, k, -Math.max(1, searchCap)));
1418
- }
1419
- else if (key.pageDown) {
1420
- setSearchSelKey((k) => moveRowSelection(searchRows, k, Math.max(1, searchCap)));
1421
- }
1422
- else if (key.return) {
1423
- submitSearchRow();
1424
- }
1425
- else if (key.backspace || key.delete) {
1426
- editSearchField((s) => s.slice(0, -1));
1427
- }
1428
- else if (input && !key.meta) {
1429
- typeIntoField(input);
1430
- }
1431
- return;
522
+ else if (key.return) {
523
+ submitNote();
1432
524
  }
1433
- // T57 modale di assegnazione: un campo di filtro più una lista. Come
1434
- // ricerca e nota, il campo mangia ogni lettera nuda `^U` (kill-line delle
1435
- // shell) per svuotarlo, e la navigazione passa dalle frecce.
1436
- if (mode === 'assign') {
1437
- if (key.escape) {
1438
- setMode('normal');
1439
- setAssignFilter('');
1440
- setNote('A assegnazione annullata');
1441
- }
1442
- else if (key.return) {
1443
- submitAssign();
1444
- }
1445
- else if (key.upArrow) {
1446
- moveAssign(-1);
1447
- }
1448
- else if (key.downArrow) {
1449
- moveAssign(1);
1450
- }
1451
- else if (key.pageUp) {
1452
- moveAssign(-Math.max(1, assignCap));
1453
- }
1454
- else if (key.pageDown) {
1455
- moveAssign(Math.max(1, assignCap));
1456
- }
1457
- else if (key.ctrl) {
1458
- // `^U` svuota; ogni altra combo è no-op. Il backspace tenuto premuto
1459
- // cancella un carattere per CHUNK letto da stdin, non per pressione
1460
- // (T53): senza `^U` ripulire un filtro digitato di getto sarebbe lento
1461
- // quanto riaprire il modale.
1462
- if (input === 'u')
1463
- setAssignFilter('');
1464
- }
1465
- else if (key.backspace || key.delete) {
1466
- setAssignFilter((f) => f.slice(0, -1));
1467
- }
1468
- else if (input && !key.meta) {
1469
- setAssignFilter((f) => f + sanitizeTyped(input));
1470
- }
1471
- return;
525
+ else if (key.ctrl && input === 'u') {
526
+ // Svuota il campo in un colpo. NON è una scorciatoia di comodo: il
527
+ // backspace tenuto premuto cancella UN carattere per CHUNK letto da
528
+ // stdin, non per pressione (`useInput` consegna il chunk, e per una
529
+ // raffica di DEL Ink alza `key.backspace` una volta sola) — misurato,
530
+ // 30 pressioni → 2 caratteri. Siccome «campo vuoto» qui è l'unico modo
531
+ // di CANCELLARE una nota, dipendere dal backspace renderebbe
532
+ // l'operazione praticamente non eseguibile. `^U` è il kill-line delle
533
+ // shell, quindi il gesto è già nelle dita di chi usa un terminale.
534
+ setNoteDraft('');
1472
535
  }
1473
- // T30: in modalità create l'handler cattura il testo e corto-circuita la
1474
- // navigazione normale (incl. q/esc → quit: qui esc annulla, non esce).
1475
- if (mode === 'create') {
1476
- if (key.escape) {
1477
- setMode('normal');
1478
- setDraft('');
1479
- setNote('C → create annullato');
1480
- }
1481
- else if (key.return) {
1482
- submitCreate();
1483
- }
1484
- else if (key.backspace || key.delete) {
1485
- setDraft((d) => d.slice(0, -1));
1486
- }
1487
- else if (input && !key.ctrl && !key.meta) {
1488
- setDraft((d) => d + input);
1489
- }
1490
- return;
536
+ else if (key.backspace || key.delete) {
537
+ setNoteDraft((d) => d.slice(0, -1));
1491
538
  }
1492
- // T53 modale nota. Come create, cattura il testo e corto-circuita la
1493
- // navigazione (qui `q` è una lettera da scrivere, non il quit).
1494
- if (mode === 'note') {
1495
- if (key.escape) {
1496
- setMode('normal');
1497
- setNoteDraft('');
1498
- setNote('N nota annullata');
1499
- }
1500
- else if (key.return) {
1501
- submitNote();
1502
- }
1503
- else if (key.ctrl && input === 'u') {
1504
- // Svuota il campo in un colpo. NON è una scorciatoia di comodo: il
1505
- // backspace tenuto premuto cancella UN carattere per CHUNK letto da
1506
- // stdin, non per pressione (`useInput` consegna il chunk, e per una
1507
- // raffica di DEL Ink alza `key.backspace` una volta sola) — misurato,
1508
- // 30 pressioni → 2 caratteri. Siccome «campo vuoto» qui è l'unico modo
1509
- // di CANCELLARE una nota, dipendere dal backspace renderebbe
1510
- // l'operazione praticamente non eseguibile. `^U` è il kill-line delle
1511
- // shell, quindi il gesto è già nelle dita di chi usa un terminale.
1512
- setNoteDraft('');
1513
- }
1514
- else if (key.backspace || key.delete) {
1515
- setNoteDraft((d) => d.slice(0, -1));
1516
- }
1517
- else if (input && !key.ctrl && !key.meta) {
1518
- // Sanificazione dei byte di controllo, come `typeIntoField` della
1519
- // ricerca: `useInput` consegna il CHUNK letto da stdin, quindi un
1520
- // incollaggio porta dentro newline e control char. Invisibili a schermo
1521
- // ma contati da Ink nella larghezza della riga — e qui finirebbero
1522
- // scritti su disco, dove resterebbero a sporcare la riga per sempre.
1523
- // Le newline diventano spazio: incollare due righe deve separare le
1524
- // parole, non fonderle.
1525
- setNoteDraft((d) => d + sanitizeTyped(input));
1526
- }
1527
- return;
539
+ else if (input && !key.ctrl && !key.meta) {
540
+ // Sanificazione dei byte di controllo, come `typeIntoField` della
541
+ // ricerca: `useInput` consegna il CHUNK letto da stdin, quindi un
542
+ // incollaggio porta dentro newline e control char. Invisibili a schermo
543
+ // ma contati da Ink nella larghezza della riga — e qui finirebbero
544
+ // scritti su disco, dove resterebbero a sporcare la riga per sempre.
545
+ // Le newline diventano spazio: incollare due righe deve separare le
546
+ // parole, non fonderle.
547
+ setNoteDraft((d) => d + sanitizeTyped(input));
1528
548
  }
1529
- // T39 — modale sort a grammatica libera: la SEQUENZA di tasti È la chain.
1530
- // Digitare `ppi` = p(asc) p(desc) i(asc) → [pri desc, id asc]. La lista si
1531
- // riordina dal vivo a ogni pressione.
1532
- if (mode === 'sort') {
1533
- if (key.escape) {
1534
- closeViewModal(true);
1535
- setNote('S → sort annullato');
1536
- }
1537
- else if (key.return) {
1538
- closeViewModal(false);
1539
- setNote(`S → sort: ${describeSort(view.sort)}`);
1540
- }
1541
- else if (input) {
1542
- // useInput consegna il CHUNK letto da stdin, non un tasto: digitando
1543
- // veloce (o incollando) `ppi` arriva come stringa unica. Si cicla su
1544
- // ogni carattere, così la chain esce identica a battitura lenta.
1545
- const keys = [...input].map((ch) => SORT_TASTI[ch]).filter(Boolean);
1546
- if (keys.length > 0) {
1547
- setView((v) => ({ ...v, sort: keys.reduce(cycleSort, v.sort) }));
1548
- }
1549
- }
1550
- return;
549
+ }
550
+ function onSortKey(input, key) {
551
+ if (key.escape) {
552
+ closeViewModal(true);
553
+ setNote('S → sort annullato');
1551
554
  }
1552
- // T39 — modale filtri: griglia 2 righe (priorità / stato), un toggle per
1553
- // valore. Anche qui l'effetto è immediato sulla lista.
1554
- if (mode === 'filter') {
1555
- const rowLen = (r) => (r === 0 ? PRI_ENTRIES.length : PROG_ENTRIES.length);
1556
- if (key.escape) {
1557
- closeViewModal(true);
1558
- setNote('F → filtri annullati');
1559
- }
1560
- else if (key.return) {
1561
- closeViewModal(false);
1562
- setNote(hiddenTasks > 0 ? `F → ${hiddenTasks} task nascoste` : 'F → nessun filtro attivo');
1563
- }
1564
- else if (key.upArrow || key.downArrow) {
1565
- setFilterCursor((c) => {
1566
- const row = c.row === 0 ? 1 : 0;
1567
- return { row, col: Math.min(c.col, rowLen(row) - 1) };
1568
- });
1569
- }
1570
- else if (key.leftArrow) {
1571
- setFilterCursor((c) => ({ ...c, col: Math.max(0, c.col - 1) }));
1572
- }
1573
- else if (key.rightArrow) {
1574
- setFilterCursor((c) => ({ ...c, col: Math.min(rowLen(c.row) - 1, c.col + 1) }));
1575
- }
1576
- else if (input === ' ') {
1577
- const { row, col } = filterCursor;
1578
- setView((v) => row === 0
1579
- ? { ...v, hiddenPri: toggleHidden(v.hiddenPri, PRI_ENTRIES[col].name) }
1580
- : { ...v, hiddenProg: toggleHidden(v.hiddenProg, PROG_ENTRIES[col].name) });
1581
- }
1582
- return;
555
+ else if (key.return) {
556
+ closeViewModal(false);
557
+ setNote(`S sort: ${describeSort(view.sort)}`);
1583
558
  }
1584
- // T41 modale edit: griglia a 4 righe. Righe 0/1 = scelta a valore singolo
1585
- // (←→ scorre), righe 2/3 = testo libero (ogni carattere stampabile entra nel
1586
- // campo). Come gli altri modali cattura tutto: `esc` annulla senza
1587
- // scrivere uscire dal deck.
1588
- if (mode === 'edit') {
1589
- if (key.escape) {
1590
- setMode('normal');
1591
- setEdit(null);
1592
- setNote('E → edit annullato');
1593
- }
1594
- else if (key.return) {
1595
- submitEdit();
1596
- }
1597
- else if (key.upArrow || key.downArrow) {
1598
- const next = ((editRow + EDIT_ROWS + (key.upArrow ? -1 : 1)) % EDIT_ROWS);
1599
- setEditRow(next);
1600
- // Il caret segue la riga attiva e atterra in CODA al nuovo campo: è la
1601
- // posizione da cui si continua a scrivere, ed è anche l'unica che non
1602
- // dipende da dove stava il cursore nel campo precedente.
1603
- setEdit((e) => (e && isTextRow(next) ? { ...e, caret: cpLen(e[editField(next)]) } : e));
1604
- }
1605
- else if ((key.leftArrow || key.rightArrow) && !isTextRow(editRow)) {
1606
- const d = key.leftArrow ? -1 : 1;
1607
- // Scorrimento CICLICO (wrap) e non clampato: le liste sono di 3-4 voci,
1608
- // arrivare in fondo e ripartire costa meno di invertire direzione.
1609
- if (editRow === 0) {
1610
- setEdit((e) => e ? { ...e, pri: EDIT_PRI[(EDIT_PRI.indexOf(e.pri) + d + EDIT_PRI.length) % EDIT_PRI.length] } : e);
1611
- }
1612
- else if (editRow === 1) {
1613
- setEdit((e) => e
1614
- ? { ...e, prog: EDIT_PROG[(EDIT_PROG.indexOf(e.prog) + d + EDIT_PROG.length) % EDIT_PROG.length] }
1615
- : e);
1616
- }
1617
- }
1618
- else if (isTextRow(editRow)) {
1619
- // Un solo ramo per i due campi di testo, la riga sceglie la chiave:
1620
- // duplicarlo significherebbe tenere allineate a mano due copie della
1621
- // stessa grammatica di input a ogni tasto aggiunto.
1622
- const field = editField(editRow);
1623
- if (key.ctrl) {
1624
- // T54 — ramo CTRL ANTEPOSTO a quelli su carattere, come in modalità
1625
- // normale: `^A` e `a` arrivano con lo stesso `input`, quindi senza
1626
- // questa precedenza il `^A` finirebbe dentro il testo.
1627
- //
1628
- // `^A`/`^E` (convenzione readline) perché `Home`/`End` NON sono
1629
- // esposte da `useInput`: arrivano come input vuoto, indistinguibili
1630
- // da qualunque altro tasto senza nome.
1631
- //
1632
- // `^D` è il delete-forward, e anche qui il motivo è un limite di Ink:
1633
- // il tasto Backspace fisico manda `\x7f` e il tasto Canc manda
1634
- // `\x1b[3~`, ma `parseKeypress` li battezza ENTRAMBI `delete` e
1635
- // svuota `input` — a valle sono lo stesso evento. `key.delete` va
1636
- // quindi al backspace (il tasto che si usa davvero) e la
1637
- // cancellazione in avanti prende il suo tasto readline.
1638
- if (input === 'a')
1639
- setEdit((e) => (e ? { ...e, caret: 0 } : e));
1640
- else if (input === 'e')
1641
- setEdit((e) => (e ? { ...e, caret: cpLen(e[field]) } : e));
1642
- else if (input === 'd')
1643
- setEdit((e) => (e ? { ...e, [field]: removeAt(e[field], e.caret) } : e));
1644
- }
1645
- else if (key.leftArrow || key.rightArrow) {
1646
- // CLAMP agli estremi, non wrap: a inizio campo `←` non deve saltare in
1647
- // fondo. Le liste di valori (righe 0/1) ciclano perché sono 3-4 voci;
1648
- // un testo no — il salto sarebbe indistinguibile da uno sfarfallio.
1649
- const d = key.leftArrow ? -1 : 1;
1650
- setEdit((e) => e ? { ...e, caret: Math.max(0, Math.min(cpLen(e[field]), e.caret + d)) } : e);
1651
- }
1652
- else if (key.backspace || key.delete) {
1653
- setEdit((e) => e ? { ...e, [field]: removeAt(e[field], e.caret - 1), caret: Math.max(0, e.caret - 1) } : e);
1654
- }
1655
- else if (input && !key.meta) {
1656
- // `sanitizeTyped`: `useInput` consegna il CHUNK di stdin, quindi un
1657
- // incollaggio porta dentro newline e byte di controllo — invisibili
1658
- // nel campo ma contati da Ink nella larghezza della riga, e destinati
1659
- // a finire tali e quali dentro tasks.md. Ed è per lo stesso motivo che
1660
- // il caret avanza della LUNGHEZZA del chunk, non di uno: un incollaggio
1661
- // entra tutto insieme.
1662
- const ins = sanitizeTyped(input);
1663
- setEdit((e) => e ? { ...e, [field]: insertAt(e[field], e.caret, ins), caret: e.caret + cpLen(ins) } : e);
1664
- }
559
+ else if (input) {
560
+ // useInput consegna il CHUNK letto da stdin, non un tasto: digitando
561
+ // veloce (o incollando) `ppi` arriva come stringa unica. Si cicla su
562
+ // ogni carattere, così la chain esce identica a battitura lenta.
563
+ const keys = [...input].map((ch) => SORT_TASTI[ch]).filter(Boolean);
564
+ if (keys.length > 0) {
565
+ setView((v) => ({ ...v, sort: keys.reduce(cycleSort, v.sort) }));
1665
566
  }
1666
- return;
1667
567
  }
1668
- // T66 — detail della task: due zone (testo scrollabile + barra azioni) e una
1669
- // selezione per ognuna. Sta PRIMA del ramo di default per la stessa ragione
1670
- // di reader e ricerca Ink non ha focus-trap, quindi la cattura È l'ordine
1671
- // dei rami — e il `return` in fondo è ciò che impedisce a `←→` di cambiare
1672
- // pane mentre muove fra le azioni: qui quel binding non esiste più. Cade lì
1673
- // anche `tab`, cioè il cambio vista: dentro il detail nessuno dei due tasti
1674
- // di navigazione del pane risponde, e non è un'eccezione da ricordare per
1675
- // ognuno — è il `return` che chiude tutto il ramo di default in una volta.
1676
- // È anche prima del ramo CTRL, quindi `^K`/`^P`/`^R` restano acceleratori
1677
- // della sola lista: chi è già nel detail ha i bottoni.
1678
- if (mode === 'detail') {
1679
- // T91 modale DENTRO il modale, e sta prima per la stessa ragione per cui
1680
- // il detail sta prima del ramo CTRL: Ink non ha focus-trap, quindi la
1681
- // cattura È l'ordine dei rami. Mentre il campo è aperto mangia ogni lettera
1682
- // nuda, `g`/`G` compresi.
1683
- if (find?.open) {
1684
- if (key.escape) {
1685
- // Annulla: butta via ciò che il modale ha prodotto. Lo scroll resta
1686
- // dove la ricerca l'ha portato riavvolgerlo sarebbe una terza
1687
- // semantica che nessun tasto ha chiesto.
1688
- setFind(null);
1689
- }
1690
- else if (key.return) {
1691
- // Congela: campo chiuso, occorrenze ancora colorate, scroll intatto.
1692
- setFind((f) => (f ? { ...f, open: false } : f));
1693
- }
1694
- else if (key.upArrow) {
1695
- moveOcc(-1);
1696
- }
1697
- else if (key.downArrow) {
1698
- moveOcc(1);
1699
- }
1700
- else if (key.leftArrow || key.rightArrow) {
1701
- const d = key.leftArrow ? -1 : 1;
1702
- setFind((f) => f ? { ...f, caret: Math.max(0, Math.min(cpLen(f.q), f.caret + d)) } : f);
1703
- }
1704
- else if (key.backspace || key.delete) {
1705
- editFind((f) => f.caret > 0 ? { ...f, q: removeAt(f.q, f.caret - 1), caret: f.caret - 1 } : f);
1706
- }
1707
- else if (key.ctrl) {
1708
- // `^U` svuota, come il filtro del modale assegnazione; ogni altra combo
1709
- // è no-op — `^F` incluso, siamo già dentro.
1710
- if (input === 'u')
1711
- editFind((f) => ({ ...f, q: '', caret: 0 }));
1712
- }
1713
- else if (input && !key.meta) {
1714
- const ins = sanitizeTyped(input);
1715
- editFind((f) => ({ ...f, q: insertAt(f.q, f.caret, ins), caret: f.caret + cpLen(ins) }));
1716
- }
1717
- return;
1718
- }
1719
- if (key.ctrl && input === 'f') {
1720
- // Fuori dal detail `^F` è la ricerca conversazioni; qui è la ricerca nel
1721
- // testo. La query sopravvive a una chiusura con `⏎`, quindi riaprire
1722
- // riprende da dov'era invece di ricominciare.
1723
- setFind((f) => (f ? { ...f, open: true } : { q: '', caret: 0, open: true }));
1724
- return;
1725
- }
1726
- if (key.escape) {
1727
- // Uno strato alla volta: se resta un'evidenziazione congelata, `esc`
1728
- // smonta quella; il detail lo chiude il secondo.
1729
- // Nessun reset di `sel`: la selezione della lista non è mai stata
1730
- // toccata, quindi si ritrova esattamente dov'era.
1731
- if (find)
1732
- setFind(null);
1733
- else
1734
- closeDetail();
568
+ }
569
+ function onFilterKey(input, key) {
570
+ const rowLen = (r) => (r === 0 ? PRI_ENTRIES.length : PROG_ENTRIES.length);
571
+ if (key.escape) {
572
+ closeViewModal(true);
573
+ setNote('F filtri annullati');
574
+ }
575
+ else if (key.return) {
576
+ closeViewModal(false);
577
+ setNote(hiddenTasks > 0 ? `F ${hiddenTasks} task nascoste` : 'F → nessun filtro attivo');
578
+ }
579
+ else if (key.upArrow || key.downArrow) {
580
+ setFilterCursor((c) => {
581
+ const row = c.row === 0 ? 1 : 0;
582
+ return { row, col: Math.min(c.col, rowLen(row) - 1) };
583
+ });
584
+ }
585
+ else if (key.leftArrow) {
586
+ setFilterCursor((c) => ({ ...c, col: Math.max(0, c.col - 1) }));
587
+ }
588
+ else if (key.rightArrow) {
589
+ setFilterCursor((c) => ({ ...c, col: Math.min(rowLen(c.row) - 1, c.col + 1) }));
590
+ }
591
+ else if (input === ' ') {
592
+ const { row, col } = filterCursor;
593
+ setView((v) => row === 0
594
+ ? { ...v, hiddenPri: toggleHidden(v.hiddenPri, PRI_ENTRIES[col].name) }
595
+ : { ...v, hiddenProg: toggleHidden(v.hiddenProg, PROG_ENTRIES[col].name) });
596
+ }
597
+ }
598
+ function onEditKey(input, key) {
599
+ if (key.escape) {
600
+ setMode('normal');
601
+ setEdit(null);
602
+ setNote('E edit annullato');
603
+ }
604
+ else if (key.return) {
605
+ submitEdit();
606
+ }
607
+ else if (key.upArrow || key.downArrow) {
608
+ const next = ((editRow + EDIT_ROWS + (key.upArrow ? -1 : 1)) % EDIT_ROWS);
609
+ setEditRow(next);
610
+ // Il caret segue la riga attiva e atterra in CODA al nuovo campo: è la
611
+ // posizione da cui si continua a scrivere, ed è anche l'unica che non
612
+ // dipende da dove stava il cursore nel campo precedente.
613
+ setEdit((e) => (e && isTextRow(next) ? { ...e, caret: cpLen(e[editField(next)]) } : e));
614
+ }
615
+ else if ((key.leftArrow || key.rightArrow) && !isTextRow(editRow)) {
616
+ const d = key.leftArrow ? -1 : 1;
617
+ // Scorrimento CICLICO (wrap) e non clampato: le liste sono di 3-4 voci,
618
+ // arrivare in fondo e ripartire costa meno di invertire direzione.
619
+ if (editRow === 0) {
620
+ setEdit((e) => e ? { ...e, pri: EDIT_PRI[(EDIT_PRI.indexOf(e.pri) + d + EDIT_PRI.length) % EDIT_PRI.length] } : e);
621
+ }
622
+ else if (editRow === 1) {
623
+ setEdit((e) => e
624
+ ? { ...e, prog: EDIT_PROG[(EDIT_PROG.indexOf(e.prog) + d + EDIT_PROG.length) % EDIT_PROG.length] }
625
+ : e);
1735
626
  }
1736
- else if (key.return) {
1737
- // `⏎` esegue SEMPRE l'azione selezionata, mai "scrolla" o "chiudi": il
1738
- // testo non è un campo attivo (si scorre, non si edita), quindi nessuna
1739
- // competizione sul tasto.
1740
- const action = DETAIL_ACTIONS[sheetAction];
1741
- const id = sheet?.id;
1742
- closeDetail();
1743
- if (id)
1744
- spawnForTask(id, action.kind, `⏎ ${action.label}`);
627
+ }
628
+ else if (isTextRow(editRow)) {
629
+ // Un solo ramo per i due campi di testo, la riga sceglie la chiave:
630
+ // duplicarlo significherebbe tenere allineate a mano due copie della
631
+ // stessa grammatica di input a ogni tasto aggiunto.
632
+ const field = editField(editRow);
633
+ if (key.ctrl) {
634
+ // T54 — ramo CTRL ANTEPOSTO a quelli su carattere, come in modalità
635
+ // normale: `^A` e `a` arrivano con lo stesso `input`, quindi senza
636
+ // questa precedenza il `^A` finirebbe dentro il testo.
637
+ //
638
+ // `^A`/`^E` (convenzione readline) perché `Home`/`End` NON sono
639
+ // esposte da `useInput`: arrivano come input vuoto, indistinguibili
640
+ // da qualunque altro tasto senza nome.
641
+ //
642
+ // `^D` è il delete-forward, e anche qui il motivo è un limite di Ink:
643
+ // il tasto Backspace fisico manda `\x7f` e il tasto Canc manda
644
+ // `\x1b[3~`, ma `parseKeypress` li battezza ENTRAMBI `delete` e
645
+ // svuota `input` — a valle sono lo stesso evento. `key.delete` va
646
+ // quindi al backspace (il tasto che si usa davvero) e la
647
+ // cancellazione in avanti prende il suo tasto readline.
648
+ if (input === 'a')
649
+ setEdit((e) => (e ? { ...e, caret: 0 } : e));
650
+ else if (input === 'e')
651
+ setEdit((e) => (e ? { ...e, caret: cpLen(e[field]) } : e));
652
+ else if (input === 'd')
653
+ setEdit((e) => (e ? { ...e, [field]: removeAt(e[field], e.caret) } : e));
1745
654
  }
1746
655
  else if (key.leftArrow || key.rightArrow) {
1747
- // Scorrimento CICLICO come le righe di scelta del modale edit: cinque
1748
- // voci, arrivare in fondo e ripartire costa meno che invertire direzione.
656
+ // CLAMP agli estremi, non wrap: a inizio campo `←` non deve saltare in
657
+ // fondo. Le liste di valori (righe 0/1) ciclano perché sono 3-4 voci;
658
+ // un testo no — il salto sarebbe indistinguibile da uno sfarfallio.
1749
659
  const d = key.leftArrow ? -1 : 1;
1750
- setSheetAction((i) => (i + d + DETAIL_ACTIONS.length) % DETAIL_ACTIONS.length);
1751
- }
1752
- else if (key.upArrow) {
1753
- scrollDetail(-1);
1754
- }
1755
- else if (key.downArrow) {
1756
- scrollDetail(1);
1757
- }
1758
- else if (key.pageUp) {
1759
- scrollDetail(-sheetCap);
1760
- }
1761
- else if (key.pageDown) {
1762
- scrollDetail(sheetCap);
660
+ setEdit((e) => e ? { ...e, caret: Math.max(0, Math.min(cpLen(e[field]), e.caret + d)) } : e);
1763
661
  }
1764
- else if (input === 'g') {
1765
- // Estremi su lettera per lo stesso motivo del reader: Ink riconosce
1766
- // `Home`/`End` ma non le espone, e qui non c'è input di testo che
1767
- // contenda le lettere.
1768
- setSheetTop(0);
662
+ else if (key.backspace || key.delete) {
663
+ setEdit((e) => e ? { ...e, [field]: removeAt(e[field], e.caret - 1), caret: Math.max(0, e.caret - 1) } : e);
1769
664
  }
1770
- else if (input === 'G') {
1771
- setSheetTop(sheetMaxTop);
665
+ else if (input && !key.meta) {
666
+ // `sanitizeTyped`: `useInput` consegna il CHUNK di stdin, quindi un
667
+ // incollaggio porta dentro newline e byte di controllo — invisibili
668
+ // nel campo ma contati da Ink nella larghezza della riga, e destinati
669
+ // a finire tali e quali dentro tasks.md. Ed è per lo stesso motivo che
670
+ // il caret avanza della LUNGHEZZA del chunk, non di uno: un incollaggio
671
+ // entra tutto insieme.
672
+ const ins = sanitizeTyped(input);
673
+ setEdit((e) => e ? { ...e, [field]: insertAt(e[field], e.caret, ins), caret: e.caret + cpLen(ins) } : e);
1772
674
  }
675
+ }
676
+ }
677
+ // Il dispatch consulta il CATALOGO (`input-modes.ts`), non l'ordine di una
678
+ // catena di `if`. `MODE_KEYS` è il custode: essendo un `Record` su
679
+ // `CapturingMode`, un modo nuovo senza handler non compila.
680
+ const MODE_KEYS = {
681
+ detail: sheet.onKey,
682
+ reader: search.onReaderKey,
683
+ search: search.onSearchKey,
684
+ assign: assign.onKey,
685
+ create: onCreateKey,
686
+ note: onNoteKey,
687
+ sort: onSortKey,
688
+ filter: onFilterKey,
689
+ edit: onEditKey,
690
+ };
691
+ useInput((input, key) => {
692
+ // Un modo capturing consuma TUTTO — acceleratori globali compresi — e le
693
+ // deroghe se le gestisce da sé (`CTRL_DEROGATIONS`). Da qui in giù si è
694
+ // quindi in `normal`, l'unico modo che cede ai `key.ctrl`.
695
+ if (captures(mode)) {
696
+ MODE_KEYS[mode](input, key);
1773
697
  return;
1774
698
  }
1775
699
  // T52/D1 — il ramo CTRL sta PRIMA di quelli su lettera nuda e li chiude
@@ -1834,7 +758,19 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1834
758
  // più battuto non è il posto dove inchiodare una scelta di prompt — le
1835
759
  // shortcut CTRL restano per chi sa già cosa vuole, `⏎` apre il ventaglio.
1836
760
  // Lo spawn a mani nude di prima è `open`, cioè `⏎ ⏎` (è il focus iniziale).
1837
- openDetail();
761
+ {
762
+ const task = selectedTaskOr('⏎', 'aprire');
763
+ if (task) {
764
+ sheet.open({
765
+ id: task.id,
766
+ // Il titolo del task file quando c'è (è l'H1, cioè la forma
767
+ // lunga), la riga di tasks.md altrimenti: il detail non deve
768
+ // restare senza intestazione solo perché il file manca.
769
+ title: detail?.title || task.desc,
770
+ text: loadTaskFileText(tasksDir, task.id),
771
+ });
772
+ }
773
+ }
1838
774
  }
1839
775
  else {
1840
776
  // T49 — ⏎ su una sessione = resume in nuova tab. Il binding si rilegge
@@ -1844,10 +780,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1844
780
  setNote(selSessionId ? 'pin stale: transcript non più presente' : 'nessuna sessione da riprendere');
1845
781
  }
1846
782
  else {
1847
- const bound = bindings.get(s.sessionId) ?? null;
1848
- const child = spawnDeckResume(bound, cwd, s.sessionId, sessionNotes.get(s.sessionId));
1849
- child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
1850
- setNote(`⏎ resume ${s.sessionId.slice(0, 8)} → tab CC${bound ? ` (${bound})` : ' (spot)'}`);
783
+ resumeSession(s.sessionId);
1851
784
  }
1852
785
  }
1853
786
  }
@@ -1973,7 +906,8 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1973
906
  setNote('A → nessuna sessione da assegnare');
1974
907
  }
1975
908
  else {
1976
- openAssign();
909
+ if (selSessionId)
910
+ assign.open(selSessionId, bindings.get(selSessionId));
1977
911
  }
1978
912
  }
1979
913
  else if (input === 't') {
@@ -2060,32 +994,32 @@ function Deck({ cwd, tasksPath, tasksDir }) {
2060
994
  // visibile, quindi va RIPETUTA nel titolo — senza, non si sa più su cosa si
2061
995
  // sta agendo.
2062
996
  if (mode === 'assign') {
2063
- if (isCompact(assignCap)) {
997
+ if (isCompact(assign.capacity)) {
2064
998
  return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 assegna \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7 esc annulla"] })] }));
2065
999
  }
2066
- const at = assignRows.findIndex((t) => (t?.id ?? null) === assignSel);
2067
- const win = windowRange(assignRows.length, at, assignCap);
2068
- const s = assignSid ? sessions.find((x) => x.sessionId === assignSid) ?? null : null;
1000
+ const at = assign.list.findIndex((t) => (t?.id ?? null) === assign.sel);
1001
+ const win = windowRange(assign.list.length, at, assign.capacity);
1002
+ const s = assign.sid ? sessions.find((x) => x.sessionId === assign.sid) ?? null : null;
2069
1003
  // Etichetta della conversazione: la nota umana se c'è (è il nome con cui la
2070
1004
  // riconosci), altrimenti la stessa derivazione della ricerca. Su una pinnata
2071
1005
  // stale non resta nulla: il titolo si accontenta dell'hash.
2072
- const label = (assignSid ? sessionNotes.get(assignSid) : '') ||
2073
- (s ? conversationLabel(s, projectCore, assignSid ? bindings.get(assignSid) : undefined) : '');
2074
- return (_jsx(AssignScreen, { sessionId: assignSid ?? '', label: label, current: assignSid ? bindings.get(assignSid) ?? null : null, filter: assignFilter, rows: assignRows.slice(win.start, win.end), selected: assignSel, matched: assignRows.length - 1, hidden: hiddenTasks, above: win.start, below: assignRows.length - win.end, childCount: childCount, columns: columns, note: note }));
1006
+ const label = (assign.sid ? sessionNotes.get(assign.sid) : '') ||
1007
+ (s ? conversationLabel(s, projectCore, assign.sid ? bindings.get(assign.sid) : undefined) : '');
1008
+ return (_jsx(AssignScreen, { sessionId: assign.sid ?? '', label: label, current: assign.sid ? bindings.get(assign.sid) ?? null : null, filter: assign.filter, rows: assign.list.slice(win.start, win.end), selected: assign.sel, matched: assign.list.length - 1, hidden: hiddenTasks, above: win.start, below: assign.list.length - win.end, childCount: childCount, columns: columns, note: note }));
2075
1009
  }
2076
1010
  // ── T66 · detail della task ─────────────────────────────────────────────
2077
1011
  // Quarta schermata sostitutiva, stessa ragione delle altre tre: un task file
2078
1012
  // non entra in un box sopra i due pane. Il budget dei pane non viene nemmeno
2079
1013
  // calcolato — il render esce di qui prima.
2080
- if (mode === 'detail' && sheet) {
2081
- if (isCompact(sheetCap)) {
2082
- return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", sheet.id, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7 esc chiude"] })] }));
1014
+ if (mode === 'detail' && sheet.sheet) {
1015
+ if (isCompact(sheet.capacity)) {
1016
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", sheet.sheet.id, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7 esc chiude"] })] }));
2083
1017
  }
2084
1018
  // Niente `windowRange`: quella centra la finestra su una selezione, qui la
2085
1019
  // posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
2086
1020
  // può accorciare il testo sotto uno scroll già dato.
2087
- const start = Math.min(sheetTop, sheetMaxTop);
2088
- return (_jsx(DetailScreen, { id: sheet.id, title: sheet.title, missing: sheet.text === null, lines: sheetLines.slice(start, start + sheetCap), spans: sheetDoc?.spans ?? [], top: start, total: sheetLines.length, capacity: sheetCap, action: sheetAction, columns: columns, find: find, occ: findRes.occ, occCur: occCur }));
1021
+ const start = Math.min(sheet.top, sheet.maxTop);
1022
+ 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, columns: columns, find: sheet.find, occ: sheet.findRes.occ, occCur: sheet.occCur }));
2089
1023
  }
2090
1024
  // ── T52 · schermate sostitutive ─────────────────────────────────────────
2091
1025
  // Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
@@ -2094,42 +1028,42 @@ function Deck({ cwd, tasksPath, tasksDir }) {
2094
1028
  // serve nemmeno calcolarlo, e la loro altezza la distribuiscono
2095
1029
  // `searchListCapacity` / `readerCapacity`.
2096
1030
  if (mode === 'search' || mode === 'reader') {
2097
- const hit = mode === 'reader' && readerRow?.kind === 'hit' ? readerRow.hit : null;
1031
+ const hit = mode === 'reader' && search.readerRow?.kind === 'hit' ? search.readerRow.hit : null;
2098
1032
  // Terminale sotto la cornice: riga singola invece del box, per lo stesso
2099
1033
  // motivo del `budget.compact` del deck — un frame più alto di `rows` fa
2100
1034
  // pulire lo schermo a Ink a ogni redraw, e il poll lo versa nello scrollback.
2101
- if (isCompact(hit ? readerCap : searchCap)) {
1035
+ if (isCompact(hit ? search.readerCap : search.listCap)) {
2102
1036
  return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", hit ? 'reader' : 'ricerca', " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7", ' ', "esc ", hit ? 'torna' : 'chiude'] })] }));
2103
1037
  }
2104
1038
  if (hit) {
2105
1039
  // Niente `windowRange`: quella centra la finestra su una SELEZIONE, qui
2106
1040
  // la posizione è lo scroll che l'utente muove a mano. Il clamp serve
2107
1041
  // comunque — un resize può accorciare il testo sotto uno scroll già dato.
2108
- const start = Math.min(readerTop, readerMaxTop);
2109
- return (_jsx(ReaderScreen, { hit: hit, lines: readerLines.slice(start, start + readerCap), top: start, total: readerLines.length, capacity: readerCap, bound: bindings.get(hit.sessionId) ?? null }));
1042
+ const start = Math.min(search.readerTop, search.readerMaxTop);
1043
+ return (_jsx(ReaderScreen, { hit: hit, lines: search.readerLines.slice(start, start + search.readerCap), top: start, total: search.readerLines.length, capacity: search.readerCap, bound: bindings.get(hit.sessionId) ?? null }));
2110
1044
  }
2111
- const selIdx = rowIndexOfKey(searchRows, searchSelKey);
2112
- const win = windowRange(searchRows.length, selIdx, searchCap);
1045
+ const selIdx = rowIndexOfKey(search.rows, search.selKey);
1046
+ const win = windowRange(search.rows.length, selIdx, search.listCap);
2113
1047
  // Anteprima dell'occorrenza selezionata: prende le righe che la lista non
2114
1048
  // usa. Con molti risultati `spare` è 0 e il pannello non esiste — la lista
2115
1049
  // se le riprende tutte, che è la priorità giusta quando c'è molto da
2116
1050
  // scorrere. La finestra si CENTRA sul match (`windowRange`), così il
2117
1051
  // contesto arriva da entrambi i lati.
2118
- const spare = searchPreviewCapacity(searchCap, win.end - win.start);
1052
+ const spare = searchPreviewCapacity(search.listCap, win.end - win.start);
2119
1053
  let preview = null;
2120
- if (spare >= 1 && searchSelRow?.kind === 'hit') {
2121
- const h = searchSelRow.hit;
2122
- const mline = Math.max(0, searchPreviewBody.findIndex((l) => l.end > h.matchStart));
2123
- const pw = windowRange(searchPreviewBody.length, mline, spare);
1054
+ if (spare >= 1 && search.selRow?.kind === 'hit') {
1055
+ const h = search.selRow.hit;
1056
+ const mline = Math.max(0, search.previewBody.findIndex((l) => l.end > h.matchStart));
1057
+ const pw = windowRange(search.previewBody.length, mline, spare);
2124
1058
  preview = {
2125
1059
  hit: h,
2126
- lines: searchPreviewBody.slice(pw.start, pw.end),
1060
+ lines: search.previewBody.slice(pw.start, pw.end),
2127
1061
  from: pw.start,
2128
- total: searchPreviewBody.length,
1062
+ total: search.previewBody.length,
2129
1063
  ts: sessions.find((s) => s.sessionId === h.sessionId)?.ts ?? 0,
2130
1064
  };
2131
1065
  }
2132
- return (_jsx(SearchScreen, { preview: preview, hash: searchHash, query: searchQuery, field: searchField, opts: searchOpts, result: searchResult, rows: searchRows.slice(win.start, win.end), selectedKey: searchSelKey, selectedKind: selectedRow(searchRows, searchSelKey)?.kind ?? null, above: win.start, below: searchRows.length - win.end, capacity: searchCap, bindings: bindings, pinned: pinned, sessionNotes: sessionNotes, projectCore: projectCore, columns: columns, note: note }));
1066
+ return (_jsx(SearchScreen, { preview: preview, hash: search.hash, query: search.query, field: search.field, opts: search.opts, result: search.result, rows: search.rows.slice(win.start, win.end), selectedKey: search.selKey, selectedKind: selectedRow(search.rows, search.selKey)?.kind ?? null, above: win.start, below: search.rows.length - win.end, capacity: search.listCap, bindings: bindings, pinned: pinned, sessionNotes: sessionNotes, projectCore: projectCore, columns: columns, note: note }));
2133
1067
  }
2134
1068
  // ── Budget d'altezza ────────────────────────────────────────────────────
2135
1069
  // Il frame deve restare sotto `rows`, sempre: oltre quella soglia Ink smette
@@ -2178,608 +1112,5 @@ function Deck({ cwd, tasksPath, tasksDir }) {
2178
1112
  }
2179
1113
  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"] })) : (_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, _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, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: paneTasks.length - taskWin.end, columns: columns }), _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] }));
2180
1114
  }
2181
- const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
2182
- // Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
2183
- // spingono giù i pane invece di coprirli, così la lista che stai filtrando
2184
- // resta sempre visibile mentre la componi.
2185
- function SortModal({ sort }) {
2186
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "S \u203A sort chain" }), sort.length === 0 ? (_jsx(Text, { dimColor: true, children: "nessuna chiave \u00B7 resta l'ordine per id \u2191" })) : (_jsx(Text, { children: sort
2187
- .map((e, i) => `${i + 1}. ${SORT_UI[e.key]} ${e.dir === 'asc' ? '↑' : '↓'}`)
2188
- .join(' ') }))] }));
2189
- }
2190
- function FilterModal({ view, cursor }) {
2191
- const rows = [
2192
- { label: 'pri ', entries: PRI_ENTRIES, hidden: new Set(view.hiddenPri) },
2193
- { label: 'stato', entries: PROG_ENTRIES, hidden: new Set(view.hiddenProg) },
2194
- ];
2195
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "F \u203A filtri" }), rows.map((row, r) => (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: row.label }), row.entries.map((e, c) => {
2196
- const on = !row.hidden.has(e.name);
2197
- const here = cursor.row === r && cursor.col === c;
2198
- return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ", sanitize(e.glyph)] }, e.name));
2199
- })] }, row.label)))] }));
2200
- }
2201
- /**
2202
- * Campo di testo del modale edit: finestra ancorata al caret + cursore inverso
2203
- * nella posizione REALE.
2204
- *
2205
- * Il cursore non è più uno spazio inverso appiccicato in coda ma la cella `at`
2206
- * della finestra — cioè il carattere su cui il caret sta davvero. Fuori fuoco
2207
- * (`focused` falso) il caret non si disegna e la finestra si ancora in fondo,
2208
- * che è la vista utile per un campo che non si sta scrivendo.
2209
- */
2210
- function EditTextField({ label, value, caret, focused, cols, }) {
2211
- const win = caretWindow(value, focused ? caret : cpLen(value), cols);
2212
- 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) })] }));
2213
- }
2214
- // T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
2215
- // coprirli: la riga che stai modificando resta visibile sopra la lista).
2216
- // La riga di anteprima mostra il testo ESATTO che finirà nel campo `Progress`
2217
- // del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
2218
- function EditModal({ id, draft, row, columns, }) {
2219
- const mark = (r) => (row === r ? CARET : CARET_OFF);
2220
- // Budget dei campi di testo, DERIVATO da `columns` (mai una costante): il box
2221
- // del modale è ANNIDATO nella cornice del deck, quindi le cornici da scalare
2222
- // sono due — root (bordo 2 + paddingX 2) e modale (bordo 2 + paddingX 2) — più
2223
- // caret 2, etichetta 6, gap 2 e cursore 1. Totale 19.
2224
- // Un titolo di tasks.md arriva a ~64 caratteri: senza taglio la riga va a capo
2225
- // dentro il box, che si alza di una riga e sfonda il budget verticale (invariante ③).
2226
- const fieldBudget = Math.max(8, columns - 19);
2227
- 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))] })] }));
2228
- }
2229
- // T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
2230
- // caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
2231
- // allinearsi, e i glifi BMP larghi 2 sono proprio la classe che Ink e il
2232
- // terminale misurano diversamente (vedi width.ts).
2233
- const KIND_TAG = { ai: 'ai', tool: 'tl', human: 'hu' };
2234
- const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
2235
- /**
2236
- * Larghezza dell'estratto, DERIVATA dalle colonne del terminale.
2237
- *
2238
- * Una costante qui è spazio buttato a ogni riga: su un terminale a 190 colonne
2239
- * un estratto fisso a 50 lascia il match con ~20 caratteri di contesto per lato
2240
- * quando potrebbe averne 80 — e il contesto attorno al match è l'unica ragione
2241
- * per cui si legge la riga invece di aprire il reader.
2242
- *
2243
- * Scomposizione delle colonne consumate dalla cornice e dal prefisso di riga:
2244
- * 4 box esterno (2 bordi + 2 padding)
2245
- * 4 box lista (2 bordi + 2 padding)
2246
- * 2 caret
2247
- * 4 indice del record
2248
- * 4 spazio + tag del kind (2 caratteri) + spazio
2249
- * Prudente per costruzione: sottostimare tronca un carattere in più,
2250
- * sovrastimare manderebbe la riga a capo e sfonderebbe il budget d'altezza.
2251
- */
2252
- function searchExcerptWidth(columns) {
2253
- return Math.max(30, (columns || 80) - 18);
2254
- }
2255
- /** Titolo della conversazione sulla riga-gruppo: prende ciò che avanza dopo le
2256
- * colonne a larghezza fissa (caret, pin, hash, task, conteggio, data). */
2257
- function searchTitleWidth(columns) {
2258
- return Math.max(24, (columns || 80) - 56);
2259
- }
2260
- /**
2261
- * Cosa scrivere sulla riga-gruppo per distinguere una conversazione dall'altra.
2262
- *
2263
- * Non basta `session.title`: quel titolo è la label della tab Ptyxis, cioè
2264
- * `<emoji> <name>` più un eventuale suffisso. Su una lista tutta dello
2265
- * stesso progetto (D3) è una COLONNA COSTANTE — tre righe su quattro
2266
- * identiche, che è esattamente il difetto che la riga-gruppo doveva evitare.
2267
- *
2268
- * Si toglie quindi il core `<name>` con tutto ciò che lo precede (noto dal file
2269
- * config), e se ciò
2270
- * che resta è vuoto o duplica la task già mostrata nella sua colonna, si
2271
- * ripiega sul primo prompt — l'unica cosa che davvero identifica quella
2272
- * conversazione e non un'altra.
2273
- */
2274
- function conversationLabel(s, core, bound) {
2275
- let t = stripProjectCore(s.title, core);
2276
- if (bound && t === bound)
2277
- t = '';
2278
- return t || s.firstPrompt || '(senza titolo)';
2279
- }
2280
- /**
2281
- * Riga di toggle del modale ricerca.
2282
- *
2283
- * La mappa tasto→significato è SEMPRE a schermo: `^R` da solo è opaco quanto lo
2284
- * era il range `1-9` delle launch prima di T43.
2285
- *
2286
- * Lo stato acceso/spento passa da `[x]`/`[ ]`, non dal solo colore — stessa
2287
- * convenzione del modale filtri. Il colore è ridondanza, non l'informazione: su
2288
- * un terminale monocromo, o in una cattura di testo, sei toggle tutti uguali
2289
- * non direbbero più quali sono attivi.
2290
- */
2291
- function ToggleHint({ opts }) {
2292
- const flag = (on, key, label) => (_jsxs(Text, { color: on ? 'green' : 'gray', dimColor: !on, bold: on, children: [' ', key, "[", on ? 'x' : ' ', "] ", label] }, key));
2293
- return (_jsxs(Text, { wrap: "truncate-end", children: [flag(opts.regex, '^R', 'regex'), flag(opts.caseSensitive, '^A', 'Aa'), flag(opts.wholeWord, '^W', 'word'), _jsx(Text, { dimColor: true, children: ' │' }), flag(opts.kinds.ai, '^B', 'IA'), flag(opts.kinds.tool, '^T', 'tools'), flag(opts.kinds.human, '^U', 'human')] }));
2294
- }
2295
- /** Intestazione della lista: dice sempre quanto NON si sta vedendo (regex rotta,
2296
- * query troppo corta, occorrenze tagliate dal cap, righe fuori finestra). */
2297
- function SearchListHeader({ result, query, above, below, }) {
2298
- if (result.error) {
2299
- return (_jsxs(Text, { color: "red", wrap: "truncate-end", children: [WARN, " regex non valida \u00B7 ", result.error] }));
2300
- }
2301
- if (result.idle) {
2302
- return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: query.length === 0
2303
- ? 'digita la chiave da cercare'
2304
- : `almeno ${MIN_QUERY} caratteri (${query.length})` }));
2305
- }
2306
- if (result.shown === 0) {
2307
- return (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: "nessuna occorrenza" }));
2308
- }
2309
- return (_jsxs(Text, { bold: true, wrap: "truncate-end", children: [result.shown, " occorrenze in ", result.sessionCount, " conversazioni", result.hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", result.hidden, " oltre il cap"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }));
2310
- }
2311
- function SearchScreen({ preview, hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, sessionNotes, projectCore, columns, note, }) {
2312
- const enter = selectedKind === 'session' ? 'resume' : selectedKind === 'hit' ? 'leggi' : '—';
2313
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["ricerca \u00B7 ", _jsx(Text, { color: "yellow", children: "tab" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " naviga \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " ", enter, " \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "hash " }), _jsx(Text, { color: field === 'hash' ? 'yellow' : undefined, children: hash }), field === 'hash' ? _jsx(Text, { inverse: true, children: " " }) : null, !hash ? _jsx(Text, { dimColor: true, children: " (vuoto = tutte le conversazioni)" }) : null] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "chiave " }), _jsx(Text, { color: field === 'query' ? 'yellow' : undefined, children: query }), field === 'query' ? _jsx(Text, { inverse: true, children: " " }) : null] }), _jsx(ToggleHint, { opts: opts })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsx(SearchListHeader, { result: result, query: query, above: above, below: below }), rows.slice(0, Math.max(0, capacity)).map((row) => {
2314
- const sel = row.key === selectedKey;
2315
- if (row.kind === 'session') {
2316
- const s = row.session;
2317
- const bound = bindings.get(s.sessionId);
2318
- const rowNote = sessionNotes.get(s.sessionId);
2319
- const noteShown = rowNote ? cut(rowNote, 24) : '';
2320
- // `+3` = i due caporali e lo spazio che li separa dall'etichetta.
2321
- // Il pavimento non è cosmetico: senza, un terminale stretto manda
2322
- // l'argomento di `cut` sotto zero, cioè un budget negativo.
2323
- // La nota si misura con `termWidth`, non con `.length`: contiene
2324
- // testo umano, emoji compresi.
2325
- const restWidth = Math.max(8, searchTitleWidth(columns) - (noteShown ? termWidth(noteShown) + 3 : 0));
2326
- return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), noteShown ? _jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", noteShown, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(noteShown), children: cut(conversationLabel(s, projectCore, bound), restWidth) }), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
2327
- }
2328
- const h = row.hit;
2329
- return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: String(h.idx).padStart(4) }), ' ', _jsx(Text, { color: KIND_COLOR[h.kind], children: KIND_TAG[h.kind] }), ' ', cut(h.excerpt, searchExcerptWidth(columns))] }, row.key));
2330
- })] }), preview ? _jsx(SearchPreviewPane, { p: preview }) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
2331
- }
2332
- /**
2333
- * Anteprima dell'occorrenza selezionata, sotto la lista.
2334
- *
2335
- * Riempie le righe che la lista non usa: con pochi risultati il terminale
2336
- * resterebbe vuoto per tre quarti, e il contesto attorno al match è proprio
2337
- * ciò che serve per decidere se è l'occorrenza giusta. Nel caso comune evita
2338
- * del tutto di aprire il reader.
2339
- *
2340
- * Si aggiorna navigando con le frecce, e la finestra è centrata sul match:
2341
- * stessa `windowRange` della lista, stessa evidenziazione del reader
2342
- * (`ReaderLine`) — nessuna primitiva nuova.
2343
- */
2344
- function SearchPreviewPane({ p }) {
2345
- const last = Math.min(p.total, p.from + p.lines.length);
2346
- const occ = [{ start: p.hit.matchStart, end: p.hit.matchEnd }];
2347
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["record ", p.hit.idx, " \u00B7 ", KIND_LABEL[p.hit.kind], p.ts ? ` · ${fmtDateTime(p.ts)}` : '', " \u00B7 righe ", p.from + 1, "-", last, " di ", p.total, " \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " apre il reader"] }), p.lines.map((l, i) => (_jsx(ReaderLine, { line: l, occ: occ, current: 0 }, p.from + i)))] }));
2348
- }
2349
- /**
2350
- * Reader fullscreen (T52 · D8).
2351
- *
2352
- * Mostra il messaggio INTERO che contiene l'occorrenza, aperto già posizionato
2353
- * sul match e con il match evidenziato. È un `mode` a sé, catturato prima del
2354
- * ramo `search`: il modale ricerca resta montato sotto e su `esc` si ritrova
2355
- * con query, toggle e selezione intatti.
2356
- */
2357
- function ReaderScreen({ hit, lines, top, total, capacity, bound, }) {
2358
- const last = Math.min(total, top + capacity);
2359
- const occ = [{ start: hit.matchStart, end: hit.matchEnd }];
2360
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["reader \u00B7 ", _jsx(Text, { color: "cyan", children: hit.sessionId.slice(0, 8) }), " \u00B7 record ", hit.idx, " \u00B7", ' ', KIND_LABEL[hit.kind], bound ? ` · ${bound}` : '', " \u00B7 righe ", total === 0 ? 0 : top + 1, "-", last, " di ", total] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g" }), " inizio \u00B7 ", _jsx(Text, { color: "yellow", children: "G" }), " fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " torna alla lista"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: lines.map((l, i) => (_jsx(ReaderLine, { line: l, occ: occ, current: 0 }, top + i))) })] }));
2361
- }
2362
- /** Una riga con le porzioni di match evidenziate. Gli offset sono quelli del
2363
- * testo sorgente, quindi un match a cavallo dell'a-capo si colora su entrambe
2364
- * le righe senza casi speciali — entrambe intersecano il suo intervallo.
2365
- *
2366
- * Regge N occorrenze perché il detail (T91) ne mostra tutte quelle visibili; il
2367
- * reader (T52) ne passa una sola, che è il caso degenere dello stesso taglio. */
2368
- function ReaderLine({ line, occ, current, }) {
2369
- const segs = sliceLine(line.text, line.start, occ, current);
2370
- if (segs.length === 0)
2371
- return _jsx(Text, { wrap: "truncate-end", children: line.text || ' ' });
2372
- return (_jsx(Text, { wrap: "truncate-end", children: segs.map((s, i) => s.hit ? (
2373
- // La corrente si distingue dalle altre per COLORE di sfondo, non per
2374
- // presenza: tutte restano visibili, o navigare fra occorrenze non
2375
- // mostrerebbe più dove sono le altre.
2376
- _jsx(Text, { backgroundColor: s.current ? 'cyan' : 'yellow', color: "black", children: s.text }, i)) : (_jsx(Text, { children: s.text }, i))) }));
2377
- }
2378
- /** Resa di ogni costrutto markdown (T75 · D4): un solo livello di enfasi per
2379
- * costrutto, senza un secondo alfabeto da imparare. Heading uguali a ogni
2380
- * livello — la gerarchia la porta già il testo. `code` e `fence` condividono
2381
- * il giallo perché sono lo stesso costrutto a due granularità: dargli due
2382
- * colori direbbe che sono due cose. */
2383
- const MD_STYLE = {
2384
- heading: { bold: true, color: 'cyan' },
2385
- bold: { bold: true },
2386
- code: { color: 'yellow' },
2387
- fence: { color: 'yellow' },
2388
- };
2389
- /**
2390
- * Una riga del detail (T75): markdown reso, con sopra l'evidenziazione della
2391
- * ricerca.
2392
- *
2393
- * Due segmentazioni sulla stessa riga, annidate e non fuse: prima si taglia sui
2394
- * costrutti markdown, poi ogni pezzo si ritaglia sulle occorrenze. L'ordine non
2395
- * è indifferente — così un match a cavallo di un `**grassetto**` resta
2396
- * evidenziato per intero e insieme conserva il grassetto sulla metà che ce
2397
- * l'ha, cosa che una segmentazione unica dovrebbe risolvere decidendo chi vince.
2398
- *
2399
- * Gli offset di `occ` e di `spans` indicizzano ENTRAMBI il testo reso: è ciò
2400
- * che permette di comporli senza rimappature. Vedi `sheetDoc` per il perché la
2401
- * ricerca del detail ha smesso di scandire il sorgente.
2402
- */
2403
- function DetailLine({ line, spans, occ, current, }) {
2404
- const styled = sliceSpans(line, spans);
2405
- // Riga vuota → uno spazio: un `<Text>` senza contenuto Ink non lo disegna, e
2406
- // il testo si compatterebbe perdendo la struttura del file.
2407
- if (styled.length === 0)
2408
- return _jsx(Text, { wrap: "truncate-end", children: " " });
2409
- let off = line.start;
2410
- return (_jsx(Text, { wrap: "truncate-end", children: styled.map((seg, i) => {
2411
- const st = seg.kind ? MD_STYLE[seg.kind] : undefined;
2412
- const at = off;
2413
- off += seg.text.length;
2414
- // Senza ricerca aperta il secondo taglio non ha niente da tagliare, e
2415
- // saltarlo evita di allocare tre array per ogni riga a ogni freccia.
2416
- if (occ.length === 0) {
2417
- return (_jsx(Text, { bold: st?.bold, color: st?.color, children: seg.text }, i));
2418
- }
2419
- return (_jsx(Text, { bold: st?.bold, color: st?.color, children: sliceLine(seg.text, at, occ, current).map((p, j) => p.hit ? (_jsx(Text, { backgroundColor: p.current ? 'cyan' : 'yellow', color: "black", children: p.text }, j)) : (_jsx(Text, { children: p.text }, j))) }, i));
2420
- }) }));
2421
- }
2422
- /** Campo della ricerca nel detail: finestra ancorata al caret, cursore inverso
2423
- * sulla cella reale. Gemello di `EditTextField` senza la label, che qui sta
2424
- * fuori perché il campo vive in FLUSSO su una riga condivisa col contatore —
2425
- * non su una riga propria. */
2426
- function DetailFindField({ value, caret, cols }) {
2427
- const win = caretWindow(value, caret, cols);
2428
- return (_jsxs(_Fragment, { children: [_jsx(Text, { children: sanitize(win.head) }), _jsx(Text, { inverse: true, children: sanitize(win.at) }), _jsx(Text, { children: sanitize(win.tail) })] }));
2429
- }
2430
- /**
2431
- * Detail della task (T66): il task file scrollabile più la barra azioni.
2432
- *
2433
- * Unisce due gesti che erano due schermate — leggere la task e agire su di essa
2434
- * — perché convergono sullo stesso oggetto: si legge la Description proprio per
2435
- * decidere QUALE azione lanciare, e con due overlay separati quella decisione
2436
- * costava uscire dal viewer e ricordarsi la combo.
2437
- *
2438
- * Le azioni sono BOTTONI AFFIANCATI e non voci di un menu verticale: un
2439
- * rettangolo ha già coordinate e area cliccabile, quindi il layout sopravvive
2440
- * all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione. La
2441
- * navigazione da tastiera ci si sovrappone senza conflitti.
2442
- */
2443
- function DetailScreen({ id, title, missing, lines, spans, top, total, capacity, action, columns, find, occ, occCur, }) {
2444
- const last = Math.min(total, top + capacity);
2445
- // Il taglio lo fa il chiamante (invariante ③ di width.ts): la riga bottoni è
2446
- // ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso — ma
2447
- // la correttezza non deve dipendere dall'alfabeto che capita nella riga.
2448
- const width = Math.max(20, (columns || 80) - 4);
2449
- const segs = DETAIL_ACTIONS.map((a) => `[ ${a.label} ]`);
2450
- const parts = [];
2451
- segs.forEach((s, i) => {
2452
- if (i > 0)
2453
- parts.push(' ');
2454
- parts.push(s);
2455
- });
2456
- const dropped = (v) => segs.filter((s, i) => v[i * 2] !== s).length;
2457
- // Due passate: la seconda serve SOLO quando qualcosa cade, e riserva le
2458
- // colonne del contatore. Riservarle sempre costerebbe 6 colonne su ogni
2459
- // terminale largo per un avviso che lì non comparirà mai.
2460
- let shown = cutParts(parts, width);
2461
- if (dropped(shown) > 0)
2462
- shown = cutParts(parts, Math.max(0, width - 6));
2463
- const cutCount = dropped(shown);
2464
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: id }), " \u00B7 ", cut(title, Math.max(10, width - 34)), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), find?.open ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " occorrenza \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " caret \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " tieni \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^F" }), " cerca \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " esegui \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] })), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: missing ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: [WARN, " task file non trovato \u00B7 le azioni restano attive (deck-run risolve la task per id)"] })) : (lines.map((l, i) => (_jsx(DetailLine, { line: l, spans: spans, occ: occ, current: occCur }, top + i)))) }), find?.open ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "cerca " }), _jsx(DetailFindField, { value: find.q, caret: find.caret, cols: Math.max(10, width - 28) }), find.q.length === 0 ? (_jsx(Text, { dimColor: true, children: " \u00B7 digita per cercare" })) : occ.length === 0 ? (_jsx(Text, { color: "yellow", children: " \u00B7 nessuna occorrenza" })) : (_jsxs(Text, { color: "cyan", children: [' ', "\u00B7 ", occCur + 1, "/", occ.length] }))] }) })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === action, color: i / 2 === action ? 'green' : 'gray', children: part }, i))), cutCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cutCount] }) : null] }) })] }));
2465
- }
2466
- /**
2467
- * Larghezza del testo dentro la lista della schermata di assegnazione: box
2468
- * esterno (2 bordi + 2 padding) + box lista (2 bordi + 2 padding).
2469
- *
2470
- * Invariante ③ (`width.ts`): il taglio lo fa il chiamante. Delegarlo a
2471
- * `wrap="truncate-end"` passerebbe da `cli-truncate`, che restituisce una riga
2472
- * più larga di quella chiesta di una colonna per emoji — e quelle colonne
2473
- * finiscono sopra il bordo, che sparisce dalla riga.
2474
- */
2475
- function assignTextWidth(columns) {
2476
- return Math.max(20, (columns || 80) - 8);
2477
- }
2478
- /**
2479
- * Schermata di assegnazione di una conversazione a una task (T57).
2480
- *
2481
- * Fullscreen sostitutiva (D3) come la ricerca: la lista task non entra in un
2482
- * box sopra i due pane. Da lì discende che l'oggetto dell'azione — la sessione
2483
- * selezionata, che non è più a schermo — va ripetuto nel titolo.
2484
- *
2485
- * L'header della lista conta le task escluse dai filtri della vista (D4): la
2486
- * scelta di mostrare `viewTasks` e non tutte le task ha come prezzo noto che un
2487
- * filtro può nascondere proprio il bersaglio, e quel prezzo non deve essere
2488
- * silenzioso — stessa convenzione del `+N più vecchie` del pane sessioni.
2489
- */
2490
- function AssignScreen({ sessionId, label, current, filter, rows, selected, matched, hidden, above, below, childCount, columns, note, }) {
2491
- const width = assignTextWidth(columns);
2492
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["assegna ", _jsx(Text, { color: "cyan", children: sessionId.slice(0, 8) }), label ? ` «${cut(sanitize(label), Math.max(10, Math.floor(width / 4)))}»` : '', " \u00B7 ora", ' ', current ? _jsx(Text, { color: "green", children: current }) : 'spot', " \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), ' ', "assegna \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " pulisci \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }), _jsx(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "filtro " }), _jsx(Text, { color: "yellow", children: cut(filter, Math.max(8, width - 24)) }), _jsx(Text, { inverse: true, children: " " }), !filter ? _jsx(Text, { dimColor: true, children: " (id o titolo)" }) : null] }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [matched, " task", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", hidden, " fuori dai filtri"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), rows.map((task) => {
2493
- // T57/D2 — `detach` è una VOCE della lista, non un tasto a parte: un
2494
- // solo gesto (`A`), un solo modale. Speculare alla riga meta `spot`
2495
- // del pane task, e nominata con l'AZIONE («detach») invece che con lo
2496
- // stato d'arrivo — è ciò che si sta per fare.
2497
- if (!task) {
2498
- const sel = selected === null;
2499
- // Le colonne fisse (caret + `○ detach` + i due spazi) sono 12: solo
2500
- // la glossa si taglia, così su un terminale stretto resta comunque
2501
- // il nome dell'azione invece di un moncone di frase.
2502
- return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, "\u25CB detach ", cut('la sessione torna spot', width - 12)] }, "detach"));
2503
- }
2504
- const sel = selected === task.id;
2505
- const n = childCount.get(task.id) ?? 0;
2506
- const head = `${CARET_OFF}${task.id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
2507
- const tail = n > 0 ? ` (${n})` : '';
2508
- const desc = cut(task.desc, Math.max(4, width - termWidth(head) - termWidth(tail)));
2509
- return (_jsxs(Text, { inverse: sel, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
2510
- })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
2511
- }
2512
- /**
2513
- * Header del pane task, tagliato QUI e non da Ink — stesso motivo del gemello
2514
- * `SessionsHeader`, con una differenza di rischio: qui i segmenti sono tutti
2515
- * ASCII più le frecce `↑↓` (larghe 1), quindi `cli-truncate` oggi darebbe la
2516
- * riga giusta per caso. Il taglio resta del deck perché la correttezza non deve
2517
- * dipendere dall'alfabeto che capita nella riga: il primo glifo largo 2 che
2518
- * entrasse in un segmento nuovo riaprirebbe il difetto in silenzio, e a
2519
- * scoprirlo sarebbe il bordo del pane a schermo.
2520
- *
2521
- * `truncate-end` taglia dalla coda, e `cutParts` conserva l'ordine: l'ultimo
2522
- * segmento resta il primo a cedere il posto (`↑↓` in coda alle voci navigabili).
2523
- *
2524
- * T100 — la riga non è più informativa: le voci del catalogo sono SELEZIONABILI
2525
- * con `tab`, e l'attiva si distingue in video inverso (D5 — costa 0 colonne e non
2526
- * entra in gara con la semantica di colore già occupata). Le voci ci sono tutte
2527
- * anche a 0 (D1): un catalogo che si accorcia sposta le voci sotto le dita.
2528
- * L'ordine è vincolato — le navigabili PRIMA di `↑N`/`↓N`, che cadono per primi
2529
- * su un terminale stretto — e la voce attiva ha la precedenza sul budget (D6).
2530
- */
2531
- function TasksHeader({ counts, active, above, below, focused, columns, }) {
2532
- const views = TASK_VIEWS.map((v, i) => {
2533
- const n = v.count(counts);
2534
- return {
2535
- // Il separatore sta nel segmento, non fra i segmenti: `cutParts` misura la
2536
- // riga pezzo per pezzo e uno spazio fuori dai pezzi non verrebbe contato.
2537
- text: `${i > 0 ? ' · ' : ''}${v.label(counts)}`,
2538
- color: v.color,
2539
- dim: v.dim || n === 0,
2540
- active: v.id === active,
2541
- };
2542
- });
2543
- const segments = [
2544
- ...views,
2545
- { text: above > 0 ? ` · ↑${above}` : '', dim: true, active: false, color: undefined },
2546
- { text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false, color: undefined },
2547
- ];
2548
- const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
2549
- return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, inverse: seg.active, children: shown[i] }, i)) : null) }));
2550
- }
2551
- function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, childCount, focused, loadError, windowStart, above, below, columns, }) {
2552
- const allSelected = selected === ROW_ALL;
2553
- const spotSelected = selected === ROW_SPOT;
2554
- return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(TasksHeader, { counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut(sanitize(`sort: ${describeSort(view.sort)}` +
2555
- (view.hiddenPri.length + view.hiddenProg.length > 0
2556
- ? ' · filtri: ' +
2557
- [
2558
- ...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
2559
- ...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
2560
- ]
2561
- .map((e) => `−${e.glyph}`)
2562
- .join(' ')
2563
- : '')), paneTextWidth(columns)) }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni", allCount > 0 ? ` (${allCount})` : ''] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : paneCount === 0 ? (
2564
- // T100/D1 — una voce a contatore 0 resta navigabile, e selezionarla dà
2565
- // una lista vuota che DICE perché è vuota. Senza la nota il pane si
2566
- // legge come rotto: le righe meta restano, le task no, e niente spiega
2567
- // che è la vista scelta a non contenere nulla.
2568
- _jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(taskView(activeView).empty, paneTextWidth(columns)) })) : (tasks.map((task, i) => {
2569
- // windowStart riporta l'indice di finestra a quello della lista
2570
- // completa, su cui è keyata la selezione. +META_ROWS: le prime due
2571
- // righe sono le meta.
2572
- const sel = windowStart + i + META_ROWS === selected;
2573
- const n = childCount.get(task.id) ?? 0;
2574
- // Invariante ③: la descrizione è l'unico pezzo a lunghezza libera, e
2575
- // si taglia QUI sul budget che resta dopo le colonne fisse. Lasciarlo
2576
- // fare a `truncate-end` significa passare da `cli-truncate`, che
2577
- // restituisce una riga più larga del pane (una colonna per emoji) e
2578
- // quindi scrive sopra il bordo. Le parti fisse si misurano con
2579
- // `termWidth`: `task.id` è `T9` o `T52`, i due glifi valgono 2 ciascuno.
2580
- const head = `${CARET_OFF}${task.id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
2581
- const tail = n > 0 ? ` (${n})` : '';
2582
- const desc = cut(task.desc, Math.max(4, paneTextWidth(columns) - termWidth(head) - termWidth(tail)));
2583
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
2584
- }))] }));
2585
- }
2586
- /**
2587
- * Header del pane sessioni, tagliato QUI e non da Ink.
2588
- *
2589
- * `📌{pinnedCount}` è un glifo largo 2 in mezzo alla riga: con
2590
- * `wrap="truncate-end"` il taglio passava da `cli-truncate`, che indicizza per
2591
- * code point con un budget in colonne e restituiva una riga larga 45 su un
2592
- * budget di 44 — la colonna in più finiva sopra il bordo destro del pane, che
2593
- * spariva dalla riga (invariante ③ di `width.ts`).
2594
- *
2595
- * I segmenti restano segmenti fino al render: il taglio è della RIGA (budget
2596
- * condiviso, `cutParts`), la resa è del pezzo. Il giallo su `📌N` distingue le
2597
- * pinnate dal resto dell'header e non è decorazione.
2598
- */
2599
- function SessionsHeader({ parentLabel, counts, active, above, below, focused, columns, }) {
2600
- const views = SESSION_VIEWS.map((v) => {
2601
- const n = v.count(counts);
2602
- return {
2603
- text: ` · ${v.label(counts, parentLabel)}`,
2604
- color: v.color,
2605
- dim: v.dim || n === 0,
2606
- active: v.id === active,
2607
- };
2608
- });
2609
- const segments = [
2610
- // `Sessions` non è una voce del catalogo: nomina il pane, non un
2611
- // sottoinsieme, quindi non è raggiungibile con le frecce.
2612
- { text: 'Sessions', color: undefined, dim: false, active: false },
2613
- ...views,
2614
- { text: above > 0 ? ` · ↑${above}` : '', dim: true, active: false, color: undefined },
2615
- { text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false, color: undefined },
2616
- ];
2617
- const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
2618
- return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, inverse: seg.active, children: shown[i] }, i)) : null) }));
2619
- }
2620
- function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, counts, activeView, paneCount, selectedId, focused, above, below, columns, forkOf, sessionNotes, projectCore, live, }) {
2621
- return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(SessionsHeader, { parentLabel: parentLabel, counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), paneCount === 0 ? (
2622
- // T100 — la nota della vista di default resta quella storica, che nomina
2623
- // il PARENT (task, spot o tutte); le altre tre viste portano la propria,
2624
- // che nomina il sottoinsieme. Sono due vuoti diversi: «questo parent non
2625
- // ha conversazioni» e «questo sottoinsieme del parent è vuoto».
2626
- _jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(sessionView(activeView).empty ??
2627
- (isAll
2628
- ? 'nessuna conversazione nel progetto'
2629
- : isSpot
2630
- ? 'nessuna sessione libera'
2631
- : 'nessuna sessione legata a questa task'), paneTextWidth(columns)) })) : (rows.map((row, i) => {
2632
- // T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
2633
- // box pesante (coerente con lo styling delle Done dimmate).
2634
- if (row.kind === 'separator') {
2635
- return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: SESSION_SEP }, `sep${i}`));
2636
- }
2637
- const sel = row.sessionId === selectedId;
2638
- // T50 — pin stale: transcript sparito, nessuna Session da mostrare.
2639
- // Riga navigabile e spinnabile (`p`), marcata, mai un crash.
2640
- if (row.kind === 'pinned' && row.stale) {
2641
- // T60 — anche qui la nota si taglia sul budget DERIVATO, non su un
2642
- // 30 inchiodato: su un pane stretto quel valore fisso mandava la
2643
- // riga oltre il bordo, e a ripararla arrivava `cli-truncate` (che
2644
- // sfora di una colonna per emoji e mangia il bordo stesso).
2645
- const staleNote = sessionNotes.get(row.sessionId);
2646
- // La riga stale è libera (niente colonne: non ha né titolo né
2647
- // data), ma il binding va detto lo stesso — è una pinnata, quindi
2648
- // l'header del pane non ne dice l'appartenenza.
2649
- const staleTask = bindings.get(row.sessionId) ?? null;
2650
- const staleW = Math.max(0, paneTextWidth(columns) -
2651
- (2 /* caret */ +
2652
- termWidth(`${WARN} pin stale `) +
2653
- SID_CHARS +
2654
- (staleTask ? termWidth(staleTask) + 1 : 0) +
2655
- 3 /* spazio + caporali */));
2656
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, SID_CHARS) }), staleTask ? _jsxs(Text, { color: "green", children: [" ", staleTask] }) : null, staleNote ? _jsxs(Text, { color: "yellow", children: [" \u00AB", cut(staleNote, staleW), "\u00BB"] }) : null] }, row.sessionId));
2657
- }
2658
- const s = row.session; // non-stale → session presente
2659
- const isPinnedRow = row.kind === 'pinned';
2660
- // T28 — un ramo eredita il titolo dell'origine: senza marcatore le due
2661
- // righe sarebbero identiche a occhio.
2662
- const forked = forkOf.has(s.sessionId);
2663
- // T59 D2 — nella vista "tutte" il marker è PER-SESSIONE (binding letto
2664
- // dal sidecar) e non deciso dal parent: la lista mescola scoped e spot,
2665
- // quindi un marker uniforme mentirebbe su metà delle righe. E il solo
2666
- // glifo direbbe *che* la conversazione è legata senza dire *a cosa* —
2667
- // informazione monca proprio qui, l'unica vista dove l'appartenenza
2668
- // non è scritta da nessun'altra parte dello schermo: da qui la colonna
2669
- // task accanto, che esiste solo in questa vista.
2670
- const bound = bindings.get(s.sessionId) ?? null;
2671
- const linked = isAll ? Boolean(bound) : !isSpot;
2672
- // T62 — liveness e binding sono ORTOGONALI: la cella marker dice a chi
2673
- // appartiene la conversazione (pin/task/spot), questa dice se è aperta
2674
- // adesso. Farle condividere una cella perderebbe una delle due.
2675
- const liveEntry = live.get(s.sessionId);
2676
- // Stesso motivo per cui la colonna esiste: una pinnata resta in lista
2677
- // qualunque sia il parent selezionato, quindi l'header non ne dice
2678
- // l'appartenenza e la cella va riempita anche fuori dalla vista
2679
- // "tutte". Sulle contestuali, dove l'header parla già, resta vuota —
2680
- // ma la cella è comunque larga `taskW`, o le colonne a destra
2681
- // slitterebbero riga per riga.
2682
- const taskCell = isAll || isPinnedRow ? (bound ?? TASK_EMPTY) : '';
2683
- // T60 — colonne VERE: ogni cella fissa è larga esattamente quanto
2684
- // dichiara, riempita di spazi con `pad` (che misura in colonne, non in
2685
- // caratteri). Il marker va portato a 2 anche quando è `○`, largo 1:
2686
- // era lui a far slittare a sinistra di una colonna tutta la riga di
2687
- // ogni sessione spot.
2688
- const age = relTime(s.ts);
2689
- // Il taglio del titolo è ciò che RESTA, calcolato per sottrazione: le
2690
- // colonne fisse sono note, quindi l'unica cella elastica prende il
2691
- // resto. Pavimento `0` e non un minimo di cortesia — è un tetto, non
2692
- // una preferenza: alzarlo sopra lo spazio reale fa uscire la riga dal
2693
- // pane e le mangia il bordo (invariante ③).
2694
- const titleW = Math.max(0, paneTextWidth(columns) -
2695
- (2 /* caret */ +
2696
- 2 /* marker */ +
2697
- 1 /* gutter */ +
2698
- 1 /* T62 · colonna liveness */ +
2699
- SID_CHARS +
2700
- 1 /* gutter */ +
2701
- (taskW > 0 ? taskW + 1 : 0) +
2702
- 1 /* gutter prima della data */ +
2703
- ageW));
2704
- // T28 — `⑂` sta DENTRO la cella titolo, non in una colonna sua: una
2705
- // colonna dedicata costerebbe 2 spazi vuoti su ogni riga non-fork, e
2706
- // metterlo fuori cella sposterebbe il bordo del titolo solo sui rami —
2707
- // cioè rimetterebbe lo slittamento che le colonne tolgono.
2708
- const forkMark = forked ? '⑂ ' : '';
2709
- const inner = Math.max(0, titleW - termWidth(forkMark));
2710
- // T60 — il testo arriva già ripulito di ciò che le colonne accanto
2711
- // dicono già (progetto e task id): senza, la cella conterrebbe
2712
- // `🧵 loom-works · T59` accanto a una colonna che dice `T59`.
2713
- const label = rowLabel(sessionTitle(s, projectCore, bound), sessionNotes.get(s.sessionId), inner);
2714
- const used = (label.note ? termWidth(label.note) + 2 : 0) +
2715
- (label.note && label.rest ? 1 : 0) +
2716
- termWidth(label.rest);
2717
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : undefined, children: liveEntry ? (liveEntry.status === 'busy' ? LIVE_BUSY : LIVE_IDLE) : LIVE_NONE }), _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : 'cyan', bold: Boolean(liveEntry), children: s.sessionId.slice(0, SID_CHARS) }), ' ', taskW > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: bound && taskCell ? 'green' : undefined, dimColor: !bound, children: pad(taskCell, taskW) }), ' '] })) : null, forkMark ? _jsx(Text, { color: "magenta", children: forkMark }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' '.repeat(Math.max(0, inner - used)), ' ', _jsx(Text, { dimColor: true, children: pad(age, ageW, 'right') })] }, s.sessionId));
2718
- }))] }));
2719
- }
2720
- function PreviewPane(p) {
2721
- return (_jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: p.kind === 'task' ? (_jsx(TaskPreview, { detail: p.detail, maxLines: p.maxLines, columns: p.columns })) : (_jsx(SessionPreview, { s: p.s, firstLines: p.firstLines, lastLines: p.lastLines, columns: p.columns, origin: p.origin, note: p.note, live: p.live })) }));
2722
- }
2723
- // T49 — corpo della preview sessione. Tutti i campi vengono dal parse già
2724
- // cached dell'adapter (mtime-keyed): non costa I/O al movimento di selezione.
2725
- // Mostra "da dove parte, dove è arrivata": il primo prompt utente (`» `) e
2726
- // l'ultima risposta del modello (`« `). L'anteprima del primo prompt compare
2727
- // SOLO con un titolo custom — senza, il titolo È già il primo prompt e la riga
2728
- // lo duplicherebbe (D4 preflight). Le righe rese non superano mai il riservato
2729
- // dal budget (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
2730
- function SessionPreview({ s, firstLines, lastLines, columns, origin, note, live, }) {
2731
- const width = previewTextWidth(columns);
2732
- const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
2733
- const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
2734
- return (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), " \u00B7 ", s.gitBranch || '-', origin ? ` · ⑂ da ${origin.slice(0, 8)}` : '', live ? (_jsx(Text, { color: live.status === 'busy' ? 'yellow' : 'green', children: ` · ${live.status === 'busy' ? LIVE_BUSY : LIVE_IDLE} viva pid ${live.pid} (${live.status})` })) : null] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
2735
- }
2736
- /**
2737
- * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
2738
- * Estratto dal componente perché il budget deve saperlo PRIMA di renderizzare:
2739
- * sono righe fisse che tolgono spazio alla descrizione.
2740
- */
2741
- function detailMetaOf(detail) {
2742
- const meta = META_KEYS.map((k) => detail.fields[k])
2743
- .filter(Boolean)
2744
- .join(' · ');
2745
- const commit = detail.fields['Last tracked commit'] ?? '';
2746
- return { meta, commit, metaLines: 1 + (meta ? 1 : 0) + (commit ? 1 : 0) };
2747
- }
2748
- /**
2749
- * T70 — larghezza utile del testo dentro il blocco preview, che è a PIENA
2750
- * larghezza: box esterno (2 bordi + 2 padding) → box preview (2 bordi + 2
2751
- * padding). Niente più `/2`: il blocco non vive più dentro un pane al 50%,
2752
- * quindi una riga di descrizione dispone del doppio delle colonne.
2753
- *
2754
- * Volutamente prudente: sottostimare tronca qualche carattere in più,
2755
- * sovrastimare farebbe andare a capo una riga e sforare il tetto d'altezza.
2756
- */
2757
- function previewTextWidth(columns) {
2758
- return Math.max(10, (columns || 80) - 8);
2759
- }
2760
- /**
2761
- * Larghezza del TESTO dentro un pane al 50%: box esterno (2 bordi + 2 padding)
2762
- * → metà → bordo + padding del pane.
2763
- *
2764
- * Invariante ③ (`width.ts`): chi renderizza una riga a lunghezza libera la
2765
- * taglia PRIMA con questa larghezza. Delegarlo a `wrap="truncate-end"`
2766
- * significa passare da `cli-truncate`, che indicizza per code point e restituisce
2767
- * una riga più larga di quella chiesta — una colonna per ogni emoji astrale a
2768
- * sinistra del taglio. Quelle colonne finiscono sopra il bordo del pane, che
2769
- * sparisce dalla riga: è la sminchiatura visibile a schermo.
2770
- */
2771
- function paneTextWidth(columns) {
2772
- return Math.max(20, Math.floor(((columns || 80) - 4) / 2) - 4);
2773
- }
2774
- /** Corpo della preview task: titolo, meta, descrizione wrappata, commit. */
2775
- function TaskPreview({ detail, maxLines, columns, }) {
2776
- const { meta, commit } = detailMetaOf(detail);
2777
- // Wrap calcolato qui, non delegato a `<Text wrap="wrap">`: il budget ha
2778
- // riservato ESATTAMENTE `maxLines` righe, e un wrap deciso da Ink a runtime
2779
- // ne produrrebbe un numero che il budget non conosce — cioè il frame torna a
2780
- // sforare e il bug si riapre da questa singola casella di testo.
2781
- const lines = wrapLines(detail.description ?? '', previewTextWidth(columns), maxLines);
2782
- return (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: detail.title || detail.id }), meta ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: meta }) : null, lines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", children: line }, i))), commit ? _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", commit] }) : null] }));
2783
- }
2784
1115
  const cwd = process.cwd();
2785
1116
  render(_jsx(Deck, { cwd: cwd, tasksPath: resolveTasksPath(cwd), tasksDir: resolveTasksDir(cwd) }));