T24e: Agent, environment
- SandboxStatus: a dot and a word (a ring while it starts or works), what it uses of what it has as meters, and how long it has run; the header wraps - McpServerList over lists::ListItem: a dot per state, what it runs or why it failed, its tools once connected, and a switch - ToolRegistry: tools grouped by source, found by every word of the owner's query, each Allow, Ask or Deny; rows wrap at 280px - MemoryPanel: a line to keep a memory, Enter or Remember, blank words kept out; each memory with its source, when, and Forget - CheckpointList over data_display::Timeline, newest first, the current point marked, Rewind on the rest - gallery: every control wired to state
This commit is contained in:
1 parent
8550c086b0
commit
cbdcf85cec
9 files changed
+1245
-5
No files matched your search
@@ -0,0 +1,281 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use ely_gpui_component::{
|
||||||
|
agent::{
|
||||||
|
Checkpoint, CheckpointList, McpServer, McpServerList, Memory, MemoryPanel, RegisteredTool,
|
||||||
|
SandboxState, SandboxStatus, ServerState, ToolAccess, ToolRegistry,
|
||||||
|
},
|
||||||
|
forms::{SearchInput, TextInput},
|
||||||
|
};
|
||||||
|
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||||
|
use jiff::{Timestamp, ToSpan};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
probe::probe,
|
||||||
|
ui::{keep, section, set},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn sandbox_section(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||||
|
section(
|
||||||
|
"SandboxStatus",
|
||||||
|
"Where an agent's sandbox stands: a dot and a word, what it uses of what it has, and how long it has run. A dot rings while it starts or works.",
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_wrap()
|
||||||
|
.items_start()
|
||||||
|
.gap_4()
|
||||||
|
.child(
|
||||||
|
div().w(px(320.)).child(
|
||||||
|
SandboxStatus::new("agent-sandbox", "python 3.12 · 2 vCPU", SandboxState::Ready)
|
||||||
|
.cpu(0.18)
|
||||||
|
.memory(1_240_000_000, 4_294_967_296)
|
||||||
|
.uptime(Duration::from_secs(47 * 60)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div().w(px(280.)).child(
|
||||||
|
SandboxStatus::new("agent-sandbox-starting", "node 22 · 1 vCPU", SandboxState::Starting),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn servers() -> Vec<McpServer> {
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"files",
|
||||||
|
"Filesystem",
|
||||||
|
"npx @modelcontextprotocol/server-filesystem ~/ely",
|
||||||
|
ServerState::Connected,
|
||||||
|
11,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"github",
|
||||||
|
"GitHub",
|
||||||
|
"https://api.githubcopilot.com/mcp",
|
||||||
|
ServerState::Connected,
|
||||||
|
38,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"figma",
|
||||||
|
"Figma",
|
||||||
|
"Connecting to the desktop app…",
|
||||||
|
ServerState::Connecting,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"postgres",
|
||||||
|
"Postgres",
|
||||||
|
"Refused: password authentication failed",
|
||||||
|
ServerState::Failed,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"linear",
|
||||||
|
"Linear",
|
||||||
|
"https://mcp.linear.app/sse",
|
||||||
|
ServerState::Off,
|
||||||
|
24,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, name, detail, state, tools)| McpServer {
|
||||||
|
key: key.into(),
|
||||||
|
name: name.into(),
|
||||||
|
detail: detail.into(),
|
||||||
|
state,
|
||||||
|
tools,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tools() -> Vec<RegisteredTool> {
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"read_file",
|
||||||
|
"Filesystem",
|
||||||
|
"Reads a file under the project",
|
||||||
|
ToolAccess::Allow,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"write_file",
|
||||||
|
"Filesystem",
|
||||||
|
"Writes or replaces a file",
|
||||||
|
ToolAccess::Ask,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"run_command",
|
||||||
|
"Filesystem",
|
||||||
|
"Runs a shell command in the sandbox",
|
||||||
|
ToolAccess::Ask,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"create_issue",
|
||||||
|
"GitHub",
|
||||||
|
"Opens an issue in a repository",
|
||||||
|
ToolAccess::Ask,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"merge_pull_request",
|
||||||
|
"GitHub",
|
||||||
|
"Merges a pull request",
|
||||||
|
ToolAccess::Deny,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"web_search",
|
||||||
|
"Built in",
|
||||||
|
"Searches the web and cites sources",
|
||||||
|
ToolAccess::Allow,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, source, description, access)| RegisteredTool {
|
||||||
|
name: name.into(),
|
||||||
|
source: source.into(),
|
||||||
|
description: description.into(),
|
||||||
|
access,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connectors_section(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||||
|
let list = keep("agent-servers", servers, window, cx);
|
||||||
|
let registry = keep("agent-tools", tools, window, cx);
|
||||||
|
let query = window.use_keyed_state("agent-find-tool", cx, |window, cx| {
|
||||||
|
TextInput::new(window, cx).placeholder("Find a tool")
|
||||||
|
});
|
||||||
|
let (now_list, now_tools, now_query) = (
|
||||||
|
list.read(cx).clone(),
|
||||||
|
registry.read(cx).clone(),
|
||||||
|
query.read(cx).text().to_string(),
|
||||||
|
);
|
||||||
|
section(
|
||||||
|
"MCPServerList / ConnectorList / ToolRegistry Panel",
|
||||||
|
"Servers and connectors the agent can reach, each with where it stands and a switch; and every tool they bring, found by name, each allowed, asked for first, or denied.",
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_wrap()
|
||||||
|
.items_start()
|
||||||
|
.gap_6()
|
||||||
|
.child(
|
||||||
|
div().w(px(420.)).child(McpServerList::new("agent-mcp", now_list).on_toggle(move |key, on, _, cx| {
|
||||||
|
let mut next = list.read(cx).clone();
|
||||||
|
let server = next.iter_mut().find(|server| &server.key == key).expect("a listed server");
|
||||||
|
server.state = if on { ServerState::Connected } else { ServerState::Off };
|
||||||
|
set(&list, next, cx)
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(px(460.))
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_3()
|
||||||
|
.child(SearchInput::new("agent-find-tool", &query))
|
||||||
|
.child(
|
||||||
|
ToolRegistry::new("agent-registry", now_tools)
|
||||||
|
.query(now_query)
|
||||||
|
.on_access(move |ix, access, _, cx| {
|
||||||
|
let mut next = registry.read(cx).clone();
|
||||||
|
next[ix].access = access;
|
||||||
|
set(®istry, next, cx)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn memory_section(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||||
|
let now = Timestamp::now();
|
||||||
|
let kept = keep(
|
||||||
|
"agent-memories",
|
||||||
|
|| {
|
||||||
|
vec![
|
||||||
|
Memory {
|
||||||
|
key: "tone".into(),
|
||||||
|
text: "Prefers plain words and short sentences in notes.".into(),
|
||||||
|
source: Some("Design review".into()),
|
||||||
|
at: now - 72.hours(),
|
||||||
|
},
|
||||||
|
Memory {
|
||||||
|
key: "lift".into(),
|
||||||
|
text: "Dark themes lift accents by 18%; surfaces by 4%.".into(),
|
||||||
|
source: Some("Lift the dark theme".into()),
|
||||||
|
at: now - 5.hours(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
let field = window.use_keyed_state("agent-memory-field", cx, |window, cx| {
|
||||||
|
TextInput::new(window, cx).placeholder("Something to keep")
|
||||||
|
});
|
||||||
|
let points = keep(
|
||||||
|
"agent-checkpoints",
|
||||||
|
|| {
|
||||||
|
[
|
||||||
|
("Read the palette", 0, 40),
|
||||||
|
("Measured contrast", 0, 32),
|
||||||
|
("Lifted the accents", 2, 18),
|
||||||
|
("Ran the tests", 2, 9),
|
||||||
|
("Fixed the dark lift", 3, 2),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(label, files, minutes)| Checkpoint {
|
||||||
|
key: SharedString::from(label),
|
||||||
|
label: label.into(),
|
||||||
|
at: now - (minutes as i64).minutes(),
|
||||||
|
files,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
},
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
let at = keep("agent-checkpoint", || 4_usize, window, cx);
|
||||||
|
let (now_kept, now_points, now_at) =
|
||||||
|
(kept.read(cx).clone(), points.read(cx).clone(), *at.read(cx));
|
||||||
|
let (added, forgot) = (kept.clone(), kept);
|
||||||
|
section(
|
||||||
|
"MemoryPanel / CheckpointList / Rewind",
|
||||||
|
"What an agent keeps between conversations, a line to add to it and a way to forget; and the points it can go back to, newest first, the one it stands on marked.",
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_wrap()
|
||||||
|
.items_start()
|
||||||
|
.gap_6()
|
||||||
|
.child(
|
||||||
|
div().w(px(400.)).child(
|
||||||
|
MemoryPanel::new("agent-memory", &field, now_kept)
|
||||||
|
.on_add(move |text, _, cx| {
|
||||||
|
let mut next = added.read(cx).clone();
|
||||||
|
let key = SharedString::from(format!("kept-{}", next.len()));
|
||||||
|
next.insert(0, Memory { key, text: text.to_string().into(), source: None, at: Timestamp::now() });
|
||||||
|
set(&added, next, cx)
|
||||||
|
})
|
||||||
|
.on_forget(move |key, _, cx| {
|
||||||
|
let mut next = forgot.read(cx).clone();
|
||||||
|
next.retain(|memory| &memory.key != key);
|
||||||
|
set(&forgot, next, cx)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(probe(
|
||||||
|
"agent-checkpoints",
|
||||||
|
div().w(px(400.)).child(
|
||||||
|
CheckpointList::new("agent-points", now_points, now_at)
|
||||||
|
.on_rewind(move |ix, _, cx| set(&at, ix, cx)),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod changes;
|
mod changes;
|
||||||
|
mod environment;
|
||||||
mod previews;
|
mod previews;
|
||||||
mod progress;
|
mod progress;
|
||||||
mod tools;
|
mod tools;
|
||||||
@@ -54,5 +55,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
|||||||
.child(changes::changes_section(window, cx))
|
.child(changes::changes_section(window, cx))
|
||||||
.child(previews::watching(window, cx))
|
.child(previews::watching(window, cx))
|
||||||
.child(previews::artifacts(window, cx))
|
.child(previews::artifacts(window, cx))
|
||||||
|
.child(environment::sandbox_section(window, cx))
|
||||||
|
.child(environment::connectors_section(window, cx))
|
||||||
|
.child(environment::memory_section(window, cx))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
use std::{rc::Rc, time::Duration};
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, ElementId, FontWeight, Hsla, IntoElement, ParentElement, RenderOnce,
|
||||||
|
SharedString, Styled, Window, div, prelude::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
data_display::Meter,
|
||||||
|
forms::Switch,
|
||||||
|
lists::{List, ListItem},
|
||||||
|
motion::Pulse,
|
||||||
|
theme::{ActiveTheme, IconSize, Palette, Radius, TextSize},
|
||||||
|
typography::{
|
||||||
|
Ellipsis,
|
||||||
|
format::{DurationStyle, duration, file_size, plural},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A dot in `color`, ringing while `live`.
|
||||||
|
fn dot(id: ElementId, color: Hsla, live: bool, cx: &App) -> AnyElement {
|
||||||
|
let mark = div()
|
||||||
|
.flex_none()
|
||||||
|
.size(cx.theme().status_dot())
|
||||||
|
.rounded_full()
|
||||||
|
.bg(color);
|
||||||
|
if live {
|
||||||
|
Pulse::new(id).child(mark).into_any_element()
|
||||||
|
} else {
|
||||||
|
mark.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a sandbox stands.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum SandboxState {
|
||||||
|
Starting,
|
||||||
|
Ready,
|
||||||
|
Busy,
|
||||||
|
Stopped,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SandboxState {
|
||||||
|
fn word(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Starting => "Starting",
|
||||||
|
Self::Ready => "Ready",
|
||||||
|
Self::Busy => "Busy",
|
||||||
|
Self::Stopped => "Stopped",
|
||||||
|
Self::Failed => "Failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color(self, colors: &Palette) -> Hsla {
|
||||||
|
match self {
|
||||||
|
Self::Starting => colors.warning,
|
||||||
|
Self::Busy => colors.info,
|
||||||
|
Self::Ready => colors.success,
|
||||||
|
Self::Stopped => colors.fg_subtle,
|
||||||
|
Self::Failed => colors.danger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an agent's sandbox stands: its name, a dot and a word, what it uses of what it has, and how long it has run.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct SandboxStatus {
|
||||||
|
id: ElementId,
|
||||||
|
name: SharedString,
|
||||||
|
state: SandboxState,
|
||||||
|
cpu: Option<f32>,
|
||||||
|
memory: Option<(u64, u64)>,
|
||||||
|
uptime: Option<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SandboxStatus {
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
name: impl Into<SharedString>,
|
||||||
|
state: SandboxState,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
name: name.into(),
|
||||||
|
state,
|
||||||
|
cpu: None,
|
||||||
|
memory: None,
|
||||||
|
uptime: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The share of its processors in use, 0 to 1.
|
||||||
|
pub fn cpu(mut self, share: f32) -> Self {
|
||||||
|
self.cpu = Some(share);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Memory in use and its limit, in bytes.
|
||||||
|
pub fn memory(mut self, used: u64, limit: u64) -> Self {
|
||||||
|
assert!(limit > 0 && used <= limit, "memory {used} of {limit}");
|
||||||
|
self.memory = Some((used, limit));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uptime(mut self, uptime: Duration) -> Self {
|
||||||
|
self.uptime = Some(uptime);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for SandboxStatus {
|
||||||
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let colors = theme.colors.clone();
|
||||||
|
let live = matches!(self.state, SandboxState::Starting | SandboxState::Busy);
|
||||||
|
let color = self.state.color(&colors);
|
||||||
|
div()
|
||||||
|
.w_full()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_3()
|
||||||
|
.p_4()
|
||||||
|
.rounded(theme.radius(Radius::Lg))
|
||||||
|
.border_1()
|
||||||
|
.border_color(colors.border)
|
||||||
|
.bg(colors.surface)
|
||||||
|
.text_size(theme.text_size(TextSize::Sm))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_wrap()
|
||||||
|
.items_center()
|
||||||
|
.gap_x_2()
|
||||||
|
.gap_y_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w(theme.label_width())
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.child(dot((self.id.clone(), "state").into(), color, live, cx))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.font_weight(FontWeight::MEDIUM)
|
||||||
|
.child(Ellipsis::new(self.name)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_none()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.text_color(colors.fg_muted)
|
||||||
|
.child(self.state.word())
|
||||||
|
.children(self.uptime.map(|up| {
|
||||||
|
div().text_color(colors.fg_subtle).child(format!(
|
||||||
|
"up {}",
|
||||||
|
duration(up.as_secs(), DurationStyle::Compact)
|
||||||
|
))
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.children(self.cpu.map(|share| {
|
||||||
|
Meter::new(
|
||||||
|
(self.id.clone(), "cpu"),
|
||||||
|
"Processors",
|
||||||
|
share.clamp(0.0, 1.0),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
.children(self.memory.map(|(used, limit)| {
|
||||||
|
Meter::new(
|
||||||
|
(self.id.clone(), "memory"),
|
||||||
|
"Memory",
|
||||||
|
used as f32 / limit as f32,
|
||||||
|
)
|
||||||
|
.detail(format!(
|
||||||
|
"{} of {}",
|
||||||
|
file_size(used, true),
|
||||||
|
file_size(limit, true)
|
||||||
|
))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a connected server stands.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ServerState {
|
||||||
|
Connected,
|
||||||
|
Connecting,
|
||||||
|
Failed,
|
||||||
|
Off,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A server or connector the agent can reach: its key and name, where it runs or why it failed, where it stands and how many tools it brings.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct McpServer {
|
||||||
|
pub key: SharedString,
|
||||||
|
pub name: SharedString,
|
||||||
|
pub detail: SharedString,
|
||||||
|
pub state: ServerState,
|
||||||
|
pub tools: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnToggle = Rc<dyn Fn(&SharedString, bool, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// Servers and connectors the agent can reach: each with a dot for where it stands, what it runs or why it failed, how many tools it brings, and a switch.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct McpServerList {
|
||||||
|
id: ElementId,
|
||||||
|
servers: Vec<McpServer>,
|
||||||
|
on_toggle: Option<OnToggle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl McpServerList {
|
||||||
|
pub fn new(id: impl Into<ElementId>, servers: impl IntoIterator<Item = McpServer>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
servers: servers.into_iter().collect(),
|
||||||
|
on_toggle: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets a server's key and whether it is now on.
|
||||||
|
pub fn on_toggle(
|
||||||
|
mut self,
|
||||||
|
handler: impl Fn(&SharedString, bool, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.on_toggle = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for McpServerList {
|
||||||
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let colors = cx.theme().colors.clone();
|
||||||
|
List::new()
|
||||||
|
.divided()
|
||||||
|
.children(self.servers.into_iter().map(|server| {
|
||||||
|
let (color, live) = match server.state {
|
||||||
|
ServerState::Connected => (colors.success, false),
|
||||||
|
ServerState::Connecting => (colors.warning, true),
|
||||||
|
ServerState::Failed => (colors.danger, false),
|
||||||
|
ServerState::Off => (colors.fg_subtle, false),
|
||||||
|
};
|
||||||
|
let toggle = self.on_toggle.clone().map(|toggle| {
|
||||||
|
let key = server.key.clone();
|
||||||
|
Switch::new(
|
||||||
|
(self.id.clone(), format!("switch-{}", server.key)),
|
||||||
|
server.state != ServerState::Off,
|
||||||
|
)
|
||||||
|
.on_change(move |on, window, cx| {
|
||||||
|
log::info!("mcp server {key}: {}", if on { "on" } else { "off" });
|
||||||
|
toggle(&key, on, window, cx)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
ListItem::new(
|
||||||
|
(self.id.clone(), format!("server-{}", server.key)),
|
||||||
|
server.name,
|
||||||
|
)
|
||||||
|
.description(server.detail)
|
||||||
|
.leading(
|
||||||
|
div()
|
||||||
|
.size(cx.theme().icon_size(IconSize::Sm))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.child(dot(
|
||||||
|
(self.id.clone(), format!("dot-{}", server.key)).into(),
|
||||||
|
color,
|
||||||
|
live,
|
||||||
|
cx,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.trailing(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_3()
|
||||||
|
.text_size(cx.theme().text_size(TextSize::Xs))
|
||||||
|
.text_color(colors.fg_subtle)
|
||||||
|
.when(server.state == ServerState::Connected, |end| {
|
||||||
|
end.child(plural(server.tools as u64, "tool", "tools"))
|
||||||
|
})
|
||||||
|
.children(toggle),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||||
|
SharedString, Styled, Window, div, prelude::*,
|
||||||
|
};
|
||||||
|
use jiff::Timestamp;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
buttons::{Button, ButtonVariant, IconButton},
|
||||||
|
data_display::{Timeline, TimelineItem, Tone},
|
||||||
|
forms::{Enter, Input, Pick, TextInput},
|
||||||
|
primitives::IconName,
|
||||||
|
theme::{ActiveTheme, ControlSize, TextSize},
|
||||||
|
typography::{RelativeTime, format::plural},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Something an agent keeps between conversations: its key and words, where it came from, and when.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Memory {
|
||||||
|
pub key: SharedString,
|
||||||
|
pub text: SharedString,
|
||||||
|
pub source: Option<SharedString>,
|
||||||
|
pub at: Timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnAdd = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
||||||
|
type OnForget = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// What an agent keeps between conversations: a field to add a memory, then each one with where it came from, when, and a way to forget it.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct MemoryPanel {
|
||||||
|
id: ElementId,
|
||||||
|
field: Entity<TextInput>,
|
||||||
|
memories: Vec<Memory>,
|
||||||
|
on_add: Option<OnAdd>,
|
||||||
|
on_forget: Option<OnForget>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryPanel {
|
||||||
|
/// `field` is the owner's line for a new memory.
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
field: &Entity<TextInput>,
|
||||||
|
memories: impl IntoIterator<Item = Memory>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
field: field.clone(),
|
||||||
|
memories: memories.into_iter().collect(),
|
||||||
|
on_add: None,
|
||||||
|
on_forget: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the words to keep; the field empties after.
|
||||||
|
pub fn on_add(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
|
||||||
|
self.on_add = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the key of the memory to forget.
|
||||||
|
pub fn on_forget(
|
||||||
|
mut self,
|
||||||
|
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.on_forget = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hands the field's words to `add` and empties it; blank words are kept out.
|
||||||
|
fn keep(field: &Entity<TextInput>, add: &OnAdd, window: &mut Window, cx: &mut App) {
|
||||||
|
let text = field.read(cx).text().trim().to_string();
|
||||||
|
if text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::info!("memory: kept {} characters", text.len());
|
||||||
|
add(&text, window, cx);
|
||||||
|
field.update(cx, |input, cx| input.set_text("", cx));
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for MemoryPanel {
|
||||||
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let colors = theme.colors.clone();
|
||||||
|
let ready = !self.field.read(cx).text().trim().is_empty();
|
||||||
|
let count = self.memories.len();
|
||||||
|
let adding = self.on_add.map(|add| {
|
||||||
|
let (enter, press, typed, pressed) =
|
||||||
|
(add.clone(), add, self.field.clone(), self.field.clone());
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.capture_action(move |_: &Enter, window, cx| {
|
||||||
|
cx.stop_propagation();
|
||||||
|
keep(&typed, &enter, window, cx)
|
||||||
|
})
|
||||||
|
.child(Input::new(&self.field)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new((self.id.clone(), "add"), "Remember")
|
||||||
|
.variant(ButtonVariant::Secondary)
|
||||||
|
.disabled(!ready)
|
||||||
|
.on_click(move |_, window, cx| keep(&pressed, &press, window, cx)),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
div()
|
||||||
|
.w_full()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_3()
|
||||||
|
.text_size(theme.text_size(TextSize::Sm))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_between()
|
||||||
|
.child(div().font_weight(FontWeight::MEDIUM).child("Memory"))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(theme.text_size(TextSize::Xs))
|
||||||
|
.text_color(colors.fg_subtle)
|
||||||
|
.child(plural(count as u64, "memory", "memories")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.children(adding)
|
||||||
|
.when(count == 0, |panel| {
|
||||||
|
panel.child(div().text_color(colors.fg_subtle).child("Nothing kept yet"))
|
||||||
|
})
|
||||||
|
.children(self.memories.into_iter().map(|memory| {
|
||||||
|
let forget = self.on_forget.clone().map(|forget| {
|
||||||
|
let key = memory.key.clone();
|
||||||
|
IconButton::new(
|
||||||
|
(self.id.clone(), format!("forget-{}", memory.key)),
|
||||||
|
IconName::Trash2,
|
||||||
|
)
|
||||||
|
.variant(ButtonVariant::Ghost)
|
||||||
|
.size(ControlSize::Sm)
|
||||||
|
.tooltip("Forget")
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
log::info!("memory: forget {key}");
|
||||||
|
forget(&key, window, cx)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_start()
|
||||||
|
.gap_2()
|
||||||
|
.py_2()
|
||||||
|
.border_t_1()
|
||||||
|
.border_color(colors.border)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.child(div().text_color(colors.fg).child(memory.text))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.mt_0p5()
|
||||||
|
.flex()
|
||||||
|
.flex_wrap()
|
||||||
|
.gap_x_1()
|
||||||
|
.text_size(theme.text_size(TextSize::Xs))
|
||||||
|
.text_color(colors.fg_subtle)
|
||||||
|
.children(memory.source.map(|source| format!("{source} ·")))
|
||||||
|
.child(RelativeTime::new(
|
||||||
|
(self.id.clone(), format!("when-{}", memory.key)),
|
||||||
|
memory.at,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.children(forget.map(|button| div().flex_none().child(button)))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A point an agent can go back to: its key, what it had done, when, and how many files it had touched.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Checkpoint {
|
||||||
|
pub key: SharedString,
|
||||||
|
pub label: SharedString,
|
||||||
|
pub at: Timestamp,
|
||||||
|
pub files: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points an agent can go back to, newest first: each with what it had done, when and how many files it had touched; the one it stands on marked, Rewind on the rest.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct CheckpointList {
|
||||||
|
id: ElementId,
|
||||||
|
checkpoints: Vec<Checkpoint>,
|
||||||
|
current: usize,
|
||||||
|
on_rewind: Option<Pick>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CheckpointList {
|
||||||
|
/// `checkpoints` oldest first; `current` indexes the one the agent stands on.
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
checkpoints: impl IntoIterator<Item = Checkpoint>,
|
||||||
|
current: usize,
|
||||||
|
) -> Self {
|
||||||
|
let checkpoints: Vec<Checkpoint> = checkpoints.into_iter().collect();
|
||||||
|
assert!(
|
||||||
|
current < checkpoints.len(),
|
||||||
|
"checkpoint {current} of {}",
|
||||||
|
checkpoints.len()
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
checkpoints,
|
||||||
|
current,
|
||||||
|
on_rewind: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the index of the checkpoint to go back to.
|
||||||
|
pub fn on_rewind(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||||
|
self.on_rewind = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for CheckpointList {
|
||||||
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let colors = cx.theme().colors.clone();
|
||||||
|
let current = self.current;
|
||||||
|
let items: Vec<(usize, Checkpoint)> =
|
||||||
|
self.checkpoints.into_iter().enumerate().rev().collect();
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.fold(Timeline::new(), |timeline, (ix, point)| {
|
||||||
|
let rewind = self
|
||||||
|
.on_rewind
|
||||||
|
.clone()
|
||||||
|
.filter(|_| ix != current)
|
||||||
|
.map(|rewind| {
|
||||||
|
Button::new((self.id.clone(), format!("rewind-{ix}")), "Rewind to here")
|
||||||
|
.variant(ButtonVariant::Ghost)
|
||||||
|
.size(ControlSize::Sm)
|
||||||
|
.icon(IconName::RotateCcw)
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
log::info!("checkpoints: rewind to {ix}");
|
||||||
|
rewind(ix, window, cx)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let item = TimelineItem::new(point.label)
|
||||||
|
.time(RelativeTime::new(
|
||||||
|
(self.id.clone(), format!("when-{ix}")),
|
||||||
|
point.at,
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_wrap()
|
||||||
|
.items_center()
|
||||||
|
.gap_x_2()
|
||||||
|
.child(plural(point.files as u64, "file", "files"))
|
||||||
|
.when(ix == current, |meta| {
|
||||||
|
meta.child(div().text_color(colors.accent).child("Here now"))
|
||||||
|
})
|
||||||
|
.children(rewind),
|
||||||
|
);
|
||||||
|
timeline.item(if ix == current {
|
||||||
|
item.tone(Tone::Accent)
|
||||||
|
} else {
|
||||||
|
item
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
mod artifact;
|
mod artifact;
|
||||||
mod changes;
|
mod changes;
|
||||||
|
mod environment;
|
||||||
|
mod memory;
|
||||||
mod permission;
|
mod permission;
|
||||||
mod previews;
|
mod previews;
|
||||||
mod progress;
|
mod progress;
|
||||||
|
mod registry;
|
||||||
#[cfg(all(test, feature = "test-support"))]
|
#[cfg(all(test, feature = "test-support"))]
|
||||||
mod tests;
|
mod tests;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
|
||||||
pub use artifact::ArtifactPanel;
|
pub use artifact::ArtifactPanel;
|
||||||
pub use changes::{ChangeState, FileChange, FileChangeCard, MultiFileDiffReview};
|
pub use changes::{ChangeState, FileChange, FileChangeCard, MultiFileDiffReview};
|
||||||
|
pub use environment::{McpServer, McpServerList, SandboxState, SandboxStatus, ServerState};
|
||||||
|
pub use memory::{Checkpoint, CheckpointList, Memory, MemoryPanel};
|
||||||
pub use permission::{Permission, PermissionPrompt, ToolApprovalDialog};
|
pub use permission::{Permission, PermissionPrompt, ToolApprovalDialog};
|
||||||
pub use previews::{BrowserPreview, ComputerUseViewer, LivePreview};
|
pub use previews::{BrowserPreview, ComputerUseViewer, LivePreview};
|
||||||
pub use progress::{AgentPlan, AgentProgress, AgentState, AgentStatus, AgentStep, AgentStepList};
|
pub use progress::{AgentPlan, AgentProgress, AgentState, AgentStatus, AgentStep, AgentStepList};
|
||||||
|
pub use registry::{RegisteredTool, ToolAccess, ToolRegistry};
|
||||||
pub use tools::{ToolCallCard, ToolCallGroup};
|
pub use tools::{ToolCallCard, ToolCallGroup};
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||||
|
Window, div, prelude::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
buttons::SegmentedControl,
|
||||||
|
theme::{ActiveTheme, ControlSize, TextSize},
|
||||||
|
typography::Ellipsis,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// What an agent may do with a tool.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ToolAccess {
|
||||||
|
Allow,
|
||||||
|
Ask,
|
||||||
|
Deny,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolAccess {
|
||||||
|
const ALL: [(ToolAccess, &'static str, &'static str); 3] = [
|
||||||
|
(ToolAccess::Allow, "allow", "Allow"),
|
||||||
|
(ToolAccess::Ask, "ask", "Ask"),
|
||||||
|
(ToolAccess::Deny, "deny", "Deny"),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn key(self) -> &'static str {
|
||||||
|
Self::ALL
|
||||||
|
.iter()
|
||||||
|
.find(|(access, _, _)| *access == self)
|
||||||
|
.expect("every access has a key")
|
||||||
|
.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tool the agent can call: its name, where it comes from, what it does, and what the agent may do with it.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct RegisteredTool {
|
||||||
|
pub name: SharedString,
|
||||||
|
pub source: SharedString,
|
||||||
|
pub description: SharedString,
|
||||||
|
pub access: ToolAccess,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tools whose name or description holds every word of `query`, ignoring case, by index.
|
||||||
|
pub(crate) fn found(tools: &[RegisteredTool], query: &str) -> Vec<usize> {
|
||||||
|
let words: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
|
||||||
|
(0..tools.len())
|
||||||
|
.filter(|ix| {
|
||||||
|
let text = format!("{} {}", tools[*ix].name, tools[*ix].description).to_lowercase();
|
||||||
|
words.iter().all(|word| text.contains(word))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnAccess = Rc<dyn Fn(usize, ToolAccess, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// Every tool the agent may call, grouped by where it comes from and found by the owner's query: each allowed, asked for first, or denied.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct ToolRegistry {
|
||||||
|
id: ElementId,
|
||||||
|
tools: Vec<RegisteredTool>,
|
||||||
|
query: SharedString,
|
||||||
|
on_access: Option<OnAccess>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolRegistry {
|
||||||
|
pub fn new(id: impl Into<ElementId>, tools: impl IntoIterator<Item = RegisteredTool>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
tools: tools.into_iter().collect(),
|
||||||
|
query: SharedString::default(),
|
||||||
|
on_access: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the tools that hold every word, such as a `forms::SearchInput`'s.
|
||||||
|
pub fn query(mut self, query: impl Into<SharedString>) -> Self {
|
||||||
|
self.query = query.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets a tool's index and its new access.
|
||||||
|
pub fn on_access(
|
||||||
|
mut self,
|
||||||
|
handler: impl Fn(usize, ToolAccess, &mut Window, &mut App) + 'static,
|
||||||
|
) -> Self {
|
||||||
|
self.on_access = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for ToolRegistry {
|
||||||
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let colors = theme.colors.clone();
|
||||||
|
let kept = found(&self.tools, &self.query);
|
||||||
|
let mut groups: Vec<(SharedString, Vec<usize>)> = Vec::new();
|
||||||
|
for ix in kept {
|
||||||
|
let source = &self.tools[ix].source;
|
||||||
|
match groups.iter_mut().find(|(name, _)| name == source) {
|
||||||
|
Some((_, items)) => items.push(ix),
|
||||||
|
None => groups.push((source.clone(), vec![ix])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let empty = groups.is_empty();
|
||||||
|
div()
|
||||||
|
.w_full()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_4()
|
||||||
|
.text_size(theme.text_size(TextSize::Sm))
|
||||||
|
.when(empty, |list| {
|
||||||
|
list.child(div().text_color(colors.fg_subtle).child("No tools match"))
|
||||||
|
})
|
||||||
|
.children(groups.into_iter().map(|(source, items)| {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.pb_1()
|
||||||
|
.text_size(theme.text_size(TextSize::Xs))
|
||||||
|
.font_weight(FontWeight::MEDIUM)
|
||||||
|
.text_color(colors.fg_subtle)
|
||||||
|
.child(format!("{source} · {}", items.len())),
|
||||||
|
)
|
||||||
|
.children(items.into_iter().map(|ix| {
|
||||||
|
let tool = &self.tools[ix];
|
||||||
|
let access = self.on_access.clone();
|
||||||
|
let control = ToolAccess::ALL.iter().fold(
|
||||||
|
SegmentedControl::new(
|
||||||
|
(self.id.clone(), format!("access-{ix}")),
|
||||||
|
tool.access.key(),
|
||||||
|
)
|
||||||
|
.size(ControlSize::Sm),
|
||||||
|
|control, (_, key, label)| control.segment(*key, *label, None),
|
||||||
|
);
|
||||||
|
let control = match access {
|
||||||
|
Some(access) => control.on_change(move |key, window, cx| {
|
||||||
|
let next = ToolAccess::ALL
|
||||||
|
.iter()
|
||||||
|
.find(|(_, name, _)| *name == key.as_ref())
|
||||||
|
.expect("a segment names an access")
|
||||||
|
.0;
|
||||||
|
log::info!("tool registry: tool {ix} {next:?}");
|
||||||
|
access(ix, next, window, cx)
|
||||||
|
}),
|
||||||
|
None => control,
|
||||||
|
};
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_wrap()
|
||||||
|
.items_center()
|
||||||
|
.gap_x_3()
|
||||||
|
.gap_y_1()
|
||||||
|
.py_2()
|
||||||
|
.border_t_1()
|
||||||
|
.border_color(colors.border)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w(theme.label_width())
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.font_family(theme.mono_family.clone())
|
||||||
|
.child(Ellipsis::new(tool.name.clone())),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(theme.text_size(TextSize::Xs))
|
||||||
|
.text_color(colors.fg_muted)
|
||||||
|
.child(Ellipsis::new(tool.description.clone())),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(div().flex_none().child(control))
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{RegisteredTool, ToolAccess, found};
|
||||||
|
|
||||||
|
fn tool(name: &str, description: &str) -> RegisteredTool {
|
||||||
|
RegisteredTool {
|
||||||
|
name: name.to_string().into(),
|
||||||
|
source: "files".into(),
|
||||||
|
description: description.to_string().into(),
|
||||||
|
access: ToolAccess::Ask,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_query_keeps_tools_that_hold_every_word() {
|
||||||
|
let tools = [
|
||||||
|
tool("read_file", "Reads a file"),
|
||||||
|
tool("write_file", "Writes a file"),
|
||||||
|
tool("web_search", "Searches the web"),
|
||||||
|
];
|
||||||
|
assert_eq!(found(&tools, ""), [0, 1, 2]);
|
||||||
|
assert_eq!(found(&tools, "FILE"), [0, 1]);
|
||||||
|
assert_eq!(found(&tools, "writes file"), [1]);
|
||||||
|
assert_eq!(found(&tools, "web file"), Vec::<usize>::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
use gpui::{
|
||||||
|
AppContext as _, Context, Entity, IntoElement, KeyBinding, ParentElement, Render, SharedString,
|
||||||
|
Styled, TestAppContext, VisualTestContext, Window, div, px,
|
||||||
|
};
|
||||||
|
use jiff::Timestamp;
|
||||||
|
|
||||||
|
use super::{press, settle};
|
||||||
|
use crate::{
|
||||||
|
agent::{
|
||||||
|
Checkpoint, CheckpointList, McpServer, McpServerList, Memory, MemoryPanel, SandboxState,
|
||||||
|
SandboxStatus, ServerState,
|
||||||
|
},
|
||||||
|
forms::{self, TextInput},
|
||||||
|
primitives::FocusNext,
|
||||||
|
theme::Theme,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn setup(cx: &mut TestAppContext) {
|
||||||
|
cx.update(|cx| {
|
||||||
|
Theme::init(cx);
|
||||||
|
forms::bind_keys(cx);
|
||||||
|
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A memory panel over a field, and the words it kept.
|
||||||
|
struct Memories {
|
||||||
|
field: Entity<TextInput>,
|
||||||
|
kept: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Memories {
|
||||||
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let view = cx.entity();
|
||||||
|
let memory = Memory {
|
||||||
|
key: "tone".into(),
|
||||||
|
text: "Prefers plain words".into(),
|
||||||
|
source: None,
|
||||||
|
at: Timestamp::UNIX_EPOCH,
|
||||||
|
};
|
||||||
|
div().w(px(360.0)).child(
|
||||||
|
MemoryPanel::new("memory", &self.field, [memory])
|
||||||
|
.on_add(move |text, _, cx| {
|
||||||
|
view.update(cx, |memories, _| memories.kept.push(text.to_string()))
|
||||||
|
})
|
||||||
|
.on_forget(|_, _, _| {}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn memories(cx: &mut TestAppContext) -> (Entity<Memories>, &mut VisualTestContext) {
|
||||||
|
setup(cx);
|
||||||
|
let (view, cx) = cx.add_window_view(|window, cx| Memories {
|
||||||
|
field: cx.new(|cx| TextInput::new(window, cx)),
|
||||||
|
kept: Vec::new(),
|
||||||
|
});
|
||||||
|
cx.update(|window, _| window.activate_window());
|
||||||
|
settle(cx);
|
||||||
|
(view, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn enter_keeps_a_memory_and_empties_the_field(cx: &mut TestAppContext) {
|
||||||
|
let (view, cx) = memories(cx);
|
||||||
|
cx.update(|window, _| window.focus_next());
|
||||||
|
cx.simulate_input(" ");
|
||||||
|
press("enter", cx);
|
||||||
|
cx.simulate_input("Lifts stay under 20%");
|
||||||
|
press("enter", cx);
|
||||||
|
view.read_with(cx, |memories, cx| {
|
||||||
|
assert_eq!(
|
||||||
|
memories.kept,
|
||||||
|
["Lifts stay under 20%"],
|
||||||
|
"blank words are kept out"
|
||||||
|
);
|
||||||
|
assert_eq!(memories.field.read(cx).text(), "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two servers, and the switches thrown.
|
||||||
|
struct Servers {
|
||||||
|
thrown: Vec<(SharedString, bool)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Servers {
|
||||||
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let view = cx.entity();
|
||||||
|
let server = |key: &str, state| McpServer {
|
||||||
|
key: key.to_string().into(),
|
||||||
|
name: key.to_string().into(),
|
||||||
|
detail: "npx server".into(),
|
||||||
|
state,
|
||||||
|
tools: 3,
|
||||||
|
};
|
||||||
|
div().w(px(360.0)).child(
|
||||||
|
McpServerList::new(
|
||||||
|
"servers",
|
||||||
|
[
|
||||||
|
server("files", ServerState::Connected),
|
||||||
|
server("web", ServerState::Off),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.on_toggle(move |key, on, _, cx| {
|
||||||
|
view.update(cx, |servers, _| servers.thrown.push((key.clone(), on)))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_servers_switch_turns_it_off_and_on(cx: &mut TestAppContext) {
|
||||||
|
setup(cx);
|
||||||
|
let (view, cx) = cx.add_window_view(|_, _| Servers { thrown: Vec::new() });
|
||||||
|
cx.update(|window, _| window.activate_window());
|
||||||
|
settle(cx);
|
||||||
|
cx.update(|window, _| window.focus_next());
|
||||||
|
press("space", cx);
|
||||||
|
cx.update(|window, _| window.focus_next());
|
||||||
|
press("space", cx);
|
||||||
|
let thrown = view.read_with(cx, |servers, _| servers.thrown.clone());
|
||||||
|
assert_eq!(thrown, [("files".into(), false), ("web".into(), true)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three checkpoints standing on the middle one, and the rewinds asked for.
|
||||||
|
struct Points {
|
||||||
|
asked: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Points {
|
||||||
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let view = cx.entity();
|
||||||
|
let point = |key: &str| Checkpoint {
|
||||||
|
key: key.to_string().into(),
|
||||||
|
label: key.to_string().into(),
|
||||||
|
at: Timestamp::UNIX_EPOCH,
|
||||||
|
files: 2,
|
||||||
|
};
|
||||||
|
div().w(px(360.0)).child(
|
||||||
|
CheckpointList::new(
|
||||||
|
"points",
|
||||||
|
[point("first"), point("second"), point("third")],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.on_rewind(move |ix, _, cx| view.update(cx, |points, _| points.asked.push(ix))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn every_checkpoint_but_the_current_rewinds(cx: &mut TestAppContext) {
|
||||||
|
setup(cx);
|
||||||
|
let (view, cx) = cx.add_window_view(|_, _| Points { asked: Vec::new() });
|
||||||
|
cx.update(|window, _| window.activate_window());
|
||||||
|
settle(cx);
|
||||||
|
cx.update(|window, _| window.focus_next());
|
||||||
|
press("enter", cx);
|
||||||
|
cx.update(|window, _| window.focus_next());
|
||||||
|
press("enter", cx);
|
||||||
|
assert_eq!(
|
||||||
|
view.read_with(cx, |points, _| points.asked.clone()),
|
||||||
|
[2, 0],
|
||||||
|
"newest first, the current skipped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "memory 5 of 4")]
|
||||||
|
fn memory_past_its_limit_fails_loud() {
|
||||||
|
let _ = SandboxStatus::new("box", "python", SandboxState::Ready).memory(5, 4);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
mod changes;
|
mod changes;
|
||||||
|
mod environment;
|
||||||
mod previews;
|
mod previews;
|
||||||
|
|
||||||
use super::{AgentProgress, Permission, PermissionPrompt, ToolApprovalDialog};
|
use super::{AgentProgress, Permission, PermissionPrompt, ToolApprovalDialog};
|
||||||
|
|||||||
+5
-5
@@ -193,11 +193,11 @@
|
|||||||
|
|
||||||
## T24e Agent · Environment
|
## T24e Agent · Environment
|
||||||
|
|
||||||
- [ ] SandboxStatus
|
- [x] SandboxStatus
|
||||||
- [ ] MCPServerList / ConnectorList
|
- [x] MCPServerList / ConnectorList → `agent::McpServerList`
|
||||||
- [ ] ToolRegistry Panel
|
- [x] ToolRegistry Panel → `agent::ToolRegistry`
|
||||||
- [ ] MemoryPanel
|
- [x] MemoryPanel
|
||||||
- [ ] CheckpointList / Rewind
|
- [x] CheckpointList / Rewind → `agent::CheckpointList`, Rewind on each point but the current
|
||||||
|
|
||||||
## T24f Agent · Control and cost
|
## T24f Agent · Control and cost
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user