1use 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#[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
70struct ToolCallInfo {
74 name: String,
75 arguments: serde_json::Value,
76}
77
78#[derive(Debug, Clone, Copy)]
82struct GutterInfo {
83 position: usize,
84 show: bool,
85}
86
87#[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
98pub 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: bool,
121
122 pub on_select: Option<Box<dyn FnMut(String)>>,
124 pub on_cancel: Option<Box<dyn FnMut()>>,
126 pub on_label_change: Option<BoxLabelChange>,
128}
129
130pub 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 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 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 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 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 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 }
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn truncate_display_text(&self, text: &str) -> String {
1084 const MAX_LEN: usize = 200;
1085 text.chars().take(MAX_LEN).collect()
1086 }
1087
1088 fn format_label_timestamp(&self, timestamp: &str) -> String {
1091 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 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 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 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 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 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 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 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 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 lines.extend(self.render_help(width));
1422
1423 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 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 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 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 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 const TREE_GUTTER_WIDTH: usize = 2; 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 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 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
1610impl 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}