@lamemind/loom-deck 0.69.0 → 0.71.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/actions.js CHANGED
@@ -26,6 +26,7 @@ import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spaw
26
26
  import { resolveSpawnId } from './spawn-config.js';
27
27
  import { useTaskOps } from './task-ops.js';
28
28
  import { inboxWords } from './inbox.js';
29
+ import { docWords } from './doc-tree.js';
29
30
  import { wrapWords } from './wrap-scan.js';
30
31
  export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overrides, catalog, setNote, }) {
31
32
  // La riga di stato di OGNI spawn di sessione Claude: il comando esatto, come
@@ -386,6 +387,58 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
386
387
  spawned.child.on('error', () => setNote(`⚠ drain ${file.basename} fallito (${DECK_RUN})`));
387
388
  noteSpawn(spawned);
388
389
  }
390
+ /**
391
+ * T160 — la terna della riga `rebalance` sul bersaglio selezionato.
392
+ *
393
+ * Separata dallo spawn perché ha due chiamanti che la vogliono in due momenti
394
+ * diversi: la schermata, che MOSTRA il comando in fondo prima che `⏎` lo
395
+ * lanci, e l'attuatore, che lo lancia. Comporlo due volte darebbe due comandi
396
+ * capaci di divergere — e quello mostrato è la sola cosa che rende `⏎` una
397
+ * scelta invece di un salto nel buio.
398
+ *
399
+ * P9 — su una riga SENZA FLAG il prompt è vuoto, e il deck non spawna. Il
400
+ * flag resta un invito a guardare e non un ordine, quindi la skill su un
401
+ * bersaglio in equilibrio produrrebbe un report e nient'altro: aprire una
402
+ * conversazione per farselo dire è un giro di modello pagato per niente. La
403
+ * ragione viaggia accanto al vuoto (`hint`), o la riga muta si leggerebbe come
404
+ * un comando che non si è riusciti a comporre.
405
+ */
406
+ function rebalanceSpawn(row) {
407
+ if (row.flags.length === 0) {
408
+ return {
409
+ prompt: '',
410
+ hint: `${row.path} non porta flag: rebalance-doc non ha niente da fare qui`,
411
+ };
412
+ }
413
+ const d = spawnDefaults('rebalance', { target: row.path, words: docWords(row.path) });
414
+ return { prompt: d.prompt ?? '', hint: '' };
415
+ }
416
+ /**
417
+ * T160 — apre la sessione che RIORGANIZZA la topologia attorno a un bersaglio.
418
+ *
419
+ * Gemella di `drainInbox` e `unwrapPath` nella forma: sessione NUDA (il
420
+ * rebalance lavora sulla doc, non su una task), `sessionId` pinnato per poter
421
+ * scrivere la nota nel sidecar, titolo dalla riga di catalogo.
422
+ *
423
+ * Il modello della riga è `sonnet` ed è una cella FISSA, non una preferenza:
424
+ * `rebalance-doc` dichiara `model: sonnet` nel frontmatter e quel campo
425
+ * ri-timbra il modello della sessione qualunque `--model` arrivi dallo spawn.
426
+ * Passarlo comunque nell'argv resta giusto — lo spawn è deterministico e la
427
+ * riga di stato mostra il comando vero — ma configurarlo sarebbe una cella che
428
+ * mente.
429
+ */
430
+ function rebalanceDoc(row, prompt) {
431
+ if (!prompt)
432
+ return;
433
+ const sid = randomUUID();
434
+ const d = spawnDefaults('rebalance', { target: row.path, words: docWords(row.path) });
435
+ const title = d.title ?? '';
436
+ if (title)
437
+ appendNote(cwd, sid, title);
438
+ const spawned = spawnBare(cwd, prompt, d.model, sid, title);
439
+ spawned.child.on('error', () => setNote(`⚠ rebalance ${row.path} fallito (${DECK_RUN})`));
440
+ noteSpawn(spawned);
441
+ }
389
442
  /**
390
443
  * T134 — apre la sessione che SROTOLA l'hard-wrap di un path.
391
444
  *
@@ -463,6 +516,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
463
516
  togglePin,
464
517
  togglePriority,
465
518
  drainInbox,
519
+ rebalanceSpawn,
520
+ rebalanceDoc,
466
521
  unwrapPath,
467
522
  openTerminal,
468
523
  openClaude,
package/dist/cli.js CHANGED
@@ -32,7 +32,7 @@ import { useSearchOverlay } from './overlays/search.js';
32
32
  import { useSheetOverlay } from './overlays/sheet.js';
33
33
  import { useAssignOverlay } from './overlays/assign.js';
34
34
  import { useProjectStatus } from './overlays/status.js';
35
- import { useInboxOverlay } from './overlays/inbox.js';
35
+ import { useFileSheet } from './overlays/file-sheet.js';
36
36
  import { useWrapOverlay } from './overlays/wrap.js';
37
37
  import { useSpawnPage } from './overlays/spawn.js';
38
38
  import { useGitlink } from './overlays/gitlink.js';
@@ -41,7 +41,9 @@ import { useTextModals, useViewModals } from './overlays/modals.js';
41
41
  import { useTerminalSize, useSpawnConfig } from './hooks.js';
42
42
  import { EditModal, FilterModal, PurgeModal, SortModal } from './ui/modals.js';
43
43
  import { StatusHeadline } from './ui/status-screen.js';
44
- import { InboxPane, SessionsPane, TasksPane } from './ui/panes.js';
44
+ import { SessionsPane, TasksPane } from './ui/panes.js';
45
+ import { InboxPane } from './ui/inbox-pane.js';
46
+ import { DocPane } from './ui/doc-pane.js';
45
47
  import { PreviewPane, detailMetaOf } from './ui/preview.js';
46
48
  import { CompactNotice, HintBar, TextBox, screenFor } from './ui/screens.js';
47
49
  import { VERSION } from './version.js';
@@ -123,14 +125,27 @@ function Deck({ cwd, tasksPath, tasksDir }) {
123
125
  setMode,
124
126
  setNote,
125
127
  });
126
- // T134 — il detail di un file inbox. Come lo sheet della task riceve lo spawn
127
- // come callback: un hook di overlay non esce dal deck.
128
- const inbox = useInboxOverlay({
128
+ // T134/T160 — le DUE istanze dello sheet di file: la coda inbox e il bersaglio
129
+ // doc. Stesso hook (`useFileSheet`, P11), due modi distinti — `esc` da una
130
+ // torna al pane inbox, dall'altra al pane doc, e un modo condiviso
131
+ // obbligherebbe a ricordare altrove da dove si era entrati. Come lo sheet
132
+ // della task ricevono lo spawn come callback: un hook di overlay non esce dal
133
+ // deck.
134
+ const inbox = useFileSheet({
135
+ mode: 'inbox',
129
136
  rows,
130
137
  columns,
131
138
  setMode,
132
139
  setNote,
133
- onDrain: actions.drainInbox,
140
+ onEnter: actions.drainInbox,
141
+ });
142
+ const docSheet = useFileSheet({
143
+ mode: 'doc',
144
+ rows,
145
+ columns,
146
+ setMode,
147
+ setNote,
148
+ onEnter: actions.rebalanceDoc,
134
149
  });
135
150
  // T134 — l'hard-wrap. Come il project status non è solo un overlay: i suoi
136
151
  // numeri si leggono in testata a schermata chiusa, e la lista è una delle sue
@@ -188,6 +203,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
188
203
  search,
189
204
  status,
190
205
  inbox,
206
+ doc: docSheet,
191
207
  wrap,
192
208
  spawn,
193
209
  gitlink,
@@ -210,9 +226,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
210
226
  ? model.selInbox
211
227
  ? 'inbox'
212
228
  : 'none'
213
- : model.selSessionObj
214
- ? 'session'
215
- : 'none';
229
+ : model.focus === 'doctree'
230
+ ? model.selDoc
231
+ ? 'doc'
232
+ : 'none'
233
+ : model.selSessionObj
234
+ ? 'session'
235
+ : 'none';
216
236
  const detailParts = model.detail ? detailMetaOf(model.detail) : null;
217
237
  const launch = launchRow(model.launch, columns);
218
238
  // T154 — i bottoni del blocco selettore della nuda: stessa primitiva
@@ -262,11 +282,16 @@ function Deck({ cwd, tasksPath, tasksDir }) {
262
282
  sessionViewId: model.sessionViewId,
263
283
  parentLabel: model.parentLabel,
264
284
  hasLoadError: Boolean(model.loadError),
265
- rightPane: model.rightPane,
285
+ deckMode: model.deckMode,
266
286
  inboxFiles: model.inboxFiles,
267
287
  selInboxPath: model.selInboxPath,
268
288
  inboxCounts: model.inboxCounts,
269
289
  inboxViewId: model.inboxViewId,
290
+ docRows: model.docRows,
291
+ selDocPath: model.selDocPath,
292
+ docCounts: model.docCounts,
293
+ docViewId: model.docViewId,
294
+ docHasDirs: model.docHasDirs,
270
295
  });
271
296
  useDeckInput({
272
297
  cwd,
@@ -296,7 +321,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
296
321
  projectName: model.projectName,
297
322
  taskRowData: model.taskRowData,
298
323
  hiddenTasks: model.hiddenTasks,
299
- overlays: { assign, sheet, search, status, inbox, wrap, spawn },
324
+ overlays: { assign, sheet, search, status, inbox, doc: docSheet, wrap, spawn },
300
325
  });
301
326
  if (screen)
302
327
  return screen;
@@ -312,10 +337,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
312
337
  hasSession: model.selSessionObj !== null,
313
338
  hasSessionId: model.selSessionId !== null,
314
339
  purgeBulk: model.purgeBulk,
315
- inboxPane: model.rightPane === 'inbox',
340
+ docMode: model.deckMode === 'doc',
316
341
  hasInbox: model.selInbox !== null,
342
+ hasDoc: model.selDoc !== null,
317
343
  gitlink: gitlink.enabled,
318
- }), indicators: indicators, columns: columns }), frame.launchLine ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [launch.segments.map((s) => s.text).join(LAUNCH_SEP), launch.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", launch.overflow, " fuori riga"] })) : null, launch.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", launch.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null, launch.filler, BARE_LABEL.text, ' ', bareButtons.nodes] })) : null, mode === 'create' ? _jsx(TextBox, { glyph: "C", value: textModals.draft }) : null, mode === 'note' ? _jsx(TextBox, { glyph: "\u270E", value: textModals.noteDraft }) : null, mode === 'sort' ? _jsx(SortModal, { sort: model.view.sort }) : null, mode === 'filter' ? (_jsx(FilterModal, { view: model.view, cursor: viewModals.filterCursor })) : null, mode === 'edit' && textModals.edit && textModals.editTask ? (_jsx(EditModal, { id: textModals.editTask.id, draft: textModals.edit, row: textModals.editCursor.row, caret: textModals.editCursor.caret, columns: columns })) : null, mode === 'purge' && purge.draft ? (_jsx(PurgeModal, { draft: purge.draft, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: frame.windowTasks, counts: model.taskCounts, activeView: model.taskViewId, paneCount: model.paneTasks.length, view: model.view, selected: model.selIndex, spotCount: model.spotCount, allCount: model.sessions.length, idW: model.taskCols.id, tailW: model.taskCols.tail, focused: model.focus === 'tasks', loadError: model.loadError, windowStart: frame.taskWin.start, above: frame.taskWin.start, below: model.paneTasks.length - frame.taskWin.end, columns: columns, data: model.taskRowData, blockMark: model.blockMark }), model.rightPane === 'inbox' ? (_jsx(InboxPane, { files: frame.windowInbox, counts: model.inboxCounts, activeView: model.inboxViewId, paneCount: model.inboxFiles.length, selectedPath: model.selInboxPath, focused: model.focus === 'inbox', above: frame.inboxWin.start, below: model.inboxFiles.length - frame.inboxWin.end, columns: columns, ok: model.inboxOk, scanned: model.inboxScanned })) : (_jsx(SessionsPane, { parentLabel: model.parentLabel, isSpot: model.isSpot, isAll: model.isAll, bindings: model.bindings, taskW: model.sessionCols.task, ageW: model.sessionCols.age, rows: frame.windowRows, counts: model.sessionCounts, activeView: model.sessionViewId, paneCount: model.sessionRows.length, selectedId: model.selSessionId ?? undefined, focused: model.focus === 'sessions', above: frame.sessionWin.start, below: model.sessionRows.length - frame.sessionWin.end, columns: columns, forkOf: model.forkOf, sessionNotes: model.sessionNotes, sessionPriority: model.sessionPriority, projectCore: model.projectCore, live: model.live }))] }), frame.budget.preview && previewKind === 'task' && model.detail ? (_jsx(PreviewPane, { kind: "task", detail: model.detail, maxLines: frame.budget.detailLines, columns: columns })) : frame.budget.preview && previewKind === 'inbox' && model.selInbox ? (_jsx(PreviewPane, { kind: "inbox", file: model.selInbox, columns: columns })) : frame.budget.preview && previewKind === 'session' && model.selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: model.selSessionObj, firstLines: frame.budget.sessionFirstLines, lastLines: frame.budget.sessionLastLines, columns: columns, origin: model.forkOf.get(model.selSessionObj.sessionId) ?? null, note: model.sessionNotes.get(model.selSessionObj.sessionId) ?? '', live: model.live.get(model.selSessionObj.sessionId) ?? null, resumeModel: model.resumeModel })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
344
+ }), indicators: indicators, columns: columns }), frame.launchLine ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [launch.segments.map((s) => s.text).join(LAUNCH_SEP), launch.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", launch.overflow, " fuori riga"] })) : null, launch.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", launch.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null, launch.filler, BARE_LABEL.text, ' ', bareButtons.nodes] })) : null, mode === 'create' ? _jsx(TextBox, { glyph: "C", value: textModals.draft }) : null, mode === 'note' ? _jsx(TextBox, { glyph: "\u270E", value: textModals.noteDraft }) : null, mode === 'sort' ? _jsx(SortModal, { sort: model.view.sort }) : null, mode === 'filter' ? (_jsx(FilterModal, { view: model.view, cursor: viewModals.filterCursor })) : null, mode === 'edit' && textModals.edit && textModals.editTask ? (_jsx(EditModal, { id: textModals.editTask.id, draft: textModals.edit, row: textModals.editCursor.row, caret: textModals.editCursor.caret, columns: columns })) : null, mode === 'purge' && purge.draft ? (_jsx(PurgeModal, { draft: purge.draft, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [model.deckMode === 'doc' ? (_jsx(DocPane, { rows: frame.windowDoc, counts: model.docCounts, activeView: model.docViewId, paneCount: model.docRows.length, selectedPath: model.selDocPath, focused: model.focus === 'doctree', above: frame.docWin.start, below: model.docRows.length - frame.docWin.end, columns: columns, ok: model.docOk, scanned: model.docScanned, scanning: model.docScanning, hasDirs: model.docHasDirs })) : (_jsx(TasksPane, { tasks: frame.windowTasks, counts: model.taskCounts, activeView: model.taskViewId, paneCount: model.paneTasks.length, view: model.view, selected: model.selIndex, spotCount: model.spotCount, allCount: model.sessions.length, idW: model.taskCols.id, tailW: model.taskCols.tail, focused: model.focus === 'tasks', loadError: model.loadError, windowStart: frame.taskWin.start, above: frame.taskWin.start, below: model.paneTasks.length - frame.taskWin.end, columns: columns, data: model.taskRowData, blockMark: model.blockMark })), model.deckMode === 'doc' ? (_jsx(InboxPane, { files: frame.windowInbox, counts: model.inboxCounts, activeView: model.inboxViewId, paneCount: model.inboxFiles.length, selectedPath: model.selInboxPath, focused: model.focus === 'inbox', above: frame.inboxWin.start, below: model.inboxFiles.length - frame.inboxWin.end, columns: columns, ok: model.inboxOk, scanned: model.inboxScanned })) : (_jsx(SessionsPane, { parentLabel: model.parentLabel, isSpot: model.isSpot, isAll: model.isAll, bindings: model.bindings, taskW: model.sessionCols.task, ageW: model.sessionCols.age, rows: frame.windowRows, counts: model.sessionCounts, activeView: model.sessionViewId, paneCount: model.sessionRows.length, selectedId: model.selSessionId ?? undefined, focused: model.focus === 'sessions', above: frame.sessionWin.start, below: model.sessionRows.length - frame.sessionWin.end, columns: columns, forkOf: model.forkOf, sessionNotes: model.sessionNotes, sessionPriority: model.sessionPriority, projectCore: model.projectCore, live: model.live }))] }), frame.budget.preview && previewKind === 'task' && model.detail ? (_jsx(PreviewPane, { kind: "task", detail: model.detail, maxLines: frame.budget.detailLines, columns: columns })) : frame.budget.preview && previewKind === 'inbox' && model.selInbox ? (_jsx(PreviewPane, { kind: "inbox", file: model.selInbox, columns: columns })) : frame.budget.preview && previewKind === 'doc' && model.selDoc ? (_jsx(PreviewPane, { kind: "doc", row: model.selDoc, columns: columns })) : frame.budget.preview && previewKind === 'session' && model.selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: model.selSessionObj, firstLines: frame.budget.sessionFirstLines, lastLines: frame.budget.sessionLastLines, columns: columns, origin: model.forkOf.get(model.selSessionObj.sessionId) ?? null, note: model.sessionNotes.get(model.selSessionObj.sessionId) ?? '', live: model.live.get(model.selSessionObj.sessionId) ?? null, resumeModel: model.resumeModel })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
319
345
  }
320
346
  const cwd = process.cwd();
321
347
  // T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
@@ -14,15 +14,16 @@
14
14
  // la ragione per cui i loro conti di parametri restano leggibili.
15
15
  import { useEffect, useMemo, useState } from 'react';
16
16
  import { loadArchivableDays, loadIdentity, loadLaunch } from './config.js';
17
- import { useArchivable, useCommitTimes, useDirtyFolders, useEpicHierarchy, useInboxScan, useSessions, useTaskDetail, useTasks, } from './hooks.js';
17
+ import { useArchivable, useCommitTimes, useDirtyFolders, useDocScan, useEpicHierarchy, useInboxScan, useSessions, useTaskDetail, useTasks, } from './hooks.js';
18
18
  import { DEFAULT_INBOX_STALE_HOURS, staleCount } from './inbox.js';
19
+ import { cycleDocView, docCounts as deriveDocCounts, docView, selectDocRows, } from './doc-views.js';
19
20
  import { cycleInboxView, inboxCounts as deriveInboxCounts, inboxView, selectInboxRows, } from './inbox-views.js';
20
21
  import { docsRootName } from './tasks.js';
21
22
  import { isDone } from './layout.js';
22
23
  import { TASK_EMPTY, modelAlias, relTime } from './glyphs.js';
23
24
  import { assembleSessionList, firstSelectableId, rowIndexOf, selectedSession, } from './session-list.js';
24
25
  import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, } from './pane-views.js';
25
- import { ALL, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, ROW_ALL, ROW_SPOT, SPOT, } from './model.js';
26
+ import { ALL, LEFT_PANE, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, RIGHT_PANE, ROW_ALL, ROW_SPOT, SPOT, isLeftPane, } from './model.js';
26
27
  import { applyView, epicRollup, taskColumns } from './view.js';
27
28
  import { loadView } from './view-store.js';
28
29
  import { termWidth } from './width.js';
@@ -166,15 +167,21 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
166
167
  // riaperta a freddo si legge come la lista intera.
167
168
  const [taskViewId, setTaskViewId] = useState('tasks');
168
169
  const [sessionViewId, setSessionViewId] = useState('context');
169
- // T134 — quale pane occupa lo slot destro, e la vista attiva di quello inbox.
170
- // Volatili entrambi (D6 preflight): il deck riapre sempre sulle sessioni.
171
- const [rightPane, setRightPane] = useState('sessions');
170
+ // T160 — il MODO del deck (quale coppia di pane è montata) e la vista attiva
171
+ // dei due pane che il modo doc porta con sé. Volatili tutti e tre: il deck
172
+ // riapre sempre in modo task, sulle viste di default.
173
+ const [deckMode, setDeckMode] = useState('task');
172
174
  const [inboxViewId, setInboxViewId] = useState('all');
175
+ const [docViewId, setDocViewId] = useState('all');
173
176
  // Selezione KEYED SUL PATH, mai su indice: la lista si riordina sotto le
174
177
  // viste e si accorcia a ogni scan, e un indice grezzo punterebbe alla riga
175
178
  // sbagliata in silenzio (stessa trappola di T39 sulle task e T50 sulle
176
179
  // conversazioni).
177
180
  const [selInboxPath, setSelInboxPath] = useState(null);
181
+ // T160 — gemella della precedente, sul pane doc: keyed sul PATH e mai
182
+ // sull'indice, perché la lista cambia forma fra le viste (albero con le
183
+ // cartelle, liste piatte per flag) e si riordina a ogni scan.
184
+ const [selDocPath, setSelDocPath] = useState(null);
178
185
  // T136 — data dell'ultimo commit di ogni task file, per la chiave `commit`
179
186
  // della chain di sort.
180
187
  const commitAt = useCommitTimes(tasksDir, cwd);
@@ -218,6 +225,21 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
218
225
  const inboxStale = staleCount(inbox.files, DEFAULT_INBOX_STALE_HOURS, Date.now());
219
226
  const inboxFiles = useMemo(() => selectInboxRows(inboxViewId, inbox.files), [inboxViewId, inbox.files]);
220
227
  const selInbox = inboxFiles.find((f) => f.path === selInboxPath) ?? null;
228
+ // T160 — l'albero della doc, quinto scan della famiglia e l'unico con un
229
+ // interruttore: gira solo mentre il modo doc è montato (P3 preflight). Il
230
+ // primo giro lo fa partire l'effect di `useDocScan` quando `enabled` passa a
231
+ // vero, quindi il montaggio rimisura da sé — `toggleDeckMode` sotto non deve
232
+ // chiamare `doc.scan()` come fa il gemello inbox, e chiamarlo darebbe due
233
+ // spawn da quattro secondi per un gesto solo.
234
+ const doc = useDocScan(cwd, docsRoot, deckMode === 'doc');
235
+ // La misura GREZZA, memoizzata a parte: la consumano tre derivazioni (i
236
+ // contatori, le righe della vista, l'elenco dei file di una cartella nello
237
+ // sheet) e ricomporre l'oggetto a ogni render darebbe una identità nuova a
238
+ // ogni giro, cioè tre memo che non memoizzano più niente.
239
+ const docData = useMemo(() => ({ files: doc.files, dirs: doc.dirs, hasDirs: doc.hasDirs }), [doc.files, doc.dirs, doc.hasDirs]);
240
+ const docCounts = useMemo(() => deriveDocCounts(docData), [docData]);
241
+ const docRows = useMemo(() => selectDocRows(docViewId, docData), [docViewId, docData]);
242
+ const selDoc = docRows.find((r) => r.path === selDocPath) ?? null;
221
243
  // T100 — le task effettivamente a schermo: la vista principale coincide con
222
244
  // `viewTasks` (nessun ricalcolo sul cammino di default), le altre due passano
223
245
  // dal predicato del catalogo. I CONTATORI restano misurati sulla vista di
@@ -344,6 +366,17 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
344
366
  setSelInboxPath(inboxFiles[0].path);
345
367
  }
346
368
  }, [inboxFiles, selInboxPath]);
369
+ // T160 — gemello del precedente sul pane doc: un file splittato sparisce dalla
370
+ // vista `SPLIT` al primo scan dopo l'operazione, e la selezione cade sulla
371
+ // prima riga invece che su una posizione a caso.
372
+ useEffect(() => {
373
+ if (selDocPath !== null && !docRows.some((r) => r.path === selDocPath)) {
374
+ setSelDocPath(docRows[0]?.path ?? null);
375
+ }
376
+ else if (selDocPath === null && docRows.length > 0) {
377
+ setSelDocPath(docRows[0].path);
378
+ }
379
+ }, [docRows, selDocPath]);
347
380
  /** Selezione per INDICE nella vista, riconvertita subito in sentinella o id.
348
381
  * T21 — la chiama anche il click su una riga del pane task. */
349
382
  function selectTaskRow(index) {
@@ -391,11 +424,20 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
391
424
  setSelInboxPath(null);
392
425
  setNote(`vista inbox: ${inboxView(next).label(inboxCounts)}`);
393
426
  }
427
+ function selectDocView(next) {
428
+ if (next === docViewId)
429
+ return;
430
+ setDocViewId(next);
431
+ setSelDocPath(null);
432
+ setNote(`vista doc: ${docView(next).label(docCounts)}`);
433
+ }
394
434
  function cycleView(delta) {
395
435
  if (focus === 'tasks')
396
436
  selectTaskView(cycleTaskView(taskViewId, delta));
397
437
  else if (focus === 'inbox')
398
438
  selectInboxView(cycleInboxView(inboxViewId, delta));
439
+ else if (focus === 'doctree')
440
+ selectDocView(cycleDocView(docViewId, delta));
399
441
  else
400
442
  selectSessionView(cycleSessionView(sessionViewId, delta));
401
443
  }
@@ -412,6 +454,20 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
412
454
  const next = Math.max(0, Math.min(inboxFiles.length - 1, (at < 0 ? 0 : at) + delta));
413
455
  setSelInboxPath(inboxFiles[next].path);
414
456
  }
457
+ /** Gemelle delle due sopra, sul pane doc. Nessuna riga è non-selezionabile:
458
+ * una cartella è un bersaglio di `rebalance-doc` quanto un file. */
459
+ function selectDocRow(index) {
460
+ const r = docRows[index];
461
+ if (r)
462
+ setSelDocPath(r.path);
463
+ }
464
+ function moveDocSel(delta) {
465
+ const at = docRows.findIndex((r) => r.path === selDocPath);
466
+ if (docRows.length === 0)
467
+ return;
468
+ const next = Math.max(0, Math.min(docRows.length - 1, (at < 0 ? 0 : at) + delta));
469
+ setSelDocPath(docRows[next].path);
470
+ }
415
471
  /** T148 — `m` scorre il selettore modello della riga sessione selezionata al
416
472
  * valore SUCCESSIVO del catalogo (ciclico), gemella di `cycleView` ma su un
417
473
  * asse diverso: qui non c'è una vista da cambiare, solo un valore da far
@@ -434,34 +490,45 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
434
490
  return next;
435
491
  }
436
492
  /**
437
- * T134 — `^B` scambia i due pane dello slot destro (D8 preflight).
493
+ * T160 — `^B` scambia il MODO, cioè ENTRAMBI gli slot insieme.
494
+ *
495
+ * Erede del `toggleInboxPane` di T134, che scambiava il solo pane destro. Un
496
+ * tasto per slot avrebbe reso raggiungibili le due combinazioni che l'asse
497
+ * unico esiste per non avere (albero doc accanto alle conversazioni, lista
498
+ * task accanto alla coda inbox), e ne avrebbe richiesto un secondo che nessuna
499
+ * legenda ha spazio per annunciare.
438
500
  *
439
- * Il FOCUS segue il pane: chi stava guardando a destra continua a guardare a
440
- * destra, ma quello che c'è adesso è l'altro pane. Lasciarlo su `sessions`
441
- * con l'inbox montato darebbe un focus su un pane che non è a schermo, e le
442
- * azioni della lista sessioni resterebbero attive su una selezione invisibile.
501
+ * Il FOCUS segue la POSIZIONE, non il contenuto: chi stava guardando a
502
+ * sinistra continua a guardare a sinistra, e quello che c'è adesso è l'altro
503
+ * pane. È la stessa regola di T134 (lì con una posizione sola su cui valeva) e
504
+ * la stessa di `←→`, che nominano un lato e non un pane.
505
+ *
506
+ * Nessuno scan esplicito qui, a differenza del gemello di T134: il montaggio
507
+ * lo rimisura da sé, perché l'effect di `useDocScan` è keyed sull'interruttore
508
+ * e riparte quando passa a vero. Chiamarlo anche di qui darebbe due spawn da
509
+ * quattro secondi per un gesto solo. Lo scan inbox invece resta chiesto a
510
+ * mano — il suo hook gira sempre, e non ha un interruttore che gli dica che
511
+ * qualcuno ha appena aperto il pane.
443
512
  */
444
- function toggleInboxPane() {
445
- const next = rightPane === 'inbox' ? 'sessions' : 'inbox';
446
- setRightPane(next);
447
- setFocus((f) => (f === 'tasks' ? f : next));
448
- // Montare il pane RIMISURA la coda. Il periodico resta, come rete per il
449
- // deck lasciato aperto sul pane inbox, ma non è più l'unico canale: senza
450
- // questa chiamata chi apre il pane guarda l'ultima misura, che può essere
451
- // vecchia quanto l'intero intervallo. Non serve il verso opposto — tornare
452
- // alle sessioni non guarda la coda, e uno scan lì si pagherebbe per nessuno.
453
- if (next === 'inbox')
513
+ function switchMode(next) {
514
+ if (next === deckMode)
515
+ return;
516
+ setDeckMode(next);
517
+ setFocus((f) => (isLeftPane(f) ? LEFT_PANE[next] : RIGHT_PANE[next]));
518
+ if (next === 'doc')
454
519
  inbox.scan();
455
- setNote(next === 'inbox' ? '^B → pane inbox' : '^B → pane sessioni');
520
+ setNote(next === 'doc' ? '^B → modo doc' : '^B → modo task');
521
+ }
522
+ function toggleDeckMode() {
523
+ switchMode(deckMode === 'doc' ? 'task' : 'doc');
456
524
  }
457
- /** `esc` in vista normale: torna alle sessioni. Solo in quel verso — `esc`
458
- * dice «esci da dove sei», e sulle sessioni non c'è più niente da cui uscire
459
- * (il tasto resta inerte, come è sempre stato). */
460
- function closeInboxPane() {
461
- if (rightPane !== 'inbox')
525
+ /** `esc` in vista normale: torna al modo task. Solo in quel verso — `esc` dice
526
+ * «esci da dove sei», e dal modo task non c'è più niente da cui uscire (il
527
+ * tasto resta inerte, come è sempre stato). */
528
+ function closeDocMode() {
529
+ if (deckMode !== 'doc')
462
530
  return false;
463
- setRightPane('sessions');
464
- setFocus((f) => (f === 'inbox' ? 'sessions' : f));
531
+ switchMode('task');
465
532
  return true;
466
533
  }
467
534
  return {
@@ -517,8 +584,9 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
517
584
  cycleResumeModel,
518
585
  bareModel,
519
586
  cycleBareModel,
587
+ // modo del deck
588
+ deckMode,
520
589
  // derivazioni del pane inbox
521
- rightPane,
522
590
  inboxScanned: inbox.scanned,
523
591
  inboxOk: inbox.ok,
524
592
  inboxViewId,
@@ -527,6 +595,17 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
527
595
  inboxFiles,
528
596
  selInboxPath,
529
597
  selInbox,
598
+ // derivazioni del pane doc
599
+ docData,
600
+ docScanned: doc.scanned,
601
+ docOk: doc.ok,
602
+ docScanning: doc.scanning,
603
+ docHasDirs: doc.hasDirs,
604
+ docViewId,
605
+ docCounts,
606
+ docRows,
607
+ selDocPath,
608
+ selDoc,
530
609
  // mutatori di navigazione
531
610
  selectTaskRow,
532
611
  moveTaskSel,
@@ -535,8 +614,11 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
535
614
  selectInboxView,
536
615
  selectInboxRow,
537
616
  moveInboxSel,
538
- toggleInboxPane,
539
- closeInboxPane,
617
+ selectDocView,
618
+ selectDocRow,
619
+ moveDocSel,
620
+ toggleDeckMode,
621
+ closeDocMode,
540
622
  cycleView,
541
623
  };
542
624
  }