@lamemind/loom-deck 0.14.0 → 0.16.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 +26 -4
- package/dist/cli.js +113 -24
- package/dist/search.js +20 -8
- package/dist/task-edit.js +5 -5
- package/dist/tasks.js +17 -5
- package/dist/view.js +9 -4
- package/dist/viewport.js +18 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,12 +4,16 @@ Deck TUI (Ink) **per-progetto** della famiglia [loom](https://github.com/lamemin
|
|
|
4
4
|
|
|
5
5
|
Legge il `tasks.md` del progetto e, con un tasto (poi un click), **spawna** una tab
|
|
6
6
|
[Ptyxis](https://gitlab.gnome.org/chergert/ptyxis) che avvia una sessione Claude Code
|
|
7
|
-
già bound alla task via `LOOM_TASK`,
|
|
7
|
+
già bound alla task via `LOOM_TASK`, con un prompt di recap sullo stato della task.
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
↑↓ scegli la task → ⏎ → tab CC di fianco →
|
|
10
|
+
↑↓ scegli la task → ⏎ → tab CC di fianco → LOOM_TASK bound + recap stato task
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
Entrambi i prefissi del contratto loom entrano in lista: **`T`** (code task) e
|
|
14
|
+
**`D`** (doc task). Nessuna differenza di trattamento — il prompt iniziale è un
|
|
15
|
+
recap, non l'invocazione di una skill, quindi vale identico sulle due famiglie.
|
|
16
|
+
|
|
13
17
|
## Ruolo nella famiglia loom
|
|
14
18
|
|
|
15
19
|
`loom-deck` è un **client** con runtime proprio (TUI Ink) che **consuma** il contratto
|
|
@@ -148,7 +152,19 @@ che non può mai produrre un risultato.
|
|
|
148
152
|
|
|
149
153
|
Con l'hash vuoto la lista è **raggruppata per conversazione**; `⏎` è contestuale
|
|
150
154
|
alla riga selezionata — su una riga-conversazione **riprende** la sessione
|
|
151
|
-
(come dal pane Sessions), su una riga-occorrenza apre il **reader**.
|
|
155
|
+
(come dal pane Sessions), su una riga-occorrenza apre il **reader**. La selezione
|
|
156
|
+
parte dalla prima *occorrenza*, non dalla riga di gruppo: quella è un segnaposto
|
|
157
|
+
di navigazione, raggiungibile con le frecce ma non una destinazione.
|
|
158
|
+
|
|
159
|
+
L'estratto attorno al match **si allarga col terminale**: a 190 colonne sono
|
|
160
|
+
~170 caratteri di contesto, non i 50 di un valore fisso — ed è il contesto la
|
|
161
|
+
ragione per cui si legge la riga invece di aprire il reader.
|
|
162
|
+
|
|
163
|
+
Sotto la lista, un **pannello di anteprima** mostra il contesto attorno
|
|
164
|
+
all'occorrenza selezionata e si aggiorna navigando con le frecce. Prende solo le
|
|
165
|
+
righe che la lista non usa: con pochi risultati riempie il terminale, con molti
|
|
166
|
+
sparisce e la lista se le riprende — quando c'è tanto da scorrere la priorità è
|
|
167
|
+
vedere più occorrenze, il contesto è il premio per una ricerca già stretta.
|
|
152
168
|
|
|
153
169
|
Il reader mostra il messaggio intero, aperto già **posizionato sull'occorrenza**
|
|
154
170
|
col match evidenziato: `↑↓` riga, `PgUp`/`PgDn` pagina, `g`/`G` estremi, `esc`
|
|
@@ -181,6 +197,11 @@ p priorità s stato i id
|
|
|
181
197
|
|
|
182
198
|
Partendo da catena vuota, digitare `ppi` produce `[pri ↓, id ↑]`. Il ciclo parte
|
|
183
199
|
sempre **dallo stato corrente**, che il modale mostra dal vivo mentre digiti.
|
|
200
|
+
|
|
201
|
+
Sull'**id** il confronto è numerico (`T9` prima di `T10`, non lessicografico) e i
|
|
202
|
+
due prefissi sono blocchi distinti — le `D` in coda alle `T`: i counter `T` e `D`
|
|
203
|
+
sono separati nel contratto loom, quindi `T01` e `D01` non sono confrontabili
|
|
204
|
+
come numeri soli.
|
|
184
205
|
A parità su tutte le chiavi decide sempre l'`id` (confronto **numerico**: `T9`
|
|
185
206
|
prima di `T10`) → l'ordine è deterministico, mai instabile fra un refresh e l'altro.
|
|
186
207
|
|
|
@@ -222,7 +243,8 @@ npx @lamemind/loom-deck
|
|
|
222
243
|
scripts/deck-run T18
|
|
223
244
|
```
|
|
224
245
|
|
|
225
|
-
Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude '
|
|
246
|
+
Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude 'recap stato task T18'`
|
|
247
|
+
(prompt override-abile via `LOOM_DECK_ENTER_PROMPT`, placeholder `{TASK}`).
|
|
226
248
|
|
|
227
249
|
## Sviluppo (TUI Ink)
|
|
228
250
|
|
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions
|
|
|
13
13
|
import { appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
14
14
|
import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, selectedSession, } from './session-list.js';
|
|
15
15
|
import { launchLegend, loadIdentity, loadLaunch } from './config.js';
|
|
16
|
-
import { isCompact, layoutBudget, normalizeEmoji, readerCapacity, searchListCapacity, windowRange, wrapLines, wrapWithOffsets, } from './viewport.js';
|
|
16
|
+
import { isCompact, layoutBudget, normalizeEmoji, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, wrapLines, wrapWithOffsets, } from './viewport.js';
|
|
17
17
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
18
18
|
import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
|
|
19
19
|
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
@@ -497,11 +497,19 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
497
497
|
// ≤9 ms su tutti i tipi — sotto il tempo fra due battute. Il memo evita di
|
|
498
498
|
// rifarla sui re-render che non toccano né query né opzioni (il poll delle
|
|
499
499
|
// sessioni ogni 1,5s è già filtrato dalla signature in `useSessions`).
|
|
500
|
-
const searchResult = useMemo(() => searchSessions(sessions, searchHash, searchQuery, searchOpts), [sessions, searchHash, searchQuery, searchOpts]);
|
|
500
|
+
const searchResult = useMemo(() => searchSessions(sessions, searchHash, searchQuery, searchOpts, searchExcerptWidth(columns)), [sessions, searchHash, searchQuery, searchOpts, columns]);
|
|
501
501
|
// Con l'hash valorizzato la conversazione è una sola e già nominata nel campo:
|
|
502
502
|
// la riga-sessione ripeterebbe un dato costante rubando una riga per gruppo.
|
|
503
503
|
const searchFlat = searchHash.trim().length > 0;
|
|
504
504
|
const searchRows = useMemo(() => buildRows(searchResult, searchFlat), [searchResult, searchFlat]);
|
|
505
|
+
// T52 — riga selezionata e, se è un'occorrenza, il suo corpo wrappato per
|
|
506
|
+
// l'anteprima sotto la lista. Memoizzato per (testo, larghezza): navigando
|
|
507
|
+
// con le frecce si ri-wrappa solo quando cambia davvero l'occorrenza.
|
|
508
|
+
const searchSelRow = useMemo(() => selectedRow(searchRows, searchSelKey), [searchRows, searchSelKey]);
|
|
509
|
+
const searchPreviewWidth = Math.max(20, (columns || 80) - 8);
|
|
510
|
+
const searchPreviewBody = useMemo(() => searchSelRow?.kind === 'hit'
|
|
511
|
+
? wrapWithOffsets(searchSelRow.hit.text, searchPreviewWidth)
|
|
512
|
+
: [], [searchSelRow, searchPreviewWidth]);
|
|
505
513
|
// T52 — corpo del messaggio aperto nel reader, wrappato UNA volta per (testo,
|
|
506
514
|
// larghezza). Senza memo ogni pressione di freccia rifarebbe l'a-capo di un
|
|
507
515
|
// messaggio che nella coda lunga arriva a 150k char.
|
|
@@ -673,22 +681,52 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
673
681
|
setSearchOpts((o) => ({ ...o, kinds: { ...o.kinds, [kind]: !o.kinds[kind] } }));
|
|
674
682
|
}
|
|
675
683
|
function editSearchField(fn) {
|
|
684
|
+
// La nota racconta l'esito di un'AZIONE su una lista che, con l'eager, si
|
|
685
|
+
// ricostruisce a ogni carattere: appena la query cambia è già scaduta.
|
|
686
|
+
// Lasciarla lì la fa leggere come se descrivesse lo stato corrente — nello
|
|
687
|
+
// specifico «nessuna occorrenza selezionata» sopra una lista con una riga
|
|
688
|
+
// visibilmente selezionata, cioè una contraddizione a schermo.
|
|
689
|
+
setNote('');
|
|
676
690
|
if (searchField === 'hash')
|
|
677
691
|
setSearchHash(fn);
|
|
678
692
|
else
|
|
679
693
|
setSearchQuery(fn);
|
|
680
694
|
}
|
|
681
695
|
// `useInput` consegna il CHUNK letto da stdin, non un tasto: un incollaggio —
|
|
682
|
-
// o una raffica di tasti
|
|
683
|
-
// byte di controllo compresi.
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
696
|
+
// o una raffica di tasti piu' veloce di una read — arriva come stringa unica,
|
|
697
|
+
// byte di controllo compresi. Due conseguenze, entrambe verificate su pty:
|
|
698
|
+
//
|
|
699
|
+
// 1. I byte di controllo finiscono DENTRO il campo se non li si filtra. Non
|
|
700
|
+
// si vedono, ma Ink li conta nella larghezza della riga e nessun match li
|
|
701
|
+
// soddisfa -> la ricerca smette di trovare senza dire perche'. Le newline
|
|
702
|
+
// diventano spazio invece di sparire: incollare due righe deve separare
|
|
703
|
+
// le parole, non fonderle.
|
|
704
|
+
//
|
|
705
|
+
// 2. Un TAB digitato subito dopo una lettera (~60 ms, cioe' battitura veloce
|
|
706
|
+
// normale) arriva incollato ad essa: `key.tab` resta falso e il ramo del
|
|
707
|
+
// cambio campo non scatta mai. Trattarlo come testo lo renderebbe uno
|
|
708
|
+
// spazio, per giunta nel campo sbagliato — e' cosi' che `70897aff` + Tab
|
|
709
|
+
// + `congelat` finiva tutto quanto nel campo hash.
|
|
710
|
+
//
|
|
711
|
+
// Si spezza quindi il chunk SUL TAB applicando il cambio campo in mezzo: il
|
|
712
|
+
// risultato e' identico alla battitura lenta. Stessa lezione del modale sort
|
|
713
|
+
// (T39), che cicla sui caratteri del chunk invece di leggerlo intero.
|
|
688
714
|
function typeIntoField(chunk) {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
715
|
+
setNote('');
|
|
716
|
+
const clean = (s) => s.replace(/[\r\n]/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '');
|
|
717
|
+
const parts = chunk.split('\t');
|
|
718
|
+
let field = searchField;
|
|
719
|
+
const add = { hash: '', query: '' };
|
|
720
|
+
for (let k = 0; k < parts.length; k++) {
|
|
721
|
+
if (k > 0)
|
|
722
|
+
field = field === 'hash' ? 'query' : 'hash';
|
|
723
|
+
add[field] += clean(parts[k]);
|
|
724
|
+
}
|
|
725
|
+
if (add.hash)
|
|
726
|
+
setSearchHash((v) => v + add.hash);
|
|
727
|
+
if (add.query)
|
|
728
|
+
setSearchQuery((v) => v + add.query);
|
|
729
|
+
setSearchField(field);
|
|
692
730
|
}
|
|
693
731
|
const searchCap = searchListCapacity(rows, Boolean(note));
|
|
694
732
|
useInput((input, key) => {
|
|
@@ -1095,7 +1133,26 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1095
1133
|
}
|
|
1096
1134
|
const selIdx = rowIndexOfKey(searchRows, searchSelKey);
|
|
1097
1135
|
const win = windowRange(searchRows.length, selIdx, searchCap);
|
|
1098
|
-
|
|
1136
|
+
// Anteprima dell'occorrenza selezionata: prende le righe che la lista non
|
|
1137
|
+
// usa. Con molti risultati `spare` è 0 e il pannello non esiste — la lista
|
|
1138
|
+
// se le riprende tutte, che è la priorità giusta quando c'è molto da
|
|
1139
|
+
// scorrere. La finestra si CENTRA sul match (`windowRange`), così il
|
|
1140
|
+
// contesto arriva da entrambi i lati.
|
|
1141
|
+
const spare = searchPreviewCapacity(searchCap, win.end - win.start);
|
|
1142
|
+
let preview = null;
|
|
1143
|
+
if (spare >= 1 && searchSelRow?.kind === 'hit') {
|
|
1144
|
+
const h = searchSelRow.hit;
|
|
1145
|
+
const mline = Math.max(0, searchPreviewBody.findIndex((l) => l.end > h.matchStart));
|
|
1146
|
+
const pw = windowRange(searchPreviewBody.length, mline, spare);
|
|
1147
|
+
preview = {
|
|
1148
|
+
hit: h,
|
|
1149
|
+
lines: searchPreviewBody.slice(pw.start, pw.end),
|
|
1150
|
+
from: pw.start,
|
|
1151
|
+
total: searchPreviewBody.length,
|
|
1152
|
+
ts: sessions.find((s) => s.sessionId === h.sessionId)?.ts ?? 0,
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
return (_jsx(SearchScreen, { preview: preview, hash: searchHash, query: searchQuery, field: searchField, opts: searchOpts, result: searchResult, rows: searchRows.slice(win.start, win.end), selectedKey: searchSelKey, selectedKind: selectedRow(searchRows, searchSelKey)?.kind ?? null, above: win.start, below: searchRows.length - win.end, capacity: searchCap, bindings: bindings, pinned: pinned, projectCore: identity ? `${identity.owner} ${identity.name}` : null, columns: columns, note: note }));
|
|
1099
1156
|
}
|
|
1100
1157
|
// ── Budget d'altezza ────────────────────────────────────────────────────
|
|
1101
1158
|
// Il frame deve restare sotto `rows`, sempre: oltre quella soglia Ink smette
|
|
@@ -1171,6 +1228,31 @@ function EditModal({ id, draft, row }) {
|
|
|
1171
1228
|
// terminale misurano diversamente (vedi normalizeEmoji).
|
|
1172
1229
|
const KIND_TAG = { ai: 'ai', tool: 'tl', human: 'hu' };
|
|
1173
1230
|
const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
|
|
1231
|
+
/**
|
|
1232
|
+
* Larghezza dell'estratto, DERIVATA dalle colonne del terminale.
|
|
1233
|
+
*
|
|
1234
|
+
* Una costante qui è spazio buttato a ogni riga: su un terminale a 190 colonne
|
|
1235
|
+
* un estratto fisso a 50 lascia il match con ~20 caratteri di contesto per lato
|
|
1236
|
+
* quando potrebbe averne 80 — e il contesto attorno al match è l'unica ragione
|
|
1237
|
+
* per cui si legge la riga invece di aprire il reader.
|
|
1238
|
+
*
|
|
1239
|
+
* Scomposizione delle colonne consumate dalla cornice e dal prefisso di riga:
|
|
1240
|
+
* 4 box esterno (2 bordi + 2 padding)
|
|
1241
|
+
* 4 box lista (2 bordi + 2 padding)
|
|
1242
|
+
* 2 caret
|
|
1243
|
+
* 4 indice del record
|
|
1244
|
+
* 3 spazio + tag del kind + spazio
|
|
1245
|
+
* Prudente per costruzione: sottostimare tronca un carattere in più,
|
|
1246
|
+
* sovrastimare manderebbe la riga a capo e sfonderebbe il budget d'altezza.
|
|
1247
|
+
*/
|
|
1248
|
+
function searchExcerptWidth(columns) {
|
|
1249
|
+
return Math.max(30, (columns || 80) - 17);
|
|
1250
|
+
}
|
|
1251
|
+
/** Titolo della conversazione sulla riga-gruppo: prende ciò che avanza dopo le
|
|
1252
|
+
* colonne a larghezza fissa (caret, pin, hash, task, conteggio, data). */
|
|
1253
|
+
function searchTitleWidth(columns) {
|
|
1254
|
+
return Math.max(24, (columns || 80) - 56);
|
|
1255
|
+
}
|
|
1174
1256
|
/**
|
|
1175
1257
|
* Cosa scrivere sulla riga-gruppo per distinguere una conversazione dall'altra.
|
|
1176
1258
|
*
|
|
@@ -1227,27 +1309,34 @@ function SearchListHeader({ result, query, above, below, }) {
|
|
|
1227
1309
|
}
|
|
1228
1310
|
return (_jsxs(Text, { bold: true, wrap: "truncate-end", children: [result.shown, " occorrenze in ", result.sessionCount, " conversazioni", result.hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", result.hidden, " oltre il cap"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }));
|
|
1229
1311
|
}
|
|
1230
|
-
|
|
1231
|
-
* Schermata di ricerca full-text (T52).
|
|
1232
|
-
*
|
|
1233
|
-
* Due campi + toggle + lista di occorrenze. Con l'hash vuoto la lista è
|
|
1234
|
-
* raggruppata per conversazione: la riga-gruppo NON ripete il nome del progetto
|
|
1235
|
-
* (D3: sono tutte dello stesso progetto, sarebbe una colonna costante) e usa lo
|
|
1236
|
-
* spazio per ciò che distingue davvero una conversazione — hash, task legata,
|
|
1237
|
-
* titolo, data.
|
|
1238
|
-
*/
|
|
1239
|
-
function SearchScreen({ hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, projectCore, note, }) {
|
|
1312
|
+
function SearchScreen({ preview, hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, projectCore, columns, note, }) {
|
|
1240
1313
|
const enter = selectedKind === 'session' ? 'resume' : selectedKind === 'hit' ? 'leggi' : '—';
|
|
1241
1314
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["ricerca \u00B7 ", _jsx(Text, { color: "yellow", children: "tab" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " naviga \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " ", enter, " \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "hash " }), _jsx(Text, { color: field === 'hash' ? 'yellow' : undefined, children: hash }), field === 'hash' ? _jsx(Text, { inverse: true, children: " " }) : null, !hash ? _jsx(Text, { dimColor: true, children: " (vuoto = tutte le conversazioni)" }) : null] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "chiave " }), _jsx(Text, { color: field === 'query' ? 'yellow' : undefined, children: query }), field === 'query' ? _jsx(Text, { inverse: true, children: " " }) : null] }), _jsx(ToggleHint, { opts: opts })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsx(SearchListHeader, { result: result, query: query, above: above, below: below }), rows.slice(0, Math.max(0, capacity)).map((row) => {
|
|
1242
1315
|
const sel = row.key === selectedKey;
|
|
1243
1316
|
if (row.kind === 'session') {
|
|
1244
1317
|
const s = row.session;
|
|
1245
1318
|
const bound = bindings.get(s.sessionId);
|
|
1246
|
-
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), truncate(conversationLabel(s, projectCore, bound),
|
|
1319
|
+
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), truncate(conversationLabel(s, projectCore, bound), searchTitleWidth(columns)), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
|
|
1247
1320
|
}
|
|
1248
1321
|
const h = row.hit;
|
|
1249
1322
|
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: String(h.idx).padStart(4) }), ' ', _jsx(Text, { color: KIND_COLOR[h.kind], children: KIND_TAG[h.kind] }), " ", h.excerpt] }, row.key));
|
|
1250
|
-
})] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
|
|
1323
|
+
})] }), preview ? _jsx(SearchPreviewPane, { p: preview }) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Anteprima dell'occorrenza selezionata, sotto la lista.
|
|
1327
|
+
*
|
|
1328
|
+
* Riempie le righe che la lista non usa: con pochi risultati il terminale
|
|
1329
|
+
* resterebbe vuoto per tre quarti, e il contesto attorno al match è proprio
|
|
1330
|
+
* ciò che serve per decidere se è l'occorrenza giusta. Nel caso comune evita
|
|
1331
|
+
* del tutto di aprire il reader.
|
|
1332
|
+
*
|
|
1333
|
+
* Si aggiorna navigando con le frecce, e la finestra è centrata sul match:
|
|
1334
|
+
* stessa `windowRange` della lista, stessa evidenziazione del reader
|
|
1335
|
+
* (`ReaderLine`) — nessuna primitiva nuova.
|
|
1336
|
+
*/
|
|
1337
|
+
function SearchPreviewPane({ p }) {
|
|
1338
|
+
const last = Math.min(p.total, p.from + p.lines.length);
|
|
1339
|
+
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, from: p.hit.matchStart, to: p.hit.matchEnd }, p.from + i)))] }));
|
|
1251
1340
|
}
|
|
1252
1341
|
/**
|
|
1253
1342
|
* Reader fullscreen (T52 · D8).
|
package/dist/search.js
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
/** Sotto questa soglia non si cerca: 1-2 char matchano ovunque e la lista
|
|
20
20
|
* sarebbe rumore. Stato lecito, non un errore. */
|
|
21
21
|
export const MIN_QUERY = 3;
|
|
22
|
-
/** Larghezza
|
|
22
|
+
/** Larghezza di ripiego dell'estratto, usata solo quando il chiamante non
|
|
23
|
+
* conosce le colonne del terminale (test, uso non-TUI). In UI la larghezza
|
|
24
|
+
* arriva SEMPRE da `columns`: inchiodarla qui sprecherebbe tutto lo spazio di
|
|
25
|
+
* un terminale largo — su 190 colonne significa ~140 buttate per riga. */
|
|
23
26
|
export const EXCERPT_WIDTH = 50;
|
|
24
27
|
/** Cap per singola conversazione. Serve a tenere leggibile un gruppo, non a
|
|
25
28
|
* proteggere la memoria: una query corta può matchare centinaia di volte nello
|
|
@@ -121,7 +124,7 @@ export function excerptAround(text, start, end, width = EXCERPT_WIDTH) {
|
|
|
121
124
|
*
|
|
122
125
|
* `re` deve avere il flag `g` (lo garantisce buildMatcher).
|
|
123
126
|
*/
|
|
124
|
-
function scanBody(sessionId, body, re, need) {
|
|
127
|
+
function scanBody(sessionId, body, re, need, excerptWidth) {
|
|
125
128
|
const hits = [];
|
|
126
129
|
let count = 0;
|
|
127
130
|
re.lastIndex = 0;
|
|
@@ -135,7 +138,7 @@ function scanBody(sessionId, body, re, need) {
|
|
|
135
138
|
sessionId,
|
|
136
139
|
idx: body.idx,
|
|
137
140
|
kind: body.kind,
|
|
138
|
-
excerpt: excerptAround(body.text, start, end),
|
|
141
|
+
excerpt: excerptAround(body.text, start, end, excerptWidth),
|
|
139
142
|
matchStart: start,
|
|
140
143
|
matchEnd: end,
|
|
141
144
|
text: body.text,
|
|
@@ -155,7 +158,7 @@ function scanBody(sessionId, body, re, need) {
|
|
|
155
158
|
* uguaglianza: gli 8 char che la statusline mostra bastano a individuarla, e
|
|
156
159
|
* nessuno copia un uuid intero a mano.
|
|
157
160
|
*/
|
|
158
|
-
export function searchSessions(sessions, hashPrefix, query, opts) {
|
|
161
|
+
export function searchSessions(sessions, hashPrefix, query, opts, excerptWidth = EXCERPT_WIDTH) {
|
|
159
162
|
const prefix = hashPrefix.trim().toLowerCase();
|
|
160
163
|
const scoped = prefix
|
|
161
164
|
? sessions.filter((s) => s.sessionId.toLowerCase().startsWith(prefix))
|
|
@@ -178,7 +181,7 @@ export function searchSessions(sessions, hashPrefix, query, opts) {
|
|
|
178
181
|
// spazio nella lista. A zero la scansione prosegue lo stesso — serve il
|
|
179
182
|
// conteggio, che è ciò che alimenta «+N occorrenze».
|
|
180
183
|
const need = Math.max(0, Math.min(MAX_HITS_PER_SESSION - hits.length, MAX_TOTAL_HITS - shown - hits.length));
|
|
181
|
-
const r = scanBody(session.sessionId, body, re, need);
|
|
184
|
+
const r = scanBody(session.sessionId, body, re, need, excerptWidth);
|
|
182
185
|
found += r.count;
|
|
183
186
|
hits.push(...r.hits);
|
|
184
187
|
}
|
|
@@ -221,10 +224,19 @@ export function buildRows(result, flat) {
|
|
|
221
224
|
}
|
|
222
225
|
return rows;
|
|
223
226
|
}
|
|
224
|
-
/**
|
|
225
|
-
*
|
|
227
|
+
/**
|
|
228
|
+
* Riga su cui atterrare quando la selezione va ricostruita.
|
|
229
|
+
*
|
|
230
|
+
* Preferisce la prima **occorrenza**, non la prima riga in assoluto. In modalità
|
|
231
|
+
* raggruppata la lista si apre sempre con una riga-sessione, che è un
|
|
232
|
+
* segnaposto di navigazione e non una destinazione: atterrarci lascerebbe
|
|
233
|
+
* l'anteprima vuota all'apertura di ogni ricerca, e `⏎` prometterebbe un resume
|
|
234
|
+
* quando l'utente ha appena cercato delle occorrenze.
|
|
235
|
+
*
|
|
236
|
+
* Le righe-sessione restano raggiungibili con le frecce. null = lista vuota.
|
|
237
|
+
*/
|
|
226
238
|
export function firstRowKey(rows) {
|
|
227
|
-
return rows[0]?.key ?? null;
|
|
239
|
+
return (rows.find((r) => r.kind === 'hit') ?? rows[0])?.key ?? null;
|
|
228
240
|
}
|
|
229
241
|
/** Indice della riga con quella chiave; -1 se assente. */
|
|
230
242
|
export function rowIndexOfKey(rows, key) {
|
package/dist/task-edit.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// isolata in `writeTaskEdit`, così la grammatica delle sostituzioni è testabile
|
|
4
4
|
// senza toccare il filesystem.
|
|
5
5
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
-
import { findTaskFile } from './tasks.js';
|
|
6
|
+
import { TASK_ID_RE, findTaskFile } from './tasks.js';
|
|
7
7
|
// Il task file scrive la priorità per NOME (`- **Priority**: Med`), tasks.md la
|
|
8
8
|
// scrive come GLIFO (colonna Pri). Due rappresentazioni dello stesso valore →
|
|
9
9
|
// due mappe, entrambe keyed sul nome canonico di view.ts (unica fonte del rango).
|
|
@@ -81,11 +81,11 @@ export function initialDetail(current, prog, date = today()) {
|
|
|
81
81
|
* restano i token grezzi originali: nessun re-flow della tabella, il diff resta
|
|
82
82
|
* di una riga. `ok:false` = id assente → il chiamante non scrive nulla.
|
|
83
83
|
*
|
|
84
|
-
* L'id deve rispettare
|
|
85
|
-
*
|
|
86
|
-
* di separatore, riscrivendone le
|
|
84
|
+
* L'id deve rispettare `TASK_ID_RE` (importato da tasks.ts — stesso gate di
|
|
85
|
+
* `parseTasks`, non una copia): senza, un id arbitrario matcherebbe la riga di
|
|
86
|
+
* HEADER (`| ID | Pri | K | Prog |`) o quella di separatore, riscrivendone le
|
|
87
|
+
* celle e sfondando la tabella.
|
|
87
88
|
*/
|
|
88
|
-
const TASK_ID_RE = /^T\d+$/;
|
|
89
89
|
export function updateTasksMdRow(content, id, priGlyph, progGlyph) {
|
|
90
90
|
if (!TASK_ID_RE.test(id))
|
|
91
91
|
return { content, ok: false };
|
package/dist/tasks.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { normalizeEmoji } from './viewport.js';
|
|
4
|
+
/**
|
|
5
|
+
* Forma di un ID task: `T` (code) o `D` (doc) + numero. FONTE UNICA del gate —
|
|
6
|
+
* `parseTasks` lo usa per scartare header/separatore della tabella, `task-edit`
|
|
7
|
+
* per non riscrivere per sbaglio quelle stesse righe. Tenerlo in un posto solo
|
|
8
|
+
* evita che i due gate divergano (uno accetterebbe righe che l'altro rifiuta).
|
|
9
|
+
*/
|
|
10
|
+
export const TASK_ID_RE = /^[TD]\d+$/;
|
|
4
11
|
// D1 (preflight T20): default docs/tasks.md, override della docs-root via env
|
|
5
12
|
// LOOM_DECK_DOCS_ROOT (es. questo progetto usa `runtime`). No auto-detect.
|
|
6
13
|
export function resolveTasksPath(cwd = process.cwd()) {
|
|
@@ -13,9 +20,11 @@ export function resolveTasksDir(cwd = process.cwd()) {
|
|
|
13
20
|
return join(dirname(resolveTasksPath(cwd)), 'tasks');
|
|
14
21
|
}
|
|
15
22
|
// Estrae le righe `| Tnn | Pri | K | Prog | Task |` della Tasks Overview.
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
23
|
+
// Due prefissi ammessi (contratto plugin, counter separati): `T` = code task,
|
|
24
|
+
// `D` = doc task. Le D entrano in lista come le T perché `deck-run` NON inietta
|
|
25
|
+
// `run-task`: il prompt iniziale è un recap diretto ("recap stato task <id>"),
|
|
26
|
+
// valido identico su una doc task. Righe header/separatore non matchano `^[TD]\d+$`
|
|
27
|
+
// → scartate.
|
|
19
28
|
export function parseTasks(content) {
|
|
20
29
|
const tasks = [];
|
|
21
30
|
for (const line of content.split('\n')) {
|
|
@@ -25,7 +34,7 @@ export function parseTasks(content) {
|
|
|
25
34
|
const cells = t.split('|').map((c) => c.trim());
|
|
26
35
|
// cells[0] = '' (prima del primo |), cells[last] = '' (dopo l'ultimo |)
|
|
27
36
|
const id = cells[1];
|
|
28
|
-
if (
|
|
37
|
+
if (!TASK_ID_RE.test(id))
|
|
29
38
|
continue;
|
|
30
39
|
// Colonne overview: | ID | Pri | K | Prog | Task | → cells[2]=Pri (icona
|
|
31
40
|
// priorità), cells[4]=Prog. K (Kind, cells[3]) non serve al deck.
|
|
@@ -77,7 +86,10 @@ export function parseTaskDetail(id, content) {
|
|
|
77
86
|
let inDesc = false;
|
|
78
87
|
for (const line of content.split('\n')) {
|
|
79
88
|
if (!title && line.startsWith('# ')) {
|
|
80
|
-
|
|
89
|
+
// I due template scrivono H1 diversi — `# Task: …` (code) e
|
|
90
|
+
// `# Doc Task: …` (doc): entrambi i cappelli vanno via, il titolo mostrato
|
|
91
|
+
// è la descrizione, non la sua categoria (già data dall'id).
|
|
92
|
+
title = line.replace(/^#\s+/, '').replace(/^(?:Doc\s+)?Task:\s*/, '').trim();
|
|
81
93
|
continue;
|
|
82
94
|
}
|
|
83
95
|
if (line.startsWith('## ')) {
|
package/dist/view.js
CHANGED
|
@@ -53,11 +53,16 @@ export function progRank(glyph) {
|
|
|
53
53
|
return PROG_TABLE.find((e) => e.glyph === g)?.rank ?? UNKNOWN_RANK;
|
|
54
54
|
}
|
|
55
55
|
// `T10`.localeCompare(`T9`) mette T10 prima: l'ID va confrontato NUMERICO.
|
|
56
|
-
// parseTasks ammette
|
|
57
|
-
// il
|
|
56
|
+
// parseTasks ammette due prefissi con counter SEPARATI (`T` code, `D` doc) →
|
|
57
|
+
// il solo numero non basta, T01 e D01 collidono. Il prefisso pesa quindi come
|
|
58
|
+
// cifra più significativa (T prima di D, blocco doc in coda), il numero come
|
|
59
|
+
// meno significativa: il rango resta un intero singolo, quindi il comparator
|
|
60
|
+
// della chain non cambia forma. Il gap 1e6 è oltre qualunque counter reale.
|
|
61
|
+
// Un id fuori forma non deve far esplodere il comparator → coda con MAX_SAFE.
|
|
62
|
+
const ID_PREFIX_RANK = { T: 0, D: 1 };
|
|
58
63
|
export function idNum(id) {
|
|
59
|
-
const m = /^
|
|
60
|
-
return m ? Number(m[
|
|
64
|
+
const m = /^([TD])(\d+)$/.exec(id);
|
|
65
|
+
return m ? ID_PREFIX_RANK[m[1]] * 1_000_000 + Number(m[2]) : Number.MAX_SAFE_INTEGER;
|
|
61
66
|
}
|
|
62
67
|
function rankOf(task, key) {
|
|
63
68
|
if (key === 'pri')
|
package/dist/viewport.js
CHANGED
|
@@ -202,6 +202,24 @@ const READER_CHROME = 8;
|
|
|
202
202
|
export function readerCapacity(rows) {
|
|
203
203
|
return Math.max(0, (rows || 24) - SLACK - READER_CHROME);
|
|
204
204
|
}
|
|
205
|
+
// T52 — cornice del pannello di anteprima sotto la lista occorrenze:
|
|
206
|
+
// marginTop + 2 bordi + riga meta.
|
|
207
|
+
const SEARCH_PREVIEW_CHROME = 4;
|
|
208
|
+
/**
|
|
209
|
+
* Righe di contesto concesse all'anteprima dell'occorrenza selezionata.
|
|
210
|
+
*
|
|
211
|
+
* Prende SOLO ciò che avanza: la lista si dimensiona sul contenuto e, quando i
|
|
212
|
+
* risultati sono pochi, il resto del terminale resterebbe vuoto. Con molti
|
|
213
|
+
* risultati lo spazio torna alla lista e il pannello sparisce da sé — la
|
|
214
|
+
* priorità è vedere quante più occorrenze possibile, il contesto è il premio
|
|
215
|
+
* per una ricerca già stretta.
|
|
216
|
+
*
|
|
217
|
+
* Restituisce 0 quando non c'è spazio nemmeno per una riga utile: un pannello
|
|
218
|
+
* di sola cornice ruberebbe 4 righe per non mostrare nulla.
|
|
219
|
+
*/
|
|
220
|
+
export function searchPreviewCapacity(capacity, listRows) {
|
|
221
|
+
return Math.max(0, capacity - listRows - SEARCH_PREVIEW_CHROME);
|
|
222
|
+
}
|
|
205
223
|
/**
|
|
206
224
|
* Il terminale non ospita nemmeno la cornice della schermata: va sostituita da
|
|
207
225
|
* una riga singola, come fa `Budget.compact` per il deck.
|
package/package.json
CHANGED