@lamemind/loom-deck 0.17.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +67 -50
- package/dist/config.js +4 -4
- package/dist/session-list.js +9 -6
- package/dist/sessions.js +17 -5
- package/dist/task-index.js +8 -1
- package/dist/tasks.js +15 -14
- package/dist/viewport.js +0 -158
- package/dist/width.js +333 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,8 @@ import { buildRows, firstRowKey, moveRowSelection, rowIndexOfKey, searchSessions
|
|
|
13
13
|
import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSessionIndex, } from './task-index.js';
|
|
14
14
|
import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, rowLabel, selectedSession, stripProjectCore, } from './session-list.js';
|
|
15
15
|
import { launchLegend, loadIdentity, loadLaunch } from './config.js';
|
|
16
|
-
import { isCompact, layoutBudget,
|
|
16
|
+
import { isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
|
|
17
|
+
import { cut, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
|
|
17
18
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
18
19
|
import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
|
|
19
20
|
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
@@ -339,41 +340,23 @@ function useTaskDetail(tasksDir, id) {
|
|
|
339
340
|
}, [tasksDir, id]);
|
|
340
341
|
return detail;
|
|
341
342
|
}
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
// composto — la riga della legenda launch, una descrizione task con emoji
|
|
348
|
-
// dentro, il titolo di una sessione;
|
|
349
|
-
// - decideva per intervallo di codepoint invece che per larghezza misurata,
|
|
350
|
-
// quindi avrebbe timbrato anche `↓` `↑` `−` (larghi 1) se gli fossero
|
|
351
|
-
// arrivati da soli, accorciando la riga invece di allargarla.
|
|
352
|
-
const forceEmojiWidth = normalizeEmoji;
|
|
353
|
-
// Glifi LETTERALI del JSX che ricadono nella classe mal misurata (BMP largo 2
|
|
354
|
-
// senza VS16). I dati passano dai loader, questi no: normalizzati una volta
|
|
355
|
-
// qui, così nessun sito di render li scrive nudi. `↳ ○ ▸ ⏎ · − ↑ ↓` sono
|
|
356
|
-
// larghi 1 e restano intatti.
|
|
357
|
-
const CARET = normalizeEmoji('▶ ');
|
|
343
|
+
// Glifi LETTERALI del JSX. I dati passano dai loader, che sanificano al
|
|
344
|
+
// confine; questi no — quindi passano da `sanitize` una volta qui, così nessun
|
|
345
|
+
// sito di render scrive un glifo nudo. `↳ ○ ▸ ⏎ · − ↑ ↓` sono già concordi e
|
|
346
|
+
// restano intatti; `▶` e `⚠` sono discordi e vengono sostituiti (`width.ts`).
|
|
347
|
+
const CARET = sanitize('▶ ');
|
|
358
348
|
const CARET_OFF = ' ';
|
|
359
|
-
|
|
360
|
-
// (come CARET), altrimenti la riga slitta di una colonna.
|
|
361
|
-
const WARN = normalizeEmoji('⚠');
|
|
349
|
+
const WARN = sanitize('⚠');
|
|
362
350
|
// T50 — separatore leggero fra blocco pinnate e contestuali. `─` (box-drawing) è
|
|
363
|
-
// largo 1 sia per string-width sia per il terminale
|
|
351
|
+
// largo 1 sia per string-width sia per il terminale. Corto +
|
|
364
352
|
// wrap="truncate-end" così non va mai a capo nel pane al 50%.
|
|
365
353
|
const SESSION_SEP = '─'.repeat(16);
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
// riservato → la coda della riga (bordo destro del pane incluso) slittava a
|
|
371
|
-
// sinistra, sfasando il layout solo su quelle righe. `✅` (U+2705) è invece
|
|
372
|
-
// emoji-presentation-default → largo 2 sia per string-width sia per il terminale,
|
|
373
|
-
// come 🔵/🟡: colonna Prog allineata. Cambia SOLO il display: `task.prog` resta
|
|
374
|
-
// `✔️` così `isDone()` continua a matchare.
|
|
354
|
+
// Marker Done per il DISPLAY. `task.prog` resta il `✔️` letto da tasks.md —
|
|
355
|
+
// `isDone()` e le lookup di `view.ts` ci confrontano sopra, e `task-edit` lo
|
|
356
|
+
// riscrive sul file: è una chiave semantica, non testo. Qui `sanitize` lo
|
|
357
|
+
// traduce nel suo gemello concorde (`✅`) solo per finire nel frame.
|
|
375
358
|
function displayProg(prog) {
|
|
376
|
-
return
|
|
359
|
+
return sanitize(prog);
|
|
377
360
|
}
|
|
378
361
|
// T49 — size umana compatta per il detail pane sessione.
|
|
379
362
|
function fmtSize(bytes) {
|
|
@@ -581,7 +564,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
581
564
|
}
|
|
582
565
|
const sid = randomUUID();
|
|
583
566
|
const beforeIds = new Set(tasks.map((t) => t.id));
|
|
584
|
-
setNote(`⏳ creando task… "${
|
|
567
|
+
setNote(`⏳ creando task… "${cut(text, 40)}" (sid ${sid.slice(0, 8)})`);
|
|
585
568
|
const child = spawnCreateTask(text, cwd, sid, (ok) => {
|
|
586
569
|
if (!ok) {
|
|
587
570
|
setNote(`⚠ create-task fallito (${CLAUDE_CMD} -p)`);
|
|
@@ -633,7 +616,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
633
616
|
appendNote(cwd, sid, text);
|
|
634
617
|
reloadSessions();
|
|
635
618
|
setNote(text
|
|
636
|
-
? `✎ nota su ${sid.slice(0, 8)}: "${
|
|
619
|
+
? `✎ nota su ${sid.slice(0, 8)}: "${cut(text, 40)}"`
|
|
637
620
|
: `✎ nota rimossa da ${sid.slice(0, 8)}`);
|
|
638
621
|
}
|
|
639
622
|
// T41 — apertura dell'edit: la bozza parte dai valori ATTUALI della task, non
|
|
@@ -1302,7 +1285,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1302
1285
|
if (budget.compact) {
|
|
1303
1286
|
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"] })] }));
|
|
1304
1287
|
}
|
|
1305
|
-
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 === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \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' : canResume ? 'resume' : '—', canResume ? ' · f fork' : '', canPin ? ' · p pin · N nota' : '', " \u00B7 ^F cerca \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 === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _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, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children:
|
|
1288
|
+
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 === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \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' : canResume ? 'resume' : '—', canResume ? ' · f fork' : '', canPin ? ' · p pin · N nota' : '', " \u00B7 ^F cerca \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 === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _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, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
1306
1289
|
}
|
|
1307
1290
|
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
1308
1291
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
@@ -1321,7 +1304,7 @@ function FilterModal({ view, cursor }) {
|
|
|
1321
1304
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "F \u203A filtri" }), rows.map((row, r) => (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: row.label }), row.entries.map((e, c) => {
|
|
1322
1305
|
const on = !row.hidden.has(e.name);
|
|
1323
1306
|
const here = cursor.row === r && cursor.col === c;
|
|
1324
|
-
return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ",
|
|
1307
|
+
return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ", sanitize(e.glyph)] }, e.name));
|
|
1325
1308
|
})] }, row.label)))] }));
|
|
1326
1309
|
}
|
|
1327
1310
|
// T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
|
|
@@ -1330,12 +1313,12 @@ function FilterModal({ view, cursor }) {
|
|
|
1330
1313
|
// del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
|
|
1331
1314
|
function EditModal({ id, draft, row }) {
|
|
1332
1315
|
const mark = (r) => (row === r ? CARET : CARET_OFF);
|
|
1333
|
-
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: [' ',
|
|
1316
|
+
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: [' ', sanitize(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: [' ', sanitize(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 ", sanitize(progressText(draft.prog, draft.detail))] })] }));
|
|
1334
1317
|
}
|
|
1335
1318
|
// T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
|
|
1336
1319
|
// caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
|
|
1337
1320
|
// allinearsi, e i glifi BMP larghi 2 sono proprio la classe che Ink e il
|
|
1338
|
-
// terminale misurano diversamente (vedi
|
|
1321
|
+
// terminale misurano diversamente (vedi width.ts).
|
|
1339
1322
|
const KIND_TAG = { ai: 'ai', tool: 'tl', human: 'hu' };
|
|
1340
1323
|
const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
|
|
1341
1324
|
/**
|
|
@@ -1351,12 +1334,12 @@ const KIND_COLOR = { ai: 'cyan', tool: 'gray', human: 'green' };
|
|
|
1351
1334
|
* 4 box lista (2 bordi + 2 padding)
|
|
1352
1335
|
* 2 caret
|
|
1353
1336
|
* 4 indice del record
|
|
1354
|
-
*
|
|
1337
|
+
* 4 spazio + tag del kind (2 caratteri) + spazio
|
|
1355
1338
|
* Prudente per costruzione: sottostimare tronca un carattere in più,
|
|
1356
1339
|
* sovrastimare manderebbe la riga a capo e sfonderebbe il budget d'altezza.
|
|
1357
1340
|
*/
|
|
1358
1341
|
function searchExcerptWidth(columns) {
|
|
1359
|
-
return Math.max(30, (columns || 80) -
|
|
1342
|
+
return Math.max(30, (columns || 80) - 18);
|
|
1360
1343
|
}
|
|
1361
1344
|
/** Titolo della conversazione sulla riga-gruppo: prende ciò che avanza dopo le
|
|
1362
1345
|
* colonne a larghezza fissa (caret, pin, hash, task, conteggio, data). */
|
|
@@ -1421,17 +1404,18 @@ function SearchScreen({ preview, hash, query, field, opts, result, rows, selecte
|
|
|
1421
1404
|
const s = row.session;
|
|
1422
1405
|
const bound = bindings.get(s.sessionId);
|
|
1423
1406
|
const rowNote = sessionNotes.get(s.sessionId);
|
|
1424
|
-
const noteShown = rowNote ?
|
|
1407
|
+
const noteShown = rowNote ? cut(rowNote, 24) : '';
|
|
1425
1408
|
// `+3` = i due caporali e lo spazio che li separa dall'etichetta.
|
|
1426
1409
|
// Il pavimento non è cosmetico: senza, un terminale stretto manda
|
|
1427
|
-
// l'argomento di `
|
|
1428
|
-
//
|
|
1429
|
-
|
|
1430
|
-
|
|
1410
|
+
// l'argomento di `cut` sotto zero, cioè un budget negativo.
|
|
1411
|
+
// La nota si misura con `termWidth`, non con `.length`: contiene
|
|
1412
|
+
// testo umano, emoji compresi.
|
|
1413
|
+
const restWidth = Math.max(8, searchTitleWidth(columns) - (noteShown ? termWidth(noteShown) + 3 : 0));
|
|
1414
|
+
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, pinned.has(s.sessionId) ? _jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" }) : _jsx(Text, { dimColor: true, children: "\u25CB" }), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, 8) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), bound ?? _jsx(Text, { dimColor: true, children: "spot" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), noteShown ? _jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", noteShown, "\u00BB "] }) : null, _jsx(Text, { dimColor: Boolean(noteShown), children: cut(conversationLabel(s, projectCore, bound), restWidth) }), _jsxs(Text, { dimColor: true, children: [' ', "(", row.hitCount, row.hidden > 0 ? `+${row.hidden}` : '', ") ", fmtDateTime(s.ts)] })] }, row.key));
|
|
1431
1415
|
}
|
|
1432
1416
|
const h = row.hit;
|
|
1433
|
-
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: String(h.idx).padStart(4) }), ' ', _jsx(Text, { color: KIND_COLOR[h.kind], children: KIND_TAG[h.kind] }),
|
|
1434
|
-
})] }), preview ? _jsx(SearchPreviewPane, { p: preview }) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children:
|
|
1417
|
+
return (_jsxs(Text, { inverse: sel, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { dimColor: true, children: String(h.idx).padStart(4) }), ' ', _jsx(Text, { color: KIND_COLOR[h.kind], children: KIND_TAG[h.kind] }), ' ', cut(h.excerpt, searchExcerptWidth(columns))] }, row.key));
|
|
1418
|
+
})] }), preview ? _jsx(SearchPreviewPane, { p: preview }) : null, note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
1435
1419
|
}
|
|
1436
1420
|
/**
|
|
1437
1421
|
* Anteprima dell'occorrenza selezionata, sotto la lista.
|
|
@@ -1478,13 +1462,22 @@ function TasksPane({ tasks, filtered, total, hidden, view, selected, spotCount,
|
|
|
1478
1462
|
...PRI_ENTRIES.filter((e) => view.hiddenPri.includes(e.name)),
|
|
1479
1463
|
...PROG_ENTRIES.filter((e) => view.hiddenProg.includes(e.name)),
|
|
1480
1464
|
]
|
|
1481
|
-
.map((e) => `−${
|
|
1465
|
+
.map((e) => `−${sanitize(e.glyph)}`)
|
|
1482
1466
|
.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) => {
|
|
1483
1467
|
// windowStart riporta l'indice di finestra a quello della lista
|
|
1484
1468
|
// completa, su cui è keyata la selezione. +1: lo 0 è spot.
|
|
1485
1469
|
const sel = windowStart + i + 1 === selected;
|
|
1486
1470
|
const n = childCount.get(task.id) ?? 0;
|
|
1487
|
-
|
|
1471
|
+
// Invariante ③: la descrizione è l'unico pezzo a lunghezza libera, e
|
|
1472
|
+
// si taglia QUI sul budget che resta dopo le colonne fisse. Lasciarlo
|
|
1473
|
+
// fare a `truncate-end` significa passare da `cli-truncate`, che
|
|
1474
|
+
// restituisce una riga più larga del pane (una colonna per emoji) e
|
|
1475
|
+
// quindi scrive sopra il bordo. Le parti fisse si misurano con
|
|
1476
|
+
// `termWidth`: `task.id` è `T9` o `T52`, i due glifi valgono 2 ciascuno.
|
|
1477
|
+
const head = `${CARET_OFF}${task.id} ${sanitize(task.pri)} ${displayProg(task.prog)} `;
|
|
1478
|
+
const tail = n > 0 ? ` (${n})` : '';
|
|
1479
|
+
const desc = cut(task.desc, Math.max(4, paneTextWidth(columns) - termWidth(head) - termWidth(tail)));
|
|
1480
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: !sel && isDone(task.prog), wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, task.id, " ", sanitize(task.pri), " ", displayProg(task.prog), " ", desc, tail] }, task.id));
|
|
1488
1481
|
})), detail && detailLines > 0 ? (_jsx(DetailPane, { detail: detail, maxLines: detailLines, columns: columns })) : null] }));
|
|
1489
1482
|
}
|
|
1490
1483
|
function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, selectedId, focused, above, below, detail, firstLines, lastLines, columns, forkOf, sessionNotes, projectCore, }) {
|
|
@@ -1498,7 +1491,7 @@ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, s
|
|
|
1498
1491
|
// T50 — pin stale: transcript sparito, nessuna Session da mostrare.
|
|
1499
1492
|
// Riga navigabile e spinnabile (`p`), marcata, mai un crash.
|
|
1500
1493
|
if (row.kind === 'pinned' && row.stale) {
|
|
1501
|
-
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, 8) }), sessionNotes.get(row.sessionId) ? (_jsxs(Text, { color: "yellow", children: [" \u00AB",
|
|
1494
|
+
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, 8) }), sessionNotes.get(row.sessionId) ? (_jsxs(Text, { color: "yellow", children: [" \u00AB", cut(sessionNotes.get(row.sessionId), 30), "\u00BB"] })) : null] }, row.sessionId));
|
|
1502
1495
|
}
|
|
1503
1496
|
const s = row.session; // non-stale → session presente
|
|
1504
1497
|
const isPinnedRow = row.kind === 'pinned';
|
|
@@ -1508,7 +1501,17 @@ function SessionsPane({ parentLabel, isSpot, rows, total, pinnedCount, hidden, s
|
|
|
1508
1501
|
const forked = forkOf.has(s.sessionId);
|
|
1509
1502
|
// T53 — con una nota il prefisso di progetto sparisce e le sue colonne
|
|
1510
1503
|
// passano alla nota; senza, il titolo resta com'è (vedi `rowLabel`).
|
|
1511
|
-
|
|
1504
|
+
//
|
|
1505
|
+
// Invariante ③: il budget è DERIVATO da `columns`, non inchiodato.
|
|
1506
|
+
// Con un valore fisso (erano 44) su un terminale stretto la riga
|
|
1507
|
+
// superava il pane, e a troncarla finiva `cli-truncate` — che sfora
|
|
1508
|
+
// di una colonna per emoji e si mangia il bordo destro.
|
|
1509
|
+
const meta = ` · ${s.gitBranch || '-'} · ${relTime(s.ts)}`;
|
|
1510
|
+
const labelBudget = Math.max(12, paneTextWidth(columns) -
|
|
1511
|
+
(2 /* caret */ + 2 /* icona */ + 1 /* spazio */ + (forked ? 2 : 0)) -
|
|
1512
|
+
termWidth(meta) -
|
|
1513
|
+
1 /* spazio prima del meta */);
|
|
1514
|
+
const label = rowLabel(s.title, sessionNotes.get(s.sessionId), projectCore, labelBudget);
|
|
1512
1515
|
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: "\uD83D\uDCCC" })) : isSpot ? (_jsx(Text, { dimColor: true, children: "\u25CB" })) : (_jsx(Text, { color: "green", children: "\uD83D\uDD17" })), ' ', forked ? _jsx(Text, { color: "magenta", children: "\u2442 " }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' ', _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.gitBranch || '-', " \u00B7 ", relTime(s.ts)] })] }, s.sessionId));
|
|
1513
1516
|
})), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null, note: sessionNotes.get(detail.sessionId) ?? '' })) : null] }));
|
|
1514
1517
|
}
|
|
@@ -1549,6 +1552,20 @@ function detailMetaOf(detail) {
|
|
|
1549
1552
|
function detailTextWidth(columns) {
|
|
1550
1553
|
return Math.max(10, Math.floor(((columns || 80) - 4) / 2) - 9);
|
|
1551
1554
|
}
|
|
1555
|
+
/**
|
|
1556
|
+
* Larghezza del TESTO dentro un pane al 50%: box esterno (2 bordi + 2 padding)
|
|
1557
|
+
* → metà → bordo + padding del pane.
|
|
1558
|
+
*
|
|
1559
|
+
* Invariante ③ (`width.ts`): chi renderizza una riga a lunghezza libera la
|
|
1560
|
+
* taglia PRIMA con questa larghezza. Delegarlo a `wrap="truncate-end"`
|
|
1561
|
+
* significa passare da `cli-truncate`, che indicizza per code point e restituisce
|
|
1562
|
+
* una riga più larga di quella chiesta — una colonna per ogni emoji astrale a
|
|
1563
|
+
* sinistra del taglio. Quelle colonne finiscono sopra il bordo del pane, che
|
|
1564
|
+
* sparisce dalla riga: è la sminchiatura visibile a schermo.
|
|
1565
|
+
*/
|
|
1566
|
+
function paneTextWidth(columns) {
|
|
1567
|
+
return Math.max(20, Math.floor(((columns || 80) - 4) / 2) - 4);
|
|
1568
|
+
}
|
|
1552
1569
|
function DetailPane({ detail, maxLines, columns, }) {
|
|
1553
1570
|
const { meta, commit } = detailMetaOf(detail);
|
|
1554
1571
|
// Wrap calcolato qui, non delegato a `<Text wrap="wrap">`: il budget ha
|
package/dist/config.js
CHANGED
|
@@ -5,7 +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 {
|
|
8
|
+
import { sanitize } from './width.js';
|
|
9
9
|
export function configFilePath(projectRoot) {
|
|
10
10
|
return join(projectRoot, '.claude', 'loom-works.json');
|
|
11
11
|
}
|
|
@@ -25,10 +25,10 @@ export function parseLaunch(raw) {
|
|
|
25
25
|
out.push({
|
|
26
26
|
// L'emoji arriva dal file config: testo arbitrario, quindi va normalizzato
|
|
27
27
|
// come ogni altro dato esterno — `☕` senza VS16 allargherebbe di una cella
|
|
28
|
-
// la riga della legenda, mandandola a capo (vedi
|
|
29
|
-
emoji:
|
|
28
|
+
// la riga della legenda, mandandola a capo (vedi width.ts).
|
|
29
|
+
emoji: sanitize(typeof emoji === 'string' ? emoji : '▸'),
|
|
30
30
|
// `label` è opzionale per contratto → fallback sul comando stesso.
|
|
31
|
-
label:
|
|
31
|
+
label: sanitize(typeof label === 'string' && label ? label : command),
|
|
32
32
|
command,
|
|
33
33
|
});
|
|
34
34
|
}
|
package/dist/session-list.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// - Trap T39: la selezione è KEYED SU sessionId, non su indice posizionale. La
|
|
15
15
|
// lista è una vista trasformata (due gruppi + separatore): un indice grezzo
|
|
16
16
|
// identificherebbe la riga sbagliata dopo un pin/cambio-contesto.
|
|
17
|
-
import {
|
|
17
|
+
import { cut, termWidth } from './width.js';
|
|
18
18
|
// ── T53 · etichetta di riga ────────────────────────────────────────────────
|
|
19
19
|
/**
|
|
20
20
|
* Toglie dal titolo il core `<owner> <name>` del progetto e la punteggiatura di
|
|
@@ -62,19 +62,22 @@ const MIN_NOTE = 14;
|
|
|
62
62
|
*/
|
|
63
63
|
export function rowLabel(title, note, core, budget) {
|
|
64
64
|
if (!note)
|
|
65
|
-
return { note: '', rest:
|
|
65
|
+
return { note: '', rest: cut(title, Math.max(0, budget)) };
|
|
66
66
|
// 2 colonne per i caporali « », 1 per lo spazio prima del residuo.
|
|
67
67
|
const rest = stripProjectCore(title, core);
|
|
68
68
|
const noteBudget = Math.max(0, budget - 2);
|
|
69
69
|
if (!rest)
|
|
70
|
-
return { note:
|
|
70
|
+
return { note: cut(note, noteBudget), rest: '' };
|
|
71
71
|
// Il `min` col budget totale non è ridondante: su un pane strettissimo
|
|
72
72
|
// `MIN_NOTE` supererebbe le colonne disponibili e la riga andrebbe a capo —
|
|
73
73
|
// che è precisamente il difetto che ogni larghezza qui dentro esiste per
|
|
74
74
|
// evitare (una riga wrappata sfonda il budget d'ALTEZZA, non solo l'estetica).
|
|
75
|
-
const shownNote =
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
const shownNote = cut(note, Math.min(noteBudget, Math.max(MIN_NOTE, noteBudget - MIN_REST - 1)));
|
|
76
|
+
// `termWidth`, non `.length`: quanto RESTA si misura in colonne come tutto il
|
|
77
|
+
// resto: una nota con un emoji conta 2 sullo schermo e 1 (o 2) code unit, e
|
|
78
|
+
// sottrarre le une dalle altre rimetterebbe la riga fuori dal pane.
|
|
79
|
+
const restBudget = noteBudget - termWidth(shownNote) - 1;
|
|
80
|
+
return { note: shownNote, rest: restBudget >= MIN_REST ? cut(rest, restBudget) : '' };
|
|
78
81
|
}
|
|
79
82
|
export function assembleSessionList(childSessions, allSessions, pinned, maxContext) {
|
|
80
83
|
// Le pinnate si risolvono contro TUTTE le sessioni del progetto, non solo le
|
package/dist/sessions.js
CHANGED
|
@@ -1,7 +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 {
|
|
4
|
+
import { sanitize } from './width.js';
|
|
5
5
|
// CC codifica la project dir sostituendo ogni char non-alfanumerico con '-'
|
|
6
6
|
// (verificato: `/home/lamemind/cc-host` → `-home-lamemind-cc-host`). È LOSSY
|
|
7
7
|
// (un '-' nel path e un '/' collassano nello stesso char) → il forward è
|
|
@@ -122,7 +122,12 @@ function collectBodies(message, idx, isAssistant, out) {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
for (const kind of ['ai', 'tool', 'human']) {
|
|
125
|
-
|
|
125
|
+
// Sanificato QUI, prima che la ricerca ci giri sopra: gli offset del match
|
|
126
|
+
// (`matchStart`/`matchEnd`) indicizzano questo testo, e sanificare a valle —
|
|
127
|
+
// al render — li sposterebbe, evidenziando la parte sbagliata della riga.
|
|
128
|
+
// Un corpo di transcript è la sorgente più ostile che il deck legge: emoji
|
|
129
|
+
// qualsiasi, output di tool con byte di controllo, CJK.
|
|
130
|
+
const text = sanitize(parts[kind].join('\n'));
|
|
126
131
|
if (!text)
|
|
127
132
|
continue;
|
|
128
133
|
if (kind === 'human' && isInterrupt(text))
|
|
@@ -220,14 +225,21 @@ export function parseTranscript(content, path, mtime, sizeBytes) {
|
|
|
220
225
|
cwd,
|
|
221
226
|
gitBranch,
|
|
222
227
|
parentUuid,
|
|
223
|
-
title:
|
|
228
|
+
title: sanitize(customTitle || firstUserText || '(senza titolo)'),
|
|
224
229
|
ts: mtime,
|
|
225
230
|
path,
|
|
226
231
|
sizeBytes,
|
|
227
232
|
turns,
|
|
228
233
|
customTitle,
|
|
229
|
-
|
|
230
|
-
|
|
234
|
+
// Sanificati come il titolo, e per lo stesso motivo: le due preview del
|
|
235
|
+
// detail pane sono testo di transcript messo NEL FRAME. Erano l'ultimo
|
|
236
|
+
// varco rimasto — un `✅` (BMP largo 2) nell'estratto prende una cella
|
|
237
|
+
// sola nella griglia di Ink e due colonne sul terminale, quindi la riga
|
|
238
|
+
// scivola a destra e si mangia il bordo del pane. Qui il confine è lo
|
|
239
|
+
// stesso di `title`, non il render: `wrapLines` conta le colonne su questo
|
|
240
|
+
// testo, sanificare a valle sposterebbe l'a-capo già calcolato.
|
|
241
|
+
firstPrompt: sanitize(firstUserText),
|
|
242
|
+
lastReply: sanitize(lastAssistantText),
|
|
231
243
|
bodies,
|
|
232
244
|
};
|
|
233
245
|
}
|
package/dist/task-index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
+
import { sanitize } from './width.js';
|
|
3
4
|
export function taskIndexPath(projectRoot) {
|
|
4
5
|
return join(projectRoot, '.claude', 'loom', 'session-tasks.jsonl');
|
|
5
6
|
}
|
|
@@ -60,8 +61,14 @@ export function loadSessionIndex(projectRoot) {
|
|
|
60
61
|
// nota vuota da mostrare. Il `typeof` esclude i record senza il campo, che
|
|
61
62
|
// non devono toccare una nota scritta da un record precedente.
|
|
62
63
|
if (typeof d.note === 'string') {
|
|
64
|
+
// Sanificata in lettura, non solo alla digitazione: `sanitizeTyped`
|
|
65
|
+
// toglie i byte di controllo ma non ripara la larghezza, e il file è
|
|
66
|
+
// editabile a mano — una nota con un `✅` finirebbe nel frame larga il
|
|
67
|
+
// doppio di quanto Ink ha contato. Il round-trip (edit di una nota già
|
|
68
|
+
// sanificata → riscrittura del sostituto) è il prezzo accettato: a
|
|
69
|
+
// schermo il glifo originale non era comunque disegnabile.
|
|
63
70
|
if (d.note)
|
|
64
|
-
notes.set(d.sessionId, d.note);
|
|
71
|
+
notes.set(d.sessionId, sanitize(d.note));
|
|
65
72
|
else
|
|
66
73
|
notes.delete(d.sessionId);
|
|
67
74
|
}
|
package/dist/tasks.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { sanitize } from './width.js';
|
|
4
4
|
/**
|
|
5
5
|
* Forma di un ID task: `T` (code) o `D` (doc) + numero. FONTE UNICA del gate —
|
|
6
6
|
* `parseTasks` lo usa per scartare header/separatore della tabella, `task-edit`
|
|
@@ -42,16 +42,17 @@ export function parseTasks(content) {
|
|
|
42
42
|
const prog = cells[4] ?? '';
|
|
43
43
|
// desc = colonna finale; join per resistere a eventuali `|` nella descrizione.
|
|
44
44
|
const desc = cells.slice(5, -1).join('|').trim();
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
45
|
+
// Sanificazione AL CONFINE: tutto ciò che entra da tasks.md è testo
|
|
46
|
+
// arbitrario e può contenere glifi che Ink e il terminale misurano in modo
|
|
47
|
+
// diverso (vedi `width.ts`). Farlo qui invece che a ogni sito di render è
|
|
48
|
+
// ciò che impedisce di dimenticarne uno.
|
|
49
|
+
//
|
|
50
|
+
// `pri` e `prog` sono l'eccezione: NON si sanificano qui perché sono chiavi
|
|
51
|
+
// SEMANTICHE, non testo — `isDone()` e le lookup di `view.ts` confrontano
|
|
52
|
+
// il glifo, e `task-edit` lo riscrive in tasks.md. Sanificarli qui
|
|
53
|
+
// renderebbe `✔` un `✅` anche su disco, cambiando il formato di famiglia.
|
|
54
|
+
// La sanificazione di quei due avviene al display (`displayProg`).
|
|
55
|
+
tasks.push({ id, pri, prog, desc: sanitize(desc) });
|
|
55
56
|
}
|
|
56
57
|
return tasks;
|
|
57
58
|
}
|
|
@@ -112,12 +113,12 @@ export function parseTaskDetail(id, content) {
|
|
|
112
113
|
// allarga la riga oltre il bordo del pane.
|
|
113
114
|
const normFields = {};
|
|
114
115
|
for (const [k, v] of Object.entries(fields))
|
|
115
|
-
normFields[k] =
|
|
116
|
+
normFields[k] = sanitize(v);
|
|
116
117
|
return {
|
|
117
118
|
id,
|
|
118
|
-
title:
|
|
119
|
+
title: sanitize(title),
|
|
119
120
|
fields: normFields,
|
|
120
|
-
description:
|
|
121
|
+
description: sanitize(descLines.join('\n').trim()),
|
|
121
122
|
};
|
|
122
123
|
}
|
|
123
124
|
export function loadTaskDetail(dir, id) {
|
package/dist/viewport.js
CHANGED
|
@@ -13,62 +13,6 @@
|
|
|
13
13
|
// dettaglio) passa da qui e riceve una capienza in righe.
|
|
14
14
|
//
|
|
15
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
|
-
/**
|
|
54
|
-
* Collassa gli spazi (description multi-paragrafo → blocco unico) e tronca a
|
|
55
|
-
* `n` caratteri, con ellissi quando taglia.
|
|
56
|
-
*
|
|
57
|
-
* Vive qui e non nel modulo di render perché è misura di larghezza come il
|
|
58
|
-
* resto del file, e perché la cascata dell'etichetta di riga (`session-list.ts`)
|
|
59
|
-
* ne ha bisogno restando pura — importarla da `cli.tsx` tirerebbe dentro Ink e
|
|
60
|
-
* React in un modulo che si testa senza terminale.
|
|
61
|
-
*/
|
|
62
|
-
export function truncate(s, n) {
|
|
63
|
-
// Budget non positivo → stringa vuota, e va detto ESPLICITAMENTE: senza questa
|
|
64
|
-
// guardia `n = 0` produce `slice(0, -1)`, e un indice negativo in JS conta
|
|
65
|
-
// dalla FINE — restituirebbe quasi tutta la stringa invece di niente, cioè
|
|
66
|
-
// l'opposto del troncamento, in silenzio e proprio quando lo spazio manca.
|
|
67
|
-
if (n <= 0)
|
|
68
|
-
return '';
|
|
69
|
-
const flat = s.replace(/\s+/g, ' ').trim();
|
|
70
|
-
return flat.length > n ? flat.slice(0, n - 1).trimEnd() + '…' : flat;
|
|
71
|
-
}
|
|
72
16
|
/** Righe lasciate libere sotto il frame. La condizione di Ink è `>=`, quindi
|
|
73
17
|
* basterebbe 1; ne teniamo 1 come margine per l'a-capo del cursore. */
|
|
74
18
|
export const SLACK = 1;
|
|
@@ -252,53 +196,6 @@ export function searchPreviewCapacity(capacity, listRows) {
|
|
|
252
196
|
export function isCompact(capacity) {
|
|
253
197
|
return capacity < 1;
|
|
254
198
|
}
|
|
255
|
-
/**
|
|
256
|
-
* A-capo che PRESERVA la struttura del testo e traccia gli offset.
|
|
257
|
-
*
|
|
258
|
-
* Distinto da `wrapLines`, che collassa tutto in un flusso unico: lì serviva una
|
|
259
|
-
* preview compatta di 2-4 righe, qui si legge un messaggio intero — appiattire
|
|
260
|
-
* le newline renderebbe illeggibile qualunque blocco di codice o elenco.
|
|
261
|
-
*
|
|
262
|
-
* Ogni riga prodotta è una FETTA CONTIGUA del sorgente: è ciò che permette di
|
|
263
|
-
* intersecarla con l'intervallo del match e colorare solo la parte giusta,
|
|
264
|
-
* anche quando il match cade a cavallo di due righe.
|
|
265
|
-
*
|
|
266
|
-
* Taglia sull'ultimo spazio disponibile; una parola più larga della riga viene
|
|
267
|
-
* spezzata a forza, altrimenti l'a-capo lo farebbe il terminale — fuori dal
|
|
268
|
-
* conteggio delle righe, cioè di nuovo un frame più alto di `rows`.
|
|
269
|
-
*/
|
|
270
|
-
export function wrapWithOffsets(text, width) {
|
|
271
|
-
const out = [];
|
|
272
|
-
if (width <= 0)
|
|
273
|
-
return out;
|
|
274
|
-
let base = 0;
|
|
275
|
-
for (const raw of text.split('\n')) {
|
|
276
|
-
// Tab e CR diventano UN singolo spazio, non quattro: la sostituzione deve
|
|
277
|
-
// conservare la lunghezza, o gli offset delle righe smettono di indicizzare
|
|
278
|
-
// il sorgente e il match verrebbe evidenziato spostato. Un tab lasciato
|
|
279
|
-
// passare sarebbe l'errore opposto — lo conteremmo 1 e il terminale 8.
|
|
280
|
-
const line = raw.replace(/[\t\r]/g, ' ');
|
|
281
|
-
if (line.length === 0) {
|
|
282
|
-
out.push({ text: '', start: base, end: base });
|
|
283
|
-
}
|
|
284
|
-
let pos = 0;
|
|
285
|
-
while (pos < line.length) {
|
|
286
|
-
if (line.length - pos <= width) {
|
|
287
|
-
out.push({ text: line.slice(pos), start: base + pos, end: base + line.length });
|
|
288
|
-
break;
|
|
289
|
-
}
|
|
290
|
-
let brk = line.lastIndexOf(' ', pos + width);
|
|
291
|
-
if (brk <= pos)
|
|
292
|
-
brk = pos + width; // parola più lunga della riga
|
|
293
|
-
out.push({ text: line.slice(pos, brk), start: base + pos, end: base + brk });
|
|
294
|
-
pos = brk;
|
|
295
|
-
while (line[pos] === ' ')
|
|
296
|
-
pos++; // lo spazio del taglio non apre la riga dopo
|
|
297
|
-
}
|
|
298
|
-
base += raw.length + 1; // +1 = il '\n' consumato dallo split
|
|
299
|
-
}
|
|
300
|
-
return out;
|
|
301
|
-
}
|
|
302
199
|
/**
|
|
303
200
|
* Finestra scorrevole su una lista più lunga della capienza.
|
|
304
201
|
*
|
|
@@ -317,58 +214,3 @@ export function windowRange(total, selected, capacity) {
|
|
|
317
214
|
const start = Math.max(0, Math.min(sel - Math.floor(capacity / 2), total - capacity));
|
|
318
215
|
return { start, end: start + capacity };
|
|
319
216
|
}
|
|
320
|
-
/**
|
|
321
|
-
* Hard-wrap a larghezza fissa, con tetto di righe.
|
|
322
|
-
*
|
|
323
|
-
* Serve un conteggio righe DETERMINISTICO: `<Text wrap="wrap">` di Ink wrappa a
|
|
324
|
-
* runtime su una larghezza che il budget non conosce, quindi il pannello
|
|
325
|
-
* dettaglio potrebbe sforare il tetto e riaprire il bug. Qui il testo viene
|
|
326
|
-
* spezzato prima, e ogni riga è renderizzata con `wrap="truncate-end"`.
|
|
327
|
-
*
|
|
328
|
-
* Sottostimare `width` è sicuro (tronca prima), sovrastimarlo no (la riga
|
|
329
|
-
* andrebbe a capo aggiungendo altezza non contabilizzata).
|
|
330
|
-
*/
|
|
331
|
-
export function wrapLines(text, width, maxLines) {
|
|
332
|
-
if (maxLines <= 0 || width <= 0)
|
|
333
|
-
return [];
|
|
334
|
-
const flat = text.replace(/\s+/g, ' ').trim();
|
|
335
|
-
if (!flat)
|
|
336
|
-
return [];
|
|
337
|
-
const lines = [];
|
|
338
|
-
let line = '';
|
|
339
|
-
for (const word of flat.split(' ')) {
|
|
340
|
-
if (!line) {
|
|
341
|
-
line = word;
|
|
342
|
-
}
|
|
343
|
-
else if (line.length + 1 + word.length <= width) {
|
|
344
|
-
line += ' ' + word;
|
|
345
|
-
}
|
|
346
|
-
else {
|
|
347
|
-
lines.push(line);
|
|
348
|
-
line = word;
|
|
349
|
-
if (lines.length === maxLines)
|
|
350
|
-
break;
|
|
351
|
-
}
|
|
352
|
-
// Parola più lunga della riga: spezzala a forza, altrimenti l'a-capo lo
|
|
353
|
-
// farebbe il terminale — fuori dal nostro conteggio.
|
|
354
|
-
while (line.length > width) {
|
|
355
|
-
lines.push(line.slice(0, width));
|
|
356
|
-
line = line.slice(width);
|
|
357
|
-
if (lines.length === maxLines)
|
|
358
|
-
break;
|
|
359
|
-
}
|
|
360
|
-
if (lines.length === maxLines)
|
|
361
|
-
break;
|
|
362
|
-
}
|
|
363
|
-
if (line && lines.length < maxLines)
|
|
364
|
-
lines.push(line);
|
|
365
|
-
const kept = lines.slice(0, maxLines);
|
|
366
|
-
// Troncamento MAI silenzioso, come le liste: l'ellissi segnala che il testo
|
|
367
|
-
// continua oltre il pannello.
|
|
368
|
-
const consumed = kept.join(' ').length;
|
|
369
|
-
if (consumed < flat.length && kept.length > 0) {
|
|
370
|
-
const last = kept[kept.length - 1];
|
|
371
|
-
kept[kept.length - 1] = last.length >= width ? last.slice(0, Math.max(0, width - 1)) + '…' : last + '…';
|
|
372
|
-
}
|
|
373
|
-
return kept;
|
|
374
|
-
}
|
package/dist/width.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Contabilità UNICA della larghezza — fonte di verità per «quanto è larga
|
|
2
|
+
// questa stringa sullo schermo».
|
|
3
|
+
//
|
|
4
|
+
// PERCHÉ ESISTE. Nel frame convivevano tre unità di misura diverse, e nessuna
|
|
5
|
+
// era la colonna del terminale:
|
|
6
|
+
//
|
|
7
|
+
// 1. `string-width` — quella con cui Ink misura per il layout (Yoga) e per il
|
|
8
|
+
// troncamento. Conta 2 ogni emoji, VS16 o no.
|
|
9
|
+
// 2. `.length` / `.slice` — code unit UTF-16, usate dai vecchi `truncate` e
|
|
10
|
+
// `wrapLines`. Un emoji astrale (`🔥`) sono 2 unità per 2 colonne (caso
|
|
11
|
+
// fortunato), uno BMP (`⚡`) 1 unità per 2 colonne (sbagliato).
|
|
12
|
+
// 3. `slice-ansi`, dentro `cli-truncate`, dentro `wrap="truncate-end"` di Ink
|
|
13
|
+
// (`ink/build/wrap-text.js`): indicizza per CODE POINT ma riceve un budget
|
|
14
|
+
// di COLONNE. Ogni glifo largo 2 che sta a sinistra del taglio fa uscire
|
|
15
|
+
// la riga 1 colonna più larga del richiesto.
|
|
16
|
+
// 4. VTE/Ptyxis, che disegna: largo 2 solo se East-Asian-Width ∈ {W, F}, con
|
|
17
|
+
// gli Ambiguous a 1 e il VS16 IGNORATO.
|
|
18
|
+
//
|
|
19
|
+
// Una riga più larga del riservato sovrascrive il bordo del pane nella griglia
|
|
20
|
+
// di caratteri di Ink (bordo mangiato = layout sminchiato) e, quando il taglio
|
|
21
|
+
// cade su un glifo largo, fa crescere la riga composta oltre `columns`: il
|
|
22
|
+
// terminale la manda a capo, compare una riga vuota e tutto ciò che sta sotto
|
|
23
|
+
// slitta di una riga. Una riga più STRETTA del riservato sposta a sinistra
|
|
24
|
+
// bordo e pane vicino, sempre e solo sulle righe che contengono il glifo.
|
|
25
|
+
//
|
|
26
|
+
// LE TRE INVARIANTI. Tutto il resto del deck si appoggia a queste:
|
|
27
|
+
//
|
|
28
|
+
// ① Alfabeto concorde — nel frame entrano solo caratteri per cui
|
|
29
|
+
// `stringWidth(ch) === termWidth(ch)`. Il predicato è CALCOLATO, non una
|
|
30
|
+
// lista: qualunque glifo nuovo (in una descrizione, in un transcript) è
|
|
31
|
+
// giudicato dalla stessa regola. I discordi sono i ~120 emoji a
|
|
32
|
+
// presentazione-testo del BMP (`▶ ✔ ⚠ ❤ ✂ ➡ …`): string-width li dà 2, il
|
|
33
|
+
// terminale ne disegna 1. Non sono riparabili con un selettore di
|
|
34
|
+
// variazione (né VS16 né VS15 cambiano il verdetto di string-width) →
|
|
35
|
+
// vengono SOSTITUITI con un gemello concorde.
|
|
36
|
+
//
|
|
37
|
+
// ② VS16 sui glifi larghi 2 del BMP, e SOLO su quelli. Ink compone il frame
|
|
38
|
+
// su una griglia di celle assegnando 2 celle a un carattere se
|
|
39
|
+
// `isFullwidthCodePoint(cp) || value.length > 1`
|
|
40
|
+
// (`@alcalzone/ansi-tokenize`, un token per CODE POINT). `isFullwidthCodePoint`
|
|
41
|
+
// copre il CJK ma NON gli emoji, quindi:
|
|
42
|
+
// · astrale (`🔥`, 2 code unit) → `value.length > 1` → 2 celle, giusto;
|
|
43
|
+
// · BMP largo 2 (`⚡`, 1 code unit) → 1 sola cella, una in meno del dovuto;
|
|
44
|
+
// · BMP + VS16 → 1 + 1 = 2 celle, di nuovo giusto.
|
|
45
|
+
// Timbrare un ASTRALE è quindi l'errore opposto: gli darebbe 3 celle per 2
|
|
46
|
+
// colonne e la riga uscirebbe una colonna più CORTA del padding calcolato.
|
|
47
|
+
//
|
|
48
|
+
// ③ Il taglio lo fa il deck, non Ink. Restava scoperta la terza contabilità
|
|
49
|
+
// (`slice-ansi`, per code point): con un budget di colonne in ingresso,
|
|
50
|
+
// ogni glifo largo 2 a sinistra del taglio allarga il risultato di una
|
|
51
|
+
// colonna, e nessun timbro può ripararlo senza rompere ② o ①. L'unica via
|
|
52
|
+
// è non farlo mai intervenire: chi renderizza una riga a lunghezza libera
|
|
53
|
+
// la taglia PRIMA con `cut()`, su un budget derivato da `columns`.
|
|
54
|
+
// `wrap="truncate-end"` resta come rete, non come meccanismo.
|
|
55
|
+
//
|
|
56
|
+
// L'assunzione sul terminale (EAW, ambiguous = 1, VS16 ignorato) vive TUTTA in
|
|
57
|
+
// `termWidth`. Se un giorno cambia il terminale, cambia questa funzione e basta.
|
|
58
|
+
import stringWidth from 'string-width';
|
|
59
|
+
import { eastAsianWidth } from 'get-east-asian-width';
|
|
60
|
+
/** Variation Selector-16: forza la presentazione emoji del carattere che precede. */
|
|
61
|
+
const VS16 = '️';
|
|
62
|
+
/** Zero-width per il terminale: combining marks e format chars (VS16 incluso). */
|
|
63
|
+
const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}]$/u;
|
|
64
|
+
/**
|
|
65
|
+
* Larghezza in colonne secondo il TERMINALE (VTE/Ptyxis).
|
|
66
|
+
*
|
|
67
|
+
* `eastAsianWidth` restituisce già 1 per gli Ambiguous — che è il default di
|
|
68
|
+
* VTE (`utf8-ambiguous-width`) e il motivo per cui `▶` (EAW = Ambiguous) e `✔`
|
|
69
|
+
* (EAW = Neutral) vengono disegnati stretti anche col VS16 appiccicato.
|
|
70
|
+
*/
|
|
71
|
+
export function termWidth(s) {
|
|
72
|
+
let w = 0;
|
|
73
|
+
for (const ch of s) {
|
|
74
|
+
if (ZERO_WIDTH.test(ch))
|
|
75
|
+
continue;
|
|
76
|
+
w += eastAsianWidth(ch.codePointAt(0));
|
|
77
|
+
}
|
|
78
|
+
return w;
|
|
79
|
+
}
|
|
80
|
+
/** Il carattere è misurato allo stesso modo da Ink e dal terminale? */
|
|
81
|
+
export function agrees(ch) {
|
|
82
|
+
return stringWidth(ch) === termWidth(ch);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Sostituzioni esplicite per i discordi che il deck usa come SEGNALE: qui la
|
|
86
|
+
* semantica del glifo va preservata, non degradata a segnaposto.
|
|
87
|
+
*
|
|
88
|
+
* I sostituti sono concordi per costruzione (verificati dal test): `▸` è
|
|
89
|
+
* Neutral e non-emoji → 1 ovunque; `✅` `❗` sono astrali a presentazione-emoji
|
|
90
|
+
* → 2 ovunque.
|
|
91
|
+
*/
|
|
92
|
+
const GLYPH_MAP = new Map([
|
|
93
|
+
['▶', '▸'], // caret di selezione
|
|
94
|
+
['◀', '◂'],
|
|
95
|
+
['✔', '✅'], // marker Done
|
|
96
|
+
['☑', '✅'],
|
|
97
|
+
['✖', '❌'],
|
|
98
|
+
['⚠', '❗'], // warning (riga pin stale)
|
|
99
|
+
['❗', '❗'],
|
|
100
|
+
]);
|
|
101
|
+
/**
|
|
102
|
+
* Segnaposto per i discordi che arrivano da testo NON nostro (descrizioni,
|
|
103
|
+
* titoli, corpi dei transcript): non c'è una traduzione sensata glifo-per-glifo
|
|
104
|
+
* di `❤ ✂ ☀ ➡ …`, e lasciarli passare romperebbe la riga che li ospita.
|
|
105
|
+
* Largo 1 e concorde.
|
|
106
|
+
*/
|
|
107
|
+
const FILLER = '·';
|
|
108
|
+
/**
|
|
109
|
+
* Fast-path: sotto U+1100 non esiste né un wide né un discorde, quindi non
|
|
110
|
+
* c’è niente da sanificare. `\n` e `\t` stanno fuori dal sospetto pur essendo
|
|
111
|
+
* control char — li normalizzano i chiamanti (`wrapLines` collassa il
|
|
112
|
+
* whitespace, `wrapWithOffsets` li mappa preservando gli offset) e tenerli
|
|
113
|
+
* dentro costerebbe il fast-path su OGNI corpo di transcript, visto che
|
|
114
|
+
* `sanitize` gira anche sui ~10 MB di testo cercabile del progetto.
|
|
115
|
+
*/
|
|
116
|
+
const SUSPECT = /[^\n\t\u0020-\u10FF]/;
|
|
117
|
+
/**
|
|
118
|
+
* Byte di controllo che il terminale INTERPRETA — ESC in testa. Passati nudi
|
|
119
|
+
* da un transcript muoverebbero il cursore o cambierebbero i colori dal mezzo
|
|
120
|
+
* di una preview. Diventano uno spazio: largo 1, concorde, inerte.
|
|
121
|
+
*/
|
|
122
|
+
const CONTROL = /[\u0000-\u001F\u007F-\u009F]/;
|
|
123
|
+
/**
|
|
124
|
+
* Sostituzione di UN carattere secondo le invarianti ① e ②. Memoizzata: i
|
|
125
|
+
* corpi cercabili sono ~10 MB e i caratteri distinti che li attraversano sono
|
|
126
|
+
* poche decine — misurare la stessa emoji un milione di volte è il costo che
|
|
127
|
+
* questa cache toglie.
|
|
128
|
+
*/
|
|
129
|
+
const FIXED = new Map();
|
|
130
|
+
function fixChar(ch) {
|
|
131
|
+
const cached = FIXED.get(ch);
|
|
132
|
+
if (cached !== undefined)
|
|
133
|
+
return cached;
|
|
134
|
+
let out;
|
|
135
|
+
if (CONTROL.test(ch)) {
|
|
136
|
+
out = ' ';
|
|
137
|
+
}
|
|
138
|
+
else if (ZERO_WIDTH.test(ch)) {
|
|
139
|
+
// Il VS16 in ingresso si scarta SEMPRE: se serve lo ri-mette il ramo ② sul
|
|
140
|
+
// carattere che lo precede. Passare da «scarta e ri-timbra» invece che da
|
|
141
|
+
// «tieni se c'è» è ciò che rende `sanitize` idempotente. Le altre combining
|
|
142
|
+
// mark e i format char restano dove sono (0 colonne per entrambe le conte).
|
|
143
|
+
out = ch === VS16 ? '' : ch;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const fixedGlyph = agrees(ch) ? ch : (GLYPH_MAP.get(ch) ?? FILLER); // ①
|
|
147
|
+
// ② una cella per colonna nella griglia di Ink — SOLO sui BMP.
|
|
148
|
+
const bmpWide = fixedGlyph.codePointAt(0) < 0x10000 && termWidth(fixedGlyph) === 2;
|
|
149
|
+
out = bmpWide ? fixedGlyph + VS16 : fixedGlyph;
|
|
150
|
+
}
|
|
151
|
+
FIXED.set(ch, out);
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Caratteri che possono violare un'invariante: control char, tutto ciò che sta
|
|
156
|
+
* da U+1100 in su (primo wide dell'insieme) e gli astrali. `\n` e `\t` restano
|
|
157
|
+
* fuori — li normalizzano i chiamanti, e includerli qui costerebbe una
|
|
158
|
+
* sostituzione su ogni riga di ogni transcript.
|
|
159
|
+
*/
|
|
160
|
+
const RISKY = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]|[\u1100-\uFFFF]|[\u{10000}-\u{10FFFF}]/gu;
|
|
161
|
+
/**
|
|
162
|
+
* Rende una stringa sicura da mettere nel frame: applica le invarianti ① e ②.
|
|
163
|
+
*
|
|
164
|
+
* Idempotente, e a costo quasi zero sul testo ASCII (fast-path) — cioè sulla
|
|
165
|
+
* stragrande maggioranza dei corpi di transcript, che è il volume vero: la
|
|
166
|
+
* versione carattere-per-carattere costava secondi all'avvio del deck.
|
|
167
|
+
*/
|
|
168
|
+
export function sanitize(s) {
|
|
169
|
+
if (!s || !SUSPECT.test(s))
|
|
170
|
+
return s;
|
|
171
|
+
return s.replace(RISKY, fixChar);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Taglio a un budget di COLONNE, ellissi inclusa nel budget.
|
|
175
|
+
*
|
|
176
|
+
* Rimpiazza il vecchio `truncate`, che tagliava per code unit: su una riga con
|
|
177
|
+
* due emoji il risultato usciva 2 colonne oltre il budget, cioè esattamente
|
|
178
|
+
* dentro il bordo del pane.
|
|
179
|
+
*
|
|
180
|
+
* Collassa il whitespace come faceva il predecessore: i chiamanti passano
|
|
181
|
+
* descrizioni multi-riga e si aspettano un blocco unico.
|
|
182
|
+
*/
|
|
183
|
+
export function cut(s, cols) {
|
|
184
|
+
const flat = s.replace(/\s+/g, ' ').trim();
|
|
185
|
+
if (cols <= 0)
|
|
186
|
+
return '';
|
|
187
|
+
if (termWidth(flat) <= cols)
|
|
188
|
+
return flat;
|
|
189
|
+
if (cols === 1)
|
|
190
|
+
return '…';
|
|
191
|
+
let out = '';
|
|
192
|
+
let w = 0;
|
|
193
|
+
for (const ch of flat) {
|
|
194
|
+
const cw = termWidth(ch);
|
|
195
|
+
if (w + cw > cols - 1)
|
|
196
|
+
break; // -1 = la colonna dell'ellissi
|
|
197
|
+
out += ch;
|
|
198
|
+
w += cw;
|
|
199
|
+
}
|
|
200
|
+
// Un estratto può arrivare qui con la SUA ellissi già in coda (la ricerca
|
|
201
|
+
// ritaglia il contesto attorno al match): due ellissi di fila non aggiungono
|
|
202
|
+
// informazione, tolgono una colonna di testo.
|
|
203
|
+
return out.trimEnd().replace(/…$/, '') + '…';
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Indice sorgente raggiunto consumando al più `cols` colonne da `from`.
|
|
207
|
+
*
|
|
208
|
+
* Il conteggio è per colonne, l'indice restituito è in code unit: è ciò che
|
|
209
|
+
* tiene gli offset del match allineati al sorgente mentre l'a-capo ragiona in
|
|
210
|
+
* colonne.
|
|
211
|
+
*/
|
|
212
|
+
function advance(line, from, cols) {
|
|
213
|
+
let i = from;
|
|
214
|
+
let w = 0;
|
|
215
|
+
while (i < line.length) {
|
|
216
|
+
const cp = line.codePointAt(i);
|
|
217
|
+
const ch = String.fromCodePoint(cp);
|
|
218
|
+
const cw = termWidth(ch);
|
|
219
|
+
if (w + cw > cols)
|
|
220
|
+
break;
|
|
221
|
+
w += cw;
|
|
222
|
+
i += ch.length;
|
|
223
|
+
}
|
|
224
|
+
return i;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* A-capo che PRESERVA la struttura del testo e traccia gli offset.
|
|
228
|
+
*
|
|
229
|
+
* Distinto da `wrapLines`, che collassa tutto in un flusso unico: lì serve una
|
|
230
|
+
* preview compatta di 2-4 righe, qui si legge un messaggio intero — appiattire
|
|
231
|
+
* le newline renderebbe illeggibile qualunque blocco di codice o elenco.
|
|
232
|
+
*
|
|
233
|
+
* Ogni riga prodotta è una FETTA CONTIGUA del sorgente: è ciò che permette di
|
|
234
|
+
* intersecarla con l'intervallo del match e colorare solo la parte giusta,
|
|
235
|
+
* anche quando il match cade a cavallo di due righe.
|
|
236
|
+
*
|
|
237
|
+
* Taglia sull'ultimo spazio disponibile; una parola più larga della riga viene
|
|
238
|
+
* spezzata a forza, altrimenti l'a-capo lo farebbe il terminale — fuori dal
|
|
239
|
+
* conteggio delle righe, cioè di nuovo un frame più alto di `rows`.
|
|
240
|
+
*/
|
|
241
|
+
export function wrapWithOffsets(text, width) {
|
|
242
|
+
const out = [];
|
|
243
|
+
if (width <= 0)
|
|
244
|
+
return out;
|
|
245
|
+
let base = 0;
|
|
246
|
+
for (const raw of text.split('\n')) {
|
|
247
|
+
// Tab e CR diventano UN singolo spazio, non quattro: la sostituzione deve
|
|
248
|
+
// conservare la lunghezza, o gli offset delle righe smettono di indicizzare
|
|
249
|
+
// il sorgente e il match verrebbe evidenziato spostato. Un tab lasciato
|
|
250
|
+
// passare sarebbe l'errore opposto — lo conteremmo 1 e il terminale 8.
|
|
251
|
+
const line = raw.replace(/[\t\r]/g, ' ');
|
|
252
|
+
if (line.length === 0) {
|
|
253
|
+
out.push({ text: '', start: base, end: base });
|
|
254
|
+
}
|
|
255
|
+
let pos = 0;
|
|
256
|
+
while (pos < line.length) {
|
|
257
|
+
const stop = advance(line, pos, width);
|
|
258
|
+
if (stop >= line.length) {
|
|
259
|
+
out.push({ text: line.slice(pos), start: base + pos, end: base + line.length });
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
let brk = line.lastIndexOf(' ', stop);
|
|
263
|
+
if (brk <= pos)
|
|
264
|
+
brk = stop; // parola più lunga della riga
|
|
265
|
+
out.push({ text: line.slice(pos, brk), start: base + pos, end: base + brk });
|
|
266
|
+
pos = brk;
|
|
267
|
+
while (line[pos] === ' ')
|
|
268
|
+
pos++; // lo spazio del taglio non apre la riga dopo
|
|
269
|
+
}
|
|
270
|
+
base += raw.length + 1; // +1 = il '\n' consumato dallo split
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Hard-wrap a larghezza fissa, con tetto di righe.
|
|
276
|
+
*
|
|
277
|
+
* Serve un conteggio righe DETERMINISTICO: `<Text wrap="wrap">` di Ink wrappa a
|
|
278
|
+
* runtime su una larghezza che il budget non conosce, quindi il pannello
|
|
279
|
+
* dettaglio potrebbe sforare il tetto e riaprire il bug dell'altezza. Qui il
|
|
280
|
+
* testo viene spezzato prima, e ogni riga è renderizzata con `truncate-end`.
|
|
281
|
+
*
|
|
282
|
+
* Sottostimare `width` è sicuro (tronca prima), sovrastimarlo no (la riga
|
|
283
|
+
* andrebbe a capo aggiungendo altezza non contabilizzata).
|
|
284
|
+
*/
|
|
285
|
+
export function wrapLines(text, width, maxLines) {
|
|
286
|
+
if (maxLines <= 0 || width <= 0)
|
|
287
|
+
return [];
|
|
288
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
289
|
+
if (!flat)
|
|
290
|
+
return [];
|
|
291
|
+
const lines = [];
|
|
292
|
+
let line = '';
|
|
293
|
+
const push = () => {
|
|
294
|
+
lines.push(line);
|
|
295
|
+
line = '';
|
|
296
|
+
};
|
|
297
|
+
for (const word of flat.split(' ')) {
|
|
298
|
+
if (!line) {
|
|
299
|
+
line = word;
|
|
300
|
+
}
|
|
301
|
+
else if (termWidth(line) + 1 + termWidth(word) <= width) {
|
|
302
|
+
line += ' ' + word;
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
push();
|
|
306
|
+
line = word;
|
|
307
|
+
if (lines.length === maxLines)
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
// Parola più lunga della riga: spezzala a forza, altrimenti l'a-capo lo
|
|
311
|
+
// farebbe il terminale — fuori dal nostro conteggio.
|
|
312
|
+
while (termWidth(line) > width) {
|
|
313
|
+
const stop = advance(line, 0, width);
|
|
314
|
+
lines.push(line.slice(0, stop));
|
|
315
|
+
line = line.slice(stop);
|
|
316
|
+
if (lines.length === maxLines)
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
if (lines.length === maxLines)
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
if (line && lines.length < maxLines)
|
|
323
|
+
lines.push(line);
|
|
324
|
+
const kept = lines.slice(0, maxLines);
|
|
325
|
+
// Troncamento MAI silenzioso, come le liste: l'ellissi segnala che il testo
|
|
326
|
+
// continua oltre il pannello.
|
|
327
|
+
const consumed = kept.join(' ').length;
|
|
328
|
+
if (consumed < flat.length && kept.length > 0) {
|
|
329
|
+
const last = kept[kept.length - 1];
|
|
330
|
+
kept[kept.length - 1] = termWidth(last) >= width ? cut(last, width) : last + '…';
|
|
331
|
+
}
|
|
332
|
+
return kept;
|
|
333
|
+
}
|
package/package.json
CHANGED