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:
parent
02d9359435
commit
5da6962e4a
192 changed files with 10337 additions and 3421 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)), ®);
|
||||
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)), ®);
|
||||
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)), ®);
|
||||
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)), ®);
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
InputOutcome::Action(Action::OpenUrl(ref url))
|
||||
if url == crate::views::privacy_banner::PRIVACY_BANNER_POLICY_URL
|
||||
));
|
||||
draw_frame_privacy(&mut agent, ®, &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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, .. } => {
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue