Synced from monorepo
Synced from monorepo Changes: - grok-shell: send an expired external-provider credential to the sign-in flow, not a 401 loop - pager: clickable ▲ jumps to the top of the response being read - grok-shell: keep a large task log from making the completion message too long - Plan viewer scrollbar: widen grab zone to the border column; fix striped thumb in Terminal.app - pager: poll the tmux probe teardown grace instead of sleeping it - security: vendor-compat MCP kill switch is now actually enforced when reported as on - grok-shell: restore session eviction when a leader client disconnects - Bump rust-toolchain to 1.93.0 - workspace: lexical-normalize permission path patterns before glob matching - pager: reject garbage Enter in the /resume picker - pager: show Mermaid affordances in plan mode preview - pager: drop manage-account link from /session-info - workspace: auto-approve read-only git queries; defer write floor to auto classifier - Add free-form pattern editor to the "Always allow" command prompt - grok-shell: fix /btw caching - pager: Tab walks answers in the ask_user_question card - External-provider auth refresh: single 7s attempt instead of 3×5s - pager: don't resurrect finished background tasks as Running when completion arrives first - pager: report tmux truecolor clamping in Doctor - Fix plan viewer scrollbar click+drag hijacked by comment gutter - pager/shell: stop double Recap after the same last turn - sampler: preserve x-should-retry through stream collection - pager: clear plan-mode indicator immediately when the user approves a plan - pager: tmux does not re-read its config on reattach Source-Revision: 64c4de99cc822b25ce9c54ab5a4f372093d0885d
This commit is contained in:
parent
a422116582
commit
780d1388ff
323 changed files with 12258 additions and 7226 deletions
|
|
@ -24,6 +24,8 @@ use syntect::easy::HighlightLines;
|
|||
use crate::render::scrollbar::SCROLLBAR_TOTAL_COLS;
|
||||
use crate::render::wrapping::word_wrap_line;
|
||||
use crate::scrollback::blocks::markdown_content::MarkdownContent;
|
||||
use crate::scrollback::blocks::mermaid_content::{MermaidDisplay, mermaid_display};
|
||||
use crate::scrollback::render::DiagramAffordancePlacement;
|
||||
use crate::syntax::get_syntect;
|
||||
use crate::theme::Theme;
|
||||
use crate::views::list_pane::{
|
||||
|
|
@ -32,6 +34,9 @@ use crate::views::list_pane::{
|
|||
|
||||
use xai_ratatui_textarea::ElementId;
|
||||
|
||||
/// Stable ids for mermaid affordance rows (above source lines and comments).
|
||||
const MERMAID_AFFORDANCE_ID_BASE: u64 = 2_000_000;
|
||||
|
||||
// ── Line item ───────────────────────────────────────────────────────────
|
||||
|
||||
/// A single source line for the line viewer.
|
||||
|
|
@ -421,12 +426,103 @@ impl ListItem for CommentLine {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Mermaid affordance row ────────────────────────────────────────────
|
||||
|
||||
/// Blank reserved row under a Mermaid diagram; buttons are painted by the
|
||||
/// draw loop (same pattern as scrollback).
|
||||
pub struct MermaidAffordanceLine {
|
||||
item_id: u64,
|
||||
/// Fence body — data for Open / Copy path / Copy source.
|
||||
pub source: String,
|
||||
prefix: Line<'static>,
|
||||
}
|
||||
|
||||
impl MermaidAffordanceLine {
|
||||
fn new(item_id: u64, source: String, max_digits: usize) -> Self {
|
||||
let prefix = Line::from(Span::styled(
|
||||
" ".repeat(max_digits + 1),
|
||||
Style::default().fg(Theme::current().gray_dim),
|
||||
));
|
||||
Self {
|
||||
item_id,
|
||||
source,
|
||||
prefix,
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix_width(&self) -> u16 {
|
||||
crate::views::list_pane::line_display_width(&self.prefix) as u16
|
||||
}
|
||||
}
|
||||
|
||||
impl ListItem for MermaidAffordanceLine {
|
||||
fn content(&self) -> &Line<'_> {
|
||||
static EMPTY: std::sync::LazyLock<Line<'static>> = std::sync::LazyLock::new(Line::default);
|
||||
&EMPTY
|
||||
}
|
||||
|
||||
fn prefix(&self) -> Option<Line<'_>> {
|
||||
Some(self.prefix.clone())
|
||||
}
|
||||
|
||||
fn prefix_in_selection(&self) -> Option<Line<'_>> {
|
||||
Some(self.prefix.clone())
|
||||
}
|
||||
|
||||
fn prefix_cursor(&self) -> Option<Line<'_>> {
|
||||
Some(self.prefix.clone())
|
||||
}
|
||||
|
||||
fn stable_id(&self) -> u64 {
|
||||
self.item_id
|
||||
}
|
||||
|
||||
fn is_selectable(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn search_text(&self) -> &str {
|
||||
""
|
||||
}
|
||||
|
||||
fn copy_text(&self) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
1
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer, _selected: bool, _focused: bool) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
// Blank prefix only — write via cell_mut so out-of-bounds coords
|
||||
// cannot panic (Buffer::set_line indexes and panics on OOB).
|
||||
let prefix_w = self.prefix_width().min(area.width);
|
||||
let style = self
|
||||
.prefix
|
||||
.spans
|
||||
.first()
|
||||
.map(|s| s.style)
|
||||
.unwrap_or_default();
|
||||
for dx in 0..prefix_w {
|
||||
let Some(cell) = buf.cell_mut((area.x.saturating_add(dx), area.y)) else {
|
||||
break;
|
||||
};
|
||||
cell.set_char(' ');
|
||||
cell.set_style(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plan viewer item ──────────────────────────────────────────────────
|
||||
|
||||
/// A viewer item: either a source line or an inline review comment.
|
||||
/// Source line, review comment, or Mermaid affordance row.
|
||||
pub enum PlanViewerItem {
|
||||
Source(Box<SourceLine>),
|
||||
Comment(CommentLine),
|
||||
MermaidAffordance(MermaidAffordanceLine),
|
||||
}
|
||||
|
||||
impl PlanViewerItem {
|
||||
|
|
@ -434,14 +530,14 @@ impl PlanViewerItem {
|
|||
pub fn line_number(&self) -> Option<usize> {
|
||||
match self {
|
||||
Self::Source(s) => Some(s.line_number),
|
||||
Self::Comment(_) => None,
|
||||
Self::Comment(_) | Self::MermaidAffordance(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The comment ID, if this is a comment item.
|
||||
pub fn comment_id(&self) -> Option<u64> {
|
||||
match self {
|
||||
Self::Source(_) => None,
|
||||
Self::Source(_) | Self::MermaidAffordance(_) => None,
|
||||
Self::Comment(c) => Some(c.comment_id),
|
||||
}
|
||||
}
|
||||
|
|
@ -452,6 +548,7 @@ impl ListItem for PlanViewerItem {
|
|||
match self {
|
||||
Self::Source(s) => s.content(),
|
||||
Self::Comment(c) => c.content(),
|
||||
Self::MermaidAffordance(m) => m.content(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -459,6 +556,7 @@ impl ListItem for PlanViewerItem {
|
|||
match self {
|
||||
Self::Source(s) => s.prefix(),
|
||||
Self::Comment(c) => c.prefix(),
|
||||
Self::MermaidAffordance(m) => m.prefix(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -466,6 +564,7 @@ impl ListItem for PlanViewerItem {
|
|||
match self {
|
||||
Self::Source(s) => s.prefix_in_selection(),
|
||||
Self::Comment(c) => c.prefix_in_selection(),
|
||||
Self::MermaidAffordance(m) => m.prefix_in_selection(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -473,6 +572,7 @@ impl ListItem for PlanViewerItem {
|
|||
match self {
|
||||
Self::Source(s) => s.prefix_cursor(),
|
||||
Self::Comment(c) => c.prefix_cursor(),
|
||||
Self::MermaidAffordance(m) => m.prefix_cursor(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -480,17 +580,22 @@ impl ListItem for PlanViewerItem {
|
|||
match self {
|
||||
Self::Source(s) => s.stable_id(),
|
||||
Self::Comment(c) => c.stable_id(),
|
||||
Self::MermaidAffordance(m) => m.stable_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_selectable(&self) -> bool {
|
||||
true
|
||||
match self {
|
||||
Self::Source(_) | Self::Comment(_) => true,
|
||||
Self::MermaidAffordance(m) => m.is_selectable(),
|
||||
}
|
||||
}
|
||||
|
||||
fn search_text(&self) -> &str {
|
||||
match self {
|
||||
Self::Source(s) => s.search_text(),
|
||||
Self::Comment(c) => c.search_text(),
|
||||
Self::MermaidAffordance(m) => m.search_text(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -498,6 +603,7 @@ impl ListItem for PlanViewerItem {
|
|||
match self {
|
||||
Self::Source(s) => s.copy_text(),
|
||||
Self::Comment(c) => c.copy_text(),
|
||||
Self::MermaidAffordance(m) => m.copy_text(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -505,6 +611,7 @@ impl ListItem for PlanViewerItem {
|
|||
match self {
|
||||
Self::Source(s) => s.desired_height(width),
|
||||
Self::Comment(c) => c.desired_height(width),
|
||||
Self::MermaidAffordance(m) => m.desired_height(width),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -512,13 +619,14 @@ impl ListItem for PlanViewerItem {
|
|||
match self {
|
||||
Self::Source(s) => s.render(area, buf, selected, focused),
|
||||
Self::Comment(c) => c.render(area, buf, selected, focused),
|
||||
Self::MermaidAffordance(m) => m.render(area, buf, selected, focused),
|
||||
}
|
||||
}
|
||||
|
||||
fn goto_line_number(&self) -> Option<usize> {
|
||||
match self {
|
||||
Self::Source(s) => Some(s.line_number),
|
||||
Self::Comment(_) => None,
|
||||
Self::Comment(_) | Self::MermaidAffordance(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -619,6 +727,8 @@ pub struct LineViewerState {
|
|||
/// Copy of comments last applied via `rebuild_with_comments`, so that
|
||||
/// a width-triggered rebuild can re-interleave them automatically.
|
||||
last_comments: Vec<crate::views::plan_approval_view::PlanComment>,
|
||||
/// `(source_lines index to follow, diagram source)` for affordance rows.
|
||||
mermaid_after: Vec<(usize, String)>,
|
||||
/// When `true`, the viewer uses the full overlay area instead of the
|
||||
/// 75% centered popup. Toggled by Ctrl+F.
|
||||
pub fullscreen: bool,
|
||||
|
|
@ -669,6 +779,7 @@ impl LineViewerState {
|
|||
markdown_content: None,
|
||||
last_table_width: None,
|
||||
last_comments: Vec::new(),
|
||||
mermaid_after: Vec::new(),
|
||||
fullscreen: false,
|
||||
})
|
||||
}
|
||||
|
|
@ -730,6 +841,7 @@ impl LineViewerState {
|
|||
markdown_content: Some(content),
|
||||
last_table_width: None,
|
||||
last_comments: Vec::new(),
|
||||
mermaid_after: Vec::new(),
|
||||
fullscreen: false,
|
||||
})
|
||||
}
|
||||
|
|
@ -784,9 +896,11 @@ impl LineViewerState {
|
|||
}
|
||||
self.last_table_width = Some(content_width);
|
||||
|
||||
self.source_lines = build_markdown_lines(content, Some(content_width));
|
||||
let built = build_markdown_lines(content, Some(content_width));
|
||||
self.source_lines = built.source_lines;
|
||||
self.mermaid_after = built.mermaid_after;
|
||||
|
||||
if self.last_comments.is_empty() {
|
||||
if self.last_comments.is_empty() && self.mermaid_after.is_empty() {
|
||||
self.lines = self
|
||||
.source_lines
|
||||
.iter()
|
||||
|
|
@ -799,6 +913,64 @@ impl LineViewerState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Screen rects for visible Mermaid affordance rows (for paint + hit-testing).
|
||||
pub fn diagram_affordance_placements(
|
||||
&self,
|
||||
content_area: Rect,
|
||||
) -> Vec<DiagramAffordancePlacement> {
|
||||
if content_area.width == 0 || content_area.height == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let scroll = self.list_state.scroll_offset();
|
||||
let layout = self.list_state.layout();
|
||||
let visible = self.list_state.visible_range();
|
||||
if visible.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let first_vi = visible.start;
|
||||
let skip_first = self.list_state.first_item_skip_rows();
|
||||
let mut placements = Vec::new();
|
||||
|
||||
for vi in visible {
|
||||
let pi = self.list_state.to_physical(vi);
|
||||
let Some(PlanViewerItem::MermaidAffordance(m)) = self.lines.get(pi) else {
|
||||
continue;
|
||||
};
|
||||
let item_h = layout.item_height(vi);
|
||||
let skip = if vi == first_vi { skip_first } else { 0 };
|
||||
if skip >= item_h {
|
||||
continue;
|
||||
}
|
||||
// Align with list-pane layout: first visible item may be top-clipped.
|
||||
let screen_y_offset = layout
|
||||
.virtual_y(vi)
|
||||
.saturating_sub(scroll)
|
||||
.saturating_add(skip as usize);
|
||||
if screen_y_offset >= content_area.height as usize {
|
||||
continue;
|
||||
}
|
||||
let prefix_w = m.prefix_width();
|
||||
let text_w = content_area
|
||||
.width
|
||||
.saturating_sub(prefix_w)
|
||||
.saturating_sub(SCROLLBAR_TOTAL_COLS);
|
||||
if text_w == 0 {
|
||||
continue;
|
||||
}
|
||||
placements.push(DiagramAffordancePlacement {
|
||||
screen_rect: Rect {
|
||||
x: content_area.x.saturating_add(prefix_w),
|
||||
y: content_area.y.saturating_add(screen_y_offset as u16),
|
||||
width: text_w,
|
||||
height: 1,
|
||||
},
|
||||
source: m.source.clone(),
|
||||
});
|
||||
}
|
||||
placements
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn markdown_content_for_test(&self) -> Option<&str> {
|
||||
self.markdown_content.as_deref()
|
||||
|
|
@ -889,9 +1061,19 @@ impl LineViewerState {
|
|||
self.list_state.invalidate_layout();
|
||||
}
|
||||
|
||||
/// Interleave source lines with comments without updating `last_comments`.
|
||||
/// Interleave source lines with Mermaid affordance rows and comments
|
||||
/// without updating `last_comments`.
|
||||
///
|
||||
/// `mermaid_after` is document-ordered; affordances sit under the
|
||||
/// diagram art, before any comments on the same source line.
|
||||
fn interleave_comments(&mut self, comments: &[crate::views::plan_approval_view::PlanComment]) {
|
||||
let max_digits = digit_count(self.source_lines.len().max(1));
|
||||
let max_digits = digit_count(
|
||||
self.source_lines
|
||||
.last()
|
||||
.map(|s| s.line_number)
|
||||
.unwrap_or(1)
|
||||
.max(1),
|
||||
);
|
||||
|
||||
let mut sorted: Vec<_> = comments.iter().collect();
|
||||
sorted.sort_by_key(|c| c.line_range.end);
|
||||
|
|
@ -906,13 +1088,26 @@ impl LineViewerState {
|
|||
let mut items: Vec<PlanViewerItem> = Vec::new();
|
||||
let mut comment_idx = 0;
|
||||
let comment_id_base: u64 = 1_000_000;
|
||||
let mut mermaid_i = 0usize;
|
||||
|
||||
for src in &self.source_lines {
|
||||
for (src_idx, src) in self.source_lines.iter().enumerate() {
|
||||
let ln = src.line_number;
|
||||
let mut src = src.clone();
|
||||
src.commented = commented_lines.contains(&ln);
|
||||
items.push(PlanViewerItem::Source(Box::new(src)));
|
||||
|
||||
while mermaid_i < self.mermaid_after.len() && self.mermaid_after[mermaid_i].0 == src_idx
|
||||
{
|
||||
items.push(PlanViewerItem::MermaidAffordance(
|
||||
MermaidAffordanceLine::new(
|
||||
MERMAID_AFFORDANCE_ID_BASE + mermaid_i as u64,
|
||||
self.mermaid_after[mermaid_i].1.clone(),
|
||||
max_digits,
|
||||
),
|
||||
));
|
||||
mermaid_i += 1;
|
||||
}
|
||||
|
||||
while comment_idx < sorted.len() && sorted[comment_idx].line_range.end == ln + 1 {
|
||||
let c = sorted[comment_idx];
|
||||
let item_id = comment_id_base + c.id;
|
||||
|
|
@ -1032,16 +1227,27 @@ fn source_line_count(content: &str) -> usize {
|
|||
}
|
||||
}
|
||||
|
||||
struct BuiltMarkdownLines {
|
||||
source_lines: Vec<SourceLine>,
|
||||
/// Document-ordered `(source_lines index to follow, diagram source)`.
|
||||
mermaid_after: Vec<(usize, String)>,
|
||||
}
|
||||
|
||||
/// Build markdown-rendered source lines from file content.
|
||||
///
|
||||
/// Uses `MarkdownContent` to render the full document, then groups rendered
|
||||
/// lines by source line using `line_source_map`. Each source line becomes
|
||||
/// one `SourceLine` item that may span multiple visual lines (e.g., a table
|
||||
/// block renders as border + header + separator + data + border).
|
||||
fn build_markdown_lines(content: &str, max_table_width: Option<usize>) -> Vec<SourceLine> {
|
||||
///
|
||||
/// With `render_mermaid` auto/on, also anchors affordance rows under each
|
||||
/// closed mermaid fence.
|
||||
fn build_markdown_lines(content: &str, max_table_width: Option<usize>) -> BuiltMarkdownLines {
|
||||
let md = MarkdownContent::new_source_faithful(content, max_table_width);
|
||||
let pre_wrap = md.pre_wrap_lines();
|
||||
let source_map = md.line_source_map();
|
||||
let mermaid = md.mermaid_content();
|
||||
let mermaid_ranges = md.mermaid_block_ranges();
|
||||
|
||||
// Background colors come from each line's style (set by the renderer
|
||||
// for code blocks etc.). pre_wrap_lines() returns owned Lines that
|
||||
|
|
@ -1053,9 +1259,9 @@ fn build_markdown_lines(content: &str, max_table_width: Option<usize>) -> Vec<So
|
|||
let slc = source_line_count(content);
|
||||
let max_digits = digit_count(slc.max(1));
|
||||
|
||||
// Group rendered lines by source line number.
|
||||
// source_map is indexed by rendered-line index, value is 0-based source line.
|
||||
// Group by source line; track which group each pre-wrap line lands in.
|
||||
let mut groups: Vec<(usize, Vec<Line<'static>>, Vec<Option<Color>>)> = Vec::new();
|
||||
let mut prewrap_to_group: Vec<usize> = Vec::with_capacity(pre_wrap.len());
|
||||
for (rendered_idx, rendered_line) in pre_wrap.into_iter().enumerate() {
|
||||
let src_line = source_map.get(rendered_idx).copied().unwrap_or(0);
|
||||
let bg = line_bgs.get(rendered_idx).copied().flatten();
|
||||
|
|
@ -1064,11 +1270,15 @@ fn build_markdown_lines(content: &str, max_table_width: Option<usize>) -> Vec<So
|
|||
{
|
||||
last.1.push(rendered_line);
|
||||
last.2.push(bg);
|
||||
prewrap_to_group.push(groups.len() - 1);
|
||||
continue;
|
||||
}
|
||||
groups.push((src_line, vec![rendered_line], vec![bg]));
|
||||
prewrap_to_group.push(groups.len() - 1);
|
||||
}
|
||||
|
||||
// group index → source_lines index after blank-line injection.
|
||||
let mut group_to_source_idx: Vec<usize> = Vec::with_capacity(groups.len());
|
||||
let mut source_lines = Vec::new();
|
||||
let mut next_item_id = 0u64;
|
||||
let mut next_blank_src = 0usize;
|
||||
|
|
@ -1091,6 +1301,7 @@ fn build_markdown_lines(content: &str, max_table_width: Option<usize>) -> Vec<So
|
|||
}
|
||||
}
|
||||
|
||||
group_to_source_idx.push(source_lines.len());
|
||||
source_lines.push(SourceLine::new_markdown(
|
||||
next_item_id,
|
||||
src_line_0based + 1,
|
||||
|
|
@ -1120,7 +1331,31 @@ fn build_markdown_lines(content: &str, max_table_width: Option<usize>) -> Vec<So
|
|||
}
|
||||
}
|
||||
|
||||
source_lines
|
||||
let show_affordances = mermaid_display(crate::appearance::cache::load_render_mermaid())
|
||||
== MermaidDisplay::Affordances;
|
||||
let mut mermaid_after = Vec::new();
|
||||
if show_affordances {
|
||||
for (i, range) in mermaid_ranges.iter().enumerate() {
|
||||
if range.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(&group_idx) = prewrap_to_group.get(range.end - 1) else {
|
||||
continue;
|
||||
};
|
||||
let Some(&src_idx) = group_to_source_idx.get(group_idx) else {
|
||||
continue;
|
||||
};
|
||||
let Some(source) = mermaid.source(i) else {
|
||||
continue;
|
||||
};
|
||||
mermaid_after.push((src_idx, source.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
BuiltMarkdownLines {
|
||||
source_lines,
|
||||
mermaid_after,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert syntect highlighting output to a ratatui Line.
|
||||
|
|
@ -1726,7 +1961,7 @@ mod tests {
|
|||
fn source_line(item: &PlanViewerItem) -> &SourceLine {
|
||||
match item {
|
||||
PlanViewerItem::Source(source) => source,
|
||||
PlanViewerItem::Comment(_) => panic!("expected source line"),
|
||||
_ => panic!("expected source line"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1741,8 +1976,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn build_markdown_lines_preserves_blank_source_lines() {
|
||||
let lines = build_markdown_lines("# Plan\n\n- First\n\n- Second", Some(80));
|
||||
let numbered_rows: Vec<(usize, Vec<String>)> = lines
|
||||
let built = build_markdown_lines("# Plan\n\n- First\n\n- Second", Some(80));
|
||||
let numbered_rows: Vec<(usize, Vec<String>)> = built
|
||||
.source_lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
(
|
||||
|
|
@ -1766,8 +2002,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn markdown_source_blank_line_renders_as_numbered_empty_row() {
|
||||
let lines = build_markdown_lines("# Plan\n\n- First", Some(80));
|
||||
let blank = &lines[1];
|
||||
let built = build_markdown_lines("# Plan\n\n- First", Some(80));
|
||||
let blank = &built.source_lines[1];
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 1));
|
||||
|
||||
blank.render(Rect::new(0, 0, 20, 1), &mut buf, false, true);
|
||||
|
|
@ -1776,6 +2012,39 @@ mod tests {
|
|||
assert_eq!(row_text(&buf, 0), "2 ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mermaid_affordance_respects_render_setting() {
|
||||
use crate::appearance::{RenderMermaid, cache};
|
||||
|
||||
const MD: &str = "# Plan\n\n```mermaid\nflowchart TD\n A --> B\n```\n\nDone.\n";
|
||||
|
||||
cache::set_render_mermaid(RenderMermaid::On);
|
||||
let built = build_markdown_lines(MD, Some(80));
|
||||
assert_eq!(built.mermaid_after.len(), 1);
|
||||
assert!(built.mermaid_after[0].1.contains("A --> B"));
|
||||
assert!(built.mermaid_after[0].0 < built.source_lines.len());
|
||||
|
||||
let mut viewer =
|
||||
LineViewerState::open_markdown_content("plan.md", MD.to_owned(), None).unwrap();
|
||||
viewer.prepare_layout(100, 40);
|
||||
assert_eq!(
|
||||
viewer
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|i| matches!(i, PlanViewerItem::MermaidAffordance(_)))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let placements = viewer.diagram_affordance_placements(Rect::new(0, 0, 100, 40));
|
||||
assert_eq!(placements.len(), 1);
|
||||
assert_eq!(placements[0].screen_rect.height, 1);
|
||||
assert!(placements[0].screen_rect.width > 0);
|
||||
|
||||
cache::set_render_mermaid(RenderMermaid::Off);
|
||||
assert!(build_markdown_lines(MD, Some(80)).mermaid_after.is_empty());
|
||||
cache::set_render_mermaid(RenderMermaid::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_viewer_selection_uses_source_line_numbers_with_blank_rows() {
|
||||
let mut viewer = LineViewerState::open_markdown_content(
|
||||
|
|
|
|||
|
|
@ -166,6 +166,14 @@ impl ListPaneState {
|
|||
self.scrollbar_dragging
|
||||
}
|
||||
|
||||
/// Whether a mouse position lands in the scrollbar's grab zone
|
||||
/// ([`crate::render::scrollbar::scrollbar_grab_zone`]).
|
||||
pub fn scrollbar_hit(&self, column: u16, row: u16) -> bool {
|
||||
self.scrollbar_area().is_some_and(|sb| {
|
||||
crate::render::scrollbar::scrollbar_grab_zone(sb).contains((column, row).into())
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether search/filter is enabled in the config.
|
||||
pub fn is_search_enabled(&self) -> bool {
|
||||
self.config.search_enabled
|
||||
|
|
@ -2208,14 +2216,15 @@ impl ListPaneState {
|
|||
|
||||
match kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
// Scrollbar click?
|
||||
if let Some(sb) = self.scrollbar_area()
|
||||
&& column >= sb.x
|
||||
&& column < sb.x + sb.width
|
||||
{
|
||||
if self.scrollbar_hit(column, row) {
|
||||
self.scrollbar_dragging = true;
|
||||
return self.apply_scrollbar_click(row, items);
|
||||
}
|
||||
// A new press elsewhere ends any stale thumb latch (lost Up
|
||||
// from terminal coalescing / SSH / focus loss). Callers that
|
||||
// treat `is_scrollbar_dragging()` after this dispatch as
|
||||
// "this Down hit the track" rely on that.
|
||||
self.scrollbar_dragging = false;
|
||||
// Content click → select item.
|
||||
if pane_area.width > 0 && pane_area.height > 0 && row >= pane_area.y {
|
||||
let ry = (row - pane_area.y) as usize;
|
||||
|
|
@ -2252,12 +2261,7 @@ impl ListPaneState {
|
|||
items: &[T],
|
||||
) {
|
||||
// Check if mouse is on scrollbar → percentage scroll.
|
||||
if let Some(sb) = self.scrollbar_area()
|
||||
&& column >= sb.x
|
||||
&& column < sb.x + sb.width
|
||||
&& row >= sb.y
|
||||
&& row < sb.y + sb.height
|
||||
{
|
||||
if self.scrollbar_hit(column, row) {
|
||||
let total = self.total_height();
|
||||
let pct_delta = ((total as f64) * 0.0025).round() as i32;
|
||||
let effective = pct_delta.max(lines.abs()) * lines.signum();
|
||||
|
|
|
|||
|
|
@ -2775,4 +2775,42 @@ mod tests {
|
|||
assert!(state.handle_paste("a\nb", &items));
|
||||
assert_eq!(state.input_text(), "a\nb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_down_clears_stale_scrollbar_drag_latch() {
|
||||
use crossterm::event::{MouseButton, MouseEventKind};
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
let items: Vec<TestItem> = (0..40).map(TestItem::new).collect();
|
||||
let mut state = ListPaneState::new(WrapMode::NoWrap, false);
|
||||
let pane = Rect::new(0, 0, 80, 10);
|
||||
let track = Rect::new(79, 0, 1, 10);
|
||||
state.prepare_layout(&items, pane.width, pane.height);
|
||||
state.set_scrollbar_area(Some(track));
|
||||
|
||||
assert!(state.handle_mouse_event(
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
track.x,
|
||||
5,
|
||||
pane,
|
||||
&items,
|
||||
));
|
||||
assert!(
|
||||
state.is_scrollbar_dragging(),
|
||||
"press on the track must latch a thumb drag"
|
||||
);
|
||||
|
||||
// Lost Up, then a content press — latch must not stick.
|
||||
assert!(state.handle_mouse_event(
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
10,
|
||||
4,
|
||||
pane,
|
||||
&items,
|
||||
));
|
||||
assert!(
|
||||
!state.is_scrollbar_dragging(),
|
||||
"a later content Down must clear a stale scrollbar latch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,98 @@ pub enum PermissionFocus {
|
|||
/// Esc exits back to Options (prompt text is preserved).
|
||||
/// Enter submits the followup message.
|
||||
FollowupInput,
|
||||
/// User is editing a free-form "Always allow" command pattern (a glob).
|
||||
/// Entered with `e` on a bash prompt; the buffer is [`PatternEditState`].
|
||||
/// Esc discards it and returns to Options; Enter persists the pattern.
|
||||
PatternEdit,
|
||||
}
|
||||
|
||||
/// Single-line editor buffer for a free-form "Always allow" command pattern.
|
||||
///
|
||||
/// `cursor` is a byte offset into `buffer`, kept on a `char` boundary by every
|
||||
/// mutation so slicing is always valid. Content mutations set `dirty`; cursor
|
||||
/// moves do not. A confirmed grant is a glob only when dirty — unedited save
|
||||
/// is a literal prefix of the pre-filled command.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PatternEditState {
|
||||
pub buffer: String,
|
||||
pub cursor: usize,
|
||||
/// True after any content mutation (insert/delete/clear). Routes the grant
|
||||
/// to `allowed_bash_globs` when the pattern is confirmed.
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
impl PatternEditState {
|
||||
/// Start editing `initial` with the cursor at the end (clean).
|
||||
pub fn new(initial: impl Into<String>) -> Self {
|
||||
let buffer = initial.into();
|
||||
let cursor = buffer.len();
|
||||
Self {
|
||||
buffer,
|
||||
cursor,
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the user has mutated the buffer since open.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.dirty
|
||||
}
|
||||
|
||||
/// The trimmed pattern to persist, or `None` when blank.
|
||||
pub fn trimmed(&self) -> Option<&str> {
|
||||
let t = self.buffer.trim();
|
||||
(!t.is_empty()).then_some(t)
|
||||
}
|
||||
|
||||
pub fn insert_char(&mut self, ch: char) {
|
||||
self.buffer.insert(self.cursor, ch);
|
||||
self.cursor += ch.len_utf8();
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
pub fn backspace(&mut self) {
|
||||
if let Some(ch) = self.buffer[..self.cursor].chars().next_back() {
|
||||
self.cursor -= ch.len_utf8();
|
||||
self.buffer.remove(self.cursor);
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(&mut self) {
|
||||
if self.cursor < self.buffer.len() {
|
||||
self.buffer.remove(self.cursor);
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_left(&mut self) {
|
||||
if let Some(ch) = self.buffer[..self.cursor].chars().next_back() {
|
||||
self.cursor -= ch.len_utf8();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_right(&mut self) {
|
||||
if let Some(ch) = self.buffer[self.cursor..].chars().next() {
|
||||
self.cursor += ch.len_utf8();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_home(&mut self) {
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
pub fn move_end(&mut self) {
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
if !self.buffer.is_empty() {
|
||||
self.dirty = true;
|
||||
}
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Currently selected scope for an MCP "Always allow" prompt.
|
||||
|
|
@ -201,6 +293,19 @@ impl PermissionViewState {
|
|||
.as_ref()
|
||||
.is_some_and(|s| s.server_prefix.is_some())
|
||||
}
|
||||
|
||||
/// Whether this prompt offers the free-form bash pattern editor (`e`): a
|
||||
/// bash command with an `AllowAlways` row to persist the pattern to. The
|
||||
/// height reservation, the render/hint gates, and the `e` key handler must
|
||||
/// all use this so they cannot drift (a stale copy would mis-size the
|
||||
/// overlay or advertise a key that does nothing).
|
||||
pub fn has_editable_bash_pattern(&self) -> bool {
|
||||
self.bash_highlights.is_some()
|
||||
&& self
|
||||
.options
|
||||
.iter()
|
||||
.any(|o| o.kind == acp::PermissionOptionKind::AllowAlways)
|
||||
}
|
||||
}
|
||||
|
||||
/// 1-based shortcut character for the given 0-based option index.
|
||||
|
|
@ -269,9 +374,11 @@ fn permission_chrome_height(state: &PermissionViewState, content_w: usize) -> u1
|
|||
.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() {
|
||||
// Rows reserved for the hint / edit controls; must match the render below:
|
||||
// two while editing (field + preview), else one when arrows or `e` show.
|
||||
if state.focus == PermissionFocus::PatternEdit {
|
||||
h = h.saturating_add(2);
|
||||
} else if state.has_adjustable_scope() || state.has_editable_bash_pattern() {
|
||||
h = h.saturating_add(1);
|
||||
}
|
||||
h.saturating_add(1) // gap before options
|
||||
|
|
@ -419,6 +526,7 @@ pub fn render_permission_view(
|
|||
area: Rect,
|
||||
state: &PermissionViewState,
|
||||
followup_text: &str,
|
||||
pattern_edit: Option<&PatternEditState>,
|
||||
hovered_item: Option<usize>,
|
||||
theme: &Theme,
|
||||
focused: bool,
|
||||
|
|
@ -430,6 +538,8 @@ pub fn render_permission_view(
|
|||
}
|
||||
|
||||
let is_followup = state.focus == PermissionFocus::FollowupInput;
|
||||
// Editor is only active while focus is PatternEdit *and* the buffer exists.
|
||||
let pattern_edit = pattern_edit.filter(|_| state.focus == PermissionFocus::PatternEdit);
|
||||
|
||||
// Fill background — same as the focused prompt (bg_light).
|
||||
let bg = Style::default().bg(theme.bg_light);
|
||||
|
|
@ -521,8 +631,17 @@ pub fn render_permission_view(
|
|||
}
|
||||
|
||||
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;
|
||||
// Editing needs two rows (field + preview); otherwise one hint row when the
|
||||
// arrows or the `e` editor affordance is available.
|
||||
let show_edit_hint = state.has_editable_bash_pattern();
|
||||
let header_extra_h: u16 = if pattern_edit.is_some() {
|
||||
2
|
||||
} else if show_scope_hint || show_edit_hint {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let options_reserve = header_extra_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;
|
||||
|
|
@ -547,19 +666,38 @@ pub fn render_permission_view(
|
|||
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.
|
||||
if let Some(edit) = pattern_edit {
|
||||
// ── Free-form pattern editor (two rows) ──
|
||||
if y < area.y + area.height {
|
||||
render_pattern_editor_line(buf, content_x, y, content_width, edit, theme);
|
||||
y += 1;
|
||||
}
|
||||
if y < area.y + area.height {
|
||||
let command = preview_command_text(state);
|
||||
render_pattern_preview_line(buf, content_x, y, content_width, edit, &command, theme);
|
||||
y += 1;
|
||||
}
|
||||
} else if (show_scope_hint || show_edit_hint) && y < area.y + area.height {
|
||||
// Readable secondary text (accent-highlighted keys). Advertise the
|
||||
// arrows only when there's a scope to move between, but always offer
|
||||
// `e edit` on a bash prompt so the free-form option is discoverable.
|
||||
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);
|
||||
let key_style = Style::default().fg(theme.accent_user);
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
if show_scope_hint {
|
||||
spans.push(Span::styled("\u{2190} \u{2192}", key_style));
|
||||
spans.push(Span::styled(" narrow scope", hint_style));
|
||||
}
|
||||
if show_edit_hint {
|
||||
if show_scope_hint {
|
||||
spans.push(Span::styled(" \u{00b7} ", hint_style));
|
||||
}
|
||||
spans.push(Span::styled("e", key_style));
|
||||
spans.push(Span::styled(" edit pattern", hint_style));
|
||||
}
|
||||
buf.set_line(content_x, y, &Line::from(spans), content_width);
|
||||
y += 1;
|
||||
}
|
||||
|
||||
|
|
@ -691,6 +829,123 @@ pub fn render_permission_view(
|
|||
}
|
||||
}
|
||||
|
||||
/// The primary command text the session enforcer matches a bash grant against:
|
||||
/// the primary segment's words with wrappers (`timeout`/`nice`/`env`) peeled.
|
||||
/// Shared by the pattern editor's pre-fill and its live match preview so both
|
||||
/// agree with enforcement. Falls back to the raw command when untokenized.
|
||||
pub(crate) fn preview_command_text(state: &PermissionViewState) -> String {
|
||||
match state.bash_highlights.as_ref() {
|
||||
Some(h) => xai_grok_workspace::permission::bash_command_splitting::unwrap_command_wrappers(
|
||||
&h.highlighted_words,
|
||||
)
|
||||
.join(" "),
|
||||
None => state.bash_command_raw.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the single-line free-form pattern editor: an `❯ ` prompt followed by
|
||||
/// the buffer text with a block caret. Horizontally scrolls to keep the cursor
|
||||
/// visible so long patterns stay editable in a narrow overlay.
|
||||
fn render_pattern_editor_line(
|
||||
buf: &mut Buffer,
|
||||
content_x: u16,
|
||||
y: u16,
|
||||
content_width: u16,
|
||||
edit: &PatternEditState,
|
||||
theme: &Theme,
|
||||
) {
|
||||
let prompt_style = Style::default().fg(theme.accent_user);
|
||||
buf.set_span(content_x, y, &Span::styled("\u{276f} ", prompt_style), 2);
|
||||
|
||||
let text_x = content_x + 2;
|
||||
let window = content_width.saturating_sub(2) as usize;
|
||||
if window == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let chars: Vec<char> = edit.buffer.chars().collect();
|
||||
let cursor_idx = edit.buffer[..edit.cursor].chars().count();
|
||||
// Reserve one column for the caret so an end-of-line cursor is visible.
|
||||
let start = (cursor_idx + 1).saturating_sub(window);
|
||||
|
||||
let text_style = Style::default().fg(theme.text_primary);
|
||||
let caret_style = Style::default().fg(theme.bg_light).bg(theme.accent_user);
|
||||
|
||||
let end = (start + window).min(chars.len());
|
||||
let mut col: u16 = 0;
|
||||
for (offset, ch) in chars[start..end].iter().enumerate() {
|
||||
let idx = start + offset;
|
||||
let style = if idx == cursor_idx {
|
||||
caret_style
|
||||
} else {
|
||||
text_style
|
||||
};
|
||||
buf.set_span(text_x + col, y, &Span::styled(ch.to_string(), style), 1);
|
||||
col += 1;
|
||||
}
|
||||
// Block caret past the final character (cursor at end of buffer).
|
||||
if cursor_idx >= chars.len() && (col as usize) < window {
|
||||
buf.set_span(text_x + col, y, &Span::styled(" ", caret_style), 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the live preview line under the pattern editor: whether the edited
|
||||
/// pattern still matches the command being approved (reuses the real evaluator
|
||||
/// so it can't drift), a non-blocking "very broad" warning, and the key hints.
|
||||
fn render_pattern_preview_line(
|
||||
buf: &mut Buffer,
|
||||
content_x: u16,
|
||||
y: u16,
|
||||
content_width: u16,
|
||||
edit: &PatternEditState,
|
||||
command: &str,
|
||||
theme: &Theme,
|
||||
) {
|
||||
let dim = Style::default()
|
||||
.fg(theme.text_secondary)
|
||||
.add_modifier(Modifier::DIM);
|
||||
let sep = Span::styled(" \u{00b7} ", dim);
|
||||
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
match edit.trimmed() {
|
||||
None => {
|
||||
spans.push(Span::styled(
|
||||
"type a command pattern to allow (e.g. gh api repos/*)",
|
||||
dim,
|
||||
));
|
||||
}
|
||||
Some(pattern) => {
|
||||
if xai_grok_workspace::permission::bash_pattern_matches_command(pattern, command) {
|
||||
spans.push(Span::styled(
|
||||
"\u{2713} matches this command",
|
||||
Style::default().fg(theme.accent_success),
|
||||
));
|
||||
} else {
|
||||
spans.push(Span::styled(
|
||||
"\u{2717} won't match this command",
|
||||
Style::default().fg(theme.accent_error),
|
||||
));
|
||||
}
|
||||
if xai_grok_workspace::permission::bash_pattern_is_broad(pattern) {
|
||||
spans.push(sep.clone());
|
||||
spans.push(Span::styled(
|
||||
"\u{26a0} very broad",
|
||||
Style::default().fg(theme.warning),
|
||||
));
|
||||
}
|
||||
spans.push(sep);
|
||||
spans.push(Span::styled(
|
||||
"Enter",
|
||||
Style::default().fg(theme.accent_user),
|
||||
));
|
||||
spans.push(Span::styled(" save ", dim));
|
||||
spans.push(Span::styled("Esc", Style::default().fg(theme.accent_user)));
|
||||
spans.push(Span::styled(" cancel", dim));
|
||||
}
|
||||
}
|
||||
buf.set_line(content_x, y, &Line::from(spans), content_width);
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
@ -1828,6 +2083,38 @@ mod tests {
|
|||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn pattern_edit_edits_at_the_cursor() {
|
||||
let mut e = PatternEditState::new("ghapi");
|
||||
assert!(!e.is_dirty());
|
||||
assert_eq!(e.cursor, "ghapi".len()); // new() starts at the end
|
||||
e.move_home();
|
||||
e.move_right();
|
||||
e.move_right();
|
||||
assert!(!e.is_dirty(), "cursor moves are not content mutations");
|
||||
e.insert_char(' ');
|
||||
assert!(e.is_dirty());
|
||||
assert_eq!(e.buffer, "gh api");
|
||||
e.delete();
|
||||
assert_eq!(e.buffer, "gh pi");
|
||||
e.move_home();
|
||||
e.backspace(); // no-op at start
|
||||
assert_eq!((e.buffer.as_str(), e.cursor), ("gh pi", 0));
|
||||
e.clear();
|
||||
assert_eq!(e.trimmed(), None);
|
||||
assert!(e.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_edit_respects_char_boundaries() {
|
||||
let mut e = PatternEditState::new("café");
|
||||
e.backspace();
|
||||
assert_eq!(e.buffer, "caf");
|
||||
e.insert_char('é');
|
||||
assert_eq!(e.buffer, "café");
|
||||
assert!(e.is_dirty());
|
||||
}
|
||||
|
||||
fn mcp_state(tool: &str, server: Option<&str>, selected: McpScope) -> McpScopeState {
|
||||
McpScopeState {
|
||||
tool_name: tool.to_owned(),
|
||||
|
|
@ -1912,7 +2199,9 @@ mod tests {
|
|||
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);
|
||||
let _ = render_permission_view(
|
||||
&mut buf, area, &state, "", None, None, &theme, true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1936,7 +2225,7 @@ mod tests {
|
|||
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,
|
||||
&mut buf, area, &state, "follow", None, None, &theme, true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2227,7 +2516,7 @@ mod tests {
|
|||
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 _ = render_permission_view(&mut buf, area, &state, "", None, None, &theme, true);
|
||||
|
||||
let text: String = (0..area.height)
|
||||
.map(|row| {
|
||||
|
|
@ -2268,7 +2557,7 @@ mod tests {
|
|||
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);
|
||||
let _ = render_permission_view(&mut buf, area, state, "", None, None, &theme, true);
|
||||
(0..area.height)
|
||||
.map(|row| {
|
||||
(area.x..area.x + area.width)
|
||||
|
|
|
|||
|
|
@ -56,6 +56,19 @@ pub enum QuestionSelection {
|
|||
Multi(HashSet<usize>),
|
||||
}
|
||||
|
||||
/// A cursor move within one question's answer rows.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CursorMotion {
|
||||
Next,
|
||||
Prev,
|
||||
HalfPageDown,
|
||||
HalfPageUp,
|
||||
PageDown,
|
||||
PageUp,
|
||||
First,
|
||||
Last,
|
||||
}
|
||||
|
||||
/// Focus mode within the question view.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum QuestionFocus {
|
||||
|
|
@ -332,6 +345,53 @@ impl QuestionViewState {
|
|||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Move the cursor within the active question, clamped at both ends.
|
||||
pub fn move_cursor(&mut self, motion: CursorMotion) {
|
||||
let last = self.total_items(self.active_tab).saturating_sub(1);
|
||||
let cursor = self.cursor();
|
||||
let target = match motion {
|
||||
CursorMotion::Next => cursor + 1,
|
||||
CursorMotion::Prev => cursor.saturating_sub(1),
|
||||
CursorMotion::HalfPageDown => cursor + (last / 2).max(1),
|
||||
CursorMotion::HalfPageUp => cursor.saturating_sub((last.max(1) / 2).max(1)),
|
||||
CursorMotion::PageDown => cursor + last.max(1),
|
||||
CursorMotion::PageUp => cursor.saturating_sub(last.max(1)),
|
||||
CursorMotion::First => 0,
|
||||
CursorMotion::Last => last,
|
||||
};
|
||||
self.set_cursor(target.min(last));
|
||||
}
|
||||
|
||||
pub fn is_on_first_row(&self) -> bool {
|
||||
self.cursor() == 0
|
||||
}
|
||||
|
||||
pub fn is_on_last_row(&self) -> bool {
|
||||
self.cursor() + 1 >= self.total_items(self.active_tab)
|
||||
}
|
||||
|
||||
/// Whether the question at `q_idx` has any answer marked.
|
||||
pub fn has_selection(&self, q_idx: usize) -> bool {
|
||||
let option_selected = !self.selected_labels(q_idx).is_empty();
|
||||
let freeform_selected = self
|
||||
.per_question_freeform_selected
|
||||
.get(q_idx)
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
option_selected || freeform_selected
|
||||
}
|
||||
|
||||
pub fn clear_selection(&mut self, q_idx: usize) {
|
||||
match self.selections.get_mut(q_idx) {
|
||||
Some(QuestionSelection::Multi(selected)) => selected.clear(),
|
||||
Some(QuestionSelection::Single(selected)) => *selected = None,
|
||||
None => {}
|
||||
}
|
||||
if let Some(freeform_selected) = self.per_question_freeform_selected.get_mut(q_idx) {
|
||||
*freeform_selected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set cursor position for the active question, clamped to valid range.
|
||||
pub fn set_cursor(&mut self, pos: usize) {
|
||||
let max = self.total_items(self.active_tab).saturating_sub(1);
|
||||
|
|
@ -813,14 +873,7 @@ impl QuestionViewState {
|
|||
/// nothing is selected, `Esc` (which only clears the selection) has
|
||||
/// nothing to do, so it can fall through to the dashboard back-out.
|
||||
pub fn active_tab_has_selection(&self) -> bool {
|
||||
let idx = self.active_tab;
|
||||
let option_selected = !self.selected_labels(idx).is_empty();
|
||||
let freeform_selected = self
|
||||
.per_question_freeform_selected
|
||||
.get(idx)
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
option_selected || freeform_selected
|
||||
self.has_selection(self.active_tab)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -952,6 +1005,24 @@ impl QuestionViewState {
|
|||
pub fn prev_question(&mut self) {
|
||||
self.active_tab = self.active_tab.saturating_sub(1);
|
||||
}
|
||||
|
||||
/// Advance to the next question (wraps past the last, back to the first).
|
||||
pub fn wrapping_next_question(&mut self) {
|
||||
let last = self.questions.len().saturating_sub(1);
|
||||
self.active_tab = if self.active_tab < last {
|
||||
self.active_tab + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
|
||||
/// Go to the previous question (wraps before the first, round to the last).
|
||||
pub fn wrapping_prev_question(&mut self) {
|
||||
self.active_tab = match self.active_tab.checked_sub(1) {
|
||||
Some(prev) => prev,
|
||||
None => self.questions.len().saturating_sub(1),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────────
|
||||
|
|
@ -2362,6 +2433,39 @@ mod tests {
|
|||
assert_eq!(state.active_tab, 0); // clamped at start
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_question_cycling_loops_at_boundaries() {
|
||||
let qs = vec![
|
||||
make_question("Q1?", &["A"], false),
|
||||
make_question("Q2?", &["B"], false),
|
||||
];
|
||||
let mut state = QuestionViewState::new("tc".into(), qs, StashedPrompt::default());
|
||||
|
||||
state.wrapping_next_question();
|
||||
assert_eq!(state.active_tab, 1);
|
||||
state.wrapping_next_question();
|
||||
assert_eq!(
|
||||
state.active_tab, 0,
|
||||
"past the last question, back to the first"
|
||||
);
|
||||
|
||||
state.wrapping_prev_question();
|
||||
assert_eq!(
|
||||
state.active_tab, 1,
|
||||
"before the first question, round to the last"
|
||||
);
|
||||
|
||||
let mut single = QuestionViewState::new(
|
||||
"tc".into(),
|
||||
vec![make_question("Only?", &["A"], false)],
|
||||
StashedPrompt::default(),
|
||||
);
|
||||
single.wrapping_next_question();
|
||||
assert_eq!(single.active_tab, 0);
|
||||
single.wrapping_prev_question();
|
||||
assert_eq!(single.active_tab, 0);
|
||||
}
|
||||
|
||||
// ── compute_max_label_w ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -20,6 +20,17 @@ use crate::views::picker::{PickerEntry, PickerField, PickerRow, PickerState};
|
|||
/// they don't collide with fuzzy-entry indices.
|
||||
pub const CONTENT_EXPAND_OFFSET: usize = 100_000;
|
||||
|
||||
/// Session id for free-text Enter (`SubmitQuery` with no selectable rows).
|
||||
///
|
||||
/// Only a trimmed UUID is loadable — pasted garbage must not call
|
||||
/// `LoadSession` (that left the TUI stuck mid-load).
|
||||
pub fn session_id_for_direct_load(query: &str) -> Option<&str> {
|
||||
let q = query.trim();
|
||||
// `Uuid::try_parse` rejects empty, multi-line, and non-UUID text.
|
||||
uuid::Uuid::try_parse(q).ok()?;
|
||||
Some(q)
|
||||
}
|
||||
|
||||
/// Derive a short repo display name from a CWD path.
|
||||
///
|
||||
/// Uses the last 2 normal path components joined by `-`. For paths with
|
||||
|
|
@ -1730,4 +1741,16 @@ mod tests {
|
|||
Some(PickerItem::Fuzzy { original_index: 0 })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_for_direct_load_accepts_uuid_only() {
|
||||
let sid = "019fb61a-85a5-7ba0-a4ec-24647dca1893";
|
||||
assert_eq!(session_id_for_direct_load(sid), Some(sid));
|
||||
assert_eq!(session_id_for_direct_load(&format!(" {sid} ")), Some(sid));
|
||||
assert_eq!(session_id_for_direct_load("not-a-uuid"), None);
|
||||
assert_eq!(session_id_for_direct_load(""), None);
|
||||
assert_eq!(session_id_for_direct_load("pasted garbage!!!"), None);
|
||||
assert_eq!(session_id_for_direct_load("hello\nworld"), None);
|
||||
assert_eq!(session_id_for_direct_load(&format!("{sid}\nextra")), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue