@lamemind/loom-deck 0.54.1 → 0.56.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.
@@ -0,0 +1,231 @@
1
+ // I modali IN FLUSSO: quelli che si disegnano in un box dentro la cornice
2
+ // invece di sostituirla. Sono due famiglie e stanno nello stesso file perché
3
+ // hanno lo stesso mestiere — tenere una bozza mentre si digita — e perché il
4
+ // loro gemello di vista esiste già e si chiama `ui/modals.tsx`. La simmetria
5
+ // `ui/modals.tsx` (vista) ↔ `overlays/modals.ts` (stato e input) è essa stessa
6
+ // un'informazione: chi cerca l'uno trova l'altro.
7
+ //
8
+ // `useViewModals` governa sort e filtri, che compongono la VISTA. Lo stato
9
+ // `view` non è loro e vive nel modello (`deck-model.ts`): è ciò che `applyView`
10
+ // consuma, è persistito su disco e lo rilegge il tasto `w`. Qui resta ciò che è
11
+ // davvero del modale — la fotografia per l'annullamento e la posizione nella
12
+ // griglia dei filtri.
13
+ //
14
+ // `useTextModals` governa create, nota ed edit, cioè i tre campi di testo. Le
15
+ // scritture che ne conseguono escono come callback (deroga T104: un hook di
16
+ // overlay non esce dal deck).
17
+ import { useState } from 'react';
18
+ import { caretAtEnd, fieldsKey } from '../fields.js';
19
+ import { sanitizeTyped } from '../glyphs.js';
20
+ import { EDIT_FIELDS, EDIT_PRI, EDIT_PROG, editTextField, SORT_TASTI, } from '../model.js';
21
+ import { cycleSort, describeSort, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from '../view.js';
22
+ export function useViewModals({ view, setView, hiddenTasks, setMode, setNote, }) {
23
+ // T39 — fotografia della vista all'apertura di un modale: la lista si
24
+ // aggiorna dal vivo, quindi `esc` deve poter ripristinare.
25
+ const [backup, setBackup] = useState(null);
26
+ const [filterCursor, setFilterCursor] = useState({ row: 0, col: 0 });
27
+ /** Chiusura: `restore` rimette la fotografia (esc = annulla), altrimenti tiene ciò che si è composto (⏎). */
28
+ function close(restore) {
29
+ if (restore && backup) {
30
+ const snapshot = backup;
31
+ setView(() => snapshot);
32
+ }
33
+ setBackup(null);
34
+ setMode('normal');
35
+ }
36
+ function openSort() {
37
+ setBackup(view);
38
+ setNote('');
39
+ setMode('sort');
40
+ }
41
+ function openFilter() {
42
+ setBackup(view);
43
+ setNote('');
44
+ setMode('filter');
45
+ }
46
+ function onSortKey(input, key) {
47
+ if (key.escape) {
48
+ close(true);
49
+ setNote('S → sort annullato');
50
+ }
51
+ else if (key.return) {
52
+ close(false);
53
+ setNote(`S → sort: ${describeSort(view.sort)}`);
54
+ }
55
+ else if (input) {
56
+ // useInput consegna il CHUNK letto da stdin, non un tasto: digitando
57
+ // veloce (o incollando) `ppi` arriva come stringa unica. Si cicla su
58
+ // ogni carattere, così la chain esce identica a battitura lenta.
59
+ const keys = [...input].map((ch) => SORT_TASTI[ch]).filter(Boolean);
60
+ if (keys.length > 0) {
61
+ setView((v) => ({ ...v, sort: keys.reduce(cycleSort, v.sort) }));
62
+ }
63
+ }
64
+ }
65
+ function onFilterKey(input, key) {
66
+ const rowLen = (r) => (r === 0 ? PRI_ENTRIES.length : PROG_ENTRIES.length);
67
+ if (key.escape) {
68
+ close(true);
69
+ setNote('F → filtri annullati');
70
+ }
71
+ else if (key.return) {
72
+ close(false);
73
+ setNote(hiddenTasks > 0 ? `F → ${hiddenTasks} task nascoste` : 'F → nessun filtro attivo');
74
+ }
75
+ else if (key.upArrow || key.downArrow) {
76
+ setFilterCursor((c) => {
77
+ const row = c.row === 0 ? 1 : 0;
78
+ return { row, col: Math.min(c.col, rowLen(row) - 1) };
79
+ });
80
+ }
81
+ else if (key.leftArrow) {
82
+ setFilterCursor((c) => ({ ...c, col: Math.max(0, c.col - 1) }));
83
+ }
84
+ else if (key.rightArrow) {
85
+ setFilterCursor((c) => ({ ...c, col: Math.min(rowLen(c.row) - 1, c.col + 1) }));
86
+ }
87
+ else if (input === ' ') {
88
+ const { row, col } = filterCursor;
89
+ setView((v) => row === 0
90
+ ? { ...v, hiddenPri: toggleHidden(v.hiddenPri, PRI_ENTRIES[col].name) }
91
+ : { ...v, hiddenProg: toggleHidden(v.hiddenProg, PROG_ENTRIES[col].name) });
92
+ }
93
+ }
94
+ return { filterCursor, openSort, openFilter, onSortKey, onFilterKey };
95
+ }
96
+ export function useTextModals({ setMode, setNote, onCreate, onNote, onEdit, editDraftFor, currentNote, }) {
97
+ const [draft, setDraft] = useState('');
98
+ // T53 — bozza della nota sulla conversazione selezionata. Si apre PRECARICATA
99
+ // con la nota esistente: annotare due volte è quasi sempre correggere, non
100
+ // riscrivere da zero, e un campo vuoto costringerebbe a ridigitare tutto per
101
+ // cambiare una parola. Confermare il campo vuoto cancella la nota.
102
+ const [noteDraft, setNoteDraft] = useState('');
103
+ const [noteSid, setNoteSid] = useState(null);
104
+ // T41 — bozza dell'edit (null fuori dal modale) e posizione nell'area di
105
+ // compilazione: riga in fuoco + caret dentro la riga di testo attiva.
106
+ const [edit, setEdit] = useState(null);
107
+ const [editTask, setEditTask] = useState(null);
108
+ const [editCursor, setEditCursor] = useState({ row: 0, caret: 0 });
109
+ function openCreate() {
110
+ setNote('');
111
+ setMode('create');
112
+ }
113
+ function openNote(sid) {
114
+ setNoteSid(sid);
115
+ setNoteDraft(currentNote(sid));
116
+ setNote('');
117
+ setMode('note');
118
+ }
119
+ function openEdit(task) {
120
+ setEditTask(task);
121
+ setEdit(editDraftFor(task));
122
+ // Si apre sulla riga 0 (priorità), che non è un campo di testo: il caret
123
+ // prende la sua posizione entrando in una riga di testo con ↑↓.
124
+ setEditCursor({ row: 0, caret: 0 });
125
+ setNote('');
126
+ setMode('edit');
127
+ }
128
+ function onCreateKey(input, key) {
129
+ if (key.escape) {
130
+ setMode('normal');
131
+ setDraft('');
132
+ setNote('C → create annullato');
133
+ }
134
+ else if (key.return) {
135
+ const text = draft.trim();
136
+ setMode('normal');
137
+ setDraft('');
138
+ onCreate(text);
139
+ }
140
+ else if (key.backspace || key.delete) {
141
+ setDraft((d) => d.slice(0, -1));
142
+ }
143
+ else if (input && !key.ctrl && !key.meta) {
144
+ setDraft((d) => d + input);
145
+ }
146
+ }
147
+ function onNoteKey(input, key) {
148
+ if (key.escape) {
149
+ setMode('normal');
150
+ setNoteDraft('');
151
+ setNote('N → titolo annullato');
152
+ }
153
+ else if (key.return) {
154
+ const sid = noteSid;
155
+ const text = noteDraft.trim();
156
+ setMode('normal');
157
+ setNoteDraft('');
158
+ if (sid)
159
+ onNote(sid, text);
160
+ }
161
+ else if (key.ctrl && input === 'u') {
162
+ // Svuota il campo in un colpo. NON è una scorciatoia di comodo: il
163
+ // backspace tenuto premuto cancella UN carattere per CHUNK letto da
164
+ // stdin, non per pressione (`useInput` consegna il chunk, e per una
165
+ // raffica di DEL Ink alza `key.backspace` una volta sola) — misurato,
166
+ // 30 pressioni → 2 caratteri. Siccome «campo vuoto» qui è l'unico modo
167
+ // di CANCELLARE una nota, dipendere dal backspace renderebbe
168
+ // l'operazione praticamente non eseguibile. `^U` è il kill-line delle
169
+ // shell, quindi il gesto è già nelle dita di chi usa un terminale.
170
+ setNoteDraft('');
171
+ }
172
+ else if (key.backspace || key.delete) {
173
+ setNoteDraft((d) => d.slice(0, -1));
174
+ }
175
+ else if (input && !key.ctrl && !key.meta) {
176
+ // Sanificazione dei byte di controllo, come `typeIntoField` della
177
+ // ricerca: `useInput` consegna il CHUNK letto da stdin, quindi un
178
+ // incollaggio porta dentro newline e control char. Invisibili a schermo
179
+ // ma contati da Ink nella larghezza della riga — e qui finirebbero
180
+ // scritti su disco, dove resterebbero a sporcare la riga per sempre.
181
+ // Le newline diventano spazio: incollare due righe deve separare le
182
+ // parole, non fonderle.
183
+ setNoteDraft((d) => d + sanitizeTyped(input));
184
+ }
185
+ }
186
+ // Il ponte fra le quattro righe del modale e la bozza. I valori restano
187
+ // tipizzati (`PriName`, `ProgName`) invece di finire in un record generico:
188
+ // `fields.ts` governa la grammatica dei tasti, non il modello dati.
189
+ const editIO = {
190
+ text: (row) => edit?.[editTextField(row)] ?? '',
191
+ setText: (row, next) => setEdit((e) => (e ? { ...e, [editTextField(row)]: next } : e)),
192
+ choice: (row) => row === 0
193
+ ? Math.max(0, EDIT_PRI.indexOf(edit?.pri ?? 'med'))
194
+ : Math.max(0, EDIT_PROG.indexOf(edit?.prog ?? 'todo')),
195
+ setChoice: (row, index) => setEdit((e) => e ? (row === 0 ? { ...e, pri: EDIT_PRI[index] } : { ...e, prog: EDIT_PROG[index] }) : e),
196
+ };
197
+ function onEditKey(input, key) {
198
+ if (key.escape) {
199
+ setMode('normal');
200
+ setEdit(null);
201
+ setNote('E → edit annullato');
202
+ return;
203
+ }
204
+ if (key.return) {
205
+ const task = editTask;
206
+ const current = edit;
207
+ setMode('normal');
208
+ setEdit(null);
209
+ if (task && current)
210
+ onEdit(task, current);
211
+ return;
212
+ }
213
+ // Tutto il resto è dell'area di compilazione, che è la STESSA del detail
214
+ // (T117): una grammatica sola, non due copie da tenere allineate a mano.
215
+ fieldsKey(input, key, EDIT_FIELDS, editCursor, setEditCursor, editIO);
216
+ }
217
+ return {
218
+ draft,
219
+ noteDraft,
220
+ edit,
221
+ editTask,
222
+ editCursor,
223
+ openCreate,
224
+ openNote,
225
+ openEdit,
226
+ onCreateKey,
227
+ onNoteKey,
228
+ onEditKey,
229
+ };
230
+ }
231
+ export { caretAtEnd };
@@ -0,0 +1,44 @@
1
+ // T112 — l'overlay di conferma dell'eliminazione (`CANC`).
2
+ //
3
+ // T131 — è nato dentro `cli.tsx` e ci è rimasto per centosessantacinque righe,
4
+ // pur avendo da subito la forma esatta degli altri overlay: un modo dichiarato
5
+ // in `CAPTURING_MODES`, uno stato proprio, un handler proprio, un'apertura con
6
+ // precondizioni. È la prova che `MODE_KEYS` non basta come custode — quel
7
+ // `Record` obbliga un modo nuovo ad avere un handler, non a tenerlo fuori dal
8
+ // componente. Il gate che lo pretende è `test/input-wiring.test.ts`.
9
+ //
10
+ // La deroga di T104 vale anche qui: l'hook non esce dal deck. Costruire la
11
+ // bozza e ordinarne l'esecuzione sono due cose diverse — la prima arriva come
12
+ // `draftFor`, la seconda parte come `onSubmit`, ed entrambe vivono in
13
+ // `actions.ts`. Quello che resta qui è lo stato del modale e i suoi tasti.
14
+ import { useState } from 'react';
15
+ export function usePurgeOverlay({ setMode, setNote, draftFor, onSubmit, }) {
16
+ // La bozza della conferma (null fuori dal modale).
17
+ const [draft, setDraft] = useState(null);
18
+ function open() {
19
+ const next = draftFor();
20
+ if (!next)
21
+ return;
22
+ setDraft(next);
23
+ setNote('');
24
+ setMode('purge');
25
+ }
26
+ function onKey(_input, key) {
27
+ if (key.escape) {
28
+ setMode('normal');
29
+ setDraft(null);
30
+ setNote('CANC → eliminazione annullata');
31
+ }
32
+ else if (key.return) {
33
+ const current = draft;
34
+ setMode('normal');
35
+ setDraft(null);
36
+ if (current)
37
+ onSubmit(current);
38
+ }
39
+ else if ((key.leftArrow || key.rightArrow) && draft?.ignored) {
40
+ setDraft((p) => (p ? { ...p, ignored: p.ignored === 'keep' ? 'purge' : 'keep' } : p));
41
+ }
42
+ }
43
+ return { draft, open, onKey };
44
+ }
@@ -4,8 +4,8 @@
4
4
  // vista raggiungibile con `tab`.
5
5
  //
6
6
  // Modulo PURO: nessun import da ink/react, nessun I/O. Il calcolo resta dove
7
- // stava — `view.ts` per i filtri task, `session-list.ts` per l'assemblaggio a
8
- // due gruppi — qui c'è solo la mappa di quali sottoinsiemi esistono, come si
7
+ // stava — `view.ts` per i filtri task, `session-list.ts` per l'assemblaggio
8
+ // della lista — qui c'è solo la mappa di quali sottoinsiemi esistono, come si
9
9
  // chiamano e con che numero.
10
10
  //
11
11
  // Invarianti (decisioni congelate al create-task e al preflight):
@@ -65,11 +65,6 @@ export function selectTasks(tasks, id, ctx) {
65
65
  * concorde (largo 1 per entrambe le contabilità), quindi non passa da
66
66
  * `sanitize`. */
67
67
  const LIVE_GLYPH = '●';
68
- /** Righe selezionabili della lista assemblata (il separatore non è una riga di
69
- * contenuto: fuori dalla vista di default i due gruppi non ci sono più). */
70
- function selectable(rows) {
71
- return rows.filter((r) => r.kind !== 'separator');
72
- }
73
68
  export const SESSION_VIEWS = [
74
69
  {
75
70
  id: 'context',
@@ -82,15 +77,19 @@ export const SESSION_VIEWS = [
82
77
  id: 'live',
83
78
  label: (c) => `${LIVE_GLYPH}${c.live} vive`,
84
79
  count: (c) => c.live,
85
- rows: (ctx) => selectable(ctx.assembled.rows).filter((r) => ctx.isLive(r.sessionId)),
80
+ rows: (ctx) => ctx.assembled.rows.filter((r) => ctx.isLive(r.sessionId)),
86
81
  color: 'green',
87
82
  empty: 'nessuna conversazione viva in questa lista',
88
83
  },
89
84
  {
85
+ // T133 D7 — sola vista che NON filtra la lista: il suo insieme è più largo
86
+ // (le pinnate di ogni task, più le stale), quindi il core lo consegna a
87
+ // parte. Un filtro su `rows` mostrerebbe le sole pinnate del parent
88
+ // selezionato, cioè la risposta sbagliata alla domanda «quali ho pinnato».
90
89
  id: 'pinned',
91
90
  label: (c) => `📌${c.pinned}`,
92
91
  count: (c) => c.pinned,
93
- rows: (ctx) => ctx.assembled.rows.filter((r) => r.kind === 'pinned'),
92
+ rows: (ctx) => ctx.assembled.pinnedRows,
94
93
  color: 'yellow',
95
94
  empty: 'nessuna conversazione pinnata',
96
95
  },
@@ -100,11 +99,7 @@ export const SESSION_VIEWS = [
100
99
  id: 'older',
101
100
  label: (c) => `+${c.older} più vecchie`,
102
101
  count: (c) => c.older,
103
- rows: (ctx) => ctx.assembled.contextOverflow.map((s) => ({
104
- kind: 'context',
105
- sessionId: s.sessionId,
106
- session: s,
107
- })),
102
+ rows: (ctx) => ctx.assembled.overflowRows,
108
103
  dim: true,
109
104
  empty: 'nessuna conversazione oltre il cap',
110
105
  },
@@ -0,0 +1,106 @@
1
+ // T134 — dove sta il plugin loom-works su questa macchina, e quale versione.
2
+ //
3
+ // Terza volta che il deck incontra l'assenza di `CLAUDE_PLUGIN_ROOT`: è una env
4
+ // che esiste solo dentro un processo Claude Code, e il deck è un binario npm
5
+ // spawnato da Ptyxis. Le prime due volte (età delle task archiviabili in
6
+ // `archivable.ts`, gate `--ignored-files` in `purge.ts`) hanno RIPLICATO un
7
+ // criterio di poche righe. Qui si risolve invece di duplicare, perché la
8
+ // superficie da replicare sarebbe troppo grande — la grammatica del marker
9
+ // inbox da un lato, le tre euristiche tarate dello scanner di wrap dall'altro —
10
+ // e una divergenza fra le due copie non si vedrebbe finché non conta i file
11
+ // sbagliati.
12
+ //
13
+ // L'allow `Read(~/.claude/plugins/cache/…/**)` che `init` scrive in
14
+ // `.claude/settings.json` non c'entra: è un permesso per gli agent di Claude
15
+ // Code. Il deck non passa dal motore dei permessi e non ha niente da farsi
16
+ // autorizzare — gli manca soltanto di sapere quale versione eseguire.
17
+ import { readdirSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ const MARKETPLACE_OWNER = 'lamemind';
21
+ const PLUGIN_NAME = 'loom-works';
22
+ /** Radice delle cache version-pinned del plugin. */
23
+ export function pluginCacheRoot() {
24
+ const env = process.env.LOOM_DECK_PLUGIN_CACHE_ROOT;
25
+ if (env)
26
+ return env;
27
+ return join(homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_OWNER, PLUGIN_NAME);
28
+ }
29
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
30
+ /**
31
+ * Confronto semver NUMERICO per componente: `-1` se `a` precede `b`, `1` se lo
32
+ * segue, `0` se pari.
33
+ *
34
+ * È il punto di tutto il modulo. In ordine lessicografico `10.1.3` precede
35
+ * `7.9.1` — `'1' < '7'` al primo carattere — quindi un `sort()` ingenuo su
36
+ * questa cartella sceglierebbe `9.0.0` fra le quaranta presenti, cioè una cache
37
+ * di due major fa, e lo farebbe senza dare alcun segno di sbagliare: gli script
38
+ * ci sono, girano, e rispondono col contratto di allora.
39
+ */
40
+ export function compareSemver(a, b) {
41
+ const pa = SEMVER_RE.exec(a);
42
+ const pb = SEMVER_RE.exec(b);
43
+ if (!pa || !pb)
44
+ return 0;
45
+ for (let i = 1; i <= 3; i++) {
46
+ const diff = Number(pa[i]) - Number(pb[i]);
47
+ if (diff !== 0)
48
+ return diff < 0 ? -1 : 1;
49
+ }
50
+ return 0;
51
+ }
52
+ /** La più alta fra le versioni passate; `null` se nessuna è un semver X.Y.Z. */
53
+ export function pickLatestVersion(names) {
54
+ let best = null;
55
+ for (const name of names) {
56
+ if (!SEMVER_RE.test(name))
57
+ continue;
58
+ if (best === null || compareSemver(name, best) > 0)
59
+ best = name;
60
+ }
61
+ return best;
62
+ }
63
+ /**
64
+ * Cache installata più recente, o `null` se il plugin non è installato su questa
65
+ * macchina (radice assente, o presente ma senza una sola cartella semver).
66
+ *
67
+ * `null` non è un errore da riportare: un deck su una macchina senza il plugin
68
+ * deve restare muto sugli indicatori che ne dipendono, non rompersi.
69
+ */
70
+ export function resolvePluginCache() {
71
+ const root = pluginCacheRoot();
72
+ let names;
73
+ try {
74
+ names = readdirSync(root, { withFileTypes: true })
75
+ .filter((e) => e.isDirectory())
76
+ .map((e) => e.name);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ const version = pickLatestVersion(names);
82
+ if (version === null)
83
+ return null;
84
+ return { root: join(root, version), version };
85
+ }
86
+ /**
87
+ * La versione si risolve UNA VOLTA e resta quella per tutta la vita del
88
+ * processo. La cache cambia solo a un `plugin update`, quindi un deck aperto
89
+ * durante l'update continua a leggere la versione di prima fino al riavvio.
90
+ * È il limite accettato, e sta scritto qui invece di essere scoperto dopo.
91
+ */
92
+ let resolved;
93
+ export function pluginCache() {
94
+ if (resolved === undefined)
95
+ resolved = resolvePluginCache();
96
+ return resolved;
97
+ }
98
+ /** Path assoluto di uno script del plugin, `null` se il plugin non c'è. */
99
+ export function pluginScript(relative) {
100
+ const cache = pluginCache();
101
+ return cache ? join(cache.root, relative) : null;
102
+ }
103
+ /** Solo per i test: dimentica la versione risolta al primo accesso. */
104
+ export function resetPluginCache() {
105
+ resolved = undefined;
106
+ }
@@ -1,19 +1,26 @@
1
- // T50 — Assemblaggio della lista sessioni a DUE gruppi: pinnate (sempre in
2
- // lista, in cima) + separatore + contestuali (figlie del parent selezionato).
1
+ // T133 — Assemblaggio della lista sessioni: UNA lista sola, le figlie del parent
2
+ // selezionato in ordine `ts desc`. Il pin è un ATTRIBUTO della riga (glifo, vista
3
+ // dedicata), non una posizione privilegiata.
3
4
  // Modulo PURO: nessun import da ink/react, nessun I/O → testabile senza terminale
4
5
  // (il pacchetto non ha infrastruttura TUI di test, solo unit sui core puri).
5
6
  //
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.
7
+ // Invarianti (decisioni congelate al preflight T133):
8
+ // - D1/D3: una forma sola di lista qualunque sia il parent, vista «tutte»
9
+ // compresa. Nessun criterio speciale per le righe pinnate: nessun blocco in
10
+ // cima, nessun ordine proprio, nessuna esenzione dal cap (D6).
11
+ // - D4: una pinnata compare in lista se e solo se è figlia del parent
12
+ // selezionato. Le pinnate fuori contesto vivono nella sola vista `📌`.
13
+ // - D7: le righe pinnate escono in un CAMPO A PARTE (`pinnedRows`), non dentro
14
+ // `rows`: il loro insieme è più largo della lista (tutte le sessioni del
15
+ // progetto più le stale), quindi non è esprimibile come filtro di `rows`.
16
+ // - D8: `pinnedRows` conserva l'ordine per RANGO di pin desc (ultima pinnata in
17
+ // cima), dato dal sidecar `session-tasks.jsonl`. È l'unico lettore rimasto del
18
+ // rango.
19
+ // - D5: una pinnata il cui transcript non esiste più è una riga STALE, presente
20
+ // nelle sole `pinnedRows` — navigabile e spinnabile dalla vista `📌`.
21
+ // - Trap T39: la selezione è KEYED SU sessionId, non su indice posizionale. Le
22
+ // righe a schermo sono quelle della vista attiva, e un indice grezzo
23
+ // identificherebbe la riga sbagliata al primo cambio di vista o di parent.
17
24
  import { cut, termWidth } from './width.js';
18
25
  // ── T53 · etichetta di riga ────────────────────────────────────────────────
19
26
  /**
@@ -121,66 +128,50 @@ export function rowLabel(text, note, budget) {
121
128
  return { note: shownNote, rest: restBudget >= MIN_REST ? cut(rest, restBudget) : '' };
122
129
  }
123
130
  export function assembleSessionList(childSessions, allSessions, pinned, maxContext) {
124
- // Le pinnate si risolvono contro TUTTE le sessioni del progetto, non solo le
125
- // figlie del parent: una pinnata può appartenere a un'altra task. Assente
126
- // dallo store → stale.
127
- const byId = new Map(allSessions.map((s) => [s.sessionId, s]));
128
- const pinnedIds = [...pinned.entries()]
129
- .sort((a, b) => b[1] - a[1]) // rango desc → ultima pinnata in cima (D2)
130
- .map(([id]) => id);
131
- const pinnedRows = pinnedIds.map((id) => {
132
- const session = byId.get(id) ?? null;
133
- return { kind: 'pinned', sessionId: id, session, stale: session === null };
134
- });
135
- const pinnedSet = new Set(pinnedIds);
136
- const dedupedContext = childSessions.filter((s) => !pinnedSet.has(s.sessionId));
137
131
  const cap = Math.max(0, maxContext);
138
- const shownContext = dedupedContext.slice(0, cap);
139
- const overflowContext = dedupedContext.slice(cap);
140
- const contextRows = shownContext.map((s) => ({
141
- kind: 'context',
132
+ const toRow = (s) => ({
133
+ kind: 'session',
142
134
  sessionId: s.sessionId,
143
135
  session: s,
144
- }));
145
- const rows = [...pinnedRows];
146
- // Separatore SOLO fra due gruppi entrambi non vuoti: senza pinnate o senza
147
- // contestuali non c'è confine da segnare.
148
- if (pinnedRows.length > 0 && contextRows.length > 0)
149
- rows.push({ kind: 'separator' });
150
- rows.push(...contextRows);
151
- return {
152
- rows,
153
- pinnedCount: pinnedRows.length,
154
- contextTotal: dedupedContext.length,
155
- contextHidden: overflowContext.length,
156
- contextOverflow: overflowContext,
157
- };
158
- }
159
- function isSelectable(r) {
160
- return r.kind !== 'separator';
136
+ pinned: pinned.has(s.sessionId),
137
+ });
138
+ const rows = childSessions.slice(0, cap).map(toRow);
139
+ const overflowRows = childSessions.slice(cap).map(toRow);
140
+ // Le pinnate si risolvono contro TUTTE le sessioni del progetto, non solo le
141
+ // figlie del parent: una pinnata può appartenere a un'altra task, e la vista
142
+ // dedicata è l'unico posto dove resta raggiungibile. Assente dallo store →
143
+ // stale.
144
+ const byId = new Map(allSessions.map((s) => [s.sessionId, s]));
145
+ const pinnedRows = [...pinned.entries()]
146
+ .sort((a, b) => b[1] - a[1]) // rango desc → ultima pinnata in cima (D8)
147
+ .map(([id]) => {
148
+ const session = byId.get(id);
149
+ return session
150
+ ? { kind: 'session', sessionId: id, session, pinned: true }
151
+ : { kind: 'stale', sessionId: id };
152
+ });
153
+ return { rows, pinnedRows, contextTotal: childSessions.length, overflowRows };
161
154
  }
162
- /** Primo id selezionabile (salta il separatore); null = lista senza righe. */
155
+ /** Primo id della lista; null = lista senza righe. */
163
156
  export function firstSelectableId(rows) {
164
- return rows.find(isSelectable)?.sessionId ?? null;
157
+ return rows[0]?.sessionId ?? null;
165
158
  }
166
159
  /** Indice della riga con quel sessionId nell'ARRAY COMPLETO (per il windowing);
167
- * -1 se assente o id null. Il separatore non ha id → mai matchato. */
160
+ * -1 se assente o id null. */
168
161
  export function rowIndexOf(rows, sessionId) {
169
162
  if (sessionId === null)
170
163
  return -1;
171
- return rows.findIndex((r) => isSelectable(r) && r.sessionId === sessionId);
164
+ return rows.findIndex((r) => r.sessionId === sessionId);
172
165
  }
173
- /** Sposta la selezione di `delta` fra le sole righe selezionabili (attraversa il
174
- * separatore senza fermarcisi). Id perso → prima riga; lista vuota → null. */
166
+ /** Sposta la selezione di `delta`. Id perso prima riga; lista vuota → null. */
175
167
  export function moveSelection(rows, currentId, delta) {
176
- const selectable = rows.filter(isSelectable);
177
- if (selectable.length === 0)
168
+ if (rows.length === 0)
178
169
  return null;
179
- const cur = selectable.findIndex((r) => r.sessionId === currentId);
170
+ const cur = rows.findIndex((r) => r.sessionId === currentId);
180
171
  if (cur < 0)
181
- return selectable[0].sessionId;
182
- const next = Math.max(0, Math.min(selectable.length - 1, cur + delta));
183
- return selectable[next].sessionId;
172
+ return rows[0].sessionId;
173
+ const next = Math.max(0, Math.min(rows.length - 1, cur + delta));
174
+ return rows[next].sessionId;
184
175
  }
185
176
  /**
186
177
  * T57 — id su cui atterrare quando la riga `sessionId` sta per USCIRE dalla
@@ -192,44 +183,19 @@ export function moveSelection(rows, currentId, delta) {
192
183
  * assegnazione rimanderebbe la selezione in cima. L'id va catturato PRIMA di
193
184
  * riscrivere il sidecar — dopo, la riga non c'è più e il vicino non è
194
185
  * calcolabile.
195
- */
196
- export function neighborId(rows, sessionId) {
197
- const selectable = rows.filter(isSelectable);
198
- const at = selectable.findIndex((r) => r.sessionId === sessionId);
199
- if (at < 0)
200
- return null;
201
- return selectable[at + 1]?.sessionId ?? selectable[at - 1]?.sessionId ?? null;
202
- }
203
- /**
204
- * Id su cui atterrare quando la riga pinnata `sessionId` viene SPINNATA: la
205
- * pinnata successiva, la precedente se era l'ultima, la prima contestuale se il
206
- * gruppo pinnate si svuota. `null` se `sessionId` non è pinnata o non resta
207
- * nessuna riga.
208
186
  *
209
- * Non è `neighborId` ristretto al gruppo: quello attraversa il separatore,
210
- * quindi sull'ULTIMA pinnata atterrerebbe sulla prima contestuale il caret
211
- * scavalcherebbe l'intero gruppo invece di risalire di una riga. Qui il confine
212
- * fra i due gruppi è la regola, non un ostacolo.
213
- *
214
- * L'id va calcolato PRIMA di riscrivere il sidecar: dopo, la riga pinnata non
215
- * c'è più e il vicino nel gruppo non è calcolabile. Serve perché una spinnata
216
- * non sparisce necessariamente dalla lista — se appartiene al parent
217
- * selezionato ricompare fra le contestuali, e la selezione keyed sull'id la
218
- * seguirebbe fin laggiù, trascinando il caret.
187
+ * T133 D12 stesso servizio per l'unpin dentro la vista `📌`, dove spinnare fa
188
+ * uscire la riga. Nella lista principale non serve: una pinnata è in lista
189
+ * perché è figlia del parent, e spinnarla non la fa uscire.
219
190
  */
220
- export function unpinLandingId(rows, sessionId) {
221
- const pinnedRows = rows.filter((r) => r.kind === 'pinned');
222
- const at = pinnedRows.findIndex((r) => r.sessionId === sessionId);
191
+ export function neighborId(rows, sessionId) {
192
+ const at = rows.findIndex((r) => r.sessionId === sessionId);
223
193
  if (at < 0)
224
194
  return null;
225
- const inGroup = pinnedRows[at + 1]?.sessionId ?? pinnedRows[at - 1]?.sessionId;
226
- if (inGroup)
227
- return inGroup;
228
- const firstContext = rows.find((r) => r.kind === 'context');
229
- return firstContext && isSelectable(firstContext) ? firstContext.sessionId : null;
195
+ return rows[at + 1]?.sessionId ?? rows[at - 1]?.sessionId ?? null;
230
196
  }
231
197
  /** Session della riga selezionata; null se stale o nessuna selezione. */
232
198
  export function selectedSession(rows, sessionId) {
233
- const r = rows.find((row) => isSelectable(row) && row.sessionId === sessionId);
234
- return r && isSelectable(r) ? r.session : null;
199
+ const r = rows.find((row) => row.sessionId === sessionId);
200
+ return r && r.kind === 'session' ? r.session : null;
235
201
  }