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:
grokkybara[bot] 2026-07-26 20:03:03 +01:00
commit b41c75a578
92 changed files with 9410 additions and 3788 deletions

View file

@ -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.

View file

@ -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();

View file

@ -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)]

View file

@ -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()

View file

@ -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"));

View file

@ -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())

View file

@ -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");

View file

@ -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()

View file

@ -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!(

View file

@ -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");
}
}

View file

@ -73,6 +73,7 @@ fn terminal() -> TerminalContext {
vte_version: None,
tmux_extended_keys: None,
term_program_version: None,
env_term_version: None,
}
}

View file

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

View file

@ -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!(
[

View file

@ -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

View file

@ -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());

View file

@ -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 &copy_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()

View file

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