codetether_agent/tui/app/
message_text.rs1use std::collections::HashMap;
2
3use crate::provider::{ContentPart, Message, Role};
4use crate::session::Session;
5use crate::tui::app::state::App;
6use crate::tui::app::text::truncate_preview;
7use crate::tui::chat::message::{ChatMessage, MessageType};
8
9pub fn extract_message_text(content: &[ContentPart]) -> String {
10 let mut out = String::new();
11 for part in content {
12 match part {
13 ContentPart::Text { text } | ContentPart::Thinking { text } => {
14 push_line(&mut out, text)
15 }
16 ContentPart::ToolCall {
17 name, arguments, ..
18 } => push_line(&mut out, &format!("Tool call: {name} {arguments}")),
19 ContentPart::ToolResult { content, .. } => push_line(&mut out, content),
20 ContentPart::Image { url, .. } => push_line(&mut out, &format!("[image: {url}]")),
21 ContentPart::File { path, .. } => push_line(&mut out, &format!("[file: {path}]")),
22 }
23 }
24 out
25}
26
27pub fn sync_messages_from_session(app: &mut App, session: &Session) {
28 app.state.messages = session_messages_to_chat_messages(session);
29 app.state.current_request_first_token_ms = None;
30 app.state.current_request_last_token_ms = None;
31 app.state.last_request_first_token_ms = None;
32 app.state.last_request_last_token_ms = None;
33 app.state.last_completion_model = None;
34 app.state.last_completion_latency_ms = None;
35 app.state.last_completion_prompt_tokens = None;
36 app.state.last_completion_output_tokens = None;
37 app.state.last_tool_name = None;
38 app.state.last_tool_latency_ms = None;
39 app.state.last_tool_success = None;
40 app.state.reset_tool_preview_scroll();
41 app.state.set_tool_preview_max_scroll(0);
42 app.state.scroll_to_bottom();
43}
44
45fn session_messages_to_chat_messages(session: &Session) -> Vec<ChatMessage> {
46 let mut chat_messages = Vec::new();
47 let mut tool_call_names = HashMap::new();
48
49 for message in &session.messages {
50 chat_messages.extend(chat_messages_from_provider_message(
51 message,
52 &mut tool_call_names,
53 ));
54 }
55
56 chat_messages
57}
58
59fn chat_messages_from_provider_message(
60 message: &Message,
61 tool_call_names: &mut HashMap<String, String>,
62) -> Vec<ChatMessage> {
63 let mut chat_messages = Vec::new();
64 let mut text_buffer = String::new();
65
66 for part in &message.content {
67 match part {
68 ContentPart::Text { text } => push_line(&mut text_buffer, text),
69 ContentPart::Thinking { text } => {
70 flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
71 chat_messages.push(ChatMessage::new(
72 MessageType::Thinking(text.clone()),
73 truncate_preview(text, 600),
74 ));
75 }
76 ContentPart::ToolCall {
77 id,
78 name,
79 arguments,
80 ..
81 } => {
82 flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
83 tool_call_names.insert(id.clone(), name.clone());
84 chat_messages.push(ChatMessage::new(
85 MessageType::ToolCall {
86 name: name.clone(),
87 arguments: arguments.clone(),
88 },
89 format!("{name}: {}", truncate_preview(arguments, 240)),
90 ));
91 }
92 ContentPart::ToolResult {
93 tool_call_id,
94 content,
95 } => {
96 flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
97 let name = tool_call_names
98 .get(tool_call_id)
99 .cloned()
100 .unwrap_or_else(|| "tool".to_string());
101 let success = !content.trim_start().starts_with("Error:");
102 chat_messages.push(ChatMessage::new(
103 MessageType::ToolResult {
104 name: name.clone(),
105 output: content.clone(),
106 success,
107 duration_ms: None,
108 },
109 format!("{name}: {}", truncate_preview(content, 600)),
110 ));
111 }
112 ContentPart::Image { url, .. } => {
113 flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
114 chat_messages.push(ChatMessage::new(
115 MessageType::Image { url: url.clone() },
116 url.clone(),
117 ));
118 }
119 ContentPart::File { path, .. } => {
120 flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
121 chat_messages.push(ChatMessage::new(
122 MessageType::File {
123 path: path.clone(),
124 size: None,
125 },
126 path.clone(),
127 ));
128 }
129 }
130 }
131
132 flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
133 chat_messages
134}
135
136fn flush_text_buffer(role: Role, text_buffer: &mut String, chat_messages: &mut Vec<ChatMessage>) {
137 if text_buffer.trim().is_empty() {
138 text_buffer.clear();
139 return;
140 }
141
142 chat_messages.push(ChatMessage::new(
143 message_type(role),
144 std::mem::take(text_buffer),
145 ));
146}
147
148fn push_line(out: &mut String, text: &str) {
149 if !out.is_empty() {
150 out.push('\n');
151 }
152 out.push_str(text);
153}
154
155fn message_type(role: Role) -> MessageType {
156 match role {
157 Role::User => MessageType::User,
158 Role::Assistant => MessageType::Assistant,
159 Role::System | Role::Tool => MessageType::System,
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::{extract_message_text, sync_messages_from_session};
166 use crate::provider::{ContentPart, Message, Role};
167 use crate::session::Session;
168 use crate::tui::app::state::App;
169 use crate::tui::chat::message::MessageType;
170 use chrono::Utc;
171
172 #[test]
173 fn extract_message_text_keeps_tool_markers_for_plain_export() {
174 let content = vec![
175 ContentPart::Text {
176 text: "hello".to_string(),
177 },
178 ContentPart::ToolCall {
179 id: "call_1".to_string(),
180 name: "read".to_string(),
181 arguments: "{\"path\":\"src/main.rs\"}".to_string(),
182 thought_signature: None,
183 },
184 ];
185
186 assert!(extract_message_text(&content).contains("Tool call: read"));
187 }
188
189 #[tokio::test]
190 async fn sync_messages_from_session_preserves_tool_entries() {
191 let mut app = App::default();
192 let mut session = Session::new().await.expect("session should create");
193 let now = Utc::now();
194 session.created_at = now;
195 session.updated_at = now;
196 session.messages = vec![
197 Message {
198 role: Role::User,
199 content: vec![ContentPart::Text {
200 text: "Inspect src/main.rs".to_string(),
201 }],
202 },
203 Message {
204 role: Role::Assistant,
205 content: vec![ContentPart::ToolCall {
206 id: "call_1".to_string(),
207 name: "read".to_string(),
208 arguments: "{\"path\":\"src/main.rs\"}".to_string(),
209 thought_signature: None,
210 }],
211 },
212 Message {
213 role: Role::Tool,
214 content: vec![ContentPart::ToolResult {
215 tool_call_id: "call_1".to_string(),
216 content: "fn main() {}".to_string(),
217 }],
218 },
219 Message {
220 role: Role::Assistant,
221 content: vec![ContentPart::Text {
222 text: "Read complete.".to_string(),
223 }],
224 },
225 ];
226
227 sync_messages_from_session(&mut app, &session);
228
229 assert!(matches!(
230 app.state.messages[0].message_type,
231 MessageType::User
232 ));
233 assert!(matches!(
234 app.state.messages[1].message_type,
235 MessageType::ToolCall { .. }
236 ));
237 match &app.state.messages[2].message_type {
238 MessageType::ToolResult { name, success, .. } => {
239 assert_eq!(name, "read");
240 assert!(*success);
241 }
242 other => panic!("expected tool result message, got {other:?}"),
243 }
244 assert!(matches!(
245 app.state.messages[3].message_type,
246 MessageType::Assistant
247 ));
248 }
249}