@lamemind/loom-deck 0.75.0 → 0.77.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 +24 -2
- package/dist/actions.js +67 -5
- package/dist/archivable.js +1 -1
- package/dist/claude-cmd.js +101 -0
- package/dist/cli.js +18 -7
- package/dist/commit-times.js +5 -2
- package/dist/config.js +51 -6
- package/dist/deck-model.js +14 -9
- package/dist/deck-run.js +7 -0
- package/dist/doc-tree.js +2 -2
- package/dist/frame.js +7 -0
- package/dist/git-bash.js +78 -0
- package/dist/gitlink.js +1 -0
- package/dist/hooks.js +21 -7
- package/dist/inbox.js +2 -2
- package/dist/input.js +7 -0
- package/dist/live-sessions.js +4 -1
- package/dist/overlays/gitlink.js +12 -2
- package/dist/overlays/wrap.js +10 -1
- package/dist/pane-views.js +12 -2
- package/dist/platform.js +93 -0
- package/dist/project-path.js +102 -0
- package/dist/purge.js +1 -0
- package/dist/sane-note.js +18 -24
- package/dist/sessions.js +63 -29
- package/dist/spawn-catalog.js +4 -4
- package/dist/spawn-config.js +4 -4
- package/dist/spawn.js +77 -11
- package/dist/tab-args.js +141 -0
- package/dist/tab-compose.js +233 -0
- package/dist/tab-launch.js +169 -0
- package/dist/tab-wrapper.js +44 -0
- package/dist/tasks.js +9 -22
- package/dist/ui/doc-pane.js +6 -2
- package/dist/ui/inbox-pane.js +7 -2
- package/dist/windows-terminal.js +153 -0
- package/dist/wrap-scan.js +2 -2
- package/package.json +3 -2
- package/scripts/deck-run +11 -678
- package/scripts/prompt-catalog +6 -11
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// T176 — la COMPOSIZIONE di una tab di conversazione: dai quattro assi di
|
|
2
|
+
// `deck-run` (`tab-args.ts`) a titolo, flag di `claude`, env di sessione e
|
|
3
|
+
// comando in-tab.
|
|
4
|
+
//
|
|
5
|
+
// Un composer solo per Linux e Windows (D1 preflight). Cambia per piattaforma
|
|
6
|
+
// solo CHI riceve il risultato: su Linux una riga per `bash -lc` dentro una tab
|
|
7
|
+
// Ptyxis, su Windows l'argv di `claude` in una variabile d'ambiente letta dal
|
|
8
|
+
// wrapper della tab (`windows-terminal.ts`). Titolo, modello, permission mode e
|
|
9
|
+
// prompt si risolvono qui una volta, per entrambe.
|
|
10
|
+
//
|
|
11
|
+
// Modulo PURO: il file di progetto, il catalogo dei prompt e l'env arrivano già
|
|
12
|
+
// letti. Le regole e i messaggi sono quelli dello script bash che questo modulo
|
|
13
|
+
// ha sostituito, e il gate di parità su Linux (`test/deck-run.test.ts`) li
|
|
14
|
+
// misura sul comando in-tab.
|
|
15
|
+
import { saneNote } from './sane-note.js';
|
|
16
|
+
import { MODEL_DEFAULT, MODELS } from './spawn-catalog.js';
|
|
17
|
+
import { shellQuote } from './spawn.js';
|
|
18
|
+
export const PERMISSION_MODES = ['acceptEdits', 'auto', 'bypassPermissions', 'manual', 'dontAsk', 'plan'];
|
|
19
|
+
/** L'alfabeto della chiave di stato: il valore entra in `bash -lc "…"` e il
|
|
20
|
+
* file di progetto è editabile a mano, quindi si scarta invece di quotare. */
|
|
21
|
+
const STATE_KEY_RE = /^[A-Za-z0-9_-]+$/;
|
|
22
|
+
/**
|
|
23
|
+
* Un campo del file di progetto come lo leggeva `jq -r '.campo // empty'`:
|
|
24
|
+
* `null`, `false` o assente danno vuoto, una stringa passa com'è, ogni altro
|
|
25
|
+
* valore diventa il suo JSON (`jq -r` stampa così numeri e oggetti).
|
|
26
|
+
*/
|
|
27
|
+
function configField(config, key) {
|
|
28
|
+
if (!config || typeof config !== 'object' || Array.isArray(config))
|
|
29
|
+
return '';
|
|
30
|
+
const v = config[key];
|
|
31
|
+
if (v === undefined || v === null || v === false)
|
|
32
|
+
return '';
|
|
33
|
+
return typeof v === 'string' ? v : JSON.stringify(v);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* La label del progetto, `{emoji} {name}`: la chiave con cui compass riconosce
|
|
37
|
+
* la finestra cercando nel titolo della tab attiva. Vuota se il file manca o
|
|
38
|
+
* non porta entrambi i campi, e il titolo ricade su `cc`.
|
|
39
|
+
*/
|
|
40
|
+
export function projectLabel(config) {
|
|
41
|
+
const emoji = configField(config, 'emoji');
|
|
42
|
+
const name = configField(config, 'name');
|
|
43
|
+
return emoji && name ? `${emoji} ${name}` : '';
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Il titolo della tab: label + `· <task>` + nota + `· fork`.
|
|
47
|
+
*
|
|
48
|
+
* La label sta in TESTA perché il match di compass è un `.includes` sulla
|
|
49
|
+
* chiave, e tutto il resto va in coda per non spezzarla. Il suffisso task
|
|
50
|
+
* distingue le tab di più task, la nota (T64) quelle di più conversazioni sulla
|
|
51
|
+
* stessa task, `· fork` (T28) un ramo dalla sua origine. La nota è l'unico testo
|
|
52
|
+
* libero del titolo: non si quota, si RIDUCE (`saneNote`) — e ridotta a vuoto
|
|
53
|
+
* non lascia nessun suffisso.
|
|
54
|
+
*/
|
|
55
|
+
export function tabTitle(req, label) {
|
|
56
|
+
let title;
|
|
57
|
+
if (req.noTask)
|
|
58
|
+
title = label || 'cc';
|
|
59
|
+
else
|
|
60
|
+
title = label ? `${label} · ${req.task}` : `cc ${req.task}`;
|
|
61
|
+
if (req.titleNote) {
|
|
62
|
+
const note = saneNote(req.titleNote);
|
|
63
|
+
if (note)
|
|
64
|
+
title = `${title} ${note}`;
|
|
65
|
+
}
|
|
66
|
+
if (req.fork)
|
|
67
|
+
title = `${title} · fork`;
|
|
68
|
+
return title;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Il permission mode: env `LOOM_DECK_PERMISSION_MODE` > campo del file > `manual`.
|
|
72
|
+
*
|
|
73
|
+
* Passato SEMPRE al CLI, anche `manual`: lo spawn resta deterministico invece
|
|
74
|
+
* di dipendere da un default che cambia fra versioni. Un valore fuori elenco
|
|
75
|
+
* non arriva al CLI — produrrebbe una tab con un comando rotto — e degrada a
|
|
76
|
+
* `manual` con un avviso.
|
|
77
|
+
*/
|
|
78
|
+
function permissionMode(ctx, warnings) {
|
|
79
|
+
const raw = ctx.env.LOOM_DECK_PERMISSION_MODE || configField(ctx.config, 'permissionMode');
|
|
80
|
+
if (!raw)
|
|
81
|
+
return 'manual';
|
|
82
|
+
if (PERMISSION_MODES.includes(raw))
|
|
83
|
+
return raw;
|
|
84
|
+
warnings.push(`permissionMode ignoto: '${raw}' → fallback 'manual'`);
|
|
85
|
+
return 'manual';
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Il kind di catalogo effettivo: solo sul ramo bound senza prompt letterale.
|
|
89
|
+
* `--no-task` e `--prompt` non hanno una riga di catalogo da consultare, né per
|
|
90
|
+
* il prompt né per il modello.
|
|
91
|
+
*/
|
|
92
|
+
function catalogKind(req) {
|
|
93
|
+
return !req.promptGiven && !req.noTask ? req.promptKind || 'recap' : '';
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Il modello: `--model` > env `LOOM_DECK_MODEL` > modello del kind nel catalogo
|
|
97
|
+
* > `MODEL_DEFAULT`.
|
|
98
|
+
*
|
|
99
|
+
* Il deck TUI passa SEMPRE `--model` già risolto (T161), quindi dal deck la
|
|
100
|
+
* cascata si ferma al primo gradino: i tre sotto servono a chi invoca a mano.
|
|
101
|
+
* Le voci restano ALIAS fino dentro `claude --model`: un id versionato cablato
|
|
102
|
+
* qui diventerebbe un modello inesistente al primo cambio di generazione. Un
|
|
103
|
+
* valore ignoto degrada al default con un avviso — arriverebbe al CLI, che lo
|
|
104
|
+
* rifiuterebbe all'avvio.
|
|
105
|
+
*/
|
|
106
|
+
function modelOf(req, ctx, warnings) {
|
|
107
|
+
let model = req.model || ctx.env.LOOM_DECK_MODEL || '';
|
|
108
|
+
const kind = catalogKind(req);
|
|
109
|
+
if (!model && kind && kind !== 'none')
|
|
110
|
+
model = ctx.catalog.get(kind)?.model ?? '';
|
|
111
|
+
if (!model)
|
|
112
|
+
return MODEL_DEFAULT;
|
|
113
|
+
if (MODELS.includes(model))
|
|
114
|
+
return model;
|
|
115
|
+
warnings.push(`modello ignoto: '${model}' → fallback '${MODEL_DEFAULT}' (usa ${MODELS.join('|')})`);
|
|
116
|
+
return MODEL_DEFAULT;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Il prompt iniziale, vuoto = nessun prompt.
|
|
120
|
+
*
|
|
121
|
+
* `--prompt` vince su tutto ed è usato così com'è. Una sessione nuda senza
|
|
122
|
+
* `--prompt` non ha prompt: il default `recap` del catalogo non si applica,
|
|
123
|
+
* perché ogni template interpola `{TASK}` e con la task vuota sarebbe un
|
|
124
|
+
* comando monco. Sul ramo bound il kind sceglie il template; un catalogo che
|
|
125
|
+
* non ha la voce è un'installazione mutila, e la sessione parte senza prompt
|
|
126
|
+
* dicendolo. `LOOM_DECK_ENTER_PROMPT` vince sul kind, tranne su `none`.
|
|
127
|
+
*/
|
|
128
|
+
function promptOf(req, ctx, warnings) {
|
|
129
|
+
if (req.promptGiven)
|
|
130
|
+
return req.promptText;
|
|
131
|
+
if (req.noTask)
|
|
132
|
+
return '';
|
|
133
|
+
const kind = catalogKind(req);
|
|
134
|
+
if (kind === 'none')
|
|
135
|
+
return '';
|
|
136
|
+
const enter = ctx.env.LOOM_DECK_ENTER_PROMPT;
|
|
137
|
+
const template = ctx.catalog.get(kind)?.template;
|
|
138
|
+
if (template === undefined) {
|
|
139
|
+
warnings.push(`catalogo prompt non leggibile ('${ctx.catalogPath}'): sessione senza prompt iniziale`);
|
|
140
|
+
}
|
|
141
|
+
const text = enter || template || '';
|
|
142
|
+
return text.replaceAll('{TASK}', req.task);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* La chiave di stato per compass, solo su Linux: env `LOOM_DECK_STATE_PROFILE`
|
|
146
|
+
* (anche vuota = nessun annuncio) > `id` del file di progetto.
|
|
147
|
+
*
|
|
148
|
+
* Lo stato di una sessione viaggia verso compass via D-Bus keyed su
|
|
149
|
+
* `PTYXIS_PROFILE`, e compass lo associa a un progetto solo se la chiave è l'`id`
|
|
150
|
+
* di un progetto del registry. Senza la variabile forzata in-tab, Ptyxis ne
|
|
151
|
+
* esporterebbe una col valore del profilo di default, e il badge resterebbe
|
|
152
|
+
* fermo senza nessun errore. Il nome della variabile è storico: non contiene
|
|
153
|
+
* l'UUID di un profilo. Su Windows compass non esiste, e la chiave non serve.
|
|
154
|
+
*/
|
|
155
|
+
function stateKey(ctx, warnings) {
|
|
156
|
+
if (ctx.platform === 'win32')
|
|
157
|
+
return '';
|
|
158
|
+
const raw = ctx.env.LOOM_DECK_STATE_PROFILE !== undefined
|
|
159
|
+
? ctx.env.LOOM_DECK_STATE_PROFILE
|
|
160
|
+
: configField(ctx.config, 'id');
|
|
161
|
+
if (raw && !STATE_KEY_RE.test(raw)) {
|
|
162
|
+
warnings.push(`profilo di stato ignorato (caratteri non ammessi): '${raw}'`);
|
|
163
|
+
return '';
|
|
164
|
+
}
|
|
165
|
+
return raw;
|
|
166
|
+
}
|
|
167
|
+
/** Un testo come UN argomento di una shell POSIX: sempre fra apici singoli, con
|
|
168
|
+
* l'apice stesso chiuso, escapato e riaperto (`'\''`). Dentro gli apici nessun
|
|
169
|
+
* altro carattere resta speciale, newline compresi. */
|
|
170
|
+
export function posixQuote(text) {
|
|
171
|
+
return `'${text.replace(/'/g, `'\\''`)}'`;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* La composizione intera.
|
|
175
|
+
*
|
|
176
|
+
* Ordine degli argomenti di `claude`: nome, permission mode e modello prima dei
|
|
177
|
+
* flag di continuità, il prompt sempre ultimo. Il modello sta accanto al
|
|
178
|
+
* permission mode perché sono la stessa cosa — la scelta di chi apre la
|
|
179
|
+
* sessione — e non una proprietà della task né della ripresa.
|
|
180
|
+
*
|
|
181
|
+
* Su una ripresa il prompt salta anche sul ramo bound: riprendere significa
|
|
182
|
+
* continuare la conversazione, non iniettarle un messaggio nuovo; il fork è una
|
|
183
|
+
* ripresa con id nuovo e ricade nello stesso ramo. Il `--session-id` vale invece
|
|
184
|
+
* anche sul ramo nudo: nel fork di una sessione spot rende noto in anticipo
|
|
185
|
+
* l'id del ramo, senza il quale il lineage non sarebbe registrabile.
|
|
186
|
+
*/
|
|
187
|
+
export function composeTab(req, ctx) {
|
|
188
|
+
const warnings = [];
|
|
189
|
+
const title = tabTitle(req, projectLabel(ctx.config));
|
|
190
|
+
const mode = permissionMode(ctx, warnings);
|
|
191
|
+
const model = modelOf(req, ctx, warnings);
|
|
192
|
+
const key = stateKey(ctx, warnings);
|
|
193
|
+
const prompt = promptOf(req, ctx, warnings);
|
|
194
|
+
const withPrompt = prompt !== '' && !req.resumeId;
|
|
195
|
+
const flags = ['--permission-mode', mode, '--model', model];
|
|
196
|
+
if (req.sessionId)
|
|
197
|
+
flags.push('--session-id', req.sessionId);
|
|
198
|
+
if (req.resumeId)
|
|
199
|
+
flags.push('--resume', req.resumeId);
|
|
200
|
+
if (req.fork)
|
|
201
|
+
flags.push('--fork-session');
|
|
202
|
+
const sessionEnv = {};
|
|
203
|
+
if (key)
|
|
204
|
+
sessionEnv.PTYXIS_PROFILE = key;
|
|
205
|
+
if (!req.noTask)
|
|
206
|
+
sessionEnv.LOOM_TASK = req.task;
|
|
207
|
+
// La riga in-tab nella grafia storica dello script: ogni flag seguito da uno
|
|
208
|
+
// spazio, il prompt in coda fra apici. Il gate di parità su Linux confronta
|
|
209
|
+
// questa riga byte per byte, spazio finale compreso quando il prompt manca.
|
|
210
|
+
const envPrefix = Object.entries(sessionEnv)
|
|
211
|
+
.map(([k, v]) => `${k}=${shellQuote(v)} `)
|
|
212
|
+
.join('');
|
|
213
|
+
const flagText = flags.map((f) => `${f} `).join('');
|
|
214
|
+
const inTab = `${envPrefix}claude --name ${posixQuote(title)} ${flagText}` +
|
|
215
|
+
(withPrompt ? posixQuote(prompt) : '');
|
|
216
|
+
return {
|
|
217
|
+
title,
|
|
218
|
+
claudeArgs: ['--name', title, ...flags, ...(withPrompt ? [prompt] : [])],
|
|
219
|
+
sessionEnv,
|
|
220
|
+
inTab,
|
|
221
|
+
warnings,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* L'argv di Ptyxis: una tab nella finestra attiva (quella col focus, cioè il
|
|
226
|
+
* deck), o una finestra nuova con `--new-window` (T162) per chi chiama da fuori
|
|
227
|
+
* dalla finestra del progetto. Il comando gira dentro `bash -lc`, e le env di
|
|
228
|
+
* sessione viaggiano DENTRO il comando: `ptyxis --tab` consegna la tab
|
|
229
|
+
* all'istanza già aperta, che non eredita l'env di chi la chiede.
|
|
230
|
+
*/
|
|
231
|
+
export function ptyxisArgv(newWindow, workdir, title, inTab) {
|
|
232
|
+
return [newWindow ? '--new-window' : '--tab', '-d', workdir, '-T', title, '--', 'bash', '-lc', inTab];
|
|
233
|
+
}
|
|
@@ -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
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
32
|
-
export function resolveTasksDir(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.
|
|
@@ -142,7 +129,7 @@ export function loadTasks(path) {
|
|
|
142
129
|
* sciolti in spazi, cioè il buco `{slug}` dei template di titolo.
|
|
143
130
|
*
|
|
144
131
|
* Viene dal NOME del file e non dalla descrizione in `tasks.md` per una ragione
|
|
145
|
-
* di alfabeto: il nome è già dentro quello di `
|
|
132
|
+
* di alfabeto: il nome è già dentro quello di `saneNote` per costruzione —
|
|
146
133
|
* minuscolo, separato da trattini, senza punteggiatura — mentre la descrizione
|
|
147
134
|
* porta apostrofi e `/`, che la riduzione toglie senza sostituto saldando le
|
|
148
135
|
* parole (`l'epica/doc` → `lepicadoc`).
|
package/dist/ui/doc-pane.js
CHANGED
|
@@ -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 }),
|
|
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
|
package/dist/ui/inbox-pane.js
CHANGED
|
@@ -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 }),
|
|
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 è
|