use std::fs::File;
use std::io::{self, IsTerminal, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, Weak};
use std::time::Duration;
use crossterm::cursor::MoveTo;
use crossterm::style::Print;
use crossterm::terminal::{Clear, ClearType};
use crossterm::{execute, queue};
use super::caps::LineCaps;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Sink {
Stdout,
Stderr,
}
impl Sink {
fn with<R>(self, f: impl FnOnce(&mut LineWriter<'_>) -> R) -> R {
match self {
Self::Stdout => {
let stdout = io::stdout();
let mut lock = stdout.lock();
f(&mut LineWriter(&mut lock))
}
Self::Stderr => {
let stderr = io::stderr();
let mut lock = stderr.lock();
f(&mut LineWriter(&mut lock))
}
}
}
}
pub struct LineWriter<'a>(&'a mut (dyn Write + 'a));
impl Write for LineWriter<'_> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.flush()
}
}
pub trait Ephemeral: Send + Sync {
fn erase(&self);
fn restore(&self);
}
#[derive(Default)]
struct Inner {
line_held: bool,
registered: Vec<(u64, Weak<dyn Ephemeral>)>,
regions: Vec<(u64, Region)>,
suspended: bool,
next_id: u64,
prompt_owner: Option<std::thread::ThreadId>,
prompt_depth: usize,
watcher_reading: bool,
quiet_waiters: usize,
}
impl Inner {
fn watcher_can_read(&self) -> bool {
self.prompt_owner.is_none() && !self.watcher_reading && self.quiet_waiters == 0
}
}
fn arbiter() -> &'static (Mutex<Inner>, Condvar) {
static ARBITER: OnceLock<(Mutex<Inner>, Condvar)> = OnceLock::new();
ARBITER.get_or_init(|| (Mutex::new(Inner::default()), Condvar::new()))
}
fn lock() -> MutexGuard<'static, Inner> {
arbiter()
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn live_ephemerals(state: &mut Inner) -> Vec<Arc<dyn Ephemeral>> {
state.registered.retain(|(_, w)| w.strong_count() > 0);
state
.registered
.iter()
.filter_map(|(_, w)| w.upgrade())
.collect()
}
pub(crate) fn suspended() -> bool {
lock().suspended
}
#[must_use = "dropping the registration immediately deregisters the ephemeral"]
pub struct EphemeralRegistration {
id: u64,
}
impl EphemeralRegistration {
pub fn suspended(&self) -> bool {
suspended()
}
}
impl Drop for EphemeralRegistration {
fn drop(&mut self) {
lock().registered.retain(|(id, _)| *id != self.id);
}
}
fn shift_clear_of(held: &[(u64, Region)], want: Region) -> Option<Region> {
let Region::Rows { mut top, height } = want else {
return held.is_empty().then_some(want);
};
for _ in 0..=held.len() {
let candidate = Region::Rows { top, height };
match held.iter().find(|(_, h)| h.intersects(candidate)) {
None => return Some(candidate),
Some((_, Region::WholeScreen)) => return None,
Some((
_,
Region::Rows {
top: holder_top, ..
},
)) => {
top = holder_top.checked_sub(height)?;
}
}
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Placement {
pub top: u16,
pub height: u16,
pub scroll: u16,
pub return_to: (u16, u16),
pub rows: u16,
}
pub fn place_below_cursor(
cursor: (u16, u16),
screen_rows: u16,
height: u16,
held: &[(u64, Region)],
policy: OnCollision,
) -> Option<Placement> {
let rows = screen_rows.max(1);
let (x, y) = (cursor.0, cursor.1.min(rows - 1));
let occupied = u16::from(x > 0);
let available = rows - occupied;
if available == 0 {
return None;
}
let height = height.clamp(1, available);
let start = y + occupied;
let overflow = height.saturating_sub(rows - start);
let natural = Placement {
top: start - overflow,
height,
scroll: overflow,
return_to: (x, y - overflow),
rows,
};
let wanted = Region::Rows {
top: natural.top,
height,
};
let contested =
held.iter().any(|(_, h)| h.intersects(wanted)) || (overflow > 0 && !held.is_empty());
if !contested {
return Some(natural);
}
match policy {
OnCollision::Refuse => None,
OnCollision::SuspendHolder => Some(natural),
OnCollision::Shift => {
let bottom = Region::Rows {
top: rows - height,
height,
};
let Region::Rows { top, height } = shift_clear_of(held, bottom)? else {
return None;
};
Some(Placement {
top,
height,
scroll: 0,
return_to: (x, y),
rows,
})
}
}
}
pub fn blank_rows<W: Write>(w: &mut W, top: u16, height: u16) -> io::Result<()> {
for row in top..top.saturating_add(height) {
queue!(w, MoveTo(0, row), Clear(ClearType::CurrentLine))?;
}
Ok(())
}
struct StdinQuiet {
_watcher: Option<WatcherStdinGuard>,
}
const QUIET_WAIT: Duration = Duration::from_millis(300);
const SILENT_HOLD_OFF: Duration = Duration::from_secs(60);
static DSR_SILENT_UNTIL: AtomicU64 = AtomicU64::new(0);
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
impl StdinQuiet {
fn take_within(wait: Duration) -> Option<Self> {
let (m, cv) = arbiter();
let mut state = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let me = std::thread::current().id();
let deadline = std::time::Instant::now() + wait;
state.quiet_waiters += 1;
let acquired = loop {
if state.prompt_owner == Some(me) {
break Some(Self { _watcher: None });
}
if state.prompt_owner.is_none() && !state.watcher_reading {
state.watcher_reading = true;
break Some(Self {
_watcher: Some(WatcherStdinGuard),
});
}
let Some(remaining) = deadline.checked_duration_since(std::time::Instant::now()) else {
break None;
};
state = cv
.wait_timeout(state, remaining)
.unwrap_or_else(std::sync::PoisonError::into_inner)
.0;
};
state.quiet_waiters -= 1;
acquired
}
}
fn cursor_position_quiet() -> Option<(u16, u16)> {
if now_secs() < DSR_SILENT_UNTIL.load(Ordering::Relaxed) {
return None;
}
let _quiet = StdinQuiet::take_within(QUIET_WAIT)?;
match crossterm::cursor::position() {
Ok(cursor) => Some(cursor),
Err(_) => {
DSR_SILENT_UNTIL.store(
now_secs().saturating_add(SILENT_HOLD_OFF.as_secs()),
Ordering::Relaxed,
);
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Region {
Rows { top: u16, height: u16 },
WholeScreen,
}
impl Region {
#[must_use]
pub fn intersects(self, other: Self) -> bool {
match (self, other) {
(Self::WholeScreen, _) | (_, Self::WholeScreen) => true,
(Self::Rows { top: a, height: ah }, Self::Rows { top: b, height: bh }) => {
ah != 0 && bh != 0 && a < b.saturating_add(bh) && b < a.saturating_add(ah)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnCollision {
SuspendHolder,
Shift,
Refuse,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum TerminalTaker {
CockpitModal,
PermissionAuthorization,
PermissionQuestion,
RichSurfaceModal,
LeanSurfaceModal,
SlashForm,
SetupWizard,
CodexEnvAdoption,
PlainCliConfirm,
}
impl TerminalTaker {
pub const ALL: &'static [Self] = &[
Self::CockpitModal,
Self::PermissionAuthorization,
Self::PermissionQuestion,
Self::RichSurfaceModal,
Self::LeanSurfaceModal,
Self::SlashForm,
Self::SetupWizard,
Self::CodexEnvAdoption,
Self::PlainCliConfirm,
];
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::CockpitModal => "CockpitModal",
Self::PermissionAuthorization => "PermissionAuthorization",
Self::PermissionQuestion => "PermissionQuestion",
Self::RichSurfaceModal => "RichSurfaceModal",
Self::LeanSurfaceModal => "LeanSurfaceModal",
Self::SlashForm => "SlashForm",
Self::SetupWizard => "SetupWizard",
Self::CodexEnvAdoption => "CodexEnvAdoption",
Self::PlainCliConfirm => "PlainCliConfirm",
}
}
#[must_use]
pub const fn on_held_rows(self) -> OnCollision {
match self {
Self::CockpitModal | Self::PermissionAuthorization => OnCollision::SuspendHolder,
_ => OnCollision::Refuse,
}
}
}
pub struct RegionLease {
id: u64,
region: Region,
cursor: Option<CursorReturn>,
}
#[derive(Debug, Clone, Copy)]
struct CursorReturn {
x: u16,
y: u16,
sink: Sink,
}
impl RegionLease {
#[must_use]
pub fn region(&self) -> Region {
self.region
}
#[must_use]
pub fn cursor_return(&self) -> Option<(u16, u16)> {
self.cursor.map(|back| (back.x, back.y))
}
pub fn relocate(&mut self, to: Region, policy: OnCollision) -> bool {
let mut state = lock();
let contested = state
.regions
.iter()
.any(|(id, held)| *id != self.id && held.intersects(to));
match policy {
OnCollision::Refuse if contested => return false,
OnCollision::Shift => return false,
_ => {}
}
if let Some(entry) = state.regions.iter_mut().find(|(id, _)| *id == self.id) {
entry.1 = to;
}
self.region = to;
true
}
}
impl Drop for RegionLease {
fn drop(&mut self) {
if let (Some(back), Region::Rows { top, height }) = (self.cursor, self.region) {
back.sink.with(|w| {
let _ =
blank_rows(w, top, height).and_then(|()| execute!(w, MoveTo(back.x, back.y)));
});
}
let (m, cv) = arbiter();
let mut state = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
state.regions.retain(|(id, _)| *id != self.id);
cv.notify_all();
}
}
pub struct LineLease {
id: u64,
sink: Sink,
painted: AtomicBool,
}
impl LineLease {
pub fn sink(&self) -> Sink {
self.sink
}
pub fn erase(&self) {
if !self.painted.swap(false, Ordering::SeqCst) {
return;
}
self.sink.with(|w| {
let _ = execute!(w, Print("\r"), Clear(ClearType::UntilNewLine));
let _ = w.flush();
});
}
pub fn paint(&self, f: impl FnOnce(&mut LineWriter<'_>) -> io::Result<()>) {
if suspended() {
return;
}
self.sink.with(|w| {
let _ = queue!(w, Print("\r"), Clear(ClearType::UntilNewLine));
let _ = f(w);
let _ = w.flush();
});
self.painted.store(true, Ordering::SeqCst);
}
pub fn emit_line(&self, f: impl FnOnce(&mut LineWriter<'_>) -> io::Result<()>) {
self.erase();
self.sink.with(|w| {
let _ = f(w);
let _ = w.flush();
});
}
}
impl Drop for LineLease {
fn drop(&mut self) {
self.erase();
let (m, cv) = arbiter();
let mut state = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
state.line_held = false;
state.registered.retain(|(id, _)| *id != self.id);
cv.notify_all();
}
}
static SUSPENSIONS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[must_use]
pub(crate) fn prompts_permitted(protocol: bool) -> bool {
!protocol
}
pub fn prompt_windows_constructed() -> u64 {
SUSPENSIONS.load(Ordering::SeqCst)
}
pub struct Terminal;
const LEASE_WAIT: Duration = Duration::from_millis(50);
impl Terminal {
pub fn lease(sink: Sink) -> Option<LineLease> {
Self::lease_with_caps(super::caps::detect(), sink)
}
pub fn lease_with_caps(caps: LineCaps, sink: Sink) -> Option<LineLease> {
if super::caps::protocol_mode() || !caps.can_own() {
return None;
}
let (m, cv) = arbiter();
let mut state = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if state.line_held {
let (guard, timeout) = cv
.wait_timeout_while(state, LEASE_WAIT, |s| s.line_held)
.unwrap_or_else(std::sync::PoisonError::into_inner);
if timeout.timed_out() {
return None;
}
state = guard;
}
state.line_held = true;
state.next_id += 1;
let id = state.next_id;
Some(LineLease {
id,
sink,
painted: AtomicBool::new(false),
})
}
pub fn emit_line(sink: Sink, f: impl FnOnce(&mut LineWriter<'_>) -> io::Result<()>) {
let live: Vec<Arc<dyn Ephemeral>> = live_ephemerals(&mut lock());
for e in &live {
e.erase();
}
sink.with(|w| {
let _ = f(w);
let _ = w.flush();
});
}
pub fn lease_region(region: Region, policy: OnCollision) -> Option<RegionLease> {
let mut state = lock();
let granted = match policy {
OnCollision::SuspendHolder => region,
OnCollision::Refuse => {
if state.regions.iter().any(|(_, h)| h.intersects(region)) {
return None;
}
region
}
OnCollision::Shift => shift_clear_of(&state.regions, region)?,
};
state.next_id += 1;
let id = state.next_id;
state.regions.push((id, granted));
Some(RegionLease {
id,
region: granted,
cursor: None,
})
}
pub fn lease_below_cursor(
height: u16,
policy: OnCollision,
sink: Sink,
) -> io::Result<Option<RegionLease>> {
let Some((mut lease, place)) = Self::lease_below_cursor_quiet(height, policy)? else {
return Ok(None);
};
let (x, y) = place.return_to;
lease.cursor = Some(CursorReturn { x, y, sink });
sink.with(|w| {
if place.scroll > 0 {
queue!(w, MoveTo(0, place.rows.saturating_sub(1)))?;
for _ in 0..place.scroll {
queue!(w, Print("\n"))?;
}
}
blank_rows(w, place.top, place.height)?;
queue!(w, MoveTo(0, place.top))?;
w.flush()
})?;
Ok(Some(lease))
}
pub fn lease_below_cursor_quiet(
height: u16,
policy: OnCollision,
) -> io::Result<Option<(RegionLease, Placement)>> {
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return Ok(None);
}
let Some(cursor) = cursor_position_quiet() else {
return Ok(None);
};
let (_, rows) = crossterm::terminal::size()?;
let mut state = lock();
let Some(place) = place_below_cursor(cursor, rows, height, &state.regions, policy) else {
return Ok(None);
};
state.next_id += 1;
let id = state.next_id;
let region = Region::Rows {
top: place.top,
height: place.height,
};
state.regions.push((id, region));
Ok(Some((
RegionLease {
id,
region,
cursor: None,
},
place,
)))
}
pub(crate) fn register(id: u64, e: &Arc<dyn Ephemeral>) {
lock().registered.push((id, Arc::downgrade(e)));
}
pub fn register_ephemeral(e: &Arc<dyn Ephemeral>) -> EphemeralRegistration {
let mut state = lock();
state.next_id += 1;
let id = state.next_id;
state.registered.push((id, Arc::downgrade(e)));
EphemeralRegistration { id }
}
pub fn suspend_for_prompt(taker: TerminalTaker) -> PromptWindow {
Self::suspend_for_prompt_with_output(PromptOutput::Stdout, taker)
}
pub fn suspend_for_prompt_to(output: File, taker: TerminalTaker) -> PromptWindow {
Self::suspend_for_prompt_with_output(PromptOutput::File(output), taker)
}
fn suspend_for_prompt_with_output(output: PromptOutput, taker: TerminalTaker) -> PromptWindow {
SUSPENSIONS.fetch_add(1, Ordering::SeqCst);
if !prompts_permitted(super::caps::protocol_mode()) {
return PromptWindow::vetoed(output);
}
if taker.on_held_rows() == OnCollision::Refuse {
if let Some(held) = lock().regions.first().map(|(_, region)| *region) {
return PromptWindow::refused_rows(output, taker, held);
}
}
let stdin = StdinToken::acquire();
let live: Vec<Arc<dyn Ephemeral>> = {
let mut state = lock();
state.suspended = true;
live_ephemerals(&mut state)
};
for e in &live {
e.erase();
}
notify_prompt_observer(true);
PromptWindow {
_seal: Seal,
stdin: Some(stdin),
resume: live,
output,
live: true,
refusal: None,
}
}
}
struct StdinToken {
#[cfg(unix)]
restore: Option<libc::termios>,
}
#[cfg(test)]
pub(super) static STDIN_TOKEN_WAITERS: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
impl StdinToken {
fn acquire() -> Self {
let thread = std::thread::current().id();
let (m, cv) = arbiter();
let mut state = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
while state.watcher_reading
|| state
.prompt_owner
.as_ref()
.is_some_and(|owner| *owner != thread)
{
#[cfg(test)]
STDIN_TOKEN_WAITERS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
state = cv
.wait(state)
.unwrap_or_else(std::sync::PoisonError::into_inner);
#[cfg(test)]
STDIN_TOKEN_WAITERS.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
}
state.prompt_owner = Some(thread);
state.prompt_depth += 1;
drop(state);
Self {
#[cfg(unix)]
restore: enter_prompt_line_mode().ok(),
}
}
}
impl Drop for StdinToken {
fn drop(&mut self) {
#[cfg(unix)]
if let Some(prev) = self.restore.take() {
unsafe {
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &prev);
}
}
let (m, cv) = arbiter();
let mut state = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
state.prompt_depth = state.prompt_depth.saturating_sub(1);
if state.prompt_depth == 0 {
state.prompt_owner = None;
cv.notify_all();
}
}
}
#[cfg(unix)]
fn enter_prompt_line_mode() -> io::Result<libc::termios> {
unsafe {
let fd = libc::STDIN_FILENO;
let mut prev: libc::termios = std::mem::zeroed();
if libc::tcgetattr(fd, &mut prev) != 0 {
return Err(io::Error::last_os_error());
}
let mut line = prev;
line.c_lflag |= libc::ICANON | libc::ECHO;
line.c_cc[libc::VMIN] = 1;
line.c_cc[libc::VTIME] = 0;
if libc::tcsetattr(fd, libc::TCSANOW, &line) != 0 {
return Err(io::Error::last_os_error());
}
Ok(prev)
}
}
pub struct WatcherStdinGuard;
pub fn try_watch_stdin() -> Option<WatcherStdinGuard> {
let mut state = lock();
if !state.watcher_can_read() {
return None;
}
state.watcher_reading = true;
Some(WatcherStdinGuard)
}
impl Drop for WatcherStdinGuard {
fn drop(&mut self) {
let (m, cv) = arbiter();
let mut state = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
state.watcher_reading = false;
cv.notify_all();
}
}
pub fn prompt_stdin_active() -> bool {
lock().prompt_owner.is_some()
}
struct Seal;
enum PromptOutput {
Stdout,
File(File),
}
impl PromptOutput {
fn write_text(&self, text: &str, newline: bool) -> io::Result<()> {
fn write_to(mut output: impl Write, text: &str, newline: bool) -> io::Result<()> {
if newline {
writeln!(output, "{text}")?;
} else {
write!(output, "{text}")?;
}
output.flush()
}
match self {
Self::Stdout => write_to(io::stdout(), text, newline),
Self::File(file) => write_to(file, text, newline),
}
}
fn is_terminal(&self) -> bool {
match self {
Self::Stdout => io::stdout().is_terminal(),
Self::File(file) => file.is_terminal(),
}
}
}
pub struct PromptWindow {
_seal: Seal,
stdin: Option<StdinToken>,
resume: Vec<Arc<dyn Ephemeral>>,
output: PromptOutput,
live: bool,
refusal: Option<Refusal>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum Refusal {
Protocol,
RowsHeld(TerminalTaker, Region),
}
impl Refusal {
fn error(self, action: &str) -> io::Error {
let because = match self {
Self::Protocol => "fd 1 is a machine protocol channel and there is \
no operator to answer"
.to_string(),
Self::RowsHeld(taker, held) => format!(
"another surface holds {held:?} and `{}` declared it would not \
take rows it does not own — ask through that surface's own \
seam instead",
taker.name()
),
};
io::Error::new(
io::ErrorKind::NotConnected,
format!("refusing to {action}: {because}"),
)
}
}
impl PromptWindow {
fn vetoed(output: PromptOutput) -> Self {
Self::inert(output, Refusal::Protocol)
}
fn refused_rows(output: PromptOutput, taker: TerminalTaker, held: Region) -> Self {
Self::inert(output, Refusal::RowsHeld(taker, held))
}
fn inert(output: PromptOutput, refusal: Refusal) -> Self {
Self {
_seal: Seal,
stdin: None,
resume: Vec::new(),
output,
live: false,
refusal: Some(refusal),
}
}
pub fn ask(&self, text: &str) -> io::Result<()> {
self.ensure_can_ask()?;
self.output.write_text(text, false)
}
pub(super) fn ensure_can_ask(&self) -> io::Result<()> {
if let Some(refusal) = self.refusal {
return Err(refusal.error("ask"));
}
Ok(())
}
pub fn read_line(&self) -> io::Result<String> {
let mut buf = String::new();
self.read_line_into(&mut buf)?;
Ok(buf)
}
pub fn read_line_into(&self, buf: &mut String) -> io::Result<usize> {
if let Some(refusal) = self.refusal {
return Err(refusal.error("read an answer"));
}
io::stdin().read_line(buf)
}
pub fn notice(&self, text: &str) -> io::Result<()> {
if self.refusal.is_some() {
return Ok(());
}
self.output.write_text(text, true)
}
pub(crate) fn output_is_terminal(&self) -> bool {
self.output.is_terminal()
}
#[cfg(any(test, feature = "test-util"))]
pub fn test_stub() -> Self {
Self {
_seal: Seal,
stdin: None,
resume: Vec::new(),
output: PromptOutput::Stdout,
live: false,
refusal: None,
}
}
}
impl Drop for PromptWindow {
fn drop(&mut self) {
if !self.live {
return;
}
lock().suspended = false;
for e in &self.resume {
e.restore();
}
self.stdin = None;
notify_prompt_observer(false);
}
}
fn notify_prompt_observer(open: bool) {
crate::lifecycle::emit(if open {
crate::lifecycle::LifecycleEvent::Blocked
} else {
crate::lifecycle::LifecycleEvent::Unblocked
});
}
#[cfg(test)]
#[path = "arbiter_tests.rs"]
mod tests;