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

@ -354,13 +354,17 @@ impl Default for ScrollConfig {
}
}
/// Follow indicator display mode.
/// Scroll indicator display mode: the ▼ jump-to-bottom arrow below
/// scrollback and its ▲ jump-to-response-top mirror under the sticky
/// prompt header.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FollowIndicator {
/// No follow indicator.
/// No scroll indicators.
None,
/// Show ▼ centered in the gap row below scrollback when not following
/// and there's content below the viewport.
/// and there's content below the viewport, and ▲ centered under the
/// sticky prompt header while the answer being read starts above the
/// viewport top.
#[default]
Center,
}
@ -991,8 +995,8 @@ pub struct RawScrollConfig {
/// If a scroll would be less than this percentage, scroll by this amount instead.
/// 0 = minimal scroll (default), 25 = quarter page, 100 = full page.
pub min_page_fraction: u8,
/// Follow indicator in the gap row below scrollback.
/// "none" = hidden, "center" = ▼ centered when content is below viewport.
/// Scroll indicators: the ▼ below scrollback and the ▲ under the sticky
/// prompt header. "none" = hidden, "center" = centered arrows.
pub follow_indicator: RawFollowIndicator,
/// When follow mode scrolls to new content, auto-select the latest entry.
pub follow_auto_select: bool,
@ -1019,13 +1023,13 @@ impl Default for RawScrollConfig {
}
}
/// Follow indicator display mode (TOML format).
/// Scroll indicator display mode (TOML format).
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RawFollowIndicator {
/// No follow indicator.
/// No scroll indicators.
None,
/// Show ▼ centered in the gap row below scrollback.
/// Show ▼ centered below scrollback and ▲ under the sticky prompt header.
#[default]
Center,
}

View file

@ -125,6 +125,23 @@ pub fn needs_scrollbar(total_lines: u16, viewport_lines: u16) -> bool {
total_lines > viewport_lines
}
/// The scrollbar's mouse grab zone: the track plus one column of slop on
/// each side.
///
/// Users read a thumb drawn flush against a modal border as one
/// two-column widget and press the border half (reported on macOS
/// Terminal.app and ghostty over SSH), so near-miss presses must still
/// grab the thumb.
pub fn scrollbar_grab_zone(track: Rect) -> Rect {
let x = track.x.saturating_sub(SCROLLBAR_GAP_COLS);
Rect {
x,
y: track.y,
width: (track.x - x).saturating_add(track.width).saturating_add(1),
height: track.height,
}
}
/// Whether the view is at the bottom (following mode position).
#[allow(dead_code)] // Useful helper, kept for future use
pub fn is_at_bottom(total_lines: u16, viewport_lines: u16, offset: u16) -> bool {
@ -217,53 +234,26 @@ pub fn render_scrollbar(
offset: u16,
is_following: bool,
) {
if SCROLLBARS_HIDDEN.load(Ordering::Relaxed) {
return;
}
let Some(scrollbar_area) = scrollbar_area else {
return;
};
if scrollbar_area.width == 0 || scrollbar_area.height == 0 {
return;
}
if !needs_scrollbar(total_lines, viewport_lines) {
return;
}
let lengths = ScrollLengths {
content_len: total_lines as usize,
viewport_len: viewport_lines as usize,
};
let scrollbar = ScrollBar::vertical(lengths).offset(offset as usize);
// Render into ratatui-core scratch buffer
let core_area = CoreRect {
x: scrollbar_area.x,
y: scrollbar_area.y,
width: scrollbar_area.width,
height: scrollbar_area.height,
};
let mut scratch = CoreBuffer::empty(core_area);
(&scrollbar).render(core_area, &mut scratch);
// Copy to ratatui buffer with follow-aware styling
let (track_style, thumb_style) = scrollbar_styles(is_following);
for row in 0..scrollbar_area.height {
let x = scrollbar_area.x;
let y = scrollbar_area.y + row;
let src = &scratch[(x, y)];
let dst = &mut buf[(x, y)];
if src.symbol() == " " {
dst.set_symbol(" ");
dst.set_style(track_style);
} else {
dst.set_symbol("\u{2588}");
dst.set_style(thumb_style);
}
render_scrollbar_styled(
buf,
scrollbar_area,
total_lines,
viewport_lines,
offset,
track_style,
thumb_style,
);
}
/// Some emulators (notably macOS Terminal.app) do not stretch the `█`
/// glyph over the cell's line-gap pixels, so a foreground-only thumb
/// renders striped with dark bars; the background fill covers the whole
/// cell box.
fn thumb_fill_style(thumb_style: Style) -> Style {
match thumb_style.fg {
Some(fg) => thumb_style.bg(fg),
None => thumb_style,
}
}
@ -332,6 +322,7 @@ pub fn render_scrollbar_styled(
(&scrollbar).render(core_area, &mut scratch);
// Copy to ratatui buffer with custom styling
let thumb_fill = thumb_fill_style(thumb_style);
for row in 0..scrollbar_area.height {
let x = scrollbar_area.x;
let y = scrollbar_area.y + row;
@ -342,7 +333,7 @@ pub fn render_scrollbar_styled(
dst.set_style(track_style);
} else {
dst.set_symbol("\u{2588}");
dst.set_style(thumb_style);
dst.set_style(thumb_fill);
}
}
}
@ -398,6 +389,21 @@ mod tests {
assert!(scrollbar.is_none());
}
#[test]
fn test_scrollbar_grab_zone() {
let zone = scrollbar_grab_zone(Rect::new(39, 2, 1, 10));
assert_eq!(zone, Rect::new(38, 2, 3, 10));
assert!(zone.contains((38, 2).into()), "gap column grabs");
assert!(zone.contains((39, 11).into()), "track grabs");
assert!(zone.contains((40, 5).into()), "border column grabs");
assert!(!zone.contains((37, 5).into()), "two columns left is out");
assert!(!zone.contains((41, 5).into()), "two columns right is out");
assert!(!zone.contains((39, 12).into()), "rows are bounded");
let zone = scrollbar_grab_zone(Rect::new(0, 0, 1, 4));
assert_eq!(zone, Rect::new(0, 0, 2, 4));
}
#[test]
fn test_needs_scrollbar() {
assert!(needs_scrollbar(100, 10)); // Content > viewport
@ -473,6 +479,36 @@ mod tests {
}
}
/// macOS Terminal.app leaves line-gap pixels unpainted under a
/// foreground-only `█`, striping the thumb with dark bars.
#[test]
fn test_thumb_cells_fill_background() {
let area = Rect::new(0, 0, 10, 10);
let (_, scrollbar_area) = split_area_for_scrollbar(area);
let sb = scrollbar_area.unwrap();
let track = Style::new().bg(Color::Black);
let thumb = Style::new().fg(Color::White).bg(Color::Black);
let mut buf = Buffer::empty(area);
render_scrollbar_styled(&mut buf, scrollbar_area, 100, 10, 50, track, thumb);
let mut thumb_cells = 0;
for y in 0..sb.height {
let cell = &buf[(sb.x, sb.y + y)];
if cell.symbol() == "\u{2588}" {
thumb_cells += 1;
assert_eq!(
cell.style().bg,
cell.style().fg,
"thumb cell background must match the glyph color"
);
} else {
assert_eq!(cell.style().bg, Some(Color::Black), "track keeps its bg");
}
}
assert!(thumb_cells > 0, "a thumb must be rendered");
}
#[test]
fn test_scrollbar_thumb_position() {
let area = Rect::new(0, 0, 10, 10);

View file

@ -9,6 +9,9 @@ const TMUX_QUERY_TIMEOUT: Duration = Duration::from_secs(2);
/// into a drain timeout. The main process wait still uses only
/// [`TMUX_QUERY_TIMEOUT`].
const POST_EXIT_CLEANUP_GRACE: Duration = Duration::from_millis(300);
/// How long a signalled process group may take to empty before it is killed.
const GROUP_EXIT_GRACE: Duration = Duration::from_millis(100);
const GROUP_EXIT_POLL: Duration = Duration::from_millis(1);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TmuxCommand<'a> {
@ -16,6 +19,7 @@ enum TmuxCommand<'a> {
OptionValue(&'a str),
OptionSupport(&'a str),
ControlMode,
ClientFeatures,
}
#[derive(Clone, Debug, Eq, PartialEq)]
@ -131,10 +135,24 @@ fn terminate_tmux_tree(group: &xai_tty_utils::ProcessGroup, child: &mut std::pro
let _ = child.wait();
}
/// SIGTERM the group, then escalate to SIGKILL only if it outlives the grace.
///
/// Callers reach this with the leader already reaped, so the group is usually
/// empty on the first check.
fn terminate_owned_group(group: &xai_tty_utils::ProcessGroup) {
let _ = group.terminate();
std::thread::sleep(Duration::from_millis(100));
// KILL is unconditional because leader state says nothing about descendants.
let deadline = std::time::Instant::now() + GROUP_EXIT_GRACE;
loop {
if group.has_live_members() == Some(false) {
// `return`, not `break`: the reaped leader's pid may already
// belong to an unrelated group, so an empty group gets no SIGKILL.
return;
}
if std::time::Instant::now() >= deadline {
break;
}
std::thread::sleep(GROUP_EXIT_POLL);
}
let _ = group.kill();
}
@ -186,6 +204,25 @@ fn query_option_support_with(runner: &dyn TmuxCommandRunner, option: &str) -> Tm
}
}
/// The attached client's resolved terminal features, as a comma-separated list
/// (`RGB`, `clipboard`, `focus`, …).
///
/// tmux resolves this once per client at attach time from the outer terminal's
/// terminfo plus `terminal-features` / `terminal-overrides`, and it decides
/// whether 24-bit SGR survives the multiplexer. `COLORTERM` inside the pane
/// describes only what the pane's program emits, so it cannot answer that.
///
/// Empty output means the answer is unknown rather than negative: tmux before
/// 3.2 has no `terminal-features` and renders the unknown format as an empty
/// string, and a server with no attached client has nothing to report.
pub fn query_client_features() -> TmuxQueryResult<String> {
query_client_features_with(&LiveTmuxCommandRunner)
}
fn query_client_features_with(runner: &dyn TmuxCommandRunner) -> TmuxQueryResult<String> {
parse_value(runner.run(TmuxCommand::ClientFeatures))
}
pub fn query_control_mode() -> TmuxQueryResult<bool> {
query_control_mode_with(&LiveTmuxCommandRunner)
}
@ -221,6 +258,11 @@ fn build_tmux_command(command: TmuxCommand<'_>) -> Command {
.stdout(Stdio::piped())
.stderr(Stdio::piped());
}
TmuxCommand::ClientFeatures => {
cmd.args(["display-message", "-p", "#{client_termfeatures}"])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
}
}
cmd.stdin(Stdio::null()).envs(xai_tty_utils::pager_env());
xai_tty_utils::detach_std_command(&mut cmd);
@ -283,6 +325,7 @@ mod tests {
TmuxCommand::OptionValue(option) => format!("value:{option}"),
TmuxCommand::OptionSupport(option) => format!("support:{option}"),
TmuxCommand::ControlMode => "control-mode".to_owned(),
TmuxCommand::ClientFeatures => "client-features".to_owned(),
});
self.output.clone()
}
@ -304,6 +347,10 @@ mod tests {
TmuxCommand::ControlMode,
vec!["display-message", "-p", "#{client_flags}"],
),
(
TmuxCommand::ClientFeatures,
vec!["display-message", "-p", "#{client_termfeatures}"],
),
];
for (request, args) in cases {
let cmd = build_tmux_command(request);
@ -436,4 +483,67 @@ mod tests {
assert!(output.status_success, "expected successful status");
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "tmux 3.4");
}
#[cfg(unix)]
#[test]
fn empty_group_teardown_does_not_wait_out_the_grace() {
let group = xai_tty_utils::ProcessGroup::new().expect("group");
let started = std::time::Instant::now();
terminate_owned_group(&group);
let elapsed = started.elapsed();
assert!(
elapsed < GROUP_EXIT_GRACE / 2,
"an empty group must not wait out the grace, took {elapsed:?}"
);
}
#[cfg(unix)]
#[test]
fn group_that_ignores_sigterm_waits_the_grace_and_is_killed() {
let mut group = xai_tty_utils::ProcessGroup::new().expect("group");
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg("trap '' TERM; sleep 1000")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
xai_tty_utils::detach_std_command(&mut cmd);
#[allow(clippy::disallowed_methods)] // test fixture; the test kills it
let mut child = cmd.spawn().expect("spawn sigterm-ignoring child");
group.attach_std(&child).expect("attach");
// The shell installs its trap ~0.3ms after exec. Signal before that
// and it dies to the default SIGTERM, leaving a zombie that still
// reports live — the test then passes without exercising SIGKILL.
std::thread::sleep(Duration::from_millis(250));
let started = std::time::Instant::now();
terminate_owned_group(&group);
let elapsed = started.elapsed();
assert!(
elapsed >= GROUP_EXIT_GRACE,
"an occupied group must still get the full grace, took {elapsed:?}"
);
// Bounded: without SIGKILL this fails in seconds rather than blocking
// the run on `sleep 1000`.
let reap_deadline = std::time::Instant::now() + Duration::from_secs(5);
let status = loop {
match child.try_wait().expect("poll child") {
Some(status) => break status,
None if std::time::Instant::now() < reap_deadline => {
std::thread::sleep(Duration::from_millis(10));
}
None => {
let _ = child.kill();
let _ = child.wait();
panic!("the child survived teardown, so SIGKILL never escalated");
}
}
};
assert!(
!status.success(),
"the child must have been killed, got {status:?}"
);
}
}