@lamemind/loom-deck 0.28.0 → 0.30.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 +48 -24
- package/dist/cli.js +385 -44
- package/dist/live-sessions.js +135 -0
- package/dist/search.js +2 -2
- package/dist/tasks.js +23 -0
- package/dist/text-search.js +100 -0
- package/dist/viewport.js +35 -0
- package/package.json +1 -1
- package/scripts/deck-run +23 -18
package/README.md
CHANGED
|
@@ -10,9 +10,10 @@ già bound alla task via `LOOM_TASK`.
|
|
|
10
10
|
↑↓ scegli la task → ⏎ → tab CC di fianco → LOOM_TASK bound
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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)).
|
|
16
17
|
|
|
17
18
|
Entrambi i prefissi del contratto loom entrano in lista: **`T`** (code task) e
|
|
18
19
|
**`D`** (doc task). Il trattamento è identico ovunque tranne che su `^R`, dove il
|
|
@@ -37,7 +38,7 @@ Ptyxis. Chiudere il deck non uccide le sessioni. La tab nasce nella window *atti
|
|
|
37
38
|
|
|
38
39
|
## Stato
|
|
39
40
|
|
|
40
|
-
Bootstrap + spike ① + **TUI ③** funzionante (legge `tasks.md`, `⏎`
|
|
41
|
+
Bootstrap + spike ① + **TUI ③** funzionante (legge `tasks.md`, `⏎` apre il detail). Roadmap:
|
|
41
42
|
|
|
42
43
|
```
|
|
43
44
|
① spike spawn-tab + LOOM_TASK ✅ scripts/deck-run
|
|
@@ -56,7 +57,7 @@ senza collisioni:
|
|
|
56
57
|
|---|---|---|
|
|
57
58
|
| `↑` `↓` | naviga nella lista | |
|
|
58
59
|
| `←` `→` `tab` | cambia pane | |
|
|
59
|
-
| `⏎` | azione primaria del pane | Tasks →
|
|
60
|
+
| `⏎` | azione primaria del pane | Tasks → apre il **detail** della task selezionata · Sessions → riprende (`claude --resume`) la sessione selezionata |
|
|
60
61
|
| **MAIUSCOLA** | **apre un modale** | cattura tutti i tasti; `esc` annulla, non esce |
|
|
61
62
|
| minuscola | azione immediata, one-shot | |
|
|
62
63
|
| `CTRL`+lettera | idiomi universali, toggle dentro i modali di testo, varianti di un'azione | `^F` = find, come ovunque · `^K`/`^P`/`^R` = spawn con un altro prompt |
|
|
@@ -79,6 +80,7 @@ Assegnazioni correnti:
|
|
|
79
80
|
| modale | `S` | sort chain |
|
|
80
81
|
| modale | `F` | filtri |
|
|
81
82
|
| modale | `^F` | **ricerca full-text** nelle conversazioni del progetto |
|
|
83
|
+
| modale | `⏎` | **detail** della task selezionata: task file scrollabile + barra azioni |
|
|
82
84
|
| immediata | `^K` | spawna la task selezionata col prompt di **recap** |
|
|
83
85
|
| immediata | `^P` | spawna la task selezionata sul **preflight** |
|
|
84
86
|
| immediata | `^R` | spawna la task selezionata in **esecuzione** |
|
|
@@ -91,7 +93,7 @@ Assegnazioni correnti:
|
|
|
91
93
|
Il footer è **due righe con due nature diverse**:
|
|
92
94
|
|
|
93
95
|
```
|
|
94
|
-
|
|
96
|
+
⏎ detail · ^K/^P/^R spawn · ^F cerca · C nuova · E edit · S sort · F filtri · w salva
|
|
95
97
|
t 💻 · c 🤖 · 1 📝 codium · 2 ☕ idea
|
|
96
98
|
```
|
|
97
99
|
|
|
@@ -123,28 +125,50 @@ finestra Ptyxis, senza passare da un modale. `c` (minuscola, azione) e `C`
|
|
|
123
125
|
(maiuscola, modale create-task) restano distinte per la regola sopra — così come
|
|
124
126
|
`f` (fork) e `F` (filtri).
|
|
125
127
|
|
|
126
|
-
### I
|
|
128
|
+
### I cinque modi di entrare in una task
|
|
127
129
|
|
|
128
|
-
Sulla task selezionata (focus sul pane Tasks)
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
Sulla task selezionata (focus sul pane Tasks) si apre una sessione **bound**:
|
|
131
|
+
`LOOM_TASK` iniettata, `sessionId` pinnato, binding scritto nel sidecar. Fra i
|
|
132
|
+
modi cambia **solo il prompt iniziale**.
|
|
131
133
|
|
|
132
|
-
| Tasto | Prompt |
|
|
133
|
-
|
|
134
|
-
|
|
|
135
|
-
| `^K` | `recap stato task <id>` — prompt diretto, nessuna skill |
|
|
136
|
-
| `^P` | `/loom-works:preflight-task <id>` |
|
|
137
|
-
| `^R` | `/loom-works:run-task <id>`, oppure `/loom-works:run-doc <id>` se l'id è una `D` |
|
|
134
|
+
| Azione (detail) | Tasto diretto | Prompt |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `open` | — | nessuno — il contesto lo carica l'hook `SessionStart`, il primo messaggio lo scrivi tu |
|
|
137
|
+
| `status` | `^K` | `recap stato task <id>` — prompt diretto, nessuna skill |
|
|
138
|
+
| `preflight` | `^P` | `/loom-works:preflight-task <id>` |
|
|
139
|
+
| `run` | `^R` | `/loom-works:run-task <id>`, oppure `/loom-works:run-doc <id>` se l'id è una `D` |
|
|
140
|
+
| `checkpoint` | — | `/loom-works:checkpoint-task <id>` |
|
|
138
141
|
|
|
139
142
|
Il prompt iniziale è la scelta di **chi apre**, non una proprietà della task: lo
|
|
140
143
|
stesso task file si apre per leggerne lo stato, per congelarne le decisioni, per
|
|
141
|
-
eseguirlo o per entrarci a mani nude — e
|
|
142
|
-
|
|
143
|
-
|
|
144
|
+
eseguirlo o per entrarci a mani nude — e l'azione scelta *è* quell'intento.
|
|
145
|
+
|
|
146
|
+
Le due superfici sono complementari, non alternative. `⏎` apre il **detail** e lì
|
|
147
|
+
si legge la Description *mentre* si decide: è il caso normale, perché la domanda
|
|
148
|
+
«quale azione?» quasi sempre si risponde leggendo la task. I tre `CTRL` saltano
|
|
149
|
+
il passaggio per chi lo sa già. Dentro il detail sono inerti — i bottoni sono lì.
|
|
150
|
+
|
|
151
|
+
Col focus sul pane Sessions né `⏎` né i `CTRL` spawnano: l'oggetto dell'azione è
|
|
152
|
+
la task selezionata, e senza quel pane a fuoco non ce n'è una (il deck lo dice
|
|
153
|
+
nella riga di nota, invece di aprire qualcosa a caso).
|
|
154
|
+
|
|
155
|
+
### `⏎` — il detail della task
|
|
156
|
+
|
|
157
|
+
Overlay fullscreen a due zone: il **task file per intero**, scrollabile
|
|
158
|
+
(`↑↓` riga, `PgUp`/`PgDn` pagina, `g`/`G` estremi), e una riga di **bottoni**
|
|
159
|
+
navigabile con `←→`, con `open` a fuoco all'apertura. `⏎` esegue l'azione
|
|
160
|
+
selezionata e chiude; `esc` chiude lasciando la lista con la stessa selezione.
|
|
161
|
+
|
|
162
|
+
Le azioni non sono un catalogo nuovo: chiamano lo **stesso** percorso di spawn
|
|
163
|
+
dei tasti diretti. Una superficie in più, zero percorsi di spawn in più.
|
|
164
|
+
|
|
165
|
+
Sono bottoni affiancati e non voci di un menu verticale perché è la forma che
|
|
166
|
+
sopravvive all'arrivo del mouse: un rettangolo ha già coordinate e area
|
|
167
|
+
cliccabile, una lista di voci andrebbe rifatta.
|
|
144
168
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
169
|
+
Dentro il detail `←→` non cambia più pane — il ramo `useInput` della modalità
|
|
170
|
+
chiude prima di arrivare a quello di default. Ink non ha focus-trap: la cattura
|
|
171
|
+
**è** l'ordine dei rami.
|
|
148
172
|
|
|
149
173
|
Il catalogo dei prompt vive in `scripts/deck-run`, non nella TUI: è il primitive
|
|
150
174
|
UI-agnostico, il deck gli passa un **simbolo** (`--prompt-kind`) e non una
|
|
@@ -298,7 +322,7 @@ npx @lamemind/loom-deck
|
|
|
298
322
|
```bash
|
|
299
323
|
# dalla project dir con un tasks.md
|
|
300
324
|
scripts/deck-run T18
|
|
301
|
-
scripts/deck-run T18 --prompt-kind none|recap|preflight|run
|
|
325
|
+
scripts/deck-run T18 --prompt-kind none|recap|preflight|run|checkpoint
|
|
302
326
|
```
|
|
303
327
|
|
|
304
328
|
Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude 'recap stato task T18'`.
|
|
@@ -320,7 +344,7 @@ alcun errore visibile. Override o disattivazione via `LOOM_DECK_STATE_PROFILE`
|
|
|
320
344
|
|
|
321
345
|
```bash
|
|
322
346
|
npm install
|
|
323
|
-
npm run dev # tsx src/cli.tsx — lista tasks.md reale, ↑↓ naviga ·
|
|
347
|
+
npm run dev # tsx src/cli.tsx — lista tasks.md reale, ↑↓ naviga · ⏎ detail · ^K/^P/^R spawn · q esci
|
|
324
348
|
npm run build # tsc → dist/
|
|
325
349
|
npm test # node:test sul core vista (src/view.ts), senza Ink né terminale
|
|
326
350
|
```
|
package/dist/cli.js
CHANGED
|
@@ -8,15 +8,17 @@ import { statSync } from 'node:fs';
|
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
|
-
import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from './tasks.js';
|
|
11
|
+
import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, loadTaskFileText, } from './tasks.js';
|
|
12
12
|
import { discoverProjectSessions } from './sessions.js';
|
|
13
|
+
import { discoverLiveSessions, liveSig } from './live-sessions.js';
|
|
13
14
|
import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions, selectedRow, DEFAULT_OPTIONS, MIN_QUERY, } from './search.js';
|
|
14
15
|
import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
15
16
|
import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, rowLabel, selectedSession, sessionTitle, stripProjectCore, } from './session-list.js';
|
|
16
17
|
import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch, } from './config.js';
|
|
17
18
|
import { countArchivable, SCAN_INTERVAL_MS } from './archivable.js';
|
|
18
|
-
import { assignListCapacity, isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
|
|
19
|
+
import { assignListCapacity, detailCapacity, isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
|
|
19
20
|
import { caretWindow, cut, cutParts, pad, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
|
|
21
|
+
import { scanText, sliceLine, topForOffset, } from './text-search.js';
|
|
20
22
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
21
23
|
import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
|
|
22
24
|
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
@@ -136,6 +138,21 @@ function spawnOut(cmd, args, opts) {
|
|
|
136
138
|
fake.unref = () => fake;
|
|
137
139
|
return fake;
|
|
138
140
|
}
|
|
141
|
+
// T66 — le azioni del detail. Non sono un catalogo nuovo: ognuna è un
|
|
142
|
+
// `--prompt-kind` già esistente più `checkpoint`, e tutte passano dallo stesso
|
|
143
|
+
// `spawnForTask` dei CTRL della lista — una superficie in più, zero percorsi di
|
|
144
|
+
// spawn in più.
|
|
145
|
+
//
|
|
146
|
+
// L'etichetta è distinta dal kind dove il kind è il nome del MECCANISMO e
|
|
147
|
+
// l'etichetta quello dell'INTENZIONE: `none` è "aprire la task a mani nude",
|
|
148
|
+
// `recap` è "vedere a che punto sta".
|
|
149
|
+
const DETAIL_ACTIONS = [
|
|
150
|
+
{ kind: 'none', label: 'open' },
|
|
151
|
+
{ kind: 'preflight', label: 'preflight' },
|
|
152
|
+
{ kind: 'run', label: 'run' },
|
|
153
|
+
{ kind: 'recap', label: 'status' },
|
|
154
|
+
{ kind: 'checkpoint', label: 'checkpoint' },
|
|
155
|
+
];
|
|
139
156
|
// Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
|
|
140
157
|
// ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
|
|
141
158
|
// sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
|
|
@@ -379,6 +396,7 @@ function useSessions(projectRoot) {
|
|
|
379
396
|
forkOf: new Map(),
|
|
380
397
|
pinned: new Map(),
|
|
381
398
|
notes: new Map(),
|
|
399
|
+
live: new Map(),
|
|
382
400
|
});
|
|
383
401
|
// T50 — pin/unpin scrive il sidecar e vuole feedback IMMEDIATO, non al
|
|
384
402
|
// prossimo tick del poll (1.5s): la reload è esposta via ref così il toggle la
|
|
@@ -397,6 +415,18 @@ function useSessions(projectRoot) {
|
|
|
397
415
|
sessions = [];
|
|
398
416
|
index = { bindings: new Map(), forkOf: new Map(), pinned: new Map(), notes: new Map() };
|
|
399
417
|
}
|
|
418
|
+
// T62 — le vive stanno sullo STESSO tick delle altre fonti, non su una
|
|
419
|
+
// scala propria come `useArchivable`: `status` cambia a ogni turno, quindi
|
|
420
|
+
// un refresh più lento mostrerebbe `idle` su una sessione che lavora.
|
|
421
|
+
// Il try è separato perché il registry è una fonte indipendente: se manca
|
|
422
|
+
// (versione del CLI che non lo scrive) la lista deve restare, senza vive.
|
|
423
|
+
let live;
|
|
424
|
+
try {
|
|
425
|
+
live = discoverLiveSessions(projectRoot);
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
live = new Map();
|
|
429
|
+
}
|
|
400
430
|
const { bindings, forkOf, pinned, notes } = index;
|
|
401
431
|
// La signature copre anche fork, pin e note: un record di lineage, un
|
|
402
432
|
// toggle di pin o una nota appena scritta cambiano la lista renderizzata,
|
|
@@ -409,11 +439,13 @@ function useSessions(projectRoot) {
|
|
|
409
439
|
'#' +
|
|
410
440
|
[...pinned.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',') +
|
|
411
441
|
'#' +
|
|
412
|
-
[...notes.entries()].map(([k, v]) => `${k}"${v}`).sort().join(',')
|
|
442
|
+
[...notes.entries()].map(([k, v]) => `${k}"${v}`).sort().join(',') +
|
|
443
|
+
'#' +
|
|
444
|
+
liveSig(live);
|
|
413
445
|
if (sig === lastSig)
|
|
414
446
|
return;
|
|
415
447
|
lastSig = sig;
|
|
416
|
-
setState({ sessions, bindings, forkOf, pinned, notes });
|
|
448
|
+
setState({ sessions, bindings, forkOf, pinned, notes, live });
|
|
417
449
|
};
|
|
418
450
|
reloadRef.current = reload;
|
|
419
451
|
reload();
|
|
@@ -494,6 +526,18 @@ const SID_CHARS = 8;
|
|
|
494
526
|
* vuota di soli spazi lascerebbe un buco che si legge come "colonna finita",
|
|
495
527
|
* e la riga tornerebbe a sembrare disallineata pur non essendolo. */
|
|
496
528
|
const TASK_EMPTY = '·';
|
|
529
|
+
// T62 — colonna liveness, larga 1, incollata al sessionId senza gutter proprio:
|
|
530
|
+
// il glifo qualifica QUELL'id, e uno spazio in mezzo lo farebbe leggere come una
|
|
531
|
+
// colonna a sé. Entrambi Ambiguous (EAW) → 1 colonna per il terminale e 1 cella
|
|
532
|
+
// per Ink, quindi concordi (invariante ① di width.ts): il pieno/vuoto del
|
|
533
|
+
// cerchio è l'unico asse che varia, e resta scandibile in verticale.
|
|
534
|
+
//
|
|
535
|
+
// Sulla riga CHIUSA c'è uno spazio e non un terzo glifo: le chiuse sono la
|
|
536
|
+
// maggioranza di ogni lista, e marcarle vorrebbe dire disegnare N volte «niente
|
|
537
|
+
// da dire» — la colonna diventerebbe rumore invece di un segnale.
|
|
538
|
+
const LIVE_IDLE = '●';
|
|
539
|
+
const LIVE_BUSY = '◍';
|
|
540
|
+
const LIVE_NONE = ' ';
|
|
497
541
|
// Marker Done per il DISPLAY. `task.prog` resta il `✔️` letto da tasks.md —
|
|
498
542
|
// `isDone()` e le lookup di `view.ts` ci confrontano sopra, e `task-edit` lo
|
|
499
543
|
// riscrive sul file: è una chiave semantica, non testo. Qui `sanitize` lo
|
|
@@ -550,7 +594,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
550
594
|
// già la riga di STATO in fondo al frame (il feedback di un'azione). Due
|
|
551
595
|
// concetti diversi a una lettera di distanza sarebbero una trappola di
|
|
552
596
|
// lettura — e di scrittura, visto che `setNote` compare in quasi ogni ramo.
|
|
553
|
-
const { sessions, bindings, forkOf, pinned, notes: sessionNotes, reload: reloadSessions, } = useSessions(cwd);
|
|
597
|
+
const { sessions, bindings, forkOf, pinned, notes: sessionNotes, live, reload: reloadSessions, } = useSessions(cwd);
|
|
554
598
|
const [focus, setFocus] = useState('tasks');
|
|
555
599
|
// T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
|
|
556
600
|
// (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
|
|
@@ -609,6 +653,26 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
609
653
|
const [assignSid, setAssignSid] = useState(null);
|
|
610
654
|
const [assignFilter, setAssignFilter] = useState('');
|
|
611
655
|
const [assignSel, setAssignSel] = useState(null);
|
|
656
|
+
// T66 — la task aperta nel detail, FOTOGRAFATA all'apertura: id, titolo e
|
|
657
|
+
// testo integrale del task file (`text` null = file assente). Non si rilegge
|
|
658
|
+
// da `selTask` per la stessa ragione di `assignSid`: l'overlay copre la lista,
|
|
659
|
+
// quindi l'oggetto dell'azione deve restare quello che si è scelto anche se un
|
|
660
|
+
// tick del poll spostasse la selezione sotto.
|
|
661
|
+
//
|
|
662
|
+
// Si chiama `sheet` e non `detail` perché `detail` in questo componente è già
|
|
663
|
+
// il `TaskDetail` del blocco preview sotto i pane: due cose vicine con lo
|
|
664
|
+
// stesso nome sono una trappola di lettura (stesso motivo di
|
|
665
|
+
// `note`/`sessionNotes`).
|
|
666
|
+
const [sheet, setSheet] = useState(null);
|
|
667
|
+
const [sheetTop, setSheetTop] = useState(0);
|
|
668
|
+
const [sheetAction, setSheetAction] = useState(0);
|
|
669
|
+
// T91 — ricerca dentro il detail. `open` distingue i due modi in cui la si
|
|
670
|
+
// lascia: `esc` butta via ciò che il modale ha prodotto (`find` a null,
|
|
671
|
+
// evidenziazione via), `⏎` lo congela e restituisce il controllo allo strato
|
|
672
|
+
// sotto — campo chiuso, occorrenze ancora colorate, scroll dov'era. Senza il
|
|
673
|
+
// flag i due gesti collasserebbero su uno solo.
|
|
674
|
+
const [find, setFind] = useState(null);
|
|
675
|
+
const [occIdx, setOccIdx] = useState(0);
|
|
612
676
|
// Dimensioni vive del terminale: sono l'input del budget d'altezza sotto.
|
|
613
677
|
const { rows, columns } = useTerminalSize();
|
|
614
678
|
// Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
|
|
@@ -669,6 +733,10 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
669
733
|
const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, isAll ? MAX_SESSIONS_ALL : MAX_SESSIONS), [childSessions, sessions, pinned, isAll]);
|
|
670
734
|
const sessionRows = assembled.rows;
|
|
671
735
|
const selSessionObj = selectedSession(sessionRows, selSessionId);
|
|
736
|
+
// T62 — contato sulla lista INTERA (stessa ragione delle larghezze di colonna
|
|
737
|
+
// qui sotto): derivarlo dalla finestra visibile lo farebbe cambiare a ogni
|
|
738
|
+
// scroll, cioè un contatore che conta lo schermo invece della lista.
|
|
739
|
+
const liveCount = useMemo(() => sessionRows.filter((r) => r.kind !== 'separator' && live.has(r.sessionId)).length, [sessionRows, live]);
|
|
672
740
|
// T60 — larghezze delle colonne fisse della lista sessioni, misurate sulla
|
|
673
741
|
// lista INTERA e non sulla finestra visibile: derivarle dalle sole righe a
|
|
674
742
|
// schermo le farebbe cambiare a ogni scroll, cioè l'opposto di una tabella.
|
|
@@ -722,6 +790,42 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
722
790
|
const readerLines = useMemo(() => (readerRow?.kind === 'hit' ? wrapWithOffsets(readerRow.hit.text, readerWidth) : []), [readerRow, readerWidth]);
|
|
723
791
|
const readerCap = readerCapacity(rows);
|
|
724
792
|
const readerMaxTop = Math.max(0, readerLines.length - readerCap);
|
|
793
|
+
// T66 — testo del task file wrappato per il detail. `wrapWithOffsets` e non
|
|
794
|
+
// `wrapLines`: quest'ultimo appiattisce gli a-capo in un flusso unico, che per
|
|
795
|
+
// una preview di 4 righe va bene e per un task file — titoli, bullet, tabelle,
|
|
796
|
+
// blocchi di codice — significa renderlo illeggibile. Memoizzato per (testo,
|
|
797
|
+
// larghezza), o ogni pressione di freccia rifarebbe l'a-capo di 9KB.
|
|
798
|
+
//
|
|
799
|
+
// Cornici da scalare: box esterno (2 bordi + 2 padding) + box testo (2 bordi +
|
|
800
|
+
// 2 padding) = 8. Sottostimare tronca un carattere, sovrastimare manda a capo
|
|
801
|
+
// una riga che il budget d'altezza non ha contato.
|
|
802
|
+
const sheetWidth = Math.max(20, (columns || 80) - 8);
|
|
803
|
+
// Le righe conservano i propri offset invece di essere appiattite a stringa
|
|
804
|
+
// (T66 le buttava con `.map((l) => l.text)`): è ciò che rende
|
|
805
|
+
// l'evidenziazione un'intersezione di intervalli invece di un caso speciale
|
|
806
|
+
// per il match spezzato dall'a-capo.
|
|
807
|
+
const sheetLines = useMemo(() => (sheet?.text ? wrapWithOffsets(sheet.text, sheetWidth) : []), [sheet, sheetWidth]);
|
|
808
|
+
const sheetCap = detailCapacity(rows, find?.open === true);
|
|
809
|
+
const sheetMaxTop = Math.max(0, sheetLines.length - sheetCap);
|
|
810
|
+
// Lo scan gira sulla STESSA stringa che si renderizza (`sheet.text`, già
|
|
811
|
+
// passata da `sanitize` in `loadTaskFileText`): una rilettura del file darebbe
|
|
812
|
+
// offset che indicizzano un documento diverso da quello a schermo, cioè
|
|
813
|
+
// un'evidenziazione spostata di N caratteri e nessun errore.
|
|
814
|
+
const findRes = useMemo(() => (find && sheet?.text ? scanText(sheet.text, find.q) : { occ: [], error: '' }), [find?.q, sheet]);
|
|
815
|
+
// L'indice si clampa qui invece di essere corretto a ogni `setOccIdx`: la
|
|
816
|
+
// lista si accorcia da sola mentre si digita, e un indice fuori range vivrebbe
|
|
817
|
+
// per il tempo di un render.
|
|
818
|
+
const occCur = findRes.occ.length > 0 ? Math.min(occIdx, findRes.occ.length - 1) : -1;
|
|
819
|
+
// Salto all'occorrenza corrente, centrata. Non dipende da `sheetTop`, quindi
|
|
820
|
+
// non si auto-rilancia; dipende da `sheetLines` e `sheetCap`, quindi un resize
|
|
821
|
+
// ricalcola la posizione senza toccare le occorrenze — che sono offset del
|
|
822
|
+
// sorgente e il resize non le sposta.
|
|
823
|
+
useEffect(() => {
|
|
824
|
+
const o = occCur >= 0 ? findRes.occ[occCur] : undefined;
|
|
825
|
+
if (!o)
|
|
826
|
+
return;
|
|
827
|
+
setSheetTop(topForOffset(sheetLines, o.start, sheetCap));
|
|
828
|
+
}, [findRes, occCur, sheetLines, sheetCap]);
|
|
725
829
|
// T57 — righe del modale assegnazione: `null` (detach) in testa, poi le task
|
|
726
830
|
// della VISTA corrente (D4 — filtri e sort inclusi, coerenza con ciò che si
|
|
727
831
|
// stava leggendo a sinistra; le escluse restano contate nell'header).
|
|
@@ -825,29 +929,87 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
825
929
|
// è la task SELEZIONATA: senza il pane task a fuoco non ce n'è una. Per `⏎` il
|
|
826
930
|
// ramo è irraggiungibile (ci arriva già dentro `focus === 'tasks'`); per i tre
|
|
827
931
|
// CTRL, che il ramo `key.ctrl` intercetta globalmente, è l'unico posto.
|
|
828
|
-
|
|
932
|
+
// T66 — la guardia, estratta perché ora ha due chiamanti: gli spawn dalla
|
|
933
|
+
// lista e l'apertura del detail. Le tre uscite sono le stesse (pane sbagliato,
|
|
934
|
+
// riga meta, task sparita), e duplicarle vorrebbe dire tenerne allineati i
|
|
935
|
+
// messaggi a mano. `verb` è l'unica cosa che cambia fra i due usi.
|
|
936
|
+
function selectedTaskOr(keyLabel, verb) {
|
|
829
937
|
if (focus !== 'tasks') {
|
|
830
|
-
setNote(`${keyLabel} →
|
|
831
|
-
return;
|
|
938
|
+
setNote(`${keyLabel} → ${verb}: seleziona una task (←→ per il pane)`);
|
|
939
|
+
return null;
|
|
832
940
|
}
|
|
833
941
|
// T59 — la guardia è "non è una task", non "è spot": le righe meta sono due
|
|
834
942
|
// e nessuna delle due ha una task da aprire. Il messaggio dice quale delle
|
|
835
943
|
// due, perché il motivo è diverso (vista di sola lettura vs sessioni libere).
|
|
836
944
|
if (isAll || isSpot) {
|
|
837
945
|
setNote(isAll
|
|
838
|
-
?
|
|
839
|
-
:
|
|
840
|
-
return;
|
|
946
|
+
? `tutte: vista di sola lettura, nessuna task da ${verb}`
|
|
947
|
+
: `spot: sessioni libere, nessuna task da ${verb}`);
|
|
948
|
+
return null;
|
|
841
949
|
}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
950
|
+
return selTask;
|
|
951
|
+
}
|
|
952
|
+
// Lo spawn vero, su una task GIÀ risolta. Separato dalla guardia perché il
|
|
953
|
+
// detail passa l'id fotografato all'apertura e non la selezione corrente: la
|
|
954
|
+
// lista lì sotto non è più a schermo, quindi non è più la fonte dell'oggetto.
|
|
955
|
+
function spawnForTask(id, kind, keyLabel) {
|
|
845
956
|
const sid = randomUUID();
|
|
846
|
-
appendTaskBinding(cwd, sid,
|
|
847
|
-
const child = spawnDeck(
|
|
848
|
-
child.on('error', () => setNote(`⚠ spawn ${
|
|
957
|
+
appendTaskBinding(cwd, sid, id);
|
|
958
|
+
const child = spawnDeck(id, cwd, sid, kind);
|
|
959
|
+
child.on('error', () => setNote(`⚠ spawn ${id} fallito (${DECK_RUN})`));
|
|
849
960
|
const what = kind === 'none' ? '' : ` · ${kind}`;
|
|
850
|
-
setNote(`${keyLabel} spawn ${
|
|
961
|
+
setNote(`${keyLabel} spawn ${id}${what} → tab CC (sid ${sid.slice(0, 8)})`);
|
|
962
|
+
}
|
|
963
|
+
function spawnTaskSession(kind, keyLabel) {
|
|
964
|
+
const task = selectedTaskOr(keyLabel, 'spawnare');
|
|
965
|
+
if (task)
|
|
966
|
+
spawnForTask(task.id, kind, keyLabel);
|
|
967
|
+
}
|
|
968
|
+
// T66 — `⏎` sul pane task apre il detail invece di spawnare. Il testo del task
|
|
969
|
+
// file si legge QUI e non a ogni cambio selezione: l'overlay è l'unico
|
|
970
|
+
// consumer, e leggerlo in anticipo pagherebbe un file a ogni pressione di
|
|
971
|
+
// freccia per una schermata che quasi sempre non si apre.
|
|
972
|
+
// File assente → `text` null: l'overlay si apre lo stesso, perché spawnare una
|
|
973
|
+
// sessione non richiede il file (lo risolve `deck-run` per id).
|
|
974
|
+
function openDetail() {
|
|
975
|
+
const task = selectedTaskOr('⏎', 'aprire');
|
|
976
|
+
if (!task)
|
|
977
|
+
return;
|
|
978
|
+
setSheet({
|
|
979
|
+
id: task.id,
|
|
980
|
+
// Il titolo del task file quando c'è (è l'H1, cioè la forma lunga), la
|
|
981
|
+
// riga di tasks.md altrimenti: il detail non deve restare senza intestazione
|
|
982
|
+
// solo perché il file manca.
|
|
983
|
+
title: detail?.title || task.desc,
|
|
984
|
+
text: loadTaskFileText(tasksDir, task.id),
|
|
985
|
+
});
|
|
986
|
+
setSheetTop(0);
|
|
987
|
+
setSheetAction(0);
|
|
988
|
+
setFind(null);
|
|
989
|
+
setOccIdx(0);
|
|
990
|
+
setNote('');
|
|
991
|
+
setMode('detail');
|
|
992
|
+
}
|
|
993
|
+
function closeDetail() {
|
|
994
|
+
setMode('normal');
|
|
995
|
+
setSheet(null);
|
|
996
|
+
setFind(null);
|
|
997
|
+
}
|
|
998
|
+
function scrollDetail(delta) {
|
|
999
|
+
setSheetTop((t) => Math.max(0, Math.min(sheetMaxTop, t + delta)));
|
|
1000
|
+
}
|
|
1001
|
+
/** Modifica la query: l'insieme delle occorrenze cambia, quindi si riparte
|
|
1002
|
+
* dalla prima. Il movimento del caret NON passa di qui — sposta il cursore,
|
|
1003
|
+
* non i risultati. */
|
|
1004
|
+
function editFind(next) {
|
|
1005
|
+
setFind((f) => (f ? next(f) : f));
|
|
1006
|
+
setOccIdx(0);
|
|
1007
|
+
}
|
|
1008
|
+
function moveOcc(d) {
|
|
1009
|
+
const n = findRes.occ.length;
|
|
1010
|
+
if (n === 0)
|
|
1011
|
+
return;
|
|
1012
|
+
setOccIdx((i) => (Math.min(i, n - 1) + d + n) % n);
|
|
851
1013
|
}
|
|
852
1014
|
// T53 — apertura del modale nota sulla conversazione selezionata. Come
|
|
853
1015
|
// `openEdit`, la bozza parte dal valore ATTUALE: annotare una seconda volta è
|
|
@@ -1428,6 +1590,110 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1428
1590
|
}
|
|
1429
1591
|
return;
|
|
1430
1592
|
}
|
|
1593
|
+
// T66 — detail della task: due zone (testo scrollabile + barra azioni) e una
|
|
1594
|
+
// selezione per ognuna. Sta PRIMA del ramo di default per la stessa ragione
|
|
1595
|
+
// di reader e ricerca — Ink non ha focus-trap, quindi la cattura È l'ordine
|
|
1596
|
+
// dei rami — e il `return` in fondo è ciò che impedisce a `←→` di cambiare
|
|
1597
|
+
// pane mentre muove fra le azioni: qui quel binding non esiste più.
|
|
1598
|
+
// È anche prima del ramo CTRL, quindi `^K`/`^P`/`^R` restano acceleratori
|
|
1599
|
+
// della sola lista: chi è già nel detail ha i bottoni.
|
|
1600
|
+
if (mode === 'detail') {
|
|
1601
|
+
// T91 — modale DENTRO il modale, e sta prima per la stessa ragione per cui
|
|
1602
|
+
// il detail sta prima del ramo CTRL: Ink non ha focus-trap, quindi la
|
|
1603
|
+
// cattura È l'ordine dei rami. Mentre il campo è aperto mangia ogni lettera
|
|
1604
|
+
// nuda, `g`/`G` compresi.
|
|
1605
|
+
if (find?.open) {
|
|
1606
|
+
if (key.escape) {
|
|
1607
|
+
// Annulla: butta via ciò che il modale ha prodotto. Lo scroll resta
|
|
1608
|
+
// dove la ricerca l'ha portato — riavvolgerlo sarebbe una terza
|
|
1609
|
+
// semantica che nessun tasto ha chiesto.
|
|
1610
|
+
setFind(null);
|
|
1611
|
+
}
|
|
1612
|
+
else if (key.return) {
|
|
1613
|
+
// Congela: campo chiuso, occorrenze ancora colorate, scroll intatto.
|
|
1614
|
+
setFind((f) => (f ? { ...f, open: false } : f));
|
|
1615
|
+
}
|
|
1616
|
+
else if (key.upArrow) {
|
|
1617
|
+
moveOcc(-1);
|
|
1618
|
+
}
|
|
1619
|
+
else if (key.downArrow) {
|
|
1620
|
+
moveOcc(1);
|
|
1621
|
+
}
|
|
1622
|
+
else if (key.leftArrow || key.rightArrow) {
|
|
1623
|
+
const d = key.leftArrow ? -1 : 1;
|
|
1624
|
+
setFind((f) => f ? { ...f, caret: Math.max(0, Math.min(cpLen(f.q), f.caret + d)) } : f);
|
|
1625
|
+
}
|
|
1626
|
+
else if (key.backspace || key.delete) {
|
|
1627
|
+
editFind((f) => f.caret > 0 ? { ...f, q: removeAt(f.q, f.caret - 1), caret: f.caret - 1 } : f);
|
|
1628
|
+
}
|
|
1629
|
+
else if (key.ctrl) {
|
|
1630
|
+
// `^U` svuota, come il filtro del modale assegnazione; ogni altra combo
|
|
1631
|
+
// è no-op — `^F` incluso, siamo già dentro.
|
|
1632
|
+
if (input === 'u')
|
|
1633
|
+
editFind((f) => ({ ...f, q: '', caret: 0 }));
|
|
1634
|
+
}
|
|
1635
|
+
else if (input && !key.meta) {
|
|
1636
|
+
const ins = sanitizeTyped(input);
|
|
1637
|
+
editFind((f) => ({ ...f, q: insertAt(f.q, f.caret, ins), caret: f.caret + cpLen(ins) }));
|
|
1638
|
+
}
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
if (key.ctrl && input === 'f') {
|
|
1642
|
+
// Fuori dal detail `^F` è la ricerca conversazioni; qui è la ricerca nel
|
|
1643
|
+
// testo. La query sopravvive a una chiusura con `⏎`, quindi riaprire
|
|
1644
|
+
// riprende da dov'era invece di ricominciare.
|
|
1645
|
+
setFind((f) => (f ? { ...f, open: true } : { q: '', caret: 0, open: true }));
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
if (key.escape) {
|
|
1649
|
+
// Uno strato alla volta: se resta un'evidenziazione congelata, `esc`
|
|
1650
|
+
// smonta quella; il detail lo chiude il secondo.
|
|
1651
|
+
// Nessun reset di `sel`: la selezione della lista non è mai stata
|
|
1652
|
+
// toccata, quindi si ritrova esattamente dov'era.
|
|
1653
|
+
if (find)
|
|
1654
|
+
setFind(null);
|
|
1655
|
+
else
|
|
1656
|
+
closeDetail();
|
|
1657
|
+
}
|
|
1658
|
+
else if (key.return) {
|
|
1659
|
+
// `⏎` esegue SEMPRE l'azione selezionata, mai "scrolla" o "chiudi": il
|
|
1660
|
+
// testo non è un campo attivo (si scorre, non si edita), quindi nessuna
|
|
1661
|
+
// competizione sul tasto.
|
|
1662
|
+
const action = DETAIL_ACTIONS[sheetAction];
|
|
1663
|
+
const id = sheet?.id;
|
|
1664
|
+
closeDetail();
|
|
1665
|
+
if (id)
|
|
1666
|
+
spawnForTask(id, action.kind, `⏎ ${action.label}`);
|
|
1667
|
+
}
|
|
1668
|
+
else if (key.leftArrow || key.rightArrow) {
|
|
1669
|
+
// Scorrimento CICLICO come le righe di scelta del modale edit: cinque
|
|
1670
|
+
// voci, arrivare in fondo e ripartire costa meno che invertire direzione.
|
|
1671
|
+
const d = key.leftArrow ? -1 : 1;
|
|
1672
|
+
setSheetAction((i) => (i + d + DETAIL_ACTIONS.length) % DETAIL_ACTIONS.length);
|
|
1673
|
+
}
|
|
1674
|
+
else if (key.upArrow) {
|
|
1675
|
+
scrollDetail(-1);
|
|
1676
|
+
}
|
|
1677
|
+
else if (key.downArrow) {
|
|
1678
|
+
scrollDetail(1);
|
|
1679
|
+
}
|
|
1680
|
+
else if (key.pageUp) {
|
|
1681
|
+
scrollDetail(-sheetCap);
|
|
1682
|
+
}
|
|
1683
|
+
else if (key.pageDown) {
|
|
1684
|
+
scrollDetail(sheetCap);
|
|
1685
|
+
}
|
|
1686
|
+
else if (input === 'g') {
|
|
1687
|
+
// Estremi su lettera per lo stesso motivo del reader: Ink riconosce
|
|
1688
|
+
// `Home`/`End` ma non le espone, e qui non c'è input di testo che
|
|
1689
|
+
// contenda le lettere.
|
|
1690
|
+
setSheetTop(0);
|
|
1691
|
+
}
|
|
1692
|
+
else if (input === 'G') {
|
|
1693
|
+
setSheetTop(sheetMaxTop);
|
|
1694
|
+
}
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1431
1697
|
// T52/D1 — il ramo CTRL sta PRIMA di quelli su lettera nuda e li chiude
|
|
1432
1698
|
// tutti. `CTRL+F` e `f` nudo arrivano con lo STESSO `input` ('f'),
|
|
1433
1699
|
// distinguibili solo da `key.ctrl`: senza questa precedenza `CTRL+F`
|
|
@@ -1469,12 +1735,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1469
1735
|
}
|
|
1470
1736
|
else if (key.return) {
|
|
1471
1737
|
if (focus === 'tasks') {
|
|
1472
|
-
//
|
|
1473
|
-
//
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
|
|
1738
|
+
// T66 — ⏎ apre il DETAIL, non più una sessione. Secondo rimappaggio in
|
|
1739
|
+
// due task (T56 lo spostò da recap a sessione a mani nude), e la
|
|
1740
|
+
// direzione è una sola: da azione singola a punto d'ingresso. Il tasto
|
|
1741
|
+
// più battuto non è il posto dove inchiodare una scelta di prompt — le
|
|
1742
|
+
// shortcut CTRL restano per chi sa già cosa vuole, `⏎` apre il ventaglio.
|
|
1743
|
+
// Lo spawn a mani nude di prima è `open`, cioè `⏎ ⏎` (è il focus iniziale).
|
|
1744
|
+
openDetail();
|
|
1478
1745
|
}
|
|
1479
1746
|
else {
|
|
1480
1747
|
// T49 — ⏎ su una sessione = resume in nuova tab. Il binding si rilegge
|
|
@@ -1665,7 +1932,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1665
1932
|
// l'indicatore `focus:` — il pane a fuoco si vede già dall'evidenziazione, e
|
|
1666
1933
|
// ridirlo a parole costava colonne su una riga che tronca in silenzio.
|
|
1667
1934
|
const keyLegend = sanitize([
|
|
1668
|
-
...(canSpawn ? ['
|
|
1935
|
+
...(canSpawn ? ['⏎ detail', '^K/^P/^R spawn'] : canResume ? ['⏎ resume'] : []),
|
|
1669
1936
|
...(canResume ? ['f fork'] : []),
|
|
1670
1937
|
...(canPin ? ['p pin', 'N nota', 'A assegna'] : []),
|
|
1671
1938
|
'^F cerca',
|
|
@@ -1695,6 +1962,20 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1695
1962
|
(s ? conversationLabel(s, projectCore, assignSid ? bindings.get(assignSid) : undefined) : '');
|
|
1696
1963
|
return (_jsx(AssignScreen, { sessionId: assignSid ?? '', label: label, current: assignSid ? bindings.get(assignSid) ?? null : null, filter: assignFilter, rows: assignRows.slice(win.start, win.end), selected: assignSel, matched: assignRows.length - 1, hidden: hiddenTasks, above: win.start, below: assignRows.length - win.end, childCount: childCount, columns: columns, note: note }));
|
|
1697
1964
|
}
|
|
1965
|
+
// ── T66 · detail della task ─────────────────────────────────────────────
|
|
1966
|
+
// Quarta schermata sostitutiva, stessa ragione delle altre tre: un task file
|
|
1967
|
+
// non entra in un box sopra i due pane. Il budget dei pane non viene nemmeno
|
|
1968
|
+
// calcolato — il render esce di qui prima.
|
|
1969
|
+
if (mode === 'detail' && sheet) {
|
|
1970
|
+
if (isCompact(sheetCap)) {
|
|
1971
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", sheet.id, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7 esc chiude"] })] }));
|
|
1972
|
+
}
|
|
1973
|
+
// Niente `windowRange`: quella centra la finestra su una selezione, qui la
|
|
1974
|
+
// posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
|
|
1975
|
+
// può accorciare il testo sotto uno scroll già dato.
|
|
1976
|
+
const start = Math.min(sheetTop, sheetMaxTop);
|
|
1977
|
+
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 }));
|
|
1978
|
+
}
|
|
1698
1979
|
// ── T52 · schermate sostitutive ─────────────────────────────────────────
|
|
1699
1980
|
// Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
|
|
1700
1981
|
// pane: una lista di occorrenze non entra in un box da 4 righe. Prendono
|
|
@@ -1784,7 +2065,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1784
2065
|
if (budget.compact) {
|
|
1785
2066
|
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? parentLabel, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
|
|
1786
2067
|
}
|
|
1787
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${legend.shown}` : '', legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, archivable: archivable, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '' })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
2068
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${legend.shown}` : '', legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, archivable: archivable, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore, live: live, liveCount: liveCount })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '', live: live.get(selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
1788
2069
|
}
|
|
1789
2070
|
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
1790
2071
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
@@ -1951,7 +2232,8 @@ function SearchScreen({ preview, hash, query, field, opts, result, rows, selecte
|
|
|
1951
2232
|
*/
|
|
1952
2233
|
function SearchPreviewPane({ p }) {
|
|
1953
2234
|
const last = Math.min(p.total, p.from + p.lines.length);
|
|
1954
|
-
|
|
2235
|
+
const occ = [{ start: p.hit.matchStart, end: p.hit.matchEnd }];
|
|
2236
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["record ", p.hit.idx, " \u00B7 ", KIND_LABEL[p.hit.kind], p.ts ? ` · ${fmtDateTime(p.ts)}` : '', " \u00B7 righe ", p.from + 1, "-", last, " di ", p.total, " \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " apre il reader"] }), p.lines.map((l, i) => (_jsx(ReaderLine, { line: l, occ: occ, current: 0 }, p.from + i)))] }));
|
|
1955
2237
|
}
|
|
1956
2238
|
/**
|
|
1957
2239
|
* Reader fullscreen (T52 · D8).
|
|
@@ -1963,18 +2245,71 @@ function SearchPreviewPane({ p }) {
|
|
|
1963
2245
|
*/
|
|
1964
2246
|
function ReaderScreen({ hit, lines, top, total, capacity, bound, }) {
|
|
1965
2247
|
const last = Math.min(total, top + capacity);
|
|
1966
|
-
|
|
2248
|
+
const occ = [{ start: hit.matchStart, end: hit.matchEnd }];
|
|
2249
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["reader \u00B7 ", _jsx(Text, { color: "cyan", children: hit.sessionId.slice(0, 8) }), " \u00B7 record ", hit.idx, " \u00B7", ' ', KIND_LABEL[hit.kind], bound ? ` · ${bound}` : '', " \u00B7 righe ", total === 0 ? 0 : top + 1, "-", last, " di ", total] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g" }), " inizio \u00B7 ", _jsx(Text, { color: "yellow", children: "G" }), " fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " torna alla lista"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: lines.map((l, i) => (_jsx(ReaderLine, { line: l, occ: occ, current: 0 }, top + i))) })] }));
|
|
1967
2250
|
}
|
|
1968
|
-
/** Una riga
|
|
1969
|
-
*
|
|
1970
|
-
*
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
2251
|
+
/** Una riga con le porzioni di match evidenziate. Gli offset sono quelli del
|
|
2252
|
+
* testo sorgente, quindi un match a cavallo dell'a-capo si colora su entrambe
|
|
2253
|
+
* le righe senza casi speciali — entrambe intersecano il suo intervallo.
|
|
2254
|
+
*
|
|
2255
|
+
* Regge N occorrenze perché il detail (T91) ne mostra tutte quelle visibili; il
|
|
2256
|
+
* reader (T52) ne passa una sola, che è il caso degenere dello stesso taglio. */
|
|
2257
|
+
function ReaderLine({ line, occ, current, }) {
|
|
2258
|
+
const segs = sliceLine(line.text, line.start, occ, current);
|
|
2259
|
+
if (segs.length === 0)
|
|
1975
2260
|
return _jsx(Text, { wrap: "truncate-end", children: line.text || ' ' });
|
|
1976
|
-
|
|
1977
|
-
|
|
2261
|
+
return (_jsx(Text, { wrap: "truncate-end", children: segs.map((s, i) => s.hit ? (
|
|
2262
|
+
// La corrente si distingue dalle altre per COLORE di sfondo, non per
|
|
2263
|
+
// presenza: tutte restano visibili, o navigare fra occorrenze non
|
|
2264
|
+
// mostrerebbe più dove sono le altre.
|
|
2265
|
+
_jsx(Text, { backgroundColor: s.current ? 'cyan' : 'yellow', color: "black", children: s.text }, i)) : (_jsx(Text, { children: s.text }, i))) }));
|
|
2266
|
+
}
|
|
2267
|
+
/** Campo della ricerca nel detail: finestra ancorata al caret, cursore inverso
|
|
2268
|
+
* sulla cella reale. Gemello di `EditTextField` senza la label, che qui sta
|
|
2269
|
+
* fuori perché il campo vive in FLUSSO su una riga condivisa col contatore —
|
|
2270
|
+
* non su una riga propria. */
|
|
2271
|
+
function DetailFindField({ value, caret, cols }) {
|
|
2272
|
+
const win = caretWindow(value, caret, cols);
|
|
2273
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { children: sanitize(win.head) }), _jsx(Text, { inverse: true, children: sanitize(win.at) }), _jsx(Text, { children: sanitize(win.tail) })] }));
|
|
2274
|
+
}
|
|
2275
|
+
/**
|
|
2276
|
+
* Detail della task (T66): il task file scrollabile più la barra azioni.
|
|
2277
|
+
*
|
|
2278
|
+
* Unisce due gesti che erano due schermate — leggere la task e agire su di essa
|
|
2279
|
+
* — perché convergono sullo stesso oggetto: si legge la Description proprio per
|
|
2280
|
+
* decidere QUALE azione lanciare, e con due overlay separati quella decisione
|
|
2281
|
+
* costava uscire dal viewer e ricordarsi la combo.
|
|
2282
|
+
*
|
|
2283
|
+
* Le azioni sono BOTTONI AFFIANCATI e non voci di un menu verticale: un
|
|
2284
|
+
* rettangolo ha già coordinate e area cliccabile, quindi il layout sopravvive
|
|
2285
|
+
* all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione. La
|
|
2286
|
+
* navigazione da tastiera ci si sovrappone senza conflitti.
|
|
2287
|
+
*/
|
|
2288
|
+
function DetailScreen({ id, title, missing, lines, top, total, capacity, action, columns, find, occ, occCur, }) {
|
|
2289
|
+
const last = Math.min(total, top + capacity);
|
|
2290
|
+
// Il taglio lo fa il chiamante (invariante ③ di width.ts): la riga bottoni è
|
|
2291
|
+
// ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso — ma
|
|
2292
|
+
// la correttezza non deve dipendere dall'alfabeto che capita nella riga.
|
|
2293
|
+
const width = Math.max(20, (columns || 80) - 4);
|
|
2294
|
+
const segs = DETAIL_ACTIONS.map((a) => `[ ${a.label} ]`);
|
|
2295
|
+
const parts = [];
|
|
2296
|
+
segs.forEach((s, i) => {
|
|
2297
|
+
if (i > 0)
|
|
2298
|
+
parts.push(' ');
|
|
2299
|
+
parts.push(s);
|
|
2300
|
+
});
|
|
2301
|
+
const dropped = (v) => segs.filter((s, i) => v[i * 2] !== s).length;
|
|
2302
|
+
// Due passate: la seconda serve SOLO quando qualcosa cade, e riserva le
|
|
2303
|
+
// colonne del contatore. Riservarle sempre costerebbe 6 colonne su ogni
|
|
2304
|
+
// terminale largo per un avviso che lì non comparirà mai.
|
|
2305
|
+
let shown = cutParts(parts, width);
|
|
2306
|
+
if (dropped(shown) > 0)
|
|
2307
|
+
shown = cutParts(parts, Math.max(0, width - 6));
|
|
2308
|
+
const cutCount = dropped(shown);
|
|
2309
|
+
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)"] })) : (
|
|
2310
|
+
// Riga vuota → uno spazio: un `<Text>` senza contenuto Ink non lo
|
|
2311
|
+
// disegna, e il testo si compatterebbe perdendo la struttura del file.
|
|
2312
|
+
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] }) })] }));
|
|
1978
2313
|
}
|
|
1979
2314
|
/**
|
|
1980
2315
|
* Larghezza del testo dentro la lista della schermata di assegnazione: box
|
|
@@ -2089,9 +2424,10 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
|
|
|
2089
2424
|
* condiviso, `cutParts`), la resa è del pezzo. Il giallo su `📌N` distingue le
|
|
2090
2425
|
* pinnate dal resto dell'header e non è decorazione.
|
|
2091
2426
|
*/
|
|
2092
|
-
function SessionsHeader({ parentLabel, total, pinnedCount, hidden, above, below, focused, columns, }) {
|
|
2427
|
+
function SessionsHeader({ parentLabel, total, pinnedCount, liveCount, hidden, above, below, focused, columns, }) {
|
|
2093
2428
|
const segments = [
|
|
2094
2429
|
{ text: `Sessions · ${parentLabel} (${total})` },
|
|
2430
|
+
...(liveCount > 0 ? [{ text: ` · ${LIVE_IDLE}${liveCount} vive`, color: 'green' }] : []),
|
|
2095
2431
|
...(pinnedCount > 0 ? [{ text: ` · 📌${pinnedCount}`, color: 'yellow' }] : []),
|
|
2096
2432
|
...(hidden > 0 ? [{ text: ` · +${hidden} più vecchie`, dim: true }] : []),
|
|
2097
2433
|
...(above > 0 ? [{ text: ` · ↑${above}`, dim: true }] : []),
|
|
@@ -2100,8 +2436,8 @@ function SessionsHeader({ parentLabel, total, pinnedCount, hidden, above, below,
|
|
|
2100
2436
|
const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns));
|
|
2101
2437
|
return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, children: shown[i] }, i)) : null) }));
|
|
2102
2438
|
}
|
|
2103
|
-
function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, total, pinnedCount, hidden, selectedId, focused, above, below, columns, forkOf, sessionNotes, projectCore, }) {
|
|
2104
|
-
return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(SessionsHeader, { parentLabel: parentLabel, total: total, pinnedCount: pinnedCount, hidden: hidden, above: above, below: below, focused: focused, columns: columns }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isAll
|
|
2439
|
+
function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, total, pinnedCount, hidden, selectedId, focused, above, below, columns, forkOf, sessionNotes, projectCore, live, liveCount, }) {
|
|
2440
|
+
return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(SessionsHeader, { parentLabel: parentLabel, total: total, pinnedCount: pinnedCount, liveCount: liveCount, hidden: hidden, above: above, below: below, focused: focused, columns: columns }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isAll
|
|
2105
2441
|
? 'nessuna conversazione nel progetto'
|
|
2106
2442
|
: isSpot
|
|
2107
2443
|
? 'nessuna sessione libera'
|
|
@@ -2146,6 +2482,10 @@ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows,
|
|
|
2146
2482
|
// task accanto, che esiste solo in questa vista.
|
|
2147
2483
|
const bound = bindings.get(s.sessionId) ?? null;
|
|
2148
2484
|
const linked = isAll ? Boolean(bound) : !isSpot;
|
|
2485
|
+
// T62 — liveness e binding sono ORTOGONALI: la cella marker dice a chi
|
|
2486
|
+
// appartiene la conversazione (pin/task/spot), questa dice se è aperta
|
|
2487
|
+
// adesso. Farle condividere una cella perderebbe una delle due.
|
|
2488
|
+
const liveEntry = live.get(s.sessionId);
|
|
2149
2489
|
// Stesso motivo per cui la colonna esiste: una pinnata resta in lista
|
|
2150
2490
|
// qualunque sia il parent selezionato, quindi l'header non ne dice
|
|
2151
2491
|
// l'appartenenza e la cella va riempita anche fuori dalla vista
|
|
@@ -2168,6 +2508,7 @@ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows,
|
|
|
2168
2508
|
(2 /* caret */ +
|
|
2169
2509
|
2 /* marker */ +
|
|
2170
2510
|
1 /* gutter */ +
|
|
2511
|
+
1 /* T62 · colonna liveness */ +
|
|
2171
2512
|
SID_CHARS +
|
|
2172
2513
|
1 /* gutter */ +
|
|
2173
2514
|
(taskW > 0 ? taskW + 1 : 0) +
|
|
@@ -2186,11 +2527,11 @@ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows,
|
|
|
2186
2527
|
const used = (label.note ? termWidth(label.note) + 2 : 0) +
|
|
2187
2528
|
(label.note && label.rest ? 1 : 0) +
|
|
2188
2529
|
termWidth(label.rest);
|
|
2189
|
-
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color:
|
|
2530
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : undefined, children: liveEntry ? (liveEntry.status === 'busy' ? LIVE_BUSY : LIVE_IDLE) : LIVE_NONE }), _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : 'cyan', bold: Boolean(liveEntry), children: s.sessionId.slice(0, SID_CHARS) }), ' ', taskW > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: bound && taskCell ? 'green' : undefined, dimColor: !bound, children: pad(taskCell, taskW) }), ' '] })) : null, forkMark ? _jsx(Text, { color: "magenta", children: forkMark }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' '.repeat(Math.max(0, inner - used)), ' ', _jsx(Text, { dimColor: true, children: pad(age, ageW, 'right') })] }, s.sessionId));
|
|
2190
2531
|
}))] }));
|
|
2191
2532
|
}
|
|
2192
2533
|
function PreviewPane(p) {
|
|
2193
|
-
return (_jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: p.kind === 'task' ? (_jsx(TaskPreview, { detail: p.detail, maxLines: p.maxLines, columns: p.columns })) : (_jsx(SessionPreview, { s: p.s, firstLines: p.firstLines, lastLines: p.lastLines, columns: p.columns, origin: p.origin, note: p.note })) }));
|
|
2534
|
+
return (_jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: p.kind === 'task' ? (_jsx(TaskPreview, { detail: p.detail, maxLines: p.maxLines, columns: p.columns })) : (_jsx(SessionPreview, { s: p.s, firstLines: p.firstLines, lastLines: p.lastLines, columns: p.columns, origin: p.origin, note: p.note, live: p.live })) }));
|
|
2194
2535
|
}
|
|
2195
2536
|
// T49 — corpo della preview sessione. Tutti i campi vengono dal parse già
|
|
2196
2537
|
// cached dell'adapter (mtime-keyed): non costa I/O al movimento di selezione.
|
|
@@ -2199,11 +2540,11 @@ function PreviewPane(p) {
|
|
|
2199
2540
|
// SOLO con un titolo custom — senza, il titolo È già il primo prompt e la riga
|
|
2200
2541
|
// lo duplicherebbe (D4 preflight). Le righe rese non superano mai il riservato
|
|
2201
2542
|
// dal budget (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
|
|
2202
|
-
function SessionPreview({ s, firstLines, lastLines, columns, origin, note, }) {
|
|
2543
|
+
function SessionPreview({ s, firstLines, lastLines, columns, origin, note, live, }) {
|
|
2203
2544
|
const width = previewTextWidth(columns);
|
|
2204
2545
|
const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
|
|
2205
2546
|
const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
|
|
2206
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), " \u00B7 ", s.gitBranch || '-', origin ? ` · ⑂ da ${origin.slice(0, 8)}` : ''] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
|
|
2547
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), " \u00B7 ", s.gitBranch || '-', origin ? ` · ⑂ da ${origin.slice(0, 8)}` : '', live ? (_jsx(Text, { color: live.status === 'busy' ? 'yellow' : 'green', children: ` · ${live.status === 'busy' ? LIVE_BUSY : LIVE_IDLE} viva pid ${live.pid} (${live.status})` })) : null] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
|
|
2207
2548
|
}
|
|
2208
2549
|
/**
|
|
2209
2550
|
* Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
function liveSessionsRoot() {
|
|
5
|
+
return join(homedir(), '.claude', 'sessions');
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Campo 22 (`starttime`) di `/proc/<pid>/stat`, o null se la riga non è
|
|
9
|
+
* parsabile.
|
|
10
|
+
*
|
|
11
|
+
* Il taglio è sull'ULTIMA `)` e non su uno split ingenuo: il campo 2 (`comm`) è
|
|
12
|
+
* il nome dell'eseguibile fra parentesi e può contenere spazi e parentesi a sua
|
|
13
|
+
* volta, quindi tokenizzare da inizio riga sfasa tutti i campi successivi. Dopo
|
|
14
|
+
* l'ultima `)` il primo token è il campo 3 → `starttime` è l'indice 19.
|
|
15
|
+
*/
|
|
16
|
+
export function procStartOf(statLine) {
|
|
17
|
+
const at = statLine.lastIndexOf(')');
|
|
18
|
+
if (at < 0)
|
|
19
|
+
return null;
|
|
20
|
+
const fields = statLine.slice(at + 1).trim().split(/\s+/);
|
|
21
|
+
return fields[19] ?? null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Il processo `pid` è ANCORA quello che ha scritto il file di registry?
|
|
25
|
+
*
|
|
26
|
+
* Il registry è keyed su pid, e i pid si riciclano: senza questo confronto un
|
|
27
|
+
* pid riusato da un altro programma marcherebbe «aperta» una conversazione
|
|
28
|
+
* morta, e un'entry stale lasciata da un `kill -9` (il CLI non fa in tempo a
|
|
29
|
+
* ripulire) resterebbe viva per sempre. Il guard sta dentro il file stesso —
|
|
30
|
+
* `procStart` è esattamente il campo 22 di `/proc/<pid>/stat`, verificato — e
|
|
31
|
+
* quel campo è il tempo di avvio in tick dal boot: irripetibile per un pid
|
|
32
|
+
* riciclato, che parte per definizione dopo.
|
|
33
|
+
*/
|
|
34
|
+
function isAlive(pid, procStart) {
|
|
35
|
+
let stat;
|
|
36
|
+
try {
|
|
37
|
+
stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false; // processo finito → nessun /proc/<pid>
|
|
41
|
+
}
|
|
42
|
+
return procStartOf(stat) === procStart;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Semantica di UNA entry del registry, separata dalla lettura del disco: qui
|
|
46
|
+
* c'è solo cosa rende un record utilizzabile, ed è l'unica parte che vale la
|
|
47
|
+
* pena testare (su una stringa, senza inventare un finto `~/.claude`).
|
|
48
|
+
*
|
|
49
|
+
* `pid` arriva dal NOME del file e non dal campo omonimo: è il nome che decide
|
|
50
|
+
* quale `/proc/<pid>/stat` interrogare, e far divergere le due cose renderebbe
|
|
51
|
+
* il guard verificabile su un processo diverso da quello controllato.
|
|
52
|
+
*/
|
|
53
|
+
export function parseLiveEntry(content, pid) {
|
|
54
|
+
let d;
|
|
55
|
+
try {
|
|
56
|
+
d = JSON.parse(content);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const sessionId = typeof d.sessionId === 'string' ? d.sessionId : '';
|
|
62
|
+
const procStart = typeof d.procStart === 'string' ? d.procStart : '';
|
|
63
|
+
const cwd = typeof d.cwd === 'string' ? d.cwd : '';
|
|
64
|
+
// Senza uno dei tre l'entry non è utilizzabile: `sessionId` è la chiave del
|
|
65
|
+
// join con la lista, `procStart` è il guard anti-pid-riciclato (assente →
|
|
66
|
+
// non si può stabilire la liveness, e assumerla sarebbe il falso positivo che
|
|
67
|
+
// il guard esiste per escludere), `cwd` è il filtro di progetto.
|
|
68
|
+
if (!sessionId || !procStart || !cwd)
|
|
69
|
+
return null;
|
|
70
|
+
return {
|
|
71
|
+
entry: {
|
|
72
|
+
sessionId,
|
|
73
|
+
pid,
|
|
74
|
+
status: d.status === 'busy' ? 'busy' : 'idle',
|
|
75
|
+
name: typeof d.name === 'string' ? d.name : '',
|
|
76
|
+
cwd,
|
|
77
|
+
},
|
|
78
|
+
procStart,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Le sessioni VIVE del solo progetto corrente, indicizzate per `sessionId` —
|
|
83
|
+
* la stessa chiave del sidecar `session-tasks.jsonl` e di `deck-run --resume`,
|
|
84
|
+
* quindi il join con la riga di lista è diretto.
|
|
85
|
+
*
|
|
86
|
+
* Il filtro per `cwd` è d'EFFICIENZA, non di correttezza: risparmia lo `stat`
|
|
87
|
+
* di `/proc` sui processi degli altri progetti (il registry è globale, non
|
|
88
|
+
* per-progetto). Il match resta sul `sessionId`.
|
|
89
|
+
*
|
|
90
|
+
* Nessuna cache mtime-keyed come in `sessions.ts`: le entry sono poche e di
|
|
91
|
+
* poche centinaia di byte, mentre `status` cambia a ogni turno — una cache qui
|
|
92
|
+
* mostrerebbe `idle` su una sessione che sta lavorando, cioè il difetto che la
|
|
93
|
+
* colonna esiste per non avere.
|
|
94
|
+
*/
|
|
95
|
+
export function discoverLiveSessions(projectRoot) {
|
|
96
|
+
const out = new Map();
|
|
97
|
+
let files;
|
|
98
|
+
try {
|
|
99
|
+
files = readdirSync(liveSessionsRoot()).filter((f) => f.endsWith('.json'));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return out; // registry assente (CLI mai avviato, o versione che non lo scrive)
|
|
103
|
+
}
|
|
104
|
+
for (const f of files) {
|
|
105
|
+
const pid = Number(basename(f, '.json'));
|
|
106
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
107
|
+
continue;
|
|
108
|
+
let content;
|
|
109
|
+
try {
|
|
110
|
+
content = readFileSync(join(liveSessionsRoot(), f), 'utf8');
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
continue; // il processo può finire fra readdir e read
|
|
114
|
+
}
|
|
115
|
+
const parsed = parseLiveEntry(content, pid);
|
|
116
|
+
if (!parsed)
|
|
117
|
+
continue;
|
|
118
|
+
const { entry, procStart } = parsed;
|
|
119
|
+
if (entry.cwd !== projectRoot && !entry.cwd.startsWith(projectRoot + '/'))
|
|
120
|
+
continue;
|
|
121
|
+
if (!isAlive(pid, procStart))
|
|
122
|
+
continue;
|
|
123
|
+
out.set(entry.sessionId, entry);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
/** Signature del poll: cambia quando cambia l'insieme delle vive O lo stato di
|
|
128
|
+
* una di esse. Il `pid` ci sta dentro perché un `/clear` sposta lo stesso
|
|
129
|
+
* `sessionId` su un processo diverso senza toccare né insieme né stato. */
|
|
130
|
+
export function liveSig(live) {
|
|
131
|
+
return [...live.values()]
|
|
132
|
+
.map((l) => `${l.sessionId}:${l.pid}:${l.status}`)
|
|
133
|
+
.sort()
|
|
134
|
+
.join(',');
|
|
135
|
+
}
|
package/dist/search.js
CHANGED
|
@@ -58,8 +58,8 @@ function escapeLiteral(s) {
|
|
|
58
58
|
* `(`) è la NORMA durante la digitazione, non un caso limite. Deve degradare a
|
|
59
59
|
* lista vuota con segnalazione inline, mai a un crash della TUI.
|
|
60
60
|
*/
|
|
61
|
-
export function buildMatcher(query, opts) {
|
|
62
|
-
if (query.length <
|
|
61
|
+
export function buildMatcher(query, opts, min = MIN_QUERY) {
|
|
62
|
+
if (query.length < min)
|
|
63
63
|
return { re: null, error: '' };
|
|
64
64
|
let source = opts.regex ? query : escapeLiteral(query);
|
|
65
65
|
// Il gruppo non-catturante è obbligatorio: senza, `\b` si legherebbe al solo
|
package/dist/tasks.js
CHANGED
|
@@ -121,6 +121,29 @@ export function parseTaskDetail(id, content) {
|
|
|
121
121
|
description: sanitize(descLines.join('\n').trim()),
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Testo INTEGRALE del task file (T66 · detail).
|
|
126
|
+
*
|
|
127
|
+
* Distinto da `loadTaskDetail`, che estrae header e Description per il blocco
|
|
128
|
+
* preview: qui si legge la task per intero — Acceptance, Deliverables,
|
|
129
|
+
* Implementation Notes — e un parse che tenesse solo le sezioni note
|
|
130
|
+
* nasconderebbe proprio ciò per cui si apre l'overlay.
|
|
131
|
+
*
|
|
132
|
+
* `sanitize` al confine come per il detail parsato: il task file è testo libero
|
|
133
|
+
* e porta glifi che Ink e il terminale misurano diversamente. Non torna mai su
|
|
134
|
+
* disco da qui, quindi la sostituzione non ha il rischio di `pri`/`prog`.
|
|
135
|
+
*/
|
|
136
|
+
export function loadTaskFileText(dir, id) {
|
|
137
|
+
const path = findTaskFile(dir, id);
|
|
138
|
+
if (!path)
|
|
139
|
+
return null;
|
|
140
|
+
try {
|
|
141
|
+
return sanitize(readFileSync(path, 'utf8'));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
124
147
|
export function loadTaskDetail(dir, id) {
|
|
125
148
|
const path = findTaskFile(dir, id);
|
|
126
149
|
if (!path)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ricerca dentro UN documento: occorrenze come offset del testo sorgente.
|
|
3
|
+
*
|
|
4
|
+
* Gemello di `search.ts` per il motore, opposto per forma. Lì il corpus è N
|
|
5
|
+
* conversazioni, quindi il risultato raggruppa per sessione, cappa le occorrenze
|
|
6
|
+
* e produce estratti; qui il corpus è una stringa sola e la risposta utile è la
|
|
7
|
+
* posizione, non l'estratto — il testo è già a schermo.
|
|
8
|
+
*
|
|
9
|
+
* Gli offset sono quelli del SORGENTE, mai coordinate riga/colonna del testo
|
|
10
|
+
* wrappato. Due proprietà ne discendono, ed è il motivo per cui il modulo esiste:
|
|
11
|
+
*
|
|
12
|
+
* - **Immunità al re-wrap.** Un resize cambia larghezza e numero di righe ma non
|
|
13
|
+
* sposta un match, perché il sorgente non cambia. Coordinate «riga 42,
|
|
14
|
+
* colonna 7» andrebbero ricalcolate a ogni SIGWINCH.
|
|
15
|
+
* - **Match a cavallo dell'a-capo senza casi speciali.** Ogni riga di
|
|
16
|
+
* `wrapWithOffsets` è una fetta contigua del sorgente, quindi evidenziare è
|
|
17
|
+
* intersecare due intervalli — e un match spezzato su due righe si colora su
|
|
18
|
+
* entrambe perché entrambe intersecano.
|
|
19
|
+
*
|
|
20
|
+
* Puro: nessun Ink, nessun terminale, nessun filesystem.
|
|
21
|
+
*/
|
|
22
|
+
import { buildMatcher } from './search.js';
|
|
23
|
+
export const LITERAL = { regex: false, caseSensitive: false, wholeWord: false };
|
|
24
|
+
/**
|
|
25
|
+
* Minimo di caratteri più basso di `MIN_QUERY`.
|
|
26
|
+
*
|
|
27
|
+
* Su N conversazioni una query di 2 caratteri produce migliaia di occorrenze
|
|
28
|
+
* inutili, e la soglia a 3 protegge dall'indicizzazione a vuoto. Su un task file
|
|
29
|
+
* `T7` o `⏎` sono query legittime e frequenti: la soglia lì non protegge da
|
|
30
|
+
* niente, impedisce soltanto.
|
|
31
|
+
*/
|
|
32
|
+
export const MIN_DETAIL_QUERY = 1;
|
|
33
|
+
const EMPTY = { occ: [], error: '' };
|
|
34
|
+
/**
|
|
35
|
+
* Tutte le occorrenze di `query` in `text`, in ordine di posizione.
|
|
36
|
+
*
|
|
37
|
+
* Il testo deve essere **la stessa stringa che si renderizza**: passa da
|
|
38
|
+
* `sanitize` al confine di caricamento, e cercare su una rilettura del file
|
|
39
|
+
* produrrebbe offset che indicizzano un documento diverso da quello a schermo —
|
|
40
|
+
* evidenziazione spostata di N caratteri, senza nessun errore.
|
|
41
|
+
*/
|
|
42
|
+
export function scanText(text, query, opts = LITERAL) {
|
|
43
|
+
const { re, error } = buildMatcher(query, opts, MIN_DETAIL_QUERY);
|
|
44
|
+
if (!re)
|
|
45
|
+
return error ? { occ: [], error } : EMPTY;
|
|
46
|
+
const occ = [];
|
|
47
|
+
re.lastIndex = 0;
|
|
48
|
+
let m;
|
|
49
|
+
while ((m = re.exec(text)) !== null) {
|
|
50
|
+
// Una regex può matchare la STRINGA VUOTA (`a*`, `^`, `\b`): `exec` non
|
|
51
|
+
// avanzerebbe `lastIndex` e il ciclo non terminerebbe mai. Un match di
|
|
52
|
+
// larghezza zero non è nemmeno evidenziabile, quindi si salta invece di
|
|
53
|
+
// registrarlo.
|
|
54
|
+
if (m[0].length === 0) {
|
|
55
|
+
re.lastIndex++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
occ.push({ start: m.index, end: m.index + m[0].length });
|
|
59
|
+
}
|
|
60
|
+
return { occ, error };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Taglia una riga wrappata nei pezzi da colorare, intersecando gli offset.
|
|
64
|
+
*
|
|
65
|
+
* `occ` è in ordine e senza sovrapposizioni (`exec` avanza monotono), quindi
|
|
66
|
+
* basta una passata. Il guard `a < pos` regge comunque il caso di intervalli
|
|
67
|
+
* sovrapposti passati da un chiamante futuro, senza produrre testo duplicato.
|
|
68
|
+
*/
|
|
69
|
+
export function sliceLine(lineText, lineStart, occ, current) {
|
|
70
|
+
const out = [];
|
|
71
|
+
let pos = 0;
|
|
72
|
+
for (let i = 0; i < occ.length; i++) {
|
|
73
|
+
const a = Math.max(0, Math.min(lineText.length, occ[i].start - lineStart));
|
|
74
|
+
const b = Math.max(0, Math.min(lineText.length, occ[i].end - lineStart));
|
|
75
|
+
if (b <= a || a < pos)
|
|
76
|
+
continue;
|
|
77
|
+
if (a > pos)
|
|
78
|
+
out.push({ text: lineText.slice(pos, a), hit: false, current: false });
|
|
79
|
+
out.push({ text: lineText.slice(a, b), hit: true, current: i === current });
|
|
80
|
+
pos = b;
|
|
81
|
+
}
|
|
82
|
+
if (pos < lineText.length) {
|
|
83
|
+
out.push({ text: lineText.slice(pos), hit: false, current: false });
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Scroll che porta l'offset al CENTRO della finestra visibile.
|
|
89
|
+
*
|
|
90
|
+
* Stessa ricetta di `submitSearchRow` (T52). Centrare conta più che sembri:
|
|
91
|
+
* un'occorrenza portata in cima alla finestra arriva senza il contesto che la
|
|
92
|
+
* precede, ed è il contesto che dice se è quella giusta.
|
|
93
|
+
*/
|
|
94
|
+
export function topForOffset(lines, offset, capacity) {
|
|
95
|
+
const maxTop = Math.max(0, lines.length - capacity);
|
|
96
|
+
let i = lines.findIndex((l) => l.end > offset);
|
|
97
|
+
if (i < 0)
|
|
98
|
+
i = Math.max(0, lines.length - 1);
|
|
99
|
+
return Math.max(0, Math.min(maxTop, i - Math.floor(capacity / 2)));
|
|
100
|
+
}
|
package/dist/viewport.js
CHANGED
|
@@ -57,6 +57,11 @@ export const MODAL_HEIGHT = {
|
|
|
57
57
|
// pagherebbe due volte (il suo costo + quello che toglie ai pane sotto, che
|
|
58
58
|
// in quel momento non si stanno nemmeno guardando).
|
|
59
59
|
assign: 0,
|
|
60
|
+
// T66 — il detail della task: task file scrollabile + barra azioni. Quarta
|
|
61
|
+
// sostitutiva, stesso motivo delle altre tre — un task file da 200 righe non
|
|
62
|
+
// entra in un box sopra i pane. La sua altezza la distribuisce
|
|
63
|
+
// `detailCapacity`.
|
|
64
|
+
detail: 0,
|
|
60
65
|
};
|
|
61
66
|
/**
|
|
62
67
|
* Distribuisce le righe disponibili fra lista task, lista sessioni e preview.
|
|
@@ -204,6 +209,36 @@ const READER_CHROME = 8;
|
|
|
204
209
|
export function readerCapacity(rows) {
|
|
205
210
|
return Math.max(0, (rows || 24) - SLACK - READER_CHROME);
|
|
206
211
|
}
|
|
212
|
+
// T66 — cornice del detail della task.
|
|
213
|
+
// 2 bordi del box esterno
|
|
214
|
+
// 1 titolo "loom-deck"
|
|
215
|
+
// 1 riga meta (id · titolo · posizione nel testo)
|
|
216
|
+
// 1 riga hint
|
|
217
|
+
// 1 marginTop del box testo
|
|
218
|
+
// 2 bordi del box testo
|
|
219
|
+
// 1 marginTop della riga azioni
|
|
220
|
+
// 1 riga azioni
|
|
221
|
+
//
|
|
222
|
+
// Le ultime due sono il motivo per cui il detail non può riusare READER_CHROME:
|
|
223
|
+
// la barra bottoni è una riga FISSA in più dentro l'overlay, e ogni riga fissa
|
|
224
|
+
// aggiunta va scalata dalla capienza del contenuto o il frame sfonda `rows`
|
|
225
|
+
// (stessa invariante di TASKS_PANE_CHROME).
|
|
226
|
+
const DETAIL_CHROME = 10;
|
|
227
|
+
// T91 — la ricerca dentro il detail: marginTop + riga del campo.
|
|
228
|
+
//
|
|
229
|
+
// `MODAL_HEIGHT` dice che il detail costa 0 ai due pane (li sostituisce), e da
|
|
230
|
+
// lì è facile leggere «dentro l'overlay lo spazio è gratis». Le due contabilità
|
|
231
|
+
// sono distinte: quella dice quanto l'overlay toglie ai PANE, questa quanto
|
|
232
|
+
// toglie al PROPRIO contenuto. Una riga fissa aggiunta e non scalata è ciò che
|
|
233
|
+
// fa sfondare `rows` e manda Ink nel ramo `clearTerminal`, che su VTE riversa un
|
|
234
|
+
// frame nello scrollback a ogni tick del poll.
|
|
235
|
+
const DETAIL_SEARCH_CHROME = 2;
|
|
236
|
+
/** Righe di task file che entrano nel terminale; con la ricerca aperta il campo
|
|
237
|
+
* si paga qui, non altrove. */
|
|
238
|
+
export function detailCapacity(rows, searching = false) {
|
|
239
|
+
const extra = searching ? DETAIL_SEARCH_CHROME : 0;
|
|
240
|
+
return Math.max(0, (rows || 24) - SLACK - DETAIL_CHROME - extra);
|
|
241
|
+
}
|
|
207
242
|
// T52 — cornice del pannello di anteprima sotto la lista occorrenze:
|
|
208
243
|
// marginTop + 2 bordi + riga meta.
|
|
209
244
|
const SEARCH_PREVIEW_CHROME = 4;
|
package/package.json
CHANGED
package/scripts/deck-run
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# Tre assi ORTOGONALI, da non confondere fra loro:
|
|
12
12
|
# binding task <TaskID> ..... --no-task
|
|
13
13
|
# continuità nuova ........ --resume [--fork]
|
|
14
|
-
# prompt --prompt-kind none|recap|preflight|run
|
|
14
|
+
# prompt --prompt-kind none|recap|preflight|run|checkpoint
|
|
15
15
|
# Il terzo asse (T56) prima non esisteva: il prompt era una CONSEGUENZA degli
|
|
16
16
|
# altri due (bound ⇒ recap, --no-task ⇒ niente, --resume ⇒ niente), quindi
|
|
17
17
|
# "task-bound SENZA prompt" era inesprimibile e l'unico modo di non avere un
|
|
@@ -74,7 +74,7 @@ PROMPT_KIND=""
|
|
|
74
74
|
# Sotto --fork il --session-id torna AMMESSO (anzi: è il punto) — la mutua
|
|
75
75
|
# esclusione con --resume esiste perché una ripresa nuda riscrive il transcript
|
|
76
76
|
# dell'id ripreso, mentre il fork ne apre uno NUOVO, che possiamo quindi pinnare.
|
|
77
|
-
# --prompt-kind <none|recap|preflight|run> (T56): sceglie il prompt iniziale fra
|
|
77
|
+
# --prompt-kind <none|recap|preflight|run|checkpoint> (T56, T66): sceglie il prompt iniziale fra
|
|
78
78
|
# quelli del catalogo qui sotto. Enum e non stringa libera: il prompt viaggia
|
|
79
79
|
# dentro apici singoli in `bash -lc`, quindi il testo va tenuto in un posto solo
|
|
80
80
|
# e verificato una volta, invece di spostare il rischio di quoting su ogni
|
|
@@ -125,6 +125,7 @@ USAGE="uso: deck-run <TaskID> [--prompt-kind <kind>] [--session-id <uuid>]
|
|
|
125
125
|
recap recap stato task <TaskID>
|
|
126
126
|
preflight /loom-works:preflight-task <TaskID>
|
|
127
127
|
run /loom-works:run-task <TaskID> (run-doc sulle task D)
|
|
128
|
+
checkpoint /loom-works:checkpoint-task <TaskID>
|
|
128
129
|
|
|
129
130
|
--title-note nota della conversazione, appesa al titolo tab come «nota»
|
|
130
131
|
(ridotta a lettere/cifre/spazi/-/_, cap 60 char)"
|
|
@@ -154,8 +155,8 @@ fi
|
|
|
154
155
|
# lì il valore arriva da un file editabile a mano, qui da un argomento nostro.
|
|
155
156
|
if [[ -n "$PROMPT_KIND" ]]; then
|
|
156
157
|
case "$PROMPT_KIND" in
|
|
157
|
-
none|recap|preflight|run) ;;
|
|
158
|
-
*) echo "--prompt-kind ignoto: '${PROMPT_KIND}' (usa none|recap|preflight|run)" >&2
|
|
158
|
+
none|recap|preflight|run|checkpoint) ;;
|
|
159
|
+
*) echo "--prompt-kind ignoto: '${PROMPT_KIND}' (usa none|recap|preflight|run|checkpoint)" >&2
|
|
159
160
|
echo "$USAGE" >&2
|
|
160
161
|
exit 2 ;;
|
|
161
162
|
esac
|
|
@@ -336,11 +337,12 @@ FORK_FLAG=""
|
|
|
336
337
|
# Il prompt iniziale è la scelta di CHI APRE la sessione, non una proprietà della
|
|
337
338
|
# task: lo stesso task file si apre per leggerne lo stato, per un preflight, per
|
|
338
339
|
# eseguirlo o a mani nude. Il kind è quell'intento.
|
|
339
|
-
# none
|
|
340
|
-
#
|
|
341
|
-
# recap
|
|
342
|
-
# preflight
|
|
343
|
-
# run
|
|
340
|
+
# none → nessun prompt (≠ stringa vuota: PROMPT_ARG sparisce del tutto,
|
|
341
|
+
# come sul ramo --resume, altrimenti CC riceve un posizionale vuoto)
|
|
342
|
+
# recap → prompt DIRETTO, no skill: l'utente rivede/steera prima di lanciare
|
|
343
|
+
# preflight → skill di preflight sulla task
|
|
344
|
+
# run → skill di esecuzione sulla task
|
|
345
|
+
# checkpoint → skill di checkpoint sulla task
|
|
344
346
|
# Vincolo di quoting: nessun template deve contenere apici singoli (viaggia
|
|
345
347
|
# dentro '...' nel comando in-tab). `/loom-works:...` passa intero proprio perché
|
|
346
348
|
# è single-quoted — lo slash e i due punti non sono metacaratteri lì dentro.
|
|
@@ -355,16 +357,19 @@ FORK_FLAG=""
|
|
|
355
357
|
# del contratto loom (`T` code, `D` doc) e le due hanno skill di esecuzione
|
|
356
358
|
# diverse — `run-task` è dichiarata code-only, `run-doc` è la sua complementare.
|
|
357
359
|
# Su una D il prompt `run-task` invocherebbe la skill sbagliata. Gli altri kind
|
|
358
|
-
# non discriminano
|
|
359
|
-
# `
|
|
360
|
+
# non discriminano, ma è una verifica da rifare per OGNI kind nuovo, non una
|
|
361
|
+
# proprietà del catalogo: `recap` è un prompt diretto (nessuna skill),
|
|
362
|
+
# `preflight-task` risolve il task file per id, e `checkpoint-task` estrae il
|
|
363
|
+
# taskId con `T\d+|D\d+` — copre quindi entrambi i prefissi da sé.
|
|
360
364
|
case "${PROMPT_KIND:-recap}" in
|
|
361
|
-
none)
|
|
362
|
-
recap)
|
|
363
|
-
preflight)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
365
|
+
none) PROMPT="" ;;
|
|
366
|
+
recap) PROMPT="recap stato task ${TASK}" ;;
|
|
367
|
+
preflight) PROMPT="/loom-works:preflight-task ${TASK}" ;;
|
|
368
|
+
checkpoint) PROMPT="/loom-works:checkpoint-task ${TASK}" ;;
|
|
369
|
+
run) case "$TASK" in
|
|
370
|
+
D*) PROMPT="/loom-works:run-doc ${TASK}" ;;
|
|
371
|
+
*) PROMPT="/loom-works:run-task ${TASK}" ;;
|
|
372
|
+
esac ;;
|
|
368
373
|
esac
|
|
369
374
|
if [[ "${PROMPT_KIND:-}" != "none" && -n "${LOOM_DECK_ENTER_PROMPT:-}" ]]; then
|
|
370
375
|
PROMPT="${LOOM_DECK_ENTER_PROMPT//\{TASK\}/${TASK}}"
|