@lamemind/loom-deck 0.15.0 → 0.17.0

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