diff --git a/.agent-context b/.agent-context new file mode 100644 index 0000000000..1c1d8edd57 --- /dev/null +++ b/.agent-context @@ -0,0 +1,90 @@ +# Agent Context - IPC All Actions + +## Feature +IPC All Actions - Expose all terminal actions via IPC + JSON screen output + +## Current State +Starting implementation. Need to expand IPC to expose all ~50 terminal actions. + +## Key Files +- `frontends/rioterm/src/ipc.rs` - Core IPC server and commands (MAIN FILE) +- `frontends/rioterm/src/bindings/mod.rs` - Action enum (lines 368-597) - reference for all actions +- `frontends/rioterm/src/application.rs` - handle_ipc_command implementation +- `frontends/rioterm/src/cli.rs` - CLI argument parsing + +## Implementation Tasks + +### 1. Expand ListActions to return ALL actions +Currently returns hardcoded 5 actions. Need to return all ~50 from bindings/mod.rs: +- Simple actions: paste, quit, copy, searchforward, searchbackward, clearhistory, resetfontsize, etc. +- Parametrized actions: selecttab(N), gotobookmark(N), connectssh(N), insertsnippet(N), jumptodirectory(N), run(cmd), scroll(N) + +### 2. Expand TriggerAction to handle ALL actions +Current handle_ipc_command in application.rs only handles 5 actions. +Parse action string using `Action::from(String)` in bindings/mod.rs (line 211-366). +Then execute via route.window.screen.execute_action() or similar. + +### 3. Add JSON screen output format +Add new command: `DumpScreenJson` or flag on `DumpScreen` +Return structured cell data: +```rust +ScreenDumpJson { + lines: Vec, + cursor: CursorPosition, + dimensions: (usize, usize), +} + +struct ScreenLine { + text: String, + cells: Vec, +} + +struct CellInfo { + char: char, + fg: String, // hex color + bg: String, + flags: Vec, // bold, italic, underline, etc. +} +``` + +### 4. Add line selection for DumpScreen +Add parameters: `DumpScreen { start_line: Option, end_line: Option }` +CLI: `midterm --dump-screen --lines 0-10` or `--dump-screen --line 5` + +### 5. Add ListTabs command +Return info about all tabs: +```rust +IpcCommand::ListTabs + +IpcResponse::Tabs { + tabs: Vec, + current: usize, +} + +struct TabInfo { + index: usize, + title: String, + splits: usize, + working_directory: Option, +} +``` + +## Testing +```bash +# After each change, test with: +midterm --list-actions # Should show all ~50 actions +midterm --action selecttab(1) +midterm --action splitright +midterm --dump-screen --json +midterm --list-tabs +``` + +## Blockers +None + +## Next Actions +1. Update ListActions in application.rs to return all actions +2. Update TriggerAction to parse and execute all actions +3. Add JSON screen dump format +4. Add line selection for screen dump +5. Add ListTabs command diff --git a/.agent-progress b/.agent-progress new file mode 100644 index 0000000000..dd5b1c8eff --- /dev/null +++ b/.agent-progress @@ -0,0 +1,10 @@ +# Agent Progress - ipc-actions +# Format: KEY=VALUE, one per line. Agents append, last value wins. +# Created: 2025-12-30T05:48:22Z + +feature=ipc-actions +name=IPC All Actions +status=pending +step=0 +step_name=Not started +created=2025-12-30T05:48:22Z diff --git a/.gitignore b/.gitignore index d2bc34ee0b..1cc670eb11 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ misc/72/ # goreleaser dist/ +.worktrees/ diff --git a/.harness/config.json b/.harness/config.json new file mode 100644 index 0000000000..46ab2776c6 --- /dev/null +++ b/.harness/config.json @@ -0,0 +1,5 @@ +{ + "base_branch": "main", + "max_parallel": 3, + "worktree_parent": ".worktrees" +} diff --git a/.harness/state.json b/.harness/state.json new file mode 100644 index 0000000000..a0ea9c8d9b --- /dev/null +++ b/.harness/state.json @@ -0,0 +1,128 @@ +{ + "project": "midterm", + "version": "2.0.0", + "created": "2025-12-30T00:33:00Z", + "updated": "2025-12-30T04:30:00Z", + "features": [ + { + "id": "command-palette", + "name": "Command Palette", + "description": "Add Cmd+Shift+P command palette with fuzzy search", + "status": "completed", + "priority": 1, + "branch": null, + "pr": null, + "depends_on": [], + "started": "2025-12-30T02:00:00Z", + "completed": "2025-12-30T03:00:00Z" + }, + { + "id": "tab-reorder", + "name": "Tab Reordering", + "description": "Add Cmd+Shift+Left/Right to reorder tabs", + "status": "completed", + "priority": 2, + "branch": null, + "pr": null, + "depends_on": [], + "started": "2025-12-30T01:00:00Z", + "completed": "2025-12-30T01:30:00Z" + }, + { + "id": "transparency", + "name": "Transparent Background", + "description": "Add liquid glass transparent background with blur", + "status": "completed", + "priority": 3, + "branch": null, + "pr": null, + "depends_on": [], + "started": "2025-12-30T01:30:00Z", + "completed": "2025-12-30T02:00:00Z" + }, + { + "id": "url-detection", + "name": "URL Detection", + "description": "Click URLs to open in browser", + "status": "completed", + "priority": 4, + "branch": null, + "pr": null, + "depends_on": [], + "started": "2025-12-30T02:00:00Z", + "completed": "2025-12-30T02:30:00Z" + }, + { + "id": "bookmarks", + "name": "Directory Bookmarks", + "description": "Quick directory bookmarks with Cmd+B/Cmd+Shift+B", + "status": "completed", + "priority": 5, + "branch": null, + "pr": null, + "depends_on": [], + "started": "2025-12-30T02:30:00Z", + "completed": "2025-12-30T03:00:00Z" + }, + { + "id": "shell-integration", + "name": "Shell Integration", + "description": "OSC 133 shell integration for command tracking", + "status": "completed", + "priority": 6, + "branch": null, + "pr": null, + "depends_on": [], + "started": "2025-12-30T03:00:00Z", + "completed": "2025-12-30T03:30:00Z" + }, + { + "id": "command-notifications", + "name": "Command Notifications", + "description": "Notify when long-running commands complete", + "status": "completed", + "priority": 7, + "branch": null, + "pr": null, + "depends_on": ["shell-integration"], + "started": "2025-12-30T03:30:00Z", + "completed": "2025-12-30T04:00:00Z" + }, + { + "id": "session-management", + "name": "Session Save/Restore", + "description": "Save and restore terminal sessions with Cmd+Shift+S/O", + "status": "completed", + "priority": 8, + "branch": null, + "pr": null, + "depends_on": [], + "started": "2025-12-30T04:00:00Z", + "completed": "2025-12-30T04:15:00Z" + }, + { + "id": "ssh-profiles", + "name": "SSH Profile Manager", + "description": "Manage SSH profiles with Cmd+G/Cmd+Shift+G", + "status": "completed", + "priority": 9, + "branch": null, + "pr": null, + "depends_on": [], + "started": "2025-12-30T04:15:00Z", + "completed": "2025-12-30T04:25:00Z" + }, + { + "id": "snippets", + "name": "Snippets System", + "description": "Text snippets with Cmd+/ and Cmd+Shift+N", + "status": "completed", + "priority": 10, + "branch": null, + "pr": null, + "depends_on": [], + "started": "2025-12-30T04:25:00Z", + "completed": "2025-12-30T04:30:00Z" + } + ] +} diff --git a/Cargo.lock b/Cargo.lock index d28e3e6073..013b85f9b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3391,6 +3391,7 @@ dependencies = [ "rio-window", "rustc-hash 2.1.1", "serde", + "serde_json", "smallvec", "teletypewriter", "tinyvec", diff --git a/frontends/rioterm/Cargo.toml b/frontends/rioterm/Cargo.toml index ad6c813919..c690552829 100644 --- a/frontends/rioterm/Cargo.toml +++ b/frontends/rioterm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rioterm" -description = "Rio terminal is a hardware-accelerated GPU terminal emulator, focusing to run in desktops and browsers." +description = "midterm is a hardware-accelerated GPU terminal emulator, focusing to run in desktops and browsers." version.workspace = true authors.workspace = true edition.workspace = true @@ -13,7 +13,7 @@ documentation.workspace = true readme.workspace = true [[bin]] -name = "rio" +name = "midterm" path = "src/main.rs" [dependencies] @@ -34,6 +34,7 @@ regex = { workspace = true } raw-window-handle = { workspace = true } clap = { version = "4.5.42", features = ["derive"] } dirs = "6.0.0" +serde_json = "1.0" notify = "8.2.0" rustc-hash = { workspace = true } image_rs = { workspace = true } @@ -103,7 +104,7 @@ assets = [ ["../../LICENSE", "usr/share/doc/rio/", "664"] ] extended-description = """\ -Rio terminal is a hardware-accelerated GPU terminal emulator, focusing to run in desktops and browsers. +midterm is a hardware-accelerated GPU terminal emulator, focusing to run in desktops and browsers. The supported platforms currently consist of BSD, Linux, MacOS and Windows.""" [[bench]] diff --git a/frontends/rioterm/src/application.rs b/frontends/rioterm/src/application.rs index e3d0abcc4f..94d4e776f9 100644 --- a/frontends/rioterm/src/application.rs +++ b/frontends/rioterm/src/application.rs @@ -1,10 +1,18 @@ use crate::event::{ClickState, EventPayload, EventProxy, RioEvent, RioEventType}; use crate::ime::Preedit; +#[cfg(unix)] +use crate::ipc::{IpcCommand, IpcResponse, IpcServer}; use crate::renderer::utils::update_colors_based_on_theme; use crate::router::{routes::RoutePath, Router}; use crate::scheduler::{Scheduler, TimerId, Topic}; use crate::screen::touch::on_touch; use crate::watcher::configuration_file_updates; +#[cfg(unix)] +use crate::bindings::Action; +#[cfg(unix)] +use rio_backend::crosswords::grid::Dimensions; +#[cfg(unix)] +use rio_backend::crosswords::pos::{Column, Line}; #[cfg(all( feature = "audio", not(target_os = "macos"), @@ -35,6 +43,8 @@ pub struct Application<'a> { event_proxy: EventProxy, router: Router<'a>, scheduler: Scheduler, + #[cfg(unix)] + ipc_server: Option, } impl Application<'_> { @@ -65,11 +75,23 @@ impl Application<'_> { #[cfg(target_os = "macos")] event_loop.set_confirm_before_quit(config.confirm_before_quit); + // Start IPC server for remote control + #[cfg(unix)] + let ipc_server = match IpcServer::new() { + Ok(server) => Some(server), + Err(e) => { + tracing::warn!("Failed to start IPC server: {}", e); + None + } + }; + Application { config, event_proxy, router, scheduler, + #[cfg(unix)] + ipc_server, } } @@ -164,6 +186,394 @@ impl Application<'_> { } } + /// Send a macOS notification when a long-running command completes. + #[cfg(target_os = "macos")] + fn send_command_notification(&self, exit_code: i32, duration_secs: f64) { + let status = if exit_code == 0 { "✓" } else { "✗" }; + let duration_str = if duration_secs >= 60.0 { + format!("{:.0}m {:.0}s", duration_secs / 60.0, duration_secs % 60.0) + } else { + format!("{:.1}s", duration_secs) + }; + + let title = format!("Command {} ({})", status, duration_str); + let message = if exit_code == 0 { + "Command completed successfully".to_string() + } else { + format!("Command failed with exit code {}", exit_code) + }; + + // Use osascript for notification + let script = format!( + r#"display notification "{}" with title "midterm" subtitle "{}""#, + message, title + ); + + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .spawn(); + + tracing::debug!( + "Sent notification: {} - {} (exit={})", + title, + message, + exit_code + ); + } + + #[cfg(not(target_os = "macos"))] + fn send_command_notification(&self, _exit_code: i32, _duration_secs: f64) { + // Notifications only supported on macOS for now + } + + #[cfg(unix)] + fn poll_ipc(&mut self) { + let server = match &self.ipc_server { + Some(s) => s, + None => return, + }; + + for (cmd, response_tx) in server.poll() { + let response = self.handle_ipc_command(cmd); + let _ = response_tx.send(response); + } + } + + #[cfg(unix)] + fn get_any_route(&self) -> Option { + // Try focused first, fall back to any route + self.router.get_focused_route() + .or_else(|| self.router.routes.keys().next().copied()) + } + + #[cfg(unix)] + fn handle_ipc_command(&mut self, cmd: IpcCommand) -> IpcResponse { + match cmd { + IpcCommand::Ping => IpcResponse::Pong, + + IpcCommand::ListActions => { + // Return ALL available actions from bindings/mod.rs + let actions = vec![ + // Basic actions + "paste".to_string(), + "copy".to_string(), + "quit".to_string(), + "none".to_string(), + + // Search actions + "searchforward".to_string(), + "searchbackward".to_string(), + "searchconfirm".to_string(), + "searchcancel".to_string(), + "searchclear".to_string(), + "searchfocusnext".to_string(), + "searchfocusprevious".to_string(), + "searchdeleteword".to_string(), + "searchhistorynext".to_string(), + "searchhistoryprevious".to_string(), + + // History and font + "clearhistory".to_string(), + "resetfontsize".to_string(), + "increasefontsize".to_string(), + "decreasefontsize".to_string(), + + // Window and tab management + "createwindow".to_string(), + "createtab".to_string(), + "movecurrenttabtoprev".to_string(), + "movecurrenttabtonext".to_string(), + "closetab".to_string(), + "closesplitortab".to_string(), + "closeunfocusedtabs".to_string(), + "renametab".to_string(), + "selectprevtab".to_string(), + "selectnexttab".to_string(), + "selectlasttab".to_string(), + + // Bookmarks + "addbookmark".to_string(), + "showbookmarks".to_string(), + + // Configuration + "openconfigeditor".to_string(), + + // Character input + "receivechar".to_string(), + + // Scrolling + "scrollpageup".to_string(), + "scrollpagedown".to_string(), + "scrollhalfpageup".to_string(), + "scrollhalfpagedown".to_string(), + "scrolltotop".to_string(), + "scrolltobottom".to_string(), + + // Split management + "splitright".to_string(), + "splitdown".to_string(), + "selectnextsplit".to_string(), + "selectprevsplit".to_string(), + "selectnextsplitortab".to_string(), + "selectprevsplitortab".to_string(), + "movedividerup".to_string(), + "movedividerdown".to_string(), + "movedividerleft".to_string(), + "movedividerright".to_string(), + + // Vi mode + "togglevimode".to_string(), + + // Fullscreen + "togglefullscreen".to_string(), + + // Session management + "savesession".to_string(), + "restoresession".to_string(), + + // SSH + "showsshprofiles".to_string(), + "addsshprofile".to_string(), + + // Snippets + "showsnippets".to_string(), + "addsnippet".to_string(), + + // Broadcast mode + "togglebroadcast".to_string(), + + // Directory jumper + "showdirectoryjumper".to_string(), + + // Parameterized actions (show format) + "selecttab(N)".to_string(), + "gotobookmark(N)".to_string(), + "connectssh(N)".to_string(), + "insertsnippet(N)".to_string(), + "jumptodirectory(N)".to_string(), + "run(COMMAND)".to_string(), + "scroll(N)".to_string(), + ]; + IpcResponse::Actions(actions) + } + + IpcCommand::TriggerAction(action_name) => { + // Get the focused route's screen and trigger the action + let window_id = match self.get_any_route() { + Some(id) => id, + None => return IpcResponse::Error("No focused window".to_string()), + }; + + let route = match self.router.routes.get_mut(&window_id) { + Some(r) => r, + None => return IpcResponse::Error("Route not found".to_string()), + }; + + // Convert action name to Action enum using From + let action = Action::from(action_name.clone()); + + // Execute the action + match route.window.screen.execute_action(&action) { + Ok(_) => IpcResponse::ActionTriggered(action_name), + Err(e) => IpcResponse::Error(e), + } + } + + IpcCommand::GetStatus => { + let window_id = match self.get_any_route() { + Some(id) => id, + None => return IpcResponse::Error("No focused window".to_string()), + }; + + let route = match self.router.routes.get(&window_id) { + Some(r) => r, + None => return IpcResponse::Error("Route not found".to_string()), + }; + + let tabs = route.window.screen.context_manager.len(); + let current_tab = route.window.screen.context_manager.current_index(); + let splits = route.window.screen.context_manager.current_grid_len(); + let broadcast_mode = route.window.screen.context_manager.broadcast_input; + + let current_directory = { + let terminal = route.window.screen.context_manager.current().terminal.lock(); + terminal.current_directory.as_ref().map(|p| p.to_string_lossy().to_string()) + }; + + // Try to get git branch + let git_branch = current_directory.as_ref().and_then(|dir| { + std::process::Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(dir) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + }); + + IpcResponse::Status { + tabs, + current_tab, + splits, + broadcast_mode, + current_directory, + git_branch, + } + } + + IpcCommand::DumpScreen { format, start_line, end_line } => { + let window_id = match self.get_any_route() { + Some(id) => id, + None => return IpcResponse::Error("No focused window".to_string()), + }; + + let route = match self.router.routes.get(&window_id) { + Some(r) => r, + None => return IpcResponse::Error("Route not found".to_string()), + }; + + let terminal = route.window.screen.context_manager.current().terminal.lock(); + let grid = &terminal.grid; + let cursor = &grid.cursor; + let num_lines = grid.screen_lines(); + let num_cols = grid.columns(); + + // Calculate line range + let start = start_line.unwrap_or(0).min(num_lines); + let end = end_line.unwrap_or(num_lines).min(num_lines); + + // Check if JSON format requested + let is_json = format.as_ref().map(|f| f == "json").unwrap_or(false); + + if is_json { + // JSON format with cell data + use crate::ipc::ScreenCell; + let mut json_lines = Vec::new(); + + for line_idx in start..end { + let mut json_line = Vec::new(); + for col_idx in 0..num_cols { + let cell = &grid[Line(line_idx as i32)][Column(col_idx)]; + + // For now, skip color information (would need color palette access) + json_line.push(ScreenCell { + c: cell.c, + fg: None, // TODO: Extract from color palette + bg: None, // TODO: Extract from color palette + bold: cell.flags.contains(rio_backend::crosswords::square::Flags::BOLD), + italic: cell.flags.contains(rio_backend::crosswords::square::Flags::ITALIC), + underline: cell.flags.contains(rio_backend::crosswords::square::Flags::UNDERLINE), + }); + } + json_lines.push(json_line); + } + + IpcResponse::ScreenDumpJson { + lines: json_lines, + cursor_row: cursor.pos.row.0 as usize, + cursor_col: cursor.pos.col.0, + start_line: start, + end_line: end, + } + } else { + // Plain text format + let mut lines = Vec::new(); + for line_idx in start..end { + let mut line = String::new(); + for col_idx in 0..num_cols { + let cell = &grid[Line(line_idx as i32)][Column(col_idx)]; + line.push(cell.c); + } + lines.push(line.trim_end().to_string()); + } + + IpcResponse::ScreenDump { + lines, + cursor_row: cursor.pos.row.0 as usize, + cursor_col: cursor.pos.col.0, + } + } + } + + IpcCommand::ListTabs => { + let window_id = match self.get_any_route() { + Some(id) => id, + None => return IpcResponse::Error("No focused window".to_string()), + }; + + let route = match self.router.routes.get(&window_id) { + Some(r) => r, + None => return IpcResponse::Error("Route not found".to_string()), + }; + + use crate::ipc::TabInfo; + let current_index = route.window.screen.context_manager.current_index(); + let num_tabs = route.window.screen.context_manager.len(); + + let mut tabs = Vec::new(); + for index in 0..num_tabs { + // Get tab title - this requires accessing the context manager + let title = format!("Tab {}", index + 1); // Simplified for now + tabs.push(TabInfo { + index, + title, + is_current: index == current_index, + }); + } + + IpcResponse::Tabs(tabs) + } + + IpcCommand::SendInput(text) => { + let window_id = match self.get_any_route() { + Some(id) => id, + None => return IpcResponse::Error("No focused window".to_string()), + }; + + let route = match self.router.routes.get_mut(&window_id) { + Some(r) => r, + None => return IpcResponse::Error("Route not found".to_string()), + }; + + let bytes = text.as_bytes().to_vec(); + let len = bytes.len(); + route.window.screen.write_to_pty(bytes); + IpcResponse::InputSent(len) + } + + IpcCommand::ScreenContains(pattern) => { + let window_id = match self.get_any_route() { + Some(id) => id, + None => return IpcResponse::Error("No focused window".to_string()), + }; + + let route = match self.router.routes.get(&window_id) { + Some(r) => r, + None => return IpcResponse::Error("Route not found".to_string()), + }; + + let terminal = route.window.screen.context_manager.current().terminal.lock(); + let grid = &terminal.grid; + let num_lines = grid.screen_lines(); + let num_cols = grid.columns(); + + // Search through visible lines + for line_idx in 0..num_lines { + let mut line = String::new(); + for col_idx in 0..num_cols { + let cell = &grid[Line(line_idx as i32)][Column(col_idx)]; + line.push(cell.c); + } + if line.contains(&pattern) { + return IpcResponse::ScreenContainsResult(true); + } + } + IpcResponse::ScreenContainsResult(false) + } + } + } + pub fn run( &mut self, event_loop: EventLoop, @@ -803,6 +1213,27 @@ impl ApplicationHandler for Application<'_> { } } } + RioEventType::Rio(RioEvent::CommandFinished { + exit_code, + duration_secs, + }) => { + // Only notify if: + // 1. Command ran for at least 5 seconds + // 2. Window is not focused + const NOTIFY_THRESHOLD_SECS: f64 = 5.0; + + if duration_secs >= NOTIFY_THRESHOLD_SECS { + let is_focused = self + .router + .routes + .get(&window_id) + .is_some_and(|route| route.window.is_focused); + + if !is_focused { + self.send_command_notification(exit_code, duration_secs); + } + } + } _ => {} } } @@ -1400,7 +1831,7 @@ impl ApplicationHandler for Application<'_> { } RoutePath::ConfirmQuit => { route.window.screen.render_dialog( - "Quit Rio?", + "Quit midterm?", "Continue -> press escape key", "Quit -> press enter key", ); @@ -1435,6 +1866,10 @@ impl ApplicationHandler for Application<'_> { } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + // Poll IPC server for commands + #[cfg(unix)] + self.poll_ipc(); + let control_flow = match self.scheduler.update() { Some(instant) => ControlFlow::WaitUntil(instant), None => ControlFlow::Wait, @@ -1460,7 +1895,7 @@ impl ApplicationHandler for Application<'_> { key: &rio_window::event::KeyEvent, modifiers: &rio_window::event::Modifiers, ) { - let window_id = match self.router.get_focused_route() { + let window_id = match self.get_any_route() { Some(window_id) => window_id, None => return, }; diff --git a/frontends/rioterm/src/bindings/mod.rs b/frontends/rioterm/src/bindings/mod.rs index 75e03576a9..eb85e4a8d3 100644 --- a/frontends/rioterm/src/bindings/mod.rs +++ b/frontends/rioterm/src/bindings/mod.rs @@ -241,6 +241,9 @@ impl From for Action { "closetab" => Some(Action::TabCloseCurrent), "closesplitortab" => Some(Action::CloseCurrentSplitOrTab), "closeunfocusedtabs" => Some(Action::TabCloseUnfocused), + "renametab" => Some(Action::RenameTab), + "addbookmark" => Some(Action::AddBookmark), + "showbookmarks" => Some(Action::ShowBookmarks), "openconfigeditor" => Some(Action::ConfigEditor), "selectprevtab" => Some(Action::SelectPrevTab), "selectnexttab" => Some(Action::SelectNextTab), @@ -264,6 +267,14 @@ impl From for Action { "movedividerright" => Some(Action::MoveDividerRight), "togglevimode" => Some(Action::ToggleViMode), "togglefullscreen" => Some(Action::ToggleFullscreen), + "savesession" => Some(Action::SaveSession), + "restoresession" => Some(Action::RestoreSession), + "showsshprofiles" => Some(Action::ShowSSHProfiles), + "addsshprofile" => Some(Action::AddSSHProfile), + "showsnippets" => Some(Action::ShowSnippets), + "addsnippet" => Some(Action::AddSnippet), + "togglebroadcast" => Some(Action::ToggleBroadcast), + "showdirectoryjumper" => Some(Action::ShowDirectoryJumper), "none" => Some(Action::None), _ => None, }; @@ -281,6 +292,42 @@ impl From for Action { } } + let re = regex::Regex::new(r"gotobookmark\(([^()]+)\)").unwrap(); + for capture in re.captures_iter(&action) { + if let Some(matched) = capture.get(1) { + let matched_string = matched.as_str().to_string(); + let parsed_matched_string: usize = matched_string.parse().unwrap_or(0); + return Action::GoToBookmark(parsed_matched_string); + } + } + + let re = regex::Regex::new(r"connectssh\(([^()]+)\)").unwrap(); + for capture in re.captures_iter(&action) { + if let Some(matched) = capture.get(1) { + let matched_string = matched.as_str().to_string(); + let parsed_matched_string: usize = matched_string.parse().unwrap_or(0); + return Action::ConnectSSH(parsed_matched_string); + } + } + + let re = regex::Regex::new(r"insertsnippet\(([^()]+)\)").unwrap(); + for capture in re.captures_iter(&action) { + if let Some(matched) = capture.get(1) { + let matched_string = matched.as_str().to_string(); + let parsed_matched_string: usize = matched_string.parse().unwrap_or(0); + return Action::InsertSnippet(parsed_matched_string); + } + } + + let re = regex::Regex::new(r"jumptodirectory\(([^()]+)\)").unwrap(); + for capture in re.captures_iter(&action) { + if let Some(matched) = capture.get(1) { + let matched_string = matched.as_str().to_string(); + let parsed_matched_string: usize = matched_string.parse().unwrap_or(0); + return Action::JumpToDirectory(parsed_matched_string); + } + } + let re = regex::Regex::new(r"run\(([^()]+)\)").unwrap(); for capture in re.captures_iter(&action) { if let Some(matched) = capture.get(1) { @@ -437,6 +484,27 @@ pub enum Action { /// Close all other tabs (leave only the current tab). TabCloseUnfocused, + /// Rename current tab. + RenameTab, + + /// Add current directory to bookmarks. + AddBookmark, + + /// Show bookmarks list. + ShowBookmarks, + + /// Go to bookmark by index (0-9). + GoToBookmark(usize), + + /// Show SSH profiles list and connect. + ShowSSHProfiles, + + /// Add a new SSH profile. + AddSSHProfile, + + /// Connect to SSH profile by index (0-9). + ConnectSSH(usize), + /// Toggle fullscreen. #[allow(dead_code)] ToggleFullscreen, @@ -500,6 +568,30 @@ pub enum Action { /// Allow receiving char input. ReceiveChar, + /// Save current session (all tabs and their working directories). + SaveSession, + + /// Restore last saved session. + RestoreSession, + + /// Show snippets list and insert selected. + ShowSnippets, + + /// Add a new snippet. + AddSnippet, + + /// Insert snippet by index (0-9). + InsertSnippet(usize), + + /// Toggle broadcast mode - send input to all splits. + ToggleBroadcast, + + /// Show directory jumper (frecency-based). + ShowDirectoryJumper, + + /// Jump to directory by index. + JumpToDirectory(usize), + /// No action. None, } diff --git a/frontends/rioterm/src/cli.rs b/frontends/rioterm/src/cli.rs index 1f3bfde825..75e8e0bc5f 100644 --- a/frontends/rioterm/src/cli.rs +++ b/frontends/rioterm/src/cli.rs @@ -12,6 +12,34 @@ pub struct Cli { /// Options which can be passed via IPC. #[clap(flatten)] pub window_options: WindowOptions, + + /// Send an action to a running midterm instance. + #[clap(long, value_name = "ACTION")] + pub action: Option, + + /// Dump the current screen content from a running instance. + #[clap(long)] + pub dump_screen: bool, + + /// Get status info from a running instance (JSON). + #[clap(long)] + pub status: bool, + + /// List available actions. + #[clap(long)] + pub list_actions: bool, + + /// Send text input to the terminal. + #[clap(long, value_name = "TEXT")] + pub send: Option, + + /// Wait for screen to contain pattern (with timeout in ms). + #[clap(long, value_name = "PATTERN")] + pub wait_for: Option, + + /// Timeout for wait operations in milliseconds. + #[clap(long, default_value = "5000")] + pub timeout: u64, } #[derive(Serialize, Deserialize, Args, Default, Clone, Debug, PartialEq, Eq)] diff --git a/frontends/rioterm/src/constants.rs b/frontends/rioterm/src/constants.rs index 348efa0c63..1364398ec6 100644 --- a/frontends/rioterm/src/constants.rs +++ b/frontends/rioterm/src/constants.rs @@ -9,6 +9,9 @@ pub const PADDING_Y_WITH_TAB_ON_TOP: f32 = 15.0; #[cfg(target_os = "macos")] pub const PADDING_Y: f32 = 26.; +#[cfg(target_os = "macos")] +pub const PADDING_Y_WITH_TAB_ON_TOP: f32 = 48.; + #[cfg(not(any(target_os = "macos")))] pub const INACTIVE_TAB_WIDTH_SIZE: f32 = 4.; diff --git a/frontends/rioterm/src/context/mod.rs b/frontends/rioterm/src/context/mod.rs index 083f4c5c24..6677e9b016 100644 --- a/frontends/rioterm/src/context/mod.rs +++ b/frontends/rioterm/src/context/mod.rs @@ -135,6 +135,8 @@ pub struct ContextManager { window_id: WindowId, pub config: ContextManagerConfig, pub titles: ContextManagerTitles, + /// When true, keyboard input is sent to all splits in the current tab + pub broadcast_input: bool, } pub fn create_dead_context( @@ -391,6 +393,7 @@ impl ContextManager { window_id, config: ctx_config, titles, + broadcast_input: false, }) } @@ -438,6 +441,7 @@ impl ContextManager { window_id, config, titles, + broadcast_input: false, }) } diff --git a/frontends/rioterm/src/context/title.rs b/frontends/rioterm/src/context/title.rs index 1519230c01..b01acf01b0 100644 --- a/frontends/rioterm/src/context/title.rs +++ b/frontends/rioterm/src/context/title.rs @@ -1,7 +1,81 @@ use crate::context::Context; use rustc_hash::FxHashMap; +use std::path::Path; +use std::sync::Mutex; use std::time::Instant; +// Cache for git status to avoid calling git on every title update +static GIT_CACHE: Mutex> = Mutex::new(None); +const GIT_CACHE_DURATION_MS: u128 = 2000; + +/// Get git branch and dirty status for a directory. +/// Returns (branch_name, is_dirty) or None if not a git repo. +fn get_git_status(dir: &Path) -> Option<(String, bool)> { + // Check cache first + let cache_key = dir.to_string_lossy().to_string(); + { + if let Ok(cache) = GIT_CACHE.lock() { + if let Some((cached_dir, cached_result, cached_time)) = cache.as_ref() { + if cached_dir == &cache_key + && cached_time.elapsed().as_millis() < GIT_CACHE_DURATION_MS + { + // Parse cached result + if cached_result.is_empty() { + return None; + } + let is_dirty = cached_result.ends_with('*'); + let branch = if is_dirty { + cached_result.trim_end_matches('*').to_string() + } else { + cached_result.clone() + }; + return Some((branch, is_dirty)); + } + } + } + } + + // Get branch name + let branch_output = std::process::Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(dir) + .output() + .ok()?; + + if !branch_output.status.success() { + // Not a git repo, cache empty result + if let Ok(mut cache) = GIT_CACHE.lock() { + *cache = Some((cache_key, String::new(), Instant::now())); + } + return None; + } + + let branch = String::from_utf8_lossy(&branch_output.stdout) + .trim() + .to_string(); + + // Check if dirty + let status_output = std::process::Command::new("git") + .args(["status", "--porcelain"]) + .current_dir(dir) + .output() + .ok()?; + + let is_dirty = !status_output.stdout.is_empty(); + + // Cache result + let cache_result = if is_dirty { + format!("{}*", branch) + } else { + branch.clone() + }; + if let Ok(mut cache) = GIT_CACHE.lock() { + *cache = Some((cache_key, cache_result, Instant::now())); + } + + Some((branch, is_dirty)) +} + pub struct ContextTitleExtra { pub program: String, pub path: String, @@ -10,6 +84,7 @@ pub struct ContextTitleExtra { pub struct ContextTitle { pub content: String, pub extra: Option, + pub is_custom: bool, // User-set title that shouldn't be auto-updated } pub struct ContextManagerTitles { @@ -26,7 +101,7 @@ impl ContextManagerTitles { ) -> ContextManagerTitles { let key = format!("{idx}{content};"); let mut map = FxHashMap::default(); - map.insert(idx, ContextTitle { content, extra }); + map.insert(idx, ContextTitle { content, extra, is_custom: false }); ContextManagerTitles { key, titles: map, @@ -41,7 +116,18 @@ impl ContextManagerTitles { content: String, extra: Option, ) { - self.titles.insert(idx, ContextTitle { content, extra }); + // Don't overwrite custom titles + if let Some(existing) = self.titles.get(&idx) { + if existing.is_custom { + return; + } + } + self.titles.insert(idx, ContextTitle { content, extra, is_custom: false }); + } + + #[inline] + pub fn set_custom_title(&mut self, idx: usize, content: String) { + self.titles.insert(idx, ContextTitle { content, extra: None, is_custom: true }); } #[inline] @@ -193,6 +279,28 @@ pub fn update_title( } } } + "git" => { + let current_dir = { + let terminal = context.terminal.lock(); + terminal.current_directory.clone() + }; + if let Some(ref dir) = current_dir { + if let Some((branch, is_dirty)) = get_git_status(dir) { + let git_str = if is_dirty { + format!("[{}*]", branch) + } else { + format!("[{}]", branch) + }; + new_template = new_template.replace(to_replace_str, &git_str); + matched = true; + } else { + // Not a git repo, remove the placeholder + new_template = new_template.replace(to_replace_str, ""); + } + } else { + new_template = new_template.replace(to_replace_str, ""); + } + } // TODO: // "path_relative" => { // #[cfg(unix)] diff --git a/frontends/rioterm/src/ipc.rs b/frontends/rioterm/src/ipc.rs new file mode 100644 index 0000000000..31e638b98c --- /dev/null +++ b/frontends/rioterm/src/ipc.rs @@ -0,0 +1,337 @@ +// IPC module for remote control of midterm +// Provides Unix socket-based communication for CLI control + +use serde::{Deserialize, Serialize}; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::PathBuf; +use std::sync::mpsc; + +/// Get the path to the IPC socket +pub fn socket_path() -> PathBuf { + let runtime_dir = std::env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()); + runtime_dir.join("midterm.sock") +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum IpcCommand { + /// Trigger an action by name + TriggerAction(String), + /// Get current screen content (plain text or JSON) + DumpScreen { + format: Option, // "json" or "text" (default) + start_line: Option, + end_line: Option, + }, + /// Get status information + GetStatus, + /// List available actions + ListActions, + /// List all tabs + ListTabs, + /// Ping to check if server is alive + Ping, + /// Send text input to the terminal + SendInput(String), + /// Check if screen contains pattern + ScreenContains(String), +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ScreenCell { + pub c: char, + pub fg: Option, // Foreground color in hex + pub bg: Option, // Background color in hex + pub bold: bool, + pub italic: bool, + pub underline: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct TabInfo { + pub index: usize, + pub title: String, + pub is_current: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum IpcResponse { + /// Action was triggered successfully + ActionTriggered(String), + /// Screen content dump (plain text) + ScreenDump { + lines: Vec, + cursor_row: usize, + cursor_col: usize, + }, + /// Screen content dump (JSON with cell data) + ScreenDumpJson { + lines: Vec>, + cursor_row: usize, + cursor_col: usize, + start_line: usize, + end_line: usize, + }, + /// Status information + Status { + tabs: usize, + current_tab: usize, + splits: usize, + broadcast_mode: bool, + current_directory: Option, + git_branch: Option, + }, + /// List of available actions + Actions(Vec), + /// List of tabs + Tabs(Vec), + /// Pong response + Pong, + /// Input was sent + InputSent(usize), + /// Screen contains check result + ScreenContainsResult(bool), + /// Error message + Error(String), +} + +/// Send a command to a running midterm instance +pub fn send_command(cmd: IpcCommand) -> Result { + let path = socket_path(); + if !path.exists() { + return Err("No midterm instance running (socket not found)".to_string()); + } + + let mut stream = UnixStream::connect(&path) + .map_err(|e| format!("Failed to connect to midterm: {}", e))?; + + let cmd_json = serde_json::to_string(&cmd) + .map_err(|e| format!("Failed to serialize command: {}", e))?; + + writeln!(stream, "{}", cmd_json) + .map_err(|e| format!("Failed to send command: {}", e))?; + + stream.flush() + .map_err(|e| format!("Failed to flush: {}", e))?; + + let mut reader = BufReader::new(stream); + let mut response_line = String::new(); + reader.read_line(&mut response_line) + .map_err(|e| format!("Failed to read response: {}", e))?; + + serde_json::from_str(&response_line) + .map_err(|e| format!("Failed to parse response: {}", e)) +} + +/// IPC server that listens for commands +pub struct IpcServer { + listener: UnixListener, +} + +impl IpcServer { + /// Create and start the IPC server + pub fn new() -> Result { + let path = socket_path(); + + // Remove existing socket if it exists + if path.exists() { + let _ = std::fs::remove_file(&path); + } + + let listener = UnixListener::bind(&path) + .map_err(|e| format!("Failed to bind IPC socket: {}", e))?; + + // Set non-blocking so we can poll + listener.set_nonblocking(true) + .map_err(|e| format!("Failed to set non-blocking: {}", e))?; + + tracing::info!("IPC server listening on {:?}", path); + + Ok(Self { listener }) + } + + /// Check for and handle incoming commands (non-blocking) + /// Returns any commands that were received + pub fn poll(&self) -> Vec<(IpcCommand, mpsc::Sender)> { + let mut commands = Vec::new(); + + // Accept connections (non-blocking) + while let Ok((stream, _)) = self.listener.accept() { + if let Some(cmd) = self.handle_connection(stream) { + commands.push(cmd); + } + } + + commands + } + + fn handle_connection(&self, stream: UnixStream) -> Option<(IpcCommand, mpsc::Sender)> { + let mut reader = BufReader::new(stream.try_clone().ok()?); + let mut line = String::new(); + + if reader.read_line(&mut line).ok()? == 0 { + return None; + } + + let cmd: IpcCommand = serde_json::from_str(&line).ok()?; + + // Create a channel for the response + let (tx, rx) = mpsc::channel(); + + // Spawn a thread to wait for and send the response + std::thread::spawn(move || { + if let Ok(response) = rx.recv_timeout(std::time::Duration::from_secs(5)) { + if let Ok(mut stream) = stream.try_clone() { + if let Ok(json) = serde_json::to_string(&response) { + let _ = writeln!(stream, "{}", json); + let _ = stream.flush(); + } + } + } + }); + + Some((cmd, tx)) + } +} + +impl Drop for IpcServer { + fn drop(&mut self) { + // Clean up socket file + let _ = std::fs::remove_file(socket_path()); + } +} + +/// Handle CLI commands that communicate with a running instance +pub fn handle_cli_command( + action: Option, + dump_screen: bool, + status: bool, + list_actions: bool, + send: Option, + wait_for: Option, + timeout: u64, +) -> bool { + // If any IPC command is specified, handle it and exit + if action.is_none() && !dump_screen && !status && !list_actions && send.is_none() && wait_for.is_none() { + return false; // No IPC command, continue normal startup + } + + // Handle wait_for with polling + if let Some(pattern) = wait_for { + let start = std::time::Instant::now(); + let timeout_duration = std::time::Duration::from_millis(timeout); + + loop { + match send_command(IpcCommand::ScreenContains(pattern.clone())) { + Ok(IpcResponse::ScreenContainsResult(true)) => { + println!("Pattern '{}' found", pattern); + return true; + } + Ok(IpcResponse::ScreenContainsResult(false)) => { + if start.elapsed() >= timeout_duration { + eprintln!("Timeout waiting for pattern '{}'", pattern); + std::process::exit(1); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + Ok(IpcResponse::Error(e)) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + Err(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + _ => { + eprintln!("Unexpected response"); + std::process::exit(1); + } + } + } + } + + let cmd = if let Some(action_name) = action { + IpcCommand::TriggerAction(action_name) + } else if dump_screen { + IpcCommand::DumpScreen { + format: None, // Default to text format + start_line: None, + end_line: None, + } + } else if status { + IpcCommand::GetStatus + } else if list_actions { + IpcCommand::ListActions + } else if let Some(text) = send { + IpcCommand::SendInput(text) + } else { + return false; + }; + + match send_command(cmd) { + Ok(response) => { + match response { + IpcResponse::ActionTriggered(name) => { + println!("Action '{}' triggered", name); + } + IpcResponse::ScreenDump { lines, cursor_row, cursor_col } => { + println!("=== Screen Dump (cursor at {},{}) ===", cursor_row, cursor_col); + for line in lines { + println!("{}", line); + } + } + IpcResponse::ScreenDumpJson { lines, cursor_row, cursor_col, start_line, end_line } => { + let json = serde_json::to_string_pretty(&serde_json::json!({ + "lines": lines, + "cursor_row": cursor_row, + "cursor_col": cursor_col, + "start_line": start_line, + "end_line": end_line, + })).unwrap(); + println!("{}", json); + } + IpcResponse::Tabs(tabs) => { + let json = serde_json::to_string_pretty(&tabs).unwrap(); + println!("{}", json); + } + IpcResponse::Status { tabs, current_tab, splits, broadcast_mode, current_directory, git_branch } => { + println!("{{"); + println!(" \"tabs\": {},", tabs); + println!(" \"current_tab\": {},", current_tab); + println!(" \"splits\": {},", splits); + println!(" \"broadcast_mode\": {},", broadcast_mode); + println!(" \"current_directory\": {:?},", current_directory); + println!(" \"git_branch\": {:?}", git_branch); + println!("}}"); + } + IpcResponse::Actions(actions) => { + println!("Available actions:"); + for action in actions { + println!(" {}", action); + } + } + IpcResponse::Pong => { + println!("pong"); + } + IpcResponse::InputSent(len) => { + println!("Sent {} bytes", len); + } + IpcResponse::ScreenContainsResult(found) => { + println!("{}", found); + } + IpcResponse::Error(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + } + Err(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + + true // Command was handled, exit +} diff --git a/frontends/rioterm/src/main.rs b/frontends/rioterm/src/main.rs index 5c97d9481a..e79c7cec63 100644 --- a/frontends/rioterm/src/main.rs +++ b/frontends/rioterm/src/main.rs @@ -11,6 +11,8 @@ mod constants; mod context; mod hints; mod ime; +#[cfg(unix)] +pub mod ipc; mod messenger; mod mouse; #[cfg(windows)] @@ -145,6 +147,20 @@ fn main() -> Result<(), Box> { // Load command line options. let args = cli::Cli::parse(); + // Handle IPC commands (communicate with running instance) + #[cfg(unix)] + if ipc::handle_cli_command( + args.action.clone(), + args.dump_screen, + args.status, + args.list_actions, + args.send.clone(), + args.wait_for.clone(), + args.timeout, + ) { + return Ok(()); + } + let write_config_path = args.window_options.terminal_options.write_config.clone(); if let Some(config_path) = write_config_path { let _ = setup_logs_by_filter_level("TRACE", false); diff --git a/frontends/rioterm/src/panic.rs b/frontends/rioterm/src/panic.rs index ca222dceeb..b48e3b4355 100644 --- a/frontends/rioterm/src/panic.rs +++ b/frontends/rioterm/src/panic.rs @@ -25,7 +25,7 @@ pub fn attach_handler() { MessageBoxW( std::ptr::null_mut(), win32_string(&msg).as_ptr(), - win32_string("Rio: Runtime Error").as_ptr(), + win32_string("midterm: Runtime Error").as_ptr(), MB_ICONERROR | MB_OK | MB_SETFOREGROUND | MB_TASKMODAL, ); } diff --git a/frontends/rioterm/src/renderer/mod.rs b/frontends/rioterm/src/renderer/mod.rs index 24e616368b..570f23df45 100644 --- a/frontends/rioterm/src/renderer/mod.rs +++ b/frontends/rioterm/src/renderer/mod.rs @@ -2,6 +2,7 @@ mod char_cache; mod font_cache; pub mod navigation; mod search; +pub mod status; pub mod utils; use crate::context::renderable::TerminalSnapshot; diff --git a/frontends/rioterm/src/renderer/navigation.rs b/frontends/rioterm/src/renderer/navigation.rs index d8bd2fe82e..4936ae839a 100644 --- a/frontends/rioterm/src/renderer/navigation.rs +++ b/frontends/rioterm/src/renderer/navigation.rs @@ -1,5 +1,6 @@ use crate::constants::*; use crate::context::title::ContextTitle; +use crate::renderer::status::StatusInfo; use rio_backend::config::colors::Colors; use rio_backend::config::navigation::{Navigation, NavigationMode}; use rio_backend::sugarloaf::{FragmentStyle, Object, Quad, RichText, Sugarloaf}; @@ -10,6 +11,7 @@ pub struct ScreenNavigation { pub navigation: Navigation, pub padding_y: [f32; 2], color_automation: HashMap>, + status_info: StatusInfo, } impl ScreenNavigation { @@ -22,6 +24,7 @@ impl ScreenNavigation { navigation, color_automation, padding_y, + status_info: StatusInfo::new(), } } @@ -58,6 +61,10 @@ impl ScreenNavigation { dimensions, ), NavigationMode::TopTab => { + // On macOS, offset for title bar; on other platforms, start at 0 + #[cfg(target_os = "macos")] + let position_y = 26.0; + #[cfg(not(target_os = "macos"))] let position_y = 0.0; self.tab( sugarloaf, @@ -271,6 +278,54 @@ impl ScreenNavigation { initial_position_x += name_modifier + 40.; } + + // Render status info (battery % and time) on the right side + self.render_status_info(sugarloaf, objects, colors, position_y, dimensions); + } + + /// Render battery percentage and current time on the right side of the tab bar + #[inline] + fn render_status_info( + &mut self, + sugarloaf: &mut Sugarloaf, + objects: &mut Vec, + colors: &Colors, + position_y: f32, + dimensions: (f32, f32, f32), + ) { + let (width, _, scale) = dimensions; + let status_text = self.status_info.get_display_string(); + + // Calculate position for right-aligned text + // Approximate character width: ~8 pixels at font size 14 + let char_width = 8.0; + let text_width = status_text.len() as f32 * char_width; + let padding_right = 10.0; + let position_x = (width / scale) - text_width - padding_right; + + // Create and render the status text + let status_rich_text = sugarloaf.create_temp_rich_text(); + sugarloaf.set_rich_text_font_size(&status_rich_text, 14.); + let content = sugarloaf.content(); + + let status_line = content.sel(status_rich_text); + status_line + .clear() + .new_line() + .add_text( + &status_text, + FragmentStyle { + color: colors.tabs_foreground, + ..FragmentStyle::default() + }, + ) + .build(); + + objects.push(Object::RichText(RichText { + id: status_rich_text, + position: [position_x, position_y], + lines: None, + })); } } diff --git a/frontends/rioterm/src/renderer/status.rs b/frontends/rioterm/src/renderer/status.rs new file mode 100644 index 0000000000..b013624222 --- /dev/null +++ b/frontends/rioterm/src/renderer/status.rs @@ -0,0 +1,170 @@ +//! Status bar information provider (battery percentage and current time) +//! +//! This module provides functionality to display battery percentage and current time +//! in the terminal's tab bar. + +use std::time::{Duration, Instant}; + +/// Cached status information to avoid expensive system calls on every frame +pub struct StatusInfo { + battery_percentage: Option, + time_string: String, + last_update: Instant, + update_interval: Duration, +} + +impl Default for StatusInfo { + fn default() -> Self { + Self::new() + } +} + +impl StatusInfo { + pub fn new() -> Self { + let mut status = Self { + battery_percentage: None, + time_string: String::new(), + last_update: Instant::now() - Duration::from_secs(60), // Force initial update + update_interval: Duration::from_secs(30), // Update every 30 seconds + }; + status.update(); + status + } + + /// Update status info if enough time has passed + pub fn update_if_needed(&mut self) { + if self.last_update.elapsed() >= self.update_interval { + self.update(); + } + } + + /// Force update status info + fn update(&mut self) { + self.battery_percentage = get_battery_percentage(); + self.time_string = get_current_time(); + self.last_update = Instant::now(); + } + + /// Get the formatted status string for display + pub fn get_display_string(&mut self) -> String { + self.update_if_needed(); + + match self.battery_percentage { + Some(pct) => format!("{}% {}", pct, self.time_string), + None => self.time_string.clone(), + } + } +} + +/// Get the current time formatted as HH:MM +fn get_current_time() -> String { + use std::time::SystemTime; + + let now = SystemTime::now(); + let duration = now + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + + // Calculate local time (this is a simplified approach) + // For proper timezone handling, chrono would be ideal, but we'll use a shell command fallback + #[cfg(target_os = "macos")] + { + if let Ok(output) = std::process::Command::new("date") + .arg("+%H:%M") + .output() + { + if output.status.success() { + return String::from_utf8_lossy(&output.stdout).trim().to_string(); + } + } + } + + #[cfg(not(target_os = "macos"))] + { + if let Ok(output) = std::process::Command::new("date") + .arg("+%H:%M") + .output() + { + if output.status.success() { + return String::from_utf8_lossy(&output.stdout).trim().to_string(); + } + } + } + + // Fallback: UTC time from duration + let secs = duration.as_secs(); + let hours = (secs / 3600) % 24; + let minutes = (secs / 60) % 60; + format!("{:02}:{:02}", hours, minutes) +} + +/// Get battery percentage on macOS using pmset +#[cfg(target_os = "macos")] +fn get_battery_percentage() -> Option { + let output = std::process::Command::new("pmset") + .arg("-g") + .arg("batt") + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Parse output like: "Now drawing from 'Battery Power'\n -InternalBattery-0 (id=...) 85%; ..." + for line in stdout.lines() { + if line.contains("InternalBattery") || line.contains('%') { + // Find the percentage value + for part in line.split_whitespace() { + if part.ends_with("%;") || part.ends_with('%') { + let num_str = part.trim_end_matches("%;").trim_end_matches('%'); + if let Ok(pct) = num_str.parse::() { + return Some(pct); + } + } + } + } + } + + None +} + +/// Get battery percentage on non-macOS platforms +#[cfg(not(target_os = "macos"))] +fn get_battery_percentage() -> Option { + // Try reading from /sys/class/power_supply/BAT0/capacity (Linux) + #[cfg(target_os = "linux")] + { + for bat in ["BAT0", "BAT1", "BAT2"] { + let path = format!("/sys/class/power_supply/{}/capacity", bat); + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(pct) = content.trim().parse::() { + return Some(pct); + } + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_status_info_creation() { + let status = StatusInfo::new(); + assert!(!status.time_string.is_empty()); + } + + #[test] + fn test_get_current_time_format() { + let time = get_current_time(); + // Should be in HH:MM format + assert_eq!(time.len(), 5); + assert!(time.contains(':')); + } +} diff --git a/frontends/rioterm/src/renderer/utils.rs b/frontends/rioterm/src/renderer/utils.rs index 2f43d01594..d0f4a8483f 100644 --- a/frontends/rioterm/src/renderer/utils.rs +++ b/frontends/rioterm/src/renderer/utils.rs @@ -33,6 +33,8 @@ pub fn padding_top_from_config( return additional + padding_y_top; } else if navigation.hide_if_single && num_tabs == 1 { return default_padding; + } else if navigation.mode == NavigationMode::TopTab { + return constants::PADDING_Y_WITH_TAB_ON_TOP + padding_y_top; } } diff --git a/frontends/rioterm/src/router/mod.rs b/frontends/rioterm/src/router/mod.rs index b7f07418a9..54205cf727 100644 --- a/frontends/rioterm/src/router/mod.rs +++ b/frontends/rioterm/src/router/mod.rs @@ -371,7 +371,7 @@ impl Router<'_> { event_proxy, &new_config, &self.font_library, - "Rio Settings", + "midterm Settings", None, None, self.clipboard.clone(), diff --git a/frontends/rioterm/src/router/routes/assistant.rs b/frontends/rioterm/src/router/routes/assistant.rs index 61ed486458..8b29d0dd8f 100644 --- a/frontends/rioterm/src/router/routes/assistant.rs +++ b/frontends/rioterm/src/router/routes/assistant.rs @@ -88,7 +88,7 @@ pub fn screen( let heading_line = content.sel(heading); heading_line .clear() - .add_text("Woops! Rio got errors", FragmentStyle::default()) + .add_text("Woops! midterm got errors", FragmentStyle::default()) .build(); let paragraph_action_line = content.sel(paragraph_action); diff --git a/frontends/rioterm/src/router/routes/welcome.rs b/frontends/rioterm/src/router/routes/welcome.rs index 3115f472ee..ac7e094cdc 100644 --- a/frontends/rioterm/src/router/routes/welcome.rs +++ b/frontends/rioterm/src/router/routes/welcome.rs @@ -52,7 +52,7 @@ pub fn screen(sugarloaf: &mut Sugarloaf, context_dimension: &ContextDimension) { let heading_line = content.sel(heading); heading_line .clear() - .add_text("Welcome to Rio Terminal", FragmentStyle::default()) + .add_text("Welcome to midterm", FragmentStyle::default()) .build(); let paragraph_action_line = content.sel(paragraph_action); @@ -107,7 +107,17 @@ pub fn screen(sugarloaf: &mut Sugarloaf, context_dimension: &ContextDimension) { .new_line() .add_text("", FragmentStyle::default()) .new_line() - .add_text("More info in rioterm.com", FragmentStyle::default()) + .add_text("", FragmentStyle::default()) + .new_line() + .add_text("Powered by ", FragmentStyle::default()) + .add_text( + "Rio Terminal", + FragmentStyle { + color: [0.94, 0.47, 0.0, 1.0], // Rio orange #F07900 + ..FragmentStyle::default() + }, + ) + .add_text(" - rioterm.com", FragmentStyle::default()) .build(); objects.push(Object::RichText(RichText { diff --git a/frontends/rioterm/src/router/window.rs b/frontends/rioterm/src/router/window.rs index 5a2f701384..118b7f22ea 100644 --- a/frontends/rioterm/src/router/window.rs +++ b/frontends/rioterm/src/router/window.rs @@ -13,7 +13,7 @@ pub const DEFAULT_MINIMUM_WINDOW_WIDTH: i32 = 300; any(feature = "wayland", feature = "x11"), not(any(target_os = "macos", windows)) ))] -pub const APPLICATION_ID: &str = "Rio"; +pub const APPLICATION_ID: &str = "midterm"; pub fn create_window_builder( title: &str, diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index 48874aba95..d331d63e16 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -74,6 +74,114 @@ const MAX_SEARCH_WHILE_TYPING: Option = Some(1000); /// Maximum number of search terms stored in the history. const MAX_SEARCH_HISTORY_SIZE: usize = 255; +// ============ Directory Frecency Functions ============ + +/// Get the path to the directory frecency file. +fn get_frecency_path() -> std::path::PathBuf { + dirs::config_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("rio") + .join("directory_frecency.json") +} + +/// Load directory frecency data. +pub fn load_frecency() -> Vec { + let path = get_frecency_path(); + if path.exists() { + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(data) = serde_json::from_str::>(&content) { + return data; + } + } + } + Vec::new() +} + +/// Save directory frecency data. +fn save_frecency(data: &[serde_json::Value]) { + let path = get_frecency_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(data) { + let _ = std::fs::write(&path, json); + } +} + +/// Calculate frecency score for a directory entry. +pub fn calculate_frecency_score(frequency: u64, last_access: u64) -> f64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let age_hours = if now > last_access { + (now - last_access) / 3600 + } else { + 0 + }; + + let recency_weight = match age_hours { + 0..=1 => 4.0, // Last hour + 2..=24 => 2.0, // Last day + 25..=168 => 1.0, // Last week + _ => 0.5, // Older + }; + + frequency as f64 * recency_weight +} + +/// Record a directory visit for frecency tracking. +pub fn record_directory_visit(path: &str) { + if path.is_empty() || path == "/" { + return; + } + + let mut data = load_frecency(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + // Find existing entry or create new one + let mut found = false; + for entry in data.iter_mut() { + if entry.get("path").and_then(|p| p.as_str()) == Some(path) { + let freq = entry.get("frequency").and_then(|f| f.as_u64()).unwrap_or(0); + entry["frequency"] = serde_json::json!(freq + 1); + entry["last_access"] = serde_json::json!(now); + found = true; + break; + } + } + + if !found { + data.push(serde_json::json!({ + "path": path, + "frequency": 1, + "last_access": now, + })); + } + + // Keep only top 100 entries by score + data.sort_by(|a, b| { + let score_a = calculate_frecency_score( + a.get("frequency").and_then(|f| f.as_u64()).unwrap_or(0), + a.get("last_access").and_then(|t| t.as_u64()).unwrap_or(0), + ); + let score_b = calculate_frecency_score( + b.get("frequency").and_then(|f| f.as_u64()).unwrap_or(0), + b.get("last_access").and_then(|t| t.as_u64()).unwrap_or(0), + ); + score_b.partial_cmp(&score_a).unwrap_or(std::cmp::Ordering::Equal) + }); + data.truncate(100); + + save_frecency(&data); +} + +// ============ End Frecency Functions ============ + pub struct Screen<'screen> { bindings: crate::bindings::KeyBindings, mouse_bindings: Vec, @@ -286,6 +394,21 @@ impl Screen<'_> { &mut self.context_manager } + /// Write bytes to PTY, broadcasting to all splits if broadcast mode is enabled. + #[inline] + pub fn write_to_pty(&mut self, bytes: Vec) { + if self.context_manager.broadcast_input { + // Send to all splits in the current tab + let grid = self.context_manager.current_grid_mut(); + for context_item in grid.contexts_mut().values_mut() { + context_item.val.messenger.send_write(bytes.clone()); + } + } else { + // Send only to the focused split + self.context_manager.current_mut().messenger.send_write(bytes); + } + } + #[inline] pub fn set_modifiers(&mut self, modifiers: Modifiers) { self.modifiers = modifiers; @@ -585,7 +708,7 @@ impl Screen<'_> { _ => build_key_sequence(key, mods, mode), }; - self.ctx_mut().current_mut().messenger.send_write(bytes); + self.write_to_pty(bytes); return; } @@ -683,7 +806,7 @@ impl Screen<'_> { self.scroll_bottom_when_cursor_not_visible(); self.clear_selection(); - self.ctx_mut().current_mut().messenger.send_write(bytes); + self.write_to_pty(bytes); } } @@ -995,6 +1118,51 @@ impl Screen<'_> { self.resize_top_or_bottom_line(1); self.render(); } + Act::RenameTab => { + self.rename_current_tab(); + } + Act::AddBookmark => { + self.add_bookmark(); + } + Act::ShowBookmarks => { + self.show_bookmarks(); + } + Act::GoToBookmark(index) => { + self.go_to_bookmark(*index); + } + Act::SaveSession => { + self.save_session(); + } + Act::RestoreSession => { + self.restore_session(); + } + Act::ShowSSHProfiles => { + self.show_ssh_profiles(); + } + Act::AddSSHProfile => { + self.add_ssh_profile(); + } + Act::ConnectSSH(index) => { + self.connect_ssh(*index); + } + Act::ShowSnippets => { + self.show_snippets(); + } + Act::AddSnippet => { + self.add_snippet(); + } + Act::InsertSnippet(index) => { + self.insert_snippet(*index); + } + Act::ToggleBroadcast => { + self.toggle_broadcast(); + } + Act::ShowDirectoryJumper => { + self.show_directory_jumper(); + } + Act::JumpToDirectory(index) => { + self.jump_to_directory(*index); + } Act::Quit => { self.context_manager.quit(); } @@ -1169,96 +1337,1440 @@ impl Screen<'_> { } } - ignore_chars.unwrap_or(false) + ignore_chars.unwrap_or(false) + } + + pub fn split_right_with_config(&mut self, config: rio_backend::config::Config) { + let rich_text_id = self.sugarloaf.create_rich_text(); + self.context_manager + .split_from_config(rich_text_id, false, config); + + self.render(); + } + + pub fn split_right(&mut self) { + let rich_text_id = self.sugarloaf.create_rich_text(); + self.context_manager.split(rich_text_id, false); + + self.render(); + } + + pub fn split_down(&mut self) { + let rich_text_id = self.sugarloaf.create_rich_text(); + self.context_manager.split(rich_text_id, true); + + self.render(); + } + + pub fn move_divider_up(&mut self) { + let amount = 20.0; // Default movement amount + if self.context_manager.move_divider_up(amount) { + self.render(); + } + } + + pub fn move_divider_down(&mut self) { + let amount = 20.0; // Default movement amount + if self.context_manager.move_divider_down(amount) { + self.render(); + } + } + + pub fn move_divider_left(&mut self) { + let amount = 40.0; // Default movement amount + if self.context_manager.move_divider_left(amount) { + self.render(); + } + } + + pub fn move_divider_right(&mut self) { + let amount = 40.0; // Default movement amount + if self.context_manager.move_divider_right(amount) { + self.render(); + } + } + + pub fn create_tab(&mut self) { + let redirect = true; + + // We resize the current tab ahead to prepare the + // dimensions to be copied to next tab. + let num_tabs = self.ctx().len(); + self.resize_top_or_bottom_line(num_tabs + 1); + + let rich_text_id = self.sugarloaf.create_rich_text(); + self.context_manager.add_context(redirect, rich_text_id); + + self.cancel_search(); + self.render(); + } + + pub fn close_split_or_tab(&mut self) { + if self.context_manager.current_grid_len() > 1 { + self.clear_selection(); + self.context_manager.remove_current_grid(); + self.render(); + } else { + self.close_tab(); + } + } + + pub fn close_tab(&mut self) { + self.clear_selection(); + self.context_manager.close_current_context(); + + self.cancel_search(); + if self.ctx().len() <= 1 { + return; + } + + let num_tabs = self.ctx().len().wrapping_sub(1); + self.resize_top_or_bottom_line(num_tabs); + self.render(); + } + + pub fn rename_current_tab(&mut self) { + #[cfg(target_os = "macos")] + { + let current_idx = self.context_manager.current_index(); + let current_title = self + .context_manager + .titles + .titles + .get(¤t_idx) + .map(|t| t.content.clone()) + .unwrap_or_else(|| "tab".to_string()); + + // Use osascript to show a dialog + let script = format!( + r#"display dialog "Enter new tab name:" default answer "{}" buttons {{"Cancel", "OK"}} default button "OK""#, + current_title + ); + + if let Ok(output) = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + // Parse "button returned:OK, text returned:NewName" + if let Some(text_start) = stdout.find("text returned:") { + let new_name = stdout[text_start + 14..].trim().to_string(); + if !new_name.is_empty() { + self.context_manager.titles.set_custom_title(current_idx, new_name); + self.render(); + } + } + } + } + } + + #[cfg(not(target_os = "macos"))] + { + // On non-macOS, we could implement a different approach + // For now, just log that rename is not supported + tracing::warn!("Tab rename is only supported on macOS"); + } + } + + fn get_bookmarks_path() -> std::path::PathBuf { + dirs::config_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("rio") + .join("bookmarks.json") + } + + fn load_bookmarks() -> Vec { + let path = Self::get_bookmarks_path(); + if path.exists() { + if let Ok(contents) = std::fs::read_to_string(&path) { + if let Ok(bookmarks) = serde_json::from_str::>(&contents) { + return bookmarks; + } + } + } + Vec::new() + } + + fn save_bookmarks(bookmarks: &[String]) { + let path = Self::get_bookmarks_path(); + if let Ok(contents) = serde_json::to_string_pretty(bookmarks) { + let _ = std::fs::write(path, contents); + } + } + + pub fn add_bookmark(&mut self) { + // Get current directory from terminal + let current_dir = { + let terminal = self.context_manager.current().terminal.lock(); + terminal.current_directory.clone() + }; + + if let Some(dir) = current_dir { + let dir_str = dir.to_string_lossy().to_string(); + let mut bookmarks = Self::load_bookmarks(); + + // Don't add duplicates + if !bookmarks.contains(&dir_str) { + bookmarks.push(dir_str.clone()); + Self::save_bookmarks(&bookmarks); + tracing::info!("Added bookmark: {}", dir_str); + } + } else { + tracing::warn!("Cannot add bookmark: no current directory"); + } + } + + pub fn show_bookmarks(&mut self) { + #[cfg(target_os = "macos")] + { + let bookmarks = Self::load_bookmarks(); + if bookmarks.is_empty() { + let script = r#"display dialog "No bookmarks saved.\n\nUse Cmd+Shift+B to add the current directory as a bookmark." buttons {"OK"} default button "OK" with title "Bookmarks""#; + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(script) + .output(); + return; + } + + // Build a list of bookmarks with numbers + let bookmark_list: Vec = bookmarks + .iter() + .enumerate() + .map(|(i, b)| format!("{}: {}", i + 1, b)) + .collect(); + let list_str = bookmark_list.join("\", \""); + + let script = format!( + r#"choose from list {{"{}"}}" with title "Bookmarks" with prompt "Select a directory:" OK button name "Go" cancel button name "Cancel""#, + list_str + ); + + if let Ok(output) = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout != "false" { + // Parse the selected item (format: "N: /path/to/dir") + if let Some(colon_pos) = stdout.find(':') { + let path = stdout[colon_pos + 2..].to_string(); + // Send cd command to terminal + self.paste(&format!("cd '{}'\n", path), true); + } + } + } + } + } + + #[cfg(not(target_os = "macos"))] + { + tracing::warn!("Bookmarks UI is only supported on macOS"); + } + } + + pub fn go_to_bookmark(&mut self, index: usize) { + let bookmarks = Self::load_bookmarks(); + if index > 0 && index <= bookmarks.len() { + let path = &bookmarks[index - 1]; + self.paste(&format!("cd '{}'\n", path), true); + } else { + tracing::warn!("Bookmark {} not found", index); + } + } + + // Session management + + fn get_session_path() -> std::path::PathBuf { + dirs::config_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("rio") + .join("session.json") + } + + /// Save the current session (all tabs with their working directories). + pub fn save_session(&mut self) { + let mut tabs: Vec = Vec::new(); + + // Get working directory from current tab + let terminal = self.context_manager.current().terminal.lock(); + let current_working_dir = terminal + .current_directory + .as_ref() + .map(|p| p.to_string_lossy().to_string()); + drop(terminal); + + // Save all tabs with their titles + let num_tabs = self.context_manager.len(); + for idx in 0..num_tabs { + let title = self + .context_manager + .titles + .titles + .get(&idx) + .map(|t| t.content.clone()); + let is_custom_title = self + .context_manager + .titles + .titles + .get(&idx) + .is_some_and(|t| t.is_custom); + + // For the current tab, use the working dir we retrieved + let tab_working_dir = if idx == self.context_manager.current_index() { + current_working_dir.clone() + } else { + None // Can't easily get other tabs' working dirs + }; + + tabs.push(serde_json::json!({ + "index": idx, + "working_dir": tab_working_dir, + "title": title, + "is_custom_title": is_custom_title, + })); + } + + // Use Unix timestamp + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let session = serde_json::json!({ + "version": 1, + "saved_at": timestamp, + "tabs": tabs, + "active_tab": self.context_manager.current_index(), + }); + + let path = Self::get_session_path(); + if let Ok(contents) = serde_json::to_string_pretty(&session) { + if std::fs::write(&path, contents).is_ok() { + tracing::info!("Session saved to {:?} ({} tabs)", path, tabs.len()); + + // Show notification + #[cfg(target_os = "macos")] + { + let script = format!( + r#"display notification "Saved {} tabs" with title "midterm" subtitle "Session Saved""#, + tabs.len() + ); + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .spawn(); + } + } else { + tracing::error!("Failed to save session to {:?}", path); + } + } + } + + /// Restore the last saved session. + pub fn restore_session(&mut self) { + let path = Self::get_session_path(); + if !path.exists() { + tracing::warn!("No session file found at {:?}", path); + + #[cfg(target_os = "macos")] + { + let script = r#"display dialog "No saved session found.\n\nUse Cmd+Shift+S to save your current session." buttons {"OK"} default button "OK" with title "Restore Session""#; + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(script) + .spawn(); + } + return; + } + + let contents = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + tracing::error!("Failed to read session file: {}", e); + return; + } + }; + + let session: serde_json::Value = match serde_json::from_str(&contents) { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to parse session file: {}", e); + return; + } + }; + + let tabs = match session.get("tabs").and_then(|t| t.as_array()) { + Some(t) => t, + None => { + tracing::error!("Invalid session file: missing tabs array"); + return; + } + }; + + if tabs.is_empty() { + tracing::warn!("Session file has no tabs"); + return; + } + + // Restore working directory from the first tab that has one + let working_dir = tabs + .iter() + .find_map(|tab| tab.get("working_dir").and_then(|w| w.as_str())); + + if let Some(dir) = working_dir { + self.paste(&format!("cd '{}' && clear\n", dir), true); + tracing::info!("Restored session - changed to directory: {}", dir); + + #[cfg(target_os = "macos")] + { + let script = format!( + r#"display notification "Restored to {}" with title "midterm" subtitle "Session Restored""#, + dir + ); + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .spawn(); + } + } else { + tracing::warn!("Session has no working directories to restore"); + + #[cfg(target_os = "macos")] + { + let script = r#"display notification "No working directory saved" with title "midterm" subtitle "Session Restore""#; + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(script) + .spawn(); + } + } + } + + // SSH Profile Management + + fn get_ssh_profiles_path() -> std::path::PathBuf { + dirs::config_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("rio") + .join("ssh_profiles.json") + } + + fn load_ssh_profiles() -> Vec { + let path = Self::get_ssh_profiles_path(); + if path.exists() { + if let Ok(contents) = std::fs::read_to_string(&path) { + if let Ok(profiles) = serde_json::from_str::>(&contents) { + return profiles; + } + } + } + Vec::new() + } + + fn save_ssh_profiles(profiles: &[serde_json::Value]) { + let path = Self::get_ssh_profiles_path(); + if let Ok(contents) = serde_json::to_string_pretty(profiles) { + let _ = std::fs::write(path, contents); + } + } + + /// Add a new SSH profile via dialog. + pub fn add_ssh_profile(&mut self) { + #[cfg(target_os = "macos")] + { + // Prompt for profile name + let name_script = r#" + set dialogResult to display dialog "Enter a name for this SSH profile:" default answer "" with title "Add SSH Profile" buttons {"Cancel", "Next"} default button "Next" + if button returned of dialogResult is "Cancel" then + return "CANCELLED" + end if + return text returned of dialogResult + "#; + + let name_output = std::process::Command::new("osascript") + .arg("-e") + .arg(name_script) + .output(); + + let name = match name_output { + Ok(output) if output.status.success() => { + let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if s == "CANCELLED" || s.is_empty() { + return; + } + s + } + _ => return, + }; + + // Prompt for host + let host_script = r#" + set dialogResult to display dialog "Enter the SSH host (e.g., user@hostname or hostname):" default answer "" with title "Add SSH Profile" buttons {"Cancel", "Next"} default button "Next" + if button returned of dialogResult is "Cancel" then + return "CANCELLED" + end if + return text returned of dialogResult + "#; + + let host_output = std::process::Command::new("osascript") + .arg("-e") + .arg(host_script) + .output(); + + let host = match host_output { + Ok(output) if output.status.success() => { + let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if s == "CANCELLED" || s.is_empty() { + return; + } + s + } + _ => return, + }; + + // Prompt for port (optional) + let port_script = r#" + set dialogResult to display dialog "Enter the SSH port (leave empty for default 22):" default answer "" with title "Add SSH Profile" buttons {"Cancel", "Save"} default button "Save" + if button returned of dialogResult is "Cancel" then + return "CANCELLED" + end if + return text returned of dialogResult + "#; + + let port_output = std::process::Command::new("osascript") + .arg("-e") + .arg(port_script) + .output(); + + let port: Option = match port_output { + Ok(output) if output.status.success() => { + let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if s == "CANCELLED" { + return; + } + s.parse().ok() + } + _ => None, + }; + + // Create and save the profile + let profile = serde_json::json!({ + "name": name, + "host": host, + "port": port, + }); + + let mut profiles = Self::load_ssh_profiles(); + profiles.push(profile); + Self::save_ssh_profiles(&profiles); + + tracing::info!("Added SSH profile: {} -> {}", name, host); + + let script = format!( + r#"display notification "Added profile: {}" with title "midterm" subtitle "SSH Profile Saved""#, + name + ); + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .spawn(); + } + + #[cfg(not(target_os = "macos"))] + { + tracing::warn!("SSH profile UI is only supported on macOS"); + } + } + + /// Show SSH profiles and connect to selected one. + pub fn show_ssh_profiles(&mut self) { + #[cfg(target_os = "macos")] + { + let profiles = Self::load_ssh_profiles(); + if profiles.is_empty() { + let script = r#"display dialog "No SSH profiles saved.\n\nUse ⌘⇧G to add a new SSH profile." buttons {"OK"} default button "OK" with title "SSH Profiles""#; + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(script) + .output(); + return; + } + + let profile_list: Vec = profiles + .iter() + .enumerate() + .map(|(i, p)| { + let name = p.get("name").and_then(|n| n.as_str()).unwrap_or("Unknown"); + let host = p.get("host").and_then(|h| h.as_str()).unwrap_or(""); + format!("{}: {} ({})", i + 1, name, host) + }) + .collect(); + let list_str = profile_list.join("\", \""); + + let script = format!( + r#"choose from list {{"{}"}}" with title "SSH Profiles" with prompt "Select a profile to connect:" OK button name "Connect" cancel button name "Cancel""#, + list_str + ); + + if let Ok(output) = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout != "false" { + // Parse the selected profile index + if let Some(colon_pos) = stdout.find(':') { + if let Ok(index) = stdout[..colon_pos].trim().parse::() { + self.connect_ssh(index); + } + } + } + } + } + } + + #[cfg(not(target_os = "macos"))] + { + tracing::warn!("SSH profiles UI is only supported on macOS"); + } + } + + /// Connect to SSH profile by index (1-based). + pub fn connect_ssh(&mut self, index: usize) { + let profiles = Self::load_ssh_profiles(); + if index == 0 || index > profiles.len() { + tracing::warn!("SSH profile {} not found", index); + return; + } + + let profile = &profiles[index - 1]; + let host = match profile.get("host").and_then(|h| h.as_str()) { + Some(h) => h, + None => { + tracing::error!("SSH profile {} has no host", index); + return; + } + }; + + let port = profile.get("port").and_then(|p| p.as_u64()); + + // Build SSH command + let ssh_cmd = if let Some(p) = port { + format!("ssh -p {} {}\n", p, host) + } else { + format!("ssh {}\n", host) + }; + + self.paste(&ssh_cmd, true); + tracing::info!("Connecting to SSH profile {}: {}", index, host); + } + + /// Get the path to the snippets file. + fn get_snippets_path() -> std::path::PathBuf { + dirs::config_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("rio") + .join("snippets.json") + } + + /// Load snippets from file. + fn load_snippets() -> Vec { + let path = Self::get_snippets_path(); + if path.exists() { + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(snippets) = serde_json::from_str::>(&content) { + return snippets; + } + } + } + Vec::new() + } + + /// Save snippets to file. + fn save_snippets(snippets: &[serde_json::Value]) { + let path = Self::get_snippets_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string_pretty(snippets) { + let _ = std::fs::write(&path, json); + } + } + + /// Add a new snippet via macOS dialogs. + pub fn add_snippet(&mut self) { + #[cfg(target_os = "macos")] + { + // Prompt for snippet name + let name_script = r#" + set dialogResult to display dialog "Enter a name for this snippet:" default answer "" with title "Add Snippet" buttons {"Cancel", "Next"} default button "Next" + if button returned of dialogResult is "Cancel" then + return "CANCELLED" + end if + return text returned of dialogResult + "#; + + let name_output = std::process::Command::new("osascript") + .arg("-e") + .arg(name_script) + .output(); + + let name = match name_output { + Ok(output) if output.status.success() => { + let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if s == "CANCELLED" || s.is_empty() { + return; + } + s + } + _ => return, + }; + + // Prompt for snippet content + let content_script = r#" + set dialogResult to display dialog "Enter the snippet text (commands, code, etc):" default answer "" with title "Add Snippet" buttons {"Cancel", "Save"} default button "Save" + if button returned of dialogResult is "Cancel" then + return "CANCELLED" + end if + return text returned of dialogResult + "#; + + let content_output = std::process::Command::new("osascript") + .arg("-e") + .arg(content_script) + .output(); + + let content = match content_output { + Ok(output) if output.status.success() => { + let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if s == "CANCELLED" || s.is_empty() { + return; + } + s + } + _ => return, + }; + + // Create and save the snippet + let snippet = serde_json::json!({ + "name": name, + "content": content, + }); + + let mut snippets = Self::load_snippets(); + snippets.push(snippet); + Self::save_snippets(&snippets); + + tracing::info!("Added snippet: {}", name); + + let script = format!( + r#"display notification "Added snippet: {}" with title "midterm" subtitle "Snippet Saved""#, + name + ); + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .spawn(); + } + + #[cfg(not(target_os = "macos"))] + { + tracing::warn!("Snippet UI is only supported on macOS"); + } + } + + /// Show snippets and insert selected one. + pub fn show_snippets(&mut self) { + #[cfg(target_os = "macos")] + { + let snippets = Self::load_snippets(); + if snippets.is_empty() { + let script = r#"display dialog "No snippets saved.\n\nUse ⌘⇧N to add a new snippet." buttons {"OK"} default button "OK" with title "Snippets""#; + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(script) + .output(); + return; + } + + let snippet_list: Vec = snippets + .iter() + .enumerate() + .map(|(i, s)| { + let name = s.get("name").and_then(|n| n.as_str()).unwrap_or("Unknown"); + format!("{}: {}", i + 1, name) + }) + .collect(); + let list_str = snippet_list.join("\", \""); + + let script = format!( + r#"choose from list {{"{}"}}" with title "Snippets" with prompt "Select a snippet to insert:" OK button name "Insert" cancel button name "Cancel""#, + list_str + ); + + if let Ok(output) = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout != "false" { + // Parse the selected snippet index + if let Some(colon_pos) = stdout.find(':') { + if let Ok(index) = stdout[..colon_pos].trim().parse::() { + self.insert_snippet(index); + } + } + } + } + } + } + + #[cfg(not(target_os = "macos"))] + { + tracing::warn!("Snippets UI is only supported on macOS"); + } + } + + /// Insert snippet by index (1-based). + pub fn insert_snippet(&mut self, index: usize) { + let snippets = Self::load_snippets(); + if index == 0 || index > snippets.len() { + tracing::warn!("Snippet {} not found", index); + return; + } + + let snippet = &snippets[index - 1]; + let content = match snippet.get("content").and_then(|c| c.as_str()) { + Some(c) => c, + None => { + tracing::error!("Snippet {} has no content", index); + return; + } + }; + + // Paste the snippet content (don't execute automatically - user can press Enter) + self.paste(content, true); + let name = snippet.get("name").and_then(|n| n.as_str()).unwrap_or("Unknown"); + tracing::info!("Inserted snippet {}: {}", index, name); } - pub fn split_right_with_config(&mut self, config: rio_backend::config::Config) { - let rich_text_id = self.sugarloaf.create_rich_text(); - self.context_manager - .split_from_config(rich_text_id, false, config); + /// Toggle broadcast input mode. + /// When enabled, keyboard input is sent to all splits in the current tab. + pub fn toggle_broadcast(&mut self) { + self.context_manager.broadcast_input = !self.context_manager.broadcast_input; + let status = if self.context_manager.broadcast_input { + "ON" + } else { + "OFF" + }; + tracing::info!("Broadcast input: {}", status); - self.render(); + #[cfg(target_os = "macos")] + { + let message = if self.context_manager.broadcast_input { + "Broadcast mode ON - typing in all splits" + } else { + "Broadcast mode OFF" + }; + let script = format!( + r#"display notification "{}" with title "midterm""#, + message + ); + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .spawn(); + } } - pub fn split_right(&mut self) { - let rich_text_id = self.sugarloaf.create_rich_text(); - self.context_manager.split(rich_text_id, false); + /// Show directory jumper dialog. + pub fn show_directory_jumper(&mut self) { + #[cfg(target_os = "macos")] + { + let mut data = load_frecency(); - self.render(); - } + // If empty, seed with current directory + if data.is_empty() { + let current_dir = { + let terminal = self.context_manager.current().terminal.lock(); + terminal.current_directory.clone() + }; - pub fn split_down(&mut self) { - let rich_text_id = self.sugarloaf.create_rich_text(); - self.context_manager.split(rich_text_id, true); + if let Some(dir) = current_dir { + let dir_str = dir.to_string_lossy().to_string(); + record_directory_visit(&dir_str); + data = load_frecency(); + } - self.render(); - } + if data.is_empty() { + let script = r#"display dialog "No directories visited yet.\n\nNavigate to directories and they will be tracked." buttons {"OK"} default button "OK" with title "Directory Jumper""#; + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(script) + .output(); + return; + } + } - pub fn move_divider_up(&mut self) { - let amount = 20.0; // Default movement amount - if self.context_manager.move_divider_up(amount) { - self.render(); - } - } + // Sort by frecency score + data.sort_by(|a, b| { + let score_a = calculate_frecency_score( + a.get("frequency").and_then(|f| f.as_u64()).unwrap_or(0), + a.get("last_access").and_then(|t| t.as_u64()).unwrap_or(0), + ); + let score_b = calculate_frecency_score( + b.get("frequency").and_then(|f| f.as_u64()).unwrap_or(0), + b.get("last_access").and_then(|t| t.as_u64()).unwrap_or(0), + ); + score_b.partial_cmp(&score_a).unwrap_or(std::cmp::Ordering::Equal) + }); - pub fn move_divider_down(&mut self) { - let amount = 20.0; // Default movement amount - if self.context_manager.move_divider_down(amount) { - self.render(); + // Take top 15 entries + let top_dirs: Vec = data + .iter() + .take(15) + .enumerate() + .filter_map(|(i, entry)| { + entry.get("path").and_then(|p| p.as_str()).map(|path| { + // Shorten home directory + let display_path = if let Some(home) = dirs::home_dir() { + let home_str = home.to_string_lossy(); + if path.starts_with(home_str.as_ref()) { + path.replacen(home_str.as_ref(), "~", 1) + } else { + path.to_string() + } + } else { + path.to_string() + }; + format!("{}: {}", i + 1, display_path) + }) + }) + .collect(); + + let list_str = top_dirs.join("\", \""); + + let script = format!( + r#"choose from list {{"{}"}}" with title "Directory Jumper" with prompt "Jump to directory:" OK button name "Jump" cancel button name "Cancel""#, + list_str + ); + + if let Ok(output) = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout != "false" { + // Parse the selected directory index + if let Some(colon_pos) = stdout.find(':') { + if let Ok(index) = stdout[..colon_pos].trim().parse::() { + self.jump_to_directory(index); + } + } + } + } + } } - } - pub fn move_divider_left(&mut self) { - let amount = 40.0; // Default movement amount - if self.context_manager.move_divider_left(amount) { - self.render(); + #[cfg(not(target_os = "macos"))] + { + tracing::warn!("Directory jumper UI is only supported on macOS"); } } - pub fn move_divider_right(&mut self) { - let amount = 40.0; // Default movement amount - if self.context_manager.move_divider_right(amount) { - self.render(); + /// Jump to a directory by index (1-based). + pub fn jump_to_directory(&mut self, index: usize) { + let mut data = load_frecency(); + + // Sort by frecency score + data.sort_by(|a, b| { + let score_a = calculate_frecency_score( + a.get("frequency").and_then(|f| f.as_u64()).unwrap_or(0), + a.get("last_access").and_then(|t| t.as_u64()).unwrap_or(0), + ); + let score_b = calculate_frecency_score( + b.get("frequency").and_then(|f| f.as_u64()).unwrap_or(0), + b.get("last_access").and_then(|t| t.as_u64()).unwrap_or(0), + ); + score_b.partial_cmp(&score_a).unwrap_or(std::cmp::Ordering::Equal) + }); + + if index == 0 || index > data.len() { + tracing::warn!("Directory {} not found", index); + return; } + + let entry = &data[index - 1]; + let path = match entry.get("path").and_then(|p| p.as_str()) { + Some(p) => p, + None => { + tracing::error!("Directory entry {} has no path", index); + return; + } + }; + + // Record this visit to update frecency + record_directory_visit(path); + + // cd to the directory + let cd_cmd = format!("cd {}\n", path); + self.paste(&cd_cmd, true); + tracing::info!("Jumping to directory: {}", path); } - pub fn create_tab(&mut self) { - let redirect = true; + /// Execute an action generically - used by IPC and other external triggers. + /// This method delegates to the appropriate handler for each action type. + pub fn execute_action(&mut self, action: &Act) -> Result<(), String> { + match action { + // Basic clipboard actions + Act::Paste => { + let content = self.clipboard.borrow_mut().get(ClipboardType::Clipboard); + self.paste(&content, true); + Ok(()) + } + Act::Copy => { + self.copy_selection(ClipboardType::Clipboard); + Ok(()) + } + Act::PasteSelection => { + let content = self.clipboard.borrow_mut().get(ClipboardType::Selection); + self.paste(&content, true); + Ok(()) + } + Act::ClearSelection => { + self.clear_selection(); + Ok(()) + } - // We resize the current tab ahead to prepare the - // dimensions to be copied to next tab. - let num_tabs = self.ctx().len(); - self.resize_top_or_bottom_line(num_tabs + 1); + // Search actions + Act::SearchForward => { + self.start_search(Direction::Right); + self.resize_top_or_bottom_line(self.ctx().len()); + self.render(); + Ok(()) + } + Act::SearchBackward => { + self.start_search(Direction::Left); + self.resize_top_or_bottom_line(self.ctx().len()); + self.render(); + Ok(()) + } + Act::Search(SearchAction::SearchConfirm) => { + self.confirm_search(); + self.resize_top_or_bottom_line(self.ctx().len()); + self.render(); + Ok(()) + } + Act::Search(SearchAction::SearchCancel) => { + self.cancel_search(); + self.resize_top_or_bottom_line(self.ctx().len()); + self.render(); + Ok(()) + } + Act::Search(SearchAction::SearchClear) => { + let direction = self.search_state.direction; + self.cancel_search(); + self.start_search(direction); + self.resize_top_or_bottom_line(self.ctx().len()); + self.render(); + Ok(()) + } + Act::Search(SearchAction::SearchFocusNext) => { + self.advance_search_origin(self.search_state.direction); + self.resize_top_or_bottom_line(self.ctx().len()); + self.render(); + Ok(()) + } + Act::Search(SearchAction::SearchFocusPrevious) => { + let direction = self.search_state.direction.opposite(); + self.advance_search_origin(direction); + self.resize_top_or_bottom_line(self.ctx().len()); + self.render(); + Ok(()) + } + Act::Search(SearchAction::SearchDeleteWord) => { + self.search_pop_word(); + self.render(); + Ok(()) + } + Act::Search(SearchAction::SearchHistoryPrevious) => { + self.search_history_previous(); + self.render(); + Ok(()) + } + Act::Search(SearchAction::SearchHistoryNext) => { + self.search_history_next(); + self.render(); + Ok(()) + } - let rich_text_id = self.sugarloaf.create_rich_text(); - self.context_manager.add_context(redirect, rich_text_id); + // History and font + Act::ClearHistory => { + let mut terminal = self.context_manager.current_mut().terminal.lock(); + terminal.clear_saved_history(); + drop(terminal); + self.render(); + Ok(()) + } + Act::IncreaseFontSize => { + self.change_font_size(FontSizeAction::Increase); + Ok(()) + } + Act::DecreaseFontSize => { + self.change_font_size(FontSizeAction::Decrease); + Ok(()) + } + Act::ResetFontSize => { + self.change_font_size(FontSizeAction::Reset); + Ok(()) + } - self.cancel_search(); - self.render(); - } + // Window and tab management + Act::WindowCreateNew => { + self.context_manager.create_new_window(); + Ok(()) + } + Act::TabCreateNew => { + self.create_tab(); + Ok(()) + } + Act::MoveCurrentTabToPrev => { + self.cancel_search(); + self.clear_selection(); + self.context_manager.move_current_to_prev(); + self.render(); + Ok(()) + } + Act::MoveCurrentTabToNext => { + self.cancel_search(); + self.clear_selection(); + self.context_manager.move_current_to_next(); + self.render(); + Ok(()) + } + Act::TabCloseCurrent => { + self.close_tab(); + Ok(()) + } + Act::CloseCurrentSplitOrTab => { + self.close_split_or_tab(); + Ok(()) + } + Act::TabCloseUnfocused => { + self.clear_selection(); + self.cancel_search(); + if self.ctx().len() <= 1 { + return Ok(()); + } + self.context_manager.close_unfocused_tabs(); + self.resize_top_or_bottom_line(1); + self.render(); + Ok(()) + } + Act::RenameTab => { + self.rename_current_tab(); + Ok(()) + } + Act::SelectPrevTab => { + self.cancel_search(); + self.clear_selection(); + self.context_manager.switch_to_prev(); + self.render(); + Ok(()) + } + Act::SelectNextTab => { + self.cancel_search(); + self.clear_selection(); + self.context_manager.switch_to_next(); + self.render(); + Ok(()) + } + Act::SelectLastTab => { + self.cancel_search(); + self.context_manager.select_last_tab(); + self.render(); + Ok(()) + } + Act::SelectTab(index) => { + self.context_manager.select_tab(*index); + self.cancel_search(); + self.render(); + Ok(()) + } - pub fn close_split_or_tab(&mut self) { - if self.context_manager.current_grid_len() > 1 { - self.clear_selection(); - self.context_manager.remove_current_grid(); - self.render(); - } else { - self.close_tab(); - } - } + // Bookmarks + Act::AddBookmark => { + self.add_bookmark(); + Ok(()) + } + Act::ShowBookmarks => { + self.show_bookmarks(); + Ok(()) + } + Act::GoToBookmark(index) => { + self.go_to_bookmark(*index); + Ok(()) + } - pub fn close_tab(&mut self) { - self.clear_selection(); - self.context_manager.close_current_context(); + // Configuration + Act::ConfigEditor => { + self.context_manager.switch_to_settings(); + Ok(()) + } - self.cancel_search(); - if self.ctx().len() <= 1 { - return; - } + // Scrolling + Act::ScrollPageUp => { + let mut terminal = self.context_manager.current_mut().terminal.lock(); + let scroll_lines = terminal.grid.screen_lines() as i32; + terminal.vi_mode_cursor = terminal.vi_mode_cursor.scroll(&terminal, scroll_lines); + terminal.scroll_display(Scroll::PageUp); + drop(terminal); + self.render(); + Ok(()) + } + Act::ScrollPageDown => { + let mut terminal = self.context_manager.current_mut().terminal.lock(); + let scroll_lines = -(terminal.grid.screen_lines() as i32); + terminal.vi_mode_cursor = terminal.vi_mode_cursor.scroll(&terminal, scroll_lines); + terminal.scroll_display(Scroll::PageDown); + drop(terminal); + self.render(); + Ok(()) + } + Act::ScrollHalfPageUp => { + let mut terminal = self.context_manager.current_mut().terminal.lock(); + let scroll_lines = terminal.grid.screen_lines() as i32 / 2; + terminal.vi_mode_cursor = terminal.vi_mode_cursor.scroll(&terminal, scroll_lines); + terminal.scroll_display(Scroll::Delta(scroll_lines)); + drop(terminal); + self.render(); + Ok(()) + } + Act::ScrollHalfPageDown => { + let mut terminal = self.context_manager.current_mut().terminal.lock(); + let scroll_lines = -(terminal.grid.screen_lines() as i32 / 2); + terminal.vi_mode_cursor = terminal.vi_mode_cursor.scroll(&terminal, scroll_lines); + terminal.scroll_display(Scroll::Delta(scroll_lines)); + drop(terminal); + self.render(); + Ok(()) + } + Act::ScrollToTop => { + let mut terminal = self.context_manager.current_mut().terminal.lock(); + terminal.scroll_display(Scroll::Top); + drop(terminal); + self.render(); + Ok(()) + } + Act::ScrollToBottom => { + let mut terminal = self.context_manager.current_mut().terminal.lock(); + terminal.scroll_display(Scroll::Bottom); + drop(terminal); + self.render(); + Ok(()) + } + Act::Scroll(lines) => { + let mut terminal = self.context_manager.current_mut().terminal.lock(); + terminal.scroll_display(Scroll::Delta(*lines)); + drop(terminal); + self.render(); + Ok(()) + } - let num_tabs = self.ctx().len().wrapping_sub(1); - self.resize_top_or_bottom_line(num_tabs); - self.render(); + // Split management + Act::SplitRight => { + self.split_right(); + Ok(()) + } + Act::SplitDown => { + self.split_down(); + Ok(()) + } + Act::SelectNextSplit => { + self.cancel_search(); + self.context_manager.select_next_split(); + self.render(); + Ok(()) + } + Act::SelectPrevSplit => { + self.cancel_search(); + self.context_manager.select_prev_split(); + self.render(); + Ok(()) + } + Act::SelectNextSplitOrTab => { + self.cancel_search(); + self.clear_selection(); + self.context_manager.switch_to_next_split_or_tab(); + self.render(); + Ok(()) + } + Act::SelectPrevSplitOrTab => { + self.cancel_search(); + self.clear_selection(); + self.context_manager.switch_to_prev_split_or_tab(); + self.render(); + Ok(()) + } + Act::MoveDividerUp => { + self.move_divider_down(); // Inverted: user wants divider up, means expand bottom + Ok(()) + } + Act::MoveDividerDown => { + self.move_divider_up(); // Inverted: user wants divider down, means expand top + Ok(()) + } + Act::MoveDividerLeft => { + self.move_divider_left(); + Ok(()) + } + Act::MoveDividerRight => { + self.move_divider_right(); + Ok(()) + } + + // Vi mode + Act::ToggleViMode => { + let context = self.context_manager.current_mut(); + let mut terminal = context.terminal.lock(); + terminal.toggle_vi_mode(); + let has_vi_mode_enabled = terminal.mode().contains(Mode::VI); + drop(terminal); + context.renderable_content.pending_update.set_ui_damage(rio_backend::event::TerminalDamage::Full); + self.renderer.set_vi_mode(has_vi_mode_enabled); + self.render(); + Ok(()) + } + + // Session management + Act::SaveSession => { + self.save_session(); + Ok(()) + } + Act::RestoreSession => { + self.restore_session(); + Ok(()) + } + + // SSH + Act::ShowSSHProfiles => { + self.show_ssh_profiles(); + Ok(()) + } + Act::AddSSHProfile => { + self.add_ssh_profile(); + Ok(()) + } + Act::ConnectSSH(index) => { + self.connect_ssh(*index); + Ok(()) + } + + // Snippets + Act::ShowSnippets => { + self.show_snippets(); + Ok(()) + } + Act::AddSnippet => { + self.add_snippet(); + Ok(()) + } + Act::InsertSnippet(index) => { + self.insert_snippet(*index); + Ok(()) + } + + // Broadcast mode + Act::ToggleBroadcast => { + self.toggle_broadcast(); + Ok(()) + } + + // Directory jumper + Act::ShowDirectoryJumper => { + self.show_directory_jumper(); + Ok(()) + } + Act::JumpToDirectory(index) => { + self.jump_to_directory(*index); + Ok(()) + } + + // Run command + Act::Run(program) => { + self.exec(program.program(), program.args()); + Ok(()) + } + + // Esc sequence + Act::Esc(s) => { + self.paste(s, false); + Ok(()) + } + + // Quit + Act::Quit => { + self.context_manager.quit(); + Ok(()) + } + + // Actions that don't make sense for IPC or aren't implemented yet + Act::None => Ok(()), + Act::ClearLogNotice => Ok(()), // No-op for now, notification clearing handled elsewhere + Act::ReceiveChar => Err("ReceiveChar cannot be triggered via IPC".to_string()), + Act::Hint(_) => Err("Hint action cannot be triggered via IPC".to_string()), + Act::ViMotion(_) => Err("Vi motion cannot be triggered via IPC".to_string()), + Act::Vi(_) => Err("Vi action cannot be triggered via IPC".to_string()), + Act::Mouse(_) => Err("Mouse action cannot be triggered via IPC".to_string()), + + #[cfg(not(any(target_os = "macos", windows)))] + Act::CopySelection => { + self.copy_selection(ClipboardType::Selection); + Ok(()) + } + + #[cfg(target_os = "macos")] + Act::Hide => { + Err("Hide window is not supported via IPC".to_string()) + } + + #[cfg(target_os = "macos")] + Act::HideOtherApplications => { + Err("Hide other applications is not supported via IPC".to_string()) + } + + Act::Minimize => { + Err("Minimize is not supported via IPC".to_string()) + } + + Act::SpawnNewInstance => { + Err("Spawn new instance is not supported via IPC".to_string()) + } + + Act::ToggleFullscreen => { + Err("Toggle fullscreen is not supported via IPC".to_string()) + } + + Act::ToggleMaximized => { + Err("Toggle maximized is not supported via IPC".to_string()) + } + + #[cfg(target_os = "macos")] + Act::ToggleSimpleFullscreen => { + Err("Toggle simple fullscreen is not supported via IPC".to_string()) + } + } } pub fn resize_top_or_bottom_line(&mut self, num_tabs: usize) { @@ -2459,8 +3971,71 @@ impl Screen<'_> { self.mouse.accumulated_scroll.y %= height; } + /// Check if pasted text contains dangerous commands. + /// Returns the warning message if dangerous, None otherwise. + fn is_dangerous_paste(text: &str) -> Option<&'static str> { + let text_lower = text.to_lowercase(); + + const DANGEROUS_PATTERNS: &[(&str, &str)] = &[ + ("rm -rf /", "Deletes entire filesystem"), + ("rm -rf ~", "Deletes home directory"), + ("rm -rf *", "Deletes all files in directory"), + ("rm -rf .", "Deletes current directory"), + ("> /dev/sd", "Overwrites disk device"), + ("mkfs.", "Formats filesystem"), + ("dd if=", "Raw disk write operation"), + (":(){ :|:& };:", "Fork bomb - crashes system"), + ("chmod -r 777 /", "Removes all file permissions"), + ("chmod 000 /", "Removes all file permissions"), + ("curl | sh", "Executes remote script"), + ("curl | bash", "Executes remote script"), + ("wget | sh", "Executes remote script"), + ("wget | bash", "Executes remote script"), + ("| sh", "Pipes to shell execution"), + ("| bash", "Pipes to shell execution"), + ("> /dev/null 2>&1 &", "Runs hidden background process"), + ("history -c", "Clears command history"), + ("shred", "Permanently destroys files"), + ]; + + for (pattern, warning) in DANGEROUS_PATTERNS { + if text_lower.contains(pattern) { + return Some(warning); + } + } + None + } + #[inline] pub fn paste(&mut self, text: &str, bracketed: bool) { + // Check for dangerous paste patterns + #[cfg(target_os = "macos")] + if let Some(warning) = Self::is_dangerous_paste(text) { + let preview = if text.len() > 100 { + format!("{}...", &text[..100]) + } else { + text.to_string() + }; + // Escape quotes for AppleScript + let preview_escaped = preview.replace('\\', "\\\\").replace('"', "\\\""); + let script = format!( + r#"display dialog "⚠️ Potentially dangerous paste detected!\n\nWarning: {}\n\nContent preview:\n{}\n\nAre you sure you want to paste this?" buttons {{"Cancel", "Paste Anyway"}} default button "Cancel" with icon caution with title "Dangerous Paste Warning""#, + warning, preview_escaped + ); + + if let Ok(output) = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.contains("Paste Anyway") { + tracing::info!("User cancelled dangerous paste: {}", warning); + return; + } + } + } + if self.search_active() { for c in text.chars() { self.search_input(c); diff --git a/misc/logo-option-a.svg b/misc/logo-option-a.svg new file mode 100644 index 0000000000..d51c4ed120 --- /dev/null +++ b/misc/logo-option-a.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + diff --git a/misc/logo-option-b.svg b/misc/logo-option-b.svg new file mode 100644 index 0000000000..e5288cbaef --- /dev/null +++ b/misc/logo-option-b.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/misc/osx/Rio.app/Contents/Resources/icon.icns b/misc/osx/Rio.app/Contents/Resources/icon.icns deleted file mode 100644 index 7b664fe73a..0000000000 Binary files a/misc/osx/Rio.app/Contents/Resources/icon.icns and /dev/null differ diff --git a/misc/osx/Rio.app/Contents/Info.plist b/misc/osx/midterm.app/Contents/Info.plist similarity index 73% rename from misc/osx/Rio.app/Contents/Info.plist rename to misc/osx/midterm.app/Contents/Info.plist index f59df9ebc9..e81631e5b7 100644 --- a/misc/osx/Rio.app/Contents/Info.plist +++ b/misc/osx/midterm.app/Contents/Info.plist @@ -7,9 +7,9 @@ CFBundleDevelopmentRegion English CFBundleDisplayName - Rio + midterm CFBundleExecutable - rio + midterm CFBundleDocumentTypes @@ -30,7 +30,7 @@ CFBundleTypeIconFile Document CFBundleTypeName - Rio Text Document + midterm Text Document CFBundleTypeRole Editor CFBundleTypeOSTypes @@ -81,13 +81,13 @@ CFBundleIdentifier - com.raphaelamorim.rio + com.jamesisanai.midterm6 CFBundleGetInfoString - Hardware-accelerated GPU terminal emulator. More information: https://raphamorim.io/rio/ + midterm - Powered by Rio Terminal (rioterm.com). Hardware-accelerated GPU terminal emulator. CFBundleInfoDictionaryVersion 6.0 CFBundleName - Rio + midterm CFBundlePackageType APPL CFBundleIconFile @@ -102,12 +102,12 @@ CFBundleURLName - Rio + midterm CFBundleTypeRole Viewer CFBundleURLSchemes - rio + midterm @@ -126,43 +126,43 @@ NSSupportsAutomaticGraphicsSwitching NSAppleEventsUsageDescription - An application in Rio would like to access AppleScript. + An application in midterm would like to access AppleScript. NSSystemAdministrationUsageDescription - An application in Rio requires elevated permissions. + An application in midterm requires elevated permissions. NSBluetoothAlwaysUsageDescription - An application in Rio wants to use Bluetooth. + An application in midterm wants to use Bluetooth. NSCalendarsUsageDescription - An application in Rio would like to access calendar data. + An application in midterm would like to access calendar data. NSCameraUsageDescription - An application in Rio would like to access the camera. + An application in midterm would like to access the camera. NSCameraUseContinuityCameraDeviceType NSContactsUsageDescription - An application in Rio wants to access your contacts. + An application in midterm wants to access your contacts. NSLocationAlwaysUsageDescription - An application in Rio would like to access your location information, even in the background. + An application in midterm would like to access your location information, even in the background. NSLocationUsageDescription - An application in Rio would like to access your location information. + An application in midterm would like to access your location information. NSLocationWhenInUseUsageDescription - An application in Rio would like to access your location information while active. + An application in midterm would like to access your location information while active. NSMicrophoneUsageDescription - An application in Rio would like to access your microphone. + An application in midterm would like to access your microphone. NSLocationTemporaryUsageDescriptionDictionary - A program running inside Rio would like to access your location temporarily. + A program running inside midterm would like to access your location temporarily. NSRemindersUsageDescription - An application in Rio would like to access your reminders. + An application in midterm would like to access your reminders. NSHumanReadableCopyright - Copyright (c) Raphael Amorim 2022-present. All rights reserved. + midterm by James Dowzard. Powered by Rio Terminal - Copyright (c) Raphael Amorim 2022-present. NSRequiresAquaSystemAppearance NO NSSpeechRecognitionUsageDescription - A program running inside Rio would like to access speech recognition. + A program running inside midterm would like to access speech recognition. NSSupportsAutomaticGraphicsSwitching NSSupportsSuddenTermination NSSystemAdministrationUsageDescription - A program running inside Rio requires elevated privileges. + A program running inside midterm requires elevated privileges. TICapsLockLanguageSwitchCapable com.apple.security.automation.apple-events diff --git a/misc/osx/midterm.app/Contents/MacOS/midterm b/misc/osx/midterm.app/Contents/MacOS/midterm new file mode 100755 index 0000000000..9abe964f83 Binary files /dev/null and b/misc/osx/midterm.app/Contents/MacOS/midterm differ diff --git a/misc/osx/Rio.app/Contents/Resources/icon-classic.icns b/misc/osx/midterm.app/Contents/Resources/icon-classic.icns similarity index 100% rename from misc/osx/Rio.app/Contents/Resources/icon-classic.icns rename to misc/osx/midterm.app/Contents/Resources/icon-classic.icns diff --git a/misc/osx/midterm.app/Contents/Resources/icon.icns b/misc/osx/midterm.app/Contents/Resources/icon.icns new file mode 100644 index 0000000000..a6dc809572 Binary files /dev/null and b/misc/osx/midterm.app/Contents/Resources/icon.icns differ diff --git a/rio-backend/src/crosswords/mod.rs b/rio-backend/src/crosswords/mod.rs index db86bf7d39..92f21ba767 100644 --- a/rio-backend/src/crosswords/mod.rs +++ b/rio-backend/src/crosswords/mod.rs @@ -433,6 +433,9 @@ where keyboard_mode_idx: usize, inactive_keyboard_mode_stack: [u8; KEYBOARD_MODE_STACK_MAX_DEPTH], inactive_keyboard_mode_idx: usize, + + // Shell integration: track when a command started executing. + command_start_time: Option, } impl Crosswords { @@ -484,6 +487,7 @@ impl Crosswords { keyboard_mode_idx: 0, inactive_keyboard_mode_stack: Default::default(), inactive_keyboard_mode_idx: 0, + command_start_time: None, } } @@ -2997,6 +3001,53 @@ impl Handler for Crosswords { self.event_proxy .send_event(RioEvent::PtyWrite(response), self.window_id); } + + // Shell integration (OSC 133) handlers + + #[inline] + fn shell_prompt_start(&mut self) { + trace!("Shell integration: prompt start (OSC 133;A)"); + // Reset command timing when prompt starts + self.command_start_time = None; + } + + #[inline] + fn shell_command_start(&mut self) { + trace!("Shell integration: command start (OSC 133;B)"); + } + + #[inline] + fn shell_command_executed(&mut self) { + trace!("Shell integration: command executed (OSC 133;C)"); + // Record when the command started executing + self.command_start_time = Some(std::time::Instant::now()); + } + + #[inline] + fn shell_command_finished(&mut self, exit_code: i32) { + let duration_secs = self + .command_start_time + .map(|start| start.elapsed().as_secs_f64()) + .unwrap_or(0.0); + + trace!( + "Shell integration: command finished (OSC 133;D) exit={} duration={:.1}s", + exit_code, + duration_secs + ); + + // Send event for notification handling + self.event_proxy.send_event( + RioEvent::CommandFinished { + exit_code, + duration_secs, + }, + self.window_id, + ); + + // Reset timing + self.command_start_time = None; + } } pub struct CrosswordsSize { diff --git a/rio-backend/src/event/mod.rs b/rio-backend/src/event/mod.rs index fe3dc75596..119ed1f117 100644 --- a/rio-backend/src/event/mod.rs +++ b/rio-backend/src/event/mod.rs @@ -162,6 +162,13 @@ pub enum RioEvent { // No operation Noop, + + /// Command finished (from OSC 133 shell integration). + /// Contains exit code and duration in seconds. + CommandFinished { + exit_code: i32, + duration_secs: f64, + }, } impl Debug for RioEvent { @@ -231,6 +238,9 @@ impl Debug for RioEvent { RioEvent::ColorChange(route_id, color, rgb) => { write!(f, "ColorChange({route_id}, {color:?}, {rgb:?})") } + RioEvent::CommandFinished { exit_code, duration_secs } => { + write!(f, "CommandFinished(exit={exit_code}, duration={duration_secs:.1}s)") + } } } } diff --git a/rio-backend/src/performer/handler.rs b/rio-backend/src/performer/handler.rs index 659db7dc4a..889b1445b2 100644 --- a/rio-backend/src/performer/handler.rs +++ b/rio-backend/src/performer/handler.rs @@ -397,6 +397,20 @@ pub trait Handler { /// Handle XTGETTCAP response. fn xtgettcap_response(&mut self, _response: String) {} + + // Shell integration (OSC 133) + + /// OSC 133;A - Prompt started. + fn shell_prompt_start(&mut self) {} + + /// OSC 133;B - Command started (user pressed Enter). + fn shell_command_start(&mut self) {} + + /// OSC 133;C - Command is now executing. + fn shell_command_executed(&mut self) {} + + /// OSC 133;D - Command finished with exit code. + fn shell_command_finished(&mut self, _exit_code: i32) {} } pub trait Timeout: Default { @@ -947,6 +961,35 @@ impl copa::Perform for Performer<'_, U, T> { } } + // Shell integration (OSC 133). + // Format: OSC 133 ; [; ] ST + // A = prompt start, B = command start, C = command executed, D = command finished + b"133" => { + if params.len() < 2 || params[1].is_empty() { + unhandled(params); + return; + } + + match params[1] { + b"A" => self.handler.shell_prompt_start(), + b"B" => self.handler.shell_command_start(), + b"C" => self.handler.shell_command_executed(), + b"D" => { + // Parse exit code if provided (format: D;exitcode) + let exit_code = if params.len() > 2 { + simd_utf8::from_utf8_fast(params[2]) + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) + } else { + 0 + }; + self.handler.shell_command_finished(exit_code); + } + _ => unhandled(params), + } + } + _ => unhandled(params), } }