@lamemind/loom-deck 0.31.0 → 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/width.js +55 -4
- package/package.json +1 -1
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/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