diff --git a/Cargo.lock b/Cargo.lock index df5adbc..82aa3b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1815,6 +1815,18 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "zen-mode" +version = "0.1.0" +dependencies = [ + "herdr-client", + "ratatui", + "serde", + "serde_json", + "tempfile", + "toml", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 2246716..e386e05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,13 @@ resolver = "3" # Rust plugins must be listed here. Plugins in other languages live under # plugins/ too but are not cargo members. -members = ["crates/herdr-client", "plugins/command-palette", "plugins/git-glance", "plugins/project-runner"] +members = [ + "crates/herdr-client", + "plugins/command-palette", + "plugins/git-glance", + "plugins/project-runner", + "plugins/zen-mode", +] [workspace.package] edition = "2024" diff --git a/README.md b/README.md index d182ad5..7c689a8 100644 --- a/README.md +++ b/README.md @@ -150,3 +150,51 @@ description = "run project script" `↑`/`↓` or `ctrl-p`/`ctrl-n` move · `enter` runs in a new tab · `ctrl-v` split right · `ctrl-x` split down · `esc` closes · `ctrl-u` clears. + +## Zen mode (`plugins/zen-mode`) + +Switcher shortlist for focused work: pick up to **two workspaces** and **three +tabs** in each, then toggle zen on to focus into that set. Deactivating keeps +the selection. Unselected tabs stay open and visible in Herdr (this is a +switcher, not host chrome filtering). Missing saved workspaces/tabs toast via +`notification.show` and are skipped on activate. + +State: `~/.local/state/herdr/plugins/jacob.zen-mode/zen.toml`. + +### Keybinding + +One binding opens the switcher. Activate or deactivate zen from inside it with +`z` (no second shortcut required). `jacob.zen-mode.toggle` remains available +from the command palette if you want it without a keybind. + +```toml +[[keys.command]] +key = "cmd+shift+z" +type = "plugin_action" +command = "jacob.zen-mode.configure" +description = "zen mode" +``` + +### Configure keys + +`↑`/`↓` or `j`/`k` move · `enter` focuses the selected tab (or workspace) and +closes · `z` toggles zen on/off (on focuses a shortlist tab and closes; off +stays in the popup) · `r` replace · `a` add workspace · `t` add tab · `d` +remove · `esc` close. In pickers, type to filter · `enter` selects · `esc` +backs out. Tabs and workspaces show agent status (`● working`, `blocked`, +`done`, `idle`) when Herdr reports one. + +### Notes / limits + +- Zen does **not** hide unselected workspaces or tabs in Herdr’s sidebar or tab + bar. It only remembers a shortlist, toggles focus into it, and lets you jump + to a picked tab from configure. +- Collapsing the left sidebar is a Herdr UI action, not something this plugin + can do on toggle. There is no sidebar collapse API for plugins today. + - Toggle manually: default `prefix+b` (`toggle_sidebar` in + `~/.config/herdr/config.toml`). + - Or start collapsed on next launch: + ```toml + sidebar_start_collapsed = true + sidebar_collapsed_mode = "hidden" # or "compact" for a thin rail + ``` diff --git a/plugins/zen-mode/Cargo.toml b/plugins/zen-mode/Cargo.toml new file mode 100644 index 0000000..2609c05 --- /dev/null +++ b/plugins/zen-mode/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "zen-mode" +version = "0.1.0" +edition.workspace = true + +[dependencies] +herdr-client.workspace = true +ratatui.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/plugins/zen-mode/herdr-plugin.toml b/plugins/zen-mode/herdr-plugin.toml new file mode 100644 index 0000000..ad828f9 --- /dev/null +++ b/plugins/zen-mode/herdr-plugin.toml @@ -0,0 +1,29 @@ +id = "jacob.zen-mode" +name = "Zen Mode" +version = "0.1.0" +min_herdr_version = "0.9.0" +description = "Switcher shortlist: up to two workspaces and three tabs each; toggle focus among the selection" +platforms = ["macos", "linux"] + +[[build]] +command = ["cargo", "build", "--release", "--target-dir", "target"] + +[[actions]] +id = "configure" +title = "Configure zen mode" +contexts = ["workspace"] +command = ["sh", "-c", "exec \"$HERDR_BIN_PATH\" plugin pane open --plugin jacob.zen-mode --entrypoint configure"] + +[[actions]] +id = "toggle" +title = "Toggle zen mode" +contexts = ["workspace"] +command = ["target/release/zen-mode", "toggle"] + +[[panes]] +id = "configure" +title = "Zen Mode" +placement = "popup" +width = "60%" +height = "50%" +command = ["target/release/zen-mode", "ui"] diff --git a/plugins/zen-mode/src/activate.rs b/plugins/zen-mode/src/activate.rs new file mode 100644 index 0000000..839ce2f --- /dev/null +++ b/plugins/zen-mode/src/activate.rs @@ -0,0 +1,134 @@ +//! Toggle zen on/off: resolve the shortlist, toast gaps, focus a tab. + +use herdr_client::Api; +use serde_json::json; + +use crate::resolve::{self, Resolution}; +use crate::state::ZenState; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToggleOutcome { + pub active: bool, + pub resolution: Resolution, +} + +/// Flip `active`, persist, and when turning on focus the best available tab. +pub fn toggle(api: &impl Api, state: &mut ZenState, current_tab_id: Option<&str>) -> Result { + if state.active { + state.active = false; + return Ok(ToggleOutcome { + active: false, + resolution: Resolution { available_tab_ids: Vec::new(), missing: Vec::new(), focus_tab_id: None }, + }); + } + + state.active = true; + let workspaces = api.workspace_list().map_err(|err| err.to_string())?; + let tabs = api.tab_list().map_err(|err| err.to_string())?; + let resolution = resolve::resolve(state, &workspaces, &tabs, current_tab_id); + + for message in &resolution.missing { + notify(api, "Zen mode", message)?; + } + if resolution.available_tab_ids.is_empty() { + notify(api, "Zen mode", "no selected tabs available")?; + } else if let Some(tab_id) = &resolution.focus_tab_id { + api.request("tab.focus", json!({ "tab_id": tab_id })).map_err(|err| err.to_string())?; + } + + Ok(ToggleOutcome { active: true, resolution }) +} + +pub fn notify(api: &impl Api, title: &str, body: &str) -> Result<(), String> { + api.request( + "notification.show", + json!({ + "title": title, + "body": body, + "sound": "none", + }), + ) + .map_err(|err| err.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::WorkspaceSlot; + use crate::testing::FakeApi; + use serde_json::json; + + fn live_lists() -> FakeApi { + FakeApi::new() + .ok( + "workspace.list", + json!({ + "workspaces": [ + {"workspace_id": "w1", "number": 1, "label": "Core", "focused": true, "agent_status": "idle"}, + {"workspace_id": "w2", "number": 2, "label": "Docs", "focused": false, "agent_status": "idle"} + ] + }), + ) + .ok( + "tab.list", + json!({ + "tabs": [ + {"tab_id": "w1:t1", "workspace_id": "w1", "number": 1, "label": "Claude", "focused": true, "agent_status": "idle"}, + {"tab_id": "w1:t2", "workspace_id": "w1", "number": 2, "label": "Logs", "focused": false, "agent_status": "idle"}, + {"tab_id": "w2:t1", "workspace_id": "w2", "number": 1, "label": "Write", "focused": false, "agent_status": "idle"} + ] + }), + ) + .ok("tab.focus", json!({})) + .ok("notification.show", json!({})) + } + + #[test] + fn activate_focuses_and_toasts_missing_without_closing() { + let api = live_lists(); + let mut state = ZenState { + active: false, + workspaces: vec![ + WorkspaceSlot { workspace_id: "w1".into(), tab_ids: vec!["w1:t1".into(), "w1:t9".into()] }, + WorkspaceSlot { workspace_id: "w9".into(), tab_ids: vec!["w9:t1".into()] }, + ], + }; + let outcome = toggle(&api, &mut state, Some("w1:t1")).unwrap(); + assert!(outcome.active); + assert!(state.active); + assert_eq!(outcome.resolution.focus_tab_id.as_deref(), Some("w1:t1")); + assert_eq!(state.workspaces[0].tab_ids, ["w1:t1", "w1:t9"]); + + let methods: Vec<_> = api.calls().into_iter().map(|(method, _)| method).collect(); + assert!(methods.contains(&"tab.focus".to_string())); + assert!(methods.contains(&"notification.show".to_string())); + assert!(!methods.iter().any(|method| method.contains("close") || method.contains("move"))); + } + + #[test] + fn deactivate_clears_active_and_keeps_selection() { + let api = FakeApi::new(); + let mut state = ZenState { + active: true, + workspaces: vec![WorkspaceSlot { workspace_id: "w1".into(), tab_ids: vec!["w1:t1".into()] }], + }; + let outcome = toggle(&api, &mut state, None).unwrap(); + assert!(!outcome.active); + assert!(!state.active); + assert_eq!(state.workspaces[0].tab_ids, ["w1:t1"]); + assert!(api.calls().is_empty()); + } + + #[test] + fn activate_with_empty_shortlist_notifies_and_skips_focus() { + let api = live_lists(); + let mut state = ZenState::default(); + let outcome = toggle(&api, &mut state, None).unwrap(); + assert!(outcome.active); + assert!(outcome.resolution.focus_tab_id.is_none()); + let methods: Vec<_> = api.calls().into_iter().map(|(method, _)| method).collect(); + assert!(methods.contains(&"notification.show".to_string())); + assert!(!methods.contains(&"tab.focus".to_string())); + } +} diff --git a/plugins/zen-mode/src/app.rs b/plugins/zen-mode/src/app.rs new file mode 100644 index 0000000..599da01 --- /dev/null +++ b/plugins/zen-mode/src/app.rs @@ -0,0 +1,597 @@ +//! Configure UI state and key handling, independent of the terminal. + +use herdr_client::models::{Tab, Workspace}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::state::{MAX_TABS_PER_WORKSPACE, MAX_WORKSPACES, ZenState}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + Continue, + Quit, + /// Focus a shortlist tab and close the popup. + FocusTab { + tab_id: String, + }, + /// Focus a shortlist workspace (no tabs picked yet) and close the popup. + FocusWorkspace { + workspace_id: String, + }, + /// Activate or deactivate zen from the switcher. + ToggleZen, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Mode { + Overview, + PickWorkspace { target: WorkspaceTarget }, + PickTab { target: TabTarget }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspaceTarget { + Add, + Replace { index: usize }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TabTarget { + Add { workspace_index: usize }, + Replace { workspace_index: usize, tab_index: usize }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Row { + Workspace { index: usize }, + Tab { workspace_index: usize, tab_index: usize }, + AddWorkspace, + AddTab { workspace_index: usize }, +} + +pub struct App { + pub state: ZenState, + pub workspaces: Vec, + pub tabs: Vec, + pub mode: Mode, + pub rows: Vec, + pub selected: usize, + pub query: String, + pub picker_indexes: Vec, + pub status: Option, + dirty: bool, +} + +impl App { + pub fn new(state: ZenState, workspaces: Vec, tabs: Vec) -> Self { + let mut app = Self { + state, + workspaces, + tabs, + mode: Mode::Overview, + rows: Vec::new(), + selected: 0, + query: String::new(), + picker_indexes: Vec::new(), + status: None, + dirty: false, + }; + app.rebuild_overview(); + app + } + + pub fn take_dirty(&mut self) -> bool { + let dirty = self.dirty; + self.dirty = false; + dirty + } + + pub fn handle_key(&mut self, key: KeyEvent) -> Command { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if ctrl && key.code == KeyCode::Char('c') { + return Command::Quit; + } + match self.mode { + Mode::Overview => self.overview_key(key, ctrl), + Mode::PickWorkspace { .. } => self.pick_workspace_key(key, ctrl), + Mode::PickTab { .. } => self.pick_tab_key(key, ctrl), + } + } + + fn overview_key(&mut self, key: KeyEvent, ctrl: bool) -> Command { + match key.code { + KeyCode::Esc => return Command::Quit, + KeyCode::Up => self.move_selection(-1), + KeyCode::Down => self.move_selection(1), + KeyCode::Char('p') if ctrl => self.move_selection(-1), + KeyCode::Char('n') if ctrl => self.move_selection(1), + KeyCode::Char('k') => self.move_selection(-1), + KeyCode::Char('j') => self.move_selection(1), + KeyCode::Enter => return self.activate_row(), + KeyCode::Char('z') => return Command::ToggleZen, + KeyCode::Char('r') => self.edit_row(), + KeyCode::Char('d') | KeyCode::Delete | KeyCode::Backspace => self.delete_row(), + KeyCode::Char('a') => { + if self.state.workspaces.len() < MAX_WORKSPACES { + self.enter_pick_workspace(WorkspaceTarget::Add); + } else { + self.status = Some(format!("at most {MAX_WORKSPACES} workspaces")); + } + } + KeyCode::Char('t') => { + if let Some(workspace_index) = self.selected_workspace_index() { + if self.state.workspaces[workspace_index].tab_ids.len() < MAX_TABS_PER_WORKSPACE { + self.enter_pick_tab(TabTarget::Add { workspace_index }); + } else { + self.status = Some(format!("at most {MAX_TABS_PER_WORKSPACE} tabs per workspace")); + } + } else { + self.status = Some("select a workspace first".into()); + } + } + _ => {} + } + Command::Continue + } + + fn pick_workspace_key(&mut self, key: KeyEvent, ctrl: bool) -> Command { + match key.code { + KeyCode::Esc => { + self.mode = Mode::Overview; + self.query.clear(); + self.status = None; + self.rebuild_overview(); + } + KeyCode::Up => self.move_selection(-1), + KeyCode::Down => self.move_selection(1), + KeyCode::Char('p') if ctrl => self.move_selection(-1), + KeyCode::Char('n') if ctrl => self.move_selection(1), + KeyCode::Enter => self.confirm_workspace(), + KeyCode::Char('u') if ctrl => { + self.query.clear(); + self.refilter_workspaces(); + } + KeyCode::Backspace => { + self.query.pop(); + self.refilter_workspaces(); + } + KeyCode::Char(c) if !ctrl => { + self.query.push(c); + self.refilter_workspaces(); + } + _ => {} + } + Command::Continue + } + + fn pick_tab_key(&mut self, key: KeyEvent, ctrl: bool) -> Command { + match key.code { + KeyCode::Esc => { + self.mode = Mode::Overview; + self.query.clear(); + self.status = None; + self.rebuild_overview(); + } + KeyCode::Up => self.move_selection(-1), + KeyCode::Down => self.move_selection(1), + KeyCode::Char('p') if ctrl => self.move_selection(-1), + KeyCode::Char('n') if ctrl => self.move_selection(1), + KeyCode::Enter => self.confirm_tab(), + KeyCode::Char('u') if ctrl => { + self.query.clear(); + self.refilter_tabs(); + } + KeyCode::Backspace => { + self.query.pop(); + self.refilter_tabs(); + } + KeyCode::Char(c) if !ctrl => { + self.query.push(c); + self.refilter_tabs(); + } + _ => {} + } + Command::Continue + } + + /// Enter: navigate to a shortlist item, or open an add picker. + fn activate_row(&mut self) -> Command { + let Some(row) = self.rows.get(self.selected).cloned() else { + return Command::Continue; + }; + match row { + Row::Tab { workspace_index, tab_index } => { + let Some(tab_id) = + self.state.workspaces.get(workspace_index).and_then(|slot| slot.tab_ids.get(tab_index)) + else { + self.status = Some("tab missing from shortlist".into()); + return Command::Continue; + }; + if !self.tabs.iter().any(|tab| tab.tab_id == *tab_id) { + self.status = Some(format!("tab {tab_id} unavailable")); + return Command::Continue; + } + Command::FocusTab { tab_id: tab_id.clone() } + } + Row::Workspace { index } => { + let Some(slot) = self.state.workspaces.get(index) else { + return Command::Continue; + }; + if let Some(tab_id) = + slot.tab_ids.iter().find(|tab_id| self.tabs.iter().any(|tab| tab.tab_id == **tab_id)) + { + Command::FocusTab { tab_id: tab_id.clone() } + } else if self.workspaces.iter().any(|workspace| workspace.workspace_id == slot.workspace_id) { + Command::FocusWorkspace { workspace_id: slot.workspace_id.clone() } + } else { + self.status = Some(format!("workspace {} unavailable", slot.workspace_id)); + Command::Continue + } + } + Row::AddWorkspace => { + self.enter_pick_workspace(WorkspaceTarget::Add); + Command::Continue + } + Row::AddTab { workspace_index } => { + self.enter_pick_tab(TabTarget::Add { workspace_index }); + Command::Continue + } + } + } + + /// `r`: replace the selected workspace or tab in the shortlist. + fn edit_row(&mut self) { + let Some(row) = self.rows.get(self.selected).cloned() else { + return; + }; + match row { + Row::Workspace { index } => self.enter_pick_workspace(WorkspaceTarget::Replace { index }), + Row::Tab { workspace_index, tab_index } => { + self.enter_pick_tab(TabTarget::Replace { workspace_index, tab_index }); + } + Row::AddWorkspace => self.enter_pick_workspace(WorkspaceTarget::Add), + Row::AddTab { workspace_index } => self.enter_pick_tab(TabTarget::Add { workspace_index }), + } + } + + fn delete_row(&mut self) { + let Some(row) = self.rows.get(self.selected).cloned() else { + return; + }; + match row { + Row::Workspace { index } => { + self.state.remove_workspace(index); + self.dirty = true; + self.status = Some("workspace removed".into()); + } + Row::Tab { workspace_index, tab_index } => { + self.state.remove_tab(workspace_index, tab_index); + self.dirty = true; + self.status = Some("tab removed".into()); + } + Row::AddWorkspace | Row::AddTab { .. } => {} + } + self.rebuild_overview(); + } + + fn enter_pick_workspace(&mut self, target: WorkspaceTarget) { + self.mode = Mode::PickWorkspace { target }; + self.query.clear(); + self.status = None; + self.refilter_workspaces(); + } + + fn enter_pick_tab(&mut self, target: TabTarget) { + self.mode = Mode::PickTab { target }; + self.query.clear(); + self.status = None; + self.refilter_tabs(); + } + + fn confirm_workspace(&mut self) { + let Mode::PickWorkspace { target } = self.mode else { + return; + }; + let Some(&index) = self.picker_indexes.get(self.selected) else { + self.status = Some("no workspace selected".into()); + return; + }; + let workspace_id = self.workspaces[index].workspace_id.clone(); + let result = match target { + WorkspaceTarget::Add => self.state.add_workspace(workspace_id), + WorkspaceTarget::Replace { index } => { + self.state.set_workspace(index, workspace_id); + Ok(()) + } + }; + match result { + Ok(()) => { + self.dirty = true; + self.mode = Mode::Overview; + self.query.clear(); + self.status = Some("workspace updated".into()); + self.rebuild_overview(); + } + Err(err) => self.status = Some(err), + } + } + + fn confirm_tab(&mut self) { + let Mode::PickTab { target } = self.mode else { + return; + }; + let Some(&index) = self.picker_indexes.get(self.selected) else { + self.status = Some("no tab selected".into()); + return; + }; + let tab_id = self.tabs[index].tab_id.clone(); + let result = match target { + TabTarget::Add { workspace_index } => self.state.add_tab(workspace_index, tab_id), + TabTarget::Replace { workspace_index, tab_index } => self.state.set_tab(workspace_index, tab_index, tab_id), + }; + match result { + Ok(()) => { + self.dirty = true; + self.mode = Mode::Overview; + self.query.clear(); + self.status = Some("tab updated".into()); + self.rebuild_overview(); + } + Err(err) => self.status = Some(err), + } + } + + fn selected_workspace_index(&self) -> Option { + match self.rows.get(self.selected)? { + Row::Workspace { index } | Row::AddTab { workspace_index: index } => Some(*index), + Row::Tab { workspace_index, .. } => Some(*workspace_index), + Row::AddWorkspace => None, + } + } + + fn rebuild_overview(&mut self) { + self.rows.clear(); + for (index, slot) in self.state.workspaces.iter().enumerate() { + self.rows.push(Row::Workspace { index }); + for tab_index in 0..slot.tab_ids.len() { + self.rows.push(Row::Tab { workspace_index: index, tab_index }); + } + if slot.tab_ids.len() < MAX_TABS_PER_WORKSPACE { + self.rows.push(Row::AddTab { workspace_index: index }); + } + } + if self.state.workspaces.len() < MAX_WORKSPACES { + self.rows.push(Row::AddWorkspace); + } + if self.selected >= self.rows.len() { + self.selected = self.rows.len().saturating_sub(1); + } + } + + fn refilter_workspaces(&mut self) { + let query = self.query.to_lowercase(); + self.picker_indexes = self + .workspaces + .iter() + .enumerate() + .filter(|(_, workspace)| { + let already = self.state.workspaces.iter().any(|slot| slot.workspace_id == workspace.workspace_id); + if already { + // Allow re-picking when replacing that same slot. + if let Mode::PickWorkspace { target: WorkspaceTarget::Replace { index } } = self.mode { + if self.state.workspaces.get(index).map(|slot| slot.workspace_id.as_str()) + == Some(workspace.workspace_id.as_str()) + { + // fall through + } else { + return false; + } + } else { + return false; + } + } + if query.is_empty() { + return true; + } + let label = workspace_label(workspace).to_lowercase(); + label.contains(&query) || workspace.workspace_id.to_lowercase().contains(&query) + }) + .map(|(index, _)| index) + .collect(); + self.selected = 0; + } + + fn refilter_tabs(&mut self) { + let workspace_index = match self.mode { + Mode::PickTab { target: TabTarget::Add { workspace_index } } + | Mode::PickTab { target: TabTarget::Replace { workspace_index, .. } } => workspace_index, + _ => 0, + }; + let workspace_id = + self.state.workspaces.get(workspace_index).map(|slot| slot.workspace_id.as_str()).unwrap_or(""); + let selected_tabs = + self.state.workspaces.get(workspace_index).map(|slot| slot.tab_ids.as_slice()).unwrap_or(&[]); + let replace_tab_id = match self.mode { + Mode::PickTab { target: TabTarget::Replace { tab_index, .. } } => { + selected_tabs.get(tab_index).map(String::as_str) + } + _ => None, + }; + let query = self.query.to_lowercase(); + self.picker_indexes = self + .tabs + .iter() + .enumerate() + .filter(|(_, tab)| { + if tab.workspace_id != workspace_id { + return false; + } + if selected_tabs.iter().any(|id| id == &tab.tab_id) && replace_tab_id != Some(tab.tab_id.as_str()) { + return false; + } + if query.is_empty() { + return true; + } + let label = tab_label(tab).to_lowercase(); + label.contains(&query) || tab.tab_id.to_lowercase().contains(&query) + }) + .map(|(index, _)| index) + .collect(); + self.selected = 0; + } + + fn move_selection(&mut self, delta: isize) { + let len = match self.mode { + Mode::Overview => self.rows.len(), + Mode::PickWorkspace { .. } | Mode::PickTab { .. } => self.picker_indexes.len(), + }; + if len == 0 { + self.selected = 0; + return; + } + self.selected = (self.selected as isize + delta).rem_euclid(len as isize) as usize; + } +} + +pub fn workspace_label(workspace: &Workspace) -> String { + if workspace.label.is_empty() { format!("Workspace {}", workspace.number) } else { workspace.label.clone() } +} + +pub fn tab_label(tab: &Tab) -> String { + if tab.label.is_empty() { format!("Tab {}", tab.number) } else { tab.label.clone() } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::WorkspaceSlot; + use herdr_client::models::AgentStatus; + + fn workspace(id: &str, number: u32, label: &str) -> Workspace { + Workspace { + workspace_id: id.into(), + number, + label: label.into(), + focused: false, + agent_status: AgentStatus::Unknown, + } + } + + fn tab(id: &str, workspace_id: &str, number: u32, label: &str) -> Tab { + Tab { + tab_id: id.into(), + workspace_id: workspace_id.into(), + number, + label: label.into(), + focused: false, + agent_status: AgentStatus::Unknown, + } + } + + fn sample() -> App { + App::new( + ZenState::default(), + vec![workspace("w1", 1, "Core"), workspace("w2", 2, "Docs")], + vec![tab("w1:t1", "w1", 1, "Claude"), tab("w1:t2", "w1", 2, "Logs"), tab("w2:t1", "w2", 1, "Write")], + ) + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn adds_workspace_and_tabs_through_the_picker() { + let mut app = sample(); + assert_eq!(app.rows, [Row::AddWorkspace]); + app.handle_key(key(KeyCode::Enter)); + assert!(matches!(app.mode, Mode::PickWorkspace { target: WorkspaceTarget::Add })); + app.handle_key(key(KeyCode::Enter)); + assert_eq!(app.state.workspaces[0].workspace_id, "w1"); + assert!(app.take_dirty()); + + // Move to Add tab row and pick the first tab. + app.selected = app.rows.iter().position(|row| matches!(row, Row::AddTab { .. })).unwrap(); + app.handle_key(key(KeyCode::Enter)); + app.handle_key(key(KeyCode::Enter)); + assert_eq!(app.state.workspaces[0].tab_ids, ["w1:t1"]); + } + + #[test] + fn replace_one_workspace_keeps_the_other() { + let mut app = App::new( + ZenState { + active: false, + workspaces: vec![ + WorkspaceSlot { workspace_id: "w1".into(), tab_ids: vec!["w1:t1".into()] }, + WorkspaceSlot { workspace_id: "w2".into(), tab_ids: vec!["w2:t1".into()] }, + ], + }, + vec![workspace("w1", 1, "Core"), workspace("w2", 2, "Docs"), workspace("w3", 3, "Ops")], + vec![tab("w1:t1", "w1", 1, "Claude"), tab("w2:t1", "w2", 1, "Write"), tab("w3:t1", "w3", 1, "Ship")], + ); + app.selected = 0; + app.handle_key(key(KeyCode::Char('r'))); + // Filtered: w1 (current replace slot), w3. Move to w3. + app.handle_key(key(KeyCode::Down)); + app.handle_key(key(KeyCode::Enter)); + assert_eq!(app.state.workspaces[0].workspace_id, "w3"); + assert!(app.state.workspaces[0].tab_ids.is_empty()); + assert_eq!(app.state.workspaces[1].workspace_id, "w2"); + assert_eq!(app.state.workspaces[1].tab_ids, ["w2:t1"]); + } + + #[test] + fn enter_on_a_tab_focuses_it() { + let mut app = App::new( + ZenState { + active: false, + workspaces: vec![WorkspaceSlot { + workspace_id: "w1".into(), + tab_ids: vec!["w1:t1".into(), "w1:t2".into()], + }], + }, + vec![workspace("w1", 1, "Core")], + vec![tab("w1:t1", "w1", 1, "Claude"), tab("w1:t2", "w1", 2, "Logs")], + ); + app.selected = 2; // second tab + assert_eq!(app.handle_key(key(KeyCode::Enter)), Command::FocusTab { tab_id: "w1:t2".into() }); + } + + #[test] + fn enter_on_a_workspace_focuses_its_first_available_tab() { + let mut app = App::new( + ZenState { + active: false, + workspaces: vec![WorkspaceSlot { workspace_id: "w1".into(), tab_ids: vec!["w1:t1".into()] }], + }, + vec![workspace("w1", 1, "Core")], + vec![tab("w1:t1", "w1", 1, "Claude")], + ); + app.selected = 0; + assert_eq!(app.handle_key(key(KeyCode::Enter)), Command::FocusTab { tab_id: "w1:t1".into() }); + } + + #[test] + fn delete_removes_selected_tab() { + let mut app = App::new( + ZenState { + active: false, + workspaces: vec![WorkspaceSlot { + workspace_id: "w1".into(), + tab_ids: vec!["w1:t1".into(), "w1:t2".into()], + }], + }, + vec![workspace("w1", 1, "Core")], + vec![tab("w1:t1", "w1", 1, "Claude"), tab("w1:t2", "w1", 2, "Logs")], + ); + app.selected = 1; // first tab + app.handle_key(key(KeyCode::Char('d'))); + assert_eq!(app.state.workspaces[0].tab_ids, ["w1:t2"]); + } + + #[test] + fn z_toggles_zen_from_overview() { + let mut app = sample(); + assert_eq!(app.handle_key(key(KeyCode::Char('z'))), Command::ToggleZen); + } +} diff --git a/plugins/zen-mode/src/log.rs b/plugins/zen-mode/src/log.rs new file mode 100644 index 0000000..b91c159 --- /dev/null +++ b/plugins/zen-mode/src/log.rs @@ -0,0 +1,34 @@ +//! Diagnostics go to a file: the configure UI owns the terminal while it runs. + +use std::io::Write; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Best effort: logging must never break the plugin. +pub fn append(dir: &Path, message: &str) { + let write = || -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + let mut file = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("zen.log"))?; + let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()); + writeln!(file, "{now} {message}") + }; + let _ = write(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn appends_timestamped_lines_creating_the_directory() { + let dir = tempfile::tempdir().unwrap(); + let state = dir.path().join("state"); + append(&state, "first"); + append(&state, "second"); + let log = std::fs::read_to_string(state.join("zen.log")).unwrap(); + let lines: Vec<_> = log.lines().collect(); + assert_eq!(lines.len(), 2); + assert!(lines[0].ends_with(" first")); + assert!(lines[1].ends_with(" second")); + } +} diff --git a/plugins/zen-mode/src/main.rs b/plugins/zen-mode/src/main.rs new file mode 100644 index 0000000..2844cfc --- /dev/null +++ b/plugins/zen-mode/src/main.rs @@ -0,0 +1,229 @@ +mod activate; +mod app; +mod log; +mod render; +mod resolve; +mod state; +#[cfg(test)] +mod testing; + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use herdr_client::{Api, Client, PluginContext}; +use ratatui::DefaultTerminal; +use ratatui::crossterm::event::{self, Event, KeyEventKind}; +use serde_json::json; + +use activate::ToggleOutcome; +use app::{App, Command}; +use state::ZenState; + +fn main() -> ExitCode { + match std::env::args().nth(1).as_deref() { + Some("ui") => match run_ui() { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("zen-mode: {err}"); + ExitCode::FAILURE + } + }, + Some("toggle") => match run_toggle() { + Ok(message) => { + if !message.is_empty() { + eprintln!("{message}"); + } + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("zen-mode: {err}"); + ExitCode::FAILURE + } + }, + _ => { + eprintln!("usage: zen-mode ui|toggle"); + ExitCode::from(2) + } + } +} + +fn state_dir() -> PathBuf { + std::env::var_os("HERDR_PLUGIN_STATE_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("herdr-zen-mode")) +} + +fn run_toggle() -> Result { + let dir = state_dir(); + let mut state = ZenState::load(&dir)?; + let ctx = PluginContext::from_env(); + let client = Client::from_env().map_err(|err| err.to_string())?; + let outcome = activate::toggle(&client, &mut state, ctx.tab_id.as_deref())?; + state.save(&dir)?; + Ok(toggle_summary(&outcome)) +} + +fn toggle_summary(outcome: &ToggleOutcome) -> String { + if outcome.active { + format!( + "zen on · {} tabs available · {} missing", + outcome.resolution.available_tab_ids.len(), + outcome.resolution.missing.len() + ) + } else { + "zen off".into() + } +} + +fn run_ui() -> std::io::Result<()> { + let dir = state_dir(); + let ctx = PluginContext::from_env(); + let client = match Client::from_env() { + Ok(client) => client, + Err(err) => { + log::append(&dir, &err.to_string()); + return message_screen_err(&err.to_string()); + } + }; + + let loaded = load_app(&client, &dir); + let mut terminal = ratatui::init(); + let result = match loaded { + Ok(mut app) => event_loop(&mut terminal, &mut app, &client, ctx.tab_id.as_deref(), &dir), + Err(err) => { + log::append(&dir, &err); + message_screen(&mut terminal, &err) + } + }; + ratatui::restore(); + result +} + +fn load_app(api: &impl Api, dir: &Path) -> Result { + let state = ZenState::load(dir)?; + let workspaces = api.workspace_list().map_err(|err| err.to_string())?; + let tabs = api.tab_list().map_err(|err| err.to_string())?; + Ok(App::new(state, workspaces, tabs)) +} + +fn event_loop( + terminal: &mut DefaultTerminal, + app: &mut App, + api: &impl Api, + current_tab_id: Option<&str>, + state_dir: &Path, +) -> std::io::Result<()> { + loop { + if app.take_dirty() + && let Err(err) = app.state.save(state_dir) + { + log::append(state_dir, &err); + app.status = Some(err); + } + terminal.draw(|frame| render::render(frame, app))?; + let Event::Key(key) = event::read()? else { continue }; + if key.kind != KeyEventKind::Press { + continue; + } + match app.handle_key(key) { + Command::Continue => {} + Command::Quit => { + if let Err(err) = app.state.save(state_dir) { + log::append(state_dir, &err); + } + return Ok(()); + } + Command::FocusTab { tab_id } => { + if let Err(err) = app.state.save(state_dir) { + log::append(state_dir, &err); + } + match api.request("tab.focus", json!({ "tab_id": tab_id })) { + Ok(_) => return Ok(()), + Err(err) => { + log::append(state_dir, &err.to_string()); + app.status = Some(err.to_string()); + } + } + } + Command::FocusWorkspace { workspace_id } => { + if let Err(err) = app.state.save(state_dir) { + log::append(state_dir, &err); + } + match api.request("workspace.focus", json!({ "workspace_id": workspace_id })) { + Ok(_) => return Ok(()), + Err(err) => { + log::append(state_dir, &err.to_string()); + app.status = Some(err.to_string()); + } + } + } + Command::ToggleZen => match activate::toggle(api, &mut app.state, current_tab_id) { + Ok(outcome) => { + if let Err(err) = app.state.save(state_dir) { + log::append(state_dir, &err); + app.status = Some(err); + continue; + } + // activate::toggle already focuses when turning on with an available tab. + if outcome.active && outcome.resolution.focus_tab_id.is_some() { + return Ok(()); + } + app.status = Some(toggle_summary(&outcome)); + } + Err(err) => { + log::append(state_dir, &err); + app.status = Some(err); + } + }, + } + } +} + +fn message_screen_err(message: &str) -> std::io::Result<()> { + let mut terminal = ratatui::init(); + let result = message_screen(&mut terminal, message); + ratatui::restore(); + result +} + +fn message_screen(terminal: &mut DefaultTerminal, message: &str) -> std::io::Result<()> { + use ratatui::text::{Line, Text}; + use ratatui::widgets::{Paragraph, Wrap}; + loop { + terminal.draw(|frame| { + let text = Text::from(vec![Line::from(message), Line::from(""), Line::from("press any key")]); + frame.render_widget(Paragraph::new(text).wrap(Wrap { trim: false }), frame.area()); + })?; + if let Event::Key(key) = event::read()? + && key.kind == KeyEventKind::Press + { + return Ok(()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolve::Resolution; + + #[test] + fn toggle_summary_reports_counts() { + let on = ToggleOutcome { + active: true, + resolution: Resolution { + available_tab_ids: vec!["w1:t1".into()], + missing: vec!["tab w1:t9 unavailable".into()], + focus_tab_id: Some("w1:t1".into()), + }, + }; + assert_eq!(toggle_summary(&on), "zen on · 1 tabs available · 1 missing"); + assert_eq!( + toggle_summary(&ToggleOutcome { + active: false, + resolution: Resolution { available_tab_ids: Vec::new(), missing: Vec::new(), focus_tab_id: None }, + }), + "zen off" + ); + } +} diff --git a/plugins/zen-mode/src/render.rs b/plugins/zen-mode/src/render.rs new file mode 100644 index 0000000..c3f3523 --- /dev/null +++ b/plugins/zen-mode/src/render.rs @@ -0,0 +1,251 @@ +//! Draws the zen configure popup. + +use herdr_client::models::AgentStatus; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span}; + +use crate::app::{App, Mode, Row, tab_label, workspace_label}; +use crate::state::ZenState; + +pub fn render(frame: &mut Frame, app: &App) { + let [header, rule, body, footer] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)]) + .areas(frame.area()); + render_header(frame, app, header); + frame.render_widget(Line::from("─".repeat(usize::from(rule.width))).dim(), rule); + match app.mode { + Mode::Overview => render_overview(frame, app, body), + Mode::PickWorkspace { .. } => render_workspace_picker(frame, app, body), + Mode::PickTab { .. } => render_tab_picker(frame, app, body), + } + render_footer(frame, app, footer); +} + +fn render_header(frame: &mut Frame, app: &App, area: Rect) { + let active = if app.state.active { "on" } else { "off" }; + let title = match app.mode { + Mode::Overview => format!("Zen Mode · {active}"), + Mode::PickWorkspace { .. } => format!("Pick workspace · filter: {}", app.query), + Mode::PickTab { .. } => format!("Pick tab · filter: {}", app.query), + }; + frame.render_widget(Line::from(title).cyan().bold(), area); +} + +fn render_overview(frame: &mut Frame, app: &App, area: Rect) { + if area.height == 0 { + return; + } + if app.rows.is_empty() { + frame.render_widget(Line::from(" no rows").dim(), area); + return; + } + let height = usize::from(area.height); + let offset = app.selected.saturating_sub(height.saturating_sub(1)); + for (row, (position, item)) in app.rows.iter().enumerate().skip(offset).take(height).enumerate() { + let selected = position == app.selected; + let line = overview_line(&app.state, &app.workspaces, &app.tabs, item, selected); + frame.render_widget(line, Rect { y: area.y + row as u16, height: 1, ..area }); + } +} + +fn overview_line( + state: &ZenState, + workspaces: &[herdr_client::models::Workspace], + tabs: &[herdr_client::models::Tab], + row: &Row, + selected: bool, +) -> Line<'static> { + let marker = if selected { "▸ " } else { " " }; + let spans = match row { + Row::Workspace { index } => { + let slot = &state.workspaces[*index]; + let workspace = workspaces.iter().find(|workspace| workspace.workspace_id == slot.workspace_id); + let label = workspace.map(workspace_label).unwrap_or_else(|| slot.workspace_id.clone()); + let mut spans = vec![Span::raw(format!("{marker}Workspace {label}"))]; + if let Some(workspace) = workspace { + push_status(&mut spans, workspace.agent_status); + } + spans + } + Row::Tab { workspace_index, tab_index } => { + let tab_id = &state.workspaces[*workspace_index].tab_ids[*tab_index]; + let tab = tabs.iter().find(|tab| tab.tab_id == *tab_id); + let label = tab.map(tab_label).unwrap_or_else(|| tab_id.clone()); + let mut spans = vec![Span::raw(format!("{marker} Tab {label}"))]; + if let Some(tab) = tab { + push_status(&mut spans, tab.agent_status); + } + spans + } + Row::AddWorkspace => vec![Span::raw(format!("{marker}+ Add workspace"))], + Row::AddTab { .. } => vec![Span::raw(format!("{marker} + Add tab"))], + }; + let mut line = Line::from(spans); + if selected { + line = line.style(Style::new().bg(Color::DarkGray)); + } + line +} + +fn render_workspace_picker(frame: &mut Frame, app: &App, area: Rect) { + render_picker_list(frame, area, app.selected, app.picker_indexes.len(), |position| { + let workspace = &app.workspaces[app.picker_indexes[position]]; + let mut spans = vec![Span::raw(format!("{} {}", workspace_label(workspace), workspace.workspace_id))]; + push_status(&mut spans, workspace.agent_status); + spans + }); +} + +fn render_tab_picker(frame: &mut Frame, app: &App, area: Rect) { + render_picker_list(frame, area, app.selected, app.picker_indexes.len(), |position| { + let tab = &app.tabs[app.picker_indexes[position]]; + let mut spans = vec![Span::raw(format!("{} {}", tab_label(tab), tab.tab_id))]; + push_status(&mut spans, tab.agent_status); + spans + }); +} + +fn render_picker_list( + frame: &mut Frame, + area: Rect, + selected: usize, + len: usize, + label: impl Fn(usize) -> Vec>, +) { + if area.height == 0 { + return; + } + if len == 0 { + frame.render_widget(Line::from(" no matches").dim(), area); + return; + } + let height = usize::from(area.height); + let offset = selected.saturating_sub(height.saturating_sub(1)); + for (row, position) in (offset..len).take(height).enumerate() { + let is_selected = position == selected; + let marker = if is_selected { "▸ " } else { " " }; + let mut spans = vec![Span::raw(marker)]; + spans.extend(label(position)); + let mut line = Line::from(spans); + if is_selected { + line = line.style(Style::new().bg(Color::DarkGray)); + } + frame.render_widget(line, Rect { y: area.y + row as u16, height: 1, ..area }); + } +} + +fn push_status(spans: &mut Vec>, status: AgentStatus) { + if status == AgentStatus::Unknown { + return; + } + spans.push(Span::styled(format!(" ● {}", status.as_str()), status_style(status))); +} + +fn status_style(status: AgentStatus) -> Style { + Style::new().fg(match status { + AgentStatus::Working => Color::Yellow, + AgentStatus::Blocked => Color::Red, + AgentStatus::Done => Color::Green, + AgentStatus::Idle => Color::Blue, + AgentStatus::Unknown => Color::DarkGray, + }) +} + +fn render_footer(frame: &mut Frame, app: &App, area: Rect) { + let hints = match app.mode { + Mode::Overview => "⏎ go z toggle r replace a add ws t add tab d remove esc", + Mode::PickWorkspace { .. } | Mode::PickTab { .. } => "type to filter ⏎ select esc back", + }; + let left = Line::from(hints).dim(); + let right = match &app.status { + Some(message) => Line::from(Span::raw(message.clone()).yellow()), + None => { + Line::from(format!("{} ws · {} tabs max", app.state.workspaces.len(), crate::state::MAX_TABS_PER_WORKSPACE)) + .dim() + } + }; + let hints_width = u16::try_from(left.width() + 2).unwrap_or(u16::MAX); + let [left_area, right_area] = Layout::horizontal([Constraint::Length(hints_width), Constraint::Min(0)]).areas(area); + frame.render_widget(left, left_area); + frame.render_widget(right.right_aligned(), right_area); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::WorkspaceSlot; + use herdr_client::models::{AgentStatus, Tab, Workspace}; + use ratatui::{Terminal, backend::TestBackend}; + + fn workspace(id: &str, number: u32, label: &str) -> Workspace { + Workspace { + workspace_id: id.into(), + number, + label: label.into(), + focused: false, + agent_status: AgentStatus::Unknown, + } + } + + fn tab(id: &str, workspace_id: &str, number: u32, label: &str, agent_status: AgentStatus) -> Tab { + Tab { + tab_id: id.into(), + workspace_id: workspace_id.into(), + number, + label: label.into(), + focused: false, + agent_status, + } + } + + fn draw(app: &App, width: u16, height: u16) -> Vec { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| render(frame, app)).unwrap(); + let buffer = terminal.backend().buffer(); + (0..buffer.area.height) + .map(|y| { + let line: String = (0..buffer.area.width).map(|x| buffer[(x, y)].symbol()).collect(); + line.trim_end().to_string() + }) + .collect() + } + + #[test] + fn overview_lists_grouped_selection() { + let app = App::new( + ZenState { + active: true, + workspaces: vec![WorkspaceSlot { workspace_id: "w1".into(), tab_ids: vec!["w1:t1".into()] }], + }, + vec![workspace("w1", 1, "Core")], + vec![tab("w1:t1", "w1", 1, "Claude", AgentStatus::Unknown)], + ); + let lines = draw(&app, 70, 8); + assert!(lines[0].contains("Zen Mode · on")); + assert!(lines.iter().any(|line| line.contains("Workspace Core"))); + assert!(lines.iter().any(|line| line.contains("Tab Claude"))); + } + + #[test] + fn overview_shows_tab_agent_status() { + let app = App::new( + ZenState { + active: false, + workspaces: vec![WorkspaceSlot { + workspace_id: "w1".into(), + tab_ids: vec!["w1:t1".into(), "w1:t2".into()], + }], + }, + vec![workspace("w1", 1, "Core")], + vec![ + tab("w1:t1", "w1", 1, "Claude", AgentStatus::Working), + tab("w1:t2", "w1", 2, "Idle", AgentStatus::Unknown), + ], + ); + let lines = draw(&app, 70, 8); + assert!(lines.iter().any(|line| line.contains("Tab Claude") && line.contains("● working"))); + assert!(lines.iter().any(|line| line.contains("Tab Idle") && !line.contains("●"))); + } +} diff --git a/plugins/zen-mode/src/resolve.rs b/plugins/zen-mode/src/resolve.rs new file mode 100644 index 0000000..595b8aa --- /dev/null +++ b/plugins/zen-mode/src/resolve.rs @@ -0,0 +1,130 @@ +//! Resolve a saved shortlist against the live workspace/tab lists. + +use herdr_client::models::{Tab, Workspace}; + +use crate::state::ZenState; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Resolution { + /// Tab ids that still exist, in shortlist order. + pub available_tab_ids: Vec, + /// Human-readable notices for missing workspaces/tabs. + pub missing: Vec, + /// Tab to focus on activate, if any. + pub focus_tab_id: Option, +} + +/// Keeps saved ids intact in `ZenState`; only reports what is currently available. +pub fn resolve(state: &ZenState, workspaces: &[Workspace], tabs: &[Tab], current_tab_id: Option<&str>) -> Resolution { + let mut available_tab_ids = Vec::new(); + let mut missing = Vec::new(); + + for slot in &state.workspaces { + let workspace = workspaces.iter().find(|workspace| workspace.workspace_id == slot.workspace_id); + if workspace.is_none() { + missing.push(format!("workspace {} unavailable", label_workspace(slot.workspace_id.as_str(), None))); + for tab_id in &slot.tab_ids { + missing.push(format!("tab {tab_id} unavailable")); + } + continue; + } + let workspace_label = label_workspace(slot.workspace_id.as_str(), workspace); + for tab_id in &slot.tab_ids { + match tabs.iter().find(|tab| tab.tab_id == *tab_id) { + Some(tab) if tab.workspace_id == slot.workspace_id => { + available_tab_ids.push(tab_id.clone()); + } + Some(_) => { + missing.push(format!("tab {} moved out of workspace {workspace_label}", label_tab(tab_id, tabs))); + } + None => { + missing.push(format!("tab {} unavailable", label_tab(tab_id, tabs))); + } + } + } + } + + let focus_tab_id = current_tab_id + .filter(|tab_id| available_tab_ids.iter().any(|id| id == tab_id)) + .map(str::to_string) + .or_else(|| available_tab_ids.first().cloned()); + + Resolution { available_tab_ids, missing, focus_tab_id } +} + +fn label_workspace(id: &str, workspace: Option<&Workspace>) -> String { + match workspace { + Some(workspace) if !workspace.label.is_empty() => workspace.label.clone(), + Some(workspace) => format!("Workspace {}", workspace.number), + None => id.to_string(), + } +} + +fn label_tab(tab_id: &str, tabs: &[Tab]) -> String { + match tabs.iter().find(|tab| tab.tab_id == tab_id) { + Some(tab) if !tab.label.is_empty() => tab.label.clone(), + Some(tab) => format!("Tab {}", tab.number), + None => tab_id.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::WorkspaceSlot; + use herdr_client::models::AgentStatus; + + fn workspace(id: &str, number: u32, label: &str) -> Workspace { + Workspace { + workspace_id: id.into(), + number, + label: label.into(), + focused: false, + agent_status: AgentStatus::Unknown, + } + } + + fn tab(id: &str, workspace_id: &str, number: u32, label: &str) -> Tab { + Tab { + tab_id: id.into(), + workspace_id: workspace_id.into(), + number, + label: label.into(), + focused: false, + agent_status: AgentStatus::Unknown, + } + } + + #[test] + fn keeps_available_tabs_and_reports_missing() { + let state = ZenState { + active: true, + workspaces: vec![ + WorkspaceSlot { workspace_id: "w1".into(), tab_ids: vec!["w1:t1".into(), "w1:t9".into()] }, + WorkspaceSlot { workspace_id: "w9".into(), tab_ids: vec!["w9:t1".into()] }, + ], + }; + let workspaces = vec![workspace("w1", 1, "Core")]; + let tabs = vec![tab("w1:t1", "w1", 1, "Claude")]; + let resolution = resolve(&state, &workspaces, &tabs, Some("w2:t1")); + assert_eq!(resolution.available_tab_ids, ["w1:t1"]); + assert_eq!(resolution.focus_tab_id.as_deref(), Some("w1:t1")); + assert!(resolution.missing.iter().any(|message| message.contains("w1:t9"))); + assert!(resolution.missing.iter().any(|message| message.contains("w9"))); + } + + #[test] + fn prefers_current_tab_when_it_is_in_the_shortlist() { + let state = ZenState { + active: true, + workspaces: vec![WorkspaceSlot { + workspace_id: "w1".into(), + tab_ids: vec!["w1:t1".into(), "w1:t2".into()], + }], + }; + let workspaces = vec![workspace("w1", 1, "Core")]; + let tabs = vec![tab("w1:t1", "w1", 1, "A"), tab("w1:t2", "w1", 2, "B")]; + let resolution = resolve(&state, &workspaces, &tabs, Some("w1:t2")); + assert_eq!(resolution.focus_tab_id.as_deref(), Some("w1:t2")); + } +} diff --git a/plugins/zen-mode/src/state.rs b/plugins/zen-mode/src/state.rs new file mode 100644 index 0000000..9a861cb --- /dev/null +++ b/plugins/zen-mode/src/state.rs @@ -0,0 +1,227 @@ +//! Persisted zen shortlist under `HERDR_PLUGIN_STATE_DIR/zen.toml`. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +pub const MAX_WORKSPACES: usize = 2; +pub const MAX_TABS_PER_WORKSPACE: usize = 3; + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct ZenState { + #[serde(default)] + pub active: bool, + #[serde(default)] + pub workspaces: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceSlot { + pub workspace_id: String, + #[serde(default)] + pub tab_ids: Vec, +} + +impl ZenState { + pub fn load(dir: &Path) -> Result { + let path = dir.join("zen.toml"); + if !path.exists() { + return Ok(Self::default()); + } + let text = std::fs::read_to_string(&path).map_err(|err| format!("read {}: {err}", path.display()))?; + let mut state: Self = toml::from_str(&text).map_err(|err| format!("parse {}: {err}", path.display()))?; + state.clamp(); + Ok(state) + } + + pub fn save(&self, dir: &Path) -> Result<(), String> { + let mut state = self.clone(); + state.clamp(); + std::fs::create_dir_all(dir).map_err(|err| format!("create {}: {err}", dir.display()))?; + let path = dir.join("zen.toml"); + let text = toml::to_string_pretty(&state).map_err(|err| format!("serialize zen.toml: {err}"))?; + std::fs::write(&path, text).map_err(|err| format!("write {}: {err}", path.display())) + } + + /// Enforces the shortlist limits and drops empty/duplicate ids. + pub fn clamp(&mut self) { + let mut seen_workspaces = Vec::new(); + self.workspaces.retain(|slot| { + if slot.workspace_id.is_empty() || seen_workspaces.contains(&slot.workspace_id) { + return false; + } + seen_workspaces.push(slot.workspace_id.clone()); + true + }); + self.workspaces.truncate(MAX_WORKSPACES); + for slot in &mut self.workspaces { + let mut seen_tabs = Vec::new(); + slot.tab_ids.retain(|tab_id| { + if tab_id.is_empty() || seen_tabs.contains(tab_id) { + return false; + } + seen_tabs.push(tab_id.clone()); + true + }); + slot.tab_ids.truncate(MAX_TABS_PER_WORKSPACE); + } + } + + pub fn set_workspace(&mut self, index: usize, workspace_id: String) { + if index >= MAX_WORKSPACES { + return; + } + while self.workspaces.len() <= index { + self.workspaces.push(WorkspaceSlot { workspace_id: String::new(), tab_ids: Vec::new() }); + } + self.workspaces[index] = WorkspaceSlot { workspace_id: workspace_id.clone(), tab_ids: Vec::new() }; + // Keep the slot we just wrote; drop any other copy of the same id or empties. + let mut kept_target = false; + self.workspaces.retain(|slot| { + if slot.workspace_id.is_empty() { + return false; + } + if slot.workspace_id != workspace_id { + return true; + } + if kept_target { + return false; + } + kept_target = true; + true + }); + self.clamp(); + } + + pub fn add_workspace(&mut self, workspace_id: String) -> Result<(), String> { + if self.workspaces.iter().any(|slot| slot.workspace_id == workspace_id) { + return Err("workspace already selected".into()); + } + if self.workspaces.len() >= MAX_WORKSPACES { + return Err(format!("at most {MAX_WORKSPACES} workspaces")); + } + self.workspaces.push(WorkspaceSlot { workspace_id, tab_ids: Vec::new() }); + Ok(()) + } + + pub fn remove_workspace(&mut self, index: usize) { + if index < self.workspaces.len() { + self.workspaces.remove(index); + } + } + + pub fn set_tab(&mut self, workspace_index: usize, tab_index: usize, tab_id: String) -> Result<(), String> { + let slot = self.workspaces.get_mut(workspace_index).ok_or_else(|| "workspace slot missing".to_string())?; + if tab_index > slot.tab_ids.len() || tab_index >= MAX_TABS_PER_WORKSPACE { + return Err(format!("at most {MAX_TABS_PER_WORKSPACE} tabs per workspace")); + } + if tab_index < slot.tab_ids.len() { + slot.tab_ids.remove(tab_index); + } + slot.tab_ids.retain(|id| id != &tab_id); + if slot.tab_ids.len() >= MAX_TABS_PER_WORKSPACE { + return Err(format!("at most {MAX_TABS_PER_WORKSPACE} tabs per workspace")); + } + let insert_at = tab_index.min(slot.tab_ids.len()); + slot.tab_ids.insert(insert_at, tab_id); + Ok(()) + } + + pub fn add_tab(&mut self, workspace_index: usize, tab_id: String) -> Result<(), String> { + let slot = self.workspaces.get_mut(workspace_index).ok_or_else(|| "workspace slot missing".to_string())?; + if slot.tab_ids.iter().any(|id| id == &tab_id) { + return Err("tab already selected".into()); + } + if slot.tab_ids.len() >= MAX_TABS_PER_WORKSPACE { + return Err(format!("at most {MAX_TABS_PER_WORKSPACE} tabs per workspace")); + } + slot.tab_ids.push(tab_id); + Ok(()) + } + + pub fn remove_tab(&mut self, workspace_index: usize, tab_index: usize) { + if let Some(slot) = self.workspaces.get_mut(workspace_index) + && tab_index < slot.tab_ids.len() + { + slot.tab_ids.remove(tab_index); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_file_loads_as_empty_inactive() { + let dir = tempfile::tempdir().unwrap(); + let state = ZenState::load(dir.path()).unwrap(); + assert_eq!(state, ZenState::default()); + assert!(!state.active); + } + + #[test] + fn round_trips_through_toml() { + let dir = tempfile::tempdir().unwrap(); + let state = ZenState { + active: true, + workspaces: vec![ + WorkspaceSlot { + workspace_id: "w1".into(), + tab_ids: vec!["w1:t1".into(), "w1:t2".into(), "w1:t3".into()], + }, + WorkspaceSlot { workspace_id: "w2".into(), tab_ids: vec!["w2:t1".into()] }, + ], + }; + state.save(dir.path()).unwrap(); + assert_eq!(ZenState::load(dir.path()).unwrap(), state); + } + + #[test] + fn clamp_enforces_limits_and_dedupes() { + let mut state = ZenState { + active: false, + workspaces: vec![ + WorkspaceSlot { + workspace_id: "w1".into(), + tab_ids: vec!["w1:t1".into(), "w1:t1".into(), "w1:t2".into(), "w1:t3".into(), "w1:t4".into()], + }, + WorkspaceSlot { workspace_id: "w1".into(), tab_ids: vec!["w1:t9".into()] }, + WorkspaceSlot { workspace_id: "w3".into(), tab_ids: vec!["w3:t1".into()] }, + ], + }; + state.clamp(); + assert_eq!(state.workspaces.len(), 2); + assert_eq!(state.workspaces[0].workspace_id, "w1"); + assert_eq!(state.workspaces[0].tab_ids, ["w1:t1", "w1:t2", "w1:t3"]); + assert_eq!(state.workspaces[1].workspace_id, "w3"); + } + + #[test] + fn replace_workspace_clears_its_tabs_and_keeps_the_other_slot() { + let mut state = ZenState { + active: false, + workspaces: vec![ + WorkspaceSlot { workspace_id: "w1".into(), tab_ids: vec!["w1:t1".into()] }, + WorkspaceSlot { workspace_id: "w2".into(), tab_ids: vec!["w2:t1".into()] }, + ], + }; + state.set_workspace(0, "w9".into()); + assert_eq!(state.workspaces[0].workspace_id, "w9"); + assert!(state.workspaces[0].tab_ids.is_empty()); + assert_eq!(state.workspaces[1].workspace_id, "w2"); + assert_eq!(state.workspaces[1].tab_ids, ["w2:t1"]); + } + + #[test] + fn add_workspace_and_tab_respect_caps() { + let mut state = ZenState::default(); + state.add_workspace("w1".into()).unwrap(); + state.add_workspace("w2".into()).unwrap(); + assert_eq!(state.add_workspace("w3".into()).unwrap_err(), "at most 2 workspaces"); + state.add_tab(0, "w1:t1".into()).unwrap(); + state.add_tab(0, "w1:t2".into()).unwrap(); + state.add_tab(0, "w1:t3".into()).unwrap(); + assert_eq!(state.add_tab(0, "w1:t4".into()).unwrap_err(), "at most 3 tabs per workspace"); + } +} diff --git a/plugins/zen-mode/src/testing.rs b/plugins/zen-mode/src/testing.rs new file mode 100644 index 0000000..cae2801 --- /dev/null +++ b/plugins/zen-mode/src/testing.rs @@ -0,0 +1,51 @@ +//! Test double for the herdr socket API. + +use std::collections::HashMap; +use std::sync::Mutex; + +use herdr_client::{Api, Error}; +use serde_json::Value; + +#[derive(Default)] +pub struct FakeApi { + responses: HashMap>, + calls: Mutex>, +} + +impl FakeApi { + pub fn new() -> Self { + Self::default() + } + + pub fn ok(mut self, method: &str, result: Value) -> Self { + self.responses.insert(method.to_string(), Ok(result)); + self + } + + #[allow(dead_code)] + pub fn err(mut self, method: &str, code: &str, message: &str) -> Self { + self.responses.insert(method.to_string(), Err((code.to_string(), message.to_string()))); + self + } + + pub fn calls(&self) -> Vec<(String, Value)> { + self.calls.lock().unwrap().clone() + } +} + +impl Api for FakeApi { + fn request(&self, method: &str, params: Value) -> Result { + self.calls.lock().unwrap().push((method.to_string(), params)); + match self.responses.get(method) { + Some(Ok(value)) => Ok(value.clone()), + Some(Err((code, message))) => { + Err(Error::Api { method: method.to_string(), code: code.clone(), message: message.clone() }) + } + None => Err(Error::Api { + method: method.to_string(), + code: "unknown_method".into(), + message: "not faked".into(), + }), + } + } +}