Skip to main content

codetether_agent/tui/app/
message_text.rs

1use 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}