@lamemind/loom-deck 0.76.0 → 0.78.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,169 @@
1
+ // T176 — `deck-run` come programma: legge l'argv, compone la tab, la annuncia e
2
+ // la apre nel terminale della piattaforma.
3
+ //
4
+ // Il contratto verso chi lo lancia è quello dello script bash che ha sostituito:
5
+ // - uscita 2 e messaggio su stderr per un errore d'uso, nessun annuncio;
6
+ // - prima di aprire la tab, su stdout, la riga `LOOM_DECK_INTAB <comando>` col
7
+ // comando che gira nella tab — il deck la legge e la mostra nella riga di
8
+ // stato (`onInTabCommand`), invece di ricomporla;
9
+ // - poi il terminale, e l'uscita è la sua: `ptyxis --tab` e `wt.exe new-tab`
10
+ // escono appena hanno consegnato la tab all'istanza già aperta.
11
+ //
12
+ // L'I/O verso l'esterno passa da `DeckRunIo`, così il test della composizione
13
+ // completa non apre tab. Il filesystem no: file di progetto e catalogo si
14
+ // leggono dal disco anche nei test, su cartelle temporanee.
15
+ import { spawn } from 'node:child_process';
16
+ import { existsSync, readFileSync, statSync } from 'node:fs';
17
+ import { constants } from 'node:os';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { isUsageError, parseTabArgs } from './tab-args.js';
21
+ import { composeTab, ptyxisArgv } from './tab-compose.js';
22
+ import { loadPromptCatalog, PROMPT_CATALOG } from './prompt-catalog.js';
23
+ import { configFilePath } from './config.js';
24
+ import { resolveGitBash } from './git-bash.js';
25
+ import { semicolonArg, TAB_ARGV_ENV, windowsTabEnv, WT_CMD, wtNewTabArgs, wtProfile, } from './windows-terminal.js';
26
+ /** Il wrapper che gira dentro una tab di Windows Terminal, nel build: lo esegue
27
+ * il node del deck, e `src/` sotto `tsx` non ha un `.js` da dargli. */
28
+ export const TAB_WRAPPER_JS = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'tab-wrapper.js');
29
+ /**
30
+ * La cartella di lavoro della tab: `LOOM_DECK_WORKDIR`, altrimenti la cartella
31
+ * corrente.
32
+ *
33
+ * Su Linux la cartella corrente è quella LOGICA, come il `$PWD` di bash: `PWD`
34
+ * se punta alla stessa cartella di `cwd` (un symlink resta un symlink nel
35
+ * titolo e nel `-d` di Ptyxis), `cwd` altrimenti. Su Windows `PWD` arriva da
36
+ * Git Bash in forma `/c/…`, che `wt -d` non capisce: vale solo `cwd`.
37
+ */
38
+ export function workdirOf(io) {
39
+ if (io.env.LOOM_DECK_WORKDIR)
40
+ return io.env.LOOM_DECK_WORKDIR;
41
+ const pwd = io.env.PWD;
42
+ if (io.platform !== 'win32' && pwd) {
43
+ try {
44
+ const a = statSync(pwd);
45
+ const b = statSync(io.cwd);
46
+ if (a.dev === b.dev && a.ino === b.ino)
47
+ return pwd;
48
+ }
49
+ catch {
50
+ // PWD che non esiste più → la cartella vera
51
+ }
52
+ }
53
+ return io.cwd;
54
+ }
55
+ /** La cartella che contiene `.claude/loom-works.json`, risalendo da `start`
56
+ * fino alla radice compresa; `null` fuori da un progetto loom. */
57
+ export function findProjectRoot(start) {
58
+ let dir = resolve(start);
59
+ for (;;) {
60
+ if (existsSync(configFilePath(dir)))
61
+ return dir;
62
+ const up = dirname(dir);
63
+ if (up === dir)
64
+ return null;
65
+ dir = up;
66
+ }
67
+ }
68
+ /** Il file di progetto parsato, o `null` se manca o è malformato: il titolo
69
+ * ricade su `cc`, il permission mode su `manual`, e lo spawn parte lo stesso. */
70
+ function loadConfig(root) {
71
+ if (!root)
72
+ return null;
73
+ try {
74
+ return JSON.parse(readFileSync(configFilePath(root), 'utf8'));
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ /** Un argv come una shell POSIX lo leggerebbe: per le diagnostiche. */
81
+ function describe(cmd, args) {
82
+ return [cmd, ...args].map((a) => (/^[\w@%+=:,./\\-]+$/.test(a) ? a : JSON.stringify(a))).join(' ');
83
+ }
84
+ export async function runDeckRun(argv, io) {
85
+ const req = parseTabArgs(argv);
86
+ if (isUsageError(req)) {
87
+ for (const line of req.lines)
88
+ io.stderr(`${line}\n`);
89
+ return 2;
90
+ }
91
+ const workdir = workdirOf(io);
92
+ const catalogPath = io.env.LOOM_DECK_PROMPT_CATALOG || PROMPT_CATALOG;
93
+ const plan = composeTab(req, {
94
+ env: io.env,
95
+ config: loadConfig(findProjectRoot(workdir)),
96
+ catalog: loadPromptCatalog(catalogPath),
97
+ catalogPath,
98
+ platform: io.platform,
99
+ });
100
+ for (const w of plan.warnings)
101
+ io.stderr(`${w}\n`);
102
+ let cmd;
103
+ let args;
104
+ let env;
105
+ if (io.platform === 'win32') {
106
+ cmd = WT_CMD;
107
+ args = wtNewTabArgs({
108
+ newWindow: req.newWindow,
109
+ profile: wtProfile(io.env),
110
+ cwd: workdir,
111
+ title: plan.title,
112
+ command: [io.nodePath, TAB_WRAPPER_JS],
113
+ });
114
+ const bad = semicolonArg(args);
115
+ if (bad !== null) {
116
+ io.stderr(`argomento con ';' non passabile a ${WT_CMD}, che lo legge come separatore: '${bad}'\n`);
117
+ return 2;
118
+ }
119
+ env = {
120
+ ...windowsTabEnv(io.env, req.noTask ? null : req.task, resolveGitBash(io.env, existsSync)),
121
+ [TAB_ARGV_ENV]: JSON.stringify(plan.claudeArgs),
122
+ };
123
+ io.stdout(`LOOM_DECK_INTAB ${plan.inTab}\n`);
124
+ }
125
+ else {
126
+ // L'override esiste per i probe empirici (un comando non-CC nella tab): è
127
+ // una riga per bash, quindi vale solo dove la tab la esegue con bash.
128
+ const inTab = io.env.LOOM_DECK_INTAB_CMD || plan.inTab;
129
+ cmd = 'ptyxis';
130
+ args = ptyxisArgv(req.newWindow, workdir, plan.title, inTab);
131
+ io.stdout(`LOOM_DECK_INTAB ${inTab}\n`);
132
+ }
133
+ try {
134
+ return await io.launch(cmd, args, env);
135
+ }
136
+ catch (e) {
137
+ const code = e.code ?? String(e);
138
+ io.stderr(`deck-run: ${cmd} non lanciabile (${code}): ${describe(cmd, args)}\n`);
139
+ return 127;
140
+ }
141
+ }
142
+ /**
143
+ * Il lancio vero: stdio ereditato, così chi legge lo stdout di `deck-run` vede
144
+ * anche quello del terminale, come quando lo script finiva con `exec`.
145
+ *
146
+ * `windowsHide: false` e non il `true` degli altri spawn del deck: `wt.exe` è
147
+ * un'applicazione GUI, e con `true` la finestra di `-w new` partirebbe nascosta.
148
+ * Un processo GUI non apre console, quindi non c'è niente da nascondere.
149
+ */
150
+ export function launchTerminal(cmd, args, env) {
151
+ return new Promise((resolveExit, reject) => {
152
+ const child = spawn(cmd, args, { stdio: 'inherit', env: env, windowsHide: false });
153
+ child.on('error', reject);
154
+ child.on('exit', (code, signal) => {
155
+ resolveExit(code ?? 128 + (signal ? (constants.signals[signal] ?? 0) : 0));
156
+ });
157
+ });
158
+ }
159
+ export function realIo() {
160
+ return {
161
+ env: process.env,
162
+ cwd: process.cwd(),
163
+ platform: process.platform,
164
+ nodePath: process.execPath,
165
+ stdout: (s) => process.stdout.write(s),
166
+ stderr: (s) => process.stderr.write(s),
167
+ launch: launchTerminal,
168
+ };
169
+ }
@@ -0,0 +1,44 @@
1
+ // T176 — il programma che gira DENTRO una tab di Windows Terminal aperta dal
2
+ // deck: legge l'argv di `claude` dalla variabile della tab e lo lancia.
3
+ //
4
+ // Esiste perché il prompt non deve passare da nessun parser: `wt.exe` legge `;`
5
+ // come separatore di sottocomandi, cmd e PowerShell interpretano `' " & % ^ |`
6
+ // (P3 preflight). L'argv arriva come JSON nell'env, che la tab eredita da chi
7
+ // lancia `wt.exe`, e da qui va a `claude` come array, senza shell.
8
+ //
9
+ // La variabile si toglie dall'env PRIMA del lancio: è un canale verso questo
10
+ // programma, e `claude` la passerebbe a ogni hook e a ogni comando della
11
+ // sessione.
12
+ //
13
+ // `claude` è quello di `claudeLauncher` (T175): l'eseguibile nativo, lo shim npm
14
+ // letto, o `LOOM_DECK_CLAUDE_CMD`.
15
+ //
16
+ // Ctrl+C appartiene a `claude`. In una console Windows il tasto arriva a ogni
17
+ // processo attaccato, quindi anche qui: senza un handler questo processo
18
+ // uscirebbe, e la tab si chiuderebbe sotto una sessione viva. Si esce solo
19
+ // quando esce `claude`, col suo codice — e la tab si chiude o resta aperta
20
+ // secondo il `closeOnExit` del profilo (D3 preflight).
21
+ import { spawn } from 'node:child_process';
22
+ import { claudeLauncher } from './claude-cmd.js';
23
+ import { parseTabArgv, TAB_ARGV_ENV } from './windows-terminal.js';
24
+ const raw = process.env[TAB_ARGV_ENV];
25
+ delete process.env[TAB_ARGV_ENV];
26
+ const args = parseTabArgv(raw);
27
+ if (!args) {
28
+ process.stderr.write(`loom-deck: ${TAB_ARGV_ENV} assente o malformata — questa tab non sa quale conversazione aprire\n`);
29
+ process.exitCode = 2;
30
+ }
31
+ else {
32
+ process.on('SIGINT', () => { });
33
+ const claude = claudeLauncher();
34
+ // `windowsHide: false`: il figlio condivide la console della tab, che è
35
+ // proprio la finestra in cui deve comparire.
36
+ const child = spawn(claude.cmd, [...claude.prefix, ...args], { stdio: 'inherit', windowsHide: false });
37
+ child.on('error', (e) => {
38
+ process.stderr.write(`loom-deck: '${claude.cmd}' non lanciabile (${e.code ?? e.message})\n`);
39
+ process.exitCode = 127;
40
+ });
41
+ child.on('exit', (code) => {
42
+ process.exit(code ?? 1);
43
+ });
44
+ }
package/dist/tasks.js CHANGED
@@ -129,7 +129,7 @@ export function loadTasks(path) {
129
129
  * sciolti in spazi, cioè il buco `{slug}` dei template di titolo.
130
130
  *
131
131
  * Viene dal NOME del file e non dalla descrizione in `tasks.md` per una ragione
132
- * di alfabeto: il nome è già dentro quello di `_sane_note` per costruzione —
132
+ * di alfabeto: il nome è già dentro quello di `saneNote` per costruzione —
133
133
  * minuscolo, separato da trattini, senza punteggiatura — mentre la descrizione
134
134
  * porta apostrofi e `/`, che la riduzione toglie senza sostituto saldando le
135
135
  * parole (`l'epica/doc` → `lepicadoc`).
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // Detail della task (T66): quarta schermata sostitutiva, col markdown reso in
3
3
  // span tipizzati (T75) e la ricerca interna (T91).
4
4
  import { Box, Text } from 'ink';
@@ -9,6 +9,7 @@ import { ChoiceRow, FieldText, LABEL_W } from './fields.js';
9
9
  import { DETAIL_ACTIONS, MODELS } from '../spawn-catalog.js';
10
10
  import { DROW, PRIORITY_CHOICES } from '../overlays/sheet.js';
11
11
  import { CARET, CARET_OFF, WARN } from '../glyphs.js';
12
+ import { DlvArea } from './dlv-area.js';
12
13
  /** Resa di ogni costrutto markdown (T75 · D4): un solo livello di enfasi per
13
14
  * costrutto, senza un secondo alfabeto da imparare. Heading uguali a ogni
14
15
  * livello — la gerarchia la porta già il testo. `code` e `fence` condividono
@@ -79,7 +80,7 @@ const ACTION_KEYS = DETAIL_ACTIONS.map((a) => a.label[0]).join(' ');
79
80
  * rettangolo ha già coordinate e area cliccabile, quindi il layout sopravvive
80
81
  * all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione.
81
82
  */
82
- export function DetailScreen({ id, title, missing, lines, spans, top, total, capacity, action, model, spawnNote, prompt, priority, cursor, columns, find, occ, occCur, }) {
83
+ export function DetailScreen({ id, title, missing, lines, spans, top, total, capacity, action, model, spawnNote, prompt, priority, cursor, columns, find, occ, occCur, dlv, dlvRows, }) {
83
84
  const last = Math.min(total, top + capacity);
84
85
  // Il taglio lo fa il chiamante (invariante ③ di width.ts): le righe bottoni
85
86
  // sono ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso
@@ -91,5 +92,9 @@ export function DetailScreen({ id, title, missing, lines, spans, top, total, cap
91
92
  // campo vuoto): riservarle sempre toglierebbe testo visibile a un titolo lungo
92
93
  // per un avviso assente.
93
94
  const textW = (extra) => Math.max(10, width - LABEL_W - CARET_OFF.length - extra);
94
- 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: [_jsx(Text, { color: "cyan", children: id }), " \u00B7 ", cut(title, Math.max(10, width - 34)), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), find?.open ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " occorrenza \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " caret \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " tieni \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " testo \u00B7", ' ', _jsx(Text, { color: "yellow", children: ACTION_KEYS }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota campo \u00B7 ", _jsx(Text, { color: "yellow", children: "^F" }), " cerca"] })), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: missing ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: [WARN, " task file non trovato \u00B7 le azioni restano attive (deck-run risolve la task per id)"] })) : (lines.map((l, i) => (_jsx(DetailLine, { line: l, spans: spans, occ: occ, current: occCur }, top + i)))) }), find?.open ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "cerca " }), _jsx(FieldText, { value: find.q, caret: find.caret, focused: true, cols: Math.max(10, width - 28) }), find.q.length === 0 ? (_jsx(Text, { dimColor: true, children: " \u00B7 digita per cercare" })) : occ.length === 0 ? (_jsx(Text, { color: "yellow", children: " \u00B7 nessuna occorrenza" })) : (_jsxs(Text, { color: "cyan", children: [' ', "\u00B7 ", occCur + 1, "/", occ.length] }))] }) })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(ChoiceRow, { label: "azione", values: DETAIL_ACTIONS.map((a) => a.label), index: action, focused: cursor.row === DROW.action, width: width }), _jsxs(Text, { wrap: "truncate-end", children: [mark(DROW.prompt), _jsx(Text, { dimColor: true, children: 'prompt'.padEnd(LABEL_W) }), _jsx(FieldText, { value: prompt, caret: cursor.caret, focused: cursor.row === DROW.prompt, cols: textW(prompt ? 0 : PROMPT_HINT.length) }), prompt ? null : _jsx(Text, { dimColor: true, children: PROMPT_HINT })] }), _jsx(ChoiceRow, { label: "modello", values: MODELS, index: Math.max(0, MODELS.indexOf(model)), focused: cursor.row === DROW.model, width: width }), _jsxs(Text, { wrap: "truncate-end", children: [mark(DROW.title), _jsx(Text, { dimColor: true, children: 'titolo'.padEnd(LABEL_W) }), _jsx(FieldText, { value: spawnNote, caret: cursor.caret, focused: cursor.row === DROW.title, cols: textW(spawnNote ? 0 : NOTE_HINT.length) }), spawnNote ? null : _jsx(Text, { dimColor: true, children: NOTE_HINT })] }), _jsx(ChoiceRow, { label: "priorit\u00E0", values: PRIORITY_CHOICES, index: priority ? 1 : 0, focused: cursor.row === DROW.priority, width: width })] })] }));
95
+ 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: [_jsx(Text, { color: "cyan", children: id }), " \u00B7 ", cut(title, Math.max(10, width - 34)), missing ? '' : ` · righe ${total === 0 ? 0 : top + 1}-${last} di ${total}`] }), find?.open ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " occorrenza \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " caret \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " tieni \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : dlv.active ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [dlv.gate.kind === 'ready' ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " DLV aperti \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "seleziona \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " run \u00B7", ' '] })) : null, _jsx(Text, { color: "yellow", children: "tab" }), " campi \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " testo \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^F" }), " cerca"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " testo \u00B7", ' ', _jsx(Text, { color: "yellow", children: ACTION_KEYS }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^U" }), " svuota campo \u00B7 ", _jsx(Text, { color: "yellow", children: "^F" }), " cerca \u00B7", ' ', _jsx(Text, { color: "yellow", children: "tab" }), " DLV"] })), _jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: missing ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: [WARN, " task file non trovato \u00B7 le azioni restano attive (deck-run risolve la task per id)"] })) : (lines.map((l, i) => (_jsx(DetailLine, { line: l, spans: spans, occ: occ, current: occCur }, top + i)))) }), find?.open ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "cerca " }), _jsx(FieldText, { value: find.q, caret: find.caret, focused: true, cols: Math.max(10, width - 28) }), find.q.length === 0 ? (_jsx(Text, { dimColor: true, children: " \u00B7 digita per cercare" })) : occ.length === 0 ? (_jsx(Text, { color: "yellow", children: " \u00B7 nessuna occorrenza" })) : (_jsxs(Text, { color: "cyan", children: [' ', "\u00B7 ", occCur + 1, "/", occ.length] }))] }) })) : null, dlv.active ? (_jsx(DlvArea, { dlvs: dlv.dlvs, cursor: dlv.cursor, selected: dlv.selected, gate: dlv.gate, prompt: dlv.prompt, model: dlv.model, rows: dlvRows, width: width })) : (
96
+ /* Le quattro righe dell'area di compilazione, nell'ordine in cui si
97
+ decide: cosa lanciare, con quale prompt, con quale modello, sotto quale
98
+ titolo. */
99
+ _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(ChoiceRow, { label: "azione", values: DETAIL_ACTIONS.map((a) => a.label), index: action, focused: cursor.row === DROW.action, width: width }), _jsxs(Text, { wrap: "truncate-end", children: [mark(DROW.prompt), _jsx(Text, { dimColor: true, children: 'prompt'.padEnd(LABEL_W) }), _jsx(FieldText, { value: prompt, caret: cursor.caret, focused: cursor.row === DROW.prompt, cols: textW(prompt ? 0 : PROMPT_HINT.length) }), prompt ? null : _jsx(Text, { dimColor: true, children: PROMPT_HINT })] }), _jsx(ChoiceRow, { label: "modello", values: MODELS, index: Math.max(0, MODELS.indexOf(model)), focused: cursor.row === DROW.model, width: width }), _jsxs(Text, { wrap: "truncate-end", children: [mark(DROW.title), _jsx(Text, { dimColor: true, children: 'titolo'.padEnd(LABEL_W) }), _jsx(FieldText, { value: spawnNote, caret: cursor.caret, focused: cursor.row === DROW.title, cols: textW(spawnNote ? 0 : NOTE_HINT.length) }), spawnNote ? null : _jsx(Text, { dimColor: true, children: NOTE_HINT })] }), _jsx(ChoiceRow, { label: "priorit\u00E0", values: PRIORITY_CHOICES, index: priority ? 1 : 0, focused: cursor.row === DROW.priority, width: width })] }))] }));
95
100
  }
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // T189 — la lista dei DLV nel detail della task, al posto dell'area di
3
+ // compilazione quando il modo DLV è attivo.
4
+ import { Box, Text } from 'ink';
5
+ import { cut, cutParts, pad } from '../width.js';
6
+ import { parseMarkdown } from '../markdown.js';
7
+ import { windowRange } from '../viewport.js';
8
+ import { CARET, CARET_OFF, DLV_MARK, DLV_MARK_W, WARN } from '../glyphs.js';
9
+ /** Il testo di una voce come lo rende il detail sopra: senza `**` e backtick.
10
+ * Il markdown resta grezzo nel task file, e una lista che mostrasse i marker
11
+ * direbbe la stessa voce in due forme sulla stessa schermata. */
12
+ function plain(text) {
13
+ return parseMarkdown(text).text;
14
+ }
15
+ /** La riga di stato: cosa parte su `⏎`, o perché non parte niente, più quante
16
+ * righe della lista stanno fuori dalla finestra. Quattro pezzi, uno per
17
+ * colore; il budget serve per primo il secondo, perché è quello che si legge
18
+ * prima di premere. */
19
+ function statusParts(gate, prompt, model, above, below) {
20
+ const overflow = above + below > 0 ? ` · ↑${above} ↓${below}` : '';
21
+ if (gate.kind === 'ready') {
22
+ return { parts: ['⏎ ', prompt, model ? ` · ${model}` : '', overflow], tone: 'ready' };
23
+ }
24
+ const lead = gate.kind === 'blocked' ? `${WARN} ` : '';
25
+ return { parts: [lead, gate.reason, ' · ⏎ inerte', overflow], tone: gate.kind };
26
+ }
27
+ /**
28
+ * La lista dei DLV numerata come la numera `run-task`, coi fatti in grigio e
29
+ * spuntati, più una riga di stato.
30
+ *
31
+ * Il cursore c'è solo quando il gate è `ready`: fuori da lì nessuna voce è
32
+ * selezionabile, e un caret su una riga inerte prometterebbe un gesto che non
33
+ * esiste. Sopra `rows` la lista si finestra sul cursore; senza cursore parte
34
+ * dall'inizio, e la riga di stato dice quante voci restano fuori.
35
+ */
36
+ export function DlvArea({ dlvs, cursor, selected, gate, prompt, model, rows, width, }) {
37
+ const ready = gate.kind === 'ready';
38
+ const at = ready ? dlvs.findIndex((d) => d.n === cursor) : -1;
39
+ const { start, end } = windowRange(dlvs.length, at, rows);
40
+ const numW = String(dlvs.length).length;
41
+ // caret + marca + spazio + numero + spazio: tutto ciò che precede il testo.
42
+ const textW = Math.max(1, width - CARET_OFF.length - DLV_MARK_W - 1 - numW - 1);
43
+ const status = statusParts(gate, prompt, model, start, dlvs.length - end);
44
+ const [lead, body, tail, overflow] = cutParts(status.parts, width, 1);
45
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [dlvs.slice(start, end).map((d) => {
46
+ const focused = ready && d.n === cursor;
47
+ const picked = selected.has(d.n);
48
+ const mark = d.done ? DLV_MARK.done : picked ? DLV_MARK.picked : DLV_MARK.open;
49
+ return (_jsxs(Text, { wrap: "truncate-end", children: [focused ? CARET : CARET_OFF, _jsx(Text, { color: picked ? 'cyan' : undefined, dimColor: d.done, children: pad(mark, DLV_MARK_W) }), ' ', _jsx(Text, { dimColor: true, children: pad(String(d.n), numW, 'right') }), ' ', _jsx(Text, { dimColor: d.done, color: picked ? 'cyan' : undefined, bold: focused, children: cut(plain(d.text), textW) })] }, d.n));
50
+ }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: status.tone === 'off' ? undefined : 'yellow', dimColor: status.tone === 'off', children: lead }), _jsx(Text, { color: status.tone === 'ready' ? 'cyan' : status.tone === 'blocked' ? 'yellow' : undefined, dimColor: status.tone === 'off', children: body }), _jsx(Text, { dimColor: true, children: tail }), _jsx(Text, { dimColor: true, children: overflow })] })] }));
51
+ }
@@ -153,7 +153,7 @@ export function screenFor(input) {
153
153
  // posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
154
154
  // può accorciare il testo sotto uno scroll già dato.
155
155
  const start = Math.min(sheet.top, sheet.maxTop);
156
- return (_jsx(DetailScreen, { id: sheet.sheet.id, title: sheet.sheet.title, missing: sheet.sheet.text === null, lines: sheet.lines.slice(start, start + sheet.capacity), spans: sheet.doc?.spans ?? [], top: start, total: sheet.lines.length, capacity: sheet.capacity, action: sheet.action, model: sheet.model, spawnNote: sheet.spawnNote, prompt: sheet.prompt, priority: sheet.priority, cursor: sheet.cursor, columns: columns, find: sheet.find, occ: sheet.findRes.occ, occCur: sheet.occCur }));
156
+ return (_jsx(DetailScreen, { id: sheet.sheet.id, title: sheet.sheet.title, missing: sheet.sheet.text === null, lines: sheet.lines.slice(start, start + sheet.capacity), spans: sheet.doc?.spans ?? [], top: start, total: sheet.lines.length, capacity: sheet.capacity, action: sheet.action, model: sheet.model, spawnNote: sheet.spawnNote, prompt: sheet.prompt, priority: sheet.priority, cursor: sheet.cursor, columns: columns, find: sheet.find, occ: sheet.findRes.occ, occCur: sheet.occCur, dlv: sheet.dlv, dlvRows: sheet.dlvRows }));
157
157
  }
158
158
  // ── T121 · viewer del project status ────────────────────────────────────
159
159
  // Quinta schermata sostitutiva, stessa ragione delle altre quattro: un recap
package/dist/viewport.js CHANGED
@@ -74,7 +74,7 @@ export const MODAL_HEIGHT = {
74
74
  // Le quattro righe sono FISSE, non condizionali: la quarta ospita tanto gli
75
75
  // scarti del bulk quanto la scelta keep/purge della singola task sporca, e due
76
76
  // condizionali mutuamente esclusivi obbligherebbero il budget a sapere QUALE
77
- // dei due è a schermo (stessa ragione della riga nota in DETAIL_CHROME, T111).
77
+ // dei due è a schermo (stessa ragione della riga nota in DETAIL_FIELD_ROWS, T111).
78
78
  purge: 7,
79
79
  // Conferma di eliminazione di una conversazione: marginTop + 2 bordi + 3
80
80
  // righe (domanda con l'id, titolo, effetto sul disco + tasti). Nessuna riga
@@ -278,30 +278,38 @@ const READER_CHROME = 8;
278
278
  export function readerCapacity(rows) {
279
279
  return Math.max(0, (rows || 24) - SLACK - READER_CHROME);
280
280
  }
281
- // T66 — cornice del detail della task.
281
+ // T66 — cornice del detail della task, comune ai due modi (T189).
282
282
  // 2 bordi del box esterno
283
283
  // 1 titolo "loom-deck"
284
284
  // 1 riga meta (id · titolo · posizione nel testo)
285
285
  // 1 riga hint
286
286
  // 1 marginTop del box testo
287
287
  // 2 bordi del box testo
288
- // 1 marginTop dell'area di compilazione
288
+ // 1 marginTop dell'area sotto il testo
289
+ //
290
+ // Il detail non può riusare READER_CHROME perché sotto il testo porta un'AREA
291
+ // in più dentro l'overlay, e ogni riga di quell'area va scalata dalla capienza
292
+ // del contenuto o il frame sfonda `rows` (stessa invariante di
293
+ // TASKS_PANE_CHROME).
294
+ const DETAIL_BASE_CHROME = 9;
295
+ // L'area di compilazione: righe FISSE, una per campo.
289
296
  // 1 riga azione (T66)
290
297
  // 1 riga prompt (T117)
291
298
  // 1 riga modello (T108)
292
299
  // 1 riga titolo (T111)
293
300
  // 1 riga priorità (T158)
294
301
  //
295
- // Le ultime sei sono il motivo per cui il detail non può riusare READER_CHROME:
296
- // le cinque righe dell'area di compilazione sono righe FISSE in più dentro
297
- // l'overlay, e ogni riga fissa aggiunta va scalata dalla capienza del contenuto o
298
- // il frame sfonda `rows` (stessa invariante di TASKS_PANE_CHROME).
299
- //
300
- // Sono fisse e non `extra` condizionali di `detailCapacity` (T111 · D1): due
302
+ // Fisse e non `extra` condizionali dei singoli campi (T111 · D1): due
301
303
  // condizionali mutuamente esclusivi obbligherebbero il parametro a dire QUALE è
302
304
  // a schermo — un solo booleano per entrambi sottostima quando quello aperto è il
303
305
  // più alto, e sommarli toglie righe al testo per un campo che non c'è.
304
- const DETAIL_CHROME = 14;
306
+ export const DETAIL_FIELD_ROWS = 5;
307
+ // T189 — il modo DLV mette al posto dei campi la lista dei deliverable più una
308
+ // riga di stato (cosa parte su `⏎`, o perché non parte niente). È l'altezza che
309
+ // `detailCapacity` riceve come `area` quando il modo è attivo: il chiamante
310
+ // dice cosa c'è a schermo con un numero di righe, non con un flag, per la
311
+ // stessa ragione scritta sopra sui condizionali.
312
+ export const DLV_STATUS_ROWS = 1;
305
313
  // T91 — la ricerca dentro il detail: marginTop + riga del campo.
306
314
  //
307
315
  // `MODAL_HEIGHT` dice che il detail costa 0 ai due pane (li sostituisce), e da
@@ -312,10 +320,25 @@ const DETAIL_CHROME = 14;
312
320
  // frame nello scrollback a ogni tick del poll.
313
321
  const DETAIL_SEARCH_CHROME = 2;
314
322
  /** Righe di task file che entrano nel terminale; con la ricerca aperta il campo
315
- * si paga qui, non altrove. */
316
- export function detailCapacity(rows, searching = false) {
323
+ * si paga qui, non altrove. `area` = righe dell'area sotto il testo: i campi
324
+ * per default, `dlvListRows(…) + DLV_STATUS_ROWS` nel modo DLV. */
325
+ export function detailCapacity(rows, searching = false, area = DETAIL_FIELD_ROWS) {
326
+ const extra = searching ? DETAIL_SEARCH_CHROME : 0;
327
+ return Math.max(0, (rows || 24) - SLACK - DETAIL_BASE_CHROME - area - extra);
328
+ }
329
+ /**
330
+ * T189 — righe della lista DLV: tutte quelle che ci sono, ma mai più di METÀ
331
+ * dello spazio sotto la cornice. Il resto resta al testo della task, che è ciò
332
+ * per cui il detail si apre; una lista più lunga si finestra sul cursore
333
+ * (`windowRange`) e la riga di stato dice quante righe stanno fuori.
334
+ *
335
+ * Su un terminale basso può valere 0 anche con dei DLV: resta la sola riga di
336
+ * stato, e il frame non sfonda.
337
+ */
338
+ export function dlvListRows(rows, searching, total) {
317
339
  const extra = searching ? DETAIL_SEARCH_CHROME : 0;
318
- return Math.max(0, (rows || 24) - SLACK - DETAIL_CHROME - extra);
340
+ const free = (rows || 24) - SLACK - DETAIL_BASE_CHROME - DLV_STATUS_ROWS - extra;
341
+ return Math.max(0, Math.min(total, Math.floor(free / 2)));
319
342
  }
320
343
  // T121 — cornice del viewer del project status. Gemella di READER_CHROME: il
321
344
  // viewer scorre testo e basta, senza l'area di compilazione che rende caro il
@@ -383,7 +406,7 @@ const SPAWN_CHROME = 8;
383
406
  // si modificano. Un'altezza che dipendesse dalla riga selezionata farebbe
384
407
  // saltare la tabella di due righe a ogni movimento del caret, e il budget
385
408
  // dovrebbe sapere quale riga è aperta per non sottostimare — lo stesso motivo
386
- // per cui le cinque righe del detail sono dentro `DETAIL_CHROME` invece che
409
+ // per cui le cinque righe del detail sono fisse in `DETAIL_FIELD_ROWS` invece che
387
410
  // condizionali (T111 · D1).
388
411
  const SPAWN_FIELDS_CHROME = 5;
389
412
  /**
@@ -0,0 +1,153 @@
1
+ // T176 — le tab di WINDOWS TERMINAL: argv di `wt.exe`, env della tab, il canale
2
+ // che porta l'argv di `claude` al wrapper.
3
+ //
4
+ // Su Windows una conversazione si apre con
5
+ //
6
+ // wt.exe -w 0 new-tab -p loom-deck -d <root> --title <titolo>
7
+ // --suppressApplicationTitle -- <node> <tab-wrapper.js>
8
+ //
9
+ // e l'argv di `claude` (nome, flag, prompt) NON sta su quella riga: viaggia come
10
+ // JSON nella variabile `LOOM_DECK_TAB_ARGV`, perché la tab eredita l'env del
11
+ // processo che lancia `wt.exe` (T174 verdetto 5, rimisurato con `-p`). Il
12
+ // wrapper la legge, la toglie e lancia `claude` senza shell (P3 preflight).
13
+ // Così il prompt non passa né dal parser di `wt` né da una shell: `wt` legge `;`
14
+ // come separatore di sottocomandi in QUALUNQUE argomento, anche dopo `--` e
15
+ // anche escapato `\;` (misurato su Windows Terminal 1.24: la coda dopo il `;`
16
+ // diventa una tab a sé, col proprio errore), e cmd o PowerShell
17
+ // interpreterebbero `' " & % ^ |`.
18
+ //
19
+ // Modulo PURO: l'env arriva come valore, il bash di Git come stringa già
20
+ // risolta.
21
+ /** Il lanciatore di Windows Terminal: l'alias d'esecuzione in `WindowsApps`,
22
+ * che `spawn` trova nel `PATH` e lancia senza shell (misurato). */
23
+ export const WT_CMD = 'wt.exe';
24
+ /** Il profilo delle tab del deck (P9 preflight): uno solo per tutti i
25
+ * progetti, nascosto dal menu, creato a mano dall'utente nel `settings.json` di
26
+ * Windows Terminal. Porta ciò che i flag non dicono — font, `closeOnExit`, la
27
+ * shell del tasto `t`; titolo, cartella e comando li passa il deck. */
28
+ export const WT_PROFILE_DEFAULT = 'loom-deck';
29
+ /** La variabile con cui l'argv di `claude` arriva al wrapper della tab. */
30
+ export const TAB_ARGV_ENV = 'LOOM_DECK_TAB_ARGV';
31
+ /**
32
+ * I marker che una sessione Claude Code inietta nei processi figli (P4
33
+ * preflight, misurati nella shell di una sessione su Windows).
34
+ *
35
+ * La tab eredita tutto l'env del deck, e un deck avviato da una shell aperta
36
+ * dentro Claude Code li porterebbe nella conversazione nuova: con
37
+ * `CLAUDE_CODE_CHILD_SESSION` la sessione non salva il transcript («Transcript
38
+ * saving is off — inherited CLAUDE_CODE_CHILD_SESSION marker») e non compare
39
+ * nello storico del deck. Lista chiusa e non ogni `CLAUDE*`: un
40
+ * `CLAUDE_CONFIG_DIR` o un `CLAUDE_CODE_GIT_BASH_PATH` dell'utente sparirebbero
41
+ * senza segnale, mentre un marker nuovo di una versione futura si annuncia da
42
+ * sé, nel messaggio di avvio di `claude` che lo nomina.
43
+ */
44
+ export const SESSION_MARKERS = [
45
+ 'CLAUDECODE',
46
+ 'CLAUDE_CODE_CHILD_SESSION',
47
+ 'CLAUDE_CODE_SESSION_ID',
48
+ 'CLAUDE_PID',
49
+ 'CLAUDE_EFFORT',
50
+ 'CLAUDE_CODE_MESSAGING_SOCKET',
51
+ 'CLAUDE_CODE_MESSAGING_TOKEN',
52
+ 'CLAUDE_CODE_ENTRYPOINT',
53
+ 'CLAUDE_CODE_SESSION_ATTENDED',
54
+ 'CLAUDE_CODE_EXECPATH',
55
+ ];
56
+ const GIT_BASH_ENV = 'CLAUDE_CODE_GIT_BASH_PATH';
57
+ /** Il profilo da passare a `-p`: `LOOM_DECK_WT_PROFILE` se impostata (vuota =
58
+ * nessun `-p`, il profilo di default di Windows Terminal), altrimenti
59
+ * `loom-deck`. Un profilo che non esiste non è un errore: Windows Terminal
60
+ * apre la tab col profilo di default e non dice niente (misurato), quindi il
61
+ * deck lo passa sempre senza verificarlo. */
62
+ export function wtProfile(env) {
63
+ const v = env.LOOM_DECK_WT_PROFILE;
64
+ if (v === undefined)
65
+ return WT_PROFILE_DEFAULT;
66
+ return v || null;
67
+ }
68
+ /** Il titolo come lo riceve `wt`: senza `;`, che spezzerebbe il comando. Il
69
+ * titolo è composto dal deck (label, TaskID, nota ridotta) e un `;` può
70
+ * venire solo dal nome del progetto: si toglie invece di fallire. */
71
+ export function wtTitle(title) {
72
+ return title.replace(/;/g, '');
73
+ }
74
+ /**
75
+ * L'argv di `wt.exe` per una tab nuova.
76
+ *
77
+ * `-w 0` è la finestra di Windows Terminal usata per ultima — quella da cui si
78
+ * lavora col deck —, `-w new` una finestra nuova (l'equivalente di
79
+ * `--new-window` di Ptyxis). `--suppressApplicationTitle` tiene il titolo del
80
+ * deck contro quello che il programma della tab imposta (misurato): è ciò che su
81
+ * Linux fa `-T` di Ptyxis contro l'OSC del prompt. Senza titolo il flag non si
82
+ * passa, e la tab prende quello della shell.
83
+ */
84
+ export function wtNewTabArgs(tab) {
85
+ const args = ['-w', tab.newWindow ? 'new' : '0', 'new-tab'];
86
+ if (tab.profile)
87
+ args.push('-p', tab.profile);
88
+ args.push('-d', tab.cwd);
89
+ if (tab.title !== null)
90
+ args.push('--title', wtTitle(tab.title), '--suppressApplicationTitle');
91
+ if (tab.command)
92
+ args.push('--', ...tab.command);
93
+ return args;
94
+ }
95
+ /**
96
+ * Il primo argomento di `wt` che contiene un `;`, o `null`.
97
+ *
98
+ * Il titolo è già ripulito; restano i path (la cartella, node, il wrapper) e il
99
+ * nome del profilo. Un path col `;` è legale su NTFS e `wt` non lo sa portare:
100
+ * lo spawn si ferma dicendolo, invece di aprire una tab d'errore con metà
101
+ * comando.
102
+ */
103
+ export function semicolonArg(args) {
104
+ return args.find((a) => a.includes(';')) ?? null;
105
+ }
106
+ /** Toglie una chiave ignorando le maiuscole: su Windows l'env non le distingue,
107
+ * e una copia di `process.env` può portare `Path` invece di `PATH`. */
108
+ function deleteKey(env, key) {
109
+ const want = key.toUpperCase();
110
+ for (const k of Object.keys(env))
111
+ if (k.toUpperCase() === want)
112
+ delete env[k];
113
+ }
114
+ function hasKey(env, key) {
115
+ const want = key.toUpperCase();
116
+ return Object.keys(env).some((k) => k.toUpperCase() === want && env[k]);
117
+ }
118
+ /**
119
+ * L'env di una tab aperta dal deck su Windows: quello del deck, meno i marker di
120
+ * sessione, con la task della conversazione e il bash di Git.
121
+ *
122
+ * `task` = `null` per le tab senza task (`--no-task`, il tasto `t`): una
123
+ * `LOOM_TASK` ereditata legherebbe a una task una sessione che non ne ha, e
124
+ * l'hook `SessionStart` ne inietterebbe il task file. `CLAUDE_CODE_GIT_BASH_PATH`
125
+ * entra quando manca e il bash si trova: la tab eredita anche il `PATH`, e col
126
+ * `PATH` di Git Bash `claude` eseguirebbe gli hook del plugin in PowerShell
127
+ * (T174 verdetto 9), cioè senza la task in contesto.
128
+ */
129
+ export function windowsTabEnv(env, task, gitBash) {
130
+ const out = { ...env };
131
+ for (const marker of SESSION_MARKERS)
132
+ deleteKey(out, marker);
133
+ deleteKey(out, 'LOOM_TASK');
134
+ deleteKey(out, TAB_ARGV_ENV);
135
+ if (task)
136
+ out.LOOM_TASK = task;
137
+ if (gitBash && !hasKey(env, GIT_BASH_ENV))
138
+ out[GIT_BASH_ENV] = gitBash;
139
+ return out;
140
+ }
141
+ /** L'argv di `claude` letto dalla variabile della tab, o `null` se manca o non
142
+ * è un array di stringhe. */
143
+ export function parseTabArgv(raw) {
144
+ if (!raw)
145
+ return null;
146
+ try {
147
+ const v = JSON.parse(raw);
148
+ return Array.isArray(v) && v.every((a) => typeof a === 'string') ? v : null;
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
package/dist/wrap-scan.js CHANGED
@@ -87,7 +87,7 @@ export const WRAP_DEFAULT_PATH = '.';
87
87
  * T161 — il buco `{path}` del titolo e del prompt di una sessione di
88
88
  * srotolamento: il path reso a parole.
89
89
  *
90
- * Le barre diventano spazi e l'estensione cade QUI, prima di `_sane_note`, il
90
+ * Le barre diventano spazi e l'estensione cade QUI, prima di `saneNote`, il
91
91
  * cui alfabeto non ha né `/` né `.`: lasciandoceli cadere le parole si
92
92
  * salderebbero (`runtimereferencedoc-systemmd`), e il titolo della tab
93
93
  * divergerebbe da quello che la lista del deck legge dal sidecar.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.76.0",
3
+ "version": "0.78.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,6 +13,7 @@
13
13
  "scripts": {
14
14
  "dev": "tsx src/cli.tsx",
15
15
  "build": "tsc",
16
+ "pretest": "tsc",
16
17
  "test": "node test/run.mjs",
17
18
  "start": "node dist/cli.js",
18
19
  "prepare": "tsc"