@lamemind/loom-deck 0.12.0 → 0.14.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 +60 -0
- package/dist/cli.js +446 -38
- package/dist/search.js +250 -0
- package/dist/session-list.js +80 -0
- package/dist/sessions.js +110 -1
- package/dist/task-index.js +16 -2
- package/dist/viewport.js +97 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { render, Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
4
|
-
import { useState, useEffect, useMemo } from 'react';
|
|
4
|
+
import { useState, useEffect, useMemo, useRef } from 'react';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { statSync } from 'node:fs';
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
@@ -9,9 +9,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
10
|
import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from './tasks.js';
|
|
11
11
|
import { discoverProjectSessions } from './sessions.js';
|
|
12
|
-
import {
|
|
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
15
|
import { launchLegend, loadIdentity, loadLaunch } from './config.js';
|
|
14
|
-
import { layoutBudget, normalizeEmoji, windowRange, wrapLines, } from './viewport.js';
|
|
16
|
+
import { isCompact, layoutBudget, normalizeEmoji, readerCapacity, searchListCapacity, windowRange, wrapLines, wrapWithOffsets, } from './viewport.js';
|
|
15
17
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
16
18
|
import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
|
|
17
19
|
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
@@ -29,6 +31,25 @@ const SPOT = Symbol('spot');
|
|
|
29
31
|
// Modale sort a grammatica libera: un tasto per chiave, pressioni successive
|
|
30
32
|
// ciclano asc → desc → fuori dalla chain.
|
|
31
33
|
const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
|
|
34
|
+
// T52 — toggle del modale ricerca, tutti su CTRL (D2).
|
|
35
|
+
//
|
|
36
|
+
// I due campi di testo mangiano ogni lettera nuda, quindi un toggle non può
|
|
37
|
+
// essere una lettera semplice: resterebbero i caratteri della query. CTRL è
|
|
38
|
+
// l'unico livello che convive con la digitazione senza modi né navigazione.
|
|
39
|
+
//
|
|
40
|
+
// Le mnemoniche ovvie sono precluse dall'ASCII, non da una scelta di design:
|
|
41
|
+
// `^I` È il Tab (0x09) e `^H` È il Backspace (0x08) — stesso byte, nessuna
|
|
42
|
+
// distinzione possibile a valle. Quindi niente I=IA e niente H=human. Bruciati
|
|
43
|
+
// per lo stesso motivo `^M` (Enter), `^J` (LF), `^[` (Esc). Tutto il resto passa
|
|
44
|
+
// pulito, `^S`/`^Q` inclusi: il raw mode di Ink disattiva il flow-control XON/XOFF
|
|
45
|
+
// che altrimenti se li mangerebbe il terminale.
|
|
46
|
+
const SEARCH_TOGGLE_KEYS = {
|
|
47
|
+
r: 'regex',
|
|
48
|
+
a: 'caseSensitive',
|
|
49
|
+
w: 'wholeWord',
|
|
50
|
+
};
|
|
51
|
+
const SEARCH_KIND_KEYS = { b: 'ai', t: 'tool', u: 'human' };
|
|
52
|
+
const KIND_LABEL = { ai: 'IA', tool: 'tools', human: 'human' };
|
|
32
53
|
// T41 — ordine dei valori nel modale edit. Deliberatamente DIVERSO da
|
|
33
54
|
// PRI_ENTRIES/PROG_ENTRIES (che seguono il rango di sort): qui si sceglie un
|
|
34
55
|
// valore, non si ordina, quindi vince l'ordine del CICLO DI VITA — da fare →
|
|
@@ -262,7 +283,12 @@ function useSessions(projectRoot) {
|
|
|
262
283
|
sessions: [],
|
|
263
284
|
bindings: new Map(),
|
|
264
285
|
forkOf: new Map(),
|
|
286
|
+
pinned: new Map(),
|
|
265
287
|
});
|
|
288
|
+
// T50 — pin/unpin scrive il sidecar e vuole feedback IMMEDIATO, non al
|
|
289
|
+
// prossimo tick del poll (1.5s): la reload è esposta via ref così il toggle la
|
|
290
|
+
// richiama senza risottoscrivere l'intervallo.
|
|
291
|
+
const reloadRef = useRef(() => { });
|
|
266
292
|
useEffect(() => {
|
|
267
293
|
let lastSig = '';
|
|
268
294
|
const reload = () => {
|
|
@@ -274,27 +300,30 @@ function useSessions(projectRoot) {
|
|
|
274
300
|
}
|
|
275
301
|
catch {
|
|
276
302
|
sessions = [];
|
|
277
|
-
index = { bindings: new Map(), forkOf: new Map() };
|
|
303
|
+
index = { bindings: new Map(), forkOf: new Map(), pinned: new Map() };
|
|
278
304
|
}
|
|
279
|
-
const { bindings, forkOf } = index;
|
|
280
|
-
// La signature copre anche
|
|
281
|
-
// cambia
|
|
282
|
-
// farebbe un binding nuovo.
|
|
305
|
+
const { bindings, forkOf, pinned } = index;
|
|
306
|
+
// La signature copre anche fork e pin: un record di lineage o un toggle di
|
|
307
|
+
// pin appena scritto cambia la lista renderizzata, quindi deve forzare il
|
|
308
|
+
// re-render come farebbe un binding nuovo.
|
|
283
309
|
const sig = sessions.map((s) => `${s.sessionId}:${s.ts}`).join('|') +
|
|
284
310
|
'#' +
|
|
285
311
|
[...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',') +
|
|
286
312
|
'#' +
|
|
287
|
-
[...forkOf.entries()].map(([k, v]) => `${k}<${v}`).sort().join(',')
|
|
313
|
+
[...forkOf.entries()].map(([k, v]) => `${k}<${v}`).sort().join(',') +
|
|
314
|
+
'#' +
|
|
315
|
+
[...pinned.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',');
|
|
288
316
|
if (sig === lastSig)
|
|
289
317
|
return;
|
|
290
318
|
lastSig = sig;
|
|
291
|
-
setState({ sessions, bindings, forkOf });
|
|
319
|
+
setState({ sessions, bindings, forkOf, pinned });
|
|
292
320
|
};
|
|
321
|
+
reloadRef.current = reload;
|
|
293
322
|
reload();
|
|
294
323
|
const id = setInterval(reload, POLL_MS);
|
|
295
324
|
return () => clearInterval(id);
|
|
296
325
|
}, [projectRoot]);
|
|
297
|
-
return state;
|
|
326
|
+
return { ...state, reload: () => reloadRef.current() };
|
|
298
327
|
}
|
|
299
328
|
// Legge il task file della task selezionata (Q1+B T20). On-id-change: navigare
|
|
300
329
|
// con ↑↓ ricarica il dettaglio; leggere un singolo file 4-9KB è I/O triviale,
|
|
@@ -330,6 +359,13 @@ const forceEmojiWidth = normalizeEmoji;
|
|
|
330
359
|
// larghi 1 e restano intatti.
|
|
331
360
|
const CARET = normalizeEmoji('▶ ');
|
|
332
361
|
const CARET_OFF = ' ';
|
|
362
|
+
// T50 — glifo warning della riga pin-stale: BMP largo 2 senza VS16 → va timbrato
|
|
363
|
+
// (come CARET), altrimenti la riga slitta di una colonna.
|
|
364
|
+
const WARN = normalizeEmoji('⚠');
|
|
365
|
+
// T50 — separatore leggero fra blocco pinnate e contestuali. `─` (box-drawing) è
|
|
366
|
+
// largo 1 sia per string-width sia per il terminale → nessun timbro. Corto +
|
|
367
|
+
// wrap="truncate-end" così non va mai a capo nel pane al 50%.
|
|
368
|
+
const SESSION_SEP = '─'.repeat(16);
|
|
333
369
|
// Normalizza il marker Done per il display. `✔` (U+2714) è text-presentation-
|
|
334
370
|
// default: string-width — quindi Ink, sia per il layout sia per il troncamento —
|
|
335
371
|
// lo misura 2 (rispetta il VS16 di `✔️`), ma VTE/Ptyxis lo disegna largo 1
|
|
@@ -374,14 +410,17 @@ const META_KEYS = ['Priority', 'Size', 'Estimated Time', 'Progress'];
|
|
|
374
410
|
function Deck({ cwd, tasksPath, tasksDir }) {
|
|
375
411
|
const { exit } = useApp();
|
|
376
412
|
const { tasks, loadError } = useTasks(tasksPath);
|
|
377
|
-
const { sessions, bindings, forkOf } = useSessions(cwd);
|
|
413
|
+
const { sessions, bindings, forkOf, pinned, reload: reloadSessions } = useSessions(cwd);
|
|
378
414
|
const [focus, setFocus] = useState('tasks');
|
|
379
415
|
// T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
|
|
380
416
|
// (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
|
|
381
417
|
// grezzo per posizione spawnerebbe la task sbagliata, in silenzio. `null` = la
|
|
382
418
|
// riga meta "spot", sempre in testa alla lista.
|
|
383
419
|
const [selId, setSelId] = useState(null);
|
|
384
|
-
|
|
420
|
+
// T50 — selezione del pane sessioni KEYED SU sessionId (non indice): la lista
|
|
421
|
+
// a due gruppi + separatore è una vista trasformata, un indice grezzo punterebbe
|
|
422
|
+
// alla riga sbagliata dopo un pin o un cambio di contesto (stesso trap T39).
|
|
423
|
+
const [selSessionId, setSelSessionId] = useState(null);
|
|
385
424
|
const [note, setNote] = useState('');
|
|
386
425
|
// Modali: catturano i tasti e corto-circuitano la navigazione normale.
|
|
387
426
|
// T30 create · T39 sort/filter.
|
|
@@ -395,6 +434,22 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
395
434
|
// T41 — bozza dell'edit (null fuori dal modale) e riga attiva della griglia.
|
|
396
435
|
const [edit, setEdit] = useState(null);
|
|
397
436
|
const [editRow, setEditRow] = useState(0);
|
|
437
|
+
// T52 — stato del modale ricerca. VOLATILE per decisione (D4): vive in
|
|
438
|
+
// memoria, quindi riaprendo il modale ritrovi query e toggle come li avevi
|
|
439
|
+
// lasciati, ma un riavvio del deck riporta ai default. Niente schema nuovo su
|
|
440
|
+
// `deck-view.json` e nessuna scrittura implicita su disco — che
|
|
441
|
+
// contraddirebbe la regola T39 «il disco si tocca solo su `w`», qui non
|
|
442
|
+
// trasportabile perché dentro il modale `w` è un carattere digitabile.
|
|
443
|
+
const [searchHash, setSearchHash] = useState('');
|
|
444
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
445
|
+
const [searchField, setSearchField] = useState('query');
|
|
446
|
+
const [searchOpts, setSearchOpts] = useState(DEFAULT_OPTIONS);
|
|
447
|
+
const [searchSelKey, setSearchSelKey] = useState(null);
|
|
448
|
+
// Occorrenza aperta nel reader; null = reader chiuso. Tenerla separata dalla
|
|
449
|
+
// selezione della lista è ciò che permette a `esc` di tornare indietro
|
|
450
|
+
// trovando la lista esattamente com'era.
|
|
451
|
+
const [readerRow, setReaderRow] = useState(null);
|
|
452
|
+
const [readerTop, setReaderTop] = useState(0);
|
|
398
453
|
// Dimensioni vive del terminale: sono l'input del budget d'altezza sotto.
|
|
399
454
|
const { rows, columns } = useTerminalSize();
|
|
400
455
|
// Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
|
|
@@ -424,12 +479,36 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
424
479
|
}
|
|
425
480
|
// Figli della selezione: sessioni bound alla task selezionata, oppure (spot)
|
|
426
481
|
// le sessioni senza binding. sessions è già ts desc → l'ordine si eredita.
|
|
427
|
-
|
|
482
|
+
// Memoizzato così `sessionRows` resta stabile fra render che non cambiano gli
|
|
483
|
+
// input: l'effect di validità della selezione non rigira a vuoto.
|
|
484
|
+
const childSessions = useMemo(() => sessions.filter((s) => {
|
|
428
485
|
const bound = bindings.get(s.sessionId);
|
|
429
486
|
return selectedTaskId ? bound === selectedTaskId : !bound;
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
|
|
487
|
+
}), [sessions, bindings, selectedTaskId]);
|
|
488
|
+
// T50 — lista a due gruppi: pinnate (sempre, in cima) + separatore +
|
|
489
|
+
// contestuali. Dedup, cap solo sulle contestuali, righe stale per le pinnate
|
|
490
|
+
// orfane. Core PURO in session-list.ts (testabile senza Ink).
|
|
491
|
+
const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, MAX_SESSIONS), [childSessions, sessions, pinned]);
|
|
492
|
+
const sessionRows = assembled.rows;
|
|
493
|
+
const selSessionObj = selectedSession(sessionRows, selSessionId);
|
|
494
|
+
// T52 — ricerca EAGER: rigira a ogni carattere digitato, non su ⏎. È
|
|
495
|
+
// sostenibile perché i corpi sono già in RAM dentro la cache mtime-keyed
|
|
496
|
+
// dell'adapter (D5): misurato su questo progetto, 0,8 ms sui soli corpi IA e
|
|
497
|
+
// ≤9 ms su tutti i tipi — sotto il tempo fra due battute. Il memo evita di
|
|
498
|
+
// rifarla sui re-render che non toccano né query né opzioni (il poll delle
|
|
499
|
+
// sessioni ogni 1,5s è già filtrato dalla signature in `useSessions`).
|
|
500
|
+
const searchResult = useMemo(() => searchSessions(sessions, searchHash, searchQuery, searchOpts), [sessions, searchHash, searchQuery, searchOpts]);
|
|
501
|
+
// Con l'hash valorizzato la conversazione è una sola e già nominata nel campo:
|
|
502
|
+
// la riga-sessione ripeterebbe un dato costante rubando una riga per gruppo.
|
|
503
|
+
const searchFlat = searchHash.trim().length > 0;
|
|
504
|
+
const searchRows = useMemo(() => buildRows(searchResult, searchFlat), [searchResult, searchFlat]);
|
|
505
|
+
// T52 — corpo del messaggio aperto nel reader, wrappato UNA volta per (testo,
|
|
506
|
+
// larghezza). Senza memo ogni pressione di freccia rifarebbe l'a-capo di un
|
|
507
|
+
// messaggio che nella coda lunga arriva a 150k char.
|
|
508
|
+
const readerWidth = Math.max(20, (columns || 80) - 6);
|
|
509
|
+
const readerLines = useMemo(() => (readerRow?.kind === 'hit' ? wrapWithOffsets(readerRow.hit.text, readerWidth) : []), [readerRow, readerWidth]);
|
|
510
|
+
const readerCap = readerCapacity(rows);
|
|
511
|
+
const readerMaxTop = Math.max(0, readerLines.length - readerCap);
|
|
433
512
|
// T39 — selezione stabile sotto trasformazione. Se la task selezionata esce
|
|
434
513
|
// dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
|
|
435
514
|
// sulla prima visibile — fallback deterministico, mai una posizione a caso.
|
|
@@ -438,13 +517,24 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
438
517
|
setSelId(viewTasks[0]?.id ?? null);
|
|
439
518
|
}
|
|
440
519
|
}, [viewTasks, selId]);
|
|
441
|
-
//
|
|
520
|
+
// T50 — la selezione (id) resta valida sotto la vista a due gruppi: se l'id
|
|
521
|
+
// non è più una riga selezionabile (cambio parent, lista mutata, pin rimosso,
|
|
522
|
+
// sessione sparita) cade sulla prima riga — fallback deterministico, mai una
|
|
523
|
+
// posizione a caso. Sostituisce il reset-a-0 e il clamp index-based.
|
|
442
524
|
useEffect(() => {
|
|
443
|
-
|
|
444
|
-
|
|
525
|
+
if (rowIndexOf(sessionRows, selSessionId) < 0) {
|
|
526
|
+
setSelSessionId(firstSelectableId(sessionRows));
|
|
527
|
+
}
|
|
528
|
+
}, [sessionRows, selSessionId]);
|
|
529
|
+
// T52 — stessa invariante sulla lista occorrenze, dove è ancora più stretta:
|
|
530
|
+
// la lista si RICOSTRUISCE a ogni carattere digitato, quindi la chiave
|
|
531
|
+
// selezionata sparisce continuamente. Persa → prima riga, mai una posizione
|
|
532
|
+
// ereditata (che qui punterebbe a un'altra conversazione, non a un'altra riga).
|
|
445
533
|
useEffect(() => {
|
|
446
|
-
|
|
447
|
-
|
|
534
|
+
if (rowIndexOfKey(searchRows, searchSelKey) < 0) {
|
|
535
|
+
setSearchSelKey(firstRowKey(searchRows));
|
|
536
|
+
}
|
|
537
|
+
}, [searchRows, searchSelKey]);
|
|
448
538
|
// T30: submit dell'input box. Il taskId nasce DOPO create-task (lo assegna la
|
|
449
539
|
// skill scrivendo tasks.md) → non è noto allo spawn. Il sessionId invece è
|
|
450
540
|
// pinnato qui: snapshot degli id PRIMA, poi al completamento re-leggo tasks.md
|
|
@@ -544,7 +634,145 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
544
634
|
const next = Math.max(0, Math.min(viewTasks.length, selIndex + delta));
|
|
545
635
|
setSelId(next === 0 ? null : viewTasks[next - 1]?.id ?? null);
|
|
546
636
|
}
|
|
637
|
+
// T52 — `⏎` contestuale al TIPO di riga: la lista ne mescola due e l'azione
|
|
638
|
+
// giusta dipende da quale è selezionata. Riga sessione → resume, identico al
|
|
639
|
+
// `⏎` della lista sessioni (stessa funzione, T49). Riga occorrenza → reader.
|
|
640
|
+
function submitSearchRow() {
|
|
641
|
+
const row = selectedRow(searchRows, searchSelKey);
|
|
642
|
+
if (!row) {
|
|
643
|
+
setNote(searchResult.error ? '⚠ regex non valida' : 'nessuna occorrenza selezionata');
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (row.kind === 'session') {
|
|
647
|
+
const bound = bindings.get(row.session.sessionId) ?? null;
|
|
648
|
+
const child = spawnDeckResume(bound, cwd, row.session.sessionId);
|
|
649
|
+
child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
|
|
650
|
+
setNote(`⏎ resume ${row.session.sessionId.slice(0, 8)} → tab CC${bound ? ` (${bound})` : ' (spot)'}`);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
// D8 — il reader si apre POSIZIONATO sull'occorrenza, non in cima. Il 94%
|
|
654
|
+
// dei messaggi entra in una schermata e la differenza non si vede; è sul 6%
|
|
655
|
+
// lungo (p99 ≈ 88 righe, max ≈ 1547) che aprire in cima costringerebbe a
|
|
656
|
+
// rifare a mano la ricerca appena fatta — cioè proprio il lavoro che questa
|
|
657
|
+
// feature esiste per evitare.
|
|
658
|
+
const lines = wrapWithOffsets(row.hit.text, readerWidth);
|
|
659
|
+
const cap = readerCapacity(rows);
|
|
660
|
+
const matchLine = lines.findIndex((l) => l.end > row.hit.matchStart);
|
|
661
|
+
const maxTop = Math.max(0, lines.length - cap);
|
|
662
|
+
setReaderRow(row);
|
|
663
|
+
setReaderTop(Math.max(0, Math.min(maxTop, Math.max(0, matchLine) - Math.floor(cap / 2))));
|
|
664
|
+
setNote('');
|
|
665
|
+
setMode('reader');
|
|
666
|
+
}
|
|
667
|
+
function scrollReader(delta) {
|
|
668
|
+
setReaderTop((t) => Math.max(0, Math.min(readerMaxTop, t + delta)));
|
|
669
|
+
}
|
|
670
|
+
// T52 — toggle di tipo messaggio. Spegnerli tutti è uno stato lecito (lista
|
|
671
|
+
// vuota, nessun errore): è l'utente che ha chiuso ogni canale, non un guasto.
|
|
672
|
+
function toggleKind(kind) {
|
|
673
|
+
setSearchOpts((o) => ({ ...o, kinds: { ...o.kinds, [kind]: !o.kinds[kind] } }));
|
|
674
|
+
}
|
|
675
|
+
function editSearchField(fn) {
|
|
676
|
+
if (searchField === 'hash')
|
|
677
|
+
setSearchHash(fn);
|
|
678
|
+
else
|
|
679
|
+
setSearchQuery(fn);
|
|
680
|
+
}
|
|
681
|
+
// `useInput` consegna il CHUNK letto da stdin, non un tasto: un incollaggio —
|
|
682
|
+
// o una raffica di tasti più veloce di una read — arriva come stringa unica,
|
|
683
|
+
// byte di controllo compresi. Senza filtro finiscono DENTRO la query: non si
|
|
684
|
+
// vedono, ma Ink li conta nella larghezza della riga e nessun match li
|
|
685
|
+
// soddisfa, quindi la ricerca smette di trovare senza dire perché.
|
|
686
|
+
// Le newline diventano spazio invece di sparire: incollare due righe deve
|
|
687
|
+
// separare le parole, non fonderle.
|
|
688
|
+
function typeIntoField(chunk) {
|
|
689
|
+
const clean = chunk.replace(/[\r\n\t]/g, ' ').replace(/[\u0000-\u001f\u007f]/g, '');
|
|
690
|
+
if (clean)
|
|
691
|
+
editSearchField((s) => s + clean);
|
|
692
|
+
}
|
|
693
|
+
const searchCap = searchListCapacity(rows, Boolean(note));
|
|
547
694
|
useInput((input, key) => {
|
|
695
|
+
// T52 — MODALE DENTRO IL MODALE. `useInput` in Ink è globale e non ha
|
|
696
|
+
// focus-trap: non esiste un meccanismo che confini l'input a un componente.
|
|
697
|
+
// La cattura È l'ordine dei rami — quindi il reader va PRIMA della ricerca,
|
|
698
|
+
// e mentre è aperto il modale ricerca resta montato sotto con la sua
|
|
699
|
+
// selezione intatta, pronto a riprendere il controllo su `esc`.
|
|
700
|
+
if (mode === 'reader') {
|
|
701
|
+
if (key.escape) {
|
|
702
|
+
setMode('search');
|
|
703
|
+
setReaderRow(null);
|
|
704
|
+
}
|
|
705
|
+
else if (key.upArrow) {
|
|
706
|
+
scrollReader(-1);
|
|
707
|
+
}
|
|
708
|
+
else if (key.downArrow) {
|
|
709
|
+
scrollReader(1);
|
|
710
|
+
}
|
|
711
|
+
else if (key.pageUp) {
|
|
712
|
+
scrollReader(-readerCap);
|
|
713
|
+
}
|
|
714
|
+
else if (key.pageDown) {
|
|
715
|
+
scrollReader(readerCap);
|
|
716
|
+
}
|
|
717
|
+
else if (input === 'g') {
|
|
718
|
+
// Estremi su lettera e non su Home/End: Ink RICONOSCE quelle due (le
|
|
719
|
+
// mappa a 'home'/'end' nel parser) ma NON le espone — `nonAlphanumericKeys`
|
|
720
|
+
// azzera l'input e nessun flag le rappresenta, quindi arrivano
|
|
721
|
+
// indistinguibili da qualunque tasto ignoto. Verificato su pty reale.
|
|
722
|
+
// Nel reader non c'è input di testo, quindi le lettere sono libere.
|
|
723
|
+
setReaderTop(0);
|
|
724
|
+
}
|
|
725
|
+
else if (input === 'G') {
|
|
726
|
+
setReaderTop(readerMaxTop);
|
|
727
|
+
}
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
// T52 — modale ricerca. I due campi di testo mangiano ogni lettera nuda:
|
|
731
|
+
// ogni comando che deve restare vivo MENTRE si digita passa da CTRL, dai
|
|
732
|
+
// tasti freccia o da Tab. `esc` chiude il modale, non il deck.
|
|
733
|
+
if (mode === 'search') {
|
|
734
|
+
if (key.escape) {
|
|
735
|
+
setMode('normal');
|
|
736
|
+
setNote('');
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (key.ctrl) {
|
|
740
|
+
const flag = SEARCH_TOGGLE_KEYS[input];
|
|
741
|
+
if (flag) {
|
|
742
|
+
setSearchOpts((o) => ({ ...o, [flag]: !o[flag] }));
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const kind = SEARCH_KIND_KEYS[input];
|
|
746
|
+
if (kind)
|
|
747
|
+
toggleKind(kind);
|
|
748
|
+
return; // ogni altra combo ctrl (^F incluso: siamo già dentro) = no-op
|
|
749
|
+
}
|
|
750
|
+
if (key.tab) {
|
|
751
|
+
setSearchField((f) => (f === 'hash' ? 'query' : 'hash'));
|
|
752
|
+
}
|
|
753
|
+
else if (key.upArrow) {
|
|
754
|
+
setSearchSelKey((k) => moveRowSelection(searchRows, k, -1));
|
|
755
|
+
}
|
|
756
|
+
else if (key.downArrow) {
|
|
757
|
+
setSearchSelKey((k) => moveRowSelection(searchRows, k, 1));
|
|
758
|
+
}
|
|
759
|
+
else if (key.pageUp) {
|
|
760
|
+
setSearchSelKey((k) => moveRowSelection(searchRows, k, -Math.max(1, searchCap)));
|
|
761
|
+
}
|
|
762
|
+
else if (key.pageDown) {
|
|
763
|
+
setSearchSelKey((k) => moveRowSelection(searchRows, k, Math.max(1, searchCap)));
|
|
764
|
+
}
|
|
765
|
+
else if (key.return) {
|
|
766
|
+
submitSearchRow();
|
|
767
|
+
}
|
|
768
|
+
else if (key.backspace || key.delete) {
|
|
769
|
+
editSearchField((s) => s.slice(0, -1));
|
|
770
|
+
}
|
|
771
|
+
else if (input && !key.meta) {
|
|
772
|
+
typeIntoField(input);
|
|
773
|
+
}
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
548
776
|
// T30: in modalità create l'handler cattura il testo e corto-circuita la
|
|
549
777
|
// navigazione normale (incl. q/esc → quit: qui esc annulla, non esce).
|
|
550
778
|
if (mode === 'create') {
|
|
@@ -661,6 +889,21 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
661
889
|
}
|
|
662
890
|
return;
|
|
663
891
|
}
|
|
892
|
+
// T52/D1 — il ramo CTRL sta PRIMA di quelli su lettera nuda e li chiude
|
|
893
|
+
// tutti. `CTRL+F` e `f` nudo arrivano con lo STESSO `input` ('f'),
|
|
894
|
+
// distinguibili solo da `key.ctrl`: senza questa precedenza `CTRL+F`
|
|
895
|
+
// cadrebbe nel ramo fork e spawnerebbe una sessione invece di aprire la
|
|
896
|
+
// ricerca. Non è un caso isolato della `f` — `^Q` finirebbe nel quit, `^T`
|
|
897
|
+
// aprirebbe un terminale, `^C` … ogni lettera legata a un'azione ha la sua
|
|
898
|
+
// combo omonima. Chiudere qui l'intera classe è più solido che ricordarsi
|
|
899
|
+
// un `!key.ctrl` su ognuno dei rami, oggi e a ogni tasto aggiunto domani.
|
|
900
|
+
if (key.ctrl) {
|
|
901
|
+
if (input === 'f') {
|
|
902
|
+
setNote('');
|
|
903
|
+
setMode('search');
|
|
904
|
+
}
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
664
907
|
if (key.leftArrow || key.rightArrow || key.tab) {
|
|
665
908
|
setFocus((f) => (f === 'tasks' ? 'sessions' : 'tasks'));
|
|
666
909
|
}
|
|
@@ -668,13 +911,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
668
911
|
if (focus === 'tasks')
|
|
669
912
|
moveTaskSel(-1);
|
|
670
913
|
else
|
|
671
|
-
|
|
914
|
+
setSelSessionId((id) => moveSelection(sessionRows, id, -1));
|
|
672
915
|
}
|
|
673
916
|
else if (key.downArrow) {
|
|
674
917
|
if (focus === 'tasks')
|
|
675
918
|
moveTaskSel(1);
|
|
676
919
|
else
|
|
677
|
-
|
|
920
|
+
setSelSessionId((id) => moveSelection(sessionRows, id, 1));
|
|
678
921
|
}
|
|
679
922
|
else if (key.return) {
|
|
680
923
|
if (focus === 'tasks') {
|
|
@@ -697,9 +940,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
697
940
|
else {
|
|
698
941
|
// T49 — ⏎ su una sessione = resume in nuova tab. Il binding si rilegge
|
|
699
942
|
// dal sidecar (non dal padre selezionato): vale anche per le spot.
|
|
700
|
-
const s =
|
|
943
|
+
const s = selSessionObj;
|
|
701
944
|
if (!s) {
|
|
702
|
-
setNote('nessuna sessione da riprendere');
|
|
945
|
+
setNote(selSessionId ? 'pin stale: transcript non più presente' : 'nessuna sessione da riprendere');
|
|
703
946
|
}
|
|
704
947
|
else {
|
|
705
948
|
const bound = bindings.get(s.sessionId) ?? null;
|
|
@@ -739,9 +982,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
739
982
|
setNote('f → fork: seleziona una sessione (←→ per il pane)');
|
|
740
983
|
}
|
|
741
984
|
else {
|
|
742
|
-
const s =
|
|
985
|
+
const s = selSessionObj;
|
|
743
986
|
if (!s) {
|
|
744
|
-
setNote('f → nessuna sessione da forkare');
|
|
987
|
+
setNote(selSessionId ? 'f → pin stale: niente da forkare' : 'f → nessuna sessione da forkare');
|
|
745
988
|
}
|
|
746
989
|
else {
|
|
747
990
|
// L'id del ramo nasce qui, prima dello spawn: pinnandolo posso
|
|
@@ -761,6 +1004,25 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
761
1004
|
}
|
|
762
1005
|
}
|
|
763
1006
|
}
|
|
1007
|
+
else if (input === 'p') {
|
|
1008
|
+
// T50 — pin/unpin della conversazione selezionata. Minuscola = azione
|
|
1009
|
+
// immediata (convenzione T39), gemella di `f`: opera sulla riga
|
|
1010
|
+
// selezionata del pane sessioni, quindi vive solo lì. Vale anche su una
|
|
1011
|
+
// pinnata STALE (l'unico modo di spinnarla). Scrive il sidecar e ricarica
|
|
1012
|
+
// subito, senza attendere il tick del poll.
|
|
1013
|
+
if (focus !== 'sessions') {
|
|
1014
|
+
setNote('p → pin: seleziona una sessione (←→ per il pane)');
|
|
1015
|
+
}
|
|
1016
|
+
else if (!selSessionId) {
|
|
1017
|
+
setNote('p → nessuna sessione da pinnare');
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
const isPinned = pinned.has(selSessionId);
|
|
1021
|
+
appendPin(cwd, selSessionId, !isPinned);
|
|
1022
|
+
reloadSessions();
|
|
1023
|
+
setNote(`${isPinned ? 'unpin' : '📌 pin'} ${selSessionId.slice(0, 8)}`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
764
1026
|
else if (input === 't') {
|
|
765
1027
|
const title = identity ? `🖥️ ${identity.owner} ${identity.name} [term]` : null;
|
|
766
1028
|
const child = spawnTerminal(cwd, title);
|
|
@@ -801,14 +1063,40 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
801
1063
|
exit();
|
|
802
1064
|
}
|
|
803
1065
|
});
|
|
804
|
-
const selSessionObj = visibleSessions[selSession] ?? null;
|
|
805
|
-
const selSessionId = selSessionObj?.sessionId;
|
|
806
1066
|
const parentLabel = isSpot ? 'spot' : selectedTaskId ?? '—';
|
|
807
1067
|
const canSpawn = focus === 'tasks' && !isSpot;
|
|
808
1068
|
const canResume = focus === 'sessions' && selSessionObj !== null;
|
|
1069
|
+
// T50 — il pin agisce su qualunque riga selezionata (anche stale, per
|
|
1070
|
+
// spinnarla); basta il focus sul pane e una selezione.
|
|
1071
|
+
const canPin = focus === 'sessions' && selSessionId !== null;
|
|
809
1072
|
// Larghezza dal medesimo hook che dà l'altezza: dopo un resize la legenda si
|
|
810
1073
|
// ricalcola con lo stesso re-render che ridimensiona i pane.
|
|
811
1074
|
const legend = launchLegend(launch, columns);
|
|
1075
|
+
// ── T52 · schermate sostitutive ─────────────────────────────────────────
|
|
1076
|
+
// Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
|
|
1077
|
+
// pane: una lista di occorrenze non entra in un box da 4 righe. Prendono
|
|
1078
|
+
// l'intero frame, quindi escono di qui — il budget dei due pane sotto non
|
|
1079
|
+
// serve nemmeno calcolarlo, e la loro altezza la distribuiscono
|
|
1080
|
+
// `searchListCapacity` / `readerCapacity`.
|
|
1081
|
+
if (mode === 'search' || mode === 'reader') {
|
|
1082
|
+
const hit = mode === 'reader' && readerRow?.kind === 'hit' ? readerRow.hit : null;
|
|
1083
|
+
// Terminale sotto la cornice: riga singola invece del box, per lo stesso
|
|
1084
|
+
// motivo del `budget.compact` del deck — un frame più alto di `rows` fa
|
|
1085
|
+
// pulire lo schermo a Ink a ogni redraw, e il poll lo versa nello scrollback.
|
|
1086
|
+
if (isCompact(hit ? readerCap : searchCap)) {
|
|
1087
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", hit ? 'reader' : 'ricerca', " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga \u00B7", ' ', "esc ", hit ? 'torna' : 'chiude'] })] }));
|
|
1088
|
+
}
|
|
1089
|
+
if (hit) {
|
|
1090
|
+
// Niente `windowRange`: quella centra la finestra su una SELEZIONE, qui
|
|
1091
|
+
// la posizione è lo scroll che l'utente muove a mano. Il clamp serve
|
|
1092
|
+
// comunque — un resize può accorciare il testo sotto uno scroll già dato.
|
|
1093
|
+
const start = Math.min(readerTop, readerMaxTop);
|
|
1094
|
+
return (_jsx(ReaderScreen, { hit: hit, lines: readerLines.slice(start, start + readerCap), top: start, total: readerLines.length, capacity: readerCap, bound: bindings.get(hit.sessionId) ?? null }));
|
|
1095
|
+
}
|
|
1096
|
+
const selIdx = rowIndexOfKey(searchRows, searchSelKey);
|
|
1097
|
+
const win = windowRange(searchRows.length, selIdx, searchCap);
|
|
1098
|
+
return (_jsx(SearchScreen, { 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, projectCore: identity ? `${identity.owner} ${identity.name}` : null, note: note }));
|
|
1099
|
+
}
|
|
812
1100
|
// ── Budget d'altezza ────────────────────────────────────────────────────
|
|
813
1101
|
// Il frame deve restare sotto `rows`, sempre: oltre quella soglia Ink smette
|
|
814
1102
|
// di aggiornare per differenza e pulisce lo schermo a ogni redraw, che su
|
|
@@ -833,20 +1121,21 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
833
1121
|
sessionHasFirstPreview: canResume && Boolean(selSessionObj?.customTitle),
|
|
834
1122
|
sessionHasLastPreview: canResume && Boolean(selSessionObj?.lastReply),
|
|
835
1123
|
});
|
|
836
|
-
// Finestre di rendering. Le liste "logiche" (viewTasks,
|
|
1124
|
+
// Finestre di rendering. Le liste "logiche" (viewTasks, sessionRows)
|
|
837
1125
|
// restano intere: navigazione, selezione e spawn continuano a ragionare su
|
|
838
1126
|
// quelle, la finestra è solo ciò che finisce a schermo.
|
|
839
1127
|
const taskWin = windowRange(viewTasks.length, selIndex - 1, budget.taskRows);
|
|
840
1128
|
const windowTasks = viewTasks.slice(taskWin.start, taskWin.end);
|
|
841
|
-
const
|
|
842
|
-
const
|
|
1129
|
+
const selRowIndex = rowIndexOf(sessionRows, selSessionId);
|
|
1130
|
+
const sessionWin = windowRange(sessionRows.length, selRowIndex, budget.sessionRows);
|
|
1131
|
+
const windowRows = sessionRows.slice(sessionWin.start, sessionWin.end);
|
|
843
1132
|
// Sotto la soglia minima il layout a box non entra a nessun costo: si scende
|
|
844
1133
|
// a una riga sola. Perdere il deck per un terminale basso è meglio che
|
|
845
1134
|
// sporcare la cronologia del terminale a ogni poll.
|
|
846
1135
|
if (budget.compact) {
|
|
847
1136
|
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"] })] }));
|
|
848
1137
|
}
|
|
849
|
-
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' : '', " \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,
|
|
1138
|
+
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] }));
|
|
850
1139
|
}
|
|
851
1140
|
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
852
1141
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
@@ -876,6 +1165,113 @@ function EditModal({ id, draft, row }) {
|
|
|
876
1165
|
const mark = (r) => (row === r ? CARET : CARET_OFF);
|
|
877
1166
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 priorit\u00E0 e stato"] }), _jsxs(Text, { children: [mark(0), _jsx(Text, { dimColor: true, children: "pri " }), EDIT_PRI.map((p) => (_jsxs(Text, { inverse: draft.pri === p, color: draft.pri === p ? 'green' : 'gray', children: [' ', forceEmojiWidth(PRI_GLYPH[p]), " ", PRI_LABEL[p]] }, p)))] }), _jsxs(Text, { children: [mark(1), _jsx(Text, { dimColor: true, children: "stato" }), EDIT_PROG.map((p) => (_jsxs(Text, { inverse: draft.prog === p, color: draft.prog === p ? 'green' : 'gray', children: [' ', forceEmojiWidth(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(Text, { dimColor: true, children: "prog " }), _jsxs(Text, { children: [' ', draft.detail] }), row === 2 ? _jsx(Text, { inverse: true, children: " " }) : null, !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", normalizeEmoji(progressText(draft.prog, draft.detail))] })] }));
|
|
878
1167
|
}
|
|
1168
|
+
// T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
|
|
1169
|
+
// caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
|
|
1170
|
+
// allinearsi, e i glifi BMP larghi 2 sono proprio la classe che Ink e il
|
|
1171
|
+
// terminale misurano diversamente (vedi normalizeEmoji).
|
|
1172
|
+
const KIND_TAG = { ai: 'ai', tool: 'tl', human: 'hu' };
|
|
1173
|
+
const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
|
|
1174
|
+
/**
|
|
1175
|
+
* Cosa scrivere sulla riga-gruppo per distinguere una conversazione dall'altra.
|
|
1176
|
+
*
|
|
1177
|
+
* Non basta `session.title`: quel titolo è la label della tab Ptyxis, cioè
|
|
1178
|
+
* `<emoji> <owner> <name>` più un eventuale suffisso. Su una lista tutta dello
|
|
1179
|
+
* stesso progetto (D3) è una COLONNA COSTANTE — tre righe su quattro
|
|
1180
|
+
* identiche, che è esattamente il difetto che la riga-gruppo doveva evitare.
|
|
1181
|
+
*
|
|
1182
|
+
* Si toglie quindi il core `<owner> <name>` (noto dal file config), e se ciò
|
|
1183
|
+
* che resta è vuoto o duplica la task già mostrata nella sua colonna, si
|
|
1184
|
+
* ripiega sul primo prompt — l'unica cosa che davvero identifica quella
|
|
1185
|
+
* conversazione e non un'altra.
|
|
1186
|
+
*/
|
|
1187
|
+
function conversationLabel(s, core, bound) {
|
|
1188
|
+
let t = s.title;
|
|
1189
|
+
if (core) {
|
|
1190
|
+
const at = t.indexOf(core);
|
|
1191
|
+
if (at >= 0)
|
|
1192
|
+
t = t.slice(at + core.length);
|
|
1193
|
+
}
|
|
1194
|
+
t = t.replace(/^[\s·]+/, '').trim();
|
|
1195
|
+
if (bound && t === bound)
|
|
1196
|
+
t = '';
|
|
1197
|
+
return t || s.firstPrompt || '(senza titolo)';
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Riga di toggle del modale ricerca.
|
|
1201
|
+
*
|
|
1202
|
+
* La mappa tasto→significato è SEMPRE a schermo: `^R` da solo è opaco quanto lo
|
|
1203
|
+
* era il range `1-9` delle launch prima di T43.
|
|
1204
|
+
*
|
|
1205
|
+
* Lo stato acceso/spento passa da `[x]`/`[ ]`, non dal solo colore — stessa
|
|
1206
|
+
* convenzione del modale filtri. Il colore è ridondanza, non l'informazione: su
|
|
1207
|
+
* un terminale monocromo, o in una cattura di testo, sei toggle tutti uguali
|
|
1208
|
+
* non direbbero più quali sono attivi.
|
|
1209
|
+
*/
|
|
1210
|
+
function ToggleHint({ opts }) {
|
|
1211
|
+
const flag = (on, key, label) => (_jsxs(Text, { color: on ? 'green' : 'gray', dimColor: !on, bold: on, children: [' ', key, "[", on ? 'x' : ' ', "] ", label] }, key));
|
|
1212
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [flag(opts.regex, '^R', 'regex'), flag(opts.caseSensitive, '^A', 'Aa'), flag(opts.wholeWord, '^W', 'word'), _jsx(Text, { dimColor: true, children: ' │' }), flag(opts.kinds.ai, '^B', 'IA'), flag(opts.kinds.tool, '^T', 'tools'), flag(opts.kinds.human, '^U', 'human')] }));
|
|
1213
|
+
}
|
|
1214
|
+
/** Intestazione della lista: dice sempre quanto NON si sta vedendo (regex rotta,
|
|
1215
|
+
* query troppo corta, occorrenze tagliate dal cap, righe fuori finestra). */
|
|
1216
|
+
function SearchListHeader({ result, query, above, below, }) {
|
|
1217
|
+
if (result.error) {
|
|
1218
|
+
return (_jsxs(Text, { color: "red", wrap: "truncate-end", children: [WARN, " regex non valida \u00B7 ", result.error] }));
|
|
1219
|
+
}
|
|
1220
|
+
if (result.idle) {
|
|
1221
|
+
return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: query.length === 0
|
|
1222
|
+
? 'digita la chiave da cercare'
|
|
1223
|
+
: `almeno ${MIN_QUERY} caratteri (${query.length})` }));
|
|
1224
|
+
}
|
|
1225
|
+
if (result.shown === 0) {
|
|
1226
|
+
return (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: "nessuna occorrenza" }));
|
|
1227
|
+
}
|
|
1228
|
+
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] }));
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Schermata di ricerca full-text (T52).
|
|
1232
|
+
*
|
|
1233
|
+
* Due campi + toggle + lista di occorrenze. Con l'hash vuoto la lista è
|
|
1234
|
+
* raggruppata per conversazione: la riga-gruppo NON ripete il nome del progetto
|
|
1235
|
+
* (D3: sono tutte dello stesso progetto, sarebbe una colonna costante) e usa lo
|
|
1236
|
+
* spazio per ciò che distingue davvero una conversazione — hash, task legata,
|
|
1237
|
+
* titolo, data.
|
|
1238
|
+
*/
|
|
1239
|
+
function SearchScreen({ hash, query, field, opts, result, rows, selectedKey, selectedKind, above, below, capacity, bindings, pinned, projectCore, note, }) {
|
|
1240
|
+
const enter = selectedKind === 'session' ? 'resume' : selectedKind === 'hit' ? 'leggi' : '—';
|
|
1241
|
+
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) => {
|
|
1242
|
+
const sel = row.key === selectedKey;
|
|
1243
|
+
if (row.kind === 'session') {
|
|
1244
|
+
const s = row.session;
|
|
1245
|
+
const bound = bindings.get(s.sessionId);
|
|
1246
|
+
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 " }), truncate(conversationLabel(s, projectCore, bound), 44), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
|
|
1247
|
+
}
|
|
1248
|
+
const h = row.hit;
|
|
1249
|
+
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));
|
|
1250
|
+
})] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Reader fullscreen (T52 · D8).
|
|
1254
|
+
*
|
|
1255
|
+
* Mostra il messaggio INTERO che contiene l'occorrenza, aperto già posizionato
|
|
1256
|
+
* sul match e con il match evidenziato. È un `mode` a sé, catturato prima del
|
|
1257
|
+
* ramo `search`: il modale ricerca resta montato sotto e su `esc` si ritrova
|
|
1258
|
+
* con query, toggle e selezione intatti.
|
|
1259
|
+
*/
|
|
1260
|
+
function ReaderScreen({ hit, lines, top, total, capacity, bound, }) {
|
|
1261
|
+
const last = Math.min(total, top + capacity);
|
|
1262
|
+
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: ["reader \u00B7 ", _jsx(Text, { color: "cyan", children: hit.sessionId.slice(0, 8) }), " \u00B7 record ", hit.idx, " \u00B7", ' ', KIND_LABEL[hit.kind], bound ? ` · ${bound}` : '', " \u00B7 righe ", total === 0 ? 0 : top + 1, "-", last, " di ", total] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g" }), " inizio \u00B7 ", _jsx(Text, { color: "yellow", children: "G" }), " fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " torna alla lista"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: lines.map((l, i) => (_jsx(ReaderLine, { line: l, from: hit.matchStart, to: hit.matchEnd }, top + i))) })] }));
|
|
1263
|
+
}
|
|
1264
|
+
/** Una riga del reader, con la sola porzione di match evidenziata. Gli offset
|
|
1265
|
+
* sono quelli del testo sorgente, quindi un match a cavallo dell'a-capo si
|
|
1266
|
+
* colora su entrambe le righe senza casi speciali. */
|
|
1267
|
+
function ReaderLine({ line, from, to }) {
|
|
1268
|
+
const a = Math.max(0, Math.min(line.text.length, from - line.start));
|
|
1269
|
+
const b = Math.max(0, Math.min(line.text.length, to - line.start));
|
|
1270
|
+
if (b <= a) {
|
|
1271
|
+
return _jsx(Text, { wrap: "truncate-end", children: line.text || ' ' });
|
|
1272
|
+
}
|
|
1273
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [line.text.slice(0, a), _jsx(Text, { backgroundColor: "yellow", color: "black", children: line.text.slice(a, b) }), line.text.slice(b)] }));
|
|
1274
|
+
}
|
|
879
1275
|
function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, }) {
|
|
880
1276
|
const spotSelected = selected === 0;
|
|
881
1277
|
return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Tasks (", hidden > 0 ? `${filtered}/${total}` : filtered, ")", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 ", hidden, " nascoste"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort: ", describeSort(view.sort), view.hiddenPri.length + view.hiddenProg.length > 0 ? (_jsxs(Text, { children: [' ', "\u00B7 filtri:", ' ', [
|
|
@@ -891,14 +1287,26 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
|
|
|
891
1287
|
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));
|
|
892
1288
|
})), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
|
|
893
1289
|
}
|
|
894
|
-
function SessionsPane({ parentLabel, isSpot,
|
|
895
|
-
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, ")", 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' })) : (
|
|
896
|
-
|
|
1290
|
+
function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, }) {
|
|
1291
|
+
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) => {
|
|
1292
|
+
// T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
|
|
1293
|
+
// box pesante (coerente con lo styling delle Done dimmate).
|
|
1294
|
+
if (row.kind === 'separator') {
|
|
1295
|
+
return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: SESSION_SEP }, `sep${i}`));
|
|
1296
|
+
}
|
|
1297
|
+
const sel = row.sessionId === selectedId;
|
|
1298
|
+
// T50 — pin stale: transcript sparito, nessuna Session da mostrare.
|
|
1299
|
+
// Riga navigabile e spinnabile (`p`), marcata, mai un crash.
|
|
1300
|
+
if (row.kind === 'pinned' && row.stale) {
|
|
1301
|
+
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));
|
|
1302
|
+
}
|
|
1303
|
+
const s = row.session; // non-stale → session presente
|
|
1304
|
+
const isPinnedRow = row.kind === 'pinned';
|
|
897
1305
|
// T28 — un ramo eredita il titolo dell'origine: senza marcatore le due
|
|
898
1306
|
// righe sarebbero identiche a occhio. `⑂` sta PRIMA del titolo, dove
|
|
899
1307
|
// la troncatura non arriva mai.
|
|
900
1308
|
const forked = forkOf.has(s.sessionId);
|
|
901
|
-
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isSpot ? _jsx(Text, { dimColor: true, children: "\u25CB" }) : _jsx(Text, { color: "green", children: "\uD83D\uDD17" }), ' ', forked ? _jsx(Text, { color: "magenta", children: "\u2442 " }) : null, truncate(s.title, forked ? 42 : 44), ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
|
|
1309
|
+
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, truncate(s.title, forked ? 42 : 44), ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
|
|
902
1310
|
})), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null })) : null] }));
|
|
903
1311
|
}
|
|
904
1312
|
// T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
|