Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/maple-agent/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ GPUI desktop app for Maple. Workspace crates: `app` (package `maple-agent-app`,
`crates/pi-agent-core`, `crates/pi-coding-agent`, which must not depend on Maple
crates. See `README.md` for the layout, prerequisites, and command line modes.

Keep `crates/pi-*` unchanged for Maple product work. Implement policy and UI
behavior through the existing host, extension, and operations interfaces in
`crates/maple-agent` and `app`. Exceptions must address a critical need and
explain why existing interfaces are insufficient; see `crates/UPSTREAM.md` for
upstream review work.

Packaged Dev and Prod OAuth use their compiled Auth origins and a one-attempt
loopback listener in `app/src/hosted_oauth.rs`; unpackaged builds retain the
provider callback paste flow. Keep the listener bound to `127.0.0.1` through
Expand Down
18 changes: 18 additions & 0 deletions apps/maple-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ agent runtime; its desktop Agent page points users to Maple Work.
attachments (picker, paste, or drag and drop), a per-message Copy button,
and a context-window indicator. Every tool call runs without asking for
approval.
- Shell tool calls start in the task's saved folder on the execution computer.
A `cd` inside a command does not affect later calls. Repository searches
should name the intended checkout/worktree with explicit absolute paths.
Maple supplies a 30-minute default deadline to allow slow builds, and the
call can request a longer
`timeout` explicitly. Search guidance asks for a short timeout (normally
30 seconds) and a narrower scope if it expires; that shorter limit applies
only when the call requests it.
Quiet commands report their starting folder, elapsed time, time since output and
deadline every five seconds, including on collapsed tool cards. Stop and
deadlines use Pi's process-group cleanup on Unix (process-tree cleanup on Windows).
Maple bounds the whole operation, including setup and stdin delivery, and allows
up to one second for cooperative cancellation before dropping a stuck operation.
The execution policy and progress reporting live in Maple; the Pi crates are unchanged.
These are execution safeguards, not a filesystem sandbox: repository searches
must still choose the correct folder and bounded scope, and deliberately
detached processes can escape a Unix process group. `head` bounds output,
not runtime.
- Slash commands in the composer: `/btw` asks a side question the task
never sees, plus `/compact`, `/new`, `/pin`, `/web`, `/model`, and
`/help`. Skills and prompt templates appear in the same list.
Expand Down
3 changes: 3 additions & 0 deletions apps/maple-agent/app/src/ui/chat/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,8 @@ pub(super) struct ItemDerived {
pub(super) attachments: Vec<AttachmentDisplay>,
/// Readable tool output for the expanded card.
pub(super) output_text: Option<SharedString>,
/// Shell execution status stays visible even on a collapsed card.
pub(super) shell_status: Option<SharedString>,
/// `input: {json}` for the expanded card.
pub(super) input_line: Option<SharedString>,
/// +/- lines of an edit or write tool, capped at `MAX_DIFF_LINES`.
Expand All @@ -290,6 +292,7 @@ impl ItemDerived {
})
.collect(),
output_text,
shell_status: super::transcript::shell_execution_status(item).map(SharedString::from),
input_line: tool_input_line(item).map(SharedString::from),
diff_lines: Arc::new(diff_lines_for(item)),
external_agent: external_agent_activity(item).map(Arc::new),
Expand Down
21 changes: 20 additions & 1 deletion apps/maple-agent/app/src/ui/chat/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10688,8 +10688,14 @@ mod state_tests {
chat.booting = false;
chat.replace_timeline(vec![
AgentTimelineItem {
status: Some("completed".to_string()),
status: Some("running".to_string()),
title: Some("Terminal: cargo test".to_string()),
output: Some(serde_json::json!({"structuredContent": {"shellExecution": {
"cwd": "/fixture/worktree",
"elapsedSeconds": 65,
"outputIdleSeconds": 60,
"timeoutSeconds": 120,
}}})),
..item("tool-1", "tool", None)
},
AgentTimelineItem {
Expand All @@ -10711,6 +10717,19 @@ mod state_tests {
cx.simulate_resize(gpui::size(px(1200.), px(800.)));

let card = cx.debug_bounds("tool-card").expect("the tool card renders");
assert!(
cx.debug_bounds("shell-execution-status").is_some(),
"shell status remains visible on a collapsed card"
);
chat.read_with(cx, |chat, _| {
let status =
super::super::transcript::shell_execution_status(&chat.timeline[0]).unwrap();
assert!(status.contains("/fixture/worktree"));
assert!(status.contains("Running 65s"));
assert!(status.contains("no output for 60s"));
assert!(status.contains("deadline 120s"));
assert!(status.contains("Stop cancels command"));
});
let tool_title = cx
.debug_bounds("tool-title")
.expect("the tool card title renders");
Expand Down
42 changes: 39 additions & 3 deletions apps/maple-agent/app/src/ui/chat/transcript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1130,7 +1130,7 @@ fn render_tool(
let raw = item.title.as_deref().unwrap_or(&item.item_type);
SharedString::from(tool_label_title(raw).to_string())
});
let card = div()
let mut card = div()
.id(gpui::SharedString::from(format!("tool-toggle-{item_id}")))
.debug_selector(|| "tool-card".to_string())
.role(gpui::Role::DisclosureTriangle)
Expand Down Expand Up @@ -1201,8 +1201,20 @@ fn render_tool(
theme::text_muted(),
)),
);
// Collapsed: the header alone; the summary takes over the title when
// it lands. Only expanded cards pay for the derived payload strings.
if running
&& shell_execution(item).is_some()
&& let Some(status) = &transcript.derived.get(item, revision).shell_status
{
card = card.child(
div()
.text_xs()
.text_color(gpui::rgb(theme::text_secondary()))
.debug_selector(|| "shell-execution-status".to_string())
.child(status.clone()),
);
}
// Collapsed: the header and live shell status; the summary takes over the title when
// it lands. Expanded cards also show the cached arguments and output.
if !details {
return div().child(card);
}
Expand Down Expand Up @@ -1390,6 +1402,30 @@ pub(super) fn tool_output_markdown(item: &AgentTimelineItem) -> Option<String> {
(!trimmed.is_empty()).then(|| trimmed.to_string())
}

fn shell_execution(item: &AgentTimelineItem) -> Option<&serde_json::Value> {
item.output
.as_ref()?
.get("structuredContent")?
.get("shellExecution")
}

pub(super) fn shell_execution_status(item: &AgentTimelineItem) -> Option<String> {
let execution = shell_execution(item)?;
let cwd = execution.get("cwd")?.as_str()?;
let elapsed = execution.get("elapsedSeconds")?.as_u64()?;
let idle = execution.get("outputIdleSeconds")?.as_u64()?;
let deadline = execution
.get("timeoutSeconds")
.and_then(serde_json::Value::as_f64)
.map_or_else(
|| "no deadline".to_string(),
|seconds| format!("deadline {seconds}s"),
);
Some(format!(
"Started in {cwd}\nRunning {elapsed}s · no output for {idle}s · {deadline} · Stop cancels command"
))
}

fn extract_output_text(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(text) => Some(text.clone()),
Expand Down
17 changes: 12 additions & 5 deletions apps/maple-agent/crates/maple-agent/src/agent/runs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ use super::{
};

mod live;
mod shell_progress;
use live::LiveSnapshot;
use shell_progress::ShellProgress;

const AGENT_RUN_EVENT_CAPACITY: usize = 256;
const MAX_DESKTOP_QUEUE_ITEMS: usize = 16;
Expand Down Expand Up @@ -1588,7 +1590,11 @@ impl AgentRuntime {
} = run;
let attachments =
AgentAttachmentStore::new(Arc::clone(self.store.store()), self.store.account());
let watch = RunWatch::default();
let watch = RunWatch {
state: Arc::default(),
shells: ShellProgress::new(session.extension_context().cwd()),
};
let shell_status = watch.shells.start(events.clone());
let listener = session.subscribe({
let events = events.clone();
let watch = watch.clone();
Expand Down Expand Up @@ -1749,6 +1755,7 @@ impl AgentRuntime {
run.accepting = false;
}
session.unsubscribe(listener);
shell_status.stop().await;

let cancelled = stopped.is_cancelled();
if cancelled || failure.is_some() || deferred.failure.is_some() {
Expand Down Expand Up @@ -1982,9 +1989,10 @@ struct DrivenRun {

/// What a run's listener saw: the usage of its replies and whether Pi
/// retried.
#[derive(Clone, Default)]
#[derive(Clone)]
struct RunWatch {
state: Arc<Mutex<RunWatchState>>,
shells: Arc<ShellProgress>,
}

#[derive(Default)]
Expand Down Expand Up @@ -2030,16 +2038,15 @@ impl RunWatch {
} else {
let tool = match event {
pi_agent_core::AgentEvent::ToolExecutionStart { tool_call_id, .. }
| pi_agent_core::AgentEvent::ToolExecutionUpdate { tool_call_id, .. }
| pi_agent_core::AgentEvent::ToolExecutionEnd { tool_call_id, .. } => {
Some(tool_call_id.as_str())
}
_ => None,
};
(projected, tool, false)
};
for row in rows {
events.publish_row(AgentRunEvent::TimelineItem(row), tool, completed);
}
self.shells.publish(event, rows, tool, completed, events);
}
AgentSessionEvent::EntryAppended { entry } => events.note_source(entry),
AgentSessionEvent::RetryStart { .. } => lock(&self.state).retried = true,
Expand Down
Loading
Loading