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
13 changes: 10 additions & 3 deletions .agents/skills/develop-maple-agent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,17 @@ cleanup. Do not run raw `cargo clean` against the shared cache.
For composer integrations and external providers, read
`apps/maple-agent/docs/external-agents.md`. Keep provider metadata in the
runtime catalog and pass typed selection kinds through the UI bridge so
user-controlled MCP names cannot shadow provider IDs. Preserve inherited
defaults for tasks without overrides and CUA's existing backend metadata.
user-controlled MCP names cannot shadow provider IDs. External providers must
be enabled in Settings before appearing in the composer, and each task must
explicitly select them. A saved task choice cannot bypass Settings. Preserve
CUA's existing backend metadata.
Exercise warm and cold session tool catalogs and ACP exclusion when changing
run-boundary admission.
run-boundary admission. Claude Code uses a native Rust transport adapted from
the pinned Goose provider in `external_agents/claude.rs`. Preserve the source
attribution when changing that adapted code. Keep process ownership in Maple's
contained host. The `claude_native_*` tests re-execute the
Rust test binary as a CLI fixture and need no Claude account or inference
request. Keep fixture launch and environment setup shared with the Codex tests.

## Security and publication

Expand Down
29 changes: 20 additions & 9 deletions apps/maple-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,10 @@ Cargo manifests and lockfile; Research has an independent dependency graph.
keeps its row after the turn ends, and Maple tells the task when it
finishes, with a bounded result in the running turn or a new turn Maple
starts automatically. The task can use `load` to retrieve any truncated output.
- External agents: a task can hand work to the Codex CLI installed on
- External agents: a task can hand work to Codex or Claude Code installed on
this computer with the `agent_start`, `agent_send`, `agent_status`,
`agent_cancel`, and `list_agent_providers` tools, once Codex is enabled
under Settings > Integrations. Codex runs in the project with its own
`agent_cancel`, and `list_agent_providers` tools, once the provider is enabled
under Settings > Integrations. Each agent runs in the project with its own
account, context, and sandbox settings; whatever it asks approval for
comes to you through Maple's permission card, and Allow all grants it. Its progress streams
into the tool call's row and its row above the composer has a Stop
Expand Down Expand Up @@ -158,16 +158,27 @@ account configuration that may roam between devices.
The embedded design, migration rules, privacy boundary, and preview limits are
documented in [`docs/embedded-cua.md`](docs/embedded-cua.md).

#### Claude Code

Settings > Integrations lists Claude Code (`claude`) alongside Codex, with the
same per-task selection, streamed activity, permission cards, and Stop control.
Install the Claude Code CLI on the app's PATH and sign in using
`claude auth login`. Maple uses a Rust transport adapted from Goose's Claude
Code provider. The CLI is the only external runtime dependency. The integration
is off by default. Enable it in Settings to show it in the composer, then
select it for the tasks that should use it. See
[external agents](docs/external-agents.md#how-claude-code-is-driven).

#### Codex

Settings > Integrations also lists the Codex CLI when `codex` is on the PATH
(the login shell's PATH on macOS). The card shows the installed version and
whether Codex is signed in; Maple never runs Codex's sign-in itself. The
toggle is off by default. The composer lists Codex alongside CUA and custom
MCP servers, with an independent choice for each task. Tasks without an
explicit Codex choice inherit the Settings default on every run, including
older tasks; composer overrides survive relaunches. Enabling it gives runs
the external-agent tools and installs the `handoff`, `committee`, and `advisor` skills into the
toggle is off by default. Enabling it makes Codex available in the composer
alongside CUA and custom MCP servers. Each task must select Codex explicitly;
that choice survives relaunches but only applies while Settings enables Codex.
Selecting it gives that task the external-agent tools. Enabling it in Settings
installs the `handoff`, `committee`, and `advisor` skills into the
account's Goose skills directory; disabling removes only the files Maple
wrote. Codex needs version 0.143 or newer. See
[`docs/external-agents.md`](docs/external-agents.md).
Expand Down Expand Up @@ -433,7 +444,7 @@ The roots follow the platform, the same way the Tauri app's
| `<config>/settings.json` | App settings. |
| `<config>/agent/accounts/<scope>/config.json` | Per-account agent configuration (default root, model, custom MCP servers, project trust). May roam between machines. |
| `<config>/agent/accounts/<scope>/goose/config/` | Goose permission file for the account. |
| `<config>/agent/accounts/<scope>/goose/config/skills/` | Skills the account's tasks can load, including the delegation skills Maple installs while Codex is enabled. |
| `<config>/agent/accounts/<scope>/goose/config/skills/` | Skills the account's tasks can load, including the delegation skills Maple installs while any external agent is enabled. |
| `<config>/agent/goose-runtime/` | Goose process configuration. |
| `<local data>/auth.json` | Sign-in credentials (mode 0600). Device-local; never in a roaming profile. |
| `<local data>/agent/accounts/<scope>/integrations.json` | Per-account defaults and validated launch details for integrations detected on this device. |
Expand Down
1 change: 1 addition & 0 deletions apps/maple-agent/app/assets/icons/claude-mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions apps/maple-agent/app/src/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ assets!(
"icons/check.svg",
"icons/chevron-down.svg",
"icons/chevron-right.svg",
// Claude mark (simple-icons, CC0) for the Claude Code integration card.
"icons/claude-mark.svg",
"icons/copy.svg",
// Contrast-safe partner marks published at https://cua.ai/branding.
"icons/cua-mark-black.svg",
Expand Down
80 changes: 64 additions & 16 deletions apps/maple-agent/app/src/ui/chat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
//! facade + event stream.

use crate::settings::PermissionMode;
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::Arc;

use gpui::{
Expand Down Expand Up @@ -168,6 +168,12 @@ pub(crate) struct SpeechState {
pub playing: bool,
}

#[derive(Default)]
pub(super) struct QuestionSelection {
cursor: Option<usize>,
picked: BTreeSet<usize>,
}

pub struct ChatScreen {
backend: Arc<AgentBackend>,
user_id: String,
Expand Down Expand Up @@ -388,7 +394,7 @@ pub struct ChatScreen {
/// inverts the `tool_details` default for that item.
toggled_tools: HashSet<String>,
/// Ticked options of a multi-select question.
question_selected: HashMap<usize, usize>,
question_selected: HashMap<usize, QuestionSelection>,
/// Index of the question being shown within the current card; a batch
/// iterates one question at a time instead of listing them all.
question_step: usize,
Expand Down Expand Up @@ -2762,8 +2768,7 @@ impl ChatScreen {
self.sidebar.read(cx).step_target(delta)
}

/// Ctrl-1 to Ctrl-9: answer the question card with the numbered
/// option, the same as picking it and pressing Answer.
/// Ctrl-1 to Ctrl-9 submits a single choice or toggles a multi-select option.
fn pick_and_submit_question_option(&mut self, index: usize, cx: &mut Context<Self>) {
let Some(question) = self.current_question() else {
return;
Expand All @@ -2779,8 +2784,12 @@ impl ChatScreen {
if index >= options {
return;
}
self.select_question_option(step, index, cx);
self.submit_question(cx);
if question.questions[step].multi_select {
self.toggle_question_option(step, index, cx);
} else {
self.select_question_option(step, index, cx);
self.submit_question(cx);
}
}

fn escape(&mut self, cx: &mut Context<Self>) {
Expand Down Expand Up @@ -3569,10 +3578,15 @@ impl ChatScreen {
.map(|input| input.read(cx).text())
.unwrap_or_default();
let typed = typed.trim();
if let Some(option_index) = self.question_selected.get(&step)
&& let Some(option) = entry.options.get(*option_index)
{
let mut answer = vec![option.label.clone()];
let mut answer: Vec<_> = self
.question_selected
.get(&step)
.into_iter()
.flat_map(|selection| &selection.picked)
.filter_map(|index| entry.options.get(*index))
.map(|option| option.label.clone())
.collect();
if !answer.is_empty() {
if !typed.is_empty() {
// The prefix keeps the note from reading as a second
// picked option in the echoed answers array.
Expand Down Expand Up @@ -3669,28 +3683,62 @@ impl ChatScreen {
}
}

/// Pick one option of one question (single-select, codex shape).
/// Pick an option, replacing earlier choices only for single-select questions.
fn select_question_option(
&mut self,
question_index: usize,
option_index: usize,
cx: &mut Context<Self>,
) {
self.question_selected.insert(question_index, option_index);
let Some(question) = self
.current_question()
.and_then(|q| q.questions.get(question_index))
else {
return;
};
if option_index >= question.options.len() {
return;
}
let multi_select = question.multi_select;
let selection = self.question_selected.entry(question_index).or_default();
selection.cursor = Some(option_index);
if !multi_select {
selection.picked.clear();
}
selection.picked.insert(option_index);
cx.notify();
}

/// Vim navigation moves the cursor without checking multi-select options.
fn focus_question_option(&mut self, step: usize, index: usize, cx: &mut Context<Self>) {
if self
.current_question()
.and_then(|q| q.questions.get(step))
.is_some_and(|q| q.multi_select)
{
self.question_selected.entry(step).or_default().cursor = Some(index);
cx.notify();
} else {
self.select_question_option(step, index, cx);
}
}

/// Click handler for an option row: clicking the picked option again
/// clears it so a typed answer can stand alone. Ctrl-N keeps plain
/// select semantics because it submits immediately.
/// clears it so a typed answer can stand alone.
fn toggle_question_option(
&mut self,
question_index: usize,
option_index: usize,
cx: &mut Context<Self>,
) {
if self.question_selected.get(&question_index) == Some(&option_index) {
self.question_selected.remove(&question_index);
if let Some(selection) = self.question_selected.get_mut(&question_index)
&& selection.picked.remove(&option_index)
{
if selection.picked.is_empty() {
self.question_selected.remove(&question_index);
} else {
selection.cursor = Some(option_index);
}
cx.notify();
} else {
self.select_question_option(question_index, option_index, cx);
Expand Down
24 changes: 16 additions & 8 deletions apps/maple-agent/app/src/ui/chat/navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,10 +336,9 @@ impl ChatScreen {
.map(|question| question.options.len())
.unwrap_or(0);
if len > 0 {
let current = self.question_selected.get(&step).copied();
let current = self.question_selected.get(&step).and_then(|s| s.cursor);
let next = stepped_index(current, len, direction, count);
self.question_selected.insert(step, next);
cx.notify();
self.focus_question_option(step, next, cx);
}
return;
}
Expand Down Expand Up @@ -383,9 +382,7 @@ impl ChatScreen {
.map(|question| question.options.len())
.unwrap_or(0);
if len > 0 {
self.question_selected
.insert(step, if first { 0 } else { len - 1 });
cx.notify();
self.focus_question_option(step, if first { 0 } else { len - 1 }, cx);
}
return;
}
Expand Down Expand Up @@ -558,13 +555,24 @@ impl ChatScreen {
.min(question.questions.len().saturating_sub(1))
})
.unwrap_or_default();
if self.question_selected.contains_key(&step) {
if self
.current_question()
.and_then(|q| q.questions.get(step))
.is_some_and(|q| q.multi_select)
&& let Some(index) = self.question_selected.get(&step).and_then(|s| s.cursor)
{
self.toggle_question_option(step, index, cx);
} else if self
.question_selected
.get(&step)
.is_some_and(|s| !s.picked.is_empty())
{
self.submit_question(cx);
} else if let Some(input) = self.pending_question_input.clone() {
// Enter before an option is picked is the semantic route into
// the card's free-form answer. Enter inside the input still
// submits through TextInput's existing callback; j/k followed
// by Enter retains the direct option-submit path.
// by Enter retains the direct submit path for single choices.
let handle = input.read(cx).focus_handle(cx);
window.focus(&handle, cx);
cx.notify();
Expand Down
Loading
Loading