buildwithnexus 0.11.0 → 0.11.2

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.0"
52
+ version = "0.11.2"
53
53
  dependencies = [
54
54
  "criterion",
55
55
  "crossterm",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "buildwithnexus"
3
- version = "0.11.0"
3
+ version = "0.11.2"
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"
@@ -721,22 +721,73 @@ fn parse_text_tool_calls(text: &str, defs: &[tools::ToolDef]) -> Option<Vec<prov
721
721
 
722
722
  fn extract_json_tool_candidate(text: &str) -> Option<&str> {
723
723
  let trimmed = text.trim();
724
+ // Whole-text JSON (the strict, original case).
724
725
  if trimmed.starts_with('{') || trimmed.starts_with('[') {
725
726
  return Some(trimmed);
726
727
  }
727
- let rest = trimmed.strip_prefix("```")?;
728
- let fence_end = rest.find('\n')?;
729
- let lang = rest[..fence_end].trim().to_ascii_lowercase();
730
- if !lang.is_empty() && lang != "json" {
731
- return None;
728
+ // A fenced ```json block that is the entire message.
729
+ if let Some(rest) = trimmed.strip_prefix("```") {
730
+ if let Some(fence_end) = rest.find('\n') {
731
+ let lang = rest[..fence_end].trim().to_ascii_lowercase();
732
+ if lang.is_empty() || lang == "json" {
733
+ let body = &rest[fence_end + 1..];
734
+ if let Some(close) = body.rfind("```") {
735
+ if body[close + 3..].trim().is_empty() {
736
+ return Some(body[..close].trim());
737
+ }
738
+ }
739
+ }
740
+ }
732
741
  }
733
- let body = &rest[fence_end + 1..];
734
- let close = body.rfind("```")?;
735
- let after = body[close + 3..].trim();
736
- if !after.is_empty() {
737
- return None;
742
+ // XML-tagged tool calls. Small local coders (notably Qwen2.5-Coder on
743
+ // llama.cpp/Ollama) emit calls as `<tools>{…}</tools>` or
744
+ // `<tool_call>{…}</tool_call>` text in `content` instead of native
745
+ // tool_calls. Pull the first balanced JSON object out of the tagged region.
746
+ for tag in ["<tools>", "<tool_call>", "<function_call>", "<function>"] {
747
+ if let Some(pos) = text.find(tag) {
748
+ if let Some(json) = balanced_json_object(&text[pos + tag.len()..]) {
749
+ return Some(json);
750
+ }
751
+ }
752
+ }
753
+ // A bare object embedded in prose (a leading sentence, then the JSON).
754
+ balanced_json_object(text)
755
+ }
756
+
757
+ // Return the first balanced `{…}` slice, tracking string state so braces inside
758
+ // JSON string values (e.g. CSS `{ }` inside an HTML `content` field) don't end
759
+ // the object early. None if there's no `{` or it never closes (truncated output).
760
+ fn balanced_json_object(text: &str) -> Option<&str> {
761
+ let bytes = text.as_bytes();
762
+ let start = text.find('{')?;
763
+ let mut depth = 0usize;
764
+ let mut in_string = false;
765
+ let mut escaped = false;
766
+ for i in start..bytes.len() {
767
+ let c = bytes[i];
768
+ if in_string {
769
+ if escaped {
770
+ escaped = false;
771
+ } else if c == b'\\' {
772
+ escaped = true;
773
+ } else if c == b'"' {
774
+ in_string = false;
775
+ }
776
+ continue;
777
+ }
778
+ match c {
779
+ b'"' => in_string = true,
780
+ b'{' => depth += 1,
781
+ b'}' => {
782
+ depth -= 1;
783
+ if depth == 0 {
784
+ return Some(&text[start..=i]);
785
+ }
786
+ }
787
+ _ => {}
788
+ }
738
789
  }
739
- Some(body[..close].trim())
790
+ None
740
791
  }
741
792
 
742
793
  fn collect_text_tool_calls(
@@ -3227,6 +3278,67 @@ mod tests {
3227
3278
  assert_eq!(normalized.calls[0].input["command"], "npm start");
3228
3279
  }
3229
3280
 
3281
+ #[test]
3282
+ fn qwen_tools_tagged_call_with_css_braces_is_parsed() {
3283
+ // The real failure from an end-to-end run against qwen2.5-coder-1.5b on
3284
+ // llama.cpp: the model emits its call as `<tools>{…}</tools>` text with
3285
+ // an HTML `content` field whose CSS contains `{ }`. The balanced-object
3286
+ // scanner must not stop at the first CSS brace.
3287
+ let defs = tools::defs_for_context(true, 128_000);
3288
+ let reply = Reply {
3289
+ text: "<tools>{\"name\":\"write_file\",\"arguments\":{\"path\":\"index.html\",\"content\":\"<style>body { color: #ff6faa; } .hero { padding: 2rem; }</style>\"}}</tools>".to_string(),
3290
+ ..Default::default()
3291
+ };
3292
+ let normalized = normalize_text_tool_calls(reply, &defs, "build a donut shop site");
3293
+ assert_eq!(normalized.calls.len(), 1, "text: unparsed");
3294
+ assert_eq!(normalized.calls[0].name, "write_file");
3295
+ assert_eq!(normalized.calls[0].input["path"], "index.html");
3296
+ assert!(normalized.calls[0].input["content"]
3297
+ .as_str()
3298
+ .unwrap()
3299
+ .contains("#ff6faa"));
3300
+ }
3301
+
3302
+ #[test]
3303
+ fn tool_call_tagged_and_embedded_json_variants_parse() {
3304
+ let defs = tools::defs_for_context(true, 128_000);
3305
+ // Hermes/Qwen `<tool_call>` wrapper.
3306
+ let a = normalize_text_tool_calls(
3307
+ Reply {
3308
+ text: "<tool_call>{\"name\":\"read_file\",\"arguments\":{\"path\":\"a.txt\"}}</tool_call>"
3309
+ .to_string(),
3310
+ ..Default::default()
3311
+ },
3312
+ &defs,
3313
+ "read it",
3314
+ );
3315
+ assert_eq!(a.calls.len(), 1);
3316
+ assert_eq!(a.calls[0].name, "read_file");
3317
+ // A leading sentence, then a bare JSON object.
3318
+ let b = normalize_text_tool_calls(
3319
+ Reply {
3320
+ text: "Sure, I'll do that now. {\"name\":\"read_file\",\"arguments\":{\"path\":\"b.txt\"}}"
3321
+ .to_string(),
3322
+ ..Default::default()
3323
+ },
3324
+ &defs,
3325
+ "read it",
3326
+ );
3327
+ assert_eq!(b.calls.len(), 1);
3328
+ assert_eq!(b.calls[0].input["path"], "b.txt");
3329
+ }
3330
+
3331
+ #[test]
3332
+ fn balanced_json_object_respects_strings_and_truncation() {
3333
+ assert_eq!(
3334
+ balanced_json_object("x {\"a\":\"}{\"} y"),
3335
+ Some("{\"a\":\"}{\"}")
3336
+ );
3337
+ // Unterminated object (truncated at the token limit) yields nothing.
3338
+ assert_eq!(balanced_json_object("{\"a\": {\"b\": 1"), None);
3339
+ assert_eq!(balanced_json_object("no braces here"), None);
3340
+ }
3341
+
3230
3342
  #[test]
3231
3343
  fn casual_text_json_mutating_tool_call_is_ignored() {
3232
3344
  let defs = tools::defs_for_context(true, 128_000);
@@ -531,7 +531,7 @@ fn anthropic_parse(v: Value) -> Result<Reply, String> {
531
531
  }
532
532
  let stop_reason = v["stop_reason"].as_str().map(normalize_stop_reason);
533
533
  Ok(Reply {
534
- text,
534
+ text: strip_think(&text),
535
535
  calls,
536
536
  stop_reason,
537
537
  })
@@ -729,9 +729,35 @@ fn openai_request_at(
729
729
  (req, body)
730
730
  }
731
731
 
732
+ // Remove `<think>…</think>` reasoning blocks that local reasoning models
733
+ // (DeepSeek-R1 distills, Qwen thinking variants) emit inline in `content` on
734
+ // the non-streaming path. Handles multiple blocks and an unclosed `<think>`
735
+ // (drops the trailing leaked chain-of-thought). Text without think tags is
736
+ // returned untouched. The streaming path already routes these to on_thinking.
737
+ fn strip_think(text: &str) -> String {
738
+ if !text.contains("<think>") {
739
+ return text.to_string();
740
+ }
741
+ let mut out = String::with_capacity(text.len());
742
+ let mut rest = text;
743
+ while let Some(start) = rest.find("<think>") {
744
+ out.push_str(&rest[..start]);
745
+ let after = &rest[start + "<think>".len()..];
746
+ match after.find("</think>") {
747
+ Some(end) => rest = &after[end + "</think>".len()..],
748
+ None => {
749
+ rest = "";
750
+ break;
751
+ }
752
+ }
753
+ }
754
+ out.push_str(rest);
755
+ out.trim().to_string()
756
+ }
757
+
732
758
  fn openai_parse(v: Value) -> Result<Reply, String> {
733
759
  let msg = &v["choices"][0]["message"];
734
- let text = msg["content"].as_str().unwrap_or_default().to_string();
760
+ let text = strip_think(msg["content"].as_str().unwrap_or_default());
735
761
  let mut calls = Vec::new();
736
762
  if let Some(tcs) = msg["tool_calls"].as_array() {
737
763
  for tc in tcs {
@@ -756,14 +782,38 @@ fn openai_parse(v: Value) -> Result<Reply, String> {
756
782
  })
757
783
  }
758
784
 
785
+ // If `s` ends with a non-empty *proper* prefix of `tag` (e.g. "a<thi" for
786
+ // "<think>"), split so the prefix can be held until the next chunk completes it.
787
+ // Returns (emit, hold). No trailing partial → (s, "").
788
+ fn split_trailing_partial<'a>(s: &'a str, tag: &str) -> (&'a str, &'a str) {
789
+ let max = (tag.len() - 1).min(s.len());
790
+ for k in (1..=max).rev() {
791
+ if s.is_char_boundary(s.len() - k) && s.as_bytes().ends_with(&tag.as_bytes()[..k]) {
792
+ return (&s[..s.len() - k], &s[s.len() - k..]);
793
+ }
794
+ }
795
+ (s, "")
796
+ }
797
+
798
+ // Route streamed OpenAI/Ollama `content` deltas: `<think>…</think>` spans go to
799
+ // `on_thinking`, everything else to `text`/`on_text`. `carry` holds a `<think>`
800
+ // or `</think>` tag that was split across chunk boundaries (routine with
801
+ // token-by-token local streaming) so the partial marker isn't leaked as output.
759
802
  fn route_openai_content(
760
803
  chunk: &str,
761
804
  in_think: &mut bool,
805
+ carry: &mut String,
762
806
  text: &mut String,
763
807
  on_text: &mut dyn FnMut(&str),
764
808
  on_thinking: &mut dyn FnMut(&str),
765
809
  ) {
766
- let mut rest = chunk;
810
+ let combined = if carry.is_empty() {
811
+ chunk.to_string()
812
+ } else {
813
+ format!("{carry}{chunk}")
814
+ };
815
+ carry.clear();
816
+ let mut rest = combined.as_str();
767
817
  loop {
768
818
  if *in_think {
769
819
  if let Some(end) = rest.find("</think>") {
@@ -777,9 +827,12 @@ fn route_openai_content(
777
827
  break;
778
828
  }
779
829
  } else {
780
- if !rest.is_empty() {
781
- on_thinking(rest);
830
+ // Hold a possible partial `</think>` at the tail for next chunk.
831
+ let (emit, hold) = split_trailing_partial(rest, "</think>");
832
+ if !emit.is_empty() {
833
+ on_thinking(emit);
782
834
  }
835
+ *carry = hold.to_string();
783
836
  break;
784
837
  }
785
838
  } else if let Some(start) = rest.find("<think>") {
@@ -794,15 +847,33 @@ fn route_openai_content(
794
847
  break;
795
848
  }
796
849
  } else {
797
- if !rest.is_empty() {
798
- text.push_str(rest);
799
- on_text(rest);
850
+ // Hold a possible partial `<think>` at the tail for the next chunk.
851
+ let (emit, hold) = split_trailing_partial(rest, "<think>");
852
+ if !emit.is_empty() {
853
+ text.push_str(emit);
854
+ on_text(emit);
800
855
  }
856
+ *carry = hold.to_string();
801
857
  break;
802
858
  }
803
859
  }
804
860
  }
805
861
 
862
+ // Flush a leftover `carry` at stream end: if we're not mid-think it was ordinary
863
+ // text that merely resembled a tag prefix, so surface it; mid-think residue
864
+ // (a dangling `</think>` prefix with no answer after) is dropped.
865
+ fn flush_think_carry(
866
+ carry: &str,
867
+ in_think: bool,
868
+ text: &mut String,
869
+ on_text: &mut dyn FnMut(&str),
870
+ ) {
871
+ if !in_think && !carry.is_empty() {
872
+ text.push_str(carry);
873
+ on_text(carry);
874
+ }
875
+ }
876
+
806
877
  fn openai_stream(
807
878
  reader: impl Read,
808
879
  on_text: &mut dyn FnMut(&str),
@@ -810,6 +881,7 @@ fn openai_stream(
810
881
  ) -> Result<Reply, String> {
811
882
  let mut text = String::new();
812
883
  let mut in_think = false;
884
+ let mut think_carry = String::new();
813
885
  let mut stop_reason: Option<String> = None;
814
886
  // index → (id, name, accumulated args)
815
887
  let mut pending: Vec<(String, String, String)> = Vec::new();
@@ -832,7 +904,14 @@ fn openai_stream(
832
904
  on_thinking(r);
833
905
  }
834
906
  if let Some(t) = delta["content"].as_str() {
835
- route_openai_content(t, &mut in_think, &mut text, on_text, on_thinking);
907
+ route_openai_content(
908
+ t,
909
+ &mut in_think,
910
+ &mut think_carry,
911
+ &mut text,
912
+ on_text,
913
+ on_thinking,
914
+ );
836
915
  }
837
916
  if let Some(tcs) = delta["tool_calls"].as_array() {
838
917
  for tc in tcs {
@@ -858,6 +937,7 @@ fn openai_stream(
858
937
  }
859
938
  false
860
939
  })?;
940
+ flush_think_carry(&think_carry, in_think, &mut text, on_text);
861
941
  let calls = pending
862
942
  .into_iter()
863
943
  .filter(|e| !e.1.is_empty())
@@ -1084,7 +1164,7 @@ fn ollama_call(i: usize, tc: &Value) -> ToolCall {
1084
1164
 
1085
1165
  fn ollama_parse(v: Value) -> Result<Reply, String> {
1086
1166
  let msg = &v["message"];
1087
- let text = msg["content"].as_str().unwrap_or_default().to_string();
1167
+ let text = strip_think(msg["content"].as_str().unwrap_or_default());
1088
1168
  let mut calls = Vec::new();
1089
1169
  if let Some(tcs) = msg["tool_calls"].as_array() {
1090
1170
  for tc in tcs {
@@ -1139,6 +1219,7 @@ fn ollama_stream(
1139
1219
  ) -> Result<Reply, String> {
1140
1220
  let mut text = String::new();
1141
1221
  let mut in_think = false;
1222
+ let mut think_carry = String::new();
1142
1223
  let mut stop_reason: Option<String> = None;
1143
1224
  let mut stream_err: Option<String> = None;
1144
1225
  let mut calls: Vec<ToolCall> = Vec::new();
@@ -1161,7 +1242,14 @@ fn ollama_stream(
1161
1242
  }
1162
1243
  if let Some(t) = msg["content"].as_str() {
1163
1244
  if !t.is_empty() {
1164
- route_openai_content(t, &mut in_think, &mut text, on_text, on_thinking);
1245
+ route_openai_content(
1246
+ t,
1247
+ &mut in_think,
1248
+ &mut think_carry,
1249
+ &mut text,
1250
+ on_text,
1251
+ on_thinking,
1252
+ );
1165
1253
  }
1166
1254
  }
1167
1255
  // Unlike OpenAI's fragmented deltas, each streamed tool call arrives
@@ -1180,6 +1268,7 @@ fn ollama_stream(
1180
1268
  if let Some(e) = stream_err {
1181
1269
  return Err(e);
1182
1270
  }
1271
+ flush_think_carry(&think_carry, in_think, &mut text, on_text);
1183
1272
  Ok(Reply {
1184
1273
  text,
1185
1274
  calls,
@@ -1518,6 +1607,75 @@ mod tests {
1518
1607
  assert_eq!(think_out, "inspect first");
1519
1608
  }
1520
1609
 
1610
+ #[test]
1611
+ fn split_trailing_partial_holds_tag_prefix() {
1612
+ assert_eq!(
1613
+ split_trailing_partial("hello <thi", "<think>"),
1614
+ ("hello ", "<thi")
1615
+ );
1616
+ assert_eq!(
1617
+ split_trailing_partial("reason</thi", "</think>"),
1618
+ ("reason", "</thi")
1619
+ );
1620
+ // No trailing partial → nothing held.
1621
+ assert_eq!(
1622
+ split_trailing_partial("plain text", "<think>"),
1623
+ ("plain text", "")
1624
+ );
1625
+ // A complete tag ends with `>`, not a tag prefix, so nothing is held —
1626
+ // the caller's find() handles complete tags first.
1627
+ assert_eq!(
1628
+ split_trailing_partial("a<think>", "<think>"),
1629
+ ("a<think>", "")
1630
+ );
1631
+ }
1632
+
1633
+ #[test]
1634
+ fn openai_stream_handles_think_open_tag_split_across_chunks() {
1635
+ // "<think>" is split as "<thi" | "nk>".
1636
+ let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"hello <thi\"}}]}\n\
1637
+ data: {\"choices\":[{\"delta\":{\"content\":\"nk>reason</think>done\"}}]}\n\
1638
+ data: [DONE]\n";
1639
+ let mut text_out = String::new();
1640
+ let mut think_out = String::new();
1641
+ openai_stream(
1642
+ Cursor::new(sse.as_bytes().to_vec()),
1643
+ &mut |t| text_out.push_str(t),
1644
+ &mut |t| think_out.push_str(t),
1645
+ )
1646
+ .unwrap();
1647
+ assert_eq!(text_out, "hello done");
1648
+ assert_eq!(think_out, "reason");
1649
+ }
1650
+
1651
+ #[test]
1652
+ fn openai_stream_handles_think_close_tag_split_across_chunks() {
1653
+ // "</think>" is split as "</thi" | "nk>".
1654
+ let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"<think>reason</thi\"}}]}\n\
1655
+ data: {\"choices\":[{\"delta\":{\"content\":\"nk>done\"}}]}\n\
1656
+ data: [DONE]\n";
1657
+ let mut text_out = String::new();
1658
+ let mut think_out = String::new();
1659
+ openai_stream(
1660
+ Cursor::new(sse.as_bytes().to_vec()),
1661
+ &mut |t| text_out.push_str(t),
1662
+ &mut |t| think_out.push_str(t),
1663
+ )
1664
+ .unwrap();
1665
+ assert_eq!(text_out, "done");
1666
+ assert_eq!(think_out, "reason");
1667
+ }
1668
+
1669
+ #[test]
1670
+ fn openai_stream_flushes_false_partial_at_stream_end() {
1671
+ // Content that merely ends with a tag-prefix and then stops must not be
1672
+ // swallowed — it wasn't a real tag.
1673
+ let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"answer<thi\"}}]}\n\
1674
+ data: [DONE]\n";
1675
+ let r = drain_openai(sse);
1676
+ assert_eq!(r.text, "answer<thi");
1677
+ }
1678
+
1521
1679
  #[test]
1522
1680
  fn openai_stream_accumulates_text_and_stops_on_done() {
1523
1681
  let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\
@@ -1744,6 +1902,40 @@ mod tests {
1744
1902
  assert_eq!(r.calls[0].input, json!({}));
1745
1903
  }
1746
1904
 
1905
+ // ── think-tag stripping ─────────────────────────────────────────────────
1906
+ #[test]
1907
+ fn strip_think_removes_blocks_and_keeps_answer() {
1908
+ assert_eq!(
1909
+ strip_think("<think>let me reason</think>The answer is 42."),
1910
+ "The answer is 42."
1911
+ );
1912
+ // Multiple blocks, with real content between them.
1913
+ assert_eq!(strip_think("<think>a</think>X<think>b</think>Y"), "XY");
1914
+ // No tags → untouched.
1915
+ assert_eq!(strip_think("plain answer"), "plain answer");
1916
+ }
1917
+
1918
+ #[test]
1919
+ fn strip_think_drops_unclosed_reasoning() {
1920
+ // An unterminated <think> means the model never emitted a final answer;
1921
+ // the leaked reasoning must not become the answer.
1922
+ assert_eq!(strip_think("intro <think>reasoning with no close"), "intro");
1923
+ }
1924
+
1925
+ #[test]
1926
+ fn openai_parse_strips_think_from_content() {
1927
+ let v = json!({"choices": [{"message": {"content": "<think>plan</think>done"}}]});
1928
+ let r = openai_parse(v).unwrap();
1929
+ assert_eq!(r.text, "done");
1930
+ }
1931
+
1932
+ #[test]
1933
+ fn ollama_parse_strips_think_from_content() {
1934
+ let v = json!({"message": {"content": "<think>hmm</think>result"}, "done_reason": "stop"});
1935
+ let r = ollama_parse(v).unwrap();
1936
+ assert_eq!(r.text, "result");
1937
+ }
1938
+
1747
1939
  // ── stop_reason ─────────────────────────────────────────────────────────
1748
1940
  #[test]
1749
1941
  fn anthropic_parse_captures_stop_reason() {
@@ -1490,14 +1490,8 @@ fn strip_html(html: &str) -> String {
1490
1490
  i += 1;
1491
1491
  }
1492
1492
 
1493
- // Collapse whitespace and decode common HTML entities.
1494
- let decoded = out
1495
- .replace("&amp;", "&")
1496
- .replace("&lt;", "<")
1497
- .replace("&gt;", ">")
1498
- .replace("&quot;", "\"")
1499
- .replace("&nbsp;", " ")
1500
- .replace("&#39;", "'");
1493
+ // Collapse whitespace and decode HTML entities (named + numeric).
1494
+ let decoded = decode_html_entities(&out);
1501
1495
 
1502
1496
  let mut result = String::new();
1503
1497
  let mut prev_ws = true;
@@ -1515,6 +1509,205 @@ fn strip_html(html: &str) -> String {
1515
1509
  result.trim().to_string()
1516
1510
  }
1517
1511
 
1512
+ // One parsed web-search result.
1513
+ struct SearchHit {
1514
+ title: String,
1515
+ url: String,
1516
+ snippet: String,
1517
+ }
1518
+
1519
+ // Percent-decode a URL-encoded string (also treats `+` as space). Invalid
1520
+ // escapes are left as-is. Bytes are reassembled as UTF-8 lossily.
1521
+ fn percent_decode(s: &str) -> String {
1522
+ let bytes = s.as_bytes();
1523
+ let mut out = Vec::with_capacity(bytes.len());
1524
+ let mut i = 0;
1525
+ while i < bytes.len() {
1526
+ match bytes[i] {
1527
+ b'%' if i + 2 < bytes.len() => {
1528
+ if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
1529
+ out.push(b);
1530
+ i += 3;
1531
+ continue;
1532
+ }
1533
+ out.push(b'%');
1534
+ i += 1;
1535
+ }
1536
+ b'+' => {
1537
+ out.push(b' ');
1538
+ i += 1;
1539
+ }
1540
+ b => {
1541
+ out.push(b);
1542
+ i += 1;
1543
+ }
1544
+ }
1545
+ }
1546
+ String::from_utf8_lossy(&out).into_owned()
1547
+ }
1548
+
1549
+ // Resolve a single entity body (the text between `&` and `;`) to its character:
1550
+ // the common named entities plus decimal (`#8217`) and hex (`#x2019`) numeric
1551
+ // character references. Returns None for anything unrecognized.
1552
+ fn entity_char(ent: &str) -> Option<char> {
1553
+ match ent {
1554
+ "amp" => Some('&'),
1555
+ "lt" => Some('<'),
1556
+ "gt" => Some('>'),
1557
+ "quot" => Some('"'),
1558
+ "apos" => Some('\''),
1559
+ "nbsp" => Some(' '),
1560
+ "mdash" => Some('—'),
1561
+ "ndash" => Some('–'),
1562
+ "hellip" => Some('…'),
1563
+ "rsquo" => Some('’'),
1564
+ "lsquo" => Some('‘'),
1565
+ "rdquo" => Some('”'),
1566
+ "ldquo" => Some('“'),
1567
+ _ => {
1568
+ let num = ent.strip_prefix('#')?;
1569
+ let code = match num.strip_prefix('x').or_else(|| num.strip_prefix('X')) {
1570
+ Some(hex) => u32::from_str_radix(hex, 16).ok()?,
1571
+ None => num.parse::<u32>().ok()?,
1572
+ };
1573
+ char::from_u32(code)
1574
+ }
1575
+ }
1576
+ }
1577
+
1578
+ // Decode HTML entities in a single left-to-right pass — named entities and
1579
+ // decimal/hex numeric character references. Single-pass so `&amp;lt;` decodes to
1580
+ // the literal `&lt;` (not `<`), and an unrecognized `&…;` is left untouched.
1581
+ fn decode_html_entities(s: &str) -> String {
1582
+ if !s.contains('&') {
1583
+ return s.to_string();
1584
+ }
1585
+ let mut out = String::with_capacity(s.len());
1586
+ let mut rest = s;
1587
+ while let Some(amp) = rest.find('&') {
1588
+ out.push_str(&rest[..amp]);
1589
+ let tail = &rest[amp..];
1590
+ // `&` and `;` are ASCII, so `semi` is a valid char boundary; the ≤12
1591
+ // bound keeps a lone `&` in prose from swallowing a distant `;`.
1592
+ if let Some(semi) = tail.find(';') {
1593
+ if semi <= 12 {
1594
+ if let Some(ch) = entity_char(&tail[1..semi]) {
1595
+ out.push(ch);
1596
+ rest = &tail[semi + 1..];
1597
+ continue;
1598
+ }
1599
+ }
1600
+ }
1601
+ out.push('&');
1602
+ rest = &tail[1..];
1603
+ }
1604
+ out.push_str(rest);
1605
+ out
1606
+ }
1607
+
1608
+ // Read attribute `attr` from the tag that opens at byte index `tag_start`,
1609
+ // scanning only within that tag (up to its closing `>`).
1610
+ fn tag_attr(html: &str, tag_start: usize, attr: &str) -> Option<String> {
1611
+ let end = html[tag_start..].find('>').map(|e| tag_start + e)?;
1612
+ let seg = &html[tag_start..end];
1613
+ let key = format!("{attr}=");
1614
+ let after = &seg[seg.find(&key)? + key.len()..];
1615
+ let quote = after.chars().next()?;
1616
+ if quote == '"' || quote == '\'' {
1617
+ let rest = &after[1..];
1618
+ rest.find(quote).map(|c| rest[..c].to_string())
1619
+ } else {
1620
+ let endv = after.find(char::is_whitespace).unwrap_or(after.len());
1621
+ Some(after[..endv].to_string())
1622
+ }
1623
+ }
1624
+
1625
+ // DuckDuckGo Lite wraps result links in a redirect: `//duckduckgo.com/l/?uddg=
1626
+ // <percent-encoded-target>&rut=…`. Recover the real destination; fall back to
1627
+ // normalizing a protocol-relative href.
1628
+ fn ddg_real_url(href: &str) -> String {
1629
+ let href = href.replace("&amp;", "&");
1630
+ if let Some(p) = href.find("uddg=") {
1631
+ let rest = &href[p + "uddg=".len()..];
1632
+ let end = rest.find('&').unwrap_or(rest.len());
1633
+ return percent_decode(&rest[..end]);
1634
+ }
1635
+ if let Some(stripped) = href.strip_prefix("//") {
1636
+ return format!("https://{stripped}");
1637
+ }
1638
+ href.to_string()
1639
+ }
1640
+
1641
+ // Parse DuckDuckGo Lite result rows into structured hits. Returns empty if the
1642
+ // markup doesn't match (the caller then falls back to a stripped-text blob).
1643
+ fn parse_ddg_lite(html: &str) -> Vec<SearchHit> {
1644
+ let mut titles: Vec<(String, String)> = Vec::new();
1645
+ let mut i = 0;
1646
+ while let Some(rel) = html[i..].find("<a ") {
1647
+ let tag_start = i + rel;
1648
+ let Some(gt) = html[tag_start..].find('>').map(|e| tag_start + e) else {
1649
+ break;
1650
+ };
1651
+ if html[tag_start..gt].contains("result-link") {
1652
+ let url = ddg_real_url(&tag_attr(html, tag_start, "href").unwrap_or_default());
1653
+ let after = &html[gt + 1..];
1654
+ if let Some(close) = after.to_lowercase().find("</a") {
1655
+ let title = decode_html_entities(&strip_html(&after[..close]))
1656
+ .trim()
1657
+ .to_string();
1658
+ if !title.is_empty() {
1659
+ titles.push((title, url));
1660
+ }
1661
+ }
1662
+ }
1663
+ i = gt + 1;
1664
+ }
1665
+
1666
+ let mut snippets: Vec<String> = Vec::new();
1667
+ let mut j = 0;
1668
+ while let Some(rel) = html[j..].find("result-snippet") {
1669
+ let pos = j + rel;
1670
+ if let Some(gt) = html[pos..].find('>') {
1671
+ let start = pos + gt + 1;
1672
+ if let Some(lt) = html[start..].find('<') {
1673
+ snippets.push(
1674
+ decode_html_entities(&strip_html(&html[start..start + lt]))
1675
+ .trim()
1676
+ .to_string(),
1677
+ );
1678
+ }
1679
+ }
1680
+ j = pos + "result-snippet".len();
1681
+ }
1682
+
1683
+ titles
1684
+ .into_iter()
1685
+ .enumerate()
1686
+ .map(|(k, (title, url))| SearchHit {
1687
+ title,
1688
+ url,
1689
+ snippet: snippets.get(k).cloned().unwrap_or_default(),
1690
+ })
1691
+ .collect()
1692
+ }
1693
+
1694
+ // Render parsed hits as compact, numbered `title / url / snippet` blocks that a
1695
+ // small model can read, capped at `max` results.
1696
+ fn format_search_hits(hits: &[SearchHit], max: usize) -> String {
1697
+ let mut out = String::new();
1698
+ for (n, h) in hits.iter().take(max).enumerate() {
1699
+ out.push_str(&format!("{}. {}\n", n + 1, h.title));
1700
+ if !h.url.is_empty() {
1701
+ out.push_str(&format!(" {}\n", h.url));
1702
+ }
1703
+ if !h.snippet.is_empty() {
1704
+ out.push_str(&format!(" {}\n", h.snippet));
1705
+ }
1706
+ out.push('\n');
1707
+ }
1708
+ out.trim_end().to_string()
1709
+ }
1710
+
1518
1711
  // Commands so destructive they require confirmation in every mode.
1519
1712
  pub fn catastrophic(cmd: &str) -> bool {
1520
1713
  let lower = cmd.to_lowercase();
@@ -1861,13 +2054,170 @@ fn xml_escape(s: &str) -> String {
1861
2054
  }
1862
2055
 
1863
2056
  fn docx_paragraph(text: &str, style: Option<&str>) -> String {
1864
- let style = style
2057
+ let ppr = style
1865
2058
  .map(|s| format!("<w:pPr><w:pStyle w:val=\"{s}\"/></w:pPr>"))
1866
2059
  .unwrap_or_default();
1867
- format!(
1868
- "<w:p>{style}<w:r><w:t>{}</w:t></w:r></w:p>",
2060
+ format!("<w:p>{ppr}{}</w:p>", docx_runs(text))
2061
+ }
2062
+
2063
+ // Emit one Word run for `text` with the active inline formatting. Empty text
2064
+ // produces nothing (an all-empty paragraph is still valid OOXML).
2065
+ fn docx_push_run(out: &mut String, text: &str, bold: bool, italic: bool, code: bool) {
2066
+ if text.is_empty() {
2067
+ return;
2068
+ }
2069
+ let mut rpr = String::new();
2070
+ if bold {
2071
+ rpr.push_str("<w:b/>");
2072
+ }
2073
+ if italic {
2074
+ rpr.push_str("<w:i/>");
2075
+ }
2076
+ if code {
2077
+ rpr.push_str("<w:rFonts w:ascii=\"Consolas\" w:hAnsi=\"Consolas\" w:cs=\"Consolas\"/>");
2078
+ }
2079
+ if !rpr.is_empty() {
2080
+ rpr = format!("<w:rPr>{rpr}</w:rPr>");
2081
+ }
2082
+ out.push_str("<w:r>");
2083
+ out.push_str(&rpr);
2084
+ out.push_str(&format!(
2085
+ "<w:t xml:space=\"preserve\">{}</w:t></w:r>",
1869
2086
  xml_escape(text)
1870
- )
2087
+ ));
2088
+ }
2089
+
2090
+ // Light-markdown inline formatting → Word runs: `**bold**`, `*italic*`, and
2091
+ // `` `code` `` (verbatim). Emphasis markers only toggle when they flank
2092
+ // non-space text, so arithmetic like `5 * 3` and glob patterns stay literal;
2093
+ // underscores are deliberately left alone so `snake_case` isn't italicized.
2094
+ fn docx_runs(text: &str) -> String {
2095
+ let chars: Vec<char> = text.chars().collect();
2096
+ let n = chars.len();
2097
+ let mut out = String::new();
2098
+ let mut buf = String::new();
2099
+ let (mut bold, mut italic, mut code) = (false, false, false);
2100
+ let mut i = 0;
2101
+ while i < n {
2102
+ let c = chars[i];
2103
+ // Inside a code span everything is literal until the closing backtick.
2104
+ if code {
2105
+ if c == '`' {
2106
+ docx_push_run(&mut out, &buf, bold, italic, code);
2107
+ buf.clear();
2108
+ code = false;
2109
+ } else {
2110
+ buf.push(c);
2111
+ }
2112
+ i += 1;
2113
+ continue;
2114
+ }
2115
+ match c {
2116
+ '`' => {
2117
+ docx_push_run(&mut out, &buf, bold, italic, code);
2118
+ buf.clear();
2119
+ code = true;
2120
+ i += 1;
2121
+ }
2122
+ '*' if i + 1 < n && chars[i + 1] == '*' => {
2123
+ // Opening `**` must precede non-space; closing must follow it.
2124
+ let flanks = if bold {
2125
+ i > 0 && !chars[i - 1].is_whitespace()
2126
+ } else {
2127
+ i + 2 < n && !chars[i + 2].is_whitespace()
2128
+ };
2129
+ if flanks {
2130
+ docx_push_run(&mut out, &buf, bold, italic, code);
2131
+ buf.clear();
2132
+ bold = !bold;
2133
+ } else {
2134
+ buf.push('*');
2135
+ buf.push('*');
2136
+ }
2137
+ i += 2;
2138
+ }
2139
+ '*' => {
2140
+ let flanks = if italic {
2141
+ i > 0 && !chars[i - 1].is_whitespace()
2142
+ } else {
2143
+ i + 1 < n && !chars[i + 1].is_whitespace()
2144
+ };
2145
+ if flanks {
2146
+ docx_push_run(&mut out, &buf, bold, italic, code);
2147
+ buf.clear();
2148
+ italic = !italic;
2149
+ } else {
2150
+ buf.push('*');
2151
+ }
2152
+ i += 1;
2153
+ }
2154
+ _ => {
2155
+ buf.push(c);
2156
+ i += 1;
2157
+ }
2158
+ }
2159
+ }
2160
+ docx_push_run(&mut out, &buf, bold, italic, code);
2161
+ out
2162
+ }
2163
+
2164
+ // Split a markdown table line into trimmed cells, dropping the outer pipes.
2165
+ fn parse_table_cells(line: &str) -> Vec<String> {
2166
+ let t = line.trim();
2167
+ let t = t.strip_prefix('|').unwrap_or(t);
2168
+ let t = t.strip_suffix('|').unwrap_or(t);
2169
+ t.split('|').map(|c| c.trim().to_string()).collect()
2170
+ }
2171
+
2172
+ // A markdown table row: trimmed, starts with `|`, and has at least two pipes.
2173
+ fn is_table_row(line: &str) -> bool {
2174
+ let t = line.trim();
2175
+ t.starts_with('|') && t.matches('|').count() >= 2
2176
+ }
2177
+
2178
+ // The `|---|:--:|` alignment row: every cell is only dashes/colons.
2179
+ fn is_table_separator(line: &str) -> bool {
2180
+ let cells = parse_table_cells(line);
2181
+ !cells.is_empty()
2182
+ && cells
2183
+ .iter()
2184
+ .all(|c| !c.is_empty() && c.chars().all(|ch| ch == '-' || ch == ':'))
2185
+ }
2186
+
2187
+ // One table cell. Header cells are a single bold run; body cells get full
2188
+ // inline markdown formatting.
2189
+ fn docx_table_cell(text: &str, header: bool) -> String {
2190
+ let runs = if header {
2191
+ let mut s = String::new();
2192
+ docx_push_run(&mut s, text, true, false, false);
2193
+ s
2194
+ } else {
2195
+ docx_runs(text)
2196
+ };
2197
+ format!("<w:tc><w:tcPr><w:tcW w:w=\"0\" w:type=\"auto\"/></w:tcPr><w:p>{runs}</w:p></w:tc>")
2198
+ }
2199
+
2200
+ // A bordered Word table. The first row is treated as the (bold) header.
2201
+ fn docx_table(rows: &[Vec<String>]) -> String {
2202
+ let mut out = String::from(
2203
+ "<w:tbl><w:tblPr><w:tblW w:w=\"0\" w:type=\"auto\"/><w:tblBorders>\
2204
+ <w:top w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
2205
+ <w:left w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
2206
+ <w:bottom w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
2207
+ <w:right w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
2208
+ <w:insideH w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
2209
+ <w:insideV w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>\
2210
+ </w:tblBorders></w:tblPr>",
2211
+ );
2212
+ for (r, row) in rows.iter().enumerate() {
2213
+ out.push_str("<w:tr>");
2214
+ for cell in row {
2215
+ out.push_str(&docx_table_cell(cell, r == 0));
2216
+ }
2217
+ out.push_str("</w:tr>");
2218
+ }
2219
+ out.push_str("</w:tbl>");
2220
+ out
1871
2221
  }
1872
2222
 
1873
2223
  fn docx_document(title: &str, body: &str) -> String {
@@ -1875,8 +2225,25 @@ fn docx_document(title: &str, body: &str) -> String {
1875
2225
  r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>"#,
1876
2226
  );
1877
2227
  out.push_str(&docx_paragraph(title, Some("Title")));
1878
- for line in body.lines() {
1879
- let trimmed = line.trim();
2228
+ let lines: Vec<&str> = body.lines().collect();
2229
+ let mut idx = 0;
2230
+ while idx < lines.len() {
2231
+ // A run of consecutive `|`-delimited rows becomes one Word table; the
2232
+ // alignment separator row is dropped and the first row is the header.
2233
+ if is_table_row(lines[idx]) {
2234
+ let mut rows: Vec<Vec<String>> = Vec::new();
2235
+ while idx < lines.len() && is_table_row(lines[idx]) {
2236
+ if !is_table_separator(lines[idx]) {
2237
+ rows.push(parse_table_cells(lines[idx]));
2238
+ }
2239
+ idx += 1;
2240
+ }
2241
+ if !rows.is_empty() {
2242
+ out.push_str(&docx_table(&rows));
2243
+ }
2244
+ continue;
2245
+ }
2246
+ let trimmed = lines[idx].trim();
1880
2247
  if trimmed.is_empty() {
1881
2248
  out.push_str("<w:p/>");
1882
2249
  } else if let Some(h) = trimmed.strip_prefix("### ") {
@@ -1890,6 +2257,7 @@ fn docx_document(title: &str, body: &str) -> String {
1890
2257
  } else {
1891
2258
  out.push_str(&docx_paragraph(trimmed, None));
1892
2259
  }
2260
+ idx += 1;
1893
2261
  }
1894
2262
  out.push_str(r#"<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>"#);
1895
2263
  out
@@ -2983,7 +3351,20 @@ pub fn run(name: &str, input: &Value, cwd: &Path) -> Outcome {
2983
3351
  .set("User-Agent", "Mozilla/5.0 (compatible; buildwithnexus/1.0)"),
2984
3352
  ) {
2985
3353
  Ok(resp) => match resp.into_string() {
2986
- Ok(html) => ok(truncate(strip_html(&html), MAX_OUT)),
3354
+ Ok(html) => {
3355
+ let hits = parse_ddg_lite(&html);
3356
+ if hits.is_empty() {
3357
+ // Markup didn't match — fall back to the raw text so
3358
+ // the model still gets something.
3359
+ ok(truncate(strip_html(&html), MAX_OUT))
3360
+ } else {
3361
+ let body = format_search_hits(&hits, 10);
3362
+ ok(truncate(
3363
+ format!("{} results for \"{query}\":\n\n{body}", hits.len()),
3364
+ MAX_OUT,
3365
+ ))
3366
+ }
3367
+ }
2987
3368
  Err(e) => err(format!("search response error: {e}")),
2988
3369
  },
2989
3370
  Err(e) => err(format!("web search failed: {e}")),
@@ -3802,6 +4183,178 @@ mod tests {
3802
4183
  use super::*;
3803
4184
  use std::path::PathBuf;
3804
4185
 
4186
+ // ── web search parsing ──────────────────────────────────────────────────
4187
+ const DDG_LITE_SAMPLE: &str = r#"<html><body><form>search</form><table>
4188
+ <tr><td>1.&nbsp;</td><td>
4189
+ <a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fdoc.rust-lang.org%2Fstd%2F&amp;rut=abc" class='result-link'>Rust std docs</a>
4190
+ </td></tr>
4191
+ <tr><td>&nbsp;</td><td class='result-snippet'>The Rust Standard Library &amp; API reference.</td></tr>
4192
+ <tr><td>2.&nbsp;</td><td>
4193
+ <a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fcrates.io%2F&amp;rut=def" class='result-link'>crates.io</a>
4194
+ </td></tr>
4195
+ <tr><td>&nbsp;</td><td class='result-snippet'>The Rust package registry.</td></tr>
4196
+ </table></body></html>"#;
4197
+
4198
+ #[test]
4199
+ fn percent_decode_handles_escapes_and_plus() {
4200
+ assert_eq!(percent_decode("a%2Fb%20c+d"), "a/b c d");
4201
+ // A malformed trailing escape is left literal, not dropped.
4202
+ assert_eq!(percent_decode("100%"), "100%");
4203
+ }
4204
+
4205
+ #[test]
4206
+ fn decode_html_entities_single_pass_no_double_decode() {
4207
+ // Single pass: &amp; becomes a literal & and the following "lt;" is left
4208
+ // alone, so this must not collapse to "<".
4209
+ assert_eq!(decode_html_entities("a &amp;lt; b"), "a &lt; b");
4210
+ assert_eq!(
4211
+ decode_html_entities("x &lt;y&gt; &quot;z&quot;"),
4212
+ "x <y> \"z\""
4213
+ );
4214
+ }
4215
+
4216
+ #[test]
4217
+ fn decode_html_entities_resolves_numeric_and_typographic() {
4218
+ // Decimal and hex numeric references for a curly apostrophe.
4219
+ assert_eq!(decode_html_entities("it&#8217;s"), "it’s");
4220
+ assert_eq!(decode_html_entities("it&#x2019;s"), "it’s");
4221
+ // Common typographic named entities.
4222
+ assert_eq!(decode_html_entities("a &mdash; b &hellip;"), "a — b …");
4223
+ }
4224
+
4225
+ #[test]
4226
+ fn decode_html_entities_leaves_unknown_and_bare_amp_literal() {
4227
+ assert_eq!(decode_html_entities("Tom & Jerry"), "Tom & Jerry");
4228
+ assert_eq!(decode_html_entities("&notreal;"), "&notreal;");
4229
+ // A `&` with no nearby `;` is untouched.
4230
+ assert_eq!(
4231
+ decode_html_entities("cats & dogs everywhere"),
4232
+ "cats & dogs everywhere"
4233
+ );
4234
+ }
4235
+
4236
+ #[test]
4237
+ fn ddg_real_url_recovers_target_from_redirect() {
4238
+ assert_eq!(
4239
+ ddg_real_url("//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa&amp;rut=z"),
4240
+ "https://example.com/a"
4241
+ );
4242
+ assert_eq!(ddg_real_url("//example.com/x"), "https://example.com/x");
4243
+ }
4244
+
4245
+ #[test]
4246
+ fn parse_ddg_lite_extracts_structured_hits() {
4247
+ let hits = parse_ddg_lite(DDG_LITE_SAMPLE);
4248
+ assert_eq!(hits.len(), 2);
4249
+ assert_eq!(hits[0].title, "Rust std docs");
4250
+ assert_eq!(hits[0].url, "https://doc.rust-lang.org/std/");
4251
+ assert_eq!(
4252
+ hits[0].snippet,
4253
+ "The Rust Standard Library & API reference."
4254
+ );
4255
+ assert_eq!(hits[1].title, "crates.io");
4256
+ assert_eq!(hits[1].url, "https://crates.io/");
4257
+ }
4258
+
4259
+ #[test]
4260
+ fn parse_ddg_lite_returns_empty_on_unrelated_html() {
4261
+ assert!(parse_ddg_lite("<html><body>no results here</body></html>").is_empty());
4262
+ }
4263
+
4264
+ #[test]
4265
+ fn format_search_hits_numbers_and_caps() {
4266
+ let hits = parse_ddg_lite(DDG_LITE_SAMPLE);
4267
+ let out = format_search_hits(&hits, 1);
4268
+ assert!(out.starts_with("1. Rust std docs"));
4269
+ assert!(out.contains("https://doc.rust-lang.org/std/"));
4270
+ // Capped at 1 — the second hit must not appear.
4271
+ assert!(!out.contains("crates.io"));
4272
+ }
4273
+
4274
+ // ── docx tables ─────────────────────────────────────────────────────────
4275
+ #[test]
4276
+ fn table_row_and_separator_detection() {
4277
+ assert!(is_table_row("| a | b |"));
4278
+ assert!(is_table_row("|a|b|"));
4279
+ assert!(!is_table_row("a | b")); // no leading pipe
4280
+ assert!(!is_table_row("- bullet"));
4281
+ assert!(is_table_separator("| --- | :---: |"));
4282
+ assert!(!is_table_separator("| a | b |"));
4283
+ }
4284
+
4285
+ #[test]
4286
+ fn parse_table_cells_drops_outer_pipes_and_trims() {
4287
+ assert_eq!(parse_table_cells("| a | b |c|"), vec!["a", "b", "c"]);
4288
+ }
4289
+
4290
+ #[test]
4291
+ fn docx_document_renders_markdown_table() {
4292
+ let body = "Intro line.\n| Name | Price |\n| --- | --- |\n| Glazed | $1.50 |\n| Old Fashioned | $2.00 |\n\nOutro.";
4293
+ let doc = docx_document("Menu", body);
4294
+ // A real table with a header row is emitted.
4295
+ assert!(doc.contains("<w:tbl>"));
4296
+ assert_eq!(doc.matches("<w:tr>").count(), 3); // header + 2 body rows
4297
+ // Header cells are bold; the separator row is gone.
4298
+ assert!(doc.contains("<w:rPr><w:b/></w:rPr><w:t xml:space=\"preserve\">Name</w:t>"));
4299
+ assert!(!doc.contains("---"));
4300
+ // Surrounding prose still renders as paragraphs.
4301
+ assert!(doc.contains(">Intro line.</w:t>"));
4302
+ assert!(doc.contains(">Outro.</w:t>"));
4303
+ }
4304
+
4305
+ #[test]
4306
+ fn docx_document_without_table_is_unchanged_shape() {
4307
+ let doc = docx_document("T", "# Head\n- item\nplain");
4308
+ assert!(!doc.contains("<w:tbl>"));
4309
+ assert!(doc.contains(">• item</w:t>"));
4310
+ }
4311
+
4312
+ // ── docx inline formatting ──────────────────────────────────────────────
4313
+ #[test]
4314
+ fn docx_runs_renders_bold_italic_and_code() {
4315
+ let out = docx_runs("plain **bold** and *italic* and `code` end");
4316
+ // Bold run carries <w:b/>, italic <w:i/>, code the monospace font.
4317
+ assert!(out.contains("<w:rPr><w:b/></w:rPr><w:t xml:space=\"preserve\">bold</w:t>"));
4318
+ assert!(out.contains("<w:rPr><w:i/></w:rPr><w:t xml:space=\"preserve\">italic</w:t>"));
4319
+ assert!(out.contains("w:ascii=\"Consolas\""));
4320
+ assert!(out.contains(">code</w:t>"));
4321
+ // The markers themselves are consumed, not emitted as literal text.
4322
+ assert!(!out.contains('*'));
4323
+ assert!(!out.contains('`'));
4324
+ }
4325
+
4326
+ #[test]
4327
+ fn docx_runs_leaves_arithmetic_and_snake_case_literal() {
4328
+ // Spaced `*` is not emphasis; `_` never toggles italic.
4329
+ let out = docx_runs("compute 5 * 3 for my_var_name");
4330
+ assert!(!out.contains("<w:i/>"));
4331
+ assert!(!out.contains("<w:b/>"));
4332
+ assert!(out.contains("5 * 3 for my_var_name"));
4333
+ }
4334
+
4335
+ #[test]
4336
+ fn docx_runs_code_span_is_verbatim() {
4337
+ // Emphasis markers inside a code span stay literal.
4338
+ let out = docx_runs("call `a*b` now");
4339
+ assert!(out.contains(">a*b</w:t>"));
4340
+ assert!(!out.contains("<w:i/>"));
4341
+ }
4342
+
4343
+ #[test]
4344
+ fn docx_runs_escapes_xml_in_runs() {
4345
+ let out = docx_runs("**a < b & c**");
4346
+ assert!(out.contains("a &lt; b &amp; c"));
4347
+ assert!(out.contains("<w:b/>"));
4348
+ }
4349
+
4350
+ #[test]
4351
+ fn docx_runs_unmatched_marker_stays_literal() {
4352
+ // A lone trailing `**` with nothing after it must not open emphasis.
4353
+ let out = docx_runs("trailing **");
4354
+ assert!(!out.contains("<w:b/>"));
4355
+ assert!(out.contains("trailing **"));
4356
+ }
4357
+
3805
4358
  // ── truncate ────────────────────────────────────────────────────────────
3806
4359
  #[test]
3807
4360
  fn truncate_shorter_than_max_unchanged() {
@@ -1074,39 +1074,89 @@ fn format_links(s: &str) -> String {
1074
1074
  out
1075
1075
  }
1076
1076
 
1077
- fn format_italics(s: &str) -> String {
1078
- if !s.contains('*') {
1079
- return s.to_string();
1077
+ // Flush the pending plain-text run to `out`, wrapping it in the styles active
1078
+ // when it was collected. Italic is applied inside bold so both can nest.
1079
+ fn flush_styled_run(out: &mut String, buf: &mut String, bold_on: bool, italic_on: bool) {
1080
+ if buf.is_empty() {
1081
+ return;
1080
1082
  }
1081
- let mut out = String::with_capacity(s.len() + 16);
1082
- let mut is_italic = false;
1083
- for (i, part) in s.split('*').enumerate() {
1084
- if i > 0 && is_italic {
1085
- out.push_str(&italic(part));
1086
- } else {
1087
- out.push_str(part);
1088
- }
1089
- is_italic = !is_italic;
1083
+ let mut s = std::mem::take(buf);
1084
+ if italic_on {
1085
+ s = italic(&s);
1090
1086
  }
1091
- out
1087
+ if bold_on {
1088
+ s = bold(&s);
1089
+ }
1090
+ out.push_str(&s);
1092
1091
  }
1093
1092
 
1093
+ // Render inline Markdown (`` `code` ``, `**bold**`, `*italic*`) into ANSI in a
1094
+ // single left-to-right pass. A marker only opens a style when a matching closer
1095
+ // exists ahead and it isn't followed by whitespace, so an unmatched `` ` ``,
1096
+ // `**`, or `*` — including arithmetic like `5 * 3` — stays literal instead of
1097
+ // styling the rest of the line. Links are resolved first by `format_links`.
1094
1098
  fn format_inline_md(text: &str) -> String {
1095
- let mut out = String::with_capacity(text.len() + 32);
1096
- for (i, part) in text.split('`').enumerate() {
1097
- if i % 2 == 1 {
1098
- out.push_str(&yellow(part));
1099
- } else {
1100
- let with_links = format_links(part);
1101
- for (j, bpart) in with_links.split("**").enumerate() {
1102
- if j % 2 == 1 {
1103
- out.push_str(&bold(bpart));
1104
- } else {
1105
- out.push_str(&format_italics(bpart));
1106
- }
1099
+ let linked = format_links(text);
1100
+ let chars: Vec<char> = linked.chars().collect();
1101
+ let n = chars.len();
1102
+ let mut out = String::with_capacity(linked.len() + 32);
1103
+ let mut buf = String::new();
1104
+ let (mut bold_on, mut italic_on) = (false, false);
1105
+ let mut i = 0;
1106
+ while i < n {
1107
+ let c = chars[i];
1108
+ if c == '`' {
1109
+ // Code span: style only if there's a closing backtick ahead.
1110
+ if let Some(rel) = chars[i + 1..].iter().position(|&x| x == '`') {
1111
+ flush_styled_run(&mut out, &mut buf, bold_on, italic_on);
1112
+ let code: String = chars[i + 1..i + 1 + rel].iter().collect();
1113
+ out.push_str(&yellow(&code));
1114
+ i += rel + 2;
1115
+ continue;
1116
+ }
1117
+ buf.push('`');
1118
+ i += 1;
1119
+ continue;
1120
+ }
1121
+ if c == '*' && i + 1 < n && chars[i + 1] == '*' {
1122
+ let toggles = if bold_on {
1123
+ i > 0 && !chars[i - 1].is_whitespace()
1124
+ } else {
1125
+ i + 2 < n
1126
+ && !chars[i + 2].is_whitespace()
1127
+ && chars[i + 2..]
1128
+ .windows(2)
1129
+ .any(|w| w[0] == '*' && w[1] == '*')
1130
+ };
1131
+ if toggles {
1132
+ flush_styled_run(&mut out, &mut buf, bold_on, italic_on);
1133
+ bold_on = !bold_on;
1134
+ } else {
1135
+ buf.push('*');
1136
+ buf.push('*');
1137
+ }
1138
+ i += 2;
1139
+ continue;
1140
+ }
1141
+ if c == '*' {
1142
+ let toggles = if italic_on {
1143
+ i > 0 && !chars[i - 1].is_whitespace()
1144
+ } else {
1145
+ i + 1 < n && !chars[i + 1].is_whitespace() && chars[i + 1..].contains(&'*')
1146
+ };
1147
+ if toggles {
1148
+ flush_styled_run(&mut out, &mut buf, bold_on, italic_on);
1149
+ italic_on = !italic_on;
1150
+ } else {
1151
+ buf.push('*');
1107
1152
  }
1153
+ i += 1;
1154
+ continue;
1108
1155
  }
1156
+ buf.push(c);
1157
+ i += 1;
1109
1158
  }
1159
+ flush_styled_run(&mut out, &mut buf, bold_on, italic_on);
1110
1160
  out
1111
1161
  }
1112
1162
 
@@ -3591,6 +3641,25 @@ mod tests {
3591
3641
  assert_eq!(super::end_word(&b, 10), 14);
3592
3642
  }
3593
3643
 
3644
+ #[test]
3645
+ fn inline_md_unbalanced_markers_stay_literal() {
3646
+ // A single `*` (arithmetic), a lone backtick, and an unmatched `**`
3647
+ // must not style the rest of the line — they render verbatim.
3648
+ assert_eq!(format_inline_md("5 * 3 = 15"), "5 * 3 = 15");
3649
+ assert_eq!(format_inline_md("call `foo now"), "call `foo now");
3650
+ assert_eq!(format_inline_md("a ** b"), "a ** b");
3651
+ }
3652
+
3653
+ #[test]
3654
+ fn inline_md_balanced_markers_consume_delimiters() {
3655
+ // Balanced emphasis/code: the markers are consumed and the inner text
3656
+ // preserved (checked after stripping ANSI, so this holds in any color
3657
+ // mode).
3658
+ assert_eq!(plain(&format_inline_md("a *word* b")), "a word b");
3659
+ assert_eq!(plain(&format_inline_md("a **bold** c")), "a bold c");
3660
+ assert_eq!(plain(&format_inline_md("use `code` here")), "use code here");
3661
+ }
3662
+
3594
3663
  #[test]
3595
3664
  fn render_md_draws_fenced_code_blocks() {
3596
3665
  let doc = "before\n```rust\nlet x = 1;\n```\nafter";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buildwithnexus",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
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",