From 05bdc2d802a7a98095cf03a9a3758bd5983f0179 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Mon, 1 Jun 2026 11:38:15 -0700 Subject: [PATCH] feat(ai): /ai start|stop agent control + in-room typing indicator Owner of the spawning client can summon/dismiss a local AI agent from inside the room (default ollama/qwen2.5:3b); the agent emits encrypted typing frames that drive a "thinking" spinner in the client. Co-Authored-By: Claude Opus 4.6 --- cmd_chat/agent/bridge.py | 9 +++ hh/src/app.rs | 152 ++++++++++++++++++++++++++++++++++++++- hh/src/net.rs | 20 ++++++ hh/src/ui.rs | 27 ++++++- 4 files changed, 204 insertions(+), 4 deletions(-) diff --git a/cmd_chat/agent/bridge.py b/cmd_chat/agent/bridge.py index 99921a6..eab2c2d 100644 --- a/cmd_chat/agent/bridge.py +++ b/cmd_chat/agent/bridge.py @@ -56,8 +56,15 @@ class AgentBridge(Client): return None return rest # sole-agent form: `/ai ` + async def _send_typing(self, ws, on: bool) -> None: + """Tell the room our reply is (not) being generated, so clients can show + a spinner. A control frame — never displayed as chat.""" + frame = json.dumps({"_ai": "typing", "name": self.name, "on": on}) + await ws.send(self.room_fernet.encrypt(frame.encode()).decode()) + async def _answer(self, ws, question: str, asker: str) -> None: self.transcript.append(Msg("user", f"{asker}: {question}")) + await self._send_typing(ws, True) try: reply = await asyncio.to_thread( self.provider.complete, @@ -66,6 +73,8 @@ class AgentBridge(Client): ) except Exception as e: # noqa: BLE001 — surface any provider failure in-room reply = f"[ai error: {e}]" + finally: + await self._send_typing(ws, False) reply = reply.strip() or "[empty reply]" self.transcript.append(Msg("assistant", reply)) await ws.send(self.room_fernet.encrypt(reply.encode()).decode()) diff --git a/hh/src/app.rs b/hh/src/app.rs index 330fc5c..4af8b21 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -91,6 +91,11 @@ pub enum Net { sudoers: Vec, }, Ft(ft::Ft), + /// An AI agent is generating a reply (`on`) or has finished (`!on`). + AiTyping { + name: String, + on: bool, + }, Err(String), Closed, } @@ -127,6 +132,10 @@ pub struct App { pub error: Option, /// The room password this client authenticated with (shown by `/pw`). pub password: String, + /// AI agents currently generating a reply — drives the "thinking" spinner. + pub ai_typing: std::collections::HashSet, + /// Monotonic tick counter used to animate the AI spinner. + pub spin: usize, } impl App { @@ -151,6 +160,8 @@ impl App { reconnecting: false, error: None, password: String::new(), + ai_typing: std::collections::HashSet::new(), + spin: 0, } } @@ -202,7 +213,7 @@ impl App { self.connected = true; self.chat_scroll = 0; self.sys(format!("joined as {} ⛧", self.me)); - self.sys("/sbx launch · /drive (Esc releases) · /send · /pw show password · PgUp/PgDn scroll chat · ctrl-q quit"); + self.sys("/sbx launch · /drive (Esc releases) · /ai start · /ai · /send · /pw show password · PgUp/PgDn scroll chat · ctrl-q quit"); } Net::Message(l) => self.push_line(l), Net::Roster { users, capacity } => { @@ -213,9 +224,17 @@ impl App { Net::Left(uid) => { if let Some(p) = self.users.iter().position(|u| u.user_id == uid) { let name = self.users.remove(p).username; + self.ai_typing.remove(&name); // a departed agent isn't thinking self.sys(format!("{name} left")); } } + Net::AiTyping { name, on } => { + if on { + self.ai_typing.insert(name); + } else { + self.ai_typing.remove(&name); + } + } Net::SbxStatus { backend, ready, @@ -488,6 +507,8 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme let mut announced_dims: Option<(u16, u16)> = None; let mut active_send: Option = None; let mut send_seq: u64 = 0; + // The local AI agent subprocess this client spawned via `/ai start`, if any. + let mut agent: Option = None; let downloads = PathBuf::from("./downloads"); enable_raw_mode()?; @@ -622,7 +643,8 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme app.chat_scroll = 0; // jump back to live on send handle_command(&line, &mut app, &mut theme, &mut active_send, &mut send_seq, &mut broker, &mut broker_meta, &mut launching, &mut announced_dims, - &out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term); + &out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term, + &mut agent, ¶ms); } KeyCode::Backspace => { app.input.pop(); } // Scroll: ↑/↓ scroll the sandbox terminal if one is up, @@ -793,7 +815,7 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme } _ = sigterm.recv() => { break Ok(()); } _ = sighup.recv() => { break Ok(()); } - _ = tick.tick() => {} + _ = tick.tick() => { app.spin = app.spin.wrapping_add(1); } } }; @@ -803,6 +825,10 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme sbx::teardown(be, &name); } } + if let Some(mut child) = agent.take() { + let _ = child.kill(); + let _ = child.wait(); + } disable_raw_mode()?; execute!( term.backend_mut(), @@ -841,6 +867,8 @@ fn handle_command( app_tx: &UnboundedSender, session: &Session, term: &Terminal>, + agent: &mut Option, + params: &net::ConnParams, ) { let room = &session.room; if line == "/help" || line == "/?" { @@ -1051,6 +1079,48 @@ fn handle_command( broadcast_acl(out_tx, room, app); app.sys(format!("revoked drive from {target}")); } + } else if line == "/ai stop" { + // Reap a child that already exited (e.g. failed auth) so the message is honest. + if agent + .as_mut() + .is_some_and(|c| matches!(c.try_wait(), Ok(Some(_)))) + { + *agent = None; + } + if let Some(mut child) = agent.take() { + let _ = child.kill(); + let _ = child.wait(); + app.sys("⛧ dismissed the AI agent"); + } else { + app.sys("no AI agent was started from this client"); + } + } else if let Some(rest) = line + .strip_prefix("/ai start") + .filter(|r| r.is_empty() || r.starts_with(' ')) + { + // Drop a handle to an agent that has already exited so we can restart. + if agent + .as_mut() + .is_some_and(|c| matches!(c.try_wait(), Ok(Some(_)))) + { + *agent = None; + } + if agent.is_some() { + app.sys("an AI agent is already running from this client — /ai stop first"); + } else { + let m = rest.trim(); + let model = if m.is_empty() { "qwen2.5:3b" } else { m }; + let name = "oracle"; + match spawn_agent(params, &app.password, name, model) { + Ok(child) => { + *agent = Some(child); + app.sys(format!( + "⛧ summoning {name} (ollama/{model})… it will announce when online" + )); + } + Err(e) => app.err(format!("/ai start failed: {e}")), + } + } } else if !line.is_empty() && app.connected { let _ = out_tx.send(WsMsg::Text(room.encrypt(line.as_bytes()))); } @@ -1112,3 +1182,79 @@ fn spawn_launch( } }); } + +/// Locate the repo root (the dir containing `cmd_chat/agent/`) by walking up from +/// the executable's path and the current directory — so `/ai start` finds the +/// Python agent whether the client runs from the checkout or its `target/` dir. +fn find_repo_root() -> Option { + let mut starts: Vec = Vec::new(); + if let Ok(exe) = std::env::current_exe() { + starts.push(exe); + } + if let Ok(cwd) = std::env::current_dir() { + starts.push(cwd); + } + for start in starts { + let mut dir: Option<&std::path::Path> = Some(start.as_path()); + while let Some(d) = dir { + if d.join("cmd_chat").join("agent").is_dir() { + return Some(d.to_path_buf()); + } + dir = d.parent(); + } + } + None +} + +/// Spawn the Python AI agent as a child process that joins this room as a normal +/// encrypted client (same SRP + room password). Returns the child handle so +/// `/ai stop` (and client quit) can kill it. The agent's stdout/stderr go to a +/// log file in the temp dir so its prints never corrupt the TUI. +fn spawn_agent( + params: &net::ConnParams, + password: &str, + name: &str, + model: &str, +) -> std::result::Result { + use std::process::{Command, Stdio}; + let root = find_repo_root().ok_or_else(|| { + "can't locate the repo (cmd_chat/) — run the client from the checkout".to_string() + })?; + // Prefer the project venv's interpreter; fall back to HH_AI_PYTHON or python3. + let venv_py = root.join(".venv/bin/python"); + let program = if venv_py.is_file() { + venv_py + } else { + std::path::PathBuf::from(std::env::var("HH_AI_PYTHON").unwrap_or_else(|_| "python3".into())) + }; + let log_path = std::env::temp_dir().join(format!("hh-agent-{name}.log")); + let log = std::fs::File::create(&log_path) + .map_err(|e| format!("agent log {}: {e}", log_path.display()))?; + let log_err = log.try_clone().map_err(|e| e.to_string())?; + let mut cmd = Command::new(&program); + cmd.current_dir(&root) + .arg("-m") + .arg("cmd_chat.agent") + .arg(¶ms.ip) + .arg(params.port.to_string()) + .arg("--name") + .arg(name) + .arg("--provider") + .arg("ollama") + .arg("--model") + .arg(model) + .stdin(Stdio::null()) + .stdout(Stdio::from(log)) + .stderr(Stdio::from(log_err)); + if !password.is_empty() { + cmd.arg("--password").arg(password); + } + if params.no_tls { + cmd.arg("--no-tls"); + } + if params.insecure { + cmd.arg("--insecure"); + } + cmd.spawn() + .map_err(|e| format!("could not start agent ({}): {e}", program.display())) +} diff --git a/hh/src/net.rs b/hh/src/net.rs index d96a00f..52c7297 100644 --- a/hh/src/net.rs +++ b/hh/src/net.rs @@ -178,6 +178,13 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, live: bool) -> Decoded { Decoded::Skip }; } + if t.starts_with("{\"_ai\":") { + return if live { + parse_ai(&t).map(Decoded::Sbx).unwrap_or(Decoded::Skip) + } else { + Decoded::Skip + }; + } (t, false) } Err(_) => ("[unreadable — wrong room password?]".to_string(), true), @@ -220,6 +227,19 @@ fn parse_sbx(text: &str, sender: &str) -> Option { } } +/// Parse a decrypted `{"_ai":"typing",...}` frame — an AI agent signalling that +/// it is (or has finished) generating a reply, so the UI can show a spinner. +fn parse_ai(text: &str) -> Option { + let v: Value = serde_json::from_str(text).ok()?; + if v["_ai"].as_str()? != "typing" { + return None; + } + Some(Net::AiTyping { + name: v["name"].as_str().unwrap_or("ai").to_string(), + on: v["on"].as_bool().unwrap_or(false), + }) +} + /// Parse a decrypted `{"_perm":"acl",...}` frame. fn parse_perm(text: &str) -> Option { let v: Value = serde_json::from_str(text).ok()?; diff --git a/hh/src/ui.rs b/hh/src/ui.rs index dcfd3d9..5b1c9ee 100644 --- a/hh/src/ui.rs +++ b/hh/src/ui.rs @@ -128,6 +128,15 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) { ), kv("/sbx stop", "tear down the sandbox (purges the VM)"), kv("/drive", "type into the shared shell (Esc releases)"), + kv( + "/ai start [model]", + "spawn a local AI agent (default ollama/qwen2.5:3b)", + ), + kv("/ai stop", "dismiss the agent you started"), + kv( + "/ai ", + "ask an AI agent in the room (/ai if many)", + ), kv( "/grant ", "let a member drive the shell (owner)", @@ -357,6 +366,17 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th f.render_widget(roster, area); } +/// Animated "⠋ oracle is thinking…" title shown while AI agents generate a reply. +fn ai_thinking_title(app: &App) -> String { + const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let glyph = FRAMES[(app.spin / 2) % FRAMES.len()]; + let mut names: Vec<&str> = app.ai_typing.iter().map(String::as_str).collect(); + names.sort_unstable(); + let who = names.join(", "); + let verb = if names.len() > 1 { "are" } else { "is" }; + format!(" {glyph} {who} {verb} thinking… ") +} + fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) { let input = Paragraph::new(Line::from(vec![ Span::styled("> ", Style::default().fg(theme.accent)), @@ -378,9 +398,14 @@ fn draw_input(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &The None if app.driving => { format!(" {} DRIVING the shell — Esc to release ", theme.sigil) } + None if !app.ai_typing.is_empty() => ai_thinking_title(app), None => " message · enter send · /drive for shell · ctrl-q quit ".to_string(), }, - Style::default().fg(theme.title), + Style::default().fg(if app.ai_typing.is_empty() { + theme.title + } else { + theme.accent + }), )), ); f.render_widget(input, area);