aboutsummaryrefslogtreecommitdiff
path: root/src/app/mod.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/app/mod.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/app/mod.rs')
-rw-r--r--src/app/mod.rs4139
1 files changed, 317 insertions, 3822 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs
index c28f62da..90ee33b8 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -2,7 +2,6 @@
//!
//! - `state.rs` — AppState, Mode, and pure data structs
//! - `actions.rs` — state mutations (testable without PTYs/async)
-//! - `input.rs` — key/mouse → action translation
pub(crate) mod actions;
mod agent_resume;
@@ -12,15 +11,13 @@ pub(crate) use agents::{AGENT_START_SETTLE_DELAY, MAX_AGENT_START_TIMEOUT};
mod api;
mod api_helpers;
pub(crate) use api_helpers::limit_snapshot_lines;
-mod config_io;
mod creation;
+mod custom_commands;
mod git_refresh;
mod ids;
-mod input;
pub(crate) mod pane_graphics;
mod popup;
mod runtime;
-mod runtime_mutations;
mod session;
pub mod state;
mod tab_bar_status;
@@ -30,31 +27,20 @@ mod theme_sync;
mod window_title;
mod worktrees;
-use std::collections::{HashMap, HashSet};
-use std::future::pending;
-use std::io::{self, Write};
+use std::collections::HashMap;
+#[cfg(unix)]
+use std::io;
use std::sync::Arc;
use std::time::{Duration, Instant};
const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16);
-pub(crate) const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(30);
-const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100);
const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500);
const GIT_REPO_DISCOVERY_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
const PENDING_AGENT_RESUME_THEME_WAIT: Duration = Duration::from_millis(750);
const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5);
-const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350);
-const PANE_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350);
-const PANE_COPY_HIGHLIGHT_DURATION: Duration = Duration::from_millis(500);
-const COPY_FEEDBACK_DURATION: Duration = Duration::from_secs(2);
-
-use crossterm::{
- event::{DisableMouseCapture, EnableMouseCapture},
- execute, terminal,
-};
+
use ratatui::layout::Rect;
-use ratatui::DefaultTerminal;
use tokio::sync::{mpsc, Notify};
use tracing::info;
@@ -80,21 +66,37 @@ pub(crate) struct OverlayPaneState {
temp_files: Vec<std::path::PathBuf>,
}
-#[derive(Debug, Clone, Copy)]
-pub(crate) struct PaneClickState {
- pane_id: crate::layout::PaneId,
- viewport_row: u16,
- col: u16,
- at: Instant,
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct AppPolicy {
+ pub(crate) restore_session: bool,
+ pub(crate) persist_session: bool,
+ pub(crate) persist_plugin_registry: bool,
+ pub(crate) background_updates: bool,
}
-impl PaneClickState {
- fn is_double_click_for(self, next: Self) -> bool {
- self.pane_id == next.pane_id
- && next.at.duration_since(self.at) <= PANE_DOUBLE_CLICK_WINDOW
- && self.viewport_row.abs_diff(next.viewport_row) <= 1
- && self.col.abs_diff(next.col) <= 1
- }
+impl AppPolicy {
+ pub(crate) const PRODUCTION: Self = Self {
+ restore_session: true,
+ persist_session: true,
+ persist_plugin_registry: true,
+ background_updates: true,
+ };
+
+ #[cfg(test)]
+ pub(crate) const TEST: Self = Self {
+ restore_session: false,
+ persist_session: false,
+ persist_plugin_registry: false,
+ background_updates: false,
+ };
+
+ #[cfg(unix)]
+ pub(crate) const HANDOFF_REPLACEMENT: Self = Self {
+ restore_session: false,
+ persist_session: true,
+ persist_plugin_registry: true,
+ background_updates: true,
+ };
}
pub struct App {
@@ -109,12 +111,9 @@ pub struct App {
pub(crate) api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>,
pub(crate) event_hub: crate::api::EventHub,
pub(crate) last_focus: Option<(usize, crate::layout::PaneId)>,
- pub(crate) no_session: bool,
- pub(crate) input_rx: Option<mpsc::Receiver<crate::raw_input::RawInputEvent>>,
- pub(crate) last_terminal_size: Option<(u16, u16)>,
+ pub(crate) policy: AppPolicy,
pub(crate) config_diagnostic_deadline: Option<Instant>,
pub(crate) toast_deadline: Option<Instant>,
- pub(crate) copy_feedback_deadline: Option<Instant>,
pub(crate) last_api_notification_at: Option<Instant>,
pub(crate) last_git_remote_status_refresh: Instant,
pub(crate) last_git_repo_discovery_refresh: Instant,
@@ -126,10 +125,6 @@ pub struct App {
pub(crate) pending_api_worktree_removes: HashMap<String, u64>,
pub(crate) pending_api_worktree_remove_paths: HashMap<std::path::PathBuf, u64>,
pub(crate) next_api_worktree_operation_id: u64,
- pub(crate) last_sidebar_divider_click: Option<Instant>,
- pub(crate) last_pane_click: Option<PaneClickState>,
- pub(crate) pending_url_click_sources: HashSet<InputSourceId>,
- pub(crate) next_resize_poll: Instant,
pub(crate) next_auto_update_check: Option<Instant>,
pub(crate) next_agent_manifest_update_check: Option<Instant>,
pub(crate) update_version_check_enabled: bool,
@@ -137,8 +132,6 @@ pub struct App {
pub(crate) loaded_host_cursor: crate::config::HostCursorModeConfig,
pub(crate) agent_metadata_deadline: Option<Instant>,
pub(crate) pending_agent_resume_deadline: Option<Instant>,
- pub(crate) selection_autoscroll_deadline: Option<Instant>,
- pub(crate) selection_highlight_clear_deadline: Option<Instant>,
pub(crate) session_save_deadline: Option<Instant>,
pub(crate) session_save_thread: Option<std::thread::JoinHandle<()>>,
pub(crate) detached_process_children: Vec<std::process::Child>,
@@ -153,91 +146,30 @@ pub struct App {
pub(crate) last_render_at: Option<Instant>,
/// Last attempt that could update a connected presentation surface.
pub(crate) last_presentation_at: Option<Instant>,
- pub(crate) input_leases: input::InputLeaseTable,
pub render_notify: Arc<Notify>,
pub(crate) render_dirty: Arc<crate::render_signal::RenderSignal>,
pub(crate) full_redraw_pending: bool,
pub(crate) overlay_panes: HashMap<crate::layout::PaneId, OverlayPaneState>,
- pub(crate) local_terminal_notifications: bool,
- /// Whether this process applies `AppEvent::PrefixInputSource` to the host input source.
- /// The headless server sets this to false: the switch belongs to the foreground client,
- /// even when an App-internal drain consumes the event before the forwarding drain.
- pub(crate) local_input_source_switch: bool,
pub(crate) config_reloaded_from_disk: bool,
- prefix_input_source: Box<dyn crate::platform::PrefixInputSource>,
+ client_shell_keybindings_profile: Option<String>,
+ endpoint_commands: custom_commands::EndpointCommandRegistry,
}
pub(crate) const APP_EVENT_CHANNEL_CAPACITY: usize = 256;
pub(crate) const APP_EVENT_DRAIN_LIMIT: usize = 64;
-pub(crate) enum LoopEvent {
- Timer,
- Internal(AppEvent),
- Api(Box<crate::api::ApiRequestMessage>),
- RawInput(crate::raw_input::RawInputEvent),
- InputClosed,
- RenderRequested,
+fn auto_updates_enabled(background_updates: bool) -> bool {
+ background_updates && !cfg!(debug_assertions)
}
-struct SyncOutputGuard;
-
-impl SyncOutputGuard {
- fn begin() -> io::Result<Self> {
- let mut stdout = io::stdout().lock();
- stdout.write_all(b"\x1b[?2026h")?;
- stdout.flush()?;
- Ok(Self)
- }
+fn background_update_check_enabled(background_updates: bool, check_enabled: bool) -> bool {
+ auto_updates_enabled(background_updates) && check_enabled
}
-impl Drop for SyncOutputGuard {
- fn drop(&mut self) {
- let mut stdout = io::stdout().lock();
- let _ = stdout.write_all(b"\x1b[?2026l");
- let _ = stdout.flush();
- }
-}
-
-async fn recv_raw_input_or_pending(
- input_rx: Option<&mut mpsc::Receiver<crate::raw_input::RawInputEvent>>,
-) -> Option<crate::raw_input::RawInputEvent> {
- match input_rx {
- Some(rx) => rx.recv().await,
- None => pending().await,
- }
-}
-
-async fn sleep_until_or_pending(deadline: Option<Instant>) {
- match deadline {
- Some(deadline) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await,
- None => pending().await,
- }
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub(crate) struct TerminalInputTarget {
- terminal_id: crate::terminal::TerminalId,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub(crate) enum TerminalInputContext {
- Pane,
- Popup(crate::terminal::TerminalId),
-}
-
-pub(crate) type InputSourceId = u64;
-const LOCAL_INPUT_SOURCE: InputSourceId = 0;
-
-fn auto_updates_enabled(no_session: bool) -> bool {
- !no_session && !cfg!(debug_assertions)
-}
-
-fn background_update_check_enabled(no_session: bool, check_enabled: bool) -> bool {
- auto_updates_enabled(no_session) && check_enabled
-}
-
-fn load_plugin_registry(no_session: bool) -> crate::app::state::InstalledPluginRegistry {
- if no_session {
+fn load_plugin_registry(
+ persist_plugin_registry: bool,
+) -> crate::app::state::InstalledPluginRegistry {
+ if !persist_plugin_registry {
return std::collections::HashMap::new();
}
let entries = crate::persist::plugin_registry::load();
@@ -393,10 +325,33 @@ fn resolve_effective_theme(
)
}
+pub(crate) fn client_theme_runtime_from_config(config: &Config) -> state::ThemeRuntimeConfig {
+ theme_runtime_config(config, true)
+}
+
+pub(crate) fn client_palette_for_theme(
+ runtime: &state::ThemeRuntimeConfig,
+ name: &str,
+) -> state::Palette {
+ resolve_palette_for_theme_name(name, "catppuccin", runtime, None)
+}
+
+pub(crate) fn client_palette_from_config(config: &Config) -> state::Palette {
+ let runtime = client_theme_runtime_from_config(config);
+ resolve_effective_theme(&runtime, None).0
+}
+
+pub(crate) fn client_palette_for_appearance(
+ runtime: &state::ThemeRuntimeConfig,
+ appearance: crate::terminal_theme::HostAppearance,
+) -> state::Palette {
+ resolve_effective_theme(runtime, Some(appearance)).0
+}
+
impl App {
pub fn new(
config: &Config,
- no_session: bool,
+ policy: AppPolicy,
config_diagnostic: Option<String>,
api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>,
event_hub: crate::api::EventHub,
@@ -410,24 +365,8 @@ impl App {
// Try to restore previous session
let mut restored_terminals = std::collections::HashMap::new();
let mut restored_terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- let (
- workspaces,
- active,
- selected,
- sidebar_width,
- sidebar_width_source,
- sidebar_section_split,
- collapsed_space_keys,
- ) = if no_session {
- (
- Vec::new(),
- None,
- 0,
- config.ui.sidebar_width,
- state::SidebarWidthSource::ConfigDefault,
- 0.5_f32,
- std::collections::HashSet::new(),
- )
+ let (workspaces, active, selected) = if !policy.restore_session {
+ (Vec::new(), None, 0)
} else if let Some(snap) = crate::persist::load() {
let history = config
.experimental
@@ -451,67 +390,19 @@ impl App {
restored_terminal_runtimes = terminal_runtimes.into();
if ws.is_empty() {
crate::logging::session_restored(0, "empty");
- (
- Vec::new(),
- None,
- 0,
- snap.sidebar_width.unwrap_or(config.ui.sidebar_width),
- if snap.sidebar_width.is_some() {
- state::SidebarWidthSource::Persisted
- } else {
- state::SidebarWidthSource::ConfigDefault
- },
- snap.sidebar_section_split.unwrap_or(0.5),
- snap.collapsed_space_keys,
- )
+ (Vec::new(), None, 0)
} else {
crate::logging::session_restored(ws.len(), "ok");
let active = snap.active.filter(|&i| i < ws.len());
let selected = snap.selected.min(ws.len().saturating_sub(1));
- (
- ws,
- active,
- selected,
- snap.sidebar_width.unwrap_or(config.ui.sidebar_width),
- if snap.sidebar_width.is_some() {
- state::SidebarWidthSource::Persisted
- } else {
- state::SidebarWidthSource::ConfigDefault
- },
- snap.sidebar_section_split.unwrap_or(0.5),
- snap.collapsed_space_keys,
- )
+ (ws, active, selected)
}
} else {
- (
- Vec::new(),
- None,
- 0,
- config.ui.sidebar_width,
- state::SidebarWidthSource::ConfigDefault,
- 0.5_f32,
- std::collections::HashSet::new(),
- )
+ (Vec::new(), None, 0)
};
let agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort);
- // Validate sidebar bounds before they reach any `u16::clamp(min, max)`
- // call: `clamp` panics when `min > max`. On bad config, fall back to
- // the built-in defaults rather than crashing on the first render.
- let (sidebar_min_width, sidebar_max_width) = crate::config::validated_sidebar_bounds(
- config.ui.sidebar_min_width,
- config.ui.sidebar_max_width,
- )
- .unwrap_or_else(|| {
- tracing::warn!(
- min = config.ui.sidebar_min_width,
- max = config.ui.sidebar_max_width,
- "ui.sidebar_min_width is greater than sidebar_max_width; falling back to default bounds (18, 36)"
- );
- (18, 36)
- });
-
let worktree_directory =
crate::worktree::expand_tilde_absolute_path(&config.worktrees.directory);
@@ -530,11 +421,7 @@ impl App {
let startup_product_announcement =
crate::product_announcements::load_unseen_for_current_version();
- let mode = if config.should_show_onboarding() {
- state::Mode::Onboarding
- } else if startup_product_announcement.is_some() {
- state::Mode::ProductAnnouncement
- } else if active.is_some() {
+ let mode = if active.is_some() {
state::Mode::Terminal
} else {
state::Mode::Navigate
@@ -560,33 +447,9 @@ impl App {
selected,
mode,
should_quit: false,
- detach_exits: no_session,
- detach_requested: false,
- request_new_workspace: false,
- request_new_tab: false,
- request_new_linked_worktree: None,
- request_open_existing_worktree: None,
- request_new_workspace_cwd: None,
- request_remove_linked_worktree: None,
- request_submit_worktree_create: false,
- request_submit_worktree_open: false,
- request_submit_worktree_remove: false,
- request_reload_config: false,
request_client_config_reload: false,
- request_clipboard_write: None,
- creating_new_tab: false,
- requested_new_tab_name: None,
- pending_workspace_create_cwd: None,
- rename_pane_target: None,
- worktree_create: None,
- worktree_open: None,
- worktree_remove: None,
worktree_directory,
- collapsed_space_keys,
- request_complete_onboarding: false,
- name_input: String::new(),
- name_input_replace_on_type: false,
- release_notes: None,
+ latest_release_notes,
product_announcement: startup_product_announcement.map(|announcement| {
state::ProductAnnouncementState {
version: announcement.version,
@@ -597,36 +460,10 @@ impl App {
preview: announcement.preview,
}
}),
- keybind_help: state::KeybindHelpState::default(),
- navigator: state::NavigatorState::default(),
- copy_mode: None,
- workspace_scroll: 0,
- agent_panel_scroll: 0,
- tab_scroll: 0,
- tab_scroll_follow_active: true,
- mobile_switcher_scroll: 0,
view: state::ViewState {
- layout: state::ViewLayout::Desktop,
- sidebar_rect: Rect::default(),
- workspace_card_areas: Vec::new(),
- tab_bar_rect: Rect::default(),
- tab_hit_areas: Vec::new(),
- tab_scroll_left_hit_area: Rect::default(),
- tab_scroll_right_hit_area: Rect::default(),
- new_tab_hit_area: Rect::default(),
terminal_area: Rect::default(),
- mobile_header_rect: Rect::default(),
- mobile_menu_hit_area: Rect::default(),
- toast_hit_area: Rect::default(),
pane_infos: Vec::new(),
- split_borders: Vec::new(),
},
- drag: None,
- workspace_presses: HashMap::new(),
- tab_presses: HashMap::new(),
- selection: None,
- selection_autoscroll: None,
- context_menu: None,
update_available,
update_install_command,
latest_release_notes_available,
@@ -634,61 +471,33 @@ impl App {
config_diagnostic,
toast: None,
pending_agent_notifications: std::collections::HashMap::new(),
- copy_feedback: None,
outer_terminal_focus: None,
prefix_code,
prefix_mods,
headless_size: config.headless_size(),
- default_sidebar_width: config.ui.sidebar_width,
- sidebar_width,
- sidebar_min_width,
- sidebar_max_width,
- mobile_width_threshold: config.ui.mobile_width_threshold,
- sidebar_width_source,
- sidebar_width_auto: false,
- sidebar_collapsed: config.ui.sidebar_start_collapsed,
- sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode,
- sidebar_section_split,
agent_panel_sort,
- status_indicators: config.ui.status_indicators,
agent_view_override: None,
sidebar_agents: config.ui.sidebar.agents.clone(),
sidebar_spaces: config.ui.sidebar.spaces.clone(),
next_agent_state_change_seq: 0,
- mouse_capture: config.ui.mouse_capture,
- copy_on_select: config.ui.copy_on_select,
- right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(),
- right_click_passthrough: None,
- redraw_on_focus_gained: config.ui.redraw_on_focus_gained,
- mouse_scroll_lines: config.ui.mouse_scroll_lines(),
confirm_close: config.ui.confirm_close,
- prompt_new_tab_name: config.ui.prompt_new_tab_name,
- prompt_new_workspace_name: config.ui.prompt_new_workspace_name,
pane_borders: config.ui.pane_borders,
pane_outer_borders: config.ui.pane_outer_borders,
pane_scrollbars: config.ui.pane_scrollbars,
pane_gaps: config.ui.pane_gaps,
show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders,
- hide_tab_bar_when_single_tab: config.ui.hide_tab_bar_when_single_tab,
- tab_bar_position: config.ui.tab_bar_position,
tab_bar_right: Vec::new(),
tab_bar_right_separator: String::new(),
- pane_history_persistence: config.experimental.pane_history,
reveal_hidden_cursor_for_cjk_ime: config.experimental.reveal_hidden_cursor_for_cjk_ime,
cjk_ime_agent_filter_configured: !config.experimental.cjk_ime_agents.is_empty(),
cjk_ime_agents: parse_cjk_ime_agents(&config.experimental.cjk_ime_agents),
cjk_ime_cursor_shape: config.experimental.cjk_ime_cursor_shape.to_decscusr(),
- switch_ascii_input_source_in_prefix: config
- .experimental
- .switch_ascii_input_source_in_prefix,
kitty_graphics_enabled: config.experimental.kitty_graphics,
default_shell: config.terminal.default_shell.clone(),
shell_mode: config.terminal.shell_mode,
new_terminal_cwd: config.terminal.new_cwd.clone(),
pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes,
- accent: crate::config::parse_color(&config.ui.accent),
sound: config.ui.sound.clone(),
- local_sound_playback: true,
toast_config: config.ui.toast.clone(),
keybinds: config.keybinds(),
palette: theme_palette,
@@ -696,29 +505,19 @@ impl App {
theme_runtime,
host_terminal_appearance: None,
host_terminal_appearance_explicit: false,
- settings: state::SettingsState {
- section: state::SettingsSection::Theme,
- list: state::SelectionListState::new(0),
- original_palette: None,
- original_theme: None,
- },
integration_recommendations: crate::integration::integration_recommendations(),
agent_manifest_summaries,
agent_manifest_update_status: crate::detect::manifest_update::load_status(),
- integration_install_messages: Vec::new(),
- installed_plugins: load_plugin_registry(no_session),
+ installed_plugins: load_plugin_registry(policy.persist_plugin_registry),
plugin_panes: std::collections::HashMap::new(),
popup_pane: None,
plugin_command_logs: Vec::new(),
next_plugin_command_log_id: 1,
plugin_commands_in_flight: 0,
- global_menu: state::MenuListState::new(0),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
host_cell_size: crate::kitty_graphics::HostCellSize::default(),
- host_mouse_pixels: None,
session_dirty: false,
terminal_runtime_shutdowns: Vec::new(),
- confirm_close_workspace_id: None,
};
state.terminals = restored_terminals;
@@ -730,13 +529,15 @@ impl App {
cwd.as_deref().and_then(crate::workspace::git_branch);
}
- // Background auto-update is disabled in monolithic no-session mode
+ // Background auto-update is disabled for non-persistent test apps
// and in debug/test builds so local development never mutates the
// running binary out from under spawned test processes.
let version_check_enabled =
- background_update_check_enabled(no_session, config.update.version_check);
- let manifest_check_enabled =
- background_update_check_enabled(no_session, config.update.manifest_check);
+ background_update_check_enabled(policy.background_updates, config.update.version_check);
+ let manifest_check_enabled = background_update_check_enabled(
+ policy.background_updates,
+ config.update.manifest_check,
+ );
if version_check_enabled {
let update_tx = event_tx.clone();
std::thread::spawn(move || crate::update::auto_update(update_tx));
@@ -754,11 +555,13 @@ impl App {
.get(idx)
.and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id)))
});
+ let client_shell_keybindings_profile = config.local_keybindings_profile_toml().ok();
+ let endpoint_commands =
+ custom_commands::EndpointCommandRegistry::new(&state.keybinds.custom_commands);
let mut app = Self {
config_diagnostic_deadline: None,
toast_deadline: None,
- copy_feedback_deadline: None,
last_api_notification_at: None,
state,
pane_graphics: pane_graphics::Runtime::default(),
@@ -778,10 +581,6 @@ impl App {
pending_api_worktree_removes: HashMap::new(),
pending_api_worktree_remove_paths: HashMap::new(),
next_api_worktree_operation_id: 1,
- last_sidebar_divider_click: None,
- last_pane_click: None,
- pending_url_click_sources: HashSet::new(),
- next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL,
next_auto_update_check: version_check_enabled
.then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL),
next_agent_manifest_update_check: manifest_check_enabled
@@ -799,26 +598,20 @@ impl App {
tab_bar_commands: Vec::new(),
next_tab_bar_datetime_refresh: None,
window_title_template: None,
- selection_autoscroll_deadline: None,
- selection_highlight_clear_deadline: None,
persist_pane_history: config.experimental.pane_history,
last_render_at: None,
last_presentation_at: None,
- input_leases: input::InputLeaseTable::default(),
api_rx,
event_hub,
last_focus,
- no_session,
- input_rx: None,
- last_terminal_size: terminal::size().ok(),
+ policy,
render_notify,
render_dirty,
full_redraw_pending: false,
overlay_panes: HashMap::new(),
- local_terminal_notifications: true,
- local_input_source_switch: true,
config_reloaded_from_disk: false,
- prefix_input_source: Box::new(crate::platform::RealPrefixInputSource::default()),
+ client_shell_keybindings_profile,
+ endpoint_commands,
};
app.configure_tab_bar_status(&config.ui.tab_bar_right, &config.ui.tab_bar_right_separator);
app.configure_window_title(&config.ui.window_title);
@@ -837,7 +630,13 @@ impl App {
crate::handoff_runtime::ImportedHandoffRuntime,
>,
) -> io::Result<Self> {
- let mut app = Self::new(config, true, config_diagnostic, api_rx, event_hub);
+ let mut app = Self::new(
+ config,
+ AppPolicy::HANDOFF_REPLACEMENT,
+ config_diagnostic,
+ api_rx,
+ event_hub,
+ );
let (workspaces, terminals, runtimes) = crate::persist::restore_handoff(
snapshot,
config.advanced.scrollback_limit_bytes,
@@ -850,20 +649,6 @@ impl App {
)?;
let pane_id_aliases = crate::persist::handoff_pane_aliases(snapshot, &workspaces);
- app.no_session = false;
- app.state.installed_plugins = load_plugin_registry(app.no_session);
- let now = Instant::now();
- if background_update_check_enabled(app.no_session, app.update_version_check_enabled) {
- app.next_auto_update_check = app
- .state
- .update_available
- .is_none()
- .then_some(now + AUTO_UPDATE_CHECK_INTERVAL);
- }
- if background_update_check_enabled(app.no_session, app.update_manifest_check_enabled) {
- app.next_agent_manifest_update_check = Some(now + AUTO_UPDATE_CHECK_INTERVAL);
- }
- app.state.detach_exits = false;
app.state.pane_id_aliases = pane_id_aliases;
app.state.workspaces = workspaces;
app.state.terminals = terminals;
@@ -874,14 +659,6 @@ impl App {
app.state.selected = snapshot
.selected
.min(app.state.workspaces.len().saturating_sub(1));
- if let Some(width) = snapshot.sidebar_width {
- app.state.sidebar_width = width;
- app.state.sidebar_width_source = state::SidebarWidthSource::Persisted;
- }
- if let Some(split) = snapshot.sidebar_section_split {
- app.state.sidebar_section_split = split;
- }
- app.state.collapsed_space_keys = snapshot.collapsed_space_keys.clone();
app.state.mode = if app.state.active.is_some() {
state::Mode::Terminal
} else {
@@ -906,367 +683,15 @@ impl App {
self.terminal_runtimes.assume_handoff_ownership();
}
- fn request_repaint(&mut self) {
- self.full_redraw_pending = true;
- }
-
- pub(crate) fn sync_prefix_input_source(&mut self, previous_mode: Mode) {
- // Emit the input-source intent on entering/leaving the ASCII realm, like `ClipboardWrite`;
- // the foreground (client, or this app in monolithic mode) applies the switch. Keyed on the
- // realm so multi-level prefix commands stay ASCII. The switch is flag-gated but the restore
- // always fires on exit, so a mid-interaction flag toggle can't strand the host on ASCII.
- let active = match (
- previous_mode.wants_ascii_input(),
- self.state.mode.wants_ascii_input(),
- ) {
- (false, true) if self.state.switch_ascii_input_source_in_prefix => true,
- (true, false) => false,
- _ => return,
- };
- if let Err(err) = self
- .event_tx
- .try_send(crate::events::AppEvent::PrefixInputSource { active })
- {
- tracing::warn!(active, %err, "failed to queue prefix input-source change");
- }
- }
-
- pub(crate) fn handle_internal_event_with_prefix_sync(
- &mut self,
- event: crate::events::AppEvent,
- ) -> bool {
- let previous_mode = self.state.mode;
- let changed = self.handle_internal_event_with_render_impact(event);
- self.sync_prefix_input_source(previous_mode);
- changed
- }
-
- #[cfg(test)]
- pub(crate) fn set_prefix_input_source(
- &mut self,
- source: Box<dyn crate::platform::PrefixInputSource>,
- ) {
- self.prefix_input_source = source;
- }
-
- pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
- if self.input_rx.is_none() {
- self.input_rx = Some(crate::raw_input::spawn_input_reader());
- }
- self.query_host_terminal_theme();
-
- let mut needs_render = true;
- let mut sent_window_title: Option<Option<String>> = None;
- let mut host_mouse_capture_active = self.state.mouse_capture;
- let mut host_keyboard_report_all_active = false;
-
- while !self.state.should_quit {
- self.reap_finished_detached_processes();
- if self.render_dirty.is_pending() {
- needs_render = true;
- }
- // Drain a bounded internal-event batch for responsiveness. API handlers
- // perform an exhaustive drain before reading pane/runtime state.
- if self.drain_internal_events() {
- needs_render = true;
- }
- if self.expire_due_metadata(Instant::now()) {
- needs_render = true;
- }
- if self.drain_api_requests() {
- needs_render = true;
- }
-
- self.sync_focus_events();
- self.sync_session_save_schedule();
-
- let now = Instant::now();
- if self.handle_scheduled_tasks(now, needs_render) {
- needs_render = true;
- }
-
- if self.state.request_complete_onboarding {
- self.state.request_complete_onboarding = false;
- self.open_settings_from_onboarding();
- needs_render = true;
- }
-
- if self.state.request_new_workspace {
- self.state.request_new_workspace = false;
- self.runtime_workspace_create(
- "tui.workspace.create",
- crate::api::schema::WorkspaceCreateParams {
- cwd: None,
- focus: true,
- label: None,
- env: Default::default(),
- },
- );
- needs_render = true;
- }
-
- if self.state.request_new_tab {
- self.state.request_new_tab = false;
- let label = self.state.requested_new_tab_name.take();
- self.runtime_tab_create(
- "tui.tab.create",
- crate::api::schema::TabCreateParams {
- workspace_id: None,
- cwd: None,
- focus: true,
- label,
- env: Default::default(),
- },
- );
- needs_render = true;
- }
-
- if let Some(ws_idx) = self.state.request_new_linked_worktree.take() {
- self.open_new_linked_worktree_dialog(ws_idx);
- needs_render = true;
- }
-
- if let Some(ws_idx) = self.state.request_open_existing_worktree.take() {
- self.open_existing_worktree_dialog(ws_idx);
- needs_render = true;
- }
-
- if let Some(cwd) = self.state.request_new_workspace_cwd.take() {
- self.runtime_workspace_create(
- "tui.workspace.create_cwd",
- crate::api::schema::WorkspaceCreateParams {
- cwd: Some(cwd.display().to_string()),
- focus: true,
- label: None,
- env: Default::default(),
- },
- );
- needs_render = true;
- }
-
- if let Some(ws_idx) = self.state.request_remove_linked_worktree.take() {
- self.open_remove_linked_worktree_confirmation(ws_idx);
- needs_render = true;
- }
-
- if self.state.request_submit_worktree_create {
- self.state.request_submit_worktree_create = false;
- self.submit_worktree_create_via_api();
- needs_render = true;
- }
-
- if self.state.request_submit_worktree_open {
- self.state.request_submit_worktree_open = false;
- self.submit_worktree_open_via_api();
- needs_render = true;
- }
-
- if self.state.request_submit_worktree_remove {
- self.state.request_submit_worktree_remove = false;
- self.submit_worktree_remove_via_api();
- needs_render = true;
- }
-
- if self.state.request_reload_config {
- self.state.request_reload_config = false;
- self.reload_config();
- needs_render = true;
- }
-
- if self.ensure_default_workspace() {
- needs_render = true;
- }
-
- if self.pane_graphics.retain_live_panes(&self.state) {
- needs_render = true;
- }
-
- let now = Instant::now();
- self.render_dirty
- .set_immediate_pty_sources(self.state.app_surface_pane_ids());
- self.sync_host_mouse_capture(&mut host_mouse_capture_active)?;
- self.sync_host_keyboard_report_all(&mut host_keyboard_report_all_active)?;
-
- if needs_render && self.can_render_now(now) {
- let render_request = self.render_dirty.take();
- self.sync_terminal_titles(&render_request.terminal_title_sources);
- if self.window_title_configured() {
- let title = self
- .window_title()
- .and_then(|title| crate::config::sanitize_window_title_text(&title));
- if sent_window_title.as_ref() != Some(&title) {
- crate::terminal_effects::write_window_title(
- &mut std::io::stdout(),
- title.as_deref(),
- )?;
- sent_window_title = Some(title);
- }
- }
- let _sync_output = SyncOutputGuard::begin()?;
- let kitty_graphics_enabled = self.state.kitty_graphics_enabled;
- if self.full_redraw_pending {
- for cell in &mut terminal.current_buffer_mut().content {
- cell.set_skip(true);
- }
- terminal.swap_buffers();
- self.full_redraw_pending = false;
- }
- let mut cell_size = self.state.host_cell_size;
- terminal.draw(|frame| {
- let area = frame.area();
- if kitty_graphics_enabled {
- if let Some(observed_cell_size) =
- crate::kitty_graphics::HostCellSize::try_from_terminal(area)
- {
- self.state.host_cell_size = observed_cell_size;
- cell_size = observed_cell_size;
- } else if !cell_size.is_known() {
- cell_size =
- crate::kitty_graphics::HostCellSize::fallback_for_area(area);
- }
- crate::ui::compute_view_with_cell_size(
- &mut self.state,
- &self.terminal_runtimes,
- area,
- cell_size,
- );
- } else {
- crate::ui::compute_view_with_runtime_registry(
- &mut self.state,
- &self.terminal_runtimes,
- area,
- );
- }
- crate::ui::render_with_runtime_registry(
- &self.state,
- &self.terminal_runtimes,
- frame,
- );
- })?;
- if kitty_graphics_enabled {
- crate::kitty_graphics::paint_local_pane_graphics(
- &self.state,
- &self.pane_graphics,
- &self.terminal_runtimes,
- cell_size,
- )?;
- }
- self.sync_pending_agent_resume_deadline(now);
- if self.start_pending_agent_resumes(self.pending_agent_resume_due(now)) {
- self.render_dirty.request_generic();
- self.render_notify.notify_one();
- }
- self.record_render_attempt(now, true);
- needs_render = false;
- continue;
- }
-
- let next_deadline = self.next_loop_deadline(now, needs_render);
- let event = {
- let input_rx = self.input_rx.as_mut();
- tokio::select! {
- maybe_api = self.api_rx.recv() => match maybe_api {
- Some(msg) => LoopEvent::Api(Box::new(msg)),
- None => LoopEvent::Timer,
- },
- maybe_ev = self.event_rx.recv() => match maybe_ev {
- Some(ev) => LoopEvent::Internal(ev),
- None => LoopEvent::Timer,
- },
- maybe_input = recv_raw_input_or_pending(input_rx) => match maybe_input {
- Some(input) => LoopEvent::RawInput(input),
- None => LoopEvent::InputClosed,
- },
- _ = sleep_until_or_pending(next_deadline) => LoopEvent::Timer,
- _ = self.render_notify.notified() => LoopEvent::RenderRequested,
- }
- };
-
- match event {
- LoopEvent::Timer => {}
- LoopEvent::Internal(ev) => {
- if self.handle_internal_event_with_prefix_sync(ev) {
- needs_render = true;
- }
- }
- LoopEvent::Api(msg) => {
- if self.handle_api_request_message(*msg) {
- needs_render = true;
- }
- }
- LoopEvent::RawInput(input) => {
- if self.handle_raw_input_batch(input).await {
- needs_render = true;
- }
- }
- LoopEvent::InputClosed => {
- self.input_rx = None;
- }
- LoopEvent::RenderRequested => {
- if self.render_dirty.is_pending() {
- needs_render = true;
- }
- }
- }
- }
-
- // Save session on exit (skip in --no-session mode)
- if !self.no_session {
- self.save_session_now();
- }
-
- Ok(())
- }
-
- fn sync_host_keyboard_report_all(&self, active: &mut bool) -> io::Result<()> {
- let desired = self.host_keyboard_report_all_requested();
- if desired == *active {
- return Ok(());
- }
- crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired)?;
- *active = desired;
- Ok(())
- }
-
- fn sync_host_mouse_capture(&self, active: &mut bool) -> io::Result<()> {
- let desired = self
- .state
- .should_capture_host_mouse_from(&self.terminal_runtimes);
- if desired == *active {
- return Ok(());
- }
- crate::terminal_modes::clear_host_mouse_reporting(&mut io::stdout())?;
- if desired {
- execute!(io::stdout(), EnableMouseCapture)?;
- } else {
- execute!(io::stdout(), DisableMouseCapture)?;
- }
- *active = desired;
- Ok(())
- }
-
pub(crate) fn ensure_default_workspace(&mut self) -> bool {
- if !self.state.workspaces.is_empty()
- || self.state.mode == Mode::Onboarding
- || self.state.pending_workspace_create_cwd.is_some()
- {
+ if !self.state.workspaces.is_empty() {
return false;
}
- let previous_mode = self.state.mode;
- let preserve_mode = matches!(
- previous_mode,
- Mode::ReleaseNotes | Mode::ProductAnnouncement | Mode::Settings
- );
let cwd = self.resolve_new_terminal_cwd(None);
match self.create_workspace_with_options(cwd, true) {
- Ok(_) => {
- if preserve_mode {
- self.state.mode = previous_mode;
- }
- true
- }
+ Ok(_) => true,
Err(err) => {
tracing::error!(err = %err, "failed to create default workspace");
self.state.mode = Mode::Navigate;
@@ -1275,14 +700,7 @@ impl App {
}
}
- pub(crate) fn dismiss_release_notes(&mut self) {
- let preview = self
- .state
- .release_notes
- .as_ref()
- .is_some_and(|notes| notes.preview);
-
- self.state.release_notes = None;
+ fn mark_release_notes_seen(&mut self, preview: bool) {
if !preview {
if let Err(err) = crate::release_notes::mark_current_version_seen() {
self.state.config_diagnostic =
@@ -1290,16 +708,6 @@ impl App {
self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(5));
}
}
-
- if self.state.product_announcement.is_some() {
- self.state.mode = Mode::ProductAnnouncement;
- } else {
- self.state.mode = if self.state.active.is_some() {
- Mode::Terminal
- } else {
- Mode::Navigate
- };
- }
}
pub(crate) fn dismiss_product_announcement(&mut self) {
@@ -1314,87 +722,6 @@ impl App {
}
}
}
-
- self.state.mode = if self.state.active.is_some() {
- Mode::Terminal
- } else {
- Mode::Navigate
- };
- }
-
- pub(crate) fn scroll_release_notes(&mut self, delta: i16) {
- let max_scroll = self.state.release_notes_max_scroll();
- if let Some(notes) = &mut self.state.release_notes {
- notes.scroll = if delta.is_negative() {
- notes.scroll.saturating_sub(delta.unsigned_abs())
- } else {
- notes.scroll.saturating_add(delta as u16)
- }
- .min(max_scroll);
- }
- }
-
- pub(crate) fn scroll_product_announcement(&mut self, delta: i16) {
- let max_scroll = self.state.product_announcement_max_scroll();
- if let Some(announcement) = &mut self.state.product_announcement {
- announcement.scroll = if delta.is_negative() {
- announcement.scroll.saturating_sub(delta.unsigned_abs())
- } else {
- announcement.scroll.saturating_add(delta as u16)
- }
- .min(max_scroll);
- }
- }
-
- pub(crate) fn open_settings_from_onboarding(&mut self) {
- self.mark_onboarding_complete();
- self.refresh_integration_recommendations();
- crate::app::input::open_settings_at(&mut self.state, state::SettingsSection::Integrations);
- }
-
- pub(crate) fn refresh_integration_recommendations(&mut self) {
- self.state.integration_recommendations = crate::integration::integration_recommendations();
- }
-
- pub(crate) fn install_recommended_integrations(&mut self) {
- let targets = self
- .state
- .integration_recommendations
- .iter()
- .filter(|recommendation| recommendation.needs_install())
- .map(|recommendation| recommendation.target)
- .collect::<Vec<_>>();
-
- self.state.integration_install_messages.clear();
- if targets.is_empty() {
- self.state
- .integration_install_messages
- .push("all detected integrations are current".to_string());
- return;
- }
-
- for target in targets {
- let label = crate::integration::integration_target_label(target);
- match crate::integration::install_target(target) {
- Ok(messages) => {
- self.state
- .integration_install_messages
- .push(format!("installed {label}"));
- self.state
- .integration_install_messages
- .extend(messages.into_iter().filter(|message| {
- message.starts_with(crate::integration::INSTALL_WARNING_PREFIX)
- }));
- }
- Err(err) => self
- .state
- .integration_install_messages
- .push(format!("{label}: {err}")),
- }
- }
-
- self.state.integration_recommendations = crate::integration::integration_recommendations();
- self.state.mark_session_dirty();
}
pub(crate) fn reload_config(&mut self) -> crate::config::ConfigReloadReport {
@@ -1431,6 +758,8 @@ impl App {
}
}
};
+ self.endpoint_commands =
+ custom_commands::EndpointCommandRegistry::new(&self.state.keybinds.custom_commands);
self.sync_toast_deadline(previous_toast);
report
}
@@ -1452,6 +781,12 @@ impl App {
self.state.prefix_code = live.prefix.0;
self.state.prefix_mods = live.prefix.1;
self.state.keybinds = live.keybinds;
+ match config.local_keybindings_profile_toml() {
+ Ok(profile) => self.client_shell_keybindings_profile = Some(profile),
+ Err(err) => diagnostics.push(format!(
+ "failed to publish server keybindings: {err}; kept previous keybindings"
+ )),
+ }
diagnostics.extend(keybind_diagnostics);
}
Err(keybind_diagnostics) => {
@@ -1480,44 +815,14 @@ impl App {
&config.ui.window_title,
));
- self.state.default_sidebar_width = config.ui.sidebar_width;
- if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault {
- self.state.sidebar_width = config.ui.sidebar_width;
- }
- self.state.sidebar_min_width = config.ui.sidebar_min_width;
- self.state.sidebar_max_width = config.ui.sidebar_max_width;
- self.state.sidebar_collapsed_mode = config.ui.sidebar_collapsed_mode;
- self.state.mobile_width_threshold = config.ui.mobile_width_threshold;
- // Re-clamp the live width to the new bounds. No source guard — bounds
- // always apply, including to widths owned by Persisted or Manual.
- self.state.sidebar_width = self
- .state
- .sidebar_width
- .clamp(self.state.sidebar_min_width, self.state.sidebar_max_width);
- self.state.mouse_capture = config.ui.mouse_capture;
- self.state.copy_on_select = config.ui.copy_on_select;
- if self.state.redraw_on_focus_gained != config.ui.redraw_on_focus_gained {
- self.state.request_client_config_reload = true;
- }
- self.state.redraw_on_focus_gained = config.ui.redraw_on_focus_gained;
- if self.loaded_host_cursor != config.ui.host_cursor {
- self.state.request_client_config_reload = true;
- }
self.loaded_host_cursor = config.ui.host_cursor;
- self.state.mouse_scroll_lines = config.ui.mouse_scroll_lines();
- self.state.right_click_passthrough_modifiers =
- config.ui.right_click_passthrough_modifiers();
self.state.confirm_close = config.ui.confirm_close;
- self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name;
- self.state.prompt_new_workspace_name = config.ui.prompt_new_workspace_name;
self.state.pane_borders = config.ui.pane_borders;
self.state.pane_outer_borders = config.ui.pane_outer_borders;
self.state.pane_scrollbars = config.ui.pane_scrollbars;
self.state.pane_gaps = config.ui.pane_gaps;
self.state.show_agent_labels_on_pane_borders =
config.ui.show_agent_labels_on_pane_borders;
- self.state.hide_tab_bar_when_single_tab = config.ui.hide_tab_bar_when_single_tab;
- self.state.tab_bar_position = config.ui.tab_bar_position;
self.configure_tab_bar_status(
&config.ui.tab_bar_right,
&config.ui.tab_bar_right_separator,
@@ -1525,14 +830,8 @@ impl App {
self.configure_window_title(&config.ui.window_title);
self.state.agent_panel_sort =
agent_panel_sort_from_config(config.ui.agent_panel_sort);
- self.state.status_indicators = config.ui.status_indicators;
self.state.sidebar_agents = config.ui.sidebar.agents.clone();
self.state.sidebar_spaces = config.ui.sidebar.spaces.clone();
- self.state.agent_panel_scroll = 0;
- self.state.accent = crate::config::parse_color(&config.ui.accent);
- if !self.state.local_sound_playback && self.state.sound != config.ui.sound {
- self.state.request_client_config_reload = true;
- }
self.state.sound = config.ui.sound.clone();
self.state.toast_config = config.ui.toast.clone();
}
@@ -1543,7 +842,6 @@ impl App {
self.state.kitty_graphics_enabled = config.experimental.kitty_graphics;
crate::kitty_graphics::set_enabled(config.experimental.kitty_graphics);
if was_kitty_graphics_enabled && !config.experimental.kitty_graphics {
- let _ = crate::kitty_graphics::clear_all_host_graphics();
self.pane_graphics.clear();
self.state.host_cell_size = crate::kitty_graphics::HostCellSize::default();
}
@@ -1554,10 +852,7 @@ impl App {
self.state.cjk_ime_agents = parse_cjk_ime_agents(&config.experimental.cjk_ime_agents);
self.state.cjk_ime_cursor_shape =
config.experimental.cjk_ime_cursor_shape.to_decscusr();
- self.state.switch_ascii_input_source_in_prefix =
- config.experimental.switch_ascii_input_source_in_prefix;
self.persist_pane_history = config.experimental.pane_history;
- self.state.pane_history_persistence = config.experimental.pane_history;
if !self.persist_pane_history {
crate::persist::clear_history();
}
@@ -1586,7 +881,7 @@ impl App {
self.next_auto_update_check = None;
} else if !previous_version_check_enabled
&& background_update_check_enabled(
- self.no_session,
+ self.policy.background_updates,
self.update_version_check_enabled,
)
&& self.state.update_available.is_none()
@@ -1598,7 +893,7 @@ impl App {
self.next_agent_manifest_update_check = None;
} else if !previous_manifest_check_enabled
&& background_update_check_enabled(
- self.no_session,
+ self.policy.background_updates,
self.update_manifest_check_enabled,
)
{
@@ -1654,459 +949,27 @@ impl App {
}
}
+ self.state.request_client_config_reload = true;
crate::config::ConfigReloadReport {
status,
diagnostics,
}
}
}
-
-// ---------------------------------------------------------------------------
-// Input routing for headless server mode
-// ---------------------------------------------------------------------------
-
-impl App {
- pub(crate) fn terminal_input_context(&self) -> Option<TerminalInputContext> {
- if let Some(popup) = &self.state.popup_pane {
- Some(TerminalInputContext::Popup(popup.terminal_id.clone()))
- } else if self.state.mode == Mode::Terminal {
- Some(TerminalInputContext::Pane)
- } else {
- None
- }
- }
-
- fn execute_repeat_plan_headless(
- &mut self,
- source_id: InputSourceId,
- lease_key: input::InputLeaseKey,
- key: crate::input::TerminalKey,
- plan: input::RepeatPlan,
- ) {
- match plan {
- input::RepeatPlan::Forwarded(target) => {
- if !self.forward_terminal_key_to_target_headless(&target, key) {
- self.input_leases.remove(&lease_key);
- }
- }
- input::RepeatPlan::Reprocess {
- context,
- repetitions,
- tracked,
- } => {
- let key = key
- .with_kind(crossterm::event::KeyEventKind::Repeat)
- .with_repeat_count(1);
- let mut forwarded_target = None;
- for _ in 0..repetitions {
- if let Some(target) = &forwarded_target {
- if !self.forward_terminal_key_to_target_headless(target, key.clone()) {
- self.input_leases.remove(&lease_key);
- break;
- }
- continue;
- }
- let current_context = self.terminal_input_context();
- if !self.input_leases.reprocess_allowed(
- lease_key,
- &context,
- current_context.as_ref(),
- tracked,
- ) {
- break;
- }
- if let Some(target) =
- self.handle_terminal_key_headless_from(source_id, key.clone())
- {
- if tracked {
- self.input_leases.insert_forwarded(
- lease_key,
- target.clone(),
- key.clone(),
- );
- forwarded_target = Some(target);
- }
- }
- }
- }
- input::RepeatPlan::Ignore => {}
- }
- }
-
- /// Routes raw input bytes from a client through the existing input pipeline.
- ///
- /// The input bytes are parsed into `RawInputEvent`s and then processed.
- /// In terminal mode, keys are routed through the same semantic
- /// key-handling path as monolithic herdr so they are re-encoded for the
- /// focused pane's negotiated keyboard protocol instead of passing host
- /// terminal escape sequences through unchanged.
- #[cfg(test)]
- pub(crate) fn route_client_input(&mut self, data: Vec<u8>) {
- let events = crate::raw_input::parse_raw_input_bytes_sync(&data);
- self.route_client_events(events, true);
- }
-
- pub(crate) fn route_client_pixel_mouse(
- &mut self,
- source_id: InputSourceId,
- data: &[u8],
- geometry: crate::input::mouse::HostGeometry,
- ) -> bool {
- let Some((x, y)) = crate::input::mouse::parse_report(data) else {
- return false;
- };
- let Some((column, row)) = geometry.cell(x, y) else {
- return false;
- };
- let Some(cell_report) = crate::input::mouse::report_at_cell(data, column, row) else {
- return false;
- };
- let mut events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report);
- if events.len() != 1 || !matches!(events[0], crate::raw_input::RawInputEvent::Mouse(_)) {
- return false;
- }
- self.state.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry });
- self.route_client_events_from(source_id, std::mem::take(&mut events), false);
- self.state.host_mouse_pixels = None;
- true
- }
-
- pub(crate) fn route_client_events(
- &mut self,
- events: Vec<crate::raw_input::RawInputEvent>,
- apply_host_terminal_theme: bool,
- ) {
- self.route_client_events_from(LOCAL_INPUT_SOURCE, events, apply_host_terminal_theme);
- }
-
- pub(crate) fn route_client_events_from(
- &mut self,
- source_id: InputSourceId,
- events: Vec<crate::raw_input::RawInputEvent>,
- apply_host_terminal_theme: bool,
- ) {
- for event in events {
- let previous_mode = self.state.mode;
- match event {
- crate::raw_input::RawInputEvent::Key(key) => {
- let lease_key = input::InputLeaseKey::new(source_id, &key);
- let key = self.input_leases.normalize_press(&lease_key, key);
- match key.kind {
- crossterm::event::KeyEventKind::Press => {
- let initial_context = self.terminal_input_context();
- let target = if initial_context.is_some() {
- self.handle_terminal_key_headless_from(source_id, key.clone())
- } else {
- self.handle_non_terminal_key_headless(key.clone());
- None
- };
- let resulting_context = self.terminal_input_context();
- let plan = self.input_leases.complete_press(
- lease_key,
- &key,
- initial_context.as_ref(),
- resulting_context.as_ref(),
- target,
- );
- self.execute_repeat_plan_headless(source_id, lease_key, key, plan);
- }
- crossterm::event::KeyEventKind::Repeat => {
- let current_context = self.terminal_input_context();
- let plan = self.input_leases.plan_repeat(
- lease_key,
- &key,
- current_context.as_ref(),
- );
- self.execute_repeat_plan_headless(source_id, lease_key, key, plan);
- }
- crossterm::event::KeyEventKind::Release => {
- if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) {
- let _ = self
- .forward_terminal_key_to_target_headless(&lease.target, key);
- }
- }
- }
- }
- crate::raw_input::RawInputEvent::Text(text) => {
- self.handle_text_commit_headless(text.as_str());
- }
- crate::raw_input::RawInputEvent::Mouse(mouse) => {
- if self.state.popup_pane.is_some() || self.state.mouse_capture {
- self.handle_mouse_event_headless(source_id, mouse);
- } else {
- self.state
- .handle_pane_mouse_only(&self.terminal_runtimes, mouse);
- }
- }
- crate::raw_input::RawInputEvent::Paste(text) => {
- if self.try_route_paste_to_popup(&text) {
- } else if self.state.mode != Mode::Terminal {
- self.paste_into_active_text_input(&text);
- } else {
- if let Some(ws_idx) = self.state.active {
- if let Some(ws) = self.state.workspaces.get(ws_idx) {
- if let Some(focused) = ws.focused_pane_id() {
- if let Some(runtime) = self.state.runtime_for_pane_in_workspace(
- &self.terminal_runtimes,
- ws_idx,
- focused,
- ) {
- let _ = runtime.try_send_paste(text);
- }
- }
- }
- }
- }
- }
- crate::raw_input::RawInputEvent::OuterFocusGained => {
- self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained);
- }
- crate::raw_input::RawInputEvent::OuterFocusLost => {
- self.release_input_source_headless(source_id);
- self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost);
- }
- crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => {
- if apply_host_terminal_theme {
- self.update_host_terminal_theme(kind, color);
- }
- }
- crate::raw_input::RawInputEvent::HostPaletteColors { colors } => {
- if apply_host_terminal_theme {
- self.update_host_terminal_palette_colors(&colors);
- }
- }
- crate::raw_input::RawInputEvent::HostColorSchemeChanged(appearance) => {
- if apply_host_terminal_theme {
- self.set_host_terminal_appearance(appearance, true);
- }
- }
- crate::raw_input::RawInputEvent::HostCellSizeReport { .. } => {}
- crate::raw_input::RawInputEvent::Unsupported => {}
- }
- self.sync_prefix_input_source(previous_mode);
- }
- }
-
- pub(crate) fn clear_input_source(&mut self, source_id: InputSourceId) {
- // Call this only when the input source is gone for good. A pending URL
- // click has to outlive a plain focus change, because opening the URL
- // raises the browser and costs the host terminal its focus before the
- // mouse release arrives.
- self.pending_url_click_sources.remove(&source_id);
- self.release_input_source_headless(source_id);
- }
-
- /// Handles a key event in non-terminal mode for the headless server.
- ///
- /// Uses the standalone handler functions that work on `&mut AppState`
- /// since the server doesn't have the async context of the monolithic App.
- fn handle_non_terminal_key_headless(&mut self, key: crate::input::TerminalKey) {
- let key_event = key.as_key_event();
- if input::modal_paste_target_active(&self.state)
- && input::is_modal_paste_shortcut(&key_event)
- {
- if let Some(text) = crate::platform::read_clipboard_text() {
- self.paste_into_active_text_input(&text);
- }
- return;
- }
-
- match self.state.mode {
- Mode::Prefix => {
- self.handle_prefix_key(key);
- }
- Mode::Navigate => {
- self.handle_navigate_key(key);
- }
- Mode::Copy => {
- self.handle_copy_mode_key(key);
- }
- Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => {
- self.handle_rename_key_via_api(key_event);
- }
- Mode::NewLinkedWorktree => {
- self.handle_worktree_create_key(key_event);
- }
- Mode::OpenExistingWorktree => {
- self.handle_worktree_open_key(key_event);
- }
- Mode::ConfirmRemoveWorktree => {
- self.handle_worktree_remove_key(key_event);
- }
- Mode::Resize => {
- self.handle_resize_key_via_api(key);
- }
- Mode::ConfirmClose => {
- self.handle_confirm_close_key_via_api(key_event);
- }
- Mode::ContextMenu => {
- self.handle_context_menu_key_via_api(key_event);
- }
- Mode::KeybindHelp => {
- input::handle_keybind_help_key(&mut self.state, key);
- }
- Mode::GlobalMenu => {
- input::handle_global_menu_key(&mut self.state, key_event);
- }
- Mode::Onboarding => {
- self.handle_onboarding_key(key_event);
- }
- Mode::ReleaseNotes => {
- self.handle_release_notes_key(key_event);
- }
- Mode::ProductAnnouncement => {
- self.handle_product_announcement_key(key_event);
- }
- Mode::Settings => {
- self.handle_settings_key(key_event);
- }
- Mode::Navigator => {
- input::handle_navigator_key(&mut self.state, &self.terminal_runtimes, key_event);
- }
- Mode::Terminal => {
- // Should not be called in terminal mode.
- }
- }
- }
-
- /// Handles a mouse event for the headless server.
- ///
- /// Delegates to the same mouse handling logic used in the monolithic
- /// mode (hit-testing against the rendered UI), which works because
- /// the server's AppState maintains view geometry from virtual rendering.
- fn handle_mouse_event_headless(
- &mut self,
- source_id: InputSourceId,
- mouse: crossterm::event::MouseEvent,
- ) {
- self.handle_mouse_from_input_source(source_id, mouse);
- }
-}
-
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::detect::{Agent, AgentState};
- use crate::terminal::TerminalRuntime;
use crate::workspace::Workspace;
- use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
- use std::cell::Cell;
- use std::rc::Rc;
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::sync::Mutex;
- fn raw_key(
- code: KeyCode,
- modifiers: KeyModifiers,
- kind: KeyEventKind,
- ) -> crate::raw_input::RawInputEvent {
- crate::raw_input::RawInputEvent::Key(
- crate::input::TerminalKey::new(code, modifiers).with_kind(kind),
- )
- }
-
- #[cfg(windows)]
- #[tokio::test]
- async fn native_repeats_and_releases_follow_the_pressed_pane() {
- let record = crate::input::WindowsKeyRecord {
- key_down: true,
- repeat_count: 1,
- virtual_key_code: 27,
- virtual_scan_code: 1,
- unicode: 27,
- control_key_state: 0,
- };
- let key = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())
- .with_windows_record(record);
- let enhanced = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())
- .with_windows_record(crate::input::WindowsKeyRecord {
- control_key_state: 0x0100,
- ..record
- });
- assert_ne!(
- input::InputLeaseKey::new(7, &key),
- input::InputLeaseKey::new(7, &enhanced)
- );
-
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let pressed_pane = workspace.focused_pane_id().unwrap();
- let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
- workspace.tabs[0].layout.focus_pane(pressed_pane);
- let (pressed_runtime, mut pressed_rx) =
- TerminalRuntime::test_with_channel_capacity(80, 24, 6);
- let (other_runtime, mut other_rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 6);
- workspace.tabs[0]
- .runtimes
- .insert(pressed_pane, pressed_runtime);
- workspace.tabs[0].runtimes.insert(other_pane, other_runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- let release = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())
- .with_windows_record(crate::input::WindowsKeyRecord {
- key_down: false,
- repeat_count: 1,
- unicode: 0,
- ..record
- })
- .with_kind(KeyEventKind::Release);
- app.route_client_events(
- vec![crate::raw_input::RawInputEvent::Key(key.clone())],
- false,
- );
- assert!(app.state.focus_pane_in_workspace(0, other_pane));
- app.route_client_events(
- vec![
- crate::raw_input::RawInputEvent::Key(key.clone()),
- crate::raw_input::RawInputEvent::Key(key.clone().with_kind(KeyEventKind::Repeat)),
- crate::raw_input::RawInputEvent::Key(release),
- crate::raw_input::RawInputEvent::Key(key.clone()),
- crate::raw_input::RawInputEvent::OuterFocusLost,
- ],
- false,
- );
-
- for _ in 0..3 {
- assert_eq!(
- pressed_rx.try_recv().unwrap(),
- bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_")
- );
- }
- assert_eq!(
- pressed_rx.try_recv().unwrap(),
- bytes::Bytes::from_static(b"\x1b[27;1;0;0;0;1_")
- );
- assert!(pressed_rx.try_recv().is_err());
-
- assert_eq!(
- other_rx.try_recv().unwrap(),
- bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_")
- );
- assert_eq!(
- other_rx.try_recv().unwrap(),
- bytes::Bytes::from_static(b"\x1b[27;1;27;0;0;1_")
- );
- assert!(other_rx.try_recv().is_err());
- assert!(app.input_leases.is_empty());
- }
-
- fn release_notes_state() -> state::ReleaseNotesState {
- state::ReleaseNotesState {
- version: "0.1.0".into(),
- body: "notes".into(),
- scroll: 0,
- preview: true,
- }
- }
-
fn test_app() -> App {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
App::new(
&Config::default(),
- true,
+ crate::app::AppPolicy::TEST,
None,
api_rx,
crate::api::EventHub::default(),
@@ -2131,230 +994,6 @@ mod tests {
"/usr/bin/true"
}
- #[derive(Clone, Default)]
- struct FakePrefixInputSource {
- switch_calls: Rc<Cell<usize>>,
- restore_calls: Rc<Cell<usize>>,
- switched: Rc<Cell<bool>>,
- will_switch: bool,
- }
-
- impl FakePrefixInputSource {
- fn switching() -> Self {
- Self {
- will_switch: true,
- ..Self::default()
- }
- }
-
- fn no_op() -> Self {
- Self {
- will_switch: false,
- ..Self::default()
- }
- }
- }
-
- impl crate::platform::PrefixInputSource for FakePrefixInputSource {
- fn switch_to_ascii(&mut self) {
- self.switch_calls.set(self.switch_calls.get() + 1);
- if self.will_switch {
- self.switched.set(true);
- }
- }
-
- fn restore(&mut self) {
- if self.switched.replace(false) {
- self.restore_calls.set(self.restore_calls.get() + 1);
- }
- }
- }
-
- /// Drain the app event channel, returning the `active` flags of any emitted
- /// `PrefixInputSource` events (the host-local input-source intents).
- fn drained_prefix_active(app: &mut App) -> Vec<bool> {
- let mut out = Vec::new();
- while let Ok(ev) = app.event_rx.try_recv() {
- if let crate::events::AppEvent::PrefixInputSource { active } = ev {
- out.push(active);
- }
- }
- out
- }
-
- #[test]
- fn sync_prefix_input_source_emits_switch_then_restore_when_enabled() {
- let mut app = test_app();
- app.state.switch_ascii_input_source_in_prefix = true;
-
- // Terminal -> Prefix emits the ASCII-switch intent.
- app.state.mode = Mode::Prefix;
- app.sync_prefix_input_source(Mode::Terminal);
- assert_eq!(drained_prefix_active(&mut app), vec![true]);
-
- // Prefix -> Terminal emits the restore intent.
- app.state.mode = Mode::Terminal;
- app.sync_prefix_input_source(Mode::Prefix);
- assert_eq!(drained_prefix_active(&mut app), vec![false]);
- }
-
- #[test]
- fn sync_prefix_input_source_does_not_emit_switch_when_flag_disabled() {
- let mut app = test_app();
- app.state.switch_ascii_input_source_in_prefix = false;
-
- // Entering the realm with the flag off emits nothing.
- app.state.mode = Mode::Prefix;
- app.sync_prefix_input_source(Mode::Terminal);
- assert!(drained_prefix_active(&mut app).is_empty());
-
- // Leaving the realm still emits the restore (harmless if nothing was switched), so a
- // mid-interaction flag toggle can't strand the host on ASCII.
- app.state.mode = Mode::Terminal;
- app.sync_prefix_input_source(Mode::Prefix);
- assert_eq!(drained_prefix_active(&mut app), vec![false]);
- }
-
- #[test]
- fn mode_wants_ascii_input_classification() {
- // Allowlist: the prefix command/navigation realm wants ASCII.
- for mode in [
- Mode::Prefix,
- Mode::Navigate,
- Mode::Navigator,
- Mode::Copy,
- Mode::Resize,
- Mode::ConfirmClose,
- Mode::ConfirmRemoveWorktree,
- Mode::ContextMenu,
- Mode::GlobalMenu,
- Mode::KeybindHelp,
- ] {
- assert!(mode.wants_ascii_input(), "{mode:?} should want ASCII");
- }
- // Everything else (terminal, text entry, startup overlays) keeps the user's IME.
- for mode in [
- Mode::Terminal,
- Mode::RenameWorkspace,
- Mode::RenameTab,
- Mode::RenamePane,
- Mode::NewLinkedWorktree,
- Mode::OpenExistingWorktree,
- Mode::Settings,
- Mode::Onboarding,
- Mode::ReleaseNotes,
- Mode::ProductAnnouncement,
- ] {
- assert!(!mode.wants_ascii_input(), "{mode:?} should keep the IME");
- }
- }
-
- #[test]
- fn sync_prefix_input_source_keeps_realm_across_multi_level_prefix_commands() {
- let mut app = test_app();
- app.state.switch_ascii_input_source_in_prefix = true;
-
- // Terminal -> Prefix switches once.
- app.state.mode = Mode::Prefix;
- app.sync_prefix_input_source(Mode::Terminal);
- assert_eq!(drained_prefix_active(&mut app), vec![true]);
-
- // Prefix -> sub-mode and sub-mode -> sub-mode stay in the realm: no emit.
- app.state.mode = Mode::Navigator;
- app.sync_prefix_input_source(Mode::Prefix);
- app.state.mode = Mode::Resize;
- app.sync_prefix_input_source(Mode::Navigator);
- assert!(
- drained_prefix_active(&mut app).is_empty(),
- "must not switch or restore while still in the realm"
- );
-
- // Leaving the realm back to the terminal restores.
- app.state.mode = Mode::Terminal;
- app.sync_prefix_input_source(Mode::Resize);
- assert_eq!(drained_prefix_active(&mut app), vec![false]);
- }
-
- #[test]
- fn sync_prefix_input_source_restores_when_entering_rename_text_mode() {
- let mut app = test_app();
- app.state.switch_ascii_input_source_in_prefix = true;
-
- app.state.mode = Mode::Prefix;
- app.sync_prefix_input_source(Mode::Terminal);
- assert_eq!(drained_prefix_active(&mut app), vec![true]);
-
- // Prefix -> RenameTab leaves the realm (text entry wants the IME): restore.
- app.state.mode = Mode::RenameTab;
- app.sync_prefix_input_source(Mode::Prefix);
- assert_eq!(drained_prefix_active(&mut app), vec![false]);
- }
-
- #[test]
- fn handle_internal_event_prefix_input_source_applies_switch_and_restore() {
- // The monolithic (in-process) path applies the host switch when it consumes the event.
- let mut app = test_app();
- let fake = FakePrefixInputSource::switching();
- let switch_calls = fake.switch_calls.clone();
- let restore_calls = fake.restore_calls.clone();
- app.set_prefix_input_source(Box::new(fake));
-
- app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: true });
- assert_eq!(switch_calls.get(), 1);
- assert_eq!(restore_calls.get(), 0);
-
- app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: false });
- assert_eq!(restore_calls.get(), 1);
- }
-
- #[test]
- fn handle_internal_event_prefix_input_source_restore_is_safe_when_switch_was_noop() {
- // Already-ASCII / failed-switch case: the restore on leave must stay harmless.
- let mut app = test_app();
- let fake = FakePrefixInputSource::no_op();
- let switch_calls = fake.switch_calls.clone();
- let restore_calls = fake.restore_calls.clone();
- app.set_prefix_input_source(Box::new(fake));
-
- app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: true });
- app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: false });
- assert_eq!(switch_calls.get(), 1);
- assert_eq!(restore_calls.get(), 0);
- }
-
- #[tokio::test]
- async fn raw_input_dispatch_emits_input_source_intent_when_leaving_prefix() {
- // Leaving prefix mode happens inside the raw-input dispatch, not in `handle_key` itself —
- // the sync must sit at the dispatch layer so any event that exits prefix (here Esc) still
- // emits the restore intent.
- let mut app = test_app();
- app.state.switch_ascii_input_source_in_prefix = true;
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- // ctrl+b (the default prefix key) enters prefix mode → switch intent.
- app.handle_raw_input_event(raw_key(
- KeyCode::Char('b'),
- KeyModifiers::CONTROL,
- KeyEventKind::Press,
- ))
- .await;
- assert_eq!(app.state.mode, Mode::Prefix);
- assert_eq!(drained_prefix_active(&mut app), vec![true]);
-
- // Esc leaves prefix mode → restore intent.
- app.handle_raw_input_event(raw_key(
- KeyCode::Esc,
- KeyModifiers::empty(),
- KeyEventKind::Press,
- ))
- .await;
- assert_eq!(app.state.mode, Mode::Terminal);
- assert_eq!(drained_prefix_active(&mut app), vec![false]);
- }
-
fn config_env_lock() -> &'static Mutex<()> {
crate::config::test_config_env_lock()
}
@@ -2393,7 +1032,7 @@ mod tests {
let mut app = test_app();
app.git_refresh_in_flight = true;
- let changed = app.handle_internal_event_with_prefix_sync(AppEvent::GitStatusRefreshed {
+ let changed = app.handle_internal_event_with_render_impact(AppEvent::GitStatusRefreshed {
results: Vec::new(),
cache_updates: Vec::new(),
});
@@ -2424,10 +1063,10 @@ mod tests {
result: Ok(output.map(str::to_string)),
};
- assert!(!app.handle_internal_event_with_prefix_sync(event(generation, None)));
- assert!(app.handle_internal_event_with_prefix_sync(event(generation, Some("ready"))));
- assert!(!app.handle_internal_event_with_prefix_sync(event(generation, Some("ready"))));
- assert!(!app.handle_internal_event_with_prefix_sync(event(
+ assert!(!app.handle_internal_event_with_render_impact(event(generation, None)));
+ assert!(app.handle_internal_event_with_render_impact(event(generation, Some("ready"))));
+ assert!(!app.handle_internal_event_with_render_impact(event(generation, Some("ready"))));
+ assert!(!app.handle_internal_event_with_render_impact(event(
generation.wrapping_add(1),
Some("stale"),
)));
@@ -2475,59 +1114,6 @@ mod tests {
}
#[test]
- fn clipboard_write_event_shows_feedback_toast() {
- let mut app = test_app();
-
- app.handle_internal_event(AppEvent::ClipboardWrite {
- content: b"copied".to_vec(),
- });
-
- assert!(app.state.toast.is_none());
- let feedback = app.state.copy_feedback.as_ref().expect("copy feedback");
- assert_eq!(feedback.message, "copied to clipboard");
- assert!(app.copy_feedback_deadline.is_some());
- }
-
- #[test]
- fn clipboard_feedback_can_be_disabled() {
- let mut app = test_app();
- app.state.toast_config.clipboard.enabled = false;
-
- app.handle_internal_event(AppEvent::ClipboardWrite {
- content: b"copied".to_vec(),
- });
-
- assert!(app.state.copy_feedback.is_none());
- assert!(app.copy_feedback_deadline.is_none());
- }
-
- #[test]
- fn clipboard_feedback_does_not_replace_notification_toast() {
- let mut app = test_app();
- app.state.toast = Some(crate::app::state::ToastNotification {
- kind: crate::app::state::ToastKind::NeedsAttention,
- title: "pi needs attention".to_string(),
- context: "background · 2".to_string(),
- position: None,
- target: None,
- });
- let original_toast = app.state.toast.clone();
-
- app.handle_internal_event(AppEvent::ClipboardWrite {
- content: b"copied".to_vec(),
- });
-
- assert_eq!(app.state.toast, original_toast);
- assert_eq!(
- app.state
- .copy_feedback
- .as_ref()
- .map(|feedback| feedback.message.as_str()),
- Some("copied to clipboard")
- );
- }
-
- #[test]
fn notification_show_api_creates_herdr_toast_with_position() {
let mut app = test_app();
app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
@@ -2564,38 +1150,6 @@ mod tests {
}
#[test]
- fn notification_show_api_herdr_toast_expires() {
- let mut app = test_app();
- app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
-
- let response =
- app.handle_api_request_after_internal_events_drained(crate::api::schema::Request {
- id: "notify".into(),
- method: crate::api::schema::Method::NotificationShow(
- crate::api::schema::NotificationShowParams {
- title: "build failed".into(),
- body: None,
- position: None,
- sound: crate::api::schema::NotificationShowSound::None,
- },
- ),
- });
-
- let parsed: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
- assert_eq!(
- parsed.result,
- crate::api::schema::ResponseResult::NotificationShow {
- shown: true,
- reason: crate::api::schema::NotificationShowReason::Shown,
- }
- );
- let deadline = app.toast_deadline.expect("api toast deadline");
- assert!(app.handle_scheduled_tasks(deadline, false));
- assert!(app.state.toast.is_none());
- assert!(app.toast_deadline.is_none());
- }
-
- #[test]
fn notification_show_api_respects_off_delivery() {
let mut app = test_app();
app.state.toast_config.delivery = crate::config::ToastDelivery::Off;
@@ -2765,62 +1319,18 @@ mod tests {
config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority;
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
+ let app = App::new(
+ &config,
+ crate::app::AppPolicy::TEST,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority);
}
#[test]
- fn startup_uses_configured_sidebar_state() {
- let mut config = Config::default();
- config.ui.sidebar_start_collapsed = true;
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
-
- let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
-
- assert!(app.state.sidebar_collapsed);
- }
-
- #[test]
- fn startup_uses_redraw_on_focus_gained_config() {
- let mut config = Config::default();
- config.ui.redraw_on_focus_gained = false;
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
-
- let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
-
- assert!(!app.state.redraw_on_focus_gained);
- }
-
- #[test]
- fn workspace_name_prompt_suppresses_default_creation_while_pending() {
- let mut app = test_app();
- app.state.prompt_new_workspace_name = true;
-
- app.begin_tui_workspace_create("test.workspace.create");
-
- assert_eq!(app.state.mode, Mode::RenameWorkspace);
- assert!(app.state.pending_workspace_create_cwd.is_some());
- assert!(!app.ensure_default_workspace());
- assert!(app.state.workspaces.is_empty());
-
- app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()));
- assert!(app.state.workspaces.is_empty());
- assert!(app.state.pending_workspace_create_cwd.is_none());
- }
-
- #[test]
- fn startup_uses_workspace_name_prompt_config() {
- let mut config = Config::default();
- config.ui.prompt_new_workspace_name = true;
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
-
- let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
-
- assert!(app.state.prompt_new_workspace_name);
- }
-
- #[test]
fn theme_auto_switch_is_opt_in_and_preserves_manual_default() {
let mut config = Config::default();
config.theme.name = Some("tokyo-night".to_string());
@@ -2837,7 +1347,13 @@ mod tests {
});
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
+ let app = App::new(
+ &config,
+ crate::app::AppPolicy::TEST,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
assert!(!app.state.theme_runtime.auto_switch);
assert_eq!(app.state.theme_name, "tokyo-night");
@@ -2850,12 +1366,19 @@ mod tests {
config.theme.name = Some("tokyo-night".to_string());
config.theme.auto_switch = true;
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
+ let mut app = App::new(
+ &config,
+ crate::app::AppPolicy::TEST,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
assert_eq!(app.state.theme_name, "tokyo-night");
- assert!(
- app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true)
- );
+ assert!(app.set_host_terminal_appearance_state(
+ Some(crate::terminal_theme::HostAppearance::Light),
+ true,
+ ));
assert_eq!(app.state.theme_name, "tokyo-night-day");
assert_eq!(app.state.palette, state::Palette::tokyo_night_day());
@@ -2871,9 +1394,18 @@ mod tests {
..Default::default()
});
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
+ let mut app = App::new(
+ &config,
+ crate::app::AppPolicy::TEST,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
- app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true);
+ app.set_host_terminal_appearance_state(
+ Some(crate::terminal_theme::HostAppearance::Light),
+ true,
+ );
assert_eq!(app.state.theme_name, "gruvbox-light");
assert_eq!(
@@ -2903,7 +1435,13 @@ mod tests {
..Default::default()
});
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
+ let mut app = App::new(
+ &config,
+ crate::app::AppPolicy::TEST,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
assert_eq!(
app.state.palette.accent,
@@ -2922,7 +1460,10 @@ mod tests {
ratatui::style::Color::Rgb(16, 17, 18)
);
- app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Light, true);
+ app.set_host_terminal_appearance_state(
+ Some(crate::terminal_theme::HostAppearance::Light),
+ true,
+ );
assert_eq!(
app.state.palette.accent,
@@ -2932,31 +1473,6 @@ mod tests {
}
#[test]
- fn inferred_background_appearance_does_not_override_explicit_report() {
- let mut config = Config::default();
- config.theme.name = Some("catppuccin".to_string());
- config.theme.auto_switch = true;
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
-
- app.set_host_terminal_appearance(crate::terminal_theme::HostAppearance::Dark, true);
- app.update_host_terminal_theme(
- crate::terminal_theme::DefaultColorKind::Background,
- crate::terminal_theme::RgbColor {
- r: 0xff,
- g: 0xff,
- b: 0xff,
- },
- );
-
- assert_eq!(
- app.state.host_terminal_appearance,
- Some(crate::terminal_theme::HostAppearance::Dark)
- );
- assert_eq!(app.state.theme_name, "catppuccin");
- }
-
- #[test]
fn startup_restores_preview_update_available_from_saved_notes() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("startup-preview-update-available");
@@ -2969,6 +1485,81 @@ mod tests {
assert_eq!(app.state.update_available.as_deref(), Some("99.99.99"));
assert!(app.state.latest_release_notes_available);
+ assert_eq!(
+ app.state
+ .latest_release_notes
+ .as_ref()
+ .map(|notes| notes.version.as_str()),
+ Some("99.99.99")
+ );
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
+ fn update_ready_refreshes_cached_release_notes() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("update-ready-refreshes-release-notes");
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+ let mut app = test_app();
+ assert!(app.state.latest_release_notes.is_none());
+
+ crate::release_notes::save_pending("99.99.99", "### Changed\n- One").unwrap();
+ app.handle_internal_event(AppEvent::UpdateReady {
+ version: "99.99.99".into(),
+ install_command: "herdr update".into(),
+ });
+
+ assert_eq!(
+ app.state.latest_release_notes.as_ref().map(|notes| (
+ notes.version.as_str(),
+ notes.body.as_str(),
+ notes.preview
+ )),
+ Some(("99.99.99", "### Changed\n- One", true))
+ );
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
+ fn release_notes_dismiss_api_marks_current_seen_but_keeps_preview_unseen() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("release-notes-dismiss-persistence");
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let dismiss = |app: &mut App, version: &str| {
+ let response = app.handle_api_request(crate::api::schema::Request {
+ id: format!("dismiss-{version}"),
+ method: crate::api::schema::Method::ReleaseNotesDismiss(
+ crate::api::schema::ReleaseNotesDismissParams {
+ version: version.to_owned(),
+ },
+ ),
+ });
+ let response: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert_eq!(response["result"]["type"], "ok");
+ };
+ let show_on_startup = || {
+ let stored: serde_json::Value = serde_json::from_str(
+ &std::fs::read_to_string(crate::release_notes::pending_path()).unwrap(),
+ )
+ .unwrap();
+ stored["show_on_startup"].as_bool()
+ };
+
+ let current = env!("CARGO_PKG_VERSION");
+ crate::release_notes::save_pending(current, "### Changed\n- Current").unwrap();
+ let mut app = test_app();
+ dismiss(&mut app, current);
+ assert_eq!(show_on_startup(), Some(false));
+
+ crate::release_notes::save_pending("99.99.99", "### Changed\n- Preview").unwrap();
+ let mut app = test_app();
+ dismiss(&mut app, "99.99.99");
+ assert_eq!(show_on_startup(), Some(true));
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
@@ -3005,10 +1596,15 @@ mod tests {
};
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
+ let app = App::new(
+ &config,
+ crate::app::AppPolicy::TEST,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
assert_eq!(app.state.mode, Mode::Navigate);
- assert!(app.state.release_notes.is_none());
assert!(app.state.latest_release_notes_available);
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
@@ -3016,7 +1612,7 @@ mod tests {
}
#[test]
- fn startup_still_auto_opens_unseen_product_announcement() {
+ fn startup_loads_unseen_product_announcement_for_clients() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("startup-product-announcement-auto-open");
let state_home = path.parent().unwrap().join("state");
@@ -3042,9 +1638,15 @@ mod tests {
};
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
+ let app = App::new(
+ &config,
+ crate::app::AppPolicy::TEST,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
- assert_eq!(app.state.mode, Mode::ProductAnnouncement);
+ assert_eq!(app.state.mode, Mode::Navigate);
assert_eq!(
app.state
.product_announcement
@@ -3052,7 +1654,6 @@ mod tests {
.map(|announcement| announcement.id.as_str()),
Some("startup-announcement")
);
- assert!(app.state.release_notes.is_none());
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
restore_xdg_state_home(original_xdg_state_home);
@@ -3066,35 +1667,12 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
- "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[server]\nheadless_cols = 160\nheadless_rows = 50\n[ui]\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\ncopy_on_select = false\nright_click_passthrough_modifier = \"ctrl\"\nprompt_new_workspace_name = true\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n",
+ "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[server]\nheadless_cols = 160\nheadless_rows = 50\n[ui]\nagent_panel_sort = \"priority\"\n[ui.toast]\ndelivery = \"herdr\"\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
let mut app = test_app();
- let selection_pane = crate::layout::PaneId::alloc();
- app.state.selection = Some(crate::selection::Selection::range(
- selection_pane,
- 0,
- 0,
- 1,
- None,
- ));
- app.state.selection_autoscroll = Some(state::SelectionAutoscroll {
- direction: state::SelectionAutoscrollDirection::Down,
- last_mouse_screen_col: 1,
- last_mouse_screen_row: 1,
- inner_rect: ratatui::layout::Rect::new(0, 0, 2, 2),
- });
- let selection_deadline = Instant::now();
- app.selection_autoscroll_deadline = Some(selection_deadline);
- app.selection_highlight_clear_deadline = Some(selection_deadline);
- app.last_pane_click = Some(PaneClickState {
- pane_id: selection_pane,
- viewport_row: 0,
- col: 0,
- at: selection_deadline,
- });
app.next_auto_update_check = Some(Instant::now());
app.next_agent_manifest_update_check = Some(Instant::now());
let report = app.reload_config();
@@ -3113,33 +1691,8 @@ mod tests {
crate::config::ToastDelivery::Herdr
);
assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority);
- assert!(!app.state.redraw_on_focus_gained);
- assert!(!app.state.copy_on_select);
- assert!(app.state.prompt_new_workspace_name);
- assert!(app.state.selection.is_some());
- assert!(app.state.selection_autoscroll.is_some());
- assert_eq!(app.selection_autoscroll_deadline, Some(selection_deadline));
- assert_eq!(
- app.selection_highlight_clear_deadline,
- Some(selection_deadline)
- );
- assert!(app.last_pane_click.is_some());
-
- app.state.mode = Mode::Copy;
- app.state.selection = Some(crate::selection::Selection::range(
- selection_pane,
- 0,
- 0,
- 1,
- None,
- ));
let report = app.reload_config();
assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
- assert!(app.state.selection.is_some());
- assert_eq!(
- app.state.right_click_passthrough_modifiers,
- Some(KeyModifiers::CONTROL)
- );
assert!(app.state.request_client_config_reload);
assert_eq!(app.state.default_shell, "nu");
assert_eq!(
@@ -3154,7 +1707,6 @@ mod tests {
assert!(!app.update_manifest_check_enabled);
assert!(app.next_auto_update_check.is_none());
assert!(app.next_agent_manifest_update_check.is_none());
- assert!(app.state.switch_ascii_input_source_in_prefix);
assert!(app.state.config_diagnostic.is_none());
let toast = app.state.toast.as_ref().unwrap();
assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled);
@@ -3166,23 +1718,19 @@ mod tests {
}
#[test]
- fn reload_config_requests_client_reload_for_host_cursor_only_change() {
+ fn reload_config_requests_client_reload_for_key_only_change() {
let _guard = config_env_lock().lock().unwrap();
- let path = temp_config_path("reload-config-host-cursor");
+ let path = temp_config_path("reload-config-key-only");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
- std::fs::write(&path, "[ui]\nhost_cursor = \"native\"\n").unwrap();
+ std::fs::write(&path, "[keys]\nprefix = \"ctrl+a\"\n").unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
let mut app = test_app();
app.state.request_client_config_reload = false;
-
let report = app.reload_config();
assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
- assert_eq!(
- app.loaded_host_cursor,
- crate::config::HostCursorModeConfig::Native
- );
+ assert_eq!(app.state.prefix_code, KeyCode::Char('a'));
assert!(app.state.request_client_config_reload);
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
@@ -3190,31 +1738,24 @@ mod tests {
}
#[test]
- fn reload_config_updates_sidebar_width_only_when_config_owned() {
+ fn reload_config_requests_client_reload_for_host_cursor_only_change() {
let _guard = config_env_lock().lock().unwrap();
- let path = temp_config_path("reload-config-sidebar-width");
+ let path = temp_config_path("reload-config-host-cursor");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(&path, "[ui]\nhost_cursor = \"native\"\n").unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
let mut app = test_app();
- assert_eq!(
- app.state.sidebar_width_source,
- state::SidebarWidthSource::ConfigDefault
- );
+ app.state.request_client_config_reload = false;
- std::fs::write(&path, "[ui]\nsidebar_width = 34\n").unwrap();
let report = app.reload_config();
- assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
- assert_eq!(app.state.default_sidebar_width, 34);
- assert_eq!(app.state.sidebar_width, 34);
- app.state.sidebar_width = 31;
- app.state.sidebar_width_source = state::SidebarWidthSource::Manual;
- std::fs::write(&path, "[ui]\nsidebar_width = 35\n").unwrap();
- let report = app.reload_config();
assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
- assert_eq!(app.state.default_sidebar_width, 35);
- assert_eq!(app.state.sidebar_width, 31);
+ assert_eq!(
+ app.loaded_host_cursor,
+ crate::config::HostCursorModeConfig::Native
+ );
+ assert!(app.state.request_client_config_reload);
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
@@ -3233,11 +1774,9 @@ mod tests {
"[ui.sidebar.agents]\nrows = [[\"state_icon\", \"$summary\"]]\nrow_gap = 1\n\n[ui.sidebar.agents.rows_by_agent]\nclaude = [[\"terminal_title_stripped\"]]\n\n[ui.sidebar.spaces]\nrows = [[\"workspace\", \"$jj_status\"]]\nrow_gap = 3\n",
)
.unwrap();
- app.state.agent_panel_scroll = 5;
let report = app.reload_config();
assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
- assert_eq!(app.state.agent_panel_scroll, 0);
assert_eq!(
app.state.sidebar_agents.rows,
vec![vec![
@@ -3276,146 +1815,6 @@ mod tests {
}
#[test]
- fn reload_config_does_not_reset_sidebar_to_startup_state() {
- let _guard = config_env_lock().lock().unwrap();
- let path = temp_config_path("reload-config-sidebar-start-collapsed");
- std::fs::create_dir_all(path.parent().unwrap()).unwrap();
- std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
-
- let mut app = test_app();
- assert!(!app.state.sidebar_collapsed);
-
- std::fs::write(&path, "[ui]\nsidebar_start_collapsed = true\n").unwrap();
- let report = app.reload_config();
- assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
- assert!(!app.state.sidebar_collapsed);
-
- std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
- let _ = std::fs::remove_dir_all(path.parent().unwrap());
- }
-
- #[test]
- fn reload_config_updates_sidebar_collapsed_mode() {
- let _guard = config_env_lock().lock().unwrap();
- let path = temp_config_path("reload-config-sidebar-collapsed-mode");
- std::fs::create_dir_all(path.parent().unwrap()).unwrap();
- std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
-
- let mut app = test_app();
- assert_eq!(
- app.state.sidebar_collapsed_mode,
- crate::config::SidebarCollapsedModeConfig::Compact
- );
-
- std::fs::write(&path, "[ui]\nsidebar_collapsed_mode = \"hidden\"\n").unwrap();
- let report = app.reload_config();
- assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
- assert_eq!(
- app.state.sidebar_collapsed_mode,
- crate::config::SidebarCollapsedModeConfig::Hidden
- );
-
- std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
- let _ = std::fs::remove_dir_all(path.parent().unwrap());
- }
-
- #[test]
- fn reload_config_updates_sidebar_bounds_and_reclamps() {
- let _guard = config_env_lock().lock().unwrap();
- let path = temp_config_path("reload-config-sidebar-bounds");
- std::fs::create_dir_all(path.parent().unwrap()).unwrap();
- std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
-
- let mut app = test_app();
- // Default bounds.
- assert_eq!(app.state.sidebar_min_width, 18);
- assert_eq!(app.state.sidebar_max_width, 36);
- assert_eq!(
- app.state.mobile_width_threshold,
- crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD
- );
-
- // Manually set a width and flip the source so the existing
- // sidebar_width-only-when-config-owned guard does NOT update it.
- app.state.sidebar_width = 30;
- app.state.sidebar_width_source = state::SidebarWidthSource::Manual;
-
- // Tightening max below the current width must re-clamp the live width
- // even when source is Manual — bounds always apply.
- std::fs::write(&path, "[ui]\nsidebar_max_width = 24\n").unwrap();
- let report = app.reload_config();
- assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
- assert_eq!(app.state.sidebar_max_width, 24);
- assert_eq!(
- app.state.sidebar_width, 24,
- "manual width must re-clamp to new max"
- );
-
- // Loosening max leaves the live width alone (it's already within bounds).
- app.state.sidebar_width = 24;
- std::fs::write(&path, "[ui]\nsidebar_max_width = 60\n").unwrap();
- let report = app.reload_config();
- assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
- assert_eq!(app.state.sidebar_max_width, 60);
- assert_eq!(app.state.sidebar_width, 24);
-
- // Raising min above the current width re-clamps upward.
- std::fs::write(&path, "[ui]\nsidebar_min_width = 30\n").unwrap();
- let report = app.reload_config();
- assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
- assert_eq!(app.state.sidebar_min_width, 30);
- assert_eq!(
- app.state.sidebar_width, 30,
- "manual width must re-clamp up to new min"
- );
-
- std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
- let _ = std::fs::remove_dir_all(path.parent().unwrap());
- }
-
- #[test]
- fn reload_config_updates_mobile_width_threshold() {
- let _guard = config_env_lock().lock().unwrap();
- let path = temp_config_path("reload-config-mobile-width-threshold");
- std::fs::create_dir_all(path.parent().unwrap()).unwrap();
- std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
-
- let mut app = test_app();
- assert_eq!(
- app.state.mobile_width_threshold,
- crate::config::DEFAULT_MOBILE_WIDTH_THRESHOLD
- );
-
- std::fs::write(&path, "[ui]\nmobile_width_threshold = 96\n").unwrap();
- let report = app.reload_config();
-
- assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
- assert_eq!(app.state.mobile_width_threshold, 96);
-
- std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
- let _ = std::fs::remove_dir_all(path.parent().unwrap());
- }
-
- #[test]
- fn app_new_falls_back_to_default_bounds_on_inverted_config() {
- let mut config = Config::default();
- config.ui.sidebar_min_width = 50;
- config.ui.sidebar_max_width = 30;
-
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
-
- assert_eq!(
- app.state.sidebar_min_width, 18,
- "App::new must fall back to default min when bounds are inverted"
- );
- assert_eq!(
- app.state.sidebar_max_width, 36,
- "App::new must fall back to default max when bounds are inverted"
- );
- }
-
- #[test]
fn reload_config_invalid_sidebar_bounds_keeps_previous_ui_and_returns_partial() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("reload-config-invalid-sidebar-bounds");
@@ -3423,17 +1822,15 @@ mod tests {
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
let mut app = test_app();
- let original_min = app.state.sidebar_min_width;
- let original_max = app.state.sidebar_max_width;
- let original_mouse_capture = app.state.mouse_capture;
+ let original_pane_borders = app.state.pane_borders;
// Pair the bad bounds with another `[ui]` field change to confirm the
// entire section is treated as invalid (not just the bounds).
- let target_mouse_capture = !original_mouse_capture;
+ let target_pane_borders = !original_pane_borders;
std::fs::write(
&path,
format!(
- "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\nmouse_capture = {}\n",
- target_mouse_capture
+ "[ui]\nsidebar_min_width = 50\nsidebar_max_width = 30\npane_borders = {}\n",
+ target_pane_borders
),
)
.unwrap();
@@ -3445,11 +1842,9 @@ mod tests {
&& diagnostic.contains("sidebar_max_width")
&& diagnostic.contains("greater")
}));
- assert_eq!(app.state.sidebar_min_width, original_min);
- assert_eq!(app.state.sidebar_max_width, original_max);
assert_eq!(
- app.state.mouse_capture, original_mouse_capture,
- "[ui] is treated as invalid on bad bounds; mouse_capture must not apply"
+ app.state.pane_borders, original_pane_borders,
+ "[ui] is treated as invalid on bad bounds; pane_borders must not apply"
);
assert_eq!(
app.state.config_diagnostic.as_deref(),
@@ -3501,10 +1896,10 @@ mod tests {
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
let mut app = test_app();
- let target_mouse_capture = !app.state.mouse_capture;
+ let target_pane_borders = !app.state.pane_borders;
std::fs::write(
&path,
- format!("[ui]\nmouse_capture = {target_mouse_capture}\nmouse_captur = false\n"),
+ format!("[ui]\npane_borders = {target_pane_borders}\nmouse_captur = false\n"),
)
.unwrap();
@@ -3515,7 +1910,7 @@ mod tests {
report.diagnostics,
vec!["unknown config key ui.mouse_captur; ignoring key"]
);
- assert_eq!(app.state.mouse_capture, target_mouse_capture);
+ assert_eq!(app.state.pane_borders, target_pane_borders);
assert_eq!(
app.state.config_diagnostic.as_deref(),
Some("config.toml has unknown keys; herdr config check")
@@ -3622,85 +2017,6 @@ mod tests {
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
-
- #[test]
- fn settings_save_toast_delivery_persists_then_applies_live_config() {
- let _guard = config_env_lock().lock().unwrap();
- let path = temp_config_path("settings-save-toast-delivery");
- std::fs::create_dir_all(path.parent().unwrap()).unwrap();
- std::fs::write(&path, "onboarding = false\n").unwrap();
- std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
-
- let mut app = test_app();
- assert_eq!(
- app.state.toast_config.delivery,
- crate::config::ToastDelivery::Off
- );
-
- app.save_toast_delivery(crate::config::ToastDelivery::Terminal);
-
- assert_eq!(
- app.state.toast_config.delivery,
- crate::config::ToastDelivery::Terminal
- );
- let content = std::fs::read_to_string(&path).unwrap();
- assert!(content.contains("delivery = \"terminal\""));
- assert!(app.state.config_diagnostic.is_none());
-
- std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
- let _ = std::fs::remove_dir_all(path.parent().unwrap());
- }
-
- #[test]
- fn save_status_indicators_persists_then_applies_live_config() {
- let _guard = config_env_lock().lock().unwrap();
- let path = temp_config_path("save-status-indicators");
- std::fs::create_dir_all(path.parent().unwrap()).unwrap();
- std::fs::write(&path, "onboarding = false\n").unwrap();
- std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
-
- let mut app = test_app();
- assert_eq!(
- app.state.status_indicators,
- crate::config::StatusIndicatorStyle::Dots
- );
-
- app.save_status_indicators(crate::config::StatusIndicatorStyle::Symbols);
-
- assert_eq!(
- app.state.status_indicators,
- crate::config::StatusIndicatorStyle::Symbols
- );
- let content = std::fs::read_to_string(&path).unwrap();
- assert!(content.contains("status_indicators = \"symbols\""));
- assert!(app.state.config_diagnostic.is_none());
-
- std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
- let _ = std::fs::remove_dir_all(path.parent().unwrap());
- }
-
- #[test]
- fn save_agent_panel_sort_persists_then_applies_live_config() {
- let _guard = config_env_lock().lock().unwrap();
- let path = temp_config_path("save-agent-panel-sort");
- std::fs::create_dir_all(path.parent().unwrap()).unwrap();
- std::fs::write(&path, "onboarding = false\n").unwrap();
- std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
-
- let mut app = test_app();
- assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Spaces);
-
- app.save_agent_panel_sort(state::AgentPanelSort::Priority);
-
- assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority);
- let content = std::fs::read_to_string(&path).unwrap();
- assert!(content.contains("agent_panel_sort = \"priority\""));
- assert!(app.state.config_diagnostic.is_none());
-
- std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
- let _ = std::fs::remove_dir_all(path.parent().unwrap());
- }
-
#[test]
fn reload_config_keeps_current_state_on_invalid_toml() {
let _guard = config_env_lock().lock().unwrap();
@@ -3734,323 +2050,6 @@ mod tests {
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
-
- #[tokio::test]
- async fn raw_input_waits_when_reader_is_gone() {
- let result =
- tokio::time::timeout(Duration::from_millis(20), recv_raw_input_or_pending(None)).await;
- assert!(result.is_err());
- }
-
- #[tokio::test]
- async fn terminal_mode_handles_repeat_key_events() {
- let mut app = test_app();
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- let handled = app
- .handle_raw_input_event(raw_key(
- KeyCode::Backspace,
- KeyModifiers::empty(),
- KeyEventKind::Repeat,
- ))
- .await;
-
- assert!(handled);
- }
-
- #[tokio::test]
- async fn monolithic_input_forwards_report_all_printable_event_kinds() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- for kind in [
- KeyEventKind::Press,
- KeyEventKind::Repeat,
- KeyEventKind::Release,
- ] {
- app.handle_raw_input_event(raw_key(KeyCode::Char('j'), KeyModifiers::empty(), kind))
- .await;
- }
-
- assert_eq!(
- rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1b[106;1:1u")
- );
- assert_eq!(
- rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1b[106;1:2u")
- );
- assert_eq!(
- rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1b[106;1:3u")
- );
- assert!(rx.try_recv().is_err());
- }
-
- #[tokio::test]
- async fn outer_focus_gained_marks_visible_done_panes_seen() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let root_pane = workspace.tabs[0].root_pane;
- let split_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
- let background_tab = workspace.test_add_tab(Some("background"));
- let background_pane = workspace.tabs[background_tab].root_pane;
-
- app.state.workspaces = vec![workspace];
- app.state.ensure_test_terminals();
- let root_terminal_id = app.state.workspaces[0].tabs[0].panes[&root_pane]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&root_terminal_id)
- .unwrap()
- .state = AgentState::Idle;
- app.state.workspaces[0].tabs[0]
- .panes
- .get_mut(&root_pane)
- .unwrap()
- .seen = false;
- let split_terminal_id = app.state.workspaces[0].tabs[0].panes[&split_pane]
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&split_terminal_id)
- .unwrap()
- .state = AgentState::Idle;
- app.state.workspaces[0].tabs[0]
- .panes
- .get_mut(&split_pane)
- .unwrap()
- .seen = false;
- let bg_terminal_id = app.state.workspaces[0].tabs[background_tab].panes[&background_pane]
- .attached_terminal_id
- .clone();
- app.state.terminals.get_mut(&bg_terminal_id).unwrap().state = AgentState::Idle;
- app.state.workspaces[0].tabs[background_tab]
- .panes
- .get_mut(&background_pane)
- .unwrap()
- .seen = false;
-
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.outer_terminal_focus = Some(false);
-
- let handled = app
- .handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained)
- .await;
-
- assert!(handled);
- assert_eq!(app.state.outer_terminal_focus, Some(true));
- assert!(app.state.workspaces[0].tabs[0].panes[&root_pane].seen);
- assert!(app.state.workspaces[0].tabs[0].panes[&split_pane].seen);
- assert!(!app.state.workspaces[0].tabs[background_tab].panes[&background_pane].seen);
- }
-
- #[tokio::test]
- async fn outer_focus_gained_does_not_require_full_redraw_when_disabled() {
- let mut app = test_app();
- app.state.redraw_on_focus_gained = false;
-
- let handled = app
- .handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained)
- .await;
-
- assert!(handled);
- assert_eq!(app.state.outer_terminal_focus, Some(true));
- assert!(!app.full_redraw_pending);
- }
-
- #[tokio::test]
- async fn monolithic_outer_focus_events_reach_reporting_pane() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("focus-reporting");
- let pane_id = workspace.tabs[0].root_pane;
- let (runtime, mut input_rx) =
- crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
- 80,
- 24,
- 0,
- b"\x1b[?1004h",
- 4,
- );
- workspace.insert_test_runtime(pane_id, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- assert!(
- app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained)
- .await
- );
- assert_eq!(
- input_rx
- .recv()
- .await
- .expect("forwarded focus gained report"),
- bytes::Bytes::from_static(b"\x1b[I")
- );
-
- assert!(
- !app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusLost)
- .await
- );
- assert_eq!(
- input_rx.recv().await.expect("forwarded focus lost report"),
- bytes::Bytes::from_static(b"\x1b[O")
- );
- }
-
- #[tokio::test]
- async fn outer_focus_events_reconcile_pending_pane_focus() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("focus-transition");
- let previous_pane = workspace.tabs[0].root_pane;
- let next_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
- workspace.tabs[0].layout.focus_pane(previous_pane);
- let (runtime, mut input_rx) =
- crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
- 80,
- 24,
- 0,
- b"\x1b[?1004h",
- 4,
- );
- workspace.insert_test_runtime(next_pane, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.last_focus = Some((0, previous_pane));
-
- assert!(app.state.focus_pane_in_workspace(0, next_pane));
- assert!(
- !app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusLost)
- .await
- );
- app.sync_focus_events();
- assert_eq!(
- input_rx.try_recv().unwrap(),
- bytes::Bytes::from_static(b"\x1b[O")
- );
- assert!(input_rx.try_recv().is_err());
-
- assert!(app.state.focus_pane_in_workspace(0, previous_pane));
- app.sync_focus_events();
- assert_eq!(
- input_rx.try_recv().unwrap(),
- bytes::Bytes::from_static(b"\x1b[O")
- );
-
- assert!(app.state.focus_pane_in_workspace(0, next_pane));
- app.sync_focus_events();
- assert_eq!(
- input_rx.try_recv().unwrap(),
- bytes::Bytes::from_static(b"\x1b[O")
- );
-
- assert!(
- app.handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained)
- .await
- );
- assert_eq!(
- input_rx.try_recv().unwrap(),
- bytes::Bytes::from_static(b"\x1b[I")
- );
-
- assert!(app.state.focus_pane_in_workspace(0, previous_pane));
- app.sync_focus_events();
- assert_eq!(
- input_rx.try_recv().unwrap(),
- bytes::Bytes::from_static(b"\x1b[O")
- );
- assert!(app.state.focus_pane_in_workspace(0, next_pane));
- app.sync_focus_events();
- assert_eq!(
- input_rx.try_recv().unwrap(),
- bytes::Bytes::from_static(b"\x1b[I")
- );
- }
-
- #[tokio::test]
- async fn repeat_key_events_are_ignored_outside_terminal_mode() {
- let mut app = test_app();
- app.state.mode = Mode::ReleaseNotes;
- app.state.release_notes = Some(release_notes_state());
-
- let handled = app
- .handle_raw_input_event(raw_key(
- KeyCode::Enter,
- KeyModifiers::empty(),
- KeyEventKind::Repeat,
- ))
- .await;
-
- assert!(!handled);
- assert_eq!(app.state.mode, Mode::ReleaseNotes);
- assert!(app.state.release_notes.is_some());
- }
-
- #[tokio::test]
- async fn modal_press_does_not_leak_repeat_into_terminal_mode() {
- let mut app = test_app();
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::ReleaseNotes;
- app.state.release_notes = Some(release_notes_state());
-
- let press_handled = app
- .handle_raw_input_event(raw_key(
- KeyCode::Enter,
- KeyModifiers::empty(),
- KeyEventKind::Press,
- ))
- .await;
- let repeat_handled = app
- .handle_raw_input_event(raw_key(
- KeyCode::Enter,
- KeyModifiers::empty(),
- KeyEventKind::Repeat,
- ))
- .await;
- let release_handled = app
- .handle_raw_input_event(raw_key(
- KeyCode::Enter,
- KeyModifiers::empty(),
- KeyEventKind::Release,
- ))
- .await;
- let next_press_handled = app
- .handle_raw_input_event(raw_key(
- KeyCode::Enter,
- KeyModifiers::empty(),
- KeyEventKind::Press,
- ))
- .await;
-
- assert!(press_handled);
- assert_eq!(app.state.mode, Mode::Terminal);
- assert!(!repeat_handled);
- assert!(!release_handled);
- assert!(next_press_handled);
- }
-
#[test]
fn read_only_api_requests_do_not_force_rerender() {
let read_only = crate::api::schema::Request {
@@ -4117,6 +2116,35 @@ mod tests {
crate::api::schema::AgentViewClearParams::default(),
),
};
+ let command_invoke = crate::api::schema::Request {
+ id: "req_10".into(),
+ method: crate::api::schema::Method::CommandInvoke(
+ crate::api::schema::CommandInvokeParams {
+ command_id: "cmd_boot_1_0".into(),
+ workspace_id: Some("w1".into()),
+ tab_id: Some("w1:t1".into()),
+ pane_id: Some("w1:p1".into()),
+ selection: None,
+ },
+ ),
+ };
+ let announcement_dismiss = crate::api::schema::Request {
+ id: "req_11".into(),
+ method: crate::api::schema::Method::ProductAnnouncementDismiss(
+ crate::api::schema::ProductAnnouncementDismissParams {
+ version: "0.8.2".into(),
+ id: "client-shell".into(),
+ },
+ ),
+ };
+ let release_notes_dismiss = crate::api::schema::Request {
+ id: "req_12".into(),
+ method: crate::api::schema::Method::ReleaseNotesDismiss(
+ crate::api::schema::ReleaseNotesDismissParams {
+ version: "0.8.2".into(),
+ },
+ ),
+ };
assert!(!crate::api::request_changes_ui(&read_only));
assert!(!crate::api::request_changes_ui(&worktree_list));
@@ -4127,6 +2155,9 @@ mod tests {
assert!(crate::api::request_changes_ui(&pane_focus_direction));
assert!(crate::api::request_changes_ui(&pane_resize));
assert!(crate::api::request_changes_ui(&agent_view));
+ assert!(crate::api::request_changes_ui(&command_invoke));
+ assert!(crate::api::request_changes_ui(&announcement_dismiss));
+ assert!(crate::api::request_changes_ui(&release_notes_dismiss));
}
#[test]
@@ -4538,105 +2569,6 @@ mod tests {
}
#[tokio::test]
- 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", exiting_test_command());
-
- let mut app = test_app();
- let mut workspace = Workspace::test_new("api-pane-split-background-tab");
- let active_pane = workspace.tabs[0].root_pane;
- let background_tab = workspace.test_add_tab(Some("worker"));
- let target_pane = workspace.tabs[background_tab].root_pane;
- workspace.switch_tab(background_tab);
- let background_previous_focus =
- workspace.test_split(ratatui::layout::Direction::Horizontal);
- workspace.switch_tab(0);
- app.state.workspaces = vec![workspace];
- app.state.ensure_test_terminals();
- let split_cwd = std::env::temp_dir();
- let target_terminal_id = app.state.workspaces[0]
- .pane_state(target_pane)
- .unwrap()
- .attached_terminal_id
- .clone();
- app.state
- .terminals
- .get_mut(&target_terminal_id)
- .unwrap()
- .cwd = split_cwd.clone();
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state
- .focus_pane_in_workspace(0, background_previous_focus);
- app.state.focus_pane_in_workspace(0, active_pane);
-
- let target_pane_id = app.pane_info(0, target_pane).unwrap().pane_id;
- let target_tab_id = app.public_tab_id(0, background_tab).unwrap();
-
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "req_pane_split_background_tab".into(),
- method: crate::api::schema::Method::PaneSplit(crate::api::schema::PaneSplitParams {
- workspace_id: None,
- target_pane_id: Some(target_pane_id),
- direction: crate::api::schema::SplitDirection::Right,
- ratio: None,
- cwd: None,
- focus: false,
- right_click: Default::default(),
- env: Default::default(),
- }),
- });
- let response: serde_json::Value = serde_json::from_str(&response).unwrap();
-
- assert_eq!(response["result"]["type"], "pane_info");
- assert_eq!(response["result"]["pane"]["tab_id"], target_tab_id);
- let response_cwd =
- std::path::PathBuf::from(response["result"]["pane"]["cwd"].as_str().unwrap());
- assert_eq!(
- crate::worktree::canonical_or_original(&response_cwd),
- crate::worktree::canonical_or_original(&split_cwd)
- );
- assert_eq!(response["result"]["pane"]["focused"], false);
- assert_eq!(app.state.active, Some(0));
- assert_eq!(app.state.workspaces[0].active_tab, 0);
- assert_eq!(
- app.state.workspaces[0].tabs[0].layout.focused(),
- active_pane
- );
- assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1);
- assert_eq!(
- app.state.workspaces[0].tabs[background_tab]
- .layout
- .focused(),
- background_previous_focus
- );
- assert_eq!(
- app.state.workspaces[0].tabs[background_tab]
- .layout
- .pane_count(),
- 3
- );
- app.state.last_pane();
- assert_eq!(app.state.workspaces[0].active_tab, background_tab);
- assert_eq!(
- app.state.workspaces[0].tabs[background_tab]
- .layout
- .focused(),
- background_previous_focus
- );
-
- let runtimes: Vec<_> = app.terminal_runtimes.drain().collect();
- for (_terminal_id, runtime) in runtimes {
- runtime.shutdown();
- }
- match original_shell {
- Some(value) => std::env::set_var("SHELL", value),
- None => std::env::remove_var("SHELL"),
- }
- }
-
- #[tokio::test]
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");
@@ -4977,15 +2909,14 @@ mod tests {
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
assert_eq!(response["error"]["code"], "confirmation_required");
- assert_eq!(app.state.mode, Mode::ConfirmClose);
- assert_eq!(app.state.selected, 0);
+ assert_eq!(app.state.selected, 1);
assert_eq!(app.state.workspaces.len(), 2);
}
#[test]
fn session_dirty_flag_schedules_debounced_save() {
let mut app = test_app();
- app.no_session = false;
+ app.policy.persist_session = true;
app.state.session_dirty = true;
app.sync_session_save_schedule();
@@ -4995,24 +2926,9 @@ mod tests {
}
#[test]
- fn next_loop_deadline_includes_session_save_deadline() {
- let mut app = test_app();
- let now = Instant::now();
- app.session_save_deadline = Some(now + Duration::from_secs(2));
- app.next_resize_poll = now + Duration::from_secs(5);
- app.next_auto_update_check = Some(now + Duration::from_secs(6));
-
- assert_eq!(
- app.next_loop_deadline(now, false),
- app.session_save_deadline
- );
- }
-
- #[test]
fn headless_next_loop_deadline_ignores_resize_poll() {
let mut app = test_app();
let now = Instant::now();
- app.next_resize_poll = now + Duration::from_millis(100);
app.session_save_deadline = Some(now + Duration::from_secs(2));
app.next_auto_update_check = Some(now + Duration::from_secs(6));
@@ -5026,7 +2942,6 @@ mod tests {
fn headless_next_loop_deadline_returns_none_when_resize_poll_is_only_deadline() {
let mut app = test_app();
let now = Instant::now();
- app.next_resize_poll = now - Duration::from_millis(1);
app.config_diagnostic_deadline = None;
app.toast_deadline = None;
app.next_auto_update_check = None;
@@ -5040,16 +2955,6 @@ mod tests {
}
#[test]
- fn due_session_save_deadline_is_cleared() {
- let mut app = test_app();
- app.session_save_deadline = Some(Instant::now() - Duration::from_secs(1));
-
- app.handle_scheduled_tasks(Instant::now(), false);
-
- assert!(app.session_save_deadline.is_none());
- }
-
- #[test]
fn due_session_save_starts_background_writer() {
let _guard = crate::config::test_config_env_lock().lock().unwrap();
let config_home = unique_temp_path("background-session-save");
@@ -5057,12 +2962,12 @@ mod tests {
std::env::remove_var(crate::session::SESSION_ENV_VAR);
let mut app = test_app();
- app.no_session = false;
+ app.policy.persist_session = true;
app.state.workspaces = vec![Workspace::test_new("autosave")];
app.state.ensure_test_terminals();
app.session_save_deadline = Some(Instant::now() - Duration::from_secs(1));
- app.handle_scheduled_tasks(Instant::now(), false);
+ app.start_background_session_save();
assert!(app.session_save_thread.is_some());
assert!(app.session_save_deadline.is_none());
@@ -5076,7 +2981,7 @@ mod tests {
#[test]
fn background_session_save_reschedules_when_writer_is_busy() {
let mut app = test_app();
- app.no_session = false;
+ app.policy.persist_session = true;
let (release_tx, release_rx) = std::sync::mpsc::channel();
app.session_save_thread = Some(std::thread::spawn(move || {
let _ = release_rx.recv();
@@ -5088,14 +2993,14 @@ mod tests {
assert!(app.session_save_deadline.is_some());
release_tx.send(()).unwrap();
- app.no_session = true;
+ app.policy.persist_session = false;
app.save_session_now();
}
#[test]
fn final_session_save_joins_background_writer_before_returning() {
let mut app = test_app();
- app.no_session = true;
+ app.policy.persist_session = false;
let (release_tx, release_rx) = std::sync::mpsc::channel();
let (done_tx, done_rx) = std::sync::mpsc::channel();
app.session_save_thread = Some(std::thread::spawn(move || {
@@ -5113,52 +3018,6 @@ mod tests {
done_rx.try_recv().unwrap();
assert!(app.session_save_thread.is_none());
}
-
- #[test]
- fn next_loop_deadline_includes_selection_autoscroll_deadline() {
- let mut app = test_app();
- let now = Instant::now();
- app.next_resize_poll = now + Duration::from_millis(300);
- app.selection_autoscroll_deadline = Some(now + Duration::from_millis(5));
- app.session_save_deadline = Some(now + Duration::from_millis(200));
- assert_eq!(
- app.next_loop_deadline(now, false),
- app.selection_autoscroll_deadline
- );
- }
-
- #[test]
- fn tick_selection_autoscroll_self_heals_when_state_cleared() {
- let mut app = test_app();
- let now = Instant::now();
- app.state.selection_autoscroll = None;
- app.selection_autoscroll_deadline = Some(now);
- app.tick_selection_autoscroll(now);
- assert!(app.selection_autoscroll_deadline.is_none());
- }
-
- #[test]
- fn tick_selection_autoscroll_stops_on_rect_change() {
- let mut app = test_app();
- let now = Instant::now();
- let ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- app.state.workspaces.push(ws);
- app.state.active = Some(0);
- app.state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None));
- // Set autoscroll with a stale inner_rect that doesn't match pane_infos
- app.state.selection_autoscroll = Some(state::SelectionAutoscroll {
- direction: state::SelectionAutoscrollDirection::Down,
- last_mouse_screen_col: 0,
- last_mouse_screen_row: 999,
- inner_rect: ratatui::layout::Rect::new(0, 0, 1, 1), // wrong rect
- });
- app.selection_autoscroll_deadline = Some(now);
- app.tick_selection_autoscroll(now);
- assert!(app.state.selection_autoscroll.is_none());
- assert!(app.selection_autoscroll_deadline.is_none());
- }
-
#[tokio::test]
async fn full_internal_event_queue_eventually_applies_working_to_idle_transition() {
let mut app = test_app();
@@ -5239,1368 +3098,4 @@ mod tests {
"Working→Idle should still apply after temporary queue pressure"
);
}
-
- #[test]
- fn route_client_input_dispatches_navigate_mode_keybinds() {
- let mut app = test_app();
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
-
- // Start in navigate mode.
- app.state.mode = Mode::Navigate;
-
- // Send Ctrl+B then Esc (prefix → leave navigate mode).
- // Ctrl+B is 0x02 in raw terminal input.
- // After entering navigate mode and pressing Esc, we should leave navigate mode.
- let esc_bytes = vec![0x1b]; // Esc
- app.route_client_input(esc_bytes);
- // Esc in navigate mode should leave navigate mode.
- assert_eq!(
- app.state.mode,
- Mode::Terminal,
- "Esc should leave navigate mode and return to Terminal mode"
- );
- }
-
- #[test]
- fn explicit_text_commit_does_not_trigger_navigate_binding() {
- let mut app = test_app();
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.detach_exits = false;
- app.state.mode = Mode::Navigate;
-
- app.route_client_events(
- vec![crate::raw_input::RawInputEvent::Text(
- crate::input::TextCommit::new("q"),
- )],
- false,
- );
-
- assert!(!app.state.detach_requested);
- assert_eq!(app.state.mode, Mode::Navigate);
- assert!(app.input_leases.is_empty());
- }
-
- #[test]
- fn route_client_input_q_detaches_in_persistence_mode() {
- let mut app = test_app();
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.detach_exits = false;
-
- // Start in navigate mode.
- app.state.mode = Mode::Navigate;
- assert!(!app.state.detach_requested);
-
- let q_bytes = b"q".to_vec();
- app.route_client_input(q_bytes);
-
- assert!(
- app.state.detach_requested,
- "q should detach in persistence mode"
- );
- assert_eq!(
- app.state.mode,
- Mode::Terminal,
- "q should leave navigate mode"
- );
- }
-
- #[test]
- fn route_client_input_prefix_then_q_detaches_in_persistence_mode() {
- let mut app = test_app();
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.detach_exits = false;
-
- // Start in terminal mode (default after workspace creation).
- app.state.mode = Mode::Terminal;
- assert!(!app.state.detach_requested);
-
- // Send Ctrl+B (prefix key, raw byte 0x02).
- let prefix_bytes = vec![0x02];
- app.route_client_input(prefix_bytes);
-
- assert_eq!(
- app.state.mode,
- Mode::Prefix,
- "prefix key should enter prefix mode"
- );
- assert!(
- !app.state.detach_requested,
- "prefix key should not set detach flag"
- );
-
- let q_bytes = b"q".to_vec();
- app.route_client_input(q_bytes);
-
- assert!(
- app.state.detach_requested,
- "q should detach in persistence mode"
- );
- assert_eq!(
- app.state.mode,
- Mode::Terminal,
- "q should leave navigate mode"
- );
- }
-
- #[test]
- fn route_client_input_prefix_tab_dispatches_global_last_pane() {
- let config: Config = toml::from_str(
- r#"
-[keys]
-last_pane = "prefix+tab"
-"#,
- )
- .unwrap();
- let mut app = test_app();
- let mut first = Workspace::test_new("one");
- let first_second_tab = first.test_add_tab(Some("logs"));
- let first_second_root = first.tabs[first_second_tab].root_pane;
- let second = Workspace::test_new("two");
- let second_root = second.tabs[0].root_pane;
- app.state.workspaces = vec![first, second];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.keybinds = config.keybinds();
- app.state.mode = Mode::Terminal;
- app.state.switch_workspace_tab(0, first_second_tab);
- app.state.switch_workspace_tab(1, 0);
-
- app.route_client_input(vec![0x02, b'\t']);
-
- assert_eq!(app.state.mode, Mode::Terminal);
- assert_eq!(app.state.active, Some(0));
- assert_eq!(app.state.workspaces[0].active_tab, first_second_tab);
- assert_eq!(
- app.state.workspaces[0].focused_pane_id(),
- Some(first_second_root)
- );
-
- app.route_client_input(vec![0x02, b'\t']);
-
- assert_eq!(app.state.active, Some(1));
- assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(second_root));
- }
-
- #[tokio::test]
- async fn route_client_input_double_prefix_passes_prefix_through_to_focused_pane() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.prefix_code = KeyCode::Char('l');
- app.state.prefix_mods = KeyModifiers::CONTROL;
-
- app.route_client_input(vec![0x0c]);
- assert_eq!(app.state.mode, Mode::Prefix);
-
- app.route_client_input(vec![0x0c]);
- assert_eq!(app.state.mode, Mode::Terminal);
- assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from(vec![0x0c]));
- }
-
- #[tokio::test]
- async fn route_client_input_reencodes_terminal_keys_for_focused_pane_protocol() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- // Ghostty/kitty-style Ctrl-C should be normalized back to the pane's
- // negotiated encoding instead of being forwarded verbatim.
- app.route_client_input(b"\x1b[99;5u".to_vec());
-
- assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from(vec![3]));
-
- // iTerm2 and rxvt-style hosts may send F4 as CSI 14~. Normalize it
- // through the same semantic key path instead of leaking host bytes.
- app.route_client_input(b"\x1b[14~".to_vec());
-
- assert_eq!(
- rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1bOS")
- );
- }
-
- #[tokio::test]
- async fn host_report_all_supplies_printable_releases_for_event_type_only_panes() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) = TerminalRuntime::test_with_channel_and_scrollback_bytes(
- 80,
- 24,
- 0,
- b"\x1b[>4;2m\x1b[=3;1u",
- 4,
- );
- assert_eq!(
- runtime.keyboard_protocol(),
- crate::input::KeyboardProtocol::Kitty { flags: 3 }
- );
- assert!(runtime
- .input_state()
- .is_some_and(|state| state.modify_other_keys));
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- assert!(app.host_keyboard_report_all_requested());
-
- app.route_client_input(b"\x1b[106;1:1u\x1b[106;1:2u\x1b[106;1:3u".to_vec());
- assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j"));
- assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"j"));
- assert_eq!(
- rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1b[106;1:3u")
- );
- assert!(rx.try_recv().is_err());
-
- let runtime = app
- .state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused)
- .unwrap();
- runtime.test_process_pty_bytes(b"\x1b[>4;0m");
- assert!(!runtime
- .input_state()
- .is_some_and(|state| state.modify_other_keys));
- assert!(!app.host_keyboard_report_all_requested());
-
- #[cfg(unix)]
- {
- runtime.test_process_pty_bytes(b"\x1b[>4;1m");
- assert!(!runtime
- .input_state()
- .is_some_and(|state| state.modify_other_keys));
- assert!(!app.host_keyboard_report_all_requested());
- }
- }
-
- #[tokio::test]
- async fn host_report_all_follows_terminal_protocol_and_command_modes() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, _rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 1);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- assert!(app.host_keyboard_report_all_requested());
-
- app.state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused)
- .unwrap()
- .test_process_pty_bytes(b"\x1b[<u");
- assert!(!app.host_keyboard_report_all_requested());
-
- app.state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused)
- .unwrap()
- .test_process_pty_bytes(b"\x1b[>15u");
- let other_pane = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
- let (other_runtime, _rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>7u", 1);
- app.state.workspaces[0].tabs[0]
- .runtimes
- .insert(other_pane, other_runtime);
- assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(other_pane));
- assert!(!app.host_keyboard_report_all_requested());
-
- assert!(app.state.focus_pane_in_workspace(0, focused));
- app.state.mode = Mode::Prefix;
- assert!(app.host_keyboard_report_all_requested());
- app.state.mode = Mode::Navigate;
- assert!(app.host_keyboard_report_all_requested());
- app.state.mode = Mode::RenameWorkspace;
- assert!(!app.host_keyboard_report_all_requested());
- }
-
- #[tokio::test]
- async fn route_client_input_forwards_report_all_printable_event_kinds() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_input(b"\x1b[106u\x1b[106;1:2u\x1b[106;1:3u".to_vec());
-
- assert_eq!(
- rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1b[106;1:1u")
- );
- assert_eq!(
- rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1b[106;1:2u")
- );
- assert_eq!(
- rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1b[106;1:3u")
- );
- assert!(rx.try_recv().is_err());
- }
-
- #[tokio::test]
- async fn explicit_text_commit_bypasses_bindings_leases_and_key_encoding() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_events(
- vec![crate::raw_input::RawInputEvent::Text(
- crate::input::TextCommit::new("你🙂"),
- )],
- false,
- );
-
- assert_eq!(rx.recv().await.unwrap().as_ref(), "你🙂".as_bytes());
- assert!(rx.try_recv().is_err());
- assert!(app.input_leases.is_empty());
- }
-
- #[tokio::test]
- async fn committed_ime_text_bypasses_report_all_key_encoding() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_input("你".as_bytes().to_vec());
-
- assert_eq!(
- rx.recv().await.unwrap(),
- bytes::Bytes::from_static("你".as_bytes())
- );
- assert!(app.input_leases.is_empty());
- }
-
- #[tokio::test]
- async fn kitty_associated_ime_text_bypasses_report_all_key_encoding() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_input(b"\x1b[32;;20320:22909u".to_vec());
-
- assert_eq!(
- rx.recv().await.unwrap(),
- bytes::Bytes::from_static("你好".as_bytes())
- );
- assert!(rx.try_recv().is_err());
- assert!(app.input_leases.is_empty());
- }
-
- #[tokio::test]
- async fn committed_ascii_uppercase_bypasses_report_all_key_encoding() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_input(b"A".to_vec());
-
- assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"A"));
- assert!(app.input_leases.is_empty());
- }
-
- #[tokio::test]
- async fn committed_text_does_not_erase_owned_physical_key() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_input(b"\x1b[106u".to_vec());
- app.route_client_input(b"j".to_vec());
- app.route_client_input(b"\x1b[106;1:3u".to_vec());
-
- assert_eq!(
- rx.try_recv().expect("physical press"),
- bytes::Bytes::from_static(b"\x1b[106;1:1u")
- );
- assert_eq!(
- rx.try_recv().expect("committed text"),
- bytes::Bytes::from_static(b"j")
- );
- assert_eq!(
- rx.try_recv().expect("physical release"),
- bytes::Bytes::from_static(b"\x1b[106;1:3u")
- );
- assert!(app.input_leases.is_empty());
- }
-
- #[tokio::test]
- async fn outer_focus_loss_releases_owned_report_all_keys() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_events(
- vec![
- raw_key(
- KeyCode::Char('j'),
- KeyModifiers::empty(),
- KeyEventKind::Press,
- ),
- crate::raw_input::RawInputEvent::OuterFocusLost,
- ],
- false,
- );
-
- assert_eq!(
- rx.try_recv().expect("forwarded press"),
- bytes::Bytes::from_static(b"\x1b[106;1:1u")
- );
- assert_eq!(
- rx.try_recv().expect("synthetic release on focus loss"),
- bytes::Bytes::from_static(b"\x1b[106;1:3u")
- );
- assert!(app.input_leases.is_empty());
- }
-
- #[tokio::test]
- async fn disconnected_input_source_releases_owned_report_all_keys() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_events_from(
- 42,
- vec![raw_key(
- KeyCode::Char('j'),
- KeyModifiers::empty(),
- KeyEventKind::Press,
- )],
- false,
- );
- app.clear_input_source(42);
-
- assert_eq!(
- rx.try_recv().expect("forwarded press"),
- bytes::Bytes::from_static(b"\x1b[106;1:1u")
- );
- assert_eq!(
- rx.try_recv().expect("synthetic release on disconnect"),
- bytes::Bytes::from_static(b"\x1b[106;1:3u")
- );
- assert!(app.input_leases.is_empty());
- }
-
- #[tokio::test]
- async fn physical_count_one_repeats_and_release_keep_the_pressed_pane() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let pressed_pane = workspace.focused_pane_id().unwrap();
- let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
- workspace.tabs[0].layout.focus_pane(pressed_pane);
- let (pressed_runtime, mut pressed_rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 5);
- let (other_runtime, mut other_rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
- workspace.tabs[0]
- .runtimes
- .insert(pressed_pane, pressed_runtime);
- workspace.tabs[0].runtimes.insert(other_pane, other_runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- let record = crate::input::WindowsKeyRecord {
- key_down: true,
- repeat_count: 1,
- virtual_key_code: 65,
- virtual_scan_code: 30,
- unicode: 97,
- control_key_state: 0,
- };
- let roundtrip = |events: Vec<crate::protocol::ClientInputEvent>| {
- let message = crate::protocol::ClientMessage::InputEvents { events };
- let encoded =
- bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap();
- let (decoded, _): (crate::protocol::ClientMessage, _) =
- bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
- let crate::protocol::ClientMessage::InputEvents { events } = decoded else {
- panic!("expected structured input events");
- };
- events
- .into_iter()
- .map(|event| event.to_raw_input_event())
- .collect()
- };
- let event = |kind, record| crate::protocol::ClientInputEvent::Key {
- code: crate::protocol::ClientKeyCode::Char('a'),
- modifiers: 0,
- kind,
- repeat_count: 1,
- generated_text: None,
- source: crate::protocol::ClientKeySource::WindowsConsole { record },
- };
-
- app.route_client_events(
- roundtrip(vec![event(crate::protocol::ClientKeyKind::Press, record)]),
- false,
- );
- assert!(app.state.focus_pane_in_workspace(0, other_pane));
- app.route_client_events(
- roundtrip(vec![
- event(crate::protocol::ClientKeyKind::Press, record),
- event(crate::protocol::ClientKeyKind::Repeat, record),
- event(
- crate::protocol::ClientKeyKind::Release,
- crate::input::WindowsKeyRecord {
- key_down: false,
- unicode: 0,
- ..record
- },
- ),
- ]),
- false,
- );
-
- for expected in [
- b"\x1b[97;1:1u".as_slice(),
- b"\x1b[97;1:2u".as_slice(),
- b"\x1b[97;1:2u".as_slice(),
- b"\x1b[97;1:3u".as_slice(),
- ] {
- assert_eq!(pressed_rx.try_recv().unwrap().as_ref(), expected);
- }
- assert!(pressed_rx.try_recv().is_err());
- assert!(other_rx.try_recv().is_err());
- assert!(app.input_leases.is_empty());
- }
-
- #[tokio::test]
- async fn grouped_physical_press_and_runtime_loss_preserve_count_then_close_the_lease() {
- let mut app = test_app();
- let workspace = Workspace::test_new("test");
- let pane_id = workspace.focused_pane_id().unwrap();
- let terminal_id = workspace.tabs[0].terminal_id(pane_id).unwrap().clone();
- app.state.workspaces = vec![workspace];
- app.state.ensure_test_terminals();
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- let (runtime, mut rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
- app.terminal_runtimes.insert(terminal_id.clone(), runtime);
-
- let record = crate::input::WindowsKeyRecord {
- key_down: true,
- repeat_count: 3,
- virtual_key_code: 65,
- virtual_scan_code: 30,
- unicode: 97,
- control_key_state: 0,
- };
- let message = crate::protocol::ClientMessage::InputEvents {
- events: vec![crate::protocol::ClientInputEvent::Key {
- code: crate::protocol::ClientKeyCode::Char('a'),
- modifiers: 0,
- kind: crate::protocol::ClientKeyKind::Press,
- repeat_count: 3,
- generated_text: None,
- source: crate::protocol::ClientKeySource::WindowsConsole { record },
- }],
- };
- let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap();
- let (decoded, _): (crate::protocol::ClientMessage, _) =
- bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
- let crate::protocol::ClientMessage::InputEvents { events } = decoded else {
- panic!("expected structured input events");
- };
-
- app.route_client_events(
- events
- .into_iter()
- .map(|event| event.to_raw_input_event())
- .collect(),
- false,
- );
- app.shutdown_terminal_runtime(terminal_id.clone());
-
- assert_eq!(
- rx.try_recv().expect("grouped press"),
- bytes::Bytes::from_static(b"\x1b[97;1:1u\x1b[97;1:2u\x1b[97;1:2u")
- );
- assert_eq!(
- rx.try_recv()
- .expect("synthetic release before runtime shutdown"),
- bytes::Bytes::from_static(b"\x1b[97;1:3u")
- );
- assert!(rx.try_recv().is_err());
- assert!(app.input_leases.is_empty());
- assert!(app.terminal_runtimes.get(&terminal_id).is_none());
- }
-
- #[tokio::test]
- async fn report_all_repeat_and_release_return_to_the_pressed_pane() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let pressed_pane = workspace.focused_pane_id().unwrap();
- let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
- workspace.tabs[0].layout.focus_pane(pressed_pane);
- let (pressed_runtime, mut pressed_rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4);
- let (other_runtime, mut other_rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4);
- workspace.tabs[0]
- .runtimes
- .insert(pressed_pane, pressed_runtime);
- workspace.tabs[0].runtimes.insert(other_pane, other_runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_input(b"\x1b[106u".to_vec());
- assert!(app.state.focus_pane_in_workspace(0, other_pane));
- app.route_client_input(b"\x1b[106;1:2u\x1b[106;1:3u".to_vec());
-
- assert_eq!(
- pressed_rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1b[106;1:1u")
- );
- assert_eq!(
- pressed_rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1b[106;1:2u")
- );
- assert_eq!(
- pressed_rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1b[106;1:3u")
- );
- assert!(pressed_rx.try_recv().is_err());
- assert!(other_rx.try_recv().is_err());
- }
-
- #[tokio::test]
- async fn report_all_key_ownership_is_isolated_by_client() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let first_pane = workspace.focused_pane_id().unwrap();
- let second_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
- workspace.tabs[0].layout.focus_pane(first_pane);
- let (first_runtime, mut first_rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3);
- let (second_runtime, mut second_rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3);
- workspace.tabs[0].runtimes.insert(first_pane, first_runtime);
- workspace.tabs[0]
- .runtimes
- .insert(second_pane, second_runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_events_from(
- 1,
- vec![raw_key(
- KeyCode::Char('j'),
- KeyModifiers::empty(),
- KeyEventKind::Press,
- )],
- false,
- );
- assert!(app.state.focus_pane_in_workspace(0, second_pane));
- app.route_client_events_from(
- 2,
- vec![raw_key(
- KeyCode::Char('j'),
- KeyModifiers::empty(),
- KeyEventKind::Press,
- )],
- false,
- );
- app.route_client_events_from(
- 1,
- vec![raw_key(
- KeyCode::Char('j'),
- KeyModifiers::empty(),
- KeyEventKind::Release,
- )],
- false,
- );
- app.route_client_events_from(
- 2,
- vec![raw_key(
- KeyCode::Char('j'),
- KeyModifiers::empty(),
- KeyEventKind::Release,
- )],
- false,
- );
-
- for rx in [&mut first_rx, &mut second_rx] {
- assert_eq!(
- rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1b[106;1:1u")
- );
- assert_eq!(
- rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1b[106;1:3u")
- );
- assert!(rx.try_recv().is_err());
- }
- }
-
- #[tokio::test]
- async fn report_all_release_survives_modifier_release_order() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_input(b"\x1b[106:74;2u\x1b[106;1:3u".to_vec());
-
- assert_eq!(
- rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1b[106:74;2:1u")
- );
- assert_eq!(
- rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1b[106;1:3u")
- );
- assert!(rx.try_recv().is_err());
- }
-
- #[tokio::test]
- async fn route_client_input_does_not_forward_release_for_consumed_prefix() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) =
- TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_input(b"\x1b[98;5u\x1b[98;5:3u".to_vec());
-
- assert_eq!(app.state.mode, Mode::Prefix);
- assert!(rx.try_recv().is_err());
- }
-
- #[tokio::test]
- async fn route_client_input_preserves_shift_enter_for_modify_other_keys_pane() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24);
- runtime.test_process_pty_bytes(b"\x1b[>4;1m");
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_input(b"\x1b[13;2u".to_vec());
-
- assert_eq!(
- rx.recv().await.unwrap(),
- bytes::Bytes::from_static(b"\x1b[27;2;13~")
- );
- }
-
- #[tokio::test]
- async fn route_client_input_splits_multi_event_payloads_before_forwarding() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_input(b"ab".to_vec());
-
- assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"a"));
- assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"b"));
- assert!(rx.try_recv().is_err());
- }
-
- #[tokio::test]
- async fn route_client_input_forwards_multilingual_ime_text_to_focused_pane() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let text = "中日한🙂";
- let (runtime, mut rx) =
- TerminalRuntime::test_with_channel_capacity(80, 24, text.chars().count());
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_input(text.as_bytes().to_vec());
-
- let mut forwarded = Vec::new();
- for _ in text.chars() {
- let chunk = rx.recv().await.unwrap();
- forwarded.extend_from_slice(&chunk);
- }
- assert_eq!(forwarded, text.as_bytes());
- assert!(rx.try_recv().is_err());
- }
-
- #[tokio::test]
- async fn route_client_input_forwards_long_voice_like_cjk_text_without_truncation() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(64);
- let char_count = text.chars().count();
- let (runtime, mut rx) = TerminalRuntime::test_with_channel_capacity(80, 24, char_count);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_input(text.as_bytes().to_vec());
-
- let mut forwarded = Vec::new();
- for _ in 0..char_count {
- let chunk = rx.recv().await.unwrap();
- forwarded.extend_from_slice(&chunk);
- }
- assert_eq!(forwarded, text.as_bytes());
- assert!(rx.try_recv().is_err());
- }
-
- #[test]
- fn route_client_input_handles_mouse_events() {
- let mut app = test_app();
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
-
- // Send a mouse scroll-up event via SGR encoding.
- let mouse_bytes = b"\x1b[<64;10;5M".to_vec();
- // This should not panic even though mouse handling is simplified
- // in headless mode.
- app.route_client_input(mouse_bytes);
- // No assertions on specific behavior — just no panic.
- }
-
- #[test]
- fn route_client_input_advances_onboarding_modal() {
- let mut app = test_app();
- app.state.mode = Mode::Onboarding;
-
- app.route_client_input(b"\r".to_vec());
-
- assert_eq!(app.state.mode, Mode::Settings);
- assert_eq!(
- app.state.settings.section,
- state::SettingsSection::Integrations
- );
- }
-
- #[test]
- fn route_client_input_pastes_bracketed_text_into_rename_modal() {
- let mut app = test_app();
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::RenameTab;
- app.state.name_input = "2".into();
- app.state.name_input_replace_on_type = true;
-
- app.route_client_input(b"\x1b[200~feature/logs\x1b[201~".to_vec());
-
- assert_eq!(app.state.name_input, "feature/logs");
- assert!(!app.state.name_input_replace_on_type);
- }
-
- #[test]
- fn route_client_input_rename_enter_submits_through_api_path() {
- let mut app = test_app();
- app.state.workspaces = vec![Workspace::test_new("old")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::RenameWorkspace;
- app.state.name_input = "new".into();
-
- app.route_client_input(b"\r".to_vec());
-
- assert_eq!(app.state.workspaces[0].custom_name.as_deref(), Some("new"));
- assert!(app.event_hub.events_after(0).iter().any(|(_, event)| {
- matches!(event.event, crate::api::schema::EventKind::WorkspaceRenamed)
- }));
- }
-
- #[test]
- fn route_client_input_context_menu_enter_submits_through_api_path() {
- let mut app = test_app();
- app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.confirm_close = false;
- app.state.context_menu = Some(state::ContextMenuState {
- kind: state::ContextMenuKind::Workspace { ws_idx: 1 },
- x: 2,
- y: 2,
- list: state::MenuListState::new(1),
- });
- app.state.mode = Mode::ContextMenu;
-
- app.route_client_input(b"\r".to_vec());
-
- assert_eq!(app.state.workspaces.len(), 1);
- assert_eq!(app.state.workspaces[0].display_name(), "a");
- assert!(app.event_hub.events_after(0).iter().any(|(_, event)| {
- matches!(event.event, crate::api::schema::EventKind::WorkspaceClosed)
- }));
- }
-
- #[test]
- fn raw_ctrl_v_decodes_as_modal_paste_shortcut() {
- let events = crate::raw_input::parse_raw_input_bytes_sync(&[0x16]);
- let Some(crate::raw_input::RawInputEvent::Key(key)) = events.first() else {
- panic!("expected ctrl-v key event");
- };
-
- assert!(input::is_modal_paste_shortcut(&key.as_key_event()));
- }
-
- #[test]
- fn route_client_events_pastes_text_into_new_linked_worktree_modal() {
- let mut app = test_app();
- app.state.mode = Mode::NewLinkedWorktree;
- app.state.name_input = "generated-branch".into();
- app.state.name_input_replace_on_type = true;
- app.state.worktree_create = Some(state::WorktreeCreateState {
- source_workspace_id: "source".into(),
- source_checkout_path: "/repo/herdr".into(),
- source_existing_membership: None,
- source_repo_root: "/repo/herdr".into(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- branch: "generated-branch".into(),
- checkout_path: "/repo/herdr-generated-branch".into(),
- error: None,
- creating: false,
- });
-
- app.route_client_events(
- vec![crate::raw_input::RawInputEvent::Paste(
- "feature/linear-302".into(),
- )],
- true,
- );
-
- assert_eq!(app.state.name_input, "feature/linear-302");
- assert_eq!(
- app.state
- .worktree_create
- .as_ref()
- .map(|create| create.branch.as_str()),
- Some("feature/linear-302")
- );
- }
-
- #[tokio::test]
- async fn route_client_events_pastes_only_into_popup() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("tiled");
- let focused = workspace.focused_pane_id().unwrap();
- let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24);
- workspace.tabs[0].runtimes.insert(focused, tiled_runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel(40, 12);
- app.install_test_popup_runtime(popup_runtime);
- assert!(app
- .state
- .should_capture_host_mouse_from(&app.terminal_runtimes));
-
- app.route_client_events(
- vec![crate::raw_input::RawInputEvent::Paste("popup-only".into())],
- true,
- );
-
- assert_eq!(
- popup_rx.try_recv().unwrap(),
- bytes::Bytes::from_static(b"popup-only")
- );
- assert!(tiled_rx.try_recv().is_err());
-
- app.route_client_events(
- vec![raw_key(
- KeyCode::Char('x'),
- KeyModifiers::NONE,
- KeyEventKind::Press,
- )],
- true,
- );
- assert_eq!(
- popup_rx.try_recv().unwrap(),
- bytes::Bytes::from_static(b"x")
- );
- assert!(tiled_rx.try_recv().is_err());
-
- app.state.mode = Mode::Settings;
- assert!(
- app.handle_raw_input_event(raw_key(
- KeyCode::Char('y'),
- KeyModifiers::NONE,
- KeyEventKind::Repeat,
- ))
- .await
- );
- assert_eq!(
- popup_rx.try_recv().unwrap(),
- bytes::Bytes::from_static(b"y")
- );
- assert!(tiled_rx.try_recv().is_err());
- }
-
- #[tokio::test]
- async fn route_client_events_discards_paste_when_popup_runtime_is_missing() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("tiled");
- let focused = workspace.focused_pane_id().unwrap();
- let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24);
- workspace.tabs[0].runtimes.insert(focused, tiled_runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- let install_missing_popup = |app: &mut App| {
- let popup_terminal_id = crate::terminal::TerminalId::alloc();
- app.state.terminals.insert(
- popup_terminal_id.clone(),
- crate::terminal::TerminalState::new(
- popup_terminal_id.clone(),
- std::path::PathBuf::from("/popup"),
- ),
- );
- app.state.popup_pane = Some(state::PopupPaneState {
- pane_id: crate::layout::PaneId::alloc(),
- terminal_id: popup_terminal_id,
- width: None,
- height: None,
- });
- };
- install_missing_popup(&mut app);
-
- app.route_client_events(
- vec![crate::raw_input::RawInputEvent::Paste("discard-me".into())],
- true,
- );
-
- assert!(tiled_rx.try_recv().is_err());
- assert!(app.state.popup_pane.is_none());
-
- install_missing_popup(&mut app);
- assert!(
- app.handle_raw_input_event(crate::raw_input::RawInputEvent::Paste(
- "discard-monolithic".into(),
- ))
- .await
- );
- assert!(tiled_rx.try_recv().is_err());
- assert!(app.state.popup_pane.is_none());
- }
-
- #[tokio::test]
- async fn popup_mouse_motion_preserves_scrollback() {
- let mut app = test_app();
- app.state.mode = Mode::Terminal;
- app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 80, 24);
- let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel_and_scrollback_bytes(
- 40,
- 2,
- 1024,
- b"one\r\ntwo\r\nthree\r\n\x1b[?1003h\x1b[?1006h",
- 4,
- );
- popup_runtime.scroll_up(1);
- assert!(popup_runtime
- .scroll_metrics()
- .is_some_and(|metrics| metrics.offset_from_bottom > 0));
- app.install_test_popup_runtime(popup_runtime);
- let (_, inner) =
- crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area).unwrap();
-
- app.route_client_events(
- vec![crate::raw_input::RawInputEvent::Mouse(
- crossterm::event::MouseEvent {
- kind: crossterm::event::MouseEventKind::Moved,
- column: inner.x + 1,
- row: inner.y,
- modifiers: crossterm::event::KeyModifiers::NONE,
- },
- )],
- true,
- );
-
- assert!(popup_rx.try_recv().is_ok());
- assert!(app
- .popup_runtime()
- .and_then(TerminalRuntime::scroll_metrics)
- .is_some_and(|metrics| metrics.offset_from_bottom > 0));
- }
-
- #[tokio::test]
- async fn route_client_events_routes_popup_mouse_when_global_capture_is_disabled() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("tiled");
- let focused = workspace.focused_pane_id().unwrap();
- let (tiled_runtime, mut tiled_rx) = TerminalRuntime::test_with_channel(80, 24);
- tiled_runtime.test_process_pty_bytes(b"\x1b[?1000h\x1b[?1006h");
- workspace.tabs[0].runtimes.insert(focused, tiled_runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.mouse_capture = false;
- app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 80, 24);
-
- let (popup_runtime, mut popup_rx) = TerminalRuntime::test_with_channel(40, 12);
- popup_runtime.test_process_pty_bytes(b"\x1b[?1000h\x1b[?1006h");
- app.install_test_popup_runtime(popup_runtime);
- let (_, inner) =
- crate::ui::popup_pane_rects(&app.state, app.state.view.terminal_area).unwrap();
-
- app.route_client_events(
- vec![crate::raw_input::RawInputEvent::Mouse(
- crossterm::event::MouseEvent {
- kind: crossterm::event::MouseEventKind::Down(
- crossterm::event::MouseButton::Left,
- ),
- column: inner.x,
- row: inner.y,
- modifiers: crossterm::event::KeyModifiers::NONE,
- },
- )],
- true,
- );
-
- assert!(popup_rx.try_recv().is_ok());
- assert!(tiled_rx.try_recv().is_err());
-
- assert!(
- app.handle_raw_input_event(crate::raw_input::RawInputEvent::Mouse(
- crossterm::event::MouseEvent {
- kind: crossterm::event::MouseEventKind::Down(
- crossterm::event::MouseButton::Left,
- ),
- column: inner.x + 1,
- row: inner.y,
- modifiers: crossterm::event::KeyModifiers::NONE,
- },
- ))
- .await
- );
- assert!(popup_rx.try_recv().is_ok());
- assert!(tiled_rx.try_recv().is_err());
- }
-
- #[test]
- fn route_client_input_closes_release_notes_modal() {
- let mut app = test_app();
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::ReleaseNotes;
- app.state.release_notes = Some(release_notes_state());
-
- app.route_client_input(b"\x1b".to_vec());
-
- assert_eq!(app.state.mode, Mode::Terminal);
- assert!(app.state.release_notes.is_none());
- }
-
- #[test]
- fn route_client_input_closes_settings_modal() {
- let mut app = test_app();
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Settings;
- app.state.settings.original_theme = Some(app.state.theme_name.clone());
- app.state.settings.original_palette = Some(app.state.palette.clone());
-
- app.route_client_input(b"\x1b".to_vec());
-
- assert_eq!(app.state.mode, Mode::Terminal);
- }
-
- #[test]
- fn route_client_input_updates_host_terminal_theme_from_osc_response() {
- let mut app = test_app();
-
- app.route_client_input(b"\x1b]11;#123456\x07\x1b]4;7;rgb:aaaa/bbbb/cccc\x1b\\".to_vec());
-
- assert_eq!(
- app.state.host_terminal_theme.background,
- Some(crate::terminal_theme::RgbColor {
- r: 0x12,
- g: 0x34,
- b: 0x56,
- })
- );
- assert_eq!(
- app.state.host_terminal_theme.palette[7],
- Some(crate::terminal_theme::RgbColor {
- r: 0xaa,
- g: 0xbb,
- b: 0xcc,
- })
- );
-
- app.route_client_input(crate::raw_input::GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec());
- assert_eq!(
- app.state.host_terminal_theme.palette[7],
- Some(crate::terminal_theme::RgbColor {
- r: 0xaa,
- g: 0xbb,
- b: 0xcc,
- })
- );
-
- app.route_client_input(b"\x1b]4;7;rgb:dddd/eeee/ffff\x1b\\".to_vec());
- assert_eq!(
- app.state.host_terminal_theme.palette[7],
- Some(crate::terminal_theme::RgbColor {
- r: 0xdd,
- g: 0xee,
- b: 0xff,
- })
- );
- }
-
- #[tokio::test]
- async fn route_client_input_does_not_forward_incomplete_osc_introducer_to_pane() {
- let mut app = test_app();
- let mut workspace = Workspace::test_new("test");
- let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 1);
- workspace.tabs[0].runtimes.insert(focused, runtime);
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.route_client_input(b"\x1b]".to_vec());
-
- assert!(rx.try_recv().is_err());
- }
-
- #[test]
- fn parse_raw_input_bytes_with_ranges_tracks_offsets() {
- // Verify that the range-aware parser correctly tracks byte offsets
- // for events within a multi-event input buffer.
- let input = b"\x1b[Aa".to_vec(); // Up arrow + 'a'
- let events = crate::raw_input::parse_raw_input_bytes_with_ranges(&input);
-
- assert_eq!(events.len(), 2, "should parse Up arrow and 'a'");
- // Up arrow: \x1b[A = 3 bytes starting at offset 0
- assert_eq!(events[0].start, 0);
- assert_eq!(events[0].len, 3);
- // 'a': 1 byte starting at offset 3
- assert_eq!(events[1].start, 3);
- assert_eq!(events[1].len, 1);
-
- // Verify the raw bytes for each event are correct.
- assert_eq!(
- &input[events[0].start..events[0].start + events[0].len],
- b"\x1b[A"
- );
- assert_eq!(
- &input[events[1].start..events[1].start + events[1].len],
- b"a"
- );
- }
}