@lamemind/loom-deck 0.60.0 → 0.61.1

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
@@ -18,6 +18,32 @@ const FORMAT_RE = /^#(\d+)$/;
18
18
  // Il basename del task file, non il path intero (che cambia con la docs-root)
19
19
  // e non `TASK_ID_RE` di tasks.ts (ancorata all'id nudo, non matcha `T136-x.md`).
20
20
  const TASK_FILE_RE = /^(T\d+)-.*\.md$/;
21
+ const EMPTY_MAP = new Map();
22
+ /**
23
+ * T153 — gate module-level: `git log` gira solo se lo sha di HEAD è cambiato
24
+ * dall'ultima chiamata su questa stessa coppia (tasksDir, projectRoot). Le
25
+ * date di ultimo commit cambiano SOLO se HEAD si muove (§Implementation
26
+ * Notes T153): un rev-parse fallito degrada come degradava il log, mai un
27
+ * throw. `key` tiene le due coppie separate — un test che passa tmpdir
28
+ * diversi a chiamate successive non deve leggere la cache dell'altro.
29
+ */
30
+ let cache = null;
31
+ let logSpawns = 0;
32
+ /** T153/DLV6 — quante volte è partito lo spawn COSTOSO (`git log`), non il
33
+ * gate. Il gate (`rev-parse`) gira a ogni chiamata per costruzione: contarlo
34
+ * renderebbe il numero inutile a dimostrare il no-op. */
35
+ export function commitLogSpawnCount() {
36
+ return logSpawns;
37
+ }
38
+ async function headSha(projectRoot) {
39
+ try {
40
+ const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: projectRoot });
41
+ return stdout.trim();
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
21
47
  /**
22
48
  * Parsing puro dell'output `git log --format=#%ct --name-only`. Isolato da
23
49
  * `commitTimes` perché è l'unica parte che vale la pena collaudare: invocare
@@ -54,13 +80,33 @@ export function parseCommitLog(output) {
54
80
  * incompleta proprio per le task vecchie mai più toccate, quelle che sotto
55
81
  * `desc` finiscono in coda — dove un timestamp assente e uno vecchio si
56
82
  * confonderebbero.
83
+ *
84
+ * T153 — gate su `rev-parse HEAD` (un ordine di grandezza più economico
85
+ * dello spawn del log che sostituisce, cifre misurate nel Progress Log): a
86
+ * sha invariato torna la STESSA istanza di mappa, non una ricostruita — è
87
+ * ciò che rende inutile il confronto per firma in `useCommitTimes` (P1
88
+ * preflight).
57
89
  */
58
90
  export async function commitTimes(tasksDir, projectRoot) {
91
+ const key = `${projectRoot}\u0000${tasksDir}`;
92
+ const sha = await headSha(projectRoot);
93
+ if (sha !== null && cache && cache.key === key && cache.sha === sha) {
94
+ return cache.map;
95
+ }
96
+ let map;
59
97
  try {
98
+ // Il contatore sale PRIMA dell'await: conta gli spawn partiti, e uno
99
+ // spawn fallito costa comunque la fork del processo.
100
+ logSpawns++;
60
101
  const { stdout } = await execFileAsync('git', ['log', '--format=#%ct', '--name-only', '--', tasksDir], { cwd: projectRoot });
61
- return parseCommitLog(stdout);
102
+ map = parseCommitLog(stdout);
62
103
  }
63
104
  catch {
64
- return new Map();
105
+ map = EMPTY_MAP;
65
106
  }
107
+ // sha === null (git muto): non si cachea uno stato che il prossimo giro
108
+ // potrebbe smentire in silenzio (repo che ricompare) senza mai vedersi
109
+ // ricontrollato.
110
+ cache = sha !== null ? { key, sha, map } : null;
111
+ return map;
66
112
  }
@@ -8,7 +8,7 @@
8
8
  // parentela si edita anche a mano dentro un task file già in tabella, non solo
9
9
  // alla nascita di una riga: un trigger sulla sola firma degli id (come
10
10
  // `useArchivable`) lascerebbe quell'edit stale fino al prossimo giro largo.
11
- import { readdirSync, readFileSync } from 'node:fs';
11
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
  import { taskEpicOf, taskIsEpic } from './tasks.js';
14
14
  // Stesso pattern di `TASK_FILE_RE` in commit-times.ts: il basename del task
@@ -17,10 +17,23 @@ import { taskEpicOf, taskIsEpic } from './tasks.js';
17
17
  const TASK_FILE_RE = /^(T\d+)-.*\.md$/;
18
18
  export const EMPTY_EPIC_HIERARCHY = { epicOf: new Map(), epics: new Set() };
19
19
  /**
20
- * Lettura SINCRONA di tutti i task file sotto `tasksDir`: costo misurato in P2,
21
- * accettabile sul poll da 1,5s. Cartella illeggibile o singolo file non
22
- * apribile entry saltata, mai un throw un dato di rendering non può
23
- * rompere il deck, stessa regola di `archivableIds`/`commitTimes`.
20
+ * T153 gate module-level: il read+parse dei task file gira solo se la mtime
21
+ * MASSIMA fra i file `T<N>-*.md` di `tasksDir` è cambiata da ultima chiamata
22
+ * (un ordine di grandezza più economico del parse pieno che sostituisce,
23
+ * cifre misurate nel Progress Log). Sulla SOLA mtime e non su una firma
24
+ * degli id: deve intercettare un edit a mano di
25
+ * `**Parent Task**` DENTRO un file già in tabella, che non aggiunge né toglie
26
+ * nessun id (Testing Notes T153). A gate scattato torna la STESSA istanza —
27
+ * `useEpicHierarchy` non ha più bisogno di una firma propria per lo stesso
28
+ * motivo di `useCommitTimes` (P1 preflight).
29
+ */
30
+ let cache = null;
31
+ /**
32
+ * Lettura SINCRONA di tutti i task file sotto `tasksDir`: costo del gate
33
+ * accettabile sul poll da 1,5s anche a scan pieno (mai il caso in regime).
34
+ * Cartella illeggibile o singolo file non apribile → entry saltata, mai un
35
+ * throw — un dato di rendering non può rompere il deck, stessa regola di
36
+ * `archivableIds`/`commitTimes`.
24
37
  */
25
38
  export function scanEpicHierarchy(tasksDir) {
26
39
  let entries;
@@ -28,15 +41,32 @@ export function scanEpicHierarchy(tasksDir) {
28
41
  entries = readdirSync(tasksDir);
29
42
  }
30
43
  catch {
44
+ cache = null;
31
45
  return EMPTY_EPIC_HIERARCHY;
32
46
  }
33
- const epicOf = new Map();
34
- const epics = new Set();
47
+ let maxMtime = 0;
48
+ const files = [];
35
49
  for (const file of entries) {
36
- const m = TASK_FILE_RE.exec(file);
37
- if (!m)
50
+ if (!TASK_FILE_RE.test(file))
38
51
  continue;
39
- const id = m[1];
52
+ let mtime;
53
+ try {
54
+ mtime = statSync(join(tasksDir, file)).mtimeMs;
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ files.push(file);
60
+ if (mtime > maxMtime)
61
+ maxMtime = mtime;
62
+ }
63
+ if (cache && cache.key === tasksDir && cache.maxMtime === maxMtime) {
64
+ return cache.hierarchy;
65
+ }
66
+ const epicOf = new Map();
67
+ const epics = new Set();
68
+ for (const file of files) {
69
+ const id = TASK_FILE_RE.exec(file)[1];
40
70
  let content;
41
71
  try {
42
72
  content = readFileSync(join(tasksDir, file), 'utf8');
@@ -50,5 +80,7 @@ export function scanEpicHierarchy(tasksDir) {
50
80
  if (parent)
51
81
  epicOf.set(id, parent);
52
82
  }
53
- return { epicOf, epics };
83
+ const hierarchy = { epicOf, epics };
84
+ cache = { key: tasksDir, maxMtime, hierarchy };
85
+ return hierarchy;
54
86
  }
package/dist/hooks.js CHANGED
@@ -72,28 +72,26 @@ export function useTasks(tasksPath) {
72
72
  /**
73
73
  * T136 — data dell'ultimo commit di ogni task file, per la chiave di sort
74
74
  * `commit`. D1 (preflight): sullo STESSO poll di `tasks.md` (`POLL_MS`), non
75
- * su una scala propria come `useArchivable` — una passata di `git log` costa
76
- * ~33ms ed è UNA invocazione per tick, indipendentemente dal numero di task.
75
+ * su una scala propria come `useArchivable`.
77
76
  *
78
- * P5 (preflight): `commitTimes` ritorna una `Map` nuova a ogni tick anche
79
- * quando il contenuto non cambia; passata nuda a una `useMemo` a valle ne
80
- * romperebbe la memoizzazione ogni 1,5s. Si confronta per FIRMA prima di
81
- * aggiornare lo stato, come `lastMtime` in `useTasks` e `lastSig` in
82
- * `useSessions`: l'identità della mappa cambia solo dopo un commit vero.
77
+ * T153 `commitTimes` gatea da sé su `rev-parse HEAD` (davanti allo spawn
78
+ * caro del log, vedi `commit-times.ts`) e a sha invariato torna la STESSA
79
+ * istanza di mappa. L'hook confronta quindi per IDENTITÀ, non più per firma
80
+ * ricostruita: la firma evitava solo il re-render, il gate a monte evita il
81
+ * lavoro. `lastMtime` in `useTasks` resta il gemello sullo stesso schema.
83
82
  */
84
83
  export function useCommitTimes(tasksDir, projectRoot) {
85
84
  const [commitAt, setCommitAt] = useState(() => new Map());
86
85
  useEffect(() => {
87
- let lastSig = '';
86
+ let last = null;
88
87
  let alive = true;
89
88
  const reload = () => {
90
89
  commitTimes(tasksDir, projectRoot).then((next) => {
91
90
  if (!alive)
92
91
  return;
93
- const sig = [...next.entries()].map(([id, ts]) => `${id}:${ts}`).sort().join(',');
94
- if (sig === lastSig)
95
- return;
96
- lastSig = sig;
92
+ if (next === last)
93
+ return; // stessa istanza → il gate non è scattato
94
+ last = next;
97
95
  setCommitAt(next);
98
96
  });
99
97
  };
@@ -108,29 +106,24 @@ export function useCommitTimes(tasksDir, projectRoot) {
108
106
  }
109
107
  /**
110
108
  * T67 — mappa cappello/figlie (`**Parent Task**`, `Size: Epic`), sullo STESSO
111
- * poll di `tasks.md` e non su una scala propria: P2 (preflight) misura 7,86 ms
112
- * per il read+parse di 106 task file, meno dei ~33 ms di `git log` che
113
- * `useCommitTimes` paga già ogni tick. La parentela si edita anche a mano su
114
- * un task file già in tabella — un trigger sulla sola firma degli id, come
115
- * `useArchivable`, non vedrebbe quell'edit e lo lascerebbe stale fino al
116
- * prossimo giro largo.
109
+ * poll di `tasks.md` e non su una scala propria.
117
110
  *
118
- * Firma prima di `setState`, gemella di `lastSig`/`lastMtime` degli altri
119
- * poll: senza, la mappa cambia identità a ogni tick e rompe le `useMemo` a
120
- * valle che la consumano (`applyView`, `selectTasks`).
111
+ * T153 `scanEpicHierarchy` gatea da sulla mtime massima dei task file
112
+ * (davanti al read+parse caro, vedi `epic-hierarchy.ts`) e a mtime invariata
113
+ * torna la STESSA istanza: la parentela editata a mano dentro un file già in
114
+ * tabella resta vista, perché il gate è sulla mtime e non sugli id. L'hook
115
+ * confronta quindi per IDENTITÀ, non più per firma ricostruita, gemello di
116
+ * `useCommitTimes` per lo stesso motivo (P1 preflight).
121
117
  */
122
118
  export function useEpicHierarchy(tasksDir) {
123
119
  const [hierarchy, setHierarchy] = useState(EMPTY_EPIC_HIERARCHY);
124
120
  useEffect(() => {
125
- let lastSig = '';
121
+ let last = null;
126
122
  const reload = () => {
127
123
  const next = scanEpicHierarchy(tasksDir);
128
- const sig = [...next.epicOf.entries()].map(([c, p]) => `${c}<${p}`).sort().join(',') +
129
- '#' +
130
- [...next.epics].sort().join(',');
131
- if (sig === lastSig)
132
- return;
133
- lastSig = sig;
124
+ if (next === last)
125
+ return; // stessa istanza → il gate non è scattato
126
+ last = next;
134
127
  setHierarchy(next);
135
128
  };
136
129
  reload();
@@ -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.1",
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