@lamemind/loom-deck 0.31.0 → 0.33.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.
@@ -0,0 +1,260 @@
1
+ // Rendering del markdown per il detail della task (T75): dal sorgente al
2
+ // testo VISIBILE più gli intervalli da colorare.
3
+ //
4
+ // PERCHÉ IL PARSER PRODUCE TESTO E NON COLORE. La tentazione naturale è
5
+ // restituire una stringa già colorata con escape ANSI dentro. Sarebbe il modo
6
+ // più corto e il più sbagliato: `width.ts` misura CARATTERI per contare le
7
+ // colonne del terminale, e una sequenza `ESC[1m` le violerebbe tutte —
8
+ // `termWidth` conterebbe colonne che il terminale non disegna, quindi l'a-capo
9
+ // cadrebbe nel posto sbagliato e il bordo del pane verrebbe mangiato.
10
+ //
11
+ // Da qui la separazione: qui si producono `(testo-visibile, span tipizzati)`,
12
+ // il colore lo mette il render intersecando gli span con le righe wrappate.
13
+ //
14
+ // PERCHÉ I MARKER VANNO VIA PRIMA DEL WRAP, non dopo. `**foo**` occupa 3
15
+ // colonne rese e 7 grezze. Wrappare sul testo con i marker manderebbe a capo
16
+ // contando 7, cioè ogni riga uscirebbe più corta del budget di 4 colonne per
17
+ // ogni `**` — un a-capo sbagliato, non un difetto estetico. La catena è quindi
18
+ // obbligata:
19
+ //
20
+ // sanitize(raw) → parseMarkdown → wrapWithOffsets → interseca
21
+ //
22
+ // `sanitize` sta PRIMA (T75 · D1) perché non conserva la lunghezza: timbra col
23
+ // VS16 ogni glifo BMP largo 2 e sostituisce i discordi (`⚠`→`❗️`, `✔`→`✅️`),
24
+ // quindi applicarla dopo sposterebbe gli offset appena calcolati. Applicarla
25
+ // prima è sicuro perché i marker markdown sono ASCII e cadono fuori dal suo
26
+ // insieme di rischio. Nel deck la sanificazione avviene già al confine di
27
+ // caricamento (`loadTaskFileText`), quindi qui il testo arriva timbrato.
28
+ //
29
+ // GLI SPAN SONO PIATTI E DISGIUNTI. Il nesting del markdown (codice dentro un
30
+ // heading, grassetto dentro un bullet) è risolto QUI, non al render: ogni
31
+ // carattere del testo reso appartiene al più a uno span. Il render diventa
32
+ // così una mappa `kind → attributi Ink` senza logica di merge, ed è la stessa
33
+ // proprietà che permette di comporre questi span con quelli della ricerca
34
+ // (`sliceLine` in `text-search.ts`) senza che le due segmentazioni si
35
+ // contendano lo stesso carattere.
36
+ /** Apertura o chiusura di un blocco di codice: almeno tre backtick a inizio
37
+ * riga, eventualmente indentati. La info-string dopo i backtick (` ```bash `)
38
+ * fa parte del delimitatore e sparisce con lui. */
39
+ const FENCE = /^\s*```/;
40
+ /** `# ` … `###### ` — lo spazio è obbligatorio, o `#tag` diventerebbe un
41
+ * titolo. Il livello non si conserva: la resa è la stessa per tutti (D4). */
42
+ const HEADING = /^(#{1,6})\s+(.*)$/;
43
+ /** Voce di elenco: indentazione + marker + almeno uno spazio. Lo spazio
44
+ * obbligatorio è ciò che tiene fuori `---` (riga orizzontale) e `**bold**` a
45
+ * inizio riga, che altrimenti passerebbero per bullet. Indentazione e spazio
46
+ * dopo il marker sono catturati per essere RIMESSI identici: il glifo occupa
47
+ * una colonna come il marker che sostituisce, quindi conservare i due lati
48
+ * conserva la colonna d'inizio del testo, che è ciò che tiene allineato un
49
+ * elenco scritto a mano. */
50
+ const BULLET = /^(\s*)[-*+](\s+)/;
51
+ /** Il glifo del bullet. Concorde (`stringWidth` 1, `termWidth` 1), quindi si
52
+ * può inserire DOPO `sanitize` senza violare l'invariante ① di `width.ts`. */
53
+ const BULLET_GLYPH = '•';
54
+ /**
55
+ * Testo reso e span di stile a partire dal markdown grezzo.
56
+ *
57
+ * Due livelli, e l'ordine fra loro non è negoziabile: il blocco decide se
58
+ * l'inline gira o è sospeso. Dentro un fence `**` e i backtick sono contenuto,
59
+ * non marcatura — una regex per riga non potrebbe saperlo, perché il fence ha
60
+ * stato multi-riga.
61
+ *
62
+ * Fuori scope per scelta (T75): tabelle — l'inline ci gira sopra come ovunque,
63
+ * ma le colonne non vengono allineate — corsivo e link.
64
+ */
65
+ export function parseMarkdown(src) {
66
+ const out = [];
67
+ const spans = [];
68
+ /** Offset del reso a cui inizierà la prossima riga emessa. */
69
+ let base = 0;
70
+ let inFence = false;
71
+ const emit = (text, lineSpans) => {
72
+ for (const s of lineSpans) {
73
+ spans.push({ start: base + s.start, end: base + s.end, kind: s.kind });
74
+ }
75
+ out.push(text);
76
+ base += text.length + 1; // +1 = il '\n' che unirà questa riga alla prossima
77
+ };
78
+ for (const line of src.split('\n')) {
79
+ if (FENCE.test(line)) {
80
+ // D2 — il delimitatore è un marker quanto `**` e `#`, quindi sparisce
81
+ // come gli altri: il confine del blocco lo porta il colore uniforme.
82
+ // Conseguenza contabile: due righe in meno per fence nel totale che
83
+ // alimenta il contatore `righe N-M di T`.
84
+ inFence = !inFence;
85
+ continue;
86
+ }
87
+ if (inFence) {
88
+ emit(line, line.length > 0 ? [{ start: 0, end: line.length, kind: 'fence' }] : []);
89
+ continue;
90
+ }
91
+ const h = HEADING.exec(line);
92
+ if (h) {
93
+ const body = h[2];
94
+ const r = scanInline(body, 'heading');
95
+ emit(r.text, r.spans);
96
+ continue;
97
+ }
98
+ const b = BULLET.exec(line);
99
+ if (b) {
100
+ const r = scanInline(line.slice(b[0].length), null);
101
+ const prefix = b[1] + BULLET_GLYPH + b[2];
102
+ emit(prefix + r.text, r.spans.map((s) => ({ ...s, start: s.start + prefix.length, end: s.end + prefix.length })));
103
+ continue;
104
+ }
105
+ const r = scanInline(line, null);
106
+ emit(r.text, r.spans);
107
+ }
108
+ return { text: out.join('\n'), spans };
109
+ }
110
+ /**
111
+ * Livello inline: `**grassetto**` e `` `codice` `` su UNA riga.
112
+ *
113
+ * `base` è il costrutto che avvolge la riga (`heading`) o `null` per il testo
114
+ * normale, e serve a due cose: riempire i tratti non marcati con lo stile del
115
+ * blocco, e assorbire il grassetto quando il blocco è già enfatizzato — un
116
+ * `**` dentro un titolo non deve spezzarlo in un pezzo cyan e uno bianco.
117
+ *
118
+ * **Il perimetro è la riga, e questo è il presidio contro il marker spaiato.**
119
+ * Un `**` che non si chiude non può mangiare il resto del documento perché la
120
+ * ricerca del chiudente non esce dalla riga: resta letterale, il testo non si
121
+ * perde e il documento sotto è intatto.
122
+ *
123
+ * Precedenza al codice, come in CommonMark: dentro `` `**x**` `` gli asterischi
124
+ * sono contenuto. Ne discende l'ordine dei rami qui sotto.
125
+ */
126
+ function scanInline(src, base) {
127
+ let text = '';
128
+ const spans = [];
129
+ /** Inizio del tratto corrente non ancora coperto da uno span inline. */
130
+ let plain = 0;
131
+ let i = 0;
132
+ const closePlain = () => {
133
+ if (base && text.length > plain)
134
+ spans.push({ start: plain, end: text.length, kind: base });
135
+ plain = text.length;
136
+ };
137
+ while (i < src.length) {
138
+ const ch = src[i];
139
+ if (ch === '`') {
140
+ // Delimitatore a N backtick: chiude su una sequenza di ESATTAMENTE N.
141
+ // Serve davvero, non è zelo — la doc di questo progetto cita i backtick
142
+ // scrivendo `` `code` ``, e un parser a un backtick solo la ridurrebbe a
143
+ // poltiglia.
144
+ let n = 0;
145
+ while (src[i + n] === '`')
146
+ n++;
147
+ const close = findFenceRun(src, i + n, n);
148
+ if (close >= 0) {
149
+ closePlain();
150
+ const start = text.length;
151
+ text += stripPadding(src.slice(i + n, close));
152
+ if (text.length > start)
153
+ spans.push({ start, end: text.length, kind: 'code' });
154
+ plain = text.length;
155
+ i = close + n;
156
+ continue;
157
+ }
158
+ // Non si chiude entro la riga: i backtick sono testo.
159
+ text += src.slice(i, i + n);
160
+ i += n;
161
+ continue;
162
+ }
163
+ if (ch === '*' && src[i + 1] === '*') {
164
+ const close = src.indexOf('**', i + 2);
165
+ if (close >= 0) {
166
+ closePlain();
167
+ const start = text.length;
168
+ // Il contenuto si riparsa: `**testo con `codice`**` è legittimo, e il
169
+ // codice dentro vince sul grassetto perché è più specifico.
170
+ const inner = scanInline(src.slice(i + 2, close), base ?? 'bold');
171
+ text += inner.text;
172
+ for (const s of inner.spans) {
173
+ spans.push({ start: start + s.start, end: start + s.end, kind: s.kind });
174
+ }
175
+ plain = text.length;
176
+ i = close + 2;
177
+ continue;
178
+ }
179
+ text += '**';
180
+ i += 2;
181
+ continue;
182
+ }
183
+ text += ch;
184
+ i++;
185
+ }
186
+ closePlain();
187
+ return { text, spans: coalesce(spans) };
188
+ }
189
+ /** Prima sequenza di ESATTAMENTE `n` backtick a partire da `from`; -1 se non
190
+ * c'è. Una sequenza più lunga non chiude — è la regola CommonMark, ed è ciò
191
+ * che permette a `` `` ` `` `` di contenere un backtick singolo. */
192
+ function findFenceRun(s, from, n) {
193
+ for (let i = from; i < s.length; i++) {
194
+ if (s[i] !== '`')
195
+ continue;
196
+ let k = 0;
197
+ while (s[i + k] === '`')
198
+ k++;
199
+ if (k === n)
200
+ return i;
201
+ i += k - 1;
202
+ }
203
+ return -1;
204
+ }
205
+ /** Uno spazio di cortesia in testa e in coda si toglie: serve a scrivere
206
+ * `` ` `` senza incollarlo ai delimitatori, non a spaziare il risultato. */
207
+ function stripPadding(s) {
208
+ if (s.length > 1 && s.startsWith(' ') && s.endsWith(' ') && s.trim().length > 0) {
209
+ return s.slice(1, -1);
210
+ }
211
+ return s;
212
+ }
213
+ /** Fonde gli intervalli adiacenti dello stesso kind: il riempimento del tratto
214
+ * base ne produce a raffica, e ognuno costerebbe un `<Text>` in più al
215
+ * render. */
216
+ function coalesce(spans) {
217
+ const out = [];
218
+ for (const s of spans) {
219
+ const last = out[out.length - 1];
220
+ if (last && last.kind === s.kind && last.end === s.start)
221
+ last.end = s.end;
222
+ else
223
+ out.push({ ...s });
224
+ }
225
+ return out;
226
+ }
227
+ /**
228
+ * Taglia una riga wrappata nei pezzi da colorare, intersecando gli offset.
229
+ *
230
+ * Gemello di `sliceLine` (`text-search.ts`), che fa lo stesso lavoro con un
231
+ * intervallo booleano invece che tipizzato: la riga è una fetta CONTIGUA del
232
+ * testo reso, quindi uno span a cavallo dell'a-capo si colora su entrambe le
233
+ * righe senza nessun caso speciale — entrambe lo intersecano.
234
+ *
235
+ * Restituisce sempre la copertura completa della riga, buchi inclusi come
236
+ * segmenti `kind: null`: chi renderizza concatena e basta, senza dover
237
+ * ricostruire ciò che è rimasto fuori.
238
+ */
239
+ export function sliceSpans(line, spans) {
240
+ const out = [];
241
+ const { text, start } = line;
242
+ let pos = 0;
243
+ for (const s of spans) {
244
+ if (s.end <= start)
245
+ continue;
246
+ if (s.start >= start + text.length)
247
+ break; // `spans` è ordinato: il resto è oltre
248
+ const a = Math.max(0, Math.min(text.length, s.start - start));
249
+ const b = Math.max(0, Math.min(text.length, s.end - start));
250
+ if (b <= a || a < pos)
251
+ continue;
252
+ if (a > pos)
253
+ out.push({ text: text.slice(pos, a), kind: null });
254
+ out.push({ text: text.slice(a, b), kind: s.kind });
255
+ pos = b;
256
+ }
257
+ if (pos < text.length)
258
+ out.push({ text: text.slice(pos), kind: null });
259
+ return out;
260
+ }
@@ -0,0 +1,135 @@
1
+ // T100 — Catalogo delle viste dei due pane. L'header smette di essere una riga
2
+ // di contatori e diventa un SELETTORE: ogni segmento che nomina un sottoinsieme
3
+ // calcolabile (`nascoste`, `archiviabili`, `vive`, `📌`, `+più vecchie`) è una
4
+ // vista raggiungibile con ←/→.
5
+ //
6
+ // Modulo PURO: nessun import da ink/react, nessun I/O. Il calcolo resta dove
7
+ // stava — `view.ts` per i filtri task, `session-list.ts` per l'assemblaggio a
8
+ // due gruppi — qui c'è solo la mappa di quali sottoinsiemi esistono, come si
9
+ // chiamano e con che numero.
10
+ //
11
+ // Invarianti (decisioni congelate al create-task e al preflight):
12
+ // - D1 create — catalogo FISSO: le voci ci sono tutte anche a contatore 0. Un
13
+ // catalogo che si accorcia quando un numero va a zero sposta le voci sotto le
14
+ // dita e può far svanire la vista corrente mentre la guardi (togliere un
15
+ // filtro mentre sei su `nascoste`).
16
+ // - D1 preflight — navigazione CICLICA, come `DETAIL_ACTIONS` e le righe di
17
+ // scelta del modale edit.
18
+ // - Il numero di una voce si misura SEMPRE sulla vista di default, mai su
19
+ // quella attiva: altrimenti navigare cambierebbe i contatori che si sta
20
+ // navigando, e il catalogo tornerebbe dinamico per la via di dietro.
21
+ // - `↑N`/`↓N` non stanno qui: contano elementi fuori finestra per altezza del
22
+ // terminale, cioè una POSIZIONE, non un insieme.
23
+ import { isVisible, compareTasks } from './view.js';
24
+ export const TASK_VIEWS = [
25
+ {
26
+ id: 'tasks',
27
+ label: (c) => `Tasks (${c.hidden > 0 ? `${c.filtered}/${c.total}` : c.filtered})`,
28
+ count: (c) => c.filtered,
29
+ has: (t, ctx) => isVisible(t, ctx.view),
30
+ empty: 'nessuna task in vista: i filtri le escludono tutte',
31
+ },
32
+ {
33
+ // `yellow` come prima: avverte che la vista è distorta da un filtro.
34
+ id: 'hidden',
35
+ label: (c) => `${c.hidden} nascoste`,
36
+ count: (c) => c.hidden,
37
+ has: (t, ctx) => !isVisible(t, ctx.view),
38
+ color: 'yellow',
39
+ empty: 'nessuna task nascosta: nessun filtro ne esclude',
40
+ },
41
+ {
42
+ // T61 — dim: le Done oltre soglia non alterano ciò che stai guardando.
43
+ id: 'archivable',
44
+ label: (c) => `${c.archivable} archiviabili`,
45
+ count: (c) => c.archivable,
46
+ has: (t, ctx) => ctx.archivable.has(t.id),
47
+ dim: true,
48
+ empty: "nessuna Done oltre la soglia d'età",
49
+ },
50
+ ];
51
+ /**
52
+ * Le task della vista `id`, filtrate col predicato e ordinate con la chain
53
+ * corrente. L'ordinamento vale per ogni vista, non solo la principale: una lista
54
+ * di task ordinata in un modo e l'altra in un altro sarebbe un secondo asse che
55
+ * si muove da solo.
56
+ */
57
+ export function selectTasks(tasks, id, ctx) {
58
+ const entry = taskView(id);
59
+ const picked = tasks.filter((t) => entry.has(t, ctx));
60
+ picked.sort((a, b) => compareTasks(a, b, ctx.view.sort));
61
+ return picked;
62
+ }
63
+ /** Glifo della sessione viva, gemello di `LIVE_IDLE` del render. Duplicato qui
64
+ * perché il catalogo è puro e non può importare da `cli.tsx`; è un carattere
65
+ * concorde (largo 1 per entrambe le contabilità), quindi non passa da
66
+ * `sanitize`. */
67
+ const LIVE_GLYPH = '●';
68
+ /** Righe selezionabili della lista assemblata (il separatore non è una riga di
69
+ * contenuto: fuori dalla vista di default i due gruppi non ci sono più). */
70
+ function selectable(rows) {
71
+ return rows.filter((r) => r.kind !== 'separator');
72
+ }
73
+ export const SESSION_VIEWS = [
74
+ {
75
+ id: 'context',
76
+ label: (c, parent) => `${parent} (${c.total})`,
77
+ count: (c) => c.total,
78
+ rows: (ctx) => ctx.assembled.rows,
79
+ empty: null,
80
+ },
81
+ {
82
+ id: 'live',
83
+ label: (c) => `${LIVE_GLYPH}${c.live} vive`,
84
+ count: (c) => c.live,
85
+ rows: (ctx) => selectable(ctx.assembled.rows).filter((r) => ctx.isLive(r.sessionId)),
86
+ color: 'green',
87
+ empty: 'nessuna conversazione viva in questa lista',
88
+ },
89
+ {
90
+ id: 'pinned',
91
+ label: (c) => `📌${c.pinned}`,
92
+ count: (c) => c.pinned,
93
+ rows: (ctx) => ctx.assembled.rows.filter((r) => r.kind === 'pinned'),
94
+ color: 'yellow',
95
+ empty: 'nessuna conversazione pinnata',
96
+ },
97
+ {
98
+ // D4 preflight — la coda troncata, non la lista intera senza cap: il numero
99
+ // dell'header e il numero di righe devono coincidere.
100
+ id: 'older',
101
+ label: (c) => `+${c.older} più vecchie`,
102
+ count: (c) => c.older,
103
+ rows: (ctx) => ctx.assembled.contextOverflow.map((s) => ({
104
+ kind: 'context',
105
+ sessionId: s.sessionId,
106
+ session: s,
107
+ })),
108
+ dim: true,
109
+ empty: 'nessuna conversazione oltre il cap',
110
+ },
111
+ ];
112
+ export function selectSessionRows(id, ctx) {
113
+ return sessionView(id).rows(ctx);
114
+ }
115
+ // ── lookup e navigazione ───────────────────────────────────────────────────
116
+ export function taskView(id) {
117
+ return TASK_VIEWS.find((v) => v.id === id) ?? TASK_VIEWS[0];
118
+ }
119
+ export function sessionView(id) {
120
+ return SESSION_VIEWS.find((v) => v.id === id) ?? SESSION_VIEWS[0];
121
+ }
122
+ /** Scorrimento ciclico del catalogo: `→` sull'ultima voce torna alla prima. Id
123
+ * ignoto → prima voce, mai un indice negativo. */
124
+ function cycle(catalog, current, delta) {
125
+ const at = catalog.findIndex((v) => v.id === current);
126
+ if (at < 0)
127
+ return catalog[0];
128
+ return catalog[(at + delta + catalog.length) % catalog.length];
129
+ }
130
+ export function cycleTaskView(current, delta) {
131
+ return cycle(TASK_VIEWS, current, delta).id;
132
+ }
133
+ export function cycleSessionView(current, delta) {
134
+ return cycle(SESSION_VIEWS, current, delta).id;
135
+ }
@@ -134,7 +134,9 @@ export function assembleSessionList(childSessions, allSessions, pinned, maxConte
134
134
  });
135
135
  const pinnedSet = new Set(pinnedIds);
136
136
  const dedupedContext = childSessions.filter((s) => !pinnedSet.has(s.sessionId));
137
- const shownContext = dedupedContext.slice(0, Math.max(0, maxContext));
137
+ const cap = Math.max(0, maxContext);
138
+ const shownContext = dedupedContext.slice(0, cap);
139
+ const overflowContext = dedupedContext.slice(cap);
138
140
  const contextRows = shownContext.map((s) => ({
139
141
  kind: 'context',
140
142
  sessionId: s.sessionId,
@@ -150,7 +152,8 @@ export function assembleSessionList(childSessions, allSessions, pinned, maxConte
150
152
  rows,
151
153
  pinnedCount: pinnedRows.length,
152
154
  contextTotal: dedupedContext.length,
153
- contextHidden: dedupedContext.length - shownContext.length,
155
+ contextHidden: overflowContext.length,
156
+ contextOverflow: overflowContext,
154
157
  };
155
158
  }
156
159
  function isSelectable(r) {
package/dist/width.js CHANGED
@@ -224,8 +224,16 @@ export function cut(s, cols) {
224
224
  * portano glifi larghi 2 (`📌` nelle sessioni, i glifi di priorità nei filtri
225
225
  * delle task), cioè la condizione esatta in cui `cli-truncate` restituisce una
226
226
  * riga più larga del budget e il bordo del pane sparisce.
227
+ *
228
+ * T100/D6 — `priority` è l'indice del pezzo che il budget serve PER PRIMO,
229
+ * quando la riga porta un segmento su cui si sta agendo (la vista attiva di un
230
+ * header-selettore). Gli altri si mostrano solo se avanza spazio, e cadono dalla
231
+ * coda come prima. L'ORDINE DI RESA non cambia — cambia chi paga il taglio: su
232
+ * un terminale stretto sparisce un contatore, mai la voce che dice dove sei.
233
+ * Riservare non allarga la riga: resta un tetto, e con `priority` da solo più
234
+ * largo del budget è lui a essere tagliato.
227
235
  */
228
- export function cutParts(parts, cols) {
236
+ export function cutParts(parts, cols, priority = -1) {
229
237
  const empty = parts.map(() => '');
230
238
  if (cols <= 0)
231
239
  return empty;
@@ -234,10 +242,45 @@ export function cutParts(parts, cols) {
234
242
  const out = [...empty];
235
243
  const budget = cols - 1; // -1 = la colonna dell'ellissi, che qui c'è di sicuro
236
244
  let w = 0;
237
- let full = false;
238
- for (let i = 0; i < parts.length && !full; i++) {
245
+ const hasPriority = priority >= 0 && priority < parts.length;
246
+ if (hasPriority) {
247
+ // Il prioritario prende il suo spazio per primo, e se non ci sta intero è
248
+ // lui a essere tagliato: dev'esserci comunque, anche monco.
239
249
  let acc = '';
240
- for (const ch of parts[i]) {
250
+ for (const ch of parts[priority]) {
251
+ const cw = termWidth(ch);
252
+ if (w + cw > budget)
253
+ break;
254
+ acc += ch;
255
+ w += cw;
256
+ }
257
+ out[priority] = acc;
258
+ }
259
+ for (let i = 0; i < parts.length; i++) {
260
+ if (hasPriority && i === priority)
261
+ continue;
262
+ const part = parts[i];
263
+ const pw = termWidth(part);
264
+ if (hasPriority) {
265
+ // Col prioritario già servito il taglio a metà di un altro segmento
266
+ // lascerebbe a schermo un moncone seguito da una voce intera, che si legge
267
+ // come due cose slegate invece che come una riga troncata: qui un pezzo o
268
+ // entra tutto o cede il posto.
269
+ //
270
+ // E chi cede si porta dietro tutti quelli dopo di lui, invece di lasciare
271
+ // passare il primo più corto: l'ordine dell'array È l'ordine di
272
+ // importanza (le voci navigabili prima dei contatori di scroll), e un
273
+ // riempimento a buchi lo scavalcherebbe — l'header mostrerebbe `↓46`
274
+ // avendo buttato la voce che si può selezionare.
275
+ if (w + pw > budget)
276
+ break;
277
+ out[i] = part;
278
+ w += pw;
279
+ continue;
280
+ }
281
+ let acc = '';
282
+ let full = false;
283
+ for (const ch of part) {
241
284
  const cw = termWidth(ch);
242
285
  if (w + cw > budget) {
243
286
  full = true;
@@ -247,7 +290,15 @@ export function cutParts(parts, cols) {
247
290
  w += cw;
248
291
  }
249
292
  out[i] = acc;
293
+ if (full)
294
+ break;
250
295
  }
296
+ // Col prioritario servito per primo il primo pezzo a schermo può non essere il
297
+ // pezzo 0, e i segmenti portano il proprio separatore in testa: senza questa
298
+ // potatura la riga aprirebbe con un ` · ` appeso al nulla.
299
+ const first = out.findIndex((p) => p !== '');
300
+ if (first > 0)
301
+ out[first] = out[first].replace(/^[\s·]+/, '');
251
302
  // L'ellissi va sull'ultimo pezzo che ha davvero del testo: appiccicarla a un
252
303
  // pezzo vuoto la staccherebbe dal punto di taglio, e il lettore la leggerebbe
253
304
  // come un segmento a sé.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.31.0",
3
+ "version": "0.33.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": {