@lamemind/loom-deck 0.62.1 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,7 +74,7 @@ Regola unica, senza eccezioni — pensata per reggere l'aggiunta di nuove azioni
74
74
  | `⏎` | azione primaria del pane | Tasks → apre il **detail** della task selezionata · Sessions → riprende (`claude --resume`) la sessione selezionata |
75
75
  | **MAIUSCOLA** | **apre un modale** | cattura tutti i tasti; `esc` annulla, non esce |
76
76
  | minuscola | azione immediata, one-shot | |
77
- | `CTRL`+lettera | idiomi universali, toggle dentro i modali di testo, varianti di un'azione | `^F` = find, come ovunque · `^K`/`^P`/`^R` = spawn con un altro prompt · `^G`/`^O` = genera/apri il project status |
77
+ | `CTRL`+lettera | idiomi universali, toggle dentro i modali di testo, varianti di un'azione | `^F` = find, come ovunque · `^K`/`^P`/`^R` = spawn con un altro prompt · `^G`/`^O` = genera/apri il project status · `^U` = update del gitlink |
78
78
  | `1`…`9` | voce `launch` n-esima del progetto | da `.claude/loom-works.json` |
79
79
  | `esc` | chiude l'overlay aperto | in modalità normale è inerte: **nessuna lettera chiude il deck** |
80
80
  | `^C` `^C` | chiude il deck | la prima pressione avverte in riga di stato, la seconda entro **5 secondi** esce; passata la finestra si riparte dall'avviso. Da dentro un modale il primo `^C` riporta alla lista — l'avviso vive nella riga di stato, che le schermate a pieno frame non disegnano |
@@ -96,6 +96,7 @@ Assegnazioni correnti:
96
96
  | modale | `CANC` | **elimina** — la task selezionata; su una riga meta della vista `archiviabili`, l'intero insieme |
97
97
  | modale | `^O` | apre il **project status** in cache (viewer fullscreen) |
98
98
  | immediata | `^G` | **genera** il project status (skill headless, dura minuti) |
99
+ | immediata | `^U` | **bumpa il gitlink** dei submodule idonei (solo su un progetto con `.gitmodules`) |
99
100
  | immediata | `^K` | spawna la task selezionata col prompt di **recap** |
100
101
  | immediata | `^P` | spawna la task selezionata sul **preflight** |
101
102
  | immediata | `^R` | spawna la task selezionata in **esecuzione** |
@@ -108,7 +109,7 @@ Assegnazioni correnti:
108
109
  Il footer è **due righe con due nature diverse**:
109
110
 
110
111
  ```
111
- ⏎ detail · ^K/^P/^R spawn · ^G genera status · ^O apri status · ^F cerca · C nuova · E edit · S sort · F filtri · w salva
112
+ ⏎ detail · ^K/^P/^R spawn · ^G genera status · ^O apri status · ^U bumpa gitlink · ^F cerca · C nuova · E edit · S sort · F filtri · w salva
112
113
  t 💻 · c 🤖 · 1 📝 codium · 2 ☕ idea
113
114
  ```
114
115
 
@@ -274,6 +275,38 @@ Il recap vive in un file markdown sotto `/tmp/loom-deck-status-<uid>/`, col nome
274
275
 
275
276
  A generazione finita arriva una notifica desktop (`notify-send`): la generazione dura minuti e nel frattempo si guarda altrove, e la notifica di GNOME persiste nel cassetto — cosa che un suono non farebbe. Senza `notify-send` installato non succede **niente**: nessuna riga, nessun messaggio. L'assenza di un binario è una proprietà stabile della macchina, non un evento, e segnalarla a ogni generazione sarebbe rumore ripetuto su una funzione accessoria mentre il deliverable vero è arrivato comunque.
276
277
 
278
+ ### `^U` — il gitlink dei submodule
279
+
280
+ Su un progetto **cappello** (un repo che aggrega altri repo come submodule) ogni membro è pinnato a un commit fisso. Quando il membro avanza — un commit nel deck, nel plugin — quel puntatore resta indietro finché qualcuno non lo bumpa, e il gesto vive nella memoria di chi committa. Il deck lo mette sotto gli occhi e lo rende un tasto.
281
+
282
+ L'indicatore sta in coda alla riga della legenda, accanto agli altri due:
283
+
284
+ ```
285
+ gitlink 2 wrap 77 [ Inbox 📄 3/1/2 ]
286
+ ```
287
+
288
+ | Corpo | Significato |
289
+ |---|---|
290
+ | _(assente)_ | il progetto non ha `.gitmodules`: nessun timer, nessuno spawn, nessuna colonna spesa |
291
+ | `missing` | prima misura non ancora tornata |
292
+ | `?` | plugin loom-works non installato su questa macchina, o script in errore |
293
+ | `ok` | ogni membro coincide col proprio gitlink |
294
+ | `2` | due membri col checkout diverso dal gitlink |
295
+ | `⚠` in coda | l'ultimo bump è fallito, **oppure** un membro richiede un gesto che il bump non fa |
296
+
297
+ Nel numero entrano solo i membri che un bump può sistemare. Un submodule non inizializzato o in conflitto di merge resta fuori e si dice col glifo: sommarlo darebbe un contatore che il tasto non porta mai a zero, e un contatore così smette di essere letto.
298
+
299
+ **`^U` bumpa**, senza conferma: il commit è locale e si annulla con un `git reset` che non tocca nessun remote. Il deck non pusha il cappello — quel gesto resta al flusso normale, perché un gitlink pushato per sbaglio è già nella copia di chiunque abbia pullato.
300
+
301
+ Il deck **non classifica da sé**: spawna `bump-gitlink.sh` del plugin (in `--dry-run` per la misura, nudo per il bump) e mostra il TSV che ne esce. Nessuna sessione Claude in mezzo — è un `execFile` con esito sincrono, come il contatore inbox e lo scan hard-wrap. Lo stesso script si lancia a mano da shell, ed è lì che vivono le due guardie che rendono il bump sicuro:
302
+
303
+ - **il verso** — `git submodule status` marca con `+` un checkout diverso dal gitlink **in entrambi i versi**, senza distinguerli. Un cappello pullato senza `git submodule update` ha il checkout più vecchio del gitlink e porta lo stesso `+`: bumparlo pinnerebbe il cappello a un commit precedente. Quel membro si salta, e la riga di stato nomina `git submodule update`.
304
+ - **il remote** — il cappello può pinnare solo commit già raggiungibili dal remote del membro. Un gitlink verso un commit locale produce un cappello che si clona ma non si ricostruisce, e il difetto è invisibile a chi lo introduce: nella sua copia quel commit esiste. Quel membro si salta, e la riga di stato nomina `git -C <membro> push`.
305
+
306
+ La riga di stato dopo il tasto riporta **un esito per membro**, non un totale: nel caso misto — uno pushato, uno no — un riassunto direbbe «1 bumpato» e lascerebbe fuori proprio il comando che sblocca l'altro.
307
+
308
+ La misura gira ogni 30 secondi e subito dopo un bump. Il `--dry-run` non contatta la rete: il `fetch` che precede la guardia sul remote gira solo nel bump, quindi il contatore può dire «bumpabile» di un commit che il bump poi salta perché sparito dal remote. La guardia sta sull'azione, e un contatore non è un'autorizzazione.
309
+
277
310
  ## Vista: filtri e ordinamenti
278
311
 
279
312
  La lista è una **vista** sulla `tasks.md`: si filtra e si ordina senza toccare il file.
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';
@@ -31,6 +32,7 @@ import { useAssignOverlay } from './overlays/assign.js';
31
32
  import { useProjectStatus } from './overlays/status.js';
32
33
  import { useInboxOverlay } from './overlays/inbox.js';
33
34
  import { useWrapOverlay } from './overlays/wrap.js';
35
+ import { useGitlink } from './overlays/gitlink.js';
34
36
  import { usePurgeOverlay } from './overlays/purge.js';
35
37
  import { useTextModals, useViewModals } from './overlays/modals.js';
36
38
  import { useTerminalSize } from './hooks.js';
@@ -109,6 +111,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
109
111
  setNote,
110
112
  onApply: actions.unwrapPath,
111
113
  });
114
+ // T155 — il gitlink dei submodule. Terzo sensore con lo stato in vista
115
+ // normale, dopo il project status e l'hard-wrap: qui però non c'è nessuna
116
+ // schermata da aprire — l'unica superficie è l'indicatore, e l'unica azione
117
+ // committa.
118
+ const gitlink = useGitlink({ cwd, setNote });
112
119
  const purge = usePurgeOverlay({
113
120
  setMode,
114
121
  setNote,
@@ -138,6 +145,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
138
145
  status,
139
146
  inbox,
140
147
  wrap,
148
+ gitlink,
141
149
  purge,
142
150
  view: viewModals,
143
151
  text: textModals,
@@ -162,10 +170,26 @@ function Deck({ cwd, tasksPath, tasksDir }) {
162
170
  : 'none';
163
171
  const detailParts = model.detail ? detailMetaOf(model.detail) : null;
164
172
  const launch = launchRow(model.launch, columns);
173
+ // T154 — i bottoni del blocco selettore della nuda: stessa primitiva
174
+ // (`choiceButtons`) del selettore di resume/fork, sulle SHORT (D1) e con
175
+ // `avail` pari alla larghezza naturale del gruppo, così `cutParts` non
176
+ // taglia mai (il blocco è riservato, non conteso — vedi `launchRow`).
177
+ const bareButtons = choiceButtons(MODEL_SHORT_LIST, MODELS.indexOf(model.bareModel), BARE_BUTTONS_WIDTH, MODELS.indexOf(MODEL_DEFAULT));
165
178
  // T134 — gli indicatori ancorati a destra della riga legenda. Si compongono
166
179
  // PRIMA della legenda perché è la loro larghezza a decidere quanto ne resta:
167
180
  // hanno la precedenza sul budget (D5 preflight).
168
181
  const indicators = indicatorRow({
182
+ // T155 — `null` chiude il gate: su un progetto senza `.gitmodules` il
183
+ // segmento non esiste, e la riga legenda si riprende quelle colonne.
184
+ gitlink: gitlink.enabled
185
+ ? {
186
+ count: gitlink.count,
187
+ attention: gitlink.attention,
188
+ scanned: gitlink.scanned,
189
+ ok: gitlink.ok,
190
+ failed: gitlink.failed,
191
+ }
192
+ : null,
169
193
  inbox: {
170
194
  counts: model.inboxCounts,
171
195
  stale: model.inboxStale,
@@ -245,7 +269,8 @@ function Deck({ cwd, tasksPath, tasksDir }) {
245
269
  purgeBulk: model.purgeBulk,
246
270
  inboxPane: model.rightPane === 'inbox',
247
271
  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] }));
272
+ gitlink: gitlink.enabled,
273
+ }), 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
274
  }
250
275
  const cwd = process.cwd();
251
276
  // 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
@@ -83,6 +106,9 @@ export function deckLegend(state) {
83
106
  // cosa e sta a due voci di distanza.
84
107
  '^G genera status',
85
108
  '^O apri status',
109
+ // T155 — la voce nomina il GESTO, come le due sopra: «gitlink» da solo
110
+ // direbbe l'oggetto e non che il tasto lo allinea con un commit.
111
+ ...(state.gitlink ? ['^U bumpa gitlink'] : []),
86
112
  // T134 — la voce nomina il pane che il tasto MONTA, non quello montato:
87
113
  // `^B` scambia i due, e annunciare quello che si sta già guardando
88
114
  // direbbe il contrario di ciò che il tasto fa. Stessa regola di `CANC
@@ -102,21 +128,46 @@ export function deckLegend(state) {
102
128
  * seconde ri-splittando il primo sarebbe un conto parallelo, che diverge alla
103
129
  * prima label che contenga il separatore.
104
130
  *
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.
131
+ * Le celle della surface (più il ` · ` che la separa dalle voci) sono già spese
132
+ * sulla riga → vanno riservate, o le voci launch la sfonderebbero di quel
133
+ * tanto. T154/P8 — il blocco selettore della nuda entra nella STESSA riserva:
134
+ * su un progetto con molte voci `launch` a cedere per prima è una voce custom,
135
+ * mai il selettore (un bottone di modello troncato sarebbe indistinguibile da
136
+ * un modello che non esiste, una voce launch resta raggiungibile col suo
137
+ * tasto-cifra anche quando non è scritta in riga).
107
138
  */
108
139
  export function launchRow(launch, columns) {
109
140
  const surfaceLegend = SURFACE_SEGMENTS.map((s) => s.text).join(LAUNCH_SEP);
110
- const legend = launchLegend(launch, columns, cellWidth(surfaceLegend) + cellWidth(LAUNCH_SEP));
141
+ const reserved = cellWidth(surfaceLegend) + cellWidth(LAUNCH_SEP) + BARE_BLOCK_WIDTH;
142
+ const legend = launchLegend(launch, columns, reserved);
111
143
  const segments = [
112
144
  ...SURFACE_SEGMENTS,
113
145
  ...legend.taken.map((text, i) => ({ key: String(i + 1), text })),
114
146
  ];
147
+ // Larghezza REALE della sinistra, testo di overflow/irraggiungibili incluso:
148
+ // sono loro a decidere quanto riempimento serve, non solo i segmenti.
149
+ const leftWidth = termWidth(segments.map((s) => s.text).join(LAUNCH_SEP)) +
150
+ (legend.overflow > 0 ? termWidth(` · +${legend.overflow} fuori riga`) : 0) +
151
+ (legend.unreachable > 0
152
+ ? termWidth(` · ${legend.unreachable} oltre la 9ª (non raggiungibili)`)
153
+ : 0);
154
+ // T154/P5 — la formula dell'ancoraggio: colonne del bordo esterno, meno la
155
+ // sinistra già disegnata, meno il blocco che deve starci per intero. Un
156
+ // pavimento di 1 tiene sempre almeno uno spazio fra le due metà, invece di
157
+ // farle toccare quando lo schermo è troppo stretto per il resto.
158
+ const filler = ' '.repeat(Math.max(1, columns - 6 - leftWidth - BARE_BLOCK_WIDTH));
159
+ const bareCol = FRAME_TEXT_COL + leftWidth + termWidth(filler);
160
+ const bareRegion = {
161
+ key: BARE_LABEL.key,
162
+ start: bareCol,
163
+ end: bareCol + termWidth(BARE_LABEL.text) - 1,
164
+ };
115
165
  return {
116
166
  segments,
117
- regions: rowRegions(segments, LAUNCH_SEP, FRAME_TEXT_COL),
167
+ regions: [...rowRegions(segments, LAUNCH_SEP, FRAME_TEXT_COL), bareRegion],
118
168
  overflow: legend.overflow,
119
169
  unreachable: legend.unreachable,
170
+ filler,
120
171
  };
121
172
  }
122
173
  /**
@@ -184,11 +235,47 @@ export function wrapIndicator(state) {
184
235
  : String(state.count);
185
236
  return sanitize(`wrap ${body}${state.ok ? '' : ` ${WARN}`}`);
186
237
  }
238
+ /**
239
+ * T155 — l'indicatore del gitlink: `gitlink 2`.
240
+ *
241
+ * Conta i membri il cui checkout NON coincide col gitlink registrato, in
242
+ * entrambi i versi (`disalignedCount` in `gitlink.ts`). Fuori dal numero restano
243
+ * i membri che nessun bump sistema — non inizializzati, in conflitto — perché un
244
+ * contatore che il tasto non può portare a zero smette di essere letto: è la
245
+ * stessa regola per cui `misto` non entra nel contatore dell'hard-wrap.
246
+ *
247
+ * Quattro corpi distinti, come per gli altri due indicatori: `missing` finché la
248
+ * prima misura non è tornata, `?` quando lo script non è raggiungibile (plugin
249
+ * non installato su questa macchina, o uscito male), il numero quando c'è
250
+ * qualcosa da bumpare, `ok` quando tutti i membri coincidono.
251
+ *
252
+ * Il glifo di allerta si AGGIUNGE al corpo invece di sostituirlo, e copre i due
253
+ * fatti che il numero non dice: l'ultimo bump è fallito, oppure un membro
254
+ * richiede un gesto che il bump non fa. Entrambi restano veri accanto al
255
+ * conteggio, e chi lo vede preme il tasto per leggere la riga di stato, che
256
+ * nomina i membri.
257
+ */
258
+ export function gitlinkIndicator(state) {
259
+ const body = !state.scanned
260
+ ? STATUS_MISSING
261
+ : !state.ok
262
+ ? '?'
263
+ : state.count > 0
264
+ ? String(state.count)
265
+ : 'ok';
266
+ const warn = state.failed || state.attention > 0 ? ` ${WARN}` : '';
267
+ return sanitize(`gitlink ${body}${warn}`);
268
+ }
187
269
  export function indicatorRow(state, columns) {
188
- // Ordine di lettura: l'indicatore, poi il bottone. Il bottone sta all'estremo
189
- // destro perché è l'unico dei due che MONTA qualcosa restando in vista
190
- // normale — la lista del wrap è una schermata che si apre e si chiude.
270
+ // Ordine di lettura: gli indicatori, poi il bottone. Il bottone sta
271
+ // all'estremo destro perché è l'unico dei tre che MONTA qualcosa restando in
272
+ // vista normale — la lista del wrap è una schermata che si apre e si chiude.
273
+ //
274
+ // Il gitlink sta in TESTA al blocco, che è ancorato al bordo destro: è l'unico
275
+ // segmento che può mancare del tutto (il gate su `.gitmodules`), e da lì la
276
+ // sua assenza non sposta gli altri due.
191
277
  const segments = [
278
+ ...(state.gitlink ? [{ key: '^u', text: gitlinkIndicator(state.gitlink) }] : []),
192
279
  { key: '^w', text: wrapIndicator(state.wrap) },
193
280
  { key: '^b', text: inboxButton(state.inbox) },
194
281
  ];
@@ -0,0 +1,210 @@
1
+ // T155 — il gitlink dei submodule come dato del deck.
2
+ //
3
+ // Il cappello pinna ogni membro a un commit fisso (gitlink `160000`). Quando un
4
+ // submodule avanza — un commit nel deck, nel plugin, in compass — il gitlink
5
+ // resta indietro finché qualcuno non lo bumpa a mano.
6
+ //
7
+ // LA CLASSIFICAZIONE NON STA QUI (P1 preflight). Il deck spawna
8
+ // `bump-gitlink.sh --dry-run` del plugin e mostra il TSV che ne esce: la stessa
9
+ // sequenza che poi committa è quella che ha misurato, quindi il sensore non può
10
+ // dire «bumpabile» in base a un criterio diverso da quello del bump. Replicarla
11
+ // in TypeScript darebbe due implementazioni delle due guardie — quella sul verso
12
+ // e quella sul remote — e una divergenza fra le due si vedrebbe solo come un
13
+ // cappello pinnato su un commit sbagliato, giorni dopo, nel clone di qualcun
14
+ // altro. Stesso confine di `inbox.ts` e `wrap-scan.ts`, e non quello di
15
+ // `purge.ts`, che passa da una sessione Claude: qui non c'è nessun prompt, solo
16
+ // un `execFile` con esito sincrono.
17
+ //
18
+ // Lettore puro + spawn: nessun React, nessuna resa. È l'unico modo di provarlo
19
+ // senza pseudo-terminale.
20
+ import { execFile } from 'node:child_process';
21
+ import { existsSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { promisify } from 'node:util';
24
+ import { pluginScript } from './plugin-cache.js';
25
+ const execFileAsync = promisify(execFile);
26
+ export const GITLINK_SCRIPT = 'scripts/utils/bump-gitlink.sh';
27
+ /**
28
+ * Ogni 30 secondi (P2 preflight).
29
+ *
30
+ * Il gate che regge gli altri poll stretti del deck qui NON funziona: il
31
+ * disallineamento nasce da un commit dentro il submodule, che lascia fermi sia
32
+ * `HEAD` del cappello (`commit-times.ts`) sia la mtime dei task file
33
+ * (`epic-hierarchy.ts`) — non c'è niente di economico da misurare prima. Resta
34
+ * il costo nudo, misurato sul cappello a tre submodule: 0,03s per giro, dentro
35
+ * il budget che T153 ha fissato per il loop.
36
+ *
37
+ * Il dry-run NON contatta la rete (P11): la guardia col `fetch` gira solo nel
38
+ * bump. Ne discende che il sensore può contare «bumpabile» un commit che il
39
+ * bump poi salta perché sparito dal remote — la guardia sta sull'AZIONE, e un
40
+ * contatore non è un'autorizzazione.
41
+ */
42
+ export const GITLINK_SCAN_INTERVAL_MS = 30 * 1000;
43
+ /**
44
+ * Gli stati che lo script emette, più `sconosciuto` che è del solo lettore.
45
+ *
46
+ * I primi tre vengono dai prefissi di `git submodule status`; gli altri
47
+ * spacchettano il prefisso `+`, che da solo NON dice il verso — `man
48
+ * git-submodule`: «`+` if the currently checked out submodule commit does not
49
+ * match the SHA-1 found in the index». Un checkout più vecchio del gitlink
50
+ * (cappello pullato senza `git submodule update`) porta lo stesso `+` di un
51
+ * commit nuovo nel membro, e le due situazioni hanno rimedi opposti.
52
+ *
53
+ * `bumpato` esiste solo nell'output del bump: nel dry-run un membro idoneo resta
54
+ * `avanti`. Sono due fatti diversi — «lo bumperei» e «l'ho bumpato» — e un solo
55
+ * nome per entrambi renderebbe la riga di stato indistinguibile da un'anteprima.
56
+ */
57
+ export const GITLINK_STATES = [
58
+ 'allineato',
59
+ 'non-inizializzato',
60
+ 'conflitto',
61
+ 'avanti',
62
+ 'indietro',
63
+ 'non-pushato',
64
+ 'remote-irraggiungibile',
65
+ 'bumpato',
66
+ ];
67
+ const KNOWN = new Set(GITLINK_STATES);
68
+ /**
69
+ * Il TSV di `bump-gitlink.sh`: una riga per submodule, `path ⇥ stato ⇥ dettaglio`.
70
+ * Nessuna riga di intestazione — il primo campo è già un path.
71
+ *
72
+ * Uno stato che il deck non conosce diventa `sconosciuto` invece di sparire: la
73
+ * riga la si è comunque ricevuta, e scartarla farebbe calare il contatore ogni
74
+ * volta che lo script guadagna uno stato nuovo — cioè proprio quando il deck ha
75
+ * più bisogno di dirlo. Il dettaglio è l'ULTIMO campo e si ricompone: può
76
+ * contenere un comando, e un comando può contenere un tab.
77
+ */
78
+ export function parseGitlinkTsv(stdout) {
79
+ const out = [];
80
+ for (const line of stdout.split('\n')) {
81
+ if (!line.trim())
82
+ continue;
83
+ const c = line.split('\t');
84
+ if (c.length < 2 || !c[0])
85
+ continue;
86
+ out.push({
87
+ path: c[0],
88
+ state: KNOWN.has(c[1]) ? c[1] : 'sconosciuto',
89
+ detail: c.slice(2).join('\t'),
90
+ });
91
+ }
92
+ return out;
93
+ }
94
+ /**
95
+ * Gli stati che il contatore somma: i membri il cui checkout non coincide col
96
+ * gitlink, in entrambi i versi.
97
+ *
98
+ * `non-inizializzato` e `conflitto` restano FUORI per decisione: nessun bump li
99
+ * sistema — il primo vuole un `git submodule update --init`, il secondo la
100
+ * risoluzione di un merge — e sommarli darebbe un numero che il tasto non può
101
+ * portare a zero. Un contatore che non arriva mai a zero smette di essere letto
102
+ * (stessa regola di `misto` nell'indicatore hard-wrap). Si dicono comunque, col
103
+ * glifo di allerta accanto al numero.
104
+ */
105
+ const DISALIGNED = new Set([
106
+ 'avanti',
107
+ 'indietro',
108
+ 'non-pushato',
109
+ 'remote-irraggiungibile',
110
+ ]);
111
+ /** Quanti membri hanno il checkout diverso dal gitlink: è il numero a schermo. */
112
+ export function disalignedCount(members) {
113
+ return members.filter((m) => DISALIGNED.has(m.state)).length;
114
+ }
115
+ /** Quanti sono bumpabili adesso: il solo `avanti` passa entrambe le guardie. */
116
+ export function bumpableCount(members) {
117
+ return members.filter((m) => m.state === 'avanti').length;
118
+ }
119
+ /**
120
+ * Qualcosa richiede attenzione ma nessun bump lo sistema: un membro non
121
+ * inizializzato, un conflitto di merge, uno stato che questo deck non conosce.
122
+ * Vale il glifo di allerta, mai una cifra nel contatore.
123
+ */
124
+ export function attentionCount(members) {
125
+ return members.filter((m) => m.state === 'non-inizializzato' || m.state === 'conflitto' || m.state === 'sconosciuto').length;
126
+ }
127
+ /**
128
+ * Gli stati che il bump ha SALTATO: il membro è disallineato ma una delle due
129
+ * guardie l'ha fermato, e il rimedio non è un bump ma il gesto che il dettaglio
130
+ * nomina.
131
+ */
132
+ const SKIPPED = new Set(['indietro', 'non-pushato', 'remote-irraggiungibile']);
133
+ /**
134
+ * La riga di stato dopo un bump: un esito per membro, non un totale.
135
+ *
136
+ * Il caso che rende necessario nominarli è il MISTO — un membro pushato e uno
137
+ * no. Lì un messaggio riassuntivo direbbe «1 bumpato» e lascerebbe fuori proprio
138
+ * l'informazione che serve ad agire: quale membro è rimasto indietro e con quale
139
+ * comando lo si sblocca. Il gesto arriva già scritto dallo script, che è dove la
140
+ * guardia lo ha deciso: comporlo qui darebbe due frasi da tenere allineate.
141
+ *
142
+ * I bumpati vengono per primi — è l'esito che il tasto prometteva — e i saltati
143
+ * dopo, ognuno col proprio glifo di allerta. I membri allineati non compaiono:
144
+ * la riga di stato dice cosa è successo, non cosa esiste.
145
+ */
146
+ export function gitlinkNote(members) {
147
+ const done = members
148
+ .filter((m) => m.state === 'bumpato')
149
+ .map((m) => `✔ ${m.path} ${m.detail}`.trim());
150
+ const skipped = members
151
+ .filter((m) => SKIPPED.has(m.state))
152
+ .map((m) => `⚠ ${m.path}: ${m.detail}`);
153
+ return [...done, ...skipped].join(' · ');
154
+ }
155
+ /**
156
+ * Il progetto ha submodule da misurare.
157
+ *
158
+ * È il gate di ATTIVAZIONE dell'intero sensore (DLV2): senza `.gitmodules` non
159
+ * parte nessun timer, non si spawna niente e l'indicatore non compare affatto —
160
+ * non «0», che occuperebbe colonne per dire che la funzione non si applica.
161
+ *
162
+ * Il file, non `git submodule status`: rispondere alla domanda «ci sono
163
+ * submodule?» con lo stesso spawn che si vuole evitare sarebbe la misura che il
164
+ * gate esiste per non pagare.
165
+ */
166
+ export function hasGitmodules(projectRoot) {
167
+ return existsSync(join(projectRoot, '.gitmodules'));
168
+ }
169
+ /**
170
+ * Invoca lo script e ne parsa l'output.
171
+ *
172
+ * I modi di non avere il dato — plugin non installato su questa macchina,
173
+ * script in errore, git muto — collassano in un solo `ok: false`, come per la
174
+ * coda inbox: a schermo non cambiano la prima mossa di chi li vede. Nessuno dei
175
+ * due è un throw, un indicatore informativo non può rompere il deck.
176
+ *
177
+ * Lo stdout si legge ANCHE quando l'exit è non-zero: lo script classifica tutti
178
+ * i membri prima di agire, quindi su un errore parziale (un `git` che fallisce
179
+ * su un membro) le righe già emesse restano vere e dicono più di una schermata
180
+ * vuota.
181
+ */
182
+ async function run(projectRoot, args) {
183
+ const script = pluginScript(GITLINK_SCRIPT);
184
+ if (!script)
185
+ return { members: [], ok: false };
186
+ try {
187
+ const { stdout } = await execFileAsync(script, args, {
188
+ cwd: projectRoot,
189
+ maxBuffer: 1024 * 1024,
190
+ });
191
+ return { members: parseGitlinkTsv(stdout), ok: true };
192
+ }
193
+ catch (e) {
194
+ const stdout = e.stdout ?? '';
195
+ return { members: parseGitlinkTsv(stdout), ok: false };
196
+ }
197
+ }
198
+ /** La misura, senza toccare niente: nessun fetch, nessun commit. */
199
+ export function scanGitlink(projectRoot) {
200
+ return run(projectRoot, ['--dry-run']);
201
+ }
202
+ /**
203
+ * Il bump vero: fetch, due guardie, commit unico coi soli membri idonei. Il
204
+ * cappello NON viene pushato — quel gesto resta al flusso normale, perché un
205
+ * gitlink committato per sbaglio si annulla con un `git reset` che non tocca
206
+ * nessun remote, mentre uno pushato è già nella copia di chiunque abbia pullato.
207
+ */
208
+ export function bumpGitlink(projectRoot) {
209
+ return run(projectRoot, []);
210
+ }
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
@@ -322,6 +322,19 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
322
322
  else if (input === 'e') {
323
323
  overlays.wrap.scan();
324
324
  }
325
+ else if (input === 'u') {
326
+ // T155/P5 preflight — `^U` (update del gitlink) bumpa i submodule
327
+ // idonei. Non è la coppia genera/apri di `^G`/`^O` e `^W`/`^E`: qui non
328
+ // c'è nessuna schermata da aprire, la misura gira da sé ogni 30 secondi
329
+ // e l'unico gesto è l'azione. Nessuna conferma, perché il commit è
330
+ // locale e reversibile (P6).
331
+ //
332
+ // `^B` era la mnemonica migliore per «bump» ed è di T134 (scambia i due
333
+ // pane dello slot destro). `^U` svuota un campo dentro i modi capturing,
334
+ // ma quelli consumano l'input per intero e non vedono mai questo ramo —
335
+ // stesso doppio significato per contesto già in esercizio su `^F`.
336
+ overlays.gitlink.bump();
337
+ }
325
338
  else if (input === 'b') {
326
339
  // T134/D8 preflight — `^B` (box/bacheca) scambia i due pane dello slot
327
340
  // destro. Non è un modale né una schermata: il pane resta uno dei due
@@ -467,9 +480,16 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
467
480
  actions.togglePin();
468
481
  }
469
482
  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();
483
+ // T148/T154bersaglio deciso dal focus E dalla selezione (P2): con
484
+ // una riga sessioni selezionata (stale compresa, P3) scorre il modello
485
+ // di resume/fork di quella riga; altrove (focus task, focus inbox,
486
+ // pane sessioni senza selezione) scorre il modello della sessione nuda.
487
+ if (model.focus === 'sessions' && model.selSessionId) {
488
+ actions.cycleResumeModel();
489
+ }
490
+ else {
491
+ actions.cycleBareModel();
492
+ }
473
493
  }
474
494
  else if (input === 'N') {
475
495
  // T53 — nota sulla conversazione selezionata. MAIUSCOLA perché apre un
@@ -0,0 +1,154 @@
1
+ // T155 — il sensore del gitlink: stato, gate di attivazione, cadenza.
2
+ //
3
+ // Non è un overlay come `sheet` o `search` — non apre nessuna schermata e non
4
+ // entra in `MODE_KEYS`. Sta qui per la stessa ragione di `useProjectStatus` e
5
+ // `useWrapOverlay`: tiene uno stato che si legge in vista normale e possiede
6
+ // l'azione che quello stato abilita, e `hooks.ts` è già a 487 righe (P3
7
+ // preflight, D1/P12 — nessuno split dentro questa task).
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
+ import { attentionCount, bumpableCount, bumpGitlink, disalignedCount, gitlinkNote, GITLINK_SCAN_INTERVAL_MS, hasGitmodules, scanGitlink, } from '../gitlink.js';
10
+ export function useGitlink(deps) {
11
+ const { cwd, setNote } = deps;
12
+ /**
13
+ * IL GATE. Senza `.gitmodules` il progetto non ha submodule, quindi non c'è
14
+ * niente da misurare: nessun timer parte, nessuno spawn viene fatto, e
15
+ * l'indicatore non compare affatto — non «0», che occuperebbe colonne della
16
+ * riga legenda per dire che la funzione non si applica.
17
+ *
18
+ * Misurato UNA VOLTA per project root e non a ogni giro: un repo non guadagna
19
+ * submodule mentre il deck è aperto, e se lo facesse basta riavviarlo. È lo
20
+ * stesso limite accettato dalla cache del plugin (`plugin-cache.ts`), scritto
21
+ * qui invece che scoperto dopo.
22
+ */
23
+ const enabled = useMemo(() => hasGitmodules(cwd), [cwd]);
24
+ const [members, setMembers] = useState([]);
25
+ const [ok, setOk] = useState(true);
26
+ /** Almeno un tentativo è stato fatto: distingue «non ho ancora misurato» da
27
+ * «ho misurato e sono tutti allineati», che a schermo sono due cose diverse. */
28
+ const [scanned, setScanned] = useState(false);
29
+ const [epoch, setEpoch] = useState(0);
30
+ const [bumping, setBumping] = useState(false);
31
+ /** L'ultimo bump si è fermato prima di committare. STICKY: un giro di misura
32
+ * riuscito non lo azzera — dice com'è andata l'ultima AZIONE, che è un fatto
33
+ * diverso da com'è andata l'ultima misura, e i due restano entrambi veri.
34
+ * Stessa regola del `failed` del project status. */
35
+ const [failed, setFailed] = useState(false);
36
+ // Il flag di corsa sta in un ref e non in stato, come in `useInboxScan`: due
37
+ // richieste ravvicinate girano nello stesso tick di React, e un valore di
38
+ // stato arriverebbe al render dopo — cioè troppo tardi per impedire il
39
+ // secondo spawn.
40
+ const busy = useRef(false);
41
+ // `alive` vive fuori dall'effect perché la richiesta forzata sopravvive al
42
+ // giro che l'ha ospitata: la scatena un gesto, non l'effect.
43
+ const alive = useRef(true);
44
+ useEffect(() => {
45
+ alive.current = true;
46
+ return () => {
47
+ alive.current = false;
48
+ };
49
+ }, []);
50
+ const run = useCallback(() => {
51
+ if (busy.current)
52
+ return;
53
+ busy.current = true;
54
+ scanGitlink(cwd).then((res) => {
55
+ busy.current = false;
56
+ if (!alive.current)
57
+ return;
58
+ // Un tentativo fallito NON butta via l'esito dell'ultimo riuscito: le
59
+ // righe precedenti restano vere, e il guasto si dice col glifo di allerta
60
+ // accanto al numero. Stessa regola del project status e della coda inbox.
61
+ setOk(res.ok);
62
+ setScanned(true);
63
+ if (res.ok || res.members.length > 0)
64
+ setMembers(res.members);
65
+ });
66
+ }, [cwd]);
67
+ useEffect(() => {
68
+ if (!enabled)
69
+ return;
70
+ run();
71
+ const id = setInterval(run, GITLINK_SCAN_INTERVAL_MS);
72
+ return () => clearInterval(id);
73
+ }, [enabled, run, epoch]);
74
+ /**
75
+ * Rimisura adesso e riparte col timer da zero.
76
+ *
77
+ * Il reset non è cosmetico: senza, l'intervallo continuerebbe a scorrere
78
+ * dall'ultimo tick automatico e potrebbe rimisurare pochi istanti dopo un
79
+ * dato appena misurato — costo pieno, risultato identico. Stessa forma di
80
+ * `useWrapScan` e `useInboxScan`.
81
+ */
82
+ const refresh = useCallback(() => {
83
+ setEpoch((e) => e + 1);
84
+ }, []);
85
+ const count = disalignedCount(members);
86
+ const bumpable = bumpableCount(members);
87
+ /**
88
+ * Il bump vero — l'unica azione del sensore.
89
+ *
90
+ * NESSUNA CONFERMA (P6 preflight): il commit è locale e si annulla con un
91
+ * `git reset` che non tocca nessun remote, e lo script non pusha il cappello.
92
+ * È la stessa forma di `^G`, che genera senza chiedere.
93
+ *
94
+ * I quattro rifiuti dicono SEMPRE perché (AC: il tasto su zero disallineati
95
+ * non spawna niente e lo dice). Un tasto che non facesse niente in silenzio
96
+ * sarebbe indistinguibile da un binding mancante.
97
+ */
98
+ function bump() {
99
+ if (!enabled) {
100
+ setNote('^U → questo progetto non ha submodule: nessun gitlink da bumpare');
101
+ return;
102
+ }
103
+ if (busy.current || bumping) {
104
+ setNote('^U → bump del gitlink già in corso');
105
+ return;
106
+ }
107
+ if (!scanned) {
108
+ setNote('^U → gitlink non ancora misurato');
109
+ return;
110
+ }
111
+ if (bumpable === 0) {
112
+ const why = gitlinkNote(members);
113
+ setNote(count === 0
114
+ ? '^U → gitlink già allineato: niente da bumpare'
115
+ : `^U → nessun membro bumpabile · ${why}`);
116
+ return;
117
+ }
118
+ busy.current = true;
119
+ setBumping(true);
120
+ setNote(`⏳ bump del gitlink su ${bumpable} membro${bumpable > 1 ? 'i' : ''}…`);
121
+ bumpGitlink(cwd).then((res) => {
122
+ busy.current = false;
123
+ if (!alive.current)
124
+ return;
125
+ setBumping(false);
126
+ setOk(res.ok);
127
+ setScanned(true);
128
+ setFailed(!res.ok);
129
+ if (res.members.length > 0)
130
+ setMembers(res.members);
131
+ const note = gitlinkNote(res.members);
132
+ setNote(note || (res.ok ? '✔ gitlink: nessun membro bumpato' : '⚠ bump del gitlink fallito'));
133
+ // Rimisura: l'output del bump porta già gli stati nuovi, ma il giro
134
+ // successivo li conferma da `git submodule status` invece che dalla
135
+ // memoria di ciò che si è appena fatto — ed è ciò che fa scendere il
136
+ // contatore senza riavviare il deck anche quando il commit ha toccato più
137
+ // di quanto lo script credeva.
138
+ refresh();
139
+ });
140
+ }
141
+ return {
142
+ enabled,
143
+ members,
144
+ ok,
145
+ scanned,
146
+ bumping,
147
+ failed,
148
+ count,
149
+ bumpable,
150
+ attention: attentionCount(members),
151
+ bump,
152
+ refresh,
153
+ };
154
+ }
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.64.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": {