@lamemind/loom-deck 0.71.1 → 0.72.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,6 +21,7 @@ import { taskSlug } from './tasks.js';
21
21
  import { appendNote, appendPin, appendPriority, appendSessionRecord, appendTaskBinding, } from './task-index.js';
22
22
  import { neighborId } from './session-list.js';
23
23
  import { cut, cutMiddle } from './width.js';
24
+ import { dropSessionFiles, sessionArtifacts } from './session-drop.js';
24
25
  import { saveView, viewFilePath } from './view-store.js';
25
26
  import { onInTabCommand, runLaunch, spawnClaudeEmpty, spawnBare, spawnDeck, spawnDeckFork, spawnDeckResume, spawnTerminal, DECK_RUN, } from './spawn.js';
26
27
  import { resolveSpawnId } from './spawn-config.js';
@@ -350,6 +351,60 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
350
351
  model.reloadSessions();
351
352
  setNote(`${on ? 'priorità via' : '🚨 prioritaria'} ${sid.slice(0, 8)}`);
352
353
  }
354
+ /**
355
+ * La bozza di conferma per `CANC` sul pane sessioni, o `null` se non c'è
356
+ * niente da confermare (la nota dice perché). Tre rifiuti, in ordine:
357
+ * nessuna riga selezionata; riga stale (il transcript non c'è già più — da
358
+ * lì si esce spinnando, non eliminando); conversazione VIVA — un processo
359
+ * `claude` ci sta scrivendo, e togliergli il file da sotto lascia un
360
+ * processo che continua a scrivere su un inode sganciato, invisibile a tutti.
361
+ */
362
+ function dropDraftFor() {
363
+ if (model.focus !== 'sessions') {
364
+ setNote('CANC → eliminare: seleziona una conversazione (→ per il pane)');
365
+ return null;
366
+ }
367
+ const sid = model.selSessionId;
368
+ if (!sid) {
369
+ setNote('CANC → nessuna conversazione da eliminare');
370
+ return null;
371
+ }
372
+ const s = model.selSessionObj;
373
+ if (!s) {
374
+ setNote(`CANC → ${sid.slice(0, 8)}: transcript già assente (p per spinnare)`);
375
+ return null;
376
+ }
377
+ const alive = model.live.get(sid);
378
+ if (alive) {
379
+ setNote(`CANC → ${sid.slice(0, 8)} è viva (pid ${alive.pid}): chiudila prima`);
380
+ return null;
381
+ }
382
+ return { sessionId: sid, title: s.title, path: s.path, hasFolder: sessionArtifacts(s.path).length > 1 };
383
+ }
384
+ /**
385
+ * L'eliminazione confermata. Il vicino si calcola PRIMA di toccare il disco,
386
+ * come nel pin: dopo la riga non c'è più e non ha vicini. Se la conversazione
387
+ * era pinnata si spinna nello stesso gesto — altrimenti il sidecar la terrebbe
388
+ * in lista come pinnata stale, cioè una riga che dice «c'era» di una cosa
389
+ * appena eliminata apposta.
390
+ */
391
+ function dropSession(draft) {
392
+ const short = draft.sessionId.slice(0, 8);
393
+ const landing = neighborId(model.sessionRows, draft.sessionId);
394
+ try {
395
+ dropSessionFiles(draft.path);
396
+ }
397
+ catch (e) {
398
+ setNote(`⚠ ${short}: eliminazione fallita: ${e instanceof Error ? e.message : String(e)}`);
399
+ return;
400
+ }
401
+ if (model.pinned.has(draft.sessionId))
402
+ appendPin(cwd, draft.sessionId, false);
403
+ model.reloadSessions();
404
+ if (landing)
405
+ model.setSelSessionId(landing);
406
+ setNote(`✔ eliminata ${short} «${cut(draft.title, 40)}»`);
407
+ }
353
408
  /** `t` — terminale a project root, con un titolo che il matcher di compass riconosce. */
354
409
  function openTerminal() {
355
410
  const title = model.identity ? `🖥️ ${model.identity.name} [term]` : null;
@@ -548,6 +603,8 @@ export function useDeckActions({ cwd, tasksPath, tasksDir, columns, model, overr
548
603
  forkSession,
549
604
  togglePin,
550
605
  togglePriority,
606
+ dropDraftFor,
607
+ dropSession,
551
608
  drainInbox,
552
609
  rebalanceSpawn,
553
610
  rebalanceDoc,
package/dist/cli.js CHANGED
@@ -37,9 +37,10 @@ import { useWrapOverlay } from './overlays/wrap.js';
37
37
  import { useSpawnPage } from './overlays/spawn.js';
38
38
  import { useGitlink } from './overlays/gitlink.js';
39
39
  import { usePurgeOverlay } from './overlays/purge.js';
40
+ import { useDropOverlay } from './overlays/drop.js';
40
41
  import { useTextModals, useViewModals } from './overlays/modals.js';
41
42
  import { useTerminalSize, useSpawnConfig } from './hooks.js';
42
- import { EditModal, FilterModal, PurgeModal, SortModal } from './ui/modals.js';
43
+ import { DropModal, EditModal, FilterModal, PurgeModal, SortModal } from './ui/modals.js';
43
44
  import { StatusHeadline } from './ui/status-screen.js';
44
45
  import { SessionsPane, TasksPane } from './ui/panes.js';
45
46
  import { InboxPane } from './ui/inbox-pane.js';
@@ -181,6 +182,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
181
182
  draftFor: actions.purgeDraftFor,
182
183
  onSubmit: actions.purgeTasks,
183
184
  });
185
+ const drop = useDropOverlay({
186
+ setMode,
187
+ setNote,
188
+ draftFor: actions.dropDraftFor,
189
+ onSubmit: actions.dropSession,
190
+ });
184
191
  const viewModals = useViewModals({
185
192
  view: model.view,
186
193
  setView: model.setView,
@@ -208,6 +215,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
208
215
  spawn,
209
216
  gitlink,
210
217
  purge,
218
+ drop,
211
219
  view: viewModals,
212
220
  text: textModals,
213
221
  };
@@ -341,7 +349,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
341
349
  hasInbox: model.selInbox !== null,
342
350
  hasDoc: model.selDoc !== null,
343
351
  gitlink: gitlink.enabled,
344
- }), indicators: indicators, columns: columns }), frame.launchLine ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [launch.segments.map((s) => s.text).join(LAUNCH_SEP), launch.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", launch.overflow, " fuori riga"] })) : null, launch.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", launch.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null, launch.filler, BARE_LABEL.text, ' ', bareButtons.nodes] })) : null, mode === 'create' ? _jsx(TextBox, { glyph: "C", value: textModals.draft }) : null, mode === 'note' ? _jsx(TextBox, { glyph: "\u270E", value: textModals.noteDraft }) : null, mode === 'sort' ? _jsx(SortModal, { sort: model.view.sort }) : null, mode === 'filter' ? (_jsx(FilterModal, { view: model.view, cursor: viewModals.filterCursor })) : null, mode === 'edit' && textModals.edit && textModals.editTask ? (_jsx(EditModal, { id: textModals.editTask.id, draft: textModals.edit, row: textModals.editCursor.row, caret: textModals.editCursor.caret, columns: columns })) : null, mode === 'purge' && purge.draft ? (_jsx(PurgeModal, { draft: purge.draft, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [model.deckMode === 'doc' ? (_jsx(DocPane, { rows: frame.windowDoc, counts: model.docCounts, activeView: model.docViewId, paneCount: model.docRows.length, selectedPath: model.selDocPath, focused: model.focus === 'doctree', above: frame.docWin.start, below: model.docRows.length - frame.docWin.end, columns: columns, ok: model.docOk, scanned: model.docScanned, scanning: model.docScanning, hasDirs: model.docHasDirs })) : (_jsx(TasksPane, { tasks: frame.windowTasks, counts: model.taskCounts, activeView: model.taskViewId, paneCount: model.paneTasks.length, view: model.view, selected: model.selIndex, spotCount: model.spotCount, allCount: model.sessions.length, idW: model.taskCols.id, tailW: model.taskCols.tail, focused: model.focus === 'tasks', loadError: model.loadError, windowStart: frame.taskWin.start, above: frame.taskWin.start, below: model.paneTasks.length - frame.taskWin.end, columns: columns, data: model.taskRowData, blockMark: model.blockMark })), model.deckMode === 'doc' ? (_jsx(InboxPane, { files: frame.windowInbox, counts: model.inboxCounts, activeView: model.inboxViewId, paneCount: model.inboxFiles.length, selectedPath: model.selInboxPath, focused: model.focus === 'inbox', above: frame.inboxWin.start, below: model.inboxFiles.length - frame.inboxWin.end, columns: columns, ok: model.inboxOk, scanned: model.inboxScanned })) : (_jsx(SessionsPane, { parentLabel: model.parentLabel, isSpot: model.isSpot, isAll: model.isAll, bindings: model.bindings, taskW: model.sessionCols.task, ageW: model.sessionCols.age, rows: frame.windowRows, counts: model.sessionCounts, activeView: model.sessionViewId, paneCount: model.sessionRows.length, selectedId: model.selSessionId ?? undefined, focused: model.focus === 'sessions', above: frame.sessionWin.start, below: model.sessionRows.length - frame.sessionWin.end, columns: columns, forkOf: model.forkOf, sessionNotes: model.sessionNotes, sessionPriority: model.sessionPriority, projectCore: model.projectCore, live: model.live }))] }), frame.budget.preview && previewKind === 'task' && model.detail ? (_jsx(PreviewPane, { kind: "task", detail: model.detail, maxLines: frame.budget.detailLines, columns: columns })) : frame.budget.preview && previewKind === 'inbox' && model.selInbox ? (_jsx(PreviewPane, { kind: "inbox", file: model.selInbox, columns: columns })) : frame.budget.preview && previewKind === 'doc' && model.selDoc ? (_jsx(PreviewPane, { kind: "doc", row: model.selDoc, columns: columns })) : frame.budget.preview && previewKind === 'session' && model.selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: model.selSessionObj, firstLines: frame.budget.sessionFirstLines, lastLines: frame.budget.sessionLastLines, columns: columns, origin: model.forkOf.get(model.selSessionObj.sessionId) ?? null, note: model.sessionNotes.get(model.selSessionObj.sessionId) ?? '', live: model.live.get(model.selSessionObj.sessionId) ?? null, resumeModel: model.resumeModel })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
352
+ }), indicators: indicators, columns: columns }), frame.launchLine ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [launch.segments.map((s) => s.text).join(LAUNCH_SEP), launch.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", launch.overflow, " fuori riga"] })) : null, launch.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", launch.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null, launch.filler, BARE_LABEL.text, ' ', bareButtons.nodes] })) : null, mode === 'create' ? _jsx(TextBox, { glyph: "C", value: textModals.draft }) : null, mode === 'note' ? _jsx(TextBox, { glyph: "\u270E", value: textModals.noteDraft }) : null, mode === 'sort' ? _jsx(SortModal, { sort: model.view.sort }) : null, mode === 'filter' ? (_jsx(FilterModal, { view: model.view, cursor: viewModals.filterCursor })) : null, mode === 'edit' && textModals.edit && textModals.editTask ? (_jsx(EditModal, { id: textModals.editTask.id, draft: textModals.edit, row: textModals.editCursor.row, caret: textModals.editCursor.caret, columns: columns })) : null, mode === 'purge' && purge.draft ? (_jsx(PurgeModal, { draft: purge.draft, columns: columns })) : null, mode === 'drop' && drop.draft ? _jsx(DropModal, { draft: drop.draft, columns: columns }) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [model.deckMode === 'doc' ? (_jsx(DocPane, { rows: frame.windowDoc, counts: model.docCounts, activeView: model.docViewId, paneCount: model.docRows.length, selectedPath: model.selDocPath, focused: model.focus === 'doctree', above: frame.docWin.start, below: model.docRows.length - frame.docWin.end, columns: columns, ok: model.docOk, scanned: model.docScanned, scanning: model.docScanning, hasDirs: model.docHasDirs })) : (_jsx(TasksPane, { tasks: frame.windowTasks, counts: model.taskCounts, activeView: model.taskViewId, paneCount: model.paneTasks.length, view: model.view, selected: model.selIndex, spotCount: model.spotCount, allCount: model.sessions.length, idW: model.taskCols.id, tailW: model.taskCols.tail, focused: model.focus === 'tasks', loadError: model.loadError, windowStart: frame.taskWin.start, above: frame.taskWin.start, below: model.paneTasks.length - frame.taskWin.end, columns: columns, data: model.taskRowData, blockMark: model.blockMark })), model.deckMode === 'doc' ? (_jsx(InboxPane, { files: frame.windowInbox, counts: model.inboxCounts, activeView: model.inboxViewId, paneCount: model.inboxFiles.length, selectedPath: model.selInboxPath, focused: model.focus === 'inbox', above: frame.inboxWin.start, below: model.inboxFiles.length - frame.inboxWin.end, columns: columns, ok: model.inboxOk, scanned: model.inboxScanned })) : (_jsx(SessionsPane, { parentLabel: model.parentLabel, isSpot: model.isSpot, isAll: model.isAll, bindings: model.bindings, taskW: model.sessionCols.task, ageW: model.sessionCols.age, rows: frame.windowRows, counts: model.sessionCounts, activeView: model.sessionViewId, paneCount: model.sessionRows.length, selectedId: model.selSessionId ?? undefined, focused: model.focus === 'sessions', above: frame.sessionWin.start, below: model.sessionRows.length - frame.sessionWin.end, columns: columns, forkOf: model.forkOf, sessionNotes: model.sessionNotes, sessionPriority: model.sessionPriority, projectCore: model.projectCore, live: model.live }))] }), frame.budget.preview && previewKind === 'task' && model.detail ? (_jsx(PreviewPane, { kind: "task", detail: model.detail, maxLines: frame.budget.detailLines, columns: columns })) : frame.budget.preview && previewKind === 'inbox' && model.selInbox ? (_jsx(PreviewPane, { kind: "inbox", file: model.selInbox, columns: columns })) : frame.budget.preview && previewKind === 'doc' && model.selDoc ? (_jsx(PreviewPane, { kind: "doc", row: model.selDoc, columns: columns })) : frame.budget.preview && previewKind === 'session' && model.selSessionObj ? (_jsx(PreviewPane, { kind: "session", s: model.selSessionObj, firstLines: frame.budget.sessionFirstLines, lastLines: frame.budget.sessionLastLines, columns: columns, origin: model.forkOf.get(model.selSessionObj.sessionId) ?? null, note: model.sessionNotes.get(model.selSessionObj.sessionId) ?? '', live: model.live.get(model.selSessionObj.sessionId) ?? null, resumeModel: model.resumeModel })) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
345
353
  }
346
354
  const cwd = process.cwd();
347
355
  // T116 — `exitOnCtrlC: false` toglie a Ink l'uscita immediata su `^C`: senza,
@@ -96,7 +96,7 @@ export function sessionColumns(rows, bindings, isAll) {
96
96
  /**
97
97
  * Le conversazioni figlie della selezione: tutte quelle del progetto (`≡
98
98
  * tutte`), quelle bound alla task selezionata, oppure (spot) quelle senza
99
- * binding. `sessions` è già ts desc → l'ordine si eredita in tutti e tre i rami.
99
+ * binding. `sessions` è già nell'ordine di `compareSessions` → l'ordine si eredita in tutti e tre i rami.
100
100
  */
101
101
  export function childSessionsOf(sessions, bindings, selectedTaskId, isAll) {
102
102
  if (isAll)
@@ -306,7 +306,7 @@ export function useDeckModel({ cwd, tasksPath, tasksDir, bareModel: bareModelDef
306
306
  // Memoizzato così `sessionRows` resta stabile fra render che non cambiano gli
307
307
  // input: l'effect di validità della selezione non rigira a vuoto.
308
308
  const childSessions = useMemo(() => childSessionsOf(sessions, bindings, selectedTaskId, isAll), [sessions, bindings, selectedTaskId, isAll]);
309
- // T133 — lista unica: le figlie del parent, `ts desc`, cap su tutte. Le
309
+ // T133 — lista unica: le figlie del parent, ordine di `compareSessions`, cap su tutte. Le
310
310
  // pinnate del progetto escono a parte (`pinnedRows`), per la sola vista `📌`.
311
311
  // Core PURO in session-list.ts (testabile senza Ink).
312
312
  const assembled = useMemo(() => assembleSessionList(childSessions, sessions, pinned, isAll ? MAX_SESSIONS_ALL : MAX_SESSIONS), [childSessions, sessions, pinned, isAll]);
package/dist/frame.js CHANGED
@@ -97,7 +97,10 @@ export function deckLegend(state) {
97
97
  // apertura: una legenda che annunciasse «tutte» dove il tasto ne pota una
98
98
  // sola sarebbe peggio di nessuna legenda.
99
99
  ...(state.focus === 'tasks' ? [state.purgeBulk ? 'CANC elimina tutte' : 'CANC elimina'] : []),
100
- ...(canResume ? ['f fork', 'm modello'] : []),
100
+ // `CANC` sul pane sessioni: stessa condizione del fork (una conversazione
101
+ // VERA sotto il cursore), perché una stale non ha un transcript da
102
+ // eliminare — da lì si esce con `p`.
103
+ ...(canResume ? ['f fork', 'm modello', 'CANC elimina'] : []),
101
104
  // T117 · D3 — «titolo» anche qui: è lo stesso valore che il detail chiede
102
105
  // allo spawn, e due nomi per una cosa sola li paga chi legge le due
103
106
  // schermate. Rename di sola ETICHETTA: il dato resta `note` nel sidecar e
package/dist/glyphs.js CHANGED
@@ -228,6 +228,13 @@ export function fmtDateTime(ts) {
228
228
  const p = (n) => String(n).padStart(2, '0');
229
229
  return `${p(d.getDate())}/${p(d.getMonth() + 1)} ${p(d.getHours())}:${p(d.getMinutes())}`;
230
230
  }
231
+ /** Come `fmtDateTime`, ma con l'assenza resa esplicita: le date lette dal
232
+ * transcript (`firstPromptTs`, `lastReplyTs`) valgono 0 quando il record non
233
+ * c'è, e 0 formattato darebbe il 01/01/1970 — una data vera al posto di un
234
+ * dato mancante. */
235
+ export function fmtDateTimeOr(ts, absent = '-') {
236
+ return ts > 0 ? fmtDateTime(ts) : absent;
237
+ }
231
238
  // T121 — sola ora del giorno, per l'indicatore di project status in testata. Il
232
239
  // giorno non ci sta e non servirebbe: un recap del giorno prima è comunque
233
240
  // stale, e la cache muore col reboot.
@@ -37,6 +37,7 @@ export const CAPTURING_MODES = [
37
37
  'filter',
38
38
  'edit',
39
39
  'purge',
40
+ 'drop',
40
41
  ];
41
42
  const _MODES_ARE_EXHAUSTIVE = true;
42
43
  void _MODES_ARE_EXHAUSTIVE;
@@ -56,7 +57,8 @@ export function captures(mode) {
56
57
  * e un'eccezione che vive solo dentro un `if` annidato è come non averla.
57
58
  *
58
59
  * `purge` (T112) non ne ha e non può averne: è una domanda binaria, e dentro
59
- * una domanda non c'è nessun acceleratore da salvare.
60
+ * una domanda non c'è nessun acceleratore da salvare. Lo stesso vale per
61
+ * `drop`, la conferma gemella sulle conversazioni.
60
62
  */
61
63
  export const CTRL_DEROGATIONS = {
62
64
  detail: ['f'],
package/dist/input.js CHANGED
@@ -91,6 +91,7 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
91
91
  filter: overlays.view.onFilterKey,
92
92
  edit: overlays.text.onEditKey,
93
93
  purge: overlays.purge.onKey,
94
+ drop: overlays.drop.onKey,
94
95
  };
95
96
  // T21 (mandata 2) — la ROTELLA, per i soli modi che scorrono un contenuto
96
97
  // lungo (`SCROLLING_MODES`). Stessa forma di `MODE_KEYS`: un `Record` sul
@@ -525,7 +526,15 @@ export function useDeckInput({ cwd, tasksDir, mode, setMode, setNote, model, act
525
526
  // c'è nessun campo di testo da cui Backspace possa rubare un significato,
526
527
  // e ogni modo che ne ha uno è capturing e non vede questo ramo. Il modale
527
528
  // di conferma è la rete contro chi usa Backspace come «indietro».
528
- overlays.purge.open();
529
+ //
530
+ // Il bersaglio lo decide il FOCUS, come per `⏎`: sul pane sessioni il
531
+ // tasto elimina la conversazione selezionata (transcript su disco),
532
+ // altrove pota una task via skill. Due overlay e non uno perché i due
533
+ // effetti non hanno niente in comune oltre al tasto.
534
+ if (model.focus === 'sessions')
535
+ overlays.drop.open();
536
+ else
537
+ overlays.purge.open();
529
538
  }
530
539
  else if (input === 'C') {
531
540
  overlays.text.openCreate();
package/dist/model.js CHANGED
@@ -6,7 +6,7 @@ export const POLL_MS = 1500;
6
6
  // — armamento e messaggio scadono insieme, o la riga prometterebbe un'uscita
7
7
  // che il tasto non fa più.
8
8
  export const QUIT_WINDOW_MS = 5000;
9
- // Cap del pane sessioni: le più recenti (ts desc), le altre restano nell'indice
9
+ // Cap del pane sessioni: le più recenti (ordine di `compareSessions`), le altre restano nell'indice
10
10
  // ma fuori vista. Non-silenzioso → l'header mostra quante sono nascoste.
11
11
  export const MAX_SESSIONS = 30;
12
12
  // T59 D3 — cap dedicato alla vista "tutte": a 30 su un progetto con ~170
@@ -0,0 +1,36 @@
1
+ // L'overlay di conferma dell'eliminazione di una CONVERSAZIONE (`CANC` sul pane
2
+ // sessioni). Gemello di `purge.ts` nella forma — un modo in `CAPTURING_MODES`,
3
+ // una bozza, un handler binario ⏎/esc — e separato da lui nell'oggetto: la
4
+ // task si pota via skill del plugin con commit, la conversazione si rimuove
5
+ // dal disco e basta, e una conferma sola per i due bersagli direbbe l'effetto
6
+ // di uno mentre esegue l'altro.
7
+ //
8
+ // La deroga di T104 vale anche qui: l'hook non esce dal deck. La bozza arriva
9
+ // da `draftFor`, l'esecuzione parte da `onSubmit`, entrambe in `actions.ts`.
10
+ import { useState } from 'react';
11
+ export function useDropOverlay({ setMode, setNote, draftFor, onSubmit, }) {
12
+ const [draft, setDraft] = useState(null);
13
+ function open() {
14
+ const next = draftFor();
15
+ if (!next)
16
+ return;
17
+ setDraft(next);
18
+ setNote('');
19
+ setMode('drop');
20
+ }
21
+ function onKey(_input, key) {
22
+ if (key.escape) {
23
+ setMode('normal');
24
+ setDraft(null);
25
+ setNote('CANC → eliminazione annullata');
26
+ }
27
+ else if (key.return) {
28
+ const current = draft;
29
+ setMode('normal');
30
+ setDraft(null);
31
+ if (current)
32
+ onSubmit(current);
33
+ }
34
+ }
35
+ return { draft, open, onKey };
36
+ }
@@ -0,0 +1,35 @@
1
+ // Eliminazione di una conversazione dal disco — il pezzo PURO, senza React e
2
+ // senza selezione: riceve il path del transcript e rimuove ciò che CC tiene per
3
+ // quella conversazione. La decisione (quale, se viva, se pinnata) sta in
4
+ // `actions.ts`; la conferma nell'overlay `drop`.
5
+ import { existsSync, rmSync, statSync } from 'node:fs';
6
+ import { basename, dirname, join } from 'node:path';
7
+ /**
8
+ * Gli artefatti su disco di una conversazione: il transcript `<sid>.jsonl` e,
9
+ * quando esiste, la cartella omonima accanto (`<sid>/`) dove CC scrive i
10
+ * transcript dei subagent e gli output dei tool. Il sidecar del deck
11
+ * (`session-tasks.jsonl`) NON è fra questi: è nostro, non di CC, e i suoi
12
+ * record restano — chi elimina spinna da sé, così non nasce una riga stale.
13
+ *
14
+ * Il path deve finire in `.jsonl`: è l'unica forma che questo modulo accetta di
15
+ * rimuovere, e un path qualunque passato per errore non deve diventare un
16
+ * `rm -rf`.
17
+ */
18
+ export function sessionArtifacts(transcriptPath) {
19
+ if (!transcriptPath.endsWith('.jsonl')) {
20
+ throw new Error(`non è un transcript: ${transcriptPath}`);
21
+ }
22
+ const sibling = join(dirname(transcriptPath), basename(transcriptPath, '.jsonl'));
23
+ const out = [transcriptPath];
24
+ if (existsSync(sibling) && statSync(sibling).isDirectory())
25
+ out.push(sibling);
26
+ return out;
27
+ }
28
+ /** Rimuove gli artefatti e restituisce i path toccati. Irreversibile: i
29
+ * transcript non stanno in git, e non esiste un cestino. */
30
+ export function dropSessionFiles(transcriptPath) {
31
+ const targets = sessionArtifacts(transcriptPath);
32
+ for (const t of targets)
33
+ rmSync(t, { recursive: true, force: true });
34
+ return targets;
35
+ }
@@ -1,5 +1,5 @@
1
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
2
+ // selezionato nell'ordine di `compareSessions`. Il pin è un ATTRIBUTO della riga (glifo, vista
3
3
  // dedicata), non una posizione privilegiata.
4
4
  // Modulo PURO: nessun import da ink/react, nessun I/O → testabile senza terminale
5
5
  // (il pacchetto non ha infrastruttura TUI di test, solo unit sui core puri).
package/dist/sessions.js CHANGED
@@ -187,7 +187,9 @@ export function parseTranscript(content, path, mtime, sizeBytes) {
187
187
  let parentUuid = null;
188
188
  let customTitle = '';
189
189
  let firstUserText = '';
190
+ let firstUserTs = 0;
190
191
  let lastAssistantText = '';
192
+ let lastAssistantTs = 0;
191
193
  let model = '';
192
194
  let turns = 0;
193
195
  const bodies = [];
@@ -219,6 +221,10 @@ export function parseTranscript(content, path, mtime, sizeBytes) {
219
221
  }
220
222
  if (typeof d.customTitle === 'string' && d.customTitle)
221
223
  customTitle = d.customTitle; // last-wins
224
+ // Il `timestamp` del record è ISO UTC; `Date.parse` → epoch ms, NaN se
225
+ // manca o è malformato (i record di stato — `last-prompt`, `mode` — non lo
226
+ // portano affatto). Si legge solo dove serve, cioè accanto ai due testi.
227
+ const recordTs = typeof d.timestamp === 'string' ? Date.parse(d.timestamp) : NaN;
222
228
  if (d.type === 'user') {
223
229
  // T49: turno = prompt umano. I tool_result viaggiano anch'essi come
224
230
  // type:user ma senza blocchi text → extractText '' li esclude.
@@ -228,8 +234,11 @@ export function parseTranscript(content, path, mtime, sizeBytes) {
228
234
  const t = extractText(d.message);
229
235
  if (t && !isInterrupt(t)) {
230
236
  turns++;
231
- if (!firstUserText)
237
+ if (!firstUserText) {
232
238
  firstUserText = t;
239
+ if (!Number.isNaN(recordTs))
240
+ firstUserTs = recordTs;
241
+ }
233
242
  }
234
243
  }
235
244
  else if (d.type === 'assistant') {
@@ -237,8 +246,11 @@ export function parseTranscript(content, path, mtime, sizeBytes) {
237
246
  // early-stop — l'ultima riga assistant con testo è quella buona. I
238
247
  // record assistant di solo tool_use danno '' e non sovrascrivono.
239
248
  const t = extractText(d.message);
240
- if (t)
249
+ if (t) {
241
250
  lastAssistantText = t;
251
+ if (!Number.isNaN(recordTs))
252
+ lastAssistantTs = recordTs;
253
+ }
242
254
  // T110 — stesso regime last-wins, ma su un asse suo: un record di solo
243
255
  // tool_use porta comunque il modello, e va contato. Nessun costo di I/O:
244
256
  // la riga è già parsata per turni, primo prompt e ultima risposta.
@@ -269,6 +281,8 @@ export function parseTranscript(content, path, mtime, sizeBytes) {
269
281
  // testo, sanificare a valle sposterebbe l'a-capo già calcolato.
270
282
  firstPrompt: sanitize(firstUserText),
271
283
  lastReply: sanitize(lastAssistantText),
284
+ firstPromptTs: firstUserTs,
285
+ lastReplyTs: lastAssistantTs,
272
286
  // Sanificato come il titolo e le due preview, e per lo stesso motivo: è una
273
287
  // stringa di sorgente esterna che finisce NEL FRAME (riga meta del blocco
274
288
  // preview). Il confine di sanificazione è per SORGENTE, non per campo — un
@@ -279,9 +293,24 @@ export function parseTranscript(content, path, mtime, sizeBytes) {
279
293
  bodies,
280
294
  };
281
295
  }
296
+ /** L'ordine della lista sessioni: ultima risposta del modello, più recente in
297
+ * cima. Chi non ha ancora una risposta con testo (`lastReplyTs === 0`: la
298
+ * prima risposta è in corso, o non è mai arrivata) sta sopra tutte — è la
299
+ * conversazione che aspetta, non quella che ha finito. Il mtime (`ts`) resta
300
+ * solo come spareggio: si sposta anche aprendo la conversazione senza farci
301
+ * nulla, e come chiave prima metteva in cima una sessione soltanto guardata. */
302
+ export function compareSessions(a, b) {
303
+ const aOpen = a.lastReplyTs === 0;
304
+ const bOpen = b.lastReplyTs === 0;
305
+ if (aOpen !== bOpen)
306
+ return aOpen ? -1 : 1;
307
+ if (a.lastReplyTs !== b.lastReplyTs)
308
+ return b.lastReplyTs - a.lastReplyTs;
309
+ return b.ts - a.ts;
310
+ }
282
311
  // Discovery read-only delle sessioni del SOLO progetto corrente (D2 preflight
283
312
  // T27): legge la project dir calcolata dal forward-transform, filtra per cwd
284
- // (difesa contro le collisioni lossy del naming), ordina per ts desc.
313
+ // (difesa contro le collisioni lossy del naming), ordina con `compareSessions`.
285
314
  export function discoverProjectSessions(projectRoot) {
286
315
  const dir = join(claudeProjectsRoot(), projectDirName(projectRoot));
287
316
  let files;
@@ -322,12 +351,12 @@ export function discoverProjectSessions(projectRoot) {
322
351
  for (const key of cache.keys())
323
352
  if (!seen.has(key))
324
353
  cache.delete(key);
325
- out.sort((a, b) => b.ts - a.ts);
354
+ out.sort(compareSessions);
326
355
  return out;
327
356
  }
328
357
  // Raggruppa per gitBranch (D2: group-by-branch nel progetto corrente). Ordine
329
- // dei gruppi = sessione più recente nel gruppo (desc); dentro il gruppo resta
330
- // l'ordine ts desc ereditato dall'input.
358
+ // dei gruppi = la prima sessione di ciascuno, con `compareSessions`; dentro il
359
+ // gruppo resta l'ordine ereditato dall'input.
331
360
  export function groupByBranch(sessions) {
332
361
  const map = new Map();
333
362
  for (const s of sessions) {
@@ -340,5 +369,5 @@ export function groupByBranch(sessions) {
340
369
  }
341
370
  return [...map.entries()]
342
371
  .map(([branch, arr]) => ({ branch, sessions: arr }))
343
- .sort((a, b) => b.sessions[0].ts - a.sessions[0].ts);
372
+ .sort((a, b) => compareSessions(a.sessions[0], b.sessions[0]));
344
373
  }
package/dist/ui/modals.js CHANGED
@@ -6,7 +6,7 @@ import { Box, Text } from 'ink';
6
6
  import { cut, cutParts, sanitize } from '../width.js';
7
7
  import { FieldText } from './fields.js';
8
8
  import { CARET, CARET_OFF, WARN } from '../glyphs.js';
9
- import { EDIT_PRI, EDIT_PROG } from '../model.js';
9
+ import { EDIT_PRI, EDIT_PROG, } from '../model.js';
10
10
  import { PRI_ENTRIES, PROG_ENTRIES } from '../view.js';
11
11
  import { progressText, PRI_GLYPH, PRI_LABEL, PROG_GLYPH } from '../task-edit.js';
12
12
  export const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id', commit: 'commit' };
@@ -60,6 +60,19 @@ export function PurgeModal({ draft, columns }) {
60
60
  ? `${WARN} ${draft.skipped.length} scartate (folder con file non tracciati): ${idList(draft.skipped)}`
61
61
  : 'nessuna esclusa · ⏎ conferma · esc annulla', width) }))] }));
62
62
  }
63
+ /**
64
+ * Conferma di eliminazione di una CONVERSAZIONE, in flusso come `PurgeModal`
65
+ * e con la stessa regola: la conferma dice cosa sparisce (id e titolo) e
66
+ * l'effetto sul disco, non un nome di vista. Tre righe fisse, costo dichiarato
67
+ * in `MODAL_HEIGHT.drop`.
68
+ */
69
+ export function DropModal({ draft, columns }) {
70
+ const width = Math.max(8, columns - 8);
71
+ const effect = draft.hasFolder
72
+ ? 'transcript + cartella subagent rimossi da ~/.claude/projects · irreversibile'
73
+ : 'transcript rimosso da ~/.claude/projects · irreversibile';
74
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "red", children: ["CANC \u203A eliminare la conversazione ", draft.sessionId.slice(0, 8), "?"] }), _jsx(Text, { wrap: "truncate-end", children: cut(`«${draft.title}»`, width) }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: cut(`${effect} · ⏎ conferma · esc annulla`, width) })] }));
75
+ }
63
76
  /** Gli ID del bersaglio, troncati con `+N` quando non ci stanno tutti. Il
64
77
  * troncamento lo fa questa funzione e non `cut`, perché tagliare a metà un id
65
78
  * (`T1…`) direbbe una cosa falsa invece di dire che manca. */
package/dist/ui/panes.js CHANGED
@@ -152,7 +152,11 @@ export function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW
152
152
  // caratteri). Il marker va portato a 2 anche quando è `○`, largo 1:
153
153
  // era lui a far slittare a sinistra di una colonna tutta la riga di
154
154
  // ogni sessione spot.
155
- const age = relTime(s.ts);
155
+ // L'età è quella dell'ultima risposta, la stessa chiave che ordina la
156
+ // lista (`compareSessions`): un'età dal mtime accanto a un ordine per
157
+ // risposta darebbe una colonna che non cresce scendendo. `-` quando
158
+ // la risposta non c'è ancora — la riga sta in cima per quello.
159
+ const age = s.lastReplyTs > 0 ? relTime(s.lastReplyTs) : '-';
156
160
  // Il taglio del titolo è ciò che RESTA, calcolato per sottrazione: le
157
161
  // colonne fisse sono note, quindi l'unica cella elastica prende il
158
162
  // resto. Pavimento `0` e non un minimo di cortesia — è un tetto, non
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  import { Box, Text } from 'ink';
5
5
  import { cut, cutParts, wrapLines } from '../width.js';
6
6
  import { previewTextWidth, previewTitleParts } from '../layout.js';
7
- import { LIVE_BUSY, LIVE_IDLE, META_KEYS, fmtDateTime, fmtSize, modelAlias, } from '../glyphs.js';
7
+ import { LIVE_BUSY, LIVE_IDLE, META_KEYS, fmtDateTime, fmtDateTimeOr, fmtSize, modelAlias, } from '../glyphs.js';
8
8
  import { NATURA_SHORT, inboxMark } from '../inbox.js';
9
9
  import { flagNames } from '../doc-tree.js';
10
10
  import { ChoiceRow } from './fields.js';
@@ -93,7 +93,7 @@ export function SessionPreview({ s, firstLines, lastLines, columns, origin, note
93
93
  // di una colonna per emoji (invariante ③ di width.ts) e mangia il bordo del
94
94
  // box. Pezzi e ragione stanno in `previewTitleParts`.
95
95
  const [sidPart, notePart, titlePart] = cutParts(previewTitleParts(s.sessionId, note, s.title), previewTextWidth(columns));
96
- return (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: sidPart }), notePart ? _jsx(Text, { color: "yellow", children: notePart }) : null, _jsx(Text, { dimColor: Boolean(note), children: titlePart })] }), _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}`)))] }));
96
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: sidPart }), notePart ? _jsx(Text, { color: "yellow", children: notePart }) : null, _jsx(Text, { dimColor: Boolean(note), children: titlePart })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [fmtSize(s.sizeBytes), " \u00B7 ", s.turns, " turni \u00B7 \u00BB ", fmtDateTimeOr(s.firstPromptTs), " \u00B7 \u00AB ", fmtDateTimeOr(s.lastReplyTs), " \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}`)))] }));
97
97
  }
98
98
  /**
99
99
  * Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
@@ -83,6 +83,9 @@ export function HintBar({ mode, purge, keyLegend, indicators, columns, }) {
83
83
  if (mode === 'purge') {
84
84
  return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["elimina task \u00B7", ' ', purge?.ignored ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " keep/purge dei file non tracciati \u00B7", ' '] })) : null, _jsx(Text, { color: "yellow", children: "\u23CE" }), " conferma \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
85
85
  }
86
+ if (mode === 'drop') {
87
+ return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["elimina conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " conferma \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
88
+ }
86
89
  if (mode === 'edit') {
87
90
  return (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] }));
88
91
  }
package/dist/viewport.js CHANGED
@@ -76,6 +76,11 @@ export const MODAL_HEIGHT = {
76
76
  // condizionali mutuamente esclusivi obbligherebbero il budget a sapere QUALE
77
77
  // dei due è a schermo (stessa ragione della riga nota in DETAIL_CHROME, T111).
78
78
  purge: 7,
79
+ // Conferma di eliminazione di una conversazione: marginTop + 2 bordi + 3
80
+ // righe (domanda con l'id, titolo, effetto sul disco + tasti). Nessuna riga
81
+ // condizionale: la terza nomina la cartella subagent solo quando c'è, ma la
82
+ // riga c'è sempre.
83
+ drop: 6,
79
84
  // T52 — search e reader sono gli unici modali NON in flusso: sostituiscono i
80
85
  // due pane invece di spingerli giù (una lista di occorrenze non entra in un
81
86
  // box sopra il deck). Costo 0 nel budget dei pane perché quel budget non
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamemind/loom-deck",
3
- "version": "0.71.1",
3
+ "version": "0.72.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": {