Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/permission_view.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

3072 lines
116 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<PermissionViewState>` on [`AgentView`].
//! Only the **front** request is rendered and interactive — subsequent requests
//! wait in the queue. This matches the TUI's `VecDeque<PermissionRequest>`
//! 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
/// `<server>__`, 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<String>,
/// 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<acp::RequestPermissionRequest>,
/// 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<acp::PermissionOption>,
/// 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<BashCommandHighlights>,
/// 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<String>,
// -- 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<McpScopeState>,
// -- 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<String>,
/// 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<String>,
// -- 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<InlinePromptArea>,
}
/// 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
/// (`"<n> (●) "` = 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<usize>,
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<Line<'_>> =
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<usize> = 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<String> = state
.bash_highlights
.as_ref()
.map(|h| h.highlighted_words[..state.bash_selection_count].join(" "));
let mut inline_prompt_result: Option<InlinePromptArea> = 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 "<n> (●) " 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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<usize> = 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<Line<'static>>
where
I: IntoIterator<Item = &'a str>,
{
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<usize> {
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<Line<'static>> {
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<Vec<(usize, usize, bool)>> {
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<usize> {
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<Line<'static>> {
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<Span<'static>> = 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<Line<'static>> {
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<Span<'static>> = 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<Line<'static>> {
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<Line<'static>> = 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<Span<'static>>, width: usize) -> Vec<Line<'static>> {
let width = width.max(1);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut line_spans: Vec<Span<'static>> = 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<String> {
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<scope_text>)`. 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 <Server>"`.
fn dynamic_option_label(
option: &acp::PermissionOption,
selected_words: Option<&str>,
mcp_scope: Option<&McpScopeState>,
) -> (String, Option<String>) {
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::<McpToolPermission>(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::<BashCommandPermission>(
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<acp::PermissionOption> = (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 <server>__* 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<McpScopeState>) -> 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<char> = text.chars().collect();
let spans: Vec<Span<'static>> = 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::<String>(), 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<String> = 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<String> = 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::<String>()
+ "\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::<String>()
+ "\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::<String>()
})
.collect::<Vec<_>>()
.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<String> = 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 <<EOF && echo after\nfoo && bar inside body\nEOF";
let lines = build_raw_bash_lines(script, 80);
let rendered: Vec<String> = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.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 <<EOF") && r.contains("&&")),
"opener with real && should render: {rendered:?}"
);
}
#[test]
fn soft_wrap_does_not_break_on_quoted_and() {
let line = r#"echo "keep && together" && echo next"#;
let breaks = soft_break_offsets_after_operators(line);
assert_eq!(breaks.len(), 1, "breaks={breaks:?}");
let width = 28;
assert!(UnicodeWidthStr::width(line) > 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 <<EOF && echo after\nthis is a very long heredoc body line with many spaces that would otherwise wrap\nEOF";
let prepared = prepare_bash_display_text(script);
let body_line = prepared
.lines()
.find(|l| l.contains("very long heredoc"))
.expect("body line");
let width = 20;
assert!(UnicodeWidthStr::width(body_line) > 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<String> = 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<String> = 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<String> = 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<usize> = std::env::var("PERMISSION_UI_RENDER_WIDTHS")
.ok()
.map(|s| s.split(',').filter_map(|p| p.trim().parse().ok()).collect())
.filter(|v: &Vec<usize>| !v.is_empty())
.unwrap_or_else(|| vec![60, 80, 100]);
let mut issues: Vec<String> = 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<String> = 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<String> {
let mut out = Vec::new();
let mut cur: Option<String> = 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
}
}