pi-supernova 0.10.1 → 0.11.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.
Files changed (81) hide show
  1. package/README.md +376 -221
  2. package/docs/CHANGELOG.md +107 -5
  3. package/docs/TOKEN_COSTS.md +149 -324
  4. package/index.js +40 -11
  5. package/package.json +4 -3
  6. package/src/adapters/bash.js +40 -10
  7. package/src/adapters/edit.js +77 -23
  8. package/src/adapters/errors.js +4 -0
  9. package/src/adapters/index.js +1 -0
  10. package/src/adapters/list.js +6 -3
  11. package/src/adapters/read-focus.js +6 -0
  12. package/src/adapters/read-image.js +17 -9
  13. package/src/adapters/read-indexed.js +72 -0
  14. package/src/adapters/read-text.js +19 -2
  15. package/src/adapters/read.js +57 -13
  16. package/src/adapters/refs.js +17 -7
  17. package/src/adapters/write.js +14 -21
  18. package/src/bridge/host-bridge.js +152 -39
  19. package/src/bridge/invoke.js +3 -1
  20. package/src/bridge/tool-registry.js +132 -16
  21. package/src/context/evidence-graph.js +1 -0
  22. package/src/context/evidence-rank.js +1 -0
  23. package/src/context/evidence.js +4 -2
  24. package/src/context/fuzzy.js +31 -8
  25. package/src/context/outline.js +33 -9
  26. package/src/context/query.js +7 -2
  27. package/src/context/repo-index.js +8 -3
  28. package/src/context/search-files.js +20 -0
  29. package/src/context/search.js +25 -10
  30. package/src/context/snap-search.js +28 -6
  31. package/src/context/snap.js +8 -3
  32. package/src/context/source-entry.js +64 -28
  33. package/src/context/spans.js +1 -0
  34. package/src/contract/bash.js +16 -7
  35. package/src/contract/edit.js +26 -7
  36. package/src/contract/program.js +2 -1
  37. package/src/contract/read.js +46 -46
  38. package/src/fs/background.js +10 -2
  39. package/src/fs/check.js +844 -46
  40. package/src/fs/commit.js +89 -15
  41. package/src/fs/diff.js +12 -9
  42. package/src/fs/directory.js +14 -4
  43. package/src/fs/file-io.js +28 -7
  44. package/src/fs/glob.js +93 -11
  45. package/src/fs/json-read.js +3 -2
  46. package/src/fs/json-size.js +2 -1
  47. package/src/fs/lines.js +49 -2
  48. package/src/fs/patch.js +26 -10
  49. package/src/fs/process-tree.js +19 -1
  50. package/src/fs/read-window.js +36 -20
  51. package/src/fs/source-window.js +5 -10
  52. package/src/fs/text-ops.js +39 -19
  53. package/src/fs/vfs.js +296 -41
  54. package/src/fs/workspace.js +24 -6
  55. package/src/output/bottleneck.js +50 -25
  56. package/src/output/final.js +20 -0
  57. package/src/output/format.js +97 -36
  58. package/src/output/outcome.js +25 -9
  59. package/src/runtime/batch-input.js +17 -3
  60. package/src/runtime/guest-api.js +32 -7
  61. package/src/runtime/guest-worker.js +22 -4
  62. package/src/runtime/parallel.js +9 -49
  63. package/src/runtime/program-batch.js +53 -28
  64. package/src/runtime/program-file.js +2 -1
  65. package/src/runtime/program.js +94 -41
  66. package/src/runtime/reference.js +10 -9
  67. package/src/runtime/runtime.js +86 -17
  68. package/src/runtime/worker-pool.js +10 -1
  69. package/src/shared/decode.js +9 -8
  70. package/src/shared/image-worker.js +7 -0
  71. package/src/shared/image.js +58 -15
  72. package/src/shared/png.js +18 -0
  73. package/src/shared/result.js +30 -9
  74. package/src/shared/syntax-context.js +7 -1
  75. package/src/shared/utf8.js +11 -13
  76. package/src/ui/host-render.js +4 -0
  77. package/src/ui/omp-frame.js +2 -0
  78. package/src/ui/progress.js +2 -1
  79. package/src/ui/render-measure.js +11 -0
  80. package/src/ui/render.js +92 -31
  81. package/src/ui/trace.js +8 -5
@@ -16,13 +16,19 @@ export function sourceContext(source, line, column, lineBreaks = JS_LINES) {
16
16
  if (text === undefined) return "";
17
17
  const located = Number.isInteger(column) && column >= 0;
18
18
  const position = located ? Math.min(column, text.length) : 0;
19
+ const {shown,caret} = renderSourceWindow(text, position, located);
20
+
21
+ return "\n " + shown + (caret ? "\n " + caret : "");
22
+ }
23
+
24
+ function renderSourceWindow(text, position, located) {
19
25
  const start = Math.max(0, Math.min(position - 80, text.length - 160));
20
26
  const end = Math.min(text.length, start + 160);
21
27
  const prefix = start > 0 ? "…" : "";
22
28
  const shown = prefix + displaySource(text.slice(start, end)) + (end < text.length ? "…" : "");
23
29
  const caret = located ? " ".repeat(stringWidth(prefix + displaySource(text.slice(start, position)))) + "^" : "";
24
30
 
25
- return "\n " + shown + (caret ? "\n " + caret : "");
31
+ return {shown,caret};
26
32
  }
27
33
 
28
34
  /** acorn-style error that carries a loc, when it has one. */
@@ -1,17 +1,15 @@
1
- /** Strict decode: binary or non-UTF-8 text must fail loudly, never as U+FFFD. */
2
- export function decodeUtf8Strict(bytes, target) {
3
- try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); }
4
- catch {
5
- throw new Error("not valid UTF-8 (binary or non-UTF-8 text): " + target + "; inspect or convert it with bash (for example iconv -f latin1 -t utf8 or xxd)");
6
- }
1
+ function invalidUtf8(target) {
2
+ return new Error("not valid UTF-8 (binary or non-UTF-8 text): " + target + "; inspect or convert it with bash (for example iconv -f latin1 -t utf8 or xxd)");
7
3
  }
8
4
 
9
- /** Prefix window: the byte cut may split a character, so drop one partial tail. */
10
- export function decodeUtf8Window(bytes) {
11
- for (let cut = 0; cut <= 3 && cut < bytes.length; cut++) {
12
- try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, bytes.length - cut)); }
13
- catch { /* a partial character at the cut is expected */ }
14
- }
5
+ /** Strict text fidelity includes the BOM; individual formats may consume it. */
6
+ export function decodeUtf8Strict(bytes, target) {
7
+ try { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
8
+ catch { throw invalidUtf8(target); }
9
+ }
15
10
 
16
- return bytes.toString("utf8");
11
+ /** Streaming decode omits only an incomplete tail, never corrupt interior bytes. */
12
+ export function decodeUtf8Window(bytes, target = "read window") {
13
+ try { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes, { stream: true }); }
14
+ catch { throw invalidUtf8(target); }
17
15
  }
@@ -66,6 +66,7 @@ function isToolArgs(value) {
66
66
 
67
67
  function detectResultHost(options, ctxOrArgs) {
68
68
  if (isTheme(options) || !isObject(ctxOrArgs) || isRenderContext(ctxOrArgs)) return "pi";
69
+
69
70
  return isToolArgs(ctxOrArgs) ? "omp" : "pi";
70
71
  }
71
72
 
@@ -82,6 +83,7 @@ function resultArgs(ctxOrArgs, context) {
82
83
  function piResultArgs(result, options, theme, ctxOrArgs) {
83
84
  const opts = isObject(options) ? options : {};
84
85
  const context = ensureState(contextFrom(opts, ctxOrArgs));
86
+
85
87
  return resultRenderModel(result, theme, context, opts, opts, detectResultHost(options, ctxOrArgs), resultArgs(ctxOrArgs, context));
86
88
  }
87
89
 
@@ -91,6 +93,7 @@ function resultRenderModel(result, theme, context, flags, options, host, args) {
91
93
 
92
94
  function oddballResultArgs(result, theme, themeOrCtx) {
93
95
  const context = ensureState(isObject(themeOrCtx) ? themeOrCtx : {});
96
+
94
97
  return resultRenderModel(result, theme, context, context, {}, "pi", context.args);
95
98
  }
96
99
 
@@ -101,4 +104,5 @@ function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs) {
101
104
 
102
105
  throw new Error("supernova renderResult: theme missing (expected Pi or OMP signature)");
103
106
  }
107
+
104
108
  export {normalizeResultRenderArgs};
@@ -54,10 +54,12 @@ const STATUS_PREFIX = { error: ["error", "✗ "], running: ["dim", "… "] };
54
54
 
55
55
  function statusHeader(theme, { title, description, state, icon }) {
56
56
  const resolved = icon ?? (state === "error" ? "error" : undefined);
57
+
57
58
  const fg = (key, text) => {
58
59
  try { return isFunction(theme?.fg) ? theme.fg(key, text) : text; }
59
60
  catch { return text; }
60
61
  };
62
+
61
63
  const prefixSpec = STATUS_PREFIX[resolved];
62
64
  const prefix = prefixSpec ? fg(prefixSpec[0], prefixSpec[1]) : "";
63
65
  const titleText = fg("accent", title);
@@ -1,6 +1,7 @@
1
1
  import {isFunction} from '../shared/decode.js';
2
2
 
3
- const PROGRESS_FRAME_MS = 80;
3
+ // Leave headroom for Pi's own render coalescing while providing ~31Hz live state.
4
+ const PROGRESS_FRAME_MS = 32;
4
5
 
5
6
  /**
6
7
  * Live trace updates for the card. The first update is immediate (seeds the result slot);
@@ -102,11 +102,22 @@ function pushWrapSegment(out, current, columns, segment, size, width) {
102
102
 
103
103
  /** Wrap complete, already-sanitized result text without splitting graphemes. */
104
104
  export function wrapLine(line, width) {
105
+ width = Math.floor(width);
106
+
105
107
  if (width <= 0) return [];
106
108
  const text = String(line).replace(/\t/g, " ");
107
109
 
108
110
  if (measureWidth(text) <= width) return [text];
109
111
  const out = [];
112
+
113
+ // Sanitized printable ASCII is one column per UTF-16 unit. Bulk slicing
114
+ // avoids segmenting every source character; Unicode keeps the grapheme path.
115
+ if (/^[\x20-\x7e]*$/.test(text)) {
116
+ for (let start = 0; start < text.length; start += width) out.push(text.slice(start, start + width));
117
+
118
+ return out;
119
+ }
120
+
110
121
  const current = { text: "" };
111
122
  let columns = 0;
112
123
 
package/src/ui/render.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import {normalizeCallRenderArgs,normalizeResultRenderArgs} from './host-render.js';
2
+
2
3
  export {normalizeCallRenderArgs} from './host-render.js';
3
- import {formatDiffRows,operationsFromTrace,formatDuration,formatOpRow,cleanBlockText} from './trace.js';
4
+
5
+ import {formatDiffRows,operationsFromTrace,formatDuration,formatOpRow,cleanBlockText,cleanInlineText} from './trace.js';
4
6
  /**
5
7
  * Supernova TUI renderers.
6
8
  *
@@ -16,7 +18,9 @@ import {formatDiffRows,operationsFromTrace,formatDuration,formatOpRow,cleanBlock
16
18
  import { isString, isObject } from "../shared/decode.js";
17
19
  import { measureWidth, hardTruncate, clampLine, wrapLine } from "./render-measure.js";
18
20
  import { novaFramedBlock, novaStatusLine } from "./omp-frame.js";
19
- import { formatValue } from "../output/format.js";
21
+ import { formatBoundedValue, truncateChars } from "../output/format.js";
22
+
23
+ const MAX_UI_RESULT_CHARS = 32_000;
20
24
 
21
25
  export { measureWidth, hardTruncate, clampLine };
22
26
 
@@ -38,53 +42,95 @@ function traceFor(payload, context) {
38
42
  return Array.isArray(trace) ? trace : [];
39
43
  }
40
44
 
41
- function resultLines(value, width) {
42
- const text = isString(value) ? value : formatValue(value);
45
+ function resultLines(value, width, layout) {
46
+ // Bound display text before Unicode layout. Complete machine-facing values
47
+ // and the execution evidence remain untouched in result.details.
48
+ const text = isString(value) ? truncateChars(value, MAX_UI_RESULT_CHARS, "UI preview").text
49
+ : formatBoundedValue(value, MAX_UI_RESULT_CHARS);
50
+
51
+ const clean = cleanBlockText(text);
43
52
 
44
- return cleanBlockText(text).split("\n").flatMap(line => wrapLine(line, width));
53
+ if (layout?.width === width && layout.text === clean) return layout.lines;
54
+ const lines = clean.split("\n").flatMap(line => wrapLine(line, width));
55
+
56
+ if (layout) Object.assign(layout, {width, text:clean, lines});
57
+
58
+ return lines;
45
59
  }
46
60
 
47
- function appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError) {
61
+ function appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError, expanded) {
48
62
  for (const op of ops.slice(0, maxOps)) {
49
63
  lines.push(formatOpRow(theme, op, width, isPartial, isError));
50
64
 
51
- if (maxDiffLines === 0 || !op.diff || !isObject(op.diff)) continue;
65
+ if (!expanded || maxDiffLines === 0 || !op.diff || !isObject(op.diff)) continue;
52
66
 
53
67
  for (const row of formatDiffRows(op.diff, theme, maxDiffLines)) lines.push(" " + row);
54
68
  }
55
69
 
56
- if (ops.length > maxOps) lines.push(theme.fg("dim", ` … ${ops.length - maxOps} more calls`));
70
+ if (!expanded && maxDiffLines > 0) {
71
+ const latest = ops.findLast(op => op.diff && isObject(op.diff));
72
+
73
+ if (latest) {
74
+ const label = latest.mutationAttempt || latest.ok === false ? "latest attempted change" : "latest change";
75
+ lines.push(theme.fg("dim", `── ${label}: ${cleanInlineText(latest.target)} ──`));
76
+
77
+ for (const row of formatDiffRows(latest.diff, theme, maxDiffLines)) lines.push(" " + row);
78
+ }
79
+ }
57
80
  }
58
81
 
59
- function appendError(lines, theme, payload, _expanded, width) {
82
+ function appendError(lines, theme, payload, expanded, width) {
60
83
  const errors = [payload?.error || "error"];
84
+
61
85
  for (const [i, program] of (payload?.programs ?? []).entries()) {
62
86
  if (program.details?.ok === false) errors.push(`program ${i + 1}: ${program.details.error || resultTextContent(program)}`);
63
87
  }
64
- for (const error of errors) for (const line of resultLines("✗ " + cleanBlockText(error), width)) lines.push(theme.fg("error", line));
88
+
89
+ let remaining = expanded ? Infinity : 8;
90
+
91
+ for (const [i, error] of errors.entries()) {
92
+ const wrapped = resultLines("✗ " + error, width);
93
+ const shown = wrapped.slice(0, remaining);
94
+
95
+ for (const line of shown) lines.push(theme.fg("error", line));
96
+ remaining -= shown.length;
97
+
98
+ if (!expanded && (shown.length < wrapped.length || (remaining === 0 && i + 1 < errors.length))) {
99
+ lines.push(theme.fg("dim", " … more error details (expand)"));
100
+ break;
101
+ }
102
+ }
65
103
  }
66
104
 
67
105
  function appendMutations(lines, theme, payload, width) {
68
106
  const m = payload?.mutations;
107
+
69
108
  if (!m || !["committed", "rolledBack", "external", "pendingCommits", "recoveryFailed"].some(key => m[key])) return;
109
+
70
110
  const extra = [
71
111
  [m.external, "; external calls attempted=" + m.external],
72
112
  [m.pendingCommits, "; pendingCommits=" + m.pendingCommits],
73
113
  [m.recoveryFailed, "; recovery failed: inspect files"],
74
- ].filter(([enabled]) => enabled).map(([, text]) => text).join("");
114
+ ].flatMap(([enabled, text]) => (enabled ? [text] : [])).join("");
115
+
75
116
  const summary = "file versions: committed=" + (m.committed || 0) + " rolledBack=" + (m.rolledBack || 0) + extra;
76
117
  const role = m.rolledBack || m.recoveryFailed ? "warning" : "dim";
118
+
77
119
  for (const line of resultLines(summary, width)) lines.push(theme.fg(role, line));
78
120
  }
79
121
 
80
- function appendResult(lines, theme, payload, expanded, width) {
122
+ function appendResult(lines, theme, payload, expanded, width, layout) {
81
123
  if (expanded) lines.push(theme.fg("dim", "── result ──"));
82
- const wrapped = resultLines(payload.result, width);
83
- const shown = expanded ? wrapped : wrapped.slice(0, 8);
124
+ const wrapped = resultLines(payload.result, width, layout);
125
+ const shown = expanded ? wrapped : wrapped.slice(0, 5);
84
126
 
85
127
  for (const line of shown) lines.push(theme.fg("toolOutput", line));
86
128
 
87
- if (!expanded && wrapped.length > shown.length) lines.push(theme.fg("dim", ` … ${wrapped.length - shown.length} more result lines`));
129
+ if (!expanded && wrapped.length > shown.length) lines.push(theme.fg("dim", ` … ${wrapped.length - shown.length} more result lines (expand)`));
130
+
131
+ if (isString(payload.result) && payload.result.length > MAX_UI_RESULT_CHARS) {
132
+ lines.push(theme.fg("dim", " … UI preview clipped; select fields for complete values"));
133
+ }
88
134
  }
89
135
 
90
136
  function appendLogs(lines, theme, payload, width) {
@@ -93,42 +139,47 @@ function appendLogs(lines, theme, payload, width) {
93
139
  for (const log of payload.logs) for (const line of resultLines(log, width)) lines.push(theme.fg("dim", line));
94
140
  }
95
141
 
96
- function appendTail(lines, theme, payload, expanded, isError, width, previewResult) {
142
+ function appendTail(lines, theme, payload, expanded, isError, width, layout, showResult) {
97
143
  if (isError) appendError(lines, theme, payload, expanded, width);
98
- else if (payload?.result !== undefined && (expanded || previewResult)) appendResult(lines, theme, payload, expanded, width);
144
+ else if ((expanded || showResult) && payload?.result !== undefined) appendResult(lines, theme, payload, expanded, width, layout);
99
145
 
100
146
  if (expanded && payload?.logs?.length) appendLogs(lines, theme, payload, width);
101
147
  }
102
148
 
103
149
  function bodyLimits(expanded, isPartial) {
104
- return { maxOps: expanded ? 24 : 8, maxDiffLines: expanded ? 24 : isPartial ? 0 : 8 };
150
+ return { maxOps: expanded ? 24 : 8, maxDiffLines: expanded ? 24 : isPartial ? 0 : 5 };
105
151
  }
106
152
 
107
- function visibleTrace(trace, maxOps, isPartial) {
108
- return isPartial ? trace.slice(-maxOps) : trace.slice(0, maxOps);
153
+ function visibleTrace(trace, maxOps) {
154
+ // Keep the viewport anchored to the same recent work when the run finishes.
155
+ return trace.slice(-maxOps);
109
156
  }
110
157
 
111
- function appendOverflow(lines, theme, trace, maxOps, isPartial) {
112
- if (trace.length > maxOps) lines.push(theme.fg("dim", ` … ${trace.length - maxOps} ${isPartial ? "earlier" : "more"} calls`));
158
+ function appendOverflow(lines, theme, trace, maxOps) {
159
+ if (trace.length > maxOps) lines.push(theme.fg("dim", ` … ${trace.length - maxOps} earlier calls (expand)`));
113
160
  }
114
161
 
115
162
  function appendEmptyOps(lines, theme, ops, isError, isPartial) {
116
163
  if (ops.length === 0 && !isError && !isPartial) lines.push(theme.fg("dim", "JavaScript-only execution"));
117
164
  }
118
165
 
119
- function buildBodyLines(theme, width, { payload, context, expanded, isPartial, isError }) {
166
+ function buildBodyLines(theme, width, { payload, context, expanded, isPartial, isError }, layout) {
120
167
  const trace = traceFor(payload, context);
121
168
  const { maxOps, maxDiffLines } = bodyLimits(expanded, isPartial);
122
- const ops = operationsFromTrace(visibleTrace(trace, maxOps, isPartial));
123
- // Trace success records an operation, not persistence of every staged version.
124
- // Mixed rollback/commit counts cannot safely be attributed to individual rows.
169
+ const ops = operationsFromTrace(visibleTrace(trace, maxOps));
170
+
171
+ // Native persistence states follow publication/checkpoint outcomes. Legacy and
172
+ // captured-tool traces remain conservative where persistence is unknown.
125
173
  for (const op of ops) op.mutationAttempt = mutationAttempt(op, payload, isPartial, isError);
126
174
  const lines = [];
127
- appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError);
128
- appendOverflow(lines, theme, trace, maxOps, isPartial);
175
+ appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError, expanded);
176
+ appendOverflow(lines, theme, trace, maxOps);
177
+
129
178
  if (!isPartial) appendMutations(lines, theme, payload, width);
179
+
130
180
  if (Array.isArray(payload?.trace)) appendEmptyOps(lines, theme, ops, isError, isPartial);
131
- appendTail(lines, theme, payload, expanded, isError, width, ops.length === 0 && !isPartial);
181
+ // Successful adapter calls stay compact; returned source/JSON is expansion-only.
182
+ appendTail(lines, theme, payload, expanded, isError, width, layout, ops.length === 0 && !isPartial);
132
183
 
133
184
  return { lines, opCount: trace.length };
134
185
  }
@@ -136,7 +187,8 @@ function buildBodyLines(theme, width, { payload, context, expanded, isPartial, i
136
187
  function describeCard(model, opCount) {
137
188
  const wall = model.payload?.wallMs != null ? formatDuration(model.payload.wallMs) : "";
138
189
  const calls = opCount > 0 ? `${opCount} call${opCount === 1 ? "" : "s"}` : "";
139
- const status = model.isError ? "failed" : model.isPartial ? "running" : calls ? "" : "complete";
190
+ const saved = model.payload?.mutations?.committed > 0 || traceFor(model.payload, model.context).some(row=>row.mutationState === "saved");
191
+ const status = model.isError ? saved ? "partial · failed step" : "failed" : model.isPartial ? "running" : "complete";
140
192
 
141
193
  return [calls, status, wall].filter(Boolean).join(" · ");
142
194
  }
@@ -179,6 +231,10 @@ function renderCardLines(theme, model, width, view) {
179
231
  }
180
232
 
181
233
  class UnifiedResultCard {
234
+ // Retain only one bounded, unstyled result layout per card. Theme invalidation
235
+ // still repaints every row; content or column changes replace the geometry.
236
+ resultLayout = {};
237
+
182
238
  set(theme, model) {
183
239
  this.theme = theme;
184
240
  this.model = model;
@@ -193,7 +249,7 @@ class UnifiedResultCard {
193
249
  if (!theme || !model || width <= 0) return [];
194
250
 
195
251
  if (this.cache?.width === width) return this.cache.lines;
196
- const view = buildBodyLines(theme, Math.max(1, width - 4), model);
252
+ const view = buildBodyLines(theme, Math.max(1, width - 4), model, this.resultLayout);
197
253
  const lines = renderCardLines(theme, model, width, view);
198
254
  this.cache = { width, lines };
199
255
 
@@ -215,7 +271,9 @@ function resultTextContent(result) {
215
271
 
216
272
  function payloadFromResult(result, hostError) {
217
273
  const payload = result?.details;
274
+
218
275
  if (hostError) return {...payload, ok:false, error:payload?.error || resultTextContent(result) || "tool execution failed"};
276
+
219
277
  return payload ?? {result:resultTextContent(result)};
220
278
  }
221
279
 
@@ -236,6 +294,7 @@ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextAr
236
294
  themeArg,
237
295
  contextArg,
238
296
  );
297
+
239
298
  // Pi omits isError from result and supplies it through render context.
240
299
  const hostError = result?.isError === true || context?.isError === true || options?.isError === true;
241
300
  const payload = payloadFromResult(result, hostError);
@@ -248,6 +307,8 @@ export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextAr
248
307
  }
249
308
 
250
309
  function mutationAttempt(op, payload, isPartial, isError) {
310
+ if (op.mutationState) return op.mutationState !== "saved";
311
+
251
312
  return op.ok === true && ["write", "edit", "patch"].includes(op.tool)
252
313
  && (isPartial || isError || payload?.mutations?.rolledBack > 0 || payload?.mutations?.recoveryFailed);
253
314
  }
package/src/ui/trace.js CHANGED
@@ -5,6 +5,7 @@ import {clampLine,fitPath} from './render-measure.js';
5
5
  function diffGut(theme, item) {
6
6
  const [sign, color, textColor] = DIFF_STYLES.get(item.type) ?? [" ", "dim", "toolDiffContext"];
7
7
  const gutter = theme.fg(color, (sign + (item.lineNum || 0)).padStart(5));
8
+
8
9
  return gutter + theme.fg("borderMuted", " │ ") + theme.fg(textColor, sign + " " + cleanInlineText(item.text));
9
10
  }
10
11
 
@@ -45,7 +46,7 @@ function displayOperation(tool, target, diff, ok, item) {
45
46
  if (!rawName) return null;
46
47
  const normalized = rawName === "apply_patch" ? "patch" : rawName;
47
48
 
48
- return { tool: normalized, target, diff, ok, ms: item?.ms, exitCode: item?.exitCode, time: item?.time, error: item?.error };
49
+ return { tool: normalized, target, diff, ok, ms: item?.ms, exitCode: item?.exitCode, time: item?.time, error: item?.error, mutationState: item?.mutationState };
49
50
  }
50
51
 
51
52
  function batchTarget(paths) {
@@ -206,12 +207,13 @@ function formatTarget(op, budget) {
206
207
  function opMarker(theme, op, isPartial, isError) {
207
208
  const status = [
208
209
  [op.ok === false, "error", "×"],
209
- [op.mutationAttempt, "warning", "·"],
210
+ [op.mutationAttempt || op.mutationState === "rolled back" || op.mutationState === "uncertain", "warning", "·"],
210
211
  [op.ok === true, "success", "✓"],
211
212
  [isPartial, "dim", "·"],
212
213
  [isError, "error", "×"],
213
214
  [true, "success", "✓"],
214
215
  ].find(([matches]) => matches);
216
+
215
217
  return theme.fg(status[1], status[2]);
216
218
  }
217
219
 
@@ -263,13 +265,14 @@ function formatOpRow(theme, op, width, isPartial, isError) {
263
265
  const duration = theme.fg("dim", durationText.padStart(DURATION_COL));
264
266
  const exit = appendExit(theme, op);
265
267
  const counts = appendDiffCounts(theme, op);
266
- const outcome = op.mutationAttempt ? "attempted " : "";
267
- const prefix = `${marker} ${tool} ${duration} ` + exit.text + counts.text + theme.fg("warning", outcome);
268
+ const outcome = op.mutationState ? op.mutationState + " " : op.mutationAttempt ? "attempted " : "";
269
+ const prefix = `${marker} ${tool} ${duration} ` + exit.text + counts.text + theme.fg(op.mutationState === "saved" ? "success" : "warning", outcome);
268
270
  const used = 2 + toolText.length + 1 + DURATION_COL + 2 + exit.width + counts.width + outcome.length;
269
271
 
270
272
  return opRowSuffix(theme, op, prefix, Math.max(1, width - used));
271
273
  }
272
- export { formatDiffRows, operationsFromTrace, formatDuration, formatOpRow, cleanBlockText };
274
+
275
+ export { formatDiffRows, operationsFromTrace, formatDuration, formatOpRow, cleanBlockText, cleanInlineText };
273
276
 
274
277
  const DIFF_STYLES = new Map([
275
278
  ["remove", ["-", "toolDiffRemoved", "toolDiffRemoved"]],