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:
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 environment;
|
||||
mod previews;
|
||||
mod progress;
|
||||
mod tools;
|
||||
@@ -54,5 +55,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
.child(changes::changes_section(window, cx))
|
||||
.child(previews::watching(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()
|
||||
}
|
||||
@@ -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 changes;
|
||||
mod environment;
|
||||
mod memory;
|
||||
mod permission;
|
||||
mod previews;
|
||||
mod progress;
|
||||
mod registry;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
mod tools;
|
||||
|
||||
pub use artifact::ArtifactPanel;
|
||||
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 previews::{BrowserPreview, ComputerUseViewer, LivePreview};
|
||||
pub use progress::{AgentPlan, AgentProgress, AgentState, AgentStatus, AgentStep, AgentStepList};
|
||||
pub use registry::{RegisteredTool, ToolAccess, ToolRegistry};
|
||||
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 environment;
|
||||
mod previews;
|
||||
|
||||
use super::{AgentProgress, Permission, PermissionPrompt, ToolApprovalDialog};
|
||||
|
||||
+5
-5
@@ -193,11 +193,11 @@
|
||||
|
||||
## T24e Agent · Environment
|
||||
|
||||
- [ ] SandboxStatus
|
||||
- [ ] MCPServerList / ConnectorList
|
||||
- [ ] ToolRegistry Panel
|
||||
- [ ] MemoryPanel
|
||||
- [ ] CheckpointList / Rewind
|
||||
- [x] SandboxStatus
|
||||
- [x] MCPServerList / ConnectorList → `agent::McpServerList`
|
||||
- [x] ToolRegistry Panel → `agent::ToolRegistry`
|
||||
- [x] MemoryPanel
|
||||
- [x] CheckpointList / Rewind → `agent::CheckpointList`, Rewind on each point but the current
|
||||
|
||||
## T24f Agent · Control and cost
|
||||
|
||||
|
||||
Reference in new issue
Block a user