@lamemind/loom-deck 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,28 +2,19 @@
2
2
 
3
3
  Deck TUI (Ink) **per-progetto** della famiglia [loom](https://github.com/lamemind/loom-works).
4
4
 
5
- Legge il `tasks.md` del progetto e, con un tasto (poi un click), **spawna** una tab
6
- [Ptyxis](https://gitlab.gnome.org/chergert/ptyxis) che avvia una sessione Claude Code
7
- già bound alla task via `LOOM_TASK`.
5
+ Legge il `tasks.md` del progetto e, con un tasto (poi un click), **spawna** una tab [Ptyxis](https://gitlab.gnome.org/chergert/ptyxis) che avvia una sessione Claude Code già bound alla task via `LOOM_TASK`.
8
6
 
9
7
  ```
10
8
  ↑↓ scegli la task → ⏎ → tab CC di fianco → LOOM_TASK bound
11
9
  ```
12
10
 
13
- `⏎` apre il **detail** della task — task file scrollabile più una barra azioni —
14
- e da lì si sceglie con quale prompt entrare; `^K`/`^P`/`^R` restano acceleratori
15
- per chi lo sa già (vedi [I cinque modi di entrare in una
16
- task](#i-cinque-modi-di-entrare-in-una-task)).
11
+ `⏎` apre il **detail** della task — task file scrollabile più una barra azioni — e da lì si sceglie con quale prompt entrare; `^K`/`^P`/`^R` restano acceleratori per chi lo sa già (vedi [I cinque modi di entrare in una task](#i-cinque-modi-di-entrare-in-una-task)).
17
12
 
18
- Entrambi i prefissi del contratto loom entrano in lista: **`T`** (code task) e
19
- **`D`** (doc task). Il trattamento è identico ovunque tranne che su `^R`, dove il
20
- prompt dispatcha sulla skill di esecuzione della rispettiva famiglia.
13
+ Entrambi i prefissi del contratto loom entrano in lista: **`T`** (code task) e **`D`** (doc task). Il trattamento è identico ovunque tranne che su `^R`, dove il prompt dispatcha sulla skill di esecuzione della rispettiva famiglia.
21
14
 
22
15
  ## Ruolo nella famiglia loom
23
16
 
24
- `loom-deck` è un **client** con runtime proprio (TUI Ink) che **consuma** il contratto
25
- definito da `loom-works-plugin` (formato `tasks.md`, variabile `LOOM_TASK`) — non lo
26
- ridefinisce. Divisione dei ruoli con Compass:
17
+ `loom-deck` è un **client** con runtime proprio (TUI Ink) che **consuma** il contratto definito da `loom-works-plugin` (formato `tasks.md`, variabile `LOOM_TASK`) — non lo ridefinisce. Divisione dei ruoli con Compass:
27
18
 
28
19
  | | scope | ruolo | domanda |
29
20
  |---|---|---|---|
@@ -32,8 +23,7 @@ ridefinisce. Divisione dei ruoli con Compass:
32
23
 
33
24
  ## Architettura di processo
34
25
 
35
- Il deck è **UN processo Node**: spawna ma **non contiene** le sessioni CC — le possiede
36
- Ptyxis. Chiudere il deck non uccide le sessioni. La tab nasce nella window *attiva*
26
+ Il deck è **UN processo Node**: spawna ma **non contiene** le sessioni CC — le possiede Ptyxis. Chiudere il deck non uccide le sessioni. La tab nasce nella window *attiva*
37
27
  (quella col focus = il deck) → desktop isolation "gratis".
38
28
 
39
29
  ## Stato
@@ -50,14 +40,13 @@ Bootstrap + spike ① + **TUI ③** funzionante (legge `tasks.md`, `⏎` apre il
50
40
 
51
41
  ## Standard shortcut
52
42
 
53
- Regola unica, senza eccezioni — pensata per reggere l'aggiunta di nuove azioni
54
- senza collisioni:
43
+ Regola unica, senza eccezioni — pensata per reggere l'aggiunta di nuove azioni senza collisioni:
55
44
 
56
45
  | Tasto | Semantica | Note |
57
46
  |---|---|---|
58
47
  | `↑` `↓` | naviga nella lista | |
59
- | `tab` | cambia pane | l'unico tasto che sposta il focus |
60
- | `←` `→` | cambia **vista** del pane a fuoco | ciclico; il catalogo è l'header stesso |
48
+ | `←` `→` | cambia pane | assoluto, non toggle: `←` = Tasks, `→` = Sessions — la direzione nomina il pane, che è affiancato |
49
+ | `tab` | cambia **vista** del pane a fuoco | ciclico (`shift+tab` indietro); il catalogo è l'header stesso |
61
50
  | `⏎` | azione primaria del pane | Tasks → apre il **detail** della task selezionata · Sessions → riprende (`claude --resume`) la sessione selezionata |
62
51
  | **MAIUSCOLA** | **apre un modale** | cattura tutti i tasti; `esc` annulla, non esce |
63
52
  | minuscola | azione immediata, one-shot | |
@@ -65,12 +54,7 @@ senza collisioni:
65
54
  | `1`…`9` | voce `launch` n-esima del progetto | da `.claude/loom-works.json` |
66
55
  | `q` `esc` | esce dal deck | in un modale `esc` annulla soltanto |
67
56
 
68
- `CTRL` è il terzo livello, aggiunto quando è arrivata la ricerca. Serve perché
69
- un modale con **campi di testo** mangia ogni lettera nuda: là dentro nessun
70
- comando può essere una lettera semplice. `CTRL+X` e `x` nudo condividono lo
71
- stesso `input` e si distinguono solo per `key.ctrl`, quindi in modalità normale
72
- il ramo `CTRL` è valutato **per primo** e chiude l'intera classe — senza,
73
- `CTRL+F` cadrebbe nel ramo `f` e forkerebbe una sessione invece di cercare.
57
+ `CTRL` è il terzo livello, aggiunto quando è arrivata la ricerca. Serve perché un modale con **campi di testo** mangia ogni lettera nuda: là dentro nessun comando può essere una lettera semplice. `CTRL+X` e `x` nudo condividono lo stesso `input` e si distinguono solo per `key.ctrl`, quindi in modalità normale il ramo `CTRL` è valutato **per primo** e chiude l'intera classe — senza, `CTRL+F` cadrebbe nel ramo `f` e forkerebbe una sessione invece di cercare.
74
58
 
75
59
  Assegnazioni correnti:
76
60
 
@@ -98,39 +82,20 @@ Il footer è **due righe con due nature diverse**:
98
82
  t 💻 · c 🤖 · 1 📝 codium · 2 ☕ idea
99
83
  ```
100
84
 
101
- - **tasti** — cosa puoi fare *qui e ora*. Solo le voci attive: quelle contestuali
102
- compaiono quando il pane a fuoco le rende possibili e altrimenti spariscono,
85
+ - **tasti** — cosa puoi fare *qui e ora*. Solo le voci attive: quelle contestuali compaiono quando il pane a fuoco le rende possibili e altrimenti spariscono,
103
86
  invece di mostrarsi inerti. Fuori navigazione (`↑↓` `←→`), uscita (`q`) e
104
- indicatore `focus:` — le prime due sono universali in qualunque TUI, il pane a
105
- fuoco si vede già dall'evidenziazione.
106
- - **surface** — *dove* puoi aprire qualcosa: prima le due built-in (`t`/`c`), poi
107
- le voci `launch` del progetto. Stanno insieme perché hanno la stessa natura
108
- fire-once, `cwd` = project root, nessuno stato — e differiscono solo per essere
109
- universali invece che custom.
110
-
111
- L'indice da solo è opaco (le `launch` sono custom per-progetto, senza una lettera
112
- fissa per app), quindi la riga espone la mappa e non il conteggio. Se non entrano
113
- in larghezza, si ferma a voci intere e mostra il contatore di quelle fuori riga —
114
- mai un troncamento silenzioso; le celle delle due built-in sono riservate a monte,
115
- o le voci sfonderebbero il box di quel tanto. Il cap a `9` è imposto dai
116
- tasti-cifra, non dallo schema: un progetto può dichiarare più di 9 voci, quelle
117
- oltre la nona sono configurate ma non raggiungibili (e la riga lo dice).
118
-
119
- Le emoji sono quelle del menu compass. Per il terminale compass usa 🖥️, che nel
120
- frame Ink non sopravvive: `sanitize` lo sostituisce perché VTE lo disegna largo 1
121
- mentre `string-width` ne conta 2 (invariante ① di `src/width.ts`) — 💻 è il
122
- gemello concorde.
123
-
124
- `t` e `c` sono gemelle: entrambe aprono una surface del cappello nella stessa
125
- finestra Ptyxis, senza passare da un modale. `c` (minuscola, azione) e `C`
126
- (maiuscola, modale create-task) restano distinte per la regola sopra — così come
127
- `f` (fork) e `F` (filtri).
87
+ indicatore `focus:` — le prime due sono universali in qualunque TUI, il pane a fuoco si vede già dall'evidenziazione.
88
+ - **surface** *dove* puoi aprire qualcosa: prima le due built-in (`t`/`c`), poi le voci `launch` del progetto. Stanno insieme perché hanno la stessa natura — fire-once, `cwd` = project root, nessuno stato — e differiscono solo per essere universali invece che custom.
89
+
90
+ L'indice da solo è opaco (le `launch` sono custom per-progetto, senza una lettera fissa per app), quindi la riga espone la mappa e non il conteggio. Se non entrano in larghezza, si ferma a voci intere e mostra il contatore di quelle fuori riga — mai un troncamento silenzioso; le celle delle due built-in sono riservate a monte, o le voci sfonderebbero il box di quel tanto. Il cap a `9` è imposto dai tasti-cifra, non dallo schema: un progetto può dichiarare più di 9 voci, quelle oltre la nona sono configurate ma non raggiungibili (e la riga lo dice).
91
+
92
+ Le emoji sono quelle del menu compass. Per il terminale compass usa 🖥️, che nel frame Ink non sopravvive: `sanitize` lo sostituisce perché VTE lo disegna largo 1 mentre `string-width` ne conta 2 (invariante ① di `src/width.ts`) — 💻 è il gemello concorde.
93
+
94
+ `t` e `c` sono gemelle: entrambe aprono una surface del cappello nella stessa finestra Ptyxis, senza passare da un modale. `c` (minuscola, azione) e `C` (maiuscola, modale create-task) restano distinte per la regola sopra — così come `f` (fork) e `F` (filtri).
128
95
 
129
96
  ### I cinque modi di entrare in una task
130
97
 
131
- Sulla task selezionata (focus sul pane Tasks) si apre una sessione **bound**:
132
- `LOOM_TASK` iniettata, `sessionId` pinnato, binding scritto nel sidecar. Fra i
133
- modi cambia **solo il prompt iniziale**.
98
+ Sulla task selezionata (focus sul pane Tasks) si apre una sessione **bound**: `LOOM_TASK` iniettata, `sessionId` pinnato, binding scritto nel sidecar. Fra i modi cambia **solo il prompt iniziale**.
134
99
 
135
100
  | Azione (detail) | Tasto diretto | Prompt |
136
101
  |---|---|---|
@@ -140,18 +105,11 @@ modi cambia **solo il prompt iniziale**.
140
105
  | `run` | `^R` | `/loom-works:run-task <id>`, oppure `/loom-works:run-doc <id>` se l'id è una `D` |
141
106
  | `checkpoint` | — | `/loom-works:checkpoint-task <id>` |
142
107
 
143
- Il prompt iniziale è la scelta di **chi apre**, non una proprietà della task: lo
144
- stesso task file si apre per leggerne lo stato, per congelarne le decisioni, per
145
- eseguirlo o per entrarci a mani nude — e l'azione scelta *è* quell'intento.
108
+ Il prompt iniziale è la scelta di **chi apre**, non una proprietà della task: lo stesso task file si apre per leggerne lo stato, per congelarne le decisioni, per eseguirlo o per entrarci a mani nude — e l'azione scelta *è* quell'intento.
146
109
 
147
- Le due superfici sono complementari, non alternative. `⏎` apre il **detail** e lì
148
- si legge la Description *mentre* si decide: è il caso normale, perché la domanda
149
- «quale azione?» quasi sempre si risponde leggendo la task. I tre `CTRL` saltano
150
- il passaggio per chi lo sa già. Dentro il detail sono inerti — i bottoni sono lì.
110
+ Le due superfici sono complementari, non alternative. `⏎` apre il **detail** e lì si legge la Description *mentre* si decide: è il caso normale, perché la domanda «quale azione?» quasi sempre si risponde leggendo la task. I tre `CTRL` saltano il passaggio per chi lo sa già. Dentro il detail sono inerti — i bottoni sono lì.
151
111
 
152
- Col focus sul pane Sessions né `⏎` né i `CTRL` spawnano: l'oggetto dell'azione è
153
- la task selezionata, e senza quel pane a fuoco non ce n'è una (il deck lo dice
154
- nella riga di nota, invece di aprire qualcosa a caso).
112
+ Col focus sul pane Sessions né `⏎` né i `CTRL` spawnano: l'oggetto dell'azione è la task selezionata, e senza quel pane a fuoco non ce n'è una (il deck lo dice nella riga di nota, invece di aprire qualcosa a caso).
155
113
 
156
114
  ### `⏎` — il detail della task
157
115
 
@@ -160,44 +118,25 @@ Overlay fullscreen a due zone: il **task file per intero**, scrollabile
160
118
  navigabile con `←→`, con `open` a fuoco all'apertura. `⏎` esegue l'azione
161
119
  selezionata e chiude; `esc` chiude lasciando la lista con la stessa selezione.
162
120
 
163
- Le azioni non sono un catalogo nuovo: chiamano lo **stesso** percorso di spawn
164
- dei tasti diretti. Una superficie in più, zero percorsi di spawn in più.
121
+ Le azioni non sono un catalogo nuovo: chiamano lo **stesso** percorso di spawn dei tasti diretti. Una superficie in più, zero percorsi di spawn in più.
165
122
 
166
- Sono bottoni affiancati e non voci di un menu verticale perché è la forma che
167
- sopravvive all'arrivo del mouse: un rettangolo ha già coordinate e area
168
- cliccabile, una lista di voci andrebbe rifatta.
123
+ Sono bottoni affiancati e non voci di un menu verticale perché è la forma che sopravvive all'arrivo del mouse: un rettangolo ha già coordinate e area cliccabile, una lista di voci andrebbe rifatta.
169
124
 
170
- Dentro il detail `←→` cicla i bottoni invece di cambiare la vista del pane — il
171
- ramo `useInput` della modalità chiude prima di arrivare a quello di default. Ink
172
- non ha focus-trap: la cattura **è** l'ordine dei rami, e cambiare cosa fa un
173
- tasto fuori dal detail non tocca cosa fa dentro.
125
+ Dentro il detail `←→` cicla i bottoni invece di cambiare pane, e `tab` non cambia la vista — il
126
+ ramo `useInput` della modalità chiude prima di arrivare a quello di default. Ink non ha focus-trap: la cattura **è** l'ordine dei rami, e cambiare cosa fa un tasto fuori dal detail non tocca cosa fa dentro.
174
127
 
175
- Il catalogo dei prompt vive in `scripts/deck-run`, non nella TUI: è il primitive
176
- UI-agnostico, il deck gli passa un **simbolo** (`--prompt-kind`) e non una
177
- stringa. Così il testo sta in un posto solo, e con lui il suo vincolo di quoting
178
- — il prompt viaggia dentro apici singoli in `bash -lc`, quindi non può contenerne.
128
+ Il catalogo dei prompt vive in `scripts/deck-run`, non nella TUI: è il primitive UI-agnostico, il deck gli passa un **simbolo** (`--prompt-kind`) e non una stringa. Così il testo sta in un posto solo, e con lui il suo vincolo di quoting — il prompt viaggia dentro apici singoli in `bash -lc`, quindi non può contenerne.
179
129
 
180
130
  ### `f` — forkare una conversazione
181
131
 
182
- Il fork rama la sessione selezionata: `claude --resume <origine> --fork-session`
183
- apre un **sessionId nuovo** con il transcript copiato, lasciando l'origine
184
- intatta. Serve quando vuoi ripartire da un certo stato senza perdere il ramo
185
- originale — e siccome i due id sono distinti, non esistono mai due processi che
186
- scrivono lo stesso file (il vincolo *single-writer* dello store di Claude Code).
132
+ Il fork rama la sessione selezionata: `claude --resume <origine> --fork-session` apre un **sessionId nuovo** con il transcript copiato, lasciando l'origine intatta. Serve quando vuoi ripartire da un certo stato senza perdere il ramo originale — e siccome i due id sono distinti, non esistono mai due processi che scrivono lo stesso file (il vincolo *single-writer* dello store di Claude Code).
187
133
 
188
134
  Il nuovo id lo genera il deck e lo pinna con `--session-id`, per due ragioni:
189
135
 
190
- - il ramo **eredita la task** dell'origine (senza id noto in anticipo il fork
191
- di una sessione scoped comparirebbe come spot);
192
- - il **lineage** finisce nel sidecar `.claude/loom/session-tasks.jsonl` come
193
- campo `forkOf`. Serve perché il transcript del fork **non nomina** la sessione
194
- d'origine da nessuna parte: è una copia verbatim (stessi uuid dei messaggi) e
195
- `parentUuid` incatena i messaggi dentro un transcript, non le sessioni fra
196
- loro. Senza quel record un ramo sarebbe una riga gemella dell'originale, di
197
- cui eredita anche il titolo.
136
+ - il ramo **eredita la task** dell'origine (senza id noto in anticipo il fork di una sessione scoped comparirebbe come spot);
137
+ - il **lineage** finisce nel sidecar `.claude/loom/session-tasks.jsonl` come campo `forkOf`. Serve perché il transcript del fork **non nomina** la sessione d'origine da nessuna parte: è una copia verbatim (stessi uuid dei messaggi) e `parentUuid` incatena i messaggi dentro un transcript, non le sessioni fra loro. Senza quel record un ramo sarebbe una riga gemella dell'originale, di cui eredita anche il titolo.
198
138
 
199
- Un ramo si riconosce dal marker `⑂` nella lista e dalla riga `⑂ da <id>` nel
200
- pannello di dettaglio; la sua tab Ptyxis titola `<label> · <task> · fork`.
139
+ Un ramo si riconosce dal marker `⑂` nella lista e dalla riga `⑂ da <id>` nel pannello di dettaglio; la sua tab Ptyxis titola `<label> · <task> · fork`.
201
140
 
202
141
  > **Nota di migrazione (0.6.0)**: `c` → **`C`** per creare una task, e le voci
203
142
  > `codium`/`idea` non hanno più una lettera dedicata (erano `C`/`I` hardcoded):
@@ -205,20 +144,14 @@ pannello di dettaglio; la sua tab Ptyxis titola `<label> · <task> · fork`.
205
144
 
206
145
  ### `^F` — cercare dentro le conversazioni
207
146
 
208
- Il navigator trova una conversazione per **metadati** (titolo, data, turni). `^F`
209
- la trova per **contenuto**: «l'IA me l'ha detto 150k di testo fa, ricordo mezza
210
- parola». Claude Code non ha un find-in-conversation, ma i transcript sono tutti
211
- su disco e il deck li legge già.
147
+ Il navigator trova una conversazione per **metadati** (titolo, data, turni). `^F` la trova per **contenuto**: «l'IA me l'ha detto 150k di testo fa, ricordo mezza parola». Claude Code non ha un find-in-conversation, ma i transcript sono tutti su disco e il deck li legge già.
212
148
 
213
149
  Due campi, `tab` per passare dall'uno all'altro:
214
150
 
215
- - **hash** — prefisso del `sessionId` (gli 8 char della statusline bastano),
216
- restringe a una conversazione. Vuoto = **tutte** quelle del progetto.
217
- - **chiave** — il termine cercato. La ricerca è **eager**: si aggiorna a ogni
218
- carattere, da 3 in su.
151
+ - **hash** — prefisso del `sessionId` (gli 8 char della statusline bastano), restringe a una conversazione. Vuoto = **tutte** quelle del progetto.
152
+ - **chiave** il termine cercato. La ricerca è **eager**: si aggiorna a ogni carattere, da 3 in su.
219
153
 
220
- Sei toggle, tutti su `CTRL` perché i campi di testo occupano le lettere nude.
221
- Lo stato si legge da `[x]`/`[ ]`, non dal solo colore:
154
+ Sei toggle, tutti su `CTRL` perché i campi di testo occupano le lettere nude. Lo stato si legge da `[x]`/`[ ]`, non dal solo colore:
222
155
 
223
156
  | Tasto | Toggle | Default |
224
157
  |---|---|---|
@@ -229,42 +162,21 @@ Lo stato si legge da `[x]`/`[ ]`, non dal solo colore:
229
162
  | `^T` | cerca nei **tool** (`tool_use` / `tool_result`) | off |
230
163
  | `^U` | cerca nei prompt **umani** | off |
231
164
 
232
- Non esiste un toggle *thinking*: quei blocchi sono persistiti **senza testo**
233
- (il transcript porta la sola firma crittografica), quindi sarebbe una casella
234
- che non può mai produrre un risultato.
165
+ Non esiste un toggle *thinking*: quei blocchi sono persistiti **senza testo** (il transcript porta la sola firma crittografica), quindi sarebbe una casella che non può mai produrre un risultato.
235
166
 
236
- Con l'hash vuoto la lista è **raggruppata per conversazione**; `⏎` è contestuale
237
- alla riga selezionata — su una riga-conversazione **riprende** la sessione
238
- (come dal pane Sessions), su una riga-occorrenza apre il **reader**. La selezione
239
- parte dalla prima *occorrenza*, non dalla riga di gruppo: quella è un segnaposto
240
- di navigazione, raggiungibile con le frecce ma non una destinazione.
167
+ Con l'hash vuoto la lista è **raggruppata per conversazione**; `⏎` è contestuale alla riga selezionata — su una riga-conversazione **riprende** la sessione (come dal pane Sessions), su una riga-occorrenza apre il **reader**. La selezione parte dalla prima *occorrenza*, non dalla riga di gruppo: quella è un segnaposto di navigazione, raggiungibile con le frecce ma non una destinazione.
241
168
 
242
- L'estratto attorno al match **si allarga col terminale**: a 190 colonne sono
243
- ~170 caratteri di contesto, non i 50 di un valore fisso — ed è il contesto la
244
- ragione per cui si legge la riga invece di aprire il reader.
169
+ L'estratto attorno al match **si allarga col terminale**: a 190 colonne sono ~170 caratteri di contesto, non i 50 di un valore fisso — ed è il contesto la ragione per cui si legge la riga invece di aprire il reader.
245
170
 
246
- Sotto la lista, un **pannello di anteprima** mostra il contesto attorno
247
- all'occorrenza selezionata e si aggiorna navigando con le frecce. Prende solo le
248
- righe che la lista non usa: con pochi risultati riempie il terminale, con molti
249
- sparisce e la lista se le riprende — quando c'è tanto da scorrere la priorità è
250
- vedere più occorrenze, il contesto è il premio per una ricerca già stretta.
171
+ Sotto la lista, un **pannello di anteprima** mostra il contesto attorno all'occorrenza selezionata e si aggiorna navigando con le frecce. Prende solo le righe che la lista non usa: con pochi risultati riempie il terminale, con molti sparisce e la lista se le riprende — quando c'è tanto da scorrere la priorità è vedere più occorrenze, il contesto è il premio per una ricerca già stretta.
251
172
 
252
173
  Il reader mostra il messaggio intero, aperto già **posizionato sull'occorrenza**
253
174
  col match evidenziato: `↑↓` riga, `PgUp`/`PgDn` pagina, `g`/`G` estremi, `esc`
254
- torna alla lista senza perdere query, toggle e selezione. (Gli estremi stanno su
255
- lettera e non su `Home`/`End` perché Ink non espone quei due tasti: arrivano
256
- indistinguibili da qualunque tasto ignoto.)
175
+ torna alla lista senza perdere query, toggle e selezione. (Gli estremi stanno su lettera e non su `Home`/`End` perché Ink non espone quei due tasti: arrivano indistinguibili da qualunque tasto ignoto.)
257
176
 
258
- I toggle e la query sono **volatili**: sopravvivono alla chiusura del modale,
259
- non al riavvio del deck — comporre una ricerca non tocca il disco.
177
+ I toggle e la query sono **volatili**: sopravvivono alla chiusura del modale, non al riavvio del deck — comporre una ricerca non tocca il disco.
260
178
 
261
- **Come fa a essere istantanea.** I corpi dei messaggi restano in RAM dentro la
262
- cache mtime-keyed che il deck usa già per la lista sessioni: quei file venivano
263
- comunque deserializzati per turni e titoli, e i corpi buttati. Trattenerli non
264
- aggiunge I/O, aggiunge memoria — e ne aggiunge poca, perché un JSONL è per l'85%
265
- overhead (misurato su un progetto reale: 57 MB su disco = 9,8 MB di testo
266
- cercabile). Cercarci dentro costa 1-9 ms; un prefiltro `grep` sugli stessi file
267
- ne costerebbe 26, perché rileggerebbe il volume pieno a ogni battuta.
179
+ **Come fa a essere istantanea.** I corpi dei messaggi restano in RAM dentro la cache mtime-keyed che il deck usa già per la lista sessioni: quei file venivano comunque deserializzati per turni e titoli, e i corpi buttati. Trattenerli non aggiunge I/O, aggiunge memoria — e ne aggiunge poca, perché un JSONL è per l'85% overhead (misurato su un progetto reale: 57 MB su disco = 9,8 MB di testo cercabile). Cercarci dentro costa 1-9 ms; un prefiltro `grep` sugli stessi file ne costerebbe 26, perché rileggerebbe il volume pieno a ogni battuta.
268
180
 
269
181
  ## Vista: filtri e ordinamenti
270
182
 
@@ -281,30 +193,23 @@ p priorità s stato i id
281
193
  Partendo da catena vuota, digitare `ppi` produce `[pri ↓, id ↑]`. Il ciclo parte
282
194
  sempre **dallo stato corrente**, che il modale mostra dal vivo mentre digiti.
283
195
 
284
- Sull'**id** il confronto è numerico (`T9` prima di `T10`, non lessicografico) e i
285
- due prefissi sono blocchi distinti — le `D` in coda alle `T`: i counter `T` e `D`
286
- sono separati nel contratto loom, quindi `T01` e `D01` non sono confrontabili
287
- come numeri soli.
288
- A parità su tutte le chiavi decide sempre l'`id` (confronto **numerico**: `T9`
196
+ Sull'**id** il confronto è numerico (`T9` prima di `T10`, non lessicografico) e i due prefissi sono blocchi distinti — le `D` in coda alle `T`: i counter `T` e `D` sono separati nel contratto loom, quindi `T01` e `D01` non sono confrontabili come numeri soli. A parità su tutte le chiavi decide sempre l'`id` (confronto **numerico**: `T9`
289
197
  prima di `T10`) → l'ordine è deterministico, mai instabile fra un refresh e l'altro.
290
198
 
291
199
  **`F` — filtri.** Un toggle per ogni priorità e per ogni stato, componibili in AND.
292
200
  `↑↓` cambia riga, `←→` scorre i valori, `spazio` mostra/nasconde.
293
201
 
294
- In entrambi i modali la lista si aggiorna **dal vivo**; `⏎` conferma, `esc`
295
- ripristina la vista com'era all'apertura.
202
+ In entrambi i modali la lista si aggiorna **dal vivo**; `⏎` conferma, `esc` ripristina la vista com'era all'apertura.
296
203
 
297
- Con un filtro attivo l'header dichiara sempre quanto sta nascondendo
298
- (`Tasks (9/25) · 16 nascoste`): il deck non finge mai una lista completa.
204
+ Con un filtro attivo l'header dichiara sempre quanto sta nascondendo (`Tasks (9/25) · 16 nascoste`): il deck non finge mai una lista completa.
299
205
 
300
- **Persistenza.** La vista non si salva da sola — sperimentare non sporca nulla.
301
- `w` la scrive in `.claude/loom/deck-view.json` (macchina-locale, da gitignorare)
206
+ **Persistenza.** La vista non si salva da sola — sperimentare non sporca nulla. `w` la scrive in `.claude/loom/deck-view.json` (macchina-locale, da gitignorare)
302
207
  e al riavvio viene ripristinata. File assente o corrotto → default puliti.
303
208
 
304
209
  ## L'header è un selettore di vista
305
210
 
306
211
  Ogni segmento dell'header nomina un sottoinsieme che il deck sa già calcolare.
307
- `←`/`→` lo rendono **raggiungibile**: la voce attiva si evidenzia in video
212
+ `tab` lo rende **raggiungibile**: la voce attiva si evidenzia in video
308
213
  inverso e la lista sotto mostra quel sottoinsieme.
309
214
 
310
215
  ```
@@ -317,23 +222,14 @@ Sessions · tutte (317) · ●2 vive · 📌4 · +213 più vecchie · ↑7 ·
317
222
  | Tasks | `Tasks (n/N)` (filtri applicati) · `N nascoste` (il complemento esatto di quei filtri) · `N archiviabili` (Done oltre `archivableDays`, cieca ai filtri) |
318
223
  | Sessions | `{parent} (N)` · `●N vive` · `📌N` · `+N più vecchie` (le contestuali che il cap `maxContext` tronca) |
319
224
 
320
- - **Il catalogo è fisso**, anche a contatore 0 (la voce si mostra dim). Un
321
- catalogo che si accorcia sposta le voci sotto le dita, e la vista corrente può
322
- svanire mentre la guardi — togliere un filtro mentre sei su `nascoste`.
225
+ - **Il catalogo è fisso**, anche a contatore 0 (la voce si mostra dim). Un catalogo che si accorcia sposta le voci sotto le dita, e la vista corrente può svanire mentre la guardi — togliere un filtro mentre sei su `nascoste`.
323
226
  - **`↑N` `↓N` non sono viste**: contano elementi fuori finestra per altezza del
324
227
  terminale, cioè una posizione, non un insieme. Restano in coda, non selezionabili.
325
- - **Due assi ortogonali**: il pane task sceglie il *parent* delle conversazioni
326
- (`≡ tutte` / `○ spot` / una task), l'header sceglie *quale sottoinsieme* di quel
327
- parent. Cambiare parent non azzera la vista, che si ricalcola dentro il nuovo.
328
- - **`F` è inerte fuori dalla vista `Tasks`** e lo dice con una nota: su
329
- `nascoste` riapplicare i filtri non ha senso, `archiviabili` è cieca per scelta.
330
- - **La vista è volatile**: non entra in `deck-view.json`, il deck riapre sempre
331
- sulla voce 1. Un filtro salvato lo si è scelto; una vista non-default riaperta
332
- a freddo si leggerebbe come la lista intera.
333
- - **Cambiare vista riporta la selezione in cima**, righe meta comprese: nessuna
334
- regola di mantenimento, nessun tentativo di ritrovare l'elemento precedente.
335
- - Su un terminale stretto **la voce attiva è servita per prima** dal budget di
336
- larghezza: sparisce un contatore, mai la voce che dice dove sei.
228
+ - **Due assi ortogonali**: il pane task sceglie il *parent* delle conversazioni (`≡ tutte` / `○ spot` / una task), l'header sceglie *quale sottoinsieme* di quel parent. Cambiare parent non azzera la vista, che si ricalcola dentro il nuovo.
229
+ - **`F` è inerte fuori dalla vista `Tasks`** e lo dice con una nota: su `nascoste` riapplicare i filtri non ha senso, `archiviabili` è cieca per scelta.
230
+ - **La vista è volatile**: non entra in `deck-view.json`, il deck riapre sempre sulla voce 1. Un filtro salvato lo si è scelto; una vista non-default riaperta a freddo si leggerebbe come la lista intera.
231
+ - **Cambiare vista riporta la selezione in cima**, righe meta comprese: nessuna regola di mantenimento, nessun tentativo di ritrovare l'elemento precedente.
232
+ - Su un terminale stretto **la voce attiva è servita per prima** dal budget di larghezza: sparisce un contatore, mai la voce che dice dove sei.
337
233
 
338
234
  ## Installazione
339
235
 
@@ -346,9 +242,7 @@ npx @lamemind/loom-deck
346
242
  ## Requisiti runtime
347
243
 
348
244
  - **Node.js ≥ 18** (dichiarato in `engines`).
349
- - **[Ptyxis](https://gitlab.gnome.org/chergert/ptyxis)** (terminale GNOME) — **dipendenza
350
- di runtime, non risolvibile da npm**. Lo spawn di una tab (`scripts/deck-run`) invoca il
351
- binario `ptyxis`: l'**install riesce anche senza**, ma al momento dello spawn il comando
245
+ - **[Ptyxis](https://gitlab.gnome.org/chergert/ptyxis)** (terminale GNOME) — **dipendenza di runtime, non risolvibile da npm**. Lo spawn di una tab (`scripts/deck-run`) invoca il binario `ptyxis`: l'**install riesce anche senza**, ma al momento dello spawn il comando
352
246
  fallisce (gestito: handler `error` → la TUI resta viva, mostra la nota). Senza una GUI
353
247
  GNOME con Ptyxis installato, il deck naviga i task ma **non apre sessioni**.
354
248
  - **[Claude Code](https://claude.com/claude-code)** nel `PATH` — la tab spawnata avvia `claude`.
@@ -361,19 +255,9 @@ scripts/deck-run T18
361
255
  scripts/deck-run T18 --prompt-kind none|recap|preflight|run|checkpoint
362
256
  ```
363
257
 
364
- Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude 'recap stato task T18'`.
365
- Il prompt iniziale è il terzo asse dello spawn, ortogonale al binding task
366
- (`<TaskID>` vs `--no-task`) e alla continuità (nuova vs `--resume`/`--fork`):
367
- senza flag resta `recap`, `none` apre la sessione bound **senza** alcun prompt.
368
- `LOOM_DECK_ENTER_PROMPT` (placeholder `{TASK}`) resta un override che vince sul
369
- kind — tranne su `none`, che è una richiesta esplicita di non averne.
370
-
371
- La tab porta anche `PTYXIS_PROFILE` forzata al profilo bindato al progetto nel
372
- registry (`bindings/claude`), letto da dconf: è la chiave con cui loom-compass
373
- associa a un progetto lo stato annunciato dagli hook (running/ask/done). Una tab
374
- `ptyxis --tab` nuda erediterebbe il profilo di default, e l'annuncio finirebbe
375
- keyed su un UUID che nessun progetto dichiara — pallino fermo su idle, senza
376
- alcun errore visibile. Override o disattivazione via `LOOM_DECK_STATE_PROFILE`
258
+ Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude 'recap stato task T18'`. Il prompt iniziale è il terzo asse dello spawn, ortogonale al binding task (`<TaskID>` vs `--no-task`) e alla continuità (nuova vs `--resume`/`--fork`): senza flag resta `recap`, `none` apre la sessione bound **senza** alcun prompt. `LOOM_DECK_ENTER_PROMPT` (placeholder `{TASK}`) resta un override che vince sul kind — tranne su `none`, che è una richiesta esplicita di non averne.
259
+
260
+ La tab porta anche `PTYXIS_PROFILE` forzata al profilo bindato al progetto nel registry (`bindings/claude`), letto da dconf: è la chiave con cui loom-compass associa a un progetto lo stato annunciato dagli hook (running/ask/done). Una tab `ptyxis --tab` nuda erediterebbe il profilo di default, e l'annuncio finirebbe keyed su un UUID che nessun progetto dichiara — pallino fermo su idle, senza alcun errore visibile. Override o disattivazione via `LOOM_DECK_STATE_PROFILE`
377
261
  (settata a vuoto → nessun annuncio); progetto non registrato → nessun prefisso.
378
262
 
379
263
  ## Sviluppo (TUI Ink)
@@ -385,12 +269,9 @@ npm run build # tsc → dist/
385
269
  npm test # node:test sul core vista (src/view.ts), senza Ink né terminale
386
270
  ```
387
271
 
388
- Il core di filtri e ordinamenti (`src/view.ts`) è **puro**: nessun import da Ink o
389
- React, nessun I/O. È il motivo per cui è testabile con `node:test` su array
390
- fixture, mentre la TUI resta un guscio sottile che lo consuma.
272
+ Il core di filtri e ordinamenti (`src/view.ts`) è **puro**: nessun import da Ink o React, nessun I/O. È il motivo per cui è testabile con `node:test` su array fixture, mentre la TUI resta un guscio sottile che lo consuma.
391
273
 
392
- Il deck cerca `tasks.md` in `$PWD/${LOOM_DECK_DOCS_ROOT:-docs}/tasks.md`. Progetti
393
- con docs-root non-standard esportano la variabile, es. `LOOM_DECK_DOCS_ROOT=runtime`.
274
+ Il deck cerca `tasks.md` in `$PWD/${LOOM_DECK_DOCS_ROOT:-docs}/tasks.md`. Progetti con docs-root non-standard esportano la variabile, es. `LOOM_DECK_DOCS_ROOT=runtime`.
394
275
 
395
276
  La lista si **auto-aggiorna** quando `tasks.md` cambia sotto (poll su `mtime`, ~1.5s):
396
277
  crei/checkpoint una task da un'altra sessione → il deck la riflette senza riavvio.
package/dist/cli.js CHANGED
@@ -20,6 +20,7 @@ import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessio
20
20
  import { assignListCapacity, detailCapacity, isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
21
21
  import { caretWindow, cut, cutParts, pad, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
22
22
  import { scanText, sliceLine, topForOffset, } from './text-search.js';
23
+ import { parseMarkdown, sliceSpans, } from './markdown.js';
23
24
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
24
25
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
25
26
  import { loadView, saveView, viewFilePath } from './view-store.js';
@@ -627,7 +628,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
627
628
  // modale: la lista si aggiorna dal vivo, quindi `esc` deve poter ripristinare.
628
629
  const [view, setView] = useState(() => loadView(cwd));
629
630
  const [viewBackup, setViewBackup] = useState(null);
630
- // T100 — vista attiva di ciascun pane, navigata con ←/→. VOLATILE per
631
+ // T100 — vista attiva di ciascun pane, navigata con `tab`. VOLATILE per
631
632
  // decisione (D3 create): non entra in `deck-view.json`, il deck riapre sempre
632
633
  // su `Tasks` e su `{parent}`. Il criterio è il rischio di leggere una lista
633
634
  // parziale credendola completa — un filtro salvato lo si è scelto, una vista
@@ -840,18 +841,32 @@ function Deck({ cwd, tasksPath, tasksDir }) {
840
841
  // 2 padding) = 8. Sottostimare tronca un carattere, sovrastimare manda a capo
841
842
  // una riga che il budget d'altezza non ha contato.
842
843
  const sheetWidth = Math.max(20, (columns || 80) - 8);
844
+ // T75 — il markdown si rende PRIMA del wrap, e il resto della catena lavora
845
+ // sul testo reso: `**foo**` occupa 3 colonne rese e 7 grezze, quindi
846
+ // wrappare sui marker manderebbe a capo su un conteggio che il terminale non
847
+ // disegna. Memo separato dal wrap perché il parse dipende solo dal testo: un
848
+ // resize ri-wrappa 9KB, non li ri-parsa.
849
+ const sheetDoc = useMemo(() => (sheet?.text ? parseMarkdown(sheet.text) : null), [sheet]);
843
850
  // Le righe conservano i propri offset invece di essere appiattite a stringa
844
851
  // (T66 le buttava con `.map((l) => l.text)`): è ciò che rende
845
852
  // l'evidenziazione un'intersezione di intervalli invece di un caso speciale
846
- // per il match spezzato dall'a-capo.
847
- const sheetLines = useMemo(() => (sheet?.text ? wrapWithOffsets(sheet.text, sheetWidth) : []), [sheet, sheetWidth]);
853
+ // per il match spezzato dall'a-capo. Dopo T75 gli offset indicizzano il testo
854
+ // RESO, ed è l'unica coordinata coerente che resti il sorgente non è più
855
+ // ciò che sta a schermo.
856
+ const sheetLines = useMemo(() => (sheetDoc ? wrapWithOffsets(sheetDoc.text, sheetWidth) : []), [sheetDoc, sheetWidth]);
848
857
  const sheetCap = detailCapacity(rows, find?.open === true);
849
858
  const sheetMaxTop = Math.max(0, sheetLines.length - sheetCap);
850
- // Lo scan gira sulla STESSA stringa che si renderizza (`sheet.text`, già
851
- // passata da `sanitize` in `loadTaskFileText`): una rilettura del file darebbe
852
- // offset che indicizzano un documento diverso da quello a schermo, cioè
853
- // un'evidenziazione spostata di N caratteri e nessun errore.
854
- const findRes = useMemo(() => (find && sheet?.text ? scanText(sheet.text, find.q) : { occ: [], error: '' }), [find?.q, sheet]);
859
+ // Lo scan gira sulla STESSA stringa che si renderizza: cercare su un testo
860
+ // diverso da quello a schermo darebbe offset che indicizzano un altro
861
+ // documento, cioè un'evidenziazione spostata di N caratteri e nessun errore.
862
+ //
863
+ // Dopo T75 quella stringa è il testo RESO, non più il sorgente: si cerca ciò
864
+ // che si vede. Ne discende che `**` non è più cercabile — è la conseguenza
865
+ // voluta, perché a schermo non c'è; e `Priority`, che prima era `**Priority**`
866
+ // e si trovava lo stesso, continua a trovarsi.
867
+ const findRes = useMemo(() => find && sheetDoc
868
+ ? scanText(sheetDoc.text, find.q)
869
+ : { occ: [], error: '' }, [find?.q, sheetDoc]);
855
870
  // L'indice si clampa qui invece di essere corretto a ogni `setOccIdx`: la
856
871
  // lista si accorcia da sola mentre si digita, e un indice fuori range vivrebbe
857
872
  // per il tempo di un render.
@@ -975,7 +990,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
975
990
  // messaggi a mano. `verb` è l'unica cosa che cambia fra i due usi.
976
991
  function selectedTaskOr(keyLabel, verb) {
977
992
  if (focus !== 'tasks') {
978
- setNote(`${keyLabel} → ${verb}: seleziona una task (tab per il pane)`);
993
+ setNote(`${keyLabel} → ${verb}: seleziona una task ( per il pane)`);
979
994
  return null;
980
995
  }
981
996
  // T59 — la guardia è "non è una task", non "è spot": le righe meta sono due
@@ -1226,7 +1241,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1226
1241
  else
1227
1242
  setSel(paneTasks[next - META_ROWS]?.id ?? SPOT);
1228
1243
  }
1229
- // T100 — ←/→ navigano il catalogo viste del pane in focus. Il reset della
1244
+ // T100 — `tab` naviga il catalogo viste del pane in focus. Il reset della
1230
1245
  // selezione è la regola letterale «prima riga in alto», senza eccezioni: sul
1231
1246
  // pane task è `ROW_ALL` (D2 preflight — le righe meta non si saltano, e il
1232
1247
  // parent delle sessioni che torna a `tutte` è un effetto accettato); sul pane
@@ -1654,7 +1669,10 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1654
1669
  // selezione per ognuna. Sta PRIMA del ramo di default per la stessa ragione
1655
1670
  // di reader e ricerca — Ink non ha focus-trap, quindi la cattura È l'ordine
1656
1671
  // dei rami — e il `return` in fondo è ciò che impedisce a `←→` di cambiare
1657
- // pane mentre muove fra le azioni: qui quel binding non esiste più.
1672
+ // pane mentre muove fra le azioni: qui quel binding non esiste più. Cade lì
1673
+ // anche `tab`, cioè il cambio vista: dentro il detail nessuno dei due tasti
1674
+ // di navigazione del pane risponde, e non è un'eccezione da ricordare per
1675
+ // ognuno — è il `return` che chiude tutto il ramo di default in una volta.
1658
1676
  // È anche prima del ramo CTRL, quindi `^K`/`^P`/`^R` restano acceleratori
1659
1677
  // della sola lista: chi è già nel detail ha i bottoni.
1660
1678
  if (mode === 'detail') {
@@ -1779,16 +1797,22 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1779
1797
  return;
1780
1798
  }
1781
1799
  if (key.tab) {
1782
- // T100 — `tab` resta l'unico tasto che sposta il focus fra i due pane. Le
1783
- // frecce orizzontali facevano lo stesso lavoro (due tasti per un'azione)
1784
- // mentre la navigazione DENTRO un pane non ne aveva nessuno: ora sono il
1785
- // selettore di vista dell'header. Il ramo `mode === 'detail'`, anteposto e
1786
- // chiuso da `return`, non è più l'eccezione a «cambia pane» ma a «cambia
1787
- // vista» l'ordine dei rami non cambia, cambia cosa cattura.
1788
- setFocus((f) => (f === 'tasks' ? 'sessions' : 'tasks'));
1800
+ // `tab` cicla la VISTA del pane a fuoco, `←→` spostano il focus fra i due
1801
+ // pane: il criterio dell'assegnazione è spaziale. Una freccia orizzontale
1802
+ // porta con una direzione e i pane sono affiancati (task a sinistra,
1803
+ // sessioni a destra), quindi il tasto NOMINA il pane invece di limitarsi
1804
+ // a scambiarlo; un catalogo ciclico non ha un verso da rispettare, e un
1805
+ // tasto solo gli basta. `shift+tab` (backtab, `[Z`) scorre a rovescio — il
1806
+ // verso che un tasto singolo non esprime sta nel modificatore, e su cinque
1807
+ // voci risparmia quattro pressioni.
1808
+ cycleView(key.shift ? -1 : 1);
1789
1809
  }
1790
1810
  else if (key.leftArrow || key.rightArrow) {
1791
- cycleView(key.leftArrow ? -1 : 1);
1811
+ // Binding ASSOLUTO, non toggle: `←` porta sempre sui task e `→` sempre
1812
+ // sulle sessioni, quindi ripremere lo stesso tasto non riporta indietro.
1813
+ // È ciò che lo rende spaziale — la direzione indica una destinazione, e
1814
+ // con due soli pane un toggle sarebbe indistinguibile solo per caso.
1815
+ setFocus(key.leftArrow ? 'tasks' : 'sessions');
1792
1816
  }
1793
1817
  else if (key.upArrow) {
1794
1818
  if (focus === 'tasks')
@@ -1865,7 +1889,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1865
1889
  // Vive solo sul pane sessioni: il fork ha per oggetto una conversazione,
1866
1890
  // e senza focus lì non ce n'è una selezionata su cui agire.
1867
1891
  if (focus !== 'sessions') {
1868
- setNote('f → fork: seleziona una sessione (tab per il pane)');
1892
+ setNote('f → fork: seleziona una sessione ( per il pane)');
1869
1893
  }
1870
1894
  else {
1871
1895
  const s = selSessionObj;
@@ -1897,7 +1921,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1897
1921
  // pinnata STALE (l'unico modo di spinnarla). Scrive il sidecar e ricarica
1898
1922
  // subito, senza attendere il tick del poll.
1899
1923
  if (focus !== 'sessions') {
1900
- setNote('p → pin: seleziona una sessione (tab per il pane)');
1924
+ setNote('p → pin: seleziona una sessione ( per il pane)');
1901
1925
  }
1902
1926
  else if (!selSessionId) {
1903
1927
  setNote('p → nessuna sessione da pinnare');
@@ -1925,7 +1949,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1925
1949
  // su una pinnata STALE, perché annotare «questa non c'è più, era X» è
1926
1950
  // proprio il caso in cui una nota serve.
1927
1951
  if (focus !== 'sessions') {
1928
- setNote('N → nota: seleziona una sessione (tab per il pane)');
1952
+ setNote('N → nota: seleziona una sessione ( per il pane)');
1929
1953
  }
1930
1954
  else if (!selSessionId) {
1931
1955
  setNote('N → nessuna sessione da annotare');
@@ -1943,7 +1967,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1943
1967
  // il binding è nostro, il transcript è di CC — riassegnare una
1944
1968
  // conversazione il cui transcript non c'è più resta legittimo.
1945
1969
  if (focus !== 'sessions') {
1946
- setNote('A → assegna: seleziona una sessione (tab per il pane)');
1970
+ setNote('A → assegna: seleziona una sessione ( per il pane)');
1947
1971
  }
1948
1972
  else if (!selSessionId) {
1949
1973
  setNote('A → nessuna sessione da assegnare');
@@ -2061,7 +2085,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
2061
2085
  // posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
2062
2086
  // può accorciare il testo sotto uno scroll già dato.
2063
2087
  const start = Math.min(sheetTop, sheetMaxTop);
2064
- return (_jsx(DetailScreen, { id: sheet.id, title: sheet.title, missing: sheet.text === null, lines: sheetLines.slice(start, start + sheetCap), top: start, total: sheetLines.length, capacity: sheetCap, action: sheetAction, columns: columns, find: find, occ: findRes.occ, occCur: occCur }));
2088
+ return (_jsx(DetailScreen, { id: sheet.id, title: sheet.title, missing: sheet.text === null, lines: sheetLines.slice(start, start + sheetCap), spans: sheetDoc?.spans ?? [], top: start, total: sheetLines.length, capacity: sheetCap, action: sheetAction, columns: columns, find: find, occ: findRes.occ, occCur: occCur }));
2065
2089
  }
2066
2090
  // ── T52 · schermate sostitutive ─────────────────────────────────────────
2067
2091
  // Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
@@ -2351,6 +2375,50 @@ function ReaderLine({ line, occ, current, }) {
2351
2375
  // mostrerebbe più dove sono le altre.
2352
2376
  _jsx(Text, { backgroundColor: s.current ? 'cyan' : 'yellow', color: "black", children: s.text }, i)) : (_jsx(Text, { children: s.text }, i))) }));
2353
2377
  }
2378
+ /** Resa di ogni costrutto markdown (T75 · D4): un solo livello di enfasi per
2379
+ * costrutto, senza un secondo alfabeto da imparare. Heading uguali a ogni
2380
+ * livello — la gerarchia la porta già il testo. `code` e `fence` condividono
2381
+ * il giallo perché sono lo stesso costrutto a due granularità: dargli due
2382
+ * colori direbbe che sono due cose. */
2383
+ const MD_STYLE = {
2384
+ heading: { bold: true, color: 'cyan' },
2385
+ bold: { bold: true },
2386
+ code: { color: 'yellow' },
2387
+ fence: { color: 'yellow' },
2388
+ };
2389
+ /**
2390
+ * Una riga del detail (T75): markdown reso, con sopra l'evidenziazione della
2391
+ * ricerca.
2392
+ *
2393
+ * Due segmentazioni sulla stessa riga, annidate e non fuse: prima si taglia sui
2394
+ * costrutti markdown, poi ogni pezzo si ritaglia sulle occorrenze. L'ordine non
2395
+ * è indifferente — così un match a cavallo di un `**grassetto**` resta
2396
+ * evidenziato per intero e insieme conserva il grassetto sulla metà che ce
2397
+ * l'ha, cosa che una segmentazione unica dovrebbe risolvere decidendo chi vince.
2398
+ *
2399
+ * Gli offset di `occ` e di `spans` indicizzano ENTRAMBI il testo reso: è ciò
2400
+ * che permette di comporli senza rimappature. Vedi `sheetDoc` per il perché la
2401
+ * ricerca del detail ha smesso di scandire il sorgente.
2402
+ */
2403
+ function DetailLine({ line, spans, occ, current, }) {
2404
+ const styled = sliceSpans(line, spans);
2405
+ // Riga vuota → uno spazio: un `<Text>` senza contenuto Ink non lo disegna, e
2406
+ // il testo si compatterebbe perdendo la struttura del file.
2407
+ if (styled.length === 0)
2408
+ return _jsx(Text, { wrap: "truncate-end", children: " " });
2409
+ let off = line.start;
2410
+ return (_jsx(Text, { wrap: "truncate-end", children: styled.map((seg, i) => {
2411
+ const st = seg.kind ? MD_STYLE[seg.kind] : undefined;
2412
+ const at = off;
2413
+ off += seg.text.length;
2414
+ // Senza ricerca aperta il secondo taglio non ha niente da tagliare, e
2415
+ // saltarlo evita di allocare tre array per ogni riga a ogni freccia.
2416
+ if (occ.length === 0) {
2417
+ return (_jsx(Text, { bold: st?.bold, color: st?.color, children: seg.text }, i));
2418
+ }
2419
+ return (_jsx(Text, { bold: st?.bold, color: st?.color, children: sliceLine(seg.text, at, occ, current).map((p, j) => p.hit ? (_jsx(Text, { backgroundColor: p.current ? 'cyan' : 'yellow', color: "black", children: p.text }, j)) : (_jsx(Text, { children: p.text }, j))) }, i));
2420
+ }) }));
2421
+ }
2354
2422
  /** Campo della ricerca nel detail: finestra ancorata al caret, cursore inverso
2355
2423
  * sulla cella reale. Gemello di `EditTextField` senza la label, che qui sta
2356
2424
  * fuori perché il campo vive in FLUSSO su una riga condivisa col contatore —
@@ -2372,7 +2440,7 @@ function DetailFindField({ value, caret, cols }) {
2372
2440
  * all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione. La
2373
2441
  * navigazione da tastiera ci si sovrappone senza conflitti.
2374
2442
  */
2375
- function DetailScreen({ id, title, missing, lines, top, total, capacity, action, columns, find, occ, occCur, }) {
2443
+ function DetailScreen({ id, title, missing, lines, spans, top, total, capacity, action, columns, find, occ, occCur, }) {
2376
2444
  const last = Math.min(total, top + capacity);
2377
2445
  // Il taglio lo fa il chiamante (invariante ③ di width.ts): la riga bottoni è
2378
2446
  // ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso — ma
@@ -2393,10 +2461,7 @@ function DetailScreen({ id, title, missing, lines, top, total, capacity, action,
2393
2461
  if (dropped(shown) > 0)
2394
2462
  shown = cutParts(parts, Math.max(0, width - 6));
2395
2463
  const cutCount = dropped(shown);
2396
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: id }), " \u00B7 ", cut(title, Math.max(10, width - 34)), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), find?.open ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " occorrenza \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " caret \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " tieni \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^F" }), " cerca \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " esegui \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] })), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: missing ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: [WARN, " task file non trovato \u00B7 le azioni restano attive (deck-run risolve la task per id)"] })) : (
2397
- // Riga vuota → uno spazio: un `<Text>` senza contenuto Ink non lo
2398
- // disegna, e il testo si compatterebbe perdendo la struttura del file.
2399
- lines.map((l, i) => _jsx(ReaderLine, { line: l, occ: occ, current: occCur }, top + i))) }), find?.open ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "cerca " }), _jsx(DetailFindField, { value: find.q, caret: find.caret, cols: Math.max(10, width - 28) }), find.q.length === 0 ? (_jsx(Text, { dimColor: true, children: " \u00B7 digita per cercare" })) : occ.length === 0 ? (_jsx(Text, { color: "yellow", children: " \u00B7 nessuna occorrenza" })) : (_jsxs(Text, { color: "cyan", children: [' ', "\u00B7 ", occCur + 1, "/", occ.length] }))] }) })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === action, color: i / 2 === action ? 'green' : 'gray', children: part }, i))), cutCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cutCount] }) : null] }) })] }));
2464
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: id }), " \u00B7 ", cut(title, Math.max(10, width - 34)), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), find?.open ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " occorrenza \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " caret \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " tieni \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^F" }), " cerca \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " esegui \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] })), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: missing ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: [WARN, " task file non trovato \u00B7 le azioni restano attive (deck-run risolve la task per id)"] })) : (lines.map((l, i) => (_jsx(DetailLine, { line: l, spans: spans, occ: occ, current: occCur }, top + i)))) }), find?.open ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "cerca " }), _jsx(DetailFindField, { value: find.q, caret: find.caret, cols: Math.max(10, width - 28) }), find.q.length === 0 ? (_jsx(Text, { dimColor: true, children: " \u00B7 digita per cercare" })) : occ.length === 0 ? (_jsx(Text, { color: "yellow", children: " \u00B7 nessuna occorrenza" })) : (_jsxs(Text, { color: "cyan", children: [' ', "\u00B7 ", occCur + 1, "/", occ.length] }))] }) })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === action, color: i / 2 === action ? 'green' : 'gray', children: part }, i))), cutCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cutCount] }) : null] }) })] }));
2400
2465
  }
2401
2466
  /**
2402
2467
  * Larghezza del testo dentro la lista della schermata di assegnazione: box
@@ -2457,7 +2522,7 @@ function AssignScreen({ sessionId, label, current, filter, rows, selected, match
2457
2522
  * segmento resta il primo a cedere il posto (`↑↓` in coda alle voci navigabili).
2458
2523
  *
2459
2524
  * T100 — la riga non è più informativa: le voci del catalogo sono SELEZIONABILI
2460
- * con ←/→, e l'attiva si distingue in video inverso (D5 — costa 0 colonne e non
2525
+ * con `tab`, e l'attiva si distingue in video inverso (D5 — costa 0 colonne e non
2461
2526
  * entra in gara con la semantica di colore già occupata). Le voci ci sono tutte
2462
2527
  * anche a 0 (D1): un catalogo che si accorcia sposta le voci sotto le dita.
2463
2528
  * L'ordine è vincolato — le navigabili PRIMA di `↑N`/`↓N`, che cadono per primi
@@ -0,0 +1,260 @@
1
+ // Rendering del markdown per il detail della task (T75): dal sorgente al
2
+ // testo VISIBILE più gli intervalli da colorare.
3
+ //
4
+ // PERCHÉ IL PARSER PRODUCE TESTO E NON COLORE. La tentazione naturale è
5
+ // restituire una stringa già colorata con escape ANSI dentro. Sarebbe il modo
6
+ // più corto e il più sbagliato: `width.ts` misura CARATTERI per contare le
7
+ // colonne del terminale, e una sequenza `ESC[1m` le violerebbe tutte —
8
+ // `termWidth` conterebbe colonne che il terminale non disegna, quindi l'a-capo
9
+ // cadrebbe nel posto sbagliato e il bordo del pane verrebbe mangiato.
10
+ //
11
+ // Da qui la separazione: qui si producono `(testo-visibile, span tipizzati)`,
12
+ // il colore lo mette il render intersecando gli span con le righe wrappate.
13
+ //
14
+ // PERCHÉ I MARKER VANNO VIA PRIMA DEL WRAP, non dopo. `**foo**` occupa 3
15
+ // colonne rese e 7 grezze. Wrappare sul testo con i marker manderebbe a capo
16
+ // contando 7, cioè ogni riga uscirebbe più corta del budget di 4 colonne per
17
+ // ogni `**` — un a-capo sbagliato, non un difetto estetico. La catena è quindi
18
+ // obbligata:
19
+ //
20
+ // sanitize(raw) → parseMarkdown → wrapWithOffsets → interseca
21
+ //
22
+ // `sanitize` sta PRIMA (T75 · D1) perché non conserva la lunghezza: timbra col
23
+ // VS16 ogni glifo BMP largo 2 e sostituisce i discordi (`⚠`→`❗️`, `✔`→`✅️`),
24
+ // quindi applicarla dopo sposterebbe gli offset appena calcolati. Applicarla
25
+ // prima è sicuro perché i marker markdown sono ASCII e cadono fuori dal suo
26
+ // insieme di rischio. Nel deck la sanificazione avviene già al confine di
27
+ // caricamento (`loadTaskFileText`), quindi qui il testo arriva timbrato.
28
+ //
29
+ // GLI SPAN SONO PIATTI E DISGIUNTI. Il nesting del markdown (codice dentro un
30
+ // heading, grassetto dentro un bullet) è risolto QUI, non al render: ogni
31
+ // carattere del testo reso appartiene al più a uno span. Il render diventa
32
+ // così una mappa `kind → attributi Ink` senza logica di merge, ed è la stessa
33
+ // proprietà che permette di comporre questi span con quelli della ricerca
34
+ // (`sliceLine` in `text-search.ts`) senza che le due segmentazioni si
35
+ // contendano lo stesso carattere.
36
+ /** Apertura o chiusura di un blocco di codice: almeno tre backtick a inizio
37
+ * riga, eventualmente indentati. La info-string dopo i backtick (` ```bash `)
38
+ * fa parte del delimitatore e sparisce con lui. */
39
+ const FENCE = /^\s*```/;
40
+ /** `# ` … `###### ` — lo spazio è obbligatorio, o `#tag` diventerebbe un
41
+ * titolo. Il livello non si conserva: la resa è la stessa per tutti (D4). */
42
+ const HEADING = /^(#{1,6})\s+(.*)$/;
43
+ /** Voce di elenco: indentazione + marker + almeno uno spazio. Lo spazio
44
+ * obbligatorio è ciò che tiene fuori `---` (riga orizzontale) e `**bold**` a
45
+ * inizio riga, che altrimenti passerebbero per bullet. Indentazione e spazio
46
+ * dopo il marker sono catturati per essere RIMESSI identici: il glifo occupa
47
+ * una colonna come il marker che sostituisce, quindi conservare i due lati
48
+ * conserva la colonna d'inizio del testo, che è ciò che tiene allineato un
49
+ * elenco scritto a mano. */
50
+ const BULLET = /^(\s*)[-*+](\s+)/;
51
+ /** Il glifo del bullet. Concorde (`stringWidth` 1, `termWidth` 1), quindi si
52
+ * può inserire DOPO `sanitize` senza violare l'invariante ① di `width.ts`. */
53
+ const BULLET_GLYPH = '•';
54
+ /**
55
+ * Testo reso e span di stile a partire dal markdown grezzo.
56
+ *
57
+ * Due livelli, e l'ordine fra loro non è negoziabile: il blocco decide se
58
+ * l'inline gira o è sospeso. Dentro un fence `**` e i backtick sono contenuto,
59
+ * non marcatura — una regex per riga non potrebbe saperlo, perché il fence ha
60
+ * stato multi-riga.
61
+ *
62
+ * Fuori scope per scelta (T75): tabelle — l'inline ci gira sopra come ovunque,
63
+ * ma le colonne non vengono allineate — corsivo e link.
64
+ */
65
+ export function parseMarkdown(src) {
66
+ const out = [];
67
+ const spans = [];
68
+ /** Offset del reso a cui inizierà la prossima riga emessa. */
69
+ let base = 0;
70
+ let inFence = false;
71
+ const emit = (text, lineSpans) => {
72
+ for (const s of lineSpans) {
73
+ spans.push({ start: base + s.start, end: base + s.end, kind: s.kind });
74
+ }
75
+ out.push(text);
76
+ base += text.length + 1; // +1 = il '\n' che unirà questa riga alla prossima
77
+ };
78
+ for (const line of src.split('\n')) {
79
+ if (FENCE.test(line)) {
80
+ // D2 — il delimitatore è un marker quanto `**` e `#`, quindi sparisce
81
+ // come gli altri: il confine del blocco lo porta il colore uniforme.
82
+ // Conseguenza contabile: due righe in meno per fence nel totale che
83
+ // alimenta il contatore `righe N-M di T`.
84
+ inFence = !inFence;
85
+ continue;
86
+ }
87
+ if (inFence) {
88
+ emit(line, line.length > 0 ? [{ start: 0, end: line.length, kind: 'fence' }] : []);
89
+ continue;
90
+ }
91
+ const h = HEADING.exec(line);
92
+ if (h) {
93
+ const body = h[2];
94
+ const r = scanInline(body, 'heading');
95
+ emit(r.text, r.spans);
96
+ continue;
97
+ }
98
+ const b = BULLET.exec(line);
99
+ if (b) {
100
+ const r = scanInline(line.slice(b[0].length), null);
101
+ const prefix = b[1] + BULLET_GLYPH + b[2];
102
+ emit(prefix + r.text, r.spans.map((s) => ({ ...s, start: s.start + prefix.length, end: s.end + prefix.length })));
103
+ continue;
104
+ }
105
+ const r = scanInline(line, null);
106
+ emit(r.text, r.spans);
107
+ }
108
+ return { text: out.join('\n'), spans };
109
+ }
110
+ /**
111
+ * Livello inline: `**grassetto**` e `` `codice` `` su UNA riga.
112
+ *
113
+ * `base` è il costrutto che avvolge la riga (`heading`) o `null` per il testo
114
+ * normale, e serve a due cose: riempire i tratti non marcati con lo stile del
115
+ * blocco, e assorbire il grassetto quando il blocco è già enfatizzato — un
116
+ * `**` dentro un titolo non deve spezzarlo in un pezzo cyan e uno bianco.
117
+ *
118
+ * **Il perimetro è la riga, e questo è il presidio contro il marker spaiato.**
119
+ * Un `**` che non si chiude non può mangiare il resto del documento perché la
120
+ * ricerca del chiudente non esce dalla riga: resta letterale, il testo non si
121
+ * perde e il documento sotto è intatto.
122
+ *
123
+ * Precedenza al codice, come in CommonMark: dentro `` `**x**` `` gli asterischi
124
+ * sono contenuto. Ne discende l'ordine dei rami qui sotto.
125
+ */
126
+ function scanInline(src, base) {
127
+ let text = '';
128
+ const spans = [];
129
+ /** Inizio del tratto corrente non ancora coperto da uno span inline. */
130
+ let plain = 0;
131
+ let i = 0;
132
+ const closePlain = () => {
133
+ if (base && text.length > plain)
134
+ spans.push({ start: plain, end: text.length, kind: base });
135
+ plain = text.length;
136
+ };
137
+ while (i < src.length) {
138
+ const ch = src[i];
139
+ if (ch === '`') {
140
+ // Delimitatore a N backtick: chiude su una sequenza di ESATTAMENTE N.
141
+ // Serve davvero, non è zelo — la doc di questo progetto cita i backtick
142
+ // scrivendo `` `code` ``, e un parser a un backtick solo la ridurrebbe a
143
+ // poltiglia.
144
+ let n = 0;
145
+ while (src[i + n] === '`')
146
+ n++;
147
+ const close = findFenceRun(src, i + n, n);
148
+ if (close >= 0) {
149
+ closePlain();
150
+ const start = text.length;
151
+ text += stripPadding(src.slice(i + n, close));
152
+ if (text.length > start)
153
+ spans.push({ start, end: text.length, kind: 'code' });
154
+ plain = text.length;
155
+ i = close + n;
156
+ continue;
157
+ }
158
+ // Non si chiude entro la riga: i backtick sono testo.
159
+ text += src.slice(i, i + n);
160
+ i += n;
161
+ continue;
162
+ }
163
+ if (ch === '*' && src[i + 1] === '*') {
164
+ const close = src.indexOf('**', i + 2);
165
+ if (close >= 0) {
166
+ closePlain();
167
+ const start = text.length;
168
+ // Il contenuto si riparsa: `**testo con `codice`**` è legittimo, e il
169
+ // codice dentro vince sul grassetto perché è più specifico.
170
+ const inner = scanInline(src.slice(i + 2, close), base ?? 'bold');
171
+ text += inner.text;
172
+ for (const s of inner.spans) {
173
+ spans.push({ start: start + s.start, end: start + s.end, kind: s.kind });
174
+ }
175
+ plain = text.length;
176
+ i = close + 2;
177
+ continue;
178
+ }
179
+ text += '**';
180
+ i += 2;
181
+ continue;
182
+ }
183
+ text += ch;
184
+ i++;
185
+ }
186
+ closePlain();
187
+ return { text, spans: coalesce(spans) };
188
+ }
189
+ /** Prima sequenza di ESATTAMENTE `n` backtick a partire da `from`; -1 se non
190
+ * c'è. Una sequenza più lunga non chiude — è la regola CommonMark, ed è ciò
191
+ * che permette a `` `` ` `` `` di contenere un backtick singolo. */
192
+ function findFenceRun(s, from, n) {
193
+ for (let i = from; i < s.length; i++) {
194
+ if (s[i] !== '`')
195
+ continue;
196
+ let k = 0;
197
+ while (s[i + k] === '`')
198
+ k++;
199
+ if (k === n)
200
+ return i;
201
+ i += k - 1;
202
+ }
203
+ return -1;
204
+ }
205
+ /** Uno spazio di cortesia in testa e in coda si toglie: serve a scrivere
206
+ * `` ` `` senza incollarlo ai delimitatori, non a spaziare il risultato. */
207
+ function stripPadding(s) {
208
+ if (s.length > 1 && s.startsWith(' ') && s.endsWith(' ') && s.trim().length > 0) {
209
+ return s.slice(1, -1);
210
+ }
211
+ return s;
212
+ }
213
+ /** Fonde gli intervalli adiacenti dello stesso kind: il riempimento del tratto
214
+ * base ne produce a raffica, e ognuno costerebbe un `<Text>` in più al
215
+ * render. */
216
+ function coalesce(spans) {
217
+ const out = [];
218
+ for (const s of spans) {
219
+ const last = out[out.length - 1];
220
+ if (last && last.kind === s.kind && last.end === s.start)
221
+ last.end = s.end;
222
+ else
223
+ out.push({ ...s });
224
+ }
225
+ return out;
226
+ }
227
+ /**
228
+ * Taglia una riga wrappata nei pezzi da colorare, intersecando gli offset.
229
+ *
230
+ * Gemello di `sliceLine` (`text-search.ts`), che fa lo stesso lavoro con un
231
+ * intervallo booleano invece che tipizzato: la riga è una fetta CONTIGUA del
232
+ * testo reso, quindi uno span a cavallo dell'a-capo si colora su entrambe le
233
+ * righe senza nessun caso speciale — entrambe lo intersecano.
234
+ *
235
+ * Restituisce sempre la copertura completa della riga, buchi inclusi come
236
+ * segmenti `kind: null`: chi renderizza concatena e basta, senza dover
237
+ * ricostruire ciò che è rimasto fuori.
238
+ */
239
+ export function sliceSpans(line, spans) {
240
+ const out = [];
241
+ const { text, start } = line;
242
+ let pos = 0;
243
+ for (const s of spans) {
244
+ if (s.end <= start)
245
+ continue;
246
+ if (s.start >= start + text.length)
247
+ break; // `spans` è ordinato: il resto è oltre
248
+ const a = Math.max(0, Math.min(text.length, s.start - start));
249
+ const b = Math.max(0, Math.min(text.length, s.end - start));
250
+ if (b <= a || a < pos)
251
+ continue;
252
+ if (a > pos)
253
+ out.push({ text: text.slice(pos, a), kind: null });
254
+ out.push({ text: text.slice(a, b), kind: s.kind });
255
+ pos = b;
256
+ }
257
+ if (pos < text.length)
258
+ out.push({ text: text.slice(pos), kind: null });
259
+ return out;
260
+ }
@@ -1,7 +1,7 @@
1
1
  // T100 — Catalogo delle viste dei due pane. L'header smette di essere una riga
2
2
  // di contatori e diventa un SELETTORE: ogni segmento che nomina un sottoinsieme
3
3
  // calcolabile (`nascoste`, `archiviabili`, `vive`, `📌`, `+più vecchie`) è una
4
- // vista raggiungibile con ←/→.
4
+ // vista raggiungibile con `tab`.
5
5
  //
6
6
  // Modulo PURO: nessun import da ink/react, nessun I/O. Il calcolo resta dove
7
7
  // stava — `view.ts` per i filtri task, `session-list.ts` per l'assemblaggio a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
5
5
  "type": "module",
6
6
  "bin": {