@lamemind/loom-deck 0.54.1 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,235 @@
1
+ // Le operazioni su una TASK: crearla, editarla, potarla.
2
+ //
3
+ // T131 — nate insieme agli spawn dentro `cli.tsx` e separate da quelli qui,
4
+ // sulla linea di frattura che l'analisi dello split aveva indicato: ciò che
5
+ // apre una conversazione da una parte, ciò che ordina un lavoro sulla task list
6
+ // dall'altra. Il discriminante operativo è l'OGGETTO — una sessione contro una
7
+ // riga di `tasks.md` — e si vede dagli effetti: qui si scrive il task file, si
8
+ // committa, si delega a una skill headless.
9
+ //
10
+ // Il deck non rimuove e non riscrive mai la task list da sé: `createTask` e
11
+ // `purgeTasks` ordinano a `loom-works:create-task` e `loom-works:clean-tasks`,
12
+ // che quelle sequenze le implementano già una volta. Averne due significherebbe
13
+ // due creazioni e due rimozioni capaci di divergere.
14
+ //
15
+ // Le stringhe di `setNote` di questo file sono ASSERITE dal gate
16
+ // `test/modes-smoke.test.ts` (`eliminare N task?`, `nessun push`, `scartate`,
17
+ // `tasks.md`): si copiano verbatim, non si migliorano di passaggio.
18
+ import { randomUUID } from 'node:crypto';
19
+ import { loadTasks } from './tasks.js';
20
+ import { appendTaskBinding } from './task-index.js';
21
+ import { cut, sanitize } from './width.js';
22
+ import { idList } from './ui/modals.js';
23
+ import { purgeTargets, splitTargets } from './purge.js';
24
+ import { initialDetail, writeTaskEdit, PRI_GLYPH, PRI_LABEL } from './task-edit.js';
25
+ import { priName, progName } from './view.js';
26
+ import { commitTaskEdit, spawnCleanTasks, spawnCreateTask, CLAUDE_CMD, } from './spawn.js';
27
+ export function useTaskOps({ cwd, tasksPath, tasksDir, model, setNote, selectedTaskOr, }) {
28
+ // T30 — il taskId nasce DOPO create-task (lo assegna la skill scrivendo
29
+ // tasks.md) → non è noto allo spawn. Il sessionId invece è pinnato qui:
30
+ // snapshot degli id PRIMA, poi al completamento re-leggo tasks.md e il diff dà
31
+ // il nuovo id → appendTaskBinding lega la sessione (scoped).
32
+ function createTask(text) {
33
+ if (!text) {
34
+ setNote('C → create annullato (vuoto)');
35
+ return;
36
+ }
37
+ const sid = randomUUID();
38
+ const beforeIds = new Set(model.tasks.map((t) => t.id));
39
+ setNote(`⏳ creando task… "${cut(text, 40)}" (sid ${sid.slice(0, 8)})`);
40
+ const child = spawnCreateTask(text, cwd, sid, (ok) => {
41
+ if (!ok) {
42
+ setNote(`⚠ create-task fallito (${CLAUDE_CMD} -p)`);
43
+ return;
44
+ }
45
+ let newId;
46
+ try {
47
+ newId = loadTasks(tasksPath).find((t) => !beforeIds.has(t.id))?.id;
48
+ }
49
+ catch {
50
+ // tasks.md illeggibile → id non rilevato, sotto
51
+ }
52
+ if (newId) {
53
+ appendTaskBinding(cwd, sid, newId);
54
+ setNote(`✔ ${newId} creata · sessione scoped (sid ${sid.slice(0, 8)})`);
55
+ }
56
+ else {
57
+ setNote(`✔ task creata (id non rilevato) · sid ${sid.slice(0, 8)}`);
58
+ }
59
+ });
60
+ child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
61
+ }
62
+ // T41 — scrive tasks.md + task file, poi committa. Il commit è immediato e non
63
+ // confermato (scelta esplicita: l'edit è una micro-modifica, la storia
64
+ // granulare vale più di un batch). Se nessuno dei due lati è stato scritto non
65
+ // si committa nulla — `paths` vuoto renderebbe `git commit --` un commit di
66
+ // TUTTO il working tree, che è l'opposto di ciò che vogliamo.
67
+ function writeEdit(task, draft) {
68
+ // Il titolo si scrive solo se è CAMBIATO davvero: rimandarlo identico
69
+ // riscriverebbe comunque la cella (collassando spazi ed escape) e sporcherebbe
70
+ // il diff di una riga per un edit di sola priorità. Vuoto → scartato: una
71
+ // task senza descrizione in overview non è più riconoscibile.
72
+ const title = draft.title.trim();
73
+ const titleChanged = title.length > 0 && title !== task.rawDesc.trim();
74
+ let res;
75
+ try {
76
+ res = writeTaskEdit({
77
+ tasksPath,
78
+ tasksDir,
79
+ id: task.id,
80
+ pri: draft.pri,
81
+ prog: draft.prog,
82
+ detail: draft.detail,
83
+ title: titleChanged ? title : undefined,
84
+ });
85
+ }
86
+ catch (e) {
87
+ setNote(`⚠ ${task.id}: scrittura fallita (${e.message})`);
88
+ return;
89
+ }
90
+ if (res.paths.length === 0) {
91
+ setNote(`⚠ ${task.id}: nessun campo aggiornabile (riga o task file assenti)`);
92
+ return;
93
+ }
94
+ const summary = `${PRI_GLYPH[draft.pri]} ${PRI_LABEL[draft.pri]} · ${res.progress}${titleChanged ? ` · "${cut(sanitize(title), 32)}"` : ''}`;
95
+ setNote(`⏳ ${task.id} → ${summary} · commit…`);
96
+ commitTaskEdit(cwd, res.paths, `chore(${task.id}): pri ${PRI_LABEL[draft.pri]} · stato ${res.progress}${titleChanged ? ' · titolo' : ''}`, (ok, err) => {
97
+ setNote(ok
98
+ ? `✔ ${task.id} → ${summary} · committato`
99
+ : `⚠ ${task.id} salvato, commit fallito: ${err}`);
100
+ });
101
+ }
102
+ /**
103
+ * La bozza di conferma per `CANC`, o `null` se non c'è niente da confermare
104
+ * (in tal caso la nota dice già perché).
105
+ *
106
+ * Il tasto è uno e il bersaglio ha due taglie: la task selezionata, o
107
+ * l'insieme intero della vista `archiviabili`. A discriminare è `purgeBulk` —
108
+ * la selezione prima della vista.
109
+ *
110
+ * Il bersaglio del bulk si legge da `paneTasks`, cioè dalla stessa fonte che
111
+ * disegna le righe e alimenta il contatore in header (D6): mai il `Set` grezzo
112
+ * di `archivable.ts`, mai un secondo filtro sullo stato. T100 ha fissato che
113
+ * ciò che si conta e ciò che si mostra siano lo stesso insieme per
114
+ * costruzione; qui l'invariante si estende a ciò che si pota.
115
+ */
116
+ function purgeDraftFor() {
117
+ // La guardia di focus sta QUI e non solo dentro `selectedTaskOr`: il ramo
118
+ // bulk non passa da quella, perché il suo oggetto è la vista e non la
119
+ // selezione. Senza, `CANC` col focus sulle sessioni potrebbe potare in
120
+ // blocco senza che nessuna task fosse selezionata.
121
+ if (model.focus !== 'tasks') {
122
+ setNote('CANC → eliminare: seleziona una task (← per il pane)');
123
+ return null;
124
+ }
125
+ const bulk = model.purgeBulk;
126
+ const ids = bulk ? model.paneTasks.map((t) => t.id) : [];
127
+ if (!bulk) {
128
+ const task = selectedTaskOr('CANC', 'eliminare');
129
+ if (!task)
130
+ return null;
131
+ ids.push(task.id);
132
+ }
133
+ if (ids.length === 0) {
134
+ setNote('CANC → nessuna task in vista da eliminare');
135
+ return null;
136
+ }
137
+ // Ricalcolo al momento dell'AZIONE, non lettura del campionamento in lista:
138
+ // una folder sporcata dopo l'ultimo scan si presenterebbe come eliminabile.
139
+ const { clean, dirty } = splitTargets(purgeTargets(ids, tasksDir, cwd));
140
+ if (bulk) {
141
+ // Il gate del plugin esce 2 PRIMA di toccare qualsiasi cosa, quindi una
142
+ // sola folder sporca annullerebbe il purge di tutte le altre: si scartano
143
+ // a monte e il modale nomina sia le potate sia le scartate.
144
+ if (clean.length === 0) {
145
+ setNote(`CANC → ${dirty.length} task con file non tracciati in folder: eliminale una per una`);
146
+ return null;
147
+ }
148
+ return {
149
+ ids: clean.map((t) => t.id),
150
+ skipped: dirty.map((t) => t.id),
151
+ bulk: true,
152
+ ignored: null,
153
+ survivors: 0,
154
+ };
155
+ }
156
+ // Singola con superstiti → conferma a TRE uscite (D3): la scelta keep/purge
157
+ // è rara e distruttiva in modo diverso dal purge normale, e qui è visibile
158
+ // invece di stare davanti a ogni potatura.
159
+ const one = clean[0] ?? dirty[0];
160
+ return {
161
+ ids: [one.id],
162
+ skipped: [],
163
+ bulk: false,
164
+ ignored: one.survivors > 0 ? 'keep' : null,
165
+ survivors: one.survivors,
166
+ };
167
+ }
168
+ // ⏎ nella conferma: ordina la potatura a `loom-works:clean-tasks` e la
169
+ // osserva. Il deck non rimuove niente da sé — nessun `git rm`, nessuna
170
+ // riscrittura di tasks.md, nessuna `unlink`: quella sequenza è già
171
+ // implementata una volta, e averne due significherebbe due rimozioni capaci
172
+ // di divergere.
173
+ //
174
+ // La lista si riallinea al primo tick del poll e il set delle archiviabili
175
+ // allo scan che `doneSig` fa scattare quando la popolazione Done cambia; la
176
+ // selezione, keyed su id, cade sulla prima riga della vista (effect di
177
+ // validità) invece che su una posizione residua.
178
+ function purgeTasks(draft) {
179
+ const sid = randomUUID();
180
+ setNote(`⏳ eliminando ${draft.ids.length} task… ${idList(draft.ids, 6)}`);
181
+ const child = spawnCleanTasks(draft.ids, cwd, sid, draft.ignored, (ok, detail) => {
182
+ // L'esito si misura su `tasks.md`, NON sul solo `is_error`. Misurato: col
183
+ // gate `--ignored-files` che blocca, la skill spiega il blocco e chiude
184
+ // comunque `is_error: false` — un successo dichiarato su zero rimozioni.
185
+ // Il segnale robusto è quali degli ID bersaglio non hanno più una riga:
186
+ // deterministico, e indipendente da come la skill racconta sé stessa.
187
+ let survived = draft.ids;
188
+ try {
189
+ const now = new Set(loadTasks(tasksPath).map((t) => t.id));
190
+ survived = draft.ids.filter((id) => now.has(id));
191
+ }
192
+ catch {
193
+ // tasks.md illeggibile → nessuna verifica possibile, e allora si dice
194
+ // che non è stata fatta invece di dedurre un esito.
195
+ setNote(`⚠ ${draft.ids.length} task: esito non verificabile (tasks.md illeggibile)`);
196
+ return;
197
+ }
198
+ const removed = draft.ids.length - survived.length;
199
+ if (removed === draft.ids.length) {
200
+ // Il push non c'è, per scelta della skill: finché nessuno pusha, un
201
+ // altro worktree continua a vedere le task potate in tasks.md. Non
202
+ // dirlo lascerebbe leggere l'assenza di push come «fatto».
203
+ setNote(`✔ ${removed} task eliminate · commit locali, nessun push`);
204
+ }
205
+ else {
206
+ // Il gate `--ignored-files` (exit 2) può scattare lo stesso: la
207
+ // dirtiness è un dato campionato e una folder può sporcarsi fra il
208
+ // ricalcolo e l'apply. Il testo del result event è l'unica cosa che dice
209
+ // PERCHÉ, quindi entra nella riga invece di un ⚠ muto.
210
+ setNote(`⚠ ${removed}/${draft.ids.length} eliminate · restano ${idList(survived, 4)} · ${cut(detail || (ok ? '' : `${CLAUDE_CMD} -p`), 56)}`);
211
+ }
212
+ });
213
+ child?.on('error', () => setNote(`⚠ clean-tasks: '${CLAUDE_CMD}' non lanciabile`));
214
+ }
215
+ /** T41 — la bozza dell'edit seminata dai valori ATTUALI della task. */
216
+ function editDraftFor(task) {
217
+ // La priorità arriva dal glifo di tasks.md (già in `selTask`), lo stato dal
218
+ // suo glifo Prog; il progresso arbitrario dal campo `Progress` del task file
219
+ // — ma solo se è davvero custom (vedi `initialDetail`).
220
+ //
221
+ // Il titolo si semina dalla riga di tasks.md e non dall'H1 del task file per
222
+ // due ragioni: è la fonte che esiste SEMPRE (un task file può mancare), ed è
223
+ // il testo che l'utente sta guardando in lista quando preme `E`. Grezzo
224
+ // (`rawDesc`), non sanificato: rimandare a disco la forma sanificata
225
+ // riscriverebbe i glifi anche senza toccare il campo.
226
+ const prog = progName(task.prog) ?? 'todo';
227
+ return {
228
+ pri: priName(task.pri) ?? 'med',
229
+ prog,
230
+ detail: initialDetail(model.detail?.fields['Progress'] ?? '', prog),
231
+ title: task.rawDesc,
232
+ };
233
+ }
234
+ return { createTask, editDraftFor, writeEdit, purgeDraftFor, purgeTasks };
235
+ }
package/dist/ui/panes.js CHANGED
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  import { Box, Text } from 'ink';
6
6
  import { cut, pad, sanitize, termWidth } from '../width.js';
7
7
  import { paneTextWidth } from '../layout.js';
8
- import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, MODEL_W, SESSION_SEP, SID_CHARS, TASK_EMPTY, WARN, metaCount, modelShort, relTime, } from '../glyphs.js';
8
+ import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, MODEL_W, SID_CHARS, TASK_EMPTY, WARN, metaCount, modelShort, relTime, } from '../glyphs.js';
9
9
  import { TaskRow } from './task-row.js';
10
10
  import { META_ROWS, ROW_ALL, ROW_SPOT } from '../model.js';
11
11
  import { rowLabel, sessionTitle } from '../session-list.js';
@@ -94,24 +94,22 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
94
94
  ? 'nessuna conversazione nel progetto'
95
95
  : isSpot
96
96
  ? 'nessuna sessione libera'
97
- : 'nessuna sessione legata a questa task'), paneTextWidth(columns)) })) : (rows.map((row, i) => {
98
- // T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
99
- // box pesante (coerente con lo styling delle Done dimmate).
100
- if (row.kind === 'separator') {
101
- return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: SESSION_SEP }, `sep${i}`));
102
- }
97
+ : 'nessuna sessione legata a questa task'), paneTextWidth(columns)) })) : (rows.map((row) => {
103
98
  const sel = row.sessionId === selectedId;
104
99
  // T50 — pin stale: transcript sparito, nessuna Session da mostrare.
105
- // Riga navigabile e spinnabile (`p`), marcata, mai un crash.
106
- if (row.kind === 'pinned' && row.stale) {
100
+ // Riga navigabile e spinnabile (`p`), marcata, mai un crash. T133 D5 —
101
+ // compare nella sola vista `📌`, dove non ha accanto nessuna griglia da
102
+ // rispettare: resta testo libero.
103
+ if (row.kind === 'stale') {
107
104
  // T60 — anche qui la nota si taglia sul budget DERIVATO, non su un
108
105
  // 30 inchiodato: su un pane stretto quel valore fisso mandava la
109
106
  // riga oltre il bordo, e a ripararla arrivava `cli-truncate` (che
110
107
  // sfora di una colonna per emoji e mangia il bordo stesso).
111
108
  const staleNote = sessionNotes.get(row.sessionId);
112
109
  // La riga stale è libera (niente colonne: non ha né titolo né
113
- // data), ma il binding va detto lo stesso — è una pinnata, quindi
114
- // l'header del pane non ne dice l'appartenenza.
110
+ // data), ma il binding va detto lo stesso — vive nella vista `📌`,
111
+ // che raccoglie le pinnate di ogni task e quindi non ne dice
112
+ // l'appartenenza dall'header.
115
113
  const staleTask = bindings.get(row.sessionId) ?? null;
116
114
  const staleW = Math.max(0, paneTextWidth(columns) -
117
115
  (2 /* caret */ +
@@ -121,8 +119,8 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
121
119
  3 /* spazio + caporali */));
122
120
  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));
123
121
  }
124
- const s = row.session; // non-stale → session presente
125
- const isPinnedRow = row.kind === 'pinned';
122
+ const s = row.session;
123
+ const isPinnedRow = row.pinned;
126
124
  // T28 — un ramo eredita il titolo dell'origine: senza marcatore le due
127
125
  // righe sarebbero identiche a occhio.
128
126
  const forked = forkOf.has(s.sessionId);
@@ -139,13 +137,12 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
139
137
  // appartiene la conversazione (pin/task/spot), questa dice se è aperta
140
138
  // adesso. Farle condividere una cella perderebbe una delle due.
141
139
  const liveEntry = live.get(s.sessionId);
142
- // Stesso motivo per cui la colonna esiste: una pinnata resta in lista
143
- // qualunque sia il parent selezionato, quindi l'header non ne dice
144
- // l'appartenenza e la cella va riempita anche fuori dalla vista
145
- // "tutte". Sulle contestuali, dove l'header parla già, resta vuota
146
- // ma la cella è comunque larga `taskW`, o le colonne a destra
147
- // slitterebbero riga per riga.
148
- const taskCell = isAll || isPinnedRow ? (bound ?? TASK_EMPTY) : '';
140
+ // T133 D10 la colonna vive nella sola vista "tutte". Il ramo sulle
141
+ // pinnate esisteva perché una pinnata stava in lista anche sotto un
142
+ // parent che non era il suo; ora ogni riga è figlia del parent
143
+ // selezionato, quindi l'header parla già per tutte e la cella
144
+ // ripeterebbe N volte ciò che è scritto una riga sopra.
145
+ const taskCell = isAll ? (bound ?? TASK_EMPTY) : '';
149
146
  // T60 — colonne VERE: ogni cella fissa è larga esattamente quanto
150
147
  // dichiara, riempita di spazi con `pad` (che misura in colonne, non in
151
148
  // caratteri). Il marker va portato a 2 anche quando è `○`, largo 1:
@@ -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.54.1",
3
+ "version": "0.56.0",
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": {