@lamemind/loom-deck 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -0
- package/dist/cli.js +350 -2
- package/dist/search.js +250 -0
- package/dist/sessions.js +110 -1
- package/dist/viewport.js +97 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,9 +51,17 @@ senza collisioni:
|
|
|
51
51
|
| `⏎` | azione primaria del pane | Tasks → spawna la task selezionata · Sessions → riprende (`claude --resume`) la sessione selezionata |
|
|
52
52
|
| **MAIUSCOLA** | **apre un modale** | cattura tutti i tasti; `esc` annulla, non esce |
|
|
53
53
|
| minuscola | azione immediata, one-shot | |
|
|
54
|
+
| `CTRL`+lettera | idiomi universali e toggle dentro i modali di testo | `^F` = find, come ovunque |
|
|
54
55
|
| `1`…`9` | voce `launch` n-esima del progetto | da `.claude/loom-works.json` |
|
|
55
56
|
| `q` `esc` | esce dal deck | in un modale `esc` annulla soltanto |
|
|
56
57
|
|
|
58
|
+
`CTRL` è il terzo livello, aggiunto quando è arrivata la ricerca. Serve perché
|
|
59
|
+
un modale con **campi di testo** mangia ogni lettera nuda: là dentro nessun
|
|
60
|
+
comando può essere una lettera semplice. `CTRL+X` e `x` nudo condividono lo
|
|
61
|
+
stesso `input` e si distinguono solo per `key.ctrl`, quindi in modalità normale
|
|
62
|
+
il ramo `CTRL` è valutato **per primo** e chiude l'intera classe — senza,
|
|
63
|
+
`CTRL+F` cadrebbe nel ramo `f` e forkerebbe una sessione invece di cercare.
|
|
64
|
+
|
|
57
65
|
Assegnazioni correnti:
|
|
58
66
|
|
|
59
67
|
| | Tasto | Cosa fa |
|
|
@@ -62,6 +70,7 @@ Assegnazioni correnti:
|
|
|
62
70
|
| modale | `E` | edit priorità/stato della task selezionata (salva + commit) |
|
|
63
71
|
| modale | `S` | sort chain |
|
|
64
72
|
| modale | `F` | filtri |
|
|
73
|
+
| modale | `^F` | **ricerca full-text** nelle conversazioni del progetto |
|
|
65
74
|
| immediata | `f` | **forka** la sessione selezionata (solo col focus sul pane Sessions) |
|
|
66
75
|
| immediata | `t` | terminale @project-root (surface standard launch) |
|
|
67
76
|
| immediata | `c` | sessione Claude **nuda**: nessuna task, nessun prompt iniziale |
|
|
@@ -107,6 +116,57 @@ pannello di dettaglio; la sua tab Ptyxis titola `<label> · <task> · fork`.
|
|
|
107
116
|
> `codium`/`idea` non hanno più una lettera dedicata (erano `C`/`I` hardcoded):
|
|
108
117
|
> ora sono voci `launch` del file config, raggiunte per indice `1`…`9`.
|
|
109
118
|
|
|
119
|
+
### `^F` — cercare dentro le conversazioni
|
|
120
|
+
|
|
121
|
+
Il navigator trova una conversazione per **metadati** (titolo, data, turni). `^F`
|
|
122
|
+
la trova per **contenuto**: «l'IA me l'ha detto 150k di testo fa, ricordo mezza
|
|
123
|
+
parola». Claude Code non ha un find-in-conversation, ma i transcript sono tutti
|
|
124
|
+
su disco e il deck li legge già.
|
|
125
|
+
|
|
126
|
+
Due campi, `tab` per passare dall'uno all'altro:
|
|
127
|
+
|
|
128
|
+
- **hash** — prefisso del `sessionId` (gli 8 char della statusline bastano),
|
|
129
|
+
restringe a una conversazione. Vuoto = **tutte** quelle del progetto.
|
|
130
|
+
- **chiave** — il termine cercato. La ricerca è **eager**: si aggiorna a ogni
|
|
131
|
+
carattere, da 3 in su.
|
|
132
|
+
|
|
133
|
+
Sei toggle, tutti su `CTRL` perché i campi di testo occupano le lettere nude.
|
|
134
|
+
Lo stato si legge da `[x]`/`[ ]`, non dal solo colore:
|
|
135
|
+
|
|
136
|
+
| Tasto | Toggle | Default |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `^R` | la chiave è una **regex** invece che testo letterale | off |
|
|
139
|
+
| `^A` | **case-sensitive** | off |
|
|
140
|
+
| `^W` | solo **parole intere** | off |
|
|
141
|
+
| `^B` | cerca nel testo **dell'IA** | **on** |
|
|
142
|
+
| `^T` | cerca nei **tool** (`tool_use` / `tool_result`) | off |
|
|
143
|
+
| `^U` | cerca nei prompt **umani** | off |
|
|
144
|
+
|
|
145
|
+
Non esiste un toggle *thinking*: quei blocchi sono persistiti **senza testo**
|
|
146
|
+
(il transcript porta la sola firma crittografica), quindi sarebbe una casella
|
|
147
|
+
che non può mai produrre un risultato.
|
|
148
|
+
|
|
149
|
+
Con l'hash vuoto la lista è **raggruppata per conversazione**; `⏎` è contestuale
|
|
150
|
+
alla riga selezionata — su una riga-conversazione **riprende** la sessione
|
|
151
|
+
(come dal pane Sessions), su una riga-occorrenza apre il **reader**.
|
|
152
|
+
|
|
153
|
+
Il reader mostra il messaggio intero, aperto già **posizionato sull'occorrenza**
|
|
154
|
+
col match evidenziato: `↑↓` riga, `PgUp`/`PgDn` pagina, `g`/`G` estremi, `esc`
|
|
155
|
+
torna alla lista senza perdere query, toggle e selezione. (Gli estremi stanno su
|
|
156
|
+
lettera e non su `Home`/`End` perché Ink non espone quei due tasti: arrivano
|
|
157
|
+
indistinguibili da qualunque tasto ignoto.)
|
|
158
|
+
|
|
159
|
+
I toggle e la query sono **volatili**: sopravvivono alla chiusura del modale,
|
|
160
|
+
non al riavvio del deck — comporre una ricerca non tocca il disco.
|
|
161
|
+
|
|
162
|
+
**Come fa a essere istantanea.** I corpi dei messaggi restano in RAM dentro la
|
|
163
|
+
cache mtime-keyed che il deck usa già per la lista sessioni: quei file venivano
|
|
164
|
+
comunque deserializzati per turni e titoli, e i corpi buttati. Trattenerli non
|
|
165
|
+
aggiunge I/O, aggiunge memoria — e ne aggiunge poca, perché un JSONL è per l'85%
|
|
166
|
+
overhead (misurato su un progetto reale: 57 MB su disco = 9,8 MB di testo
|
|
167
|
+
cercabile). Cercarci dentro costa 1-9 ms; un prefiltro `grep` sugli stessi file
|
|
168
|
+
ne costerebbe 26, perché rileggerebbe il volume pieno a ogni battuta.
|
|
169
|
+
|
|
110
170
|
## Vista: filtri e ordinamenti
|
|
111
171
|
|
|
112
172
|
La lista è una **vista** sulla `tasks.md`: si filtra e si ordina senza toccare il file.
|
package/dist/cli.js
CHANGED
|
@@ -9,10 +9,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
10
|
import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from './tasks.js';
|
|
11
11
|
import { discoverProjectSessions } from './sessions.js';
|
|
12
|
+
import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions, selectedRow, DEFAULT_OPTIONS, MIN_QUERY, } from './search.js';
|
|
12
13
|
import { appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
13
14
|
import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, selectedSession, } from './session-list.js';
|
|
14
15
|
import { launchLegend, loadIdentity, loadLaunch } from './config.js';
|
|
15
|
-
import { layoutBudget, normalizeEmoji, windowRange, wrapLines, } from './viewport.js';
|
|
16
|
+
import { isCompact, layoutBudget, normalizeEmoji, readerCapacity, searchListCapacity, windowRange, wrapLines, wrapWithOffsets, } from './viewport.js';
|
|
16
17
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
17
18
|
import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
|
|
18
19
|
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
@@ -30,6 +31,25 @@ const SPOT = Symbol('spot');
|
|
|
30
31
|
// Modale sort a grammatica libera: un tasto per chiave, pressioni successive
|
|
31
32
|
// ciclano asc → desc → fuori dalla chain.
|
|
32
33
|
const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
|
|
34
|
+
// T52 — toggle del modale ricerca, tutti su CTRL (D2).
|
|
35
|
+
//
|
|
36
|
+
// I due campi di testo mangiano ogni lettera nuda, quindi un toggle non può
|
|
37
|
+
// essere una lettera semplice: resterebbero i caratteri della query. CTRL è
|
|
38
|
+
// l'unico livello che convive con la digitazione senza modi né navigazione.
|
|
39
|
+
//
|
|
40
|
+
// Le mnemoniche ovvie sono precluse dall'ASCII, non da una scelta di design:
|
|
41
|
+
// `^I` È il Tab (0x09) e `^H` È il Backspace (0x08) — stesso byte, nessuna
|
|
42
|
+
// distinzione possibile a valle. Quindi niente I=IA e niente H=human. Bruciati
|
|
43
|
+
// per lo stesso motivo `^M` (Enter), `^J` (LF), `^[` (Esc). Tutto il resto passa
|
|
44
|
+
// pulito, `^S`/`^Q` inclusi: il raw mode di Ink disattiva il flow-control XON/XOFF
|
|
45
|
+
// che altrimenti se li mangerebbe il terminale.
|
|
46
|
+
const SEARCH_TOGGLE_KEYS = {
|
|
47
|
+
r: 'regex',
|
|
48
|
+
a: 'caseSensitive',
|
|
49
|
+
w: 'wholeWord',
|
|
50
|
+
};
|
|
51
|
+
const SEARCH_KIND_KEYS = { b: 'ai', t: 'tool', u: 'human' };
|
|
52
|
+
const KIND_LABEL = { ai: 'IA', tool: 'tools', human: 'human' };
|
|
33
53
|
// T41 — ordine dei valori nel modale edit. Deliberatamente DIVERSO da
|
|
34
54
|
// PRI_ENTRIES/PROG_ENTRIES (che seguono il rango di sort): qui si sceglie un
|
|
35
55
|
// valore, non si ordina, quindi vince l'ordine del CICLO DI VITA — da fare →
|
|
@@ -414,6 +434,22 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
414
434
|
// T41 — bozza dell'edit (null fuori dal modale) e riga attiva della griglia.
|
|
415
435
|
const [edit, setEdit] = useState(null);
|
|
416
436
|
const [editRow, setEditRow] = useState(0);
|
|
437
|
+
// T52 — stato del modale ricerca. VOLATILE per decisione (D4): vive in
|
|
438
|
+
// memoria, quindi riaprendo il modale ritrovi query e toggle come li avevi
|
|
439
|
+
// lasciati, ma un riavvio del deck riporta ai default. Niente schema nuovo su
|
|
440
|
+
// `deck-view.json` e nessuna scrittura implicita su disco — che
|
|
441
|
+
// contraddirebbe la regola T39 «il disco si tocca solo su `w`», qui non
|
|
442
|
+
// trasportabile perché dentro il modale `w` è un carattere digitabile.
|
|
443
|
+
const [searchHash, setSearchHash] = useState('');
|
|
444
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
445
|
+
const [searchField, setSearchField] = useState('query');
|
|
446
|
+
const [searchOpts, setSearchOpts] = useState(DEFAULT_OPTIONS);
|
|
447
|
+
const [searchSelKey, setSearchSelKey] = useState(null);
|
|
448
|
+
// Occorrenza aperta nel reader; null = reader chiuso. Tenerla separata dalla
|
|
449
|
+
// selezione della lista è ciò che permette a `esc` di tornare indietro
|
|
450
|
+
// trovando la lista esattamente com'era.
|
|
451
|
+
const [readerRow, setReaderRow] = useState(null);
|
|
452
|
+
const [readerTop, setReaderTop] = useState(0);
|
|
417
453
|
// Dimensioni vive del terminale: sono l'input del budget d'altezza sotto.
|
|
418
454
|
const { rows, columns } = useTerminalSize();
|
|
419
455
|
// Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
|
|
@@ -455,6 +491,24 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
455
491
|
const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, MAX_SESSIONS), [childSessions, sessions, pinned]);
|
|
456
492
|
const sessionRows = assembled.rows;
|
|
457
493
|
const selSessionObj = selectedSession(sessionRows, selSessionId);
|
|
494
|
+
// T52 — ricerca EAGER: rigira a ogni carattere digitato, non su ⏎. È
|
|
495
|
+
// sostenibile perché i corpi sono già in RAM dentro la cache mtime-keyed
|
|
496
|
+
// dell'adapter (D5): misurato su questo progetto, 0,8 ms sui soli corpi IA e
|
|
497
|
+
// ≤9 ms su tutti i tipi — sotto il tempo fra due battute. Il memo evita di
|
|
498
|
+
// rifarla sui re-render che non toccano né query né opzioni (il poll delle
|
|
499
|
+
// sessioni ogni 1,5s è già filtrato dalla signature in `useSessions`).
|
|
500
|
+
const searchResult = useMemo(() => searchSessions(sessions, searchHash, searchQuery, searchOpts), [sessions, searchHash, searchQuery, searchOpts]);
|
|
501
|
+
// Con l'hash valorizzato la conversazione è una sola e già nominata nel campo:
|
|
502
|
+
// la riga-sessione ripeterebbe un dato costante rubando una riga per gruppo.
|
|
503
|
+
const searchFlat = searchHash.trim().length > 0;
|
|
504
|
+
const searchRows = useMemo(() => buildRows(searchResult, searchFlat), [searchResult, searchFlat]);
|
|
505
|
+
// T52 — corpo del messaggio aperto nel reader, wrappato UNA volta per (testo,
|
|
506
|
+
// larghezza). Senza memo ogni pressione di freccia rifarebbe l'a-capo di un
|
|
507
|
+
// messaggio che nella coda lunga arriva a 150k char.
|
|
508
|
+
const readerWidth = Math.max(20, (columns || 80) - 6);
|
|
509
|
+
const readerLines = useMemo(() => (readerRow?.kind === 'hit' ? wrapWithOffsets(readerRow.hit.text, readerWidth) : []), [readerRow, readerWidth]);
|
|
510
|
+
const readerCap = readerCapacity(rows);
|
|
511
|
+
const readerMaxTop = Math.max(0, readerLines.length - readerCap);
|
|
458
512
|
// T39 — selezione stabile sotto trasformazione. Se la task selezionata esce
|
|
459
513
|
// dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
|
|
460
514
|
// sulla prima visibile — fallback deterministico, mai una posizione a caso.
|
|
@@ -472,6 +526,15 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
472
526
|
setSelSessionId(firstSelectableId(sessionRows));
|
|
473
527
|
}
|
|
474
528
|
}, [sessionRows, selSessionId]);
|
|
529
|
+
// T52 — stessa invariante sulla lista occorrenze, dove è ancora più stretta:
|
|
530
|
+
// la lista si RICOSTRUISCE a ogni carattere digitato, quindi la chiave
|
|
531
|
+
// selezionata sparisce continuamente. Persa → prima riga, mai una posizione
|
|
532
|
+
// ereditata (che qui punterebbe a un'altra conversazione, non a un'altra riga).
|
|
533
|
+
useEffect(() => {
|
|
534
|
+
if (rowIndexOfKey(searchRows, searchSelKey) < 0) {
|
|
535
|
+
setSearchSelKey(firstRowKey(searchRows));
|
|
536
|
+
}
|
|
537
|
+
}, [searchRows, searchSelKey]);
|
|
475
538
|
// T30: submit dell'input box. Il taskId nasce DOPO create-task (lo assegna la
|
|
476
539
|
// skill scrivendo tasks.md) → non è noto allo spawn. Il sessionId invece è
|
|
477
540
|
// pinnato qui: snapshot degli id PRIMA, poi al completamento re-leggo tasks.md
|
|
@@ -571,7 +634,145 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
571
634
|
const next = Math.max(0, Math.min(viewTasks.length, selIndex + delta));
|
|
572
635
|
setSelId(next === 0 ? null : viewTasks[next - 1]?.id ?? null);
|
|
573
636
|
}
|
|
637
|
+
// T52 — `⏎` contestuale al TIPO di riga: la lista ne mescola due e l'azione
|
|
638
|
+
// giusta dipende da quale è selezionata. Riga sessione → resume, identico al
|
|
639
|
+
// `⏎` della lista sessioni (stessa funzione, T49). Riga occorrenza → reader.
|
|
640
|
+
function submitSearchRow() {
|
|
641
|
+
const row = selectedRow(searchRows, searchSelKey);
|
|
642
|
+
if (!row) {
|
|
643
|
+
setNote(searchResult.error ? '⚠ regex non valida' : 'nessuna occorrenza selezionata');
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (row.kind === 'session') {
|
|
647
|
+
const bound = bindings.get(row.session.sessionId) ?? null;
|
|
648
|
+
const child = spawnDeckResume(bound, cwd, row.session.sessionId);
|
|
649
|
+
child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
|
|
650
|
+
setNote(`⏎ resume ${row.session.sessionId.slice(0, 8)} → tab CC${bound ? ` (${bound})` : ' (spot)'}`);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
// D8 — il reader si apre POSIZIONATO sull'occorrenza, non in cima. Il 94%
|
|
654
|
+
// dei messaggi entra in una schermata e la differenza non si vede; è sul 6%
|
|
655
|
+
// lungo (p99 ≈ 88 righe, max ≈ 1547) che aprire in cima costringerebbe a
|
|
656
|
+
// rifare a mano la ricerca appena fatta — cioè proprio il lavoro che questa
|
|
657
|
+
// feature esiste per evitare.
|
|
658
|
+
const lines = wrapWithOffsets(row.hit.text, readerWidth);
|
|
659
|
+
const cap = readerCapacity(rows);
|
|
660
|
+
const matchLine = lines.findIndex((l) => l.end > row.hit.matchStart);
|
|
661
|
+
const maxTop = Math.max(0, lines.length - cap);
|
|
662
|
+
setReaderRow(row);
|
|
663
|
+
setReaderTop(Math.max(0, Math.min(maxTop, Math.max(0, matchLine) - Math.floor(cap / 2))));
|
|
664
|
+
setNote('');
|
|
665
|
+
setMode('reader');
|
|
666
|
+
}
|
|
667
|
+
function scrollReader(delta) {
|
|
668
|
+
setReaderTop((t) => Math.max(0, Math.min(readerMaxTop, t + delta)));
|
|
669
|
+
}
|
|
670
|
+
// T52 — toggle di tipo messaggio. Spegnerli tutti è uno stato lecito (lista
|
|
671
|
+
// vuota, nessun errore): è l'utente che ha chiuso ogni canale, non un guasto.
|
|
672
|
+
function toggleKind(kind) {
|
|
673
|
+
setSearchOpts((o) => ({ ...o, kinds: { ...o.kinds, [kind]: !o.kinds[kind] } }));
|
|
674
|
+
}
|
|
675
|
+
function editSearchField(fn) {
|
|
676
|
+
if (searchField === 'hash')
|
|
677
|
+
setSearchHash(fn);
|
|
678
|
+
else
|
|
679
|
+
setSearchQuery(fn);
|
|
680
|
+
}
|
|
681
|
+
// `useInput` consegna il CHUNK letto da stdin, non un tasto: un incollaggio —
|
|
682
|
+
// o una raffica di tasti più veloce di una read — arriva come stringa unica,
|
|
683
|
+
// byte di controllo compresi. Senza filtro finiscono DENTRO la query: non si
|
|
684
|
+
// vedono, ma Ink li conta nella larghezza della riga e nessun match li
|
|
685
|
+
// soddisfa, quindi la ricerca smette di trovare senza dire perché.
|
|
686
|
+
// Le newline diventano spazio invece di sparire: incollare due righe deve
|
|
687
|
+
// separare le parole, non fonderle.
|
|
688
|
+
function typeIntoField(chunk) {
|
|
689
|
+
const clean = chunk.replace(/[\r\n\t]/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '');
|
|
690
|
+
if (clean)
|
|
691
|
+
editSearchField((s) => s + clean);
|
|
692
|
+
}
|
|
693
|
+
const searchCap = searchListCapacity(rows, Boolean(note));
|
|
574
694
|
useInput((input, key) => {
|
|
695
|
+
// T52 — MODALE DENTRO IL MODALE. `useInput` in Ink è globale e non ha
|
|
696
|
+
// focus-trap: non esiste un meccanismo che confini l'input a un componente.
|
|
697
|
+
// La cattura È l'ordine dei rami — quindi il reader va PRIMA della ricerca,
|
|
698
|
+
// e mentre è aperto il modale ricerca resta montato sotto con la sua
|
|
699
|
+
// selezione intatta, pronto a riprendere il controllo su `esc`.
|
|
700
|
+
if (mode === 'reader') {
|
|
701
|
+
if (key.escape) {
|
|
702
|
+
setMode('search');
|
|
703
|
+
setReaderRow(null);
|
|
704
|
+
}
|
|
705
|
+
else if (key.upArrow) {
|
|
706
|
+
scrollReader(-1);
|
|
707
|
+
}
|
|
708
|
+
else if (key.downArrow) {
|
|
709
|
+
scrollReader(1);
|
|
710
|
+
}
|
|
711
|
+
else if (key.pageUp) {
|
|
712
|
+
scrollReader(-readerCap);
|
|
713
|
+
}
|
|
714
|
+
else if (key.pageDown) {
|
|
715
|
+
scrollReader(readerCap);
|
|
716
|
+
}
|
|
717
|
+
else if (input === 'g') {
|
|
718
|
+
// Estremi su lettera e non su Home/End: Ink RICONOSCE quelle due (le
|
|
719
|
+
// mappa a 'home'/'end' nel parser) ma NON le espone — `nonAlphanumericKeys`
|
|
720
|
+
// azzera l'input e nessun flag le rappresenta, quindi arrivano
|
|
721
|
+
// indistinguibili da qualunque tasto ignoto. Verificato su pty reale.
|
|
722
|
+
// Nel reader non c'è input di testo, quindi le lettere sono libere.
|
|
723
|
+
setReaderTop(0);
|
|
724
|
+
}
|
|
725
|
+
else if (input === 'G') {
|
|
726
|
+
setReaderTop(readerMaxTop);
|
|
727
|
+
}
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
// T52 — modale ricerca. I due campi di testo mangiano ogni lettera nuda:
|
|
731
|
+
// ogni comando che deve restare vivo MENTRE si digita passa da CTRL, dai
|
|
732
|
+
// tasti freccia o da Tab. `esc` chiude il modale, non il deck.
|
|
733
|
+
if (mode === 'search') {
|
|
734
|
+
if (key.escape) {
|
|
735
|
+
setMode('normal');
|
|
736
|
+
setNote('');
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (key.ctrl) {
|
|
740
|
+
const flag = SEARCH_TOGGLE_KEYS[input];
|
|
741
|
+
if (flag) {
|
|
742
|
+
setSearchOpts((o) => ({ ...o, [flag]: !o[flag] }));
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const kind = SEARCH_KIND_KEYS[input];
|
|
746
|
+
if (kind)
|
|
747
|
+
toggleKind(kind);
|
|
748
|
+
return; // ogni altra combo ctrl (^F incluso: siamo già dentro) = no-op
|
|
749
|
+
}
|
|
750
|
+
if (key.tab) {
|
|
751
|
+
setSearchField((f) => (f === 'hash' ? 'query' : 'hash'));
|
|
752
|
+
}
|
|
753
|
+
else if (key.upArrow) {
|
|
754
|
+
setSearchSelKey((k) => moveRowSelection(searchRows, k, -1));
|
|
755
|
+
}
|
|
756
|
+
else if (key.downArrow) {
|
|
757
|
+
setSearchSelKey((k) => moveRowSelection(searchRows, k, 1));
|
|
758
|
+
}
|
|
759
|
+
else if (key.pageUp) {
|
|
760
|
+
setSearchSelKey((k) => moveRowSelection(searchRows, k, -Math.max(1, searchCap)));
|
|
761
|
+
}
|
|
762
|
+
else if (key.pageDown) {
|
|
763
|
+
setSearchSelKey((k) => moveRowSelection(searchRows, k, Math.max(1, searchCap)));
|
|
764
|
+
}
|
|
765
|
+
else if (key.return) {
|
|
766
|
+
submitSearchRow();
|
|
767
|
+
}
|
|
768
|
+
else if (key.backspace || key.delete) {
|
|
769
|
+
editSearchField((s) => s.slice(0, -1));
|
|
770
|
+
}
|
|
771
|
+
else if (input && !key.meta) {
|
|
772
|
+
typeIntoField(input);
|
|
773
|
+
}
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
575
776
|
// T30: in modalità create l'handler cattura il testo e corto-circuita la
|
|
576
777
|
// navigazione normale (incl. q/esc → quit: qui esc annulla, non esce).
|
|
577
778
|
if (mode === 'create') {
|
|
@@ -688,6 +889,21 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
688
889
|
}
|
|
689
890
|
return;
|
|
690
891
|
}
|
|
892
|
+
// T52/D1 — il ramo CTRL sta PRIMA di quelli su lettera nuda e li chiude
|
|
893
|
+
// tutti. `CTRL+F` e `f` nudo arrivano con lo STESSO `input` ('f'),
|
|
894
|
+
// distinguibili solo da `key.ctrl`: senza questa precedenza `CTRL+F`
|
|
895
|
+
// cadrebbe nel ramo fork e spawnerebbe una sessione invece di aprire la
|
|
896
|
+
// ricerca. Non è un caso isolato della `f` — `^Q` finirebbe nel quit, `^T`
|
|
897
|
+
// aprirebbe un terminale, `^C` … ogni lettera legata a un'azione ha la sua
|
|
898
|
+
// combo omonima. Chiudere qui l'intera classe è più solido che ricordarsi
|
|
899
|
+
// un `!key.ctrl` su ognuno dei rami, oggi e a ogni tasto aggiunto domani.
|
|
900
|
+
if (key.ctrl) {
|
|
901
|
+
if (input === 'f') {
|
|
902
|
+
setNote('');
|
|
903
|
+
setMode('search');
|
|
904
|
+
}
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
691
907
|
if (key.leftArrow || key.rightArrow || key.tab) {
|
|
692
908
|
setFocus((f) => (f === 'tasks' ? 'sessions' : 'tasks'));
|
|
693
909
|
}
|
|
@@ -856,6 +1072,31 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
856
1072
|
// Larghezza dal medesimo hook che dà l'altezza: dopo un resize la legenda si
|
|
857
1073
|
// ricalcola con lo stesso re-render che ridimensiona i pane.
|
|
858
1074
|
const legend = launchLegend(launch, columns);
|
|
1075
|
+
// ── T52 · schermate sostitutive ─────────────────────────────────────────
|
|
1076
|
+
// Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
|
|
1077
|
+
// pane: una lista di occorrenze non entra in un box da 4 righe. Prendono
|
|
1078
|
+
// l'intero frame, quindi escono di qui — il budget dei due pane sotto non
|
|
1079
|
+
// serve nemmeno calcolarlo, e la loro altezza la distribuiscono
|
|
1080
|
+
// `searchListCapacity` / `readerCapacity`.
|
|
1081
|
+
if (mode === 'search' || mode === 'reader') {
|
|
1082
|
+
const hit = mode === 'reader' && readerRow?.kind === 'hit' ? readerRow.hit : null;
|
|
1083
|
+
// Terminale sotto la cornice: riga singola invece del box, per lo stesso
|
|
1084
|
+
// motivo del `budget.compact` del deck — un frame più alto di `rows` fa
|
|
1085
|
+
// pulire lo schermo a Ink a ogni redraw, e il poll lo versa nello scrollback.
|
|
1086
|
+
if (isCompact(hit ? readerCap : searchCap)) {
|
|
1087
|
+
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'] })] }));
|
|
1088
|
+
}
|
|
1089
|
+
if (hit) {
|
|
1090
|
+
// Niente `windowRange`: quella centra la finestra su una SELEZIONE, qui
|
|
1091
|
+
// la posizione è lo scroll che l'utente muove a mano. Il clamp serve
|
|
1092
|
+
// comunque — un resize può accorciare il testo sotto uno scroll già dato.
|
|
1093
|
+
const start = Math.min(readerTop, readerMaxTop);
|
|
1094
|
+
return (_jsx(ReaderScreen, { hit: hit, lines: readerLines.slice(start, start + readerCap), top: start, total: readerLines.length, capacity: readerCap, bound: bindings.get(hit.sessionId) ?? null }));
|
|
1095
|
+
}
|
|
1096
|
+
const selIdx = rowIndexOfKey(searchRows, searchSelKey);
|
|
1097
|
+
const win = windowRange(searchRows.length, selIdx, searchCap);
|
|
1098
|
+
return (_jsx(SearchScreen, { 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, projectCore: identity ? `${identity.owner} ${identity.name}` : null, note: note }));
|
|
1099
|
+
}
|
|
859
1100
|
// ── Budget d'altezza ────────────────────────────────────────────────────
|
|
860
1101
|
// Il frame deve restare sotto `rows`, sempre: oltre quella soglia Ink smette
|
|
861
1102
|
// di aggiornare per differenza e pulisce lo schermo a ogni redraw, che su
|
|
@@ -894,7 +1135,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
894
1135
|
if (budget.compact) {
|
|
895
1136
|
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"] })] }));
|
|
896
1137
|
}
|
|
897
|
-
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 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: normalizeEmoji(note) }) : null] }));
|
|
1138
|
+
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: normalizeEmoji(note) }) : null] }));
|
|
898
1139
|
}
|
|
899
1140
|
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
900
1141
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
@@ -924,6 +1165,113 @@ function EditModal({ id, draft, row }) {
|
|
|
924
1165
|
const mark = (r) => (row === r ? CARET : CARET_OFF);
|
|
925
1166
|
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: [' ', forceEmojiWidth(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: [' ', forceEmojiWidth(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 ", normalizeEmoji(progressText(draft.prog, draft.detail))] })] }));
|
|
926
1167
|
}
|
|
1168
|
+
// T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
|
|
1169
|
+
// caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
|
|
1170
|
+
// allinearsi, e i glifi BMP larghi 2 sono proprio la classe che Ink e il
|
|
1171
|
+
// terminale misurano diversamente (vedi normalizeEmoji).
|
|
1172
|
+
const KIND_TAG = { ai: 'ai', tool: 'tl', human: 'hu' };
|
|
1173
|
+
const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
|
|
1174
|
+
/**
|
|
1175
|
+
* Cosa scrivere sulla riga-gruppo per distinguere una conversazione dall'altra.
|
|
1176
|
+
*
|
|
1177
|
+
* Non basta `session.title`: quel titolo è la label della tab Ptyxis, cioè
|
|
1178
|
+
* `<emoji> <owner> <name>` più un eventuale suffisso. Su una lista tutta dello
|
|
1179
|
+
* stesso progetto (D3) è una COLONNA COSTANTE — tre righe su quattro
|
|
1180
|
+
* identiche, che è esattamente il difetto che la riga-gruppo doveva evitare.
|
|
1181
|
+
*
|
|
1182
|
+
* Si toglie quindi il core `<owner> <name>` (noto dal file config), e se ciò
|
|
1183
|
+
* che resta è vuoto o duplica la task già mostrata nella sua colonna, si
|
|
1184
|
+
* ripiega sul primo prompt — l'unica cosa che davvero identifica quella
|
|
1185
|
+
* conversazione e non un'altra.
|
|
1186
|
+
*/
|
|
1187
|
+
function conversationLabel(s, core, bound) {
|
|
1188
|
+
let t = s.title;
|
|
1189
|
+
if (core) {
|
|
1190
|
+
const at = t.indexOf(core);
|
|
1191
|
+
if (at >= 0)
|
|
1192
|
+
t = t.slice(at + core.length);
|
|
1193
|
+
}
|
|
1194
|
+
t = t.replace(/^[\s·]+/, '').trim();
|
|
1195
|
+
if (bound && t === bound)
|
|
1196
|
+
t = '';
|
|
1197
|
+
return t || s.firstPrompt || '(senza titolo)';
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Riga di toggle del modale ricerca.
|
|
1201
|
+
*
|
|
1202
|
+
* La mappa tasto→significato è SEMPRE a schermo: `^R` da solo è opaco quanto lo
|
|
1203
|
+
* era il range `1-9` delle launch prima di T43.
|
|
1204
|
+
*
|
|
1205
|
+
* Lo stato acceso/spento passa da `[x]`/`[ ]`, non dal solo colore — stessa
|
|
1206
|
+
* convenzione del modale filtri. Il colore è ridondanza, non l'informazione: su
|
|
1207
|
+
* un terminale monocromo, o in una cattura di testo, sei toggle tutti uguali
|
|
1208
|
+
* non direbbero più quali sono attivi.
|
|
1209
|
+
*/
|
|
1210
|
+
function ToggleHint({ opts }) {
|
|
1211
|
+
const flag = (on, key, label) => (_jsxs(Text, { color: on ? 'green' : 'gray', dimColor: !on, bold: on, children: [' ', key, "[", on ? 'x' : ' ', "] ", label] }, key));
|
|
1212
|
+
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')] }));
|
|
1213
|
+
}
|
|
1214
|
+
/** Intestazione della lista: dice sempre quanto NON si sta vedendo (regex rotta,
|
|
1215
|
+
* query troppo corta, occorrenze tagliate dal cap, righe fuori finestra). */
|
|
1216
|
+
function SearchListHeader({ result, query, above, below, }) {
|
|
1217
|
+
if (result.error) {
|
|
1218
|
+
return (_jsxs(Text, { color: "red", wrap: "truncate-end", children: [WARN, " regex non valida \u00B7 ", result.error] }));
|
|
1219
|
+
}
|
|
1220
|
+
if (result.idle) {
|
|
1221
|
+
return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: query.length === 0
|
|
1222
|
+
? 'digita la chiave da cercare'
|
|
1223
|
+
: `almeno ${MIN_QUERY} caratteri (${query.length})` }));
|
|
1224
|
+
}
|
|
1225
|
+
if (result.shown === 0) {
|
|
1226
|
+
return (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: "nessuna occorrenza" }));
|
|
1227
|
+
}
|
|
1228
|
+
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] }));
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Schermata di ricerca full-text (T52).
|
|
1232
|
+
*
|
|
1233
|
+
* Due campi + toggle + lista di occorrenze. Con l'hash vuoto la lista è
|
|
1234
|
+
* raggruppata per conversazione: la riga-gruppo NON ripete il nome del progetto
|
|
1235
|
+
* (D3: sono tutte dello stesso progetto, sarebbe una colonna costante) e usa lo
|
|
1236
|
+
* spazio per ciò che distingue davvero una conversazione — hash, task legata,
|
|
1237
|
+
* titolo, data.
|
|
1238
|
+
*/
|
|
1239
|
+
function SearchScreen({ hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, projectCore, note, }) {
|
|
1240
|
+
const enter = selectedKind === 'session' ? 'resume' : selectedKind === 'hit' ? 'leggi' : '—';
|
|
1241
|
+
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) => {
|
|
1242
|
+
const sel = row.key === selectedKey;
|
|
1243
|
+
if (row.kind === 'session') {
|
|
1244
|
+
const s = row.session;
|
|
1245
|
+
const bound = bindings.get(s.sessionId);
|
|
1246
|
+
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 " }), truncate(conversationLabel(s, projectCore, bound), 44), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
|
|
1247
|
+
}
|
|
1248
|
+
const h = row.hit;
|
|
1249
|
+
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] }), " ", h.excerpt] }, row.key));
|
|
1250
|
+
})] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Reader fullscreen (T52 · D8).
|
|
1254
|
+
*
|
|
1255
|
+
* Mostra il messaggio INTERO che contiene l'occorrenza, aperto già posizionato
|
|
1256
|
+
* sul match e con il match evidenziato. È un `mode` a sé, catturato prima del
|
|
1257
|
+
* ramo `search`: il modale ricerca resta montato sotto e su `esc` si ritrova
|
|
1258
|
+
* con query, toggle e selezione intatti.
|
|
1259
|
+
*/
|
|
1260
|
+
function ReaderScreen({ hit, lines, top, total, capacity, bound, }) {
|
|
1261
|
+
const last = Math.min(total, top + capacity);
|
|
1262
|
+
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, from: hit.matchStart, to: hit.matchEnd }, top + i))) })] }));
|
|
1263
|
+
}
|
|
1264
|
+
/** Una riga del reader, con la sola porzione di match evidenziata. Gli offset
|
|
1265
|
+
* sono quelli del testo sorgente, quindi un match a cavallo dell'a-capo si
|
|
1266
|
+
* colora su entrambe le righe senza casi speciali. */
|
|
1267
|
+
function ReaderLine({ line, from, to }) {
|
|
1268
|
+
const a = Math.max(0, Math.min(line.text.length, from - line.start));
|
|
1269
|
+
const b = Math.max(0, Math.min(line.text.length, to - line.start));
|
|
1270
|
+
if (b <= a) {
|
|
1271
|
+
return _jsx(Text, { wrap: "truncate-end", children: line.text || ' ' });
|
|
1272
|
+
}
|
|
1273
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [line.text.slice(0, a), _jsx(Text, { backgroundColor: "yellow", color: "black", children: line.text.slice(a, b) }), line.text.slice(b)] }));
|
|
1274
|
+
}
|
|
927
1275
|
function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, }) {
|
|
928
1276
|
const spotSelected = selected === 0;
|
|
929
1277
|
return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Tasks (", hidden > 0 ? `${filtered}/${total}` : filtered, ")", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 ", hidden, " nascoste"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort: ", describeSort(view.sort), view.hiddenPri.length + view.hiddenProg.length > 0 ? (_jsxs(Text, { children: [' ', "\u00B7 filtri:", ' ', [
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// T52 — Core della ricerca full-text nelle conversazioni del progetto.
|
|
2
|
+
//
|
|
3
|
+
// Modulo PURO, gemello di `session-list.ts`: nessun import da ink/react, nessun
|
|
4
|
+
// I/O, nessuno stato globale. Prende in ingresso le `Session` già parsate
|
|
5
|
+
// dall'adapter (con i `bodies` trattenuti dalla cache mtime-keyed) e restituisce
|
|
6
|
+
// le righe da renderizzare. Tutto ciò che è testabile senza terminale sta qui.
|
|
7
|
+
//
|
|
8
|
+
// Invarianti (decisioni congelate al preflight):
|
|
9
|
+
// - D3: perimetro = SOLO le sessioni del progetto corrente. Il modulo cerca su
|
|
10
|
+
// ciò che gli viene passato, e chi lo chiama passa `discoverProjectSessions`.
|
|
11
|
+
// - D5: ricerca IN MEMORIA sui corpi, nessun prefiltro esterno. Un solo motore
|
|
12
|
+
// di regex (`RegExp` JS) sia in modalità literal sia in `^R` → non esiste il
|
|
13
|
+
// caso "il prefiltro scarta un file che contiene un'occorrenza reale".
|
|
14
|
+
// - D6: tre kind (ai · tool · human). Il `thinking` non è persistito.
|
|
15
|
+
// - D7: la riga-occorrenza porta l'indice del record; la data sta una volta
|
|
16
|
+
// sola sulla riga-sessione.
|
|
17
|
+
// - Trap T39/T50: la selezione è keyed su una CHIAVE DISCRIMINATA, mai su
|
|
18
|
+
// indice posizionale — la lista si riordina a ogni carattere digitato.
|
|
19
|
+
/** Sotto questa soglia non si cerca: 1-2 char matchano ovunque e la lista
|
|
20
|
+
* sarebbe rumore. Stato lecito, non un errore. */
|
|
21
|
+
export const MIN_QUERY = 3;
|
|
22
|
+
/** Larghezza nominale dell'estratto attorno al match. */
|
|
23
|
+
export const EXCERPT_WIDTH = 50;
|
|
24
|
+
/** Cap per singola conversazione. Serve a tenere leggibile un gruppo, non a
|
|
25
|
+
* proteggere la memoria: una query corta può matchare centinaia di volte nello
|
|
26
|
+
* stesso transcript e sommergere tutte le altre conversazioni. */
|
|
27
|
+
export const MAX_HITS_PER_SESSION = 20;
|
|
28
|
+
/** Cap globale. Oltre, la lista non è più navigabile a mano: la risposta giusta
|
|
29
|
+
* è restringere la query, e il contatore delle nascoste lo dice. */
|
|
30
|
+
export const MAX_TOTAL_HITS = 400;
|
|
31
|
+
export const DEFAULT_OPTIONS = {
|
|
32
|
+
regex: false,
|
|
33
|
+
caseSensitive: false,
|
|
34
|
+
wholeWord: false,
|
|
35
|
+
// D4/§Filtro: il testo IA è il caso d'uso che ha generato la task («l'IA me
|
|
36
|
+
// l'ha detto 150k fa»); tools e human si accendono a mano.
|
|
37
|
+
kinds: { ai: true, tool: false, human: false },
|
|
38
|
+
};
|
|
39
|
+
const EMPTY = {
|
|
40
|
+
groups: [],
|
|
41
|
+
shown: 0,
|
|
42
|
+
hidden: 0,
|
|
43
|
+
sessionCount: 0,
|
|
44
|
+
error: '',
|
|
45
|
+
idle: true,
|
|
46
|
+
};
|
|
47
|
+
function escapeLiteral(s) {
|
|
48
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Compila la query nel matcher.
|
|
52
|
+
*
|
|
53
|
+
* Ritorna `{re: null, error}` invece di lanciare: con la ricerca eager la regex
|
|
54
|
+
* viene ricompilata a ogni carattere, e uno stato intermedio invalido (`[a-`,
|
|
55
|
+
* `(`) è la NORMA durante la digitazione, non un caso limite. Deve degradare a
|
|
56
|
+
* lista vuota con segnalazione inline, mai a un crash della TUI.
|
|
57
|
+
*/
|
|
58
|
+
export function buildMatcher(query, opts) {
|
|
59
|
+
if (query.length < MIN_QUERY)
|
|
60
|
+
return { re: null, error: '' };
|
|
61
|
+
let source = opts.regex ? query : escapeLiteral(query);
|
|
62
|
+
// Il gruppo non-catturante è obbligatorio: senza, `\b` si legherebbe al solo
|
|
63
|
+
// primo/ultimo ramo di un'alternativa (`\bfoo|bar\b` ≠ `\b(?:foo|bar)\b`).
|
|
64
|
+
if (opts.wholeWord)
|
|
65
|
+
source = `\\b(?:${source})\\b`;
|
|
66
|
+
const flags = opts.caseSensitive ? 'g' : 'gi';
|
|
67
|
+
try {
|
|
68
|
+
return { re: new RegExp(source, flags), error: '' };
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
return { re: null, error: e.message };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Estratto centrato sul match.
|
|
76
|
+
*
|
|
77
|
+
* Il testo è RAW (newline incluse) e gli offset sono suoi: si affetta prima e si
|
|
78
|
+
* collassa il whitespace dopo, su ciascun pezzo separatamente. Collassare prima
|
|
79
|
+
* sposterebbe gli offset; collassare l'intera finestra insieme fonderebbe i
|
|
80
|
+
* confini del match con il contesto.
|
|
81
|
+
*
|
|
82
|
+
* Degrada ai bordi: match a inizio o fine messaggio prende tutto il contesto
|
|
83
|
+
* disponibile dal lato che ce l'ha, invece di lasciare metà estratto vuoto.
|
|
84
|
+
*/
|
|
85
|
+
export function excerptAround(text, start, end, width = EXCERPT_WIDTH) {
|
|
86
|
+
const collapse = (s) => s.replace(/\s+/g, ' ');
|
|
87
|
+
// Un match più lungo dell'estratto: si tronca il match stesso, altrimenti la
|
|
88
|
+
// riga andrebbe a capo e sfonderebbe il budget d'altezza della lista.
|
|
89
|
+
if (end - start >= width) {
|
|
90
|
+
return collapse(text.slice(start, start + width - 1)).trimEnd() + '…';
|
|
91
|
+
}
|
|
92
|
+
const budget = width - (end - start);
|
|
93
|
+
// Metà per lato, poi il lato che non riesce a spendere la sua quota (match a
|
|
94
|
+
// ridosso di un bordo) la cede all'altro.
|
|
95
|
+
let before = Math.floor(budget / 2);
|
|
96
|
+
let after = budget - before;
|
|
97
|
+
const availBefore = start;
|
|
98
|
+
const availAfter = text.length - end;
|
|
99
|
+
if (before > availBefore) {
|
|
100
|
+
after += before - availBefore;
|
|
101
|
+
before = availBefore;
|
|
102
|
+
}
|
|
103
|
+
if (after > availAfter) {
|
|
104
|
+
before = Math.min(availBefore, before + (after - availAfter));
|
|
105
|
+
after = availAfter;
|
|
106
|
+
}
|
|
107
|
+
const from = start - before;
|
|
108
|
+
const to = end + after;
|
|
109
|
+
const head = collapse(text.slice(from, start));
|
|
110
|
+
const mid = collapse(text.slice(start, end));
|
|
111
|
+
const tail = collapse(text.slice(end, to));
|
|
112
|
+
return (from > 0 ? '…' : '') + head + mid + tail + (to < text.length ? '…' : '');
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Scansiona un corpo: materializza al più `need` occorrenze, ma le CONTA tutte.
|
|
116
|
+
*
|
|
117
|
+
* I due numeri sono separati apposta. Fermare anche il conteggio al cap
|
|
118
|
+
* renderebbe silenziosa proprio la troncatura che il contatore deve dichiarare
|
|
119
|
+
* («+N occorrenze»), e il conteggio è la parte economica — costruire un Hit
|
|
120
|
+
* significa costruire un estratto, scorrere la stringa no.
|
|
121
|
+
*
|
|
122
|
+
* `re` deve avere il flag `g` (lo garantisce buildMatcher).
|
|
123
|
+
*/
|
|
124
|
+
function scanBody(sessionId, body, re, need) {
|
|
125
|
+
const hits = [];
|
|
126
|
+
let count = 0;
|
|
127
|
+
re.lastIndex = 0;
|
|
128
|
+
let m;
|
|
129
|
+
while ((m = re.exec(body.text)) !== null) {
|
|
130
|
+
count++;
|
|
131
|
+
if (hits.length < need) {
|
|
132
|
+
const start = m.index;
|
|
133
|
+
const end = start + m[0].length;
|
|
134
|
+
hits.push({
|
|
135
|
+
sessionId,
|
|
136
|
+
idx: body.idx,
|
|
137
|
+
kind: body.kind,
|
|
138
|
+
excerpt: excerptAround(body.text, start, end),
|
|
139
|
+
matchStart: start,
|
|
140
|
+
matchEnd: end,
|
|
141
|
+
text: body.text,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// Una regex può matchare la stringa vuota (`a*`, `^`): senza avanzare a
|
|
145
|
+
// mano, `exec` resta fermo sullo stesso indice e il ciclo non termina mai.
|
|
146
|
+
if (m[0].length === 0)
|
|
147
|
+
re.lastIndex++;
|
|
148
|
+
}
|
|
149
|
+
return { hits, count };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Cerca su tutte le sessioni passate.
|
|
153
|
+
*
|
|
154
|
+
* `hashPrefix` restringe a una conversazione per PREFISSO del sessionId, non per
|
|
155
|
+
* uguaglianza: gli 8 char che la statusline mostra bastano a individuarla, e
|
|
156
|
+
* nessuno copia un uuid intero a mano.
|
|
157
|
+
*/
|
|
158
|
+
export function searchSessions(sessions, hashPrefix, query, opts) {
|
|
159
|
+
const prefix = hashPrefix.trim().toLowerCase();
|
|
160
|
+
const scoped = prefix
|
|
161
|
+
? sessions.filter((s) => s.sessionId.toLowerCase().startsWith(prefix))
|
|
162
|
+
: sessions;
|
|
163
|
+
const { re, error } = buildMatcher(query, opts);
|
|
164
|
+
if (error)
|
|
165
|
+
return { ...EMPTY, idle: false, error };
|
|
166
|
+
if (!re)
|
|
167
|
+
return EMPTY;
|
|
168
|
+
const groups = [];
|
|
169
|
+
let shown = 0;
|
|
170
|
+
let hidden = 0;
|
|
171
|
+
for (const session of scoped) {
|
|
172
|
+
const hits = [];
|
|
173
|
+
let found = 0;
|
|
174
|
+
for (const body of session.bodies) {
|
|
175
|
+
if (!opts.kinds[body.kind])
|
|
176
|
+
continue;
|
|
177
|
+
// Quanto ancora si può MATERIALIZZARE: il minore fra spazio nel gruppo e
|
|
178
|
+
// spazio nella lista. A zero la scansione prosegue lo stesso — serve il
|
|
179
|
+
// conteggio, che è ciò che alimenta «+N occorrenze».
|
|
180
|
+
const need = Math.max(0, Math.min(MAX_HITS_PER_SESSION - hits.length, MAX_TOTAL_HITS - shown - hits.length));
|
|
181
|
+
const r = scanBody(session.sessionId, body, re, need);
|
|
182
|
+
found += r.count;
|
|
183
|
+
hits.push(...r.hits);
|
|
184
|
+
}
|
|
185
|
+
if (found === 0)
|
|
186
|
+
continue;
|
|
187
|
+
hidden += found - hits.length;
|
|
188
|
+
if (hits.length === 0)
|
|
189
|
+
continue; // tutte tagliate dal cap globale
|
|
190
|
+
groups.push({ session, hits, hidden: found - hits.length });
|
|
191
|
+
shown += hits.length;
|
|
192
|
+
}
|
|
193
|
+
return { groups, shown, hidden, sessionCount: groups.length, error: '', idle: false };
|
|
194
|
+
}
|
|
195
|
+
/** Chiave discriminata di una riga-occorrenza. `matchStart` è nella chiave
|
|
196
|
+
* perché due match nello stesso record sono righe distinte. */
|
|
197
|
+
function hitKey(h) {
|
|
198
|
+
return `hit:${h.sessionId}:${h.idx}:${h.kind}:${h.matchStart}`;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Appiattisce i gruppi in righe.
|
|
202
|
+
*
|
|
203
|
+
* Con `hashPrefix` valorizzato la lista è PIATTA: la conversazione è una sola e
|
|
204
|
+
* già nominata nel campo hash, quindi la riga-sessione ripeterebbe un dato
|
|
205
|
+
* costante rubando una riga per gruppo.
|
|
206
|
+
*/
|
|
207
|
+
export function buildRows(result, flat) {
|
|
208
|
+
const rows = [];
|
|
209
|
+
for (const g of result.groups) {
|
|
210
|
+
if (!flat) {
|
|
211
|
+
rows.push({
|
|
212
|
+
kind: 'session',
|
|
213
|
+
key: `sess:${g.session.sessionId}`,
|
|
214
|
+
session: g.session,
|
|
215
|
+
hitCount: g.hits.length,
|
|
216
|
+
hidden: g.hidden,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
for (const h of g.hits)
|
|
220
|
+
rows.push({ kind: 'hit', key: hitKey(h), hit: h });
|
|
221
|
+
}
|
|
222
|
+
return rows;
|
|
223
|
+
}
|
|
224
|
+
/** Prima riga selezionabile; null = lista vuota. Ogni riga è selezionabile —
|
|
225
|
+
* non esiste un separatore, a differenza di `session-list.ts`. */
|
|
226
|
+
export function firstRowKey(rows) {
|
|
227
|
+
return rows[0]?.key ?? null;
|
|
228
|
+
}
|
|
229
|
+
/** Indice della riga con quella chiave; -1 se assente. */
|
|
230
|
+
export function rowIndexOfKey(rows, key) {
|
|
231
|
+
if (key === null)
|
|
232
|
+
return -1;
|
|
233
|
+
return rows.findIndex((r) => r.key === key);
|
|
234
|
+
}
|
|
235
|
+
/** Sposta la selezione di `delta` righe. Chiave persa (la lista è cambiata sotto)
|
|
236
|
+
* → prima riga; lista vuota → null. */
|
|
237
|
+
export function moveRowSelection(rows, currentKey, delta) {
|
|
238
|
+
if (rows.length === 0)
|
|
239
|
+
return null;
|
|
240
|
+
const cur = rowIndexOfKey(rows, currentKey);
|
|
241
|
+
if (cur < 0)
|
|
242
|
+
return rows[0].key;
|
|
243
|
+
return rows[Math.max(0, Math.min(rows.length - 1, cur + delta))].key;
|
|
244
|
+
}
|
|
245
|
+
/** Riga selezionata, o null. */
|
|
246
|
+
export function selectedRow(rows, key) {
|
|
247
|
+
if (key === null)
|
|
248
|
+
return null;
|
|
249
|
+
return rows.find((r) => r.key === key) ?? null;
|
|
250
|
+
}
|
package/dist/sessions.js
CHANGED
|
@@ -45,6 +45,91 @@ function extractText(message) {
|
|
|
45
45
|
}
|
|
46
46
|
return '';
|
|
47
47
|
}
|
|
48
|
+
// Un record `type:user` che porta SOLO questo testo è un evento di UI (l'utente
|
|
49
|
+
// ha premuto esc), non un messaggio scritto. Stesso `type` di un prompt vero:
|
|
50
|
+
// senza escluderlo comparirebbe come «primo prompt» della conversazione e
|
|
51
|
+
// gonfierebbe la ricerca sui messaggi umani. Prefisso e non uguaglianza —
|
|
52
|
+
// esistono varianti («…by user for tool use»).
|
|
53
|
+
const INTERRUPT_MARKER = '[Request interrupted by user';
|
|
54
|
+
function isInterrupt(text) {
|
|
55
|
+
return text.trimStart().startsWith(INTERRUPT_MARKER);
|
|
56
|
+
}
|
|
57
|
+
// T52 — estrae i corpi cercabili di UN record, uno per `kind` presente.
|
|
58
|
+
//
|
|
59
|
+
// Blocchi dello stesso kind nello stesso record vengono CONCATENATI, non emessi
|
|
60
|
+
// separatamente: il reader promette «il messaggio intero», e la coppia
|
|
61
|
+
// (idx, kind) resta così una chiave univoca per la riga-occorrenza.
|
|
62
|
+
//
|
|
63
|
+
// Mappatura dei blocchi, verificata sullo store:
|
|
64
|
+
// - assistant `text` → ai
|
|
65
|
+
// - assistant `tool_use` → tool (nome + input serializzato: il comando
|
|
66
|
+
// eseguito è ciò che si cerca, non il wrapper)
|
|
67
|
+
// - assistant `thinking` → SCARTATO: il campo `thinking` è SEMPRE ''
|
|
68
|
+
// (persistita solo la `signature`, la firma
|
|
69
|
+
// crittografica). Indicizzarlo produrrebbe una
|
|
70
|
+
// categoria che non può mai dare un risultato.
|
|
71
|
+
// - user stringa nuda / `text` → human
|
|
72
|
+
// - user `tool_result` → tool (viaggia come type:user, ma non è
|
|
73
|
+
// scritto da un umano)
|
|
74
|
+
//
|
|
75
|
+
// `isAssistant` arriva dal `type` del RECORD, non da `message.role`: è il campo
|
|
76
|
+
// su cui il resto del parser già dispatcha (i due combaciano sullo store attuale
|
|
77
|
+
// — verificato — ma dipendere da uno solo evita che divergano in silenzio).
|
|
78
|
+
function collectBodies(message, idx, isAssistant, out) {
|
|
79
|
+
if (!message || typeof message !== 'object')
|
|
80
|
+
return;
|
|
81
|
+
const content = message.content;
|
|
82
|
+
const parts = { ai: [], tool: [], human: [] };
|
|
83
|
+
if (typeof content === 'string') {
|
|
84
|
+
if (content)
|
|
85
|
+
parts[isAssistant ? 'ai' : 'human'].push(content);
|
|
86
|
+
}
|
|
87
|
+
else if (Array.isArray(content)) {
|
|
88
|
+
for (const block of content) {
|
|
89
|
+
if (!block || typeof block !== 'object')
|
|
90
|
+
continue;
|
|
91
|
+
const b = block;
|
|
92
|
+
if (b.type === 'text' && typeof b.text === 'string' && b.text) {
|
|
93
|
+
parts[isAssistant ? 'ai' : 'human'].push(b.text);
|
|
94
|
+
}
|
|
95
|
+
else if (b.type === 'tool_use') {
|
|
96
|
+
const name = typeof b.name === 'string' ? b.name : 'tool';
|
|
97
|
+
let input = '';
|
|
98
|
+
try {
|
|
99
|
+
input = JSON.stringify(b.input ?? {});
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
input = ''; // input ciclico/non serializzabile → resta il solo nome
|
|
103
|
+
}
|
|
104
|
+
parts.tool.push(`${name} ${input}`);
|
|
105
|
+
}
|
|
106
|
+
else if (b.type === 'tool_result') {
|
|
107
|
+
const r = b.content;
|
|
108
|
+
if (typeof r === 'string') {
|
|
109
|
+
if (r)
|
|
110
|
+
parts.tool.push(r);
|
|
111
|
+
}
|
|
112
|
+
else if (Array.isArray(r)) {
|
|
113
|
+
for (const rb of r) {
|
|
114
|
+
if (rb && typeof rb === 'object' && rb.type === 'text') {
|
|
115
|
+
const t = rb.text;
|
|
116
|
+
if (typeof t === 'string' && t)
|
|
117
|
+
parts.tool.push(t);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const kind of ['ai', 'tool', 'human']) {
|
|
125
|
+
const text = parts[kind].join('\n');
|
|
126
|
+
if (!text)
|
|
127
|
+
continue;
|
|
128
|
+
if (kind === 'human' && isInterrupt(text))
|
|
129
|
+
continue;
|
|
130
|
+
out.push({ idx, kind, text });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
48
133
|
// Cache mtime-keyed: re-parse solo i file cambiati. Steady-state di una TUI che
|
|
49
134
|
// poll-a resta economico anche con decine di sessioni multi-MB. Lo stato vive
|
|
50
135
|
// dentro l'adapter così l'invariante "unico modulo che tocca lo store" regge.
|
|
@@ -57,6 +142,18 @@ function parseSessionFile(path, mtime, sizeBytes) {
|
|
|
57
142
|
catch {
|
|
58
143
|
return null;
|
|
59
144
|
}
|
|
145
|
+
return parseTranscript(content, path, mtime, sizeBytes);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Semantica del transcript, separata dalla lettura del file.
|
|
149
|
+
*
|
|
150
|
+
* L'I/O resta in `parseSessionFile` (l'invariante «unico modulo che tocca lo
|
|
151
|
+
* store» vale per QUESTO file, non per la funzione): qui c'è solo la parte non
|
|
152
|
+
* ovvia — cosa conta come turno, cosa è un corpo cercabile, quali record sono
|
|
153
|
+
* eventi di UI travestiti da messaggi. È anche l'unica parte che vale la pena
|
|
154
|
+
* testare, e su una stringa si testa senza inventare un finto `~/.claude`.
|
|
155
|
+
*/
|
|
156
|
+
export function parseTranscript(content, path, mtime, sizeBytes) {
|
|
60
157
|
let sessionId = basename(path).replace(/\.jsonl$/, '');
|
|
61
158
|
let cwd = '';
|
|
62
159
|
let gitBranch = '';
|
|
@@ -65,6 +162,8 @@ function parseSessionFile(path, mtime, sizeBytes) {
|
|
|
65
162
|
let firstUserText = '';
|
|
66
163
|
let lastAssistantText = '';
|
|
67
164
|
let turns = 0;
|
|
165
|
+
const bodies = [];
|
|
166
|
+
let recordIdx = -1;
|
|
68
167
|
for (const line of content.split('\n')) {
|
|
69
168
|
if (!line.trim())
|
|
70
169
|
continue;
|
|
@@ -75,6 +174,12 @@ function parseSessionFile(path, mtime, sizeBytes) {
|
|
|
75
174
|
catch {
|
|
76
175
|
continue;
|
|
77
176
|
}
|
|
177
|
+
// Indice progressivo sui soli record VALIDI: è ciò che prefissa la
|
|
178
|
+
// riga-occorrenza, quindi deve contare come li conta chi legge il file.
|
|
179
|
+
recordIdx++;
|
|
180
|
+
if (d.type === 'user' || d.type === 'assistant') {
|
|
181
|
+
collectBodies(d.message, recordIdx, d.type === 'assistant', bodies);
|
|
182
|
+
}
|
|
78
183
|
if (typeof d.sessionId === 'string' && d.sessionId)
|
|
79
184
|
sessionId = d.sessionId;
|
|
80
185
|
if (!cwd && typeof d.cwd === 'string' && d.cwd)
|
|
@@ -89,8 +194,11 @@ function parseSessionFile(path, mtime, sizeBytes) {
|
|
|
89
194
|
if (d.type === 'user') {
|
|
90
195
|
// T49: turno = prompt umano. I tool_result viaggiano anch'essi come
|
|
91
196
|
// type:user ma senza blocchi text → extractText '' li esclude.
|
|
197
|
+
// T52: e nemmeno l'interruzione da esc è un prompt — stesso `type`, ma è
|
|
198
|
+
// un evento di UI. Senza il filtro gonfia i turni e può diventare il
|
|
199
|
+
// «primo prompt» mostrato nel detail pane.
|
|
92
200
|
const t = extractText(d.message);
|
|
93
|
-
if (t) {
|
|
201
|
+
if (t && !isInterrupt(t)) {
|
|
94
202
|
turns++;
|
|
95
203
|
if (!firstUserText)
|
|
96
204
|
firstUserText = t;
|
|
@@ -120,6 +228,7 @@ function parseSessionFile(path, mtime, sizeBytes) {
|
|
|
120
228
|
customTitle,
|
|
121
229
|
firstPrompt: firstUserText,
|
|
122
230
|
lastReply: lastAssistantText,
|
|
231
|
+
bodies,
|
|
123
232
|
};
|
|
124
233
|
}
|
|
125
234
|
// Discovery read-only delle sessioni del SOLO progetto corrente (D2 preflight
|
package/dist/viewport.js
CHANGED
|
@@ -78,6 +78,13 @@ export const MODAL_HEIGHT = {
|
|
|
78
78
|
sort: 5, // marginTop + 2 bordi + titolo + 1 riga catena
|
|
79
79
|
filter: 6, // marginTop + 2 bordi + titolo + 2 righe (pri, stato)
|
|
80
80
|
edit: 8, // marginTop + 2 bordi + titolo + 3 campi + riga anteprima
|
|
81
|
+
// T52 — search e reader sono gli unici modali NON in flusso: sostituiscono i
|
|
82
|
+
// due pane invece di spingerli giù (una lista di occorrenze non entra in un
|
|
83
|
+
// box sopra il deck). Costo 0 nel budget dei pane perché quel budget non
|
|
84
|
+
// viene nemmeno calcolato: il render esce prima. La loro altezza la
|
|
85
|
+
// distribuiscono `searchListCapacity`/`readerCapacity`.
|
|
86
|
+
search: 0,
|
|
87
|
+
reader: 0,
|
|
81
88
|
};
|
|
82
89
|
/**
|
|
83
90
|
* Distribuisce le righe disponibili fra lista task, lista sessioni e dettaglio.
|
|
@@ -164,6 +171,96 @@ export function layoutBudget(input) {
|
|
|
164
171
|
compact: false,
|
|
165
172
|
};
|
|
166
173
|
}
|
|
174
|
+
// T52 — cornice fissa della schermata di ricerca. Stesso vincolo del deck
|
|
175
|
+
// normale (il frame non deve superare `rows`), contabilità separata perché la
|
|
176
|
+
// schermata è sostitutiva, non additiva.
|
|
177
|
+
//
|
|
178
|
+
// 2 bordi del box esterno
|
|
179
|
+
// 1 titolo "loom-deck"
|
|
180
|
+
// 1 riga hint/legenda tasti
|
|
181
|
+
// 1 marginTop del box query
|
|
182
|
+
// 2 bordi del box query
|
|
183
|
+
// 3 righe del box query: hash · chiave · toggle
|
|
184
|
+
// 1 marginTop del box lista
|
|
185
|
+
// 2 bordi del box lista
|
|
186
|
+
// 1 header della lista (conteggi)
|
|
187
|
+
const SEARCH_CHROME = 14;
|
|
188
|
+
/** Righe di lista occorrenze che entrano nel terminale. Zero = la cornice non
|
|
189
|
+
* ci sta nemmeno vuota → la schermata va sostituita (vedi `isCompact`). */
|
|
190
|
+
export function searchListCapacity(rows, noteLine) {
|
|
191
|
+
return Math.max(0, (rows || 24) - SLACK - SEARCH_CHROME - (noteLine ? 1 : 0));
|
|
192
|
+
}
|
|
193
|
+
// T52 — cornice del reader fullscreen.
|
|
194
|
+
// 2 bordi del box esterno
|
|
195
|
+
// 1 titolo "loom-deck"
|
|
196
|
+
// 1 riga meta (sessione · record · tipo · posizione)
|
|
197
|
+
// 1 riga hint
|
|
198
|
+
// 1 marginTop del box corpo
|
|
199
|
+
// 2 bordi del box corpo
|
|
200
|
+
const READER_CHROME = 8;
|
|
201
|
+
/** Righe di testo del messaggio che entrano nel terminale. */
|
|
202
|
+
export function readerCapacity(rows) {
|
|
203
|
+
return Math.max(0, (rows || 24) - SLACK - READER_CHROME);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Il terminale non ospita nemmeno la cornice della schermata: va sostituita da
|
|
207
|
+
* una riga singola, come fa `Budget.compact` per il deck.
|
|
208
|
+
*
|
|
209
|
+
* Non è una comodità estetica ed è lo stesso ragionamento del layout a box: un
|
|
210
|
+
* frame più alto di `rows` fa cadere Ink nel ramo `clearTerminal`, che su
|
|
211
|
+
* VTE/Ptyxis riversa ogni redraw nello scrollback. Un terminale basso è proprio
|
|
212
|
+
* la condizione che innesca quel ramo, quindi qui il fallback È il fix.
|
|
213
|
+
*/
|
|
214
|
+
export function isCompact(capacity) {
|
|
215
|
+
return capacity < 1;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* A-capo che PRESERVA la struttura del testo e traccia gli offset.
|
|
219
|
+
*
|
|
220
|
+
* Distinto da `wrapLines`, che collassa tutto in un flusso unico: lì serviva una
|
|
221
|
+
* preview compatta di 2-4 righe, qui si legge un messaggio intero — appiattire
|
|
222
|
+
* le newline renderebbe illeggibile qualunque blocco di codice o elenco.
|
|
223
|
+
*
|
|
224
|
+
* Ogni riga prodotta è una FETTA CONTIGUA del sorgente: è ciò che permette di
|
|
225
|
+
* intersecarla con l'intervallo del match e colorare solo la parte giusta,
|
|
226
|
+
* anche quando il match cade a cavallo di due righe.
|
|
227
|
+
*
|
|
228
|
+
* Taglia sull'ultimo spazio disponibile; una parola più larga della riga viene
|
|
229
|
+
* spezzata a forza, altrimenti l'a-capo lo farebbe il terminale — fuori dal
|
|
230
|
+
* conteggio delle righe, cioè di nuovo un frame più alto di `rows`.
|
|
231
|
+
*/
|
|
232
|
+
export function wrapWithOffsets(text, width) {
|
|
233
|
+
const out = [];
|
|
234
|
+
if (width <= 0)
|
|
235
|
+
return out;
|
|
236
|
+
let base = 0;
|
|
237
|
+
for (const raw of text.split('\n')) {
|
|
238
|
+
// Tab e CR diventano UN singolo spazio, non quattro: la sostituzione deve
|
|
239
|
+
// conservare la lunghezza, o gli offset delle righe smettono di indicizzare
|
|
240
|
+
// il sorgente e il match verrebbe evidenziato spostato. Un tab lasciato
|
|
241
|
+
// passare sarebbe l'errore opposto — lo conteremmo 1 e il terminale 8.
|
|
242
|
+
const line = raw.replace(/[\t\r]/g, ' ');
|
|
243
|
+
if (line.length === 0) {
|
|
244
|
+
out.push({ text: '', start: base, end: base });
|
|
245
|
+
}
|
|
246
|
+
let pos = 0;
|
|
247
|
+
while (pos < line.length) {
|
|
248
|
+
if (line.length - pos <= width) {
|
|
249
|
+
out.push({ text: line.slice(pos), start: base + pos, end: base + line.length });
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
let brk = line.lastIndexOf(' ', pos + width);
|
|
253
|
+
if (brk <= pos)
|
|
254
|
+
brk = pos + width; // parola più lunga della riga
|
|
255
|
+
out.push({ text: line.slice(pos, brk), start: base + pos, end: base + brk });
|
|
256
|
+
pos = brk;
|
|
257
|
+
while (line[pos] === ' ')
|
|
258
|
+
pos++; // lo spazio del taglio non apre la riga dopo
|
|
259
|
+
}
|
|
260
|
+
base += raw.length + 1; // +1 = il '\n' consumato dallo split
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
167
264
|
/**
|
|
168
265
|
* Finestra scorrevole su una lista più lunga della capienza.
|
|
169
266
|
*
|
package/package.json
CHANGED