buildwithnexus 0.11.1 → 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.1"
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.1"
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));
@@ -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() {