@lamemind/loom-deck 0.27.0 → 0.27.3

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
@@ -308,6 +308,14 @@ senza flag resta `recap`, `none` apre la sessione bound **senza** alcun prompt.
308
308
  `LOOM_DECK_ENTER_PROMPT` (placeholder `{TASK}`) resta un override che vince sul
309
309
  kind — tranne su `none`, che è una richiesta esplicita di non averne.
310
310
 
311
+ La tab porta anche `PTYXIS_PROFILE` forzata al profilo bindato al progetto nel
312
+ registry (`bindings/claude`), letto da dconf: è la chiave con cui loom-compass
313
+ associa a un progetto lo stato annunciato dagli hook (running/ask/done). Una tab
314
+ `ptyxis --tab` nuda erediterebbe il profilo di default, e l'annuncio finirebbe
315
+ keyed su un UUID che nessun progetto dichiara — pallino fermo su idle, senza
316
+ alcun errore visibile. Override o disattivazione via `LOOM_DECK_STATE_PROFILE`
317
+ (settata a vuoto → nessun annuncio); progetto non registrato → nessun prefisso.
318
+
311
319
  ## Sviluppo (TUI Ink)
312
320
 
313
321
  ```bash
package/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowI
16
16
  import { cellWidth, launchLegend, loadArchivableDays, loadIdentity, loadLaunch, } from './config.js';
17
17
  import { countArchivable, SCAN_INTERVAL_MS } from './archivable.js';
18
18
  import { assignListCapacity, isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
19
- import { caretWindow, cut, pad, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
19
+ import { caretWindow, cut, cutParts, pad, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
20
20
  import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
21
21
  import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
22
22
  import { loadView, saveView, viewFilePath } from './view-store.js';
@@ -680,7 +680,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
680
680
  for (const r of sessionRows) {
681
681
  if (r.kind === 'separator')
682
682
  continue;
683
- if (isAll) {
683
+ // La colonna serve dove l'appartenenza NON è già scritta altrove sullo
684
+ // schermo: nella vista "tutte" (ogni riga ha un binding proprio) e su
685
+ // OGNI riga pinnata, in qualunque vista — una pinnata è sganciata dal
686
+ // parent selezionato, quindi l'header del pane non parla per lei.
687
+ if (isAll || r.kind === 'pinned') {
684
688
  const b = bindings.get(r.sessionId);
685
689
  if (b)
686
690
  task = Math.max(task, termWidth(b));
@@ -2013,15 +2017,43 @@ function AssignScreen({ sessionId, label, current, filter, rows, selected, match
2013
2017
  return (_jsxs(Text, { inverse: sel, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
2014
2018
  })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
2015
2019
  }
2020
+ /**
2021
+ * Header del pane task, tagliato QUI e non da Ink — stesso motivo del gemello
2022
+ * `SessionsHeader`, con una differenza di rischio: qui i segmenti sono tutti
2023
+ * ASCII più le frecce `↑↓` (larghe 1), quindi `cli-truncate` oggi darebbe la
2024
+ * riga giusta per caso. Il taglio resta del deck perché la correttezza non deve
2025
+ * dipendere dall'alfabeto che capita nella riga: il primo glifo largo 2 che
2026
+ * entrasse in un segmento nuovo riaprirebbe il difetto in silenzio, e a
2027
+ * scoprirlo sarebbe il bordo del pane a schermo.
2028
+ *
2029
+ * `truncate-end` taglia dalla coda, e `cutParts` conserva l'ordine: l'ultimo
2030
+ * segmento resta il primo a cedere il posto (vedi T61 sotto, `archiviabili` in
2031
+ * coda ai contatori della vista corrente).
2032
+ */
2033
+ function TasksHeader({ filtered, total, hidden, above, below, archivable, focused, columns, }) {
2034
+ const segments = [
2035
+ { text: `Tasks (${hidden > 0 ? `${filtered}/${total}` : filtered})` },
2036
+ ...(hidden > 0 ? [{ text: ` · ${hidden} nascoste`, color: 'yellow' }] : []),
2037
+ ...(above > 0 ? [{ text: ` · ↑${above}`, dim: true }] : []),
2038
+ ...(below > 0 ? [{ text: ` · ↓${below}`, dim: true }] : []),
2039
+ ...(archivable > 0 ? [{ text: ` · ${archivable} archiviabili`, dim: true }] : []),
2040
+ ];
2041
+ const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns));
2042
+ return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, children: shown[i] }, i)) : null) }));
2043
+ }
2016
2044
  function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, allCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, archivable, }) {
2017
2045
  const allSelected = selected === ROW_ALL;
2018
2046
  const spotSelected = selected === ROW_SPOT;
2019
- return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Tasks (", hidden > 0 ? `${filtered}/${total}` : filtered, ")", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 ", hidden, " nascoste"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null, archivable > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", archivable, " archiviabili"] }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort: ", describeSort(view.sort), view.hiddenPri.length + view.hiddenProg.length > 0 ? (_jsxs(Text, { children: [' ', "\u00B7 filtri:", ' ', [
2047
+ return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(TasksHeader, { filtered: filtered, total: total, hidden: hidden, above: above, below: below, archivable: archivable, focused: focused, columns: columns }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut(sanitize(`sort: ${describeSort(view.sort)}` +
2048
+ (view.hiddenPri.length + view.hiddenProg.length > 0
2049
+ ? ' · filtri: ' +
2050
+ [
2020
2051
  ...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
2021
2052
  ...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
2022
2053
  ]
2023
- .map((e) => `−${sanitize(e.glyph)}`)
2024
- .join(' ')] })) : null] }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni", allCount > 0 ? ` (${allCount})` : ''] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : (tasks.map((task, i) => {
2054
+ .map((e) => `−${e.glyph}`)
2055
+ .join(' ')
2056
+ : '')), paneTextWidth(columns)) }), _jsxs(Text, { inverse: allSelected && focused, bold: allSelected && !focused, wrap: "truncate-end", children: [allSelected ? CARET : CARET_OFF, "\u2261 tutte le sessioni", allCount > 0 ? ` (${allCount})` : ''] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : (tasks.map((task, i) => {
2025
2057
  // windowStart riporta l'indice di finestra a quello della lista
2026
2058
  // completa, su cui è keyata la selezione. +META_ROWS: le prime due
2027
2059
  // righe sono le meta.
@@ -2039,8 +2071,32 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
2039
2071
  return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
2040
2072
  })), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
2041
2073
  }
2074
+ /**
2075
+ * Header del pane sessioni, tagliato QUI e non da Ink.
2076
+ *
2077
+ * `📌{pinnedCount}` è un glifo largo 2 in mezzo alla riga: con
2078
+ * `wrap="truncate-end"` il taglio passava da `cli-truncate`, che indicizza per
2079
+ * code point con un budget in colonne e restituiva una riga larga 45 su un
2080
+ * budget di 44 — la colonna in più finiva sopra il bordo destro del pane, che
2081
+ * spariva dalla riga (invariante ③ di `width.ts`).
2082
+ *
2083
+ * I segmenti restano segmenti fino al render: il taglio è della RIGA (budget
2084
+ * condiviso, `cutParts`), la resa è del pezzo. Il giallo su `📌N` distingue le
2085
+ * pinnate dal resto dell'header e non è decorazione.
2086
+ */
2087
+ function SessionsHeader({ parentLabel, total, pinnedCount, hidden, above, below, focused, columns, }) {
2088
+ const segments = [
2089
+ { text: `Sessions · ${parentLabel} (${total})` },
2090
+ ...(pinnedCount > 0 ? [{ text: ` · 📌${pinnedCount}`, color: 'yellow' }] : []),
2091
+ ...(hidden > 0 ? [{ text: ` · +${hidden} più vecchie`, dim: true }] : []),
2092
+ ...(above > 0 ? [{ text: ` · ↑${above}`, dim: true }] : []),
2093
+ ...(below > 0 ? [{ text: ` · ↓${below}`, dim: true }] : []),
2094
+ ];
2095
+ const shown = cutParts(segments.map((s) => s.text), paneTextWidth(columns));
2096
+ return (_jsx(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: segments.map((seg, i) => shown[i] ? (_jsx(Text, { color: seg.color, dimColor: seg.dim, children: shown[i] }, i)) : null) }));
2097
+ }
2042
2098
  function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, sessionNotes, projectCore, }) {
2043
- return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Sessions \u00B7 ", parentLabel, " (", total, ")", pinnedCount > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 \uD83D\uDCCC", pinnedCount] }) : null, hidden > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 +", hidden, " pi\u00F9 vecchie"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isAll
2099
+ return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(SessionsHeader, { parentLabel: parentLabel, total: total, pinnedCount: pinnedCount, hidden: hidden, above: above, below: below, focused: focused, columns: columns }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isAll
2044
2100
  ? 'nessuna conversazione nel progetto'
2045
2101
  : isSpot
2046
2102
  ? 'nessuna sessione libera'
@@ -2059,8 +2115,17 @@ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows,
2059
2115
  // riga oltre il bordo, e a ripararla arrivava `cli-truncate` (che
2060
2116
  // sfora di una colonna per emoji e mangia il bordo stesso).
2061
2117
  const staleNote = sessionNotes.get(row.sessionId);
2062
- const staleW = Math.max(0, paneTextWidth(columns) - (2 /* caret */ + termWidth(`${WARN} pin stale `) + SID_CHARS + 3 /* spazio + caporali */));
2063
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, SID_CHARS) }), staleNote ? _jsxs(Text, { color: "yellow", children: [" \u00AB", cut(staleNote, staleW), "\u00BB"] }) : null] }, row.sessionId));
2118
+ // La riga stale è libera (niente colonne: non ha titolo
2119
+ // data), ma il binding va detto lo stesso è una pinnata, quindi
2120
+ // l'header del pane non ne dice l'appartenenza.
2121
+ const staleTask = bindings.get(row.sessionId) ?? null;
2122
+ const staleW = Math.max(0, paneTextWidth(columns) -
2123
+ (2 /* caret */ +
2124
+ termWidth(`${WARN} pin stale `) +
2125
+ SID_CHARS +
2126
+ (staleTask ? termWidth(staleTask) + 1 : 0) +
2127
+ 3 /* spazio + caporali */));
2128
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, SID_CHARS) }), staleTask ? _jsxs(Text, { color: "green", children: [" ", staleTask] }) : null, staleNote ? _jsxs(Text, { color: "yellow", children: [" \u00AB", cut(staleNote, staleW), "\u00BB"] }) : null] }, row.sessionId));
2064
2129
  }
2065
2130
  const s = row.session; // non-stale → session presente
2066
2131
  const isPinnedRow = row.kind === 'pinned';
@@ -2076,6 +2141,13 @@ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows,
2076
2141
  // task accanto, che esiste solo in questa vista.
2077
2142
  const bound = bindings.get(s.sessionId) ?? null;
2078
2143
  const linked = isAll ? Boolean(bound) : !isSpot;
2144
+ // Stesso motivo per cui la colonna esiste: una pinnata resta in lista
2145
+ // qualunque sia il parent selezionato, quindi l'header non ne dice
2146
+ // l'appartenenza e la cella va riempita anche fuori dalla vista
2147
+ // "tutte". Sulle contestuali, dove l'header parla già, resta vuota —
2148
+ // ma la cella è comunque larga `taskW`, o le colonne a destra
2149
+ // slitterebbero riga per riga.
2150
+ const taskCell = isAll || isPinnedRow ? (bound ?? TASK_EMPTY) : '';
2079
2151
  // T60 — colonne VERE: ogni cella fissa è larga esattamente quanto
2080
2152
  // dichiara, riempita di spazi con `pad` (che misura in colonne, non in
2081
2153
  // caratteri). Il marker va portato a 2 anche quando è `○`, largo 1:
@@ -2109,7 +2181,7 @@ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows,
2109
2181
  const used = (label.note ? termWidth(label.note) + 2 : 0) +
2110
2182
  (label.note && label.rest ? 1 : 0) +
2111
2183
  termWidth(label.rest);
2112
- return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', taskW > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: bound ? 'green' : undefined, dimColor: !bound, children: pad(bound ?? TASK_EMPTY, taskW) }), ' '] })) : null, forkMark ? _jsx(Text, { color: "magenta", children: forkMark }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' '.repeat(Math.max(0, inner - used)), ' ', _jsx(Text, { dimColor: true, children: pad(age, ageW, 'right') })] }, s.sessionId));
2184
+ return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', taskW > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: bound && taskCell ? 'green' : undefined, dimColor: !bound, children: pad(taskCell, taskW) }), ' '] })) : null, forkMark ? _jsx(Text, { color: "magenta", children: forkMark }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' '.repeat(Math.max(0, inner - used)), ' ', _jsx(Text, { dimColor: true, children: pad(age, ageW, 'right') })] }, s.sessionId));
2113
2185
  })), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null, note: sessionNotes.get(detail.sessionId) ?? '' })) : null] }));
2114
2186
  }
2115
2187
  // T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
package/dist/width.js CHANGED
@@ -202,6 +202,61 @@ export function cut(s, cols) {
202
202
  // informazione, tolgono una colonna di testo.
203
203
  return out.trimEnd().replace(/…$/, '') + '…';
204
204
  }
205
+ /**
206
+ * Taglio di una riga COMPOSTA da più pezzi, ognuno con la propria resa (colore,
207
+ * dim, bold). Restituisce un array della stessa lunghezza: i pezzi interi finché
208
+ * il budget regge, quello a cavallo del taglio accorciato, i successivi vuoti.
209
+ *
210
+ * Perché non basta `cut` chiamato pezzo per pezzo: il budget è della RIGA, non
211
+ * del singolo pezzo — un pezzo va tagliato in base a quanto hanno già consumato
212
+ * quelli prima di lui. E `cut` collassa il whitespace, quindi mangerebbe lo
213
+ * spazio che separa i segmenti (` · 📌3` diventerebbe `· 📌3`), disallineando la
214
+ * riga di una colonna per ogni giunzione.
215
+ *
216
+ * Perché non basta concatenare e tagliare una volta sola: il risultato sarebbe
217
+ * una stringa unica, e i pezzi tornerebbero renderizzabili solo tutti dello
218
+ * stesso colore. Un header che perde il giallo su `📌N` perde il segnale, non
219
+ * solo la vernice.
220
+ *
221
+ * Invariante ③ (§ in testa a questo file): serve a togliere l'ultima riga
222
+ * composita ancora tagliata da Ink. Gli header dei due pane sono l'ultimo posto
223
+ * dove `wrap="truncate-end"` era rimasto il meccanismo e non la rete: entrambi
224
+ * portano glifi larghi 2 (`📌` nelle sessioni, i glifi di priorità nei filtri
225
+ * delle task), cioè la condizione esatta in cui `cli-truncate` restituisce una
226
+ * riga più larga del budget e il bordo del pane sparisce.
227
+ */
228
+ export function cutParts(parts, cols) {
229
+ const empty = parts.map(() => '');
230
+ if (cols <= 0)
231
+ return empty;
232
+ if (parts.reduce((w, p) => w + termWidth(p), 0) <= cols)
233
+ return [...parts];
234
+ const out = [...empty];
235
+ const budget = cols - 1; // -1 = la colonna dell'ellissi, che qui c'è di sicuro
236
+ let w = 0;
237
+ let full = false;
238
+ for (let i = 0; i < parts.length && !full; i++) {
239
+ let acc = '';
240
+ for (const ch of parts[i]) {
241
+ const cw = termWidth(ch);
242
+ if (w + cw > budget) {
243
+ full = true;
244
+ break;
245
+ }
246
+ acc += ch;
247
+ w += cw;
248
+ }
249
+ out[i] = acc;
250
+ }
251
+ // L'ellissi va sull'ultimo pezzo che ha davvero del testo: appiccicarla a un
252
+ // pezzo vuoto la staccherebbe dal punto di taglio, e il lettore la leggerebbe
253
+ // come un segmento a sé.
254
+ let last = out.length - 1;
255
+ while (last > 0 && out[last] === '')
256
+ last--;
257
+ out[last] = out[last].trimEnd().replace(/…$/, '') + '…';
258
+ return out;
259
+ }
205
260
  /**
206
261
  * Cella di larghezza ESATTA `cols`: taglia se eccede, riempie di spazi se manca.
207
262
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.27.0",
3
+ "version": "0.27.3",
4
4
  "description": "Deck TUI Ink per-progetto della famiglia loom: legge tasks.md e spawna sessioni Claude Code bound via LOOM_TASK",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/deck-run CHANGED
@@ -40,6 +40,8 @@
40
40
  # LOOM_DECK_WORKDIR dir di lavoro della tab (default: $PWD)
41
41
  # LOOM_DECK_ENTER_PROMPT override del prompt iniziale, {TASK}=TaskID (vince sul kind, tranne `none`)
42
42
  # LOOM_DECK_INTAB_CMD override comando in-tab (default: la sessione CC; usato per i test)
43
+ # LOOM_DECK_STATE_PROFILE PTYXIS_PROFILE annunciata a compass (default: bindings/claude
44
+ # del progetto, letto da dconf; settata a vuoto = nessun annuncio)
43
45
  # LOOM_DECK_PERMISSION_MODE override del permissionMode del file config (default: campo file, poi 'manual')
44
46
  #
45
47
  set -euo pipefail
@@ -271,6 +273,48 @@ case "${PERM_MODE:-}" in
271
273
  esac
272
274
  MODE_FLAG="--permission-mode ${PERM_MODE} "
273
275
 
276
+ # ── Profilo di stato per compass ─────────────────────────────────────────────
277
+ # Lo stato di una sessione (running/ask/done) viaggia verso compass via D-Bus
278
+ # keyed su $PTYXIS_PROFILE (hook Claude → `compass <stato>`), e compass lo mappa
279
+ # a un progetto SOLO se quell'UUID compare in `bindings/<surface>/profile` del
280
+ # registry. Ma nel modo `inline` la tab nasce da `ptyxis --tab` nudo, che eredita
281
+ # il profilo DEFAULT di Ptyxis: l'annuncio arriva keyed su un UUID che nessun
282
+ # progetto dichiara → stato scartato, pallino fermo su idle per sempre. Il match
283
+ # della FINESTRA non se ne accorge (passa dal titolo, non dal profilo), quindi il
284
+ # progetto risulta presente ma senza stato — un guasto che non si manifesta come
285
+ # errore, solo come pallino che non cambia mai.
286
+ # Rimedio: forzare in-tab la PTYXIS_PROFILE del binding claude del progetto, così
287
+ # l'annuncio è keyed sull'UUID che compass cerca davvero. Il profilo bindato NON
288
+ # è usabile come profilo vero della tab (`--tab-with-profile`): ha
289
+ # use-custom-command=true, che fa scartare il comando inline — vedi il ramo
290
+ # `profile` sotto, che per questo deve riscrivere dconf.
291
+ #
292
+ # `${VAR+set}`, non `${VAR:-}`: env settata anche a VUOTO = "salta il lookup"
293
+ # (deterministico per i test, che altrimenti leggerebbero il dconf della macchina
294
+ # su cui gira la suite). Non settata = risolvi dal registry.
295
+ # Assente/non risolvibile → nessun prefisso: si degrada al comportamento di prima
296
+ # (stato orfano) invece di rompere lo spawn.
297
+ if [[ -n "${LOOM_DECK_STATE_PROFILE+set}" ]]; then
298
+ STATE_PROFILE="${LOOM_DECK_STATE_PROFILE}"
299
+ else
300
+ STATE_PROFILE=""
301
+ if [[ -n "$_cfg" ]] && command -v dconf >/dev/null 2>&1; then
302
+ _pid="$(jq -r '.id // empty' "$_cfg" 2>/dev/null || true)"
303
+ if [[ -n "$_pid" ]]; then
304
+ STATE_PROFILE="$(dconf read "/org/lamemind/loom/projects/${_pid}/bindings/claude/profile" 2>/dev/null | tr -d "'" || true)"
305
+ fi
306
+ fi
307
+ fi
308
+ # Whitelist come per la nota del titolo: il valore entra in `bash -lc "…"`, e un
309
+ # registry è pur sempre un file editabile a mano. Un UUID Ptyxis è esadecimale;
310
+ # accetto l'alfabeto id-safe e scarto il resto invece di quotare.
311
+ if [[ -n "$STATE_PROFILE" && ! "$STATE_PROFILE" =~ ^[A-Za-z0-9_-]+$ ]]; then
312
+ echo "profilo di stato ignorato (caratteri non ammessi): '${STATE_PROFILE}'" >&2
313
+ STATE_PROFILE=""
314
+ fi
315
+ PROFILE_ENV=""
316
+ [[ -n "$STATE_PROFILE" ]] && PROFILE_ENV="PTYXIS_PROFILE=${STATE_PROFILE} "
317
+
274
318
  # --session-id per la tab: pinna il sessionId della sessione CC (vuoto → CC ne
275
319
  # genera uno). Lo spazio finale tiene la concatenazione pulita quando è assente.
276
320
  SID_FLAG=""
@@ -344,10 +388,13 @@ PROMPT_ARG=""
344
388
  # agli altri elementi task-bound: nel fork di una sessione SPOT il sessionId
345
389
  # pinnato non serve a legare una task (non c'è) ma a rendere noto in anticipo
346
390
  # l'id del ramo, senza il quale il lineage non sarebbe registrabile.
391
+ # PROFILE_ENV in testa a entrambi i rami: lo stato è una proprietà della SESSIONE
392
+ # (esiste anche senza task), esattamente come la label del titolo — legarlo al
393
+ # ramo task-bound ripeterebbe l'errore di layer già evitato per la titolazione.
347
394
  if [[ $NO_TASK -eq 1 ]]; then
348
- _default_intab="claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}"
395
+ _default_intab="${PROFILE_ENV}claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}"
349
396
  else
350
- _default_intab="LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}${PROMPT_ARG}"
397
+ _default_intab="${PROFILE_ENV}LOOM_TASK=${TASK} claude --name '${TITLE}' ${MODE_FLAG}${SID_FLAG}${RES_FLAG}${FORK_FLAG}${PROMPT_ARG}"
351
398
  fi
352
399
  IN_TAB_CMD="${LOOM_DECK_INTAB_CMD:-$_default_intab}"
353
400