Skip to content

Commit 60deb67

Browse files
authored
refactor(tui): job-control for Ctrl-Z handling (#6477)
- Moved the unix-only suspend/resume logic into a dedicated job_control module housing SuspendContext, replacing scattered cfg-gated fields and helpers in tui.rs. - Tui now holds a single suspend_context (Arc-backed) instead of multiple atomics, and the event stream uses it directly for Ctrl-Z handling. - Added detailed docs around the suspend/resume flow, cursor tracking, and the Arc/atomic ownership model for the 'static event stream. - Renamed the process-level SIGTSTP helper to suspend_process and the cursor tracker to set_cursor_y to better reflect their roles.
1 parent 0271c20 commit 60deb67

File tree

2 files changed

+212
-142
lines changed

2 files changed

+212
-142
lines changed

codex-rs/tui/src/tui.rs

Lines changed: 30 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,23 @@
1+
use std::fmt;
12
use std::io::IsTerminal;
23
use std::io::Result;
34
use std::io::Stdout;
45
use std::io::stdout;
6+
use std::panic;
57
use std::pin::Pin;
68
use std::sync::Arc;
79
use std::sync::atomic::AtomicBool;
8-
#[cfg(unix)]
9-
use std::sync::atomic::AtomicU8;
10-
#[cfg(unix)]
11-
use std::sync::atomic::AtomicU16;
1210
use std::sync::atomic::Ordering;
1311
use std::time::Duration;
1412
use std::time::Instant;
1513

1614
use crossterm::Command;
1715
use crossterm::SynchronizedUpdate;
18-
#[cfg(unix)]
19-
use crossterm::cursor::MoveTo;
2016
use crossterm::event::DisableBracketedPaste;
2117
use crossterm::event::DisableFocusChange;
2218
use crossterm::event::EnableBracketedPaste;
2319
use crossterm::event::EnableFocusChange;
2420
use crossterm::event::Event;
25-
#[cfg(unix)]
26-
use crossterm::event::KeyCode;
2721
use crossterm::event::KeyEvent;
2822
use crossterm::event::KeyboardEnhancementFlags;
2923
use crossterm::event::PopKeyboardEnhancementFlags;
@@ -38,20 +32,22 @@ use ratatui::crossterm::terminal::disable_raw_mode;
3832
use ratatui::crossterm::terminal::enable_raw_mode;
3933
use ratatui::layout::Offset;
4034
use ratatui::text::Line;
35+
use tokio::select;
36+
use tokio_stream::Stream;
4137

4238
use crate::custom_terminal;
4339
use crate::custom_terminal::Terminal as CustomTerminal;
4440
#[cfg(unix)]
45-
use crate::key_hint;
46-
use tokio::select;
47-
use tokio_stream::Stream;
41+
use crate::tui::job_control::SUSPEND_KEY;
42+
#[cfg(unix)]
43+
use crate::tui::job_control::SuspendContext;
44+
45+
#[cfg(unix)]
46+
mod job_control;
4847

4948
/// A type alias for the terminal type used in this application
5049
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
5150

52-
#[cfg(unix)]
53-
const SUSPEND_KEY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('z'));
54-
5551
pub fn set_modes() -> Result<()> {
5652
execute!(stdout(), EnableBracketedPaste)?;
5753

@@ -79,12 +75,12 @@ pub fn set_modes() -> Result<()> {
7975
struct EnableAlternateScroll;
8076

8177
impl Command for EnableAlternateScroll {
82-
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
78+
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
8379
write!(f, "\x1b[?1007h")
8480
}
8581

8682
#[cfg(windows)]
87-
fn execute_winapi(&self) -> std::io::Result<()> {
83+
fn execute_winapi(&self) -> Result<()> {
8884
Err(std::io::Error::other(
8985
"tried to execute EnableAlternateScroll using WinAPI; use ANSI instead",
9086
))
@@ -100,12 +96,12 @@ impl Command for EnableAlternateScroll {
10096
struct DisableAlternateScroll;
10197

10298
impl Command for DisableAlternateScroll {
103-
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
99+
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
104100
write!(f, "\x1b[?1007l")
105101
}
106102

107103
#[cfg(windows)]
108-
fn execute_winapi(&self) -> std::io::Result<()> {
104+
fn execute_winapi(&self) -> Result<()> {
109105
Err(std::io::Error::other(
110106
"tried to execute DisableAlternateScroll using WinAPI; use ANSI instead",
111107
))
@@ -144,8 +140,8 @@ pub fn init() -> Result<Terminal> {
144140
}
145141

146142
fn set_panic_hook() {
147-
let hook = std::panic::take_hook();
148-
std::panic::set_hook(Box::new(move |panic_info| {
143+
let hook = panic::take_hook();
144+
panic::set_hook(Box::new(move |panic_info| {
149145
let _ = restore(); // ignore any errors as we are already failing
150146
hook(panic_info);
151147
}));
@@ -165,40 +161,14 @@ pub struct Tui {
165161
pending_history_lines: Vec<Line<'static>>,
166162
alt_saved_viewport: Option<ratatui::layout::Rect>,
167163
#[cfg(unix)]
168-
resume_pending: Arc<AtomicU8>, // Stores a ResumeAction
169-
#[cfg(unix)]
170-
suspend_cursor_y: Arc<AtomicU16>, // Bottom line of inline viewport
164+
suspend_context: SuspendContext,
171165
// True when overlay alt-screen UI is active
172166
alt_screen_active: Arc<AtomicBool>,
173167
// True when terminal/tab is focused; updated internally from crossterm events
174168
terminal_focused: Arc<AtomicBool>,
175169
enhanced_keys_supported: bool,
176170
}
177171

178-
#[cfg(unix)]
179-
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
180-
#[repr(u8)]
181-
enum ResumeAction {
182-
None = 0,
183-
RealignInline = 1,
184-
RestoreAlt = 2,
185-
}
186-
187-
#[cfg(unix)]
188-
enum PreparedResumeAction {
189-
RestoreAltScreen,
190-
RealignViewport(ratatui::layout::Rect),
191-
}
192-
193-
#[cfg(unix)]
194-
fn take_resume_action(pending: &AtomicU8) -> ResumeAction {
195-
match pending.swap(ResumeAction::None as u8, Ordering::Relaxed) {
196-
1 => ResumeAction::RealignInline,
197-
2 => ResumeAction::RestoreAlt,
198-
_ => ResumeAction::None,
199-
}
200-
}
201-
202172
#[derive(Clone, Debug)]
203173
pub struct FrameRequester {
204174
frame_schedule_tx: tokio::sync::mpsc::UnboundedSender<Instant>,
@@ -244,9 +214,7 @@ impl Tui {
244214
pending_history_lines: vec![],
245215
alt_saved_viewport: None,
246216
#[cfg(unix)]
247-
resume_pending: Arc::new(AtomicU8::new(0)),
248-
#[cfg(unix)]
249-
suspend_cursor_y: Arc::new(AtomicU16::new(0)),
217+
suspend_context: SuspendContext::new(),
250218
alt_screen_active: Arc::new(AtomicBool::new(false)),
251219
terminal_focused: Arc::new(AtomicBool::new(true)),
252220
enhanced_keys_supported,
@@ -282,37 +250,20 @@ impl Tui {
282250

283251
// State for tracking how we should resume from ^Z suspend.
284252
#[cfg(unix)]
285-
let resume_pending = self.resume_pending.clone();
253+
let suspend_context = self.suspend_context.clone();
286254
#[cfg(unix)]
287255
let alt_screen_active = self.alt_screen_active.clone();
288-
#[cfg(unix)]
289-
let suspend_cursor_y = self.suspend_cursor_y.clone();
290-
291-
#[cfg(unix)]
292-
let suspend = move || {
293-
if alt_screen_active.load(Ordering::Relaxed) {
294-
// Disable alternate scroll when suspending from alt-screen
295-
let _ = execute!(stdout(), DisableAlternateScroll);
296-
let _ = execute!(stdout(), LeaveAlternateScreen);
297-
resume_pending.store(ResumeAction::RestoreAlt as u8, Ordering::Relaxed);
298-
} else {
299-
resume_pending.store(ResumeAction::RealignInline as u8, Ordering::Relaxed);
300-
}
301-
let y = suspend_cursor_y.load(Ordering::Relaxed);
302-
let _ = execute!(stdout(), MoveTo(0, y), crossterm::cursor::Show);
303-
let _ = Tui::suspend();
304-
};
305256

306257
let terminal_focused = self.terminal_focused.clone();
307258
let event_stream = async_stream::stream! {
308259
loop {
309260
select! {
310261
Some(Ok(event)) = crossterm_events.next() => {
311262
match event {
312-
crossterm::event::Event::Key(key_event) => {
263+
Event::Key(key_event) => {
313264
#[cfg(unix)]
314265
if SUSPEND_KEY.is_press(key_event) {
315-
suspend();
266+
let _ = suspend_context.suspend(&alt_screen_active);
316267
// We continue here after resume.
317268
yield TuiEvent::Draw;
318269
continue;
@@ -356,67 +307,6 @@ impl Tui {
356307
Box::pin(event_stream)
357308
}
358309

359-
#[cfg(unix)]
360-
fn suspend() -> Result<()> {
361-
restore()?;
362-
unsafe { libc::kill(0, libc::SIGTSTP) };
363-
set_modes()?;
364-
Ok(())
365-
}
366-
367-
/// When resuming from ^Z suspend, we want to put things back the way they were before suspend.
368-
/// We capture the action in an object so we can pass it into the event stream, since the relevant
369-
#[cfg(unix)]
370-
fn prepare_resume_action(
371-
&mut self,
372-
action: ResumeAction,
373-
) -> Result<Option<PreparedResumeAction>> {
374-
match action {
375-
ResumeAction::RealignInline => {
376-
let cursor_pos = self
377-
.terminal
378-
.get_cursor_position()
379-
.unwrap_or(self.terminal.last_known_cursor_pos);
380-
Ok(Some(PreparedResumeAction::RealignViewport(
381-
ratatui::layout::Rect::new(0, cursor_pos.y, 0, 0),
382-
)))
383-
}
384-
ResumeAction::RestoreAlt => {
385-
if let Ok(ratatui::layout::Position { y, .. }) = self.terminal.get_cursor_position()
386-
&& let Some(saved) = self.alt_saved_viewport.as_mut()
387-
{
388-
saved.y = y;
389-
}
390-
Ok(Some(PreparedResumeAction::RestoreAltScreen))
391-
}
392-
ResumeAction::None => Ok(None),
393-
}
394-
}
395-
396-
#[cfg(unix)]
397-
fn apply_prepared_resume_action(&mut self, prepared: PreparedResumeAction) -> Result<()> {
398-
match prepared {
399-
PreparedResumeAction::RealignViewport(area) => {
400-
self.terminal.set_viewport_area(area);
401-
}
402-
PreparedResumeAction::RestoreAltScreen => {
403-
execute!(self.terminal.backend_mut(), EnterAlternateScreen)?;
404-
// Enable "alternate scroll" so terminals may translate wheel to arrows
405-
execute!(self.terminal.backend_mut(), EnableAlternateScroll)?;
406-
if let Ok(size) = self.terminal.size() {
407-
self.terminal.set_viewport_area(ratatui::layout::Rect::new(
408-
0,
409-
0,
410-
size.width,
411-
size.height,
412-
));
413-
self.terminal.clear()?;
414-
}
415-
}
416-
}
417-
Ok(())
418-
}
419-
420310
/// Enter alternate screen and expand the viewport to full terminal size, saving the current
421311
/// inline viewport for restoration when leaving.
422312
pub fn enter_alt_screen(&mut self) -> Result<()> {
@@ -462,8 +352,9 @@ impl Tui {
462352
// If we are resuming from ^Z, we need to prepare the resume action now so we can apply it
463353
// in the synchronized update.
464354
#[cfg(unix)]
465-
let mut prepared_resume =
466-
self.prepare_resume_action(take_resume_action(&self.resume_pending))?;
355+
let mut prepared_resume = self
356+
.suspend_context
357+
.prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport);
467358

468359
// Precompute any viewport updates that need a cursor-position query before entering
469360
// the synchronized update, to avoid racing with the event reader.
@@ -490,12 +381,10 @@ impl Tui {
490381
}
491382
}
492383

493-
std::io::stdout().sync_update(|_| {
384+
stdout().sync_update(|_| {
494385
#[cfg(unix)]
495-
{
496-
if let Some(prepared) = prepared_resume.take() {
497-
self.apply_prepared_resume_action(prepared)?;
498-
}
386+
if let Some(prepared) = prepared_resume.take() {
387+
prepared.apply(&mut self.terminal)?;
499388
}
500389
let terminal = &mut self.terminal;
501390
if let Some(new_area) = pending_viewport_area.take() {
@@ -539,8 +428,7 @@ impl Tui {
539428
} else {
540429
area.bottom().saturating_sub(1)
541430
};
542-
self.suspend_cursor_y
543-
.store(inline_area_bottom, Ordering::Relaxed);
431+
self.suspend_context.set_cursor_y(inline_area_bottom);
544432
}
545433

546434
terminal.draw(|frame| {
@@ -600,12 +488,12 @@ fn spawn_frame_scheduler(
600488
pub struct PostNotification(pub String);
601489

602490
impl Command for PostNotification {
603-
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
491+
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
604492
write!(f, "\x1b]9;{}\x07", self.0)
605493
}
606494

607495
#[cfg(windows)]
608-
fn execute_winapi(&self) -> std::io::Result<()> {
496+
fn execute_winapi(&self) -> Result<()> {
609497
Err(std::io::Error::other(
610498
"tried to execute PostNotification using WinAPI; use ANSI instead",
611499
))

0 commit comments

Comments
 (0)