@lamemind/loom-deck 0.56.1 → 0.57.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.
- package/dist/actions.js +34 -1
- package/dist/cli.js +65 -9
- package/dist/deck-model.js +98 -1
- package/dist/frame.js +122 -4
- package/dist/glyphs.js +24 -0
- package/dist/hooks.js +72 -1
- package/dist/inbox-views.js +67 -0
- package/dist/inbox.js +83 -1
- package/dist/input-modes.js +3 -1
- package/dist/input.js +85 -6
- package/dist/mouse.js +17 -5
- package/dist/overlays/inbox.js +90 -0
- package/dist/overlays/wrap.js +144 -0
- package/dist/pane-header.js +25 -0
- package/dist/spawn.js +27 -0
- package/dist/tasks.js +17 -4
- package/dist/ui/inbox-screen.js +21 -0
- package/dist/ui/panes.js +67 -2
- package/dist/ui/preview.js +41 -3
- package/dist/ui/screens.js +57 -14
- package/dist/ui/wrap-screen.js +30 -0
- package/dist/viewport.js +60 -0
- package/dist/wrap-scan.js +30 -1
- package/package.json +1 -1
- package/scripts/deck-run +45 -9
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 {
|
|
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
|
*
|
package/dist/input-modes.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
295
|
-
|
|
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 === '
|
|
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
|
|
272
|
-
if (!inTasks && !
|
|
281
|
+
const inRight = ev.col >= spans.sessions.start && ev.col <= spans.sessions.end;
|
|
282
|
+
if (!inTasks && !inRight)
|
|
273
283
|
return null;
|
|
274
|
-
const
|
|
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 :
|
|
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
|
+
}
|