@lamemind/loom-deck 0.55.0 → 0.56.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,201 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Le SCHERMATE SOSTITUTIVE del deck e i due frammenti che le accompagnano.
3
+ //
4
+ // Una schermata sostitutiva prende il frame intero invece di stare in un box
5
+ // sopra i due pane: assegnazione, detail della task, project status, ricerca e
6
+ // reader. Il criterio è la taglia del contenuto — una lista di occorrenze o un
7
+ // task file non entrano in quattro righe — e la conseguenza è che il budget
8
+ // d'altezza dei pane non viene nemmeno calcolato, perché il render esce prima.
9
+ //
10
+ // `screenFor` è il ROUTER: sceglie fra le cinque e restituisce `null` quando
11
+ // nessuna è attiva, cioè quando si resta sulla lista. È una funzione e non un
12
+ // componente proprio per questo — il chiamante deve poter distinguere «ecco la
13
+ // schermata» da «non è il tuo turno», e un componente che rende `null` non gli
14
+ // direbbe la differenza in tempo utile per il proprio `return`.
15
+ //
16
+ // Fino a T131 le cinque vivevano in `cli.tsx` come cinque `if` consecutivi, con
17
+ // dentro le derivazioni di finestra di ognuna. Sono uscite insieme perché la
18
+ // scelta fra le viste non è né vista né input: la vista disegna ciò che le
19
+ // viene dato, l'input decide cosa cambiare, e chi sceglie quale albero tornare
20
+ // è un terzo mestiere.
21
+ //
22
+ // Direzione della dipendenza: questo file importa i TIPI di ritorno degli hook
23
+ // di `src/overlays/` (`import type` + `ReturnType`, nessun import a runtime).
24
+ // È lecito nell'asse — la vista sta a valle dell'input, mai a monte — ed è il
25
+ // primo file di `ui/` a farlo, quindi va detto invece che scoperto a grep.
26
+ import { Box, Text } from 'ink';
27
+ import { rowIndexOfKey, selectedRow } from '../search.js';
28
+ import { isCompact, searchPreviewCapacity, windowRange } from '../viewport.js';
29
+ import { conversationLabel } from '../layout.js';
30
+ import { taskColumns } from '../view.js';
31
+ import { AssignScreen } from './assign-screen.js';
32
+ import { DetailScreen } from './detail-screen.js';
33
+ import { StatusScreen } from './status-screen.js';
34
+ import { ReaderScreen, SearchScreen } from './search-screen.js';
35
+ /**
36
+ * Il ripiego per un terminale troppo basso: una riga sola al posto della
37
+ * cornice. Perdere il layout è meglio che sfondare `rows` — oltre quella
38
+ * soglia Ink smette di aggiornare per differenza e pulisce lo schermo a ogni
39
+ * redraw, versando un frame intero nello scrollback a ogni tick del poll.
40
+ *
41
+ * Sei call site lo usavano in copia (le cinque schermate più la lista), identici
42
+ * tranne il soggetto e il verbo di `esc`. `dot` esiste perché la sesta copia —
43
+ * quella della lista — attacca al nome del programma la propria VERSIONE, e un
44
+ * punto in mezzo la staccherebbe da ciò di cui è la versione.
45
+ */
46
+ export function CompactNotice({ what, esc, rows, columns, dot = true, }) {
47
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [dot ? ' · ' : ' ', what, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga", esc ? ` · esc ${esc}` : ''] })] }));
48
+ }
49
+ /**
50
+ * La riga sotto la testata: in `normal` la legenda dei tasti, in un modale in
51
+ * flusso le sue istruzioni. I modali sostitutivi non arrivano qui — hanno già
52
+ * preso il frame.
53
+ *
54
+ * La legenda di `normal` la calcola il chiamante (`deckLegend` in `frame.ts`),
55
+ * perché dipende da cosa è selezionato; le sette righe dei modali sono testo
56
+ * fisso e vivono qui, accanto alla forma che le rende.
57
+ */
58
+ export function HintBar({ mode, purge, keyLegend, }) {
59
+ if (mode === 'create') {
60
+ return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
61
+ }
62
+ if (mode === 'sort') {
63
+ return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
64
+ }
65
+ if (mode === 'filter') {
66
+ return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
67
+ }
68
+ if (mode === 'note') {
69
+ return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["titolo conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
70
+ }
71
+ if (mode === 'purge') {
72
+ return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["elimina task \u00B7", ' ', purge?.ignored ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " keep/purge dei file non tracciati \u00B7", ' '] })) : null, _jsx(Text, { color: "yellow", children: "\u23CE" }), " conferma \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
73
+ }
74
+ if (mode === 'edit') {
75
+ return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
76
+ }
77
+ return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend }));
78
+ }
79
+ /**
80
+ * Sceglie la schermata sostitutiva attiva, o `null` per restare sulla lista.
81
+ *
82
+ * L'ordine dei rami è quello che avevano in `cli.tsx` e non è indifferente: i
83
+ * modi si escludono a vicenda, ma `detail` e `status` chiedono anche che il
84
+ * proprio contenuto esista (`sheet.sheet`, `status.view`) — un modo dichiarato
85
+ * senza contenuto deve cadere alla lista, non a una schermata vuota.
86
+ */
87
+ export function screenFor(input) {
88
+ const { mode, rows, columns, note, overlays } = input;
89
+ const { assign, sheet, search, status } = overlays;
90
+ // ── T57 · schermata di assegnazione ─────────────────────────────────────
91
+ // Sostitutiva come ricerca e reader (D3): la lista task non entra in un box
92
+ // sopra i due pane, e prendendo l'intero frame non costa nulla al loro budget.
93
+ // Conseguenza obbligata della scelta: la sessione in assegnazione non è più
94
+ // visibile, quindi va RIPETUTA nel titolo — senza, non si sa più su cosa si
95
+ // sta agendo.
96
+ if (mode === 'assign') {
97
+ if (isCompact(assign.capacity)) {
98
+ return _jsx(CompactNotice, { what: "assegna", esc: "annulla", rows: rows, columns: columns });
99
+ }
100
+ const at = assign.list.findIndex((t) => (t?.id ?? null) === assign.sel);
101
+ const win = windowRange(assign.list.length, at, assign.capacity);
102
+ const s = assign.sid
103
+ ? input.sessions.find((x) => x.sessionId === assign.sid) ?? null
104
+ : null;
105
+ // Etichetta della conversazione: la nota umana se c'è (è il nome con cui la
106
+ // riconosci), altrimenti la stessa derivazione della ricerca. Su una pinnata
107
+ // stale non resta nulla: il titolo si accontenta dell'hash.
108
+ const label = (assign.sid ? input.sessionNotes.get(assign.sid) : '') ||
109
+ (s
110
+ ? conversationLabel(s, input.projectCore, assign.sid ? input.bindings.get(assign.sid) : undefined)
111
+ : '');
112
+ // T124 — le colonne si misurano sulla lista FILTRATA di questa schermata,
113
+ // non su `paneTasks`: la popolazione è un'altra, e riusare le larghezze del
114
+ // pane darebbe una colonna dimensionata su righe che qui non ci sono.
115
+ // `assign.list` porta in testa la riga `detach` (`null`), che non è una task.
116
+ const assignCols = taskColumns(assign.list.filter((t) => t !== null), input.taskRowData);
117
+ return (_jsx(AssignScreen, { sessionId: assign.sid ?? '', label: label, current: assign.sid ? input.bindings.get(assign.sid) ?? null : null, filter: assign.filter, rows: assign.list.slice(win.start, win.end), selected: assign.sel, matched: assign.list.length - 1, hidden: input.hiddenTasks, above: win.start, below: assign.list.length - win.end, idW: assignCols.id, tailW: assignCols.tail, data: input.taskRowData, columns: columns, note: note }));
118
+ }
119
+ // ── T66 · detail della task ─────────────────────────────────────────────
120
+ // Quarta schermata sostitutiva, stessa ragione delle altre tre: un task file
121
+ // non entra in un box sopra i due pane. Il budget dei pane non viene nemmeno
122
+ // calcolato — il render esce di qui prima.
123
+ if (mode === 'detail' && sheet.sheet) {
124
+ if (isCompact(sheet.capacity)) {
125
+ return (_jsx(CompactNotice, { what: sheet.sheet.id, esc: "chiude", rows: rows, columns: columns }));
126
+ }
127
+ // Niente `windowRange`: quella centra la finestra su una selezione, qui la
128
+ // posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
129
+ // può accorciare il testo sotto uno scroll già dato.
130
+ const start = Math.min(sheet.top, sheet.maxTop);
131
+ return (_jsx(DetailScreen, { id: sheet.sheet.id, title: sheet.sheet.title, missing: sheet.sheet.text === null, lines: sheet.lines.slice(start, start + sheet.capacity), spans: sheet.doc?.spans ?? [], top: start, total: sheet.lines.length, capacity: sheet.capacity, action: sheet.action, model: sheet.model, spawnNote: sheet.spawnNote, prompt: sheet.prompt, cursor: sheet.cursor, columns: columns, find: sheet.find, occ: sheet.findRes.occ, occCur: sheet.occCur }));
132
+ }
133
+ // ── T121 · viewer del project status ────────────────────────────────────
134
+ // Quinta schermata sostitutiva, stessa ragione delle altre quattro: un recap
135
+ // di progetto è lungo quanto un task file e non entra in un box sopra i pane.
136
+ if (mode === 'status' && status.view) {
137
+ if (isCompact(status.capacity)) {
138
+ return (_jsx(CompactNotice, { what: "project status", esc: "chiude", rows: rows, columns: columns }));
139
+ }
140
+ // Il clamp serve anche qui: un resize può accorciare il testo sotto uno
141
+ // scroll già dato.
142
+ const start = Math.min(status.top, status.maxTop);
143
+ return (_jsx(StatusScreen, { name: input.projectCore ?? input.projectName, label: status.label, building: status.building, failed: status.failed, view: status.view, lines: status.lines.slice(start, start + status.capacity), spans: status.doc?.spans ?? [], top: start, total: status.lines.length, capacity: status.capacity, columns: columns }));
144
+ }
145
+ // ── T52 · ricerca e reader ──────────────────────────────────────────────
146
+ // Gli unici modali che NON stanno in flusso sopra i pane: una lista di
147
+ // occorrenze non entra in un box da 4 righe. Prendono l'intero frame, quindi
148
+ // escono di qui — il budget dei due pane sotto non serve nemmeno calcolarlo,
149
+ // e la loro altezza la distribuiscono `searchListCapacity` / `readerCapacity`.
150
+ if (mode === 'search' || mode === 'reader') {
151
+ const hit = mode === 'reader' && search.readerRow?.kind === 'hit' ? search.readerRow.hit : null;
152
+ // Terminale sotto la cornice: riga singola invece del box, per lo stesso
153
+ // motivo del `budget.compact` del deck — un frame più alto di `rows` fa
154
+ // pulire lo schermo a Ink a ogni redraw, e il poll lo versa nello scrollback.
155
+ if (isCompact(hit ? search.readerCap : search.listCap)) {
156
+ return (_jsx(CompactNotice, { what: hit ? 'reader' : 'ricerca', esc: hit ? 'torna' : 'chiude', rows: rows, columns: columns }));
157
+ }
158
+ if (hit) {
159
+ // Niente `windowRange`: quella centra la finestra su una SELEZIONE, qui
160
+ // la posizione è lo scroll che l'utente muove a mano. Il clamp serve
161
+ // comunque — un resize può accorciare il testo sotto uno scroll già dato.
162
+ const start = Math.min(search.readerTop, search.readerMaxTop);
163
+ return (_jsx(ReaderScreen, { hit: hit, lines: search.readerLines.slice(start, start + search.readerCap), top: start, total: search.readerLines.length, capacity: search.readerCap, bound: input.bindings.get(hit.sessionId) ?? null }));
164
+ }
165
+ const selIdx = rowIndexOfKey(search.rows, search.selKey);
166
+ const win = windowRange(search.rows.length, selIdx, search.listCap);
167
+ // Anteprima dell'occorrenza selezionata: prende le righe che la lista non
168
+ // usa. Con molti risultati `spare` è 0 e il pannello non esiste — la lista
169
+ // se le riprende tutte, che è la priorità giusta quando c'è molto da
170
+ // scorrere. La finestra si CENTRA sul match (`windowRange`), così il
171
+ // contesto arriva da entrambi i lati.
172
+ const spare = searchPreviewCapacity(search.listCap, win.end - win.start);
173
+ let preview = null;
174
+ if (spare >= 1 && search.selRow?.kind === 'hit') {
175
+ const h = search.selRow.hit;
176
+ const mline = Math.max(0, search.previewBody.findIndex((l) => l.end > h.matchStart));
177
+ const pw = windowRange(search.previewBody.length, mline, spare);
178
+ preview = {
179
+ hit: h,
180
+ lines: search.previewBody.slice(pw.start, pw.end),
181
+ from: pw.start,
182
+ total: search.previewBody.length,
183
+ ts: input.sessions.find((s) => s.sessionId === h.sessionId)?.ts ?? 0,
184
+ };
185
+ }
186
+ return (_jsx(SearchScreen, { preview: preview, hash: search.hash, query: search.query, field: search.field, opts: search.opts, result: search.result, rows: search.rows.slice(win.start, win.end), selectedKey: search.selKey, selectedKind: selectedRow(search.rows, search.selKey)?.kind ?? null, above: win.start, below: search.rows.length - win.end, capacity: search.listCap, bindings: input.bindings, pinned: input.pinned, sessionNotes: input.sessionNotes, projectCore: input.projectCore, columns: columns, note: note }));
187
+ }
188
+ return null;
189
+ }
190
+ /**
191
+ * I due box di testo in flusso — create e nota — e i tre modali che li
192
+ * accompagnano stanno in `ui/modals.tsx`; qui resta il solo `CreateBox`, che
193
+ * non aveva una casa perché nato come JSX inline nel corpo del deck.
194
+ *
195
+ * Il cursore sta in coda al testo (append only): un cursore mobile vorrebbe
196
+ * gestire frecce e Home/End, e `Home`/`End` non sono nemmeno esposte da
197
+ * `useInput`.
198
+ */
199
+ export function TextBox({ glyph, value }) {
200
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: [glyph, " \u203A "] }), _jsx(Text, { children: value }), _jsx(Text, { inverse: true, children: " " })] }));
201
+ }
@@ -0,0 +1,122 @@
1
+ // T134 — l'hard-wrap dei `.md` come dato del deck.
2
+ //
3
+ // Lo scanner è `scripts/docs/md-wrap.py` del plugin, e resta lì per intero: il
4
+ // riconoscimento del wrap sta in tre euristiche tarate su un collaudo (la
5
+ // colonna è una banda con tolleranza, si stima sul 90° percentile, lo
6
+ // srotolamento itera a colonna ferma) e replicarle qui darebbe due misure
7
+ // destinate a divergere in silenzio.
8
+ //
9
+ // A differenza della coda inbox, questa misura è CARA: cammina l'albero del
10
+ // progetto intero, submodule compresi. Da qui la forma già in casa per il
11
+ // project status — generare e aprire su due tasti distinti, con una cache su
12
+ // disco in mezzo — invece di uno scan all'avvio che rallenterebbe proprio il
13
+ // momento in cui si vuole vedere qualcosa subito.
14
+ import { execFile } from 'node:child_process';
15
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { dirname, join } from 'node:path';
18
+ import { promisify } from 'node:util';
19
+ import { pluginScript } from './plugin-cache.js';
20
+ const execFileAsync = promisify(execFile);
21
+ export const WRAP_SCRIPT = 'scripts/docs/md-wrap.py';
22
+ const FIELD_RE = /^(col|ratio|breaks|prose)=(.*)$/;
23
+ /**
24
+ * Il TSV di `md-wrap.py --scan`: una riga per file NON `libero`, nella forma
25
+ * `verdetto ⇥ path ⇥ col=N ⇥ ratio=N ⇥ breaks=N ⇥ prose=N`.
26
+ *
27
+ * I campi si leggono per nome e non per posizione: sono già etichettati
28
+ * nell'output, e leggerli per indice trasformerebbe una colonna aggiunta in
29
+ * fondo in numeri sbagliati invece che in un campo ignorato.
30
+ */
31
+ export function parseWrapTsv(stdout) {
32
+ const out = [];
33
+ for (const line of stdout.split('\n')) {
34
+ if (!line.trim())
35
+ continue;
36
+ const cells = line.split('\t');
37
+ const verdict = cells[0];
38
+ if (verdict !== 'WRAP' && verdict !== 'misto')
39
+ continue;
40
+ if (!cells[1])
41
+ continue;
42
+ const fields = {};
43
+ for (const cell of cells.slice(2)) {
44
+ const m = FIELD_RE.exec(cell.trim());
45
+ if (m)
46
+ fields[m[1]] = m[2];
47
+ }
48
+ const column = Number(fields.col);
49
+ out.push({
50
+ verdict,
51
+ path: cells[1],
52
+ column: Number.isFinite(column) ? column : null,
53
+ ratio: Number(fields.ratio) || 0,
54
+ breaks: Number(fields.breaks) || 0,
55
+ prose: Number(fields.prose) || 0,
56
+ });
57
+ }
58
+ // I peggiori in cima: chi apre la lista srotola da lì.
59
+ return out.sort((a, b) => {
60
+ if (a.verdict !== b.verdict)
61
+ return a.verdict === 'WRAP' ? -1 : 1;
62
+ return b.breaks - a.breaks;
63
+ });
64
+ }
65
+ /**
66
+ * D4 — il contatore conta i soli `WRAP`.
67
+ *
68
+ * `misto` ospita il falso allarme strutturale: file di note scritte una riga per
69
+ * pensiero, che nessuno vuole srotolare. Sommarlo darebbe un numero che non si
70
+ * può portare a zero, e un contatore che non arriva mai a zero smette di essere
71
+ * letto.
72
+ */
73
+ export function wrapCount(files) {
74
+ return files.filter((f) => f.verdict === 'WRAP').length;
75
+ }
76
+ /**
77
+ * Cartella della cache, una per utente e con `mode 0700` — stessa ragione del
78
+ * project status: `/tmp` è condivisa, e un file a nome prevedibile può essere
79
+ * preceduto dal symlink di un altro utente.
80
+ */
81
+ export function wrapCacheDir() {
82
+ return join(tmpdir(), `loom-deck-wrap-${process.getuid?.() ?? 0}`);
83
+ }
84
+ export function wrapCacheFile(projectRoot) {
85
+ const env = process.env.LOOM_DECK_WRAP_FILE;
86
+ if (env)
87
+ return env;
88
+ return join(wrapCacheDir(), `${projectRoot.replace(/[^a-zA-Z0-9]/g, '-')}.tsv`);
89
+ }
90
+ /** File assente o illeggibile → nessuna cache: `missing` è lo stato di partenza. */
91
+ export function readWrapCache(path) {
92
+ try {
93
+ const raw = readFileSync(path, 'utf8');
94
+ return { files: parseWrapTsv(raw), mtime: statSync(path).mtimeMs };
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ /**
101
+ * Lo scan, in headless. `false` = guasto — plugin assente, `python3` assente,
102
+ * script in errore: i tre casi collassano, perché a schermo non cambiano la
103
+ * prima mossa di chi li vede.
104
+ *
105
+ * La cache si riscrive SOLO su successo: un tentativo fallito lascia in piedi
106
+ * l'esito dell'ultimo scan riuscito, che resta vero e ancora apribile.
107
+ */
108
+ export async function runWrapScan(projectRoot) {
109
+ const script = pluginScript(WRAP_SCRIPT);
110
+ if (!script)
111
+ return false;
112
+ try {
113
+ const { stdout } = await execFileAsync('python3', [script, '--root', projectRoot, '--scan'], { cwd: projectRoot, maxBuffer: 16 * 1024 * 1024 });
114
+ const path = wrapCacheFile(projectRoot);
115
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
116
+ writeFileSync(path, stdout, { mode: 0o600 });
117
+ return true;
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.55.0",
3
+ "version": "0.56.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": {