Skip to main content

pi/interactive/
tree_ui.rs

1use super::*;
2
3impl PiApp {
4    #[allow(clippy::too_many_lines)]
5    pub(super) fn handle_tree_ui_key(&mut self, key: &KeyMsg) -> Option<Cmd> {
6        let tree_ui = self.tree_ui.take()?;
7
8        match tree_ui {
9            TreeUiState::Selector(mut selector) => {
10                match key.key_type {
11                    KeyType::Up => selector.move_selection(-1),
12                    KeyType::Down => selector.move_selection(1),
13                    KeyType::CtrlU => {
14                        selector.user_only = !selector.user_only;
15                        if let Ok(session_guard) = self.session.try_lock() {
16                            selector.rebuild(&session_guard);
17                        }
18                    }
19                    KeyType::CtrlO => {
20                        selector.show_all = !selector.show_all;
21                        if let Ok(session_guard) = self.session.try_lock() {
22                            selector.rebuild(&session_guard);
23                        }
24                    }
25                    KeyType::Esc | KeyType::CtrlC => {
26                        self.status_message = Some("Tree navigation cancelled".to_string());
27                        self.tree_ui = None;
28                        return None;
29                    }
30                    KeyType::Enter => {
31                        if selector.rows.is_empty() {
32                            self.tree_ui = None;
33                            return None;
34                        }
35
36                        let selected = selector.rows[selector.selected].clone();
37                        selector.last_selected_id = Some(selected.id.clone());
38
39                        let (new_leaf_id, editor_text) = if let Some(text) = selected.resubmit_text
40                        {
41                            (selected.parent_id.clone(), Some(text))
42                        } else {
43                            (Some(selected.id.clone()), None)
44                        };
45
46                        // No-op if already at target leaf.
47                        if selector.current_leaf_id.as_deref() == new_leaf_id.as_deref() {
48                            self.status_message = Some("Already on that branch".to_string());
49                            self.tree_ui = None;
50                            return None;
51                        }
52
53                        let Ok(session_guard) = self.session.try_lock() else {
54                            self.status_message = Some("Session busy; try again".to_string());
55                            self.tree_ui = None;
56                            return None;
57                        };
58
59                        let old_leaf_id = session_guard.leaf_id.clone();
60                        let (entries_to_summarize, summary_from_id) = collect_tree_branch_entries(
61                            &session_guard,
62                            old_leaf_id.as_deref(),
63                            new_leaf_id.as_deref(),
64                        );
65                        let session_id = session_guard.header.id.clone();
66                        drop(session_guard);
67
68                        let api_key_present = self.agent.try_lock().is_ok_and(|agent_guard| {
69                            agent_guard.stream_options().api_key.is_some()
70                        });
71
72                        let pending = PendingTreeNavigation {
73                            session_id,
74                            old_leaf_id,
75                            selected_entry_id: selected.id,
76                            new_leaf_id,
77                            editor_text,
78                            entries_to_summarize,
79                            summary_from_id,
80                            api_key_present,
81                        };
82
83                        if pending.entries_to_summarize.is_empty() {
84                            // Nothing to summarize; switch immediately.
85                            self.start_tree_navigation(pending, TreeSummaryChoice::NoSummary, None);
86                            return None;
87                        }
88
89                        self.tree_ui = Some(TreeUiState::SummaryPrompt(TreeSummaryPromptState {
90                            pending,
91                            selected: 0,
92                        }));
93                        return None;
94                    }
95                    _ => {}
96                }
97
98                self.tree_ui = Some(TreeUiState::Selector(selector));
99            }
100            TreeUiState::SummaryPrompt(mut prompt) => {
101                match key.key_type {
102                    KeyType::Up => {
103                        if prompt.selected > 0 {
104                            prompt.selected -= 1;
105                        }
106                    }
107                    KeyType::Down => {
108                        if prompt.selected < TreeSummaryChoice::all().len().saturating_sub(1) {
109                            prompt.selected += 1;
110                        }
111                    }
112                    KeyType::Esc | KeyType::CtrlC => {
113                        self.status_message = Some("Tree navigation cancelled".to_string());
114                        self.tree_ui = None;
115                        return None;
116                    }
117                    KeyType::Enter => {
118                        let choice = TreeSummaryChoice::all()[prompt.selected];
119                        match choice {
120                            TreeSummaryChoice::NoSummary | TreeSummaryChoice::Summarize => {
121                                let pending = prompt.pending;
122                                self.start_tree_navigation(pending, choice, None);
123                                return None;
124                            }
125                            TreeSummaryChoice::SummarizeWithCustomPrompt => {
126                                self.tree_ui =
127                                    Some(TreeUiState::CustomPrompt(TreeCustomPromptState {
128                                        pending: prompt.pending,
129                                        instructions: String::new(),
130                                    }));
131                                return None;
132                            }
133                        }
134                    }
135                    _ => {}
136                }
137                self.tree_ui = Some(TreeUiState::SummaryPrompt(prompt));
138            }
139            TreeUiState::CustomPrompt(mut custom) => {
140                match key.key_type {
141                    KeyType::Esc | KeyType::CtrlC => {
142                        self.tree_ui = Some(TreeUiState::SummaryPrompt(TreeSummaryPromptState {
143                            pending: custom.pending,
144                            selected: 2,
145                        }));
146                        return None;
147                    }
148                    KeyType::Backspace => {
149                        custom.instructions.pop();
150                    }
151                    KeyType::Enter => {
152                        let pending = custom.pending;
153                        let instructions = if custom.instructions.trim().is_empty() {
154                            None
155                        } else {
156                            Some(custom.instructions)
157                        };
158                        self.start_tree_navigation(
159                            pending,
160                            TreeSummaryChoice::SummarizeWithCustomPrompt,
161                            instructions,
162                        );
163                        return None;
164                    }
165                    KeyType::Runes => {
166                        for ch in key.runes.iter().copied() {
167                            custom.instructions.push(ch);
168                        }
169                    }
170                    _ => {}
171                }
172                self.tree_ui = Some(TreeUiState::CustomPrompt(custom));
173            }
174        }
175
176        None
177    }
178
179    /// Handle keyboard input when the branch picker overlay is active.
180    pub fn handle_branch_picker_key(&mut self, key: &KeyMsg) -> Option<Cmd> {
181        let picker = self.branch_picker.as_mut()?;
182
183        match key.key_type {
184            KeyType::Up => picker.select_prev(),
185            KeyType::Down => picker.select_next(),
186            KeyType::Runes if key.runes == ['k'] => picker.select_prev(),
187            KeyType::Runes if key.runes == ['j'] => picker.select_next(),
188            KeyType::Enter => {
189                if let Some(branch) = picker.selected_branch().cloned() {
190                    self.branch_picker = None;
191                    return self.switch_to_branch_leaf(&branch.leaf_id);
192                }
193                self.branch_picker = None;
194            }
195            KeyType::Esc | KeyType::CtrlC => {
196                self.branch_picker = None;
197                self.status_message = Some("Branch picker cancelled".to_string());
198            }
199            KeyType::Runes if key.runes == ['q'] => {
200                self.branch_picker = None;
201            }
202            _ => {} // consume all other input while picker is open
203        }
204
205        None
206    }
207
208    /// Switch the active branch to a different leaf. Reloads the conversation.
209    fn switch_to_branch_leaf(&mut self, leaf_id: &str) -> Option<Cmd> {
210        let (session_id, old_leaf_id) = self
211            .session
212            .try_lock()
213            .ok()
214            .map(|g| (g.header.id.clone(), g.leaf_id.clone()))
215            .unwrap_or_default();
216
217        let pending = PendingTreeNavigation {
218            session_id,
219            old_leaf_id,
220            selected_entry_id: leaf_id.to_string(),
221            new_leaf_id: Some(leaf_id.to_string()),
222            editor_text: None,
223            entries_to_summarize: Vec::new(),
224            summary_from_id: String::new(),
225            api_key_present: false,
226        };
227        self.start_tree_navigation(pending, TreeSummaryChoice::NoSummary, None);
228        None
229    }
230
231    /// Open the branch picker if the session has sibling branches.
232    pub fn open_branch_picker(&mut self) {
233        if self.agent_state != AgentState::Idle {
234            self.status_message = Some("Cannot switch branches while processing".to_string());
235            return;
236        }
237
238        let branches = self
239            .session
240            .try_lock()
241            .ok()
242            .and_then(|guard| guard.sibling_branches().map(|(_, b)| b));
243
244        match branches {
245            Some(branches) if branches.len() > 1 => {
246                self.branch_picker = Some(BranchPickerOverlay::new(branches));
247            }
248            _ => {
249                self.status_message =
250                    Some("No branches to pick (use /fork to create one)".to_string());
251            }
252        }
253    }
254
255    /// Cycle to the next or previous sibling branch (Ctrl+Right / Ctrl+Left).
256    pub fn cycle_sibling_branch(&mut self, forward: bool) {
257        if self.agent_state != AgentState::Idle {
258            self.status_message = Some("Cannot switch branches while processing".to_string());
259            return;
260        }
261
262        let target = self.session.try_lock().ok().and_then(|guard| {
263            let (_, branches) = guard.sibling_branches()?;
264            if branches.len() <= 1 {
265                return None;
266            }
267            let current_idx = branches.iter().position(|b| b.is_current)?;
268            let next_idx = if forward {
269                (current_idx + 1) % branches.len()
270            } else {
271                current_idx.checked_sub(1).unwrap_or(branches.len() - 1)
272            };
273            Some(branches[next_idx].leaf_id.clone())
274        });
275
276        if let Some(leaf_id) = target {
277            self.switch_to_branch_leaf(&leaf_id);
278        } else {
279            self.status_message = Some("No sibling branches (use /fork to create one)".to_string());
280        }
281    }
282
283    #[allow(clippy::too_many_lines)]
284    pub(super) fn start_tree_navigation(
285        &mut self,
286        pending: PendingTreeNavigation,
287        choice: TreeSummaryChoice,
288        custom_instructions: Option<String>,
289    ) {
290        let summary_requested = matches!(
291            choice,
292            TreeSummaryChoice::Summarize | TreeSummaryChoice::SummarizeWithCustomPrompt
293        );
294
295        // Fast path: no summary + no extensions. Keep it synchronous so unit tests can drive it
296        // without running the async runtime.
297        if !summary_requested && self.extensions.is_none() {
298            let Ok(mut session_guard) = self.session.try_lock() else {
299                self.status_message = Some("Session busy; try again".to_string());
300                return;
301            };
302
303            if let Some(target_id) = &pending.new_leaf_id {
304                if !session_guard.navigate_to(target_id) {
305                    self.status_message = Some(format!("Branch target not found: {target_id}"));
306                    return;
307                }
308            } else {
309                session_guard.reset_leaf();
310            }
311
312            let (messages, usage) = conversation_from_session(&session_guard);
313            let agent_messages = session_guard.to_messages_for_current_path();
314            let status_leaf = pending
315                .new_leaf_id
316                .clone()
317                .unwrap_or_else(|| "root".to_string());
318            drop(session_guard);
319
320            self.spawn_save_session();
321
322            if let Ok(mut agent_guard) = self.agent.try_lock() {
323                agent_guard.replace_messages(agent_messages);
324            }
325
326            self.messages = messages;
327            self.message_render_cache.clear();
328            self.total_usage = usage;
329            self.current_response.clear();
330            self.current_thinking.clear();
331            self.agent_state = AgentState::Idle;
332            self.current_tool = None;
333            self.abort_handle = None;
334            self.status_message = Some(format!("Switched to {status_leaf}"));
335            self.scroll_to_bottom();
336
337            if let Some(text) = pending.editor_text {
338                self.input.set_value(&text);
339            }
340            self.input.focus();
341
342            return;
343        }
344
345        let event_tx = self.event_tx.clone();
346        let session = Arc::clone(&self.session);
347        let agent = Arc::clone(&self.agent);
348        let extensions = self.extensions.clone();
349        let reserve_tokens = self.config.branch_summary_reserve_tokens();
350        let runtime_handle = self.runtime_handle.clone();
351
352        let Ok(agent_guard) = self.agent.try_lock() else {
353            self.status_message = Some("Agent busy; try again".to_string());
354            self.agent_state = AgentState::Idle;
355            return;
356        };
357        let provider = agent_guard.provider();
358        let key_opt = agent_guard.stream_options().api_key.clone();
359
360        self.tree_ui = None;
361        self.agent_state = AgentState::Processing;
362        self.status_message = Some("Switching branches...".to_string());
363
364        runtime_handle.spawn(async move {
365            let cx = Cx::for_request();
366
367            let from_id_for_event = pending
368                .old_leaf_id
369                .clone()
370                .unwrap_or_else(|| "root".to_string());
371            let to_id_for_event = pending
372                .new_leaf_id
373                .clone()
374                .unwrap_or_else(|| "root".to_string());
375
376            if let Some(manager) = extensions.clone() {
377                let cancelled = manager
378                    .dispatch_cancellable_event(
379                        ExtensionEventName::SessionBeforeSwitch,
380                        Some(json!({
381                            "fromId": from_id_for_event.clone(),
382                            "toId": to_id_for_event.clone(),
383                            "sessionId": pending.session_id.clone(),
384                        })),
385                        EXTENSION_EVENT_TIMEOUT_MS,
386                    )
387                    .await
388                    .unwrap_or(false);
389                if cancelled {
390                    let _ = event_tx.try_send(PiMsg::System(
391                        "Session switch cancelled by extension".to_string(),
392                    ));
393                    return;
394                }
395            }
396
397            let summary_skipped =
398                summary_requested && key_opt.is_none() && !pending.entries_to_summarize.is_empty();
399            let summary_text = if !summary_requested || pending.entries_to_summarize.is_empty() {
400                None
401            } else if let Some(key) = key_opt.as_deref() {
402                match crate::compaction::summarize_entries(
403                    &pending.entries_to_summarize,
404                    provider,
405                    key,
406                    reserve_tokens,
407                    custom_instructions.as_deref(),
408                )
409                .await
410                {
411                    Ok(summary) => summary,
412                    Err(err) => {
413                        let _ = event_tx
414                            .try_send(PiMsg::AgentError(format!("Branch summary failed: {err}")));
415                        return;
416                    }
417                }
418            } else {
419                None
420            };
421
422            let messages_for_agent = {
423                let mut guard = match session.lock(&cx).await {
424                    Ok(guard) => guard,
425                    Err(err) => {
426                        let _ = event_tx
427                            .try_send(PiMsg::AgentError(format!("Failed to lock session: {err}")));
428                        return;
429                    }
430                };
431
432                if let Some(target_id) = &pending.new_leaf_id {
433                    if !guard.navigate_to(target_id) {
434                        let _ = event_tx.try_send(PiMsg::AgentError(format!(
435                            "Branch target not found: {target_id}"
436                        )));
437                        return;
438                    }
439                } else {
440                    guard.reset_leaf();
441                }
442
443                if let Some(summary_text) = summary_text {
444                    guard.append_branch_summary(
445                        pending.summary_from_id.clone(),
446                        summary_text,
447                        None,
448                        None,
449                    );
450                }
451
452                let _ = guard.save().await;
453                guard.to_messages_for_current_path()
454            };
455
456            {
457                let mut agent_guard = match agent.lock(&cx).await {
458                    Ok(guard) => guard,
459                    Err(err) => {
460                        let _ = event_tx
461                            .try_send(PiMsg::AgentError(format!("Failed to lock agent: {err}")));
462                        return;
463                    }
464                };
465                agent_guard.replace_messages(messages_for_agent);
466            }
467
468            let (messages, usage) = {
469                let guard = match session.lock(&cx).await {
470                    Ok(guard) => guard,
471                    Err(err) => {
472                        let _ = event_tx
473                            .try_send(PiMsg::AgentError(format!("Failed to lock session: {err}")));
474                        return;
475                    }
476                };
477                conversation_from_session(&guard)
478            };
479
480            let status = if summary_skipped {
481                Some(format!(
482                    "Switched to {to_id_for_event} (no summary: missing API key)"
483                ))
484            } else {
485                Some(format!("Switched to {to_id_for_event}"))
486            };
487
488            let _ = event_tx.try_send(PiMsg::ConversationReset {
489                messages,
490                usage,
491                status,
492            });
493
494            if let Some(text) = pending.editor_text {
495                let _ = event_tx.try_send(PiMsg::SetEditorText(text));
496            }
497
498            if let Some(manager) = extensions {
499                let _ = manager
500                    .dispatch_event(
501                        ExtensionEventName::SessionSwitch,
502                        Some(json!({
503                            "fromId": from_id_for_event,
504                            "toId": to_id_for_event,
505                            "sessionId": pending.session_id,
506                        })),
507                    )
508                    .await;
509            }
510        });
511    }
512}