@lamemind/loom-deck 0.58.0 → 0.59.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
@@ -131,12 +131,36 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
131
131
  // T49 — resume di una conversazione in una nuova tab. Unico punto: lo chiamano
132
132
  // il `⏎` della lista sessioni e quello sulla riga-sessione della ricerca, che
133
133
  // devono restare la stessa azione.
134
- function resumeSession(sessionId) {
134
+ // T148 — il modello lo decide il CHIAMANTE, non questa funzione: la lista
135
+ // principale passa il selettore della preview (`model.resumeModel`), la
136
+ // ricerca passa il modello ereditato dalla sessione senza superficie di
137
+ // scelta (D1 preflight). I due chiamanti restano la stessa azione perché è
138
+ // lo spawn a essere identico, non la sorgente del valore.
139
+ function resumeSession(sessionId, modelKind) {
135
140
  const bound = model.bindings.get(sessionId) ?? null;
136
- const spawned = spawnDeckResume(bound, cwd, sessionId, model.sessionNotes.get(sessionId));
141
+ const spawned = spawnDeckResume(bound, cwd, sessionId, modelKind, model.sessionNotes.get(sessionId));
137
142
  spawned.child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
138
143
  noteSpawn(spawned);
139
144
  }
145
+ /** T148 — `m`: scorre il modello con cui la sessione selezionata riprenderà
146
+ * al successivo del catalogo. Stesso guard di `f` (serve un transcript
147
+ * vero, non basta una riga selezionata): su una pinnata stale il blocco
148
+ * preview non esiste — non c'è un bottone da cambiare. Nessuna nota di
149
+ * conferma sul successo — il feedback è il bottone stesso, già a schermo
150
+ * nella preview. */
151
+ function cycleResumeModel() {
152
+ if (model.focus !== 'sessions') {
153
+ setNote('m → modello: seleziona una sessione (→ per il pane)');
154
+ return;
155
+ }
156
+ if (!model.selSessionObj) {
157
+ setNote(model.selSessionId
158
+ ? 'm → pin stale: nessun modello da cambiare'
159
+ : 'm → nessuna sessione selezionata');
160
+ return;
161
+ }
162
+ model.cycleResumeModel();
163
+ }
140
164
  // T53 — scrive il sidecar e ricarica subito, senza attendere il tick del poll
141
165
  // (stesso feedback immediato del pin).
142
166
  //
@@ -202,7 +226,10 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
202
226
  ...(bound ? { taskId: bound } : {}),
203
227
  forkOf: s.sessionId,
204
228
  });
205
- const spawned = spawnDeckFork(bound, cwd, s.sessionId, newId);
229
+ // T148 il fork è l'altra continuità che condivide la stessa riga
230
+ // selezionata: porta via lo stesso `model.resumeModel` del resume, non un
231
+ // default fisso (P2 preflight).
232
+ const spawned = spawnDeckFork(bound, cwd, s.sessionId, newId, model.resumeModel);
206
233
  spawned.child.on('error', () => setNote(`⚠ fork fallito (${DECK_RUN})`));
207
234
  noteSpawn(spawned);
208
235
  }
@@ -310,6 +337,7 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
310
337
  spawnForTask,
311
338
  spawnTaskSession,
312
339
  resumeSession,
340
+ cycleResumeModel,
313
341
  writeNote,
314
342
  assignSession,
315
343
  forkSession,
package/dist/cli.js CHANGED
@@ -19,6 +19,8 @@ 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';
22
24
  import { deckLegend, frameGeometry, headlineWidth, indicatorRow, launchRow } from './frame.js';
23
25
  import { useDeckModel } from './deck-model.js';
24
26
  import { useDeckActions } from './actions.js';
@@ -70,7 +72,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
70
72
  hasNote: Boolean(note),
71
73
  setMode,
72
74
  setNote,
73
- onResume: (row) => actions.resumeSession(row.session.sessionId),
75
+ // T148/D1 — nessun selettore nella ricerca: il modello è quello ereditato
76
+ // dalla conversazione, senza superficie di scelta.
77
+ onResume: (row) => actions.resumeSession(row.session.sessionId, modelAlias(row.session.model) ?? MODEL_DEFAULT),
74
78
  });
75
79
  // T121 — il project status. Non è solo un overlay come i tre sopra: il suo
76
80
  // stato si legge in TESTATA a schermata chiusa, e il viewer è una delle sue
@@ -241,7 +245,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
241
245
  purgeBulk: model.purgeBulk,
242
246
  inboxPane: model.rightPane === 'inbox',
243
247
  hasInbox: model.selInbox !== null,
244
- }), 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 }), 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 })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : 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 }), 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] }));
245
249
  }
246
250
  const cwd = process.cwd();
247
251
  // T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
@@ -19,13 +19,14 @@ import { DEFAULT_INBOX_STALE_HOURS, staleCount } from './inbox.js';
19
19
  import { cycleInboxView, inboxCounts as deriveInboxCounts, inboxView, selectInboxRows, } from './inbox-views.js';
20
20
  import { docsRootName } from './tasks.js';
21
21
  import { isDone } from './layout.js';
22
- import { TASK_EMPTY, relTime } from './glyphs.js';
22
+ import { TASK_EMPTY, modelAlias, relTime } from './glyphs.js';
23
23
  import { assembleSessionList, firstSelectableId, rowIndexOf, selectedSession, } from './session-list.js';
24
24
  import { cycleSessionView, cycleTaskView, selectSessionRows, selectTasks, sessionView, taskView, } from './pane-views.js';
25
25
  import { ALL, MAX_SESSIONS, MAX_SESSIONS_ALL, META_ROWS, ROW_ALL, ROW_SPOT, SPOT, } from './model.js';
26
26
  import { applyView, taskColumns } from './view.js';
27
27
  import { loadView } from './view-store.js';
28
28
  import { termWidth } from './width.js';
29
+ import { MODELS, MODEL_DEFAULT } from './spawn.js';
29
30
  /**
30
31
  * Conteggio dei figli per task, più il rollup delle VIVE e le conversazioni
31
32
  * senza binding.
@@ -124,6 +125,12 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
124
125
  // a due gruppi + separatore è una vista trasformata, un indice grezzo punterebbe
125
126
  // alla riga sbagliata dopo un pin o un cambio di contesto (stesso trap T39).
126
127
  const [selSessionId, setSelSessionId] = useState(null);
128
+ // T148/P8 — il modello con cui la riga selezionata riprenderà (resume/fork).
129
+ // UN SOLO valore, non una mappa sessionId → scelta: si ri-inizializza a ogni
130
+ // cambio di riga (l'effect qui sotto, keyed su `selSessionId`), quindi
131
+ // tornare su una riga già visitata NON ricorda la scelta fatta lì — è lo
132
+ // stesso costo che T108 ha già accettato per il selettore del detail.
133
+ const [resumeModel, setResumeModel] = useState(MODEL_DEFAULT);
127
134
  // T39 — vista corrente (filtri + sort). Vive nel modello e non nell'hook dei
128
135
  // modali che la editano: è ciò che `applyView` consuma per produrre la lista,
129
136
  // è persistita su disco e la rilegge il tasto `w`. È stato del modello che due
@@ -275,6 +282,17 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
275
282
  setSelSessionId(firstSelectableId(sessionRows));
276
283
  }
277
284
  }, [sessionRows, selSessionId]);
285
+ // T148/P8 — reset del selettore modello all'evento «la riga selezionata è
286
+ // cambiata». Il selettore del detail (T108) si azzera all'APERTURA
287
+ // dell'overlay; questo blocco non apre e non chiude mai, quindi il suo
288
+ // evento equivalente è il cambio di `selSessionId`. Keyed SOLO su quello (non
289
+ // su `selSessionObj`, che cambia identità a ogni poll delle sessioni anche
290
+ // quando la riga selezionata resta la stessa): un'altra dipendenza
291
+ // resetterebbe la scelta dell'utente sotto un timer invece che sotto un
292
+ // movimento del cursore.
293
+ useEffect(() => {
294
+ setResumeModel(modelAlias(selSessionObj?.model ?? '') ?? MODEL_DEFAULT);
295
+ }, [selSessionId]);
278
296
  // T134 — gemello dei due sopra: un file drenato sparisce dalla coda al primo
279
297
  // scan successivo, e la selezione cade sulla prima riga della lista invece
280
298
  // che su una posizione a caso.
@@ -354,6 +372,15 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
354
372
  const next = Math.max(0, Math.min(inboxFiles.length - 1, (at < 0 ? 0 : at) + delta));
355
373
  setSelInboxPath(inboxFiles[next].path);
356
374
  }
375
+ /** T148 — `m` scorre il selettore modello della riga sessione selezionata al
376
+ * valore SUCCESSIVO del catalogo (ciclico), gemella di `cycleView` ma su un
377
+ * asse diverso: qui non c'è una vista da cambiare, solo un valore da far
378
+ * avanzare. Il guard (nessuna sessione, focus altrove) sta nell'attuatore
379
+ * `actions.ts`, come per `f`/`p`: qui c'è solo la meccanica dello stato. */
380
+ function cycleResumeModel() {
381
+ const next = MODELS[(MODELS.indexOf(resumeModel) + 1) % MODELS.length];
382
+ setResumeModel(next);
383
+ }
357
384
  /**
358
385
  * T134 — `^B` scambia i due pane dello slot destro (D8 preflight).
359
386
  *
@@ -432,6 +459,8 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, setNote, }) {
432
459
  sessionCounts,
433
460
  selSessionObj,
434
461
  sessionCols,
462
+ resumeModel,
463
+ cycleResumeModel,
435
464
  // derivazioni del pane inbox
436
465
  rightPane,
437
466
  inboxScanned: inbox.scanned,
package/dist/frame.js CHANGED
@@ -72,7 +72,7 @@ export function deckLegend(state) {
72
72
  // apertura: una legenda che annunciasse «tutte» dove il tasto ne pota una
73
73
  // sola sarebbe peggio di nessuna legenda.
74
74
  ...(state.focus === 'tasks' ? [state.purgeBulk ? 'CANC elimina tutte' : 'CANC elimina'] : []),
75
- ...(canResume ? ['f fork'] : []),
75
+ ...(canResume ? ['f fork', 'm modello'] : []),
76
76
  // T117 · D3 — «titolo» anche qui: è lo stesso valore che il detail chiede
77
77
  // allo spawn, e due nomi per una cosa sola li paga chi legge le due
78
78
  // schermate. Rename di sola ETICHETTA: il dato resta `note` nel sidecar e
package/dist/glyphs.js CHANGED
@@ -51,6 +51,23 @@ export function modelShort(id) {
51
51
  }
52
52
  return MODEL_UNKNOWN;
53
53
  }
54
+ /**
55
+ * T148/P3 — id di modello → alias del CLI, stesso catalogo di `modelShort`
56
+ * (`MODEL_SHORT`): un id vuoto o fuori famiglia (gli stessi due casi che qui
57
+ * rendono `·` e `?`) torna `null` invece di un valore inventato — il chiamante
58
+ * degrada a `MODEL_DEFAULT` senza marcare nessun bottone come provenienza,
59
+ * perché su quei due casi non c'è una provenienza vera da dichiarare.
60
+ */
61
+ export function modelAlias(id) {
62
+ if (!id)
63
+ return null;
64
+ const lower = id.toLowerCase();
65
+ for (const family of Object.keys(MODEL_SHORT)) {
66
+ if (lower.includes(family))
67
+ return family;
68
+ }
69
+ return null;
70
+ }
54
71
  // T62 — colonna liveness, larga 1, incollata al sessionId senza gutter proprio:
55
72
  // il glifo qualifica QUELL'id, e uno spazio in mezzo lo farebbe leggere come una
56
73
  // colonna a sé. Entrambi Ambiguous (EAW) → 1 colonna per il terminale e 1 cella
package/dist/input.js CHANGED
@@ -415,7 +415,7 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
415
415
  : 'nessuna sessione da riprendere');
416
416
  }
417
417
  else {
418
- actions.resumeSession(s.sessionId);
418
+ actions.resumeSession(s.sessionId, model.resumeModel);
419
419
  }
420
420
  }
421
421
  }
@@ -466,6 +466,11 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
466
466
  // selezionata del pane sessioni.
467
467
  actions.togglePin();
468
468
  }
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();
473
+ }
469
474
  else if (input === 'N') {
470
475
  // T53 — nota sulla conversazione selezionata. MAIUSCOLA perché apre un
471
476
  // modale: nel deck le minuscole sono azioni immediate (`f` fork, `p` pin,
package/dist/spawn.js CHANGED
@@ -224,14 +224,22 @@ export function spawnDeck(id, cwd, sessionId, kind, model = MODEL_DEFAULT, spawn
224
224
  // `.claude/loom-works.json`): tenerlo così evita di dare al primitive un secondo
225
225
  // file da conoscere. Il titolo si congela qui — `claude --name` lo setta una
226
226
  // volta sola, quindi una nota cambiata DOPO non ri-titola la tab già aperta.
227
- export function resumeArgs(taskId, sessionId, note) {
227
+ //
228
+ // T148 — il modello viaggia SEMPRE, come in `deckArgs`: senza `--model`
229
+ // `deck-run` risolve il proprio default fisso (`opus`) e lo passa comunque al
230
+ // CLI, quindi una conversazione haiku ripresa senza il flag ripartirebbe in
231
+ // opus (P2 preflight). Qui però non c'è un default fisso da cablare: chi
232
+ // chiama ha già risolto il valore — il modello della conversazione d'origine,
233
+ // o quello scelto al suo posto — e lo passa come argomento obbligatorio.
234
+ export function resumeArgs(taskId, sessionId, model, note) {
228
235
  const args = taskId ? [taskId, '--resume', sessionId] : ['--no-task', '--resume', sessionId];
236
+ args.push('--model', model);
229
237
  if (note)
230
238
  args.push('--title-note', note);
231
239
  return args;
232
240
  }
233
- export function spawnDeckResume(taskId, cwd, sessionId, note) {
234
- return launchDeckRun(resumeArgs(taskId, sessionId, note), cwd);
241
+ export function spawnDeckResume(taskId, cwd, sessionId, model, note) {
242
+ return launchDeckRun(resumeArgs(taskId, sessionId, model, note), cwd);
235
243
  }
236
244
  // T28 — FORK: `deck-run <task|--no-task> --resume <origine> --fork --session-id
237
245
  // <nuovo>`. Variante del resume, non una terza forma: cambia solo che CC apre un
@@ -245,7 +253,11 @@ export function spawnDeckResume(taskId, cwd, sessionId, note) {
245
253
  // nota nel sidecar — ereditare quella dell'origine metterebbe nel titolo una
246
254
  // maniglia che nella lista non compare, cioè una promessa falsa. Il fork si
247
255
  // distingue col suo marcatore, `· fork`.
248
- export function forkArgs(taskId, originId, newId) {
256
+ //
257
+ // T148 — stesso obbligo di `resumeArgs`: il fork è un'altra forma di
258
+ // continuità, quindi eredita anch'esso il modello dell'origine invece del
259
+ // default fisso di uno spawn nuovo (P2 preflight).
260
+ export function forkArgs(taskId, originId, newId, model) {
249
261
  return [
250
262
  ...(taskId ? [taskId] : ['--no-task']),
251
263
  '--resume',
@@ -253,10 +265,12 @@ export function forkArgs(taskId, originId, newId) {
253
265
  '--fork',
254
266
  '--session-id',
255
267
  newId,
268
+ '--model',
269
+ model,
256
270
  ];
257
271
  }
258
- export function spawnDeckFork(taskId, cwd, originId, newId) {
259
- return launchDeckRun(forkArgs(taskId, originId, newId), cwd);
272
+ export function spawnDeckFork(taskId, cwd, originId, newId, model) {
273
+ return launchDeckRun(forkArgs(taskId, originId, newId, model), cwd);
260
274
  }
261
275
  // T42 — sessione Claude NUDA: nessuna task, nessun prompt iniziale, nessun
262
276
  // sessionId pinnato (quindi nessuna entry nel sidecar session-tasks.jsonl: senza
@@ -2,10 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Detail della task (T66): quarta schermata sostitutiva, col markdown reso in
3
3
  // span tipizzati (T75) e la ricerca interna (T91).
4
4
  import { Box, Text } from 'ink';
5
- import { cut, cutParts } from '../width.js';
5
+ import { cut } from '../width.js';
6
6
  import { sliceLine } from '../text-search.js';
7
7
  import { sliceSpans } from '../markdown.js';
8
- import { FieldText } from './fields.js';
8
+ import { ChoiceRow, FieldText, LABEL_W } from './fields.js';
9
9
  import { DETAIL_ACTIONS, MODELS } from '../spawn.js';
10
10
  import { DROW } from '../overlays/sheet.js';
11
11
  import { CARET, CARET_OFF, WARN } from '../glyphs.js';
@@ -62,37 +62,6 @@ const PROMPT_HINT = ' · nessun prompt iniziale';
62
62
  * che nessuno ci ripassi — un'etichetta che nomina dei tasti a mano è accoppiata
63
63
  * al binding, e resta indietro appena il binding cambia. */
64
64
  const ACTION_KEYS = DETAIL_ACTIONS.map((a) => a.label[0]).join(' ');
65
- /** Prefisso incolonnato delle quattro righe dell'area di compilazione: le
66
- * etichette si leggono una sotto l'altra, quindi la larghezza è quella della
67
- * più lunga. Entra nel budget di ogni campo, da cui la costante. */
68
- const LABEL_W = 8;
69
- /** Una riga a SCELTA dell'area di compilazione: bottoni affiancati, voce attiva
70
- * in video inverso.
71
- *
72
- * Due passate di `cutParts`: la seconda serve SOLO quando qualcosa cade, e
73
- * riserva le colonne del contatore. Riservarle sempre costerebbe 6 colonne su
74
- * ogni terminale largo per un avviso che lì non comparirà mai.
75
- *
76
- * `priority` sulla voce SELEZIONATA e non sulla prima: qui il troncamento
77
- * cancellerebbe l'unica informazione che la riga esiste per dare — quale valore
78
- * sta per essere usato.
79
- */
80
- function ChoiceRow({ label, values, index, focused, width, }) {
81
- const segs = values.map((v) => `[ ${v} ]`);
82
- const parts = [];
83
- segs.forEach((s, i) => {
84
- if (i > 0)
85
- parts.push(' ');
86
- parts.push(s);
87
- });
88
- const avail = Math.max(0, width - LABEL_W - CARET_OFF.length);
89
- let shown = cutParts(parts, avail, index * 2);
90
- const dropped = (v) => segs.filter((s, i) => v[i * 2] !== s).length;
91
- if (dropped(shown) > 0)
92
- shown = cutParts(parts, Math.max(0, avail - 6), index * 2);
93
- const cut = dropped(shown);
94
- 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, color: i / 2 === index ? 'green' : 'gray', children: part }, i))), cut > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 +", cut] }) : null] }));
95
- }
96
65
  /**
97
66
  * Detail della task (T66): il task file scrollabile più l'area di compilazione.
98
67
  *
package/dist/ui/fields.js CHANGED
@@ -9,8 +9,9 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
9
9
  // il marker di riga, il detail dentro un prefisso incolonnato). Tolta di mezzo,
10
10
  // dei due gemelli resta una cosa sola.
11
11
  import { Text } from 'ink';
12
- import { caretWindow, sanitize } from '../width.js';
12
+ import { caretWindow, cutParts, sanitize } from '../width.js';
13
13
  import { cpLen } from '../layout.js';
14
+ import { CARET, CARET_OFF } from '../glyphs.js';
14
15
  /**
15
16
  * Fuori fuoco (`focused` falso) il caret non si disegna e la finestra si ancora
16
17
  * in fondo, che è la vista utile per un campo che non si sta scrivendo: `at` lì
@@ -21,3 +22,40 @@ export function FieldText({ value, caret, focused, cols, }) {
21
22
  const win = caretWindow(value, focused ? caret : cpLen(value), cols);
22
23
  return (_jsxs(_Fragment, { children: [_jsx(Text, { children: sanitize(win.head) }), focused ? _jsx(Text, { inverse: true, children: sanitize(win.at) }) : null, _jsx(Text, { children: sanitize(win.tail) })] }));
23
24
  }
25
+ /** Prefisso incolonnato delle righe a scelta/testo di un'area di compilazione:
26
+ * le etichette si leggono una sotto l'altra, quindi la larghezza è quella
27
+ * della più lunga fra le righe che la usano (T117 nel detail, T148 nella
28
+ * preview sessione). */
29
+ export const LABEL_W = 8;
30
+ /**
31
+ * T117 — una riga a SCELTA di un'area di compilazione: bottoni affiancati,
32
+ * voce attiva in video inverso.
33
+ *
34
+ * Due passate di `cutParts`: la seconda serve SOLO quando qualcosa cade, e
35
+ * riserva le colonne del contatore. Riservarle sempre costerebbe 6 colonne su
36
+ * ogni terminale largo per un avviso che lì non comparirà mai.
37
+ *
38
+ * `priority` sulla voce SELEZIONATA e non sulla prima: qui il troncamento
39
+ * cancellerebbe l'unica informazione che la riga esiste per dare — quale valore
40
+ * sta per essere usato.
41
+ *
42
+ * T148 — estratto 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.
45
+ */
46
+ export function ChoiceRow({ label, values, index, focused, width, originIndex, }) {
47
+ const segs = values.map((v) => `[ ${v} ]`);
48
+ const parts = [];
49
+ segs.forEach((s, i) => {
50
+ if (i > 0)
51
+ parts.push(' ');
52
+ parts.push(s);
53
+ });
54
+ const avail = Math.max(0, width - LABEL_W - CARET_OFF.length);
55
+ let shown = cutParts(parts, avail, index * 2);
56
+ const dropped = (v) => segs.filter((s, i) => v[i * 2] !== s).length;
57
+ if (dropped(shown) > 0)
58
+ shown = cutParts(parts, Math.max(0, avail - 6), index * 2);
59
+ 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] }));
61
+ }
@@ -4,15 +4,17 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  import { Box, Text } from 'ink';
5
5
  import { cut, wrapLines } from '../width.js';
6
6
  import { previewTextWidth } from '../layout.js';
7
- import { LIVE_BUSY, LIVE_IDLE, META_KEYS, SID_CHARS, fmtDateTime, fmtSize } from '../glyphs.js';
7
+ import { LIVE_BUSY, LIVE_IDLE, META_KEYS, SID_CHARS, fmtDateTime, fmtSize, modelAlias, } from '../glyphs.js';
8
8
  import { NATURA_SHORT, inboxMark } from '../inbox.js';
9
+ import { ChoiceRow } from './fields.js';
10
+ import { MODELS } from '../spawn.js';
9
11
  /** Colonne del prefisso delle due anteprime della preview sessione (`» `, `« `,
10
12
  * e i due spazi delle righe di continuazione). Costante perché entra nel
11
13
  * budget di wrap, che è il posto in cui dimenticarla non produce un errore ma
12
14
  * un bordo mangiato. */
13
15
  const PREVIEW_PREFIX_W = 2;
14
16
  export function PreviewPane(p) {
15
- return (_jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: p.kind === 'task' ? (_jsx(TaskPreview, { detail: p.detail, maxLines: p.maxLines, columns: p.columns })) : p.kind === 'inbox' ? (_jsx(InboxPreview, { file: p.file, columns: p.columns })) : (_jsx(SessionPreview, { s: p.s, firstLines: p.firstLines, lastLines: p.lastLines, columns: p.columns, origin: p.origin, note: p.note, live: p.live })) }));
17
+ return (_jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: p.kind === 'task' ? (_jsx(TaskPreview, { detail: p.detail, maxLines: p.maxLines, columns: p.columns })) : p.kind === 'inbox' ? (_jsx(InboxPreview, { file: p.file, columns: p.columns })) : (_jsx(SessionPreview, { s: p.s, firstLines: p.firstLines, lastLines: p.lastLines, columns: p.columns, origin: p.origin, note: p.note, live: p.live, resumeModel: p.resumeModel })) }));
16
18
  }
17
19
  /**
18
20
  * T134 — corpo della preview inbox: due righe FISSE, contate da
@@ -45,7 +47,7 @@ export function InboxPreview({ file, columns }) {
45
47
  // SOLO con un titolo custom — senza, il titolo È già il primo prompt e la riga
46
48
  // lo duplicherebbe (D4 preflight). Le righe rese non superano mai il riservato
47
49
  // dal budget (`firstLines`/`lastLines`); renderne meno è sicuro (frame più corto).
48
- export function SessionPreview({ s, firstLines, lastLines, columns, origin, note, live, }) {
50
+ export function SessionPreview({ s, firstLines, lastLines, columns, origin, note, live, resumeModel, }) {
49
51
  // Il prefisso `» `/`« ` è largo 2 e sta SULLA riga: va tolto dal budget di
50
52
  // wrap, o una riga piena esce dal box di quelle due colonne e a raccoglierla
51
53
  // arriva `cli-truncate`, che sfora di una colonna per emoji e mangia il bordo
@@ -57,7 +59,12 @@ export function SessionPreview({ s, firstLines, lastLines, columns, origin, note
57
59
  const width = previewTextWidth(columns) - PREVIEW_PREFIX_W;
58
60
  const first = s.customTitle && firstLines > 0 ? wrapLines(s.firstPrompt, width, firstLines) : [];
59
61
  const last = s.lastReply && lastLines > 0 ? wrapLines(s.lastReply, width, lastLines) : [];
60
- return (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), " \u00B7 ", s.gitBranch || '-', s.model ? ` · ${s.model}` : '', origin ? ` · ⑂ da ${origin.slice(0, 8)}` : '', live ? (_jsx(Text, { color: live.status === 'busy' ? 'yellow' : 'green', children: ` · ${live.status === 'busy' ? LIVE_BUSY : LIVE_IDLE} viva pid ${live.pid} (${live.status})` })) : null] }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
62
+ // T148/P3 l'alias del modello D'ORIGINE, per la sottolineatura (P5):
63
+ // `undefined` quando l'id non mappa su nessuna famiglia nota, cioè quando
64
+ // non c'è una provenienza vera da dichiarare.
65
+ const originAlias = modelAlias(s.model);
66
+ const originIndex = originAlias ? MODELS.indexOf(originAlias) : undefined;
67
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', note ? _jsxs(Text, { color: "yellow", children: ["\u00AB", note, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(note), children: s.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 ", fmtDateTime(s.ts), " \u00B7 ", s.gitBranch || '-', s.model ? ` · ${s.model}` : '', origin ? ` · ⑂ da ${origin.slice(0, 8)}` : '', live ? (_jsx(Text, { color: live.status === 'busy' ? 'yellow' : 'green', children: ` · ${live.status === 'busy' ? LIVE_BUSY : LIVE_IDLE} viva pid ${live.pid} (${live.status})` })) : null] }), _jsx(ChoiceRow, { label: "modello", values: MODELS, index: Math.max(0, MODELS.indexOf(resumeModel)), focused: false, width: previewTextWidth(columns), originIndex: originIndex }), first.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '» ' : ' ', line] }, `f${i}`))), last.map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [i === 0 ? '« ' : ' ', line] }, `l${i}`)))] }));
61
68
  }
62
69
  /**
63
70
  * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
package/dist/viewport.js CHANGED
@@ -38,9 +38,11 @@ const TASKS_PANE_CHROME = 6; // 2 bordi + header "Tasks (n)" + riga sort + 2 rig
38
38
  const SESSIONS_PANE_CHROME = 3; // 2 bordi + header "Sessions · …"
39
39
  const PREVIEW_CHROME = 3; // marginTop + 2 bordi
40
40
  /** Preview sessione: righe fisse = titolo + riga meta (size · turni · ultima
41
- * attività · branch). Le due anteprime (primo prompt + ultima risposta) sono
42
- * variabili, ciascuna al più MAX_SESSION_PREVIEW righe. */
43
- const SESSION_DETAIL_FIXED = 2;
41
+ * attività · branch) + riga bottoni del modello di resume/fork (T148/P4). Le
42
+ * due anteprime (primo prompt + ultima risposta) sono variabili, ciascuna al
43
+ * più MAX_SESSION_PREVIEW righe. Conseguenza accettata: su un terminale basso
44
+ * il blocco sparisce con una riga di anticipo rispetto a prima di T148. */
45
+ const SESSION_DETAIL_FIXED = 3;
44
46
  const MAX_SESSION_PREVIEW = 3;
45
47
  /** T134 — preview inbox: nome del file + riga meta (natura, marcatori, branch,
46
48
  * cappello, cifre, età). Righe TUTTE fisse, nessuna variabile: il corpo del
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.58.0",
3
+ "version": "0.59.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": {