@lamemind/loom-deck 0.46.0 → 0.48.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/cli.js CHANGED
@@ -11,13 +11,13 @@ import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch,
11
11
  import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, TASK_VIEWS, } from './pane-views.js';
12
12
  import { isCompact, layoutBudget, searchPreviewCapacity, windowRange, } from './viewport.js';
13
13
  import { cut, cutMiddle, sanitize, termWidth } from './width.js';
14
- import { applyView, cycleSort, describeSort, idColumnWidth, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
14
+ import { applyView, cycleSort, describeSort, priName, progName, taskColumns, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
15
15
  import { initialDetail, writeTaskEdit, PRI_GLYPH, PRI_LABEL } from './task-edit.js';
16
16
  import { ALL, EDIT_FIELDS, EDIT_PRI, EDIT_PROG, editTextField, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, QUIT_WINDOW_MS, ROW_ALL, ROW_SPOT, SORT_TASTI, SPOT, } from './model.js';
17
17
  import { purgeTargets, splitTargets } from './purge.js';
18
18
  import { conversationLabel, isDone } from './layout.js';
19
19
  import { fieldsKey } from './fields.js';
20
- import { TASK_EMPTY, relTime, sanitizeTyped, taskTail } from './glyphs.js';
20
+ import { TASK_EMPTY, relTime, sanitizeTyped } from './glyphs.js';
21
21
  import { commitTaskEdit, runLaunch, spawnClaudeEmpty, spawnCleanTasks, spawnCreateTask, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, onInTabCommand, CLAUDE_CMD, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
22
22
  import { EditModal, FilterModal, PurgeModal, SortModal, idList } from './ui/modals.js';
23
23
  import { ReaderScreen, SearchScreen } from './ui/search-screen.js';
@@ -162,28 +162,43 @@ function Deck({ cwd, tasksPath, tasksDir }) {
162
162
  // alla vista che sceglie l'header (D2 create).
163
163
  const parentLabel = isAll ? 'tutte' : isSpot ? 'spot' : selectedTaskId ?? '—';
164
164
  // Conteggio figli per task + spot (badge nel Tasks pane).
165
+ //
166
+ // T124 — il rollup delle VIVE si deriva nello stesso ciclo, non da un secondo
167
+ // passaggio sul registry dei processi: quel registry conosce una conversazione
168
+ // PRIMA che abbia scritto il suo primo record di transcript, quindi due
169
+ // derivazioni indipendenti renderebbero producibile `1/0` — una viva senza
170
+ // totale — che non è un caso limite teorico ma la finestra normale fra lo
171
+ // spawn e il primo turno. Intersecando qui, `vive ≤ totali` è garantito per
172
+ // costruzione: la conversazione appena nata resta invisibile a entrambi i
173
+ // numeri finché non scrive, e il segnale arriva in ritardo di un turno invece
174
+ // che sbagliato.
165
175
  const childCount = new Map();
176
+ const taskLive = new Map();
166
177
  let spotCount = 0;
167
178
  for (const s of sessions) {
168
179
  const bound = bindings.get(s.sessionId);
169
- if (bound)
170
- childCount.set(bound, (childCount.get(bound) ?? 0) + 1);
171
- else
180
+ if (!bound) {
172
181
  spotCount++;
182
+ continue;
183
+ }
184
+ childCount.set(bound, (childCount.get(bound) ?? 0) + 1);
185
+ const entry = live.get(s.sessionId);
186
+ if (!entry)
187
+ continue;
188
+ const prev = taskLive.get(bound);
189
+ // Rollup a stato misto: vince `busy`. Fra N vive, quella che sta lavorando è
190
+ // la ragione per cui si guarda la riga.
191
+ taskLive.set(bound, {
192
+ count: (prev?.count ?? 0) + 1,
193
+ status: prev?.status === 'busy' || entry.status === 'busy' ? 'busy' : 'idle',
194
+ });
173
195
  }
196
+ const taskRowData = { childCount, live: taskLive, dirty: dirtyFolders };
174
197
  // T118 — colonne fisse della lista task, misurate su `paneTasks` (la vista
175
198
  // attiva INTERA) e non sulla finestra visibile: gemelle di `sessionCols` e
176
199
  // per la stessa ragione, che una larghezza derivata dallo schermo si muove a
177
- // ogni scroll. La coda porta dentro il proprio gutter, così l'allineamento a
178
- // destra lo produce `pad` da sé; nessuna riga con qualcosa da scrivere →
179
- // colonna spenta a `0`, e le descrizioni si riprendono lo spazio.
180
- const taskCols = (() => {
181
- let tail = 0;
182
- for (const t of paneTasks) {
183
- tail = Math.max(tail, termWidth(taskTail(childCount.get(t.id) ?? 0, dirtyFolders.has(t.id))));
184
- }
185
- return { id: idColumnWidth(paneTasks), tail: tail > 0 ? tail + 1 : 0 };
186
- })();
200
+ // ogni scroll.
201
+ const taskCols = taskColumns(paneTasks, taskRowData);
187
202
  // Figli della selezione: tutte le conversazioni del progetto (`≡ tutte`), le
188
203
  // sessioni bound alla task selezionata, oppure (spot) quelle senza binding.
189
204
  // sessions è già ts desc → l'ordine si eredita in tutti e tre i rami.
@@ -1276,7 +1291,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1276
1291
  // stale non resta nulla: il titolo si accontenta dell'hash.
1277
1292
  const label = (assign.sid ? sessionNotes.get(assign.sid) : '') ||
1278
1293
  (s ? conversationLabel(s, projectCore, assign.sid ? bindings.get(assign.sid) : undefined) : '');
1279
- return (_jsx(AssignScreen, { sessionId: assign.sid ?? '', label: label, current: assign.sid ? bindings.get(assign.sid) ?? null : null, filter: assign.filter, rows: assign.list.slice(win.start, win.end), selected: assign.sel, matched: assign.list.length - 1, hidden: hiddenTasks, above: win.start, below: assign.list.length - win.end, childCount: childCount, columns: columns, note: note }));
1294
+ // T124 le colonne si misurano sulla lista FILTRATA di questa schermata,
1295
+ // non su `paneTasks`: la popolazione è un'altra, e riusare le larghezze del
1296
+ // pane darebbe una colonna dimensionata su righe che qui non ci sono.
1297
+ // `assign.list` porta in testa la riga `detach` (`null`), che non è una task.
1298
+ const assignCols = taskColumns(assign.list.filter((t) => t !== null), taskRowData);
1299
+ return (_jsx(AssignScreen, { sessionId: assign.sid ?? '', label: label, current: assign.sid ? bindings.get(assign.sid) ?? null : null, filter: assign.filter, rows: assign.list.slice(win.start, win.end), selected: assign.sel, matched: assign.list.length - 1, hidden: hiddenTasks, above: win.start, below: assign.list.length - win.end, idW: assignCols.id, tailW: assignCols.tail, data: taskRowData, columns: columns, note: note }));
1280
1300
  }
1281
1301
  // ── T66 · detail della task ─────────────────────────────────────────────
1282
1302
  // Quarta schermata sostitutiva, stessa ragione delle altre tre: un task file
@@ -1381,7 +1401,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1381
1401
  if (budget.compact) {
1382
1402
  return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "v", VERSION, " \u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? parentLabel, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
1383
1403
  }
1384
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: ["v", VERSION] })] }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["titolo conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'purge' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["elimina task \u00B7", ' ', purge?.ignored ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " keep/purge dei file non tracciati \u00B7", ' '] })) : null, _jsx(Text, { color: "yellow", children: "\u23CE" }), " conferma \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${legend.shown}` : '', legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editCursor.row, caret: editCursor.caret, columns: columns })) : null, mode === 'purge' && purge ? _jsx(PurgeModal, { draft: purge, columns: columns }) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, counts: taskCounts, activeView: taskViewId, paneCount: paneTasks.length, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, idW: taskCols.id, tailW: taskCols.tail, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: paneTasks.length - taskWin.end, columns: columns, dirty: dirtyFolders }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, counts: sessionCounts, activeView: sessionViewId, paneCount: sessionRows.length, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore, live: live })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '', live: live.get(selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1404
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: ["v", VERSION] })] }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["titolo conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'purge' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["elimina task \u00B7", ' ', purge?.ignored ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " keep/purge dei file non tracciati \u00B7", ' '] })) : null, _jsx(Text, { color: "yellow", children: "\u23CE" }), " conferma \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${legend.shown}` : '', legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editCursor.row, caret: editCursor.caret, columns: columns })) : null, mode === 'purge' && purge ? _jsx(PurgeModal, { draft: purge, columns: columns }) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, counts: taskCounts, activeView: taskViewId, paneCount: paneTasks.length, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, idW: taskCols.id, tailW: taskCols.tail, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: paneTasks.length - taskWin.end, columns: columns, data: taskRowData }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, counts: sessionCounts, activeView: sessionViewId, paneCount: sessionRows.length, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore, live: live })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '', live: live.get(selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
1385
1405
  }
1386
1406
  const cwd = process.cwd();
1387
1407
  // T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
package/dist/glyphs.js CHANGED
@@ -64,6 +64,14 @@ export function modelShort(id) {
64
64
  // Sulla riga CHIUSA c'è uno spazio e non un terzo glifo: le chiuse sono la
65
65
  // maggioranza di ogni lista, e marcarle vorrebbe dire disegnare N volte «niente
66
66
  // da dire» — la colonna diventerebbe rumore invece di un segnale.
67
+ //
68
+ // T124 — non sono più il vocabolario di una superficie sola: la riga del pane
69
+ // task adotta la stessa coppia, nella stessa forma (glifo attaccato all'id che
70
+ // qualifica, colore e grassetto sull'id) e con lo stesso significato. L'unica
71
+ // differenza ammessa è la POPOLAZIONE su cui il glifo si pronuncia — una
72
+ // conversazione di là, le N conversazioni di una task di qua, con un rollup che
73
+ // fa vincere `busy`. Riusarli con un significato spostato sarebbe un falso
74
+ // amico, peggio di un glifo nuovo.
67
75
  export const LIVE_IDLE = '●';
68
76
  export const LIVE_BUSY = '◍';
69
77
  export const LIVE_NONE = ' ';
@@ -89,13 +97,33 @@ export function displayProg(prog) {
89
97
  * a disegnarla (sulla finestra, in `panes.tsx`). Due formattazioni gemelle
90
98
  * divergerebbero alla prima modifica, e la colonna risulterebbe larga quanto
91
99
  * una stringa che nessuno scrive.
100
+ *
101
+ * T124 — il contatore spezza vive e totali (`(1/5`) invece di sommarle: un
102
+ * numero solo cresce e non torna più indietro, quindi dice quanta storia ha la
103
+ * task e non se lì sta succedendo qualcosa adesso. A zero vive resta il solo
104
+ * totale (`(5`): scrivere `(0/5` metterebbe uno zero su quasi tutte le righe di
105
+ * ogni lista, e il segnale sparirebbe nel rumore.
106
+ *
107
+ * La parentesi di CHIUSURA cade sempre. La colonna è ancorata a destra, quindi
108
+ * la `)` costerebbe una cella per spostare verso l'interno l'unica cosa che si
109
+ * legge davvero; il bordo del pane chiude già il gruppo. Quella di apertura
110
+ * serve invece a staccare il contatore dalla descrizione a lunghezza libera che
111
+ * gli sta a sinistra — l'asimmetria è voluta.
92
112
  */
93
- export function taskTail(childCount, dirty) {
94
- const count = childCount > 0 ? `(${childCount})` : '';
113
+ export function taskTail(live, total, dirty) {
114
+ const count = total > 0 ? (live > 0 ? `(${live}/${total}` : `(${total}`) : '';
95
115
  if (!dirty)
96
116
  return count;
97
117
  return count ? `${WARN} ${count}` : WARN;
98
118
  }
119
+ /** T124 — contatore di una riga META del pane task (`≡ tutte`, `○ spot`), che
120
+ * non è una colonna ancorata a niente. Prende comunque la grafia senza chiusa:
121
+ * due forme dello stesso oggetto a due righe di distanza si leggono come un
122
+ * errore, mentre una forma sola applicata anche dove la sua ragione non serve
123
+ * si legge come una convenzione. */
124
+ export function metaCount(n) {
125
+ return n > 0 ? ` (${n}` : '';
126
+ }
99
127
  // T49 — size umana compatta per il detail pane sessione.
100
128
  export function fmtSize(bytes) {
101
129
  if (bytes < 1024)
package/dist/model.js CHANGED
@@ -56,10 +56,14 @@ export const KIND_LABEL = { ai: 'IA', tool: 'tools', human: 'human' };
56
56
  // T41 — ordine dei valori nel modale edit. Deliberatamente DIVERSO da
57
57
  // PRI_ENTRIES/PROG_ENTRIES (che seguono il rango di sort): qui si sceglie un
58
58
  // valore, non si ordina, quindi vince l'ordine del CICLO DI VITA — da fare →
59
- // in corso → chiusa → bloccata. La priorità resta alta→bassa, che è già
60
- // l'ordine naturale di lettura.
59
+ // preflight fatto → in corso → chiusa → bloccata. La priorità resta alta→bassa,
60
+ // che è già l'ordine naturale di lettura.
61
+ //
62
+ // L'elenco deve coprire OGNI valore di ProgName: il cursore del modale nasce da
63
+ // `Math.max(0, EDIT_PROG.indexOf(prog))` (cli.tsx), quindi un valore assente
64
+ // torna -1, viene schiacciato a 0 e salvare retrocede la task alla prima voce.
61
65
  export const EDIT_PRI = ['high', 'med', 'low'];
62
- export const EDIT_PROG = ['todo', 'wip', 'done', 'locked'];
66
+ export const EDIT_PROG = ['todo', 'ready', 'wip', 'done', 'locked'];
63
67
  // T117 — le righe del modale edit come DATO, nella forma che `fields.ts`
64
68
  // consuma: 0 priorità · 1 stato · 2 progresso libero · 3 titolo. Il modale non
65
69
  // dà lettere di selezione alle due righe a scelta, dove i valori sono glifi e
@@ -18,7 +18,8 @@ import { scanText, topForOffset } from '../text-search.js';
18
18
  import { parseMarkdown } from '../markdown.js';
19
19
  import { cpLen, insertAt, removeAt } from '../layout.js';
20
20
  import { sanitizeTyped } from '../glyphs.js';
21
- import { ACTION_HOTKEYS, DETAIL_ACTIONS, MODELS, MODEL_DEFAULT, } from '../spawn.js';
21
+ import { ACTION_HOTKEYS, DETAIL_ACTIONS, MODELS, MODEL_DEFAULT, specializeRecap, } from '../spawn.js';
22
+ import { taskIsEpic } from '../tasks.js';
22
23
  import { fieldsKey } from '../fields.js';
23
24
  import { loadPromptCatalog, promptFor } from '../prompt-catalog.js';
24
25
  // T117 — le quattro righe dell'area di compilazione del detail, nell'ordine in
@@ -50,6 +51,10 @@ export function useSheetOverlay(deps) {
50
51
  // T117 — il prompt iniziale, EDITABILE. Quello che si legge nel campo è quello
51
52
  // che parte: non un'anteprima di qualcos'altro.
52
53
  const [prompt, setPrompt] = useState('');
54
+ /** La task aperta è un cappello (`Size: Epic`)? Deciso UNA VOLTA all'apertura,
55
+ * dal testo che il detail ha già in mano: rifarlo a ogni cambio di azione
56
+ * riparserebbe l'intero task file per un campo dell'header. */
57
+ const [epic, setEpic] = useState(false);
53
58
  const [cursor, setCursor] = useState({ row: DROW.action, caret: 0 });
54
59
  // Il catalogo si legge una volta per vita del deck: è un file di quattro righe
55
60
  // accanto al codice, non un dato che cambia sotto i piedi.
@@ -105,12 +110,16 @@ export function useSheetOverlay(deps) {
105
110
  }, [findRes, occCur, lines, capacity]);
106
111
  /** Apre il detail su una task, azzerando scroll, area di compilazione e ricerca. */
107
112
  function open(next) {
113
+ // Calcolato qui e usato subito: `setEpic` non ha ancora aggiornato lo stato
114
+ // quando `setPrompt` gira, quindi il valore locale è l'unico leggibile ora.
115
+ const isEpic = taskIsEpic(next.id, next.text);
108
116
  setSheet(next);
109
117
  setTop(0);
110
118
  setAction(0);
119
+ setEpic(isEpic);
111
120
  setModel(MODEL_DEFAULT);
112
121
  setSpawnNote('');
113
- setPrompt(promptFor(catalog, DETAIL_ACTIONS[0].kind, next.id));
122
+ setPrompt(promptFor(catalog, specializeRecap(DETAIL_ACTIONS[0].kind, isEpic), next.id));
114
123
  setCursor({ row: DROW.action, caret: 0 });
115
124
  setFind(null);
116
125
  setOccIdx(0);
@@ -129,7 +138,7 @@ export function useSheetOverlay(deps) {
129
138
  setAction(index);
130
139
  const id = sheet?.id;
131
140
  if (id)
132
- setPrompt(promptFor(catalog, DETAIL_ACTIONS[index].kind, id));
141
+ setPrompt(promptFor(catalog, specializeRecap(DETAIL_ACTIONS[index].kind, epic), id));
133
142
  }
134
143
  // Il ponte fra le quattro righe e i quattro stati. Le righe restano
135
144
  // TIPIZZATE dove vivono (`ModelKind`, indice dell'azione) invece di finire in
@@ -234,13 +243,16 @@ export function useSheetOverlay(deps) {
234
243
  if (key.return) {
235
244
  // `⏎` esegue SEMPRE l'azione selezionata, da qualunque riga: i campi sono
236
245
  // di una riga sola, quindi nessuno di loro ha da farci un a-capo.
246
+ // Il kind viaggia specializzato quanto il prompt che lo accompagna: se il
247
+ // campo è stato svuotato a mano il testo non parte e resta lui a dire cosa
248
+ // ricevera' la sessione, quindi i due non possono divergere.
237
249
  const act = DETAIL_ACTIONS[action];
238
250
  const id = sheet?.id;
239
251
  const note = spawnNote.trim();
240
252
  const text = prompt.trim();
241
253
  close();
242
254
  if (id)
243
- onAction(id, act.kind, model, note, text);
255
+ onAction(id, specializeRecap(act.kind, epic), model, note, text);
244
256
  return;
245
257
  }
246
258
  if (key.pageUp) {
package/dist/spawn.js CHANGED
@@ -117,6 +117,27 @@ export function onInTabCommand(child, cb) {
117
117
  }
118
118
  });
119
119
  }
120
+ /**
121
+ * `recap` → la sotto-skill giusta, quando chi spawna SA se la task è un cappello.
122
+ *
123
+ * `recap` resta il kind onesto per chi non lo sa: punta al dispatcher, che
124
+ * risolve la task e classifica da sé. È il caso degli acceleratori della lista,
125
+ * dove il deck ha in mano solo `tasks.md` — e lì il `Size` non c'è. Il DETAIL
126
+ * invece il task file l'ha già letto, quindi può saltare il giro e pagare un
127
+ * turno di modello in meno.
128
+ *
129
+ * Non è una classificazione duplicata: il criterio (`Size: Epic`) resta uno solo
130
+ * e sta in `taskIsEpic`, che legge lo stesso campo che leggerebbe il dispatcher.
131
+ * Quello che si evita è il RITARDO, non il giudizio.
132
+ *
133
+ * Ogni kind diverso da `recap` passa intatto: la specializzazione è un caso, non
134
+ * una trasformazione da applicare a tutti.
135
+ */
136
+ export function specializeRecap(kind, epic) {
137
+ if (kind !== 'recap')
138
+ return kind;
139
+ return epic ? 'recap-epic' : 'recap-task';
140
+ }
120
141
  // L'ordine È il giro di `tab` nel detail, non una preferenza di lettura:
121
142
  // cambiarlo sposta le voci sotto le dita di chi le ha imparate. Fino a T111 era
122
143
  // anche il binding delle cifre `1`-`4`, passate poi al campo nota.
package/dist/task-edit.js CHANGED
@@ -14,6 +14,7 @@ export const PRI_GLYPH = { high: '🔥', med: '⚡', low: '🔹' };
14
14
  // lunga resta riconosciuto da priName/progName/isDone.
15
15
  export const PROG_GLYPH = {
16
16
  todo: '🔵',
17
+ ready: '🟢',
17
18
  wip: '🟡',
18
19
  done: '✔️',
19
20
  locked: '🔒',
@@ -23,6 +24,7 @@ export const PROG_GLYPH = {
23
24
  // `🔵 Todo`, `✔️ Done at 2026-07-20`) → il glifo è il prefisso, il resto è prosa.
24
25
  const PROG_DEFAULT = {
25
26
  todo: 'Todo',
27
+ ready: 'Ready',
26
28
  wip: 'In Progress',
27
29
  done: 'Done',
28
30
  locked: 'Locked',
package/dist/tasks.js CHANGED
@@ -153,6 +153,30 @@ export function parseTaskDetail(id, content) {
153
153
  description: sanitize(descLines.join('\n').trim()),
154
154
  };
155
155
  }
156
+ /**
157
+ * La task è un cappello (epica)?
158
+ *
159
+ * Il marker è `Size: Epic`, e sta sul CAPPELLO perché la parentela la dichiara
160
+ * la figlia (`**Parent Task**`): un padre non sa di averne senza scandagliare
161
+ * tutti gli altri task file, cosa che il deck non può fare nel loop di poll.
162
+ *
163
+ * Legge dal testo INTEGRALE del task file, l'unico posto dove il `Size` esiste:
164
+ * in `tasks.md` quella colonna non c'è, quindi la lista non può rispondere e la
165
+ * domanda ha senso solo dove il file è già stato aperto (il detail).
166
+ *
167
+ * Riusa `parseTaskDetail` invece di una regex propria: la grammatica dei bullet
168
+ * header (`- **Campo**: valore`) è già scritta lì, e una seconda copia
169
+ * divergerebbe al primo campo che cambia forma. Confronto case-insensitive — il
170
+ * valore lo scrive un umano nel file, non uno script.
171
+ *
172
+ * Testo assente (`null`, task file non ancora letto) → `false`: la mancanza di
173
+ * prova non è prova di cappello, e degradare sul caso comune è l'esito benigno.
174
+ */
175
+ export function taskIsEpic(id, text) {
176
+ if (!text)
177
+ return false;
178
+ return (parseTaskDetail(id, text).fields['Size'] ?? '').trim().toLowerCase() === 'epic';
179
+ }
156
180
  /**
157
181
  * Testo INTEGRALE del task file (T66 · detail).
158
182
  *
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Schermata di assegnazione conversazione → task (T57).
3
3
  import { Box, Text } from 'ink';
4
- import { cut, sanitize, termWidth } from '../width.js';
5
- import { assignTextWidth, isDone } from '../layout.js';
6
- import { CARET, CARET_OFF, displayProg } from '../glyphs.js';
4
+ import { cut, sanitize } from '../width.js';
5
+ import { assignTextWidth } from '../layout.js';
6
+ import { CARET, CARET_OFF } from '../glyphs.js';
7
+ import { TaskRow } from './task-row.js';
7
8
  /**
8
9
  * Schermata di assegnazione di una conversazione a una task (T57).
9
10
  *
@@ -16,7 +17,7 @@ import { CARET, CARET_OFF, displayProg } from '../glyphs.js';
16
17
  * filtro può nascondere proprio il bersaglio, e quel prezzo non deve essere
17
18
  * silenzioso — stessa convenzione del `+N più vecchie` del pane sessioni.
18
19
  */
19
- export function AssignScreen({ sessionId, label, current, filter, rows, selected, matched, hidden, above, below, childCount, columns, note, }) {
20
+ export function AssignScreen({ sessionId, label, current, filter, rows, selected, matched, hidden, above, below, idW, tailW, data, columns, note, }) {
20
21
  const width = assignTextWidth(columns);
21
22
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["assegna ", _jsx(Text, { color: "cyan", children: sessionId.slice(0, 8) }), label ? ` «${cut(sanitize(label), Math.max(10, Math.floor(width / 4)))}»` : '', " \u00B7 ora", ' ', current ? _jsx(Text, { color: "green", children: current }) : 'spot', " \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), ' ', "assegna \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " pulisci \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }), _jsx(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "filtro " }), _jsx(Text, { color: "yellow", children: cut(filter, Math.max(8, width - 24)) }), _jsx(Text, { inverse: true, children: " " }), !filter ? _jsx(Text, { dimColor: true, children: " (id o titolo)" }) : null] }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [matched, " task", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", hidden, " fuori dai filtri"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), rows.map((task) => {
22
23
  // T57/D2 — `detach` è una VOCE della lista, non un tasto a parte: un
@@ -30,11 +31,11 @@ export function AssignScreen({ sessionId, label, current, filter, rows, selected
30
31
  // il nome dell'azione invece di un moncone di frase.
31
32
  return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, "\u25CB detach ", cut('la sessione torna spot', width - 12)] }, "detach"));
32
33
  }
33
- const sel = selected === task.id;
34
- const n = childCount.get(task.id) ?? 0;
35
- const head = `${CARET_OFF}${task.id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
36
- const tail = n > 0 ? ` (${n})` : '';
37
- const desc = cut(task.desc, Math.max(4, width - termWidth(head) - termWidth(tail)));
38
- return (_jsxs(Text, { inverse: sel, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
34
+ // T124 la riga è la STESSA del pane task, non più una copia
35
+ // ricomposta qui: id paddato alla colonna, coda ancorata al bordo
36
+ // destro, marker dirty ed evidenze della liveness arrivano tutti dal
37
+ // componente condiviso. `focused` è sempre vero una schermata
38
+ // sostitutiva non cede il fuoco a nessun altro pane.
39
+ return (_jsx(TaskRow, { task: task, sel: selected === task.id, focused: true, width: width, idW: idW, tailW: tailW, data: data }, task.id));
39
40
  })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
40
41
  }
package/dist/ui/panes.js CHANGED
@@ -4,12 +4,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  // larghezze già calcolate, non li derivano.
5
5
  import { Box, Text } from 'ink';
6
6
  import { cut, cutParts, pad, sanitize, termWidth } from '../width.js';
7
- import { isDone, paneTextWidth } from '../layout.js';
8
- import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, MODEL_W, SESSION_SEP, SID_CHARS, TASK_EMPTY, WARN, displayProg, modelShort, relTime, taskTail, } from '../glyphs.js';
7
+ import { paneTextWidth } from '../layout.js';
8
+ import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, MODEL_W, SESSION_SEP, SID_CHARS, TASK_EMPTY, WARN, metaCount, modelShort, relTime, } from '../glyphs.js';
9
+ import { TaskRow } from './task-row.js';
9
10
  import { META_ROWS, ROW_ALL, ROW_SPOT } from '../model.js';
10
11
  import { rowLabel, sessionTitle } from '../session-list.js';
11
12
  import { sessionView, taskView, SESSION_VIEWS, TASK_VIEWS, } from '../pane-views.js';
12
- import { describeSort, padId, PRI_ENTRIES, PROG_ENTRIES } from '../view.js';
13
+ import { describeSort, PRI_ENTRIES, PROG_ENTRIES, } from '../view.js';
13
14
  /**
14
15
  * Header del pane task, tagliato QUI e non da Ink — stesso motivo del gemello
15
16
  * `SessionsHeader`, con una differenza di rischio: qui i segmenti sono tutti
@@ -49,7 +50,7 @@ export function TasksHeader({ counts, active, above, below, focused, columns, })
49
50
  const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
50
51
  return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, inverse: seg.active, children: shown[i] }, i)) : null) }));
51
52
  }
52
- export function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, childCount, idW, tailW, focused, loadError, windowStart, above, below, columns, dirty, }) {
53
+ export function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, idW, tailW, focused, loadError, windowStart, above, below, columns, data, }) {
53
54
  const allSelected = selected === ROW_ALL;
54
55
  const spotSelected = selected === ROW_SPOT;
55
56
  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)}` +
@@ -61,42 +62,16 @@ export function TasksPane({ tasks, counts, activeView, paneCount, view, selected
61
62
  ]
62
63
  .map((e) => `−${e.glyph}`)
63
64
  .join(' ')
64
- : '')), paneTextWidth(columns)) }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni", allCount > 0 ? ` (${allCount})` : ''] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : paneCount === 0 ? (
65
+ : '')), paneTextWidth(columns)) }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni", metaCount(allCount)] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", metaCount(spotCount)] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : paneCount === 0 ? (
65
66
  // T100/D1 — una voce a contatore 0 resta navigabile, e selezionarla dà
66
67
  // una lista vuota che DICE perché è vuota. Senza la nota il pane si
67
68
  // legge come rotto: le righe meta restano, le task no, e niente spiega
68
69
  // che è la vista scelta a non contenere nulla.
69
- _jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(taskView(activeView).empty, paneTextWidth(columns)) })) : (tasks.map((task, i) => {
70
+ _jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(taskView(activeView).empty, paneTextWidth(columns)) })) : (tasks.map((task, i) => (_jsx(TaskRow, { task: task,
70
71
  // windowStart riporta l'indice di finestra a quello della lista
71
72
  // completa, su cui è keyata la selezione. +META_ROWS: le prime due
72
73
  // righe sono le meta.
73
- const sel = windowStart + i + META_ROWS === selected;
74
- const tail = taskTail(childCount.get(task.id) ?? 0, dirty.has(task.id));
75
- // T118 — l'id è paddato a `idW`, quindi `head` ha la stessa larghezza
76
- // su ogni riga e Pri/Prog cadono sempre nella stessa colonna.
77
- const id = padId(task.id, idW);
78
- // Invariante ③: la descrizione è l'unico pezzo a lunghezza libera, e
79
- // si taglia QUI sul budget che resta dopo le colonne fisse. Lasciarlo
80
- // fare a `truncate-end` significa passare da `cli-truncate`, che
81
- // restituisce una riga più larga del pane (una colonna per emoji) e
82
- // quindi scrive sopra il bordo. Le parti fisse si misurano con
83
- // `termWidth`: i due glifi Pri/Prog valgono 2 ciascuno.
84
- const head = `${CARET_OFF}${id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
85
- // T118 — la colonna della coda si riserva PRIMA di tagliare la
86
- // descrizione, e per la stessa larghezza su ogni riga: appesa dopo,
87
- // entrava nel budget solo dove c'era qualcosa da scrivere, e la
88
- // descrizione si tagliava a una colonna diversa riga per riga.
89
- //
90
- // Pavimento `0` su tutte e tre le misure e non un minimo di cortesia:
91
- // il budget è un TETTO. Un pavimento sopra lo spazio reale fa uscire
92
- // la riga dal pane e le mangia il bordo. `reserve` si clampa su ciò
93
- // che avanza, così `head + desc + coda` sta sempre dentro il pane.
94
- const avail = Math.max(0, paneTextWidth(columns) - termWidth(head));
95
- const reserve = Math.min(tailW, avail);
96
- const descW = avail - reserve;
97
- const desc = cut(task.desc, descW);
98
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, ' '.repeat(Math.max(0, descW - termWidth(desc))), pad(tail, reserve, 'right')] }, task.id));
99
- }))] }));
74
+ sel: windowStart + i + META_ROWS === selected, focused: focused, width: paneTextWidth(columns), idW: idW, tailW: tailW, data: data }, task.id))))] }));
100
75
  }
101
76
  /**
102
77
  * Header del pane sessioni, tagliato QUI e non da Ink.
@@ -0,0 +1,58 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ // La riga di lista di UNA task, condivisa fra il pane task e la schermata di
3
+ // assegnazione (T124).
4
+ //
5
+ // Prima viveva solo nel pane, e la schermata di assegnazione ne teneva una copia
6
+ // ricomposta a mano: id non paddato alla colonna, coda non ancorata al bordo,
7
+ // marker dirty assente, contatore scritto in proprio. Nessuna di quelle
8
+ // differenze produceva un errore — la schermata rendeva righe valide, solo
9
+ // diverse — quindi nessun test le vedeva e ogni evoluzione della lista si
10
+ // fermava al suo confine. Un componente solo è ciò che toglie il gemello.
11
+ //
12
+ // Le due sedi differiscono per tre cose sole, tutte parametri: la larghezza
13
+ // utile, il concetto di fuoco (la schermata di assegnazione è sempre a fuoco) e
14
+ // la finestra di righe che il chiamante ha già ritagliato.
15
+ import { Text } from 'ink';
16
+ import { cut, pad, sanitize, termWidth } from '../width.js';
17
+ import { isDone } from '../layout.js';
18
+ import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, displayProg, taskTail, } from '../glyphs.js';
19
+ import { padId } from '../view.js';
20
+ export function TaskRow({ task, sel, focused, width, idW, tailW, data, }) {
21
+ // T124 — il predicato è UNO e si valuta qui una volta sola: da lui dipendono
22
+ // tutte e tre le evidenze (id in grassetto+colore, glifo, numeratore in coda).
23
+ // Tre condizioni scritte in tre siti divergono alla prima modifica, e la riga
24
+ // finirebbe per dire due cose diverse su sé stessa.
25
+ const live = data.live.get(task.id) ?? null;
26
+ // Stessa resa dell'hash conversazione del pane sessioni, perché è lo stesso
27
+ // stato: `◍` giallo di là e `◍` nero di qua sarebbero due grafie di una cosa
28
+ // sola.
29
+ const color = live ? (live.status === 'busy' ? 'yellow' : 'green') : undefined;
30
+ // Attaccato all'id, non staccato: il glifo qualifica QUELL'id, e uno spazio in
31
+ // mezzo lo farebbe leggere come una colonna a sé. Consuma il primo dei due
32
+ // spazi verso Pri, quindi la riga non si allarga di una cella quando si
33
+ // accende — `LIVE_NONE` è uno spazio, e tutti e tre sono larghi 1.
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));
36
+ const id = padId(task.id, idW);
37
+ // Invariante ③: la descrizione è l'unico pezzo a lunghezza libera, e si taglia
38
+ // QUI sul budget che resta dopo le colonne fisse. Lasciarlo fare a
39
+ // `truncate-end` significa passare da `cli-truncate`, che restituisce una riga
40
+ // più larga del pane (una colonna per emoji) e quindi scrive sopra il bordo.
41
+ // Le parti fisse si misurano con `termWidth`: i due glifi Pri/Prog valgono 2
42
+ // ciascuno.
43
+ const head = `${CARET_OFF}${id}${glyph} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
44
+ // T118 — la colonna della coda si riserva PRIMA di tagliare la descrizione, e
45
+ // per la stessa larghezza su ogni riga: appesa dopo, entrava nel budget solo
46
+ // dove c'era qualcosa da scrivere, e la descrizione si tagliava a una colonna
47
+ // diversa riga per riga.
48
+ //
49
+ // Pavimento `0` su tutte e tre le misure e non un minimo di cortesia: il
50
+ // budget è un TETTO. Un pavimento sopra lo spazio reale fa uscire la riga dal
51
+ // pane e le mangia il bordo. `reserve` si clampa su ciò che avanza, così
52
+ // `head + desc + coda` sta sempre dentro la sede.
53
+ const avail = Math.max(0, width - termWidth(head));
54
+ const reserve = Math.min(tailW, avail);
55
+ const descW = avail - reserve;
56
+ 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')] }));
58
+ }
package/dist/view.js CHANGED
@@ -1,5 +1,7 @@
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';
4
+ import { termWidth } from './width.js';
3
5
  // D2 (preflight T39): sort opinato, filtri off. La lista parte ordinata ma
4
6
  // completa — nessuna task sparisce senza che l'utente abbia toccato una leva.
5
7
  export const DEFAULT_VIEW = {
@@ -19,10 +21,15 @@ const PRI_TABLE = [
19
21
  { name: 'low', glyph: '🔹', rank: 1 },
20
22
  ];
21
23
  // Ordine per "attivabilità" (desc = prima ciò su cui puoi agire): in corso →
22
- // da fare → bloccata → chiusa. Non è l'ordine del ciclo di vita: il deck serve
23
- // a scegliere su cosa lavorare, quindi le Done stanno in fondo sotto `desc`.
24
+ // preflight fatto → da fare → bloccata → chiusa. Non è l'ordine del ciclo di
25
+ // vita: il deck serve a scegliere su cosa lavorare, quindi le Done stanno in
26
+ // fondo sotto `desc`. `ready` sta sopra `todo` perché il design è già congelato
27
+ // (run-task parte senza Q&A), e sotto `wip` perché il lavoro già aperto viene
28
+ // prima. Il rango governa anche l'ordine delle colonne della barra SHIFT+F,
29
+ // derivata da PROG_ENTRIES.
24
30
  const PROG_TABLE = [
25
- { name: 'wip', glyph: '🟡', rank: 4 },
31
+ { name: 'wip', glyph: '🟡', rank: 5 },
32
+ { name: 'ready', glyph: '🟢', rank: 4 },
26
33
  { name: 'todo', glyph: '🔵', rank: 3 },
27
34
  { name: 'locked', glyph: '🔒', rank: 2 },
28
35
  { name: 'done', glyph: '✔', rank: 1 },
@@ -90,6 +97,25 @@ export function padId(id, cols) {
90
97
  return id.padEnd(cols);
91
98
  return m[1] + ' '.repeat(Math.max(0, cols - id.length)) + m[2];
92
99
  }
100
+ /**
101
+ * T118/T124 — le due colonne fisse della lista task, misurate sulla popolazione
102
+ * COMPLETA della vista e non sulla finestra visibile.
103
+ *
104
+ * `tail` porta dentro il proprio gutter (`+1`), così l'allineamento a destra lo
105
+ * produce `pad` da sé; nessuna riga con qualcosa da scrivere → `0`, colonna
106
+ * spenta, e le descrizioni si riprendono lo spazio.
107
+ *
108
+ * La stringa misurata qui è la STESSA che il render disegna (`taskTail`): due
109
+ * formattazioni gemelle divergerebbero alla prima modifica, e la colonna
110
+ * risulterebbe larga quanto una stringa che nessuno scrive.
111
+ */
112
+ export function taskColumns(tasks, data) {
113
+ let tail = 0;
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))));
116
+ }
117
+ return { id: idColumnWidth(tasks), tail: tail > 0 ? tail + 1 : 0 };
118
+ }
93
119
  function rankOf(task, key) {
94
120
  if (key === 'pri')
95
121
  return priRank(task.pri);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.46.0",
3
+ "version": "0.48.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
@@ -103,7 +103,7 @@ MODEL=""
103
103
  # Sotto --fork il --session-id torna AMMESSO (anzi: è il punto) — la mutua
104
104
  # esclusione con --resume esiste perché una ripresa nuda riscrive il transcript
105
105
  # dell'id ripreso, mentre il fork ne apre uno NUOVO, che possiamo quindi pinnare.
106
- # --prompt-kind <none|recap|preflight|run|checkpoint> (T56, T66): sceglie il prompt iniziale fra
106
+ # --prompt-kind <none|recap|recap-task|recap-epic|preflight|run|checkpoint>: sceglie il prompt iniziale fra
107
107
  # quelli del catalogo qui sotto. Enum e non stringa libera: il prompt viaggia
108
108
  # dentro apici singoli in `bash -lc`, quindi il testo va tenuto in un posto solo
109
109
  # e verificato una volta, invece di spostare il rischio di quoting su ogni
@@ -164,7 +164,9 @@ USAGE="uso: deck-run <TaskID> [--model <alias>] [--prompt-kind <kind>] [--sessio
164
164
 
165
165
  --prompt-kind prompt iniziale della sessione bound (default: recap)
166
166
  none nessun prompt — sessione aperta sulla task, a mani nude
167
- recap /loom-works:recap-status <TaskID>
167
+ recap /loom-works:recap-status <TaskID> (dispatcher)
168
+ recap-task /loom-works:recap-status-task <TaskID> (task non cappello)
169
+ recap-epic /loom-works:recap-status-epic <TaskID> (Size: Epic)
168
170
  preflight /loom-works:preflight-task <TaskID>
169
171
  run /loom-works:run-task <TaskID>
170
172
  checkpoint /loom-works:checkpoint-task <TaskID>
@@ -203,8 +205,8 @@ fi
203
205
  # lì il valore arriva da un file editabile a mano, qui da un argomento nostro.
204
206
  if [[ -n "$PROMPT_KIND" ]]; then
205
207
  case "$PROMPT_KIND" in
206
- none|recap|preflight|run|checkpoint) ;;
207
- *) echo "--prompt-kind ignoto: '${PROMPT_KIND}' (usa none|recap|preflight|run|checkpoint)" >&2
208
+ none|recap|recap-task|recap-epic|preflight|run|checkpoint) ;;
209
+ *) echo "--prompt-kind ignoto: '${PROMPT_KIND}' (usa none|recap|recap-task|recap-epic|preflight|run|checkpoint)" >&2
208
210
  echo "$USAGE" >&2
209
211
  exit 2 ;;
210
212
  esac
@@ -427,6 +429,15 @@ FORK_FLAG=""
427
429
  # ("recap stato task <id>"), scelto perché non esisteva una skill
428
430
  # di recap tarata sulla singola task e quella di progetto avrebbe
429
431
  # risposto largo — vincolo caduto col dispatcher.
432
+ # recap-task → la sotto-skill diretta, senza il giro del dispatcher
433
+ # recap-epic → idem per un cappello (`Size: Epic`)
434
+ # I due specializzati li sceglie chi SA già come è fatta la task:
435
+ # il detail del deck, che il task file l'ha aperto. Chi non lo sa
436
+ # — gli acceleratori della lista, che leggono solo `tasks.md`, e
437
+ # quindi non vedono il `Size` — resta su `recap` e lascia
438
+ # classificare al dispatcher. Il criterio non è duplicato: sta in
439
+ # `taskIsEpic` (src/tasks.ts) e legge lo stesso campo che leggerebbe
440
+ # il dispatcher; quello che cambia è solo QUANDO viene letto.
430
441
  # preflight → skill di preflight sulla task
431
442
  # run → skill di esecuzione sulla task
432
443
  # checkpoint → skill di checkpoint sulla task
@@ -15,7 +15,14 @@
15
15
  # Vincolo sui template: nessun apice singolo. Il testo finisce dentro `'...'`
16
16
  # nel comando passato a `bash -lc`, e questo file è committato — un apice qui
17
17
  # sarebbe un errore di scrittura, non un input da quotare.
18
+ # `recap` è il DISPATCHER: risolve la task, ne legge il Size e passa da sé alla
19
+ # sotto-skill. Lo usa chi non sa se la task è un cappello — gli acceleratori
20
+ # della lista, dove il deck ha in mano solo tasks.md, che il Size non ce l'ha.
21
+ # Le due voci `recap-task`/`recap-epic` saltano quel giro: le sceglie il DETAIL,
22
+ # che il task file l'ha già aperto e quindi il Size lo conosce.
18
23
  recap /loom-works:recap-status {TASK}
24
+ recap-task /loom-works:recap-status-task {TASK}
25
+ recap-epic /loom-works:recap-status-epic {TASK}
19
26
  preflight /loom-works:preflight-task {TASK}
20
27
  run /loom-works:run-task {TASK}
21
28
  checkpoint /loom-works:checkpoint-task {TASK}