Support Qwen 3 <think> blocks and add 14B model
- Parse and render Qwen 3 `<think>…</think>` reasoning blocks in chat - Add Qwen_Qwen3-14B-GGUF model support - Update CI to trigger on pull_request and workflow_dispatch
paydii committed
Apr 26, 2026 at 07:47 UTC
a58ff6ef1a7708489b132ba0bed174e1ff049942
4 files changed
+107
-3
.github/workflows/ci.yml
+4
-1
@@ -4,7 +4,10 @@ name: CI
4
# Splits formatting, clippy, and tests into separate jobs for clearer reporting
5
# across supported desktop targets.
6
7
-on: push
7
+on:
8
+ push:
9
+ pull_request:
10
+ workflow_dispatch:
11
12
env:
13
CARGO_TERM_COLOR: always
src/chat.rs
+97
-1
@@ -30,6 +30,44 @@ use ratatui::{
30
use tokio::sync::mpsc;
31
use tokio::time::{Duration, Instant, interval};
32
33
+// ── Think-block stripping ─────────────────────────────────────────────────────
34
+
35
+/// Strip `<think>…</think>` blocks from a model response.
36
+///
37
+/// Qwen 3 models emit `<think>…</think>` before the real answer. This
38
+/// function separates the thinking content from the visible reply so the
39
+/// UI can render them differently (dimmed / collapsed).
40
+///
41
+/// Returns `(thinking_text, visible_reply)`. Either may be empty.
42
+pub(crate) fn strip_think_blocks(raw: &str) -> (String, String) {
43
+ let mut thinking = String::new();
44
+ let mut remainder = raw;
45
+
46
+ while let Some(start) = remainder.find("<think>") {
47
+ // Text before <think> is visible.
48
+ let before = &remainder[..start];
49
+ if let Some(end) = remainder[start..].find("</think>") {
50
+ let block = &remainder[start + 7..start + end];
51
+ thinking.push_str(block.trim());
52
+ remainder = &remainder[start + end + 8..];
53
+ // Prepend any text before <think> to the leftover.
54
+ if !before.trim().is_empty() {
55
+ // Unusual — text before <think>. Keep it visible.
56
+ let mut combined = before.to_string();
57
+ combined.push_str(remainder);
58
+ return (thinking, combined.trim().to_string());
59
+ }
60
+ } else {
61
+ // Unclosed <think> — treat rest as thinking (model ran out of tokens).
62
+ thinking.push_str(remainder[start + 7..].trim());
63
+ remainder = before;
64
+ break;
65
+ }
66
+ }
67
+
68
+ (thinking, remainder.trim().to_string())
69
+}
70
+
71
// ── Message types ─────────────────────────────────────────────────────────────
72
73
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -44,6 +82,8 @@ enum Role {
82
struct ChatMessage {
83
role: Role,
84
text: String,
85
+ /// Extracted `<think>…</think>` content, if any (Qwen 3 reasoning).
86
+ think_block: Option<String>,
87
}
88
89
impl ChatMessage {
@@ -51,13 +91,17 @@ impl ChatMessage {
91
Self {
92
role: Role::User,
93
text: text.into(),
94
+ think_block: None,
95
}
96
}
97
98
fn assistant(text: impl Into<String>) -> Self {
99
+ let raw = text.into();
100
+ let (think, visible) = strip_think_blocks(&raw);
101
Self {
102
role: Role::Assistant,
60
- text: text.into(),
103
+ text: visible,
104
+ think_block: if think.is_empty() { None } else { Some(think) },
105
}
106
}
107
@@ -65,6 +109,7 @@ impl ChatMessage {
109
Self {
110
role: Role::System,
111
text: text.into(),
112
+ think_block: None,
113
}
114
}
115
@@ -72,6 +117,7 @@ impl ChatMessage {
117
Self {
118
role: Role::Banner,
119
text: text.into(),
120
+ think_block: None,
121
}
122
}
123
}
@@ -844,6 +890,24 @@ fn render_chat_message<'a>(lines: &mut Vec<Line<'a>>, msg: &ChatMessage) {
890
}
891
}
892
Role::Assistant => {
893
+ // Show thinking block dimmed if present.
894
+ if let Some(ref think) = msg.think_block {
895
+ let think_summary = if think.len() > 120 {
896
+ format!("{}…", &think[..120])
897
+ } else {
898
+ think.clone()
899
+ };
900
+ lines.push(Line::from(vec![
901
+ Span::styled("💭 ", Style::default().fg(Color::DarkGray)),
902
+ Span::styled(
903
+ think_summary,
904
+ Style::default()
905
+ .fg(Color::DarkGray)
906
+ .add_modifier(Modifier::ITALIC),
907
+ ),
908
+ ]));
909
+ }
910
+
911
for (i, segment) in text_lines.iter().enumerate() {
912
let mut spans = Vec::new();
913
if i == 0 {
@@ -1509,3 +1573,35 @@ async fn event_loop<B: ratatui::backend::Backend>(
1573
1574
Ok(())
1575
}
1576
+
1577
+#[cfg(test)]
1578
+mod tests {
1579
+ use super::strip_think_blocks;
1580
+
1581
+ #[test]
1582
+ fn strip_think_blocks_separates_thinking_and_visible_reply() {
1583
+ let raw = "<think>I should inspect the code first.</think>Here is the fix.";
1584
+ let (thinking, visible) = strip_think_blocks(raw);
1585
+
1586
+ assert_eq!(thinking, "I should inspect the code first.");
1587
+ assert_eq!(visible, "Here is the fix.");
1588
+ }
1589
+
1590
+ #[test]
1591
+ fn strip_think_blocks_handles_unclosed_think_block() {
1592
+ let raw = "<think>I am still reasoning about the bug";
1593
+ let (thinking, visible) = strip_think_blocks(raw);
1594
+
1595
+ assert_eq!(thinking, "I am still reasoning about the bug");
1596
+ assert_eq!(visible, "");
1597
+ }
1598
+
1599
+ #[test]
1600
+ fn strip_think_blocks_leaves_plain_text_untouched() {
1601
+ let raw = "No hidden reasoning here.";
1602
+ let (thinking, visible) = strip_think_blocks(raw);
1603
+
1604
+ assert_eq!(thinking, "");
1605
+ assert_eq!(visible, "No hidden reasoning here.");
1606
+ }
1607
+}
src/main.rs
+4
-1
@@ -1120,7 +1120,10 @@ impl Agent for SiGitAgent {
1120
String::new()
1121
}
1122
} else {
1123
- reply_text
1123
+ // Strip Qwen 3 `<think>…</think>` blocks — the editor doesn't
1124
+ // need to see internal reasoning tokens.
1125
+ let (_think, visible) = chat::strip_think_blocks(&reply_text);
1126
+ visible
1127
};
1128
1129
if !final_text.is_empty() {
src/models.rs
+2
@@ -44,6 +44,7 @@ pub(crate) fn model_id_to_config(model_id: &str) -> Option<GgufModelConfig> {
44
Some(match model_id {
45
"bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
46
"bartowski/Qwen_Qwen3-8B-GGUF" => GgufModelConfig::qwen3_8b(),
47
+ "bartowski/Qwen_Qwen3-14B-GGUF" => GgufModelConfig::qwen3_14b(),
48
"bartowski/Qwen_Qwen3-1.7B-GGUF" => GgufModelConfig::qwen3_1_7b(),
49
"bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(),
50
"bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(),
@@ -61,6 +62,7 @@ fn is_tool_calling(model_id: &str) -> bool {
62
model_id,
63
"bartowski/Qwen_Qwen3-4B-GGUF"
64
| "bartowski/Qwen_Qwen3-8B-GGUF"
65
+ | "bartowski/Qwen_Qwen3-14B-GGUF"
66
| "bartowski/Qwen_Qwen3-1.7B-GGUF"
67
| "bartowski/Qwen2.5-Coder-7B-Instruct-GGUF"
68
)