@lamemind/loom-deck 0.60.0 → 0.61.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
@@ -21,7 +21,7 @@ import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, } from '
21
21
  import { neighborId } from './session-list.js';
22
22
  import { cut, cutMiddle } from './width.js';
23
23
  import { saveView, viewFilePath } from './view-store.js';
24
- import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
24
+ import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, fallbackTitle, DECK_RUN, MODEL_DEFAULT, } from './spawn.js';
25
25
  import { useTaskOps } from './task-ops.js';
26
26
  export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNote, }) {
27
27
  // La riga di stato di OGNI spawn di sessione Claude: il comando esatto, come
@@ -104,12 +104,19 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, setNo
104
104
  // il campo è editabile: quello che l'utente legge è quello che parte. Assente
105
105
  // per gli acceleratori della lista, che non hanno un campo da cui prenderlo e
106
106
  // viaggiano col simbolo.
107
+ // T150 — il vuoto si riempie QUI, prima che i due canali si separino: il
108
+ // sidecar (`appendNote`, sotto) e il titolo tab (`--title-note`, dentro
109
+ // `spawnDeck`) devono vedere lo STESSO valore, o le due superfici mostrano
110
+ // nomi diversi per la stessa conversazione. Vale anche sugli acceleratori
111
+ // della lista (^K/^P/^R), che passano di qui con `spawnNote` sul proprio
112
+ // default '' (P1 preflight) — nascono quindi titolati anche loro.
107
113
  function spawnForTask(id, kind, modelKind, spawnNote = '', prompt) {
108
114
  const sid = randomUUID();
115
+ const note = spawnNote || fallbackTitle(tasksDir, id, kind) || '';
109
116
  appendTaskBinding(cwd, sid, id);
110
- if (spawnNote)
111
- appendNote(cwd, sid, spawnNote);
112
- const spawned = spawnDeck(id, cwd, sid, kind, modelKind, spawnNote, prompt);
117
+ if (note)
118
+ appendNote(cwd, sid, note);
119
+ const spawned = spawnDeck(id, cwd, sid, kind, modelKind, note, prompt);
113
120
  spawned.child.on('error', () => setNote(`⚠ spawn ${id} fallito (${DECK_RUN})`));
114
121
  // Il modello resta SEMPRE visibile anche quando è il default, perché è un
115
122
  // argomento esplicito del comando (T108): gli acceleratori della lista non
@@ -111,9 +111,11 @@ const MIN_NOTE = 14;
111
111
  export function rowLabel(text, note, budget) {
112
112
  if (!note)
113
113
  return { note: '', rest: cut(text, Math.max(0, budget)) };
114
- // 2 colonne per i caporali « », 1 per lo spazio prima del residuo.
114
+ // 1 colonna per lo spazio prima del residuo (T150 la nota non porta più i
115
+ // caporali « » che decoravano il render, quindi non riserva più le loro 2
116
+ // colonne).
115
117
  const rest = text;
116
- const noteBudget = Math.max(0, budget - 2);
118
+ const noteBudget = Math.max(0, budget);
117
119
  if (!rest)
118
120
  return { note: cut(note, noteBudget), rest: '' };
119
121
  // Il `min` col budget totale non è ridondante: su un pane strettissimo
package/dist/spawn.js CHANGED
@@ -3,7 +3,8 @@
3
3
  import { spawn } from 'node:child_process';
4
4
  import { EventEmitter } from 'node:events';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { dirname, join } from 'node:path';
6
+ import { basename, dirname, join } from 'node:path';
7
+ import { findTaskFile } from './tasks.js';
7
8
  // scripts/deck-run è un sibling della dir del bundle: src/ (dev, tsx) e dist/
8
9
  // (build, node) stanno entrambi sotto la package root → risalita di un livello.
9
10
  export const DECK_RUN = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'deck-run');
@@ -171,6 +172,43 @@ export const DETAIL_ACTIONS = [
171
172
  // DISTINTE fra loro. Due label con la stessa lettera renderebbero la seconda
172
173
  // irraggiungibile, e in silenzio — chi aggiunge una voce lo controlla qui.
173
174
  export const ACTION_HOTKEYS = Object.fromEntries(DETAIL_ACTIONS.map((a, i) => [a.label[0], i]));
175
+ // T150 — titolo di fallback quando il campo nota è vuoto, derivato dall'AZIONE
176
+ // e dal task. Mappa sul KIND, non sulla label (P2 preflight): alla sede del
177
+ // fallback (`spawnForTask` in actions.ts) arriva il kind — già specializzato
178
+ // quando la chiamata viene dal detail (`recap-task`/`recap-epic`) — mai la
179
+ // label del catalogo.
180
+ // D2/P7 — `none` (label "open") non ha una parola: è "nessuna azione", e
181
+ // inventargliene una contraddirebbe l'intenzione dell'utente.
182
+ // P3 — le tre varianti di recap condividono la parola: la specializzazione
183
+ // sceglie quale skill parte, non cosa l'utente sta chiedendo.
184
+ const ACTION_WORD = {
185
+ preflight: 'PREFL',
186
+ run: 'RUN',
187
+ recap: 'RECAP',
188
+ 'recap-task': 'RECAP',
189
+ 'recap-epic': 'RECAP',
190
+ checkpoint: 'CHKPOINT',
191
+ };
192
+ /**
193
+ * Titolo di fallback per una conversazione con nota vuota: `{AZIONE} {slug}`,
194
+ * o il solo slug per `none` (D1/D2 preflight). Lo slug viene dal NOME del
195
+ * task file (`findTaskFile`), non dalla descrizione di `tasks.md`: il nome è
196
+ * già dentro l'alfabeto di `_sane_note` per costruzione — minuscolo, separato
197
+ * da trattini, senza punteggiatura — mentre la descrizione porta apostrofi e
198
+ * `/` che la riduzione toglie senza sostituto, saldando le parole (D1
199
+ * razionale).
200
+ *
201
+ * `null` quando il task file non si trova: il chiamante decide se ripiegare
202
+ * su nota vuota o su un altro fallback.
203
+ */
204
+ export function fallbackTitle(tasksDir, id, kind) {
205
+ const path = findTaskFile(tasksDir, id);
206
+ if (!path)
207
+ return null;
208
+ const slug = basename(path, '.md').slice(id.length + 1).replace(/-/g, ' ');
209
+ const word = ACTION_WORD[kind];
210
+ return word ? `${word} ${slug}` : slug;
211
+ }
174
212
  // Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
175
213
  // ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
176
214
  // sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
package/dist/ui/panes.js CHANGED
@@ -181,8 +181,8 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
181
181
  termWidth(`${WARN} pin stale `) +
182
182
  SID_CHARS +
183
183
  (staleTask ? termWidth(staleTask) + 1 : 0) +
184
- 3 /* spazio + caporali */));
185
- 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));
184
+ 1 /* spazio prima della nota */));
185
+ 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: [" ", cut(staleNote, staleW)] }) : null] }, row.sessionId));
186
186
  }
187
187
  const s = row.session;
188
188
  const isPinnedRow = row.pinned;
@@ -241,9 +241,9 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
241
241
  // dicono già (progetto e task id): senza, la cella conterrebbe
242
242
  // `🧵 loom-works · T59` accanto a una colonna che dice `T59`.
243
243
  const label = rowLabel(sessionTitle(s, projectCore, bound), sessionNotes.get(s.sessionId), inner);
244
- const used = (label.note ? termWidth(label.note) + 2 : 0) +
244
+ const used = (label.note ? termWidth(label.note) : 0) +
245
245
  (label.note && label.rest ? 1 : 0) +
246
246
  termWidth(label.rest);
247
- 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: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : undefined, children: liveEntry ? (liveEntry.status === 'busy' ? LIVE_BUSY : LIVE_IDLE) : LIVE_NONE }), _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : 'cyan', bold: Boolean(liveEntry), children: s.sessionId.slice(0, SID_CHARS) }), ' ', _jsx(Text, { dimColor: !s.model, children: pad(modelShort(s.model), MODEL_W) }), ' ', 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));
247
+ 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: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : undefined, children: liveEntry ? (liveEntry.status === 'busy' ? LIVE_BUSY : LIVE_IDLE) : LIVE_NONE }), _jsx(Text, { color: liveEntry ? (liveEntry.status === 'busy' ? 'yellow' : 'green') : 'cyan', bold: Boolean(liveEntry), children: s.sessionId.slice(0, SID_CHARS) }), ' ', _jsx(Text, { dimColor: !s.model, children: pad(modelShort(s.model), MODEL_W) }), ' ', 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 ? (_jsx(Text, { color: "yellow", bold: true, children: label.note })) : 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));
248
248
  }))] }));
249
249
  }
@@ -64,7 +64,7 @@ export function SessionPreview({ s, firstLines, lastLines, columns, origin, note
64
64
  // non c'è una provenienza vera da dichiarare.
65
65
  const originAlias = modelAlias(s.model);
66
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}`)))] }));
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: [note, " "] }) : 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}`)))] }));
68
68
  }
69
69
  /**
70
70
  * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
@@ -55,13 +55,14 @@ export function SearchScreen({ preview, hash, query, field, opts, result, rows,
55
55
  const bound = bindings.get(s.sessionId);
56
56
  const rowNote = sessionNotes.get(s.sessionId);
57
57
  const noteShown = rowNote ? cut(rowNote, 24) : '';
58
- // `+3` = i due caporali e lo spazio che li separa dall'etichetta.
59
- // Il pavimento non è cosmetico: senza, un terminale stretto manda
60
- // l'argomento di `cut` sotto zero, cioè un budget negativo.
61
- // La nota si misura con `termWidth`, non con `.length`: contiene
62
- // testo umano, emoji compresi.
63
- const restWidth = Math.max(8, searchTitleWidth(columns) - (noteShown ? termWidth(noteShown) + 3 : 0));
64
- return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), noteShown ? _jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", noteShown, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(noteShown), children: cut(conversationLabel(s, projectCore, bound), restWidth) }), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
58
+ // `+1` = lo spazio che separa la nota dall'etichetta (T150 — niente
59
+ // più caporali « » a decorarla). Il pavimento non è cosmetico:
60
+ // senza, un terminale stretto manda l'argomento di `cut` sotto
61
+ // zero, cioè un budget negativo. La nota si misura con
62
+ // `termWidth`, non con `.length`: contiene testo umano, emoji
63
+ // compresi.
64
+ const restWidth = Math.max(8, searchTitleWidth(columns) - (noteShown ? termWidth(noteShown) + 1 : 0));
65
+ return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), noteShown ? _jsxs(Text, { color: "yellow", bold: true, children: [noteShown, " "] }) : null, _jsx(Text, { dimColor: Boolean(noteShown), children: cut(conversationLabel(s, projectCore, bound), restWidth) }), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
65
66
  }
66
67
  const h = row.hit;
67
68
  return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: String(h.idx).padStart(4) }), ' ', _jsx(Text, { color: KIND_COLOR[h.kind], children: KIND_TAG[h.kind] }), ' ', cut(h.excerpt, searchExcerptWidth(columns))] }, row.key));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.60.0",
3
+ "version": "0.61.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": {
package/scripts/deck-run CHANGED
@@ -183,7 +183,7 @@ USAGE="uso: deck-run <TaskID> [--model <alias>] [--prompt-kind <kind>] [--sessio
183
183
  --model modello della sessione: fable|opus|sonnet|haiku (default: opus)
184
184
  valore ignoto → fallback sul default, con avviso su stderr
185
185
 
186
- --title-note nota della conversazione, appesa al titolo tab come «nota»
186
+ --title-note nota della conversazione, appesa al titolo tab
187
187
  (ridotta a lettere/cifre/spazi/-/_, cap 60 char)"
188
188
 
189
189
  if [[ $NO_TASK -eq 1 && -n "$TASK" ]]; then
@@ -317,15 +317,16 @@ if [[ -n "$_cfg" ]]; then
317
317
  fi
318
318
  # Suffisso nota (T64): distingue fra loro le tab di più conversazioni sulla
319
319
  # STESSA task, che altrimenti condividono un titolo identico (`label · T81`) —
320
- # la nota è già la maniglia con cui l'utente le distingue in lista, e le stesse
321
- # `«…»` della lista la rendono riconoscibile a colpo d'occhio anche nella tab
322
- # bar. In coda per la solita ragione degli altri suffissi: il match compass è
323
- # `.includes(label)`, la label deve restare intatta in TESTA.
324
- # Nota ridotta a vuoto (era tutta emoji/punteggiatura) nessun suffisso, non
325
- # `«»` a vuoto.
320
+ # la nota è già la maniglia con cui l'utente le distingue in lista. In coda per
321
+ # la solita ragione degli altri suffissi: il match compass è `.includes(label)`,
322
+ # la label deve restare intatta in TESTA.
323
+ # T150 nudo, senza i caporali `«…»` che lo decoravano: la nota di fallback
324
+ # generata dal deck quando il campo è vuoto passa dallo stesso alfabeto ridotto
325
+ # e non ha bisogno di una cornice per distinguersi da un titolo scritto a mano.
326
+ # Nota ridotta a vuoto (era tutta emoji/punteggiatura) → nessun suffisso.
326
327
  if [[ -n "$TITLE_NOTE" ]]; then
327
328
  _note="$(_sane_note "$TITLE_NOTE")"
328
- [[ -n "$_note" ]] && TITLE="${TITLE} «${_note}»"
329
+ [[ -n "$_note" ]] && TITLE="${TITLE} ${_note}"
329
330
  fi
330
331
  # Suffisso fork (T28): un ramo eredita task e label dell'origine, quindi senza
331
332
  # marcatore le due tab risulterebbero omonime nella stessa window. Suffisso e