Synced from monorepo
Synced from monorepo Changes: - Workspace server: report `/ready` as failed with dwell on hub connect failure - Refresh OIDC token for the Grok agent in the shell - ACP terminal output recorder - Cross-platform provider auth commands in the shell - Default `/resume` to Grok sessions with a hint for hidden external sessions - Resume sessions by title with `--resume` - Limit app-builder archive size - Data-driven tag labels for slash commands - Doctor fixes for tmux - Custom provider gateways and subprocess environment policy in the shell - `/tutorial` — opt-in onboarding tour of Grok Build - Soft and required CLI version checks in the shell - Privacy banner env overrides survive live settings updates - Add remote flag to override the image-edit model - Return profile fields from auth info even when the access token is expired - Add edit control on queued prompt rows - Keep fail-closed policy when clearing orphans with no team - Setting to disable the Ctrl+Space/F8 voice shortcut - Pass `--raw` to pw-record so Linux dictation works on older PipeWire - Validate git URLs when adding marketplace entries - Stop shipping stale tool-doc parameter and tool names - Re-point dashboard attach after `/fork` only when the parent was attached - Surface Grok Computer media-generation results as file-path chunks - Clear web background-task tray on kill and keep the task description - Show privacy upsell banner in agent view until acted on - Add tools-server client callback surface - Protect persistent global hook sources Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
This commit is contained in:
parent
a5727c5960
commit
69f0ba880a
286 changed files with 22939 additions and 9624 deletions
53
Cargo.lock
generated
53
Cargo.lock
generated
|
|
@ -5280,9 +5280,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.9"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
|
|
@ -5293,8 +5293,8 @@ dependencies = [
|
|||
"num-traits",
|
||||
"png",
|
||||
"tiff",
|
||||
"zune-core 0.5.1",
|
||||
"zune-jpeg 0.5.15",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6267,9 +6267,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.7.8"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "692af879e4d9383c0fd9dec15524af6b6977c8bf1c6b278a4526d5341347c574"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
|
|
@ -10413,16 +10413,16 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.10.3"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
|
||||
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error",
|
||||
"weezl",
|
||||
"zune-jpeg 0.4.21",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -12047,6 +12047,12 @@ dependencies = [
|
|||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wildmatch"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d654e41fe05169e03e27b97e0c23716535da037c1652a31fd99c6b2fad84059"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
|
@ -13389,7 +13395,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "xai-grok-pager"
|
||||
version = "0.2.110"
|
||||
version = "0.2.111"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"ansi-to-tui",
|
||||
|
|
@ -13479,7 +13485,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "xai-grok-pager-bin"
|
||||
version = "0.2.110"
|
||||
version = "0.2.111"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
|
|
@ -13743,7 +13749,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "xai-grok-shell"
|
||||
version = "0.2.110"
|
||||
version = "0.2.111"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
|
|
@ -13934,12 +13940,15 @@ dependencies = [
|
|||
name = "xai-grok-subagent-resolution"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"xai-grok-agent",
|
||||
"xai-grok-sampling-types",
|
||||
"xai-grok-tools",
|
||||
"xai-tool-types",
|
||||
|
|
@ -14079,6 +14088,7 @@ dependencies = [
|
|||
"url",
|
||||
"uuid",
|
||||
"which",
|
||||
"wildmatch",
|
||||
"windows 0.61.3",
|
||||
"wiremock",
|
||||
"xai-computer-hub-core",
|
||||
|
|
@ -14139,7 +14149,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "xai-grok-version"
|
||||
version = "0.2.110"
|
||||
version = "0.2.111"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
|
@ -14804,34 +14814,19 @@ dependencies = [
|
|||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.4.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
|
||||
dependencies = [
|
||||
"zune-core 0.4.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core 0.5.1",
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ http = "1"
|
|||
http-body-util = "0.1"
|
||||
humantime-serde = "1"
|
||||
ignore = "0.4"
|
||||
image = { version = "0.25.9", default-features = false }
|
||||
image = { version = "0.25.10", default-features = false }
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
indicatif = "0.18"
|
||||
infer = "0.19.0"
|
||||
|
|
@ -273,6 +273,7 @@ walkdir = "2"
|
|||
webbrowser = { version = "1.0.4" }
|
||||
which = "8"
|
||||
whoami = "1.4"
|
||||
wildmatch = "2"
|
||||
windows = { version = "0.61", features = ["Win32_Security", "Win32_Security_Authorization", "Win32_Foundation", "Win32_System_Threading", "Win32_System_JobObjects", "Win32_System_Console", "Win32_System_Pipes"] }
|
||||
wiremock = "0.6"
|
||||
wl-clipboard-rs = "0.9"
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
30192d2eef5d91a8fff0e53957de5bd05b43398c
|
||||
95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
|
||||
|
|
|
|||
|
|
@ -273,6 +273,8 @@ mod tests {
|
|||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
query_params: Default::default(),
|
||||
env_http_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(128_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
stream_tool_calls: None,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ fn test_config_with_window(context_window: u64) -> SamplingConfig {
|
|||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
query_params: Default::default(),
|
||||
env_http_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(context_window)
|
||||
.expect("test context_window must be non-zero"),
|
||||
reasoning_effort: None,
|
||||
|
|
@ -1170,6 +1172,8 @@ async fn update_sampling_config_is_queryable() {
|
|||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
query_params: Default::default(),
|
||||
env_http_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(200_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
stream_tool_calls: None,
|
||||
|
|
@ -1555,6 +1559,8 @@ async fn build_request_uses_sampling_config() {
|
|||
top_p: Some(0.9),
|
||||
api_backend: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
query_params: Default::default(),
|
||||
env_http_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(128_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
stream_tool_calls: None,
|
||||
|
|
@ -3696,6 +3702,8 @@ async fn sampling_config_survives_compaction_replacement() {
|
|||
top_p: Some(0.95),
|
||||
api_backend: ApiBackend::Responses,
|
||||
extra_headers: Default::default(),
|
||||
query_params: Default::default(),
|
||||
env_http_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(500_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
stream_tool_calls: None,
|
||||
|
|
@ -3779,6 +3787,8 @@ async fn model_metadata_lost_after_compaction_then_recovered_on_next_turn() {
|
|||
top_p: Some(0.95),
|
||||
api_backend: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
query_params: Default::default(),
|
||||
env_http_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(500_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
stream_tool_calls: None,
|
||||
|
|
@ -3867,6 +3877,8 @@ async fn context_window_downgrade_triggers_auto_compact() {
|
|||
top_p: Some(0.95),
|
||||
api_backend: ApiBackend::Responses,
|
||||
extra_headers: Default::default(),
|
||||
query_params: Default::default(),
|
||||
env_http_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(500_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
stream_tool_calls: None,
|
||||
|
|
|
|||
|
|
@ -409,6 +409,8 @@ mod tests {
|
|||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
query_params: Default::default(),
|
||||
env_http_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(128_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
stream_tool_calls: None,
|
||||
|
|
|
|||
|
|
@ -181,6 +181,8 @@ mod tests {
|
|||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
query_params: Default::default(),
|
||||
env_http_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(128_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
stream_tool_calls: None,
|
||||
|
|
@ -224,6 +226,8 @@ mod tests {
|
|||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
query_params: Default::default(),
|
||||
env_http_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(128_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
stream_tool_calls: None,
|
||||
|
|
|
|||
|
|
@ -1037,6 +1037,7 @@ impl AgentBuilder {
|
|||
session_env: self.session_env.unwrap_or_default(),
|
||||
notification_handle: self.notification_handle.clone(),
|
||||
owner_session_id: self.owner_session_id.clone(),
|
||||
subagent: None,
|
||||
parent_scheduler_handle: self.parent_scheduler_handle.take(),
|
||||
skills: skill_info.clone(),
|
||||
state_path,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ pub enum PromptAudience {
|
|||
Subagent,
|
||||
}
|
||||
use xai_grok_tools::bridge::ToolBridge;
|
||||
use xai_grok_tools::types::template_renderer::TemplateRenderer;
|
||||
/// Agent-specific inputs for system prompt rendering.
|
||||
///
|
||||
/// Serializable (JSON/YAML) so users can dump it and inspect fields.
|
||||
|
|
@ -259,12 +260,21 @@ impl PromptContext {
|
|||
/// MiniJinja so that `${{ tools.by_kind.* }}` variables resolve
|
||||
/// correctly regardless of prompt mode.
|
||||
pub async fn render(&self, tool_bridge: &ToolBridge) -> Option<String> {
|
||||
let renderer = tool_bridge.template_renderer_snapshot().await?;
|
||||
self.render_with_renderer(&renderer)
|
||||
}
|
||||
/// Render the full system prompt from a finalized tool-name renderer.
|
||||
///
|
||||
/// Hosts that do not own a [`ToolBridge`] use this path so they still
|
||||
/// consume the production base-template and prompt-body composition.
|
||||
pub fn render_with_renderer(&self, renderer: &TemplateRenderer) -> Option<String> {
|
||||
let placeholders = self.placeholders();
|
||||
let render = |template: &str| renderer.render_with_extra(template, &placeholders).ok();
|
||||
let prompt = match self.prompt_mode {
|
||||
PromptMode::Extend => {
|
||||
let decrypted;
|
||||
let base = match &self.system_prompt {
|
||||
TemplateOverride::Custom(s) => s.as_str(),
|
||||
TemplateOverride::Custom(template) => template.as_str(),
|
||||
TemplateOverride::Codex => {
|
||||
decrypted = apply_patch_template();
|
||||
&decrypted
|
||||
|
|
@ -278,21 +288,14 @@ impl PromptContext {
|
|||
&decrypted
|
||||
}
|
||||
};
|
||||
let mut p = tool_bridge.render_prompt(base, &placeholders).await?;
|
||||
if let Some(ref body) = self.prompt_body {
|
||||
p.push_str("\n\n");
|
||||
let rendered_body = tool_bridge
|
||||
.render_prompt(body, &placeholders)
|
||||
.await
|
||||
.unwrap_or_else(|| body.clone());
|
||||
p.push_str(&rendered_body);
|
||||
let mut prompt = render(base)?;
|
||||
if let Some(body) = &self.prompt_body {
|
||||
prompt.push_str("\n\n");
|
||||
prompt.push_str(&render(body).unwrap_or_else(|| body.clone()));
|
||||
}
|
||||
p
|
||||
}
|
||||
PromptMode::Full => {
|
||||
let body = self.prompt_body.as_deref().unwrap_or("");
|
||||
tool_bridge.render_prompt(body, &placeholders).await?
|
||||
prompt
|
||||
}
|
||||
PromptMode::Full => render(self.prompt_body.as_deref().unwrap_or(""))?,
|
||||
};
|
||||
Some(prompt)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -412,6 +412,29 @@ where
|
|||
},
|
||||
}
|
||||
}
|
||||
/// Nested `slash_command_tags` map: present-but-malformed → `None` (warn) so one
|
||||
/// bad value cannot fail the whole [`RemoteSettings`] parse.
|
||||
fn deserialize_tolerant_slash_command_tags<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<std::collections::BTreeMap<String, String>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
|
||||
match value {
|
||||
None | Some(serde_json::Value::Null) => Ok(None),
|
||||
Some(v) => match serde_json::from_value::<std::collections::BTreeMap<String, String>>(v) {
|
||||
Ok(m) => Ok(Some(m)),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"ignoring malformed remote slash_command_tags; falling through to local/none"
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
/// Remote settings fetched from cli-chat-proxy `GET /v1/settings`.
|
||||
///
|
||||
/// All fields are `Option` with `#[serde(default)]` so that:
|
||||
|
|
@ -715,6 +738,12 @@ pub struct RemoteSettings {
|
|||
/// `None` or `[]` = no tips shown.
|
||||
#[serde(default)]
|
||||
pub tips: Option<Vec<String>>,
|
||||
/// Free-form per-command tags (e.g. `new`, `beta`) rendered as a bracketed
|
||||
/// label in the slash dropdown, keyed by canonical command name. Present-but-
|
||||
/// malformed → `None` (does not fail the whole parse); local
|
||||
/// `[slash_command_tags]` overrides per key. See `resolve_slash_command_tags`.
|
||||
#[serde(default, deserialize_with = "deserialize_tolerant_slash_command_tags")]
|
||||
pub slash_command_tags: Option<std::collections::BTreeMap<String, String>>,
|
||||
/// When present, controls the non-Git-repo warning at session start.
|
||||
/// Controlled via remote settings (`non_git_warning` in `grok_build_settings`).
|
||||
/// Takes precedence over `[features] non_git_warning` in config.toml:
|
||||
|
|
@ -790,6 +819,9 @@ pub struct RemoteSettings {
|
|||
/// (`grok-imagine-image-quality`). Absent/empty → default model.
|
||||
#[serde(default)]
|
||||
pub image_gen_model_override: Option<String>,
|
||||
/// Optional Imagine model override for `image_edit`. Absent/empty → default.
|
||||
#[serde(default)]
|
||||
pub image_edit_model_override: Option<String>,
|
||||
/// Video tools / `/imagine-video`. `None` → env / `[features]` / default on.
|
||||
#[serde(default)]
|
||||
pub video_gen_enabled: Option<bool>,
|
||||
|
|
|
|||
569
crates/codegen/xai-grok-config/src/global_hook_sources.rs
Normal file
569
crates/codegen/xai-grok-config/src/global_hook_sources.rs
Normal file
|
|
@ -0,0 +1,569 @@
|
|||
//! Grok-owned direct global hook paths shared by shell discovery and sandbox
|
||||
//! write-deny: `$GROK_HOME/hooks`, `hooks-paths`, and absolute registry targets.
|
||||
//! Relative registry lines, project hooks, and vendor compat are out of scope.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GlobalHookSourceKind {
|
||||
/// `$GROK_HOME/hooks/` (discovered + protected).
|
||||
HookDirectory,
|
||||
/// `$GROK_HOME/hooks-paths` (protected; never loaded as hook JSON).
|
||||
RegistryFile,
|
||||
/// Absolute registry target (must exist before sandbox apply).
|
||||
ConfiguredSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GlobalHookSource {
|
||||
pub path: PathBuf,
|
||||
pub kind: GlobalHookSourceKind,
|
||||
}
|
||||
|
||||
impl GlobalHookSource {
|
||||
pub fn is_dir(&self) -> bool {
|
||||
match self.kind {
|
||||
GlobalHookSourceKind::HookDirectory => true,
|
||||
GlobalHookSourceKind::RegistryFile => false,
|
||||
GlobalHookSourceKind::ConfiguredSource => {
|
||||
if self.path.exists() {
|
||||
self.path.is_dir()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// False for the registry file itself (not hook JSON / not a hook dir).
|
||||
pub fn is_discovery_source(&self) -> bool {
|
||||
!matches!(self.kind, GlobalHookSourceKind::RegistryFile)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GlobalHookSourceError {
|
||||
#[error("cannot read hooks-paths {path}: {source}")]
|
||||
HooksPathsRead {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
#[error("symlinked GROK_HOME is not allowed under sandbox write-deny: {path}")]
|
||||
SymlinkedGrokHome { path: PathBuf },
|
||||
#[error("hook source path contains a symlink component (retargetable): {path}")]
|
||||
SymlinkedSource { path: PathBuf },
|
||||
#[error("hook JSON file has hard-link aliases (st_nlink={nlink}): {path}")]
|
||||
HardLinkedHookFile { path: PathBuf, nlink: u64 },
|
||||
#[error("hook JSON path is not a regular file: {path}")]
|
||||
InvalidHookJsonFile { path: PathBuf },
|
||||
#[error("Grok hooks directory has wrong type (expected real directory): {path}")]
|
||||
InvalidHooksDir { path: PathBuf },
|
||||
#[error("Grok hooks-paths registry has wrong type (expected real file): {path}")]
|
||||
InvalidRegistryFile { path: PathBuf },
|
||||
#[error("cannot create Grok hooks directory {path}: {source}")]
|
||||
CreateHooksDir {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
#[error("cannot create Grok hooks-paths registry {path}: {source}")]
|
||||
CreateRegistryFile {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Hard-fail omits all sources. Soft `configured_error` keeps fixed slots and
|
||||
/// omits configured targets (sandbox must fail closed; discovery may log).
|
||||
#[derive(Debug)]
|
||||
pub struct ResolvedGlobalHookSources {
|
||||
pub sources: Vec<GlobalHookSource>,
|
||||
pub configured_error: Option<GlobalHookSourceError>,
|
||||
}
|
||||
|
||||
impl ResolvedGlobalHookSources {
|
||||
pub fn is_incomplete(&self) -> bool {
|
||||
self.configured_error.is_some()
|
||||
}
|
||||
|
||||
pub fn discovery_sources(&self) -> impl Iterator<Item = &GlobalHookSource> {
|
||||
self.sources.iter().filter(|s| s.is_discovery_source())
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS firmlinks are not attacker-retargetable; ignore in symlink scans.
|
||||
fn is_system_firmlink(path: &Path) -> bool {
|
||||
matches!(
|
||||
path.to_str(),
|
||||
Some("/tmp")
|
||||
| Some("/var")
|
||||
| Some("/etc")
|
||||
| Some("/private/tmp")
|
||||
| Some("/private/var")
|
||||
| Some("/private/etc")
|
||||
)
|
||||
}
|
||||
|
||||
/// True if any existing path component is a retargetable symlink (firmlinks skipped).
|
||||
pub fn path_has_symlink_component(path: &Path) -> bool {
|
||||
let mut cur = PathBuf::new();
|
||||
for c in path.components() {
|
||||
cur.push(c.as_os_str());
|
||||
match std::fs::symlink_metadata(&cur) {
|
||||
Ok(m) if m.file_type().is_symlink() => {
|
||||
if is_system_firmlink(&cur) {
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn push_unique(out: &mut Vec<GlobalHookSource>, source: GlobalHookSource) {
|
||||
if !out.iter().any(|s| s.path == source.path) {
|
||||
out.push(source);
|
||||
}
|
||||
}
|
||||
|
||||
/// Existing non-symlink directory ancestors (parent-first), excluding `/`.
|
||||
pub fn existing_ancestor_chain(path: &Path) -> Vec<PathBuf> {
|
||||
let mut chain = Vec::new();
|
||||
let mut cur = path.parent().map(Path::to_path_buf);
|
||||
while let Some(p) = cur {
|
||||
if p.as_os_str().is_empty() || p == Path::new("/") {
|
||||
break;
|
||||
}
|
||||
match std::fs::symlink_metadata(&p) {
|
||||
Ok(m) if m.file_type().is_dir() && !m.file_type().is_symlink() => {
|
||||
chain.push(p.clone());
|
||||
}
|
||||
Ok(_) => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
cur = p.parent().map(Path::to_path_buf);
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
/// Linux: `st_dev` differs from parent, or listed in mountinfo. Else false.
|
||||
pub(crate) fn is_filesystem_mountpoint(path: &Path) -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if path == Path::new("/") {
|
||||
return true;
|
||||
}
|
||||
let Ok(meta) = std::fs::metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(parent) = path.parent()
|
||||
&& let Ok(pm) = std::fs::metadata(parent)
|
||||
&& meta.dev() != pm.dev()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Ok(mountinfo) = std::fs::read_to_string("/proc/self/mountinfo") else {
|
||||
return false;
|
||||
};
|
||||
let path_s = path.to_string_lossy();
|
||||
for line in mountinfo.lines() {
|
||||
let Some((left, _)) = line.split_once(" - ") else {
|
||||
continue;
|
||||
};
|
||||
let fields: Vec<&str> = left.split_whitespace().collect();
|
||||
if fields.len() < 5 {
|
||||
continue;
|
||||
}
|
||||
if fields[4] == path_s.as_ref() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = path;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Ancestors to RW self-bind so rename is EBUSY: parent→root, skip already-
|
||||
/// mounted nodes but keep pinning renameable ancestors above them (never `/`).
|
||||
pub fn ancestors_to_pin_as_mountpoints(path: &Path) -> Vec<PathBuf> {
|
||||
ancestors_to_pin_as_mountpoints_with(path, is_filesystem_mountpoint)
|
||||
}
|
||||
|
||||
pub(crate) fn ancestors_to_pin_as_mountpoints_with(
|
||||
path: &Path,
|
||||
is_mountpoint: impl Fn(&Path) -> bool,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut chain = Vec::new();
|
||||
let mut cur = path.parent().map(Path::to_path_buf);
|
||||
while let Some(p) = cur {
|
||||
if p.as_os_str().is_empty() || p == Path::new("/") {
|
||||
break;
|
||||
}
|
||||
match std::fs::symlink_metadata(&p) {
|
||||
Ok(m) if m.file_type().is_dir() && !m.file_type().is_symlink() => {
|
||||
if is_mountpoint(&p) {
|
||||
cur = p.parent().map(Path::to_path_buf);
|
||||
continue;
|
||||
}
|
||||
chain.push(p.clone());
|
||||
}
|
||||
Ok(_) => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
cur = p.parent().map(Path::to_path_buf);
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
/// Unique ancestors, rootward-first (shallowest first).
|
||||
pub fn unique_ancestors_rootward(sources: &[GlobalHookSource]) -> Vec<PathBuf> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut all = Vec::new();
|
||||
for s in sources {
|
||||
for anc in ancestors_to_pin_as_mountpoints(&s.path) {
|
||||
if seen.insert(anc.clone()) {
|
||||
all.push(anc);
|
||||
}
|
||||
}
|
||||
}
|
||||
all.sort_by_key(|p| p.components().count());
|
||||
all
|
||||
}
|
||||
|
||||
fn require_real_dir(path: &Path) -> Result<(), GlobalHookSourceError> {
|
||||
let meta = std::fs::symlink_metadata(path).map_err(|source| {
|
||||
GlobalHookSourceError::CreateHooksDir {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
if meta.file_type().is_symlink() || !meta.file_type().is_dir() {
|
||||
return Err(GlobalHookSourceError::InvalidHooksDir {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_real_file(path: &Path) -> Result<(), GlobalHookSourceError> {
|
||||
let meta = std::fs::symlink_metadata(path).map_err(|source| {
|
||||
GlobalHookSourceError::CreateRegistryFile {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
if meta.file_type().is_symlink() || !meta.file_type().is_file() {
|
||||
return Err(GlobalHookSourceError::InvalidRegistryFile {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure real `$GROK_HOME/hooks` dir + `hooks-paths` file (create if missing).
|
||||
/// Race-resistant create (`create_dir` / `create_new`+`O_NOFOLLOW`); never
|
||||
/// truncates an existing registry; rejects symlinks/wrong types.
|
||||
pub fn ensure_grok_hook_slots(grok_home: &Path) -> Result<(), GlobalHookSourceError> {
|
||||
if path_has_symlink_component(grok_home) {
|
||||
return Err(GlobalHookSourceError::SymlinkedGrokHome {
|
||||
path: grok_home.to_path_buf(),
|
||||
});
|
||||
}
|
||||
|
||||
match std::fs::create_dir(grok_home) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
std::fs::create_dir_all(grok_home).map_err(|source| {
|
||||
GlobalHookSourceError::CreateHooksDir {
|
||||
path: grok_home.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Err(source) => {
|
||||
return Err(GlobalHookSourceError::CreateHooksDir {
|
||||
path: grok_home.to_path_buf(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
if path_has_symlink_component(grok_home) {
|
||||
return Err(GlobalHookSourceError::SymlinkedGrokHome {
|
||||
path: grok_home.to_path_buf(),
|
||||
});
|
||||
}
|
||||
let grok_meta = std::fs::symlink_metadata(grok_home).map_err(|source| {
|
||||
GlobalHookSourceError::CreateHooksDir {
|
||||
path: grok_home.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
if grok_meta.file_type().is_symlink() || !grok_meta.file_type().is_dir() {
|
||||
return Err(GlobalHookSourceError::SymlinkedGrokHome {
|
||||
path: grok_home.to_path_buf(),
|
||||
});
|
||||
}
|
||||
|
||||
let hooks = grok_home.join("hooks");
|
||||
match std::fs::create_dir(&hooks) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
|
||||
require_real_dir(&hooks)?;
|
||||
}
|
||||
Err(source) => {
|
||||
return Err(GlobalHookSourceError::CreateHooksDir {
|
||||
path: hooks,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
require_real_dir(&hooks)?;
|
||||
if path_has_symlink_component(&hooks) {
|
||||
return Err(GlobalHookSourceError::SymlinkedSource { path: hooks });
|
||||
}
|
||||
|
||||
let registry = grok_home.join("hooks-paths");
|
||||
match open_registry_create_new(®istry) {
|
||||
Ok(f) => drop(f),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
|
||||
require_real_file(®istry)?;
|
||||
}
|
||||
Err(source) => {
|
||||
return Err(GlobalHookSourceError::CreateRegistryFile {
|
||||
path: registry,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
require_real_file(®istry)?;
|
||||
if path_has_symlink_component(®istry) {
|
||||
return Err(GlobalHookSourceError::SymlinkedSource { path: registry });
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
const O_NOFOLLOW: i32 = 0x20000;
|
||||
#[cfg(any(
|
||||
target_os = "macos",
|
||||
target_os = "ios",
|
||||
target_os = "freebsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "netbsd"
|
||||
))]
|
||||
const O_NOFOLLOW: i32 = 0x0100;
|
||||
|
||||
fn open_registry_create_new(path: &Path) -> io::Result<std::fs::File> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.custom_flags(O_NOFOLLOW)
|
||||
.open(path)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve Grok-owned direct global hook sources (`reject_symlinks` for sandbox).
|
||||
pub fn resolve_global_hook_sources(
|
||||
grok_home: Option<&Path>,
|
||||
reject_symlinks: bool,
|
||||
) -> Result<ResolvedGlobalHookSources, GlobalHookSourceError> {
|
||||
let mut out = Vec::new();
|
||||
let mut configured_error = None;
|
||||
|
||||
if let Some(grok) = grok_home {
|
||||
if reject_symlinks && path_has_symlink_component(grok) {
|
||||
return Err(GlobalHookSourceError::SymlinkedGrokHome {
|
||||
path: grok.to_path_buf(),
|
||||
});
|
||||
}
|
||||
|
||||
let hooks = grok.join("hooks");
|
||||
let hooks_paths = grok.join("hooks-paths");
|
||||
if reject_symlinks {
|
||||
for p in [&hooks, &hooks_paths] {
|
||||
if path_has_symlink_component(p) {
|
||||
return Err(GlobalHookSourceError::SymlinkedSource { path: p.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
push_unique(
|
||||
&mut out,
|
||||
GlobalHookSource {
|
||||
path: hooks,
|
||||
kind: GlobalHookSourceKind::HookDirectory,
|
||||
},
|
||||
);
|
||||
push_unique(
|
||||
&mut out,
|
||||
GlobalHookSource {
|
||||
path: hooks_paths.clone(),
|
||||
kind: GlobalHookSourceKind::RegistryFile,
|
||||
},
|
||||
);
|
||||
|
||||
match std::fs::read_to_string(&hooks_paths) {
|
||||
Ok(content) => {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let path = PathBuf::from(trimmed);
|
||||
if !path.is_absolute() {
|
||||
continue;
|
||||
}
|
||||
if reject_symlinks && path_has_symlink_component(&path) {
|
||||
return Err(GlobalHookSourceError::SymlinkedSource { path });
|
||||
}
|
||||
push_unique(
|
||||
&mut out,
|
||||
GlobalHookSource {
|
||||
path,
|
||||
kind: GlobalHookSourceKind::ConfiguredSource,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(e) => {
|
||||
configured_error = Some(GlobalHookSourceError::HooksPathsRead {
|
||||
path: hooks_paths,
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ResolvedGlobalHookSources {
|
||||
sources: out,
|
||||
configured_error,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn missing_configured_sources(sources: &[GlobalHookSource]) -> Vec<PathBuf> {
|
||||
sources
|
||||
.iter()
|
||||
.filter(|s| s.kind == GlobalHookSourceKind::ConfiguredSource && !s.path.exists())
|
||||
.map(|s| s.path.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Discovery filename filter: `*.json`, not hidden, not editor temps.
|
||||
pub fn is_direct_hook_json_name(name: &str) -> bool {
|
||||
if !name.ends_with(".json") || name.len() <= 5 {
|
||||
return false;
|
||||
}
|
||||
if name.starts_with('.') {
|
||||
return false;
|
||||
}
|
||||
if name.ends_with('~') || name.ends_with(".swp") || name.ends_with(".swo") {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Immediate discovery JSON files under `dir` (sorted, non-recursive).
|
||||
pub fn list_direct_hook_json_files(dir: &Path) -> io::Result<Vec<PathBuf>> {
|
||||
let mut out = Vec::new();
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !is_direct_hook_json_name(name) {
|
||||
continue;
|
||||
}
|
||||
out.push(path);
|
||||
}
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Regular non-symlink file with `st_nlink == 1`.
|
||||
#[cfg(unix)]
|
||||
pub fn validate_direct_hook_json_file(path: &Path) -> Result<(), GlobalHookSourceError> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let meta = std::fs::symlink_metadata(path).map_err(|source| {
|
||||
GlobalHookSourceError::HooksPathsRead {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
if meta.file_type().is_symlink() {
|
||||
return Err(GlobalHookSourceError::SymlinkedSource {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
if !meta.file_type().is_file() {
|
||||
return Err(GlobalHookSourceError::InvalidHookJsonFile {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
if meta.nlink() != 1 {
|
||||
return Err(GlobalHookSourceError::HardLinkedHookFile {
|
||||
path: path.to_path_buf(),
|
||||
nlink: meta.nlink(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn validated_hook_json_files_for_sources(
|
||||
sources: &[GlobalHookSource],
|
||||
) -> Result<Vec<PathBuf>, GlobalHookSourceError> {
|
||||
let mut files = Vec::new();
|
||||
for s in sources {
|
||||
if !s.is_dir() || !s.path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let listed = list_direct_hook_json_files(&s.path).map_err(|source| {
|
||||
GlobalHookSourceError::HooksPathsRead {
|
||||
path: s.path.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
for f in listed {
|
||||
validate_direct_hook_json_file(&f)?;
|
||||
files.push(f);
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
files.dedup();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "global_hook_sources_tests.rs"]
|
||||
mod tests;
|
||||
282
crates/codegen/xai-grok-config/src/global_hook_sources_tests.rs
Normal file
282
crates/codegen/xai-grok-config/src/global_hook_sources_tests.rs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn absolute_hooks_paths_only_and_fixed_slots() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
let nested = dir.join("extra");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("hooks-paths"),
|
||||
format!("{}\nrelative/x\n", nested.display()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_global_hook_sources(Some(dir), false).unwrap();
|
||||
assert!(resolved.configured_error.is_none());
|
||||
let sources = &resolved.sources;
|
||||
assert!(
|
||||
sources.iter().any(|s| {
|
||||
s.path == dir.join("hooks") && s.kind == GlobalHookSourceKind::HookDirectory
|
||||
})
|
||||
);
|
||||
assert!(sources.iter().any(|s| {
|
||||
s.path == dir.join("hooks-paths") && s.kind == GlobalHookSourceKind::RegistryFile
|
||||
}));
|
||||
assert!(
|
||||
sources
|
||||
.iter()
|
||||
.any(|s| { s.path == nested && s.kind == GlobalHookSourceKind::ConfiguredSource })
|
||||
);
|
||||
assert!(!sources.iter().any(|s| s.path.ends_with("relative/x")));
|
||||
assert!(missing_configured_sources(sources).is_empty());
|
||||
|
||||
// Discovery must never treat the registry file as a hook source.
|
||||
let discovery: Vec<_> = resolved
|
||||
.discovery_sources()
|
||||
.map(|s| s.path.clone())
|
||||
.collect();
|
||||
assert!(!discovery.iter().any(|p| p == &dir.join("hooks-paths")));
|
||||
assert!(discovery.iter().any(|p| p == &dir.join("hooks")));
|
||||
assert!(discovery.iter().any(|p| p == &nested));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_configured_is_reported() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
let missing = dir.join("nope").join("hooks");
|
||||
std::fs::write(dir.join("hooks-paths"), format!("{}\n", missing.display())).unwrap();
|
||||
let resolved = resolve_global_hook_sources(Some(dir), false).unwrap();
|
||||
assert!(resolved.configured_error.is_none());
|
||||
let miss = missing_configured_sources(&resolved.sources);
|
||||
assert!(miss.iter().any(|p| p == &missing));
|
||||
assert!(!miss.iter().any(|p| p == &dir.join("hooks")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_paths_read_error_keeps_fixed_slots() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
// Directory named hooks-paths → read_to_string fails with IsADirectory.
|
||||
std::fs::create_dir_all(dir.join("hooks-paths")).unwrap();
|
||||
let resolved = resolve_global_hook_sources(Some(dir), false).unwrap();
|
||||
assert!(resolved.is_incomplete());
|
||||
assert!(matches!(
|
||||
resolved.configured_error,
|
||||
Some(GlobalHookSourceError::HooksPathsRead { .. })
|
||||
));
|
||||
assert!(
|
||||
resolved.sources.iter().any(|s| {
|
||||
s.path == dir.join("hooks") && s.kind == GlobalHookSourceKind::HookDirectory
|
||||
})
|
||||
);
|
||||
assert!(resolved.sources.iter().any(|s| {
|
||||
s.path == dir.join("hooks-paths") && s.kind == GlobalHookSourceKind::RegistryFile
|
||||
}));
|
||||
assert!(
|
||||
!resolved
|
||||
.sources
|
||||
.iter()
|
||||
.any(|s| s.kind == GlobalHookSourceKind::ConfiguredSource)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn reject_symlinked_configured_source() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
let real = tmp.path().join("real-hooks");
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
let link = dir.join("link-hooks");
|
||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
||||
std::fs::write(dir.join("hooks-paths"), format!("{}\n", link.display())).unwrap();
|
||||
let err = resolve_global_hook_sources(Some(dir), true).unwrap_err();
|
||||
assert!(matches!(err, GlobalHookSourceError::SymlinkedSource { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_hooks_paths_is_ok_empty_configured() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
let resolved = resolve_global_hook_sources(Some(dir), false).unwrap();
|
||||
assert!(resolved.configured_error.is_none());
|
||||
assert!(missing_configured_sources(&resolved.sources).is_empty());
|
||||
assert!(
|
||||
resolved
|
||||
.sources
|
||||
.iter()
|
||||
.any(|s| s.kind == GlobalHookSourceKind::HookDirectory)
|
||||
);
|
||||
assert!(
|
||||
resolved
|
||||
.sources
|
||||
.iter()
|
||||
.any(|s| s.kind == GlobalHookSourceKind::RegistryFile)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_creates_hooks_dir_and_empty_registry() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("grok");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
ensure_grok_hook_slots(&dir).unwrap();
|
||||
let hooks = dir.join("hooks");
|
||||
let reg = dir.join("hooks-paths");
|
||||
assert!(hooks.is_dir());
|
||||
assert!(reg.is_file());
|
||||
assert_eq!(std::fs::read(®).unwrap(), b"");
|
||||
// Idempotent — does not truncate existing registry content.
|
||||
std::fs::write(®, b"/abs/extra\n").unwrap();
|
||||
ensure_grok_hook_slots(&dir).unwrap();
|
||||
assert_eq!(std::fs::read(®).unwrap(), b"/abs/extra\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn ensure_rejects_preexisting_symlink_hooks_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("grok");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let real = tmp.path().join("real-hooks");
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
std::os::unix::fs::symlink(&real, dir.join("hooks")).unwrap();
|
||||
let err = ensure_grok_hook_slots(&dir).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
GlobalHookSourceError::InvalidHooksDir { .. }
|
||||
| GlobalHookSourceError::SymlinkedSource { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn ensure_rejects_preexisting_symlink_registry() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("grok");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let target = tmp.path().join("evil-registry");
|
||||
std::fs::write(&target, b"attacker\n").unwrap();
|
||||
std::os::unix::fs::symlink(&target, dir.join("hooks-paths")).unwrap();
|
||||
let err = ensure_grok_hook_slots(&dir).unwrap_err();
|
||||
// create_new hits EEXIST on the symlink → require_real_file rejects it;
|
||||
// or O_NOFOLLOW path — never write through the symlink.
|
||||
assert!(matches!(
|
||||
err,
|
||||
GlobalHookSourceError::InvalidRegistryFile { .. }
|
||||
| GlobalHookSourceError::SymlinkedSource { .. }
|
||||
| GlobalHookSourceError::CreateRegistryFile { .. }
|
||||
));
|
||||
// Attacker target must remain unchanged (no write-through).
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"attacker\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn ensure_rejects_directory_named_hooks_paths() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("grok");
|
||||
std::fs::create_dir_all(dir.join("hooks-paths")).unwrap();
|
||||
let err = ensure_grok_hook_slots(&dir).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
GlobalHookSourceError::InvalidRegistryFile { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn ensure_rejects_file_named_hooks_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("grok");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("hooks"), b"not-a-dir").unwrap();
|
||||
let err = ensure_grok_hook_slots(&dir).unwrap_err();
|
||||
assert!(matches!(err, GlobalHookSourceError::InvalidHooksDir { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_ancestor_chain_lists_parents() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let leaf = tmp.path().join("a").join("b").join("c");
|
||||
std::fs::create_dir_all(&leaf).unwrap();
|
||||
let chain = existing_ancestor_chain(&leaf);
|
||||
assert_eq!(chain[0], tmp.path().join("a").join("b"));
|
||||
assert!(chain.iter().any(|p| p == &tmp.path().join("a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_direct_hook_json_files_matches_discovery_filter() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::write(dir.join("active.json"), b"{}").unwrap();
|
||||
std::fs::write(dir.join(".hidden.json"), b"{}").unwrap();
|
||||
std::fs::write(dir.join("backup.json~"), b"{}").unwrap();
|
||||
std::fs::write(dir.join("notes.txt"), b"x").unwrap();
|
||||
let files = list_direct_hook_json_files(dir).unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
assert!(files[0].ends_with("active.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn validate_direct_hook_json_rejects_hardlink_and_symlink() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let f = tmp.path().join("a.json");
|
||||
let hl = tmp.path().join("b.json");
|
||||
std::fs::write(&f, b"{}").unwrap();
|
||||
std::fs::hard_link(&f, &hl).unwrap();
|
||||
assert!(matches!(
|
||||
validate_direct_hook_json_file(&f),
|
||||
Err(GlobalHookSourceError::HardLinkedHookFile { .. })
|
||||
));
|
||||
let real = tmp.path().join("real.json");
|
||||
let link = tmp.path().join("link.json");
|
||||
std::fs::write(&real, b"{}").unwrap();
|
||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
||||
assert!(matches!(
|
||||
validate_direct_hook_json_file(&link),
|
||||
Err(GlobalHookSourceError::SymlinkedSource { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestors_to_pin_skips_mountpoints_but_continues_above() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let outer = tmp.path().join("outer");
|
||||
let mid = outer.join("preexisting-bind");
|
||||
let leaf = mid.join("hooks");
|
||||
std::fs::create_dir_all(&leaf).unwrap();
|
||||
|
||||
// Synthetic: treat `preexisting-bind` as already a mountpoint.
|
||||
let pin = ancestors_to_pin_as_mountpoints_with(&leaf, |p| p == mid);
|
||||
assert!(
|
||||
pin.iter().any(|p| p == &outer),
|
||||
"must pin renameable ancestor ABOVE an intermediate mountpoint: {pin:?}"
|
||||
);
|
||||
assert!(
|
||||
!pin.iter().any(|p| p == &mid),
|
||||
"must NOT re-bind an already-mounted ancestor: {pin:?}"
|
||||
);
|
||||
assert!(
|
||||
!pin.iter().any(|p| p == Path::new("/")),
|
||||
"must never pin /: {pin:?}"
|
||||
);
|
||||
|
||||
// Immediate parent of leaf is mid (mountpoint) — skipped; outer still present.
|
||||
let sources = [GlobalHookSource {
|
||||
path: leaf,
|
||||
kind: GlobalHookSourceKind::ConfiguredSource,
|
||||
}];
|
||||
// With real mountpoint detector, under temp dirs nothing is a mount → full chain.
|
||||
let rootward = unique_ancestors_rootward(&sources);
|
||||
for w in rootward.windows(2) {
|
||||
assert!(
|
||||
w[0].components().count() <= w[1].components().count(),
|
||||
"rootward order broken: {rootward:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
pub mod campaigns;
|
||||
pub mod config_override;
|
||||
pub mod fs_atomic;
|
||||
pub mod global_hook_sources;
|
||||
mod loader;
|
||||
mod macos_managed;
|
||||
mod managed_cache;
|
||||
|
|
@ -31,6 +32,17 @@ pub mod version_overrides;
|
|||
pub use campaigns::{
|
||||
CampaignEntry, CampaignOverrides, filter_active_campaigns, ids_touching_paths,
|
||||
};
|
||||
pub use global_hook_sources::{
|
||||
GlobalHookSource, GlobalHookSourceError, GlobalHookSourceKind, ResolvedGlobalHookSources,
|
||||
ensure_grok_hook_slots, existing_ancestor_chain, is_direct_hook_json_name,
|
||||
list_direct_hook_json_files, missing_configured_sources, path_has_symlink_component,
|
||||
resolve_global_hook_sources, unique_ancestors_rootward,
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
pub use global_hook_sources::{
|
||||
validate_direct_hook_json_file, validated_hook_json_files_for_sources,
|
||||
};
|
||||
pub use loader::{
|
||||
CampaignsState, ConfigLayers, MANAGED_CONFIG_FILENAME, ManagedConfigLayer,
|
||||
REQUIREMENTS_FILENAME, apply_version_overrides_with_registered, campaigns_application_disabled,
|
||||
|
|
@ -43,7 +55,7 @@ pub use macos_managed::MDM_REQUIREMENTS_SOURCE;
|
|||
pub use managed_cache::{
|
||||
MANAGED_CONFIG_CACHE_FILE, ServingIdentity, SyncMarker, bump_rollback_floor,
|
||||
bump_rollback_floor_with_now, confirmed_team_switch, confirmed_team_switch_at,
|
||||
is_managed_config_hard_stale_for, is_managed_config_stale_for,
|
||||
fail_closed_policy_armed_at, is_managed_config_hard_stale_for, is_managed_config_stale_for,
|
||||
managed_config_identity_changed_at, managed_deployment_id, managed_policy_compromised_for,
|
||||
mark_managed_config_synced, mark_managed_config_synced_at, normalize_identity,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -175,6 +175,30 @@ fn write_marker_atomically(home: &Path, json: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether fail-closed managed policy is armed on disk for `home`.
|
||||
///
|
||||
/// True when the sync marker records `fail_closed`, on-disk `requirements.toml`
|
||||
/// parses as fail_closed, or `requirements.toml` exists but is unreadable
|
||||
/// (cannot confirm it is disarmed — must not let `clear_orphan` wipe).
|
||||
/// False only when neither the marker nor the file indicates fail_closed
|
||||
/// (including when the file is absent / `NotFound`).
|
||||
/// Companion to the signed session gate in [`managed_policy_compromised_for`].
|
||||
pub fn fail_closed_policy_armed_at(home: &Path) -> bool {
|
||||
if read_managed_config_cache(home).is_some_and(|c| c.fail_closed) {
|
||||
return true;
|
||||
}
|
||||
// Defense in depth: files remain after a stripped/corrupt marker.
|
||||
match std::fs::read_to_string(home.join(crate::loader::REQUIREMENTS_FILENAME)) {
|
||||
Ok(s) => prod_mc_cli_chat_proxy_types::fail_closed_flag_status(&s).is_enabled(),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
|
||||
Err(e) => {
|
||||
// File present but unreadable: do not allow clear_orphan to wipe.
|
||||
tracing::warn!("requirements.toml unreadable; treating as fail_closed armed: {e}");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sync marker, or `None` if absent / unreadable / corrupt. Allow-on-unreadable:
|
||||
/// a read blip or torn write mustn't lock out a managed user. Unreadable/corrupt are
|
||||
/// logged (a corruption-to-disarm isn't silent) and self-heal on the next sync.
|
||||
|
|
|
|||
|
|
@ -1316,6 +1316,50 @@ fn managed_config_stale_for_far_future_sync() {
|
|||
);
|
||||
}
|
||||
|
||||
/// Unreadable requirements (PermissionDenied) with no fail_closed marker must
|
||||
/// still arm the gate so clear_orphan cannot wipe policy that may still be
|
||||
/// fail_closed on disk.
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn unreadable_requirements_treats_fail_closed_as_armed() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
let req = home.join(crate::loader::REQUIREMENTS_FILENAME);
|
||||
std::fs::write(&req, "fail_closed = true\n").unwrap();
|
||||
assert!(
|
||||
fail_closed_policy_armed_at(home),
|
||||
"readable fail_closed requirements must arm the gate"
|
||||
);
|
||||
|
||||
// Drop read perms so read_to_string fails with PermissionDenied (not NotFound).
|
||||
std::fs::set_permissions(&req, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
// Restore on drop so tempfile cleanup can remove the file.
|
||||
struct RestorePerms<'a>(&'a std::path::Path);
|
||||
impl Drop for RestorePerms<'_> {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::set_permissions(self.0, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
}
|
||||
let _restore = RestorePerms(&req);
|
||||
|
||||
assert!(
|
||||
fail_closed_policy_armed_at(home),
|
||||
"unreadable requirements must treat fail_closed as armed (no wipe)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Absent requirements + no fail_closed marker → not armed (safe to clear).
|
||||
#[test]
|
||||
fn missing_requirements_and_marker_not_armed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(
|
||||
!fail_closed_policy_armed_at(dir.path()),
|
||||
"NotFound requirements with no marker must not arm fail_closed"
|
||||
);
|
||||
}
|
||||
|
||||
// The is-managed claim gate tests live in a sibling child module (this file is
|
||||
// past the 1k-line mark); same private access via the #[path] include below.
|
||||
#[path = "claim_tests.rs"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{ManagedConfigError, ManagedConfigRequest, ManagedItem};
|
||||
use super::{ManagedConfigError, ManagedConfigRequest, ManagedItem, ManagedItemState};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CommentSyntax {
|
||||
|
|
@ -94,6 +94,27 @@ pub(super) fn outer_block(
|
|||
.map(|(start, end)| text[start..end].trim_end_matches(['\r', '\n']).to_owned()))
|
||||
}
|
||||
|
||||
pub(super) fn item_state(
|
||||
original: &str,
|
||||
namespace: &str,
|
||||
owned_item_prefix: &str,
|
||||
item: &ManagedItem,
|
||||
comments: &CommentSyntax,
|
||||
path: &Path,
|
||||
) -> Result<ManagedItemState, ManagedConfigError> {
|
||||
let parsed = parse_block(original, namespace, owned_item_prefix, comments, path)?;
|
||||
let Some(range) = parsed.items.get(&item.name) else {
|
||||
return Ok(ManagedItemState::Absent);
|
||||
};
|
||||
let expected = item_section(item, comments, parsed.newline);
|
||||
let actual = original[range.start..range.end].trim_end_matches(['\r', '\n']);
|
||||
Ok(if actual == expected {
|
||||
ManagedItemState::Exact
|
||||
} else {
|
||||
ManagedItemState::NeedsUpdate
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn render_update(
|
||||
original: &str,
|
||||
namespace: &str,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,14 @@ pub struct ManagedConfigRequest {
|
|||
pub struct ManagedTextInspection {
|
||||
original_text: Option<String>,
|
||||
unmanaged_text: String,
|
||||
requested_items: Vec<ManagedItemState>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ManagedItemState {
|
||||
Absent,
|
||||
Exact,
|
||||
NeedsUpdate,
|
||||
}
|
||||
|
||||
impl ManagedTextInspection {
|
||||
|
|
@ -56,6 +64,10 @@ impl ManagedTextInspection {
|
|||
pub fn unmanaged_text(&self) -> &str {
|
||||
&self.unmanaged_text
|
||||
}
|
||||
|
||||
pub fn requested_item_state(&self, index: usize) -> Option<ManagedItemState> {
|
||||
self.requested_items.get(index).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable source and output state presented before application.
|
||||
|
|
@ -206,6 +218,20 @@ impl ManagedConfig {
|
|||
let parent_plan = ParentPlan::capture(parent)?;
|
||||
let original = source::read_source(&target_path)?;
|
||||
let text = original.text(&target_path)?;
|
||||
let requested_items = request
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
format::item_state(
|
||||
text,
|
||||
&request.namespace,
|
||||
&request.owned_item_prefix,
|
||||
item,
|
||||
&request.comments,
|
||||
&target_path,
|
||||
)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let rendered = format::render_update(
|
||||
text,
|
||||
&request.namespace,
|
||||
|
|
@ -217,6 +243,7 @@ impl ManagedConfig {
|
|||
let inspection = ManagedTextInspection {
|
||||
original_text: original.bytes.as_ref().map(|_| text.to_owned()),
|
||||
unmanaged_text: rendered.unmanaged_text,
|
||||
requested_items,
|
||||
};
|
||||
let updated = rendered.updated.into_bytes();
|
||||
let changes =
|
||||
|
|
@ -244,6 +271,13 @@ impl ManagedConfig {
|
|||
transaction::apply(plan, &transaction::NoopObserver)
|
||||
}
|
||||
|
||||
/// Verify that the exact source path, parent identities, symlink target,
|
||||
/// bytes, mode, and file identity captured by `plan` are unchanged without
|
||||
/// publishing its proposed update.
|
||||
pub fn verify_unchanged(plan: &ManagedConfigPlan) -> Result<(), ManagedConfigError> {
|
||||
source::revalidate(plan)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn apply_with_observer(
|
||||
plan: ManagedConfigPlan,
|
||||
|
|
|
|||
|
|
@ -271,6 +271,8 @@ fn load_hooks_from_directory(dir: &Path) -> (Vec<HookSpec>, Vec<HookError>) {
|
|||
let mut specs = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Best-effort listing: a bad dirent is recorded and skipped so sibling
|
||||
// hooks still load. (Sandbox fail-closed listing lives in xai_grok_config.)
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
|
|
@ -285,7 +287,7 @@ fn load_hooks_from_directory(dir: &Path) -> (Vec<HookSpec>, Vec<HookError>) {
|
|||
}
|
||||
};
|
||||
|
||||
let mut json_files: Vec<std::path::PathBuf> = Vec::new();
|
||||
let mut json_files = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
|
|
@ -297,9 +299,11 @@ fn load_hooks_from_directory(dir: &Path) -> (Vec<HookSpec>, Vec<HookError>) {
|
|||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let path = entry.path();
|
||||
if !is_valid_hook_file(&path) {
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !xai_grok_config::is_direct_hook_json_name(name) || !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
json_files.push(path);
|
||||
|
|
@ -330,21 +334,12 @@ fn load_hooks_from_directory(dir: &Path) -> (Vec<HookSpec>, Vec<HookError>) {
|
|||
}
|
||||
|
||||
/// Check whether a path is a valid hook file (*.json, not hidden/temp).
|
||||
#[cfg(test)]
|
||||
fn is_valid_hook_file(path: &Path) -> bool {
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
return false;
|
||||
}
|
||||
if name.starts_with('.') {
|
||||
return false;
|
||||
}
|
||||
if name.ends_with('~') || name.ends_with(".swp") || name.ends_with(".swo") {
|
||||
return false;
|
||||
}
|
||||
path.is_file()
|
||||
xai_grok_config::is_direct_hook_json_name(name) && path.is_file()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "xai-grok-pager-bin"
|
||||
version = "0.2.110"
|
||||
version = "0.2.111"
|
||||
edition.workspace = true
|
||||
license = "Apache-2.0"
|
||||
authors = ["xAI"]
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ use xai_grok_shell::leader::{
|
|||
use xai_grok_shell::leader::{
|
||||
ControlPayload, LeaderClient, LeaderEnvUrls, connect_or_spawn, socket_path_for_ws_url,
|
||||
};
|
||||
use xai_grok_update::{UpdateConfig, auto_update, enforce_minimum_version_or_exit};
|
||||
use xai_grok_update::{UpdateConfig, auto_update, enforce_version_policy_or_exit};
|
||||
/// Apply headless args to an existing config, only overriding values that are
|
||||
/// explicitly set. This allows environment defaults to be preserved when
|
||||
/// specific args are not provided.
|
||||
|
|
@ -1771,6 +1771,7 @@ async fn async_main(args: PagerArgs) -> Result<()> {
|
|||
if let Some(Command::Wrap(ref wrap_args)) = args.command {
|
||||
return xai_grok_pager::wrap_cmd::run(wrap_args);
|
||||
}
|
||||
args.pin_local_resume_target()?;
|
||||
let saved_profile = args.saved_resume_profile();
|
||||
let sandbox_profile_arg = match args.startup_sandbox_profile(saved_profile.as_deref()) {
|
||||
xai_grok_pager::app::cli::SandboxStartup::Apply(profile) => profile,
|
||||
|
|
@ -1828,7 +1829,7 @@ async fn async_main(args: PagerArgs) -> Result<()> {
|
|||
Use `grok-pager agent {flag}` instead."
|
||||
);
|
||||
}
|
||||
enforce_minimum_version_or_exit(&update_config).await;
|
||||
enforce_version_policy_or_exit();
|
||||
return run_agent_command(
|
||||
agent_args,
|
||||
args.permission_mode_flag.clone(),
|
||||
|
|
@ -1992,7 +1993,7 @@ async fn async_main(args: PagerArgs) -> Result<()> {
|
|||
if let Some(prompt) = headless_prompt {
|
||||
init_tracing_simple(HEADLESS_ENTRYPOINT);
|
||||
let _otel_guard = xai_grok_telemetry::otel_layer::otel_guard();
|
||||
enforce_minimum_version_or_exit(&update_config).await;
|
||||
enforce_version_policy_or_exit();
|
||||
let launch_yolo = xai_grok_shell::util::config::effective_yolo_for_launch(
|
||||
args.yolo,
|
||||
args.permission_mode_flag.as_deref(),
|
||||
|
|
@ -2017,6 +2018,7 @@ async fn async_main(args: PagerArgs) -> Result<()> {
|
|||
xai_grok_pager::headless::HeadlessOptions {
|
||||
session_id: args.session_id.clone(),
|
||||
resume: args.resume_session.or(args.load_session),
|
||||
resume_title_pinned: args.resume_target_pinned,
|
||||
cwd: args.cwd,
|
||||
yolo: launch_yolo.yolo,
|
||||
trust: args.trust,
|
||||
|
|
@ -2047,7 +2049,7 @@ async fn async_main(args: PagerArgs) -> Result<()> {
|
|||
)
|
||||
.await;
|
||||
}
|
||||
enforce_minimum_version_or_exit(&update_config).await;
|
||||
enforce_version_policy_or_exit();
|
||||
let _otel_guard = xai_grok_telemetry::otel_layer::otel_guard();
|
||||
type UpdateWaitHandle = tokio::task::JoinHandle<std::io::Result<std::process::ExitStatus>>;
|
||||
let bg_update_wait: std::sync::Arc<tokio::sync::Mutex<Option<UpdateWaitHandle>>> =
|
||||
|
|
|
|||
|
|
@ -193,7 +193,12 @@ fn resume_body_rows(agent: &AgentView, width: u16) -> u16 {
|
|||
state,
|
||||
Some(current_repo.as_str()),
|
||||
);
|
||||
measure_entries(&picker_entries)
|
||||
// Reserve a row for the pinned hidden-external hint when shown.
|
||||
let hint_row = u16::from(
|
||||
!agent.app_chat_mode
|
||||
&& minimal_api::hidden_external_hint(entries.as_deref(), *source_filter).is_some(),
|
||||
);
|
||||
measure_entries(&picker_entries).saturating_add(hint_row)
|
||||
}
|
||||
|
||||
fn render_resume(
|
||||
|
|
@ -203,6 +208,7 @@ fn render_resume(
|
|||
theme: &Theme,
|
||||
) -> Option<(u16, u16)> {
|
||||
let cwd = agent.session.cwd.to_string_lossy().to_string();
|
||||
let chat_mode = agent.app_chat_mode;
|
||||
let Some(ActiveModal::SessionPicker {
|
||||
entries,
|
||||
state,
|
||||
|
|
@ -212,7 +218,7 @@ fn render_resume(
|
|||
else {
|
||||
return None;
|
||||
};
|
||||
let (title_row, search_row, divider_row, list_area, footer_row) = chrome_layout(area);
|
||||
let (title_row, search_row, divider_row, mut list_area, footer_row) = chrome_layout(area);
|
||||
|
||||
let entries_data = entries.as_deref().unwrap_or(&[]);
|
||||
let content_width = area.width.saturating_sub(2);
|
||||
|
|
@ -238,6 +244,9 @@ fn render_resume(
|
|||
state,
|
||||
Some(current_repo.as_str()),
|
||||
);
|
||||
let hidden_hint = (!chat_mode)
|
||||
.then(|| minimal_api::hidden_external_hint(entries.as_deref(), *source_filter))
|
||||
.flatten();
|
||||
|
||||
render_title(buf, title_row, theme, "Resume session");
|
||||
// Focus-aware search bar (cursor only when search is focused).
|
||||
|
|
@ -256,6 +265,21 @@ fn render_resume(
|
|||
);
|
||||
render_divider(buf, divider_row, theme);
|
||||
|
||||
// Pinned above the list so it stays visible regardless of list scroll.
|
||||
if let Some(hint) = hidden_hint.as_deref() {
|
||||
render_dim_line(
|
||||
buf,
|
||||
Rect {
|
||||
height: 1,
|
||||
..list_area
|
||||
},
|
||||
theme,
|
||||
hint,
|
||||
);
|
||||
list_area.y += 1;
|
||||
list_area.height = list_area.height.saturating_sub(1);
|
||||
}
|
||||
|
||||
let nsc = vec![false; picker_entries.len()];
|
||||
let hit = picker::render_picker_content(
|
||||
buf,
|
||||
|
|
@ -493,14 +517,22 @@ fn render_mcps(
|
|||
|
||||
// ─────────────────────────────── helpers ────────────────────────────────────
|
||||
|
||||
/// Sum the display height of grouped picker entries: a header is one row; a row
|
||||
/// is its label line plus its collapsed summary lines (what the picker draws
|
||||
/// when the row is not expanded).
|
||||
/// Sum the display height of grouped picker entries: a header is one row (plus
|
||||
/// the blank spacer `render_picker_content` draws before non-first headers); a
|
||||
/// row is its label line plus its collapsed summary lines (what the picker
|
||||
/// draws when the row is not expanded).
|
||||
fn measure_entries(entries: &[PickerEntry<'_>]) -> u16 {
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
PickerEntry::Header { .. } => 1u16,
|
||||
.enumerate()
|
||||
.map(|(idx, e)| match e {
|
||||
PickerEntry::Header { .. } => {
|
||||
if idx == 0 {
|
||||
1u16
|
||||
} else {
|
||||
2u16
|
||||
}
|
||||
}
|
||||
PickerEntry::Row(r) => {
|
||||
if r.expanded {
|
||||
1u16.saturating_add(r.description_lines.len() as u16)
|
||||
|
|
@ -678,6 +710,37 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_panel_pins_hidden_external_hint_above_scrolling_list() {
|
||||
// More native rows than the panel fits: the hint must stay pinned
|
||||
// above the list instead of scrolling away with it.
|
||||
let mut entries: Vec<_> = (0..20)
|
||||
.map(|i| session_entry(&format!("native-{i}")))
|
||||
.collect();
|
||||
let mut foreign = session_entry("claude-session");
|
||||
foreign.source = "claude".into();
|
||||
entries.push(foreign);
|
||||
let mut a = with_resume(entries);
|
||||
let theme = Theme::current();
|
||||
let area = Rect::new(0, 0, 80, 10);
|
||||
let mut buf = Buffer::empty(area);
|
||||
render(&mut buf, area, &mut a, ListPanel::Resume, &theme);
|
||||
|
||||
let text = buffer_text(&buf);
|
||||
assert!(
|
||||
text.contains("1 external session hidden \u{b7} f to show"),
|
||||
"hidden foreign rows must stay explained while the list scrolls:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
text.find("external session hidden") < text.find("native-"),
|
||||
"the hint must be pinned above the first list row:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("claude-session"),
|
||||
"foreign row stays hidden under the default filter:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_search_uses_picker_grapheme_viewport_at_narrow_width() {
|
||||
let grapheme = "👩🏽\u{200d}💻";
|
||||
|
|
|
|||
|
|
@ -78,6 +78,16 @@ pub fn inference_request_count(content: &ContentController) -> usize {
|
|||
/// `default_coding_data_retention_opt_out()`. The mock server accepts any
|
||||
/// bearer. Pair with [`oauth_credential_ops`].
|
||||
pub fn seed_fake_oauth(content: &ContentController, user: &str) {
|
||||
seed_fake_oauth_with_opt_out(content, user, false);
|
||||
}
|
||||
|
||||
/// Like [`seed_fake_oauth`], but with `coding_data_retention_opt_out: true` —
|
||||
/// the auth-side precondition for the coding-data privacy upsell banner.
|
||||
pub fn seed_fake_oauth_coding_data_opted_out(content: &ContentController, user: &str) {
|
||||
seed_fake_oauth_with_opt_out(content, user, true);
|
||||
}
|
||||
|
||||
fn seed_fake_oauth_with_opt_out(content: &ContentController, user: &str, opted_out: bool) {
|
||||
let grok_home = content.home().join(".grok");
|
||||
std::fs::create_dir_all(&grok_home).expect("create temp .grok");
|
||||
std::fs::write(
|
||||
|
|
@ -94,7 +104,7 @@ pub fn seed_fake_oauth(content: &ContentController, user: &str) {
|
|||
"refresh_token": "pty-test-refresh-token",
|
||||
"oidc_issuer": "https://auth.x.ai",
|
||||
"oidc_client_id": "b1a00492-073a-47ea-816f-4c329264a828",
|
||||
"coding_data_retention_opt_out": false
|
||||
"coding_data_retention_opt_out": {opted_out}
|
||||
}}
|
||||
}}"#
|
||||
),
|
||||
|
|
|
|||
|
|
@ -41,8 +41,9 @@ pub use content::{
|
|||
};
|
||||
pub use env::pager_binary;
|
||||
pub use flows::{
|
||||
inference_request_count, oauth_credential_ops, seed_fake_oauth, submit_turn,
|
||||
wait_for_labels_absent, wait_for_model_via_new_sessions,
|
||||
inference_request_count, oauth_credential_ops, seed_fake_oauth,
|
||||
seed_fake_oauth_coding_data_opted_out, submit_turn, wait_for_labels_absent,
|
||||
wait_for_model_via_new_sessions,
|
||||
};
|
||||
pub use host_clipboard::HostClipboardTextGuard;
|
||||
pub use leader::LeaderCluster;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,247 @@
|
|||
//! E2E: the coding-data privacy upsell banner — shown on the welcome screen
|
||||
//! for an opted-out OAuth user under the `privacy_notice_rollout` flag,
|
||||
//! persisting into the agent view, and acked (never re-shown) via both
|
||||
//! buttons: `[Customize in settings]` opens the settings chooser and stamps
|
||||
//! `[privacy].privacy_banner_acked`; `[Accept]` opts the user in through the
|
||||
//! shell's `PUT /privacy/coding-data-retention` round trip before acking.
|
||||
//!
|
||||
//! Drives the real pager binary through a PTY against the shared mock
|
||||
//! inference server (isolated `$HOME`), with a seeded opted-out OAuth entry
|
||||
//! as the active auth (`XAI_API_KEY` removed) and the rollout forced on via
|
||||
//! `GROK_PRIVACY_NOTICE_ROLLOUT=1`.
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo test -p xai-grok-pager-pty-harness --test privacy_banner_e2e \
|
||||
//! -- --ignored --nocapture
|
||||
//! ```
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use xai_grok_pager_pty_harness::{
|
||||
ContentController, EnvOp, PtyExitPoll, PtyHarness, keys, pager_binary,
|
||||
seed_fake_oauth_coding_data_opted_out,
|
||||
};
|
||||
|
||||
const ROWS: u16 = 50;
|
||||
const COLS: u16 = 120;
|
||||
const BANNER_TITLE: &str = "Help improve Grok";
|
||||
const CUSTOMIZE: &str = "[Customize in settings]";
|
||||
const ACCEPT: &str = "[Accept]";
|
||||
const ACK: &str = "BANNERACK";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore] // opt-in: spawns the real pager binary in a PTY (CI runs with --ignored)
|
||||
async fn privacy_banner_welcome_customize_ack_persists() {
|
||||
run_customize().await.expect("privacy banner customize e2e");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore] // opt-in: spawns the real pager binary in a PTY (CI runs with --ignored)
|
||||
async fn privacy_banner_persists_into_agent_view_and_accept_opts_in() {
|
||||
run_accept().await.expect("privacy banner accept e2e");
|
||||
}
|
||||
|
||||
/// Rollout flag forced on (env override beats remote settings) and the
|
||||
/// sandbox's fake `XAI_API_KEY` removed so the seeded opted-out OAuth entry
|
||||
/// is the active auth — the banner's two preconditions.
|
||||
fn banner_env_ops() -> [EnvOp<'static>; 2] {
|
||||
[
|
||||
EnvOp::set("GROK_PRIVACY_NOTICE_ROLLOUT", "1"),
|
||||
EnvOp::remove("XAI_API_KEY"),
|
||||
]
|
||||
}
|
||||
|
||||
async fn run_customize() -> Result<()> {
|
||||
let content = ContentController::start()
|
||||
.await
|
||||
.context("start mock server")?;
|
||||
seed_fake_oauth_coding_data_opted_out(&content, "pty-privacy-user");
|
||||
|
||||
let project = tempfile::tempdir().context("project dir")?;
|
||||
std::fs::create_dir_all(project.path().join(".git")).context("create .git")?;
|
||||
let binary = pager_binary().context("resolve pager binary")?;
|
||||
|
||||
let mut pager = spawn_pager(&binary, &content, project.path()).context("spawn pager")?;
|
||||
wait_for_banner(&mut pager)?;
|
||||
assert!(
|
||||
pager.contains_text(ACCEPT),
|
||||
"welcome banner is missing {ACCEPT}:\n{}",
|
||||
pager.screen_contents()
|
||||
);
|
||||
|
||||
click_text(&mut pager, CUSTOMIZE).context("click Customize")?;
|
||||
pager
|
||||
.wait_for_text("Coding data sharing", Duration::from_secs(20))
|
||||
.context("settings chooser opened on Coding data sharing")?;
|
||||
assert!(
|
||||
pager.contains_text("Opt in") && pager.contains_text("Opt out"),
|
||||
"chooser is missing the Opt in / Opt out choices:\n{}",
|
||||
pager.screen_contents()
|
||||
);
|
||||
|
||||
// Customize acks immediately; the config write is async — poll for it.
|
||||
wait_for_ack_on_disk(&mut pager, content.home(), Duration::from_secs(10))?;
|
||||
|
||||
// Close the chooser, then the settings list, then quit gracefully.
|
||||
pager.inject_keys(keys::ESC).context("close chooser")?;
|
||||
pager.update(Duration::from_millis(300));
|
||||
pager.inject_keys(keys::ESC).context("close settings")?;
|
||||
pager.update(Duration::from_millis(300));
|
||||
quit_via_double_ctrl_c(&mut pager)?;
|
||||
drop(pager);
|
||||
|
||||
// Relaunch with the same sandbox: the acked banner must not re-show.
|
||||
// Sync on "New worktree" — rendered only on the authenticated welcome
|
||||
// menu ("Quit" also appears while auth is still pending, where the
|
||||
// banner is gated off regardless of the ack).
|
||||
let mut relaunched =
|
||||
spawn_pager(&binary, &content, project.path()).context("relaunch pager")?;
|
||||
relaunched
|
||||
.wait_for_text("New worktree", Duration::from_secs(20))
|
||||
.context("relaunched authenticated welcome screen")?;
|
||||
relaunched.update(Duration::from_secs(2));
|
||||
assert!(
|
||||
!relaunched.contains_text(BANNER_TITLE),
|
||||
"acked banner re-showed after relaunch:\n{}",
|
||||
relaunched.screen_contents()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_accept() -> Result<()> {
|
||||
let content = ContentController::start()
|
||||
.await
|
||||
.context("start mock server")?;
|
||||
content.set_response(format!("{ACK} done."));
|
||||
seed_fake_oauth_coding_data_opted_out(&content, "pty-privacy-user");
|
||||
|
||||
let project = tempfile::tempdir().context("project dir")?;
|
||||
std::fs::create_dir_all(project.path().join(".git")).context("create .git")?;
|
||||
let binary = pager_binary().context("resolve pager binary")?;
|
||||
|
||||
let mut pager = spawn_pager(&binary, &content, project.path()).context("spawn pager")?;
|
||||
wait_for_banner(&mut pager)?;
|
||||
|
||||
pager.inject_keys(b"hello").context("type prompt")?;
|
||||
pager.inject_keys(keys::ENTER).context("submit prompt")?;
|
||||
pager
|
||||
.wait_for_text(ACK, Duration::from_secs(30))
|
||||
.context("turn response rendered")?;
|
||||
pager.update(Duration::from_millis(1000));
|
||||
assert!(
|
||||
pager.contains_text(BANNER_TITLE),
|
||||
"banner did not persist into the agent view:\n{}",
|
||||
pager.screen_contents()
|
||||
);
|
||||
|
||||
click_text(&mut pager, ACCEPT).context("click Accept")?;
|
||||
|
||||
// Ack only lands after the shell's PUT round trip confirms 2xx.
|
||||
pager
|
||||
.wait_for_text_absent(BANNER_TITLE, Duration::from_secs(20))
|
||||
.context("banner disappeared after Accept")?;
|
||||
wait_for_ack_on_disk(&mut pager, content.home(), Duration::from_secs(10))?;
|
||||
|
||||
let put_bodies: Vec<_> = content
|
||||
.requests()
|
||||
.iter()
|
||||
.filter(|e| e.method == "PUT" && e.path == "/v1/privacy/coding-data-retention")
|
||||
.filter_map(|e| e.body.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
put_bodies
|
||||
.iter()
|
||||
.any(|b| b["codingDataRetentionOptOut"] == serde_json::json!(false)),
|
||||
"mock server did not see the opt-in PUT; got: {put_bodies:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_pager(binary: &Path, content: &ContentController, project: &Path) -> Result<PtyHarness> {
|
||||
PtyHarness::spawn_with_content_env_ops_in_dir(
|
||||
binary,
|
||||
ROWS,
|
||||
COLS,
|
||||
content,
|
||||
&[],
|
||||
&banner_env_ops(),
|
||||
Some(project),
|
||||
)
|
||||
}
|
||||
|
||||
/// Wait for the welcome menu first (auth resolved) so a missing banner is a
|
||||
/// real failure rather than an early frame, then for the banner itself.
|
||||
fn wait_for_banner(pager: &mut PtyHarness) -> Result<()> {
|
||||
pager
|
||||
.wait_for_text("Quit", Duration::from_secs(20))
|
||||
.context("welcome screen")?;
|
||||
pager
|
||||
.wait_for_text(BANNER_TITLE, Duration::from_secs(20))
|
||||
.context("privacy banner on screen")
|
||||
}
|
||||
|
||||
/// Click `needle` by injecting an SGR (DECSET 1006) press + release at its
|
||||
/// first character. The wire encoding is 1-based `col;row`
|
||||
/// (`screen_contents` line 0 = row 1); the banner region is ASCII-only, so
|
||||
/// the byte offset within the line is the column.
|
||||
fn click_text(pager: &mut PtyHarness, needle: &str) -> Result<()> {
|
||||
let screen = pager.screen_contents();
|
||||
let (row0, col0) = screen
|
||||
.lines()
|
||||
.enumerate()
|
||||
.find_map(|(row, line)| line.find(needle).map(|col| (row, col)))
|
||||
.with_context(|| format!("{needle:?} not on screen:\n{screen}"))?;
|
||||
let (row, col) = (row0 + 1, col0 + 1);
|
||||
pager
|
||||
.inject_keys(format!("\x1b[<0;{col};{row}M\x1b[<0;{col};{row}m").as_bytes())
|
||||
.context("inject SGR click")?;
|
||||
pager.update(Duration::from_millis(250));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll `<home>/.grok/config.toml` for the async `privacy_banner_acked`
|
||||
/// write, pumping PTY output between polls so the pager never blocks on a
|
||||
/// full output buffer.
|
||||
fn wait_for_ack_on_disk(pager: &mut PtyHarness, home: &Path, timeout: Duration) -> Result<()> {
|
||||
let path = home.join(".grok").join("config.toml");
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let body = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
if body.contains("privacy_banner_acked") {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!(
|
||||
"timed out after {timeout:?} waiting for privacy_banner_acked in {}\n\
|
||||
config contents:\n{body}\nscreen:\n{}",
|
||||
path.display(),
|
||||
pager.screen_contents()
|
||||
);
|
||||
}
|
||||
pager.update(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// First Ctrl+C arms the quit confirmation on the empty prompt, the second
|
||||
/// confirms; retry the pair in case an overlay swallowed the first one.
|
||||
fn quit_via_double_ctrl_c(pager: &mut PtyHarness) -> Result<()> {
|
||||
for _ in 0..3 {
|
||||
pager.inject_keys(keys::CTRL_C).context("ctrl-c arm")?;
|
||||
pager.update(Duration::from_millis(250));
|
||||
pager.inject_keys(keys::CTRL_C).context("ctrl-c confirm")?;
|
||||
pager.update(Duration::from_millis(250));
|
||||
match pager.wait_exit_code(Duration::from_secs(5))? {
|
||||
PtyExitPoll::Exited(code) => {
|
||||
assert_eq!(code, 0, "graceful quit should exit 0, got {code}");
|
||||
return Ok(());
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
bail!(
|
||||
"pager did not exit after repeated double Ctrl+C\nscreen:\n{}",
|
||||
pager.screen_contents()
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
//! Shared tmux command protocol and result parsing.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
const TMUX_QUERY_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
/// After the leader exits, allow this much additional time for process-group
|
||||
/// teardown and concurrent pipe drains so a near-deadline success is not turned
|
||||
/// into a drain timeout. The main process wait still uses only
|
||||
/// [`TMUX_QUERY_TIMEOUT`].
|
||||
const POST_EXIT_CLEANUP_GRACE: Duration = Duration::from_millis(300);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum TmuxCommand<'a> {
|
||||
|
|
@ -25,15 +33,108 @@ struct LiveTmuxCommandRunner;
|
|||
|
||||
impl TmuxCommandRunner for LiveTmuxCommandRunner {
|
||||
fn run(&self, command: TmuxCommand<'_>) -> Result<TmuxCommandOutput, String> {
|
||||
let output = build_tmux_command(command)
|
||||
.output()
|
||||
run_tmux_bounded(command, TMUX_QUERY_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_tmux_bounded(
|
||||
command: TmuxCommand<'_>,
|
||||
timeout: Duration,
|
||||
) -> Result<TmuxCommandOutput, String> {
|
||||
let mut command = build_tmux_command(command);
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("failed to run tmux: {error}"))?;
|
||||
let group = xai_tty_utils::ProcessGroup::new()
|
||||
.and_then(|mut group| {
|
||||
group.attach_std(&child)?;
|
||||
Ok(group)
|
||||
})
|
||||
.map_err(|error| {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
format!("failed to own tmux process tree: {error}")
|
||||
})?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| "tmux stdout pipe was not captured".to_owned())?;
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| "tmux stderr pipe was not captured".to_owned())?;
|
||||
let stdout = spawn_pipe_drain(stdout, "stdout");
|
||||
let stderr = spawn_pipe_drain(stderr, "stderr");
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if std::time::Instant::now() < deadline => {
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
Ok(None) => {
|
||||
terminate_tmux_tree(&group, &mut child);
|
||||
return Err(format!("tmux query timed out after {timeout:?}"));
|
||||
}
|
||||
Err(error) => {
|
||||
terminate_tmux_tree(&group, &mut child);
|
||||
return Err(format!("failed to wait for tmux: {error}"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The leader may be reaped while descendants still exist or hold pipes.
|
||||
// Use a fresh post-exit bound so near-deadline success still drains; the
|
||||
// main process deadline is not extended for hung leaders.
|
||||
let cleanup_deadline = std::time::Instant::now() + POST_EXIT_CLEANUP_GRACE;
|
||||
terminate_owned_group(&group);
|
||||
let stdout = recv_pipe_drain(stdout, cleanup_deadline, "stdout")?;
|
||||
let stderr = recv_pipe_drain(stderr, cleanup_deadline, "stderr")?;
|
||||
Ok(TmuxCommandOutput {
|
||||
status_success: output.status.success(),
|
||||
stdout: output.stdout,
|
||||
stderr: output.stderr,
|
||||
status_success: status.success(),
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_pipe_drain(
|
||||
mut pipe: impl std::io::Read + Send + 'static,
|
||||
label: &'static str,
|
||||
) -> std::sync::mpsc::Receiver<Result<Vec<u8>, String>> {
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
std::thread::spawn(move || {
|
||||
let mut output = Vec::new();
|
||||
let result = pipe
|
||||
.read_to_end(&mut output)
|
||||
.map(|_| output)
|
||||
.map_err(|error| format!("failed to read tmux {label}: {error}"));
|
||||
let _ = sender.send(result);
|
||||
});
|
||||
receiver
|
||||
}
|
||||
|
||||
fn recv_pipe_drain(
|
||||
receiver: std::sync::mpsc::Receiver<Result<Vec<u8>, String>>,
|
||||
deadline: std::time::Instant,
|
||||
label: &'static str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
receiver
|
||||
.recv_timeout(remaining)
|
||||
.map_err(|_| format!("tmux {label} did not close before the query deadline"))?
|
||||
}
|
||||
|
||||
fn terminate_tmux_tree(group: &xai_tty_utils::ProcessGroup, child: &mut std::process::Child) {
|
||||
terminate_owned_group(group);
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn terminate_owned_group(group: &xai_tty_utils::ProcessGroup) {
|
||||
let _ = group.terminate();
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
// KILL is unconditional because leader state says nothing about descendants.
|
||||
let _ = group.kill();
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
|
|
@ -102,22 +203,22 @@ fn build_tmux_command(command: TmuxCommand<'_>) -> Command {
|
|||
let mut cmd = Command::new("tmux");
|
||||
match command {
|
||||
TmuxCommand::Version => {
|
||||
cmd.arg("-V").stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
cmd.arg("-V").stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
}
|
||||
TmuxCommand::OptionValue(option) => {
|
||||
cmd.args(["show-option", "-gqv", option])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
.stderr(Stdio::piped());
|
||||
}
|
||||
TmuxCommand::OptionSupport(option) => {
|
||||
cmd.args(["show-option", "-gv", option])
|
||||
.stdout(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
}
|
||||
TmuxCommand::ControlMode => {
|
||||
cmd.args(["display-message", "-p", "#{client_flags}"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
.stderr(Stdio::piped());
|
||||
}
|
||||
}
|
||||
cmd.stdin(Stdio::null()).envs(xai_tty_utils::pager_env());
|
||||
|
|
@ -271,4 +372,67 @@ mod tests {
|
|||
TmuxQueryResult::Unavailable
|
||||
);
|
||||
}
|
||||
|
||||
/// A leader that exits successfully just under the process deadline must
|
||||
/// still return captured output: post-exit TERM grace + pipe drain use a
|
||||
/// separate bound and must not turn success into a drain timeout.
|
||||
///
|
||||
/// A background descendant keeps the captured pipes open until process-group
|
||||
/// teardown so the drain cannot finish during the wait loop. That makes the
|
||||
/// post-exit cleanup window load-bearing once the main deadline is nearly
|
||||
/// exhausted.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
#[serial_test::serial(tmux_probe_path)]
|
||||
fn successful_near_deadline_exit_still_returns_captured_output() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let bin = temp.path().join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
let tmux = bin.join("tmux");
|
||||
// Burn most of the process budget, then exit successfully while a
|
||||
// descendant still holds the pipes. Remaining main-deadline time is
|
||||
// intentionally below the fixed TERM grace sleep so a shared deadline
|
||||
// would fail the drain; the separate post-exit cleanup grace must keep
|
||||
// this a success. Perl select is used for subsecond precision.
|
||||
let timeout = Duration::from_millis(1500);
|
||||
std::fs::write(
|
||||
&tmux,
|
||||
"#!/bin/sh\n\
|
||||
/usr/bin/perl -e 'select(undef, undef, undef, 1.2)'\n\
|
||||
( exec sleep 30 ) &\n\
|
||||
printf 'tmux 3.4\\n'\n\
|
||||
exit 0\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&tmux, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let previous_path = std::env::var_os("PATH");
|
||||
let mut path = OsString::from(bin.as_os_str());
|
||||
path.push(":");
|
||||
if let Some(existing) = &previous_path {
|
||||
path.push(existing);
|
||||
}
|
||||
// SAFETY: serialized on `tmux_probe_path`; restored before return.
|
||||
unsafe {
|
||||
std::env::set_var("PATH", &path);
|
||||
}
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
run_tmux_bounded(TmuxCommand::Version, timeout)
|
||||
}));
|
||||
match previous_path {
|
||||
Some(value) => unsafe {
|
||||
std::env::set_var("PATH", value);
|
||||
},
|
||||
None => unsafe {
|
||||
std::env::remove_var("PATH");
|
||||
},
|
||||
}
|
||||
let output = result
|
||||
.expect("near-deadline probe must not panic")
|
||||
.expect("near-deadline success must not become a drain error");
|
||||
assert!(output.status_success, "expected successful status");
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "tmux 3.4");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "xai-grok-pager"
|
||||
version = "0.2.110"
|
||||
version = "0.2.111"
|
||||
edition.workspace = true
|
||||
license = "Apache-2.0"
|
||||
authors = ["xAI"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
# Coming from Claude, Cursor, or Codex?
|
||||
|
||||
Fear not — your settings, rules, and skills come with you. Grok Build
|
||||
reads the same project conventions other agents use, and imports the rest.
|
||||
|
||||
## Picked up automatically
|
||||
|
||||
- **Rules & instructions** — `AGENTS.md` (the Codex/OpenCode convention),
|
||||
`CLAUDE.md` (including nested ones), and `*.md` rules under
|
||||
`.claude/rules/` and `.cursor/rules/`.
|
||||
- **Skills & custom commands** — `~/.claude/skills/`, `~/.claude/commands/`,
|
||||
`~/.cursor/skills/`, and their project-level twins. Flat command `.md`
|
||||
files become slash commands here too.
|
||||
- **MCP servers** — from `~/.claude.json`, `.cursor/mcp.json`, and project
|
||||
`.mcp.json`.
|
||||
- **Hooks** — from `.claude/settings.json`, including matcher aliases like
|
||||
`Bash`, so most hooks run unchanged.
|
||||
|
||||
## One-step import
|
||||
|
||||
**`/import-claude`** scans your `~/.claude` settings — permissions, env
|
||||
vars, MCP servers, hooks — and shows a checkbox preview; confirming
|
||||
writes the items you selected into your `.grok` config. Re-run it anytime.
|
||||
|
||||
## Pick up where you left off
|
||||
|
||||
The **`/resume-claude`**, **`/resume-codex`**, and **`/resume-cursor`**
|
||||
skills continue a recent session from those tools right here.
|
||||
|
||||
## Check what was discovered
|
||||
|
||||
Run **`grok inspect`** in a repo to see every rules file, skill, and MCP
|
||||
server Grok picked up, tagged with where it came from. Each compat source
|
||||
can be toggled in `[compat.claude]` / `[compat.cursor]` config sections.
|
||||
|
||||
And a few things you might have missed elsewhere: `/btw` asks a side
|
||||
question without interrupting the current task, and `/rewind` restores
|
||||
actual file snapshots, not just chat history.
|
||||
|
||||
*Go deeper: `/docs Project Rules (AGENTS.md)`, `/docs Skills`, or `/docs MCP Servers`*
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Your First Prompt
|
||||
|
||||
Grok Build is a conversation with an agent that can read your code, run
|
||||
commands, and edit files — right here in your terminal.
|
||||
|
||||
Type what you want and press `Enter`. Grok streams its work into the
|
||||
**scrollback** above the prompt: responses, shell commands, file edits.
|
||||
|
||||
## Keep typing while Grok works
|
||||
|
||||
While a turn is running, `Enter` **queues** your next message instead of
|
||||
interrupting. Change your mind? Press `Enter` on the empty prompt to stop
|
||||
the current turn and send the queued message right away.
|
||||
|
||||
## You are always in control
|
||||
|
||||
- **`Esc`** — cancel a running turn immediately (your draft is kept).
|
||||
- **`Esc Esc`** while idle — clear the prompt; with an empty prompt, open
|
||||
the rewind picker instead. Cleared something by accident? `Ctrl+Z` undoes.
|
||||
- **`Ctrl+Q`** — quit (`Ctrl+D` in VS Code-family terminals), press twice.
|
||||
|
||||
The **shortcuts bar** at the bottom always shows the keys relevant to what
|
||||
you're doing right now — when in doubt, look down.
|
||||
|
||||
*Go deeper: `/docs Getting Started`*
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# Attach Files, Images & Paste
|
||||
|
||||
The more precisely you point Grok at the right context, the better the
|
||||
result. Three ways to get things into the prompt:
|
||||
|
||||
## Mention files with `@`
|
||||
|
||||
Type `@` for a fuzzy file picker — line ranges work too:
|
||||
|
||||
```
|
||||
@src/main.rs attach a file
|
||||
@src/main.rs:10-50 attach specific lines
|
||||
@!.env reach hidden files with @!
|
||||
```
|
||||
|
||||
## Paste images
|
||||
|
||||
Paste a screenshot straight into the prompt: `Cmd+V` on macOS, `Ctrl+V` on
|
||||
Linux, `Alt+V` on Windows. Great for error dialogs, designs, and diagrams.
|
||||
|
||||
## Run shell commands yourself
|
||||
|
||||
Type `!` on an empty prompt to run a shell command directly — the output
|
||||
lands in the scrollback where Grok can see it too.
|
||||
|
||||
*Go deeper: `/docs Getting Started`*
|
||||
32
crates/codegen/xai-grok-pager/docs/tutorial/04-navigation.md
Normal file
32
crates/codegen/xai-grok-pager/docs/tutorial/04-navigation.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Finding Your Way Around
|
||||
|
||||
The screen has three parts: the **scrollback** (the conversation), the
|
||||
**prompt** below it, and the **shortcuts bar** at the bottom. Panes for
|
||||
todos and background tasks slide in when you need them.
|
||||
|
||||
## Focus
|
||||
|
||||
**`Tab`** switches focus between the prompt and the scrollback. Focused
|
||||
scrollback gets a selection you can move with the arrow keys.
|
||||
|
||||
## Moving through the conversation
|
||||
|
||||
- **`↑`/`↓`** — select the previous/next entry.
|
||||
- **`Shift+←`/`Shift+→`** — jump between turns (your prompts).
|
||||
- **`PageUp`/`PageDown`** — scroll by page; this works straight from the
|
||||
prompt, no focus change needed.
|
||||
- **`←`/`→`** — collapse/expand the selected entry; long tool output stays
|
||||
out of your way until you want it.
|
||||
- **`Enter`** — open the selected entry in a fullscreen viewer.
|
||||
|
||||
## Panes
|
||||
|
||||
- **`Ctrl+T`** — toggle the **todos pane**: Grok's live plan for the
|
||||
current task.
|
||||
- **`Ctrl+G`** — toggle the **tasks pane**: everything running in the
|
||||
background, with its status.
|
||||
|
||||
Prefer vim keys? **`/vim-mode`** switches the scrollback to `j`/`k`,
|
||||
`g`/`G`, and friends.
|
||||
|
||||
*Go deeper: `/docs Keyboard Shortcuts`*
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Slash Commands
|
||||
|
||||
Type `/` on an empty prompt and a searchable dropdown of commands appears.
|
||||
A few worth knowing on day one:
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/help` | Browse every command and keyboard shortcut |
|
||||
| `/model` | Switch models or reasoning effort |
|
||||
| `/resume` | Pick up a previous session where you left off |
|
||||
| `/new` | Start a fresh session |
|
||||
| `/compact` | Compress a long conversation to free up context |
|
||||
| `/btw` | Send Grok an aside *without* interrupting its current task |
|
||||
| `/rewind` | Restore your files and history to an earlier prompt |
|
||||
| `/docs` | Full How-to Guides, in the TUI or on the web |
|
||||
| `/feedback` | Send feedback to the team |
|
||||
|
||||
Two of those deserve a second look:
|
||||
|
||||
- **`/compact`** takes an optional hint: `/compact keep the auth details`.
|
||||
Check context usage anytime with `/context` — Grok also auto-compacts
|
||||
when the window fills up.
|
||||
- **`/rewind`** restores actual file snapshots taken at each prompt, not
|
||||
just the chat.
|
||||
|
||||
## The command palette
|
||||
|
||||
Press **`Ctrl+P`** (or `?` from the scrollback) to open the command palette —
|
||||
one searchable list of every command, shortcut, and skill. There's also a
|
||||
full shortcuts cheatsheet on `Ctrl+.` (use `Ctrl+X` if your terminal
|
||||
swallows it).
|
||||
|
||||
You don't need to memorize anything: `/` and `Ctrl+P` will always show you
|
||||
what's available.
|
||||
|
||||
*Go deeper: `/docs Slash Commands`*
|
||||
34
crates/codegen/xai-grok-pager/docs/tutorial/06-worktrees.md
Normal file
34
crates/codegen/xai-grok-pager/docs/tutorial/06-worktrees.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Parallel Work: Worktrees
|
||||
|
||||
Want Grok working on a feature while you (or another Grok session) work on
|
||||
something else in the same repo? **Git worktrees** give each session its own
|
||||
isolated checkout — no stepping on each other's changes, no stashing.
|
||||
|
||||
## Start a session in a worktree
|
||||
|
||||
- **From anywhere:** press `Ctrl+N` (twice to confirm) for a new session,
|
||||
then choose the worktree option.
|
||||
- **From the welcome screen:** press `Ctrl+W` (inside a git repo) to open
|
||||
the New Worktree dialog.
|
||||
- **From the shell:**
|
||||
|
||||
```bash
|
||||
grok --worktree=my-feature "refactor the auth module"
|
||||
```
|
||||
|
||||
(Use `=` — otherwise the prompt is taken as the worktree name.)
|
||||
|
||||
## Why this is great
|
||||
|
||||
- Run two or three Grok sessions on the same repo simultaneously.
|
||||
- Experiments stay isolated — if a change doesn't work out, your main
|
||||
checkout is untouched.
|
||||
- When the work is done, apply the changes back like any git branch.
|
||||
|
||||
**`/fork`** copies your current conversation into a parallel session —
|
||||
add a directive to point it at a task: `/fork try the async approach`.
|
||||
|
||||
Running several agents? The **dashboard** (`/dashboard` or `Ctrl+\`) shows
|
||||
every session grouped by state — who needs input, who's working, who's done.
|
||||
|
||||
*Go deeper: `/docs Session Management`*
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Plan Mode & Permissions
|
||||
|
||||
Grok asks before doing anything risky — and can plan before it codes.
|
||||
|
||||
## Permissions
|
||||
|
||||
When Grok wants to run a risky command or edit a file, it pauses and asks:
|
||||
allow once, always allow that kind of action, or deny.
|
||||
|
||||
Reading is always free: file reads, searches, and safe read-only commands
|
||||
(`ls`, `git status`, `grep`, …) never prompt. Chained commands are
|
||||
checked piece by piece — `ls && rm -rf tmp` still prompts for the `rm`.
|
||||
|
||||
Trust the session? `/always-approve` (or `Ctrl+O`) skips the prompts.
|
||||
|
||||
## Plan mode
|
||||
|
||||
For bigger or more ambiguous tasks, use **plan mode**: Grok explores the
|
||||
codebase read-only, designs an approach, and presents a plan you approve
|
||||
*before* any code is written.
|
||||
|
||||
- **`Shift+Tab`** (prompt focused) cycles the mode: Normal → Plan →
|
||||
Always-approve.
|
||||
- **`/plan`** enters plan mode directly; `/plan <task>` plans that task in
|
||||
one step.
|
||||
|
||||
When the plan is ready: `a` approves, `c` comments on a specific line,
|
||||
`s` requests changes — Grok iterates until you're happy, then implements.
|
||||
|
||||
A good habit: plan mode for "how should we even do this?", normal mode for
|
||||
"just do it".
|
||||
|
||||
## Long-running commands
|
||||
|
||||
A build or test run hogging the turn? **`Ctrl+B`** sends it to the
|
||||
background — Grok keeps working and you're notified when it finishes
|
||||
(`Ctrl+G` shows the tasks pane).
|
||||
|
||||
*Go deeper: `/docs Plan Mode` or `/docs Permissions and Safety`*
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# Make It Yours
|
||||
|
||||
## The easiest way: just ask
|
||||
|
||||
Grok knows its own capabilities and can configure itself. Try:
|
||||
|
||||
- *"add the Postgres MCP server for our staging db"*
|
||||
- *"switch to a light theme"*
|
||||
- *"write an AGENTS.md for this repo"*
|
||||
|
||||
If you'd rather drive, everything below has a command too.
|
||||
|
||||
## Teach Grok your project: AGENTS.md
|
||||
|
||||
Drop an `AGENTS.md` file in your repo root with build commands, conventions,
|
||||
and gotchas. Grok reads it automatically in every session — it's the single
|
||||
highest-leverage customization:
|
||||
|
||||
```markdown
|
||||
# My Project
|
||||
- Run tests with `pnpm test`
|
||||
- Never edit files under generated/
|
||||
```
|
||||
|
||||
## Teach Grok your facts: memory
|
||||
|
||||
Start a prompt with `#` (or use `/remember`) to save a note for future
|
||||
sessions: `# the staging deploy uses eu-west`.
|
||||
|
||||
## Looks, keys, and extensions
|
||||
|
||||
- **`/theme`** — color themes (or `auto` to follow your OS); **`/settings`**
|
||||
(or `F2`) for everything else; **`/vim-mode`** if that's your thing.
|
||||
- **Skills** (`/skills`) — reusable prompt packages; user-invocable skills
|
||||
become slash commands automatically.
|
||||
- **MCP servers** (`/mcps`) and **plugins & hooks** (`/plugins`, `/hooks`).
|
||||
|
||||
Start with `AGENTS.md` and a theme; add the rest when you need it.
|
||||
|
||||
*Go deeper: `/docs Project Rules (AGENTS.md)`, `/docs Skills`, or `/docs MCP Servers`*
|
||||
29
crates/codegen/xai-grok-pager/docs/tutorial/09-where-next.md
Normal file
29
crates/codegen/xai-grok-pager/docs/tutorial/09-where-next.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Where to Go Next
|
||||
|
||||
You know enough to be productive. When you want more:
|
||||
|
||||
## Built-in help
|
||||
|
||||
- **`/help`** or **`Ctrl+P`** — every command, shortcut, and skill, searchable.
|
||||
- **`/docs`** — the full How-to Guides inside the TUI (`/docs web` for the
|
||||
online docs). Covers sessions, headless mode, subagents, sandboxing,
|
||||
memory, and much more.
|
||||
- **Ask Grok itself** — it can read its own user guide and set itself up.
|
||||
Try: "How do I run you in CI?" or "add an MCP server for GitHub".
|
||||
|
||||
## Good habits
|
||||
|
||||
- Sessions save automatically. Resume the latest with `grok -c`, or pick
|
||||
one with `/resume` (`Ctrl+S`).
|
||||
- Long session getting slow? `/compact` frees context; `/context` shows
|
||||
where it's going.
|
||||
- Automate anything: `grok -p "summarize new TODOs" --output-format json`
|
||||
runs headless — great for scripts and CI.
|
||||
- Stay current with `grok update`; see what changed with `/release-notes`.
|
||||
- Something feel off? `/feedback` goes straight to the team.
|
||||
|
||||
## Reopen this tutorial
|
||||
|
||||
Type **`/tutorial`** anytime.
|
||||
|
||||
Now go build something.
|
||||
|
|
@ -347,6 +347,16 @@ Browse the in-TUI How-to Guides, open the online Build docs, or jump straight to
|
|||
- `/docs web` opens https://docs.x.ai/build/overview in your browser.
|
||||
- `/docs <title>` opens a specific guide by case-insensitive title match.
|
||||
|
||||
### `/tutorial`
|
||||
|
||||
Open the onboarding tutorial: a short list of topics (your first prompt, attaching context, navigation, slash commands, worktrees, plan mode, customization, switching from another agent tool) — each a ~30-second read, with `→` flowing straight to the next topic. Nothing auto-shows — this command (or the command palette) is the way in.
|
||||
|
||||
```
|
||||
/tutorial
|
||||
```
|
||||
|
||||
Aliases: `/tour`, `/onboarding`
|
||||
|
||||
### `/import-claude`
|
||||
|
||||
Open the Claude import modal to bring over `~/.claude` settings: permissions, environment variables, MCP servers, hooks, and paths.
|
||||
|
|
|
|||
|
|
@ -221,9 +221,11 @@ temperature = 0.7 # sampling temperature (0.0-2.0)
|
|||
top_p = 0.95 # nucleus sampling parameter
|
||||
max_completion_tokens = 8192 # max tokens per response
|
||||
context_window = 128000 # context window size (for auto-compact)
|
||||
query_params = { api-version = "2026-07-22" } # query params appended to every request URL
|
||||
env_http_headers = { "X-Tenant" = "TENANT_TOKEN" } # request headers from env vars, resolved at client build
|
||||
```
|
||||
|
||||
Credential resolution: `api_key` > `env_key` > signed-in session token > `XAI_API_KEY`.
|
||||
Credential resolution: `api_key` > `env_key` > signed-in session token > `XAI_API_KEY`. See [Custom Models](11-custom-models.md#request-query-parameters) for `query_params` and `env_http_headers`, and [Sandbox Mode](18-sandbox.md#shell-environment-policy) for `[shell_environment_policy]`, which restricts the environment variables tool subprocesses inherit.
|
||||
|
||||
To override a built-in model, use its name as the section key and set only the fields you need:
|
||||
|
||||
|
|
@ -513,6 +515,41 @@ otel_log_user_prompts = false # content gate (admins
|
|||
otel_log_tool_details = false # content gate (admins can pin via requirements)
|
||||
```
|
||||
|
||||
### Version pinning
|
||||
|
||||
Control which versions the CLI may auto-update to and which versions may run. Set
|
||||
these in `[cli]`, or in a managed layer for fleet-wide policy. Each has an
|
||||
environment override that can only tighten the bound, for CI and testing.
|
||||
|
||||
> **Changed:** `minimum_version` no longer blocks startup. It is now a soft
|
||||
> anti-downgrade floor for the updater. For a hard floor that prevents old
|
||||
> versions from starting, use `required_minimum_version`.
|
||||
|
||||
```toml
|
||||
[cli]
|
||||
minimum_version = "0.2.109" # updater won't downgrade below this
|
||||
maximum_version = "0.2.180" # updater won't install above this
|
||||
required_minimum_version = "0.2.100" # refuse to start below this
|
||||
required_maximum_version = "0.2.200" # refuse to start above this
|
||||
```
|
||||
|
||||
- `minimum_version` (`GROK_MINIMUM_VERSION`) is a soft anti-downgrade floor. The
|
||||
updater skips a target below it and keeps the current version. It never blocks
|
||||
startup.
|
||||
- `maximum_version` (`GROK_MAXIMUM_VERSION`) is a soft ceiling. The updater caps
|
||||
its target at it and never installs above it.
|
||||
- `required_minimum_version` (`GROK_REQUIRED_MINIMUM_VERSION`) and
|
||||
`required_maximum_version` (`GROK_REQUIRED_MAXIMUM_VERSION`) are hard bounds. If
|
||||
the running version is outside the range, the CLI exits at startup and instructs
|
||||
the user to install an approved version. `grok update` and `grok --version` keep
|
||||
working so an out-of-range install can recover.
|
||||
- Bounds resolve across config layers by tightening only: a floor takes the
|
||||
highest value and a ceiling the lowest, so a managed bound can't be loosened,
|
||||
and a user or environment bound can't cancel a managed hard bound. An invalid
|
||||
value is ignored so a bad policy can't block startup.
|
||||
- An explicit `grok update --version X` is allowed above the ceiling, to recover
|
||||
from a too-new install, and rejected below the hard floor.
|
||||
|
||||
### Enterprise deployment
|
||||
|
||||
A complete config for enterprise use:
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ top_p = 0.95 # Nucleus sampling parameter
|
|||
max_completion_tokens = 8192 # Maximum tokens per response
|
||||
context_window = 128000 # Total context window in tokens
|
||||
extra_headers = { "x-api-key" = "sk-..." } # Extra request headers, sent verbatim (optional)
|
||||
query_params = { api-version = "2026-07-22" } # Query params appended to every request URL (optional)
|
||||
env_http_headers = { "X-Tenant" = "TENANT_TOKEN" } # Headers from env vars, resolved at client build (optional)
|
||||
```
|
||||
|
||||
### Credential Resolution
|
||||
|
|
@ -131,6 +133,36 @@ This is a small, fixed set of environment-wide knobs. Settings that identify a s
|
|||
|
||||
> **Note on `stream_tool_calls`:** this one affects request *shape*, not just sampling. A few endpoints (some BYOK providers) expect it left unset; if a global `stream_tool_calls = true` causes problems for such a model, opt that model out with `stream_tool_calls = false` in its `[model.<id>]` block.
|
||||
|
||||
### Request Query Parameters
|
||||
|
||||
Some gateways route or version on the query string. `query_params` appends percent-encoded query parameters to every request Grok makes for a model. For example, a gateway that selects an API version this way:
|
||||
|
||||
```toml
|
||||
[model.my-gateway]
|
||||
model = "my-model"
|
||||
base_url = "https://gateway.example/v1"
|
||||
api_backend = "responses"
|
||||
env_key = "GATEWAY_API_KEY"
|
||||
query_params = { api-version = "2026-07-22" }
|
||||
```
|
||||
|
||||
A key that also appears in the `base_url` query string is overridden (last value wins) rather than duplicated. Query parameters are saved in the session, so do not put secrets in them: use `env_http_headers` for a secret.
|
||||
|
||||
### Environment-Variable Headers
|
||||
|
||||
`env_http_headers` maps a request header to the name of an environment variable that supplies its value, so a per-request secret never has to be written into `config.toml`:
|
||||
|
||||
```toml
|
||||
[model.gateway]
|
||||
model = "my-model"
|
||||
base_url = "https://gateway.example/v1"
|
||||
env_http_headers = { "X-Tenant-Token" = "GATEWAY_TENANT_TOKEN" }
|
||||
```
|
||||
|
||||
Grok reads each variable when it builds the client for a session and places the value in the request headers only, never on disk. A header is skipped when its variable is unset or blank, and a resolved value overrides an `extra_headers` entry of the same name. Use `extra_headers` for a static value and `env_http_headers` for one that comes from the environment.
|
||||
|
||||
Both fields also work on a shared `[model_providers.<id>]` block. A model that points at a provider with `model_provider = "<id>"` inherits the provider's `query_params` and `env_http_headers` when it sets none of its own, matching how `extra_headers` is inherited.
|
||||
|
||||
---
|
||||
|
||||
## Overriding Built-in Models
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ Grok processes the prompt, runs any necessary tools, and prints the result to st
|
|||
| `-m, --model <MODEL>` | Model to use (e.g., `grok-build`) |
|
||||
| `-s, --session-id <ID>` | Create a **new** session with this **UUID** (errors if invalid UUID or already in use under the target session directory; does not resume — use `-r`/`-c`) |
|
||||
| `--fork-session` | With `-r`/`-c`, fork into a new session ID instead of appending to the original |
|
||||
| `-r, --resume <ID>` | Resume an existing session (errors if not found) |
|
||||
| `-r, --resume <ID_OR_TITLE>` | Resume an existing session by ID, or by title for the current directory, ignoring letter case (a sole manually renamed match wins among duplicates; remaining duplicates error with their IDs; UUID-shaped values always take the ID path; scripts should prefer IDs) |
|
||||
| `-c, --continue` | Continue the most recent session in current directory |
|
||||
| `--cwd <PATH>` | Set working directory |
|
||||
| `--output-format <FMT>` | Output format: `plain`, `json`, `streaming-json` |
|
||||
|
|
@ -256,7 +256,7 @@ grok -p "hello" --session-id "$(uuidgen | tr '[:upper:]' '[:lower:]')" --output-
|
|||
|
||||
### Resume (`-r`)
|
||||
|
||||
The `-r/--resume` flag resumes a specific session by ID. It errors if the session does not exist:
|
||||
The `-r/--resume` flag resumes a specific session by ID, or by title for the current directory when the value is not an ID, ignoring letter case (a sole manually renamed match wins among duplicates; remaining duplicates error with their IDs; UUID-shaped values always take the ID path — scripts should prefer IDs). It errors if the session does not exist:
|
||||
|
||||
```bash
|
||||
# Get the session ID from a previous JSON response
|
||||
|
|
|
|||
|
|
@ -82,13 +82,15 @@ To switch between, rename, or close the sessions that are currently active (the
|
|||
|
||||
### From the Command Line
|
||||
|
||||
Resume a specific session by ID:
|
||||
Resume a specific session by ID or title:
|
||||
|
||||
```bash
|
||||
grok --resume <session-id>
|
||||
grok --resume <session-id-or-title>
|
||||
```
|
||||
|
||||
Run `grok --resume` without an ID to resume the most recent session for the current directory.
|
||||
A value that is not a session ID is matched against session titles for the current directory, ignoring letter case (a simple lowercase comparison) — handy after `/rename`. If several sessions share the title, a single manually renamed session wins over auto-generated duplicates; otherwise the command errors and lists the matching IDs. UUID-shaped values are always treated as session IDs, never titles. Scripts should prefer IDs.
|
||||
|
||||
Run `grok --resume` without a value to resume the most recent session for the current directory.
|
||||
|
||||
### From the Welcome Screen
|
||||
|
||||
|
|
@ -187,14 +189,14 @@ In headless mode, you manage sessions through command-line flags:
|
|||
# New session each time (default)
|
||||
grok -p "Hello"
|
||||
|
||||
# Resume an existing session by ID (errors if it does not exist)
|
||||
grok -p "Continue where we left off" -r <session-id>
|
||||
# Resume an existing session by ID or title (errors if it does not exist)
|
||||
grok -p "Continue where we left off" -r <session-id-or-title>
|
||||
|
||||
# Continue the most recent session in the current directory
|
||||
grok -p "What were we doing?" -c
|
||||
```
|
||||
|
||||
In headless mode, resume an existing session with `-r`/`--resume`, which errors if the session does not exist, or continue the most recent session in the current directory with `-c`/`--continue`. Pass the session ID from JSON output (see below) to `-r`.
|
||||
In headless mode, resume an existing session with `-r`/`--resume`, which errors if the session does not exist, or continue the most recent session in the current directory with `-c`/`--continue`. A non-ID value is matched against session titles for the current directory, ignoring letter case (a sole manually renamed match wins among duplicates; remaining duplicates error with their IDs; UUID-shaped values always take the ID path) — scripts should pass the session ID from JSON output (see below) to `-r`.
|
||||
|
||||
Use `-s`/`--session-id` only to **create** a new session with a **UUID** (errors if the value is not a UUID, or if that ID already has a session under the target session directory). It does **not** resume an existing session — that was the old hidden upsert behavior; use `-r`/`-c` instead. Combine `-s` with `-r`/`-c` only when also passing `--fork-session` (forks history into a new ID; optional `-s` names the child UUID). This matches Claude Code’s anti-overwrite model (client preflight under the write cwd; sequential use is reliable, concurrent same-ID is best-effort).
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,18 @@ To block specific files (e.g. `.env` or credential paths) on top of a profile, d
|
|||
|
||||
**strict** -- The most restrictive profile, for reviewing untrusted code. The agent can only read files within the current working directory and essential system paths. Writes are limited to CWD, `~/.grok/`, and temp directories. Child-process network access is blocked on Linux (no-op on macOS).
|
||||
|
||||
### Direct global hook write protection
|
||||
|
||||
Under `workspace`, `read-only`, and `strict` (and custom profiles that extend those bases), the Grok state directory remains writable for session/runtime files, but the kernel **write-denies** the Grok-owned direct disk paths used as user-global hook sources (they stay readable):
|
||||
|
||||
- `~/.grok/hooks/` (hook directory)
|
||||
- `~/.grok/hooks-paths` (registry file; not loaded as hook JSON — only its absolute targets are)
|
||||
- Absolute targets listed in `hooks-paths` (relative lines are ignored; missing targets refuse sandbox start)
|
||||
|
||||
On first launch under these profiles, Grok creates a real empty `hooks/` directory and empty `hooks-paths` file when they are missing (never symlinks or wrong types). Claude/Cursor global settings are **not** covered by this write-deny; discovery of those vendors remains separately gated by compatibility settings.
|
||||
|
||||
A symlinked `$GROK_HOME` or a `hooks-paths` entry with a symlink component is refused at sandbox start (prevents retargeting). Existing parent directories of protected paths are pinned so they cannot be renamed out from under the deny (siblings remain writable). On Linux, nested user namespaces are disabled inside bubblewrap so mount binds cannot be rearranged. Project hooks remain gated by folder trust. The `devbox` profile does not apply this protection (disposable VMs). Profiles that require it refuse to start if the kernel policy cannot be applied (including Linux without verified read-only mounts).
|
||||
|
||||
---
|
||||
|
||||
## Custom Profiles
|
||||
|
|
@ -191,6 +203,25 @@ In practice, on Linux this means:
|
|||
|
||||
---
|
||||
|
||||
## Shell Environment Policy
|
||||
|
||||
The sandbox controls which files and network a subprocess can reach. The top-level `[shell_environment_policy]` table controls which environment variables it inherits, so a tool command the model runs cannot read a secret that happens to sit in your shell environment.
|
||||
|
||||
```toml
|
||||
[shell_environment_policy]
|
||||
inherit = "core" # all (default) | core | none
|
||||
ignore_default_excludes = false # also drop *KEY* / *SECRET* / *TOKEN*
|
||||
exclude = ["ACME_*", "CI_*"] # drop these names
|
||||
include_only = ["PATH", "HOME"] # if set, keep only these names
|
||||
set = { MY_FLAG = "1" } # force these values
|
||||
```
|
||||
|
||||
Grok builds the child environment in order: it starts from `inherit` (`all` keeps everything, `core` keeps a small platform set such as `PATH` and `HOME`, `none` starts empty); drops the built-in secret patterns `*KEY*`, `*SECRET*`, and `*TOKEN*` unless `ignore_default_excludes = true`; drops any `exclude` matches; applies `set`; and, when `include_only` is non-empty, keeps only the matching names. Patterns are case-insensitive globs (`*`, `?`).
|
||||
|
||||
The default (`inherit = "all"`, `ignore_default_excludes = true`) leaves the environment untouched, so nothing changes until you configure a policy. On the non-persistent backend the policy also filters variables captured from your login shell, so an `.rc` file export cannot slip a secret past `exclude` or `include_only`. The persistent shell is one exception: it applies the policy to its base environment, but variables that an `.rc` file exports during login are replayed from a snapshot and are not re-filtered, so keep secrets out of shell startup files there. Enforcement covers the bash tool and terminals on macOS, Linux, and Windows.
|
||||
|
||||
---
|
||||
|
||||
## Event Logging
|
||||
|
||||
Sandbox events are logged to `~/.grok/sandbox-events.jsonl` for debugging. Events include:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,28 @@ that appear only as silence during capture.
|
|||
`/terminal-setup`, `/terminal-check`, and `/terminal-info` remain aliases for
|
||||
`/doctor`.
|
||||
|
||||
When Doctor finds an explicit unhealthy tmux setting, `/doctor fix` lists the
|
||||
available automatic fixes. Apply one named fix at a time, for example
|
||||
`/doctor fix tmux-clipboard` or `grok doctor fix dcs-passthrough --yes`.
|
||||
Doctor can persist these three tmux options:
|
||||
|
||||
- `terminal.tmux-clipboard` — `set -g set-clipboard on`
|
||||
- `terminal.dcs-passthrough` — `set -wg allow-passthrough on`
|
||||
- `terminal.tmux-extended-keys` — `set -g extended-keys on`
|
||||
|
||||
A tmux fix edits only the persistent config on the computer hosting the affected
|
||||
tmux server, including remote sessions. Plain tmux uses the real
|
||||
`$HOME/.tmux.conf`; Byobu-tmux uses its effective `BYOBU_CONFIG_DIR` and refuses
|
||||
to guess if that directory is unavailable or unsafe. Grok preserves the file's
|
||||
line endings and mode, makes a backup when changing an existing file, and
|
||||
refuses conflicting or ambiguous direct assignments.
|
||||
|
||||
Grok deliberately does **not** run `tmux source-file` or change the live tmux
|
||||
server. Reload with the exact command shown after apply, or detach and reattach,
|
||||
then run `/doctor` again. Until reload, the live finding is expected to remain.
|
||||
The conservative config scan checks direct global assignments only; review
|
||||
sourced files, conditionals, plugins, and generated tmux setup yourself.
|
||||
|
||||
---
|
||||
|
||||
## Detected Terminals
|
||||
|
|
|
|||
|
|
@ -115,11 +115,21 @@ pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut App
|
|||
agent.set_sharing_enabled(v);
|
||||
}
|
||||
}
|
||||
// Env overrides win over live updates too, mirroring the startup
|
||||
// resolution in event_loop — otherwise the proxy's explicit `false`
|
||||
// (sent for kill-switch semantics) clobbers a local test override
|
||||
// moments after launch.
|
||||
if let Some(v) = update.privacy_notice_rollout {
|
||||
app.privacy_notice_rollout = v;
|
||||
app.privacy_notice_rollout =
|
||||
xai_grok_config::env_bool("GROK_PRIVACY_NOTICE_ROLLOUT").unwrap_or(v);
|
||||
}
|
||||
if let Some(v) = update.privacy_banner_reshow_days {
|
||||
app.privacy_banner_reshow_days = Some(v);
|
||||
app.privacy_banner_reshow_days = Some(
|
||||
std::env::var("GROK_PRIVACY_BANNER_RESHOW_DAYS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(v),
|
||||
);
|
||||
}
|
||||
// Tier before voice: same payload may set "API Key" and voice_mode_enabled=false.
|
||||
// Always recompute is_api_key_auth from the tier so a later Free/SuperGrok
|
||||
|
|
@ -298,6 +308,19 @@ pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut App
|
|||
}
|
||||
}
|
||||
|
||||
// Re-resolve dropdown tags only when the update carries the field. Some(None) =
|
||||
// remote cleared (drop remote layer); Some(Some(map)) = set; outer None = field
|
||||
// absent (older shell) → keep the tags resolved at startup. Env + local
|
||||
// [slash_command_tags] always apply via resolve_slash_command_tags.
|
||||
if let Some(remote_tags) = update.slash_command_tags.as_ref() {
|
||||
use xai_grok_shell::util::config::resolve_slash_command_tags;
|
||||
let effective_config = xai_grok_shell::config::load_effective_config().ok();
|
||||
let empty_toml = toml::Value::Table(Default::default());
|
||||
let tags_config = effective_config.as_ref().unwrap_or(&empty_toml);
|
||||
*app.command_tags.borrow_mut() =
|
||||
resolve_slash_command_tags(tags_config, remote_tags.as_ref());
|
||||
}
|
||||
|
||||
tracing::info!("settings updated via x.ai/settings/update");
|
||||
true
|
||||
}
|
||||
|
|
@ -490,6 +513,12 @@ pub(super) struct PagerSettingsUpdate {
|
|||
session_picker_grouped: Option<bool>,
|
||||
#[serde(default)]
|
||||
tips: Option<Vec<String>>,
|
||||
/// Free-form per-command slash-dropdown tags (canonical name → tag).
|
||||
/// Presence-aware and tolerant: omit = no update (older shell), `null` =
|
||||
/// remote cleared, map = set, malformed = warn + treat as absent so a
|
||||
/// bad value never fails the whole `PagerSettingsUpdate` parse.
|
||||
#[serde(default, deserialize_with = "deserialize_settings_update_tags")]
|
||||
slash_command_tags: Option<Option<std::collections::BTreeMap<String, String>>>,
|
||||
// `announcements` is deliberately NOT consumed here: every shell writer of
|
||||
// remote_settings also emits gen-ordered `x.ai/announcements/update`
|
||||
// (emit_announcements_if_changed), and a gen-less apply on this path could
|
||||
|
|
@ -532,6 +561,33 @@ where
|
|||
Ok(Some(Option::<String>::deserialize(deserializer)?))
|
||||
}
|
||||
|
||||
/// Presence-aware + tolerant tags map for live settings updates.
|
||||
/// Only invoked when the field is present (`#[serde(default)]` covers omit).
|
||||
/// - JSON null → `Some(None)` (explicit remote clear)
|
||||
/// - valid object → `Some(Some(map))`
|
||||
/// - malformed → warn + `Ok(None)` (leave tags alone; do not fail the struct)
|
||||
fn deserialize_settings_update_tags<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Option<std::collections::BTreeMap<String, String>>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
match value {
|
||||
serde_json::Value::Null => Ok(Some(None)),
|
||||
v => match serde_json::from_value::<std::collections::BTreeMap<String, String>>(v) {
|
||||
Ok(m) => Ok(Some(Some(m))),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"malformed slash_command_tags in settings update; leaving tags unchanged"
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod presence_aware_dto_tests {
|
||||
use super::*;
|
||||
|
|
@ -570,4 +626,61 @@ mod presence_aware_dto_tests {
|
|||
"string must be Some(Some(_))"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_tags_dto_absent_null_map_and_malformed() {
|
||||
// 1. field absent → outer None (leave tags alone)
|
||||
let absent: PagerSettingsUpdate = serde_json::from_value(serde_json::json!({
|
||||
"tips": ["hello"],
|
||||
}))
|
||||
.expect("absent slash_command_tags must not fail parse");
|
||||
assert_eq!(absent.slash_command_tags, None, "omit must be None");
|
||||
assert_eq!(absent.tips.as_deref(), Some(&["hello".to_string()][..]));
|
||||
|
||||
// 2. explicit null → Some(None) (remote cleared)
|
||||
let null_v: PagerSettingsUpdate = serde_json::from_value(serde_json::json!({
|
||||
"slash_command_tags": null,
|
||||
}))
|
||||
.expect("null slash_command_tags must parse");
|
||||
assert_eq!(
|
||||
null_v.slash_command_tags,
|
||||
Some(None),
|
||||
"explicit null must be Some(None)"
|
||||
);
|
||||
|
||||
// 3. valid map → Some(Some(map))
|
||||
let map_v: PagerSettingsUpdate = serde_json::from_value(serde_json::json!({
|
||||
"slash_command_tags": {"workflows": "new"},
|
||||
}))
|
||||
.expect("valid slash_command_tags map must parse");
|
||||
let tags = map_v
|
||||
.slash_command_tags
|
||||
.as_ref()
|
||||
.and_then(|inner| inner.as_ref())
|
||||
.expect("expected Some(Some(map))");
|
||||
assert_eq!(tags.get("workflows").map(String::as_str), Some("new"));
|
||||
assert_eq!(tags.len(), 1);
|
||||
|
||||
// 4. malformed must NOT fail the whole struct; sibling fields still apply
|
||||
let bad: PagerSettingsUpdate = serde_json::from_value(serde_json::json!({
|
||||
"slash_command_tags": ["oops"],
|
||||
"tips": ["still-applied"],
|
||||
"permission_mode": "always-approve",
|
||||
}))
|
||||
.expect("malformed slash_command_tags must not fail PagerSettingsUpdate parse");
|
||||
assert_eq!(
|
||||
bad.slash_command_tags, None,
|
||||
"malformed tags treated as absent"
|
||||
);
|
||||
assert_eq!(
|
||||
bad.tips.as_deref(),
|
||||
Some(&["still-applied".to_string()][..]),
|
||||
"sibling tips must still parse"
|
||||
);
|
||||
assert_eq!(
|
||||
bad.permission_mode,
|
||||
Some(Some("always-approve".into())),
|
||||
"sibling permission_mode must still parse"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1837,6 +1837,7 @@ pub(super) fn task_completed_notif(
|
|||
block_waited: false,
|
||||
explicitly_killed: false,
|
||||
owner_session_id: None,
|
||||
description: None,
|
||||
},
|
||||
will_wake,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -509,6 +509,10 @@ pub enum Action {
|
|||
SetHunkTrackerMode(String),
|
||||
/// Set default screen mode (`fullscreen` | `minimal`); restart-required.
|
||||
SetScreenMode(String),
|
||||
/// Enable/disable the Ctrl+Space / F8 voice-dictation shortcut. SHELL-owned;
|
||||
/// persisted to `[ui].voice_keybind_enabled`. Takes effect on the next
|
||||
/// keypress; `/voice` is unaffected.
|
||||
SetVoiceKeybindEnabled(bool),
|
||||
/// Set the voice capture mode (`toggle` | `hold`). SHELL-owned; persisted to
|
||||
/// `[ui].voice_capture_mode`. Takes effect for the next Ctrl+Space press.
|
||||
SetVoiceCaptureMode(String),
|
||||
|
|
@ -605,6 +609,9 @@ pub enum Action {
|
|||
OpenCommandPalette,
|
||||
/// Open the in-TUI How-to Guides doc picker (`/docs`, palette "How-to Guides").
|
||||
OpenHowtoGuides,
|
||||
/// Open the onboarding tutorial overlay (`/tutorial` or the command
|
||||
/// palette).
|
||||
OpenTutorial,
|
||||
/// Open the reset-settings confirmation dialog for a specific key.
|
||||
/// Moves the Settings modal state into `ResetSettingsConfirm` so
|
||||
/// the underlying modal survives the confirm dialog.
|
||||
|
|
@ -2838,7 +2845,6 @@ pub enum TaskResult {
|
|||
},
|
||||
DoctorFixApplied {
|
||||
target: DoctorFixTarget,
|
||||
shell: crate::diagnostics::ShellKind,
|
||||
result: Result<crate::diagnostics::FixOutcome, String>,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1712,10 +1712,7 @@ mod question_no_freeform_tests {
|
|||
&mut scratch,
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
crate::app::agent_view::BannerSlotParams::none(),
|
||||
&bundle,
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
|
|||
|
|
@ -491,6 +491,16 @@ mod link_click_tests {
|
|||
announcements: &[xai_grok_announcements::RemoteAnnouncement],
|
||||
banner_height: u16,
|
||||
cols: u16,
|
||||
) -> Buffer {
|
||||
draw_frame_privacy(agent, reg, announcements, banner_height, cols, false)
|
||||
}
|
||||
fn draw_frame_privacy(
|
||||
agent: &mut AgentView,
|
||||
reg: &ActionRegistry,
|
||||
announcements: &[xai_grok_announcements::RemoteAnnouncement],
|
||||
banner_height: u16,
|
||||
cols: u16,
|
||||
privacy_banner: bool,
|
||||
) -> Buffer {
|
||||
let area = Rect::new(0, 0, cols, 30);
|
||||
let bundle = crate::app::bundle::BundleState::default();
|
||||
|
|
@ -503,10 +513,14 @@ mod link_click_tests {
|
|||
&mut scratch,
|
||||
None,
|
||||
false,
|
||||
banner_height,
|
||||
crate::app::agent_view::BannerSlotParams {
|
||||
height: banner_height,
|
||||
announcements,
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
hidden_ids: &std::collections::BTreeSet::new(),
|
||||
privacy_banner,
|
||||
mouse_pos: None,
|
||||
tip: None,
|
||||
},
|
||||
&bundle,
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
@ -556,6 +570,77 @@ mod link_click_tests {
|
|||
"click where [hide] used to be must not hide-and-persist under a dropdown"
|
||||
);
|
||||
}
|
||||
/// Privacy upsell banner: when the caller passes `privacy_banner: true`,
|
||||
/// the render layer gives it the slot (even over an announcement — the
|
||||
/// critical-outranks-privacy ranking lives in `AppView::draw`, which
|
||||
/// never passes `true` while a critical announcement is live), arms its
|
||||
/// three rects, and clicks dispatch the banner actions. Turning it off
|
||||
/// clears the rects.
|
||||
#[test]
|
||||
fn privacy_banner_owns_slot_and_clicks_dispatch() {
|
||||
let reg = ActionRegistry::defaults();
|
||||
let mut agent = make_agent();
|
||||
agent.last_terminal_size = (80, 30);
|
||||
let critical = [xai_grok_announcements::RemoteAnnouncement {
|
||||
severity: Some("critical".into()),
|
||||
title: Some("ZZCRIT".into()),
|
||||
message: Some("outage".into()),
|
||||
..Default::default()
|
||||
}];
|
||||
let buf = draw_frame_privacy(&mut agent, ®, &critical, 2, 80, true);
|
||||
let text: String = (0..buf.area.height)
|
||||
.map(|y| {
|
||||
(0..buf.area.width)
|
||||
.map(|x| buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "))
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect();
|
||||
assert!(text.contains("Help improve Grok"), "banner copy painted");
|
||||
assert!(
|
||||
!text.contains("ZZCRIT"),
|
||||
"critical announcement yields the slot to the privacy banner"
|
||||
);
|
||||
assert!(
|
||||
agent.hit_announcement_hide.rect.is_none(),
|
||||
"announcement [hide] must not be clickable under the privacy banner"
|
||||
);
|
||||
let rect = agent
|
||||
.privacy_banner
|
||||
.hit_accept
|
||||
.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)
|
||||
));
|
||||
let rect = agent
|
||||
.privacy_banner
|
||||
.hit_customize
|
||||
.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)
|
||||
));
|
||||
let rect = agent
|
||||
.privacy_banner
|
||||
.hit_legal
|
||||
.rect
|
||||
.expect("legal 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
|
||||
));
|
||||
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.hit_announcement_hide.rect.is_some());
|
||||
}
|
||||
/// Promo twin of the [hide] suppression test: the [label] CTA rect must
|
||||
/// also drop under an open dropdown so a dropdown click cannot open a URL
|
||||
/// from a button that is no longer on screen.
|
||||
|
|
@ -2120,10 +2205,7 @@ mod link_click_tests {
|
|||
&mut scratch,
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
crate::app::agent_view::BannerSlotParams::none(),
|
||||
&bundle,
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
@ -2221,10 +2303,10 @@ mod link_click_tests {
|
|||
&mut scratch,
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
Some("ZZSESSIONTIPZZ never shown in agent view"),
|
||||
crate::app::agent_view::BannerSlotParams {
|
||||
tip: Some("ZZSESSIONTIPZZ never shown in agent view"),
|
||||
..crate::app::agent_view::BannerSlotParams::none()
|
||||
},
|
||||
&bundle,
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
@ -2295,10 +2377,14 @@ mod link_click_tests {
|
|||
&mut scratch,
|
||||
None,
|
||||
false,
|
||||
2,
|
||||
&critical,
|
||||
&std::collections::BTreeSet::new(),
|
||||
Some(long_tip.as_str()),
|
||||
crate::app::agent_view::BannerSlotParams {
|
||||
height: 2,
|
||||
announcements: &critical,
|
||||
hidden_ids: &std::collections::BTreeSet::new(),
|
||||
privacy_banner: false,
|
||||
mouse_pos: None,
|
||||
tip: Some(long_tip.as_str()),
|
||||
},
|
||||
&bundle,
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
|
|||
|
|
@ -272,6 +272,57 @@ pub struct HitArea {
|
|||
pub rect: Option<Rect>,
|
||||
pub hovered: bool,
|
||||
}
|
||||
/// 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.
|
||||
#[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,
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
/// Banner-slot inputs to [`AgentView::draw`]. Slot precedence is computed
|
||||
/// by the caller (`AppView::draw`).
|
||||
pub struct BannerSlotParams<'a> {
|
||||
/// Reserved slot height (0 = no slot this frame).
|
||||
pub(crate) height: u16,
|
||||
pub(crate) announcements: &'a [xai_grok_announcements::RemoteAnnouncement],
|
||||
pub(crate) hidden_ids: &'a std::collections::BTreeSet<String>,
|
||||
/// Privacy upsell banner owns the slot (highest banner precedence
|
||||
/// below critical announcements; gated by the caller).
|
||||
pub(crate) privacy_banner: bool,
|
||||
/// Last mouse position, for mouse-pos-driven hover styling.
|
||||
pub(crate) mouse_pos: Option<(u16, u16)>,
|
||||
/// Session tip, only when it owns the slot.
|
||||
pub(crate) tip: Option<&'a str>,
|
||||
}
|
||||
impl BannerSlotParams<'static> {
|
||||
/// No banner slot this frame.
|
||||
pub fn none() -> Self {
|
||||
static EMPTY_IDS: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
||||
Self {
|
||||
height: 0,
|
||||
announcements: &[],
|
||||
hidden_ids: &EMPTY_IDS,
|
||||
privacy_banner: false,
|
||||
mouse_pos: None,
|
||||
tip: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl HitArea {
|
||||
/// Update hover state for a mouse position. Returns `true` if changed.
|
||||
pub fn update_hover(&mut self, col: u16, row: u16) -> bool {
|
||||
|
|
@ -1061,6 +1112,9 @@ pub struct AgentView {
|
|||
pub hit_announcement_hide: HitArea,
|
||||
/// `[label]` CTA button on the promo banner row (click opens its link).
|
||||
pub hit_announcement_cta: HitArea,
|
||||
/// Privacy upsell banner state: slot ownership + click targets
|
||||
/// (packaged like [`Self::plugin_cta`]).
|
||||
pub privacy_banner: PrivacyBannerState,
|
||||
/// `[label]` upgrade CTA appended after the cwd path in the status bar
|
||||
/// (click opens its link; nulled under dropdowns / occluders like the
|
||||
/// banner CTA).
|
||||
|
|
@ -2048,7 +2102,12 @@ fn resolve_action(action_id: Option<ActionId>) -> Option<InputOutcome> {
|
|||
ActionId::ToggleMultiline => return None,
|
||||
ActionId::InterjectPrompt => return None,
|
||||
ActionId::EnableVoiceMode => Action::EnableVoiceMode,
|
||||
ActionId::VoiceToggle => Action::VoiceToggle,
|
||||
ActionId::VoiceToggle => {
|
||||
if !crate::app::voice_keybind_enabled() {
|
||||
return None;
|
||||
}
|
||||
Action::VoiceToggle
|
||||
}
|
||||
ActionId::ShortcutsHelp => return None,
|
||||
ActionId::OpenSettings => return None,
|
||||
ActionId::ToggleTodos
|
||||
|
|
@ -3416,6 +3475,25 @@ mod dropdown_chrome_tests {
|
|||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod voice_keybind_gate_tests {
|
||||
use super::*;
|
||||
/// The per-pane chord route drops `VoiceToggle` while the Voice shortcut
|
||||
/// setting is off (the event-loop intercept skips the chord in that state,
|
||||
/// so this route is what would otherwise leak it through).
|
||||
#[test]
|
||||
fn resolve_action_honors_voice_keybind_gate() {
|
||||
let prev = crate::app::voice_keybind_enabled();
|
||||
crate::app::set_voice_keybind_enabled_for_test(false);
|
||||
assert!(resolve_action(Some(ActionId::VoiceToggle)).is_none());
|
||||
crate::app::set_voice_keybind_enabled_for_test(true);
|
||||
assert!(matches!(
|
||||
resolve_action(Some(ActionId::VoiceToggle)),
|
||||
Some(InputOutcome::Action(Action::VoiceToggle))
|
||||
));
|
||||
crate::app::set_voice_keybind_enabled_for_test(prev);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod prompt_input_mode_tests {
|
||||
use super::*;
|
||||
use crate::app::actions::Action;
|
||||
|
|
|
|||
|
|
@ -646,6 +646,7 @@ impl AgentView {
|
|||
},
|
||||
filter_key_hint: if has_filter { Some("f") } else { None },
|
||||
filter_active: filter != crate::views::extensions_modal::StatusFilter::All,
|
||||
header_note: None,
|
||||
action_keys: &action_keys,
|
||||
disable_search: false,
|
||||
compact_bottom_bar: false,
|
||||
|
|
@ -1028,6 +1029,7 @@ impl AgentView {
|
|||
},
|
||||
filter_key_hint: if has_filter { Some("f") } else { None },
|
||||
filter_active: filter != crate::views::extensions_modal::StatusFilter::All,
|
||||
header_note: None,
|
||||
action_keys: &action_keys,
|
||||
disable_search: false,
|
||||
compact_bottom_bar: false,
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ impl AgentView {
|
|||
pub(crate) fn ephemeral_tip_needs_tick(&self) -> bool {
|
||||
self.ephemeral_tip.is_active()
|
||||
&& !self.session_banner_active
|
||||
&& !self.privacy_banner.active
|
||||
&& (!self.ephemeral_tip.active_is_ambient() || self.ephemeral_tip_can_render())
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +145,9 @@ impl AgentView {
|
|||
let occluded = !self.permission_queue.is_empty()
|
||||
|| self.question_view.is_some()
|
||||
|| self.active_modal.is_some()
|
||||
// Privacy upsell banner owns the slot until acted on — a
|
||||
// session-long occluder like the session announcement banner.
|
||||
|| self.privacy_banner.active
|
||||
// Subagent fullscreen takeover: draw early-returns into
|
||||
// draw_subagent_fullscreen and never paints the parent banner.
|
||||
|| self.active_subagent.is_some()
|
||||
|
|
|
|||
|
|
@ -2072,10 +2072,7 @@ pub(super) mod paste_key_tests {
|
|||
&mut scratch,
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
crate::app::agent_view::BannerSlotParams::none(),
|
||||
&bundle,
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
|
|||
|
|
@ -649,10 +649,7 @@ impl AgentView {
|
|||
scratch,
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
super::BannerSlotParams::none(),
|
||||
bundle_state,
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
@ -685,10 +682,7 @@ impl AgentView {
|
|||
scratch: &mut ScratchBuffer,
|
||||
pending_hint: Option<PendingHint>,
|
||||
overlay_focused: bool,
|
||||
banner_height: u16,
|
||||
banner_announcements: &[xai_grok_announcements::RemoteAnnouncement],
|
||||
hidden_announcement_ids: &std::collections::BTreeSet<String>,
|
||||
tip: Option<&str>,
|
||||
banner: super::BannerSlotParams<'_>,
|
||||
bundle_state: &crate::app::bundle::BundleState,
|
||||
in_dashboard_overlay: bool,
|
||||
link_spans_out: &mut Vec<xai_ratatui_inline::LinkSpan>,
|
||||
|
|
@ -704,11 +698,20 @@ impl AgentView {
|
|||
esc_owned_before_agent,
|
||||
} = app_params;
|
||||
self.in_dashboard_overlay = in_dashboard_overlay;
|
||||
let super::BannerSlotParams {
|
||||
height: banner_height,
|
||||
announcements: banner_announcements,
|
||||
hidden_ids: hidden_announcement_ids,
|
||||
privacy_banner,
|
||||
mouse_pos,
|
||||
tip,
|
||||
} = banner;
|
||||
self.session_banner_active = crate::views::announcements::first_session_announcement(
|
||||
banner_announcements,
|
||||
hidden_announcement_ids,
|
||||
)
|
||||
.is_some();
|
||||
self.privacy_banner.active = privacy_banner;
|
||||
self.pinned_upgrade_cta_live =
|
||||
crate::views::announcements::promo_cta(banner_announcements, hidden_announcement_ids)
|
||||
.is_some_and(|(owner, _, _)| !crate::views::announcements::is_dismissible(owner));
|
||||
|
|
@ -756,6 +759,7 @@ impl AgentView {
|
|||
self.hit_announcement_hide.clear();
|
||||
self.hit_announcement_cta.clear();
|
||||
self.hit_upgrade_cta.clear();
|
||||
self.privacy_banner.clear_hits();
|
||||
return self.draw_subagent_fullscreen(
|
||||
&child_sid.clone(),
|
||||
area,
|
||||
|
|
@ -1121,6 +1125,7 @@ impl AgentView {
|
|||
let btw_height =
|
||||
crate::views::btw_overlay::btw_panel_height(self.btw_state.as_ref(), inner_width);
|
||||
let cta_height = match &self.plugin_cta.phase {
|
||||
_ if privacy_banner => 0,
|
||||
CtaPhase::Hidden => 0,
|
||||
CtaPhase::Matched { .. } if self.prompt.text().trim().is_empty() => 0,
|
||||
_ => 1,
|
||||
|
|
@ -2050,7 +2055,24 @@ impl AgentView {
|
|||
self.hit_bg_button.clear();
|
||||
self.hit_plan_approval_status.clear();
|
||||
}
|
||||
if let Some((ref msg, remaining)) = self.mode_switch_banner {
|
||||
let privacy_banner_owns_slot = privacy_banner && layout.banner.height >= 2;
|
||||
if !privacy_banner_owns_slot {
|
||||
self.privacy_banner.clear_hits();
|
||||
}
|
||||
if privacy_banner_owns_slot {
|
||||
self.hit_announcement_hide.clear();
|
||||
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);
|
||||
self.privacy_banner
|
||||
.hit_customize
|
||||
.set_unless_dropdown(Some(rects.customize), dropdown_open);
|
||||
self.privacy_banner
|
||||
.hit_legal
|
||||
.set_unless_dropdown(Some(rects.legal), dropdown_open);
|
||||
} else if let Some((ref msg, remaining)) = self.mode_switch_banner {
|
||||
self.hit_announcement_hide.clear();
|
||||
self.hit_announcement_cta.clear();
|
||||
if layout.banner.height > 0 && layout.banner.width > 4 {
|
||||
|
|
@ -4364,10 +4386,7 @@ mod voice_recording_overlay_tests {
|
|||
&mut scratch,
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
crate::app::agent_view::BannerSlotParams::none(),
|
||||
&BundleState::default(),
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
@ -4431,10 +4450,7 @@ mod overlay_post_flush_tests {
|
|||
&mut scratch,
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
crate::app::agent_view::BannerSlotParams::none(),
|
||||
&BundleState::default(),
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
|
|||
|
|
@ -198,6 +198,7 @@ impl AgentView {
|
|||
hit_cancel_button: Default::default(),
|
||||
hit_announcement_hide: Default::default(),
|
||||
hit_announcement_cta: Default::default(),
|
||||
privacy_banner: Default::default(),
|
||||
hit_upgrade_cta: Default::default(),
|
||||
hit_voice_stop_button: Default::default(),
|
||||
hit_scrollbar: Default::default(),
|
||||
|
|
|
|||
|
|
@ -770,6 +770,13 @@ pub struct AppView {
|
|||
/// [`PromptWidget::adopt_slash_mru`] so command recency is shared across
|
||||
/// surfaces (single-threaded UI; no process-global singleton).
|
||||
pub(crate) slash_mru: std::rc::Rc<std::cell::RefCell<crate::slash::mru::SlashMru>>,
|
||||
/// The single resolved per-command tag map (canonical name → free-form tag).
|
||||
/// Owned here and injected into every agent prompt and the dashboard dispatch
|
||||
/// via [`PromptWidget::adopt_command_tags`] so slash-dropdown tags are shared
|
||||
/// across surfaces. Populated from remote settings + local config; updated
|
||||
/// in place so adopters see refreshes without re-adopting.
|
||||
pub(crate) command_tags:
|
||||
std::rc::Rc<std::cell::RefCell<std::collections::HashMap<String, String>>>,
|
||||
/// Whether the welcome screen prompt is currently capturing focus (user typed in it).
|
||||
/// When true, menu shortcuts like n/w/q are disabled and Escape unfocuses the prompt.
|
||||
pub welcome_prompt_focused: bool,
|
||||
|
|
@ -989,6 +996,11 @@ pub struct AppView {
|
|||
pub fork_worktree_mode: WorktreeMode,
|
||||
/// Restore code state on resume (`--restore-code`).
|
||||
pub restore_code: Option<bool>,
|
||||
/// Startup resume target that missed local id/title resolution and was
|
||||
/// deferred to the worktree resume handler (set from materialization).
|
||||
/// Worktree failure messages append the no-match hint only for this
|
||||
/// exact target.
|
||||
pub resume_local_miss: Option<String>,
|
||||
pub agent_override: Option<serde_json::Value>,
|
||||
/// ACP-advertised commands seeded into every new `AgentSession` so
|
||||
/// autocomplete has shell builtins and skills before any runtime
|
||||
|
|
@ -1115,6 +1127,10 @@ pub struct AppView {
|
|||
/// Whether the pager uses fullscreen (alt-screen) or inline mode.
|
||||
/// Set from the resolved terminal state at startup.
|
||||
pub(crate) screen_mode: super::ScreenMode,
|
||||
/// Onboarding tutorial overlay, if open. Top-level (not per-agent) so it
|
||||
/// works over both the welcome screen and an agent session. Opened by
|
||||
/// `/tutorial` (also in the command palette).
|
||||
pub tutorial: Option<crate::views::tutorial::TutorialState>,
|
||||
/// Agent Dashboard state. `Some(_)` only when the dashboard view
|
||||
/// is active (`active_view == AgentDashboard`) or recently closed.
|
||||
/// Held outside the `ActiveView` discriminant because `DashboardState`
|
||||
|
|
@ -1339,8 +1355,11 @@ impl AppView {
|
|||
) -> Self {
|
||||
let slash_mru =
|
||||
std::rc::Rc::new(std::cell::RefCell::new(crate::slash::mru::SlashMru::new()));
|
||||
let command_tags =
|
||||
std::rc::Rc::new(std::cell::RefCell::new(std::collections::HashMap::new()));
|
||||
let mut welcome_prompt = PromptWidget::new();
|
||||
welcome_prompt.adopt_slash_mru(slash_mru.clone());
|
||||
welcome_prompt.adopt_command_tags(command_tags.clone());
|
||||
Self {
|
||||
active_view: ActiveView::Welcome,
|
||||
auth_return_view: None,
|
||||
|
|
@ -1381,6 +1400,7 @@ impl AppView {
|
|||
tip: None,
|
||||
welcome_prompt,
|
||||
slash_mru,
|
||||
command_tags,
|
||||
welcome_prompt_focused: true,
|
||||
welcome_tip_typing_dismissed: false,
|
||||
pending_effects: Vec::new(),
|
||||
|
|
@ -1457,6 +1477,7 @@ impl AppView {
|
|||
new_session_worktree_mode: WorktreeMode::Never,
|
||||
fork_worktree_mode: WorktreeMode::Ask,
|
||||
restore_code: None,
|
||||
resume_local_miss: None,
|
||||
agent_override: None,
|
||||
bootstrap_acp_commands,
|
||||
auth_methods: Vec::new(),
|
||||
|
|
@ -1525,6 +1546,7 @@ impl AppView {
|
|||
session_picker_grouped: false,
|
||||
cancel_rewind_enabled: true,
|
||||
session_recap_available: false,
|
||||
tutorial: None,
|
||||
dashboard: None,
|
||||
dashboard_return: None,
|
||||
dashboard_persisted: None,
|
||||
|
|
@ -2341,6 +2363,17 @@ impl AppView {
|
|||
);
|
||||
if is_mouse_action {}
|
||||
}
|
||||
if let Some(tutorial) = self.tutorial.as_mut()
|
||||
&& matches!(ev, Event::Key(_) | Event::Mouse(_) | Event::Paste(_))
|
||||
{
|
||||
match crate::views::tutorial::handle_tutorial_input(ev, tutorial) {
|
||||
crate::views::tutorial::TutorialOutcome::Closed => {
|
||||
self.tutorial = None;
|
||||
}
|
||||
crate::views::tutorial::TutorialOutcome::Consumed => {}
|
||||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
let zdr_blocked = self.is_zdr_blocked();
|
||||
let has_access = self.has_access();
|
||||
let welcome_pinned_upgrade_cta = crate::views::announcements::promo_cta(
|
||||
|
|
@ -2349,6 +2382,12 @@ impl AppView {
|
|||
)
|
||||
.is_some_and(|(owner, _, _)| !crate::views::announcements::is_dismissible(owner));
|
||||
let has_foreign_resume = self.foreign_resume_hint().is_some();
|
||||
let sp_loading = crate::views::session_picker::loading_spinner_active(
|
||||
self.session_picker_entries.as_deref(),
|
||||
self.session_picker_source_filter,
|
||||
self.session_picker_loading,
|
||||
&self.session_picker_lanes,
|
||||
);
|
||||
let outcome = match self.active_view {
|
||||
ActiveView::Welcome => handle_welcome_input(
|
||||
ev,
|
||||
|
|
@ -2398,6 +2437,7 @@ impl AppView {
|
|||
has_access,
|
||||
is_zdr_blocked: zdr_blocked,
|
||||
sp_entries: &mut self.session_picker_entries,
|
||||
sp_loading,
|
||||
sp_state: &mut self.session_picker_state,
|
||||
sp_content_results: &self.session_picker_content_results,
|
||||
sp_content_loading: self.session_picker_content_loading,
|
||||
|
|
@ -2867,7 +2907,12 @@ impl AppView {
|
|||
git_ref: None,
|
||||
},
|
||||
ActionId::OpenDashboard => Action::OpenDashboard,
|
||||
ActionId::VoiceToggle => Action::VoiceToggle,
|
||||
ActionId::VoiceToggle => {
|
||||
if !self.current_ui.voice_keybind_enabled.unwrap_or(true) {
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
Action::VoiceToggle
|
||||
}
|
||||
_ => return InputOutcome::Unchanged,
|
||||
};
|
||||
if def.requires_confirmation {
|
||||
|
|
@ -2990,6 +3035,9 @@ struct WelcomeInputCtx<'a> {
|
|||
has_access: bool,
|
||||
is_zdr_blocked: bool,
|
||||
sp_entries: &'a mut Option<Vec<SessionPickerEntry>>,
|
||||
/// Mirrors the render's `session_picker_loading` param: the spinner-only
|
||||
/// picker still owns input (Esc must dismiss it, not hit the hidden menu).
|
||||
sp_loading: bool,
|
||||
sp_state: &'a mut crate::views::picker::PickerState,
|
||||
sp_content_results:
|
||||
&'a Option<Vec<xai_grok_shell::extensions::session_search::SearchSessionHit>>,
|
||||
|
|
@ -3010,8 +3058,8 @@ struct WelcomeInputCtx<'a> {
|
|||
cwd_has_git_ancestor: bool,
|
||||
session_picker_grouped: bool,
|
||||
sp_source_filter: &'a mut crate::views::session_picker::SourceFilter,
|
||||
/// Process-wide `--chat`: the session picker hides its Local/Remote
|
||||
/// source filter (conversations-only list), so `f` must not cycle it.
|
||||
/// Process-wide `--chat`: the session picker hides its source filter
|
||||
/// (conversations-only list), so `f` must not cycle it.
|
||||
chat_mode: bool,
|
||||
}
|
||||
/// Welcome view input -- auth-state-aware routing.
|
||||
|
|
@ -3153,7 +3201,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
|||
}
|
||||
return InputOutcome::Unchanged;
|
||||
}
|
||||
if ctx.sp_entries.is_some() && matches!(ctx.auth_state, AuthState::Done) {
|
||||
if (ctx.sp_entries.is_some() || ctx.sp_loading) && matches!(ctx.auth_state, AuthState::Done) {
|
||||
use crate::views::picker::{PickerConfig, PickerOutcome, handle_picker_input};
|
||||
let source_filter = *ctx.sp_source_filter;
|
||||
let current_repo =
|
||||
|
|
@ -3187,6 +3235,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
|||
filter_label: (!ctx.chat_mode).then(|| source_filter.label()),
|
||||
filter_key_hint: (!ctx.chat_mode).then_some("f"),
|
||||
filter_active: !ctx.chat_mode && source_filter.is_active(),
|
||||
header_note: None,
|
||||
action_keys: &[],
|
||||
disable_search: false,
|
||||
compact_bottom_bar: false,
|
||||
|
|
@ -3612,7 +3661,9 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
|
|||
if let Some(rect) = ctx.privacy_banner_legal_rect
|
||||
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
|
||||
{
|
||||
return InputOutcome::Action(Action::OpenUrl("https://x.ai/legal".to_string()));
|
||||
return InputOutcome::Action(Action::OpenUrl(
|
||||
crate::views::privacy_banner::PRIVACY_BANNER_LEGAL_URL.to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(rect) = ctx.changelog_cta_rect
|
||||
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
|
||||
|
|
@ -4080,6 +4131,12 @@ impl AppView {
|
|||
let dev_fps_rows = self.dev_fps_rows();
|
||||
let fps_overlay = self.fps_hud.overlay(dev_fps_rows);
|
||||
let foreign_resume_hint = self.foreign_resume_hint().cloned();
|
||||
let privacy_banner_agent = self.privacy_banner_should_show()
|
||||
&& !crate::views::announcements::has_critical_session_announcement(
|
||||
&self.active_announcements,
|
||||
&self.hidden_announcement_ids,
|
||||
);
|
||||
let agent_mouse_pos = self.last_mouse_pos;
|
||||
let Self {
|
||||
active_view,
|
||||
agents,
|
||||
|
|
@ -4190,9 +4247,13 @@ impl AppView {
|
|||
mouse_pos: self.last_mouse_pos,
|
||||
is_zdr_blocked: zdr_blocked_for_draw,
|
||||
session_picker: self.session_picker_entries.as_deref(),
|
||||
session_picker_loading: self.session_picker_entries.is_none()
|
||||
&& (self.session_picker_loading
|
||||
|| self.session_picker_lanes.foreign_loading),
|
||||
session_picker_loading:
|
||||
crate::views::session_picker::loading_spinner_active(
|
||||
self.session_picker_entries.as_deref(),
|
||||
self.session_picker_source_filter,
|
||||
self.session_picker_loading,
|
||||
&self.session_picker_lanes,
|
||||
),
|
||||
compact,
|
||||
pending_hint,
|
||||
startup_warnings: &self.startup_warnings,
|
||||
|
|
@ -4300,6 +4361,14 @@ impl AppView {
|
|||
},
|
||||
);
|
||||
}
|
||||
if let Some(tutorial) = self.tutorial.as_mut() {
|
||||
crate::views::tutorial::render_tutorial(
|
||||
f.buffer_mut(),
|
||||
view_area,
|
||||
tutorial,
|
||||
compact,
|
||||
);
|
||||
}
|
||||
if let Some(fps) = &fps_overlay {
|
||||
fps.render(full_area, f.buffer_mut());
|
||||
}
|
||||
|
|
@ -4307,7 +4376,7 @@ impl AppView {
|
|||
panel.render(full_area, f.buffer_mut());
|
||||
}
|
||||
let has_cloud_modal = false;
|
||||
let cursor = if has_cloud_modal {
|
||||
let cursor = if has_cloud_modal || self.tutorial.is_some() {
|
||||
None
|
||||
} else {
|
||||
result.cursor_pos
|
||||
|
|
@ -4414,9 +4483,13 @@ impl AppView {
|
|||
&self.active_announcements,
|
||||
&self.hidden_announcement_ids,
|
||||
);
|
||||
let show_session_tip = self.tip.is_some() && agent.should_show_tip();
|
||||
let privacy_banner = privacy_banner_agent;
|
||||
let show_session_tip =
|
||||
!privacy_banner && self.tip.is_some() && agent.should_show_tip();
|
||||
let has_mode_banner = agent.mode_switch_banner.is_some();
|
||||
let banner_height = if has_mode_banner {
|
||||
let banner_height = if privacy_banner {
|
||||
2
|
||||
} else if has_mode_banner {
|
||||
1
|
||||
} else if announcement_banner_h > 0 {
|
||||
announcement_banner_h
|
||||
|
|
@ -4432,14 +4505,18 @@ impl AppView {
|
|||
scratch,
|
||||
pending_hint,
|
||||
overlay_focused,
|
||||
banner_height,
|
||||
&self.active_announcements,
|
||||
&self.hidden_announcement_ids,
|
||||
if show_session_tip {
|
||||
crate::app::agent_view::BannerSlotParams {
|
||||
height: banner_height,
|
||||
announcements: &self.active_announcements,
|
||||
hidden_ids: &self.hidden_announcement_ids,
|
||||
privacy_banner,
|
||||
mouse_pos: agent_mouse_pos,
|
||||
tip: if show_session_tip {
|
||||
self.tip.as_deref()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
&self.bundle_state,
|
||||
overlay_active,
|
||||
link_spans,
|
||||
|
|
@ -4460,6 +4537,14 @@ impl AppView {
|
|||
compact,
|
||||
);
|
||||
}
|
||||
if let Some(tutorial) = self.tutorial.as_mut() {
|
||||
crate::views::tutorial::render_tutorial(
|
||||
f.buffer_mut(),
|
||||
view_area,
|
||||
tutorial,
|
||||
compact,
|
||||
);
|
||||
}
|
||||
if let Some(fps) = &fps_overlay {
|
||||
fps.render(full_area, f.buffer_mut());
|
||||
}
|
||||
|
|
@ -4468,10 +4553,17 @@ impl AppView {
|
|||
}
|
||||
let (cursor_pos, post_flush) = result;
|
||||
let has_cloud = false;
|
||||
if has_cloud || self.import_claude_modal.is_some() {
|
||||
if has_cloud
|
||||
|| self.import_claude_modal.is_some()
|
||||
|| self.tutorial.is_some()
|
||||
{
|
||||
link_spans.clear();
|
||||
}
|
||||
let cursor = if has_cloud { None } else { cursor_pos };
|
||||
let cursor = if has_cloud || self.tutorial.is_some() {
|
||||
None
|
||||
} else {
|
||||
cursor_pos
|
||||
};
|
||||
return (cursor, Self::merge_escapes(notif_escapes, post_flush));
|
||||
}
|
||||
}
|
||||
|
|
@ -4543,10 +4635,8 @@ impl AppView {
|
|||
scratch,
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
crate::app::agent_view::BannerSlotParams::none(
|
||||
),
|
||||
bundle_state,
|
||||
false,
|
||||
link_spans,
|
||||
|
|
@ -4568,13 +4658,24 @@ impl AppView {
|
|||
Self::dashboard_stale_image_clears(agents, drawn_popup_agent);
|
||||
let popup_post_flush =
|
||||
Self::merge_post_flush(stale_clears, popup_post_flush);
|
||||
let tutorial_open = self.tutorial.is_some();
|
||||
if let Some(tutorial) = self.tutorial.as_mut() {
|
||||
crate::views::tutorial::render_tutorial(
|
||||
f.buffer_mut(),
|
||||
view_area,
|
||||
tutorial,
|
||||
compact,
|
||||
);
|
||||
}
|
||||
if let Some(fps) = &fps_overlay {
|
||||
fps.render(full_area, f.buffer_mut());
|
||||
}
|
||||
if let Some(panel) = &scroll_debug_panel {
|
||||
panel.render(full_area, f.buffer_mut());
|
||||
}
|
||||
let cursor = if dashboard.attached_agent.is_some() {
|
||||
let cursor = if tutorial_open {
|
||||
None
|
||||
} else if dashboard.attached_agent.is_some() {
|
||||
popup_cursor
|
||||
} else {
|
||||
dash_cursor
|
||||
|
|
@ -4704,6 +4805,7 @@ impl AppView {
|
|||
|| self.import_claude_modal.is_some()
|
||||
|| self.new_worktree_dialog.is_some()
|
||||
|| self.welcome_doc_viewer.is_some()
|
||||
|| self.tutorial.is_some()
|
||||
|| matches!(self.active_view, ActiveView::AgentDashboard
|
||||
if self.dashboard.as_ref().is_some_and(|d| d.shortcuts_modal.is_some()))
|
||||
|| cloud_modal_open
|
||||
|
|
@ -4900,7 +5002,14 @@ impl AppView {
|
|||
}
|
||||
needs_redraw = true;
|
||||
}
|
||||
if self.session_picker_content_loading {
|
||||
if self.session_picker_content_loading
|
||||
|| crate::views::session_picker::loading_spinner_active(
|
||||
self.session_picker_entries.as_deref(),
|
||||
self.session_picker_source_filter,
|
||||
self.session_picker_loading,
|
||||
&self.session_picker_lanes,
|
||||
)
|
||||
{
|
||||
needs_redraw = true;
|
||||
} else {
|
||||
let frame = crate::views::welcome::shimmer_frame();
|
||||
|
|
@ -4967,6 +5076,21 @@ impl AppView {
|
|||
agent.btw_state,
|
||||
Some(crate::views::btw_overlay::BtwOverlayState::Loading { .. })
|
||||
) && spinner_frame_tick;
|
||||
needs_redraw |= matches!(
|
||||
agent.active_modal.as_ref(),
|
||||
Some(crate::views::modal::ActiveModal::SessionPicker {
|
||||
entries,
|
||||
loading,
|
||||
lanes,
|
||||
source_filter,
|
||||
..
|
||||
}) if crate::views::session_picker::loading_spinner_active(
|
||||
entries.as_deref(),
|
||||
*source_filter,
|
||||
*loading,
|
||||
lanes,
|
||||
)
|
||||
) && spinner_frame_tick;
|
||||
needs_redraw |= agent.drain_blocked();
|
||||
agent.prompt.slash_controller.set_workflows_available(
|
||||
agent
|
||||
|
|
@ -5277,6 +5401,21 @@ impl AppView {
|
|||
|| agent.video_load_rx.is_some()
|
||||
|| agent.mermaid_needs_tick()
|
||||
|| !agent.permission_queue.is_empty()
|
||||
|| matches!(
|
||||
agent.active_modal.as_ref(),
|
||||
Some(crate::views::modal::ActiveModal::SessionPicker {
|
||||
entries,
|
||||
loading,
|
||||
lanes,
|
||||
source_filter,
|
||||
..
|
||||
}) if crate::views::session_picker::loading_spinner_active(
|
||||
entries.as_deref(),
|
||||
*source_filter,
|
||||
*loading,
|
||||
lanes,
|
||||
)
|
||||
)
|
||||
|| agent.subagent_views.iter().any(|(sid, child)| {
|
||||
child.toast.is_some()
|
||||
|| child.ephemeral_tip_needs_tick()
|
||||
|
|
@ -5518,6 +5657,7 @@ pub(crate) mod tests {
|
|||
new_session_worktree_mode: WorktreeMode::Never,
|
||||
fork_worktree_mode: WorktreeMode::Ask,
|
||||
restore_code: None,
|
||||
resume_local_miss: None,
|
||||
agent_override: None,
|
||||
bootstrap_acp_commands: Vec::new(),
|
||||
auth_methods: Vec::new(),
|
||||
|
|
@ -5563,6 +5703,9 @@ pub(crate) mod tests {
|
|||
slash_mru: std::rc::Rc::new(std::cell::RefCell::new(
|
||||
crate::slash::mru::SlashMru::new_in_memory(),
|
||||
)),
|
||||
command_tags: std::rc::Rc::new(std::cell::RefCell::new(
|
||||
std::collections::HashMap::new(),
|
||||
)),
|
||||
welcome_prompt_focused: false,
|
||||
welcome_tip_typing_dismissed: false,
|
||||
welcome_menu_index: None,
|
||||
|
|
@ -5644,6 +5787,7 @@ pub(crate) mod tests {
|
|||
session_picker_grouped: false,
|
||||
cancel_rewind_enabled: true,
|
||||
session_recap_available: false,
|
||||
tutorial: None,
|
||||
dashboard: None,
|
||||
dashboard_return: None,
|
||||
dashboard_persisted: None,
|
||||
|
|
@ -6055,6 +6199,72 @@ pub(crate) mod tests {
|
|||
app.session_picker_content_loading = true;
|
||||
assert_eq!(app.tick_demand(), TickDemand::Fast);
|
||||
}
|
||||
/// An open modal session picker that is still fetching keeps fast ticks
|
||||
/// alive on an otherwise-idle agent (its loading spinner must animate) —
|
||||
/// including after the fast foreign scan lands rows the default Grok
|
||||
/// filter hides; once the native list settles the demand parks again.
|
||||
#[test]
|
||||
fn tick_demand_fast_while_modal_session_picker_loads() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = super::super::agent::AgentId(0);
|
||||
assert_eq!(app.tick_demand(), TickDemand::None, "idle agent parks");
|
||||
app.agents.get_mut(&id).unwrap().active_modal =
|
||||
Some(crate::views::modal::ActiveModal::SessionPicker {
|
||||
state: crate::views::picker::PickerState::default(),
|
||||
entries: None,
|
||||
loading: true,
|
||||
lanes: Default::default(),
|
||||
previous_palette: None,
|
||||
window: crate::views::modal_window::ModalWindowState::new(),
|
||||
content_results: None,
|
||||
content_loading: false,
|
||||
deep_search_seq: 0,
|
||||
entries_query: None,
|
||||
source_filter: crate::views::session_picker::SourceFilter::default(),
|
||||
pending_delete: None,
|
||||
});
|
||||
assert_eq!(
|
||||
app.tick_demand(),
|
||||
TickDemand::Fast,
|
||||
"loading modal picker must keep the spinner animating"
|
||||
);
|
||||
let foreign_entry = SessionPickerEntry {
|
||||
id: "claude-1".into(),
|
||||
summary: "claude".into(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
created_at: chrono::Utc::now(),
|
||||
cwd: String::new(),
|
||||
hostname: None,
|
||||
source: "claude".into(),
|
||||
model_id: None,
|
||||
num_messages: 0,
|
||||
last_active_at: None,
|
||||
branch: None,
|
||||
repo_name: "r".into(),
|
||||
worktree_label: None,
|
||||
card_detail: None,
|
||||
};
|
||||
if let Some(crate::views::modal::ActiveModal::SessionPicker { entries, .. }) =
|
||||
app.agents.get_mut(&id).unwrap().active_modal.as_mut()
|
||||
{
|
||||
*entries = Some(vec![foreign_entry]);
|
||||
}
|
||||
assert_eq!(
|
||||
app.tick_demand(),
|
||||
TickDemand::Fast,
|
||||
"foreign rows hidden by the Grok filter must not end the loading spinner"
|
||||
);
|
||||
if let Some(crate::views::modal::ActiveModal::SessionPicker { loading, .. }) =
|
||||
app.agents.get_mut(&id).unwrap().active_modal.as_mut()
|
||||
{
|
||||
*loading = false;
|
||||
}
|
||||
assert_eq!(
|
||||
app.tick_demand(),
|
||||
TickDemand::None,
|
||||
"settled picker must not keep demanding ticks"
|
||||
);
|
||||
}
|
||||
/// An idle agent view demands no ticks at all; the macOS Cmd link-hover
|
||||
/// poll (when it is the only pending work) demands Slow, never Fast.
|
||||
#[test]
|
||||
|
|
@ -9293,10 +9503,7 @@ pub(crate) mod tests {
|
|||
&mut crate::scrollback::render::ScratchBuffer::new(),
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
crate::app::agent_view::BannerSlotParams::none(),
|
||||
&BundleState::default(),
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
@ -9342,10 +9549,7 @@ pub(crate) mod tests {
|
|||
&mut crate::scrollback::render::ScratchBuffer::new(),
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
crate::app::agent_view::BannerSlotParams::none(),
|
||||
&BundleState::default(),
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
@ -9395,10 +9599,7 @@ pub(crate) mod tests {
|
|||
&mut crate::scrollback::render::ScratchBuffer::new(),
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
crate::app::agent_view::BannerSlotParams::none(),
|
||||
&BundleState::default(),
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
@ -9820,6 +10021,46 @@ pub(crate) mod tests {
|
|||
assert!(scroll > 0, "wheel must advance doc scroll, got {scroll}");
|
||||
}
|
||||
#[test]
|
||||
fn tutorial_is_scroll_blocking_and_wheel_scrolls_topic() {
|
||||
let mut app = test_app();
|
||||
app.active_view = ActiveView::Welcome;
|
||||
let mut tut = crate::views::tutorial::TutorialState::new();
|
||||
let _ = crate::views::tutorial::handle_tutorial_input(
|
||||
&Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||
&mut tut,
|
||||
);
|
||||
app.tutorial = Some(tut);
|
||||
assert!(
|
||||
app.is_scroll_blocking_modal_open(),
|
||||
"tutorial overlay must block background scroll",
|
||||
);
|
||||
let outcome = app.handle_input(&scroll_event(MouseEventKind::ScrollDown, 40, 12));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert!(
|
||||
app.last_scroll_pos.is_none(),
|
||||
"wheel must not reach the background scroll path while the tutorial is open",
|
||||
);
|
||||
let tut = app.tutorial.as_ref().expect("tutorial stays open");
|
||||
assert!(
|
||||
tut.scroll > 0,
|
||||
"wheel must advance topic scroll, got {}",
|
||||
tut.scroll
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn tutorial_esc_on_list_closes_overlay() {
|
||||
let mut app = test_app();
|
||||
app.active_view = ActiveView::Welcome;
|
||||
app.tutorial = Some(crate::views::tutorial::TutorialState::new());
|
||||
let outcome =
|
||||
app.handle_input(&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
|
||||
assert!(matches!(outcome, InputOutcome::Changed));
|
||||
assert!(
|
||||
app.tutorial.is_none(),
|
||||
"Esc on the list closes the tutorial"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn dashboard_shortcuts_modal_is_scroll_blocking() {
|
||||
let mut app = test_app();
|
||||
app.active_view = ActiveView::AgentDashboard;
|
||||
|
|
@ -9897,6 +10138,22 @@ pub(crate) mod tests {
|
|||
"Ctrl+Space on the dashboard must route to VoiceToggle, got {outcome:?}"
|
||||
);
|
||||
}
|
||||
/// With `[ui].voice_keybind_enabled = false` the global fallthrough must
|
||||
/// swallow the chord — otherwise Ctrl+Space would still start dictation via
|
||||
/// the registry route whenever the event-loop intercept skips it.
|
||||
#[test]
|
||||
fn ctrl_space_on_dashboard_ignored_when_keybind_disabled() {
|
||||
let mut app = test_app();
|
||||
pin_non_vscode_registry(&mut app);
|
||||
app.active_view = ActiveView::AgentDashboard;
|
||||
app.dashboard = Some(crate::views::dashboard::DashboardState::new());
|
||||
app.current_ui.voice_keybind_enabled = Some(false);
|
||||
let outcome = app.handle_input(&key_event(KeyCode::Char(' '), KeyModifiers::CONTROL));
|
||||
assert!(
|
||||
!matches!(outcome, InputOutcome::Action(Action::VoiceToggle)),
|
||||
"Ctrl+Space must be inert with the voice shortcut disabled, got {outcome:?}"
|
||||
);
|
||||
}
|
||||
/// Esc while voice is recording on the dashboard must STOP voice (route to
|
||||
/// `VoiceToggle`) rather than fall into the dashboard's Esc cascade
|
||||
/// (clear filter / unfocus / deselect / exit).
|
||||
|
|
@ -11361,7 +11618,7 @@ pub(crate) mod tests {
|
|||
let _ = app.handle_input(&f_key);
|
||||
assert_eq!(
|
||||
app.session_picker_source_filter,
|
||||
crate::views::session_picker::SourceFilter::All,
|
||||
crate::views::session_picker::SourceFilter::Grok,
|
||||
"f must not cycle the hidden source filter under chat mode"
|
||||
);
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -538,11 +538,14 @@ pub struct PagerArgs {
|
|||
value_name = "PROMPT"
|
||||
)]
|
||||
pub system_prompt_override: Option<String>,
|
||||
/// Resume a session by ID, or the most recent if omitted.
|
||||
/// Resume a session by ID or title, or the most recent if omitted.
|
||||
/// Non-ID values match session titles for the current directory
|
||||
/// (ignoring letter case; a sole renamed match wins among duplicates,
|
||||
/// otherwise ambiguity errors; UUID-shaped values always mean IDs).
|
||||
#[arg(
|
||||
long = "resume",
|
||||
short = 'r',
|
||||
value_name = "SESSION_ID",
|
||||
value_name = "SESSION_ID_OR_TITLE",
|
||||
num_args = 0..= 1,
|
||||
default_missing_value = "",
|
||||
conflicts_with_all = ["continue_last_session"]
|
||||
|
|
@ -556,6 +559,17 @@ pub struct PagerArgs {
|
|||
conflicts_with_all = ["continue_last_session"]
|
||||
)]
|
||||
pub load_session: Option<String>,
|
||||
/// Set by [`Self::pin_local_resume_target`]: the resume target was
|
||||
/// resolved (or definitively missed) before the OS sandbox, so
|
||||
/// materialization must not re-run local title selection.
|
||||
#[clap(skip)]
|
||||
pub resume_target_pinned: bool,
|
||||
/// Sandbox profile of the title-pinned session, captured at pin time from
|
||||
/// the selected summary (outer `None` = no title pin happened). The
|
||||
/// id-based peek cannot re-derive it: a legacy id duplicated across cwd
|
||||
/// dirs makes that lookup ambiguous.
|
||||
#[clap(skip)]
|
||||
pub(crate) pinned_resume_profile: Option<Option<String>>,
|
||||
/// Continue the most recent session for the current working directory.
|
||||
#[arg(
|
||||
short = 'c',
|
||||
|
|
@ -858,13 +872,69 @@ impl PagerArgs {
|
|||
let explicit = self.sandbox.as_deref().filter(|s| !s.is_empty());
|
||||
Self::resolve_startup_sandbox(explicit, saved.map(String::from))
|
||||
}
|
||||
/// Pin an explicit non-UUID, non-chat resume/load target to its canonical
|
||||
/// local session id, before the (irreversible) OS sandbox is applied.
|
||||
///
|
||||
/// Resolving once — recorded via `resume_target_pinned` so materialization
|
||||
/// never re-runs local title selection — makes the saved-profile peek and
|
||||
/// materialization consume the same immutable target; re-selecting after
|
||||
/// the sandbox would race a concurrent rename/create. Listing failures
|
||||
/// and ambiguity are hard errors here (fail closed / surfaced before the
|
||||
/// sandbox); a definitive no-match keeps the raw arg for the legacy
|
||||
/// remote/worktree id path.
|
||||
pub fn pin_local_resume_target(&mut self) -> anyhow::Result<()> {
|
||||
let cwd_buf = std::env::current_dir().ok();
|
||||
let cwd_str = cwd_buf.as_deref().map(|p| p.to_string_lossy());
|
||||
self.pin_local_resume_target_for_cwd(cwd_str.as_deref())
|
||||
}
|
||||
/// Same as [`Self::pin_local_resume_target`] with an explicit cwd, so
|
||||
/// tests never mutate the process cwd.
|
||||
pub fn pin_local_resume_target_for_cwd(&mut self, cwd: Option<&str>) -> anyhow::Result<()> {
|
||||
if self.chat() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(target) = self.session_to_resume().map(str::to_owned) else {
|
||||
return Ok(());
|
||||
};
|
||||
use crate::app::session_title_resolve::{PinnedResumeTarget, presandbox_resume_target};
|
||||
let pinned = presandbox_resume_target(&target, cwd)?;
|
||||
self.resume_target_pinned = true;
|
||||
if let PinnedResumeTarget::Title {
|
||||
ref id,
|
||||
ref sandbox_profile,
|
||||
} = pinned
|
||||
{
|
||||
eprintln!("Resuming session {} (matched by title)", id);
|
||||
self.pinned_resume_profile = Some(sandbox_profile.clone());
|
||||
}
|
||||
let Some(id) = pinned.id() else {
|
||||
return Ok(());
|
||||
};
|
||||
if self
|
||||
.resume_session
|
||||
.as_deref()
|
||||
.is_some_and(|s| !s.is_empty())
|
||||
{
|
||||
self.resume_session = Some(id);
|
||||
} else if self.load_session.as_deref().is_some_and(|s| !s.is_empty()) {
|
||||
self.load_session = Some(id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The sandbox profile persisted with the session being resumed, if any.
|
||||
/// Local, best-effort; `None` when not resuming or nothing is found. Read once
|
||||
/// for the profile resume resolution.
|
||||
pub fn saved_resume_profile(&self) -> Option<String> {
|
||||
let cwd_buf = std::env::current_dir().ok();
|
||||
let cwd_str = cwd_buf.as_deref().map(|p| p.to_string_lossy());
|
||||
let cwd = cwd_str.as_deref();
|
||||
self.saved_resume_profile_for_cwd(cwd_str.as_deref())
|
||||
}
|
||||
/// Same as [`Self::saved_resume_profile`] with an explicit cwd, so tests
|
||||
/// never mutate the process cwd.
|
||||
pub fn saved_resume_profile_for_cwd(&self, cwd: Option<&str>) -> Option<String> {
|
||||
if let Some(pinned) = &self.pinned_resume_profile {
|
||||
return pinned.clone();
|
||||
}
|
||||
match self.resume_target() {
|
||||
ResumeTarget::SessionId(id) => {
|
||||
xai_grok_shell::session::persistence::resumed_session_sandbox_profile(
|
||||
|
|
@ -952,18 +1022,24 @@ mod tests {
|
|||
command: None,
|
||||
}))
|
||||
));
|
||||
let fix =
|
||||
PagerArgs::try_parse_from(["grok", "doctor", "fix", "terminal.ssh-wrap", "--yes"])
|
||||
for id in [
|
||||
"terminal.ssh-wrap",
|
||||
"tmux-clipboard",
|
||||
"terminal.dcs-passthrough",
|
||||
"tmux-extended-keys",
|
||||
] {
|
||||
let fix = PagerArgs::try_parse_from(["grok", "doctor", "fix", id, "--yes"])
|
||||
.expect("doctor fix parses");
|
||||
assert!(matches!(
|
||||
fix.command,
|
||||
Some(Command::Doctor(crate::doctor_cmd::DoctorArgs {
|
||||
json: false,
|
||||
command: Some(crate::doctor_cmd::DoctorCommand::Fix(
|
||||
crate::doctor_cmd::FixArgs { ref id, yes: true }
|
||||
crate::doctor_cmd::FixArgs { id: Some(ref parsed), yes: true }
|
||||
)),
|
||||
})) if id.as_deref() == Some("terminal.ssh-wrap")
|
||||
})) if parsed == id
|
||||
));
|
||||
}
|
||||
let list = PagerArgs::try_parse_from(["grok", "doctor", "fix"])
|
||||
.expect("doctor fix without an ID lists applicable fixes");
|
||||
assert!(matches!(
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ pub(super) fn ensure_dashboard_state(app: &mut AppView) {
|
|||
let mut state = dashboard_state_from_persisted(app);
|
||||
state.gc_stale_refs(&dashboard_alive_fn(&app.agents));
|
||||
state.adopt_slash_mru(app.slash_mru.clone());
|
||||
state.adopt_command_tags(app.command_tags.clone());
|
||||
state.set_screen_mode(app.screen_mode);
|
||||
state.set_recap_visible(app.session_recap_available);
|
||||
state.set_voice_visible(app.voice_mode_enabled);
|
||||
|
|
|
|||
|
|
@ -55,12 +55,14 @@ pub(crate) fn dispatch_initial_prompt(app: &mut AppView, prompt: String) -> Vec<
|
|||
effects
|
||||
}
|
||||
|
||||
pub(super) fn collect_live_doctor_report(
|
||||
pub(super) fn collect_live_doctor_report_for_terminal(
|
||||
app: &AppView,
|
||||
agent_id: AgentId,
|
||||
terminal: &crate::terminal::TerminalContext,
|
||||
) -> Option<crate::diagnostics::DiagnosticReport> {
|
||||
let agent = app.agents.get(&agent_id)?;
|
||||
let mut report = crate::slash::commands::doctor::DoctorCommand::report(
|
||||
let mut report = crate::slash::commands::doctor::DoctorCommand::report_for_terminal(
|
||||
terminal,
|
||||
app.screen_mode,
|
||||
crate::diagnostics::TuiRuntimeRequest {
|
||||
workspace: &agent.session.cwd,
|
||||
|
|
@ -88,7 +90,8 @@ pub(super) fn dispatch_doctor(request: DoctorRequest, app: &mut AppView) -> Vec<
|
|||
let ActiveView::Agent(agent_id) = app.active_view else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(report) = collect_live_doctor_report(app, agent_id) else {
|
||||
let terminal = crate::terminal::terminal_context().clone();
|
||||
let Some(report) = collect_live_doctor_report_for_terminal(app, agent_id, &terminal) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
|
|
@ -108,7 +111,7 @@ pub(super) fn dispatch_doctor(request: DoctorRequest, app: &mut AppView) -> Vec<
|
|||
return vec![Effect::PlanDoctorFix {
|
||||
target,
|
||||
report: Box::new(report),
|
||||
terminal: crate::terminal::terminal_context().clone(),
|
||||
terminal,
|
||||
request,
|
||||
}];
|
||||
}
|
||||
|
|
@ -136,9 +139,6 @@ pub(super) fn open_doctor_fix_question(
|
|||
return;
|
||||
}
|
||||
let preview = crate::diagnostics::format_fix_preview(&plan);
|
||||
agent
|
||||
.scrollback
|
||||
.push_block(RenderBlock::system(preview.clone()));
|
||||
let question = Question {
|
||||
question: "Apply this fix?".to_owned(),
|
||||
options: vec![
|
||||
|
|
@ -150,7 +150,7 @@ pub(super) fn open_doctor_fix_question(
|
|||
},
|
||||
QuestionOption {
|
||||
label: "Cancel".to_owned(),
|
||||
description: "Do not change your shell configuration.".to_owned(),
|
||||
description: "Do not change the configuration.".to_owned(),
|
||||
preview: None,
|
||||
id: None,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -85,7 +85,8 @@ use super::settings::setters::{
|
|||
set_prompt_suggestions, set_remember_tool_approvals, set_render_mermaid,
|
||||
set_respect_manual_folds, set_screen_mode, set_scroll_lines, set_scroll_mode, set_scroll_speed,
|
||||
set_show_thinking_blocks, set_show_tips, set_simple_mode, set_theme, set_timeline,
|
||||
set_timestamps, set_vim_mode, set_voice_capture_mode, set_voice_stt_language,
|
||||
set_timestamps, set_vim_mode, set_voice_capture_mode, set_voice_keybind_enabled,
|
||||
set_voice_stt_language,
|
||||
};
|
||||
use super::settings::ui::{
|
||||
dispatch_confirm_reset_setting, dispatch_open_command_palette, dispatch_open_howto_guides,
|
||||
|
|
@ -94,7 +95,7 @@ use super::settings::ui::{
|
|||
dispatch_toggle_vim_mode,
|
||||
};
|
||||
use super::status::{
|
||||
dispatch_copy_session_id, dispatch_manage_billing, dispatch_open_gboom,
|
||||
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,
|
||||
|
|
@ -932,6 +933,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
|||
Action::ShowReleaseNotes { title, content } => {
|
||||
dispatch_show_release_notes(app, title, content)
|
||||
}
|
||||
Action::OpenTutorial => dispatch_open_tutorial(app),
|
||||
Action::RenameSession { title } => dispatch_rename_session(app, title),
|
||||
Action::ShowContextInfo => dispatch_show_context_info(app),
|
||||
Action::ShowUsage => dispatch_show_usage(app),
|
||||
|
|
@ -972,6 +974,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
|||
Action::SetDefaultSelectedPermission(s) => set_default_selected_permission(app, s),
|
||||
Action::SetHunkTrackerMode(s) => set_hunk_tracker_mode(app, s),
|
||||
Action::SetScreenMode(s) => set_screen_mode(app, s),
|
||||
Action::SetVoiceKeybindEnabled(v) => set_voice_keybind_enabled(app, v),
|
||||
Action::SetVoiceCaptureMode(s) => set_voice_capture_mode(app, s),
|
||||
Action::SetVoiceSttLanguage(s) => set_voice_stt_language(app, s),
|
||||
Action::ToggleTimestamps => dispatch_toggle_timestamps(app),
|
||||
|
|
@ -1172,7 +1175,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
|
|||
.scrollback
|
||||
.push_block(crate::scrollback::block::RenderBlock::system(format!(
|
||||
"Applying {}…",
|
||||
plan.id
|
||||
plan.id()
|
||||
)));
|
||||
}
|
||||
vec![Effect::ApplyDoctorFix { target, plan }]
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ pub(in crate::app::dispatch) fn dispatch_fork_resolved(
|
|||
.expect("just-inserted agent missing");
|
||||
agent.prompt.set_compact(app.appearance.prompt.compact);
|
||||
agent.prompt.adopt_slash_mru(app.slash_mru.clone());
|
||||
agent.prompt.adopt_command_tags(app.command_tags.clone());
|
||||
agent
|
||||
.prompt
|
||||
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
|
||||
|
|
@ -241,6 +242,12 @@ pub(in crate::app::dispatch) fn dispatch_fork_resolved(
|
|||
.push_block(RenderBlock::system(parent_marker));
|
||||
}
|
||||
switch_to_agent(app, new_id, SwitchCause::Fork);
|
||||
if let Some(d) = app.dashboard.as_mut()
|
||||
&& d.attached_agent == Some(parent_id)
|
||||
{
|
||||
d.attached_agent = Some(new_id);
|
||||
d.focus_row(crate::views::dashboard::DashboardRowId::TopLevel(new_id));
|
||||
}
|
||||
if worktree {
|
||||
vec![Effect::CreateWorktreeSession {
|
||||
agent_id: new_id,
|
||||
|
|
|
|||
|
|
@ -331,6 +331,7 @@ pub(in crate::app::dispatch) fn dispatch_new_session_inner_with_id(
|
|||
let agent = app.agents.get_mut(&agent_id).unwrap();
|
||||
agent.prompt.set_compact(app.appearance.prompt.compact);
|
||||
agent.prompt.adopt_slash_mru(app.slash_mru.clone());
|
||||
agent.prompt.adopt_command_tags(app.command_tags.clone());
|
||||
agent
|
||||
.prompt
|
||||
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
|
||||
|
|
@ -671,6 +672,7 @@ pub(in crate::app::dispatch) fn dispatch_new_worktree_session(
|
|||
let agent = app.agents.get_mut(&agent_id).unwrap();
|
||||
agent.prompt.set_compact(app.appearance.prompt.compact);
|
||||
agent.prompt.adopt_slash_mru(app.slash_mru.clone());
|
||||
agent.prompt.adopt_command_tags(app.command_tags.clone());
|
||||
agent
|
||||
.prompt
|
||||
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
|
||||
|
|
|
|||
|
|
@ -187,6 +187,9 @@ fn dispatch_load_session_ungated(
|
|||
agent_mut.loading_placeholder_id = Some(loading_placeholder_id);
|
||||
agent_mut.prompt.set_compact(app.appearance.prompt.compact);
|
||||
agent_mut.prompt.adopt_slash_mru(app.slash_mru.clone());
|
||||
agent_mut
|
||||
.prompt
|
||||
.adopt_command_tags(app.command_tags.clone());
|
||||
agent_mut
|
||||
.prompt
|
||||
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
|
||||
|
|
@ -837,6 +840,7 @@ pub(in crate::app::dispatch) fn dispatch_load_session_with_restore(
|
|||
agent.begin_replay_window();
|
||||
agent.prompt.set_compact(app.appearance.prompt.compact);
|
||||
agent.prompt.adopt_slash_mru(app.slash_mru.clone());
|
||||
agent.prompt.adopt_command_tags(app.command_tags.clone());
|
||||
agent
|
||||
.prompt
|
||||
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
|
||||
|
|
@ -1229,13 +1233,21 @@ pub(in crate::app::dispatch) fn dispatch_session_picker_closed(app: &mut AppView
|
|||
/// Fetch invalidation shared by EVERY picker-dismissal path:
|
||||
/// modal Esc/mouse close, modal and welcome picks (all variants), and the
|
||||
/// welcome-screen Esc. Only chat mode can have a query-stamped search in
|
||||
/// flight; Build mode must NOT bump — only the plain list fetch exists there
|
||||
/// and its responses keep their pre-existing last-write-wins behavior.
|
||||
/// flight; a Build-mode MODAL close must NOT bump — only the plain list
|
||||
/// fetch exists there and its response lands on the hidden welcome fields
|
||||
/// (pre-existing last-write-wins behavior). A WELCOME dismissal must bump
|
||||
/// and drop the loading flag: the welcome view survives the close, so a
|
||||
/// still-loading flag holds `show_picker` in a spinner limbo that ignores
|
||||
/// input until the late response lands and resurrects the picker.
|
||||
fn invalidate_picker_fetch_on_dismiss(app: &mut AppView) {
|
||||
invalidate_foreign_picker(app);
|
||||
if app.chat_mode {
|
||||
let welcome_dismissal = matches!(app.active_view, crate::app::app_view::ActiveView::Welcome);
|
||||
if app.chat_mode || welcome_dismissal {
|
||||
app.session_picker_list_seq += 1;
|
||||
}
|
||||
if welcome_dismissal {
|
||||
app.session_picker_loading = false;
|
||||
}
|
||||
app.session_picker_deep_search_seq += 1;
|
||||
app.session_picker_content_loading = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,6 +193,39 @@ pub(in crate::app::dispatch) fn set_voice_capture_mode(
|
|||
}]
|
||||
}
|
||||
|
||||
/// Mirror the voice-shortcut gate into `app.current_ui` (read live by the
|
||||
/// event-loop chord intercept) and the process-global mirror (read by key
|
||||
/// routing / view code without an `AppView`). Called by the commit path AND by
|
||||
/// [`apply_setting_rollback`](super::ui::apply_setting_rollback).
|
||||
pub(super) fn set_voice_keybind_enabled_inner(app: &mut AppView, new: bool) {
|
||||
app.current_ui.voice_keybind_enabled = Some(new);
|
||||
crate::app::VOICE_KEYBIND_ENABLED.store(new, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
|
||||
/// Enable/disable the Ctrl+Space / F8 voice shortcut. SHELL-owned; persists to
|
||||
/// `[ui].voice_keybind_enabled` via `Effect::PersistSetting`. Applies on the
|
||||
/// next keypress (no restart). Only the chord is gated — `/voice`, Esc while
|
||||
/// listening, and the recording-row `[stop]` keep working.
|
||||
pub(in crate::app::dispatch) fn set_voice_keybind_enabled(
|
||||
app: &mut AppView,
|
||||
new: bool,
|
||||
) -> Vec<Effect> {
|
||||
let prev_state = app.current_ui.voice_keybind_enabled;
|
||||
let prev_effective = prev_state.unwrap_or(true);
|
||||
if prev_effective == new && prev_state.is_some() {
|
||||
return vec![];
|
||||
}
|
||||
set_voice_keybind_enabled_inner(app, new);
|
||||
refresh_open_settings_modals(app);
|
||||
tracing::info!(target: "settings", key = "voice_keybind_enabled", value = new, "setting changed");
|
||||
app.show_toast(&save_success_toast("Voice shortcut", new));
|
||||
vec![Effect::PersistSetting {
|
||||
key: "voice_keybind_enabled",
|
||||
value: crate::settings::SettingValue::Bool(new),
|
||||
rollback_value: crate::settings::SettingValue::Bool(prev_effective),
|
||||
}]
|
||||
}
|
||||
|
||||
/// Mirror the STT language preference into `app.current_ui` and
|
||||
/// `app.voice_config.language` (may be the client-only `"auto"` sentinel; the
|
||||
/// voice crate resolves it at connect time). Called by the commit path AND by
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use super::setters::{
|
|||
set_scroll_lines_inner, set_scroll_mode_inner, set_scroll_speed_inner,
|
||||
set_show_thinking_blocks_inner, set_show_tips_inner, set_simple_mode_inner, set_theme_inner,
|
||||
set_timeline_inner, set_timestamps, set_timestamps_inner, set_vim_mode_inner,
|
||||
set_voice_capture_mode_inner, set_voice_stt_language_inner,
|
||||
set_voice_capture_mode_inner, set_voice_keybind_enabled_inner, set_voice_stt_language_inner,
|
||||
};
|
||||
use crate::app::actions::{Action, Effect};
|
||||
use crate::app::app_view::{ActiveView, AppView};
|
||||
|
|
@ -872,6 +872,9 @@ pub(in crate::app::dispatch) fn action_for_reset(
|
|||
Some(Action::SetHunkTrackerMode((*s).to_string()))
|
||||
}
|
||||
("screen_mode", SettingValue::Enum(s)) => Some(Action::SetScreenMode((*s).to_string())),
|
||||
("voice_keybind_enabled", SettingValue::Bool(b)) => {
|
||||
Some(Action::SetVoiceKeybindEnabled(*b))
|
||||
}
|
||||
("voice_capture_mode", SettingValue::Enum(s)) => {
|
||||
Some(Action::SetVoiceCaptureMode((*s).to_string()))
|
||||
}
|
||||
|
|
@ -1132,6 +1135,9 @@ pub(in crate::app::dispatch) fn apply_setting_rollback(
|
|||
("screen_mode", SettingValue::Enum(s)) => {
|
||||
set_screen_mode_inner(app, crate::settings::canonical_screen_mode(Some(s)));
|
||||
}
|
||||
("voice_keybind_enabled", SettingValue::Bool(b)) => {
|
||||
set_voice_keybind_enabled_inner(app, *b)
|
||||
}
|
||||
("voice_capture_mode", SettingValue::Enum(s)) => {
|
||||
set_voice_capture_mode_inner(
|
||||
app,
|
||||
|
|
|
|||
|
|
@ -575,6 +575,23 @@ pub(super) fn dispatch_copy_session_id(app: &mut AppView, index: usize) -> Vec<E
|
|||
vec![]
|
||||
}
|
||||
|
||||
/// Open the onboarding tutorial overlay (top-level modal — works over both
|
||||
/// the welcome screen and an agent session). Toggles: dispatching while
|
||||
/// open closes instead of stacking.
|
||||
pub(super) fn dispatch_open_tutorial(app: &mut AppView) -> Vec<Effect> {
|
||||
// Minimal mode has no modal host: the overlay would render nothing
|
||||
// while the app-level intercept swallowed all input.
|
||||
if app.screen_mode.is_minimal() {
|
||||
return vec![];
|
||||
}
|
||||
if app.tutorial.is_some() {
|
||||
app.tutorial = None;
|
||||
return vec![];
|
||||
}
|
||||
app.tutorial = Some(crate::views::tutorial::TutorialState::new());
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_show_release_notes(
|
||||
app: &mut AppView,
|
||||
title: String,
|
||||
|
|
|
|||
|
|
@ -216,13 +216,6 @@ pub(crate) fn current_doctor_target(
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(crate) fn doctor_target_is_current(app: &AppView, target: &DoctorFixTarget) -> bool {
|
||||
app.agents.get(&target.agent_id).is_some_and(|agent| {
|
||||
agent.session.session_id == target.session_id
|
||||
&& agent.session_binding_epoch == target.session_binding_epoch
|
||||
&& agent.session.cwd == target.cwd
|
||||
})
|
||||
}
|
||||
pub(crate) fn deliver_doctor_message(app: &mut AppView, preferred: AgentId, message: String) {
|
||||
let destination = app
|
||||
.agents
|
||||
|
|
@ -632,81 +625,9 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
|
|||
}
|
||||
vec![]
|
||||
}
|
||||
TaskResult::DoctorFixApplied {
|
||||
target,
|
||||
shell,
|
||||
result,
|
||||
} => {
|
||||
TaskResult::DoctorFixApplied { target, result } => {
|
||||
let message = match result {
|
||||
Ok(outcome) => {
|
||||
let report_agent = doctor_target_is_current(app, &target)
|
||||
.then_some(target.agent_id)
|
||||
.or_else(|| match app.active_view {
|
||||
ActiveView::Agent(id) if app.agents.contains_key(&id) => Some(id),
|
||||
_ => app.agents.keys().next().copied(),
|
||||
});
|
||||
let Some(report_agent) = report_agent else {
|
||||
let message = match outcome.status {
|
||||
crate::diagnostics::FixStatus::Applied => {
|
||||
format!(
|
||||
"Set up SSH wrapping in {}.",
|
||||
outcome.changed_path.display()
|
||||
)
|
||||
}
|
||||
crate::diagnostics::FixStatus::AlreadyConfigured => {
|
||||
format!(
|
||||
"SSH wrapping is already set up in {}.",
|
||||
outcome.changed_path.display()
|
||||
)
|
||||
}
|
||||
};
|
||||
deliver_doctor_message(app, target.agent_id, message);
|
||||
return vec![];
|
||||
};
|
||||
let Some(mut report) =
|
||||
super::prompt::collect_live_doctor_report(app, report_agent)
|
||||
else {
|
||||
unreachable!("report destination came from app.agents")
|
||||
};
|
||||
report = crate::diagnostics::configured_report(
|
||||
report,
|
||||
crate::diagnostics::managed_alias_configured(&outcome.changed_path, shell),
|
||||
);
|
||||
if report
|
||||
.findings
|
||||
.iter()
|
||||
.any(|finding| finding.id == outcome.id)
|
||||
{
|
||||
format!(
|
||||
"The change was applied, but Doctor still reports `{}`.",
|
||||
outcome.id
|
||||
)
|
||||
} else {
|
||||
let status = match outcome.status {
|
||||
crate::diagnostics::FixStatus::Applied => {
|
||||
format!(
|
||||
"Set up SSH wrapping in {}.",
|
||||
outcome.changed_path.display()
|
||||
)
|
||||
}
|
||||
crate::diagnostics::FixStatus::AlreadyConfigured => {
|
||||
format!(
|
||||
"SSH wrapping is already set up in {}.",
|
||||
outcome.changed_path.display()
|
||||
)
|
||||
}
|
||||
};
|
||||
let backup = outcome
|
||||
.backup_path
|
||||
.as_ref()
|
||||
.map(|path| format!("\nBackup: {}", path.display()))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"{status}{backup}\nStart a new shell to use the alias.\n\n{}",
|
||||
crate::diagnostics::format_doctor(&report)
|
||||
)
|
||||
}
|
||||
}
|
||||
Ok(outcome) => crate::diagnostics::format_fix_success(&outcome),
|
||||
Err(error) if error.starts_with("Could not apply the fix:") => error,
|
||||
Err(error) => format!("Could not apply the fix: {error}"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2439,10 +2439,7 @@ fn dashboard_attach_subagent_switches_to_parent_with_subagent_focused() {
|
|||
&mut scratch,
|
||||
None,
|
||||
false,
|
||||
0,
|
||||
&[],
|
||||
&std::collections::BTreeSet::new(),
|
||||
None,
|
||||
crate::app::agent_view::BannerSlotParams::none(),
|
||||
&crate::app::bundle::BundleState::default(),
|
||||
false,
|
||||
&mut Vec::new(),
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ fn test_app() -> AppView {
|
|||
new_session_worktree_mode: crate::app::app_view::WorktreeMode::Never,
|
||||
fork_worktree_mode: crate::app::app_view::WorktreeMode::Ask,
|
||||
restore_code: None,
|
||||
resume_local_miss: None,
|
||||
agent_override: None,
|
||||
bootstrap_acp_commands: Vec::new(),
|
||||
auth_methods: vec![acp::AuthMethod::Agent(acp::AuthMethodAgent::new(
|
||||
|
|
@ -180,6 +181,7 @@ fn test_app() -> AppView {
|
|||
slash_mru: std::rc::Rc::new(std::cell::RefCell::new(
|
||||
crate::slash::mru::SlashMru::new_in_memory(),
|
||||
)),
|
||||
command_tags: std::rc::Rc::new(std::cell::RefCell::new(std::collections::HashMap::new())),
|
||||
welcome_prompt_focused: false,
|
||||
welcome_tip_typing_dismissed: false,
|
||||
welcome_menu_index: None,
|
||||
|
|
@ -260,6 +262,7 @@ fn test_app() -> AppView {
|
|||
session_picker_grouped: false,
|
||||
cancel_rewind_enabled: true,
|
||||
session_recap_available: false,
|
||||
tutorial: None,
|
||||
dashboard: None,
|
||||
dashboard_return: None,
|
||||
dashboard_persisted: None,
|
||||
|
|
|
|||
|
|
@ -361,6 +361,8 @@ fn welcome_selection_survives_foreign_insertion_with_viewport_offset() {
|
|||
app.session_picker_grouped = false;
|
||||
app.foreign_session_scan_seq = 8;
|
||||
app.session_picker_lanes.foreign_loading = true;
|
||||
// Pin All: the insertion only shifts rows when foreign entries are visible.
|
||||
app.session_picker_source_filter = SourceFilter::All;
|
||||
app.session_picker_entries = Some(vec![
|
||||
at(make_picker_entry("a", "/repo"), 20),
|
||||
at(make_picker_entry("b", "/repo"), 10),
|
||||
|
|
@ -397,7 +399,12 @@ fn modal_selection_survives_native_and_foreign_completion_races() {
|
|||
at(make_picker_entry("b", "/repo"), 10),
|
||||
],
|
||||
);
|
||||
if let Some(ActiveModal::SessionPicker { state, lanes, .. }) = get_active_agent_mut(&mut app)
|
||||
if let Some(ActiveModal::SessionPicker {
|
||||
state,
|
||||
lanes,
|
||||
source_filter,
|
||||
..
|
||||
}) = get_active_agent_mut(&mut app)
|
||||
.unwrap()
|
||||
.active_modal
|
||||
.as_mut()
|
||||
|
|
@ -405,6 +412,8 @@ fn modal_selection_survives_native_and_foreign_completion_races() {
|
|||
state.selected = 2;
|
||||
state.scroll_offset = Some(1);
|
||||
lanes.foreign_loading = true;
|
||||
// Pin All: the insertion only shifts rows when foreign entries are visible.
|
||||
*source_filter = SourceFilter::All;
|
||||
}
|
||||
|
||||
let _ = dispatch(
|
||||
|
|
@ -479,7 +488,8 @@ fn external_filter_clears_and_suppresses_native_content_state() {
|
|||
app.session_picker_content_results = Some(vec![content_hit("native-hit")]);
|
||||
app.session_picker_content_loading = true;
|
||||
app.session_picker_state.expanded.insert(0);
|
||||
app.session_picker_source_filter = SourceFilter::Remote;
|
||||
// Grok cycles straight into External.
|
||||
app.session_picker_source_filter = SourceFilter::Grok;
|
||||
let old_detail_generation = app.session_picker_detail_generation;
|
||||
|
||||
let effects = dispatch(Action::CycleSessionSourceFilter, &mut app);
|
||||
|
|
@ -573,7 +583,8 @@ fn modal_external_filter_clears_native_content_and_blocks_forced_search() {
|
|||
.active_modal
|
||||
.as_mut()
|
||||
{
|
||||
*source_filter = SourceFilter::Remote;
|
||||
// Grok cycles straight into External.
|
||||
*source_filter = SourceFilter::Grok;
|
||||
*content_results = Some(vec![content_hit("native-hit")]);
|
||||
*content_loading = true;
|
||||
state.set_query("native");
|
||||
|
|
@ -599,6 +610,27 @@ fn modal_external_filter_clears_native_content_and_blocks_forced_search() {
|
|||
assert!(dispatch(Action::ForceDeepSearch, &mut app).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_reaches_every_filter_with_foreign_present() {
|
||||
// One press from the default reveals externals, and Local/Remote stay
|
||||
// reachable on the same plain cycle even with foreign rows loaded.
|
||||
let mut app = test_app();
|
||||
app.session_picker_entries = Some(vec![
|
||||
make_picker_entry("native", "/repo"),
|
||||
make_foreign_entry("foreign", "claude", "/repo"),
|
||||
]);
|
||||
for expected in [
|
||||
SourceFilter::External,
|
||||
SourceFilter::All,
|
||||
SourceFilter::Local,
|
||||
SourceFilter::Remote,
|
||||
SourceFilter::Grok,
|
||||
] {
|
||||
let _ = dispatch(Action::CycleSessionSourceFilter, &mut app);
|
||||
assert_eq!(app.session_picker_source_filter, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_modal_owns_stale_and_external_deep_search_results() {
|
||||
for external in [false, true] {
|
||||
|
|
|
|||
|
|
@ -551,6 +551,64 @@ fn dispatch_fork_sets_forked_from_on_new_agent() {
|
|||
assert_eq!(new_agent.session.forked_from, Some(AgentId(0)));
|
||||
}
|
||||
|
||||
/// GBT-4789 — dashboard attach follows the forked child.
|
||||
#[test]
|
||||
fn dispatch_fork_repoints_dashboard_attached_agent_to_child() {
|
||||
let mut app = fork_test_app();
|
||||
ensure_dashboard_state(&mut app);
|
||||
app.dashboard.as_mut().unwrap().attached_agent = Some(AgentId(0));
|
||||
|
||||
dispatch(Action::Fork(fork_args(Some(false), None)), &mut app);
|
||||
|
||||
assert!(
|
||||
matches!(app.active_view, ActiveView::Agent(id) if id == AgentId(1)),
|
||||
"fork must switch active view to the child"
|
||||
);
|
||||
assert_eq!(
|
||||
app.dashboard.as_ref().unwrap().attached_agent,
|
||||
Some(AgentId(1)),
|
||||
"attached_agent must re-point to the forked child so overlay \
|
||||
back-out (Left/Esc/Ctrl+\\) keeps working",
|
||||
);
|
||||
}
|
||||
|
||||
/// Fork must not invent dashboard attach when none was set.
|
||||
#[test]
|
||||
fn dispatch_fork_without_dashboard_attach_leaves_attached_none() {
|
||||
let mut app = fork_test_app();
|
||||
ensure_dashboard_state(&mut app);
|
||||
assert!(app.dashboard.as_ref().unwrap().attached_agent.is_none());
|
||||
|
||||
dispatch(Action::Fork(fork_args(Some(false), None)), &mut app);
|
||||
|
||||
assert_eq!(
|
||||
app.dashboard.as_ref().unwrap().attached_agent,
|
||||
None,
|
||||
"fork must not enable overlay chrome when the parent was not attached",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_fork_keeps_stale_attach_on_other_agent() {
|
||||
let mut app = fork_test_app();
|
||||
insert_placeholder_agent(&mut app, AgentId(1));
|
||||
app.next_agent_id = 2;
|
||||
ensure_dashboard_state(&mut app);
|
||||
app.dashboard.as_mut().unwrap().attached_agent = Some(AgentId(1));
|
||||
|
||||
dispatch(Action::Fork(fork_args(Some(false), None)), &mut app);
|
||||
|
||||
assert!(
|
||||
matches!(app.active_view, ActiveView::Agent(id) if id == AgentId(2)),
|
||||
"fork must switch active view to the child"
|
||||
);
|
||||
assert_eq!(
|
||||
app.dashboard.as_ref().unwrap().attached_agent,
|
||||
Some(AgentId(1)),
|
||||
"attach on a different agent must not be re-pointed to the fork child",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_fork_pushes_parent_marker_with_directive() {
|
||||
let mut app = fork_test_app();
|
||||
|
|
|
|||
|
|
@ -2189,6 +2189,77 @@ fn welcome_esc_drops_in_flight_fetch_response() {
|
|||
"in-flight fetch must not repopulate the closed welcome picker"
|
||||
);
|
||||
}
|
||||
/// Build-mode sibling of the chat Esc test, pinning Esc-during-load: with the
|
||||
/// fast foreign lane landed (hidden by the Grok default → CTA) and the native
|
||||
/// fetch still in flight, Esc must really dismiss the picker — drop the
|
||||
/// loading flag (a lingering flag holds `show_picker` in a spinner limbo that
|
||||
/// ignores input) and stale the fetch so its late response cannot resurrect
|
||||
/// the picker.
|
||||
#[test]
|
||||
fn build_welcome_esc_during_load_dismisses_without_resurrection() {
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
let mut app = test_app();
|
||||
assert!(!app.chat_mode);
|
||||
let _ = dispatch(Action::FetchSessionList, &mut app);
|
||||
let seq = app.session_picker_list_seq;
|
||||
assert!(app.session_picker_loading);
|
||||
let mut foreign = make_picker_entry("claude-1", "/repo");
|
||||
foreign.source = "claude".into();
|
||||
app.session_picker_entries = Some(vec![foreign]);
|
||||
let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
let out = app.handle_input(&esc);
|
||||
assert!(
|
||||
matches!(
|
||||
out,
|
||||
crate::app::app_view::InputOutcome::Action(Action::SessionPickerClosed)
|
||||
),
|
||||
"welcome Esc must surface SessionPickerClosed, got {out:?}"
|
||||
);
|
||||
assert!(
|
||||
app.session_picker_entries.is_none(),
|
||||
"Esc clears the welcome picker"
|
||||
);
|
||||
let _ = dispatch(Action::SessionPickerClosed, &mut app);
|
||||
assert!(
|
||||
!app.session_picker_loading,
|
||||
"dismissal must end the loading limbo (`show_picker` keys off it)"
|
||||
);
|
||||
let _ = dispatch(
|
||||
Action::TaskComplete(TaskResult::SessionListLoaded {
|
||||
scope: ListScope::Cwd,
|
||||
sessions: vec![make_picker_entry("native-late", "/repo")],
|
||||
partial: None,
|
||||
seq,
|
||||
query: None,
|
||||
}),
|
||||
&mut app,
|
||||
);
|
||||
assert!(
|
||||
app.session_picker_entries.is_none(),
|
||||
"late native response must not resurrect the closed picker"
|
||||
);
|
||||
}
|
||||
/// The spinner-only loading picker (nothing landed yet) still owns Esc: it
|
||||
/// must dismiss the picker instead of dead-keying into the menu it covers.
|
||||
#[test]
|
||||
fn build_welcome_esc_dismisses_spinner_only_loading_picker() {
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
let mut app = test_app();
|
||||
let _ = dispatch(Action::FetchSessionList, &mut app);
|
||||
assert!(app.session_picker_loading);
|
||||
assert!(app.session_picker_entries.is_none());
|
||||
let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
let out = app.handle_input(&esc);
|
||||
assert!(
|
||||
matches!(
|
||||
out,
|
||||
crate::app::app_view::InputOutcome::Action(Action::SessionPickerClosed)
|
||||
),
|
||||
"Esc on the loading picker must close it, got {out:?}"
|
||||
);
|
||||
let _ = dispatch(Action::SessionPickerClosed, &mut app);
|
||||
assert!(!app.session_picker_loading, "picker fully dismissed");
|
||||
}
|
||||
/// Build-mode canary: modal close must not bump the list seq — an in-flight
|
||||
/// plain fetch keeps its pre-existing land-after-close behavior.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1404,6 +1404,9 @@ fn move_setting_away_from_default(app: &mut AppView, key: crate::settings::Setti
|
|||
"screen_mode" => {
|
||||
let _ = dispatch(Action::SetScreenMode("minimal".to_string()), app);
|
||||
}
|
||||
"voice_keybind_enabled" => {
|
||||
let _ = dispatch(Action::SetVoiceKeybindEnabled(false), app);
|
||||
}
|
||||
"voice_capture_mode" => {
|
||||
let _ = dispatch(Action::SetVoiceCaptureMode("toggle".to_string()), app);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1084,3 +1084,19 @@ fn minimal_update_notice_no_active_agent_is_noop() {
|
|||
// Must not panic and must not require an agent.
|
||||
commit_minimal_update_notice(&mut app, "9.9.9");
|
||||
}
|
||||
|
||||
// ── Tutorial dispatch tests ──────────────────────────────────────────
|
||||
|
||||
/// `/tutorial` (and the palette entry) open the overlay; dispatching again
|
||||
/// while open toggles it closed. No side effects either way.
|
||||
#[test]
|
||||
fn open_tutorial_toggles_overlay_without_effects() {
|
||||
let mut app = test_app();
|
||||
let effects = dispatch(Action::OpenTutorial, &mut app);
|
||||
assert!(app.tutorial.is_some(), "tutorial opens");
|
||||
assert!(effects.is_empty(), "open emits nothing, got: {effects:?}");
|
||||
|
||||
let effects = dispatch(Action::OpenTutorial, &mut app);
|
||||
assert!(app.tutorial.is_none(), "toggle closes");
|
||||
assert!(effects.is_empty(), "close emits nothing, got: {effects:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ fn doctor_planning_opens_refuses_remote_and_rejects_stale_identity() {
|
|||
let target = doctor_target(&app, id);
|
||||
|
||||
app.agents.get_mut(&id).unwrap().prompt.set_text("draft");
|
||||
let scrollback_len = app.agents[&id].scrollback.len();
|
||||
dispatch_task_result(
|
||||
TaskResult::DoctorFixPlanned {
|
||||
target: target.clone(),
|
||||
|
|
@ -102,6 +103,25 @@ fn doctor_planning_opens_refuses_remote_and_rejects_stale_identity() {
|
|||
&mut app,
|
||||
);
|
||||
assert_eq!(app.agents[&id].prompt.text(), "");
|
||||
assert_eq!(
|
||||
app.agents[&id].scrollback.len(),
|
||||
scrollback_len,
|
||||
"the confirmation preview belongs only in the question modal"
|
||||
);
|
||||
let question = app.agents[&id]
|
||||
.question_view
|
||||
.as_ref()
|
||||
.expect("doctor question")
|
||||
.questions
|
||||
.first()
|
||||
.expect("doctor question contents");
|
||||
assert!(
|
||||
question.options[0]
|
||||
.preview
|
||||
.as_deref()
|
||||
.is_some_and(|preview| preview.contains("Doctor Fix")),
|
||||
"the modal must retain the exact fix preview"
|
||||
);
|
||||
app.agents.get_mut(&id).unwrap().question_view = None;
|
||||
|
||||
dispatch_task_result(
|
||||
|
|
@ -146,7 +166,6 @@ fn doctor_apply_completion_prefers_initiator_then_active_and_welcome_fallback()
|
|||
dispatch_task_result(
|
||||
TaskResult::DoctorFixApplied {
|
||||
target: target.clone(),
|
||||
shell: crate::diagnostics::ShellKind::Bash,
|
||||
result: Err("stale plan".to_owned()),
|
||||
},
|
||||
&mut app,
|
||||
|
|
@ -160,7 +179,6 @@ fn doctor_apply_completion_prefers_initiator_then_active_and_welcome_fallback()
|
|||
dispatch_task_result(
|
||||
TaskResult::DoctorFixApplied {
|
||||
target: target.clone(),
|
||||
shell: crate::diagnostics::ShellKind::Bash,
|
||||
result: Err("apply failed".to_owned()),
|
||||
},
|
||||
&mut app,
|
||||
|
|
@ -175,7 +193,6 @@ fn doctor_apply_completion_prefers_initiator_then_active_and_welcome_fallback()
|
|||
dispatch_task_result(
|
||||
TaskResult::DoctorFixApplied {
|
||||
target,
|
||||
shell: crate::diagnostics::ShellKind::Bash,
|
||||
result: Err("validator failed".to_owned()),
|
||||
},
|
||||
&mut app,
|
||||
|
|
@ -187,33 +204,68 @@ fn doctor_apply_completion_prefers_initiator_then_active_and_welcome_fallback()
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_apply_success_renders_refreshed_report() {
|
||||
fn doctor_apply_reload_success_does_not_claim_live_finding_disappeared() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let target = doctor_target(&app, id);
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join(".bashrc");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"# >>> grok doctor >>>\n# >>> terminal.ssh-wrap >>>\nalias ssh='grok wrap ssh'\n# <<< terminal.ssh-wrap <<<\n# <<< grok doctor <<<\n",
|
||||
)
|
||||
.unwrap();
|
||||
let path = temp.path().join(".tmux.conf");
|
||||
dispatch_task_result(
|
||||
TaskResult::DoctorFixApplied {
|
||||
target,
|
||||
shell: crate::diagnostics::ShellKind::Bash,
|
||||
result: Ok(crate::diagnostics::FixOutcome {
|
||||
id: crate::diagnostics::SSH_WRAP_ID,
|
||||
status: crate::diagnostics::FixStatus::Applied,
|
||||
changed_path: path,
|
||||
backup_path: None,
|
||||
}),
|
||||
result: Ok(crate::diagnostics::FixOutcome::new_for_test(
|
||||
crate::diagnostics::TMUX_CLIPBOARD_ID,
|
||||
crate::diagnostics::FixStatus::Applied,
|
||||
path.clone(),
|
||||
None,
|
||||
crate::diagnostics::FixActivation::RequiresReload,
|
||||
None,
|
||||
)),
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
let output = last_system_text(&app, id);
|
||||
assert!(
|
||||
output.starts_with(&format!(
|
||||
"Added `set -g set-clipboard on` to `{}`.",
|
||||
path.display()
|
||||
)),
|
||||
"{output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("Reload tmux with `tmux source-file"),
|
||||
"{output}"
|
||||
);
|
||||
assert!(output.contains("Run /doctor again to verify"), "{output}");
|
||||
assert!(!output.contains("0 issues"), "{output}");
|
||||
assert!(!output.contains("Environment\n"), "{output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_apply_success_only_renders_resolution_instructions() {
|
||||
let mut app = test_app_with_agent();
|
||||
let id = AgentId(0);
|
||||
let target = doctor_target(&app, id);
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
dispatch_task_result(
|
||||
TaskResult::DoctorFixApplied {
|
||||
target,
|
||||
result: Ok(crate::diagnostics::FixOutcome::new_for_test(
|
||||
crate::diagnostics::SSH_WRAP_ID,
|
||||
crate::diagnostics::FixStatus::Applied,
|
||||
temp.path().join(".bashrc"),
|
||||
None,
|
||||
crate::diagnostics::FixActivation::SatisfiedNow,
|
||||
Some(crate::diagnostics::ShellKind::Bash),
|
||||
)),
|
||||
},
|
||||
&mut app,
|
||||
);
|
||||
let output = last_system_text(&app, id);
|
||||
assert!(output.starts_with("Set up SSH wrapping in"), "{output}");
|
||||
assert!(output.contains("Environment\n"), "{output}");
|
||||
assert!(output.contains("Start a new shell"), "{output}");
|
||||
assert!(!output.contains("Environment\n"), "{output}");
|
||||
assert!(!output.contains("Findings\n"), "{output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -260,6 +260,10 @@ pub(crate) struct SessionFlags {
|
|||
/// Active auth is API key (not OAuth/session). Drives rate-limit copy in
|
||||
/// `format_acp_error`. Default `false` (OAuth copy) for tests.
|
||||
pub is_api_key_auth: bool,
|
||||
/// Startup resume target deferred to the worktree handler after missing
|
||||
/// local id/title resolution. Worktree failure messages append the
|
||||
/// no-match hint only when the failing target equals this value.
|
||||
pub resume_local_miss: Option<String>,
|
||||
}
|
||||
impl SessionFlags {
|
||||
/// Resolve the agent profile name from the flags.
|
||||
|
|
@ -1110,6 +1114,14 @@ pub(crate) async fn persist_setting(
|
|||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
"voice_keybind_enabled" => {
|
||||
let SettingValue::Bool(b) = value else {
|
||||
return Err(kind_mismatch("voice_keybind_enabled", "Bool", &value));
|
||||
};
|
||||
xai_grok_shell::util::config::set_voice_keybind_enabled(b)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
"voice_capture_mode" => {
|
||||
let SettingValue::Enum(s) = value else {
|
||||
return Err(kind_mismatch("voice_capture_mode", "Enum", &value));
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
//! back through dispatch.
|
||||
mod helpers;
|
||||
use super::actions;
|
||||
use super::session_title_resolve::worktree_resume_failure_message;
|
||||
#[allow(unused_imports)]
|
||||
use super::{agent, dispatch};
|
||||
pub use helpers::ConversationsPartial;
|
||||
|
|
@ -248,6 +249,7 @@ pub(crate) fn execute(
|
|||
.insert("sessionId".into(), serde_json::json!(sid));
|
||||
}
|
||||
let restore_code = session_flags.restore_code;
|
||||
let resume_local_miss = session_flags.resume_local_miss.clone();
|
||||
tracing::info!(
|
||||
?restore_code,
|
||||
?load_session_id,
|
||||
|
|
@ -257,6 +259,9 @@ pub(crate) fn execute(
|
|||
tasks
|
||||
.spawn(async move {
|
||||
if let Some(sid) = load_session_id {
|
||||
let local_miss = resume_local_miss
|
||||
.as_deref()
|
||||
.filter(|t| *t == sid);
|
||||
let resume_started = std::time::Instant::now();
|
||||
let wt_type = xai_grok_shell::util::config::worktree_type();
|
||||
let copy_mode = if git_ref.is_some() {
|
||||
|
|
@ -300,10 +305,9 @@ pub(crate) fn execute(
|
|||
);
|
||||
return TaskResult::WorktreeSessionFailed {
|
||||
agent_id,
|
||||
error: sanitize_user_error(
|
||||
&format!(
|
||||
"couldn't resume worktree session: {e}"
|
||||
),
|
||||
error: worktree_resume_failure_message(
|
||||
local_miss,
|
||||
&sanitize_user_error(&e.to_string()),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -315,10 +319,9 @@ pub(crate) fn execute(
|
|||
Err(e) => {
|
||||
return TaskResult::WorktreeSessionFailed {
|
||||
agent_id,
|
||||
error: sanitize_user_error(
|
||||
&format!(
|
||||
"couldn't resume worktree session: {e}"
|
||||
),
|
||||
error: worktree_resume_failure_message(
|
||||
local_miss,
|
||||
&sanitize_user_error(&e.to_string()),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -333,10 +336,9 @@ pub(crate) fn execute(
|
|||
.unwrap_or_else(|| err.to_string());
|
||||
return TaskResult::WorktreeSessionFailed {
|
||||
agent_id,
|
||||
error: sanitize_user_error(
|
||||
&format!(
|
||||
"couldn't resume worktree session: {msg}"
|
||||
),
|
||||
error: worktree_resume_failure_message(
|
||||
local_miss,
|
||||
&sanitize_user_error(&msg),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -1867,7 +1869,6 @@ pub(crate) fn execute(
|
|||
Effect::ApplyDoctorFix { target, plan } => {
|
||||
tasks
|
||||
.spawn(async move {
|
||||
let shell = plan.shell;
|
||||
let result = tokio::task::spawn_blocking(move || crate::diagnostics::apply_fix(
|
||||
*plan,
|
||||
))
|
||||
|
|
@ -1876,7 +1877,6 @@ pub(crate) fn execute(
|
|||
.and_then(|result| result.map_err(|error| error.to_string()));
|
||||
TaskResult::DoctorFixApplied {
|
||||
target,
|
||||
shell,
|
||||
result,
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2295,6 +2295,27 @@ fn sanitize_user_error_collapses_disk_full() {
|
|||
"couldn't create worktree: failed to get HEAD commit from source"
|
||||
);
|
||||
}
|
||||
/// Production ordering of the deferred worktree resume failure: the
|
||||
/// detail is sanitized FIRST, then composed — sanitizing the composed
|
||||
/// message would collapse a disk-full chain whole and erase the title
|
||||
/// hint for a deferred local-miss target.
|
||||
#[test]
|
||||
fn worktree_resume_failure_sanitizes_detail_before_hint() {
|
||||
let raw = "failed to copy index: No space left on device (os error 28)";
|
||||
let msg = worktree_resume_failure_message(
|
||||
Some("typo title"),
|
||||
&sanitize_user_error(raw),
|
||||
);
|
||||
assert_eq!(
|
||||
msg,
|
||||
format!(
|
||||
"couldn't resume worktree session: No space left on device; {}",
|
||||
crate::app::session_title_resolve::title_miss_hint("typo title")
|
||||
)
|
||||
);
|
||||
let id_msg = worktree_resume_failure_message(None, &sanitize_user_error(raw));
|
||||
assert_eq!(id_msg, "couldn't resume worktree session: No space left on device");
|
||||
}
|
||||
/// A resume-picker entry converts to a **dormant** dashboard roster row
|
||||
/// (the non-leader idle source) preserving title, cwd, model, worktree
|
||||
/// flag, origin, and last-change time.
|
||||
|
|
|
|||
|
|
@ -1132,7 +1132,9 @@ pub(crate) async fn run(
|
|||
}
|
||||
|
||||
{
|
||||
use xai_grok_shell::util::config::{resolve_announcements, resolve_tips};
|
||||
use xai_grok_shell::util::config::{
|
||||
resolve_announcements, resolve_slash_command_tags, resolve_tips,
|
||||
};
|
||||
|
||||
let remote_announcements = remote_settings
|
||||
.as_ref()
|
||||
|
|
@ -1163,6 +1165,15 @@ pub(crate) async fn run(
|
|||
let grok_home = xai_grok_tools::util::grok_home::grok_home();
|
||||
app.tip = xai_grok_shell::util::tips::pick_and_advance(&app.tips, &grok_home);
|
||||
}
|
||||
|
||||
// Slash-command dropdown tags: remote base, local [slash_command_tags]
|
||||
// wins per key. Mutate the shared map in place so every adopter sees it.
|
||||
let remote_slash_tags = remote_settings
|
||||
.as_ref()
|
||||
.and_then(|s| s.slash_command_tags.as_ref());
|
||||
let empty_toml = toml::Value::Table(Default::default());
|
||||
let tags_config = effective_config.as_ref().unwrap_or(&empty_toml);
|
||||
*app.command_tags.borrow_mut() = resolve_slash_command_tags(tags_config, remote_slash_tags);
|
||||
}
|
||||
|
||||
let hints = xai_grok_shell::util::config::resolve_hints(
|
||||
|
|
@ -1328,6 +1339,13 @@ pub(crate) async fn run(
|
|||
app.voice_config.language =
|
||||
crate::settings::canonical_voice_stt_language(Some(pref)).to_string();
|
||||
}
|
||||
// Seed the Voice shortcut gate's process-global mirror for key-routing and
|
||||
// view code without an `AppView`; the chord intercept reads `current_ui`
|
||||
// live and the settings setter updates both.
|
||||
crate::app::VOICE_KEYBIND_ENABLED.store(
|
||||
app.current_ui.voice_keybind_enabled.unwrap_or(true),
|
||||
std::sync::atomic::Ordering::Release,
|
||||
);
|
||||
// Resolve the per-tip contextual hints now that `current_ui` is hydrated and
|
||||
// propagate the prompt-relevant tips to any agents built at startup. New
|
||||
// agents adopt the gates at creation; settings toggles re-apply at runtime.
|
||||
|
|
@ -1573,12 +1591,19 @@ pub(crate) async fn run(
|
|||
// chokepoints self-gate when auth + folder trust is closed.
|
||||
use crate::app::session_startup::MaterializedStartup;
|
||||
let startup_action = match &materialized {
|
||||
MaterializedStartup::Resume { session_id, .. } if args.worktree.is_some() => {
|
||||
MaterializedStartup::Resume {
|
||||
session_id,
|
||||
deferred_local_miss,
|
||||
..
|
||||
} if args.worktree.is_some() => {
|
||||
tracing::info!(
|
||||
session_id,
|
||||
restore_code = ?app.restore_code,
|
||||
"RESTORE_CODE_DEBUG: worktree+resume path taken"
|
||||
);
|
||||
// Materialization-time provenance for the worktree failure hint;
|
||||
// the effect matches it against the exact deferred target.
|
||||
app.resume_local_miss = deferred_local_miss.then(|| session_id.clone());
|
||||
Some(Action::NewWorktreeSession {
|
||||
load_session_id: Some(session_id.clone()),
|
||||
label: args.worktree.as_ref().filter(|s| !s.is_empty()).cloned(),
|
||||
|
|
@ -3109,12 +3134,19 @@ async fn drain_and_process(
|
|||
// 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.
|
||||
// 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
|
||||
&& is_voice_chord(ke)
|
||||
&& (ke.kind != KeyEventKind::Release || app.voice_hold_owned())
|
||||
&& voice_chord_claims_event(
|
||||
ke.kind,
|
||||
app.current_ui.voice_keybind_enabled.unwrap_or(true),
|
||||
app.voice_hold_owned(),
|
||||
)
|
||||
{
|
||||
// Hold-to-talk only when selected AND the terminal reports key
|
||||
// releases (Kitty protocol); otherwise fall back to a tap toggle.
|
||||
|
|
@ -3365,6 +3397,22 @@ fn voice_chord_action(
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether the event-loop intercept claims a voice-chord key event (pure for
|
||||
/// unit tests).
|
||||
///
|
||||
/// An active hold session owns its chord events end-to-end regardless of the
|
||||
/// Voice shortcut setting — its release only ever stops capture, so flipping
|
||||
/// the setting off mid-hold must not orphan it and wedge the mic open.
|
||||
/// Outside a hold, a bare release is never ours (normal typing) and a press
|
||||
/// honors the setting; an unclaimed press falls through to normal routing,
|
||||
/// where `ActionId::VoiceToggle` resolution is gated on the same setting.
|
||||
fn voice_chord_claims_event(kind: KeyEventKind, keybind_enabled: bool, hold_owned: bool) -> bool {
|
||||
if hold_owned {
|
||||
return true;
|
||||
}
|
||||
kind != KeyEventKind::Release && keybind_enabled
|
||||
}
|
||||
|
||||
/// The voice-capture chord: **Ctrl+Space** or **F8**. A press needs the exact
|
||||
/// chord (matching the registry, so Shift+F8 / Ctrl+Alt+Space don't fire); a
|
||||
/// release matches the key alone (Space/F8), since on Kitty the Ctrl release can
|
||||
|
|
@ -3581,6 +3629,7 @@ fn process_effects(
|
|||
chat_mode: app.chat_mode,
|
||||
screen_mode_label: Some(app.screen_mode.meta_label()),
|
||||
is_api_key_auth: app.is_api_key_auth,
|
||||
resume_local_miss: app.resume_local_miss.clone(),
|
||||
};
|
||||
for eff in effs {
|
||||
let (quit, meta) = effects::execute(eff, tasks, &app.acp_tx, &app.cwd, &flags, progress_tx);
|
||||
|
|
@ -3698,6 +3747,39 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Hold-owned events are claimed even with the setting off (a dropped
|
||||
/// release would wedge the mic open — past regression); otherwise presses
|
||||
/// honor the setting and bare releases are never claimed.
|
||||
#[test]
|
||||
fn voice_chord_claims_event_cases() {
|
||||
let press = KeyEventKind::Press;
|
||||
let repeat = KeyEventKind::Repeat;
|
||||
let release = KeyEventKind::Release;
|
||||
// (kind, keybind_enabled, hold_owned) -> claimed
|
||||
let cases = [
|
||||
// Hold-owned: everything claimed, setting on or off.
|
||||
((release, false, true), true),
|
||||
((release, true, true), true),
|
||||
((press, false, true), true),
|
||||
((repeat, false, true), true),
|
||||
// No hold: press/repeat follow the setting.
|
||||
((press, true, false), true),
|
||||
((press, false, false), false),
|
||||
((repeat, true, false), true),
|
||||
((repeat, false, false), false),
|
||||
// No hold: a bare release is never ours (normal typing).
|
||||
((release, true, false), false),
|
||||
((release, false, false), false),
|
||||
];
|
||||
for ((kind, enabled, owned), want) in cases {
|
||||
assert_eq!(
|
||||
voice_chord_claims_event(kind, enabled, owned),
|
||||
want,
|
||||
"voice_chord_claims_event({kind:?},{enabled},{owned})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── plan_reconnect_load ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ impl ClusterClient {
|
|||
chat_mode: self.app.chat_mode,
|
||||
screen_mode_label: Some(self.app.screen_mode.meta_label()),
|
||||
is_api_key_auth: self.app.is_api_key_auth,
|
||||
resume_local_miss: self.app.resume_local_miss.clone(),
|
||||
};
|
||||
for eff in effs {
|
||||
let (_quit, _meta) = effects::execute(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ mod display_refresh_startup;
|
|||
mod effects;
|
||||
pub mod roster;
|
||||
pub mod session_startup;
|
||||
pub(crate) mod session_title_resolve;
|
||||
pub mod status_blocks;
|
||||
pub mod subagent;
|
||||
pub mod subscription;
|
||||
|
|
@ -178,6 +179,19 @@ pub(crate) fn voice_mode_enabled() -> bool {
|
|||
pub fn set_voice_mode_enabled_for_test(on: bool) {
|
||||
VOICE_MODE_ENABLED.store(on, Ordering::Release);
|
||||
}
|
||||
/// Process-global gate for the Ctrl+Space / F8 voice chord, for key-routing
|
||||
/// and view code without an `AppView` (`resolve_action`, the cheatsheet).
|
||||
/// Default ON. Seeded at startup from `[ui].voice_keybind_enabled` and
|
||||
/// updated live by the settings setter; unlike [`VOICE_MODE_ENABLED`] it only
|
||||
/// silences the keybinding — `/voice` and the other voice surfaces stay up.
|
||||
pub(crate) static VOICE_KEYBIND_ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
pub(crate) fn voice_keybind_enabled() -> bool {
|
||||
VOICE_KEYBIND_ENABLED.load(Ordering::Acquire)
|
||||
}
|
||||
/// Test helper for the process-global voice-keybind gate.
|
||||
pub fn set_voice_keybind_enabled_for_test(on: bool) {
|
||||
VOICE_KEYBIND_ENABLED.store(on, Ordering::Release);
|
||||
}
|
||||
/// `[features] voice_mode` from merged `requirements.toml`.
|
||||
pub(crate) fn voice_mode_requirement_pin() -> Option<bool> {
|
||||
xai_grok_config::load_merged_requirements().and_then(|req| {
|
||||
|
|
|
|||
|
|
@ -573,6 +573,7 @@ impl AgentView {
|
|||
filter_label: None,
|
||||
filter_key_hint: None,
|
||||
filter_active: false,
|
||||
header_note: None,
|
||||
action_keys: &[],
|
||||
disable_search: false,
|
||||
compact_bottom_bar: false,
|
||||
|
|
@ -732,6 +733,7 @@ impl AgentView {
|
|||
filter_label: None,
|
||||
filter_key_hint: None,
|
||||
filter_active: false,
|
||||
header_note: None,
|
||||
action_keys: &[],
|
||||
disable_search: false,
|
||||
compact_bottom_bar: false,
|
||||
|
|
@ -989,8 +991,8 @@ impl AgentView {
|
|||
_ => false,
|
||||
};
|
||||
|
||||
// Chat-mode picker lists conversations only: the Local/Remote
|
||||
// source filter and local-disk delete are dead weight there.
|
||||
// Chat-mode picker lists conversations only: the source
|
||||
// filter and local-disk delete are dead weight there.
|
||||
let chat_mode = self.app_chat_mode;
|
||||
let config = PickerConfig {
|
||||
title: Some("Resume session"),
|
||||
|
|
@ -1007,6 +1009,7 @@ impl AgentView {
|
|||
filter_label: (!chat_mode).then(|| source_filter.label()),
|
||||
filter_key_hint: (!chat_mode).then_some("f"),
|
||||
filter_active: !chat_mode && source_filter.is_active(),
|
||||
header_note: None,
|
||||
action_keys: if chat_mode || focused_is_foreign {
|
||||
&[]
|
||||
} else {
|
||||
|
|
@ -1324,6 +1327,7 @@ impl AgentView {
|
|||
filter_label: None,
|
||||
filter_key_hint: None,
|
||||
filter_active: false,
|
||||
header_note: None,
|
||||
action_keys: &[],
|
||||
disable_search: false,
|
||||
compact_bottom_bar: false,
|
||||
|
|
@ -2107,7 +2111,16 @@ impl AgentView {
|
|||
non_sel_flags.push(false);
|
||||
}
|
||||
|
||||
let entries_area = Rect {
|
||||
let hidden_hint = if chat_mode {
|
||||
None
|
||||
} else {
|
||||
crate::views::session_picker::hidden_external_hint(
|
||||
entries.as_deref(),
|
||||
*source_filter,
|
||||
)
|
||||
};
|
||||
|
||||
let mut entries_area = Rect {
|
||||
x: content_area.x,
|
||||
y: entries_start_y,
|
||||
width: content_area.width,
|
||||
|
|
@ -2115,6 +2128,22 @@ impl AgentView {
|
|||
.height
|
||||
.saturating_sub(entries_start_y.saturating_sub(content_area.y)),
|
||||
};
|
||||
// Pinned above the list so it stays visible regardless of scroll.
|
||||
if let Some(hint) = hidden_hint.as_deref()
|
||||
&& entries_area.height > 0
|
||||
{
|
||||
buf.set_stringn(
|
||||
entries_area.x + 1,
|
||||
entries_area.y,
|
||||
hint,
|
||||
entries_area.width.saturating_sub(1) as usize,
|
||||
ratatui::style::Style::default()
|
||||
.fg(theme.gray_dim)
|
||||
.bg(theme.bg_base),
|
||||
);
|
||||
entries_area.y += 1;
|
||||
entries_area.height -= 1;
|
||||
}
|
||||
let content_hit = picker::render_picker_content_with_scrollbar_x(
|
||||
buf,
|
||||
entries_area,
|
||||
|
|
@ -2124,7 +2153,13 @@ impl AgentView {
|
|||
&non_sel_flags,
|
||||
&[],
|
||||
Some(theme.bg_base),
|
||||
entries.is_none() && (*loading || lanes.foreign_loading),
|
||||
crate::views::session_picker::loading_spinner_active(
|
||||
entries.as_deref(),
|
||||
*source_filter,
|
||||
*loading,
|
||||
lanes,
|
||||
),
|
||||
self.scrollback.tick_count(),
|
||||
mca.inner_x + mca.inner_width - 1,
|
||||
);
|
||||
state.hit_areas = Some(picker::PickerHitAreas {
|
||||
|
|
@ -2524,7 +2559,7 @@ mod session_picker_delete_tests {
|
|||
};
|
||||
assert_eq!(
|
||||
filter,
|
||||
crate::views::session_picker::SourceFilter::All,
|
||||
crate::views::session_picker::SourceFilter::Grok,
|
||||
"f must not cycle the hidden source filter under chat mode"
|
||||
);
|
||||
}
|
||||
|
|
@ -2553,6 +2588,11 @@ mod session_picker_delete_tests {
|
|||
let mut foreign = entry("codex-session");
|
||||
foreign.source = "codex".into();
|
||||
open_picker(&mut agent, vec![foreign]);
|
||||
// Pin All: the refusals only fire when the foreign row is focusable.
|
||||
if let Some(ActiveModal::SessionPicker { source_filter, .. }) = agent.active_modal.as_mut()
|
||||
{
|
||||
*source_filter = crate::views::session_picker::SourceFilter::All;
|
||||
}
|
||||
|
||||
let delete = agent.handle_palette_or_arg_input(&key('d'));
|
||||
assert!(matches!(delete, InputOutcome::Changed));
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
use super::actions::Action;
|
||||
use super::agent_view::{
|
||||
AgentPane, AgentView, CONTEXT_CLICK_DEBOUNCE_MS, CtaPhase, MULTI_CLICK_TIMEOUT_MS,
|
||||
PromptInputMode, TextClickState, app_should_open_link_on_click, has_native_link_hover,
|
||||
is_link_modifier_held, is_text_selection_on_double_click,
|
||||
PromptInputMode, PromptMode, TextClickState, app_should_open_link_on_click,
|
||||
has_native_link_hover, is_link_modifier_held, is_text_selection_on_double_click,
|
||||
};
|
||||
use super::app_view::InputOutcome;
|
||||
use crate::scrollback::block::BlockContent;
|
||||
|
|
@ -125,6 +125,32 @@ impl AgentView {
|
|||
self.cancel_trigger_hint = Some(crate::app::actions::CancelTrigger::Mouse);
|
||||
return InputOutcome::Action(Action::CancelTurn);
|
||||
}
|
||||
if self
|
||||
.privacy_banner
|
||||
.hit_accept
|
||||
.contains(mouse.column, mouse.row)
|
||||
&& !self.pos_occluded(mouse.column, mouse.row)
|
||||
{
|
||||
return InputOutcome::Action(Action::PrivacyBannerAccept);
|
||||
}
|
||||
if self
|
||||
.privacy_banner
|
||||
.hit_customize
|
||||
.contains(mouse.column, mouse.row)
|
||||
&& !self.pos_occluded(mouse.column, mouse.row)
|
||||
{
|
||||
return InputOutcome::Action(Action::PrivacyBannerCustomize);
|
||||
}
|
||||
if self
|
||||
.privacy_banner
|
||||
.hit_legal
|
||||
.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(),
|
||||
));
|
||||
}
|
||||
if self.hit_announcement_hide.contains(mouse.column, mouse.row)
|
||||
&& !self.pos_occluded(mouse.column, mouse.row)
|
||||
{
|
||||
|
|
@ -433,6 +459,18 @@ impl AgentView {
|
|||
{
|
||||
return InputOutcome::Action(action);
|
||||
}
|
||||
if let Some(id) = self.queue.edit_click(mouse.column, mouse.row)
|
||||
&& (!matches!(self.prompt_mode, PromptMode::EditingQueued { .. })
|
||||
|| self.set_active_pane(AgentPane::Queue, false))
|
||||
{
|
||||
let row = self.queue.row_ref(id);
|
||||
let is_server = matches!(
|
||||
row.as_ref().map(|r| r.origin),
|
||||
Some(crate::views::queue_pane::QueueRowOrigin::Server)
|
||||
);
|
||||
self.enter_queue_edit(id, is_server, row);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
self.set_active_pane(AgentPane::Queue, false);
|
||||
self.queue.handle_mouse(
|
||||
mouse.kind,
|
||||
|
|
@ -1013,13 +1051,12 @@ impl AgentView {
|
|||
) {
|
||||
changed |= self.queue.update_delete_hover(mouse.column, mouse.row);
|
||||
changed |= self.queue.update_send_now_hover(mouse.column, mouse.row);
|
||||
changed |= self.queue.update_edit_hover(mouse.column, mouse.row);
|
||||
changed |= self.queue.update_row_hover(mouse.column, mouse.row);
|
||||
} else {
|
||||
if self.queue.hovered_delete_id.is_some() {
|
||||
self.queue.clear_delete_hover();
|
||||
changed = true;
|
||||
}
|
||||
changed |= self.queue.clear_delete_hover();
|
||||
changed |= self.queue.clear_send_now_hover();
|
||||
changed |= self.queue.clear_edit_hover();
|
||||
changed |= self.queue.clear_row_hover();
|
||||
}
|
||||
changed |= self.hit_plan_button.update_hover(mouse.column, mouse.row);
|
||||
|
|
@ -1037,6 +1074,18 @@ impl AgentView {
|
|||
changed |= self
|
||||
.hit_announcement_cta
|
||||
.update_hover(mouse.column, mouse.row);
|
||||
changed |= self
|
||||
.privacy_banner
|
||||
.hit_accept
|
||||
.update_hover(mouse.column, mouse.row);
|
||||
changed |= self
|
||||
.privacy_banner
|
||||
.hit_customize
|
||||
.update_hover(mouse.column, mouse.row);
|
||||
changed |= self
|
||||
.privacy_banner
|
||||
.hit_legal
|
||||
.update_hover(mouse.column, mouse.row);
|
||||
changed |= self
|
||||
.plugin_cta
|
||||
.hit_connect
|
||||
|
|
@ -1252,6 +1301,10 @@ mod tests {
|
|||
fn click_delete(agent: &mut AgentView, selected_id: u64) -> InputOutcome {
|
||||
click_queue_button(agent, selected_id, |a, c, r| a.queue.delete_click(c, r))
|
||||
}
|
||||
/// Left-click the row's `[edit]` button.
|
||||
fn click_edit(agent: &mut AgentView, selected_id: u64) -> InputOutcome {
|
||||
click_queue_button(agent, selected_id, |a, c, r| a.queue.edit_click(c, r))
|
||||
}
|
||||
/// Mouse "Send now" (interject) on the last local row keeps the pane open
|
||||
/// when a server row remains — the third sibling site of the same fix.
|
||||
#[test]
|
||||
|
|
@ -1377,6 +1430,190 @@ mod tests {
|
|||
assert!(agent.active_modal.is_none());
|
||||
assert_eq!(agent.prompt.text(), "draft");
|
||||
}
|
||||
/// Mouse `[edit]` on a queued row enters the same queued-edit flow as the
|
||||
/// keyboard `e` (`QueueEvent::EditSelected` → `enter_queue_edit`): the
|
||||
/// composer loads the row text and the prompt pane takes focus in
|
||||
/// `EditingQueued` mode, leaving the row itself queued.
|
||||
#[test]
|
||||
fn mouse_edit_click_enters_queued_edit_mode() {
|
||||
let mut agent = running_agent_local_only();
|
||||
let ids = agent.queue.entry_ids();
|
||||
let outcome = click_edit(&mut agent, ids[0]);
|
||||
assert!(
|
||||
matches!(outcome, InputOutcome::Changed),
|
||||
"edit click redraws without dispatching an action, got {outcome:?}"
|
||||
);
|
||||
match &agent.prompt_mode {
|
||||
PromptMode::EditingQueued {
|
||||
id,
|
||||
original,
|
||||
server_id,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*id, ids[0]);
|
||||
assert_eq!(original, "local one");
|
||||
assert!(
|
||||
server_id.is_none(),
|
||||
"local row must not take the server edit path"
|
||||
);
|
||||
}
|
||||
other => panic!("expected EditingQueued, got {other:?}"),
|
||||
}
|
||||
assert_eq!(agent.prompt.text(), "local one");
|
||||
assert_eq!(agent.active_pane, AgentPane::Prompt);
|
||||
assert_eq!(agent.session.pending_prompts.len(), 1);
|
||||
}
|
||||
/// Clicking another row's `[edit]` while a DIRTY queued edit is active
|
||||
/// must not re-enter `enter_queue_edit` — that would bypass the
|
||||
/// dirty-edit lock and overwrite `stashed_prompt`, so Esc would restore
|
||||
/// the edit text instead of the user's original draft. The click falls
|
||||
/// through to the pane switch, which the lock blocks.
|
||||
#[test]
|
||||
fn mouse_edit_click_during_dirty_edit_preserves_first_edit_and_stash() {
|
||||
let mut agent = make_running_agent();
|
||||
let ids = agent.queue.entry_ids();
|
||||
agent.stashed_prompt = Some(crate::views::prompt_widget::StashedPrompt {
|
||||
text: "draft".into(),
|
||||
cursor: 0,
|
||||
images: Vec::new(),
|
||||
chip_elements: Vec::new(),
|
||||
image_counter: 0,
|
||||
image_undo_stash: Vec::new(),
|
||||
});
|
||||
agent.prompt_mode = PromptMode::EditingQueued {
|
||||
id: ids[1],
|
||||
original: "local one".into(),
|
||||
server_id: None,
|
||||
kind: crate::app::agent::QueueEntryKind::Prompt,
|
||||
};
|
||||
agent.prompt.set_text("local one EDITED");
|
||||
agent.active_pane = AgentPane::Prompt;
|
||||
let outcome = click_edit(&mut agent, ids[0]);
|
||||
assert!(matches!(outcome, InputOutcome::Changed), "got {outcome:?}");
|
||||
match &agent.prompt_mode {
|
||||
PromptMode::EditingQueued { id, original, .. } => {
|
||||
assert_eq!(*id, ids[1], "the first edit's target row must survive");
|
||||
assert_eq!(original, "local one");
|
||||
}
|
||||
other => panic!("expected the first edit to stay active, got {other:?}"),
|
||||
}
|
||||
assert_eq!(agent.prompt.text(), "local one EDITED");
|
||||
assert_eq!(
|
||||
agent.stashed_prompt.as_ref().map(|s| s.text.as_str()),
|
||||
Some("draft"),
|
||||
"the pre-edit draft must survive for Esc-restore"
|
||||
);
|
||||
assert!(
|
||||
agent.pending_effects.is_empty(),
|
||||
"no hold effect may be emitted for the clicked row"
|
||||
);
|
||||
}
|
||||
/// Same guard for a dirty SERVER-row edit: clicking another row's
|
||||
/// `[edit]` must not replace the edit (which would strand the first
|
||||
/// row's combine hold) nor emit a second `QueueHoldEdit`.
|
||||
#[test]
|
||||
fn mouse_edit_click_during_dirty_server_edit_keeps_hold_target() {
|
||||
let mut agent = make_running_agent();
|
||||
let ids = agent.queue.entry_ids();
|
||||
agent.prompt_mode = PromptMode::EditingQueued {
|
||||
id: ids[0],
|
||||
original: "server one".into(),
|
||||
server_id: Some("p1".into()),
|
||||
kind: crate::app::agent::QueueEntryKind::Prompt,
|
||||
};
|
||||
agent.prompt.set_text("server one EDITED");
|
||||
agent.active_pane = AgentPane::Prompt;
|
||||
let outcome = click_edit(&mut agent, ids[1]);
|
||||
assert!(matches!(outcome, InputOutcome::Changed), "got {outcome:?}");
|
||||
match &agent.prompt_mode {
|
||||
PromptMode::EditingQueued { id, server_id, .. } => {
|
||||
assert_eq!(*id, ids[0], "the held server row must stay the edit target");
|
||||
assert_eq!(server_id.as_deref(), Some("p1"));
|
||||
}
|
||||
other => panic!("expected the server edit to stay active, got {other:?}"),
|
||||
}
|
||||
assert_eq!(agent.prompt.text(), "server one EDITED");
|
||||
assert!(
|
||||
agent.pending_effects.is_empty(),
|
||||
"no second QueueHoldEdit may be emitted while one row is held"
|
||||
);
|
||||
}
|
||||
/// Clicking another row's `[edit]` while a CLEAN (unchanged) edit is
|
||||
/// active must open the clicked row's edit on the SAME click: the
|
||||
/// canonical pane switch exits the clean edit — releasing its server
|
||||
/// combine hold — and the arm then enters the clicked row instead of
|
||||
/// letting the click die on the pane switch.
|
||||
#[test]
|
||||
fn mouse_edit_click_during_clean_edit_switches_to_clicked_row() {
|
||||
let mut agent = make_running_agent();
|
||||
let ids = agent.queue.entry_ids();
|
||||
agent.stashed_prompt = Some(crate::views::prompt_widget::StashedPrompt {
|
||||
text: "draft".into(),
|
||||
cursor: 0,
|
||||
images: Vec::new(),
|
||||
chip_elements: Vec::new(),
|
||||
image_counter: 0,
|
||||
image_undo_stash: Vec::new(),
|
||||
});
|
||||
agent.prompt_mode = PromptMode::EditingQueued {
|
||||
id: ids[0],
|
||||
original: "server one".into(),
|
||||
server_id: Some("p1".into()),
|
||||
kind: crate::app::agent::QueueEntryKind::Prompt,
|
||||
};
|
||||
agent.prompt.set_text("server one");
|
||||
agent.active_pane = AgentPane::Prompt;
|
||||
let outcome = click_edit(&mut agent, ids[1]);
|
||||
assert!(matches!(outcome, InputOutcome::Changed), "got {outcome:?}");
|
||||
match &agent.prompt_mode {
|
||||
PromptMode::EditingQueued {
|
||||
id,
|
||||
original,
|
||||
server_id,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*id, ids[1], "one click must open the clicked row's edit");
|
||||
assert_eq!(original, "local one");
|
||||
assert!(server_id.is_none());
|
||||
}
|
||||
other => panic!("expected EditingQueued for the clicked row, got {other:?}"),
|
||||
}
|
||||
assert_eq!(agent.prompt.text(), "local one");
|
||||
assert_eq!(agent.active_pane, AgentPane::Prompt);
|
||||
assert!(
|
||||
agent.pending_effects.iter().any(|e| matches!(
|
||||
e,
|
||||
crate::app::actions::Effect::QueueReleaseEdit { id, .. } if id == "p1"
|
||||
)),
|
||||
"clean exit must release the held server row, effects = {:?}",
|
||||
agent.pending_effects
|
||||
);
|
||||
assert_eq!(
|
||||
agent.stashed_prompt.as_ref().map(|s| s.text.as_str()),
|
||||
Some("draft")
|
||||
);
|
||||
}
|
||||
/// Clicking `[edit]` on a server row still awaiting its enqueue
|
||||
/// confirmation (an optimistic echo) is ignored: the shell has no row to
|
||||
/// hold yet, so the `hold_edit` would no-op and the later-confirmed row
|
||||
/// could be absorbed mid-edit.
|
||||
#[test]
|
||||
fn mouse_edit_click_on_optimistic_server_row_is_ignored() {
|
||||
let mut agent = make_running_agent();
|
||||
agent.optimistic_queue_ids.insert("p1".into());
|
||||
let ids = agent.queue.entry_ids();
|
||||
let outcome = click_edit(&mut agent, ids[0]);
|
||||
assert!(matches!(outcome, InputOutcome::Changed), "got {outcome:?}");
|
||||
assert!(
|
||||
matches!(agent.prompt_mode, PromptMode::Normal),
|
||||
"an unconfirmed echo must not be editable"
|
||||
);
|
||||
assert_eq!(agent.prompt.text(), "");
|
||||
assert!(
|
||||
agent.pending_effects.is_empty(),
|
||||
"no QueueHoldEdit may be emitted for a row the shell doesn't have"
|
||||
);
|
||||
}
|
||||
/// A synthetic left-click on a rendered follow-up chip yields the LITERAL
|
||||
/// `SubmitFollowUp` action (never a slash-command path).
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -217,6 +217,16 @@ impl AgentView {
|
|||
/// `QueueEvent::EditSelected` (called from `handle_queue_key`).
|
||||
pub(super) fn enter_queue_edit(&mut self, id: u64, is_server: bool, row: Option<QueueRowRef>) {
|
||||
use crate::app::agent::QueueEntryKind;
|
||||
// Still an optimistic echo: its `session/prompt` RPC is in flight, so
|
||||
// the shell has no row to hold yet — the `hold_edit` would no-op and
|
||||
// the later-confirmed row could be absorbed while the composer edits
|
||||
// it. Ignore until the confirming `x.ai/queue/changed` lands (mirrors
|
||||
// the send-now park gate in `force_interject_queue_row`).
|
||||
if let Some(sid) = row.as_ref().and_then(|r| r.server_id.as_deref())
|
||||
&& self.optimistic_queue_ids.contains(sid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
type QueueEditEntryData = (
|
||||
String,
|
||||
QueueEntryKind,
|
||||
|
|
|
|||
|
|
@ -360,6 +360,10 @@ pub enum MaterializedStartup {
|
|||
session_id: String,
|
||||
original_cwd: Option<PathBuf>,
|
||||
title: Option<String>,
|
||||
/// The target missed local id/title resolution and was deferred to
|
||||
/// the worktree resume handler; worktree failure messages append the
|
||||
/// no-match hint only for this outcome (never inferred from shape).
|
||||
deferred_local_miss: bool,
|
||||
},
|
||||
/// Fork from a resolved parent, then load the child.
|
||||
Fork {
|
||||
|
|
@ -369,6 +373,19 @@ pub enum MaterializedStartup {
|
|||
new_session_id: Option<String>,
|
||||
},
|
||||
}
|
||||
/// Whether materialization may resolve a non-id resume arg by title locally.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TitleResolution {
|
||||
/// No pre-sandbox pin ran (direct callers, tests): materialization owns
|
||||
/// title selection.
|
||||
Allowed,
|
||||
/// The composition root already pinned — or definitively missed — the
|
||||
/// target before the irreversible OS sandbox. Re-selecting by title here
|
||||
/// would race a concurrent rename/create and resume a session whose
|
||||
/// persisted profile was never checked; a pinned id that vanished must
|
||||
/// also never be reinterpreted as a title.
|
||||
PinnedPreSandbox,
|
||||
}
|
||||
/// Context for [`materialize_startup`] (interactive vs headless share this).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MaterializeCtx {
|
||||
|
|
@ -381,6 +398,8 @@ pub struct MaterializeCtx {
|
|||
/// the local disk store. Always `false` without the optional feature;
|
||||
/// setting it anyway errors rather than silently falling back to disk.
|
||||
pub chat_mode: bool,
|
||||
/// See [`TitleResolution`]; carried from the pre-sandbox pin outcome.
|
||||
pub title_resolution: TitleResolution,
|
||||
}
|
||||
impl MaterializeCtx {
|
||||
/// `--resume` miss bails fast.
|
||||
|
|
@ -392,6 +411,11 @@ impl MaterializeCtx {
|
|||
has_worktree: args.worktree.is_some(),
|
||||
allow_remote_restore: Self::default_allow_remote_restore(),
|
||||
chat_mode: args.chat(),
|
||||
title_resolution: if args.resume_target_pinned {
|
||||
TitleResolution::PinnedPreSandbox
|
||||
} else {
|
||||
TitleResolution::Allowed
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -493,6 +517,7 @@ pub async fn materialize_startup_for_cwd(
|
|||
session_id: id,
|
||||
original_cwd: None,
|
||||
title,
|
||||
deferred_local_miss: false,
|
||||
})
|
||||
}
|
||||
SessionStartupIntent::ForkFrom {
|
||||
|
|
@ -523,6 +548,7 @@ pub async fn materialize_startup_for_cwd(
|
|||
session_id,
|
||||
original_cwd: None,
|
||||
title: None,
|
||||
deferred_local_miss: false,
|
||||
});
|
||||
}
|
||||
let r = resolve_existing_session(ctx, &session_id, cwd).await?;
|
||||
|
|
@ -530,6 +556,7 @@ pub async fn materialize_startup_for_cwd(
|
|||
session_id: r.id,
|
||||
original_cwd: r.original_cwd,
|
||||
title: r.title,
|
||||
deferred_local_miss: r.deferred_local_miss,
|
||||
})
|
||||
}
|
||||
SessionStartupIntent::ForkFrom {
|
||||
|
|
@ -566,6 +593,9 @@ struct ResolvedExisting {
|
|||
id: String,
|
||||
original_cwd: Option<PathBuf>,
|
||||
title: Option<String>,
|
||||
/// True only for the worktree-defer arm: the target missed local
|
||||
/// id/title resolution.
|
||||
deferred_local_miss: bool,
|
||||
}
|
||||
/// Resolve an existing session for strict resume (local / any-cwd / remote / worktree defer).
|
||||
async fn resolve_existing_session(
|
||||
|
|
@ -579,6 +609,7 @@ async fn resolve_existing_session(
|
|||
id: local_id,
|
||||
original_cwd: None,
|
||||
title: None,
|
||||
deferred_local_miss: false,
|
||||
});
|
||||
}
|
||||
if let Some(original_cwd) = xai_grok_shell::session::resolve_local_session_any_cwd(session_id) {
|
||||
|
|
@ -595,26 +626,58 @@ async fn resolve_existing_session(
|
|||
id: session_id.to_string(),
|
||||
original_cwd: Some(PathBuf::from(original_cwd)),
|
||||
title: None,
|
||||
deferred_local_miss: false,
|
||||
});
|
||||
}
|
||||
let arg_is_uuid = super::session_title_resolve::is_uuid_shaped(session_id);
|
||||
if !arg_is_uuid
|
||||
&& ctx.title_resolution == TitleResolution::Allowed
|
||||
&& let Some(resolved) = resolve_session_by_title(session_id, cwd).await?
|
||||
{
|
||||
return Ok(resolved);
|
||||
}
|
||||
if ctx.has_worktree {
|
||||
tracing::info!(
|
||||
session_id = %session_id,
|
||||
"Session not found locally; deferring restore to worktree resume handler"
|
||||
);
|
||||
eprintln!(
|
||||
"Session {} not found locally; it will be restored into the new worktree.",
|
||||
"Session {:?} not found locally; it will be restored into the new worktree.",
|
||||
session_id
|
||||
);
|
||||
return Ok(ResolvedExisting {
|
||||
id: session_id.to_string(),
|
||||
original_cwd: None,
|
||||
title: None,
|
||||
deferred_local_miss: !arg_is_uuid,
|
||||
});
|
||||
}
|
||||
if !ctx.allow_remote_restore {
|
||||
if !arg_is_uuid {
|
||||
anyhow::bail!(
|
||||
"Session does not exist: {}",
|
||||
super::session_title_resolve::title_miss_hint(session_id)
|
||||
);
|
||||
}
|
||||
anyhow::bail!("Session does not exist");
|
||||
}
|
||||
let restored = restore_session_from_remote(session_id, cwd).await;
|
||||
if arg_is_uuid {
|
||||
return restored;
|
||||
}
|
||||
restored.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"{e:#}; {}",
|
||||
super::session_title_resolve::title_miss_hint(session_id)
|
||||
)
|
||||
})
|
||||
}
|
||||
/// Remote-restore tail of [`resolve_existing_session`], split out so non-id
|
||||
/// targets can wrap every failure with the title-miss hint.
|
||||
async fn restore_session_from_remote(
|
||||
session_id: &str,
|
||||
cwd: &str,
|
||||
) -> anyhow::Result<ResolvedExisting> {
|
||||
let raw_config = xai_grok_shell::config::load_effective_config()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load config: {}", e))?;
|
||||
if let Some((false, source)) =
|
||||
|
|
@ -626,7 +689,7 @@ async fn resolve_existing_session(
|
|||
);
|
||||
}
|
||||
eprintln!(
|
||||
"Session {} not found locally, restoring from remote...",
|
||||
"Session {:?} not found locally, restoring from remote...",
|
||||
session_id
|
||||
);
|
||||
let agent_config = xai_grok_shell::agent::config::Config::new_from_toml_cfg(&raw_config)
|
||||
|
|
@ -683,8 +746,33 @@ async fn resolve_existing_session(
|
|||
id: effective_id,
|
||||
original_cwd: None,
|
||||
title: None,
|
||||
deferred_local_miss: false,
|
||||
})
|
||||
}
|
||||
/// Resolve a non-id resume arg as a session title among local sessions for `cwd`.
|
||||
///
|
||||
/// Matching/disambiguation rules live in [`super::session_title_resolve`]
|
||||
/// (shared with the pre-sandbox saved-profile peek); this adds the cwd-scoped
|
||||
/// listing and the resolved-id announcement. The arg is matched in memory and
|
||||
/// never used as a filesystem path.
|
||||
async fn resolve_session_by_title(
|
||||
arg: &str,
|
||||
cwd: &str,
|
||||
) -> anyhow::Result<Option<ResolvedExisting>> {
|
||||
let summaries = xai_grok_shell::session::persistence::list_summaries(Some(cwd)).await?;
|
||||
let Some(chosen) = super::session_title_resolve::select_by_title(arg, &summaries)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let id = chosen.info.id.to_string();
|
||||
tracing::info!(session_id = %id, "Session resolved by title");
|
||||
eprintln!("Resuming session {} (matched by title)", id);
|
||||
Ok(Some(ResolvedExisting {
|
||||
id,
|
||||
original_cwd: None,
|
||||
title: chosen.display_title_opt(),
|
||||
deferred_local_miss: false,
|
||||
}))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -898,6 +986,7 @@ mod tests {
|
|||
has_worktree: false,
|
||||
allow_remote_restore: true,
|
||||
chat_mode: true,
|
||||
title_resolution: TitleResolution::Allowed,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
|
|
@ -948,6 +1037,7 @@ mod tests {
|
|||
session_id,
|
||||
original_cwd,
|
||||
title,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(session_id, "conv-e2f1");
|
||||
assert!(original_cwd.is_none());
|
||||
|
|
@ -1005,6 +1095,7 @@ mod tests {
|
|||
has_worktree: false,
|
||||
allow_remote_restore: false,
|
||||
chat_mode: false,
|
||||
title_resolution: TitleResolution::Allowed,
|
||||
};
|
||||
let err = materialize_startup_for_cwd(
|
||||
ctx,
|
||||
|
|
@ -1086,4 +1177,157 @@ mod tests {
|
|||
other => panic!("expected Resume, got {other:?}"),
|
||||
}
|
||||
}
|
||||
mod resume_by_title {
|
||||
use super::*;
|
||||
use crate::test_util::GrokHomeFixture;
|
||||
fn local_ctx() -> MaterializeCtx {
|
||||
MaterializeCtx {
|
||||
has_worktree: false,
|
||||
allow_remote_restore: false,
|
||||
chat_mode: false,
|
||||
title_resolution: TitleResolution::Allowed,
|
||||
}
|
||||
}
|
||||
async fn resume(arg: &str, cwd: &str) -> anyhow::Result<MaterializedStartup> {
|
||||
materialize_startup_for_cwd(
|
||||
local_ctx(),
|
||||
SessionStartupIntent::Resume {
|
||||
session_id: Some(arg.into()),
|
||||
most_recent_for_cwd: false,
|
||||
},
|
||||
cwd,
|
||||
)
|
||||
.await
|
||||
}
|
||||
/// Also covers letter-case insensitivity: the query case differs from
|
||||
/// the stored title.
|
||||
#[serial_test::serial(GROK_HOME)]
|
||||
#[tokio::test]
|
||||
async fn title_fallback_resumes_single_match_case_insensitively() {
|
||||
let mut fx = GrokHomeFixture::new();
|
||||
let cwd_str = fx.cwd_str();
|
||||
let id = "bbbbbbbb-1111-2222-3333-444444444444";
|
||||
fx.write_summary(
|
||||
&cwd_str,
|
||||
id,
|
||||
serde_json::json!({ "generated_title": "Fix Login Bug", "title_is_manual": true }),
|
||||
);
|
||||
fx.write_summary(
|
||||
&cwd_str,
|
||||
"bbbbbbbb-1111-2222-3333-555555555555",
|
||||
serde_json::json!({ "generated_title": "Other Work" }),
|
||||
);
|
||||
match resume("fix login bug", &cwd_str).await.unwrap() {
|
||||
MaterializedStartup::Resume {
|
||||
session_id,
|
||||
original_cwd,
|
||||
title,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(session_id, id);
|
||||
assert!(original_cwd.is_none());
|
||||
assert_eq!(title.as_deref(), Some("Fix Login Bug"));
|
||||
}
|
||||
other => panic!("expected Resume, got {other:?}"),
|
||||
}
|
||||
}
|
||||
/// Id resolution stays authoritative: when the arg is an on-disk
|
||||
/// session id, the title fallback is never consulted even though
|
||||
/// another session carries that exact title.
|
||||
#[serial_test::serial(GROK_HOME)]
|
||||
#[tokio::test]
|
||||
async fn id_hit_beats_title_fallback() {
|
||||
let mut fx = GrokHomeFixture::new();
|
||||
let cwd_str = fx.cwd_str();
|
||||
fx.write_summary(
|
||||
&cwd_str,
|
||||
"release-notes",
|
||||
serde_json::json!({ "generated_title": "id-owner" }),
|
||||
);
|
||||
fx.write_summary(
|
||||
&cwd_str,
|
||||
"cccccccc-1111-2222-3333-444444444444",
|
||||
serde_json::json!({ "generated_title": "release-notes", "title_is_manual": true }),
|
||||
);
|
||||
match resume("release-notes", &cwd_str).await.unwrap() {
|
||||
MaterializedStartup::Resume {
|
||||
session_id, title, ..
|
||||
} => {
|
||||
assert_eq!(session_id, "release-notes");
|
||||
assert!(title.is_none());
|
||||
}
|
||||
other => panic!("expected Resume, got {other:?}"),
|
||||
}
|
||||
}
|
||||
/// Provenance for the worktree failure hint: only the defer arm (a
|
||||
/// local id/title miss under `--worktree`) flags the target; a
|
||||
/// resolved local id — even a legacy non-UUID one — never does.
|
||||
#[serial_test::serial(GROK_HOME)]
|
||||
#[tokio::test]
|
||||
async fn worktree_defer_flags_local_miss_and_local_hit_does_not() {
|
||||
let mut fx = GrokHomeFixture::new();
|
||||
let cwd_str = fx.cwd_str();
|
||||
fx.write_summary(&cwd_str, "release-notes", serde_json::json!({}));
|
||||
let worktree_ctx = MaterializeCtx {
|
||||
has_worktree: true,
|
||||
..local_ctx()
|
||||
};
|
||||
let resume_intent = |arg: &str| SessionStartupIntent::Resume {
|
||||
session_id: Some(arg.into()),
|
||||
most_recent_for_cwd: false,
|
||||
};
|
||||
let hit =
|
||||
materialize_startup_for_cwd(worktree_ctx, resume_intent("release-notes"), &cwd_str)
|
||||
.await
|
||||
.unwrap();
|
||||
match hit {
|
||||
MaterializedStartup::Resume {
|
||||
session_id,
|
||||
deferred_local_miss,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(session_id, "release-notes");
|
||||
assert!(!deferred_local_miss, "resolved id must not flag a miss");
|
||||
}
|
||||
other => panic!("expected Resume, got {other:?}"),
|
||||
}
|
||||
let miss = materialize_startup_for_cwd(
|
||||
worktree_ctx,
|
||||
resume_intent("no such target"),
|
||||
&cwd_str,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match miss {
|
||||
MaterializedStartup::Resume {
|
||||
session_id,
|
||||
deferred_local_miss,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(session_id, "no such target");
|
||||
assert!(deferred_local_miss, "defer must flag the local miss");
|
||||
}
|
||||
other => panic!("expected Resume, got {other:?}"),
|
||||
}
|
||||
let uuid_miss = materialize_startup_for_cwd(
|
||||
worktree_ctx,
|
||||
resume_intent("99999999-9999-4999-8999-999999999999"),
|
||||
&cwd_str,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match uuid_miss {
|
||||
MaterializedStartup::Resume {
|
||||
deferred_local_miss,
|
||||
..
|
||||
} => {
|
||||
assert!(
|
||||
!deferred_local_miss,
|
||||
"UUID defer must not flag a title-capable miss"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Resume, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
169
crates/codegen/xai-grok-pager/src/app/session_title_resolve.rs
Normal file
169
crates/codegen/xai-grok-pager/src/app/session_title_resolve.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
//! Resume-by-title selection shared by startup paths.
|
||||
//!
|
||||
//! The composition root pins an explicit non-id resume target to its
|
||||
//! canonical session id BEFORE the irreversible OS sandbox
|
||||
//! ([`super::cli::PagerArgs::pin_local_resume_target`]), so the saved-profile
|
||||
//! peek and materialization act on one immutable target instead of racing a
|
||||
//! concurrent rename between two title lookups. Materialization keeps
|
||||
//! [`select_by_title`] as the authoritative error source (ambiguity /
|
||||
//! no-match) and as a fallback for callers that bypass pinning.
|
||||
|
||||
use xai_grok_shell::session::persistence::Summary;
|
||||
|
||||
/// UUID-shaped resume args always take the id path, even when no such id
|
||||
/// exists and a session is titled with that exact UUID.
|
||||
pub(crate) fn is_uuid_shaped(arg: &str) -> bool {
|
||||
uuid::Uuid::try_parse(arg).is_ok()
|
||||
}
|
||||
|
||||
/// Canonical key for title equality: trimmed `str::to_lowercase`. Plain
|
||||
/// case-insensitive equality, not full Unicode caseless matching.
|
||||
fn title_key(s: &str) -> String {
|
||||
s.trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// Hint appended to every terminal failure for a non-id resume target, so the
|
||||
/// title miss stays visible even when remote restore produces the final error.
|
||||
/// Debug formatting: the arg is arbitrary user text.
|
||||
pub(crate) fn title_miss_hint(arg: &str) -> String {
|
||||
format!(
|
||||
"no session id or title matched {arg:?} for this directory; \
|
||||
try `grok sessions search {arg:?}`"
|
||||
)
|
||||
}
|
||||
|
||||
/// Select the local session a resume arg names by title.
|
||||
///
|
||||
/// - `Ok(None)`: UUID-shaped or blank arg, or no title matched — the caller
|
||||
/// keeps id-miss behavior.
|
||||
/// - `Ok(Some)`: exactly one match, or a sole manual `/rename` among
|
||||
/// duplicates (explicit user intent beats colliding auto titles).
|
||||
/// - `Err`: ambiguous — never silently pick one, headless scripts need
|
||||
/// determinism. Candidate titles are Debug-escaped: `/rename` accepts
|
||||
/// arbitrary text, and raw control characters would corrupt the listing.
|
||||
pub(crate) fn select_by_title<'a>(
|
||||
arg: &str,
|
||||
summaries: &'a [Summary],
|
||||
) -> anyhow::Result<Option<&'a Summary>> {
|
||||
if is_uuid_shaped(arg) {
|
||||
return Ok(None);
|
||||
}
|
||||
let needle = title_key(arg);
|
||||
if needle.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let matches: Vec<&Summary> = summaries
|
||||
.iter()
|
||||
.filter(|s| title_key(s.display_title()) == needle)
|
||||
.collect();
|
||||
match matches.as_slice() {
|
||||
[] => Ok(None),
|
||||
[only] => Ok(Some(*only)),
|
||||
_ => {
|
||||
let manual: Vec<&&Summary> = matches
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
s.manual_title_opt()
|
||||
.is_some_and(|t| title_key(&t) == needle)
|
||||
})
|
||||
.collect();
|
||||
if let [only] = manual.as_slice() {
|
||||
return Ok(Some(**only));
|
||||
}
|
||||
let listing = matches
|
||||
.iter()
|
||||
.map(|s| format!(" {} {:?}", s.info.id, s.display_title()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
anyhow::bail!(
|
||||
"Multiple sessions match title {:?}:\n{listing}\n\
|
||||
Resume by session id instead: grok --resume <session-id>",
|
||||
arg.trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of the pre-sandbox resolution of an explicit resume arg.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PinnedResumeTarget {
|
||||
/// Nothing local resolved (UUID-shaped, no cwd, junk, or ambiguous
|
||||
/// title): leave the raw arg alone — materialization owns the
|
||||
/// authoritative error / remote path.
|
||||
Unresolved,
|
||||
/// Resolved as a local id (possibly the restored child of a remote id).
|
||||
Id(String),
|
||||
/// Resolved by title to this session. The selected summary's persisted
|
||||
/// sandbox profile rides along: re-deriving it from the id is ambiguous
|
||||
/// when a legacy id is duplicated across cwd dirs.
|
||||
Title {
|
||||
id: String,
|
||||
sandbox_profile: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl PinnedResumeTarget {
|
||||
pub(crate) fn id(self) -> Option<String> {
|
||||
match self {
|
||||
Self::Unresolved => None,
|
||||
Self::Id(id) | Self::Title { id, .. } => Some(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an explicit resume arg to a pinned local session id before the
|
||||
/// (irreversible) OS sandbox: the saved-profile peek and materialization must
|
||||
/// consume one immutable target, not re-run title selection against mutable
|
||||
/// summaries. Id lookups stay authoritative (same order as
|
||||
/// `resolve_existing_session`), preserving the restored-child id so the peek
|
||||
/// cannot drift to a same-id session in another cwd. Errs on a listing
|
||||
/// failure (fail closed instead of guessing) and on ambiguity, which must
|
||||
/// surface before the sandbox rather than after it.
|
||||
pub(crate) fn presandbox_resume_target(
|
||||
arg: &str,
|
||||
cwd: Option<&str>,
|
||||
) -> anyhow::Result<PinnedResumeTarget> {
|
||||
if is_uuid_shaped(arg) {
|
||||
return Ok(PinnedResumeTarget::Unresolved);
|
||||
}
|
||||
let Some(cwd) = cwd else {
|
||||
return Ok(PinnedResumeTarget::Unresolved);
|
||||
};
|
||||
if let Some(local_id) = xai_grok_shell::session::resolve_local_session(arg, cwd) {
|
||||
return Ok(PinnedResumeTarget::Id(local_id));
|
||||
}
|
||||
if xai_grok_shell::session::resolve_local_session_any_cwd(arg).is_some() {
|
||||
return Ok(PinnedResumeTarget::Id(arg.to_string()));
|
||||
}
|
||||
let summaries = xai_grok_shell::session::persistence::local_summaries_for_cwd_sync(cwd)
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("failed to list local sessions while resolving --resume {arg:?}: {e}")
|
||||
})?;
|
||||
Ok(select_by_title(arg, &summaries)?
|
||||
.map(|s| PinnedResumeTarget::Title {
|
||||
id: s.info.id.to_string(),
|
||||
sandbox_profile: s.sandbox_profile.clone(),
|
||||
})
|
||||
.unwrap_or(PinnedResumeTarget::Unresolved))
|
||||
}
|
||||
|
||||
/// Failure message for a worktree resume. `local_miss_target` is `Some(arg)`
|
||||
/// only when materialization deferred exactly this target after missing local
|
||||
/// id/title resolution — provenance is threaded, never inferred from id
|
||||
/// shape, so a resolved legacy non-UUID id gets no false no-match hint.
|
||||
/// `detail` must already be user-sanitized: sanitizing the composed message
|
||||
/// instead would collapse disk-full chains whole and erase the appended hint.
|
||||
pub(crate) fn worktree_resume_failure_message(
|
||||
local_miss_target: Option<&str>,
|
||||
detail: &str,
|
||||
) -> String {
|
||||
let msg = format!("couldn't resume worktree session: {detail}");
|
||||
match local_miss_target {
|
||||
Some(target) => format!("{msg}; {}", title_miss_hint(target)),
|
||||
None => msg,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "session_title_resolve_tests.rs"]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
use super::*;
|
||||
use crate::test_util::GrokHomeFixture;
|
||||
use clap::Parser;
|
||||
|
||||
/// In-memory `Summary` via serde: every field without `#[serde(default)]`
|
||||
/// must be present, and a struct literal would break on each new field.
|
||||
fn summary(id: &str, title: Option<&str>, manual: bool) -> Summary {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"info": { "id": id, "cwd": "/ws" },
|
||||
"session_summary": "auto summary",
|
||||
"created_at": "2026-07-01T00:00:00Z",
|
||||
"updated_at": "2026-07-01T00:00:00Z",
|
||||
"num_messages": 1,
|
||||
"current_model_id": "grok-build",
|
||||
"generated_title": title,
|
||||
"title_is_manual": manual,
|
||||
}))
|
||||
.expect("valid Summary JSON")
|
||||
}
|
||||
|
||||
fn id_of(s: Option<&Summary>) -> String {
|
||||
s.expect("expected a selected summary").info.id.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_or_unmatched_arg_selects_none() {
|
||||
let s = [summary("a", Some("Fix Login"), false)];
|
||||
assert!(select_by_title("nope", &s).unwrap().is_none());
|
||||
assert!(select_by_title(" ", &s).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_match_is_case_insensitive_and_trimmed() {
|
||||
let s = [
|
||||
summary("a", Some("Fix Login Bug"), false),
|
||||
summary("b", Some("Other"), false),
|
||||
];
|
||||
assert_eq!(id_of(select_by_title(" FIX login bug ", &s).unwrap()), "a");
|
||||
}
|
||||
|
||||
/// The contract is a simple lowercase comparison: accented letters match
|
||||
/// across case, but one-to-many case folds do not (`to_lowercase` maps
|
||||
/// U+00DF to itself, so "STRASSE" never equals a stored "straße").
|
||||
#[test]
|
||||
fn non_ascii_case_matching_contract() {
|
||||
let s = [summary("a", Some("Café Löschen"), false)];
|
||||
assert_eq!(id_of(select_by_title("CAFÉ LÖSCHEN", &s).unwrap()), "a");
|
||||
let sharp = [summary("b", Some("straße"), false)];
|
||||
assert!(select_by_title("STRASSE", &sharp).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_auto_titles_error_lists_ids_with_escaped_titles() {
|
||||
// A title with a newline would corrupt the one-match-per-line listing if
|
||||
// rendered raw.
|
||||
let s = [
|
||||
summary("id-a", Some("Dup\nTitle"), false),
|
||||
summary("id-b", Some("Dup\nTitle"), false),
|
||||
];
|
||||
let msg = select_by_title("dup\ntitle", &s).unwrap_err().to_string();
|
||||
assert!(
|
||||
msg.contains("id-a") && msg.contains("id-b"),
|
||||
"both ids must be listed: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("Dup\\nTitle"),
|
||||
"titles must be Debug-escaped: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sole_manual_rename_wins_among_duplicates() {
|
||||
let s = [
|
||||
summary("auto1", Some("Dup"), false),
|
||||
summary("man1", Some("Dup"), true),
|
||||
summary("auto2", Some("Dup"), false),
|
||||
];
|
||||
assert_eq!(id_of(select_by_title("dup", &s).unwrap()), "man1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_manual_renames_stay_ambiguous() {
|
||||
let s = [
|
||||
summary("man1", Some("Dup"), true),
|
||||
summary("man2", Some("Dup"), true),
|
||||
];
|
||||
let msg = select_by_title("Dup", &s).unwrap_err().to_string();
|
||||
assert!(
|
||||
msg.contains("man1") && msg.contains("man2"),
|
||||
"both manual ids must be listed: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuid_shaped_arg_never_matches_titles() {
|
||||
let uuid = "12345678-1234-1234-1234-123456789abc";
|
||||
let s = [summary("a", Some(uuid), true)];
|
||||
assert!(select_by_title(uuid, &s).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_miss_hint_escapes_arg_and_suggests_search() {
|
||||
let hint = title_miss_hint("evil\ntitle");
|
||||
assert!(hint.contains("evil\\ntitle"), "arg must be escaped: {hint}");
|
||||
assert!(
|
||||
hint.contains("grok sessions search"),
|
||||
"missing hint: {hint}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The worktree defer drops the local zero-match context; the failure
|
||||
/// message restores it only for a threaded deferred-miss target. A resolved
|
||||
/// legacy non-UUID id (no threaded miss) must not get a false no-match hint.
|
||||
#[test]
|
||||
fn worktree_failure_message_hint_follows_threaded_provenance() {
|
||||
let msg = worktree_resume_failure_message(Some("typo title"), "restore failed");
|
||||
assert!(msg.contains("couldn't resume worktree session: restore failed"));
|
||||
assert!(msg.contains("no session id or title matched"), "{msg}");
|
||||
assert!(msg.contains("grok sessions search"), "{msg}");
|
||||
let resolved_msg = worktree_resume_failure_message(None, "restore failed");
|
||||
assert_eq!(
|
||||
resolved_msg,
|
||||
"couldn't resume worktree session: restore failed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression (production wiring): pinning rewrites the `-r` title to the
|
||||
/// canonical id, the profile peek sees the saved profile, and a conflicting
|
||||
/// explicit profile is refused exactly like id resume.
|
||||
#[serial_test::serial(GROK_HOME)]
|
||||
#[test]
|
||||
fn pin_title_resume_finds_saved_profile_and_conflicts() {
|
||||
let mut fx = GrokHomeFixture::new();
|
||||
let cwd_str = fx.cwd_str();
|
||||
let id = "abcdabcd-1111-2222-3333-444444444444";
|
||||
fx.write_summary(
|
||||
&cwd_str,
|
||||
id,
|
||||
serde_json::json!({
|
||||
"generated_title": "Locked Down",
|
||||
"title_is_manual": true,
|
||||
"sandbox_profile": "strict",
|
||||
}),
|
||||
);
|
||||
let mut args = crate::app::cli::PagerArgs::try_parse_from([
|
||||
"grok",
|
||||
"-r",
|
||||
"locked down",
|
||||
"--sandbox",
|
||||
"off",
|
||||
])
|
||||
.unwrap();
|
||||
args.pin_local_resume_target_for_cwd(Some(&cwd_str))
|
||||
.unwrap();
|
||||
assert_eq!(args.session_to_resume(), Some(id));
|
||||
|
||||
let saved = args.saved_resume_profile_for_cwd(Some(&cwd_str));
|
||||
assert_eq!(saved.as_deref(), Some("strict"));
|
||||
match args.startup_sandbox_profile(saved.as_deref()) {
|
||||
crate::app::cli::SandboxStartup::Conflict { requested, saved } => {
|
||||
assert_eq!(requested, "off");
|
||||
assert_eq!(saved, "strict");
|
||||
}
|
||||
other => panic!("expected Conflict, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: a non-UUID remote id with a restored local child pins to the
|
||||
/// child, so the peek reads the child's profile instead of an exact same-id
|
||||
/// session in another cwd.
|
||||
#[serial_test::serial(GROK_HOME)]
|
||||
#[test]
|
||||
fn pin_prefers_restored_child_over_same_id_in_other_cwd() {
|
||||
let mut fx = GrokHomeFixture::new();
|
||||
let cwd_str = fx.cwd_str();
|
||||
let child = "cafecafe-1111-2222-3333-444444444444";
|
||||
fx.write_summary(
|
||||
&cwd_str,
|
||||
child,
|
||||
serde_json::json!({
|
||||
"parent_session_id": "legacy-remote-7",
|
||||
"sandbox_profile": "strict",
|
||||
}),
|
||||
);
|
||||
let other_cwd = tempfile::tempdir().expect("other cwd tempdir");
|
||||
let other_str = other_cwd.path().to_string_lossy().to_string();
|
||||
fx.write_summary(
|
||||
&other_str,
|
||||
"legacy-remote-7",
|
||||
serde_json::json!({ "sandbox_profile": "off" }),
|
||||
);
|
||||
|
||||
let mut args =
|
||||
crate::app::cli::PagerArgs::try_parse_from(["grok", "-r", "legacy-remote-7"]).unwrap();
|
||||
args.pin_local_resume_target_for_cwd(Some(&cwd_str))
|
||||
.unwrap();
|
||||
assert_eq!(args.session_to_resume(), Some(child));
|
||||
assert_eq!(
|
||||
args.saved_resume_profile_for_cwd(Some(&cwd_str)).as_deref(),
|
||||
Some("strict")
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: materialization consumes the pinned id via the ordinary id
|
||||
/// path. A rename/create between the pre-sandbox pin and materialization
|
||||
/// must not re-select by title.
|
||||
#[serial_test::serial(GROK_HOME)]
|
||||
#[tokio::test]
|
||||
async fn materialization_consumes_pinned_id_after_concurrent_rename() {
|
||||
let mut fx = GrokHomeFixture::new();
|
||||
let cwd_str = fx.cwd_str();
|
||||
let pinned = "dadadada-1111-2222-3333-444444444444";
|
||||
fx.write_summary(
|
||||
&cwd_str,
|
||||
pinned,
|
||||
serde_json::json!({ "generated_title": "Alpha", "title_is_manual": true }),
|
||||
);
|
||||
|
||||
let mut args = crate::app::cli::PagerArgs::try_parse_from(["grok", "-r", "alpha"]).unwrap();
|
||||
args.pin_local_resume_target_for_cwd(Some(&cwd_str))
|
||||
.unwrap();
|
||||
assert_eq!(args.session_to_resume(), Some(pinned));
|
||||
|
||||
// Concurrent rename/create after the pin: the pinned session loses the
|
||||
// title and a decoy gains it.
|
||||
fx.write_summary(
|
||||
&cwd_str,
|
||||
pinned,
|
||||
serde_json::json!({ "generated_title": "Beta", "title_is_manual": true }),
|
||||
);
|
||||
fx.write_summary(
|
||||
&cwd_str,
|
||||
"dadadada-1111-2222-3333-555555555555",
|
||||
serde_json::json!({ "generated_title": "Alpha", "title_is_manual": true }),
|
||||
);
|
||||
|
||||
use crate::app::session_startup::{MaterializedStartup, materialize_startup_for_cwd};
|
||||
let intent = args.session_startup_intent().unwrap();
|
||||
let out = materialize_startup_for_cwd(pinned_local_ctx(), intent, &cwd_str)
|
||||
.await
|
||||
.unwrap();
|
||||
match out {
|
||||
MaterializedStartup::Resume { session_id, .. } => assert_eq!(session_id, pinned),
|
||||
other => panic!("expected Resume, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Local-only ctx carrying the composition root's pin outcome
|
||||
/// (`resume_target_pinned` maps to `PinnedPreSandbox` in production).
|
||||
fn pinned_local_ctx() -> crate::app::session_startup::MaterializeCtx {
|
||||
crate::app::session_startup::MaterializeCtx {
|
||||
has_worktree: false,
|
||||
allow_remote_restore: false,
|
||||
chat_mode: false,
|
||||
title_resolution: crate::app::session_startup::TitleResolution::PinnedPreSandbox,
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: an ambiguous title now fails at the pin, before the
|
||||
/// irreversible sandbox, instead of deferring to materialization.
|
||||
#[serial_test::serial(GROK_HOME)]
|
||||
#[test]
|
||||
fn pin_ambiguous_title_errors_before_sandbox() {
|
||||
let mut fx = GrokHomeFixture::new();
|
||||
let cwd_str = fx.cwd_str();
|
||||
fx.write_summary(
|
||||
&cwd_str,
|
||||
"e0e0e0e0-1111-2222-3333-444444444444",
|
||||
serde_json::json!({ "generated_title": "Dup" }),
|
||||
);
|
||||
fx.write_summary(
|
||||
&cwd_str,
|
||||
"e0e0e0e0-1111-2222-3333-555555555555",
|
||||
serde_json::json!({ "generated_title": "Dup" }),
|
||||
);
|
||||
|
||||
let mut args = crate::app::cli::PagerArgs::try_parse_from(["grok", "-r", "Dup"]).unwrap();
|
||||
let msg = args
|
||||
.pin_local_resume_target_for_cwd(Some(&cwd_str))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
msg.contains("Multiple sessions match title"),
|
||||
"unexpected message: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a definitive pre-sandbox no-match must not be re-selected by
|
||||
/// title at materialization — a session created/renamed into the title after
|
||||
/// the sandbox would resume under an unverified profile.
|
||||
#[serial_test::serial(GROK_HOME)]
|
||||
#[tokio::test]
|
||||
async fn pinned_no_match_does_not_retry_title_after_sandbox() {
|
||||
let mut fx = GrokHomeFixture::new();
|
||||
let cwd_str = fx.cwd_str();
|
||||
|
||||
let mut args = crate::app::cli::PagerArgs::try_parse_from(["grok", "-r", "ghost"]).unwrap();
|
||||
args.pin_local_resume_target_for_cwd(Some(&cwd_str))
|
||||
.unwrap();
|
||||
assert!(args.resume_target_pinned);
|
||||
assert_eq!(args.session_to_resume(), Some("ghost"));
|
||||
|
||||
// The title appears only after the pin (and the sandbox).
|
||||
let late = "f0f0f0f0-1111-2222-3333-444444444444";
|
||||
fx.write_summary(
|
||||
&cwd_str,
|
||||
late,
|
||||
serde_json::json!({ "generated_title": "ghost", "title_is_manual": true }),
|
||||
);
|
||||
|
||||
use crate::app::session_startup::{
|
||||
MaterializeCtx, TitleResolution, materialize_startup_for_cwd,
|
||||
};
|
||||
let intent = args.session_startup_intent().unwrap();
|
||||
let msg = materialize_startup_for_cwd(pinned_local_ctx(), intent.clone(), &cwd_str)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
msg.contains("no session id or title matched"),
|
||||
"must not resume the late title match: {msg}"
|
||||
);
|
||||
// Contrast: an unpinned caller (no pre-sandbox pin ran) may still select
|
||||
// the title — the gate, not the data, decides.
|
||||
let allowed_ctx = MaterializeCtx {
|
||||
title_resolution: TitleResolution::Allowed,
|
||||
..pinned_local_ctx()
|
||||
};
|
||||
let out = materialize_startup_for_cwd(allowed_ctx, intent, &cwd_str)
|
||||
.await
|
||||
.unwrap();
|
||||
match out {
|
||||
crate::app::session_startup::MaterializedStartup::Resume { session_id, .. } => {
|
||||
assert_eq!(session_id, late);
|
||||
}
|
||||
other => panic!("expected Resume, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: a pinned non-UUID id that vanishes before materialization
|
||||
/// must not be reinterpreted as another session's title.
|
||||
#[serial_test::serial(GROK_HOME)]
|
||||
#[tokio::test]
|
||||
async fn pinned_non_uuid_id_is_not_reinterpreted_as_title() {
|
||||
let mut fx = GrokHomeFixture::new();
|
||||
let cwd_str = fx.cwd_str();
|
||||
fx.write_summary(&cwd_str, "legacy-remote-7", serde_json::json!({}));
|
||||
|
||||
let mut args =
|
||||
crate::app::cli::PagerArgs::try_parse_from(["grok", "-r", "legacy-remote-7"]).unwrap();
|
||||
args.pin_local_resume_target_for_cwd(Some(&cwd_str))
|
||||
.unwrap();
|
||||
assert!(args.resume_target_pinned);
|
||||
assert_eq!(args.session_to_resume(), Some("legacy-remote-7"));
|
||||
|
||||
// The pinned id vanishes; a decoy session gains it as a title.
|
||||
fx.remove_session(&cwd_str, "legacy-remote-7");
|
||||
fx.write_summary(
|
||||
&cwd_str,
|
||||
"f1f1f1f1-1111-2222-3333-444444444444",
|
||||
serde_json::json!({ "generated_title": "legacy-remote-7", "title_is_manual": true }),
|
||||
);
|
||||
|
||||
use crate::app::session_startup::materialize_startup_for_cwd;
|
||||
let intent = args.session_startup_intent().unwrap();
|
||||
let msg = materialize_startup_for_cwd(pinned_local_ctx(), intent, &cwd_str)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
msg.contains("no session id or title matched"),
|
||||
"must not resume the decoy titled with the pinned id: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a legacy id duplicated across cwd dirs is ambiguous to the
|
||||
/// session listings (`RelocationView::select` drops multi-path journal-less
|
||||
/// ids before the cwd filter), so its title never reaches selection: the pin
|
||||
/// stays unresolved, the profile peek finds nothing, and materialization
|
||||
/// fails closed with the hint instead of resuming under an unverified
|
||||
/// profile. The carried-profile path for unique ids is pinned by
|
||||
/// `pin_title_resume_finds_saved_profile_and_conflicts`.
|
||||
#[serial_test::serial(GROK_HOME)]
|
||||
#[tokio::test]
|
||||
async fn duplicate_legacy_id_is_not_title_addressable() {
|
||||
let mut fx = GrokHomeFixture::new();
|
||||
let cwd_str = fx.cwd_str();
|
||||
fx.write_summary(
|
||||
&cwd_str,
|
||||
"legacy-twin",
|
||||
serde_json::json!({
|
||||
"generated_title": "Locked Down",
|
||||
"title_is_manual": true,
|
||||
"sandbox_profile": "strict",
|
||||
}),
|
||||
);
|
||||
let other_cwd = tempfile::tempdir().expect("other cwd tempdir");
|
||||
let other_str = other_cwd.path().to_string_lossy().to_string();
|
||||
fx.write_summary(
|
||||
&other_str,
|
||||
"legacy-twin",
|
||||
serde_json::json!({ "sandbox_profile": "off" }),
|
||||
);
|
||||
|
||||
let mut args =
|
||||
crate::app::cli::PagerArgs::try_parse_from(["grok", "-r", "locked down"]).unwrap();
|
||||
args.pin_local_resume_target_for_cwd(Some(&cwd_str))
|
||||
.unwrap();
|
||||
assert!(args.resume_target_pinned);
|
||||
assert_eq!(args.session_to_resume(), Some("locked down"));
|
||||
assert!(args.saved_resume_profile_for_cwd(Some(&cwd_str)).is_none());
|
||||
|
||||
use crate::app::session_startup::{
|
||||
MaterializeCtx, TitleResolution, materialize_startup_for_cwd,
|
||||
};
|
||||
let ctx = MaterializeCtx::from_pager_args(&args);
|
||||
assert_eq!(ctx.title_resolution, TitleResolution::PinnedPreSandbox);
|
||||
|
||||
let intent = args.session_startup_intent().unwrap();
|
||||
let msg = materialize_startup_for_cwd(pinned_local_ctx(), intent, &cwd_str)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
msg.contains("no session id or title matched"),
|
||||
"duplicate-id session must fail closed, not resume: {msg}"
|
||||
);
|
||||
}
|
||||
|
|
@ -166,7 +166,7 @@ fn tmux_config_and_reload_notes_output_is_stable() {
|
|||
&terminal,
|
||||
TmuxProbeFacts {
|
||||
version: TmuxProbeResult::Unavailable,
|
||||
extended_keys: TmuxProbeResult::Unavailable,
|
||||
extended_keys: TmuxProbeResult::Available("off".to_owned()),
|
||||
set_clipboard: TmuxProbeResult::Available("off".to_owned()),
|
||||
allow_passthrough_support: TmuxProbeResult::Available(()),
|
||||
allow_passthrough: TmuxProbeResult::Available("off".to_owned()),
|
||||
|
|
@ -200,14 +200,17 @@ fn tmux_config_and_reload_notes_output_is_stable() {
|
|||
"Issues (3)\n",
|
||||
"\n",
|
||||
" ! terminal.tmux-clipboard `set-clipboard` is off in tmux, so OSC 52 clipboard copies are blocked\n",
|
||||
" Automatic setup: `grok doctor fix tmux-clipboard`\n",
|
||||
" Add `set -g set-clipboard on` to ~/.byobu/.tmux.conf\n",
|
||||
" Note: Reload tmux with `tmux source-file ~/.byobu/.tmux.conf`, or detach and reattach.\n",
|
||||
"\n",
|
||||
" ! terminal.dcs-passthrough `allow-passthrough` is off in tmux, which can block clipboard copies in nested sessions\n",
|
||||
" Add `set -g allow-passthrough on` to ~/.byobu/.tmux.conf\n",
|
||||
" Automatic setup: `grok doctor fix dcs-passthrough`\n",
|
||||
" Add `set -wg allow-passthrough on` to ~/.byobu/.tmux.conf\n",
|
||||
" Note: Reload tmux with `tmux source-file ~/.byobu/.tmux.conf`, or detach and reattach.\n",
|
||||
"\n",
|
||||
" ! terminal.tmux-extended-keys `extended-keys` is off in tmux, so some shortcuts may not work\n",
|
||||
" Automatic setup: `grok doctor fix tmux-extended-keys`\n",
|
||||
" Add `set -g extended-keys on` to ~/.byobu/.tmux.conf\n",
|
||||
" Note: Reload tmux with `tmux source-file ~/.byobu/.tmux.conf`, or detach and reattach.\n",
|
||||
)
|
||||
|
|
@ -503,11 +506,12 @@ fn runtime_merge_does_not_duplicate_view_findings() {
|
|||
for id in [
|
||||
"terminal.tmux-clipboard",
|
||||
"terminal.dcs-passthrough",
|
||||
"terminal.tmux-extended-keys",
|
||||
"terminal.limited-color",
|
||||
] {
|
||||
assert_eq!(output.matches(id).count(), 1, "{id}:\n{output}");
|
||||
}
|
||||
assert!(output.contains("Issues (3)"), "{output}");
|
||||
assert!(output.contains("Issues (4)"), "{output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -611,6 +615,12 @@ fn keyboard_fact_formats_from_explicit_target_evidence() {
|
|||
multiplexer: MultiplexerKind::Undetected,
|
||||
byobu: None,
|
||||
ssh: false,
|
||||
tmux: crate::diagnostics::TmuxFacts {
|
||||
extended_keys: crate::diagnostics::TmuxOptionFact::Unavailable,
|
||||
set_clipboard: crate::diagnostics::TmuxOptionFact::Unavailable,
|
||||
allow_passthrough_support: crate::diagnostics::TmuxSupportFact::Unavailable,
|
||||
allow_passthrough: crate::diagnostics::TmuxOptionFact::Unavailable,
|
||||
},
|
||||
color: ColorFacts {
|
||||
level: RuntimeFact::Available(ColorLevel::TrueColor),
|
||||
available_themes: crate::theme::ThemeKind::ALL.to_vec(),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -12,6 +12,12 @@ pub(super) fn report() -> DiagnosticReport {
|
|||
multiplexer: MultiplexerKind::Undetected,
|
||||
byobu: None,
|
||||
ssh: false,
|
||||
tmux: crate::diagnostics::TmuxFacts {
|
||||
extended_keys: crate::diagnostics::TmuxOptionFact::Unavailable,
|
||||
set_clipboard: crate::diagnostics::TmuxOptionFact::Unavailable,
|
||||
allow_passthrough_support: crate::diagnostics::TmuxSupportFact::Unavailable,
|
||||
allow_passthrough: crate::diagnostics::TmuxOptionFact::Unavailable,
|
||||
},
|
||||
color: crate::diagnostics::ColorFacts {
|
||||
level: crate::diagnostics::RuntimeFact::Unavailable,
|
||||
available_themes: Vec::new(),
|
||||
|
|
@ -71,12 +77,7 @@ fn terminal() -> TerminalContext {
|
|||
}
|
||||
|
||||
pub(super) fn request(home: &Path, shell: &str) -> FixRequest {
|
||||
FixRequest {
|
||||
id: SSH_WRAP_ID,
|
||||
home: home.to_path_buf(),
|
||||
shell: Some(PathBuf::from(shell)),
|
||||
validator: None,
|
||||
}
|
||||
FixRequest::new_for_test(SSH_WRAP_ID, home, Some(PathBuf::from(shell)), None, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -101,10 +102,13 @@ fn applicable_fix_listing_uses_report_metadata_and_planner_availability() {
|
|||
let report = report();
|
||||
let local = terminal();
|
||||
let local_fixes = applicable_automatic_fixes_with(&report, &local, |id| {
|
||||
Ok(FixRequest {
|
||||
FixRequest::new_for_test(
|
||||
id,
|
||||
..request(temp.path(), "/bin/bash")
|
||||
})
|
||||
temp.path(),
|
||||
Some(PathBuf::from("/bin/bash")),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
local_fixes,
|
||||
|
|
@ -132,6 +136,557 @@ fn applicable_fix_listing_uses_report_metadata_and_planner_availability() {
|
|||
);
|
||||
}
|
||||
|
||||
fn tmux_terminal(byobu: bool) -> TerminalContext {
|
||||
TerminalContext {
|
||||
multiplexer: MultiplexerKind::Tmux,
|
||||
byobu: byobu.then_some(crate::terminal::ByobuBackend::Tmux),
|
||||
tmux_version: Some("tmux 3.4".to_owned()),
|
||||
tmux_extended_keys: Some("off".to_owned()),
|
||||
..terminal()
|
||||
}
|
||||
}
|
||||
|
||||
fn tmux_report(id: DiagnosticId, evidence: TmuxEvidence) -> DiagnosticReport {
|
||||
let mut report = report();
|
||||
report.findings.clear();
|
||||
report.facts.multiplexer = MultiplexerKind::Tmux;
|
||||
report.facts.tmux = crate::diagnostics::TmuxFacts {
|
||||
extended_keys: crate::diagnostics::TmuxOptionFact::Available(
|
||||
if evidence == TmuxEvidence::ExtendedKeys {
|
||||
"off"
|
||||
} else {
|
||||
"on"
|
||||
}
|
||||
.to_owned(),
|
||||
),
|
||||
set_clipboard: crate::diagnostics::TmuxOptionFact::Available(
|
||||
if evidence == TmuxEvidence::Clipboard {
|
||||
"off"
|
||||
} else {
|
||||
"on"
|
||||
}
|
||||
.to_owned(),
|
||||
),
|
||||
allow_passthrough_support: crate::diagnostics::TmuxSupportFact::Supported,
|
||||
allow_passthrough: crate::diagnostics::TmuxOptionFact::Available(
|
||||
if evidence == TmuxEvidence::DcsPassthrough {
|
||||
"off"
|
||||
} else {
|
||||
"on"
|
||||
}
|
||||
.to_owned(),
|
||||
),
|
||||
};
|
||||
report.findings.push(DiagnosticFinding {
|
||||
id,
|
||||
disposition: FindingDisposition::Issue,
|
||||
message: "tmux option disabled".to_owned(),
|
||||
remediation: None,
|
||||
automatic_remediation: automatic_remediation_for(id),
|
||||
note: None,
|
||||
});
|
||||
report
|
||||
}
|
||||
|
||||
fn tmux_request(home: &Path, id: DiagnosticId) -> FixRequest {
|
||||
FixRequest::new_for_test(id, home, None, None, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_fix_registry_resolves_every_short_and_canonical_id() {
|
||||
for (id, handle, _) in automatic_fix_choices() {
|
||||
assert_eq!(resolve_fix_id(handle).unwrap(), id);
|
||||
assert_eq!(resolve_fix_id(&id.to_string()).unwrap(), id);
|
||||
assert_eq!(
|
||||
human_fix_command(id).unwrap(),
|
||||
format!("grok doctor fix {handle}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_fix_is_available_here_in_remote_sessions_while_ssh_wrap_stays_local_only() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut terminal = tmux_terminal(false);
|
||||
terminal.is_ssh = true;
|
||||
let mut report = tmux_report(TMUX_CLIPBOARD_ID, TmuxEvidence::Clipboard);
|
||||
report.facts.ssh = true;
|
||||
assert_eq!(
|
||||
applicable_automatic_fixes_with(&report, &terminal, |id| {
|
||||
Ok(tmux_request(temp.path(), id))
|
||||
}),
|
||||
vec![(
|
||||
TMUX_CLIPBOARD_ID,
|
||||
"tmux-clipboard",
|
||||
AutomaticFixAvailability::Here,
|
||||
)]
|
||||
);
|
||||
assert!(
|
||||
plan_fix(
|
||||
tmux_request(temp.path(), TMUX_CLIPBOARD_ID),
|
||||
&report,
|
||||
&terminal,
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_specs_plan_exact_independent_managed_items() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
for (id, evidence, line) in [
|
||||
(
|
||||
TMUX_CLIPBOARD_ID,
|
||||
TmuxEvidence::Clipboard,
|
||||
"set -g set-clipboard on",
|
||||
),
|
||||
(
|
||||
DCS_PASSTHROUGH_ID,
|
||||
TmuxEvidence::DcsPassthrough,
|
||||
"set -wg allow-passthrough on",
|
||||
),
|
||||
(
|
||||
TMUX_EXTENDED_KEYS_ID,
|
||||
TmuxEvidence::ExtendedKeys,
|
||||
"set -g extended-keys on",
|
||||
),
|
||||
] {
|
||||
let plan = plan_fix(
|
||||
tmux_request(temp.path(), id),
|
||||
&tmux_report(id, evidence),
|
||||
&tmux_terminal(false),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(plan.change().requested_path, temp.path().join(".tmux.conf"));
|
||||
assert!(
|
||||
plan.change()
|
||||
.block
|
||||
.contains(&format!("# >>> {id} >>>\n{line}\n# <<< {id} <<<"))
|
||||
);
|
||||
assert!(!plan.change().block.contains("terminal.ssh-wrap"));
|
||||
let preview = format_fix_preview(&plan);
|
||||
assert!(preview.contains("does not reload or modify the live tmux server"));
|
||||
assert!(preview.contains("Run /doctor again to verify the live setting"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_absolute_directory_rejects_hostile_home_and_byobu_values() {
|
||||
for value in [
|
||||
".",
|
||||
"..",
|
||||
"/",
|
||||
"relative",
|
||||
"/tmp/../escape",
|
||||
"/tmp/bad\nname",
|
||||
"~/x",
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
SafeAbsoluteDirectory::parse(PathBuf::from(value), "HOME"),
|
||||
Err(FixError::UnsafeDirectory { .. })
|
||||
),
|
||||
"{value:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_instruction_shell_quotes_and_markdown_escapes_paths() {
|
||||
assert_eq!(
|
||||
reload_instruction(Path::new("/tmp/a b/q'v.conf")),
|
||||
"Reload tmux with `tmux source-file '/tmp/a b/q'\\''v.conf'`, or detach and reattach."
|
||||
);
|
||||
assert_eq!(
|
||||
reload_instruction(Path::new("/tmp/a`b.conf")),
|
||||
"Reload tmux with ``tmux source-file '/tmp/a`b.conf'``, or detach and reattach."
|
||||
);
|
||||
assert_eq!(
|
||||
shell_quote_path(Path::new("/tmp/a`b.conf")).unwrap(),
|
||||
"'/tmp/a`b.conf'"
|
||||
);
|
||||
assert_eq!(
|
||||
reload_instruction(Path::new("/tmp/bad\npath")),
|
||||
"Detach and reattach to activate the persistent tmux setting."
|
||||
);
|
||||
assert_eq!(markdown_code_path(Path::new("/tmp/a`b")), "``/tmp/a`b``");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn full_preview_safely_renders_backtick_requested_symlink_target_and_backup_paths() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let home = root.join("home`dir");
|
||||
let target_dir = root.join("target`dir");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
std::fs::create_dir_all(&target_dir).unwrap();
|
||||
let target = target_dir.join("tmux`target.conf");
|
||||
std::fs::write(&target, "set -g mouse on\n").unwrap();
|
||||
symlink(&target, home.join(".tmux.conf")).unwrap();
|
||||
let plan = plan_fix(
|
||||
tmux_request(&home, TMUX_CLIPBOARD_ID),
|
||||
&tmux_report(TMUX_CLIPBOARD_ID, TmuxEvidence::Clipboard),
|
||||
&tmux_terminal(false),
|
||||
)
|
||||
.unwrap();
|
||||
let preview = format_fix_preview(&plan);
|
||||
assert!(preview.contains("File: ``"), "{preview}");
|
||||
assert!(preview.contains("Actual file: ``"), "{preview}");
|
||||
assert!(preview.contains("Backup will be saved to: ``"), "{preview}");
|
||||
assert!(preview.contains("home`dir/.tmux.conf"), "{preview}");
|
||||
assert!(preview.contains("tmux`target.conf"), "{preview}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_plain_byobu_and_custom_config_paths_are_physical() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let report = tmux_report(TMUX_CLIPBOARD_ID, TmuxEvidence::Clipboard);
|
||||
let plain = plan_fix(
|
||||
tmux_request(temp.path(), TMUX_CLIPBOARD_ID),
|
||||
&report,
|
||||
&tmux_terminal(false),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
plain.change().requested_path,
|
||||
temp.path().join(".tmux.conf")
|
||||
);
|
||||
assert!(
|
||||
!plain
|
||||
.change()
|
||||
.requested_path
|
||||
.to_string_lossy()
|
||||
.contains('~')
|
||||
);
|
||||
|
||||
let custom = FixRequest::new_for_test(
|
||||
TMUX_CLIPBOARD_ID,
|
||||
temp.path(),
|
||||
None,
|
||||
None,
|
||||
Some(temp.path().join("custom-byobu")),
|
||||
)
|
||||
.unwrap();
|
||||
let byobu = plan_fix(custom, &report, &tmux_terminal(true)).unwrap();
|
||||
assert_eq!(
|
||||
byobu.change().requested_path,
|
||||
temp.path().join("custom-byobu/.tmux.conf")
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
plan_fix(
|
||||
tmux_request(temp.path(), TMUX_CLIPBOARD_ID),
|
||||
&report,
|
||||
&tmux_terminal(true)
|
||||
),
|
||||
Err(FixError::ByobuConfigUnavailable)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_managed_items_coexist_and_each_apply_is_one_transaction() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join(".tmux.conf");
|
||||
for (id, evidence, line) in [
|
||||
(
|
||||
TMUX_CLIPBOARD_ID,
|
||||
TmuxEvidence::Clipboard,
|
||||
"set -g set-clipboard on",
|
||||
),
|
||||
(
|
||||
DCS_PASSTHROUGH_ID,
|
||||
TmuxEvidence::DcsPassthrough,
|
||||
"set -wg allow-passthrough on",
|
||||
),
|
||||
(
|
||||
TMUX_EXTENDED_KEYS_ID,
|
||||
TmuxEvidence::ExtendedKeys,
|
||||
"set -g extended-keys on",
|
||||
),
|
||||
] {
|
||||
let plan = plan_fix(
|
||||
tmux_request(temp.path(), id),
|
||||
&tmux_report(id, evidence),
|
||||
&tmux_terminal(false),
|
||||
)
|
||||
.unwrap();
|
||||
let outcome = apply_fix(plan).unwrap();
|
||||
assert_eq!(outcome.activation(), FixActivation::RequiresReload);
|
||||
assert_eq!(outcome.changed_path(), path);
|
||||
assert!(format_fix_success(&outcome).contains("Run /doctor again"));
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains(line));
|
||||
}
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(content.matches("# >>> grok doctor >>>").count(), 1);
|
||||
for id in [TMUX_CLIPBOARD_ID, DCS_PASSTHROUGH_ID, TMUX_EXTENDED_KEYS_ID] {
|
||||
assert_eq!(content.matches(&format!("# >>> {id} >>>")).count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_scanner_handles_server_scopes_separators_prefixes_and_native_blocks() {
|
||||
let path = Path::new("/tmp/tmux.conf");
|
||||
for spec in [&TMUX_CLIPBOARD_SPEC, &TMUX_EXTENDED_KEYS_SPEC] {
|
||||
let healthy = spec.healthy_values[0];
|
||||
for assignment in [
|
||||
format!("set {} {healthy}\n", spec.option),
|
||||
format!("set -s {} {healthy}\n", spec.option),
|
||||
format!("set-option -gq {} {healthy}\n", spec.option),
|
||||
format!("set -w {} {healthy}\n", spec.option),
|
||||
format!("FOO=bar set -g {} {healthy}\n", spec.option),
|
||||
format!("set -g mouse on; set -g {} {healthy}\n", spec.option),
|
||||
] {
|
||||
assert_eq!(
|
||||
scan_direct_tmux_option(&assignment, path, spec).unwrap(),
|
||||
DirectOptionState::Healthy,
|
||||
"{assignment:?}"
|
||||
);
|
||||
}
|
||||
for conflict in [
|
||||
format!("set {} off\n", spec.option),
|
||||
format!("set -s {} off\n", spec.option),
|
||||
format!("set-option -g {} off\n", spec.option),
|
||||
format!("set -w {} off\n", spec.option),
|
||||
format!("set -g mouse on; set -g {} off\n", spec.option),
|
||||
format!("set -g {} o\\\nff\n", spec.option),
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
scan_direct_tmux_option(&conflict, path, spec),
|
||||
Err(FixError::ExistingCustomization { .. })
|
||||
),
|
||||
"{conflict:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let spec = &DCS_PASSTHROUGH_SPEC;
|
||||
for healthy in [
|
||||
"setw -g allow-passthrough on\n",
|
||||
"set-window-option -g allow-passthrough all\n",
|
||||
"set -wg allow-passthrough on\n",
|
||||
] {
|
||||
assert_eq!(
|
||||
scan_direct_tmux_option(healthy, path, spec).unwrap(),
|
||||
DirectOptionState::Healthy,
|
||||
"{healthy:?}"
|
||||
);
|
||||
}
|
||||
for conflict in [
|
||||
"setw -g allow-passthrough off\n",
|
||||
"set-window-option -g allow-passthrough off\n",
|
||||
"set -wg allow-passthrough off\n",
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
scan_direct_tmux_option(conflict, path, spec),
|
||||
Err(FixError::ExistingCustomization { .. })
|
||||
),
|
||||
"{conflict:?}"
|
||||
);
|
||||
}
|
||||
for local in [
|
||||
"set allow-passthrough on\n",
|
||||
"setw allow-passthrough on\n",
|
||||
"setw -t:1 allow-passthrough off\n",
|
||||
] {
|
||||
assert_eq!(
|
||||
scan_direct_tmux_option(local, path, spec).unwrap(),
|
||||
DirectOptionState::Absent,
|
||||
"{local:?}"
|
||||
);
|
||||
}
|
||||
|
||||
for spec in [
|
||||
&TMUX_CLIPBOARD_SPEC,
|
||||
&DCS_PASSTHROUGH_SPEC,
|
||||
&TMUX_EXTENDED_KEYS_SPEC,
|
||||
] {
|
||||
for ignored in [
|
||||
format!("# set -g {} off\n", spec.option),
|
||||
format!("set -g @{} off\n", spec.option),
|
||||
format!("set -g {}-copy off\n", spec.option),
|
||||
format!("%if 1\nset -g {} off\n%endif\n", spec.option),
|
||||
format!("if-shell true {{ set -g {} off }}\n", spec.option),
|
||||
] {
|
||||
assert_eq!(
|
||||
scan_direct_tmux_option(&ignored, path, spec).unwrap(),
|
||||
DirectOptionState::Absent,
|
||||
"{ignored:?}"
|
||||
);
|
||||
}
|
||||
for ambiguous in [
|
||||
format!("se -g {} off\n", spec.option),
|
||||
format!("set -g {} off extra\n", spec.option),
|
||||
format!("set -g {}\n", spec.option),
|
||||
format!("set -g {} 'unterminated\n", spec.option),
|
||||
format!("set -g {} \\\n", spec.option),
|
||||
format!("set -t target\nset -g {} off\n", spec.option),
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
scan_direct_tmux_option(&ambiguous, path, spec),
|
||||
Err(FixError::ExistingCustomization { .. })
|
||||
),
|
||||
"{ambiguous:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_direct_form_after_managed_block_fails_persistent_verification() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join(".tmux.conf");
|
||||
for conflict in [
|
||||
"set set-clipboard off",
|
||||
"set -s set-clipboard off",
|
||||
"set-option -g set-clipboard off",
|
||||
"set -g mouse on; set -g set-clipboard off",
|
||||
"se -g set-clipboard off",
|
||||
] {
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"# >>> grok doctor >>>\n# >>> terminal.tmux-clipboard >>>\nset -g set-clipboard on\n# <<< terminal.tmux-clipboard <<<\n# <<< grok doctor <<<\n{conflict}\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!tmux_option_configured(&path, &TMUX_CLIPBOARD_SPEC),
|
||||
"{conflict}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthy_direct_does_not_suppress_repair_of_noncanonical_managed_item() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join(".tmux.conf");
|
||||
let report = tmux_report(TMUX_CLIPBOARD_ID, TmuxEvidence::Clipboard);
|
||||
for content in [
|
||||
"set -g set-clipboard on\n# >>> grok doctor >>>\n# >>> terminal.tmux-clipboard >>>\nset -g set-clipboard off\n# <<< terminal.tmux-clipboard <<<\n# <<< grok doctor <<<\n",
|
||||
"# >>> grok doctor >>>\n# >>> terminal.tmux-clipboard >>>\nset -g set-clipboard off\n# <<< terminal.tmux-clipboard <<<\n# <<< grok doctor <<<\nset -g set-clipboard on\n",
|
||||
] {
|
||||
std::fs::write(&path, content).unwrap();
|
||||
let plan = plan_fix(
|
||||
tmux_request(temp.path(), TMUX_CLIPBOARD_ID),
|
||||
&report,
|
||||
&tmux_terminal(false),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(format_fix_preview(&plan).contains("Text to add:\n"));
|
||||
let outcome = apply_fix(plan).unwrap();
|
||||
assert_eq!(outcome.status(), FixStatus::Applied);
|
||||
assert!(
|
||||
std::fs::read_to_string(&path)
|
||||
.unwrap()
|
||||
.contains("# >>> terminal.tmux-clipboard >>>\nset -g set-clipboard on\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_applicability_uses_exact_positive_probe_gates() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let terminal = tmux_terminal(false);
|
||||
let mut clipboard = tmux_report(TMUX_CLIPBOARD_ID, TmuxEvidence::Clipboard);
|
||||
clipboard.facts.tmux.set_clipboard =
|
||||
crate::diagnostics::TmuxOptionFact::Available("external".to_owned());
|
||||
assert!(matches!(
|
||||
plan_fix(
|
||||
tmux_request(temp.path(), TMUX_CLIPBOARD_ID),
|
||||
&clipboard,
|
||||
&terminal
|
||||
),
|
||||
Err(FixError::TmuxNotApplicable)
|
||||
));
|
||||
|
||||
let mut dcs = tmux_report(DCS_PASSTHROUGH_ID, TmuxEvidence::DcsPassthrough);
|
||||
for support in [
|
||||
crate::diagnostics::TmuxSupportFact::Unsupported,
|
||||
crate::diagnostics::TmuxSupportFact::Unavailable,
|
||||
crate::diagnostics::TmuxSupportFact::Error,
|
||||
] {
|
||||
dcs.facts.tmux.allow_passthrough_support = support;
|
||||
assert!(matches!(
|
||||
plan_fix(
|
||||
tmux_request(temp.path(), DCS_PASSTHROUGH_ID),
|
||||
&dcs,
|
||||
&terminal
|
||||
),
|
||||
Err(FixError::TmuxNotApplicable)
|
||||
));
|
||||
}
|
||||
|
||||
let mut extended = tmux_report(TMUX_EXTENDED_KEYS_ID, TmuxEvidence::ExtendedKeys);
|
||||
extended.facts.tmux.extended_keys = crate::diagnostics::TmuxOptionFact::Unavailable;
|
||||
assert!(matches!(
|
||||
plan_fix(
|
||||
tmux_request(temp.path(), TMUX_EXTENDED_KEYS_ID),
|
||||
&extended,
|
||||
&terminal
|
||||
),
|
||||
Err(FixError::TmuxNotApplicable)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_stale_plan_and_idempotence_reuse_managed_writer_safety() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join(".tmux.conf");
|
||||
std::fs::write(&path, "set -g mouse on\n").unwrap();
|
||||
let report = tmux_report(TMUX_CLIPBOARD_ID, TmuxEvidence::Clipboard);
|
||||
let plan = plan_fix(
|
||||
tmux_request(temp.path(), TMUX_CLIPBOARD_ID),
|
||||
&report,
|
||||
&tmux_terminal(false),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(&path, "set -g mouse off\n").unwrap();
|
||||
assert!(matches!(
|
||||
apply_fix(plan),
|
||||
Err(FixError::TmuxManaged(
|
||||
xai_grok_config::managed_text::ManagedConfigError::StalePlan(_)
|
||||
))
|
||||
));
|
||||
|
||||
std::fs::write(&path, "set -g set-clipboard on\n").unwrap();
|
||||
let plan = plan_fix(
|
||||
tmux_request(temp.path(), TMUX_CLIPBOARD_ID),
|
||||
&report,
|
||||
&tmux_terminal(false),
|
||||
)
|
||||
.unwrap();
|
||||
let preview = format_fix_preview(&plan);
|
||||
assert!(preview.contains("Text to add: None"), "{preview}");
|
||||
assert!(!preview.contains("Backup will be saved"), "{preview}");
|
||||
let outcome = apply_fix(plan).unwrap();
|
||||
assert_eq!(outcome.status(), FixStatus::AlreadyConfigured);
|
||||
assert!(verify_persistent_fix(&outcome));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).unwrap(),
|
||||
"set -g set-clipboard on\n"
|
||||
);
|
||||
|
||||
let stale = plan_fix(
|
||||
tmux_request(temp.path(), TMUX_CLIPBOARD_ID),
|
||||
&report,
|
||||
&tmux_terminal(false),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(&path, "set -g set-clipboard off\n").unwrap();
|
||||
assert!(matches!(
|
||||
apply_fix(stale),
|
||||
Err(FixError::TmuxManaged(
|
||||
xai_grok_config::managed_text::ManagedConfigError::StalePlan(_)
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_zsh_and_fish_plans_use_exact_paths_and_aliases() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
|
@ -145,22 +700,26 @@ fn bash_zsh_and_fish_plans_use_exact_paths_and_aliases() {
|
|||
),
|
||||
] {
|
||||
let plan = plan_fix(request(temp.path(), shell), &report(), &terminal()).unwrap();
|
||||
assert_eq!(plan.id, SSH_WRAP_ID);
|
||||
assert_eq!(plan.changes[0].requested_path, temp.path().join(relative));
|
||||
assert_eq!(plan.id(), SSH_WRAP_ID);
|
||||
assert_eq!(plan.change().requested_path, temp.path().join(relative));
|
||||
assert_eq!(
|
||||
plan.changes[0].block,
|
||||
plan.change().block,
|
||||
format!(
|
||||
"# >>> grok doctor >>>\n# >>> terminal.ssh-wrap >>>\n{alias}\n# <<< terminal.ssh-wrap <<<\n# <<< grok doctor <<<"
|
||||
)
|
||||
);
|
||||
assert!(plan.caveats.iter().any(|line| line.contains("command ssh")));
|
||||
assert!(plan.caveats.iter().any(|line| line.contains("ssh -f")));
|
||||
assert!(
|
||||
plan.caveats
|
||||
plan.caveats()
|
||||
.iter()
|
||||
.any(|line| line.contains("command ssh"))
|
||||
);
|
||||
assert!(plan.caveats().iter().any(|line| line.contains("ssh -f")));
|
||||
assert!(
|
||||
plan.caveats()
|
||||
.iter()
|
||||
.any(|line| line.contains("ControlPersist"))
|
||||
);
|
||||
assert!(plan.caveats.iter().any(|line| line.contains("~^Z")));
|
||||
assert!(plan.caveats().iter().any(|line| line.contains("~^Z")));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -367,8 +926,8 @@ fn comments_and_managed_alias_do_not_create_false_conflicts() {
|
|||
.unwrap();
|
||||
let plan = plan_fix(request(temp.path(), "/bin/zsh"), &report(), &terminal()).unwrap();
|
||||
let outcome = apply_fix(plan).unwrap();
|
||||
assert_eq!(outcome.status, FixStatus::AlreadyConfigured);
|
||||
assert!(outcome.backup_path.is_none());
|
||||
assert_eq!(outcome.status(), FixStatus::AlreadyConfigured);
|
||||
assert!(outcome.backup_path().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -409,9 +968,42 @@ fn stale_plan_is_rejected_and_apply_verifies_postcondition() {
|
|||
|
||||
let plan = plan_fix(request(temp.path(), "/bin/bash"), &report(), &terminal()).unwrap();
|
||||
let outcome = apply_fix(plan).unwrap();
|
||||
assert_eq!(outcome.status, FixStatus::Applied);
|
||||
assert_eq!(outcome.id, SSH_WRAP_ID);
|
||||
assert_eq!(outcome.status(), FixStatus::Applied);
|
||||
assert_eq!(outcome.id(), SSH_WRAP_ID);
|
||||
assert_eq!(outcome.shell(), Some(ShellKind::Bash));
|
||||
assert!(managed_alias_configured(&path, ShellKind::Bash));
|
||||
assert!(outcome.managed_alias_is_configured());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_wrap_outcome_verifies_with_planned_shell_not_process_shell() {
|
||||
// Post-apply verification must use the shell stored on the outcome. Even if
|
||||
// `$SHELL` is missing or points at a different shell family, a successful
|
||||
// apply against bash must still report the managed alias as configured.
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join(".bashrc");
|
||||
let plan = plan_fix(request(temp.path(), "/bin/bash"), &report(), &terminal()).unwrap();
|
||||
let outcome = apply_fix(plan).unwrap();
|
||||
assert_eq!(outcome.shell(), Some(ShellKind::Bash));
|
||||
assert_eq!(outcome.changed_path(), path);
|
||||
assert!(outcome.managed_alias_is_configured());
|
||||
|
||||
// Fish uses a different alias syntax; checking the bash-written path with
|
||||
// fish must not count as configured. The outcome keeps bash regardless.
|
||||
assert!(!managed_alias_configured(&path, ShellKind::Fish));
|
||||
assert!(
|
||||
outcome.managed_alias_is_configured(),
|
||||
"outcome must keep the planned bash shell, not re-derive from $SHELL"
|
||||
);
|
||||
|
||||
let filtered = configured_report(report(), outcome.managed_alias_is_configured());
|
||||
assert!(
|
||||
!filtered
|
||||
.findings
|
||||
.iter()
|
||||
.any(|finding| finding.id == SSH_WRAP_ID),
|
||||
"configured_report must drop ssh-wrap when outcome shell matches the write"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -437,7 +1029,8 @@ fn configured_report_reaches_pass_state_only_for_exact_managed_alias() {
|
|||
healthy.findings.clear();
|
||||
let plan = plan_fix(request(temp.path(), "/bin/bash"), &healthy, &terminal()).unwrap();
|
||||
assert_eq!(
|
||||
plan.id, SSH_WRAP_ID,
|
||||
plan.id(),
|
||||
SSH_WRAP_ID,
|
||||
"healthy reports can plan idempotent setup"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,12 +20,15 @@ pub use doctor_format::format_doctor;
|
|||
#[cfg(test)]
|
||||
pub(crate) use fix::test_fix_plan;
|
||||
pub use fix::{
|
||||
AutomaticRemediation, FixError, FixOutcome, FixPlan, FixRequest, FixStatus, PlannedChange,
|
||||
SSH_WRAP_FIX_COMMAND, SSH_WRAP_ID, SSH_WRAP_ONE_OFF, ShellKind, apply_fix, configured_report,
|
||||
AutomaticRemediation, DCS_PASSTHROUGH_ID, FixActivation, FixError, FixOutcome, FixPlan,
|
||||
FixRequest, FixStatus, PlannedChange, SSH_WRAP_FIX_COMMAND, SSH_WRAP_ID, SSH_WRAP_ONE_OFF,
|
||||
ShellKind, TMUX_CLIPBOARD_ID, TMUX_EXTENDED_KEYS_ID, apply_fix, configured_report,
|
||||
managed_alias_configured, plan_fix, resolve_fix_id, ssh_wrap_automatic_remediation,
|
||||
verify_persistent_fix,
|
||||
};
|
||||
pub(crate) use fix::{
|
||||
format_applicable_automatic_fixes, format_fix_preview, human_fix_command, select_fix_plan,
|
||||
automatic_fix_choices, automatic_remediation_for, format_applicable_automatic_fixes,
|
||||
format_fix_preview, format_fix_success, human_fix_command, select_fix_plan,
|
||||
};
|
||||
pub(crate) use model::probe_requires_live_tui;
|
||||
pub(crate) use model::{
|
||||
|
|
@ -37,7 +40,7 @@ pub(crate) use model::{
|
|||
pub use model::{
|
||||
ClipboardFacts, ColorFacts, DataControlFact, DiagnosticFacts, DiagnosticFinding, DiagnosticId,
|
||||
DiagnosticReport, FindingDisposition, KeyboardFact, ManualRemediation, NewlineFact, ProbeNote,
|
||||
ProbeStatus, RuntimeFact, VoiceFacts,
|
||||
ProbeStatus, RuntimeFact, TmuxFacts, TmuxOptionFact, TmuxSupportFact, VoiceFacts,
|
||||
};
|
||||
pub use view::{DiagnosticSnapshot, view};
|
||||
|
||||
|
|
@ -298,7 +301,12 @@ pub(crate) fn collect_startup_warnings_from(
|
|||
warnings.extend(diagnose_clipboard_from_facts(tmux, &config_path));
|
||||
}
|
||||
|
||||
if ctx.kitty_skip_reason() == Some("tmux_extended_keys_off") {
|
||||
if ctx.is_tmux_backed()
|
||||
&& matches!(
|
||||
&tmux.extended_keys,
|
||||
probes::TmuxProbeResult::Available(value) if value == "off"
|
||||
)
|
||||
{
|
||||
let mut warning = TerminalWarning::new(
|
||||
WarningCategory::TmuxExtendedKeysOff,
|
||||
"`extended-keys` is off in tmux, so some shortcuts may not work",
|
||||
|
|
@ -619,7 +627,7 @@ pub(crate) fn collect_notification_warnings_with_method(
|
|||
let mut warning = TerminalWarning::new(
|
||||
WarningCategory::DcsPassthrough,
|
||||
"`allow-passthrough` is off in tmux, so terminal notifications are blocked",
|
||||
Some("set -g allow-passthrough on"),
|
||||
Some("set -wg allow-passthrough on"),
|
||||
Some(&config_path),
|
||||
);
|
||||
warning.note = Some(tmux_reload_note(&config_path));
|
||||
|
|
@ -774,7 +782,7 @@ pub fn diagnose_clipboard_from_values(
|
|||
let mut warning = TerminalWarning::new(
|
||||
WarningCategory::DcsPassthrough,
|
||||
"`allow-passthrough` is off in tmux, which can block clipboard copies in nested sessions",
|
||||
Some("set -g allow-passthrough on"),
|
||||
Some("set -wg allow-passthrough on"),
|
||||
Some(config_path),
|
||||
);
|
||||
warning.note = Some(tmux_reload_note(config_path));
|
||||
|
|
@ -1430,7 +1438,7 @@ mod tests {
|
|||
let w = diagnose_clipboard_from_values(Some("on"), true, Some("off"), "~/.tmux.conf");
|
||||
assert_eq!(w.len(), 1);
|
||||
assert_eq!(w[0].category, WarningCategory::DcsPassthrough);
|
||||
assert_eq!(w[0].fix.as_deref(), Some("set -g allow-passthrough on"));
|
||||
assert_eq!(w[0].fix.as_deref(), Some("set -wg allow-passthrough on"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2128,7 +2136,7 @@ mod tests {
|
|||
TerminalWarning::new(
|
||||
WarningCategory::DcsPassthrough,
|
||||
"DCS passthrough is disabled",
|
||||
Some("set -g allow-passthrough on"),
|
||||
Some("set -wg allow-passthrough on"),
|
||||
Some("~/.tmux.conf"),
|
||||
),
|
||||
TerminalWarning::new(
|
||||
|
|
@ -2306,7 +2314,13 @@ mod tests {
|
|||
|
||||
fn collect_extended_keys_warnings(ctx: &TerminalContext) -> Vec<TerminalWarning> {
|
||||
let query = FakeTmuxQuery::healthy_modern();
|
||||
collect_startup_warnings(ctx, &query, false, true)
|
||||
let mut snapshot = test_snapshot(ctx, &query, false, true, false, None);
|
||||
snapshot.tmux.extended_keys = ctx
|
||||
.tmux_extended_keys
|
||||
.clone()
|
||||
.map(probes::TmuxProbeResult::Available)
|
||||
.unwrap_or(probes::TmuxProbeResult::Unavailable);
|
||||
super::collect_startup_warnings(&snapshot)
|
||||
.into_iter()
|
||||
.filter(|w| w.category == WarningCategory::TmuxExtendedKeysOff)
|
||||
.collect()
|
||||
|
|
@ -2666,7 +2680,7 @@ mod tests {
|
|||
assert_eq!(w.len(), 1);
|
||||
assert_eq!(w[0].category, WarningCategory::DcsPassthrough);
|
||||
assert!(w[0].message.contains("notification"));
|
||||
assert_eq!(w[0].fix.as_deref(), Some("set -g allow-passthrough on"));
|
||||
assert_eq!(w[0].fix.as_deref(), Some("set -wg allow-passthrough on"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ pub struct DiagnosticFacts {
|
|||
pub multiplexer: MultiplexerKind,
|
||||
pub byobu: Option<ByobuBackend>,
|
||||
pub ssh: bool,
|
||||
pub tmux: TmuxFacts,
|
||||
pub color: ColorFacts,
|
||||
pub keyboard: Option<KeyboardFact>,
|
||||
pub newline: Option<NewlineFact>,
|
||||
|
|
@ -107,6 +108,30 @@ pub enum VoiceFacts {
|
|||
Missing { error: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TmuxFacts {
|
||||
pub extended_keys: TmuxOptionFact,
|
||||
pub set_clipboard: TmuxOptionFact,
|
||||
pub allow_passthrough_support: TmuxSupportFact,
|
||||
pub allow_passthrough: TmuxOptionFact,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum TmuxOptionFact {
|
||||
Available(String),
|
||||
Unsupported,
|
||||
Unavailable,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum TmuxSupportFact {
|
||||
Supported,
|
||||
Unsupported,
|
||||
Unavailable,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ColorFacts {
|
||||
pub level: RuntimeFact<ColorLevel>,
|
||||
|
|
|
|||
|
|
@ -138,6 +138,63 @@ pub fn collect_doctor_tui<'a>(
|
|||
/// Collect standalone evidence without running live tmux subprocesses; skipped
|
||||
/// tmux evidence is reported unavailable so a stuck server cannot block doctor.
|
||||
pub fn collect_standalone<'a>(terminal: &'a TerminalContext) -> StandaloneDiagnosticSnapshot<'a> {
|
||||
collect_standalone_with_tmux(terminal, unavailable_tmux())
|
||||
}
|
||||
|
||||
/// Collect bounded live tmux facts for explicit fix planning.
|
||||
pub fn collect_standalone_fix<'a>(
|
||||
terminal: &'a TerminalContext,
|
||||
id: Option<crate::diagnostics::DiagnosticId>,
|
||||
) -> StandaloneDiagnosticSnapshot<'a> {
|
||||
collect_standalone_with_tmux(terminal, collect_tmux_fix(terminal, id, &LiveTmuxProbe))
|
||||
}
|
||||
|
||||
fn collect_tmux_fix(
|
||||
terminal: &TerminalContext,
|
||||
id: Option<crate::diagnostics::DiagnosticId>,
|
||||
tmux: &dyn TmuxOptionQuery,
|
||||
) -> TmuxProbeFacts {
|
||||
if !terminal.is_tmux_backed() {
|
||||
return unavailable_tmux();
|
||||
}
|
||||
let wants = |candidate| id.is_none() || id == Some(candidate);
|
||||
let set_clipboard = if wants(crate::diagnostics::TMUX_CLIPBOARD_ID) {
|
||||
tmux.show_option("set-clipboard")
|
||||
} else {
|
||||
TmuxProbeResult::Unavailable
|
||||
};
|
||||
let extended_keys = if wants(crate::diagnostics::TMUX_EXTENDED_KEYS_ID) {
|
||||
tmux.show_option("extended-keys")
|
||||
} else {
|
||||
TmuxProbeResult::Unavailable
|
||||
};
|
||||
let (allow_passthrough_support, allow_passthrough) =
|
||||
if wants(crate::diagnostics::DCS_PASSTHROUGH_ID) {
|
||||
let support = tmux.option_support("allow-passthrough");
|
||||
let value = match &support {
|
||||
TmuxProbeResult::Available(()) => tmux.show_option("allow-passthrough"),
|
||||
TmuxProbeResult::Unsupported => TmuxProbeResult::Unsupported,
|
||||
TmuxProbeResult::Unavailable => TmuxProbeResult::Unavailable,
|
||||
TmuxProbeResult::Error(error) => TmuxProbeResult::Error(error.clone()),
|
||||
};
|
||||
(support, value)
|
||||
} else {
|
||||
(TmuxProbeResult::Unavailable, TmuxProbeResult::Unavailable)
|
||||
};
|
||||
TmuxProbeFacts {
|
||||
version: TmuxProbeResult::Unavailable,
|
||||
extended_keys,
|
||||
set_clipboard,
|
||||
allow_passthrough_support,
|
||||
allow_passthrough,
|
||||
control_mode: TmuxProbeResult::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_standalone_with_tmux<'a>(
|
||||
terminal: &'a TerminalContext,
|
||||
tmux: TmuxProbeFacts,
|
||||
) -> StandaloneDiagnosticSnapshot<'a> {
|
||||
let host_os = crate::host::HostOs::current();
|
||||
let display_server = crate::host::DisplayServer::current();
|
||||
let is_wayland = display_server == crate::host::DisplayServer::Wayland;
|
||||
|
|
@ -146,7 +203,7 @@ pub fn collect_standalone<'a>(terminal: &'a TerminalContext) -> StandaloneDiagno
|
|||
let container_no_display = xai_grok_shell::util::clipboard::is_containerized_without_display();
|
||||
collect_standalone_from(
|
||||
terminal,
|
||||
unavailable_tmux(),
|
||||
tmux,
|
||||
WaylandProbeFacts {
|
||||
is_wayland,
|
||||
data_control,
|
||||
|
|
@ -292,7 +349,7 @@ fn collect_tmux(
|
|||
.tmux_extended_keys
|
||||
.clone()
|
||||
.map(TmuxProbeResult::Available)
|
||||
.unwrap_or(TmuxProbeResult::Unavailable),
|
||||
.unwrap_or_else(|| tmux.show_option("extended-keys")),
|
||||
set_clipboard: tmux.show_option("set-clipboard"),
|
||||
allow_passthrough_support,
|
||||
allow_passthrough,
|
||||
|
|
@ -387,7 +444,11 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
fake.calls.into_inner(),
|
||||
["support:allow-passthrough", "set-clipboard"]
|
||||
[
|
||||
"support:allow-passthrough",
|
||||
"extended-keys",
|
||||
"set-clipboard"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -406,7 +467,12 @@ mod tests {
|
|||
assert_eq!(snapshot.tmux.control_mode, TmuxProbeResult::Available(true));
|
||||
assert_eq!(
|
||||
fake.calls.into_inner(),
|
||||
["support:allow-passthrough", "set-clipboard", "control-mode"]
|
||||
[
|
||||
"support:allow-passthrough",
|
||||
"extended-keys",
|
||||
"set-clipboard",
|
||||
"control-mode",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ use crate::diagnostics::probes::{
|
|||
use crate::diagnostics::{
|
||||
ClipboardFacts, ColorFacts, DataControlFact, DiagnosticFacts, DiagnosticFinding, DiagnosticId,
|
||||
DiagnosticReport, FindingDisposition, KeyboardFact, ManualRemediation, NewlineFact, ProbeNote,
|
||||
ProbeStatus, RuntimeFact, TerminalWarning, WarningCategory,
|
||||
ProbeStatus, RuntimeFact, TerminalWarning, TmuxFacts, TmuxOptionFact, TmuxSupportFact,
|
||||
WarningCategory,
|
||||
};
|
||||
use crate::terminal::TerminalName;
|
||||
|
||||
|
|
@ -227,6 +228,14 @@ fn facts(
|
|||
multiplexer: ctx.multiplexer,
|
||||
byobu: ctx.byobu,
|
||||
ssh: ctx.is_ssh,
|
||||
tmux: TmuxFacts {
|
||||
extended_keys: tmux_option_fact(&snapshot.common.tmux.extended_keys),
|
||||
set_clipboard: tmux_option_fact(&snapshot.common.tmux.set_clipboard),
|
||||
allow_passthrough_support: tmux_support_fact(
|
||||
&snapshot.common.tmux.allow_passthrough_support,
|
||||
),
|
||||
allow_passthrough: tmux_option_fact(&snapshot.common.tmux.allow_passthrough),
|
||||
},
|
||||
color: ColorFacts {
|
||||
level: match snapshot.color_level {
|
||||
RuntimeEvidence::Available(level) => RuntimeFact::Available(level),
|
||||
|
|
@ -508,8 +517,7 @@ fn finding(warning: TerminalWarning, disposition: FindingDisposition) -> Option<
|
|||
fix,
|
||||
config_path: warning.config_path,
|
||||
}),
|
||||
automatic_remediation: (id == crate::diagnostics::SSH_WRAP_ID)
|
||||
.then(crate::diagnostics::ssh_wrap_automatic_remediation),
|
||||
automatic_remediation: crate::diagnostics::automatic_remediation_for(id),
|
||||
note: warning.note,
|
||||
})
|
||||
}
|
||||
|
|
@ -596,6 +604,24 @@ fn probe_notes(snapshot: &DiagnosticSnapshot<'_>) -> Vec<ProbeNote> {
|
|||
notes
|
||||
}
|
||||
|
||||
fn tmux_option_fact(result: &TmuxProbeResult<String>) -> TmuxOptionFact {
|
||||
match result {
|
||||
TmuxProbeResult::Available(value) => TmuxOptionFact::Available(value.to_owned()),
|
||||
TmuxProbeResult::Unsupported => TmuxOptionFact::Unsupported,
|
||||
TmuxProbeResult::Unavailable => TmuxOptionFact::Unavailable,
|
||||
TmuxProbeResult::Error(_) => TmuxOptionFact::Error,
|
||||
}
|
||||
}
|
||||
|
||||
fn tmux_support_fact(result: &TmuxProbeResult<()>) -> TmuxSupportFact {
|
||||
match result {
|
||||
TmuxProbeResult::Available(()) => TmuxSupportFact::Supported,
|
||||
TmuxProbeResult::Unsupported => TmuxSupportFact::Unsupported,
|
||||
TmuxProbeResult::Unavailable => TmuxSupportFact::Unavailable,
|
||||
TmuxProbeResult::Error(_) => TmuxSupportFact::Error,
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_note<T>(notes: &mut Vec<ProbeNote>, probe: &'static str, result: &TmuxProbeResult<T>) {
|
||||
let (status, message) = match result {
|
||||
TmuxProbeResult::Available(_) => return,
|
||||
|
|
|
|||
|
|
@ -222,7 +222,83 @@ fn findings_have_stable_semantic_ids_and_dispositions() {
|
|||
ssh_wrap.automatic_remediation,
|
||||
Some(crate::diagnostics::ssh_wrap_automatic_remediation())
|
||||
);
|
||||
assert!(report.findings[0].automatic_remediation.is_none());
|
||||
assert_eq!(
|
||||
report.findings[0].automatic_remediation,
|
||||
crate::diagnostics::automatic_remediation_for(DiagnosticId::new(
|
||||
"terminal",
|
||||
"tmux-clipboard"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_tmux_finding_metadata_uses_stable_automatic_fix_ids_without_schema_changes() {
|
||||
let mut terminal = TerminalContext {
|
||||
brand: TerminalName::Iterm2,
|
||||
env_brand: TerminalName::Iterm2,
|
||||
multiplexer: MultiplexerKind::Tmux,
|
||||
tmux_version: Some("tmux 3.4".to_owned()),
|
||||
tmux_extended_keys: Some("off".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
let report = view(snapshot(
|
||||
&terminal,
|
||||
TmuxProbeFacts {
|
||||
version: TmuxProbeResult::Available("tmux 3.4".to_owned()),
|
||||
extended_keys: TmuxProbeResult::Available("off".to_owned()),
|
||||
set_clipboard: TmuxProbeResult::Available("off".to_owned()),
|
||||
allow_passthrough_support: TmuxProbeResult::Available(()),
|
||||
allow_passthrough: TmuxProbeResult::Available("off".to_owned()),
|
||||
control_mode: TmuxProbeResult::Available(false),
|
||||
},
|
||||
available_runtime(),
|
||||
false,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
report
|
||||
.findings
|
||||
.iter()
|
||||
.filter_map(|finding| finding.automatic_remediation)
|
||||
.map(|automatic| (automatic.fix_id, automatic.command))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
(
|
||||
crate::diagnostics::TMUX_CLIPBOARD_ID,
|
||||
"grok doctor fix terminal.tmux-clipboard",
|
||||
),
|
||||
(
|
||||
crate::diagnostics::DCS_PASSTHROUGH_ID,
|
||||
"grok doctor fix terminal.dcs-passthrough",
|
||||
),
|
||||
(
|
||||
crate::diagnostics::TMUX_EXTENDED_KEYS_ID,
|
||||
"grok doctor fix terminal.tmux-extended-keys",
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
terminal.tmux_extended_keys = Some("on".to_owned());
|
||||
let healthy = view(snapshot(
|
||||
&terminal,
|
||||
TmuxProbeFacts {
|
||||
version: TmuxProbeResult::Available("tmux 3.4".to_owned()),
|
||||
extended_keys: TmuxProbeResult::Available("on".to_owned()),
|
||||
set_clipboard: TmuxProbeResult::Available("external".to_owned()),
|
||||
allow_passthrough_support: TmuxProbeResult::Available(()),
|
||||
allow_passthrough: TmuxProbeResult::Available("all".to_owned()),
|
||||
control_mode: TmuxProbeResult::Available(false),
|
||||
},
|
||||
available_runtime(),
|
||||
false,
|
||||
));
|
||||
for id in [
|
||||
crate::diagnostics::TMUX_CLIPBOARD_ID,
|
||||
crate::diagnostics::DCS_PASSTHROUGH_ID,
|
||||
crate::diagnostics::TMUX_EXTENDED_KEYS_ID,
|
||||
] {
|
||||
assert!(healthy.findings.iter().all(|finding| finding.id != id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::path::Path;
|
|||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::diagnostics::{DiagnosticReport, FixPlan, FixStatus, ShellKind};
|
||||
use crate::diagnostics::{DiagnosticReport, FixActivation, FixPlan, ShellKind};
|
||||
|
||||
mod human;
|
||||
mod json;
|
||||
|
|
@ -28,7 +28,7 @@ pub enum DoctorCommand {
|
|||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, clap::Args)]
|
||||
pub struct FixArgs {
|
||||
/// Fix to apply. Use `ssh-wrap` or `terminal.ssh-wrap`. Omit it to list available automatic fixes.
|
||||
/// Named fix to apply. Omit it to list available automatic fixes.
|
||||
pub id: Option<String>,
|
||||
/// Apply the displayed changes without confirmation.
|
||||
#[arg(long, requires = "id")]
|
||||
|
|
@ -106,11 +106,13 @@ fn run_fix(
|
|||
writer: &mut impl Write,
|
||||
) -> Result<()> {
|
||||
let terminal = crate::terminal::standalone_terminal_context();
|
||||
let Some(value) = args.id.as_deref() else {
|
||||
let report = configured_report_for_terminal(
|
||||
collect_report_with(crate::diagnostics::probes::collect_standalone(&terminal)),
|
||||
collect_report_with(crate::diagnostics::probes::collect_standalone_fix(
|
||||
&terminal, None,
|
||||
)),
|
||||
&terminal,
|
||||
);
|
||||
let Some(value) = args.id.as_deref() else {
|
||||
write!(
|
||||
writer,
|
||||
"{}",
|
||||
|
|
@ -119,6 +121,13 @@ fn run_fix(
|
|||
return Ok(());
|
||||
};
|
||||
let id = crate::diagnostics::resolve_fix_id(value)?;
|
||||
let report = configured_report_for_terminal(
|
||||
collect_report_with(crate::diagnostics::probes::collect_standalone_fix(
|
||||
&terminal,
|
||||
Some(id),
|
||||
)),
|
||||
&terminal,
|
||||
);
|
||||
let request = crate::diagnostics::FixRequest::from_environment(id)?;
|
||||
let plan = crate::diagnostics::plan_fix(request, &report, &terminal)?;
|
||||
apply_fix_plan(args, stdin_is_terminal, input, writer, &terminal, plan)
|
||||
|
|
@ -150,39 +159,36 @@ fn apply_fix_plan(
|
|||
}
|
||||
}
|
||||
|
||||
let shell = plan.shell;
|
||||
let outcome = crate::diagnostics::apply_fix(plan)?;
|
||||
if outcome.activation() == FixActivation::SatisfiedNow {
|
||||
// Use the shell stored on the outcome (from planning), not `$SHELL`.
|
||||
// `$SHELL` may be missing or no longer match the shell the plan targeted.
|
||||
let post_report = crate::diagnostics::configured_report(
|
||||
collect_report_with(crate::diagnostics::probes::collect_standalone(terminal)),
|
||||
crate::diagnostics::managed_alias_configured(&outcome.changed_path, shell),
|
||||
outcome.managed_alias_is_configured(),
|
||||
);
|
||||
if post_report
|
||||
.findings
|
||||
.iter()
|
||||
.any(|finding| finding.id == outcome.id)
|
||||
.any(|finding| finding.id == outcome.id())
|
||||
{
|
||||
anyhow::bail!(
|
||||
"The change was applied, but Doctor still reports `{}`.",
|
||||
outcome.id
|
||||
outcome.id()
|
||||
);
|
||||
}
|
||||
} else if !crate::diagnostics::verify_persistent_fix(&outcome) {
|
||||
anyhow::bail!(
|
||||
"The change was applied, but Doctor could not verify `{}` in persistent configuration.",
|
||||
outcome.id()
|
||||
);
|
||||
}
|
||||
|
||||
match outcome.status {
|
||||
FixStatus::Applied => writeln!(
|
||||
writeln!(
|
||||
writer,
|
||||
"\nSet up SSH wrapping in {}.",
|
||||
outcome.changed_path.display()
|
||||
)?,
|
||||
FixStatus::AlreadyConfigured => writeln!(
|
||||
writer,
|
||||
"\nSSH wrapping is already set up in {}.",
|
||||
outcome.changed_path.display()
|
||||
)?,
|
||||
}
|
||||
if let Some(backup) = outcome.backup_path {
|
||||
writeln!(writer, "Backup: {}", backup.display())?;
|
||||
}
|
||||
writeln!(writer, "Start a new shell to use the alias.")?;
|
||||
"\n{}",
|
||||
crate::diagnostics::format_fix_success(&outcome)
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,12 +41,14 @@ fn local_terminal() -> TerminalContext {
|
|||
}
|
||||
|
||||
fn ssh_wrap_fix_request(home: &std::path::Path) -> crate::diagnostics::FixRequest {
|
||||
crate::diagnostics::FixRequest {
|
||||
id: crate::diagnostics::SSH_WRAP_ID,
|
||||
home: home.to_path_buf(),
|
||||
shell: Some(std::path::PathBuf::from("/bin/bash")),
|
||||
validator: None,
|
||||
}
|
||||
crate::diagnostics::FixRequest::new_for_test(
|
||||
crate::diagnostics::SSH_WRAP_ID,
|
||||
home,
|
||||
Some(std::path::PathBuf::from("/bin/bash")),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
static TMUX_ROUTE: ClipboardRoute = ClipboardRoute {
|
||||
|
|
@ -96,6 +98,12 @@ fn healthy_report() -> DiagnosticReport {
|
|||
multiplexer: MultiplexerKind::Undetected,
|
||||
byobu: None,
|
||||
ssh: false,
|
||||
tmux: crate::diagnostics::TmuxFacts {
|
||||
extended_keys: crate::diagnostics::TmuxOptionFact::Unavailable,
|
||||
set_clipboard: crate::diagnostics::TmuxOptionFact::Unavailable,
|
||||
allow_passthrough_support: crate::diagnostics::TmuxSupportFact::Unavailable,
|
||||
allow_passthrough: crate::diagnostics::TmuxOptionFact::Unavailable,
|
||||
},
|
||||
color: ColorFacts {
|
||||
level: RuntimeFact::Available(ColorLevel::TrueColor),
|
||||
available_themes: ThemeKind::ALL.to_vec(),
|
||||
|
|
@ -156,7 +164,9 @@ fn mixed_report() -> DiagnosticReport {
|
|||
fix: "set -g set-clipboard on".to_owned(),
|
||||
config_path: Some("~/.tmux.conf".to_owned()),
|
||||
}),
|
||||
automatic_remediation: None,
|
||||
automatic_remediation: crate::diagnostics::automatic_remediation_for(
|
||||
DiagnosticId::new("terminal", "tmux-clipboard"),
|
||||
),
|
||||
note: Some("Reload tmux after editing.".to_owned()),
|
||||
},
|
||||
DiagnosticFinding {
|
||||
|
|
@ -487,6 +497,7 @@ fn human_mixed_fixture_is_exact() {
|
|||
"\n",
|
||||
"Findings\n",
|
||||
" ! terminal.tmux-clipboard OSC 52 clipboard passthrough is disabled\n",
|
||||
" → Automatic setup: `grok doctor fix tmux-clipboard`\n",
|
||||
" → Add `set -g set-clipboard on` to ~/.tmux.conf\n",
|
||||
" Reload tmux after editing.\n",
|
||||
" i terminal.ssh-wrap Use local SSH wrapping\n",
|
||||
|
|
@ -749,7 +760,10 @@ fn json_contract_is_structural_stable_ordered_and_ansi_free() {
|
|||
"fix": "set -g set-clipboard on",
|
||||
"configPath": "~/.tmux.conf"
|
||||
},
|
||||
"automaticRemediation": null,
|
||||
"automaticRemediation": {
|
||||
"fixId": "terminal.tmux-clipboard",
|
||||
"command": "grok doctor fix terminal.tmux-clipboard"
|
||||
},
|
||||
"note": "Reload tmux after editing."
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -158,6 +158,9 @@ fn parse_prompt_json(json_str: &str) -> anyhow::Result<Vec<acp::ContentBlock>> {
|
|||
pub struct HeadlessOptions {
|
||||
pub session_id: Option<String>,
|
||||
pub resume: Option<String>,
|
||||
/// The composition root pinned (or definitively missed) `resume` before
|
||||
/// the OS sandbox; materialization must not re-run local title selection.
|
||||
pub resume_title_pinned: bool,
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub yolo: bool,
|
||||
pub trust: bool,
|
||||
|
|
@ -805,12 +808,20 @@ async fn apply_headless_model_and_effort(
|
|||
/// Startup-materialization context for headless (`-p`) runs. Never chat:
|
||||
/// `HeadlessOptions` carries no chat flag, so headless resume targets are
|
||||
/// always disk/GCS Build sessions.
|
||||
fn headless_materialize_ctx(has_worktree: bool) -> crate::app::session_startup::MaterializeCtx {
|
||||
fn headless_materialize_ctx(
|
||||
has_worktree: bool,
|
||||
resume_title_pinned: bool,
|
||||
) -> crate::app::session_startup::MaterializeCtx {
|
||||
crate::app::session_startup::MaterializeCtx {
|
||||
has_worktree,
|
||||
allow_remote_restore:
|
||||
crate::app::session_startup::MaterializeCtx::default_allow_remote_restore(),
|
||||
chat_mode: false,
|
||||
title_resolution: if resume_title_pinned {
|
||||
crate::app::session_startup::TitleResolution::PinnedPreSandbox
|
||||
} else {
|
||||
crate::app::session_startup::TitleResolution::Allowed
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -983,7 +994,7 @@ pub async fn run_single_turn(
|
|||
|
||||
let cwd_str = cwd.to_string_lossy().to_string();
|
||||
let materialized = session_startup::materialize_startup_for_cwd(
|
||||
headless_materialize_ctx(options.worktree.is_some()),
|
||||
headless_materialize_ctx(options.worktree.is_some(), options.resume_title_pinned),
|
||||
intent,
|
||||
&cwd_str,
|
||||
)
|
||||
|
|
@ -1850,13 +1861,25 @@ mod tests {
|
|||
}
|
||||
|
||||
/// Headless materialization is never chat, regardless of worktree flag —
|
||||
/// resume targets stay disk/GCS Build sessions.
|
||||
/// resume targets stay disk/GCS Build sessions. The pre-sandbox pin flag
|
||||
/// must carry through so a pinned target is never re-title-selected.
|
||||
#[test]
|
||||
fn headless_materialize_ctx_stays_non_chat() {
|
||||
use crate::app::session_startup::TitleResolution;
|
||||
for has_worktree in [false, true] {
|
||||
let ctx = headless_materialize_ctx(has_worktree);
|
||||
for pinned in [false, true] {
|
||||
let ctx = headless_materialize_ctx(has_worktree, pinned);
|
||||
assert!(!ctx.chat_mode);
|
||||
assert_eq!(ctx.has_worktree, has_worktree);
|
||||
assert_eq!(
|
||||
ctx.title_resolution,
|
||||
if pinned {
|
||||
TitleResolution::PinnedPreSandbox
|
||||
} else {
|
||||
TitleResolution::Allowed
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ pub mod share_cmd;
|
|||
pub mod slash;
|
||||
pub mod startup;
|
||||
pub mod tips;
|
||||
pub mod tutorial_docs;
|
||||
pub mod wrap_clipboard_image;
|
||||
pub mod wrap_cmd;
|
||||
pub(crate) mod wrap_filter;
|
||||
|
|
|
|||
|
|
@ -747,6 +747,14 @@ pub fn build_session_entry_data(
|
|||
)
|
||||
}
|
||||
|
||||
/// [`crate::views::session_picker::hidden_external_hint`].
|
||||
pub fn hidden_external_hint(
|
||||
entries: Option<&[SessionPickerEntry]>,
|
||||
source_filter: SourceFilter,
|
||||
) -> Option<String> {
|
||||
crate::views::session_picker::hidden_external_hint(entries, source_filter)
|
||||
}
|
||||
|
||||
/// [`crate::views::session_picker::build_grouped_picker_entries`].
|
||||
pub fn build_grouped_picker_entries<'a>(
|
||||
entries_data: &'a [SessionPickerEntry],
|
||||
|
|
|
|||
|
|
@ -178,6 +178,9 @@ pub enum MarketplaceCommand {
|
|||
Add {
|
||||
/// Git URL, GitHub shorthand (e.g. user/repo), or local directory path.
|
||||
url: String,
|
||||
/// Skip the reachability probe (e.g. for hosts only reachable on VPN).
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Remove a marketplace source and uninstall its plugins
|
||||
Remove {
|
||||
|
|
@ -794,7 +797,7 @@ async fn run_marketplace(cmd: MarketplaceCommand) -> Result<()> {
|
|||
|
||||
match cmd {
|
||||
MarketplaceCommand::List { json } => marketplace_list(&sources, json),
|
||||
MarketplaceCommand::Add { url } => marketplace_add(&sources, &url),
|
||||
MarketplaceCommand::Add { url, force } => marketplace_add(&sources, &url, force),
|
||||
MarketplaceCommand::Remove { source } => marketplace_remove(&sources, &source),
|
||||
MarketplaceCommand::Update { name } => marketplace_update(&sources, name.as_deref()),
|
||||
}
|
||||
|
|
@ -845,6 +848,7 @@ fn marketplace_list(
|
|||
fn marketplace_add(
|
||||
sources: &[xai_grok_plugin_marketplace::MarketplaceSource],
|
||||
url: &str,
|
||||
force: bool,
|
||||
) -> Result<()> {
|
||||
use xai_grok_shell::plugin::MarketplaceAddInput;
|
||||
|
||||
|
|
@ -896,6 +900,15 @@ fn marketplace_add(
|
|||
bail!("Marketplace source already configured: {identity}");
|
||||
}
|
||||
|
||||
if !force && let MarketplaceAddInput::GitUrl(git_url) = &input {
|
||||
xai_grok_plugin_marketplace::git::probe_git_remote(git_url).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"{e}\nNot adding \"{url}\": it doesn't look like a reachable git repository. \
|
||||
Re-run with --force to add it anyway (e.g. a host only reachable on VPN)."
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let name = match &input {
|
||||
MarketplaceAddInput::GitUrl(u) => plugin::name_from_url(u),
|
||||
MarketplaceAddInput::LocalPath(p) => plugin::name_from_path(p),
|
||||
|
|
|
|||
|
|
@ -1351,6 +1351,36 @@ pub fn default_settings() -> Vec<SettingMeta> {
|
|||
restart_required: true,
|
||||
hidden_in_minimal: false,
|
||||
},
|
||||
// SHELL-owned, persisted to `[ui].voice_keybind_enabled`. Default ON —
|
||||
// `None` (inherit) reads as `true`. Disables only the Ctrl+Space / F8
|
||||
// chord; `/voice` (and Esc / the recording-row `[stop]`) keep working.
|
||||
SettingMeta {
|
||||
key: "voice_keybind_enabled",
|
||||
category: SettingCategory::Editor,
|
||||
owner: SettingOwner::Shell,
|
||||
label: "Voice shortcut",
|
||||
description: "Enable the Ctrl+Space / F8 shortcut for voice dictation. \
|
||||
When off, the keys are ignored; /voice still starts \
|
||||
dictation.",
|
||||
keywords: &[
|
||||
"voice",
|
||||
"dictation",
|
||||
"mic",
|
||||
"microphone",
|
||||
"speech",
|
||||
"stt",
|
||||
"keybinding",
|
||||
"hotkey",
|
||||
"ctrl+space",
|
||||
"f8",
|
||||
"disable",
|
||||
],
|
||||
kind: SettingKind::Bool {
|
||||
default: ui_default.voice_keybind_enabled.unwrap_or(true),
|
||||
},
|
||||
restart_required: false,
|
||||
hidden_in_minimal: false,
|
||||
},
|
||||
// SHELL-owned, persisted to `[ui].voice_capture_mode`. The `hold` choice
|
||||
// is hidden on terminals without key-release reporting (see
|
||||
// `effective_enum_choices`) and falls back to `toggle` at runtime.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue