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).
3072 lines
116 KiB
Rust
3072 lines
116 KiB
Rust
//! 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
|
||
}
|
||
}
|