@lamemind/loom-deck 0.12.0 → 0.13.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/dist/cli.js +98 -38
- package/dist/session-list.js +80 -0
- package/dist/task-index.js +16 -2
- 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,7 +9,8 @@ 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 { appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
12
|
+
import { appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
13
|
+
import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, selectedSession, } from './session-list.js';
|
|
13
14
|
import { launchLegend, loadIdentity, loadLaunch } from './config.js';
|
|
14
15
|
import { layoutBudget, normalizeEmoji, windowRange, wrapLines, } from './viewport.js';
|
|
15
16
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
@@ -262,7 +263,12 @@ function useSessions(projectRoot) {
|
|
|
262
263
|
sessions: [],
|
|
263
264
|
bindings: new Map(),
|
|
264
265
|
forkOf: new Map(),
|
|
266
|
+
pinned: new Map(),
|
|
265
267
|
});
|
|
268
|
+
// T50 — pin/unpin scrive il sidecar e vuole feedback IMMEDIATO, non al
|
|
269
|
+
// prossimo tick del poll (1.5s): la reload è esposta via ref così il toggle la
|
|
270
|
+
// richiama senza risottoscrivere l'intervallo.
|
|
271
|
+
const reloadRef = useRef(() => { });
|
|
266
272
|
useEffect(() => {
|
|
267
273
|
let lastSig = '';
|
|
268
274
|
const reload = () => {
|
|
@@ -274,27 +280,30 @@ function useSessions(projectRoot) {
|
|
|
274
280
|
}
|
|
275
281
|
catch {
|
|
276
282
|
sessions = [];
|
|
277
|
-
index = { bindings: new Map(), forkOf: new Map() };
|
|
283
|
+
index = { bindings: new Map(), forkOf: new Map(), pinned: new Map() };
|
|
278
284
|
}
|
|
279
|
-
const { bindings, forkOf } = index;
|
|
280
|
-
// La signature copre anche
|
|
281
|
-
// cambia
|
|
282
|
-
// farebbe un binding nuovo.
|
|
285
|
+
const { bindings, forkOf, pinned } = index;
|
|
286
|
+
// La signature copre anche fork e pin: un record di lineage o un toggle di
|
|
287
|
+
// pin appena scritto cambia la lista renderizzata, quindi deve forzare il
|
|
288
|
+
// re-render come farebbe un binding nuovo.
|
|
283
289
|
const sig = sessions.map((s) => `${s.sessionId}:${s.ts}`).join('|') +
|
|
284
290
|
'#' +
|
|
285
291
|
[...bindings.entries()].map(([k, v]) => `${k}=${v}`).sort().join(',') +
|
|
286
292
|
'#' +
|
|
287
|
-
[...forkOf.entries()].map(([k, v]) => `${k}<${v}`).sort().join(',')
|
|
293
|
+
[...forkOf.entries()].map(([k, v]) => `${k}<${v}`).sort().join(',') +
|
|
294
|
+
'#' +
|
|
295
|
+
[...pinned.entries()].map(([k, v]) => `${k}@${v}`).sort().join(',');
|
|
288
296
|
if (sig === lastSig)
|
|
289
297
|
return;
|
|
290
298
|
lastSig = sig;
|
|
291
|
-
setState({ sessions, bindings, forkOf });
|
|
299
|
+
setState({ sessions, bindings, forkOf, pinned });
|
|
292
300
|
};
|
|
301
|
+
reloadRef.current = reload;
|
|
293
302
|
reload();
|
|
294
303
|
const id = setInterval(reload, POLL_MS);
|
|
295
304
|
return () => clearInterval(id);
|
|
296
305
|
}, [projectRoot]);
|
|
297
|
-
return state;
|
|
306
|
+
return { ...state, reload: () => reloadRef.current() };
|
|
298
307
|
}
|
|
299
308
|
// Legge il task file della task selezionata (Q1+B T20). On-id-change: navigare
|
|
300
309
|
// con ↑↓ ricarica il dettaglio; leggere un singolo file 4-9KB è I/O triviale,
|
|
@@ -330,6 +339,13 @@ const forceEmojiWidth = normalizeEmoji;
|
|
|
330
339
|
// larghi 1 e restano intatti.
|
|
331
340
|
const CARET = normalizeEmoji('▶ ');
|
|
332
341
|
const CARET_OFF = ' ';
|
|
342
|
+
// T50 — glifo warning della riga pin-stale: BMP largo 2 senza VS16 → va timbrato
|
|
343
|
+
// (come CARET), altrimenti la riga slitta di una colonna.
|
|
344
|
+
const WARN = normalizeEmoji('⚠');
|
|
345
|
+
// T50 — separatore leggero fra blocco pinnate e contestuali. `─` (box-drawing) è
|
|
346
|
+
// largo 1 sia per string-width sia per il terminale → nessun timbro. Corto +
|
|
347
|
+
// wrap="truncate-end" così non va mai a capo nel pane al 50%.
|
|
348
|
+
const SESSION_SEP = '─'.repeat(16);
|
|
333
349
|
// Normalizza il marker Done per il display. `✔` (U+2714) è text-presentation-
|
|
334
350
|
// default: string-width — quindi Ink, sia per il layout sia per il troncamento —
|
|
335
351
|
// lo misura 2 (rispetta il VS16 di `✔️`), ma VTE/Ptyxis lo disegna largo 1
|
|
@@ -374,14 +390,17 @@ const META_KEYS = ['Priority', 'Size', 'Estimated Time', 'Progress'];
|
|
|
374
390
|
function Deck({ cwd, tasksPath, tasksDir }) {
|
|
375
391
|
const { exit } = useApp();
|
|
376
392
|
const { tasks, loadError } = useTasks(tasksPath);
|
|
377
|
-
const { sessions, bindings, forkOf } = useSessions(cwd);
|
|
393
|
+
const { sessions, bindings, forkOf, pinned, reload: reloadSessions } = useSessions(cwd);
|
|
378
394
|
const [focus, setFocus] = useState('tasks');
|
|
379
395
|
// T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
|
|
380
396
|
// (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
|
|
381
397
|
// grezzo per posizione spawnerebbe la task sbagliata, in silenzio. `null` = la
|
|
382
398
|
// riga meta "spot", sempre in testa alla lista.
|
|
383
399
|
const [selId, setSelId] = useState(null);
|
|
384
|
-
|
|
400
|
+
// T50 — selezione del pane sessioni KEYED SU sessionId (non indice): la lista
|
|
401
|
+
// a due gruppi + separatore è una vista trasformata, un indice grezzo punterebbe
|
|
402
|
+
// alla riga sbagliata dopo un pin o un cambio di contesto (stesso trap T39).
|
|
403
|
+
const [selSessionId, setSelSessionId] = useState(null);
|
|
385
404
|
const [note, setNote] = useState('');
|
|
386
405
|
// Modali: catturano i tasti e corto-circuitano la navigazione normale.
|
|
387
406
|
// T30 create · T39 sort/filter.
|
|
@@ -424,12 +443,18 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
424
443
|
}
|
|
425
444
|
// Figli della selezione: sessioni bound alla task selezionata, oppure (spot)
|
|
426
445
|
// le sessioni senza binding. sessions è già ts desc → l'ordine si eredita.
|
|
427
|
-
|
|
446
|
+
// Memoizzato così `sessionRows` resta stabile fra render che non cambiano gli
|
|
447
|
+
// input: l'effect di validità della selezione non rigira a vuoto.
|
|
448
|
+
const childSessions = useMemo(() => sessions.filter((s) => {
|
|
428
449
|
const bound = bindings.get(s.sessionId);
|
|
429
450
|
return selectedTaskId ? bound === selectedTaskId : !bound;
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
|
|
451
|
+
}), [sessions, bindings, selectedTaskId]);
|
|
452
|
+
// T50 — lista a due gruppi: pinnate (sempre, in cima) + separatore +
|
|
453
|
+
// contestuali. Dedup, cap solo sulle contestuali, righe stale per le pinnate
|
|
454
|
+
// orfane. Core PURO in session-list.ts (testabile senza Ink).
|
|
455
|
+
const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, MAX_SESSIONS), [childSessions, sessions, pinned]);
|
|
456
|
+
const sessionRows = assembled.rows;
|
|
457
|
+
const selSessionObj = selectedSession(sessionRows, selSessionId);
|
|
433
458
|
// T39 — selezione stabile sotto trasformazione. Se la task selezionata esce
|
|
434
459
|
// dalla vista (filtro appena attivato, oppure sparita da tasks.md), si cade
|
|
435
460
|
// sulla prima visibile — fallback deterministico, mai una posizione a caso.
|
|
@@ -438,13 +463,15 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
438
463
|
setSelId(viewTasks[0]?.id ?? null);
|
|
439
464
|
}
|
|
440
465
|
}, [viewTasks, selId]);
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
466
|
+
// T50 — la selezione (id) resta valida sotto la vista a due gruppi: se l'id
|
|
467
|
+
// non è più una riga selezionabile (cambio parent, lista mutata, pin rimosso,
|
|
468
|
+
// sessione sparita) cade sulla prima riga — fallback deterministico, mai una
|
|
469
|
+
// posizione a caso. Sostituisce il reset-a-0 e il clamp index-based.
|
|
445
470
|
useEffect(() => {
|
|
446
|
-
|
|
447
|
-
|
|
471
|
+
if (rowIndexOf(sessionRows, selSessionId) < 0) {
|
|
472
|
+
setSelSessionId(firstSelectableId(sessionRows));
|
|
473
|
+
}
|
|
474
|
+
}, [sessionRows, selSessionId]);
|
|
448
475
|
// T30: submit dell'input box. Il taskId nasce DOPO create-task (lo assegna la
|
|
449
476
|
// skill scrivendo tasks.md) → non è noto allo spawn. Il sessionId invece è
|
|
450
477
|
// pinnato qui: snapshot degli id PRIMA, poi al completamento re-leggo tasks.md
|
|
@@ -668,13 +695,13 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
668
695
|
if (focus === 'tasks')
|
|
669
696
|
moveTaskSel(-1);
|
|
670
697
|
else
|
|
671
|
-
|
|
698
|
+
setSelSessionId((id) => moveSelection(sessionRows, id, -1));
|
|
672
699
|
}
|
|
673
700
|
else if (key.downArrow) {
|
|
674
701
|
if (focus === 'tasks')
|
|
675
702
|
moveTaskSel(1);
|
|
676
703
|
else
|
|
677
|
-
|
|
704
|
+
setSelSessionId((id) => moveSelection(sessionRows, id, 1));
|
|
678
705
|
}
|
|
679
706
|
else if (key.return) {
|
|
680
707
|
if (focus === 'tasks') {
|
|
@@ -697,9 +724,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
697
724
|
else {
|
|
698
725
|
// T49 — ⏎ su una sessione = resume in nuova tab. Il binding si rilegge
|
|
699
726
|
// dal sidecar (non dal padre selezionato): vale anche per le spot.
|
|
700
|
-
const s =
|
|
727
|
+
const s = selSessionObj;
|
|
701
728
|
if (!s) {
|
|
702
|
-
setNote('nessuna sessione da riprendere');
|
|
729
|
+
setNote(selSessionId ? 'pin stale: transcript non più presente' : 'nessuna sessione da riprendere');
|
|
703
730
|
}
|
|
704
731
|
else {
|
|
705
732
|
const bound = bindings.get(s.sessionId) ?? null;
|
|
@@ -739,9 +766,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
739
766
|
setNote('f → fork: seleziona una sessione (←→ per il pane)');
|
|
740
767
|
}
|
|
741
768
|
else {
|
|
742
|
-
const s =
|
|
769
|
+
const s = selSessionObj;
|
|
743
770
|
if (!s) {
|
|
744
|
-
setNote('f → nessuna sessione da forkare');
|
|
771
|
+
setNote(selSessionId ? 'f → pin stale: niente da forkare' : 'f → nessuna sessione da forkare');
|
|
745
772
|
}
|
|
746
773
|
else {
|
|
747
774
|
// L'id del ramo nasce qui, prima dello spawn: pinnandolo posso
|
|
@@ -761,6 +788,25 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
761
788
|
}
|
|
762
789
|
}
|
|
763
790
|
}
|
|
791
|
+
else if (input === 'p') {
|
|
792
|
+
// T50 — pin/unpin della conversazione selezionata. Minuscola = azione
|
|
793
|
+
// immediata (convenzione T39), gemella di `f`: opera sulla riga
|
|
794
|
+
// selezionata del pane sessioni, quindi vive solo lì. Vale anche su una
|
|
795
|
+
// pinnata STALE (l'unico modo di spinnarla). Scrive il sidecar e ricarica
|
|
796
|
+
// subito, senza attendere il tick del poll.
|
|
797
|
+
if (focus !== 'sessions') {
|
|
798
|
+
setNote('p → pin: seleziona una sessione (←→ per il pane)');
|
|
799
|
+
}
|
|
800
|
+
else if (!selSessionId) {
|
|
801
|
+
setNote('p → nessuna sessione da pinnare');
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
const isPinned = pinned.has(selSessionId);
|
|
805
|
+
appendPin(cwd, selSessionId, !isPinned);
|
|
806
|
+
reloadSessions();
|
|
807
|
+
setNote(`${isPinned ? 'unpin' : '📌 pin'} ${selSessionId.slice(0, 8)}`);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
764
810
|
else if (input === 't') {
|
|
765
811
|
const title = identity ? `🖥️ ${identity.owner} ${identity.name} [term]` : null;
|
|
766
812
|
const child = spawnTerminal(cwd, title);
|
|
@@ -801,11 +847,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
801
847
|
exit();
|
|
802
848
|
}
|
|
803
849
|
});
|
|
804
|
-
const selSessionObj = visibleSessions[selSession] ?? null;
|
|
805
|
-
const selSessionId = selSessionObj?.sessionId;
|
|
806
850
|
const parentLabel = isSpot ? 'spot' : selectedTaskId ?? '—';
|
|
807
851
|
const canSpawn = focus === 'tasks' && !isSpot;
|
|
808
852
|
const canResume = focus === 'sessions' && selSessionObj !== null;
|
|
853
|
+
// T50 — il pin agisce su qualunque riga selezionata (anche stale, per
|
|
854
|
+
// spinnarla); basta il focus sul pane e una selezione.
|
|
855
|
+
const canPin = focus === 'sessions' && selSessionId !== null;
|
|
809
856
|
// Larghezza dal medesimo hook che dà l'altezza: dopo un resize la legenda si
|
|
810
857
|
// ricalcola con lo stesso re-render che ridimensiona i pane.
|
|
811
858
|
const legend = launchLegend(launch, columns);
|
|
@@ -833,20 +880,21 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
833
880
|
sessionHasFirstPreview: canResume && Boolean(selSessionObj?.customTitle),
|
|
834
881
|
sessionHasLastPreview: canResume && Boolean(selSessionObj?.lastReply),
|
|
835
882
|
});
|
|
836
|
-
// Finestre di rendering. Le liste "logiche" (viewTasks,
|
|
883
|
+
// Finestre di rendering. Le liste "logiche" (viewTasks, sessionRows)
|
|
837
884
|
// restano intere: navigazione, selezione e spawn continuano a ragionare su
|
|
838
885
|
// quelle, la finestra è solo ciò che finisce a schermo.
|
|
839
886
|
const taskWin = windowRange(viewTasks.length, selIndex - 1, budget.taskRows);
|
|
840
887
|
const windowTasks = viewTasks.slice(taskWin.start, taskWin.end);
|
|
841
|
-
const
|
|
842
|
-
const
|
|
888
|
+
const selRowIndex = rowIndexOf(sessionRows, selSessionId);
|
|
889
|
+
const sessionWin = windowRange(sessionRows.length, selRowIndex, budget.sessionRows);
|
|
890
|
+
const windowRows = sessionRows.slice(sessionWin.start, sessionWin.end);
|
|
843
891
|
// Sotto la soglia minima il layout a box non entra a nessun costo: si scende
|
|
844
892
|
// a una riga sola. Perdere il deck per un terminale basso è meglio che
|
|
845
893
|
// sporcare la cronologia del terminale a ogni poll.
|
|
846
894
|
if (budget.compact) {
|
|
847
895
|
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
896
|
}
|
|
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,
|
|
897
|
+
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 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
898
|
}
|
|
851
899
|
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
852
900
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
@@ -891,14 +939,26 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
|
|
|
891
939
|
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
940
|
})), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
|
|
893
941
|
}
|
|
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
|
-
|
|
942
|
+
function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, }) {
|
|
943
|
+
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) => {
|
|
944
|
+
// T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
|
|
945
|
+
// box pesante (coerente con lo styling delle Done dimmate).
|
|
946
|
+
if (row.kind === 'separator') {
|
|
947
|
+
return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: SESSION_SEP }, `sep${i}`));
|
|
948
|
+
}
|
|
949
|
+
const sel = row.sessionId === selectedId;
|
|
950
|
+
// T50 — pin stale: transcript sparito, nessuna Session da mostrare.
|
|
951
|
+
// Riga navigabile e spinnabile (`p`), marcata, mai un crash.
|
|
952
|
+
if (row.kind === 'pinned' && row.stale) {
|
|
953
|
+
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));
|
|
954
|
+
}
|
|
955
|
+
const s = row.session; // non-stale → session presente
|
|
956
|
+
const isPinnedRow = row.kind === 'pinned';
|
|
897
957
|
// T28 — un ramo eredita il titolo dell'origine: senza marcatore le due
|
|
898
958
|
// righe sarebbero identiche a occhio. `⑂` sta PRIMA del titolo, dove
|
|
899
959
|
// la troncatura non arriva mai.
|
|
900
960
|
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));
|
|
961
|
+
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
962
|
})), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null })) : null] }));
|
|
903
963
|
}
|
|
904
964
|
// T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// T50 — Assemblaggio della lista sessioni a DUE gruppi: pinnate (sempre in
|
|
2
|
+
// lista, in cima) + separatore + contestuali (figlie del parent selezionato).
|
|
3
|
+
// Modulo PURO: nessun import da ink/react, nessun I/O → testabile senza terminale
|
|
4
|
+
// (il pacchetto non ha infrastruttura TUI di test, solo unit sui core puri).
|
|
5
|
+
//
|
|
6
|
+
// Invarianti (decisioni congelate al preflight):
|
|
7
|
+
// - D2: dentro il blocco pinnate l'ordine è di PIN stabile (ultima pinnata in
|
|
8
|
+
// cima), dato dal rango del sidecar `session-tasks.jsonl` — non `ts desc`.
|
|
9
|
+
// - Dedup: una sessione sia pinnata sia contestuale compare SOLO fra le pinnate.
|
|
10
|
+
// - Cap: `maxContext` limita SOLO le contestuali; le pinnate sono esenti. Il
|
|
11
|
+
// conteggio delle troncate (`contextHidden`) resta esposto (non-silenzioso).
|
|
12
|
+
// - D3: una pinnata il cui transcript non esiste più è una riga STALE (session
|
|
13
|
+
// null), navigabile e spinnabile — non sparisce in silenzio.
|
|
14
|
+
// - Trap T39: la selezione è KEYED SU sessionId, non su indice posizionale. La
|
|
15
|
+
// lista è una vista trasformata (due gruppi + separatore): un indice grezzo
|
|
16
|
+
// identificherebbe la riga sbagliata dopo un pin/cambio-contesto.
|
|
17
|
+
export function assembleSessionList(childSessions, allSessions, pinned, maxContext) {
|
|
18
|
+
// Le pinnate si risolvono contro TUTTE le sessioni del progetto, non solo le
|
|
19
|
+
// figlie del parent: una pinnata può appartenere a un'altra task. Assente
|
|
20
|
+
// dallo store → stale.
|
|
21
|
+
const byId = new Map(allSessions.map((s) => [s.sessionId, s]));
|
|
22
|
+
const pinnedIds = [...pinned.entries()]
|
|
23
|
+
.sort((a, b) => b[1] - a[1]) // rango desc → ultima pinnata in cima (D2)
|
|
24
|
+
.map(([id]) => id);
|
|
25
|
+
const pinnedRows = pinnedIds.map((id) => {
|
|
26
|
+
const session = byId.get(id) ?? null;
|
|
27
|
+
return { kind: 'pinned', sessionId: id, session, stale: session === null };
|
|
28
|
+
});
|
|
29
|
+
const pinnedSet = new Set(pinnedIds);
|
|
30
|
+
const dedupedContext = childSessions.filter((s) => !pinnedSet.has(s.sessionId));
|
|
31
|
+
const shownContext = dedupedContext.slice(0, Math.max(0, maxContext));
|
|
32
|
+
const contextRows = shownContext.map((s) => ({
|
|
33
|
+
kind: 'context',
|
|
34
|
+
sessionId: s.sessionId,
|
|
35
|
+
session: s,
|
|
36
|
+
}));
|
|
37
|
+
const rows = [...pinnedRows];
|
|
38
|
+
// Separatore SOLO fra due gruppi entrambi non vuoti: senza pinnate o senza
|
|
39
|
+
// contestuali non c'è confine da segnare.
|
|
40
|
+
if (pinnedRows.length > 0 && contextRows.length > 0)
|
|
41
|
+
rows.push({ kind: 'separator' });
|
|
42
|
+
rows.push(...contextRows);
|
|
43
|
+
return {
|
|
44
|
+
rows,
|
|
45
|
+
pinnedCount: pinnedRows.length,
|
|
46
|
+
contextTotal: dedupedContext.length,
|
|
47
|
+
contextHidden: dedupedContext.length - shownContext.length,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function isSelectable(r) {
|
|
51
|
+
return r.kind !== 'separator';
|
|
52
|
+
}
|
|
53
|
+
/** Primo id selezionabile (salta il separatore); null = lista senza righe. */
|
|
54
|
+
export function firstSelectableId(rows) {
|
|
55
|
+
return rows.find(isSelectable)?.sessionId ?? null;
|
|
56
|
+
}
|
|
57
|
+
/** Indice della riga con quel sessionId nell'ARRAY COMPLETO (per il windowing);
|
|
58
|
+
* -1 se assente o id null. Il separatore non ha id → mai matchato. */
|
|
59
|
+
export function rowIndexOf(rows, sessionId) {
|
|
60
|
+
if (sessionId === null)
|
|
61
|
+
return -1;
|
|
62
|
+
return rows.findIndex((r) => isSelectable(r) && r.sessionId === sessionId);
|
|
63
|
+
}
|
|
64
|
+
/** Sposta la selezione di `delta` fra le sole righe selezionabili (attraversa il
|
|
65
|
+
* separatore senza fermarcisi). Id perso → prima riga; lista vuota → null. */
|
|
66
|
+
export function moveSelection(rows, currentId, delta) {
|
|
67
|
+
const selectable = rows.filter(isSelectable);
|
|
68
|
+
if (selectable.length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
const cur = selectable.findIndex((r) => r.sessionId === currentId);
|
|
71
|
+
if (cur < 0)
|
|
72
|
+
return selectable[0].sessionId;
|
|
73
|
+
const next = Math.max(0, Math.min(selectable.length - 1, cur + delta));
|
|
74
|
+
return selectable[next].sessionId;
|
|
75
|
+
}
|
|
76
|
+
/** Session della riga selezionata; null se stale o nessuna selezione. */
|
|
77
|
+
export function selectedSession(rows, sessionId) {
|
|
78
|
+
const r = rows.find((row) => isSelectable(row) && row.sessionId === sessionId);
|
|
79
|
+
return r && isSelectable(r) ? r.session : null;
|
|
80
|
+
}
|
package/dist/task-index.js
CHANGED
|
@@ -11,6 +11,10 @@ export function appendSessionRecord(projectRoot, rec) {
|
|
|
11
11
|
export function appendTaskBinding(projectRoot, sessionId, taskId) {
|
|
12
12
|
appendSessionRecord(projectRoot, { sessionId, taskId });
|
|
13
13
|
}
|
|
14
|
+
/** T50 — pin/unpin (toggle): append immediato, il reader risolve last-wins. */
|
|
15
|
+
export function appendPin(projectRoot, sessionId, pinned) {
|
|
16
|
+
appendSessionRecord(projectRoot, { sessionId, pinned });
|
|
17
|
+
}
|
|
14
18
|
// Una sola lettura del JSONL per entrambe le mappe: il deck poll-a l'indice a
|
|
15
19
|
// ogni tick, leggere il file due volte raddoppierebbe l'I/O per nulla.
|
|
16
20
|
// Last-wins per campo (un re-pin dello stesso sessionId sovrascrive), e i due
|
|
@@ -19,13 +23,15 @@ export function appendTaskBinding(projectRoot, sessionId, taskId) {
|
|
|
19
23
|
export function loadSessionIndex(projectRoot) {
|
|
20
24
|
const bindings = new Map();
|
|
21
25
|
const forkOf = new Map();
|
|
26
|
+
const pinned = new Map();
|
|
22
27
|
let content;
|
|
23
28
|
try {
|
|
24
29
|
content = readFileSync(taskIndexPath(projectRoot), 'utf8');
|
|
25
30
|
}
|
|
26
31
|
catch {
|
|
27
|
-
return { bindings, forkOf };
|
|
32
|
+
return { bindings, forkOf, pinned };
|
|
28
33
|
}
|
|
34
|
+
let order = 0; // posizione crescente dei record pinned → rango di pin (D2)
|
|
29
35
|
for (const line of content.split('\n')) {
|
|
30
36
|
if (!line.trim())
|
|
31
37
|
continue;
|
|
@@ -37,10 +43,18 @@ export function loadSessionIndex(projectRoot) {
|
|
|
37
43
|
bindings.set(d.sessionId, d.taskId);
|
|
38
44
|
if (typeof d.forkOf === 'string')
|
|
39
45
|
forkOf.set(d.sessionId, d.forkOf);
|
|
46
|
+
// last-wins per campo: un `pinned:false` finale rimuove il pin, un
|
|
47
|
+
// `pinned:true` (ri)assegna il rango con la posizione corrente nel file.
|
|
48
|
+
if (typeof d.pinned === 'boolean') {
|
|
49
|
+
if (d.pinned)
|
|
50
|
+
pinned.set(d.sessionId, order++);
|
|
51
|
+
else
|
|
52
|
+
pinned.delete(d.sessionId);
|
|
53
|
+
}
|
|
40
54
|
}
|
|
41
55
|
catch {
|
|
42
56
|
// riga corrotta → skip
|
|
43
57
|
}
|
|
44
58
|
}
|
|
45
|
-
return { bindings, forkOf };
|
|
59
|
+
return { bindings, forkOf, pinned };
|
|
46
60
|
}
|
package/package.json
CHANGED