This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-protocol/src/display.rs
T
Miguel PalhasandClaude Opus 5 28778b2224
ci / web (push) Successful in 1m56s
ci / rust (push) Successful in 5m13s
ci / flake (push) Successful in 8m8s
feat(maestro): add maestro send for typing into a session
Adds POST /api/sessions/:id/input and a CLI command on top of it, so a
script (or another agent) can hand an instruction to a running session
without attaching to it.

The text goes in as one write and the Enter follows 150ms later: agents
that run a full-screen prompt coalesce a burst of input into a paste, and
a newline inside that burst becomes a line break instead of sending the
message. `--no-enter` skips the Enter and leaves the text in the prompt.

The session argument takes an ID, a unique ID prefix, or a session name;
names win over prefixes. A session whose pane has exited is refused (409)
rather than silently accepted — unlike a stray keystroke on an attach
socket, a send that goes nowhere is worth hearing about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 15:10:18 +01:00

412 lines
13 KiB
Rust

//! Path-abbreviation helpers shared by every client that renders a session's
//! directory (CLI, TUI, web (#117)). Ports of `web/src/types.ts` and
//! `web/src/tree.ts`'s `abbreviateCwd`, `abbreviateFolderName`, `mergeLinear`,
//! and `sessionTreePath` — keep them in sync.
use std::path::{Path, PathBuf};
use crate::Session;
/// The name a session was given (#125), if any. `None` for one that was never
/// renamed — clients fall back to the command.
pub fn custom_name(s: &Session) -> Option<&str> {
match s.metadata.get("name") {
Some(serde_json::Value::String(name)) => Some(name.as_str()),
_ => None,
}
}
/// Collapse a daemon's home dir to `~`. `home` empty leaves the path
/// untouched. Mirrors the web's `abbreviateCwd`.
pub fn abbreviate_dir(path: &Path, home: &str) -> String {
let p = path.to_string_lossy();
if !home.is_empty() {
if p == home {
return "~".into();
}
if let Some(rest) = p.strip_prefix(&format!("{home}/")) {
return format!("~/{rest}");
}
}
p.into_owned()
}
/// Result of [`abbreviate_folder_name`]: the last path segment kept verbatim,
/// and — when there was something to collapse — the shortened lead-in a
/// caller can render dimmed (the web's `.folder-stem`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AbbreviatedFolderName {
/// The collapsed earlier segments, joined by `/`, with no trailing
/// slash. `None` when `name` had two or fewer segments — nothing to
/// gain from collapsing, so `last` carries the name unabbreviated.
pub stem: Option<String>,
pub last: String,
}
/// `~/git.naps.pt/naps62/maestro` → stem `~/g/n`, last `maestro`: keep the
/// last segment verbatim, collapse every earlier one to its first character.
/// Two-or-fewer segments are left alone. Mirrors the web's
/// `abbreviateFolderName`, minus the HTML escaping/wrapping — a ratatui
/// caller styles `stem` dim itself rather than parsing markup out of it.
pub fn abbreviate_folder_name(name: &str) -> AbbreviatedFolderName {
let parts: Vec<&str> = name.split('/').collect();
if parts.len() <= 2 {
return AbbreviatedFolderName {
stem: None,
last: name.to_string(),
};
}
let last = parts[parts.len() - 1].to_string();
let stem = parts[..parts.len() - 1]
.iter()
.map(|p| {
if *p == "~" {
"~".to_string()
} else {
p.chars().next().map(String::from).unwrap_or_default()
}
})
.collect::<Vec<_>>()
.join("/");
AbbreviatedFolderName {
stem: Some(stem),
last,
}
}
/// The directory a session lives in: its live `current_cwd` if the daemon
/// has observed one, else the spawn `cwd`. Mirrors the web's `sessionCwd`.
fn session_cwd(s: &Session) -> Option<PathBuf> {
if let Some(cur) = &s.current_cwd {
if !cur.as_os_str().is_empty() {
return Some(cur.clone());
}
}
if !s.cwd.as_os_str().is_empty() {
return Some(s.cwd.clone());
}
None
}
/// Where a session sits in the directory tree. A worktree session belongs to
/// its repository, not to the worktree (#99): otherwise `~/tea/agent-skills`
/// grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one
/// session of the same name, and the repository stops being where you look
/// for the work. Mirrors the web's `sessionTreePath`.
pub fn session_tree_path(s: &Session) -> Option<PathBuf> {
if let Some(wt) = &s.worktree {
return Some(wt.base_repo.clone());
}
session_cwd(s)
}
/// One node of a directory tree grouping sessions by path, generic over the
/// leaf payload since building the tree itself (grouping key, sort order,
/// filtering) is not this crate's job — only [`merge_linear`] is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TreeNode<T> {
Folder(TreeFolder<T>),
Leaf(T),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeFolder<T> {
pub name: String,
pub path: String,
pub children: Vec<TreeNode<T>>,
}
/// Fold a folder with a single folder-child into one node (`a` + `b` →
/// `a/b`), repeating down the chain, then recurse into the surviving
/// branches. The merged node keeps the deepest path so collapse/expand state
/// stays stable as it grows. Mirrors the web's `mergeLinear`.
pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
let mut cur = folder;
loop {
let single_folder_child =
cur.children.len() == 1 && matches!(cur.children[0], TreeNode::Folder(_));
if !single_folder_child {
break;
}
let child = match cur.children.pop().expect("checked len == 1 above") {
TreeNode::Folder(f) => f,
TreeNode::Leaf(_) => unreachable!("checked matches! above"),
};
cur = TreeFolder {
name: format!("{}/{}", cur.name, child.name),
path: child.path,
children: child.children,
};
}
cur.children = cur
.children
.into_iter()
.map(|c| match c {
TreeNode::Folder(f) => TreeNode::Folder(merge_linear(f)),
leaf => leaf,
})
.collect();
cur
}
/// Command wrappers that run another program: the program is what follows.
const WRAPPERS: [&str; 8] = [
"sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command",
];
/// The program a command line runs, basenamed and stripped of its arguments —
/// what a session card shows per `docs/SIDEBAR.md`, which names the program
/// and never its flags. Leading `FOO=bar` assignments and wrappers (`sudo`,
/// `npx`, `pnpm dlx`, …) are looked through. Mirrors the web's `splitCommand`.
pub fn command_program(command: &str) -> String {
let words: Vec<&str> = command.split_whitespace().collect();
let mut i = 0;
while i < words.len() {
let word = words[i];
if is_env_assignment(word) {
i += 1;
continue;
}
let wrapper = basename(word).to_lowercase();
if WRAPPERS.contains(&wrapper.as_str()) {
i += 1;
if (wrapper == "pnpm" || wrapper == "yarn") && words.get(i) == Some(&"dlx") {
i += 1;
}
continue;
}
break;
}
match words.get(i) {
Some(word) => basename(word).to_string(),
// Nothing but wrappers and assignments: say what was there rather than
// rendering an empty fragment.
None => command.trim().to_string(),
}
}
/// `FOO=bar`, the shell's own prefix for one command's environment.
fn is_env_assignment(word: &str) -> bool {
let mut chars = word.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
let rest: String = chars.collect();
match rest.split_once('=') {
Some((name, _)) => name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
None => false,
}
}
fn basename(word: &str) -> &str {
match word.rsplit_once('/') {
Some((_, tail)) => tail,
None => word,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{SessionStatus, SessionWorktree};
use chrono::{TimeZone, Utc};
use std::collections::HashMap;
use uuid::Uuid;
fn session(cwd: &str) -> Session {
Session {
id: Uuid::from_u128(1),
cwd: PathBuf::from(cwd),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc.with_ymd_and_hms(2026, 8, 25, 12, 0, 0).unwrap(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: Utc::now(),
current_cwd: None,
blocked_work: None,
}
}
#[test]
fn abbreviate_dir_collapses_home() {
assert_eq!(
abbreviate_dir(Path::new("/home/naps/maestro"), "/home/naps"),
"~/maestro"
);
assert_eq!(abbreviate_dir(Path::new("/home/naps"), "/home/naps"), "~");
}
#[test]
fn abbreviate_dir_leaves_paths_outside_home_untouched() {
assert_eq!(
abbreviate_dir(Path::new("/srv/work/api"), "/home/naps"),
"/srv/work/api"
);
assert_eq!(
abbreviate_dir(Path::new("/srv/work/api"), ""),
"/srv/work/api"
);
}
#[test]
fn abbreviate_folder_name_collapses_the_stem() {
let got = abbreviate_folder_name("~/git.naps.pt/naps62/maestro");
assert_eq!(
got,
AbbreviatedFolderName {
stem: Some("~/g/n".into()),
last: "maestro".into(),
}
);
}
/// Two-or-fewer segments are left alone — nothing to gain from collapsing.
#[test]
fn abbreviate_folder_name_two_segment_no_op() {
assert_eq!(
abbreviate_folder_name("~/work"),
AbbreviatedFolderName {
stem: None,
last: "~/work".into(),
}
);
assert_eq!(
abbreviate_folder_name("work"),
AbbreviatedFolderName {
stem: None,
last: "work".into(),
}
);
}
#[test]
fn session_tree_path_prefers_worktree_base_repo() {
let mut s = session("/home/n/tea/agent-skills/worktrees/largo-clarinet");
s.worktree = Some(SessionWorktree {
path: PathBuf::from("/home/n/tea/agent-skills/worktrees/largo-clarinet"),
base_repo: PathBuf::from("/home/n/tea/agent-skills"),
});
assert_eq!(
session_tree_path(&s),
Some(PathBuf::from("/home/n/tea/agent-skills"))
);
}
#[test]
fn session_tree_path_falls_back_to_cwd_without_a_worktree() {
let s = session("/home/n/tea/arr");
assert_eq!(
session_tree_path(&s),
Some(PathBuf::from("/home/n/tea/arr"))
);
}
#[test]
fn session_tree_path_prefers_live_current_cwd_over_spawn_cwd() {
let mut s = session("/home/n/tea/arr");
s.current_cwd = Some(PathBuf::from("/home/n/tea/arr/crates"));
assert_eq!(
session_tree_path(&s),
Some(PathBuf::from("/home/n/tea/arr/crates"))
);
}
fn leaf(name: &str) -> TreeNode<String> {
TreeNode::Leaf(name.into())
}
fn folder(name: &str, path: &str, children: Vec<TreeNode<String>>) -> TreeFolder<String> {
TreeFolder {
name: name.into(),
path: path.into(),
children,
}
}
/// The web's mergeLinear test: keeps the deepest path so collapse state
/// survives growth.
#[test]
fn merge_linear_folds_a_chain_and_keeps_the_deepest_path() {
let tree = folder("a", "a", vec![TreeNode::Folder(folder("b", "a/b", vec![]))]);
let merged = merge_linear(tree);
assert_eq!(merged.name, "a/b");
assert_eq!(merged.path, "a/b");
}
#[test]
fn merge_linear_stops_where_a_directory_branches() {
let tree = folder(
"git",
"git",
vec![
TreeNode::Folder(folder("one", "git/one", vec![])),
TreeNode::Folder(folder("two", "git/two", vec![])),
],
);
let merged = merge_linear(tree);
assert_eq!(merged.name, "git");
assert_eq!(merged.children.len(), 2);
}
#[test]
fn merge_linear_stops_at_a_leaf() {
let tree = folder("a", "a", vec![leaf("session")]);
let merged = merge_linear(tree);
assert_eq!(merged.name, "a");
assert_eq!(merged.children, vec![leaf("session")]);
}
#[test]
fn merge_linear_recurses_into_surviving_branches() {
let tree = folder(
"root",
"root",
vec![
TreeNode::Folder(folder(
"a",
"root/a",
vec![TreeNode::Folder(folder("b", "root/a/b", vec![leaf("s")]))],
)),
leaf("other"),
],
);
let merged = merge_linear(tree);
assert_eq!(merged.children.len(), 2);
let TreeNode::Folder(nested) = &merged.children[0] else {
panic!("expected folder")
};
assert_eq!(nested.name, "a/b");
}
#[test]
fn command_program_drops_arguments() {
assert_eq!(
command_program("claude --dangerously-skip-permissions"),
"claude"
);
assert_eq!(command_program(" zsh -l "), "zsh");
assert_eq!(command_program("hx src/main.rs"), "hx");
}
#[test]
fn command_program_looks_through_paths_env_and_wrappers() {
assert_eq!(command_program("/usr/local/bin/claude -p hi"), "claude");
assert_eq!(command_program("FOO=1 BAR=2 claude"), "claude");
assert_eq!(command_program("npx opencode"), "opencode");
assert_eq!(command_program("pnpm dlx opencode run"), "opencode");
}
#[test]
fn command_program_keeps_a_line_that_is_only_wrappers() {
assert_eq!(command_program("sudo"), "sudo");
assert_eq!(command_program(""), "");
}
}