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.
@@ -49,7 +49,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
49
49
 
50
50
  [[package]]
51
51
  name = "buildwithnexus"
52
- version = "0.11.2"
52
+ version = "0.11.3"
53
53
  dependencies = [
54
54
  "criterion",
55
55
  "crossterm",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "buildwithnexus"
3
- version = "0.11.2"
3
+ version = "0.11.3"
4
4
  edition = "2021"
5
5
  description = "A hilariously fast agentic AI CLI harness — remote or local models, full TUI with modes, memory, skills, hooks, and image input"
6
6
  license = "MIT"
@@ -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
- let prompt = if default.is_empty() {
919
- format!(
920
- " {} {}\n Answer: ",
921
- tui::yellow("?"),
922
- tui::bold(&full_prompt)
923
- )
924
- } else {
925
- format!(
926
- " {} {}\n Answer {} ",
927
- tui::yellow("?"),
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));
@@ -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
- // finish/question control tools and python_tool for local extensions. Pure
323
- // aliases (`read`/`write`/`edit`/`bash`, `publish_artifact`) still dispatch in
324
- // `run` if a model insists, but advertising duplicates wastes prompt tokens
325
- // and confuses weak models. Keep this list at 12 defs or fewer.
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. Resend the artifact with the full HTML document markup, embedded CSS, and embedded JavaScript — in `contents`."
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.2",
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",