use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::Deserialize;
use crate::app::App;
use crate::input::keymap::parse_key_spec;
#[derive(Debug, Deserialize)]
struct RawCommand {
cmd: String,
#[serde(default)]
path: Option<String>,
#[serde(default)]
key: Option<String>,
#[serde(default)]
id: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
group: Option<String>,
#[serde(default)]
keys: Vec<String>,
#[serde(default)]
text: Option<String>,
#[serde(default)]
col: Option<u16>,
#[serde(default)]
row: Option<u16>,
#[serde(default)]
button: Option<String>,
#[serde(default)]
dy: Option<i32>,
#[serde(default)]
mods: Option<String>,
#[serde(default)]
expect: Option<String>,
#[serde(default)]
ms: Option<u64>,
#[serde(default)]
from_col: Option<u16>,
#[serde(default)]
from_row: Option<u16>,
#[serde(default)]
command: Vec<String>,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
section: Option<String>,
#[serde(default)]
count: Option<u32>,
#[serde(default)]
level: Option<String>,
#[serde(default)]
side: Option<String>,
#[serde(default)]
color: Option<String>,
#[serde(default)]
click_command: Option<String>,
#[serde(default)]
priority: Option<u8>,
#[serde(default)]
min_width: Option<u16>,
#[serde(default)]
max_width: Option<u16>,
#[serde(default)]
sound: Option<bool>,
#[serde(default)]
source: Option<String>,
}
fn parse_toast_level(s: Option<&str>) -> crate::app::ToastLevel {
use crate::app::ToastLevel;
match s.map(|v| v.to_ascii_lowercase()).as_deref() {
Some("warn") | Some("warning") => ToastLevel::Warn,
Some("error") | Some("err") => ToastLevel::Error,
_ => ToastLevel::Info,
}
}
fn parse_progress_status(s: Option<&str>) -> crate::app::ProgressStatus {
use crate::app::ProgressStatus;
match s.map(|v| v.to_ascii_lowercase()).as_deref() {
Some("failed") | Some("fail") | Some("error") => ProgressStatus::Failed,
Some("cancelled") | Some("canceled") => ProgressStatus::Cancelled,
_ => ProgressStatus::Success,
}
}
fn parse_segment_side(s: Option<&str>) -> crate::app::SegmentSide {
use crate::app::SegmentSide;
match s.map(|v| v.to_ascii_lowercase()).as_deref() {
Some("left") => SegmentSide::Left,
_ => SegmentSide::Right,
}
}
#[derive(Debug)]
pub enum IpcCommand {
Open(PathBuf),
Key(String),
Type(String),
Click {
col: u16,
row: u16,
button: ratatui::crossterm::event::MouseButton,
mods: ratatui::crossterm::event::KeyModifiers,
},
Hover { col: u16, row: u16 },
Scroll { col: u16, row: u16, dy: i32 },
Drag {
from_col: u16,
from_row: u16,
col: u16,
row: u16,
},
MouseDown {
col: u16,
row: u16,
button: ratatui::crossterm::event::MouseButton,
mods: ratatui::crossterm::event::KeyModifiers,
},
MouseMove { col: u16, row: u16 },
MouseUp {
col: u16,
row: u16,
button: ratatui::crossterm::event::MouseButton,
mods: ratatui::crossterm::event::KeyModifiers,
},
Wait { ms: u64 },
ExpectScreen { text: String, contains: bool },
RunCommand(String),
RegisterCommand {
id: String,
title: String,
group: String,
keys: Vec<String>,
},
Snapshot,
Toast(String, crate::app::ToastLevel),
ToastPersistent {
id: String,
text: String,
level: crate::app::ToastLevel,
},
ToastDismiss(String),
ProgressStart { id: String, label: String },
ProgressUpdate {
id: String,
label: Option<String>,
percent: Option<u8>,
},
ProgressEnd {
id: String,
status: crate::app::ProgressStatus,
},
StatuslineSetSegment {
id: String,
side: crate::app::SegmentSide,
text: String,
color: Option<String>,
click_command: Option<String>,
priority: u8,
min_width: u16,
max_width: u16,
},
StatuslineClearSegment { id: String },
Notify {
title: String,
body: String,
level: crate::app::ToastLevel,
sound: bool,
source: Option<String>,
},
OpenPty {
cwd: Option<PathBuf>,
command: Vec<String>,
},
SetActivityBadge { section: String, count: u32 },
DumpRects,
Ghost(String),
Quit,
Restart,
Unknown(String),
}
pub struct Ipc {
dir: PathBuf,
cmd_path: PathBuf,
screen_path: PathBuf,
status_path: PathBuf,
events_path: PathBuf,
cmd_offset: u64,
exit_event_written: std::sync::atomic::AtomicBool,
last_backend_size: std::cell::Cell<Option<(u16, u16)>>,
}
pub const DEFAULT_HEADLESS_W: u16 = 200;
pub const DEFAULT_HEADLESS_H: u16 = 60;
impl Drop for Ipc {
fn drop(&mut self) {
if !self
.exit_event_written
.load(std::sync::atomic::Ordering::Relaxed)
{
self.append_event(
r#"{"event":"shutdown","reason":"unexpected","note":"ipc drop without happy-path exit"}"#,
);
}
}
}
impl Ipc {
pub fn init(workspace: &Path) -> io::Result<Ipc> {
let dir = workspace.join(".mnml").join("ipc");
std::fs::create_dir_all(&dir)?;
let _ = ensure_workspace_gitignore(workspace);
let cmd_path = dir.join("command");
let screen_path = dir.join("screen.txt");
let status_path = dir.join("status.json");
let events_path = dir.join("events.jsonl");
for p in [&cmd_path, &screen_path, &status_path, &events_path] {
if std::fs::symlink_metadata(p)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
eprintln!(
"mnml: {} was a symlink — replacing it with a regular file",
p.display()
);
let _ = std::fs::remove_file(p);
}
}
let pre_queued = std::fs::read_to_string(&cmd_path).unwrap_or_default();
crate::secret_file::write_secret(&cmd_path, b"")?;
crate::secret_file::write_secret(&events_path, b"")?;
crate::secret_file::write_secret(&screen_path, b"")?;
crate::secret_file::write_secret(&status_path, b"{}")?;
let ipc = Ipc {
dir,
cmd_path,
screen_path,
status_path,
events_path,
cmd_offset: 0,
exit_event_written: std::sync::atomic::AtomicBool::new(false),
last_backend_size: std::cell::Cell::new(None),
};
if !pre_queued.is_empty() {
let lines = pre_queued.lines().count();
ipc.append_event(&format!(
"{{\"event\":\"ipc_init_truncated\",\"bytes\":{bytes},\"lines\":{lines}}}",
bytes = pre_queued.len(),
));
}
Ok(ipc)
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn poll(&mut self) -> Vec<IpcCommand> {
let mut f = match std::fs::File::open(&self.cmd_path) {
Ok(f) => f,
Err(_) => return Vec::new(),
};
let len = f.metadata().map(|m| m.len()).unwrap_or(0);
if len < self.cmd_offset {
self.cmd_offset = 0;
}
if len == self.cmd_offset {
return Vec::new();
}
if io::Seek::seek(&mut f, io::SeekFrom::Start(self.cmd_offset)).is_err() {
return Vec::new();
}
let mut buf = String::new();
if f.read_to_string(&mut buf).is_err() {
return Vec::new();
}
let mut consumed = 0usize;
let mut out = Vec::new();
for line in buf.split_inclusive('\n') {
if !line.ends_with('\n') {
break; }
consumed += line.len();
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
out.push(parse_command(trimmed));
}
self.cmd_offset += consumed as u64;
out
}
pub fn write_screen(&self, text: &str) {
let _ = crate::secret_file::write_secret(&self.screen_path, text.as_bytes());
}
pub fn write_status(&self, json: &str) {
let _ = crate::secret_file::write_secret(&self.status_path, json.as_bytes());
}
pub fn write_rects(&self, json: &str) {
let _ = crate::secret_file::write_secret(&self.dir.join("rects.json"), json.as_bytes());
}
pub fn append_event(&self, json_line: &str) {
if json_line.contains(r#""event":"exit""#) {
self.exit_event_written
.store(true, std::sync::atomic::Ordering::Relaxed);
}
if let Ok(mut f) = crate::secret_file::append_secret(&self.events_path) {
let _ = writeln!(f, "{json_line}");
}
}
}
fn parse_command(line: &str) -> IpcCommand {
let raw: RawCommand = match serde_json::from_str(line) {
Ok(r) => r,
Err(_) => return IpcCommand::Unknown(line.to_string()),
};
match raw.cmd.as_str() {
"open" => match raw.path {
Some(p) => IpcCommand::Open(PathBuf::from(p)),
None => IpcCommand::Unknown(line.to_string()),
},
"key" => match raw.key {
Some(k) => IpcCommand::Key(k),
None => IpcCommand::Unknown(line.to_string()),
},
"type" => match raw.text {
Some(t) => IpcCommand::Type(t),
None => IpcCommand::Unknown(line.to_string()),
},
"run-command" => match raw.id {
Some(id) => IpcCommand::RunCommand(id),
None => IpcCommand::Unknown(line.to_string()),
},
"register-command" => match raw.id {
Some(id) => IpcCommand::RegisterCommand {
title: raw.title.unwrap_or_else(|| id.clone()),
group: raw.group.unwrap_or_else(|| "plugin".to_string()),
keys: raw.keys,
id,
},
None => IpcCommand::Unknown(line.to_string()),
},
"click" => match (raw.col, raw.row) {
(Some(col), Some(row)) => IpcCommand::Click {
col,
row,
button: parse_mouse_button(raw.button.as_deref()),
mods: parse_mods(raw.mods.as_deref()),
},
_ => IpcCommand::Unknown(line.to_string()),
},
"hover" => match (raw.col, raw.row) {
(Some(col), Some(row)) => IpcCommand::Hover { col, row },
_ => IpcCommand::Unknown(line.to_string()),
},
"scroll" => match (raw.col, raw.row) {
(Some(col), Some(row)) => IpcCommand::Scroll {
col,
row,
dy: raw.dy.unwrap_or(1),
},
_ => IpcCommand::Unknown(line.to_string()),
},
"drag" => match (raw.from_col, raw.from_row, raw.col, raw.row) {
(Some(fc), Some(fr), Some(tc), Some(tr)) => IpcCommand::Drag {
from_col: fc,
from_row: fr,
col: tc,
row: tr,
},
_ => IpcCommand::Unknown(line.to_string()),
},
"mouse_down" => match (raw.col, raw.row) {
(Some(col), Some(row)) => IpcCommand::MouseDown {
col,
row,
button: parse_mouse_button(raw.button.as_deref()),
mods: parse_mods(raw.mods.as_deref()),
},
_ => IpcCommand::Unknown(line.to_string()),
},
"mouse_move" => match (raw.col, raw.row) {
(Some(col), Some(row)) => IpcCommand::MouseMove { col, row },
_ => IpcCommand::Unknown(line.to_string()),
},
"mouse_up" => match (raw.col, raw.row) {
(Some(col), Some(row)) => IpcCommand::MouseUp {
col,
row,
button: parse_mouse_button(raw.button.as_deref()),
mods: parse_mods(raw.mods.as_deref()),
},
_ => IpcCommand::Unknown(line.to_string()),
},
"wait_ms" => match raw.ms {
Some(ms) => IpcCommand::Wait { ms },
None => IpcCommand::Unknown(line.to_string()),
},
"expect_screen" => match raw.text {
Some(t) => {
let contains = match raw.expect.as_deref() {
Some("lacks") => false,
_ => true, };
IpcCommand::ExpectScreen { text: t, contains }
}
None => IpcCommand::Unknown(line.to_string()),
},
"snapshot" => IpcCommand::Snapshot,
"toast" => match raw.text {
Some(t) => IpcCommand::Toast(t, parse_toast_level(raw.level.as_deref())),
None => IpcCommand::Unknown(line.to_string()),
},
"toast-persistent" => match (raw.id, raw.text) {
(Some(id), Some(text)) => IpcCommand::ToastPersistent {
id,
text,
level: parse_toast_level(raw.level.as_deref()),
},
_ => IpcCommand::Unknown(line.to_string()),
},
"toast-dismiss" => match raw.id {
Some(id) => IpcCommand::ToastDismiss(id),
None => IpcCommand::Unknown(line.to_string()),
},
"progress-start" => match (raw.id, raw.text) {
(Some(id), Some(label)) => IpcCommand::ProgressStart { id, label },
_ => IpcCommand::Unknown(line.to_string()),
},
"progress-update" => match raw.id {
Some(id) => IpcCommand::ProgressUpdate {
id,
label: raw.text,
percent: raw.count.map(|c| c.min(100) as u8),
},
_ => IpcCommand::Unknown(line.to_string()),
},
"progress-end" => match raw.id {
Some(id) => IpcCommand::ProgressEnd {
id,
status: parse_progress_status(raw.text.as_deref()),
},
_ => IpcCommand::Unknown(line.to_string()),
},
"statusline-set-segment" => match (raw.id, raw.text) {
(Some(id), Some(text)) => IpcCommand::StatuslineSetSegment {
id,
side: parse_segment_side(raw.side.as_deref()),
text,
color: raw.color,
click_command: raw.click_command,
priority: raw.priority.unwrap_or(100),
min_width: raw.min_width.unwrap_or(4),
max_width: raw.max_width.unwrap_or(30),
},
_ => IpcCommand::Unknown(line.to_string()),
},
"statusline-clear-segment" => match raw.id {
Some(id) => IpcCommand::StatuslineClearSegment { id },
None => IpcCommand::Unknown(line.to_string()),
},
"notify" => match raw.text {
Some(body) => IpcCommand::Notify {
title: raw.title.unwrap_or_else(|| "mnml".to_string()),
body,
level: parse_toast_level(raw.level.as_deref()),
sound: raw.sound.unwrap_or(false),
source: raw.source,
},
None => IpcCommand::Unknown(line.to_string()),
},
"open-pty" => {
if raw.command.is_empty() {
IpcCommand::Unknown(line.to_string())
} else {
IpcCommand::OpenPty {
cwd: raw.cwd.map(PathBuf::from),
command: raw.command,
}
}
}
"set-activity-badge" => match (raw.section, raw.count) {
(Some(s), Some(c)) => IpcCommand::SetActivityBadge {
section: s,
count: c,
},
_ => IpcCommand::Unknown(line.to_string()),
},
"dump-rects" => IpcCommand::DumpRects,
"ghost" => match raw.text {
Some(t) if !t.is_empty() => IpcCommand::Ghost(t),
_ => IpcCommand::Unknown(line.to_string()),
},
"quit" => IpcCommand::Quit,
"restart" => IpcCommand::Restart,
_ => IpcCommand::Unknown(line.to_string()),
}
}
const WORKSPACE_STATE_DIRS: &[&str] = &[".mnml", ".rqst"];
fn gitignore_covers(existing: &str, dir: &str) -> bool {
existing.lines().any(|line| {
let t = line
.split('#')
.next()
.unwrap_or("")
.trim()
.trim_start_matches('/')
.trim_end_matches("**")
.trim_end_matches('/');
t == dir
})
}
pub(crate) fn ensure_workspace_gitignore(workspace: &Path) -> io::Result<()> {
if !workspace.join(".git").exists() {
return Ok(());
}
let gi = workspace.join(".gitignore");
if std::fs::symlink_metadata(&gi)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
eprintln!(
"mnml: {} is a symlink — not modifying it. Add `.mnml/` \
(and `.rqst/` if you use the HTTP client) yourself.",
gi.display()
);
return Ok(());
}
let existing = std::fs::read_to_string(&gi).unwrap_or_default();
let missing: Vec<&str> = WORKSPACE_STATE_DIRS
.iter()
.copied()
.filter(|d| *d == ".mnml" || workspace.join(d).exists())
.filter(|d| !gitignore_covers(&existing, d))
.collect();
if missing.is_empty() {
return Ok(());
}
let mut out = existing;
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str("# Added by mnml — workspace state: IPC, session, and HTTP\n");
out.push_str("# history / captured traffic / env values (these carry secrets)\n");
for dir in missing {
out.push_str(dir);
out.push_str("/\n");
}
std::fs::write(&gi, out)
}
fn parse_mouse_button(s: Option<&str>) -> ratatui::crossterm::event::MouseButton {
use ratatui::crossterm::event::MouseButton;
match s.map(str::to_ascii_lowercase).as_deref() {
Some("middle" | "m") => MouseButton::Middle,
Some("right" | "r") => MouseButton::Right,
_ => MouseButton::Left,
}
}
fn parse_mods(s: Option<&str>) -> ratatui::crossterm::event::KeyModifiers {
use ratatui::crossterm::event::KeyModifiers;
let Some(s) = s else {
return KeyModifiers::NONE;
};
let mut out = KeyModifiers::NONE;
for token in s.split(',') {
match token.trim().to_ascii_lowercase().as_str() {
"ctrl" | "control" => out |= KeyModifiers::CONTROL,
"alt" | "option" => out |= KeyModifiers::ALT,
"shift" => out |= KeyModifiers::SHIFT,
"super" | "cmd" | "meta" => out |= KeyModifiers::SUPER,
_ => {}
}
}
out
}
pub fn apply(app: &mut App, cmd: &IpcCommand) -> String {
match cmd {
IpcCommand::Open(p) => {
let path = if p.is_absolute() {
p.clone()
} else {
app.workspace.join(p)
};
app.open_path(&path);
json_event(&[("event", "open"), ("path", &path.display().to_string())])
}
IpcCommand::Key(spec) => {
let looks_like_typo =
spec.chars().count() >= 3 && spec.chars().all(|c| c.is_ascii_alphabetic());
let tokens: Vec<String> = if !spec.contains(char::is_whitespace)
&& !spec.contains('+')
&& parse_key_spec(spec).is_none()
&& spec.chars().count() >= 2
&& !looks_like_typo
{
spec.chars().map(|c| c.to_string()).collect()
} else {
spec.split_whitespace().map(String::from).collect()
};
let parsed: Vec<KeyEvent> = tokens.iter().filter_map(|t| parse_key_spec(t)).collect();
if parsed.len() == tokens.len() && !parsed.is_empty() {
for ev in parsed {
crate::tui::dispatch_key(app, ev);
}
json_event(&[("event", "key"), ("key", spec)])
} else {
json_event(&[("event", "key_unparsed"), ("key", spec)])
}
}
IpcCommand::Type(text) => {
for c in text.chars() {
let ev = if c == '\n' {
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
} else {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
};
crate::tui::dispatch_key(app, ev);
}
json_event(&[("event", "type"), ("text", text)])
}
IpcCommand::RunCommand(id) => {
let ok = crate::command::run(id, app);
json_event(&[
("event", "command_run"),
("id", id),
("ok", if ok { "true" } else { "false" }),
])
}
IpcCommand::RegisterCommand {
id,
title,
group,
keys,
} => {
app.register_dynamic_command(crate::command::DynCommand {
id: id.clone(),
title: title.clone(),
group: group.clone(),
keys: keys.clone(),
ex_run: None,
owner_integration_id: None,
});
json_event(&[
("event", "command_registered"),
("id", id),
("title", title),
])
}
IpcCommand::Click {
col,
row,
button,
mods,
} => {
use ratatui::crossterm::event::{MouseEvent, MouseEventKind};
crate::tui::dispatch_mouse(
app,
MouseEvent {
kind: MouseEventKind::Down(*button),
column: *col,
row: *row,
modifiers: *mods,
},
);
crate::tui::dispatch_mouse(
app,
MouseEvent {
kind: MouseEventKind::Up(*button),
column: *col,
row: *row,
modifiers: *mods,
},
);
json_event(&[
("event", "click"),
("button", &format!("{button:?}")),
("col", &col.to_string()),
("row", &row.to_string()),
])
}
IpcCommand::Hover { col, row } => {
use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
crate::tui::dispatch_mouse(
app,
MouseEvent {
kind: MouseEventKind::Moved,
column: *col,
row: *row,
modifiers: KeyModifiers::NONE,
},
);
json_event(&[
("event", "hover"),
("col", &col.to_string()),
("row", &row.to_string()),
])
}
IpcCommand::Drag {
from_col,
from_row,
col,
row,
} => {
use ratatui::crossterm::event::{
KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
crate::tui::dispatch_mouse(
app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: *from_col,
row: *from_row,
modifiers: KeyModifiers::NONE,
},
);
let steps = (col.abs_diff(*from_col)).max(row.abs_diff(*from_row)) as usize;
for s in 1..=steps {
let t = s as f32 / steps as f32;
let cx = (*from_col as f32 + (*col as f32 - *from_col as f32) * t).round() as u16;
let cy = (*from_row as f32 + (*row as f32 - *from_row as f32) * t).round() as u16;
crate::tui::dispatch_mouse(
app,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: cx,
row: cy,
modifiers: KeyModifiers::NONE,
},
);
}
crate::tui::dispatch_mouse(
app,
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: *col,
row: *row,
modifiers: KeyModifiers::NONE,
},
);
json_event(&[
("event", "drag"),
("from", &format!("{from_col},{from_row}")),
("to", &format!("{col},{row}")),
("steps", &steps.to_string()),
])
}
IpcCommand::MouseDown {
col,
row,
button,
mods,
} => {
use ratatui::crossterm::event::{MouseEvent, MouseEventKind};
crate::tui::dispatch_mouse(
app,
MouseEvent {
kind: MouseEventKind::Down(*button),
column: *col,
row: *row,
modifiers: *mods,
},
);
json_event(&[
("event", "mouse_down"),
("col", &col.to_string()),
("row", &row.to_string()),
])
}
IpcCommand::MouseMove { col, row } => {
use ratatui::crossterm::event::{
KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
let kind = if app.tree_drag.is_some() {
MouseEventKind::Drag(MouseButton::Left)
} else {
MouseEventKind::Moved
};
crate::tui::dispatch_mouse(
app,
MouseEvent {
kind,
column: *col,
row: *row,
modifiers: KeyModifiers::NONE,
},
);
json_event(&[
("event", "mouse_move"),
("col", &col.to_string()),
("row", &row.to_string()),
])
}
IpcCommand::MouseUp {
col,
row,
button,
mods,
} => {
use ratatui::crossterm::event::{MouseEvent, MouseEventKind};
crate::tui::dispatch_mouse(
app,
MouseEvent {
kind: MouseEventKind::Up(*button),
column: *col,
row: *row,
modifiers: *mods,
},
);
json_event(&[
("event", "mouse_up"),
("col", &col.to_string()),
("row", &row.to_string()),
])
}
IpcCommand::Wait { ms } => {
let step = std::time::Duration::from_millis(40);
let total = std::time::Duration::from_millis(*ms);
let start = std::time::Instant::now();
while start.elapsed() < total {
let remaining = total.saturating_sub(start.elapsed());
std::thread::sleep(step.min(remaining));
crate::tui::tick_chord_chain(app);
}
json_event(&[("event", "wait_ms"), ("ms", &ms.to_string())])
}
IpcCommand::ExpectScreen { text, contains } => {
let screen_path = app.workspace.join(".mnml/ipc/screen.txt");
let screen = std::fs::read_to_string(&screen_path).unwrap_or_default();
let found = screen.contains(text.as_str());
let ok = if *contains { found } else { !found };
json_event(&[
("event", "expect_screen"),
("mode", if *contains { "contains" } else { "lacks" }),
("text", text),
("ok", if ok { "true" } else { "false" }),
])
}
IpcCommand::Scroll { col, row, dy } => {
use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
let kind = if *dy >= 0 {
MouseEventKind::ScrollUp
} else {
MouseEventKind::ScrollDown
};
for _ in 0..dy.unsigned_abs() {
crate::tui::dispatch_mouse(
app,
MouseEvent {
kind,
column: *col,
row: *row,
modifiers: KeyModifiers::NONE,
},
);
}
json_event(&[
("event", "scroll"),
("col", &col.to_string()),
("row", &row.to_string()),
("dy", &dy.to_string()),
])
}
IpcCommand::Snapshot => json_event(&[("event", "snapshot")]),
IpcCommand::Toast(text, level) => {
app.toast_leveled(text.clone(), *level);
let level_str = match level {
crate::app::ToastLevel::Warn => "warn",
crate::app::ToastLevel::Error => "error",
_ => "info",
};
json_event(&[("event", "toast"), ("text", text), ("level", level_str)])
}
IpcCommand::ToastPersistent { id, text, level } => {
app.toast_persistent(id.clone(), text.clone(), *level);
json_event(&[("event", "toast_persistent"), ("id", id), ("text", text)])
}
IpcCommand::ToastDismiss(id) => {
app.toast_dismiss(id);
json_event(&[("event", "toast_dismiss"), ("id", id)])
}
IpcCommand::ProgressStart { id, label } => {
app.progress_start(id.clone(), label.clone());
json_event(&[("event", "progress_start"), ("id", id), ("label", label)])
}
IpcCommand::ProgressUpdate { id, label, percent } => {
app.progress_update(id, label.clone(), *percent);
json_event(&[("event", "progress_update"), ("id", id)])
}
IpcCommand::ProgressEnd { id, status } => {
app.progress_end(id, *status);
let s = match status {
crate::app::ProgressStatus::Success => "success",
crate::app::ProgressStatus::Failed => "failed",
crate::app::ProgressStatus::Cancelled => "cancelled",
};
json_event(&[("event", "progress_end"), ("id", id), ("status", s)])
}
IpcCommand::StatuslineSetSegment {
id,
side,
text,
color,
click_command,
priority,
min_width,
max_width,
} => {
app.statusline_set_segment(
id.clone(),
*side,
text.clone(),
color.clone(),
click_command.clone(),
*priority,
*min_width,
*max_width,
);
json_event(&[
("event", "statusline_set_segment"),
("id", id),
("text", text),
])
}
IpcCommand::StatuslineClearSegment { id } => {
app.statusline_clear_segment(id);
json_event(&[("event", "statusline_clear_segment"), ("id", id)])
}
IpcCommand::Notify {
title,
body,
level,
sound,
source,
} => {
app.notify(
title.clone(),
body.clone(),
*level,
*sound,
source.as_deref(),
);
json_event(&[("event", "notify"), ("title", title), ("body", body)])
}
IpcCommand::OpenPty { cwd, command } => {
if command.is_empty() {
json_event(&[("event", "open_pty_error"), ("reason", "command empty")])
} else {
let exe = command[0].clone();
let args: Vec<String> = command[1..].to_vec();
let cwd_path = cwd.clone().unwrap_or_else(|| app.workspace.clone());
let label = exe.rsplit('/').next().unwrap_or(&exe).to_string();
let profile = crate::pty_pane::BinaryProfile {
label,
exe: exe.clone(),
args,
cwd: Some(cwd_path),
env: Vec::new(),
session_id: None,
integration_id: None,
};
app.open_pty(profile);
json_event(&[("event", "open_pty"), ("exe", &exe)])
}
}
IpcCommand::SetActivityBadge { section, count } => {
app.set_activity_badge(section.clone(), *count);
json_event(&[
("event", "set_activity_badge"),
("section", section),
("count", &count.to_string()),
])
}
IpcCommand::DumpRects => json_event(&[("event", "dump_rects")]),
IpcCommand::Ghost(text) => {
let mut applied = false;
if let Some(pane) = app.active.and_then(|i| app.panes.get_mut(i))
&& let crate::pane::Pane::Editor(b) = pane
{
b.editor.ghost_suggestion = Some(text.clone());
applied = true;
}
json_event(&[
("event", "ghost"),
("applied", if applied { "true" } else { "false" }),
("text", text),
])
}
IpcCommand::Quit => {
app.should_quit = true;
json_event(&[("event", "quit")])
}
IpcCommand::Restart => {
app.restart_requested = true;
app.should_quit = true;
json_event(&[("event", "restart")])
}
IpcCommand::Unknown(s) => json_event(&[("event", "unknown"), ("raw", s)]),
}
}
pub fn dump_screen_status(ipc: &Ipc, screen: &ratatui::buffer::Buffer, app: &App) {
let area = screen.area();
ipc.last_backend_size.set(Some((area.width, area.height)));
if app.config.ipc.write_screen {
ipc.write_screen(&screen_to_text(screen));
}
ipc.write_status(&status_json(app));
ipc.write_rects(&rects_dump_json(app));
}
pub fn rects_dump_json(app: &App) -> String {
let mut out = String::from("[\n");
let mut first = true;
let push_rect = |out: &mut String, first: &mut bool, label: &str, r: ratatui::layout::Rect| {
if !*first {
out.push_str(",\n");
}
*first = false;
out.push_str(&format!(
" {{\"label\":\"{label}\",\"x\":{x},\"y\":{y},\"w\":{w},\"h\":{h}}}",
x = r.x,
y = r.y,
w = r.width,
h = r.height
));
};
macro_rules! one {
($label:expr, $field:expr) => {
if let Some(r) = $field {
push_rect(&mut out, &mut first, $label, r);
}
};
}
one!("tree_toggle", app.rects.tree_toggle);
one!("tree_edge", app.rects.tree_edge);
one!("tree", app.rects.tree);
one!(
"integration_section_toggle",
app.rects.integration_section_toggle
);
one!(
"statusline_workspace_chip",
app.rects.statusline_workspace_chip
);
one!("statusline_branch_chip", app.rects.statusline_branch_chip);
one!("statusline_mode_chip", app.rects.statusline_mode_chip);
one!("statusline_clock_chip", app.rects.statusline_clock_chip);
one!("statusline_mixr_chip", app.rects.statusline_mixr_chip);
one!(
"statusline_mixr_play_chip",
app.rects.statusline_mixr_play_chip
);
one!(
"statusline_mixr_ffwd_chip",
app.rects.statusline_mixr_ffwd_chip
);
one!("statusline_lsp_chip", app.rects.statusline_lsp_chip);
one!("statusline_wrap_chip", app.rects.statusline_wrap_chip);
one!(
"statusline_autosave_chip",
app.rects.statusline_autosave_chip
);
one!(
"statusline_filesize_chip",
app.rects.statusline_filesize_chip
);
one!("statusline_lncol_chip", app.rects.statusline_lncol_chip);
one!("statusline_test_chip", app.rects.statusline_test_chip);
one!(
"statusline_ai_claude_chip",
app.rects.statusline_ai_claude_chip
);
one!(
"statusline_ai_codex_chip",
app.rects.statusline_ai_codex_chip
);
one!(
"statusline_coverage_chip",
app.rects.statusline_coverage_chip
);
one!(
"integrations_tab_installed",
app.rects.integrations_tab_installed
);
one!(
"integrations_tab_marketplace",
app.rects.integrations_tab_marketplace
);
one!("integrations_tab_in_dev", app.rects.integrations_tab_in_dev);
one!(
"integrations_tab_refresh",
app.rects.integrations_tab_refresh
);
one!("integrations_tab_sort", app.rects.integrations_tab_sort);
one!("statusline_file_chip", app.rects.statusline_file_chip);
one!(
"statusline_diagnostics_chip",
app.rects.statusline_diagnostics_chip
);
one!(
"statusline_language_chip",
app.rects.statusline_language_chip
);
one!("statusline_symbol_chip", app.rects.statusline_symbol_chip);
one!("statusline_pr_chip", app.rects.statusline_pr_chip);
one!("statusline_macro_chip", app.rects.statusline_macro_chip);
one!("statusline_find_chip", app.rects.statusline_find_chip);
one!("statusline_sel_chip", app.rects.statusline_sel_chip);
one!(
"statusline_progress_chip",
app.rects.statusline_progress_chip
);
one!(
"statusline_bg_tasks_chip",
app.rects.statusline_bg_tasks_chip
);
one!("statusline_ai_chip", app.rects.statusline_ai_chip);
one!("activity_bar_gear", app.rects.activity_bar_gear);
one!("cmdline_bar", app.rects.cmdline_bar);
one!("palette_sidebar_button", app.rects.palette_sidebar_button);
one!(
"palette_right_panel_button",
app.rects.palette_right_panel_button
);
one!("palette_back_button", app.rects.palette_back_button);
one!("palette_forward_button", app.rects.palette_forward_button);
one!("palette_search_chip", app.rects.palette_search_chip);
one!("palette_dropdown_button", app.rects.palette_dropdown_button);
one!(
"palette_add_integration_button",
app.rects.palette_add_integration_button
);
one!("right_panel_edge", app.rects.right_panel_edge);
one!("right_panel_close", app.rects.right_panel_close);
one!(
"right_panel_empty_outline",
app.rects.right_panel_empty_outline
);
one!(
"right_panel_empty_diagnostics",
app.rects.right_panel_empty_diagnostics
);
one!("right_panel_empty_ai", app.rects.right_panel_empty_ai);
one!("right_panel_empty_grep", app.rects.right_panel_empty_grep);
one!("right_panel_empty_test", app.rects.right_panel_empty_test);
one!(
"bufferline_new_tab_button",
app.rects.bufferline_new_tab_button
);
one!("bufferline_theme_toggle", app.rects.bufferline_theme_toggle);
one!("bufferline_window_close", app.rects.bufferline_window_close);
for (rect, leaf_key, is_left) in &app.rects.leaf_tab_arrows {
let side = if *is_left { "left" } else { "right" };
one!(&format!("leaf_tab_arrow_{side}_{leaf_key}"), Some(*rect));
}
one!("settings_save_button", app.rects.settings_save_button);
one!("settings_cancel_button", app.rects.settings_cancel_button);
one!("settings_filter_row", app.rects.settings_filter_row);
one!(
"todos_panel_filter_input",
app.rects.todos_panel_filter_input
);
one!(
"notes_panel_filter_input",
app.rects.notes_panel_filter_input
);
one!(
"sessions_panel_filter_input",
app.rects.sessions_panel_filter_input
);
one!("session_new_chip", app.rects.session_new_chip);
one!("notes_panel_new_chip", app.rects.notes_panel_new_chip);
one!(
"findings_panel_filter_input",
app.rects.findings_panel_filter_input
);
one!("agents_panel_new_chip", app.rects.agents_panel_new_chip);
one!("agents_panel_pr_chip", app.rects.agents_panel_pr_chip);
one!(
"agents_panel_filter_input",
app.rects.agents_panel_filter_input
);
one!(
"cloud_agents_new_run_button",
app.rects.cloud_agents_new_run_button
);
one!(
"cloud_agents_filter_input",
app.rects.cloud_agents_filter_input
);
one!("http_panel_new_chip", app.rects.http_panel_new_chip);
one!("http_panel_env_new_chip", app.rects.http_panel_env_new_chip);
one!(
"http_panel_chain_new_chip",
app.rects.http_panel_chain_new_chip
);
one!(
"http_panel_collection_new_chip",
app.rects.http_panel_collection_new_chip
);
one!("http_panel_filter_input", app.rects.http_panel_filter_input);
one!(
"git_palette_filter_input",
app.rects.git_palette_filter_input
);
one!(
"git_palette_refresh_chip",
app.rects.git_palette_refresh_chip
);
one!(
"notes_panel_refresh_chip",
app.rects.notes_panel_refresh_chip
);
one!(
"findings_panel_refresh_chip",
app.rects.findings_panel_refresh_chip
);
one!(
"agents_panel_refresh_chip",
app.rects.agents_panel_refresh_chip
);
one!(
"todos_panel_refresh_chip",
app.rects.todos_panel_refresh_chip
);
one!(
"cloud_agents_refresh_chip",
app.rects.cloud_agents_refresh_chip
);
for (r, _leaf, tag) in &app.rects.split_strip_ai_buttons {
let label = if *tag == 1 {
"split_strip_ai_codex"
} else {
"split_strip_ai_claude"
};
push_rect(&mut out, &mut first, label, *r);
}
for (r, idx) in &app.rects.settings_rows {
push_rect(&mut out, &mut first, &format!("settings_row:{idx}"), *r);
}
for (r, code) in &app.rects.quit_prompt_buttons {
push_rect(
&mut out,
&mut first,
&format!("quit_prompt_button:{code}"),
*r,
);
}
for (r, code) in &app.rects.confirm_dialog_buttons {
push_rect(
&mut out,
&mut first,
&format!("confirm_dialog_button:{code}"),
*r,
);
}
for (r, code) in &app.rects.close_prompt_buttons {
push_rect(
&mut out,
&mut first,
&format!("close_prompt_button:{code}"),
*r,
);
}
for (r, _) in &app.rects.bufferline_tab_close {
push_rect(&mut out, &mut first, "bufferline_tab_close", *r);
}
for (r, i) in &app.rects.bufferline_tab_page_chips {
push_rect(&mut out, &mut first, &format!("tab_page:{i}"), *r);
}
for (r, i) in &app.rects.bufferline_tab_page_close {
push_rect(&mut out, &mut first, &format!("tab_page_close:{i}"), *r);
}
for (r, i) in &app.rects.menu_bar_words {
push_rect(&mut out, &mut first, &format!("menu_bar:{i}"), *r);
}
if let Some((r, _)) = app.rects.menu_bar_overflow {
push_rect(&mut out, &mut first, "menu_bar_overflow", r);
}
if let Some(r) = app.rects.hover_help_strip {
push_rect(&mut out, &mut first, "hover_help_strip", r);
}
if let Some(r) = app.rects.hover_help_kebab {
push_rect(&mut out, &mut first, "hover_help_kebab", r);
}
for (r, hit) in &app.rects.first_launch_hits {
let label = match hit {
crate::ui::first_launch_overlay::FirstLaunchHit::NerdFontOk(ok) => {
if *ok {
"first_launch:nerd_font_yes"
} else {
"first_launch:nerd_font_no"
}
}
};
push_rect(&mut out, &mut first, label, *r);
}
for (r, label) in &app.rects.tree_icon_buttons {
push_rect(&mut out, &mut first, &format!("tree_icon:{label}"), *r);
}
for (r, idx) in &app.rects.marketplace_row_rects {
push_rect(&mut out, &mut first, &format!("marketplace_row:{idx}"), *r);
}
for (r, idx) in &app.rects.integration_icon_rects {
push_rect(&mut out, &mut first, &format!("integration:{idx}"), *r);
}
for (r, section) in &app.rects.activity_bar_icons {
push_rect(&mut out, &mut first, &format!("activity:{section:?}"), *r);
}
for (r, idx) in &app.rects.extra_workspace_toggles {
push_rect(
&mut out,
&mut first,
&format!("extra_workspace_toggle:{idx}"),
*r,
);
}
for (r, _) in &app.rects.git_rail_rows {
push_rect(&mut out, &mut first, "git_rail_row", *r);
}
for (r, id) in &app.rects.bufferline_tabs {
push_rect(&mut out, &mut first, &format!("bufferline_tab:{id}"), *r);
}
for (r, _, tab_pane) in &app.rects.split_tab_chips {
push_rect(
&mut out,
&mut first,
&format!("split_tab_chip:{tab_pane}"),
*r,
);
}
for (r, _, tab_pane) in &app.rects.split_tab_close {
push_rect(
&mut out,
&mut first,
&format!("split_tab_close:{tab_pane}"),
*r,
);
}
for (r, leaf_pane) in &app.rects.split_tab_plus_buttons {
push_rect(
&mut out,
&mut first,
&format!("split_tab_plus:{leaf_pane}"),
*r,
);
}
for (r, id) in &app.rects.pane_bodies {
push_rect(&mut out, &mut first, &format!("pane_body:{id}"), *r);
}
for hit in &app.rects.scrollbars {
push_rect(
&mut out,
&mut first,
&format!("scrollbar:{:?}", hit.kind),
hit.area,
);
}
for (r, idx) in &app.rects.right_panel_tabs {
push_rect(&mut out, &mut first, &format!("right_panel_tab:{idx}"), *r);
}
for (r, leaf_active, dir) in &app.rects.split_strip_buttons {
let la = match leaf_active {
Some(pid) => pid.to_string(),
None => "none".to_string(),
};
push_rect(
&mut out,
&mut first,
&format!("split_strip:{la}:{dir:?}"),
*r,
);
}
for (r, leaf_active) in &app.rects.split_strip_term_buttons {
let label = match leaf_active {
Some(pid) => format!("split_strip_term:{pid}"),
None => "split_strip_term:none".to_string(),
};
push_rect(&mut out, &mut first, &label, *r);
}
for (r, leaf_active) in &app.rects.split_strip_maximize_buttons {
let label = match leaf_active {
Some(pid) => format!("split_strip_maximize:{pid}"),
None => "split_strip_maximize:none".to_string(),
};
push_rect(&mut out, &mut first, &label, *r);
}
if let Some(r) = app.rects.picker_box {
push_rect(&mut out, &mut first, "picker_box", r);
}
for (r, idx) in &app.rects.picker_items {
push_rect(&mut out, &mut first, &format!("picker_item:{idx}"), *r);
}
for (r, pid, field) in &app.rects.request_fields {
push_rect(
&mut out,
&mut first,
&format!("request_field:{pid}:{field:?}"),
*r,
);
}
for (r, idx) in &app.rects.context_menu_items {
push_rect(
&mut out,
&mut first,
&format!("context_menu_item:{idx}"),
*r,
);
}
for (r, pid, tab) in &app.rects.request_edit_tabs {
push_rect(
&mut out,
&mut first,
&format!("request_edit_tab:{pid}:{tab:?}"),
*r,
);
}
for (r, pid, view) in &app.rects.request_tabs {
push_rect(
&mut out,
&mut first,
&format!("request_tab:{pid}:{view:?}"),
*r,
);
}
if let Some(r) = app.rects.request_edit_split_chip {
push_rect(&mut out, &mut first, "request_edit_split_chip", r);
}
if let Some(r) = app.rects.request_edit_split_divider {
push_rect(&mut out, &mut first, "request_edit_split_divider", r);
}
if let Some(r) = app.rects.request_split_toggle {
push_rect(&mut out, &mut first, "request_split_toggle", r);
}
for (r, pid, tab) in &app.rects.request_edit_tabs_split {
push_rect(
&mut out,
&mut first,
&format!("request_edit_tab_split:{pid}:{tab:?}"),
*r,
);
}
for (r, name) in &app.rects.request_var_click_rects {
push_rect(&mut out, &mut first, &format!("request_var:{name}"), *r);
}
out.push_str("\n]");
out
}
pub fn drain_commands(ipc: &mut Ipc, app: &mut App) -> bool {
let cmds = ipc.poll();
let any = !cmds.is_empty();
for c in &cmds {
let ev = apply(app, c);
ipc.append_event(&ev);
if matches!(c, IpcCommand::Snapshot) {
force_snapshot_paint(ipc, app);
}
}
any
}
fn force_snapshot_paint(ipc: &Ipc, app: &mut App) {
let (w, h) = ipc
.last_backend_size
.get()
.unwrap_or((DEFAULT_HEADLESS_W, DEFAULT_HEADLESS_H));
let backend = ratatui::backend::TestBackend::new(w, h);
let mut term = match ratatui::Terminal::new(backend) {
Ok(t) => t,
Err(_) => return,
};
if term.draw(|f| crate::ui::draw(f, app)).is_ok() {
dump_screen_status(ipc, term.backend().buffer(), app);
}
}
pub fn drain_plugin_events(ipc: &Ipc, app: &mut App) {
for id in app.take_pending_plugin_invocations() {
ipc.append_event(&json_event(&[("event", "plugin-command"), ("id", &id)]));
}
}
pub fn screen_to_text(buf: &ratatui::buffer::Buffer) -> String {
let area = buf.area;
let mut out = String::with_capacity((area.width as usize + 1) * area.height as usize);
for y in area.top()..area.bottom() {
let mut row = String::with_capacity(area.width as usize);
for x in area.left()..area.right() {
row.push_str(buf[(x, y)].symbol());
}
out.push_str(row.trim_end());
out.push('\n');
}
out
}
pub fn status_json(app: &App) -> String {
let focus = match app.focus {
crate::focus::Focus::Tree => "tree",
crate::focus::Focus::Pane => "pane",
crate::focus::Focus::RightPanel => "right_panel",
crate::focus::Focus::BottomPanel => "bottom_panel",
};
let active_file = app
.active_editor()
.and_then(|b| b.path.clone())
.map(|p| p.display().to_string())
.unwrap_or_default();
let (cur_row, cur_col) = app
.active_editor()
.map(|b| {
let (r, c) = b.editor.row_col();
(r + 1, c + 1)
})
.unwrap_or((0, 0));
let mode = app.editing_mode().label().unwrap_or("none");
let tree_cursor = app.tree.cursor();
let tree_sel = app
.tree
.selected_row()
.map(|r| r.path.display().to_string())
.unwrap_or_default();
let panes: Vec<String> = app
.panes
.iter()
.map(|p| {
format!(
"{{\"title\":{},\"dirty\":{}}}",
json_str(&p.title()),
p.is_dirty()
)
})
.collect();
let right_panel_panes_json = app
.right_panel_panes
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
format!(
"{{\"focus\":{},\"activePane\":{},\"activeFile\":{},\"cursor\":{{\"line\":{},\"col\":{}}},\"mode\":{},\"treeCursor\":{},\"treeSelection\":{},\"treeVisible\":{},\"rightPanelVisible\":{},\"rightPanelPanes\":[{}],\"rightPanelActiveIdx\":{},\"panes\":[{}],\"quit\":{}}}",
json_str(focus),
app.active
.map(|i| i.to_string())
.unwrap_or_else(|| "null".to_string()),
json_str(&active_file),
cur_row,
cur_col,
json_str(mode),
tree_cursor,
json_str(&tree_sel),
app.tree_visible,
app.right_panel_visible,
right_panel_panes_json,
app.right_panel_active_idx,
panes.join(","),
app.should_quit,
)
}
fn json_event(pairs: &[(&str, &str)]) -> String {
let body: Vec<String> = pairs
.iter()
.map(|(k, v)| format!("{}:{}", json_str(k), json_str(v)))
.collect();
format!("{{{}}}", body.join(","))
}
fn json_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
#[test]
fn parses_plugin_commands() {
assert!(matches!(
parse_command(r#"{"cmd":"register-command","id":"p.a","title":"A"}"#),
IpcCommand::RegisterCommand { .. }
));
assert!(matches!(
parse_command(r#"{"cmd":"run-command","id":"file.save"}"#),
IpcCommand::RunCommand(_)
));
assert!(matches!(
parse_command(r#"{"cmd":"type","text":"hi"}"#),
IpcCommand::Type(_)
));
assert!(matches!(
parse_command(r#"{"cmd":"run-command"}"#),
IpcCommand::Unknown(_)
));
}
#[test]
fn parse_command_covers_every_arm() {
use IpcCommand::*;
assert!(matches!(
parse_command(r#"{"cmd":"open","path":"a.txt"}"#),
Open(_)
));
assert!(matches!(parse_command(r#"{"cmd":"open"}"#), Unknown(_)));
assert!(matches!(
parse_command(r#"{"cmd":"key","key":"ctrl+s"}"#),
Key(_)
));
assert!(matches!(parse_command(r#"{"cmd":"key"}"#), Unknown(_)));
assert!(matches!(parse_command(r#"{"cmd":"type"}"#), Unknown(_)));
assert!(matches!(parse_command(r#"{"cmd":"snapshot"}"#), Snapshot));
assert!(matches!(parse_command(r#"{"cmd":"quit"}"#), Quit));
assert!(matches!(parse_command(r#"{"cmd":"restart"}"#), Restart));
assert!(matches!(
parse_command(r#"{"cmd":"ghost","text":"foo()"}"#),
Ghost(_)
));
assert!(matches!(
parse_command(r#"{"cmd":"ghost","text":""}"#),
Unknown(_)
));
assert!(matches!(parse_command(r#"{"cmd":"ghost"}"#), Unknown(_)));
assert!(matches!(parse_command(r#"{"cmd":"bogus"}"#), Unknown(_)));
assert!(matches!(parse_command("not json at all"), Unknown(_)));
assert!(matches!(parse_command("{"), Unknown(_)));
}
#[test]
fn parses_mouse_commands() {
use ratatui::crossterm::event::{KeyModifiers, MouseButton};
let cmd = parse_command(r#"{"cmd":"click","col":5,"row":10}"#);
match cmd {
IpcCommand::Click {
col,
row,
button,
mods,
} => {
assert_eq!(col, 5);
assert_eq!(row, 10);
assert!(matches!(button, MouseButton::Left));
assert_eq!(mods, KeyModifiers::NONE);
}
other => panic!("expected Click, got {other:?}"),
}
let cmd =
parse_command(r#"{"cmd":"click","col":1,"row":2,"button":"middle","mods":"ctrl"}"#);
match cmd {
IpcCommand::Click { button, mods, .. } => {
assert!(matches!(button, MouseButton::Middle));
assert_eq!(mods, KeyModifiers::CONTROL);
}
other => panic!("expected Click, got {other:?}"),
}
let cmd = parse_command(r#"{"cmd":"click","col":1,"row":2,"button":"r"}"#);
match cmd {
IpcCommand::Click { button, .. } => assert!(matches!(button, MouseButton::Right)),
other => panic!("expected Click, got {other:?}"),
}
assert!(matches!(
parse_command(r#"{"cmd":"hover","col":0,"row":0}"#),
IpcCommand::Hover { col: 0, row: 0 }
));
match parse_command(r#"{"cmd":"scroll","col":3,"row":4}"#) {
IpcCommand::Scroll { col, row, dy } => {
assert_eq!(col, 3);
assert_eq!(row, 4);
assert_eq!(dy, 1);
}
other => panic!("expected Scroll, got {other:?}"),
}
match parse_command(r#"{"cmd":"scroll","col":0,"row":0,"dy":-3}"#) {
IpcCommand::Scroll { dy, .. } => assert_eq!(dy, -3),
other => panic!("expected Scroll, got {other:?}"),
}
assert!(matches!(
parse_command(r#"{"cmd":"click","col":5}"#),
IpcCommand::Unknown(_)
));
assert!(matches!(
parse_command(r#"{"cmd":"hover","row":5}"#),
IpcCommand::Unknown(_)
));
}
#[test]
fn parse_mods_handles_aliases_and_combinations() {
use ratatui::crossterm::event::KeyModifiers;
assert_eq!(
parse_mods(Some("ctrl,shift")),
KeyModifiers::CONTROL | KeyModifiers::SHIFT
);
assert_eq!(parse_mods(Some("alt")), KeyModifiers::ALT);
assert_eq!(parse_mods(Some("option")), KeyModifiers::ALT);
assert_eq!(parse_mods(Some("cmd")), KeyModifiers::SUPER);
assert_eq!(parse_mods(Some("meta")), KeyModifiers::SUPER);
assert_eq!(
parse_mods(Some("Ctrl,Hyper,Alt")),
KeyModifiers::CONTROL | KeyModifiers::ALT
);
assert_eq!(parse_mods(None), KeyModifiers::NONE);
}
#[test]
fn ipc_init_skips_gitignore_outside_git_repo() {
let dir = tempfile::tempdir().unwrap();
let _ipc = Ipc::init(dir.path()).unwrap();
assert!(!dir.path().join(".gitignore").exists());
}
#[test]
fn ipc_init_creates_gitignore_in_git_repo() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let _ipc = Ipc::init(dir.path()).unwrap();
let gi = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(
gi.contains(".mnml/"),
"gitignore should mention .mnml/: {gi}"
);
assert!(
gi.contains("Added by mnml"),
"should include header comment"
);
}
#[test]
fn ipc_init_appends_to_existing_gitignore() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let gi = dir.path().join(".gitignore");
std::fs::write(&gi, "target/\n").unwrap();
let _ipc = Ipc::init(dir.path()).unwrap();
let content = std::fs::read_to_string(&gi).unwrap();
assert!(content.contains("target/"), "existing entries preserved");
assert!(content.contains(".mnml/"), "new entry added");
}
#[test]
fn ipc_init_is_idempotent_on_gitignore() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let gi = dir.path().join(".gitignore");
std::fs::write(&gi, "target/\n.mnml/\n").unwrap();
let _ipc = Ipc::init(dir.path()).unwrap();
let content = std::fs::read_to_string(&gi).unwrap();
assert_eq!(
content.matches(".mnml/").count(),
1,
"second init should not append duplicate; content:\n{content}"
);
}
#[test]
fn ipc_write_screen_defaults_off_and_parses_the_opt_in() {
assert!(!crate::config::Config::default().ipc.write_screen);
let d = tempfile::tempdir().unwrap();
std::fs::create_dir_all(d.path().join(".mnml")).unwrap();
std::fs::write(
d.path().join(".mnml/config.toml"),
"[ipc]\nwrite_screen = true\n",
)
.unwrap();
let cfg = crate::config::Config::load(None, d.path());
assert!(cfg.ipc.write_screen, "[ipc] write_screen = true ignored");
}
#[test]
#[cfg(unix)]
fn ipc_init_refuses_to_write_through_a_symlink() {
let outside = tempfile::tempdir().unwrap();
let victim = outside.path().join("victim.txt");
std::fs::write(&victim, "IMPORTANT").unwrap();
let ws = tempfile::tempdir().unwrap();
let ipc_dir = ws.path().join(".mnml/ipc");
std::fs::create_dir_all(&ipc_dir).unwrap();
std::os::unix::fs::symlink(&victim, ipc_dir.join("screen.txt")).unwrap();
let ipc = Ipc::init(ws.path()).unwrap();
ipc.write_screen("rendered UI with an open .env in it");
assert_eq!(
std::fs::read_to_string(&victim).unwrap(),
"IMPORTANT",
"must not write through the symlink"
);
assert!(
!std::fs::symlink_metadata(ipc_dir.join("screen.txt"))
.unwrap()
.file_type()
.is_symlink(),
"symlink should have been replaced by a regular file"
);
}
#[test]
#[cfg(unix)]
fn gitignore_symlink_is_left_alone() {
let outside = tempfile::tempdir().unwrap();
let victim = outside.path().join("real-gitignore");
std::fs::write(&victim, "original\n").unwrap();
let ws = tempfile::tempdir().unwrap();
std::fs::create_dir(ws.path().join(".git")).unwrap();
std::os::unix::fs::symlink(&victim, ws.path().join(".gitignore")).unwrap();
ensure_workspace_gitignore(ws.path()).unwrap();
assert_eq!(
std::fs::read_to_string(&victim).unwrap(),
"original\n",
"must not rewrite through the symlink"
);
}
#[test]
fn rqst_dir_is_ignored_once_it_exists() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let gi = dir.path().join(".gitignore");
ensure_workspace_gitignore(dir.path()).unwrap();
let first = std::fs::read_to_string(&gi).unwrap();
assert!(first.contains(".mnml/"));
assert!(
!first.contains(".rqst/"),
"shouldn't pre-emptively ignore an unused dir:\n{first}"
);
std::fs::create_dir(dir.path().join(".rqst")).unwrap();
ensure_workspace_gitignore(dir.path()).unwrap();
let second = std::fs::read_to_string(&gi).unwrap();
assert!(second.contains(".rqst/"), "content:\n{second}");
assert_eq!(second.matches(".mnml/").count(), 1, "no duplicate .mnml");
ensure_workspace_gitignore(dir.path()).unwrap();
let third = std::fs::read_to_string(&gi).unwrap();
assert_eq!(third.matches(".rqst/").count(), 1, "content:\n{third}");
}
#[test]
fn gitignore_covers_recognises_the_usual_spellings() {
for spelling in [
".rqst",
".rqst/",
"/.rqst",
"/.rqst/",
".rqst/**",
" .rqst/ ",
] {
assert!(
gitignore_covers(&format!("target/\n{spelling}\n"), ".rqst"),
"should recognise {spelling:?}"
);
}
assert!(!gitignore_covers("# .rqst/ is noisy\ntarget/\n", ".rqst"));
assert!(!gitignore_covers(".rqst-old/\n", ".rqst"));
}
#[test]
fn poll_reads_complete_lines_then_advances_the_offset() {
let dir = tempfile::tempdir().unwrap();
let mut ipc = Ipc::init(dir.path()).unwrap();
std::fs::write(
&ipc.cmd_path,
"{\"cmd\":\"quit\"}\n{\"cmd\":\"snapshot\"}\n",
)
.unwrap();
assert_eq!(ipc.poll().len(), 2);
assert!(ipc.poll().is_empty());
}
#[test]
fn poll_holds_a_partial_line_until_its_newline_arrives() {
let dir = tempfile::tempdir().unwrap();
let mut ipc = Ipc::init(dir.path()).unwrap();
std::fs::write(&ipc.cmd_path, "{\"cmd\":\"quit\"}").unwrap();
assert!(ipc.poll().is_empty(), "partial line must not be consumed");
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&ipc.cmd_path)
.unwrap();
f.write_all(b"\n").unwrap();
drop(f);
assert_eq!(ipc.poll().len(), 1);
}
#[test]
fn poll_skips_blank_lines() {
let dir = tempfile::tempdir().unwrap();
let mut ipc = Ipc::init(dir.path()).unwrap();
std::fs::write(&ipc.cmd_path, "\n\n{\"cmd\":\"quit\"}\n\n").unwrap();
assert_eq!(ipc.poll().len(), 1);
}
#[test]
fn poll_resets_when_the_command_file_is_truncated() {
let dir = tempfile::tempdir().unwrap();
let mut ipc = Ipc::init(dir.path()).unwrap();
std::fs::write(
&ipc.cmd_path,
"{\"cmd\":\"snapshot\"}\n{\"cmd\":\"snapshot\"}\n",
)
.unwrap();
assert_eq!(ipc.poll().len(), 2);
std::fs::write(&ipc.cmd_path, "{\"cmd\":\"quit\"}\n").unwrap();
assert_eq!(ipc.poll().len(), 1);
}
#[test]
fn apply_restart_sets_both_restart_and_quit() {
let dir = tempfile::tempdir().unwrap();
let mut app = App::new(dir.path().to_path_buf(), Config::default()).unwrap();
apply(&mut app, &IpcCommand::Restart);
assert!(app.should_quit);
assert!(app.restart_requested);
}
#[test]
fn json_str_escapes_the_dangerous_characters() {
assert_eq!(json_str("plain"), "\"plain\"");
assert_eq!(json_str("a\"b"), "\"a\\\"b\"");
assert_eq!(json_str("a\\b"), "\"a\\\\b\"");
assert_eq!(json_str("line\nbreak"), "\"line\\nbreak\"");
assert_eq!(json_str("tab\there"), "\"tab\\there\"");
assert_eq!(json_str("\u{1}"), "\"\\u0001\"");
}
#[test]
fn screen_to_text_trims_trailing_space_and_joins_rows() {
let buf = ratatui::buffer::Buffer::with_lines(["ab ", "cd"]);
assert_eq!(screen_to_text(&buf), "ab\ncd\n");
}
#[test]
fn toast_parses_optional_level() {
let cmd = parse_command(r#"{"cmd":"toast","text":"hi"}"#);
match cmd {
IpcCommand::Toast(t, l) => {
assert_eq!(t, "hi");
assert_eq!(l, crate::app::ToastLevel::Info);
}
other => panic!("expected Toast, got {other:?}"),
}
let cmd = parse_command(r#"{"cmd":"toast","text":"broke","level":"error"}"#);
assert!(matches!(
cmd,
IpcCommand::Toast(_, crate::app::ToastLevel::Error)
));
let cmd = parse_command(r#"{"cmd":"toast","text":"x","level":"neon"}"#);
assert!(matches!(
cmd,
IpcCommand::Toast(_, crate::app::ToastLevel::Info)
));
}
#[test]
fn notify_fires_toast_and_queues_os_notification_when_no_source() {
let dir = tempfile::tempdir().unwrap();
let mut app = App::new(dir.path().to_path_buf(), Config::default()).unwrap();
apply(
&mut app,
&IpcCommand::Notify {
title: "Slack".into(),
body: "@chris mentioned you".into(),
level: crate::app::ToastLevel::Info,
sound: false,
source: None,
},
);
assert!(app.toast_stack.iter().any(|t| t.text.contains("mentioned")));
assert_eq!(app.pending_os_notifications.len(), 1);
let (title, body, sound) = &app.pending_os_notifications[0];
assert_eq!(title, "Slack");
assert_eq!(body, "@chris mentioned you");
assert!(!sound);
}
#[test]
fn notify_respects_never_policy() {
use crate::integration_manifest::{IntegrationManifest, NotificationsSpec, OsNotifyPolicy};
let dir = tempfile::tempdir().unwrap();
let mut app = App::new(dir.path().to_path_buf(), Config::default()).unwrap();
app.integration_manifests.push(IntegrationManifest {
id: "silent".into(),
label: "Silent".into(),
description: None,
version: None,
binary: Some("mnml-silent".into()),
category: None,
chip: None,
commands: vec![],
context_menu: vec![],
menu_bar: vec![],
statusline: None,
values_sources: vec![],
statusline_segments: vec![],
settings: vec![],
notifications: Some(NotificationsSpec {
os_notify_on: OsNotifyPolicy::Never,
os_rate_limit_sec: 0,
}),
requires: None,
auth: vec![],
prefetch: vec![],
source_path: std::path::PathBuf::new(),
homepage: None,
docs: None,
repository: None,
author: None,
override_env: std::collections::HashMap::new(),
override_auth_values: std::collections::HashMap::new(),
auto_update_override: None,
});
apply(
&mut app,
&IpcCommand::Notify {
title: "Silent".into(),
body: "hi".into(),
level: crate::app::ToastLevel::Info,
sound: false,
source: Some("silent".into()),
},
);
assert_eq!(app.toast_stack.len(), 1);
assert_eq!(app.pending_os_notifications.len(), 0);
}
#[test]
fn statusline_segment_round_trip() {
let dir = tempfile::tempdir().unwrap();
let mut app = App::new(dir.path().to_path_buf(), Config::default()).unwrap();
apply(
&mut app,
&IpcCommand::StatuslineSetSegment {
id: "slack.status".into(),
side: crate::app::SegmentSide::Right,
text: "◇ 3 dms".into(),
color: Some("purple".into()),
click_command: Some("slack.open".into()),
priority: 150,
min_width: 4,
max_width: 20,
},
);
assert_eq!(app.dynamic_segments.len(), 1);
assert_eq!(app.dynamic_segments[0].text, "◇ 3 dms");
assert_eq!(app.dynamic_segments[0].priority, 150);
apply(
&mut app,
&IpcCommand::StatuslineSetSegment {
id: "slack.status".into(),
side: crate::app::SegmentSide::Right,
text: "◇ 5 dms".into(),
color: Some("red".into()),
click_command: None,
priority: 200,
min_width: 4,
max_width: 20,
},
);
assert_eq!(app.dynamic_segments.len(), 1);
assert_eq!(app.dynamic_segments[0].text, "◇ 5 dms");
assert_eq!(app.dynamic_segments[0].color.as_deref(), Some("red"));
apply(
&mut app,
&IpcCommand::StatuslineClearSegment {
id: "slack.status".into(),
},
);
assert!(app.dynamic_segments.is_empty());
}
#[test]
fn progress_start_update_end_round_trip() {
let dir = tempfile::tempdir().unwrap();
let mut app = App::new(dir.path().to_path_buf(), Config::default()).unwrap();
apply(
&mut app,
&IpcCommand::ProgressStart {
id: "build.1".into(),
label: "Compiling…".into(),
},
);
assert_eq!(app.progress_items.len(), 1);
assert_eq!(app.progress_items[0].label, "Compiling…");
assert_eq!(app.progress_items[0].percent, None);
apply(
&mut app,
&IpcCommand::ProgressUpdate {
id: "build.1".into(),
label: Some("Linking…".into()),
percent: Some(75),
},
);
assert_eq!(app.progress_items[0].label, "Linking…");
assert_eq!(app.progress_items[0].percent, Some(75));
apply(
&mut app,
&IpcCommand::ProgressUpdate {
id: "build.1".into(),
label: None,
percent: Some(200),
},
);
assert_eq!(app.progress_items[0].percent, Some(100));
apply(
&mut app,
&IpcCommand::ProgressEnd {
id: "build.1".into(),
status: crate::app::ProgressStatus::Failed,
},
);
assert!(app.progress_items[0].finished.is_some());
assert!(
app.toast_stack
.iter()
.any(|t| t.level == crate::app::ToastLevel::Error)
);
}
#[test]
fn toast_persistent_and_dismiss_round_trip() {
let dir = tempfile::tempdir().unwrap();
let mut app = App::new(dir.path().to_path_buf(), Config::default()).unwrap();
apply(
&mut app,
&IpcCommand::ToastPersistent {
id: "job.1".into(),
text: "running…".into(),
level: crate::app::ToastLevel::Info,
},
);
assert_eq!(app.persistent_toasts.len(), 1);
assert_eq!(app.persistent_toasts[0].text, "running…");
apply(
&mut app,
&IpcCommand::ToastPersistent {
id: "job.1".into(),
text: "still going…".into(),
level: crate::app::ToastLevel::Warn,
},
);
assert_eq!(app.persistent_toasts.len(), 1);
assert_eq!(app.persistent_toasts[0].text, "still going…");
assert_eq!(app.persistent_toasts[0].level, crate::app::ToastLevel::Warn);
apply(&mut app, &IpcCommand::ToastDismiss("job.1".into()));
assert!(app.persistent_toasts.is_empty());
}
#[test]
fn plugin_command_round_trip() {
let dir = tempfile::tempdir().unwrap();
let ipc = Ipc::init(dir.path()).unwrap();
let mut app = App::new(dir.path().to_path_buf(), Config::default()).unwrap();
apply(
&mut app,
&IpcCommand::RegisterCommand {
id: "plugin.x".into(),
title: "X".into(),
group: "plugin".into(),
keys: vec![],
},
);
assert!(app.dynamic_commands.iter().any(|c| c.id == "plugin.x"));
assert!(crate::command::run("plugin.x", &mut app));
drain_plugin_events(&ipc, &mut app);
let log = std::fs::read_to_string(dir.path().join(".mnml/ipc/events.jsonl")).unwrap();
assert!(log.contains(r#""event":"plugin-command""#), "log: {log}");
assert!(log.contains(r#""id":"plugin.x""#), "log: {log}");
}
}