@lamemind/loom-deck 0.74.0 → 0.76.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,71 @@
1
+ // T175 — i confronti di path che il deck fa sulla root del progetto, per
2
+ // piattaforma.
3
+ //
4
+ // Tre punti confrontavano path come stringhe POSIX: il filtro delle
5
+ // conversazioni del progetto (`sessions.ts`), quello delle sessioni vive
6
+ // (`live-sessions.ts`) e il nome del progetto (`deck-model.ts`). Su Linux la
7
+ // stringa È il path; su Windows no, per tre ragioni misurate (verdetto 1 di T174):
8
+ //
9
+ // - NTFS non distingue le maiuscole, e Claude Code non normalizza la grafia
10
+ // della cwd: VS Code passa `c:\…`, Windows Terminal `C:\…`, e lo stesso
11
+ // progetto ha due cartelle di transcript (`c--…` e `C--…`);
12
+ // - `\` e `/` sono entrambi separatori;
13
+ // - nei transcript `cwd` compare anche in forma MSYS (`/c/…`), quella di Git
14
+ // Bash.
15
+ //
16
+ // Modulo PURO. La piattaforma è un parametro con default quella VERA, e non
17
+ // quella di `LOOM_DECK_PLATFORM` (`platform.ts`): qui si descrive il filesystem
18
+ // su cui il deck gira, non una funzione che si può spegnere.
19
+ import { posix, win32 } from 'node:path';
20
+ /**
21
+ * La forma di un path su cui si confronta, mai quella che si mostra.
22
+ *
23
+ * Su win32: separatori portati a `\`, forma MSYS `/c/…` riportata a `C:\…`,
24
+ * tutto minuscolo, nessun separatore in coda (tranne la radice del drive, dove
25
+ * `C:` da solo vorrebbe dire «la cartella corrente di C:»). Altrove il path così
26
+ * com'è, senza il separatore in coda: il confronto resta case-sensitive come il
27
+ * filesystem.
28
+ */
29
+ export function comparablePath(p, platform = process.platform) {
30
+ if (platform !== 'win32')
31
+ return p.length > 1 ? p.replace(/\/+$/, '') : p;
32
+ let s = p.replace(/\//g, '\\');
33
+ // `\c\Users\…` (la forma MSYS dopo la sostituzione dei separatori) →
34
+ // `c:\Users\…`. Un UNC (`\\server\…`) non matcha: il secondo carattere è `\`.
35
+ const msys = /^\\([a-zA-Z])(?:\\|$)/.exec(s);
36
+ if (msys)
37
+ s = `${msys[1]}:\\${s.slice(msys[0].length)}`;
38
+ s = s.replace(/\\+$/, '');
39
+ if (/^[a-zA-Z]:$/.test(s))
40
+ s += '\\';
41
+ return s.toLowerCase();
42
+ }
43
+ /** `path` è la root stessa o sta sotto di lei. Mai un prefisso di nome: la root
44
+ * `C:\a\loom` non contiene `C:\a\loom-works`. */
45
+ export function isWithin(path, root, platform = process.platform) {
46
+ const p = comparablePath(path, platform);
47
+ const r = comparablePath(root, platform);
48
+ if (p === r)
49
+ return true;
50
+ const sep = platform === 'win32' ? '\\' : '/';
51
+ return p.startsWith(r.endsWith(sep) ? r : r + sep);
52
+ }
53
+ /** Il nome della cartella del progetto: l'ultimo segmento della root, con i
54
+ * separatori della piattaforma. Root senza segmenti (`/`, `C:\`) → la root. */
55
+ export function projectBaseName(cwd, platform = process.platform) {
56
+ return (platform === 'win32' ? win32 : posix).basename(cwd) || cwd;
57
+ }
58
+ /**
59
+ * Le cartelle di transcript del progetto, fra i nomi di `~/.claude/projects/`.
60
+ *
61
+ * Su Linux una sola, il nome esatto. Su win32 ogni nome che coincide ignorando
62
+ * le maiuscole: la stessa root avviata da VS Code (`c:\…`) e da Windows
63
+ * Terminal (`C:\…`) scrive in `c--…` e in `C--…`, e leggerne una sola perde le
64
+ * conversazioni dell'altra.
65
+ */
66
+ export function matchingDirNames(entries, name, platform = process.platform) {
67
+ if (platform !== 'win32')
68
+ return entries.filter((e) => e === name);
69
+ const want = name.toLowerCase();
70
+ return entries.filter((e) => e.toLowerCase() === want);
71
+ }
package/dist/purge.js CHANGED
@@ -80,6 +80,7 @@ export function folderSurvivors(projectRoot, folder) {
80
80
  const out = execFileSync('git', ['ls-files', '-o', '--', rel], {
81
81
  cwd: projectRoot,
82
82
  encoding: 'utf8',
83
+ windowsHide: true,
83
84
  });
84
85
  return out.split('\n').filter(Boolean);
85
86
  }
package/dist/sessions.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, join } from 'node:path';
4
4
  import { sanitize } from './width.js';
5
+ import { isWithin, matchingDirNames } from './project-path.js';
5
6
  // CC codifica la project dir sostituendo ogni char non-alfanumerico con '-'
6
7
  // (verificato: `/home/lamemind/cc-host` → `-home-lamemind-cc-host`). È LOSSY
7
8
  // (un '-' nel path e un '/' collassano nello stesso char) → il forward è
@@ -308,51 +309,84 @@ export function compareSessions(a, b) {
308
309
  return b.lastReplyTs - a.lastReplyTs;
309
310
  return b.ts - a.ts;
310
311
  }
311
- // Discovery read-only delle sessioni del SOLO progetto corrente (D2 preflight
312
- // T27): legge la project dir calcolata dal forward-transform, filtra per cwd
313
- // (difesa contro le collisioni lossy del naming), ordina con `compareSessions`.
314
- export function discoverProjectSessions(projectRoot) {
315
- const dir = join(claudeProjectsRoot(), projectDirName(projectRoot));
316
- let files;
312
+ /**
313
+ * T175 — le cartelle di transcript del progetto.
314
+ *
315
+ * Su Linux una sola, il nome esatto, e senza leggere la cartella madre: il poll
316
+ * gira ogni 1,5 s, e un `readdir` di `~/.claude/projects/` costerebbe a ogni
317
+ * tick per trovare un nome già noto. Su win32 tutte quelle che coincidono a
318
+ * meno delle maiuscole (`matchingDirNames`): Claude Code non normalizza la
319
+ * lettera del drive, e lo stesso progetto ne ha due.
320
+ */
321
+ function transcriptDirs(projectRoot) {
322
+ const root = claudeProjectsRoot();
323
+ const name = projectDirName(projectRoot);
324
+ if (process.platform !== 'win32')
325
+ return [join(root, name)];
326
+ let entries;
317
327
  try {
318
- files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
328
+ entries = readdirSync(root);
319
329
  }
320
330
  catch {
321
331
  return [];
322
332
  }
323
- const out = [];
333
+ return matchingDirNames(entries, name).map((e) => join(root, e));
334
+ }
335
+ // Discovery read-only delle sessioni del SOLO progetto corrente (D2 preflight
336
+ // T27): legge le project dir calcolate dal forward-transform, filtra per cwd
337
+ // (difesa contro le collisioni lossy del naming), ordina con `compareSessions`.
338
+ //
339
+ // D1 preflight T175 — restano fuori le conversazioni AVVIATE in una
340
+ // sottocartella, su ogni piattaforma: Claude Code ne scrive il transcript nella
341
+ // cartella che codifica la cwd di avvio, cioè un'altra. Il filtro accetta una
342
+ // cwd sotto la root perché non scarta niente di legittimo, non per raccoglierle.
343
+ export function discoverProjectSessions(projectRoot) {
344
+ // Keyed sul sessionId: su win32 due cartelle dello stesso progetto potrebbero
345
+ // portare lo stesso transcript, e due righe con un id solo romperebbero la
346
+ // selezione. Vince il più recente.
347
+ const out = new Map();
324
348
  const seen = new Set();
325
- for (const f of files) {
326
- const path = join(dir, f);
327
- seen.add(path);
328
- let mtime;
329
- let sizeBytes;
349
+ for (const dir of transcriptDirs(projectRoot)) {
350
+ let files;
330
351
  try {
331
- const st = statSync(path);
332
- mtime = st.mtimeMs;
333
- sizeBytes = st.size;
352
+ files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
334
353
  }
335
354
  catch {
336
355
  continue;
337
356
  }
338
- const cached = cache.get(path);
339
- let session;
340
- if (cached && cached.mtime === mtime) {
341
- session = cached.session;
342
- }
343
- else {
344
- session = parseSessionFile(path, mtime, sizeBytes);
345
- cache.set(path, { mtime, session });
346
- }
347
- if (session && (session.cwd === projectRoot || session.cwd.startsWith(projectRoot + '/'))) {
348
- out.push(session);
357
+ for (const f of files) {
358
+ const path = join(dir, f);
359
+ seen.add(path);
360
+ let mtime;
361
+ let sizeBytes;
362
+ try {
363
+ const st = statSync(path);
364
+ mtime = st.mtimeMs;
365
+ sizeBytes = st.size;
366
+ }
367
+ catch {
368
+ continue;
369
+ }
370
+ const cached = cache.get(path);
371
+ let session;
372
+ if (cached && cached.mtime === mtime) {
373
+ session = cached.session;
374
+ }
375
+ else {
376
+ session = parseSessionFile(path, mtime, sizeBytes);
377
+ cache.set(path, { mtime, session });
378
+ }
379
+ if (!session || !isWithin(session.cwd, projectRoot))
380
+ continue;
381
+ const prev = out.get(session.sessionId);
382
+ if (!prev || session.ts > prev.ts)
383
+ out.set(session.sessionId, session);
349
384
  }
350
385
  }
351
386
  for (const key of cache.keys())
352
387
  if (!seen.has(key))
353
388
  cache.delete(key);
354
- out.sort(compareSessions);
355
- return out;
389
+ return [...out.values()].sort(compareSessions);
356
390
  }
357
391
  // Raggruppa per gitBranch (D2: group-by-branch nel progetto corrente). Ordine
358
392
  // dei gruppi = la prima sessione di ciascuno, con `compareSessions`; dentro il
package/dist/spawn.js CHANGED
@@ -8,6 +8,9 @@ import { dirname, join } from 'node:path';
8
8
  // è DATO PURO e non importa questo file: la dipendenza va in un verso solo.
9
9
  // Qui restano i soli effetti verso l'esterno — comporre un argv e lanciarlo.
10
10
  import { MODEL_DEFAULT, PROJECT_STATUS_MODEL } from './spawn-catalog.js';
11
+ import { isAvailable } from './platform.js';
12
+ import { claudeLauncher } from './claude-cmd.js';
13
+ import { claudeEnv } from './git-bash.js';
11
14
  // scripts/deck-run è un sibling della dir del bundle: src/ (dev, tsx) e dist/
12
15
  // (build, node) stanno entrambi sotto la package root → risalita di un livello.
13
16
  export const DECK_RUN = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'deck-run');
@@ -27,9 +30,14 @@ export const DECK_RUN = join(dirname(fileURLToPath(import.meta.url)), '..', 'scr
27
30
  * test misura resta identico.
28
31
  */
29
32
  export const NO_SPAWN = process.env.LOOM_DECK_NO_SPAWN === '1';
33
+ // T175 — `windowsHide` su OGNI figlio, qui e non a ogni chiamante: su Windows
34
+ // un processo console lanciato senza apre una finestra (un `claude -p`, un
35
+ // `git`), e ogni sede di spawn che la dimenticasse ne farebbe lampeggiare una a
36
+ // ogni giro. Su Linux l'opzione non ha effetto. Un chiamante può ancora
37
+ // sovrascriverla, perché `opts` viene dopo.
30
38
  export function spawnOut(cmd, args, opts) {
31
39
  if (!NO_SPAWN)
32
- return spawn(cmd, args, opts);
40
+ return spawn(cmd, args, { windowsHide: true, ...opts });
33
41
  // Figlio inerte: emette nulla, quindi i `.on('error'|'close')` dei chiamanti
34
42
  // restano appesi senza mai scattare — che è esattamente "non è successo niente".
35
43
  const fake = new EventEmitter();
@@ -347,8 +355,11 @@ export function spawnTerminal(cwd, title) {
347
355
  child.unref();
348
356
  return child;
349
357
  }
350
- // Comando claude (override per ambienti dove non è su PATH; loom-deck → NPM).
351
- export const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
358
+ // Il NOME di claude nelle note della riga di stato: l'override se c'è,
359
+ // `claude` altrimenti. Non è ciò che si esegue — quello lo risolve
360
+ // `claudeLauncher` (`claude-cmd.ts`), che su Windows può arrivare a
361
+ // `node …cli.js` da uno shim npm: nelle note resta il nome che l'utente conosce.
362
+ export const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD || 'claude';
352
363
  // T30: invocazione di una skill del plugin da CC HEADLESS (`-p`), con
353
364
  // `--session-id` pinnato. È la TERZA strada per eseguire uno script del plugin
354
365
  // da un consumer che non ha `CLAUDE_PLUGIN_ROOT`: il path version-pinned della
@@ -365,7 +376,12 @@ export const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
365
376
  // ha funzionato" — che su un'operazione distruttiva non basta a sapere se
366
377
  // qualcosa è stato rimosso.
367
378
  function spawnSkill(prompt, cwd, sessionId, onResult, extraArgs = []) {
368
- const child = spawnOut(CLAUDE_CMD, [
379
+ // T175 — il lanciatore risolto (shim npm compreso, mai una shell) e, su
380
+ // Windows, l'env col bash di Git: senza, un deck avviato da Git Bash passa al
381
+ // figlio il `PATH` che manda gli hook del plugin in PowerShell.
382
+ const claude = claudeLauncher();
383
+ const child = spawnOut(claude.cmd, [
384
+ ...claude.prefix,
369
385
  '-p',
370
386
  '--output-format',
371
387
  'stream-json',
@@ -374,7 +390,7 @@ function spawnSkill(prompt, cwd, sessionId, onResult, extraArgs = []) {
374
390
  sessionId,
375
391
  ...extraArgs,
376
392
  prompt,
377
- ], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
393
+ ], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: claudeEnv() });
378
394
  let buf = '';
379
395
  let isError = null;
380
396
  let detail = '';
@@ -491,7 +507,13 @@ export const NOTIFY_CMD = process.env.LOOM_DECK_NOTIFY_CMD ?? 'notify-send';
491
507
  // Agganciato, la degradazione è MUTA (D6): un binario assente è una proprietà
492
508
  // stabile della macchina, e dirlo a ogni generazione sarebbe lo stesso messaggio
493
509
  // a ogni giro su una funzione che non è il deliverable.
510
+ //
511
+ // T175 — dove le notifiche non ci sono, nessuno spawn e nessun avviso: non
512
+ // hanno un tasto né un pane, e l'esito del project status è già nella riga di
513
+ // stato. Il `null` dice al chiamante che niente è partito.
494
514
  export function notifyDone(title, body) {
515
+ if (!isAvailable('notify'))
516
+ return null;
495
517
  const child = spawnOut(NOTIFY_CMD, ['--app-name=loom-deck', title, body], {
496
518
  detached: true,
497
519
  stdio: 'ignore',
package/dist/tasks.js CHANGED
@@ -8,29 +8,16 @@ import { sanitize } from './width.js';
8
8
  * divergano (uno accetterebbe righe che l'altro rifiuta).
9
9
  */
10
10
  export const TASK_ID_RE = /^T\d+$/;
11
- /**
12
- * Nome della cartella doc del progetto (`docs`, `runtime`, …).
13
- *
14
- * D1 (preflight T20): override via env `LOOM_DECK_DOCS_ROOT`, nessun
15
- * auto-detect e nessuna lettura del file config. Il deck non si lancia mai a
16
- * mano — nasce da compass, che legge `docsRoot` dal registry dconf e lo
17
- * antepone al comando, o dal custom-command del profilo Ptyxis, che ce l'ha
18
- * cablato — quindi l'env c'è sempre nei percorsi di avvio reali.
19
- *
20
- * T134 — funzione e non più letterale ripetuto: la legge anche lo scan della
21
- * coda inbox, e due copie della stessa cascata divergerebbero appena una delle
22
- * due imparasse a leggere il file config.
23
- */
24
- export function docsRootName() {
25
- return process.env.LOOM_DECK_DOCS_ROOT || 'docs';
26
- }
27
- export function resolveTasksPath(cwd = process.cwd()) {
28
- return join(cwd, docsRootName(), 'tasks.md');
11
+ // La docs-root arriva dal chiamante, già risolta: la cascata che la sceglie
12
+ // (`docsRootName` in `config.ts`) gira una volta per vita del deck, e i due path
13
+ // qui sotto devono nascere dallo stesso valore che leggono gli scan del modo doc.
14
+ export function resolveTasksPath(cwd, docsRoot) {
15
+ return join(cwd, docsRoot, 'tasks.md');
29
16
  }
30
17
  // I task file vivono in `<docsRoot>/tasks/` — sibling di tasks.md. Derivo la
31
- // dir dallo stesso path per rispettare l'override LOOM_DECK_DOCS_ROOT.
32
- export function resolveTasksDir(cwd = process.cwd()) {
33
- return join(dirname(resolveTasksPath(cwd)), 'tasks');
18
+ // dir dallo stesso path, così le due non possono puntare a docs-root diverse.
19
+ export function resolveTasksDir(cwd, docsRoot) {
20
+ return join(dirname(resolveTasksPath(cwd, docsRoot)), 'tasks');
34
21
  }
35
22
  /**
36
23
  * Indici di colonna della Tasks Overview letti dall'HEADER, non cablati.
@@ -20,6 +20,7 @@ import { DOC_FLAG_W, flagCell } from '../doc-tree.js';
20
20
  import { docView } from '../doc-views.js';
21
21
  import { docHeaderParts } from '../pane-header.js';
22
22
  import { HeaderLine } from './header-line.js';
23
+ import { offText } from '../platform.js';
23
24
  /**
24
25
  * Rientro di un livello dell'albero: UNA colonna, non due.
25
26
  *
@@ -53,14 +54,17 @@ export function DocHeader({ counts, active, above, below, focused, columns, hasD
53
54
  const { parts, shown } = docHeaderParts(counts, active, above, below, columns, hasDirs);
54
55
  return _jsx(HeaderLine, { parts: parts, shown: shown, focused: focused });
55
56
  }
56
- export function DocPane({ rows, counts, activeView, paneCount, selectedPath, focused, above, below, columns, ok, scanned, scanning, hasDirs, }) {
57
+ export function DocPane({ rows, counts, activeView, paneCount, selectedPath, focused, above, below, columns, ok, scanned, scanning, hasDirs, available = true, }) {
57
58
  const width = paneTextWidth(columns);
58
59
  // Cella elastica per sottrazione dalle colonne fisse, come nel pane inbox e
59
60
  // nella lista sessioni. Pavimento 0 e non un minimo di cortesia: è un tetto,
60
61
  // non una preferenza, e alzarlo sopra lo spazio reale fa uscire la riga dal
61
62
  // pane mangiandone il bordo (invariante ③ di width.ts).
62
63
  const nameW = Math.max(0, width - (2 /* caret */ + DOC_FLAG_W + 1 /* gutter */));
63
- return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(DocHeader, { counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns, hasDirs: hasDirs }), paneCount === 0 ? (
64
+ return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(DocHeader, { counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns, hasDirs: hasDirs }), !available ? (
65
+ // T175 — gemello del pane inbox: la misura qui non esiste, e va detto
66
+ // per nome invece di restare su «albero non ancora misurato».
67
+ _jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(offText('pluginScripts', 'albero doc'), width) })) : paneCount === 0 ? (
64
68
  // Quattro vuoti diversi, e dirlo è il punto. Ai tre del pane inbox se ne
65
69
  // aggiunge uno che solo questo scan ha: «sto misurando». Quattro secondi
66
70
  // di wall clock sono abbastanza da vedersi, e un pane vuoto per quattro
@@ -19,6 +19,7 @@ import { NATURA_SHORT, NATURA_W, inboxMark } from '../inbox.js';
19
19
  import { inboxView } from '../inbox-views.js';
20
20
  import { inboxHeaderParts } from '../pane-header.js';
21
21
  import { HeaderLine } from './header-line.js';
22
+ import { offText } from '../platform.js';
22
23
  /**
23
24
  * Header del pane inbox: le parti e il loro taglio vengono da `pane-header.ts`,
24
25
  * lo stesso modulo da cui `frame.ts` ricava le colonne cliccabili — una fonte
@@ -43,7 +44,7 @@ export function InboxHeader({ counts, active, above, below, focused, columns, })
43
44
  * colonna fissa la paga la cella elastica del nome — che è l'unica cosa con cui
44
45
  * si riconosce un file.
45
46
  */
46
- export function InboxPane({ files, counts, activeView, paneCount, selectedPath, focused, above, below, columns, ok, scanned, }) {
47
+ export function InboxPane({ files, counts, activeView, paneCount, selectedPath, focused, above, below, columns, ok, scanned, available = true, }) {
47
48
  const width = paneTextWidth(columns);
48
49
  // La cella elastica si calcola per SOTTRAZIONE dalle colonne fisse, come il
49
50
  // titolo della lista sessioni: pavimento 0 e non un minimo di cortesia — è un
@@ -58,7 +59,11 @@ export function InboxPane({ files, counts, activeView, paneCount, selectedPath,
58
59
  1 /* gutter */ +
59
60
  1 /* gutter prima della data */ +
60
61
  ageW));
61
- return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(InboxHeader, { counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), paneCount === 0 ? (
62
+ return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(InboxHeader, { counts: counts, active: activeView, above: above, below: below, focused: focused, columns: columns }), !available ? (
63
+ // T175 — un quarto vuoto, il solo che non è uno stato della coda: la
64
+ // misura qui non esiste. Va detto per nome, o si leggerebbe come «coda
65
+ // non ancora misurata» per sempre.
66
+ _jsx(Text, { color: "yellow", wrap: "truncate-end", children: cut(offText('pluginScripts', 'coda inbox'), width) })) : paneCount === 0 ? (
62
67
  // Tre vuoti diversi, e dirlo è il punto: «non ho ancora misurato» non è
63
68
  // «ho misurato e non c'è niente», e «la misura si è rotta» non è nessuno
64
69
  // dei due (D2 preflight). Una lista vuota che non dice quale dei tre è
package/dist/wrap-scan.js CHANGED
@@ -145,7 +145,7 @@ export async function runWrapScan(projectRoot) {
145
145
  if (!script)
146
146
  return false;
147
147
  try {
148
- const { stdout } = await execFileAsync('python3', [script, '--root', projectRoot, '--scan'], { cwd: projectRoot, maxBuffer: 16 * 1024 * 1024 });
148
+ const { stdout } = await execFileAsync('python3', [script, '--root', projectRoot, '--scan'], { cwd: projectRoot, maxBuffer: 16 * 1024 * 1024, windowsHide: true });
149
149
  const path = wrapCacheFile(projectRoot);
150
150
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
151
151
  writeFileSync(path, stdout, { mode: 0o600 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.74.0",
3
+ "version": "0.76.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": {
@@ -13,7 +13,7 @@
13
13
  "scripts": {
14
14
  "dev": "tsx src/cli.tsx",
15
15
  "build": "tsc",
16
- "test": "tsx --test test/*.test.ts",
16
+ "test": "node test/run.mjs",
17
17
  "start": "node dist/cli.js",
18
18
  "prepare": "tsc"
19
19
  },
@@ -1,90 +0,0 @@
1
- // T134 — il DETAIL di un file inbox: sesta schermata sostitutiva.
2
- //
3
- // Stessa forma dello `sheet` della task e per la stessa ragione: il file è
4
- // FOTOGRAFATO all'apertura — nome, natura, testo — e non è una vista sulla
5
- // selezione corrente. L'overlay copre la lista, quindi l'oggetto dell'azione
6
- // deve restare quello che si è scelto anche se un tick dello scan spostasse la
7
- // selezione sotto.
8
- //
9
- // Molto più magro dello sheet: nessuna area di compilazione. Lo sheet ha
10
- // quattro righe perché quattro parametri dello spawn sono una scelta (azione,
11
- // prompt, modello, titolo); qui non ce n'è nessuna — la skill la decide la
12
- // natura (D8), il modello è `MODEL_DEFAULT` per tutte e tre (D11 preflight), la sessione
13
- // nasce nuda (D10 preflight). Restano la lettura e un tasto.
14
- //
15
- // Lo spawn NON sta qui: arriva come callback `onDrain`, per lo stesso confine
16
- // di `useSheetOverlay` e `useSearchOverlay` — un hook di overlay non esce dal
17
- // deck.
18
- import { useMemo, useState } from 'react';
19
- import { inboxDetailCapacity, pageStep } from '../viewport.js';
20
- import { wrapWithOffsets } from '../width.js';
21
- import { parseMarkdown } from '../markdown.js';
22
- import { inboxPrompt } from '../inbox.js';
23
- export function useInboxOverlay(deps) {
24
- const { rows, columns, setMode, setNote, onDrain } = deps;
25
- const [sheet, setSheet] = useState(null);
26
- const [top, setTop] = useState(0);
27
- // Cornici da scalare: box esterno (2 bordi + 2 padding) + box testo (2 bordi
28
- // + 2 padding) = 8, come il detail della task.
29
- const width = Math.max(20, (columns || 80) - 8);
30
- // Il markdown si rende PRIMA del wrap: `**foo**` occupa 3 colonne rese e 7
31
- // grezze, quindi wrappare sui marker manderebbe a capo su un conteggio che il
32
- // terminale non disegna. Memo separato dal wrap perché il parse dipende solo
33
- // dal testo: un resize ri-wrappa, non ri-parsa.
34
- const doc = useMemo(() => (sheet?.text ? parseMarkdown(sheet.text) : null), [sheet]);
35
- const lines = useMemo(() => (doc ? wrapWithOffsets(doc.text, width) : []), [doc, width]);
36
- const capacity = inboxDetailCapacity(rows);
37
- const maxTop = Math.max(0, lines.length - capacity);
38
- /** Il prompt che partirà: derivato una volta e mostrato in fondo alla
39
- * schermata, così `⏎` non è mai un salto nel buio. */
40
- const prompt = sheet ? inboxPrompt(sheet.file) : '';
41
- function open(next) {
42
- setSheet(next);
43
- setTop(0);
44
- setNote('');
45
- setMode('inbox');
46
- }
47
- function close() {
48
- setMode('normal');
49
- setSheet(null);
50
- }
51
- function scroll(delta) {
52
- setTop((t) => Math.max(0, Math.min(maxTop, t + delta)));
53
- }
54
- function onKey(input, key) {
55
- if (key.escape) {
56
- close();
57
- }
58
- else if (key.return) {
59
- // Si chiude PRIMA di spawnare, come lo sheet della task: la schermata ha
60
- // finito il suo lavoro, e lasciarla aperta sopra una tab appena nata
61
- // farebbe credere che ci sia dell'altro da decidere.
62
- const s = sheet;
63
- close();
64
- if (s)
65
- onDrain(s.file, inboxPrompt(s.file));
66
- }
67
- else if (key.upArrow) {
68
- scroll(-1);
69
- }
70
- else if (key.downArrow) {
71
- scroll(1);
72
- }
73
- else if (key.pageUp) {
74
- scroll(-pageStep(capacity));
75
- }
76
- else if (key.pageDown) {
77
- scroll(pageStep(capacity));
78
- }
79
- else if (input === 'g') {
80
- // `g`/`G` sugli estremi, come reader, detail e project status: anche
81
- // questa schermata scorre testo, quindi eredita lo stesso alfabeto invece
82
- // di inventarne un secondo.
83
- setTop(0);
84
- }
85
- else if (input === 'G') {
86
- setTop(maxTop);
87
- }
88
- }
89
- return { sheet, doc, lines, top, capacity, maxTop, prompt, open, onKey, scroll };
90
- }
@@ -1,21 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // T134 — il detail di un file inbox: testo scorrevole più la riga che dichiara
3
- // cosa partirà su `⏎`.
4
- //
5
- // Riusa `DetailLine` del detail della task invece di ri-renderizzare il
6
- // markdown per conto proprio: sono lo stesso costrutto — un documento reso in
7
- // span tipizzati dentro un box — e due rese dello stesso markdown
8
- // divergerebbero al primo stile aggiunto. L'evidenziazione della ricerca non
9
- // serve qui (nessuna ricerca dentro l'inbox), quindi le occorrenze arrivano
10
- // vuote e il secondo taglio di `DetailLine` non gira nemmeno.
11
- import { Box, Text } from 'ink';
12
- import { cut } from '../width.js';
13
- import { DetailLine } from './detail-screen.js';
14
- import { INBOX_MARK, WARN, fmtDateTime, fmtSize } from '../glyphs.js';
15
- import { NATURA_SHORT, inboxMark } from '../inbox.js';
16
- export function InboxScreen({ file, missing, lines, spans, top, total, capacity, prompt, columns, }) {
17
- const last = Math.min(total, top + capacity);
18
- const width = Math.max(20, (columns || 80) - 4);
19
- const mark = inboxMark(file);
20
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: NATURA_SHORT[file.natura] }), ' ', cut(file.basename, Math.max(10, width - 8))] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [INBOX_MARK[mark] ? `${INBOX_MARK[mark]} ` : '', mark, file.branch ? ` ⟨${file.branch}⟩` : '', file.cappello ? ` · ${file.cappello}` : '', " \u00B7 ", file.nozioni, " nozioni \u00B7 ", file.aperte, ' ', "aperte \u00B7 ", fmtSize(file.chars), " \u00B7 ", fmtDateTime(file.created * 1000), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193/PgUp/PgDn" }), " testo \u00B7 ", _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " apre la sessione \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiude"] }), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: missing ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: [WARN, " file non leggibile \u00B7 l'azione resta attiva (la skill risolve il file per nome)"] })) : (lines.map((l, i) => (_jsx(DetailLine, { line: l, spans: spans, occ: [], current: -1 }, top + i)))) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "\u23CE \u203A " }), _jsx(Text, { color: "green", children: cut(prompt, Math.max(10, width - 4)) })] }) })] }));
21
- }