@lamemind/loom-deck 0.68.0 → 0.70.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.
@@ -107,11 +107,28 @@ export function childSessionsOf(sessions, bindings, selectedTaskId, isAll) {
107
107
  }
108
108
  export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDefault, setNote, }) {
109
109
  const { tasks, loadError } = useTasks(tasksPath);
110
+ // Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
111
+ const launch = useMemo(() => loadLaunch(cwd), [cwd]);
112
+ // Identità (T37): titolo delle tab terminale spawnate col tasto `t`.
113
+ const identity = useMemo(() => loadIdentity(cwd), [cwd]);
114
+ // T53 — il core che ogni titolo di tab porta, quindi la colonna costante da
115
+ // togliere quando serve spazio. Hoistato perché lo consumano DUE schermate
116
+ // (lista e ricerca): calcolarlo su ogni call site è il modo in cui le due
117
+ // smettono di togliere la stessa cosa.
118
+ // T58 — il core è il solo `name`, non `<emoji> <name>` come la chiave di match
119
+ // di compass: qui non serve entropia (è un taglio cosmetico, non un matcher) e
120
+ // il nome nudo ripulisce anche i titoli storici, scritti quando la formula
121
+ // includeva l'owner.
122
+ // T162 — sta SOPRA `useSessions` perché ora è anche un suo ingresso: il deck
123
+ // scrive nel sidecar il residuo del titolo, e quel residuo si ottiene
124
+ // togliendo questo stesso core (§Riempimento dei buchi in `hooks.ts`).
125
+ const projectCore = identity ? identity.name : null;
126
+ const projectName = cwd.split('/').pop() || cwd;
110
127
  // `notes` esce dall'indice come `sessionNotes`: nel deck `note` è già la riga
111
128
  // di STATO in fondo al frame (il feedback di un'azione). Due concetti diversi
112
129
  // a una lettera di distanza sarebbero una trappola di lettura — e di
113
130
  // scrittura, visto che `setNote` compare in quasi ogni ramo.
114
- const { sessions, bindings, forkOf, pinned, notes: sessionNotes, priority: sessionPriority, live, reload: reloadSessions, } = useSessions(cwd);
131
+ const { sessions, bindings, forkOf, pinned, notes: sessionNotes, priority: sessionPriority, live, reload: reloadSessions, } = useSessions(cwd, projectCore);
115
132
  const [focus, setFocus] = useState('tasks');
116
133
  // T39 — selezione KEYED SU ID, non su indice. Con una vista trasformata
117
134
  // (filtro/sort) l'indice non identifica più la stessa task: leggere l'array
@@ -158,20 +175,6 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
158
175
  // sbagliata in silenzio (stessa trappola di T39 sulle task e T50 sulle
159
176
  // conversazioni).
160
177
  const [selInboxPath, setSelInboxPath] = useState(null);
161
- // Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
162
- const launch = useMemo(() => loadLaunch(cwd), [cwd]);
163
- // Identità (T37): titolo delle tab terminale spawnate col tasto `t`.
164
- const identity = useMemo(() => loadIdentity(cwd), [cwd]);
165
- // T53 — il core che ogni titolo di tab porta, quindi la colonna costante da
166
- // togliere quando serve spazio. Hoistato perché lo consumano DUE schermate
167
- // (lista e ricerca): calcolarlo su ogni call site è il modo in cui le due
168
- // smettono di togliere la stessa cosa.
169
- // T58 — il core è il solo `name`, non `<emoji> <name>` come la chiave di match
170
- // di compass: qui non serve entropia (è un taglio cosmetico, non un matcher) e
171
- // il nome nudo ripulisce anche i titoli storici, scritti quando la formula
172
- // includeva l'owner.
173
- const projectCore = identity ? identity.name : null;
174
- const projectName = cwd.split('/').pop() || cwd;
175
178
  // T136 — data dell'ultimo commit di ogni task file, per la chiave `commit`
176
179
  // della chain di sort.
177
180
  const commitAt = useCommitTimes(tasksDir, cwd);
package/dist/hooks.js CHANGED
@@ -9,6 +9,7 @@ import { loadTasks, loadTaskDetail } from './tasks.js';
9
9
  import { discoverProjectSessions } from './sessions.js';
10
10
  import { discoverLiveSessions, liveSig } from './live-sessions.js';
11
11
  import { loadSessionIndex } from './task-index.js';
12
+ import { fillSidecarGaps } from './session-meta.js';
12
13
  import { archivableIds, SCAN_INTERVAL_MS } from './archivable.js';
13
14
  import { commitTimes } from './commit-times.js';
14
15
  import { scanEpicHierarchy, EMPTY_EPIC_HIERARCHY } from './epic-hierarchy.js';
@@ -137,7 +138,15 @@ export function useEpicHierarchy(tasksDir) {
137
138
  // ha cache mtime-keyed interna → il poll è economico; qui si evita comunque il
138
139
  // re-render inutile con una signature (sessionId:ts + binding entries): setState
139
140
  // solo quando cambia davvero qualcosa.
140
- export function useSessions(projectRoot) {
141
+ //
142
+ // T162 — è anche il posto dove il deck RIEMPIE I BUCHI dei campi derivati del
143
+ // sidecar (`title`, `model`): qui le due fonti sono già in mano nello stesso
144
+ // giro, la corrente (le `Session`) e quella su disco (l'indice). Il `core` del
145
+ // progetto arriva per argomento e non si rilegge qui: la stessa derivazione
146
+ // vive già nel modello (`projectCore`), e una seconda copia toglierebbe dal
147
+ // titolo scritto nel sidecar una stringa diversa da quella che la lista toglie
148
+ // dalle proprie righe.
149
+ export function useSessions(projectRoot, core) {
141
150
  const [state, setState] = useState({
142
151
  sessions: [],
143
152
  bindings: new Map(),
@@ -168,6 +177,8 @@ export function useSessions(projectRoot) {
168
177
  pinned: new Map(),
169
178
  notes: new Map(),
170
179
  priority: new Set(),
180
+ titles: new Map(),
181
+ models: new Map(),
171
182
  };
172
183
  }
173
184
  // T62 — le vive stanno sullo STESSO tick delle altre fonti, non su una
@@ -183,6 +194,33 @@ export function useSessions(projectRoot) {
183
194
  live = new Map();
184
195
  }
185
196
  const { bindings, forkOf, pinned, notes, priority } = index;
197
+ // T162 — i campi derivati delle PINNATE, allineati prima della signature.
198
+ //
199
+ // Gira a ogni giro e non solo al cambio di stato, perché il buco può
200
+ // nascere senza che nulla nel deck si muova: a pinnare può essere compass,
201
+ // dalla modale sulla conversazione in focus, e compass il titolo non lo
202
+ // conosce. Il deck è l'unico che ce l'ha, quindi è l'unico che può
203
+ // chiudere quel buco — e lo fa passando, non al momento del gesto.
204
+ //
205
+ // Costa solo quando c'è da scrivere: senza pinnate `sidecarGaps` esce
206
+ // subito, e con un sidecar già allineato ritorna una lista vuota (è
207
+ // l'invariante di convergenza del modulo — vedi `session-meta.ts`).
208
+ //
209
+ // Fuori dal gate della signature di proposito: un sidecar temporaneamente
210
+ // non scrivibile deve poter essere ritentato al giro dopo, e con lo stato
211
+ // invariato la signature non cambierebbe mai più.
212
+ //
213
+ // Gli append NON entrano nella signature e non producono un re-render: il
214
+ // deck il titolo lo ha dalla `Session`, che è la fonte. Questi campi
215
+ // esistono per un lettore che non può aprire un transcript, cioè compass.
216
+ fillSidecarGaps(projectRoot, {
217
+ pinned,
218
+ sessions,
219
+ bindings,
220
+ titles: index.titles,
221
+ models: index.models,
222
+ core,
223
+ });
186
224
  // La signature copre anche fork, pin, note e marca di priorità: un record
187
225
  // di lineage, un toggle di pin, una nota appena scritta o un 🚨 acceso
188
226
  // cambiano la lista renderizzata, quindi devono forzare il re-render come
@@ -209,7 +247,7 @@ export function useSessions(projectRoot) {
209
247
  reload();
210
248
  const id = setInterval(reload, POLL_MS);
211
249
  return () => clearInterval(id);
212
- }, [projectRoot]);
250
+ }, [projectRoot, core]);
213
251
  return { ...state, reload: () => reloadRef.current() };
214
252
  }
215
253
  // T61 — conteggio delle Done oltre soglia, su una scala di refresh TUTTA SUA.
@@ -0,0 +1,126 @@
1
+ // T162 — i campi DERIVATI del sidecar: cosa scrivere in `title`/`model` e
2
+ // quando scriverlo.
3
+ //
4
+ // Esiste perché compass ha bisogno del titolo di una conversazione e non può
5
+ // leggerne il transcript: l'estensione gira dentro il processo di gnome-shell,
6
+ // e una lettura sincrona di qualche megabyte blocca il compositore. Il dato
7
+ // deve quindi arrivargli da un file piccolo che già apre — il sidecar — messo
8
+ // lì da chi ce l'ha in mano, cioè il deck, che a ogni tick materializza le
9
+ // `Session` complete di titolo e modello.
10
+ //
11
+ // Il giudizio è separato dalla scrittura: `sidecarGaps` decide e ritorna i
12
+ // record, `fillSidecarGaps` li scrive. La separazione è ciò che rende testabile
13
+ // la parte non ovvia — la convergenza: un derivato che non combacia mai col
14
+ // valore su disco produrrebbe un append a ogni tick del poll, per sempre, senza
15
+ // nessun errore a dirlo.
16
+ //
17
+ // Nessun import da ink/react: il chiamante è un hook, questo modulo no.
18
+ import { modelAlias } from './glyphs.js';
19
+ import { stripProjectCore, stripTaskId } from './session-list.js';
20
+ import { NO_TITLE } from './sessions.js';
21
+ import { NO_SPAWN } from './spawn.js';
22
+ import { appendSessionMeta } from './task-index.js';
23
+ /**
24
+ * Cap del titolo scritto nel sidecar, in CODE POINT.
25
+ *
26
+ * È il `LABEL_MAX` del lettore (compass): oltre, Pango ellissizza comunque, e
27
+ * il file non deve portare una stringa che nessuno mostrerà per intero. Misura
28
+ * del preflight: col primo prompt come titolo il massimo osservato era 1.367
29
+ * caratteri, 36 conversazioni su 604 sopra 120, p90 a 61 — il cap morde su una
30
+ * minoranza e non tocca il caso normale.
31
+ *
32
+ * In code point e non in code unit perché il taglio va fatto senza spezzare una
33
+ * coppia surrogata: un'emoji tagliata a metà lascia nel file un surrogato
34
+ * spaiato, cioè un glifo rotto nel menu.
35
+ */
36
+ export const SIDECAR_TITLE_MAX = 120;
37
+ function capTitle(s) {
38
+ const cps = [...s];
39
+ return cps.length <= SIDECAR_TITLE_MAX ? s : cps.slice(0, SIDECAR_TITLE_MAX).join('');
40
+ }
41
+ /**
42
+ * Il valore da scrivere in `title`: il RESIDUO del titolo, non il titolo
43
+ * grezzo. `''` = non c'è niente da scrivere.
44
+ *
45
+ * I due casi non si trattano allo stesso modo, ed è il punto di questa
46
+ * funzione:
47
+ *
48
+ * - **con titolo custom** → il titolo di una conversazione nata dal deck È la
49
+ * label della tab (`🧵 loom-works · T32 revisione`), composta di tre pezzi
50
+ * che il lettore ha già altrove: il progetto (la riga sotto cui la
51
+ * conversazione compare), il task id (il campo `taskId` dello stesso
52
+ * record) e la nota (il campo `note`). Copiare il grezzo obbligherebbe
53
+ * compass a una seconda copia di `stripTaskId`, e a schermo ripeterebbe tre
54
+ * volte quello che già si legge.
55
+ * - **senza titolo custom** → `title` È il primo prompt, e vale intatto:
56
+ * quella è l'unica cosa che dice di cosa si parlava.
57
+ *
58
+ * Non è `sessionTitle`: quella, con residuo vuoto, cade sul primo prompt —
59
+ * dentro il deck è giusto, perché una riga muta non serve a nessuno, ma qui il
60
+ * primo prompt di una sessione nata dal deck è l'invocazione di una skill
61
+ * (`/loom-works:recap-status-task T32`), che nel menu non aggiunge nulla. Qui
62
+ * il residuo vuoto è ASSENZA: non si scrive, e chi legge cade sulla nota.
63
+ */
64
+ export function sidecarTitle(s, core, taskId) {
65
+ if (!s.customTitle)
66
+ return s.title === NO_TITLE ? '' : capTitle(s.title);
67
+ return capTitle(stripTaskId(stripProjectCore(s.title, core), taskId));
68
+ }
69
+ export function sidecarGaps(input) {
70
+ const { pinned, sessions, bindings, titles, models, core } = input;
71
+ if (pinned.size === 0)
72
+ return [];
73
+ const byId = new Map(sessions.map((s) => [s.sessionId, s]));
74
+ const out = [];
75
+ for (const sessionId of pinned.keys()) {
76
+ const session = byId.get(sessionId);
77
+ if (!session)
78
+ continue; // pinnata stale: niente da cui derivare
79
+ const gap = { sessionId };
80
+ const title = sidecarTitle(session, core, bindings.get(sessionId) ?? null);
81
+ if (title && titles.get(sessionId) !== title)
82
+ gap.title = title;
83
+ const model = modelAlias(session.model);
84
+ if (model && models.get(sessionId) !== model)
85
+ gap.model = model;
86
+ if (gap.title !== undefined || gap.model !== undefined)
87
+ out.push(gap);
88
+ }
89
+ return out;
90
+ }
91
+ /**
92
+ * Scrive i buchi di questo giro. Ritorna quanti record ha appeso — zero è
93
+ * l'esito normale a regime, non un fallimento.
94
+ *
95
+ * `NO_SPAWN` frena anche questa scrittura, ed è lo stesso freno delle tab
96
+ * Ptyxis per la stessa ragione: il gate su pseudo-terminale avvia il DECK VERO
97
+ * con cwd la project root del cappello, quindi senza freno ogni run della suite
98
+ * appende record nel sidecar reale di chi la lancia. Il valore scritto sarebbe
99
+ * pure corretto — è quello che il deck scriverebbe comunque — ma è una
100
+ * scrittura fuori dalla sandbox del test, che è precisamente ciò che il freno
101
+ * esiste per impedire.
102
+ *
103
+ * Il freno sta QUI e non nel chiamante perché questa è l'unica sede della
104
+ * scrittura: nel chiamante sarebbe una guardia che il prossimo scrittore non
105
+ * vede.
106
+ *
107
+ * Un sidecar non scrivibile non è un errore da mostrare: il giro dopo riprova,
108
+ * e nel frattempo il deck funziona identico — questi campi li legge un altro
109
+ * processo, non lui.
110
+ */
111
+ export function fillSidecarGaps(projectRoot, input) {
112
+ if (NO_SPAWN)
113
+ return 0;
114
+ let scritti = 0;
115
+ try {
116
+ for (const gap of sidecarGaps(input)) {
117
+ appendSessionMeta(projectRoot, gap.sessionId, gap);
118
+ scritti++;
119
+ }
120
+ }
121
+ catch {
122
+ // sidecar non scrivibile: si riprova al giro dopo, niente a schermo — è
123
+ // manutenzione di un campo di servizio, non un'azione che l'utente ha chiesto.
124
+ }
125
+ return scritti;
126
+ }
package/dist/sessions.js CHANGED
@@ -54,6 +54,12 @@ const INTERRUPT_MARKER = '[Request interrupted by user';
54
54
  function isInterrupt(text) {
55
55
  return text.trimStart().startsWith(INTERRUPT_MARKER);
56
56
  }
57
+ /** Titolo di una conversazione che non ne ha nessuno — né custom né un primo
58
+ * prompt da cui derivarlo. È un SEGNAPOSTO di resa, non un titolo: chi copia
59
+ * il titolo altrove (T162, il sidecar per compass) deve poterlo riconoscere e
60
+ * trattarlo come assenza, invece di propagare la stringa. Esportato per quello
61
+ * — una seconda copia del letterale divergerebbe al primo ritocco del testo. */
62
+ export const NO_TITLE = '(senza titolo)';
57
63
  // T110 — valore che il CLI scrive nel campo modello dei record che fabbrica da
58
64
  // sé (osservato su `{"type":"text","text":"No response requested."}`). Restano
59
65
  // `type:assistant` a tutti gli effetti: chi si fida del campo etichetta la
@@ -248,7 +254,7 @@ export function parseTranscript(content, path, mtime, sizeBytes) {
248
254
  cwd,
249
255
  gitBranch,
250
256
  parentUuid,
251
- title: sanitize(customTitle || firstUserText || '(senza titolo)'),
257
+ title: sanitize(customTitle || firstUserText || NO_TITLE),
252
258
  ts: mtime,
253
259
  path,
254
260
  sizeBytes,
@@ -37,6 +37,31 @@ export function appendNote(projectRoot, sessionId, note) {
37
37
  export function appendPriority(projectRoot, sessionId, priority) {
38
38
  appendSessionRecord(projectRoot, { sessionId, priority });
39
39
  }
40
+ /**
41
+ * T162 — scrive i campi DERIVATI (`title`, `model`) di una conversazione.
42
+ *
43
+ * I due vanno in UN record e non in due, anche se il lettore risolve last-wins
44
+ * per campo e due append darebbero lo stesso esito finale: due record lasciano
45
+ * un istante in cui su disco lo stato è a metà, e in quel mezzo ci sta il
46
+ * lettore di compass — che mostrerebbe il titolo nuovo e riprenderebbe col
47
+ * modello vecchio. È la stessa regola di `writeSessionMarks` lato compass.
48
+ *
49
+ * Il record porta SOLO i campi passati: uno che portasse anche l'altro col
50
+ * valore che aveva prima lo riscriverebbe, e il last-wins trasformerebbe quella
51
+ * riscrittura in una sovrascrittura di ciò che un altro scrittore ha messo nel
52
+ * frattempo. Un insieme vuoto non scrive niente, invece di appendere un record
53
+ * col solo `sessionId` che nessun lettore userebbe.
54
+ */
55
+ export function appendSessionMeta(projectRoot, sessionId, fields) {
56
+ const rec = { sessionId };
57
+ if (fields.title !== undefined)
58
+ rec.title = fields.title;
59
+ if (fields.model !== undefined)
60
+ rec.model = fields.model;
61
+ if (rec.title === undefined && rec.model === undefined)
62
+ return;
63
+ appendSessionRecord(projectRoot, rec);
64
+ }
40
65
  // Una sola lettura del JSONL per entrambe le mappe: il deck poll-a l'indice a
41
66
  // ogni tick, leggere il file due volte raddoppierebbe l'I/O per nulla.
42
67
  // Last-wins per campo (un re-pin dello stesso sessionId sovrascrive), e i due
@@ -48,12 +73,14 @@ export function loadSessionIndex(projectRoot) {
48
73
  const pinned = new Map();
49
74
  const notes = new Map();
50
75
  const priority = new Set();
76
+ const titles = new Map();
77
+ const models = new Map();
51
78
  let content;
52
79
  try {
53
80
  content = readFileSync(taskIndexPath(projectRoot), 'utf8');
54
81
  }
55
82
  catch {
56
- return { bindings, forkOf, pinned, notes, priority };
83
+ return { bindings, forkOf, pinned, notes, priority, titles, models };
57
84
  }
58
85
  let order = 0; // posizione crescente dei record pinned → rango di pin (D2)
59
86
  for (const line of content.split('\n')) {
@@ -108,10 +135,32 @@ export function loadSessionIndex(projectRoot) {
108
135
  else
109
136
  priority.delete(d.sessionId);
110
137
  }
138
+ // T162 — stesso last-wins per campo di `note`, con una differenza che
139
+ // conta: NESSUNA sanificazione in lettura. `note` la paga perché il deck
140
+ // la mette nel frame e il file è editabile a mano; qui il valore non
141
+ // entra nel frame (il deck il titolo lo ha dalla `Session`) e chi lo
142
+ // rende — compass, sotto Pango — non fa contabilità di colonne.
143
+ //
144
+ // Sanificare qui avrebbe un costo suo: il deck decide se riempire il
145
+ // buco CONFRONTANDO il valore letto con quello derivato, e un valore
146
+ // riscritto in lettura non combacerebbe mai col derivato — un append a
147
+ // ogni tick del poll, per sempre, senza nessun errore a dirlo.
148
+ if (typeof d.title === 'string') {
149
+ if (d.title)
150
+ titles.set(d.sessionId, d.title);
151
+ else
152
+ titles.delete(d.sessionId);
153
+ }
154
+ if (typeof d.model === 'string') {
155
+ if (d.model)
156
+ models.set(d.sessionId, d.model);
157
+ else
158
+ models.delete(d.sessionId);
159
+ }
111
160
  }
112
161
  catch {
113
162
  // riga corrotta → skip
114
163
  }
115
164
  }
116
- return { bindings, forkOf, pinned, notes, priority };
165
+ return { bindings, forkOf, pinned, notes, priority, titles, models };
117
166
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.68.0",
3
+ "version": "0.70.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": {
package/scripts/deck-run CHANGED
@@ -13,6 +13,8 @@
13
13
  # continuità nuova ........ --resume [--fork]
14
14
  # prompt --prompt-kind none|recap|preflight|run|checkpoint | --prompt <testo>
15
15
  # modello --model fable|opus|sonnet|haiku
16
+ # Fuori dai quattro, e volutamente non un asse: --new-window, che cambia SOLO il
17
+ # verbo dell'exec finale (tab nella finestra attiva → finestra nuova).
16
18
  # Il terzo asse (T56) prima non esisteva: il prompt era una CONSEGUENZA degli
17
19
  # altri due (bound ⇒ recap, --no-task ⇒ niente, --resume ⇒ niente), quindi
18
20
  # "task-bound SENZA prompt" era inesprimibile e l'unico modo di non avere un
@@ -78,6 +80,17 @@ SESSION_ID=""
78
80
  RESUME_ID=""
79
81
  NO_TASK=0
80
82
  FORK=0
83
+ # T162 — finestra NUOVA invece di una tab nella finestra attiva.
84
+ #
85
+ # `ptyxis --tab` va sempre nella finestra col focus, e va bene per il deck, che
86
+ # è già una tab della finestra del progetto. Un chiamante che vive FUORI da
87
+ # quella finestra — compass, che riapre una conversazione pinnata di un
88
+ # progetto chiuso — non ha una finestra attiva giusta: senza un verbo diverso
89
+ # la tab finisce nella finestra di un altro progetto, o in una a caso.
90
+ #
91
+ # Non è un quinto asse: non cambia cosa gira nella tab, solo dove la tab nasce.
92
+ # Vale identico nei due rami di spawn (inline e profile).
93
+ NEW_WINDOW=0
81
94
  TITLE_NOTE=""
82
95
  # Vuoto = flag non passato (≠ un default già risolto): serve a distinguere
83
96
  # "kind implicito" da "kind chiesto", perché con --no-task il primo è legittimo
@@ -157,6 +170,8 @@ while [[ $# -gt 0 ]]; do
157
170
  FORK=1; shift ;;
158
171
  --no-task)
159
172
  NO_TASK=1; shift ;;
173
+ --new-window)
174
+ NEW_WINDOW=1; shift ;;
160
175
  *)
161
176
  if [[ -z "$TASK" ]]; then TASK="$1"; shift
162
177
  else echo "argomento inatteso: '$1'" >&2; exit 2; fi ;;
@@ -190,7 +205,10 @@ USAGE="uso: deck-run <TaskID> [--model <alias>] [--prompt-kind <kind>] [--sessio
190
205
  valore ignoto → fallback sul default, con avviso su stderr
191
206
 
192
207
  --title-note nota della conversazione, appesa al titolo tab
193
- (ridotta a lettere/cifre/spazi/-/_, cap 60 char)"
208
+ (ridotta a lettere/cifre/spazi/-/_, cap 60 char)
209
+
210
+ --new-window apre una FINESTRA nuova invece di una tab nella finestra
211
+ attiva. Per chi chiama da fuori dalla finestra del progetto"
194
212
 
195
213
  if [[ $NO_TASK -eq 1 && -n "$TASK" ]]; then
196
214
  echo "--no-task e <TaskID> sono mutuamente esclusivi" >&2
@@ -669,19 +687,30 @@ IN_TAB_CMD="${LOOM_DECK_INTAB_CMD:-$_default_intab}"
669
687
  # vedono comunque quello che vedevano prima.
670
688
  printf 'LOOM_DECK_INTAB %s\n' "$IN_TAB_CMD"
671
689
 
690
+ # Verbo di apertura (T162): tab nella finestra attiva, o finestra nuova. Una
691
+ # variabile e non due rami duplicati per ogni modalità di spawn — `--new-window`
692
+ # è l'unica differenza fra i due casi.
693
+ if [[ $NEW_WINDOW -eq 1 ]]; then WIN_FLAG="--new-window"; else WIN_FLAG="--tab"; fi
694
+
672
695
  case "$MODE" in
673
696
  inline)
674
697
  # --tab: tab nella window attiva. -- <cmd>: comando inline nella tab.
675
698
  # LOOM_TASK viaggia dentro il comando → nessuna scrittura dconf.
676
- exec ptyxis --tab -d "$WORKDIR" -T "${TITLE}" -- bash -lc "$IN_TAB_CMD"
699
+ exec ptyxis "$WIN_FLAG" -d "$WORKDIR" -T "${TITLE}" -- bash -lc "$IN_TAB_CMD"
677
700
  ;;
678
701
  profile)
679
702
  # --tab-with-profile: riusa il profilo (custom-command FISSO) → per iniettare
680
703
  # LOOM_TASK+task va riscritto il custom-command al volo via dconf (stato condiviso).
704
+ # Con --new-window i due flag convivono: la finestra nasce e il profilo
705
+ # decide cosa gira nella sua prima tab.
681
706
  dpath="/org/gnome/Ptyxis/Profiles/${PROFILE_UUID}/"
682
707
  dconf write "${dpath}use-custom-command" true
683
708
  dconf write "${dpath}custom-command" "'bash -lc \"${IN_TAB_CMD//\"/\\\"}\"'"
684
- exec ptyxis --tab-with-profile="$PROFILE_UUID" -d "$WORKDIR" -T "${TITLE}"
709
+ if [[ $NEW_WINDOW -eq 1 ]]; then
710
+ exec ptyxis --new-window --tab-with-profile="$PROFILE_UUID" -d "$WORKDIR" -T "${TITLE}"
711
+ else
712
+ exec ptyxis --tab-with-profile="$PROFILE_UUID" -d "$WORKDIR" -T "${TITLE}"
713
+ fi
685
714
  ;;
686
715
  *)
687
716
  echo "LOOM_DECK_SPAWN_MODE ignoto: '$MODE' (usa 'inline' o 'profile')" >&2