@lamemind/loom-deck 0.62.1 → 0.63.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/dist/actions.js CHANGED
@@ -156,24 +156,30 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
156
156
  spawned.child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
157
157
  noteSpawn(spawned);
158
158
  }
159
- /** T148 — `m`: scorre il modello con cui la sessione selezionata riprenderà
160
- * al successivo del catalogo. Stesso guard di `f` (serve un transcript
161
- * vero, non basta una riga selezionata): su una pinnata stale il blocco
162
- * preview non esiste — non c'è un bottone da cambiare. Nessuna nota di
163
- * conferma sul successo il feedback è il bottone stesso, già a schermo
164
- * nella preview. */
159
+ /** T148/T154(P2,P3) — `m` col bersaglio RESUME: `input.ts` instrada qui solo
160
+ * quando il focus è sulle sessioni E una riga è selezionata (P2), quindi il
161
+ * solo caso residuo è il pin STALE l'id c'è ma il transcript no, quindi
162
+ * non c'è un bottone da cambiare (P3): resta sul selettore di resume con la
163
+ * propria nota, invece di spostare di soppiatto il selettore della nuda
164
+ * mentre il caret è su una riga sessione.
165
+ *
166
+ * La nota sul SUCCESSO nomina bersaglio e valore (`m → resume: opus`, P2):
167
+ * rovescia la scelta di T148 di non scriverne nessuna, perché lì `m` aveva
168
+ * un bersaglio solo e il bottone che cambiava era l'unico a schermo — da
169
+ * T154 i due selettori possono stare a schermo insieme. */
165
170
  function cycleResumeModel() {
166
- if (model.focus !== 'sessions') {
167
- setNote('m → modello: seleziona una sessione (→ per il pane)');
168
- return;
169
- }
170
171
  if (!model.selSessionObj) {
171
- setNote(model.selSessionId
172
- ? 'm → pin stale: nessun modello da cambiare'
173
- : 'm → nessuna sessione selezionata');
172
+ setNote('m → pin stale: nessun modello da cambiare');
174
173
  return;
175
174
  }
176
- model.cycleResumeModel();
175
+ const next = model.cycleResumeModel();
176
+ setNote(`m → resume: ${next}`);
177
+ }
178
+ /** T154 — `m` col bersaglio NUDA (`c`): il selettore che governa sta sempre
179
+ * a schermo sulla riga launch, quindi il tasto non è mai inerte qui. */
180
+ function cycleBareModel() {
181
+ const next = model.cycleBareModel();
182
+ setNote(`m → nuda: ${next}`);
177
183
  }
178
184
  // T53 — scrive il sidecar e ricarica subito, senza attendere il tick del poll
179
185
  // (stesso feedback immediato del pin).
@@ -311,9 +317,11 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
311
317
  spawned.child.on('error', () => setNote(`⚠ srotolamento ${path} fallito (${DECK_RUN})`));
312
318
  noteSpawn(spawned);
313
319
  }
314
- /** `c` — sessione claude a mani nude, senza task e senza prompt. */
320
+ /** `c` — sessione claude a mani nude, senza task e senza prompt. T154 — il
321
+ * modello è quello del selettore proprio della nuda (`model.bareModel`),
322
+ * passato SEMPRE nell'argv anche sul default. */
315
323
  function openClaude() {
316
- const spawned = spawnClaudeEmpty(cwd);
324
+ const spawned = spawnClaudeEmpty(cwd, model.bareModel);
317
325
  spawned.child.on('error', () => setNote(`⚠ c → spawn claude fallito (${DECK_RUN})`));
318
326
  noteSpawn(spawned);
319
327
  }
@@ -352,6 +360,7 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
352
360
  spawnTaskSession,
353
361
  resumeSession,
354
362
  cycleResumeModel,
363
+ cycleBareModel,
355
364
  writeNote,
356
365
  assignSession,
357
366
  forkSession,
package/dist/cli.js CHANGED
@@ -19,9 +19,10 @@ import { resolveTasksPath, resolveTasksDir } from './tasks.js';
19
19
  import { LAUNCH_SEP } from './config.js';
20
20
  import { anchorFrame, enableMouse } from './mouse.js';
21
21
  import { sanitize } from './width.js';
22
- import { modelAlias } from './glyphs.js';
23
- import { MODEL_DEFAULT } from './spawn.js';
24
- import { deckLegend, frameGeometry, headlineWidth, indicatorRow, launchRow } from './frame.js';
22
+ import { MODEL_SHORT_LIST, modelAlias } from './glyphs.js';
23
+ import { MODEL_DEFAULT, MODELS } from './spawn.js';
24
+ import { BARE_BUTTONS_WIDTH, BARE_LABEL, deckLegend, frameGeometry, headlineWidth, indicatorRow, launchRow, } from './frame.js';
25
+ import { choiceButtons } from './ui/fields.js';
25
26
  import { useDeckModel } from './deck-model.js';
26
27
  import { useDeckActions } from './actions.js';
27
28
  import { useDeckInput } from './input.js';
@@ -162,6 +163,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
162
163
  : 'none';
163
164
  const detailParts = model.detail ? detailMetaOf(model.detail) : null;
164
165
  const launch = launchRow(model.launch, columns);
166
+ // T154 — i bottoni del blocco selettore della nuda: stessa primitiva
167
+ // (`choiceButtons`) del selettore di resume/fork, sulle SHORT (D1) e con
168
+ // `avail` pari alla larghezza naturale del gruppo, così `cutParts` non
169
+ // taglia mai (il blocco è riservato, non conteso — vedi `launchRow`).
170
+ const bareButtons = choiceButtons(MODEL_SHORT_LIST, MODELS.indexOf(model.bareModel), BARE_BUTTONS_WIDTH, MODELS.indexOf(MODEL_DEFAULT));
165
171
  // T134 — gli indicatori ancorati a destra della riga legenda. Si compongono
166
172
  // PRIMA della legenda perché è la loro larghezza a decidere quanto ne resta:
167
173
  // hanno la precedenza sul budget (D5 preflight).
@@ -245,7 +251,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
245
251
  purgeBulk: model.purgeBulk,
246
252
  inboxPane: model.rightPane === 'inbox',
247
253
  hasInbox: model.selInbox !== null,
248
- }), indicators: indicators, columns: columns }), frame.launchLine ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [launch.segments.map((s) => s.text).join(LAUNCH_SEP), launch.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", launch.overflow, " fuori riga"] })) : null, launch.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", launch.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? _jsx(TextBox, { glyph: "C", value: textModals.draft }) : null, mode === 'note' ? _jsx(TextBox, { glyph: "\u270E", value: textModals.noteDraft }) : null, mode === 'sort' ? _jsx(SortModal, { sort: model.view.sort }) : null, mode === 'filter' ? (_jsx(FilterModal, { view: model.view, cursor: viewModals.filterCursor })) : null, mode === 'edit' && textModals.edit && textModals.editTask ? (_jsx(EditModal, { id: textModals.editTask.id, draft: textModals.edit, row: textModals.editCursor.row, caret: textModals.editCursor.caret, columns: columns })) : null, mode === 'purge' && purge.draft ? (_jsx(PurgeModal, { draft: purge.draft, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: frame.windowTasks, counts: model.taskCounts, activeView: model.taskViewId, paneCount: model.paneTasks.length, view: model.view, selected: model.selIndex, spotCount: model.spotCount, allCount: model.sessions.length, idW: model.taskCols.id, tailW: model.taskCols.tail, focused: model.focus === 'tasks', loadError: model.loadError, windowStart: frame.taskWin.start, above: frame.taskWin.start, below: model.paneTasks.length - frame.taskWin.end, columns: columns, data: model.taskRowData, blockMark: model.blockMark }), model.rightPane === 'inbox' ? (_jsx(InboxPane, { files: frame.windowInbox, counts: model.inboxCounts, activeView: model.inboxViewId, paneCount: model.inboxFiles.length, selectedPath: model.selInboxPath, focused: model.focus === 'inbox', above: frame.inboxWin.start, below: model.inboxFiles.length - frame.inboxWin.end, columns: columns, ok: model.inboxOk, scanned: model.inboxScanned })) : (_jsx(SessionsPane, { parentLabel: model.parentLabel, isSpot: model.isSpot, isAll: model.isAll, bindings: model.bindings, taskW: model.sessionCols.task, ageW: model.sessionCols.age, rows: frame.windowRows, counts: model.sessionCounts, activeView: model.sessionViewId, paneCount: model.sessionRows.length, selectedId: model.selSessionId ?? undefined, focused: model.focus === 'sessions', above: frame.sessionWin.start, below: model.sessionRows.length - frame.sessionWin.end, columns: columns, forkOf: model.forkOf, sessionNotes: model.sessionNotes, projectCore: model.projectCore, live: model.live }))] }), frame.budget.preview && previewKind === 'task' && model.detail ? (_jsx(PreviewPane, { kind: "task", detail: model.detail, maxLines: frame.budget.detailLines, columns: columns })) : frame.budget.preview && previewKind === 'inbox' && model.selInbox ? (_jsx(PreviewPane, { kind: "inbox", file: model.selInbox, columns: columns })) : frame.budget.preview && previewKind === 'session' && model.selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: model.selSessionObj, firstLines: frame.budget.sessionFirstLines, lastLines: frame.budget.sessionLastLines, columns: columns, origin: model.forkOf.get(model.selSessionObj.sessionId) ?? null, note: model.sessionNotes.get(model.selSessionObj.sessionId) ?? '', live: model.live.get(model.selSessionObj.sessionId) ?? null, resumeModel: model.resumeModel })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
254
+ }), indicators: indicators, columns: columns }), frame.launchLine ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [launch.segments.map((s) => s.text).join(LAUNCH_SEP), launch.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", launch.overflow, " fuori riga"] })) : null, launch.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", launch.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null, launch.filler, BARE_LABEL.text, ' ', bareButtons.nodes] })) : null, mode === 'create' ? _jsx(TextBox, { glyph: "C", value: textModals.draft }) : null, mode === 'note' ? _jsx(TextBox, { glyph: "\u270E", value: textModals.noteDraft }) : null, mode === 'sort' ? _jsx(SortModal, { sort: model.view.sort }) : null, mode === 'filter' ? (_jsx(FilterModal, { view: model.view, cursor: viewModals.filterCursor })) : null, mode === 'edit' && textModals.edit && textModals.editTask ? (_jsx(EditModal, { id: textModals.editTask.id, draft: textModals.edit, row: textModals.editCursor.row, caret: textModals.editCursor.caret, columns: columns })) : null, mode === 'purge' && purge.draft ? (_jsx(PurgeModal, { draft: purge.draft, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: frame.windowTasks, counts: model.taskCounts, activeView: model.taskViewId, paneCount: model.paneTasks.length, view: model.view, selected: model.selIndex, spotCount: model.spotCount, allCount: model.sessions.length, idW: model.taskCols.id, tailW: model.taskCols.tail, focused: model.focus === 'tasks', loadError: model.loadError, windowStart: frame.taskWin.start, above: frame.taskWin.start, below: model.paneTasks.length - frame.taskWin.end, columns: columns, data: model.taskRowData, blockMark: model.blockMark }), model.rightPane === 'inbox' ? (_jsx(InboxPane, { files: frame.windowInbox, counts: model.inboxCounts, activeView: model.inboxViewId, paneCount: model.inboxFiles.length, selectedPath: model.selInboxPath, focused: model.focus === 'inbox', above: frame.inboxWin.start, below: model.inboxFiles.length - frame.inboxWin.end, columns: columns, ok: model.inboxOk, scanned: model.inboxScanned })) : (_jsx(SessionsPane, { parentLabel: model.parentLabel, isSpot: model.isSpot, isAll: model.isAll, bindings: model.bindings, taskW: model.sessionCols.task, ageW: model.sessionCols.age, rows: frame.windowRows, counts: model.sessionCounts, activeView: model.sessionViewId, paneCount: model.sessionRows.length, selectedId: model.selSessionId ?? undefined, focused: model.focus === 'sessions', above: frame.sessionWin.start, below: model.sessionRows.length - frame.sessionWin.end, columns: columns, forkOf: model.forkOf, sessionNotes: model.sessionNotes, projectCore: model.projectCore, live: model.live }))] }), frame.budget.preview && previewKind === 'task' && model.detail ? (_jsx(PreviewPane, { kind: "task", detail: model.detail, maxLines: frame.budget.detailLines, columns: columns })) : frame.budget.preview && previewKind === 'inbox' && model.selInbox ? (_jsx(PreviewPane, { kind: "inbox", file: model.selInbox, columns: columns })) : frame.budget.preview && previewKind === 'session' && model.selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: model.selSessionObj, firstLines: frame.budget.sessionFirstLines, lastLines: frame.budget.sessionLastLines, columns: columns, origin: model.forkOf.get(model.selSessionObj.sessionId) ?? null, note: model.sessionNotes.get(model.selSessionObj.sessionId) ?? '', live: model.live.get(model.selSessionObj.sessionId) ?? null, resumeModel: model.resumeModel })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
249
255
  }
250
256
  const cwd = process.cwd();
251
257
  // T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
@@ -131,6 +131,12 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
131
131
  // tornare su una riga già visitata NON ricorda la scelta fatta lì — è lo
132
132
  // stesso costo che T108 ha già accettato per il selettore del detail.
133
133
  const [resumeModel, setResumeModel] = useState(MODEL_DEFAULT);
134
+ // T154 — il modello con cui la sessione NUDA (`c`) apre. Stato PROPRIO,
135
+ // indipendente da `resumeModel`: non segue nessuna riga selezionata (`c` non
136
+ // ha una riga, è un'azione sulla surface del cappello) e quindi non si
137
+ // azzera mai — resta quello scelto finché non si sceglie altro, per tutta la
138
+ // vita del deck.
139
+ const [bareModel, setBareModel] = useState(MODEL_DEFAULT);
134
140
  // T39 — vista corrente (filtri + sort). Vive nel modello e non nell'hook dei
135
141
  // modali che la editano: è ciò che `applyView` consuma per produrre la lista,
136
142
  // è persistita su disco e la rilegge il tasto `w`. È stato del modello che due
@@ -407,10 +413,22 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
407
413
  * valore SUCCESSIVO del catalogo (ciclico), gemella di `cycleView` ma su un
408
414
  * asse diverso: qui non c'è una vista da cambiare, solo un valore da far
409
415
  * avanzare. Il guard (nessuna sessione, focus altrove) sta nell'attuatore
410
- * `actions.ts`, come per `f`/`p`: qui c'è solo la meccanica dello stato. */
416
+ * `actions.ts`, come per `f`/`p`: qui c'è solo la meccanica dello stato.
417
+ * T154/P2 — ritorna il valore NUOVO: il chiamante lo scrive nella nota di
418
+ * conferma prima che il render con lo stato aggiornato arrivi. */
411
419
  function cycleResumeModel() {
412
420
  const next = MODELS[(MODELS.indexOf(resumeModel) + 1) % MODELS.length];
413
421
  setResumeModel(next);
422
+ return next;
423
+ }
424
+ /** T154 — gemella di `cycleResumeModel`, sullo stato della sessione NUDA:
425
+ * `m` ci finisce quando il focus non è su una riga sessione vera (P2, D4/D5
426
+ * degli AC). Nessun guard qui: il bersaglio non è mai inerte — il selettore
427
+ * che governa è sempre a schermo sulla riga launch. */
428
+ function cycleBareModel() {
429
+ const next = MODELS[(MODELS.indexOf(bareModel) + 1) % MODELS.length];
430
+ setBareModel(next);
431
+ return next;
414
432
  }
415
433
  /**
416
434
  * T134 — `^B` scambia i due pane dello slot destro (D8 preflight).
@@ -493,6 +511,8 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
493
511
  sessionCols,
494
512
  resumeModel,
495
513
  cycleResumeModel,
514
+ bareModel,
515
+ cycleBareModel,
496
516
  // derivazioni del pane inbox
497
517
  rightPane,
498
518
  inboxScanned: inbox.scanned,
package/dist/frame.js CHANGED
@@ -20,17 +20,15 @@ import { FRAME_TEXT_COL, HINT_ROW, inlineRegions, LAUNCH_ROW, PANE_TEXT_PAD, pan
20
20
  import { cellWidth, launchLegend, LAUNCH_SEP } from './config.js';
21
21
  import { layoutBudget, windowRange } from './viewport.js';
22
22
  import { sanitize, termWidth } from './width.js';
23
- import { WARN } from './glyphs.js';
23
+ import { MODEL_SHORT_LIST, WARN } from './glyphs.js';
24
24
  import { STATUS_MISSING } from './project-status.js';
25
25
  import { META_ROWS } from './model.js';
26
26
  import { rowIndexOf } from './session-list.js';
27
27
  import { VERSION } from './version.js';
28
- // Le due surface built-in del cappello, in testa alla riga launch. Non stanno
29
- // fra i tasti perché hanno la stessa natura delle voci `launch` — fire-once,
30
- // cwd = project root, nessuno stato e la differenza è solo che sono
31
- // universali (nessun progetto le dichiara) invece che custom. Emoji del menu
32
- // compass: 🤖 = nuova sessione claude. Per il terminale compass usa 🖥️, che nel
33
- // frame Ink NON passa — `sanitize` lo sostituisce (VTE lo disegna largo 1,
28
+ // La surface built-in del cappello, in testa alla riga launch. Non sta fra i
29
+ // tasti perché ha la stessa natura delle voci `launch` — fire-once, cwd =
30
+ // project root, nessuno stato. Per il terminale compass usa 🖥️, che nel frame
31
+ // Ink NON passa `sanitize` lo sostituisce (VTE lo disegna largo 1,
34
32
  // string-width dice 2: discordante, invariante ① di width.ts) e resterebbe un
35
33
  // `·` muto. 💻 è il gemello concorde; il `sanitize` qui rende il vincolo
36
34
  // automatico invece che da ricordare.
@@ -38,10 +36,35 @@ import { VERSION } from './version.js';
38
36
  // T21 — `key` è il tasto che la superficie rappresenta: un click su di essa
39
37
  // entra nell'handler di tastiera con quel tasto, invece di chiamare l'azione
40
38
  // per conto proprio. È ciò che tiene click e tasto per costruzione allineati.
41
- export const SURFACE_SEGMENTS = [
42
- { key: 't', text: sanitize('t 💻') },
43
- { key: 'c', text: sanitize('c 🤖') },
44
- ];
39
+ //
40
+ // T154/P9 `c` non vive più qui: la surface trasloca a destra come etichetta
41
+ // del blocco selettore della nuda (`BARE_LABEL` sotto), adiacente ai bottoni
42
+ // col modello con cui apre invece che sull'estremo opposto della riga.
43
+ export const SURFACE_SEGMENTS = [{ key: 't', text: sanitize('t 💻') }];
44
+ // T154/P9 — l'etichetta del blocco selettore della nuda: unico segmento
45
+ // cliccabile del blocco (nessun tasto nomina un modello, `m` cicla e non
46
+ // seleziona — i bottoni non lo sono).
47
+ export const BARE_LABEL = { key: 'c', text: sanitize('c 🤖') };
48
+ // T154/D1 — i bottoni usano le SHORT (`fab`/`ops`/`son`/`hak`), non i nomi
49
+ // interi del selettore di resume/fork: la riga launch compete per spazio con
50
+ // la legenda delle voci custom, e le short sono già il vocabolario compatto
51
+ // che la colonna modello della lista sessioni usa per lo stesso motivo.
52
+ const BARE_BUTTONS_TEXT = MODEL_SHORT_LIST.map((s) => `[ ${s} ]`).join(' ');
53
+ /** Larghezza dei soli bottoni, in celle: passata come `avail` a
54
+ * `choiceButtons` — esattamente la larghezza naturale, così `cutParts` non
55
+ * taglia mai (il blocco è RISERVATO, D1/P8: a cedere sono le voci `launch`,
56
+ * mai il selettore). */
57
+ export const BARE_BUTTONS_WIDTH = termWidth(BARE_BUTTONS_TEXT);
58
+ /** Spazio fra l'etichetta `c 🤖` e il primo bottone. */
59
+ const BARE_LABEL_GAP = ' ';
60
+ /** Larghezza FISSA del blocco intero (etichetta + spazio + bottoni): non
61
+ * dipende da quale modello sia selezionato né dal default — ogni short è
62
+ * larga uguale (3 lettere) — quindi la geometria non ha bisogno di saperli;
63
+ * la STILATURA sì, e la fa il chiamante con `choiceButtons` sugli stessi
64
+ * valori (`MODEL_SHORT_LIST`). 39 celle con l'attuale `MODELS` a 4 voci
65
+ * (D1 preflight).
66
+ */
67
+ const BARE_BLOCK_WIDTH = termWidth(BARE_LABEL.text) + termWidth(BARE_LABEL_GAP) + BARE_BUTTONS_WIDTH;
45
68
  /**
46
69
  * La legenda della modalità normale. Elenca SOLO i tasti che fanno qualcosa qui
47
70
  * e ora: le voci contestuali compaiono quando il pane a fuoco le rende possibili
@@ -102,21 +125,46 @@ export function deckLegend(state) {
102
125
  * seconde ri-splittando il primo sarebbe un conto parallelo, che diverge alla
103
126
  * prima label che contenga il separatore.
104
127
  *
105
- * Le celle delle surface (più il ` · ` che le separa dalle voci) sono già spese
106
- * sulla riga → vanno riservate, o le voci launch la sfonderebbero di quel tanto.
128
+ * Le celle della surface (più il ` · ` che la separa dalle voci) sono già spese
129
+ * sulla riga → vanno riservate, o le voci launch la sfonderebbero di quel
130
+ * tanto. T154/P8 — il blocco selettore della nuda entra nella STESSA riserva:
131
+ * su un progetto con molte voci `launch` a cedere per prima è una voce custom,
132
+ * mai il selettore (un bottone di modello troncato sarebbe indistinguibile da
133
+ * un modello che non esiste, una voce launch resta raggiungibile col suo
134
+ * tasto-cifra anche quando non è scritta in riga).
107
135
  */
108
136
  export function launchRow(launch, columns) {
109
137
  const surfaceLegend = SURFACE_SEGMENTS.map((s) => s.text).join(LAUNCH_SEP);
110
- const legend = launchLegend(launch, columns, cellWidth(surfaceLegend) + cellWidth(LAUNCH_SEP));
138
+ const reserved = cellWidth(surfaceLegend) + cellWidth(LAUNCH_SEP) + BARE_BLOCK_WIDTH;
139
+ const legend = launchLegend(launch, columns, reserved);
111
140
  const segments = [
112
141
  ...SURFACE_SEGMENTS,
113
142
  ...legend.taken.map((text, i) => ({ key: String(i + 1), text })),
114
143
  ];
144
+ // Larghezza REALE della sinistra, testo di overflow/irraggiungibili incluso:
145
+ // sono loro a decidere quanto riempimento serve, non solo i segmenti.
146
+ const leftWidth = termWidth(segments.map((s) => s.text).join(LAUNCH_SEP)) +
147
+ (legend.overflow > 0 ? termWidth(` · +${legend.overflow} fuori riga`) : 0) +
148
+ (legend.unreachable > 0
149
+ ? termWidth(` · ${legend.unreachable} oltre la 9ª (non raggiungibili)`)
150
+ : 0);
151
+ // T154/P5 — la formula dell'ancoraggio: colonne del bordo esterno, meno la
152
+ // sinistra già disegnata, meno il blocco che deve starci per intero. Un
153
+ // pavimento di 1 tiene sempre almeno uno spazio fra le due metà, invece di
154
+ // farle toccare quando lo schermo è troppo stretto per il resto.
155
+ const filler = ' '.repeat(Math.max(1, columns - 6 - leftWidth - BARE_BLOCK_WIDTH));
156
+ const bareCol = FRAME_TEXT_COL + leftWidth + termWidth(filler);
157
+ const bareRegion = {
158
+ key: BARE_LABEL.key,
159
+ start: bareCol,
160
+ end: bareCol + termWidth(BARE_LABEL.text) - 1,
161
+ };
115
162
  return {
116
163
  segments,
117
- regions: rowRegions(segments, LAUNCH_SEP, FRAME_TEXT_COL),
164
+ regions: [...rowRegions(segments, LAUNCH_SEP, FRAME_TEXT_COL), bareRegion],
118
165
  overflow: legend.overflow,
119
166
  unreachable: legend.unreachable,
167
+ filler,
120
168
  };
121
169
  }
122
170
  /**
package/dist/glyphs.js CHANGED
@@ -1,6 +1,11 @@
1
1
  // Glifi letterali del frame e formatter di display. Fase VISTA, ma senza JSX:
2
2
  // tutto qui è puro e testabile, e nessun sito di render scrive un glifo nudo.
3
3
  import { sanitize } from './width.js';
4
+ // T148/P3 — costo accettato: la fase vista importa dalla fase effetti esterni.
5
+ // Il TIPO si cancella alla compilazione (nessuna dipendenza runtime); T154
6
+ // aggiunge MODELS come VALORE — una dipendenza runtime vera, ma senza ciclo:
7
+ // `spawn.ts` non importa questo file, quindi il verso resta uno solo.
8
+ import { MODELS } from './spawn.js';
4
9
  // Glifi LETTERALI del JSX. I dati passano dai loader, che sanificano al
5
10
  // confine; questi no — quindi passano da `sanitize` una volta qui, così nessun
6
11
  // sito di render scrive un glifo nudo. `↳ ○ ▸ ⏎ · − ↑ ↓` sono già concordi e
@@ -41,6 +46,12 @@ export const MODEL_EMPTY = TASK_EMPTY;
41
46
  * legge come una riga normale e si scorre senza notarla; l'id per intero sta
42
47
  * nel blocco preview. */
43
48
  export const MODEL_UNKNOWN = '?';
49
+ /** T154 — le short di `MODEL_SHORT` nell'ordine POSIZIONALE di `MODELS`, non
50
+ * quello di inserimento dell'oggetto sopra: il blocco selettore della nuda
51
+ * sulla riga launch cicla su questo elenco, e `index`/`originIndex` restano
52
+ * indici in `MODELS` — derivarlo da lì invece di scriverlo a mano evita una
53
+ * seconda lista capace di disallinearsi al primo modello aggiunto. */
54
+ export const MODEL_SHORT_LIST = MODELS.map((m) => MODEL_SHORT[m]);
44
55
  export function modelShort(id) {
45
56
  if (!id)
46
57
  return MODEL_EMPTY;
package/dist/input.js CHANGED
@@ -467,9 +467,16 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
467
467
  actions.togglePin();
468
468
  }
469
469
  else if (input === 'm') {
470
- // T148 — scorre il modello di resume/fork della riga selezionata,
471
- // gemella di `f`/`p`: azione immediata, nessun modale.
472
- actions.cycleResumeModel();
470
+ // T148/T154bersaglio deciso dal focus E dalla selezione (P2): con
471
+ // una riga sessioni selezionata (stale compresa, P3) scorre il modello
472
+ // di resume/fork di quella riga; altrove (focus task, focus inbox,
473
+ // pane sessioni senza selezione) scorre il modello della sessione nuda.
474
+ if (model.focus === 'sessions' && model.selSessionId) {
475
+ actions.cycleResumeModel();
476
+ }
477
+ else {
478
+ actions.cycleBareModel();
479
+ }
473
480
  }
474
481
  else if (input === 'N') {
475
482
  // T53 — nota sulla conversazione selezionata. MAIUSCOLA perché apre un
package/dist/spawn.js CHANGED
@@ -330,8 +330,17 @@ export function spawnDeckFork(taskId, cwd, originId, newId, model) {
330
330
  // di spawnDeck: i tre argomenti mancano tutti insieme, un `if` per ciascuno
331
331
  // sporcherebbe il percorso bound. Il titolo tab resta la label loom — lo mette
332
332
  // deck-run, perché il match compass è window-level e non sa nulla di task.
333
- export function spawnClaudeEmpty(cwd) {
334
- return launchDeckRun(['--no-task'], cwd);
333
+ //
334
+ // T154 — il modello viaggia SEMPRE nell'argv (`emptyArgs`), anche sul default:
335
+ // prima di questa task il tasto `c` non lo passava affatto, e chi lo premeva
336
+ // non sceglieva né vedeva quale modello `deck-run` avrebbe risolto da sé.
337
+ // Estratta come i suoi fratelli (`deckArgs`/`resumeArgs`/`forkArgs`): tutti i
338
+ // costruttori di argv con un test proprio lo sono (P4 preflight).
339
+ export function emptyArgs(model) {
340
+ return ['--no-task', '--model', model];
341
+ }
342
+ export function spawnClaudeEmpty(cwd, model) {
343
+ return launchDeckRun(emptyArgs(model), cwd);
335
344
  }
336
345
  /**
337
346
  * T134 — sessione NUDA con un prompt letterale: `deck-run --no-task --prompt
package/dist/ui/fields.js CHANGED
@@ -28,8 +28,8 @@ export function FieldText({ value, caret, focused, cols, }) {
28
28
  * preview sessione). */
29
29
  export const LABEL_W = 8;
30
30
  /**
31
- * T117 — una riga a SCELTA di un'area di compilazione: bottoni affiancati,
32
- * voce attiva in video inverso.
31
+ * T117 — i soli BOTTONI di una riga a scelta: valore per valore, già tagliati
32
+ * al budget e stilati (voce attiva in video inverso, origine sottolineata).
33
33
  *
34
34
  * Due passate di `cutParts`: la seconda serve SOLO quando qualcosa cade, e
35
35
  * riserva le colonne del contatore. Riservarle sempre costerebbe 6 colonne su
@@ -39,11 +39,14 @@ export const LABEL_W = 8;
39
39
  * cancellerebbe l'unica informazione che la riga esiste per dare — quale valore
40
40
  * sta per essere usato.
41
41
  *
42
- * T148estratto dal detail della task perché un secondo chiamante (il
43
- * selettore modello della preview sessione) ne riusa la stessa resa a quadre,
44
- * non un componente gemello capace di divergere.
42
+ * T154/P6estratta da `ChoiceRow`, che ne resta il primo chiamante: un
43
+ * secondo (il blocco selettore della nuda sulla riga launch) ha bisogno dei
44
+ * soli bottoni dentro una cornice propria (nessun caret, un'etichetta diversa
45
+ * da `label.padEnd`), e riscriverne la resa produrrebbe la seconda copia della
46
+ * regola di troncamento e sottolineatura — quella che diverge al primo modello
47
+ * aggiunto a `MODELS`.
45
48
  */
46
- export function ChoiceRow({ label, values, index, focused, width, originIndex, }) {
49
+ export function choiceButtons(values, index, avail, originIndex) {
47
50
  const segs = values.map((v) => `[ ${v} ]`);
48
51
  const parts = [];
49
52
  segs.forEach((s, i) => {
@@ -51,11 +54,24 @@ export function ChoiceRow({ label, values, index, focused, width, originIndex, }
51
54
  parts.push(' ');
52
55
  parts.push(s);
53
56
  });
54
- const avail = Math.max(0, width - LABEL_W - CARET_OFF.length);
55
57
  let shown = cutParts(parts, avail, index * 2);
56
58
  const dropped = (v) => segs.filter((s, i) => v[i * 2] !== s).length;
57
59
  if (dropped(shown) > 0)
58
60
  shown = cutParts(parts, Math.max(0, avail - 6), index * 2);
59
61
  const cut = dropped(shown);
60
- return (_jsxs(Text, { wrap: "truncate-end", children: [focused ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: label.padEnd(LABEL_W) }), shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === index, underline: i / 2 === originIndex, color: i / 2 === index ? 'green' : 'gray', children: part }, i))), cut > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cut] }) : null] }));
62
+ const nodes = shown.map((part, i) => i % 2 === 1 ? (_jsx(Text, { children: part }, i)) : (_jsx(Text, { inverse: i / 2 === index, underline: i / 2 === originIndex, color: i / 2 === index ? 'green' : 'gray', children: part }, i)));
63
+ return { nodes, cut };
64
+ }
65
+ /**
66
+ * T117 — una riga a SCELTA di un'area di compilazione: caret + etichetta +
67
+ * bottoni di `choiceButtons`.
68
+ *
69
+ * T148 — estratto dal detail della task perché un secondo chiamante (il
70
+ * selettore modello della preview sessione) ne riusa la stessa resa a quadre,
71
+ * non un componente gemello capace di divergere.
72
+ */
73
+ export function ChoiceRow({ label, values, index, focused, width, originIndex, }) {
74
+ const avail = Math.max(0, width - LABEL_W - CARET_OFF.length);
75
+ const { nodes, cut } = choiceButtons(values, index, avail, originIndex);
76
+ return (_jsxs(Text, { wrap: "truncate-end", children: [focused ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: label.padEnd(LABEL_W) }), nodes, cut > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cut] }) : null] }));
61
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.62.1",
3
+ "version": "0.63.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": {