use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::style::ResetColor;
use std::io::{self, IsTerminal, Write};
use std::marker::PhantomData;
use std::time::Duration;
use super::{PromptWindow, ACTIVE_INPUT_CT};
pub const MODAL_CONTROL_HINT: &str = "Esc=back · Ctrl-C/Ctrl-D=exit";
pub const MODAL_INPUT_GLYPH: &str = "❯ ";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromptLine {
Line(String),
Back,
Exit,
Eof,
}
use super::raw_mode::RawModeGuard as RawGuard;
fn classify_headless_prompt_line(line: &str) -> PromptLine {
let line = line.trim_end_matches(['\r', '\n']);
if line == "\u{1b}" {
PromptLine::Back
} else {
PromptLine::Line(line.into())
}
}
pub fn read_prompt_window_line(
window: &PromptWindow,
prompt: &str,
echo: Echo,
) -> io::Result<PromptLine> {
if !io::stdin().is_terminal() || !window.output_is_terminal() {
window.ask(prompt)?;
let mut line = String::new();
if window.read_line_into(&mut line)? == 0 {
return Ok(PromptLine::Eof);
}
return Ok(classify_headless_prompt_line(&line));
}
window.ensure_can_ask()?;
let result = {
let mut notices = Vec::new();
let accent = modal_accent_enabled(&mut |notice| notices.push(notice));
for notice in notices {
if std::io::stderr().is_terminal() {
window.notice(¬ice.line())?;
} else {
notice.diagnostic(crate::tty::LineCaps::None, false, std::io::stderr())?;
}
}
let _guard = take_modal_ownership(window)?;
window.ask(&render(prompt, "", true, accent)?)?;
let mut value = String::new();
loop {
let key = match event::read()? {
Event::Key(key) if key.kind != KeyEventKind::Release => key,
Event::Paste(text) => {
value.extend(text.chars().filter(|ch| !ch.is_control()));
window.ask(&render(prompt, &echo.display(&value), false, accent)?)?;
continue;
}
_ => continue,
};
if let Some(line) = prompt_control(&key) {
break line;
}
match key.code {
KeyCode::Enter => break PromptLine::Line(value),
KeyCode::Backspace => {
value.pop();
window.ask(&render(prompt, &echo.display(&value), false, accent)?)?;
}
KeyCode::Char(ch)
if !key.modifiers.contains(KeyModifiers::ALT)
&& !key.modifiers.contains(KeyModifiers::CONTROL) =>
{
value.push(ch);
window.ask(&render(prompt, &echo.display(&value), false, accent)?)?;
}
_ => {}
}
}
};
window.notice("")?;
Ok(result)
}
pub trait ControlReader {
fn poll(&mut self, timeout: Duration) -> io::Result<Option<PromptLine>>;
}
pub struct PromptControlReader<'w> {
_raw: RawGuard,
_window: PhantomData<&'w PromptWindow>,
}
pub fn modal_prompt_controls<'w>(window: &'w PromptWindow) -> io::Result<PromptControlReader<'w>> {
Ok(PromptControlReader {
_raw: take_modal_ownership(window)?,
_window: PhantomData,
})
}
impl ControlReader for PromptControlReader<'_> {
fn poll(&mut self, timeout: Duration) -> io::Result<Option<PromptLine>> {
if !event::poll(timeout)? {
return Ok(None);
}
match event::read()? {
Event::Key(key) if key.kind != KeyEventKind::Release => Ok(prompt_control(&key)),
_ => Ok(None),
}
}
}
fn prompt_control(key: &KeyEvent) -> Option<PromptLine> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char(ch) if ctrl && matches!(ch, 'c' | 'd') => Some(PromptLine::Exit),
KeyCode::Esc => Some(PromptLine::Back),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Echo {
Chars,
Stars,
}
impl Echo {
#[must_use]
pub fn display(self, value: &str) -> String {
match self {
Self::Chars => value.to_string(),
Self::Stars => "*".repeat(value.chars().count()),
}
}
}
fn render(prompt: &str, value: &str, first: bool, accent: bool) -> io::Result<String> {
let mut out = Vec::new();
crossterm::queue!(out, ResetColor)?;
if first {
match prompt.rsplit_once('\n') {
Some((body, answer_row)) => {
write!(out, "{}\r\n", body.replace('\n', "\r\n"))?;
write_answer_row(&mut out, answer_row, accent)?;
}
None => write_answer_row(&mut out, prompt, accent)?,
}
} else {
crossterm::queue!(
out,
crossterm::cursor::MoveToColumn(0),
crossterm::terminal::Clear(crossterm::terminal::ClearType::CurrentLine)
)?;
write_answer_row(
&mut out,
prompt.rsplit_once('\n').map_or(prompt, |x| x.1),
accent,
)?;
}
write!(out, "{value}")?;
String::from_utf8(out).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}
fn write_answer_row(out: &mut Vec<u8>, row: &str, accent: bool) -> io::Result<()> {
if let Some(crossterm::style::Color::Rgb { r, g, b }) = answer_row_accent(row, accent) {
write!(out, "\x1b[38;2;{r};{g};{b}m{row}\x1b[0m")?;
} else {
write!(out, "{row}")?;
}
Ok(())
}
fn answer_row_accent(row: &str, accent: bool) -> Option<crossterm::style::Color> {
(accent && row == MODAL_INPUT_GLYPH).then_some(ACTIVE_INPUT_CT)
}
fn modal_accent_enabled(report: &mut dyn FnMut(crate::tty::Notice<'static>)) -> bool {
let cfg_color = crate::Config::resolve_runtime_unpublished(report)
.ok()
.and_then(|config| config.tui.as_ref().map(|tui| tui.color))
.unwrap_or_default();
modal_accent_enabled_with(&|key| std::env::var(key).ok(), cfg_color)
}
fn modal_accent_enabled_with(
get_env: &dyn Fn(&str) -> Option<String>,
cfg_color: crate::ColorMode,
) -> bool {
if let Some(mode) =
get_env("NEWT_COLOR").and_then(|value| crate::ColorMode::from_keyword(&value))
{
return mode.forced().unwrap_or(true);
}
if get_env("NO_COLOR").is_some() || get_env("TERM").as_deref() == Some("dumb") {
return false;
}
cfg_color.forced().unwrap_or(true)
}
#[cfg(test)]
mod visual_style {
use super::{
answer_row_accent, modal_accent_enabled_with, render, ACTIVE_INPUT_CT, MODAL_INPUT_GLYPH,
};
#[test]
fn the_modal_owns_its_body_and_active_answer_colors() {
let prompt = format!("Prompt — Write this file? [y/N]\n{MODAL_INPUT_GLYPH}");
let rendered = render(&prompt, "y", true, true).expect("render");
assert_eq!(
rendered,
"\x1b[0mPrompt — Write this file? [y/N]\r\n\
\x1b[38;2;255;165;90m❯ \x1b[0my"
);
assert_eq!(
answer_row_accent(MODAL_INPUT_GLYPH, true),
Some(ACTIVE_INPUT_CT)
);
assert_eq!(answer_row_accent(MODAL_INPUT_GLYPH, false), None);
}
#[test]
fn answer_row_redraw_reasserts_the_active_accent_and_resets_before_value() {
let prompt = format!("Prompt — Write this file? [y/N]\n{MODAL_INPUT_GLYPH}");
let rendered = render(&prompt, "yes", false, true).expect("render");
assert!(
rendered.ends_with("\x1b[38;2;255;165;90m❯ \x1b[0myes"),
"answer row did not own its style: {rendered:?}"
);
assert!(
rendered.starts_with("\x1b[0m"),
"did not reset inherited style"
);
assert!(!rendered.contains("Write this file"), "redrew the body too");
}
#[test]
fn long_answer_and_backspace_repaint_one_stable_no_wrap_row() {
let body = crate::tty::wrap_line(
"Prompt — choose a destination whose label needs several rows",
10,
)
.join("\n");
let prompt = format!("{body}\n{MODAL_INPUT_GLYPH}");
let long_value = "abcdefghijklmnopqrstuvwxyz";
let after_backspace = &long_value[..long_value.len() - 1];
let long = render(&prompt, long_value, false, true).expect("long redraw");
let shorter = render(&prompt, after_backspace, false, true).expect("backspace redraw");
let mut anchor = Vec::new();
crossterm::queue!(
anchor,
crossterm::style::ResetColor,
crossterm::cursor::MoveToColumn(0),
crossterm::terminal::Clear(crossterm::terminal::ClearType::CurrentLine)
)
.expect("answer-row anchor");
let anchor = String::from_utf8(anchor).expect("anchor is UTF-8");
for (redraw, value) in [(&long, long_value), (&shorter, after_backspace)] {
assert!(
redraw.starts_with(&anchor),
"each edit must return to and clear the same answer row: {redraw:?}"
);
assert!(redraw.ends_with(value), "typed value was not repainted");
assert!(
!redraw.contains("\r\n"),
"answer redraw must never advance into another row: {redraw:?}"
);
assert!(
!redraw.contains("\x1b[?7h"),
"answer redraw must not enable terminal autowrap: {redraw:?}"
);
}
}
#[test]
fn modal_accent_obeys_the_runtime_color_precedence() {
let env = |pairs: &[(&str, &str)]| {
let pairs = pairs
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect::<Vec<_>>();
move |key: &str| {
pairs
.iter()
.find(|(candidate, _)| candidate == key)
.map(|(_, value)| value.clone())
}
};
assert!(modal_accent_enabled_with(&env(&[]), crate::ColorMode::Auto));
assert!(!modal_accent_enabled_with(
&env(&[("NO_COLOR", "1")]),
crate::ColorMode::Always,
));
assert!(!modal_accent_enabled_with(
&env(&[("TERM", "dumb")]),
crate::ColorMode::Always,
));
assert!(modal_accent_enabled_with(
&env(&[
("NEWT_COLOR", "always"),
("NO_COLOR", "1"),
("TERM", "dumb"),
]),
crate::ColorMode::Never
));
assert!(!modal_accent_enabled_with(
&env(&[("NEWT_COLOR", "mono"), ("TERM", "xterm-256color"),]),
crate::ColorMode::Always
));
assert!(!modal_accent_enabled_with(
&env(&[]),
crate::ColorMode::Never
));
assert!(!modal_accent_enabled_with(
&env(&[]),
crate::ColorMode::Mono
));
assert!(modal_accent_enabled_with(&env(&[]), crate::ColorMode::Dark));
}
#[test]
fn modal_color_lookup_uses_the_non_publishing_config_path() {
let production = include_str!("modal.rs")
.split("#[cfg(test)]")
.next()
.expect("production source");
let body = production
.split("fn modal_accent_enabled(")
.nth(1)
.and_then(|tail| tail.split("\n}\n").next())
.expect("modal_accent_enabled body");
assert!(
body.contains("Config::resolve_runtime_unpublished(report)"),
"opening a visual prompt must not publish process-global runtime settings: {body}"
);
assert!(
!body.contains("Config::resolve("),
"the publishing compatibility resolver returned to the visual path: {body}"
);
}
}
fn take_modal_ownership(window: &PromptWindow) -> io::Result<RawGuard> {
if !io::stdin().is_terminal() || !window.output_is_terminal() {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"terminal required",
));
}
RawGuard::enter()
}
#[cfg(all(test, unix))]
mod raw_guard_tests {
use super::RawGuard;
struct StdinPty {
saved: libc::c_int,
master: libc::c_int,
}
impl StdinPty {
fn install() -> Self {
unsafe {
let (mut master, mut slave) = (-1, -1);
assert_eq!(
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null_mut::<libc::termios>(),
std::ptr::null_mut::<libc::winsize>()
),
0
);
let saved = libc::dup(0);
assert!(libc::dup2(slave, 0) >= 0);
libc::close(slave);
Self { saved, master }
}
}
fn lflag() -> libc::tcflag_t {
unsafe {
let mut t: libc::termios = std::mem::zeroed();
assert_eq!(libc::tcgetattr(0, &mut t), 0);
t.c_lflag
}
}
fn set_canonical_echo(on: bool) {
unsafe {
let mut t: libc::termios = std::mem::zeroed();
assert_eq!(libc::tcgetattr(0, &mut t), 0);
if on {
t.c_lflag |= libc::ICANON | libc::ECHO;
} else {
t.c_lflag &= !(libc::ICANON | libc::ECHO);
}
assert_eq!(libc::tcsetattr(0, libc::TCSANOW, &t), 0);
}
}
}
impl Drop for StdinPty {
fn drop(&mut self) {
unsafe {
libc::dup2(self.saved, 0);
libc::close(self.saved);
libc::close(self.master);
}
}
}
#[serial_test::serial(tty_arbiter)]
#[test]
fn the_guard_takes_raw_mode_regardless_of_crossterms_global_state() {
let _stdin = StdinPty::install();
StdinPty::set_canonical_echo(true);
let _ = crossterm::terminal::enable_raw_mode();
StdinPty::set_canonical_echo(true);
assert!(
StdinPty::lflag() & libc::ICANON != 0,
"precondition: canonical"
);
{
let _guard = RawGuard::enter().expect("raw");
let l = StdinPty::lflag();
assert_eq!(
l & libc::ICANON,
0,
"the modal's read must be non-canonical"
);
assert_eq!(
l & libc::ECHO,
0,
"the kernel must not echo over the editor"
);
}
let l = StdinPty::lflag();
assert!(
l & libc::ICANON != 0 && l & libc::ECHO != 0,
"prior mode restored on drop"
);
let _ = crossterm::terminal::disable_raw_mode();
}
}
#[cfg(test)]
mod headless_line_tests {
use super::*;
#[test]
fn a_literal_esc_line_is_the_piped_back_control() {
assert_eq!(classify_headless_prompt_line("\u{1b}\n"), PromptLine::Back);
assert_eq!(classify_headless_prompt_line("\u{1b}"), PromptLine::Back);
}
#[test]
fn an_answer_keeps_its_content_and_loses_only_the_newline_family() {
assert_eq!(
classify_headless_prompt_line("qwen2.5-coder:7b\r\n"),
PromptLine::Line("qwen2.5-coder:7b".into())
);
assert_eq!(
classify_headless_prompt_line(" padded \n"),
PromptLine::Line(" padded ".into())
);
}
#[test]
fn an_explicitly_empty_submission_is_an_empty_answer_not_a_control() {
assert_eq!(
classify_headless_prompt_line("\n"),
PromptLine::Line(String::new())
);
}
#[test]
fn esc_with_trailing_content_is_an_answer_not_back() {
assert_eq!(
classify_headless_prompt_line("\u{1b}[A\n"),
PromptLine::Line("\u{1b}[A".into())
);
}
}
#[cfg(test)]
mod c0b_wiring {
fn production_source() -> String {
include_str!("modal.rs")
.split("#[cfg(test)]")
.next()
.expect("the production half")
.to_string()
+ include_str!("modal.rs")
.split("#[cfg(all(test, unix))]")
.next()
.expect("the production half")
}
fn function_body(source: &str, name: &str) -> Option<String> {
let anchor = format!("fn {name}(");
let start = source.find(&anchor)?;
let mut depth = 0i32;
let mut opened = false;
let mut body = String::new();
for ch in source[start..].chars() {
body.push(ch);
match ch {
'{' => {
depth += 1;
opened = true;
}
'}' => depth -= 1,
_ => {}
}
if opened && depth <= 0 {
break;
}
}
opened.then_some(body)
}
#[test]
fn the_non_tty_branch_still_calls_the_frozen_classifier() {
let source = production_source();
let body = function_body(&source, "read_prompt_window_line")
.expect("`fn read_prompt_window_line` not found at all");
assert!(
body.contains("is_terminal()"),
"the non-TTY branch is gone from the reader: {body}"
);
assert!(
body.contains("classify_headless_prompt_line(&line)"),
"the piped branch no longer routes through the A0-frozen \
classifier — the convention `newt solve`-when-piped and the eval \
harness rely on is silently not applying: {body}"
);
assert!(
body.contains("PromptLine::Eof"),
"the Ok(0) => Eof arm left the reader: {body}"
);
}
#[test]
fn the_wiring_guard_notices_a_branch_that_stopped_classifying() {
let rewired = "fn read_prompt_window_line(w: &W, p: &str) -> R {\n\
if !io::stdin().is_terminal() {\n\
return Ok(PromptLine::Line(line));\n\
}\n\
}";
let body = function_body(rewired, "read_prompt_window_line").expect("body");
assert!(
!body.contains("classify_headless_prompt_line(&line)"),
"the twin's fixture already classifies, so it proves nothing"
);
let real = production_source();
assert!(function_body(&real, "read_prompt_window_line")
.expect("body")
.contains("classify_headless_prompt_line(&line)"));
}
}
#[cfg(test)]
mod echo_policy {
use super::Echo;
#[test]
fn stars_mask_by_character_and_never_reveal_the_value() {
assert_eq!(Echo::Stars.display("sk-live-abc"), "***********");
assert_eq!(Echo::Stars.display(""), "");
assert_eq!(Echo::Stars.display("é☃x"), "***");
for secret in ["sk-live-abc", "é☃x", "hunter2"] {
let shown = Echo::Stars.display(secret);
assert!(
!shown.contains(secret),
"the value leaked into the masked display: {shown:?}"
);
}
}
#[test]
fn chars_show_the_value_verbatim() {
assert_eq!(Echo::Chars.display("sk-live-abc"), "sk-live-abc");
assert_eq!(Echo::Chars.display("é☃x"), "é☃x");
assert_eq!(Echo::Chars.display(""), "");
assert_ne!(
Echo::Chars.display("hunter2"),
Echo::Stars.display("hunter2"),
"the two policies must actually differ"
);
}
}