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

@ -271,7 +271,8 @@ During a session, the active method handles all mid-session refreshes.
## Related settings
`/privacy` does not change these config knobs:
Coding-data sharing — **Coding data, retention, and training** in Settings,
which `/privacy` opens — does not change these config knobs:
| Setting | How to set it |
|---------|---------------|
@ -279,10 +280,11 @@ During a session, the active method handles all mid-session refreshes.
| `[telemetry] trace_upload` | `config.toml` or `GROK_TELEMETRY_TRACE_UPLOAD` |
| External OpenTelemetry | `GROK_EXTERNAL_OTEL` / `[telemetry] otel_*`. See [Monitoring Usage](24-monitoring-usage.md). |
On team accounts, only a team admin can toggle privacy with `/privacy`.
On team accounts, only a team admin can change coding-data sharing.
Team admins can also enable or disable Zero Data Retention (ZDR) for their team.
See [How to enable ZDR](https://docs.x.ai/developers/faq/security#how-to-enable-zdr).
When ZDR is on, `/privacy` cannot change coding-data sharing.
When ZDR is on, coding-data sharing cannot be changed at all — the settings
row shows `ZDR` in place of the value.
See [Monitoring Usage](24-monitoring-usage.md#related-settings) and [Configuration](05-configuration.md#telemetry).

View file

@ -396,15 +396,14 @@ View credit usage or manage billing. Alias: `/cost`.
### `/privacy`
Show or toggle privacy and data-retention status.
Open Settings on **Coding data, retention, and training**, where you choose
**Opt in** or **Opt out**. Takes no arguments.
```
/privacy
/privacy opt-in
/privacy opt-out
```
`/privacy` doesn't touch `[features] telemetry`, `trace_upload`, or your external OTEL settings — see [Monitoring Usage](24-monitoring-usage.md#related-settings). On team accounts, only a team admin can toggle privacy this way, and admins can also enable or disable Zero Data Retention for the team ([how to enable ZDR](https://docs.x.ai/developers/faq/security#how-to-enable-zdr)).
This setting doesn't touch `[features] telemetry`, `trace_upload`, or your external OTEL settings — see [Monitoring Usage](24-monitoring-usage.md#related-settings). On team accounts only a team admin can change it, and admins can also enable or disable Zero Data Retention for the team ([how to enable ZDR](https://docs.x.ai/developers/faq/security#how-to-enable-zdr)). When the choice isn't yours to make, the row says so — `ZDR` or `· Admin Managed` — instead of opening the chooser.
---

View file

@ -486,7 +486,7 @@ Keyboard shortcuts are **not** configurable — all bindings are built in. See [
These are independent knobs (see [Monitoring Usage](24-monitoring-usage.md#related-settings)):
- **`[features] telemetry`** / `GROK_TELEMETRY_ENABLED` — the product-analytics master switch. `/privacy` doesn't change it.
- **`/privacy`** / Settings — coding-data sharing, separate from telemetry.
- **Coding data, retention, and training** — the Settings row `/privacy` opens; coding-data sharing, separate from telemetry.
- **`[telemetry] trace_upload`** / `GROK_TELEMETRY_TRACE_UPLOAD` — session traces; follows telemetry when unset.
- **`[telemetry] otel_*`** / `GROK_EXTERNAL_OTEL` — external OTEL to your own collector (below).

View file

@ -109,6 +109,10 @@ grok mcp add --transport sse linear https://mcp.linear.app/sse
# Remove a server
grok mcp remove github
# Enable or disable a local/TOML (or compat-sourced) server
grok mcp enable github
grok mcp disable github
# Diagnose a server's configuration and connectivity
grok mcp doctor # Check every configured server
grok mcp doctor github # Check one server
@ -117,10 +121,16 @@ grok mcp doctor --json # Machine-readable output
The transport defaults to `stdio`; pass `--transport http` or `--transport sse` for remote servers.
By default `grok mcp add` writes to `~/.grok/config.toml` (`--scope user`). Use `--scope project` to write to `.grok/config.toml` in the current directory instead, which can be committed and shared with your team (see [Project-Scoped MCP Servers](#project-scoped-mcp-servers)). Header and environment variable values are stored verbatim, so reference secrets as `${VAR}` instead of pasting them into a committed project config (see [Example Configurations](#example-configurations)). `grok mcp list` shows servers from both scopes, marking project-scoped ones with `(project)`.
By default `grok mcp add` writes to `~/.grok/config.toml` (`--scope user`). Use `--scope project` to write to `.grok/config.toml` in the current directory instead, which can be committed and shared with your team (see [Project-Scoped MCP Servers](#project-scoped-mcp-servers)). Header and environment variable values are stored verbatim, so reference secrets as `${VAR}` instead of pasting them into a committed project config (see [Example Configurations](#example-configurations)). `grok mcp list` shows servers from both scopes, marking project-scoped ones with `(project)` and disabled ones with `(disabled)`.
`grok mcp remove` searches both scopes and exits 0 after removing the server. It exits 1 when the name is not found, or when the name is defined in both user and project scope — pass `--scope` to say which one to remove.
`grok mcp enable` / `disable` persist the personal on/off state to user `~/.grok/config.toml` (`disabled_mcp_servers`, and `[mcp_servers.<name>].enabled` when that entry exists). Scope:
- **Known names:** user/project Grok TOML, names already on the disabled list, compat sources (`.mcp.json`, Claude, Cursor), **plugin** MCP servers (same discovery as doctor/`/mcps`), and legacy managed `grok_com_*` (no local entry required).
- **Enable only:** if the cwd-nearest project definition has sticky `enabled = false`, that single key is cleared (comments preserved); disable never rewrites project configs.
- **Not full `/mcps` parity:** gateway connectors (`managed_gateway:…`, stored under `disabled_mcp_tools.__managed_gateway_connectors`) stay Space-only in the TUI. Idempotent; unknown names exit 1.
Breaking changes from earlier releases: `--env` now takes one `KEY=value` per flag (use `-e A=1 -e B=2`, not `--env A=1 B=2`), and server names may only contain letters, numbers, hyphens, and underscores.
---
@ -171,7 +181,7 @@ MCP tools are namespaced with the server name to avoid collisions:
## Toggle Servers at Runtime
You can enable or disable MCP servers during a session without restarting Grok.
You can enable or disable MCP servers without restarting Grok (TUI `/mcps` or CLI — see [CLI Management](#cli-management)).
### The /mcps Modal

View file

@ -16,7 +16,7 @@ These knobs are independent of each other (and of this guide's external OTEL str
| Setting | How to set it |
|---------|---------------|
| Telemetry master switch | `[features] telemetry` / `GROK_TELEMETRY_ENABLED` |
| `/privacy` | `/privacy opt-in` / `/privacy opt-out`, or Settings |
| Coding data, retention, and training | Settings — `/privacy` opens the row |
| Trace upload | `[telemetry] trace_upload` / `GROK_TELEMETRY_TRACE_UPLOAD` |
| External OpenTelemetry | `GROK_EXTERNAL_OTEL` / `[telemetry] otel_*` (this guide) |

View file

@ -595,14 +595,15 @@ pub enum Action {
/// Open the settings modal (F2, `/settings`, command palette).
/// If already open, closes it instead of stacking.
OpenSettings,
/// Open settings focused on a registry key (e.g. privacy banner Customize).
/// Open settings on a registry key: its chooser, or the browse row when
/// the setting is locked.
OpenSettingsFocus {
key: &'static str,
},
/// Welcome privacy banner Accept (opt-in; ack after ACP success).
PrivacyBannerAccept,
/// Welcome privacy banner Customize (ack + open settings on coding_data_sharing).
PrivacyBannerCustomize,
/// Privacy banner `[Opt in]` (ack only after ACP success).
PrivacyBannerOptIn,
/// Privacy banner `[Opt out]` (ack now, then record the decline).
PrivacyBannerOptOut,
/// Open the command palette (`/help`). The keybinding path (Ctrl+P) opens it
/// directly in `handle_agent_action`; this lets a slash command reach the
/// same modal through dispatch.
@ -735,8 +736,6 @@ pub enum Action {
TriggerDeepSearch,
/// Force an immediate deep content search, skipping the debounce.
ForceDeepSearch,
/// Show privacy and data retention status.
ShowPrivacyInfo,
SetCodingDataSharing {
opted_in: bool,
},
@ -1994,6 +1993,11 @@ pub enum Effect {
opted_in: bool,
/// Pre-toggle value to revert to on failure.
rollback_to_opted_in: bool,
/// Write generation, echoed back on the `TaskResult`. Writes to this
/// endpoint are concurrent, so a result that isn't the newest must
/// not touch state: its `rollback_to_opted_in` was captured against
/// a world that has since moved on.
seq: u64,
},
/// Rename the current session.
RenameSession {
@ -2560,12 +2564,14 @@ pub enum TaskResult {
CodingDataSharingUpdated {
agent_id: AgentId,
opted_in: bool,
seq: u64,
},
/// Coding data sharing update failed.
CodingDataSharingFailed {
agent_id: AgentId,
error: String,
rollback_to_opted_in: bool,
seq: u64,
},
/// Session rename completed successfully.
RenameSessionComplete {

View file

@ -606,39 +606,51 @@ mod link_click_tests {
);
let rect = agent
.privacy_banner
.hit_accept
.hit_opt_in
.rect
.expect("accept rect armed");
let outcome = agent.handle_input(&Event::Mouse(mouse_down(rect.x + 1, rect.y)), &reg);
assert!(matches!(
outcome,
InputOutcome::Action(Action::PrivacyBannerAccept)
InputOutcome::Action(Action::PrivacyBannerOptIn)
));
let rect = agent
.privacy_banner
.hit_customize
.hit_opt_out
.rect
.expect("customize rect armed");
let outcome = agent.handle_input(&Event::Mouse(mouse_down(rect.x + 1, rect.y)), &reg);
assert!(matches!(
outcome,
InputOutcome::Action(Action::PrivacyBannerCustomize)
InputOutcome::Action(Action::PrivacyBannerOptOut)
));
let rect = agent
.privacy_banner
.hit_legal
.hit_terms
.rect
.expect("legal rect armed");
.expect("terms rect armed");
let outcome = agent.handle_input(&Event::Mouse(mouse_down(rect.x + 1, rect.y)), &reg);
assert!(matches!(
outcome,
InputOutcome::Action(Action::OpenUrl(ref url))
if url == crate::views::privacy_banner::PRIVACY_BANNER_LEGAL_URL
if url == crate::views::privacy_banner::PRIVACY_BANNER_TERMS_URL
));
let rect = agent
.privacy_banner
.hit_policy
.rect
.expect("privacy policy rect armed");
let outcome = agent.handle_input(&Event::Mouse(mouse_down(rect.x + 1, rect.y)), &reg);
assert!(matches!(
outcome,
InputOutcome::Action(Action::OpenUrl(ref url))
if url == crate::views::privacy_banner::PRIVACY_BANNER_POLICY_URL
));
draw_frame_privacy(&mut agent, &reg, &critical, 2, 80, false);
assert!(agent.privacy_banner.hit_accept.rect.is_none());
assert!(agent.privacy_banner.hit_customize.rect.is_none());
assert!(agent.privacy_banner.hit_legal.rect.is_none());
assert!(agent.privacy_banner.hit_opt_in.rect.is_none());
assert!(agent.privacy_banner.hit_opt_out.rect.is_none());
assert!(agent.privacy_banner.hit_terms.rect.is_none());
assert!(agent.privacy_banner.hit_policy.rect.is_none());
assert!(agent.hit_announcement_hide.rect.is_some());
}
/// Promo twin of the [hide] suppression test: the [label] CTA rect must

View file

@ -275,23 +275,26 @@ pub struct HitArea {
/// Privacy upsell banner state on the agent view: whether the banner owns
/// the banner slot this frame (`active`, set at draw start like
/// `session_banner_active`; persists until acted on, so it is a tip
/// occluder AND a tip-tick freezer) plus the three click targets.
/// occluder AND a tip-tick freezer) plus the four click targets.
#[derive(Debug, Default)]
pub struct PrivacyBannerState {
pub(crate) active: bool,
/// `[Accept]` (opt in; ack after ACP success).
pub(crate) hit_accept: HitArea,
/// `[Customize in settings]` (ack + open settings on coding_data_sharing).
pub(crate) hit_customize: HitArea,
/// Legal links line (opens the legal URL).
pub(crate) hit_legal: HitArea,
/// `[Opt in]` (opt in; ack only after ACP success).
pub(crate) hit_opt_in: HitArea,
/// `[Opt out]` (ack now; record the decline).
pub(crate) hit_opt_out: HitArea,
/// "Terms" link (opens the terms of service).
pub(crate) hit_terms: HitArea,
/// "Privacy Policy" link (opens the privacy policy).
pub(crate) hit_policy: HitArea,
}
impl PrivacyBannerState {
/// Drop all click targets (slot not painted this frame).
pub fn clear_hits(&mut self) {
self.hit_accept.clear();
self.hit_customize.clear();
self.hit_legal.clear();
self.hit_opt_in.clear();
self.hit_opt_out.clear();
self.hit_terms.clear();
self.hit_policy.clear();
}
}
/// Banner-slot inputs to [`AgentView::draw`]. Slot precedence is computed

View file

@ -870,6 +870,11 @@ impl AgentView {
} else {
banner_height
};
let banner_height = if privacy_banner {
banner_height.max(crate::views::privacy_banner::height(inner_width))
} else {
banner_height
};
let tip_row_visible =
self.ephemeral_tip_renderable(area.height) && self.ephemeral_tip.is_active();
let banner_height = banner_height.max(u16::from(tip_row_visible));
@ -2063,7 +2068,8 @@ impl AgentView {
self.hit_watching_cue.clear();
self.hit_plan_approval_status.clear();
}
let privacy_banner_owns_slot = privacy_banner && layout.banner.height >= 2;
let privacy_banner_owns_slot =
privacy_banner && layout.banner.height >= crate::views::privacy_banner::MIN_HEIGHT;
if !privacy_banner_owns_slot {
self.privacy_banner.clear_hits();
}
@ -2072,14 +2078,17 @@ impl AgentView {
self.hit_announcement_cta.clear();
let rects = crate::views::privacy_banner::render(layout.banner, buf, &theme, mouse_pos);
self.privacy_banner
.hit_accept
.set_unless_dropdown(Some(rects.accept), dropdown_open);
.hit_opt_in
.set_unless_dropdown(Some(rects.opt_in), dropdown_open);
self.privacy_banner
.hit_customize
.set_unless_dropdown(Some(rects.customize), dropdown_open);
.hit_opt_out
.set_unless_dropdown(Some(rects.opt_out), dropdown_open);
self.privacy_banner
.hit_legal
.set_unless_dropdown(Some(rects.legal), dropdown_open);
.hit_terms
.set_unless_dropdown(Some(rects.terms), dropdown_open);
self.privacy_banner
.hit_policy
.set_unless_dropdown(Some(rects.policy), dropdown_open);
} else if let Some((ref msg, remaining)) = self.mode_switch_banner {
self.hit_announcement_hide.clear();
self.hit_announcement_cta.clear();

View file

@ -858,9 +858,10 @@ pub struct AppView {
/// Hit-test rect for the welcome hero upgrade CTA `[label]` button
/// (click → `AnnouncementsOpenCta(Welcome)`).
pub welcome_upgrade_cta_rect: Option<ratatui::layout::Rect>,
pub welcome_privacy_banner_accept_rect: Option<ratatui::layout::Rect>,
pub welcome_privacy_banner_customize_rect: Option<ratatui::layout::Rect>,
pub welcome_privacy_banner_legal_rect: Option<ratatui::layout::Rect>,
pub welcome_privacy_banner_opt_in_rect: Option<ratatui::layout::Rect>,
pub welcome_privacy_banner_opt_out_rect: Option<ratatui::layout::Rect>,
pub welcome_privacy_banner_terms_rect: Option<ratatui::layout::Rect>,
pub welcome_privacy_banner_policy_rect: Option<ratatui::layout::Rect>,
/// Transient welcome toast: (message, wall-clock expiry).
pub welcome_toast: Option<(String, std::time::Instant)>,
/// Sticky hover flag for the privacy banner buttons (redraw on enter/leave).
@ -1064,7 +1065,12 @@ pub struct AppView {
/// Local `[privacy].privacy_banner_acked` (RFC 3339 UTC).
pub privacy_banner_acked: Option<String>,
/// Accept awaits ACP success before ack.
pub privacy_banner_accept_inflight: bool,
pub privacy_banner_opt_in_inflight: bool,
/// Newest `SetCodingDataSharing` write. Bumped per dispatch and echoed
/// on the `TaskResult`, so an older write's late reply — whose
/// `rollback_to_opted_in` was captured before the newer one — cannot
/// clobber the current value.
pub coding_data_write_seq: u64,
/// Persisted `[cli].show_tips` mirror. `None` = no override (default `true`).
pub show_tips: Option<bool>,
/// Persisted `[cli].auto_update` mirror. `None` = no override (default `true`).
@ -1442,9 +1448,10 @@ impl AppView {
welcome_refresh_rect: None,
welcome_gate_url_rect: None,
welcome_upgrade_cta_rect: None,
welcome_privacy_banner_accept_rect: None,
welcome_privacy_banner_customize_rect: None,
welcome_privacy_banner_legal_rect: None,
welcome_privacy_banner_opt_in_rect: None,
welcome_privacy_banner_opt_out_rect: None,
welcome_privacy_banner_terms_rect: None,
welcome_privacy_banner_policy_rect: None,
welcome_toast: None,
welcome_on_privacy_banner: false,
welcome_on_upgrade_cta: false,
@ -1519,7 +1526,8 @@ impl AppView {
privacy_notice_rollout: false,
privacy_banner_reshow_days: None,
privacy_banner_acked: None,
privacy_banner_accept_inflight: false,
privacy_banner_opt_in_inflight: false,
coding_data_write_seq: 0,
show_tips: None,
auto_update: None,
ask_user_question_timeout_enabled: None,
@ -2459,11 +2467,10 @@ impl AppView {
refresh_rect: self.welcome_refresh_rect.as_ref(),
gate_url_rect: self.welcome_gate_url_rect.as_ref(),
upgrade_cta_rect: self.welcome_upgrade_cta_rect.as_ref(),
privacy_banner_accept_rect: self.welcome_privacy_banner_accept_rect.as_ref(),
privacy_banner_customize_rect: self
.welcome_privacy_banner_customize_rect
.as_ref(),
privacy_banner_legal_rect: self.welcome_privacy_banner_legal_rect.as_ref(),
privacy_banner_opt_in_rect: self.welcome_privacy_banner_opt_in_rect.as_ref(),
privacy_banner_opt_out_rect: self.welcome_privacy_banner_opt_out_rect.as_ref(),
privacy_banner_terms_rect: self.welcome_privacy_banner_terms_rect.as_ref(),
privacy_banner_policy_rect: self.welcome_privacy_banner_policy_rect.as_ref(),
on_privacy_banner: &mut self.welcome_on_privacy_banner,
on_upgrade_cta: &mut self.welcome_on_upgrade_cta,
upgrade_cta_keyboard: welcome_pinned_upgrade_cta,
@ -3057,9 +3064,10 @@ struct WelcomeInputCtx<'a> {
/// Hit-test rect for the welcome hero upgrade CTA `[label]` button
/// (click → open the promo url).
upgrade_cta_rect: Option<&'a ratatui::layout::Rect>,
privacy_banner_accept_rect: Option<&'a ratatui::layout::Rect>,
privacy_banner_customize_rect: Option<&'a ratatui::layout::Rect>,
privacy_banner_legal_rect: Option<&'a ratatui::layout::Rect>,
privacy_banner_opt_in_rect: Option<&'a ratatui::layout::Rect>,
privacy_banner_opt_out_rect: Option<&'a ratatui::layout::Rect>,
privacy_banner_terms_rect: Option<&'a ratatui::layout::Rect>,
privacy_banner_policy_rect: Option<&'a ratatui::layout::Rect>,
/// Sticky hover flag for the privacy banner buttons (redraw on
/// enter/leave/crossing so they brighten/dim).
on_privacy_banner: &'a mut bool,
@ -3698,21 +3706,28 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
xai_grok_telemetry::events::AnnouncementCtaSurface::Welcome,
));
}
if let Some(rect) = ctx.privacy_banner_accept_rect
if let Some(rect) = ctx.privacy_banner_opt_in_rect
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
{
return InputOutcome::Action(Action::PrivacyBannerAccept);
return InputOutcome::Action(Action::PrivacyBannerOptIn);
}
if let Some(rect) = ctx.privacy_banner_customize_rect
if let Some(rect) = ctx.privacy_banner_opt_out_rect
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
{
return InputOutcome::Action(Action::PrivacyBannerCustomize);
return InputOutcome::Action(Action::PrivacyBannerOptOut);
}
if let Some(rect) = ctx.privacy_banner_legal_rect
if let Some(rect) = ctx.privacy_banner_terms_rect
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
{
return InputOutcome::Action(Action::OpenUrl(
crate::views::privacy_banner::PRIVACY_BANNER_LEGAL_URL.to_string(),
crate::views::privacy_banner::PRIVACY_BANNER_TERMS_URL.to_string(),
));
}
if let Some(rect) = ctx.privacy_banner_policy_rect
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
{
return InputOutcome::Action(Action::OpenUrl(
crate::views::privacy_banner::PRIVACY_BANNER_POLICY_URL.to_string(),
));
}
if let Some(rect) = ctx.changelog_cta_rect
@ -3794,13 +3809,16 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
return InputOutcome::Changed;
}
let over_banner = ctx
.privacy_banner_accept_rect
.privacy_banner_opt_in_rect
.is_some_and(|r| r.contains(pos))
|| ctx
.privacy_banner_customize_rect
.privacy_banner_opt_out_rect
.is_some_and(|r| r.contains(pos))
|| ctx
.privacy_banner_legal_rect
.privacy_banner_terms_rect
.is_some_and(|r| r.contains(pos))
|| ctx
.privacy_banner_policy_rect
.is_some_and(|r| r.contains(pos));
if over_banner || *ctx.on_privacy_banner {
*ctx.on_privacy_banner = over_banner;
@ -4348,10 +4366,11 @@ impl AppView {
self.welcome_refresh_rect = result.refresh_rect;
self.welcome_gate_url_rect = result.gate_url_rect;
self.welcome_upgrade_cta_rect = result.upgrade_cta_rect;
self.welcome_privacy_banner_accept_rect = result.privacy_banner_accept_rect;
self.welcome_privacy_banner_customize_rect =
result.privacy_banner_customize_rect;
self.welcome_privacy_banner_legal_rect = result.privacy_banner_legal_rect;
self.welcome_privacy_banner_opt_in_rect = result.privacy_banner_opt_in_rect;
self.welcome_privacy_banner_opt_out_rect =
result.privacy_banner_opt_out_rect;
self.welcome_privacy_banner_terms_rect = result.privacy_banner_terms_rect;
self.welcome_privacy_banner_policy_rect = result.privacy_banner_policy_rect;
self.welcome_changelog_cta_rect = result.changelog_cta_rect;
if let Some((ref msg, _)) = self.welcome_toast {
paint_welcome_toast(f.buffer_mut(), view_area, msg);
@ -4538,7 +4557,7 @@ impl AppView {
!privacy_banner && self.tip.is_some() && agent.should_show_tip();
let has_mode_banner = agent.mode_switch_banner.is_some();
let banner_height = if privacy_banner {
2
crate::views::privacy_banner::MIN_HEIGHT
} else if has_mode_banner {
1
} else if announcement_banner_h > 0 {
@ -5731,7 +5750,8 @@ pub(crate) mod tests {
privacy_notice_rollout: false,
privacy_banner_reshow_days: None,
privacy_banner_acked: None,
privacy_banner_accept_inflight: false,
privacy_banner_opt_in_inflight: false,
coding_data_write_seq: 0,
show_tips: None,
auto_update: None,
ask_user_question_timeout_enabled: None,
@ -5774,9 +5794,10 @@ pub(crate) mod tests {
welcome_refresh_rect: None,
welcome_gate_url_rect: None,
welcome_upgrade_cta_rect: None,
welcome_privacy_banner_accept_rect: None,
welcome_privacy_banner_customize_rect: None,
welcome_privacy_banner_legal_rect: None,
welcome_privacy_banner_opt_in_rect: None,
welcome_privacy_banner_opt_out_rect: None,
welcome_privacy_banner_terms_rect: None,
welcome_privacy_banner_policy_rect: None,
welcome_toast: None,
welcome_on_privacy_banner: false,
welcome_on_upgrade_cta: false,
@ -10020,9 +10041,10 @@ pub(crate) mod tests {
fn welcome_privacy_banner_hover_triggers_redraw() {
let mut app = test_app();
app.active_view = ActiveView::Welcome;
app.welcome_privacy_banner_accept_rect = Some(ratatui::layout::Rect::new(50, 10, 8, 1));
app.welcome_privacy_banner_customize_rect = Some(ratatui::layout::Rect::new(25, 10, 24, 1));
app.welcome_privacy_banner_legal_rect = Some(ratatui::layout::Rect::new(2, 11, 45, 1));
app.welcome_privacy_banner_opt_in_rect = Some(ratatui::layout::Rect::new(50, 10, 8, 1));
app.welcome_privacy_banner_opt_out_rect = Some(ratatui::layout::Rect::new(25, 10, 24, 1));
app.welcome_privacy_banner_terms_rect = Some(ratatui::layout::Rect::new(7, 11, 5, 1));
app.welcome_privacy_banner_policy_rect = Some(ratatui::layout::Rect::new(17, 11, 14, 1));
let over = left_mouse(MouseEventKind::Moved, 52, 10);
assert!(matches!(app.handle_input(&over), InputOutcome::Changed));
assert!(app.welcome_on_privacy_banner);

View file

@ -96,10 +96,9 @@ use super::settings::ui::{
};
use super::status::{
dispatch_copy_session_id, dispatch_manage_billing, dispatch_open_gboom, dispatch_open_tutorial,
dispatch_privacy_banner_accept, dispatch_privacy_banner_customize, dispatch_share_session,
dispatch_show_context_info, dispatch_show_privacy_info, dispatch_show_queue,
dispatch_show_release_notes, dispatch_show_session_info, dispatch_show_tasks,
dispatch_show_usage, set_coding_data_sharing,
dispatch_privacy_banner_opt_in, dispatch_privacy_banner_opt_out, dispatch_share_session,
dispatch_show_context_info, dispatch_show_queue, dispatch_show_release_notes,
dispatch_show_session_info, dispatch_show_tasks, dispatch_show_usage, set_coding_data_sharing,
};
use super::task_result::{dispatch_task_result, unregister_all_active_sessions};
use super::transcript::{
@ -950,7 +949,6 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::SaveRememberNoteFromModal => dispatch_save_remember_note_from_modal(app),
Action::SendBtw(question) => dispatch_send_btw(app, question),
Action::SendRecap { auto } => dispatch_send_recap(app, auto),
Action::ShowPrivacyInfo => dispatch_show_privacy_info(app),
Action::SetCodingDataSharing { opted_in } => set_coding_data_sharing(app, opted_in),
Action::ToggleYolo => dispatch_toggle_yolo(app),
Action::ToggleMultiline => dispatch_toggle_multiline(app),
@ -1011,8 +1009,8 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::PreviewAutoLightTheme(v) => preview_auto_light_theme(app, v),
Action::OpenSettings => dispatch_open_settings(app, None),
Action::OpenSettingsFocus { key } => dispatch_open_settings(app, Some(key)),
Action::PrivacyBannerAccept => dispatch_privacy_banner_accept(app),
Action::PrivacyBannerCustomize => dispatch_privacy_banner_customize(app),
Action::PrivacyBannerOptIn => dispatch_privacy_banner_opt_in(app),
Action::PrivacyBannerOptOut => dispatch_privacy_banner_opt_out(app),
Action::OpenCommandPalette => dispatch_open_command_palette(app),
Action::OpenHowtoGuides => dispatch_open_howto_guides(app),
Action::OpenResetConfirm { key } => dispatch_open_reset_confirm(app, key),

View file

@ -255,8 +255,8 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(
if let Some(key) = focus_key
&& state.focus_key(key)
{
// Land directly on the setting's chooser page (e.g. the coding data
// sharing opt-in/out picker), not just the focused browse row.
// Try the chooser; a locked row keeps Browse (`try_enter_picking_enum`
// refuses when `row_lock` is set).
state.try_enter_picking_enum();
}
agent.active_modal = Some(ActiveModal::Settings { state });

View file

@ -68,53 +68,48 @@ pub(super) fn dispatch_show_session_info(app: &mut AppView) -> Vec<Effect> {
}]
}
/// Show privacy and data retention status as a system message in scrollback.
///
/// Three-state display: Enterprise ZDR, coding data sharing opted out,
/// or opted in. Labels align with `CODING_DATA_SHARING_CHOICES` in
/// `settings/defs.rs` and the `coding_data_sharing_toast` format.
///
/// Also lists config knobs that `/privacy` does not change (technical
/// pointers only; no policy claims).
pub(super) fn dispatch_show_privacy_info(app: &mut AppView) -> Vec<Effect> {
let mut lines = Vec::new();
if app.is_zdr {
// Enterprise ZDR -- the team has disabled retention entirely.
lines.push(" Zero Data Retention: enabled");
lines.push(" Your data is not retained or used for training (ZDR enabled).");
} else if app.coding_data_retention_opt_out {
// Coding data sharing opted out -- matches desktop's "Privacy mode" state.
lines.push(" Privacy: privacy mode");
lines.push(" Your code data will not be trained on or used to improve the product.");
lines.push("");
lines.push(" Use /privacy opt-in to share data and help improve the product.");
} else {
// Coding data sharing opted in -- matches desktop's "Share data" state.
lines.push(" Privacy: share data");
lines.push(" Usage and code data may be used by SpaceXAI to improve the product.");
lines.push("");
lines.push(" Use /privacy opt-out to enable privacy mode.");
}
// Config keys only; do not describe retention/training/analytics policy here.
lines.push("");
lines.push(" Other settings (not changed by /privacy):");
lines.push(" - [features] telemetry / GROK_TELEMETRY_ENABLED");
lines.push(" - [telemetry] trace_upload / GROK_TELEMETRY_TRACE_UPLOAD");
lines.push(" - GROK_EXTERNAL_OTEL / OTEL_*");
lines.push("");
lines.push(" Learn more: https://x.ai/legal");
let text = lines.join("\n");
push_system_to_any_agent(app, &text);
vec![]
}
/// State-only mutation for `coding_data_sharing`. SHELL-owned.
pub(super) fn set_coding_data_sharing_inner(app: &mut AppView, opted_in: bool) {
app.coding_data_retention_opt_out = !opted_in;
}
/// Agent the coding-data ACP write is attributed to. Privacy is app-level,
/// so the id only routes the result back; `AgentId(0)` is the synthetic
/// stand-in for the welcome screen, where the banner is reachable before a
/// session exists.
fn coding_data_sharing_agent_id(app: &AppView) -> AgentId {
match app.active_view {
ActiveView::Agent(id) => id,
_ => app.agents.keys().next().copied().unwrap_or(AgentId(0)),
}
}
/// Claim the next write generation. Every `SetCodingDataSharing` must take
/// one so its reply can be matched against the newest write.
fn next_coding_data_write_seq(app: &mut AppView) -> u64 {
app.coding_data_write_seq += 1;
app.coding_data_write_seq
}
/// Is this reply from the newest write? Writes to this endpoint run
/// concurrently and can land out of order, so an older reply must not touch
/// state: its `rollback_to_opted_in` predates the newer write, and applying
/// it would silently undo whatever the user did since.
fn is_current_coding_data_write(app: &AppView, seq: u64, agent_id: AgentId) -> bool {
if seq == app.coding_data_write_seq {
return true;
}
tracing::debug!(
target: "settings",
key = "coding_data_sharing",
?agent_id,
seq,
current = app.coding_data_write_seq,
"dropping superseded coding-data reply",
);
false
}
/// Set coding-data-sharing preference. SHELL-owned, auth-metadata-backed
/// (persists via ACP ext-request, NOT `~/.grok/config.toml`).
pub(super) fn set_coding_data_sharing(app: &mut AppView, opted_in: bool) -> Vec<Effect> {
@ -134,29 +129,18 @@ pub(super) fn set_coding_data_sharing(app: &mut AppView, opted_in: bool) -> Vec<
return vec![];
}
}
// Synthetic AgentId(0) when no agents (welcome banner Accept).
let agent_id = match app.active_view {
crate::app::app_view::ActiveView::Agent(id) => id,
_ => app
.agents
.keys()
.next()
.copied()
.unwrap_or(crate::app::agent::AgentId(0)),
};
let agent_id = coding_data_sharing_agent_id(app);
let prev = !app.coding_data_retention_opt_out;
// ── Idempotent path: toast but skip the ACP round-trip. ──────────
// ── Idempotent path: skip the ACP round-trip. ────────────────────
if prev == opted_in {
app.show_toast(&coding_data_sharing_toast(opted_in));
return vec![];
}
// ── Optimistic mutation: state, then UI feedback, then effect. ───
// Optimistic mutation. Success is silent; only the refusals above and
// the failure handler toast.
set_coding_data_sharing_inner(app, opted_in);
refresh_open_settings_modals(app);
app.show_toast(&coding_data_sharing_toast(opted_in));
tracing::info!(
target: "settings",
@ -169,32 +153,10 @@ pub(super) fn set_coding_data_sharing(app: &mut AppView, opted_in: bool) -> Vec<
agent_id,
opted_in,
rollback_to_opted_in: prev,
seq: next_coding_data_write_seq(app),
}]
}
/// Format the `Coding data sharing` toast. Asymmetric: opt-in
/// (privacy-degrading) uses ⚠ + consequence text; opt-out (safe
/// default) uses ✓. Uses display names from the registry catalog.
pub(super) fn coding_data_sharing_toast(opted_in: bool) -> String {
let display = display_for_coding_data_sharing_canonical(opted_in);
if opted_in {
// Privacy-degrading: warn glyph + spelled-out consequence.
format!(
"\u{26A0} Coding data sharing: {display} \u{2014} code samples may be retained \
for training"
)
} else {
// Safe default — uniform ✓ glyph.
format!("\u{2713} Coding data sharing: {display}")
}
}
/// Display string for the canonical bool. Keep aligned with
/// `CODING_DATA_SHARING_CHOICES` in `settings/defs.rs`.
fn display_for_coding_data_sharing_canonical(opted_in: bool) -> &'static str {
if opted_in { "Opt in" } else { "Opt out" }
}
/// Scrub an untrusted error string for toast display. Substitutes a
/// generic placeholder when the input exceeds 120 chars or contains
/// control / bidi-override characters (prevents escape-sequence
@ -212,21 +174,6 @@ pub(super) fn scrub_error_for_toast(error: &str) -> String {
}
}
/// Push a system message to the active agent's scrollback, or to any available
/// agent if on the welcome screen.
fn push_system_to_any_agent(app: &mut AppView, msg: &str) {
let block = crate::scrollback::block::RenderBlock::system(msg.to_string());
if let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get_mut(&id)
{
agent.scrollback.push_block(block);
return;
}
if let Some(agent) = app.agents.values_mut().next() {
agent.scrollback.push_block(block);
}
}
/// Show context info: fetch via x.ai/session/info and display rich breakdown.
///
/// Produces Effect::ShowContextInfo which spawns an async ACP ext request.
@ -430,16 +377,16 @@ pub(super) fn handle_coding_data_sharing_updated(
app: &mut AppView,
agent_id: AgentId,
opted_in: bool,
seq: u64,
) -> Vec<Effect> {
if !is_current_coding_data_write(app, seq, agent_id) {
return vec![];
}
// Re-anchor mirror to server-confirmed value (defense-in-depth against
// server reshaping the boolean). `agent_id` discarded — privacy is
// app-level, not per-agent.
set_coding_data_sharing_inner(app, opted_in);
refresh_open_settings_modals(app);
// Re-toast on confirmation. Without this, a slow ACP round-trip would
// leave the user with only the optimistic toast (already faded) and no
// server-confirmed feedback.
app.show_toast(&coding_data_sharing_toast(opted_in));
tracing::info!(
target: "settings",
key = "coding_data_sharing",
@ -448,9 +395,9 @@ pub(super) fn handle_coding_data_sharing_updated(
"ACP update confirmed; mirror re-anchored",
);
let mut effects = vec![];
// Ack only after successful opt-in from the privacy banner Accept path.
if app.privacy_banner_accept_inflight {
app.privacy_banner_accept_inflight = false;
// Ack only after a successful opt-in from the banner's [Opt in].
if app.privacy_banner_opt_in_inflight {
app.privacy_banner_opt_in_inflight = false;
if opted_in {
effects.extend(ack_privacy_banner(app));
}
@ -463,7 +410,15 @@ pub(super) fn handle_coding_data_sharing_failed(
agent_id: AgentId,
error: String,
rollback_to_opted_in: bool,
seq: u64,
) -> Vec<Effect> {
// A superseded failure must not revert: `rollback_to_opted_in` predates
// the newer write, so applying it would undo a change the user made
// after this one was sent. It must not toast either — nothing the user
// is looking at failed.
if !is_current_coding_data_write(app, seq, agent_id) {
return vec![];
}
// Revert optimistic mutation: inner → refresh → toast. `agent_id`
// discarded — privacy is global.
set_coding_data_sharing_inner(app, rollback_to_opted_in);
@ -481,8 +436,8 @@ pub(super) fn handle_coding_data_sharing_failed(
%error,
"ACP update failed; reverted optimistic mutation",
);
// Accept failure: no ack; clear inflight so the banner stays.
app.privacy_banner_accept_inflight = false;
// Opt-in failure: no ack; clear inflight so the banner stays.
app.privacy_banner_opt_in_inflight = false;
vec![]
}
@ -493,31 +448,43 @@ pub(in crate::app::dispatch) fn ack_privacy_banner(app: &mut AppView) -> Vec<Eff
vec![Effect::PersistPrivacyBannerAcked { acked_at }]
}
/// Accept: opt-in via settings path; ack only after ACP success.
pub(in crate::app::dispatch) fn dispatch_privacy_banner_accept(app: &mut AppView) -> Vec<Effect> {
if app.privacy_banner_accept_inflight || !app.privacy_banner_should_show() {
/// `[Opt in]`: opt in via the settings path; ack only after ACP success, so
/// a failed round trip leaves the banner up instead of recording a change
/// that did not happen.
pub(in crate::app::dispatch) fn dispatch_privacy_banner_opt_in(app: &mut AppView) -> Vec<Effect> {
if app.privacy_banner_opt_in_inflight || !app.privacy_banner_should_show() {
return vec![];
}
let effects = set_coding_data_sharing(app, true);
// should_show guarantees opted-out + unguarded, so effects is only empty
// if a guard regresses; leaving inflight false keeps Accept clickable.
app.privacy_banner_accept_inflight = !effects.is_empty();
// if a guard regresses; leaving inflight false keeps [Opt in] clickable.
app.privacy_banner_opt_in_inflight = !effects.is_empty();
effects
}
/// Customize: ack, then open settings on coding_data_sharing
/// (creates/switches agent when opened from welcome).
pub(in crate::app::dispatch) fn dispatch_privacy_banner_customize(
app: &mut AppView,
) -> Vec<Effect> {
if app.privacy_banner_accept_inflight || !app.privacy_banner_should_show() {
/// `[Opt out]`: ack locally, then record the decline.
///
/// The ack does NOT wait on the server, unlike `[Opt in]`'s: the user asked
/// for no change, so gating dismissal on a round trip would only re-ask a
/// question they answered.
///
/// The write is built here rather than through `set_coding_data_sharing`,
/// whose idempotent guard would skip it — the user is already opted out,
/// and recording that is the point. Its response re-anchors the mirror;
/// concurrent writes to this endpoint are still unordered.
pub(in crate::app::dispatch) fn dispatch_privacy_banner_opt_out(app: &mut AppView) -> Vec<Effect> {
if app.privacy_banner_opt_in_inflight || !app.privacy_banner_should_show() {
return vec![];
}
let mut effects = ack_privacy_banner(app);
effects.extend(super::settings::ui::dispatch_open_settings(
app,
Some("coding_data_sharing"),
));
effects.push(Effect::SetCodingDataSharing {
agent_id: coding_data_sharing_agent_id(app),
opted_in: false,
// Already opted out, so the revert is a no-op — and the generation
// guard drops it entirely if the user has opted in since.
rollback_to_opted_in: false,
seq: next_coding_data_write_seq(app),
});
effects
}

View file

@ -864,14 +864,17 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
}
vec![]
}
TaskResult::CodingDataSharingUpdated { agent_id, opted_in } => {
handle_coding_data_sharing_updated(app, agent_id, opted_in)
}
TaskResult::CodingDataSharingUpdated {
agent_id,
opted_in,
seq,
} => handle_coding_data_sharing_updated(app, agent_id, opted_in, seq),
TaskResult::CodingDataSharingFailed {
agent_id,
error,
rollback_to_opted_in,
} => handle_coding_data_sharing_failed(app, agent_id, error, rollback_to_opted_in),
seq,
} => handle_coding_data_sharing_failed(app, agent_id, error, rollback_to_opted_in, seq),
TaskResult::RenameSessionComplete { agent_id, title } => {
if let Some(agent) = app.agents.get_mut(&agent_id) {
let safe = crate::views::session_title::sanitize_display_text(&title);

View file

@ -159,7 +159,8 @@ fn test_app() -> AppView {
privacy_notice_rollout: false,
privacy_banner_reshow_days: None,
privacy_banner_acked: None,
privacy_banner_accept_inflight: false,
privacy_banner_opt_in_inflight: false,
coding_data_write_seq: 0,
show_tips: None,
auto_update: None,
ask_user_question_timeout_enabled: None,
@ -201,9 +202,10 @@ fn test_app() -> AppView {
welcome_gate_url_rect: None,
welcome_changelog_cta_rect: None,
welcome_upgrade_cta_rect: None,
welcome_privacy_banner_accept_rect: None,
welcome_privacy_banner_customize_rect: None,
welcome_privacy_banner_legal_rect: None,
welcome_privacy_banner_opt_in_rect: None,
welcome_privacy_banner_opt_out_rect: None,
welcome_privacy_banner_terms_rect: None,
welcome_privacy_banner_policy_rect: None,
welcome_toast: None,
welcome_on_privacy_banner: false,
welcome_on_upgrade_cta: false,

View file

@ -660,9 +660,8 @@ fn dispatch_open_settings_opens_then_close_on_reentry() {
);
}
}
/// A focused open (privacy banner Customize) landing on an agent whose
/// settings modal is already open must reopen focused on the requested
/// row — not toggle the modal closed.
/// A focused open on an agent whose settings modal is already open must
/// reopen focused on the requested row — not toggle the modal closed.
#[test]
fn dispatch_open_settings_focus_reopens_when_already_open() {
use crate::views::modal::ActiveModal;
@ -689,6 +688,62 @@ fn dispatch_open_settings_focus_reopens_when_already_open() {
"focused re-entry must land on the requested row"
);
}
/// Chooser when editable, browse row when locked. The team-admin arm is the
/// one a `team_name.is_some()` shortcut would break.
#[test]
fn dispatch_open_settings_focus_skips_the_chooser_only_when_locked() {
use crate::views::modal::ActiveModal;
use crate::views::settings_modal::SettingsModalMode;
let open_focused = |app: &mut AppView| -> SettingsModalMode {
let _ = dispatch(
Action::OpenSettingsFocus {
key: "coding_data_sharing",
},
app,
);
let agent = app.agents.get(&AgentId(0)).unwrap();
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
panic!("settings modal must be open")
};
assert_eq!(
state.focused_setting().map(|(k, _)| k),
Some("coding_data_sharing"),
"every landing focuses the row"
);
state.mode()
};
let mut app = test_app_with_agent();
assert!(
matches!(
open_focused(&mut app),
SettingsModalMode::PickingEnum { .. }
),
"an editable setting opens its chooser"
);
let mut app = test_app_with_agent();
app.is_zdr = true;
assert!(
matches!(open_focused(&mut app), SettingsModalMode::Browse),
"ZDR must stop at the row that says so"
);
let mut app = test_app_with_agent();
app.team_name = Some("acme".to_string());
app.team_role = Some("member".to_string());
assert!(
matches!(open_focused(&mut app), SettingsModalMode::Browse),
"a team-managed lock must stop at the row that says so"
);
let mut app = test_app_with_agent();
app.team_name = Some("acme".to_string());
app.team_role = Some("admin".to_string());
assert!(
matches!(
open_focused(&mut app),
SettingsModalMode::PickingEnum { .. }
),
"a team admin is not locked"
);
}
/// `dispatch_open_reset_confirm` moves the Settings modal state
/// into the new `ResetSettingsConfirm` variant, preserving it
/// across the confirm dialog's lifecycle. The dispatch arm is

View file

@ -60,94 +60,14 @@ fn send_while_idle_with_nonempty_shared_queue_routes_to_server() {
assert_eq!(q.last().map(|e| e.text.as_str()), Some("c"));
}
#[test]
fn show_privacy_info_zdr() {
let mut app = test_app_with_agent();
app.is_zdr = true;
let effects = dispatch(Action::ShowPrivacyInfo, &mut app);
assert!(effects.is_empty());
let text = last_system_text(&app, AgentId(0));
assert!(text.contains("Zero Data Retention"));
assert!(
text.contains("Other settings (not changed by /privacy)"),
"must list other settings knobs: {text}",
);
assert!(
text.contains("GROK_TELEMETRY_ENABLED") && text.contains("GROK_EXTERNAL_OTEL"),
"must list telemetry/OTEL config keys: {text}",
);
}
/// `/privacy` info-print uses the desktop-aligned "privacy mode" /
/// "share data" labels from the user's intentional rewrite.
#[test]
fn show_privacy_info_opted_out() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true;
let effects = dispatch(Action::ShowPrivacyInfo, &mut app);
assert!(effects.is_empty());
let text = last_system_text(&app, AgentId(0));
assert!(
text.contains("Privacy: privacy mode"),
"info-print must use 'Privacy: privacy mode' (desktop-aligned label): {text}",
);
assert!(text.contains("/privacy opt-in"));
assert!(
text.contains("Other settings (not changed by /privacy)")
&& text.contains("GROK_TELEMETRY_ENABLED")
&& text.contains("trace_upload")
&& text.contains("GROK_EXTERNAL_OTEL"),
"must list config knobs not changed by /privacy: {text}",
);
}
#[test]
fn show_privacy_info_opted_in() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false;
let effects = dispatch(Action::ShowPrivacyInfo, &mut app);
assert!(effects.is_empty());
let text = last_system_text(&app, AgentId(0));
assert!(
text.contains("Privacy: share data"),
"info-print must use 'Privacy: share data' (desktop-aligned label): {text}",
);
assert!(text.contains("/privacy opt-out"));
}
/// The info-print uses desktop-aligned labels ("privacy mode" /
/// "share data"). This test pins those labels to catch accidental
/// regressions to the registry's "Opt in" / "Opt out" display
/// strings.
#[test]
fn show_privacy_info_does_not_use_old_desktop_labels() {
// opted-out → "Privacy: privacy mode"
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true;
let _ = dispatch(Action::ShowPrivacyInfo, &mut app);
let text = last_system_text(&app, AgentId(0));
assert!(
text.contains("privacy mode"),
"[opted-out] info-print must contain 'privacy mode': {text:?}",
);
// opted-in → "Privacy: share data"
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false;
let _ = dispatch(Action::ShowPrivacyInfo, &mut app);
let text = last_system_text(&app, AgentId(0));
assert!(
text.contains("share data"),
"[opted-in] info-print must contain 'share data': {text:?}",
);
}
// ── coding_data_sharing dispatch tests ───
//
// The dispatcher uses **optimistic + rollback + toast**, matching the
// `set_yolo_mode` pattern. These tests pin the contract:
// - Guards (ZDR, non-admin team) toast and short-circuit.
// - Idempotent dispatch toasts but emits no Effect.
// The dispatcher uses **optimistic + rollback**, matching the
// `set_yolo_mode` pattern minus its toasts — the surfaces that change this
// setting show the result themselves. These tests pin the contract:
// - Guards (ZDR, non-admin team) toast and short-circuit; they are the
// only paths that still speak up, because nothing else on screen would.
// - Idempotent dispatch emits no Effect and says nothing.
// - Optimistic mutation flips `app.coding_data_retention_opt_out`
// BEFORE the Effect is emitted.
// - `Effect::SetCodingDataSharing` carries
@ -156,74 +76,26 @@ fn show_privacy_info_does_not_use_old_desktop_labels() {
// mutation; `TaskResult::CodingDataSharingUpdated` re-anchors
// to the server-confirmed value.
/// Idempotent re-dispatch when already opted-in toasts but emits
/// no Effect (avoids a wasted ACP round-trip).
///
/// Toast uses the **display name** ("Opt in", not the
/// snake-case canonical "opt-in") AND the **destructive `⚠`
/// glyph** on the opt-in direction (privacy-degrading).
/// Idempotent re-dispatch skips the ACP round-trip.
#[test]
fn set_coding_data_sharing_idempotent_opt_in() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false; // currently opted-in
let effects = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
assert!(
effects.is_empty(),
"idempotent re-dispatch must NOT emit Effect"
);
let toast = read_toast(&app);
assert!(
toast.contains("Opt in"),
"toast must show display name 'Opt in' (PR 9 R1, General-3 Issue 6): {toast}",
);
assert!(
!toast.contains("opt-in"),
"toast must NOT use snake-case canonical 'opt-in' — display name only: {toast}",
);
assert!(
toast.contains('\u{26A0}'),
"idempotent opt-in toast uses ⚠ destructive-warning glyph (PR 9 R1, \
General-3 Issue 5): {toast}",
);
// State unchanged.
assert!(
!app.coding_data_retention_opt_out,
"idempotent path must not mutate state",
);
}
/// Idempotent re-dispatch when already opted-out toasts but emits
/// no Effect.
///
/// Opt-out direction uses the **uniform `✓` glyph**
/// (restoring the safe default) and the display name "Opt out".
#[test]
fn set_coding_data_sharing_idempotent_opt_out() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true; // currently opted-out
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
assert!(
effects.is_empty(),
"idempotent re-dispatch must NOT emit Effect"
);
let toast = read_toast(&app);
assert!(
toast.contains("Opt out"),
"toast must show display name 'Opt out': {toast}",
);
assert!(
toast.contains('\u{2713}'),
"idempotent opt-out toast uses ✓ safe-default glyph: {toast}",
);
assert!(
!toast.contains('\u{26A0}'),
"opt-out is the safe direction — must NOT use ⚠: {toast}",
);
// State unchanged.
assert!(
app.coding_data_retention_opt_out,
"idempotent path must not mutate state",
);
fn set_coding_data_sharing_idempotent_is_silent_and_effect_free() {
for opted_in in [true, false] {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = !opted_in; // already at the target
let effects = dispatch(Action::SetCodingDataSharing { opted_in }, &mut app);
assert!(
effects.is_empty(),
"idempotent re-dispatch must NOT emit Effect (opted_in={opted_in})"
);
assert!(
app.agents[&AgentId(0)].toast.is_none(),
"idempotent re-dispatch must not toast (opted_in={opted_in})"
);
assert_eq!(
app.coding_data_retention_opt_out, !opted_in,
"idempotent path must not mutate state (opted_in={opted_in})",
);
}
}
/// ZDR teams are blocked from toggling. The blocked path
@ -314,7 +186,7 @@ fn set_coding_data_sharing_allowed_for_admin() {
}
/// Non-idempotent dispatch emits one Effect AND mutates state
/// optimistically AND toasts.
/// optimistically.
#[test]
fn set_coding_data_sharing_produces_effect_and_optimistic_mutation() {
let mut app = test_app_with_agent();
@ -326,6 +198,7 @@ fn set_coding_data_sharing_produces_effect_and_optimistic_mutation() {
agent_id,
opted_in,
rollback_to_opted_in,
seq,
} => {
assert_eq!(*agent_id, AgentId(0));
assert!(!*opted_in);
@ -333,6 +206,10 @@ fn set_coding_data_sharing_produces_effect_and_optimistic_mutation() {
*rollback_to_opted_in,
"rollback_to_opted_in must be pre-toggle value (true == opted-in)",
);
assert_eq!(
*seq, app.coding_data_write_seq,
"the effect must carry the generation it was dispatched under",
);
}
other => panic!("expected SetCodingDataSharing Effect, got {other:?}"),
}
@ -341,38 +218,36 @@ fn set_coding_data_sharing_produces_effect_and_optimistic_mutation() {
app.coding_data_retention_opt_out,
"dispatch must optimistically mutate state",
);
// Toast on every dispatch (SHELL setter contract).
assert!(app.agents[&AgentId(0)].toast.is_some());
assert!(
app.agents[&AgentId(0)].toast.is_none(),
"changing this setting must not toast — the settings row is the feedback",
);
}
/// `TaskResult::CodingDataSharingUpdated` re-anchors state to the
/// server-confirmed value (defense-in-depth) and re-toasts.
/// server-confirmed value (defense-in-depth).
#[test]
fn coding_data_sharing_updated_re_anchors_state_and_re_toasts() {
fn coding_data_sharing_updated_re_anchors_state() {
let mut app = test_app_with_agent();
// Simulate post-optimistic state: opted-out.
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
// Server confirms opt-out (same as optimistic).
let seq = app.coding_data_write_seq;
let effects = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingUpdated {
agent_id: id,
opted_in: false,
seq,
}),
&mut app,
);
assert!(effects.is_empty(), "TaskResult arm must NOT emit Effect");
// State re-anchored (was already true, stays true).
assert!(app.coding_data_retention_opt_out);
// Re-toast on confirmation uses display name + ✓.
let toast = read_toast(&app);
assert!(
toast.contains("Opt out"),
"confirmation toast must use display name 'Opt out': {toast}",
);
assert!(
toast.contains('\u{2713}'),
"opt-out confirmation toast uses ✓: {toast}",
app.agents[&AgentId(0)].toast.is_none(),
"server confirmation must not toast",
);
}
@ -386,10 +261,12 @@ fn coding_data_sharing_updated_corrects_state_if_server_disagrees() {
// overrides to "opt-in" (e.g. policy that prevents opt-out).
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
let seq = app.coding_data_write_seq;
let effects = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingUpdated {
agent_id: id,
opted_in: true, // server says opted-in
seq,
}),
&mut app,
);
@ -399,19 +276,6 @@ fn coding_data_sharing_updated_corrects_state_if_server_disagrees() {
!app.coding_data_retention_opt_out,
"server-confirmed opt-in must overwrite optimistic opt-out",
);
// Server-correction toast uses the destructive ⚠
// pattern for the opt-in direction (the privacy-degrading
// override deserves the warning glyph even if the SERVER, not
// the user, made the call).
let toast = read_toast(&app);
assert!(
toast.contains("Opt in"),
"post-correction toast uses display name 'Opt in': {toast}",
);
assert!(
toast.contains('\u{26A0}'),
"opt-in direction always uses ⚠ glyph, even on server-correction path: {toast}",
);
}
/// `TaskResult::CodingDataSharingFailed` REVERTS the optimistic
@ -428,11 +292,13 @@ fn coding_data_sharing_failed_rolls_back_and_toasts_error() {
// was opt-in (true), so `rollback_to_opted_in = true`.
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
let seq = app.coding_data_write_seq;
let effects = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: id,
error: "server error".into(),
rollback_to_opted_in: true,
seq,
}),
&mut app,
);
@ -462,11 +328,13 @@ fn coding_data_sharing_failed_rolls_back_to_opt_out() {
// failed, pre-toggle was opt-out).
app.coding_data_retention_opt_out = false;
let id = AgentId(0);
let seq = app.coding_data_write_seq;
let effects = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: id,
error: "network timeout".into(),
rollback_to_opted_in: false,
seq,
}),
&mut app,
);
@ -522,11 +390,13 @@ fn coding_data_sharing_failed_refreshes_open_modal_snapshot() {
// Optimistic flip.
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
// ACP failure.
let seq = app.coding_data_write_seq;
let _ = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: AgentId(0),
error: "x".into(),
rollback_to_opted_in: true,
seq,
}),
&mut app,
);
@ -540,103 +410,18 @@ fn coding_data_sharing_failed_refreshes_open_modal_snapshot() {
);
}
// ── coding_data_sharing toast tests ─────────────
/// The opt-in transition
/// uses the **`⚠` destructive-warning glyph** + spelled-out
/// consequence text — mirroring `yolo_toast`'s
/// "Always-approve ON: all tool actions auto-run" pattern. The
/// consequence text is verbatim-pinned because the toast is the
/// only post-commit feedback for a privacy-degrading transition;
/// a future PR that softens the wording silently degrades the
/// safety affordance.
#[test]
fn set_coding_data_sharing_opt_in_renders_destructive_warning_toast() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true; // currently opted-out
let effects = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
assert_eq!(effects.len(), 1, "non-idempotent opt-in must emit Effect");
let toast = read_toast(&app);
assert!(
toast.contains('\u{26A0}'),
"opt-in toast MUST use ⚠ glyph (PR 9 R1, General-3 Issue 5 — \
privacy-degrading transition deserves destructive-warning glyph): {toast}",
);
assert!(
!toast.contains('\u{2713}'),
"opt-in toast MUST NOT use the uniform ✓ glyph — that's the \
safe-default toast for opt-out: {toast}",
);
assert!(
toast.contains("Opt in"),
"destructive toast still uses display name 'Opt in': {toast}",
);
// Consequence text pinned: a future PR softening this loses
// the safety affordance.
assert!(
toast.contains("code samples"),
"destructive toast must spell out the consequence \
(mention 'code samples'): {toast}",
);
assert!(
toast.contains("training"),
"destructive toast must spell out the consequence \
(mention 'training'): {toast}",
);
}
/// The opt-out transition uses the
/// uniform `✓` glyph (safe default), NOT the destructive `⚠`.
/// Mirrors `yolo_toast(false)` precedent — restoring the safe
/// default doesn't warrant the heavier visual.
#[test]
fn set_coding_data_sharing_opt_out_renders_safe_default_toast() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false; // currently opted-in
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
let toast = read_toast(&app);
assert!(
toast.contains('\u{2713}'),
"opt-out toast uses ✓ safe-default glyph: {toast}",
);
assert!(
!toast.contains('\u{26A0}'),
"opt-out toast MUST NOT use ⚠ — that's reserved for the privacy-degrading \
direction (PR 9 R1): {toast}",
);
assert!(toast.contains("Opt out"));
}
/// The toast renders
/// the registered `EnumChoice.display` ("Opt in" / "Opt out"),
/// NOT the persisted canonical ("opt-in" / "opt-out"). Mirrors
/// the `set_theme_toast_format_uses_display_name` contract.
/// The display strings here are pinned by the
/// `coding_data_sharing_choices_use_canonical_strings` e2e test
/// (registry side) AND
/// `pr9_coding_data_sharing_choices_use_canonical_strings` (which
/// also pins the display labels via the same EnumChoice
/// entries).
#[test]
fn coding_data_sharing_toast_format_uses_display_name() {
let mut app = test_app_with_agent();
// Opt-in direction.
app.coding_data_retention_opt_out = true;
let _ = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
let opt_in_toast = read_toast(&app);
assert!(
opt_in_toast.contains("Opt in"),
"opt-in toast uses display 'Opt in', not canonical 'opt-in': {opt_in_toast}",
);
// Clear and test opt-out direction.
app.agents.get_mut(&AgentId(0)).unwrap().toast = None;
app.coding_data_retention_opt_out = false;
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
let opt_out_toast = read_toast(&app);
assert!(
opt_out_toast.contains("Opt out"),
"opt-out toast uses display 'Opt out', not canonical 'opt-out': {opt_out_toast}",
);
fn set_coding_data_sharing_is_silent_in_both_directions() {
for opted_in in [true, false] {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = opted_in; // a real change either way
let _ = dispatch(Action::SetCodingDataSharing { opted_in }, &mut app);
assert!(
app.agents[&AgentId(0)].toast.is_none(),
"opted_in={opted_in} must not toast, got {:?}",
app.agents[&AgentId(0)].toast,
);
}
}
/// The failure toast
@ -650,11 +435,13 @@ fn coding_data_sharing_failed_scrubs_long_error_messages() {
let id = AgentId(0);
// ~500-char error simulating a stack trace / HTML 502 page.
let huge_error = "a".repeat(500);
let seq = app.coding_data_write_seq;
let _ = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: id,
error: huge_error.clone(),
rollback_to_opted_in: false,
seq,
}),
&mut app,
);
@ -680,11 +467,13 @@ fn coding_data_sharing_failed_scrubs_control_chars_in_error() {
let id = AgentId(0);
// Short message with embedded newlines.
let multiline = "line1\nline2\nline3".to_string();
let seq = app.coding_data_write_seq;
let _ = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: id,
error: multiline.clone(),
rollback_to_opted_in: false,
seq,
}),
&mut app,
);
@ -709,11 +498,13 @@ fn coding_data_sharing_failed_preserves_short_clean_error_message() {
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
let short_clean = "network timeout".to_string();
let seq = app.coding_data_write_seq;
let _ = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: id,
error: short_clean.clone(),
rollback_to_opted_in: false,
seq,
}),
&mut app,
);
@ -804,7 +595,7 @@ fn privacy_banner_ready_app() -> AppView {
app.privacy_notice_rollout = true;
app.privacy_banner_acked = None;
app.privacy_banner_reshow_days = None;
app.privacy_banner_accept_inflight = false;
app.privacy_banner_opt_in_inflight = false;
app.is_zdr = false;
app.team_name = None;
app.coding_data_retention_opt_out = true;
@ -841,28 +632,30 @@ fn privacy_banner_should_show_respects_gates() {
assert!(!app.privacy_banner_should_show(), "rollout off");
}
/// Accept success: ACP confirmation acks the banner.
/// `[Opt in]` success: ACP confirmation acks the banner.
#[test]
fn privacy_banner_accept_success_acks() {
fn privacy_banner_opt_in_success_acks() {
let mut app = privacy_banner_ready_app();
let effects = dispatch(Action::PrivacyBannerAccept, &mut app);
let effects = dispatch(Action::PrivacyBannerOptIn, &mut app);
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::SetCodingDataSharing { opted_in: true, .. }
));
assert!(app.privacy_banner_accept_inflight);
assert!(app.privacy_banner_opt_in_inflight);
assert!(!app.coding_data_retention_opt_out);
assert!(app.privacy_banner_acked.is_none());
let seq = app.coding_data_write_seq;
let ack_effects = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingUpdated {
agent_id: AgentId(0),
opted_in: true,
seq,
}),
&mut app,
);
assert!(!app.privacy_banner_accept_inflight);
assert!(!app.privacy_banner_opt_in_inflight);
assert!(app.privacy_banner_acked.is_some());
assert!(
ack_effects
@ -872,24 +665,26 @@ fn privacy_banner_accept_success_acks() {
);
}
/// Accept failure: no ack; welcome toast carries the error.
/// `[Opt in]` failure: no ack; welcome toast carries the error.
#[test]
fn privacy_banner_accept_failure_no_ack_sets_welcome_toast() {
fn privacy_banner_opt_in_failure_no_ack_sets_welcome_toast() {
let mut app = privacy_banner_ready_app();
let effects = dispatch(Action::PrivacyBannerAccept, &mut app);
let effects = dispatch(Action::PrivacyBannerOptIn, &mut app);
assert_eq!(effects.len(), 1);
assert!(app.privacy_banner_accept_inflight);
assert!(app.privacy_banner_opt_in_inflight);
let seq = app.coding_data_write_seq;
let fail_effects = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: AgentId(0),
error: "server error".into(),
rollback_to_opted_in: false,
seq,
}),
&mut app,
);
assert!(fail_effects.is_empty());
assert!(!app.privacy_banner_accept_inflight);
assert!(!app.privacy_banner_opt_in_inflight);
assert!(app.privacy_banner_acked.is_none());
assert!(
app.coding_data_retention_opt_out,
@ -902,38 +697,160 @@ fn privacy_banner_accept_failure_no_ack_sets_welcome_toast() {
.unwrap_or("");
assert!(
toast.contains("coding data sharing"),
"welcome toast on Accept failure: {toast}"
"welcome toast on [Opt in] failure: {toast}"
);
assert!(toast.contains("server error"), "error in toast: {toast}");
}
/// Customize while an Accept ACP call is inflight must be a no-op: an
/// eager ack would survive the Accept-failure rollback and hide the
/// `[Opt out]` while an `[Opt in]` ACP call is inflight must be a no-op:
/// an eager ack would survive the opt-in-failure rollback and hide the
/// banner forever.
#[test]
fn privacy_banner_customize_noop_while_accept_inflight() {
fn privacy_banner_opt_out_noop_while_opt_in_inflight() {
let mut app = privacy_banner_ready_app();
let _ = dispatch(Action::PrivacyBannerAccept, &mut app);
assert!(app.privacy_banner_accept_inflight);
let _ = dispatch(Action::PrivacyBannerOptIn, &mut app);
assert!(app.privacy_banner_opt_in_inflight);
let effects = dispatch(Action::PrivacyBannerCustomize, &mut app);
let effects = dispatch(Action::PrivacyBannerOptOut, &mut app);
assert!(
effects.is_empty(),
"customize during inflight accept must be a no-op: {effects:?}"
"[Opt out] during an inflight [Opt in] must be a no-op: {effects:?}"
);
assert!(app.privacy_banner_acked.is_none(), "no ack while inflight");
let seq = app.coding_data_write_seq;
let _ = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: AgentId(0),
error: "server error".into(),
rollback_to_opted_in: false,
seq,
}),
&mut app,
);
assert!(
app.privacy_banner_should_show(),
"failed Accept must keep the banner even after a raced Customize"
"a failed [Opt in] must keep the banner even after a raced [Opt out]"
);
}
/// The ack must not hinge on the round trip, unlike `[Opt in]`'s.
#[test]
fn privacy_banner_opt_out_acks_now_and_records_decline() {
use crate::views::modal::ActiveModal;
let mut app = privacy_banner_ready_app();
let effects = dispatch(Action::PrivacyBannerOptOut, &mut app);
assert!(
app.privacy_banner_acked.is_some(),
"the ack lands on click, not on an ACP reply"
);
assert!(
!app.privacy_banner_should_show(),
"the banner is gone the moment it is dismissed"
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::PersistPrivacyBannerAcked { .. })),
"ack must persist: {effects:?}"
);
assert!(
effects.iter().any(|e| matches!(
e,
Effect::SetCodingDataSharing {
opted_in: false,
rollback_to_opted_in: false,
..
}
)),
"the decline rides the ordinary write, so its response re-anchors \
the mirror like every other one: {effects:?}"
);
assert!(
!app.privacy_banner_opt_in_inflight,
"a best-effort write must not arm the opt-in inflight guard, which \
would block [Opt in] and confuse both ACP result handlers"
);
assert!(
app.coding_data_retention_opt_out,
"declining leaves the user opted out"
);
assert!(
app.agents
.values()
.all(|a| !matches!(a.active_modal, Some(ActiveModal::Settings { .. }))),
"[Opt out] answers the question; it must not detour into settings"
);
}
/// A superseded reply must not touch state. `[Opt out]` fires a write, the
/// user opts in from settings before it lands, and only then does the stale
/// decline answer: its `rollback_to_opted_in: false` was captured before the
/// opt-in existed, so applying it would flip the pager to opted-out while
/// the server holds opted-in — claiming data isn't retained when it is.
#[test]
fn superseded_coding_data_reply_cannot_clobber_a_newer_write() {
for stale_failed in [true, false] {
let mut app = privacy_banner_ready_app();
// Write 1: the banner decline.
let _ = dispatch(Action::PrivacyBannerOptOut, &mut app);
assert_eq!(app.coding_data_write_seq, 1);
// Write 2: the user opts in from settings, and it confirms.
let _ = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
assert_eq!(app.coding_data_write_seq, 2);
let _ = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingUpdated {
agent_id: AgentId(0),
opted_in: true,
seq: 2,
}),
&mut app,
);
assert!(!app.coding_data_retention_opt_out, "opted in");
// Write 1 finally answers, either way it can.
let stale_reply = if stale_failed {
TaskResult::CodingDataSharingFailed {
agent_id: AgentId(0),
error: "network timeout".into(),
rollback_to_opted_in: false,
seq: 1,
}
} else {
TaskResult::CodingDataSharingUpdated {
agent_id: AgentId(0),
opted_in: false,
seq: 1,
}
};
let effects = dispatch(Action::TaskComplete(stale_reply), &mut app);
assert!(effects.is_empty(), "stale reply must emit nothing");
assert!(
!app.coding_data_retention_opt_out,
"stale reply must not undo the newer opt-in (failed={stale_failed})"
);
assert!(
app.agents[&AgentId(0)].toast.is_none(),
"stale reply must not toast — nothing the user is looking at failed"
);
}
}
/// A double-click (or a stale frame's hit rect) must not send a second
/// decline.
#[test]
fn privacy_banner_opt_out_is_idempotent() {
let mut app = privacy_banner_ready_app();
let _ = dispatch(Action::PrivacyBannerOptOut, &mut app);
let again = dispatch(Action::PrivacyBannerOptOut, &mut app);
assert!(
again.is_empty(),
"second dismissal must be inert: {again:?}"
);
}

View file

@ -125,6 +125,7 @@ fn spawn_terminal_and_display_refresh_telemetry(tel: StartupTel) {
terminal.xtversion = %t.xtversion,
terminal.term_version = %t.term_version,
terminal.term_version_source = %t.term_version_source,
terminal.kitty_event_types_withheld = t.kitty_event_types_withheld,
)
.entered();
tracing::info!("terminal environment detected");

View file

@ -3305,7 +3305,12 @@ pub(crate) fn execute(
}
});
}
Effect::SetCodingDataSharing { agent_id, opted_in, rollback_to_opted_in } => {
Effect::SetCodingDataSharing {
agent_id,
opted_in,
rollback_to_opted_in,
seq,
} => {
let tx = acp_tx.clone();
tasks
.spawn(async move {
@ -3328,6 +3333,7 @@ pub(crate) fn execute(
agent_id,
error: format!("malformed response: {e}"),
rollback_to_opted_in,
seq,
};
}
};
@ -3343,6 +3349,7 @@ pub(crate) fn execute(
agent_id,
error: msg,
rollback_to_opted_in,
seq,
};
}
let confirmed_opted_in = wrapper
@ -3353,6 +3360,7 @@ pub(crate) fn execute(
TaskResult::CodingDataSharingUpdated {
agent_id,
opted_in: confirmed_opted_in,
seq,
}
}
Err(e) => {
@ -3360,6 +3368,7 @@ pub(crate) fn execute(
agent_id,
error: format!("{e}"),
rollback_to_opted_in,
seq,
}
}
}

View file

@ -1426,6 +1426,8 @@ pub(crate) async fn run(
// Fire-and-forget XTVERSION query; must sit immediately before the input
// reader thread is spawned so no earlier stdin consumer eats the reply.
// DA2 shares that constraint but runs earlier, in `init_terminal`, so its
// version is already resolved when the startup telemetry above is emitted.
crate::terminal::xtversion::probe_at_startup();
// Read terminal events on a dedicated thread and forward them over an mpsc
@ -3165,13 +3167,13 @@ async fn drain_and_process(
}
// Voice capture chord (Ctrl+Space or F8), handled here before normal
// routing so the release reaches us and the key never lands as text.
// Hold-to-talk under Kitty (press records, release stops), else tap
// toggle. A release is only ours when a hold session owns it, so a bare
// Space release (Ctrl lifted first) stops hold-to-talk without eating
// every Space release during normal typing. `[ui].voice_keybind_enabled`
// (read live, like `voice_capture_mode`) silences chord presses without
// touching `/voice` — see `voice_chord_claims_event` for the exact
// press/release/hold gating.
// Hold-to-talk where releases are reported (press records, release
// stops), else tap toggle. A release is only ours when a hold session
// owns it, so a bare Space release (Ctrl lifted first) stops
// hold-to-talk without eating every Space release during normal typing.
// `[ui].voice_keybind_enabled` (read live, like `voice_capture_mode`)
// silences chord presses without touching `/voice` — see
// `voice_chord_claims_event` for the exact press/release/hold gating.
if let Event::Key(ke) = ev
&& app.voice_mode_enabled
&& xai_grok_voice::AUDIO_SUPPORTED
@ -3189,7 +3191,7 @@ async fn drain_and_process(
) == "hold";
let action = voice_chord_action(
hold_mode,
crate::app::kitty_flags_pushed(),
crate::app::kitty_releases_reported(),
ke.kind,
app.voice_listening(),
app.voice_hold_owned(),
@ -3405,19 +3407,18 @@ fn is_pasteable_key_event(ev: &Event) -> bool {
/// Map a voice-chord key event to its action (pure, so it's unit-testable).
///
/// Hold mode on Kitty is press-to-record / release-to-stop, but only a
/// hold-*owned* session stops on release; a `/voice`/toggle session (not
/// hold-owned) has no release of its own, so a press toggles it off. Elsewhere
/// it's a tap toggle.
/// Hold mode is press-to-record / release-to-stop, but only a hold-*owned*
/// session stops on release; a `/voice`/toggle session (not hold-owned) has no
/// release of its own, so a press toggles it off. Elsewhere it's a tap toggle.
fn voice_chord_action(
hold_mode: bool,
kitty: bool,
releases_reported: bool,
kind: KeyEventKind,
listening: bool,
hold_owned: bool,
) -> Option<crate::app::actions::Action> {
use crate::app::actions::Action;
if hold_mode && kitty {
if hold_mode && releases_reported {
match kind {
KeyEventKind::Press if !listening => Some(Action::EnableVoiceMode),
KeyEventKind::Press if !hold_owned => Some(Action::VoiceToggle),
@ -3749,8 +3750,8 @@ mod tests {
#[test]
fn voice_chord_action_cases() {
use crate::app::actions::Action;
// (hold_mode, kitty, kind, listening, hold_owned) -> action tag, with the
// toggle-stop case being a past regression.
// (hold_mode, releases_reported, kind, listening, hold_owned) -> action
// tag, with the toggle-stop case being a past regression.
let press = KeyEventKind::Press;
let release = KeyEventKind::Release;
let tag = |a: Option<Action>| match a {
@ -3761,22 +3762,24 @@ mod tests {
_ => "other",
};
let cases = [
// hold+Kitty: press idle starts; release stops; press on a hold-owned
// session waits; press on a non-hold (/voice/toggle) session toggles off.
// hold + releases: press idle starts; release stops; press on a
// hold-owned session waits; press on a non-hold (/voice/toggle)
// session toggles off.
((true, true, press, false, false), "start"),
((true, true, release, true, true), "stop"),
((true, true, press, true, true), "none"),
((true, true, press, true, false), "toggle"),
// Non-hold (toggle mode or no Kitty releases): press toggles, release noops.
// Non-hold (toggle mode or no reported releases): press toggles,
// release noops.
((false, false, press, false, false), "toggle"),
((false, false, release, true, false), "none"),
((true, false, release, true, false), "none"),
];
for ((hold, kitty, kind, listening, owned), want) in cases {
for ((hold, releases, kind, listening, owned), want) in cases {
assert_eq!(
tag(voice_chord_action(hold, kitty, kind, listening, owned)),
tag(voice_chord_action(hold, releases, kind, listening, owned)),
want,
"voice_chord_action({hold},{kitty},{kind:?},{listening},{owned})"
"voice_chord_action({hold},{releases},{kind:?},{listening},{owned})"
);
}
}

View file

@ -47,7 +47,7 @@ pub(crate) mod screen_mode_relaunch;
pub mod signal_handler;
mod turn_completion;
mod xt_filter;
pub(crate) use crate::terminal::kitty_flags_pushed;
pub(crate) use crate::terminal::{kitty_flags_pushed, kitty_releases_reported};
pub use cli::{
AgentArgs, AgentCmd, Command, HeadlessArgs, LeaderArgs, LeaderMgmtArgs, LeaderMgmtCommand,
LeaderTargetArgs, OutputFormat, PagerArgs, ServeArgs, WrapArgs,
@ -70,8 +70,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
use tokio_util::sync::CancellationToken;
use xai_grok_shell::util::config;
/// Tracks the extra Kitty keyboard layer pushed while the `/gboom` game is
/// open (see [`push_gboom_keyboard_flags`]). Kept separate from
/// `KITTY_FLAGS_PUSHED` so teardown pops both, in LIFO order.
/// open (see [`push_gboom_keyboard_flags`]). Kept separate from the base layer
/// (`terminal::kitty_keyboard`) so teardown pops both, in LIFO order.
static GBOOM_KEYBOARD_PUSHED: AtomicBool = AtomicBool::new(false);
/// While the `/gboom` game owns input, additionally request
/// `REPORT_ALL_KEYS_AS_ESCAPE_CODES` so plain letter keys (WASD) emit
@ -1328,28 +1328,31 @@ fn init_terminal(
Ok(true) => None,
_ => Some("unsupported"),
});
let use_keyboard_enhancement = skip_reason.is_none();
if use_keyboard_enhancement {
let flags = event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
crate::terminal::da2::probe_at_startup();
let flags = crate::terminal::negotiated_kitty_flags(
skip_reason,
crate::terminal::da2::detected_packed(),
);
if flags.is_empty() {
tracing::info!(
kitty.flags = "none",
kitty.skipped_reason = skip_reason.unwrap_or("unknown"),
"kitty keyboard protocol skipped"
);
} else {
xai_grok_shell::util::with_locked_stderr(|stderr| {
let _ = execute!(stderr, event::PushKeyboardEnhancementFlags(flags));
});
tracing::info!(
kitty.flags = ?flags,
kitty.disambiguate = true,
kitty.report_event_types = true,
kitty.report_event_types =
flags.contains(event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES),
kitty.report_all_keys = false,
"kitty keyboard protocol pushed"
);
} else {
tracing::info!(
kitty.flags = "none",
kitty.skipped_reason = skip_reason.unwrap_or("unknown"),
"kitty keyboard protocol skipped"
);
}
crate::terminal::set_kitty_flags_pushed(use_keyboard_enhancement);
crate::terminal::set_pushed_kitty_flags(flags);
if mode.is_fullscreen() {
let backend = CrosstermBackend::new(
crate::render::draw::TermWriter::new(frame_tx, writer_sync)

View file

@ -138,28 +138,38 @@ impl AgentView {
}
if self
.privacy_banner
.hit_accept
.hit_opt_in
.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
return InputOutcome::Action(Action::PrivacyBannerAccept);
return InputOutcome::Action(Action::PrivacyBannerOptIn);
}
if self
.privacy_banner
.hit_customize
.hit_opt_out
.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
return InputOutcome::Action(Action::PrivacyBannerCustomize);
return InputOutcome::Action(Action::PrivacyBannerOptOut);
}
if self
.privacy_banner
.hit_legal
.hit_terms
.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
return InputOutcome::Action(Action::OpenUrl(
crate::views::privacy_banner::PRIVACY_BANNER_LEGAL_URL.to_string(),
crate::views::privacy_banner::PRIVACY_BANNER_TERMS_URL.to_string(),
));
}
if self
.privacy_banner
.hit_policy
.contains(mouse.column, mouse.row)
&& !self.pos_occluded(mouse.column, mouse.row)
{
return InputOutcome::Action(Action::OpenUrl(
crate::views::privacy_banner::PRIVACY_BANNER_POLICY_URL.to_string(),
));
}
if self.hit_watching_cue.contains(mouse.column, mouse.row)
@ -1099,15 +1109,19 @@ impl AgentView {
.update_hover(mouse.column, mouse.row);
changed |= self
.privacy_banner
.hit_accept
.hit_opt_in
.update_hover(mouse.column, mouse.row);
changed |= self
.privacy_banner
.hit_customize
.hit_opt_out
.update_hover(mouse.column, mouse.row);
changed |= self
.privacy_banner
.hit_legal
.hit_terms
.update_hover(mouse.column, mouse.row);
changed |= self
.privacy_banner
.hit_policy
.update_hover(mouse.column, mouse.row);
changed |= self
.plugin_cta

View file

@ -80,6 +80,16 @@ pub enum McpCommand {
#[arg(short = 's', long, value_enum)]
scope: Option<McpScope>,
},
/// Enable an MCP server
Enable {
/// Server name
name: String,
},
/// Disable an MCP server
Disable {
/// Server name
name: String,
},
/// Diagnose MCP server configuration and connectivity
Doctor {
/// Emit machine-readable JSON output
@ -142,6 +152,8 @@ pub async fn run(mcp_args: McpArgs) -> Result<()> {
McpCommand::List { json } => run_list(json),
McpCommand::Add(args) => run_add(args).await,
McpCommand::Remove { name, scope } => run_remove(&name, scope).await,
McpCommand::Enable { name } => run_set_enabled(&name, true).await,
McpCommand::Disable { name } => run_set_enabled(&name, false).await,
McpCommand::Doctor { json, name } => run_doctor(json, name).await,
}
}
@ -151,6 +163,7 @@ fn run_list(json: bool) -> Result<()> {
// a session started in this directory would load from config.toml files.
let cwd = current_dir_or_exit();
let servers = xai_grok_shell::util::config::load_mcp_server_configs_with_project(&cwd);
let disabled = xai_grok_shell::util::config::disabled_mcp_server_names(&cwd);
if json {
let payload: serde_json::Value = servers
@ -160,6 +173,10 @@ fn run_list(json: bool) -> Result<()> {
if let Some(obj) = entry.as_object_mut() {
obj.insert("name".into(), serde_json::Value::String(name.clone()));
obj.insert("scope".into(), serde_json::Value::String(scope.to_string()));
obj.insert(
"enabled".into(),
serde_json::Value::Bool(!disabled.contains(name)),
);
}
entry
})
@ -179,7 +196,11 @@ fn run_list(json: bool) -> Result<()> {
}
McpServerTransportConfig::StreamableHttp { url, .. } => url.clone(),
};
let status = if config.enabled { "" } else { " (disabled)" };
let status = if disabled.contains(name) {
" (disabled)"
} else {
""
};
let scope_note = if *scope == "project" {
" (project)"
} else {
@ -524,6 +545,86 @@ fn surviving_definition(
})
}
/// TOML / disabled list / compat JSON / legacy `grok_com_*` (not gateway).
fn mcp_server_is_known(name: &str, cwd: &Path) -> bool {
if name.starts_with("grok_com_") {
return true;
}
xai_grok_shell::util::config::cli_known_mcp_server_names(cwd).contains(name)
}
fn available_mcp_server_names(cwd: &Path) -> Vec<String> {
let mut names: Vec<String> = xai_grok_shell::util::config::cli_known_mcp_server_names(cwd)
.into_iter()
.collect();
names.sort();
names
}
async fn run_set_enabled(name: &str, enabled: bool) -> Result<()> {
// Do not use validate_server_name (add-only: [A-Za-z0-9_-]). Enable/disable
// also targets compat/plugin names that may contain dots or other keys.
if name.is_empty() {
bail!("Server name cannot be empty.");
}
if name.starts_with("managed_gateway:") || name.contains(':') {
eprintln!(
"Gateway connectors (e.g. managed_gateway:…) cannot be toggled via CLI; use Space in /mcps."
);
std::process::exit(1);
}
let cwd = current_dir_or_exit();
if !mcp_server_is_known(name, &cwd) {
eprintln!("No MCP server named '{name}'.");
let available = available_mcp_server_names(&cwd);
if !available.is_empty() {
eprintln!("Available servers: {}", available.join(", "));
} else {
eprintln!("No MCP servers configured. Run `grok mcp add --help` to get started.");
}
std::process::exit(1);
}
let was_disabled = xai_grok_shell::util::config::disabled_mcp_server_names(&cwd).contains(name);
let modified =
xai_grok_shell::util::config::save_mcp_server_enabled_in(name, enabled, &cwd).await?;
let now_disabled = xai_grok_shell::util::config::disabled_mcp_server_names(&cwd).contains(name);
let now_enabled = !now_disabled;
if enabled && now_disabled {
eprintln!(
"Warning: '{name}' is still disabled after enable (check project-scoped config)."
);
std::process::exit(1);
}
if !enabled && now_enabled {
eprintln!("Warning: '{name}' is still enabled after disable.");
std::process::exit(1);
}
if was_disabled == now_disabled {
let state = if now_enabled { "enabled" } else { "disabled" };
println!("MCP server '{name}' is already {state}.");
} else if now_enabled {
println!("Enabled MCP server '{name}'.");
} else {
println!("Disabled MCP server '{name}'.");
}
let user_config = xai_grok_shell::util::config::user_config_path();
for path in &modified {
if path == &user_config {
println!("File modified: {}", display_user_grok_path("config.toml"));
} else {
println!("File modified: {}", path.display());
}
}
Ok(())
}
async fn run_remove(name: &str, requested_scope: Option<McpScope>) -> Result<()> {
use xai_grok_shell::util::config::{
delete_mcp_server_config_at, mcp_server_defined_at, user_config_path,
@ -1047,6 +1148,38 @@ mod tests {
}
}
#[test]
fn enable_and_disable_parse_name() {
let args = PagerArgs::try_parse_from(["grok", "mcp", "enable", "user-grafana"])
.expect("enable should parse");
match args.command {
Some(Command::Mcp(McpArgs {
command: McpCommand::Enable { name },
})) => assert_eq!(name, "user-grafana"),
other => panic!("expected mcp enable, got {other:?}"),
}
let args = PagerArgs::try_parse_from(["grok", "mcp", "disable", "grok_com_slack"])
.expect("disable should parse");
match args.command {
Some(Command::Mcp(McpArgs {
command: McpCommand::Disable { name },
})) => assert_eq!(name, "grok_com_slack"),
other => panic!("expected mcp disable, got {other:?}"),
}
}
#[test]
fn enable_disable_require_name() {
let err = PagerArgs::try_parse_from(["grok", "mcp", "enable"])
.expect_err("enable without name must fail");
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
let err = PagerArgs::try_parse_from(["grok", "mcp", "disable"])
.expect_err("disable without name must fail");
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn select_remove_site_covers_scope_presence_matrix() {
let user = xai_grok_shell::util::config::user_config_path();

View file

@ -589,6 +589,13 @@ pub fn sync_pending_user_input_marks(v: &mut AgentView) {
v.sync_pending_user_input_marks();
}
/// Scrollback entry id of the tool row for `tool_call_id`, while the tracker
/// still has that tool pending. `None` once it has been reaped, or if it never
/// reached scrollback.
pub fn pending_tool_entry_id(v: &AgentView, tool_call_id: &str) -> Option<EntryId> {
v.session.tracker.pending_tool_entry_id(tool_call_id)
}
/// [`AgentView::draw_active_modal`] — minimal reuses the full-TUI modal renderer.
pub fn draw_active_modal(
v: &mut AgentView,

View file

@ -1,7 +1,8 @@
//! ThinkingBlock - displays agent thinking/reasoning content with markdown support.
use ratatui::style::{Color, Stylize};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span, Text};
use unicode_width::UnicodeWidthStr;
use crate::render::color::blend_line_with_default;
use crate::scrollback::block::BlockContent;
@ -13,6 +14,61 @@ use crate::theme::Theme;
use super::markdown_content::MarkdownContent;
use super::quote_bar::QuoteBarStrip;
/// TODO: hard-coded because `AppView::minimal_key_intercept` matches this chord
/// literally instead of going through the keybinding registry. Resolve the
/// label from the registry once it does, so a remap is advertised correctly.
const EXPAND_HINT: &str = "ctrl+e to expand";
const EXPAND_HINT_GAP: &str = " ";
/// Append the dim `(ctrl+e to expand)` affordance to a collapsed header line.
///
/// The `Collapsed` guard matters because `render_empty_placeholder` reuses the
/// collapsed renderer for an empty body in other modes, where the hint would be
/// a lie. Skipping the hint when it does not fit keeps the header out of
/// truncation and off a second row (K5).
fn append_expand_hint(line: Line<'static>, ctx: &BlockContext) -> Line<'static> {
if !ctx
.appearance
.scrollback
.blocks
.thinking
.collapsed_expand_hint
|| ctx.mode != DisplayMode::Collapsed
{
return line;
}
let hint = format!("{EXPAND_HINT_GAP}({EXPAND_HINT})");
let used: usize = line.spans.iter().map(|s| s.content.width()).sum();
if used + hint.width() > ctx.content_width() {
return line;
}
let mut line = line;
line.spans.push(Span::styled(hint, Theme::current().dim()));
line
}
/// The de-emphasis patch applied to every reasoning body span when
/// [`crate::appearance::ThinkingConfig::body_dim_italic`] is on.
///
/// Attributes only, no foreground: the flag exists because minimal's
/// terminal-native palette makes color-based de-emphasis a no-op (design doc
/// §6.16), and SGR dim/italic survive `NO_COLOR` and either polarity.
///
/// Legacy Windows ConHost has no italic SGR and renders the request as palette
/// noise. Terminals that merely *ignore* SGR 3 (tmux without `sitm`) are not
/// gated — there is no reliable probe, and they keep the other two cues.
fn body_emphasis_patch(ctx: &BlockContext) -> Option<Style> {
if !ctx.appearance.scrollback.blocks.thinking.body_dim_italic {
return None;
}
let mut modifiers = Modifier::DIM;
if !crate::glyphs::is_legacy_windows_console() {
modifiers |= Modifier::ITALIC;
}
Some(Style::new().add_modifier(modifiers))
}
/// Block displaying agent thinking content with markdown rendering.
///
/// Uses [`MarkdownContent`] for incremental markdown rendering with cached
@ -200,6 +256,7 @@ impl ThinkingBlock {
/// Render the collapsed view: header line only, truncated to fit.
fn render_collapsed(&self, ctx: &BlockContext) -> BlockOutput {
let line = self.header_line(ctx);
let line = append_expand_hint(line, ctx);
let line = crate::render::line_utils::truncate_line(line, ctx.content_width());
BlockOutput {
lines: vec![BlockLine::separator(line)],
@ -222,6 +279,9 @@ impl ThinkingBlock {
/// Quote-bar exclusion must run before blending: blending rewrites span
/// fg colors, which would defeat the bar-style detection (it preserves
/// span structure, so the computed span indices stay valid after it).
///
/// `emphasis` ([`body_emphasis_patch`]) is applied AFTER the blend for the
/// same reason: patching styles preserves span structure.
fn thinking_body_line(
line: &Line<'static>,
joiner: &Option<String>,
@ -229,10 +289,16 @@ impl ThinkingBlock {
bg_base: Color,
fg_default: Color,
blend_factor: f32,
emphasis: Option<Style>,
) -> BlockLine {
let mut content = line.clone();
let selectable = strip.selectable(&mut content);
let blended = blend_line_with_default(content, bg_base, fg_default, blend_factor);
let mut blended = blend_line_with_default(content, bg_base, fg_default, blend_factor);
if let Some(emphasis) = emphasis {
for span in &mut blended.spans {
span.style = span.style.patch(emphasis);
}
}
let mut block_line = BlockLine::styled(blended)
.with_selection_range(Some(0))
.with_joiner(joiner.clone());
@ -246,6 +312,7 @@ impl ThinkingBlock {
let n = config.truncated_lines as usize;
let width = ctx.width as usize;
let blend_factor = config.bg_blend;
let emphasis = body_emphasis_patch(ctx);
let strip = QuoteBarStrip::new(!self.content.is_raw());
self.content.with_wrapped_lines(width, |wrapped| {
@ -273,6 +340,7 @@ impl ThinkingBlock {
bg_base,
fg_default,
blend_factor,
emphasis,
)
})
.collect(),
@ -297,6 +365,7 @@ impl ThinkingBlock {
bg_base,
fg_default,
blend_factor,
emphasis,
));
}
@ -314,6 +383,7 @@ impl ThinkingBlock {
let config = &ctx.appearance.scrollback.blocks.thinking;
let width = ctx.width as usize;
let blend_factor = config.bg_blend;
let emphasis = body_emphasis_patch(ctx);
let strip = QuoteBarStrip::new(!self.content.is_raw());
self.content.with_wrapped_lines(width, |wrapped| {
@ -338,6 +408,7 @@ impl ThinkingBlock {
bg_base,
fg_default,
blend_factor,
emphasis,
)
})
.collect(),
@ -553,4 +624,114 @@ mod tests {
assert!(matches!(line.selectable, Selectable::Spans(_)));
assert_eq!(derive_selection_text(line), "QUOTE alpha");
}
#[test]
fn thinking_body_is_not_dimmed_or_italic_by_default() {
let block = ThinkingBlock::new("plain reasoning text");
let out = block.output(&ctx(DisplayMode::Expanded, 40));
let body = out.lines.last().expect("body line");
for span in &body.content.spans {
assert!(
!span.style.add_modifier.contains(Modifier::ITALIC),
"default appearance must not italicize reasoning: {span:?}"
);
assert!(
!span.style.add_modifier.contains(Modifier::DIM),
"default appearance must not dim reasoning: {span:?}"
);
}
}
/// The `NO_COLOR` hole: under the terminal-native palette the `bg_blend`
/// fade is a no-op, so the distinction has to live in SGR attributes.
#[test]
fn thinking_body_dim_italic_survives_the_terminal_native_palette() {
let _guard = crate::theme::cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
struct LockReset;
impl Drop for LockReset {
fn drop(&mut self) {
crate::theme::cache::set_terminal_native_lock(false);
}
}
let _reset = LockReset;
crate::theme::cache::set_terminal_native_lock(true);
let mut appearance = AppearanceConfig::default();
appearance.scrollback.blocks.thinking.header = false;
appearance.scrollback.blocks.thinking.body_dim_italic = true;
let ctx = BlockContext {
appearance,
..ctx(DisplayMode::Expanded, 40)
};
let block = ThinkingBlock::new("weighing the `options` with **care**");
let out = block.output(&ctx);
assert!(!out.lines.is_empty());
for line in &out.lines {
for span in &line.content.spans {
assert!(
span.style.add_modifier.contains(Modifier::DIM),
"every reasoning span must be dim under the native palette: {span:?}"
);
assert!(
span.style.add_modifier.contains(Modifier::ITALIC),
"every reasoning span must be italic: {span:?}"
);
}
}
}
#[test]
fn collapsed_header_advertises_the_expand_key_without_adding_a_row() {
let mut appearance = AppearanceConfig::default();
appearance.scrollback.blocks.thinking.collapsed_expand_hint = true;
let hinted = |mode, width| BlockContext {
appearance: appearance.clone(),
..ctx(mode, width)
};
let text_of = |out: &BlockOutput| {
out.lines
.iter()
.map(|l| crate::scrollback::types::line_plain_text(&l.content))
.collect::<Vec<_>>()
.join("\n")
};
let block = ThinkingBlock::new("hello world");
let plain = block.output(&ctx(DisplayMode::Collapsed, 60));
assert!(!text_of(&plain).contains(EXPAND_HINT));
let out = block.output(&hinted(DisplayMode::Collapsed, 60));
assert_eq!(out.lines.len(), 1, "the hint must not add a row");
assert!(text_of(&out).contains(EXPAND_HINT), "{:?}", text_of(&out));
// Too narrow for header + hint: the header must not be pushed into
// truncation by a hint that then gets dropped anyway.
let narrow = block.output(&hinted(DisplayMode::Collapsed, 12));
assert_eq!(narrow.lines.len(), 1);
let narrow_text = text_of(&narrow);
assert!(!narrow_text.contains(EXPAND_HINT), "{narrow_text:?}");
assert_eq!(
narrow_text,
text_of(&block.output(&ctx(DisplayMode::Collapsed, 12))),
"a hint that does not fit must leave the header untouched"
);
// Not folded in these modes, so there is nothing to expand.
for mode in [DisplayMode::Expanded, DisplayMode::Truncated] {
let out = block.output(&hinted(mode, 60));
assert!(!text_of(&out).contains(EXPAND_HINT), "{mode:?}");
}
// An empty body reuses the collapsed renderer as its placeholder in
// every mode; nothing to open there either.
let empty = ThinkingBlock::new("");
for mode in [DisplayMode::Expanded, DisplayMode::Truncated] {
let out = empty.output(&hinted(mode, 60));
assert!(!text_of(&out).contains(EXPAND_HINT), "empty/{mode:?}");
}
}
}

View file

@ -76,9 +76,15 @@ pub struct ScrollbackState {
/// Minimal mode only: lowest entry index that *might* be uncommitted (not
/// yet printed into native scrollback). A lower-bound perf hint so the
/// per-frame commit pass is O(new) rather than O(history); the authoritative
/// state is the `committed` id-set above. Clamped to `entries.len()` on
/// every removal so a `shift_remove` / `remove_from` can never strand it
/// past the end. Unused (always 0) in the alt-screen / inline modes.
/// state is the `committed` id-set above. Unused (always 0) in the
/// alt-screen / inline modes.
///
/// **Contract for every mutation that shifts entry positions:** the cursor
/// may be moved *down* freely (the scan re-skips committed entries via the
/// id-set; the only cost is a longer walk), but it must never end up
/// *above* an uncommitted entry's index. An entry below the cursor is
/// scanned by nobody — neither committed to native scrollback nor drawn in
/// the live tail — so it silently vanishes.
commit_scan_cursor: usize,
/// Minimal mode only: a bounded ring of entry IDs that were committed to
@ -561,22 +567,7 @@ impl ScrollbackState {
let mut entry = entry;
entry.id = id;
// Fresh Edit entries at the block's Collapsed default adopt the
// state-owned materialize policy. An explicit non-Collapsed mode
// survives; an explicit Collapsed is indistinguishable from the
// default and may be upgraded by the effective expanded default.
if let RenderBlock::ToolCall(ToolCallBlock::Edit(edit)) = &entry.block
&& entry.display_mode == DisplayMode::Collapsed
{
entry.display_mode = edit_default_display_mode(
self.appearance
.scrollback
.blocks
.edit
.effective_expanded(crate::appearance::cache::load_collapsed_edit_blocks()),
edit,
);
}
self.apply_edit_default_display_mode(&mut entry);
// Track if this entry is running
if entry.is_running {
@ -633,6 +624,70 @@ impl ScrollbackState {
self.push(ScrollbackEntry::new(block))
}
/// Add a finalized block positioned immediately **before** the entry `anchor`,
/// instead of at the end. Falls back to [`Self::push_block`] when `anchor` is
/// no longer present.
///
/// # Precondition
///
/// **`anchor` must not already be committed.** A terminal's native
/// scrollback is append-only, so inserting above a block already printed
/// there would emit the new block below content that logically follows it.
pub fn insert_block_before(&mut self, anchor: EntryId, block: RenderBlock) -> EntryId {
let Some(index) = self.entries.get_index_of(&anchor) else {
return self.push_block(block);
};
debug_assert!(
!self.committed.contains(&anchor),
"insert_block_before: anchor {anchor:?} is already committed — the inserted \
block would print out of order in native scrollback"
);
let id = EntryId::new(self.next_id);
self.next_id += 1;
let mut entry = ScrollbackEntry::new(block);
entry.id = id;
self.apply_edit_default_display_mode(&mut entry);
if entry.is_running {
self.running.insert(id);
}
self.entries.shift_insert(index, id, entry);
if let Some(selected) = self.selected.as_mut()
&& *selected >= index
{
*selected += 1;
}
self.commit_scan_cursor = self.commit_scan_cursor.min(index);
if self.batch_depth == 0 {
self.rebuild_turns();
}
self.gaps_may_be_dirty = true;
self.invalidate_layout_cache();
self.bump_content_generation();
id
}
/// Fresh Edit entries at the block's Collapsed default adopt the
/// state-owned materialize policy. An explicit non-Collapsed mode
/// survives; an explicit Collapsed is indistinguishable from the
/// default and may be upgraded by the effective expanded default.
fn apply_edit_default_display_mode(&self, entry: &mut ScrollbackEntry) {
if let RenderBlock::ToolCall(ToolCallBlock::Edit(edit)) = &entry.block
&& entry.display_mode == DisplayMode::Collapsed
{
entry.display_mode = edit_default_display_mode(
self.appearance
.scrollback
.blocks
.edit
.effective_expanded(crate::appearance::cache::load_collapsed_edit_blocks()),
edit,
);
}
}
/// Remove an entry by EntryId. No-op if the id is not present.
///
/// Used by the cancel-with-restore flow to undo the user prompt block
@ -652,15 +707,7 @@ impl ScrollbackState {
{
self.selected = self.entries.len().checked_sub(1);
}
// Keep the minimal-mode commit cursor pointing at the same *entry*: a
// mid-list `shift_remove` below the cursor shifts every later entry
// down one, so the cursor must move down with them. Clamping alone is
// NOT enough — with entries past the cursor, `min(cursor, len)` leaves
// the cursor unchanged and the first uncommitted entry slides below it,
// where no commit/tail walk ever looks again (it would silently vanish
// from minimal mode's scrollback AND live tail). A decremented cursor
// can only be *low*, which is safe: the walk re-skips already-committed
// entries via the authoritative `committed` id-set.
// Clamping alone is not enough here — see the cursor's contract.
if let Some(idx) = removed_index
&& idx < self.commit_scan_cursor
{
@ -3048,6 +3095,98 @@ mod tests {
assert!(state.dirty_heights.contains(&id));
}
#[test]
fn insert_block_before_positions_and_keeps_ids_unique() {
let mut state = ScrollbackState::new();
let a = state.push_block(stub_block("a"));
let c = state.push_block(stub_block("c"));
let b = state.insert_block_before(c, stub_block("b"));
assert_eq!(state.len(), 3);
assert_eq!(state.index_of_id(a), Some(0));
assert_eq!(state.index_of_id(b), Some(1));
assert_eq!(state.index_of_id(c), Some(2));
assert_ne!(b, a);
assert_ne!(b, c);
}
#[test]
fn insert_block_before_falls_back_to_push_when_the_anchor_is_gone() {
let mut state = ScrollbackState::new();
let a = state.push_block(stub_block("a"));
assert!(state.remove_entry(a));
let id = state.insert_block_before(a, stub_block("late"));
assert_eq!(state.len(), 1);
assert_eq!(state.index_of_id(id), Some(0));
}
#[test]
fn insert_block_before_keeps_the_selection_on_its_entry() {
let mut state = ScrollbackState::new();
state.push_block(stub_block("a"));
let anchor = state.push_block(stub_block("b"));
state.set_selected(Some(1)); // "b"
state.insert_block_before(anchor, stub_block("inserted"));
assert_eq!(state.index_of_id(anchor), Some(2));
assert_eq!(state.selected(), Some(2));
}
#[test]
fn insert_block_before_never_strands_the_entry_below_the_commit_frontier() {
// The shape minimal produces: a committed prefix, the cursor parked at
// the first uncommitted entry, and a block anchored above that entry.
let mut state = ScrollbackState::new();
let a = state.push_block(stub_block("a"));
let b = state.push_block(stub_block("b"));
let anchor = state.push(ScrollbackEntry::running(stub_block("running tool")));
state.mark_committed(0);
state.mark_committed(1);
state.set_commit_scan_cursor(2);
let inserted = state.insert_block_before(anchor, stub_block("inserted"));
assert_eq!(state.index_of_id(inserted), Some(2));
assert!(
state.commit_scan_cursor() <= 2,
"cursor must be pulled back to (at most) the insertion point, got {}",
state.commit_scan_cursor()
);
assert!(state.is_committed(a));
assert!(state.is_committed(b));
assert!(!state.is_committed(inserted));
assert!(!state.is_committed(anchor));
}
#[test]
#[should_panic(expected = "already committed")]
fn insert_block_before_rejects_an_already_committed_anchor() {
let mut state = ScrollbackState::new();
let anchor = state.push_block(stub_block("printed"));
state.mark_committed(0);
state.insert_block_before(anchor, stub_block("too late"));
}
#[test]
fn insert_block_before_rebuilds_turn_indices() {
// Turns are positional, so a mid-list insert must rebuild them.
let mut state = ScrollbackState::new();
state.push_block(RenderBlock::user_prompt("turn one"));
let anchor = state.push_block(stub_block("work"));
state.push_block(RenderBlock::user_prompt("turn two"));
state.insert_block_before(anchor, stub_block("inserted"));
// turn one = [0, 3), turn two = [3, 4)
assert_eq!(state.turn_containing(0), Some(0));
assert_eq!(state.turn_containing(1), Some(0));
assert_eq!(state.turn_containing(2), Some(0));
assert_eq!(state.turn_containing(3), Some(1));
}
#[test]
fn set_pending_user_input_toggles_flag_and_reports_change() {
let mut state = ScrollbackState::new();

View file

@ -69,6 +69,10 @@ pub struct EntryRenderer<'a> {
/// `block_pad_{left,right}` in minimal's `committed_appearance`, content
/// starts at column 0 (aligned with the welcome card).
hide_accent: bool,
/// Paint the accent bar with [`Modifier::DIM`] on top of its color, so a
/// rail that resolved to `Color::Reset` reads as chrome rather than
/// full-brightness content.
dim_accent: bool,
/// Session/worktree cwd (`AgentSession.cwd`) for Expanded tool paths.
cwd: Option<&'a Path>,
}
@ -89,6 +93,7 @@ impl<'a> EntryRenderer<'a> {
group_header_label: None,
flat_background: false,
hide_accent: false,
dim_accent: false,
cwd: None,
}
}
@ -112,6 +117,12 @@ impl<'a> EntryRenderer<'a> {
self
}
/// See [`Self::dim_accent`]. Height-neutral — `chrome_width` is unchanged.
pub fn with_dim_accent(mut self, dim: bool) -> Self {
self.dim_accent = dim;
self
}
/// Background to paint where the block itself has none (accent column,
/// gutter, bullets). In flat mode this is `Color::Reset` — the terminal's
/// own default background — so the entry inherits terminal transparency
@ -124,6 +135,17 @@ impl<'a> EntryRenderer<'a> {
}
}
/// Shared by every accent branch so the rail cannot be dim in one running
/// state and bright in another.
fn accent_paint_style(&self, color: ratatui::style::Color) -> Style {
let style = Style::default().fg(color);
if self.dim_accent {
style.add_modifier(ratatui::style::Modifier::DIM)
} else {
style
}
}
pub fn with_appearance(mut self, appearance: AppearanceConfig) -> Self {
self.appearance = appearance;
self
@ -806,7 +828,7 @@ impl Renderable for EntryRenderer<'_> {
// Pending user input: freeze the running wave. A solid
// accent at full color reads as "paused on you" without
// the loading-spinner motion.
let style = Style::default().fg(color);
let style = self.accent_paint_style(color);
for y in accent_area.y..accent_area.y + accent_area.height {
buf.set_string_safe(accent_area.x, y, crate::glyphs::accent_bar(), style);
}
@ -821,7 +843,7 @@ impl Renderable for EntryRenderer<'_> {
let brightness =
theme::wave_brightness(self.tick, logical_row, wave_rows, WAVE_SPEED);
let animated_color = blend_color(bg, color, brightness).unwrap_or(color);
let style = Style::default().fg(animated_color);
let style = self.accent_paint_style(animated_color);
buf.set_string_safe(accent_area.x, y, crate::glyphs::accent_bar(), style);
}
} else if use_collapsed_accent && !self.is_selected {
@ -830,7 +852,7 @@ impl Renderable for EntryRenderer<'_> {
// full-color branch so the selection reads as undimmed.
let bg = bg_color.unwrap_or(self.fallback_bg());
let dimmed = blend_color(bg, color, display_cfg.dim_accent).unwrap_or(color);
let style = Style::default().fg(dimmed);
let style = self.accent_paint_style(dimmed);
for y in accent_area.y..accent_area.y + accent_area.height {
buf.set_string_safe(
accent_area.x,
@ -841,7 +863,7 @@ impl Renderable for EntryRenderer<'_> {
}
} else {
// Static accent: full color
let style = Style::default().fg(color);
let style = self.accent_paint_style(color);
for y in accent_area.y..accent_area.y + accent_area.height {
buf.set_string_safe(accent_area.x, y, crate::glyphs::accent_bar(), style);
}

View file

@ -130,16 +130,18 @@ const PERMISSION_MODE_CHOICES: &[EnumChoice] = &[
// can fail. Commit on Enter only.
// ---------------------------------------------------------------------------
// The setting's own description carries the full explanation, so the choices
// are bare labels — an empty description collapses each to a single line.
const CODING_DATA_SHARING_CHOICES: &[EnumChoice] = &[
EnumChoice {
canonical: "opt-in",
display: "Opt in",
description: "Allow SpaceXAI to retain coding session data for model training and product improvement.",
description: "",
},
EnumChoice {
canonical: "opt-out",
display: "Opt out",
description: "Do not retain coding session data for training. Does not disable product analytics.",
description: "",
},
];
@ -314,9 +316,10 @@ const SCREEN_MODE_CHOICES: &[EnumChoice] = &[
];
// Voice-capture-mode catalog. SHELL-owned, persisted to `[ui].voice_capture_mode`.
// `hold` is only offered on terminals that report key releases (Kitty keyboard
// protocol); `effective_enum_choices` hides it elsewhere, and it falls back to
// `toggle` at runtime.
// `hold` is gated on `kitty_releases_reported`; `effective_enum_choices` hides it
// elsewhere, and it falls back to `toggle` at runtime. "Kitty-protocol terminal"
// in the copy below is a deliberate user-facing simplification: Alacritty <= 0.14
// negotiates the protocol yet never reports releases, so hold stays hidden there.
const VOICE_CAPTURE_MODE_CHOICES: &[EnumChoice] = &[
EnumChoice {
canonical: "toggle",
@ -1149,10 +1152,11 @@ pub fn default_settings() -> Vec<SettingMeta> {
key: "coding_data_sharing",
category: SettingCategory::Privacy,
owner: SettingOwner::Shell,
label: "Coding data sharing",
description: "Controls whether SpaceXAI may retain and train on coding session \
data. Does not affect product analytics; see Configuration and \
Monitoring docs.",
label: "Coding data, retention, and training",
description: "Opt-in to provide SpaceXAI the ability to retain and train on \
coding data, e.g., prompts, traces, & metrics, for training and \
debugging purposes. We may still collect simple user metrics, \
e.g. how many times you use the product or a feature.",
keywords: &[
"privacy",
"data",

View file

@ -31,5 +31,5 @@ pub use registry::{
SettingCategory, SettingKey, SettingKind, SettingMeta, SettingOwner, SettingValue,
SettingsRegistry, StringValidator, canonical_hunk_tracker_mode, canonical_screen_mode,
canonical_voice_capture_mode, canonical_voice_stt_language, current_value_for,
default_value_for, dynamic_enum_choices,
default_value_for, dynamic_enum_choices, is_consent_chooser,
};

View file

@ -710,6 +710,11 @@ pub fn current_value_for(
}
}
/// Consent chooser: no docs tip, and no `d` reset (hint or key).
pub fn is_consent_chooser(key: &str) -> bool {
key == "coding_data_sharing"
}
/// Default value for `key`, derived from the registry metadata.
pub fn default_value_for(meta: &SettingMeta) -> SettingValue {
match &meta.kind {

View file

@ -1,18 +1,11 @@
//! `/privacy` -- show or toggle privacy and data retention status.
//! `/privacy` -- open the "Coding data, retention, and training" setting.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Show or toggle privacy and data retention status.
///
/// Usage:
/// - `/privacy` show current status
/// - `/privacy opt-in` opt in to coding data sharing
/// - `/privacy opt-out` opt out of coding data sharing
///
/// Case-insensitive. Only unambiguous aliases are accepted (e.g. `in`,
/// `share`, `out`, `private`) — generic toggles like `on`/`off` are
/// rejected because they're ambiguous in privacy context.
const CODING_DATA_SHARING_KEY: &str = "coding_data_sharing";
/// Open settings on `coding_data_sharing`. Takes no arguments.
pub struct PrivacyCommand;
impl SlashCommand for PrivacyCommand {
@ -21,193 +14,90 @@ impl SlashCommand for PrivacyCommand {
}
fn description(&self) -> &str {
"Show or toggle privacy & data retention status"
// Reads as the row it opens: "Coding data, retention, and training".
"Open coding data, retention, and training settings"
}
fn usage(&self) -> &str {
"/privacy [opt-in|opt-out]"
"/privacy"
}
fn takes_args(&self) -> bool {
true
/// Trailing text is ignored, not rejected: `/privacy opt-in` from muscle
/// memory should land on the page, not error.
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
CommandResult::Action(Action::OpenSettingsFocus {
key: CODING_DATA_SHARING_KEY,
})
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
let arg = args.trim();
if arg.is_empty() {
return CommandResult::Action(Action::ShowPrivacyInfo);
}
match parse_privacy_arg(arg) {
Some(opted_in) => CommandResult::Action(Action::SetCodingDataSharing { opted_in }),
None => CommandResult::Error(format!(
"Unknown argument `{arg}`. Valid options: opt-in (aliases: in, share) | \
opt-out (aliases: out, private)."
)),
}
}
}
/// Parse `/privacy <arg>` into `Some(true)` (opt-in), `Some(false)`
/// (opt-out), or `None` (unknown). Case-insensitive ASCII matching.
#[doc(hidden)]
pub fn parse_privacy_arg(arg: &str) -> Option<bool> {
const OPT_IN_ALIASES: &[&str] = &["opt-in", "in", "share"];
const OPT_OUT_ALIASES: &[&str] = &["opt-out", "out", "private"];
if OPT_IN_ALIASES.iter().any(|a| arg.eq_ignore_ascii_case(a)) {
return Some(true);
}
if OPT_OUT_ALIASES.iter().any(|a| arg.eq_ignore_ascii_case(a)) {
return Some(false);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_opt_in_canonical() {
assert_eq!(parse_privacy_arg("opt-in"), Some(true));
}
#[test]
fn parse_opt_out_canonical() {
assert_eq!(parse_privacy_arg("opt-out"), Some(false));
}
/// Case-insensitive matching.
#[test]
fn parse_case_insensitive() {
for variant in &["OPT-IN", "Opt-In", "opt-IN", "OpT-iN"] {
assert_eq!(
parse_privacy_arg(variant),
Some(true),
"case-insensitive parse must accept `{variant}` as opt-in",
);
}
for variant in &["OPT-OUT", "Opt-Out", "opt-OUT", "OpT-oUt"] {
assert_eq!(
parse_privacy_arg(variant),
Some(false),
"case-insensitive parse must accept `{variant}` as opt-out",
);
}
}
/// Pins the accepted alias catalog.
#[test]
fn parse_opt_in_aliases() {
for alias in &["in", "share"] {
assert_eq!(
parse_privacy_arg(alias),
Some(true),
"alias `{alias}` must map to opt-in",
);
}
}
#[test]
fn parse_opt_out_aliases() {
for alias in &["out", "private"] {
assert_eq!(
parse_privacy_arg(alias),
Some(false),
"alias `{alias}` must map to opt-out",
);
}
}
/// Ambiguous generic-toggle aliases must be rejected — `/privacy on`
/// is ambiguous (could mean opt-in or opt-out).
#[test]
fn parse_rejects_ambiguous_generic_aliases() {
for ambiguous in &[
"on", "off", "true", "false", "enable", "enabled", "disable", "disabled",
] {
assert_eq!(
parse_privacy_arg(ambiguous),
None,
"ambiguous alias `{ambiguous}` MUST be rejected — it would let a user typing \
`/privacy {ambiguous}` get the OPPOSITE of their intent in privacy context. \
See Security Issue 10 in PR 9 R1.",
);
}
}
/// Unknown arguments return None → the command surfaces an error
/// listing valid options. Pins the "no silent fallback" contract.
#[test]
fn parse_unknown_returns_none() {
for unknown in &["yes", "no", "maybe", "opt-maybe", "", " ", "1", "0"] {
assert_eq!(
parse_privacy_arg(unknown),
None,
"unknown arg `{unknown}` must NOT parse",
);
}
}
/// Alias families must not overlap.
#[test]
fn alias_families_disjoint() {
let opt_in_results: Vec<bool> = ["opt-in", "in", "share"]
.iter()
.map(|a| parse_privacy_arg(a).unwrap())
.collect();
assert!(
opt_in_results.iter().all(|b| *b),
"every opt-in alias must parse to true",
);
let opt_out_results: Vec<bool> = ["opt-out", "out", "private"]
.iter()
.map(|a| parse_privacy_arg(a).unwrap())
.collect();
assert!(
opt_out_results.iter().all(|b| !*b),
"every opt-out alias must parse to false",
);
}
/// Error message must list every accepted alias.
#[test]
fn error_message_lists_all_accepted_aliases() {
/// Run `/privacy <args>` in `mode`.
fn run_privacy(args: &str, mode: crate::app::ScreenMode) -> CommandResult {
use crate::acp::model_state::ModelState;
use crate::app::bundle::BundleState;
let cmd = PrivacyCommand;
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = CommandExecCtx {
models: &models,
session_id: None,
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
screen_mode: mode,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot::default(),
};
let result = cmd.run(&mut ctx, "garbage-input");
match result {
CommandResult::Error(msg) => {
// Every accepted alias appears in the error message.
for alias in &["opt-in", "in", "share", "opt-out", "out", "private"] {
assert!(
msg.contains(alias),
"error message must mention alias `{alias}` so the user knows \
what to type; msg = {msg:?}",
);
}
// Dropped ambiguous aliases must not appear.
for dropped in &["off", "true", "false", "enable", "disable"] {
assert!(
!msg.contains(dropped),
"dropped alias `{dropped}` must NOT appear in error message \
(would suggest it's still accepted); msg = {msg:?}",
);
}
}
other => panic!("expected Error result for unknown arg, got {other:?}"),
PrivacyCommand.run(&mut ctx, args)
}
fn opens_settings_row(result: &CommandResult) -> bool {
matches!(
result,
CommandResult::Action(Action::OpenSettingsFocus {
key: CODING_DATA_SHARING_KEY
})
)
}
/// Minimal suppresses the privacy banner, so `/privacy` is the only
/// route to the page there — no mode may fall back to something else.
#[test]
fn privacy_opens_settings_row_in_every_screen_mode() {
use crate::app::ScreenMode;
for mode in [
ScreenMode::Fullscreen,
ScreenMode::Inline,
ScreenMode::Minimal,
] {
let result = run_privacy("", mode);
assert!(
opens_settings_row(&result),
"`/privacy` in {mode:?} must open the settings row, got {result:?}",
);
}
}
/// The arguments this used to accept must not linger as hidden aliases
/// that change a privacy preference straight from the prompt.
#[test]
fn arguments_are_ignored_not_honored() {
use crate::app::ScreenMode;
assert!(
!PrivacyCommand.takes_args(),
"the dropdown must not offer an argument slot"
);
for args in [
" ", "opt-in", "opt-out", "in", "out", "share", "private", "status", "info",
"garbage",
] {
let result = run_privacy(args, ScreenMode::Inline);
assert!(
opens_settings_row(&result),
"`/privacy {args}` must just open the page, got {result:?}",
);
}
}
}

View file

@ -26,11 +26,11 @@ impl SlashCommand for VoiceCommand {
}
fn description(&self) -> &str {
// Chord is Ctrl+Space or F8. On non-Kitty terminals hold-to-talk is
// impossible (no key releases), so it's always toggle — say so. On Kitty
// it's configurable (toggle or hold via `voice_capture_mode`), so leave
// the behavior unspecified.
if crate::app::kitty_flags_pushed() {
// Chord is Ctrl+Space or F8. Without key releases hold-to-talk is
// impossible, so it's always toggle — say so. With them it's
// configurable (toggle or hold via `voice_capture_mode`), so leave the
// behavior unspecified.
if crate::app::kitty_releases_reported() {
"Dictation (Ctrl+Space/F8; Esc/Enter to stop)"
} else {
"Toggle dictation (Ctrl+Space/F8; Esc/Enter to stop)"

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,
}
}

View file

@ -3,7 +3,7 @@
//! Individual test modules import via `use super::common::*`.
pub(crate) use serde_json::json;
pub(crate) use std::path::Path;
pub(crate) use std::path::{Path, PathBuf};
pub(crate) use std::time::{Duration, Instant};
pub(crate) use xai_grok_pager_pty_harness::{
AgentTurnExpectation, ContentController, EnvOp, MockModel, PtyExitPoll, PtyHarness,
@ -917,6 +917,63 @@ pub(crate) const MINIMAL_IDLE_SENTINEL: &str = "minimal · /help";
pub(crate) const MINIMAL_SWITCH_BACK_IDLE_SENTINEL: &str =
"minimal · /fullscreen to go back · /help";
/// Header of minimal's parked plan-approval controls strip.
pub(crate) const PLAN_PARKED_SENTINEL: &str = "Plan ready for review";
/// A plan body the `exit_plan_mode` tool will read off disk. Every step carries
/// a unique `{tag}{NNN}` sentinel, because a truncated plan still contains its
/// head and would pass a plain substring check.
pub(crate) fn plan_body(tag: &str, lines: usize) -> String {
let mut s = format!("# {tag} Plan\n\n");
for i in 0..lines {
s.push_str(&format!("- {tag}{i:03} step of the plan\n"));
}
s
}
/// Steps of a [`plan_body`] missing from everything the user could reach by
/// scrolling: native scrollback plus the visible screen.
pub(crate) fn plan_lines_missing(harness: &mut PtyHarness, tag: &str, lines: usize) -> Vec<usize> {
let full = harness.full_text();
(0..lines)
.filter(|i| !full.contains(&format!("{tag}{i:03}")))
.collect()
}
/// Steps of a [`plan_body`] that appear more than once — the print-once guard.
pub(crate) fn plan_lines_duplicated(
harness: &mut PtyHarness,
tag: &str,
lines: usize,
) -> Vec<usize> {
let full = harness.full_text();
(0..lines)
.filter(|i| full.matches(&format!("{tag}{i:03}")).count() > 1)
.collect()
}
/// Locate `<grok_home>/sessions/<encoded cwd>/<session id>/`, where the shell
/// keeps the session's `plan.md`. Polls: the first turn creates it
/// asynchronously.
pub(crate) fn session_dir(content: &ContentController, harness: &mut PtyHarness) -> PathBuf {
let sessions = content.home().join(".grok").join("sessions");
for _ in 0..100 {
if let Ok(outer) = std::fs::read_dir(&sessions) {
for cwd_dir in outer.flatten() {
if let Ok(inner) = std::fs::read_dir(cwd_dir.path()) {
for entry in inner.flatten() {
if entry.path().is_dir() {
return entry.path();
}
}
}
}
}
harness.update(Duration::from_millis(100));
}
panic!("no session dir under {}", sessions.display());
}
/// Spawn the pager in minimal mode against `content` at the default size.
pub(crate) fn spawn_minimal(content: &ContentController) -> PtyHarness {
spawn_minimal_sized(content, DEFAULT_ROWS, DEFAULT_COLS)

View file

@ -0,0 +1,131 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
/// Taller than the terminal, so a plan held in the live region is clipped.
const PLAN_LINES: usize = 100;
fn missing(harness: &mut PtyHarness, tag: &str) -> Vec<usize> {
plan_lines_missing(harness, tag, PLAN_LINES)
}
fn duplicated(harness: &mut PtyHarness, tag: &str) -> Vec<usize> {
plan_lines_duplicated(harness, tag, PLAN_LINES)
}
fn park_plan(
content: &ContentController,
harness: &mut PtyHarness,
dir: &std::path::Path,
tag: &str,
call_id: &str,
prompt: &str,
) -> AgentTurnExpectation {
std::fs::write(dir.join("plan.md"), plan_body(tag, PLAN_LINES)).expect("seed plan.md");
let expectation = expect_tool_turn(content, call_id, "exit_plan_mode", "{}".into());
harness
.inject_keys(format!("{prompt}\r").as_bytes())
.expect("submit plan prompt");
harness
.wait_for_text(PLAN_PARKED_SENTINEL, Duration::from_secs(60))
.unwrap_or_else(|e| {
panic!(
"plan approval never parked: {e}\nscreen:\n{}",
harness.screen_contents()
)
});
for _ in 0..10 {
harness.update(Duration::from_millis(100));
}
expectation
}
/// Minimal's plan-approval contract: the plan body reaches NATIVE SCROLLBACK
/// **while the approval is parked**, not only once the user answers. Users
/// reported reading a truncated plan with nothing behind it in the scrollback;
/// design doc §6.16.
///
/// Also pins the revision path: a revised plan is a fresh `exit_plan_mode` with
/// a new `tool_call_id`, and must commit as its own block exactly once.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_parked_plan_commits_to_scrollback() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} first turn done."));
// ~5x the screen, so live-region-only rendering is guaranteed to clip.
let mut harness = spawn_minimal_sized(&content, 20, 100);
wait_minimal_ready(&mut harness);
// A first turn, so the session (and its plan.md directory) exists.
harness.inject_keys(b"go\r").expect("submit first turn");
harness
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(40))
.expect("first turn streams");
let dir = session_dir(&content, &mut harness);
// ── plan 1, parked ──
let _first = park_plan(
&content,
&mut harness,
&dir,
"ONE",
"call_plan_one",
"present the plan",
);
assert!(
missing(&mut harness, "ONE").is_empty(),
"the whole plan must be readable while the approval is parked \
(missing {:?})\nscrollback rows: {}\nscreen:\n{}",
missing(&mut harness, "ONE"),
harness.scrollback_text().lines().count(),
harness.screen_contents(),
);
assert!(
duplicated(&mut harness, "ONE").is_empty(),
"parked plan must be printed exactly once (duplicated {:?})",
duplicated(&mut harness, "ONE"),
);
// ── revise: `s` focuses the feedback input, Enter sends it ──
harness.inject_keys(b"s").expect("request changes");
harness.update(Duration::from_millis(400));
let _second = park_plan(
&content,
&mut harness,
&dir,
"TWO",
"call_plan_two",
"make it shorter",
);
assert!(
missing(&mut harness, "TWO").is_empty(),
"the revised plan must also be readable while parked (missing {:?})",
missing(&mut harness, "TWO"),
);
assert!(
duplicated(&mut harness, "ONE").is_empty() && duplicated(&mut harness, "TWO").is_empty(),
"a revision must not re-emit either plan (ONE {:?}, TWO {:?})",
duplicated(&mut harness, "ONE"),
duplicated(&mut harness, "TWO"),
);
// ── approve ──
harness.inject_keys(b"a").expect("approve");
for _ in 0..40 {
harness.update(Duration::from_millis(100));
}
assert!(
duplicated(&mut harness, "ONE").is_empty() && duplicated(&mut harness, "TWO").is_empty(),
"approving must not re-print the plan (ONE {:?}, TWO {:?})",
duplicated(&mut harness, "ONE"),
duplicated(&mut harness, "TWO"),
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -0,0 +1,56 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
const PLAN_LINES: usize = 60;
const TAG: &str = "QUIT";
/// The permanent-loss shape of the parked-plan bug users hit: quitting without
/// answering must still leave the whole plan in the terminal. The pinned live
/// region is repainted and never retained, so only what was committed at park
/// time survives the process. Design doc §6.16.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_parked_plan_survives_quit() {
let content = ContentController::start().await.expect("start content");
content.set_response(format!("{MOCK_RESPONSE_SENTINEL} first turn done."));
let mut harness = spawn_minimal_sized(&content, 20, 100);
wait_minimal_ready(&mut harness);
harness.inject_keys(b"go\r").expect("submit first turn");
harness
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(40))
.expect("first turn streams");
let dir = session_dir(&content, &mut harness);
std::fs::write(dir.join("plan.md"), plan_body(TAG, PLAN_LINES)).expect("seed plan.md");
let _expectation = expect_tool_turn(&content, "call_plan_quit", "exit_plan_mode", "{}".into());
harness
.inject_keys(b"present the plan\r")
.expect("submit plan prompt");
harness
.wait_for_text(PLAN_PARKED_SENTINEL, Duration::from_secs(60))
.expect("plan approval parks");
for _ in 0..10 {
harness.update(Duration::from_millis(100));
}
// Quit without answering (Ctrl+Q arms, Ctrl+Q confirms).
let _ = harness.inject_keys(b"\x11");
harness.update(Duration::from_millis(300));
let _ = harness.inject_keys(b"\x11");
for _ in 0..40 {
harness.update(Duration::from_millis(100));
}
let missing = plan_lines_missing(&mut harness, TAG, PLAN_LINES);
assert!(
missing.is_empty(),
"plan must survive in the terminal after quitting while parked \
(missing {}/{PLAN_LINES}: {missing:?})",
missing.len(),
);
let _ = harness.quit();
}

View file

@ -0,0 +1,233 @@
// Per-test-case module for the `pty_e2e` integration test crate.
#[allow(unused_imports)]
use crate::common::*;
use xai_grok_pager_pty_harness::{
InferenceEndpoint, InferenceExpectation, InferenceRequestMatcher,
};
/// Must never appear in the answer text, so the assertions can tell them apart.
const REASONING_SENTINEL: &str = "REASONINGSENTINEL";
/// `crate::glyphs::accent_bar()` on a non-legacy console.
const RAIL: &str = "\u{2503}";
/// Kept alive for the duration of the assertions.
struct Turn {
harness: PtyHarness,
_content: ContentController,
_expectation: InferenceExpectation,
}
/// Drive one minimal turn that streams reasoning and then an answer, under
/// `NO_COLOR=1` — the case that was 100% broken, because the `bg_blend` fade is
/// a complete no-op under the terminal-native palette.
async fn run_reasoning_turn(collapse_thinking: bool) -> Turn {
// Reasoning summary deltas are a Responses-API stream shape.
let content = ContentController::start_with_models(vec![
MockModel::new("test-model").with_api_backend("responses"),
])
.await
.expect("start content");
let reasoning = format!("{REASONING_SENTINEL} pondering syllables quietly and at some length");
let answer = format!("{MOCK_RESPONSE_SENTINEL} the answer body.");
let expectation = content.expect_response(
"minimal reasoning-vs-output turn",
InferenceRequestMatcher::foreground(InferenceEndpoint::Responses),
ScriptedResponse::sse(sse::responses_api_reasoning_and_text_events(
&reasoning,
&answer,
"test-model",
)),
);
content.set_response(answer.clone());
// Ingestion is gated on this toggle, and the sandbox `$HOME` has no config.
std::fs::create_dir_all(content.home().join(".grok")).expect("mk .grok");
std::fs::write(
content.home().join(".grok/config.toml"),
"[ui]\nshow_thinking_blocks = true\n",
)
.expect("write config");
if collapse_thinking {
let grok_home = content.sandbox().grok_home().to_path_buf();
std::fs::create_dir_all(&grok_home).expect("mk grok home");
std::fs::write(
grok_home.join("pager.toml"),
"[terminal]\nminimal_collapse_thinking = true\n",
)
.expect("write pager.toml");
}
let binary = pager_binary().expect("resolve pager binary");
let mut harness = PtyHarness::spawn_with_content_env(
&binary,
DEFAULT_ROWS,
DEFAULT_COLS,
&content,
MINIMAL_ARGS,
&[("NO_COLOR", "1")],
)
.expect("spawn minimal pager");
harness.set_respond_to_queries(true);
wait_minimal_ready(&mut harness);
harness
.inject_keys(format!("{PROMPT}\r").as_bytes())
.expect("submit prompt");
harness
.wait_for_full_text(MOCK_RESPONSE_SENTINEL, Duration::from_secs(30))
.expect("turn committed");
harness
.wait_for_full_text("Thought", Duration::from_secs(10))
.expect("thinking header committed");
Turn {
harness,
_content: content,
_expectation: expectation,
}
}
/// Every styled screen row carrying `needle`, rendered as
/// `["text" dim=… italic=…]` runs: the SGR attributes the terminal emulator
/// actually received, not just the glyphs.
fn styled_rows_with(harness: &PtyHarness, needle: &str) -> Vec<String> {
harness
.screen_styled()
.into_iter()
.filter(|line| line.runs.iter().any(|r| r.text.contains(needle)))
.map(|line| {
line.runs
.iter()
.map(|r| {
format!(
"[{:?} dim={} italic={}]",
r.text.trim_end(),
r.dim,
r.italic
)
})
.collect::<Vec<_>>()
.join(" ")
})
.collect()
}
/// Reasoning must read as "not the answer" in a static native scrollback, with
/// no blank-row separator, no indent, and under `NO_COLOR` no color delta at
/// all. Three orthogonal cues, on the body rows and off the assistant rows.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_thinking_is_visually_distinct_from_output() {
let Turn { mut harness, .. } = run_reasoning_turn(false).await;
harness
.wait_for_full_text(REASONING_SENTINEL, Duration::from_secs(10))
.unwrap_or_else(|e| {
panic!(
"reasoning body must be committed: {e}\nfull:\n{}",
harness.full_text()
)
});
let screen = harness.screen_contents();
eprintln!("─── minimal screen (NO_COLOR=1) ───\n{screen}\n───");
for row in styled_rows_with(&harness, REASONING_SENTINEL) {
eprintln!("reasoning row: {row}");
}
for row in styled_rows_with(&harness, MOCK_RESPONSE_SENTINEL) {
eprintln!("answer row: {row}");
}
// 1. Structural: the rail in column 0.
let reasoning_row = screen
.lines()
.find(|l| l.contains(REASONING_SENTINEL))
.unwrap_or_else(|| panic!("reasoning row on screen:\n{screen}"));
assert!(
reasoning_row.starts_with(RAIL),
"reasoning must keep its accent rail: {reasoning_row:?}"
);
let answer_row = screen
.lines()
.find(|l| l.contains(MOCK_RESPONSE_SENTINEL))
.unwrap_or_else(|| panic!("answer row on screen:\n{screen}"));
assert!(
!answer_row.starts_with(RAIL),
"assistant output must not wear a rail: {answer_row:?}"
);
// 2. Attributes: SGR survives NO_COLOR where a foreground blend does not.
let runs_with = |needle: &str| -> Vec<_> {
harness
.screen_styled()
.into_iter()
.flat_map(|l| l.runs)
.filter(|r| r.text.contains(needle))
.collect::<Vec<_>>()
};
let reasoning_runs = runs_with(REASONING_SENTINEL);
assert!(!reasoning_runs.is_empty(), "no styled reasoning run found");
for run in &reasoning_runs {
assert!(run.dim, "reasoning must be dim under NO_COLOR: {run:?}");
assert!(run.italic, "reasoning must be italic: {run:?}");
}
let answer_runs = runs_with(MOCK_RESPONSE_SENTINEL);
assert!(!answer_runs.is_empty(), "no styled answer run found");
assert!(
answer_runs.iter().any(|r| !r.dim && !r.italic),
"assistant output must stay undimmed and upright: {answer_runs:?}"
);
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}
/// The collapsed header advertises the only way back into the body, and
/// `Ctrl+E` must honour the advertisement by re-printing it in full (K10).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn minimal_collapse_thinking_toggle_folds_and_ctrl_e_reopens() {
let Turn { mut harness, .. } = run_reasoning_turn(true).await;
harness
.wait_for_full_text("ctrl+e to expand", Duration::from_secs(10))
.unwrap_or_else(|e| {
panic!(
"collapsed reasoning must advertise the expand key: {e}\nfull:\n{}",
harness.full_text()
)
});
eprintln!(
"─── collapsed ([terminal] minimal_collapse_thinking = true) ───\n{}\n───",
harness.screen_contents()
);
assert!(
!harness.contains_full_text(REASONING_SENTINEL),
"the body must be folded away:\n{}",
harness.full_text()
);
harness.inject_keys(b"\x05").expect("ctrl+e");
harness
.wait_for_full_text(REASONING_SENTINEL, Duration::from_secs(10))
.unwrap_or_else(|e| {
panic!(
"ctrl+e must re-print the folded reasoning: {e}\nfull:\n{}",
harness.full_text()
)
});
eprintln!("─── after ctrl+e ───\n{}\n───", harness.screen_contents());
assert!(
!harness.contains_text("panicked"),
"pager panicked\nscreen:\n{}",
harness.screen_contents()
);
quit_minimal(&mut harness);
}

View file

@ -20,6 +20,8 @@ mod minimal_flush_left_no_hpad;
mod minimal_help_opens_command_palette;
mod minimal_lookup_commits_one_line_summary;
mod minimal_new_session_keeps_history_and_resets;
mod minimal_parked_plan_commits_to_scrollback;
mod minimal_parked_plan_survives_quit;
mod minimal_queue_indicator_shows_while_running;
mod minimal_resize_preserves_committed_scrollback;
mod minimal_settings_modal_opens_and_closes;
@ -28,5 +30,6 @@ mod minimal_short_response_stays_on_screen;
mod minimal_slash_dropdown_dismisses_with_esc;
mod minimal_slash_switches_from_fullscreen;
mod minimal_slash_switches_to_fullscreen;
mod minimal_thinking_is_visually_distinct_from_output;
mod minimal_transcript_opens_in_pager;
mod minimal_transcript_pager_restore_no_artifacts;

View file

@ -5270,51 +5270,23 @@ fn default_selected_permission_mouse_click_on_indicator_opens_picker_in_one_clic
}
}
/// The `/privacy` slash command's argument parser
/// is case-insensitive and supports a deliberately-pared-down list of
/// unambiguous-semantic aliases. The unit-level coverage lives in the
/// slash command module; this e2e test pins the integration contract
/// (the parser is reachable from the slash command and produces the
/// expected `Action`).
///
/// Ambiguous aliases
/// (`on/off/true/false/enable/disable`) were DROPPED because they
/// could be read either as "turn on privacy" (=opt-out) or "turn on
/// sharing" (=opt-in). For a privacy-critical setting we err on the
/// side of explicit, unambiguous arguments. The test below verifies
/// both the accept list AND the reject list.
/// `/privacy` takes no arguments: it opens the settings page and nothing
/// else. The alias parser it used to carry (`opt-in`, `share`, `out`, …) is
/// gone — a one-word prompt alias could flip a privacy preference with none
/// of the disclosure copy in front of the user, and the ambiguous forms
/// (`on`/`off`) risked landing on the opposite of the intent.
#[test]
fn pr9_privacy_slash_command_parses_aliases() {
use xai_grok_pager::slash::commands::privacy::parse_privacy_arg;
fn pr9_privacy_slash_command_takes_no_arguments() {
use xai_grok_pager::slash::commands::builtin_commands;
use xai_grok_pager::slash::registry::CommandRegistry;
// Canonical names.
assert_eq!(parse_privacy_arg("opt-in"), Some(true));
assert_eq!(parse_privacy_arg("opt-out"), Some(false));
// Case-insensitive (sample).
assert_eq!(parse_privacy_arg("Opt-In"), Some(true));
assert_eq!(parse_privacy_arg("OPT-OUT"), Some(false));
// Unambiguous-semantic aliases (pruned list).
assert_eq!(parse_privacy_arg("in"), Some(true));
assert_eq!(parse_privacy_arg("out"), Some(false));
assert_eq!(parse_privacy_arg("share"), Some(true));
assert_eq!(parse_privacy_arg("private"), Some(false));
// Ambiguous aliases MUST be rejected. `/privacy on`
// could be read as "turn on privacy" (=opt-out, the OPPOSITE of
// what an earlier mapping returned). For a privacy
// setting, ambiguity = silent data-exfiltration risk.
for ambiguous in &["on", "off", "true", "false", "enable", "disable"] {
assert_eq!(
parse_privacy_arg(ambiguous),
None,
"ambiguous alias `{ambiguous}` MUST be rejected (PR 9 R1, Security Issue 10)",
);
}
// Unknown.
assert_eq!(parse_privacy_arg("maybe"), None);
let reg = CommandRegistry::new(builtin_commands());
let cmd = reg.get("privacy").expect("/privacy must be registered");
assert!(
!cmd.takes_args(),
"/privacy must not advertise an argument slot"
);
assert_eq!(cmd.usage(), "/privacy");
}
// ---------------------------------------------------------------------------