buildwithnexus 0.11.2 → 0.11.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/harness/Cargo.lock +1 -1
- package/harness/Cargo.toml +1 -1
- package/harness/src/agent.rs +33 -15
- package/harness/src/tools.rs +113 -8
- package/package.json +1 -1
package/harness/Cargo.lock
CHANGED
package/harness/Cargo.toml
CHANGED
package/harness/src/agent.rs
CHANGED
|
@@ -866,6 +866,17 @@ fn text_tool_call(name: &str, input: serde_json::Value) -> provider::ToolCall {
|
|
|
866
866
|
}
|
|
867
867
|
}
|
|
868
868
|
|
|
869
|
+
// The single-line composer prompt for reading a question answer. Kept on one
|
|
870
|
+
// line (no `\n`) so the alt-screen composer positions the cursor correctly and
|
|
871
|
+
// echoes typed input; the question text itself is printed separately above.
|
|
872
|
+
fn answer_input_prompt(default: &str) -> String {
|
|
873
|
+
if default.is_empty() {
|
|
874
|
+
" Answer: ".to_string()
|
|
875
|
+
} else {
|
|
876
|
+
format!(" Answer {}: ", tui::dim(&format!("[{default}]")))
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
869
880
|
fn answer_question(input: &serde_json::Value) -> (String, bool) {
|
|
870
881
|
let question = if let Some(qs) = input["questions"].as_array() {
|
|
871
882
|
let mut acc = Vec::new();
|
|
@@ -915,21 +926,16 @@ fn answer_question(input: &serde_json::Value) -> (String, bool) {
|
|
|
915
926
|
);
|
|
916
927
|
}
|
|
917
928
|
let default = input["default"].as_str().unwrap_or("").trim();
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
tui::bold(&full_prompt),
|
|
929
|
-
tui::dim(&format!("[{default}]"))
|
|
930
|
-
)
|
|
931
|
-
};
|
|
932
|
-
let ans = tui::ask(&prompt).unwrap_or_default();
|
|
929
|
+
// Render the question on its own transcript line, then read the answer with
|
|
930
|
+
// a SINGLE-LINE composer prompt. A multi-line prompt string mis-positions
|
|
931
|
+
// the alt-screen composer cursor (prompt_width counts across the newline),
|
|
932
|
+
// which hides what the user types.
|
|
933
|
+
tui::line(&format!(
|
|
934
|
+
" {} {}",
|
|
935
|
+
tui::yellow("?"),
|
|
936
|
+
tui::bold(&full_prompt)
|
|
937
|
+
));
|
|
938
|
+
let ans = tui::ask(&answer_input_prompt(default)).unwrap_or_default();
|
|
933
939
|
let out = if ans.trim().is_empty() && !default.is_empty() {
|
|
934
940
|
default.to_string()
|
|
935
941
|
} else {
|
|
@@ -3244,6 +3250,18 @@ mod tests {
|
|
|
3244
3250
|
use super::*;
|
|
3245
3251
|
use serde_json::json;
|
|
3246
3252
|
|
|
3253
|
+
#[test]
|
|
3254
|
+
fn answer_input_prompt_is_single_line() {
|
|
3255
|
+
// Must never contain a newline — a multi-line prompt breaks the
|
|
3256
|
+
// alt-screen composer's cursor positioning and hides typed input.
|
|
3257
|
+
let p = answer_input_prompt("");
|
|
3258
|
+
assert!(!p.contains('\n'));
|
|
3259
|
+
assert!(p.contains("Answer"));
|
|
3260
|
+
let d = answer_input_prompt("yes");
|
|
3261
|
+
assert!(!d.contains('\n'));
|
|
3262
|
+
assert!(d.contains("yes"));
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3247
3265
|
#[test]
|
|
3248
3266
|
fn permission_parsing() {
|
|
3249
3267
|
assert!(matches!(permission("auto"), Permission::Auto));
|
package/harness/src/tools.rs
CHANGED
|
@@ -318,11 +318,14 @@ pub fn defs_for_context(include_subagent: bool, context_tokens: usize) -> Vec<To
|
|
|
318
318
|
}
|
|
319
319
|
|
|
320
320
|
// The compact surface advertises one canonical tool per capability — read,
|
|
321
|
-
// write, edit, shell, glob, grep, list, artifact publishing, plus the
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
321
|
+
// write, edit, shell, glob, grep, list, artifact publishing, plus the finish
|
|
322
|
+
// control tool and python_tool for local extensions. The `question` tool is
|
|
323
|
+
// deliberately omitted: small models use it to stall (endlessly re-asking a
|
|
324
|
+
// clarifying question instead of building), so on the compact surface they must
|
|
325
|
+
// act on sensible defaults. Pure aliases (`read`/`write`/`edit`/`bash`,
|
|
326
|
+
// `publish_artifact`) still dispatch in `run` if a model insists, but
|
|
327
|
+
// advertising duplicates wastes prompt tokens and confuses weak models. Keep
|
|
328
|
+
// this list at 12 defs or fewer.
|
|
326
329
|
fn compact_tool(name: &str) -> bool {
|
|
327
330
|
matches!(
|
|
328
331
|
name,
|
|
@@ -334,7 +337,6 @@ fn compact_tool(name: &str) -> bool {
|
|
|
334
337
|
| "grep_files"
|
|
335
338
|
| "list_dir"
|
|
336
339
|
| "finish"
|
|
337
|
-
| "question"
|
|
338
340
|
| "Artifact"
|
|
339
341
|
| "python_tool"
|
|
340
342
|
)
|
|
@@ -1182,6 +1184,57 @@ fn first_local_script_src(contents: &str) -> Option<String> {
|
|
|
1182
1184
|
None
|
|
1183
1185
|
}
|
|
1184
1186
|
|
|
1187
|
+
// True if `val` is a local relative path (not an absolute URL or data: URI) —
|
|
1188
|
+
// such a reference won't exist next to a published single-file artifact.
|
|
1189
|
+
fn is_local_asset(val: &str) -> bool {
|
|
1190
|
+
let v = val.trim().to_ascii_lowercase();
|
|
1191
|
+
!v.is_empty()
|
|
1192
|
+
&& !v.starts_with("http://")
|
|
1193
|
+
&& !v.starts_with("https://")
|
|
1194
|
+
&& !v.starts_with("//")
|
|
1195
|
+
&& !v.starts_with("data:")
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Extract a (possibly quoted) attribute value from the text right after `attr=`.
|
|
1199
|
+
fn attr_after(after: &str) -> String {
|
|
1200
|
+
let v = if let Some(rest) = after.strip_prefix('"') {
|
|
1201
|
+
rest.split('"').next().unwrap_or("")
|
|
1202
|
+
} else if let Some(rest) = after.strip_prefix('\'') {
|
|
1203
|
+
rest.split('\'').next().unwrap_or("")
|
|
1204
|
+
} else {
|
|
1205
|
+
after
|
|
1206
|
+
.split(|c: char| c.is_whitespace() || c == '>')
|
|
1207
|
+
.next()
|
|
1208
|
+
.unwrap_or("")
|
|
1209
|
+
};
|
|
1210
|
+
v.trim().to_string()
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// A `<link rel="stylesheet" href="local.css">` pointing at a local file that
|
|
1214
|
+
// won't exist next to the published artifact. Returns the local href, else None.
|
|
1215
|
+
fn first_local_stylesheet_href(contents: &str) -> Option<String> {
|
|
1216
|
+
let lower = contents.to_ascii_lowercase();
|
|
1217
|
+
let mut at = 0;
|
|
1218
|
+
while let Some(rel) = lower[at..].find("<link") {
|
|
1219
|
+
let tag_start = at + rel;
|
|
1220
|
+
let tag_end = lower[tag_start..]
|
|
1221
|
+
.find('>')
|
|
1222
|
+
.map(|e| tag_start + e)
|
|
1223
|
+
.unwrap_or(lower.len());
|
|
1224
|
+
let tag_lower = &lower[tag_start..tag_end];
|
|
1225
|
+
if tag_lower.contains("stylesheet") {
|
|
1226
|
+
if let Some(hp) = tag_lower.find("href=") {
|
|
1227
|
+
let val = attr_after(&contents[tag_start + hp + "href=".len()..tag_end]);
|
|
1228
|
+
if is_local_asset(&val) {
|
|
1229
|
+
return Some(val);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
at = tag_end.max(tag_start + "<link".len());
|
|
1234
|
+
}
|
|
1235
|
+
None
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1185
1238
|
// Rejection reasons for artifact contents. Every message quotes the offending
|
|
1186
1239
|
// snippet and the exact rule, plus what to change — cheap models can't fix
|
|
1187
1240
|
// what they can't see.
|
|
@@ -1196,7 +1249,12 @@ fn artifact_quality_error(contents: &str, kind: &str) -> Option<String> {
|
|
|
1196
1249
|
let trimmed_len = contents.trim().len();
|
|
1197
1250
|
if trimmed_len < 300 {
|
|
1198
1251
|
return Some(format!(
|
|
1199
|
-
"HTML artifact is too small to be a complete runnable app: {trimmed_len} chars, but the minimum is 300.
|
|
1252
|
+
"HTML artifact is too small to be a complete runnable app: {trimmed_len} chars, but the minimum is 300. It must be ONE self-contained file: do NOT link external files (no <link rel=stylesheet> or <script src=…>); put ALL CSS inside a <style> block and ALL JavaScript — the full working logic — inside a <script> block. Resend the complete document in `contents`."
|
|
1253
|
+
));
|
|
1254
|
+
}
|
|
1255
|
+
if let Some(href) = first_local_stylesheet_href(contents) {
|
|
1256
|
+
return Some(format!(
|
|
1257
|
+
"HTML artifact links a local stylesheet via <link rel=\"stylesheet\" href=\"{href}\">, which will not exist next to the published file. Move those styles into an inline <style>…</style> block so the artifact is self-contained."
|
|
1200
1258
|
));
|
|
1201
1259
|
}
|
|
1202
1260
|
if let Some(src) = first_local_script_src(contents) {
|
|
@@ -4570,7 +4628,6 @@ mod tests {
|
|
|
4570
4628
|
"grep_files",
|
|
4571
4629
|
"list_dir",
|
|
4572
4630
|
"finish",
|
|
4573
|
-
"question",
|
|
4574
4631
|
"Artifact",
|
|
4575
4632
|
"python_tool",
|
|
4576
4633
|
] {
|
|
@@ -4583,6 +4640,7 @@ mod tests {
|
|
|
4583
4640
|
"kb_record",
|
|
4584
4641
|
"verify",
|
|
4585
4642
|
"text_editor_20250124",
|
|
4643
|
+
"question",
|
|
4586
4644
|
"AskUserQuestion",
|
|
4587
4645
|
"read",
|
|
4588
4646
|
"write",
|
|
@@ -5411,6 +5469,53 @@ print("hello " + data.get("name", "world"))
|
|
|
5411
5469
|
let _ = fs::remove_dir_all(&d);
|
|
5412
5470
|
}
|
|
5413
5471
|
|
|
5472
|
+
#[test]
|
|
5473
|
+
fn artifact_rejects_local_stylesheet_link() {
|
|
5474
|
+
let d = tempdir();
|
|
5475
|
+
// A 1.5B model's canvas-game stub linked <link rel=stylesheet href=styles.css>;
|
|
5476
|
+
// it must be rejected with a message naming the file and telling the
|
|
5477
|
+
// model to inline the CSS.
|
|
5478
|
+
let body = html_app("draw();").replace(
|
|
5479
|
+
"<head>",
|
|
5480
|
+
"<head><link rel=\"stylesheet\" href=\"styles.css\">",
|
|
5481
|
+
);
|
|
5482
|
+
let r = run(
|
|
5483
|
+
"Artifact",
|
|
5484
|
+
&json!({"title": "Game", "contents": body, "type": "html"}),
|
|
5485
|
+
&d,
|
|
5486
|
+
);
|
|
5487
|
+
assert!(r.is_error);
|
|
5488
|
+
assert!(r.content.contains("styles.css"), "{}", r.content);
|
|
5489
|
+
assert!(r.content.contains("<style"), "{}", r.content);
|
|
5490
|
+
let _ = fs::remove_dir_all(&d);
|
|
5491
|
+
}
|
|
5492
|
+
|
|
5493
|
+
#[test]
|
|
5494
|
+
fn artifact_allows_external_stylesheet_link() {
|
|
5495
|
+
let d = tempdir();
|
|
5496
|
+
let body = html_app("draw();").replace(
|
|
5497
|
+
"<head>",
|
|
5498
|
+
"<head><link rel=\"stylesheet\" href=\"https://cdn.example.com/x.css\">",
|
|
5499
|
+
);
|
|
5500
|
+
let r = run(
|
|
5501
|
+
"Artifact",
|
|
5502
|
+
&json!({"title": "Game", "contents": body, "type": "html"}),
|
|
5503
|
+
&d,
|
|
5504
|
+
);
|
|
5505
|
+
assert!(!r.is_error, "{}", r.content);
|
|
5506
|
+
let _ = fs::remove_dir_all(&d);
|
|
5507
|
+
}
|
|
5508
|
+
|
|
5509
|
+
#[test]
|
|
5510
|
+
fn is_local_asset_classifies_paths() {
|
|
5511
|
+
assert!(is_local_asset("styles.css"));
|
|
5512
|
+
assert!(is_local_asset("./css/app.css"));
|
|
5513
|
+
assert!(!is_local_asset("https://x.com/a.css"));
|
|
5514
|
+
assert!(!is_local_asset("//cdn/a.css"));
|
|
5515
|
+
assert!(!is_local_asset("data:text/css,body{}"));
|
|
5516
|
+
assert!(!is_local_asset(""));
|
|
5517
|
+
}
|
|
5518
|
+
|
|
5414
5519
|
#[test]
|
|
5415
5520
|
fn artifact_allows_external_script_src() {
|
|
5416
5521
|
let d = tempdir();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buildwithnexus",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
4
4
|
"description": "A hilariously fast, agentic AI CLI harness in Rust — remote (API key) or local models, full-screen TUI with sessions, plan/build/brainstorm modes, and hooks",
|
|
5
5
|
"bin": {
|
|
6
6
|
"buildwithnexus": "bin/buildwithnexus.js",
|