From b1cf2b871f9b38736e2dd926b552ba7c934c233f Mon Sep 17 00:00:00 2001 From: pfigs Date: Mon, 11 May 2026 13:15:47 +0300 Subject: [PATCH 01/11] feat(session): carry cwd and command on Session struct Foundational change for the claudio PTY spawn-target work. The GUI needs cwd + command at PTY-spawn time, and the daemon now stores both alongside the existing fields. Assisted-By: Claude Code Pedro Silva --- src/session/session.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/session/session.rs b/src/session/session.rs index f2b8c96..9ce67a6 100644 --- a/src/session/session.rs +++ b/src/session/session.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use super::mode::SessionMode; /// Daemon-side session metadata. PTYs are owned by the GUI process. @@ -7,16 +9,26 @@ pub struct Session { pub mode: SessionMode, pub busy: bool, pub shell_mode: bool, + pub cwd: Option, + pub command: Option>, } impl Session { - pub fn new(id: String, name: String, mode: SessionMode) -> Self { + pub fn new( + id: String, + name: String, + mode: SessionMode, + cwd: Option, + command: Option>, + ) -> Self { Self { id, name, mode, busy: false, shell_mode: false, + cwd, + command, } } } From 888aeeaf81cc7f7b81033a7c193c0823140512c9 Mon Sep 17 00:00:00 2001 From: pfigs Date: Mon, 11 May 2026 13:17:56 +0300 Subject: [PATCH 02/11] feat(session): accept cwd and command in create_session Lets external callers request a PTY rooted at a specific directory with a specific entry command, instead of always inheriting cwd and execing the user's $SHELL with claude. Server call site passes None for both until the IPC surface widens in the next commit. Assisted-By: Claude Code Pedro Silva --- src/ipc/server.rs | 2 +- src/session/manager.rs | 41 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/ipc/server.rs b/src/ipc/server.rs index 947422e..c5509ff 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -211,7 +211,7 @@ async fn dispatch( } Request::New { name, mode } => { let mut mgr = manager.lock().await; - let session = mgr.create_session(name, mode); + let session = mgr.create_session(name, mode, None, None); let id = session.id.clone(); let name = session.name.clone(); let mode_str = session.mode.to_string(); diff --git a/src/session/manager.rs b/src/session/manager.rs index 445c87b..685b1b3 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -18,7 +18,13 @@ impl SessionManager { } } - pub fn create_session(&mut self, name: Option, mode: SessionMode) -> &Session { + pub fn create_session( + &mut self, + name: Option, + mode: SessionMode, + cwd: Option, + command: Option>, + ) -> &Session { let id = uuid::Uuid::new_v4().to_string()[..8].to_string(); let name = name.unwrap_or_else(|| { let mut idx = 0; @@ -31,7 +37,7 @@ impl SessionManager { } }); - let session = Session::new(id.clone(), name, mode); + let session = Session::new(id.clone(), name, mode, cwd, command); self.sessions.insert(id.clone(), session); // Auto-focus if this is the first session @@ -118,3 +124,34 @@ impl SessionManager { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn create_session_stores_cwd_and_command() { + let mut mgr = SessionManager::new(); + let cwd = PathBuf::from("/tmp/orchestrator-x"); + let cmd = vec!["claude".to_string(), "Read starter.md".to_string()]; + let session = mgr.create_session( + Some("feature-x".to_string()), + SessionMode::Listening, + Some(cwd.clone()), + Some(cmd.clone()), + ); + assert_eq!(session.cwd.as_ref(), Some(&cwd)); + assert_eq!(session.command.as_ref(), Some(&cmd)); + assert_eq!(session.name, "feature-x"); + assert!(matches!(session.mode, SessionMode::Listening)); + } + + #[test] + fn create_session_defaults_cwd_and_command_to_none() { + let mut mgr = SessionManager::new(); + let session = mgr.create_session(None, SessionMode::Speaking, None, None); + assert!(session.cwd.is_none()); + assert!(session.command.is_none()); + } +} From 7924d601ba313f2321b8f3cdc49500a7ec1c30fb Mon Sep 17 00:00:00 2001 From: pfigs Date: Mon, 11 May 2026 13:20:13 +0300 Subject: [PATCH 03/11] feat(ipc): extend Request::New with cwd and command Additive change. Old clients continue to work because both fields are serde-defaulted to None. Existing GUI/server/client call sites pass None until later commits wire real values through. Assisted-By: Claude Code Pedro Silva --- src/gui/app.rs | 12 ++++++++++++ src/ipc/client.rs | 11 ++++++++++- src/ipc/protocol.rs | 35 +++++++++++++++++++++++++++++++++++ src/ipc/server.rs | 2 +- 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/gui/app.rs b/src/gui/app.rs index c688ac1..c1dc72a 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -99,6 +99,8 @@ impl ClaudioApp { &Request::New { name: Some(ps.name), mode: SessionMode::Speaking, + cwd: None, + command: None, }, ); } @@ -258,6 +260,8 @@ impl ClaudioApp { &Request::New { name: None, mode: SessionMode::Speaking, + cwd: None, + command: None, }, ); }) @@ -279,6 +283,8 @@ impl ClaudioApp { &Request::New { name: None, mode: SessionMode::Speaking, + cwd: None, + command: None, }, ); }) @@ -519,6 +525,8 @@ impl ClaudioApp { &Request::New { name: Some(name), mode: SessionMode::Speaking, + cwd: None, + command: None, }, ); } @@ -839,6 +847,8 @@ impl ClaudioApp { &Request::New { name: None, mode: SessionMode::Speaking, + cwd: None, + command: None, }, ); }) @@ -937,6 +947,8 @@ impl ClaudioApp { &Request::New { name: Some(name), mode: SessionMode::Speaking, + cwd: None, + command: None, }, ) { tracing::error!("IPC send_command failed for worktree session: {e}"); diff --git a/src/ipc/client.rs b/src/ipc/client.rs index e58883f..9cd0d11 100644 --- a/src/ipc/client.rs +++ b/src/ipc/client.rs @@ -46,7 +46,16 @@ pub async fn send_new_session( name: Option, mode: SessionMode, ) -> Result { - let resp = send_request(socket_path, &Request::New { name, mode }).await?; + let resp = send_request( + socket_path, + &Request::New { + name, + mode, + cwd: None, + command: None, + }, + ) + .await?; match into_result(resp)? { ResponseData::Session(info) => Ok(info), _ => Err(anyhow::anyhow!("unexpected response")), diff --git a/src/ipc/protocol.rs b/src/ipc/protocol.rs index 180b0f1..71f27b9 100644 --- a/src/ipc/protocol.rs +++ b/src/ipc/protocol.rs @@ -11,6 +11,10 @@ pub enum Request { New { name: Option, mode: SessionMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + command: Option>, }, List, Focus { @@ -74,3 +78,34 @@ impl Response { Self::ok(ResponseData::Empty {}) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_request_serializes_cwd_and_command() { + let req = Request::New { + name: Some("feature-x".into()), + mode: SessionMode::Listening, + cwd: Some("/tmp/wt".into()), + command: Some(vec!["claude".into(), "Read starter.md".into()]), + }; + let json = serde_json::to_string(&req).unwrap(); + let parsed: Request = serde_json::from_str(&json).unwrap(); + assert!(matches!(parsed, Request::New { .. })); + } + + #[test] + fn new_request_back_compat_without_cwd_or_command() { + let json = r#"{"cmd":"new","name":"foo","mode":"speaking"}"#; + let parsed: Request = serde_json::from_str(json).unwrap(); + match parsed { + Request::New { cwd, command, .. } => { + assert!(cwd.is_none()); + assert!(command.is_none()); + } + _ => panic!("expected New"), + } + } +} diff --git a/src/ipc/server.rs b/src/ipc/server.rs index c5509ff..2cce63d 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -209,7 +209,7 @@ async fn dispatch( let _ = shutdown_tx.send(true); Response::ok_empty() } - Request::New { name, mode } => { + Request::New { name, mode, .. } => { let mut mgr = manager.lock().await; let session = mgr.create_session(name, mode, None, None); let id = session.id.clone(); From f2104d66c62953c694ba0bd80aafda29dcb69f07 Mon Sep 17 00:00:00 2001 From: pfigs Date: Mon, 11 May 2026 13:23:32 +0300 Subject: [PATCH 04/11] feat(ipc): wire cwd and command end to end through New requests Extends Request::New, server dispatch, send_new_session, and the SessionCreated event so the GUI can spawn a PTY rooted at a specific cwd running a specific argv when the daemon is asked from the CLI. External callers (orchestrator, scripts) now have parity with the GUI's own NewSession / NewSessionInDir flows. Existing GUI flows push None onto pending_commands to keep the FIFO queues in lock-step. Assisted-By: Claude Code Pedro Silva --- src/gui/app.rs | 31 ++++++++++++++++++++++++++++--- src/gui/session_state.rs | 33 ++++++++++++++++++++++++++++----- src/ipc/client.rs | 6 ++++-- src/ipc/events.rs | 4 ++++ src/ipc/server.rs | 11 +++++++++-- 5 files changed, 73 insertions(+), 12 deletions(-) diff --git a/src/gui/app.rs b/src/gui/app.rs index c1dc72a..f69be10 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -27,6 +27,7 @@ pub struct ClaudioApp { pub file_tree: FileTree, pending_session_cwds: VecDeque, pending_shell_modes: VecDeque, + pending_commands: VecDeque>>, pub renaming_session_id: Option, pub rename_input: String, pub pending_worktree_repo: Option, @@ -82,6 +83,7 @@ impl ClaudioApp { // Restore sessions from previous run let mut pending_session_cwds = VecDeque::new(); let mut pending_shell_modes = VecDeque::new(); + let mut pending_commands: VecDeque>> = VecDeque::new(); let restore_socket = socket_path.to_path_buf(); let persisted_sessions = state.gui.sessions.clone(); if !persisted_sessions.is_empty() { @@ -90,6 +92,7 @@ impl ClaudioApp { for ps in &persisted_sessions { pending_session_cwds.push_back(ps.cwd.clone().unwrap_or_else(|| default_cwd.clone())); pending_shell_modes.push_back(ps.shell_mode); + pending_commands.push_back(None); } cx.background_executor() .spawn(async move { @@ -120,6 +123,7 @@ impl ClaudioApp { file_tree, pending_session_cwds, pending_shell_modes, + pending_commands, renaming_session_id: None, rename_input: String::new(), pending_worktree_repo: None, @@ -165,11 +169,27 @@ impl ClaudioApp { } cx.notify(); } - DaemonEvent::SessionCreated { session } => { + DaemonEvent::SessionCreated { + session, + cwd, + command, + } => { if !self.sessions.iter().any(|s| s.id == session.id) { - let cwd = self.pending_session_cwds.pop_front(); + // Prefer event-supplied cwd/command (external CLI caller) + // over the queue (GUI-initiated). Queues are FIFO matched + // against GUI-issued New requests. + let cwd = cwd.or_else(|| self.pending_session_cwds.pop_front()); + let command = + command.or_else(|| self.pending_commands.pop_front().flatten()); let shell_mode = self.pending_shell_modes.pop_front().unwrap_or(false); - let state = SessionState::new_with_cwd(session, cwd, shell_mode, self.socket_path.clone(), cx); + let state = SessionState::new_with_cwd( + session, + cwd, + shell_mode, + command, + self.socket_path.clone(), + cx, + ); self.sessions.push(state); self.save_state(); } @@ -252,6 +272,7 @@ impl ClaudioApp { fn new_session(&mut self, _: &NewSession, _window: &mut Window, cx: &mut Context) { self.pending_session_cwds.push_back(self.default_cwd()); self.pending_shell_modes.push_back(false); + self.pending_commands.push_back(None); let socket = self.socket_path.clone(); cx.background_executor() .spawn(async move { @@ -275,6 +296,7 @@ impl ClaudioApp { pub fn create_shell_session(&mut self, cx: &mut Context) { self.pending_session_cwds.push_back(self.default_cwd()); self.pending_shell_modes.push_back(true); + self.pending_commands.push_back(None); let socket = self.socket_path.clone(); cx.background_executor() .spawn(async move { @@ -514,6 +536,7 @@ impl ClaudioApp { ps.cwd.clone().unwrap_or_else(|| default_cwd.clone()), ); app.pending_shell_modes.push_back(ps.shell_mode); + app.pending_commands.push_back(None); } }); let create_socket = socket.clone(); @@ -839,6 +862,7 @@ impl ClaudioApp { pub fn new_session_in_dir(&mut self, dir: PathBuf, cx: &mut Context) { self.pending_session_cwds.push_back(dir); self.pending_shell_modes.push_back(false); + self.pending_commands.push_back(None); let socket = self.socket_path.clone(); cx.background_executor() .spawn(async move { @@ -889,6 +913,7 @@ impl ClaudioApp { self.pending_session_cwds.push_back(worktree_dir.clone()); self.pending_shell_modes.push_back(false); + self.pending_commands.push_back(None); cx.spawn(async move |this: gpui::WeakEntity, cx: &mut gpui::AsyncApp| { let git_ok = cx.background_executor() diff --git a/src/gui/session_state.rs b/src/gui/session_state.rs index b174aac..cf846c9 100644 --- a/src/gui/session_state.rs +++ b/src/gui/session_state.rs @@ -233,6 +233,7 @@ impl SessionState { info: SessionInfo, cwd: Option, shell_mode: bool, + command: Option>, socket_path: PathBuf, cx: &mut gpui::Context, ) -> Self { @@ -247,12 +248,23 @@ impl SessionState { .expect("Failed to create PTY"); let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); - let mut cmd = CommandBuilder::new(&shell); - if shell_mode { - cmd.args(["-c", &format!("exec {}", shell)]); + let mut cmd = if let Some(ref argv) = command { + // External caller supplied a literal argv. Run it via the + // user's shell so the user gets a shell after the command + // exits -- matching the claude/exec $SHELL behaviour below. + let joined = shell_quote_argv(argv); + let mut c = CommandBuilder::new(&shell); + c.args(["-c", &format!("{joined}; exec {shell}")]); + c + } else if shell_mode { + let mut c = CommandBuilder::new(&shell); + c.args(["-c", &format!("exec {}", shell)]); + c } else { - cmd.args(["-c", &format!("claude; exec {}", shell)]); - } + let mut c = CommandBuilder::new(&shell); + c.args(["-c", &format!("claude; exec {}", shell)]); + c + }; cmd.env("TERM", "xterm-256color"); cmd.env("COLORTERM", "truecolor"); if let Some(ref dir) = cwd { @@ -356,6 +368,17 @@ impl SessionState { } } +/// Quote each argv element for a single `sh -c` line. +fn shell_quote_argv(argv: &[String]) -> String { + argv.iter() + .map(|a| { + let escaped = a.replace('\'', "'\\''"); + format!("'{escaped}'") + }) + .collect::>() + .join(" ") +} + fn default_palette() -> ColorPalette { ColorPalette::builder() .background(0x1e, 0x1e, 0x2e) diff --git a/src/ipc/client.rs b/src/ipc/client.rs index 9cd0d11..124fe89 100644 --- a/src/ipc/client.rs +++ b/src/ipc/client.rs @@ -45,14 +45,16 @@ pub async fn send_new_session( socket_path: &Path, name: Option, mode: SessionMode, + cwd: Option, + command: Option>, ) -> Result { let resp = send_request( socket_path, &Request::New { name, mode, - cwd: None, - command: None, + cwd, + command, }, ) .await?; diff --git a/src/ipc/events.rs b/src/ipc/events.rs index 56056c2..41ec8ee 100644 --- a/src/ipc/events.rs +++ b/src/ipc/events.rs @@ -37,6 +37,10 @@ pub enum DaemonEvent { }, SessionCreated { session: SessionInfo, + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + command: Option>, }, SessionDestroyed { session_id: String, diff --git a/src/ipc/server.rs b/src/ipc/server.rs index 2cce63d..d6d1edd 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -209,9 +209,14 @@ async fn dispatch( let _ = shutdown_tx.send(true); Response::ok_empty() } - Request::New { name, mode, .. } => { + Request::New { + name, + mode, + cwd, + command, + } => { let mut mgr = manager.lock().await; - let session = mgr.create_session(name, mode, None, None); + let session = mgr.create_session(name, mode, cwd.clone(), command.clone()); let id = session.id.clone(); let name = session.name.clone(); let mode_str = session.mode.to_string(); @@ -225,6 +230,8 @@ async fn dispatch( }; let _ = event_tx.send(DaemonEvent::SessionCreated { session: info.clone(), + cwd, + command, }); Response::ok(ResponseData::Session(info)) } From 4f550f6517bf387e69c4389f1cc9dc59a21d4323 Mon Sep 17 00:00:00 2001 From: pfigs Date: Mon, 11 May 2026 13:23:41 +0300 Subject: [PATCH 05/11] feat(cli): claudio new accepts --cwd and trailing command Mirrors the new IPC surface so external callers (orchestrator skill, scripts) can ask the daemon to spawn a PTY rooted in a worktree running a specific argv -- e.g. claudio new --name x --cwd /path --mode listening -- claude "Read starter.md and follow it." Assisted-By: Claude Code Pedro Silva --- src/cli.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/cli.rs b/src/cli.rs index de728ff..cb67fb2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -35,6 +35,14 @@ pub enum Command { /// Session mode #[arg(short, long, default_value = "speaking")] mode: SessionMode, + /// Working directory for the new session's PTY + #[arg(long)] + cwd: Option, + /// Command and args to run in the PTY (everything after `--`). + /// Example: claudio new --cwd /tmp -- claude "Read foo.md" + /// If omitted, the existing default (claude; exec $SHELL) is used. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + command: Vec, }, /// List all sessions List, @@ -103,12 +111,24 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> { let config = crate::config::Config::load()?; crate::gui::run(&config.socket_path()) } - Some(Command::New { name, mode }) => { + Some(Command::New { + name, + mode, + cwd, + command, + }) => { let config = crate::config::Config::load()?; + let cmd = if command.is_empty() { + None + } else { + Some(command) + }; let resp = crate::ipc::client::send_new_session( &config.socket_path(), name, mode, + cwd, + cmd, ) .await?; println!("Created session: {} ({})", resp.name, resp.id); From 07af3acafaa0cc9b21467a5b23e1806a4c3d043a Mon Sep 17 00:00:00 2001 From: pfigs Date: Mon, 11 May 2026 13:25:29 +0300 Subject: [PATCH 06/11] test(ipc): cover Request::New round trip with cwd and command Adds a Unix-socket round-trip test that asserts the IPC client forwards cwd+command verbatim, plus a manager-level test covering the daemon-side store. Promotes ipc/session modules to a lib target so integration tests can reach them. Assisted-By: Claude Code Pedro Silva --- Cargo.toml | 7 +++ src/lib.rs | 2 + tests/cli_new_with_command.rs | 89 +++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 src/lib.rs create mode 100644 tests/cli_new_with_command.rs diff --git a/Cargo.toml b/Cargo.toml index c4efef3..865f082 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,10 @@ authors = ["PFigs"] keywords = ["voice", "terminal", "claude", "tts", "stt"] categories = ["command-line-utilities"] +[lib] +name = "ok_claude" +path = "src/lib.rs" + [[bin]] name = "claudio" path = "src/main.rs" @@ -34,3 +38,6 @@ gpui = "0.2" gpui-terminal = { path = "gpui-terminal" } flume = "0.11" strip-ansi-escapes = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c8f0623 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,2 @@ +pub mod ipc; +pub mod session; diff --git a/tests/cli_new_with_command.rs b/tests/cli_new_with_command.rs new file mode 100644 index 0000000..42b1e74 --- /dev/null +++ b/tests/cli_new_with_command.rs @@ -0,0 +1,89 @@ +//! Integration test: `claudio new --cwd -- ` +//! +//! Verifies the daemon-side manager records cwd + command on the +//! Session, and that the IPC client round-trips both fields through +//! a Unix socket using the new Request::New schema. + +use std::path::PathBuf; +use std::time::Duration; + +use ok_claude::ipc::client::send_new_session; +use ok_claude::session::manager::SessionManager; +use ok_claude::session::mode::SessionMode; + +#[tokio::test] +async fn create_session_with_cwd_and_command_via_manager() { + let mut mgr = SessionManager::new(); + let session = mgr.create_session( + Some("orch-feature".to_string()), + SessionMode::Listening, + Some(PathBuf::from("/tmp")), + Some(vec!["echo".to_string(), "hello".to_string()]), + ); + assert_eq!(session.cwd.as_deref(), Some(std::path::Path::new("/tmp"))); + assert_eq!( + session.command.as_deref(), + Some(["echo".to_string(), "hello".to_string()].as_slice()) + ); + assert!(matches!(session.mode, SessionMode::Listening)); +} + +#[tokio::test] +async fn send_new_session_round_trip_through_unix_socket() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::UnixListener; + + let dir = tempfile::tempdir().expect("tempdir"); + let socket_path = dir.path().join("test.sock"); + + let listener = UnixListener::bind(&socket_path).unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + let req: serde_json::Value = serde_json::from_str(line.trim()).unwrap(); + let cwd = req["cwd"].as_str().unwrap_or("").to_string(); + let cmd = req["command"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join("|") + }) + .unwrap_or_default(); + let resp = serde_json::json!({ + "status": "ok", + "data": { + "type": "session", + "id": "abcd1234", + "name": format!("{cwd}::{cmd}"), + "mode": "listening", + "focused": true, + "busy": false, + } + }); + let s = serde_json::to_string(&resp).unwrap(); + writer.write_all(s.as_bytes()).await.unwrap(); + writer.write_all(b"\n").await.unwrap(); + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + + let info = send_new_session( + &socket_path, + Some("orch".to_string()), + SessionMode::Listening, + Some(PathBuf::from("/tmp/wt")), + Some(vec!["claude".to_string(), "Read starter.md".to_string()]), + ) + .await + .expect("send_new_session"); + + server.await.unwrap(); + + assert_eq!(info.name, "/tmp/wt::claude|Read starter.md"); + assert_eq!(info.mode, "listening"); +} From 8c585bbc6f0db266c3680c64f6efc2c6158f059a Mon Sep 17 00:00:00 2001 From: pfigs Date: Mon, 11 May 2026 13:29:15 +0300 Subject: [PATCH 07/11] feat(gui): introduce activity bar with Files / Orchestrator slots Adds a 48px left rail for switching between sidebar activities, replacing the single-purpose file-tree visibility toggle on the status bar. Sidebar pane content now swaps based on the selected activity. Orchestrator view is stubbed until the next commit. Assisted-By: Claude Code Pedro Silva --- src/gui/activity_bar.rs | 79 +++++++++++++++++++++++++++++++++++++++++ src/gui/app.rs | 55 ++++++++++++++++++++++------ src/gui/mod.rs | 1 + src/gui/status_bar.rs | 20 +---------- 4 files changed, 125 insertions(+), 30 deletions(-) create mode 100644 src/gui/activity_bar.rs diff --git a/src/gui/activity_bar.rs b/src/gui/activity_bar.rs new file mode 100644 index 0000000..d8e178b --- /dev/null +++ b/src/gui/activity_bar.rs @@ -0,0 +1,79 @@ +use gpui::*; + +use super::app::ClaudioApp; +use super::theme; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Activity { + Files, + Orchestrator, +} + +impl Activity { + pub fn label(self) -> &'static str { + match self { + Self::Files => "Files", + Self::Orchestrator => "Orchestrator", + } + } + + /// Single-character icon. ASCII-friendly to avoid font fallback paths. + pub fn icon(self) -> &'static str { + match self { + Self::Files => "F", + Self::Orchestrator => "O", + } + } + + pub const ALL: [Activity; 2] = [Activity::Files, Activity::Orchestrator]; +} + +impl ClaudioApp { + pub fn render_activity_bar( + &self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let mut bar = div() + .w(px(48.0)) + .h_full() + .flex() + .flex_col() + .items_center() + .py(px(8.0)) + .gap(px(4.0)) + .bg(rgb(theme::CRUST)); + + for activity in Activity::ALL { + let is_active = self.active_activity == activity; + let bg = if is_active { theme::SURFACE0 } else { theme::CRUST }; + let fg = if is_active { theme::BLUE } else { theme::OVERLAY0 }; + let id: SharedString = format!("activity-{}", activity.label()).into(); + + bar = bar.child( + div() + .id(id) + .w(px(40.0)) + .h(px(40.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.0)) + .bg(rgb(bg)) + .text_color(rgb(fg)) + .text_size(px(16.0)) + .cursor_pointer() + .hover(|s| s.bg(rgb(theme::SURFACE0))) + .child(activity.icon()) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |app, _ev: &MouseDownEvent, _window, cx| { + app.set_active_activity(activity, cx); + }), + ), + ); + } + + bar + } +} diff --git a/src/gui/app.rs b/src/gui/app.rs index f69be10..68a57e1 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -37,6 +37,7 @@ pub struct ClaudioApp { pub grid_col_ratios: Vec, pub grid_row_ratios: Vec, pub grid_resize: Option, + pub active_activity: super::activity_bar::Activity, } impl ClaudioApp { @@ -133,9 +134,19 @@ impl ClaudioApp { grid_col_ratios: Vec::new(), grid_row_ratios: Vec::new(), grid_resize: None, + active_activity: super::activity_bar::Activity::Files, } } + pub fn set_active_activity( + &mut self, + activity: super::activity_bar::Activity, + cx: &mut Context, + ) { + self.active_activity = activity; + cx.notify(); + } + fn handle_daemon_event(&mut self, event: DaemonEvent, cx: &mut Context) { match event { DaemonEvent::Snapshot { @@ -991,6 +1002,18 @@ impl ClaudioApp { } impl ClaudioApp { + pub fn render_orchestrator_sidebar( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> AnyElement { + div() + .w(px(self.file_tree_width)) + .h_full() + .child("Orchestrator (coming soon)") + .into_any_element() + } + fn render_resize_borders(&self, cx: &mut Context) -> impl IntoElement { let border = px(6.0); let corner = px(12.0); @@ -1129,11 +1152,23 @@ impl Render for ClaudioApp { app.grid_resize = None; })) .child({ - let mut content = div().flex_1().min_h(px(0.0)).flex().flex_row(); - if self.file_tree.visible { - content = content.child(self.render_file_tree(window, cx)); - // Resize handle - content = content.child( + let sidebar_pane: AnyElement = match self.active_activity { + super::activity_bar::Activity::Files => { + self.render_file_tree(window, cx) + } + super::activity_bar::Activity::Orchestrator => { + self.render_orchestrator_sidebar(window, cx) + } + }; + + div() + .flex_1() + .min_h(px(0.0)) + .flex() + .flex_row() + .child(self.render_activity_bar(window, cx)) + .child(sidebar_pane) + .child( div() .w(px(4.0)) .h_full() @@ -1143,12 +1178,10 @@ impl Render for ClaudioApp { .on_mouse_down(MouseButton::Left, cx.listener(|app, _ev: &MouseDownEvent, _window, _cx| { app.file_tree_resizing = true; })), - ); - } - content = content.child( - div().flex_1().child(self.render_terminal_grid(window, cx)), - ); - content + ) + .child( + div().flex_1().child(self.render_terminal_grid(window, cx)), + ) }) .child(self.render_resize_borders(cx)) } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index c2cbf15..50a2e5a 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1,3 +1,4 @@ +mod activity_bar; mod app; mod file_tree; mod ipc_bridge; diff --git a/src/gui/status_bar.rs b/src/gui/status_bar.rs index 3dd0f87..34ca430 100644 --- a/src/gui/status_bar.rs +++ b/src/gui/status_bar.rs @@ -14,25 +14,7 @@ impl ClaudioApp { .items_center() .bg(rgb(theme::MANTLE)) .px(px(12.0)) - .gap(px(8.0)) - .child( - div() - .id("sidebar-toggle") - .child("claudio") - .text_color(if self.file_tree.visible { - rgb(theme::BLUE) - } else { - rgb(theme::TEXT) - }) - .text_size(px(14.0)) - .cursor_pointer() - .hover(|s| s.text_color(rgb(theme::BLUE))) - .on_mouse_down(MouseButton::Left, cx.listener(|app, _ev, _window, cx| { - app.file_tree.toggle_visible(); - cx.notify(); - })), - ) - .child(div().w(px(16.0))); + .gap(px(8.0)); // Session pills for session in &self.sessions { From 3dbf5994cd358ba9fc2e4f6513b752f7c0bb3039 Mon Sep 17 00:00:00 2001 From: pfigs Date: Mon, 11 May 2026 13:31:14 +0300 Subject: [PATCH 08/11] feat(gui): add orchestrator state index parser Walks ~/.claude/orchestrator/*/*/STATUS.md, parses YAML frontmatter, classifies handoff state. Pure functions, fully unit tested. Will be consumed by the orchestrator sidebar widget in the next commit. Assisted-By: Claude Code Pedro Silva --- Cargo.lock | 70 +++++++++++- Cargo.toml | 3 + src/gui/mod.rs | 1 + src/gui/orchestrator_index.rs | 200 ++++++++++++++++++++++++++++++++++ src/lib.rs | 6 + tests/orchestrator_parser.rs | 60 ++++++++++ 6 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 src/gui/orchestrator_index.rs create mode 100644 tests/orchestrator_parser.rs diff --git a/Cargo.lock b/Cargo.lock index 3345de3..d2a98dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,6 +107,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -960,6 +969,18 @@ dependencies = [ "libc", ] +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -2866,6 +2887,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.61.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -4043,22 +4088,26 @@ dependencies = [ [[package]] name = "ok_claude" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "byteorder", + "chrono", "clap", "cpal", "dirs 6.0.0", "evdev", "flume 0.11.1", + "glob", "gpui", "gpui-terminal", "libc", "portable-pty", "serde", "serde_json", + "serde_yaml", "strip-ansi-escapes", + "tempfile", "tokio", "toml 0.8.23", "tracing", @@ -5348,6 +5397,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial2" version = "0.2.34" @@ -6435,6 +6497,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 865f082..5c7eda6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,9 @@ gpui = "0.2" gpui-terminal = { path = "gpui-terminal" } flume = "0.11" strip-ansi-escapes = "0.2" +serde_yaml = "0.9" +chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] } +glob = "0.3" [dev-dependencies] tempfile = "3" diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 50a2e5a..2176561 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -2,6 +2,7 @@ mod activity_bar; mod app; mod file_tree; mod ipc_bridge; +pub mod orchestrator_index; mod session_state; mod status_bar; mod terminal_grid; diff --git a/src/gui/orchestrator_index.rs b/src/gui/orchestrator_index.rs new file mode 100644 index 0000000..5bc10fa --- /dev/null +++ b/src/gui/orchestrator_index.rs @@ -0,0 +1,200 @@ +//! Pure parsing + filesystem walk for ~/.claude/orchestrator state. +//! No GPUI dependency on purpose -- everything here is unit-testable. + +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use serde::Deserialize; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HandoffStatus { + None, + Blocker, + Question, + Done, +} + +impl HandoffStatus { + /// Classify a handoff.md body. Looks at the *last* `## ` heading. + /// Empty/whitespace body -> None. + pub fn classify(body: &str) -> Self { + if body.trim().is_empty() { + return Self::None; + } + let mut latest = Self::None; + for line in body.lines() { + let trimmed = line.trim().to_ascii_lowercase(); + if let Some(rest) = trimmed.strip_prefix("## ") { + latest = match rest.trim() { + "blocker" => Self::Blocker, + "question" => Self::Question, + "done" | "completed" | "complete" => Self::Done, + _ => latest, + }; + } + } + latest + } +} + +#[derive(Debug, Deserialize)] +struct Frontmatter { + state: Option, + updated_at: Option, + branch: Option, + base_branch: Option, + worktree: Option, + pr: Option, +} + +#[derive(Debug, Clone)] +pub struct FeatureSummary { + pub repo: String, + pub feature_slug: String, + pub status_path: PathBuf, + pub state: String, + pub updated_at: Option, + pub branch: Option, + pub base_branch: Option, + pub worktree: Option, + pub pr: Option, + pub last_log_line: Option, + pub handoff: HandoffStatus, +} + +impl FeatureSummary { + pub fn is_completed(&self) -> bool { + matches!( + self.state.as_str(), + "done" | "completed" | "complete" | "merged" | "shipped" + ) + } +} + +/// Parse a STATUS.md text body. The path is used to derive the repo name +/// and feature slug -- expected layout is ...///STATUS.md. +pub fn parse_status_text(text: &str, path: &Path) -> Result { + let stripped = strip_frontmatter(text) + .ok_or_else(|| anyhow!("missing YAML frontmatter in {}", path.display()))?; + + let fm: Frontmatter = serde_yaml::from_str(stripped.frontmatter) + .with_context(|| format!("parsing frontmatter of {}", path.display()))?; + + let last_log_line = stripped + .body + .lines() + .filter(|l| !l.trim().is_empty()) + .last() + .map(|l| l.trim_start_matches("- ").trim().to_string()); + + let (repo, feature_slug) = derive_repo_and_feature(path) + .with_context(|| format!("deriving repo/feature from {}", path.display()))?; + + Ok(FeatureSummary { + repo, + feature_slug, + status_path: path.to_path_buf(), + state: fm.state.unwrap_or_else(|| "unknown".to_string()), + updated_at: fm.updated_at, + branch: fm.branch, + base_branch: fm.base_branch, + worktree: fm.worktree, + pr: fm.pr, + last_log_line, + handoff: HandoffStatus::None, + }) +} + +struct Stripped<'a> { + frontmatter: &'a str, + body: &'a str, +} + +fn strip_frontmatter(text: &str) -> Option> { + let trimmed = text.trim_start_matches('\u{feff}'); + if !trimmed.starts_with("---") { + return None; + } + let after_first = &trimmed[3..].trim_start_matches('\n'); + let end = after_first.find("\n---")?; + let frontmatter = &after_first[..end]; + let body = after_first[end + 4..].trim_start_matches('\n'); + Some(Stripped { frontmatter, body }) +} + +/// Expects path like `///STATUS.md`. +fn derive_repo_and_feature(path: &Path) -> Result<(String, String)> { + let mut comps: Vec<&std::ffi::OsStr> = + path.components().map(|c| c.as_os_str()).collect(); + if comps.last().map(|s| s == &std::ffi::OsStr::new("STATUS.md")) != Some(true) { + return Err(anyhow!("expected path to end in STATUS.md")); + } + comps.pop(); + let feature = comps + .pop() + .ok_or_else(|| anyhow!("missing feature dir"))? + .to_string_lossy() + .to_string(); + let repo = comps + .pop() + .ok_or_else(|| anyhow!("missing repo dir"))? + .to_string_lossy() + .to_string(); + Ok((repo, feature)) +} + +/// Walk `~/.claude/orchestrator/*/*/STATUS.md` and return parsed summaries, +/// sorted by `updated_at` descending. Failures on individual files are +/// logged via tracing and skipped, not propagated -- one bad file should +/// not blank the whole sidebar. +pub fn scan_all() -> Vec { + let home = match dirs::home_dir() { + Some(h) => h, + None => return Vec::new(), + }; + let pattern = home.join(".claude/orchestrator/*/*/STATUS.md"); + let pattern_str = pattern.to_string_lossy().to_string(); + + let mut summaries = Vec::new(); + let entries = match glob::glob(&pattern_str) { + Ok(e) => e, + Err(e) => { + tracing::warn!("orchestrator glob failed: {e}"); + return summaries; + } + }; + + for entry in entries.flatten() { + let text = match std::fs::read_to_string(&entry) { + Ok(t) => t, + Err(e) => { + tracing::debug!("orchestrator: skip {}: {e}", entry.display()); + continue; + } + }; + let mut summary = match parse_status_text(&text, &entry) { + Ok(s) => s, + Err(e) => { + tracing::debug!("orchestrator: parse fail {}: {e}", entry.display()); + continue; + } + }; + + let handoff_path = entry + .parent() + .map(|p| p.join("handoff.md")) + .unwrap_or_default(); + let handoff_text = std::fs::read_to_string(&handoff_path).unwrap_or_default(); + summary.handoff = HandoffStatus::classify(&handoff_text); + + summaries.push(summary); + } + + summaries.sort_by(|a, b| { + b.updated_at + .as_deref() + .unwrap_or("") + .cmp(a.updated_at.as_deref().unwrap_or("")) + }); + summaries +} diff --git a/src/lib.rs b/src/lib.rs index c8f0623..e65125b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,8 @@ pub mod ipc; pub mod session; + +/// Subset of the binary's `gui` module that is decoupled from GPUI and +/// can be exercised by integration tests. +pub mod gui { + pub mod orchestrator_index; +} diff --git a/tests/orchestrator_parser.rs b/tests/orchestrator_parser.rs new file mode 100644 index 0000000..ee55fb7 --- /dev/null +++ b/tests/orchestrator_parser.rs @@ -0,0 +1,60 @@ +use ok_claude::gui::orchestrator_index::{parse_status_text, HandoffStatus}; +use std::path::PathBuf; + +#[test] +fn parses_minimal_status() { + let body = r#"--- +state: in_progress +updated_at: 2026-05-11T13:00:00Z +branch: feat-x +worktree: /tmp/wt +--- +- 2026-05-11T13:00:00Z orchestrator created feature dir +- 2026-05-11T13:05:00Z spawned via claudio +"#; + + let parsed = parse_status_text( + body, + "/home/me/.claude/orchestrator/repo/2026-05-11-feat-x/STATUS.md".as_ref(), + ) + .expect("parses"); + assert_eq!(parsed.feature_slug, "2026-05-11-feat-x"); + assert_eq!(parsed.repo, "repo"); + assert_eq!(parsed.state, "in_progress"); + assert_eq!(parsed.branch.as_deref(), Some("feat-x")); + assert_eq!(parsed.worktree, Some(PathBuf::from("/tmp/wt"))); + assert_eq!( + parsed.last_log_line.as_deref(), + Some("2026-05-11T13:05:00Z spawned via claudio") + ); +} + +#[test] +fn missing_frontmatter_is_an_error() { + let body = "no frontmatter here\n"; + assert!(parse_status_text(body, "/x/repo/feat/STATUS.md".as_ref()).is_err()); +} + +#[test] +fn classify_handoff_blocker() { + let body = "## blocker\nThe build is broken because of X.\n"; + assert_eq!(HandoffStatus::classify(body), HandoffStatus::Blocker); +} + +#[test] +fn classify_handoff_question() { + let body = "## question\nShould I bump the dep?\n"; + assert_eq!(HandoffStatus::classify(body), HandoffStatus::Question); +} + +#[test] +fn classify_handoff_empty() { + assert_eq!(HandoffStatus::classify(""), HandoffStatus::None); + assert_eq!(HandoffStatus::classify(" \n "), HandoffStatus::None); +} + +#[test] +fn classify_handoff_done() { + let body = "## done\nMerged in #123.\n"; + assert_eq!(HandoffStatus::classify(body), HandoffStatus::Done); +} From 15e07b1d0b263fae5497ca92f4e1cc9dec1dc157 Mon Sep 17 00:00:00 2001 From: pfigs Date: Mon, 11 May 2026 13:33:11 +0300 Subject: [PATCH 09/11] feat(gui): orchestrator sidebar with live STATUS.md polling Polls ~/.claude/orchestrator state every 2s, renders one row per feature with state badge, handoff indicator, and last log line. Click focuses the matching PTY when present, otherwise spawns a new claudio session in the recorded worktree running `claude "Read and follow it."`. Assisted-By: Claude Code Pedro Silva --- src/gui/app.rs | 26 ++- src/gui/mod.rs | 9 +- src/gui/orchestrator_sidebar.rs | 296 ++++++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+), 17 deletions(-) create mode 100644 src/gui/orchestrator_sidebar.rs diff --git a/src/gui/app.rs b/src/gui/app.rs index 68a57e1..6affd62 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -21,13 +21,13 @@ pub struct ClaudioApp { pub ptt_active: bool, pub vad_speaking: bool, pub last_transcription: Option, - socket_path: PathBuf, + pub(super) socket_path: PathBuf, focus_handle: FocusHandle, needs_focus_sync: bool, pub file_tree: FileTree, - pending_session_cwds: VecDeque, - pending_shell_modes: VecDeque, - pending_commands: VecDeque>>, + pub(super) pending_session_cwds: VecDeque, + pub(super) pending_shell_modes: VecDeque, + pub(super) pending_commands: VecDeque>>, pub renaming_session_id: Option, pub rename_input: String, pub pending_worktree_repo: Option, @@ -38,6 +38,7 @@ pub struct ClaudioApp { pub grid_row_ratios: Vec, pub grid_resize: Option, pub active_activity: super::activity_bar::Activity, + pub orchestrator: super::orchestrator_sidebar::OrchestratorState, } impl ClaudioApp { @@ -135,9 +136,14 @@ impl ClaudioApp { grid_row_ratios: Vec::new(), grid_resize: None, active_activity: super::activity_bar::Activity::Files, + orchestrator: super::orchestrator_sidebar::OrchestratorState::new(), } } + pub fn on_mount(&self, cx: &mut Context) { + self.start_orchestrator_poll(cx); + } + pub fn set_active_activity( &mut self, activity: super::activity_bar::Activity, @@ -1002,18 +1008,6 @@ impl ClaudioApp { } impl ClaudioApp { - pub fn render_orchestrator_sidebar( - &self, - _window: &mut Window, - _cx: &mut Context, - ) -> AnyElement { - div() - .w(px(self.file_tree_width)) - .h_full() - .child("Orchestrator (coming soon)") - .into_any_element() - } - fn render_resize_borders(&self, cx: &mut Context) -> impl IntoElement { let border = px(6.0); let corner = px(12.0); diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 2176561..4d4bd83 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -3,6 +3,7 @@ mod app; mod file_tree; mod ipc_bridge; pub mod orchestrator_index; +mod orchestrator_sidebar; mod session_state; mod status_bar; mod terminal_grid; @@ -35,7 +36,13 @@ pub fn run(socket_path: &Path) -> Result<()> { app_id: Some("claudio".into()), ..Default::default() }, - |window, cx| cx.new(|cx| ClaudioApp::new(&socket, window, cx)), + |window, cx| { + cx.new(|cx| { + let app = ClaudioApp::new(&socket, window, cx); + app.on_mount(cx); + app + }) + }, ) .expect("Failed to open window"); }); diff --git a/src/gui/orchestrator_sidebar.rs b/src/gui/orchestrator_sidebar.rs new file mode 100644 index 0000000..fe8c1ac --- /dev/null +++ b/src/gui/orchestrator_sidebar.rs @@ -0,0 +1,296 @@ +use std::time::Duration; + +use gpui::*; + +use super::app::ClaudioApp; +use super::orchestrator_index::{self, FeatureSummary, HandoffStatus}; +use super::theme; + +pub struct OrchestratorState { + pub features: Vec, + pub show_completed: bool, +} + +impl OrchestratorState { + pub fn new() -> Self { + Self { + features: Vec::new(), + show_completed: false, + } + } +} + +impl ClaudioApp { + /// Kick off the background polling task that refreshes + /// `self.orchestrator.features` every 2 seconds. + pub fn start_orchestrator_poll(&self, cx: &mut Context) { + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + loop { + let features = cx + .background_executor() + .spawn(async { orchestrator_index::scan_all() }) + .await; + let _ = this.update(cx, |app, cx| { + app.orchestrator.features = features; + cx.notify(); + }); + cx.background_executor() + .timer(Duration::from_secs(2)) + .await; + } + }) + .detach(); + } + + pub fn render_orchestrator_sidebar( + &self, + _window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let header = div() + .px(px(8.0)) + .py(px(6.0)) + .flex() + .flex_row() + .items_center() + .child( + div() + .flex_1() + .child("Orchestrator") + .text_color(rgb(theme::OVERLAY0)) + .text_size(px(12.0)), + ) + .child( + div() + .id("orch-toggle-completed") + .child(if self.orchestrator.show_completed { + "all" + } else { + "active" + }) + .text_color(rgb(theme::OVERLAY0)) + .text_size(px(11.0)) + .px(px(4.0)) + .cursor_pointer() + .hover(|s| s.text_color(rgb(theme::BLUE))) + .on_mouse_down( + MouseButton::Left, + cx.listener(|app, _ev: &MouseDownEvent, _window, cx| { + app.orchestrator.show_completed = !app.orchestrator.show_completed; + cx.notify(); + }), + ), + ); + + let mut content = div() + .id("orch-content") + .flex_1() + .min_h(px(0.0)) + .overflow_y_scroll(); + + let visible: Vec<&FeatureSummary> = self + .orchestrator + .features + .iter() + .filter(|f| self.orchestrator.show_completed || !f.is_completed()) + .collect(); + + if visible.is_empty() { + content = content.child( + div() + .px(px(8.0)) + .py(px(16.0)) + .child("No orchestrator features.") + .text_color(rgb(theme::OVERLAY0)) + .text_size(px(12.0)), + ); + } else { + for feature in visible { + content = content.child(self.render_feature_row(feature, cx)); + } + } + + div() + .id("orchestrator-sidebar") + .w(px(self.file_tree_width)) + .h_full() + .flex() + .flex_col() + .bg(rgb(theme::MANTLE)) + .child(header) + .child(content) + .into_any_element() + } + + fn render_feature_row( + &self, + feature: &FeatureSummary, + cx: &mut Context, + ) -> AnyElement { + let state_color = match feature.state.as_str() { + "in_progress" | "running" | "implementing" => theme::BLUE, + "blocked" => theme::RED, + "pending" | "spawned" => theme::YELLOW, + "done" | "completed" | "complete" | "merged" | "shipped" => theme::GREEN, + _ => theme::OVERLAY0, + }; + + let handoff_badge = match feature.handoff { + HandoffStatus::Blocker => Some(("BLOCK", theme::RED)), + HandoffStatus::Question => Some(("Q", theme::YELLOW)), + HandoffStatus::Done => Some(("DONE", theme::GREEN)), + HandoffStatus::None => None, + }; + + let log_preview = feature + .last_log_line + .clone() + .unwrap_or_else(|| String::from("(no log entries)")); + let log_preview = if log_preview.len() > 64 { + format!("{}...", &log_preview[..61]) + } else { + log_preview + }; + + let feature_slug = feature.feature_slug.clone(); + let worktree = feature.worktree.clone(); + let row_id: SharedString = format!("orch-row-{}", feature.feature_slug).into(); + let has_session = self.sessions.iter().any(|s| s.name == feature.feature_slug); + + let mut row = div() + .id(row_id) + .px(px(8.0)) + .py(px(6.0)) + .w_full() + .flex() + .flex_col() + .gap(px(2.0)) + .cursor_pointer() + .hover(|s| s.bg(rgb(theme::SURFACE0))) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |app, _ev: &MouseDownEvent, _window, cx| { + app.handle_orchestrator_row_click(&feature_slug, worktree.as_deref(), cx); + }), + ); + + let mut top = div() + .flex() + .flex_row() + .items_center() + .gap(px(6.0)) + .child( + div() + .flex_1() + .min_w(px(0.0)) + .overflow_x_hidden() + .child(feature.feature_slug.clone()) + .text_color(rgb(theme::TEXT)) + .text_size(px(13.0)), + ) + .child( + div() + .child(feature.state.clone()) + .text_color(rgb(state_color)) + .text_size(px(10.0)) + .px(px(4.0)) + .rounded(px(2.0)) + .bg(rgb(theme::SURFACE0)), + ); + + if let Some((label, color)) = handoff_badge { + top = top.child( + div() + .child(label) + .text_color(rgb(theme::CRUST)) + .text_size(px(10.0)) + .px(px(4.0)) + .rounded(px(2.0)) + .bg(rgb(color)), + ); + } + + if !has_session { + top = top.child( + div() + .child("(no PTY)") + .text_color(rgb(theme::OVERLAY0)) + .text_size(px(10.0)), + ); + } + + row = row.child(top).child( + div() + .child(log_preview) + .text_color(rgb(theme::OVERLAY0)) + .text_size(px(11.0)) + .overflow_x_hidden(), + ); + + row.into_any_element() + } + + /// Click handler for an orchestrator feature row. + /// - If a session named after the feature slug exists, focus it. + /// - Otherwise, spawn a new claudio session pointed at the worktree + /// running `claude "Read /starter.md and follow it."`. + fn handle_orchestrator_row_click( + &mut self, + feature_slug: &str, + worktree: Option<&std::path::Path>, + cx: &mut Context, + ) { + if let Some(session) = self.sessions.iter().find(|s| s.name == feature_slug) { + let id = session.id.clone(); + self.focus_session_by_id(&id, cx); + return; + } + + let Some(worktree) = worktree else { + tracing::warn!( + "orchestrator: no worktree recorded for {feature_slug}; cannot spawn" + ); + return; + }; + + let starter = self + .orchestrator + .features + .iter() + .find(|f| f.feature_slug == feature_slug) + .and_then(|f| f.status_path.parent()) + .map(|p| p.join("starter.md")); + + let Some(starter) = starter else { + tracing::warn!( + "orchestrator: cannot derive starter.md path for {feature_slug}" + ); + return; + }; + + let socket = self.socket_path.clone(); + let name = feature_slug.to_string(); + let cwd = worktree.to_path_buf(); + let argv = vec![ + "claude".to_string(), + format!("Read {} and follow it.", starter.display()), + ]; + + self.pending_session_cwds.push_back(cwd.clone()); + self.pending_shell_modes.push_back(false); + self.pending_commands.push_back(Some(argv.clone())); + + let req = crate::ipc::protocol::Request::New { + name: Some(name), + mode: crate::session::mode::SessionMode::Listening, + cwd: Some(cwd), + command: Some(argv), + }; + + cx.background_executor() + .spawn(async move { + let _ = super::ipc_bridge::send_command(&socket, &req); + }) + .detach(); + } +} From be0697bf7ee5b8064d3311fad50058fd853a99e6 Mon Sep 17 00:00:00 2001 From: pfigs Date: Mon, 11 May 2026 13:34:46 +0300 Subject: [PATCH 10/11] refactor(gui): drop redundant file_tree.visible state Activity-bar selection is the single source of truth for which sidebar pane is visible. Existing Ctrl-B keybinding now sets active_activity to Files; new Ctrl-Shift-O selects Orchestrator. Assisted-By: Claude Code Pedro Silva --- src/gui/actions.rs | 2 ++ src/gui/app.rs | 17 +++++++++++++---- src/gui/file_tree.rs | 6 ------ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/gui/actions.rs b/src/gui/actions.rs index 321c5fa..1bb9bed 100644 --- a/src/gui/actions.rs +++ b/src/gui/actions.rs @@ -10,6 +10,7 @@ actions!( ToggleMode, MinimizeSession, ToggleFileTree, + ToggleOrchestrator, AddFolder, FocusFileTreeSearch, ReadAloud, @@ -30,6 +31,7 @@ pub fn register(cx: &mut App) { KeyBinding::new("ctrl-m", ToggleMode, Some("ClaudioApp")), KeyBinding::new("ctrl-shift-m", MinimizeSession, Some("ClaudioApp")), KeyBinding::new("ctrl-b", ToggleFileTree, Some("ClaudioApp")), + KeyBinding::new("ctrl-shift-o", ToggleOrchestrator, Some("ClaudioApp")), KeyBinding::new("ctrl-f", FocusFileTreeSearch, Some("ClaudioApp")), KeyBinding::new("ctrl-r", ReadAloud, Some("ClaudioApp")), KeyBinding::new("ctrl-shift-r", StopSpeech, Some("ClaudioApp")), diff --git a/src/gui/app.rs b/src/gui/app.rs index 6affd62..97a292c 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -590,7 +590,17 @@ impl ClaudioApp { } fn toggle_file_tree(&mut self, _: &ToggleFileTree, _window: &mut Window, cx: &mut Context) { - self.file_tree.toggle_visible(); + self.active_activity = super::activity_bar::Activity::Files; + cx.notify(); + } + + fn toggle_orchestrator( + &mut self, + _: &ToggleOrchestrator, + _window: &mut Window, + cx: &mut Context, + ) { + self.active_activity = super::activity_bar::Activity::Orchestrator; cx.notify(); } @@ -600,9 +610,7 @@ impl ClaudioApp { _window: &mut Window, cx: &mut Context, ) { - if !self.file_tree.visible { - self.file_tree.visible = true; - } + self.active_activity = super::activity_bar::Activity::Files; self.file_tree.search_active = !self.file_tree.search_active; cx.notify(); } @@ -1091,6 +1099,7 @@ impl Render for ClaudioApp { .on_action(cx.listener(Self::toggle_mode)) .on_action(cx.listener(Self::minimize_session)) .on_action(cx.listener(Self::toggle_file_tree)) + .on_action(cx.listener(Self::toggle_orchestrator)) .on_action(cx.listener(Self::focus_file_tree_search)) .on_action(cx.listener(Self::add_folder)) .on_action(cx.listener(Self::read_aloud)) diff --git a/src/gui/file_tree.rs b/src/gui/file_tree.rs index 56d5a60..c2188f8 100644 --- a/src/gui/file_tree.rs +++ b/src/gui/file_tree.rs @@ -42,7 +42,6 @@ pub struct FileTree { pub roots: Vec, pub worktree_roots: Vec, pub expanded: HashSet, - pub visible: bool, pub search_query: String, pub search_active: bool, pub show_files: bool, @@ -55,7 +54,6 @@ impl FileTree { roots: Vec::new(), worktree_roots: Vec::new(), expanded: HashSet::new(), - visible: true, search_query: String::new(), search_active: false, show_files: true, @@ -63,10 +61,6 @@ impl FileTree { } } - pub fn toggle_visible(&mut self) { - self.visible = !self.visible; - } - pub fn toggle_dir(&mut self, path: &PathBuf) { if self.expanded.contains(path) { self.expanded.remove(path); From 5e18d016afa8faece2256a88f797b65eb1d6a1ec Mon Sep 17 00:00:00 2001 From: pfigs Date: Mon, 11 May 2026 13:35:23 +0300 Subject: [PATCH 11/11] docs(readme): describe the orchestrator activity bar slot Assisted-By: Claude Code Pedro Silva --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a1c07ec..c29b8ff 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Voice-activated terminal session manager for [Claude Code](https://docs.anthropi - **File browser** -- resizable sidebar with multi-root folder navigation. Click files to open in your editor (`$VISUAL` / `$EDITOR` / `zed`). - **Desktop notifications** -- terminal output is scanned for approval prompts ("do you want to proceed", "allow once", etc.) and triggers a `notify-send` notification so you don't miss them. - **Git worktrees** -- right-click a repo folder to create a named worktree with its own session and branch. +- **Orchestrator integration** -- the `/orchestrator` Claude Code skill can spawn its feature sessions directly into claudio's PTY grid. The new `Orchestrator` activity (icon `O` in the left rail, or `Ctrl+Shift+O`) lists every active feature pulled from `~/.claude/orchestrator///STATUS.md`, with state badges, last log line, and a blocker indicator when the spawned session has open questions. Click a row to focus its tile or spawn one if it's not running yet. CLI form: `claudio new --cwd --mode listening -- claude "Read "`. - **Resizable grid** -- drag borders between terminal panes to resize them. ## Install