@lamemind/loom-deck 0.30.1 → 0.32.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 +40 -4
- package/dist/archivable.js +13 -7
- package/dist/cli.js +175 -61
- package/dist/pane-views.js +135 -0
- package/dist/session-list.js +5 -2
- package/dist/task-edit.js +33 -26
- package/dist/tasks.js +52 -20
- package/dist/view.js +3 -8
- package/dist/width.js +55 -4
- package/package.json +1 -1
- package/scripts/deck-run +5 -13
package/README.md
CHANGED
|
@@ -56,7 +56,8 @@ senza collisioni:
|
|
|
56
56
|
| Tasto | Semantica | Note |
|
|
57
57
|
|---|---|---|
|
|
58
58
|
| `↑` `↓` | naviga nella lista | |
|
|
59
|
-
|
|
|
59
|
+
| `tab` | cambia pane | l'unico tasto che sposta il focus |
|
|
60
|
+
| `←` `→` | cambia **vista** del pane a fuoco | ciclico; il catalogo è l'header stesso |
|
|
60
61
|
| `⏎` | azione primaria del pane | Tasks → apre il **detail** della task selezionata · Sessions → riprende (`claude --resume`) la sessione selezionata |
|
|
61
62
|
| **MAIUSCOLA** | **apre un modale** | cattura tutti i tasti; `esc` annulla, non esce |
|
|
62
63
|
| minuscola | azione immediata, one-shot | |
|
|
@@ -166,9 +167,10 @@ Sono bottoni affiancati e non voci di un menu verticale perché è la forma che
|
|
|
166
167
|
sopravvive all'arrivo del mouse: un rettangolo ha già coordinate e area
|
|
167
168
|
cliccabile, una lista di voci andrebbe rifatta.
|
|
168
169
|
|
|
169
|
-
Dentro il detail `←→`
|
|
170
|
-
chiude prima di arrivare a quello di default. Ink
|
|
171
|
-
**è** l'ordine dei rami
|
|
170
|
+
Dentro il detail `←→` cicla i bottoni invece di cambiare la vista del pane — il
|
|
171
|
+
ramo `useInput` della modalità chiude prima di arrivare a quello di default. Ink
|
|
172
|
+
non ha focus-trap: la cattura **è** l'ordine dei rami, e cambiare cosa fa un
|
|
173
|
+
tasto fuori dal detail non tocca cosa fa dentro.
|
|
172
174
|
|
|
173
175
|
Il catalogo dei prompt vive in `scripts/deck-run`, non nella TUI: è il primitive
|
|
174
176
|
UI-agnostico, il deck gli passa un **simbolo** (`--prompt-kind`) e non una
|
|
@@ -299,6 +301,40 @@ Con un filtro attivo l'header dichiara sempre quanto sta nascondendo
|
|
|
299
301
|
`w` la scrive in `.claude/loom/deck-view.json` (macchina-locale, da gitignorare)
|
|
300
302
|
e al riavvio viene ripristinata. File assente o corrotto → default puliti.
|
|
301
303
|
|
|
304
|
+
## L'header è un selettore di vista
|
|
305
|
+
|
|
306
|
+
Ogni segmento dell'header nomina un sottoinsieme che il deck sa già calcolare.
|
|
307
|
+
`←`/`→` lo rendono **raggiungibile**: la voce attiva si evidenzia in video
|
|
308
|
+
inverso e la lista sotto mostra quel sottoinsieme.
|
|
309
|
+
|
|
310
|
+
```
|
|
311
|
+
Tasks (20/78) · 58 nascoste · 23 archiviabili · ↑7 · ↓9
|
|
312
|
+
Sessions · tutte (317) · ●2 vive · 📌4 · +213 più vecchie · ↑7 · ↓9
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
| Pane | Viste |
|
|
316
|
+
|---|---|
|
|
317
|
+
| Tasks | `Tasks (n/N)` (filtri applicati) · `N nascoste` (il complemento esatto di quei filtri) · `N archiviabili` (Done oltre `archivableDays`, cieca ai filtri) |
|
|
318
|
+
| Sessions | `{parent} (N)` · `●N vive` · `📌N` · `+N più vecchie` (le contestuali che il cap `maxContext` tronca) |
|
|
319
|
+
|
|
320
|
+
- **Il catalogo è fisso**, anche a contatore 0 (la voce si mostra dim). Un
|
|
321
|
+
catalogo che si accorcia sposta le voci sotto le dita, e la vista corrente può
|
|
322
|
+
svanire mentre la guardi — togliere un filtro mentre sei su `nascoste`.
|
|
323
|
+
- **`↑N` `↓N` non sono viste**: contano elementi fuori finestra per altezza del
|
|
324
|
+
terminale, cioè una posizione, non un insieme. Restano in coda, non selezionabili.
|
|
325
|
+
- **Due assi ortogonali**: il pane task sceglie il *parent* delle conversazioni
|
|
326
|
+
(`≡ tutte` / `○ spot` / una task), l'header sceglie *quale sottoinsieme* di quel
|
|
327
|
+
parent. Cambiare parent non azzera la vista, che si ricalcola dentro il nuovo.
|
|
328
|
+
- **`F` è inerte fuori dalla vista `Tasks`** e lo dice con una nota: su
|
|
329
|
+
`nascoste` riapplicare i filtri non ha senso, `archiviabili` è cieca per scelta.
|
|
330
|
+
- **La vista è volatile**: non entra in `deck-view.json`, il deck riapre sempre
|
|
331
|
+
sulla voce 1. Un filtro salvato lo si è scelto; una vista non-default riaperta
|
|
332
|
+
a freddo si leggerebbe come la lista intera.
|
|
333
|
+
- **Cambiare vista riporta la selezione in cima**, righe meta comprese: nessuna
|
|
334
|
+
regola di mantenimento, nessun tentativo di ritrovare l'elemento precedente.
|
|
335
|
+
- Su un terminale stretto **la voce attiva è servita per prima** dal budget di
|
|
336
|
+
larghezza: sparisce un contatore, mai la voce che dice dove sei.
|
|
337
|
+
|
|
302
338
|
## Installazione
|
|
303
339
|
|
|
304
340
|
```bash
|
package/dist/archivable.js
CHANGED
|
@@ -106,17 +106,23 @@ async function gitOut(args, cwd) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
/**
|
|
109
|
-
*
|
|
109
|
+
* Quali fra le task Done passate sono oltre soglia. Le indeterminate (nessuno
|
|
110
110
|
* dei tre gradini risolve) e le righe orfane (Done in tasks.md ma task file
|
|
111
|
-
* assente) NON entrano
|
|
112
|
-
*
|
|
111
|
+
* assente) NON entrano: il deck non chiama "vecchia" una task di cui non sa
|
|
112
|
+
* l'età.
|
|
113
113
|
*
|
|
114
114
|
* Riceve gli id già filtrati sul glifo Done invece della lista completa: lo
|
|
115
115
|
* scan resta così proporzionale al vecchiume, non alla lunghezza della lista.
|
|
116
|
+
*
|
|
117
|
+
* T100 — ritorna gli ID e non più il solo conteggio: `archiviabili` è diventata
|
|
118
|
+
* una vista navigabile del pane task, e il numero da solo non basta più a
|
|
119
|
+
* disegnarne le righe. Il contatore dell'header è la lunghezza della lista, così
|
|
120
|
+
* ciò che si conta e ciò che si mostra restano lo stesso insieme per
|
|
121
|
+
* costruzione.
|
|
116
122
|
*/
|
|
117
|
-
export async function
|
|
123
|
+
export async function archivableIds(doneIds, opts) {
|
|
118
124
|
const now = opts.now ?? Date.now();
|
|
119
|
-
|
|
125
|
+
const out = [];
|
|
120
126
|
for (const id of doneIds) {
|
|
121
127
|
const taskFile = findTaskFile(opts.tasksDir, id);
|
|
122
128
|
if (!taskFile)
|
|
@@ -133,7 +139,7 @@ export async function countArchivable(doneIds, opts) {
|
|
|
133
139
|
continue;
|
|
134
140
|
const age = ageDays(iso, now);
|
|
135
141
|
if (age !== null && age >= opts.days)
|
|
136
|
-
|
|
142
|
+
out.push(id);
|
|
137
143
|
}
|
|
138
|
-
return
|
|
144
|
+
return out;
|
|
139
145
|
}
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,8 @@ import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions
|
|
|
15
15
|
import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
16
16
|
import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, rowLabel, selectedSession, sessionTitle, stripProjectCore, unpinLandingId, } from './session-list.js';
|
|
17
17
|
import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch, } from './config.js';
|
|
18
|
-
import {
|
|
18
|
+
import { archivableIds, SCAN_INTERVAL_MS } from './archivable.js';
|
|
19
|
+
import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, SESSION_VIEWS, TASK_VIEWS, } from './pane-views.js';
|
|
19
20
|
import { assignListCapacity, detailCapacity, isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
|
|
20
21
|
import { caretWindow, cut, cutParts, pad, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
|
|
21
22
|
import { scanText, sliceLine, topForOffset, } from './text-search.js';
|
|
@@ -469,22 +470,26 @@ function useSessions(projectRoot) {
|
|
|
469
470
|
// `doneSig` è una stringa, non l'array: `tasks` cambia identità a ogni re-read
|
|
470
471
|
// di tasks.md, e usarlo come dipendenza rimetterebbe lo scan sul tick da 1,5s
|
|
471
472
|
// per la via di dietro.
|
|
473
|
+
//
|
|
474
|
+
// T100 — tiene gli ID e non più il conteggio: `archiviabili` è una vista del
|
|
475
|
+
// pane, quindi servono le righe. Il contatore dell'header è `.size` dello stesso
|
|
476
|
+
// insieme che disegna la lista — un numero e una lista che non possono divergere.
|
|
472
477
|
function useArchivable(doneSig, tasksDir, projectRoot, days) {
|
|
473
|
-
const [
|
|
478
|
+
const [ids, setIds] = useState(() => new Set());
|
|
474
479
|
useEffect(() => {
|
|
475
480
|
let alive = true;
|
|
476
|
-
const
|
|
481
|
+
const done = doneSig ? doneSig.split(',') : [];
|
|
477
482
|
const scan = () => {
|
|
478
|
-
|
|
479
|
-
.then((
|
|
483
|
+
archivableIds(done, { tasksDir, projectRoot, days })
|
|
484
|
+
.then((found) => {
|
|
480
485
|
if (alive)
|
|
481
|
-
|
|
486
|
+
setIds(new Set(found));
|
|
482
487
|
})
|
|
483
|
-
// Scan fallito (task file illeggibili, git muto) →
|
|
484
|
-
//
|
|
488
|
+
// Scan fallito (task file illeggibili, git muto) → insieme vuoto, cioè
|
|
489
|
+
// voce a 0. Un contatore informativo non merita un errore a schermo.
|
|
485
490
|
.catch(() => {
|
|
486
491
|
if (alive)
|
|
487
|
-
|
|
492
|
+
setIds(new Set());
|
|
488
493
|
});
|
|
489
494
|
};
|
|
490
495
|
scan();
|
|
@@ -494,7 +499,7 @@ function useArchivable(doneSig, tasksDir, projectRoot, days) {
|
|
|
494
499
|
clearInterval(id);
|
|
495
500
|
};
|
|
496
501
|
}, [doneSig, tasksDir, projectRoot, days]);
|
|
497
|
-
return
|
|
502
|
+
return ids;
|
|
498
503
|
}
|
|
499
504
|
// Legge il task file della task selezionata (Q1+B T20). On-id-change: navigare
|
|
500
505
|
// con ↑↓ ricarica il dettaglio; leggere un singolo file 4-9KB è I/O triviale,
|
|
@@ -622,6 +627,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
622
627
|
// modale: la lista si aggiorna dal vivo, quindi `esc` deve poter ripristinare.
|
|
623
628
|
const [view, setView] = useState(() => loadView(cwd));
|
|
624
629
|
const [viewBackup, setViewBackup] = useState(null);
|
|
630
|
+
// T100 — vista attiva di ciascun pane, navigata con ←/→. VOLATILE per
|
|
631
|
+
// decisione (D3 create): non entra in `deck-view.json`, il deck riapre sempre
|
|
632
|
+
// su `Tasks` e su `{parent}`. Il criterio è il rischio di leggere una lista
|
|
633
|
+
// parziale credendola completa — un filtro salvato lo si è scelto, una vista
|
|
634
|
+
// riaperta a freddo si legge come la lista intera.
|
|
635
|
+
const [taskViewId, setTaskViewId] = useState('tasks');
|
|
636
|
+
const [sessionViewId, setSessionViewId] = useState('context');
|
|
625
637
|
const [filterCursor, setFilterCursor] = useState({ row: 0, col: 0 });
|
|
626
638
|
// T41 — bozza dell'edit (null fuori dal modale) e riga attiva della griglia.
|
|
627
639
|
const [edit, setEdit] = useState(null);
|
|
@@ -697,15 +709,31 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
697
709
|
const doneSig = useMemo(() => tasks.filter((t) => isDone(t.prog)).map((t) => t.id).join(','), [tasks]);
|
|
698
710
|
const archivableDays = useMemo(() => loadArchivableDays(cwd), [cwd]);
|
|
699
711
|
const archivable = useArchivable(doneSig, tasksDir, cwd, archivableDays);
|
|
712
|
+
// T100 — le task effettivamente a schermo: la vista principale coincide con
|
|
713
|
+
// `viewTasks` (nessun ricalcolo sul cammino di default), le altre due passano
|
|
714
|
+
// dal predicato del catalogo. I CONTATORI restano misurati sulla vista di
|
|
715
|
+
// default, o navigare cambierebbe i numeri che si sta navigando.
|
|
716
|
+
const taskCounts = {
|
|
717
|
+
filtered: viewTasks.length,
|
|
718
|
+
total: tasks.length,
|
|
719
|
+
hidden: hiddenTasks,
|
|
720
|
+
archivable: archivable.size,
|
|
721
|
+
};
|
|
722
|
+
const paneTasks = useMemo(() => taskViewId === 'tasks'
|
|
723
|
+
? viewTasks
|
|
724
|
+
: selectTasks(tasks, taskViewId, { view, archivable }), [taskViewId, viewTasks, tasks, view, archivable]);
|
|
700
725
|
const isSpot = sel === SPOT;
|
|
701
726
|
const isAll = sel === ALL;
|
|
702
727
|
const projectName = cwd.split('/').pop() || cwd;
|
|
703
728
|
// Unica fonte della selezione: si legge SEMPRE dalla vista, mai dall'array
|
|
704
729
|
// grezzo — è l'invariante che tiene allineati dettaglio mostrato e spawn.
|
|
705
|
-
const selTask = typeof sel === 'string' ?
|
|
730
|
+
const selTask = typeof sel === 'string' ? paneTasks.find((t) => t.id === sel) ?? null : null;
|
|
706
731
|
const selectedTaskId = selTask?.id ?? null;
|
|
707
|
-
const selIndex = selTask ?
|
|
732
|
+
const selIndex = selTask ? paneTasks.indexOf(selTask) + META_ROWS : isAll ? ROW_ALL : ROW_SPOT;
|
|
708
733
|
const detail = useTaskDetail(tasksDir, selectedTaskId ?? undefined);
|
|
734
|
+
// Il parent delle conversazioni: l'asse che sceglie il pane task, ortogonale
|
|
735
|
+
// alla vista che sceglie l'header (D2 create).
|
|
736
|
+
const parentLabel = isAll ? 'tutte' : isSpot ? 'spot' : selectedTaskId ?? '—';
|
|
709
737
|
// Conteggio figli per task + spot (badge nel Tasks pane).
|
|
710
738
|
const childCount = new Map();
|
|
711
739
|
let spotCount = 0;
|
|
@@ -731,12 +759,24 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
731
759
|
// contestuali. Dedup, cap solo sulle contestuali, righe stale per le pinnate
|
|
732
760
|
// orfane. Core PURO in session-list.ts (testabile senza Ink).
|
|
733
761
|
const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, isAll ? MAX_SESSIONS_ALL : MAX_SESSIONS), [childSessions, sessions, pinned, isAll]);
|
|
734
|
-
const sessionRows = assembled.rows;
|
|
735
|
-
const selSessionObj = selectedSession(sessionRows, selSessionId);
|
|
736
762
|
// T62 — contato sulla lista INTERA (stessa ragione delle larghezze di colonna
|
|
737
763
|
// qui sotto): derivarlo dalla finestra visibile lo farebbe cambiare a ogni
|
|
738
764
|
// scroll, cioè un contatore che conta lo schermo invece della lista.
|
|
739
|
-
|
|
765
|
+
// T100 — «intera» ora vuol dire la lista della vista di DEFAULT (`assembled`),
|
|
766
|
+
// non quella a schermo: il contatore di una voce del catalogo non può
|
|
767
|
+
// dipendere da quale voce è selezionata, o navigare muoverebbe i numeri.
|
|
768
|
+
const liveCount = useMemo(() => assembled.rows.filter((r) => r.kind !== 'separator' && live.has(r.sessionId)).length, [assembled, live]);
|
|
769
|
+
const sessionCounts = {
|
|
770
|
+
total: assembled.pinnedCount + assembled.contextTotal,
|
|
771
|
+
live: liveCount,
|
|
772
|
+
pinned: assembled.pinnedCount,
|
|
773
|
+
older: assembled.contextHidden,
|
|
774
|
+
};
|
|
775
|
+
// T100 — le righe a schermo sono quelle della vista attiva. Fuori dalla vista
|
|
776
|
+
// di default il separatore non c'è: segna il confine fra pinnate e
|
|
777
|
+
// contestuali, e in un sottoinsieme quel confine non esiste più.
|
|
778
|
+
const sessionRows = useMemo(() => selectSessionRows(sessionViewId, { assembled, isLive: (id) => live.has(id) }), [sessionViewId, assembled, live]);
|
|
779
|
+
const selSessionObj = selectedSession(sessionRows, selSessionId);
|
|
740
780
|
// T60 — larghezze delle colonne fisse della lista sessioni, misurate sulla
|
|
741
781
|
// lista INTERA e non sulla finestra visibile: derivarle dalle sole righe a
|
|
742
782
|
// schermo le farebbe cambiare a ogni scroll, cioè l'opposto di una tabella.
|
|
@@ -849,10 +889,10 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
849
889
|
// dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
|
|
850
890
|
// sulla prima visibile — fallback deterministico, mai una posizione a caso.
|
|
851
891
|
useEffect(() => {
|
|
852
|
-
if (typeof sel === 'string' && !
|
|
853
|
-
setSel(
|
|
892
|
+
if (typeof sel === 'string' && !paneTasks.some((t) => t.id === sel)) {
|
|
893
|
+
setSel(paneTasks[0]?.id ?? ALL);
|
|
854
894
|
}
|
|
855
|
-
}, [
|
|
895
|
+
}, [paneTasks, sel]);
|
|
856
896
|
// T50 — la selezione (id) resta valida sotto la vista a due gruppi: se l'id
|
|
857
897
|
// non è più una riga selezionabile (cambio parent, lista mutata, pin rimosso,
|
|
858
898
|
// sessione sparita) cade sulla prima riga — fallback deterministico, mai una
|
|
@@ -935,7 +975,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
935
975
|
// messaggi a mano. `verb` è l'unica cosa che cambia fra i due usi.
|
|
936
976
|
function selectedTaskOr(keyLabel, verb) {
|
|
937
977
|
if (focus !== 'tasks') {
|
|
938
|
-
setNote(`${keyLabel} → ${verb}: seleziona una task (
|
|
978
|
+
setNote(`${keyLabel} → ${verb}: seleziona una task (tab per il pane)`);
|
|
939
979
|
return null;
|
|
940
980
|
}
|
|
941
981
|
// T59 — la guardia è "non è una task", non "è spot": le righe meta sono due
|
|
@@ -1178,13 +1218,33 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1178
1218
|
// 2..N+1 = task visibili) e la riconverte subito in sentinella o id: l'indice
|
|
1179
1219
|
// non sopravvive a un cambio di filtro, l'id sì.
|
|
1180
1220
|
function moveTaskSel(delta) {
|
|
1181
|
-
const next = Math.max(0, Math.min(
|
|
1221
|
+
const next = Math.max(0, Math.min(paneTasks.length + META_ROWS - 1, selIndex + delta));
|
|
1182
1222
|
if (next === ROW_ALL)
|
|
1183
1223
|
setSel(ALL);
|
|
1184
1224
|
else if (next === ROW_SPOT)
|
|
1185
1225
|
setSel(SPOT);
|
|
1186
1226
|
else
|
|
1187
|
-
setSel(
|
|
1227
|
+
setSel(paneTasks[next - META_ROWS]?.id ?? SPOT);
|
|
1228
|
+
}
|
|
1229
|
+
// T100 — ←/→ navigano il catalogo viste del pane in focus. Il reset della
|
|
1230
|
+
// selezione è la regola letterale «prima riga in alto», senza eccezioni: sul
|
|
1231
|
+
// pane task è `ROW_ALL` (D2 preflight — le righe meta non si saltano, e il
|
|
1232
|
+
// parent delle sessioni che torna a `tutte` è un effetto accettato); sul pane
|
|
1233
|
+
// sessioni basta invalidare l'id, e l'effect di validità atterra sulla prima
|
|
1234
|
+
// riga selezionabile della vista nuova.
|
|
1235
|
+
function cycleView(delta) {
|
|
1236
|
+
if (focus === 'tasks') {
|
|
1237
|
+
const next = cycleTaskView(taskViewId, delta);
|
|
1238
|
+
setTaskViewId(next);
|
|
1239
|
+
setSel(ALL);
|
|
1240
|
+
setNote(`vista task: ${taskView(next).label(taskCounts)}`);
|
|
1241
|
+
}
|
|
1242
|
+
else {
|
|
1243
|
+
const next = cycleSessionView(sessionViewId, delta);
|
|
1244
|
+
setSessionViewId(next);
|
|
1245
|
+
setSelSessionId(null);
|
|
1246
|
+
setNote(`vista sessioni: ${sessionView(next).label(sessionCounts, parentLabel)}`);
|
|
1247
|
+
}
|
|
1188
1248
|
}
|
|
1189
1249
|
// T52 — `⏎` contestuale al TIPO di riga: la lista ne mescola due e l'azione
|
|
1190
1250
|
// giusta dipende da quale è selezionata. Riga sessione → resume, identico al
|
|
@@ -1718,9 +1778,18 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1718
1778
|
}
|
|
1719
1779
|
return;
|
|
1720
1780
|
}
|
|
1721
|
-
if (key.
|
|
1781
|
+
if (key.tab) {
|
|
1782
|
+
// T100 — `tab` resta l'unico tasto che sposta il focus fra i due pane. Le
|
|
1783
|
+
// frecce orizzontali facevano lo stesso lavoro (due tasti per un'azione)
|
|
1784
|
+
// mentre la navigazione DENTRO un pane non ne aveva nessuno: ora sono il
|
|
1785
|
+
// selettore di vista dell'header. Il ramo `mode === 'detail'`, anteposto e
|
|
1786
|
+
// chiuso da `return`, non è più l'eccezione a «cambia pane» ma a «cambia
|
|
1787
|
+
// vista» — l'ordine dei rami non cambia, cambia cosa cattura.
|
|
1722
1788
|
setFocus((f) => (f === 'tasks' ? 'sessions' : 'tasks'));
|
|
1723
1789
|
}
|
|
1790
|
+
else if (key.leftArrow || key.rightArrow) {
|
|
1791
|
+
cycleView(key.leftArrow ? -1 : 1);
|
|
1792
|
+
}
|
|
1724
1793
|
else if (key.upArrow) {
|
|
1725
1794
|
if (focus === 'tasks')
|
|
1726
1795
|
moveTaskSel(-1);
|
|
@@ -1775,9 +1844,20 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1775
1844
|
setMode('sort');
|
|
1776
1845
|
}
|
|
1777
1846
|
else if (input === 'F') {
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1847
|
+
// T100/D3 — i filtri valgono SOLO sulla vista principale: su `nascoste`
|
|
1848
|
+
// riapplicarli non ha senso (quella vista È il loro complemento), su
|
|
1849
|
+
// `archiviabili` non li si vuole (è cieca ai filtri per decisione). Stessa
|
|
1850
|
+
// forma dell'inerzia di ^K/^P/^R dentro il detail, ma keyed sulla vista
|
|
1851
|
+
// invece che sul modo — e come là, l'inerzia lo DICE invece di non fare
|
|
1852
|
+
// niente in silenzio.
|
|
1853
|
+
if (taskViewId !== 'tasks') {
|
|
1854
|
+
setNote(`F → filtri: solo sulla vista ${TASK_VIEWS[0].label(taskCounts)} (ora: ${taskView(taskViewId).label(taskCounts)})`);
|
|
1855
|
+
}
|
|
1856
|
+
else {
|
|
1857
|
+
setViewBackup(view);
|
|
1858
|
+
setNote('');
|
|
1859
|
+
setMode('filter');
|
|
1860
|
+
}
|
|
1781
1861
|
}
|
|
1782
1862
|
else if (input === 'f') {
|
|
1783
1863
|
// T28 — fork della sessione selezionata. Minuscola come `t`/`c` (T39):
|
|
@@ -1785,7 +1865,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1785
1865
|
// Vive solo sul pane sessioni: il fork ha per oggetto una conversazione,
|
|
1786
1866
|
// e senza focus lì non ce n'è una selezionata su cui agire.
|
|
1787
1867
|
if (focus !== 'sessions') {
|
|
1788
|
-
setNote('f → fork: seleziona una sessione (
|
|
1868
|
+
setNote('f → fork: seleziona una sessione (tab per il pane)');
|
|
1789
1869
|
}
|
|
1790
1870
|
else {
|
|
1791
1871
|
const s = selSessionObj;
|
|
@@ -1817,7 +1897,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1817
1897
|
// pinnata STALE (l'unico modo di spinnarla). Scrive il sidecar e ricarica
|
|
1818
1898
|
// subito, senza attendere il tick del poll.
|
|
1819
1899
|
if (focus !== 'sessions') {
|
|
1820
|
-
setNote('p → pin: seleziona una sessione (
|
|
1900
|
+
setNote('p → pin: seleziona una sessione (tab per il pane)');
|
|
1821
1901
|
}
|
|
1822
1902
|
else if (!selSessionId) {
|
|
1823
1903
|
setNote('p → nessuna sessione da pinnare');
|
|
@@ -1845,7 +1925,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1845
1925
|
// su una pinnata STALE, perché annotare «questa non c'è più, era X» è
|
|
1846
1926
|
// proprio il caso in cui una nota serve.
|
|
1847
1927
|
if (focus !== 'sessions') {
|
|
1848
|
-
setNote('N → nota: seleziona una sessione (
|
|
1928
|
+
setNote('N → nota: seleziona una sessione (tab per il pane)');
|
|
1849
1929
|
}
|
|
1850
1930
|
else if (!selSessionId) {
|
|
1851
1931
|
setNote('N → nessuna sessione da annotare');
|
|
@@ -1863,7 +1943,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1863
1943
|
// il binding è nostro, il transcript è di CC — riassegnare una
|
|
1864
1944
|
// conversazione il cui transcript non c'è più resta legittimo.
|
|
1865
1945
|
if (focus !== 'sessions') {
|
|
1866
|
-
setNote('A → assegna: seleziona una sessione (
|
|
1946
|
+
setNote('A → assegna: seleziona una sessione (tab per il pane)');
|
|
1867
1947
|
}
|
|
1868
1948
|
else if (!selSessionId) {
|
|
1869
1949
|
setNote('A → nessuna sessione da assegnare');
|
|
@@ -1912,7 +1992,6 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1912
1992
|
exit();
|
|
1913
1993
|
}
|
|
1914
1994
|
});
|
|
1915
|
-
const parentLabel = isAll ? 'tutte' : isSpot ? 'spot' : selectedTaskId ?? '—';
|
|
1916
1995
|
const canSpawn = focus === 'tasks' && selTask !== null;
|
|
1917
1996
|
const canResume = focus === 'sessions' && selSessionObj !== null;
|
|
1918
1997
|
// T50 — il pin agisce su qualunque riga selezionata (anche stale, per
|
|
@@ -2062,8 +2141,8 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
2062
2141
|
// Finestre di rendering. Le liste "logiche" (viewTasks, sessionRows)
|
|
2063
2142
|
// restano intere: navigazione, selezione e spawn continuano a ragionare su
|
|
2064
2143
|
// quelle, la finestra è solo ciò che finisce a schermo.
|
|
2065
|
-
const taskWin = windowRange(
|
|
2066
|
-
const windowTasks =
|
|
2144
|
+
const taskWin = windowRange(paneTasks.length, selIndex - META_ROWS, budget.taskRows);
|
|
2145
|
+
const windowTasks = paneTasks.slice(taskWin.start, taskWin.end);
|
|
2067
2146
|
const selRowIndex = rowIndexOf(sessionRows, selSessionId);
|
|
2068
2147
|
const sessionWin = windowRange(sessionRows.length, selRowIndex, budget.sessionRows);
|
|
2069
2148
|
const windowRows = sessionRows.slice(sessionWin.start, sessionWin.end);
|
|
@@ -2073,7 +2152,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
2073
2152
|
if (budget.compact) {
|
|
2074
2153
|
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? parentLabel, " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
|
|
2075
2154
|
}
|
|
2076
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), 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: ["nota 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 === '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: editRow, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks,
|
|
2155
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), 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: ["nota 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 === '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: editRow, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, counts: taskCounts, activeView: taskViewId, paneCount: paneTasks.length, view: view, selected: selIndex, spotCount: spotCount, allCount: sessions.length, childCount: childCount, focused: focus === 'tasks', loadError: loadError, windowStart: taskWin.start, above: taskWin.start, below: paneTasks.length - taskWin.end, columns: columns }), _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] }));
|
|
2077
2156
|
}
|
|
2078
2157
|
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
2079
2158
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
@@ -2375,24 +2454,39 @@ function AssignScreen({ sessionId, label, current, filter, rows, selected, match
|
|
|
2375
2454
|
* scoprirlo sarebbe il bordo del pane a schermo.
|
|
2376
2455
|
*
|
|
2377
2456
|
* `truncate-end` taglia dalla coda, e `cutParts` conserva l'ordine: l'ultimo
|
|
2378
|
-
* segmento resta il primo a cedere il posto (
|
|
2379
|
-
*
|
|
2457
|
+
* segmento resta il primo a cedere il posto (`↑↓` in coda alle voci navigabili).
|
|
2458
|
+
*
|
|
2459
|
+
* T100 — la riga non è più informativa: le voci del catalogo sono SELEZIONABILI
|
|
2460
|
+
* con ←/→, e l'attiva si distingue in video inverso (D5 — costa 0 colonne e non
|
|
2461
|
+
* entra in gara con la semantica di colore già occupata). Le voci ci sono tutte
|
|
2462
|
+
* anche a 0 (D1): un catalogo che si accorcia sposta le voci sotto le dita.
|
|
2463
|
+
* L'ordine è vincolato — le navigabili PRIMA di `↑N`/`↓N`, che cadono per primi
|
|
2464
|
+
* su un terminale stretto — e la voce attiva ha la precedenza sul budget (D6).
|
|
2380
2465
|
*/
|
|
2381
|
-
function TasksHeader({
|
|
2466
|
+
function TasksHeader({ counts, active, above, below, focused, columns, }) {
|
|
2467
|
+
const views = TASK_VIEWS.map((v, i) => {
|
|
2468
|
+
const n = v.count(counts);
|
|
2469
|
+
return {
|
|
2470
|
+
// Il separatore sta nel segmento, non fra i segmenti: `cutParts` misura la
|
|
2471
|
+
// riga pezzo per pezzo e uno spazio fuori dai pezzi non verrebbe contato.
|
|
2472
|
+
text: `${i > 0 ? ' · ' : ''}${v.label(counts)}`,
|
|
2473
|
+
color: v.color,
|
|
2474
|
+
dim: v.dim || n === 0,
|
|
2475
|
+
active: v.id === active,
|
|
2476
|
+
};
|
|
2477
|
+
});
|
|
2382
2478
|
const segments = [
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
...(below > 0 ? [{ text: ` · ↓${below}`, dim: true }] : []),
|
|
2387
|
-
...(archivable > 0 ? [{ text: ` · ${archivable} archiviabili`, dim: true }] : []),
|
|
2479
|
+
...views,
|
|
2480
|
+
{ text: above > 0 ? ` · ↑${above}` : '', dim: true, active: false, color: undefined },
|
|
2481
|
+
{ text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false, color: undefined },
|
|
2388
2482
|
];
|
|
2389
|
-
const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns));
|
|
2390
|
-
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, children: shown[i] }, i)) : null) }));
|
|
2483
|
+
const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
|
|
2484
|
+
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) }));
|
|
2391
2485
|
}
|
|
2392
|
-
function TasksPane({ tasks,
|
|
2486
|
+
function TasksPane({ tasks, counts, activeView, paneCount, view, selected, spotCount, allCount, childCount, focused, loadError, windowStart, above, below, columns, }) {
|
|
2393
2487
|
const allSelected = selected === ROW_ALL;
|
|
2394
2488
|
const spotSelected = selected === ROW_SPOT;
|
|
2395
|
-
return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(TasksHeader, {
|
|
2489
|
+
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)}` +
|
|
2396
2490
|
(view.hiddenPri.length + view.hiddenProg.length > 0
|
|
2397
2491
|
? ' · filtri: ' +
|
|
2398
2492
|
[
|
|
@@ -2401,7 +2495,12 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
|
|
|
2401
2495
|
]
|
|
2402
2496
|
.map((e) => `−${e.glyph}`)
|
|
2403
2497
|
.join(' ')
|
|
2404
|
-
: '')), paneTextWidth(columns)) }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni", allCount > 0 ? ` (${allCount})` : ''] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) :
|
|
2498
|
+
: '')), paneTextWidth(columns)) }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni", allCount > 0 ? ` (${allCount})` : ''] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : paneCount === 0 ? (
|
|
2499
|
+
// T100/D1 — una voce a contatore 0 resta navigabile, e selezionarla dà
|
|
2500
|
+
// una lista vuota che DICE perché è vuota. Senza la nota il pane si
|
|
2501
|
+
// legge come rotto: le righe meta restano, le task no, e niente spiega
|
|
2502
|
+
// che è la vista scelta a non contenere nulla.
|
|
2503
|
+
_jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(taskView(activeView).empty, paneTextWidth(columns)) })) : (tasks.map((task, i) => {
|
|
2405
2504
|
// windowStart riporta l'indice di finestra a quello della lista
|
|
2406
2505
|
// completa, su cui è keyata la selezione. +META_ROWS: le prime due
|
|
2407
2506
|
// righe sono le meta.
|
|
@@ -2432,24 +2531,39 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
|
|
|
2432
2531
|
* condiviso, `cutParts`), la resa è del pezzo. Il giallo su `📌N` distingue le
|
|
2433
2532
|
* pinnate dal resto dell'header e non è decorazione.
|
|
2434
2533
|
*/
|
|
2435
|
-
function SessionsHeader({ parentLabel,
|
|
2534
|
+
function SessionsHeader({ parentLabel, counts, active, above, below, focused, columns, }) {
|
|
2535
|
+
const views = SESSION_VIEWS.map((v) => {
|
|
2536
|
+
const n = v.count(counts);
|
|
2537
|
+
return {
|
|
2538
|
+
text: ` · ${v.label(counts, parentLabel)}`,
|
|
2539
|
+
color: v.color,
|
|
2540
|
+
dim: v.dim || n === 0,
|
|
2541
|
+
active: v.id === active,
|
|
2542
|
+
};
|
|
2543
|
+
});
|
|
2436
2544
|
const segments = [
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
...
|
|
2441
|
-
|
|
2442
|
-
|
|
2545
|
+
// `Sessions` non è una voce del catalogo: nomina il pane, non un
|
|
2546
|
+
// sottoinsieme, quindi non è raggiungibile con le frecce.
|
|
2547
|
+
{ text: 'Sessions', color: undefined, dim: false, active: false },
|
|
2548
|
+
...views,
|
|
2549
|
+
{ text: above > 0 ? ` · ↑${above}` : '', dim: true, active: false, color: undefined },
|
|
2550
|
+
{ text: below > 0 ? ` · ↓${below}` : '', dim: true, active: false, color: undefined },
|
|
2443
2551
|
];
|
|
2444
|
-
const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns));
|
|
2445
|
-
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, children: shown[i] }, i)) : null) }));
|
|
2552
|
+
const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns), segments.findIndex((s) => s.active));
|
|
2553
|
+
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) }));
|
|
2446
2554
|
}
|
|
2447
|
-
function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows,
|
|
2448
|
-
return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(SessionsHeader, { parentLabel: parentLabel,
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2555
|
+
function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, counts, activeView, paneCount, selectedId, focused, above, below, columns, forkOf, sessionNotes, projectCore, live, }) {
|
|
2556
|
+
return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(SessionsHeader, { parentLabel: parentLabel, counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), paneCount === 0 ? (
|
|
2557
|
+
// T100 — la nota della vista di default resta quella storica, che nomina
|
|
2558
|
+
// il PARENT (task, spot o tutte); le altre tre viste portano la propria,
|
|
2559
|
+
// che nomina il sottoinsieme. Sono due vuoti diversi: «questo parent non
|
|
2560
|
+
// ha conversazioni» e «questo sottoinsieme del parent è vuoto».
|
|
2561
|
+
_jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(sessionView(activeView).empty ??
|
|
2562
|
+
(isAll
|
|
2563
|
+
? 'nessuna conversazione nel progetto'
|
|
2564
|
+
: isSpot
|
|
2565
|
+
? 'nessuna sessione libera'
|
|
2566
|
+
: 'nessuna sessione legata a questa task'), paneTextWidth(columns)) })) : (rows.map((row, i) => {
|
|
2453
2567
|
// T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
|
|
2454
2568
|
// box pesante (coerente con lo styling delle Done dimmate).
|
|
2455
2569
|
if (row.kind === 'separator') {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// T100 — Catalogo delle viste dei due pane. L'header smette di essere una riga
|
|
2
|
+
// di contatori e diventa un SELETTORE: ogni segmento che nomina un sottoinsieme
|
|
3
|
+
// calcolabile (`nascoste`, `archiviabili`, `vive`, `📌`, `+più vecchie`) è una
|
|
4
|
+
// vista raggiungibile con ←/→.
|
|
5
|
+
//
|
|
6
|
+
// Modulo PURO: nessun import da ink/react, nessun I/O. Il calcolo resta dove
|
|
7
|
+
// stava — `view.ts` per i filtri task, `session-list.ts` per l'assemblaggio a
|
|
8
|
+
// due gruppi — qui c'è solo la mappa di quali sottoinsiemi esistono, come si
|
|
9
|
+
// chiamano e con che numero.
|
|
10
|
+
//
|
|
11
|
+
// Invarianti (decisioni congelate al create-task e al preflight):
|
|
12
|
+
// - D1 create — catalogo FISSO: le voci ci sono tutte anche a contatore 0. Un
|
|
13
|
+
// catalogo che si accorcia quando un numero va a zero sposta le voci sotto le
|
|
14
|
+
// dita e può far svanire la vista corrente mentre la guardi (togliere un
|
|
15
|
+
// filtro mentre sei su `nascoste`).
|
|
16
|
+
// - D1 preflight — navigazione CICLICA, come `DETAIL_ACTIONS` e le righe di
|
|
17
|
+
// scelta del modale edit.
|
|
18
|
+
// - Il numero di una voce si misura SEMPRE sulla vista di default, mai su
|
|
19
|
+
// quella attiva: altrimenti navigare cambierebbe i contatori che si sta
|
|
20
|
+
// navigando, e il catalogo tornerebbe dinamico per la via di dietro.
|
|
21
|
+
// - `↑N`/`↓N` non stanno qui: contano elementi fuori finestra per altezza del
|
|
22
|
+
// terminale, cioè una POSIZIONE, non un insieme.
|
|
23
|
+
import { isVisible, compareTasks } from './view.js';
|
|
24
|
+
export const TASK_VIEWS = [
|
|
25
|
+
{
|
|
26
|
+
id: 'tasks',
|
|
27
|
+
label: (c) => `Tasks (${c.hidden > 0 ? `${c.filtered}/${c.total}` : c.filtered})`,
|
|
28
|
+
count: (c) => c.filtered,
|
|
29
|
+
has: (t, ctx) => isVisible(t, ctx.view),
|
|
30
|
+
empty: 'nessuna task in vista: i filtri le escludono tutte',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
// `yellow` come prima: avverte che la vista è distorta da un filtro.
|
|
34
|
+
id: 'hidden',
|
|
35
|
+
label: (c) => `${c.hidden} nascoste`,
|
|
36
|
+
count: (c) => c.hidden,
|
|
37
|
+
has: (t, ctx) => !isVisible(t, ctx.view),
|
|
38
|
+
color: 'yellow',
|
|
39
|
+
empty: 'nessuna task nascosta: nessun filtro ne esclude',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
// T61 — dim: le Done oltre soglia non alterano ciò che stai guardando.
|
|
43
|
+
id: 'archivable',
|
|
44
|
+
label: (c) => `${c.archivable} archiviabili`,
|
|
45
|
+
count: (c) => c.archivable,
|
|
46
|
+
has: (t, ctx) => ctx.archivable.has(t.id),
|
|
47
|
+
dim: true,
|
|
48
|
+
empty: "nessuna Done oltre la soglia d'età",
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
/**
|
|
52
|
+
* Le task della vista `id`, filtrate col predicato e ordinate con la chain
|
|
53
|
+
* corrente. L'ordinamento vale per ogni vista, non solo la principale: una lista
|
|
54
|
+
* di task ordinata in un modo e l'altra in un altro sarebbe un secondo asse che
|
|
55
|
+
* si muove da solo.
|
|
56
|
+
*/
|
|
57
|
+
export function selectTasks(tasks, id, ctx) {
|
|
58
|
+
const entry = taskView(id);
|
|
59
|
+
const picked = tasks.filter((t) => entry.has(t, ctx));
|
|
60
|
+
picked.sort((a, b) => compareTasks(a, b, ctx.view.sort));
|
|
61
|
+
return picked;
|
|
62
|
+
}
|
|
63
|
+
/** Glifo della sessione viva, gemello di `LIVE_IDLE` del render. Duplicato qui
|
|
64
|
+
* perché il catalogo è puro e non può importare da `cli.tsx`; è un carattere
|
|
65
|
+
* concorde (largo 1 per entrambe le contabilità), quindi non passa da
|
|
66
|
+
* `sanitize`. */
|
|
67
|
+
const LIVE_GLYPH = '●';
|
|
68
|
+
/** Righe selezionabili della lista assemblata (il separatore non è una riga di
|
|
69
|
+
* contenuto: fuori dalla vista di default i due gruppi non ci sono più). */
|
|
70
|
+
function selectable(rows) {
|
|
71
|
+
return rows.filter((r) => r.kind !== 'separator');
|
|
72
|
+
}
|
|
73
|
+
export const SESSION_VIEWS = [
|
|
74
|
+
{
|
|
75
|
+
id: 'context',
|
|
76
|
+
label: (c, parent) => `${parent} (${c.total})`,
|
|
77
|
+
count: (c) => c.total,
|
|
78
|
+
rows: (ctx) => ctx.assembled.rows,
|
|
79
|
+
empty: null,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'live',
|
|
83
|
+
label: (c) => `${LIVE_GLYPH}${c.live} vive`,
|
|
84
|
+
count: (c) => c.live,
|
|
85
|
+
rows: (ctx) => selectable(ctx.assembled.rows).filter((r) => ctx.isLive(r.sessionId)),
|
|
86
|
+
color: 'green',
|
|
87
|
+
empty: 'nessuna conversazione viva in questa lista',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'pinned',
|
|
91
|
+
label: (c) => `📌${c.pinned}`,
|
|
92
|
+
count: (c) => c.pinned,
|
|
93
|
+
rows: (ctx) => ctx.assembled.rows.filter((r) => r.kind === 'pinned'),
|
|
94
|
+
color: 'yellow',
|
|
95
|
+
empty: 'nessuna conversazione pinnata',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
// D4 preflight — la coda troncata, non la lista intera senza cap: il numero
|
|
99
|
+
// dell'header e il numero di righe devono coincidere.
|
|
100
|
+
id: 'older',
|
|
101
|
+
label: (c) => `+${c.older} più vecchie`,
|
|
102
|
+
count: (c) => c.older,
|
|
103
|
+
rows: (ctx) => ctx.assembled.contextOverflow.map((s) => ({
|
|
104
|
+
kind: 'context',
|
|
105
|
+
sessionId: s.sessionId,
|
|
106
|
+
session: s,
|
|
107
|
+
})),
|
|
108
|
+
dim: true,
|
|
109
|
+
empty: 'nessuna conversazione oltre il cap',
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
export function selectSessionRows(id, ctx) {
|
|
113
|
+
return sessionView(id).rows(ctx);
|
|
114
|
+
}
|
|
115
|
+
// ── lookup e navigazione ───────────────────────────────────────────────────
|
|
116
|
+
export function taskView(id) {
|
|
117
|
+
return TASK_VIEWS.find((v) => v.id === id) ?? TASK_VIEWS[0];
|
|
118
|
+
}
|
|
119
|
+
export function sessionView(id) {
|
|
120
|
+
return SESSION_VIEWS.find((v) => v.id === id) ?? SESSION_VIEWS[0];
|
|
121
|
+
}
|
|
122
|
+
/** Scorrimento ciclico del catalogo: `→` sull'ultima voce torna alla prima. Id
|
|
123
|
+
* ignoto → prima voce, mai un indice negativo. */
|
|
124
|
+
function cycle(catalog, current, delta) {
|
|
125
|
+
const at = catalog.findIndex((v) => v.id === current);
|
|
126
|
+
if (at < 0)
|
|
127
|
+
return catalog[0];
|
|
128
|
+
return catalog[(at + delta + catalog.length) % catalog.length];
|
|
129
|
+
}
|
|
130
|
+
export function cycleTaskView(current, delta) {
|
|
131
|
+
return cycle(TASK_VIEWS, current, delta).id;
|
|
132
|
+
}
|
|
133
|
+
export function cycleSessionView(current, delta) {
|
|
134
|
+
return cycle(SESSION_VIEWS, current, delta).id;
|
|
135
|
+
}
|
package/dist/session-list.js
CHANGED
|
@@ -134,7 +134,9 @@ export function assembleSessionList(childSessions, allSessions, pinned, maxConte
|
|
|
134
134
|
});
|
|
135
135
|
const pinnedSet = new Set(pinnedIds);
|
|
136
136
|
const dedupedContext = childSessions.filter((s) => !pinnedSet.has(s.sessionId));
|
|
137
|
-
const
|
|
137
|
+
const cap = Math.max(0, maxContext);
|
|
138
|
+
const shownContext = dedupedContext.slice(0, cap);
|
|
139
|
+
const overflowContext = dedupedContext.slice(cap);
|
|
138
140
|
const contextRows = shownContext.map((s) => ({
|
|
139
141
|
kind: 'context',
|
|
140
142
|
sessionId: s.sessionId,
|
|
@@ -150,7 +152,8 @@ export function assembleSessionList(childSessions, allSessions, pinned, maxConte
|
|
|
150
152
|
rows,
|
|
151
153
|
pinnedCount: pinnedRows.length,
|
|
152
154
|
contextTotal: dedupedContext.length,
|
|
153
|
-
contextHidden:
|
|
155
|
+
contextHidden: overflowContext.length,
|
|
156
|
+
contextOverflow: overflowContext,
|
|
154
157
|
};
|
|
155
158
|
}
|
|
156
159
|
function isSelectable(r) {
|
package/dist/task-edit.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// isolata in `writeTaskEdit`, così la grammatica delle sostituzioni è testabile
|
|
4
4
|
// senza toccare il filesystem.
|
|
5
5
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
-
import { TASK_ID_RE, findTaskFile } from './tasks.js';
|
|
6
|
+
import { TASK_ID_RE, findTaskFile, headerColumns } from './tasks.js';
|
|
7
7
|
// Il task file scrive la priorità per NOME (`- **Priority**: Med`), tasks.md la
|
|
8
8
|
// scrive come GLIFO (colonna Pri). Due rappresentazioni dello stesso valore →
|
|
9
9
|
// due mappe, entrambe keyed sul nome canonico di view.ts (unica fonte del rango).
|
|
@@ -75,31 +75,38 @@ export function initialDetail(current, prog, date = today()) {
|
|
|
75
75
|
return stripProgGlyph(s);
|
|
76
76
|
}
|
|
77
77
|
/**
|
|
78
|
-
* Riscrive le celle Pri
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
78
|
+
* Riscrive le celle Pri, Prog e — se `desc` è passata — la descrizione della
|
|
79
|
+
* riga `| Tnn | … |` in tasks.md. Solo la PRIMA riga con quell'id — l'overview
|
|
80
|
+
* è unica, un secondo match sarebbe un duplicato da non propagare. Le celle non
|
|
81
|
+
* toccate restano i token grezzi originali: nessun re-flow della tabella, il
|
|
82
|
+
* diff resta di una riga. `ok:false` = id assente → il chiamante non scrive nulla.
|
|
83
|
+
*
|
|
84
|
+
* Le posizioni vengono dall'HEADER (`headerColumns`, stessa fonte di
|
|
85
|
+
* `parseTasks`), mai da un indice cablato: qui si SCRIVE su disco, quindi una
|
|
86
|
+
* colonna in più o in meno non produrrebbe una lettura sbagliata ma un titolo
|
|
87
|
+
* sovrascritto col glifo. Header assente → `ok:false`, non si tira a indovinare.
|
|
84
88
|
*
|
|
85
89
|
* `desc` undefined ≠ `desc` vuota: la prima non tocca la cella (chiamata
|
|
86
90
|
* legacy, solo pri/prog), la seconda la svuota davvero.
|
|
87
91
|
*
|
|
88
92
|
* La descrizione è l'ULTIMA colonna e può contenere `|` — che in una tabella
|
|
89
93
|
* markdown spezza la cella. Riscriverla significa quindi collassare tutte le
|
|
90
|
-
* celle da
|
|
91
|
-
* della vecchia descrizione resterebbero appesi in coda alla nuova.
|
|
94
|
+
* celle da `cols.desc` fino alla penultima in una sola (`splice`), altrimenti i
|
|
95
|
+
* pezzi della vecchia descrizione resterebbero appesi in coda alla nuova.
|
|
92
96
|
*
|
|
93
97
|
* L'id deve rispettare `TASK_ID_RE` (importato da tasks.ts — stesso gate di
|
|
94
98
|
* `parseTasks`, non una copia): senza, un id arbitrario matcherebbe la riga di
|
|
95
|
-
*
|
|
96
|
-
* celle e sfondando la tabella.
|
|
99
|
+
* separatore, riscrivendone le celle e sfondando la tabella.
|
|
97
100
|
*/
|
|
98
101
|
export function updateTasksMdRow(content, id, priGlyph, progGlyph, desc) {
|
|
99
102
|
if (!TASK_ID_RE.test(id))
|
|
100
103
|
return { content, ok: false };
|
|
104
|
+
const lines = content.split('\n');
|
|
105
|
+
const cols = lines.reduce((found, l) => found ?? headerColumns(l), null);
|
|
106
|
+
if (!cols)
|
|
107
|
+
return { content, ok: false };
|
|
101
108
|
let ok = false;
|
|
102
|
-
const
|
|
109
|
+
const out = lines.map((line) => {
|
|
103
110
|
if (ok)
|
|
104
111
|
return line;
|
|
105
112
|
if (!line.trim().startsWith('|'))
|
|
@@ -107,20 +114,21 @@ export function updateTasksMdRow(content, id, priGlyph, progGlyph, desc) {
|
|
|
107
114
|
const cells = line.split('|');
|
|
108
115
|
if ((cells[1] ?? '').trim() !== id)
|
|
109
116
|
return line;
|
|
110
|
-
|
|
117
|
+
// Serve la cella desc PIÙ la coda dopo il `|` finale: senza la coda lo
|
|
118
|
+
// splice inserirebbe invece di sostituire.
|
|
119
|
+
if (cells.length < cols.desc + 2)
|
|
111
120
|
return line; // riga malformata → non toccarla
|
|
112
121
|
ok = true;
|
|
113
|
-
cells[
|
|
114
|
-
cells[
|
|
122
|
+
cells[cols.pri] = ` ${priGlyph} `;
|
|
123
|
+
cells[cols.prog] = ` ${progGlyph} `;
|
|
115
124
|
if (desc !== undefined) {
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
cells.splice(5, cells.length - 6, ` ${sanitizeCell(desc)} `);
|
|
125
|
+
// L'ultima cella è la coda dopo il `|` finale (stringa vuota su una riga
|
|
126
|
+
// ben formata) e va conservata, o la riga perderebbe il bordo destro.
|
|
127
|
+
cells.splice(cols.desc, cells.length - cols.desc - 1, ` ${sanitizeCell(desc)} `);
|
|
120
128
|
}
|
|
121
129
|
return cells.join('|');
|
|
122
130
|
});
|
|
123
|
-
return { content: ok ?
|
|
131
|
+
return { content: ok ? out.join('\n') : content, ok };
|
|
124
132
|
}
|
|
125
133
|
/**
|
|
126
134
|
* Rende un testo scrivibile dentro una CELLA di tabella markdown. Due caratteri
|
|
@@ -139,11 +147,10 @@ function sanitizeCell(s) {
|
|
|
139
147
|
* ricompare nel body (residuo template) vince quello dell'header — così ciò che
|
|
140
148
|
* il deck mostra e ciò che scrive restano la stessa riga.
|
|
141
149
|
*
|
|
142
|
-
* L'H1 conserva il proprio CAPPELLO (`# Task: …`
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* una T resta `# Task:` e una D resta `# Doc Task:` senza doverlo sapere qui.
|
|
150
|
+
* L'H1 conserva il proprio CAPPELLO (`# Task: …`): `parseTaskDetail` lo strippa
|
|
151
|
+
* in lettura, quindi il titolo che arriva dal modale non ce l'ha e riscrivere la
|
|
152
|
+
* riga nuda lo perderebbe. Si rilegge dalla riga esistente e si ri-antepone tale
|
|
153
|
+
* e quale, così un file senza cappello resta senza.
|
|
147
154
|
*/
|
|
148
155
|
export function updateTaskFileFields(content, priLabel, progress, title) {
|
|
149
156
|
let priDone = false;
|
|
@@ -152,7 +159,7 @@ export function updateTaskFileFields(content, priLabel, progress, title) {
|
|
|
152
159
|
const lines = content.split('\n').map((line) => {
|
|
153
160
|
if (title !== undefined && !titleDone && /^#\s+/.test(line)) {
|
|
154
161
|
titleDone = true;
|
|
155
|
-
const prefix = line.match(/^#\s+(
|
|
162
|
+
const prefix = line.match(/^#\s+(Task:\s*)/)?.[1] ?? '';
|
|
156
163
|
return `# ${prefix}${title.replace(/\s+/g, ' ').trim()}`;
|
|
157
164
|
}
|
|
158
165
|
if (!priDone && /^-\s*\*\*Priority\*\*:/.test(line)) {
|
package/dist/tasks.js
CHANGED
|
@@ -2,12 +2,12 @@ import { readFileSync, readdirSync } from 'node:fs';
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { sanitize } from './width.js';
|
|
4
4
|
/**
|
|
5
|
-
* Forma di un ID task: `T`
|
|
6
|
-
*
|
|
7
|
-
* per
|
|
8
|
-
*
|
|
5
|
+
* Forma di un ID task: `T` + numero. FONTE UNICA del gate — `parseTasks` lo usa
|
|
6
|
+
* per scartare header/separatore della tabella, `task-edit` per non riscrivere
|
|
7
|
+
* per sbaglio quelle stesse righe. Tenerlo in un posto solo evita che i due gate
|
|
8
|
+
* divergano (uno accetterebbe righe che l'altro rifiuta).
|
|
9
9
|
*/
|
|
10
|
-
export const TASK_ID_RE = /^
|
|
10
|
+
export const TASK_ID_RE = /^T\d+$/;
|
|
11
11
|
// D1 (preflight T20): default docs/tasks.md, override della docs-root via env
|
|
12
12
|
// LOOM_DECK_DOCS_ROOT (es. questo progetto usa `runtime`). No auto-detect.
|
|
13
13
|
export function resolveTasksPath(cwd = process.cwd()) {
|
|
@@ -19,15 +19,50 @@ export function resolveTasksPath(cwd = process.cwd()) {
|
|
|
19
19
|
export function resolveTasksDir(cwd = process.cwd()) {
|
|
20
20
|
return join(dirname(resolveTasksPath(cwd)), 'tasks');
|
|
21
21
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Indici di colonna della Tasks Overview letti dall'HEADER, non cablati.
|
|
24
|
+
*
|
|
25
|
+
* Cablarli lega il parser al NUMERO di colonne invece che al loro nome: toglierne
|
|
26
|
+
* una fa scivolare tutto di uno e il deck legge la cella sbagliata **senza
|
|
27
|
+
* sollevare un errore** — la descrizione finisce dove si cerca il glifo di
|
|
28
|
+
* progresso, e `updateTasksMdRow` riscrive quella posizione su disco. È la stessa
|
|
29
|
+
* trappola già nota sull'indice di RIGA (selezione keyed su id, mai su posizione),
|
|
30
|
+
* applicata all'indice di colonna.
|
|
31
|
+
*
|
|
32
|
+
* Solo `pri` e `prog` si cercano per nome; la descrizione è per costruzione la
|
|
33
|
+
* colonna successiva a Prog, quindi rinominarla non rompe il parse. `null` = la
|
|
34
|
+
* riga non è l'header — l'header della tabella Lane (`| Lane | …`) cade qui.
|
|
35
|
+
*/
|
|
36
|
+
export function headerColumns(line) {
|
|
37
|
+
const t = line.trim();
|
|
38
|
+
if (!t.startsWith('|'))
|
|
39
|
+
return null;
|
|
40
|
+
const cells = t.split('|').map((c) => c.trim().toLowerCase());
|
|
41
|
+
if (cells[1] !== 'id')
|
|
42
|
+
return null;
|
|
43
|
+
const pri = cells.indexOf('pri');
|
|
44
|
+
const prog = cells.indexOf('prog');
|
|
45
|
+
if (pri < 0 || prog < 0)
|
|
46
|
+
return null;
|
|
47
|
+
return { pri, prog, desc: prog + 1 };
|
|
48
|
+
}
|
|
49
|
+
// Estrae le righe `| Tnn | Pri | Prog | Task |` della Tasks Overview. Righe
|
|
50
|
+
// header/separatore non matchano `^T\d+$` → scartate.
|
|
51
|
+
//
|
|
52
|
+
// Le righe si parsano solo DOPO aver incontrato l'header: senza indici derivati
|
|
53
|
+
// non si tira a indovinare, la lista resta vuota. Un fallimento visibile invece
|
|
54
|
+
// di una cella letta al posto sbagliato.
|
|
28
55
|
export function parseTasks(content) {
|
|
29
56
|
const tasks = [];
|
|
57
|
+
let cols = null;
|
|
30
58
|
for (const line of content.split('\n')) {
|
|
59
|
+
const header = headerColumns(line);
|
|
60
|
+
if (header) {
|
|
61
|
+
cols = header;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!cols)
|
|
65
|
+
continue;
|
|
31
66
|
const t = line.trim();
|
|
32
67
|
if (!t.startsWith('|'))
|
|
33
68
|
continue;
|
|
@@ -36,12 +71,10 @@ export function parseTasks(content) {
|
|
|
36
71
|
const id = cells[1];
|
|
37
72
|
if (!TASK_ID_RE.test(id))
|
|
38
73
|
continue;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const pri = cells[2] ?? '';
|
|
42
|
-
const prog = cells[4] ?? '';
|
|
74
|
+
const pri = cells[cols.pri] ?? '';
|
|
75
|
+
const prog = cells[cols.prog] ?? '';
|
|
43
76
|
// desc = colonna finale; join per resistere a eventuali `|` nella descrizione.
|
|
44
|
-
const desc = cells.slice(
|
|
77
|
+
const desc = cells.slice(cols.desc, -1).join('|').trim();
|
|
45
78
|
// Sanificazione AL CONFINE: tutto ciò che entra da tasks.md è testo
|
|
46
79
|
// arbitrario e può contenere glifi che Ink e il terminale misurano in modo
|
|
47
80
|
// diverso (vedi `width.ts`). Farlo qui invece che a ogni sito di render è
|
|
@@ -87,10 +120,9 @@ export function parseTaskDetail(id, content) {
|
|
|
87
120
|
let inDesc = false;
|
|
88
121
|
for (const line of content.split('\n')) {
|
|
89
122
|
if (!title && line.startsWith('# ')) {
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
title = line.replace(/^#\s+/, '').replace(/^(?:Doc\s+)?Task:\s*/, '').trim();
|
|
123
|
+
// Il cappello `Task:` del template va via: il titolo mostrato è la
|
|
124
|
+
// descrizione, non la categoria (già data dall'id).
|
|
125
|
+
title = line.replace(/^#\s+/, '').replace(/^Task:\s*/, '').trim();
|
|
94
126
|
continue;
|
|
95
127
|
}
|
|
96
128
|
if (line.startsWith('## ')) {
|
package/dist/view.js
CHANGED
|
@@ -53,16 +53,11 @@ export function progRank(glyph) {
|
|
|
53
53
|
return PROG_TABLE.find((e) => e.glyph === g)?.rank ?? UNKNOWN_RANK;
|
|
54
54
|
}
|
|
55
55
|
// `T10`.localeCompare(`T9`) mette T10 prima: l'ID va confrontato NUMERICO.
|
|
56
|
-
//
|
|
57
|
-
// il solo numero non basta, T01 e D01 collidono. Il prefisso pesa quindi come
|
|
58
|
-
// cifra più significativa (T prima di D, blocco doc in coda), il numero come
|
|
59
|
-
// meno significativa: il rango resta un intero singolo, quindi il comparator
|
|
60
|
-
// della chain non cambia forma. Il gap 1e6 è oltre qualunque counter reale.
|
|
56
|
+
// Prefisso unico → il numero è già una chiave totale, e il rango è il numero.
|
|
61
57
|
// Un id fuori forma non deve far esplodere il comparator → coda con MAX_SAFE.
|
|
62
|
-
const ID_PREFIX_RANK = { T: 0, D: 1 };
|
|
63
58
|
export function idNum(id) {
|
|
64
|
-
const m = /^(
|
|
65
|
-
return m ?
|
|
59
|
+
const m = /^T(\d+)$/.exec(id);
|
|
60
|
+
return m ? Number(m[1]) : Number.MAX_SAFE_INTEGER;
|
|
66
61
|
}
|
|
67
62
|
function rankOf(task, key) {
|
|
68
63
|
if (key === 'pri')
|
package/dist/width.js
CHANGED
|
@@ -224,8 +224,16 @@ export function cut(s, cols) {
|
|
|
224
224
|
* portano glifi larghi 2 (`📌` nelle sessioni, i glifi di priorità nei filtri
|
|
225
225
|
* delle task), cioè la condizione esatta in cui `cli-truncate` restituisce una
|
|
226
226
|
* riga più larga del budget e il bordo del pane sparisce.
|
|
227
|
+
*
|
|
228
|
+
* T100/D6 — `priority` è l'indice del pezzo che il budget serve PER PRIMO,
|
|
229
|
+
* quando la riga porta un segmento su cui si sta agendo (la vista attiva di un
|
|
230
|
+
* header-selettore). Gli altri si mostrano solo se avanza spazio, e cadono dalla
|
|
231
|
+
* coda come prima. L'ORDINE DI RESA non cambia — cambia chi paga il taglio: su
|
|
232
|
+
* un terminale stretto sparisce un contatore, mai la voce che dice dove sei.
|
|
233
|
+
* Riservare non allarga la riga: resta un tetto, e con `priority` da solo più
|
|
234
|
+
* largo del budget è lui a essere tagliato.
|
|
227
235
|
*/
|
|
228
|
-
export function cutParts(parts, cols) {
|
|
236
|
+
export function cutParts(parts, cols, priority = -1) {
|
|
229
237
|
const empty = parts.map(() => '');
|
|
230
238
|
if (cols <= 0)
|
|
231
239
|
return empty;
|
|
@@ -234,10 +242,45 @@ export function cutParts(parts, cols) {
|
|
|
234
242
|
const out = [...empty];
|
|
235
243
|
const budget = cols - 1; // -1 = la colonna dell'ellissi, che qui c'è di sicuro
|
|
236
244
|
let w = 0;
|
|
237
|
-
|
|
238
|
-
|
|
245
|
+
const hasPriority = priority >= 0 && priority < parts.length;
|
|
246
|
+
if (hasPriority) {
|
|
247
|
+
// Il prioritario prende il suo spazio per primo, e se non ci sta intero è
|
|
248
|
+
// lui a essere tagliato: dev'esserci comunque, anche monco.
|
|
239
249
|
let acc = '';
|
|
240
|
-
for (const ch of parts[
|
|
250
|
+
for (const ch of parts[priority]) {
|
|
251
|
+
const cw = termWidth(ch);
|
|
252
|
+
if (w + cw > budget)
|
|
253
|
+
break;
|
|
254
|
+
acc += ch;
|
|
255
|
+
w += cw;
|
|
256
|
+
}
|
|
257
|
+
out[priority] = acc;
|
|
258
|
+
}
|
|
259
|
+
for (let i = 0; i < parts.length; i++) {
|
|
260
|
+
if (hasPriority && i === priority)
|
|
261
|
+
continue;
|
|
262
|
+
const part = parts[i];
|
|
263
|
+
const pw = termWidth(part);
|
|
264
|
+
if (hasPriority) {
|
|
265
|
+
// Col prioritario già servito il taglio a metà di un altro segmento
|
|
266
|
+
// lascerebbe a schermo un moncone seguito da una voce intera, che si legge
|
|
267
|
+
// come due cose slegate invece che come una riga troncata: qui un pezzo o
|
|
268
|
+
// entra tutto o cede il posto.
|
|
269
|
+
//
|
|
270
|
+
// E chi cede si porta dietro tutti quelli dopo di lui, invece di lasciare
|
|
271
|
+
// passare il primo più corto: l'ordine dell'array È l'ordine di
|
|
272
|
+
// importanza (le voci navigabili prima dei contatori di scroll), e un
|
|
273
|
+
// riempimento a buchi lo scavalcherebbe — l'header mostrerebbe `↓46`
|
|
274
|
+
// avendo buttato la voce che si può selezionare.
|
|
275
|
+
if (w + pw > budget)
|
|
276
|
+
break;
|
|
277
|
+
out[i] = part;
|
|
278
|
+
w += pw;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
let acc = '';
|
|
282
|
+
let full = false;
|
|
283
|
+
for (const ch of part) {
|
|
241
284
|
const cw = termWidth(ch);
|
|
242
285
|
if (w + cw > budget) {
|
|
243
286
|
full = true;
|
|
@@ -247,7 +290,15 @@ export function cutParts(parts, cols) {
|
|
|
247
290
|
w += cw;
|
|
248
291
|
}
|
|
249
292
|
out[i] = acc;
|
|
293
|
+
if (full)
|
|
294
|
+
break;
|
|
250
295
|
}
|
|
296
|
+
// Col prioritario servito per primo il primo pezzo a schermo può non essere il
|
|
297
|
+
// pezzo 0, e i segmenti portano il proprio separatore in testa: senza questa
|
|
298
|
+
// potatura la riga aprirebbe con un ` · ` appeso al nulla.
|
|
299
|
+
const first = out.findIndex((p) => p !== '');
|
|
300
|
+
if (first > 0)
|
|
301
|
+
out[first] = out[first].replace(/^[\s·]+/, '');
|
|
251
302
|
// L'ellissi va sull'ultimo pezzo che ha davvero del testo: appiccicarla a un
|
|
252
303
|
// pezzo vuoto la staccherebbe dal punto di taglio, e il lettore la leggerebbe
|
|
253
304
|
// come un segmento a sé.
|
package/package.json
CHANGED
package/scripts/deck-run
CHANGED
|
@@ -124,7 +124,7 @@ USAGE="uso: deck-run <TaskID> [--prompt-kind <kind>] [--session-id <uuid>]
|
|
|
124
124
|
none nessun prompt — sessione aperta sulla task, a mani nude
|
|
125
125
|
recap recap stato task <TaskID>
|
|
126
126
|
preflight /loom-works:preflight-task <TaskID>
|
|
127
|
-
run /loom-works:run-task <TaskID>
|
|
127
|
+
run /loom-works:run-task <TaskID>
|
|
128
128
|
checkpoint /loom-works:checkpoint-task <TaskID>
|
|
129
129
|
|
|
130
130
|
--title-note nota della conversazione, appesa al titolo tab come «nota»
|
|
@@ -353,23 +353,15 @@ FORK_FLAG=""
|
|
|
353
353
|
# possa scavalcare. Il placeholder {TASK} vale solo per il valore dell'override;
|
|
354
354
|
# i template del catalogo interpolano ${TASK} diretto (nel `${VAR:-...}` la `}`
|
|
355
355
|
# di `{TASK}` chiuderebbe in anticipo l'espansione).
|
|
356
|
-
#
|
|
357
|
-
#
|
|
358
|
-
#
|
|
359
|
-
# Su una D il prompt `run-task` invocherebbe la skill sbagliata. Gli altri kind
|
|
360
|
-
# non discriminano, ma è una verifica da rifare per OGNI kind nuovo, non una
|
|
361
|
-
# proprietà del catalogo: `recap` è un prompt diretto (nessuna skill),
|
|
362
|
-
# `preflight-task` risolve il task file per id, e `checkpoint-task` estrae il
|
|
363
|
-
# taskId con `T\d+|D\d+` — copre quindi entrambi i prefissi da sé.
|
|
356
|
+
# Ogni kind è un template diretto sull'id: la lista del deck è monofamiglia
|
|
357
|
+
# (`T`, unico prefisso del contratto loom), quindi nessun prompt discrimina sulla
|
|
358
|
+
# forma dell'id.
|
|
364
359
|
case "${PROMPT_KIND:-recap}" in
|
|
365
360
|
none) PROMPT="" ;;
|
|
366
361
|
recap) PROMPT="recap stato task ${TASK}" ;;
|
|
367
362
|
preflight) PROMPT="/loom-works:preflight-task ${TASK}" ;;
|
|
368
363
|
checkpoint) PROMPT="/loom-works:checkpoint-task ${TASK}" ;;
|
|
369
|
-
run)
|
|
370
|
-
D*) PROMPT="/loom-works:run-doc ${TASK}" ;;
|
|
371
|
-
*) PROMPT="/loom-works:run-task ${TASK}" ;;
|
|
372
|
-
esac ;;
|
|
364
|
+
run) PROMPT="/loom-works:run-task ${TASK}" ;;
|
|
373
365
|
esac
|
|
374
366
|
if [[ "${PROMPT_KIND:-}" != "none" && -n "${LOOM_DECK_ENTER_PROMPT:-}" ]]; then
|
|
375
367
|
PROMPT="${LOOM_DECK_ENTER_PROMPT//\{TASK\}/${TASK}}"
|