use super::Editor;
use crate::{
config::{Config, DirectoryContext},
model::control_event::EventBroadcaster,
};
use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::{backend::TestBackend, Terminal};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
io::{self, BufRead, Write},
path::PathBuf,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ScriptCommand {
Render,
Key {
code: String,
#[serde(default)]
modifiers: Vec<String>,
},
MouseClick {
col: u16,
row: u16,
#[serde(default = "default_mouse_button")]
button: String,
},
MouseDrag {
start_col: u16,
start_row: u16,
end_col: u16,
end_row: u16,
#[serde(default = "default_mouse_button")]
button: String,
},
MouseScroll {
col: u16,
row: u16,
direction: String,
#[serde(default = "default_scroll_amount")]
amount: u16,
},
Resize {
width: u16,
height: u16,
},
Status,
GetBuffer,
OpenFile {
path: String,
},
TypeText {
text: String,
},
Quit,
ExportTest {
test_name: String,
},
WaitFor {
condition: WaitCondition,
#[serde(default = "default_wait_timeout")]
timeout_ms: u64,
#[serde(default = "default_poll_interval")]
poll_interval_ms: u64,
},
GetKeybindings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WaitCondition {
Event {
name: String,
#[serde(default)]
data: Value,
},
ScreenContains { text: String },
ScreenNotContains { text: String },
BufferContains { text: String },
PopupVisible,
PopupHidden,
}
fn default_mouse_button() -> String {
"left".to_string()
}
fn default_scroll_amount() -> u16 {
3
}
fn default_wait_timeout() -> u64 {
5000
}
fn default_poll_interval() -> u64 {
100
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ScriptResponse {
Screen {
content: String,
width: u16,
height: u16,
cursor: (u16, u16),
},
Status {
cursor_position: usize,
cursor_count: usize,
has_selection: bool,
buffer_len: usize,
file_path: Option<String>,
is_modified: bool,
},
Buffer {
content: String,
},
Ok {
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
},
Error {
message: String,
},
TestCode {
code: String,
},
Keybindings {
bindings: Vec<KeybindingEntry>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeybindingEntry {
pub key: String,
pub action: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InteractionRecord {
pub command: ScriptCommand,
pub timestamp_ms: u64,
}
pub struct ScriptControlMode {
editor: Editor,
terminal: Terminal<TestBackend>,
interactions: Vec<InteractionRecord>,
start_time: std::time::Instant,
}
impl ScriptControlMode {
pub fn new(width: u16, height: u16, dir_context: DirectoryContext) -> io::Result<Self> {
let backend = TestBackend::new(width, height);
let terminal = Terminal::new(backend)?;
let config = Config::default();
let editor = Editor::new(config, width, height, dir_context)?;
Ok(Self {
editor,
terminal,
interactions: Vec::new(),
start_time: std::time::Instant::now(),
})
}
pub fn with_working_dir(
width: u16,
height: u16,
working_dir: PathBuf,
dir_context: DirectoryContext,
) -> io::Result<Self> {
let backend = TestBackend::new(width, height);
let terminal = Terminal::new(backend)?;
let config = Config::default();
let editor =
Editor::with_working_dir(config, width, height, Some(working_dir), dir_context)?;
Ok(Self {
editor,
terminal,
interactions: Vec::new(),
start_time: std::time::Instant::now(),
})
}
pub fn event_broadcaster(&self) -> &EventBroadcaster {
self.editor.event_broadcaster()
}
pub fn open_file(&mut self, path: &PathBuf) -> io::Result<()> {
self.editor.open_file(path)?;
let _ = self.editor.process_async_messages();
self.render_to_terminal()?;
Ok(())
}
pub fn goto_line_col(&mut self, line: usize, column: Option<usize>) {
self.editor.goto_line_col(line, column);
}
pub fn run(&mut self) -> io::Result<()> {
let stdin = io::stdin();
let mut stdout = io::stdout();
self.render_to_terminal()?;
let ready_response = ScriptResponse::Ok {
message: Some("Script Control Mode ready. Send JSON commands to stdin.".to_string()),
};
writeln!(stdout, "{}", serde_json::to_string(&ready_response)?)?;
stdout.flush()?;
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
let response = ScriptResponse::Error {
message: format!("Failed to read line: {}", e),
};
writeln!(stdout, "{}", serde_json::to_string(&response)?)?;
stdout.flush()?;
continue;
}
};
if line.trim().is_empty() {
continue;
}
let command: ScriptCommand = match serde_json::from_str(&line) {
Ok(cmd) => cmd,
Err(e) => {
let response = ScriptResponse::Error {
message: format!("Failed to parse command: {}", e),
};
writeln!(stdout, "{}", serde_json::to_string(&response)?)?;
stdout.flush()?;
continue;
}
};
tracing::trace!(
"script_control: received command {:?}",
std::mem::discriminant(&command)
);
self.record_interaction(command.clone());
let response = self.execute_command(command)?;
writeln!(stdout, "{}", serde_json::to_string(&response)?)?;
stdout.flush()?;
if self.editor.should_quit() {
break;
}
}
Ok(())
}
fn record_interaction(&mut self, command: ScriptCommand) {
let timestamp_ms = self.start_time.elapsed().as_millis() as u64;
self.interactions.push(InteractionRecord {
command,
timestamp_ms,
});
}
fn execute_command(&mut self, command: ScriptCommand) -> io::Result<ScriptResponse> {
match command {
ScriptCommand::Render => self.handle_render(),
ScriptCommand::Key { code, modifiers } => self.handle_key(&code, &modifiers),
ScriptCommand::MouseClick { col, row, button } => {
self.handle_mouse_click(col, row, &button)
}
ScriptCommand::MouseDrag {
start_col,
start_row,
end_col,
end_row,
button,
} => self.handle_mouse_drag(start_col, start_row, end_col, end_row, &button),
ScriptCommand::MouseScroll {
col,
row,
direction,
amount,
} => self.handle_mouse_scroll(col, row, &direction, amount),
ScriptCommand::Resize { width, height } => self.handle_resize(width, height),
ScriptCommand::Status => self.handle_status(),
ScriptCommand::GetBuffer => self.handle_get_buffer(),
ScriptCommand::OpenFile { path } => self.handle_open_file(&path),
ScriptCommand::TypeText { text } => self.handle_type_text(&text),
ScriptCommand::Quit => self.handle_quit(),
ScriptCommand::ExportTest { test_name } => self.handle_export_test(&test_name),
ScriptCommand::WaitFor {
condition,
timeout_ms,
poll_interval_ms,
} => self.handle_wait_for(condition, timeout_ms, poll_interval_ms),
ScriptCommand::GetKeybindings => self.handle_get_keybindings(),
}
}
fn render_to_terminal(&mut self) -> io::Result<()> {
self.terminal.draw(|frame| {
self.editor.render(frame);
})?;
Ok(())
}
fn screen_to_string(&self) -> String {
let buffer = self.terminal.backend().buffer();
let (width, height) = (buffer.area.width, buffer.area.height);
let mut result = String::new();
for y in 0..height {
for x in 0..width {
let pos = buffer.index_of(x, y);
if let Some(cell) = buffer.content.get(pos) {
result.push_str(cell.symbol());
}
}
if y < height - 1 {
result.push('\n');
}
}
result
}
fn cursor_position(&mut self) -> io::Result<(u16, u16)> {
let pos = self.terminal.get_cursor_position()?;
Ok((pos.x, pos.y))
}
fn handle_render(&mut self) -> io::Result<ScriptResponse> {
self.render_to_terminal()?;
let content = self.screen_to_string();
let cursor = self.cursor_position()?;
let size = self.terminal.size()?;
Ok(ScriptResponse::Screen {
content,
width: size.width,
height: size.height,
cursor,
})
}
fn parse_key_code(code: &str) -> Result<KeyCode, String> {
match code.to_lowercase().as_str() {
"backspace" => Ok(KeyCode::Backspace),
"enter" | "return" => Ok(KeyCode::Enter),
"left" => Ok(KeyCode::Left),
"right" => Ok(KeyCode::Right),
"up" => Ok(KeyCode::Up),
"down" => Ok(KeyCode::Down),
"home" => Ok(KeyCode::Home),
"end" => Ok(KeyCode::End),
"pageup" | "page_up" => Ok(KeyCode::PageUp),
"pagedown" | "page_down" => Ok(KeyCode::PageDown),
"tab" => Ok(KeyCode::Tab),
"backtab" => Ok(KeyCode::BackTab),
"delete" | "del" => Ok(KeyCode::Delete),
"insert" | "ins" => Ok(KeyCode::Insert),
"escape" | "esc" => Ok(KeyCode::Esc),
"space" => Ok(KeyCode::Char(' ')),
"f1" => Ok(KeyCode::F(1)),
"f2" => Ok(KeyCode::F(2)),
"f3" => Ok(KeyCode::F(3)),
"f4" => Ok(KeyCode::F(4)),
"f5" => Ok(KeyCode::F(5)),
"f6" => Ok(KeyCode::F(6)),
"f7" => Ok(KeyCode::F(7)),
"f8" => Ok(KeyCode::F(8)),
"f9" => Ok(KeyCode::F(9)),
"f10" => Ok(KeyCode::F(10)),
"f11" => Ok(KeyCode::F(11)),
"f12" => Ok(KeyCode::F(12)),
s if s.len() == 1 => {
let ch = s.chars().next().unwrap();
Ok(KeyCode::Char(ch))
}
_ => Err(format!("Unknown key code: {}", code)),
}
}
fn parse_modifiers(modifiers: &[String]) -> KeyModifiers {
let mut result = KeyModifiers::NONE;
for modifier in modifiers {
match modifier.to_lowercase().as_str() {
"ctrl" | "control" => result |= KeyModifiers::CONTROL,
"alt" => result |= KeyModifiers::ALT,
"shift" => result |= KeyModifiers::SHIFT,
"super" | "meta" => result |= KeyModifiers::SUPER,
_ => {}
}
}
result
}
fn handle_key(&mut self, code: &str, modifiers: &[String]) -> io::Result<ScriptResponse> {
let key_code = match Self::parse_key_code(code) {
Ok(k) => k,
Err(e) => {
return Ok(ScriptResponse::Error { message: e });
}
};
let key_modifiers = Self::parse_modifiers(modifiers);
self.editor.handle_key(key_code, key_modifiers)?;
let _ = self.editor.process_async_messages();
self.render_to_terminal()?;
Ok(ScriptResponse::Ok { message: None })
}
fn parse_mouse_button(button: &str) -> Result<MouseButton, String> {
match button.to_lowercase().as_str() {
"left" => Ok(MouseButton::Left),
"right" => Ok(MouseButton::Right),
"middle" => Ok(MouseButton::Middle),
_ => Err(format!("Unknown mouse button: {}", button)),
}
}
fn handle_mouse_click(
&mut self,
col: u16,
row: u16,
button: &str,
) -> io::Result<ScriptResponse> {
let mouse_button = match Self::parse_mouse_button(button) {
Ok(b) => b,
Err(e) => {
return Ok(ScriptResponse::Error { message: e });
}
};
let mouse_down = MouseEvent {
kind: MouseEventKind::Down(mouse_button),
column: col,
row,
modifiers: KeyModifiers::empty(),
};
self.editor.handle_mouse(mouse_down)?;
let mouse_up = MouseEvent {
kind: MouseEventKind::Up(mouse_button),
column: col,
row,
modifiers: KeyModifiers::empty(),
};
self.editor.handle_mouse(mouse_up)?;
let _ = self.editor.process_async_messages();
self.render_to_terminal()?;
Ok(ScriptResponse::Ok { message: None })
}
fn handle_mouse_drag(
&mut self,
start_col: u16,
start_row: u16,
end_col: u16,
end_row: u16,
button: &str,
) -> io::Result<ScriptResponse> {
let mouse_button = match Self::parse_mouse_button(button) {
Ok(b) => b,
Err(e) => {
return Ok(ScriptResponse::Error { message: e });
}
};
let mouse_down = MouseEvent {
kind: MouseEventKind::Down(mouse_button),
column: start_col,
row: start_row,
modifiers: KeyModifiers::empty(),
};
self.editor.handle_mouse(mouse_down)?;
let steps = ((end_row as i32 - start_row as i32).abs())
.max((end_col as i32 - start_col as i32).abs())
.max(1);
for i in 1..=steps {
let t = i as f32 / steps as f32;
let col = start_col as f32 + (end_col as f32 - start_col as f32) * t;
let row = start_row as f32 + (end_row as f32 - start_row as f32) * t;
let drag_event = MouseEvent {
kind: MouseEventKind::Drag(mouse_button),
column: col as u16,
row: row as u16,
modifiers: KeyModifiers::empty(),
};
self.editor.handle_mouse(drag_event)?;
}
let mouse_up = MouseEvent {
kind: MouseEventKind::Up(mouse_button),
column: end_col,
row: end_row,
modifiers: KeyModifiers::empty(),
};
self.editor.handle_mouse(mouse_up)?;
let _ = self.editor.process_async_messages();
self.render_to_terminal()?;
Ok(ScriptResponse::Ok { message: None })
}
fn handle_mouse_scroll(
&mut self,
col: u16,
row: u16,
direction: &str,
amount: u16,
) -> io::Result<ScriptResponse> {
let scroll_kind = match direction.to_lowercase().as_str() {
"up" => MouseEventKind::ScrollUp,
"down" => MouseEventKind::ScrollDown,
_ => {
return Ok(ScriptResponse::Error {
message: format!("Unknown scroll direction: {}", direction),
});
}
};
for _ in 0..amount {
let scroll_event = MouseEvent {
kind: scroll_kind,
column: col,
row,
modifiers: KeyModifiers::empty(),
};
self.editor.handle_mouse(scroll_event)?;
}
let _ = self.editor.process_async_messages();
self.render_to_terminal()?;
Ok(ScriptResponse::Ok { message: None })
}
fn handle_resize(&mut self, width: u16, height: u16) -> io::Result<ScriptResponse> {
self.terminal.backend_mut().resize(width, height);
self.editor.resize(width, height);
self.render_to_terminal()?;
Ok(ScriptResponse::Ok { message: None })
}
fn handle_status(&mut self) -> io::Result<ScriptResponse> {
let state = self.editor.active_state();
let cursor_position = state.cursors.primary().position;
let cursor_count = state.cursors.count();
let has_selection = !state.cursors.primary().collapsed();
let buffer_len = state.buffer.len();
let is_modified = state.buffer.is_modified();
let file_path: Option<String> = None;
Ok(ScriptResponse::Status {
cursor_position,
cursor_count,
has_selection,
buffer_len,
file_path,
is_modified,
})
}
fn handle_get_buffer(&self) -> io::Result<ScriptResponse> {
let content = self
.editor
.active_state()
.buffer
.to_string()
.unwrap_or_default();
Ok(ScriptResponse::Buffer { content })
}
fn handle_open_file(&mut self, path: &str) -> io::Result<ScriptResponse> {
let path = PathBuf::from(path);
match self.editor.open_file(&path) {
Ok(_) => {
let _ = self.editor.process_async_messages();
self.render_to_terminal()?;
Ok(ScriptResponse::Ok {
message: Some(format!("Opened file: {}", path.display())),
})
}
Err(e) => Ok(ScriptResponse::Error {
message: format!("Failed to open file: {}", e),
}),
}
}
fn handle_type_text(&mut self, text: &str) -> io::Result<ScriptResponse> {
for ch in text.chars() {
self.editor
.handle_key(KeyCode::Char(ch), KeyModifiers::NONE)?;
}
let _ = self.editor.process_async_messages();
self.render_to_terminal()?;
Ok(ScriptResponse::Ok { message: None })
}
fn handle_quit(&mut self) -> io::Result<ScriptResponse> {
self.editor.quit();
Ok(ScriptResponse::Ok {
message: Some("Quitting editor".to_string()),
})
}
fn handle_export_test(&self, test_name: &str) -> io::Result<ScriptResponse> {
let code = self.generate_test_code(test_name);
Ok(ScriptResponse::TestCode { code })
}
fn handle_get_keybindings(&self) -> io::Result<ScriptResponse> {
let raw_bindings = self.editor.get_all_keybindings();
let bindings: Vec<KeybindingEntry> = raw_bindings
.into_iter()
.map(|(key, action)| KeybindingEntry { key, action })
.collect();
Ok(ScriptResponse::Keybindings { bindings })
}
fn handle_wait_for(
&mut self,
condition: WaitCondition,
timeout_ms: u64,
poll_interval_ms: u64,
) -> io::Result<ScriptResponse> {
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_millis(timeout_ms);
let poll_interval = std::time::Duration::from_millis(poll_interval_ms);
loop {
tracing::trace!("wait_for: polling loop iteration");
let _ = self.editor.process_async_messages();
self.render_to_terminal()?;
if self.check_wait_condition(&condition)? {
return Ok(ScriptResponse::Ok {
message: Some(format!(
"Condition met after {}ms",
start.elapsed().as_millis()
)),
});
}
if start.elapsed() >= timeout {
return Ok(ScriptResponse::Error {
message: format!(
"Timeout after {}ms waiting for condition: {:?}",
timeout_ms, condition
),
});
}
std::thread::sleep(poll_interval);
}
}
fn check_wait_condition(&mut self, condition: &WaitCondition) -> io::Result<bool> {
match condition {
WaitCondition::Event { name, data } => {
Ok(self.editor.event_broadcaster().has_match(name, data))
}
WaitCondition::ScreenContains { text } => {
let screen = self.screen_to_string();
Ok(screen.contains(text))
}
WaitCondition::ScreenNotContains { text } => {
let screen = self.screen_to_string();
Ok(!screen.contains(text))
}
WaitCondition::BufferContains { text } => {
let buffer = self
.editor
.active_state()
.buffer
.to_string()
.unwrap_or_default();
Ok(buffer.contains(text))
}
WaitCondition::PopupVisible => {
let state = self.editor.active_state();
Ok(state.popups.is_visible())
}
WaitCondition::PopupHidden => {
let state = self.editor.active_state();
Ok(!state.popups.is_visible())
}
}
}
fn generate_test_code(&self, test_name: &str) -> String {
let mut code = String::new();
code.push_str(&format!(
r#"#[test]
fn {}() -> std::io::Result<()> {{
let mut harness = EditorTestHarness::new(80, 24)?;
harness.render()?;
"#,
test_name
));
for interaction in &self.interactions {
match &interaction.command {
ScriptCommand::Render => {
code.push_str(" harness.render()?;\n");
}
ScriptCommand::Key {
code: key,
modifiers,
} => {
let key_code = self.key_code_to_rust_code(key);
let mods = self.modifiers_to_rust_code(modifiers);
code.push_str(&format!(" harness.send_key({}, {})?;\n", key_code, mods));
}
ScriptCommand::MouseClick {
col,
row,
button: _,
} => {
code.push_str(&format!(" harness.mouse_click({}, {})?;\n", col, row));
}
ScriptCommand::MouseDrag {
start_col,
start_row,
end_col,
end_row,
button: _,
} => {
code.push_str(&format!(
" harness.mouse_drag({}, {}, {}, {})?;\n",
start_col, start_row, end_col, end_row
));
}
ScriptCommand::TypeText { text } => {
let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
code.push_str(&format!(" harness.type_text(\"{}\")?;\n", escaped));
}
ScriptCommand::OpenFile { path } => {
let escaped = path.replace('\\', "\\\\").replace('"', "\\\"");
code.push_str(&format!(
" harness.open_file(std::path::Path::new(\"{}\"))?;\n",
escaped
));
}
ScriptCommand::Resize { width, height } => {
code.push_str(&format!(" harness.resize({}, {})?;\n", width, height));
}
_ => {
}
}
}
code.push_str(
r#"
Ok(())
}
"#,
);
code
}
fn key_code_to_rust_code(&self, code: &str) -> String {
match code.to_lowercase().as_str() {
"backspace" => "KeyCode::Backspace".to_string(),
"enter" | "return" => "KeyCode::Enter".to_string(),
"left" => "KeyCode::Left".to_string(),
"right" => "KeyCode::Right".to_string(),
"up" => "KeyCode::Up".to_string(),
"down" => "KeyCode::Down".to_string(),
"home" => "KeyCode::Home".to_string(),
"end" => "KeyCode::End".to_string(),
"pageup" | "page_up" => "KeyCode::PageUp".to_string(),
"pagedown" | "page_down" => "KeyCode::PageDown".to_string(),
"tab" => "KeyCode::Tab".to_string(),
"delete" | "del" => "KeyCode::Delete".to_string(),
"escape" | "esc" => "KeyCode::Esc".to_string(),
"space" => "KeyCode::Char(' ')".to_string(),
s if s.starts_with('f') && s.len() > 1 => {
if let Ok(num) = s[1..].parse::<u8>() {
format!("KeyCode::F({})", num)
} else {
format!("KeyCode::Char('{}')", s.chars().next().unwrap())
}
}
s if s.len() == 1 => {
let ch = s.chars().next().unwrap();
format!("KeyCode::Char('{}')", ch)
}
_ => format!("KeyCode::Char('{}')", code.chars().next().unwrap_or('?')),
}
}
fn modifiers_to_rust_code(&self, modifiers: &[String]) -> String {
if modifiers.is_empty() {
return "KeyModifiers::NONE".to_string();
}
let mut parts = Vec::new();
for modifier in modifiers {
match modifier.to_lowercase().as_str() {
"ctrl" | "control" => parts.push("KeyModifiers::CONTROL"),
"alt" => parts.push("KeyModifiers::ALT"),
"shift" => parts.push("KeyModifiers::SHIFT"),
"super" | "meta" => parts.push("KeyModifiers::SUPER"),
_ => {}
}
}
if parts.is_empty() {
"KeyModifiers::NONE".to_string()
} else if parts.len() == 1 {
parts[0].to_string()
} else {
parts.join(" | ")
}
}
}
pub fn get_command_schema() -> String {
serde_json::json!({
"commands": [
{
"type": "render",
"description": "Render the current screen state and return it",
"example": {"type": "render"}
},
{
"type": "key",
"description": "Send a keyboard event",
"parameters": {
"code": "Key code (e.g., 'a', 'Enter', 'Backspace', 'Left', 'F1')",
"modifiers": "Optional array of modifiers: 'ctrl', 'alt', 'shift', 'super'"
},
"examples": [
{"type": "key", "code": "a"},
{"type": "key", "code": "s", "modifiers": ["ctrl"]},
{"type": "key", "code": "Enter"}
]
},
{
"type": "mouse_click",
"description": "Click at a screen position",
"parameters": {
"col": "Column (x coordinate, 0-indexed)",
"row": "Row (y coordinate, 0-indexed)",
"button": "Optional: 'left' (default), 'right', 'middle'"
},
"example": {"type": "mouse_click", "col": 10, "row": 5}
},
{
"type": "mouse_drag",
"description": "Drag from one position to another",
"parameters": {
"start_col": "Start column",
"start_row": "Start row",
"end_col": "End column",
"end_row": "End row",
"button": "Optional: 'left' (default), 'right', 'middle'"
},
"example": {"type": "mouse_drag", "start_col": 10, "start_row": 5, "end_col": 20, "end_row": 5}
},
{
"type": "mouse_scroll",
"description": "Scroll at a position",
"parameters": {
"col": "Column",
"row": "Row",
"direction": "'up' or 'down'",
"amount": "Optional: number of lines to scroll (default: 3)"
},
"example": {"type": "mouse_scroll", "col": 40, "row": 12, "direction": "down"}
},
{
"type": "resize",
"description": "Resize the terminal",
"parameters": {
"width": "New width",
"height": "New height"
},
"example": {"type": "resize", "width": 120, "height": 40}
},
{
"type": "status",
"description": "Get editor status (cursor position, buffer info)",
"example": {"type": "status"}
},
{
"type": "get_buffer",
"description": "Get the actual buffer content (not screen representation)",
"example": {"type": "get_buffer"}
},
{
"type": "open_file",
"description": "Open a file in the editor",
"parameters": {
"path": "Path to the file"
},
"example": {"type": "open_file", "path": "/path/to/file.txt"}
},
{
"type": "type_text",
"description": "Type a string of text (convenience for multiple key presses)",
"parameters": {
"text": "Text to type"
},
"example": {"type": "type_text", "text": "Hello, World!"}
},
{
"type": "quit",
"description": "Quit the editor",
"example": {"type": "quit"}
},
{
"type": "export_test",
"description": "Export the interaction history as Rust test code",
"parameters": {
"test_name": "Name for the generated test function"
},
"example": {"type": "export_test", "test_name": "test_basic_editing"}
},
{
"type": "get_keybindings",
"description": "Get all keyboard bindings (key combinations mapped to actions)",
"example": {"type": "get_keybindings"},
"response_format": {
"type": "keybindings",
"bindings": [
{"key": "Ctrl+S", "action": "Save file"},
{"key": "Ctrl+Q", "action": "Quit"},
{"key": "Alt+F", "action": "[menu] Open File menu"}
]
}
},
{
"type": "wait_for",
"description": "Wait for a condition to be met (event-based or state polling)",
"parameters": {
"condition": "Condition object to wait for",
"timeout_ms": "Optional timeout in milliseconds (default: 5000)",
"poll_interval_ms": "Optional poll interval in milliseconds (default: 100)"
},
"condition_types": {
"event_based": {
"type": "event",
"description": "Wait for event matching name pattern (* = wildcard) and optional data",
"examples": [
{"type": "event", "name": "lsp:status_changed", "data": {"status": "running"}},
{"type": "event", "name": "editor:file_saved"},
{"type": "event", "name": "lsp:*"},
{"type": "event", "name": "plugin:git:*", "data": {"branch": "main"}}
],
"currently_emitted_events": crate::model::control_event::events::schema(),
"pattern_syntax": {
"*": "Matches any event",
"prefix:*": "Matches events starting with prefix (e.g., 'lsp:*')",
"*:suffix": "Matches events ending with suffix (e.g., '*:error')",
"exact:name": "Exact match"
},
"data_matching": {
"{}": "Matches any data",
"{\"key\": \"value\"}": "Data must contain key with exact value",
"{\"key\": null}": "Data must contain key (any value)"
}
},
"state_based": [
{"type": "screen_contains", "text": "Error"},
{"type": "screen_not_contains", "text": "Loading"},
{"type": "buffer_contains", "text": "fn main"},
{"type": "popup_visible"},
{"type": "popup_hidden"}
]
},
"examples": [
{"type": "wait_for", "condition": {"type": "event", "name": "lsp:status_changed", "data": {"status": "running"}}},
{"type": "wait_for", "condition": {"type": "screen_contains", "text": "Error"}, "timeout_ms": 10000},
{"type": "wait_for", "condition": {"type": "popup_visible"}, "poll_interval_ms": 50}
]
}
]
})
.to_string()
}