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:
parent
6e38642082
commit
47348d13ec
138 changed files with 7283 additions and 5796 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¬if.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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 } => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<()> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue