@lamemind/loom-deck 0.34.0 → 0.34.2

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/dist/viewport.js CHANGED
@@ -217,13 +217,14 @@ export function readerCapacity(rows) {
217
217
  // 1 marginTop del box testo
218
218
  // 2 bordi del box testo
219
219
  // 1 marginTop della riga azioni
220
+ // 1 riga modello (T108)
220
221
  // 1 riga azioni
221
222
  //
222
- // Le ultime due sono il motivo per cui il detail non può riusare READER_CHROME:
223
- // la barra bottoni è una riga FISSA in più dentro l'overlay, e ogni riga fissa
224
- // aggiunta va scalata dalla capienza del contenuto o il frame sfonda `rows`
225
- // (stessa invariante di TASKS_PANE_CHROME).
226
- const DETAIL_CHROME = 10;
223
+ // Le ultime tre sono il motivo per cui il detail non può riusare READER_CHROME:
224
+ // barra bottoni e selettore modello sono righe FISSE in più dentro l'overlay, e
225
+ // ogni riga fissa aggiunta va scalata dalla capienza del contenuto o il frame
226
+ // sfonda `rows` (stessa invariante di TASKS_PANE_CHROME).
227
+ const DETAIL_CHROME = 11;
227
228
  // T91 — la ricerca dentro il detail: marginTop + riga del campo.
228
229
  //
229
230
  // `MODAL_HEIGHT` dice che il detail costa 0 ai due pane (li sostituisce), e da
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.34.0",
3
+ "version": "0.34.2",
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": {
package/scripts/deck-run CHANGED
@@ -8,16 +8,19 @@
8
8
  # "recap stato task <TaskID>" — no skill, solo recap → più controllo).
9
9
  # Riusabile identico da TUI (Ink) e da web.
10
10
  #
11
- # Tre assi ORTOGONALI, da non confondere fra loro:
11
+ # Quattro assi ORTOGONALI, da non confondere fra loro:
12
12
  # binding task <TaskID> ..... --no-task
13
13
  # continuità nuova ........ --resume [--fork]
14
14
  # prompt --prompt-kind none|recap|preflight|run|checkpoint
15
+ # modello --model fable|opus|sonnet|haiku
15
16
  # Il terzo asse (T56) prima non esisteva: il prompt era una CONSEGUENZA degli
16
17
  # altri due (bound ⇒ recap, --no-task ⇒ niente, --resume ⇒ niente), quindi
17
18
  # "task-bound SENZA prompt" era inesprimibile e l'unico modo di non avere un
18
19
  # prompt era perdere la task. Il catalogo dei prompt vive qui e non nel deck
19
20
  # perché questo è il primitive UI-agnostico: il chiamante passa un SIMBOLO, non
20
21
  # una stringa — il quoting resta verificato in un posto solo (vedi PROMPT sotto).
22
+ # Il quarto asse (T108) è indipendente dagli altri tre: vale sul ramo bound come
23
+ # su --no-task, su una sessione nuova come su una ripresa.
21
24
  #
22
25
  # Con --no-task la sessione è NUDA: niente LOOM_TASK, niente prompt iniziale,
23
26
  # niente --session-id. Serve al lavoro spot che non appartiene a nessuna task.
@@ -43,6 +46,7 @@
43
46
  # LOOM_DECK_STATE_PROFILE PTYXIS_PROFILE annunciata a compass (default: bindings/claude
44
47
  # del progetto, letto da dconf; settata a vuoto = nessun annuncio)
45
48
  # LOOM_DECK_PERMISSION_MODE override del permissionMode del file config (default: campo file, poi 'manual')
49
+ # LOOM_DECK_MODEL modello quando --model non è passato (default: opus)
46
50
  #
47
51
  set -euo pipefail
48
52
 
@@ -56,6 +60,10 @@ TITLE_NOTE=""
56
60
  # "kind implicito" da "kind chiesto", perché con --no-task il primo è legittimo
57
61
  # (nessun prompt, come sempre) e il secondo è un errore d'uso.
58
62
  PROMPT_KIND=""
63
+ # Vuoto = flag non passato: la cascata (env, poi default) si risolve più sotto,
64
+ # insieme alla validazione, così l'ingresso da argomento e quello da env passano
65
+ # per lo stesso enum.
66
+ MODEL=""
59
67
  # Positional <TaskID> + flag opzionale --session-id <uuid> (T27): il deck genera
60
68
  # l'UUID e lo pinna così il binding sidecar sessionId↔taskId è deterministico.
61
69
  # --no-task (T42): modalità NUDA, senza TaskID — nessuna LOOM_TASK, nessun prompt
@@ -84,8 +92,17 @@ PROMPT_KIND=""
84
92
  # (emoji/name dal file committato, TaskID) sono controllati — quindi il testo non
85
93
  # si quota, si RIDUCE a un alfabeto sicuro (vedi _sane_note): tutto ciò che non
86
94
  # ci rientra sparisce, apici inclusi.
95
+ # --model <fable|opus|sonnet|haiku> (T108): modello della sessione. Enum e non
96
+ # stringa libera perché il valore finisce dentro `bash -lc` e un refuso
97
+ # produrrebbe una tab con un comando che il CLI rifiuta. Le voci sono ALIAS e
98
+ # restano tali: espanderle a id versionati (`claude-opus-5`) cablerebbe qui una
99
+ # generazione, che al primo bump diventa un modello inesistente.
87
100
  while [[ $# -gt 0 ]]; do
88
101
  case "$1" in
102
+ --model)
103
+ MODEL="${2:-}"; shift 2 ;;
104
+ --model=*)
105
+ MODEL="${1#*=}"; shift ;;
89
106
  --title-note)
90
107
  TITLE_NOTE="${2:-}"; shift 2 ;;
91
108
  --title-note=*)
@@ -112,7 +129,7 @@ while [[ $# -gt 0 ]]; do
112
129
  esac
113
130
  done
114
131
 
115
- USAGE="uso: deck-run <TaskID> [--prompt-kind <kind>] [--session-id <uuid>]
132
+ USAGE="uso: deck-run <TaskID> [--model <alias>] [--prompt-kind <kind>] [--session-id <uuid>]
116
133
  (es. deck-run T18)
117
134
  | deck-run --no-task (sessione nuda, senza task)
118
135
  | deck-run <TaskID> --resume <uuid> (riprende sessione scoped)
@@ -127,6 +144,9 @@ USAGE="uso: deck-run <TaskID> [--prompt-kind <kind>] [--session-id <uuid>]
127
144
  run /loom-works:run-task <TaskID>
128
145
  checkpoint /loom-works:checkpoint-task <TaskID>
129
146
 
147
+ --model modello della sessione: fable|opus|sonnet|haiku (default: opus)
148
+ valore ignoto → fallback sul default, con avviso su stderr
149
+
130
150
  --title-note nota della conversazione, appesa al titolo tab come «nota»
131
151
  (ridotta a lettere/cifre/spazi/-/_, cap 60 char)"
132
152
 
@@ -274,6 +294,28 @@ case "${PERM_MODE:-}" in
274
294
  esac
275
295
  MODE_FLAG="--permission-mode ${PERM_MODE} "
276
296
 
297
+ # ── modello (T108) ───────────────────────────────────────────────────────────
298
+ # Precedenza: --model > env LOOM_DECK_MODEL > 'opus'. Nessun campo nel file
299
+ # config: il default è unico per tutta la famiglia, e uno scalare per-progetto
300
+ # aggiungerebbe una chiave allo schema del plugin per un valore che il selettore
301
+ # del deck sovrascrive a ogni spawn.
302
+ # Il flag è passato SEMPRE, anche sul default, per la stessa ragione di
303
+ # permissionMode: lo spawn resta leggibile nel process tree invece di dipendere
304
+ # dal default del CLI, che cambia fra versioni.
305
+ # Valore fuori enum → fallback con avviso, non exit. Regime opposto a
306
+ # --prompt-kind, e il discriminante è dove finisce il valore: un kind ignoto si
307
+ # ferma DENTRO lo script (nessun template da scegliere, l'errore è del deck),
308
+ # un modello ignoto arriverebbe al CLI e produrrebbe una tab con un comando che
309
+ # fallisce all'avvio — cioè il guasto che permissionMode evita degradando.
310
+ MODEL="${MODEL:-${LOOM_DECK_MODEL:-}}"
311
+ case "${MODEL:-}" in
312
+ fable|opus|sonnet|haiku) ;;
313
+ '') MODEL="opus" ;;
314
+ *) echo "modello ignoto: '${MODEL}' → fallback 'opus' (usa fable|opus|sonnet|haiku)" >&2
315
+ MODEL="opus" ;;
316
+ esac
317
+ MODEL_FLAG="--model ${MODEL} "
318
+
277
319
  # ── Profilo di stato per compass ─────────────────────────────────────────────
278
320
  # Lo stato di una sessione (running/ask/done) viaggia verso compass via D-Bus
279
321
  # keyed su $PTYXIS_PROFILE (hook Claude → `compass <stato>`), e compass lo mappa
@@ -388,10 +430,13 @@ PROMPT_ARG=""
388
430
  # PROFILE_ENV in testa a entrambi i rami: lo stato è una proprietà della SESSIONE
389
431
  # (esiste anche senza task), esattamente come la label del titolo — legarlo al
390
432
  # ramo task-bound ripeterebbe l'errore di layer già evitato per la titolazione.
433
+ # MODEL_FLAG in entrambi i rami e prima dei flag di continuità: il modello è la
434
+ # scelta di chi apre la sessione, non una proprietà della task né della ripresa
435
+ # (T108) — stesso layer di MODE_FLAG, che infatti gli sta accanto.
391
436
  if [[ $NO_TASK -eq 1 ]]; then
392
- _default_intab="${PROFILE_ENV}claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}"
437
+ _default_intab="${PROFILE_ENV}claude --name '${TITLE}' ${MODE_FLAG}${MODEL_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}"
393
438
  else
394
- _default_intab="${PROFILE_ENV}LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}${PROMPT_ARG}"
439
+ _default_intab="${PROFILE_ENV}LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${MODEL_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}${PROMPT_ARG}"
395
440
  fi
396
441
  IN_TAB_CMD="${LOOM_DECK_INTAB_CMD:-$_default_intab}"
397
442