@lamemind/loom-deck 0.32.0 → 0.33.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 +55 -174
- package/dist/cli.js +69 -13
- package/dist/markdown.js +260 -0
- package/package.json +1 -1
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,8 +40,7 @@ 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
|
|---|---|---|
|
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
125
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,24 +193,17 @@ 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
|
|
@@ -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
|
-
|
|
327
|
-
|
|
328
|
-
-
|
|
329
|
-
|
|
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
|
-
|
|
366
|
-
(
|
|
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';
|
|
@@ -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
|
-
|
|
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
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
|
|
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.
|
|
@@ -2061,7 +2076,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
2061
2076
|
// posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
|
|
2062
2077
|
// può accorciare il testo sotto uno scroll già dato.
|
|
2063
2078
|
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 }));
|
|
2079
|
+
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
2080
|
}
|
|
2066
2081
|
// ── T52 · schermate sostitutive ─────────────────────────────────────────
|
|
2067
2082
|
// Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
|
|
@@ -2351,6 +2366,50 @@ function ReaderLine({ line, occ, current, }) {
|
|
|
2351
2366
|
// mostrerebbe più dove sono le altre.
|
|
2352
2367
|
_jsx(Text, { backgroundColor: s.current ? 'cyan' : 'yellow', color: "black", children: s.text }, i)) : (_jsx(Text, { children: s.text }, i))) }));
|
|
2353
2368
|
}
|
|
2369
|
+
/** Resa di ogni costrutto markdown (T75 · D4): un solo livello di enfasi per
|
|
2370
|
+
* costrutto, senza un secondo alfabeto da imparare. Heading uguali a ogni
|
|
2371
|
+
* livello — la gerarchia la porta già il testo. `code` e `fence` condividono
|
|
2372
|
+
* il giallo perché sono lo stesso costrutto a due granularità: dargli due
|
|
2373
|
+
* colori direbbe che sono due cose. */
|
|
2374
|
+
const MD_STYLE = {
|
|
2375
|
+
heading: { bold: true, color: 'cyan' },
|
|
2376
|
+
bold: { bold: true },
|
|
2377
|
+
code: { color: 'yellow' },
|
|
2378
|
+
fence: { color: 'yellow' },
|
|
2379
|
+
};
|
|
2380
|
+
/**
|
|
2381
|
+
* Una riga del detail (T75): markdown reso, con sopra l'evidenziazione della
|
|
2382
|
+
* ricerca.
|
|
2383
|
+
*
|
|
2384
|
+
* Due segmentazioni sulla stessa riga, annidate e non fuse: prima si taglia sui
|
|
2385
|
+
* costrutti markdown, poi ogni pezzo si ritaglia sulle occorrenze. L'ordine non
|
|
2386
|
+
* è indifferente — così un match a cavallo di un `**grassetto**` resta
|
|
2387
|
+
* evidenziato per intero e insieme conserva il grassetto sulla metà che ce
|
|
2388
|
+
* l'ha, cosa che una segmentazione unica dovrebbe risolvere decidendo chi vince.
|
|
2389
|
+
*
|
|
2390
|
+
* Gli offset di `occ` e di `spans` indicizzano ENTRAMBI il testo reso: è ciò
|
|
2391
|
+
* che permette di comporli senza rimappature. Vedi `sheetDoc` per il perché la
|
|
2392
|
+
* ricerca del detail ha smesso di scandire il sorgente.
|
|
2393
|
+
*/
|
|
2394
|
+
function DetailLine({ line, spans, occ, current, }) {
|
|
2395
|
+
const styled = sliceSpans(line, spans);
|
|
2396
|
+
// Riga vuota → uno spazio: un `<Text>` senza contenuto Ink non lo disegna, e
|
|
2397
|
+
// il testo si compatterebbe perdendo la struttura del file.
|
|
2398
|
+
if (styled.length === 0)
|
|
2399
|
+
return _jsx(Text, { wrap: "truncate-end", children: " " });
|
|
2400
|
+
let off = line.start;
|
|
2401
|
+
return (_jsx(Text, { wrap: "truncate-end", children: styled.map((seg, i) => {
|
|
2402
|
+
const st = seg.kind ? MD_STYLE[seg.kind] : undefined;
|
|
2403
|
+
const at = off;
|
|
2404
|
+
off += seg.text.length;
|
|
2405
|
+
// Senza ricerca aperta il secondo taglio non ha niente da tagliare, e
|
|
2406
|
+
// saltarlo evita di allocare tre array per ogni riga a ogni freccia.
|
|
2407
|
+
if (occ.length === 0) {
|
|
2408
|
+
return (_jsx(Text, { bold: st?.bold, color: st?.color, children: seg.text }, i));
|
|
2409
|
+
}
|
|
2410
|
+
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));
|
|
2411
|
+
}) }));
|
|
2412
|
+
}
|
|
2354
2413
|
/** Campo della ricerca nel detail: finestra ancorata al caret, cursore inverso
|
|
2355
2414
|
* sulla cella reale. Gemello di `EditTextField` senza la label, che qui sta
|
|
2356
2415
|
* fuori perché il campo vive in FLUSSO su una riga condivisa col contatore —
|
|
@@ -2372,7 +2431,7 @@ function DetailFindField({ value, caret, cols }) {
|
|
|
2372
2431
|
* all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione. La
|
|
2373
2432
|
* navigazione da tastiera ci si sovrappone senza conflitti.
|
|
2374
2433
|
*/
|
|
2375
|
-
function DetailScreen({ id, title, missing, lines, top, total, capacity, action, columns, find, occ, occCur, }) {
|
|
2434
|
+
function DetailScreen({ id, title, missing, lines, spans, top, total, capacity, action, columns, find, occ, occCur, }) {
|
|
2376
2435
|
const last = Math.min(total, top + capacity);
|
|
2377
2436
|
// Il taglio lo fa il chiamante (invariante ③ di width.ts): la riga bottoni è
|
|
2378
2437
|
// ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso — ma
|
|
@@ -2393,10 +2452,7 @@ function DetailScreen({ id, title, missing, lines, top, total, capacity, action,
|
|
|
2393
2452
|
if (dropped(shown) > 0)
|
|
2394
2453
|
shown = cutParts(parts, Math.max(0, width - 6));
|
|
2395
2454
|
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] }) })] }));
|
|
2455
|
+
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
2456
|
}
|
|
2401
2457
|
/**
|
|
2402
2458
|
* Larghezza del testo dentro la lista della schermata di assegnazione: box
|
package/dist/markdown.js
ADDED
|
@@ -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
|
+
}
|
package/package.json
CHANGED