@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 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 i fork: un record di lineage appena scritto
281
- // cambia il marker della riga, quindi deve forzare il re-render come
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
- const [selSession, setSelSession] = useState(0);
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
- const childSessions = sessions.filter((s) => {
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
- const visibleSessions = childSessions.slice(0, MAX_SESSIONS);
432
- const hiddenSessions = childSessions.length - visibleSessions.length;
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
- // Cambio padre riparti dalla prima sessione figlia.
442
- useEffect(() => {
443
- setSelSession(0);
444
- }, [selId]);
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
- setSelSession((s) => Math.min(s, Math.max(0, visibleSessions.length - 1)));
447
- }, [visibleSessions.length]);
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
- setSelSession((i) => Math.max(0, i - 1));
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
- setSelSession((i) => Math.min(visibleSessions.length - 1, i + 1));
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 = visibleSessions[selSession];
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 = visibleSessions[selSession];
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, visibleSessions)
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 sessionWin = windowRange(visibleSessions.length, selSession, budget.sessionRows);
842
- const windowSessions = visibleSessions.slice(sessionWin.start, sessionWin.end);
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, sessions: windowSessions, total: childSessions.length, hidden: hiddenSessions, selectedId: selSessionId, focused: focus === 'sessions', above: sessionWin.start, below: visibleSessions.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] }));
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, sessions, total, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, }) {
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' })) : (sessions.map((s) => {
896
- const sel = s.sessionId === selectedId;
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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
5
5
  "type": "module",
6
6
  "bin": {