pi-supernova 0.0.1 → 0.0.3
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/CHANGELOG.md +16 -0
- package/catalog.js +78 -0
- package/index.js +6 -4
- package/omp-frame.js +234 -0
- package/package.json +3 -1
- package/render-measure.js +143 -0
- package/render.js +344 -204
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Native adapters are now included in `nova.search` / `nova.describe` with schemas matching the callable adapter, even when the host catalog omits parameters or the adapter name.
|
|
8
|
+
|
|
9
|
+
## [0.0.3] - 2026-09-03
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- OMP TUI: nova cards now use the same rounded `framedBlock` + status-line chrome as native write/edit (Pi keeps the muted violet wash).
|
|
14
|
+
|
|
15
|
+
## [0.0.2] - 2026-09-03
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- OMP TUI: accept `(args, options, theme)` render signature so the custom nova card shows instead of the raw JSON args dump.
|
|
20
|
+
|
|
5
21
|
## [0.0.1] - 2026-09-03
|
|
6
22
|
|
|
7
23
|
### Added
|
package/catalog.js
CHANGED
|
@@ -1,6 +1,84 @@
|
|
|
1
1
|
|
|
2
2
|
import { isString, isObject } from "./decode.js";
|
|
3
3
|
|
|
4
|
+
export const NATIVE_TOOL_DEFINITIONS = [
|
|
5
|
+
{
|
|
6
|
+
name: "read",
|
|
7
|
+
description: "Read UTF-8 workspace files by path, or resolve a concept query to source. Supports path arrays, offset, and limit.",
|
|
8
|
+
parameters: { type: "object", properties: {
|
|
9
|
+
path: { type: "string", description: "Workspace-relative file path or concept query" },
|
|
10
|
+
target: { anyOf: [{ type: "string" }, { type: "array" }], description: "File path/query or array of paths" },
|
|
11
|
+
offset: { type: "number", description: "One-based starting line" },
|
|
12
|
+
limit: { type: "number", description: "Maximum lines to return" },
|
|
13
|
+
} },
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: "write", description: "Write UTF-8 content to a workspace file.",
|
|
17
|
+
parameters: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "edit", description: "Apply unique text replacements, or a unified diff, to a workspace file.",
|
|
21
|
+
parameters: { type: "object", properties: {
|
|
22
|
+
path: { type: "string" }, oldText: { type: "string" }, newText: { type: "string" }, edits: { type: "array" }, patch: { type: "string" },
|
|
23
|
+
}, required: ["path"] },
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "apply_patch", description: "Apply a unified diff to one workspace file.",
|
|
27
|
+
parameters: { type: "object", properties: { path: { type: "string" }, patch: { type: "string" } }, required: ["patch"] },
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "snap", description: "Resolve a concept query to the most relevant workspace source location.",
|
|
31
|
+
parameters: { type: "object", properties: { query: { type: "string" }, path: { type: "string" } }, required: ["query"] },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "surface", description: "Extract a structural outline from a workspace source file.",
|
|
35
|
+
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "bash", description: "Run a shell command inside the workspace and capture bounded output.",
|
|
39
|
+
parameters: { type: "object", properties: { command: { type: "string" }, cwd: { type: "string" }, timeoutMs: { type: "number" } }, required: ["command"] },
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "grep", description: "Search workspace file contents by pattern.",
|
|
43
|
+
parameters: { type: "object", properties: {
|
|
44
|
+
pattern: { type: "string" }, path: { type: "string" }, glob: { type: "string" }, caseSensitive: { type: "boolean" },
|
|
45
|
+
}, required: ["pattern"] },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "glob", description: "List workspace files matching a glob pattern.",
|
|
49
|
+
parameters: { type: "object", properties: { pattern: { type: "string" } }, required: ["pattern"] },
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "find", description: "List workspace files, optionally constrained by path and pattern.",
|
|
53
|
+
parameters: { type: "object", properties: { path: { type: "string" }, pattern: { type: "string" }, glob: { type: "string" } } },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "ls", description: "List direct entries in a workspace directory.",
|
|
57
|
+
parameters: { type: "object", properties: { path: { type: "string" } } },
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
export function mergeNativeToolDefinitions(tools, capturedNames = []) {
|
|
62
|
+
const nativeByName = new Map(NATIVE_TOOL_DEFINITIONS.map((tool) => [tool.name, tool]));
|
|
63
|
+
const captured = new Set(capturedNames);
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
const merged = [];
|
|
66
|
+
|
|
67
|
+
for (const tool of tools || []) {
|
|
68
|
+
const fallback = nativeByName.get(tool?.name);
|
|
69
|
+
merged.push(fallback && !captured.has(tool.name)
|
|
70
|
+
? { ...tool, ...fallback, sourceInfo: { path: "<native:" + tool.name + ">" } }
|
|
71
|
+
: tool);
|
|
72
|
+
if (tool?.name) seen.add(tool.name);
|
|
73
|
+
}
|
|
74
|
+
for (const fallback of NATIVE_TOOL_DEFINITIONS) {
|
|
75
|
+
if (!seen.has(fallback.name)) {
|
|
76
|
+
merged.push({ ...fallback, sourceInfo: { path: "<native:" + fallback.name + ">" } });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return merged;
|
|
80
|
+
}
|
|
81
|
+
|
|
4
82
|
function normalizeTool(tool) {
|
|
5
83
|
if (!tool || !isObject(tool)) return null;
|
|
6
84
|
const name = isString(tool.name) ? tool.name : "";
|
package/index.js
CHANGED
|
@@ -12,7 +12,7 @@ try {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
import { isString, isFunction } from "./decode.js";
|
|
15
|
-
import { buildCatalog, searchCatalog, describeTool } from "./catalog.js";
|
|
15
|
+
import { buildCatalog, searchCatalog, describeTool, mergeNativeToolDefinitions } from "./catalog.js";
|
|
16
16
|
import { loadConfig } from "./config.js";
|
|
17
17
|
import { createHostBridge } from "./host-bridge.js";
|
|
18
18
|
import { runGuestProgram } from "./runtime.js";
|
|
@@ -61,7 +61,8 @@ export default function piSupernova(pi) {
|
|
|
61
61
|
} catch {
|
|
62
62
|
tools = [];
|
|
63
63
|
}
|
|
64
|
-
|
|
64
|
+
const discoverable = mergeNativeToolDefinitions(tools, bridge.executors.keys());
|
|
65
|
+
catalog = buildCatalog(discoverable, config.excludeTools || []);
|
|
65
66
|
return catalog;
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -127,9 +128,10 @@ export default function piSupernova(pi) {
|
|
|
127
128
|
}),
|
|
128
129
|
),
|
|
129
130
|
}),
|
|
130
|
-
// "self" = we
|
|
131
|
-
//
|
|
131
|
+
// "self" = we own chrome. OMP uses native framedBlock (write/edit look);
|
|
132
|
+
// Pi keeps the muted violet SafeText wash. "default" falls back to raw JSON.
|
|
132
133
|
renderShell: "self",
|
|
134
|
+
mergeCallAndResult: true,
|
|
133
135
|
renderCall: renderSupernovaCall,
|
|
134
136
|
renderResult: renderSupernovaResult,
|
|
135
137
|
async execute(_id, params, signal, onUpdate, ctx) {
|
package/omp-frame.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OMP-native tool chrome for supernova.
|
|
3
|
+
*
|
|
4
|
+
* Prefer host `framedBlock` + `renderStatusLine` from
|
|
5
|
+
* `@oh-my-pi/pi-coding-agent/tui` (same path read/write/edit use). Fall back to a
|
|
6
|
+
* portable rounded frame that matches that look when the import is unavailable
|
|
7
|
+
* (Pi path-install / unit tests).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { clampLine, measureWidth, wrapPlainToWidth } from "./render-measure.js";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_BOX = {
|
|
13
|
+
topLeft: "╭",
|
|
14
|
+
topRight: "╮",
|
|
15
|
+
bottomLeft: "╰",
|
|
16
|
+
bottomRight: "╯",
|
|
17
|
+
horizontal: "─",
|
|
18
|
+
vertical: "│",
|
|
19
|
+
teeLeft: "┤",
|
|
20
|
+
teeRight: "├",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let hostFramedBlock = null;
|
|
24
|
+
let hostRenderStatusLine = null;
|
|
25
|
+
let hostMarkFramed = null;
|
|
26
|
+
let hostResolved = false;
|
|
27
|
+
|
|
28
|
+
export async function ensureOmpChrome() {
|
|
29
|
+
if (hostResolved) return;
|
|
30
|
+
hostResolved = true;
|
|
31
|
+
for (const spec of ["@oh-my-pi/pi-coding-agent/tui", "@oh-my-pi/pi-coding-agent/tui/index.js"]) {
|
|
32
|
+
try {
|
|
33
|
+
const tui = await import(spec);
|
|
34
|
+
if (typeof tui.framedBlock === "function") hostFramedBlock = tui.framedBlock;
|
|
35
|
+
if (typeof tui.renderStatusLine === "function") hostRenderStatusLine = tui.renderStatusLine;
|
|
36
|
+
if (typeof tui.markFramedBlockComponent === "function") hostMarkFramed = tui.markFramedBlockComponent;
|
|
37
|
+
if (hostFramedBlock && hostRenderStatusLine) return;
|
|
38
|
+
} catch {
|
|
39
|
+
// Pi / tests / plain node — use portable frame.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Kick off resolution at module load (extension hosts support top-level await). */
|
|
45
|
+
await ensureOmpChrome();
|
|
46
|
+
|
|
47
|
+
export function hasHostFramedBlock() {
|
|
48
|
+
return typeof hostFramedBlock === "function" && typeof hostRenderStatusLine === "function";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function boxOf(theme) {
|
|
52
|
+
const b = theme?.boxRound;
|
|
53
|
+
if (b && b.topLeft && b.horizontal && b.vertical) return b;
|
|
54
|
+
return DEFAULT_BOX;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function borderPaint(theme, state, borderColor) {
|
|
58
|
+
const key =
|
|
59
|
+
borderColor ||
|
|
60
|
+
(state === "error" ? "error" : state === "warning" ? "warning" : state === "running" || state === "pending" ? "accent" : "dim");
|
|
61
|
+
if (theme && typeof theme.fg === "function") {
|
|
62
|
+
try {
|
|
63
|
+
return (text) => theme.fg(key, text);
|
|
64
|
+
} catch {
|
|
65
|
+
/* fall through */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return (text) => text;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function statusHeader(theme, { title, description, state, spinnerFrame, icon, iconOverride }) {
|
|
72
|
+
// Match native Write/Edit: framed call heads omit the pending hourglass; only
|
|
73
|
+
// pass an icon when the caller sets one (error / running / success glyph).
|
|
74
|
+
const resolvedIcon =
|
|
75
|
+
iconOverride !== undefined
|
|
76
|
+
? undefined
|
|
77
|
+
: icon !== undefined
|
|
78
|
+
? icon
|
|
79
|
+
: state === "error"
|
|
80
|
+
? "error"
|
|
81
|
+
: undefined;
|
|
82
|
+
if (hostRenderStatusLine) {
|
|
83
|
+
return hostRenderStatusLine(
|
|
84
|
+
{
|
|
85
|
+
icon: resolvedIcon,
|
|
86
|
+
iconOverride,
|
|
87
|
+
spinnerFrame,
|
|
88
|
+
title,
|
|
89
|
+
description,
|
|
90
|
+
},
|
|
91
|
+
theme,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const titleText = theme?.fg ? theme.fg("accent", title) : title;
|
|
95
|
+
const descText = description ? (theme?.fg ? theme.fg("muted", description) : description) : "";
|
|
96
|
+
const prefix = state === "error" ? (theme?.fg ? theme.fg("error", "✗ ") : "✗ ") : "";
|
|
97
|
+
return descText ? `${prefix}${titleText}: ${descText}` : `${prefix}${titleText}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function padLine(line, width, bgFn) {
|
|
101
|
+
const w = Math.max(0, width | 0);
|
|
102
|
+
const vis = measureWidth(line);
|
|
103
|
+
const pad = Math.max(0, w - vis);
|
|
104
|
+
const padded = line + " ".repeat(pad);
|
|
105
|
+
return bgFn ? bgFn(padded) : padded;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function bgFnForState(theme, state) {
|
|
109
|
+
if (!state || !theme) return undefined;
|
|
110
|
+
if (typeof theme.bg === "function") {
|
|
111
|
+
const key =
|
|
112
|
+
state === "error" ? "toolErrorBg" : state === "pending" || state === "running" ? "toolPendingBg" : "toolSuccessBg";
|
|
113
|
+
try {
|
|
114
|
+
const probe = theme.bg(key, "x");
|
|
115
|
+
if (typeof probe !== "string") return undefined;
|
|
116
|
+
return (text) => {
|
|
117
|
+
const painted = theme.bg(key, text);
|
|
118
|
+
return typeof painted === "string" ? painted : text;
|
|
119
|
+
};
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (typeof theme.getBgAnsi === "function") {
|
|
125
|
+
try {
|
|
126
|
+
const key =
|
|
127
|
+
state === "error" ? "toolErrorBg" : state === "pending" || state === "running" ? "toolPendingBg" : "toolSuccessBg";
|
|
128
|
+
const ansi = theme.getBgAnsi(key);
|
|
129
|
+
if (!ansi) return undefined;
|
|
130
|
+
return (text) => `${ansi}${text}\x1b[49m`;
|
|
131
|
+
} catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Portable rounded frame matching OMP output-block geometry.
|
|
140
|
+
*/
|
|
141
|
+
export function renderPortableFrame(theme, { header, sections = [], state = "pending", borderColor, width }) {
|
|
142
|
+
const w = Math.max(8, width | 0);
|
|
143
|
+
const box = boxOf(theme);
|
|
144
|
+
const border = borderPaint(theme, state, borderColor);
|
|
145
|
+
const bgFn = bgFnForState(theme, state);
|
|
146
|
+
const h = box.horizontal;
|
|
147
|
+
const v = box.vertical;
|
|
148
|
+
const cap = h.repeat(3);
|
|
149
|
+
|
|
150
|
+
const paintBar = (leftChar, rightChar, label) => {
|
|
151
|
+
const left = `${leftChar}${cap}`;
|
|
152
|
+
const right = rightChar;
|
|
153
|
+
if (!label) {
|
|
154
|
+
const fill = Math.max(0, w - measureWidth(left) - measureWidth(right));
|
|
155
|
+
return padLine(`${border(left)}${border(h.repeat(fill))}${border(right)}`, w, bgFn);
|
|
156
|
+
}
|
|
157
|
+
const rawLabel = ` ${label} `;
|
|
158
|
+
const maxLabel = Math.max(0, w - measureWidth(left) - measureWidth(right));
|
|
159
|
+
const trimmed = clampLine(rawLabel, maxLabel);
|
|
160
|
+
const fill = Math.max(0, w - measureWidth(left) - measureWidth(trimmed) - measureWidth(right));
|
|
161
|
+
return padLine(`${border(left)}${trimmed}${border(h.repeat(fill))}${border(right)}`, w, bgFn);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const contentWidth = Math.max(1, w - 2 - 2); // borders + 1-col pad each side
|
|
165
|
+
const lines = [];
|
|
166
|
+
lines.push(paintBar(box.topLeft, box.topRight, header));
|
|
167
|
+
|
|
168
|
+
const normalized = sections.length > 0 ? sections : [{ lines: [] }];
|
|
169
|
+
for (const section of normalized) {
|
|
170
|
+
if (section.label) {
|
|
171
|
+
lines.push(paintBar(box.teeRight || "├", box.teeLeft || "┤", section.label));
|
|
172
|
+
}
|
|
173
|
+
for (const raw of section.lines || []) {
|
|
174
|
+
for (const piece of String(raw).split("\n")) {
|
|
175
|
+
const body = clampLine(piece, contentWidth);
|
|
176
|
+
const pad = Math.max(0, contentWidth - measureWidth(body));
|
|
177
|
+
const inner = `${body}${" ".repeat(pad)}`;
|
|
178
|
+
lines.push(padLine(`${border(v)} ${inner} ${border(v)}`, w, bgFn));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
lines.push(paintBar(box.bottomLeft, box.bottomRight, null));
|
|
184
|
+
return lines;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function createPortableFramedComponent(theme, build) {
|
|
188
|
+
let cacheWidth;
|
|
189
|
+
let cacheKey;
|
|
190
|
+
let cacheLines;
|
|
191
|
+
const comp = {
|
|
192
|
+
render(width) {
|
|
193
|
+
const opts = build(width);
|
|
194
|
+
const key = `${opts.state}|${opts.borderColor}|${opts.header}|${(opts.sections || [])
|
|
195
|
+
.map((s) => (s.lines || []).join("\n"))
|
|
196
|
+
.join("||")}`;
|
|
197
|
+
if (cacheLines && cacheWidth === width && cacheKey === key) return cacheLines;
|
|
198
|
+
cacheLines = renderPortableFrame(theme, opts);
|
|
199
|
+
cacheWidth = width;
|
|
200
|
+
cacheKey = key;
|
|
201
|
+
return cacheLines;
|
|
202
|
+
},
|
|
203
|
+
invalidate() {
|
|
204
|
+
cacheLines = undefined;
|
|
205
|
+
cacheKey = undefined;
|
|
206
|
+
cacheWidth = undefined;
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
if (hostMarkFramed) return hostMarkFramed(comp);
|
|
210
|
+
return comp;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Build a framed nova card. Uses host framedBlock when available (OMP).
|
|
215
|
+
*/
|
|
216
|
+
export function novaFramedBlock(theme, build) {
|
|
217
|
+
if (hostFramedBlock) {
|
|
218
|
+
return hostFramedBlock(theme, build);
|
|
219
|
+
}
|
|
220
|
+
return createPortableFramedComponent(theme, build);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function novaStatusLine(theme, options) {
|
|
224
|
+
return statusHeader(theme, options);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function wrapPlainLines(text, width) {
|
|
228
|
+
const w = Math.max(1, width | 0);
|
|
229
|
+
const out = [];
|
|
230
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
231
|
+
for (const chunk of wrapPlainToWidth(line, w)) out.push(chunk);
|
|
232
|
+
}
|
|
233
|
+
return out.length ? out : [""];
|
|
234
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-supernova",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Dual-host CodeMode for Pi/OMP: progressive tool discovery, result bottleneck, and Amdahl Auto parallel.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AdityaVG13",
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
"snap.js",
|
|
38
38
|
"diff.js",
|
|
39
39
|
"render.js",
|
|
40
|
+
"render-measure.js",
|
|
41
|
+
"omp-frame.js",
|
|
40
42
|
"surface.js",
|
|
41
43
|
"decode.js"
|
|
42
44
|
],
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Width / truncate primitives shared by render.js and omp-frame.js.
|
|
3
|
+
* Self-contained so path-install never depends on a host truncate that can
|
|
4
|
+
* append ellipsis after cutting to maxWidth (Pi 92>91 crash class).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { stripVTControlCharacters } from "node:util";
|
|
8
|
+
|
|
9
|
+
const ELLIPSIS = "…";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Visible columns — ANSI/OSC stripped, tabs → 3 spaces.
|
|
13
|
+
* ASCII-fast; non-ASCII uses a wide-char heuristic aligned with typical terminal
|
|
14
|
+
* / pi-tui behavior (emoji & symbols like ⚡ are 2 cols — undercount ⇒ 92>91 crash).
|
|
15
|
+
*/
|
|
16
|
+
export function measureWidth(text) {
|
|
17
|
+
const raw = String(text ?? "").replace(/\t/g, " ");
|
|
18
|
+
if (raw.length === 0) return 0;
|
|
19
|
+
const plain = raw.includes("\x1b") ? stripVTControlCharacters(raw) : raw;
|
|
20
|
+
if (/^[\x20-\x7e]*$/.test(plain)) return plain.length;
|
|
21
|
+
let width = 0;
|
|
22
|
+
for (const ch of plain) {
|
|
23
|
+
width += codePointWidth(ch.codePointAt(0));
|
|
24
|
+
}
|
|
25
|
+
return width;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function codePointWidth(cp) {
|
|
29
|
+
if (cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f)) return 0;
|
|
30
|
+
// Fullwidth / wide ranges (CJK, Hangul, emoji blocks we actually emit).
|
|
31
|
+
if (cp >= 0x1100 && cp <= 0x115f) return 2;
|
|
32
|
+
if (cp === 0x2329 || cp === 0x232a) return 2;
|
|
33
|
+
if (cp >= 0x2e80 && cp <= 0xa4cf) return 2;
|
|
34
|
+
if (cp >= 0xac00 && cp <= 0xd7a3) return 2;
|
|
35
|
+
if (cp >= 0xf900 && cp <= 0xfaff) return 2;
|
|
36
|
+
if (cp >= 0xfe10 && cp <= 0xfe19) return 2;
|
|
37
|
+
if (cp >= 0xfe30 && cp <= 0xfe6f) return 2;
|
|
38
|
+
if (cp >= 0xff00 && cp <= 0xff60) return 2;
|
|
39
|
+
if (cp >= 0xffe0 && cp <= 0xffe6) return 2;
|
|
40
|
+
if (cp >= 0x1f300 && cp <= 0x1f64f) return 2;
|
|
41
|
+
if (cp >= 0x1f900 && cp <= 0x1f9ff) return 2;
|
|
42
|
+
if (cp >= 0x20000 && cp <= 0x3fffd) return 2;
|
|
43
|
+
// Ambiguous emoji/symbols pi-tui treats as wide (⚡ U+26A1 was the 92>91 footgun).
|
|
44
|
+
if (cp === 0x26a1 || cp === 0x2b50 || cp === 0x2728) return 2;
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Truncate so the result's visible width is ALWAYS ≤ maxWidth, ellipsis included.
|
|
50
|
+
* Strips ANSI in the truncated region (crash-safety > color fidelity on overflow).
|
|
51
|
+
*/
|
|
52
|
+
export function hardTruncate(text, maxWidth, ellipsis = ELLIPSIS) {
|
|
53
|
+
const w = Math.max(0, maxWidth | 0);
|
|
54
|
+
if (w === 0) return "";
|
|
55
|
+
const raw = String(text ?? "").replace(/\t/g, " ");
|
|
56
|
+
if (measureWidth(raw) <= w) return raw;
|
|
57
|
+
|
|
58
|
+
const ell = String(ellipsis);
|
|
59
|
+
const ellW = measureWidth(ell);
|
|
60
|
+
if (ellW >= w) {
|
|
61
|
+
if (ellW === 0) return "";
|
|
62
|
+
return ell.slice(0, w);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const budget = w - ellW;
|
|
66
|
+
const plain = stripVTControlCharacters(raw);
|
|
67
|
+
let out = "";
|
|
68
|
+
let visible = 0;
|
|
69
|
+
for (const ch of plain) {
|
|
70
|
+
const cw = measureWidth(ch);
|
|
71
|
+
if (visible + cw > budget) break;
|
|
72
|
+
out += ch;
|
|
73
|
+
visible += cw;
|
|
74
|
+
}
|
|
75
|
+
return out + ell;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Absolute clamp used by every renderer. Loops + hard truncate; never returns > width.
|
|
80
|
+
*/
|
|
81
|
+
export function clampLine(line, width) {
|
|
82
|
+
const w = Math.max(1, width | 0);
|
|
83
|
+
let out = String(line ?? "").replace(/\t/g, " ");
|
|
84
|
+
if (measureWidth(out) <= w) return out;
|
|
85
|
+
out = hardTruncate(out, w, ELLIPSIS);
|
|
86
|
+
if (measureWidth(out) <= w) return out;
|
|
87
|
+
const plain = stripVTControlCharacters(out);
|
|
88
|
+
if (plain.length <= w) return plain;
|
|
89
|
+
if (w === 1) return ELLIPSIS;
|
|
90
|
+
return plain.slice(0, Math.max(0, w - 1)) + ELLIPSIS;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Wrap plain text to width, preferring breaks after `/` or space so paths stay readable.
|
|
95
|
+
* Every returned chunk is ≤ width (no ellipsis — caller clamps if needed).
|
|
96
|
+
*/
|
|
97
|
+
export function wrapPlainToWidth(plain, width) {
|
|
98
|
+
const w = Math.max(1, width | 0);
|
|
99
|
+
const text = String(plain ?? "");
|
|
100
|
+
if (text.length === 0) return [""];
|
|
101
|
+
if (measureWidth(text) <= w) return [text];
|
|
102
|
+
|
|
103
|
+
const lines = [];
|
|
104
|
+
let i = 0;
|
|
105
|
+
while (i < text.length) {
|
|
106
|
+
let end = i;
|
|
107
|
+
let visible = 0;
|
|
108
|
+
let lastBreak = -1;
|
|
109
|
+
while (end < text.length) {
|
|
110
|
+
const ch = text[end];
|
|
111
|
+
const cw = measureWidth(ch);
|
|
112
|
+
if (visible + cw > w) break;
|
|
113
|
+
visible += cw;
|
|
114
|
+
if (ch === "/" || ch === " ") lastBreak = end + 1;
|
|
115
|
+
end++;
|
|
116
|
+
}
|
|
117
|
+
if (end === i) {
|
|
118
|
+
end = i + 1;
|
|
119
|
+
} else if (end < text.length && lastBreak > i + Math.floor(w * 0.35)) {
|
|
120
|
+
end = lastBreak;
|
|
121
|
+
}
|
|
122
|
+
lines.push(text.slice(i, end));
|
|
123
|
+
i = end;
|
|
124
|
+
}
|
|
125
|
+
return lines.length > 0 ? lines : [""];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fit a filesystem path into `budget` columns, keeping the basename visible.
|
|
130
|
+
*/
|
|
131
|
+
export function fitPath(pathText, budget) {
|
|
132
|
+
const w = Math.max(1, budget | 0);
|
|
133
|
+
let p = String(pathText ?? "").replace(/\\/g, "/");
|
|
134
|
+
if (measureWidth(p) <= w) return p;
|
|
135
|
+
|
|
136
|
+
const parts = p.split("/").filter(Boolean);
|
|
137
|
+
const base = parts.length > 0 ? parts[parts.length - 1] : p;
|
|
138
|
+
const suffix = parts.length > 1 ? `…/${base}` : base;
|
|
139
|
+
if (measureWidth(suffix) <= w) return suffix;
|
|
140
|
+
return hardTruncate(base, w);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export { ELLIPSIS };
|
package/render.js
CHANGED
|
@@ -5,150 +5,23 @@
|
|
|
5
5
|
* (classic failure: 92 > 91). Path-install often cannot resolve @earendil-works/pi-tui,
|
|
6
6
|
* so every width/truncate path here is self-contained and must never trust a host
|
|
7
7
|
* truncate that appends ellipsis after cutting to maxWidth.
|
|
8
|
+
*
|
|
9
|
+
* OMP path uses rounded framedBlock chrome (same as native write/edit). Pi path
|
|
10
|
+
* keeps the muted violet SafeText wash (no side rails).
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
import { stripVTControlCharacters } from "node:util";
|
|
11
14
|
import { isString, isObject } from "./decode.js";
|
|
15
|
+
import {
|
|
16
|
+
measureWidth,
|
|
17
|
+
hardTruncate,
|
|
18
|
+
clampLine,
|
|
19
|
+
wrapPlainToWidth,
|
|
20
|
+
fitPath,
|
|
21
|
+
} from "./render-measure.js";
|
|
22
|
+
import { ensureOmpChrome, hasHostFramedBlock, novaFramedBlock, novaStatusLine } from "./omp-frame.js";
|
|
12
23
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Visible columns — ANSI/OSC stripped, tabs → 3 spaces.
|
|
17
|
-
* ASCII-fast; non-ASCII uses a wide-char heuristic aligned with typical terminal
|
|
18
|
-
* / pi-tui behavior (emoji & symbols like ⚡ are 2 cols — undercount ⇒ 92>91 crash).
|
|
19
|
-
*/
|
|
20
|
-
export function measureWidth(text) {
|
|
21
|
-
const raw = String(text ?? "").replace(/\t/g, " ");
|
|
22
|
-
if (raw.length === 0) return 0;
|
|
23
|
-
const plain = raw.includes("\x1b") ? stripVTControlCharacters(raw) : raw;
|
|
24
|
-
if (/^[\x20-\x7e]*$/.test(plain)) return plain.length;
|
|
25
|
-
let width = 0;
|
|
26
|
-
for (const ch of plain) {
|
|
27
|
-
width += codePointWidth(ch.codePointAt(0));
|
|
28
|
-
}
|
|
29
|
-
return width;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function codePointWidth(cp) {
|
|
33
|
-
if (cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f)) return 0;
|
|
34
|
-
// Fullwidth / wide ranges (CJK, Hangul, emoji blocks we actually emit).
|
|
35
|
-
if (cp >= 0x1100 && cp <= 0x115f) return 2;
|
|
36
|
-
if (cp === 0x2329 || cp === 0x232a) return 2;
|
|
37
|
-
if (cp >= 0x2e80 && cp <= 0xa4cf) return 2;
|
|
38
|
-
if (cp >= 0xac00 && cp <= 0xd7a3) return 2;
|
|
39
|
-
if (cp >= 0xf900 && cp <= 0xfaff) return 2;
|
|
40
|
-
if (cp >= 0xfe10 && cp <= 0xfe19) return 2;
|
|
41
|
-
if (cp >= 0xfe30 && cp <= 0xfe6f) return 2;
|
|
42
|
-
if (cp >= 0xff00 && cp <= 0xff60) return 2;
|
|
43
|
-
if (cp >= 0xffe0 && cp <= 0xffe6) return 2;
|
|
44
|
-
if (cp >= 0x1f300 && cp <= 0x1f64f) return 2;
|
|
45
|
-
if (cp >= 0x1f900 && cp <= 0x1f9ff) return 2;
|
|
46
|
-
if (cp >= 0x20000 && cp <= 0x3fffd) return 2;
|
|
47
|
-
// Ambiguous emoji/symbols pi-tui treats as wide (⚡ U+26A1 was the 92>91 footgun).
|
|
48
|
-
if (cp === 0x26a1 || cp === 0x2b50 || cp === 0x2728) return 2;
|
|
49
|
-
return 1;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Truncate so the result's visible width is ALWAYS ≤ maxWidth, ellipsis included.
|
|
54
|
-
* Strips ANSI in the truncated region (crash-safety > color fidelity on overflow).
|
|
55
|
-
*/
|
|
56
|
-
export function hardTruncate(text, maxWidth, ellipsis = ELLIPSIS) {
|
|
57
|
-
const w = Math.max(0, maxWidth | 0);
|
|
58
|
-
if (w === 0) return "";
|
|
59
|
-
const raw = String(text ?? "").replace(/\t/g, " ");
|
|
60
|
-
if (measureWidth(raw) <= w) return raw;
|
|
61
|
-
|
|
62
|
-
const ell = String(ellipsis);
|
|
63
|
-
const ellW = measureWidth(ell);
|
|
64
|
-
if (ellW >= w) {
|
|
65
|
-
// Degenerate: return as many ellipsis columns as fit.
|
|
66
|
-
if (ellW === 0) return "";
|
|
67
|
-
return ell.slice(0, w);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const budget = w - ellW;
|
|
71
|
-
const plain = stripVTControlCharacters(raw);
|
|
72
|
-
let out = "";
|
|
73
|
-
let visible = 0;
|
|
74
|
-
for (const ch of plain) {
|
|
75
|
-
const cw = measureWidth(ch);
|
|
76
|
-
if (visible + cw > budget) break;
|
|
77
|
-
out += ch;
|
|
78
|
-
visible += cw;
|
|
79
|
-
}
|
|
80
|
-
return out + ell;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Absolute clamp used by every renderer. Loops + hard truncate; never returns > width.
|
|
85
|
-
* Exported for tests and as the single choke point for the Pi crash contract.
|
|
86
|
-
*/
|
|
87
|
-
export function clampLine(line, width) {
|
|
88
|
-
const w = Math.max(1, width | 0);
|
|
89
|
-
let out = String(line ?? "").replace(/\t/g, " ");
|
|
90
|
-
if (measureWidth(out) <= w) return out;
|
|
91
|
-
out = hardTruncate(out, w, ELLIPSIS);
|
|
92
|
-
// Belt-and-suspenders: if anything still disagrees, force plain slice.
|
|
93
|
-
if (measureWidth(out) <= w) return out;
|
|
94
|
-
const plain = stripVTControlCharacters(out);
|
|
95
|
-
if (plain.length <= w) return plain;
|
|
96
|
-
if (w === 1) return ELLIPSIS;
|
|
97
|
-
return plain.slice(0, Math.max(0, w - 1)) + ELLIPSIS;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Wrap plain text to width, preferring breaks after `/` or space so paths stay readable.
|
|
102
|
-
* Every returned chunk is ≤ width (no ellipsis — caller clamps if needed).
|
|
103
|
-
*/
|
|
104
|
-
export function wrapPlainToWidth(plain, width) {
|
|
105
|
-
const w = Math.max(1, width | 0);
|
|
106
|
-
const text = String(plain ?? "");
|
|
107
|
-
if (text.length === 0) return [""];
|
|
108
|
-
if (measureWidth(text) <= w) return [text];
|
|
109
|
-
|
|
110
|
-
const lines = [];
|
|
111
|
-
let i = 0;
|
|
112
|
-
while (i < text.length) {
|
|
113
|
-
let end = i;
|
|
114
|
-
let visible = 0;
|
|
115
|
-
let lastBreak = -1;
|
|
116
|
-
while (end < text.length) {
|
|
117
|
-
const ch = text[end];
|
|
118
|
-
const cw = measureWidth(ch);
|
|
119
|
-
if (visible + cw > w) break;
|
|
120
|
-
visible += cw;
|
|
121
|
-
// Only soft-break on path/word separators — never mid-filename (`host-` / `bridge`).
|
|
122
|
-
if (ch === "/" || ch === " ") lastBreak = end + 1;
|
|
123
|
-
end++;
|
|
124
|
-
}
|
|
125
|
-
if (end === i) {
|
|
126
|
-
// Single wide char edge case — force one column advance.
|
|
127
|
-
end = i + 1;
|
|
128
|
-
} else if (end < text.length && lastBreak > i + Math.floor(w * 0.35)) {
|
|
129
|
-
end = lastBreak;
|
|
130
|
-
}
|
|
131
|
-
lines.push(text.slice(i, end));
|
|
132
|
-
i = end;
|
|
133
|
-
}
|
|
134
|
-
return lines.length > 0 ? lines : [""];
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Fit a filesystem path into `budget` columns, keeping the basename visible.
|
|
139
|
-
* `packages/pi-supernova/host-bridge.js` → `…/host-bridge.js` when narrow.
|
|
140
|
-
*/
|
|
141
|
-
export function fitPath(pathText, budget) {
|
|
142
|
-
const w = Math.max(1, budget | 0);
|
|
143
|
-
let p = String(pathText ?? "").replace(/\\/g, "/");
|
|
144
|
-
if (measureWidth(p) <= w) return p;
|
|
145
|
-
|
|
146
|
-
const parts = p.split("/").filter(Boolean);
|
|
147
|
-
const base = parts.length > 0 ? parts[parts.length - 1] : p;
|
|
148
|
-
const suffix = parts.length > 1 ? `…/${base}` : base;
|
|
149
|
-
if (measureWidth(suffix) <= w) return suffix;
|
|
150
|
-
return hardTruncate(base, w);
|
|
151
|
-
}
|
|
24
|
+
export { measureWidth, hardTruncate, clampLine, wrapPlainToWidth, fitPath };
|
|
152
25
|
|
|
153
26
|
function fitOutputLines(text, width) {
|
|
154
27
|
const w = Math.max(1, width | 0);
|
|
@@ -418,13 +291,113 @@ function formatOpTarget(raw, tool) {
|
|
|
418
291
|
return text.replace(/\\/g, "/");
|
|
419
292
|
}
|
|
420
293
|
|
|
421
|
-
|
|
422
|
-
|
|
294
|
+
function isTheme(value) {
|
|
295
|
+
return !!value && typeof value === "object" && typeof value.fg === "function";
|
|
296
|
+
}
|
|
423
297
|
|
|
424
|
-
|
|
298
|
+
/**
|
|
299
|
+
* Dual-host renderCall args:
|
|
300
|
+
* Pi: (args, theme, context)
|
|
301
|
+
* OMP: (args, options/renderState, theme)
|
|
302
|
+
*/
|
|
303
|
+
export function normalizeCallRenderArgs(a, b, c) {
|
|
304
|
+
if (isTheme(b)) {
|
|
305
|
+
const context = c && typeof c === "object" ? c : {};
|
|
306
|
+
if (!context.state || typeof context.state !== "object") context.state = {};
|
|
307
|
+
return { args: a, theme: b, context, host: "pi" };
|
|
308
|
+
}
|
|
309
|
+
if (isTheme(c)) {
|
|
310
|
+
const options = b && typeof b === "object" ? b : {};
|
|
311
|
+
if (!options.state || typeof options.state !== "object") options.state = {};
|
|
312
|
+
const context = {
|
|
313
|
+
...options,
|
|
314
|
+
state: options.state,
|
|
315
|
+
expanded: options.expanded,
|
|
316
|
+
isPartial: options.isPartial,
|
|
317
|
+
executionStarted: options.executionStarted,
|
|
318
|
+
argsComplete: options.argsComplete,
|
|
319
|
+
lastComponent: options.lastComponent,
|
|
320
|
+
invalidate: options.invalidate,
|
|
321
|
+
};
|
|
322
|
+
return { args: a, theme: c, context, host: "omp", options };
|
|
323
|
+
}
|
|
324
|
+
throw new Error("supernova renderCall: theme missing (expected Pi or OMP signature)");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Dual-host renderResult args:
|
|
329
|
+
* Pi: (result, {expanded,isPartial}, theme, context)
|
|
330
|
+
* OMP: (result, {expanded,isPartial}, theme, args) — 4th is args, not context
|
|
331
|
+
*
|
|
332
|
+
* Call shapes share the first three positions, so host is inferred from the 4th
|
|
333
|
+
* arg / whether the OMP tui framedBlock import resolved.
|
|
334
|
+
*/
|
|
335
|
+
function detectResultHost(options, ctxOrArgs) {
|
|
336
|
+
if (isTheme(options)) return "pi";
|
|
337
|
+
if (
|
|
338
|
+
ctxOrArgs &&
|
|
339
|
+
typeof ctxOrArgs === "object" &&
|
|
340
|
+
("lastComponent" in ctxOrArgs || "invalidate" in ctxOrArgs)
|
|
341
|
+
) {
|
|
342
|
+
return "pi";
|
|
343
|
+
}
|
|
344
|
+
if (
|
|
345
|
+
ctxOrArgs &&
|
|
346
|
+
typeof ctxOrArgs === "object" &&
|
|
347
|
+
("code" in ctxOrArgs || "timeoutMs" in ctxOrArgs)
|
|
348
|
+
) {
|
|
349
|
+
return "omp";
|
|
350
|
+
}
|
|
351
|
+
if (hasHostFramedBlock()) return "omp";
|
|
352
|
+
return "pi";
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs) {
|
|
356
|
+
if (isTheme(themeOrCtx)) {
|
|
357
|
+
const opts = options && typeof options === "object" ? options : {};
|
|
358
|
+
let context;
|
|
359
|
+
if (
|
|
360
|
+
ctxOrArgs &&
|
|
361
|
+
typeof ctxOrArgs === "object" &&
|
|
362
|
+
!isTheme(ctxOrArgs) &&
|
|
363
|
+
("lastComponent" in ctxOrArgs || "state" in ctxOrArgs || "invalidate" in ctxOrArgs)
|
|
364
|
+
) {
|
|
365
|
+
context = ctxOrArgs;
|
|
366
|
+
} else {
|
|
367
|
+
context = { ...(opts.state ? { state: opts.state } : {}), lastComponent: opts.lastComponent };
|
|
368
|
+
}
|
|
369
|
+
if (!context.state || typeof context.state !== "object") context.state = {};
|
|
370
|
+
return {
|
|
371
|
+
result,
|
|
372
|
+
expanded: !!opts.expanded,
|
|
373
|
+
isPartial: !!opts.isPartial,
|
|
374
|
+
theme: themeOrCtx,
|
|
375
|
+
context,
|
|
376
|
+
host: detectResultHost(options, ctxOrArgs),
|
|
377
|
+
options: opts,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
// Extremely defensive: (result, theme, context) oddball
|
|
381
|
+
if (isTheme(options)) {
|
|
382
|
+
const context = themeOrCtx && typeof themeOrCtx === "object" ? themeOrCtx : {};
|
|
383
|
+
if (!context.state || typeof context.state !== "object") context.state = {};
|
|
384
|
+
return {
|
|
385
|
+
result,
|
|
386
|
+
expanded: !!context.expanded,
|
|
387
|
+
isPartial: !!context.isPartial,
|
|
388
|
+
theme: options,
|
|
389
|
+
context,
|
|
390
|
+
host: "pi",
|
|
391
|
+
options: {},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
throw new Error("supernova renderResult: theme missing (expected Pi or OMP signature)");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function collectCallOps(args, context) {
|
|
425
398
|
const stateTrace = context?.state?.trace;
|
|
426
399
|
if (Array.isArray(stateTrace) && stateTrace.length > 0) {
|
|
427
|
-
|
|
400
|
+
return stateTrace
|
|
428
401
|
.map((item) => {
|
|
429
402
|
const tool = item?.name || "tool";
|
|
430
403
|
let target = "";
|
|
@@ -434,12 +407,24 @@ export function renderSupernovaCall(args, theme, context) {
|
|
|
434
407
|
return displayOperation(tool, target);
|
|
435
408
|
})
|
|
436
409
|
.filter(Boolean);
|
|
437
|
-
} else {
|
|
438
|
-
ops = extractOperationsFromCode(args?.code)
|
|
439
|
-
.map((op) => displayOperation(op.tool, op.target))
|
|
440
|
-
.filter(Boolean);
|
|
441
410
|
}
|
|
411
|
+
return extractOperationsFromCode(args?.code)
|
|
412
|
+
.map((op) => displayOperation(op.tool, op.target))
|
|
413
|
+
.filter(Boolean);
|
|
414
|
+
}
|
|
442
415
|
|
|
416
|
+
function formatElapsed(state) {
|
|
417
|
+
if (state?.wallMs != null) return `${state.wallMs}ms`;
|
|
418
|
+
if (state?.startedAt != null) {
|
|
419
|
+
const elapsed = Math.round(performance.now() - state.startedAt);
|
|
420
|
+
// Suppress sub-100ms noise on the pending head (matches quiet Write/Edit calls).
|
|
421
|
+
if (elapsed < 100) return "";
|
|
422
|
+
return elapsed >= 1000 ? `${(elapsed / 1000).toFixed(1)}s` : `${elapsed}ms`;
|
|
423
|
+
}
|
|
424
|
+
return "";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function tickCallTimer(context) {
|
|
443
428
|
const state = context?.state;
|
|
444
429
|
if (state && state.startedAt == null) state.startedAt = performance.now();
|
|
445
430
|
if (state && context?.executionStarted && state.wallMs == null && state.timer == null) {
|
|
@@ -448,16 +433,116 @@ export function renderSupernovaCall(args, theme, context) {
|
|
|
448
433
|
context.invalidate?.();
|
|
449
434
|
}, 100);
|
|
450
435
|
}
|
|
436
|
+
}
|
|
451
437
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
438
|
+
function formatOpBodyLine(theme, op) {
|
|
439
|
+
const icon = ACTION_ICONS[op.tool] || "✦ ";
|
|
440
|
+
const bullet = theme.fg("accent", icon);
|
|
441
|
+
const toolName = theme.fg("syntaxFunction", op.tool.padEnd(4, " "));
|
|
442
|
+
const rawTarget = formatOpTarget(op.target, op.tool);
|
|
443
|
+
const target = rawTarget ? " " + theme.fg("muted", rawTarget) : "";
|
|
444
|
+
return `${bullet}${toolName}${target}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function shouldUseOmpFrame(host) {
|
|
448
|
+
return host === "omp" || hasHostFramedBlock();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* OMP write/edit look: rounded framedBlock + status-line header.
|
|
453
|
+
* Pending call has no hourglass on the head row (same as native Write/Edit).
|
|
454
|
+
*/
|
|
455
|
+
function renderOmpCallCard(theme, { ops, timeStr, expanded, code }) {
|
|
456
|
+
const opSummary =
|
|
457
|
+
ops.length === 0
|
|
458
|
+
? "composing"
|
|
459
|
+
: ops.map((op) => op.tool).join(theme.sep?.dot ? ` ${theme.sep.dot} ` : " · ");
|
|
460
|
+
const description = timeStr ? `${opSummary} · ${timeStr}` : opSummary;
|
|
461
|
+
// No pending icon on the framed head row — matches native Write/Edit.
|
|
462
|
+
const header = novaStatusLine(theme, {
|
|
463
|
+
title: "nova",
|
|
464
|
+
description,
|
|
465
|
+
});
|
|
466
|
+
return novaFramedBlock(theme, (width) => {
|
|
467
|
+
const bodyLines = ops.map((op) => formatOpBodyLine(theme, op));
|
|
468
|
+
if (expanded && code) {
|
|
469
|
+
bodyLines.push(theme.fg("dim", "── source ──"));
|
|
470
|
+
for (const line of String(code).trim().split("\n")) {
|
|
471
|
+
bodyLines.push(theme.fg("toolOutput", line));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
header,
|
|
476
|
+
sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
|
|
477
|
+
state: "pending",
|
|
478
|
+
borderColor: "borderMuted",
|
|
479
|
+
width,
|
|
480
|
+
};
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function renderOmpResultCard(theme, { isErr, payload, expanded, bodyText, isPartial, spinnerFrame }) {
|
|
485
|
+
if (isErr) {
|
|
486
|
+
const errLines = [];
|
|
487
|
+
if (payload?.error) errLines.push(theme.fg("error", String(payload.error)));
|
|
488
|
+
if (expanded && payload?.logs?.length) {
|
|
489
|
+
errLines.push(theme.fg("dim", "── logs ──"));
|
|
490
|
+
for (const log of payload.logs) errLines.push(theme.fg("dim", String(log)));
|
|
491
|
+
}
|
|
492
|
+
const header = novaStatusLine(theme, {
|
|
493
|
+
icon: "error",
|
|
494
|
+
title: "nova",
|
|
495
|
+
description: payload?.error ? String(payload.error).split("\n")[0] : "error",
|
|
496
|
+
});
|
|
497
|
+
return novaFramedBlock(theme, (width) => ({
|
|
498
|
+
header,
|
|
499
|
+
sections: errLines.length > 0 ? [{ lines: errLines }] : [],
|
|
500
|
+
state: "error",
|
|
501
|
+
borderColor: "error",
|
|
502
|
+
width,
|
|
503
|
+
}));
|
|
458
504
|
}
|
|
459
505
|
|
|
460
|
-
|
|
506
|
+
const wall = payload?.wallMs != null ? `${payload.wallMs}ms` : "";
|
|
507
|
+
const header = novaStatusLine(theme, {
|
|
508
|
+
icon: isPartial ? "running" : undefined,
|
|
509
|
+
spinnerFrame,
|
|
510
|
+
title: "nova",
|
|
511
|
+
description: wall || undefined,
|
|
512
|
+
});
|
|
513
|
+
const bodyLines = String(bodyText || "").split("\n");
|
|
514
|
+
while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
|
|
515
|
+
while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === "") bodyLines.pop();
|
|
516
|
+
return novaFramedBlock(theme, (width) => ({
|
|
517
|
+
header,
|
|
518
|
+
sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
|
|
519
|
+
state: isPartial ? "pending" : "success",
|
|
520
|
+
borderColor: "borderMuted",
|
|
521
|
+
width,
|
|
522
|
+
}));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function renderSupernovaCall(a, b, c) {
|
|
526
|
+
const { args, theme, context, options, host } = normalizeCallRenderArgs(a, b, c);
|
|
527
|
+
tickCallTimer(context);
|
|
528
|
+
const ops = collectCallOps(args, context);
|
|
529
|
+
const timeStr = formatElapsed(context?.state);
|
|
530
|
+
|
|
531
|
+
if (shouldUseOmpFrame(host)) {
|
|
532
|
+
void ensureOmpChrome();
|
|
533
|
+
return renderOmpCallCard(theme, {
|
|
534
|
+
ops,
|
|
535
|
+
timeStr,
|
|
536
|
+
expanded: !!context?.expanded,
|
|
537
|
+
code: args?.code,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
|
|
542
|
+
if (options) options.lastComponent = comp;
|
|
543
|
+
else if (context) context.lastComponent = comp;
|
|
544
|
+
|
|
545
|
+
// Compact aesthetic — never dump raw JSON args (the stock tool fallback).
|
|
461
546
|
let out = theme.fg("toolTitle", theme.bold("nova"));
|
|
462
547
|
if (timeStr) {
|
|
463
548
|
out += " " + theme.fg("dim", `· ${timeStr}`);
|
|
@@ -467,12 +552,7 @@ export function renderSupernovaCall(args, theme, context) {
|
|
|
467
552
|
out += " " + theme.fg("dim", "· composing");
|
|
468
553
|
} else {
|
|
469
554
|
for (const op of ops) {
|
|
470
|
-
|
|
471
|
-
const bullet = theme.fg("accent", icon);
|
|
472
|
-
const toolName = theme.fg("syntaxFunction", op.tool.padEnd(4, " "));
|
|
473
|
-
const rawTarget = formatOpTarget(op.target, op.tool);
|
|
474
|
-
const target = rawTarget ? " " + theme.fg("muted", rawTarget) : "";
|
|
475
|
-
out += `\n ${bullet}${toolName}${target}`;
|
|
555
|
+
out += `\n ${formatOpBodyLine(theme, op)}`;
|
|
476
556
|
}
|
|
477
557
|
}
|
|
478
558
|
|
|
@@ -489,50 +569,8 @@ export function renderSupernovaCall(args, theme, context) {
|
|
|
489
569
|
return comp;
|
|
490
570
|
}
|
|
491
571
|
|
|
492
|
-
|
|
493
|
-
const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
|
|
494
|
-
|
|
495
|
-
const payload = result?.details;
|
|
496
|
-
if (context?.state && payload) {
|
|
497
|
-
let changed = false;
|
|
498
|
-
if (Array.isArray(payload.trace) && context.state.trace !== payload.trace) {
|
|
499
|
-
context.state.trace = payload.trace;
|
|
500
|
-
changed = true;
|
|
501
|
-
}
|
|
502
|
-
if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) {
|
|
503
|
-
context.state.wallMs = payload.wallMs;
|
|
504
|
-
changed = true;
|
|
505
|
-
}
|
|
506
|
-
if (context.state.timer != null && !isPartial) {
|
|
507
|
-
clearTimeout(context.state.timer);
|
|
508
|
-
context.state.timer = null;
|
|
509
|
-
}
|
|
510
|
-
if (changed) context.invalidate?.();
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
if (isPartial) {
|
|
514
|
-
comp.setText("");
|
|
515
|
-
return comp;
|
|
516
|
-
}
|
|
517
|
-
const isErr = result?.isError || payload?.ok === false;
|
|
518
|
-
|
|
519
|
-
if (isErr) {
|
|
520
|
-
let out = theme.fg("error", "✗ error");
|
|
521
|
-
if (payload?.error) {
|
|
522
|
-
out += `\n ${theme.fg("error", String(payload.error))}`;
|
|
523
|
-
}
|
|
524
|
-
if (expanded && payload?.logs?.length) {
|
|
525
|
-
out += `\n${theme.fg("dim", "── logs ──")}`;
|
|
526
|
-
for (const log of payload.logs) out += `\n ${theme.fg("dim", String(log))}`;
|
|
527
|
-
}
|
|
528
|
-
comp.setTone("error");
|
|
529
|
-
comp.setFraming(true);
|
|
530
|
-
comp.setText(out);
|
|
531
|
-
return comp;
|
|
532
|
-
}
|
|
533
|
-
|
|
572
|
+
function buildResultBody(theme, { payload, context, expanded }) {
|
|
534
573
|
let out = "";
|
|
535
|
-
|
|
536
574
|
const trace = payload?.trace || context?.state?.trace || [];
|
|
537
575
|
const diffs = trace.filter((t) => t?.diff && isObject(t.diff)).map((t) => t.diff);
|
|
538
576
|
|
|
@@ -540,7 +578,6 @@ export function renderSupernovaResult(result, { expanded, isPartial }, theme, co
|
|
|
540
578
|
const maxDiffsShown = expanded ? diffs.length : 2;
|
|
541
579
|
const shownDiffs = diffs.slice(0, maxDiffsShown);
|
|
542
580
|
for (const diff of shownDiffs) {
|
|
543
|
-
// Build unconstrained; SafeText.render(terminalWidth) is the hard clamp.
|
|
544
581
|
const box = renderDiffBox(diff, theme, 120);
|
|
545
582
|
if (box) out += (out ? "\n\n" : "") + box;
|
|
546
583
|
}
|
|
@@ -572,7 +609,110 @@ export function renderSupernovaResult(result, { expanded, isPartial }, theme, co
|
|
|
572
609
|
for (const log of payload.logs) out += `\n ${theme.fg("dim", String(log))}`;
|
|
573
610
|
}
|
|
574
611
|
}
|
|
612
|
+
return out;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextArg) {
|
|
616
|
+
const { result, expanded, isPartial, theme, context, options, host } = normalizeResultRenderArgs(
|
|
617
|
+
resultArg,
|
|
618
|
+
optionsArg,
|
|
619
|
+
themeArg,
|
|
620
|
+
contextArg,
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
const payload = result?.details;
|
|
624
|
+
if (context?.state && payload) {
|
|
625
|
+
let changed = false;
|
|
626
|
+
if (Array.isArray(payload.trace) && context.state.trace !== payload.trace) {
|
|
627
|
+
context.state.trace = payload.trace;
|
|
628
|
+
changed = true;
|
|
629
|
+
}
|
|
630
|
+
if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) {
|
|
631
|
+
context.state.wallMs = payload.wallMs;
|
|
632
|
+
changed = true;
|
|
633
|
+
}
|
|
634
|
+
if (context.state.timer != null && !isPartial) {
|
|
635
|
+
clearTimeout(context.state.timer);
|
|
636
|
+
context.state.timer = null;
|
|
637
|
+
}
|
|
638
|
+
if (changed) context.invalidate?.();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const isErr = result?.isError || payload?.ok === false;
|
|
642
|
+
const useOmp = shouldUseOmpFrame(host);
|
|
643
|
+
|
|
644
|
+
if (useOmp) {
|
|
645
|
+
void ensureOmpChrome();
|
|
646
|
+
if (isPartial && !isErr) {
|
|
647
|
+
// Streaming partials stay quiet until body content exists — same as Pi.
|
|
648
|
+
const partialBody = buildResultBody(theme, { payload, context, expanded });
|
|
649
|
+
if (!partialBody.trim()) {
|
|
650
|
+
return {
|
|
651
|
+
render: () => [],
|
|
652
|
+
invalidate() {},
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
return renderOmpResultCard(theme, {
|
|
656
|
+
isErr: false,
|
|
657
|
+
payload,
|
|
658
|
+
expanded,
|
|
659
|
+
bodyText: partialBody,
|
|
660
|
+
isPartial: true,
|
|
661
|
+
spinnerFrame: options?.spinnerFrame,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
if (isErr) {
|
|
665
|
+
return renderOmpResultCard(theme, {
|
|
666
|
+
isErr: true,
|
|
667
|
+
payload,
|
|
668
|
+
expanded,
|
|
669
|
+
bodyText: "",
|
|
670
|
+
isPartial: false,
|
|
671
|
+
spinnerFrame: options?.spinnerFrame,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
const out = buildResultBody(theme, { payload, context, expanded });
|
|
675
|
+
if (!out.trim()) {
|
|
676
|
+
return {
|
|
677
|
+
render: () => [],
|
|
678
|
+
invalidate() {},
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
return renderOmpResultCard(theme, {
|
|
682
|
+
isErr: false,
|
|
683
|
+
payload,
|
|
684
|
+
expanded,
|
|
685
|
+
bodyText: out,
|
|
686
|
+
isPartial: false,
|
|
687
|
+
spinnerFrame: options?.spinnerFrame,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
|
|
692
|
+
if (options) options.lastComponent = comp;
|
|
693
|
+
else if (context) context.lastComponent = comp;
|
|
694
|
+
|
|
695
|
+
if (isPartial) {
|
|
696
|
+
comp.setText("");
|
|
697
|
+
return comp;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (isErr) {
|
|
701
|
+
let out = theme.fg("error", "✗ error");
|
|
702
|
+
if (payload?.error) {
|
|
703
|
+
out += `\n ${theme.fg("error", String(payload.error))}`;
|
|
704
|
+
}
|
|
705
|
+
if (expanded && payload?.logs?.length) {
|
|
706
|
+
out += `\n${theme.fg("dim", "── logs ──")}`;
|
|
707
|
+
for (const log of payload.logs) out += `\n ${theme.fg("dim", String(log))}`;
|
|
708
|
+
}
|
|
709
|
+
comp.setTone("error");
|
|
710
|
+
comp.setFraming(true);
|
|
711
|
+
comp.setText(out);
|
|
712
|
+
return comp;
|
|
713
|
+
}
|
|
575
714
|
|
|
715
|
+
const out = buildResultBody(theme, { payload, context, expanded });
|
|
576
716
|
if (!out.trim()) {
|
|
577
717
|
comp.setText("");
|
|
578
718
|
return comp;
|