@lamemind/loom-deck 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -0
- package/dist/cli.js +446 -38
- package/dist/search.js +250 -0
- package/dist/session-list.js +80 -0
- package/dist/sessions.js +110 -1
- package/dist/task-index.js +16 -2
- package/dist/viewport.js +97 -0
- package/package.json +1 -1
package/dist/search.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// T52 — Core della ricerca full-text nelle conversazioni del progetto.
|
|
2
|
+
//
|
|
3
|
+
// Modulo PURO, gemello di `session-list.ts`: nessun import da ink/react, nessun
|
|
4
|
+
// I/O, nessuno stato globale. Prende in ingresso le `Session` già parsate
|
|
5
|
+
// dall'adapter (con i `bodies` trattenuti dalla cache mtime-keyed) e restituisce
|
|
6
|
+
// le righe da renderizzare. Tutto ciò che è testabile senza terminale sta qui.
|
|
7
|
+
//
|
|
8
|
+
// Invarianti (decisioni congelate al preflight):
|
|
9
|
+
// - D3: perimetro = SOLO le sessioni del progetto corrente. Il modulo cerca su
|
|
10
|
+
// ciò che gli viene passato, e chi lo chiama passa `discoverProjectSessions`.
|
|
11
|
+
// - D5: ricerca IN MEMORIA sui corpi, nessun prefiltro esterno. Un solo motore
|
|
12
|
+
// di regex (`RegExp` JS) sia in modalità literal sia in `^R` → non esiste il
|
|
13
|
+
// caso "il prefiltro scarta un file che contiene un'occorrenza reale".
|
|
14
|
+
// - D6: tre kind (ai · tool · human). Il `thinking` non è persistito.
|
|
15
|
+
// - D7: la riga-occorrenza porta l'indice del record; la data sta una volta
|
|
16
|
+
// sola sulla riga-sessione.
|
|
17
|
+
// - Trap T39/T50: la selezione è keyed su una CHIAVE DISCRIMINATA, mai su
|
|
18
|
+
// indice posizionale — la lista si riordina a ogni carattere digitato.
|
|
19
|
+
/** Sotto questa soglia non si cerca: 1-2 char matchano ovunque e la lista
|
|
20
|
+
* sarebbe rumore. Stato lecito, non un errore. */
|
|
21
|
+
export const MIN_QUERY = 3;
|
|
22
|
+
/** Larghezza nominale dell'estratto attorno al match. */
|
|
23
|
+
export const EXCERPT_WIDTH = 50;
|
|
24
|
+
/** Cap per singola conversazione. Serve a tenere leggibile un gruppo, non a
|
|
25
|
+
* proteggere la memoria: una query corta può matchare centinaia di volte nello
|
|
26
|
+
* stesso transcript e sommergere tutte le altre conversazioni. */
|
|
27
|
+
export const MAX_HITS_PER_SESSION = 20;
|
|
28
|
+
/** Cap globale. Oltre, la lista non è più navigabile a mano: la risposta giusta
|
|
29
|
+
* è restringere la query, e il contatore delle nascoste lo dice. */
|
|
30
|
+
export const MAX_TOTAL_HITS = 400;
|
|
31
|
+
export const DEFAULT_OPTIONS = {
|
|
32
|
+
regex: false,
|
|
33
|
+
caseSensitive: false,
|
|
34
|
+
wholeWord: false,
|
|
35
|
+
// D4/§Filtro: il testo IA è il caso d'uso che ha generato la task («l'IA me
|
|
36
|
+
// l'ha detto 150k fa»); tools e human si accendono a mano.
|
|
37
|
+
kinds: { ai: true, tool: false, human: false },
|
|
38
|
+
};
|
|
39
|
+
const EMPTY = {
|
|
40
|
+
groups: [],
|
|
41
|
+
shown: 0,
|
|
42
|
+
hidden: 0,
|
|
43
|
+
sessionCount: 0,
|
|
44
|
+
error: '',
|
|
45
|
+
idle: true,
|
|
46
|
+
};
|
|
47
|
+
function escapeLiteral(s) {
|
|
48
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Compila la query nel matcher.
|
|
52
|
+
*
|
|
53
|
+
* Ritorna `{re: null, error}` invece di lanciare: con la ricerca eager la regex
|
|
54
|
+
* viene ricompilata a ogni carattere, e uno stato intermedio invalido (`[a-`,
|
|
55
|
+
* `(`) è la NORMA durante la digitazione, non un caso limite. Deve degradare a
|
|
56
|
+
* lista vuota con segnalazione inline, mai a un crash della TUI.
|
|
57
|
+
*/
|
|
58
|
+
export function buildMatcher(query, opts) {
|
|
59
|
+
if (query.length < MIN_QUERY)
|
|
60
|
+
return { re: null, error: '' };
|
|
61
|
+
let source = opts.regex ? query : escapeLiteral(query);
|
|
62
|
+
// Il gruppo non-catturante è obbligatorio: senza, `\b` si legherebbe al solo
|
|
63
|
+
// primo/ultimo ramo di un'alternativa (`\bfoo|bar\b` ≠ `\b(?:foo|bar)\b`).
|
|
64
|
+
if (opts.wholeWord)
|
|
65
|
+
source = `\\b(?:${source})\\b`;
|
|
66
|
+
const flags = opts.caseSensitive ? 'g' : 'gi';
|
|
67
|
+
try {
|
|
68
|
+
return { re: new RegExp(source, flags), error: '' };
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
return { re: null, error: e.message };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Estratto centrato sul match.
|
|
76
|
+
*
|
|
77
|
+
* Il testo è RAW (newline incluse) e gli offset sono suoi: si affetta prima e si
|
|
78
|
+
* collassa il whitespace dopo, su ciascun pezzo separatamente. Collassare prima
|
|
79
|
+
* sposterebbe gli offset; collassare l'intera finestra insieme fonderebbe i
|
|
80
|
+
* confini del match con il contesto.
|
|
81
|
+
*
|
|
82
|
+
* Degrada ai bordi: match a inizio o fine messaggio prende tutto il contesto
|
|
83
|
+
* disponibile dal lato che ce l'ha, invece di lasciare metà estratto vuoto.
|
|
84
|
+
*/
|
|
85
|
+
export function excerptAround(text, start, end, width = EXCERPT_WIDTH) {
|
|
86
|
+
const collapse = (s) => s.replace(/\s+/g, ' ');
|
|
87
|
+
// Un match più lungo dell'estratto: si tronca il match stesso, altrimenti la
|
|
88
|
+
// riga andrebbe a capo e sfonderebbe il budget d'altezza della lista.
|
|
89
|
+
if (end - start >= width) {
|
|
90
|
+
return collapse(text.slice(start, start + width - 1)).trimEnd() + '…';
|
|
91
|
+
}
|
|
92
|
+
const budget = width - (end - start);
|
|
93
|
+
// Metà per lato, poi il lato che non riesce a spendere la sua quota (match a
|
|
94
|
+
// ridosso di un bordo) la cede all'altro.
|
|
95
|
+
let before = Math.floor(budget / 2);
|
|
96
|
+
let after = budget - before;
|
|
97
|
+
const availBefore = start;
|
|
98
|
+
const availAfter = text.length - end;
|
|
99
|
+
if (before > availBefore) {
|
|
100
|
+
after += before - availBefore;
|
|
101
|
+
before = availBefore;
|
|
102
|
+
}
|
|
103
|
+
if (after > availAfter) {
|
|
104
|
+
before = Math.min(availBefore, before + (after - availAfter));
|
|
105
|
+
after = availAfter;
|
|
106
|
+
}
|
|
107
|
+
const from = start - before;
|
|
108
|
+
const to = end + after;
|
|
109
|
+
const head = collapse(text.slice(from, start));
|
|
110
|
+
const mid = collapse(text.slice(start, end));
|
|
111
|
+
const tail = collapse(text.slice(end, to));
|
|
112
|
+
return (from > 0 ? '…' : '') + head + mid + tail + (to < text.length ? '…' : '');
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Scansiona un corpo: materializza al più `need` occorrenze, ma le CONTA tutte.
|
|
116
|
+
*
|
|
117
|
+
* I due numeri sono separati apposta. Fermare anche il conteggio al cap
|
|
118
|
+
* renderebbe silenziosa proprio la troncatura che il contatore deve dichiarare
|
|
119
|
+
* («+N occorrenze»), e il conteggio è la parte economica — costruire un Hit
|
|
120
|
+
* significa costruire un estratto, scorrere la stringa no.
|
|
121
|
+
*
|
|
122
|
+
* `re` deve avere il flag `g` (lo garantisce buildMatcher).
|
|
123
|
+
*/
|
|
124
|
+
function scanBody(sessionId, body, re, need) {
|
|
125
|
+
const hits = [];
|
|
126
|
+
let count = 0;
|
|
127
|
+
re.lastIndex = 0;
|
|
128
|
+
let m;
|
|
129
|
+
while ((m = re.exec(body.text)) !== null) {
|
|
130
|
+
count++;
|
|
131
|
+
if (hits.length < need) {
|
|
132
|
+
const start = m.index;
|
|
133
|
+
const end = start + m[0].length;
|
|
134
|
+
hits.push({
|
|
135
|
+
sessionId,
|
|
136
|
+
idx: body.idx,
|
|
137
|
+
kind: body.kind,
|
|
138
|
+
excerpt: excerptAround(body.text, start, end),
|
|
139
|
+
matchStart: start,
|
|
140
|
+
matchEnd: end,
|
|
141
|
+
text: body.text,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// Una regex può matchare la stringa vuota (`a*`, `^`): senza avanzare a
|
|
145
|
+
// mano, `exec` resta fermo sullo stesso indice e il ciclo non termina mai.
|
|
146
|
+
if (m[0].length === 0)
|
|
147
|
+
re.lastIndex++;
|
|
148
|
+
}
|
|
149
|
+
return { hits, count };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Cerca su tutte le sessioni passate.
|
|
153
|
+
*
|
|
154
|
+
* `hashPrefix` restringe a una conversazione per PREFISSO del sessionId, non per
|
|
155
|
+
* uguaglianza: gli 8 char che la statusline mostra bastano a individuarla, e
|
|
156
|
+
* nessuno copia un uuid intero a mano.
|
|
157
|
+
*/
|
|
158
|
+
export function searchSessions(sessions, hashPrefix, query, opts) {
|
|
159
|
+
const prefix = hashPrefix.trim().toLowerCase();
|
|
160
|
+
const scoped = prefix
|
|
161
|
+
? sessions.filter((s) => s.sessionId.toLowerCase().startsWith(prefix))
|
|
162
|
+
: sessions;
|
|
163
|
+
const { re, error } = buildMatcher(query, opts);
|
|
164
|
+
if (error)
|
|
165
|
+
return { ...EMPTY, idle: false, error };
|
|
166
|
+
if (!re)
|
|
167
|
+
return EMPTY;
|
|
168
|
+
const groups = [];
|
|
169
|
+
let shown = 0;
|
|
170
|
+
let hidden = 0;
|
|
171
|
+
for (const session of scoped) {
|
|
172
|
+
const hits = [];
|
|
173
|
+
let found = 0;
|
|
174
|
+
for (const body of session.bodies) {
|
|
175
|
+
if (!opts.kinds[body.kind])
|
|
176
|
+
continue;
|
|
177
|
+
// Quanto ancora si può MATERIALIZZARE: il minore fra spazio nel gruppo e
|
|
178
|
+
// spazio nella lista. A zero la scansione prosegue lo stesso — serve il
|
|
179
|
+
// conteggio, che è ciò che alimenta «+N occorrenze».
|
|
180
|
+
const need = Math.max(0, Math.min(MAX_HITS_PER_SESSION - hits.length, MAX_TOTAL_HITS - shown - hits.length));
|
|
181
|
+
const r = scanBody(session.sessionId, body, re, need);
|
|
182
|
+
found += r.count;
|
|
183
|
+
hits.push(...r.hits);
|
|
184
|
+
}
|
|
185
|
+
if (found === 0)
|
|
186
|
+
continue;
|
|
187
|
+
hidden += found - hits.length;
|
|
188
|
+
if (hits.length === 0)
|
|
189
|
+
continue; // tutte tagliate dal cap globale
|
|
190
|
+
groups.push({ session, hits, hidden: found - hits.length });
|
|
191
|
+
shown += hits.length;
|
|
192
|
+
}
|
|
193
|
+
return { groups, shown, hidden, sessionCount: groups.length, error: '', idle: false };
|
|
194
|
+
}
|
|
195
|
+
/** Chiave discriminata di una riga-occorrenza. `matchStart` è nella chiave
|
|
196
|
+
* perché due match nello stesso record sono righe distinte. */
|
|
197
|
+
function hitKey(h) {
|
|
198
|
+
return `hit:${h.sessionId}:${h.idx}:${h.kind}:${h.matchStart}`;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Appiattisce i gruppi in righe.
|
|
202
|
+
*
|
|
203
|
+
* Con `hashPrefix` valorizzato la lista è PIATTA: la conversazione è una sola e
|
|
204
|
+
* già nominata nel campo hash, quindi la riga-sessione ripeterebbe un dato
|
|
205
|
+
* costante rubando una riga per gruppo.
|
|
206
|
+
*/
|
|
207
|
+
export function buildRows(result, flat) {
|
|
208
|
+
const rows = [];
|
|
209
|
+
for (const g of result.groups) {
|
|
210
|
+
if (!flat) {
|
|
211
|
+
rows.push({
|
|
212
|
+
kind: 'session',
|
|
213
|
+
key: `sess:${g.session.sessionId}`,
|
|
214
|
+
session: g.session,
|
|
215
|
+
hitCount: g.hits.length,
|
|
216
|
+
hidden: g.hidden,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
for (const h of g.hits)
|
|
220
|
+
rows.push({ kind: 'hit', key: hitKey(h), hit: h });
|
|
221
|
+
}
|
|
222
|
+
return rows;
|
|
223
|
+
}
|
|
224
|
+
/** Prima riga selezionabile; null = lista vuota. Ogni riga è selezionabile —
|
|
225
|
+
* non esiste un separatore, a differenza di `session-list.ts`. */
|
|
226
|
+
export function firstRowKey(rows) {
|
|
227
|
+
return rows[0]?.key ?? null;
|
|
228
|
+
}
|
|
229
|
+
/** Indice della riga con quella chiave; -1 se assente. */
|
|
230
|
+
export function rowIndexOfKey(rows, key) {
|
|
231
|
+
if (key === null)
|
|
232
|
+
return -1;
|
|
233
|
+
return rows.findIndex((r) => r.key === key);
|
|
234
|
+
}
|
|
235
|
+
/** Sposta la selezione di `delta` righe. Chiave persa (la lista è cambiata sotto)
|
|
236
|
+
* → prima riga; lista vuota → null. */
|
|
237
|
+
export function moveRowSelection(rows, currentKey, delta) {
|
|
238
|
+
if (rows.length === 0)
|
|
239
|
+
return null;
|
|
240
|
+
const cur = rowIndexOfKey(rows, currentKey);
|
|
241
|
+
if (cur < 0)
|
|
242
|
+
return rows[0].key;
|
|
243
|
+
return rows[Math.max(0, Math.min(rows.length - 1, cur + delta))].key;
|
|
244
|
+
}
|
|
245
|
+
/** Riga selezionata, o null. */
|
|
246
|
+
export function selectedRow(rows, key) {
|
|
247
|
+
if (key === null)
|
|
248
|
+
return null;
|
|
249
|
+
return rows.find((r) => r.key === key) ?? null;
|
|
250
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// T50 — Assemblaggio della lista sessioni a DUE gruppi: pinnate (sempre in
|
|
2
|
+
// lista, in cima) + separatore + contestuali (figlie del parent selezionato).
|
|
3
|
+
// Modulo PURO: nessun import da ink/react, nessun I/O → testabile senza terminale
|
|
4
|
+
// (il pacchetto non ha infrastruttura TUI di test, solo unit sui core puri).
|
|
5
|
+
//
|
|
6
|
+
// Invarianti (decisioni congelate al preflight):
|
|
7
|
+
// - D2: dentro il blocco pinnate l'ordine è di PIN stabile (ultima pinnata in
|
|
8
|
+
// cima), dato dal rango del sidecar `session-tasks.jsonl` — non `ts desc`.
|
|
9
|
+
// - Dedup: una sessione sia pinnata sia contestuale compare SOLO fra le pinnate.
|
|
10
|
+
// - Cap: `maxContext` limita SOLO le contestuali; le pinnate sono esenti. Il
|
|
11
|
+
// conteggio delle troncate (`contextHidden`) resta esposto (non-silenzioso).
|
|
12
|
+
// - D3: una pinnata il cui transcript non esiste più è una riga STALE (session
|
|
13
|
+
// null), navigabile e spinnabile — non sparisce in silenzio.
|
|
14
|
+
// - Trap T39: la selezione è KEYED SU sessionId, non su indice posizionale. La
|
|
15
|
+
// lista è una vista trasformata (due gruppi + separatore): un indice grezzo
|
|
16
|
+
// identificherebbe la riga sbagliata dopo un pin/cambio-contesto.
|
|
17
|
+
export function assembleSessionList(childSessions, allSessions, pinned, maxContext) {
|
|
18
|
+
// Le pinnate si risolvono contro TUTTE le sessioni del progetto, non solo le
|
|
19
|
+
// figlie del parent: una pinnata può appartenere a un'altra task. Assente
|
|
20
|
+
// dallo store → stale.
|
|
21
|
+
const byId = new Map(allSessions.map((s) => [s.sessionId, s]));
|
|
22
|
+
const pinnedIds = [...pinned.entries()]
|
|
23
|
+
.sort((a, b) => b[1] - a[1]) // rango desc → ultima pinnata in cima (D2)
|
|
24
|
+
.map(([id]) => id);
|
|
25
|
+
const pinnedRows = pinnedIds.map((id) => {
|
|
26
|
+
const session = byId.get(id) ?? null;
|
|
27
|
+
return { kind: 'pinned', sessionId: id, session, stale: session === null };
|
|
28
|
+
});
|
|
29
|
+
const pinnedSet = new Set(pinnedIds);
|
|
30
|
+
const dedupedContext = childSessions.filter((s) => !pinnedSet.has(s.sessionId));
|
|
31
|
+
const shownContext = dedupedContext.slice(0, Math.max(0, maxContext));
|
|
32
|
+
const contextRows = shownContext.map((s) => ({
|
|
33
|
+
kind: 'context',
|
|
34
|
+
sessionId: s.sessionId,
|
|
35
|
+
session: s,
|
|
36
|
+
}));
|
|
37
|
+
const rows = [...pinnedRows];
|
|
38
|
+
// Separatore SOLO fra due gruppi entrambi non vuoti: senza pinnate o senza
|
|
39
|
+
// contestuali non c'è confine da segnare.
|
|
40
|
+
if (pinnedRows.length > 0 && contextRows.length > 0)
|
|
41
|
+
rows.push({ kind: 'separator' });
|
|
42
|
+
rows.push(...contextRows);
|
|
43
|
+
return {
|
|
44
|
+
rows,
|
|
45
|
+
pinnedCount: pinnedRows.length,
|
|
46
|
+
contextTotal: dedupedContext.length,
|
|
47
|
+
contextHidden: dedupedContext.length - shownContext.length,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function isSelectable(r) {
|
|
51
|
+
return r.kind !== 'separator';
|
|
52
|
+
}
|
|
53
|
+
/** Primo id selezionabile (salta il separatore); null = lista senza righe. */
|
|
54
|
+
export function firstSelectableId(rows) {
|
|
55
|
+
return rows.find(isSelectable)?.sessionId ?? null;
|
|
56
|
+
}
|
|
57
|
+
/** Indice della riga con quel sessionId nell'ARRAY COMPLETO (per il windowing);
|
|
58
|
+
* -1 se assente o id null. Il separatore non ha id → mai matchato. */
|
|
59
|
+
export function rowIndexOf(rows, sessionId) {
|
|
60
|
+
if (sessionId === null)
|
|
61
|
+
return -1;
|
|
62
|
+
return rows.findIndex((r) => isSelectable(r) && r.sessionId === sessionId);
|
|
63
|
+
}
|
|
64
|
+
/** Sposta la selezione di `delta` fra le sole righe selezionabili (attraversa il
|
|
65
|
+
* separatore senza fermarcisi). Id perso → prima riga; lista vuota → null. */
|
|
66
|
+
export function moveSelection(rows, currentId, delta) {
|
|
67
|
+
const selectable = rows.filter(isSelectable);
|
|
68
|
+
if (selectable.length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
const cur = selectable.findIndex((r) => r.sessionId === currentId);
|
|
71
|
+
if (cur < 0)
|
|
72
|
+
return selectable[0].sessionId;
|
|
73
|
+
const next = Math.max(0, Math.min(selectable.length - 1, cur + delta));
|
|
74
|
+
return selectable[next].sessionId;
|
|
75
|
+
}
|
|
76
|
+
/** Session della riga selezionata; null se stale o nessuna selezione. */
|
|
77
|
+
export function selectedSession(rows, sessionId) {
|
|
78
|
+
const r = rows.find((row) => isSelectable(row) && row.sessionId === sessionId);
|
|
79
|
+
return r && isSelectable(r) ? r.session : null;
|
|
80
|
+
}
|
package/dist/sessions.js
CHANGED
|
@@ -45,6 +45,91 @@ function extractText(message) {
|
|
|
45
45
|
}
|
|
46
46
|
return '';
|
|
47
47
|
}
|
|
48
|
+
// Un record `type:user` che porta SOLO questo testo è un evento di UI (l'utente
|
|
49
|
+
// ha premuto esc), non un messaggio scritto. Stesso `type` di un prompt vero:
|
|
50
|
+
// senza escluderlo comparirebbe come «primo prompt» della conversazione e
|
|
51
|
+
// gonfierebbe la ricerca sui messaggi umani. Prefisso e non uguaglianza —
|
|
52
|
+
// esistono varianti («…by user for tool use»).
|
|
53
|
+
const INTERRUPT_MARKER = '[Request interrupted by user';
|
|
54
|
+
function isInterrupt(text) {
|
|
55
|
+
return text.trimStart().startsWith(INTERRUPT_MARKER);
|
|
56
|
+
}
|
|
57
|
+
// T52 — estrae i corpi cercabili di UN record, uno per `kind` presente.
|
|
58
|
+
//
|
|
59
|
+
// Blocchi dello stesso kind nello stesso record vengono CONCATENATI, non emessi
|
|
60
|
+
// separatamente: il reader promette «il messaggio intero», e la coppia
|
|
61
|
+
// (idx, kind) resta così una chiave univoca per la riga-occorrenza.
|
|
62
|
+
//
|
|
63
|
+
// Mappatura dei blocchi, verificata sullo store:
|
|
64
|
+
// - assistant `text` → ai
|
|
65
|
+
// - assistant `tool_use` → tool (nome + input serializzato: il comando
|
|
66
|
+
// eseguito è ciò che si cerca, non il wrapper)
|
|
67
|
+
// - assistant `thinking` → SCARTATO: il campo `thinking` è SEMPRE ''
|
|
68
|
+
// (persistita solo la `signature`, la firma
|
|
69
|
+
// crittografica). Indicizzarlo produrrebbe una
|
|
70
|
+
// categoria che non può mai dare un risultato.
|
|
71
|
+
// - user stringa nuda / `text` → human
|
|
72
|
+
// - user `tool_result` → tool (viaggia come type:user, ma non è
|
|
73
|
+
// scritto da un umano)
|
|
74
|
+
//
|
|
75
|
+
// `isAssistant` arriva dal `type` del RECORD, non da `message.role`: è il campo
|
|
76
|
+
// su cui il resto del parser già dispatcha (i due combaciano sullo store attuale
|
|
77
|
+
// — verificato — ma dipendere da uno solo evita che divergano in silenzio).
|
|
78
|
+
function collectBodies(message, idx, isAssistant, out) {
|
|
79
|
+
if (!message || typeof message !== 'object')
|
|
80
|
+
return;
|
|
81
|
+
const content = message.content;
|
|
82
|
+
const parts = { ai: [], tool: [], human: [] };
|
|
83
|
+
if (typeof content === 'string') {
|
|
84
|
+
if (content)
|
|
85
|
+
parts[isAssistant ? 'ai' : 'human'].push(content);
|
|
86
|
+
}
|
|
87
|
+
else if (Array.isArray(content)) {
|
|
88
|
+
for (const block of content) {
|
|
89
|
+
if (!block || typeof block !== 'object')
|
|
90
|
+
continue;
|
|
91
|
+
const b = block;
|
|
92
|
+
if (b.type === 'text' && typeof b.text === 'string' && b.text) {
|
|
93
|
+
parts[isAssistant ? 'ai' : 'human'].push(b.text);
|
|
94
|
+
}
|
|
95
|
+
else if (b.type === 'tool_use') {
|
|
96
|
+
const name = typeof b.name === 'string' ? b.name : 'tool';
|
|
97
|
+
let input = '';
|
|
98
|
+
try {
|
|
99
|
+
input = JSON.stringify(b.input ?? {});
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
input = ''; // input ciclico/non serializzabile → resta il solo nome
|
|
103
|
+
}
|
|
104
|
+
parts.tool.push(`${name} ${input}`);
|
|
105
|
+
}
|
|
106
|
+
else if (b.type === 'tool_result') {
|
|
107
|
+
const r = b.content;
|
|
108
|
+
if (typeof r === 'string') {
|
|
109
|
+
if (r)
|
|
110
|
+
parts.tool.push(r);
|
|
111
|
+
}
|
|
112
|
+
else if (Array.isArray(r)) {
|
|
113
|
+
for (const rb of r) {
|
|
114
|
+
if (rb && typeof rb === 'object' && rb.type === 'text') {
|
|
115
|
+
const t = rb.text;
|
|
116
|
+
if (typeof t === 'string' && t)
|
|
117
|
+
parts.tool.push(t);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const kind of ['ai', 'tool', 'human']) {
|
|
125
|
+
const text = parts[kind].join('\n');
|
|
126
|
+
if (!text)
|
|
127
|
+
continue;
|
|
128
|
+
if (kind === 'human' && isInterrupt(text))
|
|
129
|
+
continue;
|
|
130
|
+
out.push({ idx, kind, text });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
48
133
|
// Cache mtime-keyed: re-parse solo i file cambiati. Steady-state di una TUI che
|
|
49
134
|
// poll-a resta economico anche con decine di sessioni multi-MB. Lo stato vive
|
|
50
135
|
// dentro l'adapter così l'invariante "unico modulo che tocca lo store" regge.
|
|
@@ -57,6 +142,18 @@ function parseSessionFile(path, mtime, sizeBytes) {
|
|
|
57
142
|
catch {
|
|
58
143
|
return null;
|
|
59
144
|
}
|
|
145
|
+
return parseTranscript(content, path, mtime, sizeBytes);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Semantica del transcript, separata dalla lettura del file.
|
|
149
|
+
*
|
|
150
|
+
* L'I/O resta in `parseSessionFile` (l'invariante «unico modulo che tocca lo
|
|
151
|
+
* store» vale per QUESTO file, non per la funzione): qui c'è solo la parte non
|
|
152
|
+
* ovvia — cosa conta come turno, cosa è un corpo cercabile, quali record sono
|
|
153
|
+
* eventi di UI travestiti da messaggi. È anche l'unica parte che vale la pena
|
|
154
|
+
* testare, e su una stringa si testa senza inventare un finto `~/.claude`.
|
|
155
|
+
*/
|
|
156
|
+
export function parseTranscript(content, path, mtime, sizeBytes) {
|
|
60
157
|
let sessionId = basename(path).replace(/\.jsonl$/, '');
|
|
61
158
|
let cwd = '';
|
|
62
159
|
let gitBranch = '';
|
|
@@ -65,6 +162,8 @@ function parseSessionFile(path, mtime, sizeBytes) {
|
|
|
65
162
|
let firstUserText = '';
|
|
66
163
|
let lastAssistantText = '';
|
|
67
164
|
let turns = 0;
|
|
165
|
+
const bodies = [];
|
|
166
|
+
let recordIdx = -1;
|
|
68
167
|
for (const line of content.split('\n')) {
|
|
69
168
|
if (!line.trim())
|
|
70
169
|
continue;
|
|
@@ -75,6 +174,12 @@ function parseSessionFile(path, mtime, sizeBytes) {
|
|
|
75
174
|
catch {
|
|
76
175
|
continue;
|
|
77
176
|
}
|
|
177
|
+
// Indice progressivo sui soli record VALIDI: è ciò che prefissa la
|
|
178
|
+
// riga-occorrenza, quindi deve contare come li conta chi legge il file.
|
|
179
|
+
recordIdx++;
|
|
180
|
+
if (d.type === 'user' || d.type === 'assistant') {
|
|
181
|
+
collectBodies(d.message, recordIdx, d.type === 'assistant', bodies);
|
|
182
|
+
}
|
|
78
183
|
if (typeof d.sessionId === 'string' && d.sessionId)
|
|
79
184
|
sessionId = d.sessionId;
|
|
80
185
|
if (!cwd && typeof d.cwd === 'string' && d.cwd)
|
|
@@ -89,8 +194,11 @@ function parseSessionFile(path, mtime, sizeBytes) {
|
|
|
89
194
|
if (d.type === 'user') {
|
|
90
195
|
// T49: turno = prompt umano. I tool_result viaggiano anch'essi come
|
|
91
196
|
// type:user ma senza blocchi text → extractText '' li esclude.
|
|
197
|
+
// T52: e nemmeno l'interruzione da esc è un prompt — stesso `type`, ma è
|
|
198
|
+
// un evento di UI. Senza il filtro gonfia i turni e può diventare il
|
|
199
|
+
// «primo prompt» mostrato nel detail pane.
|
|
92
200
|
const t = extractText(d.message);
|
|
93
|
-
if (t) {
|
|
201
|
+
if (t && !isInterrupt(t)) {
|
|
94
202
|
turns++;
|
|
95
203
|
if (!firstUserText)
|
|
96
204
|
firstUserText = t;
|
|
@@ -120,6 +228,7 @@ function parseSessionFile(path, mtime, sizeBytes) {
|
|
|
120
228
|
customTitle,
|
|
121
229
|
firstPrompt: firstUserText,
|
|
122
230
|
lastReply: lastAssistantText,
|
|
231
|
+
bodies,
|
|
123
232
|
};
|
|
124
233
|
}
|
|
125
234
|
// Discovery read-only delle sessioni del SOLO progetto corrente (D2 preflight
|
package/dist/task-index.js
CHANGED
|
@@ -11,6 +11,10 @@ export function appendSessionRecord(projectRoot, rec) {
|
|
|
11
11
|
export function appendTaskBinding(projectRoot, sessionId, taskId) {
|
|
12
12
|
appendSessionRecord(projectRoot, { sessionId, taskId });
|
|
13
13
|
}
|
|
14
|
+
/** T50 — pin/unpin (toggle): append immediato, il reader risolve last-wins. */
|
|
15
|
+
export function appendPin(projectRoot, sessionId, pinned) {
|
|
16
|
+
appendSessionRecord(projectRoot, { sessionId, pinned });
|
|
17
|
+
}
|
|
14
18
|
// Una sola lettura del JSONL per entrambe le mappe: il deck poll-a l'indice a
|
|
15
19
|
// ogni tick, leggere il file due volte raddoppierebbe l'I/O per nulla.
|
|
16
20
|
// Last-wins per campo (un re-pin dello stesso sessionId sovrascrive), e i due
|
|
@@ -19,13 +23,15 @@ export function appendTaskBinding(projectRoot, sessionId, taskId) {
|
|
|
19
23
|
export function loadSessionIndex(projectRoot) {
|
|
20
24
|
const bindings = new Map();
|
|
21
25
|
const forkOf = new Map();
|
|
26
|
+
const pinned = new Map();
|
|
22
27
|
let content;
|
|
23
28
|
try {
|
|
24
29
|
content = readFileSync(taskIndexPath(projectRoot), 'utf8');
|
|
25
30
|
}
|
|
26
31
|
catch {
|
|
27
|
-
return { bindings, forkOf };
|
|
32
|
+
return { bindings, forkOf, pinned };
|
|
28
33
|
}
|
|
34
|
+
let order = 0; // posizione crescente dei record pinned → rango di pin (D2)
|
|
29
35
|
for (const line of content.split('\n')) {
|
|
30
36
|
if (!line.trim())
|
|
31
37
|
continue;
|
|
@@ -37,10 +43,18 @@ export function loadSessionIndex(projectRoot) {
|
|
|
37
43
|
bindings.set(d.sessionId, d.taskId);
|
|
38
44
|
if (typeof d.forkOf === 'string')
|
|
39
45
|
forkOf.set(d.sessionId, d.forkOf);
|
|
46
|
+
// last-wins per campo: un `pinned:false` finale rimuove il pin, un
|
|
47
|
+
// `pinned:true` (ri)assegna il rango con la posizione corrente nel file.
|
|
48
|
+
if (typeof d.pinned === 'boolean') {
|
|
49
|
+
if (d.pinned)
|
|
50
|
+
pinned.set(d.sessionId, order++);
|
|
51
|
+
else
|
|
52
|
+
pinned.delete(d.sessionId);
|
|
53
|
+
}
|
|
40
54
|
}
|
|
41
55
|
catch {
|
|
42
56
|
// riga corrotta → skip
|
|
43
57
|
}
|
|
44
58
|
}
|
|
45
|
-
return { bindings, forkOf };
|
|
59
|
+
return { bindings, forkOf, pinned };
|
|
46
60
|
}
|
package/dist/viewport.js
CHANGED
|
@@ -78,6 +78,13 @@ export const MODAL_HEIGHT = {
|
|
|
78
78
|
sort: 5, // marginTop + 2 bordi + titolo + 1 riga catena
|
|
79
79
|
filter: 6, // marginTop + 2 bordi + titolo + 2 righe (pri, stato)
|
|
80
80
|
edit: 8, // marginTop + 2 bordi + titolo + 3 campi + riga anteprima
|
|
81
|
+
// T52 — search e reader sono gli unici modali NON in flusso: sostituiscono i
|
|
82
|
+
// due pane invece di spingerli giù (una lista di occorrenze non entra in un
|
|
83
|
+
// box sopra il deck). Costo 0 nel budget dei pane perché quel budget non
|
|
84
|
+
// viene nemmeno calcolato: il render esce prima. La loro altezza la
|
|
85
|
+
// distribuiscono `searchListCapacity`/`readerCapacity`.
|
|
86
|
+
search: 0,
|
|
87
|
+
reader: 0,
|
|
81
88
|
};
|
|
82
89
|
/**
|
|
83
90
|
* Distribuisce le righe disponibili fra lista task, lista sessioni e dettaglio.
|
|
@@ -164,6 +171,96 @@ export function layoutBudget(input) {
|
|
|
164
171
|
compact: false,
|
|
165
172
|
};
|
|
166
173
|
}
|
|
174
|
+
// T52 — cornice fissa della schermata di ricerca. Stesso vincolo del deck
|
|
175
|
+
// normale (il frame non deve superare `rows`), contabilità separata perché la
|
|
176
|
+
// schermata è sostitutiva, non additiva.
|
|
177
|
+
//
|
|
178
|
+
// 2 bordi del box esterno
|
|
179
|
+
// 1 titolo "loom-deck"
|
|
180
|
+
// 1 riga hint/legenda tasti
|
|
181
|
+
// 1 marginTop del box query
|
|
182
|
+
// 2 bordi del box query
|
|
183
|
+
// 3 righe del box query: hash · chiave · toggle
|
|
184
|
+
// 1 marginTop del box lista
|
|
185
|
+
// 2 bordi del box lista
|
|
186
|
+
// 1 header della lista (conteggi)
|
|
187
|
+
const SEARCH_CHROME = 14;
|
|
188
|
+
/** Righe di lista occorrenze che entrano nel terminale. Zero = la cornice non
|
|
189
|
+
* ci sta nemmeno vuota → la schermata va sostituita (vedi `isCompact`). */
|
|
190
|
+
export function searchListCapacity(rows, noteLine) {
|
|
191
|
+
return Math.max(0, (rows || 24) - SLACK - SEARCH_CHROME - (noteLine ? 1 : 0));
|
|
192
|
+
}
|
|
193
|
+
// T52 — cornice del reader fullscreen.
|
|
194
|
+
// 2 bordi del box esterno
|
|
195
|
+
// 1 titolo "loom-deck"
|
|
196
|
+
// 1 riga meta (sessione · record · tipo · posizione)
|
|
197
|
+
// 1 riga hint
|
|
198
|
+
// 1 marginTop del box corpo
|
|
199
|
+
// 2 bordi del box corpo
|
|
200
|
+
const READER_CHROME = 8;
|
|
201
|
+
/** Righe di testo del messaggio che entrano nel terminale. */
|
|
202
|
+
export function readerCapacity(rows) {
|
|
203
|
+
return Math.max(0, (rows || 24) - SLACK - READER_CHROME);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Il terminale non ospita nemmeno la cornice della schermata: va sostituita da
|
|
207
|
+
* una riga singola, come fa `Budget.compact` per il deck.
|
|
208
|
+
*
|
|
209
|
+
* Non è una comodità estetica ed è lo stesso ragionamento del layout a box: un
|
|
210
|
+
* frame più alto di `rows` fa cadere Ink nel ramo `clearTerminal`, che su
|
|
211
|
+
* VTE/Ptyxis riversa ogni redraw nello scrollback. Un terminale basso è proprio
|
|
212
|
+
* la condizione che innesca quel ramo, quindi qui il fallback È il fix.
|
|
213
|
+
*/
|
|
214
|
+
export function isCompact(capacity) {
|
|
215
|
+
return capacity < 1;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* A-capo che PRESERVA la struttura del testo e traccia gli offset.
|
|
219
|
+
*
|
|
220
|
+
* Distinto da `wrapLines`, che collassa tutto in un flusso unico: lì serviva una
|
|
221
|
+
* preview compatta di 2-4 righe, qui si legge un messaggio intero — appiattire
|
|
222
|
+
* le newline renderebbe illeggibile qualunque blocco di codice o elenco.
|
|
223
|
+
*
|
|
224
|
+
* Ogni riga prodotta è una FETTA CONTIGUA del sorgente: è ciò che permette di
|
|
225
|
+
* intersecarla con l'intervallo del match e colorare solo la parte giusta,
|
|
226
|
+
* anche quando il match cade a cavallo di due righe.
|
|
227
|
+
*
|
|
228
|
+
* Taglia sull'ultimo spazio disponibile; una parola più larga della riga viene
|
|
229
|
+
* spezzata a forza, altrimenti l'a-capo lo farebbe il terminale — fuori dal
|
|
230
|
+
* conteggio delle righe, cioè di nuovo un frame più alto di `rows`.
|
|
231
|
+
*/
|
|
232
|
+
export function wrapWithOffsets(text, width) {
|
|
233
|
+
const out = [];
|
|
234
|
+
if (width <= 0)
|
|
235
|
+
return out;
|
|
236
|
+
let base = 0;
|
|
237
|
+
for (const raw of text.split('\n')) {
|
|
238
|
+
// Tab e CR diventano UN singolo spazio, non quattro: la sostituzione deve
|
|
239
|
+
// conservare la lunghezza, o gli offset delle righe smettono di indicizzare
|
|
240
|
+
// il sorgente e il match verrebbe evidenziato spostato. Un tab lasciato
|
|
241
|
+
// passare sarebbe l'errore opposto — lo conteremmo 1 e il terminale 8.
|
|
242
|
+
const line = raw.replace(/[\t\r]/g, ' ');
|
|
243
|
+
if (line.length === 0) {
|
|
244
|
+
out.push({ text: '', start: base, end: base });
|
|
245
|
+
}
|
|
246
|
+
let pos = 0;
|
|
247
|
+
while (pos < line.length) {
|
|
248
|
+
if (line.length - pos <= width) {
|
|
249
|
+
out.push({ text: line.slice(pos), start: base + pos, end: base + line.length });
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
let brk = line.lastIndexOf(' ', pos + width);
|
|
253
|
+
if (brk <= pos)
|
|
254
|
+
brk = pos + width; // parola più lunga della riga
|
|
255
|
+
out.push({ text: line.slice(pos, brk), start: base + pos, end: base + brk });
|
|
256
|
+
pos = brk;
|
|
257
|
+
while (line[pos] === ' ')
|
|
258
|
+
pos++; // lo spazio del taglio non apre la riga dopo
|
|
259
|
+
}
|
|
260
|
+
base += raw.length + 1; // +1 = il '\n' consumato dallo split
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
167
264
|
/**
|
|
168
265
|
* Finestra scorrevole su una lista più lunga della capienza.
|
|
169
266
|
*
|
package/package.json
CHANGED