mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
feat: add per-agent sound overrides
This commit is contained in:
@@ -165,9 +165,16 @@ accent = "cyan"
|
||||
# ask for confirmation before closing a workspace
|
||||
confirm_close = true
|
||||
|
||||
[ui.sound]
|
||||
# play sounds when agents change state in background workspaces
|
||||
# a chime when an agent finishes, an alert when one needs input
|
||||
sound = true
|
||||
enabled = true
|
||||
|
||||
[ui.sound.agents]
|
||||
# per-agent override: default | on | off
|
||||
# droid is muted by default
|
||||
claude = "default"
|
||||
droid = "off"
|
||||
```
|
||||
|
||||
### environment variables
|
||||
|
||||
+8
-3
@@ -62,7 +62,8 @@ impl AppState {
|
||||
pub fn resize_pane(&mut self, direction: NavDirection) {
|
||||
if let Some(first) = self.view.pane_infos.first() {
|
||||
let area = self
|
||||
.view.pane_infos
|
||||
.view
|
||||
.pane_infos
|
||||
.iter()
|
||||
.fold(first.rect, |acc, p| acc.union(p.rect));
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
@@ -168,7 +169,7 @@ impl AppState {
|
||||
}
|
||||
|
||||
// Sound notifications for background state changes
|
||||
if self.sound && !is_active_ws && state != prev_state {
|
||||
if self.sound.allows(agent) && !is_active_ws && state != prev_state {
|
||||
match state {
|
||||
AgentState::Idle if prev_state != AgentState::Idle => {
|
||||
crate::sound::play(crate::sound::Sound::Done);
|
||||
@@ -390,7 +391,11 @@ mod tests {
|
||||
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
|
||||
|
||||
// First set it to Busy
|
||||
state.workspaces[1].panes.get_mut(&bg_pane_id).unwrap().state = AgentState::Busy;
|
||||
state.workspaces[1]
|
||||
.panes
|
||||
.get_mut(&bg_pane_id)
|
||||
.unwrap()
|
||||
.state = AgentState::Busy;
|
||||
|
||||
// Now transition to Idle while in background
|
||||
state.handle_app_event(AppEvent::StateChanged {
|
||||
|
||||
+2
-10
@@ -373,12 +373,7 @@ impl AppState {
|
||||
mouse.row - info.inner_rect.y,
|
||||
mouse.column - info.inner_rect.x,
|
||||
);
|
||||
self.selection = Some(Selection::anchor(
|
||||
info.id,
|
||||
row,
|
||||
col,
|
||||
info.inner_rect,
|
||||
));
|
||||
self.selection = Some(Selection::anchor(info.id, row, col, info.inner_rect));
|
||||
|
||||
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
|
||||
if ws.layout.focused() != info.id {
|
||||
@@ -434,10 +429,7 @@ impl AppState {
|
||||
if self.drag.take().is_some() {
|
||||
// Drag ended
|
||||
} else {
|
||||
let was_click = self
|
||||
.selection
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.was_just_click());
|
||||
let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click());
|
||||
if was_click {
|
||||
self.selection = None;
|
||||
} else {
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ impl App {
|
||||
sidebar_collapsed: false,
|
||||
confirm_close: config.ui.confirm_close,
|
||||
accent: crate::config::parse_color(&config.ui.accent),
|
||||
sound: config.ui.sound,
|
||||
sound: config.ui.sound.clone(),
|
||||
keybinds: config.keybinds(),
|
||||
};
|
||||
|
||||
|
||||
+6
-3
@@ -1,5 +1,5 @@
|
||||
use crate::config::{Keybinds, SoundConfig};
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use crate::config::Keybinds;
|
||||
use ratatui::layout::{Direction, Rect};
|
||||
use ratatui::style::Color;
|
||||
|
||||
@@ -69,7 +69,7 @@ pub struct AppState {
|
||||
pub sidebar_collapsed: bool,
|
||||
pub confirm_close: bool,
|
||||
pub accent: Color,
|
||||
pub sound: bool,
|
||||
pub sound: SoundConfig,
|
||||
pub keybinds: Keybinds,
|
||||
}
|
||||
|
||||
@@ -120,7 +120,10 @@ impl AppState {
|
||||
sidebar_collapsed: false,
|
||||
confirm_close: true,
|
||||
accent: Color::Cyan,
|
||||
sound: true,
|
||||
sound: SoundConfig {
|
||||
enabled: false,
|
||||
..SoundConfig::default()
|
||||
},
|
||||
keybinds: Keybinds {
|
||||
split_vertical: (KeyCode::Char('v'), KeyModifiers::empty()),
|
||||
split_horizontal: (KeyCode::Char('-'), KeyModifiers::empty()),
|
||||
|
||||
+141
-10
@@ -4,6 +4,8 @@ use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::detect::Agent;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
@@ -36,7 +38,68 @@ pub struct UiConfig {
|
||||
/// Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250)).
|
||||
pub accent: String,
|
||||
/// Play sounds when agents change state in background workspaces.
|
||||
pub sound: bool,
|
||||
pub sound: SoundConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SoundConfig {
|
||||
pub enabled: bool,
|
||||
pub agents: AgentSoundOverrides,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AgentSoundOverrides {
|
||||
pub pi: AgentSoundSetting,
|
||||
pub claude: AgentSoundSetting,
|
||||
pub codex: AgentSoundSetting,
|
||||
pub gemini: AgentSoundSetting,
|
||||
pub cursor: AgentSoundSetting,
|
||||
pub cline: AgentSoundSetting,
|
||||
pub open_code: AgentSoundSetting,
|
||||
pub github_copilot: AgentSoundSetting,
|
||||
pub kimi: AgentSoundSetting,
|
||||
pub droid: AgentSoundSetting,
|
||||
pub amp: AgentSoundSetting,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentSoundSetting {
|
||||
#[default]
|
||||
Default,
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
|
||||
impl SoundConfig {
|
||||
pub fn allows(&self, agent: Option<Agent>) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
!matches!(self.agents.for_agent(agent), AgentSoundSetting::Off)
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentSoundOverrides {
|
||||
pub fn for_agent(&self, agent: Option<Agent>) -> AgentSoundSetting {
|
||||
match agent {
|
||||
Some(Agent::Pi) => self.pi,
|
||||
Some(Agent::Claude) => self.claude,
|
||||
Some(Agent::Codex) => self.codex,
|
||||
Some(Agent::Gemini) => self.gemini,
|
||||
Some(Agent::Cursor) => self.cursor,
|
||||
Some(Agent::Cline) => self.cline,
|
||||
Some(Agent::OpenCode) => self.open_code,
|
||||
Some(Agent::GithubCopilot) => self.github_copilot,
|
||||
Some(Agent::Kimi) => self.kimi,
|
||||
Some(Agent::Droid) => self.droid,
|
||||
Some(Agent::Amp) => self.amp,
|
||||
None => AgentSoundSetting::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for KeysConfig {
|
||||
@@ -57,7 +120,34 @@ impl Default for UiConfig {
|
||||
sidebar_width: 26,
|
||||
confirm_close: true,
|
||||
accent: "cyan".into(),
|
||||
sound: true,
|
||||
sound: SoundConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SoundConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
agents: AgentSoundOverrides::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AgentSoundOverrides {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pi: AgentSoundSetting::Default,
|
||||
claude: AgentSoundSetting::Default,
|
||||
codex: AgentSoundSetting::Default,
|
||||
gemini: AgentSoundSetting::Default,
|
||||
cursor: AgentSoundSetting::Default,
|
||||
cline: AgentSoundSetting::Default,
|
||||
open_code: AgentSoundSetting::Default,
|
||||
github_copilot: AgentSoundSetting::Default,
|
||||
kimi: AgentSoundSetting::Default,
|
||||
droid: AgentSoundSetting::Off,
|
||||
amp: AgentSoundSetting::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,32 +310,53 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_simple_char() {
|
||||
assert_eq!(parse_key_combo("v"), (KeyCode::Char('v'), KeyModifiers::empty()));
|
||||
assert_eq!(
|
||||
parse_key_combo("v"),
|
||||
(KeyCode::Char('v'), KeyModifiers::empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ctrl_combo() {
|
||||
assert_eq!(parse_key_combo("ctrl+s"), (KeyCode::Char('s'), KeyModifiers::CONTROL));
|
||||
assert_eq!(
|
||||
parse_key_combo("ctrl+s"),
|
||||
(KeyCode::Char('s'), KeyModifiers::CONTROL)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_special_key() {
|
||||
assert_eq!(parse_key_combo("enter"), (KeyCode::Enter, KeyModifiers::empty()));
|
||||
assert_eq!(parse_key_combo("tab"), (KeyCode::Tab, KeyModifiers::empty()));
|
||||
assert_eq!(parse_key_combo("esc"), (KeyCode::Esc, KeyModifiers::empty()));
|
||||
assert_eq!(
|
||||
parse_key_combo("enter"),
|
||||
(KeyCode::Enter, KeyModifiers::empty())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_key_combo("tab"),
|
||||
(KeyCode::Tab, KeyModifiers::empty())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_key_combo("esc"),
|
||||
(KeyCode::Esc, KeyModifiers::empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ctrl_shift() {
|
||||
assert_eq!(
|
||||
parse_key_combo("ctrl+shift+a"),
|
||||
(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::SHIFT)
|
||||
(
|
||||
KeyCode::Char('a'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_f_key() {
|
||||
assert_eq!(parse_key_combo("f5"), (KeyCode::F(5), KeyModifiers::empty()));
|
||||
assert_eq!(
|
||||
parse_key_combo("f5"),
|
||||
(KeyCode::F(5), KeyModifiers::empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -275,8 +386,28 @@ fullscreen = "z"
|
||||
|
||||
let kb = config.keybinds();
|
||||
assert_eq!(kb.split_vertical.0, KeyCode::Char('s'));
|
||||
assert_eq!(kb.split_horizontal, (KeyCode::Char('s'), KeyModifiers::SHIFT));
|
||||
assert_eq!(
|
||||
kb.split_horizontal,
|
||||
(KeyCode::Char('s'), KeyModifiers::SHIFT)
|
||||
);
|
||||
assert_eq!(kb.close_pane, (KeyCode::Char('w'), KeyModifiers::CONTROL));
|
||||
assert_eq!(kb.fullscreen.0, KeyCode::Char('z'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_table_config_parses() {
|
||||
let toml = r#"
|
||||
[ui.sound]
|
||||
enabled = true
|
||||
|
||||
[ui.sound.agents]
|
||||
droid = "off"
|
||||
claude = "on"
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
assert!(config.ui.sound.enabled);
|
||||
assert_eq!(config.ui.sound.agents.droid, AgentSoundSetting::Off);
|
||||
assert_eq!(config.ui.sound.agents.claude, AgentSoundSetting::On);
|
||||
assert_eq!(config.ui.sound.agents.pi, AgentSoundSetting::Default);
|
||||
}
|
||||
}
|
||||
|
||||
+45
-42
@@ -269,9 +269,7 @@ fn detect_cline(content: &str) -> AgentState {
|
||||
return AgentState::Waiting;
|
||||
}
|
||||
// [act mode] or [plan mode] followed by "yes"
|
||||
if (lower.contains("[act mode]") || lower.contains("[plan mode]"))
|
||||
&& lower.contains("yes")
|
||||
{
|
||||
if (lower.contains("[act mode]") || lower.contains("[plan mode]")) && lower.contains("yes") {
|
||||
return AgentState::Waiting;
|
||||
}
|
||||
|
||||
@@ -440,9 +438,7 @@ fn has_selection_prompt(content: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('❯') {
|
||||
// Check if there's a digit followed by a dot nearby
|
||||
if trimmed.chars().any(|c| c.is_ascii_digit())
|
||||
&& trimmed.contains('.')
|
||||
{
|
||||
if trimmed.chars().any(|c| c.is_ascii_digit()) && trimmed.contains('.') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -481,9 +477,7 @@ fn has_spinner_activity(content: &str) -> bool {
|
||||
fn has_cursor_spinner(content: &str) -> bool {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if (trimmed.starts_with('⬡') || trimmed.starts_with('⬢'))
|
||||
&& trimmed.contains("ing")
|
||||
{
|
||||
if (trimmed.starts_with('⬡') || trimmed.starts_with('⬢')) && trimmed.contains("ing") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -525,7 +519,11 @@ pub fn foreground_job(child_pid: u32) -> Option<crate::platform::ForegroundJob>
|
||||
fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> String {
|
||||
let effective = process.argv0.as_deref().unwrap_or(&process.name);
|
||||
let lower_effective = effective.to_lowercase();
|
||||
let lower_cmdline = process.cmdline.as_deref().unwrap_or_default().to_lowercase();
|
||||
let lower_cmdline = process
|
||||
.cmdline
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
|
||||
if lower_effective == "node"
|
||||
&& (lower_cmdline.contains("/codex") || lower_cmdline.contains("@openai/codex"))
|
||||
@@ -613,7 +611,10 @@ mod tests {
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(identify_agent_in_job(&job), Some((Agent::Codex, "codex".to_string())));
|
||||
assert_eq!(
|
||||
identify_agent_in_job(&job),
|
||||
Some((Agent::Codex, "codex".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Workspace state rollup ----
|
||||
@@ -651,10 +652,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pi_busy_working_in_middle() {
|
||||
assert_eq!(
|
||||
detect_pi("line1\nWorking...\nline3"),
|
||||
AgentState::Busy
|
||||
);
|
||||
assert_eq!(detect_pi("line1\nWorking...\nline3"), AgentState::Busy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -664,10 +662,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pi_idle_no_working_text() {
|
||||
assert_eq!(
|
||||
detect_pi("some output\n\n> ready"),
|
||||
AgentState::Idle
|
||||
);
|
||||
assert_eq!(detect_pi("some output\n\n> ready"), AgentState::Idle);
|
||||
}
|
||||
|
||||
// ---- Claude Code ----
|
||||
@@ -751,10 +746,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn codex_waiting_allow_command() {
|
||||
assert_eq!(
|
||||
detect_codex("allow command?\n[y/n]"),
|
||||
AgentState::Waiting
|
||||
);
|
||||
assert_eq!(detect_codex("allow command?\n[y/n]"), AgentState::Waiting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -829,18 +821,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cursor_waiting_allow() {
|
||||
assert_eq!(
|
||||
detect_cursor("allow file edit (y)"),
|
||||
AgentState::Waiting
|
||||
);
|
||||
assert_eq!(detect_cursor("allow file edit (y)"), AgentState::Waiting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_busy_spinner() {
|
||||
assert_eq!(
|
||||
detect_cursor("⬡ Grepping.."),
|
||||
AgentState::Busy
|
||||
);
|
||||
assert_eq!(detect_cursor("⬡ Grepping.."), AgentState::Busy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -860,10 +846,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cline_waiting_tool_use() {
|
||||
assert_eq!(
|
||||
detect_cline("let cline use this tool"),
|
||||
AgentState::Waiting
|
||||
);
|
||||
assert_eq!(detect_cline("let cline use this tool"), AgentState::Waiting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1014,13 +997,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn droid_idle_prompt() {
|
||||
let screen = "╭──────────────────╮\n│ > Try something │\n╰──────────────────╯\n? for help";
|
||||
let screen =
|
||||
"╭──────────────────╮\n│ > Try something │\n╰──────────────────╯\n? for help";
|
||||
assert_eq!(detect_droid(screen), AgentState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn droid_idle_after_response() {
|
||||
let screen = "⛬ Doing well, thanks!\n\nAuto (Off)\n╭──────────╮\n│ > │\n╰──────────╯";
|
||||
let screen =
|
||||
"⛬ Doing well, thanks!\n\nAuto (Off)\n╭──────────╮\n│ > │\n╰──────────╯";
|
||||
assert_eq!(detect_droid(screen), AgentState::Idle);
|
||||
}
|
||||
|
||||
@@ -1134,8 +1119,15 @@ mod tests {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
let job = foreground_job(pid).expect("expected foreground job");
|
||||
assert!(job.processes.iter().any(|p| p.name == "sleep"), "expected sleep in {job:?}");
|
||||
assert_eq!(identify_agent_in_job(&job), None, "sleep should not map to an agent");
|
||||
assert!(
|
||||
job.processes.iter().any(|p| p.name == "sleep"),
|
||||
"expected sleep in {job:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
identify_agent_in_job(&job),
|
||||
None,
|
||||
"sleep should not map to an agent"
|
||||
);
|
||||
|
||||
// Clean up
|
||||
child.kill().ok();
|
||||
@@ -1172,8 +1164,15 @@ mod tests {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
let job = foreground_job(pid).expect("expected foreground job");
|
||||
assert!(job.processes.iter().any(|p| p.name == "sleep"), "expected sleep in {job:?}");
|
||||
assert_eq!(identify_agent_in_job(&job), None, "sleep should not map to an agent");
|
||||
assert!(
|
||||
job.processes.iter().any(|p| p.name == "sleep"),
|
||||
"expected sleep in {job:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
identify_agent_in_job(&job),
|
||||
None,
|
||||
"sleep should not map to an agent"
|
||||
);
|
||||
|
||||
child.kill().ok();
|
||||
child.wait().ok();
|
||||
@@ -1193,7 +1192,11 @@ mod tests {
|
||||
let fields: Vec<&str> = rest.split_whitespace().collect();
|
||||
|
||||
// We should have enough fields (at least 6 for tpgid)
|
||||
assert!(fields.len() >= 6, "not enough fields in stat: {}", fields.len());
|
||||
assert!(
|
||||
fields.len() >= 6,
|
||||
"not enough fields in stat: {}",
|
||||
fields.len()
|
||||
);
|
||||
|
||||
// Field 0 should be a valid state char (S, R, D, etc.)
|
||||
let state = fields[0];
|
||||
|
||||
+2
-6
@@ -10,9 +10,7 @@ use crate::layout::PaneId;
|
||||
#[derive(Debug)]
|
||||
pub enum AppEvent {
|
||||
/// A pane's child process exited.
|
||||
PaneDied {
|
||||
pane_id: PaneId,
|
||||
},
|
||||
PaneDied { pane_id: PaneId },
|
||||
/// Agent state changed in a pane (detected by the PTY reader).
|
||||
StateChanged {
|
||||
pane_id: PaneId,
|
||||
@@ -20,7 +18,5 @@ pub enum AppEvent {
|
||||
state: AgentState,
|
||||
},
|
||||
/// A new version was downloaded and installed. Restart to use it.
|
||||
UpdateReady {
|
||||
version: String,
|
||||
},
|
||||
UpdateReady { version: String },
|
||||
}
|
||||
|
||||
+13
-11
@@ -36,9 +36,17 @@ fn try_encode_csi_u(key: &KeyEvent) -> Option<Vec<u8>> {
|
||||
// understood. Even Ghostty sends these in legacy format with kitty mode on.
|
||||
// Only use CSI u for character keys and keys without legacy representations.
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right
|
||||
| KeyCode::Home | KeyCode::End | KeyCode::PageUp | KeyCode::PageDown
|
||||
| KeyCode::Insert | KeyCode::Delete | KeyCode::F(_) => {
|
||||
KeyCode::Up
|
||||
| KeyCode::Down
|
||||
| KeyCode::Left
|
||||
| KeyCode::Right
|
||||
| KeyCode::Home
|
||||
| KeyCode::End
|
||||
| KeyCode::PageUp
|
||||
| KeyCode::PageDown
|
||||
| KeyCode::Insert
|
||||
| KeyCode::Delete
|
||||
| KeyCode::F(_) => {
|
||||
return None; // let legacy handle these
|
||||
}
|
||||
_ => {}
|
||||
@@ -262,10 +270,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn legacy_ctrl_shift_end() {
|
||||
let key = KeyEvent::new(
|
||||
KeyCode::End,
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||||
);
|
||||
let key = KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
|
||||
assert_eq!(encode_key(key, false), b"\x1b[1;6F");
|
||||
}
|
||||
|
||||
@@ -330,10 +335,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn kitty_ctrl_shift_enter() {
|
||||
let key = KeyEvent::new(
|
||||
KeyCode::Enter,
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||||
);
|
||||
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
|
||||
assert_eq!(encode_key(key, true), b"\x1b[13;6u");
|
||||
}
|
||||
}
|
||||
|
||||
+10
-5
@@ -193,12 +193,17 @@ impl TileLayout {
|
||||
.filter(|s| match target_dir {
|
||||
Direction::Horizontal => {
|
||||
// Border must be near the focused pane's left or right edge
|
||||
let near_right = (s.pos as i32 - (focused_rect.x + focused_rect.width) as i32).unsigned_abs() <= 1;
|
||||
let near_right = (s.pos as i32 - (focused_rect.x + focused_rect.width) as i32)
|
||||
.unsigned_abs()
|
||||
<= 1;
|
||||
let near_left = (s.pos as i32 - focused_rect.x as i32).unsigned_abs() <= 1;
|
||||
near_right || near_left
|
||||
}
|
||||
Direction::Vertical => {
|
||||
let near_bottom = (s.pos as i32 - (focused_rect.y + focused_rect.height) as i32).unsigned_abs() <= 1;
|
||||
let near_bottom = (s.pos as i32
|
||||
- (focused_rect.y + focused_rect.height) as i32)
|
||||
.unsigned_abs()
|
||||
<= 1;
|
||||
let near_top = (s.pos as i32 - focused_rect.y as i32).unsigned_abs() <= 1;
|
||||
near_bottom || near_top
|
||||
}
|
||||
@@ -212,9 +217,9 @@ impl TileLayout {
|
||||
(Direction::Horizontal, false) => {
|
||||
(focused_rect.x as i32 - s.pos as i32).unsigned_abs()
|
||||
}
|
||||
(Direction::Vertical, true) => {
|
||||
((focused_rect.y + focused_rect.height) as i32 - s.pos as i32).unsigned_abs()
|
||||
}
|
||||
(Direction::Vertical, true) => ((focused_rect.y + focused_rect.height) as i32
|
||||
- s.pos as i32)
|
||||
.unsigned_abs(),
|
||||
(Direction::Vertical, false) => {
|
||||
(focused_rect.y as i32 - s.pos as i32).unsigned_abs()
|
||||
}
|
||||
|
||||
+17
-4
@@ -49,8 +49,8 @@ fn init_logging() {
|
||||
Err(_) => return, // can't open log file, proceed without logging
|
||||
};
|
||||
|
||||
let filter = EnvFilter::try_from_env("HERDR_LOG")
|
||||
.unwrap_or_else(|_| EnvFilter::new("herdr=info"));
|
||||
let filter =
|
||||
EnvFilter::try_from_env("HERDR_LOG").unwrap_or_else(|_| EnvFilter::new("herdr=info"));
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
@@ -85,7 +85,13 @@ const DEFAULT_CONFIG: &str = r#"# herdr configuration
|
||||
# accent = "cyan"
|
||||
|
||||
# Play sounds when agents change state in background workspaces
|
||||
# sound = true
|
||||
[ui.sound]
|
||||
# enabled = true
|
||||
|
||||
# Per-agent overrides: default | on | off
|
||||
# By default, droid is muted.
|
||||
# [ui.sound.agents]
|
||||
# droid = "off"
|
||||
"#;
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
@@ -134,7 +140,14 @@ fn main() -> io::Result<()> {
|
||||
}
|
||||
|
||||
// Reject unknown flags
|
||||
let known_flags = ["--no-session", "--version", "-V", "--default-config", "--help", "-h"];
|
||||
let known_flags = [
|
||||
"--no-session",
|
||||
"--version",
|
||||
"-V",
|
||||
"--default-config",
|
||||
"--help",
|
||||
"-h",
|
||||
];
|
||||
for arg in &args[1..] {
|
||||
if arg.starts_with('-') && !known_flags.contains(&arg.as_str()) {
|
||||
eprintln!("unknown option: {arg}");
|
||||
|
||||
+5
-2
@@ -1,5 +1,5 @@
|
||||
use std::io::{BufWriter, Read, Write};
|
||||
use std::cell::Cell;
|
||||
use std::io::{BufWriter, Read, Write};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
Arc, RwLock,
|
||||
@@ -91,7 +91,10 @@ impl PaneRuntime {
|
||||
let responses = PtyResponses::new();
|
||||
let kitty_keyboard = responses.kitty_keyboard.clone();
|
||||
let parser = Arc::new(RwLock::new(vt100::Parser::new_with_callbacks(
|
||||
rows, cols, 10000, responses.clone(),
|
||||
rows,
|
||||
cols,
|
||||
10000,
|
||||
responses.clone(),
|
||||
)));
|
||||
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
|
||||
|
||||
+7
-2
@@ -83,7 +83,9 @@ pub fn capture(
|
||||
fn capture_workspace(ws: &Workspace) -> WorkspaceSnapshot {
|
||||
let mut panes = HashMap::new();
|
||||
for id in ws.panes.keys() {
|
||||
let cwd = ws.runtimes.get(id)
|
||||
let cwd = ws
|
||||
.runtimes
|
||||
.get(id)
|
||||
.and_then(|rt| rt.cwd())
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
|
||||
panes.insert(id.raw(), PaneSnapshot { cwd });
|
||||
@@ -153,7 +155,10 @@ fn restore_workspace(
|
||||
let mut panes = HashMap::new();
|
||||
let mut runtimes = HashMap::new();
|
||||
for id in &pane_ids {
|
||||
let old_id = id_map.iter().find(|(_, new)| **new == *id).map(|(old, _)| old);
|
||||
let old_id = id_map
|
||||
.iter()
|
||||
.find(|(_, new)| **new == *id)
|
||||
.map(|(old, _)| old);
|
||||
let cwd = old_id
|
||||
.and_then(|old| snap.panes.get(old))
|
||||
.map(|p| p.cwd.clone())
|
||||
|
||||
@@ -79,7 +79,11 @@ fn foreground_pgid(pid: u32) -> Option<u32> {
|
||||
}
|
||||
|
||||
let fg = info.e_tpgid;
|
||||
if fg == 0 { None } else { Some(fg) }
|
||||
if fg == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(fg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the effective process name from `argv[0]` via `sysctl(KERN_PROCARGS2)`.
|
||||
@@ -122,9 +126,7 @@ fn process_argv0_name(pid: u32) -> Option<String> {
|
||||
}
|
||||
|
||||
// Return basename (argv[0] may be a full path like "/usr/bin/node")
|
||||
let basename = Path::new(argv0)
|
||||
.file_name()?
|
||||
.to_str()?;
|
||||
let basename = Path::new(argv0).file_name()?.to_str()?;
|
||||
|
||||
// Strip leading dash (login shells show as "-zsh")
|
||||
let name = basename.strip_prefix('-').unwrap_or(basename);
|
||||
|
||||
@@ -135,7 +135,8 @@ impl vt100::Callbacks for PtyResponses {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.kitty_keyboard.store(!stack.is_empty(), Ordering::Relaxed);
|
||||
self.kitty_keyboard
|
||||
.store(!stack.is_empty(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// === Terminal Identification ===
|
||||
@@ -150,11 +151,7 @@ impl vt100::Callbacks for PtyResponses {
|
||||
}
|
||||
}
|
||||
|
||||
fn unhandled_osc(
|
||||
&mut self,
|
||||
_screen: &mut vt100::Screen,
|
||||
params: &[&[u8]],
|
||||
) {
|
||||
fn unhandled_osc(&mut self, _screen: &mut vt100::Screen, params: &[&[u8]]) {
|
||||
let Some(cmd) = params.first() else { return };
|
||||
|
||||
match *cmd {
|
||||
@@ -237,7 +234,7 @@ mod tests {
|
||||
let r = PtyResponses::new();
|
||||
let mut p = make_parser(r.clone());
|
||||
p.process(b"\x1b[3;7H"); // move cursor
|
||||
p.process(b"\x1b[?6n"); // extended CPR
|
||||
p.process(b"\x1b[?6n"); // extended CPR
|
||||
assert_eq!(r.take(), b"\x1b[?3;7R");
|
||||
}
|
||||
|
||||
|
||||
@@ -248,6 +248,4 @@ mod tests {
|
||||
assert_eq!(row, 0);
|
||||
assert_eq!(col, 0);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
+4
-3
@@ -40,9 +40,10 @@ fn play_bytes(data: &[u8]) -> Result<(), String> {
|
||||
Command::new("afplay").arg(&tmp).output()
|
||||
} else {
|
||||
// Try paplay (PulseAudio) first, fall back to aplay (ALSA)
|
||||
Command::new("paplay").arg(&tmp).output().or_else(|_| {
|
||||
Command::new("aplay").arg(&tmp).output()
|
||||
})
|
||||
Command::new("paplay")
|
||||
.arg(&tmp)
|
||||
.output()
|
||||
.or_else(|_| Command::new("aplay").arg(&tmp).output())
|
||||
};
|
||||
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
|
||||
@@ -153,7 +153,12 @@ fn compute_sidebar_width(app: &AppState) -> u16 {
|
||||
fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
let is_navigating = matches!(
|
||||
app.mode,
|
||||
Mode::Navigate | Mode::CreateSession | Mode::RenameSession | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu
|
||||
Mode::Navigate
|
||||
| Mode::CreateSession
|
||||
| Mode::RenameSession
|
||||
| Mode::Resize
|
||||
| Mode::ConfirmClose
|
||||
| Mode::ContextMenu
|
||||
);
|
||||
|
||||
// Thin vertical separator line on the right edge
|
||||
@@ -205,7 +210,14 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(&num_label, dim_style),
|
||||
Span::styled(" ", if is_selected { row_style } else { Style::default() }),
|
||||
Span::styled(
|
||||
" ",
|
||||
if is_selected {
|
||||
row_style
|
||||
} else {
|
||||
Style::default()
|
||||
},
|
||||
),
|
||||
Span::styled(
|
||||
icon,
|
||||
if is_selected {
|
||||
@@ -226,7 +238,12 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
let is_navigating = matches!(
|
||||
app.mode,
|
||||
Mode::Navigate | Mode::CreateSession | Mode::RenameSession | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu
|
||||
Mode::Navigate
|
||||
| Mode::CreateSession
|
||||
| Mode::RenameSession
|
||||
| Mode::Resize
|
||||
| Mode::ConfirmClose
|
||||
| Mode::ContextMenu
|
||||
);
|
||||
|
||||
let highlight_style = if is_navigating {
|
||||
@@ -264,9 +281,7 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
Mode::ConfirmClose => " CLOSE?".to_string(),
|
||||
};
|
||||
let title_style = if is_navigating {
|
||||
Style::default()
|
||||
.fg(app.accent)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
Style::default().fg(app.accent).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
@@ -394,10 +409,7 @@ fn render_sidebar_toggle(frame: &mut Frame, area: Rect, collapsed: bool) {
|
||||
let x = area.x + content_w / 2;
|
||||
let toggle_area = Rect::new(x, bottom_y, 1, 1);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
icon,
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
Paragraph::new(Span::styled(icon, Style::default().fg(Color::DarkGray))),
|
||||
toggle_area,
|
||||
);
|
||||
}
|
||||
@@ -536,9 +548,7 @@ fn render_empty(frame: &mut Frame, area: Rect, accent: Color) {
|
||||
Span::styled(" Press ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
"n",
|
||||
Style::default()
|
||||
.fg(accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(accent).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" to create one", Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
@@ -555,9 +565,7 @@ fn render_empty(frame: &mut Frame, area: Rect, accent: Color) {
|
||||
|
||||
/// Floating overlay for navigate mode — appears at bottom of terminal area.
|
||||
fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
let key = Style::default()
|
||||
.fg(app.accent)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let key = Style::default().fg(app.accent).add_modifier(Modifier::BOLD);
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let label = Style::default().fg(Color::White);
|
||||
|
||||
@@ -653,9 +661,7 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
|
||||
/// Floating overlay for resize mode.
|
||||
fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
let key = Style::default()
|
||||
.fg(app.accent)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let key = Style::default().fg(app.accent).add_modifier(Modifier::BOLD);
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
|
||||
let mode_style = Style::default()
|
||||
@@ -721,20 +727,19 @@ fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2;
|
||||
let popup = Rect::new(popup_x, popup_y, popup_w, popup_h);
|
||||
|
||||
let key = Style::default()
|
||||
.fg(app.accent)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let warn = Style::default()
|
||||
.fg(Color::Red)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let key = Style::default().fg(app.accent).add_modifier(Modifier::BOLD);
|
||||
let warn = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
|
||||
let title_line = Line::from(vec![
|
||||
Span::styled(" Close workspace?", warn),
|
||||
]);
|
||||
let title_line = Line::from(vec![Span::styled(" Close workspace?", warn)]);
|
||||
|
||||
let detail_line = Line::from(vec![
|
||||
Span::styled(format!(" {ws_name}"), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(
|
||||
format!(" {ws_name}"),
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!(" — {pane_text}"), dim),
|
||||
]);
|
||||
|
||||
@@ -806,12 +811,13 @@ fn render_context_menu(app: &AppState, frame: &mut Frame) {
|
||||
if i as u16 >= inner.height {
|
||||
break;
|
||||
}
|
||||
let style = if i == menu.selected { highlight } else { normal };
|
||||
let style = if i == menu.selected {
|
||||
highlight
|
||||
} else {
|
||||
normal
|
||||
};
|
||||
let row = Rect::new(inner.x, inner.y + i as u16, inner.width, 1);
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(" {item}")).style(style),
|
||||
row,
|
||||
);
|
||||
frame.render_widget(Paragraph::new(format!(" {item}")).style(style), row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -850,7 +856,7 @@ fn state_icon_style(state: AgentState, seen: bool) -> (&'static str, Style) {
|
||||
match (state, seen) {
|
||||
(AgentState::Waiting, _) => ("●", Style::default().fg(Color::Red)),
|
||||
(AgentState::Busy, _) => ("●", Style::default().fg(Color::Yellow)),
|
||||
(AgentState::Idle, false) => ("●", Style::default().fg(Color::Blue)), // Done
|
||||
(AgentState::Idle, false) => ("●", Style::default().fg(Color::Blue)), // Done
|
||||
(AgentState::Idle, true) => ("○", Style::default().fg(Color::Green)),
|
||||
(AgentState::Unknown, _) => ("·", Style::default().fg(Color::DarkGray)),
|
||||
}
|
||||
|
||||
+28
-16
@@ -84,9 +84,12 @@ fn check_latest() -> Result<Option<ReleaseInfo>, String> {
|
||||
let output = Command::new("curl")
|
||||
.args([
|
||||
"-sfL",
|
||||
"--max-time", "10",
|
||||
"-H", "Accept: application/vnd.github+json",
|
||||
"-H", "User-Agent: herdr-updater",
|
||||
"--max-time",
|
||||
"10",
|
||||
"-H",
|
||||
"Accept: application/vnd.github+json",
|
||||
"-H",
|
||||
"User-Agent: herdr-updater",
|
||||
&url,
|
||||
])
|
||||
.output()
|
||||
@@ -129,12 +132,9 @@ fn check_latest() -> Result<Option<ReleaseInfo>, String> {
|
||||
|
||||
/// Download and install a release. Returns the installed version.
|
||||
fn download_and_install(release: &ReleaseInfo) -> Result<(), String> {
|
||||
let current_exe = env::current_exe()
|
||||
.map_err(|e| format!("can't find current binary: {e}"))?;
|
||||
let current_exe = env::current_exe().map_err(|e| format!("can't find current binary: {e}"))?;
|
||||
|
||||
let parent = current_exe
|
||||
.parent()
|
||||
.ok_or("can't find binary directory")?;
|
||||
let parent = current_exe.parent().ok_or("can't find binary directory")?;
|
||||
|
||||
// Check write permissions early
|
||||
let test_path = parent.join(".herdr-write-test");
|
||||
@@ -149,10 +149,7 @@ fn download_and_install(release: &ReleaseInfo) -> Result<(), String> {
|
||||
let _ = fs::remove_file(&test_path);
|
||||
|
||||
// Unique temp file (avoids races with concurrent instances)
|
||||
let tmp_path = parent.join(format!(
|
||||
".herdr-update-{}.tmp",
|
||||
std::process::id()
|
||||
));
|
||||
let tmp_path = parent.join(format!(".herdr-update-{}.tmp", std::process::id()));
|
||||
|
||||
// Download the exact asset URL (pinned to the release we checked)
|
||||
let status = Command::new("curl")
|
||||
@@ -232,7 +229,10 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::info!("auto-update: v{} installed, restart to use", release.version);
|
||||
tracing::info!(
|
||||
"auto-update: v{} installed, restart to use",
|
||||
release.version
|
||||
);
|
||||
|
||||
// Notify the TUI — blocking_send is safe from a std::thread
|
||||
let _ = events.blocking_send(crate::events::AppEvent::UpdateReady {
|
||||
@@ -276,7 +276,11 @@ mod tests {
|
||||
fn parse_version_basic() {
|
||||
assert_eq!(
|
||||
Version::parse("1.2.3"),
|
||||
Some(Version { major: 1, minor: 2, patch: 3 })
|
||||
Some(Version {
|
||||
major: 1,
|
||||
minor: 2,
|
||||
patch: 3
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,7 +288,11 @@ mod tests {
|
||||
fn parse_version_with_v_prefix() {
|
||||
assert_eq!(
|
||||
Version::parse("v0.1.0"),
|
||||
Some(Version { major: 0, minor: 1, patch: 0 })
|
||||
Some(Version {
|
||||
major: 0,
|
||||
minor: 1,
|
||||
patch: 0
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -310,7 +318,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn version_display() {
|
||||
let v = Version { major: 0, minor: 1, patch: 0 };
|
||||
let v = Version {
|
||||
major: 0,
|
||||
minor: 1,
|
||||
patch: 0,
|
||||
};
|
||||
assert_eq!(v.to_string(), "0.1.0");
|
||||
}
|
||||
|
||||
|
||||
+2
-3
@@ -58,9 +58,8 @@ impl Workspace {
|
||||
cwd: Option<std::path::PathBuf>,
|
||||
) -> std::io::Result<PaneId> {
|
||||
let new_id = self.layout.split_focused(direction);
|
||||
let actual_cwd = cwd.unwrap_or_else(|| {
|
||||
std::env::current_dir().unwrap_or_else(|_| "/".into())
|
||||
});
|
||||
let actual_cwd =
|
||||
cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
|
||||
let runtime = PaneRuntime::spawn(new_id, rows, cols, actual_cwd, self.events.clone())?;
|
||||
self.panes.insert(new_id, PaneState::new());
|
||||
self.runtimes.insert(new_id, runtime);
|
||||
|
||||
Reference in New Issue
Block a user