@lamemind/loom-deck 0.9.0 → 0.9.2
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 +119 -36
- package/dist/config.js +6 -2
- package/dist/sessions.js +2 -1
- package/dist/tasks.js +23 -2
- package/dist/viewport.js +190 -0
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { render, Box, Text, useApp, useInput } from 'ink';
|
|
3
|
+
import { render, Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
4
4
|
import { useState, useEffect, useMemo } from 'react';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { statSync } from 'node:fs';
|
|
@@ -11,6 +11,7 @@ import { resolveTasksPath, resolveTasksDir, loadTasks, loadTaskDetail, } from '.
|
|
|
11
11
|
import { discoverProjectSessions } from './sessions.js';
|
|
12
12
|
import { appendTaskBinding, loadTaskBindings } from './task-index.js';
|
|
13
13
|
import { launchLegend, loadIdentity, loadLaunch } from './config.js';
|
|
14
|
+
import { layoutBudget, normalizeEmoji, windowRange, wrapLines, } from './viewport.js';
|
|
14
15
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
15
16
|
import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
|
|
16
17
|
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
@@ -166,6 +167,29 @@ function commitTaskEdit(cwd, paths, message, onResult) {
|
|
|
166
167
|
child.on('close', (code) => onResult(code === 0, err.trim().split('\n')[0] ?? ''));
|
|
167
168
|
return child;
|
|
168
169
|
}
|
|
170
|
+
// Dimensioni del terminale, live sul resize.
|
|
171
|
+
//
|
|
172
|
+
// Non è una comodità di layout: senza `rows` il frame non ha tetto, e un frame
|
|
173
|
+
// più alto del terminale fa cadere Ink nel ramo `clearTerminal` (ink.js:121)
|
|
174
|
+
// che su VTE/Ptyxis riversa ogni redraw nello scrollback.
|
|
175
|
+
//
|
|
176
|
+
// Il valore iniziale conta quanto il resize: una tab Ptyxis appena aperta parte
|
|
177
|
+
// spesso a 24 righe e riceve il SIGWINCH subito dopo. Nella finestra fra i due
|
|
178
|
+
// il deck disegnava già a piena altezza — motivo per cui lo scrollback risultava
|
|
179
|
+
// sporco fin dall'avvio, prima ancora di toccare un tasto.
|
|
180
|
+
function useTerminalSize() {
|
|
181
|
+
const { stdout } = useStdout();
|
|
182
|
+
const [size, setSize] = useState({ rows: stdout.rows || 24, columns: stdout.columns || 80 });
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
const onResize = () => setSize({ rows: stdout.rows || 24, columns: stdout.columns || 80 });
|
|
185
|
+
stdout.on('resize', onResize);
|
|
186
|
+
onResize(); // allinea se il resize è arrivato prima del mount
|
|
187
|
+
return () => {
|
|
188
|
+
stdout.off('resize', onResize);
|
|
189
|
+
};
|
|
190
|
+
}, [stdout]);
|
|
191
|
+
return size;
|
|
192
|
+
}
|
|
169
193
|
// Carica tasks.md e lo ri-legge quando cambia sotto (poll su mtime). Poll
|
|
170
194
|
// (non fs.watch) perché i writer di tasks.md — checkpoint-task/create-task —
|
|
171
195
|
// riscrivono il file (probabile replace atomico), che rompe il watch sull'inode
|
|
@@ -249,22 +273,23 @@ function truncate(s, n) {
|
|
|
249
273
|
const flat = s.replace(/\s+/g, ' ').trim();
|
|
250
274
|
return flat.length > n ? flat.slice(0, n - 1).trimEnd() + '…' : flat;
|
|
251
275
|
}
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
276
|
+
// Normalizzazione larghezza glifi → `normalizeEmoji` in viewport.ts.
|
|
277
|
+
//
|
|
278
|
+
// Sostituisce il precedente `forceEmojiWidth`, che aveva l'intuizione giusta
|
|
279
|
+
// (timbrare il VS16) ma due limiti che lasciavano passare il difetto:
|
|
280
|
+
// - agiva solo su stringhe di UN codepoint, quindi non toccava mai un testo
|
|
281
|
+
// composto — la riga della legenda launch, una descrizione task con emoji
|
|
282
|
+
// dentro, il titolo di una sessione;
|
|
283
|
+
// - decideva per intervallo di codepoint invece che per larghezza misurata,
|
|
284
|
+
// quindi avrebbe timbrato anche `↓` `↑` `−` (larghi 1) se gli fossero
|
|
285
|
+
// arrivati da soli, accorciando la riga invece di allargarla.
|
|
286
|
+
const forceEmojiWidth = normalizeEmoji;
|
|
287
|
+
// Glifi LETTERALI del JSX che ricadono nella classe mal misurata (BMP largo 2
|
|
288
|
+
// senza VS16). I dati passano dai loader, questi no: normalizzati una volta
|
|
289
|
+
// qui, così nessun sito di render li scrive nudi. `↳ ○ ▸ ⏎ · − ↑ ↓` sono
|
|
290
|
+
// larghi 1 e restano intatti.
|
|
291
|
+
const CARET = normalizeEmoji('▶ ');
|
|
292
|
+
const CARET_OFF = ' ';
|
|
268
293
|
// Normalizza il marker Done per il display. `✔` (U+2714) è text-presentation-
|
|
269
294
|
// default: string-width — quindi Ink, sia per il layout sia per il troncamento —
|
|
270
295
|
// lo misura 2 (rispetta il VS16 di `✔️`), ma VTE/Ptyxis lo disegna largo 1
|
|
@@ -315,6 +340,8 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
315
340
|
// T41 — bozza dell'edit (null fuori dal modale) e riga attiva della griglia.
|
|
316
341
|
const [edit, setEdit] = useState(null);
|
|
317
342
|
const [editRow, setEditRow] = useState(0);
|
|
343
|
+
// Dimensioni vive del terminale: sono l'input del budget d'altezza sotto.
|
|
344
|
+
const { rows, columns } = useTerminalSize();
|
|
318
345
|
// Voci launch del progetto (T32): lette una volta, raggiunte per indice 1..9.
|
|
319
346
|
const launch = useMemo(() => loadLaunch(cwd), [cwd]);
|
|
320
347
|
// Identità (T37): titolo delle tab terminale spawnate col tasto `t`.
|
|
@@ -680,10 +707,39 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
680
707
|
const selSessionId = visibleSessions[selSession]?.sessionId;
|
|
681
708
|
const parentLabel = isSpot ? 'spot' : selectedTaskId ?? '—';
|
|
682
709
|
const canSpawn = focus === 'tasks' && !isSpot;
|
|
683
|
-
// Larghezza
|
|
684
|
-
//
|
|
685
|
-
const legend = launchLegend(launch,
|
|
686
|
-
|
|
710
|
+
// Larghezza dal medesimo hook che dà l'altezza: dopo un resize la legenda si
|
|
711
|
+
// ricalcola con lo stesso re-render che ridimensiona i pane.
|
|
712
|
+
const legend = launchLegend(launch, columns);
|
|
713
|
+
// ── Budget d'altezza ────────────────────────────────────────────────────
|
|
714
|
+
// Il frame deve restare sotto `rows`, sempre: oltre quella soglia Ink smette
|
|
715
|
+
// di aggiornare per differenza e pulisce lo schermo a ogni redraw, che su
|
|
716
|
+
// Ptyxis significa un frame intero versato nello scrollback per ogni tick del
|
|
717
|
+
// poll. Tutto ciò che varia in altezza (le due liste e la descrizione del
|
|
718
|
+
// dettaglio) riceve qui la propria capienza.
|
|
719
|
+
const launchLine = mode === 'normal' && launch.length > 0;
|
|
720
|
+
const detailParts = detail ? detailMetaOf(detail) : null;
|
|
721
|
+
const budget = layoutBudget({
|
|
722
|
+
rows,
|
|
723
|
+
mode,
|
|
724
|
+
launchLine,
|
|
725
|
+
noteLine: Boolean(note),
|
|
726
|
+
hasDetail: Boolean(detail),
|
|
727
|
+
detailMetaLines: detailParts?.metaLines ?? 0,
|
|
728
|
+
});
|
|
729
|
+
// Finestre di rendering. Le liste "logiche" (viewTasks, visibleSessions)
|
|
730
|
+
// restano intere: navigazione, selezione e spawn continuano a ragionare su
|
|
731
|
+
// quelle, la finestra è solo ciò che finisce a schermo.
|
|
732
|
+
const taskWin = windowRange(viewTasks.length, selIndex - 1, budget.taskRows);
|
|
733
|
+
const windowTasks = viewTasks.slice(taskWin.start, taskWin.end);
|
|
734
|
+
const sessionWin = windowRange(visibleSessions.length, selSession, budget.sessionRows);
|
|
735
|
+
const windowSessions = visibleSessions.slice(sessionWin.start, sessionWin.end);
|
|
736
|
+
// Sotto la soglia minima il layout a box non entra a nessun costo: si scende
|
|
737
|
+
// a una riga sola. Perdere il deck per un terminale basso è meglio che
|
|
738
|
+
// sporcare la cronologia del terminale a ogni poll.
|
|
739
|
+
if (budget.compact) {
|
|
740
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? 'spot', " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
|
|
741
|
+
}
|
|
742
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_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 \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : '—', " \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 t term \u00B7 c claude \u00B7 q esci \u00B7 focus: ", _jsx(Text, { color: "cyan", children: focus })] })), mode === 'normal' && launch.length > 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["launch ", legend.shown, legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, sessions: windowSessions, total: childSessions.length, hidden: hiddenSessions, selectedId: selSessionId, focused: focus === 'sessions', above: sessionWin.start, below: visibleSessions.length - sessionWin.end })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: normalizeEmoji(note) }) : null] }));
|
|
687
743
|
}
|
|
688
744
|
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
689
745
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
@@ -710,34 +766,61 @@ function FilterModal({ view, cursor }) {
|
|
|
710
766
|
// La riga di anteprima mostra il testo ESATTO che finirà nel campo `Progress`
|
|
711
767
|
// del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
|
|
712
768
|
function EditModal({ id, draft, row }) {
|
|
713
|
-
const mark = (r) => (row === r ?
|
|
714
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 priorit\u00E0 e stato"] }), _jsxs(Text, { children: [mark(0), _jsx(Text, { dimColor: true, children: "pri " }), EDIT_PRI.map((p) => (_jsxs(Text, { inverse: draft.pri === p, color: draft.pri === p ? 'green' : 'gray', children: [' ', forceEmojiWidth(PRI_GLYPH[p]), " ", PRI_LABEL[p]] }, p)))] }), _jsxs(Text, { children: [mark(1), _jsx(Text, { dimColor: true, children: "stato" }), EDIT_PROG.map((p) => (_jsxs(Text, { inverse: draft.prog === p, color: draft.prog === p ? 'green' : 'gray', children: [' ', forceEmojiWidth(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(Text, { dimColor: true, children: "prog " }), _jsxs(Text, { children: [' ', draft.detail] }), row === 2 ? _jsx(Text, { inverse: true, children: " " }) : null, !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { dimColor: true, children: ["\u21B3 ", progressText(draft.prog, draft.detail)] })] }));
|
|
769
|
+
const mark = (r) => (row === r ? CARET : CARET_OFF);
|
|
770
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 priorit\u00E0 e stato"] }), _jsxs(Text, { children: [mark(0), _jsx(Text, { dimColor: true, children: "pri " }), EDIT_PRI.map((p) => (_jsxs(Text, { inverse: draft.pri === p, color: draft.pri === p ? 'green' : 'gray', children: [' ', forceEmojiWidth(PRI_GLYPH[p]), " ", PRI_LABEL[p]] }, p)))] }), _jsxs(Text, { children: [mark(1), _jsx(Text, { dimColor: true, children: "stato" }), EDIT_PROG.map((p) => (_jsxs(Text, { inverse: draft.prog === p, color: draft.prog === p ? 'green' : 'gray', children: [' ', forceEmojiWidth(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(Text, { dimColor: true, children: "prog " }), _jsxs(Text, { children: [' ', draft.detail] }), row === 2 ? _jsx(Text, { inverse: true, children: " " }) : null, !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", normalizeEmoji(progressText(draft.prog, draft.detail))] })] }));
|
|
715
771
|
}
|
|
716
|
-
function TasksPane({ tasks, total, hidden, view, selected, spotCount, childCount, focused, loadError, detail, }) {
|
|
772
|
+
function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount, childCount, focused, loadError, detail, windowStart, above, below, detailLines, columns, }) {
|
|
717
773
|
const spotSelected = selected === 0;
|
|
718
|
-
return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, children: ["Tasks (", hidden > 0 ? `${
|
|
774
|
+
return (_jsxs(Box, { flexDirection: "column", width: "50%", marginRight: 1, borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Tasks (", hidden > 0 ? `${filtered}/${total}` : filtered, ")", hidden > 0 ? _jsxs(Text, { color: "yellow", children: [" \u00B7 ", hidden, " nascoste"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort: ", describeSort(view.sort), view.hiddenPri.length + view.hiddenProg.length > 0 ? (_jsxs(Text, { children: [' ', "\u00B7 filtri:", ' ', [
|
|
719
775
|
...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
|
|
720
776
|
...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
|
|
721
777
|
]
|
|
722
|
-
.map((e) => `−${e.glyph}`)
|
|
723
|
-
.join(' ')] })) : null] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ?
|
|
724
|
-
|
|
778
|
+
.map((e) => `−${normalizeEmoji(e.glyph)}`)
|
|
779
|
+
.join(' ')] })) : null] }), _jsxs(Text, { inverse: spotSelected && focused, bold: spotSelected && !focused, wrap: "truncate-end", children: [spotSelected ? CARET : CARET_OFF, "\u25CB spot sessioni libere", spotCount > 0 ? ` (${spotCount})` : ''] }), loadError ? (_jsx(Text, { color: "red", wrap: "truncate-end", children: loadError })) : (tasks.map((task, i) => {
|
|
780
|
+
// windowStart riporta l'indice di finestra a quello della lista
|
|
781
|
+
// completa, su cui è keyata la selezione. +1: lo 0 è spot.
|
|
782
|
+
const sel = windowStart + i + 1 === selected;
|
|
725
783
|
const n = childCount.get(task.id) ?? 0;
|
|
726
|
-
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ?
|
|
727
|
-
})), detail ? _jsx(DetailPane, { detail: detail }) : null] }));
|
|
784
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", forceEmojiWidth(task.pri), " ", displayProg(task.prog), " ", task.desc, n > 0 ? ` (${n})` : ''] }, task.id));
|
|
785
|
+
})), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
|
|
728
786
|
}
|
|
729
|
-
function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId, focused, }) {
|
|
730
|
-
return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, children: ["Sessions \u00B7 ", parentLabel, " (", total, ")", hidden > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 +", hidden, " pi\u00F9 vecchie"] }) : null] }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isSpot ? 'nessuna sessione libera' : 'nessuna sessione legata a questa task' })) : (sessions.map((s) => {
|
|
787
|
+
function SessionsPane({ parentLabel, isSpot, sessions, total, hidden, selectedId, focused, above, below, }) {
|
|
788
|
+
return (_jsxs(Box, { flexDirection: "column", width: "50%", borderStyle: "single", borderColor: focused ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Text, { bold: true, color: focused ? 'cyan' : undefined, wrap: "truncate-end", children: ["Sessions \u00B7 ", parentLabel, " (", total, ")", hidden > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 +", hidden, " pi\u00F9 vecchie"] }) : null, above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2191", above] }) : null, below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 \u2193", below] }) : null] }), total === 0 ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: isSpot ? 'nessuna sessione libera' : 'nessuna sessione legata a questa task' })) : (sessions.map((s) => {
|
|
731
789
|
const sel = s.sessionId === selectedId;
|
|
732
|
-
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ?
|
|
790
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isSpot ? _jsx(Text, { dimColor: true, children: "\u25CB" }) : _jsx(Text, { color: "green", children: "\uD83D\uDD17" }), ' ', truncate(s.title, 44), ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
|
|
733
791
|
}))] }));
|
|
734
792
|
}
|
|
735
|
-
|
|
793
|
+
/**
|
|
794
|
+
* Righe non-wrappabili del dettaglio (titolo + meta + commit) e loro conteggio.
|
|
795
|
+
* Estratto dal componente perché il budget deve saperlo PRIMA di renderizzare:
|
|
796
|
+
* sono righe fisse che tolgono spazio alla descrizione.
|
|
797
|
+
*/
|
|
798
|
+
function detailMetaOf(detail) {
|
|
736
799
|
const meta = META_KEYS.map((k) => detail.fields[k])
|
|
737
800
|
.filter(Boolean)
|
|
738
801
|
.join(' · ');
|
|
739
|
-
const commit = detail.fields['Last tracked commit'];
|
|
740
|
-
return
|
|
802
|
+
const commit = detail.fields['Last tracked commit'] ?? '';
|
|
803
|
+
return { meta, commit, metaLines: 1 + (meta ? 1 : 0) + (commit ? 1 : 0) };
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Larghezza utile del testo di descrizione, ricavata dalle colonne del
|
|
807
|
+
* terminale: box esterno (2 bordi + 2 padding) → pane al 50% → box dettaglio
|
|
808
|
+
* (2 bordi + 2 padding).
|
|
809
|
+
*
|
|
810
|
+
* Volutamente prudente: sottostimare tronca qualche carattere in più,
|
|
811
|
+
* sovrastimare farebbe andare a capo una riga e sforare il tetto d'altezza.
|
|
812
|
+
*/
|
|
813
|
+
function detailTextWidth(columns) {
|
|
814
|
+
return Math.max(10, Math.floor(((columns || 80) - 4) / 2) - 9);
|
|
815
|
+
}
|
|
816
|
+
function DetailPane({ detail, maxLines, columns, }) {
|
|
817
|
+
const { meta, commit } = detailMetaOf(detail);
|
|
818
|
+
// Wrap calcolato qui, non delegato a `<Text wrap="wrap">`: il budget ha
|
|
819
|
+
// riservato ESATTAMENTE `maxLines` righe, e un wrap deciso da Ink a runtime
|
|
820
|
+
// ne produrrebbe un numero che il budget non conosce — cioè il frame torna a
|
|
821
|
+
// sforare e il bug si riapre da questa singola casella di testo.
|
|
822
|
+
const lines = wrapLines(detail.description ?? '', detailTextWidth(columns), maxLines);
|
|
823
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: detail.title || detail.id }), meta ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: meta }) : null, lines.map((line, i) => (_jsx(Text, { wrap: "truncate-end", children: line }, i))), commit ? _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", commit] }) : null] }));
|
|
741
824
|
}
|
|
742
825
|
const cwd = process.cwd();
|
|
743
826
|
render(_jsx(Deck, { cwd: cwd, tasksPath: resolveTasksPath(cwd), tasksDir: resolveTasksDir(cwd) }));
|
package/dist/config.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// indice 1..9. Il `command` gira con cwd = project root.
|
|
6
6
|
import { readFileSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
+
import { normalizeEmoji } from './viewport.js';
|
|
8
9
|
export function configFilePath(projectRoot) {
|
|
9
10
|
return join(projectRoot, '.claude', 'loom-works.json');
|
|
10
11
|
}
|
|
@@ -22,9 +23,12 @@ export function parseLaunch(raw) {
|
|
|
22
23
|
if (typeof command !== 'string' || !command.trim())
|
|
23
24
|
continue;
|
|
24
25
|
out.push({
|
|
25
|
-
emoji
|
|
26
|
+
// L'emoji arriva dal file config: testo arbitrario, quindi va normalizzato
|
|
27
|
+
// come ogni altro dato esterno — `☕` senza VS16 allargherebbe di una cella
|
|
28
|
+
// la riga della legenda, mandandola a capo (vedi normalizeEmoji).
|
|
29
|
+
emoji: normalizeEmoji(typeof emoji === 'string' ? emoji : '▸'),
|
|
26
30
|
// `label` è opzionale per contratto → fallback sul comando stesso.
|
|
27
|
-
label: typeof label === 'string' && label ? label : command,
|
|
31
|
+
label: normalizeEmoji(typeof label === 'string' && label ? label : command),
|
|
28
32
|
command,
|
|
29
33
|
});
|
|
30
34
|
}
|
package/dist/sessions.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
|
+
import { normalizeEmoji } from './viewport.js';
|
|
4
5
|
// CC codifica la project dir sostituendo ogni char non-alfanumerico con '-'
|
|
5
6
|
// (verificato: `/home/lamemind/cc-host` → `-home-lamemind-cc-host`). È LOSSY
|
|
6
7
|
// (un '-' nel path e un '/' collassano nello stesso char) → il forward è
|
|
@@ -91,7 +92,7 @@ function parseSessionFile(path, mtime) {
|
|
|
91
92
|
cwd,
|
|
92
93
|
gitBranch,
|
|
93
94
|
parentUuid,
|
|
94
|
-
title: customTitle || firstUserText || '(senza titolo)',
|
|
95
|
+
title: normalizeEmoji(customTitle || firstUserText || '(senza titolo)'),
|
|
95
96
|
ts: mtime,
|
|
96
97
|
path,
|
|
97
98
|
};
|
package/dist/tasks.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
+
import { normalizeEmoji } from './viewport.js';
|
|
3
4
|
// D1 (preflight T20): default docs/tasks.md, override della docs-root via env
|
|
4
5
|
// LOOM_DECK_DOCS_ROOT (es. questo progetto usa `runtime`). No auto-detect.
|
|
5
6
|
export function resolveTasksPath(cwd = process.cwd()) {
|
|
@@ -32,7 +33,16 @@ export function parseTasks(content) {
|
|
|
32
33
|
const prog = cells[4] ?? '';
|
|
33
34
|
// desc = colonna finale; join per resistere a eventuali `|` nella descrizione.
|
|
34
35
|
const desc = cells.slice(5, -1).join('|').trim();
|
|
35
|
-
|
|
36
|
+
// Normalizzazione larghezza glifi AL CONFINE: tutto ciò che entra da
|
|
37
|
+
// tasks.md è testo arbitrario e può contenere emoji BMP che Ink misura una
|
|
38
|
+
// cella in meno del terminale (vedi normalizeEmoji). Farlo qui invece che a
|
|
39
|
+
// ogni sito di render è ciò che impedisce di dimenticarne uno.
|
|
40
|
+
tasks.push({
|
|
41
|
+
id,
|
|
42
|
+
pri: normalizeEmoji(pri),
|
|
43
|
+
prog: normalizeEmoji(prog),
|
|
44
|
+
desc: normalizeEmoji(desc),
|
|
45
|
+
});
|
|
36
46
|
}
|
|
37
47
|
return tasks;
|
|
38
48
|
}
|
|
@@ -85,7 +95,18 @@ export function parseTaskDetail(id, content) {
|
|
|
85
95
|
fields[key] = f[2].trim();
|
|
86
96
|
}
|
|
87
97
|
}
|
|
88
|
-
|
|
98
|
+
// Stessa normalizzazione dell'overview: il task file è testo libero, e la
|
|
99
|
+
// descrizione finisce nel pannello dettaglio dove un glifo mal misurato
|
|
100
|
+
// allarga la riga oltre il bordo del pane.
|
|
101
|
+
const normFields = {};
|
|
102
|
+
for (const [k, v] of Object.entries(fields))
|
|
103
|
+
normFields[k] = normalizeEmoji(v);
|
|
104
|
+
return {
|
|
105
|
+
id,
|
|
106
|
+
title: normalizeEmoji(title),
|
|
107
|
+
fields: normFields,
|
|
108
|
+
description: normalizeEmoji(descLines.join('\n').trim()),
|
|
109
|
+
};
|
|
89
110
|
}
|
|
90
111
|
export function loadTaskDetail(dir, id) {
|
|
91
112
|
const path = findTaskFile(dir, id);
|
package/dist/viewport.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Budget d'altezza del frame — il deck non deve MAI renderizzare più righe di
|
|
2
|
+
// quante ne ha il terminale.
|
|
3
|
+
//
|
|
4
|
+
// Perché: quando `outputHeight >= stdout.rows`, Ink (ink/build/ink.js:121)
|
|
5
|
+
// abbandona il diff incrementale e passa a `clearTerminal + write` a ogni
|
|
6
|
+
// frame. Su VTE/Ptyxis `clearTerminal` non distrugge il contenuto: lo spinge
|
|
7
|
+
// nello scrollback. Col poll del deck che ridisegna ogni POLL_MS, ogni tick
|
|
8
|
+
// deposita un frame intero nella cronologia — da lì le righe-fantasma di
|
|
9
|
+
// bordi vuoti sopra l'header.
|
|
10
|
+
//
|
|
11
|
+
// Il fix non è cosmetico: è tenere il frame sotto `rows`, sempre. Tutto ciò
|
|
12
|
+
// che è a lunghezza variabile (lista task, lista sessioni, descrizione del
|
|
13
|
+
// dettaglio) passa da qui e riceve una capienza in righe.
|
|
14
|
+
//
|
|
15
|
+
// Logica pura, zero React/Ink: testabile senza pseudo-terminale.
|
|
16
|
+
import stringWidth from 'string-width';
|
|
17
|
+
/** Variation Selector-16: forza la presentazione emoji del carattere che precede. */
|
|
18
|
+
const VS16 = '️';
|
|
19
|
+
/**
|
|
20
|
+
* Riallinea la larghezza dei glifi fra Ink e il terminale.
|
|
21
|
+
*
|
|
22
|
+
* Secondo meccanismo di sporcamento dello scrollback, indipendente dal budget
|
|
23
|
+
* d'altezza. Ink assembla ogni riga su una griglia di CARATTERI: per un emoji
|
|
24
|
+
* BMP senza VS16 (`☕` U+2615, `✔` U+2714, `⚡` U+26A1, `✅` U+2705, `▶` U+25B6)
|
|
25
|
+
* riserva una sola posizione, mentre il terminale ne disegna due. La riga esce
|
|
26
|
+
* larga `columns + 1`, il terminale la manda a capo, e l'altezza reale supera
|
|
27
|
+
* quella che Ink crede — senza mai passare dal ramo `clearTerminal`. Con più
|
|
28
|
+
* glifi sulla stessa riga l'eccesso si somma.
|
|
29
|
+
*
|
|
30
|
+
* Il VS16 esplicito fa contare 2 anche a Ink, riallineando le due contabilità.
|
|
31
|
+
*
|
|
32
|
+
* Il predicato è la LARGHEZZA MISURATA, non l'intervallo di codepoint: nello
|
|
33
|
+
* stesso blocco U+2190–U+2BFF vivono anche `↓` `↑` `−`, larghi 1, e timbrarli
|
|
34
|
+
* col VS16 li porterebbe a 2 accorciando la riga — l'errore opposto, con lo
|
|
35
|
+
* stesso effetto di layout rotto. Gli astrali (U+1F000+) Ink li conta già bene.
|
|
36
|
+
*
|
|
37
|
+
* Idempotente: un glifo che ha già il VS16 non viene ri-timbrato.
|
|
38
|
+
*/
|
|
39
|
+
export function normalizeEmoji(s) {
|
|
40
|
+
if (!s)
|
|
41
|
+
return s;
|
|
42
|
+
const cps = [...s];
|
|
43
|
+
let out = '';
|
|
44
|
+
for (let i = 0; i < cps.length; i++) {
|
|
45
|
+
const ch = cps[i];
|
|
46
|
+
out += ch;
|
|
47
|
+
const cp = ch.codePointAt(0);
|
|
48
|
+
if (cp < 0x10000 && stringWidth(ch) === 2 && cps[i + 1] !== VS16)
|
|
49
|
+
out += VS16;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** Righe lasciate libere sotto il frame. La condizione di Ink è `>=`, quindi
|
|
54
|
+
* basterebbe 1; ne teniamo 1 come margine per l'a-capo del cursore. */
|
|
55
|
+
export const SLACK = 1;
|
|
56
|
+
/** Sotto questa soglia la lista task non è più utilizzabile: meglio sacrificare
|
|
57
|
+
* il pannello di dettaglio che ridurre la lista a due righe. */
|
|
58
|
+
const MIN_TASK_ROWS = 3;
|
|
59
|
+
/** Il dettaglio è secondario: non si prende mai più di così, anche con spazio. */
|
|
60
|
+
const MAX_DETAIL_LINES = 4;
|
|
61
|
+
/** Righe di "cornice" fisse dei tre contenitori a lunghezza variabile. */
|
|
62
|
+
const TASKS_PANE_CHROME = 5; // 2 bordi + header "Tasks (n)" + riga sort + riga spot
|
|
63
|
+
const SESSIONS_PANE_CHROME = 3; // 2 bordi + header "Sessions · …"
|
|
64
|
+
const DETAIL_CHROME = 3; // marginTop + 2 bordi
|
|
65
|
+
/** Altezza di ciascuna modale, marginTop incluso. In flusso, non in overlay:
|
|
66
|
+
* spingono giù i pane, quindi il loro costo va scalato dal budget. */
|
|
67
|
+
export const MODAL_HEIGHT = {
|
|
68
|
+
normal: 0,
|
|
69
|
+
create: 4, // marginTop + 2 bordi + 1 riga input
|
|
70
|
+
sort: 5, // marginTop + 2 bordi + titolo + 1 riga catena
|
|
71
|
+
filter: 6, // marginTop + 2 bordi + titolo + 2 righe (pri, stato)
|
|
72
|
+
edit: 8, // marginTop + 2 bordi + titolo + 3 campi + riga anteprima
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Distribuisce le righe disponibili fra lista task, lista sessioni e dettaglio.
|
|
76
|
+
*
|
|
77
|
+
* I due pane stanno affiancati (flexDirection row) → l'altezza del blocco è il
|
|
78
|
+
* MAX delle due colonne, non la somma: ognuna riceve lo stesso tetto.
|
|
79
|
+
*
|
|
80
|
+
* Ordine di sacrificio quando lo spazio stringe:
|
|
81
|
+
* 1. righe di descrizione del dettaglio (fino a sparire col pannello),
|
|
82
|
+
* 2. righe della lista task, mai sotto MIN_TASK_ROWS finché il dettaglio c'è.
|
|
83
|
+
*/
|
|
84
|
+
export function layoutBudget(input) {
|
|
85
|
+
const outerChrome = 2 + // bordi del box esterno
|
|
86
|
+
1 + // titolo "loom-deck"
|
|
87
|
+
1 + // riga navigazione
|
|
88
|
+
(input.launchLine ? 1 : 0) +
|
|
89
|
+
MODAL_HEIGHT[input.mode] +
|
|
90
|
+
1 + // marginTop del blocco pane
|
|
91
|
+
(input.noteLine ? 1 : 0);
|
|
92
|
+
// Tetto per colonna: righe che restano ai due pane affiancati.
|
|
93
|
+
const avail = (input.rows || 24) - SLACK - outerChrome;
|
|
94
|
+
// La cornice del pane task (bordi + 3 header) più ALMENO una riga di lista.
|
|
95
|
+
// Il `+1` non è cosmetico: senza, un terminale bassissimo produce un pane
|
|
96
|
+
// regolamentare con zero task dentro — occupa 5 righe per non mostrare nulla,
|
|
97
|
+
// mentre la riga compatta dice le stesse cose in una.
|
|
98
|
+
if (avail < TASKS_PANE_CHROME + 1) {
|
|
99
|
+
return { taskRows: 0, sessionRows: 0, detailLines: 0, compact: true };
|
|
100
|
+
}
|
|
101
|
+
const sessionRows = Math.max(0, avail - SESSIONS_PANE_CHROME);
|
|
102
|
+
let detailLines = 0;
|
|
103
|
+
let detailChrome = 0;
|
|
104
|
+
if (input.hasDetail) {
|
|
105
|
+
const fixed = DETAIL_CHROME + input.detailMetaLines;
|
|
106
|
+
// Righe che avanzano dopo aver garantito la lista minima e la cornice del
|
|
107
|
+
// dettaglio. Serve almeno 1 riga di descrizione per giustificare il box:
|
|
108
|
+
// un pannello con la sola cornice ruberebbe 3+ righe per mostrare nulla.
|
|
109
|
+
const spare = avail - TASKS_PANE_CHROME - MIN_TASK_ROWS - fixed;
|
|
110
|
+
if (spare >= 1) {
|
|
111
|
+
detailChrome = fixed;
|
|
112
|
+
detailLines = Math.min(MAX_DETAIL_LINES, spare);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const taskRows = Math.max(0, avail - TASKS_PANE_CHROME - detailChrome - detailLines);
|
|
116
|
+
return { taskRows, sessionRows, detailLines, compact: false };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Finestra scorrevole su una lista più lunga della capienza.
|
|
120
|
+
*
|
|
121
|
+
* Centra la selezione, poi clampa ai bordi — così in cima e in fondo alla lista
|
|
122
|
+
* la finestra non spreca righe fuori dai dati.
|
|
123
|
+
*
|
|
124
|
+
* `selected` è l'indice nella lista completa; -1 (o fuori range) = nessuna
|
|
125
|
+
* selezione, la finestra parte da capo.
|
|
126
|
+
*/
|
|
127
|
+
export function windowRange(total, selected, capacity) {
|
|
128
|
+
if (capacity <= 0 || total <= 0)
|
|
129
|
+
return { start: 0, end: 0 };
|
|
130
|
+
if (total <= capacity)
|
|
131
|
+
return { start: 0, end: total };
|
|
132
|
+
const sel = selected >= 0 && selected < total ? selected : 0;
|
|
133
|
+
const start = Math.max(0, Math.min(sel - Math.floor(capacity / 2), total - capacity));
|
|
134
|
+
return { start, end: start + capacity };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Hard-wrap a larghezza fissa, con tetto di righe.
|
|
138
|
+
*
|
|
139
|
+
* Serve un conteggio righe DETERMINISTICO: `<Text wrap="wrap">` di Ink wrappa a
|
|
140
|
+
* runtime su una larghezza che il budget non conosce, quindi il pannello
|
|
141
|
+
* dettaglio potrebbe sforare il tetto e riaprire il bug. Qui il testo viene
|
|
142
|
+
* spezzato prima, e ogni riga è renderizzata con `wrap="truncate-end"`.
|
|
143
|
+
*
|
|
144
|
+
* Sottostimare `width` è sicuro (tronca prima), sovrastimarlo no (la riga
|
|
145
|
+
* andrebbe a capo aggiungendo altezza non contabilizzata).
|
|
146
|
+
*/
|
|
147
|
+
export function wrapLines(text, width, maxLines) {
|
|
148
|
+
if (maxLines <= 0 || width <= 0)
|
|
149
|
+
return [];
|
|
150
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
151
|
+
if (!flat)
|
|
152
|
+
return [];
|
|
153
|
+
const lines = [];
|
|
154
|
+
let line = '';
|
|
155
|
+
for (const word of flat.split(' ')) {
|
|
156
|
+
if (!line) {
|
|
157
|
+
line = word;
|
|
158
|
+
}
|
|
159
|
+
else if (line.length + 1 + word.length <= width) {
|
|
160
|
+
line += ' ' + word;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
lines.push(line);
|
|
164
|
+
line = word;
|
|
165
|
+
if (lines.length === maxLines)
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
// Parola più lunga della riga: spezzala a forza, altrimenti l'a-capo lo
|
|
169
|
+
// farebbe il terminale — fuori dal nostro conteggio.
|
|
170
|
+
while (line.length > width) {
|
|
171
|
+
lines.push(line.slice(0, width));
|
|
172
|
+
line = line.slice(width);
|
|
173
|
+
if (lines.length === maxLines)
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
if (lines.length === maxLines)
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
if (line && lines.length < maxLines)
|
|
180
|
+
lines.push(line);
|
|
181
|
+
const kept = lines.slice(0, maxLines);
|
|
182
|
+
// Troncamento MAI silenzioso, come le liste: l'ellissi segnala che il testo
|
|
183
|
+
// continua oltre il pannello.
|
|
184
|
+
const consumed = kept.join(' ').length;
|
|
185
|
+
if (consumed < flat.length && kept.length > 0) {
|
|
186
|
+
const last = kept[kept.length - 1];
|
|
187
|
+
kept[kept.length - 1] = last.length >= width ? last.slice(0, Math.max(0, width - 1)) + '…' : last + '…';
|
|
188
|
+
}
|
|
189
|
+
return kept;
|
|
190
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lamemind/loom-deck",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
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": {
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"license": "MIT",
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"ink": "^5.1.0",
|
|
45
|
-
"react": "^18.3.1"
|
|
45
|
+
"react": "^18.3.1",
|
|
46
|
+
"string-width": "^7.2.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@types/node": "^22.20.0",
|