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.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}