@lamemind/loom-deck 0.70.0 → 0.71.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/frame.js CHANGED
@@ -15,7 +15,7 @@
15
15
  // leggeva dichiarata seicento righe più in basso. Il costo è calcolare il
16
16
  // budget anche quando il render esce presto su una schermata sostitutiva:
17
17
  // aritmetica su array già in memoria, e `layoutBudget` accetta ogni `Mode`.
18
- import { headerItems, inboxHeaderParts, sessionHeaderParts, taskHeaderParts } from './pane-header.js';
18
+ import { docHeaderParts, headerItems, inboxHeaderParts, sessionHeaderParts, taskHeaderParts, } from './pane-header.js';
19
19
  import { FRAME_TEXT_COL, HINT_ROW, inlineRegions, LAUNCH_ROW, PANE_TEXT_PAD, paneSpans, rowRegions, } from './mouse.js';
20
20
  import { cellWidth, launchLegend, LAUNCH_SEP } from './config.js';
21
21
  import { layoutBudget, windowRange } from './viewport.js';
@@ -23,6 +23,7 @@ import { sanitize, termWidth } from './width.js';
23
23
  import { MODEL_SHORT_LIST, WARN } from './glyphs.js';
24
24
  import { STATUS_MISSING } from './project-status.js';
25
25
  import { META_ROWS } from './model.js';
26
+ import { TASK_MODE_LEGEND } from './input-modes.js';
26
27
  import { rowIndexOf } from './session-list.js';
27
28
  import { VERSION } from './version.js';
28
29
  // La surface built-in del cappello, in testa alla riga launch. Non sta fra i
@@ -79,6 +80,7 @@ export function deckLegend(state) {
79
80
  const canSpawn = state.focus === 'tasks' && state.hasTask;
80
81
  const canResume = state.focus === 'sessions' && state.hasSession;
81
82
  const canOpenInbox = state.focus === 'inbox' && state.hasInbox;
83
+ const canOpenDoc = state.focus === 'doctree' && state.hasDoc;
82
84
  // T50 — il pin agisce su qualunque riga selezionata (anche stale, per
83
85
  // spinnarla); basta il focus sul pane e una selezione.
84
86
  const canPin = state.focus === 'sessions' && state.hasSessionId;
@@ -87,7 +89,7 @@ export function deckLegend(state) {
87
89
  ? ['⏎ detail', '^K/^P/^R spawn']
88
90
  : canResume
89
91
  ? ['⏎ resume']
90
- : canOpenInbox
92
+ : canOpenInbox || canOpenDoc
91
93
  ? ['⏎ apri']
92
94
  : []),
93
95
  // T112 — la voce nomina il BERSAGLIO, che cambia di taglia senza che
@@ -112,21 +114,27 @@ export function deckLegend(state) {
112
114
  // T155 — la voce nomina il GESTO, come le due sopra: «gitlink» da solo
113
115
  // direbbe l'oggetto e non che il tasto lo allinea con un commit.
114
116
  ...(state.gitlink ? ['^U bumpa gitlink'] : []),
115
- // T134 — la voce nomina il pane che il tasto MONTA, non quello montato:
116
- // `^B` scambia i due, e annunciare quello che si sta già guardando
117
- // direbbe il contrario di ciò che il tasto fa. Stessa regola di `CANC
118
- // elimina tutte`, che nomina il bersaglio e non il tasto.
119
- state.inboxPane ? '^B sessioni' : '^B inbox',
117
+ // T160 — la voce nomina il MODO che il tasto monta, non quello montato:
118
+ // `^B` li scambia, e annunciare quello che si sta già guardando direbbe il
119
+ // contrario di ciò che il tasto fa. Stessa regola di `CANC elimina tutte`,
120
+ // che nomina il bersaglio e non il tasto.
121
+ state.docMode ? '^B task' : '^B doc',
120
122
  // T161 — la voce nomina l'OGGETTO e non il gesto, al contrario di `^G`/
121
123
  // `^O`/`^U`: lì il tasto fa qualcosa (genera, bumpa) e il nome dell'oggetto
122
124
  // da solo sarebbe ambiguo, qui apre una pagina e basta.
123
125
  '^S azioni',
124
126
  '^F cerca',
125
- 'C nuova',
126
- 'E edit',
127
- 'S sort',
128
- 'F filtri',
129
- 'w salva',
127
+ // T160 — le voci dei tasti del mondo task vengono dall'ELENCO che governa
128
+ // anche il dispatch (`input-modes.ts` §TASK_MODE_KEYS), non da cinque
129
+ // stringhe scritte qui: due elenchi divergono al primo tasto aggiunto, e
130
+ // il tasto nuovo resterebbe annunciato mentre è inerte — cioè una legenda
131
+ // che descrive un deck che non esiste.
132
+ //
133
+ // In modo doc spariscono tutte. Le altre voci del mondo task (`⏎ detail`,
134
+ // `^K/^P/^R spawn`, `CANC elimina`) non sono qui perché sono già
135
+ // condizionate dal focus, che in modo doc non è mai `tasks`: spariscono da
136
+ // sé, senza una seconda guardia.
137
+ ...(state.docMode ? [] : TASK_MODE_LEGEND),
130
138
  ].join(' · '));
131
139
  }
132
140
  /**
@@ -191,17 +199,28 @@ export function launchRow(launch, columns) {
191
199
  */
192
200
  export const INDICATOR_SEP = ' ';
193
201
  /**
194
- * Il bottone che monta il pane inbox: `[ Inbox 📄 3/1/2 🚨2 ]`.
202
+ * Il bottone che monta il modo doc: `[ Doc 📄 3/1/2 🚨2 ]`.
195
203
  *
196
- * SEMPRE ACCESO, mai grigio (D7): è un pane, non un sottoinsieme che può essere
197
- * vuoto. I tre numeri sono le tre nature nell'ordine del catalogo; la sirena e
198
- * il suo contatore compaiono solo sopra la soglia.
204
+ * T160/P4 — nomina il MODO, non più il solo pane inbox di T134, perché è quello
205
+ * che `^B` monta adesso. I contatori restano quelli dell'inbox e non ne
206
+ * guadagnano di doc: un contatore dei flag doc obbligherebbe a tenere lo scan da
207
+ * quattro secondi acceso anche in modo task, cioè a pagarlo quando nessuno
208
+ * guarda l'albero — l'opposto dell'interruttore di `useDocScan`.
199
209
  *
200
- * Prima del primo scan i numeri non esistono ancora, e stampare `0/0/0`
201
- * direbbe «ho misurato e non c'è niente» — cioè la cosa sbagliata (D2
202
- * preflight: `missing` e guasto sono stati distinti). Il glifo di allerta si
203
- * AGGIUNGE ai numeri invece di sostituirli: un tentativo fallito lascia in
204
- * piedi l'esito dell'ultimo riuscito, che resta vero e ancora apribile.
210
+ * SEMPRE ACCESO, mai grigio (D7 di T134): è un modo, non un sottoinsieme che può
211
+ * essere vuoto. I tre numeri sono le tre nature nell'ordine del catalogo; la
212
+ * sirena e il suo contatore compaiono solo sopra la soglia.
213
+ *
214
+ * Il nome non si scambia col modo montato, a differenza della voce di legenda
215
+ * qui sopra: il bottone porta i contatori della coda inbox, che vive in modo
216
+ * doc, quindi è l'etichetta di quel mondo — e un `[ Task 📄 3/1/2 ]` leggerebbe
217
+ * quei tre numeri come se fossero delle task.
218
+ *
219
+ * Prima del primo scan i numeri non esistono ancora, e stampare `0/0/0` direbbe
220
+ * «ho misurato e non c'è niente» — cioè la cosa sbagliata (D2 preflight:
221
+ * `missing` e guasto sono stati distinti). Il glifo di allerta si AGGIUNGE ai
222
+ * numeri invece di sostituirli: un tentativo fallito lascia in piedi l'esito
223
+ * dell'ultimo riuscito, che resta vero e ancora apribile.
205
224
  */
206
225
  export function inboxButton(state) {
207
226
  const numbers = state.scanned
@@ -209,7 +228,7 @@ export function inboxButton(state) {
209
228
  : STATUS_MISSING;
210
229
  const siren = state.stale > 0 ? ` 🚨${state.stale}` : '';
211
230
  const warn = state.ok ? '' : ` ${WARN}`;
212
- return sanitize(`[ Inbox 📄 ${numbers}${siren}${warn} ]`);
231
+ return sanitize(`[ Doc 📄 ${numbers}${siren}${warn} ]`);
213
232
  }
214
233
  /**
215
234
  * L'indicatore hard-wrap: `wrap 77`.
@@ -333,6 +352,9 @@ export function frameGeometry(input) {
333
352
  const selInboxIndex = input.inboxFiles.findIndex((f) => f.path === input.selInboxPath);
334
353
  const inboxWin = windowRange(input.inboxFiles.length, selInboxIndex, budget.sessionRows);
335
354
  const windowInbox = input.inboxFiles.slice(inboxWin.start, inboxWin.end);
355
+ const selDocIndex = input.docRows.findIndex((r) => r.path === input.selDocPath);
356
+ const docWin = windowRange(input.docRows.length, selDocIndex, budget.sessionRows);
357
+ const windowDoc = input.docRows.slice(docWin.start, docWin.end);
336
358
  // T21 — geometria delle liste per l'hit-test del click, dalla STESSA
337
359
  // aritmetica che disegna i pane: le parti degli header escono dal modulo che
338
360
  // `ui/panes.tsx` consuma per renderle, le righe cliccabili sono le finestre
@@ -345,12 +367,16 @@ export function frameGeometry(input) {
345
367
  taskHeader: inlineRegions(headerItems(taskHeaderParts(input.taskCounts, input.taskViewId, taskWin.start, input.paneTasks.length - taskWin.end, input.columns)), spans.tasks.start + PANE_TEXT_PAD),
346
368
  sessionHeader: inlineRegions(headerItems(sessionHeaderParts(input.parentLabel, input.sessionCounts, input.sessionViewId, sessionWin.start, input.sessionRows.length - sessionWin.end, input.columns)), spans.sessions.start + PANE_TEXT_PAD),
347
369
  inboxHeader: inlineRegions(headerItems(inboxHeaderParts(input.inboxCounts, input.inboxViewId, inboxWin.start, input.inboxFiles.length - inboxWin.end, input.columns)), spans.sessions.start + PANE_TEXT_PAD),
370
+ // Il pane doc occupa lo slot SINISTRO, quindi le sue regioni partono
371
+ // dallo span del pane task — la posizione è del lato, non del contenuto.
372
+ docHeader: inlineRegions(headerItems(docHeaderParts(input.docCounts, input.docViewId, docWin.start, input.docRows.length - docWin.end, input.columns, input.docHasDirs)), spans.tasks.start + PANE_TEXT_PAD),
348
373
  // Con un errore di caricamento al posto delle task c'è la riga rossa:
349
374
  // restano cliccabili le sole righe meta.
350
375
  taskRows: META_ROWS + (input.hasLoadError ? 0 : windowTasks.length),
351
376
  sessionRows: windowRows.length,
352
377
  inboxRows: windowInbox.length,
353
- rightPane: input.rightPane,
378
+ docRows: windowDoc.length,
379
+ mode: input.deckMode,
354
380
  };
355
381
  return {
356
382
  budget,
@@ -360,6 +386,8 @@ export function frameGeometry(input) {
360
386
  windowRows,
361
387
  inboxWin,
362
388
  windowInbox,
389
+ docWin,
390
+ windowDoc,
363
391
  listGeometry,
364
392
  // Dimensione del terminale in CELLE (colonne×righe, mai pixel — un processo
365
393
  // dentro un terminale vede solo la griglia di caratteri) e versione. La
package/dist/hooks.js CHANGED
@@ -14,6 +14,7 @@ import { archivableIds, SCAN_INTERVAL_MS } from './archivable.js';
14
14
  import { commitTimes } from './commit-times.js';
15
15
  import { scanEpicHierarchy, EMPTY_EPIC_HIERARCHY } from './epic-hierarchy.js';
16
16
  import { scanInbox, INBOX_SCAN_INTERVAL_MS } from './inbox.js';
17
+ import { scanDocTree, DOC_SCAN_INTERVAL_MS } from './doc-tree.js';
17
18
  import { mixedCount, readWrapCache, runWrapScan, wrapCacheFile, wrapCount, } from './wrap-scan.js';
18
19
  import { purgeTargets } from './purge.js';
19
20
  import { loadSpawnOverrides, saveSpawnOverrides, } from './spawn-config.js';
@@ -381,6 +382,50 @@ export function useInboxScan(projectRoot, docsRoot) {
381
382
  }, []);
382
383
  return { ...state, scan };
383
384
  }
385
+ export function useDocScan(projectRoot, docsRoot, enabled) {
386
+ const [state, setState] = useState({
387
+ files: [],
388
+ dirs: [],
389
+ hasDirs: false,
390
+ ok: true,
391
+ scanned: false,
392
+ scanning: false,
393
+ });
394
+ const [epoch, setEpoch] = useState(0);
395
+ const busy = useRef(false);
396
+ const alive = useRef(true);
397
+ useEffect(() => {
398
+ alive.current = true;
399
+ return () => {
400
+ alive.current = false;
401
+ };
402
+ }, []);
403
+ const run = useCallback(() => {
404
+ if (busy.current)
405
+ return;
406
+ busy.current = true;
407
+ setState((prev) => ({ ...prev, scanning: true }));
408
+ scanDocTree(projectRoot, docsRoot).then((res) => {
409
+ busy.current = false;
410
+ if (!alive.current)
411
+ return;
412
+ setState((prev) => res.ok
413
+ ? { files: res.files, dirs: res.dirs, hasDirs: res.hasDirs, ok: true, scanned: true, scanning: false }
414
+ : { ...prev, ok: false, scanned: true, scanning: false });
415
+ });
416
+ }, [projectRoot, docsRoot]);
417
+ useEffect(() => {
418
+ if (!enabled)
419
+ return;
420
+ run();
421
+ const id = setInterval(run, DOC_SCAN_INTERVAL_MS);
422
+ return () => clearInterval(id);
423
+ }, [run, epoch, enabled]);
424
+ const scan = useCallback(() => {
425
+ setEpoch((e) => e + 1);
426
+ }, []);
427
+ return { ...state, scan };
428
+ }
384
429
  export function useWrapScan(projectRoot) {
385
430
  const path = useMemo(() => wrapCacheFile(projectRoot), [projectRoot]);
386
431
  const [cache, setCache] = useState(() => readWrapCache(path));
package/dist/inbox.js CHANGED
@@ -14,6 +14,7 @@ import { readFileSync } from 'node:fs';
14
14
  import { promisify } from 'node:util';
15
15
  import { basename, join } from 'node:path';
16
16
  import { pluginScript } from './plugin-cache.js';
17
+ import { sanitize } from './width.js';
17
18
  const execFileAsync = promisify(execFile);
18
19
  export const INBOX_METRICS_SCRIPT = 'scripts/docs/doc-metrics.sh';
19
20
  /**
@@ -216,10 +217,18 @@ export function inboxWords(f) {
216
217
  * il detail lo dice e tiene l'azione attiva, come fa quello della task con un
217
218
  * task file mancante: la skill risolve il file per nome, non per il testo che
218
219
  * il deck è riuscito a leggere.
220
+ *
221
+ * `sanitize` AL CONFINE DI CARICAMENTO, come `loadTaskFileText` e il gemello
222
+ * `loadDocText`: la catena dichiarata in `markdown.ts` è `sanitize →
223
+ * parseMarkdown → wrapWithOffsets`, e la prima non conserva la lunghezza —
224
+ * applicarla più a valle sposterebbe gli offset degli span appena calcolati. Un
225
+ * file di nozioni è testo libero e porta glifi su cui Ink e il terminale
226
+ * divergono: senza la sanificazione il wrap conta una colonna in meno di quante
227
+ * Ink ne disegna, e la riga esce dal box mangiandone il bordo.
219
228
  */
220
229
  export function loadInboxText(projectRoot, relPath) {
221
230
  try {
222
- return readFileSync(join(projectRoot, relPath), 'utf8');
231
+ return sanitize(readFileSync(join(projectRoot, relPath), 'utf8'));
223
232
  }
224
233
  catch {
225
234
  return null;
@@ -25,6 +25,7 @@ export const CAPTURING_MODES = [
25
25
  'detail',
26
26
  'status',
27
27
  'inbox',
28
+ 'doc',
28
29
  'wrap',
29
30
  'spawn',
30
31
  'reader',
@@ -60,6 +61,42 @@ export function captures(mode) {
60
61
  export const CTRL_DEROGATIONS = {
61
62
  detail: ['f'],
62
63
  };
64
+ export const TASK_MODE_KEYS = [
65
+ { chord: { kind: 'ctrl', input: 'k' }, label: '^K', legend: null },
66
+ { chord: { kind: 'ctrl', input: 'p' }, label: '^P', legend: null },
67
+ { chord: { kind: 'ctrl', input: 'r' }, label: '^R', legend: null },
68
+ { chord: { kind: 'delete' }, label: 'CANC', legend: null },
69
+ { chord: { kind: 'plain', input: 'C' }, label: 'C', legend: 'C nuova' },
70
+ { chord: { kind: 'plain', input: 'E' }, label: 'E', legend: 'E edit' },
71
+ { chord: { kind: 'plain', input: 'S' }, label: 'S', legend: 'S sort' },
72
+ { chord: { kind: 'plain', input: 'F' }, label: 'F', legend: 'F filtri' },
73
+ { chord: { kind: 'plain', input: 'w' }, label: 'w', legend: 'w salva' },
74
+ ];
75
+ /** Le voci di legenda dei tasti del mondo task, nell'ordine dell'elenco: è ciò
76
+ * che `deckLegend` emette in modo task e omette in modo doc. */
77
+ export const TASK_MODE_LEGEND = TASK_MODE_KEYS.map((k) => k.legend).filter((v) => v !== null);
78
+ /**
79
+ * Il nome del tasto del mondo task appena premuto, o `null` se non è uno di
80
+ * loro.
81
+ *
82
+ * I tre booleani arrivano dal chiamante invece del `Key` di Ink: questo modulo
83
+ * è un catalogo di dati e resta provabile senza montare nulla — la stessa
84
+ * ragione per cui `CAPTURING_MODES` è un array e non una proprietà di un hook.
85
+ */
86
+ export function taskModeKey(input, ctrl, del) {
87
+ for (const k of TASK_MODE_KEYS) {
88
+ const c = k.chord;
89
+ if (c.kind === 'delete' ? del : c.kind === 'ctrl' ? ctrl && c.input === input : !ctrl && !del && c.input === input) {
90
+ return k.label;
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+ /** La riga di stato di un tasto inerte: nomina il tasto, il modo in cui vive e
96
+ * come tornarci. */
97
+ export function inertNote(label) {
98
+ return `${label} → solo in modo task (^B)`;
99
+ }
63
100
  /**
64
101
  * T21 (mandata 2) — i modi che SCORRONO un contenuto lungo, cioè gli unici in
65
102
  * cui la rotella del mouse fa qualcosa.
@@ -74,7 +111,7 @@ export const CTRL_DEROGATIONS = {
74
111
  * Il custode è `MODE_WHEEL` in `input.ts`, un `Record<ScrollingMode, …>` che non
75
112
  * compila se un modo entra qui senza uno scroll da chiamare.
76
113
  */
77
- export const SCROLLING_MODES = ['detail', 'status', 'inbox', 'wrap', 'reader'];
114
+ export const SCROLLING_MODES = ['detail', 'status', 'inbox', 'doc', 'wrap', 'reader'];
78
115
  const SCROLLING = new Set(SCROLLING_MODES);
79
116
  /** `true` se la rotella scorre il contenuto del modo. */
80
117
  export function scrolls(mode) {
package/dist/input.js CHANGED
@@ -22,11 +22,12 @@ import { useApp, useInput } from 'ink';
22
22
  import { hitRegion, isWheel, listHit, takeMouse, wheelDir, WHEEL_LINES, } from './mouse.js';
23
23
  import { HINT_ROW, LAUNCH_ROW } from './frame.js';
24
24
  import { moveSelection } from './session-list.js';
25
- import { captures, scrolls } from './input-modes.js';
26
- import { META_ROWS, QUIT_WINDOW_MS } from './model.js';
25
+ import { captures, inertNote, scrolls, taskModeKey, } from './input-modes.js';
26
+ import { LEFT_PANE, META_ROWS, QUIT_WINDOW_MS, RIGHT_PANE } 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
+ import { inboxPrompt, loadInboxText } from './inbox.js';
30
+ import { dirListing, loadDocText } from './doc-tree.js';
30
31
  // T116 — l'avviso della prima pressione di `^C`. La durata è INTERPOLATA dalla
31
32
  // finestra, non ricopiata: un numero scritto a mano in un testo che promette un
32
33
  // comportamento diventa falso il giorno che la costante cambia, e nessuno
@@ -78,6 +79,7 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
78
79
  detail: overlays.sheet.onKey,
79
80
  status: overlays.status.onKey,
80
81
  inbox: overlays.inbox.onKey,
82
+ doc: overlays.doc.onKey,
81
83
  wrap: overlays.wrap.onKey,
82
84
  spawn: overlays.spawn.onKey,
83
85
  reader: overlays.search.onReaderKey,
@@ -98,6 +100,7 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
98
100
  detail: overlays.sheet.scroll,
99
101
  status: overlays.status.scroll,
100
102
  inbox: overlays.inbox.scroll,
103
+ doc: overlays.doc.scroll,
101
104
  wrap: overlays.wrap.scroll,
102
105
  reader: overlays.search.scrollReader,
103
106
  };
@@ -212,10 +215,24 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
212
215
  model.selectTaskView(hit.key);
213
216
  else if (hit.pane === 'inbox')
214
217
  model.selectInboxView(hit.key);
218
+ else if (hit.pane === 'doctree')
219
+ model.selectDocView(hit.key);
215
220
  else
216
221
  model.selectSessionView(hit.key);
217
222
  return;
218
223
  }
224
+ if (hit.pane === 'doctree') {
225
+ const r = frame.windowDoc[hit.index];
226
+ if (!r)
227
+ return;
228
+ if (model.focus === 'doctree' && r.path === model.selDocPath) {
229
+ onKey('', { ...NO_MODIFIERS, return: true });
230
+ return;
231
+ }
232
+ model.setFocus('doctree');
233
+ model.selectDocRow(frame.docWin.start + hit.index);
234
+ return;
235
+ }
219
236
  if (hit.pane === 'inbox') {
220
237
  const f = frame.windowInbox[hit.index];
221
238
  if (!f)
@@ -270,6 +287,23 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
270
287
  MODE_KEYS[mode](input, key);
271
288
  return;
272
289
  }
290
+ // T160 — i tasti del mondo task sono INERTI in modo doc, e lo dicono
291
+ // (`input-modes.ts` §TASK_MODE_KEYS). Il gate sta QUI, sopra tutti i rami e
292
+ // sotto il dispatch dei modi: un tasto gated non deve raggiungere né il ramo
293
+ // `ctrl` né quello delle lettere nude, e dentro un modo capturing la
294
+ // domanda non si pone perché il modo ha già consumato l'input.
295
+ //
296
+ // Un ramo per tasto con la sua guardia avrebbe funzionato uguale e sarebbe
297
+ // stato il modo per farne dimenticare uno: il gate legge l'elenco, e un
298
+ // tasto aggiunto lì è gated e smette di essere annunciato senza toccare
299
+ // questo file.
300
+ if (model.deckMode === 'doc') {
301
+ const gated = taskModeKey(input, key.ctrl, key.delete);
302
+ if (gated) {
303
+ setNote(inertNote(gated));
304
+ return;
305
+ }
306
+ }
273
307
  // T52/D1 — il ramo CTRL sta PRIMA di quelli su lettera nuda e li chiude
274
308
  // tutti. `CTRL+F` e `f` nudo arrivano con lo STESSO `input` ('f'),
275
309
  // distinguibili solo da `key.ctrl`: senza questa precedenza `CTRL+F`
@@ -352,11 +386,13 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
352
386
  overlays.spawn.openPage();
353
387
  }
354
388
  else if (input === 'b') {
355
- // T134/D8 preflight — `^B` (box/bacheca) scambia i due pane dello slot
356
- // destro. Non è un modale né una schermata: il pane resta uno dei due
357
- // riquadri della vista normale, quindi `tab` continua a ciclare le
358
- // viste DENTRO quello montato e `←→` a spostare il focus fra le colonne.
359
- model.toggleInboxPane();
389
+ // T160 — `^B` scambia il MODO del deck, cioè entrambi gli slot insieme.
390
+ // Erede del tasto di T134, che scambiava il solo pane destro: la
391
+ // mnemonica (box/bacheca) resta, il bersaglio è salito di un livello.
392
+ // Non è un modale né una schermata — i due riquadri della vista normale
393
+ // restano al loro posto, quindi `tab` continua a ciclare le viste DENTRO
394
+ // il pane a fuoco e `←→` a spostare il fuoco fra le colonne.
395
+ model.toggleDeckMode();
360
396
  }
361
397
  return;
362
398
  }
@@ -372,25 +408,28 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
372
408
  model.cycleView(key.shift ? -1 : 1);
373
409
  }
374
410
  else if (key.escape) {
375
- // T134/D8 preflight — `esc` in vista normale chiude il pane inbox e torna
376
- // alle sessioni. Resta inerte quando le sessioni sono già montate: `esc`
377
- // dice «esci da dove sei», e da lì non c'è niente da cui uscire.
378
- model.closeInboxPane();
411
+ // T160 — `esc` in vista normale torna al modo task. Resta inerte quando il
412
+ // modo task è già montato: `esc` dice «esci da dove sei», e da lì non c'è
413
+ // niente da cui uscire.
414
+ model.closeDocMode();
379
415
  }
380
416
  else if (key.leftArrow || key.rightArrow) {
381
- // Binding ASSOLUTO, non toggle: `←` porta sempre sui task e `→` sempre
382
- // sul pane DESTRO, quindi ripremere lo stesso tasto non riporta indietro.
383
- // È ciò che lo rende spaziale — la direzione indica una destinazione, e
384
- // con due colonne un toggle sarebbe indistinguibile solo per caso.
385
- // T134 — «destro» è il pane montato, non `sessions`: il tasto nomina una
386
- // posizione, e quale dei due la occupi è un'altra decisione (`^B`).
387
- model.setFocus(key.leftArrow ? 'tasks' : model.rightPane);
417
+ // Binding ASSOLUTO, non toggle: `←` porta sempre sul pane SINISTRO e `→`
418
+ // sempre sul destro, quindi ripremere lo stesso tasto non riporta
419
+ // indietro. È ciò che lo rende spaziale — la direzione indica una
420
+ // destinazione, e con due colonne un toggle sarebbe indistinguibile solo
421
+ // per caso.
422
+ // T160 — «sinistro» e «destro» sono posizioni, non contenuti: quale pane
423
+ // le occupi lo decide il modo, che è un'altra decisione (`^B`).
424
+ model.setFocus((key.leftArrow ? LEFT_PANE : RIGHT_PANE)[model.deckMode]);
388
425
  }
389
426
  else if (key.upArrow) {
390
427
  if (model.focus === 'tasks')
391
428
  model.moveTaskSel(-1);
392
429
  else if (model.focus === 'inbox')
393
430
  model.moveInboxSel(-1);
431
+ else if (model.focus === 'doctree')
432
+ model.moveDocSel(-1);
394
433
  else
395
434
  model.setSelSessionId((id) => moveSelection(model.sessionRows, id, -1));
396
435
  }
@@ -399,11 +438,33 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
399
438
  model.moveTaskSel(1);
400
439
  else if (model.focus === 'inbox')
401
440
  model.moveInboxSel(1);
441
+ else if (model.focus === 'doctree')
442
+ model.moveDocSel(1);
402
443
  else
403
444
  model.setSelSessionId((id) => moveSelection(model.sessionRows, id, 1));
404
445
  }
405
446
  else if (key.return) {
406
- if (model.focus === 'inbox') {
447
+ if (model.focus === 'doctree') {
448
+ // T160 — `⏎` apre il DETAIL del bersaglio, non la sessione: la sessione
449
+ // la apre il `⏎` di dentro, come sul pane inbox. Il gesto è lo stesso su
450
+ // un file e su una cartella (P10) — cambia cosa si legge nel corpo, non
451
+ // cosa si preme: il testo del file, oppure l'elenco che dice perché la
452
+ // cartella è flaggata.
453
+ const r = model.selDoc;
454
+ if (!r) {
455
+ setNote('nessuna riga doc selezionata');
456
+ }
457
+ else {
458
+ const { prompt, hint } = actions.rebalanceSpawn(r);
459
+ overlays.doc.open({
460
+ item: r,
461
+ text: r.kind === 'dir' ? dirListing(model.docData, r.path) : loadDocText(cwd, r.path),
462
+ prompt,
463
+ hint,
464
+ });
465
+ }
466
+ }
467
+ else if (model.focus === 'inbox') {
407
468
  // T134 — `⏎` apre il DETAIL del file, non la sessione: la sessione la
408
469
  // apre il `⏎` di dentro. Stessa scala del pane task, dove `⏎` apre il
409
470
  // detail e le combo restano per chi sa già cosa vuole — con la
@@ -413,7 +474,15 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
413
474
  if (!f)
414
475
  setNote('nessun file inbox selezionato');
415
476
  else
416
- overlays.inbox.open({ file: f, text: loadInboxText(cwd, f.path) });
477
+ overlays.inbox.open({
478
+ item: f,
479
+ text: loadInboxText(cwd, f.path),
480
+ // T134 — la skill la decide la NATURA del file, e quella mappa vive
481
+ // in un posto solo (`inboxPrompt`, `src/inbox.ts`): il prompt si
482
+ // compone qui, dove si apre lo sheet, e non dentro l'hook — che da
483
+ // T160 è condiviso con un bersaglio di tutt'altra natura.
484
+ prompt: inboxPrompt(f),
485
+ });
417
486
  }
418
487
  else if (model.focus === 'tasks') {
419
488
  // T66 — ⏎ apre il DETAIL, non più una sessione. Secondo rimappaggio in
package/dist/model.js CHANGED
@@ -31,6 +31,17 @@ export const ALL = Symbol('all');
31
31
  export const ROW_ALL = 0;
32
32
  export const ROW_SPOT = 1;
33
33
  export const META_ROWS = 2;
34
+ /** Il pane SINISTRO di ciascun modo, e il destro. Tabelle e non due ternari
35
+ * sparsi: `←→`, il toggle del modo e l'hit-test del mouse fanno tutti e tre la
36
+ * stessa domanda, e tre risposte scritte a mano divergono al primo modo
37
+ * aggiunto. */
38
+ export const LEFT_PANE = { task: 'tasks', doc: 'doctree' };
39
+ export const RIGHT_PANE = { task: 'sessions', doc: 'inbox' };
40
+ /** Il fuoco è sul pane di sinistra? Serve al toggle del modo, che deve portare
41
+ * chi guardava a sinistra sul pane sinistro dell'altro modo. */
42
+ export function isLeftPane(focus) {
43
+ return focus === LEFT_PANE.task || focus === LEFT_PANE.doc;
44
+ }
34
45
  // Modale sort a grammatica libera: un tasto per chiave, pressioni successive
35
46
  // ciclano asc → desc → fuori dalla chain.
36
47
  export const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id', c: 'commit' };
package/dist/mouse.js CHANGED
@@ -19,6 +19,7 @@
19
19
  // eventi che sotto render pesante si frammentano su stdin e leakano dentro
20
20
  // i campi di testo. Con soli 1000+1006 gli eventi sono due per click.
21
21
  import { termWidth } from './width.js';
22
+ import { LEFT_PANE, RIGHT_PANE } from './model.js';
22
23
  /**
23
24
  * Sequenza SGR: `ESC [ < b ; x ; y M|m`, con l'ESC opzionale (fatto ①).
24
25
  *
@@ -277,19 +278,28 @@ export function inlineRegions(parts, startCol) {
277
278
  * riga sort, spazio vuoto sotto la lista o fuori dai pane. */
278
279
  export function listHit(ev, g) {
279
280
  const spans = paneSpans(g.columns);
280
- const inTasks = ev.col >= spans.tasks.start && ev.col <= spans.tasks.end;
281
+ const inLeft = ev.col >= spans.tasks.start && ev.col <= spans.tasks.end;
281
282
  const inRight = ev.col >= spans.sessions.start && ev.col <= spans.sessions.end;
282
- if (!inTasks && !inRight)
283
+ if (!inLeft && !inRight)
283
284
  return null;
284
- const isInbox = g.rightPane === 'inbox';
285
- const pane = inTasks ? 'tasks' : isInbox ? 'inbox' : 'sessions';
286
- const rightHeader = isInbox ? g.inboxHeader : g.sessionHeader;
285
+ const isDoc = g.mode === 'doc';
286
+ const pane = inLeft ? LEFT_PANE[g.mode] : RIGHT_PANE[g.mode];
287
+ const leftHeader = isDoc ? g.docHeader : g.taskHeader;
288
+ const rightHeader = isDoc ? g.inboxHeader : g.sessionHeader;
287
289
  if (ev.row === PANE_HEADER_ROW) {
288
- const key = hitRegion(inTasks ? g.taskHeader : rightHeader, ev.col);
290
+ const key = hitRegion(inLeft ? leftHeader : rightHeader, ev.col);
289
291
  return key ? { pane, target: 'view', key } : null;
290
292
  }
291
- const first = inTasks ? TASK_LIST_ROW : PANE_BODY_ROW;
292
- const count = inTasks ? g.taskRows : isInbox ? g.inboxRows : g.sessionRows;
293
+ // Fra header e lista il pane task porta la riga sort/filtri, che non si
294
+ // seleziona; il pane doc non ce l'ha e la sua lista comincia una riga prima.
295
+ const first = inLeft && !isDoc ? TASK_LIST_ROW : PANE_BODY_ROW;
296
+ const count = inLeft
297
+ ? isDoc
298
+ ? g.docRows
299
+ : g.taskRows
300
+ : isDoc
301
+ ? g.inboxRows
302
+ : g.sessionRows;
293
303
  const index = ev.row - first;
294
304
  if (index < 0 || index >= count)
295
305
  return null;