Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
14 changes: 14 additions & 0 deletions plugins/zen-mode/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions plugins/zen-mode/herdr-plugin.toml
Original file line number Diff line number Diff line change
@@ -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"]
134 changes: 134 additions & 0 deletions plugins/zen-mode/src/activate.rs
Original file line number Diff line number Diff line change
@@ -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<ToggleOutcome, String> {
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()));
}
}
Loading
Loading