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.history() {
50 if is_hidden_context_marker(message) {
51 continue;
52 }
53 chat_messages.extend(chat_messages_from_provider_message(
54 message,
55 &mut tool_call_names,
56 ));
57 }
58
59 chat_messages
60}
61
62fn is_hidden_context_marker(message: &Message) -> bool {
63 matches!(
64 (&message.role, message.content.as_slice()),
65 (
66 Role::Assistant,
67 [ContentPart::Text { text }]
68 ) if text.starts_with("[AUTO CONTEXT COMPRESSION]")
69 || text.starts_with("[CONTEXT TRUNCATED]")
70 )
71}
72
73fn chat_messages_from_provider_message(
74 message: &Message,
75 tool_call_names: &mut HashMap<String, String>,
76) -> Vec<ChatMessage> {
77 let mut chat_messages = Vec::new();
78 let mut text_buffer = String::new();
79
80 for part in &message.content {
81 match part {
82 ContentPart::Text { text } => push_line(&mut text_buffer, text),
83 ContentPart::Thinking { text } => {
84 flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
85 chat_messages.push(ChatMessage::new(
86 MessageType::Thinking(text.clone()),
87 truncate_preview(text, 600),
88 ));
89 }
90 ContentPart::ToolCall {
91 id,
92 name,
93 arguments,
94 ..
95 } => {
96 flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
97 tool_call_names.insert(id.clone(), name.clone());
98 chat_messages.push(ChatMessage::new(
99 MessageType::ToolCall {
100 name: name.clone(),
101 arguments: arguments.clone(),
102 },
103 format!("{name}: {}", truncate_preview(arguments, 240)),
104 ));
105 }
106 ContentPart::ToolResult {
107 tool_call_id,
108 content,
109 } => {
110 flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
111 let name = tool_call_names
112 .get(tool_call_id)
113 .cloned()
114 .unwrap_or_else(|| "tool".to_string());
115 let success = !content.trim_start().starts_with("Error:");
116 chat_messages.push(ChatMessage::new(
117 MessageType::ToolResult {
118 name: name.clone(),
119 output: content.clone(),
120 success,
121 duration_ms: None,
122 },
123 format!("{name}: {}", truncate_preview(content, 600)),
124 ));
125 }
126 ContentPart::Image { url, .. } => {
127 flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
128 chat_messages.push(ChatMessage::new(
129 MessageType::Image { url: url.clone() },
130 url.clone(),
131 ));
132 }
133 ContentPart::File { path, .. } => {
134 flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
135 chat_messages.push(ChatMessage::new(
136 MessageType::File {
137 path: path.clone(),
138 size: None,
139 },
140 path.clone(),
141 ));
142 }
143 }
144 }
145
146 flush_text_buffer(message.role, &mut text_buffer, &mut chat_messages);
147 chat_messages
148}
149
150fn flush_text_buffer(role: Role, text_buffer: &mut String, chat_messages: &mut Vec<ChatMessage>) {
151 if text_buffer.trim().is_empty() {
152 text_buffer.clear();
153 return;
154 }
155
156 chat_messages.push(ChatMessage::new(
157 message_type(role),
158 std::mem::take(text_buffer),
159 ));
160}
161
162fn push_line(out: &mut String, text: &str) {
163 if !out.is_empty() {
164 out.push('\n');
165 }
166 out.push_str(text);
167}
168
169fn message_type(role: Role) -> MessageType {
170 match role {
171 Role::User => MessageType::User,
172 Role::Assistant => MessageType::Assistant,
173 Role::System | Role::Tool => MessageType::System,
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::{extract_message_text, sync_messages_from_session};
180 use crate::provider::{ContentPart, Message, Role};
181 use crate::session::Session;
182 use crate::tui::app::state::App;
183 use crate::tui::chat::message::MessageType;
184 use chrono::Utc;
185
186 #[test]
187 fn extract_message_text_keeps_tool_markers_for_plain_export() {
188 let content = vec![
189 ContentPart::Text {
190 text: "hello".to_string(),
191 },
192 ContentPart::ToolCall {
193 id: "call_1".to_string(),
194 name: "read".to_string(),
195 arguments: "{\"path\":\"src/main.rs\"}".to_string(),
196 thought_signature: None,
197 },
198 ];
199
200 assert!(extract_message_text(&content).contains("Tool call: read"));
201 }
202
203 #[tokio::test]
204 async fn sync_messages_from_session_preserves_tool_entries() {
205 let mut app = App::default();
206 let mut session = Session::new().await.expect("session should create");
207 let now = Utc::now();
208 session.created_at = now;
209 session.updated_at = now;
210 session.messages = vec![
211 Message {
212 role: Role::User,
213 content: vec![ContentPart::Text {
214 text: "Inspect src/main.rs".to_string(),
215 }],
216 },
217 Message {
218 role: Role::Assistant,
219 content: vec![ContentPart::ToolCall {
220 id: "call_1".to_string(),
221 name: "read".to_string(),
222 arguments: "{\"path\":\"src/main.rs\"}".to_string(),
223 thought_signature: None,
224 }],
225 },
226 Message {
227 role: Role::Tool,
228 content: vec![ContentPart::ToolResult {
229 tool_call_id: "call_1".to_string(),
230 content: "fn main() {}".to_string(),
231 }],
232 },
233 Message {
234 role: Role::Assistant,
235 content: vec![ContentPart::Text {
236 text: "Read complete.".to_string(),
237 }],
238 },
239 ];
240
241 sync_messages_from_session(&mut app, &session);
242
243 assert!(matches!(
244 app.state.messages[0].message_type,
245 MessageType::User
246 ));
247 assert!(matches!(
248 app.state.messages[1].message_type,
249 MessageType::ToolCall { .. }
250 ));
251 match &app.state.messages[2].message_type {
252 MessageType::ToolResult { name, success, .. } => {
253 assert_eq!(name, "read");
254 assert!(*success);
255 }
256 other => panic!("expected tool result message, got {other:?}"),
257 }
258 assert!(matches!(
259 app.state.messages[3].message_type,
260 MessageType::Assistant
261 ));
262 }
263}