@lamemind/loom-deck 0.58.0 → 0.60.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 +31 -3
- package/dist/cli.js +6 -2
- package/dist/deck-model.js +69 -8
- package/dist/epic-hierarchy.js +54 -0
- package/dist/frame.js +1 -1
- package/dist/glyphs.js +60 -2
- package/dist/hooks.js +34 -0
- package/dist/input.js +6 -1
- package/dist/pane-views.js +11 -7
- package/dist/spawn.js +20 -6
- package/dist/tasks.js +20 -0
- package/dist/ui/detail-screen.js +2 -33
- package/dist/ui/fields.js +39 -1
- package/dist/ui/panes.js +2 -2
- package/dist/ui/preview.js +11 -4
- package/dist/ui/task-row.js +20 -7
- package/dist/view.js +121 -6
- package/dist/viewport.js +5 -3
- package/package.json +1 -1
package/dist/actions.js
CHANGED
|
@@ -131,12 +131,36 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
|
|
|
131
131
|
// T49 — resume di una conversazione in una nuova tab. Unico punto: lo chiamano
|
|
132
132
|
// il `⏎` della lista sessioni e quello sulla riga-sessione della ricerca, che
|
|
133
133
|
// devono restare la stessa azione.
|
|
134
|
-
|
|
134
|
+
// T148 — il modello lo decide il CHIAMANTE, non questa funzione: la lista
|
|
135
|
+
// principale passa il selettore della preview (`model.resumeModel`), la
|
|
136
|
+
// ricerca passa il modello ereditato dalla sessione senza superficie di
|
|
137
|
+
// scelta (D1 preflight). I due chiamanti restano la stessa azione perché è
|
|
138
|
+
// lo spawn a essere identico, non la sorgente del valore.
|
|
139
|
+
function resumeSession(sessionId, modelKind) {
|
|
135
140
|
const bound = model.bindings.get(sessionId) ?? null;
|
|
136
|
-
const spawned = spawnDeckResume(bound, cwd, sessionId, model.sessionNotes.get(sessionId));
|
|
141
|
+
const spawned = spawnDeckResume(bound, cwd, sessionId, modelKind, model.sessionNotes.get(sessionId));
|
|
137
142
|
spawned.child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
|
|
138
143
|
noteSpawn(spawned);
|
|
139
144
|
}
|
|
145
|
+
/** T148 — `m`: scorre il modello con cui la sessione selezionata riprenderà
|
|
146
|
+
* al successivo del catalogo. Stesso guard di `f` (serve un transcript
|
|
147
|
+
* vero, non basta una riga selezionata): su una pinnata stale il blocco
|
|
148
|
+
* preview non esiste — non c'è un bottone da cambiare. Nessuna nota di
|
|
149
|
+
* conferma sul successo — il feedback è il bottone stesso, già a schermo
|
|
150
|
+
* nella preview. */
|
|
151
|
+
function cycleResumeModel() {
|
|
152
|
+
if (model.focus !== 'sessions') {
|
|
153
|
+
setNote('m → modello: seleziona una sessione (→ per il pane)');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!model.selSessionObj) {
|
|
157
|
+
setNote(model.selSessionId
|
|
158
|
+
? 'm → pin stale: nessun modello da cambiare'
|
|
159
|
+
: 'm → nessuna sessione selezionata');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
model.cycleResumeModel();
|
|
163
|
+
}
|
|
140
164
|
// T53 — scrive il sidecar e ricarica subito, senza attendere il tick del poll
|
|
141
165
|
// (stesso feedback immediato del pin).
|
|
142
166
|
//
|
|
@@ -202,7 +226,10 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
|
|
|
202
226
|
...(bound ? { taskId: bound } : {}),
|
|
203
227
|
forkOf: s.sessionId,
|
|
204
228
|
});
|
|
205
|
-
|
|
229
|
+
// T148 — il fork è l'altra continuità che condivide la stessa riga
|
|
230
|
+
// selezionata: porta via lo stesso `model.resumeModel` del resume, non un
|
|
231
|
+
// default fisso (P2 preflight).
|
|
232
|
+
const spawned = spawnDeckFork(bound, cwd, s.sessionId, newId, model.resumeModel);
|
|
206
233
|
spawned.child.on('error', () => setNote(`⚠ fork fallito (${DECK_RUN})`));
|
|
207
234
|
noteSpawn(spawned);
|
|
208
235
|
}
|
|
@@ -310,6 +337,7 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
|
|
|
310
337
|
spawnForTask,
|
|
311
338
|
spawnTaskSession,
|
|
312
339
|
resumeSession,
|
|
340
|
+
cycleResumeModel,
|
|
313
341
|
writeNote,
|
|
314
342
|
assignSession,
|
|
315
343
|
forkSession,
|
package/dist/cli.js
CHANGED
|
@@ -19,6 +19,8 @@ import { resolveTasksPath, resolveTasksDir } from './tasks.js';
|
|
|
19
19
|
import { LAUNCH_SEP } from './config.js';
|
|
20
20
|
import { anchorFrame, enableMouse } from './mouse.js';
|
|
21
21
|
import { sanitize } from './width.js';
|
|
22
|
+
import { modelAlias } from './glyphs.js';
|
|
23
|
+
import { MODEL_DEFAULT } from './spawn.js';
|
|
22
24
|
import { deckLegend, frameGeometry, headlineWidth, indicatorRow, launchRow } from './frame.js';
|
|
23
25
|
import { useDeckModel } from './deck-model.js';
|
|
24
26
|
import { useDeckActions } from './actions.js';
|
|
@@ -70,7 +72,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
70
72
|
hasNote: Boolean(note),
|
|
71
73
|
setMode,
|
|
72
74
|
setNote,
|
|
73
|
-
|
|
75
|
+
// T148/D1 — nessun selettore nella ricerca: il modello è quello ereditato
|
|
76
|
+
// dalla conversazione, senza superficie di scelta.
|
|
77
|
+
onResume: (row) => actions.resumeSession(row.session.sessionId, modelAlias(row.session.model) ?? MODEL_DEFAULT),
|
|
74
78
|
});
|
|
75
79
|
// T121 — il project status. Non è solo un overlay come i tre sopra: il suo
|
|
76
80
|
// stato si legge in TESTATA a schermata chiusa, e il viewer è una delle sue
|
|
@@ -241,7 +245,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
241
245
|
purgeBulk: model.purgeBulk,
|
|
242
246
|
inboxPane: model.rightPane === 'inbox',
|
|
243
247
|
hasInbox: model.selInbox !== null,
|
|
244
|
-
}), indicators: indicators, columns: columns }), frame.launchLine ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [launch.segments.map((s) => s.text).join(LAUNCH_SEP), launch.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", launch.overflow, " fuori riga"] })) : null, launch.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", launch.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? _jsx(TextBox, { glyph: "C", value: textModals.draft }) : null, mode === 'note' ? _jsx(TextBox, { glyph: "\u270E", value: textModals.noteDraft }) : null, mode === 'sort' ? _jsx(SortModal, { sort: model.view.sort }) : null, mode === 'filter' ? (_jsx(FilterModal, { view: model.view, cursor: viewModals.filterCursor })) : null, mode === 'edit' && textModals.edit && textModals.editTask ? (_jsx(EditModal, { id: textModals.editTask.id, draft: textModals.edit, row: textModals.editCursor.row, caret: textModals.editCursor.caret, columns: columns })) : null, mode === 'purge' && purge.draft ? (_jsx(PurgeModal, { draft: purge.draft, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: frame.windowTasks, counts: model.taskCounts, activeView: model.taskViewId, paneCount: model.paneTasks.length, view: model.view, selected: model.selIndex, spotCount: model.spotCount, allCount: model.sessions.length, idW: model.taskCols.id, tailW: model.taskCols.tail, focused: model.focus === 'tasks', loadError: model.loadError, windowStart: frame.taskWin.start, above: frame.taskWin.start, below: model.paneTasks.length - frame.taskWin.end, columns: columns, data: model.taskRowData }), model.rightPane === 'inbox' ? (_jsx(InboxPane, { files: frame.windowInbox, counts: model.inboxCounts, activeView: model.inboxViewId, paneCount: model.inboxFiles.length, selectedPath: model.selInboxPath, focused: model.focus === 'inbox', above: frame.inboxWin.start, below: model.inboxFiles.length - frame.inboxWin.end, columns: columns, ok: model.inboxOk, scanned: model.inboxScanned })) : (_jsx(SessionsPane, { parentLabel: model.parentLabel, isSpot: model.isSpot, isAll: model.isAll, bindings: model.bindings, taskW: model.sessionCols.task, ageW: model.sessionCols.age, rows: frame.windowRows, counts: model.sessionCounts, activeView: model.sessionViewId, paneCount: model.sessionRows.length, selectedId: model.selSessionId ?? undefined, focused: model.focus === 'sessions', above: frame.sessionWin.start, below: model.sessionRows.length - frame.sessionWin.end, columns: columns, forkOf: model.forkOf, sessionNotes: model.sessionNotes, projectCore: model.projectCore, live: model.live }))] }), frame.budget.preview && previewKind === 'task' && model.detail ? (_jsx(PreviewPane, { kind: "task", detail: model.detail, maxLines: frame.budget.detailLines, columns: columns })) : frame.budget.preview && previewKind === 'inbox' && model.selInbox ? (_jsx(PreviewPane, { kind: "inbox", file: model.selInbox, columns: columns })) : frame.budget.preview && previewKind === 'session' && model.selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: model.selSessionObj, firstLines: frame.budget.sessionFirstLines, lastLines: frame.budget.sessionLastLines, columns: columns, origin: model.forkOf.get(model.selSessionObj.sessionId) ?? null, note: model.sessionNotes.get(model.selSessionObj.sessionId) ?? '', live: model.live.get(model.selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
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] }));
|
|
245
249
|
}
|
|
246
250
|
const cwd = process.cwd();
|
|
247
251
|
// T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
|
package/dist/deck-model.js
CHANGED
|
@@ -14,18 +14,19 @@
|
|
|
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';
|
|
21
21
|
import { isDone } from './layout.js';
|
|
22
|
-
import { TASK_EMPTY, relTime } from './glyphs.js';
|
|
22
|
+
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
|
+
import { MODELS, MODEL_DEFAULT } from './spawn.js';
|
|
29
30
|
/**
|
|
30
31
|
* Conteggio dei figli per task, più il rollup delle VIVE e le conversazioni
|
|
31
32
|
* senza binding.
|
|
@@ -124,6 +125,12 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
|
124
125
|
// a due gruppi + separatore è una vista trasformata, un indice grezzo punterebbe
|
|
125
126
|
// alla riga sbagliata dopo un pin o un cambio di contesto (stesso trap T39).
|
|
126
127
|
const [selSessionId, setSelSessionId] = useState(null);
|
|
128
|
+
// T148/P8 — il modello con cui la riga selezionata riprenderà (resume/fork).
|
|
129
|
+
// UN SOLO valore, non una mappa sessionId → scelta: si ri-inizializza a ogni
|
|
130
|
+
// cambio di riga (l'effect qui sotto, keyed su `selSessionId`), quindi
|
|
131
|
+
// tornare su una riga già visitata NON ricorda la scelta fatta lì — è lo
|
|
132
|
+
// stesso costo che T108 ha già accettato per il selettore del detail.
|
|
133
|
+
const [resumeModel, setResumeModel] = useState(MODEL_DEFAULT);
|
|
127
134
|
// T39 — vista corrente (filtri + sort). Vive nel modello e non nell'hook dei
|
|
128
135
|
// modali che la editano: è ciò che `applyView` consuma per produrre la lista,
|
|
129
136
|
// è persistita su disco e la rilegge il tasto `w`. È stato del modello che due
|
|
@@ -162,9 +169,21 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
|
162
169
|
// T136 — data dell'ultimo commit di ogni task file, per la chiave `commit`
|
|
163
170
|
// della chain di sort.
|
|
164
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]);
|
|
165
180
|
// La vista è una trasformazione DERIVATA, applicata a valle del load: il
|
|
166
181
|
// polling di tasks.md continua a funzionare senza saperne nulla.
|
|
167
|
-
const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view, {
|
|
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]);
|
|
168
187
|
// T61 — il conteggio guarda la lista GREZZA, non `viewTasks`: le Done fuori
|
|
169
188
|
// dai filtri della vista restano archiviabili, e un contatore che cambiasse
|
|
170
189
|
// filtrando direbbe qualcosa sulla vista invece che sulla task list.
|
|
@@ -200,9 +219,22 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
|
200
219
|
hidden: hiddenTasks,
|
|
201
220
|
archivable: archivable.size,
|
|
202
221
|
};
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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]);
|
|
206
238
|
const isSpot = sel === SPOT;
|
|
207
239
|
const isAll = sel === ALL;
|
|
208
240
|
// T112 — quando `CANC` pota in BLOCCO invece della sola task selezionata.
|
|
@@ -228,7 +260,13 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
|
228
260
|
// alla vista che sceglie l'header (D2 create).
|
|
229
261
|
const parentLabel = isAll ? 'tutte' : isSpot ? 'spot' : selectedTaskId ?? '—';
|
|
230
262
|
const { childCount, taskLive, spotCount } = rollupChildren(sessions, bindings, live);
|
|
231
|
-
const taskRowData = {
|
|
263
|
+
const taskRowData = {
|
|
264
|
+
childCount,
|
|
265
|
+
live: taskLive,
|
|
266
|
+
dirty: dirtyFolders,
|
|
267
|
+
epics: epicHierarchy.epics,
|
|
268
|
+
epicRollup: epicRollupMap,
|
|
269
|
+
};
|
|
232
270
|
// T118 — colonne fisse della lista task, misurate su `paneTasks` (la vista
|
|
233
271
|
// attiva INTERA) e non sulla finestra visibile: gemelle di `sessionCols` e
|
|
234
272
|
// per la stessa ragione, che una larghezza derivata dallo schermo si muove a
|
|
@@ -275,6 +313,17 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
|
275
313
|
setSelSessionId(firstSelectableId(sessionRows));
|
|
276
314
|
}
|
|
277
315
|
}, [sessionRows, selSessionId]);
|
|
316
|
+
// T148/P8 — reset del selettore modello all'evento «la riga selezionata è
|
|
317
|
+
// cambiata». Il selettore del detail (T108) si azzera all'APERTURA
|
|
318
|
+
// dell'overlay; questo blocco non apre e non chiude mai, quindi il suo
|
|
319
|
+
// evento equivalente è il cambio di `selSessionId`. Keyed SOLO su quello (non
|
|
320
|
+
// su `selSessionObj`, che cambia identità a ogni poll delle sessioni anche
|
|
321
|
+
// quando la riga selezionata resta la stessa): un'altra dipendenza
|
|
322
|
+
// resetterebbe la scelta dell'utente sotto un timer invece che sotto un
|
|
323
|
+
// movimento del cursore.
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
setResumeModel(modelAlias(selSessionObj?.model ?? '') ?? MODEL_DEFAULT);
|
|
326
|
+
}, [selSessionId]);
|
|
278
327
|
// T134 — gemello dei due sopra: un file drenato sparisce dalla coda al primo
|
|
279
328
|
// scan successivo, e la selezione cade sulla prima riga della lista invece
|
|
280
329
|
// che su una posizione a caso.
|
|
@@ -354,6 +403,15 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
|
354
403
|
const next = Math.max(0, Math.min(inboxFiles.length - 1, (at < 0 ? 0 : at) + delta));
|
|
355
404
|
setSelInboxPath(inboxFiles[next].path);
|
|
356
405
|
}
|
|
406
|
+
/** T148 — `m` scorre il selettore modello della riga sessione selezionata al
|
|
407
|
+
* valore SUCCESSIVO del catalogo (ciclico), gemella di `cycleView` ma su un
|
|
408
|
+
* asse diverso: qui non c'è una vista da cambiare, solo un valore da far
|
|
409
|
+
* avanzare. Il guard (nessuna sessione, focus altrove) sta nell'attuatore
|
|
410
|
+
* `actions.ts`, come per `f`/`p`: qui c'è solo la meccanica dello stato. */
|
|
411
|
+
function cycleResumeModel() {
|
|
412
|
+
const next = MODELS[(MODELS.indexOf(resumeModel) + 1) % MODELS.length];
|
|
413
|
+
setResumeModel(next);
|
|
414
|
+
}
|
|
357
415
|
/**
|
|
358
416
|
* T134 — `^B` scambia i due pane dello slot destro (D8 preflight).
|
|
359
417
|
*
|
|
@@ -417,6 +475,7 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
|
417
475
|
hiddenTasks,
|
|
418
476
|
taskCounts,
|
|
419
477
|
paneTasks,
|
|
478
|
+
blockMark: paneBlockMark,
|
|
420
479
|
isSpot,
|
|
421
480
|
isAll,
|
|
422
481
|
purgeBulk,
|
|
@@ -432,6 +491,8 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
|
432
491
|
sessionCounts,
|
|
433
492
|
selSessionObj,
|
|
434
493
|
sessionCols,
|
|
494
|
+
resumeModel,
|
|
495
|
+
cycleResumeModel,
|
|
435
496
|
// derivazioni del pane inbox
|
|
436
497
|
rightPane,
|
|
437
498
|
inboxScanned: inbox.scanned,
|
|
@@ -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/frame.js
CHANGED
|
@@ -72,7 +72,7 @@ export function deckLegend(state) {
|
|
|
72
72
|
// apertura: una legenda che annunciasse «tutte» dove il tasto ne pota una
|
|
73
73
|
// sola sarebbe peggio di nessuna legenda.
|
|
74
74
|
...(state.focus === 'tasks' ? [state.purgeBulk ? 'CANC elimina tutte' : 'CANC elimina'] : []),
|
|
75
|
-
...(canResume ? ['f fork'] : []),
|
|
75
|
+
...(canResume ? ['f fork', 'm modello'] : []),
|
|
76
76
|
// T117 · D3 — «titolo» anche qui: è lo stesso valore che il detail chiede
|
|
77
77
|
// allo spawn, e due nomi per una cosa sola li paga chi legge le due
|
|
78
78
|
// schermate. Rename di sola ETICHETTA: il dato resta `note` nel sidecar e
|
package/dist/glyphs.js
CHANGED
|
@@ -51,6 +51,23 @@ export function modelShort(id) {
|
|
|
51
51
|
}
|
|
52
52
|
return MODEL_UNKNOWN;
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* T148/P3 — id di modello → alias del CLI, stesso catalogo di `modelShort`
|
|
56
|
+
* (`MODEL_SHORT`): un id vuoto o fuori famiglia (gli stessi due casi che qui
|
|
57
|
+
* rendono `·` e `?`) torna `null` invece di un valore inventato — il chiamante
|
|
58
|
+
* degrada a `MODEL_DEFAULT` senza marcare nessun bottone come provenienza,
|
|
59
|
+
* perché su quei due casi non c'è una provenienza vera da dichiarare.
|
|
60
|
+
*/
|
|
61
|
+
export function modelAlias(id) {
|
|
62
|
+
if (!id)
|
|
63
|
+
return null;
|
|
64
|
+
const lower = id.toLowerCase();
|
|
65
|
+
for (const family of Object.keys(MODEL_SHORT)) {
|
|
66
|
+
if (lower.includes(family))
|
|
67
|
+
return family;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
54
71
|
// T62 — colonna liveness, larga 1, incollata al sessionId senza gutter proprio:
|
|
55
72
|
// il glifo qualifica QUELL'id, e uno spazio in mezzo lo farebbe leggere come una
|
|
56
73
|
// colonna a sé. Entrambi Ambiguous (EAW) → 1 colonna per il terminale e 1 cella
|
|
@@ -99,8 +116,13 @@ export const INBOX_MARK_W = 2;
|
|
|
99
116
|
// `isDone()` e le lookup di `view.ts` ci confrontano sopra, e `task-edit` lo
|
|
100
117
|
// riscrive sul file: è una chiave semantica, non testo. Qui `sanitize` lo
|
|
101
118
|
// traduce nel suo gemello concorde (`✅`) solo per finire nel frame.
|
|
102
|
-
|
|
103
|
-
|
|
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);
|
|
104
126
|
}
|
|
105
127
|
/**
|
|
106
128
|
* T118 — coda della riga task: marker dirty e contatore di conversazioni, nella
|
|
@@ -136,6 +158,42 @@ export function taskTail(live, total, dirty) {
|
|
|
136
158
|
return count;
|
|
137
159
|
return count ? `${WARN} ${count}` : WARN;
|
|
138
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
|
+
}
|
|
139
197
|
/** T124 — contatore di una riga META del pane task (`≡ tutte`, `○ spot`), che
|
|
140
198
|
* non è una colonna ancorata a niente. Prende comunque la grafia senza chiusa:
|
|
141
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
|
package/dist/input.js
CHANGED
|
@@ -415,7 +415,7 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
|
|
|
415
415
|
: 'nessuna sessione da riprendere');
|
|
416
416
|
}
|
|
417
417
|
else {
|
|
418
|
-
actions.resumeSession(s.sessionId);
|
|
418
|
+
actions.resumeSession(s.sessionId, model.resumeModel);
|
|
419
419
|
}
|
|
420
420
|
}
|
|
421
421
|
}
|
|
@@ -466,6 +466,11 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
|
|
|
466
466
|
// selezionata del pane sessioni.
|
|
467
467
|
actions.togglePin();
|
|
468
468
|
}
|
|
469
|
+
else if (input === 'm') {
|
|
470
|
+
// T148 — scorre il modello di resume/fork della riga selezionata,
|
|
471
|
+
// gemella di `f`/`p`: azione immediata, nessun modale.
|
|
472
|
+
actions.cycleResumeModel();
|
|
473
|
+
}
|
|
469
474
|
else if (input === 'N') {
|
|
470
475
|
// T53 — nota sulla conversazione selezionata. MAIUSCOLA perché apre un
|
|
471
476
|
// modale: nel deck le minuscole sono azioni immediate (`f` fork, `p` pin,
|
package/dist/pane-views.js
CHANGED
|
@@ -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,
|
|
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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
-
|
|
61
|
-
|
|
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
|
package/dist/spawn.js
CHANGED
|
@@ -224,14 +224,22 @@ export function spawnDeck(id, cwd, sessionId, kind, model = MODEL_DEFAULT, spawn
|
|
|
224
224
|
// `.claude/loom-works.json`): tenerlo così evita di dare al primitive un secondo
|
|
225
225
|
// file da conoscere. Il titolo si congela qui — `claude --name` lo setta una
|
|
226
226
|
// volta sola, quindi una nota cambiata DOPO non ri-titola la tab già aperta.
|
|
227
|
-
|
|
227
|
+
//
|
|
228
|
+
// T148 — il modello viaggia SEMPRE, come in `deckArgs`: senza `--model`
|
|
229
|
+
// `deck-run` risolve il proprio default fisso (`opus`) e lo passa comunque al
|
|
230
|
+
// CLI, quindi una conversazione haiku ripresa senza il flag ripartirebbe in
|
|
231
|
+
// opus (P2 preflight). Qui però non c'è un default fisso da cablare: chi
|
|
232
|
+
// chiama ha già risolto il valore — il modello della conversazione d'origine,
|
|
233
|
+
// o quello scelto al suo posto — e lo passa come argomento obbligatorio.
|
|
234
|
+
export function resumeArgs(taskId, sessionId, model, note) {
|
|
228
235
|
const args = taskId ? [taskId, '--resume', sessionId] : ['--no-task', '--resume', sessionId];
|
|
236
|
+
args.push('--model', model);
|
|
229
237
|
if (note)
|
|
230
238
|
args.push('--title-note', note);
|
|
231
239
|
return args;
|
|
232
240
|
}
|
|
233
|
-
export function spawnDeckResume(taskId, cwd, sessionId, note) {
|
|
234
|
-
return launchDeckRun(resumeArgs(taskId, sessionId, note), cwd);
|
|
241
|
+
export function spawnDeckResume(taskId, cwd, sessionId, model, note) {
|
|
242
|
+
return launchDeckRun(resumeArgs(taskId, sessionId, model, note), cwd);
|
|
235
243
|
}
|
|
236
244
|
// T28 — FORK: `deck-run <task|--no-task> --resume <origine> --fork --session-id
|
|
237
245
|
// <nuovo>`. Variante del resume, non una terza forma: cambia solo che CC apre un
|
|
@@ -245,7 +253,11 @@ export function spawnDeckResume(taskId, cwd, sessionId, note) {
|
|
|
245
253
|
// nota nel sidecar — ereditare quella dell'origine metterebbe nel titolo una
|
|
246
254
|
// maniglia che nella lista non compare, cioè una promessa falsa. Il fork si
|
|
247
255
|
// distingue col suo marcatore, `· fork`.
|
|
248
|
-
|
|
256
|
+
//
|
|
257
|
+
// T148 — stesso obbligo di `resumeArgs`: il fork è un'altra forma di
|
|
258
|
+
// continuità, quindi eredita anch'esso il modello dell'origine invece del
|
|
259
|
+
// default fisso di uno spawn nuovo (P2 preflight).
|
|
260
|
+
export function forkArgs(taskId, originId, newId, model) {
|
|
249
261
|
return [
|
|
250
262
|
...(taskId ? [taskId] : ['--no-task']),
|
|
251
263
|
'--resume',
|
|
@@ -253,10 +265,12 @@ export function forkArgs(taskId, originId, newId) {
|
|
|
253
265
|
'--fork',
|
|
254
266
|
'--session-id',
|
|
255
267
|
newId,
|
|
268
|
+
'--model',
|
|
269
|
+
model,
|
|
256
270
|
];
|
|
257
271
|
}
|
|
258
|
-
export function spawnDeckFork(taskId, cwd, originId, newId) {
|
|
259
|
-
return launchDeckRun(forkArgs(taskId, originId, newId), cwd);
|
|
272
|
+
export function spawnDeckFork(taskId, cwd, originId, newId, model) {
|
|
273
|
+
return launchDeckRun(forkArgs(taskId, originId, newId, model), cwd);
|
|
260
274
|
}
|
|
261
275
|
// T42 — sessione Claude NUDA: nessuna task, nessun prompt iniziale, nessun
|
|
262
276
|
// sessionId pinnato (quindi nessuna entry nel sidecar session-tasks.jsonl: senza
|
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/detail-screen.js
CHANGED
|
@@ -2,10 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
// Detail della task (T66): quarta schermata sostitutiva, col markdown reso in
|
|
3
3
|
// span tipizzati (T75) e la ricerca interna (T91).
|
|
4
4
|
import { Box, Text } from 'ink';
|
|
5
|
-
import { cut
|
|
5
|
+
import { cut } from '../width.js';
|
|
6
6
|
import { sliceLine } from '../text-search.js';
|
|
7
7
|
import { sliceSpans } from '../markdown.js';
|
|
8
|
-
import { FieldText } from './fields.js';
|
|
8
|
+
import { ChoiceRow, FieldText, LABEL_W } from './fields.js';
|
|
9
9
|
import { DETAIL_ACTIONS, MODELS } from '../spawn.js';
|
|
10
10
|
import { DROW } from '../overlays/sheet.js';
|
|
11
11
|
import { CARET, CARET_OFF, WARN } from '../glyphs.js';
|
|
@@ -62,37 +62,6 @@ const PROMPT_HINT = ' · nessun prompt iniziale';
|
|
|
62
62
|
* che nessuno ci ripassi — un'etichetta che nomina dei tasti a mano è accoppiata
|
|
63
63
|
* al binding, e resta indietro appena il binding cambia. */
|
|
64
64
|
const ACTION_KEYS = DETAIL_ACTIONS.map((a) => a.label[0]).join(' ');
|
|
65
|
-
/** Prefisso incolonnato delle quattro righe dell'area di compilazione: le
|
|
66
|
-
* etichette si leggono una sotto l'altra, quindi la larghezza è quella della
|
|
67
|
-
* più lunga. Entra nel budget di ogni campo, da cui la costante. */
|
|
68
|
-
const LABEL_W = 8;
|
|
69
|
-
/** Una riga a SCELTA dell'area di compilazione: bottoni affiancati, voce attiva
|
|
70
|
-
* in video inverso.
|
|
71
|
-
*
|
|
72
|
-
* Due passate di `cutParts`: la seconda serve SOLO quando qualcosa cade, e
|
|
73
|
-
* riserva le colonne del contatore. Riservarle sempre costerebbe 6 colonne su
|
|
74
|
-
* ogni terminale largo per un avviso che lì non comparirà mai.
|
|
75
|
-
*
|
|
76
|
-
* `priority` sulla voce SELEZIONATA e non sulla prima: qui il troncamento
|
|
77
|
-
* cancellerebbe l'unica informazione che la riga esiste per dare — quale valore
|
|
78
|
-
* sta per essere usato.
|
|
79
|
-
*/
|
|
80
|
-
function ChoiceRow({ label, values, index, focused, width, }) {
|
|
81
|
-
const segs = values.map((v) => `[ ${v} ]`);
|
|
82
|
-
const parts = [];
|
|
83
|
-
segs.forEach((s, i) => {
|
|
84
|
-
if (i > 0)
|
|
85
|
-
parts.push(' ');
|
|
86
|
-
parts.push(s);
|
|
87
|
-
});
|
|
88
|
-
const avail = Math.max(0, width - LABEL_W - CARET_OFF.length);
|
|
89
|
-
let shown = cutParts(parts, avail, index * 2);
|
|
90
|
-
const dropped = (v) => segs.filter((s, i) => v[i * 2] !== s).length;
|
|
91
|
-
if (dropped(shown) > 0)
|
|
92
|
-
shown = cutParts(parts, Math.max(0, avail - 6), index * 2);
|
|
93
|
-
const cut = dropped(shown);
|
|
94
|
-
return (_jsxs(Text, { wrap: "truncate-end", children: [focused ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: label.padEnd(LABEL_W) }), shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === index, color: i / 2 === index ? 'green' : 'gray', children: part }, i))), cut > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cut] }) : null] }));
|
|
95
|
-
}
|
|
96
65
|
/**
|
|
97
66
|
* Detail della task (T66): il task file scrollabile più l'area di compilazione.
|
|
98
67
|
*
|
package/dist/ui/fields.js
CHANGED
|
@@ -9,8 +9,9 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
9
9
|
// il marker di riga, il detail dentro un prefisso incolonnato). Tolta di mezzo,
|
|
10
10
|
// dei due gemelli resta una cosa sola.
|
|
11
11
|
import { Text } from 'ink';
|
|
12
|
-
import { caretWindow, sanitize } from '../width.js';
|
|
12
|
+
import { caretWindow, cutParts, sanitize } from '../width.js';
|
|
13
13
|
import { cpLen } from '../layout.js';
|
|
14
|
+
import { CARET, CARET_OFF } from '../glyphs.js';
|
|
14
15
|
/**
|
|
15
16
|
* Fuori fuoco (`focused` falso) il caret non si disegna e la finestra si ancora
|
|
16
17
|
* in fondo, che è la vista utile per un campo che non si sta scrivendo: `at` lì
|
|
@@ -21,3 +22,40 @@ export function FieldText({ value, caret, focused, cols, }) {
|
|
|
21
22
|
const win = caretWindow(value, focused ? caret : cpLen(value), cols);
|
|
22
23
|
return (_jsxs(_Fragment, { children: [_jsx(Text, { children: sanitize(win.head) }), focused ? _jsx(Text, { inverse: true, children: sanitize(win.at) }) : null, _jsx(Text, { children: sanitize(win.tail) })] }));
|
|
23
24
|
}
|
|
25
|
+
/** Prefisso incolonnato delle righe a scelta/testo di un'area di compilazione:
|
|
26
|
+
* le etichette si leggono una sotto l'altra, quindi la larghezza è quella
|
|
27
|
+
* della più lunga fra le righe che la usano (T117 nel detail, T148 nella
|
|
28
|
+
* preview sessione). */
|
|
29
|
+
export const LABEL_W = 8;
|
|
30
|
+
/**
|
|
31
|
+
* T117 — una riga a SCELTA di un'area di compilazione: bottoni affiancati,
|
|
32
|
+
* voce attiva in video inverso.
|
|
33
|
+
*
|
|
34
|
+
* Due passate di `cutParts`: la seconda serve SOLO quando qualcosa cade, e
|
|
35
|
+
* riserva le colonne del contatore. Riservarle sempre costerebbe 6 colonne su
|
|
36
|
+
* ogni terminale largo per un avviso che lì non comparirà mai.
|
|
37
|
+
*
|
|
38
|
+
* `priority` sulla voce SELEZIONATA e non sulla prima: qui il troncamento
|
|
39
|
+
* cancellerebbe l'unica informazione che la riga esiste per dare — quale valore
|
|
40
|
+
* sta per essere usato.
|
|
41
|
+
*
|
|
42
|
+
* T148 — estratto dal detail della task perché un secondo chiamante (il
|
|
43
|
+
* selettore modello della preview sessione) ne riusa la stessa resa a quadre,
|
|
44
|
+
* non un componente gemello capace di divergere.
|
|
45
|
+
*/
|
|
46
|
+
export function ChoiceRow({ label, values, index, focused, width, originIndex, }) {
|
|
47
|
+
const segs = values.map((v) => `[ ${v} ]`);
|
|
48
|
+
const parts = [];
|
|
49
|
+
segs.forEach((s, i) => {
|
|
50
|
+
if (i > 0)
|
|
51
|
+
parts.push(' ');
|
|
52
|
+
parts.push(s);
|
|
53
|
+
});
|
|
54
|
+
const avail = Math.max(0, width - LABEL_W - CARET_OFF.length);
|
|
55
|
+
let shown = cutParts(parts, avail, index * 2);
|
|
56
|
+
const dropped = (v) => segs.filter((s, i) => v[i * 2] !== s).length;
|
|
57
|
+
if (dropped(shown) > 0)
|
|
58
|
+
shown = cutParts(parts, Math.max(0, avail - 6), index * 2);
|
|
59
|
+
const cut = dropped(shown);
|
|
60
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [focused ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: label.padEnd(LABEL_W) }), shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === index, underline: i / 2 === originIndex, color: i / 2 === index ? 'green' : 'gray', children: part }, i))), cut > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cut] }) : null] }));
|
|
61
|
+
}
|
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.
|
package/dist/ui/preview.js
CHANGED
|
@@ -4,15 +4,17 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
4
4
|
import { Box, Text } from 'ink';
|
|
5
5
|
import { cut, wrapLines } from '../width.js';
|
|
6
6
|
import { previewTextWidth } from '../layout.js';
|
|
7
|
-
import { LIVE_BUSY, LIVE_IDLE, META_KEYS, SID_CHARS, fmtDateTime, fmtSize } from '../glyphs.js';
|
|
7
|
+
import { LIVE_BUSY, LIVE_IDLE, META_KEYS, SID_CHARS, fmtDateTime, fmtSize, modelAlias, } from '../glyphs.js';
|
|
8
8
|
import { NATURA_SHORT, inboxMark } from '../inbox.js';
|
|
9
|
+
import { ChoiceRow } from './fields.js';
|
|
10
|
+
import { MODELS } from '../spawn.js';
|
|
9
11
|
/** Colonne del prefisso delle due anteprime della preview sessione (`» `, `« `,
|
|
10
12
|
* e i due spazi delle righe di continuazione). Costante perché entra nel
|
|
11
13
|
* budget di wrap, che è il posto in cui dimenticarla non produce un errore ma
|
|
12
14
|
* un bordo mangiato. */
|
|
13
15
|
const PREVIEW_PREFIX_W = 2;
|
|
14
16
|
export function PreviewPane(p) {
|
|
15
|
-
return (_jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: p.kind === 'task' ? (_jsx(TaskPreview, { detail: p.detail, maxLines: p.maxLines, columns: p.columns })) : p.kind === 'inbox' ? (_jsx(InboxPreview, { file: p.file, columns: p.columns })) : (_jsx(SessionPreview, { s: p.s, firstLines: p.firstLines, lastLines: p.lastLines, columns: p.columns, origin: p.origin, note: p.note, live: p.live })) }));
|
|
17
|
+
return (_jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: p.kind === 'task' ? (_jsx(TaskPreview, { detail: p.detail, maxLines: p.maxLines, columns: p.columns })) : p.kind === 'inbox' ? (_jsx(InboxPreview, { file: p.file, columns: p.columns })) : (_jsx(SessionPreview, { s: p.s, firstLines: p.firstLines, lastLines: p.lastLines, columns: p.columns, origin: p.origin, note: p.note, live: p.live, resumeModel: p.resumeModel })) }));
|
|
16
18
|
}
|
|
17
19
|
/**
|
|
18
20
|
* T134 — corpo della preview inbox: due righe FISSE, contate da
|
|
@@ -45,7 +47,7 @@ export function InboxPreview({ file, columns }) {
|
|
|
45
47
|
// SOLO con un titolo custom — senza, il titolo È già il primo prompt e la riga
|
|
46
48
|
// lo duplicherebbe (D4 preflight). Le righe rese non superano mai il riservato
|
|
47
49
|
// dal budget (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
|
|
48
|
-
export function SessionPreview({ s, firstLines, lastLines, columns, origin, note, live, }) {
|
|
50
|
+
export function SessionPreview({ s, firstLines, lastLines, columns, origin, note, live, resumeModel, }) {
|
|
49
51
|
// Il prefisso `» `/`« ` è largo 2 e sta SULLA riga: va tolto dal budget di
|
|
50
52
|
// wrap, o una riga piena esce dal box di quelle due colonne e a raccoglierla
|
|
51
53
|
// arriva `cli-truncate`, che sfora di una colonna per emoji e mangia il bordo
|
|
@@ -57,7 +59,12 @@ export function SessionPreview({ s, firstLines, lastLines, columns, origin, note
|
|
|
57
59
|
const width = previewTextWidth(columns) - PREVIEW_PREFIX_W;
|
|
58
60
|
const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
|
|
59
61
|
const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
|
|
60
|
-
|
|
62
|
+
// T148/P3 — l'alias del modello D'ORIGINE, per la sottolineatura (P5):
|
|
63
|
+
// `undefined` quando l'id non mappa su nessuna famiglia nota, cioè quando
|
|
64
|
+
// non c'è una provenienza vera da dichiarare.
|
|
65
|
+
const originAlias = modelAlias(s.model);
|
|
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}`)))] }));
|
|
61
68
|
}
|
|
62
69
|
/**
|
|
63
70
|
* Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
|
package/dist/ui/task-row.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
184
|
-
visible
|
|
185
|
-
return { visible, hidden: tasks.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/dist/viewport.js
CHANGED
|
@@ -38,9 +38,11 @@ const TASKS_PANE_CHROME = 6; // 2 bordi + header "Tasks (n)" + riga sort + 2 rig
|
|
|
38
38
|
const SESSIONS_PANE_CHROME = 3; // 2 bordi + header "Sessions · …"
|
|
39
39
|
const PREVIEW_CHROME = 3; // marginTop + 2 bordi
|
|
40
40
|
/** Preview sessione: righe fisse = titolo + riga meta (size · turni · ultima
|
|
41
|
-
* attività · branch)
|
|
42
|
-
* variabili, ciascuna al
|
|
43
|
-
|
|
41
|
+
* attività · branch) + riga bottoni del modello di resume/fork (T148/P4). Le
|
|
42
|
+
* due anteprime (primo prompt + ultima risposta) sono variabili, ciascuna al
|
|
43
|
+
* più MAX_SESSION_PREVIEW righe. Conseguenza accettata: su un terminale basso
|
|
44
|
+
* il blocco sparisce con una riga di anticipo rispetto a prima di T148. */
|
|
45
|
+
const SESSION_DETAIL_FIXED = 3;
|
|
44
46
|
const MAX_SESSION_PREVIEW = 3;
|
|
45
47
|
/** T134 — preview inbox: nome del file + riga meta (natura, marcatori, branch,
|
|
46
48
|
* cappello, cifre, età). Righe TUTTE fisse, nessuna variabile: il corpo del
|
package/package.json
CHANGED