Skip to main content

rab/agent/ui/components/
tree_selector.rs

1//! TreeSelector component — matching pi's TreeSelectorComponent.
2//!
3//! Full-screen overlay for navigating the session tree: switching branches,
4//! filtering, searching, folding, editing labels, and showing label timestamps.
5//!
6//! Uses callbacks (`on_select`, `on_cancel`) for signalling back to the app.
7//! The component does NOT close itself — the app polls the result and acts.
8
9use std::collections::HashMap;
10
11use crate::agent::session::model::SessionTreeNode;
12use crate::agent::types;
13use crate::agent::ui::theme::current_theme;
14use crate::tui::Component;
15use crate::tui::focusable::{CURSOR_MARKER, Focusable};
16use crate::tui::keybindings::{
17    ACTION_EDITOR_CURSOR_LEFT, ACTION_EDITOR_CURSOR_RIGHT, ACTION_EDITOR_DELETE_CHAR_BACKWARD,
18    ACTION_SELECT_CANCEL, ACTION_SELECT_CONFIRM, ACTION_SELECT_DOWN, ACTION_SELECT_UP,
19    get_keybindings,
20};
21use crate::tui::util::{slice_by_column, truncate_to_width, visible_width, wrap_text_with_ansi};
22use chrono::Datelike;
23use crossterm::event::KeyEvent;
24use yoagent::types::{AgentMessage, Content, Message};
25
26// ── Filter mode ────────────────────────────────────────────────────
27
28/// Filter mode for tree display (matching pi's FilterMode).
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub enum FilterMode {
31    Default,
32    NoTools,
33    UserOnly,
34    LabeledOnly,
35    All,
36}
37
38impl FilterMode {
39    fn cycle_forward(self) -> Self {
40        match self {
41            FilterMode::Default => FilterMode::NoTools,
42            FilterMode::NoTools => FilterMode::UserOnly,
43            FilterMode::UserOnly => FilterMode::LabeledOnly,
44            FilterMode::LabeledOnly => FilterMode::All,
45            FilterMode::All => FilterMode::Default,
46        }
47    }
48
49    fn cycle_backward(self) -> Self {
50        match self {
51            FilterMode::Default => FilterMode::All,
52            FilterMode::NoTools => FilterMode::Default,
53            FilterMode::UserOnly => FilterMode::NoTools,
54            FilterMode::LabeledOnly => FilterMode::UserOnly,
55            FilterMode::All => FilterMode::LabeledOnly,
56        }
57    }
58
59    fn label(self) -> &'static str {
60        match self {
61            FilterMode::Default => "",
62            FilterMode::NoTools => " [no-tools]",
63            FilterMode::UserOnly => " [user]",
64            FilterMode::LabeledOnly => " [labeled]",
65            FilterMode::All => " [all]",
66        }
67    }
68}
69
70// ── Tool call info for lookup ──────────────────────────────────────
71
72/// Stored during flattening so tool results can show rich display.
73struct ToolCallInfo {
74    name: String,
75    arguments: serde_json::Value,
76}
77
78// ── Internal types ──────────────────────────────────────────────────
79
80/// Gutter info: position (displayIndent where connector was) and whether to show │.
81#[derive(Debug, Clone, Copy)]
82struct GutterInfo {
83    position: usize,
84    show: bool,
85}
86
87/// Flattened tree node for navigation.
88#[derive(Clone)]
89struct FlatNode {
90    node: SessionTreeNode,
91    indent: usize,
92    show_connector: bool,
93    is_last: bool,
94    gutters: Vec<GutterInfo>,
95    is_virtual_root_child: bool,
96}
97
98// ── TreeSelector component ──────────────────────────────────────────
99
100pub struct TreeSelector {
101    flat_nodes: Vec<FlatNode>,
102    filtered_nodes: Vec<FlatNode>,
103    selected_index: usize,
104    current_leaf_id: Option<String>,
105    max_visible_lines: usize,
106    filter_mode: FilterMode,
107    search_query: String,
108    multiple_roots: bool,
109    active_path_ids: std::collections::HashSet<String>,
110    visible_parent_map: std::collections::HashMap<String, Option<String>>,
111    visible_children_map: std::collections::HashMap<Option<String>, Vec<String>>,
112    last_selected_id: Option<String>,
113    folded_nodes: std::collections::HashSet<String>,
114    label_input_active: bool,
115    label_input_text: String,
116    label_editing_entry_id: Option<String>,
117    show_label_timestamps: bool,
118    tool_call_map: HashMap<String, ToolCallInfo>,
119    /// Focused state for IME cursor positioning.
120    focused: bool,
121
122    /// Called when user selects an entry.
123    pub on_select: Option<Box<dyn FnMut(String)>>,
124    /// Called when user cancels (presses Esc when search is empty).
125    pub on_cancel: Option<Box<dyn FnMut()>>,
126    /// Called when user changes a label.
127    pub on_label_change: Option<BoxLabelChange>,
128}
129
130/// Type alias for label change callback.
131pub type BoxLabelChange = Box<dyn FnMut(String, Option<String>)>;
132
133impl TreeSelector {
134    pub fn new(
135        tree: Vec<SessionTreeNode>,
136        current_leaf_id: Option<String>,
137        terminal_height: usize,
138        initial_filter_mode: Option<FilterMode>,
139    ) -> Self {
140        let max_visible_lines = (terminal_height.saturating_sub(8)).max(5);
141        let multiple_roots = tree.len() > 1;
142        let mut s = Self {
143            flat_nodes: Vec::new(),
144            filtered_nodes: Vec::new(),
145            selected_index: 0,
146            current_leaf_id: current_leaf_id.clone(),
147            max_visible_lines,
148            filter_mode: initial_filter_mode.unwrap_or(FilterMode::Default),
149            search_query: String::new(),
150            multiple_roots,
151            active_path_ids: std::collections::HashSet::new(),
152            visible_parent_map: std::collections::HashMap::new(),
153            visible_children_map: std::collections::HashMap::new(),
154            last_selected_id: None,
155            folded_nodes: std::collections::HashSet::new(),
156            label_input_active: false,
157            label_input_text: String::new(),
158            label_editing_entry_id: None,
159            show_label_timestamps: false,
160            tool_call_map: HashMap::new(),
161            focused: false,
162            on_select: None,
163            on_cancel: None,
164            on_label_change: None,
165        };
166        s.flat_nodes = s.flatten_tree(&tree);
167        s.build_active_path();
168        s.apply_filter();
169
170        let target_id = current_leaf_id
171            .clone()
172            .or_else(|| tree.first().map(|n| n.entry.id().to_string()));
173        s.selected_index = s.find_nearest_visible_index(target_id.as_deref());
174        s.last_selected_id = s
175            .filtered_nodes
176            .get(s.selected_index)
177            .map(|n| n.node.entry.id().to_string());
178
179        s
180    }
181
182    /// Set the initial selection to a specific entry (used when re-opening after summarization prompt).
183    /// Must be called after construction, before rendering.
184    pub fn set_initial_selection(&mut self, entry_id: &str) {
185        self.selected_index = self.find_nearest_visible_index(Some(entry_id));
186        self.last_selected_id = self
187            .filtered_nodes
188            .get(self.selected_index)
189            .map(|n| n.node.entry.id().to_string());
190    }
191
192    // ── Flatten tree ────────────────────────────────────────────
193
194    fn flatten_tree(&self, roots: &[SessionTreeNode]) -> Vec<FlatNode> {
195        let mut result: Vec<FlatNode> = Vec::new();
196        let multiple_roots = roots.len() > 1;
197
198        // Stack items
199        struct StackEntry<'a> {
200            node: &'a SessionTreeNode,
201            indent: usize,
202            just_branched: bool,
203            show_connector: bool,
204            is_last: bool,
205            gutters: Vec<GutterInfo>,
206            is_virtual_root_child: bool,
207        }
208
209        // Order roots: active branch first
210        let ordered_roots = self.order_roots(roots);
211
212        let mut stack: Vec<StackEntry> = Vec::new();
213        for (i, node) in ordered_roots.iter().enumerate().rev() {
214            let is_last = i == ordered_roots.len() - 1;
215            stack.push(StackEntry {
216                node,
217                indent: if multiple_roots { 1 } else { 0 },
218                just_branched: multiple_roots,
219                show_connector: multiple_roots,
220                is_last,
221                gutters: Vec::new(),
222                is_virtual_root_child: multiple_roots,
223            });
224        }
225
226        while let Some(entry) = stack.pop() {
227            // Extract tool calls from assistant messages for later lookup
228            // (matching pi's flattenTree which builds toolCallMap)
229            if let crate::agent::session::model::SessionEntry::Message(m) = &entry.node.entry
230                && types::message_is_assistant(&m.message)
231                && let AgentMessage::Llm(Message::Assistant { content, .. }) = &m.message
232            {
233                for c in content {
234                    if let Content::ToolCall { .. } = c {
235                        // Tool calls are extracted later in build_tool_call_map
236                    }
237                }
238            }
239
240            result.push(FlatNode {
241                node: entry.node.clone(),
242                indent: entry.indent,
243                show_connector: entry.show_connector,
244                is_last: entry.is_last,
245                gutters: entry.gutters.clone(),
246                is_virtual_root_child: entry.is_virtual_root_child,
247            });
248
249            let children = &entry.node.children;
250            let multiple_children = children.len() > 1;
251
252            // Order children: active branch first
253            let ordered_children = self.order_child_nodes(children);
254
255            let child_indent = if multiple_children || (entry.just_branched && entry.indent > 0) {
256                entry.indent + 1
257            } else {
258                entry.indent
259            };
260
261            let connector_displayed = entry.show_connector && !entry.is_virtual_root_child;
262            let display_indent = if multiple_roots {
263                entry.indent.saturating_sub(1)
264            } else {
265                entry.indent
266            };
267            let connector_position = display_indent.saturating_sub(1);
268            let mut child_gutters = entry.gutters.clone();
269            if connector_displayed {
270                child_gutters.push(GutterInfo {
271                    position: connector_position,
272                    show: !entry.is_last,
273                });
274            }
275
276            for (i, child) in ordered_children.iter().enumerate().rev() {
277                let child_is_last = i == ordered_children.len() - 1;
278                stack.push(StackEntry {
279                    node: child,
280                    indent: child_indent,
281                    just_branched: multiple_children,
282                    show_connector: multiple_children,
283                    is_last: child_is_last,
284                    gutters: child_gutters.clone(),
285                    is_virtual_root_child: false,
286                });
287            }
288        }
289
290        result
291    }
292
293    /// Build the tool call map by scanning all flat nodes.
294    /// Called once during construction.
295    fn build_tool_call_map(&mut self) {
296        self.tool_call_map.clear();
297        for flat in &self.flat_nodes {
298            if let crate::agent::session::model::SessionEntry::Message(m) = &flat.node.entry
299                && types::message_is_assistant(&m.message)
300                && let AgentMessage::Llm(Message::Assistant { content, .. }) = &m.message
301            {
302                for c in content {
303                    if let Content::ToolCall {
304                        id,
305                        name,
306                        arguments,
307                        ..
308                    } = c
309                    {
310                        self.tool_call_map.insert(
311                            id.clone(),
312                            ToolCallInfo {
313                                name: name.clone(),
314                                arguments: arguments.clone(),
315                            },
316                        );
317                    }
318                }
319            }
320        }
321    }
322
323    fn node_contains_leaf(&self, node: &SessionTreeNode) -> bool {
324        let Some(ref leaf) = self.current_leaf_id else {
325            return false;
326        };
327        if node.entry.id() == leaf {
328            return true;
329        }
330        for child in &node.children {
331            if self.node_contains_leaf(child) {
332                return true;
333            }
334        }
335        false
336    }
337
338    fn order_roots<'a>(&self, roots: &'a [SessionTreeNode]) -> Vec<&'a SessionTreeNode> {
339        let mut items: Vec<&SessionTreeNode> = roots.iter().collect();
340        items.sort_by(|a, b| {
341            let a_active = self.node_contains_leaf(a);
342            let b_active = self.node_contains_leaf(b);
343            b_active.cmp(&a_active)
344        });
345        items
346    }
347
348    fn order_child_nodes<'a>(&self, children: &'a [SessionTreeNode]) -> Vec<&'a SessionTreeNode> {
349        let mut items: Vec<&SessionTreeNode> = children.iter().collect();
350        items.sort_by(|a, b| {
351            let a_active = self.node_contains_leaf(a);
352            let b_active = self.node_contains_leaf(b);
353            b_active.cmp(&a_active)
354        });
355        items
356    }
357
358    // ── Active path ─────────────────────────────────────────────
359
360    fn build_active_path(&mut self) {
361        let Some(ref leaf) = self.current_leaf_id else {
362            return;
363        };
364        let parent_map: std::collections::HashMap<&str, &str> = self
365            .flat_nodes
366            .iter()
367            .filter_map(|f| f.node.entry.parent_id().map(|p| (f.node.entry.id(), p)))
368            .collect();
369        let mut current: Option<&str> = Some(leaf);
370        while let Some(id) = current {
371            self.active_path_ids.insert(id.to_string());
372            current = parent_map.get(id).copied();
373        }
374    }
375
376    fn is_on_active_path(&self, id: &str) -> bool {
377        self.active_path_ids.contains(id)
378    }
379
380    // ── Filter ─────────────────────────────────────────────────
381
382    fn apply_filter(&mut self) {
383        if !self.filtered_nodes.is_empty() {
384            self.last_selected_id = self
385                .filtered_nodes
386                .get(self.selected_index)
387                .map(|n| n.node.entry.id().to_string())
388                .or_else(|| self.last_selected_id.take());
389        }
390
391        let search_query_lower = self.search_query.to_lowercase();
392        let search_tokens: Vec<&str> = search_query_lower
393            .split_whitespace()
394            .filter(|s| !s.is_empty())
395            .collect();
396
397        // Build the tool call map once before filtering (so it's available for search)
398        self.build_tool_call_map();
399
400        self.filtered_nodes = self
401            .flat_nodes
402            .iter()
403            .filter(|flat| {
404                let entry = &flat.node.entry;
405                let is_current_leaf = self
406                    .current_leaf_id
407                    .as_ref()
408                    .is_some_and(|id| id == entry.id());
409
410                // Skip assistant messages with only tool calls (no text), unless error/aborted
411                if !is_current_leaf
412                    && let crate::agent::session::model::SessionEntry::Message(m) = entry
413                    && types::message_is_assistant(&m.message)
414                {
415                    let has_text = Self::message_has_text(&m.message);
416                    let is_error_or_aborted = matches!(
417                        &m.message,
418                        AgentMessage::Llm(Message::Assistant {
419                            stop_reason,
420                            ..
421                        }) if *stop_reason != yoagent::types::StopReason::Stop
422                            && *stop_reason != yoagent::types::StopReason::ToolUse
423                    );
424                    if !has_text && !is_error_or_aborted {
425                        return false;
426                    }
427                }
428
429                // Apply filter mode
430                let is_settings = matches!(
431                    entry,
432                    crate::agent::session::model::SessionEntry::Label(_)
433                        | crate::agent::session::model::SessionEntry::Custom(_)
434                        | crate::agent::session::model::SessionEntry::ModelChange(_)
435                        | crate::agent::session::model::SessionEntry::ThinkingLevelChange(_)
436                        | crate::agent::session::model::SessionEntry::SessionInfo(_)
437                );
438
439                let passes_filter = match self.filter_mode {
440                    FilterMode::UserOnly => {
441                        matches!(entry, crate::agent::session::model::SessionEntry::Message(m) if types::message_is_user(&m.message))
442                    }
443                    FilterMode::NoTools => {
444                        !is_settings
445                            && !matches!(
446                                entry,
447                                crate::agent::session::model::SessionEntry::Message(m) if types::message_is_tool_result(&m.message)
448                            )
449                    }
450                    FilterMode::LabeledOnly => flat.node.label.is_some(),
451                    FilterMode::All => true,
452                    FilterMode::Default => !is_settings,
453                };
454
455                if !passes_filter {
456                    return false;
457                }
458
459                // Search filter
460                if !search_tokens.is_empty() {
461                    let text = self.get_searchable_text(flat).to_lowercase();
462                    return search_tokens.iter().all(|t| text.contains(t));
463                }
464
465                true
466            })
467            .cloned()
468            .collect();
469
470        // Filter out descendants of folded nodes
471        if !self.folded_nodes.is_empty() {
472            let mut skip = std::collections::HashSet::new();
473            for flat in &self.flat_nodes {
474                let id = flat.node.entry.id().to_string();
475                let pid = flat.node.entry.parent_id().map(|s| s.to_string());
476                if let Some(ref parent) = pid
477                    && (self.folded_nodes.contains(parent) || skip.contains(parent))
478                {
479                    skip.insert(id);
480                }
481            }
482            self.filtered_nodes
483                .retain(|f| !skip.contains(f.node.entry.id()));
484        }
485
486        // Recalculate visual structure
487        self.recalculate_visual_structure();
488
489        if let Some(ref last) = self.last_selected_id {
490            self.selected_index = self.find_nearest_visible_index(Some(last));
491        } else if self.selected_index >= self.filtered_nodes.len() {
492            self.selected_index = self.filtered_nodes.len().saturating_sub(1);
493        }
494
495        if !self.filtered_nodes.is_empty() {
496            self.last_selected_id = self
497                .filtered_nodes
498                .get(self.selected_index)
499                .map(|n| n.node.entry.id().to_string());
500        }
501    }
502
503    fn message_has_text(msg: &AgentMessage) -> bool {
504        match msg {
505            AgentMessage::Llm(Message::Assistant { content, .. }) => content
506                .iter()
507                .any(|c| matches!(c, Content::Text { text } if !text.trim().is_empty())),
508            _ => false,
509        }
510    }
511
512    fn get_searchable_text(&self, flat: &FlatNode) -> String {
513        let entry = &flat.node.entry;
514        let mut parts = Vec::new();
515
516        if let Some(ref label) = flat.node.label {
517            parts.push(label.clone());
518        }
519
520        match entry {
521            crate::agent::session::model::SessionEntry::Message(m) => {
522                parts.push(match &m.message {
523                    AgentMessage::Llm(msg) => match msg {
524                        Message::User { content, .. } => {
525                            format!("user: {}", types::content_text(content))
526                        }
527                        Message::Assistant { content, .. } => {
528                            // Include tool call names in searchable text
529                            let text = types::content_text(content);
530                            let tool_names: Vec<&str> = content
531                                .iter()
532                                .filter_map(|c| {
533                                    if let Content::ToolCall { name, .. } = c {
534                                        Some(name.as_str())
535                                    } else {
536                                        None
537                                    }
538                                })
539                                .collect();
540                            if tool_names.is_empty() {
541                                format!("assistant: {}", text)
542                            } else {
543                                format!("assistant: {} tools: {}", text, tool_names.join(" "))
544                            }
545                        }
546                        Message::ToolResult {
547                            tool_name,
548                            tool_call_id,
549                            content,
550                            ..
551                        } => {
552                            // Include tool call info from map for search
553                            let call_info = self.tool_call_map.get(tool_call_id);
554                            let args_text = call_info
555                                .map(|info| info.arguments.to_string())
556                                .unwrap_or_default();
557                            format!(
558                                "toolResult: {} {} {}",
559                                tool_name,
560                                types::content_text(content),
561                                args_text
562                            )
563                        }
564                    },
565                    AgentMessage::Extension(ext) => ext.data.to_string(),
566                });
567            }
568            crate::agent::session::model::SessionEntry::Compaction(c) => {
569                parts.push(format!("compaction {}", c.tokens_before));
570            }
571            crate::agent::session::model::SessionEntry::BranchSummary(b) => {
572                parts.push(format!("branch summary {}", b.summary));
573            }
574            crate::agent::session::model::SessionEntry::SessionInfo(s) => {
575                parts.push("title".to_string());
576                if !s.name.is_empty() {
577                    parts.push(s.name.clone());
578                }
579            }
580            crate::agent::session::model::SessionEntry::ModelChange(m) => {
581                parts.push(format!("model {}", m.model_id));
582            }
583            crate::agent::session::model::SessionEntry::ThinkingLevelChange(t) => {
584                parts.push(format!("thinking {}", t.thinking_level));
585            }
586            crate::agent::session::model::SessionEntry::Custom(c) => {
587                parts.push(format!("custom {}", c.custom_type));
588            }
589            crate::agent::session::model::SessionEntry::Label(l) => {
590                if let Some(ref label) = l.label {
591                    parts.push(format!("label {}", label));
592                }
593            }
594            crate::agent::session::model::SessionEntry::CustomMessage(cm) => {
595                parts.push(format!("custom_message {}", cm.custom_type));
596            }
597            crate::agent::session::model::SessionEntry::ActiveToolsChange(a) => {
598                parts.push(format!("tools {}", a.active_tool_names.join(", ")));
599            }
600            crate::agent::session::model::SessionEntry::Leaf(_) => {}
601        }
602
603        parts.join(" ")
604    }
605
606    // ── Visual structure recalculation ─────────────────────────
607
608    fn recalculate_visual_structure(&mut self) {
609        if self.filtered_nodes.is_empty() {
610            return;
611        }
612
613        let visible_ids: std::collections::HashSet<&str> = self
614            .filtered_nodes
615            .iter()
616            .map(|n| n.node.entry.id())
617            .collect();
618
619        let entry_map: std::collections::HashMap<&str, &FlatNode> = self
620            .flat_nodes
621            .iter()
622            .map(|f| (f.node.entry.id(), f))
623            .collect();
624
625        let find_visible_ancestor = |node_id: &str| -> Option<String> {
626            let entry = entry_map.get(node_id)?;
627            let mut current = entry.node.entry.parent_id()?.to_string();
628            loop {
629                if visible_ids.contains(current.as_str()) {
630                    return Some(current);
631                }
632                let node = entry_map.get(current.as_str())?;
633                current = node.node.entry.parent_id()?.to_string();
634            }
635        };
636
637        self.visible_parent_map.clear();
638        self.visible_children_map.clear();
639        self.visible_children_map.insert(None, Vec::new());
640
641        for flat in &self.filtered_nodes {
642            let id = flat.node.entry.id().to_string();
643            let ancestor = find_visible_ancestor(&id);
644            self.visible_parent_map.insert(id.clone(), ancestor.clone());
645            let key = ancestor.or(None);
646            self.visible_children_map.entry(key).or_default().push(id);
647        }
648
649        let visible_root_ids = self
650            .visible_children_map
651            .get(&None)
652            .cloned()
653            .unwrap_or_default();
654        self.multiple_roots = visible_root_ids.len() > 1;
655
656        struct VisStackEntry {
657            node_id: String,
658            indent: usize,
659            just_branched: bool,
660            show_connector: bool,
661            is_last: bool,
662            gutters: Vec<GutterInfo>,
663            is_virtual_root_child: bool,
664        }
665
666        let mut stack: Vec<VisStackEntry> = Vec::new();
667
668        for (i, root_id) in visible_root_ids.iter().enumerate().rev() {
669            let is_last = i == visible_root_ids.len() - 1;
670            stack.push(VisStackEntry {
671                node_id: root_id.clone(),
672                indent: if self.multiple_roots { 1 } else { 0 },
673                just_branched: self.multiple_roots,
674                show_connector: self.multiple_roots,
675                is_last,
676                gutters: Vec::new(),
677                is_virtual_root_child: self.multiple_roots,
678            });
679        }
680
681        while let Some(entry) = stack.pop() {
682            if let Some(pos) = self
683                .filtered_nodes
684                .iter()
685                .position(|f| f.node.entry.id() == entry.node_id)
686            {
687                let flat = &mut self.filtered_nodes[pos];
688                flat.indent = entry.indent;
689                flat.show_connector = entry.show_connector;
690                flat.is_last = entry.is_last;
691                flat.gutters = entry.gutters.clone();
692                flat.is_virtual_root_child = entry.is_virtual_root_child;
693            }
694
695            let children = self
696                .visible_children_map
697                .get(&Some(entry.node_id.clone()))
698                .cloned()
699                .unwrap_or_default();
700            let multiple_children = children.len() > 1;
701
702            let child_indent = if multiple_children || (entry.just_branched && entry.indent > 0) {
703                entry.indent + 1
704            } else {
705                entry.indent
706            };
707
708            let connector_displayed = entry.show_connector && !entry.is_virtual_root_child;
709            let display_indent = if self.multiple_roots {
710                entry.indent.saturating_sub(1)
711            } else {
712                entry.indent
713            };
714            let connector_position = display_indent.saturating_sub(1);
715            let mut child_gutters = entry.gutters.clone();
716            if connector_displayed {
717                child_gutters.push(GutterInfo {
718                    position: connector_position,
719                    show: !entry.is_last,
720                });
721            }
722
723            for (i, child_id) in children.iter().enumerate().rev() {
724                let child_is_last = i == children.len() - 1;
725                stack.push(VisStackEntry {
726                    node_id: child_id.clone(),
727                    indent: child_indent,
728                    just_branched: multiple_children,
729                    show_connector: multiple_children,
730                    is_last: child_is_last,
731                    gutters: child_gutters.clone(),
732                    is_virtual_root_child: false,
733                });
734            }
735        }
736    }
737
738    // ── Navigation helpers ─────────────────────────────────────
739
740    fn find_nearest_visible_index(&self, entry_id: Option<&str>) -> usize {
741        if self.filtered_nodes.is_empty() || entry_id.is_none() {
742            return 0;
743        }
744        let id = entry_id.unwrap();
745
746        let visible_id_to_index: std::collections::HashMap<&str, usize> = self
747            .filtered_nodes
748            .iter()
749            .enumerate()
750            .map(|(i, f)| (f.node.entry.id(), i))
751            .collect();
752
753        if let Some(&idx) = visible_id_to_index.get(id) {
754            return idx;
755        }
756
757        let entry_map: std::collections::HashMap<&str, &FlatNode> = self
758            .flat_nodes
759            .iter()
760            .map(|f| (f.node.entry.id(), f))
761            .collect();
762        let mut current: Option<&str> = entry_map.get(id).and_then(|n| n.node.entry.parent_id());
763        while let Some(cid) = current {
764            if let Some(&idx) = visible_id_to_index.get(cid) {
765                return idx;
766            }
767            current = entry_map.get(cid).and_then(|n| n.node.entry.parent_id());
768        }
769
770        self.filtered_nodes.len().saturating_sub(1)
771    }
772
773    fn is_foldable(&self, entry_id: &str) -> bool {
774        let children = self.visible_children_map.get(&Some(entry_id.to_string()));
775        if children.is_none_or(|c| c.is_empty()) {
776            return false;
777        }
778        let parent = self.visible_parent_map.get(entry_id);
779        match parent {
780            None | Some(None) => true,
781            Some(Some(pid)) => self
782                .visible_children_map
783                .get(&Some(pid.clone()))
784                .is_some_and(|s| s.len() > 1),
785        }
786    }
787
788    fn find_branch_segment_start(&self, direction: &str) -> usize {
789        let selected_id = self
790            .filtered_nodes
791            .get(self.selected_index)
792            .map(|n| n.node.entry.id().to_string());
793        let Some(ref sid) = selected_id else {
794            return self.selected_index;
795        };
796
797        let index_by_id: std::collections::HashMap<&str, usize> = self
798            .filtered_nodes
799            .iter()
800            .enumerate()
801            .map(|(i, f)| (f.node.entry.id(), i))
802            .collect();
803
804        let mut current: String = sid.to_string();
805
806        if direction == "down" {
807            loop {
808                let children = self
809                    .visible_children_map
810                    .get(&Some(current.clone()))
811                    .cloned()
812                    .unwrap_or_default();
813                if children.is_empty() {
814                    return *index_by_id
815                        .get(current.as_str())
816                        .unwrap_or(&self.selected_index);
817                }
818                if children.len() > 1 {
819                    return *index_by_id
820                        .get(children[0].as_str())
821                        .unwrap_or(&self.selected_index);
822                }
823                current = children[0].clone();
824            }
825        }
826
827        // direction == "up"
828        loop {
829            let parent = self.visible_parent_map.get(current.as_str());
830            let parent_id: Option<&str> = match parent {
831                Some(None) | None => break,
832                Some(Some(pid)) => Some(pid.as_str()),
833            };
834            if let Some(pid) = parent_id {
835                let children = self
836                    .visible_children_map
837                    .get(&Some(pid.to_string()))
838                    .cloned()
839                    .unwrap_or_default();
840                if children.len() > 1
841                    && let Some(&idx) = index_by_id.get(current.as_str())
842                    && idx < self.selected_index
843                {
844                    return idx;
845                }
846                current = pid.to_string();
847            } else {
848                break;
849            }
850        }
851
852        *index_by_id
853            .get(current.as_str())
854            .unwrap_or(&self.selected_index)
855    }
856
857    // ── Entry display text ─────────────────────────────────────
858
859    fn format_tool_call(&self, name: &str, args: &serde_json::Value) -> String {
860        let shorten_path = |p: &str| -> String {
861            if let Some(home) = std::env::var_os("HOME").and_then(|h| h.into_string().ok())
862                && let Some(rest) = p.strip_prefix(&home)
863            {
864                format!("~{}", rest)
865            } else {
866                p.to_string()
867            }
868        };
869
870        match name {
871            "read" => {
872                let path = shorten_path(
873                    args.get("path")
874                        .or_else(|| args.get("file_path"))
875                        .and_then(|v| v.as_str())
876                        .unwrap_or(""),
877                );
878                let offset = args.get("offset").and_then(|v| v.as_u64());
879                let limit = args.get("limit").and_then(|v| v.as_u64());
880                let display = match (offset, limit) {
881                    (Some(o), Some(l)) => format!("{}:{}-{}", path, o, o + l - 1),
882                    (Some(o), None) => format!("{}:{}", path, o),
883                    _ => path,
884                };
885                format!("[read: {}]", display)
886            }
887            "write" => {
888                let path = shorten_path(
889                    args.get("path")
890                        .or_else(|| args.get("file_path"))
891                        .and_then(|v| v.as_str())
892                        .unwrap_or(""),
893                );
894                format!("[write: {}]", path)
895            }
896            "edit" => {
897                let path = shorten_path(
898                    args.get("path")
899                        .or_else(|| args.get("file_path"))
900                        .and_then(|v| v.as_str())
901                        .unwrap_or(""),
902                );
903                format!("[edit: {}]", path)
904            }
905            "bash" => {
906                let raw_cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
907                let cmd = raw_cmd.replace(['\n', '\t'], " ").trim().to_string();
908                let truncated: String = cmd.chars().take(50).collect();
909                if cmd.len() > 50 {
910                    format!("[bash: {}...]", truncated)
911                } else {
912                    format!("[bash: {}]", truncated)
913                }
914            }
915            "grep" => {
916                let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
917                let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
918                format!("[grep: /{}/ in {}]", pattern, shorten_path(path))
919            }
920            "find" => {
921                let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
922                let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
923                format!("[find: {} in {}]", pattern, shorten_path(path))
924            }
925            "ls" => {
926                let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
927                format!("[ls: {}]", shorten_path(path))
928            }
929            _ => {
930                // Custom tool — show name and truncated JSON args
931                let args_str = args.to_string();
932                let truncated: String = args_str.chars().take(40).collect();
933                if args_str.len() > 40 {
934                    format!("[{}: {}...]", name, truncated)
935                } else {
936                    format!("[{}: {}]", name, truncated)
937                }
938            }
939        }
940    }
941
942    fn get_entry_display_text(&self, node: &SessionTreeNode, is_selected: bool) -> String {
943        let theme = current_theme();
944        let entry = &node.entry;
945
946        let result = match entry {
947            crate::agent::session::model::SessionEntry::Message(m) => {
948                match &m.message {
949                    AgentMessage::Llm(msg) => match msg {
950                        Message::User { content, .. } => {
951                            let text = types::content_text(content);
952                            let truncated = self.truncate_display_text(&text);
953                            format!(
954                                "{}{}",
955                                theme.fg("accent", "user: "),
956                                truncated.replace('\n', " ").trim()
957                            )
958                        }
959                        Message::Assistant {
960                            content,
961                            stop_reason,
962                            error_message,
963                            ..
964                        } => {
965                            let text = types::content_text(content);
966                            let text_clean = self
967                                .truncate_display_text(&text)
968                                .replace('\n', " ")
969                                .trim()
970                                .to_string();
971                            if !text_clean.is_empty() {
972                                format!("{}{}", theme.fg("success", "assistant: "), text_clean)
973                            } else if let Some(err) = error_message {
974                                let err_display: String = err.chars().take(80).collect();
975                                format!(
976                                    "{}{}",
977                                    theme.fg("success", "assistant: "),
978                                    theme.fg("error", &err_display)
979                                )
980                            } else if *stop_reason == yoagent::types::StopReason::Aborted {
981                                format!(
982                                    "{}{}",
983                                    theme.fg("success", "assistant: "),
984                                    theme.fg("muted", "(aborted)")
985                                )
986                            } else {
987                                format!(
988                                    "{}{}",
989                                    theme.fg("success", "assistant: "),
990                                    theme.fg("muted", "(no content)")
991                                )
992                            }
993                        }
994                        Message::ToolResult {
995                            tool_name,
996                            tool_call_id,
997                            ..
998                        } => {
999                            // Look up the original tool call for a rich display
1000                            let display = self
1001                                .tool_call_map
1002                                .get(tool_call_id)
1003                                .map(|info| self.format_tool_call(&info.name, &info.arguments))
1004                                .unwrap_or_else(|| format!("[{}]", tool_name));
1005                            theme.fg("muted", &display)
1006                        }
1007                    },
1008                    AgentMessage::Extension(ext) => {
1009                        format!("{}[extension: {}]", theme.fg("dim", ""), ext.data)
1010                    }
1011                }
1012            }
1013            crate::agent::session::model::SessionEntry::Compaction(c) => {
1014                let tokens = c.tokens_before / 1000;
1015                format!(
1016                    "{}[compaction: {}k tokens]",
1017                    theme.fg("borderAccent", ""),
1018                    tokens
1019                )
1020            }
1021            crate::agent::session::model::SessionEntry::BranchSummary(b) => {
1022                let text = b.summary.replace('\n', " ").trim().to_string();
1023                let truncated = self.truncate_display_text(&text);
1024                format!("{}{}", theme.fg("warning", "[branch summary]: "), truncated)
1025            }
1026            crate::agent::session::model::SessionEntry::ModelChange(m) => {
1027                format!("{}[model: {}]", theme.fg("dim", ""), m.model_id)
1028            }
1029            crate::agent::session::model::SessionEntry::ThinkingLevelChange(t) => {
1030                format!("{}[thinking: {}]", theme.fg("dim", ""), t.thinking_level)
1031            }
1032            crate::agent::session::model::SessionEntry::Custom(c) => {
1033                format!("{}[custom: {}]", theme.fg("dim", ""), c.custom_type)
1034            }
1035            crate::agent::session::model::SessionEntry::Label(l) => {
1036                let label_text = l.label.as_deref().unwrap_or("(cleared)");
1037                format!("{}[label: {}]", theme.fg("dim", ""), label_text)
1038            }
1039            crate::agent::session::model::SessionEntry::SessionInfo(s) => {
1040                if s.name.is_empty() {
1041                    format!(
1042                        "{}[title: {}]",
1043                        theme.fg("dim", ""),
1044                        theme.italic(&theme.fg("dim", "empty"))
1045                    )
1046                } else {
1047                    format!("{}[title: {}]", theme.fg("dim", ""), &s.name)
1048                }
1049            }
1050            crate::agent::session::model::SessionEntry::CustomMessage(cm) => {
1051                // Extract text from content JSON (pi-style: content.text or content array)
1052                let text = cm
1053                    .content
1054                    .get("text")
1055                    .and_then(|v| v.as_str())
1056                    .unwrap_or("");
1057                let truncated = self.truncate_display_text(text.trim());
1058                format!(
1059                    "{}{}: {}",
1060                    theme.fg("customMessageLabel", ""),
1061                    cm.custom_type,
1062                    truncated
1063                )
1064            }
1065            crate::agent::session::model::SessionEntry::Leaf(_) => String::new(),
1066            crate::agent::session::model::SessionEntry::ActiveToolsChange(a) => {
1067                format!(
1068                    "{}[tools: {}]",
1069                    theme.fg("dim", ""),
1070                    a.active_tool_names.join(", ")
1071                )
1072            }
1073        };
1074
1075        if is_selected {
1076            theme.bold(&result)
1077        } else {
1078            result
1079        }
1080    }
1081
1082    /// Truncate display text to max N chars (matching pi's extractContent 200-char limit).
1083    fn truncate_display_text(&self, text: &str) -> String {
1084        const MAX_LEN: usize = 200;
1085        text.chars().take(MAX_LEN).collect()
1086    }
1087
1088    // ── Format label timestamp (matching pi's formatLabelTimestamp) ──
1089
1090    fn format_label_timestamp(&self, timestamp: &str) -> String {
1091        // Try RFC 3339 first, then other common formats
1092        let date = if let Ok(d) = chrono::DateTime::parse_from_rfc3339(timestamp) {
1093            d
1094        } else if let Ok(d) = chrono::DateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%.fZ") {
1095            d
1096        } else if let Ok(d) = chrono::DateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%.f%z")
1097        {
1098            d
1099        } else {
1100            return String::new();
1101        };
1102        let now = chrono::Utc::now();
1103        let time = date.format("%H:%M").to_string();
1104
1105        if date.year() == now.year() && date.month() == now.month() && date.day() == now.day() {
1106            return time;
1107        }
1108
1109        let month = date.month();
1110        let day = date.day();
1111        if date.year() == now.year() {
1112            return format!("{}/{} {}", month, day, time);
1113        }
1114
1115        let year = date.year() % 100;
1116        format!("{}/{}/{} {}", year, month, day, time)
1117    }
1118
1119    // ── Input handling ────────────────────────────────────────
1120
1121    pub fn handle_key(&mut self, key: &KeyEvent) -> bool {
1122        if self.label_input_active {
1123            return self.handle_label_input(key);
1124        }
1125
1126        let kb = get_keybindings();
1127
1128        if kb.matches(key, ACTION_SELECT_UP) {
1129            if self.filtered_nodes.is_empty() {
1130                return true;
1131            }
1132            self.selected_index = if self.selected_index == 0 {
1133                self.filtered_nodes.len() - 1
1134            } else {
1135                self.selected_index - 1
1136            };
1137            return true;
1138        }
1139
1140        if kb.matches(key, ACTION_SELECT_DOWN) {
1141            if self.filtered_nodes.is_empty() {
1142                return true;
1143            }
1144            self.selected_index = if self.selected_index >= self.filtered_nodes.len() - 1 {
1145                0
1146            } else {
1147                self.selected_index + 1
1148            };
1149            return true;
1150        }
1151
1152        // Fold with '[' (up direction), unfold with ']' (down direction)
1153        if key.code == crossterm::event::KeyCode::Char('[') && key.modifiers.is_empty() {
1154            let current_id = self
1155                .filtered_nodes
1156                .get(self.selected_index)
1157                .map(|n| n.node.entry.id());
1158            if let Some(id) = current_id
1159                && self.is_foldable(id)
1160                && !self.folded_nodes.contains(id)
1161            {
1162                self.folded_nodes.insert(id.to_string());
1163                self.apply_filter();
1164                return true;
1165            }
1166            self.selected_index = self.find_branch_segment_start("up");
1167            return true;
1168        }
1169
1170        if key.code == crossterm::event::KeyCode::Char(']') && key.modifiers.is_empty() {
1171            let current_id = self
1172                .filtered_nodes
1173                .get(self.selected_index)
1174                .map(|n| n.node.entry.id());
1175            if let Some(id) = current_id
1176                && self.folded_nodes.contains(id)
1177            {
1178                self.folded_nodes.remove(id);
1179                self.apply_filter();
1180                return true;
1181            }
1182            self.selected_index = self.find_branch_segment_start("down");
1183            return true;
1184        }
1185
1186        if kb.matches(key, ACTION_EDITOR_CURSOR_LEFT) {
1187            self.selected_index = self.selected_index.saturating_sub(self.max_visible_lines);
1188            return true;
1189        }
1190
1191        if kb.matches(key, ACTION_EDITOR_CURSOR_RIGHT) {
1192            self.selected_index = self
1193                .filtered_nodes
1194                .len()
1195                .saturating_sub(1)
1196                .min(self.selected_index + self.max_visible_lines);
1197            return true;
1198        }
1199
1200        if kb.matches(key, ACTION_SELECT_CONFIRM) {
1201            if let Some(flat) = self.filtered_nodes.get(self.selected_index) {
1202                let id = flat.node.entry.id().to_string();
1203                if let Some(ref mut cb) = self.on_select {
1204                    cb(id);
1205                }
1206            }
1207            return true;
1208        }
1209
1210        if kb.matches(key, ACTION_SELECT_CANCEL) {
1211            if !self.search_query.is_empty() {
1212                self.search_query.clear();
1213                self.folded_nodes.clear();
1214                self.apply_filter();
1215            } else if let Some(ref mut cb) = self.on_cancel {
1216                cb();
1217            }
1218            return true;
1219        }
1220
1221        // Filter shortcuts: 1..5 toggle between mode and default
1222        if key.code == crossterm::event::KeyCode::Char('1') && key.modifiers.is_empty() {
1223            self.filter_mode = if self.filter_mode == FilterMode::NoTools {
1224                FilterMode::Default
1225            } else {
1226                FilterMode::NoTools
1227            };
1228            self.folded_nodes.clear();
1229            self.apply_filter();
1230            return true;
1231        }
1232        if key.code == crossterm::event::KeyCode::Char('2') && key.modifiers.is_empty() {
1233            self.filter_mode = if self.filter_mode == FilterMode::UserOnly {
1234                FilterMode::Default
1235            } else {
1236                FilterMode::UserOnly
1237            };
1238            self.folded_nodes.clear();
1239            self.apply_filter();
1240            return true;
1241        }
1242        if key.code == crossterm::event::KeyCode::Char('3') && key.modifiers.is_empty() {
1243            self.filter_mode = if self.filter_mode == FilterMode::LabeledOnly {
1244                FilterMode::Default
1245            } else {
1246                FilterMode::LabeledOnly
1247            };
1248            self.folded_nodes.clear();
1249            self.apply_filter();
1250            return true;
1251        }
1252        if key.code == crossterm::event::KeyCode::Char('4') && key.modifiers.is_empty() {
1253            self.filter_mode = if self.filter_mode == FilterMode::All {
1254                FilterMode::Default
1255            } else {
1256                FilterMode::All
1257            };
1258            self.folded_nodes.clear();
1259            self.apply_filter();
1260            return true;
1261        }
1262        if key.code == crossterm::event::KeyCode::Char('5') && key.modifiers.is_empty() {
1263            self.filter_mode = FilterMode::Default;
1264            self.folded_nodes.clear();
1265            self.apply_filter();
1266            return true;
1267        }
1268
1269        // Cycle filters with Tab/Shift+Tab
1270        if key.code == crossterm::event::KeyCode::Tab {
1271            let old_mode = self.filter_mode;
1272            self.filter_mode = self.filter_mode.cycle_forward();
1273            if self.filter_mode != old_mode {
1274                self.folded_nodes.clear();
1275                self.apply_filter();
1276            }
1277            return true;
1278        }
1279        if key.code == crossterm::event::KeyCode::BackTab {
1280            let old_mode = self.filter_mode;
1281            self.filter_mode = self.filter_mode.cycle_backward();
1282            if self.filter_mode != old_mode {
1283                self.folded_nodes.clear();
1284                self.apply_filter();
1285            }
1286            return true;
1287        }
1288
1289        // Label editing with 'l'
1290        if key.code == crossterm::event::KeyCode::Char('l') && key.modifiers.is_empty() {
1291            if let Some(flat) = self.filtered_nodes.get(self.selected_index) {
1292                let id = flat.node.entry.id().to_string();
1293                let label = flat.node.label.clone();
1294                self.start_label_edit(id, label);
1295            }
1296            return true;
1297        }
1298
1299        // Toggle label timestamp display with 't' (matching pi's app.tree.toggleLabelTimestamp)
1300        if key.code == crossterm::event::KeyCode::Char('t') && key.modifiers.is_empty() {
1301            self.show_label_timestamps = !self.show_label_timestamps;
1302            return true;
1303        }
1304
1305        if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_BACKWARD) {
1306            if !self.search_query.is_empty() {
1307                self.search_query.pop();
1308                self.folded_nodes.clear();
1309                self.apply_filter();
1310            }
1311            return true;
1312        }
1313
1314        if let crossterm::event::KeyCode::Char(c) = key.code
1315            && !c.is_control()
1316            && !key
1317                .modifiers
1318                .contains(crossterm::event::KeyModifiers::CONTROL)
1319            && !key.modifiers.contains(crossterm::event::KeyModifiers::ALT)
1320            && !key.modifiers.contains(crossterm::event::KeyModifiers::META)
1321        {
1322            self.search_query.push(c);
1323            self.folded_nodes.clear();
1324            self.apply_filter();
1325            return true;
1326        }
1327
1328        false
1329    }
1330
1331    // ── Label editing ─────────────────────────────────────────
1332
1333    fn start_label_edit(&mut self, entry_id: String, current_label: Option<String>) {
1334        self.label_input_active = true;
1335        self.label_input_text = current_label.unwrap_or_default();
1336        self.label_editing_entry_id = Some(entry_id);
1337    }
1338
1339    fn handle_label_input(&mut self, key: &KeyEvent) -> bool {
1340        let kb = get_keybindings();
1341
1342        if kb.matches(key, ACTION_SELECT_CONFIRM) {
1343            if let Some(ref id) = self.label_editing_entry_id {
1344                let label = if self.label_input_text.trim().is_empty() {
1345                    None
1346                } else {
1347                    Some(self.label_input_text.trim().to_string())
1348                };
1349                for flat in &mut self.flat_nodes {
1350                    if flat.node.entry.id() == id {
1351                        flat.node.label = label.clone();
1352                        break;
1353                    }
1354                }
1355                if let Some(ref mut cb) = self.on_label_change {
1356                    cb(id.clone(), label);
1357                }
1358                self.apply_filter();
1359            }
1360            self.label_input_active = false;
1361            self.label_input_text.clear();
1362            self.label_editing_entry_id = None;
1363            return true;
1364        }
1365
1366        if kb.matches(key, ACTION_SELECT_CANCEL) {
1367            self.label_input_active = false;
1368            self.label_input_text.clear();
1369            self.label_editing_entry_id = None;
1370            return true;
1371        }
1372
1373        if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_BACKWARD) {
1374            self.label_input_text.pop();
1375            return true;
1376        }
1377
1378        if let crossterm::event::KeyCode::Char(c) = key.code
1379            && !c.is_control()
1380        {
1381            self.label_input_text.push(c);
1382            return true;
1383        }
1384
1385        false
1386    }
1387}
1388
1389impl Component for TreeSelector {
1390    fn render(&mut self, width: usize) -> Vec<String> {
1391        let theme = current_theme();
1392        let mut lines: Vec<String> = Vec::new();
1393
1394        lines.push(theme.fg("muted", &"─".repeat(width.saturating_sub(2))));
1395        lines.push(String::new());
1396
1397        lines.push(format!("  {}", theme.bold("Session Tree")));
1398        lines.push(String::new());
1399
1400        // Label input mode
1401        if self.label_input_active {
1402            lines.push(format!(
1403                "  {}",
1404                theme.fg("muted", "Label (empty to remove):")
1405            ));
1406            let label_display = if self.focused {
1407                format!("  {}{}", self.label_input_text, CURSOR_MARKER)
1408            } else {
1409                format!("  {}", self.label_input_text)
1410            };
1411            lines.push(label_display);
1412            lines.push(format!(
1413                "  {}",
1414                theme.fg("muted", "Enter: save \u{00b7} Esc: cancel")
1415            ));
1416            lines.push(theme.fg("muted", &"─".repeat(width.saturating_sub(2))));
1417            return lines;
1418        }
1419
1420        // Help
1421        lines.extend(self.render_help(width));
1422
1423        // Search line
1424        let search_display = if self.search_query.is_empty() {
1425            theme.fg("muted", "Type to search:")
1426        } else {
1427            format!(
1428                "{} {}",
1429                theme.fg("muted", "Search:"),
1430                theme.fg("accent", &self.search_query)
1431            )
1432        };
1433        lines.push(format!("  {}", search_display));
1434        lines.push(String::new());
1435
1436        if self.filtered_nodes.is_empty() {
1437            lines.push(format!(
1438                "  {}",
1439                theme.fg(
1440                    "muted",
1441                    &format!("No entries found  (0/0){}", self.filter_mode.label())
1442                )
1443            ));
1444            lines.push(theme.fg("muted", &"─".repeat(width.saturating_sub(2))));
1445            return lines;
1446        }
1447
1448        let count = self.filtered_nodes.len();
1449        let start = self
1450            .selected_index
1451            .saturating_sub(self.max_visible_lines / 2)
1452            .min(count.saturating_sub(self.max_visible_lines));
1453        let end = (start + self.max_visible_lines).min(count);
1454
1455        // ── Horizontal viewport scrolling (matching pi's renderHorizontalViewport) ──
1456        //
1457        // Collect rendered rows with anchor positions, compute horizontal scroll
1458        // from the selected row's anchor, then clip each body with slice_by_column.
1459
1460        struct Row {
1461            gutter: String,
1462            body: String,
1463            anchor_col: usize,
1464            body_width: usize,
1465            is_selected: bool,
1466        }
1467
1468        let mut rows: Vec<Row> = Vec::new();
1469
1470        for i in start..end {
1471            let flat = &self.filtered_nodes[i];
1472            let is_selected = i == self.selected_index;
1473
1474            let cursor = if is_selected {
1475                theme.fg("accent", "\u{203a} ")
1476            } else {
1477                "  ".to_string()
1478            };
1479
1480            let prefix = self.render_tree_prefix(flat);
1481
1482            let is_on_path = self.is_on_active_path(flat.node.entry.id());
1483            let path_marker = if is_on_path {
1484                theme.accent("\u{2022} ")
1485            } else {
1486                String::new()
1487            };
1488
1489            let is_folded = self.folded_nodes.contains(flat.node.entry.id());
1490            let shows_fold_in_connector = flat.show_connector && !flat.is_virtual_root_child;
1491            let fold_marker = if is_folded && !shows_fold_in_connector {
1492                theme.accent("\u{229e} ")
1493            } else {
1494                String::new()
1495            };
1496
1497            // Label badge with optional timestamp (matching pi's showLabelTimestamps)
1498            let label_badge = match &flat.node.label {
1499                Some(l) => {
1500                    let mut badge = format!("[{}]", l);
1501                    if self.show_label_timestamps
1502                        && let Some(ref ts) = flat.node.label_timestamp
1503                    {
1504                        let formatted = self.format_label_timestamp(ts);
1505                        if !formatted.is_empty() {
1506                            badge = format!("[{}] {} ", l, formatted);
1507                        }
1508                    }
1509                    theme.fg("warning", &format!("{} ", badge))
1510                }
1511                None => String::new(),
1512            };
1513
1514            let content = self.get_entry_display_text(&flat.node, is_selected);
1515
1516            // Body = everything after the cursor gutter
1517            let body = if label_badge.is_empty() {
1518                format!("{}{}{}{}", prefix, fold_marker, path_marker, content)
1519            } else {
1520                format!("{}{}{}{}", prefix, label_badge, path_marker, content)
1521            };
1522
1523            let body_width = visible_width(&body);
1524            // Anchor column = visible width of tree prefix (where content text starts)
1525            let anchor_col = visible_width(&prefix);
1526
1527            rows.push(Row {
1528                gutter: cursor.clone(),
1529                body,
1530                anchor_col,
1531                body_width,
1532                is_selected,
1533            });
1534        }
1535
1536        // Calculate horizontal scroll based on selected row's anchor (pi-style)
1537        const TREE_GUTTER_WIDTH: usize = 2; // cursor column
1538        let viewport_width = width.saturating_sub(TREE_GUTTER_WIDTH);
1539        let max_body_width = rows.iter().map(|r| r.body_width).max().unwrap_or(0);
1540        let max_horizontal_scroll = max_body_width.saturating_sub(viewport_width);
1541
1542        let mut horizontal_scroll: usize = 0;
1543        if max_horizontal_scroll > 0
1544            && let Some(selected) = rows.iter().find(|r| r.is_selected)
1545        {
1546            let min_visible_anchor_content = (viewport_width / 3).clamp(4, 20);
1547            if selected.anchor_col > viewport_width.saturating_sub(min_visible_anchor_content) {
1548                let anchor_context = (viewport_width / 4).clamp(2, 12);
1549                horizontal_scroll =
1550                    max_horizontal_scroll.min(selected.anchor_col.saturating_sub(anchor_context));
1551            }
1552        }
1553
1554        // Render rows with horizontal scroll applied
1555        for row in rows {
1556            let body = if horizontal_scroll > 0 {
1557                slice_by_column(&row.body, horizontal_scroll, viewport_width)
1558            } else {
1559                row.body
1560            };
1561
1562            let mut line = if row.is_selected {
1563                format!(
1564                    "{}{}",
1565                    theme.bg("selectedBg", &row.gutter),
1566                    theme.bg("selectedBg", &body)
1567                )
1568            } else {
1569                format!("{}{}", row.gutter, body)
1570            };
1571
1572            line = truncate_to_width(&line, width, "", false);
1573            lines.push(line);
1574        }
1575
1576        // Status line (matching pi: includes filter badge and label timestamp indicator)
1577        let mut status = format!(
1578            "  ({}/{}){}",
1579            self.selected_index + 1,
1580            self.filtered_nodes.len(),
1581            self.filter_mode.label()
1582        );
1583        if self.show_label_timestamps {
1584            status.push_str(" [+label time]");
1585        }
1586        lines.push(theme.fg("muted", &status));
1587
1588        lines.push(theme.fg("muted", &"─".repeat(width.saturating_sub(2))));
1589
1590        lines
1591    }
1592
1593    fn handle_input(&mut self, key: &KeyEvent) -> bool {
1594        self.handle_key(key)
1595    }
1596
1597    fn invalidate(&mut self) {}
1598}
1599
1600impl Focusable for TreeSelector {
1601    fn set_focused(&mut self, focused: bool) {
1602        self.focused = focused;
1603    }
1604
1605    fn focused(&self) -> bool {
1606        self.focused
1607    }
1608}
1609
1610// ── Rendering helpers ──────────────────────────────────────────
1611
1612impl TreeSelector {
1613    fn render_tree_prefix(&self, flat: &FlatNode) -> String {
1614        let theme = current_theme();
1615        let display_indent = if self.multiple_roots {
1616            flat.indent.saturating_sub(1)
1617        } else {
1618            flat.indent
1619        };
1620
1621        let connector = if flat.show_connector && !flat.is_virtual_root_child {
1622            if flat.is_last {
1623                "\u{2514}\u{2500} "
1624            } else {
1625                "\u{251c}\u{2500} "
1626            }
1627        } else {
1628            ""
1629        };
1630        let connector_position = if !connector.is_empty() {
1631            display_indent as isize - 1
1632        } else {
1633            -1
1634        };
1635
1636        let total_chars = display_indent * 3;
1637        let mut prefix_chars: Vec<char> = Vec::with_capacity(total_chars);
1638
1639        for i in 0..total_chars {
1640            let level = i / 3;
1641            let pos_in_level = i % 3;
1642
1643            let gutter = flat.gutters.iter().find(|g| g.position == level);
1644            if let Some(g) = gutter {
1645                if pos_in_level == 0 {
1646                    prefix_chars.push(if g.show { '\u{2502}' } else { ' ' });
1647                } else {
1648                    prefix_chars.push(' ');
1649                }
1650            } else if !connector.is_empty() && level == connector_position as usize {
1651                if pos_in_level == 0 {
1652                    prefix_chars.push(if flat.is_last { '\u{2514}' } else { '\u{251c}' });
1653                } else if pos_in_level == 1 {
1654                    let is_folded = self.folded_nodes.contains(flat.node.entry.id());
1655                    let foldable = self.is_foldable(flat.node.entry.id());
1656                    prefix_chars.push(if is_folded {
1657                        '\u{229e}'
1658                    } else if foldable {
1659                        '\u{229f}'
1660                    } else {
1661                        '\u{2500}'
1662                    });
1663                } else {
1664                    prefix_chars.push(' ');
1665                }
1666            } else {
1667                prefix_chars.push(' ');
1668            }
1669        }
1670
1671        let prefix: String = prefix_chars.into_iter().collect();
1672        theme.fg("dim", &prefix)
1673    }
1674
1675    fn render_help(&self, width: usize) -> Vec<String> {
1676        let theme = current_theme();
1677        let items = [
1678            ("\u{2191}/\u{2193}", "move"),
1679            ("[/]", "branch"),
1680            ("\u{2190}/\u{2192}", "page"),
1681            ("l", "label"),
1682            ("t", "label time"),
1683            ("1-5", "filter"),
1684            ("Tab", "cycle"),
1685            ("Enter", "select"),
1686            ("Esc", "cancel"),
1687        ];
1688        let line: String = items
1689            .iter()
1690            .map(|(key, label)| format!("{} {} ", key, label))
1691            .collect::<Vec<_>>()
1692            .join("\u{00b7} ");
1693        let wrapped = wrap_text_with_ansi(&line, width.saturating_sub(4));
1694        wrapped
1695            .into_iter()
1696            .map(|l| theme.fg("muted", &format!("  {}", l)))
1697            .collect()
1698    }
1699}