@lamemind/loom-deck 0.47.0 → 0.49.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/README.md +30 -2
- package/dist/cli.js +82 -17
- package/dist/glyphs.js +38 -2
- package/dist/input-modes.js +1 -0
- package/dist/model.js +7 -3
- package/dist/overlays/status.js +159 -0
- package/dist/project-status.js +71 -0
- package/dist/spawn.js +48 -2
- package/dist/task-edit.js +2 -0
- package/dist/ui/assign-screen.js +11 -10
- package/dist/ui/panes.js +8 -33
- package/dist/ui/status-screen.js +56 -0
- package/dist/ui/task-row.js +58 -0
- package/dist/view.js +29 -3
- package/dist/viewport.js +18 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ Regola unica, senza eccezioni — pensata per reggere l'aggiunta di nuove azioni
|
|
|
74
74
|
| `⏎` | azione primaria del pane | Tasks → apre il **detail** della task selezionata · Sessions → riprende (`claude --resume`) la sessione selezionata |
|
|
75
75
|
| **MAIUSCOLA** | **apre un modale** | cattura tutti i tasti; `esc` annulla, non esce |
|
|
76
76
|
| minuscola | azione immediata, one-shot | |
|
|
77
|
-
| `CTRL`+lettera | idiomi universali, toggle dentro i modali di testo, varianti di un'azione | `^F` = find, come ovunque · `^K`/`^P`/`^R` = spawn con un altro prompt |
|
|
77
|
+
| `CTRL`+lettera | idiomi universali, toggle dentro i modali di testo, varianti di un'azione | `^F` = find, come ovunque · `^K`/`^P`/`^R` = spawn con un altro prompt · `^G`/`^O` = genera/apri il project status |
|
|
78
78
|
| `1`…`9` | voce `launch` n-esima del progetto | da `.claude/loom-works.json` |
|
|
79
79
|
| `esc` | chiude l'overlay aperto | in modalità normale è inerte: **nessuna lettera chiude il deck** |
|
|
80
80
|
| `^C` `^C` | chiude il deck | la prima pressione avverte in riga di stato, la seconda entro **5 secondi** esce; passata la finestra si riparte dall'avviso. Da dentro un modale il primo `^C` riporta alla lista — l'avviso vive nella riga di stato, che le schermate a pieno frame non disegnano |
|
|
@@ -94,6 +94,8 @@ Assegnazioni correnti:
|
|
|
94
94
|
| modale | `^F` | **ricerca full-text** nelle conversazioni del progetto |
|
|
95
95
|
| modale | `⏎` | **detail** della task selezionata: task file scrollabile + area di compilazione a quattro righe |
|
|
96
96
|
| modale | `CANC` | **elimina** — la task selezionata; su una riga meta della vista `archiviabili`, l'intero insieme |
|
|
97
|
+
| modale | `^O` | apre il **project status** in cache (viewer fullscreen) |
|
|
98
|
+
| immediata | `^G` | **genera** il project status (skill headless, dura minuti) |
|
|
97
99
|
| immediata | `^K` | spawna la task selezionata col prompt di **recap** |
|
|
98
100
|
| immediata | `^P` | spawna la task selezionata sul **preflight** |
|
|
99
101
|
| immediata | `^R` | spawna la task selezionata in **esecuzione** |
|
|
@@ -106,7 +108,7 @@ Assegnazioni correnti:
|
|
|
106
108
|
Il footer è **due righe con due nature diverse**:
|
|
107
109
|
|
|
108
110
|
```
|
|
109
|
-
⏎ detail · ^K/^P/^R spawn · ^F cerca · C nuova · E edit · S sort · F filtri · w salva
|
|
111
|
+
⏎ detail · ^K/^P/^R spawn · ^G genera status · ^O apri status · ^F cerca · C nuova · E edit · S sort · F filtri · w salva
|
|
110
112
|
t 💻 · c 🤖 · 1 📝 codium · 2 ☕ idea
|
|
111
113
|
```
|
|
112
114
|
|
|
@@ -233,6 +235,32 @@ I toggle e la query sono **volatili**: sopravvivono alla chiusura del modale, no
|
|
|
233
235
|
|
|
234
236
|
**Come fa a essere istantanea.** I corpi dei messaggi restano in RAM dentro la cache mtime-keyed che il deck usa già per la lista sessioni: quei file venivano comunque deserializzati per turni e titoli, e i corpi buttati. Trattenerli non aggiunge I/O, aggiunge memoria — e ne aggiunge poca, perché un JSONL è per l'85% overhead (misurato su un progetto reale: 57 MB su disco = 9,8 MB di testo cercabile). Cercarci dentro costa 1-9 ms; un prefiltro `grep` sugli stessi file ne costerebbe 26, perché rileggerebbe il volume pieno a ogni battuta.
|
|
235
237
|
|
|
238
|
+
### `^G` / `^O` — il project status
|
|
239
|
+
|
|
240
|
+
Il recap di progetto (`/loom-works:recap-status-project`) non è più solo una conversazione da aprire e perdere: il deck lo tiene come **artefatto**. La prima riga della cornice lo dice sempre.
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
loom-works :: PROJECT STATUS [ 09:42 ] v0.49.0
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Il campo fra parentesi ha tre stati e basta:
|
|
247
|
+
|
|
248
|
+
| Stato | Significato |
|
|
249
|
+
|---|---|
|
|
250
|
+
| `missing` | nessun recap in cache: mai generato, o cache svuotata |
|
|
251
|
+
| `◍ building 47...` | generazione in corso, coi secondi trascorsi dallo spawn |
|
|
252
|
+
| `09:42` | ora dell'ultima generazione riuscita |
|
|
253
|
+
|
|
254
|
+
Il glifo `◍` è lo stesso della colonna liveness delle sessioni: una generazione in corso **è** una conversazione che sta lavorando, non un simbolo nuovo da imparare. Dopo un tentativo fallito il campo porta un marker in coda (`[ 09:42 ⚠ ]`) e se lo tiene finché una generazione riuscita non lo azzera — la riga di stato sparisce al primo tasto premuto, e un fallimento non deve poter passare inosservato per una pressione di freccia. Il fallimento **non** cancella l'ora: la cache non è stata riscritta, quindi il recap di prima è ancora vero e ancora apribile.
|
|
255
|
+
|
|
256
|
+
**Due tasti, non uno**: `^G` genera, `^O` apre. Aprire un recap vecchio costa una lettura di file, generarlo costa minuti di sessione Claude — legarli allo stesso tasto renderebbe il gesto economico un effetto collaterale di quello caro. `^G` con una generazione già in corso non ne fa partire una seconda; `^O` su cache assente lo dice nella riga di stato invece di aprire un viewer vuoto.
|
|
257
|
+
|
|
258
|
+
Il recap vive in un file markdown sotto `/tmp/loom-deck-status-<uid>/`, col nome derivato dal path del progetto (non-alnum → `-`, la stessa trasformazione del transcript store di Claude Code). Da lì tre conseguenze volute: **sopravvive alla chiusura del deck** (riaperto, la testata mostra l'ora di prima), muore al reboot (un recap di ieri è comunque stale), e la coordinata resta una funzione pura del path invece di un registro da tenere allineato. L'ora mostrata è l'**mtime del file**, mai un campo scritto dentro il testo: un fatto scritto due volte diverge alla prima riscrittura parziale.
|
|
259
|
+
|
|
260
|
+
`^O` apre un **viewer fullscreen** con lo stesso markdown reso del detail della task: `↑↓` riga, `PgUp`/`PgDn` pagina, `g`/`G` estremi, `esc` chiude. Aperto mentre una generazione gira, mostra la versione precedente e lo **dichiara** nella riga meta — il viewer è una fotografia e non si aggiorna da sé quando la generazione finisce.
|
|
261
|
+
|
|
262
|
+
A generazione finita arriva una notifica desktop (`notify-send`): la generazione dura minuti e nel frattempo si guarda altrove, e la notifica di GNOME persiste nel cassetto — cosa che un suono non farebbe. Senza `notify-send` installato non succede **niente**: nessuna riga, nessun messaggio. L'assenza di un binario è una proprietà stabile della macchina, non un evento, e segnalarla a ogni generazione sarebbe rumore ripetuto su una funzione accessoria mentre il deliverable vero è arrivato comunque.
|
|
263
|
+
|
|
236
264
|
## Vista: filtri e ordinamenti
|
|
237
265
|
|
|
238
266
|
La lista è una **vista** sulla `tasks.md`: si filtra e si ordina senza toccare il file.
|
package/dist/cli.js
CHANGED
|
@@ -11,17 +11,18 @@ import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch,
|
|
|
11
11
|
import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, TASK_VIEWS, } from './pane-views.js';
|
|
12
12
|
import { isCompact, layoutBudget, searchPreviewCapacity, windowRange, } from './viewport.js';
|
|
13
13
|
import { cut, cutMiddle, sanitize, termWidth } from './width.js';
|
|
14
|
-
import { applyView, cycleSort, describeSort,
|
|
14
|
+
import { applyView, cycleSort, describeSort, priName, progName, taskColumns, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
15
15
|
import { initialDetail, writeTaskEdit, PRI_GLYPH, PRI_LABEL } from './task-edit.js';
|
|
16
16
|
import { ALL, EDIT_FIELDS, EDIT_PRI, EDIT_PROG, editTextField, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, QUIT_WINDOW_MS, ROW_ALL, ROW_SPOT, SORT_TASTI, SPOT, } from './model.js';
|
|
17
17
|
import { purgeTargets, splitTargets } from './purge.js';
|
|
18
18
|
import { conversationLabel, isDone } from './layout.js';
|
|
19
19
|
import { fieldsKey } from './fields.js';
|
|
20
|
-
import { TASK_EMPTY, relTime, sanitizeTyped
|
|
20
|
+
import { TASK_EMPTY, relTime, sanitizeTyped } from './glyphs.js';
|
|
21
21
|
import { commitTaskEdit, runLaunch, spawnClaudeEmpty, spawnCleanTasks, spawnCreateTask, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, onInTabCommand, CLAUDE_CMD, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
|
|
22
22
|
import { EditModal, FilterModal, PurgeModal, SortModal, idList } from './ui/modals.js';
|
|
23
23
|
import { ReaderScreen, SearchScreen } from './ui/search-screen.js';
|
|
24
24
|
import { DetailScreen } from './ui/detail-screen.js';
|
|
25
|
+
import { StatusHeadline, StatusScreen } from './ui/status-screen.js';
|
|
25
26
|
import { AssignScreen } from './ui/assign-screen.js';
|
|
26
27
|
import { SessionsPane, TasksPane } from './ui/panes.js';
|
|
27
28
|
import { PreviewPane, detailMetaOf } from './ui/preview.js';
|
|
@@ -30,6 +31,7 @@ import { useArchivable, useDirtyFolders, useSessions, useTaskDetail, useTasks, u
|
|
|
30
31
|
import { useSearchOverlay } from './overlays/search.js';
|
|
31
32
|
import { useSheetOverlay } from './overlays/sheet.js';
|
|
32
33
|
import { useAssignOverlay } from './overlays/assign.js';
|
|
34
|
+
import { useProjectStatus } from './overlays/status.js';
|
|
33
35
|
import { captures } from './input-modes.js';
|
|
34
36
|
import { VERSION } from './version.js';
|
|
35
37
|
// T116 — l'avviso della prima pressione di `^C`. La durata è INTERPOLATA dalla
|
|
@@ -162,28 +164,43 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
162
164
|
// alla vista che sceglie l'header (D2 create).
|
|
163
165
|
const parentLabel = isAll ? 'tutte' : isSpot ? 'spot' : selectedTaskId ?? '—';
|
|
164
166
|
// Conteggio figli per task + spot (badge nel Tasks pane).
|
|
167
|
+
//
|
|
168
|
+
// T124 — il rollup delle VIVE si deriva nello stesso ciclo, non da un secondo
|
|
169
|
+
// passaggio sul registry dei processi: quel registry conosce una conversazione
|
|
170
|
+
// PRIMA che abbia scritto il suo primo record di transcript, quindi due
|
|
171
|
+
// derivazioni indipendenti renderebbero producibile `1/0` — una viva senza
|
|
172
|
+
// totale — che non è un caso limite teorico ma la finestra normale fra lo
|
|
173
|
+
// spawn e il primo turno. Intersecando qui, `vive ≤ totali` è garantito per
|
|
174
|
+
// costruzione: la conversazione appena nata resta invisibile a entrambi i
|
|
175
|
+
// numeri finché non scrive, e il segnale arriva in ritardo di un turno invece
|
|
176
|
+
// che sbagliato.
|
|
165
177
|
const childCount = new Map();
|
|
178
|
+
const taskLive = new Map();
|
|
166
179
|
let spotCount = 0;
|
|
167
180
|
for (const s of sessions) {
|
|
168
181
|
const bound = bindings.get(s.sessionId);
|
|
169
|
-
if (bound)
|
|
170
|
-
childCount.set(bound, (childCount.get(bound) ?? 0) + 1);
|
|
171
|
-
else
|
|
182
|
+
if (!bound) {
|
|
172
183
|
spotCount++;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
childCount.set(bound, (childCount.get(bound) ?? 0) + 1);
|
|
187
|
+
const entry = live.get(s.sessionId);
|
|
188
|
+
if (!entry)
|
|
189
|
+
continue;
|
|
190
|
+
const prev = taskLive.get(bound);
|
|
191
|
+
// Rollup a stato misto: vince `busy`. Fra N vive, quella che sta lavorando è
|
|
192
|
+
// la ragione per cui si guarda la riga.
|
|
193
|
+
taskLive.set(bound, {
|
|
194
|
+
count: (prev?.count ?? 0) + 1,
|
|
195
|
+
status: prev?.status === 'busy' || entry.status === 'busy' ? 'busy' : 'idle',
|
|
196
|
+
});
|
|
173
197
|
}
|
|
198
|
+
const taskRowData = { childCount, live: taskLive, dirty: dirtyFolders };
|
|
174
199
|
// T118 — colonne fisse della lista task, misurate su `paneTasks` (la vista
|
|
175
200
|
// attiva INTERA) e non sulla finestra visibile: gemelle di `sessionCols` e
|
|
176
201
|
// per la stessa ragione, che una larghezza derivata dallo schermo si muove a
|
|
177
|
-
// ogni scroll.
|
|
178
|
-
|
|
179
|
-
// colonna spenta a `0`, e le descrizioni si riprendono lo spazio.
|
|
180
|
-
const taskCols = (() => {
|
|
181
|
-
let tail = 0;
|
|
182
|
-
for (const t of paneTasks) {
|
|
183
|
-
tail = Math.max(tail, termWidth(taskTail(childCount.get(t.id) ?? 0, dirtyFolders.has(t.id))));
|
|
184
|
-
}
|
|
185
|
-
return { id: idColumnWidth(paneTasks), tail: tail > 0 ? tail + 1 : 0 };
|
|
186
|
-
})();
|
|
202
|
+
// ogni scroll.
|
|
203
|
+
const taskCols = taskColumns(paneTasks, taskRowData);
|
|
187
204
|
// Figli della selezione: tutte le conversazioni del progetto (`≡ tutte`), le
|
|
188
205
|
// sessioni bound alla task selezionata, oppure (spot) quelle senza binding.
|
|
189
206
|
// sessions è già ts desc → l'ordine si eredita in tutti e tre i rami.
|
|
@@ -742,6 +759,20 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
742
759
|
setNote,
|
|
743
760
|
onResume: (row) => resumeSession(row.session.sessionId),
|
|
744
761
|
});
|
|
762
|
+
// T121 — il project status. Non è solo un overlay come i tre sopra: il suo
|
|
763
|
+
// stato si legge in TESTATA a schermata chiusa, e il viewer è una delle sue
|
|
764
|
+
// superfici invece che il suo contenuto.
|
|
765
|
+
// Il nome è quello del file config quando c'è, la cartella altrimenti: la
|
|
766
|
+
// testata deve dire di quale progetto parla anche su un checkout non ancora
|
|
767
|
+
// registrato.
|
|
768
|
+
const status = useProjectStatus({
|
|
769
|
+
cwd,
|
|
770
|
+
name: projectCore ?? projectName,
|
|
771
|
+
rows,
|
|
772
|
+
columns,
|
|
773
|
+
setMode,
|
|
774
|
+
setNote,
|
|
775
|
+
});
|
|
745
776
|
function onCreateKey(input, key) {
|
|
746
777
|
if (key.escape) {
|
|
747
778
|
setMode('normal');
|
|
@@ -914,6 +945,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
914
945
|
// `CapturingMode`, un modo nuovo senza handler non compila.
|
|
915
946
|
const MODE_KEYS = {
|
|
916
947
|
detail: sheet.onKey,
|
|
948
|
+
status: status.onKey,
|
|
917
949
|
reader: search.onReaderKey,
|
|
918
950
|
search: search.onSearchKey,
|
|
919
951
|
assign: assign.onKey,
|
|
@@ -965,6 +997,17 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
965
997
|
else if (input === 'r') {
|
|
966
998
|
spawnTaskSession('run', '^R');
|
|
967
999
|
}
|
|
1000
|
+
else if (input === 'g') {
|
|
1001
|
+
// T121 — GENERA e APRI su due tasti distinti (D2): aprire un recap
|
|
1002
|
+
// vecchio deve costare zero, e generare non deve essere un effetto
|
|
1003
|
+
// collaterale del guardare. Nessuno dei due è preso né bruciato da Ink,
|
|
1004
|
+
// e `^S`/`^Q` sono evitati perché il terminale li intercetta come flow
|
|
1005
|
+
// control.
|
|
1006
|
+
status.generate();
|
|
1007
|
+
}
|
|
1008
|
+
else if (input === 'o') {
|
|
1009
|
+
status.open();
|
|
1010
|
+
}
|
|
968
1011
|
return;
|
|
969
1012
|
}
|
|
970
1013
|
if (key.tab) {
|
|
@@ -1251,6 +1294,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1251
1294
|
// schermate. Rename di sola ETICHETTA: il dato resta `note` nel sidecar e
|
|
1252
1295
|
// `--title-note` in deck-run, dove rinominarlo sarebbe un breaking.
|
|
1253
1296
|
...(canPin ? ['p pin', 'N titolo', 'A assegna'] : []),
|
|
1297
|
+
// T121 — le due voci nominano il GESTO e non l'oggetto: «status» da solo
|
|
1298
|
+
// sarebbe indistinguibile dal recap della task su `^K`, che è un'altra
|
|
1299
|
+
// cosa e sta a due voci di distanza.
|
|
1300
|
+
'^G genera status',
|
|
1301
|
+
'^O apri status',
|
|
1254
1302
|
'^F cerca',
|
|
1255
1303
|
'C nuova',
|
|
1256
1304
|
'E edit',
|
|
@@ -1276,7 +1324,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1276
1324
|
// stale non resta nulla: il titolo si accontenta dell'hash.
|
|
1277
1325
|
const label = (assign.sid ? sessionNotes.get(assign.sid) : '') ||
|
|
1278
1326
|
(s ? conversationLabel(s, projectCore, assign.sid ? bindings.get(assign.sid) : undefined) : '');
|
|
1279
|
-
|
|
1327
|
+
// T124 — le colonne si misurano sulla lista FILTRATA di questa schermata,
|
|
1328
|
+
// non su `paneTasks`: la popolazione è un'altra, e riusare le larghezze del
|
|
1329
|
+
// pane darebbe una colonna dimensionata su righe che qui non ci sono.
|
|
1330
|
+
// `assign.list` porta in testa la riga `detach` (`null`), che non è una task.
|
|
1331
|
+
const assignCols = taskColumns(assign.list.filter((t) => t !== null), taskRowData);
|
|
1332
|
+
return (_jsx(AssignScreen, { sessionId: assign.sid ?? '', label: label, current: assign.sid ? bindings.get(assign.sid) ?? null : null, filter: assign.filter, rows: assign.list.slice(win.start, win.end), selected: assign.sel, matched: assign.list.length - 1, hidden: hiddenTasks, above: win.start, below: assign.list.length - win.end, idW: assignCols.id, tailW: assignCols.tail, data: taskRowData, columns: columns, note: note }));
|
|
1280
1333
|
}
|
|
1281
1334
|
// ── T66 · detail della task ─────────────────────────────────────────────
|
|
1282
1335
|
// Quarta schermata sostitutiva, stessa ragione delle altre tre: un task file
|
|
@@ -1292,6 +1345,18 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1292
1345
|
const start = Math.min(sheet.top, sheet.maxTop);
|
|
1293
1346
|
return (_jsx(DetailScreen, { id: sheet.sheet.id, title: sheet.sheet.title, missing: sheet.sheet.text === null, lines: sheet.lines.slice(start, start + sheet.capacity), spans: sheet.doc?.spans ?? [], top: start, total: sheet.lines.length, capacity: sheet.capacity, action: sheet.action, model: sheet.model, spawnNote: sheet.spawnNote, prompt: sheet.prompt, cursor: sheet.cursor, columns: columns, find: sheet.find, occ: sheet.findRes.occ, occCur: sheet.occCur }));
|
|
1294
1347
|
}
|
|
1348
|
+
// ── T121 · viewer del project status ────────────────────────────────────
|
|
1349
|
+
// Quinta schermata sostitutiva, stessa ragione delle altre quattro: un recap
|
|
1350
|
+
// di progetto è lungo quanto un task file e non entra in un box sopra i pane.
|
|
1351
|
+
if (mode === 'status' && status.view) {
|
|
1352
|
+
if (isCompact(status.capacity)) {
|
|
1353
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 project status \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7 esc chiude"] })] }));
|
|
1354
|
+
}
|
|
1355
|
+
// Il clamp serve anche qui: un resize può accorciare il testo sotto uno
|
|
1356
|
+
// scroll già dato.
|
|
1357
|
+
const start = Math.min(status.top, status.maxTop);
|
|
1358
|
+
return (_jsx(StatusScreen, { name: projectCore ?? projectName, label: status.label, building: status.building, failed: status.failed, view: status.view, lines: status.lines.slice(start, start + status.capacity), spans: status.doc?.spans ?? [], top: start, total: status.lines.length, capacity: status.capacity, columns: columns }));
|
|
1359
|
+
}
|
|
1295
1360
|
// ── T52 · schermate sostitutive ─────────────────────────────────────────
|
|
1296
1361
|
// Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
|
|
1297
1362
|
// pane: una lista di occorrenze non entra in un box da 4 righe. Prendono
|
|
@@ -1381,7 +1446,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1381
1446
|
if (budget.compact) {
|
|
1382
1447
|
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "v", VERSION, " \u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? parentLabel, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
|
|
1383
1448
|
}
|
|
1384
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(
|
|
1449
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(StatusHeadline, { name: projectCore ?? projectName, label: status.label, building: status.building, failed: status.failed, cols: Math.max(4, columns - 4 - `v${VERSION}`.length - 1) }), _jsxs(Text, { dimColor: true, children: ["v", VERSION] })] }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["titolo conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'purge' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["elimina task \u00B7", ' ', purge?.ignored ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " keep/purge dei file non tracciati \u00B7", ' '] })) : null, _jsx(Text, { color: "yellow", children: "\u23CE" }), " conferma \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: keyLegend })), mode === 'normal' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [surfaceLegend, legend.shown ? ` · ${legend.shown}` : '', legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editCursor.row, caret: editCursor.caret, columns: columns })) : null, mode === 'purge' && purge ? _jsx(PurgeModal, { draft: purge, columns: columns }) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, counts: taskCounts, activeView: taskViewId, paneCount: paneTasks.length, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, idW: taskCols.id, tailW: taskCols.tail, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: paneTasks.length - taskWin.end, columns: columns, data: taskRowData }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, isAll: isAll, bindings: bindings, taskW: sessionCols.task, ageW: sessionCols.age, rows: windowRows, counts: sessionCounts, activeView: sessionViewId, paneCount: sessionRows.length, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore, live: live })] }), budget.preview && previewKind === 'task' && detail ? (_jsx(PreviewPane, { kind: "task", detail: detail, maxLines: budget.detailLines, columns: columns })) : budget.preview && previewKind === 'session' && selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: selSessionObj, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, origin: forkOf.get(selSessionObj.sessionId) ?? null, note: sessionNotes.get(selSessionObj.sessionId) ?? '', live: live.get(selSessionObj.sessionId) ?? null })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
1385
1450
|
}
|
|
1386
1451
|
const cwd = process.cwd();
|
|
1387
1452
|
// T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
|
package/dist/glyphs.js
CHANGED
|
@@ -64,6 +64,14 @@ export function modelShort(id) {
|
|
|
64
64
|
// Sulla riga CHIUSA c'è uno spazio e non un terzo glifo: le chiuse sono la
|
|
65
65
|
// maggioranza di ogni lista, e marcarle vorrebbe dire disegnare N volte «niente
|
|
66
66
|
// da dire» — la colonna diventerebbe rumore invece di un segnale.
|
|
67
|
+
//
|
|
68
|
+
// T124 — non sono più il vocabolario di una superficie sola: la riga del pane
|
|
69
|
+
// task adotta la stessa coppia, nella stessa forma (glifo attaccato all'id che
|
|
70
|
+
// qualifica, colore e grassetto sull'id) e con lo stesso significato. L'unica
|
|
71
|
+
// differenza ammessa è la POPOLAZIONE su cui il glifo si pronuncia — una
|
|
72
|
+
// conversazione di là, le N conversazioni di una task di qua, con un rollup che
|
|
73
|
+
// fa vincere `busy`. Riusarli con un significato spostato sarebbe un falso
|
|
74
|
+
// amico, peggio di un glifo nuovo.
|
|
67
75
|
export const LIVE_IDLE = '●';
|
|
68
76
|
export const LIVE_BUSY = '◍';
|
|
69
77
|
export const LIVE_NONE = ' ';
|
|
@@ -89,13 +97,33 @@ export function displayProg(prog) {
|
|
|
89
97
|
* a disegnarla (sulla finestra, in `panes.tsx`). Due formattazioni gemelle
|
|
90
98
|
* divergerebbero alla prima modifica, e la colonna risulterebbe larga quanto
|
|
91
99
|
* una stringa che nessuno scrive.
|
|
100
|
+
*
|
|
101
|
+
* T124 — il contatore spezza vive e totali (`(1/5`) invece di sommarle: un
|
|
102
|
+
* numero solo cresce e non torna più indietro, quindi dice quanta storia ha la
|
|
103
|
+
* task e non se lì sta succedendo qualcosa adesso. A zero vive resta il solo
|
|
104
|
+
* totale (`(5`): scrivere `(0/5` metterebbe uno zero su quasi tutte le righe di
|
|
105
|
+
* ogni lista, e il segnale sparirebbe nel rumore.
|
|
106
|
+
*
|
|
107
|
+
* La parentesi di CHIUSURA cade sempre. La colonna è ancorata a destra, quindi
|
|
108
|
+
* la `)` costerebbe una cella per spostare verso l'interno l'unica cosa che si
|
|
109
|
+
* legge davvero; il bordo del pane chiude già il gruppo. Quella di apertura
|
|
110
|
+
* serve invece a staccare il contatore dalla descrizione a lunghezza libera che
|
|
111
|
+
* gli sta a sinistra — l'asimmetria è voluta.
|
|
92
112
|
*/
|
|
93
|
-
export function taskTail(
|
|
94
|
-
const count =
|
|
113
|
+
export function taskTail(live, total, dirty) {
|
|
114
|
+
const count = total > 0 ? (live > 0 ? `(${live}/${total}` : `(${total}`) : '';
|
|
95
115
|
if (!dirty)
|
|
96
116
|
return count;
|
|
97
117
|
return count ? `${WARN} ${count}` : WARN;
|
|
98
118
|
}
|
|
119
|
+
/** T124 — contatore di una riga META del pane task (`≡ tutte`, `○ spot`), che
|
|
120
|
+
* non è una colonna ancorata a niente. Prende comunque la grafia senza chiusa:
|
|
121
|
+
* due forme dello stesso oggetto a due righe di distanza si leggono come un
|
|
122
|
+
* errore, mentre una forma sola applicata anche dove la sua ragione non serve
|
|
123
|
+
* si legge come una convenzione. */
|
|
124
|
+
export function metaCount(n) {
|
|
125
|
+
return n > 0 ? ` (${n}` : '';
|
|
126
|
+
}
|
|
99
127
|
// T49 — size umana compatta per il detail pane sessione.
|
|
100
128
|
export function fmtSize(bytes) {
|
|
101
129
|
if (bytes < 1024)
|
|
@@ -111,6 +139,14 @@ export function fmtDateTime(ts) {
|
|
|
111
139
|
const p = (n) => String(n).padStart(2, '0');
|
|
112
140
|
return `${p(d.getDate())}/${p(d.getMonth() + 1)} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
113
141
|
}
|
|
142
|
+
// T121 — sola ora del giorno, per l'indicatore di project status in testata. Il
|
|
143
|
+
// giorno non ci sta e non servirebbe: un recap del giorno prima è comunque
|
|
144
|
+
// stale, e la cache muore col reboot.
|
|
145
|
+
export function fmtTime(ts) {
|
|
146
|
+
const d = new Date(ts);
|
|
147
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
148
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
149
|
+
}
|
|
114
150
|
// Età relativa compatta (ms epoch → "2m"/"3h"/"5d") per il preview sessioni.
|
|
115
151
|
export function relTime(ts) {
|
|
116
152
|
const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
package/dist/input-modes.js
CHANGED
package/dist/model.js
CHANGED
|
@@ -56,10 +56,14 @@ export const KIND_LABEL = { ai: 'IA', tool: 'tools', human: 'human' };
|
|
|
56
56
|
// T41 — ordine dei valori nel modale edit. Deliberatamente DIVERSO da
|
|
57
57
|
// PRI_ENTRIES/PROG_ENTRIES (che seguono il rango di sort): qui si sceglie un
|
|
58
58
|
// valore, non si ordina, quindi vince l'ordine del CICLO DI VITA — da fare →
|
|
59
|
-
// in corso → chiusa → bloccata. La priorità resta alta→bassa,
|
|
60
|
-
// l'ordine naturale di lettura.
|
|
59
|
+
// preflight fatto → in corso → chiusa → bloccata. La priorità resta alta→bassa,
|
|
60
|
+
// che è già l'ordine naturale di lettura.
|
|
61
|
+
//
|
|
62
|
+
// L'elenco deve coprire OGNI valore di ProgName: il cursore del modale nasce da
|
|
63
|
+
// `Math.max(0, EDIT_PROG.indexOf(prog))` (cli.tsx), quindi un valore assente
|
|
64
|
+
// torna -1, viene schiacciato a 0 e salvare retrocede la task alla prima voce.
|
|
61
65
|
export const EDIT_PRI = ['high', 'med', 'low'];
|
|
62
|
-
export const EDIT_PROG = ['todo', 'wip', 'done', 'locked'];
|
|
66
|
+
export const EDIT_PROG = ['todo', 'ready', 'wip', 'done', 'locked'];
|
|
63
67
|
// T117 — le righe del modale edit come DATO, nella forma che `fields.ts`
|
|
64
68
|
// consuma: 0 priorità · 1 stato · 2 progresso libero · 3 titolo. Il modale non
|
|
65
69
|
// dà lettere di selezione alle due righe a scelta, dove i valori sono glifi e
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// T121 — il PROJECT STATUS del deck: generazione headless, cache su disco,
|
|
2
|
+
// indicatore in testata e viewer fullscreen.
|
|
3
|
+
//
|
|
4
|
+
// Non è solo un overlay come `sheet` o `search`: lo stato che tiene (cosa c'è
|
|
5
|
+
// in cache, se una generazione è in corso, com'è andato l'ultimo tentativo) è
|
|
6
|
+
// visibile in testata a schermata chiusa. Il viewer è una delle sue superfici,
|
|
7
|
+
// non il suo contenuto.
|
|
8
|
+
//
|
|
9
|
+
// Le due attivazioni sono DISTINTE per costruzione (D2): generare costa minuti
|
|
10
|
+
// di sessione Claude, aprire costa una lettura di file — legarle allo stesso
|
|
11
|
+
// tasto renderebbe il gesto economico un effetto collaterale di quello caro.
|
|
12
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { statusCapacity } from '../viewport.js';
|
|
15
|
+
import { cut, wrapWithOffsets } from '../width.js';
|
|
16
|
+
import { parseMarkdown } from '../markdown.js';
|
|
17
|
+
import { CLAUDE_CMD, notifyDone, spawnProjectStatus } from '../spawn.js';
|
|
18
|
+
import { readStatusCache, statusFilePath, statusLabel, writeStatusCache, } from '../project-status.js';
|
|
19
|
+
export function useProjectStatus(deps) {
|
|
20
|
+
const { cwd, name, rows, columns, setMode, setNote } = deps;
|
|
21
|
+
const path = useMemo(() => statusFilePath(cwd), [cwd]);
|
|
22
|
+
// Letta al mount e basta: è ciò che fa ripartire un deck riaperto dall'ora di
|
|
23
|
+
// prima invece che da `missing`.
|
|
24
|
+
const [cache, setCache] = useState(() => readStatusCache(path));
|
|
25
|
+
const [startedAt, setStartedAt] = useState(null);
|
|
26
|
+
const [failed, setFailed] = useState(false);
|
|
27
|
+
const [now, setNow] = useState(() => Date.now());
|
|
28
|
+
const [view, setView] = useState(null);
|
|
29
|
+
const [top, setTop] = useState(0);
|
|
30
|
+
// Il contatore dei secondi ha un tick PROPRIO, acceso solo mentre serve. Il
|
|
31
|
+
// poll del deck è a 1500ms: appeso a quello l'elapsed avanzerebbe a scatti
|
|
32
|
+
// irregolari. Un intervallo permanente, all'opposto, ridisegnerebbe il deck
|
|
33
|
+
// ogni secondo per sempre — costo invisibile in sviluppo, non su una macchina
|
|
34
|
+
// carica.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (startedAt === null)
|
|
37
|
+
return;
|
|
38
|
+
setNow(Date.now());
|
|
39
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
40
|
+
return () => clearInterval(id);
|
|
41
|
+
}, [startedAt]);
|
|
42
|
+
const label = statusLabel({ mtime: cache?.mtime ?? null, startedAt, now, failed });
|
|
43
|
+
function finish(ok, detail) {
|
|
44
|
+
setStartedAt(null);
|
|
45
|
+
const text = detail.trim();
|
|
46
|
+
if (!ok || !text) {
|
|
47
|
+
// Nessuna riscrittura della cache: il recap precedente resta leggibile e
|
|
48
|
+
// `^O` continua ad aprirlo. Il marker in testata è l'unica traccia che
|
|
49
|
+
// sopravvive alla riga di stato, che sparisce al primo tasto premuto.
|
|
50
|
+
setFailed(true);
|
|
51
|
+
setNote(`⚠ project status fallito · ${cut(detail || `${CLAUDE_CMD} -p`, 56)}`);
|
|
52
|
+
notifyDone('loom-deck · project status', `${name}: generazione fallita`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
writeStatusCache(path, text);
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
setFailed(true);
|
|
60
|
+
setNote(`⚠ project status: cache non scrivibile (${e.message})`);
|
|
61
|
+
notifyDone('loom-deck · project status', `${name}: cache non scrivibile`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Ri-letta da disco invece di comporre `{text, mtime: Date.now()}`: l'ora
|
|
65
|
+
// che l'indicatore mostra deve essere quella del file, o testata e viewer
|
|
66
|
+
// finirebbero a dire due orari diversi dello stesso recap.
|
|
67
|
+
setCache(readStatusCache(path));
|
|
68
|
+
setFailed(false);
|
|
69
|
+
setNote('✔ project status aggiornato · ^O per aprirlo');
|
|
70
|
+
notifyDone('loom-deck · project status', `${name}: recap pronto`);
|
|
71
|
+
}
|
|
72
|
+
function generate() {
|
|
73
|
+
if (startedAt !== null) {
|
|
74
|
+
setNote(`^G → generazione già in corso (${label})`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const sid = randomUUID();
|
|
78
|
+
setFailed(false);
|
|
79
|
+
setStartedAt(Date.now());
|
|
80
|
+
setNote(`⏳ project status di ${name}… (sid ${sid.slice(0, 8)})`);
|
|
81
|
+
const child = spawnProjectStatus(cwd, sid, finish);
|
|
82
|
+
child.on('error', () => {
|
|
83
|
+
setStartedAt(null);
|
|
84
|
+
setFailed(true);
|
|
85
|
+
setNote(`⚠ project status: '${CLAUDE_CMD}' non lanciabile`);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function open() {
|
|
89
|
+
if (!cache) {
|
|
90
|
+
// Un viewer vuoto direbbe «il recap è questo, ed è nulla»: la cache
|
|
91
|
+
// assente è uno stato del progetto, non un documento da mostrare.
|
|
92
|
+
setNote(startedAt !== null
|
|
93
|
+
? '^O → nessun recap in cache · la prima generazione è in corso'
|
|
94
|
+
: '^O → nessun recap in cache · ^G per generarlo');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
setView({ text: cache.text, mtime: cache.mtime, stale: startedAt !== null });
|
|
98
|
+
setTop(0);
|
|
99
|
+
setNote('');
|
|
100
|
+
setMode('status');
|
|
101
|
+
}
|
|
102
|
+
function close() {
|
|
103
|
+
setMode('normal');
|
|
104
|
+
setView(null);
|
|
105
|
+
}
|
|
106
|
+
// Cornici da scalare: box esterno (2 bordi + 2 padding) + box testo (2 bordi
|
|
107
|
+
// + 2 padding) = 8, come il detail.
|
|
108
|
+
const width = Math.max(20, (columns || 80) - 8);
|
|
109
|
+
// Il markdown si rende PRIMA del wrap: `**foo**` occupa 3 colonne rese e 7
|
|
110
|
+
// grezze, quindi wrappare sui marker manderebbe a capo su un conteggio che il
|
|
111
|
+
// terminale non disegna.
|
|
112
|
+
const doc = useMemo(() => (view ? parseMarkdown(view.text) : null), [view]);
|
|
113
|
+
const lines = useMemo(() => (doc ? wrapWithOffsets(doc.text, width) : []), [doc, width]);
|
|
114
|
+
const capacity = statusCapacity(rows);
|
|
115
|
+
const maxTop = Math.max(0, lines.length - capacity);
|
|
116
|
+
function scroll(delta) {
|
|
117
|
+
setTop((t) => Math.max(0, Math.min(maxTop, t + delta)));
|
|
118
|
+
}
|
|
119
|
+
function onKey(input, key) {
|
|
120
|
+
if (key.escape) {
|
|
121
|
+
close();
|
|
122
|
+
}
|
|
123
|
+
else if (key.upArrow) {
|
|
124
|
+
scroll(-1);
|
|
125
|
+
}
|
|
126
|
+
else if (key.downArrow) {
|
|
127
|
+
scroll(1);
|
|
128
|
+
}
|
|
129
|
+
else if (key.pageUp) {
|
|
130
|
+
scroll(-capacity);
|
|
131
|
+
}
|
|
132
|
+
else if (key.pageDown) {
|
|
133
|
+
scroll(capacity);
|
|
134
|
+
}
|
|
135
|
+
else if (input === 'g') {
|
|
136
|
+
// `g`/`G` sugli estremi, come nel reader e nel detail: anche questa
|
|
137
|
+
// schermata scorre testo grezzo, quindi eredita lo stesso alfabeto invece
|
|
138
|
+
// di inventarne un secondo.
|
|
139
|
+
setTop(0);
|
|
140
|
+
}
|
|
141
|
+
else if (input === 'G') {
|
|
142
|
+
setTop(maxTop);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
label,
|
|
147
|
+
building: startedAt !== null,
|
|
148
|
+
failed,
|
|
149
|
+
view,
|
|
150
|
+
doc,
|
|
151
|
+
lines,
|
|
152
|
+
top,
|
|
153
|
+
capacity,
|
|
154
|
+
maxTop,
|
|
155
|
+
generate,
|
|
156
|
+
open,
|
|
157
|
+
onKey,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// T121 — il recap di progetto come ARTEFATTO invece che come conversazione.
|
|
2
|
+
//
|
|
3
|
+
// La skill `loom-works:recap-status-project` produce un testo che oggi vive
|
|
4
|
+
// solo dentro la sessione che l'ha chiesta: il deck la invoca in headless e ne
|
|
5
|
+
// scrive il risultato su disco, così un deck riaperto lo ritrova invece di
|
|
6
|
+
// ripartire da zero.
|
|
7
|
+
//
|
|
8
|
+
// Logica pura + accesso al file, zero React: gli stati dell'indicatore si
|
|
9
|
+
// collaudano senza pseudo-terminale.
|
|
10
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { fmtTime, WARN } from './glyphs.js';
|
|
14
|
+
/**
|
|
15
|
+
* Cartella della cache, una per UTENTE.
|
|
16
|
+
*
|
|
17
|
+
* `/tmp` è condivisa e world-writable col solo sticky bit: un file a nome
|
|
18
|
+
* prevedibile lì dentro può essere preceduto da un symlink piazzato da un altro
|
|
19
|
+
* utente, e la scrittura lo seguirebbe. La cartella con `mode 0700` chiude il
|
|
20
|
+
* caso senza rinunciare a `/tmp` (D7), e l'uid è stabile quanto il path del
|
|
21
|
+
* progetto — la coordinata resta una funzione, non un registro.
|
|
22
|
+
*/
|
|
23
|
+
export function statusDir() {
|
|
24
|
+
return join(tmpdir(), `loom-deck-status-${process.getuid?.() ?? 0}`);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Coordinata del file di cache: derivata dal path del progetto con la stessa
|
|
28
|
+
* trasformazione del transcript store di Claude Code (non-alnum → `-`).
|
|
29
|
+
*
|
|
30
|
+
* L'env override esiste per i test e per la verifica a mano: senza, provare il
|
|
31
|
+
* viewer richiederebbe di spendere una generazione vera da minuti.
|
|
32
|
+
*/
|
|
33
|
+
export function statusFilePath(projectRoot) {
|
|
34
|
+
const env = process.env.LOOM_DECK_STATUS_FILE;
|
|
35
|
+
if (env)
|
|
36
|
+
return env;
|
|
37
|
+
return join(statusDir(), `${projectRoot.replace(/[^a-zA-Z0-9]/g, '-')}.md`);
|
|
38
|
+
}
|
|
39
|
+
/** File assente o illeggibile → nessuna cache. Mai un throw: `missing` non è
|
|
40
|
+
* un errore, è lo stato di partenza di ogni progetto. */
|
|
41
|
+
export function readStatusCache(path) {
|
|
42
|
+
try {
|
|
43
|
+
const text = readFileSync(path, 'utf8');
|
|
44
|
+
return { text, mtime: statSync(path).mtimeMs };
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function writeStatusCache(path, text) {
|
|
51
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
52
|
+
writeFileSync(path, text, { mode: 0o600 });
|
|
53
|
+
}
|
|
54
|
+
export const STATUS_MISSING = 'missing';
|
|
55
|
+
/**
|
|
56
|
+
* Il campo fra parentesi della testata, nei suoi tre stati.
|
|
57
|
+
*
|
|
58
|
+
* Il marker di fallimento è un'AGGIUNTA e non un quarto stato (D5): un tentativo
|
|
59
|
+
* fallito non riscrive la cache, quindi il dato precedente resta vero e ancora
|
|
60
|
+
* apribile — sostituirlo con `failed` butterebbe via un'informazione che c'è.
|
|
61
|
+
* I due fatti (cosa c'è in cache, com'è andato l'ultimo tentativo) sono sempre
|
|
62
|
+
* entrambi veri nello stesso istante.
|
|
63
|
+
*/
|
|
64
|
+
export function statusLabel(s) {
|
|
65
|
+
const base = s.startedAt !== null
|
|
66
|
+
? `building ${Math.max(0, Math.floor((s.now - s.startedAt) / 1000))}...`
|
|
67
|
+
: s.mtime !== null
|
|
68
|
+
? fmtTime(s.mtime)
|
|
69
|
+
: STATUS_MISSING;
|
|
70
|
+
return s.failed ? `${base} ${WARN}` : base;
|
|
71
|
+
}
|
package/dist/spawn.js
CHANGED
|
@@ -323,8 +323,17 @@ export const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
|
|
|
323
323
|
// un fallimento arriva come booleano nudo e la riga di stato può solo dire "non
|
|
324
324
|
// ha funzionato" — che su un'operazione distruttiva non basta a sapere se
|
|
325
325
|
// qualcosa è stato rimosso.
|
|
326
|
-
function spawnSkill(prompt, cwd, sessionId, onResult) {
|
|
327
|
-
const child = spawnOut(CLAUDE_CMD, [
|
|
326
|
+
function spawnSkill(prompt, cwd, sessionId, onResult, extraArgs = []) {
|
|
327
|
+
const child = spawnOut(CLAUDE_CMD, [
|
|
328
|
+
'-p',
|
|
329
|
+
'--output-format',
|
|
330
|
+
'stream-json',
|
|
331
|
+
'--verbose',
|
|
332
|
+
'--session-id',
|
|
333
|
+
sessionId,
|
|
334
|
+
...extraArgs,
|
|
335
|
+
prompt,
|
|
336
|
+
], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
328
337
|
let buf = '';
|
|
329
338
|
let isError = null;
|
|
330
339
|
let detail = '';
|
|
@@ -399,6 +408,43 @@ export function spawnCleanTasks(ids, cwd, sessionId, ignored, onResult) {
|
|
|
399
408
|
return null;
|
|
400
409
|
return spawnSkill(cleanTasksPrompt(ids, ignored), cwd, sessionId, onResult);
|
|
401
410
|
}
|
|
411
|
+
// T121 — recap di progetto in headless. Qui il `detail` del result event NON è
|
|
412
|
+
// un messaggio d'errore da troncare come per `create-task`/`clean-tasks`: è il
|
|
413
|
+
// DELIVERABLE, cioè il testo intero della skill, e chi chiama lo scrive su
|
|
414
|
+
// disco. Una skill read-only invocata in `-p` è una funzione che ritorna testo.
|
|
415
|
+
//
|
|
416
|
+
// `--permission-mode auto` e nessun token `yolo` (D4): le due cautele coprono
|
|
417
|
+
// cause di stallo diverse e non sono intercambiabili. `yolo` toglie le domande
|
|
418
|
+
// che la skill pone da sé, e `recap-status-project` non ne ha; il modo
|
|
419
|
+
// permissivo toglie le richieste di permesso che il CLI pone su un tool, e
|
|
420
|
+
// quelle in `-p` non hanno nessuno che risponda — il processo resterebbe appeso
|
|
421
|
+
// senza emettere niente, cioè con l'indicatore che conta all'infinito.
|
|
422
|
+
export function spawnProjectStatus(cwd, sessionId, onResult) {
|
|
423
|
+
return spawnSkill('/loom-works:recap-status-project', cwd, sessionId, onResult, [
|
|
424
|
+
'--permission-mode',
|
|
425
|
+
'auto',
|
|
426
|
+
]);
|
|
427
|
+
}
|
|
428
|
+
// T121 — notifica desktop di fine generazione. La generazione dura minuti e chi
|
|
429
|
+
// l'ha chiesta nel frattempo guarda altrove; la notifica di GNOME persiste nel
|
|
430
|
+
// cassetto, quindi raggiunge anche chi torna dieci minuti dopo — cosa che un
|
|
431
|
+
// suono non fa.
|
|
432
|
+
//
|
|
433
|
+
// Il listener su `error` è OBBLIGATORIO e non difensivo: un `error` non
|
|
434
|
+
// agganciato su un `ChildProcess` diventa un'eccezione non catturata e abbatte
|
|
435
|
+
// il deck, quindi senza di lui l'assenza di `notify-send` ucciderebbe la TUI.
|
|
436
|
+
// Agganciato, la degradazione è MUTA (D6): un binario assente è una proprietà
|
|
437
|
+
// stabile della macchina, e dirlo a ogni generazione sarebbe lo stesso messaggio
|
|
438
|
+
// a ogni giro su una funzione che non è il deliverable.
|
|
439
|
+
export function notifyDone(title, body) {
|
|
440
|
+
const child = spawnOut('notify-send', ['--app-name=loom-deck', title, body], {
|
|
441
|
+
detached: true,
|
|
442
|
+
stdio: 'ignore',
|
|
443
|
+
});
|
|
444
|
+
child.on('error', () => { });
|
|
445
|
+
child.unref?.();
|
|
446
|
+
return child;
|
|
447
|
+
}
|
|
402
448
|
// T41 — Commit dell'edit. `git commit -- <paths>` committa lo stato working-tree
|
|
403
449
|
// SOLO di quei path, ignorando l'index: se l'utente ha altro in stage (o altri
|
|
404
450
|
// file sporchi) non finisce dentro per errore. NON detached: è un'operazione
|
package/dist/task-edit.js
CHANGED
|
@@ -14,6 +14,7 @@ export const PRI_GLYPH = { high: '🔥', med: '⚡', low: '🔹' };
|
|
|
14
14
|
// lunga resta riconosciuto da priName/progName/isDone.
|
|
15
15
|
export const PROG_GLYPH = {
|
|
16
16
|
todo: '🔵',
|
|
17
|
+
ready: '🟢',
|
|
17
18
|
wip: '🟡',
|
|
18
19
|
done: '✔️',
|
|
19
20
|
locked: '🔒',
|
|
@@ -23,6 +24,7 @@ export const PROG_GLYPH = {
|
|
|
23
24
|
// `🔵 Todo`, `✔️ Done at 2026-07-20`) → il glifo è il prefisso, il resto è prosa.
|
|
24
25
|
const PROG_DEFAULT = {
|
|
25
26
|
todo: 'Todo',
|
|
27
|
+
ready: 'Ready',
|
|
26
28
|
wip: 'In Progress',
|
|
27
29
|
done: 'Done',
|
|
28
30
|
locked: 'Locked',
|
package/dist/ui/assign-screen.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// Schermata di assegnazione conversazione → task (T57).
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
|
-
import { cut, sanitize
|
|
5
|
-
import { assignTextWidth
|
|
6
|
-
import { CARET, CARET_OFF
|
|
4
|
+
import { cut, sanitize } from '../width.js';
|
|
5
|
+
import { assignTextWidth } from '../layout.js';
|
|
6
|
+
import { CARET, CARET_OFF } from '../glyphs.js';
|
|
7
|
+
import { TaskRow } from './task-row.js';
|
|
7
8
|
/**
|
|
8
9
|
* Schermata di assegnazione di una conversazione a una task (T57).
|
|
9
10
|
*
|
|
@@ -16,7 +17,7 @@ import { CARET, CARET_OFF, displayProg } from '../glyphs.js';
|
|
|
16
17
|
* filtro può nascondere proprio il bersaglio, e quel prezzo non deve essere
|
|
17
18
|
* silenzioso — stessa convenzione del `+N più vecchie` del pane sessioni.
|
|
18
19
|
*/
|
|
19
|
-
export function AssignScreen({ sessionId, label, current, filter, rows, selected, matched, hidden, above, below,
|
|
20
|
+
export function AssignScreen({ sessionId, label, current, filter, rows, selected, matched, hidden, above, below, idW, tailW, data, columns, note, }) {
|
|
20
21
|
const width = assignTextWidth(columns);
|
|
21
22
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["assegna ", _jsx(Text, { color: "cyan", children: sessionId.slice(0, 8) }), label ? ` «${cut(sanitize(label), Math.max(10, Math.floor(width / 4)))}»` : '', " \u00B7 ora", ' ', current ? _jsx(Text, { color: "green", children: current }) : 'spot', " \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), ' ', "assegna \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " pulisci \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }), _jsx(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "filtro " }), _jsx(Text, { color: "yellow", children: cut(filter, Math.max(8, width - 24)) }), _jsx(Text, { inverse: true, children: " " }), !filter ? _jsx(Text, { dimColor: true, children: " (id o titolo)" }) : null] }) }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [matched, " task", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", hidden, " fuori dai filtri"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), rows.map((task) => {
|
|
22
23
|
// T57/D2 — `detach` è una VOCE della lista, non un tasto a parte: un
|
|
@@ -30,11 +31,11 @@ export function AssignScreen({ sessionId, label, current, filter, rows, selected
|
|
|
30
31
|
// il nome dell'azione invece di un moncone di frase.
|
|
31
32
|
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, "\u25CB detach ", cut('la sessione torna spot', width - 12)] }, "detach"));
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return (
|
|
34
|
+
// T124 — la riga è la STESSA del pane task, non più una copia
|
|
35
|
+
// ricomposta qui: id paddato alla colonna, coda ancorata al bordo
|
|
36
|
+
// destro, marker dirty ed evidenze della liveness arrivano tutti dal
|
|
37
|
+
// componente condiviso. `focused` è sempre vero — una schermata
|
|
38
|
+
// sostitutiva non cede il fuoco a nessun altro pane.
|
|
39
|
+
return (_jsx(TaskRow, { task: task, sel: selected === task.id, focused: true, width: width, idW: idW, tailW: tailW, data: data }, task.id));
|
|
39
40
|
})] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
40
41
|
}
|
package/dist/ui/panes.js
CHANGED
|
@@ -4,12 +4,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
4
4
|
// larghezze già calcolate, non li derivano.
|
|
5
5
|
import { Box, Text } from 'ink';
|
|
6
6
|
import { cut, cutParts, pad, sanitize, termWidth } from '../width.js';
|
|
7
|
-
import {
|
|
8
|
-
import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, MODEL_W, SESSION_SEP, SID_CHARS, TASK_EMPTY, WARN,
|
|
7
|
+
import { paneTextWidth } from '../layout.js';
|
|
8
|
+
import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, MODEL_W, SESSION_SEP, SID_CHARS, TASK_EMPTY, WARN, metaCount, modelShort, relTime, } from '../glyphs.js';
|
|
9
|
+
import { TaskRow } from './task-row.js';
|
|
9
10
|
import { META_ROWS, ROW_ALL, ROW_SPOT } from '../model.js';
|
|
10
11
|
import { rowLabel, sessionTitle } from '../session-list.js';
|
|
11
12
|
import { sessionView, taskView, SESSION_VIEWS, TASK_VIEWS, } from '../pane-views.js';
|
|
12
|
-
import { describeSort,
|
|
13
|
+
import { describeSort, PRI_ENTRIES, PROG_ENTRIES, } from '../view.js';
|
|
13
14
|
/**
|
|
14
15
|
* Header del pane task, tagliato QUI e non da Ink — stesso motivo del gemello
|
|
15
16
|
* `SessionsHeader`, con una differenza di rischio: qui i segmenti sono tutti
|
|
@@ -49,7 +50,7 @@ export function TasksHeader({ counts, active, above, below, focused, columns, })
|
|
|
49
50
|
const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
|
|
50
51
|
return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, inverse: seg.active, children: shown[i] }, i)) : null) }));
|
|
51
52
|
}
|
|
52
|
-
export function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount,
|
|
53
|
+
export function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, idW, tailW, focused, loadError, windowStart, above, below, columns, data, }) {
|
|
53
54
|
const allSelected = selected === ROW_ALL;
|
|
54
55
|
const spotSelected = selected === ROW_SPOT;
|
|
55
56
|
return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(TasksHeader, { counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut(sanitize(`sort: ${describeSort(view.sort)}` +
|
|
@@ -61,42 +62,16 @@ export function TasksPane({ tasks, counts, activeView, paneCount, view, selected
|
|
|
61
62
|
]
|
|
62
63
|
.map((e) => `−${e.glyph}`)
|
|
63
64
|
.join(' ')
|
|
64
|
-
: '')), paneTextWidth(columns)) }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni",
|
|
65
|
+
: '')), paneTextWidth(columns)) }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni", metaCount(allCount)] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", metaCount(spotCount)] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : paneCount === 0 ? (
|
|
65
66
|
// T100/D1 — una voce a contatore 0 resta navigabile, e selezionarla dà
|
|
66
67
|
// una lista vuota che DICE perché è vuota. Senza la nota il pane si
|
|
67
68
|
// legge come rotto: le righe meta restano, le task no, e niente spiega
|
|
68
69
|
// che è la vista scelta a non contenere nulla.
|
|
69
|
-
_jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(taskView(activeView).empty, paneTextWidth(columns)) })) : (tasks.map((task, i) => {
|
|
70
|
+
_jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(taskView(activeView).empty, paneTextWidth(columns)) })) : (tasks.map((task, i) => (_jsx(TaskRow, { task: task,
|
|
70
71
|
// windowStart riporta l'indice di finestra a quello della lista
|
|
71
72
|
// completa, su cui è keyata la selezione. +META_ROWS: le prime due
|
|
72
73
|
// righe sono le meta.
|
|
73
|
-
|
|
74
|
-
const tail = taskTail(childCount.get(task.id) ?? 0, dirty.has(task.id));
|
|
75
|
-
// T118 — l'id è paddato a `idW`, quindi `head` ha la stessa larghezza
|
|
76
|
-
// su ogni riga e Pri/Prog cadono sempre nella stessa colonna.
|
|
77
|
-
const id = padId(task.id, idW);
|
|
78
|
-
// Invariante ③: la descrizione è l'unico pezzo a lunghezza libera, e
|
|
79
|
-
// si taglia QUI sul budget che resta dopo le colonne fisse. Lasciarlo
|
|
80
|
-
// fare a `truncate-end` significa passare da `cli-truncate`, che
|
|
81
|
-
// restituisce una riga più larga del pane (una colonna per emoji) e
|
|
82
|
-
// quindi scrive sopra il bordo. Le parti fisse si misurano con
|
|
83
|
-
// `termWidth`: i due glifi Pri/Prog valgono 2 ciascuno.
|
|
84
|
-
const head = `${CARET_OFF}${id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
|
|
85
|
-
// T118 — la colonna della coda si riserva PRIMA di tagliare la
|
|
86
|
-
// descrizione, e per la stessa larghezza su ogni riga: appesa dopo,
|
|
87
|
-
// entrava nel budget solo dove c'era qualcosa da scrivere, e la
|
|
88
|
-
// descrizione si tagliava a una colonna diversa riga per riga.
|
|
89
|
-
//
|
|
90
|
-
// Pavimento `0` su tutte e tre le misure e non un minimo di cortesia:
|
|
91
|
-
// il budget è un TETTO. Un pavimento sopra lo spazio reale fa uscire
|
|
92
|
-
// la riga dal pane e le mangia il bordo. `reserve` si clampa su ciò
|
|
93
|
-
// che avanza, così `head + desc + coda` sta sempre dentro il pane.
|
|
94
|
-
const avail = Math.max(0, paneTextWidth(columns) - termWidth(head));
|
|
95
|
-
const reserve = Math.min(tailW, avail);
|
|
96
|
-
const descW = avail - reserve;
|
|
97
|
-
const desc = cut(task.desc, descW);
|
|
98
|
-
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, ' '.repeat(Math.max(0, descW - termWidth(desc))), pad(tail, reserve, 'right')] }, task.id));
|
|
99
|
-
}))] }));
|
|
74
|
+
sel: windowStart + i + META_ROWS === selected, focused: focused, width: paneTextWidth(columns), idW: idW, tailW: tailW, data: data }, task.id))))] }));
|
|
100
75
|
}
|
|
101
76
|
/**
|
|
102
77
|
* Header del pane sessioni, tagliato QUI e non da Ink.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// T121 — project status: l'indicatore di testata e il viewer fullscreen.
|
|
3
|
+
//
|
|
4
|
+
// I due stanno nello stesso file perché sono due rese dello STESSO dato — cosa
|
|
5
|
+
// c'è in cache e come sta andando l'ultima generazione — e la testata compare
|
|
6
|
+
// anche dentro il viewer.
|
|
7
|
+
import { Box, Text } from 'ink';
|
|
8
|
+
import { cutParts } from '../width.js';
|
|
9
|
+
import { sanitize } from '../width.js';
|
|
10
|
+
import { LIVE_BUSY } from '../glyphs.js';
|
|
11
|
+
import { fmtTime } from '../glyphs.js';
|
|
12
|
+
import { DetailLine } from './detail-screen.js';
|
|
13
|
+
/** Nessuna occorrenza da evidenziare: il viewer non ha ricerca interna. Costanti
|
|
14
|
+
* di modulo e non letterali inline — un array nuovo a ogni riga renderizzata
|
|
15
|
+
* farebbe lavoro per niente su un recap di duecento righe. */
|
|
16
|
+
const NO_OCC = [];
|
|
17
|
+
/**
|
|
18
|
+
* L'indicatore: `<progetto> :: PROJECT STATUS [ <stato> ]`.
|
|
19
|
+
*
|
|
20
|
+
* Occupa la posizione SINISTRA della testata, quella che era di `loom-deck`
|
|
21
|
+
* (D1): il posto più visibile della cornice va a un dato che cambia, non a
|
|
22
|
+
* un'etichetta costante che chi guarda il deck sa già.
|
|
23
|
+
*
|
|
24
|
+
* Il taglio lo fa `cutParts` e non `wrap` di Ink (invariante ③ di `width.ts`),
|
|
25
|
+
* con `priority` sul blocco fra parentesi: su un terminale stretto a cadere è
|
|
26
|
+
* il nome del progetto — che chi guarda quel deck conosce — mai lo stato, che è
|
|
27
|
+
* l'unica cosa che la riga esiste per dire.
|
|
28
|
+
*/
|
|
29
|
+
export function StatusHeadline({ name, label, building, failed, cols, }) {
|
|
30
|
+
// `name` arriva dal file config, cioè da testo arbitrario: un glifo discorde
|
|
31
|
+
// allargherebbe la riga di una cella e manderebbe a capo la testata. Si
|
|
32
|
+
// sanifica QUI e non in `loadIdentity`, perché lo stesso valore serve intatto
|
|
33
|
+
// ai titoli delle tab, dove è la chiave di match di compass.
|
|
34
|
+
const shown = sanitize(name);
|
|
35
|
+
// Il glifo di conversazione viva sta DENTRO le parentesi, attaccato allo
|
|
36
|
+
// stato: qualifica quel campo, e il vocabolario è lo stesso della colonna
|
|
37
|
+
// liveness delle sessioni — una generazione in corso è una conversazione che
|
|
38
|
+
// sta lavorando, non un quinto simbolo da imparare.
|
|
39
|
+
const parts = [shown, ' :: PROJECT STATUS ', `[ ${building ? `${LIVE_BUSY} ` : ''}${label} ]`];
|
|
40
|
+
const shownParts = cutParts(parts, Math.max(4, cols), 2);
|
|
41
|
+
const color = building ? 'yellow' : failed ? 'yellow' : label === 'missing' ? 'gray' : 'green';
|
|
42
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: shownParts[0] }), _jsx(Text, { dimColor: true, children: shownParts[1] }), _jsx(Text, { bold: building, color: color, children: shownParts[2] })] }));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Viewer fullscreen del recap: quinta schermata sostitutiva.
|
|
46
|
+
*
|
|
47
|
+
* Stessa forma del detail della task — markdown reso in span tipizzati, righe
|
|
48
|
+
* wrappate col proprio offset — meno l'area di compilazione: qui non c'è niente
|
|
49
|
+
* da lanciare, si legge e basta. Da cui `↑↓` liberi di scorrere il testo, che
|
|
50
|
+
* nel detail sono spesi sul fuoco fra i campi.
|
|
51
|
+
*/
|
|
52
|
+
export function StatusScreen({ name, label, building, failed, view, lines, spans, top, total, capacity, columns, }) {
|
|
53
|
+
const last = Math.min(total, top + capacity);
|
|
54
|
+
const width = Math.max(20, (columns || 80) - 4);
|
|
55
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(StatusHeadline, { name: name, label: label, building: building, failed: failed, cols: width }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["generato alle ", _jsx(Text, { color: "cyan", children: fmtTime(view.mtime) }), " \u00B7 righe", ' ', total === 0 ? 0 : top + 1, "-", last, " di ", total, view.stale ? (_jsx(Text, { color: "yellow", children: " \u00B7 versione precedente, generazione in corso" })) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiude"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: lines.map((l, i) => (_jsx(DetailLine, { line: l, spans: spans, occ: NO_OCC, current: -1 }, top + i))) })] }));
|
|
56
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// La riga di lista di UNA task, condivisa fra il pane task e la schermata di
|
|
3
|
+
// assegnazione (T124).
|
|
4
|
+
//
|
|
5
|
+
// Prima viveva solo nel pane, e la schermata di assegnazione ne teneva una copia
|
|
6
|
+
// ricomposta a mano: id non paddato alla colonna, coda non ancorata al bordo,
|
|
7
|
+
// marker dirty assente, contatore scritto in proprio. Nessuna di quelle
|
|
8
|
+
// differenze produceva un errore — la schermata rendeva righe valide, solo
|
|
9
|
+
// diverse — quindi nessun test le vedeva e ogni evoluzione della lista si
|
|
10
|
+
// fermava al suo confine. Un componente solo è ciò che toglie il gemello.
|
|
11
|
+
//
|
|
12
|
+
// Le due sedi differiscono per tre cose sole, tutte parametri: la larghezza
|
|
13
|
+
// utile, il concetto di fuoco (la schermata di assegnazione è sempre a fuoco) e
|
|
14
|
+
// la finestra di righe che il chiamante ha già ritagliato.
|
|
15
|
+
import { Text } from 'ink';
|
|
16
|
+
import { cut, pad, sanitize, termWidth } from '../width.js';
|
|
17
|
+
import { isDone } from '../layout.js';
|
|
18
|
+
import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, displayProg, taskTail, } from '../glyphs.js';
|
|
19
|
+
import { padId } from '../view.js';
|
|
20
|
+
export function TaskRow({ task, sel, focused, width, idW, tailW, data, }) {
|
|
21
|
+
// T124 — il predicato è UNO e si valuta qui una volta sola: da lui dipendono
|
|
22
|
+
// tutte e tre le evidenze (id in grassetto+colore, glifo, numeratore in coda).
|
|
23
|
+
// Tre condizioni scritte in tre siti divergono alla prima modifica, e la riga
|
|
24
|
+
// finirebbe per dire due cose diverse su sé stessa.
|
|
25
|
+
const live = data.live.get(task.id) ?? null;
|
|
26
|
+
// Stessa resa dell'hash conversazione del pane sessioni, perché è lo stesso
|
|
27
|
+
// stato: `◍` giallo di là e `◍` nero di qua sarebbero due grafie di una cosa
|
|
28
|
+
// sola.
|
|
29
|
+
const color = live ? (live.status === 'busy' ? 'yellow' : 'green') : undefined;
|
|
30
|
+
// Attaccato all'id, non staccato: il glifo qualifica QUELL'id, e uno spazio in
|
|
31
|
+
// mezzo lo farebbe leggere come una colonna a sé. Consuma il primo dei due
|
|
32
|
+
// spazi verso Pri, quindi la riga non si allarga di una cella quando si
|
|
33
|
+
// accende — `LIVE_NONE` è uno spazio, e tutti e tre sono larghi 1.
|
|
34
|
+
const glyph = live ? (live.status === 'busy' ? LIVE_BUSY : LIVE_IDLE) : LIVE_NONE;
|
|
35
|
+
const tail = taskTail(live?.count ?? 0, data.childCount.get(task.id) ?? 0, data.dirty.has(task.id));
|
|
36
|
+
const id = padId(task.id, idW);
|
|
37
|
+
// Invariante ③: la descrizione è l'unico pezzo a lunghezza libera, e si taglia
|
|
38
|
+
// QUI sul budget che resta dopo le colonne fisse. Lasciarlo fare a
|
|
39
|
+
// `truncate-end` significa passare da `cli-truncate`, che restituisce una riga
|
|
40
|
+
// più larga del pane (una colonna per emoji) e quindi scrive sopra il bordo.
|
|
41
|
+
// Le parti fisse si misurano con `termWidth`: i due glifi Pri/Prog valgono 2
|
|
42
|
+
// ciascuno.
|
|
43
|
+
const head = `${CARET_OFF}${id}${glyph} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
|
|
44
|
+
// T118 — la colonna della coda si riserva PRIMA di tagliare la descrizione, e
|
|
45
|
+
// per la stessa larghezza su ogni riga: appesa dopo, entrava nel budget solo
|
|
46
|
+
// dove c'era qualcosa da scrivere, e la descrizione si tagliava a una colonna
|
|
47
|
+
// diversa riga per riga.
|
|
48
|
+
//
|
|
49
|
+
// Pavimento `0` su tutte e tre le misure e non un minimo di cortesia: il
|
|
50
|
+
// budget è un TETTO. Un pavimento sopra lo spazio reale fa uscire la riga dal
|
|
51
|
+
// pane e le mangia il bordo. `reserve` si clampa su ciò che avanza, così
|
|
52
|
+
// `head + desc + coda` sta sempre dentro la sede.
|
|
53
|
+
const avail = Math.max(0, width - termWidth(head));
|
|
54
|
+
const reserve = Math.min(tailW, avail);
|
|
55
|
+
const descW = avail - reserve;
|
|
56
|
+
const desc = cut(task.desc, descW);
|
|
57
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsxs(Text, { bold: Boolean(live), color: color, children: [id, glyph] }), ` ${sanitize(task.pri)} ${displayProg(task.prog)} ${desc}`, ' '.repeat(Math.max(0, descW - termWidth(desc))), pad(tail, reserve, 'right')] }));
|
|
58
|
+
}
|
package/dist/view.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// T39 — Core della vista: ordinali, sort chain multi-chiave, filtri.
|
|
2
2
|
// Modulo PURO: nessun import da ink/react, nessun I/O → testabile senza terminale.
|
|
3
|
+
import { taskTail } from './glyphs.js';
|
|
4
|
+
import { termWidth } from './width.js';
|
|
3
5
|
// D2 (preflight T39): sort opinato, filtri off. La lista parte ordinata ma
|
|
4
6
|
// completa — nessuna task sparisce senza che l'utente abbia toccato una leva.
|
|
5
7
|
export const DEFAULT_VIEW = {
|
|
@@ -19,10 +21,15 @@ const PRI_TABLE = [
|
|
|
19
21
|
{ name: 'low', glyph: '🔹', rank: 1 },
|
|
20
22
|
];
|
|
21
23
|
// Ordine per "attivabilità" (desc = prima ciò su cui puoi agire): in corso →
|
|
22
|
-
// da fare → bloccata → chiusa. Non è l'ordine del ciclo di
|
|
23
|
-
// a scegliere su cosa lavorare, quindi le Done stanno in
|
|
24
|
+
// preflight fatto → da fare → bloccata → chiusa. Non è l'ordine del ciclo di
|
|
25
|
+
// vita: il deck serve a scegliere su cosa lavorare, quindi le Done stanno in
|
|
26
|
+
// fondo sotto `desc`. `ready` sta sopra `todo` perché il design è già congelato
|
|
27
|
+
// (run-task parte senza Q&A), e sotto `wip` perché il lavoro già aperto viene
|
|
28
|
+
// prima. Il rango governa anche l'ordine delle colonne della barra SHIFT+F,
|
|
29
|
+
// derivata da PROG_ENTRIES.
|
|
24
30
|
const PROG_TABLE = [
|
|
25
|
-
{ name: 'wip', glyph: '🟡', rank:
|
|
31
|
+
{ name: 'wip', glyph: '🟡', rank: 5 },
|
|
32
|
+
{ name: 'ready', glyph: '🟢', rank: 4 },
|
|
26
33
|
{ name: 'todo', glyph: '🔵', rank: 3 },
|
|
27
34
|
{ name: 'locked', glyph: '🔒', rank: 2 },
|
|
28
35
|
{ name: 'done', glyph: '✔', rank: 1 },
|
|
@@ -90,6 +97,25 @@ export function padId(id, cols) {
|
|
|
90
97
|
return id.padEnd(cols);
|
|
91
98
|
return m[1] + ' '.repeat(Math.max(0, cols - id.length)) + m[2];
|
|
92
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* T118/T124 — le due colonne fisse della lista task, misurate sulla popolazione
|
|
102
|
+
* COMPLETA della vista e non sulla finestra visibile.
|
|
103
|
+
*
|
|
104
|
+
* `tail` porta dentro il proprio gutter (`+1`), così l'allineamento a destra lo
|
|
105
|
+
* produce `pad` da sé; nessuna riga con qualcosa da scrivere → `0`, colonna
|
|
106
|
+
* spenta, e le descrizioni si riprendono lo spazio.
|
|
107
|
+
*
|
|
108
|
+
* La stringa misurata qui è la STESSA che il render disegna (`taskTail`): due
|
|
109
|
+
* formattazioni gemelle divergerebbero alla prima modifica, e la colonna
|
|
110
|
+
* risulterebbe larga quanto una stringa che nessuno scrive.
|
|
111
|
+
*/
|
|
112
|
+
export function taskColumns(tasks, data) {
|
|
113
|
+
let tail = 0;
|
|
114
|
+
for (const t of tasks) {
|
|
115
|
+
tail = Math.max(tail, termWidth(taskTail(data.live.get(t.id)?.count ?? 0, data.childCount.get(t.id) ?? 0, data.dirty.has(t.id))));
|
|
116
|
+
}
|
|
117
|
+
return { id: idColumnWidth(tasks), tail: tail > 0 ? tail + 1 : 0 };
|
|
118
|
+
}
|
|
93
119
|
function rankOf(task, key) {
|
|
94
120
|
if (key === 'pri')
|
|
95
121
|
return priRank(task.pri);
|
package/dist/viewport.js
CHANGED
|
@@ -69,6 +69,10 @@ export const MODAL_HEIGHT = {
|
|
|
69
69
|
// entra in un box sopra i pane. La sua altezza la distribuisce
|
|
70
70
|
// `detailCapacity`.
|
|
71
71
|
detail: 0,
|
|
72
|
+
// T121 — il viewer del project status: quinta sostitutiva. Un recap di
|
|
73
|
+
// progetto è un documento lungo quanto un task file, quindi vale la stessa
|
|
74
|
+
// ragione delle altre quattro. La sua altezza la distribuisce `statusCapacity`.
|
|
75
|
+
status: 0,
|
|
72
76
|
};
|
|
73
77
|
/**
|
|
74
78
|
* Distribuisce le righe disponibili fra lista task, lista sessioni e preview.
|
|
@@ -254,6 +258,20 @@ export function detailCapacity(rows, searching = false) {
|
|
|
254
258
|
const extra = searching ? DETAIL_SEARCH_CHROME : 0;
|
|
255
259
|
return Math.max(0, (rows || 24) - SLACK - DETAIL_CHROME - extra);
|
|
256
260
|
}
|
|
261
|
+
// T121 — cornice del viewer del project status. Gemella di READER_CHROME: il
|
|
262
|
+
// viewer scorre testo e basta, senza l'area di compilazione che rende caro il
|
|
263
|
+
// detail.
|
|
264
|
+
// 2 bordi del box esterno
|
|
265
|
+
// 1 riga di testata (progetto :: PROJECT STATUS)
|
|
266
|
+
// 1 riga meta (ora della generazione · posizione nel testo)
|
|
267
|
+
// 1 riga hint
|
|
268
|
+
// 1 marginTop del box corpo
|
|
269
|
+
// 2 bordi del box corpo
|
|
270
|
+
const STATUS_CHROME = 8;
|
|
271
|
+
/** Righe di recap che entrano nel terminale. */
|
|
272
|
+
export function statusCapacity(rows) {
|
|
273
|
+
return Math.max(0, (rows || 24) - SLACK - STATUS_CHROME);
|
|
274
|
+
}
|
|
257
275
|
// T52 — cornice del pannello di anteprima sotto la lista occorrenze:
|
|
258
276
|
// marginTop + 2 bordi + riga meta.
|
|
259
277
|
const SEARCH_PREVIEW_CHROME = 4;
|
package/package.json
CHANGED