@lamemind/loom-deck 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +206 -75
- package/dist/config.js +4 -4
- package/dist/session-list.js +65 -0
- package/dist/sessions.js +8 -3
- package/dist/task-index.js +16 -2
- package/dist/tasks.js +15 -14
- package/dist/viewport.js +1 -139
- package/dist/width.js +333 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -10,10 +10,11 @@ import { dirname, join } from 'node:path';
|
|
|
10
10
|
import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from './tasks.js';
|
|
11
11
|
import { discoverProjectSessions } from './sessions.js';
|
|
12
12
|
import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions, selectedRow, DEFAULT_OPTIONS, MIN_QUERY, } from './search.js';
|
|
13
|
-
import { appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
14
|
-
import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, selectedSession, } from './session-list.js';
|
|
13
|
+
import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
14
|
+
import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, rowLabel, selectedSession, stripProjectCore, } from './session-list.js';
|
|
15
15
|
import { launchLegend, loadIdentity, loadLaunch } from './config.js';
|
|
16
|
-
import { isCompact, layoutBudget,
|
|
16
|
+
import { isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
|
|
17
|
+
import { cut, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
|
|
17
18
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
18
19
|
import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
|
|
19
20
|
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
@@ -284,6 +285,7 @@ function useSessions(projectRoot) {
|
|
|
284
285
|
bindings: new Map(),
|
|
285
286
|
forkOf: new Map(),
|
|
286
287
|
pinned: new Map(),
|
|
288
|
+
notes: new Map(),
|
|
287
289
|
});
|
|
288
290
|
// T50 — pin/unpin scrive il sidecar e vuole feedback IMMEDIATO, non al
|
|
289
291
|
// prossimo tick del poll (1.5s): la reload è esposta via ref così il toggle la
|
|
@@ -300,23 +302,25 @@ function useSessions(projectRoot) {
|
|
|
300
302
|
}
|
|
301
303
|
catch {
|
|
302
304
|
sessions = [];
|
|
303
|
-
index = { bindings: new Map(), forkOf: new Map(), pinned: new Map() };
|
|
305
|
+
index = { bindings: new Map(), forkOf: new Map(), pinned: new Map(), notes: new Map() };
|
|
304
306
|
}
|
|
305
|
-
const { bindings, forkOf, pinned } = index;
|
|
306
|
-
// La signature copre anche fork e
|
|
307
|
-
// pin appena
|
|
308
|
-
// re-render come farebbe un binding nuovo.
|
|
307
|
+
const { bindings, forkOf, pinned, notes } = index;
|
|
308
|
+
// La signature copre anche fork, pin e note: un record di lineage, un
|
|
309
|
+
// toggle di pin o una nota appena scritta cambiano la lista renderizzata,
|
|
310
|
+
// quindi devono forzare il re-render come farebbe un binding nuovo.
|
|
309
311
|
const sig = sessions.map((s) => `${s.sessionId}:${s.ts}`).join('|') +
|
|
310
312
|
'#' +
|
|
311
313
|
[...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',') +
|
|
312
314
|
'#' +
|
|
313
315
|
[...forkOf.entries()].map(([k, v]) => `${k}<${v}`).sort().join(',') +
|
|
314
316
|
'#' +
|
|
315
|
-
[...pinned.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',')
|
|
317
|
+
[...pinned.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',') +
|
|
318
|
+
'#' +
|
|
319
|
+
[...notes.entries()].map(([k, v]) => `${k}"${v}`).sort().join(',');
|
|
316
320
|
if (sig === lastSig)
|
|
317
321
|
return;
|
|
318
322
|
lastSig = sig;
|
|
319
|
-
setState({ sessions, bindings, forkOf, pinned });
|
|
323
|
+
setState({ sessions, bindings, forkOf, pinned, notes });
|
|
320
324
|
};
|
|
321
325
|
reloadRef.current = reload;
|
|
322
326
|
reload();
|
|
@@ -336,47 +340,23 @@ function useTaskDetail(tasksDir, id) {
|
|
|
336
340
|
}, [tasksDir, id]);
|
|
337
341
|
return detail;
|
|
338
342
|
}
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
345
|
-
// Normalizzazione larghezza glifi → `normalizeEmoji` in viewport.ts.
|
|
346
|
-
//
|
|
347
|
-
// Sostituisce il precedente `forceEmojiWidth`, che aveva l'intuizione giusta
|
|
348
|
-
// (timbrare il VS16) ma due limiti che lasciavano passare il difetto:
|
|
349
|
-
// - agiva solo su stringhe di UN codepoint, quindi non toccava mai un testo
|
|
350
|
-
// composto — la riga della legenda launch, una descrizione task con emoji
|
|
351
|
-
// dentro, il titolo di una sessione;
|
|
352
|
-
// - decideva per intervallo di codepoint invece che per larghezza misurata,
|
|
353
|
-
// quindi avrebbe timbrato anche `↓` `↑` `−` (larghi 1) se gli fossero
|
|
354
|
-
// arrivati da soli, accorciando la riga invece di allargarla.
|
|
355
|
-
const forceEmojiWidth = normalizeEmoji;
|
|
356
|
-
// Glifi LETTERALI del JSX che ricadono nella classe mal misurata (BMP largo 2
|
|
357
|
-
// senza VS16). I dati passano dai loader, questi no: normalizzati una volta
|
|
358
|
-
// qui, così nessun sito di render li scrive nudi. `↳ ○ ▸ ⏎ · − ↑ ↓` sono
|
|
359
|
-
// larghi 1 e restano intatti.
|
|
360
|
-
const CARET = normalizeEmoji('▶ ');
|
|
343
|
+
// Glifi LETTERALI del JSX. I dati passano dai loader, che sanificano al
|
|
344
|
+
// confine; questi no — quindi passano da `sanitize` una volta qui, così nessun
|
|
345
|
+
// sito di render scrive un glifo nudo. `↳ ○ ▸ ⏎ · − ↑ ↓` sono già concordi e
|
|
346
|
+
// restano intatti; `▶` e `⚠` sono discordi e vengono sostituiti (`width.ts`).
|
|
347
|
+
const CARET = sanitize('▶ ');
|
|
361
348
|
const CARET_OFF = ' ';
|
|
362
|
-
|
|
363
|
-
// (come CARET), altrimenti la riga slitta di una colonna.
|
|
364
|
-
const WARN = normalizeEmoji('⚠');
|
|
349
|
+
const WARN = sanitize('⚠');
|
|
365
350
|
// T50 — separatore leggero fra blocco pinnate e contestuali. `─` (box-drawing) è
|
|
366
|
-
// largo 1 sia per string-width sia per il terminale
|
|
351
|
+
// largo 1 sia per string-width sia per il terminale. Corto +
|
|
367
352
|
// wrap="truncate-end" così non va mai a capo nel pane al 50%.
|
|
368
353
|
const SESSION_SEP = '─'.repeat(16);
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
// riservato → la coda della riga (bordo destro del pane incluso) slittava a
|
|
374
|
-
// sinistra, sfasando il layout solo su quelle righe. `✅` (U+2705) è invece
|
|
375
|
-
// emoji-presentation-default → largo 2 sia per string-width sia per il terminale,
|
|
376
|
-
// come 🔵/🟡: colonna Prog allineata. Cambia SOLO il display: `task.prog` resta
|
|
377
|
-
// `✔️` così `isDone()` continua a matchare.
|
|
354
|
+
// Marker Done per il DISPLAY. `task.prog` resta il `✔️` letto da tasks.md —
|
|
355
|
+
// `isDone()` e le lookup di `view.ts` ci confrontano sopra, e `task-edit` lo
|
|
356
|
+
// riscrive sul file: è una chiave semantica, non testo. Qui `sanitize` lo
|
|
357
|
+
// traduce nel suo gemello concorde (`✅`) solo per finire nel frame.
|
|
378
358
|
function displayProg(prog) {
|
|
379
|
-
return
|
|
359
|
+
return sanitize(prog);
|
|
380
360
|
}
|
|
381
361
|
// T49 — size umana compatta per il detail pane sessione.
|
|
382
362
|
function fmtSize(bytes) {
|
|
@@ -406,11 +386,28 @@ function relTime(ts) {
|
|
|
406
386
|
return `${hr}h`;
|
|
407
387
|
return `${Math.floor(hr / 24)}d`;
|
|
408
388
|
}
|
|
389
|
+
/**
|
|
390
|
+
* Ripulisce un chunk di stdin prima di scriverlo in un campo di testo.
|
|
391
|
+
*
|
|
392
|
+
* `useInput` consegna il CHUNK letto da stdin, non un tasto: un incollaggio — o
|
|
393
|
+
* una raffica piu' veloce di una read — arriva come stringa unica, byte di
|
|
394
|
+
* controllo compresi. Non si vedono a schermo, ma Ink li conta nella larghezza
|
|
395
|
+
* della riga, e in un campo che finisce su disco (la nota, T53) resterebbero
|
|
396
|
+
* li' per sempre. Le newline diventano SPAZIO invece di sparire: incollare due
|
|
397
|
+
* righe deve separare le parole, non fonderle.
|
|
398
|
+
*/
|
|
399
|
+
function sanitizeTyped(s) {
|
|
400
|
+
return s.replace(/[\r\n]/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '');
|
|
401
|
+
}
|
|
409
402
|
const META_KEYS = ['Priority', 'Size', 'Estimated Time', 'Progress'];
|
|
410
403
|
function Deck({ cwd, tasksPath, tasksDir }) {
|
|
411
404
|
const { exit } = useApp();
|
|
412
405
|
const { tasks, loadError } = useTasks(tasksPath);
|
|
413
|
-
|
|
406
|
+
// `notes` esce dall'indice come `sessionNotes`: in questo componente `note` è
|
|
407
|
+
// già la riga di STATO in fondo al frame (il feedback di un'azione). Due
|
|
408
|
+
// concetti diversi a una lettera di distanza sarebbero una trappola di
|
|
409
|
+
// lettura — e di scrittura, visto che `setNote` compare in quasi ogni ramo.
|
|
410
|
+
const { sessions, bindings, forkOf, pinned, notes: sessionNotes, reload: reloadSessions, } = useSessions(cwd);
|
|
414
411
|
const [focus, setFocus] = useState('tasks');
|
|
415
412
|
// T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
|
|
416
413
|
// (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
|
|
@@ -426,6 +423,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
426
423
|
// T30 create · T39 sort/filter.
|
|
427
424
|
const [mode, setMode] = useState('normal');
|
|
428
425
|
const [draft, setDraft] = useState('');
|
|
426
|
+
// T53 — bozza della nota sulla conversazione selezionata. Si apre PRECARICATA
|
|
427
|
+
// con la nota esistente: annotare due volte è quasi sempre correggere, non
|
|
428
|
+
// riscrivere da zero, e un campo vuoto costringerebbe a ridigitare tutto per
|
|
429
|
+
// cambiare una parola. Confermare il campo vuoto cancella la nota.
|
|
430
|
+
const [noteDraft, setNoteDraft] = useState('');
|
|
429
431
|
// T39 — vista corrente (filtri + sort) e sua fotografia all'apertura di un
|
|
430
432
|
// modale: la lista si aggiorna dal vivo, quindi `esc` deve poter ripristinare.
|
|
431
433
|
const [view, setView] = useState(() => loadView(cwd));
|
|
@@ -456,6 +458,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
456
458
|
const launch = useMemo(() => loadLaunch(cwd), [cwd]);
|
|
457
459
|
// Identità (T37): titolo delle tab terminale spawnate col tasto `t`.
|
|
458
460
|
const identity = useMemo(() => loadIdentity(cwd), [cwd]);
|
|
461
|
+
// T53 — `<owner> <name>`: il core che ogni titolo di tab porta, quindi la
|
|
462
|
+
// colonna costante da togliere quando serve spazio. Hoistato qui perché ora
|
|
463
|
+
// lo consumano DUE schermate (lista e ricerca): calcolarlo su ogni call site
|
|
464
|
+
// è il modo in cui le due smettono di togliere la stessa cosa.
|
|
465
|
+
const projectCore = identity ? `${identity.owner} ${identity.name}` : null;
|
|
459
466
|
// La vista è una trasformazione DERIVATA, applicata a valle del load: il
|
|
460
467
|
// polling di tasks.md continua a funzionare senza saperne nulla.
|
|
461
468
|
const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
|
|
@@ -557,7 +564,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
557
564
|
}
|
|
558
565
|
const sid = randomUUID();
|
|
559
566
|
const beforeIds = new Set(tasks.map((t) => t.id));
|
|
560
|
-
setNote(`⏳ creando task… "${
|
|
567
|
+
setNote(`⏳ creando task… "${cut(text, 40)}" (sid ${sid.slice(0, 8)})`);
|
|
561
568
|
const child = spawnCreateTask(text, cwd, sid, (ok) => {
|
|
562
569
|
if (!ok) {
|
|
563
570
|
setNote(`⚠ create-task fallito (${CLAUDE_CMD} -p)`);
|
|
@@ -580,6 +587,38 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
580
587
|
});
|
|
581
588
|
child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
|
|
582
589
|
}
|
|
590
|
+
// T53 — apertura del modale nota sulla conversazione selezionata. Come
|
|
591
|
+
// `openEdit`, la bozza parte dal valore ATTUALE: annotare una seconda volta è
|
|
592
|
+
// quasi sempre correggere, e ripartire da vuoto costringerebbe a ridigitare
|
|
593
|
+
// tutto per cambiare una parola.
|
|
594
|
+
function openNote() {
|
|
595
|
+
if (!selSessionId)
|
|
596
|
+
return;
|
|
597
|
+
setNoteDraft(sessionNotes.get(selSessionId) ?? '');
|
|
598
|
+
setNote('');
|
|
599
|
+
setMode('note');
|
|
600
|
+
}
|
|
601
|
+
// T53 — ⏎ nel modale nota: scrive il sidecar e ricarica subito, senza
|
|
602
|
+
// attendere il tick del poll (stesso feedback immediato del pin).
|
|
603
|
+
//
|
|
604
|
+
// Il campo VUOTO non è un annullamento: è la CANCELLAZIONE della nota. Sono
|
|
605
|
+
// due intenzioni diverse e hanno due tasti diversi — `esc` lascia tutto com'è,
|
|
606
|
+
// `⏎` su campo svuotato toglie la nota. Trattare il vuoto come un no-op (come
|
|
607
|
+
// fa `submitCreate`, dove però una task senza titolo non esiste) renderebbe
|
|
608
|
+
// impossibile disannotare una conversazione se non con un editor sul JSONL.
|
|
609
|
+
function submitNote() {
|
|
610
|
+
const sid = selSessionId;
|
|
611
|
+
const text = noteDraft.trim();
|
|
612
|
+
setMode('normal');
|
|
613
|
+
setNoteDraft('');
|
|
614
|
+
if (!sid)
|
|
615
|
+
return;
|
|
616
|
+
appendNote(cwd, sid, text);
|
|
617
|
+
reloadSessions();
|
|
618
|
+
setNote(text
|
|
619
|
+
? `✎ nota su ${sid.slice(0, 8)}: "${cut(text, 40)}"`
|
|
620
|
+
: `✎ nota rimossa da ${sid.slice(0, 8)}`);
|
|
621
|
+
}
|
|
583
622
|
// T41 — apertura dell'edit: la bozza parte dai valori ATTUALI della task, non
|
|
584
623
|
// da default. La priorità arriva dal glifo di tasks.md (già in `selTask`), lo
|
|
585
624
|
// stato dal suo glifo Prog; il progresso arbitrario dal campo `Progress` del
|
|
@@ -713,7 +752,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
713
752
|
// (T39), che cicla sui caratteri del chunk invece di leggerlo intero.
|
|
714
753
|
function typeIntoField(chunk) {
|
|
715
754
|
setNote('');
|
|
716
|
-
const clean =
|
|
755
|
+
const clean = sanitizeTyped;
|
|
717
756
|
const parts = chunk.split('\t');
|
|
718
757
|
let field = searchField;
|
|
719
758
|
const add = { hash: '', query: '' };
|
|
@@ -830,6 +869,43 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
830
869
|
}
|
|
831
870
|
return;
|
|
832
871
|
}
|
|
872
|
+
// T53 — modale nota. Come create, cattura il testo e corto-circuita la
|
|
873
|
+
// navigazione (qui `q` è una lettera da scrivere, non il quit).
|
|
874
|
+
if (mode === 'note') {
|
|
875
|
+
if (key.escape) {
|
|
876
|
+
setMode('normal');
|
|
877
|
+
setNoteDraft('');
|
|
878
|
+
setNote('N → nota annullata');
|
|
879
|
+
}
|
|
880
|
+
else if (key.return) {
|
|
881
|
+
submitNote();
|
|
882
|
+
}
|
|
883
|
+
else if (key.ctrl && input === 'u') {
|
|
884
|
+
// Svuota il campo in un colpo. NON è una scorciatoia di comodo: il
|
|
885
|
+
// backspace tenuto premuto cancella UN carattere per CHUNK letto da
|
|
886
|
+
// stdin, non per pressione (`useInput` consegna il chunk, e per una
|
|
887
|
+
// raffica di DEL Ink alza `key.backspace` una volta sola) — misurato,
|
|
888
|
+
// 30 pressioni → 2 caratteri. Siccome «campo vuoto» qui è l'unico modo
|
|
889
|
+
// di CANCELLARE una nota, dipendere dal backspace renderebbe
|
|
890
|
+
// l'operazione praticamente non eseguibile. `^U` è il kill-line delle
|
|
891
|
+
// shell, quindi il gesto è già nelle dita di chi usa un terminale.
|
|
892
|
+
setNoteDraft('');
|
|
893
|
+
}
|
|
894
|
+
else if (key.backspace || key.delete) {
|
|
895
|
+
setNoteDraft((d) => d.slice(0, -1));
|
|
896
|
+
}
|
|
897
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
898
|
+
// Sanificazione dei byte di controllo, come `typeIntoField` della
|
|
899
|
+
// ricerca: `useInput` consegna il CHUNK letto da stdin, quindi un
|
|
900
|
+
// incollaggio porta dentro newline e control char. Invisibili a schermo
|
|
901
|
+
// ma contati da Ink nella larghezza della riga — e qui finirebbero
|
|
902
|
+
// scritti su disco, dove resterebbero a sporcare la riga per sempre.
|
|
903
|
+
// Le newline diventano spazio: incollare due righe deve separare le
|
|
904
|
+
// parole, non fonderle.
|
|
905
|
+
setNoteDraft((d) => d + sanitizeTyped(input));
|
|
906
|
+
}
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
833
909
|
// T39 — modale sort a grammatica libera: la SEQUENZA di tasti È la chain.
|
|
834
910
|
// Digitare `ppi` = p(asc) p(desc) i(asc) → [pri desc, id asc]. La lista si
|
|
835
911
|
// riordina dal vivo a ogni pressione.
|
|
@@ -1061,6 +1137,23 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1061
1137
|
setNote(`${isPinned ? 'unpin' : '📌 pin'} ${selSessionId.slice(0, 8)}`);
|
|
1062
1138
|
}
|
|
1063
1139
|
}
|
|
1140
|
+
else if (input === 'N') {
|
|
1141
|
+
// T53 — nota sulla conversazione selezionata. MAIUSCOLA perché apre un
|
|
1142
|
+
// modale: nel deck le minuscole sono azioni immediate (`f` fork, `p` pin,
|
|
1143
|
+
// `t` term, `c` claude) e le maiuscole aprono un box (`C` create, `E`
|
|
1144
|
+
// edit, `S` sort, `F` filtri). Vincolo di focus identico a `p`: vale anche
|
|
1145
|
+
// su una pinnata STALE, perché annotare «questa non c'è più, era X» è
|
|
1146
|
+
// proprio il caso in cui una nota serve.
|
|
1147
|
+
if (focus !== 'sessions') {
|
|
1148
|
+
setNote('N → nota: seleziona una sessione (←→ per il pane)');
|
|
1149
|
+
}
|
|
1150
|
+
else if (!selSessionId) {
|
|
1151
|
+
setNote('N → nessuna sessione da annotare');
|
|
1152
|
+
}
|
|
1153
|
+
else {
|
|
1154
|
+
openNote();
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1064
1157
|
else if (input === 't') {
|
|
1065
1158
|
const title = identity ? `🖥️ ${identity.owner} ${identity.name} [term]` : null;
|
|
1066
1159
|
const child = spawnTerminal(cwd, title);
|
|
@@ -1152,7 +1245,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1152
1245
|
ts: sessions.find((s) => s.sessionId === h.sessionId)?.ts ?? 0,
|
|
1153
1246
|
};
|
|
1154
1247
|
}
|
|
1155
|
-
return (_jsx(SearchScreen, { preview: preview, hash: searchHash, query: searchQuery, field: searchField, opts: searchOpts, result: searchResult, rows: searchRows.slice(win.start, win.end), selectedKey: searchSelKey, selectedKind: selectedRow(searchRows, searchSelKey)?.kind ?? null, above: win.start, below: searchRows.length - win.end, capacity: searchCap, bindings: bindings, pinned: pinned,
|
|
1248
|
+
return (_jsx(SearchScreen, { preview: preview, hash: searchHash, query: searchQuery, field: searchField, opts: searchOpts, result: searchResult, rows: searchRows.slice(win.start, win.end), selectedKey: searchSelKey, selectedKind: selectedRow(searchRows, searchSelKey)?.kind ?? null, above: win.start, below: searchRows.length - win.end, capacity: searchCap, bindings: bindings, pinned: pinned, sessionNotes: sessionNotes, projectCore: projectCore, columns: columns, note: note }));
|
|
1156
1249
|
}
|
|
1157
1250
|
// ── Budget d'altezza ────────────────────────────────────────────────────
|
|
1158
1251
|
// Il frame deve restare sotto `rows`, sempre: oltre quella soglia Ink smette
|
|
@@ -1192,7 +1285,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1192
1285
|
if (budget.compact) {
|
|
1193
1286
|
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? 'spot', " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
|
|
1194
1287
|
}
|
|
1195
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : canResume ? 'resume' : '—', canResume ? ' · f fork' : '', canPin ? ' · p pin' : '', " \u00B7 ^F cerca \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 t term \u00B7 c claude \u00B7 q esci \u00B7 focus:", ' ', _jsx(Text, { color: "cyan", children: focus })] })), mode === 'normal' && launch.length > 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["launch ", legend.shown, legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children:
|
|
1288
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : canResume ? 'resume' : '—', canResume ? ' · f fork' : '', canPin ? ' · p pin · N nota' : '', " \u00B7 ^F cerca \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 t term \u00B7 c claude \u00B7 q esci \u00B7 focus:", ' ', _jsx(Text, { color: "cyan", children: focus })] })), mode === 'normal' && launch.length > 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["launch ", legend.shown, legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
1196
1289
|
}
|
|
1197
1290
|
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
1198
1291
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
@@ -1211,7 +1304,7 @@ function FilterModal({ view, cursor }) {
|
|
|
1211
1304
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "F \u203A filtri" }), rows.map((row, r) => (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: row.label }), row.entries.map((e, c) => {
|
|
1212
1305
|
const on = !row.hidden.has(e.name);
|
|
1213
1306
|
const here = cursor.row === r && cursor.col === c;
|
|
1214
|
-
return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ",
|
|
1307
|
+
return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ", sanitize(e.glyph)] }, e.name));
|
|
1215
1308
|
})] }, row.label)))] }));
|
|
1216
1309
|
}
|
|
1217
1310
|
// T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
|
|
@@ -1220,12 +1313,12 @@ function FilterModal({ view, cursor }) {
|
|
|
1220
1313
|
// del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
|
|
1221
1314
|
function EditModal({ id, draft, row }) {
|
|
1222
1315
|
const mark = (r) => (row === r ? CARET : CARET_OFF);
|
|
1223
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 priorit\u00E0 e stato"] }), _jsxs(Text, { children: [mark(0), _jsx(Text, { dimColor: true, children: "pri " }), EDIT_PRI.map((p) => (_jsxs(Text, { inverse: draft.pri === p, color: draft.pri === p ? 'green' : 'gray', children: [' ',
|
|
1316
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 priorit\u00E0 e stato"] }), _jsxs(Text, { children: [mark(0), _jsx(Text, { dimColor: true, children: "pri " }), EDIT_PRI.map((p) => (_jsxs(Text, { inverse: draft.pri === p, color: draft.pri === p ? 'green' : 'gray', children: [' ', sanitize(PRI_GLYPH[p]), " ", PRI_LABEL[p]] }, p)))] }), _jsxs(Text, { children: [mark(1), _jsx(Text, { dimColor: true, children: "stato" }), EDIT_PROG.map((p) => (_jsxs(Text, { inverse: draft.prog === p, color: draft.prog === p ? 'green' : 'gray', children: [' ', sanitize(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(Text, { dimColor: true, children: "prog " }), _jsxs(Text, { children: [' ', draft.detail] }), row === 2 ? _jsx(Text, { inverse: true, children: " " }) : null, !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", sanitize(progressText(draft.prog, draft.detail))] })] }));
|
|
1224
1317
|
}
|
|
1225
1318
|
// T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
|
|
1226
1319
|
// caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
|
|
1227
1320
|
// allinearsi, e i glifi BMP larghi 2 sono proprio la classe che Ink e il
|
|
1228
|
-
// terminale misurano diversamente (vedi
|
|
1321
|
+
// terminale misurano diversamente (vedi width.ts).
|
|
1229
1322
|
const KIND_TAG = { ai: 'ai', tool: 'tl', human: 'hu' };
|
|
1230
1323
|
const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
|
|
1231
1324
|
/**
|
|
@@ -1241,12 +1334,12 @@ const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
|
|
|
1241
1334
|
* 4 box lista (2 bordi + 2 padding)
|
|
1242
1335
|
* 2 caret
|
|
1243
1336
|
* 4 indice del record
|
|
1244
|
-
*
|
|
1337
|
+
* 4 spazio + tag del kind (2 caratteri) + spazio
|
|
1245
1338
|
* Prudente per costruzione: sottostimare tronca un carattere in più,
|
|
1246
1339
|
* sovrastimare manderebbe la riga a capo e sfonderebbe il budget d'altezza.
|
|
1247
1340
|
*/
|
|
1248
1341
|
function searchExcerptWidth(columns) {
|
|
1249
|
-
return Math.max(30, (columns || 80) -
|
|
1342
|
+
return Math.max(30, (columns || 80) - 18);
|
|
1250
1343
|
}
|
|
1251
1344
|
/** Titolo della conversazione sulla riga-gruppo: prende ciò che avanza dopo le
|
|
1252
1345
|
* colonne a larghezza fissa (caret, pin, hash, task, conteggio, data). */
|
|
@@ -1267,13 +1360,7 @@ function searchTitleWidth(columns) {
|
|
|
1267
1360
|
* conversazione e non un'altra.
|
|
1268
1361
|
*/
|
|
1269
1362
|
function conversationLabel(s, core, bound) {
|
|
1270
|
-
let t = s.title;
|
|
1271
|
-
if (core) {
|
|
1272
|
-
const at = t.indexOf(core);
|
|
1273
|
-
if (at >= 0)
|
|
1274
|
-
t = t.slice(at + core.length);
|
|
1275
|
-
}
|
|
1276
|
-
t = t.replace(/^[\s·]+/, '').trim();
|
|
1363
|
+
let t = stripProjectCore(s.title, core);
|
|
1277
1364
|
if (bound && t === bound)
|
|
1278
1365
|
t = '';
|
|
1279
1366
|
return t || s.firstPrompt || '(senza titolo)';
|
|
@@ -1309,18 +1396,26 @@ function SearchListHeader({ result, query, above, below, }) {
|
|
|
1309
1396
|
}
|
|
1310
1397
|
return (_jsxs(Text, { bold: true, wrap: "truncate-end", children: [result.shown, " occorrenze in ", result.sessionCount, " conversazioni", result.hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", result.hidden, " oltre il cap"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }));
|
|
1311
1398
|
}
|
|
1312
|
-
function SearchScreen({ preview, hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, projectCore, columns, note, }) {
|
|
1399
|
+
function SearchScreen({ preview, hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, sessionNotes, projectCore, columns, note, }) {
|
|
1313
1400
|
const enter = selectedKind === 'session' ? 'resume' : selectedKind === 'hit' ? 'leggi' : '—';
|
|
1314
1401
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["ricerca \u00B7 ", _jsx(Text, { color: "yellow", children: "tab" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " naviga \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " ", enter, " \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "hash " }), _jsx(Text, { color: field === 'hash' ? 'yellow' : undefined, children: hash }), field === 'hash' ? _jsx(Text, { inverse: true, children: " " }) : null, !hash ? _jsx(Text, { dimColor: true, children: " (vuoto = tutte le conversazioni)" }) : null] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "chiave " }), _jsx(Text, { color: field === 'query' ? 'yellow' : undefined, children: query }), field === 'query' ? _jsx(Text, { inverse: true, children: " " }) : null] }), _jsx(ToggleHint, { opts: opts })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsx(SearchListHeader, { result: result, query: query, above: above, below: below }), rows.slice(0, Math.max(0, capacity)).map((row) => {
|
|
1315
1402
|
const sel = row.key === selectedKey;
|
|
1316
1403
|
if (row.kind === 'session') {
|
|
1317
1404
|
const s = row.session;
|
|
1318
1405
|
const bound = bindings.get(s.sessionId);
|
|
1319
|
-
|
|
1406
|
+
const rowNote = sessionNotes.get(s.sessionId);
|
|
1407
|
+
const noteShown = rowNote ? cut(rowNote, 24) : '';
|
|
1408
|
+
// `+3` = i due caporali e lo spazio che li separa dall'etichetta.
|
|
1409
|
+
// Il pavimento non è cosmetico: senza, un terminale stretto manda
|
|
1410
|
+
// l'argomento di `cut` sotto zero, cioè un budget negativo.
|
|
1411
|
+
// La nota si misura con `termWidth`, non con `.length`: contiene
|
|
1412
|
+
// testo umano, emoji compresi.
|
|
1413
|
+
const restWidth = Math.max(8, searchTitleWidth(columns) - (noteShown ? termWidth(noteShown) + 3 : 0));
|
|
1414
|
+
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), noteShown ? _jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", noteShown, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(noteShown), children: cut(conversationLabel(s, projectCore, bound), restWidth) }), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
|
|
1320
1415
|
}
|
|
1321
1416
|
const h = row.hit;
|
|
1322
|
-
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: String(h.idx).padStart(4) }), ' ', _jsx(Text, { color: KIND_COLOR[h.kind], children: KIND_TAG[h.kind] }),
|
|
1323
|
-
})] }), preview ? _jsx(SearchPreviewPane, { p: preview }) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children:
|
|
1417
|
+
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: String(h.idx).padStart(4) }), ' ', _jsx(Text, { color: KIND_COLOR[h.kind], children: KIND_TAG[h.kind] }), ' ', cut(h.excerpt, searchExcerptWidth(columns))] }, row.key));
|
|
1418
|
+
})] }), preview ? _jsx(SearchPreviewPane, { p: preview }) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
1324
1419
|
}
|
|
1325
1420
|
/**
|
|
1326
1421
|
* Anteprima dell'occorrenza selezionata, sotto la lista.
|
|
@@ -1367,16 +1462,25 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
|
|
|
1367
1462
|
...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
|
|
1368
1463
|
...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
|
|
1369
1464
|
]
|
|
1370
|
-
.map((e) => `−${
|
|
1465
|
+
.map((e) => `−${sanitize(e.glyph)}`)
|
|
1371
1466
|
.join(' ')] })) : null] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : (tasks.map((task, i) => {
|
|
1372
1467
|
// windowStart riporta l'indice di finestra a quello della lista
|
|
1373
1468
|
// completa, su cui è keyata la selezione. +1: lo 0 è spot.
|
|
1374
1469
|
const sel = windowStart + i + 1 === selected;
|
|
1375
1470
|
const n = childCount.get(task.id) ?? 0;
|
|
1376
|
-
|
|
1471
|
+
// Invariante ③: la descrizione è l'unico pezzo a lunghezza libera, e
|
|
1472
|
+
// si taglia QUI sul budget che resta dopo le colonne fisse. Lasciarlo
|
|
1473
|
+
// fare a `truncate-end` significa passare da `cli-truncate`, che
|
|
1474
|
+
// restituisce una riga più larga del pane (una colonna per emoji) e
|
|
1475
|
+
// quindi scrive sopra il bordo. Le parti fisse si misurano con
|
|
1476
|
+
// `termWidth`: `task.id` è `T9` o `T52`, i due glifi valgono 2 ciascuno.
|
|
1477
|
+
const head = `${CARET_OFF}${task.id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
|
|
1478
|
+
const tail = n > 0 ? ` (${n})` : '';
|
|
1479
|
+
const desc = cut(task.desc, Math.max(4, paneTextWidth(columns) - termWidth(head) - termWidth(tail)));
|
|
1480
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
|
|
1377
1481
|
})), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
|
|
1378
1482
|
}
|
|
1379
|
-
function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, }) {
|
|
1483
|
+
function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, sessionNotes, projectCore, }) {
|
|
1380
1484
|
return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Sessions \u00B7 ", parentLabel, " (", total, ")", pinnedCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 \uD83D\uDCCC", pinnedCount] }) : null, hidden > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 +", hidden, " pi\u00F9 vecchie"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isSpot ? 'nessuna sessione libera' : 'nessuna sessione legata a questa task' })) : (rows.map((row, i) => {
|
|
1381
1485
|
// T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
|
|
1382
1486
|
// box pesante (coerente con lo styling delle Done dimmate).
|
|
@@ -1387,7 +1491,7 @@ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, s
|
|
|
1387
1491
|
// T50 — pin stale: transcript sparito, nessuna Session da mostrare.
|
|
1388
1492
|
// Riga navigabile e spinnabile (`p`), marcata, mai un crash.
|
|
1389
1493
|
if (row.kind === 'pinned' && row.stale) {
|
|
1390
|
-
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, 8) })] }, row.sessionId));
|
|
1494
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, 8) }), sessionNotes.get(row.sessionId) ? (_jsxs(Text, { color: "yellow", children: [" \u00AB", cut(sessionNotes.get(row.sessionId), 30), "\u00BB"] })) : null] }, row.sessionId));
|
|
1391
1495
|
}
|
|
1392
1496
|
const s = row.session; // non-stale → session presente
|
|
1393
1497
|
const isPinnedRow = row.kind === 'pinned';
|
|
@@ -1395,8 +1499,21 @@ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, s
|
|
|
1395
1499
|
// righe sarebbero identiche a occhio. `⑂` sta PRIMA del titolo, dove
|
|
1396
1500
|
// la troncatura non arriva mai.
|
|
1397
1501
|
const forked = forkOf.has(s.sessionId);
|
|
1398
|
-
|
|
1399
|
-
|
|
1502
|
+
// T53 — con una nota il prefisso di progetto sparisce e le sue colonne
|
|
1503
|
+
// passano alla nota; senza, il titolo resta com'è (vedi `rowLabel`).
|
|
1504
|
+
//
|
|
1505
|
+
// Invariante ③: il budget è DERIVATO da `columns`, non inchiodato.
|
|
1506
|
+
// Con un valore fisso (erano 44) su un terminale stretto la riga
|
|
1507
|
+
// superava il pane, e a troncarla finiva `cli-truncate` — che sfora
|
|
1508
|
+
// di una colonna per emoji e si mangia il bordo destro.
|
|
1509
|
+
const meta = ` · ${s.gitBranch || '-'} · ${relTime(s.ts)}`;
|
|
1510
|
+
const labelBudget = Math.max(12, paneTextWidth(columns) -
|
|
1511
|
+
(2 /* caret */ + 2 /* icona */ + 1 /* spazio */ + (forked ? 2 : 0)) -
|
|
1512
|
+
termWidth(meta) -
|
|
1513
|
+
1 /* spazio prima del meta */);
|
|
1514
|
+
const label = rowLabel(s.title, sessionNotes.get(s.sessionId), projectCore, labelBudget);
|
|
1515
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" })) : isSpot ? (_jsx(Text, { dimColor: true, children: "\u25CB" })) : (_jsx(Text, { color: "green", children: "\uD83D\uDD17" })), ' ', forked ? _jsx(Text, { color: "magenta", children: "\u2442 " }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
|
|
1516
|
+
})), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null, note: sessionNotes.get(detail.sessionId) ?? '' })) : null] }));
|
|
1400
1517
|
}
|
|
1401
1518
|
// T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
|
|
1402
1519
|
// task. Tutti i campi vengono dal parse già cached dell'adapter (mtime-keyed):
|
|
@@ -1406,11 +1523,11 @@ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, s
|
|
|
1406
1523
|
// — senza, il titolo È già il primo prompt e la riga lo duplicherebbe (D4
|
|
1407
1524
|
// preflight). Le righe rese non superano mai il riservato dal budget
|
|
1408
1525
|
// (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
|
|
1409
|
-
function SessionDetailPane({ s, firstLines, lastLines, columns, origin, }) {
|
|
1526
|
+
function SessionDetailPane({ s, firstLines, lastLines, columns, origin, note, }) {
|
|
1410
1527
|
const width = detailTextWidth(columns);
|
|
1411
1528
|
const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
|
|
1412
1529
|
const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
|
|
1413
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [
|
|
1530
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), origin ? ` · ⑂ da ${origin.slice(0, 8)}` : ''] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
|
|
1414
1531
|
}
|
|
1415
1532
|
/**
|
|
1416
1533
|
* Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
|
|
@@ -1435,6 +1552,20 @@ function detailMetaOf(detail) {
|
|
|
1435
1552
|
function detailTextWidth(columns) {
|
|
1436
1553
|
return Math.max(10, Math.floor(((columns || 80) - 4) / 2) - 9);
|
|
1437
1554
|
}
|
|
1555
|
+
/**
|
|
1556
|
+
* Larghezza del TESTO dentro un pane al 50%: box esterno (2 bordi + 2 padding)
|
|
1557
|
+
* → metà → bordo + padding del pane.
|
|
1558
|
+
*
|
|
1559
|
+
* Invariante ③ (`width.ts`): chi renderizza una riga a lunghezza libera la
|
|
1560
|
+
* taglia PRIMA con questa larghezza. Delegarlo a `wrap="truncate-end"`
|
|
1561
|
+
* significa passare da `cli-truncate`, che indicizza per code point e restituisce
|
|
1562
|
+
* una riga più larga di quella chiesta — una colonna per ogni emoji astrale a
|
|
1563
|
+
* sinistra del taglio. Quelle colonne finiscono sopra il bordo del pane, che
|
|
1564
|
+
* sparisce dalla riga: è la sminchiatura visibile a schermo.
|
|
1565
|
+
*/
|
|
1566
|
+
function paneTextWidth(columns) {
|
|
1567
|
+
return Math.max(20, Math.floor(((columns || 80) - 4) / 2) - 4);
|
|
1568
|
+
}
|
|
1438
1569
|
function DetailPane({ detail, maxLines, columns, }) {
|
|
1439
1570
|
const { meta, commit } = detailMetaOf(detail);
|
|
1440
1571
|
// Wrap calcolato qui, non delegato a `<Text wrap="wrap">`: il budget ha
|
package/dist/config.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// indice 1..9. Il `command` gira con cwd = project root.
|
|
6
6
|
import { readFileSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
-
import {
|
|
8
|
+
import { sanitize } from './width.js';
|
|
9
9
|
export function configFilePath(projectRoot) {
|
|
10
10
|
return join(projectRoot, '.claude', 'loom-works.json');
|
|
11
11
|
}
|
|
@@ -25,10 +25,10 @@ export function parseLaunch(raw) {
|
|
|
25
25
|
out.push({
|
|
26
26
|
// L'emoji arriva dal file config: testo arbitrario, quindi va normalizzato
|
|
27
27
|
// come ogni altro dato esterno — `☕` senza VS16 allargherebbe di una cella
|
|
28
|
-
// la riga della legenda, mandandola a capo (vedi
|
|
29
|
-
emoji:
|
|
28
|
+
// la riga della legenda, mandandola a capo (vedi width.ts).
|
|
29
|
+
emoji: sanitize(typeof emoji === 'string' ? emoji : '▸'),
|
|
30
30
|
// `label` è opzionale per contratto → fallback sul comando stesso.
|
|
31
|
-
label:
|
|
31
|
+
label: sanitize(typeof label === 'string' && label ? label : command),
|
|
32
32
|
command,
|
|
33
33
|
});
|
|
34
34
|
}
|
package/dist/session-list.js
CHANGED
|
@@ -14,6 +14,71 @@
|
|
|
14
14
|
// - Trap T39: la selezione è KEYED SU sessionId, non su indice posizionale. La
|
|
15
15
|
// lista è una vista trasformata (due gruppi + separatore): un indice grezzo
|
|
16
16
|
// identificherebbe la riga sbagliata dopo un pin/cambio-contesto.
|
|
17
|
+
import { cut, termWidth } from './width.js';
|
|
18
|
+
// ── T53 · etichetta di riga ────────────────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* Toglie dal titolo il core `<owner> <name>` del progetto e la punteggiatura di
|
|
21
|
+
* giunzione che resta appesa davanti.
|
|
22
|
+
*
|
|
23
|
+
* Il titolo di una sessione È la label della tab Ptyxis (`🧵 LOCAL loom-works ·
|
|
24
|
+
* T52`): dentro un deck che mostra un progetto solo, il core è una COLONNA
|
|
25
|
+
* COSTANTE ripetuta su ogni riga. Toglierlo libera le colonne per ciò che
|
|
26
|
+
* distingue davvero una conversazione dall'altra.
|
|
27
|
+
*
|
|
28
|
+
* `core` null (file config assente o senza identità) → titolo intatto: senza
|
|
29
|
+
* sapere cosa togliere non si indovina, si lascia stare.
|
|
30
|
+
*/
|
|
31
|
+
export function stripProjectCore(title, core) {
|
|
32
|
+
let t = title;
|
|
33
|
+
if (core) {
|
|
34
|
+
const at = t.indexOf(core);
|
|
35
|
+
if (at >= 0)
|
|
36
|
+
t = t.slice(at + core.length);
|
|
37
|
+
}
|
|
38
|
+
return t.replace(/^[\s·]+/, '').trim();
|
|
39
|
+
}
|
|
40
|
+
/** Colonne minime perché un residuo dopo la nota valga la riga: sotto questa
|
|
41
|
+
* soglia si vedrebbero due lettere e un'ellissi, cioè rumore. */
|
|
42
|
+
const MIN_REST = 6;
|
|
43
|
+
/** Colonne garantite alla nota quando c'è anche un residuo da mostrare: la nota
|
|
44
|
+
* è la parte scelta da un umano, non è lei a cedere il posto per prima. */
|
|
45
|
+
const MIN_NOTE = 14;
|
|
46
|
+
/**
|
|
47
|
+
* Cosa scrivere sulla riga di una conversazione, dentro `budget` colonne.
|
|
48
|
+
*
|
|
49
|
+
* Due regimi, ed è una decisione deliberata che NON siano lo stesso:
|
|
50
|
+
*
|
|
51
|
+
* - **senza nota** → il titolo resta INTATTO, prefisso di progetto incluso.
|
|
52
|
+
* Togliere il core qui lascerebbe `T52` o il nulla (i titoli di tab non
|
|
53
|
+
* contengono altro), cioè meno informazione di prima, non più spazio.
|
|
54
|
+
* - **con nota** → il core se ne va: la nota dice già quale conversazione è,
|
|
55
|
+
* quindi le colonne del prefisso passano a lei. Il residuo (tipicamente la
|
|
56
|
+
* task, `T52`) segue dimmato se ci sta; se non resta nulla va benissimo.
|
|
57
|
+
*
|
|
58
|
+
* Il budget si divide dando la precedenza alla nota, ma senza affamare il
|
|
59
|
+
* residuo: con entrambi presenti la nota cede fino a `MIN_NOTE` per lasciare
|
|
60
|
+
* spazio al residuo, e il residuo sparisce del tutto sotto `MIN_REST` invece di
|
|
61
|
+
* ridursi a un moncone.
|
|
62
|
+
*/
|
|
63
|
+
export function rowLabel(title, note, core, budget) {
|
|
64
|
+
if (!note)
|
|
65
|
+
return { note: '', rest: cut(title, Math.max(0, budget)) };
|
|
66
|
+
// 2 colonne per i caporali « », 1 per lo spazio prima del residuo.
|
|
67
|
+
const rest = stripProjectCore(title, core);
|
|
68
|
+
const noteBudget = Math.max(0, budget - 2);
|
|
69
|
+
if (!rest)
|
|
70
|
+
return { note: cut(note, noteBudget), rest: '' };
|
|
71
|
+
// Il `min` col budget totale non è ridondante: su un pane strettissimo
|
|
72
|
+
// `MIN_NOTE` supererebbe le colonne disponibili e la riga andrebbe a capo —
|
|
73
|
+
// che è precisamente il difetto che ogni larghezza qui dentro esiste per
|
|
74
|
+
// evitare (una riga wrappata sfonda il budget d'ALTEZZA, non solo l'estetica).
|
|
75
|
+
const shownNote = cut(note, Math.min(noteBudget, Math.max(MIN_NOTE, noteBudget - MIN_REST - 1)));
|
|
76
|
+
// `termWidth`, non `.length`: quanto RESTA si misura in colonne come tutto il
|
|
77
|
+
// resto: una nota con un emoji conta 2 sullo schermo e 1 (o 2) code unit, e
|
|
78
|
+
// sottrarre le une dalle altre rimetterebbe la riga fuori dal pane.
|
|
79
|
+
const restBudget = noteBudget - termWidth(shownNote) - 1;
|
|
80
|
+
return { note: shownNote, rest: restBudget >= MIN_REST ? cut(rest, restBudget) : '' };
|
|
81
|
+
}
|
|
17
82
|
export function assembleSessionList(childSessions, allSessions, pinned, maxContext) {
|
|
18
83
|
// Le pinnate si risolvono contro TUTTE le sessioni del progetto, non solo le
|
|
19
84
|
// figlie del parent: una pinnata può appartenere a un'altra task. Assente
|
package/dist/sessions.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { sanitize } from './width.js';
|
|
5
5
|
// CC codifica la project dir sostituendo ogni char non-alfanumerico con '-'
|
|
6
6
|
// (verificato: `/home/lamemind/cc-host` → `-home-lamemind-cc-host`). È LOSSY
|
|
7
7
|
// (un '-' nel path e un '/' collassano nello stesso char) → il forward è
|
|
@@ -122,7 +122,12 @@ function collectBodies(message, idx, isAssistant, out) {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
for (const kind of ['ai', 'tool', 'human']) {
|
|
125
|
-
|
|
125
|
+
// Sanificato QUI, prima che la ricerca ci giri sopra: gli offset del match
|
|
126
|
+
// (`matchStart`/`matchEnd`) indicizzano questo testo, e sanificare a valle —
|
|
127
|
+
// al render — li sposterebbe, evidenziando la parte sbagliata della riga.
|
|
128
|
+
// Un corpo di transcript è la sorgente più ostile che il deck legge: emoji
|
|
129
|
+
// qualsiasi, output di tool con byte di controllo, CJK.
|
|
130
|
+
const text = sanitize(parts[kind].join('\n'));
|
|
126
131
|
if (!text)
|
|
127
132
|
continue;
|
|
128
133
|
if (kind === 'human' && isInterrupt(text))
|
|
@@ -220,7 +225,7 @@ export function parseTranscript(content, path, mtime, sizeBytes) {
|
|
|
220
225
|
cwd,
|
|
221
226
|
gitBranch,
|
|
222
227
|
parentUuid,
|
|
223
|
-
title:
|
|
228
|
+
title: sanitize(customTitle || firstUserText || '(senza titolo)'),
|
|
224
229
|
ts: mtime,
|
|
225
230
|
path,
|
|
226
231
|
sizeBytes,
|
package/dist/task-index.js
CHANGED
|
@@ -15,6 +15,10 @@ export function appendTaskBinding(projectRoot, sessionId, taskId) {
|
|
|
15
15
|
export function appendPin(projectRoot, sessionId, pinned) {
|
|
16
16
|
appendSessionRecord(projectRoot, { sessionId, pinned });
|
|
17
17
|
}
|
|
18
|
+
/** T53 — scrive (o cancella, con `note` vuota) la nota di una conversazione. */
|
|
19
|
+
export function appendNote(projectRoot, sessionId, note) {
|
|
20
|
+
appendSessionRecord(projectRoot, { sessionId, note });
|
|
21
|
+
}
|
|
18
22
|
// Una sola lettura del JSONL per entrambe le mappe: il deck poll-a l'indice a
|
|
19
23
|
// ogni tick, leggere il file due volte raddoppierebbe l'I/O per nulla.
|
|
20
24
|
// Last-wins per campo (un re-pin dello stesso sessionId sovrascrive), e i due
|
|
@@ -24,12 +28,13 @@ export function loadSessionIndex(projectRoot) {
|
|
|
24
28
|
const bindings = new Map();
|
|
25
29
|
const forkOf = new Map();
|
|
26
30
|
const pinned = new Map();
|
|
31
|
+
const notes = new Map();
|
|
27
32
|
let content;
|
|
28
33
|
try {
|
|
29
34
|
content = readFileSync(taskIndexPath(projectRoot), 'utf8');
|
|
30
35
|
}
|
|
31
36
|
catch {
|
|
32
|
-
return { bindings, forkOf, pinned };
|
|
37
|
+
return { bindings, forkOf, pinned, notes };
|
|
33
38
|
}
|
|
34
39
|
let order = 0; // posizione crescente dei record pinned → rango di pin (D2)
|
|
35
40
|
for (const line of content.split('\n')) {
|
|
@@ -51,10 +56,19 @@ export function loadSessionIndex(projectRoot) {
|
|
|
51
56
|
else
|
|
52
57
|
pinned.delete(d.sessionId);
|
|
53
58
|
}
|
|
59
|
+
// T53 — stesso last-wins: la stringa vuota è la CANCELLAZIONE, non una
|
|
60
|
+
// nota vuota da mostrare. Il `typeof` esclude i record senza il campo, che
|
|
61
|
+
// non devono toccare una nota scritta da un record precedente.
|
|
62
|
+
if (typeof d.note === 'string') {
|
|
63
|
+
if (d.note)
|
|
64
|
+
notes.set(d.sessionId, d.note);
|
|
65
|
+
else
|
|
66
|
+
notes.delete(d.sessionId);
|
|
67
|
+
}
|
|
54
68
|
}
|
|
55
69
|
catch {
|
|
56
70
|
// riga corrotta → skip
|
|
57
71
|
}
|
|
58
72
|
}
|
|
59
|
-
return { bindings, forkOf, pinned };
|
|
73
|
+
return { bindings, forkOf, pinned, notes };
|
|
60
74
|
}
|
package/dist/tasks.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { sanitize } from './width.js';
|
|
4
4
|
/**
|
|
5
5
|
* Forma di un ID task: `T` (code) o `D` (doc) + numero. FONTE UNICA del gate —
|
|
6
6
|
* `parseTasks` lo usa per scartare header/separatore della tabella, `task-edit`
|
|
@@ -42,16 +42,17 @@ export function parseTasks(content) {
|
|
|
42
42
|
const prog = cells[4] ?? '';
|
|
43
43
|
// desc = colonna finale; join per resistere a eventuali `|` nella descrizione.
|
|
44
44
|
const desc = cells.slice(5, -1).join('|').trim();
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
45
|
+
// Sanificazione AL CONFINE: tutto ciò che entra da tasks.md è testo
|
|
46
|
+
// arbitrario e può contenere glifi che Ink e il terminale misurano in modo
|
|
47
|
+
// diverso (vedi `width.ts`). Farlo qui invece che a ogni sito di render è
|
|
48
|
+
// ciò che impedisce di dimenticarne uno.
|
|
49
|
+
//
|
|
50
|
+
// `pri` e `prog` sono l'eccezione: NON si sanificano qui perché sono chiavi
|
|
51
|
+
// SEMANTICHE, non testo — `isDone()` e le lookup di `view.ts` confrontano
|
|
52
|
+
// il glifo, e `task-edit` lo riscrive in tasks.md. Sanificarli qui
|
|
53
|
+
// renderebbe `✔` un `✅` anche su disco, cambiando il formato di famiglia.
|
|
54
|
+
// La sanificazione di quei due avviene al display (`displayProg`).
|
|
55
|
+
tasks.push({ id, pri, prog, desc: sanitize(desc) });
|
|
55
56
|
}
|
|
56
57
|
return tasks;
|
|
57
58
|
}
|
|
@@ -112,12 +113,12 @@ export function parseTaskDetail(id, content) {
|
|
|
112
113
|
// allarga la riga oltre il bordo del pane.
|
|
113
114
|
const normFields = {};
|
|
114
115
|
for (const [k, v] of Object.entries(fields))
|
|
115
|
-
normFields[k] =
|
|
116
|
+
normFields[k] = sanitize(v);
|
|
116
117
|
return {
|
|
117
118
|
id,
|
|
118
|
-
title:
|
|
119
|
+
title: sanitize(title),
|
|
119
120
|
fields: normFields,
|
|
120
|
-
description:
|
|
121
|
+
description: sanitize(descLines.join('\n').trim()),
|
|
121
122
|
};
|
|
122
123
|
}
|
|
123
124
|
export function loadTaskDetail(dir, id) {
|
package/dist/viewport.js
CHANGED
|
@@ -13,43 +13,6 @@
|
|
|
13
13
|
// dettaglio) passa da qui e riceve una capienza in righe.
|
|
14
14
|
//
|
|
15
15
|
// Logica pura, zero React/Ink: testabile senza pseudo-terminale.
|
|
16
|
-
import stringWidth from 'string-width';
|
|
17
|
-
/** Variation Selector-16: forza la presentazione emoji del carattere che precede. */
|
|
18
|
-
const VS16 = '️';
|
|
19
|
-
/**
|
|
20
|
-
* Riallinea la larghezza dei glifi fra Ink e il terminale.
|
|
21
|
-
*
|
|
22
|
-
* Secondo meccanismo di sporcamento dello scrollback, indipendente dal budget
|
|
23
|
-
* d'altezza. Ink assembla ogni riga su una griglia di CARATTERI: per un emoji
|
|
24
|
-
* BMP senza VS16 (`☕` U+2615, `✔` U+2714, `⚡` U+26A1, `✅` U+2705, `▶` U+25B6)
|
|
25
|
-
* riserva una sola posizione, mentre il terminale ne disegna due. La riga esce
|
|
26
|
-
* larga `columns + 1`, il terminale la manda a capo, e l'altezza reale supera
|
|
27
|
-
* quella che Ink crede — senza mai passare dal ramo `clearTerminal`. Con più
|
|
28
|
-
* glifi sulla stessa riga l'eccesso si somma.
|
|
29
|
-
*
|
|
30
|
-
* Il VS16 esplicito fa contare 2 anche a Ink, riallineando le due contabilità.
|
|
31
|
-
*
|
|
32
|
-
* Il predicato è la LARGHEZZA MISURATA, non l'intervallo di codepoint: nello
|
|
33
|
-
* stesso blocco U+2190–U+2BFF vivono anche `↓` `↑` `−`, larghi 1, e timbrarli
|
|
34
|
-
* col VS16 li porterebbe a 2 accorciando la riga — l'errore opposto, con lo
|
|
35
|
-
* stesso effetto di layout rotto. Gli astrali (U+1F000+) Ink li conta già bene.
|
|
36
|
-
*
|
|
37
|
-
* Idempotente: un glifo che ha già il VS16 non viene ri-timbrato.
|
|
38
|
-
*/
|
|
39
|
-
export function normalizeEmoji(s) {
|
|
40
|
-
if (!s)
|
|
41
|
-
return s;
|
|
42
|
-
const cps = [...s];
|
|
43
|
-
let out = '';
|
|
44
|
-
for (let i = 0; i < cps.length; i++) {
|
|
45
|
-
const ch = cps[i];
|
|
46
|
-
out += ch;
|
|
47
|
-
const cp = ch.codePointAt(0);
|
|
48
|
-
if (cp < 0x10000 && stringWidth(ch) === 2 && cps[i + 1] !== VS16)
|
|
49
|
-
out += VS16;
|
|
50
|
-
}
|
|
51
|
-
return out;
|
|
52
|
-
}
|
|
53
16
|
/** Righe lasciate libere sotto il frame. La condizione di Ink è `>=`, quindi
|
|
54
17
|
* basterebbe 1; ne teniamo 1 come margine per l'a-capo del cursore. */
|
|
55
18
|
export const SLACK = 1;
|
|
@@ -75,6 +38,7 @@ const MIN_SESSION_ROWS = 3;
|
|
|
75
38
|
export const MODAL_HEIGHT = {
|
|
76
39
|
normal: 0,
|
|
77
40
|
create: 4, // marginTop + 2 bordi + 1 riga input
|
|
41
|
+
note: 4, // T53 — gemello di create: marginTop + 2 bordi + 1 riga input
|
|
78
42
|
sort: 5, // marginTop + 2 bordi + titolo + 1 riga catena
|
|
79
43
|
filter: 6, // marginTop + 2 bordi + titolo + 2 righe (pri, stato)
|
|
80
44
|
edit: 8, // marginTop + 2 bordi + titolo + 3 campi + riga anteprima
|
|
@@ -232,53 +196,6 @@ export function searchPreviewCapacity(capacity, listRows) {
|
|
|
232
196
|
export function isCompact(capacity) {
|
|
233
197
|
return capacity < 1;
|
|
234
198
|
}
|
|
235
|
-
/**
|
|
236
|
-
* A-capo che PRESERVA la struttura del testo e traccia gli offset.
|
|
237
|
-
*
|
|
238
|
-
* Distinto da `wrapLines`, che collassa tutto in un flusso unico: lì serviva una
|
|
239
|
-
* preview compatta di 2-4 righe, qui si legge un messaggio intero — appiattire
|
|
240
|
-
* le newline renderebbe illeggibile qualunque blocco di codice o elenco.
|
|
241
|
-
*
|
|
242
|
-
* Ogni riga prodotta è una FETTA CONTIGUA del sorgente: è ciò che permette di
|
|
243
|
-
* intersecarla con l'intervallo del match e colorare solo la parte giusta,
|
|
244
|
-
* anche quando il match cade a cavallo di due righe.
|
|
245
|
-
*
|
|
246
|
-
* Taglia sull'ultimo spazio disponibile; una parola più larga della riga viene
|
|
247
|
-
* spezzata a forza, altrimenti l'a-capo lo farebbe il terminale — fuori dal
|
|
248
|
-
* conteggio delle righe, cioè di nuovo un frame più alto di `rows`.
|
|
249
|
-
*/
|
|
250
|
-
export function wrapWithOffsets(text, width) {
|
|
251
|
-
const out = [];
|
|
252
|
-
if (width <= 0)
|
|
253
|
-
return out;
|
|
254
|
-
let base = 0;
|
|
255
|
-
for (const raw of text.split('\n')) {
|
|
256
|
-
// Tab e CR diventano UN singolo spazio, non quattro: la sostituzione deve
|
|
257
|
-
// conservare la lunghezza, o gli offset delle righe smettono di indicizzare
|
|
258
|
-
// il sorgente e il match verrebbe evidenziato spostato. Un tab lasciato
|
|
259
|
-
// passare sarebbe l'errore opposto — lo conteremmo 1 e il terminale 8.
|
|
260
|
-
const line = raw.replace(/[\t\r]/g, ' ');
|
|
261
|
-
if (line.length === 0) {
|
|
262
|
-
out.push({ text: '', start: base, end: base });
|
|
263
|
-
}
|
|
264
|
-
let pos = 0;
|
|
265
|
-
while (pos < line.length) {
|
|
266
|
-
if (line.length - pos <= width) {
|
|
267
|
-
out.push({ text: line.slice(pos), start: base + pos, end: base + line.length });
|
|
268
|
-
break;
|
|
269
|
-
}
|
|
270
|
-
let brk = line.lastIndexOf(' ', pos + width);
|
|
271
|
-
if (brk <= pos)
|
|
272
|
-
brk = pos + width; // parola più lunga della riga
|
|
273
|
-
out.push({ text: line.slice(pos, brk), start: base + pos, end: base + brk });
|
|
274
|
-
pos = brk;
|
|
275
|
-
while (line[pos] === ' ')
|
|
276
|
-
pos++; // lo spazio del taglio non apre la riga dopo
|
|
277
|
-
}
|
|
278
|
-
base += raw.length + 1; // +1 = il '\n' consumato dallo split
|
|
279
|
-
}
|
|
280
|
-
return out;
|
|
281
|
-
}
|
|
282
199
|
/**
|
|
283
200
|
* Finestra scorrevole su una lista più lunga della capienza.
|
|
284
201
|
*
|
|
@@ -297,58 +214,3 @@ export function windowRange(total, selected, capacity) {
|
|
|
297
214
|
const start = Math.max(0, Math.min(sel - Math.floor(capacity / 2), total - capacity));
|
|
298
215
|
return { start, end: start + capacity };
|
|
299
216
|
}
|
|
300
|
-
/**
|
|
301
|
-
* Hard-wrap a larghezza fissa, con tetto di righe.
|
|
302
|
-
*
|
|
303
|
-
* Serve un conteggio righe DETERMINISTICO: `<Text wrap="wrap">` di Ink wrappa a
|
|
304
|
-
* runtime su una larghezza che il budget non conosce, quindi il pannello
|
|
305
|
-
* dettaglio potrebbe sforare il tetto e riaprire il bug. Qui il testo viene
|
|
306
|
-
* spezzato prima, e ogni riga è renderizzata con `wrap="truncate-end"`.
|
|
307
|
-
*
|
|
308
|
-
* Sottostimare `width` è sicuro (tronca prima), sovrastimarlo no (la riga
|
|
309
|
-
* andrebbe a capo aggiungendo altezza non contabilizzata).
|
|
310
|
-
*/
|
|
311
|
-
export function wrapLines(text, width, maxLines) {
|
|
312
|
-
if (maxLines <= 0 || width <= 0)
|
|
313
|
-
return [];
|
|
314
|
-
const flat = text.replace(/\s+/g, ' ').trim();
|
|
315
|
-
if (!flat)
|
|
316
|
-
return [];
|
|
317
|
-
const lines = [];
|
|
318
|
-
let line = '';
|
|
319
|
-
for (const word of flat.split(' ')) {
|
|
320
|
-
if (!line) {
|
|
321
|
-
line = word;
|
|
322
|
-
}
|
|
323
|
-
else if (line.length + 1 + word.length <= width) {
|
|
324
|
-
line += ' ' + word;
|
|
325
|
-
}
|
|
326
|
-
else {
|
|
327
|
-
lines.push(line);
|
|
328
|
-
line = word;
|
|
329
|
-
if (lines.length === maxLines)
|
|
330
|
-
break;
|
|
331
|
-
}
|
|
332
|
-
// Parola più lunga della riga: spezzala a forza, altrimenti l'a-capo lo
|
|
333
|
-
// farebbe il terminale — fuori dal nostro conteggio.
|
|
334
|
-
while (line.length > width) {
|
|
335
|
-
lines.push(line.slice(0, width));
|
|
336
|
-
line = line.slice(width);
|
|
337
|
-
if (lines.length === maxLines)
|
|
338
|
-
break;
|
|
339
|
-
}
|
|
340
|
-
if (lines.length === maxLines)
|
|
341
|
-
break;
|
|
342
|
-
}
|
|
343
|
-
if (line && lines.length < maxLines)
|
|
344
|
-
lines.push(line);
|
|
345
|
-
const kept = lines.slice(0, maxLines);
|
|
346
|
-
// Troncamento MAI silenzioso, come le liste: l'ellissi segnala che il testo
|
|
347
|
-
// continua oltre il pannello.
|
|
348
|
-
const consumed = kept.join(' ').length;
|
|
349
|
-
if (consumed < flat.length && kept.length > 0) {
|
|
350
|
-
const last = kept[kept.length - 1];
|
|
351
|
-
kept[kept.length - 1] = last.length >= width ? last.slice(0, Math.max(0, width - 1)) + '…' : last + '…';
|
|
352
|
-
}
|
|
353
|
-
return kept;
|
|
354
|
-
}
|
package/dist/width.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Contabilità UNICA della larghezza — fonte di verità per «quanto è larga
|
|
2
|
+
// questa stringa sullo schermo».
|
|
3
|
+
//
|
|
4
|
+
// PERCHÉ ESISTE. Nel frame convivevano tre unità di misura diverse, e nessuna
|
|
5
|
+
// era la colonna del terminale:
|
|
6
|
+
//
|
|
7
|
+
// 1. `string-width` — quella con cui Ink misura per il layout (Yoga) e per il
|
|
8
|
+
// troncamento. Conta 2 ogni emoji, VS16 o no.
|
|
9
|
+
// 2. `.length` / `.slice` — code unit UTF-16, usate dai vecchi `truncate` e
|
|
10
|
+
// `wrapLines`. Un emoji astrale (`🔥`) sono 2 unità per 2 colonne (caso
|
|
11
|
+
// fortunato), uno BMP (`⚡`) 1 unità per 2 colonne (sbagliato).
|
|
12
|
+
// 3. `slice-ansi`, dentro `cli-truncate`, dentro `wrap="truncate-end"` di Ink
|
|
13
|
+
// (`ink/build/wrap-text.js`): indicizza per CODE POINT ma riceve un budget
|
|
14
|
+
// di COLONNE. Ogni glifo largo 2 che sta a sinistra del taglio fa uscire
|
|
15
|
+
// la riga 1 colonna più larga del richiesto.
|
|
16
|
+
// 4. VTE/Ptyxis, che disegna: largo 2 solo se East-Asian-Width ∈ {W, F}, con
|
|
17
|
+
// gli Ambiguous a 1 e il VS16 IGNORATO.
|
|
18
|
+
//
|
|
19
|
+
// Una riga più larga del riservato sovrascrive il bordo del pane nella griglia
|
|
20
|
+
// di caratteri di Ink (bordo mangiato = layout sminchiato) e, quando il taglio
|
|
21
|
+
// cade su un glifo largo, fa crescere la riga composta oltre `columns`: il
|
|
22
|
+
// terminale la manda a capo, compare una riga vuota e tutto ciò che sta sotto
|
|
23
|
+
// slitta di una riga. Una riga più STRETTA del riservato sposta a sinistra
|
|
24
|
+
// bordo e pane vicino, sempre e solo sulle righe che contengono il glifo.
|
|
25
|
+
//
|
|
26
|
+
// LE TRE INVARIANTI. Tutto il resto del deck si appoggia a queste:
|
|
27
|
+
//
|
|
28
|
+
// ① Alfabeto concorde — nel frame entrano solo caratteri per cui
|
|
29
|
+
// `stringWidth(ch) === termWidth(ch)`. Il predicato è CALCOLATO, non una
|
|
30
|
+
// lista: qualunque glifo nuovo (in una descrizione, in un transcript) è
|
|
31
|
+
// giudicato dalla stessa regola. I discordi sono i ~120 emoji a
|
|
32
|
+
// presentazione-testo del BMP (`▶ ✔ ⚠ ❤ ✂ ➡ …`): string-width li dà 2, il
|
|
33
|
+
// terminale ne disegna 1. Non sono riparabili con un selettore di
|
|
34
|
+
// variazione (né VS16 né VS15 cambiano il verdetto di string-width) →
|
|
35
|
+
// vengono SOSTITUITI con un gemello concorde.
|
|
36
|
+
//
|
|
37
|
+
// ② VS16 sui glifi larghi 2 del BMP, e SOLO su quelli. Ink compone il frame
|
|
38
|
+
// su una griglia di celle assegnando 2 celle a un carattere se
|
|
39
|
+
// `isFullwidthCodePoint(cp) || value.length > 1`
|
|
40
|
+
// (`@alcalzone/ansi-tokenize`, un token per CODE POINT). `isFullwidthCodePoint`
|
|
41
|
+
// copre il CJK ma NON gli emoji, quindi:
|
|
42
|
+
// · astrale (`🔥`, 2 code unit) → `value.length > 1` → 2 celle, giusto;
|
|
43
|
+
// · BMP largo 2 (`⚡`, 1 code unit) → 1 sola cella, una in meno del dovuto;
|
|
44
|
+
// · BMP + VS16 → 1 + 1 = 2 celle, di nuovo giusto.
|
|
45
|
+
// Timbrare un ASTRALE è quindi l'errore opposto: gli darebbe 3 celle per 2
|
|
46
|
+
// colonne e la riga uscirebbe una colonna più CORTA del padding calcolato.
|
|
47
|
+
//
|
|
48
|
+
// ③ Il taglio lo fa il deck, non Ink. Restava scoperta la terza contabilità
|
|
49
|
+
// (`slice-ansi`, per code point): con un budget di colonne in ingresso,
|
|
50
|
+
// ogni glifo largo 2 a sinistra del taglio allarga il risultato di una
|
|
51
|
+
// colonna, e nessun timbro può ripararlo senza rompere ② o ①. L'unica via
|
|
52
|
+
// è non farlo mai intervenire: chi renderizza una riga a lunghezza libera
|
|
53
|
+
// la taglia PRIMA con `cut()`, su un budget derivato da `columns`.
|
|
54
|
+
// `wrap="truncate-end"` resta come rete, non come meccanismo.
|
|
55
|
+
//
|
|
56
|
+
// L'assunzione sul terminale (EAW, ambiguous = 1, VS16 ignorato) vive TUTTA in
|
|
57
|
+
// `termWidth`. Se un giorno cambia il terminale, cambia questa funzione e basta.
|
|
58
|
+
import stringWidth from 'string-width';
|
|
59
|
+
import { eastAsianWidth } from 'get-east-asian-width';
|
|
60
|
+
/** Variation Selector-16: forza la presentazione emoji del carattere che precede. */
|
|
61
|
+
const VS16 = '️';
|
|
62
|
+
/** Zero-width per il terminale: combining marks e format chars (VS16 incluso). */
|
|
63
|
+
const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}]$/u;
|
|
64
|
+
/**
|
|
65
|
+
* Larghezza in colonne secondo il TERMINALE (VTE/Ptyxis).
|
|
66
|
+
*
|
|
67
|
+
* `eastAsianWidth` restituisce già 1 per gli Ambiguous — che è il default di
|
|
68
|
+
* VTE (`utf8-ambiguous-width`) e il motivo per cui `▶` (EAW = Ambiguous) e `✔`
|
|
69
|
+
* (EAW = Neutral) vengono disegnati stretti anche col VS16 appiccicato.
|
|
70
|
+
*/
|
|
71
|
+
export function termWidth(s) {
|
|
72
|
+
let w = 0;
|
|
73
|
+
for (const ch of s) {
|
|
74
|
+
if (ZERO_WIDTH.test(ch))
|
|
75
|
+
continue;
|
|
76
|
+
w += eastAsianWidth(ch.codePointAt(0));
|
|
77
|
+
}
|
|
78
|
+
return w;
|
|
79
|
+
}
|
|
80
|
+
/** Il carattere è misurato allo stesso modo da Ink e dal terminale? */
|
|
81
|
+
export function agrees(ch) {
|
|
82
|
+
return stringWidth(ch) === termWidth(ch);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Sostituzioni esplicite per i discordi che il deck usa come SEGNALE: qui la
|
|
86
|
+
* semantica del glifo va preservata, non degradata a segnaposto.
|
|
87
|
+
*
|
|
88
|
+
* I sostituti sono concordi per costruzione (verificati dal test): `▸` è
|
|
89
|
+
* Neutral e non-emoji → 1 ovunque; `✅` `❗` sono astrali a presentazione-emoji
|
|
90
|
+
* → 2 ovunque.
|
|
91
|
+
*/
|
|
92
|
+
const GLYPH_MAP = new Map([
|
|
93
|
+
['▶', '▸'], // caret di selezione
|
|
94
|
+
['◀', '◂'],
|
|
95
|
+
['✔', '✅'], // marker Done
|
|
96
|
+
['☑', '✅'],
|
|
97
|
+
['✖', '❌'],
|
|
98
|
+
['⚠', '❗'], // warning (riga pin stale)
|
|
99
|
+
['❗', '❗'],
|
|
100
|
+
]);
|
|
101
|
+
/**
|
|
102
|
+
* Segnaposto per i discordi che arrivano da testo NON nostro (descrizioni,
|
|
103
|
+
* titoli, corpi dei transcript): non c'è una traduzione sensata glifo-per-glifo
|
|
104
|
+
* di `❤ ✂ ☀ ➡ …`, e lasciarli passare romperebbe la riga che li ospita.
|
|
105
|
+
* Largo 1 e concorde.
|
|
106
|
+
*/
|
|
107
|
+
const FILLER = '·';
|
|
108
|
+
/**
|
|
109
|
+
* Fast-path: sotto U+1100 non esiste né un wide né un discorde, quindi non
|
|
110
|
+
* c’è niente da sanificare. `\n` e `\t` stanno fuori dal sospetto pur essendo
|
|
111
|
+
* control char — li normalizzano i chiamanti (`wrapLines` collassa il
|
|
112
|
+
* whitespace, `wrapWithOffsets` li mappa preservando gli offset) e tenerli
|
|
113
|
+
* dentro costerebbe il fast-path su OGNI corpo di transcript, visto che
|
|
114
|
+
* `sanitize` gira anche sui ~10 MB di testo cercabile del progetto.
|
|
115
|
+
*/
|
|
116
|
+
const SUSPECT = /[^\n\t\u0020-\u10FF]/;
|
|
117
|
+
/**
|
|
118
|
+
* Byte di controllo che il terminale INTERPRETA — ESC in testa. Passati nudi
|
|
119
|
+
* da un transcript muoverebbero il cursore o cambierebbero i colori dal mezzo
|
|
120
|
+
* di una preview. Diventano uno spazio: largo 1, concorde, inerte.
|
|
121
|
+
*/
|
|
122
|
+
const CONTROL = /[\u0000-\u001F\u007F-\u009F]/;
|
|
123
|
+
/**
|
|
124
|
+
* Sostituzione di UN carattere secondo le invarianti ① e ②. Memoizzata: i
|
|
125
|
+
* corpi cercabili sono ~10 MB e i caratteri distinti che li attraversano sono
|
|
126
|
+
* poche decine — misurare la stessa emoji un milione di volte è il costo che
|
|
127
|
+
* questa cache toglie.
|
|
128
|
+
*/
|
|
129
|
+
const FIXED = new Map();
|
|
130
|
+
function fixChar(ch) {
|
|
131
|
+
const cached = FIXED.get(ch);
|
|
132
|
+
if (cached !== undefined)
|
|
133
|
+
return cached;
|
|
134
|
+
let out;
|
|
135
|
+
if (CONTROL.test(ch)) {
|
|
136
|
+
out = ' ';
|
|
137
|
+
}
|
|
138
|
+
else if (ZERO_WIDTH.test(ch)) {
|
|
139
|
+
// Il VS16 in ingresso si scarta SEMPRE: se serve lo ri-mette il ramo ② sul
|
|
140
|
+
// carattere che lo precede. Passare da «scarta e ri-timbra» invece che da
|
|
141
|
+
// «tieni se c'è» è ciò che rende `sanitize` idempotente. Le altre combining
|
|
142
|
+
// mark e i format char restano dove sono (0 colonne per entrambe le conte).
|
|
143
|
+
out = ch === VS16 ? '' : ch;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const fixedGlyph = agrees(ch) ? ch : (GLYPH_MAP.get(ch) ?? FILLER); // ①
|
|
147
|
+
// ② una cella per colonna nella griglia di Ink — SOLO sui BMP.
|
|
148
|
+
const bmpWide = fixedGlyph.codePointAt(0) < 0x10000 && termWidth(fixedGlyph) === 2;
|
|
149
|
+
out = bmpWide ? fixedGlyph + VS16 : fixedGlyph;
|
|
150
|
+
}
|
|
151
|
+
FIXED.set(ch, out);
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Caratteri che possono violare un'invariante: control char, tutto ciò che sta
|
|
156
|
+
* da U+1100 in su (primo wide dell'insieme) e gli astrali. `\n` e `\t` restano
|
|
157
|
+
* fuori — li normalizzano i chiamanti, e includerli qui costerebbe una
|
|
158
|
+
* sostituzione su ogni riga di ogni transcript.
|
|
159
|
+
*/
|
|
160
|
+
const RISKY = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]|[\u1100-\uFFFF]|[\u{10000}-\u{10FFFF}]/gu;
|
|
161
|
+
/**
|
|
162
|
+
* Rende una stringa sicura da mettere nel frame: applica le invarianti ① e ②.
|
|
163
|
+
*
|
|
164
|
+
* Idempotente, e a costo quasi zero sul testo ASCII (fast-path) — cioè sulla
|
|
165
|
+
* stragrande maggioranza dei corpi di transcript, che è il volume vero: la
|
|
166
|
+
* versione carattere-per-carattere costava secondi all'avvio del deck.
|
|
167
|
+
*/
|
|
168
|
+
export function sanitize(s) {
|
|
169
|
+
if (!s || !SUSPECT.test(s))
|
|
170
|
+
return s;
|
|
171
|
+
return s.replace(RISKY, fixChar);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Taglio a un budget di COLONNE, ellissi inclusa nel budget.
|
|
175
|
+
*
|
|
176
|
+
* Rimpiazza il vecchio `truncate`, che tagliava per code unit: su una riga con
|
|
177
|
+
* due emoji il risultato usciva 2 colonne oltre il budget, cioè esattamente
|
|
178
|
+
* dentro il bordo del pane.
|
|
179
|
+
*
|
|
180
|
+
* Collassa il whitespace come faceva il predecessore: i chiamanti passano
|
|
181
|
+
* descrizioni multi-riga e si aspettano un blocco unico.
|
|
182
|
+
*/
|
|
183
|
+
export function cut(s, cols) {
|
|
184
|
+
const flat = s.replace(/\s+/g, ' ').trim();
|
|
185
|
+
if (cols <= 0)
|
|
186
|
+
return '';
|
|
187
|
+
if (termWidth(flat) <= cols)
|
|
188
|
+
return flat;
|
|
189
|
+
if (cols === 1)
|
|
190
|
+
return '…';
|
|
191
|
+
let out = '';
|
|
192
|
+
let w = 0;
|
|
193
|
+
for (const ch of flat) {
|
|
194
|
+
const cw = termWidth(ch);
|
|
195
|
+
if (w + cw > cols - 1)
|
|
196
|
+
break; // -1 = la colonna dell'ellissi
|
|
197
|
+
out += ch;
|
|
198
|
+
w += cw;
|
|
199
|
+
}
|
|
200
|
+
// Un estratto può arrivare qui con la SUA ellissi già in coda (la ricerca
|
|
201
|
+
// ritaglia il contesto attorno al match): due ellissi di fila non aggiungono
|
|
202
|
+
// informazione, tolgono una colonna di testo.
|
|
203
|
+
return out.trimEnd().replace(/…$/, '') + '…';
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Indice sorgente raggiunto consumando al più `cols` colonne da `from`.
|
|
207
|
+
*
|
|
208
|
+
* Il conteggio è per colonne, l'indice restituito è in code unit: è ciò che
|
|
209
|
+
* tiene gli offset del match allineati al sorgente mentre l'a-capo ragiona in
|
|
210
|
+
* colonne.
|
|
211
|
+
*/
|
|
212
|
+
function advance(line, from, cols) {
|
|
213
|
+
let i = from;
|
|
214
|
+
let w = 0;
|
|
215
|
+
while (i < line.length) {
|
|
216
|
+
const cp = line.codePointAt(i);
|
|
217
|
+
const ch = String.fromCodePoint(cp);
|
|
218
|
+
const cw = termWidth(ch);
|
|
219
|
+
if (w + cw > cols)
|
|
220
|
+
break;
|
|
221
|
+
w += cw;
|
|
222
|
+
i += ch.length;
|
|
223
|
+
}
|
|
224
|
+
return i;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* A-capo che PRESERVA la struttura del testo e traccia gli offset.
|
|
228
|
+
*
|
|
229
|
+
* Distinto da `wrapLines`, che collassa tutto in un flusso unico: lì serve una
|
|
230
|
+
* preview compatta di 2-4 righe, qui si legge un messaggio intero — appiattire
|
|
231
|
+
* le newline renderebbe illeggibile qualunque blocco di codice o elenco.
|
|
232
|
+
*
|
|
233
|
+
* Ogni riga prodotta è una FETTA CONTIGUA del sorgente: è ciò che permette di
|
|
234
|
+
* intersecarla con l'intervallo del match e colorare solo la parte giusta,
|
|
235
|
+
* anche quando il match cade a cavallo di due righe.
|
|
236
|
+
*
|
|
237
|
+
* Taglia sull'ultimo spazio disponibile; una parola più larga della riga viene
|
|
238
|
+
* spezzata a forza, altrimenti l'a-capo lo farebbe il terminale — fuori dal
|
|
239
|
+
* conteggio delle righe, cioè di nuovo un frame più alto di `rows`.
|
|
240
|
+
*/
|
|
241
|
+
export function wrapWithOffsets(text, width) {
|
|
242
|
+
const out = [];
|
|
243
|
+
if (width <= 0)
|
|
244
|
+
return out;
|
|
245
|
+
let base = 0;
|
|
246
|
+
for (const raw of text.split('\n')) {
|
|
247
|
+
// Tab e CR diventano UN singolo spazio, non quattro: la sostituzione deve
|
|
248
|
+
// conservare la lunghezza, o gli offset delle righe smettono di indicizzare
|
|
249
|
+
// il sorgente e il match verrebbe evidenziato spostato. Un tab lasciato
|
|
250
|
+
// passare sarebbe l'errore opposto — lo conteremmo 1 e il terminale 8.
|
|
251
|
+
const line = raw.replace(/[\t\r]/g, ' ');
|
|
252
|
+
if (line.length === 0) {
|
|
253
|
+
out.push({ text: '', start: base, end: base });
|
|
254
|
+
}
|
|
255
|
+
let pos = 0;
|
|
256
|
+
while (pos < line.length) {
|
|
257
|
+
const stop = advance(line, pos, width);
|
|
258
|
+
if (stop >= line.length) {
|
|
259
|
+
out.push({ text: line.slice(pos), start: base + pos, end: base + line.length });
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
let brk = line.lastIndexOf(' ', stop);
|
|
263
|
+
if (brk <= pos)
|
|
264
|
+
brk = stop; // parola più lunga della riga
|
|
265
|
+
out.push({ text: line.slice(pos, brk), start: base + pos, end: base + brk });
|
|
266
|
+
pos = brk;
|
|
267
|
+
while (line[pos] === ' ')
|
|
268
|
+
pos++; // lo spazio del taglio non apre la riga dopo
|
|
269
|
+
}
|
|
270
|
+
base += raw.length + 1; // +1 = il '\n' consumato dallo split
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Hard-wrap a larghezza fissa, con tetto di righe.
|
|
276
|
+
*
|
|
277
|
+
* Serve un conteggio righe DETERMINISTICO: `<Text wrap="wrap">` di Ink wrappa a
|
|
278
|
+
* runtime su una larghezza che il budget non conosce, quindi il pannello
|
|
279
|
+
* dettaglio potrebbe sforare il tetto e riaprire il bug dell'altezza. Qui il
|
|
280
|
+
* testo viene spezzato prima, e ogni riga è renderizzata con `truncate-end`.
|
|
281
|
+
*
|
|
282
|
+
* Sottostimare `width` è sicuro (tronca prima), sovrastimarlo no (la riga
|
|
283
|
+
* andrebbe a capo aggiungendo altezza non contabilizzata).
|
|
284
|
+
*/
|
|
285
|
+
export function wrapLines(text, width, maxLines) {
|
|
286
|
+
if (maxLines <= 0 || width <= 0)
|
|
287
|
+
return [];
|
|
288
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
289
|
+
if (!flat)
|
|
290
|
+
return [];
|
|
291
|
+
const lines = [];
|
|
292
|
+
let line = '';
|
|
293
|
+
const push = () => {
|
|
294
|
+
lines.push(line);
|
|
295
|
+
line = '';
|
|
296
|
+
};
|
|
297
|
+
for (const word of flat.split(' ')) {
|
|
298
|
+
if (!line) {
|
|
299
|
+
line = word;
|
|
300
|
+
}
|
|
301
|
+
else if (termWidth(line) + 1 + termWidth(word) <= width) {
|
|
302
|
+
line += ' ' + word;
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
push();
|
|
306
|
+
line = word;
|
|
307
|
+
if (lines.length === maxLines)
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
// Parola più lunga della riga: spezzala a forza, altrimenti l'a-capo lo
|
|
311
|
+
// farebbe il terminale — fuori dal nostro conteggio.
|
|
312
|
+
while (termWidth(line) > width) {
|
|
313
|
+
const stop = advance(line, 0, width);
|
|
314
|
+
lines.push(line.slice(0, stop));
|
|
315
|
+
line = line.slice(stop);
|
|
316
|
+
if (lines.length === maxLines)
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
if (lines.length === maxLines)
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
if (line && lines.length < maxLines)
|
|
323
|
+
lines.push(line);
|
|
324
|
+
const kept = lines.slice(0, maxLines);
|
|
325
|
+
// Troncamento MAI silenzioso, come le liste: l'ellissi segnala che il testo
|
|
326
|
+
// continua oltre il pannello.
|
|
327
|
+
const consumed = kept.join(' ').length;
|
|
328
|
+
if (consumed < flat.length && kept.length > 0) {
|
|
329
|
+
const last = kept[kept.length - 1];
|
|
330
|
+
kept[kept.length - 1] = termWidth(last) >= width ? cut(last, width) : last + '…';
|
|
331
|
+
}
|
|
332
|
+
return kept;
|
|
333
|
+
}
|
package/package.json
CHANGED