pi-supernova 0.0.1 → 0.0.4
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 +22 -0
- package/catalog.js +78 -0
- package/index.js +21 -17
- package/omp-frame.js +190 -0
- package/package.json +3 -1
- package/render-measure.js +143 -0
- package/render.js +341 -204
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
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.4] - 2026-09-03
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- OMP no longer hangs on "Loading plugins…" — removed top-level import of `@oh-my-pi/pi-coding-agent/tui` from the extension (portable framed chrome only).
|
|
14
|
+
|
|
15
|
+
## [0.0.3] - 2026-09-03
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- OMP TUI: nova cards now use the same rounded `framedBlock` + status-line chrome as native write/edit (Pi keeps the muted violet wash).
|
|
20
|
+
|
|
21
|
+
## [0.0.2] - 2026-09-03
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- OMP TUI: accept `(args, options, theme)` render signature so the custom nova card shows instead of the raw JSON args dump.
|
|
26
|
+
|
|
5
27
|
## [0.0.1] - 2026-09-03
|
|
6
28
|
|
|
7
29
|
### 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
|
@@ -1,18 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
let Type;
|
|
3
|
-
try {
|
|
4
|
-
Type = (await import("typebox")).Type;
|
|
5
|
-
} catch {
|
|
6
|
-
Type = {
|
|
7
|
-
Object: (props, opts) => ({ type: "object", properties: props || {}, additionalProperties: false, ...opts }),
|
|
8
|
-
String: (opts) => ({ type: "string", ...opts }),
|
|
9
|
-
Integer: (opts) => ({ type: "integer", ...opts }),
|
|
10
|
-
Optional: (s) => ({ ...s }),
|
|
11
|
-
};
|
|
12
|
-
}
|
|
13
|
-
|
|
1
|
+
import { createRequire } from "node:module";
|
|
14
2
|
import { isString, isFunction } from "./decode.js";
|
|
15
|
-
import { buildCatalog, searchCatalog, describeTool } from "./catalog.js";
|
|
3
|
+
import { buildCatalog, searchCatalog, describeTool, mergeNativeToolDefinitions } from "./catalog.js";
|
|
16
4
|
import { loadConfig } from "./config.js";
|
|
17
5
|
import { createHostBridge } from "./host-bridge.js";
|
|
18
6
|
import { runGuestProgram } from "./runtime.js";
|
|
@@ -25,6 +13,20 @@ import {
|
|
|
25
13
|
|
|
26
14
|
export { extractOperationsFromCode, renderSupernovaCall, renderSupernovaResult, SafeText };
|
|
27
15
|
|
|
16
|
+
// Sync only — never top-level await. Dynamic import of host/deps hung OMP plugin load.
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
let Type;
|
|
19
|
+
try {
|
|
20
|
+
Type = require("typebox").Type;
|
|
21
|
+
} catch {
|
|
22
|
+
Type = {
|
|
23
|
+
Object: (props, opts) => ({ type: "object", properties: props || {}, additionalProperties: false, ...opts }),
|
|
24
|
+
String: (opts) => ({ type: "string", ...opts }),
|
|
25
|
+
Integer: (opts) => ({ type: "integer", ...opts }),
|
|
26
|
+
Optional: (s) => ({ ...s }),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
28
30
|
function result(text, details) {
|
|
29
31
|
return { content: [{ type: "text", text }], details };
|
|
30
32
|
}
|
|
@@ -61,7 +63,8 @@ export default function piSupernova(pi) {
|
|
|
61
63
|
} catch {
|
|
62
64
|
tools = [];
|
|
63
65
|
}
|
|
64
|
-
|
|
66
|
+
const discoverable = mergeNativeToolDefinitions(tools, bridge.executors.keys());
|
|
67
|
+
catalog = buildCatalog(discoverable, config.excludeTools || []);
|
|
65
68
|
return catalog;
|
|
66
69
|
}
|
|
67
70
|
|
|
@@ -127,9 +130,10 @@ export default function piSupernova(pi) {
|
|
|
127
130
|
}),
|
|
128
131
|
),
|
|
129
132
|
}),
|
|
130
|
-
// "self" = we
|
|
131
|
-
//
|
|
133
|
+
// "self" = we own chrome. OMP uses native framedBlock (write/edit look);
|
|
134
|
+
// Pi keeps the muted violet SafeText wash. "default" falls back to raw JSON.
|
|
132
135
|
renderShell: "self",
|
|
136
|
+
mergeCallAndResult: true,
|
|
133
137
|
renderCall: renderSupernovaCall,
|
|
134
138
|
renderResult: renderSupernovaResult,
|
|
135
139
|
async execute(_id, params, signal, onUpdate, ctx) {
|
package/omp-frame.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OMP-style rounded tool chrome for supernova.
|
|
3
|
+
*
|
|
4
|
+
* Intentionally self-contained — never dynamic-imports `@oh-my-pi/pi-coding-agent`
|
|
5
|
+
* (that hung OMP plugin load). Geometry matches native write/edit framedBlock.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { clampLine, measureWidth, wrapPlainToWidth } from "./render-measure.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_BOX = {
|
|
11
|
+
topLeft: "╭",
|
|
12
|
+
topRight: "╮",
|
|
13
|
+
bottomLeft: "╰",
|
|
14
|
+
bottomRight: "╯",
|
|
15
|
+
horizontal: "─",
|
|
16
|
+
vertical: "│",
|
|
17
|
+
teeLeft: "┤",
|
|
18
|
+
teeRight: "├",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function hasHostFramedBlock() {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function ensureOmpChrome() {
|
|
26
|
+
// No-op: portable frame only. Kept so call sites stay stable.
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function boxOf(theme) {
|
|
30
|
+
const b = theme?.boxRound;
|
|
31
|
+
if (b && b.topLeft && b.horizontal && b.vertical) return b;
|
|
32
|
+
return DEFAULT_BOX;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function borderPaint(theme, state, borderColor) {
|
|
36
|
+
const key =
|
|
37
|
+
borderColor ||
|
|
38
|
+
(state === "error" ? "error" : state === "warning" ? "warning" : state === "running" || state === "pending" ? "accent" : "dim");
|
|
39
|
+
if (theme && typeof theme.fg === "function") {
|
|
40
|
+
try {
|
|
41
|
+
return (text) => theme.fg(key, text);
|
|
42
|
+
} catch {
|
|
43
|
+
/* fall through */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return (text) => text;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function statusHeader(theme, { title, description, state, spinnerFrame, icon, iconOverride }) {
|
|
50
|
+
const resolvedIcon =
|
|
51
|
+
iconOverride !== undefined
|
|
52
|
+
? undefined
|
|
53
|
+
: icon !== undefined
|
|
54
|
+
? icon
|
|
55
|
+
: state === "error"
|
|
56
|
+
? "error"
|
|
57
|
+
: undefined;
|
|
58
|
+
const titleText = theme?.fg ? theme.fg("accent", title) : title;
|
|
59
|
+
const descText = description ? (theme?.fg ? theme.fg("muted", description) : description) : "";
|
|
60
|
+
let prefix = "";
|
|
61
|
+
if (iconOverride) prefix = `${iconOverride} `;
|
|
62
|
+
else if (resolvedIcon === "error") prefix = theme?.fg ? theme.fg("error", "✗ ") : "✗ ";
|
|
63
|
+
else if (resolvedIcon === "running" || spinnerFrame) prefix = theme?.fg ? theme.fg("dim", "… ") : "… ";
|
|
64
|
+
return descText ? `${prefix}${titleText}: ${descText}` : `${prefix}${titleText}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function padLine(line, width, bgFn) {
|
|
68
|
+
const w = Math.max(0, width | 0);
|
|
69
|
+
const vis = measureWidth(line);
|
|
70
|
+
const pad = Math.max(0, w - vis);
|
|
71
|
+
const padded = line + " ".repeat(pad);
|
|
72
|
+
return bgFn ? bgFn(padded) : padded;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function bgFnForState(theme, state) {
|
|
76
|
+
if (!state || !theme) return undefined;
|
|
77
|
+
if (typeof theme.bg === "function") {
|
|
78
|
+
const key =
|
|
79
|
+
state === "error" ? "toolErrorBg" : state === "pending" || state === "running" ? "toolPendingBg" : "toolSuccessBg";
|
|
80
|
+
try {
|
|
81
|
+
const probe = theme.bg(key, "x");
|
|
82
|
+
if (typeof probe !== "string") return undefined;
|
|
83
|
+
return (text) => {
|
|
84
|
+
const painted = theme.bg(key, text);
|
|
85
|
+
return typeof painted === "string" ? painted : text;
|
|
86
|
+
};
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (typeof theme.getBgAnsi === "function") {
|
|
92
|
+
try {
|
|
93
|
+
const key =
|
|
94
|
+
state === "error" ? "toolErrorBg" : state === "pending" || state === "running" ? "toolPendingBg" : "toolSuccessBg";
|
|
95
|
+
const ansi = theme.getBgAnsi(key);
|
|
96
|
+
if (!ansi) return undefined;
|
|
97
|
+
return (text) => `${ansi}${text}\x1b[49m`;
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function renderPortableFrame(theme, { header, sections = [], state = "pending", borderColor, width }) {
|
|
106
|
+
const w = Math.max(8, width | 0);
|
|
107
|
+
const box = boxOf(theme);
|
|
108
|
+
const border = borderPaint(theme, state, borderColor);
|
|
109
|
+
const bgFn = bgFnForState(theme, state);
|
|
110
|
+
const h = box.horizontal;
|
|
111
|
+
const v = box.vertical;
|
|
112
|
+
const cap = h.repeat(3);
|
|
113
|
+
|
|
114
|
+
const paintBar = (leftChar, rightChar, label) => {
|
|
115
|
+
const left = `${leftChar}${cap}`;
|
|
116
|
+
const right = rightChar;
|
|
117
|
+
if (!label) {
|
|
118
|
+
const fill = Math.max(0, w - measureWidth(left) - measureWidth(right));
|
|
119
|
+
return padLine(`${border(left)}${border(h.repeat(fill))}${border(right)}`, w, bgFn);
|
|
120
|
+
}
|
|
121
|
+
const rawLabel = ` ${label} `;
|
|
122
|
+
const maxLabel = Math.max(0, w - measureWidth(left) - measureWidth(right));
|
|
123
|
+
const trimmed = clampLine(rawLabel, maxLabel);
|
|
124
|
+
const fill = Math.max(0, w - measureWidth(left) - measureWidth(trimmed) - measureWidth(right));
|
|
125
|
+
return padLine(`${border(left)}${trimmed}${border(h.repeat(fill))}${border(right)}`, w, bgFn);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const contentWidth = Math.max(1, w - 2 - 2);
|
|
129
|
+
const lines = [];
|
|
130
|
+
lines.push(paintBar(box.topLeft, box.topRight, header));
|
|
131
|
+
|
|
132
|
+
const normalized = sections.length > 0 ? sections : [{ lines: [] }];
|
|
133
|
+
for (const section of normalized) {
|
|
134
|
+
if (section.label) {
|
|
135
|
+
lines.push(paintBar(box.teeRight || "├", box.teeLeft || "┤", section.label));
|
|
136
|
+
}
|
|
137
|
+
for (const raw of section.lines || []) {
|
|
138
|
+
for (const piece of String(raw).split("\n")) {
|
|
139
|
+
const body = clampLine(piece, contentWidth);
|
|
140
|
+
const pad = Math.max(0, contentWidth - measureWidth(body));
|
|
141
|
+
const inner = `${body}${" ".repeat(pad)}`;
|
|
142
|
+
lines.push(padLine(`${border(v)} ${inner} ${border(v)}`, w, bgFn));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
lines.push(paintBar(box.bottomLeft, box.bottomRight, null));
|
|
148
|
+
return lines;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function createPortableFramedComponent(theme, build) {
|
|
152
|
+
let cacheWidth;
|
|
153
|
+
let cacheKey;
|
|
154
|
+
let cacheLines;
|
|
155
|
+
return {
|
|
156
|
+
render(width) {
|
|
157
|
+
const opts = build(width);
|
|
158
|
+
const key = `${opts.state}|${opts.borderColor}|${opts.header}|${(opts.sections || [])
|
|
159
|
+
.map((s) => (s.lines || []).join("\n"))
|
|
160
|
+
.join("||")}`;
|
|
161
|
+
if (cacheLines && cacheWidth === width && cacheKey === key) return cacheLines;
|
|
162
|
+
cacheLines = renderPortableFrame(theme, opts);
|
|
163
|
+
cacheWidth = width;
|
|
164
|
+
cacheKey = key;
|
|
165
|
+
return cacheLines;
|
|
166
|
+
},
|
|
167
|
+
invalidate() {
|
|
168
|
+
cacheLines = undefined;
|
|
169
|
+
cacheKey = undefined;
|
|
170
|
+
cacheWidth = undefined;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function novaFramedBlock(theme, build) {
|
|
176
|
+
return createPortableFramedComponent(theme, build);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function novaStatusLine(theme, options) {
|
|
180
|
+
return statusHeader(theme, options);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function wrapPlainLines(text, width) {
|
|
184
|
+
const w = Math.max(1, width | 0);
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
187
|
+
for (const chunk of wrapPlainToWidth(line, w)) out.push(chunk);
|
|
188
|
+
}
|
|
189
|
+
return out.length ? out : [""];
|
|
190
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-supernova",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
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 { 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,112 @@ 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
|
+
return "pi";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs) {
|
|
355
|
+
if (isTheme(themeOrCtx)) {
|
|
356
|
+
const opts = options && typeof options === "object" ? options : {};
|
|
357
|
+
let context;
|
|
358
|
+
if (
|
|
359
|
+
ctxOrArgs &&
|
|
360
|
+
typeof ctxOrArgs === "object" &&
|
|
361
|
+
!isTheme(ctxOrArgs) &&
|
|
362
|
+
("lastComponent" in ctxOrArgs || "state" in ctxOrArgs || "invalidate" in ctxOrArgs)
|
|
363
|
+
) {
|
|
364
|
+
context = ctxOrArgs;
|
|
365
|
+
} else {
|
|
366
|
+
context = { ...(opts.state ? { state: opts.state } : {}), lastComponent: opts.lastComponent };
|
|
367
|
+
}
|
|
368
|
+
if (!context.state || typeof context.state !== "object") context.state = {};
|
|
369
|
+
return {
|
|
370
|
+
result,
|
|
371
|
+
expanded: !!opts.expanded,
|
|
372
|
+
isPartial: !!opts.isPartial,
|
|
373
|
+
theme: themeOrCtx,
|
|
374
|
+
context,
|
|
375
|
+
host: detectResultHost(options, ctxOrArgs),
|
|
376
|
+
options: opts,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
// Extremely defensive: (result, theme, context) oddball
|
|
380
|
+
if (isTheme(options)) {
|
|
381
|
+
const context = themeOrCtx && typeof themeOrCtx === "object" ? themeOrCtx : {};
|
|
382
|
+
if (!context.state || typeof context.state !== "object") context.state = {};
|
|
383
|
+
return {
|
|
384
|
+
result,
|
|
385
|
+
expanded: !!context.expanded,
|
|
386
|
+
isPartial: !!context.isPartial,
|
|
387
|
+
theme: options,
|
|
388
|
+
context,
|
|
389
|
+
host: "pi",
|
|
390
|
+
options: {},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
throw new Error("supernova renderResult: theme missing (expected Pi or OMP signature)");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function collectCallOps(args, context) {
|
|
425
397
|
const stateTrace = context?.state?.trace;
|
|
426
398
|
if (Array.isArray(stateTrace) && stateTrace.length > 0) {
|
|
427
|
-
|
|
399
|
+
return stateTrace
|
|
428
400
|
.map((item) => {
|
|
429
401
|
const tool = item?.name || "tool";
|
|
430
402
|
let target = "";
|
|
@@ -434,12 +406,24 @@ export function renderSupernovaCall(args, theme, context) {
|
|
|
434
406
|
return displayOperation(tool, target);
|
|
435
407
|
})
|
|
436
408
|
.filter(Boolean);
|
|
437
|
-
} else {
|
|
438
|
-
ops = extractOperationsFromCode(args?.code)
|
|
439
|
-
.map((op) => displayOperation(op.tool, op.target))
|
|
440
|
-
.filter(Boolean);
|
|
441
409
|
}
|
|
410
|
+
return extractOperationsFromCode(args?.code)
|
|
411
|
+
.map((op) => displayOperation(op.tool, op.target))
|
|
412
|
+
.filter(Boolean);
|
|
413
|
+
}
|
|
442
414
|
|
|
415
|
+
function formatElapsed(state) {
|
|
416
|
+
if (state?.wallMs != null) return `${state.wallMs}ms`;
|
|
417
|
+
if (state?.startedAt != null) {
|
|
418
|
+
const elapsed = Math.round(performance.now() - state.startedAt);
|
|
419
|
+
// Suppress sub-100ms noise on the pending head (matches quiet Write/Edit calls).
|
|
420
|
+
if (elapsed < 100) return "";
|
|
421
|
+
return elapsed >= 1000 ? `${(elapsed / 1000).toFixed(1)}s` : `${elapsed}ms`;
|
|
422
|
+
}
|
|
423
|
+
return "";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function tickCallTimer(context) {
|
|
443
427
|
const state = context?.state;
|
|
444
428
|
if (state && state.startedAt == null) state.startedAt = performance.now();
|
|
445
429
|
if (state && context?.executionStarted && state.wallMs == null && state.timer == null) {
|
|
@@ -448,16 +432,115 @@ export function renderSupernovaCall(args, theme, context) {
|
|
|
448
432
|
context.invalidate?.();
|
|
449
433
|
}, 100);
|
|
450
434
|
}
|
|
435
|
+
}
|
|
451
436
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
437
|
+
function formatOpBodyLine(theme, op) {
|
|
438
|
+
const icon = ACTION_ICONS[op.tool] || "✦ ";
|
|
439
|
+
const bullet = theme.fg("accent", icon);
|
|
440
|
+
const toolName = theme.fg("syntaxFunction", op.tool.padEnd(4, " "));
|
|
441
|
+
const rawTarget = formatOpTarget(op.target, op.tool);
|
|
442
|
+
const target = rawTarget ? " " + theme.fg("muted", rawTarget) : "";
|
|
443
|
+
return `${bullet}${toolName}${target}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function shouldUseOmpFrame(host) {
|
|
447
|
+
return host === "omp";
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* OMP write/edit look: rounded framedBlock + status-line header.
|
|
452
|
+
* Pending call has no hourglass on the head row (same as native Write/Edit).
|
|
453
|
+
*/
|
|
454
|
+
function renderOmpCallCard(theme, { ops, timeStr, expanded, code }) {
|
|
455
|
+
const opSummary =
|
|
456
|
+
ops.length === 0
|
|
457
|
+
? "composing"
|
|
458
|
+
: ops.map((op) => op.tool).join(theme.sep?.dot ? ` ${theme.sep.dot} ` : " · ");
|
|
459
|
+
const description = timeStr ? `${opSummary} · ${timeStr}` : opSummary;
|
|
460
|
+
// No pending icon on the framed head row — matches native Write/Edit.
|
|
461
|
+
const header = novaStatusLine(theme, {
|
|
462
|
+
title: "nova",
|
|
463
|
+
description,
|
|
464
|
+
});
|
|
465
|
+
return novaFramedBlock(theme, (width) => {
|
|
466
|
+
const bodyLines = ops.map((op) => formatOpBodyLine(theme, op));
|
|
467
|
+
if (expanded && code) {
|
|
468
|
+
bodyLines.push(theme.fg("dim", "── source ──"));
|
|
469
|
+
for (const line of String(code).trim().split("\n")) {
|
|
470
|
+
bodyLines.push(theme.fg("toolOutput", line));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
header,
|
|
475
|
+
sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
|
|
476
|
+
state: "pending",
|
|
477
|
+
borderColor: "borderMuted",
|
|
478
|
+
width,
|
|
479
|
+
};
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function renderOmpResultCard(theme, { isErr, payload, expanded, bodyText, isPartial, spinnerFrame }) {
|
|
484
|
+
if (isErr) {
|
|
485
|
+
const errLines = [];
|
|
486
|
+
if (payload?.error) errLines.push(theme.fg("error", String(payload.error)));
|
|
487
|
+
if (expanded && payload?.logs?.length) {
|
|
488
|
+
errLines.push(theme.fg("dim", "── logs ──"));
|
|
489
|
+
for (const log of payload.logs) errLines.push(theme.fg("dim", String(log)));
|
|
490
|
+
}
|
|
491
|
+
const header = novaStatusLine(theme, {
|
|
492
|
+
icon: "error",
|
|
493
|
+
title: "nova",
|
|
494
|
+
description: payload?.error ? String(payload.error).split("\n")[0] : "error",
|
|
495
|
+
});
|
|
496
|
+
return novaFramedBlock(theme, (width) => ({
|
|
497
|
+
header,
|
|
498
|
+
sections: errLines.length > 0 ? [{ lines: errLines }] : [],
|
|
499
|
+
state: "error",
|
|
500
|
+
borderColor: "error",
|
|
501
|
+
width,
|
|
502
|
+
}));
|
|
458
503
|
}
|
|
459
504
|
|
|
460
|
-
|
|
505
|
+
const wall = payload?.wallMs != null ? `${payload.wallMs}ms` : "";
|
|
506
|
+
const header = novaStatusLine(theme, {
|
|
507
|
+
icon: isPartial ? "running" : undefined,
|
|
508
|
+
spinnerFrame,
|
|
509
|
+
title: "nova",
|
|
510
|
+
description: wall || undefined,
|
|
511
|
+
});
|
|
512
|
+
const bodyLines = String(bodyText || "").split("\n");
|
|
513
|
+
while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
|
|
514
|
+
while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === "") bodyLines.pop();
|
|
515
|
+
return novaFramedBlock(theme, (width) => ({
|
|
516
|
+
header,
|
|
517
|
+
sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
|
|
518
|
+
state: isPartial ? "pending" : "success",
|
|
519
|
+
borderColor: "borderMuted",
|
|
520
|
+
width,
|
|
521
|
+
}));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function renderSupernovaCall(a, b, c) {
|
|
525
|
+
const { args, theme, context, options, host } = normalizeCallRenderArgs(a, b, c);
|
|
526
|
+
tickCallTimer(context);
|
|
527
|
+
const ops = collectCallOps(args, context);
|
|
528
|
+
const timeStr = formatElapsed(context?.state);
|
|
529
|
+
|
|
530
|
+
if (shouldUseOmpFrame(host)) {
|
|
531
|
+
return renderOmpCallCard(theme, {
|
|
532
|
+
ops,
|
|
533
|
+
timeStr,
|
|
534
|
+
expanded: !!context?.expanded,
|
|
535
|
+
code: args?.code,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
|
|
540
|
+
if (options) options.lastComponent = comp;
|
|
541
|
+
else if (context) context.lastComponent = comp;
|
|
542
|
+
|
|
543
|
+
// Compact aesthetic — never dump raw JSON args (the stock tool fallback).
|
|
461
544
|
let out = theme.fg("toolTitle", theme.bold("nova"));
|
|
462
545
|
if (timeStr) {
|
|
463
546
|
out += " " + theme.fg("dim", `· ${timeStr}`);
|
|
@@ -467,12 +550,7 @@ export function renderSupernovaCall(args, theme, context) {
|
|
|
467
550
|
out += " " + theme.fg("dim", "· composing");
|
|
468
551
|
} else {
|
|
469
552
|
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}`;
|
|
553
|
+
out += `\n ${formatOpBodyLine(theme, op)}`;
|
|
476
554
|
}
|
|
477
555
|
}
|
|
478
556
|
|
|
@@ -489,50 +567,8 @@ export function renderSupernovaCall(args, theme, context) {
|
|
|
489
567
|
return comp;
|
|
490
568
|
}
|
|
491
569
|
|
|
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
|
-
|
|
570
|
+
function buildResultBody(theme, { payload, context, expanded }) {
|
|
534
571
|
let out = "";
|
|
535
|
-
|
|
536
572
|
const trace = payload?.trace || context?.state?.trace || [];
|
|
537
573
|
const diffs = trace.filter((t) => t?.diff && isObject(t.diff)).map((t) => t.diff);
|
|
538
574
|
|
|
@@ -540,7 +576,6 @@ export function renderSupernovaResult(result, { expanded, isPartial }, theme, co
|
|
|
540
576
|
const maxDiffsShown = expanded ? diffs.length : 2;
|
|
541
577
|
const shownDiffs = diffs.slice(0, maxDiffsShown);
|
|
542
578
|
for (const diff of shownDiffs) {
|
|
543
|
-
// Build unconstrained; SafeText.render(terminalWidth) is the hard clamp.
|
|
544
579
|
const box = renderDiffBox(diff, theme, 120);
|
|
545
580
|
if (box) out += (out ? "\n\n" : "") + box;
|
|
546
581
|
}
|
|
@@ -572,7 +607,109 @@ export function renderSupernovaResult(result, { expanded, isPartial }, theme, co
|
|
|
572
607
|
for (const log of payload.logs) out += `\n ${theme.fg("dim", String(log))}`;
|
|
573
608
|
}
|
|
574
609
|
}
|
|
610
|
+
return out;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export function renderSupernovaResult(resultArg, optionsArg, themeArg, contextArg) {
|
|
614
|
+
const { result, expanded, isPartial, theme, context, options, host } = normalizeResultRenderArgs(
|
|
615
|
+
resultArg,
|
|
616
|
+
optionsArg,
|
|
617
|
+
themeArg,
|
|
618
|
+
contextArg,
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
const payload = result?.details;
|
|
622
|
+
if (context?.state && payload) {
|
|
623
|
+
let changed = false;
|
|
624
|
+
if (Array.isArray(payload.trace) && context.state.trace !== payload.trace) {
|
|
625
|
+
context.state.trace = payload.trace;
|
|
626
|
+
changed = true;
|
|
627
|
+
}
|
|
628
|
+
if (payload.wallMs != null && context.state.wallMs !== payload.wallMs) {
|
|
629
|
+
context.state.wallMs = payload.wallMs;
|
|
630
|
+
changed = true;
|
|
631
|
+
}
|
|
632
|
+
if (context.state.timer != null && !isPartial) {
|
|
633
|
+
clearTimeout(context.state.timer);
|
|
634
|
+
context.state.timer = null;
|
|
635
|
+
}
|
|
636
|
+
if (changed) context.invalidate?.();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const isErr = result?.isError || payload?.ok === false;
|
|
640
|
+
const useOmp = shouldUseOmpFrame(host);
|
|
641
|
+
|
|
642
|
+
if (useOmp) {
|
|
643
|
+
if (isPartial && !isErr) {
|
|
644
|
+
// Streaming partials stay quiet until body content exists — same as Pi.
|
|
645
|
+
const partialBody = buildResultBody(theme, { payload, context, expanded });
|
|
646
|
+
if (!partialBody.trim()) {
|
|
647
|
+
return {
|
|
648
|
+
render: () => [],
|
|
649
|
+
invalidate() {},
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
return renderOmpResultCard(theme, {
|
|
653
|
+
isErr: false,
|
|
654
|
+
payload,
|
|
655
|
+
expanded,
|
|
656
|
+
bodyText: partialBody,
|
|
657
|
+
isPartial: true,
|
|
658
|
+
spinnerFrame: options?.spinnerFrame,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
if (isErr) {
|
|
662
|
+
return renderOmpResultCard(theme, {
|
|
663
|
+
isErr: true,
|
|
664
|
+
payload,
|
|
665
|
+
expanded,
|
|
666
|
+
bodyText: "",
|
|
667
|
+
isPartial: false,
|
|
668
|
+
spinnerFrame: options?.spinnerFrame,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
const out = buildResultBody(theme, { payload, context, expanded });
|
|
672
|
+
if (!out.trim()) {
|
|
673
|
+
return {
|
|
674
|
+
render: () => [],
|
|
675
|
+
invalidate() {},
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
return renderOmpResultCard(theme, {
|
|
679
|
+
isErr: false,
|
|
680
|
+
payload,
|
|
681
|
+
expanded,
|
|
682
|
+
bodyText: out,
|
|
683
|
+
isPartial: false,
|
|
684
|
+
spinnerFrame: options?.spinnerFrame,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const comp = context?.lastComponent instanceof SafeText ? context.lastComponent : new SafeText();
|
|
689
|
+
if (options) options.lastComponent = comp;
|
|
690
|
+
else if (context) context.lastComponent = comp;
|
|
691
|
+
|
|
692
|
+
if (isPartial) {
|
|
693
|
+
comp.setText("");
|
|
694
|
+
return comp;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (isErr) {
|
|
698
|
+
let out = theme.fg("error", "✗ error");
|
|
699
|
+
if (payload?.error) {
|
|
700
|
+
out += `\n ${theme.fg("error", String(payload.error))}`;
|
|
701
|
+
}
|
|
702
|
+
if (expanded && payload?.logs?.length) {
|
|
703
|
+
out += `\n${theme.fg("dim", "── logs ──")}`;
|
|
704
|
+
for (const log of payload.logs) out += `\n ${theme.fg("dim", String(log))}`;
|
|
705
|
+
}
|
|
706
|
+
comp.setTone("error");
|
|
707
|
+
comp.setFraming(true);
|
|
708
|
+
comp.setText(out);
|
|
709
|
+
return comp;
|
|
710
|
+
}
|
|
575
711
|
|
|
712
|
+
const out = buildResultBody(theme, { payload, context, expanded });
|
|
576
713
|
if (!out.trim()) {
|
|
577
714
|
comp.setText("");
|
|
578
715
|
return comp;
|