Compare commits

..
3 Commits
Author SHA1 Message Date
Miguel Palhas 4668cf8ece Merge remote-tracking branch 'origin/main' into recorder-4
ci / rust (push) Failing after 42s
ci / web (push) Failing after 20s
ci / flake (push) Successful in 3m39s
2026-09-08 13:46:03 +01:00
Miguel PalhasandClaude Opus 4.6 638155de03 feat(maestro): move activity indicator into scroll area
The "Running 1m ..." status now sits at the tail of the chat
history instead of pinned above the input, matching Claude Code's
placement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 13:45:56 +01:00
Miguel PalhasandClaude Opus 4.6 b339fecad3 feat(maestro): route maestro send through ACP for structured sessions
ci / rust (push) Waiting to run
ci / web (push) Waiting to run
ci / flake (push) Waiting to run
Structured sessions were rejected by `send_input` with NotTerminal.
Now `run_send` checks the session view and routes to
`send_structured_prompt` for structured sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-08 13:43:01 +01:00
6 changed files with 107 additions and 25 deletions
+2 -2
View File
@@ -41,8 +41,8 @@ pub enum Command {
Attach(AttachArgs), Attach(AttachArgs),
/// Kill a session by ID. /// Kill a session by ID.
Kill(KillArgs), Kill(KillArgs),
/// Type text into a running session, as if it had been typed on an /// Send text to a running session. Terminal sessions receive raw
/// attached terminal. /// keystrokes; structured sessions receive an ACP prompt.
Send(SendArgs), Send(SendArgs),
/// Install or update the agent activity hook (#106). /// Install or update the agent activity hook (#106).
Hooks(HooksArgs), Hooks(HooksArgs),
+27 -15
View File
@@ -130,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
/// `maestro send`. Answers with the session that was typed into, so the /// `maestro send`. Answers with the session that was typed into, so the
/// caller can name it once the daemon has accepted the text. /// caller can name it once the daemon has accepted the text.
/// ///
/// A bare ID skips the catalog fetch; anything else needs the list to match /// Terminal sessions get raw keystrokes via `send_input`; structured sessions
/// against. Text comes from the arguments, or from stdin when there is none — /// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
/// the shape a heredoc or a pipe wants. /// there — ACP prompts are always complete messages).
///
/// Text comes from the arguments, or from stdin when there is none — the
/// shape a heredoc or a pipe wants.
#[allow(clippy::result_large_err)] #[allow(clippy::result_large_err)]
pub async fn run_send( pub async fn run_send(
client: &DaemonClient, client: &DaemonClient,
@@ -155,19 +158,28 @@ pub async fn run_send(
return Err(CliError::NothingToSend); return Err(CliError::NothingToSend);
} }
let id = match uuid::Uuid::parse_str(args.session.trim()) { // We always need the session list: even a bare UUID needs the view field
Ok(id) => id, // to decide which endpoint to hit.
Err(_) => { let sessions = client.list_sessions().await?;
let sessions = client.list_sessions().await?; let id = resolve_session(&sessions, &args.session)?;
resolve_session(&sessions, &args.session)? let view = sessions
} .iter()
}; .find(|s| s.id == id)
.map(|s| s.view)
.unwrap_or(SessionView::Terminal);
let req = SendInputRequest { match view {
text, SessionView::Structured => {
submit: !args.no_enter, client.send_structured_prompt(id, &text).await?;
}; }
client.send_input(id, &req).await?; SessionView::Terminal => {
let req = SendInputRequest {
text,
submit: !args.no_enter,
};
client.send_input(id, &req).await?;
}
}
Ok(id) Ok(id)
} }
@@ -2,7 +2,7 @@
//! was unit-tested, but nothing exercised the client, the error decoding, or //! was unit-tested, but nothing exercised the client, the error decoding, or
//! the unreachable-daemon path without compiling and forking a real daemon. //! the unreachable-daemon path without compiling and forking a real daemon.
use maestro_protocol::{DaemonClient, DaemonInfo}; use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
use maestro_testing::{session, MockDaemon}; use maestro_testing::{session, MockDaemon};
use uuid::Uuid; use uuid::Uuid;
@@ -202,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
assert_eq!(sent, id); assert_eq!(sent, id);
} }
/// A structured session routes through the ACP prompt endpoint, not the
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
/// complete messages).
#[tokio::test]
async fn send_to_structured_session_uses_acp_prompt() {
let mock = MockDaemon::start().await;
let id = Uuid::from_u128(13);
let mut s = session(id, "/tmp", "claude");
s.view = SessionView::Structured;
mock.add_session(s);
let args = maestro_cli::cli::SendArgs {
session: id.to_string(),
text: vec!["fix".into(), "the".into(), "tests".into()],
no_enter: false,
};
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
.await
.expect("send");
assert_eq!(sent, id);
// Terminal input must stay empty — the message went through ACP.
assert!(mock.inputs().is_empty());
let prompts = mock.structured_prompts();
assert_eq!(prompts.len(), 1);
assert_eq!(prompts[0].0, id);
assert_eq!(prompts[0].1.prompt, "fix the tests");
}
#[tokio::test] #[tokio::test]
async fn send_to_an_unknown_session_fails_without_typing_anything() { async fn send_to_an_unknown_session_fails_without_typing_anything() {
let mock = MockDaemon::start().await; let mock = MockDaemon::start().await;
+38 -1
View File
@@ -22,7 +22,8 @@ use chrono::Utc;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{ use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing, routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, PROTOCOL_VERSION, GitRoot, RecentDirs, SendInputRequest, Session, SessionStatus, StructuredPromptRequest,
PROTOCOL_VERSION,
}; };
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::{broadcast, oneshot}; use tokio::sync::{broadcast, oneshot};
@@ -60,6 +61,8 @@ struct Inner {
forced_error: Option<(u16, ApiError)>, forced_error: Option<(u16, ApiError)>,
/// Everything `POST /api/sessions/:id/input` was asked to type, in order. /// Everything `POST /api/sessions/:id/input` was asked to type, in order.
inputs: Vec<(Uuid, SendInputRequest)>, inputs: Vec<(Uuid, SendInputRequest)>,
/// Every ACP prompt sent to a structured session, in order.
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -92,6 +95,10 @@ impl MockDaemon {
.route(routes::SESSIONS, get(list_sessions).post(create_session)) .route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION, delete(kill_session)) .route(routes::patterns::SESSION, delete(kill_session))
.route(routes::patterns::SESSION_INPUT, post(send_input)) .route(routes::patterns::SESSION_INPUT, post(send_input))
.route(
routes::patterns::STRUCTURED_PROMPT,
post(structured_prompt),
)
.route(routes::INFO, get(daemon_info)) .route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir)) .route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs)) .route(routes::RECENT_DIRS, get(recent_dirs))
@@ -178,6 +185,16 @@ impl MockDaemon {
self.state.inner.lock().expect("poisoned").inputs.clone() self.state.inner.lock().expect("poisoned").inputs.clone()
} }
/// Every ACP prompt this daemon received, oldest first.
pub fn structured_prompts(&self) -> Vec<(Uuid, StructuredPromptRequest)> {
self.state
.inner
.lock()
.expect("poisoned")
.structured_prompts
.clone()
}
pub fn session_count(&self) -> usize { pub fn session_count(&self) -> usize {
self.state.inner.lock().expect("poisoned").sessions.len() self.state.inner.lock().expect("poisoned").sessions.len()
} }
@@ -266,6 +283,26 @@ async fn send_input(
StatusCode::NO_CONTENT.into_response() StatusCode::NO_CONTENT.into_response()
} }
async fn structured_prompt(
State(s): State<AppState>,
Path(id): Path<Uuid>,
Json(req): Json<StructuredPromptRequest>,
) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let mut guard = s.inner.lock().expect("poisoned");
if !guard.sessions.iter().any(|x| x.id == id) {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("not_found", "no such session on this daemon")),
)
.into_response();
}
guard.structured_prompts.push((id, req));
StatusCode::NO_CONTENT.into_response()
}
async fn daemon_info(State(s): State<AppState>) -> Response { async fn daemon_info(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) { if let Some(r) = forced(&s) {
return r; return r;
+10 -4
View File
@@ -85,10 +85,10 @@ export function createStructuredConnection(
<div class="structured-main"> <div class="structured-main">
<div class="structured-scroll"> <div class="structured-scroll">
<div class="structured-rows"></div> <div class="structured-rows"></div>
</div> <div class="structured-activity" role="status" aria-live="polite" hidden>
<div class="structured-activity" role="status" aria-live="polite" hidden> <span class="structured-activity-pulse" aria-hidden="true"></span>
<span class="structured-activity-pulse" aria-hidden="true"></span> <span class="structured-activity-text">Thinking</span>
<span class="structured-activity-text">Thinking</span> </div>
</div> </div>
<form class="structured-composer"> <form class="structured-composer">
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)"> <button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
@@ -698,6 +698,7 @@ export function createStructuredConnection(
function renderActivity(): void { function renderActivity(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null; const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const wasHidden = activityEl.hidden;
activityEl.hidden = !active; activityEl.hidden = !active;
if (!active) { if (!active) {
optimisticTurnStartedAt = null; optimisticTurnStartedAt = null;
@@ -720,6 +721,11 @@ export function createStructuredConnection(
} }
activityText.textContent = parts.join(" · "); activityText.textContent = parts.join(" · ");
if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000); if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000);
// Activity lives inside the scroll area; nudge scroll when it first appears.
if (wasHidden && followingBottom) {
beginTranscriptLayout(true);
finishTranscriptLayout();
}
} }
function reconcileAwaitingBegin(): void { function reconcileAwaitingBegin(): void {
-2
View File
@@ -2318,9 +2318,7 @@ input.leaf-rename:focus {
gap: var(--sp-2); gap: var(--sp-2);
min-height: 30px; min-height: 30px;
padding: var(--sp-1) var(--sp-3); padding: var(--sp-1) var(--sp-3);
border-top: 1px solid var(--border);
color: var(--muted); color: var(--muted);
background: var(--surface-2);
font-size: var(--fs-11); font-size: var(--fs-11);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }