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
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue