//! Permission view state and helpers. //! //! When the agent requests a permission (bash, edit, MCP tool, etc.), the pager //! takes over the prompt area and shows a structured permission UI. This module //! contains: //! //! - [`PermissionViewState`] — per-request state for the permission overlay //! - [`PermissionFocus`] — options vs followup-input mode //! //! The pager maintains a `VecDeque` on [`AgentView`]. //! Only the **front** request is rendered and interactive — subsequent requests //! wait in the queue. This matches the TUI's `VecDeque` //! queueing semantics and prevents cancellation of older requests when newer //! ones arrive. //! //! No rendering or input handling here — this is pure data and helpers. use agent_client_protocol as acp; use kigi_workspace::permission::bash_command_splitting::{ BashCommandHighlights, heredoc_payload_byte_ranges, range_fully_inside, soft_break_offsets_after_operators, split_physical_line_at_soft_breaks, }; use kigi_workspace::permission::{ BashCommandPermission, McpToolPermission, mcp_titleize_segment, mcp_tool_action, mcp_tool_display_name, }; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthStr; use crate::theme::Theme; // ── Enums ────────────────────────────────────────────────────────────── /// Interaction mode for the permission overlay. /// /// Mirrors [`QuestionFocus`](crate::views::question_view::QuestionFocus) from /// `question_view.rs`. Even though `PromptWidget` owns the text editing state, /// the permission overlay needs its own mode enum so that input routing, /// rendering, and Esc behavior have a single source of truth. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PermissionFocus { /// Cursor is on an option row. j/k navigate, Enter or 1-N select /// the option at that 1-based index. /// Left/Right (or `<`/`>`) expand/contract bash selection and jump the /// cursor to AllowAlways unless it already sits on a scoped /// (AllowAlways/RejectAlways) row. Ctrl-C cancels. Options, /// User is typing a followup message in the PromptWidget. /// Entered by pressing Enter on the RejectOnce option (or `x` shortcut). /// Esc exits back to Options (prompt text is preserved). /// Enter submits the followup message. FollowupInput, } /// Currently selected scope for an MCP "Always allow" prompt. /// /// `Tool` whitelists exactly the named tool (smaller blast radius and the /// default). `Server` whitelists every tool whose name starts with /// `__`, and is only reachable when the tool name actually has a /// `__` separator (i.e. `McpScopeState::server_prefix.is_some()`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpScope { Tool, Server, } /// Per-prompt MCP scope toggle state. Populated by `acp_handler` when the /// request meta deserializes as [`McpToolPermission`]; `None` for non-MCP /// prompts. The pager flips `selected` in response to ← / → arrow keys. #[derive(Debug, Clone)] pub struct McpScopeState { /// Full tool name (e.g. `"linear__list_issues"`). pub tool_name: String, /// Server segment (everything before the single `__` separator). /// `None` when the tool name has no `__`; the toggle is hidden in /// that case and only tool-scope is offered. pub server_prefix: Option, /// Currently selected scope. Defaults to `Tool` on prompt entry. pub selected: McpScope, } impl McpScopeState { /// Action segment of the qualified tool name. See /// [`mcp_tool_action`]. pub fn action(&self) -> &str { mcp_tool_action(&self.tool_name, self.server_prefix.as_deref()) } /// User-facing tool label. See [`mcp_tool_display_name`]. pub fn display_name(&self) -> String { mcp_tool_display_name(&self.tool_name, self.server_prefix.as_deref()) } } // ── State ────────────────────────────────────────────────────────────── /// A queued permission request awaiting user response. /// /// The pager maintains a `VecDeque` of these on `AgentView`. Only the front /// request is rendered and interactive — subsequent requests wait in the queue. /// /// Not `Clone` because it owns the `response_tx` oneshot sender via /// `kigi_acp_lib::AcpArgs`. pub struct PermissionViewState { /// The ACP permission request args (holds `response_tx` for sending /// the response back to the shell). pub request: kigi_acp_lib::AcpArgs, /// Unique ID for this request (monotonic counter, same as TUI's /// `perm_req_id`). Used to guard against stale resolution attempts. pub id: usize, // -- Interaction mode -- /// Current focus mode. Determines input routing and rendering. pub focus: PermissionFocus, // -- Options -- /// All permission options from the request (cloned from /// `request.options` so the request can be moved into the struct). pub options: Vec, /// Currently focused option index (only meaningful for the front request). pub active_idx: usize, // -- Bash command selection -- /// Parsed bash highlights from request meta (None for non-bash /// permissions). Imported from `kigi-shell`, NOT duplicated locally. pub bash_highlights: Option, /// How many highlighted words are currently selected (1-indexed). /// Starts at `default_always_allow_scope(highlighted_words)`: the safe /// prefix for safe-listed commands, else first two words plus flags. /// Right-arrow (or `>`) expands, Left-arrow (or `<`) contracts, minimum 1. pub bash_selection_count: usize, /// Raw bash command string for display when `bash_highlights` is `None` /// (complex commands that tree-sitter cannot decompose). pub bash_command_raw: Option, // -- MCP scope selection -- /// MCP scope toggle state. `None` for non-MCP prompts. Populated when the /// request carries an `allow-always-mcp` option whose meta deserializes /// as `McpToolPermission`. Mutually exclusive with the bash flow at the /// per-request level. pub mcp_scope: Option, // -- Display content (precomputed on creation) -- /// Title text (e.g. agent-provided bash description, or "Allow Edit?"). pub title: String, /// Planned tool-input lines shown under the title — for MCP tools the /// pretty-printed JSON arguments the call would send (built by /// `acp_handler::build_permission_display`). Empty for bash/edit /// prompts, which have dedicated displays. pub description: Vec, /// Whether the planned-args display is expanded (Ctrl-F toggle). /// Collapsed caps the args at [`MCP_ARGS_COLLAPSED_ROWS`] rows with a /// `... Ctrl-F to expand` indicator. pub args_expanded: bool, /// Scroll offset for description area. pub desc_scroll: u16, // -- Subagent provenance -- /// If this permission was requested by a subagent, its descriptive label. /// Derived from matching `request.session_id` against known subagent /// sessions. Displayed as a provenance line above the title. pub subagent_label: Option, // -- Prompt stash (queue-level, not per-request) -- // NOTE: prompt stash is NOT on PermissionViewState. // It lives on AgentView as `permission_stashed_prompt`. // See the "Queue-level prompt stashing" section in the plan. // -- Layout cache -- /// Cached options area height (for scroll calculations). pub options_area_height: usize, /// Scroll offset for options list (when there are more options than /// fit in the visible area). pub options_scroll_offset: usize, } impl PermissionViewState { /// Whether the scope selector (← → arrows) is meaningful for this prompt. /// /// True when: /// - bash: there are 2+ highlighted words to expand/contract between, OR /// - MCP: the tool name has a `__` separator, so server-scope is reachable. pub fn has_adjustable_scope(&self) -> bool { self.bash_highlights .as_ref() .is_some_and(|h| h.highlighted_words.len() > 1) || self .mcp_scope .as_ref() .is_some_and(|s| s.server_prefix.is_some()) } } /// 1-based shortcut character for the given 0-based option index. /// Returns `' '` for indices >= 9 (we never expect that many options). fn shortcut_char(index: usize) -> char { if index < 9 { char::from(b'1' + index as u8) } else { ' ' } } /// Pre-formatted shortcut labels to avoid per-frame `format!` allocation. const SHORTCUT_LABELS: [&str; 10] = [" ", "1 ", "2 ", "3 ", "4 ", "5 ", "6 ", "7 ", "8 ", "9 "]; fn shortcut_label(index: usize) -> &'static str { SHORTCUT_LABELS .get(index + 1) .copied() .unwrap_or(SHORTCUT_LABELS[0]) } // ── Subagent tracking ────────────────────────────────────────────────── // SubagentInfo lives in app::subagent — re-export for backward compat. pub use crate::app::subagent::SubagentInfo; // ── Height calculation ───────────────────────────────────────────────── /// Chrome height for the permission view as actually rendered. /// /// Public version for mouse hit-testing in agent_view. Takes `area_h` /// so the returned value matches the rendering: when the area is too /// small for all bash lines, they get clipped, and options start earlier. pub fn permission_chrome_height_pub( state: &PermissionViewState, content_w: usize, area_h: u16, ) -> u16 { let uncapped = permission_chrome_height(state, content_w); // The rendering draws chrome then options sequentially, clipping at // area_h. So options start at min(uncapped_chrome, area_h - options - vpad_bottom). let options_and_pad = state.options.len() as u16 + 1; let max_chrome = area_h.saturating_sub(options_and_pad); uncapped.min(max_chrome) } /// Chrome height for the permission view (provenance + title + bash command /// + planned MCP arguments + inline scope hint + gap). /// /// Returns the uncapped chrome height. The caller is responsible for /// applying a height cap to the overall permission view. fn permission_chrome_height(state: &PermissionViewState, content_w: usize) -> u16 { let bash_line_count = bash_display_line_count(state, content_w) as u16; let mut h: u16 = 1; // vpad top if state.subagent_label.is_some() { h += 1; // provenance line } h += 1; // title line h += bash_line_count; // Planned MCP arguments: same `mcp_args_visible_rows` budget as the // render. Clamp before the cast (`as u16` wraps) and saturate the adds // so a pathological count can't overflow-panic in debug builds. let (args_rows, indicator) = mcp_args_visible_rows(state, content_w); let args_rows = args_rows .saturating_add(indicator as usize) .min(u16::MAX as usize) as u16; h = h.saturating_add(args_rows); // Inline "← → choose permission scope" hint when there are highlighted // words the user can narrow. Must match the render condition exactly. if state.has_adjustable_scope() { h = h.saturating_add(1); } h.saturating_add(1) // gap before options } /// Compute the total height the permission view should occupy. /// /// Caps at 50% of screen height (min 10, max 80%). The minimum ensures /// at least a couple of bash command lines are visible alongside the /// option rows. An expanded planned-args display (Ctrl-F) lifts the cap /// to the full screen height. pub fn permission_view_height(state: &PermissionViewState, screen_h: u16, content_w: usize) -> u16 { let chrome_h = permission_chrome_height(state, content_w); let options_h = state.options.len() as u16; let vpad_bottom: u16 = 1; let total = chrome_h .saturating_add(options_h) .saturating_add(vpad_bottom); if state.args_expanded { return total.min(screen_h); } let cap = (screen_h as u32 / 2) .max(10) .min(screen_h as u32 * 80 / 100) as u16; total.min(cap) } /// Collapsed row budget for the planned-args display, matching the /// question tool's `DEFAULT_MAX_CHROME_DESC_LINES`. When truncated, the /// last budgeted row is the `... Ctrl-F to expand` indicator. pub const MCP_ARGS_COLLAPSED_ROWS: usize = 5; /// Rows the planned-args display occupies: `(content_rows, show_indicator)`. /// /// The one row-budget source shared by chrome height, render, and mouse /// hit-testing. Counts plain text (no syntect on the hit-test path); /// highlighting preserves text, so the styled render wraps identically. /// The budget (>= 2) always fits content rows plus the indicator. fn mcp_args_visible_rows(state: &PermissionViewState, content_w: usize) -> (usize, bool) { let total: usize = state .description .iter() .map(|raw| char_wrap_row_count(raw, content_w)) .sum(); if !state.args_expanded && total > MCP_ARGS_COLLAPSED_ROWS { (MCP_ARGS_COLLAPSED_ROWS - 1, true) } else { (total, false) } } /// Row count [`char_wrap`] would produce, without allocating the chunks. /// Same break arithmetic; called per frame from the height/hit-test paths. fn char_wrap_row_count(s: &str, width: usize) -> usize { let width = width.max(1); let mut rows = 1usize; let mut cur_w = 0usize; let mut cur_empty = true; for ch in s.chars() { let ch_w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); if cur_w + ch_w > width && !cur_empty { rows += 1; cur_w = 0; } cur_w += ch_w; // The breaking char starts the new row, as in `char_wrap`. cur_empty = false; } rows } /// Number of wrapped lines the bash/MCP-name display needs (uncapped). fn bash_display_line_count(state: &PermissionViewState, content_w: usize) -> usize { if state.bash_highlights.is_some() || state.bash_command_raw.is_some() { build_permission_bash_lines( state.bash_highlights.as_ref(), state.bash_selection_count, state.bash_command_raw.as_deref(), content_w, ) .len() } else if let Some(ref scope) = state.mcp_scope { // MCP scope renders as a single line. Themes may differ, but we // know it doesn't wrap because we elide width separately. let _ = content_w; let _ = scope; 1 } else { 0 } } // ── Rendering ────────────────────────────────────────────────────────── fn hovered_bg(theme: &Theme) -> ratatui::style::Color { theme.bg_hover } /// Result from rendering the permission view, telling the caller where /// to render the inline prompt widget (if in FollowupInput mode). pub struct PermissionRenderResult { /// When in FollowupInput mode, the Y position and content_x/width /// where the inline prompt should be rendered (after the prefix). /// `None` when in Options mode (no inline prompt needed). pub inline_prompt: Option, } /// Layout info for the inline followup prompt. pub struct InlinePromptArea { /// X position for the prompt widget text (after "x [x] ❯ " prefix). pub text_x: u16, /// Y position of the row. pub y: u16, /// Width available for the prompt widget text. pub text_w: u16, /// X position of the content area (for prefix rendering). pub content_x: u16, /// Full width of the content area. pub content_w: u16, } /// Width available for inline prompt text given the full area width. /// /// Subtracts left padding (accent col + 2 = 3) and the followup prefix /// (`" (●) ❯ "` = 8 chars). Matches the `text_w` computed during /// rendering so `desired_height` wraps at the same width as the draw area. pub fn inline_text_width(area_width: u16) -> u16 { const LEFT_PAD: u16 = 3; // accent column + 2 padding const PREFIX_W: u16 = 8; // "x (●) ❯ " = 2 + 4 + 2 area_width.saturating_sub(LEFT_PAD + PREFIX_W) } /// Render the complete permission view into the given area. /// /// Mirrors `render_question_view`: bg_light background, accent `┃` line, /// chrome header (provenance + title + bash command), option rows with /// cursor/hover highlighting, shortcut labels. /// /// In FollowupInput mode, the RejectOnce static row is skipped and the /// returned `PermissionRenderResult` tells the caller where to render the /// inline prompt widget (matching Q/A panel's InputMode pattern). pub fn render_permission_view( buf: &mut Buffer, area: Rect, state: &PermissionViewState, followup_text: &str, hovered_item: Option, theme: &Theme, focused: bool, ) -> PermissionRenderResult { if area.height == 0 || area.width == 0 { return PermissionRenderResult { inline_prompt: None, }; } let is_followup = state.focus == PermissionFocus::FollowupInput; // Fill background — same as the focused prompt (bg_light). let bg = Style::default().bg(theme.bg_light); buf.set_style(area, bg); // Accent line ┃ on the left column — blue to match the shortcut key color. let accent_style = Style::default().fg(theme.accent_user); for row in area.y..area.y + area.height { if let Some(cell) = buf.cell_mut((area.x, row)) { cell.set_symbol(crate::glyphs::accent_bar()); // ┃ cell.set_style(accent_style); } } // Content area (left: accent + 2-char pad, right: 2-char pad) let content_x = area.x + 3; let content_width = area.width.saturating_sub(5); let mut y = area.y; // Vertical padding at the top. y += 1; // ── Chrome header ── // Bottom of the drawable area. The chrome rows below are written at // increasing `y`; when the overlay is squeezed into a 1-2 row area at the // bottom of a short terminal they must not write past it (ratatui's // set_line panics on an out-of-bounds row). let area_bottom = area.y + area.height; // Subagent provenance line (if present). if let Some(ref label) = state.subagent_label { if y < area_bottom { let prov_style = Style::default().fg(theme.gray); buf.set_line( content_x, y, &Line::from(Span::styled(label.clone(), prov_style)), content_width, ); } y += 1; } // Title (bold, accent color) — e.g. bash tool description or "Allow Edit?" if y < area_bottom { let title_style = Style::default() .fg(theme.text_primary) .add_modifier(Modifier::BOLD); buf.set_line( content_x, y, &Line::from(Span::styled(state.title.clone(), title_style)), content_width, ); } y += 1; // Bash command / MCP tool name display: syntax-highlighted and // carefully soft-wrapped. Cap the number of lines so the option rows // always remain visible. Reserve: gap(1) + options + vpad_bottom(1). let mut bash_lines: Vec> = if state.bash_highlights.is_some() || state.bash_command_raw.is_some() { build_permission_bash_lines( state.bash_highlights.as_ref(), state.bash_selection_count, state.bash_command_raw.as_deref(), content_width as usize, ) } else if let Some(ref scope) = state.mcp_scope { build_mcp_scope_lines(scope, theme, content_width as usize) } else { Vec::new() }; // Planned MCP arguments, appended to the same vec so the options // visibility cap and trailing ellipsis apply. The row budget is shared // with `permission_chrome_height` via `mcp_args_visible_rows`. { let (args_rows, indicator) = mcp_args_visible_rows(state, content_width as usize); bash_lines.extend(build_mcp_args_lines( &state.description, theme, content_width as usize, args_rows, )); if indicator { bash_lines.push(truncation_indicator_line(theme)); } } let show_scope_hint = state.has_adjustable_scope(); let scope_hint_h: u16 = if show_scope_hint { 1 } else { 0 }; let options_reserve = scope_hint_h + 1 + state.options.len() as u16 + 1; let max_bash_y = (area.y + area.height).saturating_sub(options_reserve); let mut last_drawn_bash: Option = None; for (li, bash_line) in bash_lines.iter().enumerate() { if y >= max_bash_y { break; } buf.set_line(content_x, y, bash_line, content_width); last_drawn_bash = Some(li); y += 1; } if let Some(last_idx) = last_drawn_bash && last_idx + 1 < bash_lines.len() { let text_w = bash_lines[last_idx].width() as u16; let ellipsis_x = content_x + text_w.min(content_width.saturating_sub(2)); let ellipsis_style = Style::default().fg(theme.gray); buf.set_span( ellipsis_x, y - 1, &Span::styled(" \u{2026}", ellipsis_style), 2, ); } if show_scope_hint && y < area.y + area.height { // Readable secondary text, arrows highlighted in accent for // scannability. Previously used `theme.gray` + `Modifier::DIM`, // which was unreadable on several theme backgrounds. let hint_style = Style::default() .fg(theme.text_secondary) .add_modifier(Modifier::DIM); let hint_line = Line::from(vec![ Span::styled("Use ", hint_style), Span::styled("\u{2190} \u{2192}", hint_style), Span::styled(" to choose permission scope", hint_style), ]); buf.set_line(content_x, y, &hint_line, content_width); y += 1; } // Gap before options. y += 1; // ── Option rows ── let visible_bottom = area.y + area.height; let hover_bg = hovered_bg(theme); // Precompute the selected words string for dynamic labels. let selected_words: Option = state .bash_highlights .as_ref() .map(|h| h.highlighted_words[..state.bash_selection_count].join(" ")); let mut inline_prompt_result: Option = None; for (i, option) in state.options.iter().enumerate() { if y >= visible_bottom { break; } // In FollowupInput mode, skip the RejectOnce static row — // the caller will render the inline prompt widget at this position. if is_followup && option.kind == acp::PermissionOptionKind::RejectOnce { let row_bg = theme.bg_visual; // always focused bg for the input row // Fill the FULL row width including padding between accent ┃ and content. let full_row = Rect { x: area.x + 1, // after the accent symbol y, width: area.width.saturating_sub(1), height: 1, }; buf.set_style(full_row, Style::default().bg(row_bg)); // Re-draw accent ┃ with the row bg so it blends. if let Some(cell) = buf.cell_mut((area.x, y)) { cell.set_symbol(crate::glyphs::accent_bar()); cell.set_style(Style::default().fg(theme.accent_user).bg(row_bg)); } // Render the " (●) ❯ " prefix manually (same as Q/A panel). // Use the 1-based option index so the shortcut number shown // here matches what the user types to invoke RejectOnce. 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(Modifier::BOLD); let prompt_ind = Style::default().fg(theme.accent_user).bg(row_bg); buf.set_span(content_x, y, &Span::styled(shortcut_label(i), num_style), 2); buf.set_span( content_x + 2, y, &Span::styled(format!("({}) ", crate::glyphs::filled_dot()), marker_style), 4, ); buf.set_span( content_x + 6, y, &Span::styled(crate::glyphs::prompt_arrow(), prompt_ind), 2, ); // Tell the caller where to render the prompt widget text. // Use full width to the right edge (not the 2-col-padded content_width) // so the scrollbar sits flush against the border — matching Q/A panel. let prefix_w: u16 = 8; // "x (●) ❯ " = 2 + 4 + 2 = 8 let full_w = area.width.saturating_sub(3); // only left padding (accent + 2) inline_prompt_result = Some(InlinePromptArea { text_x: content_x + prefix_w, y, text_w: full_w.saturating_sub(prefix_w), content_x, content_w: full_w, }); y += 1; continue; } let is_cursor = i == state.active_idx; let is_hovered = hovered_item == Some(i); // When the panel is unfocused, drop the cursor-row bg so it // reads as "no active selection" — same rule as question_view. let row_bg = if is_cursor && focused { theme.bg_visual } else if is_hovered { hover_bg } else { theme.bg_light }; let line = build_permission_option_line( option, i, is_cursor, row_bg, selected_words.as_deref(), state.mcp_scope.as_ref(), followup_text, content_width, theme, ); let row_rect = Rect { x: content_x, y, width: content_width, height: 1, }; buf.set_style(row_rect, Style::default().bg(row_bg)); buf.set_line(content_x, y, &line, content_width); y += 1; } // Unfocus dim: when the prompt area is unfocused (e.g. user moved // to scrollback), blend foregrounds toward `bg_light` so the panel // visually recedes. Mirrors the unfocused prompt widget pattern // (`prompt_widget.rs:1948`) and `render_question_view`. if !focused { crate::render::color::blend_area(buf, area, Some((theme.bg_light, 0.66)), None); } PermissionRenderResult { inline_prompt: inline_prompt_result, } } /// Wrap + syntax-highlight a bash command the same way the permission /// overlay body does: preserve source newlines / `\` continuations, keep /// heredoc bodies intact, quote-aware width wrap only — **no** soft-breaks /// at `&&` / `||` / `|` / `;` (those made one command look like multiple /// prompts once the full command is shown in the overlay). /// /// Used by the execute tool-call header so scrollback matches the overlay. pub(crate) fn render_bash_command_display_lines( command: &str, content_width: usize, ) -> Vec> { build_raw_bash_lines(command, content_width) } /// Build the permission-overlay bash command display. /// /// Prefers the original `raw` command string so spacing, newlines, and /// trailing-`\` line continuations survive. Falls back to re-joining /// highlight tokens only when raw is missing. Soft-wraps only when a /// physical line exceeds `content_width`, preferring breaks at shell /// operators (`&&`, `||`, `|`, `;`) over every whitespace boundary. fn build_permission_bash_lines( highlights: Option<&BashCommandHighlights>, selection_count: usize, raw: Option<&str>, content_width: usize, ) -> Vec> { let display = display_command_text(highlights, raw); if display.is_empty() { return Vec::new(); } let needs_dim = highlights.is_some_and(|h| { selection_count < h.highlighted_words.len() || !h.prefix.is_empty() || !h.suffix.is_empty() }); if needs_dim && let Some(h) = highlights { if let Some(ranges) = map_selection_ranges(&display, h, selection_count) { return build_bash_lines_with_selection(&display, &ranges, content_width); } // Mapping onto the raw string failed (token order/shape mismatch). // Do **not** fall through to a fully-bright raw render — that would // drop the selection scope cue. Reconstruct from highlight tokens // and dim by token index instead. return build_bash_lines_from_highlight_tokens(h, selection_count, content_width); } build_raw_bash_lines(&display, content_width) } /// Fallback dim path when raw-string token mapping fails: join highlight /// tokens with spaces and dim by token selection, then reuse the shared wrap. fn build_bash_lines_from_highlight_tokens( h: &BashCommandHighlights, selection_count: usize, content_width: usize, ) -> Vec> { let mut full = String::new(); let mut ranges: Vec<(usize, usize, bool)> = Vec::new(); let mut push = |text: &str, selected: bool| { if text.is_empty() { return; } if !full.is_empty() { full.push(' '); } let start = full.len(); full.push_str(text); ranges.push((start, full.len(), selected)); }; for t in &h.prefix { push(t, false); } for (i, t) in h.highlighted_words.iter().enumerate() { push(t, i < selection_count); } for t in &h.suffix { push(t, false); } if full.is_empty() { return Vec::new(); } build_bash_lines_with_selection(&full, &ranges, content_width) } /// Choose the best source text for the bash display. /// /// Raw wins when present (preserves author formatting). Otherwise reconstruct /// from highlight tokens with single spaces — last resort only. fn display_command_text(highlights: Option<&BashCommandHighlights>, raw: Option<&str>) -> String { if let Some(raw) = raw { let prepared = prepare_bash_display_text(raw); if !prepared.is_empty() { return prepared; } } if let Some(h) = highlights { return reconstruct_from_highlights(h); } String::new() } /// Normalize command text for display without destroying structure. /// /// - Unifies line endings to `\n` /// - Trims trailing whitespace per physical line (keeps indent) /// - **Preserves** intentional newlines, including lines that end in `\` /// (shell line continuations like `cmd \\\n --flag`) fn prepare_bash_display_text(command: &str) -> String { let normalized = command.replace("\r\n", "\n").replace('\r', "\n"); let mut out = String::with_capacity(normalized.len()); for (i, line) in normalized.split('\n').enumerate() { if i > 0 { out.push('\n'); } out.push_str(line.trim_end()); } // Drop trailing blank lines (common when scripts end with `\n`) // but keep interior blank lines. while out.ends_with('\n') { let without = &out[..out.len() - 1]; if without.ends_with('\\') { // Dangling `\` continuation at EOF: keep the backslash visible on // the last line but drop the now-useless trailing newline, which // would otherwise render as a stray empty row in the overlay. out.pop(); break; } if without.is_empty() || without.ends_with('\n') { out.pop(); continue; } // Single trailing newline after content — drop it. out.pop(); break; } out } /// Last-resort display when we only have tokenized highlights (no raw script). fn reconstruct_from_highlights(h: &BashCommandHighlights) -> String { let mut parts: Vec<&str> = Vec::with_capacity(h.prefix.len() + h.highlighted_words.len() + h.suffix.len()); parts.extend(h.prefix.iter().map(String::as_str)); parts.extend(h.highlighted_words.iter().map(String::as_str)); parts.extend(h.suffix.iter().map(String::as_str)); parts.join(" ") } /// Soft-wrap one physical line using tree-sitter-derived break offsets from /// the full script. Breaks only at real list/pipeline operators (never /// `&&` inside heredocs, quotes, or comments). /// /// Continuation rows after an operator soft-break **strip leading whitespace** /// so `&&` / `|` do not leave a dangling space on the next display line. /// Overlong segments that still exceed width use quote-aware wrapping (do not /// break on spaces inside `'...'` / `"..."`), so e.g. `jq '.[] | ...'` stays /// intact instead of wrapping at the `|`. fn soft_wrap_physical_line( line: &str, line_start: usize, full_breaks: &[usize], heredoc_payload: &[(usize, usize)], content_width: usize, ) -> Vec> { highlight_rows(soft_wrap_row_texts( line, line_start, full_breaks, heredoc_payload, content_width, )) } /// Compute the display row string slices for one physical `line`, applying the /// same operator-aware + quote-aware wrapping as [`soft_wrap_physical_line`] /// but **without** highlighting. Every returned slice is a sub-slice of `line` /// (so a caller can recover its byte offset via pointer arithmetic), which lets /// the selection-dimming path reuse the identical wrapping decisions. fn soft_wrap_row_texts<'a>( line: &'a str, line_start: usize, full_breaks: &[usize], heredoc_payload: &[(usize, usize)], content_width: usize, ) -> Vec<&'a str> { if content_width == 0 { return vec![line]; } if UnicodeWidthStr::width(line) <= content_width { return vec![line]; } // Heredoc body/content is free-form payload, not shell syntax — do not // soft-wrap at spaces. Keep the physical line intact even if it overflows. let line_end = line_start + line.len(); if range_fully_inside(line_start, line_end, heredoc_payload) { return vec![line]; } let chunks = split_physical_line_at_soft_breaks(line, line_start, full_breaks); // No real operators on this line (or parse found none) — quote-aware wrap. if chunks.len() <= 1 { return bash_quote_aware_wrap(line, content_width); } // Pack contiguous partitions of `line` into rows that fit the width. // Chunks are contiguous slices, so a packed row is just line[start..end]. let mut chunk_starts: Vec = Vec::with_capacity(chunks.len()); { let mut cursor = 0usize; for chunk in &chunks { debug_assert_eq!(&line[cursor..cursor + chunk.len()], *chunk); chunk_starts.push(cursor); cursor += chunk.len(); } } // Row specs as (start, end) into `line`, then trim for display. let mut row_ranges: Vec<(usize, usize)> = Vec::new(); let mut i = 0usize; while i < chunks.len() { // Skip leading whitespace when *starting* a continuation after a // previous row — that space belonged between operator and next cmd. let mut start = chunk_starts[i]; if !row_ranges.is_empty() { while start < line.len() && line.as_bytes()[start].is_ascii_whitespace() { start += 1; } // Advance i if we skipped entire leading chunks of whitespace. while i < chunks.len() && chunk_starts[i] + chunks[i].len() <= start { i += 1; } if i >= chunks.len() { break; } // If we partially skipped into the current chunk, use `start`. if chunk_starts[i] < start { // start is inside chunks[i] } else { start = chunk_starts[i]; } } let mut last_fit = i; let mut j = i; while j < chunks.len() { let end = chunk_starts[j] + chunks[j].len(); // Display width from `start` (whitespace-trimmed for continuations). let slice = &line[start..end]; if UnicodeWidthStr::width(slice) <= content_width { last_fit = j; j += 1; } else { break; } } if j == i { // Chunk alone exceeds width — emit rest of this chunk for quote-wrap. let end = chunk_starts[i] + chunks[i].len(); row_ranges.push((start, end)); i += 1; } else { let end = chunk_starts[last_fit] + chunks[last_fit].len(); row_ranges.push((start, end)); i = last_fit + 1; } } let mut out = Vec::new(); for (start, end) in row_ranges { let row = line[start..end].trim_end(); // Continuations already had leading ws skipped via `start`; first row // keeps any intentional indent. if UnicodeWidthStr::width(row) <= content_width { out.push(row); } else { out.extend(bash_quote_aware_wrap(row, content_width)); } } out } fn highlight_rows<'a, I>(rows: I) -> Vec> where I: IntoIterator, { rows.into_iter() .map(|row| { let spans = crate::views::tasks_pane::highlight_bash_command(row); Line::from(spans) }) .collect() } /// Word-wrap a bash fragment without breaking on whitespace that sits inside /// single- or double-quoted strings. /// /// Break candidates are byte offsets *after* a run of whitespace that is not /// inside quotes. If a single unbreakable span (e.g. a long `'...'` literal) /// still exceeds `width`, it is emitted as one row (may overflow the panel — /// better than splitting `jq '.[] | ...'` mid-expression). fn bash_quote_aware_wrap(line: &str, width: usize) -> Vec<&str> { if width == 0 || UnicodeWidthStr::width(line) <= width { return vec![line]; } let break_after = quote_aware_break_points(line); if break_after.is_empty() { // Nowhere safe to break (entire line is one quoted span, or no spaces). return vec![line]; } let mut rows: Vec<&str> = Vec::new(); let mut row_start = 0usize; let mut last_break = 0usize; // exclusive end of content if we break here // Consider each break point as a candidate end for the current row. let mut candidates = break_after; candidates.push(line.len()); // allow ending at EOL for &b in &candidates { if b <= row_start { continue; } let candidate = line[row_start..b].trim_end(); if UnicodeWidthStr::width(candidate) <= width { last_break = b; continue; } // Exceeded width: emit up to last_break if we made progress. if last_break > row_start { let row = line[row_start..last_break].trim_end(); if !row.is_empty() { rows.push(row); } // Next row starts after whitespace at last_break. row_start = last_break; while row_start < line.len() && line.as_bytes()[row_start].is_ascii_whitespace() { row_start += 1; } last_break = row_start; // Re-evaluate this break point against the new row_start. if b > row_start { let candidate = line[row_start..b].trim_end(); if UnicodeWidthStr::width(candidate) <= width { last_break = b; } else { // Still too wide with nothing smaller — force-emit unbreakable. let force_end = b; let row = line[row_start..force_end].trim_end(); if !row.is_empty() { rows.push(row); } row_start = force_end; while row_start < line.len() && line.as_bytes()[row_start].is_ascii_whitespace() { row_start += 1; } last_break = row_start; } } } else { // No prior break in this row — unbreakable span larger than width. let row = line[row_start..b].trim_end(); if !row.is_empty() { rows.push(row); } row_start = b; while row_start < line.len() && line.as_bytes()[row_start].is_ascii_whitespace() { row_start += 1; } last_break = row_start; } } if row_start < line.len() { let row = line[row_start..].trim_end(); if !row.is_empty() { rows.push(row); } } if rows.is_empty() { vec![line] } else { rows } } /// Byte offsets at the *start* of whitespace runs that are safe soft-wrap /// points (outside single/double quotes). The caller ends the current row at /// this offset (trimming the run) and skips the whitespace before the next /// row. Does not include offsets inside quotes. fn quote_aware_break_points(line: &str) -> Vec { let bytes = line.as_bytes(); let mut breaks = Vec::new(); let mut i = 0usize; let mut in_single = false; let mut in_double = false; while i < bytes.len() { let c = bytes[i]; if in_single { if c == b'\'' { in_single = false; } i += 1; continue; } if in_double { if c == b'\\' && i + 1 < bytes.len() { i += 2; // skip escape continue; } if c == b'"' { in_double = false; } i += 1; continue; } match c { b'\'' => { in_single = true; i += 1; } b'"' => { in_double = true; i += 1; } b if b.is_ascii_whitespace() => { // Consume the whole whitespace run; break *after* it so the // next row starts at non-ws (caller also trims). let start = i; while i < bytes.len() && bytes[i].is_ascii_whitespace() { i += 1; } // Prefer breaking after the whitespace (start of next token). // Also allow break at end of prior token by recording `start` // so the previous row can end before the spaces. if start > 0 { breaks.push(start); } } _ => i += 1, } } breaks.dedup(); breaks } /// Build syntax-highlighted lines for a (possibly multi-line) bash command. /// /// Preserves intentional newlines / `\` continuations. Soft-wraps overlong /// physical lines at tree-sitter-validated shell operators (`&&` / `||` / /// `|` / `;`), then quote-aware width wrap within each segment. fn build_raw_bash_lines(command: &str, content_width: usize) -> Vec> { let text = prepare_bash_display_text(command); if text.is_empty() { return Vec::new(); } // Operator breaks + heredoc ranges need a full-script parse so body lines // are not space-wrapped and quoted `&&` is not treated as a list op. let full_breaks = soft_break_offsets_after_operators(&text); let heredoc_payload = heredoc_payload_byte_ranges(&text); let mut out = Vec::new(); let mut offset = 0usize; for (idx, physical) in text.split('\n').enumerate() { if idx > 0 { offset += 1; // the '\n' } out.extend(soft_wrap_physical_line( physical, offset, &full_breaks, &heredoc_payload, content_width, )); offset += physical.len(); } out } /// Map highlight selection onto byte ranges in the display string. /// /// Walks tokens in order (prefix → highlighted_words → suffix), locating each /// in the display text while skipping whitespace and `\`-newline continuations. /// Returns `None` if any token cannot be found (caller falls back to undimmed). fn map_selection_ranges( display: &str, h: &BashCommandHighlights, selection_count: usize, ) -> Option> { let mut ranges = Vec::new(); let mut pos = 0usize; for token in &h.prefix { let (start, end) = find_next_token(display, pos, token)?; ranges.push((start, end, false)); pos = end; } for (i, token) in h.highlighted_words.iter().enumerate() { let (start, end) = find_next_token(display, pos, token)?; ranges.push((start, end, i < selection_count)); pos = end; } for token in &h.suffix { let (start, end) = find_next_token(display, pos, token)?; ranges.push((start, end, false)); pos = end; } Some(ranges) } /// Skip whitespace and shell line-continuations (`\` + newline). fn skip_ws_and_continuations(s: &str, mut i: usize) -> usize { let bytes = s.as_bytes(); while i < bytes.len() { let c = s[i..].chars().next().expect("i on char boundary"); if c.is_whitespace() { i += c.len_utf8(); continue; } if c == '\\' { let after = i + 1; // `\` followed by optional horizontal ws then a newline = continuation let mut j = after; while j < bytes.len() { let ch = s[j..].chars().next().expect("j on char boundary"); if ch == ' ' || ch == '\t' { j += ch.len_utf8(); continue; } if ch == '\n' { i = j + 1; break; } // Not a line continuation. return i; } if j >= bytes.len() { return i; } continue; } break; } i } /// Locate `token` at the next *in-order* shell position after `from`. /// /// Skips whitespace, line-continuations, and bare list/pipeline operators /// (`&&` `||` `|` `;`) that often sit between highlight tokens without being /// part of the highlight list themselves. Does **not** free-scan the rest of /// the string — a forward substring search would bind the wrong occurrence /// (e.g. `test` inside `latest`). fn find_next_token(display: &str, from: usize, token: &str) -> Option<(usize, usize)> { if token.is_empty() { return None; } // When the highlight token *is* an operator (e.g. prefix includes "&&"), // do not skip past operators — only skip whitespace/continuations. let i = if is_shell_op_token(token) { skip_ws_and_continuations(display, from) } else { skip_ws_ops_and_continuations(display, from) }; if i >= display.len() { return None; } let end = match_token_at(display, i, token)?; Some((i, end)) } fn is_shell_op_token(token: &str) -> bool { matches!(token, "&&" | "||" | "|" | ";") } /// Skip whitespace, `\`-newline continuations, and shell list/pipeline /// operators that are not themselves mapped highlight tokens. fn skip_ws_ops_and_continuations(s: &str, mut i: usize) -> usize { loop { i = skip_ws_and_continuations(s, i); if i >= s.len() { return i; } let rest = &s[i..]; if rest.starts_with("&&") || rest.starts_with("||") { i += 2; continue; } if rest.starts_with('|') || rest.starts_with(';') { i += 1; continue; } return i; } } fn match_token_at(display: &str, i: usize, token: &str) -> Option { if !token_start_boundary(display, i) { return None; } let rest = &display[i..]; // Bare token if rest.starts_with(token) { let end = i + token.len(); if token_end_boundary(display, end) { return Some(end); } } // Double-quoted (parser stores unquoted content for simple strings) let dq = format!("\"{token}\""); if rest.starts_with(&dq) { let end = i + dq.len(); if token_end_boundary(display, end) { return Some(end); } } // Single-quoted let sq = format!("'{token}'"); if rest.starts_with(&sq) { let end = i + sq.len(); if token_end_boundary(display, end) { return Some(end); } } None } fn token_start_boundary(display: &str, i: usize) -> bool { if i == 0 { return true; } let c = display[..i] .chars() .next_back() .expect("i on char boundary"); c.is_whitespace() || matches!( c, '&' | '|' | ';' | '<' | '>' | '(' | ')' | '`' | '\\' | '=' | '"' | '\'' ) } fn token_end_boundary(display: &str, end: usize) -> bool { if end >= display.len() { return true; } let c = display[end..].chars().next().expect("end on char boundary"); c.is_whitespace() || matches!(c, '&' | '|' | ';' | '<' | '>' | '(' | ')' | '`' | '\\') } /// Highlight each display row and apply selection dimming by global offset. /// /// Rows are produced by the **same** operator-aware + quote-aware wrapping as /// the undimmed path ([`soft_wrap_row_texts`]), so a partially-selected command /// (the common overlay state — `default_scope_count` selects only a couple of /// tokens) wraps identically to a fully-selected one: `jq '.[] | ...'` is not /// split inside its quotes and operators do not leave dangling continuation /// rows. Each row is highlighted independently and dimmed per character using /// its byte offset back into `display`. fn build_bash_lines_with_selection( display: &str, ranges: &[(usize, usize, bool)], content_width: usize, ) -> Vec> { let is_pos_selected = |pos: usize| -> bool { ranges .iter() .find(|(start, end, _)| pos >= *start && pos < *end) .map(|(_, _, sel)| *sel) .unwrap_or_else(|| { ranges .iter() .rfind(|(_, end, _)| *end <= pos) .is_some_and(|(_, _, sel)| *sel) }) }; let full_breaks = soft_break_offsets_after_operators(display); let heredoc_payload = heredoc_payload_byte_ranges(display); let mut out = Vec::new(); let mut offset = 0usize; for (line_idx, physical) in display.split('\n').enumerate() { if line_idx > 0 { offset += 1; // the '\n' } let line_start = offset; // Wrap this physical line into display rows using the identical logic // as the undimmed path. Every row is a sub-slice of `physical`, so its // start offset is recoverable via pointer arithmetic. for row in soft_wrap_row_texts( physical, line_start, &full_breaks, &heredoc_payload, content_width, ) { let row_start_in_physical = (row.as_ptr() as usize) - (physical.as_ptr() as usize); let row_global_start = line_start + row_start_in_physical; out.push(highlight_row_with_dim( row, row_global_start, &is_pos_selected, )); } offset = line_start + physical.len(); } out } /// Syntax-highlight a single display `row` and overlay `Modifier::DIM` on /// characters whose global byte offset (`global_start + local`) is not selected. fn highlight_row_with_dim( row: &str, global_start: usize, is_pos_selected: &impl Fn(usize) -> bool, ) -> Line<'static> { let hl_spans = crate::views::tasks_pane::highlight_bash_command(row); let mut spans: Vec> = Vec::new(); let mut pos = 0usize; for span in hl_spans { let text = span.content.into_owned(); let base_style = span.style; let mut i = 0; while i < text.len() { let selected = is_pos_selected(global_start + pos + i); let mut j = i + 1; while j < text.len() && is_pos_selected(global_start + pos + j) == selected { j += 1; } let (i_aligned, j_aligned) = (snap_char(&text, i), snap_char(&text, j)); if j_aligned > i_aligned { let slice = text[i_aligned..j_aligned].to_owned(); let style = if selected { base_style } else { base_style.add_modifier(Modifier::DIM) }; spans.push(Span::styled(slice, style)); } i = j; } pos += text.len(); } Line::from(spans) } /// Round a byte index down to the nearest UTF-8 char boundary. fn snap_char(s: &str, idx: usize) -> usize { let mut i = idx.min(s.len()); while i > 0 && !s.is_char_boundary(i) { i -= 1; } i } /// Render the MCP tool name as a single line with the in-scope segment /// highlighted (accent + bold) and the rest dimmed. The qualified name /// is shown title-cased as `"(Server) Action"`; tool-scope highlights /// both segments, server-scope highlights only `"(Server) "` and dims /// the action. fn build_mcp_scope_lines( scope: &McpScopeState, theme: &Theme, _content_w: usize, ) -> Vec> { let active_style = Style::default() .fg(theme.accent_user) .add_modifier(Modifier::BOLD); let inactive_style = Style::default().fg(theme.gray).add_modifier(Modifier::DIM); let spans: Vec> = match (scope.selected, scope.server_prefix.as_deref()) { // No server prefix: only tool-scope is reachable; whole name is "active". (_, None) => vec![Span::styled(scope.display_name(), active_style)], // Tool-scope highlights everything (the full qualified name is being whitelisted). (McpScope::Tool, Some(_)) => vec![Span::styled(scope.display_name(), active_style)], // Server-scope highlights "(Server) " and dims the action. (McpScope::Server, Some(prefix)) => vec![ Span::styled(format!("({}) ", mcp_titleize_segment(prefix)), active_style), Span::styled(mcp_titleize_segment(scope.action()), inactive_style), ], }; vec![Line::from(spans)] } /// Styled display lines for the planned MCP tool arguments /// ([`PermissionViewState::description`]). /// /// JSON-highlighted at render time with the theme-matched syntect instance /// (a mid-prompt `/theme` switch recolors), falling back to a flat /// secondary style. Highlighting preserves the text, so rows match /// [`mcp_args_visible_rows`]; `max_rows` stops the syntect work once the /// visible budget is filled. fn build_mcp_args_lines( description: &[String], theme: &Theme, content_w: usize, max_rows: usize, ) -> Vec> { if description.is_empty() || max_rows == 0 { return Vec::new(); } let fallback = Style::default().fg(theme.text_secondary); let syntect = crate::syntax::get_syntect(); // The highlighter is stateful across lines (pretty JSON nests). let mut hl = syntect.highlight_lines_for_token("json"); let mut out: Vec> = Vec::new(); for raw in description { // Visible budget filled — skip highlighting the rest. if out.len() >= max_rows { break; } let spans = crate::syntax::highlight_line(raw, &mut hl, syntect, fallback); out.extend(char_wrap_spans(spans, content_w)); } out.truncate(max_rows); out } /// The `... Ctrl-F to expand` indicator line for a collapsed args display. /// Styling matches the question tool's truncation indicator. fn truncation_indicator_line(theme: &Theme) -> Line<'static> { let style = Style::default().fg(theme.gray).bg(theme.bg_light); Line::from(vec![ Span::styled("... ", style), Span::styled( "Ctrl-F", Style::default().fg(theme.accent_user).bg(theme.bg_light), ), Span::styled(" to expand", style), ]) } /// Span-preserving variant of `char_wrap`: splits a styled span run into /// lines at the same unicode-width column boundaries, merging adjacent /// same-style runs. /// /// Invariant: produces exactly `char_wrap(text, width).len()` lines for /// the same flattened text (same break condition; empty input is one /// blank row). fn char_wrap_spans(spans: Vec>, width: usize) -> Vec> { let width = width.max(1); let mut lines: Vec> = Vec::new(); let mut line_spans: Vec> = Vec::new(); let mut run = String::new(); let mut run_style = Style::default(); let mut col = 0usize; for span in spans { let style = span.style; for ch in span.content.chars() { let ch_w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); let line_has_content = !run.is_empty() || !line_spans.is_empty(); if col + ch_w > width && line_has_content { if !run.is_empty() { line_spans.push(Span::styled(std::mem::take(&mut run), run_style)); } lines.push(Line::from(std::mem::take(&mut line_spans))); col = 0; } if style != run_style && !run.is_empty() { line_spans.push(Span::styled(std::mem::take(&mut run), run_style)); } run_style = style; run.push(ch); col += ch_w; } } if !run.is_empty() { line_spans.push(Span::styled(run, run_style)); } if !line_spans.is_empty() || lines.is_empty() { lines.push(Line::from(line_spans)); } lines } /// Character-wrap a plain string to `width` columns (unicode-width /// aware); an empty input yields one blank row. Character (not word) /// wrapping keeps every JSON column visible. /// /// Test-only reference: production uses [`char_wrap_row_count`] and /// [`char_wrap_spans`], pinned against this by the property tests. #[cfg(test)] fn char_wrap(s: &str, width: usize) -> Vec { let width = width.max(1); let mut out = Vec::new(); let mut cur = String::new(); let mut cur_w = 0usize; for ch in s.chars() { let ch_w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); if cur_w + ch_w > width && !cur.is_empty() { out.push(std::mem::take(&mut cur)); cur_w = 0; } cur.push(ch); cur_w += ch_w; } if !cur.is_empty() || out.is_empty() { out.push(cur); } out } /// Build a styled line for a single permission option. /// /// Normal options (AllowOnce, AllowAlways, RejectAlways) render as radio rows /// prefixed by the 1-based keyboard shortcut number: /// ```text /// 1 (*) Always allow: cargo test /// 2 (o) Yes, proceed /// 4 (o) Never allow: cargo test /// ``` /// /// RejectOnce renders as a freeform input row (matching question view style) /// using the same 1-based shortcut number as its prefix: /// ```text /// 3 [ ] Tell Grok what to do differently /// 3 [x] ❯ my followup message preview... /// ``` /// /// When `selected_words` is `Some`, AllowAlways/RejectAlways options that /// carry `BashCommandPermission` meta have their labels dynamically rebuilt /// as `"{prompt_prefix} {selected_words}"`. #[allow(clippy::too_many_arguments)] fn build_permission_option_line<'a>( option: &acp::PermissionOption, index: usize, is_cursor: bool, row_bg: ratatui::style::Color, selected_words: Option<&str>, mcp_scope: Option<&McpScopeState>, followup_text: &str, row_width: u16, theme: &Theme, ) -> Line<'a> { let num_style = Style::default().fg(theme.accent_user).bg(row_bg); let sc = shortcut_char(index); if option.kind == acp::PermissionOptionKind::RejectOnce { return build_reject_once_line(sc, is_cursor, row_bg, followup_text, theme); } // Dynamic label: AllowAlways/RejectAlways with BashCommandPermission or // McpToolPermission meta gets its scope text rebuilt from current // selection state. let (label_prefix, scope_words) = dynamic_option_label(option, selected_words, mcp_scope); // MCP scope text is a plain identifier, not a bash script — skip // syntax highlighting in that case so we don't accidentally tokenize // tool names. let scope_is_mcp = mcp_scope.is_some(); let marker = if is_cursor { format!("({})", crate::glyphs::filled_dot()) } else { "(\u{25cb})".to_string() }; let marker_style = if is_cursor { Style::default() .fg(theme.text_primary) .bg(row_bg) .add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.gray).bg(row_bg) }; let label_style = Style::default() .fg(theme.text_primary) .bg(row_bg) .add_modifier(if is_cursor { Modifier::BOLD } else { Modifier::empty() }); let mut spans = vec![ Span::styled(format!("{sc} "), num_style), Span::styled(format!("{marker} "), marker_style), Span::styled(label_prefix, label_style), ]; if let Some(scope) = scope_words { let prefix_w: usize = spans.iter().map(|s| s.width()).sum(); let max_scope = (row_width as usize).saturating_sub(prefix_w + 1); let truncated = if scope.width() > max_scope { crate::render::line_utils::truncate_str(&scope, max_scope) } else { scope }; if scope_is_mcp { spans.push(Span::styled(truncated, label_style)); } else { for s in crate::views::tasks_pane::highlight_bash_command(&truncated) { spans.push(Span::styled(s.content.into_owned(), s.style.bg(row_bg))); } } } Line::from(spans).style(Style::default().bg(row_bg)) } /// Build the RejectOnce row as a freeform input line (mirrors question view). fn build_reject_once_line<'a>( shortcut_ch: char, is_cursor: bool, row_bg: ratatui::style::Color, followup_text: &str, theme: &Theme, ) -> Line<'a> { let num_style = Style::default().fg(theme.accent_user).bg(row_bg); let has_text = !followup_text.trim().is_empty(); // Radio marker: (●) when cursor is on this row, (○) otherwise. // Same logic as other option rows — cursor position determines the marker. let (marker, marker_style) = if is_cursor { ( format!("({})", crate::glyphs::filled_dot()), Style::default() .fg(theme.text_primary) .bg(row_bg) .add_modifier(Modifier::BOLD), ) } else { ( "(\u{25cb})".to_string(), Style::default().fg(theme.gray).bg(row_bg), ) }; let prompt_indicator = Style::default().fg(theme.accent_user).bg(row_bg); let (label, label_style) = if has_text { // Show preview of typed text. let first_line = followup_text.lines().next().unwrap_or(""); let preview = crate::render::line_utils::truncate_str(first_line, 50); (preview, Style::default().fg(theme.text_primary).bg(row_bg)) } else { // Placeholder. ( "No, reject (type to add feedback)".to_string(), Style::default().fg(theme.gray).bg(row_bg), ) }; let mut spans = vec![ Span::styled(format!("{shortcut_ch} "), num_style), Span::styled(format!("{marker} "), marker_style), ]; if has_text { spans.push(Span::styled( crate::glyphs::prompt_arrow(), prompt_indicator, )); } spans.push(Span::styled(label, label_style)); Line::from(spans).style(Style::default().bg(row_bg)) } /// Compute the display label for a permission option, with dynamic /// scope-driven override for the AllowAlways / RejectAlways rows. /// /// Returns `(prefix_label, Option)`. When `scope_text` is /// `Some`, the caller renders it after the prefix; for bash that scope /// is syntax-highlighted, for MCP it is rendered as a plain identifier. /// /// Bash flow: `selected_words` carries the joined highlighted words and /// `BashCommandPermission` meta provides the prefix. /// /// MCP flow: `mcp_scope` carries the toggle selection and the option's /// `McpToolPermission` meta provides the prefix and tool name. Tool-scope /// renders the pretty tool name (`"(Server) Action"`); server-scope /// renders `"all tools from "`. fn dynamic_option_label( option: &acp::PermissionOption, selected_words: Option<&str>, mcp_scope: Option<&McpScopeState>, ) -> (String, Option) { if matches!( option.kind, acp::PermissionOptionKind::AllowAlways | acp::PermissionOptionKind::RejectAlways ) && let Some(ref meta) = option.meta { if let Some(scope) = mcp_scope && let Ok(perm) = serde_json::from_value::(serde_json::Value::Object(meta.clone())) { let scope_text = match scope.selected { McpScope::Tool => perm.display_name(), McpScope::Server => match scope.server_prefix.as_deref() { Some(s) => format!("all tools from {}", mcp_titleize_segment(s)), None => perm.display_name(), }, }; return (format!("{} ", perm.prompt_prefix), Some(scope_text)); } if let Some(words) = selected_words && let Ok(bash_perm) = serde_json::from_value::( serde_json::Value::Object(meta.clone()), ) { return ( format!("{} ", bash_perm.prompt_prefix), Some(words.to_owned()), ); } } (option.name.clone(), None) } /// Plain-string form of [`dynamic_option_label`] for surfaces without span /// styling (dashboard peek). Keeps every render surface on the one label /// source so what is shown always equals the scope the dispatch persists. pub(crate) fn option_label_for_selection( option: &acp::PermissionOption, selected_words: Option<&str>, mcp_scope: Option<&McpScopeState>, ) -> String { let (prefix, scope_text) = dynamic_option_label(option, selected_words, mcp_scope); match scope_text { Some(scope) => format!("{prefix}{scope}"), None => prefix, } } #[cfg(test)] mod tests { use super::*; use std::sync::Arc; fn mcp_state(tool: &str, server: Option<&str>, selected: McpScope) -> McpScopeState { McpScopeState { tool_name: tool.to_owned(), server_prefix: server.map(|s| s.to_owned()), selected, } } fn allow_always_mcp_option(tool: &str, server: Option<&str>) -> acp::PermissionOption { let perm = McpToolPermission { prompt_prefix: "Always allow:".to_owned(), tool_name: tool.to_owned(), server_prefix: server.map(|s| s.to_owned()), }; acp::PermissionOption::new( acp::PermissionOptionId::new(Arc::from("allow-always-mcp")), format!("Always allow: {}", tool), acp::PermissionOptionKind::AllowAlways, ) .meta( serde_json::to_value(perm) .ok() .and_then(|v| v.as_object().cloned()), ) } fn permission_state_with_title(title: &str, n_options: usize) -> PermissionViewState { let (response_tx, _rx) = tokio::sync::oneshot::channel(); let request = acp::RequestPermissionRequest::new( acp::SessionId::new(Arc::from("test")), acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from("call-1")), acp::ToolCallUpdateFields::default(), ), vec![], ); let options: Vec = (0..n_options) .map(|i| { acp::PermissionOption::new( acp::PermissionOptionId::new(Arc::from(format!("opt-{i}"))), format!("Option {i}"), acp::PermissionOptionKind::AllowOnce, ) }) .collect(); PermissionViewState { request: kigi_acp_lib::AcpArgs { request, response_tx, }, id: 0, focus: PermissionFocus::Options, options, active_idx: 0, bash_highlights: None, bash_selection_count: 0, bash_command_raw: Some("cargo test --all".to_string()), mcp_scope: None, title: title.to_string(), description: vec![], args_expanded: false, desc_scroll: 0, subagent_label: Some("subagent: worker".to_string()), options_area_height: 0, options_scroll_offset: 0, } } #[test] fn render_short_area_at_buffer_bottom_does_not_panic() { // Regression: a squeezed permission overlay (0-2 rows) at the bottom of // a short terminal wrote the provenance/title rows one past the buffer // -> ratatui "index outside of buffer" panic (reported via /feedback as // index (5, 10) in a 147x10 terminal). let theme = Theme::current(); for buf_h in [10u16, 12, 24] { for area_h in 0u16..=5 { for area_y in 0..buf_h { if area_y + area_h > buf_h { continue; } let state = permission_state_with_title("Allow command?", 3); let area = Rect::new(2, area_y, 145, area_h); let mut buf = Buffer::empty(Rect::new(0, 0, 147, buf_h)); let _ = render_permission_view(&mut buf, area, &state, "", None, &theme, true); } } } } #[test] fn render_tiny_areas_with_args_do_not_panic() { // Panic sweep: widths where the content area underflows to 0, // 0-6 row heights, areas at the buffer bottom, both toggle states. let theme = Theme::current(); for expanded in [false, true] { for buf_w in 0u16..=10 { for area_h in 0u16..=6 { for area_y in [0u16, 4, 8] { if area_y + area_h > 10 { continue; } let mut state = long_args_state(); state.args_expanded = expanded; state.subagent_label = Some("subagent: worker".into()); let area = Rect::new(0, area_y, buf_w, area_h); let mut buf = Buffer::empty(Rect::new(0, 0, buf_w.max(1), 10)); let _ = render_permission_view( &mut buf, area, &state, "follow", None, &theme, true, ); } } } } } #[test] fn view_height_stays_sane_on_tiny_screens() { // Expanded height never exceeds the screen; collapsed has a // min-floor of 10 the renderer survives on shorter areas. let mut state = long_args_state(); for screen_h in 0u16..=12 { let collapsed = permission_view_height(&state, screen_h, 20); assert!( collapsed <= screen_h.max(10), "collapsed {collapsed} exceeds screen {screen_h} (min-floor 10)" ); state.args_expanded = true; let expanded = permission_view_height(&state, screen_h, 20); assert!( expanded <= screen_h, "expanded {expanded} > screen {screen_h}" ); state.args_expanded = false; } } #[test] fn mcp_scope_state_initializes_to_tool() { // The pager constructs `mcp_scope` from request meta in // `acp_handler::enqueue_permission` with `selected: McpScope::Tool` // as the default. This sanity test pins that behavior at the // type level: a fresh state is always Tool. let s = mcp_state("linear__list", Some("linear"), McpScope::Tool); assert_eq!(s.selected, McpScope::Tool); } #[test] fn mcp_scope_toggle_left_then_right_round_trips() { // Mirror the agent_view arrow-key handler: <- contracts Tool -> Server // (visually "shrinks" the highlighted region to the server prefix); // -> expands Server -> Tool (visually "grows" back to the full tool // name). Matches the bash arrow convention. let mut s = mcp_state("linear__list", Some("linear"), McpScope::Tool); // Left contracts to server. if s.server_prefix.is_some() { s.selected = McpScope::Server; } assert_eq!(s.selected, McpScope::Server); // Right expands back to tool. s.selected = McpScope::Tool; assert_eq!(s.selected, McpScope::Tool); } #[test] fn dynamic_option_label_server_scope_renders_all_tools_from_wording() { // Pin both the UX wording AND the title-casing of the server // segment — if this regresses to `"all __* tools"` or // drops title-casing, this test breaks. let opt = allow_always_mcp_option("linear__list", Some("linear")); let scope = mcp_state("linear__list", Some("linear"), McpScope::Server); let (prefix, scope_text) = dynamic_option_label(&opt, None, Some(&scope)); assert_eq!(prefix, "Always allow: "); assert_eq!(scope_text.as_deref(), Some("all tools from Linear")); } #[test] fn dynamic_option_label_tool_scope_renders_pretty_name() { // Pins both the `"(Server) Action"` shape and the title-casing of // each side (underscores → spaces, each word capitalized). let opt = allow_always_mcp_option("linear__list_issues", Some("linear")); let scope = mcp_state("linear__list_issues", Some("linear"), McpScope::Tool); let (prefix, scope_text) = dynamic_option_label(&opt, None, Some(&scope)); assert_eq!(prefix, "Always allow: "); assert_eq!(scope_text.as_deref(), Some("(Linear) List Issues")); } fn empty_view_state(mcp_scope: Option) -> PermissionViewState { let (response_tx, _rx) = tokio::sync::oneshot::channel(); let request = acp::RequestPermissionRequest::new( acp::SessionId::new(Arc::from("test")), acp::ToolCallUpdate::new( acp::ToolCallId::new(Arc::from("call-1")), acp::ToolCallUpdateFields::default(), ), vec![], ); let perm = kigi_acp_lib::AcpArgs { request, response_tx, }; PermissionViewState { request: perm, id: 0, focus: PermissionFocus::Options, options: vec![], active_idx: 0, bash_highlights: None, bash_selection_count: 0, bash_command_raw: None, mcp_scope, title: String::new(), description: vec![], args_expanded: false, desc_scroll: 0, subagent_label: None, options_area_height: 0, options_scroll_offset: 0, } } #[test] fn mcp_scope_no_server_prefix_disables_toggle() { // When the tool name has no `__`, server_prefix is None and the // toggle is suppressed. let state = empty_view_state(Some(mcp_state("standalone", None, McpScope::Tool))); assert!(!state.has_adjustable_scope()); } #[test] fn has_adjustable_scope_true_when_mcp_has_server() { let state = empty_view_state(Some(mcp_state( "linear__list", Some("linear"), McpScope::Tool, ))); assert!(state.has_adjustable_scope()); } #[test] fn has_adjustable_scope_false_for_plain_prompt() { let state = empty_view_state(None); assert!(!state.has_adjustable_scope()); } #[test] fn char_wrap_row_count_matches_char_wrap() { // The alloc-free counter must agree with the reference wrapper. let cases = [ "", "a", "abcdef", " \"key\": \"value with spaces\",", "你好世界你好世界", "mixed 你 width 好 text", &"x".repeat(500), ]; for s in cases { for width in [1usize, 2, 3, 7, 10, 80, 500] { assert_eq!( char_wrap_row_count(s, width), char_wrap(s, width).len(), "{s:?} at width {width}" ); } } } #[test] fn char_wrap_respects_width_and_yields_blank_row_for_empty() { assert_eq!(char_wrap("abcdef", 3), vec!["abc", "def"]); assert_eq!(char_wrap("abcd", 3), vec!["abc", "d"]); // Empty input still occupies one row (blank JSON line). assert_eq!(char_wrap("", 10), vec![""]); // Width 0 is clamped to 1 (no infinite loop / panic). assert_eq!(char_wrap("ab", 0), vec!["a", "b"]); // Wide chars count as 2 columns. assert_eq!(char_wrap("你好", 2), vec!["你", "好"]); } #[test] fn char_wrap_spans_mirrors_char_wrap_boundaries() { // Chrome counts plain text, render wraps styled spans; they must // agree regardless of where style runs fall. let text = " \"key\": \"a long value with spaces and 你好 wide chars\","; for width in [1usize, 2, 7, 10, 80] { // Split the text into arbitrarily-styled runs (every 5 chars). let chars: Vec = text.chars().collect(); let spans: Vec> = chars .chunks(5) .enumerate() .map(|(i, chunk)| { let style = if i % 2 == 0 { Style::default().fg(ratatui::style::Color::Red) } else { Style::default().fg(ratatui::style::Color::Blue) }; Span::styled(chunk.iter().collect::(), style) }) .collect(); let lines = char_wrap_spans(spans, width); let plain = char_wrap(text, width); assert_eq!(lines.len(), plain.len(), "width {width}"); for (line, expect) in lines.iter().zip(&plain) { let flat: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert_eq!(&flat, expect, "width {width}"); } } } #[test] fn char_wrap_spans_preserves_styles_across_wrap() { let red = Style::default().fg(ratatui::style::Color::Red); let blue = Style::default().fg(ratatui::style::Color::Blue); let spans = vec![Span::styled("aaaa", red), Span::styled("bbbb", blue)]; let lines = char_wrap_spans(spans, 6); // Line 0: "aaaa" red + "bb" blue; line 1: "bb" blue. assert_eq!(lines.len(), 2); assert_eq!(lines[0].spans.len(), 2); assert_eq!(lines[0].spans[0].content.as_ref(), "aaaa"); assert_eq!(lines[0].spans[0].style, red); assert_eq!(lines[0].spans[1].content.as_ref(), "bb"); assert_eq!(lines[0].spans[1].style, blue); assert_eq!(lines[1].spans[0].content.as_ref(), "bb"); assert_eq!(lines[1].spans[0].style, blue); } #[test] fn build_mcp_args_lines_highlights_without_altering_text_or_count() { // Highlighting must be invisible to layout: text and row count // identical to the plain `char_wrap` mirror. let description: Vec = vec![ "{".into(), format!(" \"body\": \"{}\",", "x".repeat(120)), " \"n\": 42".into(), "}".into(), ]; let theme = Theme::current(); for width in [10usize, 40, 80] { let lines = build_mcp_args_lines(&description, &theme, width, usize::MAX); let plain: Vec = description .iter() .flat_map(|raw| char_wrap(raw, width)) .collect(); assert_eq!(lines.len(), plain.len(), "width {width}"); for (line, expect) in lines.iter().zip(&plain) { let flat: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); assert_eq!(&flat, expect, "width {width}"); } } // The JSON grammar must resolve or the builder silently degrades // to the flat fallback. Asserted on the grammar, not span counts: // NO_COLOR quantizes styles equal and spans legitimately merge. assert!( crate::syntax::get_syntect() .highlight_lines_for_token("json") .is_some(), "JSON syntax missing from the two-face syntax set" ); } #[test] fn chrome_height_counts_mcp_args_lines() { let mut state = empty_view_state(Some(mcp_state( "jira__AddjiraComment", Some("jira"), McpScope::Tool, ))); let base = permission_chrome_height(&state, 80); state.description = vec!["{".into(), " \"body\": \"hi\"".into(), "}".into()]; assert_eq!(permission_chrome_height(&state, 80), base + 3); // A line wider than the content width wraps and is counted as such. state.description = vec!["x".repeat(100)]; assert_eq!(permission_chrome_height(&state, 80), base + 2); } #[test] fn render_shows_planned_mcp_args() { // The overlay must render the payload, not just the tool name. let mut state = empty_view_state(Some(mcp_state( "jira__AddjiraComment", Some("jira"), McpScope::Tool, ))); state.title = "Allow Jira: Addjira Comment?".to_string(); state.description = vec![ "{".to_string(), " \"issue\": \"ABC-123\",".to_string(), " \"body\": \"hello from grok\"".to_string(), "}".to_string(), ]; state.options = vec![acp::PermissionOption::new( acp::PermissionOptionId::new(Arc::from("allow-once")), "Yes".to_owned(), acp::PermissionOptionKind::AllowOnce, )]; let theme = Theme::current(); let area = Rect::new(0, 0, 80, 20); let mut buf = Buffer::empty(area); let _ = render_permission_view(&mut buf, area, &state, "", None, &theme, true); let text: String = (0..area.height) .map(|row| { (0..area.width) .map(|col| buf[(col, row)].symbol().to_string()) .collect::() + "\n" }) .collect(); assert!( text.contains("\"issue\": \"ABC-123\","), "args JSON not rendered:\n{text}" ); assert!( text.contains("\"body\": \"hello from grok\""), "args JSON not rendered:\n{text}" ); // Option row still visible below the args. assert!(text.contains("Yes"), "options row missing:\n{text}"); } fn long_args_state() -> PermissionViewState { let mut state = empty_view_state(Some(mcp_state( "jira__AddjiraComment", Some("jira"), McpScope::Tool, ))); state.title = "Allow Jira: Addjira Comment?".to_string(); state.description = (0..50).map(|i| format!("\"line{i}\": {i},")).collect(); state.options = vec![acp::PermissionOption::new( acp::PermissionOptionId::new(Arc::from("allow-once")), "Yes".to_owned(), acp::PermissionOptionKind::AllowOnce, )]; state } fn render_to_text(state: &PermissionViewState, area: Rect) -> String { let theme = Theme::current(); let mut buf = Buffer::empty(area); let _ = render_permission_view(&mut buf, area, state, "", None, &theme, true); (0..area.height) .map(|row| { (area.x..area.x + area.width) .map(|col| buf[(col, row)].symbol().to_string()) .collect::() + "\n" }) .collect() } #[test] fn render_collapses_long_mcp_args_with_ctrl_f_indicator() { // Collapsed: 4 content rows + indicator, options visible. let state = long_args_state(); let text = render_to_text(&state, Rect::new(0, 0, 80, 30)); assert!(text.contains("\"line3\": 3,"), "4th content row:\n{text}"); assert!( !text.contains("\"line4\": 4,"), "5th row must be the indicator:\n{text}" ); assert!( text.contains("... Ctrl-F to expand"), "indicator missing:\n{text}" ); assert!(text.contains("Yes"), "options row missing:\n{text}"); } #[test] fn render_expanded_mcp_args_clips_at_area_keeping_options_visible() { // Expanded shows all the area allows; overflow clips with the // ellipsis and option rows always render. let mut state = long_args_state(); state.args_expanded = true; let text = render_to_text(&state, Rect::new(0, 0, 80, 12)); assert!( !text.contains("Ctrl-F to expand"), "no indicator when expanded:\n{text}" ); assert!(text.contains("Yes"), "options row missing:\n{text}"); assert!( text.contains('\u{2026}'), "area-clipped args missing ellipsis:\n{text}" ); assert!( !text.contains("\"line49\": 49,"), "args should have been clipped:\n{text}" ); // A tall area shows deep rows that the collapsed view never reaches. let text_tall = render_to_text(&state, Rect::new(0, 0, 80, 40)); assert!( text_tall.contains("\"line20\": 20,"), "expanded view must show deep rows:\n{text_tall}" ); } #[test] fn mcp_args_visible_rows_budget_and_boundary() { let mut state = long_args_state(); // 50 one-row lines, collapsed: 4 content rows + indicator. assert_eq!(mcp_args_visible_rows(&state, 80), (4, true)); // Expanded: everything, no indicator. state.args_expanded = true; assert_eq!(mcp_args_visible_rows(&state, 80), (50, false)); // Exactly at the budget: no truncation, no indicator. state.args_expanded = false; state.description = (0..MCP_ARGS_COLLAPSED_ROWS) .map(|i| format!("l{i}")) .collect(); assert_eq!( mcp_args_visible_rows(&state, 80), (MCP_ARGS_COLLAPSED_ROWS, false) ); } #[test] fn expanded_args_lift_the_view_height_cap() { let mut state = long_args_state(); let screen_h = 40; let collapsed = permission_view_height(&state, screen_h, 80); assert!( collapsed <= screen_h / 2, "collapsed view respects the 50% cap: {collapsed}" ); state.args_expanded = true; let expanded = permission_view_height(&state, screen_h, 80); assert!( expanded > screen_h / 2 && expanded <= screen_h, "expanded view may grow past 50% up to the screen: {expanded}" ); } #[test] fn dynamic_option_label_renders_tool_scope() { let opt = allow_always_mcp_option("linear__list", Some("linear")); let scope = mcp_state("linear__list", Some("linear"), McpScope::Tool); let (prefix, scope_text) = dynamic_option_label(&opt, None, Some(&scope)); assert_eq!(prefix, "Always allow: "); assert_eq!(scope_text.as_deref(), Some("(Linear) List")); } #[test] fn dynamic_option_label_renders_server_scope() { let opt = allow_always_mcp_option("linear__list", Some("linear")); let scope = mcp_state("linear__list", Some("linear"), McpScope::Server); let (prefix, scope_text) = dynamic_option_label(&opt, None, Some(&scope)); assert_eq!(prefix, "Always allow: "); assert_eq!(scope_text.as_deref(), Some("all tools from Linear")); } #[test] fn dynamic_option_label_server_scope_without_prefix_falls_back_to_tool() { // Defensive: render path should disable Server when no prefix, // but if state was constructed inconsistently the label still // renders the tool name rather than panicking. let opt = allow_always_mcp_option("standalone", None); let scope = mcp_state("standalone", None, McpScope::Server); let (_prefix, scope_text) = dynamic_option_label(&opt, None, Some(&scope)); assert_eq!(scope_text.as_deref(), Some("Standalone")); } #[test] fn dynamic_option_label_falls_back_to_bash_when_no_mcp() { // When mcp_scope is None but selected_words is Some and the meta // is BashCommandPermission, the bash branch still works. let bash_perm = BashCommandPermission { prompt_prefix: "Always allow:".to_owned(), }; let opt = acp::PermissionOption::new( acp::PermissionOptionId::new(Arc::from("allow-always-command")), "Always allow: cargo test".to_owned(), acp::PermissionOptionKind::AllowAlways, ) .meta( serde_json::to_value(bash_perm) .ok() .and_then(|v| v.as_object().cloned()), ); let (prefix, scope_text) = dynamic_option_label(&opt, Some("cargo test"), None); assert_eq!(prefix, "Always allow: "); assert_eq!(scope_text.as_deref(), Some("cargo test")); } #[test] fn dynamic_option_label_rebuilds_reject_always_bash_row() { // The "Never allow:" row shares the ←/→ word-scope selection with the // allow row, so its label must rebuild from selected_words too. let bash_perm = BashCommandPermission { prompt_prefix: "Never allow:".to_owned(), }; let opt = acp::PermissionOption::new( acp::PermissionOptionId::new(Arc::from("reject-always-command")), "Never allow: cargo test --workspace".to_owned(), acp::PermissionOptionKind::RejectAlways, ) .meta( serde_json::to_value(bash_perm) .ok() .and_then(|v| v.as_object().cloned()), ); let (prefix, scope_text) = dynamic_option_label(&opt, Some("cargo test"), None); assert_eq!(prefix, "Never allow: "); assert_eq!(scope_text.as_deref(), Some("cargo test")); } #[test] fn option_label_for_selection_matches_persisted_scope() { // Peek surface contract: the composed label must show exactly the // words the dispatch meta will persist, not the static full name. let opt = acp::PermissionOption::new( acp::PermissionOptionId::new(Arc::from("reject-always-command")), "Never allow: cargo test --workspace".to_owned(), acp::PermissionOptionKind::RejectAlways, ) .meta( serde_json::to_value(BashCommandPermission { prompt_prefix: "Never allow:".to_owned(), }) .ok() .and_then(|v| v.as_object().cloned()), ); assert_eq!( option_label_for_selection(&opt, Some("cargo"), None), "Never allow: cargo" ); // Options without scope meta keep their static name. let plain = acp::PermissionOption::new( acp::PermissionOptionId::new(Arc::from("allow-once")), "Yes, proceed".to_owned(), acp::PermissionOptionKind::AllowOnce, ); assert_eq!( option_label_for_selection(&plain, Some("cargo"), None), "Yes, proceed" ); } #[test] fn prepare_bash_display_preserves_backslash_continuations() { let raw = "docker run \\\n -v /tmp:/tmp \\\n -e FOO=bar \\\n alpine:latest\n"; let prepared = prepare_bash_display_text(raw); assert!( prepared.contains("docker run \\\n -v /tmp:/tmp \\\n -e FOO=bar \\\n alpine:latest"), "expected multi-line continuations, got: {prepared:?}" ); // Must not flatten to a single space-joined line. assert!(!prepared.contains("docker run \\ -v")); assert_eq!(prepared.lines().count(), 4); } #[test] fn prepare_bash_display_drops_dangling_trailing_continuation_newline() { // A command ending in `\` + newline (with nothing after) must not // render a stray empty row in the height-capped overlay. The trailing // backslash stays visible; only the useless newline is dropped. let prepared = prepare_bash_display_text("echo a \\\n"); assert_eq!(prepared, "echo a \\"); let rows = build_raw_bash_lines("echo a \\\n", 80); assert_eq!(rows.len(), 1, "no trailing blank row"); // Multiple trailing blank lines after a dangling `\` also collapse. assert_eq!(prepare_bash_display_text("echo a \\\n\n"), "echo a \\"); // Interior continuations are untouched. assert_eq!(prepare_bash_display_text("a \\\nb\n"), "a \\\nb"); } #[test] fn build_raw_bash_lines_keeps_continuation_rows() { let raw = "cargo test \\\n --all \\\n -- --nocapture"; let lines = build_raw_bash_lines(raw, 80); assert!( lines.len() >= 3, "expected one row per physical line, got {}", lines.len() ); let joined: String = lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.as_ref()) .collect::() }) .collect::>() .join("\n"); assert!(joined.contains("cargo test \\")); assert!(joined.contains("--all \\")); assert!(joined.contains("-- --nocapture")); } fn row_text(line: &Line<'_>) -> String { line.spans.iter().map(|s| s.content.as_ref()).collect() } #[test] fn soft_wrap_prefers_shell_operators_over_every_space() { // Wide enough that each side of `&&` fits alone, but the full // line does not — should produce two rows at the operator, not // a word-wrap mid-flag. let line = "git status --short --branch && cargo test --workspace --all-features"; let width = 40; assert!(UnicodeWidthStr::width(line) > width); let breaks = soft_break_offsets_after_operators(line); assert!( !breaks.is_empty(), "tree-sitter should find the real && operator" ); let rows = soft_wrap_physical_line(line, 0, &breaks, &[], width); assert!( rows.len() >= 2, "expected operator split, got {} rows", rows.len() ); let first = row_text(&rows[0]); assert!( first.contains("&&"), "first row should keep the operator: {first:?}" ); assert!( !first.contains("cargo"), "cargo should be on a later row, not packed with git: {first:?}" ); // Continuation must not start with a dangling space from after `&&`. let second = row_text(&rows[1]); assert!( !second.starts_with(' '), "no leading space on continuation row: {second:?}" ); assert!(second.starts_with("cargo"), "second={second:?}"); } #[test] fn soft_wrap_does_not_break_inside_jq_single_quoted_filter() { // Regression: long `gh ... --jq '.[] | ...'` must not wrap at the // space after `|` inside the single-quoted filter. let line = r#"gh search prs --author=@me --sort=updated --limit=15 --json number,title,url,state,updatedAt,repository,isDraft --jq '.[] | "\(.state)\t#\(.number)\t\(.updatedAt)\t\(.repository.nameWithOwner)\t\(.title)\t\(.url)"'"#; let width = 60; assert!(UnicodeWidthStr::width(line) > width); let breaks = soft_break_offsets_after_operators(line); assert!( breaks.is_empty(), "no shell list ops on this fragment: {breaks:?}" ); let rows = soft_wrap_physical_line(line, 0, &breaks, &[], width); let rendered: Vec = rows.iter().map(row_text).collect(); // The jq filter must never be split at `.[] |`. for r in &rendered { assert!( !(r.ends_with(".[]") || r.ends_with(".[] |") || r.trim_end() == "'.[] |"), "must not break after .[] |; rows={rendered:?}" ); } // The opening of the filter and the pipe should stay on the same row // as part of one single-quoted span (or the whole filter on one row). let joined = rendered.join("\n"); assert!( !joined.contains(".[]\n") && !joined.contains(".[] |\n"), "jq filter split across rows: {rendered:?}" ); } #[test] fn bash_quote_aware_wrap_keeps_single_quoted_span_together() { let line = "prefix_ok_here '.[] | not a pipe' trailing_words_here_too"; // Width that forces a wrap, but only at spaces *outside* quotes. let width = 20; let rows = bash_quote_aware_wrap(line, width); let has_split_inside_quotes = rows.iter().any(|r| { // A row that opens a quote without closing it while ending at | r.contains(".[]") && !r.contains("not a pipe") }); assert!(!has_split_inside_quotes, "split inside quotes: {rows:?}"); // The full quoted token must appear wholly in some row. assert!( rows.iter().any(|r| r.contains("'.[] | not a pipe'")), "quoted span must be intact in some row: {rows:?}" ); } #[test] fn soft_wrap_does_not_break_on_heredoc_body_and() { let script = "cat < = lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.as_ref()) .collect::() }) .collect(); let body_rows: Vec<&String> = rendered .iter() .filter(|r| r.contains("foo && bar")) .collect(); assert_eq!( body_rows.len(), 1, "heredoc body must stay one row, got {rendered:?}" ); assert!( rendered .iter() .any(|r| r.contains("cat < width); let rows = soft_wrap_physical_line(line, 0, &breaks, &[], width); let first: String = rows[0].spans.iter().map(|s| s.content.as_ref()).collect(); assert!( first.contains(r#""keep && together""#), "quoted && must stay on the first row: {first:?}" ); assert!(first.contains("&&"), "real operator stays with first row"); } #[test] fn display_prefers_raw_over_rejoined_tokens() { let h = BashCommandHighlights { prefix: vec!["cd".into(), "/tmp".into(), "&&".into()], highlighted_words: vec!["git".into(), "status".into()], suffix: vec![], }; // Raw keeps the original spacing / layout; rejoined tokens would // become "cd /tmp && git status". let raw = "cd /tmp && \\\n git status"; let display = display_command_text(Some(&h), Some(raw)); assert_eq!(display, "cd /tmp && \\\n git status"); } #[test] fn map_selection_ranges_across_continuations() { let display = "cd /tmp && \\\n git status --short"; let h = BashCommandHighlights { prefix: vec!["cd".into(), "/tmp".into(), "&&".into()], highlighted_words: vec!["git".into(), "status".into(), "--short".into()], suffix: vec![], }; let ranges = map_selection_ranges(display, &h, 2).expect("map tokens"); // First two highlighted words selected: git, status let selected: Vec<&str> = ranges .iter() .filter(|(_, _, sel)| *sel) .map(|(s, e, _)| &display[*s..*e]) .collect(); assert_eq!(selected, vec!["git", "status"]); } #[test] fn short_single_line_stays_one_row() { let lines = build_raw_bash_lines("echo hello", 80); assert_eq!(lines.len(), 1); } #[test] fn heredoc_body_line_does_not_wrap_at_spaces() { // Physical heredoc-body lines must not soft-wrap on spaces. let script = "cat < width); // Find body line offset in prepared text. let body_start = prepared.find(body_line).unwrap(); let breaks = soft_break_offsets_after_operators(&prepared); let heredoc = heredoc_payload_byte_ranges(&prepared); assert!( range_fully_inside(body_start, body_start + body_line.len(), &heredoc), "body must be classified as heredoc payload" ); let rows = soft_wrap_row_texts(body_line, body_start, &breaks, &heredoc, width); assert_eq!( rows.len(), 1, "heredoc body must stay one row even when narrow: {rows:?}" ); assert_eq!(rows[0], body_line); } #[test] fn map_failure_still_dims_via_token_fallback() { // When map_selection_ranges fails, do not render fully bright. // Force a map miss with tokens that cannot appear in the raw string. let raw = "echo hello world"; let h = BashCommandHighlights { prefix: vec![], highlighted_words: vec!["definitely_not_in_raw_zzzz".into(), "also_missing".into()], suffix: vec!["extra_suffix_missing".into()], }; let lines = build_permission_bash_lines(Some(&h), 1, Some(raw), 200); assert!(!lines.is_empty()); let mut has_dim = false; let mut bright = String::new(); let mut dim = String::new(); for line in &lines { for span in &line.spans { if span.style.add_modifier.contains(Modifier::DIM) { has_dim = true; dim.push_str(span.content.as_ref()); } else { bright.push_str(span.content.as_ref()); } } } assert!(has_dim, "fallback must still dim unselected tokens"); // First highlight token selected → bright; rest dimmed. assert!( bright.contains("definitely_not_in_raw_zzzz"), "selected token bright: {bright:?}" ); assert!( dim.contains("also_missing") || dim.contains("extra_suffix_missing"), "unselected tokens dim: {dim:?}" ); } #[test] fn find_next_token_does_not_bind_later_occurrence() { // Must not attach `test` to the later `test` inside a different word. // Sequence: token "cargo" then "test" — after cargo, only in-order match. let display = "cargo latest && test --all"; // From start of "latest", looking for "test" must NOT match the "test" // suffix of "latest" via free scan — only the standalone `test` after &&. let from = display.find("latest").unwrap(); // Old buggy free-scan could match inside "latest". New path only accepts // the token at the next shell position after skipping ops/ws. // From `latest`, skip doesn't skip alnum, so match at `latest` for "test" // fails (wrong token), and we return None — whole map fails closed. assert!( find_next_token(display, from, "test").is_none(), "must not match test as a suffix of latest" ); // From after "cargo ", we get "latest" not "test". let after_cargo = display.find(' ').unwrap() + 1; assert!(find_next_token(display, after_cargo, "test").is_none()); // Correct sequential map from 0 works for the real tokens. let (s, e) = find_next_token(display, 0, "cargo").unwrap(); assert_eq!(&display[s..e], "cargo"); let (s, e) = find_next_token(display, e, "latest").unwrap(); assert_eq!(&display[s..e], "latest"); let (s, e) = find_next_token(display, e, "test").unwrap(); assert_eq!(&display[s..e], "test"); } #[test] fn dim_path_wraps_quote_aware_like_undim_path() { // Regression: the REAL overlay path has highlights (Some) + raw (Some) // with a *partial* selection (default_scope_count selects ~2 tokens), // so `needs_dim` is true. Before the fix this path used plain // whitespace `word_wrap`, splitting `jq '.[] | ...'` inside its single // quotes. It must now wrap exactly like the undimmed path. let raw = r#"gh search prs --author=@me --json number,title,url --jq '.[] | "\(.state)\t#\(.number)\t\(.url)"'"#; let h = BashCommandHighlights { prefix: vec![], highlighted_words: vec![ "gh".into(), "search".into(), "prs".into(), "--author=@me".into(), "--json".into(), "number,title,url".into(), "--jq".into(), r#".[] | "\(.state)\t#\(.number)\t\(.url)""#.into(), ], suffix: vec![], }; let width = 60; // selection_count = 2 -> partial selection -> dim path. let dim_lines = build_permission_bash_lines(Some(&h), 2, Some(raw), width); let dim_rows: Vec = dim_lines.iter().map(row_text).collect(); for r in &dim_rows { assert!( !(r.trim_end().ends_with(".[]") || r.trim_end().ends_with(".[] |")), "jq filter split inside quotes under dim path; rows={dim_rows:?}" ); } // The dimmed rows must match the undimmed wrapping row-for-row. let undim_rows: Vec = build_raw_bash_lines(raw, width) .iter() .map(row_text) .collect(); assert_eq!( dim_rows, undim_rows, "dim path must wrap identically to undim path" ); } #[test] fn dim_path_preserves_selection_dimming() { // The first `selection_count` highlighted words render *without* DIM; // everything else is dimmed. Verify dimming survives the new per-row // highlight path. let raw = "git status --short && cargo test --workspace"; let h = BashCommandHighlights { prefix: vec![], highlighted_words: vec!["git".into(), "status".into(), "--short".into()], suffix: vec![ "&&".into(), "cargo".into(), "test".into(), "--workspace".into(), ], }; // Select only `git status` (2 tokens); the rest must be dimmed. let lines = build_permission_bash_lines(Some(&h), 2, Some(raw), 200); let mut dim_text = String::new(); let mut bright_text = String::new(); for line in &lines { for span in &line.spans { if span.style.add_modifier.contains(Modifier::DIM) { dim_text.push_str(span.content.as_ref()); } else { bright_text.push_str(span.content.as_ref()); } } } assert!( bright_text.contains("git") && bright_text.contains("status"), "selected tokens must be bright: bright={bright_text:?}" ); assert!( dim_text.contains("cargo") && dim_text.contains("workspace"), "unselected suffix must be dimmed: dim={dim_text:?}" ); assert!( !bright_text.contains("cargo"), "cargo must not be bright: bright={bright_text:?}" ); } #[test] fn prepare_bash_display_normalizes_crlf() { let raw = "echo a\r\necho b\r\n"; let prepared = prepare_bash_display_text(raw); assert!( !prepared.contains('\r'), "CRLF not normalized: {prepared:?}" ); assert_eq!(prepared, "echo a\necho b"); } #[test] fn tiny_widths_do_not_panic_dim_and_undim() { // width 0 and width 1 must never panic (empty rows / mid-char indices). let raw = "git status --short && cargo test --workspace | grep ok"; let h = BashCommandHighlights { prefix: vec![], highlighted_words: vec!["git".into(), "status".into(), "--short".into()], suffix: vec!["&&".into(), "cargo".into(), "test".into()], }; for w in [0usize, 1, 2, 3] { let _ = build_raw_bash_lines(raw, w); let _ = build_permission_bash_lines(Some(&h), 2, Some(raw), w); // Multi-byte content must not panic on mid-char snapping either. let _ = build_raw_bash_lines("échø 'ünîcødé && stüff' && lß", w); } } #[test] fn dim_path_multiline_continuation_no_panic_and_dims() { let raw = "cd /tmp && \\\n git status --short --branch --verbose --long"; let h = BashCommandHighlights { prefix: vec!["cd".into(), "/tmp".into(), "&&".into()], highlighted_words: vec!["git".into(), "status".into(), "--short".into()], suffix: vec!["--branch".into(), "--verbose".into(), "--long".into()], }; // Narrow width forces wrapping of the continuation line. let lines = build_permission_bash_lines(Some(&h), 2, Some(raw), 20); assert!(!lines.is_empty()); let rows: Vec = lines.iter().map(row_text).collect(); // Delimiter soft-breaks are disabled — no row should end at `&&` solely // to start the next command on a new display line. for r in &rows { let t = r.trim_end(); assert!( !(t.ends_with("&&") && rows.len() > 1), "must not soft-break at && for display: {rows:?}" ); } } /// Human-review harness: render historic bash commands as the permission /// overlay would, to stdout at several widths. /// /// ```text /// PERMISSION_UI_RENDER_REVIEW=1 cargo test -p kigi-tui --lib \ /// render_historic_bash_commands_for_review -- --nocapture --ignored /// ``` #[test] #[ignore = "manual review harness; run with PERMISSION_UI_RENDER_REVIEW=1 --ignored --nocapture"] fn render_historic_bash_commands_for_review() { let enabled = std::env::var("PERMISSION_UI_RENDER_REVIEW") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); if !enabled { eprintln!( "skip: set PERMISSION_UI_RENDER_REVIEW=1 and pass --ignored --nocapture to run" ); return; } let fixture = historic_bash_fixture_path(); let raw = std::fs::read_to_string(&fixture) .unwrap_or_else(|e| panic!("read fixture {}: {e}", fixture.display())); let commands = parse_historic_bash_fixture(&raw); assert!( !commands.is_empty(), "no commands in fixture {}", fixture.display() ); let widths: Vec = std::env::var("PERMISSION_UI_RENDER_WIDTHS") .ok() .map(|s| s.split(',').filter_map(|p| p.trim().parse().ok()).collect()) .filter(|v: &Vec| !v.is_empty()) .unwrap_or_else(|| vec![60, 80, 100]); let mut issues: Vec = Vec::new(); println!("# Permission UI bash render review"); println!("# fixture: {}", fixture.display()); println!("# commands: {} widths: {widths:?}", commands.len()); println!(); for (idx, cmd) in commands.iter().enumerate() { let n = idx + 1; println!("{}", "=".repeat(88)); println!( "CMD {n:02} ({} bytes, {} physical lines)", cmd.len(), cmd.lines().count() ); println!("{}", "-".repeat(88)); println!("SOURCE:"); for line in cmd.lines() { println!(" | {line}"); } println!(); for &w in &widths { let rows = build_raw_bash_lines(cmd, w); let texts: Vec = rows.iter().map(line_plain_text).collect(); println!("RENDER w={w} ({} rows)", texts.len()); for (ri, t) in texts.iter().enumerate() { let vis = t.replace('\t', "\\t"); println!(" {ri:>2} │{vis}│"); // Delimiter soft-breaks are disabled — a wrap row should not // end at &&/||/| solely to start the next command. if ri + 1 < texts.len() { let trimmed = t.trim_end(); if trimmed.ends_with("&&") || trimmed.ends_with("||") || (trimmed.ends_with('|') && !trimmed.ends_with("||")) { issues.push(format!( "CMD {n:02} w={w} row {ri}: soft-break at delimiter {t:?}" )); } } } // Flag split of jq-style `.[] |` across rows (quote-break regression). for window in texts.windows(2) { let a = window[0].trim_end(); let b = window[1].trim_start(); if a.ends_with(".[]") && (b.starts_with('|') || b.starts_with(" |")) { issues.push(format!("CMD {n:02} w={w}: split at .[] | ({a:?} / {b:?})")); } if a.ends_with(".[] |") || a.ends_with(".[] | ") { issues.push(format!("CMD {n:02} w={w}: row ends at .[] | ({a:?})")); } } println!(); } } println!("{}", "=".repeat(88)); if issues.is_empty() { println!("AUTO-CHECKS: OK (no delimiter soft-breaks, no .[] | splits)"); } else { println!("AUTO-CHECKS: {} issue(s)", issues.len()); for i in &issues { println!(" - {i}"); } } // Soft-fail only on auto-check issues when running the harness. assert!( issues.is_empty(), "{} auto-check issue(s) — see stdout above", issues.len() ); } fn line_plain_text(line: &Line<'_>) -> String { line.spans.iter().map(|s| s.content.as_ref()).collect() } fn historic_bash_fixture_path() -> std::path::PathBuf { // CARGO_MANIFEST_DIR = crates/codegen/kigi-tui std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/historic_bash_cmds.txt") } fn parse_historic_bash_fixture(raw: &str) -> Vec { let mut out = Vec::new(); let mut cur: Option = None; for line in raw.lines() { if line.starts_with("### CMD ") { cur = Some(String::new()); continue; } if line == "### END" { if let Some(mut s) = cur.take() { while s.ends_with('\n') { s.pop(); } if !s.is_empty() { out.push(s); } } continue; } if let Some(ref mut s) = cur { if !s.is_empty() { s.push('\n'); } s.push_str(line); } } out } }