@lamemind/loom-deck 0.57.3 → 0.59.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/commit-times.js +66 -0
- package/dist/deck-model.js +38 -4
- package/dist/frame.js +1 -1
- package/dist/glyphs.js +17 -0
- package/dist/hooks.js +38 -0
- package/dist/input.js +6 -1
- package/dist/model.js +1 -1
- package/dist/pane-views.js +1 -1
- package/dist/spawn.js +20 -6
- package/dist/ui/detail-screen.js +2 -33
- package/dist/ui/fields.js +39 -1
- package/dist/ui/modals.js +1 -1
- package/dist/ui/preview.js +11 -4
- package/dist/ui/screens.js +1 -1
- package/dist/view-store.js +1 -1
- package/dist/view.js +11 -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 }), 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,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// T136 — data dell'ultimo commit che ha toccato ogni task file, in UNA
|
|
2
|
+
// passata di `git log` invece di uno spawn per file (vedi Description T136).
|
|
3
|
+
//
|
|
4
|
+
// `--format=#%ct` mette un prefisso che nessun path porta: la riga vuota
|
|
5
|
+
// compare anche FRA il timestamp e i path (non solo a fine blocco), quindi non
|
|
6
|
+
// può fare da separatore, e «tutte cifre» sarebbe una regola che un path
|
|
7
|
+
// potrebbe violare. Un merge commit senza `-m` non elenca path e produce un
|
|
8
|
+
// blocco senza righe path: si salta da sé, nessun ramo dedicato serve.
|
|
9
|
+
//
|
|
10
|
+
// git log esce in ordine cronologico DISCENDENTE: il primo hit di un path è
|
|
11
|
+
// già il suo ultimo commit, quindi si tiene solo il primo e si ignorano gli
|
|
12
|
+
// hit successivi dello stesso id.
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
import { basename } from 'node:path';
|
|
15
|
+
import { promisify } from 'node:util';
|
|
16
|
+
const execFileAsync = promisify(execFile);
|
|
17
|
+
const FORMAT_RE = /^#(\d+)$/;
|
|
18
|
+
// Il basename del task file, non il path intero (che cambia con la docs-root)
|
|
19
|
+
// e non `TASK_ID_RE` di tasks.ts (ancorata all'id nudo, non matcha `T136-x.md`).
|
|
20
|
+
const TASK_FILE_RE = /^(T\d+)-.*\.md$/;
|
|
21
|
+
/**
|
|
22
|
+
* Parsing puro dell'output `git log --format=#%ct --name-only`. Isolato da
|
|
23
|
+
* `commitTimes` perché è l'unica parte che vale la pena collaudare: invocare
|
|
24
|
+
* git davvero misurerebbe la storia di QUESTO repo, un valore che si muove a
|
|
25
|
+
* ogni commit.
|
|
26
|
+
*/
|
|
27
|
+
export function parseCommitLog(output) {
|
|
28
|
+
const out = new Map();
|
|
29
|
+
let epoch = null;
|
|
30
|
+
for (const raw of output.split('\n')) {
|
|
31
|
+
const line = raw.trim();
|
|
32
|
+
const m = FORMAT_RE.exec(line);
|
|
33
|
+
if (m) {
|
|
34
|
+
epoch = Number(m[1]);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!line || epoch === null)
|
|
38
|
+
continue;
|
|
39
|
+
const idm = TASK_FILE_RE.exec(basename(line));
|
|
40
|
+
if (!idm)
|
|
41
|
+
continue;
|
|
42
|
+
const id = idm[1];
|
|
43
|
+
if (!out.has(id))
|
|
44
|
+
out.set(id, epoch); // primo hit = il più recente
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `Map<taskId, epoch>` dell'ultimo commit di ogni task file sotto `tasksDir`.
|
|
50
|
+
*
|
|
51
|
+
* git muto (repo assente, path fuori dal repo, git non installato) → mappa
|
|
52
|
+
* vuota, mai un throw, come `archivable.ts`: un dato di ordinamento non può
|
|
53
|
+
* rompere il deck. Nessun `--since`/`-n`: un cap renderebbe la mappa
|
|
54
|
+
* incompleta proprio per le task vecchie mai più toccate, quelle che sotto
|
|
55
|
+
* `desc` finiscono in coda — dove un timestamp assente e uno vecchio si
|
|
56
|
+
* confonderebbero.
|
|
57
|
+
*/
|
|
58
|
+
export async function commitTimes(tasksDir, projectRoot) {
|
|
59
|
+
try {
|
|
60
|
+
const { stdout } = await execFileAsync('git', ['log', '--format=#%ct', '--name-only', '--', tasksDir], { cwd: projectRoot });
|
|
61
|
+
return parseCommitLog(stdout);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return new Map();
|
|
65
|
+
}
|
|
66
|
+
}
|
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, useDirtyFolders, useInboxScan, useSessions, useTaskDetail, useTasks, } from './hooks.js';
|
|
17
|
+
import { useArchivable, useCommitTimes, useDirtyFolders, 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
26
|
import { applyView, 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
|
|
@@ -159,9 +166,12 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
|
159
166
|
// includeva l'owner.
|
|
160
167
|
const projectCore = identity ? identity.name : null;
|
|
161
168
|
const projectName = cwd.split('/').pop() || cwd;
|
|
169
|
+
// T136 — data dell'ultimo commit di ogni task file, per la chiave `commit`
|
|
170
|
+
// della chain di sort.
|
|
171
|
+
const commitAt = useCommitTimes(tasksDir, cwd);
|
|
162
172
|
// La vista è una trasformazione DERIVATA, applicata a valle del load: il
|
|
163
173
|
// polling di tasks.md continua a funzionare senza saperne nulla.
|
|
164
|
-
const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
|
|
174
|
+
const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view, { commitAt }), [tasks, view, commitAt]);
|
|
165
175
|
// T61 — il conteggio guarda la lista GREZZA, non `viewTasks`: le Done fuori
|
|
166
176
|
// dai filtri della vista restano archiviabili, e un contatore che cambiasse
|
|
167
177
|
// filtrando direbbe qualcosa sulla vista invece che sulla task list.
|
|
@@ -197,7 +207,9 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
|
197
207
|
hidden: hiddenTasks,
|
|
198
208
|
archivable: archivable.size,
|
|
199
209
|
};
|
|
200
|
-
const paneTasks = useMemo(() => taskViewId === 'tasks'
|
|
210
|
+
const paneTasks = useMemo(() => taskViewId === 'tasks'
|
|
211
|
+
? viewTasks
|
|
212
|
+
: selectTasks(tasks, taskViewId, { view, archivable, commitAt }), [taskViewId, viewTasks, tasks, view, archivable, commitAt]);
|
|
201
213
|
const isSpot = sel === SPOT;
|
|
202
214
|
const isAll = sel === ALL;
|
|
203
215
|
// T112 — quando `CANC` pota in BLOCCO invece della sola task selezionata.
|
|
@@ -270,6 +282,17 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
|
270
282
|
setSelSessionId(firstSelectableId(sessionRows));
|
|
271
283
|
}
|
|
272
284
|
}, [sessionRows, selSessionId]);
|
|
285
|
+
// T148/P8 — reset del selettore modello all'evento «la riga selezionata è
|
|
286
|
+
// cambiata». Il selettore del detail (T108) si azzera all'APERTURA
|
|
287
|
+
// dell'overlay; questo blocco non apre e non chiude mai, quindi il suo
|
|
288
|
+
// evento equivalente è il cambio di `selSessionId`. Keyed SOLO su quello (non
|
|
289
|
+
// su `selSessionObj`, che cambia identità a ogni poll delle sessioni anche
|
|
290
|
+
// quando la riga selezionata resta la stessa): un'altra dipendenza
|
|
291
|
+
// resetterebbe la scelta dell'utente sotto un timer invece che sotto un
|
|
292
|
+
// movimento del cursore.
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
setResumeModel(modelAlias(selSessionObj?.model ?? '') ?? MODEL_DEFAULT);
|
|
295
|
+
}, [selSessionId]);
|
|
273
296
|
// T134 — gemello dei due sopra: un file drenato sparisce dalla coda al primo
|
|
274
297
|
// scan successivo, e la selezione cade sulla prima riga della lista invece
|
|
275
298
|
// che su una posizione a caso.
|
|
@@ -349,6 +372,15 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
|
349
372
|
const next = Math.max(0, Math.min(inboxFiles.length - 1, (at < 0 ? 0 : at) + delta));
|
|
350
373
|
setSelInboxPath(inboxFiles[next].path);
|
|
351
374
|
}
|
|
375
|
+
/** T148 — `m` scorre il selettore modello della riga sessione selezionata al
|
|
376
|
+
* valore SUCCESSIVO del catalogo (ciclico), gemella di `cycleView` ma su un
|
|
377
|
+
* asse diverso: qui non c'è una vista da cambiare, solo un valore da far
|
|
378
|
+
* avanzare. Il guard (nessuna sessione, focus altrove) sta nell'attuatore
|
|
379
|
+
* `actions.ts`, come per `f`/`p`: qui c'è solo la meccanica dello stato. */
|
|
380
|
+
function cycleResumeModel() {
|
|
381
|
+
const next = MODELS[(MODELS.indexOf(resumeModel) + 1) % MODELS.length];
|
|
382
|
+
setResumeModel(next);
|
|
383
|
+
}
|
|
352
384
|
/**
|
|
353
385
|
* T134 — `^B` scambia i due pane dello slot destro (D8 preflight).
|
|
354
386
|
*
|
|
@@ -427,6 +459,8 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
|
|
|
427
459
|
sessionCounts,
|
|
428
460
|
selSessionObj,
|
|
429
461
|
sessionCols,
|
|
462
|
+
resumeModel,
|
|
463
|
+
cycleResumeModel,
|
|
430
464
|
// derivazioni del pane inbox
|
|
431
465
|
rightPane,
|
|
432
466
|
inboxScanned: inbox.scanned,
|
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
|
package/dist/hooks.js
CHANGED
|
@@ -10,6 +10,7 @@ import { discoverProjectSessions } from './sessions.js';
|
|
|
10
10
|
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
|
+
import { commitTimes } from './commit-times.js';
|
|
13
14
|
import { scanInbox, INBOX_SCAN_INTERVAL_MS } from './inbox.js';
|
|
14
15
|
import { mixedCount, readWrapCache, runWrapScan, wrapCacheFile, wrapCount, } from './wrap-scan.js';
|
|
15
16
|
import { purgeTargets } from './purge.js';
|
|
@@ -67,6 +68,43 @@ export function useTasks(tasksPath) {
|
|
|
67
68
|
}, [tasksPath]);
|
|
68
69
|
return { tasks, loadError };
|
|
69
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* T136 — data dell'ultimo commit di ogni task file, per la chiave di sort
|
|
73
|
+
* `commit`. D1 (preflight): sullo STESSO poll di `tasks.md` (`POLL_MS`), non
|
|
74
|
+
* su una scala propria come `useArchivable` — una passata di `git log` costa
|
|
75
|
+
* ~33ms ed è UNA invocazione per tick, indipendentemente dal numero di task.
|
|
76
|
+
*
|
|
77
|
+
* P5 (preflight): `commitTimes` ritorna una `Map` nuova a ogni tick anche
|
|
78
|
+
* quando il contenuto non cambia; passata nuda a una `useMemo` a valle ne
|
|
79
|
+
* romperebbe la memoizzazione ogni 1,5s. Si confronta per FIRMA prima di
|
|
80
|
+
* aggiornare lo stato, come `lastMtime` in `useTasks` e `lastSig` in
|
|
81
|
+
* `useSessions`: l'identità della mappa cambia solo dopo un commit vero.
|
|
82
|
+
*/
|
|
83
|
+
export function useCommitTimes(tasksDir, projectRoot) {
|
|
84
|
+
const [commitAt, setCommitAt] = useState(() => new Map());
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
let lastSig = '';
|
|
87
|
+
let alive = true;
|
|
88
|
+
const reload = () => {
|
|
89
|
+
commitTimes(tasksDir, projectRoot).then((next) => {
|
|
90
|
+
if (!alive)
|
|
91
|
+
return;
|
|
92
|
+
const sig = [...next.entries()].map(([id, ts]) => `${id}:${ts}`).sort().join(',');
|
|
93
|
+
if (sig === lastSig)
|
|
94
|
+
return;
|
|
95
|
+
lastSig = sig;
|
|
96
|
+
setCommitAt(next);
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
reload();
|
|
100
|
+
const id = setInterval(reload, POLL_MS);
|
|
101
|
+
return () => {
|
|
102
|
+
alive = false;
|
|
103
|
+
clearInterval(id);
|
|
104
|
+
};
|
|
105
|
+
}, [tasksDir, projectRoot]);
|
|
106
|
+
return commitAt;
|
|
107
|
+
}
|
|
70
108
|
// Poll delle sessioni del progetto + binding sidecar. discoverProjectSessions
|
|
71
109
|
// ha cache mtime-keyed interna → il poll è economico; qui si evita comunque il
|
|
72
110
|
// 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/model.js
CHANGED
|
@@ -33,7 +33,7 @@ export const ROW_SPOT = 1;
|
|
|
33
33
|
export const META_ROWS = 2;
|
|
34
34
|
// Modale sort a grammatica libera: un tasto per chiave, pressioni successive
|
|
35
35
|
// ciclano asc → desc → fuori dalla chain.
|
|
36
|
-
export const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
|
|
36
|
+
export const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id', c: 'commit' };
|
|
37
37
|
// T52 — toggle del modale ricerca, tutti su CTRL (D2).
|
|
38
38
|
//
|
|
39
39
|
// I due campi di testo mangiano ogni lettera nuda, quindi un toggle non può
|
package/dist/pane-views.js
CHANGED
|
@@ -57,7 +57,7 @@ export const TASK_VIEWS = [
|
|
|
57
57
|
export function selectTasks(tasks, id, ctx) {
|
|
58
58
|
const entry = taskView(id);
|
|
59
59
|
const picked = tasks.filter((t) => entry.has(t, ctx));
|
|
60
|
-
picked.sort((a, b) => compareTasks(a, b, ctx.view.sort));
|
|
60
|
+
picked.sort((a, b) => compareTasks(a, b, ctx.view.sort, ctx));
|
|
61
61
|
return picked;
|
|
62
62
|
}
|
|
63
63
|
/** Glifo della sessione viva, gemello di `LIVE_IDLE` del render. Duplicato qui
|
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/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/modals.js
CHANGED
|
@@ -9,7 +9,7 @@ import { CARET, CARET_OFF, WARN } from '../glyphs.js';
|
|
|
9
9
|
import { EDIT_PRI, EDIT_PROG } from '../model.js';
|
|
10
10
|
import { PRI_ENTRIES, PROG_ENTRIES } from '../view.js';
|
|
11
11
|
import { progressText, PRI_GLYPH, PRI_LABEL, PROG_GLYPH } from '../task-edit.js';
|
|
12
|
-
export const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
12
|
+
export const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id', commit: 'commit' };
|
|
13
13
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
14
14
|
// spingono giù i pane invece di coprirli, così la lista che stai filtrando
|
|
15
15
|
// resta sempre visibile mentre la componi.
|
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/screens.js
CHANGED
|
@@ -71,7 +71,7 @@ export function HintBar({ mode, purge, keyLegend, indicators, columns, }) {
|
|
|
71
71
|
return (_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"] }));
|
|
72
72
|
}
|
|
73
73
|
if (mode === 'sort') {
|
|
74
|
-
return (_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
|
|
74
|
+
return (_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 ", _jsx(Text, { color: "yellow", children: "c" }), " commit (asc\u2192desc\u2192off) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
|
|
75
75
|
}
|
|
76
76
|
if (mode === 'filter') {
|
|
77
77
|
return (_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"] }));
|
package/dist/view-store.js
CHANGED
|
@@ -10,7 +10,7 @@ import { DEFAULT_VIEW, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
|
10
10
|
export function viewFilePath(projectRoot) {
|
|
11
11
|
return join(projectRoot, '.claude', 'loom', 'deck-view.json');
|
|
12
12
|
}
|
|
13
|
-
const SORT_KEYS = ['pri', 'prog', 'id'];
|
|
13
|
+
const SORT_KEYS = ['pri', 'prog', 'id', 'commit'];
|
|
14
14
|
const PRI_NAMES = PRI_ENTRIES.map((e) => e.name);
|
|
15
15
|
const PROG_NAMES = PROG_ENTRIES.map((e) => e.name);
|
|
16
16
|
// Il file è editabile a mano e sopravvive ai cambi di schema: si tiene solo ciò
|
package/dist/view.js
CHANGED
|
@@ -116,11 +116,16 @@ export function taskColumns(tasks, data) {
|
|
|
116
116
|
}
|
|
117
117
|
return { id: idColumnWidth(tasks), tail: tail > 0 ? tail + 1 : 0 };
|
|
118
118
|
}
|
|
119
|
-
|
|
119
|
+
// La chiave `commit` non ha un glifo: il rango è l'epoch stesso. Assente →
|
|
120
|
+
// UNKNOWN_RANK (0), sempre più basso di un epoch reale → coda sotto `desc`,
|
|
121
|
+
// stessa semantica del glifo non riconosciuto per `pri`/`prog`.
|
|
122
|
+
function rankOf(task, key, ctx) {
|
|
120
123
|
if (key === 'pri')
|
|
121
124
|
return priRank(task.pri);
|
|
122
125
|
if (key === 'prog')
|
|
123
126
|
return progRank(task.prog);
|
|
127
|
+
if (key === 'commit')
|
|
128
|
+
return ctx.commitAt.get(task.id) ?? UNKNOWN_RANK;
|
|
124
129
|
return idNum(task.id);
|
|
125
130
|
}
|
|
126
131
|
/**
|
|
@@ -129,9 +134,9 @@ function rankOf(task, key) {
|
|
|
129
134
|
* SEMPRE deterministico (mai instabile fra re-render). Se `id` è già una chiave
|
|
130
135
|
* esplicita della chain il fallback non serve: l'id è unico, la parità è totale.
|
|
131
136
|
*/
|
|
132
|
-
export function compareTasks(a, b, sort) {
|
|
137
|
+
export function compareTasks(a, b, sort, ctx) {
|
|
133
138
|
for (const entry of sort) {
|
|
134
|
-
const diff = rankOf(a, entry.key) - rankOf(b, entry.key);
|
|
139
|
+
const diff = rankOf(a, entry.key, ctx) - rankOf(b, entry.key, ctx);
|
|
135
140
|
if (diff !== 0)
|
|
136
141
|
return entry.dir === 'asc' ? diff : -diff;
|
|
137
142
|
}
|
|
@@ -174,14 +179,14 @@ export function isVisible(task, view) {
|
|
|
174
179
|
return true;
|
|
175
180
|
}
|
|
176
181
|
/** Filtra poi ordina. Non muta l'input: il polling di tasks.md resta ignaro. */
|
|
177
|
-
export function applyView(tasks, view) {
|
|
182
|
+
export function applyView(tasks, view, ctx) {
|
|
178
183
|
const visible = tasks.filter((t) => isVisible(t, view));
|
|
179
|
-
visible.sort((a, b) => compareTasks(a, b, view.sort));
|
|
184
|
+
visible.sort((a, b) => compareTasks(a, b, view.sort, ctx));
|
|
180
185
|
return { visible, hidden: tasks.length - visible.length };
|
|
181
186
|
}
|
|
182
187
|
export const PRI_ENTRIES = PRI_TABLE.map((e) => ({ name: e.name, glyph: e.glyph }));
|
|
183
188
|
export const PROG_ENTRIES = PROG_TABLE.map((e) => ({ name: e.name, glyph: e.glyph }));
|
|
184
|
-
const SORT_LABEL = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
189
|
+
const SORT_LABEL = { pri: 'pri', prog: 'stato', id: 'id', commit: 'commit' };
|
|
185
190
|
/** Riassunto della chain per l'header ("pri↓ id↑"); vuota → "—". */
|
|
186
191
|
export function describeSort(sort) {
|
|
187
192
|
if (sort.length === 0)
|
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