Skip to main content

csd/commands/
send.rs

1//! `csd send` — inject a prompt with readiness/retry (PoC §2.2 + gotcha #1).
2//!
3//! Keystrokes sent before the TUI finishes initializing are silently dropped, and no fixed delay
4//! is reliable. So: send literal text → wait → verify the text echoed in the input box → retry if
5//! absent. Only once the echo is confirmed do we press Enter.
6
7use std::thread::sleep;
8
9use serde::Serialize;
10
11use crate::commands::{SEND_SETTLE, SUBMIT_DELAY};
12use crate::error::{Error, Result};
13use crate::tmux;
14
15/// How many trailing chars of the prompt to look for in the pane (robust to input-box wrapping).
16const ECHO_TAIL_CHARS: usize = 40;
17
18#[derive(Debug, Clone)]
19pub struct SendArgs {
20    pub session: String,
21    pub prompt: String,
22    pub submit: bool,
23    pub retries: u32,
24}
25
26#[derive(Debug, Serialize)]
27pub struct SendResult {
28    pub ok: bool,
29    pub attempts: u32,
30}
31
32pub fn run(args: SendArgs) -> Result<SendResult> {
33    crate::session::validate_name(&args.session)?;
34    let needle = echo_needle(&args.prompt);
35
36    for attempt in 1..=args.retries {
37        // Clear any partial input from a previous failed attempt, then type the prompt.
38        tmux::send_key(&args.session, "C-u")?;
39        tmux::send_literal(&args.session, &args.prompt)?;
40        sleep(SEND_SETTLE);
41
42        let pane = tmux::capture_pane(&args.session)?;
43        if echo_present(&pane, &needle) {
44            if args.submit {
45                sleep(SUBMIT_DELAY);
46                tmux::send_key(&args.session, "Enter")?;
47            }
48            return Ok(SendResult {
49                ok: true,
50                attempts: attempt,
51            });
52        }
53    }
54
55    Err(Error::InputNotAccepted(args.retries))
56}
57
58/// The trailing run of the prompt we expect to see echoed, reduced to lowercase alphanumerics.
59fn echo_needle(prompt: &str) -> String {
60    let normalized = normalize(prompt);
61    let tail: Vec<char> = normalized.chars().rev().take(ECHO_TAIL_CHARS).collect();
62    tail.into_iter().rev().collect()
63}
64
65fn echo_present(pane: &str, needle: &str) -> bool {
66    !needle.is_empty() && normalize(pane).contains(needle)
67}
68
69/// Reduce text to lowercase alphanumerics, dropping whitespace, punctuation, and TUI box glyphs —
70/// the input box wraps long prompts across bordered lines, so only the letters survive reliably.
71fn normalize(text: &str) -> String {
72    text.chars()
73        .filter(|c| c.is_alphanumeric())
74        .flat_map(char::to_lowercase)
75        .collect()
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn echo_matches_despite_wrapping() {
84        let prompt = "Ask me one clarifying question before doing anything.";
85        let needle = echo_needle(prompt);
86        // Simulate the TUI wrapping the prompt across lines with box padding.
87        let pane = "│ Ask me one clarifying question\n│ before doing anything. │";
88        assert!(echo_present(pane, &needle));
89    }
90
91    #[test]
92    fn echo_absent_when_not_typed() {
93        let needle = echo_needle("hello world this is a fairly long prompt to type");
94        assert!(!echo_present("│ > │  (empty input box)", &needle));
95    }
96}