@lamemind/loom-deck 0.59.0 → 0.61.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, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
24
+ import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, fallbackTitle, 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
@@ -104,12 +104,19 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
104
104
  // il campo è editabile: quello che l'utente legge è quello che parte. Assente
105
105
  // per gli acceleratori della lista, che non hanno un campo da cui prenderlo e
106
106
  // viaggiano col simbolo.
107
+ // T150 — il vuoto si riempie QUI, prima che i due canali si separino: il
108
+ // sidecar (`appendNote`, sotto) e il titolo tab (`--title-note`, dentro
109
+ // `spawnDeck`) devono vedere lo STESSO valore, o le due superfici mostrano
110
+ // nomi diversi per la stessa conversazione. Vale anche sugli acceleratori
111
+ // della lista (^K/^P/^R), che passano di qui con `spawnNote` sul proprio
112
+ // default '' (P1 preflight) — nascono quindi titolati anche loro.
107
113
  function spawnForTask(id, kind, modelKind, spawnNote = '', prompt) {
108
114
  const sid = randomUUID();
115
+ const note = spawnNote || fallbackTitle(tasksDir, id, kind) || '';
109
116
  appendTaskBinding(cwd, sid, id);
110
- if (spawnNote)
111
- appendNote(cwd, sid, spawnNote);
112
- const spawned = spawnDeck(id, cwd, sid, kind, modelKind, spawnNote, prompt);
117
+ if (note)
118
+ appendNote(cwd, sid, note);
119
+ const spawned = spawnDeck(id, cwd, sid, kind, modelKind, note, prompt);
113
120
  spawned.child.on('error', () => setNote(`⚠ spawn ${id} fallito (${DECK_RUN})`));
114
121
  // Il modello resta SEMPRE visibile anche quando è il default, perché è un
115
122
  // argomento esplicito del comando (T108): gli acceleratori della lista non
package/dist/cli.js CHANGED
@@ -245,7 +245,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
245
245
  purgeBulk: model.purgeBulk,
246
246
  inboxPane: model.rightPane === 'inbox',
247
247
  hasInbox: model.selInbox !== null,
248
- }), 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, resumeModel: model.resumeModel })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
248
+ }), 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, 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, 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] }));
249
249
  }
250
250
  const cwd = process.cwd();
251
251
  // T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
@@ -14,7 +14,7 @@
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, useInboxScan, useSessions, useTaskDetail, useTasks, } from './hooks.js';
17
+ import { useArchivable, useCommitTimes, useDirtyFolders, useEpicHierarchy, useInboxScan, useSessions, useTaskDetail, useTasks, } from './hooks.js';
18
18
  import { DEFAULT_INBOX_STALE_HOURS, staleCount } from './inbox.js';
19
19
  import { cycleInboxView, inboxCounts as deriveInboxCounts, inboxView, selectInboxRows, } from './inbox-views.js';
20
20
  import { docsRootName } from './tasks.js';
@@ -23,7 +23,7 @@ import { TASK_EMPTY, modelAlias, relTime } from './glyphs.js';
23
23
  import { assembleSessionList, firstSelectableId, rowIndexOf, selectedSession, } from './session-list.js';
24
24
  import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, } from './pane-views.js';
25
25
  import { ALL, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, ROW_ALL, ROW_SPOT, SPOT, } from './model.js';
26
- import { applyView, taskColumns } from './view.js';
26
+ import { applyView, epicRollup, taskColumns } from './view.js';
27
27
  import { loadView } from './view-store.js';
28
28
  import { termWidth } from './width.js';
29
29
  import { MODELS, MODEL_DEFAULT } from './spawn.js';
@@ -169,9 +169,21 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
169
169
  // T136 — data dell'ultimo commit di ogni task file, per la chiave `commit`
170
170
  // della chain di sort.
171
171
  const commitAt = useCommitTimes(tasksDir, cwd);
172
+ // T67 — cappello/figlie (`**Parent Task**`, `Size: Epic`) per il grouping
173
+ // gerarchico del Tasks pane, sullo stesso poll di `commitAt`.
174
+ const epicHierarchy = useEpicHierarchy(tasksDir);
175
+ // T67/P9 — rollup {chiuse/totali} di ogni cappello: sulla lista GREZZA come
176
+ // `doneSig` più sotto, cieco a filtri e viste — altrimenti il numero
177
+ // cambierebbe filtrando senza che nessuna figlia sia davvero comparsa o
178
+ // sparita.
179
+ const epicRollupMap = useMemo(() => epicRollup(tasks, epicHierarchy.epicOf), [tasks, epicHierarchy]);
172
180
  // La vista è una trasformazione DERIVATA, applicata a valle del load: il
173
181
  // polling di tasks.md continua a funzionare senza saperne nulla.
174
- const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view, { commitAt }), [tasks, view, commitAt]);
182
+ const { visible: viewTasks, hidden: hiddenTasks, blockMark: viewBlockMark, } = useMemo(() => applyView(tasks, view, {
183
+ commitAt,
184
+ epicOf: epicHierarchy.epicOf,
185
+ epics: epicHierarchy.epics,
186
+ }), [tasks, view, commitAt, epicHierarchy]);
175
187
  // T61 — il conteggio guarda la lista GREZZA, non `viewTasks`: le Done fuori
176
188
  // dai filtri della vista restano archiviabili, e un contatore che cambiasse
177
189
  // filtrando direbbe qualcosa sulla vista invece che sulla task list.
@@ -207,9 +219,22 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
207
219
  hidden: hiddenTasks,
208
220
  archivable: archivable.size,
209
221
  };
210
- const paneTasks = useMemo(() => taskViewId === 'tasks'
211
- ? viewTasks
212
- : selectTasks(tasks, taskViewId, { view, archivable, commitAt }), [taskViewId, viewTasks, tasks, view, archivable, commitAt]);
222
+ // T67 la vista `tasks` riusa `viewTasks`/`viewBlockMark` già calcolati
223
+ // sopra (nessun ricalcolo sul cammino di default); le altre due passano da
224
+ // `selectTasks`, che raggruppa con lo STESSO livello gerarchico (un sito
225
+ // solo per il sort, come già per la sola chain prima di T67).
226
+ const { paneTasks, paneBlockMark } = useMemo(() => {
227
+ if (taskViewId === 'tasks')
228
+ return { paneTasks: viewTasks, paneBlockMark: viewBlockMark };
229
+ const selected = selectTasks(tasks, taskViewId, {
230
+ view,
231
+ archivable,
232
+ commitAt,
233
+ epicOf: epicHierarchy.epicOf,
234
+ epics: epicHierarchy.epics,
235
+ });
236
+ return { paneTasks: selected.tasks, paneBlockMark: selected.blockMark };
237
+ }, [taskViewId, viewTasks, viewBlockMark, tasks, view, archivable, commitAt, epicHierarchy]);
213
238
  const isSpot = sel === SPOT;
214
239
  const isAll = sel === ALL;
215
240
  // T112 — quando `CANC` pota in BLOCCO invece della sola task selezionata.
@@ -235,7 +260,13 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
235
260
  // alla vista che sceglie l'header (D2 create).
236
261
  const parentLabel = isAll ? 'tutte' : isSpot ? 'spot' : selectedTaskId ?? '—';
237
262
  const { childCount, taskLive, spotCount } = rollupChildren(sessions, bindings, live);
238
- const taskRowData = { childCount, live: taskLive, dirty: dirtyFolders };
263
+ const taskRowData = {
264
+ childCount,
265
+ live: taskLive,
266
+ dirty: dirtyFolders,
267
+ epics: epicHierarchy.epics,
268
+ epicRollup: epicRollupMap,
269
+ };
239
270
  // T118 — colonne fisse della lista task, misurate su `paneTasks` (la vista
240
271
  // attiva INTERA) e non sulla finestra visibile: gemelle di `sessionCols` e
241
272
  // per la stessa ragione, che una larghezza derivata dallo schermo si muove a
@@ -444,6 +475,7 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
444
475
  hiddenTasks,
445
476
  taskCounts,
446
477
  paneTasks,
478
+ blockMark: paneBlockMark,
447
479
  isSpot,
448
480
  isAll,
449
481
  purgeBulk,
@@ -0,0 +1,54 @@
1
+ // T67 — mappa cappello/figlie per il grouping del Tasks pane, letta con UNA
2
+ // passata sui task file sotto `tasksDir`, gemella di `commitTimes` ma dal
3
+ // filesystem invece che da `git log`.
4
+ //
5
+ // P2 (preflight): sullo STESSO poll di `tasks.md` (`POLL_MS`), non su una scala
6
+ // propria come `useArchivable` — read+parse di 106 task file costa 7,86 ms,
7
+ // meno dei ~33 ms di `git log` che `useCommitTimes` paga già ogni tick. La
8
+ // parentela si edita anche a mano dentro un task file già in tabella, non solo
9
+ // alla nascita di una riga: un trigger sulla sola firma degli id (come
10
+ // `useArchivable`) lascerebbe quell'edit stale fino al prossimo giro largo.
11
+ import { readdirSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { taskEpicOf, taskIsEpic } from './tasks.js';
14
+ // Stesso pattern di `TASK_FILE_RE` in commit-times.ts: il basename del task
15
+ // file, non `TASK_ID_RE` di tasks.ts (ancorata all'id nudo, non matcha
16
+ // `T142-nome.md`).
17
+ const TASK_FILE_RE = /^(T\d+)-.*\.md$/;
18
+ export const EMPTY_EPIC_HIERARCHY = { epicOf: new Map(), epics: new Set() };
19
+ /**
20
+ * Lettura SINCRONA di tutti i task file sotto `tasksDir`: costo misurato in P2,
21
+ * accettabile sul poll da 1,5s. Cartella illeggibile o singolo file non
22
+ * apribile → entry saltata, mai un throw — un dato di rendering non può
23
+ * rompere il deck, stessa regola di `archivableIds`/`commitTimes`.
24
+ */
25
+ export function scanEpicHierarchy(tasksDir) {
26
+ let entries;
27
+ try {
28
+ entries = readdirSync(tasksDir);
29
+ }
30
+ catch {
31
+ return EMPTY_EPIC_HIERARCHY;
32
+ }
33
+ const epicOf = new Map();
34
+ const epics = new Set();
35
+ for (const file of entries) {
36
+ const m = TASK_FILE_RE.exec(file);
37
+ if (!m)
38
+ continue;
39
+ const id = m[1];
40
+ let content;
41
+ try {
42
+ content = readFileSync(join(tasksDir, file), 'utf8');
43
+ }
44
+ catch {
45
+ continue;
46
+ }
47
+ if (taskIsEpic(id, content))
48
+ epics.add(id);
49
+ const parent = taskEpicOf(id, content);
50
+ if (parent)
51
+ epicOf.set(id, parent);
52
+ }
53
+ return { epicOf, epics };
54
+ }
package/dist/glyphs.js CHANGED
@@ -116,8 +116,13 @@ export const INBOX_MARK_W = 2;
116
116
  // `isDone()` e le lookup di `view.ts` ci confrontano sopra, e `task-edit` lo
117
117
  // riscrive sul file: è una chiave semantica, non testo. Qui `sanitize` lo
118
118
  // traduce nel suo gemello concorde (`✅`) solo per finire nel frame.
119
- export function displayProg(prog) {
120
- return sanitize(prog);
119
+ //
120
+ // T67/D4 — `blank` spegne la cella su un'epica: il glifo di stato descrive il
121
+ // cappello, che non si esegue, non le task che ne realizzano il piano. Due
122
+ // spazi e non stringa vuota, o la colonna successiva slitterebbe di 2 sulle
123
+ // sole righe cappello.
124
+ export function displayProg(prog, blank = false) {
125
+ return blank ? ' ' : sanitize(prog);
121
126
  }
122
127
  /**
123
128
  * T118 — coda della riga task: marker dirty e contatore di conversazioni, nella
@@ -153,6 +158,42 @@ export function taskTail(live, total, dirty) {
153
158
  return count;
154
159
  return count ? `${WARN} ${count}` : WARN;
155
160
  }
161
+ /**
162
+ * T67/P9 — coda della riga CAPPELLO: rollup delle figlie dichiarate, cieco a
163
+ * filtri e viste (come `total` di `taskTail` sopra) — cieco anche a quale
164
+ * cappello sta guardando la selezione, perché conta TUTTE le figlie dichiarate
165
+ * ovunque nel progetto, non solo quelle nella vista corrente.
166
+ *
167
+ * Graffa e non tonda, per non confondersi col contatore conversazioni che
168
+ * questa cella SOSTITUISCE sul cappello (D2 preflight). Nessuna chiusa, come
169
+ * `taskTail`: la colonna è ancorata a destra, il bordo del pane chiude già il
170
+ * gruppo. Un cappello senza figlie (`total === 0`) rende coda VUOTA — non
171
+ * `{0/0}`, che scriverebbe uno zero rumoroso su ogni epica ancora senza figlie
172
+ * (P9).
173
+ */
174
+ export function epicTail(rollup) {
175
+ if (!rollup || rollup.total === 0)
176
+ return '';
177
+ return `{${rollup.closed}/${rollup.total}`;
178
+ }
179
+ /**
180
+ * T67/D3 — spina di blocco: connette visivamente cappello e figlie nel Tasks
181
+ * pane. Tratti di box (`┌ │ └`), non nell'alfabeto concorde dichiarato in testa
182
+ * al file ma verificati concordi a sé (`agrees()` vale per tutti e tre) —
183
+ * `sanitize` qui è quindi un no-op, applicato per la stessa disciplina delle
184
+ * altre costanti di questo file: nessun sito di render scrive un glifo nudo.
185
+ */
186
+ const BLOCK_GLYPH = {
187
+ '┌': sanitize('┌ '),
188
+ '│': sanitize('│ '),
189
+ '└': sanitize('└ '),
190
+ };
191
+ /** Cella della spina per una riga: vuota per una task normale, che NON
192
+ * riserva le 2 colonne (D3 — la lista ha due punti di partenza della
193
+ * descrizione, uno per le righe di blocco e uno per le altre). */
194
+ export function blockCell(mark) {
195
+ return mark ? BLOCK_GLYPH[mark] : '';
196
+ }
156
197
  /** T124 — contatore di una riga META del pane task (`≡ tutte`, `○ spot`), che
157
198
  * non è una colonna ancorata a niente. Prende comunque la grafia senza chiusa:
158
199
  * due forme dello stesso oggetto a due righe di distanza si leggono come un
package/dist/hooks.js CHANGED
@@ -11,6 +11,7 @@ import { discoverLiveSessions, liveSig } from './live-sessions.js';
11
11
  import { loadSessionIndex } from './task-index.js';
12
12
  import { archivableIds, SCAN_INTERVAL_MS } from './archivable.js';
13
13
  import { commitTimes } from './commit-times.js';
14
+ import { scanEpicHierarchy, EMPTY_EPIC_HIERARCHY } from './epic-hierarchy.js';
14
15
  import { scanInbox, INBOX_SCAN_INTERVAL_MS } from './inbox.js';
15
16
  import { mixedCount, readWrapCache, runWrapScan, wrapCacheFile, wrapCount, } from './wrap-scan.js';
16
17
  import { purgeTargets } from './purge.js';
@@ -105,6 +106,39 @@ export function useCommitTimes(tasksDir, projectRoot) {
105
106
  }, [tasksDir, projectRoot]);
106
107
  return commitAt;
107
108
  }
109
+ /**
110
+ * T67 — mappa cappello/figlie (`**Parent Task**`, `Size: Epic`), sullo STESSO
111
+ * poll di `tasks.md` e non su una scala propria: P2 (preflight) misura 7,86 ms
112
+ * per il read+parse di 106 task file, meno dei ~33 ms di `git log` che
113
+ * `useCommitTimes` paga già ogni tick. La parentela si edita anche a mano su
114
+ * un task file già in tabella — un trigger sulla sola firma degli id, come
115
+ * `useArchivable`, non vedrebbe quell'edit e lo lascerebbe stale fino al
116
+ * prossimo giro largo.
117
+ *
118
+ * Firma prima di `setState`, gemella di `lastSig`/`lastMtime` degli altri
119
+ * poll: senza, la mappa cambia identità a ogni tick e rompe le `useMemo` a
120
+ * valle che la consumano (`applyView`, `selectTasks`).
121
+ */
122
+ export function useEpicHierarchy(tasksDir) {
123
+ const [hierarchy, setHierarchy] = useState(EMPTY_EPIC_HIERARCHY);
124
+ useEffect(() => {
125
+ let lastSig = '';
126
+ const reload = () => {
127
+ const next = scanEpicHierarchy(tasksDir);
128
+ const sig = [...next.epicOf.entries()].map(([c, p]) => `${c}<${p}`).sort().join(',') +
129
+ '#' +
130
+ [...next.epics].sort().join(',');
131
+ if (sig === lastSig)
132
+ return;
133
+ lastSig = sig;
134
+ setHierarchy(next);
135
+ };
136
+ reload();
137
+ const id = setInterval(reload, POLL_MS);
138
+ return () => clearInterval(id);
139
+ }, [tasksDir]);
140
+ return hierarchy;
141
+ }
108
142
  // Poll delle sessioni del progetto + binding sidecar. discoverProjectSessions
109
143
  // ha cache mtime-keyed interna → il poll è economico; qui si evita comunque il
110
144
  // re-render inutile con una signature (sessionId:ts + binding entries): setState
@@ -20,7 +20,7 @@
20
20
  // navigando, e il catalogo tornerebbe dinamico per la via di dietro.
21
21
  // - `↑N`/`↓N` non stanno qui: contano elementi fuori finestra per altezza del
22
22
  // terminale, cioè una POSIZIONE, non un insieme.
23
- import { isVisible, compareTasks } from './view.js';
23
+ import { isVisible, groupHierarchy } from './view.js';
24
24
  export const TASK_VIEWS = [
25
25
  {
26
26
  id: 'tasks',
@@ -49,16 +49,20 @@ export const TASK_VIEWS = [
49
49
  },
50
50
  ];
51
51
  /**
52
- * Le task della vista `id`, filtrate col predicato e ordinate con la chain
53
- * corrente. L'ordinamento vale per ogni vista, non solo la principale: una lista
54
- * di task ordinata in un modo e l'altra in un altro sarebbe un secondo asse che
55
- * si muove da solo.
52
+ * Le task della vista `id`, filtrate col predicato e raggruppate con la STESSA
53
+ * chain e lo stesso livello gerarchico di `applyView` (`groupHierarchy`): un
54
+ * sito solo per il sort, o una vista raggrupperebbe e l'altra no (T67). Vale
55
+ * per ogni vista, non solo la principale — una lista ordinata in un modo e
56
+ * l'altra in un altro sarebbe un secondo asse che si muove da solo.
56
57
  */
57
58
  export function selectTasks(tasks, id, ctx) {
58
59
  const entry = taskView(id);
59
60
  const picked = tasks.filter((t) => entry.has(t, ctx));
60
- picked.sort((a, b) => compareTasks(a, b, ctx.view.sort, ctx));
61
- return picked;
61
+ return groupHierarchy(picked, ctx.view.sort, {
62
+ commitAt: ctx.commitAt,
63
+ epicOf: ctx.epicOf,
64
+ epics: ctx.epics,
65
+ });
62
66
  }
63
67
  /** Glifo della sessione viva, gemello di `LIVE_IDLE` del render. Duplicato qui
64
68
  * perché il catalogo è puro e non può importare da `ui/`; è un carattere
@@ -111,9 +111,11 @@ const MIN_NOTE = 14;
111
111
  export function rowLabel(text, note, budget) {
112
112
  if (!note)
113
113
  return { note: '', rest: cut(text, Math.max(0, budget)) };
114
- // 2 colonne per i caporali « », 1 per lo spazio prima del residuo.
114
+ // 1 colonna per lo spazio prima del residuo (T150 la nota non porta più i
115
+ // caporali « » che decoravano il render, quindi non riserva più le loro 2
116
+ // colonne).
115
117
  const rest = text;
116
- const noteBudget = Math.max(0, budget - 2);
118
+ const noteBudget = Math.max(0, budget);
117
119
  if (!rest)
118
120
  return { note: cut(note, noteBudget), rest: '' };
119
121
  // Il `min` col budget totale non è ridondante: su un pane strettissimo
package/dist/spawn.js CHANGED
@@ -3,7 +3,8 @@
3
3
  import { spawn } from 'node:child_process';
4
4
  import { EventEmitter } from 'node:events';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { dirname, join } from 'node:path';
6
+ import { basename, dirname, join } from 'node:path';
7
+ import { findTaskFile } from './tasks.js';
7
8
  // scripts/deck-run è un sibling della dir del bundle: src/ (dev, tsx) e dist/
8
9
  // (build, node) stanno entrambi sotto la package root → risalita di un livello.
9
10
  export const DECK_RUN = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'deck-run');
@@ -171,6 +172,43 @@ export const DETAIL_ACTIONS = [
171
172
  // DISTINTE fra loro. Due label con la stessa lettera renderebbero la seconda
172
173
  // irraggiungibile, e in silenzio — chi aggiunge una voce lo controlla qui.
173
174
  export const ACTION_HOTKEYS = Object.fromEntries(DETAIL_ACTIONS.map((a, i) => [a.label[0], i]));
175
+ // T150 — titolo di fallback quando il campo nota è vuoto, derivato dall'AZIONE
176
+ // e dal task. Mappa sul KIND, non sulla label (P2 preflight): alla sede del
177
+ // fallback (`spawnForTask` in actions.ts) arriva il kind — già specializzato
178
+ // quando la chiamata viene dal detail (`recap-task`/`recap-epic`) — mai la
179
+ // label del catalogo.
180
+ // D2/P7 — `none` (label "open") non ha una parola: è "nessuna azione", e
181
+ // inventargliene una contraddirebbe l'intenzione dell'utente.
182
+ // P3 — le tre varianti di recap condividono la parola: la specializzazione
183
+ // sceglie quale skill parte, non cosa l'utente sta chiedendo.
184
+ const ACTION_WORD = {
185
+ preflight: 'PREFL',
186
+ run: 'RUN',
187
+ recap: 'RECAP',
188
+ 'recap-task': 'RECAP',
189
+ 'recap-epic': 'RECAP',
190
+ checkpoint: 'CHKPOINT',
191
+ };
192
+ /**
193
+ * Titolo di fallback per una conversazione con nota vuota: `{AZIONE} {slug}`,
194
+ * o il solo slug per `none` (D1/D2 preflight). Lo slug viene dal NOME del
195
+ * task file (`findTaskFile`), non dalla descrizione di `tasks.md`: il nome è
196
+ * già dentro l'alfabeto di `_sane_note` per costruzione — minuscolo, separato
197
+ * da trattini, senza punteggiatura — mentre la descrizione porta apostrofi e
198
+ * `/` che la riduzione toglie senza sostituto, saldando le parole (D1
199
+ * razionale).
200
+ *
201
+ * `null` quando il task file non si trova: il chiamante decide se ripiegare
202
+ * su nota vuota o su un altro fallback.
203
+ */
204
+ export function fallbackTitle(tasksDir, id, kind) {
205
+ const path = findTaskFile(tasksDir, id);
206
+ if (!path)
207
+ return null;
208
+ const slug = basename(path, '.md').slice(id.length + 1).replace(/-/g, ' ');
209
+ const word = ACTION_WORD[kind];
210
+ return word ? `${word} ${slug}` : slug;
211
+ }
174
212
  // Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
175
213
  // ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
176
214
  // sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
package/dist/tasks.js CHANGED
@@ -190,6 +190,26 @@ export function taskIsEpic(id, text) {
190
190
  return false;
191
191
  return (parseTaskDetail(id, text).fields['Size'] ?? '').trim().toLowerCase() === 'epic';
192
192
  }
193
+ /** Primo token `T\d+` dentro `**Parent Task**` — stessa tolleranza di
194
+ * `lw_task_children` nel plugin, che ammette una maniglia dopo l'id
195
+ * (`T142 (epica sistema documentale)`) e la ignora. */
196
+ const PARENT_TOKEN_RE = /T\d+/;
197
+ /**
198
+ * Il cappello dichiarato dalla FIGLIA via `**Parent Task**`, o `null` se il
199
+ * campo è assente/vuoto — il caso comune del template, dove significa «nessun
200
+ * cappello» e non un errore di lettura.
201
+ *
202
+ * Riusa `parseTaskDetail` come `taskIsEpic`: una grammatica sola per i bullet
203
+ * header. La validità del cappello (esiste ancora? è nella vista? non è un
204
+ * ciclo?) non è compito di questa funzione — la giudica il grouping, che ha la
205
+ * lista intera sotto gli occhi e questa non ce l'ha.
206
+ */
207
+ export function taskEpicOf(id, text) {
208
+ if (!text)
209
+ return null;
210
+ const field = parseTaskDetail(id, text).fields['Parent Task'] ?? '';
211
+ return PARENT_TOKEN_RE.exec(field)?.[0] ?? null;
212
+ }
193
213
  /**
194
214
  * Testo INTEGRALE del task file (T66 · detail).
195
215
  *
package/dist/ui/panes.js CHANGED
@@ -45,7 +45,7 @@ export function TasksHeader({ counts, active, above, below, focused, columns, })
45
45
  function HeaderLine({ parts, shown, focused, }) {
46
46
  return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: parts.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, inverse: seg.active, children: shown[i] }, i)) : null) }));
47
47
  }
48
- export function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, idW, tailW, focused, loadError, windowStart, above, below, columns, data, }) {
48
+ export function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, idW, tailW, focused, loadError, windowStart, above, below, columns, data, blockMark, }) {
49
49
  const allSelected = selected === ROW_ALL;
50
50
  const spotSelected = selected === ROW_SPOT;
51
51
  return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(TasksHeader, { counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut(sanitize(`sort: ${describeSort(view.sort)}` +
@@ -66,7 +66,7 @@ export function TasksPane({ tasks, counts, activeView, paneCount, view, selected
66
66
  // windowStart riporta l'indice di finestra a quello della lista
67
67
  // completa, su cui è keyata la selezione. +META_ROWS: le prime due
68
68
  // righe sono le meta.
69
- sel: windowStart + i + META_ROWS === selected, focused: focused, width: paneTextWidth(columns), idW: idW, tailW: tailW, data: data }, task.id))))] }));
69
+ sel: windowStart + i + META_ROWS === selected, focused: focused, width: paneTextWidth(columns), idW: idW, tailW: tailW, data: data, blockMark: blockMark }, task.id))))] }));
70
70
  }
71
71
  /**
72
72
  * Header del pane sessioni, tagliato QUI e non da Ink.
@@ -181,8 +181,8 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
181
181
  termWidth(`${WARN} pin stale `) +
182
182
  SID_CHARS +
183
183
  (staleTask ? termWidth(staleTask) + 1 : 0) +
184
- 3 /* spazio + caporali */));
185
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, SID_CHARS) }), staleTask ? _jsxs(Text, { color: "green", children: [" ", staleTask] }) : null, staleNote ? _jsxs(Text, { color: "yellow", children: [" \u00AB", cut(staleNote, staleW), "\u00BB"] }) : null] }, row.sessionId));
184
+ 1 /* spazio prima della nota */));
185
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, SID_CHARS) }), staleTask ? _jsxs(Text, { color: "green", children: [" ", staleTask] }) : null, staleNote ? _jsxs(Text, { color: "yellow", children: [" ", cut(staleNote, staleW)] }) : null] }, row.sessionId));
186
186
  }
187
187
  const s = row.session;
188
188
  const isPinnedRow = row.pinned;
@@ -241,9 +241,9 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
241
241
  // dicono già (progetto e task id): senza, la cella conterrebbe
242
242
  // `🧵 loom-works · T59` accanto a una colonna che dice `T59`.
243
243
  const label = rowLabel(sessionTitle(s, projectCore, bound), sessionNotes.get(s.sessionId), inner);
244
- const used = (label.note ? termWidth(label.note) + 2 : 0) +
244
+ const used = (label.note ? termWidth(label.note) : 0) +
245
245
  (label.note && label.rest ? 1 : 0) +
246
246
  termWidth(label.rest);
247
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : undefined, children: liveEntry ? (liveEntry.status === 'busy' ? LIVE_BUSY : LIVE_IDLE) : LIVE_NONE }), _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : 'cyan', bold: Boolean(liveEntry), children: s.sessionId.slice(0, SID_CHARS) }), ' ', _jsx(Text, { dimColor: !s.model, children: pad(modelShort(s.model), MODEL_W) }), ' ', taskW > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: bound && taskCell ? 'green' : undefined, dimColor: !bound, children: pad(taskCell, taskW) }), ' '] })) : null, forkMark ? _jsx(Text, { color: "magenta", children: forkMark }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' '.repeat(Math.max(0, inner - used)), ' ', _jsx(Text, { dimColor: true, children: pad(age, ageW, 'right') })] }, s.sessionId));
247
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : undefined, children: liveEntry ? (liveEntry.status === 'busy' ? LIVE_BUSY : LIVE_IDLE) : LIVE_NONE }), _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : 'cyan', bold: Boolean(liveEntry), children: s.sessionId.slice(0, SID_CHARS) }), ' ', _jsx(Text, { dimColor: !s.model, children: pad(modelShort(s.model), MODEL_W) }), ' ', taskW > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: bound && taskCell ? 'green' : undefined, dimColor: !bound, children: pad(taskCell, taskW) }), ' '] })) : null, forkMark ? _jsx(Text, { color: "magenta", children: forkMark }) : null, label.note ? (_jsx(Text, { color: "yellow", bold: true, children: label.note })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' '.repeat(Math.max(0, inner - used)), ' ', _jsx(Text, { dimColor: true, children: pad(age, ageW, 'right') })] }, s.sessionId));
248
248
  }))] }));
249
249
  }
@@ -64,7 +64,7 @@ export function SessionPreview({ s, firstLines, lastLines, columns, origin, note
64
64
  // non c'è una provenienza vera da dichiarare.
65
65
  const originAlias = modelAlias(s.model);
66
66
  const originIndex = originAlias ? MODELS.indexOf(originAlias) : undefined;
67
- return (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), " \u00B7 ", s.gitBranch || '-', s.model ? ` · ${s.model}` : '', origin ? ` · ⑂ da ${origin.slice(0, 8)}` : '', live ? (_jsx(Text, { color: live.status === 'busy' ? 'yellow' : 'green', children: ` · ${live.status === 'busy' ? LIVE_BUSY : LIVE_IDLE} viva pid ${live.pid} (${live.status})` })) : null] }), _jsx(ChoiceRow, { label: "modello", values: MODELS, index: Math.max(0, MODELS.indexOf(resumeModel)), focused: false, width: previewTextWidth(columns), originIndex: originIndex }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
67
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', note ? _jsxs(Text, { color: "yellow", children: [note, " "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), " \u00B7 ", s.gitBranch || '-', s.model ? ` · ${s.model}` : '', origin ? ` · ⑂ da ${origin.slice(0, 8)}` : '', live ? (_jsx(Text, { color: live.status === 'busy' ? 'yellow' : 'green', children: ` · ${live.status === 'busy' ? LIVE_BUSY : LIVE_IDLE} viva pid ${live.pid} (${live.status})` })) : null] }), _jsx(ChoiceRow, { label: "modello", values: MODELS, index: Math.max(0, MODELS.indexOf(resumeModel)), focused: false, width: previewTextWidth(columns), originIndex: originIndex }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
68
68
  }
69
69
  /**
70
70
  * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
@@ -55,13 +55,14 @@ export function SearchScreen({ preview, hash, query, field, opts, result, rows,
55
55
  const bound = bindings.get(s.sessionId);
56
56
  const rowNote = sessionNotes.get(s.sessionId);
57
57
  const noteShown = rowNote ? cut(rowNote, 24) : '';
58
- // `+3` = i due caporali e lo spazio che li separa dall'etichetta.
59
- // Il pavimento non è cosmetico: senza, un terminale stretto manda
60
- // l'argomento di `cut` sotto zero, cioè un budget negativo.
61
- // La nota si misura con `termWidth`, non con `.length`: contiene
62
- // testo umano, emoji compresi.
63
- const restWidth = Math.max(8, searchTitleWidth(columns) - (noteShown ? termWidth(noteShown) + 3 : 0));
64
- return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), noteShown ? _jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", noteShown, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(noteShown), children: cut(conversationLabel(s, projectCore, bound), restWidth) }), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
58
+ // `+1` = lo spazio che separa la nota dall'etichetta (T150 — niente
59
+ // più caporali « » a decorarla). Il pavimento non è cosmetico:
60
+ // senza, un terminale stretto manda l'argomento di `cut` sotto
61
+ // zero, cioè un budget negativo. La nota si misura con
62
+ // `termWidth`, non con `.length`: contiene testo umano, emoji
63
+ // compresi.
64
+ const restWidth = Math.max(8, searchTitleWidth(columns) - (noteShown ? termWidth(noteShown) + 1 : 0));
65
+ return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), noteShown ? _jsxs(Text, { color: "yellow", bold: true, children: [noteShown, " "] }) : null, _jsx(Text, { dimColor: Boolean(noteShown), children: cut(conversationLabel(s, projectCore, bound), restWidth) }), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
65
66
  }
66
67
  const h = row.hit;
67
68
  return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: String(h.idx).padStart(4) }), ' ', _jsx(Text, { color: KIND_COLOR[h.kind], children: KIND_TAG[h.kind] }), ' ', cut(h.excerpt, searchExcerptWidth(columns))] }, row.key));
@@ -15,9 +15,9 @@ import { jsxs as _jsxs } from "react/jsx-runtime";
15
15
  import { Text } from 'ink';
16
16
  import { cut, pad, sanitize, termWidth } from '../width.js';
17
17
  import { isDone } from '../layout.js';
18
- import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, displayProg, taskTail, } from '../glyphs.js';
18
+ import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, blockCell, displayProg, epicTail, taskTail, } from '../glyphs.js';
19
19
  import { padId } from '../view.js';
20
- export function TaskRow({ task, sel, focused, width, idW, tailW, data, }) {
20
+ export function TaskRow({ task, sel, focused, width, idW, tailW, data, blockMark, }) {
21
21
  // T124 — il predicato è UNO e si valuta qui una volta sola: da lui dipendono
22
22
  // tutte e tre le evidenze (id in grassetto+colore, glifo, numeratore in coda).
23
23
  // Tre condizioni scritte in tre siti divergono alla prima modifica, e la riga
@@ -32,7 +32,13 @@ export function TaskRow({ task, sel, focused, width, idW, tailW, data, }) {
32
32
  // spazi verso Pri, quindi la riga non si allarga di una cella quando si
33
33
  // accende — `LIVE_NONE` è uno spazio, e tutti e tre sono larghi 1.
34
34
  const glyph = live ? (live.status === 'busy' ? LIVE_BUSY : LIVE_IDLE) : LIVE_NONE;
35
- const tail = taskTail(live?.count ?? 0, data.childCount.get(task.id) ?? 0, data.dirty.has(task.id));
35
+ // T67/D4 su un cappello la cella di stato descrive il cappello (non
36
+ // eseguibile) e non le figlie, quindi resta vuota; D2/P9 — la coda diventa il
37
+ // rollup delle figlie invece del contatore conversazioni.
38
+ const isEpic = data.epics.has(task.id);
39
+ const tail = isEpic
40
+ ? epicTail(data.epicRollup.get(task.id))
41
+ : taskTail(live?.count ?? 0, data.childCount.get(task.id) ?? 0, data.dirty.has(task.id));
36
42
  const id = padId(task.id, idW);
37
43
  // Invariante ③: la descrizione è l'unico pezzo a lunghezza libera, e si taglia
38
44
  // QUI sul budget che resta dopo le colonne fisse. Lasciarlo fare a
@@ -40,7 +46,7 @@ export function TaskRow({ task, sel, focused, width, idW, tailW, data, }) {
40
46
  // più larga del pane (una colonna per emoji) e quindi scrive sopra il bordo.
41
47
  // Le parti fisse si misurano con `termWidth`: i due glifi Pri/Prog valgono 2
42
48
  // ciascuno.
43
- const head = `${CARET_OFF}${id}${glyph} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
49
+ const head = `${CARET_OFF}${id}${glyph} ${sanitize(task.pri)} ${displayProg(task.prog, isEpic)} `;
44
50
  // T118 — la colonna della coda si riserva PRIMA di tagliare la descrizione, e
45
51
  // per la stessa larghezza su ogni riga: appesa dopo, entrava nel budget solo
46
52
  // dove c'era qualcosa da scrivere, e la descrizione si tagliava a una colonna
@@ -49,10 +55,17 @@ export function TaskRow({ task, sel, focused, width, idW, tailW, data, }) {
49
55
  // Pavimento `0` su tutte e tre le misure e non un minimo di cortesia: il
50
56
  // budget è un TETTO. Un pavimento sopra lo spazio reale fa uscire la riga dal
51
57
  // pane e le mangia il bordo. `reserve` si clampa su ciò che avanza, così
52
- // `head + desc + coda` sta sempre dentro la sede.
58
+ // `head + spina + desc + coda` sta sempre dentro la sede.
53
59
  const avail = Math.max(0, width - termWidth(head));
54
60
  const reserve = Math.min(tailW, avail);
55
- const descW = avail - reserve;
61
+ // T67/D3 spina di blocco DAVANTI alla descrizione, non prima dell'id: `id`
62
+ // resta l'ancora di `padId`, e una task normale non riserva le 2 colonne (la
63
+ // cella è assente, non vuota — vedi `blockCell`). Un glifo atomico: su un
64
+ // pane troppo stretto per ospitarlo intero cade del tutto, non a metà — la
65
+ // stessa disciplina di `reserve` applicata a una cella che non si può tagliare.
66
+ const spinaFull = blockCell(blockMark?.get(task.id));
67
+ const spina = termWidth(spinaFull) <= avail - reserve ? spinaFull : '';
68
+ const descW = Math.max(0, avail - reserve - termWidth(spina));
56
69
  const desc = cut(task.desc, descW);
57
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsxs(Text, { bold: Boolean(live), color: color, children: [id, glyph] }), ` ${sanitize(task.pri)} ${displayProg(task.prog)} ${desc}`, ' '.repeat(Math.max(0, descW - termWidth(desc))), pad(tail, reserve, 'right')] }));
70
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsxs(Text, { bold: Boolean(live), color: color, children: [id, glyph] }), ` ${sanitize(task.pri)} ${displayProg(task.prog, isEpic)} ${spina}${desc}`, ' '.repeat(Math.max(0, descW - termWidth(desc))), pad(tail, reserve, 'right')] }));
58
71
  }
package/dist/view.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // T39 — Core della vista: ordinali, sort chain multi-chiave, filtri.
2
2
  // Modulo PURO: nessun import da ink/react, nessun I/O → testabile senza terminale.
3
- import { taskTail } from './glyphs.js';
3
+ import { epicTail, taskTail } from './glyphs.js';
4
4
  import { termWidth } from './width.js';
5
5
  // D2 (preflight T39): sort opinato, filtri off. La lista parte ordinata ma
6
6
  // completa — nessuna task sparisce senza che l'utente abbia toccato una leva.
@@ -112,7 +112,14 @@ export function padId(id, cols) {
112
112
  export function taskColumns(tasks, data) {
113
113
  let tail = 0;
114
114
  for (const t of tasks) {
115
- tail = Math.max(tail, termWidth(taskTail(data.live.get(t.id)?.count ?? 0, data.childCount.get(t.id) ?? 0, data.dirty.has(t.id))));
115
+ // T67 un cappello scrive il rollup ({chiuse/totali}) al posto del
116
+ // contatore conversazioni: due formattazioni diverse, quindi la colonna
117
+ // deve misurare quella che la riga disegna DAVVERO per quel task, non
118
+ // sempre `taskTail`.
119
+ const cell = data.epics.has(t.id)
120
+ ? epicTail(data.epicRollup.get(t.id))
121
+ : taskTail(data.live.get(t.id)?.count ?? 0, data.childCount.get(t.id) ?? 0, data.dirty.has(t.id));
122
+ tail = Math.max(tail, termWidth(cell));
116
123
  }
117
124
  return { id: idColumnWidth(tasks), tail: tail > 0 ? tail + 1 : 0 };
118
125
  }
@@ -178,11 +185,119 @@ export function isVisible(task, view) {
178
185
  return false;
179
186
  return true;
180
187
  }
181
- /** Filtra poi ordina. Non muta l'input: il polling di tasks.md resta ignaro. */
188
+ /**
189
+ * Cappello → id normalizzati a un albero valido: entrate scartate quando il
190
+ * cappello non è NELLA LISTA passata (P6 — capo-blocco, non sparizione), punta
191
+ * a se stesso, o chiude un ciclo.
192
+ *
193
+ * Rilevamento ciclo sul grafo ORIGINALE (mai su una versione già potata): per
194
+ * ogni id si cammina la catena fino a `raw.size` passi. Se la catena rivede
195
+ * l'id di PARTENZA, quell'id è membro di un ciclo e perde il proprio arco — chi
196
+ * punta a lui da fuori il ciclo mantiene invece il proprio, e finisce agganciato
197
+ * a un capo-blocco valido (l'ex membro del ciclo, ora orfano). Implementation
198
+ * Notes: «i membri del ciclo diventano capi-blocco, nessuno sparisce».
199
+ */
200
+ function resolveParents(tasks, epicOf) {
201
+ const byId = new Set(tasks.map((t) => t.id));
202
+ const raw = new Map();
203
+ for (const t of tasks) {
204
+ const p = epicOf.get(t.id);
205
+ if (p && p !== t.id && byId.has(p))
206
+ raw.set(t.id, p);
207
+ }
208
+ const cyclic = new Set();
209
+ for (const id of raw.keys()) {
210
+ let cur = raw.get(id);
211
+ let steps = 0;
212
+ while (cur !== undefined && steps <= raw.size) {
213
+ if (cur === id) {
214
+ cyclic.add(id);
215
+ break;
216
+ }
217
+ cur = raw.get(cur);
218
+ steps++;
219
+ }
220
+ }
221
+ const parentOf = new Map();
222
+ for (const [id, p] of raw)
223
+ if (!cyclic.has(id))
224
+ parentOf.set(id, p);
225
+ return parentOf;
226
+ }
227
+ /**
228
+ * Riordina `tasks` in blocchi cappello+figlie e li appiattisce in un unico
229
+ * array: i capi-blocco (cappelli + task senza cappello valido, P6) si ordinano
230
+ * fra loro con `sort`, poi le figlie di ognuno si ordinano fra loro con lo
231
+ * STESSO `sort` e si appiattiscono in preordine sotto la propria mamma —
232
+ * ricorsivo, non un livello solo (P7), così un'epica annidata porta le proprie
233
+ * figlie subito sotto di sé.
234
+ *
235
+ * `blockMark` marca SOLO le righe che fanno parte di un blocco: `┌` sul
236
+ * cappello (un capo-blocco con almeno una figlia in lista), `│`/`└` sulle
237
+ * figlie (`└` sull'ultima dell'INTERO sottoalbero, non della sola lista
238
+ * immediata — un'epica annidata non chiude il blocco, ci sono ancora le sue
239
+ * figlie sotto). Una task normale non compare nella mappa: niente cella (D3).
240
+ */
241
+ export function groupHierarchy(tasks, sort, ctx) {
242
+ const parentOf = resolveParents(tasks, ctx.epicOf);
243
+ const childrenOf = new Map();
244
+ for (const t of tasks) {
245
+ const p = parentOf.get(t.id);
246
+ if (!p)
247
+ continue;
248
+ if (!childrenOf.has(p))
249
+ childrenOf.set(p, []);
250
+ childrenOf.get(p).push(t);
251
+ }
252
+ const roots = tasks.filter((t) => !parentOf.has(t.id));
253
+ roots.sort((a, b) => compareTasks(a, b, sort, ctx));
254
+ function flatten(task) {
255
+ const kids = [...(childrenOf.get(task.id) ?? [])].sort((a, b) => compareTasks(a, b, sort, ctx));
256
+ const sub = [task];
257
+ for (const kid of kids)
258
+ sub.push(...flatten(kid));
259
+ return sub;
260
+ }
261
+ const order = [];
262
+ const blockMark = new Map();
263
+ for (const root of roots) {
264
+ const block = flatten(root);
265
+ if (block.length > 1)
266
+ blockMark.set(root.id, '┌');
267
+ for (let i = 1; i < block.length; i++) {
268
+ blockMark.set(block[i].id, i === block.length - 1 ? '└' : '│');
269
+ }
270
+ order.push(...block);
271
+ }
272
+ return { tasks: order, blockMark };
273
+ }
274
+ /**
275
+ * Rollup {chiuse/totali} delle figlie DICHIARATE di ogni cappello — cieco a
276
+ * filtri e viste (P9): conta su TUTTE le task passate, non sulla vista
277
+ * corrente, o il numero cambierebbe filtrando senza che nessuna figlia sia
278
+ * davvero comparsa o sparita.
279
+ */
280
+ export function epicRollup(tasks, epicOf) {
281
+ const parentOf = resolveParents(tasks, epicOf);
282
+ const rollup = new Map();
283
+ for (const t of tasks) {
284
+ const p = parentOf.get(t.id);
285
+ if (!p)
286
+ continue;
287
+ const entry = rollup.get(p) ?? { closed: 0, total: 0 };
288
+ entry.total++;
289
+ if (progName(t.prog) === 'done')
290
+ entry.closed++;
291
+ rollup.set(p, entry);
292
+ }
293
+ return rollup;
294
+ }
295
+ /** Filtra, ordina, raggruppa in blocchi. Non muta l'input: il polling di
296
+ * tasks.md resta ignaro. */
182
297
  export function applyView(tasks, view, ctx) {
183
- const visible = tasks.filter((t) => isVisible(t, view));
184
- visible.sort((a, b) => compareTasks(a, b, view.sort, ctx));
185
- return { visible, hidden: tasks.length - visible.length };
298
+ const filtered = tasks.filter((t) => isVisible(t, view));
299
+ const { tasks: visible, blockMark } = groupHierarchy(filtered, view.sort, ctx);
300
+ return { visible, hidden: tasks.length - filtered.length, blockMark };
186
301
  }
187
302
  export const PRI_ENTRIES = PRI_TABLE.map((e) => ({ name: e.name, glyph: e.glyph }));
188
303
  export const PROG_ENTRIES = PROG_TABLE.map((e) => ({ name: e.name, glyph: e.glyph }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.59.0",
3
+ "version": "0.61.0",
4
4
  "description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/deck-run CHANGED
@@ -183,7 +183,7 @@ USAGE="uso: deck-run <TaskID> [--model <alias>] [--prompt-kind <kind>] [--sessio
183
183
  --model modello della sessione: fable|opus|sonnet|haiku (default: opus)
184
184
  valore ignoto → fallback sul default, con avviso su stderr
185
185
 
186
- --title-note nota della conversazione, appesa al titolo tab come «nota»
186
+ --title-note nota della conversazione, appesa al titolo tab
187
187
  (ridotta a lettere/cifre/spazi/-/_, cap 60 char)"
188
188
 
189
189
  if [[ $NO_TASK -eq 1 && -n "$TASK" ]]; then
@@ -317,15 +317,16 @@ if [[ -n "$_cfg" ]]; then
317
317
  fi
318
318
  # Suffisso nota (T64): distingue fra loro le tab di più conversazioni sulla
319
319
  # STESSA task, che altrimenti condividono un titolo identico (`label · T81`) —
320
- # la nota è già la maniglia con cui l'utente le distingue in lista, e le stesse
321
- # `«…»` della lista la rendono riconoscibile a colpo d'occhio anche nella tab
322
- # bar. In coda per la solita ragione degli altri suffissi: il match compass è
323
- # `.includes(label)`, la label deve restare intatta in TESTA.
324
- # Nota ridotta a vuoto (era tutta emoji/punteggiatura) nessun suffisso, non
325
- # `«»` a vuoto.
320
+ # la nota è già la maniglia con cui l'utente le distingue in lista. In coda per
321
+ # la solita ragione degli altri suffissi: il match compass è `.includes(label)`,
322
+ # la label deve restare intatta in TESTA.
323
+ # T150 nudo, senza i caporali `«…»` che lo decoravano: la nota di fallback
324
+ # generata dal deck quando il campo è vuoto passa dallo stesso alfabeto ridotto
325
+ # e non ha bisogno di una cornice per distinguersi da un titolo scritto a mano.
326
+ # Nota ridotta a vuoto (era tutta emoji/punteggiatura) → nessun suffisso.
326
327
  if [[ -n "$TITLE_NOTE" ]]; then
327
328
  _note="$(_sane_note "$TITLE_NOTE")"
328
- [[ -n "$_note" ]] && TITLE="${TITLE} «${_note}»"
329
+ [[ -n "$_note" ]] && TITLE="${TITLE} ${_note}"
329
330
  fi
330
331
  # Suffisso fork (T28): un ramo eredita task e label dell'origine, quindi senza
331
332
  # marcatore le due tab risulterebbero omonime nella stessa window. Suffisso e