Skip to main content

oino_view/
tree.rs

1//! Session tree navigator model.
2//!
3//! Built from `get_tree` data. Flattening: linear chains stay at one indent,
4//! branch points indent their children, the branch containing the current leaf is listed
5//! first, and gutters/connectors (`├─`, `└─`, `│`) are computed per row. Filter modes,
6//! search, folding and branch-segment jumps follow the same rules.
7
8use 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/// One tree node.
16#[derive(Clone, Debug, PartialEq)]
17pub struct TreeNode {
18    /// The entry.
19    pub entry: SessionEntry,
20    /// Children.
21    pub children: Vec<TreeNode>,
22    /// Current label.
23    pub label: Option<String>,
24    /// When the label was set (ISO).
25    pub label_timestamp: Option<String>,
26}
27
28impl TreeNode {
29    /// Parses one `get_tree` node.
30    #[must_use]
31    pub fn from_json(value: &JsonValue) -> Option<Self> {
32        // Iterative to survive very deep linear sessions.
33        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/// T r e e   F i l t e r   M o d e.
54#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
55pub enum FilterMode {
56    /// Hide bookkeeping entries.
57    #[default]
58    Default,
59    /// Default minus tool results.
60    NoTools,
61    /// User messages only.
62    UserOnly,
63    /// Labelled entries only.
64    LabeledOnly,
65    /// Everything.
66    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    /// Status label (`[no-tools]`, …; empty for default).
79    #[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    /// Parses the `treeFilterMode` setting value.
91    #[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/// Semantic kind of a row's display text (frontends map to colours).
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub enum DisplayKind {
116    /// `user: …` (accent prefix).
117    User,
118    /// `assistant: …` (success prefix).
119    Assistant,
120    /// Assistant error text.
121    AssistantError,
122    /// Muted assistant note (`(aborted)`, `(no content)`).
123    AssistantMuted,
124    /// Tool call summary (muted).
125    Tool,
126    /// Dim bookkeeping text.
127    Dim,
128    /// Custom message (`[type]: …`).
129    Custom,
130    /// Compaction (border accent).
131    Compaction,
132    /// Branch summary (warning prefix).
133    BranchSummary,
134}
135
136/// Display text of a row: optional coloured prefix plus body.
137#[derive(Clone, Debug, PartialEq, Eq)]
138pub struct DisplayText {
139    /// Kind.
140    pub kind: DisplayKind,
141    /// Prefix (`user: `, `assistant: `, `[branch summary]: `…).
142    pub prefix: String,
143    /// Body.
144    pub body: String,
145}
146
147/// One visible row.
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct TreeRow {
150    /// Entry id.
151    pub entry_id: String,
152    /// Gutter/connector prefix (`│  ├⊟ `…).
153    pub prefix: String,
154    /// The node is folded.
155    pub folded: bool,
156    /// On the root → leaf path.
157    pub on_active_path: bool,
158    /// Label.
159    pub label: Option<String>,
160    /// Label timestamp (shown when enabled).
161    pub label_timestamp: Option<String>,
162    /// Display text.
163    pub text: DisplayText,
164    /// This is the current leaf.
165    pub is_leaf: bool,
166}
167
168#[derive(Clone, Debug)]
169struct Gutter {
170    position: usize,
171    show: bool,
172}
173
174/// Work item of the flattening walk: node, indent, just branched, show connector, is last,
175/// gutters, virtual-root child.
176type 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/// The navigator model.
191#[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 flat indices.
199    visible: Vec<usize>,
200    visible_children: HashMap<String, Vec<String>>,
201    visible_parent: HashMap<String, Option<String>>,
202    folded: HashSet<String>,
203    /// Filter mode.
204    pub filter: FilterMode,
205    /// Search query.
206    pub query: String,
207    /// Show label timestamps.
208    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    /// Builds the model from `get_tree` data (`{tree, leafId}`).
238    #[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    /// Builds the model from parsed roots.
253    #[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        // Post-order "contains active leaf" flags, keyed by entry id.
270        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        // Visible parent/children maps over the filtered set.
464        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    /// Number of visible rows.
565    #[must_use]
566    pub fn len(&self) -> usize {
567        self.visible.len()
568    }
569
570    /// No visible rows.
571    #[must_use]
572    pub fn is_empty(&self) -> bool {
573        self.visible.is_empty()
574    }
575
576    /// Selected visible index.
577    #[must_use]
578    pub const fn selected(&self) -> usize {
579        self.selected
580    }
581
582    /// Selected entry.
583    #[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    /// Label of the selected entry.
591    #[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    /// Moves the selection (wrapping, `tui.select.up/down`).
599    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    /// Pages the selection (clamped).
611    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    /// A p p . t r e e . f o l d   O r   U p.
635    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    /// A p p . t r e e . u n f o l d   O r   D o w n.
648    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(&current)
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(&current).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    /// Sets the filter mode (clears folds).
712    pub fn set_filter(&mut self, mode: FilterMode) {
713        self.filter = mode;
714        self.folded.clear();
715        self.apply_filter();
716    }
717
718    /// Toggle semantics: `mode` ↔ default.
719    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    /// Cycles the filter.
729    pub fn cycle_filter(&mut self, forward: bool) {
730        self.set_filter(self.filter.step(forward));
731    }
732
733    /// Replaces the search query.
734    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    /// Updates a label after `_oino/set_label`.
741    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    /// Status line: `(n/total)` plus filter labels.
754    #[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    /// Rows `start..end` of the visible list.
768    #[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    /// Windowing: centre the selection in `height` rows.
777    #[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    /// `getEntryCopyText` for the selected entry.
1005    #[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        // a ─ b ─┬─ c ─ d (leaf)
1052        //        └─ e
1053        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        // Select c and fold it: d disappears.
1088        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}