From cbdcf85cec45ae04a560998c4c75167467ed9266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 11:00:15 -0400 Subject: [PATCH] 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 --- examples/gallery/pages/agent/environment.rs | 281 +++++++++++++++++++ examples/gallery/pages/agent/mod.rs | 4 + src/agent/environment.rs | 293 ++++++++++++++++++++ src/agent/memory.rs | 276 ++++++++++++++++++ src/agent/mod.rs | 6 + src/agent/registry.rs | 209 ++++++++++++++ src/agent/tests/environment.rs | 170 ++++++++++++ src/agent/tests/mod.rs | 1 + tasks/ch21-30.md | 10 +- 9 files changed, 1245 insertions(+), 5 deletions(-) create mode 100644 examples/gallery/pages/agent/environment.rs create mode 100644 src/agent/environment.rs create mode 100644 src/agent/memory.rs create mode 100644 src/agent/registry.rs create mode 100644 src/agent/tests/environment.rs diff --git a/examples/gallery/pages/agent/environment.rs b/examples/gallery/pages/agent/environment.rs new file mode 100644 index 0000000..550c0ca --- /dev/null +++ b/examples/gallery/pages/agent/environment.rs @@ -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 { + [ + ( + "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 { + [ + ( + "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::>() + }, + 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)), + ), + )), + ) +} diff --git a/examples/gallery/pages/agent/mod.rs b/examples/gallery/pages/agent/mod.rs index 0058607..02c3091 100644 --- a/examples/gallery/pages/agent/mod.rs +++ b/examples/gallery/pages/agent/mod.rs @@ -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() } diff --git a/src/agent/environment.rs b/src/agent/environment.rs new file mode 100644 index 0000000..bb4d1e5 --- /dev/null +++ b/src/agent/environment.rs @@ -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, + memory: Option<(u64, u64)>, + uptime: Option, +} + +impl SandboxStatus { + pub fn new( + id: impl Into, + name: impl Into, + 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; + +/// 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, + on_toggle: Option, +} + +impl McpServerList { + pub fn new(id: impl Into, servers: impl IntoIterator) -> 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), + ) + })) + } +} diff --git a/src/agent/memory.rs b/src/agent/memory.rs new file mode 100644 index 0000000..1c42672 --- /dev/null +++ b/src/agent/memory.rs @@ -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, + pub at: Timestamp, +} + +type OnAdd = Rc; +type OnForget = Rc; + +/// 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, + memories: Vec, + on_add: Option, + on_forget: Option, +} + +impl MemoryPanel { + /// `field` is the owner's line for a new memory. + pub fn new( + id: impl Into, + field: &Entity, + memories: impl IntoIterator, + ) -> 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, 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, + current: usize, + on_rewind: Option, +} + +impl CheckpointList { + /// `checkpoints` oldest first; `current` indexes the one the agent stands on. + pub fn new( + id: impl Into, + checkpoints: impl IntoIterator, + current: usize, + ) -> Self { + let checkpoints: Vec = 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 + }) + }) + } +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 841d9dc..5de33b1 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -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}; diff --git a/src/agent/registry.rs b/src/agent/registry.rs new file mode 100644 index 0000000..7e3a439 --- /dev/null +++ b/src/agent/registry.rs @@ -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 { + let words: Vec = 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; + +/// 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, + query: SharedString, + on_access: Option, +} + +impl ToolRegistry { + pub fn new(id: impl Into, tools: impl IntoIterator) -> 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) -> 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)> = 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::::new()); + } +} diff --git a/src/agent/tests/environment.rs b/src/agent/tests/environment.rs new file mode 100644 index 0000000..237c08b --- /dev/null +++ b/src/agent/tests/environment.rs @@ -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, + kept: Vec, +} + +impl Render for Memories { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, &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) -> 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, +} + +impl Render for Points { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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); +} diff --git a/src/agent/tests/mod.rs b/src/agent/tests/mod.rs index 5060db3..7883420 100644 --- a/src/agent/tests/mod.rs +++ b/src/agent/tests/mod.rs @@ -4,6 +4,7 @@ use gpui::{ }; mod changes; +mod environment; mod previews; use super::{AgentProgress, Permission, PermissionPrompt, ToolApprovalDialog}; diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 6c99df8..3de8e08 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -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