1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
//! Terminal lifecycle: enter/leave alternate screen, raw mode, mouse, paste.
//!
//! Extracted from `mod.rs` so `run()` doesn't have to spell out the dance.
use std::io;
use crossterm::{
cursor,
event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use super::termcaps::TermCaps;
type Term = Terminal<CrosstermBackend<io::Stdout>>;
/// Enable raw mode, switch to the alternate screen, enable mouse capture and
/// bracketed paste, then build a ratatui `Terminal`.
///
/// ## P16.3 kitty-keyboard push gate
///
/// `caps` is the negotiated [`TermCaps`] as `Option<&_>`.
/// [`kitty_push_enabled`](super::termcaps::kitty_push_enabled) decides whether
/// to emit `PushKeyboardEnhancementFlags`:
/// * `None` (unknown / not threaded) ⇒ today's blind best-effort push.
/// * `Some` with no DA1 fence (`da1_answered == false`) ⇒ blind push (= today).
/// * `Some`, DA1-fenced ⇒ push only if `kitty_keyboard` was negotiated true.
///
/// NOTE: in production `setup_terminal` runs BEFORE the DA1 burst
/// (`run_setup` enables raw mode here, THEN negotiates on fd 0), so caps are
/// not yet fenced at this call site and it correctly degrades to the blind
/// push — byte-identical with today. The gate is fact-based + testable for the
/// day the push moves after negotiation or a re-push is added.
///
/// [`TermCaps`]: super::termcaps::TermCaps
pub(super) fn setup_terminal(caps: Option<&TermCaps>) -> synaps_cli::Result<Term> {
enable_raw_mode().map_err(|e| {
synaps_cli::error::RuntimeError::Tool(format!("terminal setup failed: {}", e))
})?;
let mut stdout = io::stdout();
execute!(
stdout,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
)
.map_err(|e| synaps_cli::error::RuntimeError::Tool(format!("terminal setup failed: {}", e)))?;
// Best-effort: enable the kitty keyboard protocol so modifier-heavy
// chords (Ctrl+Alt+V, Ctrl+Shift+letter, etc.) report correctly on
// terminals that support it (kitty, wezterm, foot, iterm2, alacritty).
// Terminals that don't support it ignore the escape sequence, so we
// swallow any error.
if super::termcaps::kitty_push_enabled(caps) {
let _ = execute!(
stdout,
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
);
}
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).map_err(|e| {
synaps_cli::error::RuntimeError::Tool(format!("terminal setup failed: {}", e))
})?;
// Synaps renders its own input cursor in the ratatui buffer. Keeping the
// hardware cursor hidden for the whole TUI frame lifecycle prevents it from
// becoming visible at transient backend draw/scrub positions during
// high-frequency streaming redraws.
terminal.hide_cursor().ok();
Ok(terminal)
}
pub(super) fn emergency_teardown_terminal() {
disable_raw_mode().ok();
let mut stdout = io::stdout();
let _ = execute!(stdout, PopKeyboardEnhancementFlags);
execute!(
stdout,
DisableBracketedPaste,
DisableMouseCapture,
LeaveAlternateScreen
)
.ok();
// Restore cursor visibility. setup_terminal() hides the cursor for the
// entire TUI lifecycle; if a main-thread panic triggers this path the
// cursor must be shown again so the user's shell is usable afterward.
// do_teardown() (render_thread.rs) covers the normal exit path; this
// covers every other path (panic hook, signal, render-thread catch_unwind).
execute!(stdout, cursor::Show).ok();
}
/// Emergency exit (the session did not report `Ended` within the teardown
/// budget): terminal restore and `process::exit(1)`. The actor owns the
/// save/hooks/observability flush; the host flushes logs at process exit.
pub(super) fn emergency_exit() -> ! {
agent_engine::host::EngineHost::flush_installed_logs();
emergency_teardown_terminal();
std::process::exit(1);
}
// `teardown_terminal` was removed in the #116 render-thread work — the render
// thread now owns the Terminal and performs its own teardown (see render_thread.rs).
#[cfg(test)]
mod kitty_gate_tests {
//! P16.3 gate-3 (kitty-keyboard push) decision tests.
//!
//! `setup_terminal` itself drives a real terminal (raw mode, alt screen)
//! and cannot run headless, so the gate is proven at the decision-function
//! level — the exact predicate `setup_terminal` branches on.
use super::TermCaps;
use crate::tui::termcaps::kitty_push_enabled;
#[test]
fn unknown_caps_default_to_blind_push() {
// No caps threaded (the production call site passes `None`) ⇒ push,
// byte-identical with today's unconditional best-effort push.
assert!(kitty_push_enabled(None), "unknown caps must push (= today)");
}
#[test]
fn da1_timeout_defaults_to_blind_push() {
// Default caps carry no DA1 fence ⇒ still push (= today).
let caps = TermCaps::default();
assert!(!caps.da1_answered);
assert!(
kitty_push_enabled(Some(&caps)),
"no fence ⇒ blind push (= today)"
);
}
#[test]
fn negotiated_kitty_support_pushes() {
let caps = TermCaps {
da1_answered: true,
kitty_keyboard: true,
..TermCaps::default()
};
assert!(kitty_push_enabled(Some(&caps)));
}
#[test]
fn negotiated_no_kitty_skips_push() {
let caps = TermCaps {
da1_answered: true,
kitty_keyboard: false,
..TermCaps::default()
};
assert!(
!kitty_push_enabled(Some(&caps)),
"kitty negotiated off ⇒ no push"
);
}
}