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
//! Themed powerline shell prompt — installs `themes/mnml-prompt.sh` to
//! the user's config dir and emits the env vars the script reads.
//!
//! The script is sourceable from `~/.zshrc` / `~/.bashrc` via the
//! one-line opt-in documented in README.md. When `$MNML_PROMPT_SCRIPT`
//! is unset (normal shells outside mnml) the opt-in line is a
//! no-op.
//!
//! Used by [`crate::pty_pane::PtySession::spawn`] — any shell pty mnml
//! spawns inherits the env so its prompt themes against the current
//! mnml palette.
//!
//! Naming note: the related `crate::prompt` module is mnml's
//! single-line text-input overlay (command prompt for `:commit -m …`
//! and friends), nothing to do with shells.
use std::io;
use std::path::PathBuf;
use ratatui::style::Color;
use crate::ui::theme;
/// Embedded script content — written to disk lazily by
/// [`install_prompt_script`]. Bumping this string here is the
/// authoritative way to update the prompt across an mnml install.
const SCRIPT: &str = include_str!("../themes/mnml-prompt.sh");
/// `themes/mnml-prompt.sh` location on the user's machine. We write
/// to `$XDG_CONFIG_HOME/mnml/prompt.sh` (or `~/.config/mnml/prompt.sh`),
/// not the repo path, so this works for installed binaries that don't
/// have the source tree available.
pub fn script_path() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.unwrap_or_else(|| PathBuf::from("."));
base.join("mnml").join("prompt.sh")
}
/// Ensure the prompt script exists at [`script_path`] and matches the
/// embedded version. Returns the path on success. Idempotent — cheap
/// to call on every shell spawn.
///
/// We compare contents rather than mtime so an mnml upgrade with a
/// newer script gets picked up without the user having to delete the
/// installed copy by hand.
pub fn install_prompt_script() -> io::Result<PathBuf> {
let path = script_path();
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let needs_write = match std::fs::read_to_string(&path) {
Ok(existing) => existing != SCRIPT,
Err(_) => true,
};
if needs_write {
std::fs::write(&path, SCRIPT)?;
}
Ok(path)
}
/// The env-var pairs to inject into a spawned shell so the prompt
/// script picks up the current mnml palette. `context_label` is the
/// chip text on the right side ("mnml" / shell-name).
pub fn theme_env_vars(context_label: &str) -> Vec<(String, String)> {
let t = theme::cur();
let mut out = vec![
("MNML_PROMPT_BG".into(), hex(t.bg_darker)),
("MNML_PROMPT_FG".into(), hex(t.fg)),
// `teal` is the family's accent (matches the bufferline focused
// tab + statusline mode chip); fall back to `blue` if a theme
// happens to leave teal unset (none do today).
("MNML_PROMPT_ACCENT".into(), hex(t.teal)),
("MNML_PROMPT_GREEN".into(), hex(t.green)),
("MNML_PROMPT_RED".into(), hex(t.red)),
("MNML_PROMPT_YELLOW".into(), hex(t.yellow)),
("MNML_PROMPT_GREY".into(), hex(t.grey)),
("MNML_CONTEXT".into(), context_label.to_string()),
];
// `MNML_PROMPT_SCRIPT` is set after [`install_prompt_script`] so it
// always reflects the resolved on-disk path.
if let Ok(path) = install_prompt_script() {
out.push(("MNML_PROMPT_SCRIPT".into(), path.display().to_string()));
}
out
}
/// Stringify a [`ratatui::style::Color`] as `#RRGGBB`. Falls back to
/// `#000000` for named / indexed colors (the script's own defaults
/// kick in for those, which is fine — none of our shipped themes use
/// non-RGB colors).
fn hex(c: Color) -> String {
match c {
Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
_ => "#000000".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_formats_rgb_lowercase() {
assert_eq!(hex(Color::Rgb(0xAB, 0xCD, 0xEF)), "#abcdef");
assert_eq!(hex(Color::Rgb(0, 0, 0)), "#000000");
assert_eq!(hex(Color::Rgb(255, 255, 255)), "#ffffff");
}
#[test]
fn hex_falls_back_on_named_colors() {
// Named colors round-trip to "#000000" — the shell script's own
// defaults are the real fallback here.
assert_eq!(hex(Color::Reset), "#000000");
assert_eq!(hex(Color::Blue), "#000000");
}
#[test]
fn theme_env_vars_includes_all_keys_and_context() {
// Sandbox `$HOME` so install_prompt_script() doesn't write to
// the real `~/.config/mnml/prompt.sh` on a developer machine.
let d = tempfile::tempdir().unwrap();
// Serialize env mutation across all test modules. Ubuntu CI's
// higher --test-threads default exposed a race on 2026-08-03.
let _lk = crate::test_env_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
// EnvGuard restores both env vars on scope exit — including
// during panic unwind, so a failing assert! still restores.
let _home = crate::EnvGuard::set("HOME", d.path());
let _xdg = crate::EnvGuard::remove("XDG_CONFIG_HOME");
let v = theme_env_vars("mnml");
let keys: Vec<&str> = v.iter().map(|(k, _)| k.as_str()).collect();
for needed in &[
"MNML_PROMPT_BG",
"MNML_PROMPT_FG",
"MNML_PROMPT_ACCENT",
"MNML_PROMPT_GREEN",
"MNML_PROMPT_RED",
"MNML_PROMPT_YELLOW",
"MNML_PROMPT_GREY",
"MNML_CONTEXT",
"MNML_PROMPT_SCRIPT",
] {
assert!(keys.contains(needed), "missing {needed} in env vars");
}
let ctx = v.iter().find(|(k, _)| k == "MNML_CONTEXT").unwrap();
assert_eq!(ctx.1, "mnml");
// Script written into the tempdir, not the real ~/.
let script = v.iter().find(|(k, _)| k == "MNML_PROMPT_SCRIPT").unwrap();
assert!(
script
.1
.starts_with(d.path().display().to_string().as_str())
);
assert!(std::path::Path::new(&script.1).exists());
// _home and _xdg drop here → restore.
}
}