feat: add per-agent sound overrides

This commit is contained in:
Ogulcan Celik
2026-03-28 00:32:35 +03:00
parent 787786c620
commit f8c4d6a5f1
20 changed files with 350 additions and 170 deletions
+8 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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())
+6 -4
View File
@@ -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);
+4 -7
View File
@@ -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");
}
-2
View File
@@ -248,6 +248,4 @@ mod tests {
assert_eq!(row, 0);
assert_eq!(col, 0);
}
}
+4 -3
View File
@@ -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);
+41 -35
View File
@@ -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
View File
@@ -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
View File
@@ -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);