Synced from monorepo

Synced from monorepo

Changes:
- Report invalid MCP server config instead of failing startup
- Keep completed terminal output when the gateway connection is lost
- Show a duration-only detail view for single-task task output
- Don't let a stale registry turn counter hide local sessions
- Raise the file-descriptor soft limit on Linux and log effective limits at startup
- Stop aborting when HTTP client construction fails
- Make session thread and runtime spawn failures recoverable
- Fix main-prompt paste parity in the question freeform input
- Fire SessionEnd hooks on /exit and headless quit
- Embed the deployment-config signing public key
- Repaint paste-chip background on inline panel inputs
- Security: prevent acceptEdits from auto-approving agent writes into the always-trusted global hook root
- Fix stacked "Worked for" markers so parks render as status and turns close with exactly one marker
- Parse hooks from config files
- Add a remote kill-switch for managed-config signature verification
- Security: fix workspace file-reference resolution bypassing workspace filesystem confinement

Source-Revision: d02693a856a54f1030695b36b91d276e96b30b23
This commit is contained in:
grokkybara[bot] 2026-07-25 18:44:42 +00:00
commit 47348d13ec
138 changed files with 7283 additions and 5796 deletions

View file

@ -60,6 +60,9 @@ pub struct AcpConnection {
pub auth_methods: Vec<acp::AuthMethod>,
/// Cancellation token to stop the agent.
pub cancel: CancellationToken,
/// In-process agent worker thread (`connect` only). Join after cancel so
/// session actors can flush SessionEnd hooks. `None` in leader mode.
pub agent_thread: Option<std::thread::JoinHandle<anyhow::Result<()>>>,
/// ACP-advertised slash commands parsed from `InitializeResponse.meta.availableCommands`.
/// Seeded into every new `AgentSession` so autocomplete has shell builtins
/// and skills immediately, before any `AvailableCommandsUpdate` arrives.
@ -227,6 +230,7 @@ pub async fn connect(cancel: &CancellationToken, flags: ConnectFlags) -> Result<
is_grok_shell,
auth_methods,
cancel: spawned.cancel,
agent_thread: Some(spawned.thread_handle),
available_commands,
needs_login,
login_label,
@ -353,6 +357,7 @@ pub async fn connect_via_leader(
is_grok_shell,
auth_methods,
cancel: bridge.cancel,
agent_thread: None,
available_commands,
needs_login,
login_label,

View file

@ -3,8 +3,10 @@
//! Simplified to only support GrokShell (in-process) mode.
//! Subprocess and remote modes can be added later if needed.
use std::io::IsTerminal;
use std::rc::Rc;
use std::thread;
use std::time::Duration;
use anyhow::Result;
use tokio_util::sync::CancellationToken;
@ -14,15 +16,28 @@ use xai_acp_lib::{
acp_channels,
};
use xai_grok_shell::{
agent::{MvpAgent, config::Config as AgentConfig, models::RefreshStrategy},
agent::{
MvpAgent, activity::SESSION_FLUSH_GRACE, config::Config as AgentConfig,
models::RefreshStrategy,
},
auth::AuthManager,
util::grok_home::grok_home,
};
/// Extra slack when joining the agent OS thread after cancel so the flush
/// can finish and the thread can unwind.
const AGENT_JOIN_SLACK: Duration = Duration::from_secs(2);
/// How long the join stays silent before telling an interactive user why exit
/// is taking a moment. Short joins (the common case) print nothing.
const JOIN_NOTICE_AFTER: Duration = Duration::from_millis(1500);
/// Result of spawning a child agent.
pub struct SpawnedAgent {
/// Kept alive so the thread isn't detached. Will be used for graceful shutdown.
pub _thread_handle: thread::JoinHandle<Result<()>>,
/// Agent worker OS thread. Hand to [`AgentShutdownGuard`] so the worker is
/// cancelled and joined — letting session actors flush SessionEnd hooks —
/// on every exit path.
pub thread_handle: thread::JoinHandle<Result<()>>,
pub channel: AcpClientChannel,
pub cancel: CancellationToken,
/// The agent's `AuthManager`, shared so pager-side consumers (e.g. the voice
@ -30,6 +45,128 @@ pub struct SpawnedAgent {
pub auth_manager: std::sync::Arc<AuthManager>,
}
/// The single teardown mechanism for an in-process agent: cancels the worker
/// and joins it on drop, so session actors always get
/// `SessionCommand::Shutdown` (SessionEnd hooks, memory save) before the
/// process exits — on normal return, `?` bail, or panic unwind alike.
///
/// Hold one from every site that calls [`spawn_grok_shell`] (headless, the TUI,
/// `models`, `worktree`, `share`). Scope-end drop is the default; the TUI is the
/// one caller that drops it explicitly, because the join has to happen before
/// background processes are reaped (see `app::run`).
pub struct AgentShutdownGuard {
cancel: CancellationToken,
thread: Option<thread::JoinHandle<Result<()>>>,
}
impl AgentShutdownGuard {
/// Guard an in-process agent worker. A `None` thread makes the guard a
/// no-op cancel (leader mode has no in-process worker to join).
pub fn new(cancel: CancellationToken, thread: Option<thread::JoinHandle<Result<()>>>) -> Self {
Self { cancel, thread }
}
}
impl Drop for AgentShutdownGuard {
fn drop(&mut self) {
self.cancel.cancel();
let Some(handle) = self.thread.take() else {
return;
};
let timeout = SESSION_FLUSH_GRACE + AGENT_JOIN_SLACK;
match join_agent_thread(handle, timeout) {
JoinOutcome::Joined => {}
JoinOutcome::Failed(error) => {
tracing::warn!(%error, "agent worker exited with error after cancel");
}
JoinOutcome::Panicked(panic) => {
tracing::warn!(%panic, "agent worker panicked after cancel");
}
JoinOutcome::TimedOut => {
tracing::warn!(
timeout_ms = timeout.as_millis() as u64,
"agent worker did not exit within grace after cancel; \
session hooks may be incomplete"
);
}
JoinOutcome::HelperLost => {
tracing::warn!("agent worker join helper disappeared; proceeding");
}
}
}
}
/// Why the join ended, so each case is explicit at the call site (and callers
/// can tell a completed flush from an abandoned one).
#[derive(Debug, PartialEq, Eq)]
enum JoinOutcome {
/// Worker returned cleanly: session actors flushed within the grace.
Joined,
/// Worker returned an error; the flush may be incomplete.
Failed(String),
/// Worker panicked, with the payload rendered as text.
Panicked(String),
/// Worker was still running when the budget elapsed.
TimedOut,
/// The join helper vanished without reporting (helper thread itself died).
HelperLost,
}
/// Wait up to `timeout` for a cancelled agent worker to exit.
///
/// The blocking `join` runs on a helper thread so this stays callable from
/// `Drop` — which cannot await — while every caller sits on the async runtime.
/// On timeout that helper is abandoned rather than joined; this is safe **only
/// because every caller is on its way out of the process**, so the OS reaps the
/// thread at exit. Do not reuse this outside teardown.
fn join_agent_thread(handle: thread::JoinHandle<Result<()>>, timeout: Duration) -> JoinOutcome {
use std::sync::mpsc::RecvTimeoutError;
let (tx, rx) = std::sync::mpsc::channel();
thread::spawn(move || {
let _ = tx.send(handle.join());
});
// Two-phase wait: silent for a short join (overwhelmingly the common case),
// then a one-line notice so a slow SessionEnd hook does not look like a
// frozen exit. Only for a terminal — piped/JSON consumers stay clean.
let quiet = timeout.min(JOIN_NOTICE_AFTER);
match rx.recv_timeout(quiet) {
Ok(result) => return classify_join(result),
Err(RecvTimeoutError::Timeout) => {
if std::io::stderr().is_terminal() {
eprintln!("Finishing session hooks…");
}
}
Err(RecvTimeoutError::Disconnected) => return JoinOutcome::HelperLost,
}
match rx.recv_timeout(timeout.saturating_sub(quiet)) {
Ok(result) => classify_join(result),
Err(RecvTimeoutError::Timeout) => JoinOutcome::TimedOut,
Err(RecvTimeoutError::Disconnected) => JoinOutcome::HelperLost,
}
}
fn classify_join(result: thread::Result<Result<()>>) -> JoinOutcome {
match result {
Ok(Ok(())) => JoinOutcome::Joined,
Ok(Err(e)) => JoinOutcome::Failed(e.to_string()),
Err(payload) => JoinOutcome::Panicked(panic_message(payload)),
}
}
/// Render a panic payload as text — `panic!` payloads are `&str` or `String`,
/// so the log shows the message instead of an opaque `Any`.
fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
if let Some(s) = payload.downcast_ref::<&'static str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"non-string panic payload".to_string()
}
}
/// Spawn a GrokShell agent in a background thread.
///
/// Returns the ACP client channel for communication and a cancellation token.
@ -91,7 +228,7 @@ pub async fn spawn_grok_shell(
spawn_agent_thread_direct(spawn_fn, acp_agent, agent_cancel.clone(), skills_paths)?;
Ok(SpawnedAgent {
_thread_handle: handle,
thread_handle: handle,
channel: acp_client,
cancel: agent_cancel,
auth_manager: auth_manager_for_pager,
@ -161,9 +298,74 @@ fn spawn_agent_thread_direct(
};
tokio::task::yield_now().await;
// Keep running until cancelled
// Keep running until cancelled, then flush every live session
// actor (SessionEnd hooks + memory save) before the LocalSet /
// agent drop. Session actors live on dedicated OS threads and
// only exit cleanly on SessionCommand::Shutdown; without this
// flush, /exit and headless quit race process death and skip
// SessionEnd. Mirrors leader auto-update / relaunch.
cancel.cancelled().await;
agent_rc.flush_all_sessions(SESSION_FLUSH_GRACE).await;
anyhow::Result::Ok(())
})
})?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn join_reports_clean_worker_exit() {
let handle = thread::spawn(|| Ok(()));
assert_eq!(
join_agent_thread(handle, Duration::from_secs(5)),
JoinOutcome::Joined
);
}
#[test]
fn join_reports_worker_error() {
let handle = thread::spawn(|| Err(anyhow::anyhow!("flush failed")));
assert_eq!(
join_agent_thread(handle, Duration::from_secs(5)),
JoinOutcome::Failed("flush failed".to_string())
);
}
/// The timeout branch the built-binary e2e cannot reach: a wedged worker
/// (e.g. a hung SessionEnd hook) is abandoned once the budget elapses
/// instead of holding the process open indefinitely.
#[test]
fn join_abandons_wedged_worker_at_budget() {
let handle = thread::spawn(|| {
thread::sleep(Duration::from_secs(30));
Ok(())
});
let started = std::time::Instant::now();
assert_eq!(
join_agent_thread(handle, Duration::from_millis(50)),
JoinOutcome::TimedOut
);
assert!(
started.elapsed() < Duration::from_secs(5),
"join must return at its budget, not wait out the worker"
);
}
#[test]
fn panic_payloads_render_as_text() {
assert_eq!(
classify_join(Err(Box::new("boom"))),
JoinOutcome::Panicked("boom".to_string())
);
assert_eq!(
classify_join(Err(Box::new("boom".to_string()))),
JoinOutcome::Panicked("boom".to_string())
);
assert_eq!(
classify_join(Err(Box::new(7u32))),
JoinOutcome::Panicked("non-string panic payload".to_string())
);
}
}

View file

@ -268,6 +268,7 @@ pub struct AcpUpdateTracker {
last_stream_start_ms: Option<i64>,
/// Monotonic count of live parent-agent updates that changed scrollback.
agent_output_epoch: u64,
epoch_at_last_finish: u64,
/// Session project cwd for display-only redundant-`cd` stripping.
/// Set from [`AgentSession::cwd`]; not used for execution.
session_cwd: Option<PathBuf>,
@ -376,9 +377,15 @@ impl AcpUpdateTracker {
pub fn new() -> Self {
Self::default()
}
/// Current boundary for visible live parent-agent output.
pub(crate) fn agent_output_epoch(&self) -> u64 {
self.agent_output_epoch
pub(crate) fn output_since_last_finish(&self) -> bool {
self.agent_output_epoch != self.epoch_at_last_finish
}
/// Mark all output so far as accounted for without finishing the turn —
/// for terminals that must be skipped while a client command owns the
/// screen (a full `finish_turn` would flush mid-command state such as
/// `pending_compaction`).
pub(crate) fn snapshot_output_epoch(&mut self) {
self.epoch_at_last_finish = self.agent_output_epoch;
}
fn bump_agent_output_epoch(&mut self) {
self.agent_output_epoch = self.agent_output_epoch.wrapping_add(1);
@ -827,6 +834,7 @@ impl AcpUpdateTracker {
}
/// Called when PromptResponse is received (turn complete).
pub fn finish_turn(&mut self, scrollback: &mut ScrollbackState) {
self.epoch_at_last_finish = self.agent_output_epoch;
self.finish_thinking(scrollback);
if let Some(agent_id) = self.current_agent_msg.take() {
scrollback.finish_running(agent_id);
@ -2782,31 +2790,51 @@ mod tests {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
assert!(tracker.handle_update(user_message("prompt"), &meta(), &mut sb));
assert_eq!(tracker.agent_output_epoch(), 0);
assert_eq!(tracker.agent_output_epoch, 0);
assert!(tracker.handle_update(agent_chunk("response"), &meta(), &mut sb));
assert_eq!(tracker.agent_output_epoch(), 1);
assert_eq!(tracker.agent_output_epoch, 1);
let replay = NotificationMeta {
is_replay: true,
..Default::default()
};
assert!(tracker.handle_update(agent_chunk(" replay"), &replay, &mut sb));
assert_eq!(tracker.agent_output_epoch(), 1);
assert_eq!(tracker.agent_output_epoch, 1);
assert!(tracker.handle_update(thought_chunk("thinking"), &meta(), &mut sb));
assert_eq!(tracker.agent_output_epoch(), 2);
assert_eq!(tracker.agent_output_epoch, 2);
assert!(tracker.handle_update(
tool_call("read-1", acp::ToolKind::Read, "read_file"),
&meta(),
&mut sb,
));
assert_eq!(tracker.agent_output_epoch(), 3);
assert_eq!(tracker.agent_output_epoch, 3);
assert!(tracker.handle_update(tool_update_completed("read-1"), &meta(), &mut sb));
assert_eq!(tracker.agent_output_epoch(), 4);
assert_eq!(tracker.agent_output_epoch, 4);
assert!(!tracker.handle_update(
tool_call("todo-1", acp::ToolKind::Other, "TodoWrite"),
&meta(),
&mut sb,
));
assert_eq!(tracker.agent_output_epoch(), 4);
assert_eq!(tracker.agent_output_epoch, 4);
}
#[test]
fn output_since_last_finish_flips_per_turn() {
let mut sb = ScrollbackState::new();
let mut tracker = AcpUpdateTracker::new();
tracker.finish_turn(&mut sb);
assert!(
!tracker.output_since_last_finish(),
"no output right after a finish"
);
assert!(tracker.handle_update(agent_chunk("wake reply"), &meta(), &mut sb));
assert!(
tracker.output_since_last_finish(),
"an agent message chunk flips the flag"
);
tracker.finish_turn(&mut sb);
assert!(
!tracker.output_since_last_finish(),
"the next finish snapshots the epoch again"
);
}
#[test]
fn streaming_thinking() {
@ -5858,7 +5886,7 @@ mod tests {
);
assert_eq!(sb.len(), 1);
assert_eq!(tracker.pending_tools.len(), 1);
let output_epoch = tracker.agent_output_epoch();
let output_epoch = tracker.agent_output_epoch;
let modified = tracker.handle_update(
tool_update_in_progress_bg("tc1", b"started"),
&meta(),
@ -5869,9 +5897,8 @@ mod tests {
"bg tool deferral should suppress further output streaming"
);
assert_eq!(
tracker.agent_output_epoch(),
output_epoch,
"deferral must not bump the epoch (re-pushes the parked marker)"
tracker.agent_output_epoch, output_epoch,
"deferral must not bump the epoch (it is not visible agent output)"
);
assert_eq!(sb.len(), 1, "real execute entry kept for demotion");
assert!(
@ -5889,7 +5916,7 @@ mod tests {
);
}
/// Regression: a bg-tool deferral (here dropping the placeholder row) must
/// not bump `agent_output_epoch` — bumping re-pushed the parked marker.
/// not bump `agent_output_epoch` — it is not visible agent output.
#[test]
fn bg_tool_deferral_does_not_bump_agent_output_epoch() {
let mut sb = ScrollbackState::new();
@ -5900,7 +5927,7 @@ mod tests {
&mut sb,
);
assert_eq!(sb.len(), 1);
let epoch = tracker.agent_output_epoch();
let epoch = tracker.agent_output_epoch;
let update = acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from("tc1")),
acp::ToolCallUpdateFields::new()
@ -5914,9 +5941,8 @@ mod tests {
assert_eq!(sb.len(), 0, "placeholder dropped on deferral");
assert!(tracker.bg_deferred_tools.contains_key("tc1"));
assert_eq!(
tracker.agent_output_epoch(),
epoch,
"deferral must not bump the epoch (re-pushes the parked marker)"
tracker.agent_output_epoch, epoch,
"deferral must not bump the epoch (it is not visible agent output)"
);
}
/// Eager kind=Other title=`run_terminal_command` must not flash in the TUI.

View file

@ -229,15 +229,6 @@ pub(super) fn handle_task_backgrounded(notif: &acp::ExtNotification, app: &mut A
bg.scrollback_entry_id = Some(entry_id);
}
// Ext notifications reorder vs session updates: work registering after
// its awaiting wait must re-evaluate the skipped park. Root only — child
// tasks never enter root `bg_tasks`.
if !matches!(matched, SessionMatch::Child(_))
&& let Some((_, _, agent)) = resolve_notif_agent(app, &session_notif.session_id)
{
agent.maybe_push_parked_marker();
}
is_active
}
@ -608,9 +599,8 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV
// Prefer the human description for "Task completed/failed: …" labels
// (same as "Task started"), falling back to the raw command only when
// no description was supplied.
let (command, elapsed, mut description, scrollback_entry_id, was_running) =
let (command, elapsed, mut description, scrollback_entry_id) =
if let Some(bg_task) = session.bg_tasks.get_mut(task_id) {
let was_running = bg_task.status == BgTaskStatus::Running;
bg_task.status = if success {
BgTaskStatus::Done
} else {
@ -626,7 +616,6 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV
bg_task.elapsed(),
bg_task.description.clone(),
bg_task.scrollback_entry_id,
was_running,
)
} else {
// Task we didn't know about — use snapshot data. Prefer
@ -652,9 +641,7 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV
Some(d)
}
});
// Unknown task: it never counted toward the parked marker's
// running total, so its completion is not a countdown edge.
(command, elapsed, description, None, false)
(command, elapsed, description, None)
};
// Finish the "Task started" scrollback entry (stops bullet animation).
@ -707,14 +694,5 @@ pub(super) fn handle_task_completed(notif: &acp::ExtNotification, app: &mut AppV
};
scrollback.push_block(block);
// Re-eval a withheld park; the slot self-dedupes. Root sessions only.
// (Re-borrow: `resolve_target_view` consumed the earlier `&mut`.)
if was_running
&& !matches!(matched, SessionMatch::Child(_))
&& let Some(agent) = app.agents.get_mut(&matched.agent_id())
{
agent.maybe_push_parked_marker();
}
is_active
}

View file

@ -62,7 +62,8 @@ use routing::{
use prompt_origin::{finish_wake_turn, viewer_turn_anchor};
pub(crate) use prompt_origin::{
is_server_initiated_prompt, is_wake_prompt, should_adopt_running_prompt,
is_scheduler_fired_prompt, is_server_initiated_prompt, is_wake_prompt,
should_adopt_running_prompt,
};
pub(crate) use subagent_activity::finalize_killed_subagent;
@ -145,13 +146,9 @@ pub(crate) fn handle(msg: AcpClientMessage, app: &mut AppView) -> bool {
AcpClientMessage::SessionNotification(notif) => {
let mut meta = NotificationMeta::from_json(notif.request.meta.as_ref());
// Wait-state bookkeeping after the agent borrow ends (parked marker).
let mut wait_state_agent: Option<AgentId> = None;
let affected = match find_session_match(app, &notif.request.session_id) {
Some(SessionMatch::Root(id)) => {
let is_active = is_matched_agent_active(app, id);
wait_state_agent = Some(id);
// Read before the agent borrow below.
let stashed_adoption_pid = app
.pending_running_adoptions
@ -250,6 +247,7 @@ pub(crate) fn handle(msg: AcpClientMessage, app: &mut AppView) -> bool {
}
if let Some(ts) = meta.turn_start_ms {
agent.turn_start_ms = Some(ts);
agent.turn_start_ms_prompt = meta.prompt_id.clone();
}
}
@ -565,12 +563,6 @@ pub(crate) fn handle(msg: AcpClientMessage, app: &mut AppView) -> bool {
false
}
};
if let Some(aid) = wait_state_agent {
// Parked marker (any tab — the update that created the wait state stamps the park time).
if let Some(agent) = app.agents.get_mut(&aid) {
agent.maybe_push_parked_marker();
}
}
notif.response_tx.send(Ok(())).ok();
affected
}
@ -748,11 +740,6 @@ fn handle_interjection(notif: &acp::ExtNotification, app: &mut AppView) -> bool
agent
.scrollback
.push_block(RenderBlock::interjection_prompt(text));
// Interjecting into a parked wait continues the turn below this block —
// the withheld "Worked for …" marker must not fire late beneath it
// (shared-queue interjects render only via this broadcast, and the shell
// emits the queue-emptying `x.ai/queue/changed` right after it).
agent.suppress_parked_marker_on_interject();
is_active
}

View file

@ -303,11 +303,30 @@ fn build_permission_display(
}
};
let description = mcp_args_lines(req);
let description = permission_description_lines(req);
let bash_cmd = if is_execute { raw_command } else { None };
(title, description, bash_cmd)
}
/// Lines shown under the permission title: protected-edit note (if any), then
/// MCP planned-argument lines (empty for bash/edit).
fn permission_description_lines(req: &acp::RequestPermissionRequest) -> Vec<String> {
let mut lines = mcp_args_lines(req);
if is_edit_permission(req)
&& let Some(desc) = protected_edit_description(req)
{
lines.insert(0, desc);
}
lines
}
fn protected_edit_description(req: &acp::RequestPermissionRequest) -> Option<String> {
let meta = req.meta.as_ref()?;
let protected: xai_grok_workspace::permission::ProtectedEditPermission =
serde_json::from_value(serde_json::Value::Object(meta.clone())).ok()?;
protected.description.filter(|s| !s.is_empty())
}
/// Maximum stored lines for the MCP planned-arguments display. The overlay
/// clips further (options always stay visible); this only bounds memory for
/// pathologically large inputs.

View file

@ -25,7 +25,8 @@ pub(crate) fn is_scheduler_fired_prompt(prompt_id: &str) -> bool {
/// `subagent-completed-…`, `notifications-…`). These run non-adopted — no
/// `PromptResponse`, no viewer finalize — so their durable `TurnCompleted` is
/// the only signal marking the back-to-idle point (see [`finish_wake_turn`];
/// wake turns close markerless). Deliberately narrower than "non-adopted
/// a chatty wake closes with a marker, a silent one stays markerless).
/// Deliberately narrower than "non-adopted
/// synthetic": goal turns render through the goal chip/loop chrome and
/// `plan-resume-…` keeps its own markerless shape.
pub(crate) fn is_wake_prompt(prompt_id: &str) -> bool {
@ -86,10 +87,64 @@ pub(super) fn viewer_turn_anchor(turn_start_ms: Option<i64>) -> std::time::Insta
.unwrap_or(now)
}
/// Close out a wake turn: markerless, but the stream must be finished here —
/// wake turns skip `PromptResponse`, so this is the only flush site for an
/// in-flight streamed entry (dead wakes included). Leaves a real turn's
/// stop-hook stash pending for its own marker rail.
pub(super) fn finish_wake_turn(agent: &mut AgentView) {
/// Close out a wake turn — the only flush site for its in-flight streamed
/// entries (wake turns skip `PromptResponse`). Markers: visible output closes
/// with one; silence closes with none — except failures, which surface even
/// when silent (the user's standing instruction stopped executing invisibly).
/// Silent rate limits defer to the retry notifications, like the real-turn
/// rails.
pub(super) fn finish_wake_turn(
agent: &mut AgentView,
prompt_id: &str,
stop_reason: &str,
agent_result: Option<&str>,
) {
use crate::scrollback::blocks::SessionEvent;
let had_output = agent.session.tracker.output_since_last_finish();
agent.session.tracker.finish_turn(&mut agent.scrollback);
// The stored `turn_start_ms` may belong to an earlier turn (a silent wake
// streamed no deltas of its own; interleaved deltas can re-stamp it) —
// claim an elapsed only when the anchor is provably this wake's.
let anchor_is_ours = agent.turn_start_ms_prompt.as_deref() == Some(prompt_id);
let elapsed = if had_output && anchor_is_ours {
agent.turn_start_ms.and_then(|start_ms| {
let ms = chrono::Utc::now()
.timestamp_millis()
.saturating_sub(start_ms);
(ms >= 0).then(|| std::time::Duration::from_millis(ms as u64))
})
} else {
None
};
let already_failed = agent.failed_wake_marker_for.as_deref() == Some(prompt_id);
let event = match stop_reason {
"error" | "rate_limit"
if already_failed || (stop_reason == "rate_limit" && !had_output) =>
{
None
}
"error" | "rate_limit" => {
agent.failed_wake_marker_for = Some(prompt_id.to_string());
Some(SessionEvent::TurnFailed {
error: agent_result.map(str::to_string).unwrap_or_else(|| {
if stop_reason == "error" {
"unknown error".to_string()
} else {
"rate limited".to_string()
}
}),
elapsed,
})
}
"cancelled" if !had_output => None,
"cancelled" => Some(SessionEvent::TurnCancelled {
elapsed: elapsed.unwrap_or_default(),
}),
_ if !had_output => None,
_ => Some(SessionEvent::TurnCompleted { elapsed }),
};
if event.is_some() {
crate::app::turn_completion::push_turn_terminal_marker(agent, event, Some(prompt_id));
}
}

View file

@ -220,11 +220,6 @@ pub(super) fn handle_queue_changed(notif: &acp::ExtNotification, app: &mut AppVi
new_text: None,
});
}
// A queue change can empty the visible queue mid-wait — the marker
// may become eligible now (see `maybe_push_parked_marker`).
if let Some(agent) = app.agents.get_mut(&aid) {
agent.maybe_push_parked_marker();
}
}
// Adoption / turn-start correlation.

View file

@ -223,9 +223,40 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
false
} else if is_wake_prompt(&prompt_id) {
if agent.session.state.is_busy() {
if agent.session.state.command_in_flight().is_some() {
agent.session.tracker.snapshot_output_epoch();
}
let errored = matches!(stop_reason.as_str(), "error" | "rate_limit");
if errored && agent.failed_wake_marker_for.as_deref() != Some(&*prompt_id) {
agent.failed_wake_marker_for = Some(prompt_id.clone());
agent.push_end_marker_block(
crate::scrollback::blocks::SessionEvent::TurnFailed {
error: agent_result
.clone()
.unwrap_or_else(|| "unknown error".to_string()),
elapsed: None,
},
Vec::new(),
Some(prompt_id.clone()),
);
true
} else {
false
}
} else {
finish_wake_turn(agent, &prompt_id, &stop_reason, agent_result.as_deref());
true
}
} else if is_server_initiated_prompt(&prompt_id)
&& !is_scheduler_fired_prompt(&prompt_id)
{
if agent.session.state.is_busy() {
if agent.session.state.command_in_flight().is_some() {
agent.session.tracker.snapshot_output_epoch();
}
false
} else {
finish_wake_turn(agent);
agent.session.tracker.finish_turn(&mut agent.scrollback);
true
}
} else {
@ -455,7 +486,6 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
info.scrollback_entry_id = Some(entry_id);
info.is_background = is_background;
}
agent.maybe_push_parked_marker();
} else if let Some(info) = agent.subagent_sessions.get_mut(&child_session_id) {
info.is_background = is_background;
}
@ -596,9 +626,6 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
crate::app::subagent::finalize_finished_child_view(child_view, elapsed_dur);
}
}
if !resuming {
agent.maybe_push_parked_marker();
}
true
}
XaiSessionUpdate::HookAnnotation { message } => {

View file

@ -1,53 +1,9 @@
#![cfg_attr(rustfmt, rustfmt::skip)]
use super::*;
/// Regression: a shared-queue interjection renders only via the broadcast,
/// and the shell emits the queue-emptying `x.ai/queue/changed` right after
/// it — which used to fire the withheld parked marker BELOW the just-
/// rendered user message ("Worked for …" under the follow-up, flipped
/// transcript order). The broadcast must consume the marker slot instead.
#[test]
fn interjection_broadcast_mid_park_forgoes_parked_marker() {
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
let mut app = make_app_with_agent("sess-park");
{
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
simulate_task_output_wait(agent, "bg-1");
assert!(agent.is_parked_on_sendable_wait());
}
assert!(handle_ext_notification(
&interjection_broadcast("sess-park", "queued follow-up"),
&mut app,
));
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
assert_eq!(
agent.parked_wait_marker_for,
Some(crate::app::agent_view::ParkedMarkerSlot::Forgone(
"p1".into()
)),
"broadcast render must consume the parked-marker slot as Forgone"
);
// The queue-changed following the broadcast must not fire it late.
agent.maybe_push_parked_marker();
assert_eq!(
count_parked(agent),
0,
"no late 'Worked for …' marker under the interjection"
);
}
/// Regression: a Forgone slot (interjection continued
/// the parked turn, no marker on screen) must silence later marker pushes
/// — a full "Worked for …" line under the interjected message would
/// recreate the flipped transcript.
#[test]
fn forgone_slot_suppresses_later_marker_pushes() {
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
fn interjection_broadcast_mid_park_adds_no_marker() {
use crate::app::agent_view::test_fixtures::{count_turn_markers, simulate_task_output_wait};
let mut app = make_app_with_agent("sess-park");
{
@ -57,32 +13,44 @@
insert_running_task(agent, "t10", "sleep 10");
insert_running_task(agent, "t15", "sleep 15");
simulate_task_output_wait(agent, "t15");
// The parked drain interjected a queued row before the marker
// became eligible: slot consumed WITHOUT a marker.
agent.suppress_parked_marker_on_interject();
assert!(agent.renders_parked(), "forgone slot keeps parked chrome");
assert_eq!(count_parked(agent), 0, "no marker on screen");
assert!(agent.is_parked_on_sendable_wait());
assert_eq!(count_turn_markers(agent), 0, "the park writes no row");
}
assert!(handle_ext_notification(
&interjection_broadcast("sess-park", "queued follow-up"),
&mut app,
));
{
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
assert_eq!(
last_interjection_text(&agent.scrollback).as_deref(),
Some("queued follow-up"),
);
assert_eq!(
count_turn_markers(agent),
0,
"no 'Worked for …' marker around the interjection"
);
}
// A task completing in the still-parked window must stay silent.
handle_ext_notification(
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
&mut app,
);
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
assert_eq!(
count_parked(agent),
count_turn_markers(agent),
0,
"no 'Worked for …' tick under the interjection"
);
assert!(agent.renders_parked(), "the parked chrome stays on");
}
/// "sleep 10, 15, 20 in the background": completions within one park
/// episode push chips only — the marker never re-pushes. (Elapsed
/// renders as "0.0s": `turn_started_at` is unset in this fixture.)
#[test]
fn parked_completions_push_chips_without_marker_repush() {
use crate::app::agent_view::test_fixtures::simulate_task_output_wait;
fn parked_completions_push_chips_without_markers() {
use crate::app::agent_view::test_fixtures::{count_turn_markers, simulate_task_output_wait};
let mut app = make_app_with_agent("sess-park");
{
@ -93,12 +61,9 @@
insert_running_task(agent, "t15", "sleep 15");
insert_running_task(agent, "t20", "sleep 20");
simulate_task_output_wait(agent, "t20");
agent.maybe_push_parked_marker();
assert!(agent.renders_parked());
}
// Each completion lands as a chip; no marker re-push, no "N commands
// still running." lines.
handle_ext_notification(
&make_task_completed_notif("sess-park", "t10", "sleep 10", Some(0)),
&mut app,
@ -119,9 +84,9 @@
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
assert_eq!(
parked_marker_messages(agent),
vec!["Worked for 0.0s".to_string()],
"one plain marker per park episode — completions never re-push"
count_turn_markers(agent),
0,
"completions during a park never write a marker"
);
assert!(
work_status_lines(&agent.scrollback).is_empty(),
@ -129,58 +94,16 @@
);
}
/// Parity with the bg-command completion rail: a park withheld at park
/// time (held queue) gets re-evaluated by a subagent completion once the
/// blocker cleared, so the boundary marker isn't deferred to whenever the
/// next unrelated notification happens to arrive.
#[test]
fn subagent_finish_reevaluates_withheld_parked_marker() {
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
fn consecutive_subagent_finishes_stay_markerless() {
use crate::app::agent_view::test_fixtures::count_turn_markers;
let mut app = make_app_with_agent("sess-park");
{
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
for child_id in ["child-1", "child-2"] {
agent
.subagent_sessions
.insert(child_id.into(), make_subagent_info(child_id));
}
simulate_wait_all(agent);
// Held queue at park time: the marker is withheld.
agent.session.enqueue_prompt("queued follow-up".into());
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0, "held queue withholds the marker");
// The queue drains; nothing has re-evaluated the marker yet.
agent.session.pending_prompts.clear();
park_on_subagents(agent, &["child-1", "child-2", "child-3"]);
}
handle(
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
&mut app,
);
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
assert_eq!(
count_parked(agent),
1,
"the completion re-evaluates the withheld park"
);
assert_eq!(
parked_marker_messages(agent),
vec!["Worked for 0.0s".to_string()],
);
}
#[test]
fn consecutive_subagent_finishes_leave_single_parked_marker() {
let mut app = make_app_with_agent("sess-park");
let marker_id = {
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
park_on_subagents(agent, &["child-1", "child-2", "child-3"])
};
for child in ["child-1", "child-1", "child-2", "child-3"] {
handle(
make_ext_session_notification("sess-park", test_subagent_finished(child)),
@ -189,489 +112,41 @@
}
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
assert_eq!(
parked_marker_messages(agent),
vec!["Worked for 0.0s".to_string()],
"subagent finishes never re-push or mutate the park marker"
count_turn_markers(agent),
0,
"subagent finishes never write a marker mid-park"
);
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
}
/// A re-park after new parent output (text / thought / tool) is a new
/// park episode: the wait-state update that creates the second wait
/// pushes a fresh marker (epoch mismatch), while completions within one
/// episode never re-push.
#[test]
fn parent_text_thought_and_tool_output_start_new_park_episodes() {
fn repark_after_parent_output_stays_markerless() {
use crate::acp::meta::NotificationMeta;
use crate::app::agent_view::test_fixtures::simulate_task_output_wait_call;
crate::appearance::cache::set_show_thinking_blocks(true);
for output_kind in ["text", "thought", "tool"] {
let mut app = make_app_with_agent("sess-park");
let first_marker_id = {
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
for child_id in ["child-1", "child-2", "child-3"] {
agent
.subagent_sessions
.insert(child_id.into(), make_subagent_info(child_id));
}
if output_kind == "tool" {
assert!(agent.session.tracker.handle_update(
acp::SessionUpdate::ToolCall(
acp::ToolCall::new(
acp::ToolCallId::new(std::sync::Arc::from("parent-tool")),
"read_file",
)
.kind(acp::ToolKind::Read)
.status(acp::ToolCallStatus::InProgress)
.content(vec![])
.locations(vec![]),
),
&NotificationMeta::default(),
&mut agent.scrollback,
));
}
simulate_task_output_wait_call(agent, "wait-1", "not-ours", 30_000);
agent.maybe_push_parked_marker();
parked_marker_ids(agent)[0]
};
handle(
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
&mut app,
);
{
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
// Same episode: a repeated push attempt (e.g. another wait
// update restating the same wait) is deduped by epoch.
agent.maybe_push_parked_marker();
assert_eq!(parked_marker_ids(agent).len(), 1);
let output = match output_kind {
"text" => acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
acp::ContentBlock::Text(acp::TextContent::new("parent text")),
)),
"thought" => acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
acp::ContentBlock::Text(acp::TextContent::new("parent thought")),
)),
"tool" => acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(std::sync::Arc::from("parent-tool")),
acp::ToolCallUpdateFields::new()
.status(Some(acp::ToolCallStatus::Completed)),
)),
_ => unreachable!(),
};
assert!(agent.session.tracker.handle_update(
output,
&NotificationMeta::default(),
&mut agent.scrollback,
));
simulate_task_output_wait_call(agent, "wait-2", "not-ours", 30_000);
// The wait-state notification path re-evaluates the marker on
// every wait update (`maybe_push_parked_marker` from the ACP
// handler); mirror it for the fixture-driven second wait.
agent.maybe_push_parked_marker();
}
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
assert_eq!(
parked_marker_messages(agent),
vec!["Worked for 0.0s".to_string(), "Worked for 0.0s".to_string()],
"{output_kind} output must start a new park episode",
);
let marker_ids = parked_marker_ids(agent);
assert_eq!(marker_ids.len(), 2);
assert_eq!(marker_ids[0], first_marker_id);
assert_ne!(marker_ids[0], marker_ids[1]);
}
}
#[test]
fn interjection_suppresses_later_marker_push() {
let mut app = make_app_with_agent("sess-park");
let marker_id = {
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
park_on_subagents(agent, &["child-1", "child-2", "child-3"])
use crate::app::agent_view::test_fixtures::{
complete_task_output_wait_call, count_turn_markers, simulate_task_output_wait_call,
};
handle(
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
&mut app,
);
assert!(handle_ext_notification(
&interjection_broadcast("sess-park", "continue differently"),
&mut app,
));
handle(
make_ext_session_notification("sess-park", test_subagent_finished("child-2")),
&mut app,
);
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
assert_eq!(
parked_marker_messages(agent),
vec!["Worked for 0.0s".to_string()],
);
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
}
#[test]
fn replayed_subagent_finish_does_not_touch_marker() {
let mut app = make_app_with_agent("sess-park");
let marker_id = {
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
let marker_id = park_on_subagents(agent, &["child-1", "child-2"]);
agent.session.loading_replay = true;
marker_id
};
handle(
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
&mut app,
);
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
assert_eq!(
parked_marker_messages(agent),
vec!["Worked for 0.0s".to_string()],
);
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
}
#[test]
fn imminent_subagent_wait_keeps_single_marker() {
use crate::app::agent_view::test_fixtures::simulate_task_output_wait;
let mut app = make_app_with_agent("sess-park");
let marker_id = {
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
for child_id in ["child-1", "child-2"] {
agent
.subagent_sessions
.insert(child_id.into(), make_subagent_info(child_id));
}
simulate_task_output_wait(agent, "child-1");
agent.maybe_push_parked_marker();
parked_marker_ids(agent)[0]
};
handle(
make_ext_session_notification("sess-park", test_subagent_finished("child-1")),
&mut app,
);
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
assert_eq!(
parked_marker_messages(agent),
vec!["Worked for 0.0s".to_string()],
);
assert_eq!(parked_marker_ids(agent), vec![marker_id]);
}
/// Synthetic completions from cold-load reconciliation (`session_restart`
/// signal) finalize quietly — no countdown line, mirroring the suppressed
/// "Task failed" block: nothing happened in THIS session.
#[test]
fn stale_on_load_completion_pushes_no_countdown() {
use crate::app::agent_view::test_fixtures::simulate_task_output_wait;
let mut app = make_app_with_agent("sess-park");
{
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
insert_running_task(agent, "t10", "sleep 10");
insert_running_task(agent, "t15", "sleep 15");
simulate_task_output_wait(agent, "t15");
agent.maybe_push_parked_marker();
assert!(agent.renders_parked());
}
handle_ext_notification(
&make_task_completed_notif_with_signal(
"sess-park",
"t10",
"sleep 10",
None,
Some("session_restart"),
),
&mut app,
);
// Only the initial parked marker — no countdown re-push.
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
assert_eq!(parked_marker_messages(agent).len(), 1);
}
/// Task completions with no parked look (running turn chrome is up, or
/// the turn already ended) must not emit countdown lines — the Tasks
/// pane and completion blocks already narrate those states.
#[test]
fn task_completion_without_parked_look_pushes_no_countdown() {
let mut app = make_app_with_agent("sess-live");
{
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
insert_running_task(agent, "t10", "sleep 10");
insert_running_task(agent, "t15", "sleep 15");
// No wait, no parked marker: chrome is the live turn.
}
handle_ext_notification(
&make_task_completed_notif("sess-live", "t10", "sleep 10", Some(0)),
&mut app,
);
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
assert!(parked_marker_messages(agent).is_empty());
}
// -- imminent waits do not park (awaited work already finished) ----------
/// Waiting on a task that already completed: no marker, slot stays free.
#[test]
fn wait_on_already_completed_task_pushes_no_parked_marker() {
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
let mut app = make_app_with_agent("sess-park");
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
insert_running_task(agent, "t10", "sleep 10");
agent.session.bg_tasks.get_mut("t10").unwrap().status = BgTaskStatus::Done;
simulate_task_output_wait(agent, "t10");
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0, "imminent wait must not park");
assert!(
agent.parked_wait_marker_for.is_none(),
"slot must stay free for a later genuine park"
);
assert!(!agent.renders_parked());
}
/// A skipped wait leaves the slot free: a later wait on running work in
/// the same turn still parks.
#[test]
fn later_genuine_wait_still_parks_after_imminent_wait_skip() {
use crate::app::agent_view::test_fixtures::{
complete_task_output_wait_call, count_parked, simulate_task_output_wait_call,
};
let mut app = make_app_with_agent("sess-park");
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
insert_running_task(agent, "done", "sleep 1");
agent.session.bg_tasks.get_mut("done").unwrap().status = BgTaskStatus::Done;
simulate_task_output_wait_call(agent, "wait-1", "done", 30_000);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0);
simulate_task_output_wait_call(agent, "wait-1", "t10", 30_000);
assert!(agent.renders_parked());
assert_eq!(count_turn_markers(agent), 0);
complete_task_output_wait_call(agent, "wait-1");
insert_running_task(agent, "live", "sleep 99");
simulate_task_output_wait_call(agent, "wait-2", "live", 30_000);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 1, "genuine park still renders");
assert_eq!(
parked_marker_messages(agent),
vec!["Worked for 0.0s".to_string()],
);
}
/// `Failed` is terminal for imminence, not just `Done`.
#[test]
fn wait_on_failed_task_pushes_no_parked_marker() {
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
let mut app = make_app_with_agent("sess-park");
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
insert_running_task(agent, "t10", "sleep 10");
agent.session.bg_tasks.get_mut("t10").unwrap().status = BgTaskStatus::Failed;
simulate_task_output_wait(agent, "t10");
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0, "failed task wait must not park");
assert!(agent.parked_wait_marker_for.is_none());
}
/// Finished-subagent waits do not park — resolved by subagent id, then by
/// child session id.
#[test]
fn wait_on_finished_subagent_pushes_no_parked_marker() {
use crate::app::agent_view::test_fixtures::{
complete_task_output_wait_call, count_parked, simulate_task_output_wait_call,
};
let mut app = make_app_with_agent("sess-park");
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
let mut info = make_subagent_info("child-1");
info.finished = true;
agent.subagent_sessions.insert("child-1".into(), info);
simulate_task_output_wait_call(agent, "wait-1", "sa-child-1", 30_000);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0, "finished subagent wait must not park");
assert!(agent.parked_wait_marker_for.is_none());
complete_task_output_wait_call(agent, "wait-1");
simulate_task_output_wait_call(agent, "wait-2", "child-1", 30_000);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0, "child-session-id wait must not park");
assert!(agent.parked_wait_marker_for.is_none());
}
/// One unresolvable id among terminal ones keeps the park.
#[test]
fn wait_including_unknown_id_still_parks() {
use crate::acp::meta::NotificationMeta;
use crate::app::agent_view::test_fixtures::count_parked;
let mut app = make_app_with_agent("sess-park");
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
insert_running_task(agent, "done", "sleep 1");
agent.session.bg_tasks.get_mut("done").unwrap().status = BgTaskStatus::Done;
let meta = NotificationMeta::default();
agent.session.handle_update(
acp::SessionUpdate::ToolCall(
acp::ToolCall::new(
acp::ToolCallId::new(std::sync::Arc::from("wait-1")),
"get_command_or_subagent_output",
)
.kind(acp::ToolKind::Other)
.status(acp::ToolCallStatus::Pending)
.content(vec![])
.locations(vec![]),
),
&meta,
&mut agent.scrollback,
);
agent.session.handle_update(
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
acp::ToolCallId::new(std::sync::Arc::from("wait-1")),
acp::ToolCallUpdateFields::new().raw_input(Some(serde_json::json!({
"task_ids": ["done", "not-ours"],
"timeout_ms": 30_000,
}))),
assert!(agent.session.tracker.handle_update(
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
acp::ContentBlock::Text(acp::TextContent::new("between-parks content")),
)),
&meta,
&NotificationMeta::default(),
&mut agent.scrollback,
);
agent.maybe_push_parked_marker();
));
assert_eq!(count_parked(agent), 1, "unresolvable id keeps the park");
}
#[test]
fn wait_all_with_zero_work_pushes_no_parked_marker() {
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
let mut app = make_app_with_agent("sess-park");
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
simulate_wait_all(agent);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0, "zero-work wait-all must not park");
assert!(agent.parked_wait_marker_for.is_none());
}
#[test]
fn wait_all_with_running_work_still_parks() {
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
let mut app = make_app_with_agent("sess-park");
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
insert_running_task(agent, "t10", "sleep 10");
simulate_wait_all(agent);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 1, "wait-all on live work parks");
assert_eq!(
parked_marker_messages(agent),
vec!["Worked for 0.0s".to_string()],
);
}
/// `SubagentSpawned` arriving after the skipped zero-work wait
/// re-evaluates and restores the park.
#[test]
fn subagent_spawn_after_zero_work_wait_all_restores_park() {
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
let mut app = make_app_with_agent("sess-park");
{
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
simulate_wait_all(agent);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0, "zero-work wait-all skipped");
}
handle(
make_ext_session_notification(
"sess-park",
test_subagent_spawned("sess-park", "child-1"),
),
&mut app,
);
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
assert_eq!(count_parked(agent), 1, "spawn re-evaluates the skipped park");
assert_eq!(
parked_marker_messages(agent),
vec!["Worked for 0.0s".to_string()],
);
}
/// `x.ai/task_backgrounded` arriving after the skipped zero-work wait
/// re-evaluates and restores the park.
#[test]
fn task_backgrounded_after_zero_work_wait_all_restores_park() {
use crate::app::agent_view::test_fixtures::{count_parked, simulate_wait_all};
let mut app = make_app_with_agent("sess-park");
{
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
simulate_wait_all(agent);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0, "zero-work wait-all skipped");
}
handle_ext_notification(
&make_task_backgrounded_notif("sess-park", "tc-late", "t-late", "sleep 99"),
&mut app,
);
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
assert_eq!(
count_parked(agent),
1,
"task registration re-evaluates the skipped park"
);
assert_eq!(
parked_marker_messages(agent),
vec!["Worked for 0.0s".to_string()],
);
simulate_task_output_wait_call(agent, "wait-2", "t10", 30_000);
assert!(agent.renders_parked(), "the re-park renders parked again");
assert_eq!(count_turn_markers(agent), 0, "and still writes no marker");
}
#[test]
@ -770,4 +245,3 @@
"an interjection from another pane must render"
);
}

View file

@ -239,26 +239,7 @@ pub(super) fn insert_running_task(agent: &mut AgentView, task_id: &str, command:
},
);
}
/// Marker texts of all parked blocks in scrollback, in order — one per
/// park episode (re-pushed only after new parent output, i.e. a re-park).
pub(super) fn parked_marker_messages(agent: &AgentView) -> Vec<String> {
(0..agent.scrollback.len())
.filter_map(|i| match agent.scrollback.get(i).map(|e| &e.block) {
Some(RenderBlock::SessionEvent(b)) if b.parked => Some(b.event.message()),
_ => None,
})
.collect()
}
pub(super) fn parked_marker_ids(agent: &AgentView) -> Vec<EntryId> {
(0..agent.scrollback.len())
.filter_map(|i| {
let entry = agent.scrollback.get(i)?;
matches!(&entry.block, RenderBlock::SessionEvent(b) if b.parked)
.then_some(entry.id)
})
.collect()
}
pub(super) fn park_on_subagents(agent: &mut AgentView, child_ids: &[&str]) -> EntryId {
pub(super) fn park_on_subagents(agent: &mut AgentView, child_ids: &[&str]) {
use crate::app::agent_view::test_fixtures::simulate_wait_all;
agent.session.state = AgentState::TurnRunning;
agent.session.current_prompt_id = Some("p1".into());
@ -266,9 +247,7 @@ pub(super) fn park_on_subagents(agent: &mut AgentView, child_ids: &[&str]) -> En
agent.subagent_sessions.insert(child_id.into(), make_subagent_info(child_id));
}
simulate_wait_all(agent);
agent.maybe_push_parked_marker();
assert!(agent.renders_parked());
parked_marker_ids(agent)[0]
}
pub(super) fn follow_ups_ext(
response_id: &str,

View file

@ -231,7 +231,7 @@
}
#[test]
fn wake_turn_completed_is_markerless() {
fn silent_wake_turn_completed_is_markerless() {
let mut app = make_app_with_agent("sess-wake");
seed_two_bg_tasks(&mut app, "sess-wake");
let len_before = app.agents[&AgentId(0)].scrollback.len();
@ -254,7 +254,7 @@
assert_eq!(
agent.scrollback.len(),
len_before,
"a completed wake turn pushes no marker"
"a silent wake turn pushes no marker"
);
assert_eq!(
agent.watchers().commands,
@ -263,12 +263,65 @@
);
}
#[test]
fn chatty_wake_turn_completed_pushes_one_marker() {
use crate::app::agent_view::test_fixtures::count_turn_markers;
let mut app = make_app_with_agent("sess-wake");
let _ = handle(
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000),
&mut app,
);
assert_eq!(count_turn_markers(&app.agents[&AgentId(0)]), 0);
let affected = handle_ext_notification(
&xai_wake_turn_completed_notif("sess-wake", "task-completed-bg1", None),
&mut app,
);
assert!(affected);
let agent = app.agents.get(&AgentId(0)).unwrap();
assert_eq!(
count_turn_markers(agent),
1,
"a chatty wake closes with exactly one marker"
);
assert!(matches!(
last_session_event(&agent.scrollback),
Some(SessionEvent::TurnCompleted { .. })
));
}
#[test]
fn duplicate_wake_terminal_pushes_no_second_marker() {
// `finish_wake_turn` snapshots the output epoch, so a duplicate sees no new output.
use crate::app::agent_view::test_fixtures::count_turn_markers;
let mut app = make_app_with_agent("sess-wake");
let _ = handle(
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000),
&mut app,
);
let _ = handle_ext_notification(
&xai_wake_turn_completed_notif("sess-wake", "task-completed-bg1", None),
&mut app,
);
assert_eq!(count_turn_markers(&app.agents[&AgentId(0)]), 1);
let _ = handle_ext_notification(
&xai_wake_turn_completed_notif("sess-wake", "task-completed-bg1", None),
&mut app,
);
assert_eq!(
count_turn_markers(&app.agents[&AgentId(0)]),
1,
"a duplicate wake terminal must not push a second marker"
);
}
#[test]
fn wake_terminal_finishes_in_flight_streamed_entry() {
// A wake turn streams its response, then its terminal lands: the
// terminal is the ONLY flush site (wake turns skip PromptResponse),
// so the streamed entry must be finished — not left spinning until
// the next turn's stream start. Dead wakes take the same path.
// The terminal is a wake's ONLY flush site (wakes skip PromptResponse).
let mut app = make_app_with_agent("sess-wake");
let _ = handle(
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000),
@ -291,9 +344,7 @@
#[test]
fn wake_turn_completed_in_replay_only_records_pid() {
// The replay arm is untouched: a wake pid seen during a load's replay
// records adoption state and pushes nothing (markers are client-local
// and never replayed).
// Markers are client-local and never replayed.
let mut app = make_app_with_agent("sess-wake");
app.agents
.get_mut(&AgentId(0))
@ -324,9 +375,7 @@
#[test]
fn scheduler_fired_turn_completed_keeps_adopted_path() {
// `/loop` turns are synthetic but CLIENT-driven with a real finalize
// path — they must not take the wake-marker shortcut. Idle driver +
// scheduler pid → the shared finalize ignores it, no marker.
// `/loop` turns are client-driven with a real finalize path — never the wake shortcut.
let mut app = make_app_with_agent("sess-cron");
let len_before = app.agents[&AgentId(0)].scrollback.len();
@ -344,14 +393,209 @@
}
#[test]
fn failed_wake_turn_keeps_markerless_shape() {
// "Worked for" would lie about an errored/cancelled wake turn, and
// the cancel/failure UX is driver-side context this signal lacks —
// those stop reasons keep today's markerless shape.
fn silent_errored_wake_pushes_failure_marker() {
// Failures surface even when invisible: the standing instruction silently stopped.
let mut app = make_app_with_agent("sess-wake");
let len_before = app.agents[&AgentId(0)].scrollback.len();
for stop_reason in ["error", "cancelled", "rate_limit"] {
let _ = handle_ext_notification(
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false),
&mut app,
);
let agent = app.agents.get(&AgentId(0)).unwrap();
assert_eq!(agent.scrollback.len(), len_before + 1);
assert!(matches!(
last_session_event(&agent.scrollback),
Some(SessionEvent::TurnFailed { .. })
));
}
#[test]
fn silent_errored_wake_ignores_stale_turn_start_ms() {
// A silent wake streamed no deltas, so the stored `turn_start_ms` is an earlier turn's.
let mut app = make_app_with_agent("sess-wake");
app.agents.get_mut(&AgentId(0)).unwrap().turn_start_ms =
Some(chrono::Utc::now().timestamp_millis() - 600_000);
let _ = handle_ext_notification(
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false),
&mut app,
);
let agent = app.agents.get(&AgentId(0)).unwrap();
assert!(matches!(
last_session_event(&agent.scrollback),
Some(SessionEvent::TurnFailed { elapsed: None, .. })
));
}
#[test]
fn goal_terminal_snapshots_epoch_so_next_silent_wake_stays_markerless() {
// A dirty output epoch made the NEXT silent wake inherit the goal turn's output.
use crate::app::agent_view::test_fixtures::count_turn_markers;
let mut app = make_app_with_agent("sess-wake");
let _ = handle(
make_viewer_chunk_with_turn_start("sess-wake", "goal-summary-g1", 5_000),
&mut app,
);
let _ = handle_ext_notification(
&xai_turn_completed_notif("sess-wake", "goal-summary-g1", "end_turn", false),
&mut app,
);
let len_before = app.agents[&AgentId(0)].scrollback.len();
let _ = handle_ext_notification(
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "end_turn", false),
&mut app,
);
let agent = app.agents.get(&AgentId(0)).unwrap();
assert_eq!(
agent.scrollback.len(),
len_before,
"a silent wake after a goal turn must not inherit its output"
);
assert_eq!(count_turn_markers(agent), 0);
}
#[test]
fn errored_wake_terminal_during_local_turn_still_pushes_failure() {
// Failure visibility survives the busy skip: no tracker finish, no
// elapsed (the anchor is the local turn's), but the row must land.
use crate::app::agent::AgentState;
let mut app = make_app_with_agent("sess-wake");
app.agents.get_mut(&AgentId(0)).unwrap().session.state = AgentState::TurnRunning;
let len_before = app.agents[&AgentId(0)].scrollback.len();
for _ in 0..2 {
let _ = handle_ext_notification(
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false),
&mut app,
);
}
let agent = app.agents.get(&AgentId(0)).unwrap();
assert_eq!(agent.scrollback.len(), len_before + 1, "one row, deduped");
assert!(matches!(
last_session_event(&agent.scrollback),
Some(SessionEvent::TurnFailed { elapsed: None, .. })
));
}
#[test]
fn wake_terminal_during_command_snapshots_epoch_for_next_silent_wake() {
// A client command (e.g. /compact) skips the wake finish but must not
// leave the epoch dirty: the next silent wake would claim the skipped
// wake's output.
use crate::app::agent::{AgentCommand, AgentState};
use crate::app::agent_view::test_fixtures::count_turn_markers;
let mut app = make_app_with_agent("sess-wake");
let _ = handle(
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000),
&mut app,
);
app.agents.get_mut(&AgentId(0)).unwrap().session.state = AgentState::CommandRunning {
command: AgentCommand::Compact,
started_at: std::time::Instant::now(),
};
let _ = handle_ext_notification(
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "end_turn", false),
&mut app,
);
app.agents.get_mut(&AgentId(0)).unwrap().session.state = AgentState::Idle;
let len_before = app.agents[&AgentId(0)].scrollback.len();
let _ = handle_ext_notification(
&xai_turn_completed_notif("sess-wake", "task-completed-bg2", "end_turn", false),
&mut app,
);
let agent = app.agents.get(&AgentId(0)).unwrap();
assert_eq!(
agent.scrollback.len(),
len_before,
"silent wake after a command-skipped terminal must stay markerless"
);
assert_eq!(count_turn_markers(agent), 0);
}
#[test]
fn chatty_wake_with_foreign_turn_start_anchor_omits_elapsed() {
// `turn_start_ms` stamped by another prompt's deltas must not become
// this wake's elapsed.
let mut app = make_app_with_agent("sess-wake");
let _ = handle(
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 600_000),
&mut app,
);
let _ = handle_ext_notification(
&xai_turn_completed_notif("sess-wake", "task-completed-bg2", "end_turn", false),
&mut app,
);
let agent = app.agents.get(&AgentId(0)).unwrap();
assert!(matches!(
last_session_event(&agent.scrollback),
Some(SessionEvent::TurnCompleted { elapsed: None })
));
}
#[test]
fn silent_errored_wake_after_goal_turn_has_no_elapsed() {
let mut app = make_app_with_agent("sess-wake");
let _ = handle(
make_viewer_chunk_with_turn_start("sess-wake", "goal-summary-g1", 5_000),
&mut app,
);
let _ = handle_ext_notification(
&xai_turn_completed_notif("sess-wake", "goal-summary-g1", "end_turn", false),
&mut app,
);
let _ = handle_ext_notification(
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false),
&mut app,
);
let agent = app.agents.get(&AgentId(0)).unwrap();
assert!(matches!(
last_session_event(&agent.scrollback),
Some(SessionEvent::TurnFailed { elapsed: None, .. })
));
}
#[test]
fn duplicate_errored_wake_terminal_pushes_one_failure_marker() {
// Failures bypass the output-epoch dedupe, so duplicates are deduped by prompt id.
let mut app = make_app_with_agent("sess-wake");
let len_before = app.agents[&AgentId(0)].scrollback.len();
for _ in 0..2 {
let _ = handle_ext_notification(
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false),
&mut app,
);
}
assert_eq!(
app.agents[&AgentId(0)].scrollback.len(),
len_before + 1,
"one failure marker for the wake, duplicates dropped"
);
}
#[test]
fn silent_cancelled_or_rate_limited_wake_stays_markerless() {
// Rate limits ride the retry notifications instead, matching the real-turn rails.
let mut app = make_app_with_agent("sess-wake");
let len_before = app.agents[&AgentId(0)].scrollback.len();
for stop_reason in ["cancelled", "rate_limit"] {
let _ = handle_ext_notification(
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", stop_reason, false),
&mut app,
@ -361,10 +605,50 @@
assert_eq!(
app.agents[&AgentId(0)].scrollback.len(),
len_before,
"non-completion wake terminals push nothing"
"cancelled/rate-limited silent wake terminals push nothing"
);
}
#[test]
fn chatty_rate_limited_wake_closes_with_failure_marker() {
let mut app = make_app_with_agent("sess-wake");
let _ = handle(
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000),
&mut app,
);
let _ = handle_ext_notification(
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "rate_limit", false),
&mut app,
);
let agent = app.agents.get(&AgentId(0)).unwrap();
assert!(matches!(
last_session_event(&agent.scrollback),
Some(SessionEvent::TurnFailed { .. })
));
}
#[test]
fn chatty_errored_wake_pushes_failure_marker_not_worked_for() {
let mut app = make_app_with_agent("sess-wake");
let _ = handle(
make_viewer_chunk_with_turn_start("sess-wake", "task-completed-bg1", 5_000),
&mut app,
);
let _ = handle_ext_notification(
&xai_turn_completed_notif("sess-wake", "task-completed-bg1", "error", false),
&mut app,
);
let agent = app.agents.get(&AgentId(0)).unwrap();
assert!(matches!(
last_session_event(&agent.scrollback),
Some(SessionEvent::TurnFailed { .. })
));
}
#[test]
fn dead_wake_pushes_no_status_line() {
let mut app = make_app_with_agent("sess-wake");
@ -395,11 +679,8 @@
#[test]
fn wake_terminal_during_local_turn_pushes_nothing() {
// Wire interleave: wake turn W streams (pager idle), the user sends a
// prompt locally (TurnRunning), then FIFO delivers W's terminal
// before the new turn's deltas. A foreign "Worked for" under the
// fresh prompt would misattribute — the local turn pushes its own
// marker when it ends.
// FIFO can deliver a wake's terminal after a fresh local prompt starts; a
// foreign "Worked for" under that prompt would misattribute.
let mut app = make_app_with_agent("sess-wake");
seed_two_bg_tasks(&mut app, "sess-wake");
{
@ -429,10 +710,6 @@
#[test]
fn wake_terminal_leaves_real_turn_stash_pending() {
// Stop-hook stash semantics belong to real turns: a stash stamped
// with a REAL turn's pid must survive a wake turn's (markerless)
// terminal untouched — no fold, no standalone flush — and wait for
// its own marker rail.
use crate::scrollback::blocks::tool::{HookRunEntry, HookRunStatus};
let mut app = make_app_with_agent("sess-wake");
{
@ -900,9 +1177,7 @@
#[test]
fn will_wake_flag_is_ignored_wire_compat_pin() {
// `will_wake` is a wire-compat field the TUI no longer reads: a
// stamped completion must behave exactly like an unstamped one
// (chip-only). Pins the "ignored, not load-bearing" contract.
// `will_wake` is a wire-compat field the TUI no longer reads.
let mut app = make_app_with_agent("sess-wake-skip");
seed_two_bg_tasks(&mut app, "sess-wake-skip");
@ -1096,10 +1371,7 @@
#[test]
fn wake_stop_hooks_render_standalone_at_arrival() {
// Wake turns close markerless, so a wake-pid stop batch has no marker
// to fold into — it renders standalone the moment it arrives, whether
// it beats or trails its wake TurnCompleted. Never stashed: a stash
// keyed to a wake pid would wait for a marker that never comes.
// Never stashed: a stash keyed to a wake pid could wait for a marker that never comes.
let mut app = make_app_with_agent("sess-wake-idle");
// Hook beats the wake terminal.
@ -1141,9 +1413,6 @@
#[test]
fn wake_stop_hooks_never_stash_under_local_turn() {
// A wake batch landing while a LOCAL turn runs must not stash under
// (or fold onto) the unrelated local turn — it renders standalone,
// and the local turn's marker rail stays clean.
let mut app = make_app_with_agent("sess-wake-local");
{
let agent = app.agents.get_mut(&AgentId(0)).unwrap();

View file

@ -264,6 +264,7 @@ impl AgentView {
}
}
qv.focus = QuestionFocus::Navigation;
self.last_prompt_click_ms = None;
}
return InputOutcome::Changed;
}
@ -276,6 +277,7 @@ impl AgentView {
}
if key!('c', CONTROL).matches(key) {
qv.focus = QuestionFocus::Navigation;
self.last_prompt_click_ms = None;
return InputOutcome::Changed;
}
match self.prompt.route_enter(key) {
@ -306,6 +308,7 @@ impl AgentView {
}
}
qv.focus = QuestionFocus::Navigation;
self.last_prompt_click_ms = None;
let last = qv.questions.len().saturating_sub(1);
if qv.active_tab < last {
self.swap_question_freeform();
@ -349,7 +352,7 @@ impl AgentView {
&& matches!(key.code, KeyCode::Char(c) if c != ' ')
{
let text = qv.activate_freeform_input();
self.prompt.set_text(&text);
self.prompt.set_text_preserving(&text);
let _ = self.prompt.handle_key(key);
return InputOutcome::Changed;
}
@ -407,7 +410,7 @@ impl AgentView {
KeyCode::Char(' ') => {
if qv.is_on_freeform_row() {
let text = qv.activate_freeform_input();
self.prompt.set_text(&text);
self.prompt.set_text_preserving(&text);
} else {
let active = qv.active_tab;
let cursor = qv.cursor();
@ -427,7 +430,7 @@ impl AgentView {
KeyCode::Enter => {
if qv.is_on_freeform_row() {
let text = qv.activate_freeform_input();
self.prompt.set_text(&text);
self.prompt.set_text_preserving(&text);
} else {
let cursor = qv.cursor();
let active = qv.active_tab;
@ -448,7 +451,7 @@ impl AgentView {
let freeform_idx = qv.total_items(qv.active_tab).saturating_sub(1);
qv.set_cursor(freeform_idx);
let text = qv.activate_freeform_input();
self.prompt.set_text(&text);
self.prompt.set_text_preserving(&text);
}
}
KeyCode::Char('l') | KeyCode::Char(']') | KeyCode::Right
@ -590,6 +593,7 @@ impl AgentView {
*sel = None;
}
qv.focus = crate::views::question_view::QuestionFocus::Navigation;
self.last_prompt_click_ms = None;
}
let key_event = KeyEvent::new(
if key_ch == '\n' {
@ -669,6 +673,11 @@ impl AgentView {
.contains((mouse.column, mouse.row).into())
{
let _ = self.prompt.handle_mouse(mouse);
if self.prompt_click_is_double()
&& self.prompt.expand_paste_element_at_cursor()
{
self.prompt.refresh_slash(&self.session.models);
}
return InputOutcome::Changed;
}
let idx = qv.active_tab;
@ -687,6 +696,7 @@ impl AgentView {
*sel = None;
}
qv.focus = crate::views::question_view::QuestionFocus::Navigation;
self.last_prompt_click_ms = None;
}
let prompt_area = self.pane_areas.prompt;
let footer_h = 3u16;
@ -729,7 +739,7 @@ impl AgentView {
.get(active_tab)
.cloned()
.unwrap_or_default();
self.prompt.set_text(&text);
self.prompt.set_text_preserving(&text);
qv.focus = crate::views::question_view::QuestionFocus::InputMode;
}
return InputOutcome::Changed;
@ -812,7 +822,7 @@ impl AgentView {
.get(tab)
.cloned()
.unwrap_or_default();
self.prompt.set_text(&text);
self.prompt.set_text_preserving(&text);
qv.focus = QuestionFocus::InputMode;
}
}
@ -1028,7 +1038,7 @@ impl AgentView {
.get(qv.active_tab)
.map(|s| s.as_str())
.unwrap_or("");
self.prompt.set_text(new_text);
self.prompt.set_text_preserving(new_text);
}
/// Dismiss (hide) the question view without submitting answers.
///
@ -1195,6 +1205,7 @@ impl AgentView {
self.hit_question_scrollbar.clear();
self.inline_prompt_area = None;
self.last_question_click = None;
self.last_prompt_click_ms = None;
}
/// Answer the ACTIVE question of this agent's pending
/// `AskUserQuestion` from the dashboard peek panel.
@ -1684,7 +1695,7 @@ mod question_no_freeform_tests {
id: None,
}
}
fn open_question(agent: &mut AgentView, no_freeform: bool) {
pub(super) fn open_question(agent: &mut AgentView, no_freeform: bool) {
let state = QuestionViewState::new(
"tc-upsell".into(),
vec![upsell_question()],
@ -1698,7 +1709,7 @@ mod question_no_freeform_tests {
}
/// Draw one 80x30 frame so `pane_areas` and `question_scroll_region`
/// hold the real rendered layout the mouse handler hit-tests against.
fn draw_frame(agent: &mut AgentView) {
pub(super) fn draw_frame(agent: &mut AgentView) {
let area = Rect::new(0, 0, 80, 30);
let reg = ActionRegistry::defaults();
let bundle = crate::app::bundle::BundleState::default();
@ -1719,7 +1730,7 @@ mod question_no_freeform_tests {
crate::app::agent_view::AppRenderParams::default(),
);
}
fn down(col: u16, row: u16) -> MouseEvent {
pub(super) fn down(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
@ -1735,7 +1746,7 @@ mod question_no_freeform_tests {
modifiers: KeyModifiers::empty(),
}
}
fn qv(agent: &AgentView) -> &QuestionViewState {
pub(super) fn qv(agent: &AgentView) -> &QuestionViewState {
agent.question_view.as_ref().expect("question view open")
}
/// Clicking the empty rows under the last option (option gap, footer)
@ -1847,3 +1858,129 @@ mod question_no_freeform_tests {
assert_eq!(qv(&agent).focus, QuestionFocus::InputMode);
}
}
#[cfg(test)]
mod question_freeform_chip_tests {
//! Paste-chip round trip through the question freeform input:
//! re-entering input mode used to reload the unchanged draft with a
//! wholesale `set_text`, expanding every chip into raw text.
use super::super::test_fixtures::make_agent;
use super::question_no_freeform_tests::{down, draw_frame, open_question, qv};
use crate::app::agent_view::AgentView;
use crate::views::prompt_widget::KIND_PASTE;
use crate::views::question_view::QuestionFocus;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
const PASTE: &str = "line 1\nline 2\nline 3\nline 4\nline 5";
fn paste_chip_count(agent: &AgentView) -> usize {
agent
.prompt
.textarea()
.elements()
.iter()
.filter(|e| e.kind == KIND_PASTE)
.count()
}
/// Multi-line paste folds into a chip; Esc out and Enter back in must
/// keep the chip folded (not raw expanded text), and the string slot
/// keeps the full paste for the submit payload.
#[test]
fn paste_chip_survives_input_mode_round_trip() {
let mut agent = make_agent();
open_question(&mut agent, false);
let z = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE);
let _ = agent.handle_question_key(&z);
assert_eq!(qv(&agent).focus, QuestionFocus::InputMode);
let _ = agent.prompt.handle_paste(PASTE);
assert_eq!(paste_chip_count(&agent), 1, "paste must fold into a chip");
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let _ = agent.handle_question_key(&esc);
assert_eq!(qv(&agent).focus, QuestionFocus::Navigation);
assert_eq!(qv(&agent).per_question_freeform[0], PASTE);
assert!(qv(&agent).per_question_freeform_selected[0]);
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let _ = agent.handle_question_key(&enter);
assert_eq!(qv(&agent).focus, QuestionFocus::InputMode);
assert_eq!(
paste_chip_count(&agent),
1,
"re-entering input mode must keep the folded chip"
);
assert_eq!(agent.prompt.text(), PASTE, "buffer text must round-trip");
}
/// A slot rewritten by another surface (e.g. the dashboard peek answer
/// path) no longer matches the live draft, so re-entry must take the
/// normal `set_text` path and show the rewritten slot.
#[test]
fn rewritten_slot_replaces_stale_draft() {
let mut agent = make_agent();
open_question(&mut agent, false);
let z = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE);
let _ = agent.handle_question_key(&z);
let _ = agent.prompt.handle_paste(PASTE);
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let _ = agent.handle_question_key(&esc);
agent.question_view.as_mut().unwrap().per_question_freeform[0] = "peek answer".to_string();
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let _ = agent.handle_question_key(&enter);
assert_eq!(qv(&agent).focus, QuestionFocus::InputMode);
assert_eq!(
agent.prompt.text(),
"peek answer",
"a stale draft must not shadow the rewritten slot"
);
assert_eq!(paste_chip_count(&agent), 0);
}
/// Double-click on the chip inside the question freeform input expands
/// it, exactly like the main prompt; a single click must not.
#[test]
fn double_click_expands_chip_in_question_input() {
let mut agent = make_agent();
open_question(&mut agent, false);
let z = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE);
let _ = agent.handle_question_key(&z);
let _ = agent.prompt.handle_paste(PASTE);
assert_eq!(paste_chip_count(&agent), 1);
draw_frame(&mut agent);
let ta = agent.prompt.textarea_area();
assert!(ta.area() > 0, "inline textarea must have rendered");
let (col, row) = (ta.x + 2, ta.y);
let _ = agent.handle_question_mouse(&down(col, row));
assert_eq!(
paste_chip_count(&agent),
1,
"a single click must not expand the chip"
);
let _ = agent.handle_question_mouse(&down(col, row));
assert_eq!(
paste_chip_count(&agent),
0,
"double-click must expand the chip"
);
assert_eq!(agent.prompt.text(), PASTE, "content inlined as plain text");
assert_eq!(
qv(&agent).focus,
QuestionFocus::InputMode,
"expanding must not leave input mode"
);
}
/// A textarea click from before leaving InputMode must not pair with
/// the first click after re-entry as a double-click (exits clear the
/// pairing timer).
#[test]
fn click_before_exit_does_not_pair_with_click_after_reentry() {
let mut agent = make_agent();
open_question(&mut agent, false);
let z = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE);
let _ = agent.handle_question_key(&z);
let _ = agent.prompt.handle_paste(PASTE);
draw_frame(&mut agent);
let ta = agent.prompt.textarea_area();
let (col, row) = (ta.x + 2, ta.y);
let _ = agent.handle_question_mouse(&down(col, row));
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let _ = agent.handle_question_key(&esc);
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
let _ = agent.handle_question_key(&enter);
let _ = agent.handle_question_mouse(&down(col, row));
assert_eq!(paste_chip_count(&agent), 1, "chip must stay folded");
}
}

View file

@ -737,35 +737,6 @@ pub(crate) enum AgentDeferredSend {
/// Ctrl+Enter — a mid-turn interjection.
Interject,
}
/// How the parked-marker slot was consumed. Both variants carry the turn's
/// prompt id and both keep the parked (idle) chrome. `Rendered` markers are
/// one-per-park-episode — a re-park after new parent output (epoch bump)
/// pushes a fresh one (see `maybe_push_parked_marker`); `Forgone` (an
/// interjection continued the parked turn) is final — a later "Worked for"
/// line would land below the interjected message, flipping the transcript.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ParkedMarkerSlot {
/// A "Worked for X" marker block was pushed.
Rendered {
prompt_id: String,
/// The parent-output boundary at push time: chips/completions landing
/// under the marker don't bump it, so a matching epoch means "same
/// park episode — don't re-push".
agent_output_epoch: u64,
},
/// The marker was forgone: an interjection continued the parked turn.
Forgone(String),
}
impl ParkedMarkerSlot {
/// The prompt id the slot was consumed for, regardless of variant.
pub(crate) fn prompt_id(&self) -> &str {
match self {
ParkedMarkerSlot::Rendered { prompt_id, .. } | ParkedMarkerSlot::Forgone(prompt_id) => {
prompt_id
}
}
}
}
pub struct AgentView {
pub session: AgentSession,
pub(crate) session_binding_epoch: u32,
@ -848,6 +819,10 @@ pub struct AgentView {
/// turn that already ended (otherwise the viewer re-strands on "Waiting…").
/// Reset at the start of every load so it never leaks across loads.
pub(crate) replayed_terminal_prompts: HashSet<String>,
/// Wake prompt id whose failure marker already rendered — a re-delivered
/// errored wake terminal must not stack a second "Turn failed" row (the
/// output-epoch dedupe only covers chatty closes; failures bypass it).
pub(crate) failed_wake_marker_for: Option<String>,
pub active_pane: AgentPane,
/// Current mode of the prompt widget (normal vs editing a queued prompt).
pub prompt_mode: PromptMode,
@ -916,10 +891,6 @@ pub struct AgentView {
pub cleared_workflow_runs: std::collections::HashSet<String>,
pub show_workflows: bool,
pub workflows_view: crate::views::workflows::WorkflowsViewState,
/// The consumed parked-wait marker slot for the current turn, if any.
/// Keyed by prompt id: a new turn naturally invalidates the slot with no
/// explicit clear site. See [`ParkedMarkerSlot`].
pub(crate) parked_wait_marker_for: Option<ParkedMarkerSlot>,
/// Live `stop`/`stop_failure` hook runs held for the turn's terminal
/// marker (driver order: the hooks arrive before the `PromptResponse`
/// that pushes it). Consumed or flushed by `push_turn_terminal_marker`;
@ -938,6 +909,10 @@ pub struct AgentView {
/// UTC ms when the current turn started (`turnStartMs` from notification meta).
/// Used for turn elapsed display.
pub turn_start_ms: Option<i64>,
/// Prompt id the stored `turn_start_ms` belongs to (stamped together from
/// the same delta meta): wake markers may only claim an elapsed whose
/// anchor is provably their own turn's.
pub turn_start_ms_prompt: Option<String>,
/// Local wall-clock time when the current turn started.
/// Set by `maybe_drain_queue` when a prompt is sent. Used to compute
/// elapsed time for "Worked for Xm Ys" system messages.
@ -2422,15 +2397,17 @@ pub(crate) mod test_fixtures {
child_updates_replayed: false,
}
}
/// Count of parked ("Worked for X") marker blocks in the agent's
/// scrollback.
pub fn count_parked(agent: &AgentView) -> usize {
/// Count of "Worked for X" (`TurnCompleted`) marker blocks in the
/// agent's scrollback.
pub fn count_turn_markers(agent: &AgentView) -> usize {
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SessionEvent;
(0..agent.scrollback.len())
.filter(|i| {
matches!(
agent.scrollback.get(*i).map(|e| &e.block),
Some(RenderBlock::SessionEvent(b)) if b.parked
Some(RenderBlock::SessionEvent(b))
if matches!(b.event, SessionEvent::TurnCompleted { .. })
)
})
.count()

View file

@ -3,7 +3,7 @@
#[cfg(test)]
use super::test_fixtures;
use super::{AgentPane, AgentView, ParkedMarkerSlot, PromptMode, overlay_action_to_outcome};
use super::{AgentPane, AgentView, PromptMode, overlay_action_to_outcome};
use crate::actions::ActionRegistry;
use crate::app::actions::Action;
use crate::app::app_view::InputOutcome;
@ -76,8 +76,8 @@ impl AgentView {
/// the wait as user-interruptible would lie there).
///
/// Gates Enter interjecting instead of queueing and the parked queue
/// drain. The stopped-session *rendering* additionally requires the
/// parked-marker slot to be consumed — see [`Self::renders_parked`].
/// drain. The stopped-session *rendering* additionally excludes subagent
/// waits — see [`Self::renders_parked`].
/// Purely view-derived — reading it has no turn-lifecycle side effects.
pub(crate) fn is_parked_on_sendable_wait(&self) -> bool {
crate::views::turn_status::is_sendable_wait(&self.resolve_turn_activity())
@ -114,7 +114,7 @@ impl AgentView {
}
/// The current wait is a foreground subagent await — sendable, but excluded
/// from the parked marker (the parent is blocked, not completed; the
/// from the parked look (the parent is blocked, not completed; the
/// subagent reports its own progress).
pub(crate) fn is_waiting_on_subagent(&self) -> bool {
use crate::acp::tracker::{TurnActivity, WaitingReason};
@ -124,166 +124,6 @@ impl AgentView {
)
}
/// The wait can only return imminently: every awaited id is already
/// terminal, or a wait-all sees zero running work. Unknown ids and Sleep
/// are never imminent. Callers must pre-gate on
/// `is_parked_on_sendable_wait` — this predicate ignores `waits`.
fn parked_wait_resolves_imminently(&self) -> bool {
use crate::acp::tracker::{TurnActivity, WaitingReason};
match self.resolve_turn_activity() {
Some(TurnActivity::Waiting(WaitingReason::TaskOutput { task_ids, .. })) => {
!task_ids.is_empty() && task_ids.iter().all(|id| self.awaited_id_is_terminal(id))
}
// The tracker drops wait_commands_or_subagents' explicit task_ids;
// zero visible work is the only signal available here.
Some(TurnActivity::Waiting(WaitingReason::TasksComplete)) => {
self.watchers().awaitable_work() == 0
}
_ => false,
}
}
/// Terminal work behind an awaited id: bg task by task id, else subagent
/// by child session id or subagent id. Unknown → `false`.
fn awaited_id_is_terminal(&self, id: &str) -> bool {
if let Some(task) = self.session.bg_tasks.get(id) {
return task.status != crate::app::agent::BgTaskStatus::Running;
}
self.subagent_sessions
.get(id)
.or_else(|| {
self.subagent_sessions
.values()
.find(|info| info.subagent_id.as_ref() == id)
})
.is_some_and(|info| !info.is_running())
}
/// Push a "Worked for X" marker when the turn parks on a sendable wait —
/// the transcript boundary explaining the idle-looking chrome. One marker
/// per park episode: same agent-output epoch as the rendered slot means
/// no re-push (chips/completions don't bump it); an epoch bump means the
/// wait resumed and re-parked, which pushes a fresh marker. Completion
/// rails also call this to re-eval a park withheld at park time (e.g.
/// held queue since drained).
///
/// Called from the ACP notification path — not the draw path — so
/// background tabs and minimal mode stamp the park at its true moment. A
/// [`ParkedMarkerSlot::Forgone`] slot stays silent for the rest of the
/// turn (see [`Self::suppress_parked_marker_on_interject`]). UI-only: no
/// turn-lifecycle event, no stop hooks; the completion folds into an
/// uncommitted tail parked marker, else prints fresh (minimal-mode
/// commits are print-once).
pub(crate) fn maybe_push_parked_marker(&mut self) {
if !self.is_parked_on_sendable_wait()
|| self.is_waiting_on_subagent()
|| self.has_held_user_queue()
{
return;
}
let Some(prompt_id) = self.session.current_prompt_id.clone() else {
return;
};
match &self.parked_wait_marker_for {
// Interjection ordering: forgone is final for the turn.
Some(ParkedMarkerSlot::Forgone(pid)) if *pid == prompt_id => return,
// Same park episode (no parent output since the marker): the one
// marker already explains this park — chips landing below it
// must not re-push.
Some(ParkedMarkerSlot::Rendered {
prompt_id: pid,
agent_output_epoch,
..
}) if *pid == prompt_id
&& *agent_output_epoch == self.session.tracker.agent_output_epoch() =>
{
return;
}
// A tail user prompt after a rendered marker is an interjection:
// a marker line beneath it would flip the transcript.
Some(ParkedMarkerSlot::Rendered { prompt_id: pid, .. })
if *pid == prompt_id && self.tail_is_user_prompt() =>
{
return;
}
_ => {}
}
// Below the slot dedupe: a rendered park would otherwise log a false
// "skipped" on every subsequent update.
if self.parked_wait_resolves_imminently() {
tracing::debug!(
target: "prompt.parked_marker",
"parked marker skipped: awaited work already finished, wait resolves imminently"
);
return;
}
self.push_parked_marker_block(prompt_id);
}
/// The transcript tail is a user-authored prompt row.
fn tail_is_user_prompt(&self) -> bool {
matches!(
self.scrollback.last().map(|entry| &entry.block),
Some(crate::scrollback::block::RenderBlock::UserPrompt(_))
)
}
/// The parked marker block shape: a `TurnCompleted` marker flagged
/// `parked` (renders mid-turn, never accepts stop hooks).
fn push_parked_marker_block(&mut self, prompt_id: String) {
let agent_output_epoch = self.session.tracker.agent_output_epoch();
let mut block = crate::scrollback::blocks::SessionEventBlock::new(
crate::scrollback::blocks::SessionEvent::TurnCompleted {
// Unknown elapsed renders as "Worked for 0.0s" rather than
// falling back to `None`'s bare "Turn completed." — the park
// boundary should read like every other turn marker.
elapsed: Some(self.turn_elapsed().unwrap_or_default()),
},
);
block.parked = true;
block.prompt_id = Some(prompt_id.clone());
self.scrollback
.push_block(crate::scrollback::block::RenderBlock::SessionEvent(block));
self.parked_wait_marker_for = Some(ParkedMarkerSlot::Rendered {
prompt_id,
agent_output_epoch,
});
}
/// Consume the parked-marker slot as forgone when an interjection lands
/// while the turn is parked on a sendable wait: the turn visibly continues
/// below the user's message, so the withheld "Worked for … still
/// running." marker must never render under it (it would read as the turn
/// completing *after* the user's follow-up — flipped ordering). A no-op
/// when the marker already rendered (slot already stamped) or the turn is
/// not parked (a plain mid-turn interjection keeps a later park's marker).
///
/// Accepted edge: if the interject send later FAILS while the wait is
/// still parked (`TaskResult::InterjectFailed` requeues the payload), the
/// slot stays consumed — idle chrome without a marker until the wait
/// ends. Un-consuming would recreate the flipped ordering under the
/// already-rendered optimistic block.
pub(crate) fn suppress_parked_marker_on_interject(&mut self) {
if self.is_parked_on_sendable_wait()
&& let Some(prompt_id) = self.session.current_prompt_id.clone()
{
// Never downgrade a Rendered slot: with the marker on screen the
// ordering is already correct, and its countdown may keep ticking.
if self
.parked_wait_marker_for
.as_ref()
.is_some_and(|slot| slot.prompt_id() == prompt_id)
{
return;
}
tracing::debug!(
target: "prompt.auto_interject",
"parked marker forgone: interjection continued the parked turn"
);
self.parked_wait_marker_for = Some(ParkedMarkerSlot::Forgone(prompt_id));
}
}
/// Visible held rows for the "N queued" hint. 0 outside sendable waits.
pub(crate) fn held_queue_count(&self) -> usize {
// Goal-gated via `is_parked_on_sendable_wait` (0 during a goal — shell exempts goal turns).
@ -369,20 +209,13 @@ impl AgentView {
);
}
/// Whether the stopped-session look is active: the parked-marker slot for
/// the current turn was consumed (marker pushed, or forgone because an
/// interjection continued the parked turn) and the turn is still in its
/// sendable wait. Drives hiding the turn-status row and the idle keybar;
/// flips back off (the running chrome returns) the moment the wait ends
/// and the turn resumes.
/// Whether the stopped-session look is active: the turn is parked in a
/// sendable wait that is not a foreground subagent await. Purely
/// view-derived — no transcript row is written for a park. Drives the
/// idle keybar and the parked turn-status cue; flips back off (the
/// running chrome returns) the moment the wait ends and the turn resumes.
pub(crate) fn renders_parked(&self) -> bool {
self.parked_wait_marker_for
.as_ref()
.zip(self.session.current_prompt_id.as_deref())
.is_some_and(|(slot, pid)| slot.prompt_id() == pid)
&& self.is_parked_on_sendable_wait()
// Subagent waits keep running chrome — exclude them from the stopped look.
&& !self.is_waiting_on_subagent()
self.is_parked_on_sendable_wait() && !self.is_waiting_on_subagent()
}
/// Live counts for the turn-status watching cue; see
@ -423,15 +256,6 @@ impl AgentView {
stop_hooks: Vec<(String, Vec<crate::scrollback::blocks::tool::HookRunEntry>)>,
prompt_id: Option<String>,
) {
// Park → work finished → turn ended with nothing in between: fold the
// completion into the tail parked marker instead of stacking a dup row.
if self.scrollback.fold_completion_into_tail_parked_marker(
&event,
&stop_hooks,
prompt_id.as_deref(),
) {
return;
}
// The marker keeps its turn's pid for the tail-merge attribution check.
let block = crate::scrollback::blocks::SessionEventBlock::with_stop_hooks(
event, stop_hooks, prompt_id,
@ -678,10 +502,6 @@ impl AgentView {
if self.visible_queue_is_empty() {
self.hide_queue_pane();
}
// Deleting the last held row can flip the parked
// look on now (the ACP rebroadcast re-checks too,
// but the optimistic remove shouldn't lag).
self.maybe_push_parked_marker();
return InputOutcome::Action(Action::QueueRemoveShared {
id: server_id,
expected_version: row.version,
@ -691,11 +511,6 @@ impl AgentView {
}
// No drain kick (cf. mouse [cancel]): queue focus is unreachable mid-edit.
self.remove_local_queue_row(id);
// A LOCAL delete has no server rebroadcast to re-evaluate
// the parked look — deleting the last held row must flip
// the stopped chrome on immediately, not on the next
// unrelated notification.
self.maybe_push_parked_marker();
}
QueueEvent::EditSelected { id } => {
// Entry into editing mode lives in `queue_edit.rs`.

View file

@ -20,7 +20,7 @@ use crate::theme::Theme;
use crate::views::btw_overlay::BTW_OVERLAY_ENTRY_IDX;
use crate::views::modal;
use crate::views::plan_approval_view::PlanApprovalFocus;
use crate::views::prompt_widget::{PromptFlag, PromptInfo, PromptStyle};
use crate::views::prompt_widget::{PromptBg, PromptFlag, PromptInfo, PromptStyle};
use crate::views::question_view::QUESTION_VIEW_HPAD;
use crate::views::shortcuts_bar::{HintItem, PendingHint, ShortcutsBar};
use crate::views::{agent, turn_status};
@ -802,7 +802,7 @@ impl AgentView {
chrome: true,
chrome_pad_left: layout_cfg.block_pad_left,
chrome_pad_right: layout_cfg.block_pad_right,
bg_override: None,
bg: PromptBg::Default,
accent_color_override: if let Some(c) = self.prompt_input_mode.accent_color(&theme) {
Some(c)
} else if effective_plan || casual_commenting {
@ -944,7 +944,7 @@ impl AgentView {
chrome: false,
chrome_pad_left: 0,
chrome_pad_right: 0,
bg_override: Some(theme.bg_visual),
bg: PromptBg::Panel(theme.bg_visual),
accent_color_override: None,
border_color_override: None,
prefix_override: None,
@ -979,7 +979,7 @@ impl AgentView {
chrome: false,
chrome_pad_left: 0,
chrome_pad_right: 0,
bg_override: Some(theme.bg_visual),
bg: PromptBg::Panel(theme.bg_visual),
accent_color_override: None,
border_color_override: None,
prefix_override: None,
@ -2334,7 +2334,7 @@ impl AgentView {
chrome: false,
chrome_pad_left: 0,
chrome_pad_right: 0,
bg_override: Some(row_bg),
bg: PromptBg::Panel(row_bg),
accent_color_override: None,
border_color_override: None,
prefix_override: None,

View file

@ -99,6 +99,7 @@ impl AgentView {
session_reload: None,
unexpected_replay_drops: 0,
replayed_terminal_prompts: HashSet::new(),
failed_wake_marker_for: None,
active_pane: ActivePane::Prompt,
prompt_mode: PromptMode::Normal,
prompt_input_mode: PromptInputMode::Normal,
@ -125,11 +126,11 @@ impl AgentView {
cleared_workflow_runs: std::collections::HashSet::new(),
show_workflows: false,
workflows_view: crate::views::workflows::WorkflowsViewState::default(),
parked_wait_marker_for: None,
pending_stop_hooks: None,
last_cleared_goal_id: None,
show_goal_detail: false,
turn_start_ms: None,
turn_start_ms_prompt: None,
turn_started_at: None,
first_activity_logged_for: None,
turn_paused_duration: std::time::Duration::ZERO,

View file

@ -1229,6 +1229,17 @@ impl AppView {
.as_deref()
.is_some_and(|r| r.eq_ignore_ascii_case("admin"))
}
/// Why `coding_data_sharing` is locked for this user (`None` = editable).
/// Mirrors the dispatch guards in `set_coding_data_sharing`.
pub fn coding_data_sharing_lock(&self) -> Option<crate::settings::CodingDataSharingLock> {
if self.is_zdr {
Some(crate::settings::CodingDataSharingLock::Zdr)
} else if self.is_team_non_admin() {
Some(crate::settings::CodingDataSharingLock::TeamManaged)
} else {
None
}
}
/// Welcome privacy banner visibility gates.
pub fn privacy_banner_should_show(&self) -> bool {
if self.screen_mode.is_minimal() {

View file

@ -1286,6 +1286,7 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
}
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
let coding_data_sharing_lock_from_app = app.coding_data_sharing_lock();
let show_tips_from_app = app.show_tips;
let auto_update_from_app = app.auto_update;
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
@ -1390,6 +1391,7 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
.map(|(id, info)| (info.name.clone(), id.clone()))
.collect(),
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
coding_data_sharing_lock: coding_data_sharing_lock_from_app,
plan_mode_active: false,
show_tips: show_tips_from_app,
auto_update: auto_update_from_app,

View file

@ -53,9 +53,6 @@ pub(super) fn dispatch_interject(
agent
.scrollback
.push_block(RenderBlock::interjection_prompt(&text));
// Interjecting into a parked wait continues the turn below this block —
// the withheld "Worked for …" marker must not fire late beneath it.
agent.suppress_parked_marker_on_interject();
// The composer is NOT touched here: the producer that consumed composer
// text (the InterjectPrompt registry arm) clears it at the call site;
@ -145,7 +142,6 @@ pub(super) fn dispatch_send_prompt_now(
// The arm hides the queue echo pushed below — paint the block now.
super::queue::push_send_now_user_block(agent, &prompt_id, "prompt", &text, false);
}
agent.suppress_parked_marker_on_interject();
let blocks = crate::prompt_images::build_content_blocks_with_workspace(
text.clone(),

View file

@ -467,6 +467,7 @@ pub(super) fn dispatch_send_prompt_inner(
};
// Capture app-level fields before the mut-borrow on `agent`.
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
let coding_data_sharing_lock_from_app = app.coding_data_sharing_lock();
let show_tips_from_app = app.show_tips;
let auto_update_from_app = app.auto_update;
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
@ -563,6 +564,7 @@ pub(super) fn dispatch_send_prompt_inner(
.map(|(id, info)| (info.name.clone(), id.clone()))
.collect(),
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
coding_data_sharing_lock: coding_data_sharing_lock_from_app,
// Prefer optimistic pending over confirmed active.
plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active),
show_tips: show_tips_from_app,
@ -845,7 +847,6 @@ pub(super) fn dispatch_send_prompt_inner(
if parked_sendable_wait && !hold_behind_existing_queue {
agent.arm_send_now_expectation(prompt_id.clone());
agent.suppress_parked_marker_on_interject();
}
if consume_input {

View file

@ -1016,7 +1016,7 @@ mod tests {
use crate::app::actions::Action;
use crate::app::agent::AgentState;
use crate::app::agent_view::test_fixtures::{
complete_task_output_wait_call, count_parked, running_subagent_info,
complete_task_output_wait_call, count_turn_markers, running_subagent_info,
simulate_subagent_wait, simulate_task_output_wait, simulate_task_output_wait_call,
};
use crate::app::dispatch::router::dispatch;
@ -2299,64 +2299,59 @@ mod tests {
}
#[test]
fn parked_marker_fires_once_on_empty_queue_park() {
fn parked_wait_renders_parked_without_markers() {
let mut app = test_app_with_agent();
let id = AgentId(0);
dispatch(Action::SendPrompt("first".into()), &mut app);
simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1");
let agent = app.agents.get_mut(&id).unwrap();
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 1);
assert!(agent.renders_parked(), "marker + live wait = parked look");
// Idempotent within the same park.
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 1, "one marker per park");
assert!(agent.renders_parked(), "live wait = parked look");
assert_eq!(count_turn_markers(agent), 0, "a park writes no marker");
}
/// A re-park after new PARENT OUTPUT (streamed through the tracker, so
/// the agent-output epoch bumps) pushes a fresh marker for the new park
/// episode — otherwise the second park renders as a dead session.
#[test]
fn parked_marker_repushes_on_repark_after_new_parent_output() {
fn sibling_batch_park_writes_no_markers() {
use crate::acp::meta::NotificationMeta;
use std::sync::Arc;
let mut app = test_app_with_agent();
let id = AgentId(0);
dispatch(Action::SendPrompt("first".into()), &mut app);
let agent = app.agents.get_mut(&id).unwrap();
simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 1);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 1, "same episode must dedupe");
// Blocking get_task_output registers first; anchor still running.
simulate_task_output_wait_call(agent, "wait-1", "bg-anchor", 120_000);
agent
.session
.bg_tasks
.insert("bg-anchor".into(), running_bg_task("bg-anchor"));
assert_eq!(count_turn_markers(agent), 0);
complete_task_output_wait_call(agent, "wait-1");
assert!(!agent.renders_parked(), "no parked look between parks");
// Between-parks content streams through the tracker (the production
// path), bumping the agent-output epoch.
assert!(agent.session.tracker.handle_update(
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
acp::TextContent::new("between-parks content")
),)),
&NotificationMeta::default(),
&mut agent.scrollback,
));
simulate_task_output_wait_call(agent, "wait-2", "bg-1", 600_000);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 2, "new episode pushes a fresh marker");
assert!(agent.renders_parked());
for i in 2..=5 {
let tc_id = format!("wait-batch-tc{i}");
agent.session.handle_update(
acp::SessionUpdate::ToolCall(
acp::ToolCall::new(
acp::ToolCallId::new(Arc::from(tc_id.as_str())),
"run_terminal_command",
)
.kind(acp::ToolKind::Execute)
.status(acp::ToolCallStatus::Pending),
),
&NotificationMeta::default(),
&mut agent.scrollback,
);
}
assert_eq!(
count_turn_markers(agent),
0,
"a sibling-batch park must write zero markers"
);
}
/// Rows landing during a park WITHOUT parent output (chips and other
/// direct scrollback pushes) stay in the same park episode — the marker
/// is never re-pushed under them; the "… still running" status row carries
/// the ongoing-work story instead.
#[test]
fn parked_marker_stays_single_when_rows_land_mid_park() {
fn chips_and_completions_mid_park_add_no_markers() {
use crate::scrollback::block::RenderBlock;
let mut app = test_app_with_agent();
@ -2365,170 +2360,69 @@ mod tests {
let agent = app.agents.get_mut(&id).unwrap();
simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 1);
assert_eq!(count_turn_markers(agent), 0);
agent.scrollback.push_block(RenderBlock::bg_task_completed(
"sleep 5",
"bg-2",
std::time::Duration::from_secs(5),
));
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 1, "chips never re-push the marker");
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 1, "still the same park episode");
assert_eq!(count_turn_markers(agent), 0, "chips add no markers");
assert!(agent.renders_parked(), "chips keep the parked look");
}
/// A re-park whose previous marker is still the transcript tail pushes
/// nothing (poll loop: wait expiry → immediate re-issue).
#[test]
fn parked_marker_not_repushed_when_marker_still_tail() {
fn wait_completion_clears_parked_look() {
let mut app = test_app_with_agent();
let id = AgentId(0);
dispatch(Action::SendPrompt("first".into()), &mut app);
let agent = app.agents.get_mut(&id).unwrap();
simulate_task_output_wait_call(agent, "wait-1", "bg-1", 15_000);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 1);
assert!(agent.renders_parked());
// Wait tools render no blocks, so the marker stays the tail.
complete_task_output_wait_call(agent, "wait-1");
assert!(!agent.renders_parked(), "no parked look between parks");
assert_eq!(count_turn_markers(agent), 0);
simulate_task_output_wait_call(agent, "wait-2", "bg-1", 15_000);
agent.maybe_push_parked_marker();
assert!(agent.renders_parked(), "a re-park flips the look back on");
assert_eq!(count_turn_markers(agent), 0, "re-parks stay markerless");
}
#[test]
fn interjection_during_park_adds_no_marker() {
use crate::scrollback::block::RenderBlock;
let mut app = test_app_with_agent();
let id = AgentId(0);
dispatch(Action::SendPrompt("first".into()), &mut app);
simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1");
assert!(app.agents[&id].renders_parked());
let _ = dispatch(
Action::Interject {
text: "hurry up".into(),
images: Vec::new(),
},
&mut app,
);
let agent = &app.agents[&id];
assert_eq!(
count_parked(agent),
1,
"marker still at the tail: a re-push would be a duplicate line"
count_turn_markers(agent),
0,
"no marker around the interjection"
);
assert!(
agent.renders_parked(),
"the park itself still renders parked"
matches!(
agent.scrollback.last().map(|e| &e.block),
Some(RenderBlock::UserPrompt(_))
),
"the user prompt row lands with nothing under it"
);
}
/// An interjection below an already-pushed marker must not trigger a
/// restate beneath the user's message (the queue-emptying re-evaluation
/// fires before the wait-abort lands).
#[test]
fn rendered_slot_stays_quiet_under_tail_interjection() {
use crate::scrollback::block::RenderBlock;
let mut app = test_app_with_agent();
let id = AgentId(0);
dispatch(Action::SendPrompt("first".into()), &mut app);
let agent = app.agents.get_mut(&id).unwrap();
simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 1);
agent
.scrollback
.push_block(RenderBlock::interjection_prompt("hurry up"));
agent.suppress_parked_marker_on_interject();
agent.maybe_push_parked_marker();
assert_eq!(
count_parked(agent),
1,
"no marker may render beneath the interjected message"
);
}
/// `Forgone` is final for the turn: even a genuine re-park with buried
/// content must not resurrect the marker.
#[test]
fn forgone_slot_blocks_repark_repush() {
use crate::scrollback::block::RenderBlock;
let mut app = test_app_with_agent();
let id = AgentId(0);
dispatch(Action::SendPrompt("first".into()), &mut app);
let agent = app.agents.get_mut(&id).unwrap();
simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000);
agent.suppress_parked_marker_on_interject();
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0, "forgone park renders no marker");
complete_task_output_wait_call(agent, "wait-1");
agent
.scrollback
.push_block(RenderBlock::agent_message("continued below interject"));
simulate_task_output_wait_call(agent, "wait-2", "bg-1", 30_000);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0, "forgone stays silent all turn");
}
/// A work-count change never touches the marker — the counts live on the
/// status row's "… still running" cue, so the transcript stays quiet while
/// work finishes mid-park.
#[test]
fn count_change_never_restates_marker() {
let mut app = test_app_with_agent();
let id = AgentId(0);
dispatch(Action::SendPrompt("first".into()), &mut app);
let agent = app.agents.get_mut(&id).unwrap();
agent
.session
.bg_tasks
.insert("bg-1".into(), running_bg_task("bg-1"));
agent
.session
.bg_tasks
.insert("bg-2".into(), running_bg_task("bg-2"));
simulate_task_output_wait_call(agent, "wait-1", "bg-1", 30_000);
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 1);
assert_eq!(agent.watchers().commands, 2);
agent.session.bg_tasks.remove("bg-2");
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 1, "count changes never restate");
assert_eq!(agent.watchers().commands, 1, "the cue counts down instead");
}
#[test]
fn parked_marker_not_pushed_while_send_now_echo_is_only_row() {
let mut app = test_app_with_agent();
let id = AgentId(0);
dispatch(Action::SendPrompt("first".into()), &mut app);
enqueue_local(&mut app, id, "held then send-now'd");
simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1");
let agent = app.agents.get_mut(&id).unwrap();
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0, "held row withholds the marker");
agent.session.pending_prompts.clear();
agent.expect_send_now_cancel = Some("send-now-echo".into());
agent.shared_queue = vec![crate::app::prompt_queue::QueueEntryWire {
id: "send-now-echo".into(),
version: 0,
owner: None,
last_editor: None,
kind: "prompt".into(),
text: "send now payload".into(),
position: 0,
combined_texts: None,
}];
assert!(agent.visible_queue_is_empty());
assert!(agent.has_held_user_queue());
agent.maybe_push_parked_marker();
assert_eq!(
count_parked(agent),
0,
"send-now occupancy must block the parked marker"
);
agent.suppress_parked_marker_on_interject();
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0, "forgone slot stays silent");
}
/// Queued rows HOLD during a parked/blocking wait; nothing drains on its own.
#[test]
fn parked_wait_holds_queue_and_explains_itself() {
let mut app = test_app_with_agent();
@ -2538,9 +2432,11 @@ mod tests {
simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1");
let agent = app.agents.get_mut(&id).unwrap();
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0, "queued row must hold the marker");
assert!(!agent.renders_parked());
assert_eq!(count_turn_markers(agent), 0);
assert!(
agent.renders_parked(),
"the parked look is queue-occupancy-independent"
);
assert_eq!(
agent.held_queue_count(),
1,
@ -2565,12 +2461,7 @@ mod tests {
1,
"held row feeds the inline status hint"
);
agent.maybe_push_parked_marker();
assert_eq!(
count_parked(agent),
0,
"queued row holds the (excluded) marker"
);
assert_eq!(count_turn_markers(agent), 0);
assert!(!agent.renders_parked());
// Even with an empty queue + live subagent, a subagent wait never parks.
@ -2578,18 +2469,19 @@ mod tests {
.subagent_sessions
.insert("child-1".into(), running_subagent_info("child-1"));
agent.session.pending_prompts.clear();
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0, "subagent wait must never park");
assert_eq!(
count_turn_markers(agent),
0,
"subagent wait must never park"
);
assert!(
!agent.renders_parked(),
"subagent wait keeps running chrome"
);
}
/// T1 regression: once the model resumes streaming in the SAME turn, the
/// parked/stopped look must flip off (the running chrome returns) even if
/// the wait tool's terminal ToolCallUpdate never reached this client —
/// a live chunk proves the turn is no longer parked in the wait.
/// T1 regression: a live chunk must un-park even when the wait's terminal
/// ToolCallUpdate never reached this client.
#[test]
fn parked_look_clears_when_model_resumes_streaming() {
let mut app = test_app_with_agent();
@ -2598,7 +2490,6 @@ mod tests {
simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1");
let agent = app.agents.get_mut(&id).unwrap();
agent.maybe_push_parked_marker();
assert!(agent.renders_parked(), "parked look active during the wait");
// The model resumes with a message chunk (no Completed for the wait).
@ -2624,8 +2515,7 @@ mod tests {
"the stale wait must not survive a resumed stream"
);
// A new-stream thought also un-parks; same-stream thoughts must not.
// Establish the wait under stream_start=1, then thought under 9001.
// A new-stream thought (different stream_start_ms) also un-parks; same-stream must not.
{
let wait_meta = crate::acp::meta::NotificationMeta {
stream_start_ms: Some(1),
@ -2679,9 +2569,8 @@ mod tests {
);
}
/// T4 regression: the inline hint only advertises "Enter to send now"
/// when the TOP held row would actually send (server rows always; local
/// rows only when prompt-like — bash rows refuse with a toast).
/// T4 regression: the hint must only advertise "Enter to send now" when
/// the TOP held row would actually send (a bash top row no-ops).
#[test]
fn held_hint_advertises_send_now_only_for_sendable_top() {
let mut app = test_app_with_agent();
@ -2689,7 +2578,6 @@ mod tests {
dispatch(Action::SendPrompt("first".into()), &mut app);
simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1");
// Local bash row on top: counted, but Enter would no-op.
let agent = app.agents.get_mut(&id).unwrap();
agent.session.enqueue_bash_command("git status".into());
assert_eq!(agent.held_queue_count(), 1);
@ -2698,7 +2586,6 @@ mod tests {
"a bash top row must not advertise Enter-send-now"
);
// A plain local prompt on top instead: sendable.
agent.session.pending_prompts.clear();
agent.session.enqueue_prompt("plain follow-up".into());
assert!(agent.held_queue_top_sendable());
@ -2846,10 +2733,8 @@ mod tests {
assert_eq!(agent.held_queue_count(), 0);
}
/// The armed send-now cancel does NOT count as held occupancy once it is
/// the running turn (arm id == current_prompt_id) — otherwise the parked
/// marker is suppressed and a new prompt is wrongly held behind an empty
/// queue after a send-now adopts.
/// An arm that became the running turn is not held occupancy — otherwise a
/// new prompt is wrongly held behind an empty queue after a send-now adopts.
#[test]
fn has_held_user_queue_excludes_arm_that_is_running() {
let mut app = test_app_with_agent();
@ -2858,7 +2743,6 @@ mod tests {
agent.session.pending_prompts.clear();
agent.shared_queue.clear();
// Matching send-now adopt: the armed id became the running turn.
agent.expect_send_now_cancel = Some("p-run".into());
agent.session.current_prompt_id = Some("p-run".into());
assert!(
@ -2866,7 +2750,6 @@ mod tests {
"an arm for the running turn is not held occupancy"
);
// A stale arm for a different (not-running) prompt still occupies hold.
agent.session.current_prompt_id = Some("p-other".into());
assert!(
agent.has_held_user_queue(),
@ -2874,10 +2757,8 @@ mod tests {
);
}
/// T2 regression: deleting the LAST held local row re-evaluates the
/// parked look immediately (no waiting for an unrelated notification).
#[test]
fn local_delete_of_last_held_row_flips_parked_look_on() {
fn local_delete_of_last_held_row_adds_no_marker() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app_with_agent();
@ -2887,9 +2768,11 @@ mod tests {
simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1");
let agent = app.agents.get_mut(&id).unwrap();
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 0, "held row holds the marker");
assert!(!agent.renders_parked());
assert_eq!(count_turn_markers(agent), 0);
assert!(
agent.renders_parked(),
"parked look on even with a held row"
);
// Delete the row through the queue-pane key path.
agent.queue.sync_from_merged(
@ -2910,14 +2793,11 @@ mod tests {
assert!(agent.session.pending_prompts.is_empty());
assert_eq!(
count_parked(agent),
1,
"deleting the last held row must push the parked marker now"
);
assert!(
agent.renders_parked(),
"the stopped look must flip on immediately after the local delete"
count_turn_markers(agent),
0,
"deleting the last held row must not write a marker"
);
assert!(agent.renders_parked(), "the stopped look stays on");
}
/// T3 regression: a task-tool refinement that OMITS `run_in_background`
@ -2985,15 +2865,14 @@ mod tests {
);
}
/// A plain mid-turn interjection (no wait) must NOT consume the marker
/// slot: a later park in the same turn still deserves its marker.
/// A plain mid-turn interjection needs no suppression state for a later
/// park in the same turn.
#[test]
fn non_parked_interjection_keeps_later_park_marker() {
fn interjection_then_later_park_still_renders_parked() {
let mut app = test_app_with_agent();
let id = AgentId(0);
dispatch(Action::SendPrompt("first".into()), &mut app);
// Mid-turn interjection while streaming (no wait advertised).
let _ = dispatch(
Action::Interject {
text: "heads up".into(),
@ -3002,37 +2881,32 @@ mod tests {
&mut app,
);
assert!(
app.agents[&id].parked_wait_marker_for.is_none(),
"no wait → slot must stay free"
!app.agents[&id].renders_parked(),
"no wait → no parked look"
);
// The turn later parks: the marker still fires.
simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1");
let agent = app.agents.get_mut(&id).unwrap();
agent.maybe_push_parked_marker();
assert_eq!(count_parked(agent), 1, "later park keeps its marker");
assert!(agent.renders_parked(), "later park renders parked");
assert_eq!(count_turn_markers(agent), 0, "and stays markerless");
}
/// Parked chrome must clear OSC 9;4 (and treat the tab title as idle) so
/// Ghostty/WezTerm drop the progress bar while the session looks stopped.
/// The turn is still `TurnRunning` server-side — only `renders_parked`
/// flips the notification busy bit.
#[test]
fn parked_wait_clears_progress_bar_notification() {
let mut app = test_app_with_agent();
let id = AgentId(0);
dispatch(Action::SendPrompt("first".into()), &mut app);
simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1");
// Running wait, no parked marker yet → still busy chrome / progress on.
app.update_notifications();
assert!(
app.notification_service.is_progress_active(),
"live turn must keep the OSC 9;4 progress indicator active"
);
simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1");
let agent = app.agents.get_mut(&id).unwrap();
agent.maybe_push_parked_marker();
assert!(agent.renders_parked());
assert!(
agent.session.state.is_busy(),
@ -3046,46 +2920,4 @@ mod tests {
"parked look must clear OSC 9;4 so the terminal progress bar stops"
);
}
/// The parked push is the unified marker: a static `TurnCompleted` event
/// block flagged `parked`, stamped with the turn's pid. It carries no
/// work counts — the persistent "… still running" status row above the
/// prompt tracks the still-running work. The real final marker later
/// pushes separately (two static lines — main's park shape).
#[test]
fn parked_marker_is_static_completed_snapshot() {
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SessionEvent;
let mut app = test_app_with_agent();
let id = AgentId(0);
dispatch(Action::SendPrompt("first".into()), &mut app);
let agent = app.agents.get_mut(&id).unwrap();
agent
.session
.bg_tasks
.insert("bg-1".into(), running_bg_task("bg-1"));
simulate_task_output_wait(agent, "bg-1");
agent.maybe_push_parked_marker();
let block = (0..agent.scrollback.len())
.rev()
.find_map(|i| match agent.scrollback.get(i).map(|e| &e.block) {
Some(RenderBlock::SessionEvent(b)) => Some(b),
_ => None,
})
.expect("the park must push a marker block");
assert!(matches!(block.event, SessionEvent::TurnCompleted { .. }));
assert!(block.parked);
assert_eq!(
block.prompt_id, agent.session.current_prompt_id,
"the park stamps the marker with its turn's pid"
);
assert!(
block.stop_hooks.is_empty(),
"a parked marker carries no hooks"
);
// The running bg command shows in the watchers cue, not the marker.
assert_eq!(agent.watchers().commands, 1);
}
}

View file

@ -47,6 +47,7 @@ pub(crate) fn refresh_open_settings_modals(app: &mut AppView) {
let ui_snapshot = app.current_ui.clone();
// Capture app-level fields before the mut-borrow loop.
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
let coding_data_sharing_lock_from_app = app.coding_data_sharing_lock();
let show_tips_from_app = app.show_tips;
let auto_update_from_app = app.auto_update;
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
@ -80,6 +81,7 @@ pub(crate) fn refresh_open_settings_modals(app: &mut AppView) {
.map(|(id, info)| (info.name.clone(), id.clone()))
.collect(),
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
coding_data_sharing_lock: coding_data_sharing_lock_from_app,
// Prefer optimistic pending over confirmed active.
plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active),
show_tips: show_tips_from_app,
@ -182,6 +184,7 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(
let ui_snapshot = app.current_ui.clone();
// Capture app-level fields before the mut-borrow on the agent.
let coding_data_sharing_opt_out_from_app = app.coding_data_retention_opt_out;
let coding_data_sharing_lock_from_app = app.coding_data_sharing_lock();
let show_tips_from_app = app.show_tips;
let auto_update_from_app = app.auto_update;
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
@ -224,6 +227,7 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(
.map(|(id, info)| (info.name.clone(), id.clone()))
.collect(),
coding_data_sharing_opt_out: coding_data_sharing_opt_out_from_app,
coding_data_sharing_lock: coding_data_sharing_lock_from_app,
// Prefer optimistic pending over confirmed active.
plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active),
show_tips: show_tips_from_app,
@ -702,6 +706,7 @@ pub(crate) fn build_pager_snapshot(app: &AppView) -> crate::settings::PagerLocal
current_model_name: agent_current_model_name(app),
available_models: agent_available_models(app),
coding_data_sharing_opt_out: app.coding_data_retention_opt_out,
coding_data_sharing_lock: app.coding_data_sharing_lock(),
plan_mode_active: agent_plan_mode(app),
show_tips: app.show_tips,
auto_update: app.auto_update,

View file

@ -3700,17 +3700,21 @@ fn interactive_cancel_supersedes_send_now_expectation() {
);
}
/// The parked "Worked for" marker stays the only marker across a send-now cancel.
/// A send-now cancel out of a park leaves no markers at all: the park is
/// markerless and the armed expectation suppresses the cancel marker.
#[test]
fn send_now_cancel_after_park_leaves_single_parked_marker() {
use crate::app::agent_view::test_fixtures::{count_parked, simulate_task_output_wait};
fn send_now_cancel_after_park_leaves_no_markers() {
use crate::app::agent_view::test_fixtures::{count_turn_markers, simulate_task_output_wait};
let mut app = test_app_with_agent();
let id = AgentId(0);
dispatch(Action::SendPrompt("first".into()), &mut app);
simulate_task_output_wait(app.agents.get_mut(&id).unwrap(), "bg-1");
app.agents.get_mut(&id).unwrap().maybe_push_parked_marker();
assert_eq!(count_parked(&app.agents[&id]), 1);
assert_eq!(
count_turn_markers(&app.agents[&id]),
0,
"a park writes no marker"
);
// Typing into the parked wait: plain send arms the expectation; cancel arrives meta-less.
let _ = dispatch(Action::SendPrompt("next thing".into()), &mut app);
@ -3719,14 +3723,8 @@ fn send_now_cancel_after_park_leaves_single_parked_marker() {
assert_eq!(count_cancelled_markers(&app, id), 0);
assert_eq!(
count_completed_markers(&app, id),
1,
"the parked marker stays the only completed line (no duplicate)"
);
app.agents.get_mut(&id).unwrap().maybe_push_parked_marker();
assert_eq!(
count_parked(&app.agents[&id]),
1,
"no late parked marker after the send-now cancel"
0,
"no completed marker renders for the cancelled parked turn"
);
}

View file

@ -641,7 +641,7 @@ pub async fn run(
default_yolo_mode: launch_yolo.yolo,
default_auto_mode: launch_auto && !launch_yolo.yolo,
};
let connection = if use_leader {
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,
@ -656,6 +656,8 @@ pub async fn run(
);
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();
@ -756,7 +758,7 @@ pub async fn run(
.await;
crate::unified_log::flush_blocking().await;
let restore_result = restore_terminal(terminal, writer_thread, screen_mode);
cancel.cancel();
drop(agent_guard);
xai_tty_utils::global_process_scope().kill_all();
if let Err(cleanup_error) = restore_result {
match &result {

View file

@ -19,6 +19,17 @@ use crate::views::prompt_widget::PromptEvent;
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use std::time::Instant;
impl AgentView {
/// Time-paired multi-click check for the prompt textarea. Pairing is
/// time-only (no coordinates); a mispaired action is one undo step.
/// Records the click for the next pairing.
pub(super) fn prompt_click_is_double(&mut self) -> bool {
let now = std::time::Instant::now();
let is_double = self
.last_prompt_click_ms
.is_some_and(|last| now.duration_since(last).as_millis() < MULTI_CLICK_TIMEOUT_MS);
self.last_prompt_click_ms = Some(now);
is_double
}
/// Handle mouse events: click-to-focus, forward to prompt textarea.
///
/// Scroll events are handled at app level (not here).
@ -454,7 +465,6 @@ impl AgentView {
if self.visible_queue_is_empty() {
self.hide_queue_pane();
}
self.maybe_push_parked_marker();
return InputOutcome::Action(Action::QueueRemoveShared {
id: server_id,
expected_version: row.version,
@ -464,7 +474,6 @@ impl AgentView {
}
let was_drain_blocked = self.drain_blocked();
self.remove_local_queue_row(id);
self.maybe_push_parked_marker();
if was_drain_blocked {
return InputOutcome::Action(Action::DrainQueue);
}
@ -509,10 +518,7 @@ impl AgentView {
self.pending_effects.push(eff);
}
}
let now = std::time::Instant::now();
if let Some(last) = self.last_prompt_click_ms
&& now.duration_since(last).as_millis() < MULTI_CLICK_TIMEOUT_MS
{
if self.prompt_click_is_double() {
if self.prompt.file_ref_near_cursor()
&& let Some((path, initial_range)) =
self.prompt.file_ref_element_at_cursor()
@ -526,7 +532,6 @@ impl AgentView {
self.prompt.refresh_slash(&self.session.models);
}
}
self.last_prompt_click_ms = Some(now);
}
InputOutcome::Changed
}

View file

@ -19,8 +19,9 @@ use super::app_view::AppView;
/// (right-justified) on the marker line instead of as a standalone block.
///
/// All three marker rails route through here: the driver's `PromptResponse`,
/// the lost-RPC reconcile, and the viewer finalize. (Wake turns close
/// markerless — see `finish_wake_turn` in acp_handler.) `event == None`
/// the lost-RPC reconcile, and the viewer finalize. (Wake turns route through
/// `finish_wake_turn` in acp_handler, which maps their stop reason and calls
/// here only when a marker is due.) `event == None`
/// (bash turns, rate-limit / re-auth UX that replaces the marker) flushes the
/// held hooks as the legacy standalone lifecycle block so failures stay
/// visible.

View file

@ -418,9 +418,6 @@ fn last_marker_block(agent: &AgentView) -> &SessionEventBlock {
#[test]
fn real_end_marker_stays_plain_with_running_work() {
// Background work never rides the end marker as a "still running" suffix
// — the persistent "… still running" status row carries it instead. The
// running command shows up in the watchers count only.
let mut agent = running_driver("p1");
insert_bg_task(&mut agent, "bg-1", false);
@ -433,7 +430,6 @@ fn real_end_marker_stays_plain_with_running_work() {
);
let block = last_marker_block(&agent);
assert!(!block.parked);
assert_eq!(block.prompt_id.as_deref(), Some("p1"));
assert_eq!(block.event.message(), "Worked for 2.0s");
assert_eq!(
@ -533,108 +529,15 @@ fn driver_arm_records_cancel_trigger_for_reconcile() {
);
}
/// All `TurnCompleted` markers (parked and final) in scrollback order.
fn completed_markers(sb: &ScrollbackState) -> Vec<SessionEventBlock> {
(0..sb.len())
.filter_map(|i| match sb.get(i).map(|e| &e.block) {
Some(RenderBlock::SessionEvent(b))
if matches!(b.event, SessionEvent::TurnCompleted { .. }) =>
{
Some(b.clone())
}
_ => None,
})
.collect()
}
/// The tail parked marker `maybe_push_parked_marker` leaves mid-turn (same
/// shape as `push_parked_marker_block`).
fn push_parked_tail(agent: &mut AgentView, prompt_id: &str, secs: u64) {
let mut parked = SessionEventBlock::new(SessionEvent::TurnCompleted {
elapsed: Some(std::time::Duration::from_secs(secs)),
});
parked.parked = true;
parked.prompt_id = Some(prompt_id.into());
agent
.scrollback
.push_block(RenderBlock::SessionEvent(parked));
}
/// The turn-end marker takes no fold path — a park has no row to fold into.
#[test]
fn completion_folds_tail_parked_marker_instead_of_duplicating() {
// Park → work finished → turn ended with nothing in between: the
// completion folds into the parked marker, not a second identical row.
let mut agent = running_driver("p1");
push_parked_tail(&mut agent, "p1", 3);
fn turn_end_after_park_pushes_single_marker() {
use crate::app::agent_view::test_fixtures::count_turn_markers;
push_turn_terminal_marker(
&mut agent,
Some(SessionEvent::TurnCompleted {
elapsed: Some(std::time::Duration::from_secs(5)),
}),
Some("p1"),
);
let markers = completed_markers(&agent.scrollback);
assert_eq!(
markers.len(),
1,
"park + completion must render ONE marker, got {}",
markers.len()
);
assert!(!markers[0].parked, "the folded marker is the real turn end");
assert_eq!(
markers[0].event.message(),
"Worked for 5.0s",
"the folded marker carries the final elapsed"
);
}
#[test]
fn completion_fold_attaches_stop_hooks_to_folded_marker() {
let mut agent = running_driver("p1");
push_parked_tail(&mut agent, "p1", 3);
agent.pending_stop_hooks = Some(super::super::agent_view::PendingStopHooks {
prompt_id: Some("p1".into()),
groups: one_stop_group(),
});
push_turn_terminal_marker(
&mut agent,
Some(SessionEvent::TurnCompleted {
elapsed: Some(std::time::Duration::from_secs(5)),
}),
Some("p1"),
);
let markers = completed_markers(&agent.scrollback);
assert_eq!(markers.len(), 1);
assert_eq!(
markers[0].stop_hooks.len(),
1,
"stop hooks must ride the folded marker"
);
// A hook-carrying marker rests Collapsed on the fresh-push and
// attach_stop_hooks paths; the fold must match.
let folded = agent.scrollback.last().expect("folded marker entry");
assert_eq!(
folded.display_mode,
crate::scrollback::types::DisplayMode::Collapsed,
"folded marker with stop hooks must rest collapsed"
);
}
#[test]
fn completion_folds_marker_pushed_by_real_park_path() {
// Drive the real park — blocking wait through the tracker, then
// `maybe_push_parked_marker` — so the fold's keys (parked + prompt_id)
// stay pinned to the production marker shape, not the test helper's.
let mut agent = running_driver("p1");
super::super::agent_view::test_fixtures::simulate_task_output_wait(&mut agent, "bg-1");
agent.maybe_push_parked_marker();
let parked = completed_markers(&agent.scrollback);
assert_eq!(parked.len(), 1, "real park path must push one marker");
assert!(parked[0].parked);
assert!(agent.renders_parked());
assert_eq!(count_turn_markers(&agent), 0, "the park writes no marker");
push_turn_terminal_marker(
&mut agent,
@ -644,140 +547,10 @@ fn completion_folds_marker_pushed_by_real_park_path() {
Some("p1"),
);
let markers = completed_markers(&agent.scrollback);
assert_eq!(
markers.len(),
count_turn_markers(&agent),
1,
"completion must fold into the marker the real park path pushed"
);
assert!(!markers[0].parked);
}
#[test]
fn completion_does_not_fold_across_bg_completion_chip() {
// Park → bg task completes (chip lands under the marker) → turn ends:
// folding would teleport the boundary above the chip, so this flow
// intentionally keeps both markers.
let mut agent = running_driver("p1");
push_parked_tail(&mut agent, "p1", 3);
agent.scrollback.push_block(RenderBlock::bg_task_completed(
"sleep 5",
"task-1",
std::time::Duration::from_secs(5),
));
push_turn_terminal_marker(
&mut agent,
Some(SessionEvent::TurnCompleted {
elapsed: Some(std::time::Duration::from_secs(9)),
}),
Some("p1"),
);
let markers = completed_markers(&agent.scrollback);
assert_eq!(
markers.len(),
2,
"a chip between park and completion keeps both markers"
);
}
#[test]
fn completion_does_not_fold_committed_parked_marker() {
// Minimal mode already printed the parked row (print-once): an in-place
// fold would never reach the terminal — a fresh marker must be pushed.
let mut agent = running_driver("p1");
push_parked_tail(&mut agent, "p1", 3);
let parked_idx = agent.scrollback.len() - 1;
agent.scrollback.mark_committed(parked_idx);
agent.scrollback.set_commit_scan_cursor(parked_idx + 1);
push_turn_terminal_marker(
&mut agent,
Some(SessionEvent::TurnCompleted {
elapsed: Some(std::time::Duration::from_secs(5)),
}),
Some("p1"),
);
let markers = completed_markers(&agent.scrollback);
assert_eq!(
markers.len(),
2,
"committed tail must not fold: the completion appends a fresh marker"
);
assert!(markers[0].parked, "the committed parked row is untouched");
assert!(!markers[1].parked, "the fresh marker is the real turn end");
let fresh = agent.scrollback.last().expect("fresh marker entry");
assert!(
!agent.scrollback.is_committed(fresh.id),
"fresh marker is uncommitted so the commit pass will print it"
);
}
#[test]
fn completion_does_not_fold_foreign_or_buried_parked_markers() {
// Different prompt id at the tail: not this turn's park — push normally.
let mut agent = running_driver("p2");
push_parked_tail(&mut agent, "p1", 3);
push_turn_terminal_marker(
&mut agent,
Some(SessionEvent::TurnCompleted {
elapsed: Some(std::time::Duration::from_secs(5)),
}),
Some("p2"),
);
assert_eq!(
completed_markers(&agent.scrollback).len(),
2,
"a foreign parked marker must not swallow another turn's completion"
);
// Buried park (agent output rendered after it): the park no longer
// explains the tail — the completion pushes its own marker.
let mut agent = running_driver("p1");
push_parked_tail(&mut agent, "p1", 3);
agent.scrollback.push_block(RenderBlock::System(
crate::scrollback::blocks::SystemMessageBlock::new("resumed"),
));
push_turn_terminal_marker(
&mut agent,
Some(SessionEvent::TurnCompleted {
elapsed: Some(std::time::Duration::from_secs(5)),
}),
Some("p1"),
);
assert_eq!(
completed_markers(&agent.scrollback).len(),
2,
"a buried parked marker must not fold"
);
}
#[test]
fn failure_never_folds_into_a_parked_marker() {
// A parked "Worked for" is a completion-shaped boundary; a failure is a
// different outcome and must render as its own row beneath it.
let mut agent = running_driver("p1");
push_parked_tail(&mut agent, "p1", 3);
push_turn_terminal_marker(
&mut agent,
Some(SessionEvent::TurnFailed {
error: "boom".into(),
elapsed: Some(std::time::Duration::from_secs(5)),
}),
Some("p1"),
);
assert_eq!(
completed_markers(&agent.scrollback).len(),
1,
"the parked marker stays"
);
assert!(
matches!(
last_session_event(&agent.scrollback),
Some(SessionEvent::TurnFailed { .. })
),
"the failure renders as its own row"
"the real turn end pushes exactly one marker"
);
assert_eq!(last_marker_block(&agent).event.message(), "Worked for 5.0s");
}

View file

@ -26,7 +26,7 @@ use xai_grok_shell::sampling::types::{
use xai_grok_shell::util::config as cli_config;
use crate::acp::model_state::{EffortTokenError, ModelState};
use crate::acp::spawn::spawn_grok_shell;
use crate::acp::spawn::{AgentShutdownGuard, spawn_grok_shell};
use crate::client_identity::{HEADLESS_CLIENT_TYPE, PAGER_CLIENT_VERSION};
// ── Types ────────────────────────────────────────────────────────────────
@ -928,6 +928,8 @@ pub async fn run_single_turn(
anyhow::bail!("{msg}");
}
};
// Cancel + join on every return path (success or bail).
let _agent_guard = AgentShutdownGuard::new(cancel.clone(), Some(spawned.thread_handle));
let (acp_tx, mut acp_rx) = (spawned.channel.tx, spawned.channel.rx);
crate::unified_log::init(acp_tx.clone());
crate::unified_log::info(
@ -947,7 +949,6 @@ pub async fn run_single_turn(
Err(e) => {
let msg = format!("Couldn't initialize: {e}");
emitter.on_error(&msg);
cancel.cancel();
anyhow::bail!("{msg}");
}
};
@ -969,7 +970,6 @@ pub async fn run_single_turn(
Ok(is_api_key) => is_api_key,
Err(e) => {
emitter.on_error(&e.to_string());
cancel.cancel();
return Err(e);
}
};
@ -1041,7 +1041,6 @@ pub async fn run_single_turn(
Err(e) => {
let msg = format!("Couldn't create session: {e}");
emitter.on_error(&msg);
cancel.cancel();
anyhow::bail!("{msg}");
}
};
@ -1075,7 +1074,6 @@ pub async fn run_single_turn(
{
let msg = e.to_string();
emitter.on_error(&msg);
cancel.cancel();
anyhow::bail!("{msg}");
}
@ -1177,7 +1175,6 @@ pub async fn run_single_turn(
msg = acp_rx.recv() => {
let Some(msg) = msg else {
emitter.on_error("Connection closed unexpectedly");
cancel.cancel();
anyhow::bail!("Connection closed unexpectedly");
};
handle_headless_acp_message(
@ -1253,7 +1250,7 @@ pub async fn run_single_turn(
// Non-blocking flock so a slow/network ~/.grok can't hang exit.
let _ = xai_grok_shell::active_sessions::try_unregister(&session_id);
}
cancel.cancel();
// Agent cancel + join (SessionEnd flush) runs in AgentShutdownGuard::drop.
match prompt_result {
Some(Ok(resp)) => {
let stop_reason = format!("{:?}", resp.stop_reason);

View file

@ -562,10 +562,7 @@ pub fn resolve_turn_activity(v: &AgentView) -> Option<TurnActivity> {
v.resolve_turn_activity()
}
/// [`AgentView::renders_parked`] — while the parked-wait marker's turn is
/// parked, minimal renders the "… still running" cue (watchers running) or the
/// idle hint (none), mirroring the full TUI. The marker itself is pushed by
/// the shared ACP notification path, so minimal's scrollback carries it too.
/// [`AgentView::renders_parked`].
pub fn renders_parked(v: &AgentView) -> bool {
v.renders_parked()
}

View file

@ -21,6 +21,9 @@ pub async fn list_available_models(agent_config: &AgentConfig) -> Result<()> {
let cancel = CancellationToken::new();
let spawned = crate::acp::spawn::spawn_grok_shell(agent_config.clone(), &cancel, None).await?;
// Cancel + join on every return path, including the `?` below.
let _agent_guard =
crate::acp::spawn::AgentShutdownGuard::new(cancel.clone(), Some(spawned.thread_handle));
let state = list_models(&spawned.channel.tx, PAGER_CLIENT_TYPE, PAGER_CLIENT_VERSION).await?;
@ -35,6 +38,5 @@ pub async fn list_available_models(agent_config: &AgentConfig) -> Result<()> {
}
}
cancel.cancel();
Ok(())
}

View file

@ -271,10 +271,7 @@ impl SessionEvent {
/// Whether this event marks the end of an agent turn (the "Turn
/// completed/cancelled/failed" markers). These are the only events that
/// can carry the turn's stop/stop_failure hook runs inline — but a
/// parked marker renders mid-turn while the turn is still running
/// shell-side, before any Stop hook fires, so hook eligibility is the
/// block-level [`SessionEventBlock::accepts_stop_hooks`].
/// can carry the turn's stop/stop_failure hook runs inline.
pub fn is_turn_terminal(&self) -> bool {
matches!(
self,
@ -312,12 +309,6 @@ pub struct SessionEventBlock {
/// The prompt turn a terminal marker belongs to, when known. Gates
/// which stop-hook batches may merge into it.
pub prompt_id: Option<String>,
/// The marker was pushed at park time (user-interruptible blocking
/// wait): the turn is still running shell-side, so it must never accept
/// stop hooks. Rendering is unchanged — a parked wait reads as stopped.
/// Cleared when the completion folds into the uncommitted tail marker;
/// a committed tail (minimal print-once) gets a fresh row instead.
pub parked: bool,
}
impl SessionEventBlock {
@ -327,7 +318,6 @@ impl SessionEventBlock {
event,
stop_hooks: Vec::new(),
prompt_id: None,
parked: false,
}
}
@ -342,17 +332,9 @@ impl SessionEventBlock {
event,
stop_hooks,
prompt_id,
parked: false,
}
}
/// Whether this marker may carry/accept stop-hook runs: a turn-terminal
/// event that is not a parked line (which renders while the turn is
/// still running shell-side, before any Stop hook fires).
pub fn accepts_stop_hooks(&self) -> bool {
self.event.is_turn_terminal() && !self.parked
}
/// Whether any attached stop hook actually ran (non-skipped). Gates the
/// fold/selection affordances and the inline summary, mirroring
/// [`ToolCallHookData::has_content`](super::tool::ToolCallHookData::has_content).
@ -1344,44 +1326,16 @@ mod tests {
);
}
/// A parked marker block — the shape `maybe_push_parked_marker` pushes.
fn parked_marker() -> SessionEventBlock {
SessionEventBlock {
event: SessionEvent::TurnCompleted {
elapsed: Some(Duration::from_secs(24)),
},
stop_hooks: Vec::new(),
prompt_id: None,
parked: true,
}
}
#[test]
fn parked_markers_never_accept_stop_hooks() {
// A parked marker renders mid-turn, before any Stop hook fires.
let block = parked_marker();
assert!(!block.accepts_stop_hooks(), "parked marker refuses hooks");
// The real terminal marker accepts.
fn only_turn_terminal_events_accept_stop_hooks() {
let settled = SessionEventBlock::new(SessionEvent::TurnCompleted {
elapsed: Some(Duration::from_secs(24)),
});
assert!(settled.accepts_stop_hooks());
// Non-terminal events never accept, parked or not.
assert!(settled.event.is_turn_terminal());
let recap = SessionEventBlock::new(SessionEvent::Recap {
summary: "did stuff".into(),
auto: false,
});
assert!(!recap.accepts_stop_hooks());
}
#[test]
fn parked_marker_output_reads_as_plain_completed_marker() {
// The parked marker renders the plain event text — still-running
// background work is the status row's "… still running" cue, never a
// transcript suffix.
let block = parked_marker();
let out = block.output(&ctx());
assert_eq!(plain(&out.lines[0]), "Worked for 24s");
assert!(!recap.event.is_turn_terminal());
}
}

View file

@ -767,11 +767,7 @@ impl ScrollbackState {
/// failed") that can accept a live `stop`/`stop_failure` batch arriving
/// after the marker (viewer order). The walk skips blocks appended after
/// the marker. A stamped batch needs the marker to carry the same prompt
/// id — and treats parked markers as transparent: they never
/// accept hooks themselves (their turn is still running), and pid-exact
/// attribution cannot misattach, so a late prior-turn batch may cross
/// the current turn's not-yet-settled boundary into its own turn's
/// marker. An unstamped batch is positional (tail only) and stops at ANY
/// id. An unstamped batch is positional (tail only) and stops at ANY
/// terminal-event marker — without a pid there is no proof it belongs
/// further back. A same-name repeat (e.g. the session-end `stop`) is
/// always refused.
@ -787,14 +783,6 @@ impl ScrollbackState {
if !b.event.is_turn_terminal() {
continue;
}
if !b.accepts_stop_hooks() {
// Parked: transparent to a stamped batch, a hard stop for
// a positional one.
if batch_prompt_id.is_some() {
continue;
}
return None;
}
if b.stop_hooks.iter().any(|(name, _)| name == event_name) {
return None;
}
@ -812,9 +800,8 @@ impl ScrollbackState {
/// entry and collapse it so the right-justified summary — not the
/// fold-out detail — is the resting state. Returns `false` unless the
/// entry is a turn-terminal session event the batch can be attributed to
/// (see [`Self::latest_turn_marker_accepting`]) — never a parked
/// marker; re-checked here so a stray caller can't attach hooks to the
/// wrong entry.
/// (see [`Self::latest_turn_marker_accepting`]); re-checked here so a
/// stray caller can't attach hooks to the wrong entry.
pub fn attach_stop_hooks_to_marker(
&mut self,
id: EntryId,
@ -828,7 +815,7 @@ impl ScrollbackState {
let RenderBlock::SessionEvent(ref mut block) = entry.block else {
return false;
};
if !block.accepts_stop_hooks() {
if !block.event.is_turn_terminal() {
return false;
}
let attributable = match (batch_prompt_id, block.prompt_id.as_deref()) {
@ -848,59 +835,6 @@ impl ScrollbackState {
true
}
/// Fold a turn completion into a tail-adjacent parked "Worked for X"
/// marker from the same prompt turn: the parked row already IS the
/// turn's boundary, so it takes the final elapsed + stop hooks in place
/// (unparked) instead of an identical row stacking beneath it. Returns
/// `false` (caller pushes a fresh marker) when the tail doesn't match,
/// the event isn't a completion (a failure/cancel is a different
/// outcome), or minimal mode already committed the row — print-once: an
/// in-place mutation would never reach the terminal.
pub fn fold_completion_into_tail_parked_marker(
&mut self,
event: &super::blocks::SessionEvent,
stop_hooks: &[(String, Vec<super::blocks::tool::HookRunEntry>)],
prompt_id: Option<&str>,
) -> bool {
use super::blocks::SessionEvent;
// `is_none` also keeps `None` from matching a pid-less parked marker.
if prompt_id.is_none() || !matches!(event, SessionEvent::TurnCompleted { .. }) {
return false;
}
let tail_match = self.last().and_then(|entry| match &entry.block {
RenderBlock::SessionEvent(b) if b.parked && b.prompt_id.as_deref() == prompt_id => {
Some(entry.id)
}
_ => None,
});
let Some(id) = tail_match else {
return false;
};
if self.is_committed(id) {
return false;
}
let Some(entry) = self.entries.get_mut(&id) else {
return false;
};
let RenderBlock::SessionEvent(ref mut b) = entry.block else {
return false;
};
b.event = event.clone();
b.parked = false;
b.stop_hooks = stop_hooks.to_vec();
// A hook-carrying marker rests Collapsed (the right-justified summary)
// on every sibling path — fresh pushes via `default_display_mode` and
// `attach_stop_hooks_to_marker` — so the fold matches.
if b.has_stop_hook_content() && !entry.display_mode_pinned {
entry.display_mode = DisplayMode::Collapsed;
}
entry.invalidate_cache();
self.mark_structurally_dirty(id);
// The marker's searchable text changed (parked elapsed → final).
self.bump_content_generation();
true
}
/// Push a text chunk to an agent message entry.
///
/// This is the preferred way to append streaming content because it:
@ -2264,17 +2198,6 @@ mod tests {
};
let mut state = ScrollbackState::new();
// A parked marker renders mid-turn — it must never accept hooks, at
// the lookup and at the mutation site alike.
let mut parked_block =
crate::scrollback::blocks::SessionEventBlock::new(SessionEvent::TurnCompleted {
elapsed: Some(std::time::Duration::from_secs(1)),
});
parked_block.parked = true;
let parked = state.push_block(RenderBlock::SessionEvent(parked_block));
assert_eq!(state.latest_turn_marker_accepting("stop", None), None);
assert!(!state.attach_stop_hooks_to_marker(parked, "stop".into(), entries(), None));
let marker = state.push_block(RenderBlock::session_event(SessionEvent::TurnCompleted {
elapsed: Some(std::time::Duration::from_secs(2)),
}));
@ -2412,68 +2335,6 @@ mod tests {
);
}
#[test]
fn stamped_stop_hooks_cross_parked_marker_to_their_turns_marker() {
use crate::scrollback::blocks::tool::{HookRunEntry, HookRunStatus};
use crate::scrollback::blocks::{SessionEvent, SessionEventBlock};
let entries = || {
vec![HookRunEntry {
name: "h".into(),
status: HookRunStatus::Success {
elapsed: std::time::Duration::from_millis(1),
},
output: None,
}]
};
// A prior turn's settled marker, then the current turn's parked
// EndLine at the tail (viewer/reattach shape).
let mut state = ScrollbackState::new();
let prior = state.push_block(RenderBlock::SessionEvent(
SessionEventBlock::with_stop_hooks(
SessionEvent::TurnCompleted {
elapsed: Some(std::time::Duration::from_secs(2)),
},
Vec::new(),
Some("pid-a".into()),
),
));
let mut parked_block = SessionEventBlock::new(SessionEvent::TurnCompleted {
elapsed: Some(std::time::Duration::from_secs(1)),
});
parked_block.parked = true;
parked_block.prompt_id = Some("pid-b".into());
let parked = state.push_block(RenderBlock::SessionEvent(parked_block));
// A late batch stamped for the PRIOR turn crosses the parked marker
// (pid-exact attribution cannot misattach) and merges into its own
// turn's marker; the parked marker itself is untouched.
assert_eq!(
state.latest_turn_marker_accepting("stop", Some("pid-a")),
Some(prior)
);
assert!(state.attach_stop_hooks_to_marker(prior, "stop".into(), entries(), Some("pid-a")));
match &state.get_by_id(parked).unwrap().block {
RenderBlock::SessionEvent(b) => {
assert!(b.parked);
assert!(b.stop_hooks.is_empty(), "the parked marker stays clean");
}
other => panic!("expected the parked marker, got {other:?}"),
}
// The parked turn's own pid still never accepts (its Stop hooks
// cannot have fired yet), and an unstamped positional batch stops at
// the parked tail marker as before.
assert_eq!(
state.latest_turn_marker_accepting("stop_failure", Some("pid-b")),
None
);
assert_eq!(
state.latest_turn_marker_accepting("stop_failure", None),
None
);
}
/// A finished user `!` command expands to its full output; a Collapsed
/// entry keeps its fold (no snap-open at completion).
#[test]

View file

@ -27,9 +27,9 @@ pub mod defs;
pub mod registry;
pub use registry::{
DynamicEnumSource, EnumChoice, OwnedEnumChoice, PagerLocalSnapshot, SettingCategory,
SettingKey, SettingKind, SettingMeta, SettingOwner, SettingValue, SettingsRegistry,
StringValidator, canonical_hunk_tracker_mode, canonical_screen_mode,
CodingDataSharingLock, DynamicEnumSource, EnumChoice, OwnedEnumChoice, PagerLocalSnapshot,
SettingCategory, SettingKey, SettingKind, SettingMeta, SettingOwner, SettingValue,
SettingsRegistry, StringValidator, canonical_hunk_tracker_mode, canonical_screen_mode,
canonical_voice_capture_mode, canonical_voice_stt_language, current_value_for,
default_value_for, dynamic_enum_choices,
};

View file

@ -226,6 +226,23 @@ pub enum SettingValue {
Int(i64),
}
/// Why `coding_data_sharing` cannot be changed in the settings modal.
/// Computed by `AppView::coding_data_sharing_lock`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodingDataSharingLock {
Zdr,
TeamManaged,
}
impl CodingDataSharingLock {
pub fn reason(self) -> &'static str {
match self {
Self::Zdr => "Your team has Zero Data Retention.",
Self::TeamManaged => "Managed by your team admin.",
}
}
}
/// Snapshot of pager-local state captured when the modal opens.
/// Used by `current_value_for` to render against LIVE state rather
/// than the on-disk `UiConfig`. Refreshed by
@ -252,6 +269,8 @@ pub struct PagerLocalSnapshot {
/// `opt_out == false` → canonical "opt-in". Snapshot default is
/// `true` (opted out) to match the safer consumer default.
pub coding_data_sharing_opt_out: bool,
/// Why `coding_data_sharing` cannot be changed here (`None` = editable).
pub coding_data_sharing_lock: Option<CodingDataSharingLock>,
/// Whether plan mode is active. Uses effective state
/// (`pending.unwrap_or(active)`) so rapid toggles don't double-send.
/// Refreshed on all mutation paths including ACP `CurrentModeUpdate`.
@ -291,6 +310,7 @@ impl Default for PagerLocalSnapshot {
current_model_name: None,
available_models: Vec::new(),
coding_data_sharing_opt_out: true,
coding_data_sharing_lock: None,
plan_mode_active: false,
show_tips: None,
auto_update: None,

View file

@ -15,6 +15,9 @@ pub struct ShareArgs {
pub async fn run(args: &ShareArgs, agent_config: &AgentConfig) -> Result<()> {
let cancel = CancellationToken::new();
let spawned = crate::acp::spawn::spawn_grok_shell(agent_config.clone(), &cancel, None).await?;
// Cancel + join on every return path, including the `?`s below.
let _agent_guard =
crate::acp::spawn::AgentShutdownGuard::new(cancel.clone(), Some(spawned.thread_handle));
let _init: acp::InitializeResponse = acp_send(
acp::InitializeRequest::new(acp::ProtocolVersion::V1)
@ -44,6 +47,5 @@ pub async fn run(args: &ShareArgs, agent_config: &AgentConfig) -> Result<()> {
let response: ShareSessionResponse = serde_json::from_str(ext_resp.0.get())?;
println!("{}", response.share_url);
cancel.cancel();
Ok(())
}

View file

@ -571,7 +571,7 @@ pub fn render_peek_panel(
live_tail: Option<PeekLiveTailArgs<'_>>,
empty_hint: Option<&str>,
) -> PeekRenderResult {
use crate::views::prompt_widget::PromptStyle;
use crate::views::prompt_widget::{PromptBg, PromptStyle};
use ratatui::widgets::{Block, BorderType, Borders, Widget};
use unicode_width::UnicodeWidthStr;
if area.area() == 0 || area.height < 3 || area.width < 20 {
@ -724,7 +724,7 @@ pub fn render_peek_panel(
show_prefix: false,
vpad_top: 0,
chrome: false,
bg_override: Some(theme.bg_base),
bg: PromptBg::Canvas(theme.bg_base),
image_preview: false,
..PromptStyle::default()
};
@ -863,7 +863,7 @@ pub fn render_peek_panel(
show_prefix: false,
vpad_top: 0,
chrome: false,
bg_override: Some(theme.bg_base),
bg: PromptBg::Canvas(theme.bg_base),
placeholder_override: Some("reply\u{2026}"),
image_preview: false,
..PromptStyle::default()

View file

@ -2852,7 +2852,7 @@ fn render_dispatch(
) -> Option<(u16, u16)> {
use ratatui::widgets::{Block, BorderType, Borders, Widget};
use crate::views::prompt_widget::PromptStyle;
use crate::views::prompt_widget::{PromptBg, PromptStyle};
if area.area() == 0 {
return None;
@ -3028,7 +3028,7 @@ fn render_dispatch(
show_prefix: true,
vpad_top: 0,
chrome: false,
bg_override: Some(theme.bg_base),
bg: PromptBg::Canvas(theme.bg_base),
image_preview: false,
..PromptStyle::default()
};

View file

@ -158,10 +158,8 @@ pub struct PromptStyle {
/// Only used when `chrome` is true.
pub chrome_pad_left: u16,
pub chrome_pad_right: u16,
/// Override the background color. When `Some`, the prompt uses this bg
/// instead of computing one from focus state. Useful for rendering the
/// prompt inline within another widget (e.g., question view).
pub bg_override: Option<ratatui::style::Color>,
/// Background surface for the prompt; see [`PromptBg`].
pub bg: PromptBg,
/// Override the accent line color. When `Some`, uses this color instead
/// of the default `accent_user` / `gray_dim`. Used for plan mode (golden).
pub accent_color_override: Option<ratatui::style::Color>,
@ -194,6 +192,35 @@ pub struct PromptStyle {
pub image_preview: bool,
}
/// Background for the prompt widget.
///
/// Paste chips bake `theme.paste_bg` — a badge color tuned for the default
/// canvas — into their display `Line` at paste time, so the background says
/// what *kind* of surface the prompt sits on, not just its color.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PromptBg {
/// The standalone prompt's default fill (`theme.bg_base`).
#[default]
Default,
/// Explicit canvas color for prompts rendered inline within another
/// widget whose surface matches the main prompt's (dashboard dispatch
/// box, peek reply). Chips keep their badge background.
Canvas(ratatui::style::Color),
/// Inline panel color (question freeform input, permission follow-up).
/// Chip cells are repainted to blend into the panel.
Panel(ratatui::style::Color),
}
impl PromptBg {
/// Effective fill color; `default` is the standalone prompt's.
fn color(self, default: ratatui::style::Color) -> ratatui::style::Color {
match self {
Self::Default => default,
Self::Canvas(c) | Self::Panel(c) => c,
}
}
}
impl Default for PromptStyle {
fn default() -> Self {
Self {
@ -203,7 +230,7 @@ impl Default for PromptStyle {
chrome: true,
chrome_pad_left: 2,
chrome_pad_right: 1,
bg_override: None,
bg: PromptBg::Default,
accent_color_override: None,
border_color_override: None,
prefix_override: None,
@ -237,7 +264,7 @@ impl PromptStyle {
chrome: false,
chrome_pad_left: 0,
chrome_pad_right: 0,
bg_override: Some(bg),
bg: PromptBg::Panel(bg),
accent_color_override: None,
border_color_override: None,
prefix_override: None,
@ -986,6 +1013,17 @@ impl PromptWidget {
self.update_file_search_context();
}
/// [`Self::set_text`] unless the buffer already holds exactly `text`.
///
/// Skipping the no-op swap keeps chip elements, images, and undo history
/// intact when a surface reloads an unchanged draft (the question view's
/// freeform slots); any real content change takes the normal reset path.
pub fn set_text_preserving(&mut self, text: &str) {
if self.text() != text {
self.set_text(text);
}
}
/// Append plain text at the end without replacing existing chip elements.
pub fn append_text(&mut self, text: &str) {
if text.is_empty() {
@ -2856,11 +2894,7 @@ impl PromptWidget {
}
let theme = Theme::current();
let bg = if let Some(override_bg) = style.bg_override {
override_bg
} else {
theme.bg_base
};
let bg = style.bg.color(theme.bg_base);
let border_color = style.border_color_override.unwrap_or(if style.focused {
theme.prompt_border_active
@ -2987,6 +3021,21 @@ impl PromptWidget {
(&self.textarea).render_ref(ta_area, buf, &mut self.textarea_state);
// Chip bg remap (see `PromptBg::Panel`): chip `Line`s bake in
// `paste_bg` at paste time and the same element can render on
// multiple surfaces, so restyle at paint time.
if matches!(style.bg, PromptBg::Panel(_)) && bg != theme.paste_bg {
for y in ta_area.top()..ta_area.bottom() {
for x in ta_area.left()..ta_area.right() {
if let Some(cell) = buf.cell_mut((x, y))
&& cell.bg == theme.paste_bg
{
cell.bg = bg;
}
}
}
}
// Slash overlays: teal command name + args ghost text. Both use the
// same snapshot, so clone once. Capture flags for later ghost text
// suppression to avoid a second clone.
@ -3194,8 +3243,8 @@ impl PromptWidget {
}
// Unfocused dimming: blend fg toward bg (bg already precomputed above).
// Skip when bg_override is set — the prompt is inline in another widget.
if !style.focused && style.bg_override.is_none() {
// Skip when the bg is overridden — the prompt is inline in another widget.
if !style.focused && style.bg == PromptBg::Default {
// Dim only the content inside the box (skip all border chars).
let dim_area = Rect {
x: area.x + 1,

View file

@ -4573,3 +4573,53 @@
let buf = draw_bordered(11, &title_test_style(Some("my session")));
assert_eq!(buf_text_at(&buf, 1, 10, 0), "\u{2500}".repeat(9));
}
// ── PromptBg::Panel chip remap (inline surfaces) ────────────────
fn any_cell_with_bg(buf: &Buffer, bg: ratatui::style::Color) -> bool {
let area = *buf.area();
(area.top()..area.bottom())
.any(|y| (area.left()..area.right()).any(|x| buf.cell((x, y)).is_some_and(|c| c.bg == bg)))
}
/// Inline surfaces repaint the chip's baked-in `paste_bg` to the panel
/// background; without the flag the chip keeps its own background. Uses
/// a sentinel panel color so the test holds under terminal-default,
/// where every palette entry quantizes to `Color::Reset`.
#[test]
fn panel_bg_repaints_paste_chip_to_panel_bg() {
let theme = Theme::current();
let panel = ratatui::style::Color::Rgb(12, 34, 56);
assert_ne!(theme.paste_bg, panel, "fixture: sentinel must differ");
let mut pw = PromptWidget::new();
pw.handle_paste("a\nb\nc\nd\ne"); // 5 lines >= chip threshold (4)
let area = Rect::new(0, 0, 40, 2);
let inline = PromptStyle::inline(panel);
assert!(
matches!(inline.bg, PromptBg::Panel(_)),
"inline surfaces are panels"
);
let mut buf = Buffer::empty(area);
pw.draw(&mut buf, area, None, &inline, None, None);
assert!(
!any_cell_with_bg(&buf, theme.paste_bg),
"chip cells must be repainted to the panel background"
);
assert!(
any_cell_with_bg(&buf, panel),
"the chip row renders on the panel background"
);
let no_remap = PromptStyle {
bg: PromptBg::Canvas(panel),
..PromptStyle::inline(panel)
};
let mut buf = Buffer::empty(area);
pw.draw(&mut buf, area, None, &no_remap, None, None);
assert!(
any_cell_with_bg(&buf, theme.paste_bg),
"without the remap the chip keeps its own background"
);
}

View file

@ -13,8 +13,8 @@ use super::state::{
};
use crate::render::line_utils::truncate_str;
use crate::settings::{
OwnedEnumChoice, SettingKey, SettingKind, SettingMeta, SettingValue, StringValidator,
dynamic_enum_choices,
CodingDataSharingLock, OwnedEnumChoice, SettingKey, SettingKind, SettingMeta, SettingValue,
StringValidator, dynamic_enum_choices,
};
use crate::theme::Theme;
use crate::views::modal_window::{
@ -644,9 +644,9 @@ pub(super) fn render_rows(
width: area.width,
height: desc_height.min(8),
};
render_expanded_description(buf, desc_rect, meta, theme);
render_expanded_description(buf, desc_rect, meta, None, theme);
let consumed =
wrapped_description_height(meta, area.width, desc_rect.height);
wrapped_description_height(meta, None, area.width, desc_rect.height);
y_cursor = y_cursor.saturating_add(consumed);
}
continue;
@ -668,25 +668,10 @@ pub(super) fn render_rows(
}
};
let lock = state.row_lock(key);
// Decide 1 vs 2 line layout; fall back to 1 if viewport is tight.
let value_display = match value {
SettingValue::Bool(b) => {
if *b {
"on".to_string()
} else {
"off".to_string()
}
}
SettingValue::String(s) => {
if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
"(no override)".to_string()
} else {
s.clone()
}
}
SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e).to_string(),
SettingValue::Int(i) => i.to_string(),
};
let value_display = value_display(meta, value, lock);
let show_restart_pill_for_layout = meta.restart_required && is_expanded;
let layout_decision = row_layout(
area.width,
@ -722,6 +707,7 @@ pub(super) fn render_rows(
theme,
is_expanded,
is_hovered,
lock,
);
state.value_hit_rects[row_idx] = value_rect;
y_cursor = y_cursor.saturating_add(row_height);
@ -734,10 +720,12 @@ pub(super) fn render_rows(
width: area.width,
height: desc_height.min(8), // cap at 8 lines per row to keep scroll sane
};
render_expanded_description(buf, desc_rect, meta, theme);
let lock_reason = lock.map(CodingDataSharingLock::reason);
render_expanded_description(buf, desc_rect, meta, lock_reason, theme);
// Re-measure how many lines the wrapped description
// actually consumed, so y_cursor advances precisely.
let consumed = wrapped_description_height(meta, area.width, desc_rect.height);
let consumed =
wrapped_description_height(meta, lock_reason, area.width, desc_rect.height);
y_cursor = y_cursor.saturating_add(consumed);
}
}
@ -824,7 +812,7 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) ->
if matches!(meta.kind, SettingKind::Group { .. }) {
let mut h: u16 = 1;
if state.expanded_keys.contains(key) {
h = h.saturating_add(wrapped_description_height(meta, area_width, 8));
h = h.saturating_add(wrapped_description_height(meta, None, area_width, 8));
}
heights.push(h);
continue;
@ -834,24 +822,8 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) ->
continue;
};
let is_expanded = state.expanded_keys.contains(key);
let value_display = match &value {
SettingValue::Bool(b) => {
if *b {
"on".to_string()
} else {
"off".to_string()
}
}
SettingValue::String(s) => {
if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
"(no override)".to_string()
} else {
s.clone()
}
}
SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e).to_string(),
SettingValue::Int(i) => i.to_string(),
};
let lock = state.row_lock(key);
let value_display = value_display(meta, &value, lock);
let show_restart_pill = meta.restart_required && is_expanded;
let layout = row_layout(area_width, meta.label, &value_display, show_restart_pill);
let mut h: u16 = match layout {
@ -861,7 +833,12 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) ->
if is_expanded {
// Cap matches the forward render loop at line
// 2040 (`desc_rect.height = ... .min(8)`).
h = h.saturating_add(wrapped_description_height(meta, area_width, 8));
h = h.saturating_add(wrapped_description_height(
meta,
lock.map(CodingDataSharingLock::reason),
area_width,
8,
));
}
heights.push(h);
}
@ -871,13 +848,19 @@ fn compute_filtered_row_heights(state: &SettingsModalState, area_width: u16) ->
}
/// Wrapped description height for scroll math (mirrors render path).
fn wrapped_description_height(meta: &SettingMeta, area_width: u16, cap: u16) -> u16 {
fn wrapped_description_height(
meta: &SettingMeta,
lock_reason: Option<&'static str>,
area_width: u16,
cap: u16,
) -> u16 {
let indent = 4u16.min(area_width);
let wrap_w = area_width.saturating_sub(indent);
if wrap_w == 0 {
return 0;
}
let line = Line::from(Span::raw(meta.description));
let text = lock_reason.unwrap_or(meta.description);
let line = Line::from(Span::raw(text));
let wrapped = crate::render::wrapping::word_wrap_line(&line, wrap_w as usize);
(wrapped.len() as u16).min(cap)
}
@ -2230,6 +2213,37 @@ const ROW_CHEVRON_W: u16 = 2;
/// Chevron column width — reserved for all rows for alignment.
pub(super) const ROW_CHEVRON_COL_W: u16 = ROW_CHEVRON_W;
const ROW_RESTART_PILL_W: u16 = 10; // " · restart" — used for layout budgeting only.
/// Appended to the value column of a locked row (see `SettingsModalState::row_lock`).
pub(super) const ROW_ADMIN_MANAGED_SUFFIX: &str = " \u{00B7} Admin Managed";
/// Value column for ZDR-locked rows — replaces the opt-in/out value entirely.
pub(super) const ROW_ZDR_VALUE: &str = "ZDR";
/// Value-column text, shared by layout, scroll math, and paint.
pub(super) fn value_display(
meta: &SettingMeta,
value: &SettingValue,
lock: Option<CodingDataSharingLock>,
) -> String {
if lock == Some(CodingDataSharingLock::Zdr) {
return ROW_ZDR_VALUE.to_string();
}
let mut display = match value {
SettingValue::Bool(b) => if *b { "on" } else { "off" }.to_string(),
SettingValue::String(s) => {
if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
"(no override)".to_string()
} else {
s.clone()
}
}
SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e).to_string(),
SettingValue::Int(i) => i.to_string(),
};
if lock == Some(CodingDataSharingLock::TeamManaged) {
display.push_str(ROW_ADMIN_MANAGED_SUFFIX);
}
display
}
/// Per-row layout decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -2309,6 +2323,7 @@ pub(super) fn render_setting_row(
theme: &Theme,
is_expanded: bool,
is_hovered: bool,
lock: Option<CodingDataSharingLock>,
) -> Rect {
let bg = settings_list_row_bg(theme, is_selected, is_hovered);
// Paint the row bg across the full area (1 or 2 lines).
@ -2327,43 +2342,24 @@ pub(super) fn render_setting_row(
.add_modifier(Modifier::ITALIC);
let desc_style = Style::default().fg(theme.gray).bg(bg);
// Enum rows display the user-friendly name, not the canonical.
let value_text_owned;
let value_text: &str = match value {
SettingValue::Bool(b) => {
if *b {
"on"
} else {
"off"
}
}
SettingValue::String(s) => {
if s.is_empty() && matches!(meta.kind, SettingKind::DynamicEnum { .. }) {
"(no override)"
} else {
s.as_str()
}
}
SettingValue::Enum(e) => display_for_enum_canonical(&meta.kind, e),
SettingValue::Int(i) => {
value_text_owned = i.to_string();
&value_text_owned
}
};
let value_text = value_display(meta, value, lock);
let value_text = value_text.as_str();
let value_style = if matches!(value, SettingValue::Bool(false)) {
let value_style = if lock.is_some() || matches!(value, SettingValue::Bool(false)) {
Style::default().fg(theme.gray).bg(bg)
} else {
value_style
};
// Chevron for Enum/String/DynamicEnum (opens picker/editor).
let show_chevron = matches!(
(&meta.kind, value),
(SettingKind::Enum { .. }, _)
| (SettingKind::String { .. }, _)
| (SettingKind::DynamicEnum { .. }, _)
);
// Locked rows can't be entered, so they drop the affordance.
let show_chevron = lock.is_none()
&& matches!(
(&meta.kind, value),
(SettingKind::Enum { .. }, _)
| (SettingKind::String { .. }, _)
| (SettingKind::DynamicEnum { .. }, _)
);
let chevron_str = format!(" {}", crate::glyphs::chevron()); // → > on legacy ConHost
let chevron_w = if show_chevron {
chevron_str.width() as u16
@ -2589,7 +2585,13 @@ pub(super) fn render_setting_row(
}
/// Render the wrapped description for an expanded row.
fn render_expanded_description(buf: &mut Buffer, area: Rect, meta: &SettingMeta, theme: &Theme) {
fn render_expanded_description(
buf: &mut Buffer,
area: Rect,
meta: &SettingMeta,
lock_reason: Option<&'static str>,
theme: &Theme,
) {
if area.height == 0 || area.width == 0 {
return;
}
@ -2597,14 +2599,14 @@ fn render_expanded_description(buf: &mut Buffer, area: Rect, meta: &SettingMeta,
.fg(theme.gray)
.bg(theme.bg_base)
.add_modifier(Modifier::ITALIC);
let desc_src: &str = meta.description;
let desc_text = lock_reason.unwrap_or(meta.description);
// Indent 4 cols to nest under the label.
let indent = 4u16.min(area.width);
let wrap_w = area.width.saturating_sub(indent);
if wrap_w == 0 {
return;
}
let line = Line::from(Span::styled(desc_src, desc_style));
let line = Line::from(Span::styled(desc_text, desc_style));
let wrapped = crate::render::wrapping::word_wrap_line(&line, wrap_w as usize);
for (i, wrapped_line) in wrapped.iter().enumerate() {
if (i as u16) >= area.height {

View file

@ -7,9 +7,9 @@ use ratatui::layout::Rect;
use crate::app::actions::Action;
use crate::input::line_editor::LineEditor;
use crate::settings::{
EnumChoice, OwnedEnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey, SettingKind,
SettingMeta, SettingValue, SettingsRegistry, StringValidator, current_value_for,
dynamic_enum_choices,
CodingDataSharingLock, EnumChoice, OwnedEnumChoice, PagerLocalSnapshot, SettingCategory,
SettingKey, SettingKind, SettingMeta, SettingValue, SettingsRegistry, StringValidator,
current_value_for, dynamic_enum_choices,
};
use crate::views::modal_window::ModalWindowState;
@ -243,6 +243,16 @@ impl SettingsModalState {
}
}
/// Why a Browse row cannot be edited (`None` = editable). Consulted by
/// both render and input.
pub fn row_lock(&self, key: SettingKey) -> Option<CodingDataSharingLock> {
if key == "coding_data_sharing" {
self.pager_snapshot.coding_data_sharing_lock
} else {
None
}
}
/// The currently-focused setting row, if any.
pub fn focused_setting(&self) -> Option<(SettingKey, &SettingMeta)> {
match self.rows.get(self.selected)? {
@ -551,6 +561,9 @@ impl SettingsModalState {
let Some((key, meta)) = self.focused_setting() else {
return false;
};
if self.row_lock(key).is_some() {
return false;
}
// Handles both static `Enum` and `DynamicEnum` catalogs.
let (supports_preview, resolved): (bool, Vec<OwnedEnumChoice>) = match &meta.kind {
SettingKind::Enum {

View file

@ -12,8 +12,8 @@ use super::state::*;
use crate::app::actions::Action;
use crate::input::line_editor::LineEditor;
use crate::settings::{
EnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey, SettingKind, SettingMeta,
SettingOwner, SettingValue, SettingsRegistry, StringValidator,
CodingDataSharingLock, EnumChoice, PagerLocalSnapshot, SettingCategory, SettingKey,
SettingKind, SettingMeta, SettingOwner, SettingValue, SettingsRegistry, StringValidator,
};
use crate::theme::Theme;
use xai_grok_shell::agent::config::UiConfig;
@ -542,6 +542,7 @@ fn render_setting_row_shows_full_label_when_one_line_fits() {
&theme,
false, // is_expanded
false, // is_hovered
None,
);
let mut rendered = String::new();
for x in 0..area.width {
@ -976,6 +977,7 @@ fn selected_browse_row_label_is_bold() {
&theme,
false,
false,
None,
);
assert!(
@ -1433,6 +1435,7 @@ fn render_setting_row_emits_restart_pill_when_required() {
&theme,
true, // is_expanded — gate on
false, // is_hovered
None,
);
let mut rendered = String::new();
for x in 0..area.width {
@ -1457,6 +1460,7 @@ fn render_setting_row_emits_restart_pill_when_required() {
&theme,
false, // is_expanded — off
false, // is_hovered
None,
);
let mut rendered = String::new();
for x in 0..area.width {
@ -1505,6 +1509,7 @@ fn render_setting_row_hides_restart_pill_when_at_default_and_collapsed() {
&theme,
false, // is_expanded
false, // is_hovered
None,
);
let mut rendered = String::new();
for x in 0..area.width {
@ -4488,6 +4493,7 @@ fn narrow_terminal_drops_value_to_second_line() {
&theme,
false,
false, // is_hovered
None,
);
let line1 = buf_row_text(&buf, 0, area.x, area.width);
let line2 = buf_row_text(&buf, 1, area.x, area.width);
@ -4551,6 +4557,7 @@ fn wide_terminal_keeps_value_on_first_line() {
&theme,
false,
false, // is_hovered
None,
);
let line1 = buf_row_text(&buf, 0, area.x, area.width);
let line2 = buf_row_text(&buf, 1, area.x, area.width);
@ -4592,6 +4599,7 @@ fn pathologically_narrow_truncates_label_with_ellipsis() {
&theme,
false,
false, // is_hovered
None,
);
let line1 = buf_row_text(&buf, 0, area.x, area.width);
let line2 = buf_row_text(&buf, 1, area.x, area.width);
@ -5340,6 +5348,7 @@ fn bool_off_value_renders_in_dim_color() {
&theme,
false,
false,
None,
);
// Use `find_text_col` so the
// column index is the actual buffer position, not a byte
@ -5372,6 +5381,7 @@ fn bool_off_value_renders_in_dim_color() {
&theme,
false,
false,
None,
);
let on_col = find_text_col(&buf_on, 0, "on").expect("must find `on` substring");
let on_cell = buf_on.cell((on_col, 0)).expect("on cell");
@ -5443,6 +5453,7 @@ fn chevron_column_is_at_constant_right_offset() {
&theme,
false,
false,
None,
);
// Enum row — chevron column contains the `` glyph.
@ -5457,6 +5468,7 @@ fn chevron_column_is_at_constant_right_offset() {
&theme,
false,
false,
None,
);
// The chevron column is a 2-cell block at
@ -5528,6 +5540,7 @@ fn chevron_column_is_at_constant_right_offset() {
&theme,
false,
false,
None,
);
let _ = render_setting_row(
&mut buf_multi,
@ -5539,6 +5552,7 @@ fn chevron_column_is_at_constant_right_offset() {
&theme,
false,
false,
None,
);
// Bool row's `off` ends at column N; Enum row's `` glyph
// lands at column M. The contract: N == M's column
@ -5595,6 +5609,7 @@ fn chevron_column_aligns_across_one_and_two_line_layouts() {
&theme,
false,
false,
None,
);
let area_one = Rect {
x: 0,
@ -5613,6 +5628,7 @@ fn chevron_column_aligns_across_one_and_two_line_layouts() {
&theme,
false,
false,
None,
);
// The column offset from the area's right edge is constant:
// `area.right - ROW_RIGHT_PAD_W - 1` is the `` glyph
@ -7470,3 +7486,180 @@ fn preview_remains_clamped_when_pending_exceeds_widened_width() {
"clamped note must render when pending > interior, even after widening",
);
}
// ---------------------------------------------------------------------------
// Locked coding_data_sharing row (ZDR / team non-admin)
// ---------------------------------------------------------------------------
fn make_locked_state(lock: CodingDataSharingLock) -> SettingsModalState {
SettingsModalState::new(
Arc::new(SettingsRegistry::defaults()),
UiConfig::default(),
PagerLocalSnapshot {
coding_data_sharing_lock: Some(lock),
..PagerLocalSnapshot::default()
},
)
}
fn coding_data_sharing_row_idx(s: &SettingsModalState) -> usize {
s.rows
.iter()
.position(|r| matches!(r, RowEntry::Setting { key, .. } if *key == "coding_data_sharing"))
.expect("coding_data_sharing must be registered")
}
/// A locked `coding_data_sharing` row must NOT open the enum picker —
/// neither via `try_enter_picking_enum` directly (the shared entry point
/// for Enter, mouse value clicks, and the `focus_key` auto-open path) nor
/// via the Browse Enter key. With no lock, the same row opens the picker.
#[test]
fn locked_coding_data_sharing_row_does_not_open_picker() {
for lock in [
CodingDataSharingLock::Zdr,
CodingDataSharingLock::TeamManaged,
] {
let mut s = make_locked_state(lock);
s.selected = coding_data_sharing_row_idx(&s);
assert!(
!s.try_enter_picking_enum(),
"try_enter_picking_enum must return false for a locked row ({lock:?})"
);
assert!(
matches!(s.mode(), SettingsModalMode::Browse),
"mode must stay Browse for a locked row ({lock:?}), got {:?}",
s.mode()
);
let out = handle_settings_key(&mut s, &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
matches!(out, SettingsKeyOutcome::Unchanged),
"Enter on a locked row must be a no-op ({lock:?}), got {out:?}"
);
assert!(matches!(s.mode(), SettingsModalMode::Browse));
}
// Control arm: no lock → the picker opens (existing behavior).
let mut s = make_state();
s.selected = coding_data_sharing_row_idx(&s);
assert!(s.try_enter_picking_enum());
assert!(matches!(s.mode(), SettingsModalMode::PickingEnum { .. }));
}
/// Locked rows drop the `` enter-affordance and render a per-variant
/// value: ZDR replaces opt-in/out with "ZDR"; team-managed keeps the
/// value with an " · Admin Managed" suffix. Unlocked rows keep the plain
/// value + chevron.
#[test]
fn locked_coding_data_sharing_row_renders_locked_value_without_chevron() {
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 60,
};
let theme = Theme::current();
let chevron = crate::glyphs::chevron();
let mut s = make_locked_state(CodingDataSharingLock::Zdr);
let idx = coding_data_sharing_row_idx(&s);
s.selected = idx;
let mut buf = Buffer::empty(area);
render_rows(&mut buf, area, &mut s, &theme);
let rect = s.row_rects[idx];
let line = buf_row_text(&buf, rect.y, area.x, area.width);
assert!(
line.contains("ZDR") && !line.contains("Opt"),
"ZDR lock must replace the opt-in/out value with `ZDR`: {line:?}"
);
assert!(
!line.contains(chevron),
"locked row must not render the `{chevron}` enter affordance: {line:?}"
);
let mut s = make_locked_state(CodingDataSharingLock::TeamManaged);
s.selected = idx;
let mut buf = Buffer::empty(area);
render_rows(&mut buf, area, &mut s, &theme);
let rect = s.row_rects[idx];
let line = buf_row_text(&buf, rect.y, area.x, area.width);
assert!(
line.contains("Opt out \u{00B7} Admin Managed"),
"team-managed lock must append ` · Admin Managed`: {line:?}"
);
assert!(
!line.contains(chevron),
"locked row must not render the `{chevron}` enter affordance: {line:?}"
);
// Control arm: unlocked row shows the plain value + chevron.
let mut s = make_state();
s.selected = idx;
let mut buf = Buffer::empty(area);
render_rows(&mut buf, area, &mut s, &theme);
let rect = s.row_rects[idx];
let line = buf_row_text(&buf, rect.y, area.x, area.width);
assert!(
line.contains("Opt out") && !line.contains("locked"),
"unlocked row must show the plain value: {line:?}"
);
assert!(
line.contains(chevron),
"unlocked row must keep the `{chevron}` enter affordance: {line:?}"
);
}
/// Expanding a locked row replaces the registry description with the lock
/// reason; the unlocked expansion shows the description.
#[test]
fn locked_coding_data_sharing_expanded_description_replaces_with_reason() {
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 60,
};
let theme = Theme::current();
// Word-wrap may split the reason across lines; normalize the whole
// buffer to a single whitespace-collapsed string before matching.
let flatten = |buf: &Buffer| -> String {
(0..area.height)
.map(|y| buf_row_text(buf, y, area.x, area.width))
.collect::<Vec<_>>()
.join(" ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
};
let mut s = make_locked_state(CodingDataSharingLock::TeamManaged);
let idx = coding_data_sharing_row_idx(&s);
s.selected = idx;
s.expanded_keys.insert("coding_data_sharing");
let mut buf = Buffer::empty(area);
render_rows(&mut buf, area, &mut s, &theme);
let text = flatten(&buf);
assert!(
text.contains("Managed by your team admin."),
"expanded locked row must show the lock reason: {text:?}"
);
assert!(
!text.contains("Controls whether"),
"locked expansion must replace the description, not append to it: {text:?}"
);
// Control arm: unlocked expansion shows the description only.
let mut s = make_state();
s.selected = idx;
s.expanded_keys.insert("coding_data_sharing");
let mut buf = Buffer::empty(area);
render_rows(&mut buf, area, &mut s, &theme);
let text = flatten(&buf);
assert!(
text.contains("Controls whether"),
"expanded row must render the registry description: {text:?}"
);
assert!(
!text.contains("Managed by your team admin."),
"unlocked expansion must not mention the team-admin lock: {text:?}"
);
}

View file

@ -212,8 +212,7 @@ pub struct TurnStatusArgs<'a> {
pub is_pending_user_input: bool,
pub goal_verifying: bool,
pub watchers: Watchers,
/// Parked on a sendable wait (`AgentView::renders_parked`): suppress the
/// running-turn chrome and render only the still-running cue.
/// Parked on a sendable wait (`AgentView::renders_parked`).
pub parked: bool,
/// Transparent right-side background so the row blends with the
/// terminal's own background (minimal mode).
@ -295,39 +294,55 @@ pub fn render_turn_status(
return TurnStatusOutput::default();
}
// Idle or parked with watchers: persistent still-running cue (not
// scrollback — it must never scroll away). Lower priority than the
// starting-session and drain-blocked cues above.
if (state.is_idle() || parked)
&& let Some(cue) = still_running_label(watchers)
{
// Pulsing concentric circle (○ ◎ ◉ ◎) on a calm ambient cadence:
// the agent is idle, so this breath runs slower than the active
// turn spinner (see MONITOR_PULSE_DIVISOR).
let frames = crate::glyphs::monitor_icon_frames();
let frame_idx = (tick / MONITOR_PULSE_DIVISOR) as usize % frames.len();
let icon = format!("{} ", frames[frame_idx]);
let label_fg = if buttons.is_some_and(|b| b.watching_hovered) {
theme.text_primary
// Idle or parked: persistent cue (not scrollback — it must never scroll
// away). Lower priority than the starting-session and drain-blocked cues
// above. Parked never falls through to the running-turn chrome
// (spinner/timers/[stop]) — the wait aborts the moment the user types,
// so that chrome would lie.
if state.is_idle() || parked {
// Parked with held queued rows: the queued hint IS the input-semantics
// story (Enter acts on the queue immediately), so it replaces the
// generic interrupt copy.
let parked_suffix = if held_queue > 0 && held_queue_top_sendable {
format!(" \u{00b7} {held_queue} queued — Enter to send now")
} else if held_queue > 0 {
format!(" \u{00b7} {held_queue} queued")
} else {
theme.gray
" \u{00b7} send a message to interrupt".to_string()
};
let cue_width = (icon.width() + cue.width()).min(area.width as usize) as u16;
let spans = vec![
Span::styled(icon, Style::default().fg(theme.accent_system)),
Span::styled(cue, Style::default().fg(label_fg)),
];
buf.set_line(area.x, area.y, &Line::from(spans), area.width);
return TurnStatusOutput {
watching_cue: show_buttons.then(|| Rect::new(area.x, area.y, cue_width, 1)),
..TurnStatusOutput::default()
let cue = match (still_running_label(watchers), parked) {
(Some(label), true) => Some(format!("{label}{parked_suffix}")),
(Some(label), false) => Some(label),
(None, true) => Some(format!("waiting{parked_suffix}")),
(None, false) => None,
};
}
// Parked with no watchers left: render nothing. The stopped look must
// never fall through to the running-turn chrome (spinner/timers/[stop])
// — the wait aborts the moment the user types, so that chrome would lie.
if parked {
if let Some(cue) = cue {
// Pulsing concentric circle (○ ◎ ◉ ◎) on a calm ambient cadence:
// the agent is idle, so this breath runs slower than the active
// turn spinner (see MONITOR_PULSE_DIVISOR).
let frames = crate::glyphs::monitor_icon_frames();
let frame_idx = (tick / MONITOR_PULSE_DIVISOR) as usize % frames.len();
let icon = format!("{} ", frames[frame_idx]);
let label_fg = if buttons.is_some_and(|b| b.watching_hovered) {
theme.text_primary
} else {
theme.gray
};
let cue_width = (icon.width() + cue.width()).min(area.width as usize) as u16;
let spans = vec![
Span::styled(icon, Style::default().fg(theme.accent_system)),
Span::styled(cue, Style::default().fg(label_fg)),
];
buf.set_line(area.x, area.y, &Line::from(spans), area.width);
// The cue opens the tasks pane on click — only advertise the hit
// area when there are tasks to show (a watcherless parked cue has
// nothing behind it).
return TurnStatusOutput {
watching_cue: (show_buttons && watchers.total() > 0)
.then(|| Rect::new(area.x, area.y, cue_width, 1)),
..TurnStatusOutput::default()
};
}
return TurnStatusOutput::default();
}
@ -787,9 +802,7 @@ fn render_starting_session(
/// completion/events, scheduled `/loop` tasks fire prompts, and background
/// subagents inject a completion turn, any of which can start a new turn.
///
/// A parked turn (`parked` — the stopped look while blocked on a sendable
/// wait) suppresses the running-turn chrome entirely: the row shows only when
/// watchers exist, rendering the "… still running" cue.
/// A parked turn always shows the row, watchers or not.
///
/// Real MCP progress (`total > 0`) renders as a compact chip in the top status
/// bar instead, so it does not affect this row.
@ -801,7 +814,7 @@ pub fn should_show(
parked: bool,
) -> bool {
if parked {
return watchers.total() > 0;
return true;
}
!state.is_idle()
|| drain_blocked
@ -1068,9 +1081,7 @@ mod tests {
}
#[test]
fn should_show_parked_only_with_watchers() {
// Parked (turn running but rendering the stopped look): the row shows
// only to carry the "… still running" cue — never the running chrome.
fn should_show_parked_always() {
assert!(should_show(
&AgentState::TurnRunning,
false,
@ -1081,7 +1092,7 @@ mod tests {
},
true
));
assert!(!should_show(
assert!(should_show(
&AgentState::TurnRunning,
false,
None,
@ -1435,16 +1446,14 @@ mod tests {
#[test]
fn parked_with_watchers_renders_cue_not_running_chrome() {
// A parked running turn renders the still-running cue — never the busy
// spinner/timers/[stop] chrome (the wait aborts as soon as the user
// types, so that chrome would lie).
// The wait aborts as soon as the user types, so busy chrome would lie.
let text = render_parked_with_watchers(Watchers {
commands: 2,
..Watchers::default()
});
assert!(
text.contains("2 commands still running"),
"parked with bg work must render the still-running cue, got: {text:?}"
text.contains("2 commands still running \u{00b7} send a message to interrupt"),
"parked with bg work must render the interruptible still-running cue, got: {text:?}"
);
assert!(
!text.contains("Waiting") && !text.contains("[stop]"),
@ -1453,11 +1462,39 @@ mod tests {
}
#[test]
fn parked_without_watchers_renders_nothing() {
fn parked_without_watchers_renders_waiting_cue() {
let text = render_parked_with_watchers(Watchers::default());
assert!(
text.trim().is_empty(),
"parked with no watchers must render nothing, got: {text:?}"
text.contains("waiting \u{00b7} send a message to interrupt"),
"watcherless parked must render the waiting interrupt cue, got: {text:?}"
);
assert!(
!text.contains("[stop]"),
"watcherless parked must not render the running-turn chrome, got: {text:?}"
);
}
#[test]
fn parked_with_held_queue_renders_queued_hint() {
// The queued hint replaces the interrupt copy (Enter = send-now).
let activity = Some(TurnActivity::Waiting(WaitingReason::TasksComplete));
let mut args = idle_args(Watchers {
commands: 1,
..Watchers::default()
});
args.state = &AgentState::TurnRunning;
args.activity = &activity;
args.parked = true;
args.held_queue = 1;
args.held_queue_top_sendable = true;
let text = render_row_text(args, 80);
assert!(
text.contains("1 queued — Enter to send now"),
"parked with a held row must advertise the queued hint, got: {text:?}"
);
assert!(
!text.contains("send a message to interrupt"),
"queued hint replaces the interrupt copy, got: {text:?}"
);
}

View file

@ -96,6 +96,9 @@ enum WorktreeDbCommand {
pub async fn run(args: WorktreeArgs, agent_config: &AgentConfig) -> Result<()> {
let cancel = CancellationToken::new();
let spawned = crate::acp::spawn::spawn_grok_shell(agent_config.clone(), &cancel, None).await?;
// Cancel + join on every return path, including the `?` below.
let _agent_guard =
crate::acp::spawn::AgentShutdownGuard::new(cancel.clone(), Some(spawned.thread_handle));
let _init: acp::InitializeResponse = acp_send(
acp::InitializeRequest::new(acp::ProtocolVersion::V1)
@ -116,9 +119,7 @@ pub async fn run(args: WorktreeArgs, agent_config: &AgentConfig) -> Result<()> {
)
.await?;
let result = dispatch(args.command, &spawned.channel.tx).await;
cancel.cancel();
result
dispatch(args.command, &spawned.channel.tx).await
}
async fn dispatch(command: WorktreeCommand, tx: &xai_acp_lib::AcpAgentTx) -> Result<()> {