1#![expect(clippy::print_stderr, reason = "CLI/TUI output after terminal restore")]
2
3use 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#[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 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;