//! Frame rendering for [`AgentView`]: the `draw` entry point plus shortcut //! hints and the subagent fullscreen view. use super::{ ActivePane, AgentPane, AgentView, AgentViewLayout, InlineMediaHitAreas, MODE_BANNER_FADE_TICKS, PromptMode, collect_citation_links, dropdown_items_width, render_dropdown_chrome, supports_osc22, }; use crate::actions::{ActionId, ActionRegistry}; use crate::key; use crate::render::SafeBuf; use crate::render::line_utils::truncate_line; use crate::scrollback::block::BlockContent; use crate::scrollback::layout::HorizontalLayout; use crate::scrollback::render::ScratchBuffer; use crate::scrollback::text_selection::{ ResolvedSelectionBoundaries, ResolvedSelectionModel, render_active_selection_overlay, render_block_drag_overlay, render_persistent_selection_overlay, }; use crate::theme::Theme; use crate::views::btw_overlay::BTW_OVERLAY_ENTRY_IDX; use crate::views::modal; use crate::views::plan_approval_view::PlanApprovalFocus; use crate::views::prompt_widget::{PromptFlag, PromptInfo, PromptStyle}; use crate::views::question_view::QUESTION_VIEW_HPAD; use crate::views::shortcuts_bar::{HintItem, PendingHint, ShortcutsBar}; use crate::views::{agent, turn_status}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::Widget; use std::collections::HashSet; use std::time::Instant; impl AgentView { pub(crate) fn update_scrollback_selection_state( &mut self, model: ResolvedSelectionModel, boundaries: ResolvedSelectionBoundaries, ) { self.last_scrollback_selection_model = model; self.last_scrollback_selection_boundaries = boundaries; } fn clear_scrollback_selection_state(&mut self) { self.update_scrollback_selection_state(Default::default(), Default::default()); } /// Keep [`Self::timeline_hover_preview`] in sync with [`Self::timeline_hover`]. /// Called when hover changes (mouse Moved or rail rebuild under a /// stationary pointer) so render can borrow the cached text. pub(crate) fn sync_timeline_hover_preview(&mut self) { match self.timeline_hover { Some(crate::views::timeline::TimelineHit::Tick(turn_idx)) => { if self .timeline_hover_preview .as_ref() .is_some_and(|(idx, _)| *idx == turn_idx) { return; } self.timeline_hover_preview = self .scrollback .turn_preview(turn_idx) .map(|text| (turn_idx, text)); } _ => self.timeline_hover_preview = None, } } /// Open the fullscreen subagent view for `child_sid`, replaying child /// `updates.jsonl` when scrollback only has the injected task prompt. pub(crate) fn open_subagent_fullscreen(&mut self, child_sid: String) { if self.subagent_views.contains_key(&child_sid) { crate::app::subagent::ensure_subagent_child_replayed(self, &child_sid); self.active_subagent = Some(child_sid); } } /// Shortcut hints for the plan-approval prompt/comment focus states. /// /// Shared by `draw` and the cheatsheet Current section. `Tab:plan` is /// omitted when there is nothing to open (empty approval and no line /// viewer) so the footer never advertises a dead key. fn plan_approval_shortcut_hints( &self, pav: &crate::views::plan_approval_view::PlanApprovalViewState, ) -> Vec { match pav.focus { PlanApprovalFocus::Commenting => { vec![ HintItem::new(key!(Enter), "save comment"), HintItem::new(key!(Esc), "cancel"), ] } PlanApprovalFocus::Prompt => { let has_content = !pav.comments.is_empty() || !self.prompt.text().trim().is_empty(); if has_content { vec![ HintItem::new(key!(Enter), "request changes"), HintItem::new(key!(Tab), "plan"), HintItem::new(key!(Esc), "back"), ] } else { vec![ HintItem::new(key!(Enter), "approve"), HintItem::new(key!(Tab), "plan"), HintItem::new(key!(Esc), "back"), ] } } PlanApprovalFocus::Preview => vec![], } } /// Returns the *exact* hints the bottom shortcuts bar would render right now. /// /// Single source of truth for context-sensitive shortcuts (pane, overlays, /// sub-modes, selection state, turn running, plan/queue). Both the bar /// renderer and the Ctrl+. cheatsheet Current section delegate here, so /// they are guaranteed identical and every shortcut makes sense in the /// active context. /// /// Known transient: when a subagent is fullscreen (`active_subagent.is_some()`), /// draw returns early and the child renders its own bar; Current on the parent /// still reflects parent context (documented limitation, pre-existing before /// this change). pub fn current_shortcut_hints(&self, registry: &ActionRegistry) -> Vec { use crate::views::shortcuts_bar::HintItem; if let Some(ref viewer) = self.block_viewer { viewer.shortcuts_hints() } else if !self.permission_queue.is_empty() { use crate::views::permission_view::PermissionFocus; if let Some(perm) = self.permission_queue.front() { match perm.focus { PermissionFocus::FollowupInput => { vec![ HintItem::new(key!(Enter), "send"), HintItem::new(key!(Esc), "back"), ] } PermissionFocus::Options => { use crate::input::key::KeyShortcut; use crossterm::event::{KeyCode, KeyModifiers}; let n = perm.options.len().min(9) as u8; let last_ch = char::from(b'0' + n.max(1)); let last_key = KeyShortcut::new(KeyCode::Char(last_ch), KeyModifiers::NONE); let mut hints = vec![HintItem::paired(key!('1'), last_key, "select")]; if perm.has_adjustable_scope() { hints.push(HintItem::paired(key!(Left), key!(Right), "scope")); } if !perm.description.is_empty() { let label = if perm.args_expanded { "collapse" } else { "expand" }; hints.push(HintItem::new(key!('f', CONTROL), label)); } hints.push(HintItem::new(key!('o', CONTROL), "always-approve")); hints.push(HintItem::new(key!('c', CONTROL), "cancel")); hints } } } else { unreachable!("permission_queue non-empty per outer guard") } } else if let Some(ref pav) = self.plan_approval_view { self.plan_approval_shortcut_hints(pav) } else if self.line_viewer.is_some() && self.is_plan_viewer() { let suppress_shortcuts = self .line_viewer .as_ref() .is_some_and(|v| v.fullscreen && v.list_state.input_mode().is_some()); if suppress_shortcuts { vec![] } else if self.is_casual_commenting() { vec![ HintItem::new(key!(Enter), "save comment"), HintItem::new(key!(Esc), "cancel"), ] } else { let mut h = vec![ HintItem::new(key!('c'), "comment"), HintItem::new(key!('f', CONTROL), "fullscreen"), ]; if !self.plan_comments.is_empty() { h.push(HintItem::new(key!('s'), "send")); } h.push(HintItem::new(key!(Esc), "close")); h } } else if let Some(ref qv) = self.question_view { use crate::views::question_view::QuestionFocus; match qv.focus { QuestionFocus::InputMode => { if self.prompt.file_search_visible() { vec![ HintItem::paired(key!(Up), key!(Down), "nav"), HintItem::new(key!(Tab), "accept"), HintItem::new(key!(Right), "drill"), HintItem::new(key!(Esc), "dismiss"), ] } else { vec![ HintItem::new(key!(Enter), "submit"), HintItem::new(key!(Esc), "back"), ] } } QuestionFocus::Navigation => { vec![ HintItem::new(key!(Esc), "unselect"), HintItem::new(key!(Tab), "scrollback"), HintItem::new(key!('X'), "dismiss"), ] } } } else if self.cancel_turn_view.is_some() { vec![ HintItem::paired(key!('1'), key!('4'), "select"), HintItem::new(key!(Enter), "confirm"), HintItem::new(key!(Esc), "keep running"), HintItem::new(key!(Tab), "scrollback"), ] } else { self.normal_pane_hints(registry) } } /// Shared "normal pane" hints: flag computation + `build_hints` + queue hint. /// Single source of truth for the two former duplicated blocks in /// `current_shortcut_hints` and `draw`. fn normal_pane_hints(&self, registry: &ActionRegistry) -> Vec { let fold_label = self.selected_fold_label(); let is_editing = matches!(self.prompt_mode, PromptMode::EditingQueued { .. }); let selected_entry = self .scrollback .selected() .and_then(|idx| self.scrollback.entry(idx)); let selected_is_group_header = self .scrollback .selected() .is_some_and(|idx| self.scrollback.entry_content_hidden_by_group(idx)); let (selected_supports_copy, selected_meta_label, selected_supports_fullscreen) = if self.active_pane == ActivePane::Catalog { (false, None, self.catalog.selected_entry().is_some()) } else if self.active_pane == ActivePane::Tasks { let has_selected = self.tasks.selected_task_id().is_some_and(|tid| { self.session .bg_tasks .get(tid) .is_some_and(|t| !t.stdout.is_empty()) }); let has_any_selected = self.tasks.selected_task_id().is_some(); (has_selected, None, has_any_selected) } else if self.active_pane == ActivePane::Scrollback { let is_bg_task = selected_entry.is_some_and(|e| { matches!(e.block, crate::scrollback::block::RenderBlock::BgTask(_)) }); if is_bg_task { let has_stdout = selected_entry .and_then(|e| { if let crate::scrollback::block::RenderBlock::BgTask(b) = &e.block { Some(&b.task_id) } else { None } }) .is_some_and(|tid| { self.session .bg_tasks .get(tid) .is_some_and(|t| !t.stdout.is_empty()) }); (has_stdout, None, true) } else { let is_viewable_subagent = selected_entry.is_some_and(|e| { if let crate::scrollback::block::RenderBlock::Subagent(ref sb) = e.block { self.subagent_views.contains_key(&sb.child_session_id) } else { false } }); ( !selected_is_group_header && selected_entry.is_some_and(|e| e.block.supports_copy()), selected_entry .and_then(|e| e.block.copy_meta_label()) .filter(|_| !selected_is_group_header), selected_entry.is_some_and(|e| e.block.supports_fullscreen()) || is_viewable_subagent, ) } } else { ( !selected_is_group_header && selected_entry.is_some_and(|e| e.block.supports_copy()), selected_entry .and_then(|e| e.block.copy_meta_label()) .filter(|_| !selected_is_group_header), selected_entry.is_some_and(|e| e.block.supports_fullscreen()), ) }; let can_demote = self .session .tracker .running_execute_tool_call_id() .is_some(); let selected_can_kill = if self.active_pane == ActivePane::Catalog { false } else if self.active_pane == ActivePane::Tasks { self.tasks .selected_task_id() .and_then(|tid| self.session.bg_tasks.get(tid)) .is_some_and(|t| { t.status == crate::app::agent::BgTaskStatus::Running && !t.pending_kill }) } else if self.active_pane == ActivePane::Scrollback { !selected_is_group_header && selected_entry .and_then(|e| { if let crate::scrollback::block::RenderBlock::BgTask(b) = &e.block { Some(&b.task_id) } else { None } }) .and_then(|tid| self.session.bg_tasks.get(tid)) .is_some_and(|t| { t.status == crate::app::agent::BgTaskStatus::Running && !t.pending_kill }) } else { false }; let thinking_label = self.scrollback.thinking_fold_label(); let selected_is_user_prompt = selected_entry.is_some_and(|e| e.block.is_user_prompt()); let selected_is_agent_message = selected_entry.is_some_and(|e| e.block.is_agent_message()); let mut hints = agent::build_hints( self.active_pane, &self.prompt, registry, is_editing, fold_label, self.scrollback.selected_group_header_fold_label(), thinking_label, if self.active_pane == ActivePane::Tasks { self.tasks.show_done() } else { self.todo.show_done() }, selected_supports_copy, selected_meta_label, selected_supports_fullscreen, can_demote, selected_can_kill, self.multiline_mode, self.vim_mode, self.is_subagent_view, self.session.state.is_turn_running() && !self.renders_parked(), !self.visible_queue_is_empty(), selected_is_user_prompt, selected_is_agent_message, crate::terminal::terminal_context().shift_enter_unavailable(), self.scrollback_search.as_ref(), ); if (self.queue.is_visible() || !self.visible_queue_is_empty()) && self.active_pane != ActivePane::Queue && !matches!(self.prompt_mode, PromptMode::EditingQueued { .. }) && let Some(def) = registry.find(ActionId::ToggleQueue) { hints.push(def.hint()); } hints } /// Render the agent view into the given area. /// /// Thin orchestrator: computes layout, then calls shared widgets and /// agent-specific overlay helpers in sequence. Each component takes /// only the state it needs — no arg threading. /// /// Returns cursor position if the prompt is focused (for terminal cursor). /// Render a fullscreen subagent view — replaces the ENTIRE parent view. /// /// Draws: /// 1. Background fill /// 2. Title bar: icon + type + description + meta + badge + progress + [elapsed] + [✗] /// 3. Border frame (single-line box) /// 4. Child `AgentView::draw()` inside the frame #[allow(clippy::too_many_arguments)] fn draw_subagent_fullscreen( &mut self, child_sid: &str, area: Rect, buf: &mut Buffer, registry: &ActionRegistry, scratch: &mut ScratchBuffer, theme: &Theme, bundle_state: &crate::app::bundle::BundleState, ) -> ( Option<(u16, u16)>, Option, ) { use crate::app::subagent::{format_context_badge, format_subagent_label}; use ratatui::style::Modifier; use unicode_width::UnicodeWidthStr; let appearance = self.scrollback.appearance().clone(); let layout_cfg = &appearance.scrollback.layout; let compact = appearance.prompt.compact; agent::fill_background(buf, area, layout_cfg, compact, theme); let padded = Rect { x: area.x + layout_cfg.eff_hpad_left(compact), y: area.y + layout_cfg.eff_outer_vpad(compact), width: area.width.saturating_sub( layout_cfg.eff_hpad_left(compact) + layout_cfg.eff_hpad_right(compact), ), height: area .height .saturating_sub(layout_cfg.eff_outer_vpad(compact) * 2), }; if padded.width < 10 || padded.height < 5 { return (None, crate::terminal::overlay::clear().map(Into::into)); } let border_color = theme.selection_border; let frame = match crate::views::picker::render_bordered_frame( buf, padded, border_color, theme.bg_base, ) { Some(f) => f, None => { return (None, crate::terminal::overlay::clear().map(Into::into)); } }; let title_y = frame.title_row.y; let _title_row = frame.title_row; let inner = frame.content; let _border_style = Style::default().fg(border_color); let info = self.subagent_sessions.get(child_sid); let raw_description = info.map(|s| s.description.as_ref()).unwrap_or("subagent"); let is_running = info.is_some_and(|s| s.is_running()); let elapsed = info .map(|s| crate::util::format_duration(s.display_elapsed())) .unwrap_or_default(); let (type_label, description): (String, String) = match info { Some(s) => format_subagent_label(s), None => (String::new(), raw_description.to_string()), }; let icon = if is_running { let spinner_frames = crate::glyphs::dot_spinner_frames(); let tick = self.tasks.tick_count(); let frame_idx = (tick / 4) as usize % spinner_frames.len(); spinner_frames[frame_idx] } else if info.and_then(|s| s.status.as_deref()) == Some("completed") { crate::glyphs::check_mark() } else { crate::glyphs::ballot_x() }; let icon_color = if is_running { theme.accent_running } else if info.and_then(|s| s.status.as_deref()) == Some("completed") { theme.accent_success } else { theme.accent_error }; let label_color = if info.is_some_and(|s| s.pending_kill) { theme.accent_error } else if is_running { theme.accent_running } else if info.and_then(|s| s.status.as_deref()) == Some("completed") { theme.accent_success } else { theme.accent_error }; let meta = info .and_then(|s| s.model.as_deref()) .map(str::trim) .filter(|s| !s.is_empty()) .unwrap_or("") .to_string(); let badge = info.map(format_context_badge).unwrap_or(""); let activity_label: Option = if is_running { self.subagent_views.get(child_sid).and_then(|cv| { cv.resolve_turn_activity() .map(|a| crate::app::subagent::format_activity_label(&a)) .or_else(|| cv.session.state.is_busy().then(|| "Waiting".to_string())) }) } else { None }; let title_x = padded.x + 1; buf.set_span_safe( title_x, title_y, &Span::styled(format!(" {icon}"), Style::default().fg(icon_color)), 3, ); let close_text = "[\u{2717}]"; let close_width: u16 = close_text.width() as u16; let elapsed_text = elapsed.clone(); let right_margin: u16 = 1; let badge_width = if badge.is_empty() { 0 } else { badge.width() as u16 + 1 }; let activity_width: u16 = activity_label .as_deref() .map(|s| s.width() as u16 + 3) .unwrap_or(0); let right_width = activity_width + elapsed_text.width() as u16 + 1 + close_width + right_margin + badge_width; let desc_start_x = title_x + 3; let avail = padded.width.saturating_sub(5 + right_width) as usize; let type_text = if type_label.is_empty() { String::new() } else if description.is_empty() { type_label.clone() } else { format!("{type_label} ") }; let meta_text = if meta.is_empty() { String::new() } else { format!(" {meta}") }; let overhead = type_text.width() + meta_text.width(); let desc_max = avail.saturating_sub(overhead); let desc_display = crate::render::line_utils::truncate_str(&description, desc_max); if !type_text.is_empty() { buf.set_span_safe( desc_start_x, title_y, &Span::styled(&type_text, Style::default().fg(label_color)), type_text.width() as u16, ); } let desc_x = desc_start_x + type_text.width() as u16; buf.set_span_safe( desc_x, title_y, &Span::styled( &desc_display, Style::default() .fg(theme.text_primary) .add_modifier(Modifier::BOLD), ), desc_display.width() as u16, ); let after_desc_x = desc_x + desc_display.width() as u16; if !meta_text.is_empty() { buf.set_span_safe( after_desc_x, title_y, &Span::styled(&meta_text, Style::default().fg(theme.gray)), meta_text.width() as u16, ); } let mut rx = padded.x + padded.width.saturating_sub(right_margin + close_width + 1); let close_style = if self.hit_subagent_frame_close.hovered { Style::default() .fg(theme.text_primary) .add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.gray) }; buf.set_span_safe( rx, title_y, &Span::styled(close_text, close_style), close_width, ); self.hit_subagent_frame_close.rect = Some(Rect::new(rx, title_y, close_width, 1)); rx = rx.saturating_sub(elapsed_text.width() as u16 + 1); buf.set_span_safe( rx, title_y, &Span::styled(&elapsed_text, Style::default().fg(theme.gray)), elapsed_text.width() as u16, ); if let Some(activity) = activity_label.as_deref() { let segment = format!("{activity} \u{00b7} "); let w = segment.width() as u16; rx = rx.saturating_sub(w); buf.set_span_safe( rx, title_y, &Span::styled(segment, Style::default().fg(theme.gray)), w, ); } if !badge.is_empty() { rx = rx.saturating_sub(badge.width() as u16 + 1); buf.set_span_safe( rx, title_y, &Span::styled(badge, Style::default().fg(theme.gray_dim)), badge.width() as u16, ); } let mut child_post_flush = None; if inner.width > 5 && inner.height > 3 && let Some(child_view) = self.subagent_views.get_mut(child_sid) { let (_, post_flush) = child_view.draw( inner, buf, registry, scratch, None, false, 0, None, bundle_state, false, &mut Vec::new(), ); child_post_flush = post_flush; } (None, child_post_flush) } pub fn should_show_tip(&mut self) -> bool { false } /// `area` is the screen region assigned to this agent view. /// When a tracing overlay is visible, this is smaller than `f.area()`. /// #[allow(clippy::too_many_arguments)] /// Render the agent into `area`. /// /// `in_dashboard_overlay` is `true` when this view is being /// rendered inside the dashboard's session-overlay; it appends /// `Ctrl+\\:dashboard` and `Ctrl+[/]:agents` hints to the bottom shortcuts /// bar so the user can discover the keyboard back-out and /// agent-navigation shortcuts from inside the agent view itself /// (not just from the overlay's top header). #[allow(clippy::too_many_arguments)] pub fn draw( &mut self, area: Rect, buf: &mut Buffer, registry: &ActionRegistry, scratch: &mut ScratchBuffer, pending_hint: Option, overlay_focused: bool, banner_height: u16, tip: Option<&str>, bundle_state: &crate::app::bundle::BundleState, in_dashboard_overlay: bool, link_spans_out: &mut Vec, ) -> ( Option<(u16, u16)>, Option, ) { self.in_dashboard_overlay = in_dashboard_overlay; self.frame_occluder_rects.clear(); self.clear_scrollback_selection_state(); self.refresh_prompt_suggestion_gate(); let theme = Theme::current(); let link_active_style = Style::default() .fg(theme.link_fg) .add_modifier(ratatui::style::Modifier::UNDERLINED | ratatui::style::Modifier::BOLD); self.note_terminal_size((area.width, area.height)); #[allow(unused_assignments)] let mut scrollback_inline_media = Vec::new(); #[allow(unused_assignments)] let mut scrollback_diagram_affordances: Vec< crate::scrollback::render::DiagramAffordancePlacement, > = Vec::new(); if self.inline_media_active && (self.image_viewer.is_some() || self.video_viewer.is_some() || self.gboom.is_some() || self.block_viewer.is_some() || self.extensions_modal.is_some() || self.agents_modal.is_some() || self.btw_state.is_some() || self.line_viewer.is_some() || self.active_modal.is_some()) { self.inline_media_active = false; kigi_shell::util::with_locked_stderr(|stderr| { for &id in self.inline_media_ids.values() { let clear = crate::terminal::image::clear_kitty_image(id); let _ = std::io::Write::write_all(stderr, clear.as_bytes()); } }); self.inline_media_ids.clear(); self.inline_media_iterm_emitted.clear(); } if let Some(ref child_sid) = self.active_subagent.clone() { if let Some(esc) = self.take_own_inline_media_clear_escapes() { kigi_shell::util::with_locked_stderr(|stderr| { let _ = std::io::Write::write_all(stderr, esc.as_bytes()); }); } return self.draw_subagent_fullscreen( &child_sid.clone(), area, buf, registry, scratch, &theme, bundle_state, ); } if let Some(esc) = self.take_subagent_inline_media_clear_escapes() { kigi_shell::util::with_locked_stderr(|stderr| { let _ = std::io::Write::write_all(stderr, esc.as_bytes()); }); } let appearance = self.scrollback.appearance().clone(); let layout_cfg = &appearance.scrollback.layout; let scrollbar_cfg = &appearance.scrollback.scrollbar; let model_id = self .session .models .current_model_name() .unwrap_or_else(|| "unknown".to_string()); let effective_plan = self.plan_mode_pending.unwrap_or(self.plan_mode_active); let casual_commenting = self.is_casual_commenting(); let prompt_focused = if self.plan_approval_view.is_some() { self.plan_approval_view .as_ref() .is_some_and(|pav| pav.focus != PlanApprovalFocus::Preview) } else if casual_commenting { true } else { self.active_pane == AgentPane::Prompt && !overlay_focused }; let prompt_style = PromptStyle { focused: prompt_focused, show_prefix: appearance.prompt.show_prefix, vpad_top: 1, compact: appearance.prompt.compact, chrome: true, chrome_pad_left: layout_cfg.block_pad_left, chrome_pad_right: layout_cfg.block_pad_right, bg_override: None, accent_color_override: if let Some(c) = self.prompt_input_mode.accent_color(&theme) { Some(c) } else if effective_plan || casual_commenting { Some(theme.accent_plan) } else { None }, border_color_override: if effective_plan || casual_commenting { crate::render::color::blend_color(theme.bg_base, theme.accent_plan, 0.4) } else { None }, prefix_override: if let Some(p) = self.prompt_input_mode.prefix_override(&theme) { Some(p) } else if casual_commenting || self .plan_approval_view .as_ref() .is_some_and(|pav| pav.focus == PlanApprovalFocus::Commenting) { Some(( if crate::glyphs::is_legacy_windows_console() { "\u{2022} " } else { "\u{25CF} " }, theme.accent_plan, )) } else { None }, placeholder_override: if let Some(ph) = self .prompt_input_mode .placeholder_override(self.multiline_mode) { Some(ph) } else if casual_commenting || self .plan_approval_view .as_ref() .is_some_and(|pav| pav.focus == PlanApprovalFocus::Commenting) { Some("Type your comment...") } else { None }, show_accent_line: false, show_borders: true, title: self.display_name.clone(), image_preview: true, }; let compact = appearance.prompt.compact; let inner_width = AgentViewLayout::inner_width(area, layout_cfg, compact); let banner_height = if banner_height > 0 { if let Some(tip_text) = tip { banner_height.max(crate::tips::render::tip_height(inner_width, tip_text)) } else { banner_height } } else { banner_height }; let tip_row_visible = self.ephemeral_tip_renderable(area.height) && self.ephemeral_tip.is_active(); let banner_height = banner_height.max(u16::from(tip_row_visible)); let max_prompt_height = area.height / 2; let base_prompt_height = if !prompt_focused && appearance.prompt.collapse_unfocused { self.prompt .desired_height(inner_width, &prompt_style, true, max_prompt_height) .min(prompt_style.vpad_top + 1 + prompt_style.info_block(true)) } else { self.prompt .desired_height(inner_width, &prompt_style, true, max_prompt_height) }; let overlay_content_w = inner_width.saturating_sub(QUESTION_VIEW_HPAD) as usize; let permission_view_h = if let Some(perm) = self.permission_queue.front() { crate::views::permission_view::permission_view_height( perm, area.height, overlay_content_w, ) } else { 0 }; let question_view_h = if permission_view_h == 0 { if let Some(ref mut qv) = self.question_view { crate::views::question_view::question_view_height( qv, area.height, overlay_content_w, ) } else { 0 } } else { 0 }; let rewind_view_h = if permission_view_h == 0 && question_view_h == 0 { if let Some(ref rw) = self.rewind_state { crate::views::rewind::rewind_overlay_height(&rw.phase, area.height) } else { 0 } } else { 0 }; let cancel_turn_view_h = if permission_view_h == 0 && question_view_h == 0 && rewind_view_h == 0 { if self.cancel_turn_view.is_some() { modal::cancel_turn_panel_height(area.height) } else { 0 } } else { 0 }; let jump_view_h = if !self.jump_slot_taken() { if let Some(ref js) = self.jump_state { crate::views::jump::jump_overlay_height(js, area.height) } else { 0 } } else { 0 }; let is_question_input_mode = self .question_view .as_ref() .map(|qv| qv.focus == crate::views::question_view::QuestionFocus::InputMode) .unwrap_or(false); let question_input_style = PromptStyle { focused: true, show_prefix: true, vpad_top: 0, chrome: false, chrome_pad_left: 0, chrome_pad_right: 0, bg_override: Some(theme.bg_visual), accent_color_override: None, border_color_override: None, prefix_override: None, placeholder_override: None, compact: false, show_accent_line: false, show_borders: false, title: None, image_preview: true, }; let inline_prompt_max = ((area.height as u32) / 3).clamp(3, 15) as u16; let question_prompt_body_h = if question_view_h > 0 && is_question_input_mode { let question_text_w = crate::views::question_view::inline_text_width(inner_width); self.prompt.desired_height( question_text_w, &question_input_style, false, inline_prompt_max, ) } else { 0 }; let is_permission_followup = self.permission_queue.front().is_some_and(|p| { p.focus == crate::views::permission_view::PermissionFocus::FollowupInput }); let perm_inline_prompt_max = ((area.height as u32) / 3).clamp(3, 15) as u16; let perm_inline_prompt_h: u16 = if permission_view_h > 0 && is_permission_followup { let style = PromptStyle { focused: true, show_prefix: false, vpad_top: 0, chrome: false, chrome_pad_left: 0, chrome_pad_right: 0, bg_override: Some(theme.bg_visual), accent_color_override: None, border_color_override: None, prefix_override: None, placeholder_override: None, compact: false, show_accent_line: false, show_borders: false, title: None, image_preview: true, }; let perm_text_w = crate::views::permission_view::inline_text_width(inner_width); self.prompt .desired_height(perm_text_w, &style, false, perm_inline_prompt_max) .max(1) } else { 0 }; let question_footer_h: u16 = if question_view_h > 0 { 3 } else { 0 }; let prompt_height = if permission_view_h > 0 { if is_permission_followup && perm_inline_prompt_h > 1 { permission_view_h + perm_inline_prompt_h.saturating_sub(1) } else { permission_view_h } } else if question_view_h > 0 { let freeform_offset = if is_question_input_mode { 1u16 } else { 0 }; question_view_h.saturating_sub(freeform_offset) + question_prompt_body_h + question_footer_h } else if rewind_view_h > 0 { rewind_view_h } else if jump_view_h > 0 { jump_view_h } else if cancel_turn_view_h > 0 { cancel_turn_view_h } else { base_prompt_height }; let prompt_height = prompt_height.max(prompt_style.vpad_top + 1 + prompt_style.info_block(true)); let prompt_height = if self.is_subagent_view { 0 } else { prompt_height }; let prompt_height = if question_view_h > 0 { let reserved = 1 + 5 + 1 + 3; prompt_height.min(area.height.saturating_sub(reserved)) } else { prompt_height }; { use crate::app::agent::PENDING_KILL_TIMEOUT_SECS; let now = Instant::now(); for task in self.session.bg_tasks.values_mut() { if let Some(requested) = task.kill_requested_at && now.duration_since(requested).as_secs() >= PENDING_KILL_TIMEOUT_SECS { task.pending_kill = false; task.kill_requested_at = None; } } for info in self.subagent_sessions.values_mut() { if let Some(requested) = info.kill_requested_at && now.duration_since(requested).as_secs() >= PENDING_KILL_TIMEOUT_SECS { info.pending_kill = false; info.kill_requested_at = None; } } } let queued_cron_ids: HashSet<&str> = self .session .pending_prompts .iter() .filter(|p| p.kind == crate::app::agent::QueueEntryKind::Cron) .filter_map(|p| p.task_id.as_deref()) .collect(); self.tasks.sync( &self.session.bg_tasks, &self.subagent_sessions, &self.session.scheduled_tasks, self.cron_task_id.as_deref(), &queued_cron_ids, ); if self.active_pane == ActivePane::Tasks && !self.tasks.is_visible() { self.active_pane = ActivePane::Scrollback; } self.catalog.sync_from_bundle(bundle_state); if self.active_pane == ActivePane::Catalog && !self.catalog.is_visible() { self.active_pane = ActivePane::Scrollback; } self.catalog.sync_from_bundle(bundle_state); if self.active_pane == ActivePane::Catalog && !self.catalog.is_visible() { self.active_pane = ActivePane::Scrollback; } let viewer_open = self.active_subagent.is_some(); let tasks_height = if viewer_open { 0 } else { self.tasks.desired_height(area.height) }; let catalog_height = if viewer_open { 0 } else { self.catalog.desired_height(area.height) }; let todo_height = if viewer_open { 0 } else { self.todo.desired_height(area.height) }; self.sync_queue_pane(); if self.active_pane == ActivePane::Queue && !self.queue.is_visible() { self.active_pane = ActivePane::Scrollback; } let queue_height = self.queue.desired_height(); let drain_blocked = self.drain_blocked(); let watchers = turn_status::Watchers { monitors: self .session .bg_tasks .values() .filter(|t| t.is_monitor && t.status == crate::app::agent::BgTaskStatus::Running) .count(), loops: self.session.scheduled_tasks.len(), subagents: self .subagent_sessions .values() .filter(|s| s.is_running()) .count(), }; let parked = self.renders_parked(); let turn_status_height = if !parked && turn_status::should_show( &self.session.state, drain_blocked, self.mcp_init_progress.as_ref(), watchers, ) { 1 } else { 0 }; let prompt_gap = if appearance.prompt.compact || (turn_status_height > 0 && !appearance.turn_status.gap) || area.height <= agent::SHORT_TERMINAL_ROWS { 0 } else { 1 }; let _tool_usage_height = 0u16; let btw_height = crate::views::btw_overlay::btw_panel_height(self.btw_state.as_ref(), inner_width); let follow_ups_height = u16::from(self.follow_ups.is_some()); let timeline_width = crate::views::timeline::rail_width( appearance.show_timeline, self.is_subagent_view, area.width, self.scrollback.turn_count(), ); let mut layout = AgentViewLayout::compute( area, layout_cfg, scrollbar_cfg, timeline_width, prompt_height, tasks_height, catalog_height, todo_height, queue_height, btw_height, turn_status_height, banner_height, follow_ups_height, 0, prompt_gap, 1, compact, ); let search_active = self.scrollback_search.is_some() && self.active_pane == AgentPane::Scrollback; let search_reserved_rows = Self::search_reserved_rows(layout.scrollback.height, search_active); if search_reserved_rows > 0 { layout.scrollback.height -= search_reserved_rows; layout.scrollback_content.height = layout .scrollback_content .height .saturating_sub(search_reserved_rows); } let overlay_blocks_rail_hover = self.jump_state.is_some() || self.rewind_state.is_some() || self.question_view.is_some() || !self.permission_queue.is_empty() || self.cancel_turn_view.is_some() || self.block_viewer.is_some(); if layout.timeline_width > 0 { self.sync_pending_user_input_marks(); self.scrollback.set_cwd(Some(self.session.cwd.clone())); let _ = self.sync_inline_edit_layout(layout.scrollback_content.width); self.scrollback.prepare_layout( layout.scrollback_content.width, layout.scrollback_content.height, ); let viewport = crate::views::timeline::RailViewport { active: self.scrollback.active_turn_for_viewport(), up_target: self.scrollback.turn_above_viewport_top(), down_target: self.scrollback.turn_below_viewport_top(), at_bottom: !self.scrollback.has_content_below(), }; match crate::views::timeline::compute_rail( layout.scrollback, layout.timeline_x, self.scrollback.turn_count(), viewport, ) { Some(rail) => { self.timeline_rail = Some(rail); if overlay_blocks_rail_hover { self.timeline_hover = None; self.timeline_hover_preview = None; } else { let (col, row) = self.last_mouse_pos; let new_hover = self.timeline_rail.as_ref().and_then(|r| r.hit(col, row)); if new_hover != self.timeline_hover { self.timeline_hover = new_hover; self.sync_timeline_hover_preview(); } } } None => { self.timeline_rail = None; self.timeline_hover = None; self.timeline_hover_preview = None; layout = AgentViewLayout::compute( area, layout_cfg, scrollbar_cfg, 0, prompt_height, tasks_height, catalog_height, todo_height, queue_height, btw_height, turn_status_height, banner_height, follow_ups_height, 0, prompt_gap, 1, compact, ); if search_reserved_rows > 0 { layout.scrollback.height -= search_reserved_rows; layout.scrollback_content.height = layout .scrollback_content .height .saturating_sub(search_reserved_rows); } self.scrollback.prepare_layout( layout.scrollback_content.width, layout.scrollback_content.height, ); } } } else { self.timeline_rail = None; self.timeline_hover = None; self.timeline_hover_preview = None; } agent::fill_background(buf, area, layout_cfg, compact, &theme); use crate::views::agent_status::AgentStatusBar; use crate::views::context_bar; let mut status = AgentStatusBar::new(&theme); if let Some(url) = self.highlighted_link_url() { let max_len = layout.status_bar.width.saturating_sub(20) as usize; let display = if url.len() > max_len { let truncated: String = url.chars().take(max_len.saturating_sub(1)).collect(); format!("{truncated}\u{2026}") } else { url.to_string() }; let link_style = Style::default().fg(theme.link_fg).bg(theme.bg_base); status.push("link_url", Line::from(Span::styled(display, link_style))); } let running_count = self.tasks.running_count( &self.session.bg_tasks, &self.subagent_sessions, &self.session.scheduled_tasks, ); if running_count > 0 { let spinner_frames = crate::glyphs::dot_spinner_frames(); let frame_idx = (self.tasks.tick_count() / 4) as usize % spinner_frames.len(); let frame = spinner_frames[frame_idx]; let indicator = format!("{frame} {running_count}"); let mut indicator_style = Style::default().fg(theme.accent_running).bg(theme.bg_base); if self.hit_bg_status.hovered { indicator_style = indicator_style.add_modifier(ratatui::style::Modifier::BOLD); } status.push( "bg_tasks", Line::from(Span::styled(indicator, indicator_style)), ); } if self.should_show_plan_chip(&appearance) { let mut plan_style = Style::default().fg(theme.accent_plan).bg(theme.bg_base); if self.hit_plan_button.hovered { plan_style = plan_style.add_modifier(ratatui::style::Modifier::BOLD); } status.push("plan", Line::from(Span::styled("plan", plan_style))); } if let Some(ref goal) = self.goal_state { let tick = self.tasks.tick_count() as usize; let active_subagent_tokens: u64 = self .subagent_sessions .values() .filter(|s| !s.finished) .filter_map(|s| s.tokens_used) .sum(); status.push( "goal", crate::views::agent_status::goal_status_line( goal, &theme, self.hit_goal_status.hovered, tick, self.context_state.as_ref().map(|c| c.used), active_subagent_tokens, ), ); } if let Some(mcp_line) = self.mcp_init_progress.as_ref().and_then(|p| { crate::views::agent_status::mcp_status_line(p, self.scrollback.animation_tick(), &theme) }) { status.push("mcp", mcp_line); } let ctx_used = self.context_state.as_ref().map(|c| c.used); let model_window = self.session.models.get_context_window(); let ctx_total = self .context_state .as_ref() .and_then(|c| (c.total > 0).then_some(c.total)) .or(model_window); if let Some(ctx_line) = context_bar::context_bar_line_for_session( ctx_used, ctx_total, self.hit_context.hovered, &theme, self.chat_kind, ) { status.push("context", ctx_line); } let running = self.session.current_prompt_id.as_deref(); let queue_len = self.session.queue_len() + self .shared_queue .iter() .filter(|e| Some(e.id.as_str()) != running) .count(); if queue_len > 0 { use ratatui::style::Modifier; let mut queue_style = ratatui::style::Style::default() .fg(theme.accent_user) .bg(theme.bg_base); if self.hit_queue_badge.hovered { queue_style = queue_style.add_modifier(Modifier::BOLD); } status.push( "queue", Line::from(Span::styled(format!("+{queue_len}"), queue_style)), ); } let counts = self.todo.counts(); if let Some(badge_spans) = agent::render_todo_badge_spans( &counts, self.hit_badge.hovered, self.todo.badge_flash_active(), appearance.todo.badge_format, &theme, ) { status.push("badge", Line::from(badge_spans)); } let areas = status.render(buf, layout.status_bar); self.hit_bg_status.rect = areas.get("bg_tasks").copied(); self.hit_goal_status.rect = areas.get("goal").copied(); self.hit_context.rect = areas.get("context").copied(); self.hit_plan_button.rect = areas.get("plan").copied(); self.hit_queue_badge.rect = areas.get("queue").copied(); self.hit_badge.rect = areas.get("badge").copied(); let home = std::env::var("HOME").ok(); let display = self.session.cwd.display().to_string(); let short = match &home { Some(h) if display.starts_with(h.as_str()) => { format!("~{}", &display[h.len()..]) } _ => display, }; let cwd_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base); use unicode_width::UnicodeWidthStr; let mut parts: Vec = Vec::new(); let mut path_offset: u16 = 0; let lazy_git = crate::git_info::cwd_git_info_lazy(&self.session.cwd); let branch = self .current_branch .clone() .or_else(|| lazy_git.as_ref().and_then(|i| i.branch.clone())); let git_text = branch.map(|b| { let icon = crate::git_info::branch_icon(); if b.is_empty() { format!("{icon} detached") } else { format!("{icon} {b}") } }); if let Some(git_text) = git_text { let git_style = Style::default() .fg(theme.text_primary) .bg(theme.bg_base) .add_modifier(ratatui::style::Modifier::DIM); path_offset += git_text.width() as u16; parts.push(Span::styled(git_text, git_style)); path_offset += 1; parts.push(Span::styled(" ", Style::default().bg(theme.bg_base))); } let show_worktree_label = self.is_worktree || self.session.is_worktree || lazy_git.as_ref().is_some_and(|i| i.is_worktree); if show_worktree_label { let label_style = Style::default().fg(theme.accent_user).bg(theme.bg_base); path_offset += "worktree ".width() as u16; parts.push(Span::styled("worktree ", label_style)); } if let Some(profile) = kigi_sandbox::profile_name() { let sandbox_text = format!("sandbox:{profile} "); let sandbox_style = Style::default().fg(theme.warning).bg(theme.bg_base); path_offset += sandbox_text.width() as u16; parts.push(Span::styled(sandbox_text, sandbox_style)); } let path_width = short.width() as u16; let path_style = if self.hit_cwd.hovered { Style::default().fg(theme.text_primary).bg(theme.bg_base) } else { cwd_style }; parts.push(Span::styled(short, path_style)); let main_repo_display = self .main_repo .clone() .or_else(|| lazy_git.as_ref().and_then(|i| i.main_repo.clone())); if let Some(main_repo) = main_repo_display { parts.push(Span::styled( format!(" (worktree of {main_repo})"), cwd_style, )); } let cwd_line = Line::from(parts); let max_cwd_width = areas .values() .map(|r| r.x) .min() .map(|min_x| min_x.saturating_sub(layout.status_bar.x).saturating_sub(1)) .unwrap_or(layout.status_bar.width); let cwd_line = truncate_line(cwd_line, max_cwd_width as usize); let cwd_width = cwd_line.width() as u16; buf.set_line_safe( layout.status_bar.x, layout.status_bar.y, &cwd_line, cwd_width, ); let path_x = layout.status_bar.x + path_offset; let visible_path_width = path_width.min(cwd_width.saturating_sub(path_offset)); self.hit_cwd.rect = (visible_path_width > 0).then_some(Rect { x: path_x, y: layout.status_bar.y, width: visible_path_width, height: 1, }); let dropdown_open = self.prompt.any_dropdown_open(); let mut inline_edit_cursor: Option<(u16, u16)> = None; { self.sync_pending_user_input_marks(); self.scrollback.set_cwd(Some(self.session.cwd.clone())); let inline_edit_dim_from = self.sync_inline_edit_layout(layout.scrollback_content.width); self.scrollback.prepare_layout( layout.scrollback_content.width, layout.scrollback_content.height, ); let rewind_dim_from = self.rewind_dim_from_entry().or(inline_edit_dim_from); let sb_focused = self.active_pane == ActivePane::Scrollback && !overlay_focused; let search_highlight = if search_active { self.scrollback_search .as_ref() .and_then(|s| s.highlight_regex()) } else { None }; self.ensure_media_link_paths(); let sb_rendered = crate::scrollback::ScrollbackPane::new() .active(sb_focused) .with_mouse_pos(self.last_mouse_pos) .with_dim_from(rewind_dim_from) .with_hovered_entry(self.hovered_entry) .with_search_highlight(search_highlight) .with_media_paths(self.media_link_paths.clone()) .render_with_scratch_and_selection_boundaries( layout.scrollback_content, buf, &self.scrollback, scratch, ); let sb_output = sb_rendered.output; self.update_scrollback_selection_state( sb_output.selection_model.clone(), sb_rendered.selection_boundaries, ); self.reclamp_drag_head_post_render(false); if self.inline_edit.is_some() { let cursor = self.render_inline_edit(buf, layout.scrollback_content); if self.rewind_state.is_none() { inline_edit_cursor = cursor; } } if search_reserved_rows > 0 && let Some(search) = self.scrollback_search.as_ref() { let reserved_top = layout.scrollback.y + layout.scrollback.height; let bar_y = reserved_top + (search_reserved_rows - 1); if search_reserved_rows >= 2 { crate::views::picker::render_divider( buf, layout.scrollback.x, reserved_top, layout.scrollback.width, &theme, None, ); } let query = search.query(); crate::views::picker::render_search_bar( buf, layout.scrollback.x, bar_y, layout.scrollback.width, &theme, query, search.is_composing(), !search.is_composing(), query.len(), None, ); let counter = match search.current_index() { Some(i) => Some(format!("{}/{}", i + 1, search.match_count())), None if search.has_error() => Some("bad pattern".to_string()), None if !query.is_empty() => Some("no matches".to_string()), None => None, }; if let Some(counter) = counter { let w = counter.len() as u16; if layout.scrollback.width > w { buf.set_string( layout.scrollback.x + layout.scrollback.width - w, bar_y, &counter, Style::default().fg(theme.gray), ); } } } self.last_link_overlay = sb_output.link_overlay; scrollback_inline_media = sb_output.inline_media; scrollback_diagram_affordances = sb_output.diagram_affordances; if self.visible_link_map.is_stale(self.scrollback.generation()) { let citation_links = collect_citation_links(&self.scrollback, &sb_output.selection_model); self.visible_link_map.rebuild( self.scrollback.generation(), &self.last_link_overlay, citation_links, ); self.scrollback_visible_link_count = self.visible_link_map.len(); } else { self.visible_link_map .truncate(self.scrollback_visible_link_count); } let sb_link_n = self.scrollback_visible_link_count; self.paint_link_highlights(buf, link_active_style, 0..sb_link_n); agent::render_entry_hover( buf, layout.scrollback, &self.scrollback, self.hovered_entry, &theme, ); if let Some(ref drag) = self.drag_selection && drag.anchor.entry_idx != BTW_OVERLAY_ENTRY_IDX { render_active_selection_overlay( &self.last_scrollback_selection_model, drag, self.table_geometry_for_selection(drag.anchor.entry_idx, drag.anchor.range_id), buf, ); } else if let Some(ref block_drag) = self.block_drag_selection { render_block_drag_overlay(&self.last_scrollback_selection_model, block_drag, buf); } else if let Some(ref sel) = self.persistent_text_selection && sel.entry_idx != BTW_OVERLAY_ENTRY_IDX { render_persistent_selection_overlay( &self.last_scrollback_selection_model, sel, self.table_geometry_for_selection(sel.entry_idx, sel.range_id), buf, ); } agent::render_hook_hover_popup( buf, layout.scrollback, &self.scrollback, self.hovered_entry, self.last_mouse_pos, &theme, ); let any_drag_active = self.drag_selection.is_some() || self.block_drag_selection.is_some(); if !any_drag_active && !overlay_focused && let Some(ref selection_box) = sb_output.selection_box { selection_box.render(buf); self.render_selection_buttons( buf, selection_box, sb_output.selected_entry_area, &theme, ); } else { self.hit_sb_copy.clear(); self.hit_sb_view.clear(); } let rail_shown = self.timeline_rail.is_some(); if !rail_shown { agent::render_scrollbar( buf, layout.scrollback, layout.scrollbar_x, scrollbar_cfg, sb_output.scroll_info, self.scrollback.is_follow_mode(), &theme, ); } if !rail_shown && scrollbar_cfg.enabled && sb_output.scroll_info.is_some() { self.hit_scrollbar.set(Some(Rect { x: layout.scrollbar_x, y: layout.scrollback.y, width: 1, height: layout.scrollback.height, })); } else { self.hit_scrollbar.clear(); } if let Some(ref rail) = self.timeline_rail { crate::views::timeline::render_rail(buf, rail, self.timeline_hover, &theme); if let Some(crate::views::timeline::TimelineHit::Tick(turn_idx)) = self.timeline_hover { let preview = self .timeline_hover_preview .as_ref() .filter(|(idx, _)| *idx == turn_idx) .map(|(_, text)| text.as_str()); if let Some(preview) = preview { crate::views::timeline::render_tick_hover_popup( buf, rail, layout.scrollback, turn_idx, preview, &theme, ); } } } } if self.block_viewer.is_none() && !search_active { use crate::appearance::FollowIndicator; let gap_y = layout.scrollback.y + layout.scrollback.height; let gap_x = layout.scrollback.x; let gap_w = layout.scrollback.width; let mut content_line_y: Option = None; if appearance.scrollback.display.line_under_last_entry && !self.scrollback.is_empty() { let (scroll_offset, _, total_height) = self.scrollback.scroll_info(); let content_end_screen = u16::try_from( layout.scrollback.y as usize + total_height.saturating_sub(scroll_offset), ) .unwrap_or(u16::MAX); if content_end_screen <= gap_y && content_end_screen >= layout.scrollback.y { let line_y = content_end_screen; content_line_y = Some(line_y); let line_x = gap_x + 3; let line_end = (gap_x + gap_w).saturating_sub(2); let line_style = ratatui::style::Style::default().fg(theme.bg_light); for x in line_x..line_end { if let Some(cell) = buf.cell_mut((x, line_y)) { cell.set_symbol("╌"); cell.set_style(line_style); } } } } let show_indicator = appearance.scrollback.scroll.follow_indicator != FollowIndicator::None && !self.scrollback.is_follow_mode() && self.scrollback.has_content_below() && content_line_y.is_none(); if show_indicator { let center_x = gap_x + gap_w / 2; let indicator_style = ratatui::style::Style::default().fg(if self.hit_follow_indicator.hovered { theme.gray_bright } else { theme.gray }); if let Some(cell) = buf.cell_mut((center_x, gap_y)) { cell.set_symbol("▼"); cell.set_style(indicator_style); } self.hit_follow_indicator.set(Some(Rect::new( center_x.saturating_sub(1), gap_y, 3, 1, ))); } else { self.hit_follow_indicator.clear(); } } if let Some(msg) = self.active_toast_message() { let sb = layout.scrollback; let toast_text = format!(" {msg} "); let w = toast_text.chars().count() as u16; if sb.height > 0 && sb.width > w + 2 { let x = sb.right().saturating_sub(w + 1); let y = sb.bottom().saturating_sub(1); for (i, ch) in toast_text.chars().enumerate() { if let Some(cell) = buf.cell_mut((x + i as u16, y)) { cell.set_char(ch); cell.fg = theme.accent_user; cell.bg = theme.bg_base; cell.modifier = ratatui::prelude::Modifier::BOLD; } } self.frame_occluder_rects.push(Rect { x, y, width: w, height: 1, }); } } if tasks_height > 0 { let bg_focused = self.active_pane == ActivePane::Tasks && !overlay_focused; self.tasks.render( layout.tasks, buf, bg_focused, layout_cfg, &self.session.bg_tasks, &self.subagent_sessions, &self.session.scheduled_tasks, ); let close_rect = agent::render_todo_chrome( buf, layout.tasks, layout_cfg, bg_focused, false, self.hit_bg_close.hovered, &theme, ) .and_then(|sel| sel.close_button_rect()); self.hit_bg_close.set(close_rect); } if catalog_height > 0 { let cat_focused = self.active_pane == ActivePane::Catalog && !overlay_focused; self.catalog .render(layout.catalog, buf, cat_focused, layout_cfg); let close_rect = agent::render_todo_chrome( buf, layout.catalog, layout_cfg, cat_focused, false, self.hit_catalog_close.hovered, &theme, ) .and_then(|sel| sel.close_button_rect()); self.hit_catalog_close.set(close_rect); } else { self.hit_catalog_close.clear(); } if todo_height > 0 { let todo_focused = self.active_pane == ActivePane::Todo && !overlay_focused; self.todo.render(layout.todo, buf, todo_focused, layout_cfg); let close_rect = agent::render_todo_chrome( buf, layout.todo, layout_cfg, todo_focused, false, self.hit_todo_close.hovered, &theme, ) .and_then(|sel| sel.close_button_rect()); self.hit_todo_close.set(close_rect); } else { self.hit_todo_close.clear(); } if queue_height > 0 { let queue_focused = self.active_pane == ActivePane::Queue && !overlay_focused; self.queue.render( layout.queue, buf, queue_focused, layout_cfg, Some(layout.scrollback), self.session.state.is_turn_running(), ); let close_rect = agent::render_todo_chrome_with_close_label( buf, layout.queue, layout_cfg, queue_focused, false, self.hit_queue_close.hovered, &theme, Some(crate::glyphs::ballot_x_button()), ) .and_then(|sel| sel.close_button_rect()); self.hit_queue_close.set(close_rect); } else { self.hit_queue_close.clear(); } self.last_btw_selection_model = ResolvedSelectionModel::default(); self.last_btw_area = Rect::default(); if btw_height > 0 && let Some(ref btw) = self.btw_state { let tick = self.scrollback.animation_tick(); let mut btw_links = crate::render::osc8::LinkOverlay::new(); crate::views::btw_overlay::render_btw_panel( buf, btw, layout.btw, tick, self.btw_focused && self.active_pane == AgentPane::Prompt, Some(&mut self.hit_btw_close), &mut self.last_btw_selection_model, Some(&mut btw_links), &self.media_link_paths, ); self.last_btw_area = layout.btw; if !btw_links.is_empty() { self.last_link_overlay.extend_from(&btw_links); self.visible_link_map.append_from_overlay(&btw_links); } let sb_n = self.scrollback_visible_link_count; let total_links = self.visible_link_map.len(); self.paint_link_highlights(buf, link_active_style, sb_n..total_links); self.reclamp_drag_head_post_render(true); if let Some(ref drag) = self.drag_selection && drag.anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX { render_active_selection_overlay(&self.last_btw_selection_model, drag, None, buf); } else if let Some(ref sel) = self.persistent_text_selection && sel.entry_idx == BTW_OVERLAY_ENTRY_IDX { render_persistent_selection_overlay(&self.last_btw_selection_model, sel, None, buf); } } else { self.hit_btw_close.clear(); } if let Some(idx) = self.highlighted_link_idx { if self.visible_link_map.is_empty() { self.highlighted_link_idx = None; } else if idx >= self.visible_link_map.links().len() { self.highlighted_link_idx = Some(self.visible_link_map.links().len() - 1); } } if let Some(idx) = self.hovered_link_idx && (self.visible_link_map.is_empty() || idx >= self.visible_link_map.links().len()) { self.hovered_link_idx = None; } if turn_status_height > 0 { let pad_left = HorizontalLayout::ACCENT + layout_cfg.block_pad_left.saturating_sub(1); let turn_area = Rect { x: layout.turn_status.x + pad_left, y: layout.turn_status.y, width: layout.turn_status.width.saturating_sub(pad_left), height: layout.turn_status.height, }; let tick = self.scrollback.animation_tick(); let activity = self.resolve_turn_activity(); if activity != self.last_activity { if let Some(prev) = &self.last_activity { let phase_ms = self .activity_started_at .map(|t| t.elapsed().as_millis() as u64) .unwrap_or(0); let prev_label = prev.as_label(); let next_label = activity.as_ref().map(|a| a.as_label()).unwrap_or("idle"); let sid = self.session.session_id.as_ref().map(|s| s.0.as_ref()); crate::unified_log::debug( "turn.phase_transition", sid, Some(serde_json::json!( { "from" : prev_label, "to" : next_label, "phase_elapsed_ms" : phase_ms, } )), ); } self.activity_started_at = Some(Instant::now()); self.last_activity = activity.clone(); } self.hit_plan_approval_status.clear(); if let Some(ref pav) = self.plan_approval_view { let diamond_color = crate::views::turn_status::pending_diamond_color( &theme, theme.accent_plan, tick, ); let text_style = if self.hit_plan_approval_status.hovered { Style::default() .fg(theme.text_primary) .add_modifier(ratatui::style::Modifier::UNDERLINED) } else { Style::default().fg(theme.gray) }; let status_label = crate::views::plan_approval_view::plan_approval_status_label(pav.has_plan); let spans = vec![ Span::styled( format!("{} ", crate::glyphs::diamond_filled()), Style::default().fg(diamond_color), ), Span::styled(status_label, text_style), ]; buf.set_line_safe( turn_area.x, turn_area.y, &Line::from(spans), turn_area.width, ); let item_width: u16 = 2u16.saturating_add(status_label.len() as u16); self.hit_plan_approval_status.rect = Some(Rect::new( turn_area.x, turn_area.y, item_width.min(turn_area.width), 1, )); self.hit_cancel_button.rect = None; self.hit_bg_button.rect = None; } else { let has_running_execute = self .session .tracker .running_execute_tool_call_id() .is_some(); let is_pending_user_input = !self.permission_queue.is_empty() || self.question_view.is_some(); let goal_verifying = self .goal_state .as_ref() .is_some_and(|g| g.verifying_completion); let held_queue = self.held_queue_count(); let held_queue_top_sendable = self.held_queue_top_sendable(); let turn_output = turn_status::render_turn_status( buf, turn_area, &self.session.state, &activity, self.turn_elapsed(), self.activity_started_at, tick, drain_blocked, Some(turn_status::MouseButtons { cancel_hovered: self.hit_cancel_button.hovered, bg_hovered: self.hit_bg_button.hovered, }), has_running_execute, self.context_state.as_ref().map(|c| c.used), self.mcp_init_progress.as_ref(), self.bash_turn, is_pending_user_input, goal_verifying, watchers, false, held_queue, held_queue_top_sendable, ); self.hit_cancel_button .set_unless_dropdown(turn_output.cancel_button, dropdown_open); self.hit_bg_button .set_unless_dropdown(turn_output.bg_button, dropdown_open); } } else { self.hit_cancel_button.clear(); self.hit_bg_button.clear(); self.hit_plan_approval_status.clear(); } if let Some((ref msg, remaining)) = self.mode_switch_banner { if layout.banner.height > 0 && layout.banner.width > 4 { let bg = theme.bg_base; for col in 0..layout.banner.width { if let Some(cell) = buf.cell_mut((layout.banner.x + col, layout.banner.y)) { cell.set_char(' '); cell.fg = bg; cell.bg = bg; } } let opacity = if remaining > MODE_BANNER_FADE_TICKS { 1.0 } else { remaining as f32 / MODE_BANNER_FADE_TICKS as f32 }; let base_fg = theme.text_secondary; let fg = crate::render::color::blend_color(theme.bg_base, base_fg, opacity) .unwrap_or(base_fg); let text = format!(" {}", msg); let maxw = layout.banner.width.saturating_sub(2) as usize; let display: String = if text.len() > maxw { text.chars() .take(maxw.saturating_sub(1)) .collect::() + "…" } else { text }; let x = layout.banner.x; let y = layout.banner.y; for (i, ch) in display.chars().enumerate() { if let Some(cell) = buf.cell_mut((x + i as u16, y)) { cell.set_char(ch); cell.fg = fg; cell.bg = bg; } } } } else { if banner_height > 0 && let Some(tip_text) = tip { crate::tips::render::render_tip(layout.banner, buf, tip_text); } if tip_row_visible && let Some(line) = self.ephemeral_tip.line() { crate::tips::render::render_ephemeral_tip(layout.banner, buf, line); } } self.follow_up_chips = match self.follow_ups.as_ref() { Some(fu) => agent::render_follow_ups( layout.follow_ups, buf, &theme, &fu.suggestions, self.hovered_follow_up_chip, ), None => { self.hovered_follow_up_chip = None; Vec::new() } }; let editing_label; let commenting_label; let theme = Theme::current(); let mut mode_flags_vec: Vec = Vec::new(); let approval_is_commenting = self .plan_approval_view .as_ref() .is_some_and(|pav| pav.focus == PlanApprovalFocus::Commenting); if effective_plan || casual_commenting { let commenting_range: Option<&std::ops::Range> = if approval_is_commenting { self.plan_approval_view .as_ref() .and_then(|pav| pav.commenting_range.as_ref()) } else if casual_commenting { self.casual_commenting_range.as_ref() } else { None }; let plan_label: &str = if approval_is_commenting || casual_commenting { commenting_label = match commenting_range { Some(r) if r.len() == 1 => format!("commenting L{}", r.start), Some(r) => format!("commenting L{}-{}", r.start, r.end - 1), None => "commenting".to_string(), }; commenting_label.as_str() } else if self.plan_approval_view.is_some() { "plan approval" } else { "plan" }; mode_flags_vec.push(PromptFlag { text: plan_label, color: Some(theme.accent_plan), bold: false, }); } if self.session.is_yolo() && !effective_plan { mode_flags_vec.push(PromptFlag { text: "always-approve", color: None, bold: false, }); } if self.auto_flag_visible(effective_plan) { mode_flags_vec.push(PromptFlag { text: "auto", color: Some(theme.accent_system), bold: false, }); } let mode_flags: &[PromptFlag] = &mode_flags_vec; let multiline = self.multiline_mode; let model_label = match self.session.models.reasoning_effort { Some(eff) => format!("{model_id} ({eff})"), None => model_id, }; let info = match &self.prompt_mode { PromptMode::Normal => PromptInfo { model_name: &model_label, flags: mode_flags, multiline, }, PromptMode::EditingQueued { id, .. } => { let pos = self.session.queue_position(*id).map(|i| i + 1).unwrap_or(1); editing_label = format!("editing queued #{pos}"); PromptInfo { model_name: &editing_label, flags: mode_flags, multiline, } } }; let info = if let Some(label) = self.prompt_input_mode.prompt_info_override() { PromptInfo { model_name: label, flags: &[], multiline: false, } } else { info }; let mut prompt_cursor_pos: Option<(u16, u16)> = None; let mut prompt_post_flush: Option = None; if permission_view_h > 0 { let perm_area = layout.prompt; if let Some(perm) = self.permission_queue.front() { let followup_text = self.prompt.text(); let render_result = crate::views::permission_view::render_permission_view( buf, perm_area, perm, followup_text, self.hovered_permission_item, &theme, prompt_focused, ); if let Some(ref iarea) = render_result.inline_prompt { let row_bg = theme.bg_visual; let remaining_h = (perm_area.y + perm_area.height).saturating_sub(iarea.y); let perm_followup_style = PromptStyle { focused: true, show_prefix: false, vpad_top: 0, chrome: false, chrome_pad_left: 0, chrome_pad_right: 0, bg_override: Some(row_bg), accent_color_override: None, border_color_override: None, prefix_override: None, placeholder_override: None, compact: false, show_accent_line: false, show_borders: false, title: None, image_preview: true, }; let prompt_h = remaining_h.saturating_sub(1).max(1); let prompt_draw_area = Rect { x: iarea.text_x, y: iarea.y, width: iarea.text_w, height: prompt_h, }; let prompt_result_inner = self.prompt .draw(buf, prompt_draw_area, None, &perm_followup_style, None); if let Some(pos) = prompt_result_inner.cursor_pos { prompt_cursor_pos = Some(pos); } let accent_color = theme.accent_user; let track_x = prompt_draw_area.x + prompt_draw_area.width.saturating_sub(1); let accent_end = iarea.y.saturating_add(prompt_h); for extra_y in (iarea.y + 1)..accent_end { buf.set_style( Rect { x: layout.prompt.x + 1, y: extra_y, width: track_x.saturating_sub(layout.prompt.x + 1), height: 1, }, Style::default().bg(row_bg), ); if let Some(cell) = buf.cell_mut((layout.prompt.x, extra_y)) { cell.set_symbol(crate::glyphs::accent_bar()); cell.set_style(Style::default().fg(accent_color).bg(row_bg)); } } let track_bg = Style::default().bg(theme.bg_dark); for track_y in iarea.y..accent_end { if let Some(cell) = buf.cell_mut((track_x, track_y)) && cell.symbol() == " " { cell.set_style(track_bg); } } } } } else if question_view_h > 0 { let question_accent = if effective_plan { theme.accent_plan } else { theme.accent_user }; let is_input_mode = self .question_view .as_ref() .map(|qv| qv.focus == crate::views::question_view::QuestionFocus::InputMode) .unwrap_or(false); let inline_prompt_h: u16 = if is_input_mode { question_prompt_body_h.max(1) } else { 0 }; let question_area = Rect { x: layout.prompt.x, y: layout.prompt.y, width: layout.prompt.width, height: layout .prompt .height .saturating_sub(inline_prompt_h) .saturating_sub(question_footer_h), }; if let Some(ref mut qv) = self.question_view { let content_w = inner_width.saturating_sub(QUESTION_VIEW_HPAD) as usize; if let Some(question) = qv.questions.get(qv.active_tab) { let visible_options_h = crate::views::question_view::visible_options_height( question, question_area.height, content_w, qv.focused_preview(), qv.fullscreen, qv.cached_desc_cap, qv.cached_preview_cap, ); qv.clamp_scroll(visible_options_h, content_w); } } if let Some(ref qv) = self.question_view { let render_result = crate::views::question_view::render_question_view( buf, question_area, qv, self.hovered_question_item, &theme, prompt_focused, ); self.question_scroll_region = Some((render_result.options_start_y, render_result.options_end_y)); } if is_input_mode && inline_prompt_h > 0 { let row_y = question_area.y + question_area.height; let content_x = layout.prompt.x + 3; let content_w = layout.prompt.width.saturating_sub(3); let row_bg = theme.bg_visual; buf.set_style( Rect { x: content_x, y: row_y, width: content_w, height: 1, }, Style::default().bg(row_bg), ); let prefix_w = self .question_view .as_ref() .and_then(|qv| qv.questions.get(qv.active_tab)) .map(crate::views::question_view::option_prefix_w) .unwrap_or(6) as u16; let num_style = Style::default().fg(theme.accent_user).bg(row_bg); let marker_style = Style::default() .fg(theme.text_primary) .bg(row_bg) .add_modifier(ratatui::style::Modifier::BOLD); let prompt_ind = Style::default().fg(theme.accent_user).bg(row_bg); buf.set_span_safe(content_x, row_y, &Span::styled("z ", num_style), 2); let is_multi = self .question_view .as_ref() .and_then(|qv| qv.questions.get(qv.active_tab)) .and_then(|q| q.multi_select) .unwrap_or(false); let freeform_sel = self .question_view .as_ref() .and_then(|qv| { qv.per_question_freeform_selected .get(qv.active_tab) .copied() }) .unwrap_or(false); let (marker_text, actual_marker_style) = if is_multi { if freeform_sel { ("[x] ".to_string(), marker_style) } else { ( "[ ] ".to_string(), Style::default().fg(theme.gray).bg(row_bg), ) } } else if freeform_sel { (format!("({}) ", crate::glyphs::filled_dot()), marker_style) } else { ( "(\u{25cb}) ".to_string(), Style::default().fg(theme.gray).bg(row_bg), ) }; buf.set_span_safe( content_x + 2, row_y, &Span::styled(marker_text, actual_marker_style), 4, ); buf.set_span_safe( content_x + prefix_w, row_y, &Span::styled(crate::glyphs::prompt_arrow(), prompt_ind), 2, ); let text_x = content_x + prefix_w + 2; let text_w = content_w.saturating_sub(prefix_w + 2); let text_style = PromptStyle { show_prefix: false, ..question_input_style.clone() }; let prompt_draw_area = Rect { x: text_x, y: row_y, width: text_w, height: inline_prompt_h, }; let prompt_result_inner = self.prompt.draw( buf, prompt_draw_area, Some(layout.scrollback), &text_style, None, ); prompt_cursor_pos = prompt_result_inner.cursor_pos; self.inline_prompt_area = Some(Rect { x: layout.prompt.x, y: row_y, width: layout.prompt.width, height: inline_prompt_h, }); for y in row_y..row_y.saturating_add(inline_prompt_h) { if y > row_y { buf.set_style( Rect { x: content_x, y, width: prefix_w + 2, height: 1, }, Style::default().bg(row_bg), ); } for col in (layout.prompt.x + 1)..(layout.prompt.x + 3) { if let Some(cell) = buf.cell_mut((col, y)) { cell.set_char(' '); cell.set_style(Style::default().bg(row_bg)); } } if let Some(cell) = buf.cell_mut((layout.prompt.x, y)) { cell.set_symbol(crate::glyphs::accent_bar()); cell.set_style(Style::default().fg(question_accent).bg(row_bg)); } } } if !is_input_mode { self.inline_prompt_area = None; } if let Some(ref qv) = self.question_view { let footer_y = question_area.y + question_area.height + inline_prompt_h + 1; let footer_x = layout.prompt.x; let footer_w = layout.prompt.width; if footer_y < layout.prompt.y + layout.prompt.height && footer_w > 10 { use ratatui::style::Modifier; let footer_bg = theme.bg_light; let gap_above = footer_y.saturating_sub(1); if gap_above >= question_area.y + question_area.height + inline_prompt_h { buf.set_style( Rect { x: footer_x, y: gap_above, width: footer_w, height: 1, }, Style::default().bg(footer_bg), ); } let gap_below = footer_y + 1; if gap_below < layout.prompt.y + layout.prompt.height { buf.set_style( Rect { x: footer_x, y: gap_below, width: footer_w, height: 1, }, Style::default().bg(footer_bg), ); } let footer_rect = Rect { x: footer_x, y: footer_y, width: footer_w, height: 1, }; buf.set_style(footer_rect, Style::default().bg(footer_bg)); let content_x = layout.prompt.x + 3; let hint_style = Style::default() .fg(theme.gray) .bg(footer_bg) .add_modifier(Modifier::BOLD); let hint_key = Style::default() .fg(question_accent) .bg(footer_bg) .add_modifier(Modifier::BOLD); let mut left_spans: Vec> = Vec::new(); if qv.questions.len() > 1 { let counter = format!("[{}/{}] ", qv.active_tab + 1, qv.questions.len()); left_spans.push(Span::styled(counter, hint_style)); } left_spans.push(Span::styled("\u{2191}/\u{2193}", hint_key)); left_spans.push(Span::styled(" navigate", hint_style)); if qv.questions.len() > 1 { left_spans.push(Span::styled(" \u{b7} ", hint_style)); left_spans.push(Span::styled("\u{2190}/\u{2192}", hint_key)); left_spans.push(Span::styled(" question", hint_style)); } left_spans.push(Span::styled(" \u{b7} ", hint_style)); left_spans.push(Span::styled("y", hint_key)); left_spans.push(Span::styled(" copy", hint_style)); let left_line = Line::from(left_spans); let avail_w = footer_w.saturating_sub(3); buf.set_line_safe(content_x, footer_y, &left_line, avail_w); let is_last = qv.active_tab >= qv.questions.len().saturating_sub(1); let enter_label = if qv.is_on_freeform_row() { "edit" } else if is_last { "submit" } else { "select" }; let btn_key = "Enter"; let btn_bg = theme.bg_base; let bkey_style = Style::default() .fg(question_accent) .bg(btn_bg) .add_modifier(Modifier::BOLD); let blabel_style = Style::default().fg(theme.gray).bg(btn_bg); let bpad_style = Style::default().bg(btn_bg); let bw = (1 + btn_key.len() + 1 + enter_label.len() + 1) as u16; let btn_x = footer_x + footer_w.saturating_sub(3).saturating_sub(bw); if btn_x > content_x { buf.set_span_safe(btn_x, footer_y, &Span::styled(" ", bpad_style), 1); buf.set_span_safe( btn_x + 1, footer_y, &Span::styled(btn_key, bkey_style), btn_key.len() as u16, ); let cx = btn_x + 1 + btn_key.len() as u16; buf.set_span_safe(cx, footer_y, &Span::styled(":", blabel_style), 1); buf.set_span_safe( cx + 1, footer_y, &Span::styled(enter_label, blabel_style), enter_label.len() as u16, ); buf.set_span_safe( cx + 1 + enter_label.len() as u16, footer_y, &Span::styled(" ", bpad_style), 1, ); self.question_nav_buttons.clear(); let btn_rect = Rect { x: btn_x, y: footer_y, width: bw, height: 1, }; self.question_nav_buttons.push(('\n', btn_rect)); } else { self.question_nav_buttons.clear(); } } } let accent_style = Style::default().fg(question_accent).bg(theme.bg_light); for y in layout.prompt.y..layout.prompt.y + layout.prompt.height { if let Some(cell) = buf.cell_mut((layout.prompt.x, y)) { cell.set_symbol(crate::glyphs::accent_bar()); cell.set_style(accent_style); } } if let Some(ref qv) = self.question_view { let inner_scrollbar_x = layout.prompt.x + layout.prompt.width - 1; let scroll_region = self .question_scroll_region .unwrap_or((question_area.y, question_area.y + question_area.height)); let sb_rect = crate::views::question_view::render_question_scrollbar( buf, inner_scrollbar_x, qv, &theme, scroll_region, ); self.hit_question_scrollbar.set(sb_rect); } else { self.hit_question_scrollbar.clear(); } } else if rewind_view_h > 0 { if let Some(ref rw) = self.rewind_state { crate::views::rewind::render_rewind_overlay( buf, layout.prompt, &rw.phase, prompt_focused, ); } } else if jump_view_h > 0 { if let Some(ref js) = self.jump_state { crate::views::jump::render_jump_overlay(buf, layout.prompt, js, prompt_focused); } } else if cancel_turn_view_h > 0 { let buttons = &mut self.cancel_turn_buttons; if let Some(ctv) = self.cancel_turn_view.as_ref() { modal::render_cancel_turn_panel(buf, layout.prompt, ctv, prompt_focused, buttons); } else { buttons.clear(); } } else { let collapsed = !prompt_focused && appearance.prompt.collapse_unfocused; let saved_scroll = if collapsed { let s = self.prompt.scroll(); let ovr = self.prompt.textarea.scroll_override(); self.prompt.textarea.set_scroll_override(Some(0)); Some((s, ovr)) } else { None }; let prompt_result_inner = self.prompt.draw( buf, layout.prompt, Some(layout.scrollback), &prompt_style, Some(&info), ); if let Some((s, ovr)) = saved_scroll { self.prompt.textarea.set_scroll_override(ovr); self.prompt.set_scroll(s); } prompt_cursor_pos = prompt_result_inner.cursor_pos; if let Some(escapes) = prompt_result_inner.post_flush_escapes { prompt_post_flush = Some(escapes.into()); } } if self.prompt.file_search_visible() { use crate::views::file_search::dropdown::{MAX_DROPDOWN_ROWS, render_dropdown}; let item_count = self.prompt.file_search.result_count(); let item_rows = (item_count as u16).min(MAX_DROPDOWN_ROWS); if item_rows > 0 { self.prompt.file_search.ensure_visible(item_rows as usize); let bottom_border_y = if let Some(ipa) = self.inline_prompt_area { ipa.y.saturating_sub(1) } else { layout.prompt.y.saturating_sub(1) }; let panel_height = item_rows + 2; let top_border_y = bottom_border_y.saturating_sub(panel_height - 1); let panel_x = area.x + layout_cfg.eff_hpad_left(compact); let panel_width = area.width.saturating_sub( layout_cfg.eff_hpad_left(compact) + layout_cfg.eff_hpad_right(compact), ); if top_border_y < bottom_border_y && panel_width > 4 { let panel_area = Rect { x: panel_x, y: top_border_y, width: panel_width, height: panel_height, }; ratatui::widgets::Clear.render(panel_area, buf); self.frame_occluder_rects.push(panel_area); buf.set_style( panel_area, Style::default().fg(theme.text_primary).bg(theme.bg_light), ); let border_style = Style::default().fg(theme.bg_highlight).bg(theme.bg_base); let border_line = Line::styled("─".repeat(panel_width as usize), border_style); buf.set_line_safe(panel_x, top_border_y, &border_line, panel_width); buf.set_line_safe(panel_x, bottom_border_y, &border_line, panel_width); { let (k, n) = ( self.prompt.file_search.result_count(), self.prompt.file_search.total_items(), ); let hint = if k >= 1000 { format!("1k+/{n}") } else { format!("{k}/{n}") }; let hint_w = hint.len() as u16; if hint_w + 2 <= panel_width { let hint_x = panel_x + panel_width - hint_w - 1; let hint_line = Line::styled( hint, Style::default().fg(theme.gray).bg(theme.bg_base), ); buf.set_line_safe(hint_x, top_border_y, &hint_line, hint_w); } } let content_inset = 1 + layout_cfg.eff_hpad_left(compact); let items_x = layout.prompt.x + content_inset; let items_width = layout.prompt.width.saturating_sub(content_inset); let items_area = Rect { x: items_x, y: top_border_y + 1, width: items_width, height: item_rows, }; render_dropdown(buf, items_area, &self.prompt.file_search, &theme); self.dropdown_items_area = Some(items_area); } else { self.dropdown_items_area = None; } } else { self.dropdown_items_area = None; } } else { self.dropdown_items_area = None; } if !self.prompt.file_search_visible() && self.prompt.slash_open() { use crate::views::slash_dropdown::{ desired_item_rows, render_dropdown as render_slash, }; let snap = self.prompt.slash_snapshot(); let item_count = snap.matches.len(); let items_width = dropdown_items_width(layout.prompt, layout_cfg, compact); let item_rows = desired_item_rows(&snap.matches, items_width); if item_rows > 0 { if let Some(chrome) = render_dropdown_chrome( buf, item_count, item_rows, self.inline_prompt_area, layout.prompt, area, layout_cfg, compact, false, &theme, ) { let hovered = self.prompt.slash_hovered(); self.slash_dropdown_hit = render_slash(buf, chrome.items, &snap, hovered, &theme); self.slash_dropdown_items_area = Some(chrome.items); self.frame_occluder_rects.push(chrome.panel); } else { self.slash_dropdown_items_area = None; self.slash_dropdown_hit = Default::default(); } } else { self.slash_dropdown_items_area = None; self.slash_dropdown_hit = Default::default(); } } else { self.slash_dropdown_items_area = None; self.slash_dropdown_hit = Default::default(); } if !self.prompt.slash_open() && !self.prompt.file_search_visible() && self.prompt.completion_dropdown_open() { use crate::views::completion_dropdown::{ MAX_VISIBLE_ROWS, render_dropdown as render_completions, }; let dd = &self.prompt.suggestions.dropdown; let item_count = dd.items.len(); let item_rows = (item_count as u16).min(MAX_VISIBLE_ROWS); if item_rows > 0 { if let Some(chrome) = render_dropdown_chrome( buf, item_count, item_rows, self.inline_prompt_area, layout.prompt, area, layout_cfg, compact, false, &theme, ) { render_completions(buf, chrome.items, dd, &theme); self.completion_dropdown_items_area = Some(chrome.items); self.frame_occluder_rects.push(chrome.panel); } else { self.completion_dropdown_items_area = None; } } else { self.completion_dropdown_items_area = None; } } else { self.completion_dropdown_items_area = None; } if self.prompt.history_search.is_active() { use crate::render::scrollbar::render_scrollbar_styled; use ratatui::style::Modifier; let max_rows: u16 = 8; let result_count = self.prompt.history_search.result_count(); let item_rows = (result_count as u16).min(max_rows).max(1); let panel_height = item_rows + 2; let bottom_border_y = layout.prompt.y.saturating_sub(1); let top_border_y = bottom_border_y.saturating_sub(panel_height - 1); let panel_x = area.x + layout_cfg.eff_hpad_left(compact); let panel_width = area.width.saturating_sub( layout_cfg.eff_hpad_left(compact) + layout_cfg.eff_hpad_right(compact), ); if panel_height >= 3 && top_border_y < bottom_border_y && panel_width > 4 { let panel_area = Rect { x: panel_x, y: top_border_y, width: panel_width, height: panel_height, }; ratatui::widgets::Clear.render(panel_area, buf); self.frame_occluder_rects.push(panel_area); buf.set_style( panel_area, Style::default().fg(theme.text_primary).bg(theme.bg_light), ); let border_style = Style::default().fg(theme.bg_highlight).bg(theme.bg_base); let border_line = Line::styled("\u{2500}".repeat(panel_width as usize), border_style); buf.set_line_safe(panel_x, top_border_y, &border_line, panel_width); buf.set_line_safe(panel_x, bottom_border_y, &border_line, panel_width); { let hint = format!("{result_count}"); let hint_w = hint.len() as u16; if hint_w + 2 <= panel_width { let hint_x = panel_x + panel_width - hint_w - 1; buf.set_line_safe( hint_x, top_border_y, &Line::styled(hint, Style::default().fg(theme.gray).bg(theme.bg_base)), hint_w, ); } let label = " history "; let label_w = label.len() as u16; if label_w + 2 <= panel_width { buf.set_line_safe( panel_x + 1, top_border_y, &Line::styled(label, Style::default().fg(theme.gray).bg(theme.bg_base)), label_w, ); } } let content_inset = 1 + layout_cfg.eff_hpad_left(compact); let items_x = layout.prompt.x + content_inset; let items_width = layout.prompt.width.saturating_sub(content_inset); let items_area_rect = Rect { x: items_x, y: top_border_y + 1, width: items_width, height: item_rows, }; self.history_dropdown_area = Some(items_area_rect); let hover_bg = theme.bg_hover; if result_count == 0 { let msg_y = top_border_y + 1; if msg_y < bottom_border_y { let message = if self.prompt_history_loading() { " Loading..." } else { " no matching history" }; buf.set_string_safe( items_x, msg_y, message, Style::default().fg(theme.gray).bg(theme.bg_light), ); } } else { let selected = self.prompt.history_search.selected; let visible_rows = item_rows as usize; let scroll_offset = if selected >= visible_rows { selected - (visible_rows - 1) } else { 0 }; let visible_end = result_count.min(scroll_offset + visible_rows); let needs_scrollbar = result_count > visible_rows; let text_width = if needs_scrollbar { items_width.saturating_sub(2) } else { items_width }; for (vi, ri) in (scroll_offset..visible_end).enumerate() { let row_y = top_border_y + 1 + vi as u16; if row_y >= bottom_border_y { break; } let result = match self.prompt.history_search.result_at(ri) { Some(r) => r, None => continue, }; let is_selected = ri == selected; let is_hovered = self.prompt.history_search.hovered() == Some(ri) && !is_selected; let row_bg = if is_selected { theme.bg_visual } else if is_hovered { hover_bg } else { theme.bg_light }; let bold = if is_selected { Modifier::BOLD } else { Modifier::empty() }; let fill_width = if needs_scrollbar { items_width.saturating_sub(1) } else { items_width }; for col in items_x..items_x + fill_width { if let Some(cell) = buf.cell_mut((col, row_y)) { cell.set_char(' '); cell.set_style(Style::default().bg(row_bg)); } } let prefix_w: u16 = crate::glyphs::PROMPT_ARROW_WIDTH; let prefix = if is_selected { crate::glyphs::prompt_arrow() } else { " " }; let pfx_style = Style::default() .fg(theme.text_primary) .bg(row_bg) .add_modifier(bold); for (i, ch) in prefix.chars().enumerate() { let px = items_x + i as u16; if px < items_x + text_width && let Some(cell) = buf.cell_mut((px, row_y)) { cell.set_char(ch); cell.set_style(if is_selected { pfx_style } else { Style::default().bg(row_bg) }); } } let match_style = Style::default() .fg(theme.accent_user) .bg(row_bg) .add_modifier(bold); let normal_style = Style::default() .fg(theme.text_primary) .bg(row_bg) .add_modifier(bold); let mut indices = &result.indices[..]; let mut col = items_x + prefix_w; let max_col = items_x + prefix_w + text_width.saturating_sub(prefix_w); let preview: String = result .text .chars() .map(|c| if c == '\n' || c == '\r' { ' ' } else { c }) .collect(); for (ci, ch) in preview.chars().enumerate() { let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0) as u16; if col + cw > max_col { if col > items_x + prefix_w && let Some(cell) = buf.cell_mut((col.saturating_sub(1), row_y)) { cell.set_char('\u{2026}'); } break; } let is_match = indices.first() == Some(&(ci as u32)); if is_match { indices = &indices[1..]; } let st = if is_match { match_style } else { normal_style }; if let Some(cell) = buf.cell_mut((col, row_y)) { cell.set_symbol(&ch.to_string()); cell.set_style(st); } col += cw; } } if needs_scrollbar { let sb_area = Rect { x: items_x + items_width - 1, y: top_border_y + 1, width: 1, height: item_rows, }; render_scrollbar_styled( buf, Some(sb_area), result_count as u16, item_rows, scroll_offset as u16, Style::default().bg(theme.bg_dark), Style::default().fg(theme.gray_dim).bg(theme.bg_dark), ); } } } } else { self.history_dropdown_area = None; } if self.active_modal.is_some() { self.draw_active_modal(area, buf, theme, compact); self.pane_areas = layout.pane_areas(); return (None, crate::terminal::overlay::clear().map(Into::into)); } if let Some(ref viewer) = self.block_viewer { let hints = viewer.shortcuts_hints(); ShortcutsBar::new(&hints) .with_pending(pending_hint) .render(layout.shortcuts, buf); } else if !self.permission_queue.is_empty() { use crate::views::permission_view::PermissionFocus; use crate::views::shortcuts_bar::HintItem; let hints = if let Some(perm) = self.permission_queue.front() { match perm.focus { PermissionFocus::FollowupInput => { vec![ HintItem::new(key!(Enter), "send"), HintItem::new(key!(Esc), "back"), ] } PermissionFocus::Options => { use crate::input::key::KeyShortcut; use crossterm::event::{KeyCode, KeyModifiers}; let n = perm.options.len().min(9) as u8; let last_ch = char::from(b'0' + n.max(1)); let last_key = KeyShortcut::new(KeyCode::Char(last_ch), KeyModifiers::NONE); let mut hints = vec![HintItem::paired(key!('1'), last_key, "select")]; if perm.has_adjustable_scope() { hints.push(HintItem::paired(key!(Left), key!(Right), "scope")); } if !perm.description.is_empty() { let label = if perm.args_expanded { "collapse" } else { "expand" }; hints.push(HintItem::new(key!('f', CONTROL), label)); } hints.push(HintItem::new(key!('o', CONTROL), "always-approve")); hints.push(HintItem::new(key!('c', CONTROL), "cancel")); hints } } } else { vec![] }; ShortcutsBar::new(&hints) .with_pending(pending_hint) .render(layout.shortcuts, buf); } else if let Some(ref pav) = self.plan_approval_view { let hints = self.plan_approval_shortcut_hints(pav); if !hints.is_empty() { ShortcutsBar::new(&hints) .with_pending(pending_hint) .render(layout.shortcuts, buf); } } else if self.line_viewer.is_some() && self.is_plan_viewer() { let suppress_shortcuts = self .line_viewer .as_ref() .is_some_and(|v| v.fullscreen && v.list_state.input_mode().is_some()); if !suppress_shortcuts { use crate::views::shortcuts_bar::HintItem; let hints = if self.is_casual_commenting() { vec![ HintItem::new(key!(Enter), "save comment"), HintItem::new(key!(Esc), "cancel"), ] } else { let mut h = vec![ HintItem::new(key!('c'), "comment"), HintItem::new(key!('f', CONTROL), "fullscreen"), ]; if !self.plan_comments.is_empty() { h.push(HintItem::new(key!('s'), "send")); } h.push(HintItem::new(key!(Esc), "close")); h }; ShortcutsBar::new(&hints) .with_pending(pending_hint) .render(layout.shortcuts, buf); } } else if let Some(ref qv) = self.question_view { use crate::views::question_view::QuestionFocus; use crate::views::shortcuts_bar::HintItem; let hints = match qv.focus { QuestionFocus::InputMode => { if self.prompt.file_search_visible() { vec![ HintItem::paired(key!(Up), key!(Down), "nav"), HintItem::new(key!(Tab), "accept"), HintItem::new(key!(Right), "drill"), HintItem::new(key!(Esc), "dismiss"), ] } else { vec![ HintItem::new(key!(Enter), "submit"), HintItem::new(key!(Esc), "back"), ] } } QuestionFocus::Navigation => { vec![ HintItem::new(key!(Esc), "unselect"), HintItem::new(key!(Tab), "scrollback"), HintItem::new(key!('X'), "dismiss"), ] } }; ShortcutsBar::new(&hints).render(layout.shortcuts, buf); } else if self.cancel_turn_view.is_some() { use crate::views::shortcuts_bar::HintItem; let hints = vec![ HintItem::paired(key!('1'), key!('4'), "select"), HintItem::new(key!(Enter), "confirm"), HintItem::new(key!(Esc), "keep running"), HintItem::new(key!(Tab), "scrollback"), ]; ShortcutsBar::new(&hints) .with_pending(pending_hint) .render(layout.shortcuts, buf); } else { let mut hints = self.normal_pane_hints(registry); if in_dashboard_overlay { use crate::views::shortcuts_bar::HintItem; hints.insert( 0, HintItem::new( registry .find(ActionId::DashboardOverlayStop) .map(|def| def.default_key) .unwrap_or(key!('x', CONTROL)), "stop", ), ); hints.insert( 0, HintItem { keys: vec![key!('[', CONTROL), key!(']', CONTROL)], label: "agents".into(), custom_display: Some("Ctrl+[/]"), description: None, pinned: false, }, ); hints.insert(0, HintItem::new(key!('\\', CONTROL), "dashboard")); } let help_hint = registry.find(ActionId::ShortcutsHelp).map(|def| { let mut hint = def.hint(); if in_dashboard_overlay && def.default_key == key!('x', CONTROL) && let Some(alt) = def.alt_keys.first() { hint.keys = vec![*alt]; } hint }); ShortcutsBar::new(&hints) .compact(5, help_hint) .with_pending(pending_hint) .render(layout.shortcuts, buf); } let is_plan_viewer = self.is_plan_viewer(); let has_plan_comments = !self.plan_comments.is_empty(); let casual_commenting = self.is_casual_commenting(); if let Some(ref mut viewer) = self.line_viewer { use crate::views::file_search::line_viewer::render_line_viewer; use crate::views::shortcuts_bar::HintItem; let plan_prompt_focused = self .plan_approval_view .as_ref() .is_some_and(|p| p.focus != PlanApprovalFocus::Preview); let overlay_bottom = if layout.turn_status.height > 0 { layout.turn_status.y } else { layout.prompt.y }; let overlay_area = Rect { x: area.x, y: area.y, width: area.width, height: overlay_bottom.saturating_sub(area.y), }; let approval_comment_count = self .plan_approval_view .as_ref() .map(|pav| pav.comments.len()) .unwrap_or(0); let effective_comment_count = if approval_comment_count > 0 { approval_comment_count } else { self.plan_comments.len() }; if let Some(ref pav) = self.plan_approval_view { viewer.plan_mut().active_commenting_range = pav.commenting_range.clone(); } else { viewer.plan_mut().active_commenting_range = self.casual_commenting_range.clone(); } render_line_viewer( buf, overlay_area, viewer, &self.session.cwd, &theme, effective_comment_count, ); let in_plan_approval = self.plan_approval_view.is_some(); let on_comment = in_plan_approval && viewer .list_state .selected_index() .and_then(|vi| { let pi = viewer.list_state.to_physical(vi); viewer.lines.get(pi) }) .is_some_and(|item| item.comment_id().is_some()); let approval_has_comments = in_plan_approval && self .plan_approval_view .as_ref() .is_some_and(|pav| !pav.comments.is_empty()); let viewer_hints = if in_plan_approval && on_comment { let mut h = vec![ HintItem::new(key!(Enter), "edit"), HintItem::new(key!('x'), "delete"), ]; if approval_has_comments { h.push(HintItem::new(key!('s'), "send")); } else { h.push(HintItem::new(key!('a'), "approve")); } h.push(HintItem::new(key!('q'), "quit plan")); h.push(HintItem::new(key!(Tab), "prompt")); h } else if in_plan_approval { let mut h = vec![HintItem::new(key!('c'), "comment")]; if approval_has_comments { h.push(HintItem::new(key!('s'), "send")); } else { h.push(HintItem::new(key!('a'), "approve")); } h.push(HintItem::new(key!('q'), "quit plan")); if self.vim_mode { h.push(HintItem::paired(key!('j'), key!('k'), "nav")); } h.push(HintItem::new(key!('v'), "select")); h.push(HintItem::new(key!(Tab), "prompt")); h } else if is_plan_viewer { let on_casual_comment = viewer .list_state .selected_index() .and_then(|vi| { let pi = viewer.list_state.to_physical(vi); viewer.lines.get(pi) }) .is_some_and(|item| item.comment_id().is_some()); let mut h = if on_casual_comment { vec![ HintItem::new(key!(Enter), "edit"), HintItem::new(key!('x'), "delete"), ] } else { vec![HintItem::new(key!('c'), "comment")] }; if has_plan_comments { h.push(HintItem::new(key!('s'), "send")); } if self.vim_mode { h.push(HintItem::paired(key!('j'), key!('k'), "nav")); } h.push(HintItem::new(key!('v'), "select")); h.push(HintItem::new(key!('f', CONTROL), "fullscreen")); h.push(HintItem::new(key!('/'), "search")); h.push(HintItem::new(key!(Esc), "close")); h } else { let mut h = vec![HintItem::new(key!(Enter), "confirm")]; if self.vim_mode { h.push(HintItem::paired(key!('j'), key!('k'), "nav")); } h.push(HintItem::new(key!('v'), "select")); h.push(HintItem::new(key!('x'), "clear")); if self.vim_mode { h.push(HintItem::new(key!('y'), "copy")); h.push(HintItem::new(key!('Y'), "filename")); } h.push(HintItem::new(key!(':'), "goto")); h.push(HintItem::new(key!('/'), "search")); h.push(HintItem::new(key!(Esc), "cancel")); h }; let input_bar_active = viewer.list_state.input_mode().is_some(); if !(plan_prompt_focused || casual_commenting || viewer.fullscreen && input_bar_active) { ShortcutsBar::new(&viewer_hints).render(layout.shortcuts, buf); } self.pane_areas = layout.pane_areas(); let viewer_cursor = if plan_prompt_focused || self.is_casual_commenting() { prompt_cursor_pos } else { None }; return (viewer_cursor, prompt_post_flush); } if let Some(ref mut viewer) = self.image_viewer { use crate::terminal::image::{GraphicsProtocol, detect_graphics_protocol}; use crate::views::file_search::line_viewer::dim_area; use crate::views::shortcuts_bar::HintItem; let overlay_area = Rect { x: area.x, y: area.y, width: area.width, height: layout.shortcuts.y.saturating_sub(area.y), }; let popup_width = ((overlay_area.width as u32 * 90) / 100) .max(28) .min(overlay_area.width as u32) as u16; let popup_height = ((overlay_area.height as u32 * 90) / 100) .max(8) .min(overlay_area.height as u32) as u16; let popup_x = overlay_area.x + (overlay_area.width.saturating_sub(popup_width)) / 2; let popup_y = overlay_area.y + (overlay_area.height.saturating_sub(popup_height)) / 2; let popup_rect = Rect::new(popup_x, popup_y, popup_width, popup_height); let mut image_escape_emitted = false; if popup_rect.width >= 10 && popup_rect.height >= 5 { dim_area(buf, overlay_area, theme.bg_base, 0.5); ratatui::widgets::Clear.render(popup_rect, buf); buf.set_style( popup_rect, Style::default().fg(theme.text_primary).bg(theme.bg_base), ); let block = ratatui::widgets::Block::default() .borders(ratatui::widgets::Borders::ALL) .border_type(ratatui::widgets::BorderType::Rounded) .border_style(Style::default().fg(theme.gray_dim)) .style(Style::default().bg(theme.bg_base)); block.render(popup_rect, buf); let inner_cols = popup_rect.width.saturating_sub(2); let inner_rows = popup_rect.height.saturating_sub(2); let title_style = Style::default() .fg(theme.text_primary) .bg(theme.bg_base) .add_modifier(ratatui::style::Modifier::BOLD); let dim_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base); let border_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base); let title_spans: Vec = if viewer.loading { let name = viewer.title.as_deref().unwrap_or("Loading..."); vec![ ratatui::text::Span::styled("\u{2500} ", border_style), ratatui::text::Span::styled(name.to_owned(), title_style), ratatui::text::Span::styled(" \u{2500}", border_style), ] } else { let name = viewer.title.as_deref().unwrap_or(&viewer.mime_type); let dims = format!(" ({}\u{00d7}{})", viewer.image_width, viewer.image_height); vec![ ratatui::text::Span::styled("\u{2500} ", border_style), ratatui::text::Span::styled(name.to_owned(), title_style), ratatui::text::Span::styled(dims, dim_style), ratatui::text::Span::styled(" \u{2500}", border_style), ] }; viewer.modal_state.popup_area = Some(popup_rect); crate::views::modal_window::render_close_button( buf, popup_rect, &mut viewer.modal_state, &theme, ); let close_width = viewer .modal_state .close_button_rect .map_or(0, |r| r.width + 1); let tx = popup_rect.x + 2; let max_title_width = popup_rect.width.saturating_sub(2 + close_width + 1); { use unicode_width::UnicodeWidthStr; let mut cursor: u16 = 0; for span in &title_spans { let w = span.content.width() as u16; if cursor + w > max_title_width { break; } buf.set_span_safe(tx + cursor, popup_rect.y, span, w); cursor += w; } } if viewer.loading { if inner_cols > 0 && inner_rows > 0 { use crate::views::turn_status::SPINNER_DIVISOR; use unicode_width::UnicodeWidthStr; let tick = self.scrollback.animation_tick(); let frames = crate::glyphs::braille_spinner_frames(); let frame = frames[(tick / SPINNER_DIVISOR) as usize % frames.len()]; let loading = format!("{} Loading...", frame); let lw = loading.width() as u16; let lx = popup_rect.x + 1 + inner_cols.saturating_sub(lw) / 2; let ly = popup_rect.y + 1 + inner_rows / 2; buf.set_span_safe( lx, ly, &ratatui::text::Span::styled( loading, Style::default().fg(theme.gray_dim).bg(theme.bg_base), ), lw, ); } } else { let rendered_pixel = crate::terminal::overlay::static_centered( &viewer.display_bytes, viewer.image_width, viewer.image_height, popup_rect, viewer.overlay_owner_id, ) .is_some_and(|esc| { prompt_post_flush = Some(esc.into()); image_escape_emitted = true; true }); if inner_cols > 0 && inner_rows > 0 { let inner_rect = ratatui::layout::Rect::new( popup_rect.x + 1, popup_rect.y + 1, inner_cols, inner_rows, ); if !rendered_pixel { let meta_lines = vec![ ratatui::text::Line::from(""), ratatui::text::Line::from(format!( " {}x{} {}", viewer.image_width, viewer.image_height, viewer.mime_type, )), ratatui::text::Line::from(""), ratatui::text::Line::from(" Press Esc to close"), ]; ratatui::widgets::Paragraph::new(meta_lines) .style(Style::default().fg(theme.gray_dim).bg(theme.bg_base)) .render(inner_rect, buf); } else { let loading = "Loading..."; let lw = loading.len() as u16; let lx = inner_rect.x + inner_cols.saturating_sub(lw) / 2; let ly = inner_rect.y + inner_rows / 2; buf.set_span_safe( lx, ly, &ratatui::text::Span::styled( loading, Style::default().fg(theme.gray_dim).bg(theme.bg_base), ), lw, ); } } } } if !image_escape_emitted && detect_graphics_protocol() == GraphicsProtocol::Kitty { let clear = crate::terminal::overlay::clear_kitty(); prompt_post_flush = Some(clear.into()); } let hints = vec![HintItem::new(key!(Esc), "close")]; ShortcutsBar::new(&hints).render(layout.shortcuts, buf); self.pane_areas = layout.pane_areas(); return (None, prompt_post_flush); } if let Some(ref viewer) = self.video_viewer { use crate::terminal::image::{GraphicsProtocol, detect_graphics_protocol}; use crate::views::shortcuts_bar::HintItem; let overlay_area = Rect { x: area.x, y: area.y, width: area.width, height: layout.shortcuts.y.saturating_sub(area.y), }; let mut video_escape_emitted = false; if let Some(popup_rect) = crate::render::video_overlay::render_video_overlay( buf, overlay_area, viewer, theme.bg_base, theme.text_primary, theme.gray_dim, ) { if let Some(esc) = crate::terminal::overlay::volatile_centered( viewer.current_frame_data(), viewer.video_width, viewer.video_height, popup_rect, ) { prompt_post_flush = Some(esc.into()); video_escape_emitted = true; } if !video_escape_emitted && detect_graphics_protocol() == GraphicsProtocol::Kitty { let clear = crate::terminal::overlay::clear_kitty(); prompt_post_flush = Some(clear.into()); } } let play_label = if viewer.playing { "pause" } else { "play" }; let hints = vec![ HintItem::new(key!(Esc), "close"), HintItem::new(key!(' '), play_label), HintItem::new(key!(Left), "back"), HintItem::new(key!(Right), "fwd"), ]; ShortcutsBar::new(&hints).render(layout.shortcuts, buf); self.pane_areas = layout.pane_areas(); return (None, prompt_post_flush); } if let Some(gboom) = self.gboom.as_mut() { use crate::terminal::image::{GraphicsProtocol, detect_graphics_protocol}; use crate::views::shortcuts_bar::HintItem; let overlay_area = Rect { x: area.x, y: area.y, width: area.width, height: layout.shortcuts.y.saturating_sub(area.y), }; let mut gboom_escape_emitted = false; let hud = gboom.hud(); if let Some(popup_rect) = crate::render::gboom_overlay::render_gboom_overlay( buf, overlay_area, &hud, theme.bg_base, theme.text_primary, theme.gray_dim, ) { gboom.set_mouse_region( popup_rect.x, popup_rect.y, popup_rect.width, popup_rect.height, ); let inner_cols = popup_rect.width.saturating_sub(2); let inner_rows = popup_rect.height.saturating_sub(2); if inner_cols >= 10 && inner_rows >= 4 { let (px_w, px_h) = crate::gboom::GboomState::frame_size_for_cells(inner_cols, inner_rows); if let Some(png) = gboom.frame_png(px_w, px_h) && let Some(esc) = crate::terminal::overlay::volatile_image( png, inner_cols, inner_rows, popup_rect.x + 1, popup_rect.y + 1, ) { prompt_post_flush = Some(esc.into()); gboom_escape_emitted = true; } } } else { gboom.clear_mouse_region(); } if !gboom_escape_emitted && detect_graphics_protocol() == GraphicsProtocol::Kitty { let clear = crate::terminal::overlay::clear_kitty(); prompt_post_flush = Some(clear.into()); } let hints = vec![ HintItem::new(key!(Esc), "quit"), HintItem::new(key!(' '), "fire"), ]; ShortcutsBar::new(&hints).render(layout.shortcuts, buf); self.pane_areas = layout.pane_areas(); return (None, prompt_post_flush); } let block_viewer_toast = self.active_toast_message().map(|s| s.to_string()); if let Some(ref mut viewer) = self.block_viewer { use ratatui::style::Modifier; use ratatui::widgets::{Block as RBlock, BorderType, Borders, Clear, Widget}; let overlay_area = Rect { x: area.x, y: area.y, width: area.width, height: layout.shortcuts.y.saturating_sub(area.y), }; let entry = if viewer.kind == crate::views::block_viewer::ViewerKind::PlainText { Some(crate::scrollback::entry::ScrollbackEntry::new( crate::scrollback::block::RenderBlock::system(String::new()), )) } else { self.scrollback.get_by_id(viewer.entry_id).cloned() }; let Some(entry) = entry else { self.block_viewer = None; self.pane_areas = layout.pane_areas(); return (prompt_cursor_pos, prompt_post_flush); }; let popup_w = ((overlay_area.width as f32 * 0.95) as u16) .max(60) .min(overlay_area.width); let popup_h = ((overlay_area.height as f32 * 0.92) as u16) .max(12) .min(overlay_area.height.saturating_sub(2)); let h_pad = 2u16; let inner_w = popup_w.saturating_sub(2); let content_w = inner_w.saturating_sub(h_pad * 2); if popup_h.saturating_sub(2) < 3 || inner_w < 10 { self.pane_areas = layout.pane_areas(); return (prompt_cursor_pos, prompt_post_flush); } let popup_x = overlay_area.x + (overlay_area.width.saturating_sub(popup_w)) / 2; let popup_y = overlay_area.y + (overlay_area.height.saturating_sub(popup_h)) / 2; let popup_area = Rect::new(popup_x, popup_y, popup_w, popup_h); let base_style = Style::default().fg(theme.text_primary).bg(theme.bg_base); let border = RBlock::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.gray_dim)) .style(base_style); let inner = border.inner(popup_area); if inner.height < 3 || inner.width < 10 { self.pane_areas = layout.pane_areas(); return (prompt_cursor_pos, prompt_post_flush); } crate::views::file_search::line_viewer::dim_area(buf, overlay_area, theme.bg_base, 0.5); Clear.render(popup_area, buf); buf.set_style(popup_area, base_style); border.render(popup_area, buf); let close_chars: &[char] = &['[', 'x', ']']; let close_w_px = close_chars.len() as u16; let close_x = inner.x + inner.width.saturating_sub(close_w_px + 1); let close_y = inner.y; let close_rect = Rect::new(close_x, close_y, close_w_px, 1); for (i, &ch) in close_chars.iter().enumerate() { if let Some(cell) = buf.cell_mut((close_x + i as u16, close_y)) { cell.set_char(ch); let style = if viewer.modal.close_hovered { Style::default() .fg(theme.text_primary) .add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.gray_dim) }; cell.set_style(style); } } viewer.modal.close_button_rect = Some(close_rect); viewer.modal.popup_area = Some(popup_area); let preamble_ctx = crate::scrollback::types::BlockContext { mode: crate::scrollback::types::DisplayMode::Expanded, is_running: entry.is_running, width: content_w, raw: entry.raw, max_lines: None, appearance: appearance.clone(), is_selected: false, cwd: Some(self.session.cwd.clone()), }; let preamble = entry.block.preamble(&preamble_ctx); let mut prepend_lines: Vec> = preamble .as_ref() .map(|text| { text.lines .iter() .flat_map(|line| { crate::render::wrapping::wrap_header_flush( line.clone(), content_w as usize, 0, ) }) .collect() }) .unwrap_or_default(); if !prepend_lines.is_empty() { prepend_lines.push(ratatui::text::Line::from("")); } let content_x = inner.x + h_pad; let content_top = inner.y + 1; let content_height = inner.height.saturating_sub(1); let content_area = Rect { x: content_x, y: content_top, width: content_w, height: content_height, }; viewer.render_content(content_area, buf, &entry, true, &prepend_lines); viewer.render_text_drag_overlay(buf); let has_input_bar = viewer.list_state.input_mode().is_some() || viewer.list_state.matcher().is_some(); let in_visual = viewer.list_state.visual_mode; if (has_input_bar || in_visual) && content_area.height > 2 { let div_y = content_area.y + content_area.height - 2; for x in inner.x..inner.x + inner.width { if let Some(cell) = buf.cell_mut((x, div_y)) { cell.reset(); cell.set_char('\u{2500}'); cell.fg = theme.gray_dim; cell.bg = theme.bg_base; } } if in_visual && !has_input_bar { let status_y = div_y + 1; for x in inner.x..inner.x + inner.width { if let Some(cell) = buf.cell_mut((x, status_y)) { cell.reset(); cell.set_char(' '); cell.fg = theme.text_secondary; cell.bg = theme.bg_base; } } let n = viewer.list_state.copy_range().map(|r| r.len()).unwrap_or(1); let s = if n == 1 { "" } else { "s" }; let status = format!("Selected: {n} line{s}"); let status_style = Style::default().fg(theme.text_secondary).bg(theme.bg_base); buf.set_string(content_x, status_y, &status, status_style); } } if let Some(ref msg) = block_viewer_toast { let toast_text = format!(" {msg} "); let w = toast_text.len() as u16; if popup_area.height > 2 && popup_area.width > w + 2 { let tx = popup_area.right().saturating_sub(w + 2); let ty = popup_area.bottom().saturating_sub(2); for (i, ch) in toast_text.chars().enumerate() { if let Some(cell) = buf.cell_mut((tx + i as u16, ty)) { cell.set_char(ch); cell.fg = theme.accent_user; cell.bg = theme.bg_base; cell.modifier = ratatui::prelude::Modifier::BOLD; } } } } let hints = viewer.shortcuts_hints(); ShortcutsBar::new(&hints).render(layout.shortcuts, buf); self.pane_areas = layout.pane_areas(); return (prompt_cursor_pos, prompt_post_flush); } if let Some(ref mut modal_state) = self.agents_modal { let overlay_area = Rect { x: area.x, y: area.y, width: area.width, height: layout.shortcuts.y.saturating_sub(area.y).saturating_sub(1), }; let compact = self.scrollback.appearance().prompt.compact; let theme = Theme::current(); crate::views::agents_modal::render_agents_modal( buf, overlay_area, modal_state, compact, &theme, ); if let Some(ref mut detail) = self.persona_detail { crate::views::persona_detail::render_persona_detail( buf, overlay_area, detail, &theme, compact, ); } self.pane_areas = layout.pane_areas(); return (None, crate::terminal::overlay::clear().map(Into::into)); } if let Some(ref mut modal_state) = self.extensions_modal { use crate::views::extensions_modal::render_extensions_modal; use crate::views::shortcuts_bar::HintItem; let is_fullscreen = matches!( modal_state.picker_state.mode, crate::views::picker::PickerMode::FullScreen ); let overlay_area = if is_fullscreen { area } else { Rect { x: area.x, y: area.y, width: area.width, height: layout.shortcuts.y.saturating_sub(area.y).saturating_sub(1), } }; let compact = self.scrollback.appearance().prompt.compact; let tick = self.scrollback.animation_tick(); render_extensions_modal( buf, overlay_area, modal_state, Some(layout.shortcuts), compact, tick, ); if modal_state.input.is_some() { let hints = vec![ HintItem::new(key!(Enter), "submit"), HintItem::new(key!(Esc), "cancel"), ]; ShortcutsBar::new(&hints).render(layout.shortcuts, buf); } else if modal_state.pending_action.is_some() { let hints = vec![HintItem::new(key!(Esc), "dismiss")]; ShortcutsBar::new(&hints).render(layout.shortcuts, buf); } else if modal_state.picker_state.search_active { let hints = vec![ HintItem::new(key!(Esc), "clear search"), HintItem::new(key!(Enter), "keep filter"), ]; ShortcutsBar::new(&hints).render(layout.shortcuts, buf); } self.pane_areas = layout.pane_areas(); return (None, crate::terminal::overlay::clear().map(Into::into)); } let dropdown_active = self.slash_dropdown_items_area.is_some() || self.dropdown_items_area.is_some() || self.completion_dropdown_items_area.is_some() || self.history_dropdown_area.is_some(); let placements = if dropdown_active { &[][..] } else { &scrollback_inline_media[..] }; self.inline_media_hits = InlineMediaHitAreas::default(); if !placements.is_empty() { let mut all_escapes = String::new(); let mut this_frame_ids: HashSet = HashSet::new(); for placement in placements { let path = &placement.info.path; if let Some(fp_rect) = placement.filepath_screen_rect { self.inline_media_hits .filepath_areas .push((fp_rect, path.clone())); } if let Some(open_rect) = placement.open_button_screen_rect { self.inline_media_hits .open_buttons .push((open_rect, path.clone())); continue; } if !crate::terminal::image::scrollback_inline_overlay_active() { continue; } if !self.inline_media_cache.contains_key(path) && placement.screen_rect.height >= 1 { let rect = placement.screen_rect; let center_x = |len: usize| rect.x + rect.width.saturating_sub(len as u16) / 2; if placement.info.is_video && !crate::inline_media_ffmpeg::ffmpeg_available() { use crate::inline_media_ffmpeg::{FFMPEG_HINT_TEXT, ffmpeg_install_cmd}; let warn = Style::default().fg(theme.warning); buf.set_string_safe( center_x(FFMPEG_HINT_TEXT.len()), rect.y, FFMPEG_HINT_TEXT, warn, ); if let Some(cmd) = ffmpeg_install_cmd() { let dim = Style::default().fg(theme.gray_dim); buf.set_string_safe(center_x(cmd.len()), rect.y + 1, cmd, dim); } } else { let spinner_frames = crate::glyphs::braille_spinner_frames(); let tick = self.scrollback.current_tick() as usize; let spinner = spinner_frames[tick % spinner_frames.len()]; let label = format!("{spinner} Loading..."); let cy = rect.y + rect.height / 2; buf.set_string_safe( center_x(label.len()), cy, &label, Style::default().fg(theme.gray_dim), ); } } if let Some(esc) = self.build_inline_media_escapes(placement) { all_escapes.push_str(&esc); if let Some(&id) = self.inline_media_ids.get(path) { this_frame_ids.insert(id); } let rect = placement.screen_rect; if !placement.has_button_row { continue; } let button_y = rect.y + rect.height + 1; let sb_area = self.pane_areas.scrollback; let button_visible = button_y >= sb_area.y && button_y < sb_area.y + sb_area.height; if placement.info.is_video { self.inline_media_hits .video_play_areas .push((rect, path.clone())); if button_visible { let is_playing = matches!( self.inline_video, Some(ref vid) if vid.path == * path && ! vid.finished ); let play_label: String = if is_playing { let vid = self.inline_video.as_ref().unwrap(); let dur_s = vid.frames.len() as f64 / vid.fps; let pos_s = vid.current_frame as f64 / vid.fps; format!( "{}:{:02} / {}:{:02}", pos_s as u32 / 60, pos_s as u32 % 60, dur_s as u32 / 60, dur_s as u32 % 60, ) } else { "[Play]".to_string() }; let open_label = "[Open]"; let gap = 3u16; let total = play_label.len() as u16 + gap + open_label.len() as u16; let start_x = rect.x + rect.width.saturating_sub(total) / 2; buf.set_string_safe( start_x, button_y, &play_label, Style::default().fg(theme.gray), ); if !is_playing { self.inline_media_hits.play_buttons.push(( Rect { x: start_x, y: button_y, width: play_label.len() as u16, height: 1, }, path.clone(), )); } let open_x = start_x + play_label.len() as u16 + gap; buf.set_string_safe( open_x, button_y, open_label, Style::default().fg(theme.gray), ); self.inline_media_hits.open_buttons.push(( Rect { x: open_x, y: button_y, width: open_label.len() as u16, height: 1, }, path.clone(), )); } } else { self.inline_media_hits .media_areas .push((rect, path.clone())); if button_visible { let open_label = "[Open]"; let copy_label = "[Copy]"; let gap = 3u16; let total = open_label.len() as u16 + gap + copy_label.len() as u16; let start_x = rect.x + rect.width.saturating_sub(total) / 2; buf.set_string_safe( start_x, button_y, open_label, Style::default().fg(theme.gray), ); self.inline_media_hits.open_buttons.push(( Rect { x: start_x, y: button_y, width: open_label.len() as u16, height: 1, }, path.clone(), )); let copy_x = start_x + open_label.len() as u16 + gap; buf.set_string_safe( copy_x, button_y, copy_label, Style::default().fg(theme.gray), ); self.inline_media_hits.copy_image_buttons.push(( Rect { x: copy_x, y: button_y, width: copy_label.len() as u16, height: 1, }, path.clone(), )); } } } } for &old_id in &self.last_placed_ids { if !this_frame_ids.contains(&old_id) { all_escapes.push_str(&crate::terminal::image::clear_kitty_image(old_id)); self.inline_media_ids.retain(|_, &mut v| v != old_id); self.inline_media_iterm_emitted .retain(|p, _| self.inline_media_ids.contains_key(p)); } } self.last_placed_ids = this_frame_ids; if !all_escapes.is_empty() { self.inline_media_active = true; match prompt_post_flush.as_mut() { Some(existing) => existing.append_plain(&all_escapes), None => { prompt_post_flush = Some(crate::terminal::overlay::PostFlush::plain(all_escapes)); } } } else if self.inline_media_active { self.inline_media_active = false; let mut clear_esc = String::new(); for &id in self.inline_media_ids.values() { clear_esc.push_str(&crate::terminal::image::clear_kitty_image(id)); } self.inline_media_ids.clear(); self.inline_media_iterm_emitted.clear(); self.last_placed_ids.clear(); if !clear_esc.is_empty() { match prompt_post_flush.as_mut() { Some(existing) => existing.append_plain(&clear_esc), None => { prompt_post_flush = Some(crate::terminal::overlay::PostFlush::plain(clear_esc)); } } } } } else if self.inline_media_active { self.inline_media_active = false; self.stop_inline_playback(); let mut clear_esc = String::new(); for &id in self.inline_media_ids.values() { clear_esc.push_str(&crate::terminal::image::clear_kitty_image(id)); } self.inline_media_iterm_emitted.clear(); self.inline_media_ids.clear(); self.last_placed_ids.clear(); if !clear_esc.is_empty() { match prompt_post_flush.as_mut() { Some(existing) => existing.append_plain(&clear_esc), None => { prompt_post_flush = Some(crate::terminal::overlay::PostFlush::plain(clear_esc)); } } } } if !dropdown_active { self.paint_diagram_affordances(buf, scrollback_diagram_affordances, &theme); } if !self.inline_media_active && prompt_post_flush.is_none() && crate::terminal::image::detect_graphics_protocol() != crate::terminal::image::GraphicsProtocol::None { let clear = crate::terminal::overlay::clear_kitty(); prompt_post_flush = Some(clear.into()); } if self.show_goal_detail && let Some(ref goal) = self.goal_state { let todos = self.todo.todos(); let overlay_rect = crate::views::goal_detail::goal_detail_area(area, goal, todos); let tick = self.tasks.tick_count() as usize; let active_subagent_tokens: u64 = self .subagent_sessions .values() .filter(|s| !s.finished) .filter_map(|s| s.tokens_used) .sum(); let close_rect = crate::views::goal_detail::render_goal_detail( buf, overlay_rect, goal, todos, tick, self.context_state.as_ref().map(|c| c.used), active_subagent_tokens, self.hit_goal_close.hovered, ); self.hit_goal_close.rect = close_rect; self.frame_occluder_rects.push(overlay_rect); } self.pane_areas = layout.pane_areas(); { let route = crate::hyperlink_route::hyperlink_route(); if route.emit_osc8 { let occluders = &self.frame_occluder_rects; let emit_id = route.emit_id; *link_spans_out = self .last_link_overlay .links() .iter() .filter(|link| { !occluders.iter().any(|r| { link.screen_row >= r.y && link.screen_row < r.y.saturating_add(r.height) && link.col_start < r.x.saturating_add(r.width) && r.x < link.col_end }) }) .map(|link| kigi_ratatui_inline::LinkSpan { row: link.screen_row, col_start: link.col_start, col_end: link.col_end, url: link.url.clone(), id: if emit_id { link.id } else { None }, }) .collect(); } } let on_link = self.hovered_link_idx.is_some(); if supports_osc22() && on_link != self.last_pointer_on_link { self.last_pointer_on_link = on_link; use crossterm::Command; let mut seq = String::new(); if on_link { let _ = crate::terminal::SetPointerCursor.write_ansi(&mut seq); } else { let _ = crate::terminal::SetDefaultCursor.write_ansi(&mut seq); } match prompt_post_flush { Some(ref mut existing) => existing.append_plain(&seq), None => { prompt_post_flush = Some(crate::terminal::overlay::PostFlush::plain(seq)); } } } let cursor = if self.inline_edit.is_some() { inline_edit_cursor } else { prompt_cursor_pos }; (cursor, prompt_post_flush) } } #[cfg(test)] mod selection_state_tests { use super::super::test_fixtures::make_agent; use super::*; use crate::scrollback::text_selection::ResolvedSelectableLine; use crate::scrollback::types::SelectionBoundary; use std::sync::Arc; #[test] fn frame_reset_clears_model_and_companion_together() { let mut model = ResolvedSelectionModel::default(); let line = ResolvedSelectableLine { entry_idx: 0, range_id: 0, block_line_idx: 0, screen_y: 0, screen_x: 0, selectable_cols: 0..3, text: "foo".to_string(), joiner_to_previous: None, }; let mut boundaries = ResolvedSelectionBoundaries::default(); boundaries.push( &line, Arc::new(SelectionBoundary::new(" ".to_string(), String::new())), ); model.push_line(line); let mut agent = make_agent(); agent.update_scrollback_selection_state(model, boundaries); agent.clear_scrollback_selection_state(); assert!(agent.last_scrollback_selection_model.ranges.is_empty()); assert!(agent.last_scrollback_selection_boundaries.is_empty()); } } #[cfg(test)] mod overlay_post_flush_tests { use super::super::test_fixtures::make_agent; use crate::actions::ActionRegistry; use crate::app::bundle::BundleState; use crate::scrollback::render::ScratchBuffer; use ratatui::buffer::Buffer; use ratatui::layout::Rect; fn draw(agent: &mut super::AgentView) -> Option { let area = Rect::new(0, 0, 100, 40); let mut buf = Buffer::empty(area); let mut scratch = ScratchBuffer::new(); agent .draw( area, &mut buf, &ActionRegistry::defaults(), &mut scratch, None, false, 0, None, &BundleState::default(), false, &mut Vec::new(), ) .1 } fn seed_static_owner(owner_id: u64) { let _ = crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, owner_id) .unwrap() .commit(); } fn png() -> [u8; 8] { [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'] } #[test] fn fullscreen_subagent_propagates_child_clear_to_emitter() { let _guard = crate::terminal::image::set_protocol_for_test( crate::terminal::image::GraphicsProtocol::Kitty, ); crate::terminal::overlay::reset_owner(); seed_static_owner(41); let mut parent = make_agent(); parent .subagent_views .insert("child".into(), Box::new(make_agent())); parent.active_subagent = Some("child".into()); let post_flush = draw(&mut parent).expect("child clear propagates"); assert!(post_flush.as_str().contains("a=d")); let before_emit = crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, 41).unwrap(); assert!(!before_emit.as_str().contains("a=t")); post_flush.write_to(&mut Vec::new()).unwrap(); let after_emit = crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, 41).unwrap(); assert!(after_emit.as_str().contains("a=t")); } #[test] fn active_modal_returns_clear_without_committing_discarded_state() { let _guard = crate::terminal::image::set_protocol_for_test( crate::terminal::image::GraphicsProtocol::Kitty, ); crate::terminal::overlay::reset_owner(); seed_static_owner(42); let mut agent = make_agent(); agent.active_modal = Some(crate::views::modal::ActiveModal::CommandPalette { entries: Vec::new(), state: crate::views::picker::PickerState::default(), window: crate::views::modal_window::ModalWindowState::new(), }); let post_flush = draw(&mut agent).expect("modal clear returned"); assert!(post_flush.as_str().contains("a=d")); let before_emit = crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, 42).unwrap(); assert!(!before_emit.as_str().contains("a=t")); post_flush.write_to(&mut Vec::new()).unwrap(); let after_emit = crate::terminal::overlay::static_image(&png(), 20, 10, 0, 0, 42).unwrap(); assert!(after_emit.as_str().contains("a=t")); } }