@lamemind/loom-deck 0.34.1 → 0.34.2

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/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ import { initialDetail, writeTaskEdit, PRI_GLYPH, PRI_LABEL } from './task-edit.
16
16
  import { ALL, EDIT_PRI, EDIT_PROG, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, ROW_ALL, ROW_SPOT, SORT_TASTI, SPOT, } from './model.js';
17
17
  import { conversationLabel, cpLen, editField, insertAt, isDone, isTextRow, removeAt, EDIT_ROWS, } from './layout.js';
18
18
  import { TASK_EMPTY, relTime, sanitizeTyped } from './glyphs.js';
19
- import { commitTaskEdit, runLaunch, spawnClaudeEmpty, spawnCreateTask, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, CLAUDE_CMD, DECK_RUN, } from './spawn.js';
19
+ import { commitTaskEdit, runLaunch, spawnClaudeEmpty, spawnCreateTask, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, CLAUDE_CMD, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
20
20
  import { EditModal, FilterModal, SortModal } from './ui/modals.js';
21
21
  import { ReaderScreen, SearchScreen } from './ui/search-screen.js';
22
22
  import { DetailScreen } from './ui/detail-screen.js';
@@ -285,18 +285,22 @@ function Deck({ cwd, tasksPath, tasksDir }) {
285
285
  // Lo spawn vero, su una task GIÀ risolta. Separato dalla guardia perché il
286
286
  // detail passa l'id fotografato all'apertura e non la selezione corrente: la
287
287
  // lista lì sotto non è più a schermo, quindi non è più la fonte dell'oggetto.
288
- function spawnForTask(id, kind, keyLabel) {
288
+ function spawnForTask(id, kind, model, keyLabel) {
289
289
  const sid = randomUUID();
290
290
  appendTaskBinding(cwd, sid, id);
291
- const child = spawnDeck(id, cwd, sid, kind);
291
+ const child = spawnDeck(id, cwd, sid, kind, model);
292
292
  child.on('error', () => setNote(`⚠ spawn ${id} fallito (${DECK_RUN})`));
293
293
  const what = kind === 'none' ? '' : ` · ${kind}`;
294
- setNote(`${keyLabel} spawn ${id}${what} tab CC (sid ${sid.slice(0, 8)})`);
294
+ // Il modello è SEMPRE nominato, anche quando è il default: gli acceleratori
295
+ // della lista non passano dal selettore del detail e usano il default fisso
296
+ // (T108 · via a), quindi senza dirlo l'utente crederebbe di aver ereditato
297
+ // la scelta fatta nell'ultimo detail aperto.
298
+ setNote(`${keyLabel} spawn ${id}${what} · ${model} → tab CC (sid ${sid.slice(0, 8)})`);
295
299
  }
296
300
  function spawnTaskSession(kind, keyLabel) {
297
301
  const task = selectedTaskOr(keyLabel, 'spawnare');
298
302
  if (task)
299
- spawnForTask(task.id, kind, keyLabel);
303
+ spawnForTask(task.id, kind, MODEL_DEFAULT, keyLabel);
300
304
  }
301
305
  // T53 — apertura del modale nota sulla conversazione selezionata. Come
302
306
  // `openEdit`, la bozza parte dal valore ATTUALE: annotare una seconda volta è
@@ -486,7 +490,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
486
490
  columns,
487
491
  setMode,
488
492
  setNote,
489
- onAction: (id, kind, label) => spawnForTask(id, kind, label),
493
+ onAction: (id, kind, model, label) => spawnForTask(id, kind, model, label),
490
494
  });
491
495
  const search = useSearchOverlay({
492
496
  sessions,
@@ -1019,7 +1023,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1019
1023
  // posizione è lo scroll mosso a mano. Il clamp serve comunque — un resize
1020
1024
  // può accorciare il testo sotto uno scroll già dato.
1021
1025
  const start = Math.min(sheet.top, sheet.maxTop);
1022
- 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, columns: columns, find: sheet.find, occ: sheet.findRes.occ, occCur: sheet.occCur }));
1026
+ 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, columns: columns, find: sheet.find, occ: sheet.findRes.occ, occCur: sheet.occCur }));
1023
1027
  }
1024
1028
  // ── T52 · schermate sostitutive ─────────────────────────────────────────
1025
1029
  // Ricerca e reader sono gli unici modali che NON stanno in flusso sopra i
@@ -18,12 +18,16 @@ import { scanText, topForOffset } from '../text-search.js';
18
18
  import { parseMarkdown } from '../markdown.js';
19
19
  import { cpLen, insertAt, removeAt } from '../layout.js';
20
20
  import { sanitizeTyped } from '../glyphs.js';
21
- import { DETAIL_ACTIONS } from '../spawn.js';
21
+ import { DETAIL_ACTIONS, MODELS, MODEL_DEFAULT } from '../spawn.js';
22
22
  export function useSheetOverlay(deps) {
23
23
  const { rows, columns, setMode, setNote, onAction } = deps;
24
24
  const [sheet, setSheet] = useState(null);
25
25
  const [top, setTop] = useState(0);
26
26
  const [action, setAction] = useState(0);
27
+ // T108 — il modello con cui partirà la sessione. Stato del DETAIL e non del
28
+ // deck: si azzera a ogni apertura come scroll e azione, quindi non esiste una
29
+ // selezione invisibile che cambi il comportamento dei tasti della lista.
30
+ const [model, setModel] = useState(MODEL_DEFAULT);
27
31
  // T91 — ricerca dentro il detail. `open` distingue i due modi in cui la si
28
32
  // lascia: `esc` butta via ciò che il modale ha prodotto (`find` a null,
29
33
  // evidenziazione via), `⏎` lo congela e restituisce il controllo allo strato
@@ -73,11 +77,12 @@ export function useSheetOverlay(deps) {
73
77
  return;
74
78
  setTop(topForOffset(lines, o.start, capacity));
75
79
  }, [findRes, occCur, lines, capacity]);
76
- /** Apre il detail su una task, azzerando scroll, azione e ricerca. */
80
+ /** Apre il detail su una task, azzerando scroll, azione, modello e ricerca. */
77
81
  function open(next) {
78
82
  setSheet(next);
79
83
  setTop(0);
80
84
  setAction(0);
85
+ setModel(MODEL_DEFAULT);
81
86
  setFind(null);
82
87
  setOccIdx(0);
83
88
  setNote('');
@@ -175,7 +180,21 @@ export function useSheetOverlay(deps) {
175
180
  const id = sheet?.id;
176
181
  close();
177
182
  if (id)
178
- onAction(id, act.kind, `⏎ ${act.label}`);
183
+ onAction(id, act.kind, model, `⏎ ${act.label}`);
184
+ }
185
+ else if (key.tab) {
186
+ // T108 — `tab` scorre il catalogo dei modelli. Libero solo QUI: in vista
187
+ // normale cicla la vista del pane a fuoco, e le cifre `1`-`9` sono della
188
+ // legenda launch. Il detail cattura l'input per intero, quindi è dove un
189
+ // alfabeto già speso torna disponibile.
190
+ setModel((m) => MODELS[(MODELS.indexOf(m) + 1) % MODELS.length]);
191
+ }
192
+ else if (input.length === 1 && input >= '1' && input <= String(MODELS.length)) {
193
+ // Scelta diretta: chi sa già quale vuole non paga lo scorrimento. Il test
194
+ // sulla lunghezza non è pleonastico: `useInput` consegna il CHUNK letto da
195
+ // stdin, quindi un incollato come `12` passerebbe il confronto fra
196
+ // stringhe e indicizzerebbe il catalogo fuori range.
197
+ setModel(MODELS[Number(input) - 1]);
179
198
  }
180
199
  else if (key.leftArrow || key.rightArrow) {
181
200
  // Scorrimento CICLICO come le righe di scelta del modale edit: cinque
@@ -210,6 +229,7 @@ export function useSheetOverlay(deps) {
210
229
  doc,
211
230
  top,
212
231
  action,
232
+ model,
213
233
  find,
214
234
  lines,
215
235
  capacity,
package/dist/spawn.js CHANGED
@@ -32,6 +32,15 @@ export function spawnOut(cmd, args, opts) {
32
32
  fake.unref = () => fake;
33
33
  return fake;
34
34
  }
35
+ // L'ordine È il binding dei tasti `1`-`4` nel detail, non una preferenza di
36
+ // lettura: cambiarlo sposta i tasti sotto le dita di chi li ha imparati.
37
+ export const MODELS = ['fable', 'opus', 'sonnet', 'haiku'];
38
+ // Default del selettore e di ogni percorso di spawn che non passa da lui
39
+ // (`^K`/`^P`/`^R` dalla lista, resume, fork). Duplicato del default di deck-run
40
+ // e non letto da lì: il deck deve poter MOSTRARE la selezione iniziale prima di
41
+ // spawnare alcunché, e un valore che si conosce solo a spawn avvenuto non è
42
+ // mostrabile.
43
+ export const MODEL_DEFAULT = 'opus';
35
44
  // T66 — le azioni del detail. Non sono un catalogo nuovo: ognuna è un
36
45
  // `--prompt-kind` già esistente più `checkpoint`, e tutte passano dallo stesso
37
46
  // `spawnForTask` dei CTRL della lista — una superficie in più, zero percorsi di
@@ -53,11 +62,14 @@ export const DETAIL_ACTIONS = [
53
62
  // Il kind è OBBLIGATORIO e non ha default qui: il default vive in deck-run (per
54
63
  // le invocazioni a mano), mentre dal deck ogni tasto dichiara il proprio intento
55
64
  // — un default silenzioso renderebbe indistinguibili `⏎` e `^K`.
56
- export function deckArgs(id, sessionId, kind) {
57
- return [id, '--session-id', sessionId, '--prompt-kind', kind];
65
+ // Il modello, al contrario del kind, ha un default (T108): i percorsi che non
66
+ // passano dal selettore del detail non devono nominarlo per forza, e l'unico
67
+ // valore sensato per loro è quello che il selettore stesso mostra all'apertura.
68
+ export function deckArgs(id, sessionId, kind, model = MODEL_DEFAULT) {
69
+ return [id, '--session-id', sessionId, '--prompt-kind', kind, '--model', model];
58
70
  }
59
- export function spawnDeck(id, cwd, sessionId, kind) {
60
- const child = spawnOut(DECK_RUN, deckArgs(id, sessionId, kind), {
71
+ export function spawnDeck(id, cwd, sessionId, kind, model = MODEL_DEFAULT) {
72
+ const child = spawnOut(DECK_RUN, deckArgs(id, sessionId, kind, model), {
61
73
  cwd,
62
74
  detached: true,
63
75
  stdio: 'ignore',
@@ -5,7 +5,7 @@ import { Box, Text } from 'ink';
5
5
  import { caretWindow, cut, cutParts, sanitize } from '../width.js';
6
6
  import { sliceLine } from '../text-search.js';
7
7
  import { sliceSpans } from '../markdown.js';
8
- import { DETAIL_ACTIONS } from '../spawn.js';
8
+ import { DETAIL_ACTIONS, MODELS } from '../spawn.js';
9
9
  import { WARN } from '../glyphs.js';
10
10
  /** Resa di ogni costrutto markdown (T75 · D4): un solo livello di enfasi per
11
11
  * costrutto, senza un secondo alfabeto da imparare. Heading uguali a ogni
@@ -72,7 +72,7 @@ export function DetailFindField({ value, caret, cols }) {
72
72
  * all'arrivo del mouse (T21 · SGR enable + hit-test) senza migrazione. La
73
73
  * navigazione da tastiera ci si sovrappone senza conflitti.
74
74
  */
75
- export function DetailScreen({ id, title, missing, lines, spans, top, total, capacity, action, columns, find, occ, occCur, }) {
75
+ export function DetailScreen({ id, title, missing, lines, spans, top, total, capacity, action, model, columns, find, occ, occCur, }) {
76
76
  const last = Math.min(total, top + capacity);
77
77
  // Il taglio lo fa il chiamante (invariante ③ di width.ts): la riga bottoni è
78
78
  // ASCII, quindi `truncate-end` oggi darebbe il risultato giusto per caso — ma
@@ -85,13 +85,33 @@ export function DetailScreen({ id, title, missing, lines, spans, top, total, cap
85
85
  parts.push(' ');
86
86
  parts.push(s);
87
87
  });
88
- const dropped = (v) => segs.filter((s, i) => v[i * 2] !== s).length;
88
+ const dropped = (v, of) => of.filter((s, i) => v[i * 2] !== s).length;
89
89
  // Due passate: la seconda serve SOLO quando qualcosa cade, e riserva le
90
90
  // colonne del contatore. Riservarle sempre costerebbe 6 colonne su ogni
91
91
  // terminale largo per un avviso che lì non comparirà mai.
92
92
  let shown = cutParts(parts, width);
93
- if (dropped(shown) > 0)
93
+ if (dropped(shown, segs) > 0)
94
94
  shown = cutParts(parts, Math.max(0, width - 6));
95
- const cutCount = dropped(shown);
96
- 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: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^F" }), " cerca \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " esegui \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] })), _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(DetailFindField, { value: find.q, caret: find.caret, 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, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === action, color: i / 2 === action ? 'green' : 'gray', children: part }, i))), cutCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cutCount] }) : null] }) })] }));
95
+ const cutCount = dropped(shown, segs);
96
+ // T108 riga del selettore modello, stesso schema di bottoni affiancati della
97
+ // barra azioni. `priority` sulla voce SELEZIONATA e non sulla prima: qui il
98
+ // troncamento cancellerebbe l'unica informazione che la riga esiste per dare
99
+ // — quale modello sta per essere usato — mentre nella barra azioni la voce
100
+ // attiva è comunque nota dal tasto appena premuto.
101
+ const mSegs = MODELS.map((m, i) => `${i + 1} ${m}`);
102
+ const mParts = [];
103
+ mSegs.forEach((s, i) => {
104
+ if (i > 0)
105
+ mParts.push(' ');
106
+ mParts.push(s);
107
+ });
108
+ const mIdx = Math.max(0, MODELS.indexOf(model));
109
+ // `- 8` = le colonne del prefisso "modello ", che sta fuori dai parts perché
110
+ // non è un bottone e non deve mai cadere.
111
+ const mWidth = Math.max(0, width - 8);
112
+ let mShown = cutParts(mParts, mWidth, mIdx * 2);
113
+ if (dropped(mShown, mSegs) > 0)
114
+ mShown = cutParts(mParts, Math.max(0, mWidth - 6), mIdx * 2);
115
+ const mCut = dropped(mShown, mSegs);
116
+ 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: "\u2191\u2193" }), " riga \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp/PgDn" }), " pagina \u00B7", ' ', _jsx(Text, { color: "yellow", children: "g/G" }), " estremi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " azione \u00B7", ' ', _jsx(Text, { color: "yellow", children: "1-4/tab" }), " modello \u00B7 ", _jsx(Text, { color: "yellow", children: "^F" }), " cerca \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " esegui \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " chiudi"] })), _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(DetailFindField, { value: find.q, caret: find.caret, 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: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: "modello " }), mShown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { bold: i / 2 === mIdx, color: i / 2 === mIdx ? 'green' : 'gray', children: part }, i))), mCut > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", mCut] }) : null] }), _jsxs(Text, { wrap: "truncate-end", children: [shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === action, color: i / 2 === action ? 'green' : 'gray', children: part }, i))), cutCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cutCount] }) : null] })] })] }));
97
117
  }
package/dist/viewport.js CHANGED
@@ -217,13 +217,14 @@ export function readerCapacity(rows) {
217
217
  // 1 marginTop del box testo
218
218
  // 2 bordi del box testo
219
219
  // 1 marginTop della riga azioni
220
+ // 1 riga modello (T108)
220
221
  // 1 riga azioni
221
222
  //
222
- // Le ultime due sono il motivo per cui il detail non può riusare READER_CHROME:
223
- // la barra bottoni è una riga FISSA in più dentro l'overlay, e ogni riga fissa
224
- // aggiunta va scalata dalla capienza del contenuto o il frame sfonda `rows`
225
- // (stessa invariante di TASKS_PANE_CHROME).
226
- const DETAIL_CHROME = 10;
223
+ // Le ultime tre sono il motivo per cui il detail non può riusare READER_CHROME:
224
+ // barra bottoni e selettore modello sono righe FISSE in più dentro l'overlay, e
225
+ // ogni riga fissa aggiunta va scalata dalla capienza del contenuto o il frame
226
+ // sfonda `rows` (stessa invariante di TASKS_PANE_CHROME).
227
+ const DETAIL_CHROME = 11;
227
228
  // T91 — la ricerca dentro il detail: marginTop + riga del campo.
228
229
  //
229
230
  // `MODAL_HEIGHT` dice che il detail costa 0 ai due pane (li sostituisce), e da
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.34.1",
3
+ "version": "0.34.2",
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
@@ -8,16 +8,19 @@
8
8
  # "recap stato task <TaskID>" — no skill, solo recap → più controllo).
9
9
  # Riusabile identico da TUI (Ink) e da web.
10
10
  #
11
- # Tre assi ORTOGONALI, da non confondere fra loro:
11
+ # Quattro assi ORTOGONALI, da non confondere fra loro:
12
12
  # binding task <TaskID> ..... --no-task
13
13
  # continuità nuova ........ --resume [--fork]
14
14
  # prompt --prompt-kind none|recap|preflight|run|checkpoint
15
+ # modello --model fable|opus|sonnet|haiku
15
16
  # Il terzo asse (T56) prima non esisteva: il prompt era una CONSEGUENZA degli
16
17
  # altri due (bound ⇒ recap, --no-task ⇒ niente, --resume ⇒ niente), quindi
17
18
  # "task-bound SENZA prompt" era inesprimibile e l'unico modo di non avere un
18
19
  # prompt era perdere la task. Il catalogo dei prompt vive qui e non nel deck
19
20
  # perché questo è il primitive UI-agnostico: il chiamante passa un SIMBOLO, non
20
21
  # una stringa — il quoting resta verificato in un posto solo (vedi PROMPT sotto).
22
+ # Il quarto asse (T108) è indipendente dagli altri tre: vale sul ramo bound come
23
+ # su --no-task, su una sessione nuova come su una ripresa.
21
24
  #
22
25
  # Con --no-task la sessione è NUDA: niente LOOM_TASK, niente prompt iniziale,
23
26
  # niente --session-id. Serve al lavoro spot che non appartiene a nessuna task.
@@ -43,6 +46,7 @@
43
46
  # LOOM_DECK_STATE_PROFILE PTYXIS_PROFILE annunciata a compass (default: bindings/claude
44
47
  # del progetto, letto da dconf; settata a vuoto = nessun annuncio)
45
48
  # LOOM_DECK_PERMISSION_MODE override del permissionMode del file config (default: campo file, poi 'manual')
49
+ # LOOM_DECK_MODEL modello quando --model non è passato (default: opus)
46
50
  #
47
51
  set -euo pipefail
48
52
 
@@ -56,6 +60,10 @@ TITLE_NOTE=""
56
60
  # "kind implicito" da "kind chiesto", perché con --no-task il primo è legittimo
57
61
  # (nessun prompt, come sempre) e il secondo è un errore d'uso.
58
62
  PROMPT_KIND=""
63
+ # Vuoto = flag non passato: la cascata (env, poi default) si risolve più sotto,
64
+ # insieme alla validazione, così l'ingresso da argomento e quello da env passano
65
+ # per lo stesso enum.
66
+ MODEL=""
59
67
  # Positional <TaskID> + flag opzionale --session-id <uuid> (T27): il deck genera
60
68
  # l'UUID e lo pinna così il binding sidecar sessionId↔taskId è deterministico.
61
69
  # --no-task (T42): modalità NUDA, senza TaskID — nessuna LOOM_TASK, nessun prompt
@@ -84,8 +92,17 @@ PROMPT_KIND=""
84
92
  # (emoji/name dal file committato, TaskID) sono controllati — quindi il testo non
85
93
  # si quota, si RIDUCE a un alfabeto sicuro (vedi _sane_note): tutto ciò che non
86
94
  # ci rientra sparisce, apici inclusi.
95
+ # --model <fable|opus|sonnet|haiku> (T108): modello della sessione. Enum e non
96
+ # stringa libera perché il valore finisce dentro `bash -lc` e un refuso
97
+ # produrrebbe una tab con un comando che il CLI rifiuta. Le voci sono ALIAS e
98
+ # restano tali: espanderle a id versionati (`claude-opus-5`) cablerebbe qui una
99
+ # generazione, che al primo bump diventa un modello inesistente.
87
100
  while [[ $# -gt 0 ]]; do
88
101
  case "$1" in
102
+ --model)
103
+ MODEL="${2:-}"; shift 2 ;;
104
+ --model=*)
105
+ MODEL="${1#*=}"; shift ;;
89
106
  --title-note)
90
107
  TITLE_NOTE="${2:-}"; shift 2 ;;
91
108
  --title-note=*)
@@ -112,7 +129,7 @@ while [[ $# -gt 0 ]]; do
112
129
  esac
113
130
  done
114
131
 
115
- USAGE="uso: deck-run <TaskID> [--prompt-kind <kind>] [--session-id <uuid>]
132
+ USAGE="uso: deck-run <TaskID> [--model <alias>] [--prompt-kind <kind>] [--session-id <uuid>]
116
133
  (es. deck-run T18)
117
134
  | deck-run --no-task (sessione nuda, senza task)
118
135
  | deck-run <TaskID> --resume <uuid> (riprende sessione scoped)
@@ -127,6 +144,9 @@ USAGE="uso: deck-run <TaskID> [--prompt-kind <kind>] [--session-id <uuid>]
127
144
  run /loom-works:run-task <TaskID>
128
145
  checkpoint /loom-works:checkpoint-task <TaskID>
129
146
 
147
+ --model modello della sessione: fable|opus|sonnet|haiku (default: opus)
148
+ valore ignoto → fallback sul default, con avviso su stderr
149
+
130
150
  --title-note nota della conversazione, appesa al titolo tab come «nota»
131
151
  (ridotta a lettere/cifre/spazi/-/_, cap 60 char)"
132
152
 
@@ -274,6 +294,28 @@ case "${PERM_MODE:-}" in
274
294
  esac
275
295
  MODE_FLAG="--permission-mode ${PERM_MODE} "
276
296
 
297
+ # ── modello (T108) ───────────────────────────────────────────────────────────
298
+ # Precedenza: --model > env LOOM_DECK_MODEL > 'opus'. Nessun campo nel file
299
+ # config: il default è unico per tutta la famiglia, e uno scalare per-progetto
300
+ # aggiungerebbe una chiave allo schema del plugin per un valore che il selettore
301
+ # del deck sovrascrive a ogni spawn.
302
+ # Il flag è passato SEMPRE, anche sul default, per la stessa ragione di
303
+ # permissionMode: lo spawn resta leggibile nel process tree invece di dipendere
304
+ # dal default del CLI, che cambia fra versioni.
305
+ # Valore fuori enum → fallback con avviso, non exit. Regime opposto a
306
+ # --prompt-kind, e il discriminante è dove finisce il valore: un kind ignoto si
307
+ # ferma DENTRO lo script (nessun template da scegliere, l'errore è del deck),
308
+ # un modello ignoto arriverebbe al CLI e produrrebbe una tab con un comando che
309
+ # fallisce all'avvio — cioè il guasto che permissionMode evita degradando.
310
+ MODEL="${MODEL:-${LOOM_DECK_MODEL:-}}"
311
+ case "${MODEL:-}" in
312
+ fable|opus|sonnet|haiku) ;;
313
+ '') MODEL="opus" ;;
314
+ *) echo "modello ignoto: '${MODEL}' → fallback 'opus' (usa fable|opus|sonnet|haiku)" >&2
315
+ MODEL="opus" ;;
316
+ esac
317
+ MODEL_FLAG="--model ${MODEL} "
318
+
277
319
  # ── Profilo di stato per compass ─────────────────────────────────────────────
278
320
  # Lo stato di una sessione (running/ask/done) viaggia verso compass via D-Bus
279
321
  # keyed su $PTYXIS_PROFILE (hook Claude → `compass <stato>`), e compass lo mappa
@@ -388,10 +430,13 @@ PROMPT_ARG=""
388
430
  # PROFILE_ENV in testa a entrambi i rami: lo stato è una proprietà della SESSIONE
389
431
  # (esiste anche senza task), esattamente come la label del titolo — legarlo al
390
432
  # ramo task-bound ripeterebbe l'errore di layer già evitato per la titolazione.
433
+ # MODEL_FLAG in entrambi i rami e prima dei flag di continuità: il modello è la
434
+ # scelta di chi apre la sessione, non una proprietà della task né della ripresa
435
+ # (T108) — stesso layer di MODE_FLAG, che infatti gli sta accanto.
391
436
  if [[ $NO_TASK -eq 1 ]]; then
392
- _default_intab="${PROFILE_ENV}claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}"
437
+ _default_intab="${PROFILE_ENV}claude --name '${TITLE}' ${MODE_FLAG}${MODEL_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}"
393
438
  else
394
- _default_intab="${PROFILE_ENV}LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}${PROMPT_ARG}"
439
+ _default_intab="${PROFILE_ENV}LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${MODEL_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}${PROMPT_ARG}"
395
440
  fi
396
441
  IN_TAB_CMD="${LOOM_DECK_INTAB_CMD:-$_default_intab}"
397
442