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:
grokkybara[bot] 2026-08-03 08:17:57 +00:00
commit 780d1388ff
323 changed files with 12258 additions and 7226 deletions

View file

@ -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(

View file

@ -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();

View file

@ -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"
);
}
}

View file

@ -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)

View file

@ -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]

View file

@ -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);
}
}