Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2d427691d | ||
|
|
50b862b1fa | ||
|
|
4668cf8ece | ||
|
|
638155de03 | ||
|
|
1c0fff5507 | ||
|
|
10c9db6d87 | ||
|
|
b339fecad3 |
@@ -41,8 +41,8 @@ pub enum Command {
|
||||
Attach(AttachArgs),
|
||||
/// Kill a session by ID.
|
||||
Kill(KillArgs),
|
||||
/// Type text into a running session, as if it had been typed on an
|
||||
/// attached terminal.
|
||||
/// Send text to a running session. Terminal sessions receive raw
|
||||
/// keystrokes; structured sessions receive an ACP prompt.
|
||||
Send(SendArgs),
|
||||
/// Install or update the agent activity hook (#106).
|
||||
Hooks(HooksArgs),
|
||||
|
||||
@@ -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
|
||||
/// 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
|
||||
/// against. Text comes from the arguments, or from stdin when there is none —
|
||||
/// the shape a heredoc or a pipe wants.
|
||||
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
|
||||
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
|
||||
/// 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)]
|
||||
pub async fn run_send(
|
||||
client: &DaemonClient,
|
||||
@@ -155,19 +158,28 @@ pub async fn run_send(
|
||||
return Err(CliError::NothingToSend);
|
||||
}
|
||||
|
||||
let id = match uuid::Uuid::parse_str(args.session.trim()) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
let sessions = client.list_sessions().await?;
|
||||
resolve_session(&sessions, &args.session)?
|
||||
}
|
||||
};
|
||||
// We always need the session list: even a bare UUID needs the view field
|
||||
// to decide which endpoint to hit.
|
||||
let sessions = client.list_sessions().await?;
|
||||
let id = 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 {
|
||||
text,
|
||||
submit: !args.no_enter,
|
||||
};
|
||||
client.send_input(id, &req).await?;
|
||||
match view {
|
||||
SessionView::Structured => {
|
||||
client.send_structured_prompt(id, &text).await?;
|
||||
}
|
||||
SessionView::Terminal => {
|
||||
let req = SendInputRequest {
|
||||
text,
|
||||
submit: !args.no_enter,
|
||||
};
|
||||
client.send_input(id, &req).await?;
|
||||
}
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
||||
//! 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 uuid::Uuid;
|
||||
|
||||
@@ -202,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
|
||||
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]
|
||||
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
||||
let mock = MockDaemon::start().await;
|
||||
|
||||
@@ -22,7 +22,8 @@ use chrono::Utc;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_protocol::{
|
||||
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::sync::{broadcast, oneshot};
|
||||
@@ -60,6 +61,8 @@ struct Inner {
|
||||
forced_error: Option<(u16, ApiError)>,
|
||||
/// Everything `POST /api/sessions/:id/input` was asked to type, in order.
|
||||
inputs: Vec<(Uuid, SendInputRequest)>,
|
||||
/// Every ACP prompt sent to a structured session, in order.
|
||||
structured_prompts: Vec<(Uuid, StructuredPromptRequest)>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -92,6 +95,10 @@ impl MockDaemon {
|
||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||
.route(routes::patterns::SESSION, delete(kill_session))
|
||||
.route(routes::patterns::SESSION_INPUT, post(send_input))
|
||||
.route(
|
||||
routes::patterns::STRUCTURED_PROMPT,
|
||||
post(structured_prompt),
|
||||
)
|
||||
.route(routes::INFO, get(daemon_info))
|
||||
.route(routes::DIRS, get(list_dir))
|
||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||
@@ -178,6 +185,16 @@ impl MockDaemon {
|
||||
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 {
|
||||
self.state.inner.lock().expect("poisoned").sessions.len()
|
||||
}
|
||||
@@ -266,6 +283,26 @@ async fn send_input(
|
||||
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 {
|
||||
if let Some(r) = forced(&s) {
|
||||
return r;
|
||||
|
||||
@@ -193,10 +193,13 @@ choices, not daemon ones.
|
||||
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
|
||||
appear *between* tool rows in the same turn are absorbed into the group
|
||||
(intermediate narration); the last `AgentText` with no subsequent tool stays
|
||||
outside as the prominent final answer. The header still reads
|
||||
"N tool calls" (counting only tool rows). Opening the group reveals tools
|
||||
and intermediate text in chronological order, with the text rendered in a
|
||||
subdued style.
|
||||
outside as the prominent final answer. While a turn is still active
|
||||
(responding or thinking), the most recent intermediate `AgentText` or
|
||||
`AgentThought` in the group is kept outside as visible narration — replaced
|
||||
by the next text row when one arrives, and absorbed into the group when the
|
||||
turn ends. The header still reads "N tool calls" (counting only tool rows).
|
||||
Opening the group reveals tools and intermediate text in chronological
|
||||
order, with the text rendered in a subdued style.
|
||||
- The web client's user, agent, thought, tool-detail, and notice text blocks
|
||||
copy their complete source text when clicked or keyboard-activated.
|
||||
- The web client consumes `available_commands_update` notices as the live
|
||||
|
||||
@@ -446,6 +446,40 @@ describe("structured connection: data flow", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps last intermediate text outside group while turn is active", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
const respondingState: ReducedState = {
|
||||
turn: { responding: true, thinking: false, active_tool_calls: [] },
|
||||
};
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 6,
|
||||
rows: [
|
||||
row("user:1", "user", { content: { type: "text", text: "do it" } }),
|
||||
toolRow("tool:1", "one"),
|
||||
row("agent:1", "agent_text", { content: { type: "text", text: "Now fixing" } }),
|
||||
toolRow("tool:2", "two"),
|
||||
row("agent:2", "agent_text", { content: { type: "text", text: "Found more" } }),
|
||||
toolRow("tool:3", "three"),
|
||||
],
|
||||
},
|
||||
});
|
||||
ws.emit({ type: "reduced_state", state: respondingState });
|
||||
// While the turn is active, the last intermediate text (agent:2) stays
|
||||
// outside the collapsed group so it's visible as current narration.
|
||||
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).toBeNull();
|
||||
// Earlier intermediate text (agent:1) is still inside the group.
|
||||
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||
// When the turn ends, all intermediate text is absorbed.
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
|
||||
+77
-23
@@ -85,10 +85,10 @@ export function createStructuredConnection(
|
||||
<div class="structured-main">
|
||||
<div class="structured-scroll">
|
||||
<div class="structured-rows"></div>
|
||||
</div>
|
||||
<div class="structured-activity" role="status" aria-live="polite" hidden>
|
||||
<span class="structured-activity-pulse" aria-hidden="true"></span>
|
||||
<span class="structured-activity-text">Thinking</span>
|
||||
<div class="structured-activity" role="status" aria-live="polite" hidden>
|
||||
<span class="structured-activity-pulse" aria-hidden="true"></span>
|
||||
<span class="structured-activity-text">Thinking</span>
|
||||
</div>
|
||||
</div>
|
||||
<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)">
|
||||
@@ -453,6 +453,25 @@ export function createStructuredConnection(
|
||||
}
|
||||
}
|
||||
// pending at end of a turn = final text/approvals, stays outside (not flushed).
|
||||
|
||||
// While the turn is still active, keep the last intermediate text/thought
|
||||
// row outside the collapsed group so it's visible as the agent's current
|
||||
// narration. It gets absorbed on the next rebuild once newer content
|
||||
// arrives or the turn ends.
|
||||
const turnActive = state.turn.responding || state.turn.thinking;
|
||||
if (turnActive) {
|
||||
const lastGroup = groups.get(currentGid);
|
||||
if (lastGroup) {
|
||||
for (let i = lastGroup.rows.length - 1; i >= 0; i--) {
|
||||
const kind = lastGroup.rows[i].kind;
|
||||
if (kind === "agent_text" || kind === "agent_thought") {
|
||||
lastGroup.rows.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
@@ -467,6 +486,7 @@ export function createStructuredConnection(
|
||||
const liveGroupIds = new Set([...groups.keys()]);
|
||||
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
|
||||
|
||||
const prevGroupEls = new Map(groupEls);
|
||||
rowEls.clear();
|
||||
groupEls.clear();
|
||||
const children: HTMLElement[] = [];
|
||||
@@ -485,23 +505,37 @@ export function createStructuredConnection(
|
||||
rowEls.set(memberRow.id, memberEl);
|
||||
return [memberEl];
|
||||
});
|
||||
const groupEl = buildToolGroup(
|
||||
groupId,
|
||||
group.rows,
|
||||
memberEls,
|
||||
expandedGroupIds.has(groupId),
|
||||
(expanded) => {
|
||||
if (expanded) expandedGroupIds.add(groupId);
|
||||
else expandedGroupIds.delete(groupId);
|
||||
settleDisclosureLayout();
|
||||
},
|
||||
group.toolCount,
|
||||
);
|
||||
if (group.rows.some((r) => enteringRowIds.has(r.id))) {
|
||||
groupEl.classList.add("st-row-enter");
|
||||
|
||||
// Reuse existing group element when possible — avoids tearing down
|
||||
// and recreating the wrapper, which restarts CSS transitions (visible
|
||||
// flash) and destroys keyboard/focus state.
|
||||
const existing = prevGroupEls.get(groupId);
|
||||
if (existing) {
|
||||
const titleEl = existing.querySelector<HTMLElement>(".st-tool-group-title");
|
||||
if (titleEl) titleEl.textContent = `${group.toolCount} tool calls`;
|
||||
const inner = existing.querySelector<HTMLElement>(".st-disclosure-inner");
|
||||
if (inner) inner.replaceChildren(...memberEls);
|
||||
groupEls.set(groupId, existing);
|
||||
children.push(existing);
|
||||
} else {
|
||||
const groupEl = buildToolGroup(
|
||||
groupId,
|
||||
group.rows,
|
||||
memberEls,
|
||||
expandedGroupIds.has(groupId),
|
||||
(expanded) => {
|
||||
if (expanded) expandedGroupIds.add(groupId);
|
||||
else expandedGroupIds.delete(groupId);
|
||||
settleDisclosureLayout();
|
||||
},
|
||||
group.toolCount,
|
||||
);
|
||||
if (group.rows.some((r) => enteringRowIds.has(r.id))) {
|
||||
groupEl.classList.add("st-row-enter");
|
||||
}
|
||||
groupEls.set(groupId, groupEl);
|
||||
children.push(groupEl);
|
||||
}
|
||||
groupEls.set(groupId, groupEl);
|
||||
children.push(groupEl);
|
||||
continue;
|
||||
}
|
||||
// Resolved approvals not absorbed into a tool group are hidden — the
|
||||
@@ -616,12 +650,14 @@ export function createStructuredConnection(
|
||||
}
|
||||
}
|
||||
|
||||
let configDirty = false;
|
||||
function renderConfig(): void {
|
||||
const options = sessionConfigOptions();
|
||||
configEl.hidden = options.length === 0;
|
||||
if (options.length === 0) {
|
||||
configMenu.hidden = true;
|
||||
configTrigger.setAttribute("aria-expanded", "false");
|
||||
configDirty = false;
|
||||
return;
|
||||
}
|
||||
const ordered = [...options].sort((a, b) => {
|
||||
@@ -635,6 +671,15 @@ export function createStructuredConnection(
|
||||
configTrigger.title = summary;
|
||||
configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`);
|
||||
configTrigger.disabled = configBusy;
|
||||
|
||||
// When the menu is open and the user is interacting (not mid-change),
|
||||
// skip the DOM rebuild so open <select> dropdowns aren't destroyed.
|
||||
// Mark dirty so the next open rebuilds with fresh data.
|
||||
if (!configMenu.hidden && !configBusy) {
|
||||
configDirty = true;
|
||||
return;
|
||||
}
|
||||
configDirty = false;
|
||||
configMenu.replaceChildren();
|
||||
for (const option of ordered) {
|
||||
const row = document.createElement("label");
|
||||
@@ -698,6 +743,7 @@ export function createStructuredConnection(
|
||||
|
||||
function renderActivity(): void {
|
||||
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||
const wasHidden = activityEl.hidden;
|
||||
activityEl.hidden = !active;
|
||||
if (!active) {
|
||||
optimisticTurnStartedAt = null;
|
||||
@@ -720,6 +766,11 @@ export function createStructuredConnection(
|
||||
}
|
||||
activityText.textContent = parts.join(" · ");
|
||||
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 {
|
||||
@@ -735,15 +786,17 @@ export function createStructuredConnection(
|
||||
}
|
||||
|
||||
function applyState(next: ReducedState, connectionGeneration: number): void {
|
||||
const wasTurnActive = state.turn.responding || state.turn.thinking;
|
||||
state = next;
|
||||
stateSocketGeneration = connectionGeneration;
|
||||
reconcileAwaitingBegin();
|
||||
const isTurnActive = state.turn.responding || state.turn.thinking;
|
||||
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
||||
if (newPendingId !== pendingApprovalRowId) {
|
||||
const needsRebuild = newPendingId !== pendingApprovalRowId || wasTurnActive !== isTurnActive;
|
||||
if (needsRebuild) {
|
||||
updateTranscriptLayout(() => {
|
||||
const prev = pendingApprovalRowId;
|
||||
pendingApprovalRowId = newPendingId;
|
||||
if (prev !== null || newPendingId !== null) rebuildTranscript();
|
||||
rebuildTranscript();
|
||||
});
|
||||
}
|
||||
renderConfig();
|
||||
@@ -758,6 +811,7 @@ export function createStructuredConnection(
|
||||
|
||||
configTrigger.addEventListener("click", () => {
|
||||
const opening = configMenu.hidden;
|
||||
if (opening && configDirty) renderConfig();
|
||||
configMenu.hidden = !opening;
|
||||
configTrigger.setAttribute("aria-expanded", String(opening));
|
||||
});
|
||||
|
||||
@@ -2318,9 +2318,7 @@ input.leaf-rename:focus {
|
||||
gap: var(--sp-2);
|
||||
min-height: 30px;
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
font-size: var(--fs-11);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user