@git-stunts/git-warp 10.1.1 → 10.3.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.
@@ -6,20 +6,20 @@ const LAYOUT_PRESETS = {
6
6
  query: {
7
7
  'elk.algorithm': 'layered',
8
8
  'elk.direction': 'DOWN',
9
- 'elk.spacing.nodeNode': '40',
10
- 'elk.layered.spacing.nodeNodeBetweenLayers': '60',
9
+ 'elk.spacing.nodeNode': '30',
10
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '40',
11
11
  },
12
12
  path: {
13
13
  'elk.algorithm': 'layered',
14
14
  'elk.direction': 'RIGHT',
15
- 'elk.spacing.nodeNode': '40',
16
- 'elk.layered.spacing.nodeNodeBetweenLayers': '60',
15
+ 'elk.spacing.nodeNode': '30',
16
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '40',
17
17
  },
18
18
  slice: {
19
19
  'elk.algorithm': 'layered',
20
20
  'elk.direction': 'DOWN',
21
- 'elk.spacing.nodeNode': '40',
22
- 'elk.layered.spacing.nodeNodeBetweenLayers': '60',
21
+ 'elk.spacing.nodeNode': '30',
22
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '40',
23
23
  },
24
24
  };
25
25
 
@@ -46,7 +46,7 @@ function estimateNodeWidth(label) {
46
46
  return Math.max((label?.length ?? 0) * charWidth + padding, minWidth);
47
47
  }
48
48
 
49
- const NODE_HEIGHT = 40;
49
+ const NODE_HEIGHT = 30;
50
50
 
51
51
  /**
52
52
  * Converts normalised graph data to an ELK graph JSON object.
@@ -2,7 +2,9 @@
2
2
  * ASCII graph renderer: maps ELK-positioned nodes and edges onto a character grid.
3
3
  *
4
4
  * Pixel-to-character scaling:
5
- * cellW = 8 px/char, cellH = 4 px/char (approximate monospace aspect ratio)
5
+ * cellW = 10, cellH = 10
6
+ * ELK uses NODE_HEIGHT=40, nodeNode=40, betweenLayers=60.
7
+ * At cellH=10: 40px → 4 rows, compact 3-row nodes fit with natural gaps.
6
8
  */
7
9
 
8
10
  import { createBox } from './box.js';
@@ -11,8 +13,8 @@ import { ARROW } from './symbols.js';
11
13
 
12
14
  // ── Scaling constants ────────────────────────────────────────────────────────
13
15
 
14
- const CELL_W = 8;
15
- const CELL_H = 4;
16
+ const CELL_W = 10;
17
+ const CELL_H = 10;
16
18
  const MARGIN = 2;
17
19
 
18
20
  // ── Box-drawing characters (short keys for tight grid-stamping loops) ───────
@@ -76,8 +78,8 @@ function writeString(grid, r, c, str) {
76
78
  function stampNode(grid, node) {
77
79
  const r = toRow(node.y);
78
80
  const c = toCol(node.x);
79
- const w = Math.max(scaleW(node.width), 4);
80
- const h = Math.max(scaleH(node.height), 3);
81
+ const w = Math.max(toCol(node.width), 4);
82
+ const h = 3; // Always: border + label + border
81
83
 
82
84
  // Top border
83
85
  writeChar(grid, r, c, BOX.tl);
@@ -87,10 +89,8 @@ function stampNode(grid, node) {
87
89
  writeChar(grid, r, c + w - 1, BOX.tr);
88
90
 
89
91
  // Side borders
90
- for (let j = 1; j < h - 1; j++) {
91
- writeChar(grid, r + j, c, BOX.v);
92
- writeChar(grid, r + j, c + w - 1, BOX.v);
93
- }
92
+ writeChar(grid, r + 1, c, BOX.v);
93
+ writeChar(grid, r + 1, c + w - 1, BOX.v);
94
94
 
95
95
  // Bottom border
96
96
  writeChar(grid, r + h - 1, c, BOX.bl);
@@ -99,13 +99,13 @@ function stampNode(grid, node) {
99
99
  }
100
100
  writeChar(grid, r + h - 1, c + w - 1, BOX.br);
101
101
 
102
- // Label (centered)
102
+ // Label (always on row 1)
103
103
  const label = node.label ?? node.id;
104
104
  const maxLabel = w - 4;
105
105
  const truncated = label.length > maxLabel
106
106
  ? `${label.slice(0, Math.max(maxLabel - 1, 1))}\u2026`
107
107
  : label;
108
- const labelRow = r + Math.floor(h / 2);
108
+ const labelRow = r + 1;
109
109
  const labelCol = c + Math.max(1, Math.floor((w - truncated.length) / 2));
110
110
  writeString(grid, labelRow, labelCol, truncated);
111
111
  }
@@ -220,6 +220,8 @@ function drawArrowhead(grid, section, nodeSet) {
220
220
  const pc = toCol(prev.x);
221
221
 
222
222
  let arrow;
223
+ let ar = er;
224
+ let ac = ec;
223
225
  if (er > pr) {
224
226
  arrow = ARROW.down;
225
227
  } else if (er < pr) {
@@ -230,8 +232,21 @@ function drawArrowhead(grid, section, nodeSet) {
230
232
  arrow = ARROW.left;
231
233
  }
232
234
 
233
- if (!isNodeCell(nodeSet, er, ec)) {
234
- writeChar(grid, er, ec, arrow);
235
+ // If the endpoint is inside a node box, step back one cell into free space
236
+ if (isNodeCell(nodeSet, ar, ac)) {
237
+ if (er > pr) {
238
+ ar = er - 1;
239
+ } else if (er < pr) {
240
+ ar = er + 1;
241
+ } else if (ec > pc) {
242
+ ac = ec - 1;
243
+ } else {
244
+ ac = ec + 1;
245
+ }
246
+ }
247
+
248
+ if (!isNodeCell(nodeSet, ar, ac)) {
249
+ writeChar(grid, ar, ac, arrow);
235
250
  }
236
251
  }
237
252
 
@@ -282,8 +297,8 @@ function buildNodeSet(nodes) {
282
297
  for (const node of nodes) {
283
298
  const r = toRow(node.y);
284
299
  const c = toCol(node.x);
285
- const w = Math.max(scaleW(node.width), 4);
286
- const h = Math.max(scaleH(node.height), 3);
300
+ const w = Math.max(toCol(node.width), 4);
301
+ const h = 3; // Match compact node height
287
302
  for (let dr = 0; dr < h; dr++) {
288
303
  for (let dc = 0; dc < w; dc++) {
289
304
  set.add(`${r + dr},${c + dc}`);
@@ -6,75 +6,12 @@
6
6
  import { colors } from './colors.js';
7
7
  import { createBox } from './box.js';
8
8
  import { padRight, padLeft } from '../../utils/unicode.js';
9
- import { truncate } from '../../utils/truncate.js';
10
9
  import { TIMELINE } from './symbols.js';
10
+ import { OP_DISPLAY, EMPTY_OP_SUMMARY, summarizeOps, formatOpSummary } from './opSummary.js';
11
11
 
12
12
  // Default pagination settings
13
13
  const DEFAULT_PAGE_SIZE = 20;
14
14
 
15
- // Operation type to display info mapping
16
- const OP_DISPLAY = {
17
- NodeAdd: { symbol: '+', label: 'node', color: colors.success },
18
- NodeTombstone: { symbol: '-', label: 'node', color: colors.error },
19
- EdgeAdd: { symbol: '+', label: 'edge', color: colors.success },
20
- EdgeTombstone: { symbol: '-', label: 'edge', color: colors.error },
21
- PropSet: { symbol: '~', label: 'prop', color: colors.warning },
22
- BlobValue: { symbol: '+', label: 'blob', color: colors.primary },
23
- };
24
-
25
- // Default empty operation summary
26
- const EMPTY_OP_SUMMARY = Object.freeze({
27
- NodeAdd: 0,
28
- EdgeAdd: 0,
29
- PropSet: 0,
30
- NodeTombstone: 0,
31
- EdgeTombstone: 0,
32
- BlobValue: 0,
33
- });
34
-
35
- /**
36
- * Summarizes operations in a patch.
37
- * @param {Object[]} ops - Array of patch operations
38
- * @returns {Object} Summary with counts by operation type
39
- */
40
- function summarizeOps(ops) {
41
- const summary = { ...EMPTY_OP_SUMMARY };
42
- for (const op of ops) {
43
- if (op.type && summary[op.type] !== undefined) {
44
- summary[op.type]++;
45
- }
46
- }
47
- return summary;
48
- }
49
-
50
- /**
51
- * Formats operation summary as a colored string.
52
- * @param {Object} summary - Operation counts by type
53
- * @param {number} maxWidth - Maximum width for the summary string
54
- * @returns {string} Formatted summary string
55
- */
56
- function formatOpSummary(summary, maxWidth = 40) {
57
- const order = ['NodeAdd', 'EdgeAdd', 'PropSet', 'NodeTombstone', 'EdgeTombstone', 'BlobValue'];
58
- const parts = order
59
- .filter((opType) => summary[opType] > 0)
60
- .map((opType) => {
61
- const display = OP_DISPLAY[opType];
62
- return { text: `${display.symbol}${summary[opType]}${display.label}`, color: display.color };
63
- });
64
-
65
- if (parts.length === 0) {
66
- return colors.muted('(empty)');
67
- }
68
-
69
- // Truncate plain text first to avoid breaking ANSI escape sequences
70
- const plain = parts.map((p) => p.text).join(' ');
71
- const truncated = truncate(plain, maxWidth);
72
- if (truncated === plain) {
73
- return parts.map((p) => p.color(p.text)).join(' ');
74
- }
75
- return colors.muted(truncated);
76
- }
77
-
78
15
  /**
79
16
  * Ensures entry has an opSummary, computing one if needed.
80
17
  * @param {Object} entry - Patch entry
@@ -330,6 +267,6 @@ export function renderHistoryView(payload, options = {}) {
330
267
  return `${box}\n`;
331
268
  }
332
269
 
333
- export { summarizeOps };
270
+ export { summarizeOps, formatOpSummary, OP_DISPLAY, EMPTY_OP_SUMMARY };
334
271
 
335
272
  export default { renderHistoryView, summarizeOps };
@@ -9,6 +9,6 @@ export { progressBar } from './progress.js';
9
9
  export { renderInfoView } from './info.js';
10
10
  export { renderCheckView } from './check.js';
11
11
  export { renderMaterializeView } from './materialize.js';
12
- export { renderHistoryView, summarizeOps } from './history.js';
12
+ export { renderHistoryView, summarizeOps, formatOpSummary, OP_DISPLAY, EMPTY_OP_SUMMARY } from './history.js';
13
13
  export { renderPathView } from './path.js';
14
14
  export { renderGraphView } from './graph.js';
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Shared operation summary utilities for ASCII renderers.
3
+ *
4
+ * Extracted from history.js so other views (e.g. seek) can reuse the same
5
+ * op-type ordering, symbols, and formatting.
6
+ */
7
+
8
+ import { colors } from './colors.js';
9
+ import { truncate } from '../../utils/truncate.js';
10
+
11
+ // Operation type to display info mapping
12
+ export const OP_DISPLAY = Object.freeze({
13
+ NodeAdd: { symbol: '+', label: 'node', color: colors.success },
14
+ NodeTombstone: { symbol: '-', label: 'node', color: colors.error },
15
+ EdgeAdd: { symbol: '+', label: 'edge', color: colors.success },
16
+ EdgeTombstone: { symbol: '-', label: 'edge', color: colors.error },
17
+ PropSet: { symbol: '~', label: 'prop', color: colors.warning },
18
+ BlobValue: { symbol: '+', label: 'blob', color: colors.primary },
19
+ });
20
+
21
+ // Default empty operation summary
22
+ export const EMPTY_OP_SUMMARY = Object.freeze({
23
+ NodeAdd: 0,
24
+ EdgeAdd: 0,
25
+ PropSet: 0,
26
+ NodeTombstone: 0,
27
+ EdgeTombstone: 0,
28
+ BlobValue: 0,
29
+ });
30
+
31
+ /**
32
+ * Summarizes operations in a patch.
33
+ * @param {Object[]} ops - Array of patch operations
34
+ * @returns {Object} Summary with counts by operation type
35
+ */
36
+ export function summarizeOps(ops) {
37
+ const summary = { ...EMPTY_OP_SUMMARY };
38
+ for (const op of ops) {
39
+ if (op.type && summary[op.type] !== undefined) {
40
+ summary[op.type]++;
41
+ }
42
+ }
43
+ return summary;
44
+ }
45
+
46
+ /**
47
+ * Formats operation summary as a colored string.
48
+ * @param {Object} summary - Operation counts by type
49
+ * @param {number} maxWidth - Maximum width for the summary string
50
+ * @returns {string} Formatted summary string
51
+ */
52
+ export function formatOpSummary(summary, maxWidth = 40) {
53
+ const order = ['NodeAdd', 'EdgeAdd', 'PropSet', 'NodeTombstone', 'EdgeTombstone', 'BlobValue'];
54
+ const parts = order
55
+ .filter((opType) => summary[opType] > 0)
56
+ .map((opType) => {
57
+ const display = OP_DISPLAY[opType];
58
+ return { text: `${display.symbol}${summary[opType]}${display.label}`, color: display.color };
59
+ });
60
+
61
+ if (parts.length === 0) {
62
+ return colors.muted('(empty)');
63
+ }
64
+
65
+ // Truncate plain text first to avoid breaking ANSI escape sequences
66
+ const plain = parts.map((p) => p.text).join(' ');
67
+ const truncated = truncate(plain, maxWidth);
68
+ if (truncated === plain) {
69
+ return parts.map((p) => p.color(p.text)).join(' ');
70
+ }
71
+ return colors.muted(truncated);
72
+ }
73
+
@@ -0,0 +1,330 @@
1
+ /**
2
+ * ASCII renderer for the `seek --view` command.
3
+ *
4
+ * Displays a swimlane dashboard: one horizontal track per writer, with
5
+ * relative-offset column headers that map directly to `--tick=+N/-N` CLI
6
+ * syntax. Included patches (at or before the cursor) render as filled
7
+ * dots on a solid line; excluded (future) patches render as open circles
8
+ * on a dotted line.
9
+ */
10
+
11
+ import boxen from 'boxen';
12
+ import { colors } from './colors.js';
13
+ import { padRight } from '../../utils/unicode.js';
14
+ import { formatSha, formatWriterName } from './formatters.js';
15
+ import { TIMELINE } from './symbols.js';
16
+ import { formatOpSummary } from './opSummary.js';
17
+
18
+ /** Maximum number of tick columns shown in the windowed view. */
19
+ const MAX_COLS = 9;
20
+
21
+ /** Character width of each tick column (marker + connector gap). */
22
+ const COL_W = 6;
23
+
24
+ /** Character width reserved for the writer name column. */
25
+ const NAME_W = 10;
26
+
27
+ /** Middle-dot used for excluded-zone connectors. */
28
+ const DOT_MID = '\u00B7'; // ·
29
+
30
+ /** Open circle used for excluded-zone patch markers. */
31
+ const CIRCLE_OPEN = '\u25CB'; // ○
32
+
33
+ function formatDelta(n) {
34
+ if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) {
35
+ return '';
36
+ }
37
+ const sign = n > 0 ? '+' : '';
38
+ return ` (${sign}${n})`;
39
+ }
40
+
41
+ function pluralize(n, singular, plural) {
42
+ return n === 1 ? singular : plural;
43
+ }
44
+
45
+ function buildReceiptLines(tickReceipt) {
46
+ if (!tickReceipt || typeof tickReceipt !== 'object') {
47
+ return [];
48
+ }
49
+
50
+ const entries = Object.entries(tickReceipt)
51
+ .filter(([writerId, entry]) => writerId && entry && typeof entry === 'object')
52
+ .sort(([a], [b]) => a.localeCompare(b));
53
+
54
+ const lines = [];
55
+ for (const [writerId, entry] of entries) {
56
+ const sha = typeof entry.sha === 'string' ? entry.sha : null;
57
+ const opSummary = entry.opSummary && typeof entry.opSummary === 'object' ? entry.opSummary : entry;
58
+ const name = padRight(formatWriterName(writerId, NAME_W), NAME_W);
59
+ const shaStr = sha ? ` ${formatSha(sha)}` : '';
60
+ lines.push(` ${name}${shaStr} ${formatOpSummary(opSummary, 40)}`);
61
+ }
62
+
63
+ return lines;
64
+ }
65
+
66
+ // ============================================================================
67
+ // Window
68
+ // ============================================================================
69
+
70
+ /**
71
+ * Computes a sliding window of tick positions centered on the current tick.
72
+ *
73
+ * When all points fit within {@link MAX_COLS}, the full array is returned.
74
+ * Otherwise a window of MAX_COLS entries is centered on `currentIdx`, with
75
+ * clamping at both ends.
76
+ *
77
+ * @param {number[]} allPoints - All tick positions (including virtual tick 0)
78
+ * @param {number} currentIdx - Index of the current tick in `allPoints`
79
+ * @returns {{ points: number[], currentCol: number, moreLeft: boolean, moreRight: boolean }}
80
+ */
81
+ function computeWindow(allPoints, currentIdx) {
82
+ if (allPoints.length <= MAX_COLS) {
83
+ return {
84
+ points: allPoints,
85
+ currentCol: currentIdx,
86
+ moreLeft: false,
87
+ moreRight: false,
88
+ };
89
+ }
90
+
91
+ const half = Math.floor(MAX_COLS / 2);
92
+ let start = currentIdx - half;
93
+ if (start < 0) {
94
+ start = 0;
95
+ }
96
+ let end = start + MAX_COLS;
97
+ if (end > allPoints.length) {
98
+ end = allPoints.length;
99
+ start = end - MAX_COLS;
100
+ }
101
+
102
+ return {
103
+ points: allPoints.slice(start, end),
104
+ currentCol: currentIdx - start,
105
+ moreLeft: start > 0,
106
+ moreRight: end < allPoints.length,
107
+ };
108
+ }
109
+
110
+ // ============================================================================
111
+ // Header row
112
+ // ============================================================================
113
+
114
+ /**
115
+ * Builds the column header row showing relative step offsets.
116
+ *
117
+ * The current tick is rendered as `[N]` (absolute tick number); all other
118
+ * columns show their signed step distance (`-2`, `-1`, `+1`, `+2`, etc.)
119
+ * matching the `--tick=+N/-N` CLI syntax.
120
+ *
121
+ * @param {{ points: number[], currentCol: number }} win - Computed window
122
+ * @returns {string} Formatted, indented header line
123
+ */
124
+ function buildHeaderRow(win) {
125
+ const { points, currentCol } = win;
126
+ let header = '';
127
+
128
+ for (let i = 0; i < points.length; i++) {
129
+ const rel = i - currentCol;
130
+ let label;
131
+ if (rel === 0) {
132
+ label = `[${points[i]}]`;
133
+ } else if (rel > 0) {
134
+ label = `+${rel}`;
135
+ } else {
136
+ label = String(rel);
137
+ }
138
+ header += label.padEnd(COL_W);
139
+ }
140
+
141
+ const margin = ' '.repeat(NAME_W + 2);
142
+ return ` ${margin}${header.trimEnd()}`;
143
+ }
144
+
145
+ // ============================================================================
146
+ // Writer swimlane
147
+ // ============================================================================
148
+
149
+ /**
150
+ * Renders a single cell (marker) in the swimlane grid.
151
+ *
152
+ * @param {boolean} hasPatch - Whether this writer has a patch at this tick
153
+ * @param {boolean} incl - Whether this tick is in the included zone
154
+ * @returns {string} A single styled character
155
+ */
156
+ function renderCell(hasPatch, incl) {
157
+ if (hasPatch) {
158
+ return incl ? colors.success(TIMELINE.dot) : colors.muted(CIRCLE_OPEN);
159
+ }
160
+ return incl ? TIMELINE.line : colors.muted(DOT_MID);
161
+ }
162
+
163
+ /**
164
+ * Builds the swimlane track string for a writer across the window columns.
165
+ *
166
+ * @param {Set<number>} patchSet - Set of ticks where this writer has patches
167
+ * @param {number[]} points - Window tick positions
168
+ * @param {number} currentTick - Active seek cursor tick
169
+ * @returns {string} Styled swimlane track
170
+ */
171
+ function buildLane(patchSet, points, currentTick) {
172
+ let lane = '';
173
+ for (let i = 0; i < points.length; i++) {
174
+ const t = points[i];
175
+ const incl = t <= currentTick;
176
+
177
+ if (i > 0) {
178
+ const n = COL_W - 1;
179
+ lane += incl
180
+ ? TIMELINE.line.repeat(n)
181
+ : colors.muted(DOT_MID.repeat(n));
182
+ }
183
+
184
+ lane += renderCell(patchSet.has(t), incl);
185
+ }
186
+ return lane;
187
+ }
188
+
189
+ /**
190
+ * Builds one writer's horizontal swimlane row.
191
+ *
192
+ * Each tick position in the window gets a marker character:
193
+ * - `●` (green) — writer has a patch here AND tick ≤ currentTick (included)
194
+ * - `○` (muted) — writer has a patch here AND tick > currentTick (excluded)
195
+ * - `─` (solid) — no patch, included zone
196
+ * - `·` (muted) — no patch, excluded zone
197
+ *
198
+ * Between consecutive columns, connector characters of the appropriate style
199
+ * fill the gap (COL_W − 1 chars).
200
+ *
201
+ * @param {Object} opts
202
+ * @param {string} opts.writerId
203
+ * @param {Object} opts.writerInfo - `{ ticks, tipSha, tickShas }`
204
+ * @param {{ points: number[] }} opts.win - Computed window
205
+ * @param {number} opts.currentTick - Active seek cursor tick
206
+ * @returns {string} Formatted, indented swimlane line
207
+ */
208
+ function buildWriterSwimRow({ writerId, writerInfo, win, currentTick }) {
209
+ const patchSet = new Set(writerInfo.ticks);
210
+ const tickShas = writerInfo.tickShas || {};
211
+ const lane = buildLane(patchSet, win.points, currentTick);
212
+
213
+ // SHA of the highest included patch
214
+ const included = writerInfo.ticks.filter((t) => t <= currentTick);
215
+ const maxIncl = included.length > 0 ? included[included.length - 1] : null;
216
+ const sha = maxIncl !== null
217
+ ? (tickShas[maxIncl] || writerInfo.tipSha)
218
+ : writerInfo.tipSha;
219
+
220
+ const name = padRight(formatWriterName(writerId, NAME_W), NAME_W);
221
+ const shaStr = sha ? ` ${formatSha(sha)}` : '';
222
+
223
+ return ` ${name} ${lane}${shaStr}`;
224
+ }
225
+
226
+ // ============================================================================
227
+ // Body assembly
228
+ // ============================================================================
229
+
230
+ /**
231
+ * Builds the tick-position array and index of the current tick.
232
+ *
233
+ * Ensures the current tick is always present: if `tick` is absent from
234
+ * `ticks` (e.g. saved cursor after writer refs changed), it is inserted
235
+ * at the correct sorted position so the window always centres on it.
236
+ *
237
+ * @param {number[]} ticks - Discovered Lamport ticks
238
+ * @param {number} tick - Current cursor tick
239
+ * @returns {{ allPoints: number[], currentIdx: number }}
240
+ */
241
+ function buildTickPoints(ticks, tick) {
242
+ const allPoints = (ticks[0] === 0) ? [...ticks] : [0, ...ticks];
243
+ let currentIdx = allPoints.indexOf(tick);
244
+ if (currentIdx === -1) {
245
+ let ins = allPoints.findIndex((t) => t > tick);
246
+ if (ins === -1) {
247
+ ins = allPoints.length;
248
+ }
249
+ allPoints.splice(ins, 0, tick);
250
+ currentIdx = ins;
251
+ }
252
+ return { allPoints, currentIdx };
253
+ }
254
+
255
+ /**
256
+ * Builds the body lines for the seek dashboard.
257
+ *
258
+ * @param {Object} payload - Seek payload from the CLI handler
259
+ * @returns {string[]} Lines for the box body
260
+ */
261
+ function buildSeekBodyLines(payload) {
262
+ const { graph, tick, maxTick, ticks, nodes, edges, patchCount, perWriter, diff, tickReceipt } = payload;
263
+ const lines = [];
264
+
265
+ lines.push('');
266
+ lines.push(` ${colors.bold('GRAPH:')} ${graph}`);
267
+ lines.push(` ${colors.bold('POSITION:')} tick ${tick} of ${maxTick}`);
268
+ lines.push('');
269
+
270
+ if (ticks.length === 0) {
271
+ lines.push(` ${colors.muted('(no ticks)')}`);
272
+ } else {
273
+ const { allPoints, currentIdx } = buildTickPoints(ticks, tick);
274
+ const win = computeWindow(allPoints, currentIdx);
275
+
276
+ // Column headers with relative offsets
277
+ lines.push(buildHeaderRow(win));
278
+
279
+ // Per-writer swimlanes
280
+ const writerEntries = perWriter instanceof Map
281
+ ? [...perWriter.entries()]
282
+ : Object.entries(perWriter).map(([k, v]) => [k, v]);
283
+
284
+ for (const [writerId, writerInfo] of writerEntries) {
285
+ lines.push(buildWriterSwimRow({ writerId, writerInfo, win, currentTick: tick }));
286
+ }
287
+ }
288
+
289
+ lines.push('');
290
+ const edgeLabel = pluralize(edges, 'edge', 'edges');
291
+ const nodeLabel = pluralize(nodes, 'node', 'nodes');
292
+ const patchLabel = pluralize(patchCount, 'patch', 'patches');
293
+
294
+ const nodesStr = `${nodes} ${nodeLabel}${formatDelta(diff?.nodes)}`;
295
+ const edgesStr = `${edges} ${edgeLabel}${formatDelta(diff?.edges)}`;
296
+ lines.push(` ${colors.bold('State:')} ${nodesStr}, ${edgesStr}, ${patchCount} ${patchLabel}`);
297
+
298
+ const receiptLines = buildReceiptLines(tickReceipt);
299
+ if (receiptLines.length > 0) {
300
+ lines.push('');
301
+ lines.push(` ${colors.bold(`Tick ${tick}:`)}`);
302
+ lines.push(...receiptLines);
303
+ }
304
+ lines.push('');
305
+
306
+ return lines;
307
+ }
308
+
309
+ // ============================================================================
310
+ // Public API
311
+ // ============================================================================
312
+
313
+ /**
314
+ * Renders the seek view dashboard inside a double-bordered box.
315
+ *
316
+ * @param {Object} payload - Seek payload from the CLI handler
317
+ * @returns {string} Boxen-wrapped ASCII dashboard with trailing newline
318
+ */
319
+ export function renderSeekView(payload) {
320
+ const lines = buildSeekBodyLines(payload);
321
+ const body = lines.join('\n');
322
+
323
+ return `${boxen(body, {
324
+ title: ' SEEK ',
325
+ titleAlignment: 'center',
326
+ padding: 0,
327
+ borderStyle: 'double',
328
+ borderColor: 'cyan',
329
+ })}\n`;
330
+ }
@@ -1,4 +1,10 @@
1
- import stripAnsiLib from 'strip-ansi';
1
+ // ANSI escape code regex (inlined from ansi-regex@6 / strip-ansi@7)
2
+ // Valid string terminator sequences: BEL, ESC\, 0x9c
3
+ const ST = '(?:\\u0007|\\u001B\\u005C|\\u009C)';
4
+ const osc = `(?:\\u001B\\][\\s\\S]*?${ST})`;
5
+ const csi =
6
+ '[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]';
7
+ const ansiRegex = new RegExp(`${osc}|${csi}`, 'g');
2
8
 
3
9
  /**
4
10
  * Strips ANSI escape codes from a string.
@@ -8,7 +14,7 @@ import stripAnsiLib from 'strip-ansi';
8
14
  * @returns {string} The string with all ANSI codes removed
9
15
  */
10
16
  export function stripAnsi(str) {
11
- return stripAnsiLib(str);
17
+ return str.replace(ansiRegex, '');
12
18
  }
13
19
 
14
20
  export default { stripAnsi };