@lamemind/loom-deck 0.33.0 → 0.34.1

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.
@@ -0,0 +1,220 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // I due pane della vista principale — task e sessioni — con i rispettivi
3
+ // header. Sono presentazionali puri: ricevono la vista già selezionata e le
4
+ // larghezze già calcolate, non li derivano.
5
+ import { Box, Text } from 'ink';
6
+ import { cut, cutParts, pad, sanitize, termWidth } from '../width.js';
7
+ import { isDone, paneTextWidth } from '../layout.js';
8
+ import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, SESSION_SEP, SID_CHARS, TASK_EMPTY, WARN, displayProg, relTime, } from '../glyphs.js';
9
+ import { META_ROWS, ROW_ALL, ROW_SPOT } from '../model.js';
10
+ import { rowLabel, sessionTitle } from '../session-list.js';
11
+ import { sessionView, taskView, SESSION_VIEWS, TASK_VIEWS, } from '../pane-views.js';
12
+ import { describeSort, PRI_ENTRIES, PROG_ENTRIES } from '../view.js';
13
+ /**
14
+ * Header del pane task, tagliato QUI e non da Ink — stesso motivo del gemello
15
+ * `SessionsHeader`, con una differenza di rischio: qui i segmenti sono tutti
16
+ * ASCII più le frecce `↑↓` (larghe 1), quindi `cli-truncate` oggi darebbe la
17
+ * riga giusta per caso. Il taglio resta del deck perché la correttezza non deve
18
+ * dipendere dall'alfabeto che capita nella riga: il primo glifo largo 2 che
19
+ * entrasse in un segmento nuovo riaprirebbe il difetto in silenzio, e a
20
+ * scoprirlo sarebbe il bordo del pane a schermo.
21
+ *
22
+ * `truncate-end` taglia dalla coda, e `cutParts` conserva l'ordine: l'ultimo
23
+ * segmento resta il primo a cedere il posto (`↑↓` in coda alle voci navigabili).
24
+ *
25
+ * T100 — la riga non è più informativa: le voci del catalogo sono SELEZIONABILI
26
+ * con `tab`, e l'attiva si distingue in video inverso (D5 — costa 0 colonne e non
27
+ * entra in gara con la semantica di colore già occupata). Le voci ci sono tutte
28
+ * anche a 0 (D1): un catalogo che si accorcia sposta le voci sotto le dita.
29
+ * L'ordine è vincolato — le navigabili PRIMA di `↑N`/`↓N`, che cadono per primi
30
+ * su un terminale stretto — e la voce attiva ha la precedenza sul budget (D6).
31
+ */
32
+ export function TasksHeader({ counts, active, above, below, focused, columns, }) {
33
+ const views = TASK_VIEWS.map((v, i) => {
34
+ const n = v.count(counts);
35
+ return {
36
+ // Il separatore sta nel segmento, non fra i segmenti: `cutParts` misura la
37
+ // riga pezzo per pezzo e uno spazio fuori dai pezzi non verrebbe contato.
38
+ text: `${i > 0 ? ' · ' : ''}${v.label(counts)}`,
39
+ color: v.color,
40
+ dim: v.dim || n === 0,
41
+ active: v.id === active,
42
+ };
43
+ });
44
+ const segments = [
45
+ ...views,
46
+ { text: above > 0 ? ` · ↑${above}` : '', dim: true, active: false, color: undefined },
47
+ { text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false, color: undefined },
48
+ ];
49
+ const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
50
+ 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, inverse: seg.active, children: shown[i] }, i)) : null) }));
51
+ }
52
+ export function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, childCount, focused, loadError, windowStart, above, below, columns, }) {
53
+ const allSelected = selected === ROW_ALL;
54
+ const spotSelected = selected === ROW_SPOT;
55
+ return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(TasksHeader, { counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut(sanitize(`sort: ${describeSort(view.sort)}` +
56
+ (view.hiddenPri.length + view.hiddenProg.length > 0
57
+ ? ' · filtri: ' +
58
+ [
59
+ ...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
60
+ ...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
61
+ ]
62
+ .map((e) => `−${e.glyph}`)
63
+ .join(' ')
64
+ : '')), paneTextWidth(columns)) }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni", allCount > 0 ? ` (${allCount})` : ''] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : paneCount === 0 ? (
65
+ // T100/D1 — una voce a contatore 0 resta navigabile, e selezionarla dà
66
+ // una lista vuota che DICE perché è vuota. Senza la nota il pane si
67
+ // legge come rotto: le righe meta restano, le task no, e niente spiega
68
+ // che è la vista scelta a non contenere nulla.
69
+ _jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(taskView(activeView).empty, paneTextWidth(columns)) })) : (tasks.map((task, i) => {
70
+ // windowStart riporta l'indice di finestra a quello della lista
71
+ // completa, su cui è keyata la selezione. +META_ROWS: le prime due
72
+ // righe sono le meta.
73
+ const sel = windowStart + i + META_ROWS === selected;
74
+ const n = childCount.get(task.id) ?? 0;
75
+ // Invariante ③: la descrizione è l'unico pezzo a lunghezza libera, e
76
+ // si taglia QUI sul budget che resta dopo le colonne fisse. Lasciarlo
77
+ // fare a `truncate-end` significa passare da `cli-truncate`, che
78
+ // restituisce una riga più larga del pane (una colonna per emoji) e
79
+ // quindi scrive sopra il bordo. Le parti fisse si misurano con
80
+ // `termWidth`: `task.id` è `T9` o `T52`, i due glifi valgono 2 ciascuno.
81
+ const head = `${CARET_OFF}${task.id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
82
+ const tail = n > 0 ? ` (${n})` : '';
83
+ const desc = cut(task.desc, Math.max(4, paneTextWidth(columns) - termWidth(head) - termWidth(tail)));
84
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
85
+ }))] }));
86
+ }
87
+ /**
88
+ * Header del pane sessioni, tagliato QUI e non da Ink.
89
+ *
90
+ * `📌{pinnedCount}` è un glifo largo 2 in mezzo alla riga: con
91
+ * `wrap="truncate-end"` il taglio passava da `cli-truncate`, che indicizza per
92
+ * code point con un budget in colonne e restituiva una riga larga 45 su un
93
+ * budget di 44 — la colonna in più finiva sopra il bordo destro del pane, che
94
+ * spariva dalla riga (invariante ③ di `width.ts`).
95
+ *
96
+ * I segmenti restano segmenti fino al render: il taglio è della RIGA (budget
97
+ * condiviso, `cutParts`), la resa è del pezzo. Il giallo su `📌N` distingue le
98
+ * pinnate dal resto dell'header e non è decorazione.
99
+ */
100
+ export function SessionsHeader({ parentLabel, counts, active, above, below, focused, columns, }) {
101
+ const views = SESSION_VIEWS.map((v) => {
102
+ const n = v.count(counts);
103
+ return {
104
+ text: ` · ${v.label(counts, parentLabel)}`,
105
+ color: v.color,
106
+ dim: v.dim || n === 0,
107
+ active: v.id === active,
108
+ };
109
+ });
110
+ const segments = [
111
+ // `Sessions` non è una voce del catalogo: nomina il pane, non un
112
+ // sottoinsieme, quindi non è raggiungibile con le frecce.
113
+ { text: 'Sessions', color: undefined, dim: false, active: false },
114
+ ...views,
115
+ { text: above > 0 ? ` · ↑${above}` : '', dim: true, active: false, color: undefined },
116
+ { text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false, color: undefined },
117
+ ];
118
+ const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
119
+ 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, inverse: seg.active, children: shown[i] }, i)) : null) }));
120
+ }
121
+ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, counts, activeView, paneCount, selectedId, focused, above, below, columns, forkOf, sessionNotes, projectCore, live, }) {
122
+ return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(SessionsHeader, { parentLabel: parentLabel, counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), paneCount === 0 ? (
123
+ // T100 — la nota della vista di default resta quella storica, che nomina
124
+ // il PARENT (task, spot o tutte); le altre tre viste portano la propria,
125
+ // che nomina il sottoinsieme. Sono due vuoti diversi: «questo parent non
126
+ // ha conversazioni» e «questo sottoinsieme del parent è vuoto».
127
+ _jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(sessionView(activeView).empty ??
128
+ (isAll
129
+ ? 'nessuna conversazione nel progetto'
130
+ : isSpot
131
+ ? 'nessuna sessione libera'
132
+ : 'nessuna sessione legata a questa task'), paneTextWidth(columns)) })) : (rows.map((row, i) => {
133
+ // T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
134
+ // box pesante (coerente con lo styling delle Done dimmate).
135
+ if (row.kind === 'separator') {
136
+ return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: SESSION_SEP }, `sep${i}`));
137
+ }
138
+ const sel = row.sessionId === selectedId;
139
+ // T50 — pin stale: transcript sparito, nessuna Session da mostrare.
140
+ // Riga navigabile e spinnabile (`p`), marcata, mai un crash.
141
+ if (row.kind === 'pinned' && row.stale) {
142
+ // T60 — anche qui la nota si taglia sul budget DERIVATO, non su un
143
+ // 30 inchiodato: su un pane stretto quel valore fisso mandava la
144
+ // riga oltre il bordo, e a ripararla arrivava `cli-truncate` (che
145
+ // sfora di una colonna per emoji e mangia il bordo stesso).
146
+ const staleNote = sessionNotes.get(row.sessionId);
147
+ // La riga stale è libera (niente colonne: non ha né titolo né
148
+ // data), ma il binding va detto lo stesso — è una pinnata, quindi
149
+ // l'header del pane non ne dice l'appartenenza.
150
+ const staleTask = bindings.get(row.sessionId) ?? null;
151
+ const staleW = Math.max(0, paneTextWidth(columns) -
152
+ (2 /* caret */ +
153
+ termWidth(`${WARN} pin stale `) +
154
+ SID_CHARS +
155
+ (staleTask ? termWidth(staleTask) + 1 : 0) +
156
+ 3 /* spazio + caporali */));
157
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, SID_CHARS) }), staleTask ? _jsxs(Text, { color: "green", children: [" ", staleTask] }) : null, staleNote ? _jsxs(Text, { color: "yellow", children: [" \u00AB", cut(staleNote, staleW), "\u00BB"] }) : null] }, row.sessionId));
158
+ }
159
+ const s = row.session; // non-stale → session presente
160
+ const isPinnedRow = row.kind === 'pinned';
161
+ // T28 — un ramo eredita il titolo dell'origine: senza marcatore le due
162
+ // righe sarebbero identiche a occhio.
163
+ const forked = forkOf.has(s.sessionId);
164
+ // T59 D2 — nella vista "tutte" il marker è PER-SESSIONE (binding letto
165
+ // dal sidecar) e non deciso dal parent: la lista mescola scoped e spot,
166
+ // quindi un marker uniforme mentirebbe su metà delle righe. E il solo
167
+ // glifo direbbe *che* la conversazione è legata senza dire *a cosa* —
168
+ // informazione monca proprio qui, l'unica vista dove l'appartenenza
169
+ // non è scritta da nessun'altra parte dello schermo: da qui la colonna
170
+ // task accanto, che esiste solo in questa vista.
171
+ const bound = bindings.get(s.sessionId) ?? null;
172
+ const linked = isAll ? Boolean(bound) : !isSpot;
173
+ // T62 — liveness e binding sono ORTOGONALI: la cella marker dice a chi
174
+ // appartiene la conversazione (pin/task/spot), questa dice se è aperta
175
+ // adesso. Farle condividere una cella perderebbe una delle due.
176
+ const liveEntry = live.get(s.sessionId);
177
+ // Stesso motivo per cui la colonna esiste: una pinnata resta in lista
178
+ // qualunque sia il parent selezionato, quindi l'header non ne dice
179
+ // l'appartenenza e la cella va riempita anche fuori dalla vista
180
+ // "tutte". Sulle contestuali, dove l'header parla già, resta vuota —
181
+ // ma la cella è comunque larga `taskW`, o le colonne a destra
182
+ // slitterebbero riga per riga.
183
+ const taskCell = isAll || isPinnedRow ? (bound ?? TASK_EMPTY) : '';
184
+ // T60 — colonne VERE: ogni cella fissa è larga esattamente quanto
185
+ // dichiara, riempita di spazi con `pad` (che misura in colonne, non in
186
+ // caratteri). Il marker va portato a 2 anche quando è `○`, largo 1:
187
+ // era lui a far slittare a sinistra di una colonna tutta la riga di
188
+ // ogni sessione spot.
189
+ const age = relTime(s.ts);
190
+ // Il taglio del titolo è ciò che RESTA, calcolato per sottrazione: le
191
+ // colonne fisse sono note, quindi l'unica cella elastica prende il
192
+ // resto. Pavimento `0` e non un minimo di cortesia — è un tetto, non
193
+ // una preferenza: alzarlo sopra lo spazio reale fa uscire la riga dal
194
+ // pane e le mangia il bordo (invariante ③).
195
+ const titleW = Math.max(0, paneTextWidth(columns) -
196
+ (2 /* caret */ +
197
+ 2 /* marker */ +
198
+ 1 /* gutter */ +
199
+ 1 /* T62 · colonna liveness */ +
200
+ SID_CHARS +
201
+ 1 /* gutter */ +
202
+ (taskW > 0 ? taskW + 1 : 0) +
203
+ 1 /* gutter prima della data */ +
204
+ ageW));
205
+ // T28 — `⑂` sta DENTRO la cella titolo, non in una colonna sua: una
206
+ // colonna dedicata costerebbe 2 spazi vuoti su ogni riga non-fork, e
207
+ // metterlo fuori cella sposterebbe il bordo del titolo solo sui rami —
208
+ // cioè rimetterebbe lo slittamento che le colonne tolgono.
209
+ const forkMark = forked ? '⑂ ' : '';
210
+ const inner = Math.max(0, titleW - termWidth(forkMark));
211
+ // T60 — il testo arriva già ripulito di ciò che le colonne accanto
212
+ // dicono già (progetto e task id): senza, la cella conterrebbe
213
+ // `🧵 loom-works · T59` accanto a una colonna che dice `T59`.
214
+ const label = rowLabel(sessionTitle(s, projectCore, bound), sessionNotes.get(s.sessionId), inner);
215
+ const used = (label.note ? termWidth(label.note) + 2 : 0) +
216
+ (label.note && label.rest ? 1 : 0) +
217
+ termWidth(label.rest);
218
+ 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));
219
+ }))] }));
220
+ }
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Blocco preview a piena larghezza sotto i due pane: mostra la task o la
3
+ // conversazione selezionata a seconda del pane a fuoco.
4
+ import { Box, Text } from 'ink';
5
+ import { wrapLines } from '../width.js';
6
+ import { previewTextWidth } from '../layout.js';
7
+ import { LIVE_BUSY, LIVE_IDLE, META_KEYS, SID_CHARS, fmtDateTime, fmtSize } from '../glyphs.js';
8
+ export function PreviewPane(p) {
9
+ 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 })) }));
10
+ }
11
+ // T49 — corpo della preview sessione. Tutti i campi vengono dal parse già
12
+ // cached dell'adapter (mtime-keyed): non costa I/O al movimento di selezione.
13
+ // Mostra "da dove parte, dove è arrivata": il primo prompt utente (`» `) e
14
+ // l'ultima risposta del modello (`« `). L'anteprima del primo prompt compare
15
+ // SOLO con un titolo custom — senza, il titolo È già il primo prompt e la riga
16
+ // lo duplicherebbe (D4 preflight). Le righe rese non superano mai il riservato
17
+ // dal budget (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
18
+ export function SessionPreview({ s, firstLines, lastLines, columns, origin, note, live, }) {
19
+ const width = previewTextWidth(columns);
20
+ const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
21
+ const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
22
+ 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}`)))] }));
23
+ }
24
+ /**
25
+ * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
26
+ * Estratto dal componente perché il budget deve saperlo PRIMA di renderizzare:
27
+ * sono righe fisse che tolgono spazio alla descrizione.
28
+ */
29
+ export function detailMetaOf(detail) {
30
+ const meta = META_KEYS.map((k) => detail.fields[k])
31
+ .filter(Boolean)
32
+ .join(' · ');
33
+ const commit = detail.fields['Last tracked commit'] ?? '';
34
+ return { meta, commit, metaLines: 1 + (meta ? 1 : 0) + (commit ? 1 : 0) };
35
+ }
36
+ /** Corpo della preview task: titolo, meta, descrizione wrappata, commit. */
37
+ export function TaskPreview({ detail, maxLines, columns, }) {
38
+ const { meta, commit } = detailMetaOf(detail);
39
+ // Wrap calcolato qui, non delegato a `<Text wrap="wrap">`: il budget ha
40
+ // riservato ESATTAMENTE `maxLines` righe, e un wrap deciso da Ink a runtime
41
+ // ne produrrebbe un numero che il budget non conosce — cioè il frame torna a
42
+ // sforare e il bug si riapre da questa singola casella di testo.
43
+ const lines = wrapLines(detail.description ?? '', previewTextWidth(columns), maxLines);
44
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: detail.title || detail.id }), meta ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: meta }) : null, lines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", children: line }, i))), commit ? _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", commit] }) : null] }));
45
+ }
@@ -0,0 +1,115 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ // Schermate SOSTITUTIVE della ricerca full-text (T52): lista delle occorrenze
3
+ // e reader del messaggio aperto. Prendono l'intero frame — il budget dei due
4
+ // pane non viene nemmeno calcolato.
5
+ import { Box, Text } from 'ink';
6
+ import { cut, sanitize, termWidth } from '../width.js';
7
+ import { sliceLine } from '../text-search.js';
8
+ import { MIN_QUERY } from '../search.js';
9
+ import { KIND_LABEL } from '../model.js';
10
+ import { conversationLabel, searchExcerptWidth, searchTitleWidth } from '../layout.js';
11
+ import { CARET, CARET_OFF, WARN, fmtDateTime } from '../glyphs.js';
12
+ // T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
13
+ // caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
14
+ // allinearsi, e i glifi BMP larghi 2 sono proprio la classe che Ink e il
15
+ // terminale misurano diversamente (vedi width.ts).
16
+ export const KIND_TAG = { ai: 'ai', tool: 'tl', human: 'hu' };
17
+ export const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
18
+ /**
19
+ * Riga di toggle del modale ricerca.
20
+ *
21
+ * La mappa tasto→significato è SEMPRE a schermo: `^R` da solo è opaco quanto lo
22
+ * era il range `1-9` delle launch prima di T43.
23
+ *
24
+ * Lo stato acceso/spento passa da `[x]`/`[ ]`, non dal solo colore — stessa
25
+ * convenzione del modale filtri. Il colore è ridondanza, non l'informazione: su
26
+ * un terminale monocromo, o in una cattura di testo, sei toggle tutti uguali
27
+ * non direbbero più quali sono attivi.
28
+ */
29
+ export function ToggleHint({ opts }) {
30
+ const flag = (on, key, label) => (_jsxs(Text, { color: on ? 'green' : 'gray', dimColor: !on, bold: on, children: [' ', key, "[", on ? 'x' : ' ', "] ", label] }, key));
31
+ return (_jsxs(Text, { wrap: "truncate-end", children: [flag(opts.regex, '^R', 'regex'), flag(opts.caseSensitive, '^A', 'Aa'), flag(opts.wholeWord, '^W', 'word'), _jsx(Text, { dimColor: true, children: ' │' }), flag(opts.kinds.ai, '^B', 'IA'), flag(opts.kinds.tool, '^T', 'tools'), flag(opts.kinds.human, '^U', 'human')] }));
32
+ }
33
+ /** Intestazione della lista: dice sempre quanto NON si sta vedendo (regex rotta,
34
+ * query troppo corta, occorrenze tagliate dal cap, righe fuori finestra). */
35
+ export function SearchListHeader({ result, query, above, below, }) {
36
+ if (result.error) {
37
+ return (_jsxs(Text, { color: "red", wrap: "truncate-end", children: [WARN, " regex non valida \u00B7 ", result.error] }));
38
+ }
39
+ if (result.idle) {
40
+ return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: query.length === 0
41
+ ? 'digita la chiave da cercare'
42
+ : `almeno ${MIN_QUERY} caratteri (${query.length})` }));
43
+ }
44
+ if (result.shown === 0) {
45
+ return (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: "nessuna occorrenza" }));
46
+ }
47
+ 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] }));
48
+ }
49
+ export function SearchScreen({ preview, hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, sessionNotes, projectCore, columns, note, }) {
50
+ const enter = selectedKind === 'session' ? 'resume' : selectedKind === 'hit' ? 'leggi' : '—';
51
+ 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) => {
52
+ const sel = row.key === selectedKey;
53
+ if (row.kind === 'session') {
54
+ const s = row.session;
55
+ const bound = bindings.get(s.sessionId);
56
+ const rowNote = sessionNotes.get(s.sessionId);
57
+ const noteShown = rowNote ? cut(rowNote, 24) : '';
58
+ // `+3` = i due caporali e lo spazio che li separa dall'etichetta.
59
+ // Il pavimento non è cosmetico: senza, un terminale stretto manda
60
+ // l'argomento di `cut` sotto zero, cioè un budget negativo.
61
+ // La nota si misura con `termWidth`, non con `.length`: contiene
62
+ // testo umano, emoji compresi.
63
+ const restWidth = Math.max(8, searchTitleWidth(columns) - (noteShown ? termWidth(noteShown) + 3 : 0));
64
+ 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 " }), noteShown ? _jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", noteShown, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(noteShown), children: cut(conversationLabel(s, projectCore, bound), restWidth) }), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
65
+ }
66
+ const h = row.hit;
67
+ 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] }), ' ', cut(h.excerpt, searchExcerptWidth(columns))] }, row.key));
68
+ })] }), preview ? _jsx(SearchPreviewPane, { p: preview }) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
69
+ }
70
+ /**
71
+ * Anteprima dell'occorrenza selezionata, sotto la lista.
72
+ *
73
+ * Riempie le righe che la lista non usa: con pochi risultati il terminale
74
+ * resterebbe vuoto per tre quarti, e il contesto attorno al match è proprio
75
+ * ciò che serve per decidere se è l'occorrenza giusta. Nel caso comune evita
76
+ * del tutto di aprire il reader.
77
+ *
78
+ * Si aggiorna navigando con le frecce, e la finestra è centrata sul match:
79
+ * stessa `windowRange` della lista, stessa evidenziazione del reader
80
+ * (`ReaderLine`) — nessuna primitiva nuova.
81
+ */
82
+ export function SearchPreviewPane({ p }) {
83
+ const last = Math.min(p.total, p.from + p.lines.length);
84
+ const occ = [{ start: p.hit.matchStart, end: p.hit.matchEnd }];
85
+ 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)))] }));
86
+ }
87
+ /**
88
+ * Reader fullscreen (T52 · D8).
89
+ *
90
+ * Mostra il messaggio INTERO che contiene l'occorrenza, aperto già posizionato
91
+ * sul match e con il match evidenziato. È un `mode` a sé, catturato prima del
92
+ * ramo `search`: il modale ricerca resta montato sotto e su `esc` si ritrova
93
+ * con query, toggle e selezione intatti.
94
+ */
95
+ export function ReaderScreen({ hit, lines, top, total, capacity, bound, }) {
96
+ const last = Math.min(total, top + capacity);
97
+ const occ = [{ start: hit.matchStart, end: hit.matchEnd }];
98
+ 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))) })] }));
99
+ }
100
+ /** Una riga con le porzioni di match evidenziate. Gli offset sono quelli del
101
+ * testo sorgente, quindi un match a cavallo dell'a-capo si colora su entrambe
102
+ * le righe senza casi speciali — entrambe intersecano il suo intervallo.
103
+ *
104
+ * Regge N occorrenze perché il detail (T91) ne mostra tutte quelle visibili; il
105
+ * reader (T52) ne passa una sola, che è il caso degenere dello stesso taglio. */
106
+ export function ReaderLine({ line, occ, current, }) {
107
+ const segs = sliceLine(line.text, line.start, occ, current);
108
+ if (segs.length === 0)
109
+ return _jsx(Text, { wrap: "truncate-end", children: line.text || ' ' });
110
+ return (_jsx(Text, { wrap: "truncate-end", children: segs.map((s, i) => s.hit ? (
111
+ // La corrente si distingue dalle altre per COLORE di sfondo, non per
112
+ // presenza: tutte restano visibili, o navigare fra occorrenze non
113
+ // mostrerebbe più dove sono le altre.
114
+ _jsx(Text, { backgroundColor: s.current ? 'cyan' : 'yellow', color: "black", children: s.text }, i)) : (_jsx(Text, { children: s.text }, i))) }));
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.33.0",
3
+ "version": "0.34.1",
4
4
  "description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
5
5
  "type": "module",
6
6
  "bin": {