@lamemind/loom-deck 0.15.0 → 0.17.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 +13 -3
- package/dist/cli.js +149 -35
- package/dist/session-list.js +62 -0
- package/dist/task-edit.js +5 -5
- package/dist/task-index.js +16 -2
- package/dist/tasks.js +17 -5
- package/dist/view.js +9 -4
- package/dist/viewport.js +20 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,12 +4,16 @@ Deck TUI (Ink) **per-progetto** della famiglia [loom](https://github.com/lamemin
|
|
|
4
4
|
|
|
5
5
|
Legge il `tasks.md` del progetto e, con un tasto (poi un click), **spawna** una tab
|
|
6
6
|
[Ptyxis](https://gitlab.gnome.org/chergert/ptyxis) che avvia una sessione Claude Code
|
|
7
|
-
già bound alla task via `LOOM_TASK`,
|
|
7
|
+
già bound alla task via `LOOM_TASK`, con un prompt di recap sullo stato della task.
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
↑↓ scegli la task → ⏎ → tab CC di fianco →
|
|
10
|
+
↑↓ scegli la task → ⏎ → tab CC di fianco → LOOM_TASK bound + recap stato task
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
Entrambi i prefissi del contratto loom entrano in lista: **`T`** (code task) e
|
|
14
|
+
**`D`** (doc task). Nessuna differenza di trattamento — il prompt iniziale è un
|
|
15
|
+
recap, non l'invocazione di una skill, quindi vale identico sulle due famiglie.
|
|
16
|
+
|
|
13
17
|
## Ruolo nella famiglia loom
|
|
14
18
|
|
|
15
19
|
`loom-deck` è un **client** con runtime proprio (TUI Ink) che **consuma** il contratto
|
|
@@ -193,6 +197,11 @@ p priorità s stato i id
|
|
|
193
197
|
|
|
194
198
|
Partendo da catena vuota, digitare `ppi` produce `[pri ↓, id ↑]`. Il ciclo parte
|
|
195
199
|
sempre **dallo stato corrente**, che il modale mostra dal vivo mentre digiti.
|
|
200
|
+
|
|
201
|
+
Sull'**id** il confronto è numerico (`T9` prima di `T10`, non lessicografico) e i
|
|
202
|
+
due prefissi sono blocchi distinti — le `D` in coda alle `T`: i counter `T` e `D`
|
|
203
|
+
sono separati nel contratto loom, quindi `T01` e `D01` non sono confrontabili
|
|
204
|
+
come numeri soli.
|
|
196
205
|
A parità su tutte le chiavi decide sempre l'`id` (confronto **numerico**: `T9`
|
|
197
206
|
prima di `T10`) → l'ordine è deterministico, mai instabile fra un refresh e l'altro.
|
|
198
207
|
|
|
@@ -234,7 +243,8 @@ npx @lamemind/loom-deck
|
|
|
234
243
|
scripts/deck-run T18
|
|
235
244
|
```
|
|
236
245
|
|
|
237
|
-
Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude '
|
|
246
|
+
Apre una tab Ptyxis nella window attiva con `LOOM_TASK=T18 claude 'recap stato task T18'`
|
|
247
|
+
(prompt override-abile via `LOOM_DECK_ENTER_PROMPT`, placeholder `{TASK}`).
|
|
238
248
|
|
|
239
249
|
## Sviluppo (TUI Ink)
|
|
240
250
|
|
package/dist/cli.js
CHANGED
|
@@ -10,10 +10,10 @@ import { dirname, join } from 'node:path';
|
|
|
10
10
|
import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from './tasks.js';
|
|
11
11
|
import { discoverProjectSessions } from './sessions.js';
|
|
12
12
|
import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions, selectedRow, DEFAULT_OPTIONS, MIN_QUERY, } from './search.js';
|
|
13
|
-
import { appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
14
|
-
import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, selectedSession, } from './session-list.js';
|
|
13
|
+
import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
14
|
+
import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, rowLabel, selectedSession, stripProjectCore, } from './session-list.js';
|
|
15
15
|
import { launchLegend, loadIdentity, loadLaunch } from './config.js';
|
|
16
|
-
import { isCompact, layoutBudget, normalizeEmoji, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, wrapLines, wrapWithOffsets, } from './viewport.js';
|
|
16
|
+
import { isCompact, layoutBudget, normalizeEmoji, readerCapacity, searchListCapacity, searchPreviewCapacity, truncate, windowRange, wrapLines, wrapWithOffsets, } from './viewport.js';
|
|
17
17
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
18
18
|
import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
|
|
19
19
|
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
@@ -284,6 +284,7 @@ function useSessions(projectRoot) {
|
|
|
284
284
|
bindings: new Map(),
|
|
285
285
|
forkOf: new Map(),
|
|
286
286
|
pinned: new Map(),
|
|
287
|
+
notes: new Map(),
|
|
287
288
|
});
|
|
288
289
|
// T50 — pin/unpin scrive il sidecar e vuole feedback IMMEDIATO, non al
|
|
289
290
|
// prossimo tick del poll (1.5s): la reload è esposta via ref così il toggle la
|
|
@@ -300,23 +301,25 @@ function useSessions(projectRoot) {
|
|
|
300
301
|
}
|
|
301
302
|
catch {
|
|
302
303
|
sessions = [];
|
|
303
|
-
index = { bindings: new Map(), forkOf: new Map(), pinned: new Map() };
|
|
304
|
+
index = { bindings: new Map(), forkOf: new Map(), pinned: new Map(), notes: new Map() };
|
|
304
305
|
}
|
|
305
|
-
const { bindings, forkOf, pinned } = index;
|
|
306
|
-
// La signature copre anche fork e
|
|
307
|
-
// pin appena
|
|
308
|
-
// re-render come farebbe un binding nuovo.
|
|
306
|
+
const { bindings, forkOf, pinned, notes } = index;
|
|
307
|
+
// La signature copre anche fork, pin e note: un record di lineage, un
|
|
308
|
+
// toggle di pin o una nota appena scritta cambiano la lista renderizzata,
|
|
309
|
+
// quindi devono forzare il re-render come farebbe un binding nuovo.
|
|
309
310
|
const sig = sessions.map((s) => `${s.sessionId}:${s.ts}`).join('|') +
|
|
310
311
|
'#' +
|
|
311
312
|
[...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',') +
|
|
312
313
|
'#' +
|
|
313
314
|
[...forkOf.entries()].map(([k, v]) => `${k}<${v}`).sort().join(',') +
|
|
314
315
|
'#' +
|
|
315
|
-
[...pinned.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',')
|
|
316
|
+
[...pinned.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',') +
|
|
317
|
+
'#' +
|
|
318
|
+
[...notes.entries()].map(([k, v]) => `${k}"${v}`).sort().join(',');
|
|
316
319
|
if (sig === lastSig)
|
|
317
320
|
return;
|
|
318
321
|
lastSig = sig;
|
|
319
|
-
setState({ sessions, bindings, forkOf, pinned });
|
|
322
|
+
setState({ sessions, bindings, forkOf, pinned, notes });
|
|
320
323
|
};
|
|
321
324
|
reloadRef.current = reload;
|
|
322
325
|
reload();
|
|
@@ -336,12 +339,6 @@ function useTaskDetail(tasksDir, id) {
|
|
|
336
339
|
}, [tasksDir, id]);
|
|
337
340
|
return detail;
|
|
338
341
|
}
|
|
339
|
-
// Collassa gli spazi (description multi-paragrafo → blocco unico wrappabile) e
|
|
340
|
-
// tronca: il pane resta compatto e d'altezza prevedibile sotto la lista.
|
|
341
|
-
function truncate(s, n) {
|
|
342
|
-
const flat = s.replace(/\s+/g, ' ').trim();
|
|
343
|
-
return flat.length > n ? flat.slice(0, n - 1).trimEnd() + '…' : flat;
|
|
344
|
-
}
|
|
345
342
|
// Normalizzazione larghezza glifi → `normalizeEmoji` in viewport.ts.
|
|
346
343
|
//
|
|
347
344
|
// Sostituisce il precedente `forceEmojiWidth`, che aveva l'intuizione giusta
|
|
@@ -406,11 +403,28 @@ function relTime(ts) {
|
|
|
406
403
|
return `${hr}h`;
|
|
407
404
|
return `${Math.floor(hr / 24)}d`;
|
|
408
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Ripulisce un chunk di stdin prima di scriverlo in un campo di testo.
|
|
408
|
+
*
|
|
409
|
+
* `useInput` consegna il CHUNK letto da stdin, non un tasto: un incollaggio — o
|
|
410
|
+
* una raffica piu' veloce di una read — arriva come stringa unica, byte di
|
|
411
|
+
* controllo compresi. Non si vedono a schermo, ma Ink li conta nella larghezza
|
|
412
|
+
* della riga, e in un campo che finisce su disco (la nota, T53) resterebbero
|
|
413
|
+
* li' per sempre. Le newline diventano SPAZIO invece di sparire: incollare due
|
|
414
|
+
* righe deve separare le parole, non fonderle.
|
|
415
|
+
*/
|
|
416
|
+
function sanitizeTyped(s) {
|
|
417
|
+
return s.replace(/[\r\n]/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '');
|
|
418
|
+
}
|
|
409
419
|
const META_KEYS = ['Priority', 'Size', 'Estimated Time', 'Progress'];
|
|
410
420
|
function Deck({ cwd, tasksPath, tasksDir }) {
|
|
411
421
|
const { exit } = useApp();
|
|
412
422
|
const { tasks, loadError } = useTasks(tasksPath);
|
|
413
|
-
|
|
423
|
+
// `notes` esce dall'indice come `sessionNotes`: in questo componente `note` è
|
|
424
|
+
// già la riga di STATO in fondo al frame (il feedback di un'azione). Due
|
|
425
|
+
// concetti diversi a una lettera di distanza sarebbero una trappola di
|
|
426
|
+
// lettura — e di scrittura, visto che `setNote` compare in quasi ogni ramo.
|
|
427
|
+
const { sessions, bindings, forkOf, pinned, notes: sessionNotes, reload: reloadSessions, } = useSessions(cwd);
|
|
414
428
|
const [focus, setFocus] = useState('tasks');
|
|
415
429
|
// T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
|
|
416
430
|
// (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
|
|
@@ -426,6 +440,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
426
440
|
// T30 create · T39 sort/filter.
|
|
427
441
|
const [mode, setMode] = useState('normal');
|
|
428
442
|
const [draft, setDraft] = useState('');
|
|
443
|
+
// T53 — bozza della nota sulla conversazione selezionata. Si apre PRECARICATA
|
|
444
|
+
// con la nota esistente: annotare due volte è quasi sempre correggere, non
|
|
445
|
+
// riscrivere da zero, e un campo vuoto costringerebbe a ridigitare tutto per
|
|
446
|
+
// cambiare una parola. Confermare il campo vuoto cancella la nota.
|
|
447
|
+
const [noteDraft, setNoteDraft] = useState('');
|
|
429
448
|
// T39 — vista corrente (filtri + sort) e sua fotografia all'apertura di un
|
|
430
449
|
// modale: la lista si aggiorna dal vivo, quindi `esc` deve poter ripristinare.
|
|
431
450
|
const [view, setView] = useState(() => loadView(cwd));
|
|
@@ -456,6 +475,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
456
475
|
const launch = useMemo(() => loadLaunch(cwd), [cwd]);
|
|
457
476
|
// Identità (T37): titolo delle tab terminale spawnate col tasto `t`.
|
|
458
477
|
const identity = useMemo(() => loadIdentity(cwd), [cwd]);
|
|
478
|
+
// T53 — `<owner> <name>`: il core che ogni titolo di tab porta, quindi la
|
|
479
|
+
// colonna costante da togliere quando serve spazio. Hoistato qui perché ora
|
|
480
|
+
// lo consumano DUE schermate (lista e ricerca): calcolarlo su ogni call site
|
|
481
|
+
// è il modo in cui le due smettono di togliere la stessa cosa.
|
|
482
|
+
const projectCore = identity ? `${identity.owner} ${identity.name}` : null;
|
|
459
483
|
// La vista è una trasformazione DERIVATA, applicata a valle del load: il
|
|
460
484
|
// polling di tasks.md continua a funzionare senza saperne nulla.
|
|
461
485
|
const { visible: viewTasks, hidden: hiddenTasks } = useMemo(() => applyView(tasks, view), [tasks, view]);
|
|
@@ -580,6 +604,38 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
580
604
|
});
|
|
581
605
|
child.on('error', () => setNote(`⚠ create-task: '${CLAUDE_CMD}' non lanciabile`));
|
|
582
606
|
}
|
|
607
|
+
// T53 — apertura del modale nota sulla conversazione selezionata. Come
|
|
608
|
+
// `openEdit`, la bozza parte dal valore ATTUALE: annotare una seconda volta è
|
|
609
|
+
// quasi sempre correggere, e ripartire da vuoto costringerebbe a ridigitare
|
|
610
|
+
// tutto per cambiare una parola.
|
|
611
|
+
function openNote() {
|
|
612
|
+
if (!selSessionId)
|
|
613
|
+
return;
|
|
614
|
+
setNoteDraft(sessionNotes.get(selSessionId) ?? '');
|
|
615
|
+
setNote('');
|
|
616
|
+
setMode('note');
|
|
617
|
+
}
|
|
618
|
+
// T53 — ⏎ nel modale nota: scrive il sidecar e ricarica subito, senza
|
|
619
|
+
// attendere il tick del poll (stesso feedback immediato del pin).
|
|
620
|
+
//
|
|
621
|
+
// Il campo VUOTO non è un annullamento: è la CANCELLAZIONE della nota. Sono
|
|
622
|
+
// due intenzioni diverse e hanno due tasti diversi — `esc` lascia tutto com'è,
|
|
623
|
+
// `⏎` su campo svuotato toglie la nota. Trattare il vuoto come un no-op (come
|
|
624
|
+
// fa `submitCreate`, dove però una task senza titolo non esiste) renderebbe
|
|
625
|
+
// impossibile disannotare una conversazione se non con un editor sul JSONL.
|
|
626
|
+
function submitNote() {
|
|
627
|
+
const sid = selSessionId;
|
|
628
|
+
const text = noteDraft.trim();
|
|
629
|
+
setMode('normal');
|
|
630
|
+
setNoteDraft('');
|
|
631
|
+
if (!sid)
|
|
632
|
+
return;
|
|
633
|
+
appendNote(cwd, sid, text);
|
|
634
|
+
reloadSessions();
|
|
635
|
+
setNote(text
|
|
636
|
+
? `✎ nota su ${sid.slice(0, 8)}: "${truncate(text, 40)}"`
|
|
637
|
+
: `✎ nota rimossa da ${sid.slice(0, 8)}`);
|
|
638
|
+
}
|
|
583
639
|
// T41 — apertura dell'edit: la bozza parte dai valori ATTUALI della task, non
|
|
584
640
|
// da default. La priorità arriva dal glifo di tasks.md (già in `selTask`), lo
|
|
585
641
|
// stato dal suo glifo Prog; il progresso arbitrario dal campo `Progress` del
|
|
@@ -713,7 +769,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
713
769
|
// (T39), che cicla sui caratteri del chunk invece di leggerlo intero.
|
|
714
770
|
function typeIntoField(chunk) {
|
|
715
771
|
setNote('');
|
|
716
|
-
const clean =
|
|
772
|
+
const clean = sanitizeTyped;
|
|
717
773
|
const parts = chunk.split('\t');
|
|
718
774
|
let field = searchField;
|
|
719
775
|
const add = { hash: '', query: '' };
|
|
@@ -830,6 +886,43 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
830
886
|
}
|
|
831
887
|
return;
|
|
832
888
|
}
|
|
889
|
+
// T53 — modale nota. Come create, cattura il testo e corto-circuita la
|
|
890
|
+
// navigazione (qui `q` è una lettera da scrivere, non il quit).
|
|
891
|
+
if (mode === 'note') {
|
|
892
|
+
if (key.escape) {
|
|
893
|
+
setMode('normal');
|
|
894
|
+
setNoteDraft('');
|
|
895
|
+
setNote('N → nota annullata');
|
|
896
|
+
}
|
|
897
|
+
else if (key.return) {
|
|
898
|
+
submitNote();
|
|
899
|
+
}
|
|
900
|
+
else if (key.ctrl && input === 'u') {
|
|
901
|
+
// Svuota il campo in un colpo. NON è una scorciatoia di comodo: il
|
|
902
|
+
// backspace tenuto premuto cancella UN carattere per CHUNK letto da
|
|
903
|
+
// stdin, non per pressione (`useInput` consegna il chunk, e per una
|
|
904
|
+
// raffica di DEL Ink alza `key.backspace` una volta sola) — misurato,
|
|
905
|
+
// 30 pressioni → 2 caratteri. Siccome «campo vuoto» qui è l'unico modo
|
|
906
|
+
// di CANCELLARE una nota, dipendere dal backspace renderebbe
|
|
907
|
+
// l'operazione praticamente non eseguibile. `^U` è il kill-line delle
|
|
908
|
+
// shell, quindi il gesto è già nelle dita di chi usa un terminale.
|
|
909
|
+
setNoteDraft('');
|
|
910
|
+
}
|
|
911
|
+
else if (key.backspace || key.delete) {
|
|
912
|
+
setNoteDraft((d) => d.slice(0, -1));
|
|
913
|
+
}
|
|
914
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
915
|
+
// Sanificazione dei byte di controllo, come `typeIntoField` della
|
|
916
|
+
// ricerca: `useInput` consegna il CHUNK letto da stdin, quindi un
|
|
917
|
+
// incollaggio porta dentro newline e control char. Invisibili a schermo
|
|
918
|
+
// ma contati da Ink nella larghezza della riga — e qui finirebbero
|
|
919
|
+
// scritti su disco, dove resterebbero a sporcare la riga per sempre.
|
|
920
|
+
// Le newline diventano spazio: incollare due righe deve separare le
|
|
921
|
+
// parole, non fonderle.
|
|
922
|
+
setNoteDraft((d) => d + sanitizeTyped(input));
|
|
923
|
+
}
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
833
926
|
// T39 — modale sort a grammatica libera: la SEQUENZA di tasti È la chain.
|
|
834
927
|
// Digitare `ppi` = p(asc) p(desc) i(asc) → [pri desc, id asc]. La lista si
|
|
835
928
|
// riordina dal vivo a ogni pressione.
|
|
@@ -1061,6 +1154,23 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1061
1154
|
setNote(`${isPinned ? 'unpin' : '📌 pin'} ${selSessionId.slice(0, 8)}`);
|
|
1062
1155
|
}
|
|
1063
1156
|
}
|
|
1157
|
+
else if (input === 'N') {
|
|
1158
|
+
// T53 — nota sulla conversazione selezionata. MAIUSCOLA perché apre un
|
|
1159
|
+
// modale: nel deck le minuscole sono azioni immediate (`f` fork, `p` pin,
|
|
1160
|
+
// `t` term, `c` claude) e le maiuscole aprono un box (`C` create, `E`
|
|
1161
|
+
// edit, `S` sort, `F` filtri). Vincolo di focus identico a `p`: vale anche
|
|
1162
|
+
// su una pinnata STALE, perché annotare «questa non c'è più, era X» è
|
|
1163
|
+
// proprio il caso in cui una nota serve.
|
|
1164
|
+
if (focus !== 'sessions') {
|
|
1165
|
+
setNote('N → nota: seleziona una sessione (←→ per il pane)');
|
|
1166
|
+
}
|
|
1167
|
+
else if (!selSessionId) {
|
|
1168
|
+
setNote('N → nessuna sessione da annotare');
|
|
1169
|
+
}
|
|
1170
|
+
else {
|
|
1171
|
+
openNote();
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1064
1174
|
else if (input === 't') {
|
|
1065
1175
|
const title = identity ? `🖥️ ${identity.owner} ${identity.name} [term]` : null;
|
|
1066
1176
|
const child = spawnTerminal(cwd, title);
|
|
@@ -1152,7 +1262,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1152
1262
|
ts: sessions.find((s) => s.sessionId === h.sessionId)?.ts ?? 0,
|
|
1153
1263
|
};
|
|
1154
1264
|
}
|
|
1155
|
-
return (_jsx(SearchScreen, { preview: preview, hash: searchHash, query: searchQuery, field: searchField, opts: searchOpts, result: searchResult, rows: searchRows.slice(win.start, win.end), selectedKey: searchSelKey, selectedKind: selectedRow(searchRows, searchSelKey)?.kind ?? null, above: win.start, below: searchRows.length - win.end, capacity: searchCap, bindings: bindings, pinned: pinned,
|
|
1265
|
+
return (_jsx(SearchScreen, { preview: preview, hash: searchHash, query: searchQuery, field: searchField, opts: searchOpts, result: searchResult, rows: searchRows.slice(win.start, win.end), selectedKey: searchSelKey, selectedKind: selectedRow(searchRows, searchSelKey)?.kind ?? null, above: win.start, below: searchRows.length - win.end, capacity: searchCap, bindings: bindings, pinned: pinned, sessionNotes: sessionNotes, projectCore: projectCore, columns: columns, note: note }));
|
|
1156
1266
|
}
|
|
1157
1267
|
// ── Budget d'altezza ────────────────────────────────────────────────────
|
|
1158
1268
|
// Il frame deve restare sotto `rows`, sempre: oltre quella soglia Ink smette
|
|
@@ -1192,7 +1302,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1192
1302
|
if (budget.compact) {
|
|
1193
1303
|
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 ?? 'spot', " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
|
|
1194
1304
|
}
|
|
1195
|
-
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 === '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 \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : canResume ? 'resume' : '—', canResume ? ' · f fork' : '', canPin ? ' · p pin' : '', " \u00B7 ^F cerca \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 t term \u00B7 c claude \u00B7 q esci \u00B7 focus:", ' ', _jsx(Text, { color: "cyan", children: focus })] })), mode === 'normal' && launch.length > 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["launch ", 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 === '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 })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
|
|
1305
|
+
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 \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : canResume ? 'resume' : '—', canResume ? ' · f fork' : '', canPin ? ' · p pin · N nota' : '', " \u00B7 ^F cerca \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 t term \u00B7 c claude \u00B7 q esci \u00B7 focus:", ' ', _jsx(Text, { color: "cyan", children: focus })] })), mode === 'normal' && launch.length > 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["launch ", 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 })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
|
|
1196
1306
|
}
|
|
1197
1307
|
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
1198
1308
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
@@ -1267,13 +1377,7 @@ function searchTitleWidth(columns) {
|
|
|
1267
1377
|
* conversazione e non un'altra.
|
|
1268
1378
|
*/
|
|
1269
1379
|
function conversationLabel(s, core, bound) {
|
|
1270
|
-
let t = s.title;
|
|
1271
|
-
if (core) {
|
|
1272
|
-
const at = t.indexOf(core);
|
|
1273
|
-
if (at >= 0)
|
|
1274
|
-
t = t.slice(at + core.length);
|
|
1275
|
-
}
|
|
1276
|
-
t = t.replace(/^[\s·]+/, '').trim();
|
|
1380
|
+
let t = stripProjectCore(s.title, core);
|
|
1277
1381
|
if (bound && t === bound)
|
|
1278
1382
|
t = '';
|
|
1279
1383
|
return t || s.firstPrompt || '(senza titolo)';
|
|
@@ -1309,14 +1413,21 @@ function SearchListHeader({ result, query, above, below, }) {
|
|
|
1309
1413
|
}
|
|
1310
1414
|
return (_jsxs(Text, { bold: true, wrap: "truncate-end", children: [result.shown, " occorrenze in ", result.sessionCount, " conversazioni", result.hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", result.hidden, " oltre il cap"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }));
|
|
1311
1415
|
}
|
|
1312
|
-
function SearchScreen({ preview, hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, projectCore, columns, note, }) {
|
|
1416
|
+
function SearchScreen({ preview, hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, sessionNotes, projectCore, columns, note, }) {
|
|
1313
1417
|
const enter = selectedKind === 'session' ? 'resume' : selectedKind === 'hit' ? 'leggi' : '—';
|
|
1314
1418
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["ricerca \u00B7 ", _jsx(Text, { color: "yellow", children: "tab" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " naviga \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " ", enter, " \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "hash " }), _jsx(Text, { color: field === 'hash' ? 'yellow' : undefined, children: hash }), field === 'hash' ? _jsx(Text, { inverse: true, children: " " }) : null, !hash ? _jsx(Text, { dimColor: true, children: " (vuoto = tutte le conversazioni)" }) : null] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "chiave " }), _jsx(Text, { color: field === 'query' ? 'yellow' : undefined, children: query }), field === 'query' ? _jsx(Text, { inverse: true, children: " " }) : null] }), _jsx(ToggleHint, { opts: opts })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsx(SearchListHeader, { result: result, query: query, above: above, below: below }), rows.slice(0, Math.max(0, capacity)).map((row) => {
|
|
1315
1419
|
const sel = row.key === selectedKey;
|
|
1316
1420
|
if (row.kind === 'session') {
|
|
1317
1421
|
const s = row.session;
|
|
1318
1422
|
const bound = bindings.get(s.sessionId);
|
|
1319
|
-
|
|
1423
|
+
const rowNote = sessionNotes.get(s.sessionId);
|
|
1424
|
+
const noteShown = rowNote ? truncate(rowNote, 24) : '';
|
|
1425
|
+
// `+3` = i due caporali e lo spazio che li separa dall'etichetta.
|
|
1426
|
+
// Il pavimento non è cosmetico: senza, un terminale stretto manda
|
|
1427
|
+
// l'argomento di `truncate` sotto zero e `slice` conta dalla FINE
|
|
1428
|
+
// della stringa — mostrerebbe la coda del titolo, in silenzio.
|
|
1429
|
+
const restWidth = Math.max(8, searchTitleWidth(columns) - (noteShown ? noteShown.length + 3 : 0));
|
|
1430
|
+
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), noteShown ? _jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", noteShown, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(noteShown), children: truncate(conversationLabel(s, projectCore, bound), restWidth) }), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
|
|
1320
1431
|
}
|
|
1321
1432
|
const h = row.hit;
|
|
1322
1433
|
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: String(h.idx).padStart(4) }), ' ', _jsx(Text, { color: KIND_COLOR[h.kind], children: KIND_TAG[h.kind] }), " ", h.excerpt] }, row.key));
|
|
@@ -1376,7 +1487,7 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
|
|
|
1376
1487
|
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", forceEmojiWidth(task.pri), " ", displayProg(task.prog), " ", task.desc, n > 0 ? ` (${n})` : ''] }, task.id));
|
|
1377
1488
|
})), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
|
|
1378
1489
|
}
|
|
1379
|
-
function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, }) {
|
|
1490
|
+
function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, sessionNotes, projectCore, }) {
|
|
1380
1491
|
return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Sessions \u00B7 ", parentLabel, " (", total, ")", pinnedCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 \uD83D\uDCCC", pinnedCount] }) : null, hidden > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 +", hidden, " pi\u00F9 vecchie"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isSpot ? 'nessuna sessione libera' : 'nessuna sessione legata a questa task' })) : (rows.map((row, i) => {
|
|
1381
1492
|
// T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
|
|
1382
1493
|
// box pesante (coerente con lo styling delle Done dimmate).
|
|
@@ -1387,7 +1498,7 @@ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, s
|
|
|
1387
1498
|
// T50 — pin stale: transcript sparito, nessuna Session da mostrare.
|
|
1388
1499
|
// Riga navigabile e spinnabile (`p`), marcata, mai un crash.
|
|
1389
1500
|
if (row.kind === 'pinned' && row.stale) {
|
|
1390
|
-
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, 8) })] }, row.sessionId));
|
|
1501
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, 8) }), sessionNotes.get(row.sessionId) ? (_jsxs(Text, { color: "yellow", children: [" \u00AB", truncate(sessionNotes.get(row.sessionId), 30), "\u00BB"] })) : null] }, row.sessionId));
|
|
1391
1502
|
}
|
|
1392
1503
|
const s = row.session; // non-stale → session presente
|
|
1393
1504
|
const isPinnedRow = row.kind === 'pinned';
|
|
@@ -1395,8 +1506,11 @@ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, s
|
|
|
1395
1506
|
// righe sarebbero identiche a occhio. `⑂` sta PRIMA del titolo, dove
|
|
1396
1507
|
// la troncatura non arriva mai.
|
|
1397
1508
|
const forked = forkOf.has(s.sessionId);
|
|
1398
|
-
|
|
1399
|
-
|
|
1509
|
+
// T53 — con una nota il prefisso di progetto sparisce e le sue colonne
|
|
1510
|
+
// passano alla nota; senza, il titolo resta com'è (vedi `rowLabel`).
|
|
1511
|
+
const label = rowLabel(s.title, sessionNotes.get(s.sessionId), projectCore, forked ? 42 : 44);
|
|
1512
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" })) : isSpot ? (_jsx(Text, { dimColor: true, children: "\u25CB" })) : (_jsx(Text, { color: "green", children: "\uD83D\uDD17" })), ' ', forked ? _jsx(Text, { color: "magenta", children: "\u2442 " }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
|
|
1513
|
+
})), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null, note: sessionNotes.get(detail.sessionId) ?? '' })) : null] }));
|
|
1400
1514
|
}
|
|
1401
1515
|
// T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
|
|
1402
1516
|
// task. Tutti i campi vengono dal parse già cached dell'adapter (mtime-keyed):
|
|
@@ -1406,11 +1520,11 @@ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, s
|
|
|
1406
1520
|
// — senza, il titolo È già il primo prompt e la riga lo duplicherebbe (D4
|
|
1407
1521
|
// preflight). Le righe rese non superano mai il riservato dal budget
|
|
1408
1522
|
// (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
|
|
1409
|
-
function SessionDetailPane({ s, firstLines, lastLines, columns, origin, }) {
|
|
1523
|
+
function SessionDetailPane({ s, firstLines, lastLines, columns, origin, note, }) {
|
|
1410
1524
|
const width = detailTextWidth(columns);
|
|
1411
1525
|
const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
|
|
1412
1526
|
const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
|
|
1413
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [
|
|
1527
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), origin ? ` · ⑂ da ${origin.slice(0, 8)}` : ''] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
|
|
1414
1528
|
}
|
|
1415
1529
|
/**
|
|
1416
1530
|
* Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
|
package/dist/session-list.js
CHANGED
|
@@ -14,6 +14,68 @@
|
|
|
14
14
|
// - Trap T39: la selezione è KEYED SU sessionId, non su indice posizionale. La
|
|
15
15
|
// lista è una vista trasformata (due gruppi + separatore): un indice grezzo
|
|
16
16
|
// identificherebbe la riga sbagliata dopo un pin/cambio-contesto.
|
|
17
|
+
import { truncate } from './viewport.js';
|
|
18
|
+
// ── T53 · etichetta di riga ────────────────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* Toglie dal titolo il core `<owner> <name>` del progetto e la punteggiatura di
|
|
21
|
+
* giunzione che resta appesa davanti.
|
|
22
|
+
*
|
|
23
|
+
* Il titolo di una sessione È la label della tab Ptyxis (`🧵 LOCAL loom-works ·
|
|
24
|
+
* T52`): dentro un deck che mostra un progetto solo, il core è una COLONNA
|
|
25
|
+
* COSTANTE ripetuta su ogni riga. Toglierlo libera le colonne per ciò che
|
|
26
|
+
* distingue davvero una conversazione dall'altra.
|
|
27
|
+
*
|
|
28
|
+
* `core` null (file config assente o senza identità) → titolo intatto: senza
|
|
29
|
+
* sapere cosa togliere non si indovina, si lascia stare.
|
|
30
|
+
*/
|
|
31
|
+
export function stripProjectCore(title, core) {
|
|
32
|
+
let t = title;
|
|
33
|
+
if (core) {
|
|
34
|
+
const at = t.indexOf(core);
|
|
35
|
+
if (at >= 0)
|
|
36
|
+
t = t.slice(at + core.length);
|
|
37
|
+
}
|
|
38
|
+
return t.replace(/^[\s·]+/, '').trim();
|
|
39
|
+
}
|
|
40
|
+
/** Colonne minime perché un residuo dopo la nota valga la riga: sotto questa
|
|
41
|
+
* soglia si vedrebbero due lettere e un'ellissi, cioè rumore. */
|
|
42
|
+
const MIN_REST = 6;
|
|
43
|
+
/** Colonne garantite alla nota quando c'è anche un residuo da mostrare: la nota
|
|
44
|
+
* è la parte scelta da un umano, non è lei a cedere il posto per prima. */
|
|
45
|
+
const MIN_NOTE = 14;
|
|
46
|
+
/**
|
|
47
|
+
* Cosa scrivere sulla riga di una conversazione, dentro `budget` colonne.
|
|
48
|
+
*
|
|
49
|
+
* Due regimi, ed è una decisione deliberata che NON siano lo stesso:
|
|
50
|
+
*
|
|
51
|
+
* - **senza nota** → il titolo resta INTATTO, prefisso di progetto incluso.
|
|
52
|
+
* Togliere il core qui lascerebbe `T52` o il nulla (i titoli di tab non
|
|
53
|
+
* contengono altro), cioè meno informazione di prima, non più spazio.
|
|
54
|
+
* - **con nota** → il core se ne va: la nota dice già quale conversazione è,
|
|
55
|
+
* quindi le colonne del prefisso passano a lei. Il residuo (tipicamente la
|
|
56
|
+
* task, `T52`) segue dimmato se ci sta; se non resta nulla va benissimo.
|
|
57
|
+
*
|
|
58
|
+
* Il budget si divide dando la precedenza alla nota, ma senza affamare il
|
|
59
|
+
* residuo: con entrambi presenti la nota cede fino a `MIN_NOTE` per lasciare
|
|
60
|
+
* spazio al residuo, e il residuo sparisce del tutto sotto `MIN_REST` invece di
|
|
61
|
+
* ridursi a un moncone.
|
|
62
|
+
*/
|
|
63
|
+
export function rowLabel(title, note, core, budget) {
|
|
64
|
+
if (!note)
|
|
65
|
+
return { note: '', rest: truncate(title, Math.max(0, budget)) };
|
|
66
|
+
// 2 colonne per i caporali « », 1 per lo spazio prima del residuo.
|
|
67
|
+
const rest = stripProjectCore(title, core);
|
|
68
|
+
const noteBudget = Math.max(0, budget - 2);
|
|
69
|
+
if (!rest)
|
|
70
|
+
return { note: truncate(note, noteBudget), rest: '' };
|
|
71
|
+
// Il `min` col budget totale non è ridondante: su un pane strettissimo
|
|
72
|
+
// `MIN_NOTE` supererebbe le colonne disponibili e la riga andrebbe a capo —
|
|
73
|
+
// che è precisamente il difetto che ogni larghezza qui dentro esiste per
|
|
74
|
+
// evitare (una riga wrappata sfonda il budget d'ALTEZZA, non solo l'estetica).
|
|
75
|
+
const shownNote = truncate(note, Math.min(noteBudget, Math.max(MIN_NOTE, noteBudget - MIN_REST - 1)));
|
|
76
|
+
const restBudget = noteBudget - shownNote.length - 1;
|
|
77
|
+
return { note: shownNote, rest: restBudget >= MIN_REST ? truncate(rest, restBudget) : '' };
|
|
78
|
+
}
|
|
17
79
|
export function assembleSessionList(childSessions, allSessions, pinned, maxContext) {
|
|
18
80
|
// Le pinnate si risolvono contro TUTTE le sessioni del progetto, non solo le
|
|
19
81
|
// figlie del parent: una pinnata può appartenere a un'altra task. Assente
|
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 { findTaskFile } from './tasks.js';
|
|
6
|
+
import { TASK_ID_RE, findTaskFile } 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).
|
|
@@ -81,11 +81,11 @@ export function initialDetail(current, prog, date = today()) {
|
|
|
81
81
|
* restano i token grezzi originali: nessun re-flow della tabella, il diff resta
|
|
82
82
|
* di una riga. `ok:false` = id assente → il chiamante non scrive nulla.
|
|
83
83
|
*
|
|
84
|
-
* L'id deve rispettare
|
|
85
|
-
*
|
|
86
|
-
* di separatore, riscrivendone le
|
|
84
|
+
* L'id deve rispettare `TASK_ID_RE` (importato da tasks.ts — stesso gate di
|
|
85
|
+
* `parseTasks`, non una copia): senza, un id arbitrario matcherebbe la riga di
|
|
86
|
+
* HEADER (`| ID | Pri | K | Prog |`) o quella di separatore, riscrivendone le
|
|
87
|
+
* celle e sfondando la tabella.
|
|
87
88
|
*/
|
|
88
|
-
const TASK_ID_RE = /^T\d+$/;
|
|
89
89
|
export function updateTasksMdRow(content, id, priGlyph, progGlyph) {
|
|
90
90
|
if (!TASK_ID_RE.test(id))
|
|
91
91
|
return { content, ok: false };
|
package/dist/task-index.js
CHANGED
|
@@ -15,6 +15,10 @@ export function appendTaskBinding(projectRoot, sessionId, taskId) {
|
|
|
15
15
|
export function appendPin(projectRoot, sessionId, pinned) {
|
|
16
16
|
appendSessionRecord(projectRoot, { sessionId, pinned });
|
|
17
17
|
}
|
|
18
|
+
/** T53 — scrive (o cancella, con `note` vuota) la nota di una conversazione. */
|
|
19
|
+
export function appendNote(projectRoot, sessionId, note) {
|
|
20
|
+
appendSessionRecord(projectRoot, { sessionId, note });
|
|
21
|
+
}
|
|
18
22
|
// Una sola lettura del JSONL per entrambe le mappe: il deck poll-a l'indice a
|
|
19
23
|
// ogni tick, leggere il file due volte raddoppierebbe l'I/O per nulla.
|
|
20
24
|
// Last-wins per campo (un re-pin dello stesso sessionId sovrascrive), e i due
|
|
@@ -24,12 +28,13 @@ export function loadSessionIndex(projectRoot) {
|
|
|
24
28
|
const bindings = new Map();
|
|
25
29
|
const forkOf = new Map();
|
|
26
30
|
const pinned = new Map();
|
|
31
|
+
const notes = new Map();
|
|
27
32
|
let content;
|
|
28
33
|
try {
|
|
29
34
|
content = readFileSync(taskIndexPath(projectRoot), 'utf8');
|
|
30
35
|
}
|
|
31
36
|
catch {
|
|
32
|
-
return { bindings, forkOf, pinned };
|
|
37
|
+
return { bindings, forkOf, pinned, notes };
|
|
33
38
|
}
|
|
34
39
|
let order = 0; // posizione crescente dei record pinned → rango di pin (D2)
|
|
35
40
|
for (const line of content.split('\n')) {
|
|
@@ -51,10 +56,19 @@ export function loadSessionIndex(projectRoot) {
|
|
|
51
56
|
else
|
|
52
57
|
pinned.delete(d.sessionId);
|
|
53
58
|
}
|
|
59
|
+
// T53 — stesso last-wins: la stringa vuota è la CANCELLAZIONE, non una
|
|
60
|
+
// nota vuota da mostrare. Il `typeof` esclude i record senza il campo, che
|
|
61
|
+
// non devono toccare una nota scritta da un record precedente.
|
|
62
|
+
if (typeof d.note === 'string') {
|
|
63
|
+
if (d.note)
|
|
64
|
+
notes.set(d.sessionId, d.note);
|
|
65
|
+
else
|
|
66
|
+
notes.delete(d.sessionId);
|
|
67
|
+
}
|
|
54
68
|
}
|
|
55
69
|
catch {
|
|
56
70
|
// riga corrotta → skip
|
|
57
71
|
}
|
|
58
72
|
}
|
|
59
|
-
return { bindings, forkOf, pinned };
|
|
73
|
+
return { bindings, forkOf, pinned, notes };
|
|
60
74
|
}
|
package/dist/tasks.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { normalizeEmoji } from './viewport.js';
|
|
4
|
+
/**
|
|
5
|
+
* Forma di un ID task: `T` (code) o `D` (doc) + numero. FONTE UNICA del gate —
|
|
6
|
+
* `parseTasks` lo usa per scartare header/separatore della tabella, `task-edit`
|
|
7
|
+
* per non riscrivere per sbaglio quelle stesse righe. Tenerlo in un posto solo
|
|
8
|
+
* evita che i due gate divergano (uno accetterebbe righe che l'altro rifiuta).
|
|
9
|
+
*/
|
|
10
|
+
export const TASK_ID_RE = /^[TD]\d+$/;
|
|
4
11
|
// D1 (preflight T20): default docs/tasks.md, override della docs-root via env
|
|
5
12
|
// LOOM_DECK_DOCS_ROOT (es. questo progetto usa `runtime`). No auto-detect.
|
|
6
13
|
export function resolveTasksPath(cwd = process.cwd()) {
|
|
@@ -13,9 +20,11 @@ export function resolveTasksDir(cwd = process.cwd()) {
|
|
|
13
20
|
return join(dirname(resolveTasksPath(cwd)), 'tasks');
|
|
14
21
|
}
|
|
15
22
|
// Estrae le righe `| Tnn | Pri | K | Prog | Task |` della Tasks Overview.
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
23
|
+
// Due prefissi ammessi (contratto plugin, counter separati): `T` = code task,
|
|
24
|
+
// `D` = doc task. Le D entrano in lista come le T perché `deck-run` NON inietta
|
|
25
|
+
// `run-task`: il prompt iniziale è un recap diretto ("recap stato task <id>"),
|
|
26
|
+
// valido identico su una doc task. Righe header/separatore non matchano `^[TD]\d+$`
|
|
27
|
+
// → scartate.
|
|
19
28
|
export function parseTasks(content) {
|
|
20
29
|
const tasks = [];
|
|
21
30
|
for (const line of content.split('\n')) {
|
|
@@ -25,7 +34,7 @@ export function parseTasks(content) {
|
|
|
25
34
|
const cells = t.split('|').map((c) => c.trim());
|
|
26
35
|
// cells[0] = '' (prima del primo |), cells[last] = '' (dopo l'ultimo |)
|
|
27
36
|
const id = cells[1];
|
|
28
|
-
if (
|
|
37
|
+
if (!TASK_ID_RE.test(id))
|
|
29
38
|
continue;
|
|
30
39
|
// Colonne overview: | ID | Pri | K | Prog | Task | → cells[2]=Pri (icona
|
|
31
40
|
// priorità), cells[4]=Prog. K (Kind, cells[3]) non serve al deck.
|
|
@@ -77,7 +86,10 @@ export function parseTaskDetail(id, content) {
|
|
|
77
86
|
let inDesc = false;
|
|
78
87
|
for (const line of content.split('\n')) {
|
|
79
88
|
if (!title && line.startsWith('# ')) {
|
|
80
|
-
|
|
89
|
+
// I due template scrivono H1 diversi — `# Task: …` (code) e
|
|
90
|
+
// `# Doc Task: …` (doc): entrambi i cappelli vanno via, il titolo mostrato
|
|
91
|
+
// è la descrizione, non la sua categoria (già data dall'id).
|
|
92
|
+
title = line.replace(/^#\s+/, '').replace(/^(?:Doc\s+)?Task:\s*/, '').trim();
|
|
81
93
|
continue;
|
|
82
94
|
}
|
|
83
95
|
if (line.startsWith('## ')) {
|
package/dist/view.js
CHANGED
|
@@ -53,11 +53,16 @@ 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
|
-
// parseTasks ammette
|
|
57
|
-
// il
|
|
56
|
+
// parseTasks ammette due prefissi con counter SEPARATI (`T` code, `D` doc) →
|
|
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.
|
|
61
|
+
// Un id fuori forma non deve far esplodere il comparator → coda con MAX_SAFE.
|
|
62
|
+
const ID_PREFIX_RANK = { T: 0, D: 1 };
|
|
58
63
|
export function idNum(id) {
|
|
59
|
-
const m = /^
|
|
60
|
-
return m ? Number(m[
|
|
64
|
+
const m = /^([TD])(\d+)$/.exec(id);
|
|
65
|
+
return m ? ID_PREFIX_RANK[m[1]] * 1_000_000 + Number(m[2]) : Number.MAX_SAFE_INTEGER;
|
|
61
66
|
}
|
|
62
67
|
function rankOf(task, key) {
|
|
63
68
|
if (key === 'pri')
|
package/dist/viewport.js
CHANGED
|
@@ -50,6 +50,25 @@ export function normalizeEmoji(s) {
|
|
|
50
50
|
}
|
|
51
51
|
return out;
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Collassa gli spazi (description multi-paragrafo → blocco unico) e tronca a
|
|
55
|
+
* `n` caratteri, con ellissi quando taglia.
|
|
56
|
+
*
|
|
57
|
+
* Vive qui e non nel modulo di render perché è misura di larghezza come il
|
|
58
|
+
* resto del file, e perché la cascata dell'etichetta di riga (`session-list.ts`)
|
|
59
|
+
* ne ha bisogno restando pura — importarla da `cli.tsx` tirerebbe dentro Ink e
|
|
60
|
+
* React in un modulo che si testa senza terminale.
|
|
61
|
+
*/
|
|
62
|
+
export function truncate(s, n) {
|
|
63
|
+
// Budget non positivo → stringa vuota, e va detto ESPLICITAMENTE: senza questa
|
|
64
|
+
// guardia `n = 0` produce `slice(0, -1)`, e un indice negativo in JS conta
|
|
65
|
+
// dalla FINE — restituirebbe quasi tutta la stringa invece di niente, cioè
|
|
66
|
+
// l'opposto del troncamento, in silenzio e proprio quando lo spazio manca.
|
|
67
|
+
if (n <= 0)
|
|
68
|
+
return '';
|
|
69
|
+
const flat = s.replace(/\s+/g, ' ').trim();
|
|
70
|
+
return flat.length > n ? flat.slice(0, n - 1).trimEnd() + '…' : flat;
|
|
71
|
+
}
|
|
53
72
|
/** Righe lasciate libere sotto il frame. La condizione di Ink è `>=`, quindi
|
|
54
73
|
* basterebbe 1; ne teniamo 1 come margine per l'a-capo del cursore. */
|
|
55
74
|
export const SLACK = 1;
|
|
@@ -75,6 +94,7 @@ const MIN_SESSION_ROWS = 3;
|
|
|
75
94
|
export const MODAL_HEIGHT = {
|
|
76
95
|
normal: 0,
|
|
77
96
|
create: 4, // marginTop + 2 bordi + 1 riga input
|
|
97
|
+
note: 4, // T53 — gemello di create: marginTop + 2 bordi + 1 riga input
|
|
78
98
|
sort: 5, // marginTop + 2 bordi + titolo + 1 riga catena
|
|
79
99
|
filter: 6, // marginTop + 2 bordi + titolo + 2 righe (pri, stato)
|
|
80
100
|
edit: 8, // marginTop + 2 bordi + titolo + 3 campi + riga anteprima
|
package/package.json
CHANGED