aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 22:03:49 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 22:08:26 +0300
commitdf357b250c0fcf4edf2681d4f114e199afb51899 (patch)
treeeba744d9238057e8758e2098670e0561d920d998 /src
parentc584037c77c63f6a08f8837f357c89432b8043e7 (diff)
fix: clarify keybinding conflict warnings
refs #154
Diffstat (limited to 'src')
-rw-r--r--src/app/input/modal.rs38
-rw-r--r--src/app/input/navigate.rs12
-rw-r--r--src/app/input/sidebar.rs8
-rw-r--r--src/app/mod.rs28
-rw-r--r--src/app/state.rs6
-rw-r--r--src/config/io.rs39
-rw-r--r--src/config/keybinds.rs15
-rw-r--r--src/config/model.rs7
-rw-r--r--src/main.rs3
-rw-r--r--src/ui/keybind_help.rs3
-rw-r--r--src/ui/menus.rs6
-rw-r--r--src/ui/status.rs41
12 files changed, 116 insertions, 90 deletions
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
index 5b585221..084f44a9 100644
--- a/src/app/input/modal.rs
+++ b/src/app/input/modal.rs
@@ -67,7 +67,7 @@ pub(super) fn modal_action_from_buttons<A: Copy>(
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GlobalMenuAction {
- Quit,
+ Detach,
WhatsNew,
Keybinds,
ReloadConfig,
@@ -83,7 +83,7 @@ pub(super) fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> {
if state.update_available.is_some() || state.latest_release_notes_available {
actions.push(GlobalMenuAction::WhatsNew);
}
- actions.push(GlobalMenuAction::Quit);
+ actions.push(GlobalMenuAction::Detach);
actions
}
@@ -111,19 +111,19 @@ fn open_update_release_notes(state: &mut AppState) {
state.mode = Mode::ReleaseNotes;
}
-pub(super) fn request_quit_or_detach(state: &mut AppState) {
- if state.quit_detaches {
- state.detach_requested = true;
- } else {
+pub(super) fn request_detach(state: &mut AppState) {
+ if state.detach_exits {
state.should_quit = true;
+ } else {
+ state.detach_requested = true;
}
}
pub(super) fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) {
match action {
- GlobalMenuAction::Quit => {
+ GlobalMenuAction::Detach => {
leave_modal(state);
- request_quit_or_detach(state);
+ request_detach(state);
}
GlobalMenuAction::WhatsNew => open_update_release_notes(state),
GlobalMenuAction::Keybinds => open_keybind_help(state),
@@ -690,6 +690,28 @@ mod tests {
}
#[test]
+ fn detach_requests_client_detach_in_persistence_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.detach_exits = false;
+
+ request_detach(&mut state);
+
+ assert!(state.detach_requested);
+ assert!(!state.should_quit);
+ }
+
+ #[test]
+ fn detach_exits_in_no_session_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.detach_exits = true;
+
+ request_detach(&mut state);
+
+ assert!(state.should_quit);
+ assert!(!state.detach_requested);
+ }
+
+ #[test]
fn global_menu_whats_new_opens_saved_release_notes() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("whats-new-saved-release-notes");
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 8bf10832..17180fed 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -479,7 +479,6 @@ pub(crate) enum NavigateAction {
CyclePanePrevious,
Help,
Settings,
- Quit,
ReloadConfig,
OpenNotificationTarget,
Detach,
@@ -545,7 +544,6 @@ fn action_for_key(
for (bindings, action) in [
(&kb.help, NavigateAction::Help),
(&kb.settings, NavigateAction::Settings),
- (&kb.quit, NavigateAction::Quit),
(&kb.workspace_picker, NavigateAction::WorkspacePicker),
(&kb.new_workspace, NavigateAction::NewWorkspace),
(&kb.rename_workspace, NavigateAction::RenameWorkspace),
@@ -727,10 +725,6 @@ pub(super) fn execute_navigate_action_in_context(
}
NavigateAction::Help => super::modal::open_keybind_help(state),
NavigateAction::Settings => super::settings::open_settings(state),
- NavigateAction::Quit => {
- super::modal::request_quit_or_detach(state);
- leave_navigate_mode(state);
- }
NavigateAction::ReloadConfig => {
state.request_reload_config = true;
leave_navigate_mode(state);
@@ -742,7 +736,7 @@ pub(super) fn execute_navigate_action_in_context(
}
}
NavigateAction::Detach => {
- state.detach_requested = true;
+ super::modal::request_detach(state);
leave_navigate_mode(state);
}
}
@@ -1449,9 +1443,9 @@ mod tests {
}
#[test]
- fn persistence_mode_navigate_q_detaches_instead_of_quitting_server() {
+ fn navigate_q_detaches_in_persistence_mode() {
let mut state = crate::app::state::AppState::test_new();
- state.quit_detaches = true;
+ state.detach_exits = false;
handle_navigate_key(
&mut state,
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
index 7491c9c1..dc192e50 100644
--- a/src/app/input/sidebar.rs
+++ b/src/app/input/sidebar.rs
@@ -192,7 +192,7 @@ impl AppState {
} else if self.latest_release_notes_available {
labels.push("what's new");
}
- labels.push(if self.quit_detaches { "detach" } else { "quit" });
+ labels.push("detach");
labels
}
@@ -578,7 +578,7 @@ mod tests {
"keybinds",
"reload config",
"update ready",
- "quit"
+ "detach"
]
);
assert!(!app.state.should_quit);
@@ -587,7 +587,7 @@ mod tests {
#[test]
fn persistence_mode_menu_surfaces_detach_action() {
let mut app = app_for_mouse_test();
- app.state.quit_detaches = true;
+ app.state.detach_exits = false;
let launcher = app.state.global_launcher_rect();
app.handle_mouse(mouse(
@@ -625,7 +625,7 @@ mod tests {
"keybinds",
"reload config",
"what's new",
- "quit"
+ "detach"
]
);
}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 63bf690d..b87eec66 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -338,7 +338,7 @@ impl App {
selected,
mode,
should_quit: false,
- quit_detaches: !no_session,
+ detach_exits: no_session,
detach_requested: false,
request_new_workspace: false,
request_new_tab: false,
@@ -466,10 +466,7 @@ impl App {
});
Self {
- config_diagnostic_deadline: state
- .config_diagnostic
- .as_ref()
- .map(|_| Instant::now() + Duration::from_secs(8)),
+ config_diagnostic_deadline: None,
toast_deadline: None,
state,
event_tx,
@@ -797,7 +794,7 @@ impl App {
self.state.toast = None;
self.state.config_diagnostic =
crate::config::config_diagnostic_summary(&diagnostics);
- self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8));
+ self.config_diagnostic_deadline = None;
crate::config::ConfigReloadReport {
status: crate::config::ConfigReloadStatus::Failed,
diagnostics,
@@ -827,11 +824,11 @@ impl App {
self.state.keybinds = live.keybinds;
}
Err(keybind_diagnostics) => {
- let mut message = keybind_diagnostics.join("; ");
- if !message.contains("keeping current keybinds") {
- message.push_str("; keeping current keybinds");
- }
- diagnostics.push(message);
+ diagnostics.extend(
+ keybind_diagnostics
+ .into_iter()
+ .map(|diagnostic| format!("{diagnostic}; kept current keybinds")),
+ );
}
}
}
@@ -928,7 +925,7 @@ impl App {
}
} else {
self.state.config_diagnostic = crate::config::config_diagnostic_summary(&diagnostics);
- self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8));
+ self.config_diagnostic_deadline = None;
if notify_success {
self.state.toast = Some(crate::app::state::ToastNotification {
kind: crate::app::state::ToastKind::UpdateInstalled,
@@ -1545,8 +1542,7 @@ mod tests {
.config_diagnostic
.as_deref()
.is_some_and(|message| {
- message.contains("keys.new_workspace")
- && message.contains("keeping current keybinds")
+ message.contains("keys.new_workspace") && message.contains("kept current keybinds")
}));
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
@@ -2514,7 +2510,7 @@ mod tests {
app.state.workspaces = vec![Workspace::test_new("test")];
app.state.active = Some(0);
app.state.selected = 0;
- app.state.quit_detaches = true;
+ app.state.detach_exits = false;
// Start in navigate mode.
app.state.mode = Mode::Navigate;
@@ -2540,7 +2536,7 @@ mod tests {
app.state.workspaces = vec![Workspace::test_new("test")];
app.state.active = Some(0);
app.state.selected = 0;
- app.state.quit_detaches = true;
+ app.state.detach_exits = false;
// Start in terminal mode (default after workspace creation).
app.state.mode = Mode::Terminal;
diff --git a/src/app/state.rs b/src/app/state.rs
index 921a82c9..7ee79c4a 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -900,8 +900,8 @@ pub struct AppState {
pub selected: usize,
pub mode: Mode,
pub should_quit: bool,
- /// In persistence mode, client quit actions detach instead of stopping the server.
- pub quit_detaches: bool,
+ /// In monolithic --no-session mode, detach exits the app because there is no server to detach from.
+ pub detach_exits: bool,
/// Set when the current client should detach from the persistent session.
/// The server's event loop checks this and handles client detach.
pub detach_requested: bool,
@@ -1159,7 +1159,7 @@ impl AppState {
selected: 0,
mode: Mode::Navigate,
should_quit: false,
- quit_detaches: false,
+ detach_exits: false,
detach_requested: false,
request_new_workspace: false,
request_new_tab: false,
diff --git a/src/config/io.rs b/src/config/io.rs
index 12176815..3b483ba6 100644
--- a/src/config/io.rs
+++ b/src/config/io.rs
@@ -92,17 +92,22 @@ pub fn config_path() -> PathBuf {
}
pub fn config_diagnostic_summary(diagnostics: &[String]) -> Option<String> {
+ const MAX_VISIBLE_DIAGNOSTICS: usize = 4;
+
if diagnostics.is_empty() {
- None
- } else if diagnostics.len() == 1 {
- Some(diagnostics[0].clone())
- } else {
- Some(format!(
- "{} (and {} more)",
- diagnostics[0],
- diagnostics.len() - 1
- ))
+ return None;
+ }
+
+ let mut lines: Vec<String> = diagnostics
+ .iter()
+ .take(MAX_VISIBLE_DIAGNOSTICS)
+ .map(|diagnostic| diagnostic.split_whitespace().collect::<Vec<_>>().join(" "))
+ .collect();
+ let hidden = diagnostics.len().saturating_sub(MAX_VISIBLE_DIAGNOSTICS);
+ if hidden > 0 {
+ lines.push(format!("and {hidden} more config warnings"));
}
+ Some(lines.join("\n"))
}
pub fn load_live_config() -> Result<LoadedConfig, Vec<String>> {
@@ -435,6 +440,22 @@ mod tests {
}
#[test]
+ fn config_diagnostic_summary_keeps_multiple_warnings_visible() {
+ let diagnostics = vec![
+ "one".to_string(),
+ "two".to_string(),
+ "three".to_string(),
+ "four".to_string(),
+ "five".to_string(),
+ ];
+
+ assert_eq!(
+ config_diagnostic_summary(&diagnostics).as_deref(),
+ Some("one\ntwo\nthree\nfour\nand 1 more config warnings")
+ );
+ }
+
+ #[test]
fn remove_keybinding_config_sections_removes_keys_tables_only() {
let content = r#"onboarding = false
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs
index 58c26020..3a4cf754 100644
--- a/src/config/keybinds.rs
+++ b/src/config/keybinds.rs
@@ -245,7 +245,6 @@ pub struct CustomCommandKeybind {
pub struct Keybinds {
pub help: ActionKeybinds,
pub settings: ActionKeybinds,
- pub quit: ActionKeybinds,
pub new_workspace: ActionKeybinds,
pub rename_workspace: ActionKeybinds,
pub close_workspace: ActionKeybinds,
@@ -375,7 +374,6 @@ impl Config {
let mut keybinds = Keybinds {
help: action!("keys.help", &self.keys.help),
settings: action!("keys.settings", &self.keys.settings),
- quit: action!("keys.quit", &self.keys.quit),
new_workspace: action!("keys.new_workspace", &self.keys.new_workspace),
rename_workspace: action!("keys.rename_workspace", &self.keys.rename_workspace),
close_workspace: action!("keys.close_workspace", &self.keys.close_workspace),
@@ -619,10 +617,7 @@ fn reject_binding(
}
if let Some(first_field) = registry.conflict(binding) {
- let diag = format!(
- "duplicate keybinding: {field} = {:?} conflicts with {}; disabling binding",
- binding.label, first_field
- );
+ let diag = format!("{}: kept {first_field}, disabled {field}", binding.label);
warn!(message = %diag, "config diagnostic");
diagnostics.push(diag);
return true;
@@ -1232,7 +1227,7 @@ command = "echo no"
assert!(!keybinds.new_tab.bindings.is_empty());
assert!(keybinds.custom_commands.is_empty());
assert!(diagnostics.iter().any(|diag| {
- diag.contains("duplicate keybinding") && diag.contains("keys.command[0].key")
+ diag.contains("kept keys.new_tab") && diag.contains("disabled keys.command[0].key")
}));
}
@@ -1296,8 +1291,8 @@ new_workspace = "prefix+n"
let diagnostics = config.collect_diagnostics();
let kb = config.keybinds();
assert!(kb.next_tab.bindings.is_empty() || kb.new_workspace.bindings.is_empty());
- assert!(diagnostics
- .iter()
- .any(|diag| diag.contains("duplicate keybinding")));
+ assert!(diagnostics.iter().any(|diag| {
+ diag.contains("kept keys.new_workspace") && diag.contains("disabled keys.next_tab")
+ }));
}
}
diff --git a/src/config/model.rs b/src/config/model.rs
index 10e80417..f0557072 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -98,8 +98,6 @@ pub struct KeysConfig {
pub help: BindingConfig,
/// Open settings. Default: "prefix+s"
pub settings: BindingConfig,
- /// Quit or detach. Default: "prefix+q"
- pub quit: BindingConfig,
/// Create a new workspace. Default: "prefix+shift+n"
pub new_workspace: BindingConfig,
/// Rename the selected workspace. Default: "prefix+shift+w"
@@ -108,7 +106,7 @@ pub struct KeysConfig {
pub close_workspace: BindingConfig,
/// Open the workspace navigation surface. Default: "prefix+w"
pub workspace_picker: BindingConfig,
- /// Optional explicit detach shortcut in server/client mode. Default: "prefix+d".
+ /// Detach from server/client mode, or exit --no-session mode. Default: "prefix+q".
pub detach: BindingConfig,
/// Reload config.toml in the running app/server. Default: "prefix+shift+r".
pub reload_config: BindingConfig,
@@ -234,12 +232,11 @@ impl Default for KeysConfig {
prefix: "ctrl+b".into(),
help: BindingConfig::one("prefix+?"),
settings: BindingConfig::one("prefix+s"),
- quit: BindingConfig::one("prefix+q"),
new_workspace: BindingConfig::one("prefix+shift+n"),
rename_workspace: BindingConfig::one("prefix+shift+w"),
close_workspace: BindingConfig::one("prefix+shift+d"),
workspace_picker: BindingConfig::one("prefix+w"),
- detach: BindingConfig::one("prefix+d"),
+ detach: BindingConfig::one("prefix+q"),
reload_config: BindingConfig::one("prefix+shift+r"),
open_notification_target: BindingConfig::one("prefix+o"),
previous_workspace: BindingConfig::empty(),
diff --git a/src/main.rs b/src/main.rs
index 2120555e..bbf04df2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -94,8 +94,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Prefix-mode actions
# help = "prefix+?"
# settings = "prefix+s"
-# quit = "prefix+q"
-# detach = "prefix+d"
+# detach = "prefix+q"
# reload_config = "prefix+shift+r"
# open_notification_target = "prefix+o"
# workspace_picker = "prefix+w"
diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs
index fe3d7582..fb79077e 100644
--- a/src/ui/keybind_help.rs
+++ b/src/ui/keybind_help.rs
@@ -56,8 +56,7 @@ pub(super) fn keybind_help_groups(
),
(keybind_label(&kb.help), "keybinds"),
(keybind_label(&kb.settings), "settings"),
- (keybind_label(&kb.quit), "quit"),
- (keybind_label(&kb.detach), "detach from server"),
+ (keybind_label(&kb.detach), "detach"),
(keybind_label(&kb.reload_config), "reload config"),
(
keybind_label(&kb.open_notification_target),
diff --git a/src/ui/menus.rs b/src/ui/menus.rs
index aa5e3b79..c83b569f 100644
--- a/src/ui/menus.rs
+++ b/src/ui/menus.rs
@@ -76,7 +76,7 @@ pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: R
let resize = prefix_rhs_label(&kb.resize_mode);
let help = prefix_rhs_label(&kb.help);
let settings = prefix_rhs_label(&kb.settings);
- let quit = prefix_rhs_label(&kb.quit);
+ let detach = prefix_rhs_label(&kb.detach);
let line = Line::from(vec![
Span::styled(" NAVIGATE ", mode_style),
Span::raw(" "),
@@ -102,8 +102,8 @@ pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: R
Span::styled(" keybinds ", dim),
Span::styled(settings, key),
Span::styled(" settings ", dim),
- Span::styled(quit, key),
- Span::styled(" quit", dim),
+ Span::styled(detach, key),
+ Span::styled(" detach", dim),
]);
let overlay_y = area.y + area.height.saturating_sub(1);
diff --git a/src/ui/status.rs b/src/ui/status.rs
index bb506da6..28f5d17b 100644
--- a/src/ui/status.rs
+++ b/src/ui/status.rs
@@ -75,26 +75,29 @@ pub(super) fn render_toast_notification(
}
pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) {
- let text = format!(" config warning: {message} ");
- let width = text.len() as u16 + 2;
- let notif_area = Rect::new(
- area.x + area.width.saturating_sub(width.min(area.width)),
- area.y,
- width.min(area.width),
- 1,
- );
+ let style = Style::default()
+ .fg(panel_contrast_fg(p))
+ .bg(p.yellow)
+ .add_modifier(Modifier::BOLD);
- frame.render_widget(Clear, notif_area);
- frame.render_widget(
- Paragraph::new(Span::styled(
- text,
- Style::default()
- .fg(panel_contrast_fg(p))
- .bg(p.yellow)
- .add_modifier(Modifier::BOLD),
- )),
- notif_area,
- );
+ for (row, line) in message
+ .lines()
+ .filter(|line| !line.trim().is_empty())
+ .take(area.height as usize)
+ .enumerate()
+ {
+ let text = format!(" config warning: {line} ");
+ let width = (text.len() as u16).min(area.width);
+ let notif_area = Rect::new(
+ area.x + area.width.saturating_sub(width),
+ area.y + row as u16,
+ width,
+ 1,
+ );
+
+ frame.render_widget(Clear, notif_area);
+ frame.render_widget(Paragraph::new(Span::styled(text, style)), notif_area);
+ }
}
pub(super) fn state_dot(state: AgentState, seen: bool, p: &Palette) -> (&'static str, Style) {