Synced from monorepo
Changes: - Detect the herdr multiplexer - Mark /gboom as non-production code - Bound peak memory when loading a large session - Add a subagent lifecycle soak bounding threads, fds, and heap - Stream inherited replay to bound fork memory - Copy full plan from plan approval with y - Stop armed signature verification from deleting the managed-deny smoke policy - Add source-tagged terminal version telemetry - Show the UI instantly and fetch models and settings in the background - Session test helpers - computer_reason on the ConversationHistoryDone trailer
This commit is contained in:
parent
47348d13ec
commit
b41c75a578
92 changed files with 9410 additions and 3788 deletions
|
|
@ -77,6 +77,7 @@ Scroll the plan with the arrow keys or `j`/`k`. The action bar shows these short
|
|||
| `a` | Approve the plan and start building. With pending comments, this reads `approve w/ comments` and sends them alongside the approval. |
|
||||
| `s` | Request changes. Focus moves to the prompt so you can type revision notes; press `Enter` to send them. |
|
||||
| `c` | Comment on the selected line or line range. |
|
||||
| `y` | Copy the full plan to the clipboard. |
|
||||
| `q` | Quit plan -- abandon the plan without approving and turn plan mode off. |
|
||||
|
||||
Press `Tab` to move focus between the plan preview and the prompt.
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ pub async fn connect(cancel: &CancellationToken, flags: ConnectFlags) -> Result<
|
|||
startup_auth_metadata(&auth_methods);
|
||||
|
||||
let (needs_login, login_label, login_method_id, auth_start_mode, auth_meta) =
|
||||
eager_auth_or_login_fallback(
|
||||
bounded_eager_auth(
|
||||
&tx,
|
||||
&auth_methods,
|
||||
default_auth_method_id.as_ref(),
|
||||
|
|
@ -325,7 +325,7 @@ pub async fn connect_via_leader(
|
|||
startup_auth_metadata(&auth_methods);
|
||||
|
||||
let (needs_login, login_label, login_method_id, auth_start_mode, auth_meta) =
|
||||
eager_auth_or_login_fallback(
|
||||
bounded_eager_auth(
|
||||
&tx,
|
||||
&auth_methods,
|
||||
default_auth_method_id.as_ref(),
|
||||
|
|
@ -710,6 +710,49 @@ async fn eager_auth_or_login_fallback(
|
|||
}
|
||||
}
|
||||
|
||||
/// [`eager_auth_or_login_fallback`] bounded by `STARTUP_AUTH_REFRESH_TIMEOUT`,
|
||||
/// so a hung agent cannot gate the first draw. On timeout the inputs pass
|
||||
/// through unchanged and the agent finishes authentication in the background.
|
||||
async fn bounded_eager_auth(
|
||||
tx: &AcpAgentTx,
|
||||
auth_methods: &[acp::AuthMethod],
|
||||
default_auth_method_id: Option<&acp::AuthMethodId>,
|
||||
needs_login: bool,
|
||||
login_label: Option<String>,
|
||||
login_method_id: Option<acp::AuthMethodId>,
|
||||
auth_start_mode: AuthStartMode,
|
||||
) -> (
|
||||
bool,
|
||||
Option<String>,
|
||||
Option<acp::AuthMethodId>,
|
||||
AuthStartMode,
|
||||
Option<serde_json::Value>,
|
||||
) {
|
||||
match tokio::time::timeout(
|
||||
xai_grok_shell::http::STARTUP_AUTH_REFRESH_TIMEOUT,
|
||||
eager_auth_or_login_fallback(
|
||||
tx,
|
||||
auth_methods,
|
||||
default_auth_method_id,
|
||||
needs_login,
|
||||
login_label.clone(),
|
||||
login_method_id.clone(),
|
||||
auth_start_mode,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resolved) => resolved,
|
||||
Err(_) => (
|
||||
needs_login,
|
||||
login_label,
|
||||
login_method_id,
|
||||
auth_start_mode,
|
||||
None,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate with the agent using the agent's chosen default method.
|
||||
///
|
||||
/// Prefer `defaultAuthMethodId` from initialize meta when present and listed.
|
||||
|
|
|
|||
|
|
@ -188,8 +188,13 @@ pub async fn spawn_grok_shell(
|
|||
// re-login). No-op where the OS listener is unavailable.
|
||||
auth_manager.start_system_power_listener();
|
||||
|
||||
// Both embedded-agent paths (`--no-leader` and leader fallback) converge
|
||||
// here, so the agent's external-OTEL gate is applied exactly once, before boot.
|
||||
xai_grok_shell::agent::app::apply_otel_config(&auth_manager, &agent_config.grok_com_config);
|
||||
|
||||
// Best-effort refresh of managed policy before bootstrap reads it (repairs a wrong-identity/missing
|
||||
// cache). Never errors — the OS-protected system/MDM layers still apply.
|
||||
// cache). Never errors — the OS-protected system/MDM layers still apply, and every network step
|
||||
// inside is bounded (SESSION_START_AUTH_DEADLINE / SyncBudget::SessionStart).
|
||||
xai_grok_shell::managed_config::ensure_managed_policy_present(&auth_manager).await;
|
||||
|
||||
// Run the full bootstrap sequence: config resolution, process-level
|
||||
|
|
@ -200,6 +205,9 @@ pub async fn spawn_grok_shell(
|
|||
models_manager
|
||||
.list_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await;
|
||||
// Self-heal a cold-cache/failed boot fetch once the backend recovers,
|
||||
// matching the leader and stdio paths.
|
||||
models_manager.spawn_background_refresh();
|
||||
|
||||
let agent_cancel = cancel.child_token();
|
||||
let (acp_client, acp_agent) = acp_channels();
|
||||
|
|
|
|||
|
|
@ -58,6 +58,20 @@ pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut App
|
|||
return false;
|
||||
};
|
||||
|
||||
// Reseed this process's remote-campaign cache. In leader mode no in-process
|
||||
// agent seeds the TUI process, and the bounded startup prefetch can miss —
|
||||
// without this reseed a remote campaign stays invisible to
|
||||
// `resolve_dismissable_campaigns`, so a `/model` pick never records its
|
||||
// dismissal and the leader re-nudges every new session. Idempotent in
|
||||
// embedded mode, where the in-process agent seeds the same cache.
|
||||
if let Some(campaigns) = update.campaigns.clone() {
|
||||
let rs = xai_grok_shell::util::config::RemoteSettings {
|
||||
campaigns,
|
||||
..Default::default()
|
||||
};
|
||||
xai_grok_shell::util::config::set_remote_campaigns_from_settings(Some(&rs));
|
||||
}
|
||||
|
||||
if let Some(v) = update.auto_permission_mode_enabled {
|
||||
// Keep the pager's auto-permission-mode gate live with the remote settings
|
||||
// remote tier (the leader caches it agent-side; the pager process needs
|
||||
|
|
@ -523,6 +537,11 @@ pub(super) struct PagerSettingsUpdate {
|
|||
// remote_settings also emits gen-ordered `x.ai/announcements/update`
|
||||
// (emit_announcements_if_changed), and a gen-less apply on this path could
|
||||
// clobber a newer push. Single ingest path: handle_announcements_update.
|
||||
/// Remote campaigns snapshot. `Some` whenever the shell has settings
|
||||
/// (empty = campaigns withdrawn); `None`/omitted (settings-less push,
|
||||
/// older shell) must leave this process's campaign cache untouched.
|
||||
#[serde(default)]
|
||||
campaigns: Option<Vec<xai_grok_shell::util::config::CampaignOverride>>,
|
||||
#[serde(default)]
|
||||
gate_message: Option<String>,
|
||||
#[serde(default)]
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ impl AgentView {
|
|||
/// the shell-read file body), then falls back to the on-disk plan file.
|
||||
/// Request body first keeps file-backed previews working when the path
|
||||
/// resolution fails or the file disappears between intercept and open.
|
||||
fn plan_body_for_preview(&self) -> Option<String> {
|
||||
pub(super) fn plan_body_for_preview(&self) -> Option<String> {
|
||||
if let Some(content) = self
|
||||
.plan_approval_view
|
||||
.as_ref()
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ impl AgentView {
|
|||
]
|
||||
}
|
||||
}
|
||||
PlanApprovalFocus::Preview => vec![],
|
||||
PlanApprovalFocus::Preview => vec![HintItem::new(key!('y'), "copy plan")],
|
||||
}
|
||||
}
|
||||
/// Returns the *exact* hints the bottom shortcuts bar would render right now.
|
||||
|
|
@ -208,6 +208,7 @@ impl AgentView {
|
|||
} else {
|
||||
let mut h = vec![
|
||||
HintItem::new(key!('c'), "comment"),
|
||||
HintItem::new(key!('y'), "copy plan"),
|
||||
HintItem::new(key!('f', CONTROL), "fullscreen"),
|
||||
];
|
||||
if !self.plan_comments.is_empty() {
|
||||
|
|
@ -3197,6 +3198,7 @@ impl AgentView {
|
|||
} else {
|
||||
let mut h = vec![
|
||||
HintItem::new(key!('c'), "comment"),
|
||||
HintItem::new(key!('y'), "copy plan"),
|
||||
HintItem::new(key!('f', CONTROL), "fullscreen"),
|
||||
];
|
||||
if !self.plan_comments.is_empty() {
|
||||
|
|
@ -3289,6 +3291,7 @@ impl AgentView {
|
|||
.with_pending(pending_hint)
|
||||
.render(layout.shortcuts, buf);
|
||||
}
|
||||
let line_viewer_toast = self.active_toast_message().map(|s| s.to_string());
|
||||
let is_plan_viewer = self.is_plan_viewer();
|
||||
let has_plan_comments = !self.plan_comments.is_empty();
|
||||
let casual_commenting = self.is_casual_commenting();
|
||||
|
|
@ -3335,6 +3338,26 @@ impl AgentView {
|
|||
&theme,
|
||||
effective_comment_count,
|
||||
);
|
||||
let toast_area = viewer
|
||||
.last_popup_area
|
||||
.or(viewer.last_modal_area)
|
||||
.unwrap_or(overlay_area);
|
||||
if let Some(ref msg) = line_viewer_toast
|
||||
&& toast_area.height > 0
|
||||
&& let Some(toast_text) = fit_toast_text(msg, toast_area.width.saturating_sub(1))
|
||||
{
|
||||
let w = toast_text.chars().count() as u16;
|
||||
let tx = toast_area.right().saturating_sub(w + 1);
|
||||
let ty = toast_area.bottom().saturating_sub(1);
|
||||
for (i, ch) in toast_text.chars().enumerate() {
|
||||
if let Some(cell) = buf.cell_mut((tx + i as u16, ty)) {
|
||||
cell.set_char(ch);
|
||||
cell.fg = theme.accent_user;
|
||||
cell.bg = theme.bg_base;
|
||||
cell.modifier = ratatui::prelude::Modifier::BOLD;
|
||||
}
|
||||
}
|
||||
}
|
||||
let in_plan_approval = self.plan_approval_view.is_some();
|
||||
let on_comment = in_plan_approval
|
||||
&& viewer
|
||||
|
|
@ -3360,11 +3383,15 @@ impl AgentView {
|
|||
} else {
|
||||
h.push(HintItem::new(key!('a'), "approve"));
|
||||
}
|
||||
h.push(HintItem::new(key!('y'), "copy plan"));
|
||||
h.push(HintItem::new(key!('q'), "quit plan"));
|
||||
h.push(HintItem::new(key!(Tab), "prompt"));
|
||||
h
|
||||
} else if in_plan_approval {
|
||||
let mut h = vec![HintItem::new(key!('c'), "comment")];
|
||||
let mut h = vec![
|
||||
HintItem::new(key!('c'), "comment"),
|
||||
HintItem::new(key!('y'), "copy plan"),
|
||||
];
|
||||
if approval_has_comments {
|
||||
h.push(HintItem::new(key!('s'), "send"));
|
||||
} else {
|
||||
|
|
@ -3390,9 +3417,13 @@ impl AgentView {
|
|||
vec![
|
||||
HintItem::new(key!(Enter), "edit"),
|
||||
HintItem::new(key!('x'), "delete"),
|
||||
HintItem::new(key!('y'), "copy plan"),
|
||||
]
|
||||
} else {
|
||||
vec![HintItem::new(key!('c'), "comment")]
|
||||
vec![
|
||||
HintItem::new(key!('c'), "comment"),
|
||||
HintItem::new(key!('y'), "copy plan"),
|
||||
]
|
||||
};
|
||||
if has_plan_comments {
|
||||
h.push(HintItem::new(key!('s'), "send"));
|
||||
|
|
|
|||
|
|
@ -54,6 +54,19 @@ impl AgentView {
|
|||
}
|
||||
}
|
||||
|
||||
pub(super) fn copy_plan_full(&mut self) -> InputOutcome {
|
||||
let text = self
|
||||
.line_viewer
|
||||
.as_ref()
|
||||
.and_then(|v| v.markdown_content_for_feedback())
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| self.plan_body_for_preview());
|
||||
if let Some(text) = text {
|
||||
self.copy_to_clipboard(&text);
|
||||
}
|
||||
InputOutcome::Changed
|
||||
}
|
||||
|
||||
/// Handle a key event while the line viewer is open.
|
||||
pub(super) fn handle_line_viewer_key(&mut self, key: &KeyEvent) -> InputOutcome {
|
||||
let in_plan_approval = self.plan_approval_view.is_some();
|
||||
|
|
@ -185,8 +198,10 @@ impl AgentView {
|
|||
self.confirm_line_viewer(false);
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// y: copy selected line(s) to system clipboard.
|
||||
if key!('y').matches(key) {
|
||||
if self.is_plan_viewer() {
|
||||
return self.copy_plan_full();
|
||||
}
|
||||
if let Some(ref viewer) = self.line_viewer {
|
||||
let text = if viewer.list_state.visual_mode {
|
||||
if let Some(ref range) = viewer.list_state.multi_range() {
|
||||
|
|
@ -219,8 +234,10 @@ impl AgentView {
|
|||
}
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
// Y: copy filename to clipboard.
|
||||
if key!('Y').matches(key) {
|
||||
if self.is_plan_viewer() {
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if let Some(ref viewer) = self.line_viewer {
|
||||
let name = viewer
|
||||
.title_override
|
||||
|
|
@ -392,6 +409,7 @@ impl AgentView {
|
|||
let abandon_area = viewer.plan_ref().and_then(|p| p.abandon_button_area);
|
||||
let approve_area = viewer.plan_ref().and_then(|p| p.approve_button_area);
|
||||
let comment_btn_area = viewer.plan_ref().and_then(|p| p.comment_button_area);
|
||||
let copy_btn_area = viewer.plan_ref().and_then(|p| p.copy_button_area);
|
||||
// Cached `is_plan_viewer()` so we don't need to call self while
|
||||
// the line_viewer is mutably borrowed below.
|
||||
let is_plan_preview =
|
||||
|
|
@ -440,6 +458,9 @@ impl AgentView {
|
|||
// patterns just above.
|
||||
return InputOutcome::Changed;
|
||||
}
|
||||
if copy_btn_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
|
||||
return self.copy_plan_full();
|
||||
}
|
||||
if send_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into())) {
|
||||
if self.plan_approval_view.is_some() {
|
||||
if let Some(ref mut pav) = self.plan_approval_view {
|
||||
|
|
@ -528,6 +549,13 @@ impl AgentView {
|
|||
viewer.plan_mut().comment_hovered = comment_btn_hover;
|
||||
changed = true;
|
||||
}
|
||||
let copy_btn_hover =
|
||||
copy_btn_area.is_some_and(|a| a.contains((mouse.column, mouse.row).into()));
|
||||
let prev_copy_btn = viewer.plan_ref().is_some_and(|p| p.copy_hovered);
|
||||
if copy_btn_hover != prev_copy_btn {
|
||||
viewer.plan_mut().copy_hovered = copy_btn_hover;
|
||||
changed = true;
|
||||
}
|
||||
if self.plan_approval_view.is_some()
|
||||
&& let Some(area) = popup_area
|
||||
&& area.contains((mouse.column, mouse.row).into())
|
||||
|
|
|
|||
|
|
@ -123,6 +123,8 @@ fn spawn_terminal_and_display_refresh_telemetry(tel: StartupTel) {
|
|||
terminal.tmux_version = %t.tmux_version,
|
||||
terminal.term_var = %t.term_var,
|
||||
terminal.xtversion = %t.xtversion,
|
||||
terminal.term_version = %t.term_version,
|
||||
terminal.term_version_source = %t.term_version_source,
|
||||
)
|
||||
.entered();
|
||||
tracing::info!("terminal environment detected");
|
||||
|
|
|
|||
|
|
@ -4219,10 +4219,11 @@ pub(crate) fn execute(
|
|||
.to_owned()
|
||||
});
|
||||
xai_grok_shell::remote::fetch_settings_blocking(
|
||||
&proxy_base,
|
||||
&auth,
|
||||
None,
|
||||
)
|
||||
&proxy_base,
|
||||
&auth,
|
||||
None,
|
||||
)
|
||||
.into_option()
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
|
|
|
|||
|
|
@ -458,6 +458,23 @@ fn resolve_hunk_tracker_mode(
|
|||
.find(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
/// Run a connect future bounded by cancellation and `timeout`, so a hung leader
|
||||
/// or embedded spawn cannot strand the user on a blank screen.
|
||||
async fn bounded_connect(
|
||||
cancel: &CancellationToken,
|
||||
timeout: std::time::Duration,
|
||||
target: &str,
|
||||
connect: impl std::future::Future<Output = anyhow::Result<crate::acp::AcpConnection>>,
|
||||
) -> anyhow::Result<crate::acp::AcpConnection> {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => Err(anyhow::anyhow!("startup cancelled before {target} connected")),
|
||||
r = connect => r,
|
||||
() = tokio::time::sleep(timeout) => {
|
||||
Err(anyhow::anyhow!("timed out after {}s connecting to {target}", timeout.as_secs()))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Main entry point: connect to agent, init terminal, run event loop, restore.
|
||||
///
|
||||
/// If a session ID is provided via `--resume` / `--load` / `--continue`, the
|
||||
|
|
@ -487,9 +504,16 @@ pub async fn run(
|
|||
xai_grok_shell::auth::GrokComConfig::default()
|
||||
}
|
||||
};
|
||||
let refreshed_auth = xai_grok_shell::auth::try_ensure_fresh_auth(&grok_com_config).await;
|
||||
let early_prefetch =
|
||||
xai_grok_shell::agent::models::start_early_prefetch_with_auth(refreshed_auth);
|
||||
let refreshed_auth = tokio::time::timeout(
|
||||
xai_grok_shell::http::STARTUP_AUTH_REFRESH_TIMEOUT,
|
||||
xai_grok_shell::auth::try_ensure_fresh_auth(&grok_com_config),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let early_prefetch = match refreshed_auth {
|
||||
Some(auth) => xai_grok_shell::agent::models::start_early_prefetch_with_auth(Some(auth)),
|
||||
None => xai_grok_shell::agent::models::start_early_prefetch(Some(grok_com_config.clone())),
|
||||
};
|
||||
xai_grok_shell::agent::mvp_agent::warm_async_http_client();
|
||||
tokio::task::spawn_blocking(|| {});
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
|
|
@ -641,23 +665,6 @@ pub async fn run(
|
|||
default_yolo_mode: launch_yolo.yolo,
|
||||
default_auto_mode: launch_auto && !launch_yolo.yolo,
|
||||
};
|
||||
let mut connection = if use_leader {
|
||||
let conn = crate::acp::connect_via_leader(&cancel, connect_flags, &raw_config).await?;
|
||||
tracing::info!(
|
||||
elapsed_ms = startup_start.elapsed().as_millis() as u64,
|
||||
"Connected via leader"
|
||||
);
|
||||
conn
|
||||
} else {
|
||||
let conn = crate::acp::connect(&cancel, connect_flags).await?;
|
||||
tracing::info!(
|
||||
elapsed_ms = startup_start.elapsed().as_millis() as u64,
|
||||
"Connected directly (non-leader)"
|
||||
);
|
||||
conn
|
||||
};
|
||||
let agent_guard =
|
||||
crate::acp::spawn::AgentShutdownGuard::new(cancel.clone(), connection.agent_thread.take());
|
||||
let mut config_watcher = crate::appearance::ConfigWatcher::start().await?;
|
||||
let alt_screen_config_mode = config_watcher.current().alt_screen;
|
||||
let term_ctx = crate::terminal::terminal_context();
|
||||
|
|
@ -728,6 +735,53 @@ pub async fn run(
|
|||
if let Some(ref t) = session_title {
|
||||
set_terminal_title(t);
|
||||
}
|
||||
const CONNECT_UI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
let fallback_flags = use_leader.then(|| connect_flags.clone());
|
||||
let primary_target = if use_leader {
|
||||
"the grok leader"
|
||||
} else {
|
||||
"the embedded agent"
|
||||
};
|
||||
let connect_result = bounded_connect(&cancel, CONNECT_UI_TIMEOUT, primary_target, async {
|
||||
if use_leader {
|
||||
crate::acp::connect_via_leader(&cancel, connect_flags, &raw_config).await
|
||||
} else {
|
||||
crate::acp::connect(&cancel, connect_flags).await
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let (connect_result, embedded_fallback) = match connect_result {
|
||||
Err(e) if use_leader && !cancel.is_cancelled() => {
|
||||
tracing::warn!(error = %e, "leader connect failed; falling back to embedded agent");
|
||||
let flags = fallback_flags.expect("set on the use_leader path");
|
||||
let fallback =
|
||||
bounded_connect(&cancel, CONNECT_UI_TIMEOUT, "the embedded agent", async {
|
||||
crate::acp::connect(&cancel, flags).await
|
||||
})
|
||||
.await;
|
||||
(fallback, true)
|
||||
}
|
||||
other => (other, false),
|
||||
};
|
||||
let mut connection = match connect_result {
|
||||
Ok(conn) => {
|
||||
tracing::info!(
|
||||
elapsed_ms = startup_start.elapsed().as_millis() as u64,
|
||||
use_leader = use_leader && !embedded_fallback,
|
||||
embedded_fallback,
|
||||
"Connected"
|
||||
);
|
||||
conn
|
||||
}
|
||||
Err(e) => {
|
||||
crate::unified_log::flush_blocking().await;
|
||||
let _ = restore_terminal(terminal, writer_thread, screen_mode);
|
||||
cancel.cancel();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let agent_guard =
|
||||
crate::acp::spawn::AgentShutdownGuard::new(cancel.clone(), connection.agent_thread.take());
|
||||
let effective_args = PagerArgs {
|
||||
resume_session: None,
|
||||
load_session: None,
|
||||
|
|
@ -1486,6 +1540,31 @@ mod tests {
|
|||
let toml_str = format!("[cli]\nuse_leader = {enabled}");
|
||||
toml::from_str(&toml_str).unwrap()
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn bounded_connect_times_out_when_the_target_stalls() {
|
||||
let cancel = CancellationToken::new();
|
||||
let r = bounded_connect(
|
||||
&cancel,
|
||||
std::time::Duration::from_millis(20),
|
||||
"the test target",
|
||||
std::future::pending::<anyhow::Result<crate::acp::AcpConnection>>(),
|
||||
)
|
||||
.await;
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("timed out")));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn bounded_connect_returns_err_on_cancel() {
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
let r = bounded_connect(
|
||||
&cancel,
|
||||
std::time::Duration::from_secs(60),
|
||||
"the test target",
|
||||
std::future::pending::<anyhow::Result<crate::acp::AcpConnection>>(),
|
||||
)
|
||||
.await;
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("cancelled")));
|
||||
}
|
||||
#[test]
|
||||
fn terminal_title_strips_control_characters() {
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use xai_grok_shell::session::storage::{ReplayEmission, stream_replay_updates_at};
|
||||
/// Enriched subagent tracking info.
|
||||
///
|
||||
/// Keyed by `child_session_id` in `AgentView::subagent_sessions`.
|
||||
|
|
@ -107,15 +108,24 @@ struct SubagentMetaSlice {
|
|||
#[serde(default)]
|
||||
worktree_path: Option<String>,
|
||||
}
|
||||
/// Grok home for the replay path. In production this is just `grok_home()`; the
|
||||
/// whole test override below is `#[cfg(test)]`, so no thread-local or dead
|
||||
/// always-false branch ships in release.
|
||||
#[cfg(not(test))]
|
||||
fn effective_grok_home() -> std::path::PathBuf {
|
||||
xai_grok_shell::util::grok_home::grok_home()
|
||||
}
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static REPLAY_GROK_HOME: std::cell::RefCell<Option<std::path::PathBuf>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
/// Override grok home for disk-replay unit tests (thread-local; production never sets this).
|
||||
/// Override grok home for disk-replay unit tests (thread-local).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_replay_grok_home_for_tests(home: Option<std::path::PathBuf>) {
|
||||
REPLAY_GROK_HOME.with(|h| *h.borrow_mut() = home);
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn effective_grok_home() -> std::path::PathBuf {
|
||||
if let Some(home) = REPLAY_GROK_HOME.with(|h| h.borrow().clone()) {
|
||||
return home;
|
||||
|
|
@ -161,39 +171,31 @@ fn enrich_from_meta_with_home(
|
|||
info.child_cwd = meta.child_cwd.map(Arc::from);
|
||||
info.worktree_path = meta.worktree_path.map(Arc::from);
|
||||
}
|
||||
/// Best-effort replay of inherited conversation for a child subagent.
|
||||
///
|
||||
/// Reads `updates.jsonl` from the child session directory via
|
||||
/// [`load_updates_for_replay`], then feeds ACP updates through the child's
|
||||
/// tracker with replay semantics. No-ops when the child session or file is
|
||||
/// missing (typical for a live spawn before the shell has persisted updates).
|
||||
/// Best-effort replay of a child's inherited conversation, streamed one typed
|
||||
/// update at a time so a large inherited transcript is not materialized as a
|
||||
/// full `Vec` of typed structs (peak stays near the file size rather than
|
||||
/// several multiples of it). No-ops when the child session or file is missing.
|
||||
pub(crate) fn replay_inherited_updates(
|
||||
child_view: &mut crate::app::agent_view::AgentView,
|
||||
child_session_id: &str,
|
||||
) {
|
||||
let home = effective_grok_home();
|
||||
let updates = match xai_grok_shell::session::storage::load_updates_for_replay_at(
|
||||
child_session_id,
|
||||
&home,
|
||||
) {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
tracing::debug!(session_id = %child_session_id, error = %e, "failed to load updates for replay");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let replay_meta = crate::acp::meta::NotificationMeta {
|
||||
is_replay: true,
|
||||
..Default::default()
|
||||
};
|
||||
let replayed_any = !updates.is_empty();
|
||||
for update in updates {
|
||||
let outcome = match stream_replay_updates_at(child_session_id, &home, |update| {
|
||||
child_view
|
||||
.session
|
||||
.handle_update(update, &replay_meta, &mut child_view.scrollback);
|
||||
}
|
||||
if replayed_any {
|
||||
}) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(e) => {
|
||||
tracing::warn!(session_id = %child_session_id, error = %e, "failed to read updates for replay");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if outcome == ReplayEmission::Emitted {
|
||||
crate::memory_release::release_retained_memory_with("subagent-replay");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ fn terminal() -> TerminalContext {
|
|||
vte_version: None,
|
||||
tmux_extended_keys: None,
|
||||
term_program_version: None,
|
||||
env_term_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -379,6 +379,7 @@ pub(super) fn multiplexer(kind: MultiplexerKind) -> &'static str {
|
|||
MultiplexerKind::Screen => "screen",
|
||||
MultiplexerKind::Zellij => "zellij",
|
||||
MultiplexerKind::Cmux => "cmux",
|
||||
MultiplexerKind::Herdr => "herdr",
|
||||
MultiplexerKind::Undetected => "undetected",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -864,10 +864,11 @@ fn stable_mapping_tables_are_complete() {
|
|||
MultiplexerKind::Screen,
|
||||
MultiplexerKind::Zellij,
|
||||
MultiplexerKind::Cmux,
|
||||
MultiplexerKind::Herdr,
|
||||
MultiplexerKind::Undetected,
|
||||
]
|
||||
.map(multiplexer),
|
||||
["tmux", "screen", "zellij", "cmux", "undetected"]
|
||||
["tmux", "screen", "zellij", "cmux", "herdr", "undetected"]
|
||||
);
|
||||
assert_eq!(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -277,12 +277,15 @@ impl ScrollConfigOverrides {
|
|||
}
|
||||
|
||||
/// Multiplexers that re-encode mouse into their own SGR stream (tmux with
|
||||
/// `mouse on`, screen, zellij all re-emit per pane). Cmux is a Ghostty-backed
|
||||
/// passthrough and keeps the outer brand's stream.
|
||||
/// `mouse on`, screen, zellij, herdr all re-emit per pane). Cmux is a
|
||||
/// Ghostty-backed passthrough and keeps the outer brand's stream.
|
||||
fn multiplexer_reencodes_mouse(multiplexer: MultiplexerKind) -> bool {
|
||||
matches!(
|
||||
multiplexer,
|
||||
MultiplexerKind::Tmux | MultiplexerKind::Screen | MultiplexerKind::Zellij
|
||||
MultiplexerKind::Tmux
|
||||
| MultiplexerKind::Screen
|
||||
| MultiplexerKind::Zellij
|
||||
| MultiplexerKind::Herdr
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -311,8 +314,8 @@ impl ScrollConfig {
|
|||
}
|
||||
|
||||
/// Derive scroll normalization defaults from detected terminal metadata.
|
||||
/// tmux/screen/zellij re-encode mouse into their own SGR stream, so the
|
||||
/// outer brand's events-per-tick/pacing calibration describes the wrong
|
||||
/// tmux/screen/zellij/herdr re-encode mouse into their own SGR stream, so
|
||||
/// the outer brand's events-per-tick/pacing calibration describes the wrong
|
||||
/// producer — trusting an outer ept=3 profile under tmux under-counts 3x
|
||||
/// per notch when the multiplexer re-chunks to one event. Under those
|
||||
/// multiplexers the brand table is replaced by a conservative ept=1
|
||||
|
|
|
|||
|
|
@ -1147,7 +1147,7 @@ fn ghostty_duplicate_reports_do_not_feed_accel_banding() {
|
|||
|
||||
#[test]
|
||||
fn multiplexed_sessions_use_conservative_profile_regardless_of_brand() {
|
||||
// tmux/screen/zellij re-encode mouse into their own SGR stream, so
|
||||
// tmux/screen/zellij/herdr re-encode mouse into their own SGR stream, so
|
||||
// the outer brand's ept/pacing calibration is wrong under them: the
|
||||
// conservative ept=1 shape applies no matter the brand. Cmux is a
|
||||
// passthrough and Undetected means no multiplexer — both keep the
|
||||
|
|
@ -1172,6 +1172,7 @@ fn multiplexed_sessions_use_conservative_profile_regardless_of_brand() {
|
|||
MultiplexerKind::Tmux,
|
||||
MultiplexerKind::Screen,
|
||||
MultiplexerKind::Zellij,
|
||||
MultiplexerKind::Herdr,
|
||||
] {
|
||||
for brand in brands {
|
||||
let cfg = ScrollConfig::from_terminal_context(brand, mux, Default::default());
|
||||
|
|
|
|||
|
|
@ -553,6 +553,8 @@ pub struct PlanViewerExtras {
|
|||
pub comment_hovered: bool,
|
||||
pub abandon_button_area: Option<Rect>,
|
||||
pub abandon_hovered: bool,
|
||||
pub copy_button_area: Option<Rect>,
|
||||
pub copy_hovered: bool,
|
||||
pub last_click_at: Option<std::time::Instant>,
|
||||
pub gutter_drag_start: Option<usize>,
|
||||
pub gutter_drag_end: Option<usize>,
|
||||
|
|
@ -822,8 +824,7 @@ impl LineViewerState {
|
|||
}
|
||||
|
||||
/// Whether the plan modal should render the action-button footer.
|
||||
/// True for both modes: plan-approval (q/c/s|a) and casual
|
||||
/// (c/s — quit via the close-X button instead of a footer button).
|
||||
/// True for plan-approval and casual plan preview (not plain file preview).
|
||||
pub fn show_footer(&self) -> bool {
|
||||
self.plan
|
||||
.as_ref()
|
||||
|
|
@ -1477,10 +1478,7 @@ pub fn render_line_viewer(
|
|||
// Buttons use the same `key bold + label dim` treatment as
|
||||
// `render_modal_shortcuts`, sit in a single row separated by
|
||||
// ` | `, centered within the modal frame.
|
||||
//
|
||||
// - Plan-approval: q quit | c comment | s send / a approve
|
||||
// - Casual preview: c comment | s send (no `q` —
|
||||
// the close-X button handles closing in casual mode)
|
||||
// Casual preview omits `q` (close via the X button).
|
||||
if viewer.show_footer() && inner.height >= 2 {
|
||||
let div_y = inner.y + inner.height - 2;
|
||||
let div_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
|
||||
|
|
@ -1492,11 +1490,15 @@ pub fn render_line_viewer(
|
|||
let abandon_hovered = viewer.plan_ref().is_some_and(|p| p.abandon_hovered);
|
||||
let comment_hovered = viewer.plan_ref().is_some_and(|p| p.comment_hovered);
|
||||
let approve_hovered = viewer.plan_ref().is_some_and(|p| p.approve_hovered);
|
||||
let copy_hovered = viewer.plan_ref().is_some_and(|p| p.copy_hovered);
|
||||
let is_approval = viewer.feedback_active();
|
||||
|
||||
let comment_spans = build_shortcut_button('c', "comment", comment_hovered, theme);
|
||||
let comment_w: u16 = comment_spans.iter().map(|s| s.width() as u16).sum();
|
||||
|
||||
let copy_spans = build_shortcut_button('y', "copy plan", copy_hovered, theme);
|
||||
let copy_w: u16 = copy_spans.iter().map(|s| s.width() as u16).sum();
|
||||
|
||||
// In approval mode, always show `a approve`. When there are
|
||||
// pending review comments, also show `s revise` (request changes).
|
||||
// In approval mode, show `a approve` (or `a approve w/ comments`
|
||||
|
|
@ -1555,18 +1557,20 @@ pub fn render_line_viewer(
|
|||
let sep_w: u16 = 5; // separator is fixed-width ASCII; matches modal_window.rs:565
|
||||
let sep_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
|
||||
|
||||
// Total width: [action] + (sep + revise)? + sep + comment[badge?] + (sep + quit)?
|
||||
let mut total_w: u16 = 0;
|
||||
let mut base_w: u16 = 0;
|
||||
if action_w > 0 {
|
||||
total_w = total_w.saturating_add(action_w).saturating_add(sep_w);
|
||||
base_w = base_w.saturating_add(action_w).saturating_add(sep_w);
|
||||
}
|
||||
if revise_w > 0 {
|
||||
total_w = total_w.saturating_add(revise_w).saturating_add(sep_w);
|
||||
base_w = base_w.saturating_add(revise_w).saturating_add(sep_w);
|
||||
}
|
||||
total_w = total_w.saturating_add(comment_w).saturating_add(badge_w);
|
||||
base_w = base_w.saturating_add(comment_w).saturating_add(badge_w);
|
||||
if let Some((_, w)) = &quit_spans {
|
||||
total_w = total_w.saturating_add(sep_w).saturating_add(*w);
|
||||
base_w = base_w.saturating_add(sep_w).saturating_add(*w);
|
||||
}
|
||||
let with_copy_w = base_w.saturating_add(sep_w).saturating_add(copy_w);
|
||||
let show_copy = with_copy_w <= inner.width;
|
||||
let total_w = if show_copy { with_copy_w } else { base_w };
|
||||
|
||||
if total_w <= inner.width {
|
||||
let mut x = inner.x + (inner.width - total_w) / 2;
|
||||
|
|
@ -1620,6 +1624,20 @@ pub fn render_line_viewer(
|
|||
x += badge_w;
|
||||
}
|
||||
|
||||
if show_copy {
|
||||
buf.set_string(x, bottom_y, separator, sep_style);
|
||||
x += sep_w;
|
||||
let copy_x = x;
|
||||
for span in ©_spans {
|
||||
let w = span.width() as u16;
|
||||
buf.set_span(x, bottom_y, span, w);
|
||||
x += w;
|
||||
}
|
||||
viewer.plan_mut().copy_button_area = Some(Rect::new(copy_x, bottom_y, copy_w, 1));
|
||||
} else {
|
||||
viewer.plan_mut().copy_button_area = None;
|
||||
}
|
||||
|
||||
// Quit button — approval mode only.
|
||||
if let Some((spans, w)) = quit_spans {
|
||||
buf.set_string(x, bottom_y, separator, sep_style);
|
||||
|
|
@ -1640,6 +1658,7 @@ pub fn render_line_viewer(
|
|||
let plan = viewer.plan_mut();
|
||||
plan.approve_button_area = None;
|
||||
plan.comment_button_area = None;
|
||||
plan.copy_button_area = None;
|
||||
plan.abandon_button_area = None;
|
||||
}
|
||||
}
|
||||
|
|
@ -1683,6 +1702,20 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_preview_exposes_full_raw_markdown_for_copy() {
|
||||
let body = "# Plan\n\n- Do the thing\n- Then ship";
|
||||
let mut viewer = LineViewerState::open_markdown_content("plan.md", body.to_owned(), None)
|
||||
.expect("markdown content should open");
|
||||
viewer.kind = LineViewerKind::PlanPreview;
|
||||
viewer.prepare_layout(80, 20);
|
||||
|
||||
assert_eq!(
|
||||
viewer.markdown_content_for_feedback().as_deref(),
|
||||
Some(body)
|
||||
);
|
||||
}
|
||||
|
||||
fn line_text(line: &Line<'_>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -1826,6 +1826,8 @@ impl PromptWidget {
|
|||
terminal.multiplexer = %evt.terminal.multiplexer,
|
||||
terminal.is_ssh = evt.terminal.is_ssh,
|
||||
terminal.term_var = %evt.terminal.term_var,
|
||||
terminal.term_version = %evt.terminal.term_version,
|
||||
terminal.term_version_source = %evt.terminal.term_version_source,
|
||||
key.code = %evt.key_code,
|
||||
key.modifiers = %evt.key_modifiers,
|
||||
key.kind = %evt.key_kind,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
// Per-test-case module for the `pty_e2e` integration test crate.
|
||||
#[allow(unused_imports)]
|
||||
use super::common::*;
|
||||
|
||||
/// 1a. **Embedded mode (`--no-leader`) boots without hanging on a blocked backend.**
|
||||
///
|
||||
/// Enterprise deployments set `[cli] use_leader = false` and point at their own
|
||||
/// backend, often with the grok.com proxy blocked. A TCP listener that accepts
|
||||
/// but never replies stands in for that endpoint, so every startup HTTP call
|
||||
/// stalls until the client's own bounded timeout fires. The welcome screen must
|
||||
/// render anyway; a hang here means some boot path went unbounded.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn embedded_mode_boots_without_hanging_on_blocked_backend() {
|
||||
// Accept connections but never respond, holding the streams open.
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
std::thread::spawn(move || {
|
||||
let mut held = Vec::new();
|
||||
for stream in listener.incoming() {
|
||||
match stream {
|
||||
Ok(s) => held.push(s),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
let base = format!("http://{addr}/v1");
|
||||
|
||||
let home = tempfile::tempdir().expect("home");
|
||||
let grok_home = home.path().join(".grok");
|
||||
std::fs::create_dir_all(&grok_home).unwrap();
|
||||
let env = [
|
||||
("HOME", home.path().to_str().unwrap()),
|
||||
("GROK_HOME", grok_home.to_str().unwrap()),
|
||||
("XAI_API_KEY", "test-key-for-ci"),
|
||||
("GROK_CLI_CHAT_PROXY_BASE_URL", base.as_str()),
|
||||
("GROK_XAI_API_BASE_URL", base.as_str()),
|
||||
("GROK_TELEMETRY_ENABLED", "false"),
|
||||
("GROK_FEEDBACK_ENABLED", "false"),
|
||||
("GROK_TRACE_UPLOAD", "false"),
|
||||
];
|
||||
|
||||
let binary = pager_binary().expect("resolve pager binary");
|
||||
let mut harness = PtyHarness::new_inherited_env(
|
||||
&binary,
|
||||
DEFAULT_ROWS,
|
||||
DEFAULT_COLS,
|
||||
&["--no-leader"],
|
||||
&env,
|
||||
None,
|
||||
)
|
||||
.expect("spawn pager");
|
||||
|
||||
// 30s exceeds the sum of the bounded startup fetches (auth + settings +
|
||||
// catalog, ~5s each), so a timeout here means an unbounded wait, not a
|
||||
// slow one.
|
||||
harness
|
||||
.wait_for_text(WELCOME_SCREEN_SENTINEL, Duration::from_secs(30))
|
||||
.expect("embedded welcome must render despite the blocked backend (boot went unbounded)");
|
||||
let screen = harness.screen_contents();
|
||||
assert!(!screen.contains("panicked"), "panic on screen:\n{screen}");
|
||||
|
||||
harness.quit().expect("clean quit");
|
||||
}
|
||||
|
|
@ -17,6 +17,8 @@ mod auto_compact_top_row;
|
|||
mod basename_path_demo_pty;
|
||||
#[path = "pty_e2e/doubled_lines_out_of_band_repro.rs"]
|
||||
mod doubled_lines_out_of_band_repro;
|
||||
#[path = "pty_e2e/embedded_mode_boots_without_hanging_on_blocked_backend.rs"]
|
||||
mod embedded_mode_boots_without_hanging_on_blocked_backend;
|
||||
#[path = "pty_e2e/initial_prompt_positional_auto_submits.rs"]
|
||||
mod initial_prompt_positional_auto_submits;
|
||||
#[path = "pty_e2e/input_echoes_at_idle_prompt.rs"]
|
||||
|
|
|
|||
Loading…
Reference in a new issue