rhema_transcript/
summary.rs1use rhema_core::{Content, Message, Role};
11
12use crate::tree::Range;
13
14pub const HEAD_BYTES: usize = 160;
16
17pub const RANGE_HEAD_BYTES: usize = 300;
19
20pub fn message_bytes(message: &Message) -> usize {
24 message
25 .content
26 .iter()
27 .map(|content| match content {
28 Content::Text(text) => text.len(),
29 Content::ToolCall(call) => call.name.len() + call.args.to_string().len(),
30 Content::ToolResult(result) => {
31 result.content.len()
32 + result
33 .images
34 .iter()
35 .map(|image| image.data.len())
36 .sum::<usize>()
37 }
38 Content::Image(image) => image.data.len(),
39 })
40 .sum()
41}
42
43pub fn head(text: &str, cap: usize) -> String {
46 let flat = one_line(text);
47 if flat.len() <= cap {
48 return flat;
49 }
50 let mut end = cap;
51 while end > 0 && !flat.is_char_boundary(end) {
52 end -= 1;
53 }
54 format!("{}\u{2026}", &flat[..end])
55}
56
57pub fn one_line(text: &str) -> String {
59 let mut flat = String::with_capacity(text.len());
60 let mut gap = false;
61 for ch in text.chars() {
62 if ch == '\n' || ch == '\r' || ch == '\t' {
63 gap = true;
64 } else {
65 if gap && !flat.is_empty() {
66 flat.push(' ');
67 }
68 gap = false;
69 flat.push(ch);
70 }
71 }
72 flat
73}
74
75fn role_label(role: Role) -> &'static str {
78 match role {
79 Role::User => "user",
80 Role::Assistant => "assistant",
81 Role::System => "system",
82 Role::Tool => "result",
83 }
84}
85
86pub(crate) fn summarize_range(messages: &[Message], range: Range) -> ((usize, usize), String) {
90 let mut user_heads: Vec<String> = Vec::new();
91 let mut assistant_heads: Vec<String> = Vec::new();
92 let mut calls: Vec<(String, usize)> = Vec::new();
93 let mut results = 0usize;
94 let mut failures = 0usize;
95
96 let push_head = |heads: &mut Vec<String>, text: &str| {
97 if heads.len() < 2 {
98 heads.push(head(text, 80));
99 }
100 };
101 for message in &messages[..range.n.min(messages.len())] {
102 for content in &message.content {
103 match content {
104 Content::Text(text) => {
105 if message.role == Role::User {
106 push_head(&mut user_heads, text);
107 } else if message.role == Role::Assistant {
108 push_head(&mut assistant_heads, text);
109 }
110 }
111 Content::ToolCall(call) => {
112 if let Some(seen) = calls.iter_mut().find(|(name, _)| *name == call.name) {
113 seen.1 += 1;
114 } else {
115 calls.push((call.name.clone(), 1));
116 }
117 }
118 Content::ToolResult(result) => {
119 results += 1;
120 failures += usize::from(result.is_error);
121 }
122 Content::Image(_) => {}
124 }
125 }
126 }
127
128 let mut parts: Vec<String> = Vec::new();
129 parts.extend(user_heads.iter().map(|h| format!("user: {h}")));
130 if !calls.is_empty() {
131 calls.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
132 let named: Vec<String> = calls
133 .iter()
134 .take(3)
135 .map(|(name, count)| format!("{name}\u{d7}{count}"))
136 .collect();
137 let mut tool_line = if results == 0 {
138 format!(
141 "{} tool {} ({})",
142 calls.iter().map(|(_, n)| n).sum::<usize>(),
143 if calls.iter().map(|(_, n)| n).sum::<usize>() == 1 {
144 "call"
145 } else {
146 "calls"
147 },
148 named.join(", ")
149 )
150 } else {
151 format!(
152 "{} tool {} ({})",
153 results,
154 if results == 1 { "result" } else { "results" },
155 named.join(", ")
156 )
157 };
158 if calls.len() > 3 {
159 tool_line.push_str(", \u{2026}");
160 }
161 parts.push(tool_line);
162 } else if results > 0 {
163 parts.push(format!(
164 "{} tool {}",
165 results,
166 if results == 1 { "result" } else { "results" }
167 ));
168 }
169 if failures > 0 {
170 parts.push(format!("{failures} failed"));
171 }
172 parts.extend(assistant_heads.iter().map(|h| format!("assistant: {h}")));
173 ((range.id, range.n), parts.join("; "))
174}
175
176pub fn render_message(message: &Message) -> String {
180 let mut out = String::new();
181 for content in &message.content {
182 match content {
183 Content::Text(text) => {
184 if !out.is_empty() {
185 out.push('\n');
186 }
187 out.push_str(text);
188 }
189 Content::ToolCall(call) => {
190 if !out.is_empty() {
191 out.push('\n');
192 }
193 out.push_str(&format!("[calls {}({})]", call.name, call.args));
194 }
195 Content::ToolResult(result) => {
196 if !out.is_empty() {
197 out.push('\n');
198 }
199 out.push_str(&format!(
200 "[{}: {}]",
201 if result.is_error { "failed" } else { "ok" },
202 result.content
203 ));
204 for image in &result.images {
205 out.push_str(&format!("\n[image: {}]", image.media_type.as_str()));
206 }
207 }
208 Content::Image(image) => {
209 if !out.is_empty() {
210 out.push('\n');
211 }
212 out.push_str(&format!("[image: {}]", image.media_type.as_str()));
213 }
214 }
215 }
216 if out.is_empty() {
217 out.push_str(&format!("({} message, empty)", role_label(message.role)));
218 }
219 out
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use rhema_core::{ToolCall, ToolResult};
226
227 #[test]
228 fn a_results_images_count_and_describe() {
229 let message = Message {
230 role: Role::Tool,
231 content: vec![Content::ToolResult(
232 ToolResult::success("c1", "out").with_images(vec![rhema_core::Image::new(
233 rhema_core::ImageMediaType::Png,
234 "cG5n",
235 )]),
236 )],
237 };
238 assert_eq!(message_bytes(&message), 7);
240 let rendered = render_message(&message);
241 assert!(rendered.contains("[image: image/png]"), "{rendered}");
242 }
243
244 #[test]
245 fn heads_cut_on_char_boundaries() {
246 let text = "ééééé";
248 let h = head(text, 5);
249 assert_eq!(h, "\u{e9}\u{e9}\u{2026}");
252 assert_eq!(head("short", 10), "short");
253 }
254
255 #[test]
256 fn one_line_flattens_newlines_but_keeps_inner_spaces() {
257 assert_eq!(one_line("a\nb\r\nc\td"), "a b c d");
258 assert_eq!(one_line("\n\nlead"), "lead");
259 }
260
261 #[test]
262 fn bytes_count_text_arguments_and_results() {
263 let call = Message {
264 role: Role::Assistant,
265 content: vec![
266 Content::Text("running".into()),
267 Content::ToolCall(ToolCall {
268 id: "c1".into(),
269 name: "bash".into(),
270 args: serde_json::json!({ "command": "ls" }),
271 provider_data: serde_json::Value::Null,
272 }),
273 ],
274 };
275 assert_eq!(message_bytes(&call), 7 + 4 + 16);
277 }
278
279 #[test]
280 fn a_single_message_renders_whole_for_zoom() {
281 let message = Message {
282 role: Role::Assistant,
283 content: vec![
284 Content::Text("step one".into()),
285 Content::ToolCall(ToolCall {
286 id: "c9".into(),
287 name: "edit".into(),
288 args: serde_json::json!({ "path": "a.rs" }),
289 provider_data: serde_json::Value::Null,
290 }),
291 ],
292 };
293 let out = render_message(&message);
294 assert_eq!(out, "step one\n[calls edit({\"path\":\"a.rs\"})]");
295 }
296}