1use std::collections::{HashMap, HashSet};
9
10use oino_ai::{AgentMessage, AssistantContent, JsonObject, JsonValue, StopReason};
11use oino_session::{EntryKind, SessionEntry};
12
13use crate::format::read_line_range;
14
15#[derive(Clone, Debug, PartialEq)]
17pub struct TreeNode {
18 pub entry: SessionEntry,
20 pub children: Vec<TreeNode>,
22 pub label: Option<String>,
24 pub label_timestamp: Option<String>,
26}
27
28impl TreeNode {
29 #[must_use]
31 pub fn from_json(value: &JsonValue) -> Option<Self> {
32 let entry: SessionEntry = serde_json::from_value(value.get("entry")?.clone()).ok()?;
34 let mut node = Self {
35 entry,
36 children: Vec::new(),
37 label: value
38 .get("label")
39 .and_then(JsonValue::as_str)
40 .map(str::to_owned),
41 label_timestamp: value
42 .get("labelTimestamp")
43 .and_then(JsonValue::as_str)
44 .map(str::to_owned),
45 };
46 if let Some(children) = value.get("children").and_then(JsonValue::as_array) {
47 node.children = children.iter().filter_map(Self::from_json).collect();
48 }
49 Some(node)
50 }
51}
52
53#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
55pub enum FilterMode {
56 #[default]
58 Default,
59 NoTools,
61 UserOnly,
63 LabeledOnly,
65 All,
67}
68
69impl FilterMode {
70 const CYCLE: [Self; 5] = [
71 Self::Default,
72 Self::NoTools,
73 Self::UserOnly,
74 Self::LabeledOnly,
75 Self::All,
76 ];
77
78 #[must_use]
80 pub const fn label(self) -> &'static str {
81 match self {
82 Self::Default => "",
83 Self::NoTools => " [no-tools]",
84 Self::UserOnly => " [user]",
85 Self::LabeledOnly => " [labeled]",
86 Self::All => " [all]",
87 }
88 }
89
90 #[must_use]
92 pub fn parse(value: &str) -> Self {
93 match value {
94 "no-tools" => Self::NoTools,
95 "user-only" => Self::UserOnly,
96 "labeled-only" => Self::LabeledOnly,
97 "all" => Self::All,
98 _ => Self::Default,
99 }
100 }
101
102 fn step(self, forward: bool) -> Self {
103 let index = Self::CYCLE.iter().position(|m| *m == self).unwrap_or(0);
104 let len = Self::CYCLE.len();
105 Self::CYCLE[if forward {
106 (index + 1) % len
107 } else {
108 (index + len - 1) % len
109 }]
110 }
111}
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub enum DisplayKind {
116 User,
118 Assistant,
120 AssistantError,
122 AssistantMuted,
124 Tool,
126 Dim,
128 Custom,
130 Compaction,
132 BranchSummary,
134}
135
136#[derive(Clone, Debug, PartialEq, Eq)]
138pub struct DisplayText {
139 pub kind: DisplayKind,
141 pub prefix: String,
143 pub body: String,
145}
146
147#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct TreeRow {
150 pub entry_id: String,
152 pub prefix: String,
154 pub folded: bool,
156 pub on_active_path: bool,
158 pub label: Option<String>,
160 pub label_timestamp: Option<String>,
162 pub text: DisplayText,
164 pub is_leaf: bool,
166}
167
168#[derive(Clone, Debug)]
169struct Gutter {
170 position: usize,
171 show: bool,
172}
173
174type FlattenItem<'a> = (&'a TreeNode, usize, bool, bool, bool, Vec<Gutter>, bool);
177
178#[derive(Clone, Debug)]
179struct Flat {
180 entry: SessionEntry,
181 label: Option<String>,
182 label_timestamp: Option<String>,
183 indent: usize,
184 show_connector: bool,
185 is_last: bool,
186 gutters: Vec<Gutter>,
187 virtual_root_child: bool,
188}
189
190#[derive(Clone, Debug, Default)]
192pub struct TreeModel {
193 flat: Vec<Flat>,
194 leaf_id: Option<String>,
195 active_path: HashSet<String>,
196 tool_calls: HashMap<String, (String, JsonObject)>,
197 multiple_roots: bool,
198 visible: Vec<usize>,
200 visible_children: HashMap<String, Vec<String>>,
201 visible_parent: HashMap<String, Option<String>>,
202 folded: HashSet<String>,
203 pub filter: FilterMode,
205 pub query: String,
207 pub show_label_timestamps: bool,
209 selected: usize,
210 home: Option<String>,
211}
212
213fn text_of(content: &oino_ai::UserContentField) -> String {
214 content.text("")
215}
216
217fn assistant_text(message: &oino_ai::AssistantMessage) -> String {
218 message
219 .content
220 .iter()
221 .filter_map(|c| match c {
222 AssistantContent::Text(t) => Some(t.text.as_str()),
223 _ => None,
224 })
225 .collect()
226}
227
228fn normalize(text: &str) -> String {
229 text.replace(['\n', '\t'], " ").trim().to_owned()
230}
231
232fn clip(text: &str, max: usize) -> String {
233 text.chars().take(max).collect()
234}
235
236impl TreeModel {
237 #[must_use]
239 pub fn from_json(data: &JsonValue, home: Option<String>) -> Self {
240 let roots: Vec<TreeNode> = data
241 .get("tree")
242 .and_then(JsonValue::as_array)
243 .map(|nodes| nodes.iter().filter_map(TreeNode::from_json).collect())
244 .unwrap_or_default();
245 let leaf = data
246 .get("leafId")
247 .and_then(JsonValue::as_str)
248 .map(str::to_owned);
249 Self::new(&roots, leaf, home)
250 }
251
252 #[must_use]
254 pub fn new(roots: &[TreeNode], leaf_id: Option<String>, home: Option<String>) -> Self {
255 let mut model = Self {
256 leaf_id,
257 multiple_roots: roots.len() > 1,
258 home,
259 ..Self::default()
260 };
261 model.flatten(roots);
262 model.build_active_path();
263 model.apply_filter();
264 model.select_leaf();
265 model
266 }
267
268 fn flatten(&mut self, roots: &[TreeNode]) {
269 let mut contains: HashMap<String, bool> = HashMap::new();
271 let mut order: Vec<&TreeNode> = Vec::new();
272 let mut stack: Vec<&TreeNode> = roots.iter().collect();
273 while let Some(node) = stack.pop() {
274 order.push(node);
275 stack.extend(node.children.iter());
276 }
277 for node in order.iter().rev() {
278 let id = node.entry.id.as_str();
279 let has = self.leaf_id.as_deref() == Some(id)
280 || node
281 .children
282 .iter()
283 .any(|c| contains.get(c.entry.id.as_str()).copied().unwrap_or(false));
284 contains.insert(id.to_owned(), has);
285 }
286 let active = |n: &TreeNode| contains.get(n.entry.id.as_str()).copied().unwrap_or(false);
287 let multiple = roots.len() > 1;
288 let mut ordered_roots: Vec<&TreeNode> = roots.iter().collect();
289 ordered_roots.sort_by_key(|n| !active(n));
290 let mut work: Vec<FlattenItem<'_>> = Vec::new();
291 for (i, root) in ordered_roots.iter().enumerate().rev() {
292 let is_last = i == ordered_roots.len() - 1;
293 work.push((
294 root,
295 usize::from(multiple),
296 multiple,
297 multiple,
298 is_last,
299 Vec::new(),
300 multiple,
301 ));
302 }
303 while let Some((
304 node,
305 indent,
306 just_branched,
307 show_connector,
308 is_last,
309 gutters,
310 virtual_child,
311 )) = work.pop()
312 {
313 if let EntryKind::Message {
314 message: AgentMessage::Assistant(a),
315 } = &node.entry.kind
316 {
317 for c in &a.content {
318 if let AssistantContent::ToolCall(call) = c {
319 self.tool_calls
320 .insert(call.id.clone(), (call.name.clone(), call.arguments.clone()));
321 }
322 }
323 }
324 self.flat.push(Flat {
325 entry: node.entry.clone(),
326 label: node.label.clone(),
327 label_timestamp: node.label_timestamp.clone(),
328 indent,
329 show_connector,
330 is_last,
331 gutters: gutters.clone(),
332 virtual_root_child: virtual_child,
333 });
334 let multiple_children = node.children.len() > 1;
335 let mut children: Vec<&TreeNode> = node.children.iter().collect();
336 children.sort_by_key(|c| !active(c));
337 let child_indent = if multiple_children || (just_branched && indent > 0) {
338 indent + 1
339 } else {
340 indent
341 };
342 let connector_displayed = show_connector && !virtual_child;
343 let display_indent = if self.multiple_roots {
344 indent.saturating_sub(1)
345 } else {
346 indent
347 };
348 let child_gutters = if connector_displayed {
349 let mut g = gutters.clone();
350 g.push(Gutter {
351 position: display_indent.saturating_sub(1),
352 show: !is_last,
353 });
354 g
355 } else {
356 gutters
357 };
358 for (i, child) in children.iter().enumerate().rev() {
359 work.push((
360 child,
361 child_indent,
362 multiple_children,
363 multiple_children,
364 i == children.len() - 1,
365 child_gutters.clone(),
366 false,
367 ));
368 }
369 }
370 }
371
372 fn build_active_path(&mut self) {
373 let parents: HashMap<&str, Option<&str>> = self
374 .flat
375 .iter()
376 .map(|f| {
377 (
378 f.entry.id.as_str(),
379 f.entry
380 .parent_id
381 .as_ref()
382 .map(oino_session::EntryId::as_str),
383 )
384 })
385 .collect();
386 let mut current = self.leaf_id.clone();
387 while let Some(id) = current {
388 let parent = parents
389 .get(id.as_str())
390 .copied()
391 .flatten()
392 .map(str::to_owned);
393 self.active_path.insert(id);
394 current = parent;
395 }
396 }
397
398 fn passes(&self, flat: &Flat) -> bool {
399 let entry = &flat.entry;
400 if matches!(entry.kind, EntryKind::Usage { .. }) {
401 return false;
402 }
403 let is_leaf = self.leaf_id.as_deref() == Some(entry.id.as_str());
404 if let EntryKind::Message {
405 message: AgentMessage::Assistant(a),
406 } = &entry.kind
407 && !is_leaf
408 {
409 let has_text = !assistant_text(a).trim().is_empty();
410 let error = !matches!(a.stop_reason, StopReason::Stop | StopReason::ToolUse);
411 if !has_text && !error {
412 return false;
413 }
414 }
415 let settings_entry = matches!(
416 entry.kind,
417 EntryKind::Label { .. }
418 | EntryKind::ContextEdit { .. }
419 | EntryKind::Custom { .. }
420 | EntryKind::ModelChange { .. }
421 | EntryKind::ThinkingLevelChange { .. }
422 | EntryKind::SessionInfo { .. }
423 );
424 let role = entry.message().map(AgentMessage::role);
425 let passes = match self.filter {
426 FilterMode::UserOnly => role == Some("user"),
427 FilterMode::NoTools => !settings_entry && role != Some("toolResult"),
428 FilterMode::LabeledOnly => flat.label.is_some(),
429 FilterMode::All => true,
430 FilterMode::Default => !settings_entry,
431 };
432 if !passes {
433 return false;
434 }
435 let tokens: Vec<String> = self
436 .query
437 .to_lowercase()
438 .split_whitespace()
439 .map(str::to_owned)
440 .collect();
441 if tokens.is_empty() {
442 return true;
443 }
444 let text = self.searchable(flat).to_lowercase();
445 tokens.iter().all(|t| text.contains(t.as_str()))
446 }
447
448 fn searchable(&self, flat: &Flat) -> String {
449 let display = self.display(flat);
450 let mut text = format!("{}{}", display.prefix, display.body);
451 if let Some(label) = &flat.label {
452 text.push(' ');
453 text.push_str(label);
454 }
455 text
456 }
457
458 fn apply_filter(&mut self) {
459 let previous = self.selected_id();
460 let mut visible: Vec<usize> = (0..self.flat.len())
461 .filter(|&i| self.passes(&self.flat[i]))
462 .collect();
463 let visible_ids: HashSet<&str> = visible
465 .iter()
466 .map(|&i| self.flat[i].entry.id.as_str())
467 .collect();
468 let by_id: HashMap<&str, &Flat> =
469 self.flat.iter().map(|f| (f.entry.id.as_str(), f)).collect();
470 let mut parent_map: HashMap<String, Option<String>> = HashMap::new();
471 let mut children_map: HashMap<String, Vec<String>> = HashMap::new();
472 for &i in &visible {
473 let id = self.flat[i].entry.id.as_str();
474 let mut parent = self.flat[i]
475 .entry
476 .parent_id
477 .as_ref()
478 .map(oino_session::EntryId::as_str);
479 while let Some(p) = parent {
480 if visible_ids.contains(p) {
481 break;
482 }
483 parent = by_id.get(p).and_then(|f| {
484 f.entry
485 .parent_id
486 .as_ref()
487 .map(oino_session::EntryId::as_str)
488 });
489 }
490 parent_map.insert(id.to_owned(), parent.map(str::to_owned));
491 if let Some(p) = parent {
492 children_map
493 .entry(p.to_owned())
494 .or_default()
495 .push(id.to_owned());
496 }
497 }
498 if !self.folded.is_empty() {
499 let mut hidden: HashSet<String> = HashSet::new();
500 visible.retain(|&i| {
501 let id = self.flat[i].entry.id.as_str();
502 let parent = parent_map.get(id).cloned().flatten();
503 let skip = parent
504 .as_ref()
505 .is_some_and(|p| self.folded.contains(p) || hidden.contains(p));
506 if skip {
507 hidden.insert(id.to_owned());
508 }
509 !skip
510 });
511 }
512 self.visible = visible;
513 self.visible_parent = parent_map;
514 self.visible_children = children_map;
515 self.selected = previous
516 .and_then(|id| self.nearest_visible(&id))
517 .unwrap_or_else(|| self.visible.len().saturating_sub(1));
518 }
519
520 fn nearest_visible(&self, id: &str) -> Option<usize> {
521 let index: HashMap<&str, usize> = self
522 .visible
523 .iter()
524 .enumerate()
525 .map(|(pos, &i)| (self.flat[i].entry.id.as_str(), pos))
526 .collect();
527 let parents: HashMap<&str, Option<&str>> = self
528 .flat
529 .iter()
530 .map(|f| {
531 (
532 f.entry.id.as_str(),
533 f.entry
534 .parent_id
535 .as_ref()
536 .map(oino_session::EntryId::as_str),
537 )
538 })
539 .collect();
540 let mut current = Some(id);
541 while let Some(c) = current {
542 if let Some(pos) = index.get(c) {
543 return Some(*pos);
544 }
545 current = parents.get(c).copied().flatten();
546 }
547 None
548 }
549
550 fn select_leaf(&mut self) {
551 if let Some(leaf) = self.leaf_id.clone()
552 && let Some(pos) = self.nearest_visible(&leaf)
553 {
554 self.selected = pos;
555 }
556 }
557
558 fn selected_id(&self) -> Option<String> {
559 self.visible
560 .get(self.selected)
561 .map(|&i| self.flat[i].entry.id.as_str().to_owned())
562 }
563
564 #[must_use]
566 pub fn len(&self) -> usize {
567 self.visible.len()
568 }
569
570 #[must_use]
572 pub fn is_empty(&self) -> bool {
573 self.visible.is_empty()
574 }
575
576 #[must_use]
578 pub const fn selected(&self) -> usize {
579 self.selected
580 }
581
582 #[must_use]
584 pub fn selected_entry(&self) -> Option<&SessionEntry> {
585 self.visible
586 .get(self.selected)
587 .map(|&i| &self.flat[i].entry)
588 }
589
590 #[must_use]
592 pub fn selected_label(&self) -> Option<&str> {
593 self.visible
594 .get(self.selected)
595 .and_then(|&i| self.flat[i].label.as_deref())
596 }
597
598 pub fn move_by(&mut self, delta: isize) {
600 let len = self.visible.len();
601 if len == 0 {
602 return;
603 }
604 let len_i = isize::try_from(len).unwrap_or(isize::MAX);
605 let current = isize::try_from(self.selected).unwrap_or(0);
606 let next = (current + delta).rem_euclid(len_i);
607 self.selected = usize::try_from(next).unwrap_or(0);
608 }
609
610 pub fn page(&mut self, delta: isize) {
612 let max = self.visible.len().saturating_sub(1);
613 let current = isize::try_from(self.selected).unwrap_or(0);
614 let next = (current + delta).clamp(0, isize::try_from(max).unwrap_or(0));
615 self.selected = usize::try_from(next).unwrap_or(0);
616 }
617
618 fn is_foldable(&self, id: &str) -> bool {
619 let Some(children) = self.visible_children.get(id) else {
620 return false;
621 };
622 if children.is_empty() {
623 return false;
624 }
625 match self.visible_parent.get(id).cloned().flatten() {
626 None => true,
627 Some(parent) => self
628 .visible_children
629 .get(&parent)
630 .is_some_and(|s| s.len() > 1),
631 }
632 }
633
634 pub fn fold_or_up(&mut self) {
636 let Some(id) = self.selected_id() else {
637 return;
638 };
639 if self.is_foldable(&id) && !self.folded.contains(&id) {
640 self.folded.insert(id);
641 self.apply_filter();
642 } else {
643 self.selected = self.segment_start(false);
644 }
645 }
646
647 pub fn unfold_or_down(&mut self) {
649 let Some(id) = self.selected_id() else {
650 return;
651 };
652 if self.folded.remove(&id) {
653 self.apply_filter();
654 } else {
655 self.selected = self.segment_start(true);
656 }
657 }
658
659 fn segment_start(&self, down: bool) -> usize {
660 let Some(mut current) = self.selected_id() else {
661 return self.selected;
662 };
663 let index: HashMap<&str, usize> = self
664 .visible
665 .iter()
666 .enumerate()
667 .map(|(pos, &i)| (self.flat[i].entry.id.as_str(), pos))
668 .collect();
669 if down {
670 loop {
671 let children = self
672 .visible_children
673 .get(¤t)
674 .cloned()
675 .unwrap_or_default();
676 match children.len() {
677 0 => {
678 return index
679 .get(current.as_str())
680 .copied()
681 .unwrap_or(self.selected);
682 }
683 1 => current.clone_from(&children[0]),
684 _ => {
685 return index
686 .get(children[0].as_str())
687 .copied()
688 .unwrap_or(self.selected);
689 }
690 }
691 }
692 }
693 loop {
694 let Some(parent) = self.visible_parent.get(¤t).cloned().flatten() else {
695 return index
696 .get(current.as_str())
697 .copied()
698 .unwrap_or(self.selected);
699 };
700 let siblings = self.visible_children.get(&parent).map_or(0, Vec::len);
701 if siblings > 1 && current != self.selected_id().unwrap_or_default() {
702 return index
703 .get(current.as_str())
704 .copied()
705 .unwrap_or(self.selected);
706 }
707 current = parent;
708 }
709 }
710
711 pub fn set_filter(&mut self, mode: FilterMode) {
713 self.filter = mode;
714 self.folded.clear();
715 self.apply_filter();
716 }
717
718 pub fn toggle_filter(&mut self, mode: FilterMode) {
720 let next = if self.filter == mode {
721 FilterMode::Default
722 } else {
723 mode
724 };
725 self.set_filter(next);
726 }
727
728 pub fn cycle_filter(&mut self, forward: bool) {
730 self.set_filter(self.filter.step(forward));
731 }
732
733 pub fn set_query(&mut self, query: impl Into<String>) {
735 self.query = query.into();
736 self.folded.clear();
737 self.apply_filter();
738 }
739
740 pub fn set_label(&mut self, entry_id: &str, label: Option<String>, timestamp: Option<String>) {
742 if let Some(flat) = self
743 .flat
744 .iter_mut()
745 .find(|f| f.entry.id.as_str() == entry_id)
746 {
747 flat.label_timestamp = label.as_ref().and(timestamp);
748 flat.label = label;
749 }
750 self.apply_filter();
751 }
752
753 #[must_use]
755 pub fn status(&self) -> String {
756 let mut labels = self.filter.label().to_owned();
757 if self.show_label_timestamps {
758 labels.push_str(" [+label time]");
759 }
760 if self.visible.is_empty() {
761 format!("(0/0){labels}")
762 } else {
763 format!("({}/{}){labels}", self.selected + 1, self.visible.len())
764 }
765 }
766
767 #[must_use]
769 pub fn rows(&self, start: usize, end: usize) -> Vec<TreeRow> {
770 let end = end.min(self.visible.len());
771 (start.min(end)..end)
772 .map(|pos| self.row(&self.flat[self.visible[pos]]))
773 .collect()
774 }
775
776 #[must_use]
778 pub fn window(&self, height: usize) -> (usize, usize) {
779 let len = self.visible.len();
780 let start = self
781 .selected
782 .saturating_sub(height / 2)
783 .min(len.saturating_sub(height));
784 (start, (start + height).min(len))
785 }
786
787 fn row(&self, flat: &Flat) -> TreeRow {
788 let id = flat.entry.id.as_str();
789 let display_indent = if self.multiple_roots {
790 flat.indent.saturating_sub(1)
791 } else {
792 flat.indent
793 };
794 let connector = flat.show_connector && !flat.virtual_root_child;
795 let connector_position = connector.then(|| display_indent.saturating_sub(1));
796 let folded = self.folded.contains(id);
797 let mut prefix = String::new();
798 for i in 0..display_indent * 3 {
799 let level = i / 3;
800 let pos = i % 3;
801 if let Some(g) = flat.gutters.iter().find(|g| g.position == level) {
802 prefix.push(if pos == 0 && g.show { '│' } else { ' ' });
803 } else if connector_position == Some(level) {
804 prefix.push(match pos {
805 0 if flat.is_last => '└',
806 0 => '├',
807 1 if folded => '⊞',
808 1 if self.is_foldable(id) => '⊟',
809 1 => '─',
810 _ => ' ',
811 });
812 } else {
813 prefix.push(' ');
814 }
815 }
816 TreeRow {
817 entry_id: id.to_owned(),
818 prefix,
819 folded: folded && !connector,
820 on_active_path: self.active_path.contains(id),
821 label: flat.label.clone(),
822 label_timestamp: if self.show_label_timestamps {
823 flat.label_timestamp.clone()
824 } else {
825 None
826 },
827 text: self.display(flat),
828 is_leaf: self.leaf_id.as_deref() == Some(id),
829 }
830 }
831
832 fn shorten(&self, path: &str) -> String {
833 crate::format::shorten_path(path, self.home.as_deref())
834 }
835
836 fn tool_call_text(&self, name: &str, args: &JsonObject) -> String {
837 let s = |key: &str| args.get(key).and_then(JsonValue::as_str).unwrap_or("");
838 let path = || {
839 self.shorten(if s("path").is_empty() {
840 s("file_path")
841 } else {
842 s("path")
843 })
844 };
845 match name {
846 "read" => format!(
847 "[read: {}{}]",
848 path(),
849 read_line_range(
850 args.get("offset").and_then(JsonValue::as_u64),
851 args.get("limit").and_then(JsonValue::as_u64)
852 )
853 ),
854 "write" => format!("[write: {}]", path()),
855 "edit" => format!("[edit: {}]", path()),
856 "bash" => {
857 let raw = s("command");
858 let cmd = clip(&normalize(raw), 50);
859 format!(
860 "[bash: {cmd}{}]",
861 if raw.chars().count() > 50 { "..." } else { "" }
862 )
863 }
864 "grep" => format!(
865 "[grep: /{}/ in {}]",
866 s("pattern"),
867 self.shorten(if s("path").is_empty() { "." } else { s("path") })
868 ),
869 "find" => format!(
870 "[find: {} in {}]",
871 s("pattern"),
872 self.shorten(if s("path").is_empty() { "." } else { s("path") })
873 ),
874 "ls" => format!(
875 "[ls: {}]",
876 self.shorten(if s("path").is_empty() { "." } else { s("path") })
877 ),
878 other => {
879 let json = serde_json::to_string(args).unwrap_or_default();
880 format!(
881 "[{other}: {}{}]",
882 clip(&json, 40),
883 if json.chars().count() > 40 { "..." } else { "" }
884 )
885 }
886 }
887 }
888
889 #[allow(
890 clippy::too_many_lines,
891 reason = "one arm per entry type (getEntryDisplayText)"
892 )]
893 fn display(&self, flat: &Flat) -> DisplayText {
894 let text = |kind, prefix: &str, body: String| DisplayText {
895 kind,
896 prefix: prefix.to_owned(),
897 body,
898 };
899 match &flat.entry.kind {
900 EntryKind::Message { message } => match message {
901 AgentMessage::User(u) => text(
902 DisplayKind::User,
903 "user: ",
904 normalize(&clip(&text_of(&u.content), 200)),
905 ),
906 AgentMessage::Assistant(a) => {
907 let body = normalize(&clip(&assistant_text(a), 200));
908 if !body.is_empty() {
909 text(DisplayKind::Assistant, "assistant: ", body)
910 } else if a.stop_reason == StopReason::Aborted {
911 text(
912 DisplayKind::AssistantMuted,
913 "assistant: ",
914 "(aborted)".into(),
915 )
916 } else if let Some(error) = &a.error_message {
917 text(
918 DisplayKind::AssistantError,
919 "assistant: ",
920 clip(&normalize(error), 80),
921 )
922 } else {
923 text(
924 DisplayKind::AssistantMuted,
925 "assistant: ",
926 "(no content)".into(),
927 )
928 }
929 }
930 AgentMessage::ToolResult(r) => {
931 let body = self.tool_calls.get(&r.tool_call_id).map_or_else(
932 || format!("[{}]", r.tool_name),
933 |(name, args)| self.tool_call_text(name, args),
934 );
935 text(DisplayKind::Tool, "", body)
936 }
937 AgentMessage::BashExecution(b) => text(
938 DisplayKind::Dim,
939 "",
940 format!("[bash]: {}", normalize(&b.command)),
941 ),
942 other => text(DisplayKind::Dim, "", format!("[{}]", other.role())),
943 },
944 EntryKind::CustomMessage {
945 custom_type,
946 content,
947 ..
948 } => text(
949 DisplayKind::Custom,
950 &format!("[{custom_type}]: "),
951 normalize(&text_of(content)),
952 ),
953 EntryKind::Compaction(c) => text(
954 DisplayKind::Compaction,
955 "",
956 format!("[compaction: {}k tokens]", (c.tokens_before + 500) / 1000),
957 ),
958 EntryKind::BranchSummary(b) => text(
959 DisplayKind::BranchSummary,
960 "[branch summary]: ",
961 normalize(&b.summary),
962 ),
963 EntryKind::ModelChange { model_id, .. } => {
964 text(DisplayKind::Dim, "", format!("[model: {model_id}]"))
965 }
966 EntryKind::ThinkingLevelChange { thinking_level } => text(
967 DisplayKind::Dim,
968 "",
969 format!("[thinking: {thinking_level}]"),
970 ),
971 EntryKind::Custom { custom_type, .. } => {
972 text(DisplayKind::Dim, "", format!("[custom: {custom_type}]"))
973 }
974 EntryKind::ContextEdit {
975 target_id,
976 replacement,
977 } => text(
978 DisplayKind::Dim,
979 "",
980 format!(
981 "[context {}: {}]",
982 if replacement.is_none() {
983 "omit"
984 } else {
985 "replace"
986 },
987 target_id.as_str()
988 ),
989 ),
990 EntryKind::Label { label, .. } => text(
991 DisplayKind::Dim,
992 "",
993 format!("[label: {}]", label.as_deref().unwrap_or("(cleared)")),
994 ),
995 EntryKind::SessionInfo { name } => text(
996 DisplayKind::Dim,
997 "",
998 format!("[title: {}]", name.as_deref().unwrap_or("empty")),
999 ),
1000 EntryKind::Usage { .. } => text(DisplayKind::Dim, "", String::new()),
1001 }
1002 }
1003
1004 #[must_use]
1006 pub fn selected_copy_text(&self) -> Option<String> {
1007 let entry = self.selected_entry()?;
1008 let text = match &entry.kind {
1009 EntryKind::Message { message } => match message {
1010 AgentMessage::BashExecution(b) => b.command.clone(),
1011 AgentMessage::User(u) => text_of(&u.content),
1012 AgentMessage::Assistant(a) => {
1013 let t = assistant_text(a);
1014 if t.is_empty() {
1015 a.error_message.clone().unwrap_or_default()
1016 } else {
1017 t
1018 }
1019 }
1020 AgentMessage::Custom(c) => text_of(&c.content),
1021 AgentMessage::ToolResult(r) => r
1022 .content
1023 .iter()
1024 .filter_map(|c| match c {
1025 oino_ai::UserContent::Text(t) => Some(t.text.as_str()),
1026 oino_ai::UserContent::Image(_) => None,
1027 })
1028 .collect(),
1029 _ => String::new(),
1030 },
1031 EntryKind::CustomMessage { content, .. } => text_of(content),
1032 EntryKind::Compaction(c) => c.summary.clone(),
1033 EntryKind::BranchSummary(b) => b.summary.clone(),
1034 _ => String::new(),
1035 };
1036 (!text.trim().is_empty()).then_some(text)
1037 }
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042 use super::*;
1043 use serde_json::json;
1044
1045 fn user(id: &str, parent: Option<&str>, text: &str) -> JsonValue {
1046 json!({"type":"message","id":id,"parentId":parent,"timestamp":"2026-01-01T00:00:00.000Z",
1047 "message":{"role":"user","content":text,"timestamp":0}})
1048 }
1049
1050 fn tree() -> JsonValue {
1051 json!({"leafId":"d","tree":[{"entry":user("a",None,"one"),"children":[
1054 {"entry":user("b",Some("a"),"two"),"children":[
1055 {"entry":user("c",Some("b"),"three"),"children":[{"entry":user("d",Some("c"),"four"),"children":[]}]},
1056 {"entry":user("e",Some("b"),"alt"),"children":[],"label":"keep"}
1057 ]}
1058 ]}]})
1059 }
1060
1061 #[test]
1062 fn flattens_like_pi() {
1063 let model = TreeModel::from_json(&tree(), None);
1064 let rows = model.rows(0, model.len());
1065 let ids: Vec<&str> = rows.iter().map(|r| r.entry_id.as_str()).collect();
1066 assert_eq!(ids, vec!["a", "b", "c", "d", "e"]);
1067 assert_eq!(rows[0].prefix, "");
1068 assert_eq!(rows[2].prefix, "├⊟ ");
1069 assert_eq!(rows[3].prefix, "│ ");
1070 assert_eq!(rows[4].prefix, "└─ ");
1071 assert!(rows[3].on_active_path && !rows[4].on_active_path);
1072 assert_eq!(model.selected_entry().unwrap().id.as_str(), "d");
1073 assert_eq!(rows[4].label.as_deref(), Some("keep"));
1074 assert_eq!(model.status(), "(4/5)");
1075 }
1076
1077 #[test]
1078 fn filters_search_and_folding() {
1079 let mut model = TreeModel::from_json(&tree(), None);
1080 model.toggle_filter(FilterMode::LabeledOnly);
1081 assert_eq!(model.len(), 1);
1082 assert_eq!(model.status(), "(1/1) [labeled]");
1083 model.toggle_filter(FilterMode::LabeledOnly);
1084 model.set_query("thr");
1085 assert_eq!(model.len(), 1);
1086 model.set_query("");
1087 while model.selected_entry().unwrap().id.as_str() != "c" {
1089 model.move_by(-1);
1090 }
1091 model.fold_or_up();
1092 assert_eq!(model.len(), 4);
1093 model.unfold_or_down();
1094 assert_eq!(model.len(), 5);
1095 model.cycle_filter(true);
1096 assert_eq!(model.filter, FilterMode::NoTools);
1097 }
1098}