aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 00:07:24 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 16:29:44 +0300
commit3c2b46d24aa6c8011a1e198143752f2aa414d585 (patch)
treea52280bccc824ca656f8bc986a756f30901974e4 /src/app
parent5d7e56737ef30d9de5802b0684a9428c40131dda (diff)
feat: add native windows beta support
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs51
-rw-r--r--src/app/agent_resume.rs40
-rw-r--r--src/app/api.rs7
-rw-r--r--src/app/api/worktrees.rs20
-rw-r--r--src/app/input/mod.rs1
-rw-r--r--src/app/input/mouse.rs1
-rw-r--r--src/app/input/navigate.rs8
-rw-r--r--src/app/input/terminal.rs9
-rw-r--r--src/app/mod.rs16
-rw-r--r--src/app/state.rs35
10 files changed, 167 insertions, 21 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 06ffe51b..371457a8 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -2334,6 +2334,25 @@ impl AppState {
// Intercepted in App::handle_internal_event before reaching this
// dispatch; never touches AppState.
AppEvent::ClipboardWrite { .. } => Vec::new(),
+ AppEvent::TerminalCwdReported { pane_id, cwd } => {
+ if !cwd.is_absolute() || !cwd.is_dir() {
+ return Vec::new();
+ }
+ let Some(terminal_id) = self.workspaces.iter().find_map(|ws| {
+ ws.pane_state(pane_id)
+ .map(|pane| pane.attached_terminal_id.clone())
+ }) else {
+ return Vec::new();
+ };
+ let Some(terminal) = self.terminals.get_mut(&terminal_id) else {
+ return Vec::new();
+ };
+ if terminal.cwd != cwd {
+ terminal.cwd = cwd;
+ self.mark_session_dirty();
+ }
+ Vec::new()
+ }
AppEvent::GitStatusRefreshed {
results,
cache_updates,
@@ -4282,6 +4301,9 @@ mod tests {
fn hidden_session_ref_only_update_marks_session_dirty_without_visible_update() {
let mut state = app_with_workspaces(&["active"]);
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+ let test_dir = std::env::current_dir().unwrap();
+ let first_session = test_dir.join("one.jsonl").display().to_string();
+ let second_session = test_dir.join("two.jsonl").display().to_string();
let first_updates = state.handle_app_event(AppEvent::HookStateReported {
pane_id,
@@ -4291,7 +4313,7 @@ mod tests {
message: None,
custom_status: None,
seq: Some(20),
- session_ref: crate::agent_resume::AgentSessionRef::path("/tmp/one.jsonl"),
+ session_ref: crate::agent_resume::AgentSessionRef::path(first_session),
});
assert_eq!(first_updates.len(), 1);
state.session_dirty = false;
@@ -4304,7 +4326,7 @@ mod tests {
message: None,
custom_status: None,
seq: Some(21),
- session_ref: crate::agent_resume::AgentSessionRef::path("/tmp/two.jsonl"),
+ session_ref: crate::agent_resume::AgentSessionRef::path(second_session),
});
assert!(second_updates.is_empty());
@@ -4312,6 +4334,31 @@ mod tests {
}
#[test]
+ fn terminal_cwd_report_updates_terminal_cwd_and_marks_session_dirty() {
+ let mut state = app_with_workspaces(&["active"]);
+ let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+ let terminal_id = state.workspaces[0]
+ .pane_state(pane_id)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+ let cwd =
+ std::env::temp_dir().join(format!("herdr-cwd-report-test-{}", std::process::id()));
+ std::fs::create_dir_all(&cwd).unwrap();
+ state.session_dirty = false;
+
+ let updates = state.handle_app_event(AppEvent::TerminalCwdReported {
+ pane_id,
+ cwd: cwd.clone(),
+ });
+
+ assert!(updates.is_empty());
+ assert_eq!(state.terminals.get(&terminal_id).unwrap().cwd, cwd);
+ assert!(state.session_dirty);
+ let _ = std::fs::remove_dir_all(cwd);
+ }
+
+ #[test]
fn background_idle_sets_finished_toast() {
let mut state = app_with_workspaces(&["active", "background"]);
state.active = Some(0);
diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs
index 8ff6cc4d..894b0b37 100644
--- a/src/app/agent_resume.rs
+++ b/src/app/agent_resume.rs
@@ -339,6 +339,7 @@ fn shell_quote(value: &str) -> String {
mod tests {
use super::*;
+ #[cfg(unix)]
fn test_app() -> App {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
App::new(
@@ -350,6 +351,21 @@ mod tests {
)
}
+ #[cfg(unix)]
+ fn long_running_test_argv() -> Vec<String> {
+ vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()]
+ }
+
+ #[cfg(unix)]
+ fn marker_resume_test_argv() -> Vec<String> {
+ vec![
+ "/bin/sh".into(),
+ "-c".into(),
+ "printf '%s' 'restored agent: shell quoted | marker'; sleep 5".into(),
+ ]
+ }
+
+ #[cfg(unix)]
#[tokio::test]
async fn pending_agent_resume_waits_for_host_theme_before_launch() {
let mut app = test_app();
@@ -371,11 +387,7 @@ mod tests {
.expect("test terminal should exist");
terminal.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
agent: "codex".into(),
- argv: vec![
- "/bin/sh".into(),
- "-c".into(),
- "printf '%s' 'restored agent: shell quoted | marker'; sleep 5".into(),
- ],
+ argv: marker_resume_test_argv(),
dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
});
@@ -432,6 +444,7 @@ mod tests {
}
}
+ #[cfg(unix)]
#[tokio::test]
async fn pending_agent_resume_can_launch_after_theme_wait_expires() {
let mut app = test_app();
@@ -451,7 +464,7 @@ mod tests {
.expect("test terminal should exist")
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
agent: "codex".into(),
- argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ argv: long_running_test_argv(),
dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
});
@@ -465,6 +478,7 @@ mod tests {
}
}
+ #[cfg(not(windows))]
#[tokio::test]
async fn pending_agent_resume_launches_hidden_panes_with_current_terminal_area() {
let mut app = test_app();
@@ -500,7 +514,7 @@ mod tests {
.expect("test terminal should exist")
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
agent: "codex".into(),
- argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ argv: long_running_test_argv(),
dedupe_key: format!("herdr:codex\0codex\0Id\0{terminal_id}"),
});
}
@@ -520,6 +534,7 @@ mod tests {
}
}
+ #[cfg(not(windows))]
#[tokio::test]
async fn pending_agent_resume_launches_inactive_tab_panes_with_current_terminal_area() {
let mut app = test_app();
@@ -562,7 +577,7 @@ mod tests {
.expect("inactive tab terminal should exist")
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
agent: "codex".into(),
- argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ argv: long_running_test_argv(),
dedupe_key: "herdr:codex\0codex\0Id\0inactive-tab-session".into(),
});
@@ -583,6 +598,7 @@ mod tests {
}
}
+ #[cfg(not(windows))]
#[tokio::test]
async fn pending_agent_resume_launches_zoom_hidden_active_tab_panes() {
let mut app = test_app();
@@ -620,7 +636,7 @@ mod tests {
.expect("hidden zoom pane terminal should exist")
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
agent: "codex".into(),
- argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ argv: long_running_test_argv(),
dedupe_key: "herdr:codex\0codex\0Id\0zoom-hidden-session".into(),
});
@@ -641,6 +657,7 @@ mod tests {
}
}
+ #[cfg(not(windows))]
#[tokio::test]
async fn pending_agent_resume_uses_current_terminal_area_for_background_panes() {
let mut app = test_app();
@@ -676,7 +693,7 @@ mod tests {
.expect("test terminal should exist")
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
agent: "codex".into(),
- argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ argv: long_running_test_argv(),
dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
});
@@ -699,6 +716,7 @@ mod tests {
}
}
+ #[cfg(unix)]
#[tokio::test]
async fn pending_agent_resume_launches_with_inner_rect_size() {
let mut app = test_app();
@@ -734,7 +752,7 @@ mod tests {
.expect("test terminal should exist")
.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan {
agent: "codex".into(),
- argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()],
+ argv: long_running_test_argv(),
dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(),
});
diff --git a/src/app/api.rs b/src/app/api.rs
index 4bb89a77..166ea789 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -117,8 +117,12 @@ impl App {
} else {
None
};
+ let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. });
let previous_toast = self.state.toast.clone();
let pane_updates = self.state.handle_app_event(ev);
+ if terminal_cwd_reported {
+ self.mark_git_status_refresh_due(Instant::now());
+ }
for update in &pane_updates {
self.refresh_new_herdr_toast_context_for_update(update, &previous_toast);
self.emit_pane_state_update(update);
@@ -811,6 +815,7 @@ mod tests {
use super::*;
use crate::detect::{Agent, AgentState};
+ #[cfg(unix)]
fn init_repo(path: &std::path::Path) {
let status = std::process::Command::new("git")
.args(["init", "-q"])
@@ -820,6 +825,7 @@ mod tests {
assert!(status.success(), "git init failed for {}", path.display());
}
+ #[cfg(unix)]
#[tokio::test]
async fn herdr_toast_context_uses_live_root_runtime_cwd_label() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -912,6 +918,7 @@ mod tests {
let _ = std::fs::remove_dir_all(temp_root);
}
+ #[cfg(unix)]
#[tokio::test]
async fn delayed_herdr_toast_context_uses_live_root_runtime_cwd_label() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs
index eb45a6f2..d6a269ca 100644
--- a/src/app/api/worktrees.rs
+++ b/src/app/api/worktrees.rs
@@ -898,8 +898,19 @@ mod tests {
App::new(&Config::default(), true, None, api_rx, event_hub)
}
+ #[cfg(windows)]
+ fn test_shell() -> &'static str {
+ "C:\\Windows\\System32\\whoami.exe"
+ }
+
+ #[cfg(not(windows))]
+ fn test_shell() -> &'static str {
+ "/usr/bin/true"
+ }
+
fn app_with_parent(repo: &Path) -> App {
let mut app = test_app();
+ app.state.default_shell = test_shell().into();
let mut parent = Workspace::test_new("main");
parent.identity_cwd = repo.to_path_buf();
app.state.workspaces = vec![parent];
@@ -954,6 +965,9 @@ mod tests {
);
assert!(workspace.worktree.unwrap().is_linked_worktree);
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
let remove =
crate::worktree::build_worktree_remove_command(&repo, Path::new(&worktree.path), false);
crate::worktree::run_worktree_command(&remove).unwrap();
@@ -968,6 +982,7 @@ mod tests {
let event_hub = crate::api::EventHub::default();
let mut app = test_app_with_event_hub(event_hub.clone());
app.state.worktree_directory = worktree_root.clone();
+ app.state.default_shell = test_shell().into();
let response = app.handle_api_request(Request {
id: "req".into(),
@@ -1000,6 +1015,9 @@ mod tests {
"auto-created parent workspace event should include parent worktree membership"
);
+ for (_, runtime) in app.terminal_runtimes.drain() {
+ runtime.shutdown();
+ }
let remove =
crate::worktree::build_worktree_remove_command(&repo, Path::new(&worktree.path), false);
crate::worktree::run_worktree_command(&remove).unwrap();
@@ -1230,7 +1248,7 @@ mod tests {
let repo = create_committed_repo("api-worktree-open-source-repo");
let event_hub = crate::api::EventHub::default();
let mut app = test_app_with_event_hub(event_hub.clone());
- app.state.default_shell = "/usr/bin/true".into();
+ app.state.default_shell = test_shell().into();
let response = app.handle_api_request(Request {
id: "req".into(),
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index e8761a35..59fa1ccd 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -526,6 +526,7 @@ fn unique_temp_path(name: &str) -> std::path::PathBuf {
}
#[cfg(test)]
+#[cfg(unix)]
fn wait_for_file(path: &std::path::Path) -> String {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while std::time::Instant::now() < deadline {
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index ae10c0fa..e69bb2c1 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -2519,6 +2519,7 @@ mod tests {
assert_eq!(app.state.workspaces[0].display_name(), "a");
}
+ #[cfg(unix)]
#[tokio::test]
async fn keyboard_context_menu_split_keeps_new_runtime() {
let mut app = app_for_mouse_test();
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 8dacc5c9..9d861f66 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -988,12 +988,15 @@ fn shell_quote(value: &str) -> String {
#[cfg(test)]
mod tests {
+ #[cfg(unix)]
use std::time::Duration;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Direction;
- use super::super::{state_with_workspaces, unique_temp_path, wait_for_file};
+ #[cfg(unix)]
+ use super::super::wait_for_file;
+ use super::super::{state_with_workspaces, unique_temp_path};
use super::*;
use crate::{
app::App, config::Config, input::TerminalKey, terminal::TerminalState, workspace::Workspace,
@@ -1897,6 +1900,7 @@ last_pane = "prefix+tab"
assert_eq!(state.workspaces.len(), 2);
}
+ #[cfg(unix)]
#[tokio::test]
async fn custom_command_runs_from_prefix_key_in_navigate_mode() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -1946,6 +1950,7 @@ last_pane = "prefix+tab"
let _ = std::fs::remove_file(output_path);
}
+ #[cfg(unix)]
#[tokio::test]
async fn pane_overlay_command_opens_and_closes_after_exit() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -2033,6 +2038,7 @@ last_pane = "prefix+tab"
}
}
+ #[cfg(unix)]
#[tokio::test]
async fn edit_scrollback_key_opens_focused_runtime_scrollback_in_editor_pane() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index c60a9f48..60f2d4cc 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -197,9 +197,9 @@ mod tests {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
use ratatui::layout::Rect;
- use super::super::{
- app_for_mouse_test, mouse, numbered_lines_bytes, unique_temp_path, wait_for_file,
- };
+ use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes};
+ #[cfg(unix)]
+ use super::super::{unique_temp_path, wait_for_file};
use super::*;
use crate::{config::Config, events::AppEvent, workspace::Workspace};
@@ -738,6 +738,7 @@ mod tests {
assert_eq!(app.state.mode, Mode::Terminal);
}
+ #[cfg(unix)]
#[tokio::test]
async fn terminal_direct_edit_scrollback_opens_editor_pane() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -791,6 +792,7 @@ mod tests {
let _ = std::fs::remove_file(output_path);
}
+ #[cfg(unix)]
#[tokio::test]
async fn direct_custom_command_runs_before_forwarding_to_pane() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -827,6 +829,7 @@ mod tests {
let _ = std::fs::remove_file(output_path);
}
+ #[cfg(unix)]
#[tokio::test]
async fn direct_custom_pane_command_opens_overlay_pane() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 6a387c9a..3a038dee 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -1511,6 +1511,16 @@ mod tests {
)
}
+ #[cfg(windows)]
+ fn exiting_test_command() -> &'static str {
+ "C:\\Windows\\System32\\whoami.exe"
+ }
+
+ #[cfg(not(windows))]
+ fn exiting_test_command() -> &'static str {
+ "/usr/bin/true"
+ }
+
#[derive(Clone, Default)]
struct FakePrefixInputSource {
switch_calls: Rc<Cell<usize>>,
@@ -3112,7 +3122,7 @@ mod tests {
async fn pane_split_request_targets_pane_in_background_tab() {
let _guard = config_env_lock().lock().unwrap();
let original_shell = std::env::var_os("SHELL");
- std::env::set_var("SHELL", "/usr/bin/true");
+ std::env::set_var("SHELL", exiting_test_command());
let mut app = test_app();
let mut workspace = Workspace::test_new("api-pane-split-background-tab");
@@ -3209,7 +3219,7 @@ mod tests {
async fn pane_split_request_focuses_new_pane_when_requested() {
let _guard = config_env_lock().lock().unwrap();
let original_shell = std::env::var_os("SHELL");
- std::env::set_var("SHELL", "/usr/bin/true");
+ std::env::set_var("SHELL", exiting_test_command());
let mut app = test_app();
let mut workspace = Workspace::test_new("api-pane-split-focus-background-tab");
@@ -3363,7 +3373,7 @@ mod tests {
tab_id: None,
split: Some(crate::api::schema::SplitDirection::Right),
focus: true,
- argv: vec!["/usr/bin/true".into()],
+ argv: vec![exiting_test_command().into()],
}),
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
diff --git a/src/app/state.rs b/src/app/state.rs
index 494bee0f..32cf087d 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1419,6 +1419,22 @@ impl AppState {
self.switch_ascii_input_source_in_prefix
}
+ pub(crate) fn pane_exposes_host_cursor(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> bool {
+ let has_effective_agent_label = self
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.terminal_id(pane_id))
+ .and_then(|terminal_id| self.terminals.get(terminal_id))
+ .and_then(crate::terminal::TerminalState::effective_agent_label)
+ .is_some();
+
+ pane_exposes_host_cursor_for_target(has_effective_agent_label, cfg!(windows))
+ }
+
pub(crate) fn integration_updates_available(&self) -> bool {
self.integration_recommendations
.iter()
@@ -1734,12 +1750,31 @@ impl AppState {
}
}
+fn pane_exposes_host_cursor_for_target(
+ has_effective_agent_label: bool,
+ target_is_windows: bool,
+) -> bool {
+ !target_is_windows || !has_effective_agent_label
+}
+
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEvent;
#[test]
+ fn windows_agent_panes_do_not_expose_host_cursor() {
+ assert!(!pane_exposes_host_cursor_for_target(true, true));
+ assert!(pane_exposes_host_cursor_for_target(false, true));
+ }
+
+ #[test]
+ fn non_windows_agent_panes_keep_existing_host_cursor_behavior() {
+ assert!(pane_exposes_host_cursor_for_target(true, false));
+ assert!(pane_exposes_host_cursor_for_target(false, false));
+ }
+
+ #[test]
fn built_in_theme_names_resolve() {
for name in THEME_NAMES {
assert!(