pub(crate) fn verbose_mode() -> bool {
std::env::var("NEWT_CHAT_STYLE")
.map(|v| v.eq_ignore_ascii_case("verbose"))
.unwrap_or(false)
}
pub(crate) fn debug_mode(cfg: &newt_core::Config) -> bool {
trace_mode(cfg)
|| std::env::var("NEWT_DEBUG").is_ok()
|| cfg.tui.as_ref().and_then(|t| t.debug).unwrap_or(false)
}
pub(crate) fn trace_mode(cfg: &newt_core::Config) -> bool {
std::env::var("NEWT_TRACE").is_ok() || cfg.tui.as_ref().and_then(|t| t.trace).unwrap_or(false)
}
pub(crate) fn resolve_edit_mode() -> newt_core::EditMode {
std::env::var("NEWT_EDIT_MODE")
.ok()
.and_then(|v| match v.to_lowercase().as_str() {
"vi" | "vim" => Some(newt_core::EditMode::Vi),
"emacs" => Some(newt_core::EditMode::Emacs),
"nano" => Some(newt_core::EditMode::Nano),
_ => None,
})
.or_else(|| {
newt_core::Config::resolve()
.ok()
.and_then(|c| c.tui)
.map(|t| t.edit_mode)
})
.unwrap_or(newt_core::EditMode::Vi)
}
#[cfg(feature = "rich-tui")]
pub(crate) fn resolve_gutter_setting() -> Option<u16> {
if let Ok(v) = std::env::var("NEWT_GUTTER") {
match v.trim().to_lowercase().as_str() {
"auto" => return None,
"off" => return Some(0),
s => {
if let Ok(n) = s.parse::<u16>() {
return Some(n);
}
}
}
}
newt_core::Config::resolve()
.ok()
.and_then(|c| c.tui)
.and_then(|t| t.gutter)
.or(Some(1))
}
pub(crate) fn footer_mode() -> newt_core::FooterMode {
use newt_core::FooterMode;
if let Ok(v) = std::env::var("NEWT_FOOTER") {
match v.to_lowercase().as_str() {
"off" | "plain" | "0" | "false" => return FooterMode::Off,
"on" | "stamp" | "bar" | "1" | "true" => return FooterMode::On,
"auto" => return FooterMode::Auto,
_ => {}
}
}
newt_core::Config::resolve()
.ok()
.and_then(|c| c.tui)
.map(|t| t.footer)
.unwrap_or_default()
}
pub(crate) fn footer_rich_enabled(mode: newt_core::FooterMode, is_tty: bool) -> bool {
use newt_core::FooterMode;
match mode {
FooterMode::Off => false,
FooterMode::On => true,
FooterMode::Auto => is_tty,
}
}
pub const DEFAULT_RICH_PROMPT: &str = "[$TIMESTAMP] $MODEL | $MODE | $WS ❯ ";
pub const DEFAULT_LEAN_PROMPT: &str = "[$TIMESTAMP] ❯ ";
pub const PROMPT_TOKENS: &[(&str, &str, &str)] = &[
("$TIMESTAMP", "\\t", "date + time, e.g. 2026-06-16 10:34:55"),
("$DATE", "", "date, e.g. 2026-06-16"),
("$TIME", "", "time, e.g. 10:34:55"),
("$MODEL", "\\m", "active model"),
("$MODE", "\\M", "edit mode (vi / emacs)"),
("$USER", "\\u", "username"),
("$HOST", "\\h", "hostname"),
("$WS", "\\w", "workspace basename"),
("$PATH", "\\W", "full workspace path"),
("$VERSION", "\\v", "newt version"),
];
pub(crate) fn strip_one_quote_pair(s: &str) -> &str {
let bytes = s.as_bytes();
if bytes.len() >= 2
&& ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
{
&s[1..s.len() - 1]
} else {
s
}
}
pub(crate) fn prompt_token_help() -> Vec<String> {
PROMPT_TOKENS
.iter()
.map(|(name, slash, desc)| format!(" {name:<11} {slash:<3} {desc}"))
.collect()
}
pub(crate) fn current_prompt_and_preview(workspace: &str) -> (String, String) {
let template = std::env::var("NEWT_PROMPT")
.ok()
.or_else(|| {
newt_core::Config::resolve()
.ok()
.and_then(|c| c.tui)
.and_then(|t| t.prompt)
})
.unwrap_or_else(|| DEFAULT_RICH_PROMPT.to_string());
let model = newt_core::Config::resolve()
.ok()
.map(|c| super::resolve_backend_choice(&c).model)
.unwrap_or_default();
let is_vi = resolve_edit_mode() == newt_core::EditMode::Vi;
let preview = expand_prompt_tokens(&template, workspace, &model, is_vi);
(template, preview)
}
pub(crate) fn prompt_str(workspace: &str, is_vi: bool, model: &str, rich: bool) -> String {
let template = std::env::var("NEWT_PROMPT").ok().or_else(|| {
newt_core::Config::resolve()
.ok()
.and_then(|c| c.tui)
.and_then(|t| t.prompt)
});
if let Some(ref tmpl) = template {
return expand_prompt_tokens(tmpl, workspace, model, is_vi);
}
let default = if rich {
DEFAULT_RICH_PROMPT
} else {
DEFAULT_LEAN_PROMPT
};
expand_prompt_tokens(default, workspace, model, is_vi)
}
pub(crate) fn expand_prompt_tokens(
template: &str,
workspace: &str,
model: &str,
is_vi: bool,
) -> String {
let ws_base = std::path::Path::new(workspace)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| workspace.to_string());
let host = std::process::Command::new("hostname")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "localhost".into());
let user = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_default();
let now = chrono::Local::now();
let timestamp = now.format("%Y-%m-%d %H:%M:%S").to_string();
let date = now.format("%Y-%m-%d").to_string();
let time = now.format("%H:%M:%S").to_string();
let mode = if is_vi { "vi" } else { "emacs" };
let version = env!("CARGO_PKG_VERSION");
template
.replace("$TIMESTAMP", ×tamp)
.replace("$DATE", &date)
.replace("$TIME", &time)
.replace("$MODEL", model)
.replace("$MODE", mode)
.replace("$USER", &user)
.replace("$HOST", &host)
.replace("$VERSION", version)
.replace("$PATH", workspace)
.replace("$WS", &ws_base)
.replace("\\W", workspace)
.replace("\\w", &ws_base)
.replace("\\h", &host)
.replace("\\u", &user)
.replace("\\t", ×tamp)
.replace("\\m", model)
.replace("\\M", mode)
.replace("\\v", version)
}
#[cfg(test)]
mod footer_tests {
use super::*;
use newt_core::FooterMode;
#[test]
fn rich_follows_mode_and_tty() {
assert!(footer_rich_enabled(FooterMode::Auto, true));
assert!(!footer_rich_enabled(FooterMode::Auto, false));
assert!(footer_rich_enabled(FooterMode::On, true));
assert!(footer_rich_enabled(FooterMode::On, false));
assert!(!footer_rich_enabled(FooterMode::Off, true));
assert!(!footer_rich_enabled(FooterMode::Off, false));
}
#[test]
fn prompt_tokens_expand() {
let p = expand_prompt_tokens("\\m · \\w · \\M", "/home/me/proj", "gpt-4.1", true);
assert!(p.starts_with("gpt-4.1 · proj · vi"));
let p = expand_prompt_tokens("[\\t] \\m", "/srv/x", "llama3", false);
assert!(!p.contains("\\t"), "timestamp token expanded: {p}");
assert!(p.ends_with("llama3"));
}
#[test]
fn rich_default_prompt_renders_the_status_line() {
let p = expand_prompt_tokens(DEFAULT_RICH_PROMPT, "/home/me/newt-agent", "gpt-4.1", false);
assert!(p.contains("] gpt-4.1 | emacs | newt-agent ❯ "), "{p}");
assert!(p.starts_with('['));
}
#[test]
fn lean_default_prompt_is_a_timestamped_log_line() {
let p = expand_prompt_tokens(DEFAULT_LEAN_PROMPT, "/home/me/newt-agent", "gpt-4.1", true);
assert!(p.starts_with('['), "{p}");
assert!(p.ends_with("] ❯ "), "{p}");
assert!(!p.contains("$TIMESTAMP"), "timestamp token expanded: {p}");
}
#[test]
fn strip_one_quote_pair_preserves_inner_spaces() {
assert_eq!(strip_one_quote_pair("\"[$TIME] ❯ \""), "[$TIME] ❯ ");
assert_eq!(strip_one_quote_pair("'hi'"), "hi");
assert_eq!(strip_one_quote_pair("bare"), "bare");
assert_eq!(strip_one_quote_pair("\"oops'"), "\"oops'");
assert_eq!(strip_one_quote_pair("\""), "\"");
}
#[test]
fn dollar_macros_expand_without_prefix_clobber() {
let p = expand_prompt_tokens("[$MODEL | $MODE | $WS]", "/home/me/proj", "gpt-4.1", true);
assert_eq!(p, "[gpt-4.1 | vi | proj]");
let p = expand_prompt_tokens("$TIMESTAMP|$TIME|$DATE", "/x", "m", false);
assert!(
!p.contains("$TIME") && !p.contains("$DATE") && !p.contains("STAMP"),
"{p}"
);
for (name, _slash, _desc) in PROMPT_TOKENS {
let out = expand_prompt_tokens(name, "/srv/work", "llama3", false);
assert!(!out.contains(name), "token {name} not expanded: {out}");
}
}
#[test]
fn prompt_help_renders_every_documented_token() {
let help = prompt_token_help();
assert_eq!(help.len(), PROMPT_TOKENS.len());
for ((name, slash, desc), line) in PROMPT_TOKENS.iter().zip(help.iter()) {
assert!(line.contains(name), "{line}");
assert!(line.contains(desc), "{line}");
if !slash.is_empty() {
assert!(line.contains(slash), "{line}");
}
}
}
#[test]
fn workspace_basename_falls_back_to_full_path_when_absent() {
let p = expand_prompt_tokens("$WS|\\w", "/", "m", true);
assert_eq!(p, "/|/");
}
#[cfg(feature = "rich-tui")]
#[test]
fn backslash_continuation_is_bang_only() {
assert!(crate::footer_continues("! date \\"));
assert!(!crate::footer_continues("write a\\"));
assert!(!crate::footer_continues("write a function"));
assert!(!crate::footer_continues(""));
assert_eq!("foo\\\nbar".replace("\\\n", "\n"), "foo\nbar");
}
#[cfg(feature = "rich-tui")]
#[test]
fn triple_quote_block_stays_open_until_closed() {
assert!(crate::footer_continues("\"\"\""));
assert!(crate::footer_continues("\"\"\"\nline one"));
assert!(crate::footer_continues("\"\"\"\nline one\nline two"));
assert!(
!crate::footer_continues("\"\"\"\nline one\n\"\"\""),
"closing fence submits"
);
assert!(crate::footer_continues("'''\nbody"));
assert!(!crate::footer_continues("'''\nbody\n'''"));
assert!(
crate::footer_continues("'''\nbody\n\"\"\""),
"mismatched fence stays open"
);
assert!(!crate::footer_continues("\"\"\" inline text"));
}
}