@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/actions.js CHANGED
@@ -21,7 +21,7 @@ import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, } from '
21
21
  import { neighborId } from './session-list.js';
22
22
  import { cut, cutMiddle } from './width.js';
23
23
  import { saveView, viewFilePath } from './view-store.js';
24
- import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
24
+ import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
25
25
  import { useTaskOps } from './task-ops.js';
26
26
  export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNote, }) {
27
27
  // La riga di stato di OGNI spawn di sessione Claude: il comando esatto, come
@@ -239,6 +239,37 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
239
239
  child.on('error', () => setNote('⚠ t → ptyxis non lanciabile'));
240
240
  setNote(`t → terminale su ${model.projectName}`);
241
241
  }
242
+ /**
243
+ * T134 — apre la sessione PRESIDIATA che drena un file inbox.
244
+ *
245
+ * Il prompt arriva già composto dall'overlay (`inboxPrompt`), che è l'unico
246
+ * posto in cui vive la mappa natura → skill: derivarlo qui una seconda volta
247
+ * darebbe due tabelle capaci di divergere, e la seconda si scoprirebbe solo
248
+ * il giorno in cui una natura nuova apre la skill sbagliata.
249
+ *
250
+ * Sessione NUDA e modello esplicito: il drain lavora sulla doc, non sulla
251
+ * task (D10 preflight), e `opus` passa nell'argv anche essendo il default,
252
+ * come `permissionMode`.
253
+ */
254
+ function drainInbox(file, prompt) {
255
+ const spawned = spawnBare(cwd, prompt, MODEL_DEFAULT);
256
+ spawned.child.on('error', () => setNote(`⚠ drain ${file.basename} fallito (${DECK_RUN})`));
257
+ noteSpawn(spawned);
258
+ }
259
+ /**
260
+ * T134 — apre la sessione che SROTOLA l'hard-wrap di un path.
261
+ *
262
+ * `sonnet` e non il default (D9): lo srotolamento è una passata meccanica con
263
+ * un verificatore deterministico dietro (`md-wrap --apply` confronta le due
264
+ * versioni normalizzate `\s+ → spazio` e rimette indietro il file se
265
+ * differiscono), quindi il giudizio richiesto al modello è leggere un diff e
266
+ * decidere se committarlo — non progettare niente.
267
+ */
268
+ function unwrapPath(path, prompt) {
269
+ const spawned = spawnBare(cwd, prompt, 'sonnet');
270
+ spawned.child.on('error', () => setNote(`⚠ srotolamento ${path} fallito (${DECK_RUN})`));
271
+ noteSpawn(spawned);
272
+ }
242
273
  /** `c` — sessione claude a mani nude, senza task e senza prompt. */
243
274
  function openClaude() {
244
275
  const spawned = spawnClaudeEmpty(cwd);
@@ -283,6 +314,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
283
314
  assignSession,
284
315
  forkSession,
285
316
  togglePin,
317
+ drainInbox,
318
+ unwrapPath,
286
319
  openTerminal,
287
320
  openClaude,
288
321
  saveCurrentView,
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@ import { resolveTasksPath, resolveTasksDir } from './tasks.js';
19
19
  import { LAUNCH_SEP } from './config.js';
20
20
  import { anchorFrame, enableMouse } from './mouse.js';
21
21
  import { sanitize } from './width.js';
22
- import { deckLegend, frameGeometry, headlineWidth, launchRow } from './frame.js';
22
+ import { deckLegend, frameGeometry, headlineWidth, indicatorRow, launchRow } from './frame.js';
23
23
  import { useDeckModel } from './deck-model.js';
24
24
  import { useDeckActions } from './actions.js';
25
25
  import { useDeckInput } from './input.js';
@@ -27,12 +27,14 @@ import { useSearchOverlay } from './overlays/search.js';
27
27
  import { useSheetOverlay } from './overlays/sheet.js';
28
28
  import { useAssignOverlay } from './overlays/assign.js';
29
29
  import { useProjectStatus } from './overlays/status.js';
30
+ import { useInboxOverlay } from './overlays/inbox.js';
31
+ import { useWrapOverlay } from './overlays/wrap.js';
30
32
  import { usePurgeOverlay } from './overlays/purge.js';
31
33
  import { useTextModals, useViewModals } from './overlays/modals.js';
32
34
  import { useTerminalSize } from './hooks.js';
33
35
  import { EditModal, FilterModal, PurgeModal, SortModal } from './ui/modals.js';
34
36
  import { StatusHeadline } from './ui/status-screen.js';
35
- import { SessionsPane, TasksPane } from './ui/panes.js';
37
+ import { InboxPane, SessionsPane, TasksPane } from './ui/panes.js';
36
38
  import { PreviewPane, detailMetaOf } from './ui/preview.js';
37
39
  import { CompactNotice, HintBar, TextBox, screenFor } from './ui/screens.js';
38
40
  import { VERSION } from './version.js';
@@ -84,6 +86,25 @@ function Deck({ cwd, tasksPath, tasksDir }) {
84
86
  setMode,
85
87
  setNote,
86
88
  });
89
+ // T134 — il detail di un file inbox. Come lo sheet della task riceve lo spawn
90
+ // come callback: un hook di overlay non esce dal deck.
91
+ const inbox = useInboxOverlay({
92
+ rows,
93
+ columns,
94
+ setMode,
95
+ setNote,
96
+ onDrain: actions.drainInbox,
97
+ });
98
+ // T134 — l'hard-wrap. Come il project status non è solo un overlay: i suoi
99
+ // numeri si leggono in testata a schermata chiusa, e la lista è una delle sue
100
+ // superfici invece che il suo contenuto.
101
+ const wrap = useWrapOverlay({
102
+ cwd,
103
+ rows,
104
+ setMode,
105
+ setNote,
106
+ onApply: actions.unwrapPath,
107
+ });
87
108
  const purge = usePurgeOverlay({
88
109
  setMode,
89
110
  setNote,
@@ -106,7 +127,17 @@ function Deck({ cwd, tasksPath, tasksDir }) {
106
127
  editDraftFor: actions.editDraftFor,
107
128
  currentNote: (sid) => model.sessionNotes.get(sid) ?? '',
108
129
  });
109
- const overlays = { assign, sheet, search, status, purge, view: viewModals, text: textModals };
130
+ const overlays = {
131
+ assign,
132
+ sheet,
133
+ search,
134
+ status,
135
+ inbox,
136
+ wrap,
137
+ purge,
138
+ view: viewModals,
139
+ text: textModals,
140
+ };
110
141
  // T70 — un solo blocco preview, sotto i due pane, e il FOCUS decide cosa
111
142
  // contiene: a sinistra la task selezionata, a destra la conversazione. È il
112
143
  // focus e non la selezione perché il blocco è uno — averne due significava
@@ -118,11 +149,27 @@ function Deck({ cwd, tasksPath, tasksDir }) {
118
149
  ? model.detail
119
150
  ? 'task'
120
151
  : 'none'
121
- : model.selSessionObj
122
- ? 'session'
123
- : 'none';
152
+ : model.focus === 'inbox'
153
+ ? model.selInbox
154
+ ? 'inbox'
155
+ : 'none'
156
+ : model.selSessionObj
157
+ ? 'session'
158
+ : 'none';
124
159
  const detailParts = model.detail ? detailMetaOf(model.detail) : null;
125
160
  const launch = launchRow(model.launch, columns);
161
+ // T134 — gli indicatori ancorati a destra della riga legenda. Si compongono
162
+ // PRIMA della legenda perché è la loro larghezza a decidere quanto ne resta:
163
+ // hanno la precedenza sul budget (D5 preflight).
164
+ const indicators = indicatorRow({
165
+ inbox: {
166
+ counts: model.inboxCounts,
167
+ stale: model.inboxStale,
168
+ scanned: model.inboxScanned,
169
+ ok: model.inboxOk,
170
+ },
171
+ wrap: { count: wrap.count, mtime: wrap.mtime, ok: wrap.ok, scanning: wrap.scanning },
172
+ }, columns);
126
173
  const frame = frameGeometry({
127
174
  rows,
128
175
  columns,
@@ -142,8 +189,14 @@ function Deck({ cwd, tasksPath, tasksDir }) {
142
189
  sessionViewId: model.sessionViewId,
143
190
  parentLabel: model.parentLabel,
144
191
  hasLoadError: Boolean(model.loadError),
192
+ rightPane: model.rightPane,
193
+ inboxFiles: model.inboxFiles,
194
+ selInboxPath: model.selInboxPath,
195
+ inboxCounts: model.inboxCounts,
196
+ inboxViewId: model.inboxViewId,
145
197
  });
146
198
  useDeckInput({
199
+ cwd,
147
200
  tasksDir,
148
201
  mode,
149
202
  setMode,
@@ -153,8 +206,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
153
206
  overlays,
154
207
  frame,
155
208
  launchRegions: launch.regions,
209
+ indicatorRegions: indicators.regions,
156
210
  });
157
- // Le cinque schermate sostitutive prendono il frame intero: se una è attiva
211
+ // Le sei schermate sostitutive prendono il frame intero: se una è attiva
158
212
  // il render finisce qui, e il budget dei pane resta calcolato ma inutilizzato.
159
213
  const screen = screenFor({
160
214
  mode,
@@ -169,7 +223,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
169
223
  projectName: model.projectName,
170
224
  taskRowData: model.taskRowData,
171
225
  hiddenTasks: model.hiddenTasks,
172
- overlays: { assign, sheet, search, status },
226
+ overlays: { assign, sheet, search, status, inbox, wrap },
173
227
  });
174
228
  if (screen)
175
229
  return screen;
@@ -185,7 +239,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
185
239
  hasSession: model.selSessionObj !== null,
186
240
  hasSessionId: model.selSessionId !== null,
187
241
  purgeBulk: model.purgeBulk,
188
- }) }), 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] })) : 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 }), _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, 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 === '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 })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
242
+ inboxPane: model.rightPane === 'inbox',
243
+ hasInbox: model.selInbox !== null,
244
+ }), 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] })) : 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 }), 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, 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 })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
189
245
  }
190
246
  const cwd = process.cwd();
191
247
  // T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
@@ -14,7 +14,10 @@
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, useDirtyFolders, useSessions, useTaskDetail, useTasks, } from './hooks.js';
17
+ import { useArchivable, useDirtyFolders, useInboxScan, useSessions, useTaskDetail, useTasks, } from './hooks.js';
18
+ import { DEFAULT_INBOX_STALE_HOURS, staleCount } from './inbox.js';
19
+ import { cycleInboxView, inboxCounts as deriveInboxCounts, inboxView, selectInboxRows, } from './inbox-views.js';
20
+ import { docsRootName } from './tasks.js';
18
21
  import { isDone } from './layout.js';
19
22
  import { TASK_EMPTY, relTime } from './glyphs.js';
20
23
  import { assembleSessionList, firstSelectableId, rowIndexOf, selectedSession, } from './session-list.js';
@@ -133,6 +136,15 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
133
136
  // riaperta a freddo si legge come la lista intera.
134
137
  const [taskViewId, setTaskViewId] = useState('tasks');
135
138
  const [sessionViewId, setSessionViewId] = useState('context');
139
+ // T134 — quale pane occupa lo slot destro, e la vista attiva di quello inbox.
140
+ // Volatili entrambi (D6 preflight): il deck riapre sempre sulle sessioni.
141
+ const [rightPane, setRightPane] = useState('sessions');
142
+ const [inboxViewId, setInboxViewId] = useState('all');
143
+ // Selezione KEYED SUL PATH, mai su indice: la lista si riordina sotto le
144
+ // viste e si accorcia a ogni scan, e un indice grezzo punterebbe alla riga
145
+ // sbagliata in silenzio (stessa trappola di T39 sulle task e T50 sulle
146
+ // conversazioni).
147
+ const [selInboxPath, setSelInboxPath] = useState(null);
136
148
  // Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
137
149
  const launch = useMemo(() => loadLaunch(cwd), [cwd]);
138
150
  // Identità (T37): titolo delle tab terminale spawnate col tasto `t`.
@@ -162,6 +174,19 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
162
174
  // una task sola e il dato lo ricalcola al momento.
163
175
  const archivableSig = useMemo(() => [...archivable].sort().join(','), [archivable]);
164
176
  const dirtyFolders = useDirtyFolders(archivableSig, tasksDir, cwd);
177
+ // T134 — terzo scan della famiglia (D1): coda inbox per natura, età del più
178
+ // vecchio. La docs-root arriva dalla sola env, come `resolveTasksPath` — il
179
+ // limite si eredita invece di aprire qui una cascata che nessun percorso di
180
+ // avvio reale userebbe (D1 preflight).
181
+ const docsRoot = useMemo(() => docsRootName(), []);
182
+ const inbox = useInboxScan(cwd, docsRoot);
183
+ const inboxCounts = useMemo(() => deriveInboxCounts(inbox.files), [inbox.files]);
184
+ // Il numero accanto alla sirena: solo i file in coda oltre soglia. `Date.now()`
185
+ // letto a ogni render e non memoizzato — è una sottrazione, e congelarlo
186
+ // lascerebbe la sirena ferma sull'ora dello scan invece che sull'ora corrente.
187
+ const inboxStale = staleCount(inbox.files, DEFAULT_INBOX_STALE_HOURS, Date.now());
188
+ const inboxFiles = useMemo(() => selectInboxRows(inboxViewId, inbox.files), [inboxViewId, inbox.files]);
189
+ const selInbox = inboxFiles.find((f) => f.path === selInboxPath) ?? null;
165
190
  // T100 — le task effettivamente a schermo: la vista principale coincide con
166
191
  // `viewTasks` (nessun ricalcolo sul cammino di default), le altre due passano
167
192
  // dal predicato del catalogo. I CONTATORI restano misurati sulla vista di
@@ -245,6 +270,17 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
245
270
  setSelSessionId(firstSelectableId(sessionRows));
246
271
  }
247
272
  }, [sessionRows, selSessionId]);
273
+ // T134 — gemello dei due sopra: un file drenato sparisce dalla coda al primo
274
+ // scan successivo, e la selezione cade sulla prima riga della lista invece
275
+ // che su una posizione a caso.
276
+ useEffect(() => {
277
+ if (selInboxPath !== null && !inboxFiles.some((f) => f.path === selInboxPath)) {
278
+ setSelInboxPath(inboxFiles[0]?.path ?? null);
279
+ }
280
+ else if (selInboxPath === null && inboxFiles.length > 0) {
281
+ setSelInboxPath(inboxFiles[0].path);
282
+ }
283
+ }, [inboxFiles, selInboxPath]);
248
284
  /** Selezione per INDICE nella vista, riconvertita subito in sentinella o id.
249
285
  * T21 — la chiama anche il click su una riga del pane task. */
250
286
  function selectTaskRow(index) {
@@ -285,12 +321,58 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
285
321
  // parent delle sessioni che torna a `tutte` è un effetto accettato); sul pane
286
322
  // sessioni basta invalidare l'id, e l'effect di validità atterra sulla prima
287
323
  // riga selezionabile della vista nuova.
324
+ function selectInboxView(next) {
325
+ if (next === inboxViewId)
326
+ return;
327
+ setInboxViewId(next);
328
+ setSelInboxPath(null);
329
+ setNote(`vista inbox: ${inboxView(next).label(inboxCounts)}`);
330
+ }
288
331
  function cycleView(delta) {
289
332
  if (focus === 'tasks')
290
333
  selectTaskView(cycleTaskView(taskViewId, delta));
334
+ else if (focus === 'inbox')
335
+ selectInboxView(cycleInboxView(inboxViewId, delta));
291
336
  else
292
337
  selectSessionView(cycleSessionView(sessionViewId, delta));
293
338
  }
339
+ /** Selezione per INDICE nella finestra visibile: la chiama il click. */
340
+ function selectInboxRow(index) {
341
+ const f = inboxFiles[index];
342
+ if (f)
343
+ setSelInboxPath(f.path);
344
+ }
345
+ function moveInboxSel(delta) {
346
+ const at = inboxFiles.findIndex((f) => f.path === selInboxPath);
347
+ if (inboxFiles.length === 0)
348
+ return;
349
+ const next = Math.max(0, Math.min(inboxFiles.length - 1, (at < 0 ? 0 : at) + delta));
350
+ setSelInboxPath(inboxFiles[next].path);
351
+ }
352
+ /**
353
+ * T134 — `^B` scambia i due pane dello slot destro (D8 preflight).
354
+ *
355
+ * Il FOCUS segue il pane: chi stava guardando a destra continua a guardare a
356
+ * destra, ma quello che c'è adesso è l'altro pane. Lasciarlo su `sessions`
357
+ * con l'inbox montato darebbe un focus su un pane che non è a schermo, e le
358
+ * azioni della lista sessioni resterebbero attive su una selezione invisibile.
359
+ */
360
+ function toggleInboxPane() {
361
+ const next = rightPane === 'inbox' ? 'sessions' : 'inbox';
362
+ setRightPane(next);
363
+ setFocus((f) => (f === 'tasks' ? f : next));
364
+ setNote(next === 'inbox' ? '^B → pane inbox' : '^B → pane sessioni');
365
+ }
366
+ /** `esc` in vista normale: torna alle sessioni. Solo in quel verso — `esc`
367
+ * dice «esci da dove sei», e sulle sessioni non c'è più niente da cui uscire
368
+ * (il tasto resta inerte, come è sempre stato). */
369
+ function closeInboxPane() {
370
+ if (rightPane !== 'inbox')
371
+ return false;
372
+ setRightPane('sessions');
373
+ setFocus((f) => (f === 'inbox' ? 'sessions' : f));
374
+ return true;
375
+ }
294
376
  return {
295
377
  // fonti
296
378
  tasks,
@@ -338,11 +420,26 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
338
420
  sessionCounts,
339
421
  selSessionObj,
340
422
  sessionCols,
423
+ // derivazioni del pane inbox
424
+ rightPane,
425
+ inboxScanned: inbox.scanned,
426
+ inboxOk: inbox.ok,
427
+ inboxViewId,
428
+ inboxCounts,
429
+ inboxStale,
430
+ inboxFiles,
431
+ selInboxPath,
432
+ selInbox,
341
433
  // mutatori di navigazione
342
434
  selectTaskRow,
343
435
  moveTaskSel,
344
436
  selectTaskView,
345
437
  selectSessionView,
438
+ selectInboxView,
439
+ selectInboxRow,
440
+ moveInboxSel,
441
+ toggleInboxPane,
442
+ closeInboxPane,
346
443
  cycleView,
347
444
  };
348
445
  }
package/dist/frame.js CHANGED
@@ -15,11 +15,13 @@
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, sessionHeaderParts, taskHeaderParts } from './pane-header.js';
19
- import { FRAME_TEXT_COL, inlineRegions, LAUNCH_ROW, PANE_TEXT_PAD, paneSpans, rowRegions, } from './mouse.js';
18
+ import { headerItems, inboxHeaderParts, sessionHeaderParts, taskHeaderParts } from './pane-header.js';
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';
22
22
  import { sanitize, termWidth } from './width.js';
23
+ import { WARN } from './glyphs.js';
24
+ import { STATUS_MISSING } from './project-status.js';
23
25
  import { META_ROWS } from './model.js';
24
26
  import { rowIndexOf } from './session-list.js';
25
27
  import { VERSION } from './version.js';
@@ -53,11 +55,18 @@ export const SURFACE_SEGMENTS = [
53
55
  export function deckLegend(state) {
54
56
  const canSpawn = state.focus === 'tasks' && state.hasTask;
55
57
  const canResume = state.focus === 'sessions' && state.hasSession;
58
+ const canOpenInbox = state.focus === 'inbox' && state.hasInbox;
56
59
  // T50 — il pin agisce su qualunque riga selezionata (anche stale, per
57
60
  // spinnarla); basta il focus sul pane e una selezione.
58
61
  const canPin = state.focus === 'sessions' && state.hasSessionId;
59
62
  return sanitize([
60
- ...(canSpawn ? ['⏎ detail', '^K/^P/^R spawn'] : canResume ? ['⏎ resume'] : []),
63
+ ...(canSpawn
64
+ ? ['⏎ detail', '^K/^P/^R spawn']
65
+ : canResume
66
+ ? ['⏎ resume']
67
+ : canOpenInbox
68
+ ? ['⏎ apri']
69
+ : []),
61
70
  // T112 — la voce nomina il BERSAGLIO, che cambia di taglia senza che
62
71
  // cambi il tasto. Legge `purgeBulk`, la stessa condizione del ramo di
63
72
  // apertura: una legenda che annunciasse «tutte» dove il tasto ne pota una
@@ -74,6 +83,11 @@ export function deckLegend(state) {
74
83
  // cosa e sta a due voci di distanza.
75
84
  '^G genera status',
76
85
  '^O apri status',
86
+ // T134 — la voce nomina il pane che il tasto MONTA, non quello montato:
87
+ // `^B` scambia i due, e annunciare quello che si sta già guardando
88
+ // direbbe il contrario di ciò che il tasto fa. Stessa regola di `CANC
89
+ // elimina tutte`, che nomina il bersaglio e non il tasto.
90
+ state.inboxPane ? '^B sessioni' : '^B inbox',
77
91
  '^F cerca',
78
92
  'C nuova',
79
93
  'E edit',
@@ -105,6 +119,87 @@ export function launchRow(launch, columns) {
105
119
  unreachable: legend.unreachable,
106
120
  };
107
121
  }
122
+ /**
123
+ * T134 — gli INDICATORI ancorati a destra della riga legenda: dato, non
124
+ * stringa, per la stessa ragione della riga launch (`launchRow`). Gli stessi
125
+ * segmenti compongono il testo disegnato e le colonne dell'hit-test, e chi
126
+ * derivasse le seconde ri-splittando il primo terrebbe due conti che divergono
127
+ * alla prima etichetta che contiene il separatore.
128
+ *
129
+ * `key` è la COMBO che l'indicatore rappresenta, col prefisso `^`: il click non
130
+ * chiama l'azione, rientra dalla porta della tastiera premendola. È lo stesso
131
+ * schema delle superfici launch, esteso ai `ctrl` — senza il prefisso il click
132
+ * sintetizzerebbe una lettera nuda, che su questa tastiera fa un'altra cosa.
133
+ */
134
+ export const INDICATOR_SEP = ' ';
135
+ /**
136
+ * Il bottone che monta il pane inbox: `[ Inbox 📄 3/1/2 🚨2 ]`.
137
+ *
138
+ * SEMPRE ACCESO, mai grigio (D7): è un pane, non un sottoinsieme che può essere
139
+ * vuoto. I tre numeri sono le tre nature nell'ordine del catalogo; la sirena e
140
+ * il suo contatore compaiono solo sopra la soglia.
141
+ *
142
+ * Prima del primo scan i numeri non esistono ancora, e stampare `0/0/0`
143
+ * direbbe «ho misurato e non c'è niente» — cioè la cosa sbagliata (D2
144
+ * preflight: `missing` e guasto sono stati distinti). Il glifo di allerta si
145
+ * AGGIUNGE ai numeri invece di sostituirli: un tentativo fallito lascia in
146
+ * piedi l'esito dell'ultimo riuscito, che resta vero e ancora apribile.
147
+ */
148
+ export function inboxButton(state) {
149
+ const numbers = state.scanned
150
+ ? `${state.counts.nozioni}/${state.counts.derivazione}/${state.counts.sweep}`
151
+ : STATUS_MISSING;
152
+ const siren = state.stale > 0 ? ` 🚨${state.stale}` : '';
153
+ const warn = state.ok ? '' : ` ${WARN}`;
154
+ return sanitize(`[ Inbox 📄 ${numbers}${siren}${warn} ]`);
155
+ }
156
+ /**
157
+ * L'indicatore hard-wrap: `wrap 77`.
158
+ *
159
+ * Etichetta a PAROLA e non un glifo, dopo una misura sotto pty: `〰` (U+3030)
160
+ * passa il controllo di concordanza di `width.ts` — `string-width` e la nostra
161
+ * tabella EAW dicono entrambe 2 — ma dentro il frame Ink emette una colonna in
162
+ * meno di quante ne conta, e la riga esce larga `columns - 1` col bordo
163
+ * mangiato. È un terzo caso oltre alle due invarianti note: `agrees()` confronta
164
+ * due LIBRERIE, e un glifo può ingannarle entrambe insieme. Chi volesse
165
+ * rimettere un simbolo qui lo misuri sul frame vero, non su `agrees`.
166
+ *
167
+ * Conta i soli `WRAP` (D4). `misto` compare in lista con un marker proprio ma
168
+ * resta fuori: è la classe che ospita il falso allarme noto — i file di note
169
+ * scritte una riga per pensiero, che nessuno vuole srotolare — e sommarlo
170
+ * darebbe un numero che non si può portare a zero. Un contatore che non arriva
171
+ * mai a zero smette di essere letto.
172
+ *
173
+ * Tre stati distinti, come per il project status: `missing` quando lo scan non
174
+ * è mai stato eseguito (non parte all'avvio, D4), `scan…` mentre cammina
175
+ * l'albero, il numero altrimenti. Il glifo di allerta si AGGIUNGE al numero
176
+ * invece di sostituirlo — un tentativo fallito non riscrive la cache, quindi
177
+ * l'esito dell'ultimo riuscito resta vero e ancora apribile.
178
+ */
179
+ export function wrapIndicator(state) {
180
+ const body = state.scanning
181
+ ? 'scan…'
182
+ : state.mtime === null
183
+ ? STATUS_MISSING
184
+ : String(state.count);
185
+ return sanitize(`wrap ${body}${state.ok ? '' : ` ${WARN}`}`);
186
+ }
187
+ export function indicatorRow(state, columns) {
188
+ // Ordine di lettura: l'indicatore, poi il bottone. Il bottone sta all'estremo
189
+ // destro perché è l'unico dei due che MONTA qualcosa restando in vista
190
+ // normale — la lista del wrap è una schermata che si apre e si chiude.
191
+ const segments = [
192
+ { key: '^w', text: wrapIndicator(state.wrap) },
193
+ { key: '^b', text: inboxButton(state.inbox) },
194
+ ];
195
+ const text = segments.map((s) => s.text).join(INDICATOR_SEP);
196
+ const width = termWidth(text);
197
+ // Ancorato al bordo destro del box esterno: il blocco è renderizzato da un
198
+ // `justifyContent="space-between"`, quindi la sua prima colonna si ricava per
199
+ // sottrazione e non da un accumulo da sinistra.
200
+ const start = FRAME_TEXT_COL + Math.max(0, (columns || 80) - 4) - width;
201
+ return { segments, text, regions: rowRegions(segments, INDICATOR_SEP, start), width };
202
+ }
108
203
  /**
109
204
  * Il budget d'altezza e le finestre di rendering.
110
205
  *
@@ -139,6 +234,11 @@ export function frameGeometry(input) {
139
234
  const selRowIndex = rowIndexOf(input.sessionRows, input.selSessionId);
140
235
  const sessionWin = windowRange(input.sessionRows.length, selRowIndex, budget.sessionRows);
141
236
  const windowRows = input.sessionRows.slice(sessionWin.start, sessionWin.end);
237
+ // Stessa capienza del pane sessioni: i due hanno la stessa cornice e uno solo
238
+ // dei due è montato (vedi SESSIONS_PANE_CHROME in viewport.ts).
239
+ const selInboxIndex = input.inboxFiles.findIndex((f) => f.path === input.selInboxPath);
240
+ const inboxWin = windowRange(input.inboxFiles.length, selInboxIndex, budget.sessionRows);
241
+ const windowInbox = input.inboxFiles.slice(inboxWin.start, inboxWin.end);
142
242
  // T21 — geometria delle liste per l'hit-test del click, dalla STESSA
143
243
  // aritmetica che disegna i pane: le parti degli header escono dal modulo che
144
244
  // `ui/panes.tsx` consuma per renderle, le righe cliccabili sono le finestre
@@ -150,10 +250,13 @@ export function frameGeometry(input) {
150
250
  columns: input.columns,
151
251
  taskHeader: inlineRegions(headerItems(taskHeaderParts(input.taskCounts, input.taskViewId, taskWin.start, input.paneTasks.length - taskWin.end, input.columns)), spans.tasks.start + PANE_TEXT_PAD),
152
252
  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),
253
+ inboxHeader: inlineRegions(headerItems(inboxHeaderParts(input.inboxCounts, input.inboxViewId, inboxWin.start, input.inboxFiles.length - inboxWin.end, input.columns)), spans.sessions.start + PANE_TEXT_PAD),
153
254
  // Con un errore di caricamento al posto delle task c'è la riga rossa:
154
255
  // restano cliccabili le sole righe meta.
155
256
  taskRows: META_ROWS + (input.hasLoadError ? 0 : windowTasks.length),
156
257
  sessionRows: windowRows.length,
258
+ inboxRows: windowInbox.length,
259
+ rightPane: input.rightPane,
157
260
  };
158
261
  return {
159
262
  budget,
@@ -161,6 +264,8 @@ export function frameGeometry(input) {
161
264
  windowTasks,
162
265
  sessionWin,
163
266
  windowRows,
267
+ inboxWin,
268
+ windowInbox,
164
269
  listGeometry,
165
270
  // Dimensione del terminale in CELLE (colonne×righe, mai pixel — un processo
166
271
  // dentro un terminale vede solo la griglia di caratteri) e versione. La
@@ -175,4 +280,17 @@ export function frameGeometry(input) {
175
280
  export function headlineWidth(columns, headerRight) {
176
281
  return Math.max(4, columns - 4 - termWidth(headerRight) - 1);
177
282
  }
178
- export { LAUNCH_ROW };
283
+ /**
284
+ * T134 — il budget della legenda tasti, per sottrazione del blocco indicatori
285
+ * che le sta a destra sulla stessa riga.
286
+ *
287
+ * La legenda cede per prima (D5 preflight): è già troncabile per costruzione e
288
+ * ciò che perde si ricorda, mentre un contatore troncato mente. Pavimento a 4
289
+ * come la testata — sotto quella soglia non resta comunque niente di leggibile,
290
+ * e un budget negativo farebbe tornare `cut` una stringa vuota per il tramite
291
+ * di un numero che nessuno ha scelto.
292
+ */
293
+ export function legendWidth(columns, indicatorW) {
294
+ return Math.max(4, (columns || 80) - 4 - indicatorW - termWidth(INDICATOR_SEP));
295
+ }
296
+ export { HINT_ROW, LAUNCH_ROW };
package/dist/glyphs.js CHANGED
@@ -71,6 +71,30 @@ export function modelShort(id) {
71
71
  export const LIVE_IDLE = '●';
72
72
  export const LIVE_BUSY = '◍';
73
73
  export const LIVE_NONE = ' ';
74
+ /**
75
+ * T134 — colonna di stato della riga inbox, larga 2 come la cella marker della
76
+ * lista sessioni.
77
+ *
78
+ * `held` non ha glifo, e non è una dimenticanza: un file senza il token
79
+ * `drainable` resta eseguibile se qualcuno lo nomina, quindi ogni simbolo di
80
+ * divieto direbbe una cosa più forte del vero. L'assenza dice «non è in coda»
81
+ * senza promettere altro, e la colonna natura accanto continua a nominarlo.
82
+ *
83
+ * Nessun glifo è riusato dalla lista sessioni con un significato spostato: là
84
+ * `●`/`◍` dicono la liveness di un processo e `📌`/`🔗`/`○` l'appartenenza,
85
+ * qui si parla di una coda di lavoro. Un falso amico costa più di un simbolo
86
+ * nuovo.
87
+ */
88
+ export const INBOX_MARK = {
89
+ broken: WARN,
90
+ branched: sanitize('🔒'),
91
+ held: '',
92
+ queued: sanitize('⏳'),
93
+ };
94
+ /** Larghezza della cella marker della riga inbox, gemella dei 2 della lista
95
+ * sessioni: la cella si riempie di spazi anche quando è vuota, o le righe
96
+ * `held` sposterebbero a sinistra tutto ciò che segue. */
97
+ export const INBOX_MARK_W = 2;
74
98
  // Marker Done per il DISPLAY. `task.prog` resta il `✔️` letto da tasks.md —
75
99
  // `isDone()` e le lookup di `view.ts` ci confrontano sopra, e `task-edit` lo
76
100
  // riscrive sul file: è una chiave semantica, non testo. Qui `sanitize` lo