pi-supernova 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/render.js CHANGED
@@ -21,8 +21,10 @@ export { measureWidth, hardTruncate, clampLine };
21
21
  function formatDiffRows(diff, theme, maxShown = 6) {
22
22
  if (!diff || !Array.isArray(diff.lines) || diff.lines.length === 0) return [];
23
23
  const body = [];
24
+
24
25
  for (const item of diff.lines.slice(0, maxShown)) {
25
26
  const num = item.lineNum || 0;
27
+
26
28
  if (item.type === "remove") {
27
29
  const gut = theme.fg("toolDiffRemoved", `-${num}`.padStart(5));
28
30
  body.push(`${gut}${theme.fg("borderMuted", " │ ")}${theme.fg("toolDiffRemoved", `- ${cleanInlineText(item.text)}`)}`);
@@ -34,10 +36,13 @@ function formatDiffRows(diff, theme, maxShown = 6) {
34
36
  body.push(`${gut}${theme.fg("borderMuted", " │ ")}${theme.fg("toolDiffContext", ` ${cleanInlineText(item.text)}`)}`);
35
37
  }
36
38
  }
39
+
37
40
  const displayLineCount = Number.isInteger(diff.displayLineCount) ? diff.displayLineCount : diff.lines.length;
41
+
38
42
  if (displayLineCount > maxShown) {
39
43
  body.push(theme.fg("dim", ` │ … ${displayLineCount - maxShown} more lines`));
40
44
  }
45
+
41
46
  return body;
42
47
  }
43
48
 
@@ -50,6 +55,7 @@ function stripUnsafeControls(value) {
50
55
 
51
56
  function cleanBlockText(value) {
52
57
  const normalized = stripVTControlCharacters(String(value ?? "")).replace(/\r\n?/g, "\n");
58
+
53
59
  return stripUnsafeControls(normalized).replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
54
60
  }
55
61
 
@@ -59,8 +65,10 @@ function cleanInlineText(value) {
59
65
 
60
66
  function displayOperation(tool, target, diff, ok, item) {
61
67
  const rawName = cleanInlineText(tool);
68
+
62
69
  if (!rawName) return null;
63
70
  const normalized = rawName === "apply_patch" ? "patch" : rawName;
71
+
64
72
  return { tool: normalized, target, diff, ok, ms: item?.ms, exitCode: item?.exitCode, time: item?.time, error: item?.error };
65
73
  }
66
74
 
@@ -76,12 +84,17 @@ function isTheme(value) {
76
84
  export function normalizeCallRenderArgs(a, b, c) {
77
85
  if (isTheme(b)) {
78
86
  const context = isObject(c) ? c : {};
87
+
79
88
  if (!isObject(context.state)) context.state = {};
89
+
80
90
  return { args: a, theme: b, context, host: "pi" };
81
91
  }
92
+
82
93
  if (isTheme(c)) {
83
94
  const options = isObject(b) ? b : {};
95
+
84
96
  if (!isObject(options.state)) options.state = {};
97
+
85
98
  const context = {
86
99
  ...options,
87
100
  state: options.state,
@@ -92,8 +105,10 @@ export function normalizeCallRenderArgs(a, b, c) {
92
105
  lastComponent: options.lastComponent,
93
106
  invalidate: options.invalidate,
94
107
  };
108
+
95
109
  return { args: a, theme: c, context, host: "omp", options };
96
110
  }
111
+
97
112
  throw new Error("supernova renderCall: theme missing (expected Pi or OMP signature)");
98
113
  }
99
114
 
@@ -109,14 +124,19 @@ function contextFrom(opts, ctxOrArgs) {
109
124
  if (isObject(ctxOrArgs) && !isTheme(ctxOrArgs)) {
110
125
  if ("lastComponent" in ctxOrArgs || "state" in ctxOrArgs || "invalidate" in ctxOrArgs) return ctxOrArgs;
111
126
  }
127
+
112
128
  return { state: opts.state, lastComponent: opts.lastComponent };
113
129
  }
114
130
 
115
131
  function detectResultHost(options, ctxOrArgs) {
116
132
  if (isTheme(options)) return "pi";
133
+
117
134
  if (!isObject(ctxOrArgs)) return "pi";
135
+
118
136
  if ("lastComponent" in ctxOrArgs || "invalidate" in ctxOrArgs) return "pi";
119
- if ("code" in ctxOrArgs || "timeoutMs" in ctxOrArgs) return "omp";
137
+
138
+ if ("code" in ctxOrArgs || "file" in ctxOrArgs || "programs" in ctxOrArgs || "timeoutMs" in ctxOrArgs) return "omp";
139
+
120
140
  return "pi";
121
141
  }
122
142
 
@@ -124,22 +144,27 @@ function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs) {
124
144
  if (isTheme(themeOrCtx)) {
125
145
  const opts = isObject(options) ? options : {};
126
146
  const context = contextFrom(opts, ctxOrArgs);
147
+
127
148
  if (!isObject(context.state)) context.state = {};
149
+
128
150
  return {
129
151
  result,
130
152
  expanded: !!opts.expanded,
131
153
  isPartial: !!opts.isPartial,
132
154
  theme: themeOrCtx,
133
155
  context,
134
- args: ctxOrArgs?.code ? ctxOrArgs : context.args,
156
+ args: ctxOrArgs?.code || ctxOrArgs?.file || ctxOrArgs?.programs ? ctxOrArgs : context.args,
135
157
  host: detectResultHost(options, ctxOrArgs),
136
158
  options: opts,
137
159
  };
138
160
  }
161
+
139
162
  // Extremely defensive: (result, theme, context) oddball
140
163
  if (isTheme(options)) {
141
164
  const context = isObject(themeOrCtx) ? themeOrCtx : {};
165
+
142
166
  if (!isObject(context.state)) context.state = {};
167
+
143
168
  return {
144
169
  result,
145
170
  expanded: !!context.expanded,
@@ -151,18 +176,22 @@ function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs) {
151
176
  options: {},
152
177
  };
153
178
  }
179
+
154
180
  throw new Error("supernova renderResult: theme missing (expected Pi or OMP signature)");
155
181
  }
156
182
 
157
183
  function batchTarget(paths) {
158
184
  const names = paths.map((p) => String(p).replace(/\\/g, "/").split("/").pop());
185
+
159
186
  return `${paths.length} files: ${names.join(", ")}`;
160
187
  }
161
188
 
162
189
  const OPERATION_TARGETS = [
163
190
  [(item) => item?.name === "snap", (item, args) => {
164
191
  const query = args.query ? `"${args.query}"` : "";
192
+
165
193
  if (!args.path) return query;
194
+
166
195
  return `${query} → ${args.path}`;
167
196
  }],
168
197
  [(item) => item?.name === "search", (item, args) => (args.query ? `"${args.query}"` : "")],
@@ -177,60 +206,79 @@ const OPERATION_TARGETS = [
177
206
 
178
207
  function operationTarget(item) {
179
208
  const args = item?.args || {};
209
+
180
210
  for (const [predicate, formatter] of OPERATION_TARGETS) {
181
211
  if (predicate(item, args)) return formatter(item, args);
182
212
  }
213
+
183
214
  return "";
184
215
  }
185
216
 
186
217
  function parseDiffLine(rawLine) {
187
218
  const signed = /^([+-])\s*(\d+)\s?(.*)$/.exec(rawLine);
219
+
188
220
  if (signed) return { type: signed[1] === "+" ? "add" : "remove", lineNum: Number(signed[2]), text: signed[3] };
189
221
  const contextual = /^\s+(\d+)\s?(.*)$/.exec(rawLine);
222
+
190
223
  if (contextual) return { type: "context", lineNum: Number(contextual[1]), text: contextual[2] };
224
+
191
225
  return null;
192
226
  }
193
227
 
194
228
  // Text diffs are immutable, even when hosts replace trace snapshots on each frame.
195
229
  // Cache only parsed data, not theme, paths or mutable result objects.
196
230
  const textDiffCache = new Map();
231
+
197
232
  let cachedDiffChars = 0;
233
+
198
234
  const MAX_CACHED_DIFF_CHARS = 1_000_000;
199
235
 
200
236
  function normalizeTraceDiff(item) {
201
237
  const diff = item?.diff;
238
+
202
239
  if (isObject(diff)) return diff;
240
+
203
241
  if (!isString(diff) || !diff.trim()) return undefined;
204
242
  const cached = textDiffCache.get(diff);
243
+
205
244
  if (cached) return cached;
206
245
  const lines = [];
207
246
  let displayLineCount = 0;
208
247
  let added = 0;
209
248
  let removed = 0;
249
+
210
250
  for (const rawLine of cleanBlockText(diff).split("\n")) {
211
251
  const parsed = parseDiffLine(rawLine);
252
+
212
253
  if (!parsed) continue;
254
+
213
255
  if (parsed.type === "add") added += 1;
214
256
  else if (parsed.type === "remove") removed += 1;
215
257
  displayLineCount++;
258
+
216
259
  if (lines.length < 24) lines.push(parsed);
217
260
  }
261
+
218
262
  if (lines.length === 0) return undefined;
219
263
  const parsed = { added, removed, lines, displayLineCount };
264
+
220
265
  if (diff.length <= MAX_CACHED_DIFF_CHARS) {
221
266
  while (textDiffCache.size >= 24 || cachedDiffChars + diff.length > MAX_CACHED_DIFF_CHARS) {
222
267
  const oldest = textDiffCache.keys().next().value;
223
268
  textDiffCache.delete(oldest);
224
269
  cachedDiffChars -= oldest.length;
225
270
  }
271
+
226
272
  textDiffCache.set(diff, parsed);
227
273
  cachedDiffChars += diff.length;
228
274
  }
275
+
229
276
  return parsed;
230
277
  }
231
278
 
232
279
  function operationsFromTrace(trace) {
233
280
  if (!Array.isArray(trace)) return [];
281
+
234
282
  return trace
235
283
  .map((item) => displayOperation(item?.name || "tool", operationTarget(item), normalizeTraceDiff(item), item?.ok, item))
236
284
  .filter(Boolean);
@@ -241,53 +289,71 @@ const EMPTY_CALL = { render: () => [], invalidate() {} };
241
289
 
242
290
  export function renderSupernovaCall(a, b, c) {
243
291
  const { context, options } = normalizeCallRenderArgs(a, b, c);
292
+
244
293
  if (options) options.lastComponent = EMPTY_CALL;
245
294
  else if (context) context.lastComponent = EMPTY_CALL;
295
+
246
296
  return EMPTY_CALL;
247
297
  }
248
298
 
249
299
  const TOOL_COL = 7;
300
+
250
301
  const DURATION_COL = 6;
251
302
 
252
303
  function formatDuration(ms) {
253
304
  if (!Number.isFinite(ms) || ms < 0) return "";
305
+
254
306
  if (ms < 1000) return `${Math.round(ms)}ms`;
307
+
255
308
  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
256
309
  const minutes = Math.floor(ms / 60000);
257
310
  const seconds = Math.round((ms % 60000) / 1000);
311
+
258
312
  return `${minutes}m${String(seconds).padStart(2, "0")}s`;
259
313
  }
260
314
 
261
315
  /** First meaningful line of a shell command plus a count of the hidden remainder. */
262
316
  function summarizeCommand(raw) {
263
317
  const lines = cleanBlockText(raw).split("\n").map((line) => line.trim()).filter(Boolean);
318
+
264
319
  if (lines.length === 0) return "";
265
320
  const first = lines[0].replace(/\s+/g, " ");
321
+
266
322
  return lines.length > 1 ? `${first} …+${lines.length - 1} lines` : first;
267
323
  }
268
324
 
269
325
  const SHELL_TOOLS = ["bash", "exec"];
326
+
270
327
  const SEARCH_TOOLS = ["snap", "search"];
271
328
 
272
329
  function formatTarget(op, budget) {
273
330
  if (SHELL_TOOLS.includes(op.tool)) return clampLine(summarizeCommand(op.target), budget);
274
331
  const text = cleanInlineText(op.target);
332
+
275
333
  if (!text) return "";
334
+
276
335
  if (SEARCH_TOOLS.includes(op.tool)) return clampLine(text, budget);
336
+
277
337
  return fitPath(text, budget);
278
338
  }
279
339
 
280
340
  function opMarker(theme, op, isPartial, isError) {
281
341
  if (op.ok === false) return theme.fg("error", "×");
342
+
282
343
  if (op.ok === true) return theme.fg("success", "✓");
344
+
283
345
  if (isPartial) return theme.fg("dim", "·");
346
+
284
347
  if (isError) return theme.fg("error", "×");
348
+
285
349
  return theme.fg("success", "✓");
286
350
  }
287
351
 
288
352
  function opDuration(op, isPartial) {
289
353
  if (Number.isFinite(op.ms)) return formatDuration(op.ms);
354
+
290
355
  if (isPartial && op.ok === undefined && Number.isFinite(op.time)) return formatDuration(Date.now() - op.time) + "…";
356
+
291
357
  return "";
292
358
  }
293
359
 
@@ -303,54 +369,69 @@ function formatOpRow(theme, op, width, isPartial, isError) {
303
369
  const duration = theme.fg("dim", durationText.padStart(DURATION_COL));
304
370
  let prefix = `${marker} ${tool} ${duration} `;
305
371
  let used = 2 + toolText.length + 1 + DURATION_COL + 2;
372
+
306
373
  if (Number.isInteger(op.exitCode)) {
307
374
  const exit = `exit ${op.exitCode}`;
308
375
  prefix += theme.fg("error", exit) + " ";
309
376
  used += exit.length + 2;
310
377
  }
378
+
311
379
  if (op.diff && isObject(op.diff)) {
312
380
  const added = `+${op.diff.added || 0}`;
313
381
  const removed = `-${op.diff.removed || 0}`;
314
382
  prefix += theme.fg("toolDiffAdded", added) + theme.fg("dim", "/") + theme.fg("toolDiffRemoved", removed) + " ";
315
383
  used += added.length + 1 + removed.length + 1;
316
384
  }
385
+
317
386
  const budget = Math.max(1, width - used);
318
387
  const target = formatTarget(op, budget);
388
+
319
389
  if (target) return prefix + theme.fg("muted", target);
390
+
320
391
  if (op.ok === false && op.error) return prefix + theme.fg("error", clampLine(cleanInlineText(op.error), budget));
392
+
321
393
  return prefix.trimEnd();
322
394
  }
323
395
 
324
396
  function traceFor(payload, context) {
325
397
  const trace = payload?.trace || context?.state?.trace;
398
+
326
399
  return Array.isArray(trace) ? trace : [];
327
400
  }
328
401
 
329
402
  function resultLines(value, width) {
330
403
  const text = isString(value) ? value : formatValue(value);
404
+
331
405
  return cleanBlockText(text).split("\n").flatMap(line => wrapLine(line, width));
332
406
  }
333
407
 
334
408
  function appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError) {
335
409
  for (const op of ops.slice(0, maxOps)) {
336
410
  lines.push(formatOpRow(theme, op, width, isPartial, isError));
411
+
337
412
  if (maxDiffLines === 0 || !op.diff || !isObject(op.diff)) continue;
413
+
338
414
  for (const row of formatDiffRows(op.diff, theme, maxDiffLines)) lines.push(" " + row);
339
415
  }
416
+
340
417
  if (ops.length > maxOps) lines.push(theme.fg("dim", ` … ${ops.length - maxOps} more calls`));
341
418
  }
342
419
 
343
420
  function appendTail(lines, theme, payload, expanded, isError, width) {
344
421
  if (isError) {
345
422
  const error = "✗ " + (payload?.error ? cleanBlockText(payload.error) : "error");
423
+
346
424
  for (const line of expanded ? resultLines(error, width) : error.split("\n")) lines.push(theme.fg("error", line));
347
425
  }
348
426
  else if (expanded && payload?.result !== undefined) {
349
427
  lines.push(theme.fg("dim", "── result ──"));
428
+
350
429
  for (const line of resultLines(payload.result, width)) lines.push(theme.fg("toolOutput", line));
351
430
  }
431
+
352
432
  if (expanded && payload?.logs?.length) {
353
433
  lines.push(theme.fg("dim", "── logs ──"));
434
+
354
435
  for (const log of payload.logs) for (const line of resultLines(log, width)) lines.push(theme.fg("dim", line));
355
436
  }
356
437
  }
@@ -365,8 +446,10 @@ function buildBodyLines(theme, width, { payload, context, expanded, isPartial, i
365
446
  const ops = operationsFromTrace(visible);
366
447
  const lines = [];
367
448
  appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError);
449
+
368
450
  if (trace.length > maxOps) lines.push(theme.fg("dim", ` … ${trace.length - maxOps} ${isPartial ? "earlier" : "more"} calls`));
369
451
  appendTail(lines, theme, payload, expanded, isError, width);
452
+
370
453
  return { lines, opCount: trace.length };
371
454
  }
372
455
 
@@ -374,6 +457,7 @@ function describeCard(model, opCount) {
374
457
  const wall = model.payload?.wallMs != null ? formatDuration(model.payload.wallMs) : "";
375
458
  const calls = opCount > 0 ? `${opCount} call${opCount === 1 ? "" : "s"}` : "";
376
459
  const status = model.isError ? "failed" : model.isPartial ? "running" : calls ? "" : "complete";
460
+
377
461
  return [calls, status, wall].filter(Boolean).join(" · ");
378
462
  }
379
463
 
@@ -388,14 +472,18 @@ class UnifiedResultCard {
388
472
  }
389
473
  render(width = 80) {
390
474
  const { theme, model } = this;
475
+
391
476
  if (!theme || !model || width <= 0) return [];
477
+
392
478
  if (this.cache?.width === width) return this.cache.lines;
393
479
  const view = buildBodyLines(theme, Math.max(1, width - 4), model);
480
+
394
481
  const header = novaStatusLine(theme, {
395
482
  icon: model.isError ? "error" : model.isPartial ? "running" : undefined,
396
483
  title: "nova",
397
484
  description: describeCard(model, view.opCount),
398
485
  });
486
+
399
487
  // A program with no host calls has nothing to frame: one status line, no empty box.
400
488
  const lines = view.lines.length === 0
401
489
  ? [clampLine(header, width)]
@@ -407,14 +495,18 @@ class UnifiedResultCard {
407
495
  borderColor: model.isError ? "error" : "dim",
408
496
  width,
409
497
  })).render(width);
498
+
410
499
  this.cache = { width, lines };
500
+
411
501
  return lines;
412
502
  }
413
503
  }
414
504
 
415
505
  function syncState(context, payload) {
416
506
  if (!context?.state || !payload) return;
507
+
417
508
  if (Array.isArray(payload.trace) && context.state.trace !== payload.trace) context.state.trace = payload.trace;
509
+
418
510
  if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) context.state.wallMs = payload.wallMs;
419
511
  }
420
512
 
@@ -432,8 +524,10 @@ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextAr
432
524
  const isError = result?.isError || payload?.ok === false;
433
525
  const previous = host === "omp" ? options?.lastComponent : context?.lastComponent;
434
526
  const comp = previous instanceof UnifiedResultCard ? previous : new UnifiedResultCard();
527
+
435
528
  if (host === "omp" && options) options.lastComponent = comp;
436
529
  else if (context) context.lastComponent = comp;
437
530
  comp.set(theme, { payload, context, args, expanded, isPartial, isError });
531
+
438
532
  return comp;
439
533
  }