@lamemind/loom-deck 0.54.1 → 0.55.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/cli.js CHANGED
@@ -6,7 +6,7 @@ import { randomUUID } from 'node:crypto';
6
6
  import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskFileText, } from './tasks.js';
7
7
  import { rowIndexOfKey, selectedRow, } from './search.js';
8
8
  import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, } from './task-index.js';
9
- import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, selectedSession, unpinLandingId, } from './session-list.js';
9
+ import { assembleSessionList, firstSelectableId, moveSelection, neighborId, rowIndexOf, selectedSession, } from './session-list.js';
10
10
  import { cellWidth, launchLegend, LAUNCH_SEP, loadArchivableDays, loadIdentity, loadLaunch, } from './config.js';
11
11
  import { anchorFrame, enableMouse, FRAME_TEXT_COL, hitRegion, inlineRegions, isWheel, LAUNCH_ROW, listHit, PANE_TEXT_PAD, paneSpans, rowRegions, takeMouse, WHEEL_LINES, wheelDir, } from './mouse.js';
12
12
  import { headerItems, sessionHeaderParts, taskHeaderParts } from './pane-header.js';
@@ -251,9 +251,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
251
251
  const bound = bindings.get(s.sessionId);
252
252
  return selectedTaskId ? bound === selectedTaskId : !bound;
253
253
  }), [sessions, bindings, selectedTaskId, isAll]);
254
- // T50 — lista a due gruppi: pinnate (sempre, in cima) + separatore +
255
- // contestuali. Dedup, cap solo sulle contestuali, righe stale per le pinnate
256
- // orfane. Core PURO in session-list.ts (testabile senza Ink).
254
+ // T133 — lista unica: le figlie del parent, `ts desc`, cap su tutte. Le
255
+ // pinnate del progetto escono a parte (`pinnedRows`), per la sola vista `📌`.
256
+ // Core PURO in session-list.ts (testabile senza Ink).
257
257
  const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, isAll ? MAX_SESSIONS_ALL : MAX_SESSIONS), [childSessions, sessions, pinned, isAll]);
258
258
  // T62 — contato sulla lista INTERA (stessa ragione delle larghezze di colonna
259
259
  // qui sotto): derivarlo dalla finestra visibile lo farebbe cambiare a ogni
@@ -261,16 +261,14 @@ function Deck({ cwd, tasksPath, tasksDir }) {
261
261
  // T100 — «intera» ora vuol dire la lista della vista di DEFAULT (`assembled`),
262
262
  // non quella a schermo: il contatore di una voce del catalogo non può
263
263
  // dipendere da quale voce è selezionata, o navigare muoverebbe i numeri.
264
- const liveCount = useMemo(() => assembled.rows.filter((r) => r.kind !== 'separator' && live.has(r.sessionId)).length, [assembled, live]);
264
+ const liveCount = useMemo(() => assembled.rows.filter((r) => live.has(r.sessionId)).length, [assembled, live]);
265
265
  const sessionCounts = {
266
- total: assembled.pinnedCount + assembled.contextTotal,
266
+ total: assembled.contextTotal,
267
267
  live: liveCount,
268
- pinned: assembled.pinnedCount,
269
- older: assembled.contextHidden,
268
+ pinned: assembled.pinnedRows.length,
269
+ older: assembled.overflowRows.length,
270
270
  };
271
- // T100 — le righe a schermo sono quelle della vista attiva. Fuori dalla vista
272
- // di default il separatore non c'è: segna il confine fra pinnate e
273
- // contestuali, e in un sottoinsieme quel confine non esiste più.
271
+ // T100 — le righe a schermo sono quelle della vista attiva.
274
272
  const sessionRows = useMemo(() => selectSessionRows(sessionViewId, { assembled, isLive: (id) => live.has(id) }), [sessionViewId, assembled, live]);
275
273
  const selSessionObj = selectedSession(sessionRows, selSessionId);
276
274
  // T60 — larghezze delle colonne fisse della lista sessioni, misurate sulla
@@ -282,18 +280,15 @@ function Deck({ cwd, tasksPath, tasksDir }) {
282
280
  let task = 0;
283
281
  let age = 2;
284
282
  for (const r of sessionRows) {
285
- if (r.kind === 'separator')
286
- continue;
287
- // La colonna serve dove l'appartenenza NON è già scritta altrove sullo
288
- // schermo: nella vista "tutte" (ogni riga ha un binding proprio) e su
289
- // OGNI riga pinnata, in qualunque vista — una pinnata è sganciata dal
290
- // parent selezionato, quindi l'header del pane non parla per lei.
291
- if (isAll || r.kind === 'pinned') {
283
+ // T133 D10 — la colonna serve dove l'appartenenza NON è già scritta
284
+ // altrove sullo schermo, cioè nella sola vista "tutte": ogni altra lista
285
+ // contiene le figlie del parent selezionato, che l'header nomina.
286
+ if (isAll) {
292
287
  const b = bindings.get(r.sessionId);
293
288
  if (b)
294
289
  task = Math.max(task, termWidth(b));
295
290
  }
296
- if (r.session)
291
+ if (r.kind === 'session')
297
292
  age = Math.max(age, termWidth(relTime(r.session.ts)));
298
293
  }
299
294
  // La cella vuota deve poter entrare nella colonna, o le righe spot
@@ -1122,7 +1117,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1122
1117
  }
1123
1118
  else {
1124
1119
  const row = windowRows[hit.index];
1125
- if (!row || row.kind === 'separator')
1120
+ if (!row)
1126
1121
  return;
1127
1122
  if (focus === 'sessions' && row.sessionId === selSessionId) {
1128
1123
  onKey('', { ...NO_MODIFIERS, return: true });
@@ -1337,12 +1332,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
1337
1332
  }
1338
1333
  else {
1339
1334
  const isPinned = pinned.has(selSessionId);
1340
- // Dove atterra la selezione: sul pin resta sulla sessione (sale in cima
1341
- // con lei, il caret la segue perché è l'oggetto dell'azione); sull'unpin
1342
- // resta IN PLACE nel gruppo pinnate successiva, precedente, o prima
1343
- // contestuale se il gruppo si svuota. Calcolato PRIMA della riscrittura
1344
- // del sidecar, quando la riga pinnata esiste ancora.
1345
- const landing = isPinned ? unpinLandingId(sessionRows, selSessionId) : null;
1335
+ // T133 D12 il caret si sposta solo quando la riga ESCE dalla lista, e
1336
+ // succede nella sola vista `📌` (spinnare toglie la riga dall'insieme che
1337
+ // la vista mostra). Altrove pin e unpin non riordinano niente: la riga è
1338
+ // in lista perché è figlia del parent, e ci resta al suo posto. Il vicino
1339
+ // va calcolato PRIMA di riscrivere il sidecar, quando la riga c'è ancora.
1340
+ const landing = isPinned && sessionViewId === 'pinned' ? neighborId(sessionRows, selSessionId) : null;
1346
1341
  appendPin(cwd, selSessionId, !isPinned);
1347
1342
  reloadSessions();
1348
1343
  if (landing)
package/dist/glyphs.js CHANGED
@@ -8,10 +8,6 @@ import { sanitize } from './width.js';
8
8
  export const CARET = sanitize('▶ ');
9
9
  export const CARET_OFF = ' ';
10
10
  export const WARN = sanitize('⚠');
11
- // T50 — separatore leggero fra blocco pinnate e contestuali. `─` (box-drawing) è
12
- // largo 1 sia per string-width sia per il terminale. Corto +
13
- // wrap="truncate-end" così non va mai a capo nel pane al 50%.
14
- export const SESSION_SEP = '─'.repeat(16);
15
11
  // Prefisso del sessionId mostrato in lista: stesso dato e stessa lunghezza del
16
12
  // widget `⛓ <8 char>` della statusline, così le due superfici si confrontano a
17
13
  // occhio.
@@ -4,8 +4,8 @@
4
4
  // vista raggiungibile con `tab`.
5
5
  //
6
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
7
+ // stava — `view.ts` per i filtri task, `session-list.ts` per l'assemblaggio
8
+ // della lista — qui c'è solo la mappa di quali sottoinsiemi esistono, come si
9
9
  // chiamano e con che numero.
10
10
  //
11
11
  // Invarianti (decisioni congelate al create-task e al preflight):
@@ -65,11 +65,6 @@ export function selectTasks(tasks, id, ctx) {
65
65
  * concorde (largo 1 per entrambe le contabilità), quindi non passa da
66
66
  * `sanitize`. */
67
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
68
  export const SESSION_VIEWS = [
74
69
  {
75
70
  id: 'context',
@@ -82,15 +77,19 @@ export const SESSION_VIEWS = [
82
77
  id: 'live',
83
78
  label: (c) => `${LIVE_GLYPH}${c.live} vive`,
84
79
  count: (c) => c.live,
85
- rows: (ctx) => selectable(ctx.assembled.rows).filter((r) => ctx.isLive(r.sessionId)),
80
+ rows: (ctx) => ctx.assembled.rows.filter((r) => ctx.isLive(r.sessionId)),
86
81
  color: 'green',
87
82
  empty: 'nessuna conversazione viva in questa lista',
88
83
  },
89
84
  {
85
+ // T133 D7 — sola vista che NON filtra la lista: il suo insieme è più largo
86
+ // (le pinnate di ogni task, più le stale), quindi il core lo consegna a
87
+ // parte. Un filtro su `rows` mostrerebbe le sole pinnate del parent
88
+ // selezionato, cioè la risposta sbagliata alla domanda «quali ho pinnato».
90
89
  id: 'pinned',
91
90
  label: (c) => `📌${c.pinned}`,
92
91
  count: (c) => c.pinned,
93
- rows: (ctx) => ctx.assembled.rows.filter((r) => r.kind === 'pinned'),
92
+ rows: (ctx) => ctx.assembled.pinnedRows,
94
93
  color: 'yellow',
95
94
  empty: 'nessuna conversazione pinnata',
96
95
  },
@@ -100,11 +99,7 @@ export const SESSION_VIEWS = [
100
99
  id: 'older',
101
100
  label: (c) => `+${c.older} più vecchie`,
102
101
  count: (c) => c.older,
103
- rows: (ctx) => ctx.assembled.contextOverflow.map((s) => ({
104
- kind: 'context',
105
- sessionId: s.sessionId,
106
- session: s,
107
- })),
102
+ rows: (ctx) => ctx.assembled.overflowRows,
108
103
  dim: true,
109
104
  empty: 'nessuna conversazione oltre il cap',
110
105
  },
@@ -1,19 +1,26 @@
1
- // T50 — Assemblaggio della lista sessioni a DUE gruppi: pinnate (sempre in
2
- // lista, in cima) + separatore + contestuali (figlie del parent selezionato).
1
+ // T133 — Assemblaggio della lista sessioni: UNA lista sola, le figlie del parent
2
+ // selezionato in ordine `ts desc`. Il pin è un ATTRIBUTO della riga (glifo, vista
3
+ // dedicata), non una posizione privilegiata.
3
4
  // Modulo PURO: nessun import da ink/react, nessun I/O → testabile senza terminale
4
5
  // (il pacchetto non ha infrastruttura TUI di test, solo unit sui core puri).
5
6
  //
6
- // Invarianti (decisioni congelate al preflight):
7
- // - D2: dentro il blocco pinnate l'ordine è di PIN stabile (ultima pinnata in
8
- // cima), dato dal rango del sidecar `session-tasks.jsonl` non `ts desc`.
9
- // - Dedup: una sessione sia pinnata sia contestuale compare SOLO fra le pinnate.
10
- // - Cap: `maxContext` limita SOLO le contestuali; le pinnate sono esenti. Il
11
- // conteggio delle troncate (`contextHidden`) resta esposto (non-silenzioso).
12
- // - D3: una pinnata il cui transcript non esiste più è una riga STALE (session
13
- // null), navigabile e spinnabile non sparisce in silenzio.
14
- // - Trap T39: la selezione è KEYED SU sessionId, non su indice posizionale. La
15
- // lista è una vista trasformata (due gruppi + separatore): un indice grezzo
16
- // identificherebbe la riga sbagliata dopo un pin/cambio-contesto.
7
+ // Invarianti (decisioni congelate al preflight T133):
8
+ // - D1/D3: una forma sola di lista qualunque sia il parent, vista «tutte»
9
+ // compresa. Nessun criterio speciale per le righe pinnate: nessun blocco in
10
+ // cima, nessun ordine proprio, nessuna esenzione dal cap (D6).
11
+ // - D4: una pinnata compare in lista se e solo se è figlia del parent
12
+ // selezionato. Le pinnate fuori contesto vivono nella sola vista `📌`.
13
+ // - D7: le righe pinnate escono in un CAMPO A PARTE (`pinnedRows`), non dentro
14
+ // `rows`: il loro insieme è più largo della lista (tutte le sessioni del
15
+ // progetto più le stale), quindi non è esprimibile come filtro di `rows`.
16
+ // - D8: `pinnedRows` conserva l'ordine per RANGO di pin desc (ultima pinnata in
17
+ // cima), dato dal sidecar `session-tasks.jsonl`. È l'unico lettore rimasto del
18
+ // rango.
19
+ // - D5: una pinnata il cui transcript non esiste più è una riga STALE, presente
20
+ // nelle sole `pinnedRows` — navigabile e spinnabile dalla vista `📌`.
21
+ // - Trap T39: la selezione è KEYED SU sessionId, non su indice posizionale. Le
22
+ // righe a schermo sono quelle della vista attiva, e un indice grezzo
23
+ // identificherebbe la riga sbagliata al primo cambio di vista o di parent.
17
24
  import { cut, termWidth } from './width.js';
18
25
  // ── T53 · etichetta di riga ────────────────────────────────────────────────
19
26
  /**
@@ -121,66 +128,50 @@ export function rowLabel(text, note, budget) {
121
128
  return { note: shownNote, rest: restBudget >= MIN_REST ? cut(rest, restBudget) : '' };
122
129
  }
123
130
  export function assembleSessionList(childSessions, allSessions, pinned, maxContext) {
124
- // Le pinnate si risolvono contro TUTTE le sessioni del progetto, non solo le
125
- // figlie del parent: una pinnata può appartenere a un'altra task. Assente
126
- // dallo store → stale.
127
- const byId = new Map(allSessions.map((s) => [s.sessionId, s]));
128
- const pinnedIds = [...pinned.entries()]
129
- .sort((a, b) => b[1] - a[1]) // rango desc → ultima pinnata in cima (D2)
130
- .map(([id]) => id);
131
- const pinnedRows = pinnedIds.map((id) => {
132
- const session = byId.get(id) ?? null;
133
- return { kind: 'pinned', sessionId: id, session, stale: session === null };
134
- });
135
- const pinnedSet = new Set(pinnedIds);
136
- const dedupedContext = childSessions.filter((s) => !pinnedSet.has(s.sessionId));
137
131
  const cap = Math.max(0, maxContext);
138
- const shownContext = dedupedContext.slice(0, cap);
139
- const overflowContext = dedupedContext.slice(cap);
140
- const contextRows = shownContext.map((s) => ({
141
- kind: 'context',
132
+ const toRow = (s) => ({
133
+ kind: 'session',
142
134
  sessionId: s.sessionId,
143
135
  session: s,
144
- }));
145
- const rows = [...pinnedRows];
146
- // Separatore SOLO fra due gruppi entrambi non vuoti: senza pinnate o senza
147
- // contestuali non c'è confine da segnare.
148
- if (pinnedRows.length > 0 && contextRows.length > 0)
149
- rows.push({ kind: 'separator' });
150
- rows.push(...contextRows);
151
- return {
152
- rows,
153
- pinnedCount: pinnedRows.length,
154
- contextTotal: dedupedContext.length,
155
- contextHidden: overflowContext.length,
156
- contextOverflow: overflowContext,
157
- };
158
- }
159
- function isSelectable(r) {
160
- return r.kind !== 'separator';
136
+ pinned: pinned.has(s.sessionId),
137
+ });
138
+ const rows = childSessions.slice(0, cap).map(toRow);
139
+ const overflowRows = childSessions.slice(cap).map(toRow);
140
+ // Le pinnate si risolvono contro TUTTE le sessioni del progetto, non solo le
141
+ // figlie del parent: una pinnata può appartenere a un'altra task, e la vista
142
+ // dedicata è l'unico posto dove resta raggiungibile. Assente dallo store →
143
+ // stale.
144
+ const byId = new Map(allSessions.map((s) => [s.sessionId, s]));
145
+ const pinnedRows = [...pinned.entries()]
146
+ .sort((a, b) => b[1] - a[1]) // rango desc → ultima pinnata in cima (D8)
147
+ .map(([id]) => {
148
+ const session = byId.get(id);
149
+ return session
150
+ ? { kind: 'session', sessionId: id, session, pinned: true }
151
+ : { kind: 'stale', sessionId: id };
152
+ });
153
+ return { rows, pinnedRows, contextTotal: childSessions.length, overflowRows };
161
154
  }
162
- /** Primo id selezionabile (salta il separatore); null = lista senza righe. */
155
+ /** Primo id della lista; null = lista senza righe. */
163
156
  export function firstSelectableId(rows) {
164
- return rows.find(isSelectable)?.sessionId ?? null;
157
+ return rows[0]?.sessionId ?? null;
165
158
  }
166
159
  /** Indice della riga con quel sessionId nell'ARRAY COMPLETO (per il windowing);
167
- * -1 se assente o id null. Il separatore non ha id → mai matchato. */
160
+ * -1 se assente o id null. */
168
161
  export function rowIndexOf(rows, sessionId) {
169
162
  if (sessionId === null)
170
163
  return -1;
171
- return rows.findIndex((r) => isSelectable(r) && r.sessionId === sessionId);
164
+ return rows.findIndex((r) => r.sessionId === sessionId);
172
165
  }
173
- /** Sposta la selezione di `delta` fra le sole righe selezionabili (attraversa il
174
- * separatore senza fermarcisi). Id perso → prima riga; lista vuota → null. */
166
+ /** Sposta la selezione di `delta`. Id perso prima riga; lista vuota → null. */
175
167
  export function moveSelection(rows, currentId, delta) {
176
- const selectable = rows.filter(isSelectable);
177
- if (selectable.length === 0)
168
+ if (rows.length === 0)
178
169
  return null;
179
- const cur = selectable.findIndex((r) => r.sessionId === currentId);
170
+ const cur = rows.findIndex((r) => r.sessionId === currentId);
180
171
  if (cur < 0)
181
- return selectable[0].sessionId;
182
- const next = Math.max(0, Math.min(selectable.length - 1, cur + delta));
183
- return selectable[next].sessionId;
172
+ return rows[0].sessionId;
173
+ const next = Math.max(0, Math.min(rows.length - 1, cur + delta));
174
+ return rows[next].sessionId;
184
175
  }
185
176
  /**
186
177
  * T57 — id su cui atterrare quando la riga `sessionId` sta per USCIRE dalla
@@ -192,44 +183,19 @@ export function moveSelection(rows, currentId, delta) {
192
183
  * assegnazione rimanderebbe la selezione in cima. L'id va catturato PRIMA di
193
184
  * riscrivere il sidecar — dopo, la riga non c'è più e il vicino non è
194
185
  * calcolabile.
195
- */
196
- export function neighborId(rows, sessionId) {
197
- const selectable = rows.filter(isSelectable);
198
- const at = selectable.findIndex((r) => r.sessionId === sessionId);
199
- if (at < 0)
200
- return null;
201
- return selectable[at + 1]?.sessionId ?? selectable[at - 1]?.sessionId ?? null;
202
- }
203
- /**
204
- * Id su cui atterrare quando la riga pinnata `sessionId` viene SPINNATA: la
205
- * pinnata successiva, la precedente se era l'ultima, la prima contestuale se il
206
- * gruppo pinnate si svuota. `null` se `sessionId` non è pinnata o non resta
207
- * nessuna riga.
208
186
  *
209
- * Non è `neighborId` ristretto al gruppo: quello attraversa il separatore,
210
- * quindi sull'ULTIMA pinnata atterrerebbe sulla prima contestuale il caret
211
- * scavalcherebbe l'intero gruppo invece di risalire di una riga. Qui il confine
212
- * fra i due gruppi è la regola, non un ostacolo.
213
- *
214
- * L'id va calcolato PRIMA di riscrivere il sidecar: dopo, la riga pinnata non
215
- * c'è più e il vicino nel gruppo non è calcolabile. Serve perché una spinnata
216
- * non sparisce necessariamente dalla lista — se appartiene al parent
217
- * selezionato ricompare fra le contestuali, e la selezione keyed sull'id la
218
- * seguirebbe fin laggiù, trascinando il caret.
187
+ * T133 D12 stesso servizio per l'unpin dentro la vista `📌`, dove spinnare fa
188
+ * uscire la riga. Nella lista principale non serve: una pinnata è in lista
189
+ * perché è figlia del parent, e spinnarla non la fa uscire.
219
190
  */
220
- export function unpinLandingId(rows, sessionId) {
221
- const pinnedRows = rows.filter((r) => r.kind === 'pinned');
222
- const at = pinnedRows.findIndex((r) => r.sessionId === sessionId);
191
+ export function neighborId(rows, sessionId) {
192
+ const at = rows.findIndex((r) => r.sessionId === sessionId);
223
193
  if (at < 0)
224
194
  return null;
225
- const inGroup = pinnedRows[at + 1]?.sessionId ?? pinnedRows[at - 1]?.sessionId;
226
- if (inGroup)
227
- return inGroup;
228
- const firstContext = rows.find((r) => r.kind === 'context');
229
- return firstContext && isSelectable(firstContext) ? firstContext.sessionId : null;
195
+ return rows[at + 1]?.sessionId ?? rows[at - 1]?.sessionId ?? null;
230
196
  }
231
197
  /** Session della riga selezionata; null se stale o nessuna selezione. */
232
198
  export function selectedSession(rows, sessionId) {
233
- const r = rows.find((row) => isSelectable(row) && row.sessionId === sessionId);
234
- return r && isSelectable(r) ? r.session : null;
199
+ const r = rows.find((row) => row.sessionId === sessionId);
200
+ return r && r.kind === 'session' ? r.session : null;
235
201
  }
package/dist/ui/panes.js CHANGED
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  import { Box, Text } from 'ink';
6
6
  import { cut, pad, sanitize, termWidth } from '../width.js';
7
7
  import { paneTextWidth } from '../layout.js';
8
- import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, MODEL_W, SESSION_SEP, SID_CHARS, TASK_EMPTY, WARN, metaCount, modelShort, relTime, } from '../glyphs.js';
8
+ import { CARET, CARET_OFF, LIVE_BUSY, LIVE_IDLE, LIVE_NONE, MODEL_W, SID_CHARS, TASK_EMPTY, WARN, metaCount, modelShort, relTime, } from '../glyphs.js';
9
9
  import { TaskRow } from './task-row.js';
10
10
  import { META_ROWS, ROW_ALL, ROW_SPOT } from '../model.js';
11
11
  import { rowLabel, sessionTitle } from '../session-list.js';
@@ -94,24 +94,22 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
94
94
  ? 'nessuna conversazione nel progetto'
95
95
  : isSpot
96
96
  ? 'nessuna sessione libera'
97
- : 'nessuna sessione legata a questa task'), paneTextWidth(columns)) })) : (rows.map((row, i) => {
98
- // T50 — separatore leggero fra pinnate e contestuali: riga dim, non un
99
- // box pesante (coerente con lo styling delle Done dimmate).
100
- if (row.kind === 'separator') {
101
- return (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: SESSION_SEP }, `sep${i}`));
102
- }
97
+ : 'nessuna sessione legata a questa task'), paneTextWidth(columns)) })) : (rows.map((row) => {
103
98
  const sel = row.sessionId === selectedId;
104
99
  // T50 — pin stale: transcript sparito, nessuna Session da mostrare.
105
- // Riga navigabile e spinnabile (`p`), marcata, mai un crash.
106
- if (row.kind === 'pinned' && row.stale) {
100
+ // Riga navigabile e spinnabile (`p`), marcata, mai un crash. T133 D5 —
101
+ // compare nella sola vista `📌`, dove non ha accanto nessuna griglia da
102
+ // rispettare: resta testo libero.
103
+ if (row.kind === 'stale') {
107
104
  // T60 — anche qui la nota si taglia sul budget DERIVATO, non su un
108
105
  // 30 inchiodato: su un pane stretto quel valore fisso mandava la
109
106
  // riga oltre il bordo, e a ripararla arrivava `cli-truncate` (che
110
107
  // sfora di una colonna per emoji e mangia il bordo stesso).
111
108
  const staleNote = sessionNotes.get(row.sessionId);
112
109
  // La riga stale è libera (niente colonne: non ha né titolo né
113
- // data), ma il binding va detto lo stesso — è una pinnata, quindi
114
- // l'header del pane non ne dice l'appartenenza.
110
+ // data), ma il binding va detto lo stesso — vive nella vista `📌`,
111
+ // che raccoglie le pinnate di ogni task e quindi non ne dice
112
+ // l'appartenenza dall'header.
115
113
  const staleTask = bindings.get(row.sessionId) ?? null;
116
114
  const staleW = Math.max(0, paneTextWidth(columns) -
117
115
  (2 /* caret */ +
@@ -121,8 +119,8 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
121
119
  3 /* spazio + caporali */));
122
120
  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));
123
121
  }
124
- const s = row.session; // non-stale → session presente
125
- const isPinnedRow = row.kind === 'pinned';
122
+ const s = row.session;
123
+ const isPinnedRow = row.pinned;
126
124
  // T28 — un ramo eredita il titolo dell'origine: senza marcatore le due
127
125
  // righe sarebbero identiche a occhio.
128
126
  const forked = forkOf.has(s.sessionId);
@@ -139,13 +137,12 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
139
137
  // appartiene la conversazione (pin/task/spot), questa dice se è aperta
140
138
  // adesso. Farle condividere una cella perderebbe una delle due.
141
139
  const liveEntry = live.get(s.sessionId);
142
- // Stesso motivo per cui la colonna esiste: una pinnata resta in lista
143
- // qualunque sia il parent selezionato, quindi l'header non ne dice
144
- // l'appartenenza e la cella va riempita anche fuori dalla vista
145
- // "tutte". Sulle contestuali, dove l'header parla già, resta vuota
146
- // ma la cella è comunque larga `taskW`, o le colonne a destra
147
- // slitterebbero riga per riga.
148
- const taskCell = isAll || isPinnedRow ? (bound ?? TASK_EMPTY) : '';
140
+ // T133 D10 la colonna vive nella sola vista "tutte". Il ramo sulle
141
+ // pinnate esisteva perché una pinnata stava in lista anche sotto un
142
+ // parent che non era il suo; ora ogni riga è figlia del parent
143
+ // selezionato, quindi l'header parla già per tutte e la cella
144
+ // ripeterebbe N volte ciò che è scritto una riga sopra.
145
+ const taskCell = isAll ? (bound ?? TASK_EMPTY) : '';
149
146
  // T60 — colonne VERE: ogni cella fissa è larga esattamente quanto
150
147
  // dichiara, riempita di spazi con `pad` (che misura in colonne, non in
151
148
  // caratteri). Il marker va portato a 2 anche quando è `○`, largo 1:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.54.1",
3
+ "version": "0.55.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": {