@lamemind/loom-deck 0.56.1 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hooks.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // che sta fuori — dimensioni del terminale, tasks.md, sessioni del progetto,
3
3
  // task archiviabili, task file selezionato. Ognuno possiede la propria cadenza
4
4
  // di refresh e non sa nulla della vista che li consuma.
5
- import { useState, useEffect, useRef } from 'react';
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
6
  import { useStdout } from 'ink';
7
7
  import { statSync } from 'node:fs';
8
8
  import { loadTasks, loadTaskDetail } from './tasks.js';
@@ -10,6 +10,8 @@ import { discoverProjectSessions } from './sessions.js';
10
10
  import { discoverLiveSessions, liveSig } from './live-sessions.js';
11
11
  import { loadSessionIndex } from './task-index.js';
12
12
  import { archivableIds, SCAN_INTERVAL_MS } from './archivable.js';
13
+ import { scanInbox } from './inbox.js';
14
+ import { mixedCount, readWrapCache, runWrapScan, wrapCacheFile, wrapCount, } from './wrap-scan.js';
13
15
  import { purgeTargets } from './purge.js';
14
16
  import { POLL_MS } from './model.js';
15
17
  // Dimensioni del terminale, live sul resize.
@@ -216,6 +218,75 @@ export function useDirtyFolders(idsSig, tasksDir, projectRoot) {
216
218
  }, [idsSig, tasksDir, projectRoot]);
217
219
  return dirty;
218
220
  }
221
+ export function useInboxScan(projectRoot, docsRoot) {
222
+ const [state, setState] = useState({ files: [], ok: true, scanned: false });
223
+ useEffect(() => {
224
+ let alive = true;
225
+ const scan = () => {
226
+ scanInbox(projectRoot, docsRoot).then((res) => {
227
+ if (!alive)
228
+ return;
229
+ // Un tentativo fallito NON butta via l'esito dell'ultimo riuscito: la
230
+ // lista precedente resta vera e ancora apribile, e il guasto si dice
231
+ // col glifo di allerta accanto ai contatori. È la stessa regola già
232
+ // scritta per il project status (`finish` non riscrive la cache).
233
+ setState((prev) => res.ok ? { files: res.files, ok: true, scanned: true } : { ...prev, ok: false, scanned: true });
234
+ });
235
+ };
236
+ scan();
237
+ const id = setInterval(scan, SCAN_INTERVAL_MS);
238
+ return () => {
239
+ alive = false;
240
+ clearInterval(id);
241
+ };
242
+ }, [projectRoot, docsRoot]);
243
+ return state;
244
+ }
245
+ export function useWrapScan(projectRoot) {
246
+ const path = useMemo(() => wrapCacheFile(projectRoot), [projectRoot]);
247
+ const [cache, setCache] = useState(() => readWrapCache(path));
248
+ const [ok, setOk] = useState(true);
249
+ const [scanning, setScanning] = useState(false);
250
+ const [epoch, setEpoch] = useState(0);
251
+ // Il flag di corsa sta in un ref e non in stato: il ramo che lo legge gira
252
+ // NELLO STESSO tasto in cui potrebbe averlo scritto, e un valore di stato
253
+ // React arriverebbe al render dopo — cioè troppo tardi per impedire il
254
+ // secondo lancio.
255
+ const busy = useRef(false);
256
+ const run = useCallback(() => {
257
+ if (busy.current)
258
+ return;
259
+ busy.current = true;
260
+ setScanning(true);
261
+ runWrapScan(projectRoot).then((good) => {
262
+ busy.current = false;
263
+ setScanning(false);
264
+ setOk(good);
265
+ // La cache si rilegge SOLO su successo: un tentativo fallito lascia in
266
+ // piedi l'esito dell'ultimo riuscito, che resta vero e ancora apribile.
267
+ if (good)
268
+ setCache(readWrapCache(path));
269
+ });
270
+ }, [projectRoot, path]);
271
+ useEffect(() => {
272
+ const id = setInterval(run, SCAN_INTERVAL_MS);
273
+ return () => clearInterval(id);
274
+ }, [run, epoch]);
275
+ const scan = useCallback(() => {
276
+ setEpoch((e) => e + 1);
277
+ run();
278
+ }, [run]);
279
+ const files = cache?.files ?? [];
280
+ return {
281
+ files,
282
+ count: wrapCount(files),
283
+ mixed: mixedCount(files),
284
+ mtime: cache?.mtime ?? null,
285
+ ok,
286
+ scanning,
287
+ scan,
288
+ };
289
+ }
219
290
  // Legge il task file della task selezionata (Q1+B T20). On-id-change: navigare
220
291
  // con ↑↓ ricarica il dettaglio; leggere un singolo file 4-9KB è I/O triviale,
221
292
  // niente debounce serve per la tastiera. Il refresh del contenuto a file fermo
@@ -0,0 +1,67 @@
1
+ // T134 — Catalogo delle viste del pane inbox.
2
+ //
3
+ // Gemello di `pane-views.ts` per forma e invarianti, MODULO SEPARATO per il
4
+ // tipo: `SessionViewEntry.rows()` è tipizzata su `SessionRow[]` e ogni azione
5
+ // del pane destro (`p` pin, `N` nota, `A` riassegna, resume, fork) è scritta
6
+ // per una conversazione. Infilare una vista inbox in quel catalogo
7
+ // obbligherebbe a un'unione discriminata e a rendere inerte ogni azione sulla
8
+ // riga sbagliata (D6); con due cataloghi i tipi restano disgiunti per
9
+ // costruzione.
10
+ //
11
+ // Modulo PURO: nessun import da ink/react, nessun I/O. Il calcolo dei conteggi
12
+ // resta in `inbox.ts`, qui c'è solo la mappa di quali sottoinsiemi esistono,
13
+ // come si chiamano e con che numero.
14
+ //
15
+ // Invarianti ereditate dal catalogo dei due pane storici:
16
+ // - catalogo FISSO: le voci ci sono tutte anche a contatore 0, o navigando si
17
+ // spostano sotto le dita;
18
+ // - navigazione CICLICA con `tab`;
19
+ // - il numero di una voce coincide col numero di righe che la voce mostra —
20
+ // un header che dice un numero e una lista che ne mostra un altro è un
21
+ // contatore che mente, e non c'è schermata che lo dica.
22
+ import { NATURE, countByNatura, isQueued } from './inbox.js';
23
+ /** La somma dei tre contatori: quanto lavoro una skill può prendere da sola. */
24
+ export function queuedTotal(c) {
25
+ return c.nozioni + c.derivazione + c.sweep;
26
+ }
27
+ /** I contatori dell'header E del bottone di pane, da una fonte sola: i tre
28
+ * numeri che il bottone mostra sono gli stessi che dimensionano le tre viste
29
+ * di natura, quindi non possono dire cose diverse. */
30
+ export function inboxCounts(files) {
31
+ return { total: files.length, ...countByNatura(files) };
32
+ }
33
+ const NATURA_VIEWS = NATURE.map((natura) => ({
34
+ id: natura,
35
+ label: (c) => `${natura} (${c[natura]})`,
36
+ count: (c) => c[natura],
37
+ dim: (c) => c[natura] === 0,
38
+ rows: (files) => files.filter((f) => isQueued(f) && f.natura === natura),
39
+ empty: `nessun file ${natura} in coda`,
40
+ }));
41
+ export const INBOX_VIEWS = [
42
+ {
43
+ id: 'all',
44
+ label: (c) => `Tutti (${c.total})`,
45
+ count: (c) => c.total,
46
+ dim: (c) => queuedTotal(c) === 0,
47
+ // Nessun filtro: `parseInboxTsv` consegna già l'ordine per età decrescente
48
+ // (D8 — i più vecchi in cima sono i più urgenti).
49
+ rows: (files) => [...files],
50
+ empty: 'inbox vuota: nessun file da collocare',
51
+ },
52
+ ...NATURA_VIEWS,
53
+ ];
54
+ export function inboxView(id) {
55
+ return INBOX_VIEWS.find((v) => v.id === id) ?? INBOX_VIEWS[0];
56
+ }
57
+ export function selectInboxRows(id, files) {
58
+ return inboxView(id).rows(files);
59
+ }
60
+ /** Scorrimento ciclico, come i due cataloghi storici: `→` sull'ultima voce
61
+ * torna alla prima. Id ignoto → prima voce, mai un indice negativo. */
62
+ export function cycleInboxView(current, delta) {
63
+ const at = INBOX_VIEWS.findIndex((v) => v.id === current);
64
+ if (at < 0)
65
+ return INBOX_VIEWS[0].id;
66
+ return INBOX_VIEWS[(at + delta + INBOX_VIEWS.length) % INBOX_VIEWS.length].id;
67
+ }
package/dist/inbox.js CHANGED
@@ -10,8 +10,9 @@
10
10
  // Lettore puro + spawn: nessun React, nessuna resa. È l'unico modo di provarlo
11
11
  // senza pseudo-terminale.
12
12
  import { execFile } from 'node:child_process';
13
+ import { readFileSync } from 'node:fs';
13
14
  import { promisify } from 'node:util';
14
- import { basename } from 'node:path';
15
+ import { basename, join } from 'node:path';
15
16
  import { pluginScript } from './plugin-cache.js';
16
17
  const execFileAsync = promisify(execFile);
17
18
  export const INBOX_METRICS_SCRIPT = 'scripts/docs/doc-metrics.sh';
@@ -87,6 +88,32 @@ export function ageHours(created, now) {
87
88
  export function isQueued(f) {
88
89
  return f.drainable && !f.branch && f.natura !== 'malformato';
89
90
  }
91
+ /**
92
+ * Abbreviazione a larghezza fissa della natura, per la colonna della riga.
93
+ *
94
+ * CHIESTE e non derivate con uno `slice(0,3)`, come le short dei modelli:
95
+ * `der` e `swp` non coincidono col troncamento (`der`/`swe`), e una regola che
96
+ * sbaglia su due voci su quattro non è una regola.
97
+ */
98
+ export const NATURA_SHORT = {
99
+ nozioni: 'noz',
100
+ derivazione: 'der',
101
+ sweep: 'swp',
102
+ malformato: '???',
103
+ };
104
+ /** Larghezza COSTANTE della colonna natura: il dominio è chiuso e le short
105
+ * sono tutte larghe 3, quindi misurarla a ogni render calcolerebbe un numero
106
+ * già noto. */
107
+ export const NATURA_W = 3;
108
+ export function inboxMark(f) {
109
+ if (f.natura === 'malformato')
110
+ return 'broken';
111
+ if (f.branch)
112
+ return 'branched';
113
+ if (!f.drainable)
114
+ return 'held';
115
+ return 'queued';
116
+ }
90
117
  /**
91
118
  * Un contatore per natura, sui soli file in coda. Un `malformato` non ha natura
92
119
  * e non entra in nessuno dei tre — ma resta in lista, perché nasconderlo
@@ -112,6 +139,61 @@ export const DEFAULT_INBOX_STALE_HOURS = 48;
112
139
  export function staleCount(files, hours, now) {
113
140
  return files.filter((f) => isQueued(f) && ageHours(f.created, now) >= hours).length;
114
141
  }
142
+ /**
143
+ * La skill che consuma ogni natura (D8). Il file viaggia come BASENAME e non
144
+ * come path: la grammatica di matching è identica per le tre — path completo,
145
+ * basename con o senza `.md`, case-insensitive contro `{docs_root}/inbox/` —
146
+ * quindi il deck non deve conoscere il path, e non lo compone.
147
+ */
148
+ const DRAIN_SKILL = {
149
+ nozioni: 'drain-notions',
150
+ derivazione: 'derive-notions',
151
+ sweep: 'align-doc',
152
+ };
153
+ /**
154
+ * Il prompt della sessione che il deck apre su un file inbox.
155
+ *
156
+ * Su un `malformato` NON è un drain ma una RIPARAZIONE: nessuna delle tre skill
157
+ * prende un file senza natura, quindi offrirgliene una significherebbe aprire
158
+ * una sessione destinata a fermarsi allo step 0.
159
+ *
160
+ * Nessuna guardia sul token `drainable` né sul branch (D5): le tre skill
161
+ * dichiarano tutte, con le stesse parole, che un file NOMINATO si esegue anche
162
+ * senza `drainable` — quel token governa la coda automatica del notturno, non
163
+ * il permesso di eseguire, e nominare un file È la decisione che il token
164
+ * dichiarerebbe. Il solo `branch:` lo rifiutano loro, e lo fanno per chiunque:
165
+ * replicarlo qui aggiungerebbe una seconda copia divergibile per impedire ciò
166
+ * che l'utente ha appena chiesto.
167
+ *
168
+ * Niente backtick nel testo: il prompt attraversa `--prompt` come argv singolo
169
+ * e poi `bash -lc` dentro `deck-run`, dove viene quotato ad apici singoli. Con
170
+ * quel quoting un backtick sarebbe inerte, ma il testo lo si legge anche in
171
+ * riga di stato e in un titolo di tab — un carattere che non serve non si
172
+ * spende.
173
+ */
174
+ export function inboxPrompt(f) {
175
+ if (f.natura === 'malformato') {
176
+ return (`il file inbox ${f.basename} non ha un marker leggibile: nessuna delle tre skill di drain ` +
177
+ 'lo prende. aprilo, stabilisci di che natura e (nozioni, derivazione o sweep) e riporta il ' +
178
+ 'marker alla grammatica di scripts/docs/inbox.sh del plugin. non drenare niente finche il ' +
179
+ 'marker non e valido.');
180
+ }
181
+ return `/loom-works:${DRAIN_SKILL[f.natura]} ${f.basename}`;
182
+ }
183
+ /**
184
+ * Il testo del file inbox, per il detail fullscreen. `null` = illeggibile —
185
+ * il detail lo dice e tiene l'azione attiva, come fa quello della task con un
186
+ * task file mancante: la skill risolve il file per nome, non per il testo che
187
+ * il deck è riuscito a leggere.
188
+ */
189
+ export function loadInboxText(projectRoot, relPath) {
190
+ try {
191
+ return readFileSync(join(projectRoot, relPath), 'utf8');
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ }
115
197
  /**
116
198
  * Invoca la misura e ne parsa l'output.
117
199
  *
@@ -24,6 +24,8 @@
24
24
  export const CAPTURING_MODES = [
25
25
  'detail',
26
26
  'status',
27
+ 'inbox',
28
+ 'wrap',
27
29
  'reader',
28
30
  'search',
29
31
  'assign',
@@ -71,7 +73,7 @@ export const CTRL_DEROGATIONS = {
71
73
  * Il custode è `MODE_WHEEL` in `input.ts`, un `Record<ScrollingMode, …>` che non
72
74
  * compila se un modo entra qui senza uno scroll da chiamare.
73
75
  */
74
- export const SCROLLING_MODES = ['detail', 'status', 'reader'];
76
+ export const SCROLLING_MODES = ['detail', 'status', 'inbox', 'wrap', 'reader'];
75
77
  const SCROLLING = new Set(SCROLLING_MODES);
76
78
  /** `true` se la rotella scorre il contenuto del modo. */
77
79
  export function scrolls(mode) {
package/dist/input.js CHANGED
@@ -20,12 +20,13 @@
20
20
  import { useEffect, useRef } from 'react';
21
21
  import { useApp, useInput } from 'ink';
22
22
  import { hitRegion, isWheel, listHit, takeMouse, wheelDir, WHEEL_LINES, } from './mouse.js';
23
- import { LAUNCH_ROW } from './frame.js';
23
+ import { HINT_ROW, LAUNCH_ROW } from './frame.js';
24
24
  import { moveSelection } from './session-list.js';
25
25
  import { captures, scrolls } from './input-modes.js';
26
26
  import { META_ROWS, QUIT_WINDOW_MS } from './model.js';
27
27
  import { TASK_VIEWS, taskView } from './pane-views.js';
28
28
  import { loadTaskFileText } from './tasks.js';
29
+ import { loadInboxText } from './inbox.js';
29
30
  // T116 — l'avviso della prima pressione di `^C`. La durata è INTERPOLATA dalla
30
31
  // finestra, non ricopiata: un numero scritto a mano in un testo che promette un
31
32
  // comportamento diventa falso il giorno che la costante cambia, e nessuno
@@ -52,7 +53,7 @@ export const NO_MODIFIERS = {
52
53
  delete: false,
53
54
  meta: false,
54
55
  };
55
- export function useDeckInput({ tasksDir, mode, setMode, setNote, model, actions, overlays, frame, launchRegions, }) {
56
+ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, actions, overlays, frame, launchRegions, indicatorRegions, }) {
56
57
  // T116 — uscita a doppio `^C`. Il timer È lo stato dell'armamento: finché il
57
58
  // handle esiste la finestra è aperta, e non serve un secondo stato da tenere
58
59
  // in fase con lui. In un `useRef` e non in `useState` perché il ramo di uscita
@@ -76,6 +77,8 @@ export function useDeckInput({ tasksDir, mode, setMode, setNote, model, actions,
76
77
  const MODE_KEYS = {
77
78
  detail: overlays.sheet.onKey,
78
79
  status: overlays.status.onKey,
80
+ inbox: overlays.inbox.onKey,
81
+ wrap: overlays.wrap.onKey,
79
82
  reader: overlays.search.onReaderKey,
80
83
  search: overlays.search.onSearchKey,
81
84
  assign: overlays.assign.onKey,
@@ -93,6 +96,8 @@ export function useDeckInput({ tasksDir, mode, setMode, setNote, model, actions,
93
96
  const MODE_WHEEL = {
94
97
  detail: overlays.sheet.scroll,
95
98
  status: overlays.status.scroll,
99
+ inbox: overlays.inbox.scroll,
100
+ wrap: overlays.wrap.scroll,
96
101
  reader: overlays.search.scrollReader,
97
102
  };
98
103
  /**
@@ -167,6 +172,15 @@ export function useDeckInput({ tasksDir, mode, setMode, setNote, model, actions,
167
172
  onKey(hit, NO_MODIFIERS);
168
173
  return;
169
174
  }
175
+ if (ev.row === HINT_ROW) {
176
+ // T134 — stessa porta, con i modificatori: gli indicatori rappresentano
177
+ // combo `ctrl`, e una chiave nuda le farebbe cadere nel ramo delle lettere
178
+ // (`b` non è legata a niente oggi, ma `w` salva la vista).
179
+ const hit = hitRegion(indicatorRegions, ev.col);
180
+ if (hit)
181
+ onKey(hit.slice(1), { ...NO_MODIFIERS, ctrl: true });
182
+ return;
183
+ }
170
184
  onListClick(ev);
171
185
  }
172
186
  /**
@@ -195,10 +209,24 @@ export function useDeckInput({ tasksDir, mode, setMode, setNote, model, actions,
195
209
  model.setFocus(hit.pane);
196
210
  if (hit.pane === 'tasks')
197
211
  model.selectTaskView(hit.key);
212
+ else if (hit.pane === 'inbox')
213
+ model.selectInboxView(hit.key);
198
214
  else
199
215
  model.selectSessionView(hit.key);
200
216
  return;
201
217
  }
218
+ if (hit.pane === 'inbox') {
219
+ const f = frame.windowInbox[hit.index];
220
+ if (!f)
221
+ return;
222
+ if (model.focus === 'inbox' && f.path === model.selInboxPath) {
223
+ onKey('', { ...NO_MODIFIERS, return: true });
224
+ return;
225
+ }
226
+ model.setFocus('inbox');
227
+ model.selectInboxRow(frame.inboxWin.start + hit.index);
228
+ return;
229
+ }
202
230
  if (hit.pane === 'tasks') {
203
231
  // Le due righe meta hanno indice fisso; le task riportano l'indice di
204
232
  // finestra a quello della lista completa, su cui è keyata la selezione.
@@ -274,6 +302,33 @@ export function useDeckInput({ tasksDir, mode, setMode, setNote, model, actions,
274
302
  else if (input === 'o') {
275
303
  overlays.status.open();
276
304
  }
305
+ else if (input === 'w') {
306
+ // T134/D8 preflight — `^W` APRE la lista, `^E` ESEGUE lo scan: generare
307
+ // e aprire restano due tasti distinti per la stessa ragione di `^G`/`^O`
308
+ // — aprire un dato vecchio deve costare zero, e generare non deve essere
309
+ // un effetto collaterale del guardare. Qui la posta è più alta che sul
310
+ // project status: lo scan cammina l'albero del progetto intero,
311
+ // submodule compresi.
312
+ //
313
+ // `^S` sarebbe la mnemonica migliore per «scan» ed è scartata di
314
+ // proposito: nel repo esistono due commenti in contraddizione sul suo
315
+ // conto (`model.ts` dice che il raw mode di Ink disattiva il
316
+ // flow-control XON/XOFF e quindi passa pulito, `cli.tsx` dice che è
317
+ // stato evitato proprio perché il terminale lo intercetta). Finché la
318
+ // contraddizione non è risolta con una misura, un tasto nuovo non ci si
319
+ // appoggia.
320
+ overlays.wrap.open();
321
+ }
322
+ else if (input === 'e') {
323
+ overlays.wrap.scan();
324
+ }
325
+ else if (input === 'b') {
326
+ // T134/D8 preflight — `^B` (box/bacheca) scambia i due pane dello slot
327
+ // destro. Non è un modale né una schermata: il pane resta uno dei due
328
+ // riquadri della vista normale, quindi `tab` continua a ciclare le
329
+ // viste DENTRO quello montato e `←→` a spostare il focus fra le colonne.
330
+ model.toggleInboxPane();
331
+ }
277
332
  return;
278
333
  }
279
334
  if (key.tab) {
@@ -287,27 +342,51 @@ export function useDeckInput({ tasksDir, mode, setMode, setNote, model, actions,
287
342
  // voci risparmia quattro pressioni.
288
343
  model.cycleView(key.shift ? -1 : 1);
289
344
  }
345
+ else if (key.escape) {
346
+ // T134/D8 preflight — `esc` in vista normale chiude il pane inbox e torna
347
+ // alle sessioni. Resta inerte quando le sessioni sono già montate: `esc`
348
+ // dice «esci da dove sei», e da lì non c'è niente da cui uscire.
349
+ model.closeInboxPane();
350
+ }
290
351
  else if (key.leftArrow || key.rightArrow) {
291
352
  // Binding ASSOLUTO, non toggle: `←` porta sempre sui task e `→` sempre
292
- // sulle sessioni, quindi ripremere lo stesso tasto non riporta indietro.
353
+ // sul pane DESTRO, quindi ripremere lo stesso tasto non riporta indietro.
293
354
  // È ciò che lo rende spaziale — la direzione indica una destinazione, e
294
- // con due soli pane un toggle sarebbe indistinguibile solo per caso.
295
- model.setFocus(key.leftArrow ? 'tasks' : 'sessions');
355
+ // con due colonne un toggle sarebbe indistinguibile solo per caso.
356
+ // T134 «destro» è il pane montato, non `sessions`: il tasto nomina una
357
+ // posizione, e quale dei due la occupi è un'altra decisione (`^B`).
358
+ model.setFocus(key.leftArrow ? 'tasks' : model.rightPane);
296
359
  }
297
360
  else if (key.upArrow) {
298
361
  if (model.focus === 'tasks')
299
362
  model.moveTaskSel(-1);
363
+ else if (model.focus === 'inbox')
364
+ model.moveInboxSel(-1);
300
365
  else
301
366
  model.setSelSessionId((id) => moveSelection(model.sessionRows, id, -1));
302
367
  }
303
368
  else if (key.downArrow) {
304
369
  if (model.focus === 'tasks')
305
370
  model.moveTaskSel(1);
371
+ else if (model.focus === 'inbox')
372
+ model.moveInboxSel(1);
306
373
  else
307
374
  model.setSelSessionId((id) => moveSelection(model.sessionRows, id, 1));
308
375
  }
309
376
  else if (key.return) {
310
- if (model.focus === 'tasks') {
377
+ if (model.focus === 'inbox') {
378
+ // T134 — `⏎` apre il DETAIL del file, non la sessione: la sessione la
379
+ // apre il `⏎` di dentro. Stessa scala del pane task, dove `⏎` apre il
380
+ // detail e le combo restano per chi sa già cosa vuole — con la
381
+ // differenza che qui non esiste un acceleratore, perché la skill non è
382
+ // una scelta ma una conseguenza della natura del file.
383
+ const f = model.selInbox;
384
+ if (!f)
385
+ setNote('nessun file inbox selezionato');
386
+ else
387
+ overlays.inbox.open({ file: f, text: loadInboxText(cwd, f.path) });
388
+ }
389
+ else if (model.focus === 'tasks') {
311
390
  // T66 — ⏎ apre il DETAIL, non più una sessione. Secondo rimappaggio in
312
391
  // due task (T56 lo spostò da recap a sessione a mani nude), e la
313
392
  // direzione è una sola: da azione singola a punto d'ingresso. Il tasto
package/dist/mouse.js CHANGED
@@ -196,6 +196,16 @@ export function hitRegion(regions, col) {
196
196
  * dipende da quanto è popolato il progetto.
197
197
  */
198
198
  export const LAUNCH_ROW = 4;
199
+ /**
200
+ * T134 — riga della legenda tasti, che ospita anche gli INDICATORI ancorati a
201
+ * destra (bottone inbox, contatore hard-wrap). Sta subito sopra la riga launch,
202
+ * e come lei è incondizionata: bordo superiore, testata, legenda.
203
+ *
204
+ * Gli indicatori sono cliccabili, quindi questa riga entra nell'hit-test come la
205
+ * launch — e come lei il click NON chiama l'azione: rientra dalla porta della
206
+ * tastiera con la combo che l'indicatore rappresenta.
207
+ */
208
+ export const HINT_ROW = LAUNCH_ROW - 1;
199
209
  /** Colonna (1-based) del primo carattere di testo dentro il box esterno:
200
210
  * bordo + `paddingX={1}`. */
201
211
  export const FRAME_TEXT_COL = 3;
@@ -268,16 +278,18 @@ export function inlineRegions(parts, startCol) {
268
278
  export function listHit(ev, g) {
269
279
  const spans = paneSpans(g.columns);
270
280
  const inTasks = ev.col >= spans.tasks.start && ev.col <= spans.tasks.end;
271
- const inSessions = ev.col >= spans.sessions.start && ev.col <= spans.sessions.end;
272
- if (!inTasks && !inSessions)
281
+ const inRight = ev.col >= spans.sessions.start && ev.col <= spans.sessions.end;
282
+ if (!inTasks && !inRight)
273
283
  return null;
274
- const pane = inTasks ? 'tasks' : 'sessions';
284
+ const isInbox = g.rightPane === 'inbox';
285
+ const pane = inTasks ? 'tasks' : isInbox ? 'inbox' : 'sessions';
286
+ const rightHeader = isInbox ? g.inboxHeader : g.sessionHeader;
275
287
  if (ev.row === PANE_HEADER_ROW) {
276
- const key = hitRegion(inTasks ? g.taskHeader : g.sessionHeader, ev.col);
288
+ const key = hitRegion(inTasks ? g.taskHeader : rightHeader, ev.col);
277
289
  return key ? { pane, target: 'view', key } : null;
278
290
  }
279
291
  const first = inTasks ? TASK_LIST_ROW : PANE_BODY_ROW;
280
- const count = inTasks ? g.taskRows : g.sessionRows;
292
+ const count = inTasks ? g.taskRows : isInbox ? g.inboxRows : g.sessionRows;
281
293
  const index = ev.row - first;
282
294
  if (index < 0 || index >= count)
283
295
  return null;
@@ -0,0 +1,90 @@
1
+ // T134 — il DETAIL di un file inbox: sesta schermata sostitutiva.
2
+ //
3
+ // Stessa forma dello `sheet` della task e per la stessa ragione: il file è
4
+ // FOTOGRAFATO all'apertura — nome, natura, testo — e non è una vista sulla
5
+ // selezione corrente. L'overlay copre la lista, quindi l'oggetto dell'azione
6
+ // deve restare quello che si è scelto anche se un tick dello scan spostasse la
7
+ // selezione sotto.
8
+ //
9
+ // Molto più magro dello sheet: nessuna area di compilazione. Lo sheet ha
10
+ // quattro righe perché quattro parametri dello spawn sono una scelta (azione,
11
+ // prompt, modello, titolo); qui non ce n'è nessuna — la skill la decide la
12
+ // natura (D8), il modello è `opus` per tutte e tre (D11 preflight), la sessione
13
+ // nasce nuda (D10 preflight). Restano la lettura e un tasto.
14
+ //
15
+ // Lo spawn NON sta qui: arriva come callback `onDrain`, per lo stesso confine
16
+ // di `useSheetOverlay` e `useSearchOverlay` — un hook di overlay non esce dal
17
+ // deck.
18
+ import { useMemo, useState } from 'react';
19
+ import { inboxDetailCapacity, pageStep } from '../viewport.js';
20
+ import { wrapWithOffsets } from '../width.js';
21
+ import { parseMarkdown } from '../markdown.js';
22
+ import { inboxPrompt } from '../inbox.js';
23
+ export function useInboxOverlay(deps) {
24
+ const { rows, columns, setMode, setNote, onDrain } = deps;
25
+ const [sheet, setSheet] = useState(null);
26
+ const [top, setTop] = useState(0);
27
+ // Cornici da scalare: box esterno (2 bordi + 2 padding) + box testo (2 bordi
28
+ // + 2 padding) = 8, come il detail della task.
29
+ const width = Math.max(20, (columns || 80) - 8);
30
+ // Il markdown si rende PRIMA del wrap: `**foo**` occupa 3 colonne rese e 7
31
+ // grezze, quindi wrappare sui marker manderebbe a capo su un conteggio che il
32
+ // terminale non disegna. Memo separato dal wrap perché il parse dipende solo
33
+ // dal testo: un resize ri-wrappa, non ri-parsa.
34
+ const doc = useMemo(() => (sheet?.text ? parseMarkdown(sheet.text) : null), [sheet]);
35
+ const lines = useMemo(() => (doc ? wrapWithOffsets(doc.text, width) : []), [doc, width]);
36
+ const capacity = inboxDetailCapacity(rows);
37
+ const maxTop = Math.max(0, lines.length - capacity);
38
+ /** Il prompt che partirà: derivato una volta e mostrato in fondo alla
39
+ * schermata, così `⏎` non è mai un salto nel buio. */
40
+ const prompt = sheet ? inboxPrompt(sheet.file) : '';
41
+ function open(next) {
42
+ setSheet(next);
43
+ setTop(0);
44
+ setNote('');
45
+ setMode('inbox');
46
+ }
47
+ function close() {
48
+ setMode('normal');
49
+ setSheet(null);
50
+ }
51
+ function scroll(delta) {
52
+ setTop((t) => Math.max(0, Math.min(maxTop, t + delta)));
53
+ }
54
+ function onKey(input, key) {
55
+ if (key.escape) {
56
+ close();
57
+ }
58
+ else if (key.return) {
59
+ // Si chiude PRIMA di spawnare, come lo sheet della task: la schermata ha
60
+ // finito il suo lavoro, e lasciarla aperta sopra una tab appena nata
61
+ // farebbe credere che ci sia dell'altro da decidere.
62
+ const s = sheet;
63
+ close();
64
+ if (s)
65
+ onDrain(s.file, inboxPrompt(s.file));
66
+ }
67
+ else if (key.upArrow) {
68
+ scroll(-1);
69
+ }
70
+ else if (key.downArrow) {
71
+ scroll(1);
72
+ }
73
+ else if (key.pageUp) {
74
+ scroll(-pageStep(capacity));
75
+ }
76
+ else if (key.pageDown) {
77
+ scroll(pageStep(capacity));
78
+ }
79
+ else if (input === 'g') {
80
+ // `g`/`G` sugli estremi, come reader, detail e project status: anche
81
+ // questa schermata scorre testo, quindi eredita lo stesso alfabeto invece
82
+ // di inventarne un secondo.
83
+ setTop(0);
84
+ }
85
+ else if (input === 'G') {
86
+ setTop(maxTop);
87
+ }
88
+ }
89
+ return { sheet, doc, lines, top, capacity, maxTop, prompt, open, onKey, scroll };
90
+ }