Skip to main content

agent_tui_app/app/
attach.rs

1#![expect(clippy::print_stderr, reason = "CLI/TUI output after terminal restore")]
2
3//! Attach command implementation.
4
5use std::io;
6use std::io::Read;
7use std::io::Write;
8use std::panic;
9use std::str::FromStr;
10use std::sync::Arc;
11use std::sync::Mutex;
12use std::sync::atomic::AtomicBool;
13use std::sync::atomic::Ordering;
14use std::thread;
15use std::time::Duration;
16use std::time::Instant;
17
18use crate::adapters::RpcValue;
19use crate::adapters::rpc::params;
20use crate::app::rpc_client::RpcStream;
21use crate::app::rpc_client::call_stream_with_params;
22use crate::app::rpc_client::call_with_params;
23use crate::common::Colors;
24use crate::domain::session_types::TerminalSize;
25use crate::infra::ipc::ClientError;
26use crate::infra::ipc::DaemonClient;
27use crate::infra::terminal::key_to_escape_sequence;
28use base64::Engine;
29use base64::engine::general_purpose::STANDARD;
30use crossbeam_channel as channel;
31use crossterm::cursor;
32use crossterm::event;
33use crossterm::event::Event;
34use crossterm::event::KeyCode;
35use crossterm::event::KeyEventKind;
36use crossterm::event::KeyModifiers;
37use crossterm::execute;
38use crossterm::queue;
39use crossterm::style;
40use crossterm::terminal;
41use crossterm::terminal::disable_raw_mode;
42use crossterm::terminal::enable_raw_mode;
43
44pub use crate::app::error::AttachError;
45
46type PanicHook = Box<dyn Fn(&panic::PanicHookInfo<'_>) + Sync + Send + 'static>;
47type SharedPanicHook = Arc<Mutex<Option<PanicHook>>>;
48
49/// Restores terminal state on drop to avoid leaving the user's shell in a broken mode.
50#[must_use = "TerminalGuard must be held for the duration of the attach session"]
51struct TerminalGuard {
52    panic_hook_guard: TerminalPanicHookGuard,
53}
54
55impl TerminalGuard {
56    fn new() -> Result<Self, AttachError> {
57        enable_raw_mode().map_err(AttachError::Terminal)?;
58        let panic_hook_guard = TerminalPanicHookGuard::install();
59        let mut stdout = io::stdout();
60        prepare_terminal_with_rollback(&mut stdout, prepare_terminal)
61            .map_err(AttachError::Terminal)?;
62        Ok(Self { panic_hook_guard })
63    }
64}
65
66impl Drop for TerminalGuard {
67    fn drop(&mut self) {
68        restore_terminal_silently();
69    }
70}
71
72#[must_use = "TerminalPanicHookGuard must be held for the duration of the attach session"]
73struct TerminalPanicHookGuard {
74    previous_hook: Option<SharedPanicHook>,
75}
76
77impl TerminalPanicHookGuard {
78    fn install() -> Self {
79        Self {
80            previous_hook: Some(install_terminal_panic_hook()),
81        }
82    }
83
84    fn restore(&mut self) {
85        if thread::panicking() {
86            return;
87        }
88
89        let Some(previous_hook) = self.previous_hook.take() else {
90            return;
91        };
92
93        let current_hook = panic::take_hook();
94        drop(current_hook);
95
96        let previous_hook = {
97            let mut previous_hook_guard = previous_hook
98                .lock()
99                .unwrap_or_else(std::sync::PoisonError::into_inner);
100            previous_hook_guard.take()
101        };
102
103        if let Some(previous_hook) = previous_hook {
104            panic::set_hook(previous_hook);
105        }
106    }
107
108    #[cfg(test)]
109    fn has_previous_hook(&self) -> bool {
110        self.previous_hook.is_some()
111    }
112}
113
114impl Drop for TerminalPanicHookGuard {
115    fn drop(&mut self) {
116        self.restore();
117    }
118}
119
120#[derive(Debug, Clone, Copy)]
121pub enum AttachMode {
122    Tty,
123    Stream,
124}
125
126#[derive(Debug, Clone)]
127pub struct DetachKeys {
128    sequence: Vec<u8>,
129    display: String,
130}
131
132impl DetachKeys {
133    pub fn is_disabled(&self) -> bool {
134        self.sequence.is_empty()
135    }
136
137    pub fn bytes(&self) -> &[u8] {
138        &self.sequence
139    }
140
141    pub fn display(&self) -> &str {
142        &self.display
143    }
144
145    fn disabled() -> Self {
146        Self {
147            sequence: Vec::new(),
148            display: "disabled".to_string(),
149        }
150    }
151}
152
153impl Default for DetachKeys {
154    fn default() -> Self {
155        Self {
156            sequence: vec![0x10, 0x02],
157            display: "Ctrl-P Ctrl-B".to_string(),
158        }
159    }
160}
161
162impl FromStr for DetachKeys {
163    type Err = String;
164
165    fn from_str(value: &str) -> Result<Self, Self::Err> {
166        let trimmed = value.trim();
167        if trimmed.is_empty() {
168            return Err("detach keys cannot be empty".to_string());
169        }
170
171        if trimmed.eq_ignore_ascii_case("none") {
172            return Ok(Self::disabled());
173        }
174
175        let tokens: Vec<&str> = trimmed
176            .split(',')
177            .map(str::trim)
178            .filter(|token| !token.is_empty())
179            .collect();
180
181        if tokens.is_empty() {
182            return Err("detach keys cannot be empty".to_string());
183        }
184
185        let mut sequence = Vec::with_capacity(tokens.len());
186        let mut display_tokens = Vec::with_capacity(tokens.len());
187        for token in tokens {
188            let (byte, display) = parse_detach_key_token(token)?;
189            sequence.push(byte);
190            display_tokens.push(display);
191        }
192
193        Ok(Self {
194            sequence,
195            display: display_tokens.join(" "),
196        })
197    }
198}
199
200pub fn attach_ipc<C: DaemonClient>(
201    client: &mut C,
202    session_id: &str,
203    mode: AttachMode,
204    detach_keys: DetachKeys,
205) -> Result<(), AttachError> {
206    eprintln!(
207        "{} Attaching to session {}...",
208        Colors::dim("[attach]"),
209        Colors::session_id(session_id)
210    );
211
212    match mode {
213        AttachMode::Tty => {
214            if detach_keys.is_disabled() {
215                eprintln!(
216                    "{} Detach keys disabled (use --detach-keys to enable).",
217                    Colors::success("Connected!")
218                );
219            } else {
220                eprintln!(
221                    "{} Press {} to detach.",
222                    Colors::success("Connected!"),
223                    Colors::bold(detach_keys.display())
224                );
225            }
226            eprintln!();
227
228            let term_guard = TerminalGuard::new()?;
229            let stdout = Arc::new(Mutex::new(io::stdout()));
230
231            let initial_resize_warning = match terminal::size().map_err(AttachError::Terminal) {
232                Ok((cols, rows)) => TerminalSize::try_new(cols, rows).ok().and_then(|size| {
233                    sync_attach_resize(client, session_id, size)
234                        .err()
235                        .map(|error| attach_resize_warning(&error))
236                }),
237                Err(error) => return Err(error),
238            };
239            if let Ok(mut guard) = stdout.lock() {
240                render_initial_screen(client, session_id, &mut *guard);
241                if let Some(message) = initial_resize_warning.as_deref() {
242                    render_status_line(&mut *guard, Some(message));
243                }
244            }
245
246            let result = attach_ipc_loop(client, session_id, &detach_keys, stdout);
247
248            drop(term_guard);
249
250            result
251        }
252        AttachMode::Stream => {
253            let stdout = Arc::new(Mutex::new(io::stdout()));
254            attach_stream_loop(client, session_id, stdout)
255        }
256    }?;
257
258    eprintln!();
259    eprintln!(
260        "{} Detached from session {}",
261        Colors::dim("[attach]"),
262        Colors::session_id(session_id)
263    );
264
265    Ok(())
266}
267
268fn render_initial_screen<C: DaemonClient>(
269    client: &mut C,
270    session_id: &str,
271    stdout: &mut impl Write,
272) {
273    let params = params::SnapshotParams {
274        session: Some(session_id.to_string()),
275        include_cursor: true,
276        include_render: true,
277        ..Default::default()
278    };
279
280    let snapshot = match call_with_params(client, "snapshot", params) {
281        Ok(snapshot) => snapshot,
282        Err(_) => return,
283    };
284
285    let rendered = snapshot.get("rendered").and_then(|v| v.as_str());
286    let screenshot = snapshot.get("screenshot").and_then(|v| v.as_str());
287
288    let screen = match rendered.or(screenshot) {
289        Some(screen) => screen,
290        None => return,
291    };
292
293    if screen.is_empty() {
294        return;
295    }
296
297    let _ = queue!(
298        stdout,
299        terminal::Clear(terminal::ClearType::All),
300        cursor::MoveTo(0, 0),
301        style::SetAttribute(style::Attribute::Reset),
302        style::ResetColor,
303        style::Print(screen)
304    );
305
306    if let Some(cursor) = snapshot.get("cursor") {
307        let row = cursor
308            .get("row")
309            .and_then(|v| v.as_u64())
310            .unwrap_or(0)
311            .min(u16::MAX as u64) as u16;
312        let col = cursor
313            .get("col")
314            .and_then(|v| v.as_u64())
315            .unwrap_or(0)
316            .min(u16::MAX as u64) as u16;
317        let visible = cursor
318            .get("visible")
319            .and_then(|v| v.as_bool())
320            .unwrap_or(true);
321
322        let _ = queue!(stdout, cursor::MoveTo(col, row));
323        if visible {
324            let _ = queue!(stdout, cursor::Show);
325        } else {
326            let _ = queue!(stdout, cursor::Hide);
327        }
328    }
329
330    let _ = stdout.flush();
331}
332
333fn attach_ipc_loop<C: DaemonClient>(
334    client: &mut C,
335    session_id: &str,
336    detach_keys: &DetachKeys,
337    stdout: Arc<Mutex<io::Stdout>>,
338) -> Result<(), AttachError> {
339    let mut detach_detector = DetachDetector::new(detach_keys);
340    let mut paste_burst = PasteBurstState::default();
341    let mut hint_active = false;
342
343    let stream = call_stream_with_params(
344        client,
345        "attach_stream",
346        params::SessionParams {
347            session: Some(session_id.to_string()),
348        },
349    )
350    .map_err(|e| AttachError::PtyRead(format_client_error(&e)))?;
351    let mut output_worker = start_attach_stream_output(stream, Arc::clone(&stdout), false)?;
352    let event_worker = spawn_event_reader();
353    let paste_flush_tick = channel::tick(ATTACH_PASTE_BURST_CHAR_INTERVAL);
354
355    loop {
356        channel::select! {
357            recv(output_worker.receiver()) -> result => {
358                match result {
359                    Ok(Ok(())) => break,
360                    Ok(Err(err)) => return Err(err),
361                    Err(_) => break,
362                }
363            }
364            recv(paste_flush_tick) -> _ => {
365                if let Some(input) = paste_burst.flush_ready(Instant::now())
366                    && process_attach_input(
367                        client,
368                        session_id,
369                        detach_keys,
370                        &mut detach_detector,
371                        &mut hint_active,
372                        &stdout,
373                        input,
374                    )?
375                {
376                    finish_detach(&mut output_worker, &event_worker);
377                    break;
378                }
379            }
380            recv(event_worker.receiver()) -> msg => {
381                match msg {
382                    Ok(EventMessage::Event(Event::Key(key_event))) => {
383                        let now = Instant::now();
384                        if let Some(character) = plain_char_for_paste_burst(&key_event) {
385                            if let Some(input) = paste_burst.on_plain_char(character, now)
386                                && process_attach_input(
387                                    client,
388                                    session_id,
389                                    detach_keys,
390                                    &mut detach_detector,
391                                    &mut hint_active,
392                                    &stdout,
393                                    input,
394                                )?
395                            {
396                                finish_detach(&mut output_worker, &event_worker);
397                                break;
398                            }
399                            continue;
400                        }
401
402                        if let Some(input) = paste_burst.flush_all()
403                            && process_attach_input(
404                                client,
405                                session_id,
406                                detach_keys,
407                                &mut detach_detector,
408                                &mut hint_active,
409                                &stdout,
410                                input,
411                            )?
412                        {
413                            finish_detach(&mut output_worker, &event_worker);
414                            break;
415                        }
416
417                        if let Some(bytes) = key_event_to_bytes(&key_event)
418                            && process_attach_input(
419                                client,
420                                session_id,
421                                detach_keys,
422                                &mut detach_detector,
423                                &mut hint_active,
424                                &stdout,
425                                BufferedAttachInput::normal(bytes),
426                            )?
427                        {
428                            finish_detach(&mut output_worker, &event_worker);
429                            break;
430                        }
431                    }
432                    Ok(EventMessage::Event(Event::Paste(data))) => {
433                        if let Some(input) = paste_burst.flush_all()
434                            && process_attach_input(
435                                client,
436                                session_id,
437                                detach_keys,
438                                &mut detach_detector,
439                                &mut hint_active,
440                                &stdout,
441                                input,
442                            )?
443                        {
444                            finish_detach(&mut output_worker, &event_worker);
445                            break;
446                        }
447
448                        if !data.is_empty()
449                            && process_attach_input(
450                                client,
451                                session_id,
452                                detach_keys,
453                                &mut detach_detector,
454                                &mut hint_active,
455                                &stdout,
456                                BufferedAttachInput::bypass(data.into_bytes()),
457                            )?
458                        {
459                            finish_detach(&mut output_worker, &event_worker);
460                            break;
461                        }
462                    }
463                    Ok(EventMessage::Event(Event::Resize(cols, rows))) => {
464                        if let Some(input) = paste_burst.flush_all()
465                            && process_attach_input(
466                                client,
467                                session_id,
468                                detach_keys,
469                                &mut detach_detector,
470                                &mut hint_active,
471                                &stdout,
472                                input,
473                            )?
474                        {
475                            finish_detach(&mut output_worker, &event_worker);
476                            break;
477                        }
478
479                        if let Ok(size) = TerminalSize::try_new(cols, rows)
480                            && let Err(error) = sync_attach_resize(client, session_id, size)
481                        {
482                            announce_attach_warning(&stdout, &attach_resize_warning(&error));
483                        }
484                    }
485                    Ok(EventMessage::Event(_)) => {
486                        if let Some(input) = paste_burst.flush_all()
487                            && process_attach_input(
488                                client,
489                                session_id,
490                                detach_keys,
491                                &mut detach_detector,
492                                &mut hint_active,
493                                &stdout,
494                                input,
495                            )?
496                        {
497                            finish_detach(&mut output_worker, &event_worker);
498                            break;
499                        }
500                    }
501                    Ok(EventMessage::Error) => return Err(AttachError::EventRead),
502                    Err(_) => return Err(AttachError::EventRead),
503                }
504            }
505        }
506    }
507
508    Ok(())
509}
510
511fn attach_stream_loop<C: DaemonClient>(
512    client: &mut C,
513    session_id: &str,
514    stdout: Arc<Mutex<io::Stdout>>,
515) -> Result<(), AttachError> {
516    let stream = call_stream_with_params(
517        client,
518        "attach_stream",
519        params::SessionParams {
520            session: Some(session_id.to_string()),
521        },
522    )
523    .map_err(|e| AttachError::PtyRead(format_client_error(&e)))?;
524    let output_worker = start_attach_stream_output(stream, Arc::clone(&stdout), true)?;
525    let stdin_worker = spawn_stdin_reader();
526    let mut stdin_active = true;
527
528    loop {
529        if stdin_active {
530            channel::select! {
531                recv(output_worker.receiver()) -> result => {
532                    match result {
533                        Ok(Ok(())) => return Ok(()),
534                        Ok(Err(err)) => return Err(err),
535                        Err(_) => return Ok(()),
536                    }
537                }
538                recv(stdin_worker.receiver()) -> msg => {
539                    match msg {
540                        Ok(StdinMessage::Data(data)) => {
541                            if !data.is_empty() {
542                                let data_b64 = STANDARD.encode(&data);
543                                let params = params::PtyWriteParams {
544                                    session: Some(session_id.to_string()),
545                                    data: data_b64,
546                                };
547                                if let Err(e) = call_with_params(client, "pty_write", params) {
548                                    return Err(AttachError::PtyWrite(format_client_error(&e)));
549                                }
550                            }
551                        }
552                        Ok(StdinMessage::Eof) | Ok(StdinMessage::Error) => {
553                            stdin_active = false;
554                            stdin_worker.cancel();
555                        }
556                        Err(_) => {
557                            stdin_active = false;
558                            stdin_worker.cancel();
559                        }
560                    }
561                }
562            }
563        } else {
564            match output_worker.receiver().recv() {
565                Ok(Ok(())) => return Ok(()),
566                Ok(Err(err)) => return Err(err),
567                Err(_) => return Ok(()),
568            }
569        }
570    }
571}
572
573fn format_client_error(error: &ClientError) -> String {
574    let mut msg = error.to_string();
575    if let Some(suggestion) = error.suggestion() {
576        msg.push_str(&format!(" ({suggestion})"));
577    }
578    msg
579}
580
581fn prepare_terminal<W: Write>(stdout: &mut W) -> io::Result<()> {
582    execute!(
583        stdout,
584        terminal::EnterAlternateScreen,
585        terminal::Clear(terminal::ClearType::All),
586        cursor::MoveTo(0, 0),
587        style::SetAttribute(style::Attribute::Reset),
588        style::ResetColor,
589        terminal::EnableLineWrap,
590        cursor::Show,
591        event::DisableMouseCapture,
592        event::DisableFocusChange,
593        event::EnableBracketedPaste
594    )?;
595    stdout.flush()
596}
597
598fn prepare_terminal_with_rollback<W, F>(stdout: &mut W, prepare: F) -> io::Result<()>
599where
600    W: Write,
601    F: FnOnce(&mut W) -> io::Result<()>,
602{
603    if let Err(err) = prepare(stdout) {
604        let _ = disable_raw_mode();
605        let _ = reset_terminal_modes(stdout);
606        return Err(err);
607    }
608
609    Ok(())
610}
611
612fn restore_terminal_silently() {
613    let _ = disable_raw_mode();
614    let mut stdout = io::stdout();
615    let _ = reset_terminal_modes(&mut stdout);
616}
617
618fn install_terminal_panic_hook() -> SharedPanicHook {
619    let previous_hook: SharedPanicHook = Arc::new(Mutex::new(Some(panic::take_hook())));
620    let previous_hook_for_panic = Arc::clone(&previous_hook);
621    panic::set_hook(Box::new(move |panic_info| {
622        restore_terminal_silently();
623        let previous_hook_guard = previous_hook_for_panic
624            .lock()
625            .unwrap_or_else(std::sync::PoisonError::into_inner);
626        if let Some(previous_hook) = previous_hook_guard.as_ref() {
627            previous_hook(panic_info);
628        }
629    }));
630
631    previous_hook
632}
633
634fn reset_terminal_modes(stdout: &mut impl Write) -> io::Result<()> {
635    execute!(
636        stdout,
637        style::SetAttribute(style::Attribute::Reset),
638        style::ResetColor,
639        cursor::Show,
640        terminal::EnableLineWrap,
641        event::DisableMouseCapture,
642        event::DisableFocusChange,
643        event::DisableBracketedPaste,
644        terminal::LeaveAlternateScreen
645    )?;
646    stdout.flush()
647}
648
649#[derive(Debug)]
650struct DetachDetector {
651    sequence: Vec<u8>,
652    matched: usize,
653}
654
655impl DetachDetector {
656    fn new(detach_keys: &DetachKeys) -> Self {
657        Self {
658            sequence: detach_keys.bytes().to_vec(),
659            matched: 0,
660        }
661    }
662
663    fn is_partial_match(&self) -> bool {
664        self.matched > 0
665    }
666
667    fn consume(&mut self, bytes: &[u8]) -> (Vec<u8>, bool) {
668        let mut output = Vec::new();
669        for &byte in bytes {
670            if self.consume_byte(byte, &mut output) {
671                return (output, true);
672            }
673        }
674        (output, false)
675    }
676
677    fn consume_byte(&mut self, byte: u8, output: &mut Vec<u8>) -> bool {
678        if self.sequence.is_empty() {
679            output.push(byte);
680            return false;
681        }
682
683        if byte == self.sequence[self.matched] {
684            self.matched += 1;
685            if self.matched == self.sequence.len() {
686                self.matched = 0;
687                return true;
688            }
689            return false;
690        }
691
692        if self.matched > 0 {
693            output.extend_from_slice(&self.sequence[..self.matched]);
694            self.matched = 0;
695            output.push(byte);
696            return false;
697        }
698
699        output.push(byte);
700        false
701    }
702
703    fn cancel_partial_match(&mut self) -> Vec<u8> {
704        if self.matched == 0 {
705            return Vec::new();
706        }
707
708        let pending = self.sequence[..self.matched].to_vec();
709        self.matched = 0;
710        pending
711    }
712}
713
714#[derive(Debug, PartialEq, Eq)]
715struct BufferedAttachInput {
716    bytes: Vec<u8>,
717    bypass_detach: bool,
718}
719
720impl BufferedAttachInput {
721    fn normal(bytes: Vec<u8>) -> Self {
722        Self {
723            bytes,
724            bypass_detach: false,
725        }
726    }
727
728    fn bypass(bytes: Vec<u8>) -> Self {
729        Self {
730            bytes,
731            bypass_detach: true,
732        }
733    }
734}
735
736#[derive(Debug, Default)]
737struct PasteBurstState {
738    pending_first_char: Option<(char, Instant)>,
739    buffer: String,
740    last_plain_char_at: Option<Instant>,
741}
742
743impl PasteBurstState {
744    fn on_plain_char(&mut self, character: char, now: Instant) -> Option<BufferedAttachInput> {
745        if !self.buffer.is_empty() {
746            if self
747                .last_plain_char_at
748                .is_some_and(|last| now.duration_since(last) <= ATTACH_PASTE_BURST_CHAR_INTERVAL)
749            {
750                self.buffer.push(character);
751                self.last_plain_char_at = Some(now);
752                return None;
753            }
754
755            let flushed = self.take_buffer();
756            self.pending_first_char = Some((character, now));
757            return Some(flushed);
758        }
759
760        if let Some((pending, pending_at)) = self.pending_first_char {
761            if now.duration_since(pending_at) <= ATTACH_PASTE_BURST_CHAR_INTERVAL {
762                self.pending_first_char = None;
763                self.buffer.push(pending);
764                self.buffer.push(character);
765                self.last_plain_char_at = Some(now);
766                return None;
767            }
768
769            self.pending_first_char = Some((character, now));
770            return Some(Self::single_char_input(pending));
771        }
772
773        self.pending_first_char = Some((character, now));
774        None
775    }
776
777    fn flush_ready(&mut self, now: Instant) -> Option<BufferedAttachInput> {
778        if !self.buffer.is_empty() {
779            if self
780                .last_plain_char_at
781                .is_some_and(|last| now.duration_since(last) > ATTACH_PASTE_BURST_CHAR_INTERVAL)
782            {
783                return Some(self.take_buffer());
784            }
785            return None;
786        }
787
788        if let Some((pending, pending_at)) = self.pending_first_char
789            && now.duration_since(pending_at) > ATTACH_PASTE_BURST_CHAR_INTERVAL
790        {
791            self.pending_first_char = None;
792            return Some(Self::single_char_input(pending));
793        }
794
795        None
796    }
797
798    fn flush_all(&mut self) -> Option<BufferedAttachInput> {
799        if !self.buffer.is_empty() {
800            return Some(self.take_buffer());
801        }
802
803        self.pending_first_char
804            .take()
805            .map(|(pending, _)| Self::single_char_input(pending))
806    }
807
808    fn single_char_input(character: char) -> BufferedAttachInput {
809        let mut buf = [0u8; 4];
810        let bytes = character.encode_utf8(&mut buf).as_bytes().to_vec();
811        BufferedAttachInput::normal(bytes)
812    }
813
814    fn take_buffer(&mut self) -> BufferedAttachInput {
815        self.last_plain_char_at = None;
816        let bytes = self.buffer.as_bytes().to_vec();
817        self.buffer.clear();
818        BufferedAttachInput::bypass(bytes)
819    }
820}
821
822fn render_status_line(stdout: &mut impl Write, message: Option<&str>) {
823    let (cols, rows) = match terminal::size() {
824        Ok(size) => size,
825        Err(_) => return,
826    };
827    let row = rows.saturating_sub(1);
828    let mut line = message.unwrap_or("").to_string();
829    let max_len = cols as usize;
830    if line.len() > max_len {
831        line.truncate(max_len);
832    }
833    if line.len() < max_len {
834        let pad = max_len - line.len();
835        line.reserve(pad);
836        line.extend(std::iter::repeat_n(' ', pad));
837    }
838    let _ = queue!(
839        stdout,
840        cursor::SavePosition,
841        cursor::MoveTo(0, row),
842        style::SetAttribute(style::Attribute::Reset),
843        style::ResetColor,
844        style::Print(line),
845        cursor::RestorePosition
846    );
847    let _ = stdout.flush();
848}
849
850fn sync_detach_hint(
851    stdout: &Arc<Mutex<io::Stdout>>,
852    detach_keys: &DetachKeys,
853    detach_detector: &DetachDetector,
854    hint_active: &mut bool,
855) {
856    if detach_keys.is_disabled() {
857        return;
858    }
859
860    let now_active = detach_detector.is_partial_match();
861    if now_active == *hint_active {
862        return;
863    }
864
865    if let Ok(mut guard) = stdout.lock() {
866        render_status_line(
867            &mut *guard,
868            if now_active {
869                Some("Detach: sequence started, press remaining keys to detach")
870            } else {
871                None
872            },
873        );
874    }
875    *hint_active = now_active;
876}
877
878fn finish_detach(
879    output_worker: &mut AttachOutputWorker,
880    event_worker: &AttachReaderWorker<EventMessage>,
881) {
882    output_worker.abort();
883    let _ = output_worker
884        .receiver()
885        .recv_timeout(ATTACH_OUTPUT_SHUTDOWN_WAIT);
886    event_worker.cancel();
887}
888
889fn write_session_bytes<C: DaemonClient>(
890    client: &mut C,
891    session_id: &str,
892    data: &[u8],
893) -> Result<(), AttachError> {
894    if data.is_empty() {
895        return Ok(());
896    }
897
898    let data_b64 = STANDARD.encode(data);
899    let params = params::PtyWriteParams {
900        session: Some(session_id.to_string()),
901        data: data_b64,
902    };
903    call_with_params(client, "pty_write", params)
904        .map(|_| ())
905        .map_err(|e| AttachError::PtyWrite(format_client_error(&e)))
906}
907
908fn sync_attach_resize<C: DaemonClient>(
909    client: &mut C,
910    session_id: &str,
911    size: TerminalSize,
912) -> Result<(), ClientError> {
913    let params = params::ResizeParams {
914        size,
915        session: Some(session_id.to_string()),
916    };
917    call_with_params(client, "resize", params).map(|_| ())
918}
919
920fn attach_resize_warning(error: &ClientError) -> String {
921    format!("Resize sync failed: {}", format_client_error(error))
922}
923
924fn announce_attach_warning(stdout: &Arc<Mutex<io::Stdout>>, message: &str) {
925    tracing::warn!(warning = %message, "Attach warning");
926    if let Ok(mut guard) = stdout.lock() {
927        render_status_line(&mut *guard, Some(message));
928    }
929}
930
931fn process_attach_input<C: DaemonClient>(
932    client: &mut C,
933    session_id: &str,
934    detach_keys: &DetachKeys,
935    detach_detector: &mut DetachDetector,
936    hint_active: &mut bool,
937    stdout: &Arc<Mutex<io::Stdout>>,
938    input: BufferedAttachInput,
939) -> Result<bool, AttachError> {
940    let (to_send, detach) = if input.bypass_detach {
941        let mut bytes = detach_detector.cancel_partial_match();
942        bytes.extend_from_slice(&input.bytes);
943        (bytes, false)
944    } else {
945        detach_detector.consume(&input.bytes)
946    };
947
948    sync_detach_hint(stdout, detach_keys, detach_detector, hint_active);
949
950    if detach {
951        return Ok(true);
952    }
953
954    write_session_bytes(client, session_id, &to_send)?;
955    Ok(false)
956}
957
958enum StdinMessage {
959    Data(Vec<u8>),
960    Eof,
961    Error,
962}
963
964const ATTACH_STDIN_CHANNEL_CAPACITY: usize = 64;
965const ATTACH_INPUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
966const ATTACH_THREAD_JOIN_TIMEOUT: Duration = Duration::from_millis(500);
967const ATTACH_OUTPUT_SHUTDOWN_WAIT: Duration = Duration::from_millis(500);
968const ATTACH_PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);
969
970struct AttachReaderWorker<T> {
971    rx: channel::Receiver<T>,
972    cancelled: Arc<AtomicBool>,
973    join: Option<thread::JoinHandle<()>>,
974    name: &'static str,
975}
976
977impl<T> AttachReaderWorker<T> {
978    fn receiver(&self) -> &channel::Receiver<T> {
979        &self.rx
980    }
981
982    fn cancel(&self) {
983        self.cancelled.store(true, Ordering::Relaxed);
984    }
985
986    fn shutdown(&mut self, timeout: Duration) {
987        self.cancel();
988        join_thread_with_timeout(&mut self.join, timeout, self.name);
989    }
990}
991
992impl<T> Drop for AttachReaderWorker<T> {
993    fn drop(&mut self) {
994        self.shutdown(ATTACH_THREAD_JOIN_TIMEOUT);
995    }
996}
997
998struct AttachOutputWorker {
999    done_rx: channel::Receiver<Result<(), AttachError>>,
1000    join: Option<thread::JoinHandle<()>>,
1001    shutdown_signal: Option<Arc<dyn Fn() + Send + Sync>>,
1002}
1003
1004impl AttachOutputWorker {
1005    fn receiver(&self) -> &channel::Receiver<Result<(), AttachError>> {
1006        &self.done_rx
1007    }
1008
1009    fn abort(&mut self) {
1010        if let Some(shutdown_signal) = self.shutdown_signal.take() {
1011            shutdown_signal();
1012        }
1013    }
1014
1015    fn shutdown(&mut self, timeout: Duration) {
1016        self.abort();
1017        join_thread_with_timeout(&mut self.join, timeout, "attach-stream-output");
1018    }
1019}
1020
1021impl Drop for AttachOutputWorker {
1022    fn drop(&mut self) {
1023        self.shutdown(ATTACH_THREAD_JOIN_TIMEOUT);
1024    }
1025}
1026
1027fn join_thread_with_timeout(
1028    join: &mut Option<thread::JoinHandle<()>>,
1029    timeout: Duration,
1030    name: &'static str,
1031) {
1032    let Some(handle) = join.take() else {
1033        return;
1034    };
1035    let deadline = Instant::now() + timeout;
1036    while !handle.is_finished() {
1037        if Instant::now() >= deadline {
1038            tracing::warn!(
1039                thread = name,
1040                timeout_ms = timeout.as_millis(),
1041                "Timed out joining attach helper thread"
1042            );
1043            return;
1044        }
1045        thread::park_timeout(Duration::from_millis(10));
1046    }
1047    let _ = handle.join();
1048}
1049
1050fn spawn_stdin_reader() -> AttachReaderWorker<StdinMessage> {
1051    let (tx, rx) = channel::bounded(ATTACH_STDIN_CHANNEL_CAPACITY);
1052    let span = tracing::debug_span!("attach_stdin_reader");
1053    let builder = thread::Builder::new().name("attach-stdin".to_string());
1054    let cancelled = Arc::new(AtomicBool::new(false));
1055    let cancelled_for_thread = Arc::clone(&cancelled);
1056    let tx_thread = tx.clone();
1057    let join = match builder.spawn(move || {
1058        let _guard = span.enter();
1059        let mut stdin = io::stdin();
1060        let mut buf = [0u8; 4096];
1061        loop {
1062            if cancelled_for_thread.load(Ordering::Relaxed) {
1063                break;
1064            }
1065            #[cfg(unix)]
1066            {
1067                match stdin_ready(ATTACH_INPUT_POLL_INTERVAL) {
1068                    Ok(true) => {}
1069                    Ok(false) => continue,
1070                    Err(_) => {
1071                        let _ = tx_thread.send(StdinMessage::Error);
1072                        break;
1073                    }
1074                }
1075            }
1076            match stdin.read(&mut buf) {
1077                Ok(0) => {
1078                    let _ = tx_thread.send(StdinMessage::Eof);
1079                    break;
1080                }
1081                Ok(n) => {
1082                    if tx_thread
1083                        .send(StdinMessage::Data(buf[..n].to_vec()))
1084                        .is_err()
1085                    {
1086                        break;
1087                    }
1088                }
1089                Err(_) => {
1090                    let _ = tx_thread.send(StdinMessage::Error);
1091                    break;
1092                }
1093            }
1094        }
1095    }) {
1096        Ok(handle) => Some(handle),
1097        Err(err) => {
1098            tracing::warn!(error = %err, "Failed to spawn stdin reader");
1099            let _ = tx.send(StdinMessage::Error);
1100            None
1101        }
1102    };
1103    AttachReaderWorker {
1104        rx,
1105        cancelled,
1106        join,
1107        name: "attach-stdin",
1108    }
1109}
1110
1111enum EventMessage {
1112    Event(Event),
1113    Error,
1114}
1115
1116const ATTACH_EVENT_CHANNEL_CAPACITY: usize = 256;
1117
1118#[cfg(unix)]
1119fn stdin_ready(timeout: Duration) -> io::Result<bool> {
1120    use std::os::fd::AsRawFd;
1121    let mut fds = [libc::pollfd {
1122        fd: io::stdin().as_raw_fd(),
1123        events: libc::POLLIN,
1124        revents: 0,
1125    }];
1126    let timeout_ms = timeout.as_millis().min(i32::MAX as u128) as i32;
1127    loop {
1128        // SAFETY: `poll` is called with a valid pointer to a stack-allocated array.
1129        let rc = unsafe { libc::poll(fds.as_mut_ptr(), 1, timeout_ms) };
1130        if rc > 0 {
1131            return Ok(fds[0].revents & libc::POLLIN != 0);
1132        }
1133        if rc == 0 {
1134            return Ok(false);
1135        }
1136        let err = io::Error::last_os_error();
1137        if err.kind() == io::ErrorKind::Interrupted {
1138            continue;
1139        }
1140        return Err(err);
1141    }
1142}
1143
1144fn spawn_event_reader() -> AttachReaderWorker<EventMessage> {
1145    let (tx, rx) = channel::bounded(ATTACH_EVENT_CHANNEL_CAPACITY);
1146    let span = tracing::debug_span!("attach_event_reader");
1147    let builder = thread::Builder::new().name("attach-events".to_string());
1148    let cancelled = Arc::new(AtomicBool::new(false));
1149    let cancelled_for_thread = Arc::clone(&cancelled);
1150    let tx_thread = tx.clone();
1151    let join = match builder.spawn(move || {
1152        let _guard = span.enter();
1153        loop {
1154            if cancelled_for_thread.load(Ordering::Relaxed) {
1155                break;
1156            }
1157            match event::poll(ATTACH_INPUT_POLL_INTERVAL) {
1158                Ok(true) => {}
1159                Ok(false) => continue,
1160                Err(_) => {
1161                    let _ = tx_thread.send(EventMessage::Error);
1162                    break;
1163                }
1164            }
1165            match event::read() {
1166                Ok(ev) => {
1167                    if tx_thread.send(EventMessage::Event(ev)).is_err() {
1168                        break;
1169                    }
1170                }
1171                Err(_) => {
1172                    let _ = tx_thread.send(EventMessage::Error);
1173                    break;
1174                }
1175            }
1176        }
1177    }) {
1178        Ok(handle) => Some(handle),
1179        Err(err) => {
1180            tracing::warn!(error = %err, "Failed to spawn event reader");
1181            let _ = tx.send(EventMessage::Error);
1182            None
1183        }
1184    };
1185    AttachReaderWorker {
1186        rx,
1187        cancelled,
1188        join,
1189        name: "attach-events",
1190    }
1191}
1192
1193enum AttachStreamEvent {
1194    Output { data: Vec<u8>, dropped_bytes: u64 },
1195    Dropped(u64),
1196    Closed,
1197}
1198
1199fn parse_stream_event(value: RpcValue) -> Result<Option<AttachStreamEvent>, AttachError> {
1200    let event = value.get("event").and_then(|v| v.as_str());
1201    let Some(event) = event else {
1202        return Ok(None);
1203    };
1204
1205    match event {
1206        "output" => {
1207            let data_b64 = value.get("data").and_then(|v| v.as_str()).unwrap_or("");
1208            if data_b64.is_empty() {
1209                return Ok(None);
1210            }
1211            let data = STANDARD
1212                .decode(data_b64)
1213                .map_err(|e| AttachError::PtyRead(e.to_string()))?;
1214            let dropped_bytes = value
1215                .get("dropped_bytes")
1216                .and_then(|v| v.as_u64())
1217                .unwrap_or(0);
1218            Ok(Some(AttachStreamEvent::Output {
1219                data,
1220                dropped_bytes,
1221            }))
1222        }
1223        "dropped" => {
1224            let dropped_bytes = value
1225                .get("dropped_bytes")
1226                .and_then(|v| v.as_u64())
1227                .unwrap_or(0);
1228            Ok(Some(AttachStreamEvent::Dropped(dropped_bytes)))
1229        }
1230        "closed" => Ok(Some(AttachStreamEvent::Closed)),
1231        "ready" | "heartbeat" => Ok(None),
1232        _ => Ok(None),
1233    }
1234}
1235
1236fn start_attach_stream_output(
1237    stream: RpcStream,
1238    stdout: Arc<Mutex<io::Stdout>>,
1239    report_drops: bool,
1240) -> Result<AttachOutputWorker, AttachError> {
1241    let (tx, rx) = channel::bounded(1);
1242    let shutdown_signal = stream
1243        .abort_handle()
1244        .map(|handle| Arc::new(move || handle.abort()) as Arc<dyn Fn() + Send + Sync>);
1245    let builder = thread::Builder::new().name("attach-stream-output".to_string());
1246    let join = builder
1247        .spawn(move || {
1248            let result = stream_output_loop(stream, stdout, report_drops);
1249            let _ = tx.send(result);
1250        })
1251        .map_err(|err| {
1252            AttachError::PtyRead(format!("Failed to spawn attach output thread: {err}"))
1253        })?;
1254    Ok(AttachOutputWorker {
1255        done_rx: rx,
1256        join: Some(join),
1257        shutdown_signal,
1258    })
1259}
1260
1261fn stream_output_loop(
1262    mut stream: RpcStream,
1263    stdout: Arc<Mutex<io::Stdout>>,
1264    report_drops: bool,
1265) -> Result<(), AttachError> {
1266    loop {
1267        let next = stream
1268            .next_result()
1269            .map_err(|e| AttachError::PtyRead(format_client_error(&e)))?;
1270        let Some(value) = next else {
1271            return Ok(());
1272        };
1273        match parse_stream_event(value)? {
1274            Some(AttachStreamEvent::Output {
1275                data,
1276                dropped_bytes,
1277            }) => {
1278                if let (true, Ok(mut guard)) = (!data.is_empty(), stdout.lock()) {
1279                    guard.write_all(&data).map_err(AttachError::Terminal)?;
1280                    guard.flush().map_err(AttachError::Terminal)?;
1281                }
1282                if report_drops && dropped_bytes > 0 {
1283                    eprintln!(
1284                        "{} Dropped {} bytes from stream buffer.",
1285                        Colors::warning("[attach]"),
1286                        dropped_bytes
1287                    );
1288                }
1289            }
1290            Some(AttachStreamEvent::Dropped(dropped_bytes)) => {
1291                if report_drops && dropped_bytes > 0 {
1292                    eprintln!(
1293                        "{} Dropped {} bytes from stream buffer.",
1294                        Colors::warning("[attach]"),
1295                        dropped_bytes
1296                    );
1297                }
1298            }
1299            Some(AttachStreamEvent::Closed) => return Ok(()),
1300            None => {}
1301        }
1302    }
1303}
1304
1305fn plain_char_for_paste_burst(key_event: &event::KeyEvent) -> Option<char> {
1306    if key_event.kind == KeyEventKind::Release || key_event.modifiers != KeyModifiers::NONE {
1307        return None;
1308    }
1309
1310    match key_event.code {
1311        KeyCode::Char(character) => Some(character),
1312        _ => None,
1313    }
1314}
1315
1316fn key_event_to_bytes(key_event: &event::KeyEvent) -> Option<Vec<u8>> {
1317    if key_event.kind == KeyEventKind::Release {
1318        return None;
1319    }
1320
1321    match key_event.code {
1322        KeyCode::Char(c) => key_char_to_bytes(c, key_event.modifiers),
1323        KeyCode::F(n) => {
1324            let key = format!("F{n}");
1325            key_with_modifiers_to_bytes(&key, key_event.modifiers)
1326        }
1327        KeyCode::BackTab => key_to_escape_sequence("Shift+Tab"),
1328        _ => {
1329            let base = keycode_to_name(key_event.code)?;
1330            key_with_modifiers_to_bytes(base, key_event.modifiers)
1331        }
1332    }
1333}
1334
1335fn key_char_to_bytes(c: char, modifiers: KeyModifiers) -> Option<Vec<u8>> {
1336    if modifiers.contains(KeyModifiers::CONTROL) {
1337        let key = format_modified_char("Ctrl", c);
1338        return key_to_escape_sequence(&key);
1339    }
1340
1341    if modifiers.contains(KeyModifiers::ALT) {
1342        let key = format_modified_char("Alt", c);
1343        return key_to_escape_sequence(&key);
1344    }
1345
1346    let mut buf = [0u8; 4];
1347    let s = c.encode_utf8(&mut buf);
1348    Some(s.as_bytes().to_vec())
1349}
1350
1351fn key_with_modifiers_to_bytes(base: &str, modifiers: KeyModifiers) -> Option<Vec<u8>> {
1352    if modifiers.contains(KeyModifiers::SHIFT) && base == "Tab" {
1353        return key_to_escape_sequence("Shift+Tab");
1354    }
1355
1356    if modifiers.contains(KeyModifiers::CONTROL) {
1357        let key = format_modified_key("Ctrl", base);
1358        return key_to_escape_sequence(&key);
1359    }
1360
1361    if modifiers.contains(KeyModifiers::ALT) {
1362        let key = format_modified_key("Alt", base);
1363        return key_to_escape_sequence(&key);
1364    }
1365
1366    key_to_escape_sequence(base)
1367}
1368
1369fn format_modified_key(prefix: &str, base: &str) -> String {
1370    let mut key = String::with_capacity(prefix.len() + 1 + base.len());
1371    key.push_str(prefix);
1372    key.push('+');
1373    key.push_str(base);
1374    key
1375}
1376
1377fn format_modified_char(prefix: &str, c: char) -> String {
1378    let mut key = String::with_capacity(prefix.len() + 2);
1379    key.push_str(prefix);
1380    key.push('+');
1381    key.push(c);
1382    key
1383}
1384
1385fn keycode_to_name(code: KeyCode) -> Option<&'static str> {
1386    match code {
1387        KeyCode::Enter => Some("Enter"),
1388        KeyCode::Tab => Some("Tab"),
1389        KeyCode::Backspace => Some("Backspace"),
1390        KeyCode::Delete => Some("Delete"),
1391        KeyCode::Esc => Some("Escape"),
1392        KeyCode::Up => Some("ArrowUp"),
1393        KeyCode::Down => Some("ArrowDown"),
1394        KeyCode::Right => Some("ArrowRight"),
1395        KeyCode::Left => Some("ArrowLeft"),
1396        KeyCode::Home => Some("Home"),
1397        KeyCode::End => Some("End"),
1398        KeyCode::PageUp => Some("PageUp"),
1399        KeyCode::PageDown => Some("PageDown"),
1400        KeyCode::Insert => Some("Insert"),
1401        _ => None,
1402    }
1403}
1404
1405fn parse_detach_key_token(token: &str) -> Result<(u8, String), String> {
1406    let trimmed = token.trim();
1407    if trimmed.is_empty() {
1408        return Err("detach keys cannot be empty".to_string());
1409    }
1410
1411    let lower = trimmed.to_ascii_lowercase();
1412    if lower.starts_with("ctrl-") || lower.starts_with("control-") {
1413        let split_pos = match trimmed.find('-') {
1414            Some(pos) => pos,
1415            None => {
1416                return Err("detach keys: ctrl- requires a key (e.g. ctrl-p)".to_string());
1417            }
1418        };
1419        let rest = trimmed[split_pos + 1..].trim();
1420        if rest.is_empty() {
1421            return Err("detach keys: ctrl- requires a key (e.g. ctrl-p)".to_string());
1422        }
1423
1424        let ch = if rest.eq_ignore_ascii_case("space") {
1425            ' '
1426        } else {
1427            let mut chars = rest.chars();
1428            match (chars.next(), chars.next()) {
1429                (Some(ch), None) => ch,
1430                _ => return Err(format!("detach keys: unsupported ctrl key '{rest}'")),
1431            }
1432        };
1433
1434        let byte = ctrl_char_to_byte(ch)
1435            .ok_or_else(|| format!("detach keys: unsupported ctrl key '{rest}'"))?;
1436        let display = format!("Ctrl-{}", display_char(ch));
1437        return Ok((byte, display));
1438    }
1439
1440    if lower == "space" {
1441        return Ok((b' ', "Space".to_string()));
1442    }
1443
1444    let mut chars = trimmed.chars();
1445    if let (Some(ch), None) = (chars.next(), chars.next()) {
1446        if !ch.is_ascii() {
1447            return Err("detach keys must be ASCII".to_string());
1448        }
1449        let display = display_char(ch);
1450        return Ok((ch as u8, display));
1451    }
1452
1453    Err(format!("detach keys: unsupported token '{trimmed}'"))
1454}
1455
1456fn ctrl_char_to_byte(ch: char) -> Option<u8> {
1457    if ch.is_ascii_lowercase() {
1458        return Some(ch as u8 - b'a' + 1);
1459    }
1460    if ch.is_ascii_uppercase() {
1461        return Some(ch as u8 - b'A' + 1);
1462    }
1463
1464    match ch {
1465        '[' => Some(0x1b),
1466        '\\' => Some(0x1c),
1467        ']' => Some(0x1d),
1468        '^' => Some(0x1e),
1469        '_' => Some(0x1f),
1470        '?' => Some(0x7f),
1471        ' ' | '@' => Some(0x00),
1472        _ => None,
1473    }
1474}
1475
1476fn display_char(ch: char) -> String {
1477    if ch == ' ' {
1478        return "Space".to_string();
1479    }
1480    if ch.is_ascii_alphabetic() {
1481        return ch.to_ascii_uppercase().to_string();
1482    }
1483    ch.to_string()
1484}
1485
1486#[cfg(test)]
1487#[path = "attach_tests.rs"]
1488mod tests;