Synced from monorepo

Synced from monorepo

Changes:
- Workspace server: surface preview-proxy metrics through the hub metric pump
- Shell: reclaim a session’s retained state in one entry
- Shell: reclaim a session’s resident state in one entry
- Pager: withhold key event types from Alacritty builds that double keys
- Tools: cancel a session’s subagents when it closes
- Pager: keep the whole plan in scrollback and separate reasoning from output in minimal mode
- Pager: probe terminal version over DA2 and include it with feedback
- SuperGrok Plus: identity, CLI, and analytics tier surfaces
- Shell: inherit the session process scope into subagents
- Pager: build @-file-search matcher lazily on first use
- Tools: fix description and output contradictions in tool definitions
- Workspace: degrade @-file-search instead of aborting on thread exhaustion
- Tools: reap a session’s LSP servers when it closes
- Tools: fix contradictions and defects in tool descriptions, schemas, and harness pools
- MCP: reap stdio MCP children on session close
- Shell: reuse spawn-time skill discovery for session telemetry
- Tools: stop leaking shell-wrapper positional params into sourced scripts (fixes activate_conda under persistent/static shell)
- Shell: self-heal corrupt session-search SQLite cache
- Workspace: cap workspace-server tokio workers on many-core hosts
- Shell: reap a session’s child processes when it closes
- Crash handler: capture SIGABRT so panic-aborts leave crash reports
- CLI chat proxy: team-scoped Grok Code managed-config admin routes
- MCP: add CLI enable/disable for MCP servers
- Shell: cap tokio worker threads for startup thread demand
- Workspace: harden git_commit and add git_sync_base operation
- Circuit breaker: add feature-gated gRPC retry policy

Source-Revision: 2a818575225183d8ca915f5632a09b8067b5156a
This commit is contained in:
grokkybara[bot] 2026-07-28 22:50:19 +00:00
commit 5da6962e4a
192 changed files with 10337 additions and 3421 deletions

View file

@ -475,9 +475,9 @@ pub struct DashboardState {
pub peek_reply_rect: Option<Rect>,
/// Directory the reply's `@` file-search daemon is currently rooted
/// at. Tracked so [`Self::ensure_peek_reply_cwd`] can skip a
/// `retarget` (which rebuilds the daemon thread) when the peeked
/// agent's cwd hasn't actually changed. `None` = the construction
/// default (`.`); set to the launch cwd at dashboard open.
/// `retarget` (which drops the daemon so the next @-use rebuilds it)
/// when the peeked agent's cwd hasn't actually changed. `None` = the
/// construction default (`.`); set to the launch cwd at dashboard open.
peek_reply_cwd: Option<PathBuf>,
/// Cwd of the currently-peeked agent, recorded by the render pass
/// (which has the agents map). Applied lazily to the reply's `@`
@ -1919,12 +1919,12 @@ impl DashboardState {
/// Lazily root the reply's `@` file-search daemon at the peeked
/// agent's cwd (recorded in [`Self::peek_reply_target_cwd`]).
///
/// Applied only when it differs from the daemon's current root and
/// only at the moment the user composes into the reply — never on a
/// bare cursor move — because `retarget` rebuilds the matcher daemon
/// thread. So navigating past a dozen agents in other directories
/// costs nothing; the (single) retarget happens on the first
/// keystroke/paste into the reply, deduped by cwd.
/// Applied only when it differs from the daemon's current root, and only
/// when the user composes into the reply (never on a bare cursor move),
/// because `retarget` throws away the built matcher daemon and the next
/// @-use rebuilds it. So navigating past a dozen agents in other
/// directories costs nothing; the single retarget happens on the first
/// keystroke or paste into the reply, deduped by cwd.
fn ensure_peek_reply_cwd(&mut self) {
if let Some(target) = self.peek_reply_target_cwd.clone()
&& self.peek_reply_cwd.as_deref() != Some(target.as_path())

View file

@ -19,6 +19,14 @@ use super::context::{self, AtContext, normalize_display_path};
/// Top-K results to request from the fuzzy matcher.
const MATCHER_TOP_K: usize = 1000;
/// Whether a new query should restart the daemon's directory walk.
enum RestartWalk {
/// Reuse the current walk (query changed but hidden mode did not).
Keep,
/// Restart the walk, including or excluding hidden entries.
Restart { hidden: bool },
}
/// Replacement to apply to the prompt text after accepting a fuzzy result.
#[derive(Debug, Clone)]
pub struct FileSearchReplacement {
@ -28,18 +36,35 @@ pub struct FileSearchReplacement {
pub text: String,
/// Where to place the cursor after replacement.
pub cursor: usize,
/// Whether the @-context should be cleared (file accepted, not dir drill-down).
/// Whether the @-context should be cleared (an already-present directory was committed).
pub dismiss: bool,
}
/// Build accepted directory replacement text: append `/` for drill-down and a
/// trailing space when the token ends the prompt.
fn accept_text(path: &str, at_end: bool) -> String {
let mut text = path.to_owned();
text.push('/');
if at_end {
text.push(' ');
}
text
}
/// File search state for @-completion.
pub struct FileSearchState {
/// Directory the matcher walks. Mirrors the daemon's root (which is
/// otherwise moved into its worker thread) so callers can introspect
/// where `@`-completion is currently pointed.
root: PathBuf,
/// Background fuzzy matcher daemon.
daemon: FuzzyFileMatcherDaemon,
/// Background fuzzy matcher daemon, built lazily on first @-use. Eager
/// construction spawns the nucleo pool and walker threads even in sessions
/// that never open @-search; deferring it moves that thread spawn, and its
/// EAGAIN risk, to first use rather than removing it.
daemon: Option<FuzzyFileMatcherDaemon>,
/// Test-only count of daemon builds, to prove reuse (no drop-and-rebuild).
#[cfg(test)]
daemon_builds: usize,
/// Latest results snapshot from the daemon.
results: FuzzyMatcherDaemonResults,
/// Current @-context (if cursor is inside an @-token).
@ -51,7 +76,13 @@ pub struct FileSearchState {
hovered: Option<usize>,
/// Scroll offset for the dropdown list.
scroll_offset: usize,
/// Generation counter to prevent stale results from flickering in.
/// Floor for accepted result generations: the stale-result fence.
///
/// Rises monotonically and is never lowered. Each new query bumps it (see
/// `start_query`); the daemon paces its own per-tick `generation`
/// independently, so `poll` drops any snapshot whose `generation` predates
/// the floor and, on accept, raises the floor to the accepted snapshot's
/// generation. This keeps matches from a prior query from flickering in.
min_generation: usize,
/// Directory being drilled into; keeps the @-token alive when its name has
/// whitespace (`my dir`). Self-validating — applies only while the path matches.
@ -63,7 +94,9 @@ impl FileSearchState {
pub fn new(root: &Path) -> Self {
Self {
root: root.to_owned(),
daemon: FuzzyFileMatcherDaemon::new(FuzzyFileMatcher::new(root), MATCHER_TOP_K),
daemon: None,
#[cfg(test)]
daemon_builds: 0,
results: FuzzyMatcherDaemonResults::default(),
context: None,
selected: 0,
@ -74,19 +107,11 @@ impl FileSearchState {
}
}
/// Replace the underlying matcher with a new one rooted at `root`.
/// Point @-completion at a new tree (e.g. after worktree creation).
///
/// Used after worktree creation to point @-completion at the new tree.
/// Drops any built daemon; the next @-use rebuilds it lazily against `root`.
pub fn retarget(&mut self, root: &Path) {
self.root = root.to_owned();
self.daemon = FuzzyFileMatcherDaemon::new(FuzzyFileMatcher::new(root), MATCHER_TOP_K);
self.results = FuzzyMatcherDaemonResults::default();
self.context = None;
self.selected = 0;
self.hovered = None;
self.scroll_offset = 0;
self.min_generation = 0;
self.drill_prefix = None;
*self = Self::new(root);
}
/// The directory the matcher currently walks (the `@`-completion root).
@ -94,6 +119,41 @@ impl FileSearchState {
&self.root
}
/// The fuzzy matcher daemon, built lazily on first use.
///
/// The first `@`-keystroke pays a one-time cost on the UI thread: building
/// the daemon spawns the nucleo matcher pool and the directory walker.
fn ensure_daemon(&mut self) -> &mut FuzzyFileMatcherDaemon {
if self.daemon.is_none() {
let daemon =
FuzzyFileMatcherDaemon::new(FuzzyFileMatcher::new(&self.root), MATCHER_TOP_K);
self.daemon = Some(daemon);
#[cfg(test)]
{
self.daemon_builds += 1;
}
}
self.daemon.as_mut().expect("daemon built above")
}
/// Point the daemon (building it if needed) at `query`, optionally restarting
/// the directory walk, then reset dropdown selection and scroll.
///
/// The matcher never filters to directories only: a trailing `/` scopes the
/// query to a folder without hiding that folder's files.
fn start_query(&mut self, restart: RestartWalk, query: &str) {
let daemon = self.ensure_daemon();
if let RestartWalk::Restart { hidden } = restart {
daemon.restart_walk(hidden);
}
daemon.set_query(query, false);
// Advance the stale-result fence past the prior query (see `min_generation`).
self.min_generation += 1;
self.selected = 0;
self.hovered = None;
self.scroll_offset = 0;
}
// ── Visibility ──────────────────────────────────────────────────────
/// Whether the dropdown should be visible.
@ -128,13 +188,7 @@ impl FileSearchState {
/// Set the hovered index. Returns `true` if changed.
pub fn set_hovered(&mut self, index: Option<usize>) -> bool {
let clamped = index.and_then(|i| {
if i < self.results.topk.len() {
Some(i)
} else {
None
}
});
let clamped = index.filter(|&i| i < self.results.topk.len());
let changed = clamped != self.hovered;
self.hovered = clamped;
changed
@ -162,15 +216,13 @@ impl FileSearchState {
(None, Some(ctx)) => {
// Fresh `@` token is never a drill — drop any stale anchor.
self.drill_prefix = None;
// Entering @-mode: restart the directory walk.
self.daemon.restart_walk(ctx.is_hidden_mode());
// A trailing `/` scopes the query to a folder; it must not hide
// that folder's files, so never filter to directories only.
self.daemon.set_query(ctx.matcher_query(), false);
self.min_generation += 1;
self.selected = 0;
self.hovered = None;
self.scroll_offset = 0;
// Entering @-mode always restarts the walk.
self.start_query(
RestartWalk::Restart {
hidden: ctx.is_hidden_mode(),
},
ctx.matcher_query(),
);
}
(Some(old), Some(new)) => {
// Drop a stale anchor once the @-token's path content no longer
@ -184,17 +236,15 @@ impl FileSearchState {
if anchor_stale {
self.drill_prefix = None;
}
// Staying in @-mode: check if hidden mode toggled (needs re-walk).
if old.is_hidden_mode() != new.is_hidden_mode() {
self.daemon.restart_walk(new.is_hidden_mode());
}
self.daemon.set_query(new.matcher_query(), false);
self.min_generation += 1;
// Reset selection when query changes to avoid showing stale
// matches from an obscure position in the list.
self.selected = 0;
self.hovered = None;
self.scroll_offset = 0;
// Staying in @-mode only re-walks when hidden mode toggled.
let restart = if old.is_hidden_mode() != new.is_hidden_mode() {
RestartWalk::Restart {
hidden: new.is_hidden_mode(),
}
} else {
RestartWalk::Keep
};
self.start_query(restart, new.matcher_query());
}
(Some(_), None) => {
// Leaving @-mode: clear results and the drill anchor.
@ -207,6 +257,9 @@ impl FileSearchState {
}
self.context = new_ctx;
// Both @-mode arms build the daemon via `start_query`, so an active
// context implies a built daemon.
debug_assert!(self.context.is_none() || self.daemon.is_some());
}
/// Clear the context (e.g., on Esc).
@ -226,9 +279,12 @@ impl FileSearchState {
return false;
}
let results = self.daemon.get();
// Never build the daemon on the poll path: no daemon means no results yet.
let Some(daemon) = self.daemon.as_ref() else {
return false;
};
let results = daemon.get();
// Check if results actually changed (pointer comparison on Arc).
if Arc::ptr_eq(&results.topk, &self.results.topk) {
return false;
}
@ -239,7 +295,6 @@ impl FileSearchState {
if results.generation >= self.min_generation {
self.min_generation = results.generation;
self.results = results;
// Clamp selection to new result count.
if !self.results.topk.is_empty() {
self.selected = self.selected.min(self.results.topk.len() - 1);
}
@ -300,55 +355,51 @@ impl FileSearchState {
self.results.topk.get(self.selected)
}
/// Compute the text replacement for accepting the currently selected result.
/// Compute the text replacement for accepting the currently selected
/// directory (drill-down acceptance).
///
/// The `src` parameter is the full prompt text (needed to detect edge cases
/// like "replacement is a no-op" for directory drill-down).
pub fn try_replace(&mut self, src: &str) -> Option<FileSearchReplacement> {
/// Pure query. `dismiss` reports whether the caller should clear the
/// context: a directory whose `/`-append matches text already present is
/// committed (dismiss), otherwise the caller drills in and stays open. The
/// `src` parameter is the full prompt text, needed to detect that no-op
/// `/`-append.
pub fn try_replace(&self, src: &str) -> Option<FileSearchReplacement> {
let ctx = self.context.as_ref()?;
let res = self.results.topk.get(self.selected)?;
let path_str = res.path.to_string();
let mut text = normalize_display_path(&path_str).to_owned();
// Replace only the path portion of the @-token (preserving `@`
// and any hidden-mode `!` marker). See `AtContext::path_range`.
let range = ctx.path_range();
let mut cursor = range.start + text.len() + 1;
let dismiss;
if ctx.is_dir_mode() {
// Directory mode: append `/` and stay in completion for drill-down.
text = format!("{text}/");
if range.end <= src.len() && src[range.clone()] == text[..] {
// No-op replacement (same text already there) — treat as "done".
cursor += 1;
if range.end == src.len() {
text = format!("{text} ");
}
dismiss = true;
} else {
dismiss = false; // Stay in completion mode (drill-down).
}
} else {
// File mode: append trailing space if at end of input.
if range.end == src.len() {
text = format!("{text} ");
}
dismiss = true;
// Dir-only contract: this always appends `/`, so it is valid only for a
// directory chosen in dir mode. Enforce it here so a file-selection
// caller can never emit `some/file.rs/`.
if !res.is_dir || !ctx.is_dir_mode() {
return None;
}
if dismiss {
self.context = None;
self.drill_prefix = None;
// Replace only the path portion of the @-token (preserving `@` and any
// hidden-mode `!` marker). See `AtContext::path_range`.
let range = ctx.path_range();
let path = normalize_display_path(&res.path.to_string()).to_owned();
let at_end = range.end == src.len();
// A `/`-append that matches text already present commits the dir and
// dismisses; otherwise it drills in and stays open.
let no_op = src.get(range.clone()) == Some(accept_text(&path, false).as_str());
let text = accept_text(&path, no_op && at_end);
// Cursor sits just past the emitted text (after the trailing `/`).
let mut cursor = range.start + text.len();
// A committed dir that is not at the prompt end keeps its existing
// terminator (whitespace, `,`, or `;`, possibly multibyte; see
// `context::detect`); step past that one char so typing resumes after
// the directory.
if no_op && !at_end {
cursor += src[range.end..].chars().next().map_or(1, char::len_utf8);
}
Some(FileSearchReplacement {
range,
text,
range,
cursor,
dismiss,
dismiss: no_op,
})
}
@ -365,14 +416,8 @@ impl FileSearchState {
/// Test-only: install a fake context + results snapshot so tests can drive
/// acceptance flows without spinning up the background fuzzy daemon.
///
/// **Mixing with daemon polling is unsupported.** This helper assigns
/// `generation = self.min_generation` without bumping `min_generation`,
/// which means a real daemon poll occurring after `set_test_state` could
/// deliver same-generation results that overwrite the seeded fake state
/// non-deterministically. Tests that use this helper must not also drive
/// real daemon polls; if a future test needs both, bump
/// `self.min_generation` here so any in-flight daemon results are
/// rejected.
/// Bumps `min_generation` past the seeded generation so any in-flight real
/// daemon poll is rejected and cannot clobber the seeded state.
#[cfg(test)]
pub(crate) fn set_test_state(
&mut self,
@ -387,6 +432,113 @@ impl FileSearchState {
status: Default::default(),
generation: self.min_generation,
};
self.min_generation += 1;
self.selected = selected;
}
/// Test-only observable state: whether the lazy daemon has been built yet.
#[cfg(test)]
pub(crate) fn daemon_is_built(&self) -> bool {
self.daemon.is_some()
}
/// Test-only observable state: how many times the lazy daemon has been built.
#[cfg(test)]
pub(crate) fn daemon_build_count(&self) -> usize {
self.daemon_builds
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dir_result(path: &str) -> FuzzyMatchResult {
FuzzyMatchResult {
path: nucleo::Utf32String::from(path),
is_dir: true,
..Default::default()
}
}
#[test]
fn try_replace_commits_directory_already_present() {
// The selected dir's `/`-append already matches the token text, so
// acceptance commits (dismiss) rather than drilling.
let mut state = FileSearchState::new(Path::new("."));
// At the prompt end: append a trailing space so typing can continue.
let src = "@src/";
let ctx = context::detect(src, src.len()).expect("context");
state.set_test_state(ctx, vec![dir_result("src")], 0);
let r = state.try_replace(src).expect("replacement");
assert!(r.dismiss);
assert_eq!(r.range, 1..5);
assert_eq!(r.text, "src/ ");
assert_eq!(r.cursor, "@src/ ".len());
// Mid-prompt: no appended space; step past the existing terminator.
let src = "@src/ tail";
let ctx = context::detect(src, 5).expect("context");
state.set_test_state(ctx, vec![dir_result("src")], 0);
let r = state.try_replace(src).expect("replacement");
assert!(r.dismiss);
assert_eq!(r.text, "src/");
assert_eq!(r.cursor, 6);
// Mid-prompt with a multibyte terminator: step past the whole char.
let src = "@src/\u{a0}tail";
let ctx = context::detect(src, 5).expect("context");
state.set_test_state(ctx, vec![dir_result("src")], 0);
let r = state.try_replace(src).expect("replacement");
assert!(r.dismiss);
assert_eq!(r.text, "src/");
assert_eq!(r.cursor, 5 + '\u{a0}'.len_utf8());
}
#[test]
fn retarget_drops_built_daemon() {
let dir = tempfile::tempdir().expect("temp dir");
let mut state = FileSearchState::new(dir.path());
state.update_context("@alpha", "@alpha".len());
assert!(state.daemon_is_built());
state.retarget(Path::new(".."));
assert_eq!(state.root(), Path::new(".."));
assert!(!state.daemon_is_built());
}
#[test]
fn poll_does_not_build_daemon() {
let mut state = FileSearchState::new(Path::new("."));
// With no @-context, poll returns early and never touches the daemon.
assert!(!state.poll());
assert!(!state.daemon_is_built());
// With an @-context but an unbuilt daemon, poll must not force construction.
let ctx = context::detect("@foo", 4).expect("context");
state.set_test_state(ctx, Vec::new(), 0);
assert!(state.context().is_some());
assert!(!state.poll());
assert!(!state.daemon_is_built());
}
#[test]
fn daemon_is_built_lazily_on_first_use() {
let dir = tempfile::tempdir().expect("temp dir");
let mut state = FileSearchState::new(dir.path());
assert!(!state.daemon_is_built());
// The first @-search interaction builds the daemon lazily.
state.update_context("@alpha", "@alpha".len());
assert!(state.daemon_is_built());
assert!(state.context().is_some());
// A query edit stays in @-mode and reuses the same daemon: the build
// count stays at 1, proving no drop-and-rebuild.
assert_eq!(state.daemon_build_count(), 1);
state.update_context("@alpha_marker", "@alpha_marker".len());
assert!(state.daemon_is_built());
assert_eq!(state.daemon_build_count(), 1);
}
}

View file

@ -9,161 +9,418 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
/// Legal line copy — used for both render spans and mouse hit width.
const PRIVACY_BANNER_LEGAL: &str = "Learn more and read Terms and Privacy Policy.";
/// Shares its row with the buttons.
const PRIVACY_BANNER_TITLE: &str = "Help improve Grok";
/// Click target for the legal line links.
pub(crate) const PRIVACY_BANNER_LEGAL_URL: &str = "https://x.ai/legal";
const PRIVACY_BANNER_DESC: &str = "Off by default. Opt-in to allow SpaceXAI to retain coding \
data, e.g., prompts, traces, & metrics, for training and debugging purposes. Change \
anytime via settings.";
pub(crate) const PRIVACY_BANNER_TERMS_URL: &str = "https://x.ai/legal/terms-of-service";
pub(crate) const PRIVACY_BANNER_POLICY_URL: &str = "https://x.ai/legal/privacy-policy";
/// `(text, url_when_link)`.
type LegalSegment = (&'static str, Option<&'static str>);
/// Widest first; the first that fits *whole* wins. A clipped line would
/// leave hit rects over unreadable link text, and every variant keeps both
/// links so neither document becomes unreachable.
const PRIVACY_BANNER_LEGAL_VARIANTS: [&[LegalSegment]; 3] = [
&[
("Read ", None),
("Terms", Some(PRIVACY_BANNER_TERMS_URL)),
(" and ", None),
("Privacy Policy", Some(PRIVACY_BANNER_POLICY_URL)),
(".", None),
],
&[
("Terms", Some(PRIVACY_BANNER_TERMS_URL)),
(" and ", None),
("Privacy Policy", Some(PRIVACY_BANNER_POLICY_URL)),
],
&[
("Terms", Some(PRIVACY_BANNER_TERMS_URL)),
(" & ", None),
("Privacy", Some(PRIVACY_BANNER_POLICY_URL)),
],
];
const OPT_OUT_LABEL: &str = "[Opt out]";
const OPT_IN_LABEL: &str = "[Opt in]";
/// Title + legal.
const CHROME_ROWS: u16 = 2;
pub(crate) const MIN_HEIGHT: u16 = CHROME_ROWS + 1;
/// Caps banner growth on narrow terminals; overflow is elided with `…` so
/// the disclosure never looks complete when it isn't.
const MAX_BODY_ROWS: usize = 4;
/// Past this, the body abandons the button column for the full slot width:
/// a shorter banner beats a tidy right edge.
const PREFERRED_BODY_ROWS: usize = 3;
/// Hit rects returned by [`render`] for mouse handling.
pub(crate) struct PrivacyBannerRects {
pub accept: Rect,
pub customize: Rect,
pub legal: Rect,
pub opt_in: Rect,
pub opt_out: Rect,
pub terms: Rect,
pub policy: Rect,
}
/// Render the banner: copy left, `[Customize in settings]` / `[Accept]`
/// right, legal links on the second row. Needs `area.height >= 2`.
/// Hover styling mirrors the plugin CTA buttons.
impl PrivacyBannerRects {
fn none() -> Self {
Self {
opt_in: Rect::default(),
opt_out: Rect::default(),
terms: Rect::default(),
policy: Rect::default(),
}
}
}
fn button_block_width() -> u16 {
(OPT_OUT_LABEL.len() + 1 + OPT_IN_LABEL.len()) as u16
}
fn legal_width(variant: &[LegalSegment]) -> u16 {
variant.iter().map(|(text, _)| text.len() as u16).sum()
}
/// Buttons render whole or not at all, and never at the cost of the title:
/// a clipped/overflowing `[Opt in]` must not leave a click target in the
/// blank margin (a stray click there would silently opt the user in).
fn buttons_fit(area_width: u16) -> bool {
area_width >= PRIVACY_BANNER_TITLE.len() as u16 + 1 + button_block_width()
}
fn title_width(area_width: u16) -> u16 {
if buttons_fit(area_width) {
area_width - button_block_width() - 1
} else {
area_width
}
}
fn wrap_to(width: usize) -> Vec<std::borrow::Cow<'static, str>> {
if width == 0 {
return vec![];
}
let opts = textwrap::Options::new(width).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit);
textwrap::wrap(PRIVACY_BANNER_DESC, opts)
}
fn body_lines(area_width: u16) -> Vec<std::borrow::Cow<'static, str>> {
let column = wrap_to(title_width(area_width) as usize);
let mut lines = if column.len() <= PREFERRED_BODY_ROWS {
column
} else {
let full = wrap_to(area_width as usize);
if full.len() < column.len() {
full
} else {
column
}
};
if lines.len() > MAX_BODY_ROWS {
lines.truncate(MAX_BODY_ROWS);
if let Some(last) = lines.last_mut() {
let mut s = last.trim_end().to_string();
while s.chars().count() + 1 > area_width as usize {
s.pop();
}
s.push('\u{2026}');
*last = std::borrow::Cow::Owned(s);
}
}
lines
}
/// Rows needed at `width` — the body wraps, so both slot owners must size
/// from this rather than a constant.
pub(crate) fn height(width: u16) -> u16 {
CHROME_ROWS + (body_lines(width).len() as u16).max(1)
}
/// Needs `area.height >= MIN_HEIGHT`; give it [`height`] rows for the full
/// body.
pub(crate) fn render(
area: Rect,
buf: &mut Buffer,
theme: &Theme,
mouse_pos: Option<(u16, u16)>,
) -> PrivacyBannerRects {
let customize_label = "[Customize in settings]";
let accept_label = "[Accept]";
let right_w = (customize_label.len() + 1 + accept_label.len()) as u16;
// Buttons render whole or not at all: a clipped/overflowing [Accept]
// must never leave a click target in the blank margin (a stray click
// there would silently opt the user in).
let buttons_fit = area.width > right_w;
let left_w = if buttons_fit {
area.width - right_w - 1
} else {
area.width
};
let left = Rect {
x: area.x,
y: area.y,
width: left_w,
height: area.height.min(2),
};
let right = Rect {
x: area.x + left_w + 1,
y: area.y,
width: right_w,
height: 1,
};
if area.height < MIN_HEIGHT || area.width == 0 {
return PrivacyBannerRects::none();
}
let hovered = |r: Rect| {
mouse_pos.is_some_and(|(mx, my)| r.contains(ratatui::layout::Position::new(mx, my)))
};
let legal_w = if left.width as usize >= PRIVACY_BANNER_LEGAL.len() {
PRIVACY_BANNER_LEGAL.len()
} else {
"Learn more".len().min(left.width as usize)
};
// The legal line only exists when the slot really has a second row —
// otherwise its rect would make the blank row below clickable.
let legal_rect = if area.height >= 2 {
// Figma node 8698:3806.
buf.set_stringn(
area.x,
area.y,
PRIVACY_BANNER_TITLE,
title_width(area.width) as usize,
Style::default().fg(theme.text_primary),
);
let body_style = Style::default().fg(theme.gray_bright);
let body_rows = area.height - CHROME_ROWS;
let body: Vec<Line> = body_lines(area.width)
.into_iter()
.take(body_rows as usize)
.map(|l| Line::styled(l.into_owned(), body_style))
.collect();
Paragraph::new(body).render(
Rect {
x: left.x,
y: left.y.saturating_add(1),
width: legal_w as u16,
height: 1,
}
} else {
Rect::default()
};
x: area.x,
y: area.y + 1,
width: area.width,
height: body_rows,
},
buf,
);
// Figma node 8698:3806: title fg/primary, description fg/secondary,
// legal line fg/tertiary with underlined links in the same color.
// The whole legal line is one click target, so its links brighten together.
let link_fg = if hovered(legal_rect) {
theme.gray_bright
} else {
theme.gray
};
let link = Style::default()
.fg(link_fg)
.add_modifier(Modifier::UNDERLINED);
// Last row, so it gets the full width — no buttons to dodge.
let gray = Style::default().fg(theme.gray);
let title = Span::styled("Help improve Grok", Style::default().fg(theme.text_primary));
let desc = "Allow your sessions to improve SpaceXAI's models.";
// Drop trailing spans whole rather than clipping mid-word when narrow.
let line1 = if left.width as usize >= "Help improve Grok ".len() + desc.len() {
Line::from(vec![
title,
Span::raw(" "),
Span::styled(desc, Style::default().fg(theme.gray_bright)),
])
} else {
Line::from(title)
};
// Span pieces must reassemble to PRIVACY_BANNER_LEGAL.
let line2 = if left.width as usize >= PRIVACY_BANNER_LEGAL.len() {
Line::from(vec![
Span::styled("Learn more", link),
Span::styled(" and read ", gray),
Span::styled("Terms", link),
Span::styled(" and ", gray),
Span::styled("Privacy Policy", link),
Span::styled(".", gray),
])
} else {
Line::from(Span::styled("Learn more", link))
};
Paragraph::new(vec![line1, line2]).render(left, buf);
let legal_y = area.y + area.height - 1;
let mut terms_rect = Rect::default();
let mut policy_rect = Rect::default();
if let Some(variant) = PRIVACY_BANNER_LEGAL_VARIANTS
.into_iter()
.find(|v| legal_width(v) <= area.width)
{
let mut x = area.x;
let mut spans = Vec::with_capacity(variant.len());
for (text, url) in variant {
let w = text.len() as u16;
let style = match url {
None => gray,
Some(url) => {
let rect = Rect {
x,
y: legal_y,
width: w,
height: 1,
};
if *url == PRIVACY_BANNER_TERMS_URL {
terms_rect = rect;
} else {
policy_rect = rect;
}
let fg = if hovered(rect) {
theme.gray_bright
} else {
theme.gray
};
Style::default().fg(fg).add_modifier(Modifier::UNDERLINED)
}
};
spans.push(Span::styled(*text, style));
x += w;
}
Paragraph::new(Line::from(spans)).render(
Rect {
x: area.x,
y: legal_y,
width: x - area.x,
height: 1,
},
buf,
);
}
if !buttons_fit {
if !buttons_fit(area.width) {
return PrivacyBannerRects {
accept: Rect::default(),
customize: Rect::default(),
legal: legal_rect,
opt_in: Rect::default(),
opt_out: Rect::default(),
terms: terms_rect,
policy: policy_rect,
};
}
let customize_rect = Rect {
x: right.x,
y: right.y,
width: customize_label.len() as u16,
let opt_out_rect = Rect {
x: area.x + area.width - button_block_width(),
y: area.y,
width: OPT_OUT_LABEL.len() as u16,
height: 1,
};
let accept_rect = Rect {
x: right.x + customize_label.len() as u16 + 1,
y: right.y,
width: accept_label.len() as u16,
let opt_in_rect = Rect {
x: opt_out_rect.x + opt_out_rect.width + 1,
y: area.y,
width: OPT_IN_LABEL.len() as u16,
height: 1,
};
let customize_style = if hovered(customize_rect) {
let opt_out_style = if hovered(opt_out_rect) {
Style::default().fg(theme.text_primary).bg(theme.bg_hover)
} else {
Style::default().fg(theme.gray_bright)
};
let accept_style = if hovered(accept_rect) {
let opt_in_style = if hovered(opt_in_rect) {
Style::default().fg(theme.link_fg).bg(theme.bg_hover)
} else {
Style::default().fg(theme.text_primary)
};
buf.set_stringn(
customize_rect.x,
customize_rect.y,
customize_label,
customize_rect.width as usize,
customize_style,
opt_out_rect.x,
opt_out_rect.y,
OPT_OUT_LABEL,
opt_out_rect.width as usize,
opt_out_style,
);
buf.set_stringn(
accept_rect.x,
accept_rect.y,
accept_label,
accept_rect.width as usize,
accept_style,
opt_in_rect.x,
opt_in_rect.y,
OPT_IN_LABEL,
opt_in_rect.width as usize,
opt_in_style,
);
PrivacyBannerRects {
accept: accept_rect,
customize: customize_rect,
legal: legal_rect,
opt_in: opt_in_rect,
opt_out: opt_out_rect,
terms: terms_rect,
policy: policy_rect,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Render at `width` into a buffer sized by [`height`], returning the
/// rows (trailing blanks trimmed) and the hit rects.
fn draw(width: u16) -> (Vec<String>, PrivacyBannerRects) {
let h = height(width);
let area = Rect::new(0, 0, width, h);
let mut buf = Buffer::empty(area);
let rects = render(area, &mut buf, &Theme::current(), None);
let rows = (0..h)
.map(|y| {
(0..width)
.map(|x| buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "))
.collect::<String>()
.trim_end()
.to_string()
})
.collect();
(rows, rects)
}
fn rows(width: u16) -> Vec<String> {
draw(width).0
}
/// The text a legal variant reassembles to.
fn legal_text(variant: &[LegalSegment]) -> String {
variant.iter().map(|(text, _)| *text).collect()
}
/// The buffer text under `rect` on its row.
fn text_at(rows: &[String], rect: Rect) -> String {
let row = &rows[rect.y as usize];
row.chars()
.skip(rect.x as usize)
.take(rect.width as usize)
.collect()
}
/// Slot owners reserve [`height`] rows, so the last one it promises must
/// be the legal line — not a body row pushed off the end.
#[test]
fn height_reserves_every_row_the_banner_paints() {
for width in [200, 117, 110, 100, 80, 72, 60, 45, 40, 36, 30, 24, 18] {
let rows = rows(width);
assert_eq!(rows.len(), height(width) as usize);
assert!(
rows[0].starts_with(PRIVACY_BANNER_TITLE),
"width {width}: title must never be clipped, got {:?}",
rows[0]
);
let legal = rows.last().expect("legal row");
assert!(
PRIVACY_BANNER_LEGAL_VARIANTS
.iter()
.any(|v| legal_text(v) == *legal),
"width {width}: legal line must survive whole, got {legal:?}"
);
assert!(
rows[1..rows.len() - 1].iter().all(|r| !r.is_empty()),
"width {width}: body rows must not be blank: {rows:?}"
);
}
}
/// The row cap's elision is a narrow-terminal fallback, not the norm.
#[test]
fn body_copy_is_complete_at_common_widths() {
for width in [200, 117, 100, 80, 60] {
let body = rows(width)[1..].join(" ");
let flattened: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
flattened.contains(PRIVACY_BANNER_DESC),
"width {width}: body copy was truncated: {flattened:?}"
);
}
}
#[test]
fn buttons_drop_whole_when_the_row_is_too_narrow() {
let width = PRIVACY_BANNER_TITLE.len() as u16 + button_block_width(); // one short
let h = height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, h));
let rects = render(Rect::new(0, 0, width, h), &mut buf, &Theme::current(), None);
assert_eq!(rects.opt_in, Rect::default());
assert_eq!(rects.opt_out, Rect::default());
assert_ne!(rects.terms, Rect::default(), "terms link still clickable");
assert_ne!(rects.policy, Rect::default(), "policy link still clickable");
let rects = {
let width = width + 1;
let h = height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, h));
render(Rect::new(0, 0, width, h), &mut buf, &Theme::current(), None)
};
assert_eq!(rects.opt_out.width, OPT_OUT_LABEL.len() as u16);
assert_eq!(rects.opt_in.width, OPT_IN_LABEL.len() as u16);
}
#[test]
fn slot_below_min_height_arms_no_hit_rects() {
let mut buf = Buffer::empty(Rect::new(0, 0, 100, MIN_HEIGHT));
let rects = render(
Rect::new(0, 0, 100, MIN_HEIGHT - 1),
&mut buf,
&Theme::current(),
None,
);
assert_eq!(rects.opt_in, Rect::default());
assert_eq!(rects.opt_out, Rect::default());
assert_eq!(rects.terms, Rect::default());
assert_eq!(rects.policy, Rect::default());
}
/// The two links open different documents, so an off-by-one rect sends
/// the user to the wrong page.
#[test]
fn each_legal_link_hits_its_own_words() {
for width in [200, 117, 80, 60, 40, 30, 24, 18] {
let (rows, rects) = draw(width);
assert_eq!(
text_at(&rows, rects.terms),
"Terms",
"width {width}: terms rect is off its word: {rows:?}"
);
let policy = text_at(&rows, rects.policy);
assert!(
policy == "Privacy Policy" || policy == "Privacy",
"width {width}: policy rect is off its word, got {policy:?}"
);
assert!(
rects.terms.right() <= rects.policy.x,
"width {width}: link rects must not overlap"
);
}
}
}

View file

@ -2016,10 +2016,11 @@ impl PromptWidget {
// try_replace appends `/` and stays open. A file selected in dir-mode
// falls through to the ref branch (see FileSearchState::try_replace).
if let Some(r) = self.file_search.try_replace(self.textarea.text()) {
let dismiss = r.dismiss;
self.textarea.replace_range(r.range, &r.text);
self.textarea.set_cursor(r.cursor);
if !dismiss {
if r.dismiss {
self.file_search.clear_context();
} else {
// Anchor the drilled child so a whitespace name stays open
// (reuse the buffer; only a `./` prefix re-allocs).
let mut p = res.path.to_string();

View file

@ -179,8 +179,12 @@ fn handle_picking_enum(state: &mut SettingsModalState, key: &KeyEvent) -> Settin
SettingsKeyOutcome::Changed
}
// `d` reset: close picker, revert preview if applicable,
// then open the reset-confirm overlay.
KeyCode::Char('d') if key.modifiers.is_empty() => {
// then open the reset-confirm overlay. Consent choosers opt out of
// this entirely (no footer hint, no hidden shortcut) — reset stays
// reachable from the browse row.
KeyCode::Char('d')
if key.modifiers.is_empty() && !crate::settings::is_consent_chooser(setting_key) =>
{
state.transition_to_browse();
if supports_preview
&& let SettingValue::Enum(orig) = &original_value
@ -732,6 +736,14 @@ fn handle_browse(state: &mut SettingsModalState, key: &KeyEvent) -> SettingsKeyO
Some((_, meta)) if matches!(meta.kind, SettingKind::Group { .. }) => {
SettingsKeyOutcome::Unchanged
}
// A locked row isn't the user's to change, by `d` any more
// than by Enter (which `try_enter_picking_enum` refuses).
// The dispatch-time guard would catch it either way, but
// only after walking the user through a confirm dialog for
// a change that cannot happen.
Some((key, _meta)) if state.row_lock(key).is_some() => {
SettingsKeyOutcome::Unchanged
}
Some((key, _meta)) => SettingsKeyOutcome::Action(Action::OpenResetConfirm { key }),
// Focused row is a header (or out-of-bounds) — `d`
// has nothing to reset. Unchanged so the user can

View file

@ -9,7 +9,7 @@ use unicode_width::UnicodeWidthStr;
use super::state::{
CONTENT_MIN_WIDTH, MAX_THOUGHTS_WIDTH_WIDENED_MARGIN, MODAL_TITLE, RowEntry,
STANDARD_MAX_WIDTH, SettingsModalState, SettingsMode, SettingsModeKind,
TITLE_LEADING_DECORATION_W, effective_enum_choices, group_children,
TITLE_LEADING_DECORATION_W, effective_enum_choices, group_children, mode_is_consent_chooser,
};
use crate::render::line_utils::truncate_str;
use crate::settings::{
@ -122,10 +122,12 @@ pub fn render_settings_modal(
footer_lines: 2,
}
.with_compact(compact);
// Must agree with the `docs_footer_area` split below — a mismatch
// would reserve a row nothing paints (or paint into the body).
let has_tip_footer = !matches!(
state.state.mode_kind(),
SettingsModeKind::EditingString | SettingsModeKind::EditingInt
);
) && !mode_is_consent_chooser(&state.state.mode);
let footer_lines = if has_tip_footer {
modal_window::footer_lines_with_tip_gap(full_area, &sizing, shortcuts)
} else {
@ -170,6 +172,8 @@ pub fn render_settings_modal(
let (inner_area, docs_footer_area) = match state.state.mode_kind() {
SettingsModeKind::EditingString | SettingsModeKind::EditingInt => (content_area, None),
// A consent chooser shows the disclosure and the choices only.
_ if mode_is_consent_chooser(&state.state.mode) => (content_area, None),
_ => modal_window::split_content_for_tip_footer(content_area),
};
@ -2719,6 +2723,12 @@ fn render_setting_group_row(
pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'static>> {
match &state.state.mode {
SettingsMode::Browse => {
// A locked row (ZDR / team-managed) accepts neither the edit keys
// nor `d`, so it advertises neither. `→ expand` stays — that is
// how the user reads the lock reason.
let locked = state
.focused_setting()
.is_some_and(|(key, _)| state.row_lock(key).is_some());
let enter_label = match state.focused_setting() {
Some((_, meta)) if matches!(meta.kind, SettingKind::Bool { .. }) => "Enter toggle",
_ => "Enter edit",
@ -2734,16 +2744,20 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
clickable: false,
id: 0,
},
Shortcut {
];
if !locked {
shortcuts.push(Shortcut {
label: "Space toggle",
clickable: false,
id: 0,
},
Shortcut {
});
shortcuts.push(Shortcut {
label: enter_label,
clickable: false,
id: 0,
},
});
}
shortcuts.extend([
Shortcut {
label: "\u{2192} expand",
clickable: false,
@ -2754,17 +2768,19 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
clickable: false,
id: 0,
},
Shortcut {
]);
if !locked {
shortcuts.push(Shortcut {
label: "d reset",
clickable: false,
id: 0,
},
Shortcut {
label: "F2/Esc close",
clickable: false,
id: 0,
},
];
});
}
shortcuts.push(Shortcut {
label: "F2/Esc close",
clickable: false,
id: 0,
});
// Browse is nav mode (filter inactive), so append `i search` last
// (matching the shared pickers).
modal_window::push_vim_nav_search_hint(&mut shortcuts, false);
@ -2799,6 +2815,7 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
],
SettingsMode::PickingEnum {
supports_preview: sp,
key,
..
} => {
// Labels depend on whether the Enum supports live preview.
@ -2808,14 +2825,18 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
"\u{2191}/\u{2193} nav"
};
let esc_label = if *sp { "Esc revert" } else { "Esc cancel" };
vec![
let consent = crate::settings::is_consent_chooser(key);
let mut shortcuts = vec![
Shortcut {
label: nav_label,
clickable: false,
id: 0,
},
// A chooser picks one of the offered answers, so Enter
// "selects". The filter bar and the value editors, where
// Enter really does commit typed input, keep that wording.
Shortcut {
label: "Enter commit",
label: "Enter select",
clickable: false,
id: 0,
},
@ -2824,12 +2845,17 @@ pub(super) fn build_shortcuts(state: &SettingsModalState) -> Vec<Shortcut<'stati
clickable: false,
id: 0,
},
Shortcut {
];
// Consent choosers hide reset; the key is disabled there too, so
// this stays a description of what actually works on the pane.
if !consent {
shortcuts.push(Shortcut {
label: "d reset",
clickable: false,
id: 0,
},
]
});
}
shortcuts
}
SettingsMode::EditingInt { min, max, .. } => {

View file

@ -157,6 +157,14 @@ pub(super) enum SettingsMode {
},
}
/// Is the open sub-pane a [`crate::settings::is_consent_chooser`] pane?
pub(super) fn mode_is_consent_chooser(mode: &SettingsMode) -> bool {
matches!(
mode,
SettingsMode::PickingEnum { key, .. } if crate::settings::is_consent_chooser(key)
)
}
/// Settings modal state. Boxed inside `ActiveModal::Settings` to
/// avoid clippy `large_enum_variant`.
pub struct SettingsModalState {
@ -819,7 +827,7 @@ pub(super) fn setting_row_visible(
}
fn build_rows(registry: &SettingsRegistry) -> Vec<RowEntry> {
let kitty_releases = crate::app::kitty_flags_pushed();
let kitty_releases = crate::app::kitty_releases_reported();
let minimal = crate::app::minimal_mode_active();
let voice_mode = crate::app::voice_mode_enabled();
// Keys that belong to a group sub-sheet are rendered only inside that
@ -1095,7 +1103,7 @@ pub(super) fn effective_enum_choices<'a>(
choices: &'a [EnumChoice],
snapshot: &PagerLocalSnapshot,
) -> Vec<&'a EnumChoice> {
let kitty_releases = crate::app::kitty_flags_pushed();
let kitty_releases = crate::app::kitty_releases_reported();
choices
.iter()
.filter(|c| {

View file

@ -4154,14 +4154,11 @@ fn advance_next_recovers_when_selection_is_hidden() {
#[test]
fn advance_prev_recovers_when_selection_is_hidden() {
let mut s = make_state();
// Apply a filter matching only show_timestamps and simple_mode.
// "mode" matches both: compact_mode label, simple_mode label
// AND show_timestamps via... actually let's pick a more reliable
// filter — use individual keywords. "simple" matches simple_mode
// only. Let's use that and corrupt selected to compact_mode
// (hidden). Up should land on the LAST visible setting which
// is simple_mode.
s.set_query("simple");
// The filter must match exactly one setting, so the "LAST visible"
// target is unambiguous. `ascii` is a simple_mode keyword and hits
// nothing else (settings_e2e pins that). Corrupt `selected` to the
// now-hidden compact_mode; Up must land on simple_mode.
s.set_query("ascii");
let compact_idx = s
.rows
.iter()
@ -4616,8 +4613,8 @@ fn pathologically_narrow_truncates_label_with_ellipsis() {
/// Two-line rows expand `state.row_rects` to span BOTH lines so
/// mouse clicks on either line trigger the same default action.
///
/// `coding_data_sharing`: label 19 + value "Opt out" 7 + chevron
/// 2 + chrome 4 = 32 cells one-line. We render at width=28 so
/// `coding_data_sharing`'s label plus the value "Opt out", the chevron,
/// and the row chrome are far wider than the width=28 we render at, so
/// the row drops to two lines.
#[test]
fn two_line_row_hit_rect_spans_both_lines() {
@ -4685,7 +4682,7 @@ fn two_line_row_hit_rect_spans_both_lines() {
#[test]
fn two_line_row_with_expansion_renders_three_segments() {
let mut s = make_state();
// Coding data sharing's label + value (with chevron) won't
// The coding-data row's label + value (with chevron) won't
// fit on a 28-col line, forcing two-line layout.
let row_idx = s
.rows
@ -4711,11 +4708,22 @@ fn two_line_row_with_expansion_renders_three_segments() {
"expanded two-line row must allocate ≥2 lines for the row itself, got height={}",
rect.height
);
// The row label is on line 1.
// The row label is on line 1. A 28-col row truncates a long label, so
// match the head of the live copy rather than the whole string.
let label_line = buf_row_text(&buf, rect.y, area.x, area.width);
let label = s
.registry
.find("coding_data_sharing")
.expect("registered")
.label;
let head: String = label
.split_whitespace()
.take(2)
.collect::<Vec<_>>()
.join(" ");
assert!(
label_line.contains("Coding data sharing"),
"line 1 must contain the row label: {label_line:?}"
label_line.contains(&head),
"line 1 must contain the row label (head {head:?}): {label_line:?}"
);
// The value (display: "Opt out" or similar) is on line 2.
let value_line = buf_row_text(&buf, rect.y + 1, area.x, area.width);
@ -6391,6 +6399,66 @@ fn hover_breadcrumb_flips_state_and_returns_changed() {
);
}
/// `d` must be inert on a consent chooser, not merely hidden from the
/// footer.
#[test]
fn consent_chooser_drops_tip_and_reset() {
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 40,
};
let screen = |s: &mut SettingsModalState| {
let mut buf = Buffer::empty(area);
render_settings_modal(&mut buf, area, s, false, None);
(0..area.height)
.map(|y| buf_row_text(&buf, y, area.x, area.width))
.collect::<Vec<_>>()
.join("\n")
};
let mut consent = enter_picker_for("coding_data_sharing");
let text = screen(&mut consent);
assert!(
!text.contains("Ask Grok"),
"consent chooser must not render the docs tip:\n{text}"
);
assert!(
!text.contains("d reset"),
"consent chooser must not offer reset:\n{text}"
);
assert!(
text.contains("Enter select"),
"the other footer hints must survive:\n{text}"
);
let outcome = handle_settings_key(
&mut consent,
&KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
);
assert!(
matches!(outcome, SettingsKeyOutcome::Unchanged),
"`d` must be inert on a consent chooser, got {outcome:?}"
);
assert!(
matches!(consent.mode(), SettingsModalMode::PickingEnum { .. }),
"`d` must leave the chooser open, got {:?}",
consent.mode()
);
let mut ordinary = enter_picker_for("theme");
let text = screen(&mut ordinary);
assert!(
text.contains("d reset") && text.contains("Ask Grok"),
"ordinary pickers keep the tip and the reset hint:\n{text}"
);
assert!(
text.contains("Enter select") && !text.contains("Enter commit"),
"every chooser selects an answer rather than committing a value:\n{text}"
);
}
/// The row-list-with-search-bar layout reserves row 1 (below
/// the search bar) for a `─` divider in `gray_dim` — palette
/// parity.
@ -7545,6 +7613,86 @@ fn locked_coding_data_sharing_row_does_not_open_picker() {
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
}
/// `d` on a locked row must not open the confirm dialog: the dispatch-time
/// guard would refuse the reset anyway, but only after walking the user
/// through a confirmation for a change that cannot happen.
#[test]
fn locked_coding_data_sharing_row_refuses_reset() {
for lock in [
CodingDataSharingLock::Zdr,
CodingDataSharingLock::TeamManaged,
] {
let mut s = make_locked_state(lock);
s.selected = coding_data_sharing_row_idx(&s);
let out = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
);
assert!(
matches!(out, SettingsKeyOutcome::Unchanged),
"`d` on a locked row must be a no-op ({lock:?}), got {out:?}"
);
}
// Control arm: no lock → `d` still opens the confirm dialog.
let mut s = make_state();
s.selected = coding_data_sharing_row_idx(&s);
let out = handle_settings_key(
&mut s,
&KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
);
assert!(
matches!(
out,
SettingsKeyOutcome::Action(Action::OpenResetConfirm {
key: "coding_data_sharing"
})
),
"`d` on an unlocked row must still offer reset, got {out:?}"
);
}
/// `→ expand` stays on a locked row — that is how the lock reason is read.
#[test]
fn locked_row_footer_drops_the_keys_it_refuses() {
let area = Rect {
x: 0,
y: 0,
width: 120,
height: 40,
};
let screen = |s: &mut SettingsModalState| {
let mut buf = Buffer::empty(area);
render_settings_modal(&mut buf, area, s, false, None);
(0..area.height)
.map(|y| buf_row_text(&buf, y, area.x, area.width))
.collect::<Vec<_>>()
.join("\n")
};
let mut locked = make_locked_state(CodingDataSharingLock::Zdr);
locked.selected = coding_data_sharing_row_idx(&locked);
let text = screen(&mut locked);
for hint in ["d reset", "Enter edit", "Space toggle"] {
assert!(
!text.contains(hint),
"a locked row must not advertise `{hint}`:\n{text}"
);
}
assert!(
text.contains("expand"),
"`→ expand` reads the lock reason and must survive:\n{text}"
);
let mut unlocked = make_state();
unlocked.selected = coding_data_sharing_row_idx(&unlocked);
let text = screen(&mut unlocked);
assert!(
text.contains("d reset") && text.contains("Enter edit"),
"an unlocked row keeps the full footer:\n{text}"
);
}
/// Locked rows drop the `` enter-affordance and render a per-variant
/// value: ZDR replaces opt-in/out with "ZDR"; team-managed keeps the
/// value with an " · Admin Managed" suffix. Unlocked rows keep the plain
@ -7642,8 +7790,19 @@ fn locked_coding_data_sharing_expanded_description_replaces_with_reason() {
text.contains("Managed by your team admin."),
"expanded locked row must show the lock reason: {text:?}"
);
// Token from the live description so this survives copy edits.
let desc = s
.registry
.find("coding_data_sharing")
.expect("registered")
.description;
let desc_head: String = desc
.split_whitespace()
.take(3)
.collect::<Vec<_>>()
.join(" ");
assert!(
!text.contains("Controls whether"),
!text.contains(&desc_head),
"locked expansion must replace the description, not append to it: {text:?}"
);
@ -7655,7 +7814,7 @@ fn locked_coding_data_sharing_expanded_description_replaces_with_reason() {
render_rows(&mut buf, area, &mut s, &theme);
let text = flatten(&buf);
assert!(
text.contains("Controls whether"),
text.contains(&desc_head),
"expanded row must render the registry description: {text:?}"
);
assert!(

View file

@ -117,9 +117,10 @@ pub struct WelcomeRenderResult {
pub announcement_rect: Option<Rect>,
/// Hit-test rect for the promo upgrade CTA `[label]` button (click → open).
pub upgrade_cta_rect: Option<Rect>,
pub privacy_banner_accept_rect: Option<Rect>,
pub privacy_banner_customize_rect: Option<Rect>,
pub privacy_banner_legal_rect: Option<Rect>,
pub privacy_banner_opt_in_rect: Option<Rect>,
pub privacy_banner_opt_out_rect: Option<Rect>,
pub privacy_banner_terms_rect: Option<Rect>,
pub privacy_banner_policy_rect: Option<Rect>,
}
use hero_box::HERO_BOX_MIN_WIDTH;
@ -729,9 +730,10 @@ pub fn render_welcome(
announcement_truncated: false,
announcement_rect: None,
upgrade_cta_rect: None,
privacy_banner_accept_rect: None,
privacy_banner_customize_rect: None,
privacy_banner_legal_rect: None,
privacy_banner_opt_in_rect: None,
privacy_banner_opt_out_rect: None,
privacy_banner_terms_rect: None,
privacy_banner_policy_rect: None,
}
}
AuthState::Authenticating { auth_url, mode, .. } => {
@ -764,9 +766,10 @@ pub fn render_welcome(
announcement_truncated: false,
announcement_rect: None,
upgrade_cta_rect: None,
privacy_banner_accept_rect: None,
privacy_banner_customize_rect: None,
privacy_banner_legal_rect: None,
privacy_banner_opt_in_rect: None,
privacy_banner_opt_out_rect: None,
privacy_banner_terms_rect: None,
privacy_banner_policy_rect: None,
}
}
AuthState::Done if params.is_zdr_blocked => {
@ -800,9 +803,10 @@ pub fn render_welcome(
announcement_truncated: false,
announcement_rect: None,
upgrade_cta_rect: None,
privacy_banner_accept_rect: None,
privacy_banner_customize_rect: None,
privacy_banner_legal_rect: None,
privacy_banner_opt_in_rect: None,
privacy_banner_opt_out_rect: None,
privacy_banner_terms_rect: None,
privacy_banner_policy_rect: None,
}
}
// Folder-trust question: shown after auth, before any session is
@ -1719,14 +1723,18 @@ fn render_welcome_done(
});
let has_update_tip = p.pending_update_version.is_some();
let has_resume_tip = !has_update_tip && p.foreign_resume_hint.is_some();
// Tip slot precedence: pending update > privacy banner (2 rows) > resume
// hint > random tip. The update outranks the upsell so a ready update is
// never invisible; the banner takes the slot back once it's applied.
// Tip slot precedence: pending update > privacy banner (wraps, so its
// height depends on width) > resume hint > random tip. The update
// outranks the upsell so a ready update is never invisible; the banner
// takes the slot back once it's applied.
let tip_height = if !show_picker {
if has_update_tip {
1u16
} else if p.privacy_banner {
2u16
// Same inset the banner paint below uses, so the reserved rows
// and the wrapped row count can't drift.
let inset = prompt::prompt_inset(p.compact);
crate::views::privacy_banner::height(content_area.width.saturating_sub(inset * 2))
} else if has_resume_tip {
1u16
} else if let Some(tip_text) = p.tip {
@ -1941,9 +1949,10 @@ fn render_welcome_done(
// shortcuts are rendered inside the picker content area.
let mut refresh_hit_rect: Option<Rect> = None;
let mut gate_url_hit_rect: Option<Rect> = None;
let mut privacy_banner_accept_rect: Option<Rect> = None;
let mut privacy_banner_customize_rect: Option<Rect> = None;
let mut privacy_banner_legal_rect: Option<Rect> = None;
let mut privacy_banner_opt_in_rect: Option<Rect> = None;
let mut privacy_banner_opt_out_rect: Option<Rect> = None;
let mut privacy_banner_terms_rect: Option<Rect> = None;
let mut privacy_banner_policy_rect: Option<Rect> = None;
let (cursor_pos, post_flush_escapes) = if show_picker {
(None, None)
} else if !p.has_access {
@ -2071,9 +2080,10 @@ fn render_welcome_done(
height: tip_centered.height,
};
let rects = crate::views::privacy_banner::render(tip_inset, buf, theme, p.mouse_pos);
privacy_banner_accept_rect = Some(rects.accept);
privacy_banner_customize_rect = Some(rects.customize);
privacy_banner_legal_rect = Some(rects.legal);
privacy_banner_opt_in_rect = Some(rects.opt_in);
privacy_banner_opt_out_rect = Some(rects.opt_out);
privacy_banner_terms_rect = Some(rects.terms);
privacy_banner_policy_rect = Some(rects.policy);
} else if let Some(ver) = p.pending_update_version
&& layout.tip.height > 0
{
@ -2212,9 +2222,10 @@ fn render_welcome_done(
announcement_truncated,
announcement_rect,
upgrade_cta_rect,
privacy_banner_accept_rect,
privacy_banner_customize_rect,
privacy_banner_legal_rect,
privacy_banner_opt_in_rect,
privacy_banner_opt_out_rect,
privacy_banner_terms_rect,
privacy_banner_policy_rect,
}
}