use std::{
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
time::{Duration, Instant},
};
use regex::Regex;
use serde_json::json;
use tokio::sync::{mpsc, oneshot};
use crate::configuration::{PromptReadinessTemplate, TargetConfiguration};
use crate::envelope::{PromptBatchSettings, batch_envelope_groups};
use crate::runtime::paths::tmux_socket_path_for_runtime_directory;
use crate::runtime::signals::shutdown_requested;
use crate::transports::{
DeliveryEnvelope, DeliveryWaitError, LookMode, LookSnapshotPayload, OutcomeFuture, OutputView,
SendOutcome, SingleDeliveryOutcome, StartupContext, Transport, TransportError,
TransportReadiness, TransportStatus,
};
const LOOK_LINES_DEFAULT: usize = 120;
use super::pane::{
capture_pane_snapshot, capture_pane_tail_lines, emit_delivery_diagnostic, inject_literal_text,
operator_interaction_active, resolve_active_pane_target, resolve_cursor_column,
resolve_window_activity_marker, sanitize_diagnostic_text,
};
const PROMPT_INSPECT_LINES_DEFAULT: usize = 3;
const PROMPT_INSPECT_LINES_MAX: usize = 40;
const TMUX_TARGET_UNAVAILABLE_CODE: &str = "tmux_target_unavailable";
const WRITE_CHANNEL_CAPACITY: usize = 256;
type OutcomeSender = oneshot::Sender<SingleDeliveryOutcome>;
enum WriteItem {
Envelope(Box<DeliveryEnvelope>, OutcomeSender),
Raw(String, bool, OutcomeSender),
}
struct DeliveryTaskContext {
target_session: String,
runtime_directory: PathBuf,
target_member: crate::configuration::BundleMember,
}
pub struct TmuxTransport {
batch_settings: PromptBatchSettings,
sender: Option<mpsc::Sender<WriteItem>>,
task_context: Option<DeliveryTaskContext>,
shutdown_flag: Arc<AtomicBool>,
}
impl std::fmt::Debug for TmuxTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TmuxTransport")
.field("batch_settings", &self.batch_settings)
.field("sender", &self.sender.as_ref().map(|_| "..."))
.finish()
}
}
impl TmuxTransport {
#[must_use]
pub fn new(batch_settings: PromptBatchSettings) -> Self {
Self {
batch_settings,
sender: None,
task_context: None,
shutdown_flag: Arc::new(AtomicBool::new(false)),
}
}
fn ensure_task_running(&mut self) -> bool {
if self.sender.is_some() {
return true;
}
let ctx = match self.task_context.take() {
Some(ctx) => ctx,
None => return false,
};
let (sender, receiver) = mpsc::channel(WRITE_CHANNEL_CAPACITY);
self.sender = Some(sender);
let shutdown_flag = Arc::clone(&self.shutdown_flag);
let batch_settings = self.batch_settings;
thread::spawn(move || {
run_delivery_task(receiver, ctx, shutdown_flag, batch_settings);
});
true
}
fn enqueue(&self, item: WriteItem) {
if let Some(ch) = &self.sender
&& let Err(
mpsc::error::TrySendError::Full(item) | mpsc::error::TrySendError::Closed(item),
) = ch.try_send(item)
{
let (outcome_sender, message_id) = match item {
WriteItem::Envelope(env, sender) => (sender, env.message_id),
WriteItem::Raw(_, _, sender) => (sender, String::new()),
};
let _ = outcome_sender.send(SingleDeliveryOutcome {
target_session: String::new(),
message_id,
outcome: SendOutcome::Failed,
reason_code: Some("channel_full".to_string()),
reason: Some("internal write channel full or closed".to_string()),
details: None,
});
}
}
}
impl Transport for TmuxTransport {
fn startup(&mut self, context: StartupContext) -> Result<TransportStatus, TransportError> {
self.task_context = Some(DeliveryTaskContext {
target_session: context.target_member.id.clone(),
runtime_directory: context.runtime_directory,
target_member: context.target_member,
});
Ok(TransportStatus {
readiness: TransportReadiness::Ready,
})
}
fn mailw(&mut self, envelope: DeliveryEnvelope) -> OutcomeFuture {
let (sender, receiver) = oneshot::channel();
if !self.ensure_task_running() {
let _ = sender.send(SingleDeliveryOutcome {
target_session: String::new(),
message_id: envelope.message_id.clone(),
outcome: SendOutcome::Failed,
reason_code: Some("transport_not_started".to_string()),
reason: Some("mailw called before startup()".to_string()),
details: None,
});
return receiver;
}
self.enqueue(WriteItem::Envelope(Box::new(envelope), sender));
receiver
}
fn raww(&mut self, content: String, append_enter: bool) -> OutcomeFuture {
let (sender, receiver) = oneshot::channel();
if !self.ensure_task_running() {
let _ = sender.send(SingleDeliveryOutcome {
target_session: String::new(),
message_id: String::new(),
outcome: SendOutcome::Failed,
reason_code: Some("transport_not_started".to_string()),
reason: Some("raww called before startup()".to_string()),
details: None,
});
return receiver;
}
self.enqueue(WriteItem::Raw(content, append_enter, sender));
receiver
}
fn is_ready(&self) -> bool {
true
}
fn shutdown(&mut self) {
self.shutdown_flag.store(true, Ordering::Release);
self.sender = None;
}
fn give_output(&self) -> Option<Arc<dyn OutputView>> {
None
}
}
fn run_delivery_task(
mut receiver: mpsc::Receiver<WriteItem>,
ctx: DeliveryTaskContext,
shutdown_flag: Arc<AtomicBool>,
batch_settings: PromptBatchSettings,
) {
let tmux_socket_path = tmux_socket_path_for_runtime_directory(ctx.runtime_directory.as_path());
let prompt_readiness = match &ctx.target_member.target {
TargetConfiguration::Tmux(tmux_target) => tmux_target.prompt_readiness.clone(),
_ => None,
};
let mut group: Vec<(DeliveryEnvelope, OutcomeSender)> = Vec::new();
loop {
if shutdown_flag.load(Ordering::Acquire) {
drain_group_as_dropped(&mut group, &ctx.target_session);
drain_remaining_as_dropped(&mut receiver, &ctx.target_session);
return;
}
let item = match receiver.blocking_recv() {
Some(item) => item,
None => {
if !group.is_empty() {
flush_and_resolve(
&mut group,
&tmux_socket_path,
&ctx.target_session,
prompt_readiness.as_ref(),
&mut receiver,
&shutdown_flag,
batch_settings,
);
}
return;
}
};
match item {
WriteItem::Envelope(env, sender) => {
group.push((*env, sender));
}
WriteItem::Raw(content, append_enter, sender) => {
if !group.is_empty() {
flush_and_resolve(
&mut group,
&tmux_socket_path,
&ctx.target_session,
prompt_readiness.as_ref(),
&mut receiver,
&shutdown_flag,
batch_settings,
);
}
let _ = sender.send(deliver_raw(
&tmux_socket_path,
&ctx.target_session,
&content,
append_enter,
));
}
}
loop {
match receiver.try_recv() {
Ok(WriteItem::Envelope(env, sender)) => {
group.push((*env, sender));
}
Ok(WriteItem::Raw(content, append_enter, sender)) => {
if !group.is_empty() {
flush_and_resolve(
&mut group,
&tmux_socket_path,
&ctx.target_session,
prompt_readiness.as_ref(),
&mut receiver,
&shutdown_flag,
batch_settings,
);
}
let _ = sender.send(deliver_raw(
&tmux_socket_path,
&ctx.target_session,
&content,
append_enter,
));
}
Err(mpsc::error::TryRecvError::Empty) => {
if !group.is_empty() {
flush_and_resolve(
&mut group,
&tmux_socket_path,
&ctx.target_session,
prompt_readiness.as_ref(),
&mut receiver,
&shutdown_flag,
batch_settings,
);
}
break;
}
Err(mpsc::error::TryRecvError::Disconnected) => {
if !group.is_empty() {
flush_and_resolve(
&mut group,
&tmux_socket_path,
&ctx.target_session,
prompt_readiness.as_ref(),
&mut receiver,
&shutdown_flag,
batch_settings,
);
}
drain_remaining_as_dropped(&mut receiver, &ctx.target_session);
return;
}
}
}
if shutdown_flag.load(Ordering::Acquire) {
drain_group_as_dropped(&mut group, &ctx.target_session);
drain_remaining_as_dropped(&mut receiver, &ctx.target_session);
return;
}
}
}
fn drain_remaining_as_dropped(receiver: &mut mpsc::Receiver<WriteItem>, target_session: &str) {
while let Ok(item) = receiver.try_recv() {
let (sender, message_id) = match item {
WriteItem::Envelope(env, sender) => (sender, env.message_id),
WriteItem::Raw(_, _, sender) => (sender, String::new()),
};
let _ = sender.send(SingleDeliveryOutcome {
target_session: target_session.to_string(),
message_id,
outcome: SendOutcome::DroppedOnShutdown,
reason_code: Some("dropped_on_shutdown".to_string()),
reason: Some("delivery dropped due to relay shutdown".to_string()),
details: None,
});
}
}
fn drain_group_as_dropped(
group: &mut Vec<(DeliveryEnvelope, OutcomeSender)>,
target_session: &str,
) {
for (envelope, sender) in group.drain(..) {
let _ = sender.send(SingleDeliveryOutcome {
target_session: target_session.to_string(),
message_id: envelope.message_id,
outcome: SendOutcome::DroppedOnShutdown,
reason_code: Some("dropped_on_shutdown".to_string()),
reason: Some("delivery dropped due to relay shutdown".to_string()),
details: None,
});
}
}
fn flush_and_resolve(
group: &mut Vec<(DeliveryEnvelope, OutcomeSender)>,
tmux_socket_path: &Path,
target_session: &str,
prompt_readiness: Option<&PromptReadinessTemplate>,
receiver: &mut mpsc::Receiver<WriteItem>,
shutdown_flag: &AtomicBool,
batch_settings: PromptBatchSettings,
) {
if group.is_empty() {
return;
}
let quiet_window = group[0].0.quiet_window;
let quiescence_timeout = group[0].0.quiescence_timeout;
let mut deferred_raw: Option<(String, bool, OutcomeSender)> = None;
loop {
if shutdown_flag.load(Ordering::Acquire) {
for (envelope, sender) in group.drain(..) {
let _ = sender.send(make_dropped_on_shutdown(
target_session,
&envelope.message_id,
));
}
return;
}
match wait_for_quiescent_pane(
tmux_socket_path,
target_session,
quiet_window,
quiescence_timeout,
prompt_readiness,
) {
Ok(_) => {}
Err(DeliveryWaitError::Shutdown) => {
for (envelope, sender) in group.drain(..) {
let _ = sender.send(make_dropped_on_shutdown(
target_session,
&envelope.message_id,
));
}
return;
}
Err(wait_error) => {
for (envelope, sender) in group.drain(..) {
let _ = sender.send(wait_error_to_outcome(
target_session,
&wait_error,
&envelope.message_id,
));
}
return;
}
}
let mut absorbed = false;
if deferred_raw.is_none() {
loop {
match receiver.try_recv() {
Ok(WriteItem::Envelope(env, sender)) => {
group.push((*env, sender));
absorbed = true;
}
Ok(WriteItem::Raw(content, append_enter, sender)) => {
if absorbed {
deferred_raw = Some((content, append_enter, sender));
break;
} else {
paste_group(group, tmux_socket_path, target_session, batch_settings);
let _ = sender.send(deliver_raw(
tmux_socket_path,
target_session,
&content,
append_enter,
));
return;
}
}
Err(mpsc::error::TryRecvError::Empty) => break,
Err(mpsc::error::TryRecvError::Disconnected) => {
paste_group(group, tmux_socket_path, target_session, batch_settings);
drain_remaining_as_dropped(receiver, target_session);
return;
}
}
}
}
if absorbed {
continue;
}
break; }
paste_group(group, tmux_socket_path, target_session, batch_settings);
if let Some((content, append_enter, sender)) = deferred_raw {
let _ = sender.send(deliver_raw(
tmux_socket_path,
target_session,
&content,
append_enter,
));
}
}
fn paste_group(
group: &mut Vec<(DeliveryEnvelope, OutcomeSender)>,
tmux_socket_path: &Path,
target_session: &str,
batch_settings: PromptBatchSettings,
) {
if group.is_empty() {
return;
}
let pane_target = match resolve_active_pane_target(tmux_socket_path, target_session) {
Ok(pane) => pane,
Err(reason) => {
for (envelope, sender) in group.drain(..) {
let _ = sender.send(SingleDeliveryOutcome {
target_session: target_session.to_string(),
message_id: envelope.message_id.clone(),
outcome: SendOutcome::Failed,
reason_code: Some(TMUX_TARGET_UNAVAILABLE_CODE.to_string()),
reason: Some(reason.clone()),
details: None,
});
}
return;
}
};
let rendered: Vec<String> = group
.iter()
.map(|(envelope, _)| envelope.message.render_pane_envelope(&envelope.message_id))
.collect();
let budget_groups = batch_envelope_groups(&rendered, batch_settings);
let mut members = group.drain(..);
for budget_group in budget_groups {
let prompt_members: Vec<(String, OutcomeSender)> = members
.by_ref()
.take(budget_group.member_count)
.map(|(envelope, sender)| (envelope.message_id, sender))
.collect();
let inject_result = inject_literal_text(
tmux_socket_path,
&pane_target,
budget_group.combined_prompt.as_str(),
true,
);
for (message_id, sender) in prompt_members {
let outcome = match &inject_result {
Ok(()) => SingleDeliveryOutcome {
target_session: target_session.to_string(),
message_id,
outcome: SendOutcome::Delivered,
reason_code: None,
reason: None,
details: None,
},
Err(reason) => SingleDeliveryOutcome {
target_session: target_session.to_string(),
message_id,
outcome: SendOutcome::Failed,
reason_code: None,
reason: Some(reason.clone()),
details: None,
},
};
let _ = sender.send(outcome);
}
}
}
fn deliver_raw(
tmux_socket_path: &Path,
target_session: &str,
content: &str,
append_enter: bool,
) -> SingleDeliveryOutcome {
let pane_target = match resolve_active_pane_target(tmux_socket_path, target_session) {
Ok(pane) => pane,
Err(reason) => {
return SingleDeliveryOutcome {
target_session: target_session.to_string(),
message_id: String::new(),
outcome: SendOutcome::Failed,
reason_code: Some(TMUX_TARGET_UNAVAILABLE_CODE.to_string()),
reason: Some(reason),
details: None,
};
}
};
match inject_literal_text(tmux_socket_path, &pane_target, content, append_enter) {
Ok(()) => SingleDeliveryOutcome {
target_session: target_session.to_string(),
message_id: String::new(),
outcome: SendOutcome::Delivered,
reason_code: None,
reason: None,
details: None,
},
Err(reason) => SingleDeliveryOutcome {
target_session: target_session.to_string(),
message_id: String::new(),
outcome: SendOutcome::Failed,
reason_code: None,
reason: Some(reason),
details: None,
},
}
}
fn wait_error_to_outcome(
target_session: &str,
error: &DeliveryWaitError,
message_id: &str,
) -> SingleDeliveryOutcome {
match error {
DeliveryWaitError::Timeout {
timeout,
readiness_mismatch,
mismatch_reason,
} => SingleDeliveryOutcome {
target_session: target_session.to_string(),
message_id: message_id.to_string(),
outcome: SendOutcome::Failed,
reason_code: Some("quiescence_timeout".to_string()),
reason: Some(format!(
"quiescence timeout after {}ms (readiness_mismatch={}, reason={:?})",
timeout.as_millis(),
readiness_mismatch,
mismatch_reason
)),
details: None,
},
DeliveryWaitError::Failed { reason } => SingleDeliveryOutcome {
target_session: target_session.to_string(),
message_id: message_id.to_string(),
outcome: SendOutcome::Failed,
reason_code: Some(TMUX_TARGET_UNAVAILABLE_CODE.to_string()),
reason: Some(reason.clone()),
details: None,
},
DeliveryWaitError::Shutdown => make_dropped_on_shutdown(target_session, message_id),
}
}
fn make_dropped_on_shutdown(target_session: &str, message_id: &str) -> SingleDeliveryOutcome {
SingleDeliveryOutcome {
target_session: target_session.to_string(),
message_id: message_id.to_string(),
outcome: SendOutcome::DroppedOnShutdown,
reason_code: Some("dropped_on_shutdown".to_string()),
reason: Some("delivery dropped due to relay shutdown".to_string()),
details: None,
}
}
pub struct TmuxOutputView {
socket_path: PathBuf,
session_id: String,
}
impl TmuxOutputView {
#[must_use]
pub fn new(socket_path: PathBuf, session_id: String) -> Self {
Self {
socket_path,
session_id,
}
}
}
impl OutputView for TmuxOutputView {
fn look(&self, mode: LookMode) -> Result<LookSnapshotPayload, TransportError> {
if mode.offset.unwrap_or(0) > 0 {
return Err(TransportError {
code: "validation_offset_unsupported".to_string(),
reason: "offset is only supported for ACP look targets".to_string(),
details: Some(json!({ "offset": mode.offset })),
});
}
let requested_lines = mode
.lines
.map(|lines| lines as usize)
.unwrap_or(LOOK_LINES_DEFAULT);
let pane_target =
resolve_active_pane_target(self.socket_path.as_path(), self.session_id.as_str())
.map_err(|reason| TransportError {
code: "internal_unexpected_failure".to_string(),
reason: "failed to resolve active pane for look target".to_string(),
details: Some(json!({ "cause": reason })),
})?;
let snapshot_lines = capture_pane_tail_lines(
self.socket_path.as_path(),
pane_target.as_str(),
requested_lines,
)
.map_err(|reason| TransportError {
code: "internal_unexpected_failure".to_string(),
reason: "failed to capture look snapshot".to_string(),
details: Some(json!({ "cause": reason })),
})?;
Ok(LookSnapshotPayload::Lines { snapshot_lines })
}
}
#[derive(Debug)]
struct PromptReadinessMatcher {
prompt_regex: Regex,
inspect_lines: usize,
input_idle_cursor_column: Option<usize>,
}
#[derive(Debug, Default)]
struct PromptReadinessEvaluation {
ready: bool,
mismatch_reason: Option<String>,
inspected_block: Option<String>,
regex_matched: Option<bool>,
expected_cursor_column: Option<usize>,
observed_cursor_column: Option<usize>,
}
#[derive(Debug, PartialEq, Eq)]
struct PromptMismatchSignature {
mismatch_reason: Option<String>,
inspected_block: Option<String>,
regex_matched: Option<bool>,
expected_cursor_column: Option<usize>,
observed_cursor_column: Option<usize>,
}
impl PromptMismatchSignature {
fn from_evaluation(evaluation: &PromptReadinessEvaluation) -> Self {
Self {
mismatch_reason: evaluation.mismatch_reason.clone(),
inspected_block: evaluation.inspected_block.clone(),
regex_matched: evaluation.regex_matched,
expected_cursor_column: evaluation.expected_cursor_column,
observed_cursor_column: evaluation.observed_cursor_column,
}
}
}
fn should_emit_prompt_mismatch(
last: &mut Option<PromptMismatchSignature>,
evaluation: &PromptReadinessEvaluation,
) -> bool {
let signature = PromptMismatchSignature::from_evaluation(evaluation);
if last.as_ref() == Some(&signature) {
false
} else {
*last = Some(signature);
true
}
}
fn wait_for_quiescent_pane(
tmux_socket: &Path,
target_session: &str,
quiet_window: Duration,
quiescence_timeout: Option<Duration>,
prompt_readiness: Option<&PromptReadinessTemplate>,
) -> Result<String, DeliveryWaitError> {
let readiness = build_prompt_readiness_matcher(prompt_readiness)
.map_err(|reason| DeliveryWaitError::Failed { reason })?;
let deadline = quiescence_timeout.map(|timeout| Instant::now() + timeout);
let mut readiness_mismatch = false;
let mut mismatch_reason = None::<String>;
let mut last_mismatch_signature: Option<PromptMismatchSignature> = None;
loop {
if shutdown_requested() {
return Err(DeliveryWaitError::Shutdown);
}
let pane_before = resolve_active_pane_target(tmux_socket, target_session)
.map_err(|reason| DeliveryWaitError::Failed { reason })?;
let snapshot_before = capture_pane_snapshot(tmux_socket, &pane_before)
.map_err(|reason| DeliveryWaitError::Failed { reason })?;
let activity_before = resolve_window_activity_marker(tmux_socket, &pane_before)
.map_err(|reason| DeliveryWaitError::Failed { reason })?;
thread::sleep(quiet_window);
if shutdown_requested() {
return Err(DeliveryWaitError::Shutdown);
}
let pane_after = resolve_active_pane_target(tmux_socket, target_session)
.map_err(|reason| DeliveryWaitError::Failed { reason })?;
let snapshot_after = capture_pane_snapshot(tmux_socket, &pane_after)
.map_err(|reason| DeliveryWaitError::Failed { reason })?;
let activity_after = resolve_window_activity_marker(tmux_socket, &pane_after)
.map_err(|reason| DeliveryWaitError::Failed { reason })?;
let pane_is_quiescent = pane_before == pane_after
&& snapshot_before == snapshot_after
&& match (activity_before.as_ref(), activity_after.as_ref()) {
(Some(before), Some(after)) => before == after,
_ => true,
};
if pane_is_quiescent {
if let Some(reason) =
operator_interaction_active(tmux_socket, target_session, pane_after.as_str())
.map_err(|reason| DeliveryWaitError::Failed { reason })?
{
emit_delivery_diagnostic(
"delivery_operator_interaction",
&json!({
"target_session": target_session,
"pane_target": pane_after,
"reason": reason,
}),
);
continue;
}
let evaluation = match prompt_readiness_matches(
tmux_socket,
pane_after.as_str(),
snapshot_after.as_str(),
readiness.as_ref(),
) {
Ok(evaluation) => evaluation,
Err(reason) => return Err(DeliveryWaitError::Failed { reason }),
};
if evaluation.ready {
emit_delivery_diagnostic(
"delivery_ready",
&json!({
"target_session": target_session,
"pane_target": pane_after,
}),
);
return Ok(pane_after);
}
readiness_mismatch = true;
mismatch_reason = evaluation.mismatch_reason.clone();
if should_emit_prompt_mismatch(&mut last_mismatch_signature, &evaluation) {
emit_delivery_diagnostic(
"delivery_prompt_mismatch",
&json!({
"target_session": target_session,
"pane_target": pane_after,
"mismatch_reason": evaluation.mismatch_reason,
"regex_matched": evaluation.regex_matched,
"inspected_block": evaluation.inspected_block,
"expected_cursor_column": evaluation.expected_cursor_column,
"observed_cursor_column": evaluation.observed_cursor_column,
}),
);
}
}
if deadline.is_some_and(|value| Instant::now() >= value) {
let timeout = quiescence_timeout.unwrap_or_default();
emit_delivery_diagnostic(
"quiescence_timeout",
&json!({
"target_session": target_session,
"quiescence_timeout_ms": timeout.as_millis(),
"readiness_mismatch": readiness_mismatch,
"mismatch_reason": mismatch_reason,
}),
);
return Err(DeliveryWaitError::Timeout {
timeout,
readiness_mismatch,
mismatch_reason,
});
}
}
}
fn build_prompt_readiness_matcher(
template: Option<&PromptReadinessTemplate>,
) -> Result<Option<PromptReadinessMatcher>, String> {
let Some(template) = template else {
return Ok(None);
};
let prompt_regex = Regex::new(template.prompt_regex.as_str())
.map_err(|source| format!("invalid prompt_readiness.prompt_regex: {source}"))?;
let inspect_lines = template
.inspect_lines
.unwrap_or(PROMPT_INSPECT_LINES_DEFAULT)
.clamp(1, PROMPT_INSPECT_LINES_MAX);
Ok(Some(PromptReadinessMatcher {
prompt_regex,
inspect_lines,
input_idle_cursor_column: template.input_idle_cursor_column,
}))
}
fn prompt_readiness_matches(
tmux_socket: &Path,
pane_target: &str,
snapshot: &str,
matcher: Option<&PromptReadinessMatcher>,
) -> Result<PromptReadinessEvaluation, String> {
let Some(matcher) = matcher else {
return Ok(PromptReadinessEvaluation {
ready: true,
..PromptReadinessEvaluation::default()
});
};
let inspected = snapshot
.lines()
.rev()
.skip_while(|line| line.trim().is_empty())
.take(matcher.inspect_lines)
.collect::<Vec<_>>();
if inspected.is_empty() {
return Ok(PromptReadinessEvaluation {
mismatch_reason: Some(
"inspected pane tail was empty after trimming trailing blank lines".to_string(),
),
regex_matched: Some(false),
expected_cursor_column: matcher.input_idle_cursor_column,
..PromptReadinessEvaluation::default()
});
}
let mut ordered = inspected;
ordered.reverse();
let block = ordered.join("\n");
if !matcher.prompt_regex.is_match(block.as_str()) {
return Ok(PromptReadinessEvaluation {
mismatch_reason: Some("prompt regex did not match inspected pane tail".to_string()),
inspected_block: Some(sanitize_diagnostic_text(&block)),
regex_matched: Some(false),
expected_cursor_column: matcher.input_idle_cursor_column,
..PromptReadinessEvaluation::default()
});
}
let Some(expected_cursor_column) = matcher.input_idle_cursor_column else {
return Ok(PromptReadinessEvaluation {
ready: true,
inspected_block: Some(sanitize_diagnostic_text(&block)),
regex_matched: Some(true),
..PromptReadinessEvaluation::default()
});
};
let cursor_column = resolve_cursor_column(tmux_socket, pane_target)?;
if cursor_column != expected_cursor_column {
return Ok(PromptReadinessEvaluation {
mismatch_reason: Some(format!(
"cursor column {} did not match required {}",
cursor_column, expected_cursor_column
)),
inspected_block: Some(sanitize_diagnostic_text(&block)),
regex_matched: Some(true),
expected_cursor_column: Some(expected_cursor_column),
observed_cursor_column: Some(cursor_column),
..PromptReadinessEvaluation::default()
});
}
Ok(PromptReadinessEvaluation {
ready: true,
inspected_block: Some(sanitize_diagnostic_text(&block)),
regex_matched: Some(true),
expected_cursor_column: Some(expected_cursor_column),
observed_cursor_column: Some(cursor_column),
..PromptReadinessEvaluation::default()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dedup_emits_only_on_signature_transitions() {
let stuck = PromptReadinessEvaluation {
mismatch_reason: Some("prompt regex did not match inspected pane tail".to_string()),
inspected_block: Some("Do you want to proceed?".to_string()),
regex_matched: Some(false),
expected_cursor_column: Some(4),
observed_cursor_column: None,
..PromptReadinessEvaluation::default()
};
let cursor_only = PromptReadinessEvaluation {
mismatch_reason: Some("cursor column 0 did not match required 4".to_string()),
inspected_block: Some("> ".to_string()),
regex_matched: Some(true),
expected_cursor_column: Some(4),
observed_cursor_column: Some(0),
..PromptReadinessEvaluation::default()
};
let mut last = None;
assert!(
should_emit_prompt_mismatch(&mut last, &stuck),
"first mismatch must emit",
);
assert!(
!should_emit_prompt_mismatch(&mut last, &stuck),
"identical follow-up must suppress",
);
assert!(
!should_emit_prompt_mismatch(&mut last, &stuck),
"second identical follow-up must suppress",
);
assert!(
should_emit_prompt_mismatch(&mut last, &cursor_only),
"signature change must re-emit",
);
assert!(
!should_emit_prompt_mismatch(&mut last, &cursor_only),
"post-change identical follow-up must suppress",
);
}
}